[
  {
    "path": ".dockerignore",
    "content": "# explicitly list the files needed by docker.\n*\n!package.json\n!yarn.lock\n!tsconfig-ext.json\n!tsconfig-prod.json\n!tsconfig.json\n!stubs\n!app\n!buildtools\n!static\n!bower_components\n!sandbox\n!plugins\n!test\n!ext\n**/_build\n"
  },
  {
    "path": ".editorconfig",
    "content": "# EditorConfig is awesome: https://EditorConfig.org\n\n# top-most EditorConfig file\nroot = true\n\n# Unix-style newlines with a newline ending every file\n[*.{ts,js,py}]\nend_of_line = lf\ninsert_final_newline = true\ncharset = utf-8\n# indent with 2 spaces\nindent_style = space\nindent_size = 2\ntrim_trailing_whitespace = true\n"
  },
  {
    "path": ".git-blame-ignore-revs",
    "content": "a7eb4d6e60c50375a835a5300b8e6beebcfb8422\n3a859ee5454d9c2b9600c9d9a3bd859d65d496bf\n9c9c45a6894e80748f0e337d4f164d40b503cae5\na3082e1f8b59b36154e721eb3d17c6504cf63bb8\n29541707086d4de9fbe8773f42e0f5f0f845ea54\n4faa64a1c0c194b32a804b2e562361ba197d8699\ne44e6217bd223873aef2f1aa2df32dc9c17a3abf\ne75c8ce28d9470ba5e92ee25aec65869a7051668\nb7e9ebed9dddd931a9bf33ae57dccb6423a7b6ae\na893706c5b9659157878318513f6d6cbe025e51d\n2f2cd7d60144486e67fc2fe0eaf20fcafcd50f21\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: gristlabs\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/00-bug-issue.yml",
    "content": "# Inspired by PeerTube templates:\n# https://github.com/Chocobozzz/PeerTube/blob/3d4d49a23eae71f3ce62cbbd7d93f07336a106b7/.github/ISSUE_TEMPLATE/00-bug-issue.yml\nname: 🐛 Bug Report\ndescription: Use this template for reporting a bug\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking time to fill out this bug report!\n        Please search among past open/closed issues for a similar one beforehand:\n          - https://github.com/gristlabs/grist-core/issues?q=\n          - https://community.getgrist.com/\n\n  - type: textarea\n    attributes:\n      label: Describe the current behavior\n\n  - type: textarea\n    attributes:\n      label: Steps to reproduce\n      value: |\n          1.\n          2.\n          3.\n\n  - type: textarea\n    attributes:\n      label: Describe the expected behavior\n\n  - type: checkboxes\n    attributes:\n      label: Where have you encountered this bug?\n      options:\n        - label: On [docs.getgrist.com](https://docs.getgrist.com)\n        - label: On a self-hosted instance \n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: Instance information (when self-hosting only)\n      description: In case you self-host, please share information above. You can discard any question you don't know the answer.\n      value: |\n          * Grist instance:\n            * Version:\n            * URL (if it's OK for you to share it):\n            * Installation mode: docker/kubernetes/...\n            * Architecture: single-worker/multi-workers\n\n          * Browser name, version and platforms on which you could reproduce the bug:\n          * Link to browser console log if relevant:\n          * Link to server log if relevant:\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/10-installation-issue.yml",
    "content": "# Inspired by PeerTube templates:\n# https://github.com/Chocobozzz/PeerTube/blob/master/.github/ISSUE_TEMPLATE/10-installation-issue.yml\nname: 🛠️ Installation/Upgrade Issue\ndescription: Use this template for installation/upgrade issues\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Please check first the official documentation for self-hosting: https://support.getgrist.com/self-managed/\n\n  - type: markdown\n    attributes:\n      value: |\n        Please search among past open/closed issues for a similar one beforehand:\n          - https://github.com/gristlabs/grist-core/issues?q=\n          - https://community.getgrist.com/\n\n  - type: textarea\n    attributes:\n      label: Describe the problem\n\n  - type: textarea\n    attributes:\n      label: Additional information\n      value: |\n          * Grist version:\n          * Grist instance URL:\n          * SSO solution used and its version (if relevant):\n          * S3 storage solution and its version (if relevant):\n          * Docker version (if relevant):\n          * NodeJS version (if relevant):\n          * Redis version (if relevant):\n          * PostgreSQL version (if relevant):\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/20-feature-request.yml",
    "content": "# Inspired by PeerTube templates:\n# https://github.com/Chocobozzz/PeerTube/blob/master/.github/ISSUE_TEMPLATE/30-feature-request.yml\n---\nname: ✨ Feature Request\ndescription: Use this template to ask for new features and suggest new ideas 💡\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking time to share your ideas!\n        Please search among past open/closed issues for a similar one beforehand:\n          - https://github.com/gristlabs/grist-core/issues?q=\n          - https://community.getgrist.com/\n\n  - type: textarea\n    attributes:\n      label: Describe the problem to be solved\n      description: Provide a clear and concise description of what the problem is\n\n  - type: textarea\n    attributes:\n      label: Describe the solution you would like\n      description: Provide a clear and concise description of what you want to happen\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 🤷💻🤦 Question/Forum\n    url: https://community.getgrist.com/\n    about: You can ask and answer other questions here\n  - name: 💬 Discord\n    url: https://discord.com/invite/MYKpYQ3fbP\n    about: Chat with us via Discord for quick Q/A here and sharing tips\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "## Context\n\n<!-- Please include a summary of the change, with motivation and context -->\n<!-- Bonus: if you are comfortable writing one, please insert a user-story https://en.wikipedia.org/wiki/User_story#Common_templates -->\n\n## Proposed solution\n\n<!-- Describe here how you address the issue -->\n\n## Related issues\n\n<!-- If suggesting a new feature or change, please discuss it in an issue first -->\n<!-- If fixing a bug, there should be an issue describing it with steps to reproduce -->\n<!-- If this does not solve entirely the issue, make also a checklist of what is done or not: -->\n\n## Has this been tested?\n\n<!-- Put an `x` in the box that applies: -->\n\n- [ ] 👍 yes, I added tests to the test suite\n- [ ] 💭 no, because this PR is a draft and still needs work\n- [ ] 🙅 no, because this is not relevant here\n- [ ] 🙋 no, because I need help <!-- Detail how we can help you -->\n\n## Screenshots / Screencasts\n\n<!-- delete if not relevant -->\n"
  },
  {
    "path": ".github/cla/individual-cla.md",
    "content": "# Individual Contributor License Agreement (\"Agreement\"), v1.0\n\n(Based on https://www.apache.org/licenses/icla.pdf by the Apache\nFoundation. Contact support@getgrist.com if you wish to execute a\nCorporate CLA.)\n\nThank you for your interest in contributing to software projects made\navailable by Grist Labs Inc (\"Grist\"). To clarify the intellectual\nproperty license granted with Contributions from any person or entity,\nGrist must have on file a signed Contributor License Agreement (\"CLA\")\nfrom each Contributor, indicating agreement with the license terms\nbelow. This agreement is for your protection as a Contributor as well\nas the protection of Grist and its users. It does not change your\nrights to use your own Contributions for any other purpose.\n\nContributions entirely composed of commits with authorship at\n`*.gouv.fr` domains fall outside the scope of this agreement.\n\nYou accept and agree to the following terms and conditions for Your\nContributions (present and future) that you submit to Grist. Except\nfor the license granted herein to Grist and recipients of software\ndistributed by Grist, You reserve all right, title, and interest in\nand to Your Contributions.\n\n1. Definitions.\n\n   \"You\" (or \"Your\") shall mean the copyright owner or legal entity\n   authorized by the copyright owner that is making this Agreement\n   with Grist. For legal entities, the entity making a Contribution\n   and all other entities that control, are controlled by, or are\n   under common control with that entity are considered to be a single\n   Contributor. For the purposes of this definition, \"control\" means\n   (i) the power, direct or indirect, to cause the direction or\n   management of such entity, whether by contract or otherwise, or\n   (ii) ownership of fifty percent (50%) or more of the outstanding\n   shares, or (iii) beneficial ownership of such entity.\n\n   \"Contribution\" shall mean any original work of authorship,\n   including any modifications or additions to an existing work, that\n   is intentionally submitted by You to Grist for inclusion in, or\n   documentation of, any of the products owned or managed by Grist\n   (the \"Work\"). For the purposes of this definition, \"submitted\"\n   means any form of electronic, verbal, or written communication sent\n   to Grist or its representatives, including but not limited to\n   communication on electronic mailing lists, source code control\n   systems, and issue tracking systems that are managed by, or on\n   behalf of, Grist for the purpose of discussing and improving the\n   Work, but excluding communication that is conspicuously marked or\n   otherwise designated in writing by You as \"Not a Contribution.\"\n\n2. Grant of Copyright License.\n\n   Subject to the terms and conditions of this Agreement, You hereby\n   grant to Grist and to recipients of software distributed by Grist a\n   perpetual, worldwide, non-exclusive, no-charge, royalty-free,\n   irrevocable copyright license to reproduce, prepare derivative\n   works of, publicly display, publicly perform, sublicense, and\n   distribute Your Contributions and such derivative works.\n\n3. Grant of Patent License.\n\n   Subject to the terms and conditions of this Agreement, You hereby\n   grant to Grist and to recipients of software distributed by Grist a\n   perpetual, worldwide, non-exclusive, no-charge, royalty-free,\n   irrevocable (except as stated in this section) patent license to\n   make, have made, use, offer to sell, sell, import, and otherwise\n   transfer the Work, where such license applies only to those patent\n   claims licensable by You that are necessarily infringed by Your\n   Contribution(s) alone or by combination of Your Contribution(s)\n   with the Work to which such Contribution(s) was submitted. If any\n   entity institutes patent litigation against You or any other entity\n   (including a cross-claim or counterclaim in a lawsuit) alleging\n   that your Contribution, or the Work to which you have contributed,\n   constitutes direct or contributory patent infringement, then any\n   patent licenses granted to that entity under this Agreement for\n   that Contribution or Work shall terminate as of the date such\n   litigation is filed.\n\n4. You represent that you are legally entitled to grant the above\n   license. If your employer(s) has rights to intellectual property\n   that you create that includes your Contributions, you represent\n   that you have received permission to make Contributions on behalf\n   of that employer, that your employer has waived such rights for\n   your Contributions to Grist, or that your employer has executed a\n   separate Corporate CLA with Grist.\n\n5. You represent that each of Your Contributions is Your original\n   creation (see section 7 for submissions on behalf of others). You\n   represent that Your Contribution submissions include complete\n   details of any third-party license or other restriction (including,\n   but not limited to, related patents and trademarks) of which you\n   are personally aware and which are associated with any part of Your\n   Contributions.\n\n6. You are not expected to provide support for Your Contributions,\n   except to the extent You desire to provide support. You may provide\n   support for free, for a fee, or not at all. Unless required by\n   applicable law or agreed to in writing, You provide Your\n   Contributions on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS\n   OF ANY KIND, either express or implied, including, without\n   limitation, any warranties or conditions of TITLE,\n   NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR\n   PURPOSE.\n\n7. Should You wish to submit work that is not Your original creation,\n   You may submit it to Grist separately from any Contribution,\n   identifying the complete details of its source and of any license\n   or other restriction (including, but not limited to, related\n   patents, trademarks, and license agreements) of which you are\n   personally aware, and conspicuously marking the work as \"Submitted\n   on behalf of a third-party: [named here]\".\n\n8. You agree to notify Grist of any facts or circumstances of which\n   you become aware that would make these representations inaccurate\n   in any respect.\n"
  },
  {
    "path": ".github/cla/signatures.json",
    "content": "{\n  \"signedContributors\": [\n    {\n      \"name\": \"jordigh\",\n      \"id\": 260143,\n      \"comment_id\": 3053400623,\n      \"created_at\": \"2025-07-09T17:12:17Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1682\n    },\n    {\n      \"name\": \"georgegevoian\",\n      \"id\": 85144792,\n      \"comment_id\": 3070684406,\n      \"created_at\": \"2025-07-14T19:16:24Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1705\n    },\n    {\n      \"name\": \"scytacki\",\n      \"id\": 86016,\n      \"comment_id\": 3071744517,\n      \"created_at\": \"2025-07-15T03:10:33Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1661\n    },\n    {\n      \"name\": \"fflorent\",\n      \"id\": 371705,\n      \"comment_id\": 3079056343,\n      \"created_at\": \"2025-07-16T15:06:22Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1683\n    },\n    {\n      \"name\": \"manuhabitela\",\n      \"id\": 221253,\n      \"comment_id\": 3083417362,\n      \"created_at\": \"2025-07-17T09:55:14Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1431\n    },\n    {\n      \"name\": \"hexaltation\",\n      \"id\": 31125573,\n      \"comment_id\": 3083469227,\n      \"created_at\": \"2025-07-17T10:11:19Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1699\n    },\n    {\n      \"name\": \"paulfitz\",\n      \"id\": 118367,\n      \"comment_id\": 3084758085,\n      \"created_at\": \"2025-07-17T16:53:25Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1711\n    },\n    {\n      \"name\": \"Spoffy\",\n      \"id\": 4805393,\n      \"comment_id\": 3090253280,\n      \"created_at\": \"2025-07-18T18:01:45Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1670\n    },\n    {\n      \"name\": \"Anany-k\",\n      \"id\": 13112955,\n      \"comment_id\": 3095980233,\n      \"created_at\": \"2025-07-21T09:55:52Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1716\n    },\n    {\n      \"name\": \"vviers\",\n      \"id\": 30295971,\n      \"comment_id\": 3097269575,\n      \"created_at\": \"2025-07-21T15:34:37Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1719\n    },\n    {\n      \"name\": \"guillett\",\n      \"id\": 1410356,\n      \"comment_id\": 3113331623,\n      \"created_at\": \"2025-07-24T12:43:18Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1691\n    },\n    {\n      \"name\": \"ogui11aume\",\n      \"id\": 31072389,\n      \"comment_id\": 3118009823,\n      \"created_at\": \"2025-07-25T14:21:07Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1653\n    },\n    {\n      \"name\": \"mrdev023\",\n      \"id\": 11292703,\n      \"comment_id\": 3155373604,\n      \"created_at\": \"2025-08-05T14:02:58Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1750\n    },\n    {\n      \"name\": \"jonathanperret\",\n      \"id\": 300823,\n      \"comment_id\": 3233984142,\n      \"created_at\": \"2025-08-28T15:30:02Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1778\n    },\n    {\n      \"name\": \"berhalak\",\n      \"id\": 11277225,\n      \"comment_id\": 3249600375,\n      \"created_at\": \"2025-09-03T14:53:49Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1807\n    },\n    {\n      \"name\": \"Ajay-Satish-01\",\n      \"id\": 71289526,\n      \"comment_id\": 3260958924,\n      \"created_at\": \"2025-09-06T05:28:40Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1818\n    },\n    {\n      \"name\": \"dsagal\",\n      \"id\": 1091143,\n      \"comment_id\": 3293302321,\n      \"created_at\": \"2025-09-15T17:52:16Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1841\n    },\n    {\n      \"name\": \"tristanrobert\",\n      \"id\": 19711088,\n      \"comment_id\": 3297472676,\n      \"created_at\": \"2025-09-16T10:20:26Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1840\n    },\n    {\n      \"name\": \"ohemelaar\",\n      \"id\": 19656762,\n      \"comment_id\": 3326880813,\n      \"created_at\": \"2025-09-24T07:05:33Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1830\n    },\n    {\n      \"name\": \"nbush\",\n      \"id\": 3422005,\n      \"comment_id\": 3467956044,\n      \"created_at\": \"2025-10-30T13:18:24Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1906\n    },\n    {\n      \"name\": \"SimLV\",\n      \"id\": 68837817,\n      \"comment_id\": 3485625062,\n      \"created_at\": \"2025-11-04T11:57:57Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1921\n    },\n    {\n      \"name\": \"SleepyLeslie\",\n      \"id\": 142967379,\n      \"comment_id\": 3487547371,\n      \"created_at\": \"2025-11-04T18:46:35Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1922\n    },\n    {\n      \"name\": \"samchencode\",\n      \"id\": 62081196,\n      \"comment_id\": 3522061093,\n      \"created_at\": \"2025-11-12T13:53:43Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1935\n    },\n    {\n      \"name\": \"RapidShade\",\n      \"id\": 20725534,\n      \"comment_id\": 3533412593,\n      \"created_at\": \"2025-11-14T15:54:39Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 1945\n    },\n    {\n      \"name\": \"cfpwastaken\",\n      \"id\": 44261356,\n      \"comment_id\": 3694780658,\n      \"created_at\": \"2025-12-28T14:18:03Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 2023\n    },\n    {\n      \"name\": \"unknownconstant\",\n      \"id\": 14999931,\n      \"comment_id\": 3791801541,\n      \"created_at\": \"2026-01-23T18:54:49Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 2067\n    },\n    {\n      \"name\": \"mikhailbogdan-droid\",\n      \"id\": 238033187,\n      \"comment_id\": 3823827850,\n      \"created_at\": \"2026-01-30T13:42:28Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 2085\n    },\n    {\n      \"name\": \"Vortezz\",\n      \"id\": 61989315,\n      \"comment_id\": 3861607282,\n      \"created_at\": \"2026-02-06T17:08:18Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 2096\n    },\n    {\n      \"name\": \"kosssi\",\n      \"id\": 1135513,\n      \"comment_id\": 4004185474,\n      \"created_at\": \"2026-03-05T10:52:21Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 2151\n    },\n    {\n      \"name\": \"webash\",\n      \"id\": 6205899,\n      \"comment_id\": 4026041376,\n      \"created_at\": \"2026-03-09T18:57:39Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 2161\n    },\n    {\n      \"name\": \"Gwabix\",\n      \"id\": 50367170,\n      \"comment_id\": 4060017206,\n      \"created_at\": \"2026-03-14T08:23:49Z\",\n      \"repoId\": 266033395,\n      \"pullRequestNo\": 2178\n    }\n  ]\n}"
  },
  {
    "path": ".github/workflows/cla.yml",
    "content": "# Workflow body from https://github.com/contributor-assistant/github-action\n\nname: \"CLA Assistant\"\non:\n  issue_comment:\n    types: [created]\n  pull_request_target:\n    types: [opened,closed,synchronize]\n\npermissions:\n  actions: write\n  contents: write\n  pull-requests: write\n  statuses: write\n\njobs:\n  CLAAssistant:\n    runs-on: ubuntu-latest\n    steps:\n      - name: \"CLA Assistant\"\n        if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'\n        uses: contributor-assistant/github-action@v2.6.1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          path-to-signatures: '.github/cla/signatures.json'\n          path-to-document: 'https://github.com/gristlabs/grist-core/blob/main/.github/cla/individual-cla.md'\n          branch: 'main'\n          allowlist: github-actions[bot],dependabot[bot]\n          lock-pullrequest-aftermerge: false\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Push Docker image\n\non:\n  release:\n    types: [published]\n  # Allows you to run this workflow manually from the Actions tab\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: \"Tag for the resulting images\"\n        type: string\n        required: True\n        default: 'stable'\n\nenv:\n  TAG: ${{ inputs.tag || 'stable' }}\n  DOCKER_HUB_OWNER: ${{ vars.DOCKER_HUB_OWNER || github.repository_owner }}\n  PLATFORMS: ${{ vars.PLATFORMS || 'linux/amd64,linux/arm64/v8' }}\n\njobs:\n  push_to_registry:\n    name: Push Docker images to Docker Hub\n    runs-on: ubuntu-22.04\n    strategy:\n      matrix:\n        image:\n          # We build two images, `grist-oss` and `grist`.\n          # See https://github.com/gristlabs/grist-core?tab=readme-ov-file#available-docker-images\n          - name: \"grist-oss\"\n            repo: \"grist-core\"\n          - name: \"grist\"\n            repo: \"grist-ee\"\n    steps:\n      - name: Free some space\n        run: |\n          sudo rm -rf /usr/share/dotnet\n          sudo rm -rf /usr/local/lib/android\n          sudo rm -rf /usr/local/.ghcup\n          sudo rm -rf /usr/share/swift\n\n      - name: Check out the repo\n        uses: actions/checkout@v3\n\n      - name: Add a dummy ext/ directory\n        run:\n          mkdir ext && touch ext/dummy\n\n      - name: Check out the ext/ directory\n        if: matrix.image.name != 'grist-oss'\n        run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}\n\n      - name: Generate metadata tag input\n        id: meta_input\n        run: |\n          {\n            echo \"tags<<EOF\"\n            echo \"type=ref,event=branch\"\n            echo \"type=ref,event=pr\"\n            echo \"type=semver,pattern={{version}}\"\n            echo \"type=semver,pattern={{major}}.{{minor}}\"\n            echo \"type=semver,pattern={{major}}\"\n            echo \"${{ env.TAG }}\"\n            echo \"EOF\"\n          } >> $GITHUB_OUTPUT\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v4\n        with:\n          images: |\n            ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}\n          tags: |\n            ${{ steps.meta_input.outputs.tags }}\n\n      - name: Docker meta (EE)\n        if: ${{ matrix.image.name == 'grist' }}\n        id: meta_ee\n        uses: docker/metadata-action@v4\n        with:\n          images: |\n            ${{ env.DOCKER_HUB_OWNER }}/grist-ee\n          tags: |\n            ${{ steps.meta_input.outputs.tags }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v1\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v1\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Push to Docker Hub\n        uses: docker/build-push-action@v2\n        with:\n          context: .\n          build-args: GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING=${{ matrix.image.name == 'grist-oss' && 'false' || 'true' }}\n          push: true\n          platforms: ${{ env.PLATFORMS }}\n          tags: ${{ steps.meta.outputs.tags }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          build-contexts: ext=ext\n\n      - name: Push Enterprise to Docker Hub\n        if: ${{ matrix.image.name == 'grist' }}\n        uses: docker/build-push-action@v2\n        with:\n          context: .\n          build-args: |\n            BASE_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name}}\n            BASE_VERSION=${{ env.TAG }}\n            GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING=true\n          file: ext/Dockerfile\n          platforms: ${{ env.PLATFORMS }}\n          push: true\n          tags: ${{ steps.meta_ee.outputs.tags }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n"
  },
  {
    "path": ".github/workflows/docker_latest.yml",
    "content": "name: Push latest Docker image\n\non:\n  push:\n    # Trigger if latest_candidate updates. This is automatically done by another\n    # workflow whenever tests pass on main - but events don't chain without using\n    # personal access tokens so we just use a cron job.\n    branches: [ latest_candidate ]\n  schedule:\n    # Run at 5:41 UTC daily\n    - cron:  '41 5 * * *'\n  workflow_dispatch:\n    inputs:\n      branch:\n        description: \"Branch from which to create the latest Docker image (default: latest_candidate)\"\n        type: string\n        required: true\n        default: latest_candidate\n      disable_tests:\n        description: \"Should the tests be skipped?\"\n        type: boolean\n        required: True\n        default: False\n      platforms:\n        description: \"Platforms to build\"\n        type: choice\n        required: True\n        options:\n          - linux/amd64\n          - linux/arm64/v8\n          - linux/amd64,linux/arm64/v8\n        default: linux/amd64,linux/arm64/v8\n      tag:\n        description: \"Tag for the resulting images\"\n        type: string\n        required: True\n        default: 'latest'\n\nenv:\n  BRANCH: ${{ inputs.branch || 'latest_candidate' }}\n  PLATFORMS: ${{ inputs.platforms || 'linux/amd64,linux/arm64/v8' }}\n  TAG: ${{ inputs.tag || 'latest' }}\n  DOCKER_HUB_OWNER: ${{ vars.DOCKER_HUB_OWNER || github.repository_owner }}\n\njobs:\n  push_to_registry:\n    name: Push latest Docker image to Docker Hub\n    runs-on: ubuntu-22.04\n    if: ${{ vars.RUN_DAILY_BUILD }}\n    strategy:\n      matrix:\n        python-version: [3.11]\n        node-version: [22.x]\n        image:\n          # We build two images, `grist-oss` and `grist`.\n          # See https://github.com/gristlabs/grist-core?tab=readme-ov-file#available-docker-images\n          - name: \"grist-oss\"\n            repo: \"grist-core\"\n          - name: \"grist\"\n            repo: \"grist-ee\"\n    steps:\n      - name: Build settings\n        run: |\n          echo \"Branch: $BRANCH\"\n          echo \"Platforms: $PLATFORMS\"\n          echo \"Docker Hub Owner: $DOCKER_HUB_OWNER\"\n          echo \"Tag: $TAG\"\n\n      - name: Free disk space\n        run: |\n          echo \"Disk space before cleanup:\"\n          df -h /\n          echo \"Removing Android SDK...\"\n          sudo rm -rf /usr/local/lib/android\n          df -h /\n          echo \"Removing .NET...\"\n          sudo rm -rf /usr/share/dotnet\n          df -h /\n          echo \"Removing Haskell...\"\n          sudo rm -rf /usr/local/.ghcup\n          df -h /\n          echo \"Removing Swift...\"\n          sudo rm -rf /usr/share/swift\n          df -h /\n          echo \"Final disk space:\"\n          df -h /\n\n      - name: Check out the repo\n        uses: actions/checkout@v4\n        with:\n          ref: ${{ env.BRANCH }}\n\n      - name: Add a dummy ext/ directory\n        run:\n          mkdir ext && touch ext/dummy\n\n      - name: Check out the ext/ directory\n        if: matrix.image.name != 'grist-oss'\n        run: buildtools/checkout-ext-directory.sh ${{ matrix.image.repo }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v1\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v1\n\n      - name: Prepare image but do not push it yet\n        uses: docker/build-push-action@v2\n        with:\n          context: .\n          load: true\n          tags: ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}\n          cache-from: type=gha\n          build-contexts: ext=ext\n\n      - name: Use Node.js ${{ matrix.node-version }} for testing\n        if: ${{ !inputs.disable_tests }}\n        uses: actions/setup-node@v1\n        with:\n          node-version: ${{ matrix.node-version }}\n\n      - name: Set up Python ${{ matrix.python-version }} for testing - maybe not needed\n        if: ${{ !inputs.disable_tests }}\n        uses: actions/setup-python@v2\n        with:\n          python-version: ${{ matrix.python-version }}\n\n      - name: Install Python packages\n        if: ${{ !inputs.disable_tests }}\n        run: |\n          pip install virtualenv\n          yarn run install:python\n\n      - name: Install Node.js packages\n        if: ${{ !inputs.disable_tests }}\n        run: yarn install\n\n      - name: Disable the ext/ directory\n        if: ${{ !inputs.disable_tests }}\n        run: mv ext/ ext-disabled/\n\n      - name: Build Node.js code\n        if: ${{ !inputs.disable_tests }}\n        run: yarn run build\n\n      - name: Install Google Chrome and chromedriver\n        run: buildtools/install_chrome_for_tests.sh -y\n\n      - name: Run tests with default settings\n        if: ${{ !inputs.disable_tests }}\n        run: |\n          export TEST_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}\n          export VERBOSE=1\n          export DEBUG=1\n          export MOCHA_WEBDRIVER_HEADLESS=1\n          yarn run test:docker\n\n      - name: Run some tests with gvisor and python\n        if: ${{ !inputs.disable_tests }}\n        run: |\n          export TEST_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}\n          export VERBOSE=1\n          export DEBUG=1\n          export MOCHA_WEBDRIVER_HEADLESS=1\n          export GREP_TESTS='should support basic editing'\n          export TEST_DOCKER_OPTIONS='-e GRIST_SANDBOX_FLAVOR=gvisor -e PYTHON_VERSION_ON_CREATION=3'\n          yarn run test:docker\n\n      - name: Re-enable the ext/ directory\n        if: ${{ !inputs.disable_tests }}\n        run: mv ext-disabled/ ext/\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v1 \n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Push to Docker Hub\n        uses: docker/build-push-action@v2\n        with:\n          context: .\n          build-args: GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING=${{ matrix.image.name == 'grist-oss' && 'false' || 'true' }}\n          platforms: ${{ env.PLATFORMS }}\n          push: true\n          tags: ${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name }}:${{ env.TAG }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          build-contexts: ext=ext\n\n      - name: Push Enterprise to Docker Hub\n        if: ${{ matrix.image.name == 'grist' }}\n        uses: docker/build-push-action@v2\n        with:\n          context: .\n          build-args: |\n            BASE_IMAGE=${{ env.DOCKER_HUB_OWNER }}/${{ matrix.image.name}}\n            BASE_VERSION=${{ env.TAG }}\n            GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING=true\n          file: ext/Dockerfile\n          platforms: ${{ env.PLATFORMS }}\n          push: true\n          tags: ${{ env.DOCKER_HUB_OWNER }}/grist-ee:${{ env.TAG }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n  update_latest_branch:\n    name: Update latest branch\n    runs-on: ubuntu-22.04\n    needs: push_to_registry\n    steps:\n      - name: Check out the repo\n        uses: actions/checkout@v2\n        with:\n          ref: ${{ inputs.latest_branch }}\n\n      - name: Update latest branch\n        uses: ad-m/github-push-action@8407731efefc0d8f72af254c74276b7a90be36e1\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          branch: latest\n          force: true\n"
  },
  {
    "path": ".github/workflows/fly-build.yml",
    "content": "# fly-deploy will be triggered on completion of this workflow to actually deploy the code to fly.io.\n\nname: fly.io Build\non:\n  pull_request:\n    branches: [ main ]\n    types: [labeled, opened, synchronize, reopened]\n\n  # Allows running this workflow manually from the Actions tab\n  workflow_dispatch:\n\njobs:\n  build:\n    name: Build Docker image\n    runs-on: ubuntu-22.04\n    # Build when the 'preview' label is added, or when PR is updated with this label present.\n    if: >\n      github.event_name == 'workflow_dispatch' ||\n      (github.event_name == 'pull_request' &&\n      contains(github.event.pull_request.labels.*.name, 'preview'))\n    steps:\n      - uses: actions/checkout@v4\n      - name: Build and export Docker image\n        id: docker-build\n        run: >\n          ./buildtools/checkout-ext-directory.sh grist-ee &&\n          docker build -t grist-core:preview . --build-context ext=ext &&\n          docker image save grist-core:preview -o grist-core.tar\n      - name: Save PR information\n        run: |\n          echo PR_NUMBER=${{ github.event.number }} >> ./pr-info.txt\n          echo PR_SOURCE=${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }} >> ./pr-info.txt\n          echo PR_SHASUM=${{ github.event.pull_request.head.sha }} >> ./pr-info.txt\n        # PR_SOURCE looks like <owner>/<repo>-<branch>.\n        # For example, if the GitHub user \"foo\" forked grist-core as \"grist-bar\", and makes a PR from their branch named \"baz\",\n        # it will be \"foo/grist-bar-baz\". deploy.js later replaces \"/\" with \"-\", making it \"foo-grist-bar-baz\".\n      - name: Upload artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: docker-image\n          path: |\n            ./grist-core.tar\n            ./pr-info.txt\n            ./buildtools/fly-template.env\n          if-no-files-found: \"error\"\n"
  },
  {
    "path": ".github/workflows/fly-cleanup.yml",
    "content": "name: fly.io Cleanup\non:\n  schedule:\n    # Once a day, clean up jobs marked as expired\n    - cron: '50 12 * * *'\n\n  # Allows running this workflow manually from the Actions tab\n  workflow_dispatch:\n\nenv:\n  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n\njobs:\n  clean:\n    name: Clean stale deployed apps\n    runs-on: ubuntu-22.04\n    if: github.repository_owner == 'gristlabs'\n    steps:\n      - uses: actions/checkout@v3\n      - uses: superfly/flyctl-actions/setup-flyctl@master\n        with:\n          version: 0.2.72\n      - run: node buildtools/fly-deploy.js clean\n"
  },
  {
    "path": ".github/workflows/fly-deploy.yml",
    "content": "# Follow-up of fly-build, with access to secrets for making deployments.\n# This workflow runs in the target repo context. It does not, and should never execute user-supplied code.\n# See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/\n\nname: fly.io Deploy\non:\n  workflow_run:\n    workflows: [\"fly.io Build\"]\n    types:\n      - completed\n\njobs:\n  deploy:\n    name: Deploy app to fly.io\n    runs-on: ubuntu-22.04\n    if: |\n      github.event.workflow_run.event == 'pull_request' &&\n      github.event.workflow_run.conclusion == 'success'\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up flyctl\n        uses: superfly/flyctl-actions/setup-flyctl@master\n        with:\n          version: 0.2.72\n      - name: Download artifacts\n        uses: actions/github-script@v7\n        with:\n          script: |\n            var artifacts = await github.rest.actions.listWorkflowRunArtifacts({\n               owner: context.repo.owner,\n               repo: context.repo.repo,\n               run_id: ${{ github.event.workflow_run.id }},\n            });\n            var matchArtifact = artifacts.data.artifacts.filter((artifact) => {\n              return artifact.name == \"docker-image\"\n            })[0];\n            var download = await github.rest.actions.downloadArtifact({\n               owner: context.repo.owner,\n               repo: context.repo.repo,\n               artifact_id: matchArtifact.id,\n               archive_format: 'zip',\n            });\n            var fs = require('fs');\n            fs.writeFileSync('${{github.workspace}}/docker-image.zip', Buffer.from(download.data));\n            await github.rest.actions.deleteArtifact({\n               owner: context.repo.owner,\n               repo: context.repo.repo,\n               artifact_id: matchArtifact.id,\n            });\n      - name: Extract artifacts\n        id: extract_artifacts\n        run: |\n          unzip -o docker-image.zip grist-core.tar pr-info.txt buildtools/fly-template.env\n          cat ./pr-info.txt >> $GITHUB_OUTPUT\n      - name: Load Docker image\n        run: docker load --input grist-core.tar\n      - name: Deploy to fly.io\n        id: fly_deploy\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n          BRANCH_NAME: ${{ steps.extract_artifacts.outputs.PR_SOURCE }}\n        run: |\n          node buildtools/fly-deploy.js deploy\n          flyctl config -c ./fly.toml env | awk '/APP_HOME_URL/{print \"DEPLOY_URL=\" $2}' >> $GITHUB_OUTPUT\n          flyctl config -c ./fly.toml env | awk '/FLY_DEPLOY_EXPIRATION/{print \"EXPIRES=\" $2}' >> $GITHUB_OUTPUT\n      - name: Comment on PR\n        uses: actions/github-script@v7\n        with:\n          script: |\n            github.rest.issues.createComment({\n              issue_number: ${{ steps.extract_artifacts.outputs.PR_NUMBER }},\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              body: `Deployed commit \\`${{ steps.extract_artifacts.outputs.PR_SHASUM }}\\` as ${{ steps.fly_deploy.outputs.DEPLOY_URL }} (until ${{ steps.fly_deploy.outputs.EXPIRES }})`\n            })\n"
  },
  {
    "path": ".github/workflows/fly-destroy.yml",
    "content": "# This workflow runs in the target repo context, as it is triggered via pull_request_target.\n# It does not, and should not have access to code in the PR.\n# See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/\n\nname: fly.io Destroy\non:\n  pull_request_target:\n    branches: [ main ]\n    types: [unlabeled, closed]\n\n  # Allows running this workflow manually from the Actions tab\n  workflow_dispatch:\n\njobs:\n  destroy:\n    name: Remove app from fly.io\n    runs-on: ubuntu-22.04\n    # Remove the deployment when 'preview' label is removed, or the PR is closed.\n    if: |\n      github.event_name == 'workflow_dispatch' ||\n      (github.event_name == 'pull_request_target' &&\n      (github.event.action == 'closed' ||\n      (github.event.action == 'unlabeled' && github.event.label.name == 'preview')))\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up flyctl\n        uses: superfly/flyctl-actions/setup-flyctl@master\n        with:\n          version: 0.2.72\n      - name: Destroy fly.io app\n        env:\n          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}\n          BRANCH_NAME: ${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }}\n          # See fly-build for what BRANCH_NAME looks like.\n        id: fly_destroy\n        run: node buildtools/fly-deploy.js destroy\n"
  },
  {
    "path": ".github/workflows/main.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n\n  # Allows running this workflow manually from the Actions tab\n  workflow_dispatch:\n\njobs:\n  build_and_test:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      # it is helpful to know which sets of tests would have succeeded,\n      # even when there is a failure.\n      fail-fast: false\n      matrix:\n        os: ['ubuntu-24.04']\n        python-version: [3.11]\n        node-version: [22.x]\n        tests:\n          - ':lint:python:client:common:smoke:stubs:pyodide:'\n          - ':server-1-of-2:'\n          - ':server-2-of-2:'\n          - ':gen-server:'\n          - ':nbrowser-^[A-D]:'\n          - ':nbrowser-^[E-L]:'\n          - ':nbrowser-^[M-N]:'\n          - ':nbrowser-^[O-R]:'\n          - ':nbrowser-^[^A-R]:'\n          - ':projects:'\n        include:\n          - tests: ':lint:python:client:common:smoke:'\n            node-version: 22.x\n            python-version: '3.10'\n            os: ubuntu-24.04\n          - tests: ':pyodide:macsandbox:'\n            node-version: 22.x\n            python-version: '3.11'\n            os: macos-latest\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Use Node.js ${{ matrix.node-version }}\n        uses: actions/setup-node@v3\n        with:\n          node-version: ${{ matrix.node-version }}\n          cache: 'yarn'\n\n      - name: Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v4\n        with:\n          python-version: ${{ matrix.python-version }}\n          cache: 'pip'\n\n      - name: Install Python packages\n        run: |\n          pip install virtualenv\n          yarn run install:python\n\n      - name: Install Node.js packages\n        run: yarn install\n\n      - name: Install gvisor\n        if: contains(matrix.os, 'ubuntu')\n        run: |\n          docker create --name temp-runsc gristlabs/gvisor-unprivileged:buster /bin/true\n          sudo docker cp temp-runsc:/runsc /usr/bin/runsc\n          docker rm temp-runsc\n\n      - name: Run eslint\n        if: contains(matrix.tests, ':lint:')\n        run: yarn run lint:ci\n\n      - name: Make sure bucket is versioned\n        if: contains(matrix.os, 'ubuntu') && contains(matrix.tests, ':server-') || contains(matrix.os, 'ubuntu') && contains(matrix.tests, ':gen-server:')\n        env:\n          AWS_ACCESS_KEY_ID: administrator\n          AWS_SECRET_ACCESS_KEY: administrator\n        run: aws --region us-east-1 --endpoint-url http://localhost:9000 s3api put-bucket-versioning --bucket grist-docs-test --versioning-configuration Status=Enabled\n\n      - name: Build Node.js code\n        run: yarn run build\n\n      - name: Install Google Chrome and chromedriver\n        if: contains(matrix.tests, ':nbrowser-') || contains(matrix.tests, ':smoke:') || contains(matrix.tests, ':stubs:') || contains(matrix.tests, ':projects:')\n        run: buildtools/install_chrome_for_tests.sh -y\n\n      - name: Run smoke test\n        if: contains(matrix.tests, ':smoke:')\n        run: VERBOSE=1 DEBUG=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:smoke\n\n      - name: Run python tests\n        if: contains(matrix.tests, ':python:')\n        run: yarn run test:python\n\n      - name: Run client tests\n        if: contains(matrix.tests, ':client:')\n        run: yarn run test:client\n\n      - name: Run common tests\n        if: contains(matrix.tests, ':common:')\n        run: yarn run test:common\n\n      - name: Run stubs tests\n        if: contains(matrix.tests, ':stubs:')\n        run: MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:stubs\n\n      - name: Run gen-server tests with sqlite, minio and redis\n        if: contains(matrix.tests, ':gen-server:')\n        run: |\n          yarn run test:gen-server\n        # Anchors should be used once available. Not supported yet as of December 2024.\n        # https://github.com/actions/runner/issues/1182\n        env:\n          MOCHA_WEBDRIVER_HEADLESS: 1\n          TESTS: ${{ matrix.tests }}\n          GRIST_DOCS_MINIO_ACCESS_KEY: administrator\n          GRIST_DOCS_MINIO_SECRET_KEY: administrator\n          TEST_REDIS_URL: \"redis://localhost/11\"\n          GRIST_DOCS_MINIO_USE_SSL: 0\n          GRIST_DOCS_MINIO_ENDPOINT: localhost\n          GRIST_DOCS_MINIO_PORT: 9000\n          GRIST_DOCS_MINIO_BUCKET: grist-docs-test\n\n      - name: Run a couple of tests using pyodide\n        if: contains(matrix.tests, ':pyodide:')\n        run: |\n          cd sandbox/pyodide\n          make setup\n          cd ../..\n          yarn run test:server -g 'ActiveDoc.useQuerySet|Sandbox'\n          yarn run test:nbrowser -g 'Importer.*should.show.correct.preview'\n        env:\n          MOCHA_WEBDRIVER_HEADLESS: 1\n          GRIST_SANDBOX_FLAVOR: pyodide\n\n      - name: Run a couple of tests using macSandboxExec\n        if: contains(matrix.tests, ':macsandbox:')\n        run: |\n          yarn run test:server -g Sandbox\n        env:\n          MOCHA_WEBDRIVER_HEADLESS: 1\n          GRIST_SANDBOX_FLAVOR: macSandboxExec\n\n      - name: Run gen-server tests with postgres, minio and redis\n        if: contains(matrix.tests, ':gen-server:')\n        run: |\n          PGPASSWORD=$TYPEORM_PASSWORD psql -h $TYPEORM_HOST -U $TYPEORM_USERNAME -w $TYPEORM_DATABASE -c \"SHOW ALL;\" | grep ' jit '\n          yarn run test:gen-server\n        env:\n          MOCHA_WEBDRIVER_HEADLESS: 1\n          TESTS: ${{ matrix.tests }}\n          GRIST_DOCS_MINIO_ACCESS_KEY: administrator\n          GRIST_DOCS_MINIO_SECRET_KEY: administrator\n          TEST_REDIS_URL: \"redis://localhost/11\"\n          GRIST_DOCS_MINIO_USE_SSL: 0\n          GRIST_DOCS_MINIO_ENDPOINT: localhost\n          GRIST_DOCS_MINIO_PORT: 9000\n          GRIST_DOCS_MINIO_BUCKET: grist-docs-test\n          TYPEORM_TYPE: postgres\n          TYPEORM_HOST: localhost\n          TYPEORM_DATABASE: db_name\n          TYPEORM_USERNAME: db_user\n          TYPEORM_PASSWORD: db_password\n\n      - name: Run server tests with minio and redis\n        if: contains(matrix.tests, ':server-')\n        run: |\n          export TEST_SPLITS=$(echo $TESTS | sed \"s/.*:server-\\([^:]*\\).*/\\1/\")\n          yarn run test:server\n        env:\n          MOCHA_WEBDRIVER_HEADLESS: 1\n          TESTS: ${{ matrix.tests }}\n          GRIST_DOCS_MINIO_ACCESS_KEY: administrator\n          GRIST_DOCS_MINIO_SECRET_KEY: administrator\n          TEST_REDIS_URL: \"redis://localhost/11\"\n          GVISOR_FLAGS: \"-unprivileged -ignore-cgroups\"\n          GVISOR_EXTRA_DIRS: /opt\n          GRIST_DOCS_MINIO_USE_SSL: 0\n          GRIST_DOCS_MINIO_ENDPOINT: localhost\n          GRIST_DOCS_MINIO_PORT: 9000\n          GRIST_DOCS_MINIO_BUCKET: grist-docs-test\n\n      - name: Run main tests without minio and redis\n        if: contains(matrix.tests, ':nbrowser-')\n        run: |\n          mkdir -p $MOCHA_WEBDRIVER_LOGDIR\n          export GREP_TESTS=$(echo $TESTS | sed \"s/.*:nbrowser-\\([^:]*\\).*/\\1/\")\n          MOCHA_WEBDRIVER_SKIP_CLEANUP=1 MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser --parallel --jobs 3\n        env:\n          TESTS: ${{ matrix.tests }}\n          MOCHA_WEBDRIVER_LOGDIR: ${{ runner.temp }}/test-logs/webdriver\n          GVISOR_FLAGS: \"-unprivileged -ignore-cgroups\"\n          GVISOR_EXTRA_DIRS: /opt\n          TESTDIR: ${{ runner.temp }}/test-logs\n\n      - name: Run projects tests\n        if: contains(matrix.tests, ':projects:')\n        run: |\n          mkdir -p $MOCHA_WEBDRIVER_LOGDIR\n          yarn run test:projects\n        env:\n          MOCHA_WEBDRIVER_LOGDIR: ${{ runner.temp }}/test-logs/webdriver\n          TESTDIR: ${{ runner.temp }}/test-logs\n          MOCHA_WEBDRIVER_HEADLESS: 1\n\n      - name: Prepare for saving artifact\n        if: failure()\n        run: |\n          ARTIFACT_NAME=logs-$(echo $TESTS | sed 's/[^-a-zA-Z0-9]/_/g')\n          echo \"Artifact name is '$ARTIFACT_NAME'\"\n          echo \"ARTIFACT_NAME=$ARTIFACT_NAME\" >> $GITHUB_ENV\n          mkdir -p $TESTDIR\n          find $TESTDIR -iname \"*.socket\" -exec rm {} \\;\n        env:\n          TESTS: ${{ matrix.tests }}\n          TESTDIR: ${{ runner.temp }}/test-logs\n\n      - name: Save artifacts on failure\n        if: failure()\n        uses: actions/upload-artifact@v4\n        with:\n          name: ${{ env.ARTIFACT_NAME }}\n          path: ${{ runner.temp }}/test-logs  # only exists for webdriver tests\n\n    services:\n      # https://github.com/bitnami/containers/issues/83267\n      minio:\n        image: ${{ matrix.os == 'ubuntu-24.04' && 'bitnamilegacy/minio:2025.4.22' || '' }} \n        env:\n          MINIO_DEFAULT_BUCKETS: \"grist-docs-test:public\"\n          MINIO_ROOT_USER: administrator\n          MINIO_ROOT_PASSWORD: administrator\n        ports:\n          - 9000:9000\n        options: >-\n          --health-cmd \"curl -f http://localhost:9000/minio/health/ready\"\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n\n      redis:\n        image: ${{ matrix.os == 'ubuntu-24.04' && 'redis' || '' }}\n        ports:\n          - 6379:6379\n        options: >-\n          --health-cmd \"redis-cli ping\"\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n\n      postgresql:\n        image: ${{ matrix.os == 'ubuntu-24.04' && 'postgres:latest' || '' }}\n        env:\n          POSTGRES_USER: db_user\n          POSTGRES_PASSWORD: db_password\n          POSTGRES_DB: db_name\n          # JIT is enabled by default since Postgres 17 and has a huge negative impact on performance,\n          # making many tests timeout.\n          # https://support.getgrist.com/self-managed/#what-is-a-home-database\n          POSTGRES_INITDB_ARGS: \"-c jit=off\"\n        ports:\n          - 5432:5432\n        options: >-\n          --health-cmd \"pg_isready -U db_user\"\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n\n  candidate:\n    needs: build_and_test\n    if: ${{ success() && github.event_name == 'push' }}\n    runs-on: ubuntu-22.04\n    steps:\n      - name: Fetch new candidate branch\n        uses: actions/checkout@v3\n\n      - name: Update candidate branch\n        uses: ad-m/github-push-action@8407731efefc0d8f72af254c74276b7a90be36e1\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          branch: latest_candidate\n          force: true\n"
  },
  {
    "path": ".github/workflows/self-hosted.yml",
    "content": "name: Add self-hosting issues to the self-hosting project\n\non:\n  issues:\n    types:\n      - opened\n      - labeled\n\njobs:\n  add-to-project:\n    name: Add issue to project\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/add-to-project@v1.0.1\n        with:\n          project-url: https://github.com/orgs/gristlabs/projects/2\n          github-token: ${{ secrets.SELF_HOSTED_PROJECT }}\n          labeled: self-hosting\n"
  },
  {
    "path": ".github/workflows/translation_keys.yml",
    "content": "name: Translation keys\n\non:\n  push:\n    branches: [ main ]\n  workflow_dispatch:\n\npermissions:\n  pull-requests: write\n  contents: write\n\njobs:\n  build:\n    if: github.repository_owner == 'gristlabs'\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@v2\n        with:\n          fetch-depth: 0 # Let's get all the branches\n\n      - name: Use Node.js\n        uses: actions/setup-node@v1\n        with:\n          node-version: 22\n\n      - name: Install Node.js packages\n        run: yarn install\n\n      - name: Build code\n        run: yarn run build\n\n      - name: Scan for keys\n        id: scan-keys\n        run: |\n          git checkout -b translation-keys\n          yarn run generate:translation 2>&1 | tee /tmp/scan-output.txt\n          git status --porcelain\n          if [[ $(git status --porcelain | wc -l) -eq \"0\" ]]; then\n            echo \"No changes\"\n            echo \"CHANGED=false\" >> $GITHUB_ENV\n          else\n            echo \"Changes detected\"\n            echo \"CHANGED=true\" >> $GITHUB_ENV\n          fi\n\n      - name: setup git config\n        run: |\n          git config user.name \"Paul's Grist Bot\"\n          git config user.email \"<paul+bot@getgrist.com>\"\n\n      - name: Create PR and merge\n        if: env.CHANGED == 'true'\n        run: |\n          git commit -m \"automated update to translation keys\" -a\n          git push --set-upstream origin HEAD:translation-keys -f\n          num=$(gh pr list --search \"automated update to translation keys\" --json number -q \".[].number\")\n          if [[ \"$num\" != \"\" ]]; then\n            echo \"Existing translation keys PR #$num is open, skipping\"\n            exit 0\n          fi\n          sed -n '/TRANSLATION_SUMMARY_START/,/TRANSLATION_SUMMARY_END/{//d;p;}' /tmp/scan-output.txt > /tmp/pr-body.txt\n          gh pr create --title \"automated update to translation keys\" --body-file /tmp/pr-body.txt\n          gh pr merge --merge --delete-branch\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "/node_modules/\n/_build/\n/static/*.bundle.js\n/static/*.bundle.js.map\n/static/grist-plugin-api*\n/static/bundle.css\n/static/browser-check.js\n/static/*.bundle.js.*.txt\n/grist-sessions.db\n/landing.db\n/docs/\n/sandbox_venv*\n/.vscode/\n\n# Files created by grist-desktop setup\n/cpython.tar.gz\n/python\n/static_ext\n\n# Build helper files.\n/.build*\n\n*.swp\n*.pyc\n*.bak\n.DS_Store\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (http://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\n/node_modules/\njspm_packages/\n/docs\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n\n# Test\ntimings.txt\nxunit.xml\n.clipboard.lock\n\n**/_build\n\n# ext directory can be overwritten\n/ext\n/ext/**\n\n# Docker compose examples - persistent values and secrets\n/docker-compose-examples/*/persist\n/docker-compose-examples/*/secrets\n/docker-compose-examples/grist-traefik-oidc-auth/.env\n\n# Sample grist documents\n/samples/\n\n/test/assistant/data/cache/\n/test/assistant/data/results/\n/test/assistant/data/templates/\n"
  },
  {
    "path": ".nvmrc",
    "content": "v22.12.0\n"
  },
  {
    "path": ".yarnrc",
    "content": "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n# yarn lockfile v1\n\n\nyarn-offline-mirror false\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Welcome to the contribution guide for Grist!\n\nYou are eager to contribute to Grist? That's awesome! See below some contributions you can make:\n- [translate](/documentation/translations.md)\n- [write tutorials and user documentation](https://github.com/gristlabs/grist-help?tab=readme-ov-file#grist-help-center)\n- [develop](/documentation/develop.md)\n- [report issues or suggest enhancement](https://github.com/gristlabs/grist-core/issues/new/choose)\n\n"
  },
  {
    "path": "Dockerfile",
    "content": "################################################################################\n## The Grist source can be extended. This is a stub that can be overridden\n## from command line, as:\n##   docker buildx build -t ... --build-context=ext=<path> .\n## The code in <path> will then be built along with the rest of Grist.\n################################################################################\nFROM scratch AS ext\n\n################################################################################\n## Javascript build stage\n################################################################################\n\nFROM node:22-trixie AS prod-builder\n\n# Install all node dependencies.\nWORKDIR /grist\nCOPY package.json yarn.lock /grist/\nRUN \\\n  yarn install --prod --frozen-lockfile --verbose --network-timeout 600000\n\nFROM prod-builder AS builder\n\n# Create node_modules with devDependencies to be able to build the app\n# Add at global level gyp deps to build sqlite3 for prod\n# then create node_modules_prod that will be the node_modules of final image\nRUN \\\n  yarn install --frozen-lockfile --verbose --network-timeout 600000\n\n# Install any extra node dependencies (at root level, to avoid having to wrestle\n# with merging them).\nCOPY --from=ext / /grist/ext\nRUN \\\n mkdir /node_modules && \\\n cd /grist/ext && \\\n { if [ -e package.json ] ; then yarn install --frozen-lockfile --modules-folder=/node_modules --verbose --network-timeout 600000 ; fi }\n\n# Build node code.\nCOPY tsconfig.json /grist\nCOPY tsconfig-ext.json /grist\nCOPY tsconfig-prod.json /grist\nCOPY test/tsconfig.json /grist/test/tsconfig.json\nCOPY test/chai-as-promised.js /grist/test/chai-as-promised.js\nCOPY app /grist/app\nCOPY stubs /grist/stubs\nCOPY buildtools /grist/buildtools\n# Copy locales files early. During build process they are validated.\nCOPY static/locales /grist/static/locales\nRUN WEBPACK_EXTRA_MODULE_PATHS=/node_modules yarn run build:prod\n# We don't need them anymore, they will by copied to the final image.\nRUN rm -rf /grist/static/locales\n\n\n# Prepare material for optional pyodide sandbox\nCOPY sandbox/pyodide /grist/sandbox/pyodide\nCOPY sandbox/requirements.txt /grist/sandbox/requirements.txt\nRUN \\\n  cd /grist/sandbox/pyodide && make setup\n\n################################################################################\n## Python collection stage\n################################################################################\n\n# Fetch python3.11\nFROM python:3.11-slim-trixie AS collector-py3\nCOPY sandbox/requirements.txt requirements.txt\nRUN \\\n  pip3 install -r requirements.txt\n\n################################################################################\n## Sandbox collection stage\n################################################################################\n\n# Fetch gvisor-based sandbox. Note, to enable it to run within default\n# unprivileged docker, layers of protection that require privilege have\n# been stripped away, see https://github.com/google/gvisor/issues/4371\n# The standalone sandbox binary is built on buster, but remains compatible\n# with recent Debian.\n# If you'd like to use unmodified gvisor, you should be able to just drop\n# in the standard runsc binary and run the container with any extra permissions\n# it needs.\nFROM docker.io/gristlabs/gvisor-unprivileged:buster AS sandbox\n\n################################################################################\n## Run-time stage\n################################################################################\n\n# Now, start preparing final image.\nFROM node:22-trixie-slim\n\nARG GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING=false\n\n# Install curl for docker healthchecks, libexpat1 and libsqlite3-0 for python3\n# library binary dependencies, and procps for managing gvisor processes.\nRUN \\\n  apt-get update && \\\n  apt-get install -y --no-install-recommends curl libexpat1 libsqlite3-0 procps tini && \\\n  rm -rf /var/lib/apt/lists/*\n\n# Keep all storage user may want to persist in a distinct directory\nRUN mkdir -p /persist/docs\n\n# Copy node files.\nCOPY --from=builder /node_modules /node_modules\nCOPY --from=prod-builder /grist/node_modules /grist/node_modules\nCOPY --from=builder /grist/_build /grist/_build\nCOPY --from=builder /grist/static /grist/static-built\nCOPY --from=builder /grist/app/cli.sh /grist/cli\n# Patterm match here is to copy assets only if it exists in the\n# builder stage, otherwise matches nothing.\n# https://stackoverflow.com/a/70096420/11352427\nCOPY --from=builder /grist/ext/asset[s] /grist/ext/assets\n\n# Copy python3 files.\nCOPY --from=collector-py3 /usr/local/bin/python3.11 /usr/bin/python3.11\nCOPY --from=collector-py3 /usr/local/lib/python3.11 /usr/local/lib/python3.11\nCOPY --from=collector-py3 /usr/local/lib/libpython3.11.* /usr/local/lib/\n# Set default to python3\nRUN \\\n  ln -s /usr/bin/python3.11 /usr/bin/python && \\\n  ln -s /usr/bin/python3.11 /usr/bin/python3 && \\\n  ldconfig\n\n# Copy runsc.\nCOPY --from=sandbox /runsc /usr/bin/runsc\n\n# Add files needed for running server.\nCOPY package.json /grist/package.json\nCOPY bower_components /grist/bower_components\nCOPY sandbox /grist/sandbox\nCOPY plugins /grist/plugins\nCOPY static /grist/static\n\n# Make optional pyodide sandbox available\nCOPY --from=builder /grist/sandbox/pyodide /grist/sandbox/pyodide\n\n# Finalize static directory\nRUN \\\n  mv /grist/static-built/* /grist/static && \\\n  rmdir /grist/static-built\n\n# To ensure non-root users can run grist, 'other' users need read access (and execute on directories)\n# This should be the case by default when copying files in.\n# Only uncomment this if running into permissions issues, as it takes a long time to execute on some systems.\n# RUN chmod -R o+rX /grist\n\n# Add a user to allow de-escalating from root on startup\nRUN useradd -ms /bin/bash grist\nENV GRIST_DOCKER_USER=grist \\\n    GRIST_DOCKER_GROUP=grist\nWORKDIR /grist\n\n# Set some default environment variables to give a setup that works out of the box when\n# started as:\n#   docker run -p 8484:8484 -it <image>\n# Variables will need to be overridden for other setups.\n#\n# GRIST_SANDBOX_FLAVOR is set to unsandboxed by default, because it\n# appears that the services people use to run docker containers have\n# a wide variety of security settings and the functionality needed for\n# sandboxing may not be possible in every case. For default docker\n# settings, you can get sandboxing as follows:\n#   docker run --env GRIST_SANDBOX_FLAVOR=gvisor -p 8484:8484 -it <image>\n#\n# \"NODE_OPTIONS=--no-deprecation\" is set because there is a punycode\n# deprecation nag that is relevant to developers but not to users.\n# TODO: upgrade package.json to avoid using all package versions\n# using the punycode functionality that may be removed in future\n# versions of node.\n#\n# \"NODE_ENV=production\" gives ActiveDoc operations more time to\n# complete, and the express webserver also does some streamlining\n# with this setting. If you don't want these, set NODE_ENV to\n# development.\n#\nENV \\\n  GRIST_ORG_IN_PATH=true \\\n  GRIST_HOST=0.0.0.0 \\\n  GRIST_SINGLE_PORT=true \\\n  GRIST_SERVE_SAME_ORIGIN=true \\\n  GRIST_DATA_DIR=/persist/docs \\\n  GRIST_INST_DIR=/persist \\\n  GRIST_SESSION_COOKIE=grist_core \\\n  GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING=${GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING} \\\n  GVISOR_FLAGS=\"-unprivileged -ignore-cgroups\" \\\n  GRIST_SANDBOX_FLAVOR=unsandboxed \\\n  NODE_OPTIONS=\"--no-deprecation\" \\\n  NODE_ENV=production \\\n  TYPEORM_DATABASE=/persist/home.sqlite3\n\nEXPOSE 8484\n\n# When run without any arguments, we run the Grist server within\n# a simple supervisor.\n# When arguments are supplied they are treated as a command to run,\n# as is default for docker. We arrange to have a \"cli\" command that\n# is the same as \"yarn cli\" run from the source code repo.\n# So you can do things like:\n# docker run --rm -v $PWD:$PWD -it gristlabs/grist \\\n#   cli sqlite query $PWD/docs/4gtUhAEGbGAdsGNc52k4H6.grist \\\n#  --json \"select * from _gristsys_ActionHistory\"\n\nENTRYPOINT [\"./sandbox/docker_entrypoint.sh\"]\nCMD [\"node\", \"./sandbox/supervisor.mjs\"]\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2014-2022 Grist Labs Inc.\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "NOTICE.txt",
    "content": "Grist Software\nCopyright 2014-2022 Grist Labs Inc.\n\nThis product includes software developed at\nGrist Labs Inc. (https://www.getgrist.com/).\n"
  },
  {
    "path": "README.md",
    "content": "# Grist\n\nGrist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.\n\n* `grist-core` (this repo) has what you need to run a powerful server for hosting spreadsheets.\n\n* [`grist-desktop`](https://github.com/gristlabs/grist-desktop) is a Linux/macOS/Windows desktop app for viewing and editing spreadsheets stored locally.\n* [`grist-static`](https://github.com/gristlabs/grist-static) is a fully in-browser build of Grist for displaying spreadsheets on a website without back-end support.\n\nGrist is developed by [Grist Labs](https://www.linkedin.com/company/grist-labs/), an NYC-based company 🇺🇸🗽. The French government 🇫🇷 organizations [ANCT Données et Territoires](https://donnees.incubateur.anct.gouv.fr/toolbox/grist) and [DINUM (Direction Interministérielle du Numérique)](https://www.numerique.gouv.fr/dinum/) have also made significant contributions to the codebase.\n\nThe `grist-core`, `grist-desktop`, and `grist-static` repositories are all open source (Apache License, Version 2.0).\nGrist Labs offers free and paid hosted services at [getgrist.com](https://getgrist.com), sells an Enterprise product,\nand offers [cloud packaging](https://support.getgrist.com/install/grist-builder-edition/).\n\n> Questions? Feedback? Want to share what you're building with Grist? Join our [official Discord server](https://discord.gg/MYKpYQ3fbP) or visit our [Community forum](https://community.getgrist.com/). \n>\n> To keep up-to-date with everything that's going on, you can [sign up for Grist's monthly newsletter](https://www.getgrist.com/newsletter/).\n\nhttps://github.com/user-attachments/assets/fe152f60-3d15-4b11-8cb2-05731a90d273\n\n## Features in `grist-core`\n\nTo see exactly what is present in `grist-core`, you can run the [desktop app](https://github.com/gristlabs/grist-desktop), or use [`docker`](#using-grist). The absolute fastest way to try Grist out is to visit [docs.getgrist.com](https://docs.getgrist.com) and play with a spreadsheet there immediately – though if you do, please read the list of [extra extensions](#features-not-in-grist-core) that are not in `grist-core`.\n\nHowever you try it, you'll quickly see that Grist is a hybrid database/spreadsheet, meaning that:\n\n  - Columns work like they do in databases: they are named, and they hold one kind of data.\n  - Columns can be filled by formula, spreadsheet-style, with automatic updates when referenced cells change.\n\nThis 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).\n\nHere are some specific feature highlights of Grist:\n\n  * Python formulas.\n    - Full [Python syntax is supported](https://support.getgrist.com/formulas/#python), including the standard library.\n    - Many [Excel functions](https://support.getgrist.com/functions/) also available.\n    - 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 <a href=\"https://github.com/abetlen/llama-cpp-python\">llama-cpp-python</a>).\n  * A portable, self-contained format.\n    - Based on SQLite, the most widely deployed database engine.\n    - Any tool that can read SQLite can read numeric and text data from a Grist file.\n    - Enables [backups](https://support.getgrist.com/exports/#backing-up-an-entire-document) that you can confidently restore in full.\n    - Great for moving between different hosts.\n  * Can be displayed on a static website with [`grist-static`](https://github.com/gristlabs/grist-static) – no special server needed.\n  * A self-contained desktop app for viewing and editing locally: [`grist-desktop`](https://github.com/gristlabs/grist-desktop).\n  * Convenient editing and formatting features.\n    - Choices and [choice lists](https://support.getgrist.com/col-types/#choice-list-columns), for adding colorful tags to records.\n    - [References](https://support.getgrist.com/col-refs/#creating-a-new-reference-list-column) and reference lists, for cross-referencing records in other tables.\n    - [Attachments](https://support.getgrist.com/col-types/#attachment-columns), to include media or document files in records.\n    - Dates and times, toggles, and special numerics such as currency all have specialized editors and formatting options.\n    - [Conditional Formatting](https://support.getgrist.com/conditional-formatting/), letting you control the style of cells with formulas to draw attention to important information.\n  * Drag-and-drop dashboards.\n    - [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.\n    - [Summary tables](https://support.getgrist.com/summary-tables/) for summing and counting across groups.\n    - [Widget linking](https://support.getgrist.com/linking-widgets/) streamlines filtering and editing data.\n    Grist has a unique approach to visualization, where you can lay out and link distinct widgets to show together,\n    without cramming mixed material into a table.\n    - [Filter bar](https://support.getgrist.com/search-sort-filter/#filter-buttons) for quick slicing and dicing.\n  * [Incremental imports](https://support.getgrist.com/imports/#updating-existing-records).\n    - Import a CSV of the last three months activity from your bank...\n    - ...and import new activity a month later without fuss or duplication.\n  * [Native forms](https://support.getgrist.com/widget-form/). Create forms that feed directly into your spreadsheet without fuss.\n  * Integrations.\n    - 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/).\n    - Import/export to Google drive, Excel format, CSV.\n    - Link data with [custom widgets](https://support.getgrist.com/widget-custom/#_top), hosted externally.\n    - Configurable outgoing webhooks.\n  * [Many templates](https://templates.getgrist.com/) to get you started, from investment research to organizing treasure hunts.\n  * Access control options.\n    - (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)\n    - Share [individual documents](https://support.getgrist.com/sharing/), workspaces, or [team sites](https://support.getgrist.com/team-sharing/).\n    - Control access to [individual rows, columns, and tables](https://support.getgrist.com/access-rules/).\n    - Control access based on cell values and user attributes.\n  * Self-maintainable.\n    - Useful for intranet operation and specific compliance requirements.\n  * Sandboxing options for untrusted documents.\n    - On Linux or with Docker, you can enable [gVisor](https://github.com/google/gvisor) sandboxing at the individual document level.\n    - On macOS, you can use native sandboxing.\n    - On any OS, including Windows, you can use a wasm-based sandbox.\n  * Translated to many languages.\n  * `F1` key brings up some quick help. This used to go without saying, but in general Grist has good keyboard support.\n  * We post progress on [𝕏 or Twitter or whatever](https://twitter.com/getgrist) and publish [monthly newsletters](https://support.getgrist.com/newsletters/).\n\nIf you are curious about where Grist is heading, see [our roadmap](https://github.com/gristlabs/grist-core/projects/1), drop a question in [our forum](https://community.getgrist.com), or browse [our extensive documentation](https://support.getgrist.com).\n\n## Features not in `grist-core`\n\nIf you evaluate Grist by using the hosted version at [getgrist.com](https://getgrist.com), be aware that it includes some extensions to Grist that aren't present in `grist-core`. To be sure you're seeing exactly what is present in `grist-core`, you can run the [desktop app](https://github.com/gristlabs/grist-desktop), or use [`docker`](#using-grist). Here is a list of features you may see in Grist Labs' hosting or Enterprise offerings that are not in `grist-core`, in chronological order of creation. If self-hosting, you can get access to a free trial of all of them using the Enterprise toggle on the [Admin Panel](https://support.getgrist.com/admin-panel/).\n\n  * [GristConnect](https://support.getgrist.com/install/grist-connect/) (2022)\n    - Any site that has plugins for letting Discourse use its logins (such as WordPress) can also let Grist use its logins.\n    - GristConnect is a niche feature built for a specific client which you probably don't care about – `OIDC` and `SAML` support *is* part of `grist-core` and covers most authentication use cases.\n  * [Azure back-end for document storage](https://support.getgrist.com/install/cloud-storage/#azure) (2022)\n    - With `grist-core` you can store document versions in anything S3-compatible, which covers a lot of services, but not Azure specifically. The Azure back-end fills that gap.\n    - Unless you are a Microsoft shop you probably don't care about this.\n  * [Audit log streaming](https://support.getgrist.com/install/audit-log-streaming/) (2024)\n    - With `grist-core` a lot of useful information is logged, but not organized specifically with auditing in mind. Audit log streaming supplies that organization, and a UI for setting things up.\n    - Enterprises may care about this.\n  * [Advanced Admin Controls](https://support.getgrist.com/admin-controls/) (2025)\n    - This is a special page for a Grist installation administrator to monitor and edit user access to resources.\n    - It uses a special set of administrative endpoints not present on `grist-core`.\n    - If you're going to be running a large Grist installation, with employees coming and going, you may care about this.\n  * [Grist Assistant](https://support.getgrist.com/assistant/#assistant) (2025)\n    - An AI Formula Assistant - limited to working with formulas - is present in `grist-core`, but the newer Assistant can help with a wider range of tasks like building tables and dashboards and modifying data.\n    - If you have many users who need help building documents or working with data, you may care about this one.\n  * [Invite Notifications](https://support.getgrist.com/self-managed/#how-do-i-set-up-email-notifications) (2025)\n    - When a user is added to a document, or a workspace, or a site, with email notifications they will get emailed a link to access the resource.\n    - This link isn't special, with `grist-core` you can just send a link yourself or a colleague.\n    - For a big Grist installation with users who aren't in close communication, emails might be nice? Hard to guess if you'll care about this one.\n  * [Document Change and Comment Notifications](https://support.getgrist.com/document-settings/#notifications) (2025)\n    - You can achieve change notifications in `grist-core` using webhooks, but it is less convenient.\n    - People have been asking for this one for years. If you need an excuse to get your boss to pay for Grist, this might finally be the one that works?\n\n## Using Grist\n\nTo get the default version of `grist-core` running on your computer\nwith [Docker](https://www.docker.com/get-started), do:\n\n```sh\ndocker pull gristlabs/grist\ndocker run -p 8484:8484 -it gristlabs/grist\n```\n\nThen visit `http://localhost:8484` in your browser. You'll be able to create, edit, import,\nand export documents. To preserve your work across docker runs, share a directory as `/persist`:\n\n```sh\ndocker run -p 8484:8484 -v $PWD/persist:/persist -it gristlabs/grist\n```\n\nGet templates at [templates.getgrist.com](https://templates.getgrist.com) for payroll,\ninventory management, invoicing, D&D encounter tracking, and a lot\nmore, or use any document you've created on\n[docs.getgrist.com](https://docs.getgrist.com).\n\nIf you need to change the port Grist runs on, set a `PORT` variable, don't just change the\nport mapping:\n\n```\ndocker run --env PORT=9999 -p 9999:9999 -v $PWD/persist:/persist -it gristlabs/grist\n```\n\nTo enable gVisor sandboxing, set `--env GRIST_SANDBOX_FLAVOR=gvisor`.\nThis should work with default docker settings, but may not work in all\nenvironments.\n\nYou can find a lot more about configuring Grist, setting up authentication,\nand running it on a public server in our\n[Self-Managed Grist](https://support.getgrist.com/self-managed/) handbook.\n\n## Using Grist with OpenRouter for Model Agnostic and Claude Support\n\n(Instructions contributed by @lshalon)\n\nGrist's AI Formula Assistant can be configured to use OpenRouter instead of connecting directly to OpenAI, allowing you to access a wide range of AI models including Anthropic's Claude models. This isn't the only way to use Claude models, but it's a good option if you want to use Claude models with Grist or intend to use other cheaper, faster, or potentially newer models. That's because this configuration gives you more flexibility in choosing the AI model that works best for your formula generation needs.\nTo set up OpenRouter integration, configure the following environment variables:\n\n### Required: Set the endpoint to OpenRouter's API\n\n```\nASSISTANT_CHAT_COMPLETION_ENDPOINT=https://openrouter.ai/api/v1/chat/completions\n```\n\n### Required: Your OpenRouter API key\n\n```\nASSISTANT_API_KEY=your_openrouter_api_key_here\n```\n\nSign up for an OpenRouter API key at <https://openrouter.ai/>\n\n### Optional: Specify which model to use (examples below)\n\n```\nASSISTANT_MODEL=anthropic/claude-3.7-sonnet\n```\n\n### or other options like\n\n```\nASSISTANT_MODEL=deepseek/deepseek-r1-zero:free\n```\n\n```\nASSISTANT_MODEL=qwen/qwq-32b:free\n```\n\n```\nASSISTANT_MODEL=mistralai/mistral-saba\n```\n\n### Optional: Set a larger context model for fallback\n\n```\nASSISTANT_LONGER_CONTEXT_MODEL=anthropic/claude-3-opus-20240229\n```\n\nWith this configuration, Grist's AI Formula Assistant will route requests through OpenRouter to your specified model. This allows you to:\n\nAccess Anthropic's Claude models which excel at understanding context and generating accurate formulas\nSwitch between different AI models without changing your Grist configuration\nTake advantage of OpenRouter's routing capabilities to optimize for cost, speed, or quality\n\nYou can find the available models and their identifiers on the OpenRouter website.\nNote: Make sure not to set the OPENAI_API_KEY variable when using OpenRouter, as this would override the OpenRouter configuration.\n\n\n## Available Docker images\n\nThe default Docker image is `gristlabs/grist`. This contains all of\nthe standard Grist functionality, as well as extra source-available\ncode for enterprise customers taken from the\n[grist-ee](https://github.com/gristlabs/grist-ee) repository. This\nextra code is not under a free or open source license. By default,\nhowever, the code from the `grist-ee` repository is completely inert\nand inactive. This code becomes active only when enabled from the\nadministrator panel.\n\nIf you would rather use an image that contains exclusively free and\nopen source code, the `gristlabs/grist-oss` Docker image is available\nfor this purpose. It is by default functionally equivalent to the\n`gristlabs/grist` image.\n\n## The administrator panel\n\nYou can turn on a special admininistrator panel to inspect the status\nof your installation. Just visit `/admin` on your Grist server for\ninstructions. Since it is useful for the admin panel to be\navailable even when authentication isn't set up, you can give it a\nspecial access key by setting `GRIST_BOOT_KEY`.\n\n```\ndocker run -p 8484:8484 -e GRIST_BOOT_KEY=secret -it gristlabs/grist\n```\n\nThe boot page should then be available at\n`/admin?boot-key=<GRIST_BOOT_KEY>`. We are collecting probes for\ncommon problems there. If you hit a problem that isn't covered, it\nwould be great if you could add a probe for it in\n[BootProbes](https://github.com/gristlabs/grist-core/blob/main/app/server/lib/BootProbes.ts).\nYou may instead file an issue so someone else can add it.\n\n## Building from source\n\nTo build Grist from source, follow these steps:\n\n    yarn install\n    yarn install:python\n    yarn build\n    yarn start\n    # Grist will be available at http://localhost:8484/\n\nGrist formulas in documents will be run using Python executed directly on your\nmachine. You can configure sandboxing using a `GRIST_SANDBOX_FLAVOR`\nenvironment variable.\n\n * On macOS, `export GRIST_SANDBOX_FLAVOR=macSandboxExec`\n   uses the native `sandbox-exec` command for sandboxing.\n * On Linux with [gVisor's runsc](https://github.com/google/gvisor)\n   installed, `export GRIST_SANDBOX_FLAVOR=gvisor` is an option.\n * On any OS including Windows, `export GRIST_SANDBOX_FLAVOR=pyodide` is available.\n\nThese sandboxing methods have been written for our own use at Grist Labs and\nmay need tweaking to work in your own environment - pull requests\nvery welcome here!\n\nIf you wish to include Grist Labs enterprise extensions in your build,\nthe steps are as follows. Note that this will add non-OSS code to your\nbuild. It will also place a directory called `node_modules` one level\nup, at the same level as the Grist repo. If that is a problem for you,\njust move everything into a subdirectory first.\n\n    yarn install\n    yarn install:ee\n    yarn install:python\n    yarn build\n    yarn start\n    # Grist will be available at http://localhost:8484/\n\nThe enterprise code will by default not be used. You need to explicitly enable\nit in the [Admin Panel](https://support.getgrist.com/self-managed/#how-do-i-enable-grist-enterprise).\n\n## Logins\n\nLike git, Grist has features to track document revision history. So for full operation,\nGrist expects to know who the user modifying a document is. Until it does, it operates\nin a limited anonymous mode. To get you going, the docker image is configured so that\nwhen you click on the \"sign in\" button Grist will attribute your work to `you@example.com`.\nChange this by setting `GRIST_DEFAULT_EMAIL`:\n\n```\ndocker run --env GRIST_DEFAULT_EMAIL=my@email -p 8484:8484 -v $PWD/persist:/persist -it gristlabs/grist\n```\n\nYou can change your name in `Profile Settings` in\nthe [User Menu](https://support.getgrist.com/glossary/#user-menu).\n\nFor multi-user operation, or if you wish to access Grist across the\npublic internet, you'll want to connect it to your own Single Sign-On service.\nThere are a lot of ways to do this, including [SAML and forward authentication](https://support.getgrist.com/self-managed/#how-do-i-set-up-authentication).\nGrist has been tested with [Authentik](https://goauthentik.io/), [Auth0](https://auth0.com/),\nand Google/Microsoft sign-ins via [Dex](https://dexidp.io/).\n\n## Translations\n\nWe use [Weblate](https://hosted.weblate.org/engage/grist/) to manage translations.\nThanks to everyone who is pitching in. Thanks especially to the ANCT developers who\ndid the hard work of making a good chunk of the application localizable. Merci beaucoup !\n\n<a href=\"https://hosted.weblate.org/engage/grist/\">\n<img src=\"https://hosted.weblate.org/widgets/grist/-/open-graph.png\" alt=\"Translation status\" width=480 />\n</a>\n\n[![Translation detail](https://hosted.weblate.org/widgets/grist/-/multi-green.svg)](https://hosted.weblate.org/engage/grist/)\n\n## Why free and open source software\n\nThis repository, `grist-core`, is maintained by Grist Labs. Our flagship product available at [getgrist.com](https://www.getgrist.com) is built from the code you see here, combined with business-specific software designed to scale to many users, handle billing, etc.\n\nGrist Labs is an open-core company. We offer Grist hosting as a service, with free and paid plans. We also develop and sell features related to Grist using a proprietary license, targeted at the needs of enterprises with large self-managed installations.\n\nWe see data portability and autonomy as a key value, and `grist-core` is an essential part of that. We are committed to maintaining and improving the `grist-core` codebase, and to be thoughtful about how proprietary offerings impact data portability and autonomy.\n\nBy opening its source code and offering an [OSI](https://opensource.org/)-approved free license, Grist benefits its users:\n\n- **Developer community.** The freedom to examine source code, make bug fixes, and develop\n  new features is a big deal for a general-purpose spreadsheet-like product, where there is a\n  very long tail of features vital to someone somewhere.\n- **Increased trust.** Because anyone can examine the source code, &ldquo;security by obscurity&rdquo; is not\n  an option. Vulnerabilities in the code can be found by others and reported before they cause\n  damage.\n- **Independence.** Grist is available to you regardless of the fortunes of the Grist Labs business,\n  since it is open source and can be self-hosted. Using our hosted solution is convenient, but you\n  are not locked in.\n- **Price flexibility.** If you are low on funds but have time to invest, self-hosting is a great\n  option to have. And DIY users may have the technical savvy and motivation to delve in and make improvements,\n  which can benefit all users of Grist.\n- **Extensibility.** For developers, having the source open makes it easier to build extensions (such as [Custom Widgets](https://support.getgrist.com/widget-custom/)). You can more easily include Grist in your pipeline. And if a feature is missing, you can just take the source code and build on top of it.\n\nFor more on Grist Labs' history and principles, see our [About Us](https://www.getgrist.com/about/) page.\n\n## Sponsors\n\n<p align=\"center\">\n  <a href=\"https://www.dotphoton.com/\">\n    <img width=\"11%\" src=\"https://user-images.githubusercontent.com/11277225/228914729-ae581352-b37a-4ca8-b220-b1463dd1ade0.png\" />\n  </a>\n</p>\n\n## Reviews\n\n * [Grist on ProductHunt](https://www.producthunt.com/posts/grist-2)\n * [Grist on AppSumo](https://appsumo.com/products/grist/) (life-time deal is sold out)\n * [Capterra](https://www.capterra.com/p/232821/Grist/#reviews), [G2](https://www.g2.com/products/grist/reviews), [TrustRadius](https://www.trustradius.com/products/grist/reviews)\n\n## Environment variables\n\nGrist can be configured in many ways. Here are the main environment variables it is sensitive to:\n\n| Variable | Purpose |\n| -------- | ------- |\n| ALLOWED_WEBHOOK_DOMAINS | comma-separated list of permitted domains to use in webhooks (e.g. webhook.site,zapier.com). You can set this to `*` to allow all domains, but if doing so, we recommend using a carefully locked-down proxy (see `GRIST_PROXY_FOR_UNTRUSTED_URLS`) if you do not entirely trust users. Otherwise services on your internal network may become vulnerable to manipulation. |\n| APP_DOC_URL | doc worker url, set when starting an individual doc worker (other servers will find doc worker urls via redis) |\n| APP_DOC_INTERNAL_URL | like `APP_DOC_URL` but used by the home server to reach the server using an internal domain name resolution (like in a docker environment). It only makes sense to define this value in the doc worker. Defaults to `APP_DOC_URL`. |\n| APP_HOME_URL | url prefix for home api (home and doc servers need this) |\n| APP_HOME_INTERNAL_URL | like `APP_HOME_URL` but used by the home and the doc servers to reach any home workers using an internal domain name resolution (like in a docker environment). Defaults to `APP_HOME_URL` |\n| APP_STATIC_URL | url prefix for static resources |\n| APP_STATIC_INCLUDE_CUSTOM_CSS | set to \"true\" to include custom.css (from APP_STATIC_URL) in static pages |\n| APP_UNTRUSTED_URL | URL at which to serve/expect plugin content. |\n| GRIST_ACTION_HISTORY_MAX_ROWS | Maximum number of rows allowed in ActionHistory before pruning (up to a 1.25 grace factor). Defaults to 1000. ⚠️ A too low value may make the \"[Work on a copy](https://support.getgrist.com/newsletters/2021-06/#work-on-a-copy)\" feature [malfunction](https://github.com/gristlabs/grist-core/issues/1121#issuecomment-2248112023) |\n| GRIST_ACTION_HISTORY_MAX_BYTES | Maximum number of rows allowed in ActionHistory before pruning (up to a 1.25 grace factor). Defaults to 1Gb. ⚠️ A too low value may make the \"[Work on a copy](https://support.getgrist.com/newsletters/2021-06/#work-on-a-copy)\" feature [malfunction](https://github.com/gristlabs/grist-core/issues/1121#issuecomment-2248112023) |\n| GRIST_ADAPT_DOMAIN | set to \"true\" to support multiple base domains (careful, host header should be trustworthy) |\n| GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING | Whether Grist is allowed to automatically check if a newer Grist version is available. Defaults to \"true\" on the default `grist` and `grist-ee` Docker images. Defaults false in `grist-oss` and everywhere else. |\n| GRIST_ALLOW_DEPRECATED_BARE_ORG_DELETE | If set, the deprecated DELETE /api/orgs/:orgId endpoint is available. |\n| GRIST_APP_ROOT | directory containing Grist sandbox and assets (specifically the sandbox and static subdirectories). |\n| GRIST_ATTACHMENT_THRESHOLD_MB | attachment storage limit per document beyond which Grist will recommend external storage (if available). Defaults to 50MB. |\n| GRIST_BACKUP_DELAY_SECS | wait this long after a doc change before making a backup |\n| GRIST_BOOT_KEY | if set, offer diagnostics at /boot/GRIST_BOOT_KEY |\n| GRIST_BROADCAST_TIMEOUT_MS | Set the maximum time a web client has to accept a broadcast message about a document before being disconnected (default: 1 minute). |\n| GRIST_DATA_DIR | Directory in which to store documents. Defaults to `docs/` relative to the Grist application directory. In Grist's default Docker image, its default value is /persist/docs so that it will be used as a mounted volume. |\n| GRIST_DEFAULT_EMAIL | if set, login as this user if no other credentials presented |\n| GRIST_DEFAULT_PRODUCT | if set, this controls enabled features and limits of new sites. See names of PRODUCTS in Product.ts. |\n| GRIST_DEFAULT_LOCALE | Locale to use as fallback when Grist cannot honour the browser locale. |\n| GRIST_DOMAIN | in hosted Grist, Grist is served from subdomains of this domain.  Defaults to \"getgrist.com\". |\n| GRIST_EXPERIMENTAL_PLUGINS | enables experimental plugins |\n| GRIST_EXTERNAL_ATTACHMENTS_MODE | required to enable external storage for attachments. Set to \"snapshots\" to enable external storage. Default value is \"none\". Note that when enabled, a [snapshot storage has to be configured](https://support.getgrist.com/self-managed/#how-do-i-set-up-snapshots) as well. |\n| GRIST_ENABLE_SERVICE_ACCOUNTS | enables the `service accounts` feature. This feature allows users to create special service accounts that they can manage and to whom they can grant restricted access to chosen resources. Useful as a way to get fine-grained api keys for use with third party automations. Unset by default |\n| GRIST_ENABLE_REQUEST_FUNCTION | enables the REQUEST function. This function performs HTTP requests in a similar way to `requests.request`. This function presents a significant security risk, since it can let users call internal endpoints when Grist is available publicly. This function can also cause performance issues. Unset by default. |\n| GRIST_HEADERS_TIMEOUT_MS | if set, override nodes's server.headersTimeout flag. |\n| GRIST_HIDE_UI_ELEMENTS | comma-separated list of UI features to disable. Allowed names of parts: `helpCenter`, `billing`, `templates`, `createSite`, `multiSite`, `multiAccounts`, `importFromAirtable`, `sendToDrive`, `tutorials`, `supportGrist`, `themes`. If a part also exists in GRIST_UI_FEATURES, it will still be disabled. |\n| GRIST_HOST | hostname to use when listening on a port. |\n| GRIST_PROXY_FOR_UNTRUSTED_URLS | Full URL of proxy for delivering webhook payloads. Default value is `direct` for delivering payloads without proxying. |\n| HTTPS_PROXY or https_proxy | Full URL of reverse web proxy (corporate proxy) for fetching the custom widgets repository or the OIDC config from the issuer. |\n| GRIST_ID_PREFIX | for subdomains of form o-*, expect or produce o-${GRIST_ID_PREFIX}*. |\n| GRIST_IGNORE_SESSION | if set, Grist will not use a session for authentication. |\n| GRIST_INCLUDE_CUSTOM_SCRIPT_URL | if set, will load the referenced URL in a `<script>` tag on all app pages. |\n| GRIST_INST_DIR | path to Grist instance configuration files, for Grist server. |\n| GRIST_KEEP_ALIVE_TIMEOUT_MS | if set, override nodes's server.keepAliveTimeout flag. |\n| GRIST_LIST_PUBLIC_SITES | if set to true, sites shared with the public will be listed for anonymous users. Defaults to false. |\n| GRIST_MANAGED_WORKERS | if set, Grist can assume that if a url targeted at a doc worker returns a 404, that worker is gone |\n| GRIST_MAX_NEW_USER_INVITES_PER_ORG | if set, limits the number of invites to new users per org. Once exceeded, additional invites are blocked until invited users log in for the first time or are uninvited\n| GRIST_MAX_BILLING_MANAGERS_PER_ORG | if set, limits the number of billing managers per org |\n| GRIST_MAX_PARALLEL_REQUESTS_PER_DOC| max number of concurrent API requests allowed per document (default is 10, set to 0 for unlimited) |\n| GRIST_MAX_UPLOAD_ATTACHMENT_MB | max allowed size for attachments (0 or empty for unlimited). |\n| GRIST_MAX_UPLOAD_IMPORT_MB | max allowed size for imports (except .grist files) (0 or empty for unlimited). |\n| GRIST_OFFER_ALL_LANGUAGES | if set, all translated langauages are offered to the user (by default, only languages with a special 'good enough' key set are offered to user). |\n| GRIST_ORG_IN_PATH | if true, encode org in path rather than domain |\n| GRIST_PAGE_TITLE_SUFFIX | a string to append to the end of the `<title>` in HTML documents. Defaults to `\" - Grist\"`. Set to `_blank` for no suffix at all. |\n| ~GRIST_PROXY_AUTH_HEADER~ | Deprecated, and interpreted as a synonym for GRIST_FORWARD_AUTH_HEADER. |\n| GRIST_REQUEST_TIMEOUT_MS | if set, override nodes's server.requestTimeout flag. |\n| GRIST_ROUTER_URL | optional url for an api that allows servers to be (un)registered with a load balancer |\n| GRIST_SERVE_SAME_ORIGIN | set to \"true\" to access home server and doc workers on the same protocol-host-port as the top-level page, same as for custom domains (careful, host header should be trustworthy) |\n| GRIST_SERVERS | the types of server to setup. Comma separated values which may contain \"home\", \"docs\", static\" and/or \"app\". Defaults to \"home,docs,static\". |\n| GRIST_SESSION_COOKIE | if set, overrides the name of Grist's cookie |\n| GRIST_SESSION_DOMAIN | if set, associates the cookie with the given domain - otherwise defaults to GRIST_DOMAIN |\n| GRIST_SESSION_SECRET | a key used to encode sessions |\n| GRIST_SKIP_BUNDLED_WIDGETS | if set, Grist will ignore any bundled widgets included via NPM packages. |\n| GRIST_SQLITE_MODE | if set to `wal`, use SQLite in [WAL mode](https://www.sqlite.org/wal.html), if set to `sync`, use SQLite with [SYNCHRONOUS=full](https://www.sqlite.org/pragma.html#pragma_synchronous)\n| GRIST_ANON_PLAYGROUND | When set to `false` deny anonymous users access to the home page (but documents can still be shared to anonymous users). Defaults to `true`, unless GRIST_ORG_CREATION_ANYONE is `false`. |\n| GRIST_FORCE_LOGIN | Setting it to `true` is similar to setting `GRIST_ANON_PLAYGROUND: false` but it blocks any anonymous access (thus any document shared publicly actually requires the users to be authenticated before consulting them) |\n| GRIST_PERSONAL_ORGS | When set to `false` prevent new personal orgs from being created when a user signs up. Defaults to `true`, unless GRIST_ORG_CREATION_ANYONE is `false`. |\n| GRIST_ORG_CREATION_ANYONE | When set to `false`, prevent new team orgs from being created by non-admin users. Sets default values of `GRIST_ANON_PLAYGROUND` and `GRIST_PERSONAL_ORGS` to `false`. Defaults to `true`. |\n| GRIST_SINGLE_ORG | set to an org \"domain\" to pin client to that org |\n| GRIST_TEMPLATE_ORG | set to an org \"domain\" to show public docs from that org |\n| GRIST_HELP_CENTER | set the help center link ref |\n| GRIST_TERMS_OF_SERVICE_URL | if set, adds terms of service link |\n| FREE_COACHING_CALL_URL | set the link to the human help (example: email adress or meeting scheduling tool) |\n| GRIST_CONTACT_SUPPORT_URL | set the link to contact support on error pages (example: email adress or online form) |\n| GRIST_ONBOARDING_VIDEO_ID | set the ID of the YouTube video shown on the homepage and during onboarding |\n| GRIST_CUSTOM_COMMON_URLS | overwrite the default commons URLs. Its value is expected to be a JSON object and a subset of the [ICommonUrls interface](./app/common/ICommonUrls.ts). |\n| GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default) |\n| GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way. |\n| GRIST_OPEN_GRAPH_PREVIEW_IMAGE | the URL of the preview image when sharing the link on websites like social medias or chat applications. |\n| GRIST_TELEMETRY_LEVEL | the telemetry level. Can be set to: `off` (default), `limited`, or `full`. |\n| GRIST_THROTTLE_CPU | if set, CPU throttling is enabled |\n| GRIST_TRUST_PLUGINS | if set, plugins are expect to be served from the same host as the rest of the Grist app, rather than from a distinct host. Ordinarily, plugins are served from a distinct host so that the cookies used by the Grist app are not automatically available to them. Enable this only if you understand the security implications. |\n| GRIST_USER_ROOT | an extra path to look for plugins in - Grist will scan for plugins in `$GRIST_USER_ROOT/plugins`. |\n| GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter`, `billing`, `templates`, `createSite`, `multiSite`, `multiAccounts`, `importFromAirtable`, `sendToDrive`, `tutorials`, `supportGrist`, `themes`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled. |\n| GRIST_UNTRUSTED_PORT | if set, plugins will be served from the given port. This is an alternative to setting APP_UNTRUSTED_URL. |\n| 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 |\n| GRIST_LOG_HTTP | When set to `true`, log HTTP requests and responses information. Defaults to `false`. |\n| 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`. |\n| GRIST_LOG_AS_JSON | When this variable is set to `true` or a truthy value, output log lines in JSON as opposed to a plain text format. |\n| GRIST_LOG_API_DETAILS | When this variable is set to `true` or a truthy value, log the API calls details. |\n| COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to \"none\" to make it a session cookie |\n| HOME_PORT | port number to listen on for REST API server; if set to \"share\", add API endpoints to regular grist port. |\n| PORT | port number to listen on for Grist server |\n| REDIS_URL | optional redis server for browser sessions and db query caching |\n| GRIST_SNAPSHOT_TIME_CAP | optional. Define the caps for tracking buckets. Usage: {\"hour\": 25, \"day\": 32, \"isoWeek\": 12, \"month\": 96, \"year\": 1000} |\n| GRIST_SNAPSHOT_KEEP | optional. Number of recent snapshots to retain unconditionally for a document, regardless of when they were made |\n| 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 ⚠️. |\n| GRIST_ENABLE_SCIM | optional. If set, enable the [SCIM API Endpoint](https://support.getgrist.com/install/scim/) (experimental) |\n| GRIST_LOGIN_SYSTEM_TYPE | optional. If set, explicitly selects which login system to use. Valid values: `saml`, `oidc`, `forward-auth`, `minimal`. If not set, Grist will automatically detect and use the first configured login system. |\n| GRIST_OIDC_... | optional. Environment variables used to configure OpenID authentification. See [OpenID Connect](https://support.getgrist.com/install/oidc/) documentation for full related list of environment variables. |\n| GRIST_SAML_... | optional. Environment variables used to configure SAML authentification. See [SAML](https://support.getgrist.com/install/saml/) documentation for full related list of environment variables. |\n| GRIST_IDP_EXTRA_PROPS | optional. If set, defines which extra fields returned by your identity provider will be stored in the users table of the home database (in the `options.ssoExtraInfo` object). Usage: 'onekey,anotherkey'. |\n| GRIST_FEATURE_FORM_FRAMING | optional. Configures a border around a rendered form that is added for security reasons; Can be set to: `border` or `minimal`. Defaults to `border`. |\n| GRIST_TRUTHY_VALUES | optional. Comma-separated list of extra words that should be considered as truthy by the data engine beyond english defaults. Ex: \"oui,ja,si\" |\n| GRIST_FALSY_VALUES | optional. Comma-separated list of extra words that should be considered as falsy by the data engine beyond english defaults. Ex: \"non,nein,no\" |\n| GRIST_ENABLE_USER_PRESENCE | optional, enabled by default. If set to 'false', disables all user presence features. |\n#### AI Formula Assistant related variables (all optional):\n\nVariable | Purpose\n-------- | -------\nASSISTANT_API_KEY   | optional. An API key to pass when making requests to an external AI conversational endpoint.\nASSISTANT_CHAT_COMPLETION_ENDPOINT  | optional. A chat-completion style endpoint to call. Not needed if OpenAI is being used.\nASSISTANT_MODEL     | optional. If set, this string is passed along in calls to the AI conversational endpoint.\nASSISTANT_LONGER_CONTEXT_MODEL     | optional. If set, requests that fail because of a context length limitation will be retried with this model set.\nOPENAI_API_KEY      | optional. Synonym for ASSISTANT_API_KEY that assumes an OpenAI endpoint is being used. Sign up for an account on OpenAI and then generate a secret key [here](https://platform.openai.com/account/api-keys).\n\nAt the time of writing, the AI Assistant is known to function against OpenAI chat completion endpoints (those ending in `/v1/chat/completions`).\nIt is also known to function against the chat completion endpoint provided by <a href=\"https://github.com/abetlen/llama-cpp-python\">llama-cpp-python</a> and by [LM Studio](https://lmstudio.ai/). For useful results, the LLM should be on par with GPT 3.5 or above.\n\n#### Sandbox related variables:\n\nVariable | Purpose | Sandbox |\n-------- | ------- | ------- |\nGRIST_SANDBOX_FLAVOR | can be gvisor, pynbox, unsandboxed, docker, or macSandboxExec. If set, forces Grist to use the specified kind of sandbox. | N/A |\nGRIST_SANDBOX | a program or image name to run as the sandbox. See NSandbox.ts for nerdy details. | N/A |\nGVISOR_LIMIT_NPROC | the number of extant processes the sandbox is allowed to spawn when running on Linux. Defaults to 8. | GVisor |\nGVISOR_LIMIT_MEMORY | the maximum size of the sandboxed process's virtual memory (in bytes). No limit by default. | GVisor |\n\n#### Forward authentication variables:\n\nVariable | Purpose\n-------- | -------\nGRIST_FORWARD_AUTH_HEADER | if set, trust the specified header (e.g. \"x-forwarded-user\") to contain authorized user emails, and enable \"forward auth\" logins.\nGRIST_FORWARD_AUTH_LOGIN_PATH | if GRIST_FORWARD_AUTH_HEADER is set, Grist will listen at this path for logins. Defaults to `/auth/login`.\nGRIST_FORWARD_AUTH_LOGOUT_PATH | if GRIST_FORWARD_AUTH_HEADER is set, Grist will forward to this path when user logs out.\n\nForward authentication supports two modes, distinguished by `GRIST_IGNORE_SESSION`:\n\n1. With sessions, and forward-auth on login endpoints.\n\n   For example, using traefik reverse proxy with\n   [traefik-forward-auth](https://github.com/thomseddon/traefik-forward-auth) middleware:\n\n   - `GRIST_IGNORE_SESSION`: do NOT set, or set to a falsy value.\n   - Make sure your reverse proxy applies the forward auth middleware to\n     `GRIST_FORWARD_AUTH_LOGIN_PATH` and `GRIST_FORWARD_AUTH_LOGOUT_PATH`.\n   - If you want to allow anonymous access in some cases, make sure all other paths are free of\n     the forward auth middleware. Grist will trigger it as needed by redirecting to\n     `GRIST_FORWARD_AUTH_LOGIN_PATH`. Once the user is logged in, Grist will use sessions to\n     identify the user until logout.\n\n2. With no sessions, and forward-auth on all endpoints.\n\n   For example, using HTTP Basic Auth and server configuration that sets the header (specified in\n   `GRIST_FORWARD_AUTH_HEADER`) to the logged-in user.\n\n  - `GRIST_IGNORE_SESSION`: set to `true`. Grist sessions will not be used.\n  - Make sure your reverse proxy sets the header you specified for all requests that may need\n    login information. It is imperative that this header cannot be spoofed by the user, since\n    Grist will trust whatever is in it.\n\nWhen using forward authentication, you may wish to also set the following variables:\n\n  * `GRIST_FORCE_LOGIN=true` to disable anonymous access.\n\n#### Plugins:\n\nGrist has a plugin system, used internally. One useful thing you can\ndo with it is include custom widgets in a build of Grist. Custom widgets\nare usually made available just by setting `GRIST_WIDGET_LIST_URL`,\nbut that has the downside of being an external dependency, which can\nbe awkward for offline use or for archiving. Plugins offer an alternative.\n\nTo \"bundle\" custom widgets as a plugin:\n\n * Add a subdirectory of `plugins`, e.g. `plugins/my-widgets`.\n   Alternatively, you can set the `GRIST_USER_ROOT` environment\n   variable to any path you want, and then create `plugins/my-widgets`\n   within that.\n * Add a `manifest.yml` file in that subdirectory that looks like\n   this:\n\n```\nname: My Widgets\ncomponents:\n  widgets: widgets.json\n```\n\n * The `widgets.json` file should be in the format produced by\n   the [grist-widget](https://github.com/gristlabs/grist-widget)\n   repository, and should be placed in the same directory as\n   `manifest.yml`. Any material in `plugins/my-widgets`\n   will be served by Grist, and relative URLs can be used in\n   `widgets.json`.\n * Once all files are in place, restart Grist. Your widgets should\n   now be available in the custom widgets dropdown, along with\n   any others from `GRIST_WIDGET_LIST_URL`.\n * If you like, you can add multiple plugin subdirectories, with\n   multiple sets of widgets, and they'll all be made available.\n\n#### Google Drive integrations:\n\nVariable | Purpose\n-------- | -------\nGOOGLE_CLIENT_ID    | set to the Google Client Id to be used with Google API client\nGOOGLE_CLIENT_SECRET| set to the Google Client Secret to be used with Google API client\nGOOGLE_API_KEY      | set to the Google API Key to be used with Google API client (accessing public files)\nGOOGLE_DRIVE_SCOPE  | set to the scope requested for Google Drive integration (defaults to drive.file)\n\n#### Database variables:\n\nVariable | Purpose\n-------- | -------\nTYPEORM_DATABASE | database filename for sqlite or database name for other db types\nTYPEORM_HOST     | host for db\nTYPEORM_LOGGING  | set to 'true' to see all sql queries\nTYPEORM_PASSWORD | password to use\nTYPEORM_PORT     | port number for db if not the default for that db type\nTYPEORM_TYPE     | set to 'sqlite' or 'postgres'\nTYPEORM_USERNAME | username to connect as\nTYPEORM_EXTRA    | any other properties to pass to TypeORM in JSON format\n\n#### Docker-only variables:\n\nVariable | Purpose\n---------|--------\nGRIST_DOCKER_USER  | optional. When the container runs as the root user, this is the user the Grist services run as. Overrides the default.\nGRIST_DOCKER_GROUP | optional. When the container runs as the root user, this is the group the Grist services run as. Overrides the default.\n\n#### Testing:\n\nVariable | Purpose\n-------- | -------\nGRIST_TESTING_SOCKET    | a socket used for out-of-channel communication during tests only.\nGRIST_TEST_FORCE_LIGHT_MODE | if set, Grist will use light mode even if system preference is dark. Some tests just assume light mode.\nGRIST_TEST_HTTPS_OFFSET | if set, adds https ports at the specified offset.  This is useful in testing.\nGRIST_TEST_SSL_CERT     | if set, contains filename of SSL certificate.\nGRIST_TEST_SSL_KEY      | if set, contains filename of SSL private key.\nGRIST_TEST_LOGIN        | allow fake unauthenticated test logins (suitable for dev environment only).\nGRIST_TEST_ROUTER       | if set, then the home server will serve a mock version of router api at /test/router\nGREP_TESTS              | pattern for selecting specific tests to run (e.g. `env GREP_TESTS=ActionLog yarn test`).\n\n## Tests\n\nTests are run automatically as part of CI when a PR is opened. However, it can be helpful to run tests locally\nbefore pushing your changes to GitHub. First, you'll want to make sure you've installed all dependencies:\n\n```\nyarn install\nyarn install:python\n```\n\nThen, you can run the main test suite like so:\n\n```\nyarn test\n```\n\nPython tests may also be run locally. (Note: currently requires Python 3.10 - 3.11.)\n\n```\nyarn test:python\n```\n\nFor running specific tests, you can specify a pattern with the `GREP_TESTS` variable:\n\n```\nenv GREP_TESTS=ChoiceList yarn test\nenv GREP_TESTS=summary yarn test:python\n```\n\n## License\n\nThis repository, `grist-core`, is released under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0), which is an [OSI](https://opensource.org/)-approved free software license. See LICENSE.txt and NOTICE.txt for more information.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\n| Version  | Supported          |\n| -------  | ------------------ |\n| >= 1.3.2 | :white_check_mark: |\n| < 1.3.2  | upgrade required   |\n\n## Reporting a Vulnerability\n\n1. **Contact us** by sending an email to **[security@getgrist.com](mailto:security@getgrist.com)** with the following information:\n   - A description of the vulnerability.\n   - Steps to reproduce the issue.\n   - Any known impacts or suggested fixes.\n\n2. **Our response:**  \n   - We will acknowledge your report within **three working days**.\n   - We will collaborate with you to verify and address the issue.\n   - Once resolved, we’ll release a patch and notify users.\n"
  },
  {
    "path": "app/cli.sh",
    "content": "#!/usr/bin/env bash\n\nNODE_PATH=_build:_build/stubs:_build/ext node _build/app/server/companion.js \"$@\"\n"
  },
  {
    "path": "app/client/DefaultHooks.ts",
    "content": "import { UrlTweaks } from \"app/common/gristUrls\";\n\nimport { IAttrObj } from \"grainjs\";\n\nexport interface IHooks {\n  iframeAttributes?: Record<string, any>,\n  fetch?: typeof fetch,\n  baseURI?: string,\n  urlTweaks?: UrlTweaks,\n\n  /**\n   * Modify the attributes of an <a> dom element.\n   * Convenient in grist-static to directly hook up a\n   * download link with the function that provides the data.\n   */\n  maybeModifyLinkAttrs(attrs: IAttrObj): IAttrObj;\n}\n\nexport const defaultHooks: IHooks = {\n  maybeModifyLinkAttrs(attrs: IAttrObj) {\n    return attrs;\n  },\n};\n"
  },
  {
    "path": "app/client/Hooks.ts",
    "content": "import { defaultHooks } from \"app/client/DefaultHooks\";\n\nexport const hooks = defaultHooks;\n"
  },
  {
    "path": "app/client/aclui/ACLColumnList.ts",
    "content": "/**\n * Implements a widget for showing and editing a list of colIds. It offers a select dropdown to\n * add a new column, and allows removing already-added columns.\n */\nimport { aclSelect, cssSelect } from \"app/client/aclui/ACLSelect\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\n\nimport { Computed, dom, Observable, styled } from \"grainjs\";\n\nexport function aclColumnList(colIds: Observable<string[]>, validColIds: string[]) {\n  // Define some helpers functions.\n  function removeColId(colId: string) {\n    colIds.set(colIds.get().filter(c => (c !== colId)));\n  }\n  function addColId(colId: string) {\n    colIds.set([...colIds.get(), colId]);\n    selectBox.focus();\n  }\n  function onFocus(ev: FocusEvent) {\n    editing.set(true);\n    // Focus the select box, except when focus just moved from it, e.g. after Shift-Tab.\n    if (ev.relatedTarget !== selectBox) {\n      selectBox.focus();\n    }\n  }\n  function onBlur() {\n    if (!selectBox.matches(\".weasel-popup-open\") && colIds.get().length > 0) {\n      editing.set(false);\n    }\n  }\n\n  // The observable for the selected element is a Computed, with a callback for being set, which\n  // adds the selected colId to the list.\n  const newColId = Computed.create(null, use => \"\")\n    .onWrite((value) => { setTimeout(() => addColId(value), 0); });\n\n  // We don't allow adding the same column twice, so for the select dropdown build a list of\n  // unused colIds.\n  const unusedColIds = Computed.create(null, colIds, (use, _colIds) => {\n    const used = new Set(_colIds);\n    return validColIds.filter(c => !used.has(c));\n  });\n\n  // The \"editing\" observable determines which of two states is active: to show or to edit.\n  const editing = Observable.create(null, !colIds.get().length);\n\n  let selectBox: HTMLElement;\n  return cssColListWidget({ tabIndex: \"0\" },\n    dom.autoDispose(unusedColIds),\n    cssColListWidget.cls(\"-editing\", editing),\n    dom.on(\"focus\", onFocus),\n    dom.forEach(colIds, colId =>\n      cssColItem(\n        cssColId(colId),\n        cssColItemIcon(icon(\"CrossSmall\"),\n          dom.on(\"click\", () => removeColId(colId)),\n          testId(\"acl-col-remove\"),\n        ),\n        testId(\"acl-column\"),\n      ),\n    ),\n    cssNewColItem(\n      dom.update(\n        selectBox = aclSelect(newColId, unusedColIds, { defaultLabel: \"[Add Column]\" }),\n        cssSelect.cls(\"-active\"),\n        dom.on(\"blur\", onBlur),\n        dom.onKeyDown({ Escape: onBlur }),\n        // If starting out in edit mode, focus the select box.\n        (editing.get() ? (elem) => { setTimeout(() => elem.focus(), 0); } : null),\n      ),\n    ),\n  );\n}\n\nconst cssColListWidget = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  position: relative;\n  outline: none;\n  margin: 6px 8px;\n  cursor: pointer;\n  border-radius: 4px;\n\n  border: 1px solid transparent;\n  &:not(&-editing):hover {\n    border: 1px solid ${theme.accessRulesColumnListBorder};\n  }\n`);\n\nconst cssColItem = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  border-radius: 3px;\n  padding-left: 6px;\n  padding-right: 2px;\n  color: ${theme.accessRulesColumnItemFg};\n\n  .${cssColListWidget.className}-editing & {\n    background-color: ${theme.accessRulesColumnItemBg};\n  }\n`);\n\nconst cssColId = styled(\"div\", `\n  flex: auto;\n  height: 24px;\n  line-height: 24px;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`);\n\nconst cssNewColItem = styled(\"div\", `\n  margin-top: 2px;\n  display: none;\n  .${cssColListWidget.className}-editing & {\n    display: flex;\n  }\n`);\n\nconst cssColItemIcon = styled(\"div\", `\n  flex: none;\n  height: 16px;\n  width: 16px;\n  border-radius: 16px;\n  display: none;\n  cursor: default;\n  --icon-color: ${theme.accessRulesColumnItemIconFg};\n  &:hover {\n    background-color: ${theme.accessRulesColumnItemIconHoverBg};\n    --icon-color: ${theme.accessRulesColumnItemIconHoverFg};\n  }\n  .${cssColListWidget.className}-editing & {\n    display: flex;\n  }\n`);\n"
  },
  {
    "path": "app/client/aclui/ACLFormulaEditor.ts",
    "content": "import { setupAceEditorCompletions } from \"app/client/components/AceEditorCompletions\";\nimport { expandAndFilterSuggestions, ISuggestionWithSubAttrs } from \"app/client/lib/Suggestions\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { gristThemeObs } from \"app/client/ui2018/theme\";\nimport { ISuggestionWithValue } from \"app/common/ActiveDocAPI\";\nimport { Theme } from \"app/common/ThemePrefs\";\n\nimport ace, { Ace } from \"ace-builds\";\nimport { dom, DomArg, Observable, styled } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\n\nexport interface ACLFormulaOptions {\n  initialValue: string;\n  readOnly: boolean;\n  placeholder: DomArg;\n  setValue: (value: string) => void;\n  getSuggestions: () => ISuggestionWithSubAttrs[];\n  customiseEditor?: (editor: Ace.Editor) => void;\n}\n\nexport function aclFormulaEditor(options: ACLFormulaOptions) {\n  // Create an element and an editor within it.\n  const editorElem = dom(\"div\");\n  const editor: Ace.Editor = ace.edit(editorElem);\n\n  // Set various editor options.\n  function setAceTheme(newTheme: Theme) {\n    const { appearance } = newTheme;\n    const aceTheme = appearance === \"dark\" ? \"dracula\" : \"chrome\";\n    editor.setTheme(`ace/theme/${aceTheme}`);\n  }\n  setAceTheme(gristThemeObs().get());\n  const themeListener = gristThemeObs().addListener((newTheme) => {\n    setAceTheme(newTheme);\n  });\n  // ACE editor resizes automatically when maxLines is set.\n  editor.setOptions({ enableLiveAutocompletion: true, maxLines: 10 });\n  editor.renderer.setShowGutter(false);       // Default line numbers to hidden\n  editor.renderer.setPadding(5);\n  editor.renderer.setScrollMargin(4, 4, 0, 0);\n  (editor as any).$blockScrolling = Infinity;\n  editor.setReadOnly(options.readOnly);\n  editor.setFontSize(\"12\");\n  editor.setHighlightActiveLine(false);\n\n  const session = editor.getSession();\n  session.setMode(\"ace/mode/python\");\n  session.setTabSize(2);\n  session.setUseWrapMode(true);\n\n  // Implement placeholder text since the version of ACE we use doesn't support one.\n  const showPlaceholder = Observable.create(null, !options.initialValue.length);\n  editor.renderer.scroller.appendChild(\n    cssAcePlaceholder(dom.show(showPlaceholder), options.placeholder),\n  );\n  editor.on(\"change\", () => showPlaceholder.set(!editor.getValue().length));\n\n  async function getSuggestions(prefix: string): Promise<ISuggestionWithValue[]> {\n    return [\n      // The few Python keywords and constants we support.\n      \"and\", \"or\", \"not\", \"in\", \"is\", \"True\", \"False\", \"None\",\n      // Some grist-specific constants:\n      \"OWNER\", \"EDITOR\", \"VIEWER\",\n      // The common variables.\n      \"user\", \"rec\", \"newRec\",\n    ]\n      .map<ISuggestionWithValue>(suggestion => [suggestion, null])   // null means no example value\n      .concat(\n      // Other completions that depend on doc schema or other rules.\n        expandAndFilterSuggestions(prefix, options.getSuggestions())\n          .map<ISuggestionWithValue>(s => [s.value, s.example || null]),\n      );\n  }\n\n  setupAceEditorCompletions(editor, { getSuggestions });\n\n  // Save on blur.\n  editor.on(\"blur\", () => options.setValue(editor.getValue()));\n\n  // Save changes every 1 second\n  const save = debounce(() => options.setValue(editor.getValue()), 1000);\n  editor.on(\"change\", save);\n\n  // Blur (and save) on Enter key.\n  editor.commands.addCommand({\n    name: \"onEnter\",\n    bindKey: { win: \"Enter\", mac: \"Enter\" },\n    exec: () => editor.blur(),\n  });\n  // Disable Tab/Shift+Tab commands to restore their regular behavior.\n  (editor.commands as any).removeCommands([\"indent\", \"outdent\"]);\n\n  // Set the editor's initial value.\n  editor.setValue(options.initialValue);\n\n  if (options.customiseEditor) {\n    options.customiseEditor(editor);\n  }\n\n  return cssConditionInputAce(\n    dom.autoDispose(themeListener ?? null),\n    cssConditionInputAce.cls(\"-disabled\", options.readOnly),\n    // ACE editor calls preventDefault on clicks into the scrollbar area, which prevents focus\n    // being set when the click happens to be into there. To ensure we can focus on such clicks\n    // anyway, listen to the mousedown event in the capture phase.\n    dom.on(\"mousedown\", () => { editor.focus(); }, { useCapture: true }),\n    dom.onDispose(() => editor.destroy()),\n    dom.onDispose(() => save.cancel()),\n    editorElem,\n  );\n}\n\nconst cssConditionInputAce = styled(\"div\", `\n  width: 100%;\n  min-height: 28px;\n  padding: 1px;\n  border-radius: 3px;\n  border: 1px solid transparent;\n  cursor: pointer;\n\n  &:hover {\n    border: 1px solid ${theme.accessRulesFormulaEditorBorderHover};\n  }\n  &:not(&-disabled):focus-within {\n    box-shadow: inset 0 0 0 1px ${theme.accessRulesFormulaEditorFocus};\n    border-color: ${theme.accessRulesFormulaEditorFocus};\n  }\n  &:not(:focus-within) .ace_scroller, &-disabled .ace_scroller {\n    cursor: unset;\n  }\n  &-disabled, &-disabled:hover {\n    background-color: ${theme.accessRulesFormulaEditorBgDisabled};\n    box-shadow: unset;\n    border-color: transparent;\n  }\n  & .ace-chrome, & .ace-dracula {\n    background-color: ${theme.accessRulesFormulaEditorBg};\n  }\n  &:not(:focus-within) .ace_print-margin {\n    width: 0px;\n  }\n  &-disabled .ace-chrome, &-disabled .ace-dracula {\n    background-color: ${theme.accessRulesFormulaEditorBgDisabled};\n  }\n  & .ace_marker-layer, & .ace_cursor-layer {\n    display: none;\n  }\n  &:not(&-disabled) .ace_focus .ace_marker-layer, &:not(&-disabled) .ace_focus .ace_cursor-layer {\n    display: block;\n  }\n`);\n\nconst cssAcePlaceholder = styled(\"div\", `\n  padding: 4px 5px;\n  opacity: 0.5;\n`);\n"
  },
  {
    "path": "app/client/aclui/ACLMemoEditor.ts",
    "content": "import { theme } from \"app/client/ui2018/cssVars\";\n\nimport { dom, DomElementArg, Observable, styled } from \"grainjs\";\n\nexport function aclMemoEditor(obs: Observable<string>, ...args: DomElementArg[]): HTMLInputElement {\n  return cssMemoInput(\n    dom.prop(\"value\", obs),\n    dom.on(\"input\", (_e, elem) => obs.set(elem.value)),\n    ...args,\n  );\n}\n\nconst cssMemoInput = styled(\"input\", `\n  width: 100%;\n  min-height: 28px;\n  padding: 4px 5px;\n  border-radius: 3px;\n  border: 1px solid transparent;\n  cursor: pointer;\n  color: ${theme.controlFg};\n  background-color: ${theme.inputBg};\n  caret-color : ${theme.inputFg};\n  font: 12px 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;\n  overflow: hidden;\n  text-overflow: ellipsis;\n\n  &:hover {\n    border: 1px solid ${theme.inputBorder};\n  }\n  &:not(&-disabled):focus-within {\n    outline: none !important;\n    cursor: text;\n    box-shadow: inset 0 0 0 1px ${theme.controlFg};\n    border-color: ${theme.controlFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/aclui/ACLSelect.ts",
    "content": "import { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { IOption, select } from \"app/client/ui2018/menus\";\n\nimport { MaybeObsArray, Observable, styled } from \"grainjs\";\nimport * as weasel from \"popweasel\";\n\n/**\n * A styled version of select() from ui2018/menus, for use in the AccessRules page.\n */\nexport function aclSelect<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,\n  options: weasel.ISelectUserOptions = {}) {\n  return cssSelect(obs, optionArray, { buttonArrow: cssSelectArrow(\"Collapse\"), ...options });\n}\n\nexport const cssSelect = styled(select, `\n  height: 28px;\n  width: 100%;\n  border: 1px solid transparent;\n  cursor: pointer;\n\n  &:hover, &:focus, &.weasel-popup-open, &-active {\n    border: 1px solid ${theme.selectButtonBorder};\n    box-shadow: none;\n  }\n`);\n\nconst cssSelectCls = cssSelect.className;\n\nconst cssSelectArrow = styled(icon, `\n  margin: 0 2px;\n  pointer-events: none;\n  display: none;\n\n  .${cssSelectCls}:hover &, .${cssSelectCls}:focus &, .weasel-popup-open &, .${cssSelectCls}-active & {\n    display: flex;\n  }\n`);\n"
  },
  {
    "path": "app/client/aclui/ACLUsers.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { createUserImage } from \"app/client/ui/UserImage\";\nimport { cssMemberImage, cssMemberListItem, cssMemberPrimary,\n  cssMemberSecondary, cssMemberText } from \"app/client/ui/UserItem\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { gristFloatingMenuClass, menu, menuCssClass, menuItemLink } from \"app/client/ui2018/menus\";\nimport { PermissionDataWithExtraUsers } from \"app/common/ActiveDocAPI\";\nimport { IGristUrlState, userOverrideParams } from \"app/common/gristUrls\";\nimport { waitGrainObs } from \"app/common/gutil\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport { ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL } from \"app/common/UserAPI\";\nimport { getRealAccess, UserAccessData } from \"app/common/UserAPI\";\nimport { getUserRoleText } from \"app/common/UserAPI\";\n\nimport { Disposable, dom, Observable, styled } from \"grainjs\";\nimport noop from \"lodash/noop\";\nimport { cssMenu, cssMenuWrap, defaultMenuOptions, IMenuOptions, IPopupOptions, setPopupToCreateDom } from \"popweasel\";\n\nconst t = makeT(\"ViewAsDropdown\");\nconst userT = makeT(\"UserManagerModel\");\n\nfunction isSpecialEmail(email: string) {\n  return email === ANONYMOUS_USER_EMAIL || email === EVERYONE_EMAIL;\n}\n\nexport class ACLUsersPopup extends Disposable {\n  public readonly isInitialized = Observable.create(this, false);\n  public readonly allUsers = Observable.create<UserAccessData[]>(this, []);\n  private _shareUsers: UserAccessData[] = [];           // Users doc is shared with.\n  private _attributeTableUsers: UserAccessData[] = [];  // Users mentioned in attribute tables.\n  private _exampleUsers: UserAccessData[] = [];         // Example users.\n  private _currentUser: FullUser | null = null;\n\n  constructor(public pageModel: DocPageModel,\n    private _fetch: () => Promise<PermissionDataWithExtraUsers | null> = () => this._fetchData()) {\n    super();\n  }\n\n  public async load() {\n    const permissionData = await this._fetch();\n    if (this.isDisposed()) { return; }\n    this.init(permissionData);\n  }\n\n  public getUsers() {\n    const users = [...this._shareUsers, ...this._attributeTableUsers];\n    if (this._showExampleUsers()) { users.push(...this._exampleUsers); }\n    return users;\n  }\n\n  public init(permissionData: PermissionDataWithExtraUsers | null) {\n    const pageModel = this.pageModel;\n    this._currentUser = pageModel.userOverride.get()?.user || pageModel.appModel.currentValidUser;\n\n    if (permissionData) {\n      this._shareUsers = permissionData.users.map(user => ({\n        ...user,\n        access: getRealAccess(user, permissionData),\n      }))\n        .filter(user => user.access && !isSpecialEmail(user.email))\n        .filter(user => this._currentUser?.id !== user.id);\n      this._attributeTableUsers = permissionData.attributeTableUsers;\n      this._exampleUsers = permissionData.exampleUsers;\n      this.allUsers.set(this.getUsers());\n      this.isInitialized.set(true);\n    }\n  }\n\n  // Optionally have document page reverts to the default page upon activation of the view as mode\n  // by setting `options.resetDocPage` to true.\n  public attachPopup(elem: Element, options: IPopupOptions & { resetDocPage?: boolean }) {\n    setPopupToCreateDom(elem, (ctl) => {\n      const buildRow =\n        (user: UserAccessData) => this._buildUserRow(user, options);\n      const buildExampleUserRow =\n        (user: UserAccessData) => this._buildUserRow(user, { isExampleUser: true, ...options });\n      return cssMenuWrap(cssMenu(\n        dom.cls(menuCssClass),\n        dom.cls(gristFloatingMenuClass),\n        cssUsers.cls(\"\"),\n        cssHeader(t(\"Shared users\"), dom.show(this._shareUsers.length > 0)),\n        dom.forEach(this._shareUsers, buildRow),\n        (this._attributeTableUsers.length > 0) ? cssHeader(t(\"Other users from table\")) : null,\n        dom.forEach(this._attributeTableUsers, buildExampleUserRow),\n        // Include example users only if there are not many \"real\" users.\n        // It might be better to have an expandable section with these users, collapsed\n        // by default, but that's beyond my UI ken.\n        this._showExampleUsers() ? [\n          (this._exampleUsers.length > 0) ? cssHeader(t(\"Example Users\")) : null,\n          dom.forEach(this._exampleUsers, buildExampleUserRow),\n        ] : null,\n        (el) => { setTimeout(() => el.focus(), 0); },\n        dom.onKeyDown({ Escape: () => ctl.close() }),\n      ));\n    }, { ...defaultMenuOptions, ...options });\n  }\n\n  // See 'attachPopup' for more info on the 'resetDocPage' option.\n  public menu(options: IMenuOptions) {\n    return menu(() => {\n      this.load().catch(noop);\n      return [\n        cssMenuHeader(\"view as\"),\n        dom.forEach(this.allUsers, user => menuItemLink(\n          `${user.name || user.email} (${getUserRoleText(user)})`,\n          testId(\"acl-user-access\"),\n          this._viewAs(user),\n        )),\n      ];\n    }, options);\n  }\n\n  private async _fetchData() {\n    const doc = this.pageModel.currentDoc.get();\n    const gristDoc = await waitGrainObs(this.pageModel.gristDoc);\n    return doc && gristDoc.docComm.getUsersForViewAs();\n  }\n\n  private _showExampleUsers() {\n    return this._shareUsers.length + this._attributeTableUsers.length < 5;\n  }\n\n  private _buildUserRow(user: UserAccessData, opt: { isExampleUser?: boolean, resetDocPage?: boolean } = {}) {\n    return dom(\"a\",\n      { class: cssMemberListItem.className + \" \" + cssUserItem.className },\n      cssMemberImage(\n        createUserImage(opt.isExampleUser ? \"exampleUser\" : user, \"large\"),\n      ),\n      cssMemberText(\n        cssMemberPrimary(user.name || dom(\"span\", user.email),\n          cssRole(\"(\", userT(getUserRoleText(user)), \")\", testId(\"acl-user-access\")),\n        ),\n        user.name ? cssMemberSecondary(user.email) : null,\n      ),\n      this._viewAs(user, opt.resetDocPage),\n      testId(\"acl-user-item\"),\n    );\n  }\n\n  private _viewAs(user: UserAccessData, resetDocPage: boolean = false) {\n    const extraState: IGristUrlState = {};\n    if (resetDocPage) { extraState.docPage = undefined; }\n    if (this.pageModel?.isPrefork.get() &&\n      this.pageModel?.currentDoc.get()?.access !== \"owners\") {\n      // \"View As\" is restricted to document owners on the back-end. Non-owners can be\n      // permitted to pretend to be owners of a pre-forked document, but if they want\n      // to do \"View As\", that would be layering pretence over pretense. Better to just\n      // go ahead and create the fork, so the user becomes a genuine owner, so the\n      // back-end doesn't have to become too metaphysical (and maybe hard to review).\n      return dom.on(\"click\", async () => {\n        const forkResult = await this.pageModel?.gristDoc.get()?.docComm.fork();\n        if (!forkResult) { throw new Error(\"Failed to create fork\"); }\n        window.location.assign(urlState().makeUrl(userOverrideParams(user.email,\n          { ...extraState, doc: forkResult.urlId })));\n      });\n    } else {\n      // When forking isn't needed, we return a direct link to be maximally transparent\n      // about where button will go.\n      return urlState().setHref(userOverrideParams(user.email, extraState));\n    }\n  }\n}\n\nconst cssUsers = styled(\"div\", `\n  max-width: unset;\n`);\n\nconst cssUserItem = styled(cssMemberListItem, `\n  width: auto;\n  padding: 8px 16px;\n  align-items: center;\n  &:hover {\n    background-color: ${theme.lightHover};\n  }\n  &, &:hover, &:focus {\n    text-decoration: none;\n  }\n`);\n\nconst cssRole = styled(\"span\", `\n  margin: 0 8px;\n  font-weight: normal;\n`);\n\nconst cssHeader = styled(\"div\", `\n  margin: 11px 24px 14px 24px;\n  font-weight: 700;\n  text-transform: uppercase;\n  font-size: ${vars.xsmallFontSize};\n  color: ${theme.darkText};\n`);\n\nconst cssMenuHeader = styled(\"div\", `\n  margin: 8px 24px;\n  margin-bottom: 4px;\n  font-weight: 700;\n  text-transform: uppercase;\n  font-size: ${vars.xsmallFontSize};\n  color: ${theme.darkText};\n`);\n"
  },
  {
    "path": "app/client/aclui/AccessRules.ts",
    "content": "/**\n * UI for managing granular ACLs.\n */\nimport { aclColumnList } from \"app/client/aclui/ACLColumnList\";\nimport { aclFormulaEditor } from \"app/client/aclui/ACLFormulaEditor\";\nimport { aclMemoEditor } from \"app/client/aclui/ACLMemoEditor\";\nimport { aclSelect } from \"app/client/aclui/ACLSelect\";\nimport { ACLUsersPopup } from \"app/client/aclui/ACLUsers\";\nimport { permissionsWidget } from \"app/client/aclui/PermissionsWidget\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { inlineMarkdown, markdown } from \"app/client/lib/markdown\";\nimport { ISuggestionWithSubAttrs } from \"app/client/lib/Suggestions\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { reportError, UserError } from \"app/client/models/errors\";\nimport { TableData } from \"app/client/models/TableData\";\nimport { shadowScroll } from \"app/client/ui/shadowScroll\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { bigBasicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { squareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { mediaSmall, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { textInput } from \"app/client/ui2018/editableLabel\";\nimport { cssIconButton, icon } from \"app/client/ui2018/icons\";\nimport { cssNestedLinks } from \"app/client/ui2018/links\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { menu, menuItemAsync } from \"app/client/ui2018/menus\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\nimport {\n  AVAILABLE_BITS_COLUMNS,\n  AVAILABLE_BITS_TABLES,\n  emptyPermissionSet,\n  MixedPermissionValue,\n  parsePermissions,\n  PartialPermissionSet,\n  PermissionKey,\n  permissionSetToText,\n  summarizePermissions,\n  summarizePermissionSet,\n  trimPermissions,\n} from \"app/common/ACLPermissions\";\nimport { ACLRuleCollection, isSchemaEditResource, SPECIAL_RULES_TABLE_ID } from \"app/common/ACLRuleCollection\";\nimport { SpecialRuleName } from \"app/common/ACLRuleCollection\";\nimport { AclRuleProblem, AclTableDescription, getTableTitle } from \"app/common/ActiveDocAPI\";\nimport { BulkColValues, getColValues, RowRecord, UserAction } from \"app/common/DocActions\";\nimport {\n  RulePart,\n  RuleSet,\n  UserAttributeRule,\n} from \"app/common/GranularAccessClause\";\nimport { getDefaultForType, isHiddenCol } from \"app/common/gristTypes\";\nimport { commonUrls } from \"app/common/gristUrls\";\nimport { isNonNullish, localeCompare, unwrap } from \"app/common/gutil\";\nimport {\n  getPredicateFormulaProperties,\n  ParsedPredicateFormula,\n  PredicateFormulaProperties,\n  typeCheckFormula,\n} from \"app/common/PredicateFormula\";\nimport { EmptyRecordView, InfoView, RecordView } from \"app/common/RecordView\";\nimport { SchemaTypes } from \"app/common/schema\";\nimport { MetaRowRecord } from \"app/common/TableData\";\nimport { tokens } from \"app/common/ThemePrefs\";\n\nimport {\n  BaseObservable,\n  Computed,\n  Disposable,\n  dom,\n  DomContents,\n  DomElementArg,\n  IDisposableOwner,\n  MutableObsArray,\n  obsArray,\n  Observable,\n  styled,\n} from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\n\nconst t = makeT(\"AccessRules\");\n\n// Types for the rows in the ACL tables we use.\ntype ResourceRec = SchemaTypes[\"_grist_ACLResources\"] & { id?: number };\ntype RuleRec = Partial<SchemaTypes[\"_grist_ACLRules\"]> & { id?: number, resourceRec?: ResourceRec };\n\ntype UseCB = <T>(obs: BaseObservable<T>) => T;\n\n// Status of rules, which determines whether the \"Save\" button is enabled. The order of the values\n// matters, as we take the max of all the parts to determine the ultimate status.\nenum RuleStatus {\n  Unchanged,\n  ChangedValid,\n  Invalid,\n  CheckPending,\n}\n\ninterface ISuggestionInfo {\n  gristType?: string;     // If given, enables attributes using autoCompleteTypeAttributes().\n  example?: string;       // Optional example value to show with suggestions.\n}\n\n// UserAttribute autocomplete choices. RuleIndex is used to filter for only those user\n// attributes made available by the previous rules.\ninterface IAttrOption extends ISuggestionInfo {\n  ruleIndex: number;\n  value: string;\n}\n\ninterface IColTypeInfo extends ISuggestionInfo {\n  colId: string;\n}\n\nclass Suggestion implements ISuggestionWithSubAttrs {\n  constructor(public value: string, private _info?: ISuggestionInfo) {}\n  public get example() {\n    return (this._info?.gristType || \"\") + (this._info?.example ? ` (e.g. ${this._info?.example})` : \"\");\n  }\n\n  public subAttributes(): ISuggestionWithSubAttrs[] {\n    if (this._info?.gristType === \"Text\") {\n      return [\"upper()\", \"lower()\"].map(value => ({ value }));\n    }\n    return [];\n  }\n}\n\n/**\n * Top-most container managing state and dom-building for the ACL rule UI.\n */\nexport class AccessRules extends Disposable {\n  // Whether anything has changed, i.e. whether to show a \"Save\" button.\n  private _ruleStatus: Computed<RuleStatus>;\n\n  // Parsed rules obtained from DocData during last call to update(). Used for _ruleStatus.\n  private _ruleCollection = new ACLRuleCollection();\n\n  // Array of all per-table rules.\n  private _tableRules = this.autoDispose(obsArray<TableRules>());\n  private _sortedTableRules: Computed<TableRules[]>;\n\n  // The default rule set for the document (for \"*:*\").\n  private _docDefaultRuleSet = Observable.create<DefaultObsRuleSet | null>(this, null);\n\n  // Special document-level rules, for resources of the form (\"*SPECIAL:<RuleType>\").\n  // These rules are shown in different places - currently most are shown as a separate\n  // section, and one is folded into the default rule section (for SeedRule).\n  private _specialRulesWithDefault = Observable.create<SpecialRules | null>(this, null);\n  private _specialRulesSeparate = Observable.create<SpecialRules | null>(this, null);\n  private _specialRulesTemplates = Observable.create<SpecialRules | null>(this, null);\n\n  // Array of all UserAttribute rules.\n  private _userAttrRules = this.autoDispose(obsArray<ObsUserAttributeRule>());\n\n  // Array of all user-attribute choices created by UserAttribute rules. Used for lookup items in\n  // rules, and for ACLFormula completions.\n  private _userAttrChoices: Computed<IAttrOption[]>;\n\n  // Whether the save button should be enabled.\n  private _savingEnabled: Computed<boolean>;\n\n  // Whether to show \"loading\", an intro screen, or the rules UI.\n  private _uiState = Observable.create<\"loading\" | \"intro\" | \"rules\" | \"error\">(this, \"loading\");\n\n  // Whether to show \"Disable access rules\" button: when there are no custom table rules or\n  // default rules (only possibly-changed special rules left).\n  private _showDisableRules: Computed<boolean>;\n\n  // Whether a save is currently in progress.\n  private _saving = Observable.create(this, false);\n\n  // Error or warning message to show next to Save/Reset buttons if non-empty.\n  private _errorMessage = Observable.create(this, \"\");\n\n  // Details of rule problems, for offering solutions to the user.\n  private _ruleProblems = this.autoDispose(obsArray<AclRuleProblem>());\n\n  // Map of tableId to basic metadata for all tables in the document.\n  private _aclResources = new Map<string, AclTableDescription>();\n\n  private _aclUsersPopup = ACLUsersPopup.create(this, this.gristDoc.docPageModel);\n\n  constructor(public gristDoc: GristDoc) {\n    super();\n    this._ruleStatus = Computed.create(this, (use) => {\n      const defRuleSet = use(this._docDefaultRuleSet);\n      const tableRules = use(this._tableRules);\n      const specialRulesWithDefault = use(this._specialRulesWithDefault);\n      const specialRulesSeparate = use(this._specialRulesSeparate);\n      const specialRulesTemplates = use(this._specialRulesTemplates);\n      const userAttr = use(this._userAttrRules);\n      return Math.max(\n        defRuleSet ? use(defRuleSet.ruleStatus) : RuleStatus.Unchanged,\n        // If any tables/userAttrs were changed or added, they will be considered changed. If\n        // there were only removals, then length will be reduced.\n        getChangedStatus(tableRules.length < this._ruleCollection.getAllTableIds().length),\n        getChangedStatus(userAttr.length < this._ruleCollection.getUserAttributeRules().size),\n        ...tableRules.map(tr => use(tr.ruleStatus)),\n        ...userAttr.map(u => use(u.ruleStatus)),\n        specialRulesWithDefault ? use(specialRulesWithDefault.ruleStatus) : RuleStatus.Unchanged,\n        specialRulesSeparate ? use(specialRulesSeparate.ruleStatus) : RuleStatus.Unchanged,\n        specialRulesTemplates ? use(specialRulesTemplates.ruleStatus) : RuleStatus.Unchanged,\n      );\n    });\n\n    this._showDisableRules = Computed.create(this, (use) => {\n      return Boolean(use(this._docDefaultRuleSet)?.hasOnlyBuiltInRules()) &&\n        use(this._tableRules).length === 0 &&\n        use(this._userAttrRules).length === 0;\n    });\n\n    this._sortedTableRules = Computed.create(this, use =>\n      [...use(this._tableRules)].sort((a, b) =>\n        localeCompare(a.tableId, b.tableId),\n      ),\n    );\n\n    this._savingEnabled = Computed.create(this, this._ruleStatus, (use, s) =>\n      (s === RuleStatus.ChangedValid));\n\n    this._userAttrChoices = Computed.create(this, this._userAttrRules, (use, rules) => {\n      // Types are Grist equivalents of corresponding fields in app/common/User.\n      // Examples are also shown in autocomplete: include only the couple of commonly-used ones.\n      const result: IAttrOption[] = [\n        { ruleIndex: -1, value: \"user.Access\",     gristType: \"Choice\", example: \"VIEWER\" },\n        { ruleIndex: -1, value: \"user.Email\",      gristType: \"Text\",   example: '\"alice@example.com\"' },\n        { ruleIndex: -1, value: \"user.UserID\",     gristType: \"Int\" },\n        { ruleIndex: -1, value: \"user.Name\",       gristType: \"Text\" },\n        { ruleIndex: -1, value: \"user.LinkKey.\" },\n        { ruleIndex: -1, value: \"user.Origin\",     gristType: \"Text\" },\n        { ruleIndex: -1, value: \"user.SessionID\",  gristType: \"Text\" },\n        { ruleIndex: -1, value: \"user.IsLoggedIn\", gristType: \"Bool\",   example: \"True\" },\n        { ruleIndex: -1, value: \"user.UserRef\",    gristType: \"Text\" },\n      ];\n      for (const [i, rule] of rules.entries()) {\n        const tableId = use(rule.tableId);\n        const name = use(rule.name);\n        for (const c of this.getColTypeInfo(tableId)) {\n          result.push({ ...c, ruleIndex: i, value: `user.${name}.${c.colId}` });\n        }\n      }\n      return result;\n    });\n\n    // The UI in this module isn't really dynamic (that would be tricky while allowing unsaved\n    // changes). Instead, react deliberately if rules change. Note that table/column renames would\n    // trigger changes to rules, so we don't need to listen for those separately.\n    for (const tableId of [\"_grist_ACLResources\", \"_grist_ACLRules\"]) {\n      const tableData = this.gristDoc.docData.getTable(tableId)!;\n      this.autoDispose(tableData.tableActionEmitter.addListener(this._onChange, this));\n    }\n    this.autoDispose(this.gristDoc.docPageModel.currentDoc.addListener(this._updateDocAccessData, this));\n\n    this.update().catch(e => this._errorMessage.set(e.message));\n  }\n\n  public get allTableIds() { return Array.from(this._aclResources.keys()).sort(); }\n  public get userAttrRules() { return this._userAttrRules; }\n  public get userAttrChoices() { return this._userAttrChoices; }\n\n  public getTableTitle(tableId: string) {\n    const table = this._aclResources.get(tableId);\n    if (!table) { return `#Invalid (${tableId})`; }\n    return getTableTitle(table);\n  }\n\n  /**\n   * Replace internal state from the rules in DocData.\n   */\n  public async update() {\n    if (this.isDisposed()) { return; }\n    this._errorMessage.set(\"\");\n    const rules = this._ruleCollection;\n\n    try {\n      const [, , aclResources] = await Promise.all([\n        rules.update(this.gristDoc.docData, { log: console, pullOutSchemaEdit: true }),\n        this._updateDocAccessData(),\n        this.gristDoc.docComm.getAclResources(),\n      ]);\n      if (this.isDisposed()) { return; }\n\n      this._aclResources = new Map(Object.entries(aclResources.tables));\n      this._ruleProblems.set(aclResources.problems);\n      this._uiState.set(rules.haveRules() ? \"rules\" : \"intro\");\n    } catch (e) {\n      // We might have failed somewhere in Promise.all\n      if (this.isDisposed()) { return; }\n      this._uiState.set(\"error\");\n      throw e;\n    }\n\n    this._tableRules.set(\n      rules.getAllTableIds()\n        .filter(tableId => (tableId !== SPECIAL_RULES_TABLE_ID))\n        .map(tableId => TableRules.create(this._tableRules,\n          tableId, this, rules.getAllColumnRuleSets(tableId), rules.getTableDefaultRuleSet(tableId))),\n    );\n\n    const withDefaultRules: SpecialRuleName[] = [\"SeedRule\"];\n    const separateRules: SpecialRuleName[]  = [\"SchemaEdit\", \"AccessRules\", \"DocCopies\"];\n    const templateRules: SpecialRuleName[]  = [\"FullCopies\"];\n\n    SpecialRules.create(\n      this._specialRulesWithDefault, SPECIAL_RULES_TABLE_ID, this,\n      filterRuleSets(withDefaultRules, rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID)),\n      filterRuleSet(withDefaultRules, rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID)));\n    SpecialRulesMain.create(\n      this._specialRulesSeparate, SPECIAL_RULES_TABLE_ID, this,\n      filterRuleSets(separateRules, rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID)),\n      filterRuleSet(separateRules, rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID)));\n    SpecialRulesTemplates.create(\n      this._specialRulesTemplates, SPECIAL_RULES_TABLE_ID, this,\n      filterRuleSets(templateRules, rules.getAllColumnRuleSets(SPECIAL_RULES_TABLE_ID)),\n      filterRuleSet(templateRules, rules.getTableDefaultRuleSet(SPECIAL_RULES_TABLE_ID)));\n    DefaultObsRuleSet.create(this._docDefaultRuleSet, this, null, undefined, rules.getDocDefaultRuleSet());\n    this._userAttrRules.set(\n      Array.from(rules.getUserAttributeRules().values(), userAttr =>\n        ObsUserAttributeRule.create(this._userAttrRules, this, userAttr)),\n    );\n  }\n\n  /**\n   * Collect the internal state into records and sync them to the document.\n   */\n  public async save(): Promise<void> {\n    if (!this._savingEnabled.get() || this._saving.get()) {\n      return;\n    }\n\n    this._saving.set(true);\n    try {\n      // Note that if anything has changed, we apply changes relative to the current state of the\n      // ACL tables (they may have changed by other users). So our changes will win.\n\n      const docData = this.gristDoc.docData;\n      const resourcesTable = docData.getMetaTable(\"_grist_ACLResources\");\n      const rulesTable = docData.getMetaTable(\"_grist_ACLRules\");\n\n      // Add/remove resources to have just the ones we need.\n      const newResources: MetaRowRecord<\"_grist_ACLResources\">[] = flatten(\n        [{ tableId: \"*\", colIds: \"*\" }],\n        this._specialRulesWithDefault.get()?.getResources() || [],\n        this._specialRulesSeparate.get()?.getResources() || [],\n        this._specialRulesTemplates.get()?.getResources() || [],\n        ...this._tableRules.get().map(tr => tr.getResources()),\n      )\n        // Skip the fake \"*SPECIAL:SchemaEdit\" resource (frontend-specific); these rules are saved to the default\n        // resource.\n        .filter(resource => !isSchemaEditResource(resource))\n        .map(r => ({ id: -1, ...r }));\n\n      // Prepare userActions and a mapping of serializedResource to rowIds.\n      const resourceSync = syncRecords(resourcesTable, newResources, serializeResource);\n\n      const defaultResourceRowId = resourceSync.rowIdMap.get(serializeResource({ id: -1, tableId: \"*\", colIds: \"*\" }));\n      if (!defaultResourceRowId) {\n        throw new Error(t(\"Default resource missing in resource map\"));\n      }\n\n      // For syncing rules, we'll go by rowId that we store with each RulePart and with the RuleSet.\n      const newRules: RowRecord[] = [];\n      for (const rule of this.getRules()) {\n        // We use id of 0 internally to mark built-in rules. Skip those.\n        if (rule.id === 0) {\n          continue;\n        }\n\n        // Look up the rowId for the resource.\n        let resourceRowId: number | undefined;\n        // Assign the rules for the fake \"*SPECIAL:SchemaEdit\" resource to the default resource where they belong.\n        if (isSchemaEditResource(rule.resourceRec!)) {\n          resourceRowId = defaultResourceRowId;\n        } else {\n          const resourceKey = serializeResource(rule.resourceRec as RowRecord);\n          resourceRowId = resourceSync.rowIdMap.get(resourceKey);\n          if (!resourceRowId) {\n            throw new Error(t(\"Resource missing in resource map: {{resourceKey}}\", { resourceKey }));\n          }\n        }\n        newRules.push({\n          id: rule.id || -1,\n          resource: resourceRowId,\n          aclFormula: rule.aclFormula!,\n          permissionsText: rule.permissionsText!,\n          rulePos: rule.rulePos || null,\n          memo: rule.memo ?? \"\",\n        });\n      }\n\n      // UserAttribute rules are listed in the same rulesTable.\n      for (const userAttr of this._userAttrRules.get()) {\n        const rule = userAttr.getRule();\n        newRules.push({\n          id: rule.id || -1,\n          resource: defaultResourceRowId,\n          rulePos: rule.rulePos || null,\n          userAttributes: rule.userAttributes,\n        });\n      }\n\n      logTelemetryEvent(\"changedAccessRules\", {\n        full: {\n          docIdDigest: this.gristDoc.docId(),\n          ruleCount: newRules.length,\n        },\n      });\n\n      // We need to fill in rulePos values. We'll add them in the order the rules are listed (since\n      // this.getRules() returns them in a suitable order), keeping rulePos unchanged when possible.\n      let lastGoodRulePos = 0;\n      let lastGoodIndex = -1;\n      for (let i = 0; i < newRules.length; i++) {\n        const pos = newRules[i].rulePos as number;\n        if (pos && pos > lastGoodRulePos) {\n          const step = (pos - lastGoodRulePos) / (i - lastGoodIndex);\n          for (let k = lastGoodIndex + 1; k < i; k++) {\n            newRules[k].rulePos = lastGoodRulePos + step * (k - lastGoodIndex);\n          }\n          lastGoodRulePos = pos;\n          lastGoodIndex = i;\n        }\n      }\n      // Fill in the rulePos values for the remaining rules.\n      for (let k = lastGoodIndex + 1; k < newRules.length; k++) {\n        newRules[k].rulePos = ++lastGoodRulePos;\n      }\n      // Prepare the UserActions for syncing the Rules table.\n      const rulesSync = syncRecords(rulesTable, newRules);\n\n      // Finally collect and apply all the actions together.\n      try {\n        await docData.sendActions([\n          ...resourceSync.userActions,\n          ...rulesSync.userActions,\n        ]);\n      } catch (e) {\n        // Report the error, but go on to update the rules. The user may lose their entries, but\n        // will see what's in the document. To preserve entries and show what's wrong, we try to\n        // catch errors earlier.\n        reportError(e);\n      }\n\n      // Re-populate the state from DocData once the records are synced.\n      if (!this.isDisposed()) {\n        await this.update();\n      }\n    } finally {\n      if (!this.isDisposed()) {\n        this._saving.set(false);\n      }\n    }\n  }\n\n  public buildDom() {\n    return dom.domComputed(this._uiState, (uiState) => {\n      switch (uiState) {\n        case \"loading\": return cssLoading(loadingSpinner(), testId(\"access-rules-loading\"));\n        case \"intro\": return this._buildIntro();\n        case \"rules\": return this._buildRulesDom();\n        case \"error\": return this._buildError();\n      }\n    });\n  }\n\n  public _buildRulesDom() {\n    return cssOuter(\n      cssAddTableRow(\n        bigBasicButton({ disabled: true }, dom.hide(this._savingEnabled),\n          dom.text((use) => {\n            const s = use(this._ruleStatus);\n            return s === RuleStatus.CheckPending ? t(\"Checking...\") :\n              s === RuleStatus.Unchanged ? t(\"Saved\") : t(\"Invalid\");\n          }),\n          testId(\"rules-non-save\"),\n        ),\n        bigPrimaryButton(\n          t(\"Save\"),\n          dom.show(this._savingEnabled),\n          dom.on(\"click\", () => this.save()),\n          dom.prop(\"disabled\", this._saving),\n          testId(\"rules-save\"),\n        ),\n        bigBasicButton(t(\"Reset\"), dom.show(use => use(this._ruleStatus) !== RuleStatus.Unchanged),\n          dom.on(\"click\", () => this.update()),\n          testId(\"rules-revert\"),\n        ),\n\n        bigBasicButton(t(\"Add table rules\"), cssDropdownIcon(\"Dropdown\"), { style: \"margin-left: auto\" },\n          menu(() =>\n            this.allTableIds.map(tableId =>\n              // Add the table on a timeout, to avoid disabling the clicked menu item\n              // synchronously, which prevents the menu from closing on click.\n              menuItemAsync(() => this._addTableRules(tableId),\n                this.getTableTitle(tableId),\n                dom.cls(\"disabled\", use => use(this._tableRules).some(tr => tr.tableId === tableId)),\n              ),\n            ),\n          ),\n        ),\n        bigBasicButton(t(\"Add user attributes\"), dom.on(\"click\", () => this._addUserAttributes())),\n        bigBasicButton(t(\"View as\"), cssDropdownIcon(\"Dropdown\"),\n          elem => this._aclUsersPopup.attachPopup(elem, { placement: \"bottom-end\", resetDocPage: true }),\n          dom.style(\"visibility\", use => use(this._aclUsersPopup.isInitialized) ? \"\" : t(\"hidden\"))),\n      ),\n      cssConditionError({ style: \"margin-left: 16px\" },\n        dom.text(this._errorMessage),\n        testId(\"access-rules-error\"),\n      ),\n\n      dom.maybe((use) => {\n        const ruleProblems = use(this._ruleProblems);\n        return ruleProblems.length > 0 ? ruleProblems : null;\n      }, ruleProblems =>\n        cssSection(\n          cssRuleProblems(\n            this._buildRuleProblemsDom(ruleProblems))),\n      ),\n\n      shadowScroll(\n        dom.maybe(use => use(this._userAttrRules).length, () =>\n          cssSection(\n            cssSectionHeading(t(\"User Attributes\")),\n            cssTableRounded(\n              cssTableHeaderRow(\n                cssCell1(cssCell.cls(\"-rborder\"), cssCell.cls(\"-center\"), cssColHeaderCell(\"Name\")),\n                cssCell4(\n                  cssColumnGroup(\n                    cssCell1(cssColHeaderCell(t(\"Attribute to Look Up\"))),\n                    cssCell1(cssColHeaderCell(t(\"Lookup Table\"))),\n                    cssCell1(cssColHeaderCell(t(\"Lookup Column\"))),\n                    cssCellIcon(),\n                  ),\n                ),\n              ),\n              dom.forEach(this._userAttrRules, userAttr => userAttr.buildUserAttrDom()),\n            ),\n          ),\n        ),\n        dom.forEach(this._sortedTableRules, tableRules => tableRules.buildDom()),\n        cssSection(\n          cssSectionHeading(t(\"Default rules\"), testId(\"rule-table-header\")),\n          dom.maybe(this._specialRulesWithDefault, tableRules => cssSeedRule(\n            tableRules.buildCheckBoxes())),\n          cssTableRounded(\n            cssTableHeaderRow(\n              cssCell1(cssCell.cls(\"-rborder\"), cssCell.cls(\"-center\"), cssColHeaderCell(t(\"Columns\"))),\n              cssCell4(\n                cssColumnGroup(\n                  cssCellIcon(),\n                  cssCell2(cssColHeaderCell(t(\"Condition\"))),\n                  cssCell1(cssColHeaderCell(t(\"Permissions\"))),\n                  cssCellIconWithMargins(),\n                  cssCellIcon(),\n                ),\n              ),\n            ),\n            dom.maybe(this._docDefaultRuleSet, ruleSet => ruleSet.buildRuleSetDom()),\n          ),\n          testId(\"rule-table\"),\n        ),\n        dom.maybe(this._specialRulesSeparate, tableRules => tableRules.buildDom()),\n        dom.maybe(this._specialRulesTemplates, tableRules => tableRules.buildDom()),\n        dom.maybe(this._showDisableRules, () =>\n          cssDisableRulesButton(icon(\"Remove\"), t(\"Disable Access Rules\"),\n            dom.on(\"click\", () => this._confirmDisableAccessRules()),\n            testId(\"disable-access-rules\"),\n          ),\n        ),\n      ),\n    );\n  }\n\n  public _buildRuleProblemsDom(ruleProblems: AclRuleProblem[]) {\n    const buttons: (HTMLAnchorElement | HTMLButtonElement)[] = [];\n    for (const problem of ruleProblems) {\n      // Is the problem a missing table?\n      if (problem.tables) {\n        this._addButtonsForMissingTables(buttons, problem.tables.tableIds);\n      }\n      // Is the problem a missing column?\n      if (problem.columns) {\n        this._addButtonsForMissingColumns(buttons, problem.columns.tableId, problem.columns.colIds);\n      }\n      // Is the problem a misconfigured user attribute?\n      if (problem.userAttributes) {\n        const names = problem.userAttributes.names;\n        this._addButtonsForMisconfiguredUserAttributes(buttons, names);\n      }\n    }\n    return buttons.map(button => dom(\"span\", button));\n  }\n\n  /**\n   * Get a list of all rule records, for saving.\n   */\n  public getRules(): RuleRec[] {\n    return flatten(\n      ...this._tableRules.get().map(tr => tr.getRules()),\n      this._specialRulesWithDefault.get()?.getRules() || [],\n      this._specialRulesSeparate.get()?.getRules() || [],\n      this._specialRulesTemplates.get()?.getRules() || [],\n      this._docDefaultRuleSet.get()?.getRules(\"*\") || [],\n    );\n  }\n\n  public removeTableRules(tableRules: TableRules) {\n    removeItem(this._tableRules, tableRules);\n  }\n\n  public removeUserAttributes(userAttr: ObsUserAttributeRule) {\n    removeItem(this._userAttrRules, userAttr);\n  }\n\n  public async checkAclFormula(text: string): Promise<PredicateFormulaProperties> {\n    if (text) {\n      return this.gristDoc.docComm.checkAclFormula(text);\n    }\n    return {};\n  }\n\n  // Check if the given tableId, and optionally a list of colIds, are present in this document.\n  // Returns '' if valid, or an error string if not. Exempt colIds will not trigger an error.\n  public checkTableColumns(tableId: string, colIds?: string[], exemptColIds?: string[]): string {\n    if (!tableId || tableId === SPECIAL_RULES_TABLE_ID) { return \"\"; }\n    const tableColIds = this._aclResources.get(tableId)?.colIds;\n    if (!tableColIds) { return t(\"Invalid table: {{tableId}}\", { tableId }); }\n    if (colIds) {\n      const validColIds = new Set([...tableColIds, ...exemptColIds || []]);\n      const invalidColIds = colIds.filter(c => !validColIds.has(c));\n      if (invalidColIds.length === 0) { return \"\"; }\n      return t(\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\",\n        { tableId, invalidColIds: invalidColIds.join(\", \") },\n      );\n    }\n    return \"\";\n  }\n\n  // Returns a list of valid colIds for the given table, or undefined if the table isn't valid.\n  public getValidColIds(tableId: string): string[] | undefined {\n    return this._aclResources.get(tableId)?.colIds.filter(id => !isHiddenCol(id)).sort();\n  }\n\n  public getColTypeInfo(tableId?: string): IColTypeInfo[] {\n    if (!tableId) { return []; }\n    return getColTypeInfo(this.getValidColIds(tableId) || [], this.gristDoc.docData.getTable(tableId));\n  }\n\n  public typeCheckFormula(formulaParsed: ParsedPredicateFormula, tableId?: string): string | false {\n    const sampleRecord = tableId ? this._getSampleRecord(tableId) : new EmptyRecordView();\n\n    const userAttrSamples: { [key: string]: InfoView } = {};\n    for (const attr of this._userAttrRules.get()) {\n      userAttrSamples[attr.name.get()] = this._getSampleRecord(attr.tableId.get());\n    }\n    return typeCheckFormula(formulaParsed, sampleRecord, userAttrSamples);\n  }\n\n  // Get rules to use for seeding any new set of table/column rules, e.g. to give owners\n  // broad rights over the table/column contents.\n  public getSeedRules(): ObsRulePart[] {\n    return this._specialRulesWithDefault.get()?.getCustomRules(\"SeedRule\") || [];\n  }\n\n  private _buildError() {\n    return cssIntroSection(\n      markdown(t(`\\\n## Access Rules\n\nYou don't have permission to view or edit access rules for this document.`,\n      )),\n      cssErrorDetails(\"(Error: \", dom.text(this._errorMessage), \")\"),\n    );\n  }\n\n  private _buildIntro() {\n    return cssIntroSection(cssNestedLinks(\n      markdown(t(`\\\n## Access Rules\n\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' \\\nmenu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\n\nFor more granular control, you can create Access Rules to limit who can view or edit specific\ntables, columns, or rows — useful for sensitive data or role-based permissions.\n[Learn more.]({{helpAccessRules}})`,\n      { helpAccessRules: commonUrls.helpAccessRules }),\n      ),\n      cssIntroButton(\n        bigPrimaryButton(\"Enable Access Rules\",\n          dom.on(\"click\", () => this._confirmEnableAccessRules()),\n          testId(\"enable-access-rules\"),\n        ),\n      ),\n      testId(\"access-rules-intro\"),\n    ));\n  }\n\n  private _confirmEnableAccessRules() {\n    confirmModal(\n      t(\"Enable Access Rules\"),\n      t(\"Continue\"),\n      () => this._doEnableAccessRules(),\n      {\n        explanation: markdown(t(`\\\nAfter enabling Access Rules, Editors will no longer be able to change the structure of the\ndocument or edit formulas. Only Owners will be able to copy or download the document.\n\nThese settings can be changed under 'Special rules'.`,\n        )),\n      },\n    );\n  }\n\n  private _doEnableAccessRules() {\n    this._specialRulesSeparate.get()?.setToRecommended();\n    this._uiState.set(\"rules\");\n  }\n\n  private _confirmDisableAccessRules() {\n    confirmModal(\n      t(\"Disable Access Rules\"),\n      t(\"Disable and save\"),\n      () => this._doDisableAccessRules(),\n      {\n        explanation: markdown(t(`\\\nAfter disabling Access Rules, Editors will be able to change the structure of the document \\\nand edit formulas. Editors and Viewers will be able to see all data in the document, \\\nas well as copy or download it.`,\n        )),\n      },\n    );\n  }\n\n  private _doDisableAccessRules() {\n    if (!this._showDisableRules.get()) {\n      // We should only be able to get here if there are no custom rules other than the special\n      // checkboxes. If we do, that's a sign of a bug.\n      throw new UserError(\"Clear existing custom rules first\");\n    }\n    this._specialRulesWithDefault.get()?.clearRules();\n    this._specialRulesSeparate.get()?.clearRules();\n    this._specialRulesTemplates.get()?.clearRules();\n    return this.save();\n  }\n\n  private _getSampleRecord(tableId: string): InfoView {\n    return getSampleRecord(this.getValidColIds(tableId) || [], this.gristDoc.docData.getTable(tableId));\n  }\n\n  private _addTableRules(tableId: string) {\n    if (this._tableRules.get().some(tr => tr.tableId === tableId)) {\n      throw new Error(t(\"Trying to add TableRules for existing table {{tableId}}\", { tableId }));\n    }\n    const defRuleSet: RuleSet = { tableId, colIds: \"*\", body: [] };\n    const tableRules = TableRules.create(this._tableRules, tableId, this, undefined, defRuleSet);\n    this._tableRules.push(tableRules);\n    tableRules.addDefaultRules(this.getSeedRules());\n  }\n\n  private _addUserAttributes() {\n    this._userAttrRules.push(ObsUserAttributeRule.create(this._userAttrRules, this, undefined, { focus: true }));\n  }\n\n  private _onChange() {\n    if (this._ruleStatus.get() === RuleStatus.Unchanged) {\n      // If no changes, it's safe to just reload the rules from docData.\n      this.update().catch(e => this._errorMessage.set(e.message));\n    } else {\n      this._errorMessage.set(\n        t(\"Access rules have changed. Click Reset to revert your changes and refresh the rules.\"),\n      );\n    }\n  }\n\n  private async _updateDocAccessData() {\n    await this._aclUsersPopup.load();\n  }\n\n  private _addButtonsForMissingTables(buttons: (HTMLAnchorElement | HTMLButtonElement)[], tableIds: string[]) {\n    for (const tableId of tableIds) {\n      // We don't know what the table's name was, just its tableId.\n      // Hopefully, the user will understand.\n      const title = t(\"Remove {{- tableId }} rules\", { tableId });\n      const button = bigBasicButton(title, cssRemoveIcon(\"Remove\"), dom.on(\"click\", async () => {\n        this._tableRules.get()\n          .filter(rules => rules.tableId === tableId)\n          .forEach(rules => rules.remove());\n        button.style.display = \"none\";\n      }));\n      buttons.push(button);\n    }\n  }\n\n  private _addButtonsForMissingColumns(buttons: (HTMLAnchorElement | HTMLButtonElement)[],\n    tableId: string, colIds: string[]) {\n    const removeColRules = (rules: TableRules, colId: string) => {\n      for (const rule of rules.columnRuleSets.get()) {\n        const ruleColIds = new Set(rule.getColIdList());\n        if (!ruleColIds.has(colId)) { continue; }\n        if (ruleColIds.size === 1) {\n          rule.remove();\n        } else {\n          rule.removeColId(colId);\n        }\n      }\n    };\n    for (const colId of colIds) {\n      // TODO: we could translate tableId to table name in this case.\n      const title = t(\"Remove column {{- colId }} from {{- tableId }} rules\", { tableId, colId });\n      const button = bigBasicButton(title, cssRemoveIcon(\"Remove\"), dom.on(\"click\", async () => {\n        this._tableRules.get()\n          .filter(rules => rules.tableId === tableId)\n          .forEach(rules => removeColRules(rules, colId));\n        button.style.display = \"none\";\n      }));\n      buttons.push(button);\n    }\n  }\n\n  private _addButtonsForMisconfiguredUserAttributes(\n    buttons: (HTMLAnchorElement | HTMLButtonElement)[],\n    names: string[],\n  ) {\n    for (const name of names) {\n      const title = t(\"Remove {{- name }} user attribute\", { name });\n      const button = bigBasicButton(title, cssRemoveIcon(\"Remove\"), dom.on(\"click\", async () => {\n        this._userAttrRules.get()\n          .filter(rule => rule.name.get() === name)\n          .forEach(rule => rule.remove());\n        button.style.display = \"none\";\n      }));\n      buttons.push(button);\n    }\n  }\n}\n\n// Represents all rules for a table.\nclass TableRules extends Disposable {\n  // Whether any table rules changed, and if they are valid.\n  public ruleStatus: Computed<RuleStatus>;\n\n  // The column-specific rule sets.\n  protected _columnRuleSets = this.autoDispose(obsArray<ColumnObsRuleSet>());\n\n  // Whether there are any column-specific rule sets.\n  private _haveColumnRules = Computed.create(this, this._columnRuleSets, (use, cols) => cols.length > 0);\n\n  // The default rule set (for columns '*'), if one is set.\n  private _defaultRuleSet = Observable.create<DefaultObsRuleSet | null>(this, null);\n\n  constructor(public readonly tableId: string, public _accessRules: AccessRules,\n    private _colRuleSets?: RuleSet[], private _defRuleSet?: RuleSet) {\n    super();\n    this._columnRuleSets.set(this._colRuleSets?.map(rs =>\n      this._createColumnObsRuleSet(this._columnRuleSets, rs,\n        rs.colIds === \"*\" ? [] : rs.colIds)) || []);\n\n    if (!this._colRuleSets) {\n      // Must be a newly-created TableRules object. Just create a default RuleSet (for tableId:*)\n      DefaultObsRuleSet.create(this._defaultRuleSet, this._accessRules, this, this._haveColumnRules);\n    } else if (this._defRuleSet) {\n      DefaultObsRuleSet.create(this._defaultRuleSet, this._accessRules, this, this._haveColumnRules,\n        this._defRuleSet);\n    }\n\n    this.ruleStatus = Computed.create(this, (use) => {\n      const columnRuleSets = use(this._columnRuleSets);\n      const d = use(this._defaultRuleSet);\n      return Math.max(\n        getChangedStatus(\n          !this._colRuleSets ||                               // This TableRules object must be newly-added\n          Boolean(d) !== Boolean(this._defRuleSet) ||         // Default rule set got added or removed\n          columnRuleSets.length < this._colRuleSets.length,    // There was a removal\n        ),\n        d ? use(d.ruleStatus) : RuleStatus.Unchanged,         // Default rule set got changed.\n        ...columnRuleSets.map(rs => use(rs.ruleStatus)));     // Column rule set was added or changed.\n    });\n  }\n\n  /**\n   * Get all custom rules for the specific column. Used to gather the current\n   * setting of a special rule. Returns an empty list for unknown columns.\n   */\n  public getCustomRules(colId: string): ObsRulePart[] {\n    for (const ruleSet of this._columnRuleSets.get()) {\n      if (ruleSet.getColIds() === colId) {\n        return ruleSet.getCustomRules();\n      }\n    }\n    return [];\n  }\n\n  /**\n   * Add the provided rules, copying their formula, permissions, and memo.\n   */\n  public addDefaultRules(rules: ObsRulePart[]) {\n    const ruleSet = this._defaultRuleSet.get();\n    ruleSet?.addRuleParts(rules, { foldEveryoneRule: true });\n  }\n\n  public remove() {\n    this._accessRules.removeTableRules(this);\n  }\n\n  public get columnRuleSets() {\n    return this._columnRuleSets;\n  }\n\n  public buildDom() {\n    return cssSection(\n      cssSectionHeading(\n        dom(\"span\", t(\"Rules for table \"), cssTableName(this._accessRules.getTableTitle(this.tableId))),\n        cssIconButton(icon(\"Dots\"), { style: \"margin-left: auto\" },\n          menu(() => [\n            menuItemAsync(() => this._addColumnRuleSet(), t(\"Add column rule\")),\n            menuItemAsync(() => this._addDefaultRuleSet(), t(\"Add table-wide rule\")),\n            menuItemAsync(() => this._accessRules.removeTableRules(this), t(\"Delete table rules\")),\n          ]),\n          testId(\"rule-table-menu-btn\"),\n        ),\n        testId(\"rule-table-header\"),\n      ),\n      cssTableRounded(\n        cssTableHeaderRow(\n          cssCell1(cssCell.cls(\"-rborder\"), cssCell.cls(\"-center\"), cssColHeaderCell(t(\"Columns\"))),\n          cssCell4(\n            cssColumnGroup(\n              cssCellIcon(),\n              cssCell2(cssColHeaderCell(t(\"Condition\"))),\n              cssCell1(cssColHeaderCell(t(\"Permissions\"))),\n              cssCellIconWithMargins(),\n              cssCellIcon(),\n            ),\n          ),\n        ),\n        this.buildColumnRuleSets(),\n      ),\n      this.buildErrors(),\n      testId(\"rule-table\"),\n    );\n  }\n\n  public buildColumnRuleSets() {\n    return [\n      dom.forEach(this._columnRuleSets, ruleSet => ruleSet.buildRuleSetDom()),\n      dom.maybe(this._defaultRuleSet, ruleSet => ruleSet.buildRuleSetDom()),\n    ];\n  }\n\n  public buildErrors() {\n    return dom.forEach(this._columnRuleSets, c => cssConditionError(dom.text(c.formulaError)));\n  }\n\n  /**\n   * Return the resources (tableId:colIds entities), for saving, checking along the way that they\n   * are valid.\n   */\n  public getResources(): ResourceRec[] {\n    // Check that the colIds are valid.\n    const seen = {\n      allow: new Set<string>(),   // columns mentioned in rules that only have 'allow's.\n      deny: new Set<string>(),    // columns mentioned in rules that only have 'deny's.\n      mixed: new Set<string>(),    // columns mentioned in any rules.\n    };\n    for (const ruleSet of this._columnRuleSets.get()) {\n      const sign = ruleSet.summarizePermissions();\n      const counterSign = sign === \"mixed\" ? \"mixed\" : (sign === \"allow\" ? \"deny\" : \"allow\");\n      const colIds = ruleSet.getColIdList();\n      if (colIds.length === 0) {\n        throw new UserError(t(\"No columns listed in a column rule for table {{tableId}}\", { tableId: this.tableId }));\n      }\n      for (const colId of colIds) {\n        if (seen[counterSign].has(colId)) {\n          // There may be an order dependency between rules.  We've done a little analysis, to\n          // allow the useful pattern of forbidding all access to columns, and then adding back\n          // access to different sets for different teams/conditions (or allowing all access\n          // by default, and then forbidding different sets).  But if there's a mix of\n          // allows and denies, then we throw up our hands.\n          // TODO: could analyze more deeply.  An easy step would be to analyze per permission bit.\n          // Could also allow order dependency and provide a way to control the order.\n          // TODO: could be worth also flagging multiple rulesets with the same columns as\n          // undesirable.\n          throw new UserError(\n            t(\"Column {{colId}} appears in multiple rules for table {{tableId}} \\\nthat might be order-dependent. Try splitting rules up differently?\", { colId, tableId: this.tableId },\n            ),\n          );\n        }\n        if (sign === \"mixed\") {\n          seen.allow.add(colId);\n          seen.deny.add(colId);\n          seen.mixed.add(colId);\n        } else {\n          seen[sign].add(colId);\n          seen.mixed.add(colId);\n        }\n      }\n    }\n\n    return [\n      ...this._columnRuleSets.get().map(rs => ({ tableId: this.tableId, colIds: rs.getColIds() })),\n      { tableId: this.tableId, colIds: \"*\" },\n    ];\n  }\n\n  /**\n   * Get rules for this table, for saving.\n   */\n  public getRules(): RuleRec[] {\n    return flatten(\n      ...this._columnRuleSets.get().map(rs => rs.getRules(this.tableId)),\n      this._defaultRuleSet.get()?.getRules(this.tableId) || [],\n    );\n  }\n\n  public removeRuleSet(ruleSet: ObsRuleSet) {\n    if (ruleSet === this._defaultRuleSet.get()) {\n      this._defaultRuleSet.set(null);\n    } else {\n      removeItem(this._columnRuleSets, ruleSet);\n    }\n    if (!this._defaultRuleSet.get() && this._columnRuleSets.get().length === 0) {\n      this._accessRules.removeTableRules(this);\n    }\n  }\n\n  protected _createColumnObsRuleSet(\n    owner: IDisposableOwner,\n    ruleSet: RuleSet | undefined, initialColIds: string[],\n  ): ColumnObsRuleSet {\n    return ColumnObsRuleSet.create(owner, this._accessRules, this, ruleSet, initialColIds);\n  }\n\n  private _addColumnRuleSet() {\n    const ruleSet = ColumnObsRuleSet.create(this._columnRuleSets, this._accessRules, this, undefined, []);\n    this._columnRuleSets.push(ruleSet);\n    ruleSet.addRuleParts(this._accessRules.getSeedRules(), { foldEveryoneRule: true });\n  }\n\n  private _addDefaultRuleSet() {\n    const ruleSet = this._defaultRuleSet.get();\n    if (!ruleSet) {\n      DefaultObsRuleSet.create(this._defaultRuleSet, this._accessRules, this, this._haveColumnRules);\n      this.addDefaultRules(this._accessRules.getSeedRules());\n    } else {\n      const part = ruleSet.addRulePart(ruleSet.getDefaultCondition());\n      setTimeout(() => part.focusEditor?.(), 0);\n    }\n  }\n}\n\nclass SpecialRules extends TableRules {\n  public buildDom() {\n    return cssSection(\n      cssSectionHeadingMarkdown(\n        dom(\"span\", inlineMarkdown(t(\"**Special rules** (expand each rule to customize who it applies to)\"))),\n        testId(\"rule-table-header\"),\n      ),\n      this.buildCheckBoxes(),\n      testId(\"rule-table\"),\n    );\n  }\n\n  // Build dom with checkboxes, without a section wrapping it.\n  // Used for folding a special rule into another section.\n  public buildCheckBoxes() {\n    return [\n      this.buildColumnRuleSets(),\n      this.buildErrors(),\n    ];\n  }\n\n  public getResources(): ResourceRec[] {\n    return this._columnRuleSets.get()\n      .filter(rs => !rs.hasOnlyBuiltInRules())\n      .map(rs => ({ tableId: this.tableId, colIds: rs.getColIds() }));\n  }\n\n  public setToRecommended() {\n    for (const colRuleSet of this.columnRuleSets.get()) {\n      if (colRuleSet instanceof SpecialObsRuleSet) {\n        colRuleSet.setToRecommended();\n      }\n    }\n  }\n\n  public clearRules() {\n    for (const colRuleSet of this.columnRuleSets.get()) {\n      if (colRuleSet instanceof SpecialObsRuleSet) {\n        colRuleSet.clearRules();\n      }\n    }\n  }\n\n  protected _createColumnObsRuleSet(\n    owner: IDisposableOwner,\n    ruleSet: RuleSet | undefined, initialColIds: string[],\n  ): ColumnObsRuleSet {\n    return SpecialObsRuleSet.create(owner, this._accessRules, this, ruleSet, initialColIds);\n  }\n}\n\nclass SpecialRulesMain extends SpecialRules {\n  private _denyCopies?: SpecialObsRuleSetDenyCopies;\n  private _allowAccessRules?: SpecialObsRuleSetAccessRules;\n\n  public get allowAccessRules() { return this._allowAccessRules; }\n\n  public onAccessRulesToggled(value: boolean) {\n    this._denyCopies?.onAccessRulesToggled(value);\n  }\n\n  protected _createColumnObsRuleSet(\n    owner: IDisposableOwner,\n    ruleSet: RuleSet | undefined, initialColIds: string[],\n  ): ColumnObsRuleSet {\n    if (isEqual(ruleSet?.colIds, [\"SchemaEdit\"])) {\n      // The special rule for \"schemaEdit\" permissions.\n      return SpecialSchemaObsRuleSet.create(owner, this._accessRules, this, ruleSet, initialColIds);\n    } else if (isEqual(ruleSet?.colIds, [\"AccessRules\"])) {\n      return (this._allowAccessRules =\n        SpecialObsRuleSetAccessRules.create(owner, this._accessRules, this, ruleSet, initialColIds));\n    } else if (isEqual(ruleSet?.colIds, [\"DocCopies\"])) {\n      return (this._denyCopies =\n        SpecialObsRuleSetDenyCopies.create(owner, this._accessRules, this, ruleSet, initialColIds));\n    } else {\n      return SpecialObsRuleSet.create(owner, this._accessRules, this, ruleSet, initialColIds);\n    }\n  }\n}\n\nclass SpecialRulesTemplates extends SpecialRules {\n  private _isExpanded = Observable.create<boolean>(this, this.getResources().length > 0);\n\n  public buildDom() {\n    return cssSection(\n      cssSectionHeading(\n        cssSectionHeadingToggle(cssSectionHeadingToggleIcon(\"Expand\"),\n          cssSectionHeadingToggle.cls(\"-expanded\", this._isExpanded),\n          dom.on(\"click\", () => this._isExpanded.set(!this._isExpanded.get())),\n          testId(\"special-rules-templates-expand\"),\n        ),\n        t(\"Special rules for templates\"),\n      ),\n      dom.maybe(this._isExpanded, () => this.buildCheckBoxes()),\n      testId(\"special-rules-templates\"),\n    );\n  }\n}\n\n// Represents one RuleSet, for a combination of columns in one table, or the default RuleSet for\n// all remaining columns in a table.\nabstract class ObsRuleSet extends Disposable {\n  // Whether rules changed, and if they are valid. Never unchanged if this._ruleSet is undefined.\n  public ruleStatus: Computed<RuleStatus>;\n\n  // List of individual rule parts for this entity. The default permissions may be included as the\n  // last rule part, with an empty aclFormula.\n  protected readonly _body = this.autoDispose(obsArray<ObsRulePart>());\n\n  // ruleSet is omitted for a new ObsRuleSet added by the user.\n  constructor(public accessRules: AccessRules, protected _tableRules: TableRules | null, private _ruleSet?: RuleSet) {\n    super();\n    const parts = this._ruleSet?.body.map(part => ObsRulePart.create(this._body, this, part)) || [];\n    if (parts.length === 0) {\n      // If creating a new RuleSet, or if there are no rules,\n      // start with just a default permission part.\n      parts.push(ObsRulePart.create(this._body, this, undefined));\n    }\n    this._body.set(parts);\n\n    this.ruleStatus = Computed.create(this, this._body, (use, body) => {\n      // If anything was changed or added, some part.ruleStatus will be other than Unchanged. If\n      // there were only removals, then body.length will have changed.\n      // Ignore empty rules.\n      return Math.max(\n        getChangedStatus(body.filter(part => !part.isEmpty(use)).length < (this._ruleSet?.body?.length || 0)),\n        ...body.map(part => use(part.ruleStatus)));\n    });\n  }\n\n  public remove() {\n    this._tableRules?.removeRuleSet(this);\n  }\n\n  public getRules(tableId: string): RuleRec[] {\n    // Return every part in the body, tacking on resourceRec to each rule.\n    return this._body.get().map(part => ({\n      ...part.getRulePart(),\n      resourceRec: { tableId, colIds: this.getColIds() },\n    }))\n    // Skip entirely empty rule parts: they are invalid and dropping them is the best fix.\n      .filter(part => part.aclFormula || part.permissionsText);\n  }\n\n  public getColIds(): string {\n    return \"*\";\n  }\n\n  /**\n   * Check if RuleSet may only add permissions, only remove permissions, or may do either.\n   * A rule that neither adds nor removes permissions is treated as mixed for simplicity,\n   * though this would be suboptimal if this were a useful case to support.\n   */\n  public summarizePermissions(): MixedPermissionValue {\n    return summarizePermissions(this._body.get().map(p => p.summarizePermissions()));\n  }\n\n  public abstract buildResourceDom(): DomElementArg;\n\n  public buildRuleSetDom() {\n    return cssTableRow(\n      cssCell1(cssCell.cls(\"-rborder\"),\n        this.buildResourceDom(),\n        testId(\"rule-resource\"),\n      ),\n      cssCell4(cssRuleBody.cls(\"\"),\n        dom.forEach(this._body, part => part.buildRulePartDom()),\n        dom.maybe(use => !this.hasDefaultCondition(use), () =>\n          cssColumnGroup(\n            { style: \"min-height: 28px\" },\n            cssCellIcon(\n              cssIconButton(icon(\"Plus\"),\n                dom.on(\"click\", () => this.addRulePart(null)),\n                testId(\"rule-add\"),\n              ),\n            ),\n            testId(\"rule-extra-add\"),\n          ),\n        ),\n      ),\n      testId(\"rule-set\"),\n    );\n  }\n\n  public removeRulePart(rulePart: ObsRulePart) {\n    removeItem(this._body, rulePart);\n    if (this._body.get().length === 0) {\n      this._tableRules?.removeRuleSet(this);\n    }\n  }\n\n  public addRulePart(beforeRule: ObsRulePart | null,\n    content?: RulePart,\n    isNew: boolean = false): ObsRulePart {\n    const body = this._body.get();\n    const i = beforeRule ? body.indexOf(beforeRule) : body.length;\n    const part = ObsRulePart.create(this._body, this, content, isNew);\n    this._body.splice(i, 0, part);\n    return part;\n  }\n\n  /**\n   * Add a sequence of rules, taking priority over existing rules.\n   * optionally, if lowest-priority rule being added applies to\n   * everyone, and the existing rule also applies to everyone,\n   * fold those rules into one.\n   * This method is currently only called on newly created rule\n   * sets, so there's no need to check permissions and memos.\n   */\n  public addRuleParts(newParts: ObsRulePart[], options: { foldEveryoneRule?: boolean }) {\n    // Check if we need to consider folding rules that apply to everyone.\n    if (options.foldEveryoneRule) {\n      const oldParts = this._body.get();\n      const myEveryonePart = (oldParts.length === 1 && !oldParts[0].getRulePart().aclFormula) ? oldParts[0] : null;\n      const newEveryonePart = newParts[newParts.length - 1]?.getRulePart().aclFormula ? null :\n        newParts[newParts.length - 1];\n      if (myEveryonePart && newEveryonePart) {\n        // It suffices to remove the existing rule that applies to everyone,\n        // which is just an empty default from rule set creation.\n        removeItem(this._body, myEveryonePart);\n      }\n    }\n    for (const part of [...newParts].reverse()) {\n      const { permissionsText, aclFormula, memo } = part.getRulePart();\n      if (permissionsText === undefined || aclFormula === undefined) {\n        // Should not happen.\n        continue;\n      }\n\n      // Include only the permissions for the bits that this RuleSet supports. E.g. this matters\n      // for seed rules, which may include create/delete bits which shouldn't apply to columns.\n      const origPermissions = parsePermissions(permissionsText);\n      const trimmedPermissions = trimPermissions(origPermissions, this.getAvailableBits());\n      const trimmedPermissionsText = permissionSetToText(trimmedPermissions);\n\n      this.addRulePart(\n        this.getFirst() || null,\n        {\n          aclFormula,\n          permissionsText: trimmedPermissionsText,\n          permissions: trimmedPermissions,\n          memo,\n        },\n        true,\n      );\n    }\n  }\n\n  /**\n   * Returns the first built-in rule. It's the only one of the built-in rules to get a \"+\" next to\n   * it, since we don't allow inserting new rules in-between built-in rules.\n   */\n  public getFirstBuiltIn(): ObsRulePart | undefined {\n    return this._body.get().find(p => p.isBuiltIn());\n  }\n\n  // Get first rule part, built-in or not.\n  public getFirst(): ObsRulePart | undefined {\n    return this._body.get()[0];\n  }\n\n  /**\n   * When an empty-condition RulePart is the only part of a RuleSet, we can say it applies to\n   * \"Everyone\".\n   */\n  public isSoleCondition(use: UseCB, part: ObsRulePart): boolean {\n    const body = use(this._body);\n    return body.length === 1 && body[0] === part;\n  }\n\n  /**\n   * When an empty-condition RulePart is last in a RuleSet, we say it applies to \"Everyone Else\".\n   */\n  public isLastCondition(use: UseCB, part: ObsRulePart): boolean {\n    const body = use(this._body);\n    return body[body.length - 1] === part;\n  }\n\n  public hasDefaultCondition(use: UseCB): boolean {\n    const body = use(this._body);\n    return body.length > 0 && body[body.length - 1].hasEmptyCondition(use);\n  }\n\n  public getDefaultCondition(): ObsRulePart | null {\n    const body = this._body.get();\n    const last = body.length > 0 ? body[body.length - 1] : null;\n    return last?.hasEmptyCondition(unwrap) ? last : null;\n  }\n\n  /**\n   * Which permission bits to allow the user to set.\n   */\n  public getAvailableBits(): PermissionKey[] {\n    return AVAILABLE_BITS_TABLES;\n  }\n\n  /**\n   * Get valid colIds for the table that this RuleSet is for.\n   */\n  public getValidColIds(): string[] {\n    const tableId = this._tableRules?.tableId;\n    return (tableId && this.accessRules.getValidColIds(tableId)) || [];\n  }\n\n  public getColTypeInfo() { return this.accessRules.getColTypeInfo(this._tableRules?.tableId); }\n\n  public typeCheckFormula(formulaParsed: ParsedPredicateFormula) {\n    return this.accessRules.typeCheckFormula(formulaParsed, this._tableRules?.tableId);\n  }\n\n  /**\n   * Check if this rule set is limited to a set of columns.\n   */\n  public hasColumns() {\n    return false;\n  }\n\n  public hasOnlyBuiltInRules() {\n    return this._body.get().every(rule => rule.isBuiltIn());\n  }\n\n  // Get rule parts that are neither built-in nor empty.\n  public getCustomRules(): ObsRulePart[] {\n    return this._body.get().filter(rule => !rule.isBuiltInOrEmpty());\n  }\n\n  /**\n   * If the set applies to a special column, return its name.\n   */\n  public getSpecialColumn(): string | undefined {\n    if (this._ruleSet?.tableId === SPECIAL_RULES_TABLE_ID &&\n      this._ruleSet.colIds.length === 1) {\n      return this._ruleSet.colIds[0];\n    }\n  }\n}\n\nclass ColumnObsRuleSet extends ObsRuleSet {\n  // Error message for this rule set, or '' if valid.\n  public formulaError: Computed<string>;\n\n  private _colIds = Observable.create<string[]>(this, this._initialColIds);\n\n  constructor(accessRules: AccessRules, tableRules: TableRules, ruleSet: RuleSet | undefined,\n    private _initialColIds: string[]) {\n    super(accessRules, tableRules, ruleSet);\n\n    this.formulaError = Computed.create(this, (use) => {\n      // Exempt existing colIds from checks, by including as a third argument.\n      return accessRules.checkTableColumns(tableRules.tableId, use(this._colIds), this._initialColIds);\n    });\n\n    const baseRuleStatus = this.ruleStatus;\n    this.ruleStatus = Computed.create(this, (use) => {\n      if (use(this.formulaError)) { return RuleStatus.Invalid; }\n      return Math.max(\n        getChangedStatus(!isEqual(use(this._colIds), this._initialColIds)),\n        use(baseRuleStatus));\n    });\n  }\n\n  public buildResourceDom(): DomElementArg {\n    return aclColumnList(this._colIds, this._getValidColIdsList());\n  }\n\n  public getColIdList(): string[] {\n    return this._colIds.get();\n  }\n\n  public removeColId(colId: string) {\n    this._colIds.set(this._colIds.get().filter(c => (c !== colId)));\n  }\n\n  public getColIds(): string {\n    return this._colIds.get().join(\",\");\n  }\n\n  public getAvailableBits(): PermissionKey[] {\n    return AVAILABLE_BITS_COLUMNS;\n  }\n\n  public hasColumns() {\n    return true;\n  }\n\n  private _getValidColIdsList(): string[] {\n    return this.getValidColIds().filter(id => id !== \"id\");\n  }\n}\n\nclass DefaultObsRuleSet extends ObsRuleSet {\n  constructor(accessRules: AccessRules, tableRules: TableRules | null,\n    private _haveColumnRules?: Observable<boolean>, ruleSet?: RuleSet) {\n    super(accessRules, tableRules, ruleSet);\n  }\n\n  public buildResourceDom() {\n    return [\n      cssCenterContent.cls(\"\"),\n      cssDefaultLabel(\n        dom.domComputed(use => this._haveColumnRules && use(this._haveColumnRules), haveColRules =>\n          haveColRules ? withInfoTooltip(t(\"All\"), \"accessRulesTableWide\") : t(\"All\")),\n      ),\n    ];\n  }\n}\n\ninterface SpecialRuleBody {\n  permissions: string;\n  formula: string;\n}\n\n/**\n * Properties we need to know about how a special rule should function and\n * be rendered.\n */\ninterface SpecialRuleProperties extends SpecialRuleBody {\n  description: string;\n  name: string;\n  availableBits: PermissionKey[];\n}\n\nconst schemaEditRules: { [key: string]: SpecialRuleBody } = {\n  allowEditors: {\n    permissions: \"+S\",\n    formula: \"user.Access == EDITOR\",\n  },\n  denyEditors: {\n    permissions: \"-S\",\n    formula: \"user.Access != OWNER\",\n  },\n};\n\nconst specialRuleProperties: Record<SpecialRuleName, SpecialRuleProperties> = {\n  AccessRules: {\n    name: t(\"Permission to view Access Rules\"),\n    description: t(\"Allow everyone to view access rules.\"),\n    availableBits: [\"read\"],\n    permissions: \"+R\",\n    formula: \"True\",\n  },\n  DocCopies: {\n    name: t(\"Permission to access the document in full by unrestricted users\"),\n    description: t(`Restrict non-Owners from copying or downloading the full document. \\\nNote: this only affects users without read restrictions, since others will be restricted \\\nregardless of this setting.`),\n    availableBits: [\"read\"],\n    permissions: \"-R\",\n    formula: \"user.Access != OWNER\",\n  },\n  FullCopies: {\n    name: t(\"Permission to access the document in full by all users\"),\n    description: t(`Circumvent all read restrictions and allow everyone to copy the entire document, \\\nor view it in full in fiddle mode. \\\nOnly use for for examples and templates, not for documents with sensitive data.`),\n    availableBits: [\"read\"],\n    permissions: \"+R\",\n    formula: \"True\",\n  },\n  SeedRule: {\n    name: t(\"Seed rules\"),\n    description: t(\"When adding table rules, automatically add a rule to grant OWNER full access.\"),\n    availableBits: [\"read\", \"create\", \"update\", \"delete\"],\n    permissions: \"+CRUD\",\n    formula: \"user.Access in [OWNER]\",\n  },\n  SchemaEdit: {\n    name: t(\"Permission to edit document structure\"),\n    description: t(`Allow Editors to edit structure (e.g. modify and delete tables, columns, and \\\nlayouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access \\\nall data, regardless of table and column access rules!`),\n    availableBits: [\"schemaEdit\"],\n    ...schemaEditRules.denyEditors,\n  },\n};\n\nfunction getSpecialRuleProperties(name: string): SpecialRuleProperties {\n  return specialRuleProperties[name as SpecialRuleName] || {\n    ...specialRuleProperties.AccessRules,\n    name,\n    description: name,\n  };\n}\n\nclass SpecialObsRuleSet extends ColumnObsRuleSet {\n  protected _isExpanded = Observable.create<boolean>(this, false);\n  protected _isNonStandard = this._createIsNonStandardObs(this);\n  protected _isChecked = this._createIsCheckedObs(this, this._isNonStandard);\n  protected _isNonEmpty = Computed.create<boolean>(this, use => use(this._isNonStandard) || use(this._isChecked));\n\n  public get props() {\n    return getSpecialRuleProperties(this.getColIds());\n  }\n\n  public get isNonEmpty() { return this._isNonEmpty; }\n\n  public buildRuleSetDom() {\n    if (this._isNonStandard.get()) {\n      this._isExpanded.set(true);\n    }\n\n    return dom(\"div\",\n      cssRuleDescription(\n        cssIconButton(icon(\"Expand\"),\n          dom.style(\"transform\", use => use(this._isExpanded) ? \"rotate(90deg)\" : \"\"),\n          dom.on(\"click\", () => this._isExpanded.set(!this._isExpanded.get())),\n          testId(\"rule-special-expand\"),\n          { style: \"margin: -4px\" },  // subtract padding to align better.\n        ),\n        cssCheckbox(this._isChecked,\n          dom.prop(\"disabled\", this._isNonStandard),\n          testId(\"rule-special-checkbox\"),\n        ),\n        this.props.description,\n      ),\n      this._buildDomWarning(),\n      dom.maybe(this._isExpanded, () =>\n        cssTableRounded(\n          { style: \"margin-left: 56px\" },\n          cssTableHeaderRow(\n            cssCellIcon(),\n            cssCell4(cssColHeaderCell(this.props.name)),\n            cssCell1(cssColHeaderCell(\"Permissions\")),\n            cssCellIconWithMargins(),\n            cssCellIcon(),\n          ),\n          cssTableRow(\n            cssRuleBody.cls(\"\"),\n            dom.forEach(this._body, part => part.buildRulePartDom(true)),\n            dom.maybe(use => !this.hasDefaultCondition(use), () =>\n              cssColumnGroup(\n                { style: \"min-height: 28px\" },\n                cssCellIcon(\n                  cssIconButton(\n                    icon(\"Plus\"),\n                    dom.on(\"click\", () => this.addRulePart(null)),\n                    testId(\"rule-add\"),\n                  ),\n                ),\n                testId(\"rule-extra-add\"),\n              ),\n            ),\n          ),\n          testId(\"rule-set\"),\n        ),\n      ),\n      testId(\"rule-special\"),\n      testId(`rule-special-${this.getColIds()}`),   // Make accessible in tests as, e.g. rule-special-FullCopies\n    );\n  }\n\n  public getAvailableBits(): PermissionKey[] {\n    return this.props.availableBits;\n  }\n\n  public removeRulePart(rulePart: ObsRulePart) {\n    removeItem(this._body, rulePart);\n    if (this._body.get().every(rule => rule.isBuiltInOrEmpty())) {\n      this._isExpanded.set(false);\n      this._setToChecked(false);\n    }\n  }\n\n  public setToRecommended() {\n    this._setToChecked(false);\n  }\n\n  public clearRules() {\n    this._body.splice(0);\n  }\n\n  protected _buildDomWarning(): DomContents {\n    return null;\n  }\n\n  // Observable for whether this ruleSet is \"standard\", i.e. checked or unchecked state, without\n  // any strange rules that need to be shown expanded with the checkbox greyed out.\n  protected _createIsNonStandardObs(owner: IDisposableOwner): Observable<boolean> {\n    return Computed.create(owner, this._body, (use, body) =>\n      !body.every(rule => rule.isBuiltInOrEmpty(use) || rule.matches(use, this.props.formula, this.props.permissions)));\n  }\n\n  // Observable for whether the checkbox should be shown as checked. Writing to it will update\n  // rules so as to toggle the checkbox.\n  protected _createIsCheckedObs(owner: IDisposableOwner, isNonStandard: Observable<boolean>): Observable<boolean> {\n    return Computed.create(owner, this._body,\n      (use, body) => !use(isNonStandard) && !body.every(rule => rule.isBuiltInOrEmpty(use)))\n      .onWrite(val => this._setToChecked(val));\n  }\n\n  protected _setToChecked(value: boolean) {\n    const builtInRules = this._body.get().filter(r => r.isBuiltIn());\n    if (value) {\n      const rulePart = makeRulePart(this.props);\n      this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);\n    } else {\n      this._body.set(builtInRules);\n      if (builtInRules.length === 0) {\n        this._body.push(ObsRulePart.create(this._body, this, undefined));\n      }\n    }\n  }\n}\n\nfunction makeRulePart({ permissions, formula }: SpecialRuleBody): RulePart {\n  const rulePart: RulePart = {\n    aclFormula: formula,\n    permissionsText: permissions,\n    permissions: parsePermissions(permissions),\n  };\n  return rulePart;\n}\n\nclass SpecialObsRuleSetAccessRules extends SpecialObsRuleSet {\n  constructor(accessRules: AccessRules, protected _tableRules: SpecialRulesMain, ruleSet: RuleSet | undefined,\n    initialColIds: string[]) {\n    super(accessRules, _tableRules, ruleSet, initialColIds);\n  }\n\n  public _setToChecked(value: boolean) {\n    super._setToChecked(value);\n    this._tableRules.onAccessRulesToggled(value);\n  }\n}\n\nclass SpecialObsRuleSetDenyCopies extends SpecialObsRuleSet {\n  constructor(accessRules: AccessRules, protected _tableRules: SpecialRulesMain, ruleSet: RuleSet | undefined,\n    initialColIds: string[]) {\n    super(accessRules, _tableRules, ruleSet, initialColIds);\n  }\n\n  public onAccessRulesToggled(value: boolean) {\n    if (!this._isNonStandard.get()) {\n      this._setToChecked(value);\n    }\n  }\n\n  public buildRuleSetDom() {\n    return dom.update(\n      super.buildRuleSetDom(),\n      dom.show((use) => {\n        const allowAccessRules = this._tableRules.allowAccessRules?.isNonEmpty;\n        return (allowAccessRules && use(allowAccessRules)) || use(this._isNonStandard);\n      }),\n    );\n  }\n}\n\n/**\n * SchemaEdit permissions are moved out to a special fake resource \"*SPECIAL:SchemaEdit\" in the\n * frontend, to be presented under their own checkbox option. Its behaviors are a bit different\n * from other checkbox options; the differences are in the overridden methods here.\n */\nclass SpecialSchemaObsRuleSet extends SpecialObsRuleSet {\n  public setToRecommended() {\n    this._allowEditors(false);\n  }\n\n  protected _buildDomWarning(): DomContents {\n    return dom.maybe(\n      use => use(this._body).every(rule => rule.isBuiltInOrEmpty(use)),\n      // TODO get rid of red disclaimer.\n      () => cssError(\n        t(\"This options should be off if Editors' access is to be limited. \"),\n        dom(\"a\", { style: \"color: inherit; text-decoration: underline\" },\n          \"Dismiss.\", dom.on(\"click\", () => this._allowEditors(\"confirm\"))),\n        testId(\"rule-schema-edit-warning\"),\n      ),\n    );\n  }\n\n  // SchemaEdit rules support an extra \"standard\" state, where a no-op rule exists (explicit rule\n  // allowing EDITORs SchemaEdit permission), in which case we don't show a warning.\n  protected _createIsNonStandardObs(owner: IDisposableOwner): Observable<boolean> {\n    return Computed.create(owner, this._body, (use, body) =>\n      !body.every(rule => rule.isBuiltInOrEmpty(use) || rule.matches(use, this.props.formula, this.props.permissions) ||\n        rule.matches(use, schemaEditRules.allowEditors.formula, schemaEditRules.allowEditors.permissions)));\n  }\n\n  protected _createIsCheckedObs(owner: IDisposableOwner, isNonStandard: Observable<boolean>): Observable<boolean> {\n    return Computed.create(owner, this._body,\n      (use, body) => body.every(rule => rule.isBuiltInOrEmpty(use) ||\n        rule.matches(use, schemaEditRules.allowEditors.formula, schemaEditRules.allowEditors.permissions)))\n      .onWrite(val => this._allowEditors(val));\n  }\n\n  // The third \"confirm\" option is used by the \"Dismiss\" link in the warning.\n  private _allowEditors(value: boolean | \"confirm\") {\n    const builtInRules = this._body.get().filter(r => r.isBuiltIn());\n    if (value === \"confirm\") {\n      const rulePart = makeRulePart(schemaEditRules.allowEditors);\n      this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);\n    } else if (!value) {\n      const rulePart = makeRulePart(schemaEditRules.denyEditors);\n      this._body.set([ObsRulePart.create(this._body, this, rulePart, true), ...builtInRules]);\n    } else {\n      this._body.set(builtInRules);\n    }\n  }\n}\n\nclass ObsUserAttributeRule extends Disposable {\n  public ruleStatus: Computed<RuleStatus>;\n\n  // If the rule failed validation, the error message to show. Blank if valid.\n  public formulaError: Computed<string>;\n\n  private _name = Observable.create<string>(this, this._userAttr?.name || \"\");\n  private _tableId = Observable.create<string>(this, this._userAttr?.tableId || \"\");\n  private _lookupColId = Observable.create<string>(this, this._userAttr?.lookupColId || \"\");\n  private _charId = Observable.create<string>(this, \"user.\" + (this._userAttr?.charId || \"\"));\n  private _validColIds = Computed.create(this, this._tableId, (use, tableId) =>\n    this._accessRules.getValidColIds(tableId) || []);\n\n  private _userAttrChoices: Computed<IAttrOption[]>;\n  private _userAttrError = Observable.create(this, \"\");\n\n  constructor(private _accessRules: AccessRules, private _userAttr?: UserAttributeRule,\n    private _options: { focus?: boolean } = {}) {\n    super();\n    this.formulaError = Computed.create(\n      this, this._tableId, this._lookupColId, this._userAttrError,\n      (use, tableId, colId, userAttrError) => {\n        if (userAttrError.length) {\n          return userAttrError;\n        }\n\n        // Don't check for errors if it's an existing rule and hasn't changed.\n        if (use(this._tableId) === this._userAttr?.tableId &&\n          use(this._lookupColId) === this._userAttr?.lookupColId) {\n          return \"\";\n        }\n        return _accessRules.checkTableColumns(tableId, colId ? [colId] : undefined);\n      });\n    this.ruleStatus = Computed.create(this, (use) => {\n      if (use(this.formulaError)) { return RuleStatus.Invalid; }\n      return getChangedStatus(\n        use(this._name) !== this._userAttr?.name ||\n        use(this._tableId) !== this._userAttr?.tableId ||\n        use(this._lookupColId) !== this._userAttr?.lookupColId ||\n        use(this._charId) !== \"user.\" + this._userAttr?.charId,\n      );\n    });\n\n    // Reset lookupColId when tableId changes, since a colId from a different table would usually be wrong\n    this.autoDispose(this._tableId.addListener(() => this._lookupColId.set(\"\")));\n\n    this._userAttrChoices = Computed.create(this, _accessRules.userAttrRules, (use, rules) => {\n      // Filter for only those choices created by previous rules.\n      const index = rules.indexOf(this);\n      return use(this._accessRules.userAttrChoices).filter(c => (c.ruleIndex < index));\n    });\n  }\n\n  public remove() {\n    this._accessRules.removeUserAttributes(this);\n  }\n\n  public get name() { return this._name; }\n  public get tableId() { return this._tableId; }\n\n  public buildUserAttrDom() {\n    return cssTableRow(\n      cssCell1(cssCell.cls(\"-rborder\"),\n        cssCellContent(\n          cssInput(this._name, async val => this._name.set(val),\n            { placeholder: t(\"Attribute name\") },\n            (this._options.focus ? (elem) => { setTimeout(() => elem.focus(), 0); } : null),\n            testId(\"rule-userattr-name\"),\n          ),\n        ),\n      ),\n      cssCell4(cssRuleBody.cls(\"\"),\n        cssColumnGroup(\n          cssCell1(\n            aclFormulaEditor({\n              initialValue: this._charId.get(),\n              readOnly: false,\n              setValue: text => this._setUserAttr(text),\n              placeholder: \"\",\n              getSuggestions: () => this._userAttrChoices.get().map(s => new Suggestion(s.value, s)),\n              customiseEditor: (editor) => {\n                editor.on(\"focus\", () => {\n                  if (editor.getValue() == \"user.\") {\n                    // TODO this weirdly only works on the first click\n                    (editor as any).completer?.showPopup(editor);\n                  }\n                });\n              },\n            }),\n            testId(\"rule-userattr-attr\"),\n          ),\n          cssCell1(\n            aclSelect(\n              this._tableId,\n              this._accessRules.allTableIds.map(tableId => ({\n                value: tableId,\n                label: this._accessRules.getTableTitle(tableId),\n              })),\n              { defaultLabel: \"[Select Table]\" },\n            ),\n            testId(\"rule-userattr-table\"),\n          ),\n          cssCell1(\n            aclSelect(this._lookupColId, this._validColIds,\n              { defaultLabel: \"[Select Column]\" }),\n            testId(\"rule-userattr-col\"),\n          ),\n          cssCellIcon(\n            cssIconButton(icon(\"Remove\"),\n              dom.on(\"click\", () => this._accessRules.removeUserAttributes(this))),\n          ),\n          dom.maybe(this.formulaError, msg => cssConditionError(msg, testId(\"rule-error\"))),\n        ),\n      ),\n      testId(\"rule-userattr\"),\n    );\n  }\n\n  public getRule() {\n    const fullCharId = this._charId.get().trim();\n    const strippedCharId = fullCharId.startsWith(\"user.\") ?\n      fullCharId.substring(\"user.\".length) : fullCharId;\n    const spec = {\n      name: this._name.get(),\n      tableId: this._tableId.get(),\n      lookupColId: this._lookupColId.get(),\n      charId: strippedCharId,\n    };\n    for (const [prop, value] of Object.entries(spec)) {\n      if (!value) {\n        throw new UserError(t(\"Invalid user attribute rule: {{prop}} must be set\", { prop }));\n      }\n    }\n    if (this._getUserAttrError(fullCharId)) {\n      throw new UserError(t(\"Invalid user attribute to look up\"));\n    }\n    return {\n      id: this._userAttr?.origRecord?.id,\n      rulePos: this._userAttr?.origRecord?.rulePos as number | undefined,\n      userAttributes: JSON.stringify(spec),\n    };\n  }\n\n  private _setUserAttr(text: string) {\n    if (text === this._charId.get()) {\n      return;\n    }\n    this._charId.set(text);\n    this._userAttrError.set(this._getUserAttrError(text) || \"\");\n  }\n\n  private _getUserAttrError(text: string): string | null {\n    text = text.trim();\n    if (text.startsWith(\"user.LinkKey\")) {\n      if (/user\\.LinkKey\\.\\w+$/.test(text)) {\n        return null;\n      }\n      return t(\"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\");\n    }\n\n    const isChoice = this._userAttrChoices.get().map(choice => choice.value).includes(text);\n    if (!isChoice) {\n      return t(\"Not a valid user attribute\");\n    }\n    return null;\n  }\n}\n\n// Represents one line of a RuleSet, a combination of an aclFormula and permissions to apply to\n// requests that match it.\nclass ObsRulePart extends Disposable {\n  // Whether the rule part, and if it's valid or being checked.\n  public ruleStatus: Computed<RuleStatus>;\n\n  public focusEditor: (() => void) | undefined;\n\n  // Formula to show in the formula editor.\n  private _aclFormula = Observable.create<string>(this, this._rulePart?.aclFormula || \"\");\n\n  // Rule-specific completions for editing the formula, e.g. \"user.Email\" or \"rec.City\".\n  private _completions = Computed.create<ISuggestionWithSubAttrs[]>(this, (use) => {\n    const colInfo = this._ruleSet.getColTypeInfo();\n    return [\n      ...use(this._ruleSet.accessRules.userAttrChoices).map(s => new Suggestion(s.value, s)),\n      ...colInfo.map(c => new Suggestion(`rec.${c.colId}`, c)),\n      ...colInfo.map(c => new Suggestion(`$${c.colId}`, c)),\n      ...colInfo.map(c => new Suggestion(`newRec.${c.colId}`, c)),\n    ];\n  });\n\n  // The permission bits.\n  private _permissions = Observable.create<PartialPermissionSet>(\n    this, this._rulePart?.permissions || emptyPermissionSet());\n\n  // The memo text. Updated whenever changes are made within `_memoEditor`.\n  private _memo: Observable<string>;\n\n  // Reference to the memo editor element, for triggering focus. Shown when\n  // `_showMemoEditor` is true.\n  private _memoEditor: HTMLInputElement | undefined;\n\n  // Is the memo editor visible? Initialized to true if a saved memo exists for this rule.\n  private _showMemoEditor: Observable<boolean>;\n\n  // Whether the rule is being checked after a change. Saving will wait for such checks to finish.\n  private _checkPending = Observable.create(this, false);\n\n  // If the formula failed validation, the error message to show. Blank if valid.\n  private _formulaError = Observable.create(this, \"\");\n\n  private _formulaProperties = Observable.create<PredicateFormulaProperties>(this,\n    getAclFormulaProperties(this._rulePart));\n\n  // Error message if any validation failed.\n  private _error: Computed<string>;\n\n  constructor(private _ruleSet: ObsRuleSet, private _rulePart?: RulePart, isNew = false) {\n    super();\n    this._memo = Observable.create(this, _rulePart?.memo ?? \"\");\n\n    if (_rulePart && isNew) {\n      // rulePart is omitted for a new ObsRulePart added by the user. If given, isNew may be set to\n      // treat the rule as new and only use the rulePart for its initialization.\n      this._rulePart = undefined;\n    }\n\n    // If this rule has a blank memo, don't show the editor.\n    this._showMemoEditor = Observable.create(this, !this.isBuiltIn() && this._memo.get() !== \"\");\n\n    this._error = Computed.create(this, (use) => {\n      return use(this._formulaError) ||\n        this._warnInvalidFormula(use(this._formulaProperties)) ||\n        (!this._ruleSet.isLastCondition(use, this) &&\n          use(this._aclFormula) === \"\" &&\n          permissionSetToText(use(this._permissions)) !== \"\" ?\n          t(\"Condition cannot be blank\") : \"\"\n        );\n    });\n\n    const emptyPerms = emptyPermissionSet();\n    this.ruleStatus = Computed.create(this, (use) => {\n      if (use(this._error)) { return RuleStatus.Invalid; }\n      if (use(this._checkPending)) { return RuleStatus.CheckPending; }\n      return getChangedStatus(\n        use(this._aclFormula) !== (this._rulePart?.aclFormula ?? \"\") ||\n        use(this._memo) !== (this._rulePart?.memo ?? \"\") ||\n        !isEqual(use(this._permissions), this._rulePart?.permissions ?? emptyPerms),\n      );\n    });\n    // The formula may be invalid from the beginning. Make sure we show errors in this\n    // case.\n    const text = this._aclFormula.get();\n    if (text) {\n      this._setAclFormula(text, true).catch((e) => {\n        console.error(e);\n      });\n    }\n  }\n\n  public getRulePart(): RuleRec {\n    // Use id of 0 to distinguish built-in rules from newly added rule, which will have id of undefined.\n    const id = this.isBuiltIn() ? 0 : this._rulePart?.origRecord?.id;\n    return {\n      id,\n      aclFormula: this._aclFormula.get(),\n      permissionsText: permissionSetToText(this._permissions.get()),\n      rulePos: this._rulePart?.origRecord?.rulePos as number | undefined,\n      memo: this._memo.get(),\n    };\n  }\n\n  public hasEmptyCondition(use: UseCB): boolean {\n    return use(this._aclFormula) === \"\";\n  }\n\n  public matches(use: UseCB, aclFormula: string, permissionsText: string): boolean {\n    return (use(this._aclFormula) === aclFormula &&\n      permissionSetToText(use(this._permissions)) === permissionsText);\n  }\n\n  /**\n   * Check if RulePart may only add permissions, only remove permissions, or may do either.\n   * A rule that neither adds nor removes permissions is treated as mixed for simplicity,\n   * though this would be suboptimal if this were a useful case to support.\n   */\n  public summarizePermissions(): MixedPermissionValue {\n    return summarizePermissionSet(this._permissions.get());\n  }\n\n  /**\n   * Verify that the rule is in a good state, optionally given a proposed permission change.\n   */\n  public sanityCheck(pset?: PartialPermissionSet) {\n    // Nothing to do!  We now support all expressible rule permutations.\n  }\n\n  public buildRulePartDom(wide: boolean = false) {\n    return cssRulePartAndMemo(\n      cssColumnGroup(\n        cssCellIcon(\n          (this._isNonFirstBuiltIn() ?\n            null :\n            cssIconButton(icon(\"Plus\"),\n              dom.on(\"click\", () => this._ruleSet.addRulePart(this)),\n              testId(\"rule-add\"),\n            )\n          ),\n        ),\n        cssCell2(\n          wide ? cssCell4.cls(\"\") : null,\n          aclFormulaEditor({\n            initialValue: this._aclFormula.get(),\n            readOnly: this.isBuiltIn(),\n            setValue: value => this._setAclFormula(value),\n            placeholder: dom.text((use) => {\n              return (\n                this._ruleSet.isSoleCondition(use, this) ? t(\"Everyone\") :\n                  this._ruleSet.isLastCondition(use, this) ? t(\"Everyone Else\") :\n                    t(\"Enter Condition\")\n              );\n            }),\n            getSuggestions: () => this._completions.get(),\n            customiseEditor: (editor) => { this.focusEditor = () => editor.focus(); },\n          }),\n          testId(\"rule-acl-formula\"),\n        ),\n        cssCell1(cssCell.cls(\"-stretch\"),\n          permissionsWidget(this._ruleSet.getAvailableBits(), this._permissions,\n            { disabled: this.isBuiltIn(), sanityCheck: pset => this.sanityCheck(pset) },\n            testId(\"rule-permissions\"),\n          ),\n        ),\n        cssCellIconWithMargins(\n          dom.maybe(use => !this.isBuiltIn() && !use(this._showMemoEditor), () =>\n            cssIconButton(icon(\"Memo\"),\n              dom.on(\"click\", () => {\n                this._showMemoEditor.set(true);\n                // Note that focus is set when the memo icon is clicked, and not when\n                // the editor is attached to the DOM; because rules with non-blank\n                // memos have their editors visible by default when the page is first\n                // loaded, focusing on creation could cause unintended focusing.\n                setTimeout(() => this._memoEditor?.focus(), 0);\n              }),\n              testId(\"rule-memo-add\"),\n            ),\n          ),\n        ),\n        cssCellIcon(\n          (this.isBuiltIn() ?\n            null :\n            cssIconButton(icon(\"Remove\"),\n              dom.on(\"click\", () => this._ruleSet.removeRulePart(this)),\n              testId(\"rule-remove\"),\n            )\n          ),\n        ),\n        dom.maybe(this._error, msg => cssConditionError(msg, testId(\"rule-error\"))),\n        testId(\"rule-part\"),\n      ),\n      dom.maybe(this._showMemoEditor, () =>\n        cssMemoColumnGroup(\n          cssCellIcon(),\n          cssMemoIcon(\"Memo\"),\n          cssCell2(\n            wide ? cssCell4.cls(\"\") : null,\n            this._memoEditor = aclMemoEditor(this._memo,\n              {\n                placeholder: t(\"Type message to display when this rule blocks an action…\"),\n              },\n              dom.onKeyDown({\n                // Match the behavior of the formula editor.\n                Enter: (_ev, el) => el.blur(),\n              }),\n            ),\n            testId(\"rule-memo-editor\"),\n          ),\n          cssCellIconWithMargins(),\n          cssCellIcon(\n            cssIconButton(icon(\"Remove\"),\n              dom.on(\"click\", () => {\n                this._showMemoEditor.set(false);\n                this._memo.set(\"\");\n              }),\n              testId(\"rule-memo-remove\"),\n            ),\n          ),\n          testId(\"rule-memo\"),\n        ),\n      ),\n      testId(\"rule-part-and-memo\"),\n    );\n  }\n\n  public isBuiltIn(): boolean {\n    return this._rulePart ? !this._rulePart.origRecord?.id : false;\n  }\n\n  // return true if formula, permissions, and memo are all empty.\n  public isEmpty(use: UseCB = unwrap): boolean {\n    return use(this._aclFormula) === \"\" &&\n      isEqual(use(this._permissions), emptyPermissionSet()) &&\n      use(this._memo) === \"\";\n  }\n\n  public isBuiltInOrEmpty(use: UseCB = unwrap): boolean {\n    return this.isBuiltIn() || this.isEmpty(use);\n  }\n\n  private _isNonFirstBuiltIn(): boolean {\n    return this.isBuiltIn() && this._ruleSet.getFirstBuiltIn() !== this;\n  }\n\n  private async _setAclFormula(text: string, initial: boolean = false) {\n    if (text === this._aclFormula.get() && (!initial || this.isBuiltIn())) { return; }\n    this._aclFormula.set(text);\n    this._checkPending.set(true);\n    this._formulaProperties.set({});\n    this._formulaError.set(\"\");\n    try {\n      this._formulaProperties.set(await this._ruleSet.accessRules.checkAclFormula(text));\n      this.sanityCheck();\n    } catch (e) {\n      this._formulaError.set(e.message);\n    } finally {\n      this._checkPending.set(false);\n    }\n  }\n\n  private _warnInvalidFormula(formulaProperties: PredicateFormulaProperties): string | false {\n    return this._warnInvalidColIds(formulaProperties.recColIds) ||\n      this._typeCheckFormula(formulaProperties.formulaParsed);\n  }\n\n  private _warnInvalidColIds(colIds?: string[]): string | false {\n    if (!colIds?.length) { return false; }\n    const allValid = new Set(this._ruleSet.getValidColIds());\n    const specialColumn = this._ruleSet.getSpecialColumn();\n    if (specialColumn === \"SeedRule\") {\n      // We allow seed rules to refer to columns without checking\n      // them (until the seed rules are used).\n      return false;\n    }\n    const invalid = colIds.filter(c => !allValid.has(c));\n    if (invalid.length > 0) {\n      return `Invalid columns: ${invalid.join(\", \")}`;\n    }\n    return false;\n  }\n\n  private _typeCheckFormula(formulaParsed?: ParsedPredicateFormula): string | false {\n    if (!formulaParsed) { return false; }\n\n    // Don't fail seed rules. Those only get checked for validity once they are used.\n    if (this._ruleSet.getSpecialColumn() === \"SeedRule\") { return false; }\n\n    return this._ruleSet.typeCheckFormula(formulaParsed);\n  }\n}\n\n/**\n * Produce UserActions to create/update/remove records, to replace data in tableData\n * with newRecords. Records are matched on uniqueId(record), which defaults to returning\n * String(record.id). UniqueIds of new records don't need to be unique as long as they don't\n * overlap with uniqueIds of existing records.\n *\n * Return also a rowIdMap, mapping uniqueId(record) to a rowId used in the actions. The rowIds may\n * include negative values (auto-generated when newRecords doesn't include one). These may be used\n * in Reference values within the same action bundle.\n *\n * TODO This is a general-purpose function, and should live in a separate module.\n */\nfunction syncRecords(tableData: TableData, newRecords: RowRecord[],\n  uniqueId: (r: RowRecord) => string = r => String(r.id),\n): { userActions: UserAction[], rowIdMap: Map<string, number> } {\n  const oldRecords = tableData.getRecords();\n  const rowIdMap = new Map<string, number>(oldRecords.map(r => [uniqueId(r), r.id]));\n  const newRecordMap = new Map<string, RowRecord>(newRecords.map(r => [uniqueId(r), r]));\n\n  const removedRecords: RowRecord[] = oldRecords.filter(r => !newRecordMap.has(uniqueId(r)));\n\n  // Generate a unique negative rowId for each added record.\n  const addedRecords: RowRecord[] = newRecords.filter(r => !rowIdMap.has(uniqueId(r)))\n    .map((r, index) => ({ ...r, id: -(index + 1) }));\n\n  // Array of [before, after] pairs for changed records.\n  const updatedRecords: [RowRecord, RowRecord][] = oldRecords.map((r): ([RowRecord, RowRecord] | null) => {\n    const newRec = newRecordMap.get(uniqueId(r));\n    const updated = newRec && { ...r, ...newRec, id: r.id };\n    return updated && !isEqual(updated, r) ? [r, updated] : null;\n  }).filter(isNonNullish);\n\n  console.log(\"syncRecords: removing [%s], adding [%s], updating [%s]\",\n    removedRecords.map(uniqueId).join(\", \"),\n    addedRecords.map(uniqueId).join(\", \"),\n    updatedRecords.map(([r]) => uniqueId(r)).join(\", \"));\n\n  const tableId = tableData.tableId;\n  const userActions: UserAction[] = [];\n  if (removedRecords.length > 0) {\n    userActions.push([\"BulkRemoveRecord\", tableId, removedRecords.map(r => r.id)]);\n  }\n  if (updatedRecords.length > 0) {\n    userActions.push([\"BulkUpdateRecord\", tableId, updatedRecords.map(([r]) => r.id), getColChanges(updatedRecords)]);\n  }\n  if (addedRecords.length > 0) {\n    userActions.push([\"BulkAddRecord\", tableId, addedRecords.map(r => r.id), getColValues(addedRecords)]);\n  }\n\n  // Include generated rowIds for added records into the returned map.\n  addedRecords.forEach(r => rowIdMap.set(uniqueId(r), r.id));\n  return { userActions, rowIdMap };\n}\n\n/**\n * Convert a list of [before, after] rows into an object of changes, skipping columns which\n * haven't changed.\n */\nfunction getColChanges(pairs: [RowRecord, RowRecord][]): BulkColValues {\n  const colIdSet = new Set<string>();\n  for (const [before, after] of pairs) {\n    for (const c of Object.keys(after)) {\n      if (c !== \"id\" && !isEqual(before[c], after[c])) {\n        colIdSet.add(c);\n      }\n    }\n  }\n  const result: BulkColValues = {};\n  for (const colId of colIdSet) {\n    result[colId] = pairs.map(([before, after]) => after[colId]);\n  }\n  return result;\n}\n\nfunction serializeResource(rec: RowRecord): string {\n  return JSON.stringify([rec.tableId, rec.colIds]);\n}\n\nfunction flatten<T>(...args: T[][]): T[] {\n  return ([] as T[]).concat(...args);\n}\n\nfunction removeItem<T>(observableArray: MutableObsArray<T>, item: T): boolean {\n  const i = observableArray.get().indexOf(item);\n  if (i >= 0) {\n    observableArray.splice(i, 1);\n    return true;\n  }\n  return false;\n}\n\nfunction getChangedStatus(value: boolean): RuleStatus {\n  return value ? RuleStatus.ChangedValid : RuleStatus.Unchanged;\n}\n\nfunction getAclFormulaProperties(part?: RulePart): PredicateFormulaProperties {\n  const aclFormulaParsed = part?.origRecord?.aclFormulaParsed;\n  return aclFormulaParsed ? getPredicateFormulaProperties(JSON.parse(String(aclFormulaParsed))) : {};\n}\n\n// Return a rule set if it applies to one of the specified columns.\nfunction filterRuleSet(colIds: string[], ruleSet?: RuleSet): RuleSet | undefined {\n  if (!ruleSet) { return undefined; }\n  if (ruleSet.colIds === \"*\") { return ruleSet; }\n  for (const colId of ruleSet.colIds) {\n    if (colIds.includes(colId)) { return ruleSet; }\n  }\n  return undefined;\n}\n\n// Filter an array of rule sets for just those that apply to one of the specified\n// columns.\nfunction filterRuleSets(colIds: string[], ruleSets: RuleSet[]): RuleSet[] {\n  return ruleSets.map(ruleSet => filterRuleSet(colIds, ruleSet)).filter(rs => rs) as RuleSet[];\n}\n\nfunction makeSuggestionExample(value: unknown): string | undefined {\n  // Produce a representation of the value similar to Python's repr(), at least in the common case.\n  if (typeof value === \"string\") {\n    return JSON.stringify(value);     // Make clear that this is a string value\n  } else if (typeof value === \"boolean\") {\n    return value ? \"True\" : \"False\";\n  } else if (value === null) {\n    return \"None\";\n  } else if (value !== undefined) {\n    return String(value);\n  }\n}\n\nfunction getColTypeInfo(colIds: string[], tableData?: TableData): IColTypeInfo[] {\n  // Unlike aclResources, data available through docData may be restricted. If we don't know\n  // about a column, we just won't have type-specific autocomplete suggestions for it.\n  return colIds.map((colId) => {\n    const gristType = tableData?.getColType(colId);\n    // Note that example values will only be shown when tableData has been loaded, but it doesn't\n    // seem important enough to load data just for this.\n    const example = makeSuggestionExample(tableData?.getColValues(colId)?.[0]);\n    return { colId, gristType, example };\n  });\n}\n\nfunction getSampleRecord(colIds: string[], tableData?: TableData): InfoView {\n  if (!tableData) { return new EmptyRecordView(); }\n\n  const colValues: BulkColValues = {};\n  for (const colId of colIds) {\n    const gristType = tableData.getColType(colId);\n    const defaultValue = gristType ? getDefaultForType(gristType) : null;\n    // Replace null with false, to avoid producing \"No value for X\" error (from\n    // app/common/PredicateFormula.ts) for null default values, since that's usually misleading.\n    // This poor-man's type checking is weak... It actually evaluates expressions, but that means\n    // it doesn't check branches not taken.\n    colValues[colId] = defaultValue === null ? [false] : [defaultValue];\n  }\n  return new RecordView([\"TableData\", tableData.tableId, [1], colValues], 0);\n}\n\nconst cssOuter = styled(\"div\", `\n  flex: auto;\n  height: 100%;\n  width: 100%;\n  max-width: 1500px;\n  margin: 0 auto;\n  display: flex;\n  flex-direction: column;\n`);\n\nconst cssAddTableRow = styled(\"div\", `\n  flex: none;\n  margin: 16px 16px 8px 16px;\n  display: flex;\n  gap: 16px;\n`);\n\nconst cssDropdownIcon = styled(icon, `\n  margin: -2px -2px 0 4px;\n`);\n\nconst cssRemoveIcon = styled(icon, `\n  margin: -2px -2px 0 4px;\n`);\n\nconst cssSection = styled(\"div\", `\n  margin: 16px 16px 24px 16px;\n`);\n\nconst cssSectionHeading = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  margin-bottom: 8px;\n  font-weight: bold;\n  color: ${theme.lightText};\n`);\n\nconst cssSectionHeadingMarkdown = styled(cssSectionHeading, `\n  font-weight: normal;\n`);\n\nconst cssSectionHeadingToggle = styled(cssIconButton, `\n  margin-left: -4px;\n  margin-right: 8px;\n  width: unset;\n  height: unset;\n  padding: 0px;\n  &-expanded {\n    transform: rotate(90deg);\n  }\n`);\n\nconst cssSectionHeadingToggleIcon = styled(icon, `\n  width: 24px;\n  height: 24px;\n`);\n\nconst cssTableName = styled(\"span\", `\n  color: ${theme.text};\n`);\n\nconst cssInput = styled(textInput, `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  width: 100%;\n  border: 1px solid transparent;\n  cursor: pointer;\n\n  &:hover {\n    border: 1px solid ${theme.inputBorder};\n  }\n  &:focus {\n    box-shadow: inset 0 0 0 1px ${theme.controlFg};\n    border-color: ${theme.controlFg};\n    cursor: unset;\n  }\n  &[disabled] {\n    color: ${theme.inputDisabledFg};\n    background-color: ${theme.inputDisabledBg};\n    box-shadow: unset;\n    border-color: transparent;\n  }\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\nconst cssError = styled(\"div\", `\n  color: ${theme.errorText};\n  margin-left: 56px;\n  margin-bottom: 8px;\n  margin-top: 4px;\n`);\n\nconst cssConditionError = styled(\"div\", `\n  color: ${theme.errorText};\n  margin-top: 4px;\n  width: 100%;\n`);\n\n/**\n * Fairly general table styles.\n */\nconst cssTableRounded = styled(\"div\", `\n  border: 1px solid ${theme.accessRulesTableBorder};\n  border-radius: 8px;\n  overflow: hidden;\n`);\n\n// Row with a border\nconst cssTableRow = styled(\"div\", `\n  display: flex;\n  border-bottom: 1px solid ${theme.accessRulesTableBorder};\n  &:last-child {\n    border-bottom: none;\n  }\n`);\n\n// Darker table header\nconst cssTableHeaderRow = styled(cssTableRow, `\n  background-color: ${theme.accessRulesTableHeaderBg};\n  color: ${theme.accessRulesTableHeaderFg};\n`);\n\n// Cell for table column header.\nconst cssColHeaderCell = styled(\"div\", `\n  margin: 4px 8px;\n  text-transform: uppercase;\n  font-weight: 500;\n  font-size: 10px;\n`);\n\n// General table cell.\nconst cssCell = styled(\"div\", `\n  min-width: 0px;\n  overflow: hidden;\n\n  &-rborder {\n    border-right: 1px solid ${theme.accessRulesTableBorder};\n  }\n  &-center {\n    text-align: center;\n  }\n  &-stretch {\n    min-width: unset;\n    overflow: visible;\n  }\n`);\n\n// Variations on columns of different widths.\nconst cssCellIcon = styled(cssCell, `flex: none; width: 24px;`);\nconst cssCellIconWithMargins = styled(cssCellIcon, `margin: 0px 8px;`);\nconst cssCell1 = styled(cssCell, `flex: 1;`);\nconst cssCell2 = styled(cssCell, `flex: 2;`);\nconst cssCell4 = styled(cssCell, `flex: 4;`);\n\n// Group of columns, which may be placed inside a cell.\nconst cssColumnGroup = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 0px 8px;\n  margin: 0 8px;\n  flex-wrap: wrap;\n`);\n\nconst cssRuleBody = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  margin: 4px 0;\n`);\n\nconst cssRuleDescription = styled(\"div\", `\n  color: ${theme.text};\n  display: flex;\n  align-items: top;\n  margin: 16px 0 8px 0;\n  gap: 12px;\n  white-space: pre-line;  /* preserve line breaks in long descriptions */\n  max-width: 60rem;       /* better to limit the line length for wide screens */\n`);\n\nconst cssCheckbox = styled(squareCheckbox, `\n  flex: none;\n`);\n\nconst cssCellContent = styled(\"div\", `\n  margin: 4px 8px;\n`);\n\nconst cssCenterContent = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  justify-content: center;\n`);\n\nconst cssDefaultLabel = styled(\"div\", `\n  color: ${theme.accessRulesTableBodyFg};\n  font-weight: bold;\n`);\n\nconst cssRuleProblems = styled(\"div\", `\n  flex: auto;\n  height: 100%;\n  width: 100%;\n  display: flex;\n  flex-direction: row;\n  flex-wrap: wrap;\n  gap: 8px;\n`);\n\nconst cssRulePartAndMemo = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  row-gap: 4px;\n`);\n\nconst cssMemoColumnGroup = styled(cssColumnGroup, `\n  margin-bottom: 8px;\n`);\n\nconst cssMemoIcon = styled(icon, `\n  --icon-color: ${theme.accentIcon};\n  margin-left: 8px;\n  margin-right: 8px;\n`);\n\nconst cssSeedRule = styled(\"div\", `\n  margin-bottom: 16px;\n`);\n\nconst cssLoading = styled(\"div\", `\n  flex: auto;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n`);\n\nconst cssErrorDetails = styled(\"div\", `\n  color: ${tokens.secondary};\n  margin-top: 16px;\n`);\n\nconst cssIntroSection = styled(\"div\", `\n  padding: 24px;\n  width: 100%;\n  max-width: 750px;\n  margin: 16px auto;\n  color: ${theme.text};\n  font-size: ${tokens.introFontSize};\n  line-height: 1.6;\n\n  @media ${mediaSmall} {\n    & {\n      width: auto;\n      padding: 12px;\n      margin: 8px;\n    }\n  }\n`);\n\nconst cssIntroButton = styled(\"div\", `\n  margin-top: 32px;\n  text-align: center;\n`);\n\nconst cssDisableRulesButton = styled(bigBasicButton, `\n  margin: 32px auto;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  border-color: ${tokens.secondary};\n  color:        ${tokens.secondary};\n  --icon-color: ${tokens.secondary};\n  &:hover {\n    border-color: ${tokens.error};\n    color:        ${tokens.error};\n    --icon-color: ${tokens.error};\n    x;\n  }\n`);\n"
  },
  {
    "path": "app/client/aclui/PermissionsWidget.ts",
    "content": "/**\n * Implements a widget showing 3-state boxes for permissions\n * (for Allow / Deny / Pass-Through).\n */\nimport { makeT } from \"app/client/lib/localization\";\nimport { colors, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { cssIconButton, icon } from \"app/client/ui2018/icons\";\nimport { menu, menuIcon, menuItem } from \"app/client/ui2018/menus\";\nimport { PartialPermissionSet, PartialPermissionValue } from \"app/common/ACLPermissions\";\nimport { ALL_PERMISSION_PROPS, emptyPermissionSet, PermissionKey } from \"app/common/ACLPermissions\";\nimport { capitalize } from \"app/common/gutil\";\n\nimport { dom, DomElementArg, Observable, styled } from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\n\n// Canonical order of permission bits when rendered in a permissionsWidget.\nconst PERMISSION_BIT_ORDER = \"RUCDS\";\n\nconst t = makeT(\"PermissionsWidget\");\n\n/**\n * Renders a box for each of availableBits, and a dropdown with a description and some shortcuts.\n */\nexport function permissionsWidget(\n  availableBits: PermissionKey[],\n  pset: Observable<PartialPermissionSet>,\n  options: { disabled: boolean, sanityCheck?: (p: PartialPermissionSet) => void },\n  ...args: DomElementArg[]\n) {\n  availableBits = sortBits(availableBits);\n  // These are the permission sets available to set via the dropdown.\n  const empty: PartialPermissionSet = emptyPermissionSet();\n  const allowAll: PartialPermissionSet = makePermissionSet(availableBits, () => \"allow\");\n  const denyAll: PartialPermissionSet = makePermissionSet(availableBits, () => \"deny\");\n  const readOnly: PartialPermissionSet = makePermissionSet(availableBits, b => b === \"read\" ? \"allow\" : \"deny\");\n  const setPermissions = (p: PartialPermissionSet) => {\n    options.sanityCheck?.(p);\n    pset.set(p);\n  };\n\n  return cssPermissions(\n    dom.forEach(availableBits, (bit) => {\n      return cssBit(\n        bit.slice(0, 1).toUpperCase(),              // Show the first letter of the property (e.g. \"R\" for \"read\")\n        cssBit.cls(use => \"-\" + use(pset)[bit]),  // -allow, -deny class suffixes.\n        dom.attr(\"title\", use => capitalize(`${use(pset)[bit]} ${bit}`.trim())),    // Explanation on hover\n        dom.cls(\"disabled\", options.disabled),\n        // Cycle the bit's value on click, unless disabled.\n        (options.disabled ? null :\n          dom.on(\"click\", () => setPermissions({ ...pset.get(), [bit]: next(pset.get()[bit]) }))\n        ),\n      );\n    }),\n    cssIconButton(icon(\"Dropdown\"), testId(\"permissions-dropdown\"), menu(() => {\n      // Show a disabled \"Custom\" menu item if the permission set isn't a recognized one, for\n      // information purposes.\n      const isCustom = [allowAll, denyAll, readOnly, empty].every(ps => !isEqual(ps, pset.get()));\n      return [\n        (isCustom ?\n          cssMenuItem(() => null, dom.cls(\"disabled\"), menuIcon(\"Tick\"),\n            cssMenuItemContent(\n              \"Custom\",\n              cssMenuItemDetails(dom.text(use => psetDescription(use(pset)))),\n            ),\n          ) :\n          null\n        ),\n        // If the set matches any recognized pattern, mark that item with a tick (checkmark).\n        cssMenuItem(() => setPermissions(allowAll), tick(isEqual(pset.get(), allowAll)), t(\"Allow all\"),\n          dom.cls(\"disabled\", options.disabled),\n        ),\n        cssMenuItem(() => setPermissions(denyAll), tick(isEqual(pset.get(), denyAll)), t(\"Deny all\"),\n          dom.cls(\"disabled\", options.disabled),\n        ),\n        cssMenuItem(() => setPermissions(readOnly), tick(isEqual(pset.get(), readOnly)), t(\"Read only\"),\n          dom.cls(\"disabled\", options.disabled),\n        ),\n        cssMenuItem(() => setPermissions(empty),\n          // For the empty permission set, it seems clearer to describe it as \"No Effect\", but to\n          // all it \"Clear\" when offering to the user as the action.\n          isEqual(pset.get(), empty) ? [tick(true), \"No Effect\"] : [tick(false), \"Clear\"],\n          dom.cls(\"disabled\", options.disabled),\n        ),\n      ];\n    })),\n    ...args,\n  );\n}\n\nfunction next(pvalue: PartialPermissionValue): PartialPermissionValue {\n  switch (pvalue) {\n    case \"allow\": return \"\";\n    case \"deny\": return \"allow\";\n  }\n  return \"deny\";\n}\n\n// Helper to build up permission sets.\nfunction makePermissionSet(bits: PermissionKey[], makeValue: (bit: PermissionKey) => PartialPermissionValue) {\n  const pset = emptyPermissionSet();\n  for (const bit of bits) {\n    pset[bit] = makeValue(bit);\n  }\n  return pset;\n}\n\n// Helper for a tick (checkmark) icon, replacing it with an equivalent space when not shown.\nfunction tick(show: boolean) {\n  return show ? menuIcon(\"Tick\") : cssMenuIconSpace();\n}\n\n// Human-readable summary of the permission set. E.g. \"Allow Read. Deny Update, Create.\".\nfunction psetDescription(permissionSet: PartialPermissionSet): string {\n  const allow: string[] = [];\n  const deny: string[] = [];\n  for (const prop of ALL_PERMISSION_PROPS) {\n    const value = permissionSet[prop];\n    if (value === \"allow\") {\n      allow.push(capitalize(prop));\n    } else if (value === \"deny\") {\n      deny.push(capitalize(prop));\n    }\n  }\n  const parts: string[] = [];\n  if (allow.length) { parts.push(`Allow ${allow.join(\", \")}.`); }\n  if (deny.length) { parts.push(`Deny ${deny.join(\", \")}.`); }\n  return parts.join(\" \");\n}\n\n/**\n * Sort the bits in a standard way for viewing, since they could be in any order\n * in the underlying rule store. And in fact ACLPermissions.permissionSetToText\n * uses an order (CRUDS) that is different from how things have been historically\n * rendered in the UI (RUCDS).\n */\nfunction sortBits(bits: PermissionKey[]) {\n  return bits.sort((a, b) => {\n    const aIndex = PERMISSION_BIT_ORDER.indexOf(a.slice(0, 1).toUpperCase());\n    const bIndex = PERMISSION_BIT_ORDER.indexOf(b.slice(0, 1).toUpperCase());\n    return aIndex - bIndex;\n  });\n}\n\nconst cssPermissions = styled(\"div\", `\n  display: flex;\n  gap: 4px;\n`);\n\nconst cssBit = styled(\"div\", `\n  flex: none;\n  height: 24px;\n  width: 24px;\n  border-radius: 2px;\n  font-size: 13px;\n  font-weight: 500;\n  border: 1px dashed ${theme.accessRulesTableBodyLightFg};\n  color: ${theme.accessRulesTableBodyLightFg};\n  cursor: pointer;\n\n  display: flex;\n  align-items: center;\n  justify-content: center;\n\n  &-allow {\n    background-color: ${colors.lightGreen};\n    border: 1px solid ${colors.lightGreen};\n    color: white;\n  }\n  &-deny {\n    background-image: linear-gradient(-45deg, ${colors.error} 14px, white 15px 16px, ${colors.error} 16px);\n    border: 1px solid ${colors.error};\n    color: white;\n  }\n  &.disabled {\n    opacity: 0.5;\n  }\n`);\n\nconst cssMenuIconSpace = styled(\"div\", `\n  width: 24px;\n`);\n\n// Don't make disabled item too hard to see here.\nconst cssMenuItem = styled(menuItem, `\n  align-items: start;\n  &.disabled {\n    opacity: unset;\n  }\n`);\n\nconst cssMenuItemContent = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n`);\n\nconst cssMenuItemDetails = styled(\"div\", `\n  font-size: 12px;\n`);\n"
  },
  {
    "path": "app/client/apiconsole.ts",
    "content": "import { loadCssFile, loadScript } from \"app/client/lib/loadScript\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/errors\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { createAppPage } from \"app/client/ui/createAppPage\";\nimport { invokePrompt } from \"app/client/ui2018/modals\";\nimport { DocAPIImpl } from \"app/common/UserAPI\";\n\nimport { dom, styled } from \"grainjs\";\n\nimport type { AppModel } from \"app/client/models/AppModel\";\nimport type { RecordWithStringId } from \"app/plugin/DocApiTypes\";\nimport type SwaggerUI from \"swagger-ui\";\n\nconst t = makeT(\"apiconsole\");\n\n/**\n * This loads the swagger resources as if included as separate <script> and <link> tags in <head>.\n *\n * Swagger suggests building via webpack (in which case, it would be included into our JS bundle),\n * but I couldn't get past webpack errors (also it's unclear if that would be any better).\n * We load dynamically only to avoid maintaining a separate html file ust for these tags.\n */\nfunction loadExternal() {\n  return Promise.all([\n    loadScript(\"swagger-ui-bundle.js\"),\n    loadCssFile(\"swagger-ui.css\"),\n    // Stylesheet that's only applied when prefers-color-scheme is dark.\n    loadCssFile(\"swagger-ui-dark.css\"),\n  ]);\n}\n\n// Start loading scripts early (before waiting for AppModel to get initialized).\nconst externalScriptsPromise = loadExternal();\n\nlet swaggerUI: SwaggerUI | null = null;\n\n// Define a few types to allow for type-checking.\n\ntype ParamValue = string | number | null;\n\ninterface Example {\n  value: ParamValue;\n  summary: string;\n}\n\ninterface JsonSpec {\n  [propName: string]: any;\n}\ninterface SpecActions {\n  changeParamByIdentity(...args: unknown[]): unknown;\n  updateJsonSpec(spec: JsonSpec): unknown;\n}\n\nfunction applySpecActions(cb: (specActions: SpecActions, jsonSpec: JsonSpec) => void) {\n  // Don't call actions directly within `wrapActions`, react/redux doesn't like it.\n  setTimeout(() => {\n    const system = (swaggerUI as any).getSystem();\n    const jsonSpec = system.getState().getIn([\"spec\", \"json\"]);\n    cb(system.specActions, jsonSpec);\n  }, 0);\n}\n\nfunction updateSpec(cb: (spec: JsonSpec) => JsonSpec) {\n  applySpecActions((specActions: SpecActions, jsonSpec: JsonSpec) => {\n    // `jsonSpec` is a special immutable object with methods like `getIn/setIn`.\n    // `updateJsonSpec` expects a plain JS object, so we need to convert it.\n    specActions.updateJsonSpec(cb(jsonSpec).toJSON());\n  });\n}\n\nconst searchParams = new URL(location.href).searchParams;\n\nfunction setExamples(examplesArr: Example[], paramName: string) {\n  examplesArr.sort((a, b) => String(a.summary || a.value).localeCompare(String(b.summary || b.value)));\n\n  const paramValue = searchParams.get(paramName);\n  let haveCurrentValue = false;\n  if (paramValue) {\n    // If this value appears among examples, move it to the front and label it as \"Current\".\n    const index = examplesArr.findIndex(v => (String(v.value) == String(paramValue)));\n    if (index >= 0) {\n      const ex = examplesArr.splice(index, 1)[0];\n      ex.summary += \" (Current)\";\n      examplesArr.unshift(ex);\n      haveCurrentValue = true;\n    }\n  }\n  if (!haveCurrentValue) {\n    // When opening an endpoint, parameters with examples are immediately set to the first example.\n    // For documents and tables, this would immediately call our custom code,\n    // fetching lists of tables/columns. This is especially bad for documents,\n    // as the document may have to be loaded from scratch in the doc worker.\n    // So the dropdown has to start with an empty value in those cases.\n    // You'd think this would run into the check for `!value` in `changeParamByIdentity`,\n    // but apparently swagger has its own special handing for empty values before then.\n    examplesArr.unshift({ value: \"\", summary: \"Select...\" });\n  }\n\n  // Swagger expects `examples` to be an object, not an array.\n  // Prefix keys with something to ensure they aren't viewed as numbers: JS objects will iterate\n  // them in insertion (what we want) order *unless* keys look numeric. SwaggerUI will use the\n  // value from ex.value, so luckily this prefix doesn't actually matter.\n  const examples = Object.fromEntries(examplesArr.map(ex => [\"#\" + ex.value, ex]));\n  updateSpec((spec) => {\n    return spec.setIn([\"components\", \"parameters\", `${paramName}PathParam`, \"examples\"], examples);\n  });\n}\n\n// Set the value of a parameter in all endpoints.\nfunction setParamValue(resolvedParam: any, value: ParamValue) {\n  applySpecActions((specActions: SpecActions, spec: JsonSpec) => {\n    // This will be something like:\n    // \"https://url-to-grist.yml#/components/parameters/orgIdPathParam\"\n    // Note that we're assuming that the endpoint always uses `$ref` to define the parameter,\n    // rather than defining it inline.\n    // https://github.com/gristlabs/grist-help/pull/293 ensures this,\n    // but future changes to the spec must remember to do the same.\n    const ref = resolvedParam.get(\"$$ref\");\n\n    // For every endpoint in the spec...\n    for (const [pathKey, path] of spec.get(\"paths\").entries()) {\n      for (const [method, operation] of path.entries()) {\n        // Skip the $ref for now, it is only used in `scim` endpoints which don't share\n        // parameters with other endpoints.\n        if (method === \"$ref\") {\n          continue;\n        }\n        const parameters = operation.get(\"parameters\");\n        if (!parameters) { continue; }\n        for (const param of parameters.values()) {\n          // If this is the same parameter...\n          if (ref.endsWith(param.get(\"$ref\"))) {\n            // Set the value. The final `true` is `noWrap` to prevent infinite recursion.\n            specActions.changeParamByIdentity([pathKey, method], resolvedParam, value, false, true);\n          }\n        }\n      }\n    }\n  });\n}\n\nclass ExtendedDocAPIImpl extends DocAPIImpl {\n  public listTables(): Promise<{ tables: RecordWithStringId[] }> {\n    return this.requestJson(`${this.getBaseUrl()}/tables`);\n  }\n\n  public listColumns(tableId: string, includeHidden = false): Promise<{ columns: RecordWithStringId[] }> {\n    return this.requestJson(`${this.getBaseUrl()}/tables/${tableId}/columns?hidden=${includeHidden ? 1 : 0}`);\n  }\n}\n\nfunction wrapChangeParamByIdentity(appModel: AppModel, system: any, oriAction: any, ...args: any[]) {\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  const [keyPath, param, value, _isXml, noWrap] = args;\n  if (noWrap || !value) {\n    // `noWrap` is our own flag to avoid infinite recursion.\n    // It's set when calling this action inside `setParamValue` below.\n    // `value` is falsy when choosing our default \"Select...\" option from a dropdown.\n    return oriAction(...args);\n  }\n\n  const paramName = param.get(\"name\");\n\n  // These are the path parameters that we handle specially and provide examples for.\n  // When a value is selected in one endpoint, set the same value in all other endpoints.\n  // This makes a bit more convenient to do multiple different operations on the same object.\n  // But maybe it'll cause confusion/mistakes when operating on different objects?\n  if ([\"orgId\", \"workspaceId\", \"docId\", \"tableId\", \"colId\"].includes(paramName)) {\n    setParamValue(param, value);\n  }\n\n  // When a docId is selected, fetch the list of that doc's tables and set examples for tableId.\n  // This is a significant convenience, but it causes some UI jankiness.\n  // Updating the spec with these examples takes some CPU and the UI freezes for a moment.\n  // Then things jump around a bit as stuff is re-rendered, although it ends up in the right place\n  // so it shouldn't be too disruptive.\n  // All this happens after a short delay while the tables are being fetched.\n  // It *might* be possible to set these example values more efficiently/lazily but I'm not sure,\n  // and it'll probably significantly more difficult.\n  const baseUrl = appModel.api.getBaseUrl();\n  if (paramName === \"docId\") {\n    const docAPI = new ExtendedDocAPIImpl(baseUrl, value);\n    docAPI.listTables().then(({ tables}: { tables: RecordWithStringId[] }) => {\n      const examples: Example[] = tables.map(table => ({ value: table.id, summary: table.id }));\n      setExamples(examples, \"tableId\");\n    })\n      .catch(reportError);\n  }\n\n  // When a tableId is selected, fetch the list of columns and set examples for colId.\n  // This causes similar UI jankiness as above, but I think less severely since fewer endpoints\n  // have a colId parameter. In fact, there's currently only one: `DELETE /columns`.\n  // We *could* only do this when setting tableId within that endpoint,\n  // but then the dropdown will be missing if you set the tableId elsewhere and then open this endpoint.\n  // Alternatively, `GET /tables` could be modified to return column metadata for each table.\n  if (paramName === \"tableId\") {\n    // When getting tables after setting docId, `value` is the docId so we have all the info.\n    // Here `value` is the tableId and we need to get the docId separately.\n    const parameters = system.getState().getIn([\"spec\", \"meta\", \"paths\", ...keyPath, \"parameters\"]);\n    const docId = parameters.find((_value: any, key: any) => key.startsWith(\"path.docId\"))?.get(\"value\");\n    if (docId) {\n      const docAPI = new ExtendedDocAPIImpl(baseUrl, docId);\n      // Second argument of `true` includes hidden columns like gristHelper_Display and manualSort.\n      docAPI.listColumns(value, true)\n        .then(({ columns}: { columns: RecordWithStringId[] }) => {\n          const examples = columns.map(col => ({ value: col.id, summary: col.fields.label as string }));\n          setExamples(examples, \"colId\");\n        })\n        .catch(reportError);\n    }\n  }\n  return oriAction(...args);\n}\n\nfunction gristPlugin(appModel: AppModel, system: any) {\n  return {\n    statePlugins: {\n      spec: {\n        wrapActions: {\n          // Customize what happens when a parameter is changed, e.g. selected from a dropdown.\n          changeParamByIdentity: (oriAction: any) => (...args: any[]) =>\n            wrapChangeParamByIdentity(appModel, system, oriAction, ...args),\n        },\n      },\n    },\n  };\n}\n\nfunction initialize(appModel: AppModel) {\n  // These are used to set the examples for orgs, workspaces, and docs.\n  const orgsPromise = appModel.api.getOrgs();\n\n  // We make a request for each org - hopefully there aren't too many.\n  // Currently I only see rate limiting in DocApi, which shouldn't be a problem here.\n  // Fortunately we don't need a request for each workspace,\n  // since listing workspaces in an org also lists the docs in each workspace.\n  const workspacesPromise = orgsPromise.then(orgs => Promise.all(orgs.map(org =>\n    appModel.api.getOrgWorkspaces(org.id, false).then(workspaces => ({ org, workspaces })),\n  )));\n\n  // To be called after the spec is downloaded and parsed.\n  function onComplete() {\n    // Add an instruction for where to get API key.\n    const description = document.querySelector(\".information-container .info\");\n    if (description) {\n      const href = urlState().makeUrl({ account: \"account\" });\n      dom.update(description, dom(\"div\", \"Find or create your API key at \", dom(\"a\", { href }, href), \".\"));\n    }\n\n    updateSpec((spec) => {\n      // The actual spec sets the server to `https://{subdomain}.getgrist.com/api`,\n      // where {subdomain} is a variable that defaults to `docs`.\n      // We want to use the same server as the page is loaded from.\n      // This simplifies the UI and makes it work e.g. on localhost.\n      spec = spec.set(\"servers\", [{ url: window.origin + \"/api\" }]);\n\n      // Some table-specific parameters have examples with fake data in grist.yml. We don't want\n      // to actually use this for running requests, so clear those out.\n      for (const paramName of [\n        \"filterQueryParam\", \"sortQueryParam\", \"sortHeaderParam\",\n        \"limitQueryParam\", \"limitHeaderParam\",\n      ]) {\n        spec = spec.removeIn([\"components\", \"parameters\", paramName, \"example\"]);\n      }\n      return spec;\n    });\n\n    // Show that we need a key, but let's not display it. The user may or may not have the API key\n    // set. Actual requests from the console use cookies, so can work anyway. When the key is set,\n    // showing it in cleartext makes it riskier to ask for help with screenshots and the like.\n    // We set a fake key anyway to be clear that it's needed in the curl command.\n    const key = \"XXXXXXXXXXX\";\n    swaggerUI!.preauthorizeApiKey(\"ApiKey\", key);\n\n    // Set examples for orgs, workspaces, and docs.\n    orgsPromise.then((orgs) => {\n      const examples: Example[] = orgs.map(org => ({\n        value: org.domain,\n        summary: org.name,\n      }));\n      setExamples(examples, \"orgId\");\n    }).catch(reportError);\n\n    workspacesPromise.then((orgs) => {\n      const workSpaceExamples: Example[] = orgs.flatMap(({ org, workspaces }) => workspaces.map(ws => ({\n        value: ws.id,\n        summary: `${org.name} » ${ws.name}`,\n      })));\n      setExamples(workSpaceExamples, \"workspaceId\");\n\n      const docExamples = orgs.flatMap(({ org, workspaces }) => workspaces.flatMap(ws => ws.docs.map(doc => ({\n        value: doc.id,\n        summary: `${org.name} » ${ws.name} » ${doc.name}`,\n      }))));\n      setExamples(docExamples, \"docId\");\n    }).catch(reportError);\n  }\n  return onComplete;\n}\n\nasync function requestInterceptor(request: SwaggerUI.Request) {\n  delete request.headers.Authorization;\n  const url = new URL(request.url);\n  // Swagger will use this request interceptor for several kinds of\n  // requests, such as requesting the API YAML spec from Github:\n  //\n  //      Function to intercept remote definition, \"Try it out\",\n  //      and OAuth 2.0 requests.\n  //\n  //    https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/\n  //\n  // We want to ensure that only \"Try it out\" requests have XHR, so\n  // that they pass a same origin request, even if they're not GET,\n  // HEAD, or OPTIONS. \"Try it out\" requests are the requests to the\n  // same origin.\n  if (url.origin === window.origin) {\n    // Without this header, unauthenticated multipart POST requests\n    // (i.e. file uploads) would fail in the API console. We want those\n    // requests to succeed.\n    request.headers[\"X-Requested-With\"] = \"XMLHttpRequest\";\n    if (request.method.toLowerCase() === \"delete\") {\n      const text = await invokePrompt(\n        t(\"Confirm Deletion\"), {\n          btnText: t(\"Delete\"),\n          placeholder: t(\"Type DELETE here if you wish to proceed.\"),\n          body: [\n            dom(\n              \"p\",\n              t(\"Are you sure you want to delete the following?\"),\n            ),\n            dom(\n              \"p\",\n              dom(\"tt\", url.pathname),\n            ),\n            dom(\n              \"p\",\n              t(`Type DELETE if you are sure you do indeed wish to do this deletion.\nIf you are not sure, or do not understand what this operation will do,\nit would be wise to cancel it.`),\n            ),\n          ],\n        });\n      if (text !== \"DELETE\") {\n        reportError(t(\"Deletion was not confirmed, skipping.\"));\n        throw new Error(\"Deletion was not confirmed\");\n      }\n    }\n  }\n  return request;\n}\n\ncreateAppPage((appModel) => {\n  // Default Grist page prevents scrolling unnecessarily.\n  document.documentElement.style.overflow = \"initial\";\n\n  const rootNode = cssWrapper();\n  const onComplete = initialize(appModel);\n\n  externalScriptsPromise.then(() => {\n    const buildSwaggerUI: typeof SwaggerUI = (window as any).SwaggerUIBundle;\n    swaggerUI = buildSwaggerUI({\n      filter: true,\n      plugins: [gristPlugin.bind(null, appModel)],\n      url: \"https://raw.githubusercontent.com/gristlabs/grist-help/master/api/grist.yml\",\n      domNode: rootNode,\n      showMutatedRequest: false,\n      requestInterceptor,\n      onComplete,\n    });\n  })\n    .catch(reportError);\n\n  return rootNode;\n});\n\nconst cssWrapper = styled(\"div\", `\n  & .scheme-container {\n    display: none;\n  }\n  & .information-container h1 {   /* Authorization header, strangely enough */\n    display: none;\n  }\n  & .information-container .info {\n    margin-bottom: 0;\n  }\n`);\n"
  },
  {
    "path": "app/client/app.css",
    "content": "/* global variables */\n@layer grist-base {\n  :root {\n    --color-logo-row:  #F9AE41;\n    --color-logo-col:  #2CB0AF;\n    --color-logo-cell: #DEDDDD;\n    --color-logo-bg:   #42494B;\n\n    --color-link-default: #336;\n    --color-link-visited: #336;\n    --color-link-hover:   #66c;\n    --color-link-active:  #66c;\n\n    --color-link-bright: orange;\n\n    --color-start-page-bg: #f0f0f0;\n\n    --color-navbar-bg: var(--color-logo-bg);\n    --color-navbar-btn-bg: #fefefe;\n    --color-navbar-btn-bg-hover: #f6f6f6;\n    --color-navbar-btn-disabled: #ccc;\n\n    --color-tab-bar-bg: #d6d6d6;\n\n    --color-border-light: #ddd;\n    --color-border-medium: #bbb;\n\n    --color-btn-login: #ffb749;\n    --color-btn-login-background: #fff1dc;\n    --color-btn-createdoc: #3fda2c;\n    --color-btn-uploaddoc: #00dcff;\n    --color-btn-decline: #c74646;\n    --color-btn-accept: #3eda2c;\n\n    --layout-top-spacer: 20px;\n\n    --color-list-row-hover: #f0f0f0;\n\n    --color-list-item: #f6f6f6;\n    --color-list-item-hover: #e0e0e0;\n    --color-list-item-selected: #e8d53d;\n    --color-list-item-disabled: #ccc;\n    --color-list-item-action: #6eec6e;\n\n    --color-hint-text: #888;\n\n    --scroll-bar-width: 12px;\n\n    /* fonts */\n    --font-navbar-title: \"Helvetica\", \"Arial\", sans-serif;\n    --font-btn-symbols: \"Apple Symbols\", \"Arial Unicode MS\";\n  }\n\n\n\n  .flexhbox {\n    display: -webkit-flex;\n    display: flex;\n  }\n  .flexvbox {\n    display: -webkit-flex;\n    display: flex;\n    -webkit-flex-direction: column;\n    flex-direction: column;\n  }\n  .flexitem {\n    /* Makes the flex item flexible and sets the flex basis to zero (disregards content size). */\n    -webkit-flex: 1 1 0px;\n    flex: 1 1 0px;\n    /* Min-width of 0 is needed to allow the flex box to shrink below its minimum content size. */\n    min-width: 0px;\n  }\n  .flexnone {\n    /* Sizes the item based on content or width/height, and makes it fully inflexible. */\n    -webkit-flex: none;\n    flex: none;\n  }\n  .flexauto {\n    /* Sizes the item based on content or width/height, and makes it fully flexible. */\n    -webkit-flex: auto;\n    flex: auto;\n  }\n  .clipped {\n    overflow: hidden;\n  }\n\n  body {\n    /* This seems logically appropriate since we never want body to scroll, but the real reason is\n    * to avoid a major slowdown when using $().modal() dialogs (a JQuery plugin in bootstrap).\n    * Those add/remove a class to body which sets \"overflow: hidden\", which causes great slowness on\n    * Firefox (not Chrome). If body is already \"overflow: hidden\", it's much faster.\n    */\n    overflow: hidden;\n  }\n\n  .show_scrollbar::-webkit-scrollbar {\n    width: var(--scroll-bar-width);\n    height: var(--scroll-bar-width);\n    background-color: var(--scroll-bar-bg, #f0f0f0);\n  }\n  .show_scrollbar::-webkit-scrollbar-thumb {\n    background-color: var(--scroll-bar-fg, #a8a8a8);\n    -webkit-border-radius: 100px;\n    border: 2px solid var(--scroll-bar-bg, #f0f0f0);\n  }\n  .show_scrollbar::-webkit-scrollbar-thumb:vertical {\n    min-height: 4rem;\n  }\n  .show_scrollbar::-webkit-scrollbar-thumb:horizontal {\n    min-width: 4rem;\n  }\n  .show_scrollbar::-webkit-scrollbar-thumb:hover {\n    background-color: var(--scroll-bar-hover-fg, #8f8f8f);\n    -webkit-border-radius: 100px;\n  }\n  .show_scrollbar::-webkit-scrollbar-thumb:active {\n    background-color: var(--scroll-bar-active-fg, #7c7c7c);\n    -webkit-border-radius: 100px;\n  }\n  .show_scrollbar::-webkit-scrollbar-corner {\n    background-color: var(--scroll-bar-bg, #f0f0f0);\n  }\n  div.dev_warning {\n    position: absolute;\n    z-index: 10;\n    width: 100%;\n    opacity: 0.5;\n    pointer-events: none;\n    font-size: 200%;\n    color: white;\n    background: red;\n    text-align: center;\n  }\n  #browser-check-problem {\n    display: none;\n    width: 100%;\n    position: absolute;\n    z-index: var(--grist-browser-check-z-index);\n    bottom: 0;\n    left: 0;\n    padding: 4px;\n\n    /* Copy common styles that are normally set from JS-generated CSS */\n    box-sizing: border-box;\n    font-family: -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\";\n    font-size: 13px;\n    line-height: 16px;\n    -moz-osx-font-smoothing: grayscale;\n    -webkit-font-smoothing: antialiased;\n  }\n  .browser-check-wrapper {\n    margin: auto;\n    max-width: 600px;\n    padding: 8px 24px;\n    background-color: #040404;\n    border-radius: 4px;\n    border: none;\n    color: white;\n    box-shadow: 0 0 4px 0 white;\n  }\n  .browser-check-wrapper td {\n    vertical-align: middle;\n    padding: 8px 16px;\n  }\n  .browser-check-mobile {\n    display: none;\n  }\n  .browser-check-is-mobile .browser-check-mobile {\n    display: inline;\n  }\n  .browser-check-is-mobile .browser-check-desktop {\n    display: none;\n  }\n  .browser-check-wrapper a {\n    color: #16B378;\n    text-decoration: underline;\n  }\n  .browser-check-wrapper a:hover {\n    color: #b1ffe2;\n  }\n\n  .browser-check-close {\n    padding: 4px 8px;\n    border-radius: 4px;\n    background-color: #009058;\n    color: white;\n    cursor: pointer;\n  }\n  .browser-check-close:hover {\n    background-color: #16B378;\n  }\n\n  /* The bit from boostrap needed for bootstrap-datepicker. */\n  .dropdown-menu {\n    position: absolute;\n    top: 100%;\n    left: 0;\n    z-index: 1000;\n    display: none;\n    float: left;\n    min-width: 160px;\n    padding: 5px 0;\n    margin: 2px 0 0;\n    font-size: 14px;\n    text-align: left;\n    list-style: none;\n    background-color: #fff;\n    background-clip: padding-box;\n    border: 1px solid #ccc;\n    border: 1px solid rgba(0,0,0,.15);\n    border-radius: 4px;\n    -webkit-box-shadow: 0 6px 12px rgba(0,0,0,.175);\n    box-shadow: 0 6px 12px rgba(0,0,0,.175);\n  }\n}\n"
  },
  {
    "path": "app/client/app.js",
    "content": "/* global $, window */\n\n// This is the entry point into loading the whole of Grist frontend application. Some extensions\n// attempt to load it more than once (e.g. \"Lingvanex\"). This leads to duplicated work and errors.\n// At least some of such interference can be neutralized by simply ignoring repeated loads.\nif (window._gristAppLoaded) {\n  return;\n}\nwindow._gristAppLoaded = true;\n\nconst {setupLocale} = require(\"./lib/localization\");\n\nconst {AppImpl} = require(\"./ui/App\");\n\n// Disable longStackTraces, which seem to be enabled in the browser by default.\nvar bluebird = require(\"bluebird\");\nbluebird.config({ longStackTraces: false });\n\n// Set up integration between grainjs and knockout disposal.\nconst {setupKoDisposal} = require(\"grainjs\");\nconst ko = require(\"knockout\");\nsetupKoDisposal(ko);\n\n$(function() {\n  // Manually disable the bfcache. We dispose some components in App.ts on unload, and\n  // leaving the cache on causes problems when the browser back/forward buttons are pressed.\n  // Some browsers automatically disable it when the 'beforeunload' or 'unload' events\n  // have listeners, but not all do (Safari).\n  window.onpageshow = function(event) {\n    if (event.persisted) { window.location.reload(); }\n  };\n\n  const localeSetup = setupLocale();\n  // By the time dom ready is fired, resource files should already be loaded, but\n  // if that is not the case, we will redirect to an error page by throwing an error.\n  localeSetup.then(() => {\n    window.gristApp = AppImpl.create(null);\n  }).catch(error => {\n    throw new Error(`Failed to load locale: ${error?.message || \"Unknown error\"}`);\n  });\n  // Set from the login tests to stub and un-stub functions during execution.\n  window.loginTestSandbox = null;\n\n  // These modules are exposed for the sake of browser tests.\n  window.exposeModulesForTests = function() {\n    return (import(\"./exposeModulesForTests\" /* webpackChunkName: \"modulesForTests\" */));\n  };\n  window.exposedModules = {};\n  // Make it easy for tests to use loadScript() whether or not exposedModules has already loaded.\n  window.loadScript = (name) =>\n    window.exposeModulesForTests().then(() => window.exposedModules.loadScript.loadScript(name));\n});\n"
  },
  {
    "path": "app/client/billingMain.ts",
    "content": "import { BillingPage } from \"app/client/ui/BillingPage\";\nimport { createAppPage } from \"app/client/ui/createAppPage\";\n\nimport { dom } from \"grainjs\";\n\ncreateAppPage(appModel => dom.create(BillingPage, appModel));\n"
  },
  {
    "path": "app/client/browserCheck.ts",
    "content": "/**\n * Check if browser is a version we are happy with.\n * Introduce any new dependencies very carefully, checking that the code still runs\n * on old browsers afterwards.\n */\n\n// Use version of bowser with polyfill for old browsers.\nimport * as bowser from \"bowser/bundled\";\n\n// This code will run in the browser.\nconst version = bowser.getParser(window.navigator.userAgent);\n(window as any)._parsedBrowserVersion = version;\n\n// Skip if user has already dismissed a warning from us.\nif (document && window && !document.cookie.includes(\"gristbrowser=accept\")) {\n  const isHappyBrowser = version.satisfies({\n    desktop: {\n      chrome: \">=72.0.3626\",   // first 2019 version\n      firefox: \">=65\",         // first 2019 version\n      safari: \">=12.0.3\",      // first 2019 version\n      edge: \">=80\",            // one of first Chromium-based Edge versions, early 2020\n      opera: \">=66\",           // first 2020 version\n    },\n    mobile: {\n      // These were tested using browserstack, for a basic layout and cell editing. Other browsers\n      // not attempted, so Grist will show a warning there.\n      safari: \">=15\",       // 2021 version, first where layouts aren't broken\n      chrome: \">=92\",       // 2021 version which works fine\n      firefox: \">=108\",     // end-of-2022 version, couldn't try an earlier one\n    },\n  });\n  const isMobile = version.isPlatform(\"mobile\") || version.isPlatform(\"tablet\");\n  if (!isHappyBrowser) {\n    const problemElement = document.getElementById(\"browser-check-problem\");\n    const dismissElement = document.getElementById(\"browser-check-problem-dismiss\");\n    if (problemElement && dismissElement) {\n      // Prepare a button for dismissing the warning.\n      dismissElement.onclick = function() {\n        // Set a cookie so we don't show this warning once it is dismissed.\n        let cookie = \"gristbrowser=accept; path=/\";\n        // Keep the cookie for a year (60*60*24*365 seconds) before warning again.\n        cookie += \"; max-age=31536000\";\n\n        // NOTE: Safari seems to limit cookies (and other storage?) set via JS to 1 week, so\n        // people on mobile or old Safari may get prompted more often than we'd like. See\n        // https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/\n\n        if (document.location.href.includes(\".getgrist.com\")) {\n          // on *.getgrist.com, set cookie domain to getgrist.com\n          cookie += \"; Domain=.getgrist.com\";\n        }\n        document.cookie = cookie;\n        // Hide the warning, showing the loaded page that it was obscuring.\n        problemElement.style.display = \"none\";\n        return false;\n      };\n      // Show modal describing problem, and some possible solutions.\n      if (isMobile) {\n        problemElement.className += \" browser-check-is-mobile\";\n      }\n      problemElement.style.display = \"block\";\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/components/AceEditor.css",
    "content": ".ace_editor {\n  background-color: var(--grist-theme-ace-editor-bg, white);\n}\n\n.ace_editor .ace_placeholder {\n  font-family: Monaco, Menlo, \"Ubuntu Mono\", Consolas, \"Source Code Pro\", source-code-pro, monospace;\n  font-size: 11px;\n  color: var(--grist-theme-text-light, #929299);\n  font-style: italic;\n  white-space: nowrap;\n  opacity: 1.0;\n  transform: none;\n}\n\n.ace_grist_link_hidden {\n  display: none;\n}\n\n.ace_grist_link {\n  color: var(--grist-theme-ace-autocomplete-link, var(--grist-color-light-green));\n  text-decoration: underline;\n  cursor: pointer;\n}\n\n.ace_grist_example {\n  color: var(--grist-theme-ace-autocomplete-secondary-fg);\n}\n\n.ace_editor.ace_autocomplete .ace_completion-highlight {\n  color: var(--grist-theme-ace-autocomplete-highlighted-fg, #000) !important;\n  text-shadow: 0 0 0.01em;\n}\n\n.ace_editor.ace_autocomplete .ace_completion-highlight.ace_grist_link {\n  color: var(--grist-theme-ace-autocomplete-link-highlighted, var(--grist-color-dark-green)) !important;\n}\n\n.ace_editor.ace_autocomplete .ace_text-layer {\n  z-index: 7;\n  pointer-events: auto;\n}\n\n.ace_editor.ace_autocomplete {\n  color: var(--grist-theme-ace-autocomplete-primary-fg) !important;\n  background: var(--grist-theme-ace-autocomplete-bg, #fbfbfb) !important;\n  border: 1px solid var(--grist-theme-ace-autocomplete-border, lightgray) !important;\n  width: 500px !important;  /* the default in language_tools.js is 280px */\n  max-width: 80%;  /* of the screen, for hypothetical mobile support */\n}\n\n.ace_editor.ace_autocomplete .ace_marker-layer .ace_line-hover {\n  background-color: var(--grist-theme-ace-autocomplete-line-bg-hover, rgba(233,233,253,0.4)) !important;\n  border: 1px solid var(--grist-theme-ace-autocomplete-line-border-hover, #abbffe) !important;\n}\n\n.ace_editor.ace_autocomplete .ace_marker-layer .ace_active-line {\n  background-color: var(--grist-theme-ace-autocomplete-active-line-bg, #CAD6FA) !important;\n}\n\n.ace_autocomplete .ace_line .ace_ {\n  /* Ace collapses whitespace by default, which breaks alignment changes made in AceEditorCompletions.ts. */\n  white-space: pre !important;\n  flex-shrink: 0;\n}\n"
  },
  {
    "path": "app/client/components/AceEditor.js",
    "content": "var ace = require(\"ace-builds\");\nvar _ = require(\"underscore\");\n// ace-builds also has a minified build (src-min-noconflict), but we don't\n// use it since webpack already handles minification.\nrequire(\"ace-builds/src-noconflict/mode-python\");\nrequire(\"ace-builds/src-noconflict/theme-chrome\");\nrequire(\"ace-builds/src-noconflict/theme-dracula\");\nrequire(\"ace-builds/src-noconflict/ext-language_tools\");\nvar {setupAceEditorCompletions} = require(\"./AceEditorCompletions\");\nvar dom = require(\"../lib/dom\");\nvar dispose = require(\"../lib/dispose\");\nvar modelUtil = require(\"../models/modelUtil\");\nvar {gristThemeObs} = require(\"../ui2018/theme\");\n\n/**\n * A class to help set up the ace editor with standard formatting and convenience functions\n * @param {Observable} options.observable: If given, creates a 2-way binding between the observable\n *  and the value of the editor.\n * @param {Boolean} options.saveValueOnBlurEvent: Flag to indicate whether ace editor\n *  should save the value on `blur` event.\n * @param {Function} options.calcSize: Optional function used to resize the editor. It is called\n *  with (elem, desiredSize) as arguments, and should return the actual size to use for the\n *  element. Both desiredSize and the return value are objects with 'width' and 'height' members.\n */\nfunction AceEditor(options) {\n  options = options || {};\n  // Observable subscription is not created until the dom is built\n  this.observable = options.observable || null;\n  this.saveValueOnBlurEvent = !(options.saveValueOnBlurEvent === false);\n  this.calcSize = options.calcSize || ((_elem, size) => size);\n  this.editorState = options.editorState || null;\n  this._readonly = options.readonly || false;\n  this._getSuggestions = options.getSuggestions || null;\n\n  this.editor = null;\n  this.editorDom = null;\n  this.session = null;\n  this._setupCallback = null;\n  this._setupTimer = null;\n\n  this.textPadding = 10; // Space after cursor when not using wrap mode\n}\ndispose.makeDisposable(AceEditor);\n\n// Builds editor dom with additional setup possible in function `optSetupCallback`.\n// May be called multiple times by an instance of AceEditor.\nAceEditor.prototype.buildDom = function(optSetupCallback) {\n  this._fullDom = dom(\"div.code_editor_container\",\n    this.editorDom = dom(\"div\")\n  );\n  this._setupCallback = optSetupCallback;\n  this._setupTimer = setTimeout(() => this._setup(), 0);\n  return this._fullDom;\n};\n\n/**\n * You may optionally call this once the DOM returned from buildDom is attached to the document to\n * make setup and sizing more immediate.\n */\nAceEditor.prototype.onAttach = function() {\n  if (this._setupTimer) {\n    clearTimeout(this._setupTimer);\n    this._setupTimer = null;\n    this._setup();\n  }\n};\n\nAceEditor.prototype.writeObservable = function() {\n  if (this.observable) {\n    modelUtil.setSaveValue(this.observable, this.getValue());\n  }\n};\n\nAceEditor.prototype.getEditor = function() {\n  return this.editor;\n};\n\nAceEditor.prototype.getValue = function() {\n  return this.editor && this.editor.getValue();\n};\n\n/**\n * @param {String} val: The new value to set the editor to.\n * @param {Number} optCursorPos: Position where to place the cursor: at the end if omitted.\n */\nAceEditor.prototype.setValue = function(val, optCursorPos) {\n  // Note that underlying setValue() has a special meaning for second parameter:\n  // undefined or 0 is selectAll, -1 is at the document start, and 1 is at the end.\n  this.editor.setValue(val, optCursorPos === 0 ? -1 : 1);\n  if (optCursorPos > 0 && optCursorPos < val.length) {\n    var pos = this.session.getDocument().indexToPosition(optCursorPos);\n    this.editor.moveCursorTo(pos.row, pos.column);\n  }\n};\n\nAceEditor.prototype.isBuilt = function() {\n  return this.editor !== null;\n};\n\n// Enables or disables the AceEditor\nAceEditor.prototype.enable = function(bool) {\n  var editor = this.editor;\n  editor.setReadOnly(!bool);\n  editor.renderer.$cursorLayer.element.style.opacity = bool ? 100 : 0;\n  editor.gotoLine(Infinity, Infinity); // Prevents text selection on disable\n};\n\n/**\n *  Commands must be added specially to the ace editor.\n *  Attaching commands to the textarea using commandGroup.attach() only\n *  works for certain keys.\n *\n *  Note: Commands to the aceEditor are always enabled.\n *  Note: Ace defers to standard behavior when false is returned.\n */\nAceEditor.prototype.attachCommandGroup = function(commandGroup) {\n  _.each(commandGroup.knownKeys, (commandName, key) => {\n    this.editor.commands.addCommand({\n      name: commandName,\n      // We are setting readonly as true to enable all commands\n      // in a readonly mode.\n      // Because FieldEditor in readonly mode will rewire all commands that\n      // modify state, we are safe to enable them.\n      readOnly: this._readonly,\n      bindKey: {\n        win: key,\n        mac: key,\n        sender: \"editor|cli\"\n      },\n      // AceEditor wants a command to return true if it got handled, whereas our command returns\n      // true to avoid stopPropagation/preventDefault, i.e. if it hasn't been handled.\n      exec: () => !commandGroup.commands[commandName]()\n    });\n  });\n};\n\n/**\n *  Attaches a command to the editor which saves the current editor\n *  contents to the attached observable on 'Shift+Enter'.\n *  Throws error if there is no attached observable.\n *  TODO: Use instead of custom save command for more implementations of AceEditor\n */\nAceEditor.prototype.attachSaveCommand = function() {\n  if (!this.observable) {\n    throw new Error(\"Cannot attach save command to editor with no bound observable\");\n  }\n  this.editor.commands.addCommand({\n    name: \"saveFormula\",\n    bindKey: {\n      sender: \"editor|cli\"\n    },\n    // AceEditor wants a command to return true if it got handled\n    exec: () => {\n      this.writeObservable();\n      return true;\n    }\n  });\n};\n\n// Wraps words to the current width of the editor\nAceEditor.prototype.adjustContentToWidth = function() {\n  var characterWidth = this.editor.renderer.characterWidth;\n  var contentWidth = this.editor.renderer.scroller.clientWidth;\n\n  if(contentWidth > 0) {\n    this.editor.getSession().setWrapLimit(parseInt(contentWidth/characterWidth, 10) - 1);\n  }\n};\n\n/**\n * Provides opportunity to execute some functionality when value in the editor has changed.\n * Happens every time user types something to the control.\n */\nAceEditor.prototype.onChange = function() {\n  if (this.editorState) this.editorState.set(this.getValue());\n  this.resize();\n};\n\nAceEditor.prototype.setFontSize = function(pxVal) {\n  this.editor.setFontSize(pxVal);\n  this.resize();\n};\n\nAceEditor.prototype._setup = function() {\n  // Standard editor setup\n  this.editor = this.autoDisposeWith(\"destroy\", ace.edit(this.editorDom));\n  if (this._getSuggestions) {\n    setupAceEditorCompletions(this.editor, {getSuggestions: this._getSuggestions});\n  }\n  this.editor.setOptions({\n    enableLiveAutocompletion: true,   // use autocompletion without needing special activation.\n  });\n  this.session = this.editor.getSession();\n  this.session.setMode(\"ace/mode/python\");\n\n  this._setAceTheme(gristThemeObs().get());\n  this.autoDispose(gristThemeObs().addListener((newTheme) => {\n    this._setAceTheme(newTheme);\n  }));\n\n  // Default line numbers to hidden\n  this.editor.renderer.setShowGutter(false);\n  this.session.setTabSize(2);\n  this.session.setUseWrapMode(true);\n\n  this.editor.on(\"change\", this.onChange.bind(this));\n  this.editor.$blockScrolling = Infinity;\n  this.editor.setFontSize(11);\n  this.resize();\n\n  // Set up the bound observable if supplied\n  if (this.observable) {\n    var subscription = this.observable.subscribeInit(val => {if (val !== undefined) {this.setValue(val);}});\n    // Dispose with dom since subscription is created when dom is created\n    dom(this.editorDom,\n      dom.autoDispose(subscription)\n    );\n\n    if (this.saveValueOnBlurEvent) {\n      this.editor.on(\"blur\", () => {\n        this.writeObservable();\n      });\n    }\n  }\n\n  if (this._setupCallback) {\n    this._setupCallback.call(null, this.editor);\n    this._setupCallback = null;\n  }\n};\n\nAceEditor.prototype.resize = function() {\n  var wrap = this.session.getUseWrapMode();\n  var contentWidth = wrap ? 0 : this._getContentWidth();\n  var contentHeight = this._getContentHeight();\n  var desiredSize = {\n    width: wrap ? 0 : contentWidth + this.textPadding,\n    height: contentHeight,\n  };\n  var size = this.calcSize(this._fullDom, desiredSize);\n  if (size.height < contentHeight) {\n    // Editor will show a vertical scrollbar, so recalculate to make space for it.\n    desiredSize.width += 20;\n    size = this.calcSize(this._fullDom, desiredSize);\n  }\n  if (size.width < contentWidth) {\n    // Editor will show a horizontal scrollbar, so recalculate to make space for it.\n    desiredSize.height += 20;\n    size = this.calcSize(this._fullDom, desiredSize);\n  }\n\n  // Setting height or width to number like 100.00000005 won't work (it will be truncated).\n  // Unfortunately ace editor will do the same math we do, and will expect the height or width\n  // of the container to be 100.0000005, and when it finds out that it is only 100px will show\n  // scrollbars. To fix this issue we will make the container a little bit bigger.\n  // This won't help for zooming (where the same problem occurs but in many more places), but will\n  // help for Windows users who have different pixel ratio.\n  this.editorDom.style.width = size.width ? Math.ceil(size.width) + \"px\" : \"auto\";\n  this.editorDom.style.height = size.height ? Math.ceil(size.height) + \"px\" : \"auto\";\n  this.editor.resize();\n};\n\nAceEditor.prototype._getContentWidth = function() {\n  return this.session.getScreenWidth() * this.editor.renderer.characterWidth;\n};\n\nAceEditor.prototype._getContentHeight = function() {\n  return Math.max(1, this.session.getScreenLength()) * this.editor.renderer.lineHeight;\n};\n\nAceEditor.prototype._setAceTheme = function(newTheme) {\n  const {appearance} = newTheme;\n  const aceTheme = appearance === \"dark\" ? \"dracula\" : \"chrome\";\n  this.editor.setTheme(`ace/theme/${aceTheme}`);\n};\n\nlet _RangeConstructor = null; //singleton, load it lazily\nAceEditor.makeRange = function(a, b, c, d) {\n  _RangeConstructor = _RangeConstructor || ace.require(\"ace/range\").Range;\n  return new _RangeConstructor(a, b, c, d);\n};\n\nmodule.exports = AceEditor;\n"
  },
  {
    "path": "app/client/components/AceEditorCompletions.ts",
    "content": "import { ISuggestionWithValue } from \"app/common/ActiveDocAPI\";\nimport { commonUrls } from \"app/common/gristUrls\";\n\nimport ace, { Ace } from \"ace-builds\";\n\nexport interface ICompletionOptions {\n  getSuggestions(prefix: string): Promise<ISuggestionWithValue[]>;\n}\n\nconst completionOptions = new WeakMap<Ace.Editor, ICompletionOptions>();\n\nexport function setupAceEditorCompletions(editor: Ace.Editor, options: ICompletionOptions) {\n  initCustomCompleter();\n  completionOptions.set(editor, options);\n\n  // Create Autocomplete object at this point so we can turn autoSelect off.\n  // There doesn't seem to be any way to get ace to respect autoSelect otherwise.\n  // It is important for autoSelect to be off so that hitting enter doesn't automatically\n  // use a suggestion, a change of behavior that doesn't seem particularly desirable and\n  // which also breaks several existing tests.\n  const { Autocomplete } = ace.require(\"ace/autocomplete\");\n\n  const completer = new Autocomplete();\n  // Here is the source code:\n  // https://github.com/ajaxorg/ace/blob/23208f2f19020d1f69b90bc3b02460bda8422072/src/autocomplete.js#L180\n  // It seems that this function wasn't doing anything special, and wasn't returning anything, so\n  // AceEditor refused to insert new line. Option 'deleteSuffix is also not supported (line 150).\n  if (completer.keyboardHandler?.commands?.[\"Shift-Return\"]) {\n    completer.keyboardHandler.commands[\"Shift-Return\"].exec = () => false;\n  }\n  completer.autoSelect = false;\n  (editor as any).completer = completer;\n\n  // Used in the patches below. Returns true if the client should fetch fresh completions from the server,\n  // as it may have new suggestions that aren't currently shown.\n  completer._gristShouldRefreshCompletions = function(this: any, start: any) {\n    // These two lines are based on updateCompletions() in the ace autocomplete source code.\n    const end = this.editor.getCursorPosition();\n    const prefix: string = this.editor.session.getTextRange({ start, end }).toLowerCase();\n\n    return (\n      prefix.endsWith(\".\") ||  // to get fresh attributes of references\n      prefix.endsWith(\".lookupone(\") ||  // to get initial argument suggestions\n      prefix.endsWith(\".lookuprecords(\")\n    );\n  }.bind(completer);\n\n  // Patch updateCompletions and insertMatch so that fresh completions are fetched when appropriate.\n  const originalUpdate = completer.updateCompletions.bind(completer);\n  completer.updateCompletions = function(this: any, keepPopupPosition: boolean) {\n    // This next line is copied from updateCompletions() in the ace autocomplete source code.\n    if (keepPopupPosition && this.base && this.completions) {\n      // When we need fresh completions, prevent this same block from running\n      // in the original updateCompletions() function. Otherwise it will just keep any remaining completions that match,\n      // or not show any completions at all.\n      if (this._gristShouldRefreshCompletions(this.base)) {\n        this.completions = null;\n      }\n    }\n    return originalUpdate(keepPopupPosition);\n  }.bind(completer);\n\n  // Similar patch to the above.\n  const originalInsertMatch = completer.insertMatch.bind(completer);\n  completer.insertMatch = function(this: any) {\n    const base = this.base;  // this.base may become null after the next line, save it now.\n    const result = originalInsertMatch.apply(...arguments);\n    if (this._gristShouldRefreshCompletions(base)) {\n      this.showPopup(this.editor);\n    }\n    return result;\n  }.bind(completer);\n\n  aceCompleterAddHelpLinks(completer);\n\n  // Explicitly destroy the auto-completer on disposal, since it doesn't not remove the element\n  // it adds to body even when it detaches itself. Ace's AutoCompleter doesn't expose any\n  // interface for this, so this takes some hacking. (One reason for this is that Ace seems to\n  // expect that a single AutoCompleter would be used for all editor instances.)\n  editor.on(\"destroy\" as any, () => {\n    if (completer.editor) {\n      completer.detach();\n    }\n    if (completer.popup) {\n      completer.popup.destroy();                // This is not enough, but seems relevant to call.\n      completer.popup.container.remove();       // Removes the element from DOM.\n    }\n  });\n}\n\nlet _initialized = false;\nfunction initCustomCompleter() {\n  if (_initialized) { return; }\n  _initialized = true;\n\n  // The default regex just matches identifiers. We expand it to include periods (to capture\n  // attributes) and \"$\", for Grist column names. In addition, we autocomplete lookup formulas\n  // with the function name, to give suggestions for lookup keyword arguments.\n  const prefixMatchRegex = /\\w+\\.(?:lookupRecords|lookupOne)\\([\\w.$\\u00A2-\\uFFFF]*$|[\\w.$\\u00A2-\\uFFFF]+$/;\n\n  // Monkey-patch getCompletionPrefix. This is based on the source code in\n  // node_modules/ace-builds/src-noconflict/ext-language_tools.js, simplified to do the one thing\n  // we want here (since the original method's generality doesn't help us here).\n  const util = ace.require(\"ace/autocomplete/util\");\n  util.getCompletionPrefix = function getCompletionPrefix(this: any, editor: Ace.Editor) {\n    const pos = editor.getCursorPosition();\n    const line = editor.session.getLine(pos.row);\n    const match = line.slice(0, pos.column).match(prefixMatchRegex);\n    return match ? match[0] : \"\";\n  };\n\n  // Add some autocompletion with partial access to document\n  const aceLanguageTools = ace.require(\"ace/ext/language_tools\");\n  aceLanguageTools.setCompleters([]);\n  aceLanguageTools.addCompleter({\n    // For autocompletion we ship text to the sandbox and run standard completion there.\n    async getCompletions(\n      editor: Ace.Editor,\n      session: Ace.EditSession,\n      pos: Ace.Position,\n      prefix: string,\n      callback: any,\n    ) {\n      const options = completionOptions.get(editor);\n      if (!options || prefix.length === 0) { callback(null, []); return; }\n\n      // Autocompletion can be triggered in the middle of a function or method call, like\n      // in the case where one function is being switched with another. Since we normally\n      // append a \"(\" when completing such suggestions, we need to be careful not to do\n      // so if a \"(\" is already present. One way to do this in ACE is to check if the\n      // current token is a function/identifier, and the next token is a lparen; if both are\n      // true, we skip appending a \"(\" to each suggestion.\n      const wordRange = session.getWordRange(pos.row, pos.column);\n      const token = session.getTokenAt(pos.row, wordRange.end.column)!;\n      const nextToken = session.getTokenAt(pos.row, wordRange.end.column + 1);\n      const isRenamingFunc = [\"function.support\", \"identifier\"].includes(token.type) &&\n        nextToken?.type === \"paren.lparen\";\n\n      const suggestions = await options.getSuggestions(prefix);\n      // ACE autocompletions are very poorly documented. This is somewhat helpful:\n      // https://prog.world/implementing-code-completion-in-ace-editor/\n      const completions: AceSuggestion[] = suggestions.map((suggestionWithValue) => {\n        const [suggestion, example] = suggestionWithValue;\n        if (Array.isArray(suggestion)) {\n          const [funcname, argSpec] = suggestion;\n          return {\n            value: funcname + (isRenamingFunc ? \"\" : \"(\"),\n            caption: funcname + argSpec,\n            score: 1,\n            example,\n            funcname,\n          };\n        } else {\n          return {\n            value: suggestion,\n            caption: suggestion,\n            score: 1,\n            example,\n            funcname: \"\",\n          };\n        }\n      });\n\n      // For suggestions with example values, calculate the 'shared padding', i.e.\n      // the minimum width in characters that all suggestions should fill\n      // (before adding 'base padding') so that the examples are aligned.\n      const captionLengths = completions.filter(c => c.example).map(c => c.caption.length);\n      const sharedPadding = Math.min(\n        Math.min(...captionLengths) + MAX_RELATIVE_SHARED_PADDING,\n        Math.max(...captionLengths),\n        MAX_ABSOLUTE_SHARED_PADDING,\n      );\n\n      // Add the padding spaces and example values to the captions.\n      for (const c of completions) {\n        if (!c.example) { continue; }\n        const numSpaces = Math.max(0, sharedPadding - c.caption.length) + BASE_PADDING;\n        c.caption = c.caption + \" \".repeat(numSpaces) + c.example;\n      }\n\n      callback(null, completions);\n    },\n  });\n}\n\n// Regardless of other suggestions, always add this many spaces between the caption and the example.\nconst BASE_PADDING = 8;\n// In addition to the base padding, there's shared padding, which is the minimum number of spaces\n// that all suggestions should fill so that the examples are aligned.\n// However, one extremely long suggestion shouldn't result in huge padding for all suggestions.\n// To mitigate this, there are two limits on the shared padding.\n// The first limit is relative to the shortest caption in the suggestions.\n// So if all the suggestions are similarly long, there will still be some shared padding.\nconst MAX_RELATIVE_SHARED_PADDING = 15;\n// The second limit is absolute, so that even if all suggestions are long, we don't run out of popup space.\nconst MAX_ABSOLUTE_SHARED_PADDING = 40;\n\n// Suggestion objects that are passed to ace.\ninterface AceSuggestion {\n  value: string;    // the actual value inserted by the autocomplete\n  caption: string;  // the value displayed in the popup\n  score: number;\n\n  // Custom attributes used only by us\n  example: string | null;  // example value of the suggestion to show on the right\n  funcname: string;        // name of a function to link to in documentation\n}\n\n/**\n * When autocompleting a known function (with funcname received from the server call), turn the\n * function name into a link to Grist documentation.\n *\n * This is only applied for items returned from getCompletions() that include a our custom\n * `funcname` attribute.\n *\n * ACE autocomplete is poorly documented, and poorly customizable, so this is accomplished by\n * monkey-patching it. Further, the only text styling is done via styled tokens, but we can style\n * them to look like links, and handle clicks to open the destination URL.\n *\n * This implementation relies a lot on the details of the implementation in\n * node_modules/ace-builds/src-noconflict/ext-language_tools.js. Updates to ace-builds module may\n * easily break it.\n */\nfunction aceCompleterAddHelpLinks(completer: any) {\n  // Replace the $init function in order to intercept the creation of the autocomplete popup.\n  const init = completer.$init;\n  completer.$init = function() {\n    const popup = init.apply(this, arguments);\n    customizeAceCompleterPopup(this, popup);\n    return popup;\n  };\n}\n\nfunction customizeAceCompleterPopup(completer: any, popup: any) {\n  // Replace the $tokenizeRow function to produce customized tokens to style the link part.\n  const origTokenize = popup.session.bgTokenizer.$tokenizeRow;\n  popup.session.bgTokenizer.$tokenizeRow = function(row: any) {\n    const tokens = origTokenize(row);\n    return retokenizeAceCompleterRow(popup.data[row], tokens);\n  };\n\n  // Replace the click handler with one that handles link clicks.\n  popup.removeAllListeners(\"click\");\n  popup.on(\"click\", function(e: any) {\n    if (!maybeAceCompleterLinkClick(e.domEvent)) {\n      completer.insertMatch();\n    }\n    e.stop();\n  });\n}\n\nfunction retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: Ace.Token[]): Ace.Token[] {\n  if (!(rowData.funcname || rowData.example)) {\n    // Not a special completion, pass through the result of ACE's original tokenizing.\n    return tokens;\n  }\n\n  // ACE's original tokenizer splits rowData.caption into tokens to highlight matching portions.\n  // We jump in, and further divide the tokens so that those that form the link get an extra CSS\n  // class. ACE's will turn token.type into CSS classes by splitting the type on \".\" and prefixing\n  // the resulting substrings with \"ace_\".\n\n  // Funcname may be the recognized name itself (e.g. \"UPPER\"), or a method (like\n  // \"Table1.lookupOne\"), in which case only the portion after the dot is the recognized name.\n\n  // Figure out the portion that should be linkified.\n  const dot = rowData.funcname.lastIndexOf(\".\");\n  const linkStart = dot < 0 ? 0 : dot + 1;\n  const linkEnd = rowData.funcname.length;\n\n  const newTokens = [];\n\n  // Include into new tokens a special token that will be hidden, but include the link URL. On\n  // click, we find it to know what URL to open.\n  const href = `${commonUrls.functions}/#` +\n    rowData.funcname.slice(linkStart, linkEnd).toLowerCase();\n  newTokens.push({ value: href, type: \"grist_link_hidden\" });\n\n  // Find where the example value (if any) starts, so that it can be shown in grey.\n  let exampleStart: number | undefined;\n  if (rowData.example) {\n    if (!rowData.caption.endsWith(rowData.example)) {\n      // Just being cautious, this shouldn't happen.\n      console.warn(`Example \"${rowData.example}\" does not match caption \"${rowData.caption}\"`);\n    } else {\n      exampleStart = rowData.caption.length - rowData.example.length;\n    }\n  }\n\n  // Go through tokens, splitting them if needed, and modifying those that form the link part.\n  let position = 0;\n  for (const t of tokens) {\n    if (exampleStart && position + t.value.length > exampleStart) {\n      // Ensure that all text after `exampleStart` has the type 'grist_example'.\n      // Don't combine that type with the existing type, because ace highlights weirdly sometimes\n      // and it's best to just override that.\n      const end = exampleStart - position;\n      if (end > 0) {\n        newTokens.push({ value: t.value.slice(0, end), type: t.type });\n        newTokens.push({ value: t.value.slice(end), type: \"grist_example\" });\n      } else {\n        newTokens.push({ value: t.value, type: \"grist_example\" });\n      }\n    } else {\n      // Handle links to documentation.\n      // lStart/lEnd are indices of the link within the token, possibly negative.\n      const lStart = linkStart - position, lEnd = linkEnd - position;\n      if (lStart > 0) {\n        const beforeLink = t.value.slice(0, lStart);\n        newTokens.push({ value: beforeLink, type: t.type });\n      }\n      if (lEnd > 0) {\n        const inLink = t.value.slice(Math.max(0, lStart), lEnd);\n        const newType = t.type + (t.type ? \".\" : \"\") + \"grist_link\";\n        newTokens.push({ value: inLink, type: newType });\n        if (lEnd < t.value.length) {\n          const afterLink = t.value.slice(lEnd);\n          newTokens.push({ value: afterLink, type: t.type });\n        }\n      } else {\n        newTokens.push(t);\n      }\n    }\n    position += t.value.length;\n  }\n  return newTokens;\n}\n\n// On any click on AceCompleter popup, we check if we happened to click .ace_grist_link class. If\n// so, we should be able to find the URL and open another window to it.\nfunction maybeAceCompleterLinkClick(domEvent: Event) {\n  const tgt = domEvent.target as HTMLElement;\n  if (tgt?.matches(\".ace_grist_link\")) {\n    const dest = tgt.parentElement?.querySelector(\".ace_grist_link_hidden\");\n    if (dest) {\n      window.open(dest.textContent!, \"_blank\");\n      return true;\n    }\n  }\n  return false;\n}\n"
  },
  {
    "path": "app/client/components/ActionCounter.ts",
    "content": "import * as dispose from \"app/client/lib/dispose\";\nimport { DocData } from \"app/client/models/DocData\";\nimport { MinimalActionGroup } from \"app/common/ActionGroup\";\nimport { DocState } from \"app/common/DocState\";\n\nimport { Computed, Observable } from \"grainjs\";\n\nconst MAX_MEMORY_OF_COUNTED_ACTIONS = 250;\nconst MAX_COUNT = 20;\n\n/**\n * Counts the number of actions since the \"base action\"\n * (when the document was created, or forked, or copied).\n * A \"mark\" can be set, meaning a distinct action against\n * which a separate countFromMark is measured. This is\n * used for the suggestion feature.\n *\n * If the count is large, we give up and just call it\n * 'many'. We need to bear in mind also that action history\n * gets truncated and so may not be complete.\n */\nexport class ActionCounter extends dispose.Disposable {\n  // The full count from the base action.\n  public count: Observable<number | \"...\">;\n\n  // The count from the marked actionNum, if there is one\n  // (otherwise is same as `count`).\n  public countFromMark: Observable<number | \"...\">;\n\n  public isUndoBlocked: Observable<boolean>;\n\n  // List of actionNums that we've seen. Gets truncated.\n  private _actionNumList: number[];\n\n  // Set of actionNums that contributed to count.\n  private _counted: Set<number>;\n\n  // A map from actionNum to the count at that actionNum.\n  private _countAt: Map<number, number>;\n\n  // The current count.\n  private _count: number;\n\n  // The current marked actionNum, if any.\n  private _actionNumMark: number | null;\n\n  // The offset to the count at the marked actionNum, or 0.\n  private _countOffset: number;\n\n  private _docData: DocData;\n\n  public create(log: MinimalActionGroup[], docData: DocData) {\n    this._docData = docData;\n\n    // Initialize counters and stats.\n    this.count = Observable.create(this, 0);\n    this.countFromMark = Observable.create(this, 0);\n    // If there is a marked actionNum, then block undos beyond it.\n    this.isUndoBlocked = Computed.create(\n      this,\n      this.countFromMark,\n      (_, count) => this._actionNumMark ? count <= 0 : false,\n    );\n    this._counted = new Set();\n    this._actionNumList = [];\n    this._countAt = new Map();\n    this._count = 0;\n    this._actionNumMark = null;\n    this._countOffset = 0;\n\n    // Get base action if any.\n    const docSettings = docData.docSettings();\n    const state = docSettings.baseAction;\n    if (!state) {\n      this._setCount();\n      return;\n    }\n\n    // Scan log actions for the base action.\n    let base: number = 0;\n    for (let i = 0; i < log.length; i++) {\n      const action = log[log.length - i - 1];\n      if (action.actionNum === state.n &&\n        action.actionHash === state.h) {\n        base = log.length - i;\n        break;\n      }\n    }\n\n    // Either we found the base or not. Now scan forward to count\n    // actions. Need to go in this order because of possible\n    // undo/redos.\n    for (let i = base; i < log.length; i++) {\n      const action = log[i];\n      this.pushAction(action);\n    }\n  }\n\n  // This marks a state to use as the reference for countFromMark.\n  // Useful for suggestion feature.\n  public setMark(state?: DocState) {\n    this._actionNumMark = state?.n ?? null;\n    this._countOffset = -(this._countAt.get(this._actionNumMark ?? -1) ?? 0);\n    this._setCount();\n  }\n\n  public setMarkToBaseAction() {\n    const docSettings = this._docData.docSettings();\n    const state = docSettings.baseAction;\n    this.setMark(state);\n  }\n\n  // Process an action, updating the count.\n  public pushAction(action: MinimalActionGroup) {\n    if (action.isUndo) {\n      if (this._counted.has(action.otherId)) {\n        // Undoing an action we counted, so update count.\n        this._changeCount(-1);\n      }\n    } else {\n      this._countAction(action);\n      this._changeCount(+1);\n    }\n    this._actionNumList.push(action.actionNum);\n    while (this._actionNumList.length > MAX_MEMORY_OF_COUNTED_ACTIONS) {\n      const actionNum = this._actionNumList.shift()!;\n      this._counted.delete(actionNum);\n      this._countAt.delete(actionNum);\n    }\n    this._countAt.set(action.actionNum, this._count);\n  }\n\n  private _countAction(action: MinimalActionGroup) {\n    this._counted.add(action.actionNum);\n  }\n\n  private _changeCount(delta: number, value?: number) {\n    if (value === undefined) {\n      value = this._count;\n    }\n    value += delta;\n    this._count = value;\n    this._setCount();\n  }\n\n  private  _setCount() {\n    this.count.set(this._truncated(this._count));\n    this.countFromMark.set(this._truncated(this._count + this._countOffset));\n  }\n\n  private _truncated(value: number): number | \"...\" {\n    return (value > MAX_COUNT) ? \"...\" : value;\n  }\n}\n"
  },
  {
    "path": "app/client/components/ActionLog.css",
    "content": ".action_log {\n  padding: 1rem;\n  margin: 0;\n}\n\n.action_log_item {\n  list-style: none;\n  padding: 0;\n  margin: 0;\n  font-size: 1.1rem;\n}\n\n.action_info {\n  line-height: 1;\n  font-size: 0.9rem;\n  color: grey;\n  margin-bottom: 4px;\n  margin-top: 8px;\n}\n\n.action_info > span {\n  margin: 0 2px;\n}\n\n.action_info_user {\n  font-weight: 600;\n}\n\n.action_info_from_self {\n  color: var(--grist-theme-document-history-activity-text-light, #333333);\n}\n\n.action_desc {\n  color: var(--grist-theme-document-history-activity-text, unset);\n}\n\n.action_log_item.undone > .action_info,\n.action_log_item.undone > .action_desc {\n  text-decoration: line-through;\n  color: #aaa;\n}\n\n.action_log_item.buried {\n  background-color: #ddd;\n}\n\n.action_log_item.buried > .action_desc {\n  text-decoration: line-through;\n  color: #aaa;\n}\n\n.action_log_rename_pre {\n  color: #333333;\n  background: #faa;\n}\n\n.action_log_rename_post {\n  color: #333333;\n  background: #afa;\n}\n\n.action_log_table {\n  border-collapse: collapse;\n}\n\n.action_log_table caption {\n  caption-side: bottom;\n  text-align: center;\n  margin-top: 0;\n  padding-top: 0;\n  color: var(--grist-theme-document-history-activity-text-light, #333);\n}\n\n.action_log_table td {\n  border: 1px solid var(--grist-theme-document-history-table-border, lightgray);\n  cursor: pointer;\n}\n\n.action_log_table th {\n  border-top: 1px solid var(--grist-theme-document-history-table-border-light, #D9D9D9);\n  border-left: 1px solid var(--grist-theme-document-history-table-border-light, #D9D9D9);\n  border-right: 1px solid var(--grist-theme-document-history-table-border-light, #D9D9D9);\n  color: var(--grist-theme-document-history-table-header-fg, #000);\n}\n\n.action_log_table th:first-child {\n  border: none;\n  border-bottom: 1px solid var(--grist-theme-document-history-table-border-light, #D9D9D9);\n}\n\n.action_log_table td:first-child {\n  border: none;\n  border-left: 1px solid var(--grist-theme-document-history-table-border-light, #D9D9D9);\n  border-bottom: 1px solid var(--grist-theme-document-history-table-border-light, #D9D9D9);\n  color: var(--grist-theme-document-history-table-header-fg, #000);\n  cursor: inherit;\n}\n\n.action_log_table td:not(:first-child) {\n  background-color: var(--grist-theme-table-body-bg);\n}\n\n.action_log_table td, .action_log_table th {\n  padding-left: 3px;\n  padding-right: 3px;\n  font-weight: normal;\n}\n\n.action_log_cell_remove {\n  color: #333333;\n  background: #faa;\n  text-decoration: line-through;\n  padding-left: 2px;\n  padding-right: 2px;\n}\n\n.action_log_cell_pre {\n  margin-right: 3px;\n}\n\n.action_log_cell_add {\n  color: #333333;\n  background: #afa;\n  padding-left: 2px;\n  padding-right: 2px;\n}\n\n.action_comment {\n  display: none;\n}\n\n.action_info {\n  color: var(--grist-theme-document-history-activity-text-light, #929299);\n}\n"
  },
  {
    "path": "app/client/components/ActionLog.ts",
    "content": "/**\n * ActionLog manages the list of actions from server and displays them in the side bar.\n */\n\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport * as dispose from \"app/client/lib/dispose\";\nimport koArray from \"app/client/lib/koArray\";\nimport { KoArray } from \"app/client/lib/koArray\";\nimport * as koDom from \"app/client/lib/koDom\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ClientTimeData } from \"app/client/models/TimeQuery\";\nimport { basicButton } from \"app/client/ui2018/buttons\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { ActionGroup } from \"app/common/ActionGroup\";\nimport { concatenateSummaryPair } from \"app/common/ActionSummarizer\";\nimport {\n  ActionSummary, asTabularDiffs, createEmptyActionSummary, defunctTableName, getAffectedTables,\n  LabelDelta,\n} from \"app/common/ActionSummary\";\nimport { CellDelta, TabularDiff, TabularDiffs } from \"app/common/TabularDiff\";\nimport { timeFormat } from \"app/common/timeFormat\";\nimport { ResultRow, TimeCursor, TimeQuery } from \"app/common/TimeQuery\";\n\nimport { Disposable, dom, DomContents, fromKo, IDomComponent, makeTestId, styled } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport takeWhile from \"lodash/takeWhile\";\n\nconst testId = makeTestId(\"test-actionlog-\");\n\n/**\n *\n * Actions that are displayed in the log get a state observable\n * to track if they are undone/buried.\n *\n * Also for each table shown in the log, we create an observable\n * to track its name.  References to these observables are stored\n * with each action, by the name of the table at that time (the\n * name of a table can change).\n *\n */\nexport interface ActionGroupWithState extends ActionGroup {\n  state?: ko.Observable<string>;  // is action undone/buried\n  tableFilters?: { [tableId: string]: ko.Observable<string> };  // current names of tables\n  affectedTableIds?: ko.Observable<string>[]; // names of tables affecting this ActionGroup\n  context?: ko.Observable<ActionContext>;  // extra cell information, computed on demand\n}\n\nexport type ActionContext = Record<string, ResultRow[]>;\n\nconst gristNotify = window.gristNotify!;\n\n// Action display state enum.\nconst state = {\n  UNDONE: \"undone\",\n  BURIED: \"buried\",\n  DEFAULT: \"default\",\n};\n\nconst t = makeT(\"ActionLog\");\n\nexport class ActionLog extends dispose.Disposable implements IDomComponent {\n  public displayStack: KoArray<ActionGroupWithState>;\n  public selectedTableId: ko.Computed<string>;\n  public showAllTables: ko.Observable<boolean>;      // should all tables be visible?\n\n  private _gristDoc: GristDoc | null;\n\n  private _pending: ActionGroupWithState[] = [];  // cache for actions that arrive while loading log\n  private _loaded: boolean = false;               // flag set once log is loaded\n  private _loading: ko.Observable<boolean>;  // flag set while log is loading\n  private _censored: ko.Observable<boolean>;\n\n  /**\n   * Create an ActionLog.\n   * @param options - supplies the GristDoc holding the log, if we have one, so that we\n   *   can cross-reference with it.  We may not have a document, if used from the\n   *   command line renderActions utility, in which case we don't set up cross-references.\n   */\n  public create(options: { gristDoc: GristDoc | null }) {\n    // By default, just show actions for the currently viewed table.\n    this.showAllTables = ko.observable(false);\n    // We load the ActionLog lazily now, when it is first viewed.\n    this._loading = ko.observable(false);\n    this._censored = ko.observable(false);\n\n    this._gristDoc = options.gristDoc;\n\n    // TODO: displayStack grows without bound within a single session.\n    // Stack of actions as they should be displayed to the user.\n    this.displayStack = koArray<ActionGroupWithState>();\n\n    // Computed for the tableId of the table currently being viewed.\n    this.selectedTableId = this.autoDispose(ko.computed(() => {\n      if (!this._gristDoc || this._gristDoc.viewModel.isDisposed()) { return \"\"; }\n      const section = this._gristDoc.viewModel.activeSection();\n      if (!section || section.isDisposed()) { return \"\"; }\n      const table = section.table();\n      return table && !table.isDisposed() ? table.tableId() : \"\";\n    }));\n  }\n\n  public buildDom() {\n    return this._buildLogDom();\n  }\n\n  /**\n   * Figure out what has changed in the document after the given\n   * action (and not including it).\n   */\n  public async getChangesSince(actionNum: number): Promise<ActionSummary> {\n    await this._loadActionSummaries();\n    return takeWhile(this.displayStack.all(), item => item.actionNum > actionNum)\n      .reduce((summary, item) => concatenateSummaryPair(item.actionSummary, summary), createEmptyActionSummary());\n  }\n\n  /**\n   * Pushes actions as they are received from the server to the display stack.\n   * @param {Object} actionGroup - ActionGroup instance from the server.\n   */\n  public pushAction(ag: ActionGroupWithState): void {\n    if (this._loading()) {\n      this._pending.push(ag);\n      return;\n    }\n\n    ag.context = ko.observable({});\n    this._setupFilters(ag, this.displayStack.at(0) || undefined);\n    const otherAg = ag.otherId ? this.displayStack.all().find(a => a.actionNum === ag.otherId) : null;\n\n    if (otherAg) {\n      // Undo/redo action.\n      if (otherAg.state) {\n        otherAg.state(ag.isUndo ? state.UNDONE : state.DEFAULT);\n      }\n    } else {\n      // Any (non-link) action.\n      if (ag.fromSelf) {\n        // Bury all undos immediately preceding this action since they can no longer\n        // be redone. This is triggered by normal actions and undo/redo actions whose\n        // targets are not recent (not in the stack).\n        for (let i = 0; i < this.displayStack.peekLength; i++) {\n          const prevAction = this.displayStack.at(i)!;\n          if (!prevAction.state) { continue; }\n          const prevState = prevAction.state();\n          if (prevAction.fromSelf && prevState === state.DEFAULT) {\n            // When a normal action is found, stop looking to bury previous actions.\n            break;\n          } else if (prevAction.fromSelf && prevState === state.UNDONE) {\n            // The previous action was undone, so now it has become buried.\n            prevAction.state(state.BURIED);\n          }\n        }\n      }\n      if (!ag.otherId) {\n        ag.state = ko.observable(state.DEFAULT);\n        this.displayStack.unshift(ag);\n      }\n    }\n  }\n\n  /**\n   * Render a description of an action prepared on the server.\n   * @param {TabularDiffs} act - a collection of table changes\n   * @param {string} txt - a textual description of the action\n   * @param {ActionGroupWithState} ag - the full action information we have\n   */\n  public renderTabularDiffs(sum: ActionSummary, txt: string, ag?: ActionGroupWithState): HTMLElement {\n    const part = new ActionLogPartInList(\n      this._gristDoc,\n      ag,\n      this,\n    );\n    return part.renderTabularDiffs(sum, {\n      txt,\n      contextObs: ag?.context,\n    });\n  }\n\n  /**\n   * Decorate an ActionGroup with observables for controlling visibility of any\n   * table information rendered from it.  Observables are shared with the previous\n   * ActionGroup, and simply stored under a new name as needed.\n   */\n  private _setupFilters(ag: ActionGroupWithState, prev?: ActionGroupWithState): void {\n    const filt: { [name: string]: ko.Observable<string> } = ag.tableFilters = {};\n\n    // First, bring along observables for tables from previous actions.\n    if (prev) {\n      // Tables are renamed from time to time - prepare dictionary of updates.\n      const renames = new Map(ag.actionSummary.tableRenames);\n      for (const name of Object.keys(prev.tableFilters!)) {\n        if (name.startsWith(\"-\")) {\n          // skip\n        } else if (renames.has(name)) {\n          const newName = renames.get(name) || defunctTableName(name);\n          filt[newName] = prev.tableFilters![name];\n          filt[newName](newName);   // Update the observable with the new name.\n        } else {\n          filt[name] = prev.tableFilters![name];\n        }\n      }\n    }\n    // Add any more observables that we need for this action.\n    const names = getAffectedTables(ag.actionSummary);\n    for (const name of names) {\n      if (!filt[name]) { filt[name] = ko.observable(name); }\n    }\n    // Record the observables that affect this ActionGroup specifically\n    ag.affectedTableIds = names.map(name => ag.tableFilters![name]).filter(obs => obs);\n  }\n\n  /**\n   * Helper function that returns true if any table touched by the ActionGroup\n   * is set to be visible.\n   */\n  private _hasSelectedTable(ag: ActionGroupWithState): boolean {\n    if (!this._gristDoc) { return true; }\n    return ag.affectedTableIds!.some(tableId => tableId() === this.selectedTableId());\n  }\n\n  private _buildLogDom() {\n    this._loadActionSummaries().catch(() => gristNotify(t(\"Action Log failed to load\")));\n    return dom(\"div.action_log\",\n      { tabIndex: \"-1\" },\n      dom.maybe(this._censored, () => {\n        return cssHistoryCensored(dom(\n          \"p\",\n          t(\"History blocked because of access rules.\"),\n        ));\n      }),\n      // currently, if censored, no history at all available - so drop checkbox\n      dom.maybe(use => !use(this._censored), () => {\n        return dom(\"div\",\n          labeledSquareCheckbox(fromKo(this.showAllTables),\n            t(\"All tables\"),\n          ),\n        );\n      }),\n      dom(\"div.action_log_load\",\n        koDom.show(() => this._loading()),\n        \"Loading...\"),\n      koDom.foreach(this.displayStack, (ag: ActionGroupWithState) => {\n        const timestamp = ag.time ? timeFormat(\"D T\", new Date(ag.time)) : \"\";\n        let desc: DomContents = ag.desc || \"\";\n        if (ag.actionSummary) {\n          desc = this.renderTabularDiffs(ag.actionSummary, desc, ag);\n        }\n        return dom(\"div.action_log_item\",\n          koDom.cssClass(ag.state),\n          koDom.show(() => this.showAllTables() || this._hasSelectedTable(ag)),\n          dom(\"div.action_info\",\n            dom(\"span.action_info_action_num\", `#${ag.actionNum}`),\n            ag.user ? dom(\"span.action_info_user\",\n              ag.user,\n              koDom.toggleClass(\"action_info_from_self\", ag.fromSelf),\n            ) : \"\",\n            dom(\"span.action_info_timestamp\", timestamp)),\n          dom(\"span.action_desc\", desc),\n        );\n      }),\n    );\n  }\n\n  /**\n   * Fetch summaries of recent actions (with summaries) from the server.\n   */\n  private async _loadActionSummaries() {\n    if (this._loaded || !this._gristDoc) { return; }\n    this._loading(true);\n    // Returned actions are ordered with earliest actions first.\n    const { actions: result, censored } = await this._gristDoc.docComm.getActionSummaries();\n    this._censored(censored);\n    this._loading(false);\n    this._loaded = true;\n    // Add the actions to our action log.\n    result.forEach(item => this.pushAction(item));\n    // Add any actions that came in while we were fetching.  Unlikely, but\n    // perhaps possible?\n    const top = result.length > 0 ? result[result.length - 1].actionNum : 0;\n    for (const item of this._pending) {\n      if (item.actionNum > top) { this.pushAction(item); }\n    }\n    this._pending.length = 0;\n  }\n}\n\n/**\n * Factor out the display of a single action group, since that\n * is useful elsewhere in the UI now. This is an abstract class,\n * we will connect it with ActionLog in ActionLogPartInList.\n */\nexport abstract class ActionLogPart extends Disposable {\n  public constructor(\n    private _gristDocBase: GristDoc | null,\n  ) {\n    super();\n  }\n\n  /**\n   * This is used in the ActionLog to selectively show entries in the\n   * log that are relevant to a particular table. This could simply\n   * return true if everything should be shown.\n   */\n  public abstract showForTable(tableName: string): boolean;\n\n  /**\n   * When the user clicks on the specified cell within this entry,\n   * this should bring the user to that cell elsewhere in the UI,\n   * so they can see its full current context.\n   */\n  public abstract selectCell(rowId: number, colId: string, tableId: string): Promise<void>;\n\n  /**\n   * Should return completions for the rows mentioned in this entry.\n   */\n  public abstract getContext(): Promise<ActionContext | undefined>;\n\n  /**\n   * Render a description of an action prepared on the server.\n   * @param {TabularDiffs} act - a collection of table changes\n   * @param {string} txt - a textual description of the action\n   * @param {Observable} context - extra information about the action\n   */\n  public renderTabularDiffs(sum: ActionSummary, options: RenderTabularDiffOptions): HTMLElement {\n    const { txt, contextObs } = options;\n    const editDom = koDom.scope(contextObs, (context: ActionContext) => {\n      const act = asTabularDiffs(sum, {\n        context,\n        order: this._naiveColumnOrder.bind(this),\n      });\n      return dom(\"div\",\n        testId(\"tabular-diffs\"),\n        this._renderTableSchemaChanges(sum),\n        this._renderColumnSchemaChanges(sum),\n        options.customRender ? options.customRender?.(act, contextObs!, this.selectCell.bind(this)) :\n          Object.entries(act).map(([table, tdiff]: [string, TabularDiff]) => {\n            if (tdiff.cells.length === 0) { return dom(\"div\"); }\n            return dom(\n              \"table.action_log_table\",\n              koDom.show(() => this.showForTable(table)),\n              dom(\"caption\",\n                this._renderTableName(table),\n                // Add a little button to show or hide extra context.\n                // This is a baby step, there's a lot more that could\n                // and should be done here.\n                contextObs ? cssBasicButton(\n                  context[table] ? \" <\" : \" >\",\n                  dom.on(\"click\", () => this.toggleContext(contextObs, table))) : null,\n                dom.style(\"text-align\", \"left\"),\n              ),\n              dom(\n                \"tr\",\n                dom(\"th\"),\n                tdiff.header.map((diff) => {\n                  return dom(\"th\", this._renderCell(diff));\n                })),\n              tdiff.cells.map(\n                (row) => {\n                  return dom(\n                    \"tr\",\n                    dom(\"td\", this._renderCell(row.type)),\n                    row.cellDeltas.map((diff, idx: number) => {\n                      return dom(\"td\",\n                        this._renderCell(diff),\n                        dom.on(\"click\", () => {\n                          return this.selectCell(row.rowId, act[table].header[idx], table);\n                        }));\n                    }));\n                }));\n          }),\n        txt ? dom(\"span.action_comment\", txt) : null,\n      );\n    });\n    return dom(\"div\", editDom);\n  }\n\n  public async toggleContext(contextObs: ko.Observable<ActionContext>, table: string) {\n    const context = contextObs.peek();\n    if (context[table]) {\n      await this._resetContext(contextObs, table, context);\n    } else {\n      await this._setContext(contextObs, table, context);\n    }\n  }\n\n  /**\n   * Prepare dom element(s) for a cell that has been created, destroyed,\n   * or modified.\n   *\n   * @param {CellDelta|string|null} cell - a structure with before and after values,\n   *   or a plain string, or null\n   *\n   */\n  private _renderCell(cell: CellDelta | string | null) {\n    // we'll show completely empty cells as \"...\"\n    if (cell === null) {\n      return \"...\";\n    }\n    // strings are shown as themselves\n    if (typeof (cell) === \"string\") {\n      return cell;\n    }\n    if (!Array.isArray(cell)) {\n      return cell;\n    }\n    // by elimination, we have a TabularDiff.CellDelta with before and after values.\n    const [pre, post] = cell;\n    if (!pre && !post) {\n      // very boring before + after values :-)\n      return \"\";\n    } else if (pre && !post) {\n      // this is a cell that was removed\n      return dom(\"span.action_log_cell_remove\", pre[0]);\n    } else if (post && (pre === null || (pre[0] === null || pre[0] === \"\"))) {\n      // this is a cell that was added, or modified from a previously empty value\n      return dom(\"span.action_log_cell_add\", post[0]);\n    } else if (pre && post) {\n      // a modified cell\n      return dom(\"div\",\n        dom(\"span.action_log_cell_remove.action_log_cell_pre\", pre[0]),\n        dom(\"span.action_log_cell_add\", post[0]));\n    }\n    return JSON.stringify(cell);\n  }\n\n  /**\n   * Choose a table name to show.  For now, we show diffs of metadata tables also.\n   * For those tables, we show \"_grist_Foo_bar\" as \"[Foo.bar]\".\n   * @param {string} name - tableId of table\n   * @returns {string} a friendlier name for the table\n   */\n  private _renderTableName(name: string): string {\n    if (!name.startsWith(\"_grist_\")) {\n      // Ordinary data table.  Ideally, we would look up\n      // a friendly name from a raw data view - TODO.\n      return name;\n    }\n    const metaName = name.split(\"_grist_\")[1].replace(/_/g, \".\");\n    return `[${metaName}]`;\n  }\n\n  /**\n   * Show an ActionLog item when a column or table is renamed, added, or removed.\n   * Make sure the item is only shown when the affected table is not filtered out.\n   *\n   * @param scope: blank for tables, otherwise \"<tablename>.\"\n   * @param pair: the rename/addition/removal in LabelDelta format: [null, name1]\n   * for addition of name1, [name2, null] for removal of name2, [name1, name2]\n   * for a rename of name1 to name2.\n   * @return a filtered dom element.\n   */\n  private _renderSchemaChange(scope: string, pair: LabelDelta) {\n    const [pre, post] = pair;\n    // ignore addition/removal of manualSort column\n    if ((pre || post) === \"manualSort\") { return dom(\"div\"); }\n    return dom(\"div.action_log_rename\",\n      koDom.show(() => this.showForTable(post || defunctTableName(pre!))),\n      (!post ? [\"Remove \", scope, dom(\"span.action_log_rename_pre\", pre)] :\n        (!pre ? [\"Add \", scope, dom(\"span.action_log_rename_post\", post)] :\n          [\"Rename \", scope, dom(\"span.action_log_rename_pre\", pre),\n            \" to \", dom(\"span.action_log_rename_post\", post)])));\n  }\n\n  /**\n   * Show any table additions/removals/renames.\n   */\n  private _renderTableSchemaChanges(sum: ActionSummary) {\n    return dom(\"div\",\n      sum.tableRenames.map(pair => this._renderSchemaChange(\"\", pair)));\n  }\n\n  /**\n   * Show any column additions/removals/renames.\n   */\n  private _renderColumnSchemaChanges(sum: ActionSummary) {\n    return dom(\"div\",\n      Object.keys(sum.tableDeltas).filter(key => !key.startsWith(\"-\")).map(key =>\n        dom(\"div\",\n          koDom.show(() => this.showForTable(key)),\n          sum.tableDeltas[key].columnRenames.map(pair =>\n            this._renderSchemaChange(key + \".\", pair)))));\n  }\n\n  private async _resetContext(contextObs: ko.Observable<ActionContext>, tableId: string, context: ActionContext) {\n    delete context[tableId];\n    contextObs(context);\n  }\n\n  private async _setContext(contextObs: ko.Observable<ActionContext>, tableId: string, context: ActionContext) {\n    const result = await this.getContext();\n    // table may have changed name\n    tableId = Object.keys(result || {})[0] || tableId;\n    if (result) {\n      contextObs({ ...context, [tableId]: result[tableId] });\n    }\n  }\n\n  private _naiveColumnOrder(tableId: string, colIds: string[]) {\n    // Naively, if there is currently a table matching the name,\n    // use its columns for ordering. It might not be the same table!\n    // Columns may have changed since! But the consequence of getting\n    // order wrong in some cases isn't that bad, compared to getting\n    // it wrong in regular case of unchanged schema.\n    // TODO: remove this method and replace with something that uses\n    // TimeQuery or related machinery.\n    const refColIds = this._gristDocBase?.docData.getTable(tableId)?.getColIds();\n    if (!refColIds) { return colIds; }\n    const order = new Map(refColIds.map((id, i) => [id, i]));\n    order.set(\"id\", 0);\n    return [...colIds].sort((a, b) => {\n      const ai = order.get(a);\n      const bi = order.get(b);\n      if (ai === undefined && bi === undefined) { return 0; }\n      if (ai === undefined) { return 1; }\n      if (bi === undefined) { return -1; }\n      return ai - bi;\n    });\n  }\n}\n\n/**\n * Connect ActionLogPart back to ActionLog. The only non-trivial\n * work is relating cells within it to current state, via the\n * action stack.\n */\nclass ActionLogPartInList extends ActionLogPart {\n  public constructor(\n    private _gristDoc: GristDoc | null,\n    private _actionGroup: ActionGroupWithState | undefined,\n    private _actionLog: ActionLog,\n  ) {\n    super(_gristDoc);\n  }\n\n  public showForTable(tableName: string): boolean {\n    return this._showForTable(tableName, this._actionGroup);\n  }\n\n  public async selectCell(rowId: number, colId: string, tableId: string): Promise<void> {\n    if (!this._gristDoc) { return; }\n    if (!this._actionGroup) { return; }\n    const actionNum = this._actionGroup.actionNum;\n\n    // Find action in the stack.\n    const index = this._actionLog.displayStack.peek().findIndex(a => a.actionNum === actionNum);\n    if (index < 0) { throw new Error(`Cannot find action ${actionNum} in the action log.`); }\n\n    // Found the action. Now trace forward to find current tableId, colId, rowId.\n    for (let i = index; i >= 0; i--) {\n      const action = this._actionLog.displayStack.at(i)!;\n      const sum = action.actionSummary;\n      const cell = traceCell({ rowId, colId, tableId }, sum, (deletedObj: DeletedObject) => {\n        reportDeletedObject(deletedObj, action.actionNum);\n      });\n      if (cell) {\n        tableId = cell.tableId;\n        colId = cell.colId;\n        rowId = cell.rowId;\n      }\n    }\n    await showCell(this._gristDoc, { tableId, colId, rowId });\n  }\n\n  public async getContext(): Promise<ActionContext | undefined> {\n    if (!this._gristDoc) { return; }\n    if (!this._actionGroup) { return; }\n    const base = this._actionGroup.actionSummary;\n    const actionNum = this._actionGroup.actionNum;\n    return await computeContext(this._gristDoc, base, (cursor) => {\n      // Find action in the stack.\n      const index = this._actionLog.displayStack.peek().findIndex(a => a.actionNum === actionNum);\n      if (index < 0) { throw new Error(`Cannot find action ${actionNum} in the action log.`); }\n      // Found the action. Now find mapping to current doc, just\n      // after this action.\n      for (let i = index - 1; i >= 0; i--) {\n        const action = this._actionLog.displayStack.at(i)!;\n        cursor.append(action.actionSummary);\n      }\n    });\n  }\n\n  /**\n   * Return a koDom.show clause that activates when the named table is not\n   * filtered out.\n   */\n  private _showForTable(tableName: string, ag?: ActionGroupWithState): boolean {\n    if (!ag) { return true; }\n    const obs = ag.tableFilters![tableName];\n    return this._actionLog.showAllTables() || !obs || obs() === this._actionLog.selectedTableId();\n  }\n}\n\n/**\n * Trace a cell through a change. The row, column, or table may be\n * deleted, in which case reportDeletion is called and null is returned.\n * Column and table renames are tracked, with updated names returned.\n */\nexport function traceCell(cell: { rowId: number, colId: string, tableId: string },\n  summary: ActionSummary,\n  reportDeletion: (deletedObj: DeletedObject) => void) {\n  let { tableId, colId } = cell;\n  const { rowId } = cell;\n  // Check if this table was renamed / removed.\n  const tableRename: LabelDelta | undefined = summary.tableRenames.find(r => r[0] === tableId);\n  if (tableRename) {\n    const newName = tableRename[1];\n    if (!newName) {\n      reportDeletion({ tableId });\n      return null;\n    }\n    tableId = newName;\n  }\n  const td = summary.tableDeltas[tableId];\n  if (!td) {\n    return { tableId, rowId, colId };\n  }\n\n  // Check is this row was removed - if so there's no reason to go on.\n  if (td.removeRows.includes(rowId)) {\n    reportDeletion({ thisRow: true });\n    return null;\n  }\n\n  // Check if this column was renamed / added.\n  const columnRename: LabelDelta | undefined = td.columnRenames.find(r => r[0] === colId);\n  if (columnRename) {\n    const newName = columnRename[1];\n    if (!newName) {\n      reportDeletion({ colId });\n      return null;\n    }\n    colId = newName;\n  }\n  return { tableId, rowId, colId };\n}\n\n/**\n * Show a cell in the UI. That is pretty ambiguous! The same\n * rowId/colId/tableId cell might be shown in many places.  The logic\n * here is ancient, written before the Raw Data page existed for\n * example, but for simple cases where the cell appears just once on a\n * user-created page, it works okay. A lot that could be done here.\n */\nexport async function showCell(gristDoc: GristDoc, cell: {\n  tableId: string,\n  colId: string,\n  rowId: number,\n}) {\n  const { tableId, colId, rowId } = cell;\n\n  // Find the table model of interest.\n  const tableModel = gristDoc.getTableModel(tableId);\n  if (!tableModel) { return; }\n\n  // Get its \"primary\" view.\n  const viewRow = tableModel.tableMetaRow.primaryView();\n  const viewId = viewRow.getRowId();\n\n  // Switch to that view.\n  await gristDoc.openDocPage(viewId);\n\n  // Now let's pick a reasonable section in that view.\n  const viewSection = viewRow.viewSections().peek().find((s: any) => s.table().tableId() === tableId);\n  if (!viewSection) { return; }\n  const sectionId = viewSection.getRowId();\n\n  // Within that section, find the column of interest if possible.\n  const fieldIndex = viewSection.viewFields().peek().findIndex((f: any) => f.colId.peek() === colId);\n\n  // Finally, move cursor position to the section, column (if we found it), and row.\n  gristDoc.moveToCursorPos({ rowId, sectionId, fieldIndex }).catch(() => { /* do nothing */ });\n}\n\n/**\n * Look up the information in other cells on the same row as changed\n * cells. This is useful to help the user understand what row it is.\n * This is not robust code, or much tested, and should be seen as a\n * placeholder for some systematic work.\n */\nexport async function computeContext(gristDoc: GristDoc, base: ActionSummary, init?: (cursor: TimeCursor) => void) {\n  if (!gristDoc) { return; }\n\n  const data = new ClientTimeData(gristDoc.docData);\n  const cursor = new TimeCursor(data);\n\n  init?.(cursor);\n\n  async function getTable(tableId: string, rowIds: number[]) {\n    const query = new TimeQuery(cursor, tableId, \"*\", rowIds);\n    await query.update();\n    return query.all();\n  }\n\n  const result: ActionContext = {};\n  for (const [tableId, tableDelta] of Object.entries(base.tableDeltas)) {\n    const rowIds = new Set([...tableDelta.addRows,\n      ...tableDelta.updateRows]);\n    const rows = await getTable(tableId, [...rowIds]);\n    result[tableId] = rows;\n  }\n  return result;\n}\n\nconst cssBasicButton = styled(basicButton, `\n  padding: 0;\n  margin-left: 5px;\n  border: none;\n`);\n\nfunction reportDeletedObject(obj: DeletedObject, actionNum: number) {\n  // This is written to avoid code changes that require retranslating these messages.\n  if (obj.tableId) {\n    gristNotify(t(\n      \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\",\n      { tableId: obj.tableId, actionNum },\n    ));\n  }\n  if (obj.colId) {\n    gristNotify(t(\n      \"Column {{colId}} was subsequently removed in action #{{actionNum}}\",\n      { colId: obj.colId, actionNum },\n    ));\n  }\n  if (obj.thisRow) {\n    gristNotify(t(\"This row was subsequently removed in action {{actionNum}}\", { actionNum }));\n  }\n}\n\ninterface DeletedObject {\n  thisRow?: boolean;\n  colId?: string;\n  tableId?: string;\n}\n\ninterface RenderTabularDiffOptions {\n  txt?: string;\n  contextObs?: ko.Observable<ActionContext>;\n  customRender?(\n    diffs: TabularDiffs,\n    ctx: ko.Observable<ActionContext>,\n    selectCell: (rowId: number, colId: string, tableId: string) => Promise<void>\n  ): DomContents;\n}\n\nconst cssHistoryCensored = styled(\"div\", `\n  margin: 8px 16px;\n  text-align: center;\n  color: ${theme.text};\n`);\n"
  },
  {
    "path": "app/client/components/Banner.ts",
    "content": "import { colors, isNarrowScreenObs } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { tokens } from \"app/common/ThemePrefs\";\n\nimport { Disposable, dom, DomArg, DomElementArg, makeTestId, Observable, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-banner-\");\n\nexport interface BannerOptions {\n  /**\n   * Content to display in the banner.\n   */\n  content: DomArg;\n\n  /**\n   * The banner style.\n   *\n   * Warning banners have an orange background. Error banners have a red\n   * background. Info banners have a yellow background.\n   *\n   * Custom banners have no default background; a custom `backgrund` must be\n   * set.\n   */\n  style: \"warning\" | \"error\" | \"info\" | \"custom\";\n\n  /**\n   * Custom background.\n   *\n   * May be any valid CSS `background` value.\n   */\n  background?: string;\n\n  /**\n   * Optional variant of `content` to display when screen width becomes narrow.\n   */\n  contentSmall?: DomArg;\n\n  /**\n   * Whether a button to close the banner should be shown.\n   *\n   * If true, `onClose` should also be specified; it will be called when the close\n   * button is clicked.\n   *\n   * Defaults to false.\n   */\n  showCloseButton?: boolean;\n\n  /**\n   * Whether a button to collapse/expand the banner should be shown on narrow screens.\n   *\n   * Defaults to false.\n   */\n  showExpandButton?: boolean;\n\n  /**\n   * If provided, applies the css class to the banner container.\n   */\n  bannerCssClass?: string;\n\n  /**\n   * Function that is called when the banner close button is clicked.\n   *\n   * Should be used to handle disposal of the Banner.\n   */\n  onClose?(): void;\n}\n\n/**\n * A customizable banner for displaying at the top of a page.\n */\nexport class Banner extends Disposable {\n  private readonly _isExpanded = Observable.create(this, true);\n\n  constructor(private _options: BannerOptions) {\n    super();\n  }\n\n  public buildDom() {\n    return cssBanner({ class: this._options.bannerCssClass || \"\" },\n      cssBanner.cls(`-${this._options.style}`),\n      dom.style(\"background\", this._options.background ?? \"\"),\n      this._buildContent(),\n      this._buildButtons(),\n      testId(\"element\"),\n    );\n  }\n\n  private _buildContent() {\n    const { content, contentSmall } = this._options;\n    return dom.domComputed((use) => {\n      if (contentSmall === undefined) { return [content]; }\n\n      const isExpanded = use(this._isExpanded);\n      const isNarrowScreen = use(isNarrowScreenObs());\n      return [isNarrowScreen && !isExpanded ? contentSmall : content];\n    });\n  }\n\n  private _buildButtons() {\n    return cssButtons(\n      this._options.showExpandButton ? this._buildExpandButton() : null,\n      this._options.showCloseButton ? this._buildCloseButton() : null,\n    );\n  }\n\n  private _buildCloseButton() {\n    return cssButton(\"CrossBig\",\n      dom.on(\"click\", () => this._options.onClose?.()),\n      testId(\"close\"),\n    );\n  }\n\n  private _buildExpandButton() {\n    return dom.maybe(isNarrowScreenObs(), () => {\n      return cssExpandButton(\"Dropdown\",\n        cssExpandButton.cls(\"-expanded\", this._isExpanded),\n        dom.on(\"click\", () => this._isExpanded.set(!this._isExpanded.get())),\n      );\n    });\n  }\n}\n\nexport function buildBannerMessage(...domArgs: DomElementArg[]) {\n  return cssBannerMessage(\n    cssIcon(\"Idea\"),\n    cssLightlyBoldedText(domArgs),\n  );\n}\n\nconst cssBanner = styled(\"div\", `\n  display: flex;\n  padding: 10px;\n  gap: 16px;\n  color: ${tokens.white};\n\n  & a {\n    color: ${tokens.white};\n    text-decoration: underline;\n  }\n\n  &-info {\n    color: ${tokens.black};\n    --icon-color: ${tokens.black};\n    background: #FFFACD;\n  }\n\n  &-warning {\n    background: #E6A117;\n  }\n\n  &-error {\n    background: ${colors.error};\n  }\n`);\n\nexport const cssBannerLink = styled(\"span\", `\n  cursor: pointer;\n  color: unset;\n  text-decoration: underline;\n\n  &:hover, &:focus {\n    color: unset;\n  }\n`);\n\nconst cssButtons = styled(\"div\", `\n  display: flex;\n  gap: 16px;\n  flex-shrink: 0;\n  margin-left: auto;\n`);\n\nconst cssButton = styled(icon, `\n  width: 16px;\n  height: 16px;\n  cursor: pointer;\n  background-color: white;\n`);\n\nconst cssExpandButton = styled(cssButton, `\n  &-expanded {\n    -webkit-mask-image: var(--icon-DropdownUp) !important;\n  }\n`);\n\nconst cssLightlyBoldedText = styled(\"div\", `\n  font-weight: 500;\n`);\n\nconst cssIconAndText = styled(\"div\", `\n  display: flex;\n  gap: 16px;\n`);\n\nconst cssBannerMessage = styled(cssIconAndText, `\n  flex-grow: 1;\n  justify-content: center;\n`);\n\nconst cssIcon = styled(icon, `\n  flex-shrink: 0;\n  width: 16px;\n  height: 16px;\n  background-color: white;\n`);\n"
  },
  {
    "path": "app/client/components/BaseView.ts",
    "content": "import { getDefaultColValues } from \"app/client/components/BaseView2\";\nimport { CutCallback } from \"app/client/components/Clipboard\";\nimport * as commands from \"app/client/components/commands\";\nimport { CopySelection } from \"app/client/components/CopySelection\";\nimport { Cursor } from \"app/client/components/Cursor\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { buildConfirmDelete, reportUndo } from \"app/client/components/modals\";\nimport { viewCommands } from \"app/client/components/RegionFocusSwitcher\";\nimport { SelectionSummary } from \"app/client/components/SelectionSummary\";\nimport { KoArray } from \"app/client/lib/koArray\";\nimport * as tableUtil from \"app/client/lib/tableUtil\";\nimport BaseRowModel from \"app/client/models/BaseRowModel\";\nimport { ClientColumnGetters } from \"app/client/models/ClientColumnGetters\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport DataTableModel from \"app/client/models/DataTableModel\";\nimport { ExtraRows } from \"app/client/models/DataTableModelWithDiff\";\nimport { ColumnRec } from \"app/client/models/entities/ColumnRec\";\nimport { TableRec } from \"app/client/models/entities/TableRec\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { FilterInfo, ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport { MutedError } from \"app/client/models/errors\";\nimport { reportError } from \"app/client/models/errors\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { DynamicQuerySet } from \"app/client/models/QuerySet\";\nimport * as rowset from \"app/client/models/rowset\";\nimport { RowSource, SortedRowSet } from \"app/client/models/rowset\";\nimport { SectionFilter } from \"app/client/models/SectionFilter\";\nimport { UnionRowSource } from \"app/client/models/UnionRowSource\";\nimport { markAsSeen } from \"app/client/models/UserPrefs\";\nimport { buildReassignModal } from \"app/client/ui/buildReassignModal\";\nimport { createFilterMenu, IColumnFilterMenuOptions } from \"app/client/ui/ColumnFilterMenu\";\nimport { closeRegisteredMenu } from \"app/client/ui2018/menus\";\nimport { BuildEditorOptions, createAllFieldWidgets, FieldBuilder } from \"app/client/widgets/FieldBuilder\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { BulkColValues, CellValue, DocAction, UserAction } from \"app/common/DocActions\";\nimport { DocStateComparison } from \"app/common/DocState\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { IGristUrlState } from \"app/common/gristUrls\";\nimport { arrayRepeat, nativeCompare, roundDownToMultiple, waitObs } from \"app/common/gutil\";\nimport { DismissedPopup } from \"app/common/Prefs\";\nimport { SortFunc } from \"app/common/SortFunc\";\nimport { Sort } from \"app/common/SortSpec\";\nimport { CursorPos, UIRowId } from \"app/plugin/GristAPI\";\n\nimport { DomArg } from \"grainjs\";\nimport ko from \"knockout\";\nimport mapValues from \"lodash/mapValues\";\nimport moment from \"moment-timezone\";\nimport { IOpenController } from \"popweasel\";\n\nimport type { LazyArrayModel } from \"app/client/models/DataTableModel\";\nimport type { CommentWithMentions } from \"app/client/widgets/MentionTextBox\";\n\n// Disable member-ordering linting temporarily, so that it's easier to review the conversion to\n// typescript. It would be reasonable to reorder methods and re-enable this lint check.\n/* eslint-disable @typescript-eslint/member-ordering */\n\nexport interface ViewOptions {\n  isPreview?: boolean;\n  addNewRow?: boolean;\n  /**\n   * Whether this view supports cursor navigation. Defaults to false. Set to true for custom\n   * widgets that manage their own cursor. When false, Cursor.ts will skip listening to\n   * keyboard events when this view is active.\n   */\n  disabledCursor?: boolean;\n}\n\n/**\n * BaseView forms the basis for ViewSection classes.\n * @param {Object} viewSectionModel - The model for the viewSection represented.\n * @param {Boolean} options.isPreview - Whether the view is a read-only preview (e.g. Importer view).\n * @param {Boolean} options.addNewRow - Whether to include an add row in the model.\n */\nexport default class BaseView extends DisposableWithEvents {\n  public viewPane: HTMLElement;\n  public viewData: LazyArrayModel<DataRowModel>;\n  public cursor: Cursor;\n  public sortedRows: SortedRowSet;\n  public rowSource: RowSource;\n  public activeFieldBuilder: ko.Computed<FieldBuilder>;\n  public selectedColumns: ko.Computed<ViewFieldRec[]> | null;\n  public disableEditing: ko.Computed<boolean>;\n  public isTruncated: ko.Observable<boolean>;\n  public tableModel: DataTableModel;\n  public selectionSummary?: SelectionSummary;\n  public currentEditingColumnIndex: ko.Observable<number>;\n  public enableAddRow: ko.Computed<boolean>;\n  public options: ViewOptions;\n\n  public onNewRecordRequest?(): Promise<number> | void;\n\n  protected _name: string;\n  protected schemaModel: TableRec;\n  protected comparison: DocStateComparison | null;\n  protected extraRows: ExtraRows;\n  protected editRowModel: DataRowModel;\n  protected linkedRowId: ko.Computed<UIRowId | null>;\n  protected isLinkSource: ko.Computed<boolean>;\n  protected isPreview: boolean;\n  protected currentColumn: ko.Computed<ColumnRec>;\n  protected fieldBuilders: KoArray<FieldBuilder>;\n  protected copySelection: ko.Observable<CopySelection | null>;\n\n  private _queryRowSource: DynamicQuerySet;\n  private _mainRowSource: RowSource;\n  private _exemptFromFilterRows: rowset.ExemptFromFilterRowSource;\n  private _sectionFilter: SectionFilter;\n  private _filteredRowSource: rowset.FilteredRowSource;\n  private _newRowSource: rowset.RowSource;\n  private _isLoading: ko.Observable<boolean>;\n  private _pendingCursorPos: { cursorPos: CursorPos, showFirstRowIfRowMissing: boolean } | null;\n  protected _isPrinting: ko.Observable<boolean>;\n\n  constructor(\n    public gristDoc: GristDoc,\n    public viewSection: ViewSectionRec,\n    options?: ViewOptions,\n  ) {\n    super();\n    this.options = options || {};\n    this._name = this.viewSection.titleDef.peek();\n\n    // --------------------------------------------------\n    // Observable models mapped to the document\n\n    // Instantiate the models for the view metadata and for the data itself.\n    // The table should never change for a given view, so no need to watch the table() observable.\n    this.schemaModel = this.viewSection.table();\n\n    // Check if we are making a comparison with another document.\n    this.comparison = this.gristDoc.comparison;\n\n    // TODO: but accessing by tableId identifier may be problematic when the table is renamed.\n    this.tableModel = this.gristDoc.getTableModelMaybeWithDiff(this.schemaModel.tableId());\n    this.extraRows = this.tableModel.getExtraRows?.() ?? new ExtraRows();\n\n    // We use a DynamicQuerySet as the underlying RowSource, with ColumnFilters applies on top of\n    // it. It filters based on section linking, re-querying as needed in case of onDemand tables.\n    this._queryRowSource = DynamicQuerySet.create(this, gristDoc.querySetManager, this.tableModel);\n    this._mainRowSource = this._queryRowSource;\n\n    if (this.comparison) {\n      // Assign extra row ids for any rows added in the remote (right) table or removed in the\n      // local (left) table.\n      const extraRowIds = this.extraRows.getExtraRows();\n      this._mainRowSource = rowset.ExtendedRowSource.create(this, this._mainRowSource, extraRowIds);\n    }\n\n    // Rows that should temporarily be visible even if they don't match filters.\n    // This is so that a newly added row doesn't immediately disappear, which would be confusing.\n    this._exemptFromFilterRows = rowset.ExemptFromFilterRowSource.create(this);\n    this._exemptFromFilterRows.subscribeTo(this.tableModel);\n\n    // Create a section filter and a filtered row source that subscribes to its changes.\n    // `sectionFilter` also provides `setFilterOverride()` to allow controlling a filter from a column menu.\n    this._sectionFilter = SectionFilter.create(this, this.viewSection, this.tableModel.tableData);\n    this._filteredRowSource = rowset.FilteredRowSource.create(this, this._sectionFilter.sectionFilterFunc.get());\n    this._filteredRowSource.subscribeTo(this._mainRowSource);\n    this.autoDispose(this._sectionFilter.sectionFilterFunc.addListener((filterFunc) => {\n      this._exemptFromFilterRows.reset();\n      this._filteredRowSource.updateFilter(filterFunc);\n    }));\n\n    this.rowSource = UnionRowSource.create(this, [this._filteredRowSource, this._exemptFromFilterRows]);\n\n    // Sorted collection of all rows to show in this view.\n    this.sortedRows = rowset.SortedRowSet.create(this, null as any, this.tableModel.tableData);\n\n    // Create the sortFunc, and re-sort when sortSpec changes.\n    const sortFunc = new SortFunc(new ClientColumnGetters(this.tableModel, { unversioned: true }));\n    const updateSort = (spec: Sort.SortSpec) => {\n      sortFunc.updateSpec(spec);\n      this.sortedRows.updateSort((rowId1, rowId2) => {\n        const value = nativeCompare(rowId1 === \"new\", rowId2 === \"new\");\n        return value || sortFunc.compare(rowId1 as number, rowId2 as number);\n      });\n    };\n    this.autoDispose(this.viewSection.activeDisplaySortSpec.subscribe(updateSort));\n    updateSort(this.viewSection.activeDisplaySortSpec.peek());\n\n    // Here we are subscribed to the bulk of the data (main table, possibly filtered).\n    this.sortedRows.subscribeTo(this.rowSource);\n\n    // We create a special one-row RowSource for the \"Add new\" row, in case we need it.\n    this._newRowSource = (class extends rowset.RowSource {\n      public getAllRows(): rowset.RowList { return [\"new\"]; }\n      public getNumRows(): number { return 1; }\n    }).create(this);\n\n    // This is the LazyArrayModel containing DataRowModels, for rendering, e.g. with scrolly.\n    this.viewData = this.autoDispose(this.tableModel.createLazyRowsModel(this.sortedRows));\n\n    // Floating row model that is not destroyed when the row is scrolled out of view. It must be\n    // assigned manually to a rowId. Additionally, we override the saving of field values with a\n    // custom method that handles better positioning of cursor on adding a new row.\n    this.editRowModel = this.autoDispose(this.tableModel.createFloatingRowModel());\n    (this.editRowModel as any)._saveField =\n      (colName: string, value: CellValue) => this._saveEditRowField(this.editRowModel, colName, value);\n\n    // Reset heights of rows when there is an action that affects them.\n    this.listenTo(this.viewData, \"rowModelNotify\", this.onRowResize);\n\n    this.listenTo(this.viewSection.events, \"rowHeightChange\", this.onResize);\n\n    // Create a command group for keyboard shortcuts common to all views.\n    this.autoDispose(commands.createGroup(\n      viewCommands(BaseView._commonCommands, this), this, this.viewSection.hasFocus));\n    this.autoDispose(commands.createGroup(\n      BaseView._commonFocusedCommands, this, this.viewSection.hasRegionFocus));\n\n    // --------------------------------------------------\n    // Prepare logic for linking with other sections.\n\n    // A computed for the rowId of the row selected by section linking.\n    this.linkedRowId = this.autoDispose(ko.computed(() => {\n      const linking = this.viewSection.linkingState();\n      return linking?.cursorPos ? linking.cursorPos() : null;\n    }).extend({ deferred: true }));\n\n    // Update the cursor whenever linkedRowId() changes (but only if we have any linking).\n    this.autoDispose(this.linkedRowId.subscribe((rowId) => {\n      if (this.viewSection.linkingState.peek()) {\n        this.setCursorPos({ rowId: rowId || \"new\" }, true);\n      }\n    }));\n\n    this.isLinkSource = this.autoDispose(ko.pureComputed(() => this.viewSection.linkedSections().all().length > 0));\n\n    // Indicated whether editing the section should be disabled given the current linking state.\n    this.disableEditing = this.autoDispose(ko.computed(() => {\n      const linking = this.viewSection.linkingState();\n      return linking ? linking.disableEditing() : false;\n    }));\n\n    this.isPreview = this.options.isPreview ?? false;\n\n    this.enableAddRow = this.autoDispose(ko.computed(() => Boolean(this.options.addNewRow) &&\n      !this.viewSection.disableAddRemoveRows() && !this.disableEditing()));\n\n    // Hide the add row if editing is disabled via filter linking.\n    const updateEnableAddRow = (_enableAddRow: boolean) => {\n      if (_enableAddRow) {\n        this.sortedRows.subscribeTo(this._newRowSource);\n      } else {\n        this.sortedRows.unsubscribeFrom(this._newRowSource);\n      }\n    };\n    this.autoDispose(this.enableAddRow.subscribe(updateEnableAddRow));\n    updateEnableAddRow(this.enableAddRow.peek());\n\n    // --------------------------------------------------\n    // Observables local to this view\n    this._isLoading = ko.observable(true);\n    this._pendingCursorPos = {\n      cursorPos: this.viewSection.lastCursorPos,\n      showFirstRowIfRowMissing: true,\n    };\n\n    // Initialize the cursor with the previous cursor position indices, if they exist.\n    console.log(\"BaseView viewSection %s (%s) lastCursorPos %s\", this.viewSection.getRowId(),\n      this.viewSection.table().tableId(), JSON.stringify(this.viewSection.lastCursorPos));\n    this.cursor = this.autoDispose(Cursor.create(null, this, this.viewSection.lastCursorPos));\n\n    this.currentColumn = this.autoDispose(ko.pureComputed(() =>\n      this.viewSection.viewFields().at(this.cursor.fieldIndex())!.column(),\n    ).extend({ rateLimit: 0 }));     // TODO Test this without the rateLimit\n\n    this.currentEditingColumnIndex = ko.observable(-1);\n\n    // A koArray of FieldBuilder objects, one for each view-section field.\n    this.fieldBuilders = this.autoDispose(\n      createAllFieldWidgets(this.gristDoc, this.viewSection.viewFields, this.cursor, {\n        isPreview: this.isPreview,\n      }),\n    );\n\n    // An observable evaluating to the FieldBuilder for the field where the cursor is.\n    this.activeFieldBuilder = this.autoDispose(ko.pureComputed(() =>\n      this.fieldBuilders.at(this.cursor.fieldIndex())!,\n    ));\n\n    // By default, a view doesn't support selectedColumns, but it can be overridden.\n    this.selectedColumns = null;\n\n    // Observable for whether the data in this view is truncated, i.e. not all rows are included\n    // (this can only be true for on-demand tables).\n    this.isTruncated = ko.observable(false);\n\n    // This computed's purpose is the side-effect of calling makeQuery() initially and when any\n    // dependency changes.\n    this.autoDispose(ko.computed(() => {\n      this._isLoading(true);\n      const linkingFilter = this.viewSection.linkingFilter();\n      this._queryRowSource.makeQuery(linkingFilter.filters, linkingFilter.operations, (err) => {\n        if (this.isDisposed()) { return; }\n        if (err) { reportError(err); }\n        this._exemptFromFilterRows.reset();\n        this.onTableLoaded();\n      });\n    }));\n\n    // Reset cursor to the first row when filtering changes.\n    this.autoDispose(this.viewSection.linkingFilter.subscribe(x => this.onLinkFilterChange()));\n\n    // When sorting changes, reset the cursor to the first row. (The alternative of moving the\n    // cursor to stay at the same record is sometimes better, but sometimes more annoying.)\n    this.autoDispose(this.viewSection.activeSortSpec.subscribe(() => this.setCursorPos({ rowIndex: 0 })));\n\n    this.copySelection = ko.observable<CopySelection | null>(null);\n\n    // Whether parts needed for printing should be rendered now.\n    this._isPrinting = ko.observable(false);\n  }\n\n  /**\n   * These commands are common to GridView and DetailView.\n   *\n   * They work when the view is the currently active one, but not necessarily user-focused.\n   *\n   * That means the user can be focusing a button in the creator panel and run these commands:\n   * they will apply to the active view.\n   * When a command from here is executed, keyboard focus is set back to the view.\n   *\n   * There is no strict rule for which command goes here and which goes in the commonFocusedCommands list.\n   * The goal of the distinction is to:\n   *   1) allow users to run most commands easily, without having to think about actually focusing an active view,\n   *   2) make sure command keyboard shortcuts don't interfere with user keyboard navigation when the user is\n   *      focused on something else.\n   * The main thing to watch out for is the 2) point. When adding a command, ask yourself if \"blocking\" the kb shortcut\n   * when not focusing the view is risky: is the shortcut so generic that it's likely to be used outside of the view,\n   * for example for navigation? If so, the command should go in the \"focused\" list.\n   * Most commands triggered by arrow keys, Tab, Enter, pagination keys, should usually go in the focused list.\n   * Most commands with relatively hard or specific triggers should usually go in the normal list.\n   */\n  private static _commonCommands: { [key: string]: Function } & ThisType<BaseView> = {\n    input: function(init?: string, event?: Event) {\n      this.scrollToCursor(true).catch(reportError);\n      this.activateEditorAtCursor({ init, event });\n    },\n    copyLink: function() { this.copyLink().catch(reportError); },\n    filterByThisCellValue: function() { this.filterByThisCellValue(); },\n    duplicateRows: function() { this._duplicateRows().catch(reportError); },\n    openDiscussion: function(ev: unknown, payload: CommentWithMentions | null) {\n      const state = typeof payload === \"object\" && payload ? payload : null;\n      this._openDiscussionAtCursor(state);\n    },\n    insertRecordBefore: function() { this.insertRow(this.cursor.rowIndex()!)?.catch(reportError); },\n    insertRecordAfter: function() { this.insertRow(this.cursor.rowIndex()! + 1)?.catch(reportError); },\n  };\n\n  /**\n   * These commands are common to GridView and DetailView.\n   *\n   * They are enabled only when the user is actually focusing the view, meaning\n   * they don't work when the view is the active one but the user is focused on something else, like the creator panel.\n   */\n  private static _commonFocusedCommands: { [key: string]: Function } & ThisType<BaseView> = {\n    editField: function(this: BaseView, event?: KeyboardEvent) {\n      closeRegisteredMenu();\n      this.scrollToCursor(true).catch(reportError);\n      this.activateEditorAtCursor({ event });\n    },\n\n    insertCurrentDate: function() { this.insertCurrentDate(false)?.catch(reportError); },\n    insertCurrentDateTime: function() { this.insertCurrentDate(true)?.catch(reportError); },\n\n    deleteRecords: function(source?: KeyboardEvent) { this.deleteRecords(source)?.catch(reportError); },\n\n    viewAsCard: function() {\n      /* Overridden by subclasses.\n       *\n       * This is still needed so that <space> doesn't trigger the `input` command\n       * if a subclass doesn't support opening the current record as a card. */\n    },\n  };\n\n  /**\n   * Returns a selection of the selected rows and cols.  By default this will just\n   * be one row and one column as multiple cell selection is not supported.\n   * GridView overrides to support multiple cell selection.\n   */\n  protected getSelection(): CopySelection {\n    return new CopySelection(\n      this.tableModel.tableData,\n      [this.viewData.getRowId(this.cursor.rowIndex()!)],\n      [this.viewSection.viewFields().at(this.cursor.fieldIndex())!],\n      {},\n    );\n  }\n\n  protected selectedRows(): number[] {\n    return [];\n  }\n\n  protected deleteRows(rowIds: number[]) {\n    return this.tableModel.sendTableAction([\"BulkRemoveRecord\", rowIds]);\n  }\n\n  // Commands run via a Mousetrap callback get a KeyboardEvent is the first argument. This is\n  // obscure and essentially undocumented.\n  protected deleteRecords(source: unknown) {\n    if (this.gristDoc.isReadonly.get()) {\n      return;\n    }\n\n    const rowIds = this.selectedRows();\n    if (this.viewSection.disableAddRemoveRows() || rowIds.length === 0) {\n      return;\n    }\n    const isKeyboard = source instanceof KeyboardEvent;\n    const popups = this.gristDoc.docPageModel.appModel.dismissedPopups;\n    const popupName = DismissedPopup.check(\"deleteRecords\");\n    const onSave = async (remember?: boolean) => {\n      if (remember) {\n        markAsSeen(popups, popupName);\n      }\n      return this.deleteRows(rowIds);\n    };\n    if (isKeyboard && !popups.get().includes(popupName)) {\n      // If we can't find it, use viewPane itself\n      this.scrollToCursor().catch(reportError);\n      const selectedCell = this.viewPane.querySelector(\".selected_cursor\") || this.viewPane;\n      buildConfirmDelete(selectedCell, onSave, rowIds.length <= 1);\n    } else {\n      return onSave().then(() => {\n        if (!this.isDisposed()) {\n          reportUndo(this.gristDoc, `You deleted ${rowIds.length} row${rowIds.length > 1 ? \"s\" : \"\"}.`);\n        }\n        return true;\n      });\n    }\n  }\n\n  /**\n   * Sets the cursor to the given position, deferring if necessary until the current query finishes\n   * loading.\n   *\n   * @param cursorPos - Cursor position to set to\n   * @param isFromLink - Set when called as a result of cursor linking (see Cursor.setCursorPos for info)\n   * @param showFirstRowIfRowMissing - Shows the first available row if cursor points to a missing row\n   */\n  public setCursorPos(\n    cursorPos: CursorPos, isFromLink = false, showFirstRowIfRowMissing = false,\n  ): void {\n    if (this.isDisposed()) {\n      return;\n    }\n    if (!this._isLoading.peek()) {\n      this._setCursorPosImmediately(cursorPos, isFromLink, showFirstRowIfRowMissing);\n    } else {\n      // This is the first step; the second happens in onTableLoaded.\n      this._pendingCursorPos = { cursorPos, showFirstRowIfRowMissing };\n      this.cursor.setLive(false);\n    }\n  }\n\n  /**\n   * Returns a promise that's resolved when the query being loaded finishes loading.\n   * If no query is being loaded, it will resolve immediately.\n   */\n  public async getLoadingDonePromise(): Promise<void> {\n    await waitObs(this._isLoading, value => !value);\n  }\n\n  /**\n   * Start editing the selected cell.\n   * @param {String} input: If given, initialize the editor with the given input (rather than the\n   *    original content of the cell).\n   */\n  public activateEditorAtCursor(options: BuildEditorOptions = {}): void {\n    const builder = this.activeFieldBuilder();\n    if (builder.isEditorActive()) {\n      return;\n    }\n    const rowId = this.viewData.getRowId(this.cursor.rowIndex()!);\n    // LazyArrayModel row model which is also used to build the cell dom. Needed since\n    // it may be used as a key to retrieve the cell dom, which is useful for editor placement.\n    const lazyRow = this.getRenderedRowModel(rowId);\n    if (!lazyRow) {\n      // TODO scroll into view. For now, just don't activate the editor.\n      return;\n    }\n    this.editRowModel.assign(rowId);\n    builder.buildEditorDom(this.editRowModel, lazyRow, options || {});\n  }\n\n  /**\n   * Opens discussion panel at the cursor position. Returns true if discussion panel was opened.\n   */\n  private _openDiscussionAtCursor(text: CommentWithMentions | null) {\n    const builder = this.activeFieldBuilder();\n    if (builder.isEditorActive()) {\n      return false;\n    }\n    const rowId = this.viewData.getRowId(this.cursor.rowIndex()!);\n    // LazyArrayModel row model which is also used to build the cell dom. Needed since\n    // it may be used as a key to retrieve the cell dom, which is useful for editor placement.\n    const lazyRow = this.getRenderedRowModel(rowId);\n    if (!lazyRow) {\n      // TODO scroll into view. For now, just don't start discussion.\n      return false;\n    }\n    this.editRowModel.assign(rowId);\n    builder.buildDiscussionPopup(this.editRowModel, lazyRow, text);\n    return true;\n  }\n\n  /**\n   * Move the floating RowModel for editing to the current cursor position, and return it.\n   *\n   * This is used for opening the formula editor in the side panel; the current row is used to get\n   * possible exception info from the formula.\n   */\n  public moveEditRowToCursor(): DataRowModel {\n    const rowId = this.viewData.getRowId(this.cursor.rowIndex()!);\n    this.editRowModel.assign(rowId);\n    return this.editRowModel;\n  }\n\n  // Get an anchor link for the current cell and a given view section to the clipboard.\n  public getAnchorLinkForSection(sectionId: number): IGristUrlState {\n    const rowId = this.viewData.getRowId(this.cursor.rowIndex()!) ||\n    // If there are no visible rows (happens in some widget linking situations),\n    // pick an arbitrary row which will hopefully be close to the top of the table.\n      this.tableModel.tableData.findMatchingRowId({}) ||\n    // If there are no rows at all, return the 'new record' row ID.\n    // Note that this case only happens in combination with the widget linking mentioned.\n    // If the table is empty but the 'new record' row is selected, the `viewData.getRowId` line above works.\n      \"new\";\n    // The `fieldIndex` will be null if there are no visible columns.\n    const fieldIndex = this.cursor.fieldIndex.peek();\n    const field = fieldIndex !== null ? this.viewSection.viewFields().peek()[fieldIndex] : null;\n    const colRef = field?.colRef.peek();\n    const linkingRowIds = sectionId ? this.gristDoc.docModel.getLinkingRowIds(sectionId) : undefined;\n    return { hash: { sectionId, rowId, colRef, linkingRowIds } };\n  }\n\n  // Copy an anchor link for the current row to the clipboard.\n  protected async copyLink() {\n    const sectionId = this.viewSection.getRowId();\n    const anchorUrlState = this.getAnchorLinkForSection(sectionId);\n    return this.gristDoc.copyAnchorLink(anchorUrlState.hash!);\n  }\n\n  protected filterByThisCellValue() {\n    const rowId = this.viewData.getRowId(this.cursor.rowIndex()!);\n    const col = this.viewSection.viewFields().peek()[this.cursor.fieldIndex()].column();\n    let value = this.tableModel.tableData.getValue(rowId, col.colId.peek())!;\n\n    // This mimics the logic in ColumnFilterMenu.addCountsToMap\n    // ChoiceList and Reflist values get 'flattened' out so we filter by each element within.\n    // In any other column type, complex values (even lists) get converted to JSON.\n    let filterValues;\n    const colType = col.type.peek();\n    if (gristTypes.isList(value) && gristTypes.isListType(colType)) {\n      filterValues = value.slice(1);\n      if (!filterValues.length) {\n        // If the list is empty, filter instead by an empty value for the whole list\n        filterValues = [colType === \"ChoiceList\" ? \"\" : null];\n      }\n    } else {\n      if (Array.isArray(value)) {\n        value = JSON.stringify(value);\n      }\n      filterValues = [value];\n    }\n    this.viewSection.setFilter(col.getRowId(), { filter: JSON.stringify({ included: filterValues }) });\n  }\n\n  /**\n   * Insert a new row immediately before the row at the given index if given an Integer. Otherwise\n   * insert a new row at the end.\n   */\n  public insertRow(index?: number): Promise<number> | undefined {\n    if (this.gristDoc.isReadonly.get()) {\n      return;\n    }\n\n    if (this.viewSection.disableAddRemoveRows() || this.disableEditing()) {\n      return;\n    }\n    const rowId = index != null ? this.viewData.getRowId(index) : undefined;\n    const insertPos = Number.isInteger(rowId) ?\n      this.tableModel.tableData.getValue(rowId, \"manualSort\") : null;\n\n    return this.sendTableAction([\"AddRecord\", null, { manualSort: insertPos }])!\n      .then((rowId) => {\n        if (!this.isDisposed()) {\n          this._exemptFromFilterRows.addExemptRow(rowId);\n          this.setCursorPos({ rowId });\n        }\n        return rowId;\n      });\n  }\n\n  private _setCursorPosImmediately(cursorPos: CursorPos, isFromLink: boolean, showFirstRowIfRowMissing: boolean): void {\n    const fallbackCursorPos = showFirstRowIfRowMissing ? { ...cursorPos, rowId: undefined, rowIndex: 0 } : undefined;\n    this.cursor.setCursorPos(cursorPos, isFromLink, fallbackCursorPos);\n  }\n\n  private _getDefaultColValues() {\n    return getDefaultColValues(this.viewSection);\n  }\n\n  /**\n   * Enhances [Bulk]AddRecord actions to include the default values determined by the current\n   * section-linking filter.\n   */\n  private _enhanceAction(action: UserAction) {\n    if (action[0] === \"AddRecord\" || action[0] === \"BulkAddRecord\") {\n      let colValues = this._getDefaultColValues();\n      const rowIds = action[1] as number[];\n      if (action[0] === \"BulkAddRecord\") {\n        colValues = mapValues(colValues, v => rowIds.map(() => v));\n      }\n      Object.assign(colValues, action[2]);\n      return [action[0], rowIds, colValues];\n    } else {\n      return action;\n    }\n  }\n\n  /**\n   * Enhances a list of table actions and turns them from implicit-table actions into\n   * proper actions.\n   */\n  protected prepTableActions(actions: UserAction[]) {\n    actions = actions.map(a => this._enhanceAction(a));\n    actions.forEach((action_) => {\n      action_.splice(1, 0, this.tableModel.tableData.tableId);\n    });\n    return actions;\n  }\n\n  /**\n   * Shortcut for `.tableModel.tableData.sendTableActions`, which also sets default values\n   * determined by the current section-linking filter, if any.\n   */\n  protected sendTableActions(actions: UserAction[], optDesc?: string) {\n    return this.tableModel.sendTableActions(actions.map(a => this._enhanceAction(a)), optDesc);\n  }\n\n  /**\n   * Shortcut for `.tableModel.tableData.sendTableAction`, which also sets default values\n   * determined by the current section-linking filter, if any.\n   */\n  protected sendTableAction(action: UserAction, optDesc?: string) {\n    return action ? this.tableModel.sendTableAction(this._enhanceAction(action), optDesc) : null;\n  }\n\n  /**\n   * Inserts the current date/time into the selected cell if the cell is of a compatible type\n   * (Text/Date/DateTime/Any).\n   * @param {Boolean} withTime: Whether to include the time in addition to the date. This is ignored\n   *    for Date columns (assumed false) and for DateTime (assumed true).\n   */\n  protected insertCurrentDate(withTime: boolean) {\n    if (this.gristDoc.isReadonly.get()) {\n      return;\n    }\n\n    const column = this.currentColumn();\n    if (column.isRealFormula()) {\n      // Ignore the shortcut when in a formula column.\n      return;\n    }\n    const type = column.pureType();\n    let value;\n    const now = Date.now();\n    const docTimezone = this.gristDoc.docInfo.timezone.peek();\n    if (type === \"Text\" || type === \"Any\") {\n      // Use document timezone. Don't forget to use uppercase HH for 24-hour time.\n      value = moment.tz(now, docTimezone).format(\"YYYY-MM-DD\" + (withTime ? \" HH:mm:ss\" : \"\"));\n    } else if (type === \"Date\") {\n      // Get UTC midnight for the current date (as seen in docTimezone). This is a bit confusing. If\n      // it's \"2019-11-14 23:30 -05:00\", then it's \"2019-11-15 04:30\" in UTC. Since we measure time\n      // from Epoch UTC, we want the UTC time to have the correct date, so need to add the offset\n      // (-05:00) to get \"2019-11-14 23:30\" in UTC, and then round down to midnight.\n      const offsetMinutes = moment.tz(now, docTimezone).utcOffset();\n      value = roundDownToMultiple(now / 1000 + offsetMinutes * 60, 24 * 3600);\n    } else if (type === \"DateTime\") {\n      value = now / 1000;\n    } else {\n      // Ignore the shortcut when in a column of an inappropriate type.\n      return;\n    }\n    const rowId = this.viewData.getRowId(this.cursor.rowIndex()!);\n    this.editRowModel.assign(rowId);\n    return this.editRowModel.cells[column.colId()].setAndSave(value);\n  }\n\n  /**\n   * Override the saving of field values to add some extra processing:\n   * - If a new row is saved, then we may need to adjust the row where the cursor is.\n   * - We add the edited or added row to ensure it's displayed regardless of current columnFilters.\n   * - We change the main view's row observables to see the new value immediately.\n   * TODO: When saving a formula in the addRow, the cursor moves down instead of staying in place.\n   *       To fix that behavior, propose to factor out the `isAddRow` overrides from here\n   *       into a `setNewRowColValues` on the editRowModel and have `FieldBuilder._saveEdit` call\n   *       that instead of `updateColValues`.\n   */\n  private _saveEditRowField(editRowModel: DataRowModel, colName: string, value: CellValue) {\n    if (editRowModel._isAddRow.peek()) {\n      this.cursor.setLive(false);\n      const colValues = this._getDefaultColValues();\n      colValues[colName] = value;\n\n      return editRowModel.updateColValues(colValues)\n      // Once we know the new row's rowId, add it to column filters to make sure it's displayed.\n        .then((rowId) => {\n          if (!this.isDisposed()) {\n            this._exemptFromFilterRows.addExemptRow(rowId);\n            this.setCursorPos({ rowId });\n          }\n          return rowId;\n        })\n        .finally(() => !this.isDisposed() && this.cursor.setLive(true));\n    } else {\n      const rowId = editRowModel.getRowId();\n      // We are editing the floating \"edit\" rowModel, but to ensure that we see data in the main view\n      // (when the editor closes), we immediately update the main view's rowModel, if such exists.\n      const mainRowModel = this.getRenderedRowModel(rowId);\n      if (mainRowModel) {\n        mainRowModel.cells[colName](value);\n      }\n      const ret = editRowModel.updateColValues({ [colName]: value })\n        // Display this rowId, even if it doesn't match the filter,\n        // unless the filter is on a Bool column\n        .then((result) => {\n          if (!this.isDisposed() && this.currentColumn().pureType() !== \"Bool\") {\n            this._exemptFromFilterRows.addExemptRow(rowId);\n          }\n          return result;\n        })\n        .finally(() => !this.isDisposed() && mainRowModel && (mainRowModel as any)._assignColumn(colName));\n      return this.viewSection.isSorted() ? ret : null;\n      // Do not return the saveField call in the case that the column is unsorted: in this case,\n      // we assumes optimistically that the action is successful and browser events can\n      // continue being processed immediately without waiting.\n      // When sorted, we wait on the saveField call so we may determine where the row ends\n      // up for cursor movement purposes.\n    }\n  }\n\n  /**\n   * Uses the current cursor selection to return a rich paste object with a reference to the data,\n   * and the selection ranges.  See CopySelection.js\n   *\n   * @returns {pasteObj} - Paste object\n   */\n  protected copy(selection: CopySelection) {\n    // Clear the previous copy selection, if any.\n    commands.allCommands.clearCopySelection.run();\n\n    this.copySelection(selection);\n\n    return {\n      data: this.tableModel.tableData,\n      selection: selection,\n    };\n  }\n\n  /**\n   * Uses the current cursor selection to return a rich paste object with a reference to the data,\n   * the selection ranges and a callback that when called performs all of the actions needed for a cut.\n   *\n   * @returns {pasteObj} - Paste object\n   */\n  protected cut(selection: CopySelection) {\n    // Clear the previous copy selection, if any.\n    commands.allCommands.clearCopySelection.run();\n\n    this.copySelection(selection);\n\n    return {\n      data: this.tableModel.tableData,\n      selection: selection,\n      cutCallback: () => tableUtil.makeDeleteAction(selection),\n    };\n  }\n\n  /**\n   * Helper to send paste actions from the cutCallback and a list of paste actions.\n   */\n  protected sendPasteActions(cutCallback: CutCallback | null, actions: UserAction[]) {\n    let cutAction = null;\n    // If this is a cut -> paste, add the cut action and a description.\n    if (cutCallback) {\n      cutAction = cutCallback();\n      // If the cut occurs on an edit restricted cell, there may be no cut action.\n      if (cutAction) { actions.unshift(cutAction); }\n    }\n    return this.gristDoc.docData.sendActions(actions).catch((ex) => {\n      if (ex.code === \"UNIQUE_REFERENCE_VIOLATION\") {\n        buildReassignModal({\n          docModel: this.gristDoc.docModel,\n          actions: actions as DocAction[],\n        }).catch(reportError);\n        throw new MutedError();\n      } else {\n        throw ex;\n      }\n    });\n  }\n\n  protected buildDom() {\n    throw new Error(\"Not Implemented\");\n  }\n\n  /**\n   * Called by ViewLayout to return view-specific controls to add into its ViewSection's title bar.\n   * By default builds nothing. Derived views may override.\n   */\n  public buildTitleControls(): DomArg {\n    return null;\n  }\n\n  /**\n   * Called when table data gets loaded (if already loaded, then called immediately after the\n   * constructor). Derived views may override.\n   */\n  protected onTableLoaded() {\n    // Complete the setting of a pending cursor position (see setCursorPos() for the first half).\n    if (this._pendingCursorPos) {\n      this._setCursorPosImmediately(\n        this._pendingCursorPos.cursorPos, false, this._pendingCursorPos.showFirstRowIfRowMissing,\n      );\n      this._pendingCursorPos = null;\n    }\n    this._isLoading(false);\n    this.isTruncated(this._queryRowSource.isTruncated);\n    this.cursor.setLive(true);\n  }\n\n  /**\n   * Called when view gets resized. Derived views may override.\n   */\n  public onResize(): void {\n  }\n\n  /**\n   * Called when rows have changed and may potentially need resizing. Derived views may override.\n   * @param {Array<DataRowModel>} rowModels: Array of row models whose size may have changed.\n   */\n  public onRowResize(rowModels: BaseRowModel[]): void {\n  }\n\n  /**\n   * Called when user selects a different row which drives the link-filtering of this section.\n   */\n  protected onLinkFilterChange() {\n    // If this section is linked, go to the first row as the row previously selected may no longer\n    // be visible.\n    if (this.viewSection.linkingState.peek()) {\n      this.setCursorPos({ rowIndex: 0 });\n    }\n  }\n\n  /**\n   * Called before and after printing this section.\n   */\n  public prepareToPrint(onOff: boolean): void {\n    this._isPrinting(onOff);\n  }\n\n  /**\n   * Called to obtain the rowModel for the given rowId. Returns a rowModel if it belongs to the\n   * section and is rendered, otherwise returns null.\n   * Useful to tie a rendered row to the row being edited. Derived views may override.\n   */\n  protected getRenderedRowModel(rowId: UIRowId): DataRowModel | undefined {\n    return this.viewData.getRowModel(rowId);\n  }\n\n  /**\n   * Returns the index of the last non-AddNew row in the grid.\n   */\n  protected getLastDataRowIndex() {\n    const last = this.viewData.peekLength - 1;\n    return (last >= 0 && this.viewData.getRowId(last) === \"new\") ? last - 1 : last;\n  }\n\n  /**\n   * Creates and opens ColumnFilterMenu for a given field/column, and returns its PopupControl.\n   */\n  public createFilterMenu(\n    openCtl: IOpenController, filterInfo: FilterInfo, options: IColumnFilterMenuOptions,\n  ): HTMLElement {\n    const { showAllFiltersButton, onClose } = options;\n    return createFilterMenu({\n      openCtl,\n      sectionFilter: this._sectionFilter,\n      filterInfo,\n      rowSource: this._mainRowSource,\n      tableData: this.tableModel.tableData,\n      gristDoc: this.gristDoc,\n      showAllFiltersButton,\n      onClose,\n    });\n  }\n\n  /**\n   * Whether the rows shown by this view are a proper subset of all rows in the table.\n   */\n  protected isFiltered() {\n    return this._filteredRowSource.getNumRows() < this.tableModel.tableData.numRecords();\n  }\n\n  /**\n   * Makes sure that active record is in the view.\n   * @param {Boolean} sync If the scroll should be performed synchronously. For typing we should\n   * scroll synchronously, for other cases asynchronously as there might be some other operations\n   * pending (see doScrollChildIntoView in koDom).\n   */\n  public async scrollToCursor(sync?: boolean): Promise<void> {\n    // to override\n  }\n\n  /**\n   * Return a list of manual sort positions so that inserting {numInsert} rows\n   * with the returned positions will place them in between index-1 and index.\n   * when the GridView is sorted by MANUALSORT\n   **/\n  protected _getRowInsertPos(index: number, numInserts: number) {\n    const rowId = this.viewData.getRowId(index);\n    const insertPos = this.tableModel.tableData.getValue(rowId, gristTypes.MANUALSORT);\n    return Array(numInserts).fill(insertPos);\n  }\n\n  /**\n   * Duplicates selected row(s) and returns inserted rowIds\n   */\n  protected async _duplicateRows(): Promise<number[] | undefined> {\n    if (\n      this.gristDoc.isReadonly.get() ||\n      this.viewSection.disableAddRemoveRows() ||\n      this.disableEditing()\n    ) {\n      return;\n    }\n\n    // Get current selection (we need only rowIds).\n    const selection = this.getSelection();\n    const rowIds = selection.rowIds;\n    const length = rowIds.length;\n    // Start assembling action.\n    const action: UserAction = [\"BulkAddRecord\"];\n    // Put nulls as rowIds.\n    action.push(arrayRepeat(length, null));\n    const columns: BulkColValues = {};\n    action.push(columns);\n    // Calculate new positions for rows using helper function. It requires\n    // index where we want to put new rows (it accepts new row index).\n    const lastSelectedIndex = this.viewData.getRowIndex(rowIds[length - 1]);\n    columns.manualSort = this._getRowInsertPos(lastSelectedIndex + 1, length);\n    // Now copy all visible data.\n    for (const col of this.viewSection.columns.peek()) {\n      // But omit all formula columns (and empty ones).\n      const colId = col.colId.peek();\n      if (col.isFormula.peek()) {\n        continue;\n      }\n      columns[colId] = rowIds.map(id => this.tableModel.tableData.getValue(id, colId)!);\n      // If all values in a column are censored, remove this column,\n      if (columns[colId].every(gristTypes.isCensored)) {\n        delete columns[colId];\n      } else {\n        // else remove only censored values\n        columns[colId].forEach((val, i) => {\n          if (gristTypes.isCensored(val)) {\n            columns[colId][i] = null;\n          }\n        });\n      }\n    }\n    const result: number[] = await this.sendTableAction(action, `Duplicated rows ${rowIds}`);\n    return result;\n  }\n\n  public viewSelectedRecordAsCard(): void {\n    if (this.isRecordCardDisabled()) { return; }\n\n    const colRef = this.viewSection.viewFields().at(this.cursor.fieldIndex())!.column().id();\n    const rowId = this.viewData.getRowId(this.cursor.rowIndex()!);\n    const sectionId = this.viewSection.tableRecordCard().id();\n    const anchorUrlState = { hash: { colRef, rowId, sectionId, recordCard: true } };\n    urlState().pushUrl(anchorUrlState, { replace: true }).catch(reportError);\n  }\n\n  public isRecordCardDisabled(): boolean {\n    return this.viewSection.isTableRecordCardDisabled();\n  }\n}\n"
  },
  {
    "path": "app/client/components/BaseView2.ts",
    "content": "/**\n * This file contains logic moved from BaseView.js and ported to TS.\n */\n\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { getDocIdHash, PasteData } from \"app/client/lib/tableUtil\";\nimport { uploadFiles } from \"app/client/lib/uploads\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport { UserAction } from \"app/common/DocActions\";\nimport { isFullReferencingType } from \"app/common/gristTypes\";\nimport { getSetMapValue } from \"app/common/gutil\";\nimport { SchemaTypes } from \"app/common/schema\";\nimport { BulkColValues, CellValue, GristObjCode } from \"app/plugin/GristData\";\n\nimport omit from \"lodash/omit\";\nimport pick from \"lodash/pick\";\n\nfunction isFileList(value: unknown): value is File[] {\n  return Array.isArray(value) && value.every(item => (item instanceof File));\n}\n\n/**\n * Given a 2-d paste column-oriented paste data and target cols, transform the data to omit\n * fields that shouldn't be pasted over and extract rich paste data if available.\n * When pasting into empty columns, also update them with options from the source column.\n * `data` is a column-oriented 2-d array of either\n *    plain strings or rich paste data returned by `tableUtil.parsePasteHtml`.\n * `fields` are the target fields being pasted into.\n */\nexport async function parsePasteForView(\n  data: PasteData, fields: ViewFieldRec[], gristDoc: GristDoc,\n): Promise<BulkColValues> {\n  const result: BulkColValues = {};\n  const actions: UserAction[] = [];\n  const thisDocIdHash = getDocIdHash();\n\n  // If we have pasted-in files, they can go into Attachments-type columns. We collect the tasks\n  // to upload them, and perform after going through the paste data.\n  const uploadTasks: { colId: string, valueIndex: number, fileList: File[] }[] = [];\n\n  data.forEach((col, idx) => {\n    const field = fields[idx];\n    const colRec = field?.column();\n    if (!colRec || colRec.isRealFormula() || colRec.disableEditData()) {\n      return;\n    }\n    if (isFileList(col[0]) && colRec.type.peek() !== \"Attachments\") {\n      // If you attempt to paste files into a non-Attachments column, ignore rather than paste\n      // empty values.\n      return;\n    }\n\n    const parser = field.createValueParser() || (x => x);\n    let typeMatches = false;\n    if (col[0] && typeof col[0] === \"object\" && !isFileList(col[0])) {\n      const { colType, docIdHash, colRef } = col[0];\n      const targetType = colRec.type();\n      const docIdMatches = docIdHash === thisDocIdHash;\n      typeMatches = docIdMatches || !isFullReferencingType(colType || \"\");\n\n      if (targetType !== \"Any\") {\n        typeMatches = typeMatches && colType === targetType;\n      } else if (docIdMatches && colRef) {\n        // Try copying source column type and options into empty columns\n        const sourceColRec = gristDoc.docModel.columns.getRowModel(colRef);\n        const sourceType = sourceColRec.type();\n        // Check that the source column still exists, has a type other than Text, and the type hasn't changed.\n        // For Text columns, we don't copy over column info so that type guessing can still happen.\n        if (sourceColRec.getRowId() && sourceType !== \"Text\" && sourceType === colType) {\n          const colInfo: Partial<SchemaTypes[\"_grist_Tables_column\"]> = {\n            type: sourceType,\n            visibleCol: sourceColRec.visibleCol(),\n            // Conditional formatting rules are not copied right now, that's a bit more complicated\n            // and copying the formula may or may not be desirable.\n            widgetOptions: JSON.stringify(omit(sourceColRec.widgetOptionsJson(), \"rulesOptions\")),\n          };\n          actions.push(\n            [\"UpdateRecord\", \"_grist_Tables_column\", colRec.getRowId(), colInfo],\n            [\"MaybeCopyDisplayFormula\", colRef, colRec.getRowId()],\n          );\n        }\n      }\n    }\n\n    const colId = colRec.colId.peek();\n    result[colId] = col.map((v, valueIndex) => {\n      if (v) {\n        if (typeof v === \"string\") {\n          return parser(v);\n        }\n        if (isFileList(v)) {\n          uploadTasks.push({ colId, valueIndex, fileList: v });\n          return null;\n        }\n        if (typeMatches && v.hasOwnProperty(\"rawValue\")) {\n          return v.rawValue;\n        }\n        if (v.hasOwnProperty(\"displayValue\")) {\n          return parser(v.displayValue);\n        }\n      }\n      return v;\n    });\n  });\n\n  // Replace any file values going into an Attachments column with upload results.\n  // We cache uploads on the **array of files**, because the entire array value may be duplicated\n  // in the input data when pasting into multiple rows.\n  const uploads = new Map<File[], Promise<CellValue>>();\n  for (const { colId, valueIndex, fileList } of uploadTasks) {\n    const value = await getSetMapValue(uploads, fileList, async (): Promise<CellValue> => {\n      const uploadResult = await uploadFiles(fileList,\n        { docWorkerUrl: gristDoc.docComm.docWorkerUrl, sizeLimit: \"attachment\" });\n\n      if (!uploadResult) { return null; }\n\n      // Upload the attachments.\n      const attRowIds = await gristDoc.docComm.addAttachments(uploadResult.uploadId);\n      return [GristObjCode.List, ...attRowIds];\n    });\n    result[colId][valueIndex] = value;\n  }\n\n  if (actions.length) {\n    await gristDoc.docData.sendActions(actions);\n  }\n\n  return result;\n}\n\n/**\n * Get default values for a new record so that it continues to satisfy the current linking filters.\n * Exclude formula columns since we can't set their values.\n */\nexport function getDefaultColValues(viewSection: ViewSectionRec): Record<string, any> {\n  const linkingState = viewSection.linkingState.peek();\n  if (!linkingState) {\n    return {};\n  }\n  const dataColIds = viewSection.columns.peek()\n    .filter(col => !col.isRealFormula.peek())\n    .map(col => col.colId.peek());\n  return pick(linkingState.getDefaultColValues(), dataColIds);\n}\n"
  },
  {
    "path": "app/client/components/BehavioralPromptsManager.ts",
    "content": "import { showNewsPopup, showTipPopup } from \"app/client/components/modals\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { getUserPrefObs } from \"app/client/models/UserPrefs\";\nimport { GristBehavioralPrompts } from \"app/client/ui/GristTooltips\";\nimport { isNarrowScreen } from \"app/client/ui2018/cssVars\";\nimport { BehavioralPrompt, BehavioralPromptPrefs } from \"app/common/Prefs\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Computed, Disposable, dom, Observable } from \"grainjs\";\nimport { IPopupOptions, PopupControl } from \"popweasel\";\n\n/**\n * Options for showing a popup.\n */\nexport interface ShowPopupOptions {\n  /** Defaults to `false`. Only applies to \"tip\" popups. */\n  hideArrow?: boolean;\n  popupOptions?: IPopupOptions;\n  onDispose?(): void;\n}\n\n/**\n * Options for attaching a popup to a DOM element.\n */\nexport interface AttachPopupOptions extends ShowPopupOptions {\n  /**\n   * Optional callback that should return true if the popup should be disabled.\n   *\n   * If omitted, the popup is enabled.\n   */\n  isDisabled?(): boolean;\n}\n\ninterface QueuedPopup {\n  prompt: BehavioralPrompt;\n  refElement: Element;\n  options: ShowPopupOptions;\n}\n\n/**\n * Manages popups for product announcements and tips.\n *\n * Popups are shown in the order that they are attached, with at most one popup\n * visible at any point in time. Popups that aren't visible are queued until all\n * preceding popups have been dismissed.\n */\nexport class BehavioralPromptsManager extends Disposable {\n  private _isDisabled: boolean = false;\n\n  private readonly _prefs = getUserPrefObs(this._appModel.userPrefsObs, \"behavioralPrompts\",\n    { defaultValue: { dontShowTips: false, dismissedTips: [] } }) as Observable<BehavioralPromptPrefs>;\n\n  private _dismissedPopups: Computed<Set<BehavioralPrompt>> = Computed.create(this, (use) => {\n    const { dismissedTips } = use(this._prefs);\n    return new Set(dismissedTips.filter(BehavioralPrompt.guard));\n  });\n\n  private _queuedPopups: QueuedPopup[] = [];\n\n  private _activePopupCtl: PopupControl<IPopupOptions>;\n\n  constructor(private _appModel: AppModel) {\n    super();\n  }\n\n  public showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions = {}) {\n    this._queuePopup(refElement, prompt, options);\n  }\n\n  public attachPopup(prompt: BehavioralPrompt, options: AttachPopupOptions = {}) {\n    return (element: Element) => {\n      if (options.isDisabled?.()) { return; }\n\n      this._queuePopup(element, prompt, options);\n    };\n  }\n\n  public hasSeenPopup(prompt: BehavioralPrompt) {\n    return this._dismissedPopups.get().has(prompt);\n  }\n\n  public shouldShowPopup(prompt: BehavioralPrompt): boolean {\n    if (this._isDisabled) { return false; }\n\n    // For non-SaaS flavors of Grist, don't show popups if the Help Center is explicitly\n    // disabled. A separate opt-out feature could be added down the road for more granularity,\n    // but will require communication in advance to avoid disrupting users.\n    const { deploymentType, features } = getGristConfig();\n    if (\n      !features?.includes(\"helpCenter\") &&\n      // This one is an easter egg, so we make an exception.\n      prompt !== \"rickRow\"\n    ) {\n      return false;\n    }\n\n    const {\n      popupType,\n      audience = \"everyone\",\n      deviceType = \"desktop\",\n      deploymentTypes,\n      forceShow = false,\n    } = GristBehavioralPrompts[prompt];\n\n    if (\n      (audience === \"anonymous-users\" && this._appModel.currentValidUser) ||\n      (audience === \"signed-in-users\" && !this._appModel.currentValidUser)\n    ) {\n      return false;\n    }\n\n    if (\n      deploymentTypes !== \"all\" &&\n      (!deploymentType || !deploymentTypes.includes(deploymentType))\n    ) {\n      return false;\n    }\n\n    const currentDeviceType = isNarrowScreen() ? \"mobile\" : \"desktop\";\n    if (deviceType !== \"all\" && deviceType !== currentDeviceType) { return false; }\n\n    return (\n      forceShow ||\n      (popupType === \"news\" && !this.hasSeenPopup(prompt)) ||\n      (!this._prefs.get().dontShowTips && !this.hasSeenPopup(prompt))\n    );\n  }\n\n  public enable() {\n    this._isDisabled = false;\n  }\n\n  public disable() {\n    this._isDisabled = true;\n    this._removeQueuedPopups();\n    this._removeActivePopup();\n  }\n\n  public isDisabled() {\n    return this._isDisabled;\n  }\n\n  public reset() {\n    this._prefs.set({ ...this._prefs.get(), dismissedTips: [], dontShowTips: false });\n    this.enable();\n  }\n\n  private _queuePopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) {\n    if (!this.shouldShowPopup(prompt)) { return; }\n\n    this._queuedPopups.push({ prompt, refElement, options });\n    if (this._queuedPopups.length > 1) {\n      // If we're already showing a popup, wait for that one to be dismissed, which will\n      // cause the next one in the queue to be shown.\n      return;\n    }\n\n    this._showPopup(refElement, prompt, options);\n  }\n\n  private _showPopup(refElement: Element, prompt: BehavioralPrompt, options: ShowPopupOptions) {\n    const { hideArrow, onDispose, popupOptions } = options;\n    const { popupType, title, content, hideDontShowTips = false, markAsSeen = true } = GristBehavioralPrompts[prompt];\n    let ctl: PopupControl<IPopupOptions>;\n    if (popupType === \"news\") {\n      ctl = showNewsPopup(refElement, title(), content(), {\n        popupOptions,\n      });\n      ctl.onDispose(() => { if (markAsSeen) { this._markAsSeen(prompt); } });\n    } else if (popupType === \"tip\") {\n      ctl = showTipPopup(refElement, title(), content(), {\n        onClose: (dontShowTips) => {\n          if (dontShowTips) { this._dontShowTips(); }\n          if (markAsSeen) { this._markAsSeen(prompt); }\n        },\n        hideArrow,\n        popupOptions,\n        hideDontShowTips,\n      });\n    } else {\n      throw new Error(`BehavioralPromptsManager received unknown popup type: ${popupType}`);\n    }\n\n    this._activePopupCtl = ctl;\n    ctl.onDispose(() => {\n      onDispose?.();\n      this._showNextQueuedPopup();\n    });\n    const close = () => {\n      if (!ctl.isDisposed()) {\n        ctl.close();\n      }\n    };\n    dom.onElem(refElement, \"click\", () => close());\n    dom.onDisposeElem(refElement, () => close());\n\n    logTelemetryEvent(\"viewedTip\", { full: { tipName: prompt } });\n  }\n\n  private _showNextQueuedPopup() {\n    this._queuedPopups.shift();\n    if (this._queuedPopups.length !== 0) {\n      const [nextPopup] = this._queuedPopups;\n      const { refElement, prompt, options } = nextPopup;\n      this._showPopup(refElement, prompt, options);\n    }\n  }\n\n  private _markAsSeen(prompt: BehavioralPrompt) {\n    if (this._isDisabled) { return; }\n\n    const { dismissedTips } = this._prefs.get();\n    const newDismissedTips = new Set(dismissedTips);\n    newDismissedTips.add(prompt);\n    this._prefs.set({ ...this._prefs.get(), dismissedTips: [...newDismissedTips] });\n  }\n\n  private _dontShowTips() {\n    if (this._isDisabled) { return; }\n\n    this._prefs.set({ ...this._prefs.get(), dontShowTips: true });\n    this._queuedPopups = this._queuedPopups.filter(({ prompt }) => {\n      return GristBehavioralPrompts[prompt].popupType !== \"tip\";\n    });\n  }\n\n  private _removeActivePopup() {\n    if (this._activePopupCtl && !this._activePopupCtl.isDisposed()) {\n      this._activePopupCtl.close();\n    }\n  }\n\n  private _removeQueuedPopups() {\n    this._queuedPopups = [];\n  }\n}\n"
  },
  {
    "path": "app/client/components/CellPosition.ts",
    "content": "import BaseRowModel from \"app/client/models/BaseRowModel\";\nimport { DocModel, ViewFieldRec } from \"app/client/models/DocModel\";\nimport { CursorPos } from \"app/plugin/GristAPI\";\n\n/**\n * Absolute position of a cell in a document\n */\nexport abstract class CellPosition {\n  public static equals(a: CellPosition, b: CellPosition) {\n    return a && b && a.colRef == b.colRef &&\n      a.sectionId == b.sectionId &&\n      a.rowId == b.rowId;\n  }\n\n  public static create(row: BaseRowModel, field: ViewFieldRec): CellPosition {\n    const rowId = row.id.peek();\n    const colRef = field.colRef.peek();\n    const sectionId = field.viewSection.peek().id.peek();\n    return { rowId, colRef, sectionId };\n  }\n\n  public sectionId: number;\n  public rowId: number | string;\n  public colRef: number;\n}\n\n/**\n * Converts cursor position to cell absolute positions. Return null if the conversion is not\n * possible (if cursor position doesn't have enough information)\n * @param position Cursor position\n * @param docModel Document model\n */\nexport function fromCursor(position: CursorPos, docModel: DocModel): CellPosition | null {\n  if (!position.sectionId || !position.rowId || position.fieldIndex == null) {\n    return null;\n  }\n\n  const section = docModel.viewSections.getRowModel(position.sectionId);\n  const colRef = section.viewFields().peek()[position.fieldIndex]?.colRef.peek();\n\n  const cursorPosition = {\n    rowId: position.rowId as (string | number), // TODO: cursor position is wrongly typed\n    colRef,\n    sectionId: position.sectionId,\n  };\n\n  return cursorPosition;\n}\n\n/**\n * Converts cell's absolute position to current cursor position.\n * @param position Cell's absolute position\n * @param docModel DocModel\n */\nexport function toCursor(position: CellPosition, docModel: DocModel): CursorPos {\n  // translate colRef to fieldIndex\n  const fieldIndex = docModel.viewSections.getRowModel(position.sectionId)\n    .viewFields().peek()\n    .findIndex(x => x.colRef.peek() == position.colRef);\n\n  const cursorPosition = {\n    rowId: position.rowId as number, // this is hack, as cursor position can accept string\n    fieldIndex,\n    sectionId: position.sectionId,\n  };\n\n  return cursorPosition;\n}\n"
  },
  {
    "path": "app/client/components/CellSelector.ts",
    "content": "import { between } from \"app/common/gutil\";\n\nimport { Disposable } from \"grainjs\";\nimport ko from \"knockout\";\n\nimport type BaseView from \"app/client/components/BaseView\";\nimport type { DataRowModel } from \"app/client/models/DataRowModel\";\n\nexport const ROW = \"row\";\nexport const COL = \"col\";\nexport const CELL = \"cell\";\nexport const NONE = \"\";\n\nexport type ElemType = \"row\" | \"col\" | \"cell\" | \"\";\n\ninterface GridView extends BaseView {\n  domToRowModel(elem: Element, elemType: ElemType): DataRowModel | undefined;\n  domToColModel(elem: Element, elemType: ElemType): DataRowModel | undefined;\n}\n\nexport class CellSelector extends Disposable {\n  // row or col.start denotes the anchor/initial index of the select range.\n  // start is not necessarily smaller than end.\n  // IE: clicking on col 10 and dragging until the mouse is on col 5 will yield: start = 10, end = 5\n  public row = {\n    start: ko.observable(0),\n    end: ko.observable(0),\n    linePos: ko.observable(\"0px\"),    // Used by GridView for dragging rows\n    dropIndex: ko.observable(-1),     // Used by GridView for dragging rows\n  };\n\n  public col =  {\n    start: ko.observable(0),\n    end: ko.observable(0),\n    linePos: ko.observable(\"0px\"),    // Used by GridView for dragging columns\n    dropIndex: ko.observable(-1),     // Used by GridView for dragging columns\n  };\n\n  public currentSelectType = ko.observable<ElemType>(NONE);\n  public currentDragType = ko.observable<ElemType>(NONE);\n\n  constructor(public readonly view: GridView) {\n    super();\n    this.autoDispose(this.view.cursor.rowIndex.subscribe(() => this.setToCursor()));\n    this.autoDispose(this.view.cursor.fieldIndex.subscribe(() => this.setToCursor()));\n    const fieldsLength = this.autoDispose(ko.pureComputed(() => this.view.viewSection.viewFields().all().length));\n    this.autoDispose(fieldsLength.subscribe((length) => {\n      this.col.end(Math.min(this.col.end.peek(), length - 1));\n    }));\n    this.setToCursor();\n  }\n\n  public setToCursor(elemType: ElemType = NONE) {\n    // Must check that the view contains cursor.rowIndex/cursor.fieldIndex\n    // in case it has changed.\n    if (this.view.cursor.rowIndex) {\n      this.row.start(this.view.cursor.rowIndex()!);\n      this.row.end(this.view.cursor.rowIndex()!);\n    }\n    if (this.view.cursor.fieldIndex) {\n      this.col.start(this.view.cursor.fieldIndex());\n      this.col.end(this.view.cursor.fieldIndex());\n    }\n    this.currentSelectType(elemType);\n  }\n\n  public containsCell(rowIndex: number, colIndex: number): boolean {\n    return this.containsCol(colIndex) && this.containsRow(rowIndex);\n  }\n\n  public containsRow(rowIndex: number): boolean {\n    return between(rowIndex, this.row.start(), this.row.end());\n  }\n\n  public containsCol(colIndex: number): boolean {\n    return between(colIndex, this.col.start(), this.col.end());\n  }\n\n  public isSelected(elem: Element, handlerName: ElemType) {\n    if (handlerName !== this.currentSelectType()) {\n      return false;\n    }\n\n    // TODO: this only works with view: GridView.\n    // But it seems like we only ever use selectors with gridview anyway\n    const row = this.view.domToRowModel(elem, handlerName);\n    const col = this.view.domToColModel(elem, handlerName);\n    switch (handlerName) {\n      case ROW:\n        return this.containsRow(row!._index()!);\n      case COL:\n        return this.containsCol(col!._index()!);\n      case CELL:\n        return this.containsCell(row!._index()!, col!._index()!);\n      default:\n        console.error(\"Given element is not a row, cell or column\");\n        return false;\n    }\n  }\n\n  public isRowSelected(rowIndex: number): boolean {\n    return this.isCurrentSelectType(COL) || this.containsRow(rowIndex);\n  }\n\n  public isColSelected(colIndex: number): boolean {\n    return this.isCurrentSelectType(ROW) || this.containsCol(colIndex);\n  }\n\n  public isCellSelected(rowIndex: number, colIndex: number): boolean {\n    return this.isColSelected(colIndex) && this.isRowSelected(rowIndex);\n  }\n\n  public onlyCellSelected(rowIndex: number, colIndex: number): boolean {\n    return (this.row.start() === rowIndex && this.row.end() === rowIndex) &&\n      (this.col.start() === colIndex && this.col.end() === colIndex);\n  }\n\n  public isCurrentSelectType(elemType: ElemType): boolean {\n    return this._isCurrentType(this.currentSelectType(), elemType);\n  }\n\n  public isCurrentDragType(elemType: ElemType): boolean {\n    return this._isCurrentType(this.currentDragType(), elemType);\n  }\n\n  public colLower(): number {\n    return Math.min(this.col.start(), this.col.end());\n  }\n\n  public colUpper(): number {\n    return Math.max(this.col.start(), this.col.end());\n  }\n\n  public rowLower(): number {\n    return Math.min(this.row.start(), this.row.end());\n  }\n\n  public rowUpper(): number {\n    return Math.max(this.row.start(), this.row.end());\n  }\n\n  public colCount(): number {\n    return this.colUpper() - this.colLower() + 1;\n  }\n\n  public rowCount(): number {\n    return this.rowUpper() - this.rowLower() + 1;\n  }\n\n  public selectArea(rowStartIdx: number, colStartIdx: number, rowEndIdx: number, colEndIdx: number): void {\n    this.row.start(rowStartIdx);\n    this.col.start(colStartIdx);\n    this.row.end(rowEndIdx);\n    this.col.end(colEndIdx);\n    // Only select the area if it's not a single cell\n    if (this.colCount() > 1 || this.rowCount() > 1) {\n      this.currentSelectType(CELL);\n    }\n  }\n\n  private _isCurrentType(currentType: ElemType, elemType: ElemType): boolean {\n    console.assert([ROW, COL, CELL, NONE].includes(elemType));\n    return currentType === elemType;\n  }\n}\n"
  },
  {
    "path": "app/client/components/ChartView.css",
    "content": ".chart_container {\n  overflow: hidden;\n  position: absolute;\n  height: 100%;\n  width: 100%;\n}\n\n/* Add some spacing between pie chart slices, to help people differentiate them. */\n.chart_container .plotly .pielayer .slice .surface {\n  stroke: var(--grist-theme-chart-bg) !important;\n  stroke-width: 2px !important;\n  stroke-opacity: 0.5 !important;\n}\n\n@media print {\n  .chart_container .plotly .pielayer .slice .surface {\n    stroke-opacity: 1 !important;\n  }\n}\n\n/* Make the pie chart slice texts a bit more pronounced,\n to help people differentiate them from the slice backgrounds. */\n.chart_container .plotly .pielayer text.slicetext {\n  font-weight: 700 !important;\n  font-size: 14px !important;\n}\n"
  },
  {
    "path": "app/client/components/ChartView.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { consolidateValues, formatPercent, sortByXValues, splitValuesByIndex,\n  uniqXValues } from \"app/client/lib/chartUtil\";\nimport { Delay } from \"app/client/lib/Delay\";\nimport { fromKoSave } from \"app/client/lib/fromKoSave\";\nimport { loadPlotly, PlotlyType } from \"app/client/lib/imports\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ColumnRec, ViewFieldRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { ChartOptions, ViewSectionOptions } from \"app/client/models/entities/ViewSectionRec\";\nimport { reportError } from \"app/client/models/errors\";\nimport { KoSaveableObservable, ObjObservable, setSaveValue } from \"app/client/models/modelUtil\";\nimport { IPageWidget, toPageWidget } from \"app/client/ui/PageWidgetPicker\";\nimport { cssGroupLabel, cssRow, cssSeparator } from \"app/client/ui/RightPanelStyles\";\nimport { cssFieldEntry, cssFieldLabel, IField, VisibleFieldsConfig } from \"app/client/ui/VisibleFieldsConfig\";\nimport { squareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssDragger } from \"app/client/ui2018/draggableList\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { IOptionFull, linkSelect, menu, menuItem, menuText, select } from \"app/client/ui2018/menus\";\nimport { gristThemeObs } from \"app/client/ui2018/theme\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { nativeCompare, unwrap } from \"app/common/gutil\";\nimport { Sort } from \"app/common/SortSpec\";\nimport { BaseFormatter } from \"app/common/ValueFormatter\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nimport { Computed, Disposable as GrainJSDisposable, dom, DomContents, DomElementArg, fromKo,\n  IDisposable, IOption, makeTestId, Observable, styled, UseCB } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport clamp from \"lodash/clamp\";\nimport debounce from \"lodash/debounce\";\nimport defaultsDeep from \"lodash/defaultsDeep\";\nimport isNumber from \"lodash/isNumber\";\nimport merge from \"lodash/merge\";\nimport sum from \"lodash/sum\";\nimport union from \"lodash/union\";\n\nimport type { Annotations, Config, Datum, ErrorBar, Layout, LayoutAxis, Margin,\n  PlotData as PlotlyPlotData } from \"plotly.js\";\n\nlet Plotly: PlotlyType;\n\n// When charting multiple series based on user data, limit the number of series given to plotly.\nconst MAX_SERIES_IN_CHART = 100;\nconst DONUT_DEFAULT_HOLE_SIZE = 0.75;\nconst DONUT_DEFAULT_TEXT_SIZE = 24;\n\nconst testId = makeTestId(\"test-chart-\");\n\nconst t = makeT(\"ChartView\");\n\nfunction isPieLike(chartType: string) {\n  return [\"pie\", \"donut\"].includes(chartType);\n}\n\nfunction firstFieldIsLabels(chartType: string) {\n  return [\"pie\", \"donut\", \"kaplan_meier\", \"scatter\"].includes(chartType);\n}\n\nexport function isNumericOnly(chartType: string) {\n  return [\"bar\", \"pie\", \"donut\", \"kaplan_meier\", \"line\", \"area\", \"scatter\"].includes(chartType);\n}\n\n// Returns the type of the visibleCol if col is of type `Ref`, otherwise returns the type of col.\nfunction visibleColType(col: ColumnRec, use: UseCB = unwrap) {\n  const colType = use(col.pureType);\n  const isRef = colType === \"Ref\";\n  return isRef ? use(use(col.visibleColModel).type) : colType;\n}\n\n// Returns true if col is one of 'Numeric', 'Int', 'Any'.\nexport function isNumericLike(col: ColumnRec, use: UseCB = unwrap) {\n  const colType = visibleColType(col, use);\n  return [\"Numeric\", \"Int\", \"Any\"].includes(colType);\n}\n\nfunction isCategoryType(pureType: string): boolean {\n  return ![\"Numeric\", \"Int\", \"Any\", \"Date\", \"DateTime\"].includes(pureType);\n}\n\n// We use plotly's Datum to describe the type of values in cells. Cells may not match this\n// perfectly, but it's helpful for type-checking anyway.\ntype RowPropGetter = (rowId: number) => Datum;\n\n// We convert Grist data to a list of Series first, from which we then construct Plotly traces.\ninterface Series {\n  label: string;          // Corresponds to the column name.\n  values: Datum[];\n  pureType?: string;      // The pure type of the column.\n  group?: Datum;          // The group value, when grouped.\n  isInSortSpec?: boolean; // Whether this series is present in sort spec for this chart.\n}\n\nfunction getSeriesName(series: Series, haveMultiple: boolean) {\n  if (series.group === undefined) {\n    return series.label;\n  }\n\n  // Let's show [Blank] instead of leaving the name empty for that series. There is a possibility\n  // to confuse user between a blank cell and a cell holding the `[Blank]` value. But that is rare\n  // enough, and confusion can easily be removed by the chart creator by editing blank cells\n  // directly in the the table to put something more meaningful instead.\n  const groupName = series.group === \"\" ? \"[Blank]\" : series.group;\n  if (haveMultiple) {\n    return `${groupName} \\u2022 ${series.label}`;  // the unicode character is \"black circle\"\n  } else {\n    return String(groupName);\n  }\n}\n\ntype Data = Partial<PlotlyPlotData>;\n\n// The output of a ChartFunc. Normally it just returns one or more Data[] series, but sometimes it\n// includes layout information: e.g. a \"Scatter plot\" returns a Layout with axis labels.\ninterface PlotData {\n  data: Data[];\n  layout?: Partial<Layout>;\n  config?: Partial<Config>;\n}\n\n// Data options to pass to chart functions.\ninterface DataOptions extends Data {\n\n  // Allows to set the pie sort option (see: https://plotly.com/javascript/reference/pie/#pie-sort).\n  // Supports pie charts only.\n  sort?: boolean;\n\n  // Formatter to be used for the total inside donut charts.\n  totalFormatter?: BaseFormatter;\n}\n\n// Convert a list of Series into a set of Plotly traces.\ntype ChartFunc = (series: Series[], options: ChartOptions, dataOptions?: DataOptions) => PlotData;\n\n// Helper for converting numeric Date/DateTime values (seconds since Epoch) to JS Date objects for\n// use with plotly.\nfunction dateGetter(getter: RowPropGetter): RowPropGetter {\n  return (r: number) => {\n    // 0's will turn into nulls, and non-numbers will turn into NaNs and then nulls. This prevents\n    // Plotly from including 1970-01-01 onto X axis, which usually makes the plot useless.\n    const val = (getter(r) as number) * 1000;\n    // Plotly recommends using strings for dates rather than Date objects or timestamps. They are\n    // interpreted more consistently. See https://github.com/plotly/plotly.js/issues/1532#issuecomment-290420534.\n    return val ? new Date(val).toISOString() : null;\n  };\n}\n\n// List of column types whose values are encoded has list, ie: ['L', 'foo', ...]. Such values\n// require special treatment to show correctly in charts.\nconst LIST_TYPES = [\"ChoiceList\", \"RefList\"];\n\n/**\n * ChartView component displays created charts.\n */\nexport class ChartView extends BaseView {\n  private _chartType: ko.Observable<string>;\n  private _options: ObjObservable<ViewSectionOptions>;\n  private _chartDom: HTMLElement;\n  private _update: () => void;\n  private _resize: () => void;\n\n  private _formatterComp: ko.Computed<BaseFormatter | undefined>;\n\n  // peek section's sort spec\n  private get _sortSpec() { return this.viewSection.activeSortSpec.peek(); }\n\n  constructor(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {\n    super(gristDoc, viewSectionModel);\n\n    // Note that .viewPane is used by ViewLayout to insert the actual DOM into the document.\n    this._chartDom = this.viewPane = this.buildDom();\n    this.onDispose(() => { dom.domDispose(this.viewPane); this.viewPane.remove(); });\n\n    this._resize = this.autoDispose(Delay.untilAnimationFrame(this._resizeChart, this));\n\n    this._chartType = this.viewSection.chartTypeDef;\n    this._options = this.viewSection.optionsObj;\n\n    // Computed that returns the formatter of the first series. This is useful to format the total\n    // within a donut chart.\n    this._formatterComp = this.autoDispose(ko.computed(() => {\n      const field = this.viewSection.viewFields().at(1);\n      return field?.visibleColFormatter();\n    }));\n\n    this._update = debounce(() => this._updateView(), 0);\n\n    let subs: IDisposable[] = [];\n    this.autoDispose(this._chartType.subscribe(this._update));\n    this.autoDispose(this._options.subscribe(this._update));\n    this.autoDispose(this.viewSection.viewFields().subscribe((viewFields: ViewFieldRec[]) => {\n      this._update();\n      subs.forEach(sub => sub.dispose());\n      subs = [\n        ...viewFields.map(field => field.displayColModel.peek().type.subscribe(this._update)),\n        ...viewFields.map(field => field.visibleColModel.peek().type.subscribe(this._update)),\n      ];\n    }));\n    this.listenTo(this.sortedRows, \"rowNotify\", this._update);\n    this.autoDispose(this.sortedRows.getKoArray().subscribe(this._update));\n    this.autoDispose(this._formatterComp.subscribe(this._update));\n    this.autoDispose(gristThemeObs().addListener(() => this._update()));\n  }\n\n  public prepareToPrint(onOff: boolean) {\n    Plotly.relayout(this._chartDom, {}).catch(reportError);\n  }\n\n  public onResize() {\n    this._resize();\n  }\n\n  protected onTableLoaded() {\n    super.onTableLoaded();\n    this._update();\n  }\n\n  protected buildDom() {\n    return dom(\"div.chart_container\", testId(\"container\"));\n  }\n\n  private async _updateView() {\n    if (this.isDisposed()) { return; }\n\n    const chartFunc = chartTypes[this._chartType()];\n    if (typeof chartFunc !== \"function\") {\n      console.warn(\"Unknown trace type %s\", this._chartType());\n      return;\n    }\n\n    const fields: ViewFieldRec[] = this.viewSection.viewFields().all();\n    const rowIds: number[] = this.sortedRows.getKoArray().peek() as number[];\n    const startIndexForYAxis = this._options.prop(\"multiseries\").peek() ? 2 : 1;\n    let series: Series[] = fields\n      .filter((field, i) => i < startIndexForYAxis || this._isCompatibleSeries(field.column.peek()))\n      .map((field) => {\n        // Use the colId of the displayCol, which may be different in case of Reference columns.\n        const colId: string = field.displayColModel.peek().colId.peek();\n        const getter = this.tableModel.tableData.getRowPropFunc(colId) as RowPropGetter;\n        const pureType = field.displayColModel().pureType();\n        const fullGetter = (pureType === \"Date\" || pureType === \"DateTime\") ? dateGetter(getter) : getter;\n        return {\n          pureType,\n          label: field.label(),\n          values: rowIds.map(fullGetter),\n          isInSortSpec: Boolean(Sort.findCol(this._sortSpec, field.colRef.peek())),\n        };\n      });\n\n    for (let i = 0; i < series.length; ++i) {\n      if (i < fields.length && LIST_TYPES.includes(fields[i].column.peek().pureType.peek())) {\n        if (i < startIndexForYAxis) {\n          // For x-axis and group column data, split series we should split records.\n          series = splitValuesByIndex(series, i);\n        } else {\n          // For all y-axis, it's not sure what would be a sensible representation for choice list,\n          // simply stringify choice list values seems reasonable.\n          series[i].values = series[i].values.map(v => String(decodeObject(v as any)));\n        }\n      }\n    }\n\n    const dataOptions: DataOptions = {};\n    const options: ChartOptions = this._options.peek() || {};\n    let plotData: PlotData = { data: [] };\n\n    if (isPieLike(this._chartType.peek())) {\n      // Plotly's pie charts have a sort option that is enabled by default. Let's turn it off.\n      dataOptions.sort = false;\n\n      // This line is for labels to stay in order when value changes, which can happen when using\n      // charts with linked list.\n      sortByXValues(series);\n    }\n\n    if (this._chartType.peek() === \"donut\") {\n      dataOptions.totalFormatter = this._formatterComp.peek();\n    }\n\n    if (!options.multiseries && series.length) {\n      plotData = chartFunc(series, options, dataOptions);\n    } else if (series.length > 1) {\n      // We need to group all series by the first column.\n      // Sort series alphabetically only if user has not defined a sort on this chart.\n      const shouldSort = !series[0].isInSortSpec;\n      const nseries = groupSeries(series[0].values, series.slice(1), shouldSort);\n\n      // This will be in the order in which nseries Map was created; concat() flattens the arrays.\n      const xvalues = Array.from(new Set(series[1].values));\n      for (const gSeries of nseries.values()) {\n        // All series have partial list of values, ie: if some may have Q1, Q2, Q3, Q4 as x values\n        // some others might only have Q1. This causes inconsistent result in regard of the order\n        // bars will be displayed by plotly (for bar charts). This eventually result in bars not\n        // following the sorting order. This line fixes that issue by consolidating all series to\n        // have at least on entry of each x values.\n        if (this._chartType.peek() === \"bar\") {\n          if (this._sortSpec?.length) { consolidateValues(gSeries, xvalues); }\n        }\n\n        const part = chartFunc(gSeries, options, dataOptions);\n        part.data = plotData.data.concat(part.data);\n        plotData = part;\n      }\n    }\n\n    Plotly = Plotly || await loadPlotly();\n\n    // Loading plotly is asynchronous and it may happen that the chart view had been disposed in the\n    // meantime and cause error later. So let's check again.\n    if (this.isDisposed()) { return; }\n\n    const layout: Partial<Layout> = defaultsDeep(plotData.layout, this._getPlotlyLayout(options));\n    const config: Partial<Config> = { ...plotData.config, displayModeBar: false };\n    // react() can be used in place of newPlot(), and is faster when updating an existing plot.\n    await Plotly.react(this._chartDom, plotData.data, layout, config);\n    this._resizeChart();\n  }\n\n  private _resizeChart() {\n    if (this.isDisposed() || !Plotly || !this._chartDom.parentNode) { return; }\n    // Check if the chart is visible before resizing. If it's not visible, Plotly will throw an error.\n    const display = window.getComputedStyle(this._chartDom).display;\n    if (!display || display === \"none\") {\n      return;\n    }\n    Plotly.Plots.resize(this._chartDom);\n  }\n\n  private _isCompatibleSeries(col: ColumnRec) {\n    return isNumericOnly(this._chartType.peek()) ? isNumericLike(col) : true;\n  }\n\n  private _getPlotlyLayout(options: ChartOptions): Partial<Layout> {\n    // Note that each call to getPlotlyLayout() creates a new layout object. We are intentionally\n    // avoiding reuse because Plotly caches too many layout calculations when the object is reused.\n    const yaxis: Partial<LayoutAxis> = { automargin: true, title: { standoff: 0 } };\n    const xaxis: Partial<LayoutAxis> = { automargin: true, title: { standoff: 0 } };\n    if (options.logYAxis) { yaxis.type = \"log\"; }\n    if (options.invertYAxis) { yaxis.autorange = \"reversed\"; }\n    const layout = {\n      // Margins include labels, titles, legend, and may get auto-expanded beyond this.\n      margin: {\n        l: 50,\n        r: 50,\n        b: 40,  // Space below chart which includes x-axis labels\n        t: 30,  // Space above the chart (doesn't include any text)\n        pad: 4,\n      } as Margin,\n      yaxis,\n      xaxis,\n      ...(options.stacked ? { barmode: \"relative\" } : {}),\n    };\n    return merge(layout, this._getPlotlyTheme());\n  }\n\n  private _getPlotlyTheme(): Partial<Layout> {\n    const { colors } = gristThemeObs().get();\n    const { chartBg, chartLegendBg, chartFg, chartXAxis, chartYAxis } = colors.components;\n    return {\n      colorway: [\n        \"#2b78ae\",\n        \"#fe945b\",\n        \"#3a936e\",\n        \"#d34141\",\n        \"#8563cc\",\n        \"#8c564b\",\n        \"#db7fbf\",\n        \"#7f7f7f\",\n        \"#b3b42b\",\n        \"#28b4d3\",\n      ],\n      paper_bgcolor: typeof chartBg === \"string\" ? chartBg : chartBg.getRawValue(),\n      plot_bgcolor: typeof chartBg === \"string\" ? chartBg : chartBg.getRawValue(),\n      xaxis: {\n        color: typeof chartXAxis === \"string\" ? chartXAxis : chartXAxis.getRawValue(),\n      },\n      yaxis: {\n        color: typeof chartYAxis === \"string\" ? chartYAxis : chartYAxis.getRawValue(),\n      },\n      font: {\n        color: typeof chartFg === \"string\" ? chartFg : chartFg.getRawValue(),\n      },\n      legend: {\n        bgcolor: typeof chartLegendBg === \"string\" ? chartLegendBg : chartLegendBg.getRawValue(),\n      },\n    };\n  }\n}\n\n/**\n * Group the given array of series by a column of group values. The groupColumn and each of the\n * series should be arrays of the same length.\n *\n * For example, if groupColumn has CompanyID, and valueSeries contains [Date, Employees, Revenues]\n * (each an array of values), then returns a map mapping each CompanyID to the array [Date,\n * Employees, Revenue], each value of which is itself an array of values for that CompanyID.\n */\nfunction groupSeries<T extends Datum>(groupColumn: T[], valueSeries: Series[], sort: boolean): Map<T, Series[]> {\n  const nseries = new Map<T, Series[]>();\n\n  // Limit the number if group values so as to limit the total number of series we pass into\n  // Plotly. Too many series are impossible to make sense of anyway, and can hang the browser.\n  // TODO: When not all data is shown, we should probably show some indicator, similar to when\n  // OnDemand data is truncated.\n  const maxGroups = Math.floor(MAX_SERIES_IN_CHART / valueSeries.length);\n  let groupValues: T[] = [...new Set(groupColumn)];\n  if (sort) {\n    groupValues.sort();\n  }\n  groupValues = groupValues.slice(0, maxGroups);\n\n  // Set up empty lists for each group.\n  for (const group of groupValues) {\n    nseries.set(group, valueSeries.map((s: Series) => ({\n      label: s.label,\n      group,\n      values: [],\n    })));\n  }\n\n  // Now fill up the lists.\n  for (let row = 0; row < groupColumn.length; row++) {\n    const group = groupColumn[row];\n    const series: Series[] | undefined = nseries.get(group);\n    if (series) {\n      for (let i = 0; i < valueSeries.length; i++) {\n        series[i].values.push(valueSeries[i].values[row]);\n      }\n    }\n  }\n  return nseries;\n}\n\n// If errorBars are requested, removes error bar series from the 'series' list, adding instead a\n// mapping from each main Y series to the corresponding plotly ErrorBar object.\nfunction extractErrorBars(series: Series[], options: ChartOptions): Map<Series, ErrorBar> {\n  const result = new Map<Series, ErrorBar>();\n  if (options.errorBars) {\n    // We assume that series is of the form [X, Y1, Y1-bar, Y2, Y2-bar, ...] (if \"symmetric\") or\n    // [X, Y1, Y1-below, Y1-above, Y2, Y2-below, Y2-above, ...] (if \"separate\").\n    for (let i = 1; i < series.length; i++) {\n      result.set(series[i], {\n        type: \"data\",\n        symmetric: (options.errorBars === \"symmetric\"),\n        array: series[i + 1]?.values,\n        arrayminus: (options.errorBars === \"separate\" ? series[i + 2]?.values : undefined),\n        thickness: 1,\n        width: 3,\n      });\n      series.splice(i + 1, (options.errorBars === \"symmetric\" ? 1 : 2));\n    }\n  }\n  return result;\n}\n\n/**\n * The grainjs component for side-pane configuration options for a Chart section.\n */\nexport class ChartConfig extends GrainJSDisposable {\n  private static _instanceMap = new WeakMap<ViewSectionRec, ChartConfig>();\n\n  // helper to build the draggable field list\n  private _configFieldsHelper = VisibleFieldsConfig.create(this, this._gristDoc, this._section);\n\n  // The index for the x-axis in the list visible fields. Could be eigther 0 or 1 or -1 depending on\n  // whether multiseries and isXAxisUndefined are set.\n  private _xAxisFieldIndex = Computed.create(\n    this,\n    fromKo(this._optionsObj.prop(\"multiseries\")),\n    fromKo(this._optionsObj.prop(\"isXAxisUndefined\")), (_use, multiseries, isUndefined) => (\n      isUndefined ? -1 : (multiseries ? 1 : 0)\n    ),\n  );\n\n  // The colId of the grouping column, or \"\" if multiseries is disabled or there are no viewFields,\n  // for example during section removal.\n  private _groupDataColId: Computed<string> = Computed.create(this, (use) => {\n    const multiseries = use(this._optionsObj.prop(\"multiseries\"));\n    const viewFields = use(use(this._section.viewFields).getObservable());\n    if (!multiseries || viewFields.length === 0) { return \"\"; }\n    return use(use(viewFields[0].column).colId);\n  })\n    .onWrite(colId => this._setGroupDataColumn(colId));\n\n  // Updating the group data column involves several changes of the list of view fields which could\n  // leave the x-axis field index momentarily point to the wrong column. The freeze x axis\n  // observable is part of a hack to fix this issue.\n  private _freezeXAxis = Observable.create(this, false);\n\n  private _freezeYAxis = Observable.create(this, false);\n\n  // The colId of the x-axis, or \"\" is x axis is undefined.\n  private _xAxis: Computed<string> = Computed.create(\n    this, this._xAxisFieldIndex, this._freezeXAxis, (use, i, freeze) => {\n      if (freeze) { return this._xAxis.get(); }\n      const viewFields = use(use(this._section.viewFields).getObservable());\n      if (-1 < i && i < viewFields.length) {\n        return use(use(viewFields[i].column).colId);\n      }\n      return \"\";\n    })\n    .onWrite(colId => this._setXAxis(colId));\n\n  // Whether value is aggregated or not\n  private _isValueAggregated = Computed.create(this, use => this._isSummaryTable(use))\n    .onWrite(val => this._setAggregation(val));\n\n  // Columns options\n  private _columnsOptions: Computed<IOptionFull<string>[]> = Computed.create(\n    this, this._freezeXAxis, (use, freeze) => {\n      if (freeze) { return this._columnsOptions.get(); }\n      const columns = use(this._isValueAggregated) ?\n        this._getSummarySourceColumns(use) :\n        this._getColumns(use);\n      return columns\n      // filter out hidden column (ie: manualsort ...)\n        .filter(col => !col.isHiddenCol.peek())\n        .map(col => ({\n          value: col.colId(), label: col.label.peek(), icon: \"FieldColumn\" as IconName,\n        }));\n    },\n  );\n\n  // The list of available columns for the group data picker.\n  private _groupDataOptions = Computed.create<IOption<string>[]>(this, use => [\n    { value: \"\", label: \"Pick a column\" },\n    ...use(this._columnsOptions),\n  ]);\n\n  // Force checking/unchecking of the group data checkbox option.\n  private _groupDataForce = Observable.create(null, false);\n\n  // State for the group data option checkbox. True, if a group data column is set or if the user\n  // forced it. False otherwise.\n  private _groupData = Computed.create(\n    this, this._groupDataColId, this._groupDataForce, (_use, colId, force) => {\n      if (colId) { return true; }\n      return force;\n    }).onWrite((val) => {\n    if (val === false) {\n      this._groupDataColId.set(\"\");\n    }\n    this._groupDataForce.set(val);\n  });\n\n  // The label to show for the first field in the axis configurator.\n  private _firstFieldLabel = Computed.create(this, fromKo(this._section.chartTypeDef),\n    (_use, chartType) => firstFieldIsLabels(chartType) ? t(\"LABEL\") : t(\"X-AXIS\"),\n  );\n\n  // A computed that returns `this._section.chartTypeDef` and that takes care of removing the group\n  // data option when type is switched to 'pie'.\n  private _chartType = Computed.create(this, use => use(this._section.chartTypeDef))\n    .onWrite((val) => {\n      return this._gristDoc.docData.bundleActions(\"switched chart type\", async () => {\n        await this._section.chartTypeDef.saveOnly(val);\n        // When switching chart type to 'pie' makes sure to remove the group data option.\n        if (isPieLike(val)) {\n          await this._setGroupDataColumn(\"\");\n          this._groupDataForce.set(false);\n        }\n      });\n    });\n\n  constructor(private _gristDoc: GristDoc, private _section: ViewSectionRec) {\n    super();\n    ChartConfig._instanceMap.set(_section, this);\n  }\n\n  private get _optionsObj() { return this._section.optionsObj; }\n\n  public buildDom(): DomContents {\n    if (this._section.parentKey() !== \"chart\") { return null; }\n\n    return [\n      cssRow(\n        select(this._chartType, [\n          { value: \"bar\",          label: t(\"Bar chart\"),         icon: \"ChartBar\"   },\n          { value: \"pie\",          label: t(\"Pie chart\"),         icon: \"ChartPie\"   },\n          { value: \"donut\",        label: t(\"Donut chart\"),       icon: \"ChartDonut\" },\n          { value: \"area\",         label: t(\"Area chart\"),        icon: \"ChartArea\"  },\n          { value: \"line\",         label: t(\"Line chart\"),        icon: \"ChartLine\"  },\n          { value: \"scatter\",      label: t(\"Scatter plot\"),      icon: \"ChartLine\"  },\n          { value: \"kaplan_meier\", label: t(\"Kaplan-Meier plot\"), icon: \"ChartKaplan\" },\n        ]),\n        testId(\"type\"),\n      ),\n      dom.maybe(use => !isPieLike(use(this._section.chartTypeDef)), () => [\n        // These options don't make much sense for a pie chart.\n        cssCheckboxRowObs(t(\"Split series\"), this._groupData),\n        cssCheckboxRow(t(\"Invert Y-axis\"), this._optionsObj.prop(\"invertYAxis\")),\n        cssRow(\n          cssRowLabel(t(\"Orientation\")),\n          dom(\"div\", linkSelect(fromKoSave(this._optionsObj.prop(\"orientation\")), [\n            { value: \"v\", label: t(\"Vertical\") },\n            { value: \"h\", label: t(\"Horizontal\") },\n          ], { defaultLabel: t(\"Vertical\") })),\n          testId(\"orientation\"),\n        ),\n        cssCheckboxRow(t(\"Log scale Y-axis\"), this._optionsObj.prop(\"logYAxis\")),\n      ]),\n      dom.maybeOwned(use => use(this._section.chartTypeDef) === \"donut\", owner => [\n        cssSlideRow(\n          t(\"Hole size\"),\n          Computed.create(owner, use => use(this._optionsObj.prop(\"donutHoleSize\")) ?? DONUT_DEFAULT_HOLE_SIZE),\n          (val: number) => this._optionsObj.prop(\"donutHoleSize\").saveOnly(val),\n          testId(\"option\"),\n        ),\n        cssCheckboxRow(t(\"Show total\"), this._optionsObj.prop(\"showTotal\")),\n        dom.maybe(this._optionsObj.prop(\"showTotal\"), () => (\n          cssNumberWithSpinnerRow(\n            t(\"Text size\"),\n            Computed.create(owner, use => use(this._optionsObj.prop(\"textSize\")) ??  DONUT_DEFAULT_TEXT_SIZE),\n            (val: number) => this._optionsObj.prop(\"textSize\").saveOnly(val),\n            testId(\"option\"),\n          )\n        )),\n      ]),\n      dom.maybe(use => use(this._section.chartTypeDef) === \"line\", () => [\n        cssCheckboxRow(t(\"Connect gaps\"), this._optionsObj.prop(\"lineConnectGaps\")),\n        cssCheckboxRow(t(\"Show markers\"), this._optionsObj.prop(\"lineMarkers\")),\n      ]),\n      dom.maybe(use => [\"line\", \"bar\"].includes(use(this._section.chartTypeDef)), () => [\n        cssCheckboxRow(t(\"Stack series\"), this._optionsObj.prop(\"stacked\")),\n        cssRow(\n          cssRowLabel(t(\"Error bars\")),\n          dom(\"div\", linkSelect(fromKoSave(this._optionsObj.prop(\"errorBars\")), [\n            { value: \"\", label: t(\"None\") },\n            { value: \"symmetric\", label: t(\"Symmetric\") },\n            { value: \"separate\", label: t(\"Above+Below\") },\n          ], { defaultLabel: t(\"None\") })),\n          testId(\"error-bars\"),\n        ),\n        dom.domComputed(this._optionsObj.prop(\"errorBars\"), (value: ChartOptions[\"errorBars\"]) =>\n          value === \"symmetric\" ? cssRowHelp(t(\"Each Y series is followed by a series for the length of error bars.\")) :\n            value === \"separate\" ? cssRowHelp(\n              t(\"Each Y series is followed by two series, for top and bottom error bars.\"),\n            ) :\n              null,\n        ),\n      ]),\n\n      cssSeparator(),\n\n      dom.maybe(this._groupData, () =>\n        dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"chart-split-series-label\" },\n          cssGroupLabel(t(\"Split Series\"), { id: \"chart-split-series-label\" }),\n          cssRow(\n            select(this._groupDataColId, this._groupDataOptions),\n            testId(\"group-by-column\"),\n          ),\n          cssHintRow(t(\"Create separate series for each value of the selected column.\")),\n        ),\n      ),\n\n      // TODO: user should select x axis before widget reach page\n      dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"chart-first-field-label\" },\n        cssGroupLabel(dom.text(this._firstFieldLabel), testId(\"first-field-label\"), { id: \"chart-first-field-label\" }),\n        cssRow(\n          select(\n            this._xAxis, this._columnsOptions,\n            { defaultLabel: t(\"Pick a column\") },\n          ),\n          testId(\"x-axis\"),\n        ),\n        cssCheckboxRowObs(t(\"Aggregate values\"), this._isValueAggregated),\n      ),\n\n      dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"chart-series-label\" },\n        cssGroupLabel(t(\"SERIES\"), { id: \"chart-series-label\" }),\n        this._buildYAxis(),\n        cssRow(\n          cssAddYAxis(\n            cssAddIcon(\"Plus\"), t(\"Add series\"),\n            menu(() => {\n              const hiddenColumns = this._section.hiddenColumns.peek();\n              const filterFunc = this._isCompatibleSeries.bind(this);\n              const nonNumericCount = hiddenColumns.filter(col => !filterFunc(col)).length;\n              return [\n                ...hiddenColumns\n                  .filter(col => filterFunc(col))\n                  .map(col => menuItem(\n                    () => this._configFieldsHelper.addField(col),\n                    col.label.peek(),\n                  )),\n                nonNumericCount ? menuText(\n                  `${nonNumericCount} ` + (\n                    nonNumericCount > 1 ?\n                      t(`non-numeric columns are not shown`) :\n                      t(`non-numeric column is not shown`)\n                  ),\n                  testId(\"yseries-picker-message\"),\n                ) : null,\n              ];\n            }),\n            testId(\"add-y-axis\"),\n          ),\n        ),\n      ),\n\n    ];\n  }\n\n  private async _setXAxis(colId: string) {\n    const optionsObj = this._section.optionsObj;\n    const findColumn = () => this._getColumns().find(c => c.colId() === colId);\n    const viewFields = this._section.viewFields.peek();\n\n    await this._gristDoc.docData.bundleActions(\"selected new x-axis\", async () => {\n      this._freezeYAxis.set(true);\n      this._freezeXAxis.set(true);\n      try {\n        // first remove the current field\n        if (this._xAxisFieldIndex.get() !== -1 && this._xAxisFieldIndex.get() < viewFields.peek().length) {\n          await this._configFieldsHelper.removeField(viewFields.peek()[this._xAxisFieldIndex.get()]);\n        }\n\n        // if x axis was undefined, set option to false\n        await setSaveValue(this._optionsObj.prop(\"isXAxisUndefined\"), false);\n\n        // if new field was used to split series, disable multiseries\n        const fieldIndex = viewFields.peek().findIndex(f => f.column.peek().colId() === colId);\n        if (fieldIndex === 0 && optionsObj.prop(\"multiseries\").peek()) {\n          await optionsObj.prop(\"multiseries\").setAndSave(false);\n          return;\n        }\n\n        // if values aggregation is 'on' update the grouped by columns before findColumn()\n        // call. This will make sure that colId is not missing from the summary table's columns (as\n        // could happen if it were a non-numeric for instance).\n        if (this._isValueAggregated.get()) {\n          const splitColId = this._groupDataColId.get();\n          const cols = splitColId === colId ? [colId] : [splitColId, colId];\n          await this._setGroupByColumns(cols);\n        }\n\n        // if the new column for the x axis is already visible, make it the first visible column,\n        // else add it as the first visible field. The field will be first because it will be\n        // inserted before current xAxis column (which is already first (or second if we have\n        // multi-series option checked))\n        const xAxisField = viewFields.peek()[this._xAxisFieldIndex.get()];\n        if (fieldIndex > -1) {\n          await this._configFieldsHelper.changeFieldPosition(viewFields.peek()[fieldIndex], xAxisField);\n        } else {\n          const col = findColumn();\n          if (col) {\n            await this._configFieldsHelper.addField(col, xAxisField);\n          }\n        }\n      } finally {\n        this._freezeYAxis.set(false);\n        this._freezeXAxis.set(false);\n      }\n    });\n  }\n\n  private async _setGroupDataColumn(colId: string) {\n    const viewFields = this._section.viewFields.peek().peek();\n\n    await this._gristDoc.docData.bundleActions(t(\"selected new group data columns\"), async () => {\n      this._freezeXAxis.set(true);\n      this._freezeYAxis.set(true);\n      try {\n        // if grouping was already set, first remove the current field\n        if (this._groupDataColId.get()) {\n          await this._configFieldsHelper.removeField(viewFields[0]);\n        }\n\n        // if values aggregation is 'on' update the grouped by columns first. This will make sure\n        // that colId is not missing from the summary table's columns (as could happen if it were a\n        // non-numeric for instance).\n        if (this._isValueAggregated.get()) {\n          const xAxisColId = this._xAxis.get();\n          const cols = xAxisColId === colId ? [colId] : [colId, xAxisColId];\n          await this._setGroupByColumns(cols);\n        }\n\n        if (colId) {\n          const col = this._getColumns().find(c => c.colId() === colId)!;\n          const field = viewFields.find(f => f.column.peek().colId() === colId);\n\n          // if new field is already visible, moves the fields to the first place else add the field to the first\n          // place\n          if (field) {\n            await this._configFieldsHelper.changeFieldPosition(field, viewFields[0]);\n          } else {\n            await this._configFieldsHelper.addField(col, viewFields[0]);\n          }\n\n          // if this column is used as xAxis, set the xAxis to undefined (show Pick a column label)\n          if (colId === this._xAxis.get()) {\n            await this._optionsObj.prop(\"isXAxisUndefined\").setAndSave(true);\n          }\n        }\n\n        await this._optionsObj.prop(\"multiseries\").setAndSave(Boolean(colId));\n      } finally {\n        this._freezeXAxis.set(false);\n        this._freezeYAxis.set(false);\n      }\n    }, { nestInActiveBundle: true });\n  }\n\n  private _getColumns(use: UseCB = unwrap) {\n    const table = use(this._section.table);\n    return use(use(table.columns).getObservable());\n  }\n\n  private _getSummarySourceColumns(use: UseCB = unwrap) {\n    let table = use(this._section.table);\n    table = use(table.summarySource);\n    return use(use(table.columns).getObservable());\n  }\n\n  private _buildField(col: IField) {\n    return cssFieldEntry(\n      cssFieldLabel(dom.text(col.label)),\n      cssRemoveIcon(\n        \"Remove\",\n        dom.on(\"click\", () => this._configFieldsHelper.removeField(col)),\n        testId(\"ref-select-remove\"),\n      ),\n      testId(\"y-axis\"),\n    );\n  }\n\n  private _buildYAxis(): DomContents {\n    // The y-axis are all visible fields that comes after the x-axis and maybe the group data\n    // column. Hence the draggable list of y-axis needs to skip either one or two visible fields.\n    const skipFirst = Computed.create(this,\n      fromKo(this._optionsObj.prop(\"multiseries\")),\n      fromKo(this._optionsObj.prop(\"isXAxisUndefined\")),\n      (_use, multiseries, isUndefined) =>  (\n        (isUndefined ? 0 : 1) + (multiseries ? 1 : 0)\n      ));\n\n    return dom.domComputed((use) => {\n      const filterFunc = (field: ViewFieldRec) => this._isCompatibleSeries(use(field.column), use);\n      return this._configFieldsHelper.buildVisibleFieldsConfigHelper({\n        itemCreateFunc: field => this._buildField(field),\n        draggableOptions: {\n          removeButton: false,\n          drag_indicator: cssDragger,\n        }, skipFirst, freeze: this._freezeYAxis, filterFunc,\n      });\n    });\n  }\n\n  private _isCompatibleSeries(col: ColumnRec, use: UseCB = unwrap) {\n    return isNumericOnly(use(this._chartType)) ? isNumericLike(col, use) : true;\n  }\n\n  private async _setAggregation(val: boolean) {\n    try {\n      this._freezeXAxis.set(true);\n      await this._gristDoc.docData.bundleActions(t(\"Toggle chart aggregation\"), async () => {\n        if (val) {\n          await this._doAggregation();\n        } else {\n          await this._undoAggregation();\n        }\n      });\n    } finally {\n      if (!this.isDisposed()) {\n        this._freezeXAxis.set(false);\n      }\n    }\n  }\n\n  // Do the aggregation: if not a summary table, turns into one; else update groupby columns to\n  // match the X-Axis and Split-series columns.\n  private async _doAggregation(): Promise<void> {\n    if (!this._isSummaryTable()) {\n      await this._toggleSummaryTable();\n    } else {\n      await this._setGroupByColumns([this._xAxis.get(), this._groupDataColId.get()]);\n    }\n  }\n\n  // Undo the aggregation.\n  private async _undoAggregation() {\n    if (this._isSummaryTable()) {\n      await this._toggleSummaryTable();\n    }\n  }\n\n  private _isSummaryTable(use: UseCB = unwrap) {\n    return Boolean(use(use(this._section.table).summarySourceTable));\n  }\n\n  // Toggle whether section table is a summary table. Must use with care: this function calls\n  // `this.dispose()` as a side effect. Conveniently returns the ChartConfig instance of the new\n  // view section that replaces the old one.\n  private async _toggleSummaryTable(): Promise<ChartConfig> {\n    const colIds = [this._xAxis.get(), this._groupDataColId.get()];\n    const pageWidget = toPageWidget(this._section);\n    pageWidget.summarize = !this._isSummaryTable();\n    pageWidget.columns = this._getColumnIds(colIds);\n    this._ensureValidLinkingIfAny(pageWidget);\n    const newSection = await this._gristDoc.saveViewSection(this._section, pageWidget);\n    return ChartConfig._instanceMap.get(newSection)!;\n  }\n\n  private async _setGroupByColumns(groupByCols: string[]) {\n    const pageWidget = toPageWidget(this._section);\n    pageWidget.columns = this._getColumnIds(groupByCols);\n    this._ensureValidLinkingIfAny(pageWidget);\n    return this._gristDoc.saveViewSection(this._section, pageWidget);\n  }\n\n  // If section is linked to a summary table, makes sure that pageWidget describes a summary table\n  // that is more detailed than the source summary table. Function mutates `pageWidget`.\n  private _ensureValidLinkingIfAny(pageWidget: IPageWidget) {\n    if (!pageWidget.summarize) { return; }\n    if (!this._section.linkSrcSection().getRowId()) { return; }\n    const srcPageWidget = toPageWidget(this._section.linkSrcSection());\n    pageWidget.columns = union(pageWidget.columns, srcPageWidget.columns);\n  }\n\n  // Returns column ids corresponding to each colIds in the selected table (or corresponding summary\n  // source table, if select table is a summary table).\n  private _getColumnIds(colIds: string[]) {\n    const cols = this._isSummaryTable() ?\n      this._section.table().summarySource().columns().all() :\n      this._section.table().columns().all();\n    const columns = colIds\n      .map(colId => colId && cols.find(c => c.colId() === colId))\n      .filter((col): col is ColumnRec => Boolean(col))\n      .map(col => col.id());\n    return columns;\n  }\n}\n\n// Row for a numeric option. User can change value using spinners or directly using keyboard. In\n// case of invalid values, the field reverts to the saved one.\nfunction cssNumberWithSpinnerRow(label: string, value: Computed<number>, save: (val: number) => Promise<void>,\n  ...args: DomElementArg[]) {\n  const minValue = 1;\n  let input: HTMLInputElement;\n\n  // Set the input's value to the value that's saved on the server.\n  function reset() {\n    input.value = value.get() + \"px\";\n  }\n\n  async function onChange(val: string, func: (val: number) => number = v => v) {\n    let fvalue = parseFloat(val);\n    if (isFinite(fvalue)) {\n      fvalue = clamp(func(fvalue), minValue, Infinity);\n      await save(fvalue);\n    }\n    // Reset is needed if value were not a valid number.\n    reset();\n  }\n\n  return cssRow(\n    cssRowLabel(label),\n    cssNumberWithSpinner(\n      input = cssNumberInput(\n        { type: \"text\" },\n        dom.prop(\"value\", use => use(value) + \"px\"),\n        dom.on(\"change\", (_ev, el) => onChange(el.value)),\n        dom.onKeyDown({\n          ArrowDown: (_ev, el) => onChange(el.value, val => val - 1),\n          ArrowUp: (_ev, el) => onChange(el.value, val => val + 1),\n        }),\n      ),\n\n      // We add spinners as overlay in order to support showing the unit 'px' next to the value.\n      cssSpinners(\n        \"input\",\n        { type: \"number\", step: \"1\", min: String(minValue) },\n        dom.prop(\"value\", value),\n        dom.on(\"change\", (_ev, el) => onChange(el.value)),\n      ),\n    ),\n    ...args,\n  );\n}\n\n// Row for a numeric option that leaves between 0 and 1. User can change value using a slider, or\n// spinners or by directly using keyboard. Value is shown as percent. If user enter an invalid\n// value, field reverts to the saved value.\nfunction cssSlideRow(label: string, value: Computed<number>, save: (val: number) => Promise<void>,\n  ...args: DomElementArg[]) {\n  let input: HTMLInputElement;\n\n  // Set the input's value to the value that's saved on the server.\n  function reset() {\n    input.value = formatPercent(value.get());\n  }\n\n  async function onChange(val: string, func: (val: number) => number = v => v) {\n    let fvalue = parseFloat(val);\n    if (isFinite(fvalue)) {\n      fvalue = clamp(func(fvalue), 0, 99) / 100;\n      await save(fvalue);\n    }\n    // Reset is needed if value were not a valid number.\n    reset();\n  }\n\n  return cssRow(\n    cssRowLabel(label),\n    cssRangeInput(\n      { type: \"range\", min: \"0\", max: \"1\", step: \"0.01\" },\n      dom.prop(\"value\", value),\n      dom.on(\"change\", (_ev, el) => save(Number(el.value))),\n    ),\n    cssNumberWithSpinner(\n      input = cssNumberInput(\n        { type: \"text\" },\n        dom.prop(\"value\", use => formatPercent(use(value))),\n        dom.on(\"change\", (_ev, el) => onChange(el.value)),\n        dom.onKeyDown({\n          ArrowDown: (_ev, el) => onChange(el.value, val => val - 1),\n          ArrowUp: (_ev, el) => onChange(el.value, val => val + 1),\n        }),\n      ),\n\n      // We add spinners as overlay in order to support showing the unit '%' next to the value.\n      cssSpinners(\n        \"input\",\n        { type: \"number\", step: \"0.01\", min: \"0\", max: \"0.99\" },\n        dom.prop(\"value\", value),\n        dom.on(\"change\", (_ev, el) => save(Number(el.value))),\n      ),\n    ),\n    ...args,\n  );\n}\n\nfunction cssCheckboxRow(label: string, value: KoSaveableObservable<unknown>, ...args: DomElementArg[]) {\n  return cssCheckboxRowObs(label, fromKoSave(value), ...args);\n}\n\nfunction cssCheckboxRowObs(label: string, value: Observable<boolean>, ...args: DomElementArg[]) {\n  return dom(\"label\", cssRow.cls(\"\"),\n    cssRowLabel(label),\n    squareCheckbox(value, ...args),\n  );\n}\n\nfunction basicPlot(series: Series[], options: ChartOptions, dataOptions: Data): PlotData {\n  trimNonNumericData(series);\n  const errorBars = extractErrorBars(series, options);\n\n  if (dataOptions.type === \"bar\") {\n    // Plotly has weirdness when redundant values shows up on the x-axis: the values that shows\n    // up on hover is different than the value on the y-axis. It seems that one is the sum of all\n    // values with same x-axis value, while the other is the last of them. To fix this, we force\n    // unique values for the x-axis.\n    uniqXValues(series);\n  }\n  const [axis1, axis2] = options.orientation === \"h\" ? [\"y\", \"x\"] : [\"x\", \"y\"];\n\n  const dataSeries = series.slice(1).map((line: Series): Data => ({\n    name: getSeriesName(line, series.length > 2),\n    [axis1]: replaceEmptyLabels(series[0].values),\n    [axis2]: line.values,\n    [`error_${axis2}`]: errorBars.get(line),\n    orientation: options.orientation,\n    ...dataOptions,\n    stackgroup: makeRelativeStackGroup(dataOptions.stackgroup, line.values),\n  }));\n\n  // When stacking, stackgroup will be non-empty (an arbitrary value, set to \"A\" for line-charts).\n  // We further separate positive series from negative ones, by changing stackgroup to a different\n  // value (\"-A\") for series which look probably negative. This keeps positive ones above the\n  // x-axis, and negative ones below, as for barmode=relative (which only applies to bar charts).\n  function makeRelativeStackGroup(stackgroup: string | undefined, values: Datum[]) {\n    if (!stackgroup) { return stackgroup; }\n    const firstNonZero = values.find(v => v && (v > 0 || v < 0));\n    const isNegative = firstNonZero && firstNonZero < 0;\n    return isNegative ? \"-\" + stackgroup : stackgroup;\n  }\n\n  return {\n    data: dataSeries,\n    layout: {\n      [`${axis1}axis`]: { title: series.length > 0 ? { text: series[0].label } : {} },\n      // Include yaxis title for a single y-value series only (2 series total);\n      // If there are fewer than 2 total series, there is no y-series to display.\n      // If there are multiple y-series, a legend will be included instead, and the yaxis title\n      // is less meaningful, so omit it.\n      [`${axis2}axis`]: { title: series.length === 2 ? { text: series[1].label } : {} },\n    },\n  };\n}\n\n// Most chart types take a list of series and then use the first series for the X-axis, and each\n// subsequent series for their Y-axis values, allowing for multiple lines on the same plot.\n// Each series should have the form {label, values}.\nexport const chartTypes: { [name: string]: ChartFunc } = {\n  // TODO There is a lot of code duplication across chart types. Some refactoring is in order.\n  bar(series: Series[], options: ChartOptions): PlotData {\n    // If the X axis is not from numerical column, treat it as category.\n    const data = basicPlot(series, options, { type: \"bar\" });\n    const useCategory = series[0]?.pureType && isCategoryType(series[0].pureType);\n    const xaxisName = options.orientation === \"h\" ? \"yaxis\" : \"xaxis\";\n    if (useCategory && data.layout?.[xaxisName]) {\n      const axisConfig = data.layout[xaxisName]!;\n      axisConfig.type = \"category\";\n    }\n    return data;\n  },\n  line(series: Series[], options: ChartOptions): PlotData {\n    sortByXValues(series);\n    return basicPlot(series, options, {\n      type: \"scatter\",\n      connectgaps: options.lineConnectGaps,\n      mode: options.lineMarkers ? \"lines+markers\" : \"lines\",\n      stackgroup: (options.stacked ? \"A\" : \"\"),\n    });\n  },\n  area(series: Series[], options: ChartOptions): PlotData {\n    sortByXValues(series);\n    return basicPlot(series, options, {\n      type: \"scatter\",\n      fill: \"tozeroy\",\n      line: { shape: \"spline\" },\n    });\n  },\n  scatter(series: Series[], options: ChartOptions): PlotData {\n    return basicPlot(series.slice(1), options, {\n      type: \"scatter\",\n      mode: \"text+markers\",\n      text: series[0].values as string[],\n      textposition: \"bottom center\",\n    });\n  },\n\n  pie(series: Series[], _options: ChartOptions, dataOptions: DataOptions = {}): PlotData {\n    let line: Series;\n    if (series.length === 0) {\n      return { data: [] };\n    }\n    if (series.length > 1) {\n      trimNonNumericData(series);\n      line = series[1];\n    } else {\n      // When there is only one series of labels, simply count their occurrences.\n      line = { label: \"Count\", values: series[0].values.map(() => 1) };\n    }\n    return {\n      data: [{\n        type: \"pie\",\n        name: getSeriesName(line, false),\n        // nulls cause JS errors when pie charts resize, so replace with blanks.\n        // (a falsy value would cause plotly to show its index, like \"2\" which is more confusing).\n        labels: replaceEmptyLabels(series[0].values),\n        values: line.values,\n        ...dataOptions,\n      }],\n    };\n  },\n\n  donut(series: Series[], options: ChartOptions, dataOptions: DataOptions = {}): PlotData {\n    const hole = isNumber(options.donutHoleSize) ? options.donutHoleSize : DONUT_DEFAULT_HOLE_SIZE;\n    const annotations: Partial<Annotations>[] = [];\n    const plotData: PlotData = chartTypes.pie(series, options, { ...dataOptions, hole });\n\n    function format(val: number) {\n      if (dataOptions.totalFormatter) {\n        return dataOptions.totalFormatter.formatAny(val);\n      }\n      return String(val);\n    }\n\n    if (options.showTotal) {\n      annotations.push({\n        text: format(\n          series.length > 1 ?\n            sum(series[1].values.filter(isNumber)) :\n            plotData.data[0].labels!.length,\n        ),\n        showarrow: false,\n        font: {\n          size: options.textSize ?? DONUT_DEFAULT_TEXT_SIZE,\n        },\n      } as any);\n    }\n    return defaultsDeep(\n      plotData,\n      { layout: { annotations } },\n    );\n  },\n\n  kaplan_meier(series: Series[]): PlotData {\n    // For this plot, the first series names the category of each point, and the second the\n    // survival time for that point. We turn that into as many series as there are categories.\n    if (series.length < 2) { return { data: [] }; }\n    const newSeries = groupIntoSeries(series[0].values, series[1].values);\n    return {\n      data: newSeries.map((line: Series): Data => {\n        const points = kaplanMeierPlot(line.values as number[]);\n        return {\n          type: \"scatter\",\n          mode: \"lines\",\n          line: { shape: \"hv\" },\n          name: getSeriesName(line, false),\n          x: points.map(p => p.x),\n          y: points.map(p => p.y),\n        } as Data;\n      }),\n    };\n  },\n};\n\n/**\n * Assumes a list of series of the form [xValues, yValues1, yValues2, ...]. Remove from all series\n * those points for which all of the y-values are non-numeric (e.g. null or a string).\n */\nfunction trimNonNumericData(series: Series[]): void {\n  const values = series.slice(1).map(s => s.values);\n  for (const s of series) {\n    s.values = s.values.filter((_, i) => values.some(v => typeof v[i] === \"number\"));\n  }\n}\n\n/**\n * Replace empty values with \"-\", which is relevant for labels in Pie Charts and for X-axis in\n * other chart types.\n *\n * In pie charts, nulls cause JS errors. In other types, nulls in X-axis cause that point to be\n * omitted (but still affect the Y scale, causing confusion). Replace with \"-\" rather than blank\n * because plotly replaces falsy values by their index (eg \"2\") in Pie charts, which is confusing.\n */\nfunction replaceEmptyLabels(values: Datum[]): Datum[] {\n  return values.map(v => (v == null || v === \"\") ? \"-\" : v);\n}\n\n// Given two parallel arrays, returns an array of series of the form\n// {label: category, values: array-of-values}\nfunction groupIntoSeries(categoryList: Datum[], valueList: Datum[]): Series[] {\n  const groups = new Map();\n  for (const [i, cat] of categoryList.entries()) {\n    if (!groups.has(cat)) { groups.set(cat, []); }\n    groups.get(cat).push(valueList[i]);\n  }\n  return Array.from(groups, ([label, values]) => ({ label, values }));\n}\n\n// Given a list of survivalValues, returns a list of {x, y} pairs for the kaplanMeier plot.\nfunction kaplanMeierPlot(survivalValues: number[]): { x: number, y: number }[] {\n  // First get a distribution of survivalValue -> count.\n  const dist = new Map<number, number>();\n  for (const v of survivalValues) {\n    dist.set(v, (dist.get(v) || 0) + 1);\n  }\n\n  // Sort the distinct values.\n  const distinctValues = Array.from(dist.keys());\n  distinctValues.sort(nativeCompare);\n\n  // Now generate plot values, with 'x' for survivalValue and 'y' the number of surviving points.\n  let y = survivalValues.length;\n  const points = [{ x: 0, y }];\n  for (const x of distinctValues) {\n    y -= dist.get(x)!;\n    points.push({ x, y });\n  }\n  return points;\n}\n\nconst cssRowLabel = styled(\"div\", `\n  flex: 1 0 0px;\n  margin-right: 8px;\n\n  font-weight: initial;   /* negate bootstrap */\n  color: ${theme.text};\n  overflow: hidden;\n  text-overflow: ellipsis;\n  user-select: none;\n`);\n\nconst cssRowHelp = styled(cssRow, `\n  font-size: ${vars.smallFontSize};\n  color: ${theme.lightText};\n`);\n\nconst cssAddIcon = styled(icon, `\n  margin-right: 4px;\n`);\n\nconst cssAddYAxis = styled(unstyledButton, `\n  display: flex;\n  cursor: pointer;\n  color: ${theme.controlFg};\n  --icon-color: ${theme.controlFg};\n\n  &:not(:first-child) {\n    margin-top: 8px;\n  }\n  &:hover, &:focus, &:active {\n    color: ${theme.controlHoverFg};\n    --icon-color: ${theme.controlHoverFg};\n  }\n`);\n\nconst cssRemoveIcon = styled(icon, `\n  display: none;\n  cursor: pointer;\n  flex: none;\n  margin-left: 8px;\n  .${cssFieldEntry.className}:hover & {\n    display: block;\n  }\n`);\n\nconst cssHintRow = styled(\"div\", `\n  margin: -4px 16px 8px 16px;\n  color: ${theme.lightText};\n`);\n\nconst cssRangeInput = styled(\"input\", `\n  input& {\n    width: 82px;\n    margin-right: 4px;\n  }\n`);\n\nconst cssNumberWithSpinner = styled(\"div\", `\n  position: relative;\n`);\n\nconst cssNumberInput = styled(\"input\", `\n  width: 55px;\n`);\n\nconst cssSpinners = styled(\"input\", `\n  width: 19px;\n  position: absolute;\n  top: 2px;\n  right: 1px;\n  border: none;\n  outline: none;\n  appearance: none;\n  -moz-appearance: none;\n  visibility: hidden;\n\n  .${cssNumberWithSpinner.className}:hover & {\n    visibility: visible;\n  }\n\n  /* needed for chrome to show spinners, indeed the cursor could be outside of spinners' input\n  element */\n  &[type=number]::-webkit-inner-spin-button {\n    opacity: 1;\n  }\n`);\n"
  },
  {
    "path": "app/client/components/ClientScope.ts",
    "content": "import * as dispose from \"app/client/lib/dispose\";\nimport { Storage } from \"app/plugin/StorageAPI\";\nimport { checkers } from \"app/plugin/TypeCheckers\";\n\nimport { Rpc } from \"grain-rpc\";\n\n/**\n * Implementation of interfaces whose lifetime is that of the client.\n */\nexport class ClientScope extends dispose.Disposable {\n  private _pluginStorage = new Map<string, Storage>();\n\n  public create() {\n    // nothing to do\n  }\n\n  /**\n   * Make interfaces available for a plugin with a given name.  Implementations\n   * are attached directly to the supplied rpc object.\n   */\n  public servePlugin(pluginId: string, rpc: Rpc) {\n    // We have just one interface right now, storage.  We want to keep ownership\n    // of storage, so it doesn't go away when the plugin is closed.  So we cache\n    // it.\n    let storage = this._pluginStorage.get(pluginId);\n    if (!storage) {\n      storage = this._implementStorage();\n      this._pluginStorage.set(pluginId, storage);\n    }\n    rpc.registerImpl<Storage>(\"storage\", storage, checkers.Storage);\n  }\n\n  /**\n   * Create an implementation of the Storage interface.\n   */\n  private _implementStorage(): Storage {\n    const data = new Map<string, any>();\n    return {\n      getItem(key: string): any {\n        return data.get(key);\n      },\n      hasItem(key: string): boolean {\n        return data.has(key);\n      },\n      setItem(key: string, value: any) {\n        data.set(key, value);\n      },\n      removeItem(key: string) {\n        data.delete(key);\n      },\n      clear() {\n        data.clear();\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "app/client/components/Clipboard.css",
    "content": "/**\n * With some guidance from Lucidchart:\n * https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/\n */\ntextarea.copypaste {\n  position: absolute;\n  top: -100px;\n  left: 0;\n  width: 10px;\n  height: 10px;\n  font-size: 1;\n  z-index: -1;\n}\n"
  },
  {
    "path": "app/client/components/Clipboard.ts",
    "content": "/**\n * Clipboard component manages the copy/cut/paste events by capturing these events from the browser,\n * managing their state, and exposing an API to other components to get/set the data.\n *\n * Because of a lack of standardization of ClipboardEvents between browsers, the way Clipboard\n * captures the events is by creating a hidden textarea element that's always focused with some text\n * selected. Here is a good write-up of this:\n * https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/\n *\n * When ClipboardEvent is detected, Clipboard captures the event and calls the corresponding\n * copy/cut/paste/input command actions, which will get called on the appropriate component.\n *\n * Usage:\n *    Components need to register copy/cut/paste actions with command.js:\n *      .copy() should return @pasteObj (defined below).\n *      .paste(plainText, [cutSelection]) should take a plainText value and an optional cutSelection\n *      parameter which will specify the selection that should be cleared as part of paste.\n *      .input(char) should take a single input character and will be called when the user types a\n *      visible character (useful if component wants to interpret typing into a cell, for example).\n */\n\nimport { getHumanKey, isMac } from \"app/client/components/commands\";\nimport * as commands from \"app/client/components/commands\";\nimport { copyToClipboard, readDataFromClipboard } from \"app/client/lib/clipboardUtils\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { makePasteHtml, makePasteText, parsePasteHtml, PasteData } from \"app/client/lib/tableUtil\";\nimport { ShortcutKey, ShortcutKeyContent } from \"app/client/ui/ShortcutKey\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\nimport { isNonNullish } from \"app/common/gutil\";\nimport { tsvDecode } from \"app/common/tsvFormat\";\n\nimport { Disposable, dom, styled } from \"grainjs\";\n\nimport type { CopySelection } from \"app/client/components/CopySelection\";\nimport type { App } from \"app/client/ui/App\";\nimport type { DocAction } from \"app/common/DocActions\";\nimport type { TableData } from \"app/common/TableData\";\n\nconst t = makeT(\"Clipboard\");\n\n/**\n * Paste object that should be returned by implementation of `copy`.\n */\nexport interface PasteObj {\n  docName: string;\n  tableId: string;\n  data: TableData;\n  selection: CopySelection;\n  cutCallback?: CutCallback;\n}\n\nexport type CutCallback = () => DocAction | null;\n\nexport class Clipboard extends Disposable {\n  public static commands = {\n    contextMenuCopy(this: Clipboard) { this._doContextMenuCopy(); },\n    contextMenuCopyWithHeaders(this: Clipboard) { this._doContextMenuCopyWithHeaders(); },\n    contextMenuCut(this: Clipboard) { this._doContextMenuCut(); },\n    contextMenuPaste(this: Clipboard) { this._doContextMenuPaste().catch(reportError); },\n  };\n\n  /**\n   * Helper to determine if the currently active element deserves to keep its own focus, and capture copy-paste events.\n   *\n   * By default, focus is automatically allowed if:\n   *   - there is no clipboard commands registered,\n   *   - the element is an input, textarea, select or iframe,\n   *   - the element has a tabindex attribute[*]\n   *\n   * [*] tabindex automatic focus allowance can be bypassed by setting the 'ignore_tabindex' class on the element.\n   *   This is useful when fine-tuning keyboard behavior of specific components, where you might end up\n   *   needing to use tabindex but without wanting to interfere with the Clipboard or RegionFocusSwitcher.\n   *\n   * You can explicitly allow focus by setting different classes:\n   *   - using the 'clipboard_allow_focus' class will allow focusing the element having the class,\n   *   - using the 'clipboard_group_focus' class will allow focusing any descendant element of the one having the class\n   */\n  public static allowFocus(elem: Element): boolean {\n    if (!elem) {\n      return false;\n    }\n    if (elem.closest(\".clipboard_group_focus\") || elem.classList.contains(\"clipboard_allow_focus\")) {\n      return true;\n    }\n    if (elem.hasAttribute(\"tabindex\") && elem.classList.contains(\"ignore_tabindex\")) {\n      return false;\n    }\n    return FOCUS_TARGET_TAGS.has(elem.tagName) || elem.hasAttribute(\"tabindex\");\n  }\n\n  public readonly copypasteField: HTMLTextAreaElement;\n\n  // In the event of a cut a callback is provided by the viewsection that is the target of the cut.\n  // When called it returns the additional removal action needed for a cut.\n  private _cutCallback: (() => unknown) | null = null;\n\n  // The plaintext content of the cut callback. Used to verify that we are pasting the results\n  // of the cut, rather than new data from outside.\n  private _cutData: string | null = null;\n\n  constructor(private _app: App) {\n    super();\n    this.copypasteField = dom(\"textarea\", dom.cls(\"copypaste\"), dom.cls(\"mousetrap\"),\n      dom.on(\"input\", (event, elem) => {\n        const value = elem.value;\n        elem.value = \"\";\n        event.stopPropagation();\n        event.preventDefault();\n        commands.allCommands.input.run(value);\n      }),\n      dom.on(\"copy\", this._onCopy.bind(this)),\n      dom.on(\"cut\", this._onCut.bind(this)),\n      dom.on(\"paste\", this._onPaste.bind(this)),\n    );\n    document.body.appendChild(this.copypasteField);\n    this.onDispose(() => { dom.domDispose(this.copypasteField); this.copypasteField.remove(); });\n\n    FocusLayer.create(this, {\n      defaultFocusElem: this.copypasteField,\n      allowFocus: Clipboard.allowFocus,\n      onDefaultFocus: () => {\n        this.copypasteField.value = \" \";\n        this.copypasteField.select();\n        this._app.trigger(\"clipboard_focus\");\n      },\n      onDefaultBlur: () => {\n        this._app.trigger(\"clipboard_blur\");\n      },\n    });\n\n    // Expose the grabber as a global to allow upload from tests to explicitly restore focus\n    (window as any).gristClipboardGrabFocus = () => FocusLayer.grabFocus();\n\n    // Some bugs may prevent Clipboard from re-grabbing focus. To limit the impact of such bugs on\n    // the user, recover from a bad state in mousedown events. (At the moment of this comment, all\n    // such known bugs are fixed.)\n    dom.onElem(window, \"mousedown\", (ev) => {\n      if (!document.activeElement || document.activeElement === document.body) {\n        FocusLayer.grabFocus();\n      }\n    });\n\n    this.autoDispose(commands.createGroup(Clipboard.commands, this, true));\n  }\n\n  /**\n   * Internal helper fired on `copy` events. If a callback was registered from a component, calls the\n   * callback to get selection data and puts it on the clipboard.\n   */\n  private _onCopy(event: ClipboardEvent, elem: HTMLTextAreaElement) {\n    event.preventDefault();\n    const pasteObj = commands.allCommands.copy.run();\n    this._setCBdata(pasteObj, event.clipboardData!);\n  }\n\n  private _doContextMenuCopy() {\n    const pasteObj = commands.allCommands.copy.run();\n    void this._copyToClipboard(pasteObj, \"copy\", false);\n  }\n\n  private _doContextMenuCopyWithHeaders() {\n    const pasteObj = commands.allCommands.copy.run();\n    void this._copyToClipboard(pasteObj, \"copy\", true);\n  }\n\n  private _onCut(event: ClipboardEvent, elem: HTMLTextAreaElement) {\n    event.preventDefault();\n    const pasteObj = commands.allCommands.cut.run();\n    this._setCBdata(pasteObj, event.clipboardData!);\n  }\n\n  private _doContextMenuCut() {\n    const pasteObj: PasteObj = commands.allCommands.cut.run();\n    void this._copyToClipboard(pasteObj, \"cut\");\n  }\n\n  private _setCBdata(pasteObj: PasteObj, clipboardData: DataTransfer) {\n    if (!pasteObj) { return; }\n\n    const plainText = makePasteText(pasteObj.data, pasteObj.selection, false);\n    clipboardData.setData(\"text/plain\", plainText);\n    const htmlText = makePasteHtml(pasteObj.data, pasteObj.selection, false);\n    clipboardData.setData(\"text/html\", htmlText);\n\n    this._setCutCallback(pasteObj, plainText);\n  }\n\n  private async _copyToClipboard(pasteObj: PasteObj, action: \"cut\" | \"copy\", includeColHeaders: boolean = false) {\n    if (!pasteObj) { return; }\n\n    const plainText = makePasteText(pasteObj.data, pasteObj.selection, includeColHeaders);\n    let data;\n    if (typeof ClipboardItem === \"function\") {\n      const htmlText = makePasteHtml(pasteObj.data, pasteObj.selection, includeColHeaders);\n      data = new ClipboardItem({\n        \"text/plain\": new Blob([plainText], { type: \"text/plain\" }),\n        \"text/html\": new Blob([htmlText], { type: \"text/html\" }),\n      });\n    } else {\n      data = plainText;\n    }\n\n    try {\n      await copyToClipboard(data);\n    } catch {\n      showUnavailableMenuCommandModal(action);\n      return;\n    }\n\n    this._setCutCallback(pasteObj, plainText);\n  }\n\n  /**\n   * Sets the cut callback from the `pasteObj` if one exists. Otherwise clears the\n   * cut callback.\n   *\n   * The callback is called on paste, and only if the pasted data matches the `cutData`\n   * that was cut from within Grist. The callback handles removal of the data that was\n   * cut.\n   */\n  private _setCutCallback(pasteObj: PasteObj, cutData: string) {\n    if (pasteObj.cutCallback) {\n      this._cutCallback = pasteObj.cutCallback;\n      this._cutData = cutData;\n    } else {\n      this._cutCallback = null;\n      this._cutData = null;\n    }\n  }\n\n  /**\n   * Internal helper fired on `paste` events. If a callback was registered from a component, calls the\n   * callback with data from the clipboard.\n   */\n  private _onPaste(event: ClipboardEvent, elem: HTMLTextAreaElement) {\n    event.preventDefault();\n    const cb = event.clipboardData!;\n    const plainText = cb.getData(\"text/plain\");\n    const htmlText = cb.getData(\"text/html\");\n    // We process and filter cb.items because the promising-sounding cb.files may not be set for\n    // paste events, even when items include files.\n    // Note that on Firefox, this is limited to a single file due to an old Firefox bug:\n    // https://bugzilla.mozilla.org/show_bug.cgi?id=864052\n    const files = Array.from(cb.items, it => it.getAsFile()).filter(isNonNullish);\n    const pasteData = getPasteData(plainText, htmlText, files);\n    this._doPaste(pasteData, plainText);\n  }\n\n  private async _doContextMenuPaste() {\n    let clipboardItems: ClipboardItem[];\n    try {\n      clipboardItems = await readDataFromClipboard();\n    } catch {\n      showUnavailableMenuCommandModal(\"paste\");\n      return;\n    }\n    const plainText = await getTextFromClipboardItem(clipboardItems[0], \"text/plain\");\n    const htmlText = await getTextFromClipboardItem(clipboardItems[0], \"text/html\");\n    const files = await getFilesFromClipboardItems(clipboardItems);\n    const pasteData = getPasteData(plainText, htmlText, files);\n    this._doPaste(pasteData, plainText);\n  }\n\n  private _doPaste(pasteData: PasteData, plainText: string) {\n    if (this._cutData === plainText) {\n      if (this._cutCallback) {\n        // Cuts should only be possible on the first paste after a cut and only if the data being\n        // pasted matches the data that was cut.\n        commands.allCommands.paste.run(pasteData, this._cutCallback);\n      }\n    } else {\n      this._cutData = null;\n      commands.allCommands.paste.run(pasteData, null);\n    }\n    // The cut callback should only be usable once so it needs to be cleared after every paste.\n    this._cutCallback = null;\n  }\n}\n\nconst FOCUS_TARGET_TAGS = new Set([\n  \"INPUT\",\n  \"TEXTAREA\",\n  \"SELECT\",\n  \"IFRAME\",\n]);\n\n/**\n * Returns data formatted as a 2D array of strings, suitable for pasting within Grist.\n *\n * Grist stores both text/html and text/plain when copying data. When pasting back, we first\n * check if text/html exists (should exist for Grist and other spreadsheet software), and fall\n * back to text/plain otherwise.\n */\nfunction getPasteData(plainText: string, htmlText: string, fileItems: File[]): PasteData {\n  try {\n    return parsePasteHtml(htmlText);\n  } catch (e) {\n    const text = plainText.replace(/^\\uFEFF/, \"\");\n    if (text) {\n      return tsvDecode(plainText.replace(/\\r\\n?/g, \"\\n\").trimEnd());\n    }\n    if (fileItems.length > 0) {\n      return [[fileItems]];\n    }\n    return [[\"\"]];\n  }\n}\n\n/**\n * Returns clipboard data of the given `type` from `clipboardItem` as text.\n *\n * Returns an empty string if `clipboardItem` is nullish or no data exists\n * for the given `type`.\n */\nasync function getTextFromClipboardItem(clipboardItem: ClipboardItem | undefined, type: string) {\n  if (!clipboardItem) { return \"\"; }\n\n  try {\n    return (await clipboardItem.getType(type)).text();\n  } catch {\n    // No clipboard data exists for the MIME type.\n    return \"\";\n  }\n}\n\n/**\n * Returns a list of Blobs included among clipboardItems.\n */\nasync function getFilesFromClipboardItems(clipboardItems: ClipboardItem[]): Promise<File[]> {\n  const blobs = await Promise.all(\n    clipboardItems.map((item) => {\n      // Find a non-text mime type, which should indicate a file.\n      // Note that browsers may not support arbitrary files, but should support images on clipboard.\n      const mimeType = item.types.find(mtime => !mtime.startsWith(\"text/\"));\n      return mimeType ? item.getType(mimeType) : null;\n    })\n      .filter(isNonNullish),\n  );\n  return blobs.map(blob => new File([blob], \"from-clipboard\", { type: blob.type }));\n}\n\nfunction showUnavailableMenuCommandModal(action: \"cut\" | \"copy\" | \"paste\") {\n  let keys;\n  switch (action) {\n    case \"cut\": {\n      keys = \"Mod+X\";\n      break;\n    }\n    case \"copy\": {\n      keys = \"Mod+C\";\n      break;\n    }\n    case \"paste\": {\n      keys = \"Mod+V\";\n      break;\n    }\n    default: {\n      throw new Error(`Clipboard: unrecognized action ${action}`);\n    }\n  }\n\n  confirmModal(\n    t(\"Unavailable Command\"),\n    t(\"Got it\"),\n    () => {},\n    {\n      explanation: cssModalContent(\n        t(\n          \"The {{action}} menu command is not available in this browser. You can still {{action}} \\\nby using the keyboard shortcut {{shortcut}}.\",\n          {\n            action,\n            shortcut: ShortcutKey(ShortcutKeyContent(getHumanKey(keys, isMac))),\n          },\n        ),\n      ),\n      hideCancel: true,\n    },\n  );\n}\n\nconst cssModalContent = styled(\"div\", `\n  line-height: 18px;\n`);\n"
  },
  {
    "path": "app/client/components/CodeEditorPanel.css",
    "content": ".g-code-panel {\n  padding: 10px;\n  overflow: auto;\n}\n\n.g-code-viewer {\n  padding: 2rem 1rem;\n  font-family: monospace;\n  white-space: pre-wrap;\n  word-break: break-all;\n  word-wrap: break-word;\n}\n\n.g-code-viewer.hljs {\n  color: var(--grist-theme-code-view-text, #444);\n  background-color: inherit;\n}\n\n.g-code-panel-denied {\n  text-align: center;\n}\n\n.g-code-viewer .hljs-keyword {\n  color: var(--grist-theme-code-view-keyword, #444);\n}\n\n.g-code-viewer .hljs-comment {\n  color: var(--grist-theme-code-view-comment, #888888);\n}\n\n.g-code-viewer .hljs-meta {\n  color: var(--grist-theme-code-view-meta, #1F7199);\n}\n\n.g-code-viewer .hljs-title {\n  color: var(--grist-theme-code-view-title, #880000);\n}\n\n.g-code-viewer .hljs-params {\n  color: var(--grist-theme-code-view-params, #444);\n}\n\n.g-code-viewer .hljs-string {\n  color: var(--grist-theme-code-view-string, #880000);\n}\n\n.g-code-viewer .hljs-number {\n  color: var(--grist-theme-code-view-number, #880000);\n}\n\n.g-code-viewer .hljs-built_in {\n  color: var(--grist-theme-code-view-builtin, #397300);\n}\n\n.g-code-viewer .hljs-literal {\n  color: var(--grist-theme-code-view-literal, #78A960);\n}\n"
  },
  {
    "path": "app/client/components/CodeEditorPanel.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/errors\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\n\nimport { dom, Observable } from \"grainjs\";\n\n// Rather than require the whole of highlight.js, require just the core with the one language we\n// need, to keep our bundle smaller and the build faster.\n// eslint-disable-next-line @typescript-eslint/no-require-imports\nconst hljs           = require(\"highlight.js/lib/core\");\n// eslint-disable-next-line @typescript-eslint/no-require-imports\nhljs.registerLanguage(\"python\", require(\"highlight.js/lib/languages/python\"));\n\nconst t = makeT(\"CodeEditorPanel\");\n\nexport class CodeEditorPanel extends DisposableWithEvents {\n  private _code = Observable.create(this, \"\");\n  private _denied = Observable.create(this, false);\n  constructor(private _gristDoc: GristDoc) {\n    super();\n    this.listenTo(_gristDoc, \"schemaUpdateAction\", this._onSchemaUpdateAction.bind(this));\n    this._onSchemaUpdateAction().catch(reportError); // Fetch the code to initialize\n  }\n\n  public buildDom() {\n    // The tabIndex enables the element to gain focus, and the .clipboard class prevents the\n    // Clipboard module from re-grabbing it. This is a quick fix for the issue where clipboard\n    // interferes with text selection. TODO it should be possible for the Clipboard to never\n    // interfere with text selection even for un-focusable elements.\n    return dom(\"div.g-code-panel.clipboard\",\n      { tabIndex: \"-1\" },\n      dom.maybe(this._denied, () => dom(\"div.g-code-panel-denied\",\n        dom(\"h2\", dom.text(t(\"Access denied\"))),\n        dom(\"div\", dom.text(t(\"Code View is available only when you have full document access.\"))),\n      )),\n      dom.maybe(this._code, (code) => {\n        // The reason to scope and rebuild instead of using `kd.text(code)` is because\n        // hljs.highlightBlock(elem) replaces `elem` with a whole new dom tree.\n        const elem = dom(\"code.g-code-viewer\",\n          dom.text(code),\n          dom.hide(true),\n        );\n        setTimeout(() => {\n          hljs.highlightBlock(elem);\n          dom.showElem(elem, true);\n        });\n        return elem;\n      }),\n    );\n  }\n\n  private async _onSchemaUpdateAction() {\n    try {\n      const code = await this._gristDoc.docComm.fetchPythonCode();\n      if (!this.isDisposed()) {\n        this._code.set(code);\n        this._denied.set(false);\n      }\n    } catch (err) {\n      if (!String(err).match(/Cannot view code/)) {\n        throw err;\n      }\n      if (!this.isDisposed()) {\n        this._code.set(\"\");\n        this._denied.set(true);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/components/ColumnFilters.css",
    "content": "/* Hide column menus by default */\n.column_name .g-column-menu-btn {\n  visibility: hidden;\n}\n\n/* Make visible if open or in column header hover */\n.g-column-menu-btn.open,\n.g-column-menu-btn.active,\n.column_name:hover .g-column-menu-btn,\n.column_name .g-column-menu-btn.weasel-popup-open {\n  visibility: visible;\n}\n\n.g-column-menu-btn.hide-on-inactive:not(.active) {\n  visibility: hidden;\n}\n\n.g-column-menu {\n  position: absolute;\n  min-width: 180px;\n\n  z-index: 10;\n\n  padding: 4px;\n\n  background-color: #fff;\n  border: 1px solid #9D8BB5;\n  box-shadow: 0px 0px 12px #666;\n\n  text-align: left;\n}\n\n.g-column-filter-remove {\n  float: right;\n  margin: 6px 0;\n}\n\n.g-column-filter-keyword {\n  width: 100px;\n}\n\n.g-column-filter-menu {\n  margin: 6px;\n  min-width: 250px;\n}\n\n.grist-filter-menu__link {\n  cursor: pointer;\n}\n\n.g-colfilter-values-scrolly {\n  position: relative;\n  height: 200px;\n  overflow: auto;\n}\n\n.g-colfilter-menu-item {\n  padding: 1px 8px;\n  line-height: 1.6rem;\n\n  cursor: default;\n}\n\n.g-colfilter-menu-label {\n  margin-left: 4px;\n  margin-right: 4px;\n}\n\n.badge-inv {\n  background-color: #ddd;\n  color: #666;\n}\n\n.arrow_box {\n  position: absolute;\n  background: #fff;\n  border: 1px solid transparent;\n  border-top-color: #9D8BB5;\n  top: -1px;\n  left: 12px;\n}\n.arrow_box:after, .arrow_box:before {\n  bottom: 100%;\n  left: 50%;\n  border: solid transparent;\n  content: \" \";\n  height: 0;\n  width: 0;\n  position: absolute;\n  pointer-events: none;\n}\n\n.arrow_box:after {\n  border-color: rgba(255, 255, 255, 0);\n  border-bottom-color: #fff;\n  border-width: 8px;\n  margin-left: -8px;\n}\n.arrow_box:before {\n  border-color: rgba(43, 57, 255, 0);\n  border-bottom-color: #9D8BB5;\n  border-width: 9px;\n  margin-left: -9px;\n}\n"
  },
  {
    "path": "app/client/components/ColumnTransform.ts",
    "content": "/**\n * ColumnTransform is used as a abstract base class for any classes which must build a dom for the\n * purpose of allowing the user to transform a column. It is currently extended by FormulaTransform\n * and TypeTransform.\n */\nimport * as AceEditor from \"app/client/components/AceEditor\";\nimport * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { ColumnRec } from \"app/client/models/entities/ColumnRec\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { TableData } from \"app/client/models/TableData\";\nimport { FieldBuilder } from \"app/client/widgets/FieldBuilder\";\nimport { UserAction } from \"app/common/DocActions\";\nimport { GristObjCode } from \"app/plugin/GristData\";\n\nimport { Disposable, Observable } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport isPlainObject from \"lodash/isPlainObject\";\nimport noop from \"lodash/noop\";\n\n// To simplify diff (avoid rearranging methods to satisfy private/public order).\n/* eslint-disable @typescript-eslint/member-ordering */\n\ntype AceEditor = any;\n\n/**\n * Abstract class for FormulaTransform and TypeTransform to extend. Initializes properties needed\n * for both types of transform. optPureType is useful for initializing type transforms.\n */\nexport class ColumnTransform extends Disposable {\n  protected field: ViewFieldRec;\n  protected origColumn: ColumnRec;\n  protected origDisplayCol: ColumnRec;\n  protected transformColumn: ColumnRec;                 // Set in prepare()\n  protected origWidgetOptions: unknown;\n  protected isCallPending: ko.Observable<boolean>;\n  protected editor: AceEditor = null;              // Created when the dom is built by extending classes\n  protected formulaUpToDate = Observable.create(this, true);\n  protected _tableData: TableData;\n  protected rules: [GristObjCode.List, ...number[]] | null;\n\n  // Whether _doFinalize should execute the transform, or cancel it.\n  protected _shouldExecute: boolean = false;\n\n  // Ask DocData to finalize the action bundle by calling the finalize callback provided to\n  // startBundlingActions. Finalizing should always be triggered this way, for a uniform flow,\n  // since finalizing could be triggered either from DocData or from cancel/execute methods.\n  // This is a noop until startBundlingActions is called.\n  private _triggerFinalize: (() => void) = noop;\n\n  // This is set to true once finalize has started.\n  private _isFinalizing: boolean = false;\n\n  constructor(protected gristDoc: GristDoc, private _fieldBuilder: FieldBuilder) {\n    super();\n    this.field = _fieldBuilder.field;\n    this.origColumn = this.field.column();\n    this.origDisplayCol = this.field.displayColModel();\n    this.origWidgetOptions = this.field.widgetOptionsJson();\n    this.rules = this.origColumn.rules();\n    this.isCallPending = _fieldBuilder.isCallPending;\n\n    this._tableData = gristDoc.docData.getTable(this.origColumn.table().tableId())!;\n\n    this.autoDispose(commands.createGroup({\n      undo: this.cancel,\n      redo: noop,\n    }, this, true));\n\n    this.onDispose(() => {\n      this._setTransforming(false);\n      this._fieldBuilder.columnTransform = null;\n      this.isCallPending(false);\n    });\n  }\n\n  /**\n   * Build dom function should be implemented by extending classes.\n   */\n  public buildDom() {\n    throw new Error(\"Not Implemented\");\n  }\n\n  public async finalize(): Promise<void> {\n    return this._triggerFinalize();\n  }\n\n  /**\n   * Build general transform editor dom.\n   * @param {String} optInit - Optional initial value for the editor.\n   */\n  protected buildEditorDom(optInit?: string) {\n    if (!this.editor) {\n      this.editor = this.autoDispose(AceEditor.create({\n        observable: this.transformColumn.formula,\n        saveValueOnBlurEvent: false,\n        // TODO: set `getSuggestions` (see `FormulaEditor.ts` for an example).\n      }));\n    }\n    return this.editor.buildDom((aceObj: any) => {\n      aceObj.setOptions({ placeholder: \"Enter formula.\" });\n      aceObj.setHighlightActiveLine(false);\n      this.editor.adjustContentToWidth();\n      this.editor.attachSaveCommand();\n      aceObj.on(\"change\", () => {\n        if (this.editor) {\n          this.formulaUpToDate.set(this.editor.getValue() === this.transformColumn.formula());\n        }\n      });\n      aceObj.focus();\n    });\n  }\n\n  /**\n   * Helper called by constructor to prepare the column transform.\n   * @param {String} colType: A pure or complete type for the transformed column.\n   */\n  public async prepare(optColType?: string) {\n    const colType: string = optColType || this.origColumn.type.peek();\n\n    // Start bundling all actions during the transform. The verification callback ensures\n    // no errant actions are added to the bundle; if there are, finalize is immediately called.\n    const bundlingInfo = this._tableData.docData.startBundlingActions({\n      description: `Transformed column ${this.origColumn.colId()}.`,\n      shouldIncludeInBundle: this._shouldIncludeInBundle.bind(this),\n      prepare: this._doPrepare.bind(this, colType),\n      finalize: this._doFinalize.bind(this),\n    });\n\n    // triggerFinalize tells DocData to call the finalize callback we passed above; this way\n    // DocData knows when it's finished.\n    this._triggerFinalize = bundlingInfo.triggerFinalize;\n\n    // preparePromise resolves once prepare() callback has got a chance to run and finish.\n    await bundlingInfo.preparePromise;\n  }\n\n  private async _doPrepare(colType: string) {\n    if (this.isDisposed()) { return; }\n    this.isCallPending(true);\n    try {\n      const newColRef = await this.addTransformColumn(colType);\n      // Set DocModel references\n      this.field.colRef(newColRef);\n      this.transformColumn = this.field.column();\n      this.transformColumn.origColRef(this.origColumn.getRowId());\n      this._setTransforming(true);\n      return this.postAddTransformColumn();\n    } finally {\n      this.isCallPending(false);\n    }\n  }\n\n  private _shouldIncludeInBundle(actions: UserAction[]) {\n    // Allow certain expected actions. If we encounter anything else, the user must have\n    // started doing something else, and we should finalize the transform.\n    return actions.every(action => (\n      // ['AddColumn', USER_TABLE, 'gristHelper_Transform', colInfo]\n      (action[2]?.toString().startsWith(\"gristHelper_Transform\")) ||\n      // ['AddColumn', USER_TABLE, 'gristHelper_Converted', colInfo]\n      (action[2]?.toString().startsWith(\"gristHelper_Converted\")) ||\n      // ['ConvertFromColumn', USER_TABLE, SOURCE_COLUMN, 'gristHelper_Converted']\n      (action[3]?.toString().startsWith(\"gristHelper_Converted\")) ||\n      // [\"SetDisplayFormula\", USER_TABLE, ...]\n      (action[0] === \"SetDisplayFormula\") ||\n      // ['UpdateRecord', '_grist_Table_column', transformColId, ...]\n      (action[1] === \"_grist_Tables_column\") ||\n      // ['UpdateRecord', '_grist_Views_section_field', transformColId, ...] (e.g. resize)\n      (action[1] === \"_grist_Views_section_field\")\n    ));\n  }\n\n  /**\n   * Adds the transform column and returns its colRef. May be overridden by derived classes to create\n   * differently-prepared transform columns.\n   * @param {String} colType: A pure or complete type for the transformed column.\n   */\n  protected async addTransformColumn(colType: string): Promise<number> {\n    // Retrieve widget options on prepare (useful for type transforms)\n    const newColInfo = await this._tableData.sendTableAction([\"AddColumn\", \"gristHelper_Transform\", {\n      type: colType,\n      isFormula: true,\n      formula: this.getIdentityFormula(),\n      ...(this.origWidgetOptions ? { widgetOptions: JSON.stringify(this.origWidgetOptions) } : {}),\n    }]);\n    if (this.rules) {\n      // We are in bundle, it is safe to just send another action.\n      // NOTE: We could add rules with AddColumn action, but there are some optimizations that converts array values.\n      await this.gristDoc.docData.sendActions([\n        [\"UpdateRecord\", \"_grist_Tables_column\", newColInfo.colRef, { rules: this.rules }],\n      ]);\n    }\n\n    return newColInfo.colRef;\n  }\n\n  /**\n   * A derived class can override to do some processing after this.transformColumn has been set.\n   */\n  protected postAddTransformColumn(): void {\n    // Nothing in base class.\n  }\n\n  public async cancel(): Promise<void> {\n    this._shouldExecute = false;\n    return this._triggerFinalize();\n  }\n\n  protected async execute(): Promise<void> {\n    this._shouldExecute = true;\n    return this._triggerFinalize();\n  }\n\n  // This is passed as a callback to startBundlingActions(), and should NOT be called directly.\n  // Instead, call _triggerFinalize() is used to trigger it.\n  private async _doFinalize(): Promise<void> {\n    if (this.isDisposed() || this._isFinalizing) {\n      return;\n    }\n    this._isFinalizing = true;\n\n    // Define variables used after await, since this will be disposed by then.\n    const transformColId = this.transformColumn.colId();\n    const field = this.field;\n    const origRef = this.origColumn.getRowId();\n    const tableData = this._tableData;\n    this.isCallPending(true);\n    try {\n      if (this._shouldExecute) {\n        // TODO: Values flicker during executing since transform column remains a formula as values are copied\n        // back to the original column. The CopyFromColumn useraction really ought to be \"CopyAndRemove\" since\n        // that seems the best way to avoid calculating the formula on wrong values.\n        await this.gristDoc.docData.sendActions(this.executeActions());\n      }\n    } finally {\n      // Wait until the change completed to set column back, to avoid value flickering.\n      field.colRef(origRef);\n      const cleanupProm = tableData.sendTableAction([\"RemoveColumn\", transformColId]);\n      this.cleanup();\n      this.dispose();\n      await cleanupProm;\n    }\n  }\n\n  /**\n   * The user actions to send when actually executing the transform.\n   */\n  protected executeActions(): UserAction[] {\n    const newWidgetOptions = isPlainObject(this.origWidgetOptions) ?\n      { ...this.origWidgetOptions as object, ...this._fieldBuilder.options.peek() } :\n      this._fieldBuilder.options.peek();\n    return [\n      ...this.previewActions(),\n      [\n        \"CopyFromColumn\",\n        this._tableData.tableId,\n        this.transformColumn.colId.peek(),\n        this.origColumn.colId.peek(),\n        // Get the options from builder rather the transforming columns.\n        // Those options are supposed to be set by prepTransformColInfo(TypeTransform) and\n        // adjusted by client.\n        // TODO: is this really needed? Aren't those options already in the data-engine?\n        JSON.stringify(newWidgetOptions),\n      ],\n    ];\n  }\n\n  protected cleanup() {\n    // For overriding\n  }\n\n  protected getIdentityFormula() {\n    return \"return $\" + this.origColumn.colId();\n  }\n\n  protected _setTransforming(bool: boolean) {\n    this.origColumn.isTransforming(bool);\n    this.transformColumn.isTransforming(bool);\n  }\n\n  protected isFinalizing(): boolean {\n    return this._isFinalizing;\n  }\n\n  protected preview() {\n    if (!this.editor) { return; }\n    return this.editor.writeObservable();\n  }\n\n  /**\n   * Generates final actions before executing the transform. Used only when the editor was created.\n   */\n  protected previewActions(): UserAction[] {\n    if (!this.editor) { return []; }\n    const formula = this.editor.getValue();\n    const oldFormula = this.transformColumn.formula();\n    if (formula === oldFormula) { return []; }\n    if (!formula && !oldFormula) { return []; }\n    return [\n      [\"UpdateRecord\", \"_grist_Tables_column\", this.transformColumn.getRowId(), { formula }],\n    ];\n  }\n}\n"
  },
  {
    "path": "app/client/components/Comm.ts",
    "content": "/**\n * The Comm object in this module implements communication with the server. We\n * communicate via request-response calls, and also receive async messages from\n * the server.\n *\n * In this implementation, a single WebSocket is used for both purposes.\n *\n * Calls to the server:\n *    Call a method of the Comm object. The return value is a promise which will\n *    be fulfilled with the data object of the response, or rejected with\n *    an error object.\n *\n * Async messages from the server:\n *    Listen to Comm for events documented below.\n *\n *\n * Implementation\n * --------------\n * Messages are serialized as JSON using types CommRequest, CommResponse, CommResponseError for\n * method calls, and CommMessage for async messages from the server. These are all defined in\n * app/common/CommTypes. Note that this is a matter between the client's and the server's\n * communication libraries, and code outside of them should not rely on these details.\n */\n\nimport { GristWSConnection } from \"app/client/components/GristWSConnection\";\nimport * as dispose from \"app/client/lib/dispose\";\nimport * as log from \"app/client/lib/log\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { CommRequest, CommResponse, CommResponseBase, CommResponseError, ValidEvent } from \"app/common/CommTypes\";\nimport { UserAction } from \"app/common/DocActions\";\nimport { DocListAPI, OpenDocOptions, OpenLocalDocResult } from \"app/common/DocListAPI\";\nimport { GristServerAPI } from \"app/common/GristServerAPI\";\nimport { getInitialDocAssignment } from \"app/common/urlUtils\";\n\nimport { Events as BackboneEvents } from \"backbone\";\n\n/**\n * A request that is currently being processed.\n */\nexport interface CommRequestInFlight {\n  resolve: (result: unknown) => void;\n  reject: (err: Error) => void;\n  // clientId is non-null for those requests which should not be re-sent on reconnect if\n  // the clientId has changed; it is null when it's safe to re-send.\n  clientId: string | null;\n  docId: string | null;\n  methodName: string;\n  requestMsg: string;\n  sent: boolean;\n}\n\nfunction isCommResponseError(msg: CommResponse | CommResponseError): msg is CommResponseError {\n  return Boolean(msg.error);\n}\n\n/**\n * Comm object provides the interfaces to communicate with the server.\n * Each method that calls to the server returns a promise for the response.\n */\nexport class Comm extends dispose.Disposable implements GristServerAPI, DocListAPI {\n  // methods defined by GristServerAPI\n  public logout = this._wrapMethod(\"logout\");\n  public updateProfile = this._wrapMethod(\"updateProfile\");\n  public getDocList = this._wrapMethod(\"getDocList\");\n  public createNewDoc = this._wrapMethod(\"createNewDoc\");\n  public importSampleDoc = this._wrapMethod(\"importSampleDoc\");\n  public importDoc = this._wrapMethod(\"importDoc\");\n  public deleteDoc = this._wrapMethod(\"deleteDoc\");\n  // openDoc has special definition below\n  public renameDoc = this._wrapMethod(\"renameDoc\");\n  public getConfig = this._wrapMethod(\"getConfig\");\n  public updateConfig = this._wrapMethod(\"updateConfig\");\n  public showItemInFolder = this._wrapMethod(\"showItemInFolder\");\n  public getBasketTables = this._wrapMethod(\"getBasketTables\");\n  public embedTable = this._wrapMethod(\"embedTable\");\n  public reloadPlugins = this._wrapMethod(\"reloadPlugins\");\n\n  public pendingRequests: Map<number, CommRequestInFlight>;\n  public nextRequestNumber: number = 0;\n\n  protected listenTo: BackboneEvents[\"listenTo\"];            // set by Backbone\n  protected trigger: BackboneEvents[\"trigger\"];              // set by Backbone\n  protected stopListening: BackboneEvents[\"stopListening\"];  // set by Backbone\n\n  // This is a map from docId to the connection for the server that manages\n  // that docId.  In classic Grist, which doesn't have fixed docIds or multiple\n  // servers, the key is always \"null\".\n  private _connections = new Map<string | null, GristWSConnection>();\n  private _collectedUserActions: UserAction[] | null;\n  private _singleWorkerMode: boolean = getInitialDocAssignment() === null;  // is this classic Grist?\n  private _reportError?: (err: Error) => void;  // optional callback for errors\n\n  public create(reportError?: (err: Error) => void) {\n    this._reportError = reportError;\n    this.autoDisposeCallback(() => {\n      for (const connection of this._connections.values()) { connection.dispose(); }\n      this._connections.clear();\n    });\n    this.pendingRequests = new Map();\n    this.nextRequestNumber = 0;\n\n    // If collecting is turned on (by tests), this will be a list of UserActions sent to the server.\n    this._collectedUserActions = null;\n  }\n\n  /**\n   * Initialize a connection.  For classic Grist, with a single server\n   * and mutable document identifiers, we will only ever have one\n   * connection, shared for all uses.  For hosted Grist, with\n   * permanent docIds which map to potentially distinct servers, we\n   * have one connection per document.\n   *\n   * For classic grist, the docId passed here has no effect, and can\n   * be null.  For hosted Grist, if the docId is null, the id will be\n   * read from the configuration object sent by the server.  This\n   * allows the Comm object to be initialized at the same stage as\n   * it has been classically, eliminating a source of changes in timing\n   * that could effect old tests.\n   */\n  public initialize(docId: string | null): GristWSConnection {\n    docId = docId || getInitialDocAssignment();\n    let connection = this._connections.get(docId);\n    if (connection) { return connection; }\n    connection = GristWSConnection.create(null);\n    this._connections.set(docId, connection);\n    this.listenTo(connection, \"serverMessage\", this._onServerMessage.bind(this, docId));\n    this.listenTo(connection, \"connectionStatus\", (message: any, status: any) => {\n      this.trigger(\"connectionStatus\", message, status);\n    });\n    this.listenTo(connection, \"connectState\", () => {\n      const isConnected = [...this._connections.values()].some(c => c.established);\n      this.trigger(\"connectState\", isConnected);\n    });\n\n    connection.initialize(docId);\n    return connection;\n  }\n\n  // Returns a map of docId -> docWorkerUrl for existing connections, for testing.\n  public listConnections(): Map<string | null, string | null> {\n    return new Map(Array.from(this._connections, ([docId, conn]) => [docId, conn.getDocWorkerUrlOrNull()]));\n  }\n\n  /**\n   * The openDoc method is special, in that it is the first point at which\n   * we commit to a particular document.  It is also the only method not\n   * committed to a document that is called in hosted Grist - all other methods\n   * are called via DocComm.\n   */\n  public async openDoc(docName: string, options?: OpenDocOptions): Promise<OpenLocalDocResult> {\n    return this._makeRequest(null, docName, \"openDoc\", docName, options);\n  }\n\n  /**\n   * Ensure we have a connection to a docWorker serving docId, and mark it as in use by\n   * incrementing its useCount. This connection will not be disposed until a corresponding\n   * releaseDocConnection() is called.\n   */\n  public useDocConnection(docId: string): GristWSConnection {\n    const connection = this._connection(docId);\n    connection.useCount += 1;\n    log.debug(`Comm.useDocConnection(${docId}): useCount now ${connection.useCount}`);\n    return connection;\n  }\n\n  /**\n   * Remove a connection associated with a particular document. In classic grist, we skip removal,\n   * since all docs use the same server.\n   * This should be called in pair with a preceding useDocConnection() call. It decrements the\n   * connection's useCount, and disposes it when it's no longer in use.\n   */\n  public releaseDocConnection(docId: string): void {\n    const connection = this._connections.get(docId);\n    if (connection) {\n      connection.useCount -= 1;\n      log.debug(`Comm.releaseDocConnection(${docId}): useCount now ${connection.useCount}`);\n      // Dispose the connection if it is no longer in use (except in \"classic grist\").\n      if (!this._singleWorkerMode && connection.useCount <= 0) {\n        this.stopListening(connection);\n        connection.dispose();\n        this._connections.delete(docId);\n        this._rejectRequests(docId);\n      }\n    }\n  }\n\n  /**\n   * Starts or stops the collection of UserActions.\n   */\n  public userActionsCollect(optYesNo?: boolean): void {\n    this._collectedUserActions = optYesNo === false ? null : [];\n  }\n\n  /**\n   * Returns all UserActions collected since collection started or since previous call.\n   */\n  public userActionsFetchAndReset(): UserAction[] {\n    return this._collectedUserActions ? this._collectedUserActions.splice(0) : [];\n  }\n\n  /**\n   * Add UserActions to a list, for use in tests. Called by DocComm.\n   */\n  public addUserActions(actions: UserAction[]) {\n    // Note: collecting user-actions for testing is in Comm mainly for historical reasons.\n    if (this._collectedUserActions) {\n      this._collectedUserActions.push(...actions);\n    }\n  }\n\n  /**\n   * Returns a url to the worker serving the specified document.\n   */\n  public getDocWorkerUrl(docId: string | null): string {\n    return this._connection(docId).docWorkerUrl;\n  }\n\n  /**\n   * Returns true if there is one or more request that has not been fully processed.\n   */\n  public hasActiveRequests(): boolean {\n    return this.pendingRequests.size !== 0;\n  }\n\n  /**\n   * Internal implementation of all the server methods. They differ only in the name of the server\n   * method to call, and the arguments that it expects.\n   *\n   * This is made public for DocComm's use. Regular code should not call _makeRequest directly.\n   *\n   * @param {String} clientId - If non-null, we ensure that it matches the current clientId,\n   *    rejecting the call otherwise. It should be bound to the session's clientId for\n   *    session-specific calls, so that we can't send requests to the wrong session. See openDoc().\n   * @param {String} methodName - The name of the server method to call.\n   * @param {...} varArgs - Other method-specific arguments to send to the server.\n   * @returns {Promise} Promise for the response. The server may fulfill or reject it, or it may be\n   *    rejected in case of a disconnect.\n   */\n  public async _makeRequest(clientId: string | null, docId: string | null,\n    methodName: string, ...args: any[]): Promise<any> {\n    const connection = this._connection(docId);\n    if (clientId !== null && clientId !== connection.clientId) {\n      log.warn(\"Comm: Rejecting \" + methodName + \" for outdated clientId %s (current %s)\",\n        clientId, connection.clientId);\n      return Promise.reject(new Error(\"Comm: outdated session\"));\n    }\n    const request: CommRequest = {\n      reqId: this.nextRequestNumber++,\n      method: methodName,\n      args,\n    };\n    log.debug(\"Comm request #\" + request.reqId + \" \" + methodName, request.args);\n    return new Promise((resolve, reject) => {\n      const requestMsg = JSON.stringify(request);\n      const sent = connection.send(requestMsg);\n      this.pendingRequests.set(request.reqId, {\n        resolve,\n        reject,\n        clientId,\n        docId,\n        methodName,\n        requestMsg,\n        sent,\n      });\n    });\n  }\n\n  /**\n   * Create a connection to the specified document, or return an already open connection\n   * that that document.  For a docId of null, any open connection will be returned, and\n   * an error is thrown if no connection is already open.\n   */\n  private _connection(docId: string | null): GristWSConnection {\n    // for classic Grist, \"docIds\" are untrustworthy doc names, but on the plus side\n    // we only need one connections - so just replace docId with a constant.\n    if (this._singleWorkerMode) { docId = null; }\n    if (docId === null) {\n      if (this._connections.size > 0) {\n        return this._connections.values().next().value;\n      }\n      throw new Error(\"no connection available\");\n    }\n    const connection = this._connections.get(docId);\n    if (!connection) {\n      return this.initialize(docId);\n    }\n    return connection;\n  }\n\n  /**\n   * If GristWSConnection for a docId is disposed, requests that were sent to that doc will never\n   * resolve. Reject them instead here.\n   */\n  private _rejectRequests(docId: string | null) {\n    const error = \"GristWSConnection disposed\";\n    for (const [reqId, req] of this.pendingRequests) {\n      if (reqMatchesConnection(req.docId, docId)) {\n        log.warn(`Comm: Rejecting req #${reqId} ${req.methodName}: ${error}`);\n        this.pendingRequests.delete(reqId);\n        req.reject(new Error(\"Comm: \" + error));\n      }\n    }\n  }\n\n  /**\n   *\n   * This module automatically logs any errors to the console, so callers an provide an empty\n   * error-handling function if logging is all they need on error.\n   *\n   * We should watch timeouts, and log something when there is no response for a while.\n   *    There is probably no need for callers to deal with timeouts.\n   */\n  private _onServerMessage(docId: string | null, message: CommResponseBase) {\n    if (\"reqId\" in message) {\n      const reqId = message.reqId;\n      const r = this.pendingRequests.get(reqId);\n      if (r) {\n        try {\n          if (\"errorCode\" in message && message.errorCode === \"AUTH_NO_VIEW\") {\n            // We should only arrive here if the user had view access, and then lost it.\n            // We should not let the user see the document any more.  Let's reload the\n            // page, reducing this to the problem of arriving at a document the user\n            // doesn't have access to, which is already handled.\n            log.warn(`Comm response #${reqId} ${r.methodName} issued AUTH_NO_VIEW - closing`);\n            window.location.reload();\n          }\n          if (isCommResponseError(message)) {\n            let err: any = new Error(message.error);\n            if (message.status) {\n              // Change type of error to be consistent with REST API calls.\n              err = new ApiError(message.error, message.status, message.details);\n            }\n            let code = \"\";\n            if (message.errorCode) {\n              code = ` [${message.errorCode}]`;\n              err.code = message.errorCode;\n            }\n            if (message.details) {\n              err.details = message.details;\n            }\n            if (message.error?.startsWith(\"[Sandbox] UniqueReferenceError\")) {\n              err.code = \"UNIQUE_REFERENCE_VIOLATION\";\n            }\n            err.shouldFork = message.shouldFork;\n            log.warn(`Comm response #${reqId} ${r.methodName} ERROR:${code} ${message.error}` +\n              (message.shouldFork ? ` (should fork)` : \"\"));\n            this._reportError?.(err);\n            r.reject(err);\n          } else {\n            log.debug(`Comm response #${reqId} ${r.methodName} OK`);\n            r.resolve(message.data);\n          }\n        } finally {\n          this.pendingRequests.delete(reqId);\n        }\n      } else {\n        log.warn(\"Comm: Response to unknown reqId \" + reqId);\n      }\n    } else {\n      if (message.type === \"clientConnect\") {\n        // Reject or re-send any pending requests as appropriate in the order in which they were\n        // added to the pendingRequests map.\n        for (const [id, req] of this.pendingRequests) {\n          if (reqMatchesConnection(req.docId, docId)) {\n            this._resendPendingRequest(id, req);\n          }\n        }\n      }\n\n      // Another asynchronous message that's not a response. Broadcast it as an event.\n      if (ValidEvent.guard(message.type)) {\n        log.debug(\"Comm: Triggering event \" + message.type);\n        this.trigger(message.type, message);\n      } else {\n        log.warn(\"Comm: Server message of unknown type \" + message.type);\n      }\n    }\n  }\n\n  private _resendPendingRequest(reqId: number, r: CommRequestInFlight) {\n    let error = null;\n    const connection = this._connection(r.docId);\n    if (r.sent) {\n      // If we sent a request, and reconnected before getting a response, we don't know what\n      // happened. The safer choice is to reject the request.\n      error = \"interrupted by reconnect\";\n    } else if (r.clientId !== null && r.clientId !== connection.clientId) {\n      // If we are waiting to send this request for a particular clientId, but clientId changed.\n      error = \"pending with outdated clientId\";\n    } else {\n      // Waiting to send the request, and clientId is fine: go ahead and send it.\n      r.sent = connection.send(r.requestMsg);\n    }\n    if (error) {\n      log.warn(\"Comm: Rejecting req #\" + reqId + \" \" + r.methodName + \": \" + error);\n      r.reject(new Error(\"Comm: \" + error));\n      this.pendingRequests.delete(reqId);\n    }\n  }\n\n  private _wrapMethod<Name extends keyof GristServerAPI>(name: Name): GristServerAPI[Name] {\n    return this._makeRequest.bind(this, null, null, name);\n  }\n}\n\nObject.assign(Comm.prototype, BackboneEvents);\n\nfunction reqMatchesConnection(reqDocId: string | null, connDocId: string | null) {\n  return reqDocId === connDocId || !reqDocId || !connDocId;\n}\n"
  },
  {
    "path": "app/client/components/CopySelection.ts",
    "content": "import type { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport type { CellValue } from \"app/common/DocActions\";\nimport type { TableData } from \"app/common/TableData\";\nimport type { UIRowId } from \"app/plugin/GristAPI\";\n\n/**\n * The CopySelection class is an abstraction for a subset of currently selected cells.\n * @param {Array} rowIds - row ids of the rows selected\n * @param {Array} fields - MetaRowModels of the selected view fields\n * @param {Object} options.rowStyle - an object that maps rowId to an object containing\n * style options. i.e. { 1: { height: 20px } }\n * @param {Object} options.colStyle - an object that maps colId to an object containing\n * style options.\n */\nexport class CopySelection {\n  public readonly colIds = this.fields.map(f => f.colId());\n  public readonly colRefs = this.fields.map(f => f.colRef());\n  public readonly displayColIds = this.fields.map(f => f.displayColModel().colId());\n  public readonly rowStyle: { [r: number]: object } | undefined;\n  public readonly colStyle: { [c: string]: object } | undefined;\n\n  public readonly columns: {\n    colId: string,\n    fmtGetter: (rowId: UIRowId) => string,\n    rawGetter: (rowId: UIRowId) => CellValue | undefined,\n  }[];\n\n  constructor(tableData: TableData, public readonly rowIds: UIRowId[], public readonly fields: ViewFieldRec[],\n    options: {\n      rowStyle?: { [r: number]: object },\n      colStyle?: { [c: string]: object },\n    },\n  ) {\n    this.rowStyle = options.rowStyle;\n    this.colStyle = options.colStyle;\n    this.columns = fields.map((f, i) => {\n      const formatter = f.formatter();\n      const _fmtGetter = tableData.getRowPropFunc(this.displayColIds[i]);\n      const _rawGetter = tableData.getRowPropFunc(this.colIds[i]);\n\n      return {\n        colId: this.colIds[i],\n        fmtGetter: rowId => formatter.formatAny(_fmtGetter(rowId)),\n        rawGetter: rowId => _rawGetter(rowId),\n      };\n    });\n  }\n\n  public isCellSelected(rowId: UIRowId, colId: string): boolean {\n    return this.rowIds.includes(rowId) && this.colIds.includes(colId);\n  }\n\n  public onlyAddRowSelected(): boolean {\n    return this.rowIds.length === 1 && this.rowIds[0] === \"new\";\n  }\n}\n"
  },
  {
    "path": "app/client/components/CoreBanners.ts",
    "content": "import { ExternalAttachmentBanner } from \"app/client/components/ExternalAttachmentBanner\";\nimport { VersionUpdateBanner } from \"app/client/components/VersionUpdateBanner\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\n\nimport { dom } from \"grainjs\";\n\nexport function buildHomeBanners(app: AppModel) {\n  return dom.create(VersionUpdateBanner, app);\n}\n\nexport function buildDocumentBanners(docPageModel: DocPageModel) {\n  return [\n    dom.create(VersionUpdateBanner, docPageModel.appModel),\n    dom.create(ExternalAttachmentBanner, docPageModel),\n  ];\n}\n"
  },
  {
    "path": "app/client/components/Cursor.ts",
    "content": "/**\n * The Cursor module contains functionality related to the cell with the cursor, i.e. a single\n * currently selected cell.\n */\n\nimport BaseView from \"app/client/components/BaseView\";\nimport * as commands from \"app/client/components/commands\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { LazyArrayModel } from \"app/client/models/DataTableModel\";\nimport { CursorPos, UIRowId } from \"app/plugin/GristAPI\";\n\nimport { Disposable } from \"grainjs\";\nimport * as ko from \"knockout\";\n\nfunction nullAsUndefined<T>(value: T | null | undefined): T | undefined {\n  return value == null ? undefined : value;\n}\n\n// ================ SequenceNum: used to keep track of cursor edits (lastEditedAt)\n// Basically just a global auto-incrementing counter, with some types to make intent more clear\n// Cursors are constructed at SequenceNEVER (0). After that, changes to their sequenceNum will go through\n// NextSequenceNum(), so they'll have unique, monotonically increasing numbers for their lastEditedAt()\n// NOTE: (by the time the page loads they'll already be at nonzero numbers, the never is intended to be transient)\nexport type SequenceNum = number;\nexport const SequenceNEVER: SequenceNum = 0; // Cursors will start here\nlet latestGlobalSequenceNum = SequenceNEVER;\nfunction nextSequenceNum() { // First call to this func should return 1\n  latestGlobalSequenceNum++;\n  return latestGlobalSequenceNum;\n}\n\n// NOTE: If latestGlobalSequenceNum overflows, I think it would stop incrementing because of floating point imprecision\n// However, we don't need to worry about overflow because:\n//   - Number.MAX_SAFE_INTEGER is 9,007,199,254,740,991 (9 * 10^15)\n//   - even at 1000 cursor-edits per second, it would take ~300,000 yrs to overflow\n//   - Plus it's client-side, so that's a single continuous 300-millenia-long session, which would be impressive uptime\n\n/**\n * Cursor represents the location of the cursor in the viewsection. It is maintained by BaseView,\n * and implements the shared functionality related to the cursor cell.\n * @param {BaseView} baseView: The BaseView object to which this Cursor belongs.\n * @param {Object} optCursorPos: Optional object containing rowId and fieldIndex properties\n *  to which the cursor should be initialized.\n */\nexport class Cursor extends Disposable {\n  /**\n   * The commands closely tied to the cursor. They are active when the BaseView containing this\n   * Cursor has focus. Some may need to be overridden by particular views.\n   */\n  public static editorCommands = {\n    // The cursor up/down commands may need to be a bit different in non-grid views.\n    cursorUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 1); },\n    cursorDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 1); },\n    cursorLeft(this: Cursor) { this.fieldIndex(this.fieldIndex() - 1); },\n    cursorRight(this: Cursor) { this.fieldIndex(this.fieldIndex() + 1); },\n    skipUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 5); },\n    skipDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 5); },\n    pageUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 20); },    // TODO Not really pageUp\n    pageDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 20); },  // TODO Not really pageDown\n    prevField(this: Cursor) { this.fieldIndex(this.fieldIndex() - 1); },\n    nextField(this: Cursor) { this.fieldIndex(this.fieldIndex() + 1); },\n    moveToFirstRecord(this: Cursor) { this.rowIndex(0); },\n    moveToLastRecord(this: Cursor) { this.rowIndex(Infinity); },\n    moveToFirstField(this: Cursor) { this.fieldIndex(0); },\n    moveToLastField(this: Cursor) { this.fieldIndex(Infinity); },\n  };\n\n  public viewData: LazyArrayModel<DataRowModel>;\n  // observable with current cursor position\n  public currentPosition: ko.Computed<CursorPos>;\n\n  public rowIndex: ko.Computed<number | null>;     // May be null when there are no rows.\n  public fieldIndex: ko.Observable<number>;\n\n  public rowId: ko.Observable<UIRowId | null>;     // May be null when there are no rows.\n\n  // The cursor's _rowId property is always fixed across data changes. When isLive is true,\n  // the rowIndex of the cursor is recalculated to match _rowId. When false, they will\n  // be out of sync.\n  private _isLive: ko.Observable<boolean> = ko.observable(true);\n  private _sectionId: ko.Computed<number>;\n\n  private _properRowId: ko.Computed<UIRowId | null>;\n\n  // lastEditedAt is updated on _properRowId or fieldIndex update (including through setCursorPos)\n  // Used to determine which section takes priority for cursorLinking (specifically cycles/bidirectional linking)\n  private _lastEditedAt: ko.Observable<SequenceNum>;\n  // _silentUpdatesFlag prevents lastEditedAt from being updated, when a change in cursorPos isn't driven by the user.\n  // It's used when cursor linking calls setCursorPos, so that linked cursor moves don't trample lastEditedAt.\n  // WARNING: the flag approach relies on ko observables being resolved synchronously, may break if changed to grainjs?\n  private _silentUpdatesFlag: boolean = false;\n\n  constructor(baseView: BaseView, optCursorPos?: CursorPos) {\n    super();\n    optCursorPos = optCursorPos || {};\n    this.viewData = baseView.viewData;\n\n    this._sectionId = this.autoDispose(ko.computed(() => baseView.viewSection.id()));\n    this.rowId = ko.observable<UIRowId | null>(optCursorPos.rowId || 0);\n    this.rowIndex = this.autoDispose(ko.computed({\n      read: () => {\n        if (!this._isLive()) { return this.rowIndex.peek(); }\n        const rowId = this.rowId();\n        return rowId == null ? null : this.viewData.clampIndex(this.viewData.getRowIndexWithSub(rowId));\n      },\n      write: (index) => {\n        const rowIndex = index === null ? null : this.viewData.clampIndex(index);\n        this.rowId(rowIndex == null ? null : this.viewData.getRowId(rowIndex));\n      },\n    }));\n\n    this.fieldIndex = baseView.viewSection.viewFields().makeLiveIndex(optCursorPos.fieldIndex || 0);\n\n    // Custom widgets may choose to disable cursor support\n    const cursorEnabled = this.autoDispose(ko.computed(() => {\n      return !baseView.options?.disabledCursor && baseView.viewSection.hasRegionFocus();\n    }));\n    this.autoDispose(commands.createGroup(Cursor.editorCommands, this, cursorEnabled));\n\n    // RowId might diverge from the one stored in _rowId when the data changes (it is filtered out). So here\n    // we will calculate rowId based on rowIndex (so in reverse order), to have a proper value.\n    this._properRowId = this.autoDispose(ko.computed(() => {\n      const rowIndex = this.rowIndex();\n      const rowId = rowIndex === null ? null : this.viewData.getRowId(rowIndex);\n      return rowId;\n    }));\n\n    this._lastEditedAt = ko.observable(SequenceNEVER);\n\n    // update the section's activeRowId and lastCursorEdit when needed\n    this.autoDispose(this._properRowId.subscribe(rowId => baseView.viewSection.activeRowId(rowId)));\n    this.autoDispose(this._lastEditedAt.subscribe(seqNum => baseView.viewSection.lastCursorEdit(seqNum)));\n\n    // Update the cursor edit time if either the row or column change\n    // IMPORTANT: need to subscribe AFTER the properRowId->activeRowId subscription.\n    //  (Cursor-linking observables depend on lastCursorEdit, but only peek at activeRowId. Therefore, updating the\n    //   edit time triggers a re-read of activeRowId, and swapping the order will read stale values for rowId)\n    // NOTE: this may update sequence number twice for a single edit, but this shouldn't cause any issues.\n    //       For determining priority, this cursor will become the latest edited whether we call it once or twice.\n    //       For updating observables, the double-update might cause cursor-linking observables in LinkingState to\n    //       double-update, but it should be transient and get resolved immediately.\n    this.autoDispose(this._properRowId.subscribe(() => { this._cursorEdited(); }));\n    this.autoDispose(this.fieldIndex.subscribe(() => { this._cursorEdited(); }));\n\n    // On dispose, save the current cursor position to the section model.\n    this.onDispose(() => { baseView.viewSection.lastCursorPos = this.getCursorPos(); });\n\n    // calculate current position\n    this.currentPosition = this.autoDispose(ko.computed(() => this._isLive() ? this.getCursorPos() : {}));\n  }\n\n  // Returns the cursor position with rowId, rowIndex, and fieldIndex.\n  public getCursorPos(): CursorPos {\n    return {\n      rowId: nullAsUndefined(this._properRowId()),\n      rowIndex: nullAsUndefined(this.rowIndex()),\n      fieldIndex: this.fieldIndex(),\n      sectionId: this._sectionId(),\n    };\n  }\n\n  /**\n   * Moves the cursor to the given position. Only moves the row if rowId or rowIndex is valid,\n   * preferring rowId.\n   *\n   * isFromLink prevents lastEditedAt from being updated, so lastEdit reflects only user-driven edits\n   * @param {CursorPos} cursorPos - Position, as from getCursorPos().\n   * @param {boolean} [isFromLink=false] - should be set if this is a cascading update from cursor-linking\n   * @param {CursorPos} [fallbackCursorPos] - Position to use if cursorPos is set to a non-existent row.\n   *\n   * @returns {boolean} True if the cursor position was valid\n   */\n  public setCursorPos(cursorPos: CursorPos, isFromLink: boolean = false, fallbackCursorPos?: CursorPos): boolean {\n    try {\n      // If updating as a result of links, we want to NOT update lastEditedAt\n      if (isFromLink) { this._silentUpdatesFlag = true; }\n\n      let newRowIndex = this._getNewRowIndexForCursorPos(cursorPos);\n\n      if (newRowIndex === null && fallbackCursorPos !== undefined) {\n        newRowIndex = this._getNewRowIndexForCursorPos(fallbackCursorPos);\n      }\n\n      if (newRowIndex !== undefined && (newRowIndex === null || newRowIndex >= 0)) {\n        this.rowIndex(newRowIndex);\n      } else {\n        // Write rowIndex to itself to force an update of rowId if needed.\n        this.rowIndex(this.rowIndex.peek() || 0);\n      }\n\n      if (cursorPos.fieldIndex !== undefined) {\n        this.fieldIndex(cursorPos.fieldIndex);\n      }\n\n      // NOTE: _cursorEdited\n      // We primarily update cursorEdited counter from a this._properRowId.subscribe(), since that catches updates\n      //   from many sources (setCursorPos, arrowKeys, save/load, filter/sort-changes, etc)\n      // However, there's some cases where we user touches a section and properRowId doesn't change. Obvious one is\n      //   clicking in a section on the cell the cursor is already on. This doesn't change the cursor position, but it\n      //   SHOULD still update cursors to use that section as most up-to-date (user just clicked on a cell!), so we do\n      //   it here. (normally is minor issue, but can matter when a section has rows filtered out so cursors desync)\n      // Also a more subtle case: when deleting a row with several sections linked together, properRowId can fail to\n      //   update. When GridView.deleteRows calls setCursorPos to keep cursor from jumping after delete, the observable\n      //   doesn't trigger cursorEdited(), because (I think) _properRowId has already been updated that cycle.\n      //   This caused a bug when several viewSections were cursor-linked to each other and a row was deleted\n      // NOTE: Calling it explicitly here will cause cursorEdited to be called twice sometimes,\n      //   but that shouldn't cause any problems, since we don't care about edit counts, just who was edited latest.\n      this._cursorEdited();\n\n      return newRowIndex !== null;\n    } finally { // Make sure we reset this even on error\n      this._silentUpdatesFlag = false;\n    }\n  }\n\n  public setLive(isLive: boolean): void {\n    this._isLive(isLive);\n  }\n\n  // Should be called whenever the cursor is updated\n  // EXCEPT FOR: when cursor is set by linking\n  // this is used to determine which widget/cursor has most recently been touched,\n  // and therefore which one should be used to drive linking if there's a conflict\n  private _cursorEdited(): void {\n    // If updating as a result of links, we want to NOT update lastEdited\n    if (!this._silentUpdatesFlag) { this._lastEditedAt(nextSequenceNum()); }\n  }\n\n  /**\n   * Calculates the correct row index for a given cursor pos\n   * @param {CursorPos} cursorPos - Position to find the row index for\n   * @returns {number | null | undefined} - A row index >= 0 if a valid row index exists.\n   *                                        Null if the position is invalid for the current data.\n   *                                        Undefined if the cursorPos doesn't specify a new row.\n   * @private\n   */\n  private _getNewRowIndexForCursorPos(cursorPos: CursorPos): number | null | undefined {\n    let newRowIndex: number | null | undefined = undefined;\n\n    if (cursorPos.rowId !== undefined) {\n      newRowIndex = this.viewData.getRowIndex(cursorPos.rowId);\n    } else if (cursorPos.rowIndex !== undefined && cursorPos.rowIndex >= 0) {\n      newRowIndex = cursorPos.rowIndex;\n    }\n\n    if (typeof (newRowIndex) === \"number\" && newRowIndex < 0) {\n      newRowIndex = null;\n    }\n\n    return newRowIndex;\n  }\n}\n"
  },
  {
    "path": "app/client/components/CursorMonitor.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { getStorage } from \"app/client/lib/storage\";\nimport { reportError } from \"app/client/models/errors\";\nimport { IDocPage, isViewDocPage, ViewDocPage } from \"app/common/gristUrls\";\nimport { CursorPos } from \"app/plugin/GristAPI\";\n\nimport { Disposable, Listener, Observable } from \"grainjs\";\n\n/**\n * Enriched cursor position with a view id\n */\nexport type ViewCursorPos = CursorPos & { viewId: ViewDocPage };\n\n/**\n * Component for GristDoc that allows it to keep track of the latest cursor position.\n * In case, when a document is reloaded abnormally, the latest cursor\n * position should be restored from a local storage.\n */\nexport class CursorMonitor extends Disposable {\n  // abstraction to work with local storage\n  private _store: StorageWrapper;\n  // key for storing position in the memory (docId + userId)\n  private _key: string;\n  // flag that tells if the position was already restored\n  // we track document's view change event, so we only want\n  // to react to that event once\n  private _restored = false;\n\n  constructor(\n    doc: GristDoc,\n    store?: Storage) {\n    super();\n\n    this._store = new StorageWrapper(store);\n\n    // Use document id and user id as a key for storage.\n    const userId = doc.app.topAppModel.appObs.get()?.currentUser?.id ?? null;\n    this._key = doc.docId() + userId;\n\n    /**\n     * When document loads last cursor position should be restored from local storage.\n     */\n    this._whenDocumentLoadsRestorePosition(doc);\n\n    /**\n     * When a cursor position changes, its value is stored in a local storage.\n     */\n    this._whenCursorHasChangedStoreInMemory(doc);\n  }\n\n  public clear() {\n    this._store.clear(this._key);\n  }\n\n  private _whenCursorHasChangedStoreInMemory(doc: GristDoc) {\n    // whenever current position changes, store it in the memory\n    this.autoDispose(doc.cursorPosition.addListener((pos) => {\n      // if current position is not restored yet, don't change it\n      if (!this._restored) { return; }\n      // store position only when we have valid rowId\n      // for some views (like CustomView) cursor position might not reflect actual row\n      if (pos && pos.rowId !== undefined) {\n        if (pos.sectionId) {\n          pos = { ...pos, linkingRowIds: doc.docModel.getLinkingRowIds(pos.sectionId) };\n        }\n        this._storePosition(pos);\n      }\n    }));\n  }\n\n  private _whenDocumentLoadsRestorePosition(doc: GristDoc) {\n    // if doc was opened with a hash link, don't restore last position\n    if (doc.hasCustomNav.get()) {\n      return this._abortRestore();\n    }\n\n    // if we are on raw data view, we need to set the position manually\n    // as currentView observable will not be changed.\n    if (doc.activeViewId.get() === \"data\") {\n      this._doRestorePosition(doc).catch(e => reportError(e));\n      return;\n    }\n\n    // on view shown\n    this.autoDispose(oneTimeListener(doc.currentView, async () => {\n      await this._doRestorePosition(doc);\n    }));\n  }\n\n  private async _doRestorePosition(doc: GristDoc) {\n    // if the position was restored for this document do nothing\n    if (this._restored) { return; }\n    // set that we already restored the position, as some view is shown to the user\n    this._restored = true;\n    const viewId = doc.activeViewId.get();\n    if (!isViewDocPage(viewId)) {\n      return this._abortRestore();\n    }\n    const position = this._readPosition(viewId);\n    if (position) {\n      // Don't restore position if this is a collapsed section.\n      const collapsed = doc.viewModel.activeCollapsedSections.peek();\n      if (position.sectionId && collapsed.includes(position.sectionId)) {\n        return;\n      }\n      // Ignore error with finding desired cell.\n      await doc.recursiveMoveToCursorPos(position, true, true);\n    }\n  }\n\n  private _abortRestore() {\n    this.clear();\n    this._restored = true;\n  }\n\n  private _storePosition(pos: ViewCursorPos) {\n    this._store.update(this._key, pos);\n  }\n\n  private _readPosition(view: IDocPage) {\n    const lastPosition = this._store.read(this._key);\n    this._store.clear(this._key);\n    if (lastPosition?.position.viewId == view) {\n      return lastPosition.position;\n    }\n    return null;\n  }\n}\n\n// Internal implementations for working with local storage\nclass StorageWrapper {\n  constructor(private _storage = getStorage()) {\n\n  }\n\n  public update(docId: string, position: ViewCursorPos): void {\n    try {\n      const storage = this._storage;\n      const data = { docId, position, timestamp: Date.now() };\n      storage.setItem(this._key(docId), JSON.stringify(data));\n    } catch (e) {\n      console.error(\"Can't store latest position in storage. Detail error \" + e.message);\n    }\n  }\n\n  public clear(docId: string): void {\n    const storage = this._storage;\n    storage.removeItem(this._key(docId));\n  }\n\n  public read(docId: string): { docId: string; position: ViewCursorPos; } | undefined {\n    const storage = this._storage;\n    const result = storage.getItem(this._key(docId));\n    if (!result) { return undefined; }\n    return JSON.parse(result);\n  }\n\n  protected _key(docId: string) {\n    return `grist-last-position-${docId}`;\n  }\n}\n\nexport function oneTimeListener<T>(obs: Observable<T>, handler: (value: T) => any) {\n  let listener: Listener | null = obs.addListener((value) => {\n    setImmediate(dispose);\n    handler(value);\n  });\n  function dispose() {\n    if (listener) {\n      listener.dispose();\n      listener = null;\n    }\n  }\n  return { dispose };\n}\n"
  },
  {
    "path": "app/client/components/CustomCalendarView.ts",
    "content": "import { CustomView, CustomViewSettings } from \"app/client/components/CustomView\";\nimport { AccessLevel } from \"app/common/CustomWidget\";\n\nexport class CustomCalendarView extends CustomView {\n  protected getBuiltInSettings(): CustomViewSettings {\n    return {\n      widgetId: \"@gristlabs/widget-calendar\",\n      accessLevel: AccessLevel.full,\n    };\n  }\n}\n"
  },
  {
    "path": "app/client/components/CustomView.css",
    "content": "/*\n * Ensure the custom view section fits within its allocated area even if it needs to scroll inside\n * of it. This is not an issue when it contains an iframe, but .custom_view_no_mapping element\n * could be taller, but its intrinsic height should not affect the container.\n */\n.custom_view_container {\n  overflow: auto;\n  flex-basis: 0px;\n}\n\n.custom_view_content {\n  height: 100%;\n}\n\niframe.custom_view {\n  border: none;\n  flex: auto;\n}\n\n.custom_view_notification {\n  padding: 15px;\n  margin: 15px;\n}\n\n.custom_view_no_mapping {\n  padding: 15px;\n  margin: 15px;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  color: var(--grist-theme-text, var(--grist-color-dark));\n}\n\n.custom_view_no_mapping h1 {\n  max-width: 310px;\n  margin-bottom: 24px;\n  margin-top: 56px;\n\n  font-style: normal;\n  font-weight: 600;\n  font-size: 22px;\n  line-height: 26px;\n  text-align: center;\n  text-wrap: balance;\n}\n\n.custom_view_no_mapping p {\n  max-width: 310px;\n  font-style: normal;\n  font-weight: 400;\n  font-size: 13px;\n  line-height: 16px;\n  text-align: center;\n}\n"
  },
  {
    "path": "app/client/components/CustomView.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport {\n  CommandAPI,\n  ConfigNotifier,\n  CustomSectionAPIImpl,\n  GristDocAPIImpl,\n  GristViewImpl,\n  MinimumLevel,\n  RecordNotifier,\n  TableNotifier,\n  ThemeNotifier,\n  WidgetAPIImpl,\n  WidgetFrame,\n} from \"app/client/components/WidgetFrame\";\nimport { CustomSectionElement, ViewProcess } from \"app/client/lib/CustomSectionElement\";\nimport dom from \"app/client/lib/dom\";\nimport { makeTestId } from \"app/client/lib/domUtils\";\nimport * as kd from \"app/client/lib/koDom\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { CustomViewSectionDef } from \"app/client/models/entities/ViewSectionRec\";\nimport { UserError } from \"app/client/models/errors\";\nimport { closeRegisteredMenu } from \"app/client/ui2018/menus\";\nimport { AccessLevel } from \"app/common/CustomWidget\";\nimport { defaultLocale } from \"app/common/gutil\";\nimport { PluginInstance } from \"app/common/PluginInstance\";\n\nimport { dom as grains } from \"grainjs\";\nimport * as ko from \"knockout\";\n\nconst t = makeT(\"CustomView\");\nconst testId = makeTestId(\"test-custom-widget-\");\n\n/**\n *\n * Built in settings for a custom widget. Used when the custom\n * widget is the implementation of a native-looking widget,\n * for example the calendar widget.\n *\n */\nexport interface CustomViewSettings {\n  widgetId?: string;\n  accessLevel?: AccessLevel;\n}\n\n/**\n * CustomView components displays arbitrary html. There are two modes available, in the \"url\" mode\n * the content is hosted by a third-party (for instance a github page), as opposed to the \"plugin\"\n * mode where the contents is provided by a plugin. In both cases the content is rendered safely\n * within an iframe (or webview if running electron). Configuration of the component is done within\n * the view config tab in the side pane. In \"plugin\" mode, shows notification if either the plugin\n * of the section could not be found.\n */\nexport class CustomView extends BaseView {\n  // Commands enabled only when the custom view is the actually user-focused region.\n  private static _focusedCommands = {\n    async viewAsCard(event: Event) {\n      if (event instanceof KeyboardEvent) {\n        // Ignore the keyboard shortcut if pressed; it's disabled at this time for custom widgets.\n        return;\n      }\n\n      (this as unknown as BaseView).viewSelectedRecordAsCard();\n\n      // Move focus back to the app, so that keyboard shortcuts work in the popup.\n      document.querySelector<HTMLElement>(\"textarea.copypaste.mousetrap\")?.focus();\n    },\n  };\n\n  // Commands enabled when the view is the active section, even when user focuses another region.\n  private static _commands: { [key: string]: Function } & ThisType<CustomView> = {\n    async openWidgetConfiguration(this: CustomView) {\n      if (!this.isDisposed() && !this._frame?.isDisposed()) {\n        try {\n          await this._frame.editOptions();\n        } catch (err) {\n          if (err.message === \"Unknown interface\") {\n            throw new UserError(\"Custom widget doesn't expose configuration screen.\");\n          } else {\n            throw err;\n          }\n        }\n      }\n    },\n    async viewAsCard(event: Event) {\n      if (event instanceof KeyboardEvent) {\n        // Ignore the keyboard shortcut if pressed; it's disabled at this time for custom widgets.\n        return;\n      }\n\n      this.viewSelectedRecordAsCard();\n\n      // Move focus back to the app, so that keyboard shortcuts work in the popup.\n      document.querySelector<HTMLElement>(\"textarea.copypaste.mousetrap\")?.focus();\n    },\n  };\n\n  protected customDef: CustomViewSectionDef;\n\n  // state of the component\n  private _foundPlugin: ko.Observable<boolean>;\n  private _foundSection: ko.Observable<boolean>;\n  // Note the invariant: this._customSection != undefined if this._foundSection() == true\n  private _customSection: ViewProcess | undefined;\n  private _pluginInstance: PluginInstance | undefined;\n\n  private _frame: WidgetFrame;  // plugin frame (holding external page)\n  private _hasUnmappedColumns: ko.Computed<boolean>;\n  private _hasAclHiddenColumns: ko.Computed<boolean>;\n  private _unmappedColumns: ko.Computed<string[]>;\n\n  constructor(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {\n    super(gristDoc, viewSectionModel, { addNewRow: true, disabledCursor: true });\n\n    this.customDef = this.viewSection.customDef;\n\n    this.onDispose(() => {\n      if (this._customSection) {\n        this._customSection.dispose();\n      }\n    });\n    this._foundPlugin = ko.observable(false);\n    this._foundSection = ko.observable(false);\n    // Ensure that selecting another section in same plugin update the view.\n    this._foundSection.extend({ notify: \"always\" });\n\n    this.autoDispose(this.customDef.pluginId.subscribe(this._updatePluginInstance, this));\n    this.autoDispose(this.customDef.sectionId.subscribe(this._updateCustomSection, this));\n    this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasFocus));\n    this.autoDispose(commands.createGroup(CustomView._focusedCommands, this, this.viewSection.hasRegionFocus));\n\n    this._unmappedColumns = this.autoDispose(ko.pureComputed(() => {\n      const columns = this.viewSection.columnsToMap();\n      if (!columns) { return []; }\n      const required = columns.filter(col => typeof col === \"string\" || !(col.optional === true))\n        .map(col => typeof col === \"string\" ? col : col.name);\n      const mapped = this.viewSection.mappedColumns() || {};\n      return required.filter(col => !mapped[col]);\n    }));\n    this._hasUnmappedColumns = this.autoDispose(ko.pureComputed(() => this._unmappedColumns().length > 0));\n    this._hasAclHiddenColumns = this.autoDispose(ko.pureComputed(() => {\n      // If all columns are mapped, nothing to do.\n      if (!this._hasUnmappedColumns()) {\n        return false;\n      }\n\n      // Get the rowIds of the already mapped columns.\n      const mappings = this.viewSection.customDef.columnsMapping();\n      if (!mappings) {\n        return false;\n      }\n      const rowIds = Object.entries(mappings).filter(f => f[1])\n        .map(([rowId, colId]) => Array.isArray(colId) ? colId : [colId!])\n        .flat();\n      const redactedColumns = gristDoc.docModel.columns.rowModels.filter(r => !r.colId()).map(r => r.id());\n      return rowIds.some(r => redactedColumns.includes(r));\n    }));\n\n    this.viewPane = this._buildDom();\n    this.onDispose(() => { dom.domDispose(this.viewPane); this.viewPane.remove(); });\n    this._updatePluginInstance();\n  }\n\n  public async triggerPrint() {\n    if (!this.isDisposed() && this._frame) {\n      return await this._frame.callRemote(\"print\");\n    }\n  }\n\n  protected getBuiltInSettings(): CustomViewSettings {\n    return {};\n  }\n\n  /**\n   * Find a plugin instance that matches the plugin id, update the `found` observables, then tries to\n   * find a matching section.\n   */\n  private _updatePluginInstance() {\n    const pluginId = this.customDef.pluginId();\n    this._pluginInstance = this.gristDoc.docPluginManager.pluginsList.find(p => p.definition.id === pluginId);\n\n    if (this._pluginInstance) {\n      this._foundPlugin(true);\n    } else {\n      this._foundPlugin(false);\n      this._foundSection(false);\n    }\n    this._updateCustomSection();\n  }\n\n  /**\n   * If a plugin was found, find a custom section matching the section id and update the `found`\n   * observables.\n   */\n  private _updateCustomSection() {\n    if (!this._pluginInstance) { return; }\n\n    const sectionId = this.customDef.sectionId();\n    this._customSection = CustomSectionElement.find(this._pluginInstance, sectionId);\n\n    if (this._customSection) {\n      const el = this._customSection.element;\n      el.classList.add(\"flexitem\");\n      this._foundSection(true);\n    } else {\n      this._foundSection(false);\n    }\n  }\n\n  private _buildDom(): HTMLElement {\n    const { mode, url, access, renderAfterReady, widgetDef, widgetId, pluginId } = this.customDef;\n    const showPlugin = ko.pureComputed(() => this.customDef.mode() === \"plugin\");\n    const showAfterReady = () => {\n      // The empty widget page calls `grist.ready()`.\n      if (!url() && !widgetId()) { return true; }\n\n      return renderAfterReady();\n    };\n\n    // When both plugin and section are not found, let's show only plugin notification.\n    const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin());\n    const showSectionNotification = ko.pureComputed(() => showPlugin() && this._foundPlugin() && !this._foundSection());\n    const showPluginContent = ko.pureComputed(() => showPlugin() && this._foundSection())\n    // For the view to update when switching from one section to another one, the computed\n    // observable must always notify.\n      .extend({ notify: \"always\" });\n    // Some widgets have built-in settings that should override anything\n    // that is in the rest of the view options. Ideally, everything would\n    // be consistent. We could fix inconsistencies if we find them, but\n    // we are not guaranteed to have write privileges at this point.\n    const builtInSettings = this.getBuiltInSettings();\n    return dom(\"div.flexauto.flexvbox.custom_view_container\",\n      dom.autoDispose(showPlugin),\n      dom.autoDispose(showPluginNotification),\n      dom.autoDispose(showSectionNotification),\n      dom.autoDispose(showPluginContent),\n\n      kd.maybe(this._hasUnmappedColumns, () => dom(\"div.custom_view_no_mapping\",\n        testId(\"not-mapped\"),\n        dom(\"img\", { src: \"img/empty-widget.svg\" }),\n\n        kd.maybe(this._hasAclHiddenColumns, () => [\n          dom(\"h1\", kd.text(t(\"Some required columns are hidden by access rules\"))),\n          dom(\"p\",\n            t(\"To use this widget, all mapped columns must be visible. \\\nPlease contact document owner or modify access rules.\"),\n          ),\n        ]),\n        kd.maybe(() => !this._hasAclHiddenColumns(), () => [\n          dom(\"h1\", kd.text(t(\"Some required columns aren't mapped\"))),\n          dom(\"p\",\n            t(\"To use this widget, please map all non-optional columns from the creator panel on the right.\"),\n          ),\n        ]),\n      )),\n      // todo: should display content in webview when running electron\n      // prefer widgetId; spelunk in widgetDef for older docs\n      kd.scope(() => [\n        this._hasUnmappedColumns(), mode(), url(), access(), widgetId() || widgetDef()?.widgetId || \"\", pluginId(),\n      ], ([_hide, _mode, _url, _access, _widgetId, _pluginId]: string[]) =>\n        _mode === \"url\" ?\n          dom(\"div.flexauto.custom_view_content\",\n            kd.style(\"display\", _hide ? \"none\" : \"flex\"),\n            this._buildIFrame({\n              baseUrl: _url,\n              access: builtInSettings.accessLevel || (_access as AccessLevel || AccessLevel.none),\n              showAfterReady: showAfterReady(),\n              widgetId: builtInSettings.widgetId || _widgetId,\n              pluginId: _pluginId,\n            }),\n          ) :\n          null,\n      ),\n      kd.maybe(showPluginNotification, () => buildNotification(\"Plugin \",\n        dom(\"strong\", kd.text(this.customDef.pluginId)), \" was not found\",\n        dom.testId(\"customView_notification_plugin\"),\n      )),\n      kd.maybe(showSectionNotification, () => buildNotification(\"Section \",\n        dom(\"strong\", kd.text(this.customDef.sectionId)), \" was not found in plugin \",\n        dom(\"strong\", kd.text(this.customDef.pluginId)),\n        dom.testId(\"customView_notification_section\"),\n      )),\n      // When showPluginContent() is true then _foundSection() is also and _customSection is not\n      // undefined (invariant).\n      kd.maybe(showPluginContent, () => this._customSection!.element),\n    );\n  }\n\n  private _promptAccess(access: AccessLevel) {\n    if (this.gristDoc.isReadonly.get()) {\n      return;\n    }\n    this.viewSection.desiredAccessLevel(access);\n  }\n\n  private _buildIFrame(options: {\n    baseUrl: string | null,\n    access: AccessLevel,\n    showAfterReady?: boolean,\n    widgetId?: string | null,\n    pluginId?: string\n  }) {\n    const { baseUrl, access, showAfterReady, widgetId, pluginId } = options;\n    const documentSettings = this.gristDoc.docData.docSettings();\n    const readonly = this.gristDoc.isReadonly.get();\n    const widgetFrame = WidgetFrame.create(null,  {\n      url: baseUrl,\n      widgetId,\n      pluginId,\n      access,\n      preferences:\n      {\n        culture: documentSettings.locale ?? defaultLocale,\n        language: this.gristDoc.appModel.currentUser?.locale ?? defaultLocale,\n        timeZone: this.gristDoc.docInfo.timezone() ?? \"UTC\",\n        currency: documentSettings.currency ?? \"USD\",\n      },\n      readonly,\n      showAfterReady,\n      configure: (frame) => {\n        this._frame = frame;\n        // Need to cast myself to a BaseView\n        const view: BaseView = this;\n        frame.exposeAPI(\n          \"GristDocAPI\",\n          new GristDocAPIImpl(this.gristDoc),\n          GristDocAPIImpl.defaultAccess);\n        frame.exposeAPI(\n          \"GristView\",\n          new GristViewImpl(view, access), new MinimumLevel(AccessLevel.read_table));\n        frame.exposeAPI(\n          \"CustomSectionAPI\",\n          new CustomSectionAPIImpl(\n            this.viewSection,\n            access,\n            this._promptAccess.bind(this)),\n          new MinimumLevel(AccessLevel.none));\n        frame.exposeAPI(\n          \"CommandAPI\",\n          new CommandAPI(access),\n          new MinimumLevel(AccessLevel.none));\n        frame.useEvents(RecordNotifier.create(frame, view), new MinimumLevel(AccessLevel.read_table));\n        frame.useEvents(TableNotifier.create(frame, view), new MinimumLevel(AccessLevel.read_table));\n        frame.exposeAPI(\n          \"WidgetAPI\",\n          new WidgetAPIImpl(this.viewSection),\n          new MinimumLevel(AccessLevel.none)); // none access is enough\n        frame.useEvents(\n          ConfigNotifier.create(frame, this.viewSection, {\n            access,\n          }),\n          new MinimumLevel(AccessLevel.none)); // none access is enough\n        frame.useEvents(\n          ThemeNotifier.create(frame),\n          new MinimumLevel(AccessLevel.none));\n      },\n      onElem: iframe => onFrameFocus(iframe, () => {\n        if (this.isDisposed()) { return; }\n        if (!this.viewSection.isDisposed() && !this.viewSection.hasFocus()) {\n          this.viewSection.hasFocus(true);\n        }\n        // allow menus to close if any\n        closeRegisteredMenu();\n      }),\n      gristDoc: this.gristDoc,\n    });\n\n    // Can't use dom.create() because it seems buggy in this context. This dom will be detached\n    // and attached several times, and dom.create() doesn't seem to handle that well as it returns an\n    // array of nodes (comment, node, comment) and it somehow breaks the dispose order. Collapsed widgets\n    // relay on a correct order of dispose, and are detaching nodes just before they are disposed, so if\n    // the order is wrong, the node is disposed without being detached first.\n    return grains.update(widgetFrame.buildDom(), dom.autoDispose(widgetFrame));\n  }\n}\n\n// helper to build the notification's frame.\nfunction buildNotification(...args: any[]) {\n  return dom(\"div.custom_view_notification.bg-warning\", dom(\"p\", ...args));\n}\n\n/**\n * There is no way to detect if the frame was clicked. This causes a bug, when\n * there are 2 custom widgets on a page then user can't switch focus from 1 section\n * to another. The only solution is too pool and test if the iframe is an active element\n * in the dom.\n * (See https://stackoverflow.com/questions/2381336/detect-click-into-iframe-using-javascript).\n *\n * For a single iframe, it will gain focus through a hack in ViewLayout.ts.\n */\nfunction onFrameFocus(frame: HTMLIFrameElement, handler: () => void) {\n  let timer: NodeJS.Timeout | null = null;\n  // Flag that will prevent mouseenter event to be fired\n  // after dom is disposed. This shouldn't happen.\n  let disposed = false;\n  // Stops pooling.\n  function stop() {\n    if (timer) {\n      clearInterval(timer);\n      timer = null;\n    }\n  }\n  return grains.update(frame,\n    grains.on(\"mouseenter\", () => {\n      // Make sure we weren't dispose (should not happen)\n      if (disposed) { return; }\n      // If frame already has focus, do nothing.\n      // NOTE: Frame will always be an active element from our perspective,\n      // even if the focus is somewhere inside the iframe.\n      if (document.activeElement === frame) { return; }\n      // Start pooling for frame focus.\n      timer = setInterval(() => {\n        if (document.activeElement === frame) {\n          try {\n            handler();\n          } finally {\n            // Stop checking, we will start again after next mouseenter.\n            stop();\n          }\n        }\n      }, 70); // 70 is enough to make it look like a click.\n    }),\n    grains.on(\"mouseleave\", stop),\n    grains.onDispose(() => {\n      stop();\n      disposed = true;\n    }),\n  );\n}\n"
  },
  {
    "path": "app/client/components/DataTables.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { copyToClipboard } from \"app/client/lib/clipboardUtils\";\nimport { makeTestId } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { setTestState } from \"app/client/lib/testState\";\nimport { TableRec } from \"app/client/models/DocModel\";\nimport { docListHeader, docMenuTrigger } from \"app/client/ui/DocMenuCss\";\nimport { duplicateTable, DuplicateTableResponse } from \"app/client/ui/DuplicateTable\";\nimport { hoverTooltip, showTransientTooltip } from \"app/client/ui/tooltips\";\nimport { buildTableName } from \"app/client/ui/WidgetTitle\";\nimport * as css from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { loadingDots } from \"app/client/ui2018/loaders\";\nimport { menu, menuDivider, menuIcon, menuItem, menuItemAsync, menuText } from \"app/client/ui2018/menus\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\n\nimport { Computed, Disposable, dom, fromKo, observable, Observable, styled } from \"grainjs\";\nimport * as weasel from \"popweasel\";\n\nconst testId = makeTestId(\"test-raw-data-\");\n\nconst t = makeT(\"DataTables\");\n\nconst DATA_TABLES_TOOLTIP_KEY = \"dataTablesTooltip\";\n\nexport class DataTables extends Disposable {\n  private _tables: Observable<TableRec[]>;\n\n  private readonly _rowCount = Computed.create(\n    this, this._gristDoc.docPageModel.currentDocUsage, (_use, usage) => {\n      return usage?.rowCount;\n    },\n  );\n\n  // TODO: Update this whenever the rest of the UI is internationalized.\n  private readonly _rowCountFormatter = new Intl.NumberFormat(\"en-US\");\n\n  constructor(private _gristDoc: GristDoc) {\n    super();\n    this._tables = Computed.create(this, (use) => {\n      const dataTables = use(_gristDoc.docModel.rawDataTables.getObservable());\n      const summaryTables = use(_gristDoc.docModel.rawSummaryTables.getObservable());\n      // Remove tables that we don't have access to. ACL will remove tableId from those tables.\n      return [...dataTables, ...summaryTables].filter(table => Boolean(use(table.tableId)));\n    });\n  }\n\n  public buildDom() {\n    return container(\n      cssTableList(\n        /** *************  List section **********/\n        testId(\"list\"),\n        cssHeader(t(\"Raw Data Tables\")),\n        cssList(\n          dom.forEach(this._tables, (tableRec) => {\n            const isEditingName = observable(false);\n            return cssTable(\n              dom.autoDispose(isEditingName),\n              testId(\"table\"),\n              cssTableIcon(\n                dom.domComputed(use => cssTableTypeIcon(\n                  use(tableRec.summarySourceTable) !== 0 ? \"PivotLight\" : \"TypeTable\",\n                  testId(`table-id-${use(tableRec.tableId)}`),\n                )),\n              ),\n              cssTableNameAndId(\n                cssTitleRow(cssTableTitle(this._tableTitle(tableRec, isEditingName), testId(\"table-title\"))),\n                cssDetailsRow(\n                  cssTableIdWrapper(cssHoverWrapper(\n                    cssUpperCase(\"Table ID: \"),\n                    cssTableId(\n                      testId(\"table-id\"),\n                      dom.text(tableRec.tableId),\n                    ),\n                    { title: t(\"Click to copy\") },\n                    dom.on(\"click\", async (e, d) => {\n                      e.stopImmediatePropagation();\n                      e.preventDefault();\n                      showTransientTooltip(d, t(\"Table ID copied to clipboard\"), {\n                        key: \"copy-table-id\",\n                      });\n                      await copyToClipboard(tableRec.tableId.peek());\n                      setTestState({ clipboard: tableRec.tableId.peek() });\n                    }),\n                  )),\n                ),\n              ),\n              this._tableRows(tableRec),\n              cssTableButtons(\n                cssRecordCardButton(\n                  icon(\"TypeCard\"),\n                  dom.on(\"click\", (ev) => {\n                    ev.stopPropagation();\n                    ev.preventDefault();\n                    if (!tableRec.recordCardViewSection().disabled()) {\n                      this._editRecordCard(tableRec);\n                    }\n                  }),\n                  hoverTooltip(\n                    dom.domComputed(use => use(use(tableRec.recordCardViewSection).disabled) ?\n                      t(\"Record Card Disabled\") :\n                      t(\"Edit record card\")),\n                    { key: DATA_TABLES_TOOLTIP_KEY, closeOnClick: false },\n                  ),\n                  dom.hide(this._gristDoc.isReadonly),\n                  // Make the button invisible to maintain consistent alignment with non-summary tables.\n                  dom.style(\"visibility\", u => u(tableRec.summarySourceTable) === 0 ? \"visible\" : \"hidden\"),\n                  cssRecordCardButton.cls(\"-disabled\", use => use(use(tableRec.recordCardViewSection).disabled)),\n                  testId(\"table-record-card\"),\n                ),\n                cssDotsButton(\n                  testId(\"table-menu\"),\n                  testId(use => `table-menu-${use(tableRec.tableId)}`),\n                  icon(\"Dots\"),\n                  menu(() => this._menuItems(tableRec, isEditingName), { placement: \"bottom-start\" }),\n                  dom.on(\"click\", (ev) => { ev.stopPropagation(); ev.preventDefault(); }),\n                ),\n              ),\n              dom.on(\"click\", () => {\n                const sectionId = tableRec.rawViewSection.peek().getRowId();\n                if (!sectionId) {\n                  throw new Error(`Table ${tableRec.tableId.peek()} doesn't have a raw view section.`);\n                }\n                this._gristDoc.viewModel.activeSectionId(sectionId);\n              }),\n              cssTable.cls(\"-readonly\", this._gristDoc.isReadonly),\n            );\n          }),\n        ),\n      ),\n    );\n  }\n\n  private _tableTitle(table: TableRec, isEditing: Observable<boolean>) {\n    return dom.domComputed((use) => {\n      const rawViewSectionRef = use(fromKo(table.rawViewSectionRef));\n      const isSummaryTable = use(table.summarySourceTable) !== 0;\n      const isReadonly = use(this._gristDoc.isReadonly);\n      if (!rawViewSectionRef || isSummaryTable || isReadonly) {\n        // Some very old documents might not have a rawViewSection, and raw summary\n        // tables can't currently be renamed.\n        const tableName = [\n          use(table.tableNameDef), isSummaryTable ? use(table.groupDesc) : \"\",\n        ].filter(p => Boolean(p?.trim())).join(\" \");\n        return cssTableName(tableName);\n      } else {\n        return cssFlexRow(\n          dom.domComputed(fromKo(table.rawViewSection), vs =>\n            buildTableName(vs, { isEditing }, cssRenamableTableName.cls(\"\"), testId(\"widget-title\")),\n          ),\n          cssRenameTableButton(icon(\"Pencil\"),\n            dom.on(\"click\", (ev) => {\n              ev.stopPropagation();\n              ev.preventDefault();\n              isEditing.set(true);\n            }),\n            cssRenameTableButton.cls(\"-active\", isEditing),\n          ),\n        );\n      }\n    });\n  }\n\n  private _menuItems(table: TableRec, isEditingName: Observable<boolean>) {\n    const { isReadonly, docModel } = this._gristDoc;\n    return [\n      menuItem(\n        () => { isEditingName.set(true); },\n        t(\"Rename table\"),\n        dom.cls(\"disabled\", use => use(isReadonly) || use(table.summarySourceTable) !== 0),\n        testId(\"menu-rename-table\"),\n      ),\n      menuItem(\n        () => this._duplicateTable(table),\n        t(\"Duplicate table\"),\n        dom.cls(\"disabled\", use =>\n          use(isReadonly) ||\n          use(table.isHidden) ||\n          use(table.summarySourceTable) !== 0,\n        ),\n        testId(\"menu-duplicate-table\"),\n      ),\n      menuItem(\n        () => this._removeTable(table),\n        t(\"Remove table\"),\n        dom.cls(\"disabled\", use => use(isReadonly) || (\n          // Can't delete last visible table, unless it is a hidden table.\n          use(docModel.visibleTables.getObservable()).length <= 1 && !use(table.isHidden)\n        )),\n        testId(\"menu-remove-table\"),\n      ),\n      dom.maybe(use => use(table.summarySourceTable) === 0, () => [\n        menuDivider(),\n        menuItem(\n          () => this._editRecordCard(table),\n          cssMenuItemIcon(\"TypeCard\"),\n          t(\"Edit record card\"),\n          dom.cls(\"disabled\", use => use(isReadonly)),\n          testId(\"menu-edit-record-card\"),\n        ),\n        dom.domComputed(use => use(use(table.recordCardViewSection).disabled), (isDisabled) => {\n          return menuItemAsync(\n            async () => {\n              if (isDisabled) {\n                await this._enableRecordCard(table);\n              } else {\n                await this._disableRecordCard(table);\n              }\n            },\n            t(\"{{action}} Record Card\", { action: isDisabled ? \"Enable\" : \"Disable\" }),\n            dom.cls(\"disabled\", use => use(isReadonly)),\n            testId(`menu-${isDisabled ? \"enable\" : \"disable\"}-record-card`),\n          );\n        }),\n      ]),\n      dom.maybe(isReadonly, () => menuText(t(\"You do not have edit access to this document\"))),\n    ];\n  }\n\n  private _duplicateTable(r: TableRec) {\n    duplicateTable(this._gristDoc, r.tableId(), {\n      onSuccess: ({ raw_section_id }: DuplicateTableResponse) =>\n        this._gristDoc.viewModel.activeSectionId(raw_section_id),\n    });\n  }\n\n  private _removeTable(r: TableRec) {\n    const { docModel } = this._gristDoc;\n    function doRemove() {\n      return docModel.docData.sendAction([\"RemoveTable\", r.tableId()]);\n    }\n    confirmModal(t(\n      \"Delete {{formattedTableName}} data, and remove it from all pages?\",\n      { formattedTableName: r.formattedTableName() },\n    ), \"Delete\", doRemove);\n  }\n\n  private _editRecordCard(r: TableRec) {\n    const sectionId = r.recordCardViewSection.peek().getRowId();\n    if (!sectionId) {\n      throw new Error(`Table ${r.tableId.peek()} doesn't have a record card view section.`);\n    }\n\n    this._gristDoc.viewModel.activeSectionId(sectionId);\n    commands.allCommands.editLayout.run();\n  }\n\n  private async _enableRecordCard(r: TableRec) {\n    await r.recordCardViewSection().disabled.setAndSave(false);\n  }\n\n  private async _disableRecordCard(r: TableRec) {\n    await r.recordCardViewSection().disabled.setAndSave(true);\n  }\n\n  private _tableRows(table: TableRec) {\n    return dom.maybe(this._rowCount, (rowCounts) => {\n      if (rowCounts === \"hidden\") { return null; }\n\n      return cssTableRowsWrapper(\n        cssUpperCase(\"Rows: \"),\n        rowCounts === \"pending\" ? cssLoadingDots() : cssTableRows(\n          rowCounts[table.getRowId()] !== undefined ?\n            this._rowCountFormatter.format(rowCounts[table.getRowId()]) :\n            \"\",\n          testId(\"table-rows\"),\n        ),\n      );\n    });\n  }\n}\n\nconst cssMenuItemIcon = styled(menuIcon, `\n  --icon-color: ${css.theme.menuItemFg};\n\n  .${weasel.cssMenuItem.className}-sel & {\n    --icon-color: ${css.theme.menuItemSelectedFg};\n  }\n\n  .${weasel.cssMenuItem.className}.disabled & {\n    --icon-color: ${css.theme.menuItemDisabledFg};\n  }\n`);\n\nconst container = styled(\"div\", `\n  overflow-y: auto;\n  position: relative;\n`);\n\nconst cssHeader = styled(docListHeader, `\n  display: inline-block;\n`);\n\nconst cssList = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n`);\n\nconst cssTable = styled(\"div\", `\n  display: grid;\n  grid-template-columns: 16px minmax(32px, auto) minmax(0, 100px) minmax(0, 56px);\n  grid-template-rows: 1fr;\n  grid-column-gap: 8px;\n  cursor: pointer;\n  border-radius: 3px;\n  width: 100%;\n  height: calc(1em * 56/13); /* 56px for 13px font */\n  max-width: 750px;\n  padding: 0px 12px 0px 12px;\n  border: 1px solid ${css.theme.rawDataTableBorder};\n  &:hover {\n    border-color: ${css.theme.rawDataTableBorderHover};\n  }\n  &-readonly {\n    /* Row count column is hidden when document is read-only. */\n    grid-template-columns: 16px auto 56px;\n  }\n`);\n\nconst cssTableIcon = styled(\"div\", `\n  padding-top: 11px;\n  display: flex;\n`);\n\nconst cssTableNameAndId = styled(\"div\", `\n  min-width: 0px;\n  display: flex;\n  flex-direction: column;\n  margin-top: 8px;\n`);\n\nconst cssTitleRow = styled(\"div\", `\n  min-width: 100%;\n`);\n\nconst cssDetailsRow = styled(\"div\", `\n  min-width: 100%;\n  display: flex;\n  gap: 8px;\n`);\n\n// Holds dots menu (which is 24px x 24px)\nconst cssTableButtons = styled(\"div\", `\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  justify-content: flex-end;\n  column-gap: 8px;\n`);\n\nconst cssTableTypeIcon = styled(icon, `\n  --icon-color: ${css.theme.accentIcon};\n`);\n\nconst cssLine = styled(\"span\", `\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  overflow: hidden;\n`);\n\nconst cssTableIdWrapper = styled(\"div\", `\n  display: flex;\n  flex-grow: 1;\n  min-width: 0;\n`);\n\nconst cssTableRowsWrapper = styled(\"div\", `\n  display: flex;\n  overflow: hidden;\n  align-items: center;\n  color: ${css.theme.lightText};\n  line-height: 18px;\n`);\n\nconst cssHoverWrapper = styled(\"div\", `\n  display: flex;\n  overflow: hidden;\n  cursor: default;\n  align-items: baseline;\n  color: ${css.theme.lightText};\n  transition: background 0.05s;\n  padding: 0px 2px;\n  line-height: 18px;\n  &:hover {\n    background: ${css.theme.lightHover};\n  }\n`);\n\nconst cssTableId = styled(cssLine, `\n  font-size: ${css.vars.smallFontSize};\n`);\n\nconst cssTableRows = cssTableId;\n\nconst cssTableTitle = styled(\"div\", `\n  color: ${css.theme.text};\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n`);\n\nconst cssUpperCase = styled(\"span\", `\n  text-transform: uppercase;\n  letter-spacing: 0.81px;\n  font-weight: 500;\n  font-size: 9px; /* xxsmallFontSize is to small */\n  margin-right: 2px;\n  flex: 0;\n  white-space: nowrap;\n`);\n\nconst cssTableList = styled(\"div\", `\n  overflow-y: auto;\n  position: relative;\n  margin-bottom: 56px;\n`);\n\nconst cssLoadingDots = styled(loadingDots, `\n  --dot-size: 6px;\n`);\n\nconst cssTableName = styled(\"span\", `\n  color: ${css.theme.text};\n`);\n\nconst cssRecordCardButton = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  height: 24px;\n  width: 24px;\n  cursor: default;\n  padding: 4px;\n  border-radius: 3px;\n  --icon-color: ${css.theme.lightText};\n\n  &:hover {\n    background-color: ${css.theme.hover};\n    --icon-color: ${css.theme.controlFg};\n  }\n\n  &-disabled {\n    --icon-color: ${css.theme.lightText};\n    padding: 0px;\n    opacity: 0.4;\n  }\n\n  &-disabled:hover {\n    background: none;\n    --icon-color: ${css.theme.lightText};\n  }\n`);\n\nconst cssDotsButton = styled(docMenuTrigger, `\n  margin: 0px;\n\n  &:hover, &.weasel-popup-open {\n    background-color: ${css.theme.hover};\n  }\n`);\n\nconst cssRenameTableButton = styled(\"div\", `\n  flex-shrink: 0;\n  width: 16px;\n  visibility: hidden;\n  cursor: default;\n  --icon-color: ${css.theme.lightText};\n  &:hover  {\n    --icon-color: ${css.theme.controlFg};\n  }\n  &-active  {\n    visibility: hidden;\n  }\n  .${cssTableTitle.className}:hover & {\n    visibility: visible;\n  }\n`);\n\nconst cssFlexRow = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  column-gap: 8px;\n`);\n\nconst cssRenamableTableName = styled(\"div\", `\n  align-items: center;\n  flex: initial;\n`);\n"
  },
  {
    "path": "app/client/components/DetailView.css",
    "content": ".detail_menu_bottom {\n  border-top: 1px solid lightgrey;\n}\n\n/* applies to the record detail container */\n.record-layout-editor {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n\n  background: var(--grist-theme-page-panels-main-panel-bg, white);\n  z-index: 1;\n  margin-top: -6px;\n}\n\n.layout_box_maximized .record-layout-editor {\n  padding-left: 16px;\n  padding-right: 16px;\n}\n\n.g_record_detail_inner > .layout_root {\n  height: auto;\n}\n\n/* applies to all record details */\n.g_record_detail_el {\n  position: relative;\n  margin: 0.5rem;\n  padding: .5rem;\n}\n\n.g_record_detail_label_container {\n  display: flex;\n  justify-content: flex-start;\n  gap: 3px;\n}\n\n.g_record_detail_label_container .info_toggle_icon {\n  width: 13px;\n  height: 13px;\n  margin-bottom: 3px;\n}\n.g_record_detail_label {\n  min-height: 1rem;\n  color: #666;\n  font-size: 1rem;\n  font-weight: bold;\n}\n\n.g_record_detail_value {\n  position: relative;\n  min-height: 16px;\n  white-space: pre;\n  word-wrap: break-word;\n  color: var(--grist-theme-cell-fg, black);\n}\n\n.g_record_detail_value.record-add {\n  background-color: var(--grist-theme-table-add-new-bg, #f6f6ff);\n}\n\n.g_record_detail_value.scissors {\n  outline: 2px dashed var(--grist-theme-cursor, var(--grist-color-cursor));\n}\n\n.g_record_detail_value.draft {\n  padding-right: 18px;\n}\n\n.detail_row_num {\n  font-size: var(--grist-x-small-font-size);\n  font-weight: normal;\n  color: var(--grist-theme-text-light, var(--grist-color-slate));\n  padding: 8px;\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n}\n\n.detail_row_num .menu_toggle  {\n  margin-left: 0.5rem;\n}\n\n.detail_row_num:hover .menu_toggle,\n.detail_row_num .menu_toggle.weasel-popup-open {\n  color: var(--color-link-default);\n}\n\n/* hide menu on layout editor */\n.detailview_layout_editor .menu_toggle {\n  visibility: hidden !important;\n}\n\n.detail_row_num::before {\n  content: \"ROW \";\n  margin-right: 2px;\n}\n\n.detail-buttons {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n}\n\n.detail-left, .detail-right, .detail-add-btn {\n  --icon-color: var(--grist-theme-control-secondary-fg, #929299);\n  padding: 3px;\n  border-radius: 4px;\n}\n\n.detail-left.disabled, .detail-right.disabled, .detail-add-btn.disabled {\n  --icon-color: var(--grist-theme-control-secondary-disabled-fg, #D9D9D9);\n}\n\n.detail-button:not(.disabled):hover {\n  cursor: pointer;\n  background-color: var(--grist-theme-hover, rgba(217,217,217,0.6));\n}\n\n.detail-add-grp {\n  margin-left: 0.5rem;\n}\n\n/*** card view (multiple records) ***/\n\n.detailview_scroll_pane {\n  position: relative;\n  overflow-y: auto;\n  overflow-x: hidden;\n\n  /* allow 3px to the left to be visible, for highlighting active record */\n  padding-left: 3px;\n  margin-left: -3px;\n}\n\n@media not print {\n  .detailview_record_detail.active {\n    /* highlight active record in Card List by overlaying the active-section highlight */\n    margin-left: -3px;\n    border-left: 3px solid var(--grist-theme-cursor, var(--grist-color-light-green));\n  }\n\n  .detailview_record_detail.selected {\n    /* highlight selected record in Card List by overlaying the inactive-cursor highlight */\n    margin-left: -3px;\n    border-left: 3px solid var(--grist-theme-cursor-inactive, var(--grist-color-inactive-cursor));\n  }\n}\n\n/*** single record ***/\n.detailview_single {\n  overflow: auto;\n}\n\n.grist-single-record__menu {\n  align-items: center;\n  flex-shrink: 0;\n  padding: 0;\n  margin-top: -4px;\n  text-transform: uppercase;\n}\n\n.grist-single-record__menu__count {\n  white-space: nowrap;\n  text-align: right;\n  padding-right: 1rem;\n}\n\n.detailview_record_single > .detail_row_num {\n  display: none;\n}\n\n/*** detailed record \"themes\" ***/\n\n/*** label-under theme ***/\n/* TODO Deprecated. Probably best to keep styles for the sake of older docs that might specify\n * this theme, but in practice it's unlikely any docs use it.\n */\n.detail_theme_field_under {\n  display: flex;\n  display: -webkit-flex;\n  flex-direction: column-reverse;\n  -webkit-flex-direction: column-reverse;\n}\n\n.detail_theme_field_under .g_record_detail_label {\n  border-top: 1px solid #333;\n}\n\n.detail_theme_record_under {\n  border-top: 1px solid #ccc;\n  padding: 0 1rem 1rem 0;\n  border-left: 2px solid white;\n}\n\n.detail_theme_record_under:first-child {\n  border-top: none;\n}\n\n/*** compact theme ***/\n.detail_theme_record_compact {\n  /* 12px is enough margin on the right to include most of the floating scrollbar on MacOS */\n  padding: 4px 16px 0px 16px;\n  background-color: var(--grist-theme-card-compact-widget-bg, var(--grist-color-medium-grey));\n}\n\n.detail_theme_record_compact.detailview_record_single {\n  padding: 8px;\n}\n\n.detail_theme_record_compact > .detail_row_num {\n  padding: 0px;\n}\n\n.detail_theme_record_compact > .g_record_detail_inner {\n  background-color: var(--grist-theme-card-compact-record-bg, white);\n  position: relative;\n}\n\n.detail_theme_record_compact > .g_record_detail_inner > .layout_root {\n  border: 1px solid var(--grist-theme-card-compact-border, var(--grist-color-dark-grey));\n  border-right: none;\n  border-bottom: none;\n}\n\n.detail_theme_record_compact.detailview_record_single > .g_record_detail_inner {\n  height: 100%;\n}\n\n.detail_theme_record_compact.detailview_record_single > .g_record_detail_inner > .layout_root {\n  height: 100%;\n}\n\n.detail_theme_field_compact {\n  border-top: none;\n  border-left: none;\n  border-right: 1px solid var(--grist-theme-card-compact-border, var(--grist-color-dark-grey));\n  border-bottom: 1px solid var(--grist-theme-card-compact-border, var(--grist-color-dark-grey));\n  padding: 1px 1px 1px 5px;\n  margin: 0;\n  line-height: 1.2;\n}\n\n.detail_theme_field_compact .g_record_detail_label {\n  font-weight: normal;\n  font-size: var(--grist-small-font-size);\n  color: var(--grist-theme-card-compact-label, var(--grist-color-slate));\n  min-height: 0px;\n\n  white-space: nowrap;\n  overflow: hidden;\n  margin-left: 3px;     /* to align with the .field_clip content */\n  margin-right: -1px;   /* allow labels to overflow into the padding */\n}\n\n.detail_theme_record_compact .menu_toggle {\n  transform: translateY(-1px);\n}\n\n/*** form theme ***/\n\n.detail_theme_field_form {\n  padding: 1px 1px 1px 5px;\n}\n\n.detail_theme_field_form .g_record_detail_label {\n  font-size: var(--grist-small-font-size);\n  color: var(--grist-theme-card-form-label, var(--grist-color-slate));\n  font-weight: bold;\n  min-height: 0px;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  margin-right: -8px;\n}\n\n.detail_theme_field_form .g_record_detail_label_container {\n  gap: 8px;\n}\n\n/* TODO want to style better the values themselves (e.g. more padding, rounded corners, move label\n * inside value box for compact view for better cursor looks, etc), but first the cell editor\n * needs to learn to match the value box's style. Right now, the cell editor style is hard-coded.\n */\n.detail_theme_field_form > .g_record_detail_value {\n  border: 1px solid var(--grist-theme-card-form-border, lightgrey);\n}\n\n.detail_theme_record_form {\n  padding: 0px 12px 0px 8px;\n}\n\n.detail_theme_record_form.detailview_record_single {\n  padding-top: 8px;\n}\n\n.detail_theme_record_form.detailview_record_detail {\n  border-bottom: 1px solid var(--grist-theme-card-list-form-border, var(--grist-color-dark-grey));\n  padding-bottom: 12px;\n}\n\n/*** blocks theme ***/\n\n.detail_theme_record_blocks {\n  padding: 0px 12px 0px 8px;\n}\n\n.detail_theme_record_blocks > .detail_row_num {\n  padding-bottom: 0px;\n}\n\n.detail_theme_record_blocks.detailview_record_single {\n  padding: 8px;\n}\n\n.detail_theme_record_blocks.detailview_record_detail {\n  border-bottom: 1px solid var(--grist-theme-card-list-blocks-border, var(--grist-color-dark-grey));\n  padding-bottom: 8px;\n}\n\n.detail_theme_field_blocks {\n  padding: 6px;\n  margin: 8px;\n  background-color: var(--grist-theme-card-blocks-bg, var(--grist-color-medium-grey));\n  border-radius: 2px;\n}\n\n.detail_theme_field_blocks .g_record_detail_label {\n  font-size: var(--grist-small-font-size);\n  color: var(--grist-theme-card-blocks-label, var(--grist-color-slate));\n  font-weight: normal;\n  white-space: nowrap;\n  overflow: hidden;\n  margin-left: 3px;     /* to align with the .field_clip content */\n  margin-right: -6px;   /* allow labels to overflow into the padding */\n  margin-bottom: 4px;\n}\n\n@media print {\n  .detail_theme_record_compact {\n    background-color: var(--grist-color-medium-grey) !important;\n  }\n  .detail_theme_record_compact > .g_record_detail_inner {\n    background-color: white !important;\n  }\n  .detail_theme_field_blocks {\n    background-color: var(--grist-color-medium-grey) !important;\n  }\n}\n"
  },
  {
    "path": "app/client/components/DetailView.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport { parsePasteForView } from \"app/client/components/BaseView2\";\nimport * as selector from \"app/client/components/CellSelector\";\nimport { ElemType } from \"app/client/components/CellSelector\";\nimport { CutCallback } from \"app/client/components/Clipboard\";\nimport * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { renderAllRows } from \"app/client/components/Printing\";\nimport RecordLayout from \"app/client/components/RecordLayout\";\nimport { viewCommands } from \"app/client/components/RegionFocusSwitcher\";\nimport kd from \"app/client/lib/koDom\";\nimport koDomScrolly from \"app/client/lib/koDomScrolly\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { PasteData } from \"app/client/lib/tableUtil\";\nimport * as tableUtil from \"app/client/lib/tableUtil\";\nimport BaseRowModel from \"app/client/models/BaseRowModel\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport { CardContextMenu } from \"app/client/ui/CardContextMenu\";\nimport { FieldContextMenu } from \"app/client/ui/FieldContextMenu\";\nimport { descriptionInfoTooltip } from \"app/client/ui/tooltips\";\nimport { isNarrowScreen } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { indexOf } from \"app/common/gutil\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\n\nimport { dom } from \"grainjs\";\nimport ko from \"knockout\";\nimport _ from \"underscore\";\n\nconst t = makeT(\"DetailView\");\n\n// Disable member-ordering linting temporarily, so that it's easier to review the conversion to\n// typescript. It would be reasonable to reorder methods and re-enable this lint check.\n/* eslint-disable @typescript-eslint/member-ordering */\n\n/**\n * DetailView component implements a list of record layouts.\n */\nexport default class DetailView extends BaseView {\n  public recordLayout: RecordLayout;\n\n  protected cellSelector: selector.CellSelector;\n  protected scrolly: ko.Computed<any>;\n  protected layoutBoxIdx: ko.Observable<number>;\n  protected detailRecord: DataRowModel | null = null;   // Set whenever _isSingle is true\n  protected scrollPane: HTMLElement;\n\n  private _isSingle: boolean;\n  private _isExternalSectionPopup: boolean;\n  private _twoLastFieldIdsSelected: (number | null)[];\n\n  constructor(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {\n    super(gristDoc, viewSectionModel, { addNewRow: true });\n\n    this.cellSelector = selector.CellSelector.create(this, this);\n\n    this._isSingle = (this.viewSection.parentKey.peek() === \"single\");\n    this._isExternalSectionPopup = gristDoc.externalSectionId.get() === this.viewSection.id();\n\n    // --------------------------------------------------\n    // Create and attach the DOM for the view.\n    this.recordLayout = this.autoDispose(RecordLayout.create({\n      viewSection: this.viewSection,\n      buildFieldDom: this.buildFieldDom.bind(this),\n      buildCardContextMenu: this.buildCardContextMenu.bind(this),\n      buildFieldContextMenu: this.buildFieldContextMenu.bind(this),\n      resizeCallback: () => {\n        if (!this._isSingle) {\n          this.scrolly().updateSize();\n          // Keep the cursor in view if the scrolly height resets.\n          // TODO: Ideally the original position should be kept in scroll view.\n          this.scrolly().scrollRowIntoView(this.cursor.rowIndex.peek());\n        }\n      },\n    }));\n\n    this.scrolly = this.autoDispose(ko.computed(() => {\n      if (!this.recordLayout.isEditingLayout() && !this._isSingle) {\n        return koDomScrolly.getInstance(this.viewData);\n      }\n    }));\n\n    // Reset scrolly heights when record theme changes, since it affects heights.\n    this.autoDispose(this.viewSection.themeDef.subscribe(() => {\n      const scrolly = this.scrolly();\n      if (scrolly) {\n        setTimeout(function() { scrolly.resetHeights(); }, 0);\n      }\n    }));\n\n    this.layoutBoxIdx = ko.observable(0);\n\n    // --------------------------------------------------\n    if (this._isSingle) {\n      this.detailRecord = this.autoDispose(this.tableModel.createFloatingRowModel());\n      this._updateFloatingRow();\n      this.autoDispose(this.cursor.rowIndex.subscribe(this._updateFloatingRow, this));\n      this.autoDispose(this.viewData.subscribe(this._updateFloatingRow, this));\n    } else {\n      this.detailRecord = null;\n    }\n\n    // --------------------------------------------------\n    // Construct DOM\n    this.viewPane = this.buildDom();\n    this.onDispose(() => { dom.domDispose(this.viewPane); this.viewPane.remove(); });\n\n    // --------------------------------------------------\n    // Set up DOM event handling.\n    this._twoLastFieldIdsSelected = [null, null];\n\n    // Clicking on a detail field selects that field.\n    this.autoDispose(dom.onMatchElem(this.viewPane, \".g_record_detail_el\", \"mousedown\", (event, elem) => {\n      this.viewSection.hasFocus(true);\n      const rowModel = this.recordLayout.getContainingRow(elem as Element, this.viewPane);\n      const field = this.recordLayout.getContainingField(elem as Element, this.viewPane);\n      commands.allCommands.setCursor.run(rowModel, field);\n\n      // Trigger custom dom event that will bubble up. View components might not be rendered\n      // inside a virtual table which don't register this global handler (as there might be\n      // multiple instances of the virtual table component).\n      this.viewPane.dispatchEvent(new CustomEvent(\"setCursor\", { detail: [rowModel, field], bubbles: true }));\n\n      this._twoLastFieldIdsSelected.unshift(field.id());\n      this._twoLastFieldIdsSelected.pop();\n    }));\n\n    // Double-clicking on a field also starts editing the field.\n    this.autoDispose(dom.onMatchElem(this.viewPane, \".g_record_detail_el\", \"dblclick\", (event, elem) => {\n      this.activateEditorAtCursor({\n        event,\n      });\n    }));\n\n    // We authorize single click only on the value to avoid conflict with tooltip\n    this.autoDispose(dom.onMatchElem(this.viewPane, \".g_record_detail_value\", \"click\", (event, elem) => {\n      // If the click was place in a link, we don't want to trigger the single click\n      if ((event.target as HTMLElement)?.closest(\"a\")) { return; }\n\n      const field = this.recordLayout.getContainingField(elem as Element, this.viewPane);\n      if (\n        this._twoLastFieldIdsSelected[0] === this._twoLastFieldIdsSelected[1] &&\n        !isNarrowScreen() &&\n        this._canSingleClick(field)\n      ) {\n        this.activateEditorAtCursor({\n          event,\n        });\n      }\n    }));\n\n    // --------------------------------------------------\n    // Instantiate CommandGroups for the different modes.\n    this.autoDispose(commands.createGroup(viewCommands(DetailView.detailCommands, this),\n      this, this.viewSection.hasFocus));\n    this.autoDispose(commands.createGroup(DetailView.detailFocusedCommands, this, this.viewSection.hasRegionFocus));\n    const hasSelection = this.autoDispose(ko.pureComputed(() =>\n      Boolean(!this.cellSelector.isCurrentSelectType(\"\") || this.copySelection())));\n    this.autoDispose(commands.createGroup(DetailView.selectionCommands, this, hasSelection));\n  }\n\n  protected onTableLoaded() {\n    super.onTableLoaded();\n    this._updateFloatingRow();\n\n    const scrolly = this.scrolly();\n    if (scrolly) {\n      scrolly.scrollToSavedPos(this.viewSection.lastScrollPos);\n    }\n  }\n\n  protected _updateFloatingRow() {\n    if (this.detailRecord) {\n      this.viewData.setFloatingRowModel(this.detailRecord, this.cursor.rowIndex.peek());\n    }\n  }\n\n  /**\n   * DetailView commands, enabled when the view is the active one.\n   *\n   * See BaseView.commonCommands for more details.\n   */\n  protected static detailCommands: { [key: string]: Function } & ThisType<DetailView> = {\n    editLayout: function() {\n      if (this.scrolly()) {\n        this.scrolly().scrollRowIntoView(this.cursor.rowIndex());\n      }\n      this.recordLayout.editLayout(this.cursor.rowIndex()!);\n    },\n    hideCardFields: function() { this._hideCardFields().catch(reportError); },\n    copy: function() { return this.copy(this.getSelection()); },\n    cut: function() { return this.cut(this.getSelection()); },\n    paste: function(pasteObj: PasteData, cutCallback: CutCallback | null) {\n      return this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));\n    },\n  };\n\n  /**\n   * DetailView commands, enabled when the view is user-focused.\n   *\n   * See BaseView.commonCommands and BaseView.commonFocusedCommands for more details.\n   */\n  protected static detailFocusedCommands: { [key: string]: Function } & ThisType<DetailView> = {\n    cursorUp: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() - 1); },\n    cursorDown: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() + 1); },\n    pageUp: function() { this.cursor.rowIndex(this.cursor.rowIndex()! - 1); },\n    pageDown: function() { this.cursor.rowIndex(this.cursor.rowIndex()! + 1); },\n    clearValues: function() { this._clearCardFields()?.catch(reportError); },\n  };\n\n  protected static selectionCommands: { [key: string]: Function } & ThisType<DetailView> = {\n    clearCopySelection: function() { this._clearCopySelection(); },\n    cancel: function() { this._clearSelection(); },\n  };\n\n  // ----------------------------------------------------------------------\n  // To satisfy CellSelector interface, though it's not actually used.\n  public domToRowModel(elem: Element, elemType: ElemType): DataRowModel | undefined { return; }\n  public domToColModel(elem: Element, elemType: ElemType): DataRowModel | undefined { return; }\n\n  protected selectedRows() {\n    if (!this._isAddRow()) {\n      return [this.viewData.getRowId(this.cursor.rowIndex()!)];\n    }\n    return [];\n  }\n\n  protected async deleteRows(rowIds: number[]) {\n    const index = this.cursor.rowIndex();\n    try {\n      await super.deleteRows(rowIds);\n    } finally {\n      if (!this.isDisposed()) {\n        this.cursor.rowIndex(index);\n      }\n    }\n  }\n\n  /**\n   * Pastes the provided data at the current cursor.\n   *\n   * @param {Array} data - Array of arrays of data to be pasted. Each array represents a row.\n   * i.e.  [[\"1-1\", \"1-2\", \"1-3\"],\n   *        [\"2-1\", \"2-2\", \"2-3\"]]\n   * @param {Function} cutCallback - If provided returns the record removal action needed\n   *  for a cut.\n   */\n  protected async paste(data: PasteData, cutCallback: CutCallback | null) {\n    const pasteData = data[0][0];\n    const field = this.viewSection.viewFields().at(this.cursor.fieldIndex())!;\n    const isCompletePaste = (data.length === 1 && data[0].length === 1);\n\n    const richData = await parsePasteForView([[pasteData]] as PasteData, [field], this.gristDoc);\n    if (_.isEmpty(richData)) {\n      return;\n    }\n\n    // Array containing the paste action to which the cut action will be added if it exists.\n    const rowId = this.viewData.getRowId(this.cursor.rowIndex()!);\n    const action = (rowId === \"new\") ? [\"BulkAddRecord\", [null], richData] :\n      [\"BulkUpdateRecord\", [rowId], richData];\n    const cursorPos = this.cursor.getCursorPos();\n\n    return this.sendPasteActions(isCompletePaste ? cutCallback : null,\n      this.prepTableActions([action]))\n      .then((results) => {\n        // If a row was added, get its rowId from the action results.\n        const addRowId = (action[0] === \"BulkAddRecord\" ? results[0][0] : null);\n        // Restore the cursor to the right rowId, even if it jumped.\n        this.cursor.setCursorPos({ rowId: cursorPos.rowId === \"new\" ? addRowId : cursorPos.rowId });\n        commands.allCommands.clearCopySelection.run();\n      });\n  }\n\n  protected buildCardContextMenu(row: DataRowModel) {\n    const cardOptions = this._getCardContextMenuOptions(row);\n    return CardContextMenu(cardOptions);\n  }\n\n  protected buildFieldContextMenu() {\n    const fieldOptions = this._getFieldContextMenuOptions();\n    return FieldContextMenu(fieldOptions);\n  }\n\n  /**\n   * Builds the DOM for the given field of the given row.\n   * @param {MetaRowModel|String} field: Model for the field to render. For a new field being added,\n   *    this may instead be an object with {isNewField:true, colRef, label, value}.\n   * @param {DataRowModel} row: The record of data from which to render the given field.\n   */\n  protected buildFieldDom(field: RecordLayout.NewField | ViewFieldRec, row: DataRowModel) {\n    if (\"isNewField\" in field) {\n      return dom(\"div.g_record_detail_el.flexitem\",\n        dom.cls(use => \"detail_theme_field_\" + use(this.viewSection.themeDef)),\n        dom(\"div.g_record_detail_label_container\",\n          dom(\"div.g_record_detail_label\", field.label),\n        ),\n        dom(\"div.g_record_detail_value\"),\n      );\n    }\n\n    const isCellSelected = ko.pureComputed(() => {\n      return this.cursor.fieldIndex() === (field?._index()) &&\n        this.cursor.rowIndex() === (row?._index());\n    });\n    const isCellActive = ko.pureComputed(() => {\n      return this.viewSection.hasFocus() && isCellSelected();\n    });\n\n    // Whether the cell is part of an active copy-paste operation.\n    const isCopyActive = ko.computed(() => {\n      return Boolean(this.copySelection()?.isCellSelected(row.getRowId(), field.colId()));\n    });\n\n    this.autoDispose(isCellSelected.subscribe((yesNo) => {\n      if (yesNo) {\n        const layoutBox = fieldDom.closest(\".layout_hbox\")!;\n        this.layoutBoxIdx(indexOf(layoutBox.parentElement!.childNodes, layoutBox));\n      }\n    }));\n    const fieldBuilder = this.fieldBuilders.at(field._index()!)!;\n    const fieldDom = dom(\"div.g_record_detail_el.flexitem\",\n      dom.autoDispose(isCellSelected),\n      dom.autoDispose(isCellActive),\n      dom.cls(use => \"detail_theme_field_\" + use(this.viewSection.themeDef)),\n      dom(\"div.g_record_detail_label_container\",\n        dom(\"div.g_record_detail_label\", dom.text(field.displayLabel)),\n        dom.domComputed(use => use(field.description),\n          desc => desc ? descriptionInfoTooltip(desc, \"column\") : null),\n      ),\n      dom(\"div.g_record_detail_value\",\n        dom.cls(\"scissors\", isCopyActive),\n        dom.cls(\"record-add\", row._isAddRow),\n        dom.autoDispose(isCopyActive),\n        // Optional icon. Currently only use to show formula icon.\n        dom(\"div.field-icon\"),\n        fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected),\n      ),\n    );\n    return fieldDom;\n  }\n\n  protected buildDom() {\n    return dom(\"div.flexvbox.flexitem\",\n      // Add .detailview_single when showing a single card or while editing layout.\n      dom.cls(\"detailview_single\",\n        use => this._isSingle || use(this.recordLayout.isEditingLayout)),\n      // Add a marker class that editor is active - used for hiding context menu toggle.\n      dom.cls(\"detailview_layout_editor\", this.recordLayout.isEditingLayout),\n      dom.maybe(this.recordLayout.isEditingLayout, () => {\n        const rowId = this.viewData.getRowId(this.recordLayout.editIndex.peek());\n        const record = this.getRenderedRowModel(rowId);\n        return dom.update(\n          this.recordLayout.buildLayoutDom(record, true),\n          dom.cls(use => \"detail_theme_record_\" + use(this.viewSection.themeDef)),\n          dom.cls(\"detailview_record_\" + this.viewSection.parentKey.peek()),\n        );\n      }),\n      dom.maybe(use => !use(this.recordLayout.isEditingLayout), () => {\n        if (!this._isSingle) {\n          return this.scrollPane = dom(\"div.detailview_scroll_pane.flexitem\",\n            kd.scrollChildIntoView(this.cursor.rowIndex),\n            dom.onDispose(() => {\n              // Save the previous scroll values to the section.\n              if (this.scrolly()) {\n                this.viewSection.lastScrollPos = this.scrolly().getScrollPos();\n              }\n            }),\n            koDomScrolly.scrolly(this.viewData, { fitToWidth: true },\n              (row: DataRowModel) => this.makeRecord(row)),\n\n            dom.maybe(this._isPrinting, () =>\n              renderAllRows(this.tableModel, this.sortedRows.getKoArray().peek(), row =>\n                this.makeRecord(row)),\n            ),\n          );\n        } else {\n          return dom.domComputed((use) => {\n            // If this.disableEditing is set, there's already an overlay in place hiding this view.\n            if (use(this.cursor.rowIndex) === null && !use(this.disableEditing)) {\n              return dom(\"div\",\n                dom(\"div.disable_viewpane.flexvbox\", t(\"This row is unavailable or does not exist\")),\n              );\n            } else {\n              return dom.update(\n                this.makeRecord(this.detailRecord!),\n                kd.domData(\"itemModel\", this.detailRecord),\n                dom.hide(use2 => use2(this.cursor.rowIndex) === null),\n              );\n            }\n          });\n        }\n      }),\n    );\n  }\n\n  public override buildTitleControls() {\n    // Hide controls if this is a card list section, or if the section has a scroll cursor link, since\n    // the controls can be confusing in this case.\n    // Note that the controls should still be visible with a filter link.\n    const showControls = ko.computed(() => {\n      if (\n        !this._isSingle ||\n        this.recordLayout.layoutEditor() ||\n        this._isExternalSectionPopup\n      ) {\n        return false;\n      }\n      if (this.viewSection.hideViewMenu()) {\n        return false;\n      }\n      const linkingState = this.viewSection.linkingState();\n      return !(linkingState && Boolean(linkingState.cursorPos));\n    });\n    return dom(\"div\",\n      dom.autoDispose(showControls),\n\n      dom.cls(\"record-layout-editor\", use => Boolean(use(this.recordLayout.layoutEditor))),\n      dom.maybe(this.recordLayout.layoutEditor, (editor: any) => editor.buildEditorDom()),\n\n      dom.maybe(showControls, () => dom(\"div.grist-single-record__menu.flexhbox.flexnone\",\n        dom(\"div.grist-single-record__menu__count.flexitem\",\n          // Total should not include the add record row\n          kd.text(() => this._isAddRow() ? \"Add record\" :\n            `${this.cursor.rowIndex()! + 1} of ${this.getLastDataRowIndex() + 1}`),\n        ),\n        dom(\"div.detail-buttons\",\n          dom(\"div.detail-button.detail-left\",\n            icon(\"ArrowLeft\"),\n            dom.on(\"click\", () => { this.cursor.rowIndex(this.cursor.rowIndex()! - 1); }),\n            dom.cls(\"disabled\", use => use(this.cursor.rowIndex) === 0),\n          ),\n          dom(\"div.detail-button.detail-right\",\n            icon(\"ArrowRight\"),\n            dom.on(\"click\", () => { this.cursor.rowIndex(this.cursor.rowIndex()! + 1); }),\n            dom.cls(\"disabled\", use => use(this.cursor.rowIndex)! >= this.viewData.all().length - 1),\n          ),\n          dom(\"div.detail-button.detail-add-btn\",\n            icon(\"Plus\"),\n            dom.on(\"click\", () => {\n              const addRowIndex = this.viewData.getRowIndex(\"new\");\n              this.cursor.rowIndex(addRowIndex);\n            }),\n            dom.cls(\"disabled\", use => this.viewData.getRowId(use(this.cursor.rowIndex)!) === \"new\"),\n          ),\n        ),\n      )),\n    );\n  }\n\n  public override onNewRecordRequest() {\n    const addRowIndex = this.viewData.getRowIndex(\"new\");\n    this.cursor.rowIndex(addRowIndex);\n  }\n\n  public override onResize() {\n    const scrolly = this.scrolly();\n    if (scrolly) {\n      scrolly.scheduleUpdateSize();\n    }\n  }\n\n  public override onRowResize(rowModels: BaseRowModel[]): void {\n    const scrolly = this.scrolly();\n    if (scrolly) {\n      scrolly.resetItemHeights(rowModels);\n    }\n  }\n\n  protected makeRecord(record: DataRowModel) {\n    return dom.update(\n      this.recordLayout.buildLayoutDom(record),\n      dom.cls(use => \"detail_theme_record_\" + use(this.viewSection.themeDef)),\n      this.comparison ? dom.cls((use) => {\n        const rowType = this.extraRows.getRowType(use(record.id));\n        return rowType && `diff-${rowType}` || \"\";\n      }) : null,\n      dom.cls(\"active\", use =>\n        (use(this.cursor.rowIndex) === use(record._index) && use(this.viewSection.hasFocus))),\n      dom.cls(\"selected\", use =>\n        (use(this.cursor.rowIndex) === use(record._index)  && !use(this.viewSection.hasFocus))),\n      // 'detailview_record_single' or 'detailview_record_detail' doesn't need to be an observable,\n      // since a change to parentKey would cause a separate call to makeRecord.\n      dom.cls(\"detailview_record_\" + this.viewSection.parentKey.peek()),\n    );\n  }\n\n  /**\n   * Extends BaseView getRenderedRowModel. Called to obtain the rowModel for the given rowId.\n   * Returns the rowModel if it is rendered in the current view type, otherwise returns null.\n   */\n  protected override getRenderedRowModel(rowId: UIRowId) {\n    if (this.detailRecord) {\n      return this.detailRecord.getRowId() === rowId ? this.detailRecord : undefined;\n    } else {\n      return this.viewData.getRowModel(rowId);\n    }\n  }\n\n  /**\n   * Returns a boolean indicating whether the given index is the index of the add row.\n   * Index defaults to the current index of the cursor.\n   */\n  protected _isAddRow(index: number | null = this.cursor.rowIndex()) {\n    return index !== null && this.viewData.getRowId(index) === \"new\";\n  }\n\n  public async scrollToCursor(sync: boolean = true): Promise<void> {\n    if (!this.scrollPane) { return; }\n    return kd.doScrollChildIntoView(this.scrollPane, this.cursor.rowIndex(), sync);\n  }\n\n  protected async _duplicateRows(): Promise<number[] | undefined> {\n    const addRowIds = await super._duplicateRows();\n    if (!addRowIds || addRowIds.length === 0) {\n      return;\n    }\n\n    this.setCursorPos({ rowId: addRowIds[0] });\n  }\n\n  protected _canSingleClick(field: ViewFieldRec) {\n    // we can't single click if :\n    // - the field is a formula\n    // - the field is toggle (switch or checkbox)\n    if (\n      field.column().isRealFormula() ||\n      field.column().hasTriggerFormula() ||\n      (\n        field.column().pureType() === \"Bool\" &&\n        [\"Switch\", \"CheckBox\"].includes(field.visibleColFormatter().widgetOpts.widget)\n      )\n    ) {\n      return false;\n    }\n    return true;\n  }\n\n  protected _clearCardFields() {\n    if (this.gristDoc.isReadonly.get()) {\n      return;\n    }\n\n    const selection = this.getSelection();\n    const isFormula = Boolean(selection.fields[0]?.column.peek().isRealFormula.peek());\n    if (isFormula) {\n      this.activateEditorAtCursor({ init: \"\" });\n    } else {\n      const clearAction = tableUtil.makeDeleteAction(this.getSelection());\n      if (clearAction) {\n        return this.gristDoc.docData.sendAction(clearAction);\n      }\n    }\n  }\n\n  protected _hideCardFields() {\n    const selection = this.getSelection();\n    const actions = selection.fields.map(field => [\"RemoveRecord\", field.id()]);\n    return this.gristDoc.docModel.viewFields.sendTableActions(\n      actions,\n      `Hide fields ${actions.map(a => a[1]).join(\", \")} ` +\n      `from ${this.tableModel.tableData.tableId}.`,\n    );\n  }\n\n  protected _clearSelection() {\n    this.copySelection(null);\n    this.cellSelector.setToCursor();\n  }\n\n  protected _clearCopySelection() {\n    this.copySelection(null);\n  }\n\n  protected _getCardContextMenuOptions(row: DataRowModel) {\n    return {\n      disableInsert: Boolean(\n        this.gristDoc.isReadonly.get() ||\n        this.viewSection.disableAddRemoveRows() ||\n        this.tableModel.tableMetaRow.onDemand(),\n      ),\n      disableDelete: Boolean(\n        this.gristDoc.isReadonly.get() ||\n        this.viewSection.disableAddRemoveRows() ||\n        row._isAddRow(),\n      ),\n      isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,\n      numRows: this.getSelection().rowIds.length,\n    };\n  }\n\n  protected _getFieldContextMenuOptions() {\n    const selection = this.getSelection();\n    return {\n      disableModify: Boolean(selection.fields[0]?.disableModify.peek()),\n      isReadonly: this.gristDoc.isReadonly.get() || this.isPreview,\n      field: selection.fields[0],\n      isAddRow: selection.onlyAddRowSelected(),\n    };\n  }\n}\n"
  },
  {
    "path": "app/client/components/DocComm.ts",
    "content": "import { Comm } from \"app/client/components/Comm\";\nimport { reportError, reportMessage } from \"app/client/models/errors\";\nimport { Notifier } from \"app/client/models/NotifyModel\";\nimport { ActiveDocAPI, ApplyUAOptions, ApplyUAResult } from \"app/common/ActiveDocAPI\";\nimport { CommMessage } from \"app/common/CommTypes\";\nimport { UserAction } from \"app/common/DocActions\";\nimport { OpenLocalDocResult } from \"app/common/DocListAPI\";\nimport { docUrl } from \"app/common/urlUtils\";\n\nimport { Events as BackboneEvents } from \"backbone\";\nimport { Disposable, Emitter } from \"grainjs\";\n\nconst SLOW_NOTIFICATION_TIMEOUT_MS = 1000; // applies to user actions only\n\n/**\n * The type of data.methods object created by openDoc() in app/client/components/Comm.js.\n * This is used in much of client-side code, and exposed firstly as GristDoc.docComm.\n */\nexport class DocComm extends Disposable implements ActiveDocAPI {\n  // These are all the methods of ActiveDocAPI. Listing them explicitly lets typescript verify\n  // that we haven't missed any.\n  // closeDoc has a special implementation below.\n  public fetchTable = this._wrapMethod(\"fetchTable\");\n  public fetchPythonCode = this._wrapMethod(\"fetchPythonCode\");\n  public useQuerySet = this._wrapMethod(\"useQuerySet\");\n  public disposeQuerySet = this._wrapMethod(\"disposeQuerySet\");\n  // applyUserActions has a special implementation below.\n  public applyUserActionsById = this._wrapMethod(\"applyUserActionsById\");\n  public importFiles = this._wrapMethod(\"importFiles\");\n  public finishImportFiles = this._wrapMethod(\"finishImportFiles\");\n  public cancelImportFiles = this._wrapMethod(\"cancelImportFiles\");\n  public generateImportDiff = this._wrapMethod(\"generateImportDiff\");\n  public addAttachments = this._wrapMethod(\"addAttachments\");\n  public findColFromValues = this._wrapMethod(\"findColFromValues\");\n  public getFormulaError = this._wrapMethod(\"getFormulaError\");\n  public fetchURL = this._wrapMethod(\"fetchURL\");\n  public autocomplete = this._wrapMethod(\"autocomplete\");\n  public getActionSummaries = this._wrapMethod(\"getActionSummaries\");\n  public startBundleUserActions = this._wrapMethod(\"startBundleUserActions\");\n  public stopBundleUserActions = this._wrapMethod(\"stopBundleUserActions\");\n  public forwardPluginRpc = this._wrapMethod(\"forwardPluginRpc\");\n  public reloadPlugins = this._wrapMethod(\"reloadPlugins\");\n  public reloadDoc = this._wrapMethod(\"reloadDoc\");\n  public fork = this._wrapMethod(\"fork\");\n  public checkAclFormula = this._wrapMethod(\"checkAclFormula\");\n  public getAclResources = this._wrapMethod(\"getAclResources\");\n  public waitForInitialization = this._wrapMethod(\"waitForInitialization\");\n  public getUsersForViewAs = this._wrapMethod(\"getUsersForViewAs\");\n  public getAccessToken = this._wrapMethod(\"getAccessToken\");\n  public getShare = this._wrapMethod(\"getShare\");\n  public startTiming = this._wrapMethod(\"startTiming\");\n  public stopTiming = this._wrapMethod(\"stopTiming\");\n  public getAssistantState = this._wrapMethod(\"getAssistantState\");\n  public listActiveUserProfiles = this._wrapMethod(\"listActiveUserProfiles\");\n  public applyProposal = this._wrapMethod(\"applyProposal\");\n  public getAssistance = this._wrapMethod(\"getAssistance\");\n\n  public changeUrlIdEmitter = this.autoDispose(new Emitter());\n\n  // We save the clientId that was used when opening the doc. If it changes (e.g. reconnecting to\n  // another server), it would be incorrect to use the new clientId without re-opening the doc\n  // (which is handled by App.ts). This way, Comm can protect against mismatched clientIds.\n  private _clientId: string;\n  private _docFD: number;\n  private _forkPromise: Promise<void> | null = null;\n  private _isClosed: boolean = false;\n  private listenTo: BackboneEvents[\"listenTo\"];  // set by Backbone\n\n  constructor(private _comm: Comm, openResponse: OpenLocalDocResult, private _docId: string,\n    private _notifier: Notifier) {\n    super();\n    this._setOpenResponse(openResponse);\n    // If *this* doc is shutdown forcibly (e.g. via reloadDoc call), mark it as closed, so we\n    // don't attempt to close it again.\n    this.listenTo(_comm, \"docShutdown\", (m: CommMessage) => {\n      if (this.isActionFromThisDoc(m)) { this._isClosed = true; }\n    });\n    this.onDispose(async () => {\n      try {\n        await this._shutdown();\n      } catch (e) {\n        if (!String(e).match(/GristWSConnection disposed/)) {\n          reportError(e);\n        }\n      }\n    });\n  }\n\n  // Returns the URL params that identifying this open document to the DocWorker\n  // (used e.g. in attachment and download URLs).\n  public getUrlParams(): { clientId: string, docFD: number } {\n    return { clientId: this._clientId, docFD: this._docFD };\n  }\n\n  // Completes a path by adding the correct worker host and prefix for this document.\n  // E.g. \"/uploads\" becomes \"https://host.name/v/ver/o/org/uploads\"\n  public docUrl(path: string) {\n    return docUrl(this.docWorkerUrl, path);\n  }\n\n  // Returns a base url to the worker serving the current document, e.g.\n  // \"https://host.name/v/ver/\"\n  public get docWorkerUrl() {\n    return this._comm.getDocWorkerUrl(this._docId);\n  }\n\n  // Returns whether a message received by this Comm object is for the current doc.\n  public isActionFromThisDoc(message: CommMessage): boolean {\n    return message.docFD === this._docFD;\n  }\n\n  /**\n   * Overrides applyUserActions() method to also add the UserActions to a list, for use in tests.\n   */\n  public applyUserActions(actions: UserAction[], options?: ApplyUAOptions): Promise<ApplyUAResult> {\n    this._comm.addUserActions(actions);\n    return this._callMethod(\"applyUserActions\", actions, options);\n  }\n\n  /**\n   * Overrides closeDoc() method to call to Comm directly, without triggering forking logic.\n   * This is important in particular since it may be called while forking.\n   */\n  public closeDoc(): Promise<void> {\n    return this._callDocMethod(\"closeDoc\");\n  }\n\n  /**\n   * Forks the document, making sure the url gets updated, and holding any actions\n   * until the fork is complete.  If a fork has already been started/completed, this\n   * does nothing.\n   */\n  public async forkAndUpdateUrl(): Promise<void> {\n    await (this._forkPromise || (this._forkPromise = this._doForkDoc()));\n  }\n\n  // Clean up connection after closing doc.\n  private async _shutdown() {\n    console.log(`DocComm: shutdown clientId ${this._clientId} docFD ${this._docFD}`);\n    try {\n      // Close the document to unsubscribe from further updates on it.\n      if (!this._isClosed) {\n        await this.closeDoc();\n      }\n    } catch (err) {\n      console.warn(`DocComm: closeDoc failed: ${err}`);\n    } finally {\n      if (!this._comm.isDisposed()) {\n        this._comm.releaseDocConnection(this._docId);\n      }\n    }\n  }\n\n  /**\n   * Store important information from the response to openDoc, and\n   * ensure we have a connection to a docWorker for the document\n   * identified by the current docId.  the caller of _setOpenResponse\n   * should call _releaseDocConnection for any previous docId.\n   */\n  private _setOpenResponse(openResponse: OpenLocalDocResult) {\n    this._docFD = openResponse.docFD;\n    this._clientId = openResponse.clientId;\n    this._comm.useDocConnection(this._docId);\n  }\n\n  private _wrapMethod<Name extends keyof ActiveDocAPI>(name: Name): ActiveDocAPI[Name] {\n    return this._callMethod.bind(this, name);\n  }\n\n  private async _callMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {\n    return this._notifier.slowNotification(this._doCallMethod(name, ...args), SLOW_NOTIFICATION_TIMEOUT_MS);\n  }\n\n  private async _doCallMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {\n    if (this._forkPromise) {\n      // If a fork is pending or has finished, call the method after waiting for it.\n      // (If we've gone through a fork, we will not consider forking again.)\n      await this._forkPromise;\n      return this._callDocMethod(name, ...args);\n    }\n    try {\n      return await this._callDocMethod(name, ...args);\n    } catch (err) {\n      // TODO should be the suggested fork id and fork user.\n      if (err.shouldFork) {\n        // If the server suggests to fork, do it now, or wait for the fork already pending.\n        await this.forkAndUpdateUrl();\n        return this._callDocMethod(name, ...args);\n      }\n      throw err;\n    }\n  }\n\n  private _callDocMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {\n    return this._comm._makeRequest(this._clientId, this._docId, name, this._docFD, ...args);\n  }\n\n  private async _doForkDoc(): Promise<void> {\n    reportMessage(\"Preparing your copy...\", { key: \"forking\" });\n    const { urlId, docId } = await this.fork();\n    // TODO: may want to preserve linkParameters in call to openDoc.\n    const openResponse = await this._comm.openDoc(docId);\n    // Close the old doc and release the old connection. Note that the closeDoc call is expected\n    // to fail, since we close the websocket immediately after it. So let it fail silently.\n    this.closeDoc().catch(() => null);\n    this._comm.releaseDocConnection(this._docId);\n    this._docId = docId;\n    this._setOpenResponse(openResponse);\n    this.changeUrlIdEmitter.emit(urlId, openResponse);\n    reportMessage(\"You are now editing your own copy\", { key: \"forking\" });\n  }\n}\n\nObject.assign(DocComm.prototype, BackboneEvents);\n"
  },
  {
    "path": "app/client/components/DocumentUsage.ts",
    "content": "import { cssBannerLink } from \"app/client/components/Banner\";\nimport { getExternalStorageRecommendation } from \"app/client/components/ExternalAttachmentBanner\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { docListHeader } from \"app/client/ui/DocMenuCss\";\nimport { Tooltip } from \"app/client/ui/GristTooltips\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { mediaXSmall, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { loadingDots, loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { FilteredDocUsageSummary } from \"app/common/DocUsage\";\nimport { displayPlanName, Features, isFreePlan } from \"app/common/Features\";\nimport { capitalizeFirstWord } from \"app/common/gutil\";\nimport { APPROACHING_LIMIT_RATIO } from \"app/common/Limits\";\nimport { canUpgradeOrg } from \"app/common/roles\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Computed, Disposable, dom, DomContents, DomElementArg, makeTestId, styled } from \"grainjs\";\n\nconst t = makeT(\"DocumentUsage\");\n\nconst testId = makeTestId(\"test-doc-usage-\");\n\nconst { deploymentType } = getGristConfig();\n\n// Default used by the progress bar to visually indicate row usage.\n// For self-hosters, the 20,000 rows limit is not actually the limit\n// So we use a larger default value to avoid confusion.\nconst DEFAULT_MAX_ROWS = deploymentType == \"saas\" ? 20000 : 150000;\n\n// Default used by the progress bar to visually indicate data size usage.\n// 40MB on SaaS, 300MB for self-hosters (2KiB per row)\nconst DEFAULT_MAX_DATA_SIZE = DEFAULT_MAX_ROWS * 2 * 1024;\n\n// Default used by the progress bar to visually indicate attachments size usage.\nconst DEFAULT_MAX_ATTACHMENTS_SIZE = 1 * 1024 * 1024 * 1024; // 1GiB\n\n/**\n * Displays statistics about document usage, such as number of rows used.\n */\nexport class DocumentUsage extends Disposable {\n  private readonly _currentDoc = this._docPageModel.currentDoc;\n  private readonly _currentDocUsage = this._docPageModel.currentDocUsage;\n  private readonly _currentOrg = this._docPageModel.currentOrg;\n  private readonly _currentProduct = this._docPageModel.currentProduct;\n  private readonly _currentFeatures = this._docPageModel.currentFeatures;\n\n  // TODO: Update this whenever the rest of the UI is internationalized.\n  private readonly _rowCountFormatter = new Intl.NumberFormat(\"en-US\");\n\n  private readonly _rowCount = Computed.create(this, this._currentDocUsage, (_use, usage) => {\n    return usage?.rowCount;\n  });\n\n  private readonly _dataSizeBytes = Computed.create(this, this._currentDocUsage, (_use, usage) => {\n    return usage?.dataSizeBytes;\n  });\n\n  private readonly _attachmentsSizeBytes = Computed.create(this, this._currentDocUsage, (_use, usage) => {\n    return usage?.attachmentsSizeBytes;\n  });\n\n  private readonly _rowMetricOptions: Computed<MetricOptions> =\n    Computed.create(this, this._currentFeatures, this._rowCount, (_use, features, rowCount) => {\n      const maxRows = features?.baseMaxRowsPerDocument;\n      // Invalid row limits are currently treated as if they are undefined.\n      const maxValue = maxRows && maxRows > 0 ? maxRows : undefined;\n      return {\n        name: t(\"Rows\"),\n        currentValue: typeof rowCount !== \"object\" ? undefined : rowCount.total,\n        maximumValue: maxValue ?? DEFAULT_MAX_ROWS,\n        unit: \"rows\",\n        shouldHideLimits: maxValue === undefined,\n        formatValue: val => this._rowCountFormatter.format(val),\n      };\n    });\n\n  private readonly _dataSizeMetricOptions: Computed<MetricOptions> =\n    Computed.create(this, this._currentFeatures, this._dataSizeBytes, (_use, features, dataSize) => {\n      const maxSize = features?.baseMaxDataSizePerDocument;\n      // Invalid data size limits are currently treated as if they are undefined.\n      const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;\n      return {\n        name: t(\"Data size\"),\n        currentValue: typeof dataSize !== \"number\" ? undefined : dataSize,\n        maximumValue: maxValue ?? DEFAULT_MAX_DATA_SIZE,\n        unit: \"MB\",\n        shouldHideLimits: maxValue === undefined,\n        tooltip: \"dataSize\",\n        formatValue: (val) => {\n          // To display a nice, round number for `maximumValue`, we first convert\n          // to KiBs (base-2), and then convert to MBs (base-10). Normally, we wouldn't\n          // mix conversions like this, but to display something that matches our\n          // marketing limits (e.g. 40MB for Pro plan), we need to bend conversions a bit.\n          return ((val / 1024) / 1000).toFixed(2);\n        },\n      };\n    });\n\n  private readonly _attachmentsSizeMetricOptions: Computed<MetricOptions> =\n    Computed.create(this, this._currentFeatures, this._attachmentsSizeBytes, (_use, features, attachmentsSize) => {\n      const maxSize: number | undefined = features?.baseMaxAttachmentsBytesPerDocument;\n      // Invalid attachments size limits are currently treated as if they are undefined.\n      const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;\n      return {\n        name: t(\"Size of attachments\"),\n        currentValue: typeof attachmentsSize !== \"number\" ? undefined : attachmentsSize,\n        maximumValue: maxValue ?? DEFAULT_MAX_ATTACHMENTS_SIZE,\n        unit: \"GB\",\n        shouldHideLimits: maxValue === undefined,\n        formatValue: val => (val / (1024 * 1024 * 1024)).toFixed(2),\n      };\n    });\n\n  private readonly _areAllMetricsPending: Computed<boolean> =\n    Computed.create(\n      this, this._currentDoc, this._rowCount, this._dataSizeBytes, this._attachmentsSizeBytes,\n      (_use, doc, rowCount, dataSize, attachmentsSize) => {\n        const hasNonPendingMetrics = [rowCount, dataSize, attachmentsSize]\n          .some(metric => metric !== \"pending\" && metric !== undefined);\n        return !doc || !hasNonPendingMetrics;\n      },\n    );\n\n  private readonly _isAccessDenied: Computed<boolean | null> =\n    Computed.create(this, this._areAllMetricsPending, this._currentDoc, this._rowCount,\n      this._dataSizeBytes, this._attachmentsSizeBytes,\n      (_use, isLoading, doc, rowCount, dataSize, attachmentsSize) => {\n        if (isLoading) { return null; }\n\n        const { access } = doc!.workspace.org;\n        const isPublicUser = access === \"guests\" || access === null;\n        const hasHiddenMetrics = [rowCount, dataSize, attachmentsSize].some(metric => metric === \"hidden\");\n        return isPublicUser || hasHiddenMetrics;\n      },\n    );\n\n  constructor(private _docPageModel: DocPageModel) {\n    super();\n  }\n\n  public buildDom() {\n    return dom(\"div\",\n      cssHeader(t(\"Usage\"), testId(\"heading\")),\n      dom.domComputed(this._areAllMetricsPending, (isLoading) => {\n        if (isLoading) { return cssSpinner(loadingSpinner(), testId(\"loading\")); }\n\n        return [this._buildMessage(), this._buildMetrics()];\n      }),\n      testId(\"container\"),\n    );\n  }\n\n  private _buildMessage() {\n    return dom.domComputed((use) => {\n      const isAccessDenied = use(this._isAccessDenied);\n      if (isAccessDenied === null) { return null; }\n      if (isAccessDenied) {\n        return buildMessage(t(\"Usage statistics are only available to users with full access to the document data.\"));\n      }\n\n      const org = use(this._currentOrg);\n      const product = use(this._currentProduct);\n      const features = use(this._currentFeatures);\n      const usageInfo = use(this._currentDocUsage);\n      if (!org || !usageInfo) { return null; }\n      const productName = use(this._currentProduct)?.name || \"\";\n      const planLabel = displayPlanName[productName] || productName;\n\n      return [\n        // Pass on external storage recommendation if there is one.\n        usageInfo.usageRecommendations.recommendExternal ? buildMessage(getExternalStorageRecommendation()) : null,\n\n        // If usage limits have kicked in, say so.\n        usageInfo?.dataLimitInfo?.status ? buildMessage([\n          buildLimitStatusMessage(planLabel, usageInfo, features, {\n            disableRawDataLink: true,\n          }),\n          (product && isFreePlan(product.name) ?\n            [\" \", buildUpgradeMessage(\n              canUpgradeOrg(org),\n              \"long\",\n              () =>  this._docPageModel.appModel.showUpgradeModal(),\n            )] :\n            null\n          ),\n        ]) : null,\n      ];\n    });\n  }\n\n  private _buildMetrics() {\n    return dom.maybe(use => use(this._isAccessDenied) === false, () =>\n      cssUsageMetrics(\n        dom.domComputed(this._rowMetricOptions, metrics =>\n          buildUsageMetric(metrics, testId(\"rows\")),\n        ),\n        dom.domComputed(this._dataSizeMetricOptions, metrics =>\n          buildUsageMetric(metrics, testId(\"data-size\")),\n        ),\n        dom.domComputed(this._attachmentsSizeMetricOptions, metrics =>\n          buildUsageMetric(metrics, testId(\"attachments-size\")),\n        ),\n        testId(\"metrics\"),\n      ),\n    );\n  }\n}\n\nexport function buildLimitStatusMessage(\n  planName: string,\n  usageInfo: NonNullable<Pick<FilteredDocUsageSummary, \"dataLimitInfo\">>,\n  features?: Features | null,\n  options: {\n    disableRawDataLink?: boolean;\n  } = {},\n) {\n  const { disableRawDataLink = false } = options;\n  const { status, daysRemaining } = usageInfo.dataLimitInfo;\n  switch (status) {\n    case \"approachingLimit\": {\n      return [\n        \"This document is \",\n        disableRawDataLink ? \"approaching\" : buildRawDataPageLink(\"approaching\"),\n        ` ${planName} plan limits.`,\n      ];\n    }\n    case \"gracePeriod\": {\n      const gracePeriodDays = features?.gracePeriodDays;\n      if (!gracePeriodDays) {\n        return [\n          \"Document limits \",\n          disableRawDataLink ? \"exceeded\" : buildRawDataPageLink(\"exceeded\"),\n          \".\",\n        ];\n      }\n\n      return [\n        \"Document limits \",\n        disableRawDataLink ? \"exceeded\" : buildRawDataPageLink(\"exceeded\"),\n        `. In ${daysRemaining} days, this document will be read-only.`,\n      ];\n    }\n    case \"deleteOnly\": {\n      return [\n        \"This document \",\n        disableRawDataLink ? \"exceeded\" : buildRawDataPageLink(\"exceeded\"),\n        ` ${planName} plan limits and is now read-only, but you can delete rows.`,\n      ];\n    }\n  }\n}\n\nexport function buildUpgradeMessage(\n  canUpgrade: boolean,\n  variant: \"short\" | \"long\",\n  onUpgrade: () => void,\n) {\n  if (!canUpgrade) { return t(\"Contact the site owner to upgrade the plan to raise limits.\"); }\n\n  const upgradeLinkText = t(\"start your 30-day free trial of the Pro plan.\");\n  // TODO i18next\n  return [\n    variant === \"short\" ? null : t(\"For higher limits, \"),\n    buildUpgradeLink(\n      variant === \"short\" ? capitalizeFirstWord(upgradeLinkText) : upgradeLinkText,\n      () => onUpgrade(),\n    ),\n  ];\n}\n\nfunction buildUpgradeLink(linkText: string, onClick: () => void) {\n  return cssBannerLink(linkText, dom.on(\"click\", () => onClick()));\n}\n\nfunction buildRawDataPageLink(linkText: string) {\n  return cssBannerLink(linkText, urlState().setLinkUrl({ docPage: \"data\" }));\n}\n\ninterface MetricOptions {\n  name: string;\n  // If undefined, loading dots will be shown.\n  currentValue?: number;\n  // If undefined or non-positive (i.e. invalid), no limits will be assumed.\n  maximumValue?: number;\n  unit?: string;\n  // If true, limits will always be hidden, even if `maximumValue` is a positive number.\n  shouldHideLimits?: boolean;\n  // Shows an icon next to the metric name that displays a tooltip on hover.\n  tooltip?: Tooltip;\n  formatValue?(value: number): string;\n}\n\n/**\n * Builds a component which displays the current and maximum values for\n * a particular metric (e.g. row count), and a progress meter showing how\n * close `currentValue` is to hitting `maximumValue`.\n */\nfunction buildUsageMetric(options: MetricOptions, ...domArgs: DomElementArg[]) {\n  const { name, tooltip } = options;\n  return cssUsageMetric(\n    cssMetricName(\n      tooltip ?\n        withInfoTooltip(cssOverflowableText(name, testId(\"name\")), tooltip) :\n        cssOverflowableText(name, testId(\"name\")),\n    ),\n    buildUsageProgressBar(options),\n    ...domArgs,\n  );\n}\n\nfunction buildUsageProgressBar(options: MetricOptions) {\n  const {\n    currentValue,\n    maximumValue,\n    shouldHideLimits,\n    unit,\n    formatValue = n => n.toString(),\n  } = options;\n\n  let ratioUsed: number;\n  let percentUsed: number;\n  if (currentValue === undefined) {\n    ratioUsed = 0;\n    percentUsed = 0;\n  } else {\n    ratioUsed = currentValue / (maximumValue || Infinity);\n    percentUsed = Math.min(100, Math.floor(ratioUsed * 100));\n  }\n\n  return [\n    cssProgressBarContainer(\n      cssProgressBarFill(\n        { style: `width: ${percentUsed}%` },\n        // Change progress bar to red if close to limit, unless limits are hidden.\n        shouldHideLimits || ratioUsed <= APPROACHING_LIMIT_RATIO ?\n          null :\n          cssProgressBarFill.cls(\"-approaching-limit\"),\n        testId(\"progress-fill\"),\n      ),\n    ),\n    dom(\"div\",\n      currentValue === undefined ? [\"Loading \", cssLoadingDots()] : formatValue(currentValue) +\n        (shouldHideLimits || !maximumValue ? \"\" : \" of \" + formatValue(maximumValue)) +\n        (unit ? ` ${unit}` : \"\"),\n      testId(\"value\"),\n    ),\n  ];\n}\n\nfunction buildMessage(message: DomContents) {\n  return cssWarningMessage(\n    cssIcon(\"Idea\"),\n    cssLightlyBoldedText(message, testId(\"message-text\")),\n    testId(\"message\"),\n  );\n}\n\nconst cssLightlyBoldedText = styled(\"div\", `\n  font-weight: 500;\n`);\n\nconst cssWarningMessage = styled(\"div\", `\n  color: ${theme.text};\n  --icon-color: ${theme.text};\n  display: flex;\n  gap: 16px;\n  margin-top: 16px;\n`);\n\nconst cssIcon = styled(icon, `\n  flex-shrink: 0;\n  width: 16px;\n  height: 16px;\n`);\n\nconst cssMetricName = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-weight: 700;\n`);\n\nconst cssOverflowableText = styled(\"span\", `\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n`);\n\nconst cssHeader = styled(docListHeader, `\n  margin-bottom: 0px;\n`);\n\nconst cssUsageMetrics = styled(\"div\", `\n  display: flex;\n  flex-wrap: wrap;\n  margin-top: 24px;\n  row-gap: 24px;\n  column-gap: 54px;\n`);\n\nconst cssUsageMetric = styled(\"div\", `\n  color: ${theme.text};\n  display: flex;\n  flex-direction: column;\n  width: 180px;\n  gap: 8px;\n\n  @media ${mediaXSmall} {\n    & {\n      width: 100%;\n    }\n  }\n`);\n\nconst cssProgressBarContainer = styled(\"div\", `\n  width: 100%;\n  height: 4px;\n  border-radius: 5px;\n  background: ${theme.progressBarBg};\n`);\n\nconst cssProgressBarFill = styled(cssProgressBarContainer, `\n  background: ${theme.progressBarFg};\n\n  &-approaching-limit {\n    background: ${theme.progressBarErrorFg};\n  }\n`);\n\nconst cssSpinner = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  margin-top: 32px;\n`);\n\nconst cssLoadingDots = styled(loadingDots, `\n  --dot-size: 8px;\n`);\n"
  },
  {
    "path": "app/client/components/Drafts.ts",
    "content": "import { CellPosition, toCursor } from \"app/client/components/CellPosition\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ITooltipControl, showTooltip, tooltipCloseButton } from \"app/client/ui/tooltips\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { FieldEditorStateEvent } from \"app/client/widgets/FieldEditor\";\n\nimport {\n  Disposable, dom, Emitter, Holder, IDisposable, IDisposableOwner,\n  IDomArgs, MultiHolder, styled, TagElem,\n} from \"grainjs\";\n\nconst t = makeT(\"components.Drafts\");\n\n/**\n * Component that keeps track of editor's state (draft value). If user hits an escape button\n * by accident, this component will provide a way to continue the work.\n * Each editor can report its current state, that will be remembered and restored\n * when user whishes to continue his work.\n * Each document can have only one draft at a particular time, that\n * is cleared when changes occur on any other cell or the cursor navigates await from a cell.\n *\n * This component is built as a plugin for GristDoc. GristDoc, FieldBuilder, FieldEditor were just\n * extended in order to provide some public interface that this objects plugs into.\n * To disable the drafts, just simple remove it from GristDoc.\n */\nexport class Drafts extends Disposable {\n  constructor(\n    doc: GristDoc,\n  ) {\n    super();\n\n    // Here are all the parts that play some role in this feature\n\n    // Cursor will navigate the cursor on a view to a proper cell\n    const cursor: Cursor = CursorAdapter.create(this, doc);\n    // Storage will remember last draft\n    const storage: Storage = StorageAdapter.create(this);\n    // Notification will show notification with button to undo discard\n    const notification: Notification = NotificationAdapter.create(this, doc);\n    // Tooltip will hover above the editor and offer to continue from last edit\n    const tooltip: Tooltip = TooltipAdapter.create(this, doc);\n    // Editor will restore its previous state and inform about keyboard events\n    const editor: Editor = EditorAdapter.create(this, doc);\n\n    // Here is the main use case describing how parts are connected\n\n    const when = makeWhen(this);\n\n    // When user cancels the editor\n    when(editor.cellCancelled, (ev: StateChanged) => {\n      // if the state of the editor hasn't changed\n      if (!ev.modified) {\n        // close the tooltip and notification\n        tooltip.close();\n        notification.close();\n        // don't store the draft - we assume that user\n        // actually wanted to discard the draft by pressing\n        // escape again\n        return;\n      }\n      // Show notification\n      notification.showUndoDiscard();\n      // Save draft in memory\n      storage.save(ev);\n      // Make sure that tooltip is not visible\n      tooltip.close();\n    });\n\n    // When user clicks notification to continue with the draft\n    when(notification.pressed, async () => {\n      // if the draft is there\n      const draft = storage.get();\n      if (draft) {\n        // restore the position of a cell\n        await cursor.goToCell(draft.position);\n        // activate the editor\n        await editor.activate();\n        // and restore last draft\n        editor.setState(draft.state);\n      }\n      // We don't need the draft any more.\n      // If user presses escape one more time it will be created\n      // once again\n      storage.clear();\n      // Close the notification\n      notification.close();\n      // tooltip is not visible here, and will be shown\n      // when editor is activated\n    });\n\n    // When user doesn't do anything while the notification is visible\n    // remove the draft when it disappears\n    when(notification.disappeared, () => {\n      storage.clear();\n    });\n\n    // When editor is activated (user typed something or double clicked a cell)\n    when(editor.activated, (pos: CellPosition) => {\n      // if there was a draft for a cell\n      if (storage.hasDraftFor(pos)) {\n        // show tooltip to continue with a draft\n        tooltip.showContinueDraft();\n      }\n      // make sure that notification is not visible\n      notification.close();\n    });\n\n    // When editor is modified, close tooltip after some time\n    when(editor.cellModified, (_: StateChanged) => {\n      tooltip.scheduleClose();\n    });\n\n    // When user saves a cell\n    when(editor.cellSaved, (_: StateChanged) => {\n      // just close everything and clear draft\n      storage.clear();\n      tooltip.close();\n      notification.close();\n    });\n\n    // When a user clicks a tooltip to continue with a draft\n    when(tooltip.click, () => {\n      const draft = storage.get();\n      // if there was a draft\n      if (draft) {\n        // restore the draft\n        editor.setState(draft.state);\n      }\n      // close the tooltip\n      tooltip.close();\n    });\n  }\n}\n\n///////////////////////////////////////////////////////////\n// Roles definition that abstract the way this feature interacts with Grist\n\n/**\n * Cursor role can navigate the cursor to a proper cell\n */\ninterface Cursor {\n  goToCell(pos: CellPosition): Promise<void>;\n}\n\n/**\n * Editor role represents active editor that is attached to a cell.\n */\ninterface Editor {\n  // Occurs when user triggers the save operation (by the enter key, clicking away)\n  cellSaved: TypedEmitter<StateChanged>;\n  // Occurs when user triggers the save operation (by the enter key, clicking away)\n  cellModified: TypedEmitter<StateChanged>;\n  // Occurs when user typed something on a cell or double clicked it\n  activated: TypedEmitter<CellPosition>;\n  // Occurs when user cancels the edit (mainly by the escape key or by icon on mobile)\n  cellCancelled: TypedEmitter<StateChanged>;\n  // Editor can restore its state\n  setState(state: any): void;\n  // Editor can be shown up to the user on active cell\n  activate(): Promise<void>;\n}\n\n/**\n * Notification that is shown to the user on the right bottom corner\n */\ninterface Notification {\n  // Occurs when user clicked the notification\n  pressed: Signal;\n  // Occurs when notification disappears with no action from a user\n  disappeared: Signal;\n  // Notification can be closed if it is visible\n  close(): void;\n  // Show notification to the user, to inform him that he can continue with the draft\n  showUndoDiscard(): void;\n}\n\n/**\n * Storage abstraction. Is responsible for storing latest\n * draft (position and state)\n */\ninterface Storage {\n  // Retrieves latest draft data\n  get(): State | null;\n  // Stores latest draft data\n  save(ev: State): void;\n  // Checks if there is draft data at the position\n  hasDraftFor(position: CellPosition): boolean;\n  // Removes draft data\n  clear(): void;\n}\n\n/**\n * Tooltip role is responsible for showing tooltip over active field editor with an information\n * that the drafts is available, and a button to continue with the draft\n */\ninterface Tooltip {\n  // Occurs when user clicks the button on the tooltip - so he wants\n  // to continue with the draft\n  click: Signal;\n  // Show tooltip over active cell editor\n  showContinueDraft(): void;\n  // Close tooltip\n  close(): void;\n  // Close tooltip after some time\n  scheduleClose(): void;\n}\n\n/**\n * Schema of the information that is stored in the storage.\n */\ninterface State {\n  // State of the editor\n  state: any;\n  // Cell position where the draft was created\n  position: CellPosition;\n}\n\n/**\n * Event that is emitted when editor state has changed\n */\ninterface StateChanged extends State {\n  modified: boolean;\n}\n\n///////////////////////////////////////////////////////////\n// Here are all the adapters for the roles above. They\n// abstract the way this feature interacts with the GristDoc\n\nclass CursorAdapter extends Disposable implements Cursor {\n  constructor(private _doc: GristDoc) {\n    super();\n  }\n\n  public async goToCell(pos: CellPosition): Promise<void> {\n    await this._doc.recursiveMoveToCursorPos(toCursor(pos, this._doc.docModel), true);\n  }\n}\n\nclass StorageAdapter extends Disposable implements Storage {\n  private _memory: State | null;\n  public get(): State | null {\n    return this._memory;\n  }\n\n  public save(ev: State) {\n    this._memory = ev;\n  }\n\n  public hasDraftFor(position: CellPosition): boolean {\n    const item = this._memory;\n    if (item && CellPosition.equals(item.position, position)) {\n      return true;\n    }\n    return false;\n  }\n\n  public clear(): void {\n    this._memory = null;\n  }\n}\n\n/**\n * Shows a notification to the user that they can undo discard of cell edit (or any other edit action).\n */\nexport function showUndoDiscardNotification(doc: GristDoc, onClick: () => void) {\n  const notifier = doc.app.topAppModel.notifier;\n  const notification = notifier.createUserMessage(t(\"Undo discard\"), {\n    key: \"undo-discard\",\n    message: () =>\n      discardNotification(\n        dom.on(\"click\", onClick),\n      ),\n  },\n  );\n  return notification;\n}\n\nclass NotificationAdapter extends Disposable implements Notification {\n  public readonly pressed: Signal;\n  public readonly disappeared: Signal;\n  private _hadAction = false;\n  private _holder = Holder.create(this);\n\n  constructor(private _doc: GristDoc) {\n    super();\n    this.pressed = this.autoDispose(new Emitter());\n    this.disappeared = this.autoDispose(new Emitter());\n  }\n\n  public close(): void {\n    this._hadAction = true;\n    this._holder.clear();\n  }\n\n  public showUndoDiscard() {\n    const notification = showUndoDiscardNotification(this._doc, () => {\n      this._hadAction = true;\n      this.pressed.emit();\n    });\n    notification.onDispose(() => {\n      if (!this._hadAction) {\n        this.disappeared.emit();\n      }\n    });\n    this._holder.autoDispose(notification);\n    this._hadAction = false;\n  }\n}\n\nclass TooltipAdapter extends Disposable implements Tooltip {\n  public readonly click: Signal;\n\n  // there can be only one tooltip at a time\n  private _tooltip: ITooltipControl | null = null;\n  private _scheduled = false;\n\n  constructor(private _doc: GristDoc) {\n    super();\n    this.click = this.autoDispose(new Emitter());\n\n    // make sure that the tooltip is closed when this object gets disposed\n    this.onDispose(() => {\n      this.close();\n    });\n  }\n\n  public scheduleClose(): void {\n    if (this._tooltip && !this._scheduled) {\n      this._scheduled = true;\n      const origClose = this._tooltip.close;\n      this._tooltip.close = () => { clearTimeout(timer); origClose(); };\n      const timer = setTimeout(this._tooltip.close, 6000);\n    }\n  }\n\n  public showContinueDraft(): void {\n    // close tooltip if there was a previous one\n    this.close();\n\n    // get the editor dom\n    const editorDom = this._doc.activeEditor.get()?.getDom();\n    if (!editorDom) {\n      return;\n    }\n\n    // attach the tooltip\n    this._tooltip = showTooltip(\n      editorDom,\n      cellTooltip(() => this.click.emit()));\n  }\n\n  public close(): void {\n    this._scheduled = false;\n    this._tooltip?.close();\n    this._tooltip = null;\n  }\n}\n\nclass EditorAdapter extends Disposable implements Editor {\n  public readonly cellSaved: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());\n  public readonly cellModified: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());\n  public readonly activated: TypedEmitter<CellPosition> = this.autoDispose(new Emitter());\n  public readonly cellCancelled: TypedEmitter<StateChanged> = this.autoDispose(new Emitter());\n\n  private _holder = Holder.create<MultiHolder>(this);\n\n  constructor(private _doc: GristDoc) {\n    super();\n\n    // observe active editor\n    this.autoDispose(_doc.activeEditor.addListener((editor) => {\n      if (!editor) {\n        return;\n      }\n\n      // when the editor is created we assume that it is visible to the user\n      this.activated.emit(editor.cellPosition());\n\n      // Auto dispose the previous MultiHolder along with all the previous listeners, and create a\n      // new MultiHolder for the new ones.\n      const mholder = MultiHolder.create(this._holder);\n\n      mholder.autoDispose(editor.changeEmitter.addListener((e: FieldEditorStateEvent) => {\n        this.cellModified.emit({\n          position: e.position,\n          state: e.currentState,\n          modified: e.wasModified,\n        });\n      }));\n\n      // when user presses escape\n      mholder.autoDispose(editor.cancelEmitter.addListener((e: FieldEditorStateEvent) => {\n        this.cellCancelled.emit({\n          position: e.position,\n          state: e.currentState,\n          modified: e.wasModified,\n        });\n      }));\n\n      // when user presses enter to save the value\n      mholder.autoDispose(editor.saveEmitter.addListener((e: FieldEditorStateEvent) => {\n        this.cellSaved.emit({\n          position: e.position,\n          state: e.currentState,\n          modified: e.wasModified,\n        });\n      }));\n    }));\n  }\n\n  public setState(state: any): void {\n    // rebuild active editor with a state from a draft\n    this._doc.activeEditor.get()?.rebuildEditor(undefined, Number.POSITIVE_INFINITY, state);\n  }\n\n  public async activate() {\n    // open up the editor at current position\n    await this._doc.activateEditorAtCursor({});\n  }\n}\n\n///////////////////////////////////////////////////////////\n// Ui components\n\n// Cell tooltip to restore the draft - it is visible over active editor\nconst styledTooltip = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  --icon-color: ${theme.controlFg};\n\n  & > .${cssLink.className} {\n    margin-left: 8px;\n  }\n`);\n\nfunction cellTooltip(clb: () => any) {\n  return function(ctl: ITooltipControl) {\n    return styledTooltip(\n      cssLink(t(\"Restore last edit\"),\n        dom.on(\"mousedown\", (ev) => { ev.preventDefault(); ctl.close(); clb(); }),\n        testId(\"draft-tooltip\"),\n      ),\n      tooltipCloseButton(ctl),\n    );\n  };\n}\n\n// Discard notification dom\nconst styledNotification = styled(\"div\", `\n  cursor: pointer;\n  color: ${theme.controlFg};\n  &:hover {\n    text-decoration: underline;\n  }\n`);\nfunction discardNotification(...args: IDomArgs<TagElem<\"div\">>) {\n  return styledNotification(\n    t(\"Undo discard\"),\n    testId(\"draft-notification\"),\n    ...args,\n  );\n}\n\n///////////////////////////////////////////////////////////\n// Internal implementations - not relevant to main use case\n\n// helper method to listen to the Emitter and dispose the listener with a parent\nfunction makeWhen(owner: IDisposableOwner) {\n  return function <T extends EmitterType<any>>(emitter: T, handler: EmitterHandler<T>) {\n    owner.autoDispose(emitter.addListener(handler as any));\n  };\n}\n\n// Default emitter is not typed, this augments the Emitter interface\ninterface TypedEmitter<T> {\n  emit(item: T): void;\n  addListener(clb: (e: T) => any): IDisposable;\n}\ninterface Signal {\n  emit(): void;\n  addListener(clb: () => any): IDisposable;\n}\ntype EmitterType<T> = T extends TypedEmitter<infer E> ? TypedEmitter<E> : Signal;\ntype EmitterHandler<T> = T extends TypedEmitter<infer E> ? ((e: E) => any) : () => any;\n"
  },
  {
    "path": "app/client/components/DropdownConditionConfig.ts",
    "content": "import { buildDropdownConditionEditor } from \"app/client/components/DropdownConditionEditor\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewFieldRec } from \"app/client/models/DocModel\";\nimport { cssLabel, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { ISuggestionWithValue } from \"app/common/ActiveDocAPI\";\nimport { getPredicateFormulaProperties } from \"app/common/PredicateFormula\";\nimport { UserInfo } from \"app/common/User\";\n\nimport { Computed, Disposable, dom, Observable, styled } from \"grainjs\";\nimport isPlainObject from \"lodash/isPlainObject\";\n\nconst t = makeT(\"DropdownConditionConfig\");\n\n/**\n * Right panel configuration for dropdown conditions.\n *\n * Contains an instance of `DropdownConditionEditor`, the class responsible\n * for setting dropdown conditions.\n */\nexport class DropdownConditionConfig extends Disposable {\n  private _text = Computed.create(this, (use) => {\n    const dropdownCondition = use(this._field.dropdownCondition);\n    if (!dropdownCondition) { return \"\"; }\n\n    return dropdownCondition.text;\n  });\n\n  private _saveError = Observable.create<string | null>(this, null);\n\n  private _properties = Computed.create(this, (use) => {\n    const dropdownCondition = use(this._field.dropdownCondition);\n    if (!dropdownCondition?.parsed) { return null; }\n\n    return getPredicateFormulaProperties(JSON.parse(dropdownCondition.parsed));\n  });\n\n  private _column = Computed.create(this, use => use(this._field.column));\n\n  private _columns = Computed.create(this, use => use(use(use(this._column).table).visibleColumns));\n\n  private _refColumns = Computed.create(this, (use) => {\n    const refTable = use(use(this._column).refTable);\n    if (!refTable) { return null; }\n\n    return use(refTable.visibleColumns);\n  });\n\n  private _propertiesError = Computed.create<string | null>(this, (use) => {\n    const properties = use(this._properties);\n    if (!properties) { return null; }\n\n    const { recColIds = [], choiceColIds = [] } = properties;\n    const columns = use(this._columns);\n    const validRecColIds = new Set([\"id\", ...columns.map(({ colId }) => use(colId))]);\n    const invalidRecColIds = recColIds.filter(colId => !validRecColIds.has(colId));\n    if (invalidRecColIds.length > 0) {\n      return t(\"Invalid columns: {{colIds}}\", { colIds: invalidRecColIds.join(\", \") });\n    }\n\n    const refColumns = use(this._refColumns);\n    if (refColumns) {\n      const validChoiceColIds = new Set([\"id\", ...refColumns.map(({ colId }) => use(colId))]);\n      const invalidChoiceColIds = choiceColIds.filter(colId => !validChoiceColIds.has(colId));\n      if (invalidChoiceColIds.length > 0) {\n        return t(\"Invalid columns: {{colIds}}\", { colIds: invalidChoiceColIds.join(\", \") });\n      }\n    }\n\n    return null;\n  });\n\n  private _error = Computed.create<string | null>(this, (use) => {\n    const maybeSaveError = use(this._saveError);\n    if (maybeSaveError) { return maybeSaveError; }\n\n    const maybeCompiled = use(this._field.dropdownConditionCompiled);\n    if (maybeCompiled?.kind === \"failure\") { return maybeCompiled.error; }\n\n    const maybePropertiesError = use(this._propertiesError);\n    if (maybePropertiesError) { return maybePropertiesError; }\n\n    return null;\n  });\n\n  private _disabled = Computed.create(this, use =>\n    use(this._field.disableModify) ||\n    use(use(this._column).disableEditData) ||\n    use(this._field.config.multiselect),\n  );\n\n  private _isEditingCondition = Observable.create(this, false);\n\n  private _isRefField = Computed.create(this, use =>\n    [\"Ref\", \"RefList\"].includes(use(use(this._column).pureType)));\n\n  private _tooltip = Computed.create(this, use => use(this._isRefField) ?\n    \"setRefDropdownCondition\" :\n    \"setChoiceDropdownCondition\");\n\n  private _editorElement: HTMLElement;\n\n  constructor(private _field: ViewFieldRec, private _gristDoc: GristDoc) {\n    super();\n\n    this.autoDispose(this._text.addListener(() => {\n      this._saveError.set(\"\");\n    }));\n  }\n\n  public buildDom() {\n    return [\n      dom.maybe(use => !(use(this._isEditingCondition) || Boolean(use(this._text))), () => [\n        cssSetDropdownConditionRow(\n          dom.domComputed(use => withInfoTooltip(\n            textButton(\n              t(\"Set dropdown condition\"),\n              dom.on(\"click\", () => {\n                this._isEditingCondition.set(true);\n                setTimeout(() => this._editorElement.focus(), 0);\n              }),\n              dom.prop(\"disabled\", this._disabled),\n              testId(\"field-set-dropdown-condition\"),\n            ),\n            use(this._tooltip),\n          )),\n        ),\n      ]),\n      dom.maybe(use => use(this._isEditingCondition) || Boolean(use(this._text)), () => [\n        cssLabel(t(\"Dropdown Condition\")),\n        cssRow(\n          dom.create(buildDropdownConditionEditor,\n            {\n              value: this._text,\n              disabled: this._disabled,\n              getAutocompleteSuggestions: () => this._getAutocompleteSuggestions(),\n              onSave: async (value) => {\n                try {\n                  const widgetOptions = this._field.widgetOptionsJson.peek();\n                  if (value.trim() === \"\") {\n                    delete widgetOptions.dropdownCondition;\n                  } else {\n                    widgetOptions.dropdownCondition = { text: value };\n                  }\n                  await this._field.widgetOptionsJson.setAndSave(widgetOptions);\n                } catch (e) {\n                  if (e?.code === \"ACL_DENY\") {\n                    reportError(e);\n                  } else {\n                    this._saveError.set(e.message.replace(/^\\[Sandbox\\]/, \"\").trim());\n                  }\n                }\n              },\n              onDispose: () => {\n                this._isEditingCondition.set(false);\n              },\n            },\n            (el) => { this._editorElement = el; },\n            testId(\"field-dropdown-condition\"),\n          ),\n        ),\n        dom.maybe(this._error, error => cssRow(\n          cssDropdownConditionError(error), testId(\"field-dropdown-condition-error\")),\n        ),\n      ]),\n    ];\n  }\n\n  private _getAutocompleteSuggestions(): ISuggestionWithValue[] {\n    const variables = [\"choice\"];\n    const user = this._gristDoc.docPageModel.user.get();\n    if (user) {\n      variables.push(...getUserCompletions(user));\n    }\n    const refColumns = this._refColumns.get();\n    if (refColumns) {\n      variables.push(\"choice.id\", ...refColumns.map(({ colId }) => `choice.${colId.peek()}`));\n    }\n    const columns = this._columns.get();\n    variables.push(\n      ...columns.map(({ colId }) => `$${colId.peek()}`),\n      ...columns.map(({ colId }) => `rec.${colId.peek()}`),\n    );\n    const suggestions = [\n      \"and\", \"or\", \"not\", \"in\", \"is\", \"True\", \"False\", \"None\",\n      \"OWNER\", \"EDITOR\", \"VIEWER\",\n      ...variables,\n    ];\n    return suggestions.map(suggestion => [suggestion, null]);\n  }\n}\n\nfunction getUserCompletions(user: UserInfo) {\n  return Object.entries(user).flatMap(([key, value]) => {\n    if (key === \"LinkKey\") {\n      return \"user.LinkKey.\";\n    } else if (isPlainObject(value)) {\n      return Object.keys(value as { [key: string]: any })\n        .filter(valueKey => valueKey !== \"manualSort\")\n        .map(valueKey => `user.${key}.${valueKey}`);\n    } else {\n      return `user.${key}`;\n    }\n  });\n}\n\nconst cssSetDropdownConditionRow = styled(cssRow, `\n  margin-top: 16px;\n`);\n\nconst cssDropdownConditionError = styled(\"div\", `\n  color: ${theme.errorText};\n  margin-top: 4px;\n  width: 100%;\n`);\n"
  },
  {
    "path": "app/client/components/DropdownConditionEditor.ts",
    "content": "import * as AceEditor from \"app/client/components/AceEditor\";\nimport { createGroup } from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { buildHighlightedCode } from \"app/client/ui/CodeHighlight\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { createMobileButtons, getButtonMargins } from \"app/client/widgets/EditorButtons\";\nimport { EditorPlacement, ISize } from \"app/client/widgets/EditorPlacement\";\nimport { initializeAceOptions } from \"app/client/widgets/FormulaEditor\";\nimport { IEditorCommandGroup } from \"app/client/widgets/NewBaseEditor\";\nimport { ISuggestionWithValue } from \"app/common/ActiveDocAPI\";\n\nimport {\n  Computed,\n  Disposable,\n  dom,\n  DomElementArg,\n  Holder,\n  IDisposableOwner,\n  Observable,\n  styled,\n} from \"grainjs\";\n\nconst t = makeT(\"DropdownConditionEditor\");\n\ninterface BuildDropdownConditionEditorOptions {\n  value: Computed<string>;\n  disabled: Computed<boolean>;\n  onSave(value: string): Promise<void>;\n  onDispose(): void;\n  getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];\n}\n\n/**\n * Builds an editor for dropdown conditions.\n *\n * Dropdown conditions are client-evaluated predicate formulas used to filter\n * items shown in autocomplete dropdowns for Choice and Reference type columns.\n *\n * Unlike Python formulas, dropdown conditions only support a very limited set of\n * features. They are a close relative of ACL formulas, sharing the same underlying\n * parser and compiler.\n *\n * See `sandbox/grist/predicate_formula.py` and `app/common/PredicateFormula.ts` for\n * more details on parsing and compiling, respectively.\n */\nexport function buildDropdownConditionEditor(\n  owner: IDisposableOwner,\n  options: BuildDropdownConditionEditorOptions,\n  ...args: DomElementArg[]\n) {\n  const { value, disabled, onSave, onDispose, getAutocompleteSuggestions } = options;\n  return dom.create(buildHighlightedCode,\n    value,\n    { maxLines: 1 },\n    dom.cls(cssDropdownConditionField.className),\n    dom.cls(\"disabled\"),\n    cssDropdownConditionField.cls(\"-disabled\", disabled),\n    { tabIndex: \"-1\" },\n    dom.on(\"focus\", (_, refElem) => openDropdownConditionEditor(owner, {\n      refElem,\n      value,\n      onSave,\n      onDispose,\n      getAutocompleteSuggestions,\n    })),\n    ...args,\n  );\n}\n\nfunction openDropdownConditionEditor(owner: IDisposableOwner, options: {\n  refElem: Element;\n  value: Computed<string>;\n  onSave: (value: string) => Promise<void>;\n  onDispose: () => void;\n  getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];\n}) {\n  const { refElem, value, onSave, onDispose, getAutocompleteSuggestions } = options;\n\n  const saveAndDispose = async () => {\n    const editorValue = editor.getValue();\n    if (editorValue !== value.get()) {\n      await onSave(editorValue);\n    }\n    if (editor.isDisposed()) { return; }\n\n    editor.dispose();\n  };\n\n  const commands: IEditorCommandGroup = {\n    fieldEditCancel: () => editor.dispose(),\n    fieldEditSaveHere: () => editor.blur(),\n    fieldEditSave: () => editor.blur(),\n  };\n\n  const editor = DropdownConditionEditor.create(owner, {\n    editValue: value.get(),\n    commands,\n    onBlur: saveAndDispose,\n    getAutocompleteSuggestions,\n  });\n  editor.attach(refElem);\n  editor.onDispose(() => onDispose());\n}\n\ninterface DropdownConditionEditorOptions {\n  editValue: string;\n  commands: IEditorCommandGroup;\n  onBlur(): Promise<void>;\n  getAutocompleteSuggestions(prefix: string): ISuggestionWithValue[];\n}\n\nclass DropdownConditionEditor extends Disposable {\n  private _aceEditor: any;\n  private _dom: HTMLElement;\n  private _editorPlacement!: EditorPlacement;\n  private _placementHolder = Holder.create(this);\n  private _isEmpty: Computed<boolean>;\n\n  constructor(private _options: DropdownConditionEditorOptions) {\n    super();\n\n    const initialValue = _options.editValue;\n    const editorState = Observable.create(this, initialValue);\n\n    this._aceEditor = this.autoDispose(AceEditor.create({\n      calcSize: this._calcSize.bind(this),\n      editorState,\n      getSuggestions: _options.getAutocompleteSuggestions,\n    }));\n\n    this._isEmpty = Computed.create(this, editorState, (_use, state) => state === \"\");\n    this.autoDispose(this._isEmpty.addListener(() => this._updateEditorPlaceholder()));\n\n    const commandGroup = this.autoDispose(createGroup({\n      ..._options.commands,\n    }, this, true));\n\n    this._dom = cssDropdownConditionEditorWrapper(\n      cssDropdownConditionEditor(\n        createMobileButtons(_options.commands),\n        this._aceEditor.buildDom((aceObj: any) => {\n          initializeAceOptions(aceObj);\n          const val = initialValue;\n          const pos = val.length;\n          this._aceEditor.setValue(val, pos);\n          this._aceEditor.attachCommandGroup(commandGroup);\n          if (val === \"\") {\n            this._updateEditorPlaceholder();\n          }\n        }),\n      ),\n    );\n  }\n\n  public attach(cellElem: Element): void {\n    this._editorPlacement = EditorPlacement.create(this._placementHolder, this._dom, cellElem, {\n      margins: getButtonMargins(),\n    });\n    this.autoDispose(this._editorPlacement.onReposition.addListener(this._aceEditor.resize, this._aceEditor));\n    this._aceEditor.onAttach();\n    this._updateEditorPlaceholder();\n    this._aceEditor.resize();\n    this._aceEditor.getEditor().focus();\n    this._aceEditor.getEditor().on(\"blur\", () => this._options.onBlur());\n  }\n\n  public getValue(): string {\n    return this._aceEditor.getValue();\n  }\n\n  public blur() {\n    this._aceEditor.getEditor().blur();\n  }\n\n  private _updateEditorPlaceholder() {\n    const editor = this._aceEditor.getEditor();\n    const shouldShowPlaceholder = editor.session.getValue().length === 0;\n    if (editor.renderer.emptyMessageNode) {\n      // Remove the current placeholder if one is present.\n      editor.renderer.scroller.removeChild(editor.renderer.emptyMessageNode);\n    }\n    if (!shouldShowPlaceholder) {\n      editor.renderer.emptyMessageNode = null;\n    } else {\n      editor.renderer.emptyMessageNode = cssDropdownConditionPlaceholder(t(\"Enter condition.\"));\n      editor.renderer.scroller.appendChild(editor.renderer.emptyMessageNode);\n    }\n  }\n\n  private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {\n    const placeholder: HTMLElement | undefined = this._aceEditor.getEditor().renderer.emptyMessageNode;\n    if (placeholder) {\n      return this._editorPlacement.calcSizeWithPadding(elem, {\n        width: placeholder.scrollWidth,\n        height: placeholder.scrollHeight,\n      });\n    } else {\n      return this._editorPlacement.calcSizeWithPadding(elem, {\n        width: desiredElemSize.width,\n        height: desiredElemSize.height,\n      });\n    }\n  }\n}\n\nconst cssDropdownConditionField = styled(\"div\", `\n  flex: auto;\n  cursor: pointer;\n  margin-top: 4px;\n\n  &-disabled {\n    opacity: 0.4;\n    pointer-events: none;\n  }\n`);\n\nconst cssDropdownConditionEditorWrapper = styled(\"div.default_editor.formula_editor_wrapper\", `\n  border-radius: 3px;\n`);\n\nconst cssDropdownConditionEditor = styled(\"div\", `\n  background-color: ${theme.aceEditorBg};\n  padding: 5px;\n  z-index: 10;\n  overflow: hidden;\n  flex: none;\n  min-height: 22px;\n  border-radius: 3px;\n`);\n\nconst cssDropdownConditionPlaceholder = styled(\"div\", `\n  color: ${theme.lightText};\n  font-style: italic;\n  white-space: nowrap;\n`);\n"
  },
  {
    "path": "app/client/components/EditorMonitor.ts",
    "content": "import { CellPosition, toCursor } from \"app/client/components/CellPosition\";\nimport { oneTimeListener } from \"app/client/components/CursorMonitor\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { getStorage } from \"app/client/lib/storage\";\nimport { UserError } from \"app/client/models/errors\";\nimport { FieldEditor, FieldEditorStateEvent } from \"app/client/widgets/FieldEditor\";\nimport { isViewDocPage } from \"app/common/gristUrls\";\n\nimport { Disposable, Emitter, IDisposableOwner } from \"grainjs\";\n\n/**\n * Feature for GristDoc that allows it to keep track of current editor's state.\n * State is stored in local storage by default.\n */\nexport class EditorMonitor extends Disposable {\n  // abstraction to work with local storage\n  private _store: EditMemoryStorage;\n  private _restored = false;\n\n  constructor(\n    doc: GristDoc,\n    store?: Storage) {\n    super();\n\n    // create store\n    const userId = doc.app.topAppModel.appObs.get()?.currentUser?.id ?? null;\n    // use document id and user id as a key for storage\n    const key = doc.docId() + userId;\n    this._store = new EditMemoryStorage(key, store);\n\n    // listen to document events to handle view load event\n    this._listenToReload(doc).catch((err) => {\n      if (!(err instanceof UserError)) {\n        throw err;\n      }\n      // Don't report UserErrors for this feature (should not happen as\n      // the only error that is thrown was silenced by recursiveMoveToCursorPos)\n      console.error(`Error while restoring last edit position`, err);\n    });\n  }\n\n  /**\n   * Monitors a field editor and updates latest edit position\n   * @param editor Field editor to track\n   */\n  public monitorEditor(editor: FieldEditor) {\n    // typed helper to connect to the emitter\n    const on = typedListener(this);\n    // When user cancels the edit process, discard the memory of the last edited cell.\n    on(editor.cancelEmitter, (event) => {\n      this._store.clear();\n    });\n    // When saves a cell, discard the memory of the last edited cell.\n    on(editor.saveEmitter, (event) => {\n      this._store.clear();\n    });\n    // When user types in the editor, store its state\n    on(editor.changeEmitter, (event) => {\n      this._store.updateValue(event.position, event.currentState);\n    });\n  }\n\n  /**\n   * When document gets reloaded, restore last cursor position and a state of the editor.\n   * Returns last edited cell position and saved editor state or undefined.\n   */\n  private async _listenToReload(doc: GristDoc) {\n    // don't restore on readonly mode or when there is custom nav\n    if (doc.isReadonly.get() || doc.hasCustomNav.get()) {\n      this._store.clear();\n      return;\n    }\n    // if we are on raw data view, we need to set the position manually\n    // as currentView observable will not be changed.\n    if (doc.activeViewId.get() === \"data\") {\n      await this._doRestorePosition(doc);\n    } else {\n      // on view shown\n      this.autoDispose(oneTimeListener(doc.currentView, async () => {\n        await this._doRestorePosition(doc);\n      }));\n    }\n  }\n\n  private async _doRestorePosition(doc: GristDoc) {\n    if (this._restored) {\n      return;\n    }\n    this._restored = true;\n    const viewId = doc.activeViewId.get();\n    // if view wasn't rendered (page is displaying history or code view) do nothing\n    if (!isViewDocPage(viewId)) {\n      this._store.clear();\n      return;\n    }\n    const lastEdit = this._store.readValue();\n    if (lastEdit) {\n      // set the cursor at right cell\n      await doc.recursiveMoveToCursorPos(toCursor(lastEdit.position, doc.docModel), true, true);\n      // activate the editor\n      await doc.activateEditorAtCursor({ state: lastEdit.value });\n    }\n  }\n}\n\n// Internal implementation, not relevant to the main use case\n\n// typed listener for the Emitter class\nfunction typedListener(owner: IDisposableOwner) {\n  return function(emitter: Emitter, clb: (e: FieldEditorStateEvent) => any) {\n    owner.autoDispose(emitter.addListener(clb));\n  };\n}\n\n// Marker for a editor state - each editor can report any data as long as it is serialized\ntype EditorState = any;\n\n// Schema for value stored in the local storage\ninterface LastEditData {\n  // absolute position for a cell\n  position: CellPosition;\n  // editor's state\n  value: EditorState;\n}\n\n// Abstraction for working with local storage\nclass EditMemoryStorage {\n  private _entry: LastEditData | null = null;\n  private _timestamp = 0;\n\n  constructor(private _key: string, private _storage = getStorage()) {\n  }\n\n  public updateValue(pos: CellPosition, value: EditorState): void {\n    this._entry = { position: pos, value: value };\n    this.save();\n  }\n\n  public readValue(): LastEditData | null {\n    this.load();\n    return this._entry;\n  }\n\n  public clear(): void {\n    this._entry = null;\n    this.save();\n  }\n\n  public timestamp(): number {\n    return this._timestamp;\n  }\n\n  protected _storageKey() {\n    return `grist-last-edit-${this._key}`;\n  }\n\n  protected load() {\n    const storage = this._storage;\n    const data = storage.getItem(this._storageKey());\n    this._entry = null;\n    this._timestamp = 0;\n\n    if (data) {\n      try {\n        const { entry, timestamp } = JSON.parse(data);\n        if (typeof entry === \"undefined\" || typeof timestamp != \"number\") {\n          console.error(\"[EditMemory] Data in local storage has a different structure\");\n          return;\n        }\n        this._entry = entry;\n        this._timestamp = timestamp;\n      } catch (e) {\n        console.error(\"[EditMemory] Can't deserialize date from local storage\");\n      }\n    }\n  }\n\n  protected save(): void {\n    const storage = this._storage;\n\n    // if entry was removed - clear the storage\n    if (!this._entry) {\n      storage.removeItem(this._storageKey());\n      return;\n    }\n\n    try {\n      this._timestamp = Date.now();\n      const data = { timestamp: this._timestamp, entry: this._entry };\n      storage.setItem(this._storageKey(), JSON.stringify(data));\n    } catch (ex) {\n      console.error(\"Can't save current edited cell state. Error message: \" + ex?.message);\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/components/EmbedForm.css",
    "content": ".embed-form-desc {\n  margin: 10px 0;\n}\n\n.embed-form-basket-id {\n  font-weight: bold;\n  margin-right: 5px;\n}\n\n.embed-form-tables {\n  text-align: center;\n}\n\n.embed-form-tables .kf_row > .kf_elem {\n  margin: 0;\n  width: 90%;\n}\n\n.embed-form-table-id {\n  text-align: left;\n}\n\n.embed-form-published {\n  background-color: #f0f9f9;\n  border: 1px dashed #35afae;\n  padding: 0 5px 5px 5px;\n}\n\n.embed-form-unpublished {\n  padding: 5px;\n}\n\n.embed-form-link {\n  text-align: center;\n}\n\n.embed-form-connect {\n  text-align: center;\n}\n"
  },
  {
    "path": "app/client/components/ExternalAttachmentBanner.ts",
    "content": "import { Banner, buildBannerMessage, cssBannerLink } from \"app/client/components/Banner\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { localStorageJsonObs } from \"app/client/lib/localStorageObs\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { PREFERRED_STORAGE_ANCHOR } from \"app/common/gristUrls\";\n\nimport { Disposable, dom, makeTestId, Observable } from \"grainjs\";\n\nconst t = makeT(\"ExternalAttachmentBanner\");\n\nconst testId = makeTestId(\"test-external-attachment-banner-\");\n\ninterface ShowExternalAttachmentBannerPrefer {\n  dismissed: boolean,\n}\n\n// Modeled after VersionUpdateBanner.\nexport class ExternalAttachmentBanner extends Disposable {\n  // Session storage observable. Set to false to dismiss the banner for the session.\n  private _showBannerPref: Observable<ShowExternalAttachmentBannerPrefer>;\n\n  constructor(private _docPageModel: DocPageModel) {\n    super();\n    this.autoDispose(this._docPageModel.currentDocId.addListener((docId) => {\n      if (this._showBannerPref?.isDisposed() === false) {\n        this._showBannerPref.dispose();\n      }\n      const userId = this._docPageModel.appModel.currentUser?.id ?? 0;\n      this._showBannerPref = localStorageJsonObs(\n        `u=${userId}:doc=${docId}:showExternalAttachmentBanner`,\n        {\n          dismissed: false,\n        },\n      );\n    }));\n  }\n\n  public buildDom() {\n    return dom.maybe(this._docPageModel.appModel.isOwner(), () => {\n      return dom.domComputed((use) => {\n        const usage = use(this._docPageModel.currentDocUsage);\n        if (!usage?.usageRecommendations?.recommendExternal) {\n          return;\n        }\n\n        const bannerPref = use(this._showBannerPref);\n        if (bannerPref.dismissed) {\n          return null;\n        }\n\n        return dom.create(Banner, {\n          content: buildBannerMessage(\n            getExternalStorageRecommendation(),\n            testId(\"text\"),\n          ),\n          style: \"warning\",\n          showCloseButton: true,\n          onClose: () => this._showBannerPref.set({\n            dismissed: true,\n          }),\n        });\n      });\n    });\n  }\n}\n\n/**\n * Get the text for the banner. This text is also shown\n * on the raw data page. It contains a link to a part of\n * the document settings page where external storage is\n * configured. The phrasing of the text is a little awkward\n * to make it more practical to translate, given that the\n * link text is separate from the main body of the text and\n * a translator may not see them together.\n */\nexport function getExternalStorageRecommendation() {\n  return t(`Recommendation: {{storageRecommendation}}\nWhen storing large attachments, or many of them, we recommend\nkeeping them in external storage. This document is currently\nusing internal storage for attachments, which keeps it\nself-contained but may limit performance.`, {\n    storageRecommendation: cssBannerLink(\n      t(\"Set the document to use external storage.\"),\n      urlState().setLinkUrl({\n        docPage: \"settings\",\n        hash: {\n          anchor: PREFERRED_STORAGE_ANCHOR,\n        },\n      }),\n    ),\n  });\n}\n"
  },
  {
    "path": "app/client/components/FieldConfigTab.css",
    "content": ".formula_button_f {\n  font-size: 1.2rem;\n}\n\n.formula_button_x {\n  font-style: bold;\n  font-size: 0.9rem;\n  line-height: 0.9rem;\n}\n"
  },
  {
    "path": "app/client/components/FormRenderer.ts",
    "content": "import * as css from \"app/client/components/FormRendererCss\";\nimport { bindMarkdown } from \"app/client/components/Forms/styles\";\nimport { getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { FormField, FormOptionsSortOrder, getFormOptionsLimit } from \"app/client/ui/FormAPI\";\nimport { dropdownWithSearch } from \"app/client/ui/searchDropdown\";\nimport { isXSmallScreenObs, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\nimport { toggleSwitch } from \"app/client/ui2018/toggleSwitch\";\nimport { isAffirmative, isNumber } from \"app/common/gutil\";\nimport { CellValue } from \"app/plugin/GristData\";\n\nimport {\n  Disposable,\n  dom,\n  DomContents,\n  IAttrObj,\n  makeTestId,\n  MutableObsArray,\n  obsArray,\n  Observable,\n  styled,\n} from \"grainjs\";\nimport { IPopupOptions, PopupControl } from \"popweasel\";\n\nconst testId = makeTestId(\"test-form-\");\n\nconst t = makeT(\"FormRenderer\");\n\nconst G = getBrowserGlobals(\"window\");\n\n/**\n * A node in a recursive, tree-like hierarchy comprising the layout of a form.\n */\nexport interface FormLayoutNode {\n  /** Unique ID of the node. Used by FormView. */\n  id: string;\n  type: FormLayoutNodeType;\n  children?: FormLayoutNode[];\n  // Used by Layout.\n  submitText?: string;\n  successURL?: string;\n  successText?: string;\n  anotherResponse?: boolean;\n  // Used by Field.\n  formRequired?: boolean;\n  leaf?: number;\n  // Used by Label and Paragraph.\n  text?: string;\n  // Used by Paragraph.\n  alignment?: string;\n}\n\nexport type FormLayoutNodeType =\n  | \"Paragraph\" |\n  \"Section\" |\n  \"Columns\" |\n  \"Submit\" |\n  \"Placeholder\" |\n  \"Layout\" |\n  \"Field\" |\n  \"Label\" |\n  \"Separator\" |\n  \"Header\";\n\n/**\n * Context used by FormRenderer to build each node.\n */\nexport interface FormRendererContext {\n  /** Field metadata, keyed by field id. */\n  fields: Record<number, FormField>;\n  /** The root of the FormLayoutNode tree. */\n  rootLayoutNode: FormLayoutNode;\n  /** Disables the Submit node if true. */\n  disabled: Observable<boolean>;\n  /** Error to show above the Submit node. */\n  error: Observable<string | null>;\n}\n\n/**\n * Returns a copy of `layoutSpec` with any leaf nodes that don't exist\n * in `fieldIds` removed. Optionally if the fieldIds is a map of old to new\n * field ids, the leaf nodes will be updated to the new field ids.\n */\nexport function cleanFormLayoutSpec(\n  layoutSpec: FormLayoutNode,\n  fieldIds: Set<number> | Record<number, number>,\n): FormLayoutNode | null {\n  if (layoutSpec.leaf) {\n    if (fieldIds instanceof Set) {\n      return fieldIds.has(layoutSpec.leaf) ? { ...layoutSpec } : null;\n    }\n    return fieldIds[layoutSpec.leaf] ? { ...layoutSpec, leaf: fieldIds[layoutSpec.leaf] } : null;\n  }\n\n  return {\n    ...layoutSpec,\n    children: layoutSpec.children\n      ?.map(child => cleanFormLayoutSpec(child, fieldIds))\n      .filter((child): child is FormLayoutNode => child !== null),\n  };\n}\n\n/**\n * A renderer for a form layout.\n *\n * Takes the root FormLayoutNode and additional context for each node, and returns\n * the DomContents of the rendered form.\n *\n * A closely related set of classes exist in `app/client/components/Forms/*`; those are\n * specifically used to render a version of a form that is suitable for displaying within\n * a Form widget, where submitting a form isn't possible.\n *\n * TODO: merge the two implementations or factor out what's common.\n */\nexport abstract class FormRenderer extends Disposable {\n  public static new(\n    layoutNode: FormLayoutNode,\n    context: FormRendererContext,\n    parent?: FormRenderer,\n  ): FormRenderer {\n    const Renderer = FormRenderers[layoutNode.type] ?? ParagraphRenderer;\n    return new Renderer(layoutNode, context, parent);\n  }\n\n  protected children: FormRenderer[];\n\n  constructor(\n    protected layoutNode: FormLayoutNode,\n    protected context: FormRendererContext,\n    protected parent?: FormRenderer,\n  ) {\n    super();\n    this.children = (this.layoutNode.children ?? []).map(child =>\n      this.autoDispose(FormRenderer.new(child, this.context, this)));\n  }\n\n  public abstract render(): DomContents;\n\n  /**\n   * Reset the state of this layout node and all of its children.\n   */\n  public reset() {\n    this.children.forEach(child => child.reset());\n  }\n}\n\nclass LabelRenderer extends FormRenderer {\n  public render() {\n    return css.label(this.layoutNode.text ?? \"\");\n  }\n}\n\nclass ParagraphRenderer extends FormRenderer {\n  public render() {\n    return css.paragraph(\n      css.paragraph.cls(`-alignment-${this.layoutNode.alignment || \"left\"}`),\n      bindMarkdown(this.layoutNode.text || \"\"),\n    );\n  }\n}\n\nclass SectionRenderer extends FormRenderer {\n  public render() {\n    return css.section(\n      this.children.map(child => child.render()),\n    );\n  }\n}\n\nclass ColumnsRenderer extends FormRenderer {\n  public render() {\n    return css.columns(\n      { style: `--grist-columns-count: ${this._getColumnsCount()}` },\n      this.children.map(child => child.render()),\n    );\n  }\n\n  private _getColumnsCount() {\n    return this.children.length || 1;\n  }\n}\n\nclass SubmitRenderer extends FormRenderer {\n  public render() {\n    return [\n      css.error(dom.text(use => use(this.context.error) ?? \"\")),\n      css.submitButtons(\n        css.resetButton(\n          t(\"Reset\"),\n          dom.attr(\"aria-disabled\", use => use(this.context.disabled) ? \"true\" : \"false\"),\n          { type: \"button\" },\n          dom.on(\"click\", (event) => {\n            if (this.context.disabled.get()) {\n              return event.preventDefault();\n            }\n            return confirmModal(\n              \"Are you sure you want to reset your form?\",\n              \"Reset\",\n              () => this.parent?.reset(),\n            );\n          }),\n          testId(\"reset\"),\n        ),\n        css.submitButton(\n          dom(\"button\",\n            dom.attr(\"aria-disabled\", use => use(this.context.disabled) ? \"true\" : \"false\"),\n            { type: \"submit\" },\n            dom.domComputed((use) => {\n              return use(this.context.disabled) ?\n                [css.buttonLoadingSpinner(), t(\"Submitting…\")] :\n                this.context.rootLayoutNode.submitText || t(\"Submit\");\n            }),\n            dom.on(\"click\", (event) => {\n              if (this.context.disabled.get()) {\n                return event.preventDefault();\n              }\n              return validateRequiredLists();\n            }),\n          ),\n        ),\n      ),\n    ];\n  }\n}\n\nclass PlaceholderRenderer extends FormRenderer {\n  public render() {\n    return dom(\"div\");\n  }\n}\n\nclass LayoutRenderer extends FormRenderer {\n  public render() {\n    return this.children.map(child => child.render());\n  }\n}\n\nclass FieldRenderer extends FormRenderer {\n  public renderer: BaseFieldRenderer;\n\n  public constructor(layoutNode: FormLayoutNode, context: FormRendererContext) {\n    super(layoutNode, context);\n    const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null;\n    if (!field) { throw new Error(); }\n\n    const Renderer = FieldRenderers[field.type as keyof typeof FieldRenderers] ?? BaseTextRenderer;\n    this.renderer = this.autoDispose(new Renderer(field, context));\n  }\n\n  public render() {\n    return this.renderer.render();\n  }\n\n  public reset() {\n    this.renderer.resetInput();\n  }\n}\n\nabstract class BaseFieldRenderer extends Disposable {\n  public constructor(protected field: FormField, protected context: FormRendererContext) {\n    super();\n  }\n\n  public render() {\n    return css.field(\n      dom.hide(this.field.options.formIsHidden || false),\n      this.label(),\n      dom(\"div\", this.input()),\n      this.fieldDomAttributes(),\n    );\n  }\n\n  public name() {\n    return this.field.colId;\n  }\n\n  public id() {\n    return this.name().replace(/\\s+/g, \"-\");\n  }\n\n  public label(): HTMLElement {\n    return dom(\"label\",\n      css.label.cls(\"\"),\n      css.label.cls(\"-required\", Boolean(this.field.options.formRequired)),\n      { for: this.name(), id: `${this.id()}-label` },\n      this.field.question,\n    );\n  }\n\n  public abstract input(): DomContents;\n\n  public abstract resetInput(): void;\n\n  /**\n   * A Field renderer can override this to add additional attributes to the field's DOM element.\n   */\n  public fieldDomAttributes(): IAttrObj {\n    return {};\n  }\n\n  protected getInitialValue(): string | null {\n    if (this.field.options.formAcceptFromUrl) {\n      if (G.window.location.search) {\n        return new URLSearchParams(window.location.search).get(this.field.colId);\n      }\n    }\n    return null;\n  }\n\n  protected getInitialValueList(): string[] {\n    if (this.field.options.formAcceptFromUrl) {\n      if (G.window.location.search) {\n        return new URLSearchParams(window.location.search).getAll(this.field.colId);\n      }\n    }\n    return [];\n  }\n}\n\nclass BaseTextRenderer extends BaseFieldRenderer {\n  protected inputType = \"text\";\n\n  private _format = this.field.options.formTextFormat ?? \"singleline\";\n  private _lineCount = String(this.field.options.formTextLineCount || 3);\n  private _value = Observable.create<string>(this, this.getInitialValue() ?? \"\");\n\n  public input() {\n    let element: HTMLInputElement | HTMLTextAreaElement;\n    if (this._format === \"singleline\") {\n      element = this._renderSingleLineInput();\n    } else {\n      element = this._renderMultiLineInput();\n    }\n\n    return element;\n  }\n\n  public resetInput(): void {\n    this._value.setAndTrigger(this.getInitialValue() ?? \"\");\n  }\n\n  private _renderSingleLineInput() {\n    return css.textInput(\n      {\n        type: this.inputType,\n        name: this.name(),\n        id: this.id(),\n        required: this.field.options.formRequired,\n      },\n      dom.prop(\"value\", this._value),\n      preventSubmitOnEnter(),\n    );\n  }\n\n  private _renderMultiLineInput() {\n    return css.textarea(\n      {\n        name: this.name(),\n        id: this.id(),\n        required: this.field.options.formRequired,\n        rows: this._lineCount,\n      },\n      dom.prop(\"value\", this._value),\n    );\n  }\n}\n\nclass TextRenderer extends BaseTextRenderer {\n  private _counter = Observable.create<number>(this, this.getInitialValue()?.length ?? 0);\n\n  public label() {\n    const maximumLength = this.field.options.formTextMaximumLength;\n\n    if (!maximumLength) {\n      return super.label();\n    }\n\n    return labelRow(\n      super.label(),\n      constraintLabel(\n        dom.text(use => `(${use(this._counter)} / ${maximumLength})`),\n        testId(\"text-constraint\"),\n      ),\n    );\n  }\n\n  public input(): HTMLTextAreaElement | HTMLInputElement {\n    const element = super.input();\n\n    const maximumLength = this.field.options.formTextMaximumLength;\n\n    if (maximumLength) {\n      element.maxLength = maximumLength;\n\n      dom.update(element,\n        dom.on(\"input\", (_e, elem: HTMLTextAreaElement | HTMLInputElement) => this._counter.set(elem.value.length)),\n      );\n    }\n\n    return element;\n  }\n}\n\nclass NumericRenderer extends BaseFieldRenderer {\n  protected inputType = \"text\";\n\n  private _format = this.field.options.formNumberFormat ?? \"text\";\n  private _value = Observable.create<string>(this, this.getInitialValue() ?? \"\");\n  private _spinnerValue = Observable.create<number | \"\">(this, this.getInitialNumericValue());\n\n  public input() {\n    if (this._format === \"text\") {\n      return this._renderTextInput();\n    } else {\n      return this._renderSpinnerInput();\n    }\n  }\n\n  public resetInput(): void {\n    this._value.setAndTrigger(this.getInitialValue() ?? \"\");\n    this._spinnerValue.setAndTrigger(this.getInitialNumericValue());\n  }\n\n  protected getInitialNumericValue(): number | \"\" {\n    const val = this.getInitialValue();\n    return (val && isNumber(val)) ? parseFloat(val) : \"\";\n  }\n\n  private _renderTextInput() {\n    return css.textInput(\n      {\n        type: this.inputType,\n        name: this.name(),\n        id: this.id(),\n        required: this.field.options.formRequired,\n      },\n      dom.prop(\"value\", this._value),\n      preventSubmitOnEnter(),\n    );\n  }\n\n  private _renderSpinnerInput() {\n    return css.spinner(\n      this._spinnerValue,\n      {\n        setValueOnInput: true,\n        inputArgs: [\n          {\n            name: this.name(),\n            id: this.id(),\n            required: this.field.options.formRequired,\n          },\n          preventSubmitOnEnter(),\n        ],\n      },\n    );\n  }\n}\n\nclass DateRenderer extends BaseTextRenderer {\n  protected inputType = \"date\";\n}\n\nclass DateTimeRenderer extends BaseTextRenderer {\n  protected inputType = \"datetime-local\";\n}\n\nexport const selectPlaceholder = () => t(\"Select...\");\n\nclass ChoiceRenderer extends BaseFieldRenderer  {\n  protected value: Observable<string>;\n\n  private _choices: string[];\n  private _selectElement: HTMLElement;\n  private _ctl?: PopupControl<IPopupOptions>;\n  private _format = this.field.options.formSelectFormat ?? \"select\";\n  private _alignment = this.field.options.formOptionsAlignment ?? \"vertical\";\n  private _radioButtons: MutableObsArray<{\n    label: string;\n    checked: Observable<boolean>\n  }> = this.autoDispose(obsArray());\n\n  public constructor(field: FormField, context: FormRendererContext) {\n    super(field, context);\n\n    const choices = this.field.options.choices;\n    if (!Array.isArray(choices) || choices.some(choice => typeof choice !== \"string\")) {\n      this._choices = [];\n    } else {\n      sortChoicesInPlace(choices, choice => String(choice), this.field.options.formOptionsSortOrder);\n      this._choices = choices;\n    }\n\n    const initialValue = this.getInitialValue();\n    this.value = Observable.create<string>(this, initialValue ?? \"\");\n\n    this._radioButtons.set(this._choices.map(choice => ({\n      label: String(choice),\n      checked: Observable.create(this, String(choice) === initialValue),\n    })));\n  }\n\n  public fieldDomAttributes() {\n    if (this._format === \"radio\") {\n      return {\n        \"role\": \"group\",\n        \"aria-labelledby\": `${this.id()}-label`,\n      };\n    }\n    return {};\n  }\n\n  public input() {\n    if (this._format === \"select\") {\n      return this._renderSelectInput();\n    } else {\n      return this._renderRadioInput();\n    }\n  }\n\n  public resetInput() {\n    const initialValue = this.getInitialValue();\n    this.value.set(initialValue ?? \"\");\n    this._radioButtons.get().forEach((radioButton) => {\n      radioButton.checked.set(radioButton.label === initialValue);\n    });\n  }\n\n  protected override getInitialValue() {\n    const val = super.getInitialValue();\n    return val && this._choices.includes(val) ? val : null;\n  }\n\n  private _renderSelectInput() {\n    return css.hybridSelect(\n      this._selectElement = css.select(\n        { name: this.name(), id: this.id(), required: this.field.options.formRequired },\n        dom.on(\"input\", (_e, elem) => this.value.set(elem.value)),\n        dom(\"option\", { value: \"\" }, selectPlaceholder()),\n        this._choices.map(choice => dom(\"option\",\n          { value: choice },\n          dom.prop(\"selected\", use => use(this.value) === choice),\n          choice,\n        )),\n        dom.onKeyDown({\n          \"Enter$\": ev => this._maybeOpenSearchSelect(ev),\n          \" $\": ev => this._maybeOpenSearchSelect(ev),\n          \"ArrowUp$\": ev => this._maybeOpenSearchSelect(ev),\n          \"ArrowDown$\": ev => this._maybeOpenSearchSelect(ev),\n          \"Backspace$\": () => this.value.set(\"\"),\n        }),\n        preventSubmitOnEnter(),\n      ),\n      dom.maybe(use => !use(isXSmallScreenObs()), () =>\n        css.searchSelect(\n          css.currentSelectValue(dom.text(use => use(this.value) || selectPlaceholder())),\n          dropdownWithSearch<string>({\n            action: value => this.value.set(value),\n            options: () => this._choices.map(choice => ({\n              label: choice,\n              value: choice,\n            })),\n            onClose: () => { setTimeout(() => this._selectElement.focus()); },\n            placeholder: t(\"Search\"),\n            acOptions: { maxResults: 100, keepOrder: false, showEmptyItems: true },\n            popupOptions: {\n              trigger: [\n                \"click\",\n                (_el, ctl) => { this._ctl = ctl; },\n              ],\n            },\n            matchTriggerElemWidth: true,\n          }),\n          css.resetSelectButton(\n            icon(\"CrossSmall\"),\n            dom.attr(\"aria-label\", t(\"Clear selection for: {{-inputLabel}}\", { inputLabel: this.field.question })),\n            dom.hide(use => !use(this.value)),\n            dom.on(\"click\", (ev) => {\n              this.value.set(\"\");\n              this._selectElement.focus();\n              ev.stopPropagation();\n              ev.preventDefault();\n            }),\n            testId(\"search-select-clear-btn\"),\n          ),\n          css.searchSelectIcon(\"Collapse\"),\n          testId(\"search-select\"),\n        ),\n      ),\n    );\n  }\n\n  private _renderRadioInput() {\n    const required = this.field.options.formRequired;\n    return css.radioList(\n      css.radioList.cls(\"-horizontal\", this._alignment === \"horizontal\"),\n      dom.cls(\"grist-radio-list\"),\n      dom.cls(\"required\", Boolean(required)),\n      { name: this.name(), required },\n      dom.forEach(this._radioButtons, radioButton =>\n        css.radio(\n          dom(\"input\",\n            dom.prop(\"checked\", radioButton.checked),\n            dom.on(\"change\", (_e, elem) => radioButton.checked.set(elem.checked)),\n            {\n              type: \"radio\",\n              name: `${this.name()}`,\n              value: radioButton.label,\n            },\n            preventSubmitOnEnter(),\n          ),\n          dom(\"span\", radioButton.label),\n        ),\n      ),\n    );\n  }\n\n  private _maybeOpenSearchSelect(ev: KeyboardEvent) {\n    if (isXSmallScreenObs().get()) {\n      return;\n    }\n\n    ev.preventDefault();\n    ev.stopPropagation();\n    this._ctl?.open();\n  }\n}\n\nclass BoolRenderer extends BaseFieldRenderer {\n  protected inputType = \"checkbox\";\n  protected checked = Observable.create<boolean>(this, isAffirmative(this.getInitialValue()));\n\n  private _format = this.field.options.formToggleFormat ?? \"switch\";\n\n  public render() {\n    return css.field(\n      dom(\"div\", this.input()),\n    );\n  }\n\n  public input() {\n    if (this._format === \"switch\") {\n      return this._renderSwitchInput();\n    } else {\n      return this._renderCheckboxInput();\n    }\n  }\n\n  public resetInput(): void {\n    this.checked.set(isAffirmative(this.getInitialValue()));\n  }\n\n  private _renderSwitchInput() {\n    return toggleSwitch(this.checked, {\n      label: this.field.question,\n      inputArgs: [\n        { name: this.name(), required: this.field.options.formRequired },\n        preventSubmitOnEnter(),\n      ],\n      labelArgs: [\n        css.label.cls(\"-required\", Boolean(this.field.options.formRequired)),\n      ],\n    });\n  }\n\n  private _renderCheckboxInput() {\n    return css.toggle(\n      css.checkboxInput(\n        dom.prop(\"checked\", this.checked),\n        dom.prop(\"value\", use => use(this.checked) ? \"1\" : \"0\"),\n        dom.on(\"change\", (_e, elem) => this.checked.set(elem.checked)),\n        {\n          type: this.inputType,\n          name: this.name(),\n          required: this.field.options.formRequired,\n        },\n        preventSubmitOnEnter(),\n      ),\n      css.toggleLabel(\n        css.label.cls(\"-required\", Boolean(this.field.options.formRequired)),\n        this.field.question,\n      ),\n    );\n  }\n}\n\nclass ChoiceListRenderer extends BaseFieldRenderer  {\n  protected checkboxes: MutableObsArray<{\n    label: string;\n    checked: Observable<boolean>\n  }> = this.autoDispose(obsArray());\n\n  private _alignment = this.field.options.formOptionsAlignment ?? \"vertical\";\n\n  public constructor(field: FormField, context: FormRendererContext) {\n    super(field, context);\n\n    let choices = this.field.options.choices;\n    if (!Array.isArray(choices) || choices.some(choice => typeof choice !== \"string\")) {\n      choices = [];\n    } else {\n      sortChoicesInPlace(choices, choice => String(choice), this.field.options.formOptionsSortOrder);\n      choices = choices.slice(0, getFormOptionsLimit(this.field.options));\n    }\n\n    const initialValues = new Set(this.getInitialValueList());\n    this.checkboxes.set(choices.map(choice => ({\n      label: choice,\n      checked: Observable.create(this, initialValues.has(choice)),\n    })));\n  }\n\n  public fieldDomAttributes() {\n    return {\n      \"role\": \"group\",\n      \"aria-labelledby\": `${this.id()}-label`,\n    };\n  }\n\n  public input() {\n    const required = this.field.options.formRequired;\n    return css.checkboxList(\n      css.checkboxList.cls(\"-horizontal\", this._alignment === \"horizontal\"),\n      dom.cls(\"grist-checkbox-list\"),\n      dom.cls(\"required\", Boolean(required)),\n      { name: this.name(), required },\n      dom.forEach(this.checkboxes, checkbox =>\n        css.checkbox(\n          css.checkboxInput(\n            dom.prop(\"checked\", checkbox.checked),\n            dom.on(\"change\", (_e, elem) => checkbox.checked.set(elem.checked)),\n            {\n              type: \"checkbox\",\n              name: `${this.name()}[]`,\n              value: checkbox.label,\n            },\n            preventSubmitOnEnter(),\n          ),\n          dom(\"span\", checkbox.label),\n        ),\n      ),\n    );\n  }\n\n  public resetInput(): void {\n    const initialValues = new Set(this.getInitialValueList());\n    this.checkboxes.get().forEach((checkbox) => {\n      checkbox.checked.set(initialValues.has(checkbox.label));\n    });\n  }\n}\n\nclass RefListRenderer extends BaseFieldRenderer {\n  protected checkboxes: MutableObsArray<{\n    label: string;\n    value: string;\n    checked: Observable<boolean>\n  }> = this.autoDispose(obsArray());\n\n  private _alignment = this.field.options.formOptionsAlignment ?? \"vertical\";\n\n  public constructor(field: FormField, context: FormRendererContext) {\n    super(field, context);\n\n    const references = this.field.refValues ?? [];\n    // Sort by the second value, which is the display value.\n    sortChoicesInPlace(references, ref => String(ref[1]), this.field.options.formOptionsSortOrder);\n    references.splice(getFormOptionsLimit(this.field.options));\n    const initialValues = new Set(this.getInitialValueList());\n    this.checkboxes.set(references.map(reference => ({\n      label: String(reference[1]),\n      value: String(reference[0]),\n      checked: Observable.create(this, initialValues.has(String(reference[1]))),\n    })));\n  }\n\n  public fieldDomAttributes() {\n    return {\n      \"role\": \"group\",\n      \"aria-labelledby\": `${this.id()}-label`,\n    };\n  }\n\n  public input() {\n    const required = this.field.options.formRequired;\n    return css.checkboxList(\n      css.checkboxList.cls(\"-horizontal\", this._alignment === \"horizontal\"),\n      dom.cls(\"grist-checkbox-list\"),\n      dom.cls(\"required\", Boolean(required)),\n      { name: this.name(), required },\n      dom.forEach(this.checkboxes, checkbox =>\n        css.checkbox(\n          css.checkboxInput(\n            dom.prop(\"checked\", checkbox.checked),\n            dom.on(\"change\", (_e, elem) => checkbox.checked.set(elem.checked)),\n            {\n              \"type\": \"checkbox\",\n              \"data-grist-type\": this.field.type,\n              \"name\": `${this.name()}[]`,\n              \"value\": checkbox.value,\n            },\n            preventSubmitOnEnter(),\n          ),\n          dom(\"span\", checkbox.label),\n        ),\n      ),\n    );\n  }\n\n  public resetInput(): void {\n    const initialValues = new Set(this.getInitialValueList());\n    this.checkboxes.get().forEach((checkbox) => {\n      checkbox.checked.set(initialValues.has(checkbox.label));\n    });\n  }\n}\n\nclass RefRenderer extends BaseFieldRenderer {\n  protected value: Observable<string>;\n\n  private _format = this.field.options.formSelectFormat ?? \"select\";\n  private _alignment = this.field.options.formOptionsAlignment ?? \"vertical\";\n  private _choices: [number | string, CellValue][];\n  private _selectElement: HTMLElement;\n  private _ctl?: PopupControl<IPopupOptions>;\n  private _radioButtons: MutableObsArray<{\n    label: string;\n    value: string;\n    checked: Observable<boolean>\n  }> = this.autoDispose(obsArray());\n\n  public constructor(field: FormField, context: FormRendererContext) {\n    super(field, context);\n\n    const choices: [number | string, CellValue][] = this.field.refValues ?? [];\n    // Sort by the second value, which is the display value.\n    sortChoicesInPlace(choices, choice => String(choice[1]), this.field.options.formOptionsSortOrder);\n    this._choices = choices;\n\n    const initialValue = this.getInitialValue();\n    this.value = Observable.create<string>(this, initialValue ?? \"\");\n\n    this._radioButtons.set(this._choices.map(reference => ({\n      label: String(reference[1]),\n      value: String(reference[0]),\n      checked: Observable.create(this, String(reference[0]) === initialValue),\n    })));\n  }\n\n  public fieldDomAttributes() {\n    if (this._format === \"radio\") {\n      return {\n        \"role\": \"group\",\n        \"aria-labelledby\": `${this.id()}-label`,\n      };\n    }\n    return {};\n  }\n\n  public input() {\n    if (this._format === \"select\") {\n      return this._renderSelectInput();\n    } else {\n      return this._renderRadioInput();\n    }\n  }\n\n  public resetInput(): void {\n    const initialValue = this.getInitialValue();\n    this.value.set(initialValue ?? \"\");\n    this._radioButtons.get().forEach((radioButton) => {\n      radioButton.checked.set(radioButton.value === initialValue);\n    });\n  }\n\n  // This returns the reference rather than its label (e.g. '5' rather than 'Some Name').\n  protected override getInitialValue() {\n    const initialValue = super.getInitialValue();\n    const choice = initialValue && this._choices.find(([id, value]) => (String(value) === initialValue));\n    return choice ? String(choice[0]) : null;\n  }\n\n  private _renderSelectInput() {\n    return css.hybridSelect(\n      this._selectElement = css.select(\n        {\n          \"name\": this.name(),\n          \"id\": this.id(),\n          \"data-grist-type\": this.field.type,\n          \"required\": this.field.options.formRequired,\n        },\n        dom.on(\"input\", (_e, elem) => this.value.set(elem.value)),\n        dom(\"option\",\n          { value: \"\" },\n          selectPlaceholder(),\n          dom.prop(\"selected\", use => use(this.value) === \"\"),\n        ),\n        this._choices.map(choice => dom(\"option\",\n          { value: String(choice[0]) },\n          String(choice[1]),\n          dom.prop(\"selected\", use => use(this.value) === String(choice[0])),\n        )),\n        dom.onKeyDown({\n          \"Enter$\": ev => this._maybeOpenSearchSelect(ev),\n          \" $\": ev => this._maybeOpenSearchSelect(ev),\n          \"ArrowUp$\": ev => this._maybeOpenSearchSelect(ev),\n          \"ArrowDown$\": ev => this._maybeOpenSearchSelect(ev),\n          \"Backspace$\": () => this.value.set(\"\"),\n        }),\n        preventSubmitOnEnter(),\n      ),\n      dom.maybe(use => !use(isXSmallScreenObs()), () =>\n        css.searchSelect(\n          css.currentSelectValue(dom.text((use) => {\n            const choice = this._choices.find(c => String(c[0]) === use(this.value));\n            return String(choice?.[1] || selectPlaceholder());\n          })),\n          dropdownWithSearch<string>({\n            action: value => this.value.set(value),\n            options: () => this._choices.map(choice => ({\n              label: String(choice[1]),\n              value: String(choice[0]),\n            })),\n            onClose: () => { setTimeout(() => this._selectElement.focus()); },\n            acOptions: { maxResults: 100, keepOrder: false, showEmptyItems: true },\n            placeholder: \"Search\",\n            popupOptions: {\n              trigger: [\n                \"click\",\n                (_el, ctl) => { this._ctl = ctl; },\n              ],\n            },\n            matchTriggerElemWidth: true,\n          }),\n          css.resetSelectButton(\n            icon(\"CrossSmall\"),\n            dom.attr(\"aria-label\", t(\"Clear selection for: {{-inputLabel}}\", { inputLabel: this.field.question })),\n            dom.hide(use => !use(this.value)),\n            dom.on(\"click\", (ev) => {\n              this.value.set(\"\");\n              this._selectElement.focus();\n              ev.stopPropagation();\n              ev.preventDefault();\n            }),\n            testId(\"search-select-clear-btn\"),\n          ),\n          css.searchSelectIcon(\"Collapse\"),\n          testId(\"search-select\"),\n        ),\n      ),\n    );\n  }\n\n  private _renderRadioInput() {\n    const required = this.field.options.formRequired;\n    return css.radioList(\n      css.radioList.cls(\"-horizontal\", this._alignment === \"horizontal\"),\n      dom.cls(\"grist-radio-list\"),\n      dom.cls(\"required\", Boolean(required)),\n      { \"name\": this.name(), required, \"data-grist-type\": this.field.type },\n      dom.forEach(this._radioButtons, radioButton =>\n        css.radio(\n          dom(\"input\",\n            dom.prop(\"checked\", radioButton.checked),\n            dom.on(\"change\", (_e, elem) => radioButton.checked.set(elem.checked)),\n            {\n              type: \"radio\",\n              name: `${this.name()}`,\n              value: radioButton.value,\n            },\n            preventSubmitOnEnter(),\n          ),\n          dom(\"span\", radioButton.label),\n        ),\n      ),\n    );\n  }\n\n  private _maybeOpenSearchSelect(ev: KeyboardEvent) {\n    if (isXSmallScreenObs().get()) {\n      return;\n    }\n\n    ev.preventDefault();\n    ev.stopPropagation();\n    this._ctl?.open();\n  }\n}\n\nclass AttachmentsRenderer extends BaseFieldRenderer {\n  protected inputType = \"file\";\n  // Note that we aren't attempting to support initial values (taken from URL) for attachments.\n  // That wouldn't be expected by anyone anyway.\n  private _value = Observable.create<File[]>(this, []);\n\n  public input() {\n    return css.attachmentInput(\n      dom.cls(\"field_clip\"),\n      {\n        type: this.inputType,\n        name: this.name(),\n        id: this.id(),\n        required: this.field.options.formRequired,\n      },\n      dom.prop(\"value\", this._value),\n      dom.prop(\"multiple\", true),\n      testId(\"attachment-input\"),\n    );\n  }\n\n  public resetInput(): void {\n    this._value.set([]);\n  }\n}\n\nconst FieldRenderers = {\n  Text: TextRenderer,\n  Numeric: NumericRenderer,\n  Int: NumericRenderer,\n  Choice: ChoiceRenderer,\n  Bool: BoolRenderer,\n  ChoiceList: ChoiceListRenderer,\n  Date: DateRenderer,\n  DateTime: DateTimeRenderer,\n  Ref: RefRenderer,\n  RefList: RefListRenderer,\n  Attachments: AttachmentsRenderer,\n};\n\nconst FormRenderers = {\n  Paragraph: ParagraphRenderer,\n  Section: SectionRenderer,\n  Columns: ColumnsRenderer,\n  Submit: SubmitRenderer,\n  Placeholder: PlaceholderRenderer,\n  Layout: LayoutRenderer,\n  Field: FieldRenderer,\n  Label: LabelRenderer,\n  // Aliases for Paragraph.\n  Separator: ParagraphRenderer,\n  Header: ParagraphRenderer,\n};\n\nfunction preventSubmitOnEnter() {\n  return dom.onKeyDown({ Enter$: ev => ev.preventDefault() });\n}\n\n/**\n * Validates the required attribute of checkbox and radio lists, such as those\n * used by Choice, Choice List, Reference, and Reference List fields.\n *\n * Since lists of checkboxes and radios don't natively support a required attribute, we\n * simulate it by marking the first checkbox/radio of each required list as being a\n * required input. Then, we make another pass and unmark all required checkbox/radio\n * inputs if they belong to a list where at least one checkbox/radio is checked. If any\n * inputs in a required are left as required, HTML validations that are triggered when\n * submitting a form will catch them and prevent the submission.\n */\nfunction validateRequiredLists() {\n  for (const type of [\"checkbox\", \"radio\"]) {\n    const requiredLists = document\n      .querySelectorAll(`.grist-${type}-list.required:not(:has(input:checked))`);\n    Array.from(requiredLists).forEach(function(list) {\n      const firstOption = list.querySelector(`input[type=\"${type}\"]`);\n      firstOption?.setAttribute(\"required\", \"required\");\n    });\n\n    const requiredListsWithCheckedOption = document\n      .querySelectorAll(`.grist-${type}-list.required:has(input:checked`);\n    Array.from(requiredListsWithCheckedOption).forEach(function(list) {\n      const firstOption = list.querySelector(`input[type=\"${type}\"]`);\n      firstOption?.removeAttribute(\"required\");\n    });\n  }\n}\n\n/**\n * Uses formOptions.formOptionsSortOrder to sort the passed-in array of choices in-place,\n * according to the value of getSortKey(item).\n */\nexport function sortChoicesInPlace<T>(\n  choices: T[], getSortKey: (item: T) => string, formOptionsSortOrder: FormOptionsSortOrder | undefined,\n) {\n  const sortOrder = formOptionsSortOrder ?? \"default\";\n  if (sortOrder !== \"default\") {\n    // The `numeric` option is to use natural sort on numbers (e.g. \"1\" < \"2\" < \"10\"); this seems\n    // a better choice for presenting a list of choices in forms when sorting is requested.\n    const collator = new Intl.Collator(undefined, { numeric: true });\n    choices.sort((a, b) => collator.compare(getSortKey(a), getSortKey(b)));\n    if (sortOrder === \"descending\") {\n      choices.reverse();\n    }\n  }\n}\n\nconst constraintLabel = styled(\"span\", `\n  color: ${theme.text};\n  font-size: ${vars.xsmallFontSize};\n  margin-left: 8px;\n`);\n\nconst labelRow = styled(\"div\", `\n  display: flex;\n  align-items: center;\n`);\n"
  },
  {
    "path": "app/client/components/FormRendererCss.ts",
    "content": "import { colors, mediaXSmall, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { numericSpinner } from \"app/client/widgets/NumericSpinner\";\n\nimport { styled } from \"grainjs\";\n\nexport const label = styled(\"div\", `\n  &-required::after {\n    content: \"*\";\n    color: ${vars.primaryBg};\n    margin-left: 4px;\n  }\n`);\n\nexport const paragraph = styled(\"div\", `\n  overflow-wrap: break-word;\n  position: relative;\n  clip-path: inset(0px);\n\n  &-alignment-left {\n    text-align: left;\n  }\n  &-alignment-center {\n    text-align: center;\n  }\n  &-alignment-right {\n    text-align: right;\n  }\n`);\n\nexport const section = styled(\"div\", `\n  border-radius: 3px;\n  border: 1px solid ${colors.darkGrey};\n  padding: 24px;\n  margin-top: 12px;\n  margin-bottom: 24px;\n\n  & > div + div {\n    margin-top: 8px;\n    margin-bottom: 12px;\n  }\n`);\n\nexport const columns = styled(\"div\", `\n  display: grid;\n  grid-template-columns: repeat(var(--grist-columns-count), 1fr);\n  gap: 16px;\n`);\n\nexport const submitButtons = styled(\"div\", `\n  margin-top: 16px;\n  display: flex;\n  justify-content: center;\n  column-gap: 8px;\n`);\n\nexport const resetButton = styled(\"button\", `\n  line-height: inherit;\n  font-size: ${vars.mediumFontSize};\n  padding: 10px 24px;\n  cursor: pointer;\n  background-color: transparent;\n  color: ${vars.primaryBg};\n  border: 1px solid ${vars.primaryBg};\n  border-radius: 4px;\n  outline-color: ${vars.primaryBgHover};\n\n  &:hover {\n    color: ${vars.primaryBgHover};\n    border-color: ${vars.primaryBgHover};\n  }\n  &[aria-disabled=\"true\"] {\n    cursor: not-allowed;\n    color: ${colors.light};\n    background-color: ${colors.slate};\n    border-color: ${colors.slate};\n  }\n`);\n\nexport const submitButton = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n\n  & button[type=\"submit\"] {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    gap: 4px;\n    background-color: ${vars.primaryBg};\n    border: 1px solid ${vars.primaryBg};\n    color: white;\n    padding: 10px 24px;\n    border-radius: 4px;\n    font-size: 13px;\n    cursor: pointer;\n    line-height: inherit;\n    outline-color: ${vars.primaryBgHover};\n  }\n  & button[type=\"submit\"]:hover {\n    border-color: ${vars.primaryBgHover};\n    background-color: ${vars.primaryBgHover};\n  }\n  & button[type=\"submit\"][aria-disabled=\"true\"] {\n    cursor: not-allowed;\n    color: ${colors.light};\n    background-color: ${colors.slate};\n    border-color: ${colors.slate};\n  }\n`);\n\nexport const field = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  justify-content: space-between;\n\n  & > .${label.className} {\n    color: ${colors.dark};\n    font-size: 13px;\n    font-style: normal;\n    font-weight: 700;\n    line-height: 16px; /* 145.455% */\n    margin-top: 8px;\n    margin-bottom: 8px;\n    display: block;\n    overflow-wrap: break-word;\n  }\n`);\n\nexport const error = styled(\"div\", `\n  margin-top: 16px;\n  text-align: center;\n  color: ${colors.error};\n  min-height: 22px;\n`);\n\nexport const textInput = styled(\"input\", `\n  color: ${colors.dark};\n  background-color: ${colors.light};\n  height: 29px;\n  width: 100%;\n  font-size: 13px;\n  line-height: inherit;\n  padding: 4px 8px;\n  border: 1px solid ${colors.darkGrey};\n  border-radius: 3px;\n  outline-color: ${vars.primaryBgHover};\n`);\n\nexport const textarea = styled(\"textarea\", `\n  display: block;\n  color: ${colors.dark};\n  background-color: ${colors.light};\n  min-height: 29px;\n  width: 100%;\n  font-size: 13px;\n  line-height: inherit;\n  padding: 4px 8px;\n  border: 1px solid ${colors.darkGrey};\n  border-radius: 3px;\n  outline-color: ${vars.primaryBgHover};\n  resize: none;\n`);\n\nexport const checkboxInput = styled(\"input\", `\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  margin: 0;\n  padding: 0;\n  flex-shrink: 0;\n  display: inline-block;\n  width: 16px;\n  height: 16px;\n  --radius: 3px;\n  position: relative;\n  margin-right: 8px;\n  vertical-align: baseline;\n\n  &:focus {\n    outline-color: ${vars.primaryBgHover};\n  }\n  &:checked:enabled, &:indeterminate:enabled {\n    --color: ${vars.primaryBg};\n  }\n  &:disabled {\n    --color: ${colors.darkGrey};\n    cursor: not-allowed;\n  }\n  &::before, &::after {\n    content: '';\n    position: absolute;\n    top: 0;\n    left: 0;\n    height: 16px;\n    width: 16px;\n    box-sizing: border-box;\n    border: 1px solid var(--color, ${colors.darkGrey});\n    border-radius: var(--radius);\n  }\n  &:checked::before, &:disabled::before, &:indeterminate::before {\n    background-color: var(--color);\n  }\n  &:not(:checked):indeterminate::after {\n    -webkit-mask-image: var(--icon-Minus);\n  }\n  &:not(:disabled)::after {\n    background-color: ${colors.light};\n  }\n  &:checked::after, &:indeterminate::after {\n    content: '';\n    position: absolute;\n    height: 16px;\n    width: 16px;\n    -webkit-mask-image: var(--icon-Tick);\n    -webkit-mask-size: contain;\n    -webkit-mask-position: center;\n    -webkit-mask-repeat: no-repeat;\n    background-color: ${colors.light};\n  }\n`);\n\nexport const spinner = styled(numericSpinner, `\n  & input {\n    height: 29px;\n    border: none;\n    font-size: 13px;\n    line-height: inherit;\n  }\n\n  &:focus-within {\n    outline: 2px solid ${vars.primaryBgHover};\n  }\n`);\n\nexport const toggle = styled(\"label\", `\n  position: relative;\n  display: inline-flex;\n  margin-top: 8px;\n\n  &:hover {\n    --color: ${colors.hover};\n  }\n`);\n\nexport const toggleLabel = styled(\"span\", `\n  font-size: 13px;\n  font-weight: 700;\n  line-height: 16px;\n  overflow-wrap: anywhere;\n`);\n\nexport const checkboxList = styled(\"div\", `\n  display: inline-flex;\n  flex-direction: column;\n  gap: 8px;\n\n  &-horizontal {\n    flex-direction: row;\n    flex-wrap: wrap;\n    column-gap: 16px;\n  }\n`);\n\nexport const checkbox = styled(\"label\", `\n  display: flex;\n  font-size: 13px;\n  line-height: 16px;\n  gap: 8px;\n  overflow-wrap: anywhere;\n\n  & input {\n    margin: 0px !important;\n  }\n  &:hover {\n    --color: ${colors.hover};\n  }\n`);\n\nexport const radioList = checkboxList;\n\nexport const radio = styled(\"label\", `\n  position: relative;\n  display: inline-flex;\n  gap: 8px;\n  font-size: 13px;\n  line-height: 16px;\n  font-weight: normal;\n  min-width: 0px;\n  outline-color: ${vars.primaryBgHover};\n  overflow-wrap: anywhere;\n\n  & input {\n    flex-shrink: 0;\n    appearance: none;\n    width: 16px;\n    height: 16px;\n    margin: 0px;\n    border-radius: 50%;\n    background-clip: content-box;\n    border: 1px solid ${colors.darkGrey};\n    background-color: transparent;\n    outline-color: ${vars.primaryBgHover};\n  }\n  & input:hover {\n    border: 1px solid ${colors.hover};\n  }\n  & input:checked {\n    padding: 2px;\n    background-color: ${vars.primaryBg};\n    border: 1px solid ${vars.primaryBg};\n  }\n`);\n\nexport const hybridSelect = styled(\"div\", `\n  position: relative;\n`);\n\nexport const select = styled(\"select\", `\n  position: absolute;\n  padding: 4px 8px;\n  border-radius: 3px;\n  border: 1px solid ${colors.darkGrey};\n  font-size: 13px;\n  outline: none;\n  background: white;\n  line-height: inherit;\n  height: 29px;\n  flex: auto;\n  width: 100%;\n\n  @media ${mediaXSmall} {\n    & {\n      outline: revert;\n      outline-color: ${vars.primaryBgHover};\n      position: relative;\n    }\n  }\n`);\n\nexport const searchSelect = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  position: relative;\n  padding: 4px 8px;\n  border-radius: 3px;\n  outline: 1px solid ${colors.darkGrey};\n  font-size: 13px;\n  background: white;\n  line-height: inherit;\n  height: 29px;\n  flex: auto;\n  width: 100%;\n\n  select:focus + & {\n    outline: 2px solid ${vars.primaryBgHover};\n  }\n`);\n\nexport const currentSelectValue = styled(\"div\", \"flex: 1\");\n\nexport const searchSelectIcon = styled(icon, `\n  flex-shrink: 0;\n`);\n\nexport const resetSelectButton = styled(unstyledButton, `\n  flex-shrink: 0;\n  cursor: pointer;\n`);\n\nexport const attachmentInput = styled(\"input\", `\n  display: flex;\n  flex-wrap: wrap;\n  white-space: pre-wrap;\n  position: relative;\n  width: 100%;\n\n  &:focus {\n    outline-color: ${vars.primaryBgHover};\n  }\n\n  &::file-selector-button, &::-webkit-file-upload-button {\n    background-color: ${vars.primaryBg};\n    border: 1px solid ${vars.primaryBg};\n    color: white;\n    padding: 4px 8px;\n    border-radius: 4px;\n    font-size: 13px;\n    cursor: pointer;\n    line-height: inherit;\n    outline-color: ${vars.primaryBgHover};\n  }\n\n  &::file-selector-button:hover, &::-webkit-file-upload-button:hover {\n    border-color: ${vars.primaryBgHover};\n    background-color: ${vars.primaryBgHover};\n  }\n\n  &::file-selector-button:disabled, &::-webkit-file-upload-button:disabled {\n    cursor: not-allowed;\n    color: ${colors.light};\n    background-color: ${colors.slate};\n    border-color: ${colors.slate};\n  }\n`);\n\nexport const buttonLoadingSpinner = styled(loadingSpinner, `\n  width: 1em;\n  height: 1em;\n  line-height: inherit;\n  border-radius: 50%;\n  border-width: 1px;\n  margin-bottom: 1px;\n  --loader-fg: currentColor;\n`);\n"
  },
  {
    "path": "app/client/components/Forms/Columns.ts",
    "content": "import { FormLayoutNode } from \"app/client/components/FormRenderer\";\nimport { buildEditor } from \"app/client/components/Forms/Editor\";\nimport { FieldModel } from \"app/client/components/Forms/Field\";\nimport { buildMenu } from \"app/client/components/Forms/Menu\";\nimport { BoxModel } from \"app/client/components/Forms/Model\";\nimport * as style from \"app/client/components/Forms/styles\";\nimport { makeTestId } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport * as menus from \"app/client/ui2018/menus\";\nimport { inlineStyle, not } from \"app/common/gutil\";\n\nimport { bundleChanges, Computed, dom, IDomArgs, MultiHolder, Observable, styled } from \"grainjs\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nconst testId = makeTestId(\"test-forms-\");\n\nconst t = makeT(\"Columns\");\n\nexport class ColumnsModel extends BoxModel {\n  private _columnCount = Computed.create(this, use => use(this.children).length);\n\n  public removeChild(box: BoxModel) {\n    if (box.type === \"Placeholder\") {\n      // Make sure we have at least one rendered.\n      if (this.children.get().length <= 1) {\n        return;\n      }\n      return super.removeChild(box);\n    }\n    // We will replace this box with a placeholder.\n    this.replace(box, Placeholder());\n  }\n\n  // Dropping a box on this component (Columns) directly will add it as a new column.\n  public accept(dropped: FormLayoutNode): BoxModel {\n    if (!this.parent) { throw new Error(\"No parent\"); }\n\n    // We need to remove it from the parent, so find it first.\n    const droppedRef = dropped.id ? this.root().find(dropped.id) : null;\n\n    // If this is already my child, don't do anything.\n    if (droppedRef?.parent === this) {\n      return droppedRef;\n    }\n\n    droppedRef?.removeSelf();\n\n    return this.append(dropped);\n  }\n\n  public render(...args: IDomArgs<HTMLElement>): HTMLElement {\n    const dragHover = Observable.create(null, false);\n\n    const content: HTMLElement = style.cssColumns(\n      dom.autoDispose(dragHover),\n\n      testId(\"content\"),\n\n      // Pass column count as a css variable (to style the grid).\n      inlineStyle(`--css-columns-count`, this._columnCount),\n\n      // Render placeholders as children.\n      dom.forEach(this.children, (child) => {\n        const toRender = child ?? BoxModel.new(Placeholder(), this);\n        return toRender.render(testId(\"column\"));\n      }),\n\n      // Append + button at the end.\n      cssPlaceholder(\n        testId(\"add\"),\n        icon(\"Plus\"),\n        dom.on(\"click\", async () => {\n          await this.save(() => {\n            this.placeAfterListChild()(Placeholder());\n          });\n        }),\n        style.cssColumn.cls(\"-add-button\"),\n        style.cssColumn.cls(\"-drag-over\", dragHover),\n\n        dom.on(\"dragleave\", (ev) => {\n          ev.stopPropagation();\n          ev.preventDefault();\n          // Just remove the style and stop propagation.\n          dragHover.set(false);\n        }),\n        dom.on(\"dragover\", (ev) => {\n          // As usual, prevent propagation.\n          ev.stopPropagation();\n          ev.preventDefault();\n          // Here we just change the style of the element.\n          ev.dataTransfer!.dropEffect = \"move\";\n          dragHover.set(true);\n        }),\n      ),\n\n      ...args,\n    );\n    return buildEditor({ box: this, content });\n  }\n\n  public async deleteSelf(): Promise<void> {\n    // Prepare all the fields that are children of this column for removal.\n    const fieldsToRemove = Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[];\n    const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());\n\n    await this.parent?.save(async () => {\n      // FormView is particularly sensitive to the order that view fields and\n      // the form layout are modified. Specifically, if the layout is\n      // modified before view fields are removed, deleting a column with\n      // mapped fields inside seems to break. The same issue affects sections\n      // containing mapped fields. Reversing the order causes no such issues.\n      //\n      // TODO: narrow down why this happens and see if it's worth fixing.\n      if (fieldIdsToRemove.length > 0) {\n        await this.view.viewSection.removeField(fieldIdsToRemove);\n      }\n\n      // Remove each child of this column from the layout.\n      this.children.get().forEach((child) => { child.removeSelf(); });\n\n      // Remove this column from the layout.\n      this.removeSelf();\n    });\n  }\n}\n\nexport class PlaceholderModel extends BoxModel {\n  public render(...args: IDomArgs<HTMLElement>): HTMLElement {\n    const [box, view] = [this, this.view];\n    const scope = new MultiHolder();\n\n    const liveIndex = Computed.create(scope, (use) => {\n      if (!box.parent) { return -1; }\n      const parentChildren = use(box.parent.children);\n      return parentChildren.indexOf(box);\n    });\n\n    const boxModelAt = Computed.create(scope, (use) => {\n      const index = use(liveIndex);\n      if (index === null) { return null; }\n      const childBox = use(box.children)[index];\n      if (!childBox) {\n        return null;\n      }\n      return childBox;\n    });\n\n    const dragHover = Observable.create(scope, false);\n\n    return cssPlaceholder(\n      style.cssDrop(),\n      testId(\"Placeholder\"),\n      testId(\"element\"),\n      dom.attr(\"data-box-model\", String(box.type)),\n      dom.autoDispose(scope),\n\n      style.cssColumn.cls(\"-drag-over\", dragHover),\n      style.cssColumn.cls(\"-empty\", not(boxModelAt)),\n      style.cssColumn.cls(\"-selected\", use => use(view.selectedBox) === box),\n\n      buildMenu({\n        box: this,\n        insertBox,\n        customItems: [menus.menuItem(removeColumn, menus.menuIcon(\"Remove\"), t(\"Remove Column\"))],\n      }),\n\n      dom.on(\"contextmenu\", (ev) => {\n        ev.stopPropagation();\n      }),\n\n      dom.on(\"dragleave\", (ev) => {\n        ev.stopPropagation();\n        ev.preventDefault();\n        // Just remove the style and stop propagation.\n        dragHover.set(false);\n      }),\n\n      dom.on(\"dragover\", (ev) => {\n        // As usual, prevent propagation.\n        ev.stopPropagation();\n        ev.preventDefault();\n        // Here we just change the style of the element.\n        ev.dataTransfer!.dropEffect = \"move\";\n        dragHover.set(true);\n      }),\n\n      dom.on(\"drop\", (ev) => {\n        ev.stopPropagation();\n        ev.preventDefault();\n        dragHover.set(false);\n\n        // Get the box that was dropped.\n        const dropped = JSON.parse(ev.dataTransfer!.getData(\"text/plain\"));\n\n        // We need to remove it from the parent, so find it first.\n        const droppedId = dropped.id;\n        const droppedRef = box.root().find(droppedId);\n\n        // Make sure that the dropped stuff is not our parent.\n        if (droppedRef) {\n          for (const child of droppedRef.traverse()) {\n            if (this === child) {\n              return;\n            }\n          }\n        }\n\n        // Now we simply insert it after this box.\n        bundleChanges(() => {\n          droppedRef?.removeSelf();\n          const parent = box.parent!;\n          parent.replace(box, dropped);\n          parent.save().catch(reportError);\n        });\n      }),\n      // If we an occupant, render it.\n      dom.maybe(boxModelAt, child => child.render()),\n      // If not, render a placeholder.\n      dom.maybe(not(boxModelAt), () =>\n        dom(\"span\", `Column `, dom.text(use => String(use(liveIndex) + 1))),\n      ),\n      ...args,\n    );\n\n    function insertBox(childBox: FormLayoutNode) {\n      // Make sure we have at least as many columns as the index we are inserting at.\n      if (!box.parent) { throw new Error(\"No parent\"); }\n      return box.parent.replace(box, childBox);\n    }\n\n    async function removeColumn() {\n      await box.deleteSelf();\n    }\n  }\n}\n\nexport function Placeholder(): FormLayoutNode {\n  return { id: uuidv4(), type: \"Placeholder\" };\n}\n\nexport function Columns(): FormLayoutNode {\n  return { id: uuidv4(), type: \"Columns\", children: [Placeholder(), Placeholder()] };\n}\n\nconst cssPlaceholder = styled(\"div\", `\n  position: relative;\n  & * {\n    /* Otherwise it will emit drag events that we want to ignore to avoid flickering */\n    pointer-events: none;\n  }\n`);\n"
  },
  {
    "path": "app/client/components/Forms/Editor.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { buildMenu } from \"app/client/components/Forms/Menu\";\nimport { BoxModel, parseBox } from \"app/client/components/Forms/Model\";\nimport * as style from \"app/client/components/Forms/styles\";\nimport { makeTestId, stopEvent } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\n\nimport { BindableValue, dom, DomContents, IDomArgs, MultiHolder, Observable } from \"grainjs\";\n\nconst testId = makeTestId(\"test-forms-\");\nconst t = makeT(\"Editor\");\n\ninterface Props {\n  box: BoxModel,\n  /** Should we show an overlay */\n  overlay?: Observable<boolean>,\n  /** Custom drag indicator slot */\n  drag?: HTMLElement,\n  /**\n   * Actual element to put into the editor. This is the main content of the editor.\n   */\n  content: DomContents,\n  /**\n   * Whether to show the remove button. Defaults to true.\n   */\n  showRemoveButton?: BindableValue<boolean>,\n  /**\n   * Custom remove icon.\n   */\n  removeIcon?: IconName,\n  /**\n   * Custom remove button rendered atop overlay.\n   */\n  removeButton?: DomContents,\n  /**\n   * Tooltip for the remove button.\n   */\n  removeTooltip?: string,\n  /**\n   * Position of the remove button. Defaults to inside.\n   */\n  removePosition?: \"inside\" | \"right\",\n  editMode?: Observable<boolean>,\n}\n\nexport function buildEditor(props: Props, ...args: IDomArgs<HTMLElement>) {\n  const owner: MultiHolder = new MultiHolder();\n  const { box, overlay } = props;\n  const view = box.view;\n  const dragHover = Observable.create(owner, false);\n  let element: HTMLElement;\n\n  // When element is selected, scroll it into view.\n  owner.autoDispose(view.selectedBox.addListener((selectedBox) => {\n    if (selectedBox === box) {\n      element?.scrollIntoView({ behavior: \"smooth\", block: \"nearest\", inline: \"nearest\" });\n    }\n  }));\n\n  // Default remove icon, can be overriden by props.\n  const defaultRemoveButton = () => style.cssRemoveButton(\n    icon((props.removeIcon as any) ?? \"RemoveBig\"),\n    dom.on(\"click\", (ev) => {\n      stopEvent(ev);\n      box.view.selectedBox.set(box);\n      allCommands.deleteFields.run();\n    }),\n    props.removeButton === null ? null : hoverTooltip(props.removeTooltip ?? t(\"Delete\")),\n    style.cssRemoveButton.cls(\"-right\", props.removePosition === \"right\"),\n    testId(\"remove\"),\n  );\n\n  const dragAbove = Observable.create(owner, false);\n  const dragBelow = Observable.create(owner, false);\n  const dragging = Observable.create(owner, false);\n\n  return element = style.cssFieldEditor(\n    testId(\"editor\"),\n\n    style.cssFieldEditor.cls(\"-drag-above\", use => use(dragAbove) && use(dragHover)),\n    style.cssFieldEditor.cls(\"-drag-below\", use => use(dragBelow) && use(dragHover)),\n\n    props.drag ?? style.cssDragWrapper(style.cssDrag(\"DragDrop\")),\n    style.cssFieldEditor.cls(`-${props.box.type}`),\n\n    // Turn on active like state when we clicked here.\n    style.cssFieldEditor.cls(\"-selected\", box.selected),\n    style.cssFieldEditor.cls(\"-cut\", box.cut),\n    testId(\"field-editor-selected\", box.selected),\n\n    // Select on click.\n    dom.on(\"click\", (ev) => {\n      stopEvent(ev);\n      box.view.selectedBox.set(box);\n    }),\n\n    // Attach context menu.\n    buildMenu({\n      box,\n      context: true,\n    }),\n\n    // And now drag and drop support.\n    { draggable: \"true\" },\n\n    // In Firefox, 'draggable' interferes with mouse selection in child input elements. Workaround\n    // is to turn off 'draggable' temporarily (see https://stackoverflow.com/q/21680363/328565).\n    dom.on(\"mousedown\", (ev, elem) => {\n      const isInput = [\"INPUT\", \"TEXTAREA\"].includes((ev.target as Element)?.tagName);\n      // Turn off 'draggable' for inputs only, to support selection there; keep it on elsewhere.\n      elem.draggable = !isInput;\n    }),\n    dom.on(\"mouseup\", (ev, elem) => { elem.draggable = true; }),\n\n    // When started, we just put the box into the dataTransfer as a plain text.\n    // TODO: this might be very sofisticated in the future.\n    dom.on(\"dragstart\", (ev) => {\n      // Prevent propagation, as we might be in a nested editor.\n      ev.stopPropagation();\n      if (props.editMode?.get()) {\n        ev.preventDefault();\n        return;\n      }\n\n      ev.dataTransfer?.setData(\"text/plain\", JSON.stringify(box.toJSON()));\n      ev.dataTransfer!.dropEffect = \"move\";\n      dragging.set(true);\n    }),\n\n    dom.on(\"dragover\", (ev) => {\n      // As usual, prevent propagation.\n      ev.stopPropagation();\n      ev.preventDefault();\n      ev.stopImmediatePropagation();\n      // Here we just change the style of the element.\n      ev.dataTransfer!.dropEffect = \"move\";\n      dragHover.set(true);\n\n      // If we are being dragged, don't animate anything.\n      if (dragging.get()) { return; }\n\n      // We only animate if the box will add dropped element as sibling.\n      if (box.willAccept() !== \"sibling\") {\n        return;\n      }\n\n      const myHeight = element.offsetHeight;\n      const percentHeight = Math.round((ev.offsetY / myHeight) * 100);\n\n      // If we are in the top half, we want to animate ourselves and transform a little below.\n      if (percentHeight < 40) {\n        dragAbove.set(true);\n        dragBelow.set(false);\n      } else if (percentHeight > 60) {\n        dragAbove.set(false);\n        dragBelow.set(true);\n      } else {\n        dragAbove.set(false);\n        dragBelow.set(false);\n      }\n    }),\n\n    dom.on(\"dragleave\", (ev) => {\n      ev.stopPropagation();\n      ev.preventDefault();\n      // Just remove the style and stop propagation.\n      dragHover.set(false);\n      dragAbove.set(false);\n      dragBelow.set(false);\n    }),\n\n    dom.on(\"dragend\", () => {\n      dragHover.set(false);\n      dragAbove.set(false);\n      dragBelow.set(false);\n      dragging.set(false);\n    }),\n\n    dom.on(\"drop\", async (ev) => {\n      stopEvent(ev);\n      dragHover.set(false);\n      dragging.set(false);\n      dragAbove.set(false);\n      const wasBelow = dragBelow.get();\n      dragBelow.set(false);\n\n      const dropped = parseBox(ev.dataTransfer!.getData(\"text/plain\"));\n      if (!dropped) { return; }\n      // We need to remove it from the parent, so find it first.\n      const droppedId = dropped.id;\n      if (droppedId === box.id) { return; }\n      const droppedModel = box.root().find(droppedId);\n      // It might happen that parent is dropped into child, so we need to check for that.\n      if (droppedModel?.find(box.id)) { return; }\n\n      if (!box.willAccept(droppedModel)) {\n        return;\n      }\n\n      // TODO: accept should do the swapping.\n      if (box.willAccept(droppedModel) === \"swap\") {\n        await box.save(async () => {\n          box.parent!.swap(box, droppedModel!);\n        });\n        return;\n      }\n\n      await box.save(async () => {\n        // When a field is dragged from the creator panel, it has a colId instead of a fieldRef (as there is no\n        // field yet). In this case, we need to create a field first.\n        if (dropped.type === \"Field\" && typeof dropped.leaf === \"string\") {\n          dropped.leaf = await view.showColumn(dropped.leaf);\n        }\n        box.accept(dropped, wasBelow ? \"below\" : \"above\");\n      });\n    }),\n\n    style.cssFieldEditor.cls(\"-drag-hover\", dragHover),\n    style.cssFieldEditorContent(\n      props.content,\n      style.cssDrop(),\n    ),\n    testId(box.type),\n    testId(\"element\"),\n    dom.attr(\"data-box-model\", String(box.type)),\n    dom.maybe(overlay, () => style.cssSelectedOverlay()),\n    dom.maybe(props.showRemoveButton ?? true, () => [\n      props.removeButton ?? dom.maybe(use => !props.editMode || !use(props.editMode), defaultRemoveButton),\n    ]),\n    ...args,\n  );\n}\n"
  },
  {
    "path": "app/client/components/Forms/Field.ts",
    "content": "import { FormLayoutNode, selectPlaceholder, sortChoicesInPlace } from \"app/client/components/FormRenderer\";\nimport { buildEditor } from \"app/client/components/Forms/Editor\";\nimport { FormView } from \"app/client/components/Forms/FormView\";\nimport { BoxModel, ignoreClick } from \"app/client/components/Forms/Model\";\nimport * as css from \"app/client/components/Forms/styles\";\nimport { stopEvent } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { DocModel, refRecord } from \"app/client/models/DocModel\";\nimport TableModel from \"app/client/models/TableModel\";\nimport {\n  FormFieldOptions,\n  FormNumberFormat,\n  FormOptionsAlignment,\n  FormOptionsSortOrder,\n  FormSelectFormat,\n  FormTextFormat,\n  FormToggleFormat,\n  getFormOptionsLimit,\n} from \"app/client/ui/FormAPI\";\nimport { autoGrow } from \"app/client/ui/forms\";\nimport { cssCheckboxSquare, cssLabel, squareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { cssRadioInput } from \"app/client/ui2018/radio\";\nimport { toggleSwitch } from \"app/client/ui2018/toggleSwitch\";\nimport { isBlankValue } from \"app/common/gristTypes\";\nimport { Constructor, not } from \"app/common/gutil\";\n\nimport {\n  BindableValue,\n  Computed,\n  Disposable,\n  dom,\n  DomContents,\n  DomElementArg,\n  Holder,\n  IDisposableOwner,\n  IDomArgs,\n  makeTestId,\n  MultiHolder,\n  observable,\n  Observable,\n  toKo,\n  UseCB,\n} from \"grainjs\";\nimport * as ko from \"knockout\";\n\nimport type { ViewFieldRec } from \"app/client/models/DocModel\";\n\nconst testId = makeTestId(\"test-forms-\");\n\nconst t = makeT(\"Field\");\n\n/**\n * Container class for all fields.\n */\nexport class FieldModel extends BoxModel {\n  /**\n   * Edit mode, (only one element can be in edit mode in the form editor).\n   */\n  public edit = Observable.create(this, false);\n  public fieldRef = this.autoDispose(ko.pureComputed(() => toKo(ko, this.leaf)()));\n  public field = refRecord(this.view.gristDoc.docModel.viewFields, this.fieldRef);\n  public colId = Computed.create(this, use => use(use(this.field).colId));\n  public column = Computed.create(this, use => use(use(this.field).column));\n  public required: Computed<boolean>;\n  public question = Computed.create(this, (use) => {\n    const field = use(this.field);\n    if (field.isDisposed() || use(field.id) === 0) { return \"\"; }\n    return use(field.question) || use(field.origLabel);\n  });\n\n  public description = Computed.create(this, (use) => {\n    const field = use(this.field);\n    return use(field.description);\n  });\n\n  /**\n   * Column type of the field.\n   */\n  public colType = Computed.create(this, (use) => {\n    const field = use(this.field);\n    return use(use(field.column).pureType);\n  });\n\n  /**\n   * Field row id.\n   */\n  public get leaf() {\n    return this.prop(\"leaf\") as Observable<number>;\n  }\n\n  /**\n   * A renderer of question instance.\n   */\n  public renderer = Computed.create(this, (use) => {\n    const ctor = fieldConstructor(use(this.colType));\n    const instance = new ctor(this);\n    use.owner.autoDispose(instance);\n    return instance;\n  });\n\n  constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) {\n    super(box, parent, view);\n\n    this.required = Computed.create(this, (use) => {\n      const field = use(this.field);\n      return Boolean(use(field.widgetOptionsJson.prop(\"formRequired\")));\n    });\n\n    this.question.onWrite((value) => {\n      this.field.peek().question.setAndSave(value).catch(reportError);\n    });\n\n    this.autoDispose(\n      this.selected.addListener((now, then) => {\n        if (!now && then) {\n          setImmediate(() => !this.edit.isDisposed() && this.edit.set(false));\n        }\n      }),\n    );\n  }\n\n  public override render(...args: IDomArgs<HTMLElement>): HTMLElement {\n    // Updated question is used for editing, we don't save on every key press, but only on blur (or enter, etc).\n    const save = (value: string) => {\n      value = value?.trim();\n      // If question is empty or same as original, don't save.\n      if (!value || value === this.field.peek().question()) {\n        return;\n      }\n      this.field.peek().question.setAndSave(value).catch(reportError);\n    };\n    const overlay = Observable.create(null, true);\n\n    const content = dom.domComputed(this.renderer, r => r.buildDom({\n      edit: this.edit,\n      overlay,\n      onSave: save,\n    }));\n\n    return buildEditor({\n      box: this,\n      overlay,\n      removeIcon: \"CrossBig\",\n      removeTooltip: t(\"Hide\"),\n      editMode: this.edit,\n      content,\n    },\n    dom.on(\"dblclick\", () => this.selected.get() && this.edit.set(true)),\n    dom.style(\"opacity\", (use) => {\n      if ((use(use(this.field).widgetOptionsJson) as FormFieldOptions).formIsHidden) {\n        return \"50%\";\n      } else {\n        return \"\";\n      }\n    }),\n    ...args,\n    );\n  }\n\n  public async deleteSelf() {\n    const rowId = this.field.peek().id.peek();\n    const view = this.view;\n    const root = this.root();\n    this.removeSelf();\n    // The order here matters for undo.\n    await root.save(async () => {\n      // Make sure to save first layout without this field, otherwise the undo won't work properly.\n      await root.save();\n      // We are disposed at this point, be still can access the view.\n      if (rowId) {\n        await view.viewSection.removeField(rowId);\n      }\n    });\n  }\n}\n\nexport abstract class Question extends Disposable {\n  protected field = this.model.field;\n\n  constructor(public model: FieldModel) {\n    super();\n  }\n\n  public buildDom(props: {\n    edit: Observable<boolean>,\n    overlay: Observable<boolean>,\n    onSave: (value: string) => void,\n  }, ...args: IDomArgs<HTMLElement>) {\n    return css.cssQuestion(\n      testId(\"question\"),\n      testType(this.model.colType),\n      this.renderLabel(props),\n      this.renderInput(),\n      css.cssQuestion.cls(\"-required\", this.model.required),\n      ...args,\n    );\n  }\n\n  public abstract renderInput(): DomContents;\n\n  protected renderLabel(props: {\n    edit: Observable<boolean>,\n    onSave: (value: string) => void,\n  }, ...args: DomElementArg[]) {\n    const { edit, onSave } = props;\n\n    const scope = new MultiHolder();\n\n    // When in edit, we will update a copy of the question.\n    const draft = Observable.create(scope, this.model.question.get());\n    scope.autoDispose(\n      this.model.question.addListener(q => draft.set(q)),\n    );\n    const controller = Computed.create(scope, use => use(draft));\n    controller.onWrite((value) => {\n      if (this.isDisposed() || draft.isDisposed()) { return; }\n      if (!edit.get()) { return; }\n      draft.set(value);\n    });\n\n    // Wire up save method.\n    const saveDraft = (ok: boolean) => {\n      if (this.isDisposed() || draft.isDisposed()) { return; }\n      if (!ok || !edit.get() || !controller.get()) {\n        controller.set(this.model.question.get());\n        return;\n      }\n      onSave(controller.get());\n    };\n    let element: HTMLTextAreaElement;\n\n    scope.autoDispose(\n      props.edit.addListener((now, then) => {\n        if (now && !then) {\n          // When we go into edit mode, we copy the question into draft.\n          draft.set(this.model.question.get());\n          // And focus on the element.\n          setTimeout(() => {\n            element?.focus();\n            element?.select();\n          }, 10);\n        }\n      }),\n    );\n\n    return [\n      dom.autoDispose(scope),\n      css.cssRequiredWrapper(\n        testId(\"label\"),\n        // When in edit - hide * and change display from grid to display\n        css.cssRequiredWrapper.cls(\"-required\", use => use(this.model.required) && !use(this.model.edit)),\n        dom.maybe(props.edit, () => [\n          element = css.cssEditableLabel(\n            controller,\n            { onInput: true },\n            // Attach common Enter,Escape, blur handlers.\n            css.saveControls(edit, saveDraft),\n            // Autoselect whole text when mounted.\n            // Auto grow for textarea.\n            autoGrow(controller),\n            // Enable normal menu.\n            dom.on(\"contextmenu\", stopEvent),\n            dom.style(\"resize\", \"none\"),\n            css.cssEditableLabel.cls(\"-edit\"),\n            testId(\"label-editor\"),\n          ),\n        ]),\n        dom.maybe(not(props.edit), () => [\n          css.cssRenderedLabel(\n            dom.text(controller),\n            testId(\"label-rendered\"),\n          ),\n        ]),\n        // When selected, we want to be able to edit the label by clicking it\n        // so we need to make it relative and z-indexed.\n        dom.style(\"position\", u => u(this.model.selected) ? \"relative\" : \"static\"),\n        dom.style(\"z-index\", \"2\"),\n        dom.on(\"click\", (ev) => {\n          if (this.model.selected.get() && !props.edit.get()) {\n            props.edit.set(true);\n            ev.stopPropagation();\n          }\n        }),\n        ...args,\n      ),\n    ];\n  }\n}\n\nclass TextModel extends Question {\n  private _format = Computed.create<FormTextFormat>(this, (use) => {\n    const field = use(this.field);\n    return use(field.widgetOptionsJson.prop(\"formTextFormat\")) ?? \"singleline\";\n  });\n\n  private _rowCount = Computed.create<number>(this, (use) => {\n    const field = use(this.field);\n    return use(field.widgetOptionsJson.prop(\"formTextLineCount\")) || 3;\n  });\n\n  public renderInput() {\n    return dom.domComputed(this._format, (format) => {\n      switch (format) {\n        case \"singleline\": {\n          return this._renderSingleLineInput();\n        }\n        case \"multiline\": {\n          return this._renderMultiLineInput();\n        }\n      }\n    });\n  }\n\n  private _renderSingleLineInput() {\n    return css.cssInput(\n      dom.prop(\"name\", u => u(u(this.field).colId)),\n      { type: \"text\", tabIndex: \"-1\" },\n    );\n  }\n\n  private _renderMultiLineInput() {\n    return css.cssTextArea(\n      dom.prop(\"name\", u => u(u(this.field).colId)),\n      dom.prop(\"rows\", this._rowCount),\n      { tabIndex: \"-1\" },\n    );\n  }\n}\n\nclass NumericModel extends Question {\n  private _format = Computed.create<FormNumberFormat>(this, (use) => {\n    const field = use(this.field);\n    return use(field.widgetOptionsJson.prop(\"formNumberFormat\")) ?? \"text\";\n  });\n\n  public renderInput() {\n    return dom.domComputed(this._format, (format) => {\n      switch (format) {\n        case \"text\": {\n          return this._renderTextInput();\n        }\n        case \"spinner\": {\n          return this._renderSpinnerInput();\n        }\n      }\n    });\n  }\n\n  private _renderTextInput() {\n    return css.cssInput(\n      dom.prop(\"name\", u => u(u(this.field).colId)),\n      { type: \"text\", tabIndex: \"-1\" },\n    );\n  }\n\n  private _renderSpinnerInput() {\n    return css.cssSpinner(observable(\"\"), {});\n  }\n}\n\nclass ChoiceModel extends Question {\n  protected choices: Computed<string[]>;\n\n  protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => {\n    const field = use(this.field);\n    return use(field.widgetOptionsJson.prop(\"formOptionsAlignment\")) ?? \"vertical\";\n  });\n\n  private _format = Computed.create<FormSelectFormat>(this, (use) => {\n    const field = use(this.field);\n    return use(field.widgetOptionsJson.prop(\"formSelectFormat\")) ?? \"select\";\n  });\n\n  private _sortOrder = Computed.create<FormOptionsSortOrder | undefined>(this, (use) => {\n    const field = use(this.field);\n    return use(field.widgetOptionsJson.prop(\"formOptionsSortOrder\"));\n  });\n\n  constructor(model: FieldModel) {\n    super(model);\n    this.choices = Computed.create(this, (use) => {\n      // Read choices from field.\n      const field = use(this.field);\n      const choices = use(field.widgetOptionsJson.prop(\"choices\"))?.slice() ?? [];\n\n      // Make sure it is an array of strings.\n      if (!Array.isArray(choices) || choices.some(choice => typeof choice !== \"string\")) {\n        return [];\n      } else {\n        sortChoicesInPlace(choices, item => String(item), use(this._sortOrder));\n        return choices;\n      }\n    });\n  }\n\n  public renderInput() {\n    return dom(\"div\",\n      dom.domComputed(this._format, (format) => {\n        if (format === \"select\") {\n          return this._renderSelectInput();\n        } else {\n          return this._renderRadioInput();\n        }\n      }),\n      dom.maybe(use => use(this.choices).length === 0, () => [\n        css.cssWarningMessage(css.cssWarningIcon(\"Warning\"), t(\"No choices configured\")),\n      ]),\n    );\n  }\n\n  private _renderSelectInput() {\n    return css.cssSelect(\n      { tabIndex: \"-1\" },\n      ignoreClick,\n      dom.prop(\"name\", use => use(use(this.field).colId)),\n      dom(\"option\",\n        selectPlaceholder(),\n        { value: \"\" },\n      ),\n      dom.forEach(this.choices, choice => dom(\"option\",\n        choice,\n        { value: choice },\n      )),\n    );\n  }\n\n  private _renderRadioInput() {\n    return css.cssRadioList(\n      css.cssRadioList.cls(\"-horizontal\", use => use(this.alignment) === \"horizontal\"),\n      dom.prop(\"name\", use => use(use(this.field).colId)),\n      dom.forEach(this.choices, choice => css.cssRadioLabel(\n        cssRadioInput({ type: \"radio\" }),\n        choice,\n      )),\n    );\n  }\n}\n\nclass ChoiceListModel extends ChoiceModel {\n  private _choices = Computed.create(this, (use) => {\n    return use(this.choices).slice(0, useFormOptionsLimit(use, this.field));\n  });\n\n  public renderInput() {\n    const field = this.field;\n    return css.cssCheckboxList(\n      css.cssCheckboxList.cls(\"-horizontal\", use => use(this.alignment) === \"horizontal\"),\n      dom.prop(\"name\", use => use(use(field).colId)),\n      dom.forEach(this._choices, choice => css.cssCheckboxLabel(\n        css.cssCheckboxLabel.cls(\"-horizontal\", use => use(this.alignment) === \"horizontal\"),\n        cssCheckboxSquare({ type: \"checkbox\" }),\n        choice,\n      )),\n      dom.maybe(use => use(this._choices).length === 0, () => [\n        css.cssWarningMessage(css.cssWarningIcon(\"Warning\"), t(\"No choices configured\")),\n      ]),\n    );\n  }\n}\n\nclass BoolModel extends Question {\n  private _format = Computed.create<FormToggleFormat>(this, (use) => {\n    const field = use(this.field);\n    return use(field.widgetOptionsJson.prop(\"formToggleFormat\")) ?? \"switch\";\n  });\n\n  public override buildDom(props: {\n    edit: Observable<boolean>,\n    overlay: Observable<boolean>,\n    question: Observable<string>,\n    onSave: () => void,\n  }) {\n    return css.cssQuestion(\n      testId(\"question\"),\n      testType(this.model.colType),\n      css.cssToggle(\n        this.renderInput(),\n        this.renderLabel(props, css.cssLabelInline.cls(\"\")),\n      ),\n    );\n  }\n\n  public override renderInput() {\n    return dom.domComputed(this._format, (format) => {\n      if (format === \"switch\") {\n        return this._renderSwitchInput();\n      } else {\n        return this._renderCheckboxInput();\n      }\n    });\n  }\n\n  private _renderSwitchInput() {\n    return toggleSwitch();\n  }\n\n  private _renderCheckboxInput() {\n    return cssLabel(\n      cssCheckboxSquare({ type: \"checkbox\" }),\n    );\n  }\n}\n\nclass DateModel extends Question {\n  public renderInput() {\n    return dom(\"div\",\n      css.cssInput(\n        dom.prop(\"name\", this.model.colId),\n        { type: \"date\", style: \"margin-right: 5px;\" },\n      ),\n    );\n  }\n}\n\nclass DateTimeModel extends Question {\n  public renderInput() {\n    return dom(\"div\",\n      css.cssInput(\n        dom.prop(\"name\", this.model.colId),\n        { type: \"datetime-local\", style: \"margin-right: 5px;\" },\n      ),\n      dom.style(\"width\", \"100%\"),\n    );\n  }\n}\n\nclass RefListModel extends Question {\n  protected options: Computed<{ label: string, value: string }[]>;\n\n  protected alignment = Computed.create<FormOptionsAlignment>(this, (use) => {\n    const field = use(this.field);\n    return use(field.widgetOptionsJson.prop(\"formOptionsAlignment\")) ?? \"vertical\";\n  });\n\n  private _sortOrder = Computed.create<FormOptionsSortOrder | undefined>(this, (use) => {\n    const field = use(this.field);\n    return use(field.widgetOptionsJson.prop(\"formOptionsSortOrder\"));\n  });\n\n  constructor(model: FieldModel) {\n    super(model);\n    this.options = this._getOptions();\n  }\n\n  public renderInput() {\n    return css.cssCheckboxList(\n      css.cssCheckboxList.cls(\"-horizontal\", use => use(this.alignment) === \"horizontal\"),\n      dom.prop(\"name\", this.model.colId),\n      dom.forEach(this.options, option => css.cssCheckboxLabel(\n        squareCheckbox(observable(false)),\n        option.label,\n      )),\n      dom.maybe(use => use(this.options).length === 0, () => [\n        css.cssWarningMessage(\n          css.cssWarningIcon(\"Warning\"),\n          t(\"No values in show column of referenced table\"),\n        ),\n      ]),\n    );\n  }\n\n  private _getOptions() {\n    const tableId = Computed.create(this, (use) => {\n      const refTable = use(use(this.model.column).refTable);\n      return refTable ? use(refTable.tableId) : \"\";\n    });\n\n    const colId = Computed.create(this, (use) => {\n      const dispColumnIdObs = use(use(this.model.column).visibleColModel);\n      return use(dispColumnIdObs.colId) || \"id\";\n    });\n\n    const observer = this._columnObserver(this, this.model.view.gristDoc.docModel, tableId, colId);\n\n    return Computed.create(this, (use) => {\n      const values = use(observer)\n        .filter(([_id, value]) => !isBlankValue(value))\n        .map(([id, value]) => ({ label: String(value), value: String(id) }));\n\n      sortChoicesInPlace(values, item => item.label, use(this._sortOrder));\n      return values.slice(0, useFormOptionsLimit(use, this.field));\n    });\n  }\n\n  /**\n   * Creates computed with all the data for the given column.\n   */\n  private _columnObserver(\n    owner: IDisposableOwner,\n    docModel: DocModel,\n    tableId: Observable<string>,\n    columnId: Observable<string>,\n  ) {\n    const tableModel = Computed.create(owner, use => docModel.dataTables[use(tableId)]);\n    const refreshed = Observable.create(owner, 0);\n    const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1);\n    const holder = Holder.create(owner);\n    const listener = (tab: TableModel) => {\n      if (tab.tableData.tableId === \"\") { return; }\n\n      // Now subscribe to any data change in that table.\n      const subs = MultiHolder.create(holder);\n      subs.autoDispose(tab.tableData.dataLoadedEmitter.addListener(toggle));\n      subs.autoDispose(tab.tableData.tableActionEmitter.addListener(toggle));\n      tab.fetch().catch(reportError);\n    };\n    owner.autoDispose(tableModel.addListener(listener));\n    listener(tableModel.get());\n    const values = Computed.create(owner, refreshed, (use) => {\n      const rows = use(tableModel).getAllRows();\n      const colValues = use(tableModel).tableData.getColValues(use(columnId));\n      if (!colValues) { return []; }\n      return rows.map((row, i) => [row, colValues[i]]);\n    });\n    return values;\n  }\n}\n\nclass RefModel extends RefListModel {\n  private _format = Computed.create<FormSelectFormat>(this, (use) => {\n    const field = use(this.field);\n    return use(field.widgetOptionsJson.prop(\"formSelectFormat\")) ?? \"select\";\n  });\n\n  public renderInput() {\n    return dom(\"div\",\n      dom.domComputed(this._format, (format) => {\n        if (format === \"select\") {\n          return this._renderSelectInput();\n        } else {\n          return this._renderRadioInput();\n        }\n      }),\n      dom.maybe(use => use(this.options).length === 0, () => [\n        css.cssWarningMessage(\n          css.cssWarningIcon(\"Warning\"),\n          t(\"No values in show column of referenced table\"),\n        ),\n      ]),\n    );\n  }\n\n  private _renderSelectInput() {\n    return css.cssSelect(\n      { tabIndex: \"-1\" },\n      ignoreClick,\n      dom.prop(\"name\", this.model.colId),\n      dom(\"option\",\n        selectPlaceholder(),\n        { value: \"\" },\n      ),\n      dom.forEach(this.options, ({ label, value }) => dom(\"option\",\n        label,\n        { value },\n      )),\n    );\n  }\n\n  private _renderRadioInput() {\n    return css.cssRadioList(\n      css.cssRadioList.cls(\"-horizontal\", use => use(this.alignment) === \"horizontal\"),\n      dom.prop(\"name\", use => use(use(this.field).colId)),\n      dom.forEach(this.options, ({ label, value }) => css.cssRadioLabel(\n        cssRadioInput({ type: \"radio\" }),\n        label,\n      )),\n    );\n  }\n}\n\nconst AnyModel = TextModel;\n\nclass AttachmentsModel extends Question {\n  public renderInput() {\n    return dom(\"div\",\n      css.cssAttachmentInput(\n        dom.prop(\"name\", use => use(use(this.field).colId)),\n        dom.prop(\"type\", \"file\"),\n        dom.prop(\"multiple\", true),\n      ),\n    );\n  }\n}\n\nfunction fieldConstructor(type: string): Constructor<Question> {\n  switch (type) {\n    case \"Any\": return AnyModel;\n    case \"Bool\": return BoolModel;\n    case \"Choice\": return ChoiceModel;\n    case \"ChoiceList\": return ChoiceListModel;\n    case \"Date\": return DateModel;\n    case \"DateTime\": return DateTimeModel;\n    case \"Int\": return NumericModel;\n    case \"Numeric\": return NumericModel;\n    case \"Ref\": return RefModel;\n    case \"RefList\": return RefListModel;\n    case \"Attachments\": return AttachmentsModel;\n    default: return TextModel;\n  }\n}\n\nfunction useFormOptionsLimit(use: UseCB, field: ko.Computed<ViewFieldRec>): number {\n  return getFormOptionsLimit(use(use(field).widgetOptionsJson) as FormFieldOptions);\n}\n\n/**\n * Creates a hidden input element with element type. Used in tests.\n */\nfunction testType(value: BindableValue<string>) {\n  return dom(\"input\", { type: \"hidden\" }, dom.prop(\"value\", value), testId(\"type\"));\n}\n"
  },
  {
    "path": "app/client/components/Forms/FormConfig.ts",
    "content": "import { fromKoSave } from \"app/client/lib/fromKoSave\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewFieldRec } from \"app/client/models/DocModel\";\nimport { fieldWithDefault, SaveableObjObservable } from \"app/client/models/modelUtil\";\nimport { FormFieldOptions, FormOptionsAlignment, FormOptionsSortOrder, FormSelectFormat } from \"app/client/ui/FormAPI\";\nimport { FORM_OPTIONS_DEFAULT_LIMIT } from \"app/client/ui/FormAPI\";\nimport {\n  cssLabel,\n  cssNumericSpinner,\n  cssRow,\n  cssSeparator,\n} from \"app/client/ui/RightPanelStyles\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { buttonSelect } from \"app/client/ui2018/buttonSelect\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { select } from \"app/client/ui2018/menus\";\n\nimport { Computed, Disposable, dom, fromKo, IDisposableOwner, makeTestId, styled } from \"grainjs\";\n\nconst t = makeT(\"FormConfig\");\n\nconst testId = makeTestId(\"test-form-\");\n\nexport class FormSelectConfig extends Disposable {\n  constructor(private _field: ViewFieldRec) {\n    super();\n  }\n\n  public buildDom() {\n    const format = fieldWithDefault<FormSelectFormat>(\n      this._field.widgetOptionsJson.prop(\"formSelectFormat\"),\n      \"select\",\n    );\n\n    return [\n      cssLabel(t(\"Field Format\")),\n      cssRow(\n        buttonSelect(\n          fromKoSave(format),\n          [\n            { value: \"select\", label: t(\"Select\") },\n            { value: \"radio\", label: t(\"Radio\") },\n          ],\n          testId(\"field-format\"),\n        ),\n      ),\n      dom.maybe(use => use(format) === \"radio\", () => dom.create(FormOptionsAlignmentConfig, this._field)),\n    ];\n  }\n}\n\nexport class FormOptionsAlignmentConfig extends Disposable {\n  constructor(private _field: ViewFieldRec) {\n    super();\n  }\n\n  public buildDom() {\n    const alignment = fieldWithDefault<FormOptionsAlignment>(\n      this._field.widgetOptionsJson.prop(\"formOptionsAlignment\"),\n      \"vertical\",\n    );\n\n    return [\n      cssLabel(t(\"Options Alignment\")),\n      cssRow(\n        select(\n          fromKoSave(alignment),\n          [\n            { value: \"vertical\", label: t(\"Vertical\") },\n            { value: \"horizontal\", label: t(\"Horizontal\") },\n          ],\n          { defaultLabel: t(\"Vertical\") },\n        ),\n      ),\n    ];\n  }\n}\n\nexport class FormOptionsSortConfig extends Disposable {\n  constructor(private _field: ViewFieldRec) {\n    super();\n  }\n\n  public buildDom() {\n    const optionsSortOrder = fieldWithDefault<FormOptionsSortOrder>(\n      this._field.widgetOptionsJson.prop(\"formOptionsSortOrder\"),\n      \"default\",\n    );\n\n    return [\n      cssLabel(t(\"Options Sort Order\")),\n      cssRow(\n        select(\n          fromKoSave(optionsSortOrder),\n          [\n            { value: \"default\", label: t(\"Default\") },\n            { value: \"ascending\", label: t(\"Ascending\") },\n            { value: \"descending\", label: t(\"Descending\") },\n          ],\n          { defaultLabel: t(\"Default\") },\n        ),\n      ),\n    ];\n  }\n}\n\nexport class FormOptionsLimitConfig extends Disposable {\n  constructor(private _field: ViewFieldRec) {\n    super();\n  }\n\n  public buildDom() {\n    const optionsLimitProp = this._field.widgetOptionsJson.prop(\"formOptionsLimit\");\n    const optionsLimit = fieldWithDefault<number | \"\">(\n      optionsLimitProp,\n      \"\",\n    );\n\n    return [\n      cssLabel(t(\"Options limit\")),\n      cssRow(\n        cssNumericSpinner(\n          fromKo(optionsLimit),\n          {\n            defaultValue: FORM_OPTIONS_DEFAULT_LIMIT,\n            minValue: 1,\n            maxValue: 1000,\n            save: async val => optionsLimitProp.setAndSave(val ? Math.floor(val) : undefined),\n            inputArgs: [testId(\"field-options-limit\")],\n          },\n        ),\n      ),\n    ];\n  }\n}\n\n/**\n * obsPropWithSaveOnWrite(owner, observable, prop, fallback) creates an observable for\n * observable()[prop], similar to fieldWithDefault(jsonObservable.prop(prop), fallback).\n *\n * It also sets and saves the observable on write, to satisfy the expectations of the checkbox\n * element. It uses `setAndSaveOrRevert` for saving.\n *\n * TODO Move this helper to a common place, e.g. modelUtil once it's converted to typescript.\n */\nfunction obsPropWithSaveOnWrite<Props extends object, Key extends keyof Props, Val extends Props[Key]>(\n  owner: IDisposableOwner,\n  obs: SaveableObjObservable<Props>,\n  prop: Key,\n  fallback: Val,\n): Computed<NonNullable<Props[Key]> | Val> {\n  return Computed.create(owner, use => use(obs)[prop] ?? fallback)\n    .onWrite((value) => {\n      obs.setAndSaveOrRevert({ ...obs.peek(), [prop]: value }).catch(reportError);\n    });\n}\n\nexport class FormFieldRulesConfig extends Disposable {\n  constructor(private _field: ViewFieldRec) {\n    super();\n  }\n\n  public buildDom() {\n    const widgetOptionsObs: SaveableObjObservable<FormFieldOptions> = this._field.widgetOptionsJson;\n    const isRequiredObs = obsPropWithSaveOnWrite(this, widgetOptionsObs, \"formRequired\", false);\n    const isHiddenObs = obsPropWithSaveOnWrite(this, widgetOptionsObs, \"formIsHidden\", false);\n    const acceptFromUrl = obsPropWithSaveOnWrite(this, widgetOptionsObs, \"formAcceptFromUrl\", false);\n\n    return [\n      cssSeparator(),\n      cssLabel(t(\"Field Rules\")),\n      cssRow(labeledSquareCheckbox(\n        isRequiredObs,\n        t(\"Required field\"),\n        testId(\"field-required\"),\n      )),\n      cssRow(labeledSquareCheckbox(\n        isHiddenObs,\n        t(\"Hidden field\"),\n        testId(\"field-hidden\"),\n      )),\n      cssRow(withInfoTooltip(\n        labeledSquareCheckbox(\n          acceptFromUrl,\n          t(\"Accept value from URL\"),\n          testId(\"field-accept-from-url\"),\n        ),\n        \"formUrlValues\",\n      )),\n      dom.maybe(acceptFromUrl, () => [\n        // We set tabIndex to let the user select the text to copy-paste the column ID (parameter name).\n        cssHintRow({ tabIndex: \"-1\" },\n          t(\"URL parameter:\\n{{colId}}=VALUE\", { colId: dom(\"b\", dom.text(this._field.colId)) }),\n          testId(\"field-url-hint\"),\n        ),\n      ]),\n    ];\n  }\n}\n\nconst cssHintRow = styled(\"div\", `\n  margin-left: 40px;\n  margin-right: 16px;\n  color: ${theme.lightText};\n`);\n"
  },
  {
    "path": "app/client/components/Forms/FormView.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport * as commands from \"app/client/components/commands\";\nimport { cleanFormLayoutSpec, FormLayoutNode, FormLayoutNodeType } from \"app/client/components/FormRenderer\";\nimport * as components from \"app/client/components/Forms/elements\";\nimport { NewBox } from \"app/client/components/Forms/Menu\";\nimport { BoxModel, LayoutModel, parseBox, Place } from \"app/client/components/Forms/Model\";\nimport * as style from \"app/client/components/Forms/styles\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { copyToClipboard } from \"app/client/lib/clipboardUtils\";\nimport { AsyncComputed, makeTestId, stopEvent } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { localStorageBoolObs } from \"app/client/lib/localStorageObs\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { ViewFieldRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { ShareRec } from \"app/client/models/entities/ShareRec\";\nimport { InsertColOptions } from \"app/client/models/entities/ViewSectionRec\";\nimport { reportError } from \"app/client/models/errors\";\nimport { docUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { jsonObservable, SaveableObjObservable } from \"app/client/models/modelUtil\";\nimport { hoverTooltip, showTransientTooltip } from \"app/client/ui/tooltips\";\nimport { cssButton } from \"app/client/ui2018/buttons\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\nimport { INITIAL_FIELDS_COUNT } from \"app/common/Forms\";\nimport { isOwner } from \"app/common/roles\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Computed, dom, Holder, IDomArgs, MultiHolder, Observable } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport isEqual from \"lodash/isEqual\";\nimport { defaultMenuOptions, IOpenController, setPopupToCreateDom } from \"popweasel\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nconst t = makeT(\"FormView\");\n\nconst testId = makeTestId(\"test-forms-\");\n\nexport class FormView extends BaseView {\n  public selectedBox: Computed<BoxModel | null>;\n  public selectedColumns: ko.Computed<ViewFieldRec[]> | null;\n  public disableDeleteSection: Computed<boolean>;\n\n  protected menuHolder: Holder<any>;\n  protected bundle: (clb: () => Promise<void>) => Promise<void>;\n\n  private _formFields: Computed<ViewFieldRec[]>;\n  private _layoutSpec: SaveableObjObservable<FormLayoutNode>;\n  private _layout: Computed<FormLayoutNode>;\n  private _root: BoxModel;\n  private _savedLayout: any;\n  private _saving: boolean = false;\n  private _previewUrl: Computed<string>;\n  private _pageShare: Computed<ShareRec | null>;\n  private _remoteShare: AsyncComputed<{ key: string } | null>;\n  private _isFork: Computed<boolean>;\n  private _published: Computed<boolean>;\n  private _showPublishedMessage: Observable<boolean>;\n  private _isOwner: boolean;\n  private _openingForm: Observable<boolean>;\n  private _formEditorBodyElement: HTMLElement;\n\n  constructor(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {\n    super(gristDoc, viewSectionModel, { addNewRow: false });\n    this.menuHolder = Holder.create(this);\n\n    // We will store selected box here.\n    const selectedBox = Observable.create(this, null as BoxModel | null);\n\n    // But we will guard it with a computed, so that if box is disposed we will clear it.\n    this.selectedBox = Computed.create(this, use => use(selectedBox));\n\n    // Prepare scope for the method calls.\n    const holder = Holder.create(this);\n\n    this.selectedBox.onWrite((box) => {\n      // Create new scope and dispose the previous one (using holder).\n      const scope = MultiHolder.create(holder);\n      if (!box) {\n        selectedBox.set(null);\n        return;\n      }\n      if (box.isDisposed()) {\n        throw new Error(\"Box is disposed\");\n      }\n      selectedBox.set(box);\n\n      // Now subscribe to the new box, if it is disposed, remove it from the selected box.\n      // Note that the dispose listener itself is disposed when the box is switched as we don't\n      // care anymore for this event if the box is switched.\n      scope.autoDispose(box.onDispose(() => {\n        if (selectedBox.get() === box) {\n          selectedBox.set(null);\n        }\n      }));\n    });\n\n    this.bundle = clb => this.gristDoc.docData.bundleActions(\"Saving form layout\", clb, { nestInActiveBundle: true });\n\n    this.selectedBox.addListener((v) => {\n      if (!v) { return; }\n      const colRef = Number(v.prop(\"leaf\").get());\n      if (!colRef || typeof colRef !== \"number\") { return; }\n      const fieldIndex = this.viewSection.viewFields().all().findIndex(f => f.getRowId() === colRef);\n      if (fieldIndex === -1) { return; }\n      this.cursor.setCursorPos({ fieldIndex });\n    });\n\n    this.selectedColumns = this.autoDispose(ko.pureComputed(() => {\n      const result = this.viewSection.viewFields().all().filter((field, index) => {\n        // During column removal or restoring (with undo), some columns fields\n        // might be disposed.\n        if (field.isDisposed() || field.column().isDisposed()) { return false; }\n        return this.cursor.currentPosition().fieldIndex === index;\n      });\n      return result;\n    }));\n\n    // Wire up selected fields to the cursor.\n    this.autoDispose(this.selectedColumns.subscribe((columns) => {\n      this.viewSection.selectedFields(columns);\n    }));\n    this.viewSection.selectedFields(this.selectedColumns.peek());\n\n    this._formFields = Computed.create(this, (use) => {\n      const fields = use(use(this.viewSection.viewFields).getObservable());\n      return fields.filter((f) => {\n        const column = use(f.column);\n        return (\n          !(use(column.isRealFormula) && !use(column.colId).startsWith(\"gristHelper_Transform\"))\n        );\n      });\n    });\n\n    this._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode | null) => {\n      // Sometimes the layout spec is not a form layout, but a layout from another type of widget\n      // This used to cause the document to crash (see: https://github.com/gristlabs/grist-core/issues/1677)\n      if (layoutSpec?.type === \"Layout\") {\n        // This is already a form layout. Let's keep it.\n        return layoutSpec;\n      } else {\n        // Overwrite old layout with a clean form layout\n        return buildDefaultFormLayout(this._formFields.get());\n      }\n    });\n\n    this._layout = Computed.create(this, (use) => {\n      const fields = use(this._formFields);\n      const layoutSpec = use(this._layoutSpec);\n      const patchedLayout = cleanFormLayoutSpec(layoutSpec, new Set(fields.map(f => f.id())));\n      if (!patchedLayout) { throw new Error(\"Invalid form layout spec\"); }\n\n      return patchedLayout;\n    });\n\n    this._root = this.autoDispose(new LayoutModel(this._layout.get(), null, async (clb?: () => Promise<void>) => {\n      await this.bundle(async () => {\n        if (clb) {\n          await clb();\n        }\n        await this.save();\n      });\n    }, this));\n\n    this._layout.addListener((v) => {\n      if (this._saving) {\n        console.warn(\"Layout changed while saving\");\n        return;\n      }\n      // When the layout has changed, we will update the root, but only when it is not the same\n      // as the one we just saved.\n      if (isEqual(v, this._savedLayout)) { return; }\n      if (this._savedLayout) {\n        this._savedLayout = v;\n      }\n      this._root.update(v);\n    });\n\n    const keyboardActions = {\n      copy: () => {\n        const selected = this.selectedBox.get();\n        if (!selected) { return; }\n        selected.copySelf().catch(reportError);\n      },\n      cut: () => {\n        const selected = this.selectedBox.get();\n        if (!selected) { return; }\n        selected.cutSelf().catch(reportError);\n      },\n      paste: () => {\n        const doPaste = async () => {\n          const boxInClipboard = parseBox(await navigator.clipboard.readText());\n          if (!boxInClipboard) { return; }\n          if (!this.selectedBox.get()) {\n            this.selectedBox.set(this._root.insert(boxInClipboard, 0));\n          } else {\n            this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));\n          }\n          const maybeCutBox = this._root.find(boxInClipboard.id);\n          if (maybeCutBox?.cut.get()) {\n            maybeCutBox.removeSelf();\n          }\n          await this._root.save();\n          await navigator.clipboard.writeText(\"\");\n        };\n        doPaste().catch(reportError);\n      },\n      nextField: () => {\n        const current = this.selectedBox.get();\n        const all = [...this._root.traverse()];\n        if (!all.length) { return; }\n        if (!current) {\n          this.selectedBox.set(all[0]);\n        } else {\n          const next = all[all.indexOf(current) + 1];\n          if (next) {\n            this.selectedBox.set(next);\n          } else {\n            this.selectedBox.set(all[0]);\n          }\n        }\n      },\n      prevField: () => {\n        const current = this.selectedBox.get();\n        const all = [...this._root.traverse()];\n        if (!all.length) { return; }\n        if (!current) {\n          this.selectedBox.set(all[all.length - 1]);\n        } else {\n          const next = all[all.indexOf(current) - 1];\n          if (next) {\n            this.selectedBox.set(next);\n          } else {\n            this.selectedBox.set(all[all.length - 1]);\n          }\n        }\n      },\n      lastField: () => {\n        const all = [...this._root.traverse()];\n        if (!all.length) { return; }\n        this.selectedBox.set(all[all.length - 1]);\n      },\n      firstField: () => {\n        const all = [...this._root.traverse()];\n        if (!all.length) { return; }\n        this.selectedBox.set(all[0]);\n      },\n      edit: () => {\n        const selected = this.selectedBox.get();\n        if (!selected) { return; }\n        (selected as any)?.edit?.set(true); // TODO: hacky way\n      },\n      clearValues: () => {\n        const selected = this.selectedBox.get();\n        if (!selected || selected.canRemove?.() === false) { return; }\n        keyboardActions.nextField();\n        this.bundle(async () => {\n          await selected.deleteSelf();\n        }).catch(reportError);\n      },\n      insertFieldBefore: (what: NewBox) => {\n        const selected = this.selectedBox.get();\n        if (!selected) { return; }\n        if (\"add\" in what || \"show\" in what) {\n          this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError);\n        } else {\n          selected.insertBefore(components.defaultElement(what.structure));\n          this.save().catch(reportError);\n        }\n      },\n      insertField: (what: NewBox) => {\n        const selected = this.selectedBox.get();\n        if (!selected) { return; }\n        const place = selected.placeAfterListChild();\n        if (\"add\" in what || \"show\" in what) {\n          this.addNewQuestion(place, what).catch(reportError);\n        } else {\n          place(components.defaultElement(what.structure));\n          this.save().catch(reportError);\n        }\n      },\n      insertFieldAfter: (what: NewBox) => {\n        const selected = this.selectedBox.get();\n        if (!selected) { return; }\n        if (\"add\" in what || \"show\" in what) {\n          this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError);\n        } else {\n          selected.insertAfter(components.defaultElement(what.structure));\n          this.save().catch(reportError);\n        }\n      },\n    };\n    this.autoDispose(commands.createGroup({\n      ...keyboardActions,\n      cursorDown: keyboardActions.nextField,\n      cursorUp: keyboardActions.prevField,\n      cursorLeft: keyboardActions.prevField,\n      cursorRight: keyboardActions.nextField,\n      shiftDown: keyboardActions.lastField,\n      shiftUp: keyboardActions.firstField,\n      editField: keyboardActions.edit,\n      deleteFields: keyboardActions.clearValues,\n    }, this, this.viewSection.hasRegionFocus));\n\n    const commandHandlers = {\n      hideFields: (colId: [string]) => {\n        // Get the ref from colId.\n        const existing: [number, string][] =\n          this.viewSection.viewFields().all().map(f => [f.id(), f.column().colId()]);\n        const ref = existing.filter(([_, c]) => colId.includes(c)).map(([r, _]) => r);\n        if (!ref.length) { return; }\n        const box = Array.from(this._root.filter(b => ref.includes(b.prop(\"leaf\")?.get())));\n        box.forEach(b => b.removeSelf());\n        this._root.save(async () => {\n          await this.viewSection.removeField(ref);\n        }).catch(reportError);\n      },\n      showColumns: (colIds: string[]) => {\n        // Sanity check that type is correct.\n        if (!colIds.every(c => typeof c === \"string\")) { throw new Error(\"Invalid column id\"); }\n        this._root.save(async () => {\n          const boxes: FormLayoutNode[] = [];\n          for (const colId of colIds) {\n            const fieldRef = await this.viewSection.showColumn(colId);\n            const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);\n            if (!field) { continue; }\n            const box = {\n              id: uuidv4(),\n              leaf: fieldRef,\n              type: \"Field\" as FormLayoutNodeType,\n            };\n            boxes.push(box);\n          }\n          // Add to selected or last section, or root.\n          const selected = this.selectedBox.get();\n          if (selected instanceof components.SectionModel) {\n            boxes.forEach(b => selected.append(b));\n          } else {\n            const topLevel = this._root.kids().reverse().find(b => b instanceof components.SectionModel);\n            if (topLevel) {\n              boxes.forEach(b => topLevel.append(b));\n            } else {\n              boxes.forEach(b => this._root.append(b));\n            }\n          }\n        }).catch(reportError);\n      },\n    };\n\n    this.autoDispose(commands.createGroup(commandHandlers, this, this.viewSection.hasFocus));\n\n    this._previewUrl = Computed.create(this, (use) => {\n      const doc = use(this.gristDoc.docPageModel.currentDoc);\n      if (!doc) { return \"\"; }\n      const url = urlState().makeUrl({\n        ...docUrl(doc),\n        form: {\n          vsId: use(this.viewSection.id),\n        },\n      });\n      return url;\n    });\n\n    this._pageShare = Computed.create(this, (use) => {\n      const page = use(use(this.viewSection.view).page);\n      if (!page) { return null; }\n      return use(page.share);\n    });\n\n    this._remoteShare = AsyncComputed.create(this, async (use) => {\n      const share = use(this._pageShare);\n      if (!share) { return null; }\n      try {\n        const remoteShare = await this.gristDoc.docComm.getShare(use(share.linkId));\n        return remoteShare ?? null;\n      } catch (ex) {\n        // TODO: for now ignore the error, but the UI should be updated to not show editor\n        // for non owners.\n        if (ex.code === \"AUTH_NO_OWNER\") { return null; }\n        throw ex;\n      }\n    });\n\n    this._isFork = Computed.create(this, (use) => {\n      const { docPageModel } = this.gristDoc;\n      return use(docPageModel.isFork) || use(docPageModel.isPrefork);\n    });\n\n    this._published = Computed.create(this, (use) => {\n      const isFork = use(this._isFork);\n      if (isFork) { return false; }\n\n      const pageShare = use(this._pageShare);\n      const remoteShare = use(this._remoteShare) || use(this._remoteShare.dirty);\n      const validShare = pageShare && remoteShare;\n      if (!validShare) { return false; }\n\n      return use(pageShare.optionsObj.prop(\"publish\")) &&\n        use(this.viewSection.shareOptionsObj.prop(\"publish\"));\n    });\n\n    const userId = this.gristDoc.app.topAppModel.appObs.get()?.currentUser?.id || 0;\n    this._showPublishedMessage = this.autoDispose(localStorageBoolObs(\n      `u:${userId};d:${this.gristDoc.docId()};vs:${this.viewSection.id()};formShowPublishedMessage`,\n      true,\n    ));\n\n    this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get());\n\n    this._openingForm = Observable.create(this, false);\n\n    // Last line, build the dom.\n    this.viewPane = this.buildDom();\n    this.onDispose(() => { dom.domDispose(this.viewPane); this.viewPane.remove(); });\n  }\n\n  public insertColumn(colId?: string | null, options?: InsertColOptions) {\n    return this.viewSection.insertColumn(colId, { ...options, nestInActiveBundle: true });\n  }\n\n  public showColumn(colRef: number | string, index?: number) {\n    return this.viewSection.showColumn(colRef, index);\n  }\n\n  public buildDom() {\n    return style.cssFormView(\n      testId(\"editor\"),\n      this._formEditorBodyElement = style.cssFormEditBody(\n        style.cssFormContainer(\n          style.cssFormContainer.cls(\"-border\", getGristConfig().formFraming === \"border\"),\n          dom(\"div\", testId(\"content\"), dom.forEach(this._root.children, (child) => {\n            if (!child) {\n              return dom(\"div\", \"Empty node\");\n            }\n            const element = child.render();\n            if (!(element instanceof Node)) {\n              throw new Error(\"Element is not an HTMLElement\");\n            }\n            return element;\n          })),\n        ),\n      ),\n      this._buildPublisher(),\n      dom.on(\"click\", () => this.selectedBox.set(null)),\n      dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()),\n    );\n  }\n\n  public buildOverlay(...args: IDomArgs) {\n    return style.cssSelectedOverlay(\n      ...args,\n    );\n  }\n\n  public async addNewQuestion(insert: Place, action: { add: string } | { show: string }) {\n    await this.gristDoc.docData.bundleActions(`Saving form layout`, async () => {\n      // First save the layout, so that we don't have autogenerated layout.\n      await this.save();\n      // Now that the layout is saved, we won't be bothered with autogenerated layout,\n      // and we can safely insert to column.\n      let fieldRef = 0;\n      if (\"show\" in action) {\n        fieldRef = await this.showColumn(action.show);\n      } else {\n        const result = await this.insertColumn(null, {\n          colInfo: {\n            type: action.add,\n          },\n        });\n        fieldRef = result.fieldRef;\n      }\n      // And add it into the layout.\n      this.selectedBox.set(insert({\n        id: uuidv4(),\n        leaf: fieldRef,\n        type: \"Field\",\n      }));\n      await this._root.save();\n    }, { nestInActiveBundle: true });\n  }\n\n  public async save() {\n    try {\n      this._saving = true;\n      const newVersion = { ...this._root.toJSON() };\n      // If nothing has changed, don't bother.\n      if (isEqual(newVersion, this._savedLayout)) { return; }\n      this._savedLayout = newVersion;\n      await this._layoutSpec.setAndSave(newVersion);\n    } finally {\n      this._saving = false;\n    }\n  }\n\n  private async _handleClickPublish() {\n    if (this.gristDoc.appModel.dismissedPopups.get().includes(\"publishForm\")) {\n      await this._publishForm();\n    } else {\n      confirmModal(t(\"Publish your form?\"),\n        t(\"Publish\"),\n        async (dontShowAgain) => {\n          await this._publishForm();\n          if (dontShowAgain) {\n            this.gristDoc.appModel.dismissPopup(\"publishForm\", true);\n          }\n        },\n        {\n          explanation: (\n            dom(\"div\",\n              style.cssParagraph(\n                t(\n                  \"Publishing your form will generate a share link. Anyone with the link can \\\nsee the empty form and submit a response.\",\n                ),\n              ),\n              style.cssParagraph(\n                t(\n                  \"Users are limited to submitting \\\nentries (records in your table) and reading pre-set values in designated \\\nfields, such as reference and choice columns.\",\n                ),\n              ),\n            )\n          ),\n          hideDontShowAgain: false,\n        },\n      );\n    }\n  }\n\n  private async _publishForm() {\n    const page = this.viewSection.view().page();\n    if (!page) {\n      throw new Error(\"Unable to publish form: undefined page\");\n    }\n    let validShare = page.shareRef() !== 0;\n    // If page is shared, make sure home server is aware of it.\n    if (validShare) {\n      try {\n        const pageShare = page.share();\n        const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId());\n        validShare = !!serverShare;\n      } catch (ex) {\n        // TODO: for now ignore the error, but the UI should be updated to not show editor\n        if (ex.code === \"AUTH_NO_OWNER\") {\n          return;\n        }\n        throw ex;\n      }\n    }\n\n    logTelemetryEvent(\"publishedForm\", {\n      full: {\n        docIdDigest: this.gristDoc.docId(),\n      },\n    });\n\n    await this.gristDoc.docModel.docData.bundleActions(\"Publish form\", async () => {\n      if (!validShare) {\n        const shareRef = await this.gristDoc.docModel.docData.sendAction([\n          \"AddRecord\",\n          \"_grist_Shares\",\n          null,\n          {\n            linkId: uuidv4(),\n            options: JSON.stringify({\n              publish: true,\n            }),\n          },\n        ]);\n        await this.gristDoc.docModel.docData.sendAction([\"UpdateRecord\", \"_grist_Pages\", page.id(), { shareRef }]);\n      } else {\n        const share = page.share();\n        share.optionsObj.update({ publish: true });\n        await share.optionsObj.save();\n      }\n\n      await this.save();\n      this.viewSection.shareOptionsObj.update({\n        form: true,\n        publish: true,\n      });\n      await this.viewSection.shareOptionsObj.save();\n    });\n  }\n\n  private async _handleClickUnpublish() {\n    if (this.gristDoc.appModel.dismissedPopups.get().includes(\"unpublishForm\")) {\n      await this._unpublishForm();\n    } else {\n      confirmModal(t(\"Unpublish your form?\"),\n        t(\"Unpublish\"),\n        async (dontShowAgain) => {\n          await this._unpublishForm();\n          if (dontShowAgain) {\n            this.gristDoc.appModel.dismissPopup(\"unpublishForm\", true);\n          }\n        },\n        {\n          explanation: (\n            dom(\"div\",\n              style.cssParagraph(\n                t(\n                  \"Unpublishing the form will disable the share link so that users accessing \\\nyour form via that link will see an error.\",\n                ),\n              ),\n            )\n          ),\n          hideDontShowAgain: false,\n        },\n      );\n    }\n  }\n\n  private async _unpublishForm() {\n    logTelemetryEvent(\"unpublishedForm\", {\n      full: {\n        docIdDigest: this.gristDoc.docId(),\n      },\n    });\n\n    await this.gristDoc.docModel.docData.bundleActions(\"Unpublish form\", async () => {\n      this.viewSection.shareOptionsObj.update({\n        publish: false,\n      });\n      await this.viewSection.shareOptionsObj.save();\n\n      const view = this.viewSection.view();\n      if (view.viewSections().peek().every(vs => !vs.shareOptionsObj.prop(\"publish\")())) {\n        const share = this._pageShare.get();\n        if (!share) { return; }\n\n        share.optionsObj.update({\n          publish: false,\n        });\n        await share.optionsObj.save();\n      }\n    });\n  }\n\n  private _buildPublisher() {\n    return style.cssSwitcher(\n      this._buildNotifications(),\n      style.cssButtonGroup(\n        style.cssSmallButton(\n          style.cssSmallButton.cls(\"-frameless\"),\n          icon(\"Revert\"),\n          testId(\"reset\"),\n          dom(\"div\", t(\"Reset form\")),\n          dom.style(\"visibility\", use => use(this._published) ? \"hidden\" : \"visible\"),\n          dom.style(\"margin-right\", \"auto\"), // move it to the left\n          dom.on(\"click\", () => {\n            return confirmModal(t(\"Are you sure you want to reset your form?\"),\n              t(\"Reset\"),\n              () => this._resetForm(),\n            );\n          }),\n        ),\n        dom.domComputed(this._published, (published) => {\n          if (published) {\n            return style.cssSmallButton(\n              testId(\"view\"),\n              icon(\"EyeShow\"),\n              t(\"View\"),\n              dom.boolAttr(\"disabled\", this._openingForm),\n              dom.on(\"click\", async (ev) => {\n                // If this form is not yet saved, we will save it first.\n                if (!this._savedLayout) {\n                  await this.save();\n                }\n\n                try {\n                  this._openingForm.set(true);\n                  window.open(await this._getFormUrl());\n                } finally {\n                  this._openingForm.set(false);\n                }\n              }),\n            );\n          } else {\n            return style.cssSmallLinkButton(\n              testId(\"preview\"),\n              icon(\"EyeShow\"),\n              t(\"Preview\"),\n              dom.attr(\"href\", this._previewUrl),\n              dom.prop(\"target\", \"_blank\"),\n              dom.on(\"click\", async (ev) => {\n                // If this form is not yet saved, we will save it first.\n                if (!this._savedLayout) {\n                  stopEvent(ev);\n                  await this.save();\n                  window.open(this._previewUrl.get());\n                }\n              }),\n            );\n          }\n        }),\n        style.cssSmallButton(\n          icon(\"Share\"),\n          testId(\"share\"),\n          dom(\"div\", t(\"Share\")),\n          dom.show(use => this._isOwner && use(this._published)),\n          (elem) => {\n            setPopupToCreateDom(elem, ctl => this._buildShareMenu(ctl), {\n              ...defaultMenuOptions,\n              placement: \"top-end\",\n            });\n          },\n        ),\n        dom.domComputed((use) => {\n          const isFork = use(this._isFork);\n          const published = use(this._published);\n          return published ?\n            style.cssSmallButton(\n              dom(\"div\", t(\"Unpublish\")),\n              dom.show(this._isOwner),\n              style.cssSmallButton.cls(\"-warning\"),\n              dom.on(\"click\", () => this._handleClickUnpublish()),\n              testId(\"unpublish\"),\n            ) :\n            style.cssSmallButton(\n              dom(\"div\", t(\"Publish\")),\n              dom.boolAttr(\"disabled\", isFork),\n              !isFork ? null : hoverTooltip(t(\"Save your document to publish this form.\"), {\n                placement: \"top\",\n              }),\n              dom.show(this._isOwner),\n              cssButton.cls(\"-primary\"),\n              dom.on(\"click\", () => this._handleClickPublish()),\n              testId(\"publish\"),\n            );\n        }),\n      ),\n    );\n  }\n\n  private async _getFormUrl() {\n    const share = this._pageShare.get();\n    if (!share) {\n      throw new Error(\"Unable to get form link: form is not published\");\n    }\n\n    const remoteShare = await this.gristDoc.docComm.getShare(share.linkId());\n    if (!remoteShare) {\n      throw new Error(\"Unable to get form link: form is not published\");\n    }\n\n    return urlState().makeUrl({\n      doc: undefined,\n      form: {\n        shareKey: remoteShare.key,\n        vsId: this.viewSection.id(),\n      },\n    });\n  }\n\n  private _buildShareMenu(ctl: IOpenController) {\n    const formUrl = Observable.create<string | null>(ctl, null);\n    const showEmbedCode = Observable.create(this, false);\n    const embedCode = Computed.create(ctl, formUrl, (_use, url) => {\n      if (!url) { return null; }\n\n      return '<iframe style=\"border: none; width: 640px; ' +\n        `height: ${this._getEstimatedFormHeightPx()}px\" src=\"${url}\"></iframe>`;\n    });\n\n    // Reposition the popup when its height changes.\n    ctl.autoDispose(formUrl.addListener(() => ctl.update()));\n    ctl.autoDispose(showEmbedCode.addListener(() => ctl.update()));\n\n    this._getFormUrl()\n      .then((url) => {\n        if (ctl.isDisposed()) { return; }\n\n        formUrl.set(url);\n      })\n      .catch((e) => {\n        ctl.close();\n        reportError(e);\n      });\n\n    return style.cssShareMenu(\n      dom.cls(menuCssClass),\n      style.cssShareMenuHeader(\n        style.cssShareMenuCloseButton(\n          icon(\"CrossBig\"),\n          dom.on(\"click\", () => ctl.close()),\n        ),\n      ),\n      style.cssShareMenuBody(\n        dom.domComputed((use) => {\n          const url = use(formUrl);\n          const code = use(embedCode);\n          if (!url || !code) {\n            return style.cssShareMenuSpinner(loadingSpinner());\n          }\n\n          return [\n            dom(\"div\",\n              style.cssShareMenuSectionHeading(\n                t(\"Share this form\"),\n              ),\n              dom(\"div\",\n                style.cssShareMenuHintText(\n                  t(\"Anyone with the link below can see the empty form and submit a response.\"),\n                ),\n                style.cssShareMenuUrlBlock(\n                  style.cssShareMenuUrl(\n                    testId(\"link\"),\n                    { readonly: true, value: url },\n                    dom.on(\"click\", (_ev, el) => { setTimeout(() => el.select(), 0); }),\n                  ),\n                  style.cssShareMenuCopyButton(\n                    testId(\"copy-link\"),\n                    t(\"Copy link\"),\n                    dom.on(\"click\", async (_ev, el) => {\n                      await copyToClipboard(url);\n                      showTransientTooltip(\n                        el,\n                        t(\"Link copied to clipboard\"),\n                        { key: \"share-form-menu\" },\n                      );\n                    }),\n                  ),\n                ),\n              ),\n            ),\n            dom.domComputed(showEmbedCode, (showCode) => {\n              if (!showCode) {\n                return dom(\"div\",\n                  style.cssShareMenuEmbedFormButton(\n                    t(\"Embed this form\"),\n                    dom.on(\"click\", () => showEmbedCode.set(true)),\n                  ),\n                );\n              } else {\n                return dom(\"div\",\n                  style.cssShareMenuSectionHeading(t(\"Embed this form\")),\n                  dom.maybe(showEmbedCode, () => style.cssShareMenuCodeBlock(\n                    style.cssShareMenuCode(\n                      code,\n                      { readonly: true, rows: \"3\" },\n                      dom.on(\"click\", (_ev, el) => { setTimeout(() => el.select(), 0); }),\n                    ),\n                    style.cssShareMenuCodeBlockButtons(\n                      style.cssShareMenuCopyButton(\n                        testId(\"code\"),\n                        t(\"Copy code\"),\n                        dom.on(\"click\", async (_ev, el) => {\n                          await copyToClipboard(code);\n                          showTransientTooltip(\n                            el,\n                            t(\"Code copied to clipboard\"),\n                            { key: \"share-form-menu\" },\n                          );\n                        }),\n                      ),\n                    ),\n                  )),\n                );\n              }\n            }),\n          ];\n        }),\n      ),\n    );\n  }\n\n  private _getSectionCount() {\n    return [...this._root.filter(box => box.type === \"Section\")].length;\n  }\n\n  private _getEstimatedFormHeightPx() {\n    return (\n      // Form height.\n      this._formEditorBodyElement.scrollHeight +\n      // Minus \"+\" button height in each section.\n      (-32 * this._getSectionCount()) +\n      // Plus form footer height (visible only in the preview and published form).\n      64\n    );\n  }\n\n  private _buildNotifications() {\n    return [\n      this._buildFormPublishedNotification(),\n    ];\n  }\n\n  private _buildFormPublishedNotification() {\n    return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => {\n      return style.cssSwitcherMessage(\n        style.cssSwitcherMessageBody(\n          t(\n            \"Your form is published. Every change is live and visible to users \\\nwith access to the form. If you want to make changes in draft, unpublish the form.\",\n          ),\n        ),\n        style.cssSwitcherMessageDismissButton(\n          icon(\"CrossSmall\"),\n          dom.on(\"click\", () => {\n            this._showPublishedMessage.set(false);\n          }),\n        ),\n        dom.show(this._isOwner),\n      );\n    });\n  }\n\n  private async _resetForm() {\n    this.selectedBox.set(null);\n    await this.gristDoc.docData.bundleActions(\"Reset form\", async () => {\n      // First we will remove all fields from this section, and add top 9 back.\n      const toDelete = this.viewSection.viewFields().all().map(f => f.getRowId());\n\n      const toAdd = this.viewSection.table().columns().peek()\n        .filter(c => c.isFormCol())\n        .sort((a, b) => a.parentPos() - b.parentPos());\n\n      const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id());\n      const parentId = colRef.map(() => this.viewSection.id());\n      const parentPos = colRef.map((_, i) => i + 1);\n      const ids = colRef.map(() => null);\n\n      await this.gristDoc.docData.sendActions([\n        [\"BulkRemoveRecord\", \"_grist_Views_section_field\", toDelete],\n        [\"BulkAddRecord\", \"_grist_Views_section_field\", ids, {\n          colRef,\n          parentId,\n          parentPos,\n        }],\n      ]);\n\n      const fields = this.viewSection.viewFields().all().slice(0, 9);\n      await this._layoutSpec.setAndSave(buildDefaultFormLayout(fields));\n    });\n  }\n}\n\n/**\n * Generates a default form layout based on the fields in the view section.\n */\nexport function buildDefaultFormLayout(fields: ViewFieldRec[]): FormLayoutNode {\n  const boxes: FormLayoutNode[] = fields.map((f) => {\n    return {\n      id: uuidv4(),\n      type: \"Field\",\n      leaf: f.id(),\n    };\n  });\n  const section = components.Section(...boxes);\n  return {\n    id: uuidv4(),\n    type: \"Layout\",\n    children: [\n      { id: uuidv4(), type: \"Paragraph\", text: FORM_TITLE, alignment: \"center\" },\n      { id: uuidv4(), type: \"Paragraph\", text: FORM_DESC, alignment: \"center\" },\n      section,\n      { id: uuidv4(), type: \"Submit\" },\n    ],\n  };\n}\n\n// Default values when form is reset.\nconst FORM_TITLE = t(\"# **Form Title**\");\nconst FORM_DESC = t(\"Your form description goes here.\");\n"
  },
  {
    "path": "app/client/components/Forms/MappedFieldsConfig.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ColumnRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { basicButton, cssButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { squareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssDragger } from \"app/client/ui2018/draggableList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { visuallyHiddenStyles } from \"app/client/ui2018/visuallyHidden\";\n\nimport { Computed, Disposable, dom, fromKo, makeTestId, Observable, styled } from \"grainjs\";\nimport * as ko from \"knockout\";\n\nconst testId = makeTestId(\"test-vfc-\");\nconst t = makeT(\"MappedFieldsConfig\");\n\n/**\n * This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds\n * the ability to drag and drop fields onto the form.\n */\nexport class MappedFieldsConfig extends Disposable {\n  constructor(private _section: ViewSectionRec) {\n    super();\n  }\n\n  public buildDom() {\n    const unmappedColumns = fromKo(this.autoDispose(ko.pureComputed(() => {\n      if (this._section.isDisposed()) {\n        return [];\n      }\n      const fields = new Set(this._section.viewFields().map(f => f.colId()).all());\n      const cols = this._section.table().visibleColumns()\n        .filter(c => c.isFormCol() && !fields.has(c.colId()));\n      return cols.map(col => ({\n        col,\n        selected: Observable.create(null, false),\n      }));\n    })));\n    const mappedColumns = fromKo(this.autoDispose(ko.pureComputed(() => {\n      if (this._section.isDisposed()) {\n        return [];\n      }\n      const cols = this._section.viewFields().map(f => f.column()).all()\n        .filter(c => c.isFormCol());\n      return cols.map(col => ({\n        col,\n        selected: Observable.create(null, false),\n      }));\n    })));\n\n    const anyUnmappedSelected = Computed.create(this, (use) => {\n      return use(unmappedColumns).some(c => use(c.selected));\n    });\n\n    const anyMappedSelected = Computed.create(this, (use) => {\n      return use(mappedColumns).some(c => use(c.selected));\n    });\n\n    const mapSelected = async () => {\n      await allCommands.showColumns.run(\n        unmappedColumns.get().filter(c => c.selected.get()).map(c => c.col.colId.peek()));\n    };\n\n    const unMapSelected = async () => {\n      await allCommands.hideFields.run(\n        mappedColumns.get().filter(c => c.selected.get()).map(c => c.col.colId.peek()));\n    };\n\n    return [\n      dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"mapped-fields-label\" },\n        cssHeader(\n          cssFieldListHeader(\n            dom.text(t(\"Mapped\")),\n            { id: \"mapped-fields-label\" },\n          ),\n          selectAllLabel(\n            dom.on(\"click\", () => {\n              mappedColumns.get().forEach(col => col.selected.set(true));\n            }),\n            dom.show(/* any mapped columns */ use => use(mappedColumns).length > 0),\n            { \"aria-describedby\": \"mapped-fields-label\" },\n          ),\n        ),\n        dom(\"div\",\n          testId(\"visible-fields\"),\n          dom.forEach(mappedColumns, (field) => {\n            return this._buildMappedField(field);\n          }),\n        ),\n        dom.maybe(anyMappedSelected, () =>\n          cssRow(\n            primaryButton(\n              dom.text(t(\"Unmap fields\")),\n              dom.on(\"click\", unMapSelected),\n              testId(\"visible-hide\"),\n            ),\n            basicButton(\n              t(\"Clear\"),\n              dom.on(\"click\", () => mappedColumns.get().forEach(col => col.selected.set(false))),\n              testId(\"visible-clear\"),\n            ),\n            testId(\"visible-batch-buttons\"),\n          ),\n        ),\n      ),\n      dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"unmapped-fields-label\" },\n        cssHeader(\n          cssFieldListHeader(\n            dom.text(t(\"Unmapped\")),\n            { id: \"unmapped-fields-label\" },\n          ),\n          selectAllLabel(\n            dom.on(\"click\", () => {\n              unmappedColumns.get().forEach(col => col.selected.set(true));\n            }),\n            dom.show(/* any unmapped columns */ use => use(unmappedColumns).length > 0),\n            { \"aria-describedby\": \"unmapped-fields-label\" },\n          ),\n        ),\n        dom(\"div\",\n          testId(\"hidden-fields\"),\n          dom.forEach(unmappedColumns, (field) => {\n            return this._buildUnmappedField(field);\n          }),\n        ),\n        dom.maybe(anyUnmappedSelected, () =>\n          cssRow(\n            primaryButton(\n              dom.text(t(\"Map fields\")),\n              dom.on(\"click\", mapSelected),\n              testId(\"hidden-show\"),\n            ),\n            basicButton(\n              t(\"Clear\"),\n              dom.on(\"click\", () => unmappedColumns.get().forEach(col => col.selected.set(false))),\n              testId(\"hidden-clear\"),\n            ),\n            testId(\"visible-batch-buttons\"),\n          ),\n        ),\n      ),\n    ];\n  }\n\n  private _buildUnmappedField(props: { col: ColumnRec, selected: Observable<boolean> }) {\n    const column = props.col;\n    return cssDragRow(\n      testId(\"hidden-field\"),\n      { draggable: \"true\" },\n      dom.on(\"dragstart\", (ev) => {\n        // Prevent propagation, as we might be in a nested editor.\n        ev.stopPropagation();\n        ev.dataTransfer?.setData(\"text/plain\", JSON.stringify({\n          type: \"Field\",\n          leaf: column.colId.peek(), // TODO: convert to Field\n        }));\n        ev.dataTransfer!.dropEffect = \"move\";\n      }),\n      cssSimpleDragger(),\n      cssFieldEntry(\n        cssFieldLabel(dom.text(column.label)),\n        cssHideIconButton(\n          icon(\"EyeShow\"),\n          testId(\"hide\"),\n          dom.on(\"click\", () => {\n            allCommands.showColumns.run([column.colId.peek()]);\n          }),\n          dom.attr(\"aria-label\", use => t(\"Unmap {{label}}\", { label: use(column.label) })),\n        ),\n        cssSquareCheckbox(\n          props.selected,\n          dom.attr(\"aria-label\", use => t(\"Unmap {{label}} (batch mode)\", { label: use(column.label) })),\n        ),\n      ),\n    );\n  }\n\n  private _buildMappedField(props: { col: ColumnRec, selected: Observable<boolean> }) {\n    const column = props.col;\n    return cssDragRow(\n      testId(\"visible-field\"),\n      cssSimpleDragger(\n        cssSimpleDragger.cls(\"-hidden\"),\n      ),\n      cssFieldEntry(\n        cssFieldLabel(dom.text(column.label)),\n        cssHideIconButton(\n          icon(\"EyeHide\"),\n          testId(\"hide\"),\n          dom.attr(\"aria-label\", use => t(\"Hide {{label}}\", { label: use(column.label) })),\n          dom.on(\"click\", () => {\n            allCommands.hideFields.run([column.colId.peek()]);\n          }),\n        ),\n        cssSquareCheckbox(\n          props.selected,\n          dom.attr(\"aria-label\", use => t(\"Hide {{label}} (batch mode)\", { label: use(column.label) })),\n        ),\n      ),\n    );\n  }\n}\n\nfunction selectAllLabel(...args: any[]) {\n  return cssControlLabel(\n    testId(\"select-all\"),\n    icon(\"Tick\"),\n    dom(\"span\", t(\"Select all\")),\n    ...args,\n  );\n}\n\nconst cssControlLabel = styled(unstyledButton, `\n  --icon-color: ${theme.controlFg};\n  color: ${theme.controlFg};\n  cursor: pointer;\n  line-height: 16px;\n`);\n\n// TODO: reuse them\nconst cssDragRow = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  margin: 0 16px 0px 0px;\n  margin-bottom: 2px;\n`);\n\nconst cssFieldEntry = styled(\"div\", `\n  display: flex;\n  background-color: ${theme.hover};\n  border-radius: 2px;\n  margin: 0 8px 0 0;\n  padding: 4px 8px;\n  cursor: default;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  flex: 1 1 auto;\n\n  --icon-color: ${theme.lightText};\n`);\n\nconst cssSimpleDragger = styled(cssDragger, `\n  cursor: grab;\n  .${cssDragRow.className}:hover & {\n    visibility: visible;\n  }\n  &-hidden {\n    visibility: hidden !important;\n  }\n`);\n\nconst cssHideIconButton = styled(unstyledButton, `\n  --icon-color: ${theme.lightText};\n  line-height: 1;\n  flex: none;\n  margin-right: 8px;\n  &:not(:focus, :focus-within, .${cssFieldEntry.className}:hover &) {\n    ${visuallyHiddenStyles}\n  }\n`);\n\nconst cssFieldLabel = styled(\"span\", `\n  color: ${theme.text};\n  flex: 1 1 auto;\n  text-overflow: ellipsis;\n  overflow: hidden;\n`);\n\nconst cssFieldListHeader = styled(\"span\", `\n  color: ${theme.text};\n  flex: 1 1 0px;\n  font-size: ${vars.xsmallFontSize};\n  text-transform: uppercase;\n`);\n\nconst cssRow = styled(\"div\", `\n  display: flex;\n  margin: 16px;\n  --icon-color: ${theme.lightText};\n  & > .${cssButton.className} {\n    margin-right: 8px;\n  }\n`);\n\nconst cssHeader = styled(cssRow, `\n  align-items: baseline;\n  justify-content: space-between;\n  margin-bottom: 12px;\n  line-height: 1em;\n  & * {\n    line-height: 1em;\n  }\n`);\n\nconst cssSquareCheckbox = styled(squareCheckbox, `\n  flex-shrink: 0;\n`);\n"
  },
  {
    "path": "app/client/components/Forms/Menu.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { FormLayoutNodeType } from \"app/client/components/FormRenderer\";\nimport * as components from \"app/client/components/Forms/elements\";\nimport { FormView } from \"app/client/components/Forms/FormView\";\nimport { BoxModel, Place } from \"app/client/components/Forms/Model\";\nimport { makeTestId, stopEvent } from \"app/client/lib/domUtils\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { getColumnTypes as getNewColumnTypes } from \"app/client/ui/GridViewMenus\";\nimport * as menus from \"app/client/ui2018/menus\";\n\nimport { Computed, dom, IDomArgs, MultiHolder } from \"grainjs\";\n\nconst t = makeT(\"Menu\");\nconst testId = makeTestId(\"test-forms-menu-\");\n\n// New box to add, either a new column of type, an existing column (by column id), or a structure.\nexport type NewBox = { add: string } | { show: string } | { structure: FormLayoutNodeType };\n\ninterface Props {\n  /**\n   * If this menu was shown as a result of clicking on a box. This box will be selected.\n   */\n  box?: BoxModel;\n  /**\n   * Parent view (to access GristDoc/selectedBox and others, TODO: this should be turned into events)\n   */\n  view?: FormView;\n  /**\n   * Whether this is context menu, so move `Copy` etc in front, and nest new items in its own menu.\n   */\n  context?: boolean;\n  /**\n   * Custom menu items to be added at the bottom (below additional separator).\n   */\n  customItems?: Element[],\n  /**\n   * Custom logic of finding right spot to insert the new box.\n   */\n  insertBox?: Place,\n}\n\nexport function buildMenu(props: Props, ...args: IDomArgs<HTMLElement>): IDomArgs<HTMLElement> {\n  const { box, context, customItems } = props;\n  const view = box?.view ?? props.view;\n  if (!view) { throw new Error(\"No view provided\"); }\n  const gristDoc = view.gristDoc;\n  const viewSection = view.viewSection;\n  const owner = new MultiHolder();\n\n  const unmapped = Computed.create(owner, (use) => {\n    const types = getNewColumnTypes(gristDoc, use(viewSection.tableId));\n    const normalCols = use(viewSection.hiddenColumns).filter(col => use(col.isFormCol));\n    const list = normalCols.map((col) => {\n      return {\n        label: use(col.label),\n        icon: types.find(type => type.colType === use(col.pureType))?.icon ?? \"TypeCell\",\n        colId: use(col.colId),\n      };\n    });\n    return list;\n  });\n\n  const oneTo5 = Computed.create(owner, use => use(unmapped).length > 0 && use(unmapped).length <= 5);\n  const moreThan5 = Computed.create(owner, use => use(unmapped).length > 5);\n\n  // If we are in a column, then we can't insert a new column.\n  const disableInsert = box?.parent?.type === \"Columns\" && box.type !== \"Placeholder\";\n\n  return [\n    dom.autoDispose(owner),\n    menus.menu((ctl) => {\n      box?.view.selectedBox.set(box);\n\n      // Same for structure.\n      const struct = (structure: FormLayoutNodeType) => ({ structure });\n\n      // Actions:\n\n      // Insert field before and after.\n      const above = (el: NewBox) => () => {\n        allCommands.insertFieldBefore.run(el);\n      };\n      const below = (el: NewBox) => () => {\n        allCommands.insertFieldAfter.run(el);\n      };\n      const atEnd = (el: NewBox) => () => {\n        allCommands.insertField.run(el);\n      };\n      const custom = props.insertBox ? (el: NewBox) => () => {\n        if (\"add\" in el || \"show\" in el) {\n          return view.addNewQuestion(props.insertBox!, el);\n        } else {\n          props.insertBox!(components.defaultElement(el.structure));\n          return view.save();\n        }\n      } : null;\n\n      // Field menus.\n      const quick = [\"Text\", \"Numeric\", \"Choice\", \"Date\"];\n      const disabled: string[] = [];\n      const commonTypes = () => getNewColumnTypes(gristDoc, viewSection.tableId());\n      const isQuick = ({ colType}: { colType: string }) => quick.includes(colType);\n      const notQuick = ({ colType}: { colType: string }) => !quick.includes(colType);\n      const isEnabled = ({ colType}: { colType: string }) => !disabled.includes(colType);\n\n      const insertMenu = (where: typeof above) => () => {\n        return [\n          menus.menuSubHeader(t(\"New question\")),\n          ...commonTypes()\n            .filter(isQuick)\n            .filter(isEnabled)\n            .map(ct => menus.menuItem(where({ add: ct.colType }), menus.menuIcon(ct.icon!), ct.displayName)),\n          menus.menuItemSubmenu(\n            () => commonTypes()\n              .filter(notQuick)\n              .filter(isEnabled)\n              .map(ct => menus.menuItem(\n                where({ add: ct.colType }),\n                menus.menuIcon(ct.icon!),\n                ct.displayName,\n              )),\n            {},\n            menus.menuIcon(\"Dots\"),\n            dom(\"span\", t(\"More\"), dom.style(\"margin-right\", \"8px\")),\n          ),\n          dom.maybe(oneTo5, () => [\n            menus.menuDivider(),\n            menus.menuSubHeader(t(\"Unmapped fields\")),\n            dom.domComputed(unmapped, uf =>\n              uf.map(({ label, icon, colId }) => menus.menuItem(\n                where({ show: colId }),\n                menus.menuIcon(icon),\n                label,\n                testId(\"unmapped\"),\n                testId(\"unmapped-\" + colId),\n              )),\n            ),\n          ]),\n          dom.maybe(moreThan5, () => [\n            menus.menuDivider(),\n            menus.menuSubHeaderMenu(\n              () => unmapped.get().map(\n                ({ label, icon, colId }) => menus.menuItem(\n                  where({ show: colId }),\n                  menus.menuIcon(icon),\n                  label,\n                  testId(\"unmapped\"),\n                  testId(\"unmapped-\" + colId),\n                )),\n              {},\n              dom(\"span\", \"Unmapped fields\", dom.style(\"margin-right\", \"8px\")),\n            ),\n          ]),\n          menus.menuDivider(),\n          menus.menuSubHeader(t(\"Building blocks\")),\n          menus.menuItem(where(struct(\"Header\")), menus.menuIcon(\"Headband\"), t(\"Header\")),\n          menus.menuItem(where(struct(\"Paragraph\")), menus.menuIcon(\"Paragraph\"), t(\"Paragraph\")),\n          menus.menuItem(where(struct(\"Columns\")), menus.menuIcon(\"Columns\"), t(\"Columns\")),\n          menus.menuItem(where(struct(\"Separator\")), menus.menuIcon(\"Separator\"), t(\"Separator\")),\n\n          props.customItems ? menus.menuDivider() : null,\n          ...(props.customItems ?? []),\n        ];\n      };\n\n      if (!props.context && !disableInsert) {\n        return insertMenu(custom ?? atEnd)();\n      }\n\n      return [\n        disableInsert ? null : [\n          menus.menuItemSubmenu(insertMenu(above), { action: above({ add: \"Text\" }) }, t(\"Insert question above\")),\n          menus.menuItemSubmenu(insertMenu(below), { action: below({ add: \"Text\" }) }, t(\"Insert question below\")),\n          menus.menuDivider(),\n        ],\n        menus.menuItemCmd(allCommands.contextMenuCopy, t(\"Copy\")),\n        menus.menuItemCmd(allCommands.contextMenuCut, t(\"Cut\")),\n        menus.menuItemCmd(allCommands.contextMenuPaste, t(\"Paste\")),\n        menus.menuDivider(),\n        menus.menuItemCmd(allCommands.deleteFields, \"Hide\"),\n        elem => void FocusLayer.create(ctl, { defaultFocusElem: elem, pauseMousetrap: true }),\n        customItems?.length ? menus.menuDivider(dom.style(\"min-width\", \"200px\")) : null,\n        ...(customItems ?? []),\n        ...args,\n      ];\n    }, { trigger: [context ? \"contextmenu\" : \"click\"] }),\n    context ? dom.on(\"contextmenu\", stopEvent) : null,\n  ];\n}\n"
  },
  {
    "path": "app/client/components/Forms/Model.ts",
    "content": "import { FormLayoutNode, FormLayoutNodeType } from \"app/client/components/FormRenderer\";\nimport * as elements from \"app/client/components/Forms/elements\";\nimport { FormView } from \"app/client/components/Forms/FormView\";\nimport { MaybePromise } from \"app/plugin/gutil\";\n\nimport {\n  bundleChanges,\n  Computed,\n  Disposable,\n  dom,\n  IDomArgs,\n  MutableObsArray,\n  obsArray,\n  Observable,\n} from \"grainjs\";\n\ntype Callback = () => Promise<void>;\n\n/**\n * A place where to insert a box.\n */\nexport type Place = (box: FormLayoutNode) => BoxModel;\n\n/**\n * View model constructed from a box JSON structure.\n */\nexport abstract class BoxModel extends Disposable {\n  /**\n   * A factory method that creates a new BoxModel from a Box JSON by picking the right class based on the type.\n   */\n  public static new(box: FormLayoutNode, parent: BoxModel | null, view: FormView | null = null): BoxModel {\n    const subClassName = `${box.type.split(\":\")[0]}Model`;\n    const factories = elements as any;\n    const factory = factories[subClassName];\n    if (!parent && !view) { throw new Error(\"Cannot create detached box\"); }\n    // If we have a factory, use it.\n    if (factory) {\n      return new factory(box, parent, view || parent!.view);\n    }\n    // Otherwise, use the default.\n    return new DefaultBoxModel(box, parent, view || parent!.view);\n  }\n\n  /**\n   * The unique id of the box.\n   */\n  public id: string;\n  /**\n   * Type of the box. As the type is bounded to the class that is used to render the box, it is possible\n   * to change the type of the box just by changing this value. The box is then replaced in the parent.\n   */\n  public type: FormLayoutNodeType;\n  /**\n   * List of children boxes.\n   */\n  public children: MutableObsArray<BoxModel>;\n  /**\n   * Publicly exposed state if the element was just cut.\n   * TODO: this should be moved to FormView, as this model doesn't care about that.\n   */\n  public cut = Observable.create(this, false);\n\n  /**\n   * Computed if this box is selected or not.\n   */\n  public selected: Computed<boolean>;\n\n  /**\n   * Any other dynamically added properties (that are not concrete fields in the derived classes)\n   */\n  private _props: Record<string, Observable<any>> = {};\n  /**\n   * Don't use it directly, use the BoxModel.new factory method instead.\n   */\n  constructor(box: FormLayoutNode, public parent: BoxModel | null, public view: FormView) {\n    super();\n\n    this.selected = Computed.create(this, use => use(view.selectedBox) === this && use(view.viewSection.hasFocus));\n\n    this.children = this.autoDispose(obsArray([]));\n\n    // We are owned by the parent children list.\n    if (parent) {\n      parent.children.autoDispose(this);\n    }\n\n    this.id = box.id;\n\n    // Create observables for all properties.\n    this.type = box.type;\n\n    // And now update this and all children based on the box JSON.\n    bundleChanges(() => {\n      this.update(box);\n    });\n\n    // Some boxes need to do some work after initialization, so we call this method.\n    // Of course, they also can override the constructor, but this is a bit easier.\n    this.onCreate();\n  }\n\n  /**\n   * The only method that derived classes need to implement. It should return a DOM element that\n   * represents this box.\n   */\n  public abstract render(...args: IDomArgs<HTMLElement>): HTMLElement;\n\n  public removeChild(box: BoxModel) {\n    const myIndex = this.children.get().indexOf(box);\n    if (myIndex < 0) { throw new Error(\"Cannot remove box that is not in parent\"); }\n    this.children.splice(myIndex, 1);\n  }\n\n  /**\n   * Remove self from the parent without saving.\n   */\n  public removeSelf() {\n    this.parent?.removeChild(this);\n  }\n\n  /**\n   * Remove self from the parent and save. Use to bundle layout save with any other changes.\n   * See Fields for the implementation.\n   * TODO: this is needed as action bundling is very limited.\n   */\n  public async deleteSelf() {\n    const parent = this.parent;\n    this.removeSelf();\n    await parent!.save();\n  }\n\n  /**\n   * Copies self and puts it into clipboard.\n   */\n  public async copySelf() {\n    [...this.root().traverse()].forEach(box => box?.cut.set(false));\n    // Add this box as a json to clipboard.\n    await navigator.clipboard.writeText(JSON.stringify(this.toJSON()));\n  }\n\n  /**\n   * Cuts self and puts it into clipboard.\n   */\n  public async cutSelf() {\n    await this.copySelf();\n    this.cut.set(true);\n  }\n\n  /**\n   * The way this box will accept dropped content.\n   * - sibling: it will add it as a sibling\n   * - child: it will add it as a child.\n   * - swap: swaps with the box\n   */\n  public willAccept(box?: FormLayoutNode | BoxModel | null): \"sibling\" | \"child\" | \"swap\" | null {\n    // If myself and the dropped element share the same parent, and the parent is a column\n    // element, just swap us.\n    if (this.parent && box instanceof BoxModel && this.parent === box?.parent && box.parent?.type === \"Columns\") {\n      return \"swap\";\n    }\n\n    // If we are in column, we won't accept anything.\n    if (this.parent?.type === \"Columns\") { return null; }\n\n    return \"sibling\";\n  }\n\n  /**\n   * Accepts box from clipboard and inserts it before this box or if this is a container box, then\n   * as a first child. Default implementation is to insert before self.\n   */\n  public accept(dropped: FormLayoutNode, hint: \"above\" | \"below\" = \"above\") {\n    // Get the box that was dropped.\n    if (!dropped) { return null; }\n    if (dropped.id === this.id) {\n      return null;\n    }\n    // We need to remove it from the parent, so find it first.\n    const droppedId = dropped.id;\n    const droppedRef = droppedId ? this.root().find(droppedId) : null;\n    if (droppedRef) {\n      droppedRef.removeSelf();\n    }\n    return hint === \"above\" ? this.placeBeforeMe()(dropped) : this.placeAfterMe()(dropped);\n  }\n\n  public prop(name: string, defaultValue?: any) {\n    if (!this._props[name]) {\n      this._props[name] = Observable.create(this, defaultValue ?? null);\n    }\n    return this._props[name];\n  }\n\n  public hasProp(name: string) {\n    return this._props.hasOwnProperty(name);\n  }\n\n  public async save(before?: () => MaybePromise<void>): Promise<void> {\n    if (!this.parent) { throw new Error(\"Cannot save detached box\"); }\n    return this.parent.save(before);\n  }\n\n  /**\n   * Replaces children at index.\n   */\n  public replaceAtIndex(box: FormLayoutNode, index: number) {\n    const newOne = BoxModel.new(box, this);\n    this.children.splice(index, 1, newOne);\n    return newOne;\n  }\n\n  public swap(box1: BoxModel, box2: BoxModel) {\n    const index1 = this.children.get().indexOf(box1);\n    const index2 = this.children.get().indexOf(box2);\n    if (index1 < 0 || index2 < 0) { throw new Error(\"Cannot swap boxes that are not in parent\"); }\n    const box1JSON = box1.toJSON();\n    const box2JSON = box2.toJSON();\n    this.replace(box1, box2JSON);\n    this.replace(box2, box1JSON);\n  }\n\n  public append(box: FormLayoutNode) {\n    const newOne = BoxModel.new(box, this);\n    this.children.push(newOne);\n    return newOne;\n  }\n\n  public insert(box: FormLayoutNode, index: number) {\n    const newOne = BoxModel.new(box, this);\n    this.children.splice(index, 0, newOne);\n    return newOne;\n  }\n\n  /**\n   * Replaces existing box with a new one, whenever it is found.\n   */\n  public replace(existing: BoxModel, newOne: FormLayoutNode | BoxModel) {\n    const index = this.children.get().indexOf(existing);\n    if (index < 0) { throw new Error(\"Cannot replace box that is not in parent\"); }\n    const model = newOne instanceof BoxModel ? newOne : BoxModel.new(newOne, this);\n    model.parent = this;\n    model.view = this.view;\n    this.children.splice(index, 1, model);\n    return model;\n  }\n\n  /**\n   * Creates a place to insert a box before this box.\n   */\n  public placeBeforeFirstChild() {\n    return (box: FormLayoutNode) => this.insert(box, 0);\n  }\n\n  // Some other places.\n  public placeAfterListChild() {\n    return (box: FormLayoutNode) => this.insert(box, this.children.get().length);\n  }\n\n  public placeAt(index: number) {\n    return (box: FormLayoutNode) => this.insert(box, index);\n  }\n\n  public placeAfterChild(child: BoxModel) {\n    return (box: FormLayoutNode) => this.insert(box, this.children.get().indexOf(child) + 1);\n  }\n\n  public placeAfterMe() {\n    return this.parent!.placeAfterChild(this);\n  }\n\n  public placeBeforeMe() {\n    return this.parent!.placeAt(this.parent!.children.get().indexOf(this));\n  }\n\n  public insertAfter(json: any) {\n    return this.parent!.insert(json, this.parent!.children.get().indexOf(this) + 1);\n  }\n\n  public insertBefore(json: any) {\n    return this.parent!.insert(json, this.parent!.children.get().indexOf(this));\n  }\n\n  public root() {\n    let root: BoxModel = this;\n    while (root.parent) { root = root.parent; }\n    return root;\n  }\n\n  /**\n   * Finds a box with a given id in the tree.\n   */\n  public find(droppedId: string | undefined | null): BoxModel | null {\n    if (!droppedId) { return null; }\n    for (const child of this.kids()) {\n      if (child.id === droppedId) { return child; }\n      const found = child.find(droppedId);\n      if (found) { return found; }\n    }\n    return null;\n  }\n\n  public* filter(filter: (box: BoxModel) => boolean): Iterable<BoxModel> {\n    for (const child of this.kids()) {\n      if (filter(child)) { yield child; }\n      yield* child.filter(filter);\n    }\n  }\n\n  public includes(box: BoxModel) {\n    for (const child of this.kids()) {\n      if (child === box) { return true; }\n      if (child.includes(box)) { return true; }\n    }\n  }\n\n  public kids() {\n    return this.children.get().filter(Boolean);\n  }\n\n  /**\n   * The core responsibility of this method is to update this box and all children based on the box JSON.\n   * This is counterpart of the FloatingRowModel, that enables this instance to point to a different box.\n   */\n  public update(boxDef: FormLayoutNode) {\n    // If we have a type and the type is changed, then we need to replace the box.\n    if (this.type && boxDef.type !== this.type) {\n      if (!this.parent) { throw new Error(\"Cannot replace detached box\"); }\n      this.parent.replace(this, BoxModel.new(boxDef, this.parent));\n      return;\n    }\n\n    // Update all properties of self.\n    for (const someKey in boxDef) {\n      const key = someKey as keyof FormLayoutNode;\n      // Skip some keys.\n      if (key === \"id\" || key === \"type\" || key === \"children\") { continue; }\n      // Skip any inherited properties.\n      if (!boxDef.hasOwnProperty(key)) { continue; }\n      // Skip if the value is the same.\n      if (this.prop(key).get() === boxDef[key]) { continue; }\n      this.prop(key).set(boxDef[key]);\n    }\n\n    // First remove any children from the model that aren't in `boxDef`.\n    const boxDefChildren = boxDef.children ?? [];\n    const boxDefChildrenIds = new Set(boxDefChildren.map(c => c.id));\n    for (const child of this.children.get()) {\n      if (!boxDefChildrenIds.has(child.id)) {\n        child.removeSelf();\n      }\n    }\n\n    // Then add or update the children from `boxDef` to the model.\n    const newChildren: BoxModel[] = [];\n    const modelChildrenById = new Map(this.children.get().map(c => [c.id, c]));\n    for (const boxDefChild of boxDefChildren) {\n      if (!boxDefChild.id || !modelChildrenById.has(boxDefChild.id)) {\n        newChildren.push(BoxModel.new(boxDefChild, this));\n      } else {\n        const existingChild = modelChildrenById.get(boxDefChild.id)!;\n        existingChild.update(boxDefChild);\n        newChildren.push(existingChild);\n      }\n    }\n    this.children.set(newChildren);\n  }\n\n  /**\n   * Serialize this box to JSON.\n   */\n  public toJSON(): FormLayoutNode {\n    return {\n      id: this.id,\n      type: this.type,\n      children: this.children.get().map(child => child?.toJSON() || null),\n      ...(Object.fromEntries(Object.entries(this._props).map(([key, val]) => [key, val.get()]))),\n    };\n  }\n\n  public* traverse(): IterableIterator<BoxModel> {\n    for (const child of this.kids()) {\n      yield child;\n      yield* child.traverse();\n    }\n  }\n\n  public canRemove() {\n    return true;\n  }\n\n  protected onCreate() {\n\n  }\n}\n\nexport class LayoutModel extends BoxModel {\n  public disableDeleteSection: Computed<boolean>;\n\n  constructor(\n    box: FormLayoutNode,\n    public parent: BoxModel | null,\n    public _save: (clb?: Callback) => Promise<void>,\n    public view: FormView,\n  ) {\n    super(box, parent, view);\n    this.disableDeleteSection = Computed.create(this, (use) => {\n      return use(this.children).filter(c => c.type === \"Section\").length === 1;\n    });\n  }\n\n  public async save(clb?: Callback) {\n    return await this._save(clb);\n  }\n\n  public override render(): HTMLElement {\n    throw new Error(\"Method not implemented.\");\n  }\n}\n\nclass DefaultBoxModel extends BoxModel {\n  public render(): HTMLElement {\n    return dom(\"div\", `Unknown box type ${this.type}`);\n  }\n}\n\nexport const ignoreClick = dom.on(\"click\", (ev) => {\n  ev.stopPropagation();\n  ev.preventDefault();\n});\n\nexport function unwrap<T>(val: T | Computed<T>): T {\n  return val instanceof Computed ? val.get() : val;\n}\n\nexport function parseBox(text: string): FormLayoutNode | null {\n  try {\n    const json = JSON.parse(text);\n    return json && typeof json === \"object\" && json.type ? json : null;\n  } catch (e) {\n    return null;\n  }\n}\n"
  },
  {
    "path": "app/client/components/Forms/Paragraph.ts",
    "content": "import { FormLayoutNode } from \"app/client/components/FormRenderer\";\nimport { buildEditor } from \"app/client/components/Forms/Editor\";\nimport { BoxModel } from \"app/client/components/Forms/Model\";\nimport * as css from \"app/client/components/Forms/styles\";\nimport { textarea } from \"app/client/ui/inputs\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { not } from \"app/common/gutil\";\n\nimport { Computed, dom, Observable, styled } from \"grainjs\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nexport class ParagraphModel extends BoxModel {\n  public edit = Observable.create(this, false);\n\n  protected defaultValue = \"**Lorem** _ipsum_ dolor\";\n  protected cssClass = \"\";\n\n  private _overlay = Computed.create(this, not(this.selected));\n\n  public override render(): HTMLElement {\n    const box = this;\n    const editMode = box.edit;\n    const text = this.prop(\"text\", this.defaultValue) as Observable<string | undefined>;\n\n    // There is a spacial hack here. We might be created as a separator component, but the rendering\n    // for separator looks bad when it is the only content, so add a special case for that.\n    const isSeparator = Computed.create(this, use => use(text) === \"---\");\n\n    return buildEditor({\n      box: this,\n      overlay: this._overlay,\n      editMode,\n      content: css.cssMarkdownRendered(\n        css.buildMarkdown(use => use(text) || \"\", dom.hide(editMode)),\n        dom.maybe(use => !use(text) && !use(editMode), () => cssEmpty(\"(empty)\")),\n        css.cssMarkdownRendered.cls(\"-separator\", isSeparator),\n        dom.on(\"click\", () => {\n          if (!editMode.get() && this.selected.get()) {\n            editMode.set(true);\n          }\n        }),\n        css.cssMarkdownRendered.cls(\"-edit\", editMode),\n        css.cssMarkdownRendered.cls(u => `-alignment-${u(box.prop(\"alignment\", \"left\"))}`),\n        this.cssClass ? dom.cls(this.cssClass, not(editMode)) : null,\n        dom.maybe(editMode, () => {\n          const draft = Observable.create(null, text.get() || \"\");\n          return cssTextArea(draft, { autoGrow: true, onInput: true },\n            cssTextArea.cls(\"-edit\", editMode),\n            (elem) => {\n              setTimeout(() => {\n                elem.focus();\n                elem.setSelectionRange(elem.value.length, elem.value.length);\n              }, 10);\n            },\n            css.saveControls(editMode, (ok) => {\n              if (ok && editMode.get()) {\n                text.set(draft.get());\n                this.save().catch(reportError);\n              }\n            }),\n          );\n        }),\n      ),\n    });\n  }\n}\n\nexport function Paragraph(text: string, alignment?: \"left\" | \"right\" | \"center\"): FormLayoutNode {\n  return { id: uuidv4(), type: \"Paragraph\", text, alignment };\n}\n\nconst cssTextArea = styled(textarea, `\n  color: ${theme.inputFg};\n  background-color: ${theme.mainPanelBg};\n  border: 0px;\n  width: 100%;\n  padding: 3px 6px;\n  outline: none;\n  max-height: 300px;\n  min-height: calc(3em * 1.5);\n  resize: none;\n  border-radius: 3px;\n  &-edit {\n    cursor: auto;\n    background: ${theme.inputBg};\n    outline: 2px solid black;\n    outline-offset: 1px;\n    border-radius: 2px;\n  }\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n  &[readonly] {\n    background-color: ${theme.inputDisabledBg};\n    color: ${theme.inputDisabledFg};\n  }\n`);\n\nconst cssEmpty = styled(\"div\", `\n  color: ${theme.inputPlaceholderFg};\n  font-style: italic;\n`);\n"
  },
  {
    "path": "app/client/components/Forms/Section.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { FormLayoutNode } from \"app/client/components/FormRenderer\";\nimport { buildEditor } from \"app/client/components/Forms/Editor\";\nimport { FieldModel } from \"app/client/components/Forms/Field\";\nimport { FormView } from \"app/client/components/Forms/FormView\";\nimport { buildMenu } from \"app/client/components/Forms/Menu\";\nimport { BoxModel, LayoutModel } from \"app/client/components/Forms/Model\";\nimport { Paragraph } from \"app/client/components/Forms/Paragraph\";\nimport * as style from \"app/client/components/Forms/styles\";\nimport { makeTestId } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport * as menus from \"app/client/ui2018/menus\";\n\nimport { dom, styled } from \"grainjs\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nconst t = makeT(\"Section\");\n\nconst testId = makeTestId(\"test-forms-\");\n\n/**\n * Component that renders a section of the form.\n */\nexport class SectionModel extends BoxModel {\n  constructor(box: FormLayoutNode, parent: BoxModel | null, view: FormView) {\n    super(box, parent, view);\n  }\n\n  public override render(): HTMLElement {\n    const children = this.children;\n    return buildEditor({\n      box: this,\n      // Custom drag element that is little bigger and at the top of the section.\n      drag: style.cssDragWrapper(style.cssDrag(\"DragDrop\", style.cssDrag.cls(\"-top\"))),\n      showRemoveButton: use => !use((this.root() as LayoutModel).disableDeleteSection),\n      // Content is just a list of children.\n      content: style.cssSection(\n        // Wrap them in a div that mutes hover events.\n        cssSectionItems(\n          testId(\"content\"),\n          dom.forEach(children, child => child.render()),\n        ),\n        // Plus icon\n        style.cssPlusButton(\n          testId(\"plus\"),\n          style.cssDrop(),\n          style.cssCircle(\n            style.cssPlusIcon(\"Plus\"),\n            buildMenu({\n              box: this,\n              customItems: [\n                menus.menuItem(\n                  () => allCommands.insertFieldBefore.run({ structure: \"Section\" }),\n                  menus.menuIcon(\"Section\"),\n                  t(\"Insert section above\"),\n                ),\n                menus.menuItem(\n                  () => allCommands.insertFieldAfter.run({ structure: \"Section\" }),\n                  menus.menuIcon(\"Section\"),\n                  t(\"Insert section below\"),\n                ),\n              ],\n            }),\n          ),\n        ),\n      ) },\n    );\n  }\n\n  public override willAccept(): \"sibling\" | \"child\" | null {\n    return \"child\";\n  }\n\n  /**\n   * Accepts box from clipboard and inserts it before this box or if this is a container box, then\n   * as a first child. Default implementation is to insert before self.\n   */\n  public override accept(dropped: FormLayoutNode) {\n    // Get the box that was dropped.\n    if (!dropped) { return null; }\n    if (dropped.id === this.id) {\n      return null;\n    }\n    // We need to remove it from the parent, so find it first.\n    const droppedRef = dropped.id ? this.root().find(dropped.id) : null;\n    if (droppedRef) {\n      droppedRef.removeSelf();\n    }\n\n    // Depending of the type of dropped box we need to insert it in different places.\n    // By default we insert it before this box.\n    let place = this.placeBeforeMe();\n    if (dropped.type === \"Field\") {\n      // Fields are inserted after last child.\n      place = this.placeAfterListChild();\n    }\n\n    return place(dropped);\n  }\n\n  public async deleteSelf(): Promise<void> {\n    // Prepare all the fields that are children of this section for removal.\n    const fieldsToRemove = Array.from(this.filter(b => b instanceof FieldModel)) as FieldModel[];\n    const fieldIdsToRemove = fieldsToRemove.map(f => f.leaf.get());\n\n    await this.parent?.save(async () => {\n      // Remove the fields.\n      if (fieldIdsToRemove.length > 0) {\n        await this.view.viewSection.removeField(fieldIdsToRemove);\n      }\n\n      // Remove each child of this section from the layout.\n      this.children.get().forEach((child) => { child.removeSelf(); });\n\n      // Remove this section from the layout.\n      this.removeSelf();\n    });\n  }\n\n  public canRemove() {\n    return !((this.parent as LayoutModel).disableDeleteSection.get());\n  }\n}\n\nexport function Section(...children: FormLayoutNode[]): FormLayoutNode {\n  return {\n    id: uuidv4(),\n    type: \"Section\",\n    children: [\n      Paragraph(t(\"## **Header**\")),\n      Paragraph(t(\"Description\")),\n      ...children,\n    ],\n  };\n}\n\nconst cssSectionItems = styled(\"div.hover_border\", `\n`);\n"
  },
  {
    "path": "app/client/components/Forms/Submit.ts",
    "content": "import * as css from \"app/client/components/FormRendererCss\";\nimport { BoxModel } from \"app/client/components/Forms/Model\";\nimport { makeTestId } from \"app/client/lib/domUtils\";\nimport { bigPrimaryButton } from \"app/client/ui2018/buttons\";\n\nimport { dom } from \"grainjs\";\nconst testId = makeTestId(\"test-forms-\");\n\nexport class SubmitModel extends BoxModel {\n  public canRemove() {\n    return false;\n  }\n\n  public override render() {\n    const text = this.view.viewSection.layoutSpecObj.prop(\"submitText\");\n    return dom(\n      \"div\",\n      css.error(testId(\"error\")),\n      css.submitButtons(\n        bigPrimaryButton(\n          dom.text(use => use(text) || \"Submit\"),\n          { disabled: true },\n          testId(\"submit\"),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "app/client/components/Forms/elements.ts",
    "content": "import { FormLayoutNode, FormLayoutNodeType } from \"app/client/components/FormRenderer\";\nimport { Columns, Placeholder } from \"app/client/components/Forms/Columns\";\nimport { Paragraph } from \"app/client/components/Forms/Paragraph\";\nimport { Section } from \"app/client/components/Forms/Section\";\n\nimport { v4 as uuidv4 } from \"uuid\";\n/**\n * Add any other element you whish to use in the form here.\n * FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It\n * to render and manage the element.\n */\nexport * from \"app/client/components/Forms/Paragraph\";\nexport * from \"app/client/components/Forms/Section\";\nexport * from \"app/client/components/Forms/Field\";\nexport * from \"app/client/components/Forms/Columns\";\nexport * from \"app/client/components/Forms/Submit\";\n\nexport function defaultElement(type: FormLayoutNodeType): FormLayoutNode {\n  switch (type) {\n    case \"Columns\": return Columns();\n    case \"Placeholder\": return Placeholder();\n    case \"Separator\": return Paragraph(\"---\");\n    case \"Header\": return Paragraph(\"# **Header**\", \"center\");\n    case \"Section\": return Section();\n    default: return { id: uuidv4(), type };\n  }\n}\n"
  },
  {
    "path": "app/client/components/Forms/styles.ts",
    "content": "import { textarea } from \"app/client/ui/inputs\";\nimport { sanitizeHTMLIntoDOM } from \"app/client/ui/sanitizeHTML\";\nimport { basicButton, basicButtonLink, primaryButtonLink, textButton } from \"app/client/ui2018/buttons\";\nimport { cssLabel } from \"app/client/ui2018/checkbox\";\nimport { colors, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { numericSpinner } from \"app/client/widgets/NumericSpinner\";\n\nimport { BindableValue, dom, DomElementArg, IDomArgs, Observable, styled } from \"grainjs\";\nimport { marked } from \"marked\";\n\nimport type { App } from \"app/client/ui/App\";\n\nexport const cssFormView = styled(\"div.flexauto.flexvbox\", `\n  color: ${theme.text};\n  display: flex;\n  flex-direction: column;\n  flex-basis: 0px;\n  align-items: center;\n  justify-content: space-between;\n  position: relative;\n  overflow: auto;\n  min-height: 100%;\n  width: 100%;\n`);\n\nexport const cssFormContainer = styled(\"div\", `\n  background-color: ${theme.mainPanelBg};\n  color: ${theme.text};\n  width: 600px;\n  align-self: center;\n  margin: 0px auto;\n  border-radius: 3px;\n  display: flex;\n  flex-direction: column;\n  max-width: calc(100% - 32px);\n  gap: 8px;\n  line-height: 1.42857143;\n  &-border {\n    border: 2px solid ${colors.lightGreen};\n    border-radius: 12px;\n    padding: 18px;\n    width: calc(600px + 32px);\n  }\n`);\n\nexport const cssFieldEditor = styled(\"div.hover_border.field_editor\", `\n  position: relative;\n  cursor: pointer;\n  user-select: none;\n  outline: none;\n  padding: 8px;\n  border-radius: 3px;\n  margin-bottom: 4px;\n  --hover-visible: hidden;\n  transition: transform 0.2s ease-in-out;\n  &-Section {\n    outline: 1px solid ${theme.modalBorderDark};\n    margin-bottom: 24px;\n    padding: 16px;\n  }\n  &:hover:not(:has(.hover_border:hover),&-cut) {\n    --hover-visible: visible;\n    outline: 1px solid ${theme.controlPrimaryBg};\n  }\n  &-selected:not(&-cut) {\n    background: ${theme.lightHover};\n    outline: 1px solid ${theme.controlPrimaryBg};\n    --selected-block: block;\n  }\n  &:active:not(:has(&:active)) {\n    outline: 1px solid ${theme.controlPrimaryHoverBg};\n  }\n  &-drag-hover {\n    outline: 2px dashed ${theme.controlPrimaryBg};\n    outline-offset: 2px;\n  }\n  &-cut {\n    outline: 2px dashed ${colors.orange};\n    outline-offset: 2px;\n  }\n  &-FormDescription {\n    margin-bottom: 10px;\n  }\n  &-drag-above {\n    transform: translateY(2px);\n  }\n  &-drag-below {\n    transform: translateY(-2px);\n  }\n`);\n\nexport const cssSection = styled(\"div\", `\n  position: relative;\n  color: ${theme.text};\n  margin: 0px auto;\n  min-height: 50px;\n`);\n\nexport const cssCheckboxList = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n\n  &-horizontal {\n    flex-direction: row;\n    flex-wrap: wrap;\n    column-gap: 16px;\n  }\n`);\n\nexport const cssCheckboxLabel = styled(cssLabel, `\n  font-size: 13px;\n  line-height: 16px;\n  font-weight: normal;\n  user-select: none;\n  display: flex;\n  gap: 8px;\n  margin: 0px;\n  overflow-wrap: anywhere;\n`);\n\nexport const cssRadioList = cssCheckboxList;\n\nexport const cssRadioLabel = cssCheckboxLabel;\n\nexport function textbox(obs: Observable<string | undefined>, ...args: DomElementArg[]): HTMLInputElement {\n  return dom(\"input\",\n    dom.prop(\"value\", u => u(obs) || \"\"),\n    dom.on(\"input\", (_e, elem) => obs.set(elem.value)),\n    ...args,\n  );\n}\n\nexport const cssQuestion = styled(\"div\", `\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n`);\n\nexport const cssRequiredWrapper = styled(\"div\", `\n  margin: 8px 0px;\n  min-height: 16px;\n  overflow-wrap: break-word;\n\n  &-required {\n    display: grid;\n    grid-template-columns: auto 1fr;\n    gap: 4px;\n  }\n  &-required:after {\n    content: \"*\";\n    color: ${colors.lightGreen};\n    font-size: 11px;\n    font-weight: 700;\n  }\n`);\n\nexport const cssRenderedLabel = styled(\"div\", `\n  font-weight: normal;\n  padding: 0px;\n  border: 0px;\n  width: 100%;\n  margin: 0px;\n  background: transparent;\n  cursor: pointer;\n  min-height: 16px;\n\n  color: ${theme.mediumText};\n  font-size: 13px;\n  line-height: 16px;\n  font-weight: 700;\n  white-space: pre-wrap;\n  &-placeholder {\n    font-style: italic\n  }\n`);\n\nexport const cssEditableLabel = styled(textarea, `\n  font-weight: normal;\n  outline: none;\n  display: block;\n  padding: 0px;\n  border: 0px;\n  width: 100%;\n  margin: 0px;\n  background: transparent;\n  cursor: pointer;\n  min-height: 1.5rem;\n\n  color: ${theme.mediumText};\n  font-size: 12px;\n  font-weight: 700;\n\n  &::placeholder {\n    font-style: italic\n  }\n  &-edit {\n    cursor: auto;\n    background: ${theme.inputBg};\n    outline: 2px solid ${theme.accessRulesFormulaEditorFocus};\n    outline-offset: 1px;\n    border-radius: 2px;\n  }\n`);\n\nexport const cssLabelInline = styled(\"div\", `\n  line-height: 16px;\n  margin: 0px;\n  overflow-wrap: anywhere;\n`);\n\nexport const cssDesc = styled(\"div\", `\n  font-size: 12px;\n  font-weight: 400;\n  margin-top: 4px;\n  color: ${theme.darkText};\n  white-space: pre-wrap;\n  font-style: italic;\n  font-weight: 400;\n  line-height: 1.6;\n`);\n\nexport const cssInput = styled(\"input\", `\n  background-color: ${theme.inputBg};\n  font-size: inherit;\n  height: 29px;\n  padding: 4px 8px;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  outline: none;\n  pointer-events: none;\n\n  &:disabled {\n    color: ${theme.inputDisabledFg};\n    background-color: ${theme.inputDisabledBg};\n  }\n  &-invalid {\n    color: ${theme.inputInvalid};\n  }\n  &[type=\"number\"], &[type=\"date\"], &[type=\"datetime-local\"], &[type=\"text\"] {\n    width: 100%;\n  }\n`);\n\nexport const cssTextArea = styled(\"textarea\", `\n  background-color: ${theme.inputBg};\n  font-size: inherit;\n  min-height: 29px;\n  padding: 4px 8px;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  outline: none;\n  pointer-events: none;\n  resize: none;\n  width: 100%;\n\n  &:disabled {\n    color: ${theme.inputDisabledFg};\n    background-color: ${theme.inputDisabledBg};\n  }\n`);\n\nexport const cssSpinner = styled(numericSpinner, `\n  height: 29px;\n`);\n\nexport const cssSelect = styled(\"select\", `\n  flex: auto;\n  width: 100%;\n  background-color: ${theme.inputBg};\n  font-size: inherit;\n  height: 27px;\n  padding: 4px 8px;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  outline: none;\n  pointer-events: none;\n`);\n\nexport const cssToggle = styled(\"div\", `\n  display: grid;\n  grid-template-columns: auto 1fr;\n  margin-top: 12px;\n  gap: 8px;\n  --grist-actual-cell-color: ${colors.lightGreen};\n`);\n\nexport const cssWarningMessage = styled(\"div\", `\n  margin-top: 8px;\n  display: flex;\n  align-items: center;\n  column-gap: 8px;\n`);\n\nexport const cssWarningIcon = styled(icon, `\n  --icon-color: ${colors.warning};\n  flex-shrink: 0;\n`);\n\nexport const cssFieldEditorContent = styled(\"div\", `\n  height: 100%;\n`);\n\nexport const cssSelectedOverlay = styled(\"div._cssSelectedOverlay\", `\n  inset: 0;\n  position: absolute;\n  opacity: 0;\n  outline: none;\n  .${cssFieldEditor.className}-selected > & {\n    opacity: 1;\n  }\n`);\n\nexport const cssPlusButton = styled(\"div\", `\n  position: relative;\n  min-height: 32px;\n  cursor: pointer;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n`);\n\nexport const cssCircle = styled(\"div\", `\n  border-radius: 50%;\n  width: 24px;\n  height: 24px;\n  background-color: ${theme.addNewCircleSmallBg};\n  color: ${theme.addNewCircleSmallFg};\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  .${cssPlusButton.className}:hover & {\n    background: ${theme.addNewCircleSmallHoverBg};\n  }\n`);\n\nexport const cssPlusIcon = styled(icon, `\n --icon-color: ${theme.controlPrimaryFg};\n`);\n\nexport const cssColumns = styled(\"div\", `\n  display: grid;\n  grid-template-columns: repeat(var(--css-columns-count), 1fr) 32px;\n  gap: 8px;\n  padding: 8px 4px;\n`);\n\nexport const cssColumn = styled(\"div\", `\n  position: relative;\n  &-empty, &-add-button {\n    position: relative;\n    min-height: 32px;\n    cursor: pointer;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    padding-right: 8px;\n    --icon-color: ${theme.lightText};\n    align-self: stretch;\n    transition: height 0.2s ease-in-out;\n    border: 2px dashed ${theme.inputBorder};\n    background: ${theme.lightHover};\n    color: ${theme.lightText};\n    border-radius: 4px;\n    padding: 2px 4px;\n    font-size: 12px;\n  }\n\n  &-selected {\n    border: 2px dashed ${theme.lightText};\n  }\n\n  &-empty:hover, &-add-button:hover {\n    border: 2px dashed ${theme.lightText};\n  }\n\n  &-drag-over {\n    outline: 2px dashed ${theme.controlPrimaryBg};\n  }\n`);\n\nexport const cssButtonGroup = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  flex-wrap: wrap;\n  padding: 0px 24px 0px 24px;\n  gap: 8px;\n  /* So that the height is 40px in normal state */\n  padding-top: calc((40px - 24px) / 2);\n  padding-bottom: calc((40px - 24px) / 2);\n`);\n\nexport const cssSmallLinkButton = styled(basicButtonLink, `\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  min-height: 26px;\n`);\n\nconst textSmallButton = `\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  min-height: 26px;\n\n  &-frameless {\n    background-color: transparent;\n    border: none;\n  }\n  &-warning {\n    color: ${theme.controlPrimaryFg};\n    background-color: ${theme.toastWarningBg};\n    border: none;\n  }\n  &-warning:hover {\n    color: ${theme.controlPrimaryFg};\n    background-color: #B8791B;\n    border: none;\n  }\n`;\n\nexport const cssSmallButton = styled(basicButton, textSmallButton);\nexport const cssPrimarySmallLink = styled(primaryButtonLink, textSmallButton);\n\nexport const cssMarkdownRendered = styled(\"div\", `\n  min-height: 1.5rem;\n  font-size: 15px;\n  overflow-wrap: break-word;\n\n  & textarea {\n    font-size: 15px;\n  }\n  &-edit textarea {\n    outline: 2px solid ${theme.accessRulesFormulaEditorFocus};\n  }\n  & strong {\n    font-weight: 600;\n  }\n  &-alignment-left {\n    text-align: left;\n  }\n  &-alignment-center {\n    text-align: center;\n  }\n  &-alignment-right {\n    text-align: right;\n  }\n  & hr {\n    border-color: ${theme.inputBorder};\n    margin: 8px 0px;\n  }\n  &-separator {\n    display: flex;\n    flex-direction: column;\n    justify-content: center;\n  }\n  &-separator hr {\n    margin: 0px;\n  }\n`);\n\nconst cssMarkdownContainer = styled(\"div\", `\n  clip-path: inset(0px);\n`);\n\nconst SHADOW_STYLE = `\n  :host > p:last-child {\n    margin-bottom: 0px;\n  }\n  strong {\n    font-weight: 600;\n  }\n  h1 {\n    font-size: 24px;\n    margin: 4px 0px;\n    font-weight: normal;\n  }\n  h2 {\n    font-size: 22px;\n    margin: 4px 0px;\n    font-weight: normal;\n  }\n  h3 {\n    font-size: 16px;\n    margin: 4px 0px;\n    font-weight: normal;\n  }\n  h4 {\n    font-size: 13px;\n    margin: 4px 0px;\n    font-weight: normal;\n  }\n  h5 {\n    font-size: 11px;\n    margin: 4px 0px;\n    font-weight: normal;\n  }\n  h6 {\n    font-size: 10px;\n    margin: 4px 0px;\n    font-weight: normal;\n  }\n`;\n\nlet shadowStyle: CSSStyleSheet | null = null;\nexport function bindMarkdown(textObs: BindableValue<string>) {\n  if (!shadowStyle) {\n    shadowStyle = new CSSStyleSheet();\n    // TODO: remove casting once Typescript supports new API (from 4.8.2).\n    (shadowStyle as any).replaceSync(SHADOW_STYLE);\n  }\n  return function(container: HTMLElement) {\n    const shadow = container.attachShadow({ mode: \"open\" });\n    (shadow as any).adoptedStyleSheets = [shadowStyle!];\n    dom.update(shadow,\n      dom.domComputed(textObs, text => sanitizeHTMLIntoDOM(marked(text || \"\", {\n        async: false,\n      })),\n      ));\n  };\n}\n\nexport function buildMarkdown(obs: BindableValue<string>, ...args: IDomArgs<HTMLDivElement>) {\n  return cssMarkdownContainer(\n    bindMarkdown(obs),\n    ...args,\n  );\n}\n\nexport const cssDrop = styled(\"div.test-forms-drag\", `\n  position: absolute;\n  pointer-events: none;\n  top: 2px;\n  left: 2px;\n  width: 1px;\n  height: 1px;\n`);\n\nexport const cssDragWrapper = styled(\"div\", `\n  position: absolute;\n  inset: 0px;\n  left: -16px;\n  top: 0px;\n  height: 100%;\n  width: 16px;\n`);\n\nexport const cssDrag = styled(icon, `\n  position: absolute;\n  visibility: var(--hover-visible, hidden);\n  top: calc(50% - 16px / 2);\n  width: 16px;\n  height: 16px;\n  --icon-color: ${theme.controlPrimaryBg};\n  &-top {\n    top: 16px;\n  }\n`);\n\nexport const cssPreview = styled(\"iframe\", `\n  height: 100%;\n  width: 100%;\n  border: 0px;\n`);\n\nexport const cssSwitcher = styled(\"div\", `\n  border-top: 1px solid ${theme.menuBorder};\n  width: 100%;\n`);\n\nexport const cssSwitcherMessage = styled(\"div\", `\n  display: flex;\n  padding: 8px 16px;\n`);\n\nexport const cssSwitcherMessageBody = styled(\"div\", `\n  flex-grow: 1;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  padding: 8px 16px;\n`);\n\nexport const cssSwitcherMessageDismissButton = styled(\"div\", `\n  align-self: flex-start;\n  flex-shrink: 0;\n  padding: 0px;\n  border-radius: 4px;\n  cursor: pointer;\n  --icon-color: ${theme.controlSecondaryFg};\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n\nexport const cssParagraph = styled(\"div\", `\n  margin-bottom: 16px;\n`);\n\nexport const cssFormEditBody = styled(\"div\", `\n  width: 100%;\n  overflow: auto;\n  padding: 20px;\n`);\n\nexport const cssRemoveButton = styled(\"div\", `\n  position: absolute;\n  right: 11px;\n  top: 11px;\n  border-radius: 3px;\n  background: ${theme.attachmentsEditorButtonHoverBg};\n  display: none;\n  height: 16px;\n  width: 16px;\n  align-items: center;\n  justify-content: center;\n  line-height: 0px;\n  z-index: 3;\n  & > div {\n    height: 13px;\n    width: 13px;\n  }\n  &:hover {\n    background: ${theme.controlSecondaryHoverBg};\n    cursor: pointer;\n  }\n  .${cssFieldEditor.className}-selected > &,\n  .${cssFieldEditor.className}:hover:not(:has(.hover_border:hover)) > & {\n    display: flex;\n  }\n  &-right {\n    right: -20px;\n  }\n`);\n\nexport const cssShareMenu = styled(\"div\", `\n  color: ${theme.text};\n  background-color: ${theme.popupBg};\n  width: min(calc(100% - 16px), 400px);\n  border-radius: 3px;\n  padding: 8px;\n`);\n\nexport const cssShareMenuHeader = styled(\"div\", `\n  display: flex;\n  justify-content: flex-end;\n`);\n\nexport const cssShareMenuBody = styled(\"div\", `\n  box-sizing: content-box;\n  display: flex;\n  flex-direction: column;\n  row-gap: 32px;\n  padding: 0px 16px 24px 16px;\n  min-height: 160px;\n`);\n\nexport const cssShareMenuCloseButton = styled(\"div\", `\n  flex-shrink: 0;\n  border-radius: 4px;\n  cursor: pointer;\n  padding: 4px;\n  --icon-color: ${theme.popupCloseButtonFg};\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n\nexport const cssShareMenuSectionHeading = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  font-weight: 600;\n  margin-bottom: 16px;\n`);\n\nexport const cssShareMenuHintText = styled(\"div\", `\n  color: ${theme.lightText};\n`);\n\nexport const cssShareMenuSpinner = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  min-height: inherit;\n`);\n\nexport const cssShareMenuSectionButtons = styled(\"div\", `\n  display: flex;\n  justify-content: flex-end;\n  margin-top: 16px;\n`);\n\nexport const cssShareMenuUrlBlock = styled(\"div\", `\n  display: flex;\n  background-color: ${theme.inputReadonlyBg};\n  padding: 8px;\n  border-radius: 3px;\n  width: 100%;\n  margin-top: 16px;\n`);\n\nexport const cssShareMenuUrl = styled(\"input\", `\n  background: transparent;\n  flex-grow: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  border: none;\n  outline: none;\n`);\n\nexport const cssShareMenuCopyButton = styled(textButton, `\n  margin-left: 4px;\n  font-weight: 500;\n`);\n\nexport const cssShareMenuEmbedFormButton = styled(textButton, `\n  font-weight: 500;\n`);\n\nexport const cssShareMenuCodeBlock = styled(\"div\", `\n  border-radius: 3px;\n  background-color: ${theme.inputReadonlyBg};\n  padding: 8px;\n`);\n\nexport const cssShareMenuCodeBlockButtons = styled(\"div\", `\n  display: flex;\n  justify-content: flex-end;\n`);\n\nexport const cssShareMenuCode = styled(\"textarea\", `\n  background-color: transparent;\n  border: none;\n  border-radius: 3px;\n  word-break: break-all;\n  width: 100%;\n  outline: none;\n  resize: none;\n`);\n\nexport const cssFormDisabledOverlay = styled(\"div\", `\n  background-color: ${theme.widgetBg};\n  opacity: 0.8;\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  z-index: 100;\n`);\n\nexport const cssAttachmentInput = styled(\"input\", `\n  display: flex;\n  flex-wrap: wrap;\n  white-space: pre-wrap;\n  position: relative;\n  width: 100%;\n\n  &::file-selector-button, &::-webkit-file-upload-button {\n    background-color: ${theme.controlPrimaryBg};\n    border: 1px solid ${theme.controlPrimaryBg};\n    color: white;\n    padding: 4px 8px;\n    border-radius: 4px;\n    font-size: 13px;\n    cursor: pointer;\n    line-height: inherit;\n    outline-color: ${theme.controlPrimaryBg};\n  }\n\n  &::file-selector-button:hover, &::-webkit-file-upload-button:hover {\n    border-color: ${theme.controlPrimaryBg};\n    background-color: ${theme.controlPrimaryBg};\n  }\n\n  &::file-selector-button:disabled, &::-webkit-file-upload-button:disabled {\n    cursor: not-allowed;\n    color: ${colors.light};\n    background-color: ${colors.slate};\n    border-color: ${colors.slate};\n  }\n`);\n\nexport function saveControls(editMode: Observable<boolean>, save: (ok: boolean) => void) {\n  return [\n    dom.onKeyDown({\n      Enter$: (ev) => {\n        // if shift ignore\n        if (ev.shiftKey) {\n          return;\n        }\n        ev.stopPropagation();\n        ev.preventDefault();\n        save(true);\n        editMode.set(false);\n        if (ev.target && \"blur\" in ev.target) {\n          (ev.target as any).blur();\n        }\n      },\n      Escape: (ev) => {\n        save(false);\n        editMode.set(false);\n        if (ev.target && \"blur\" in ev.target) {\n          (ev.target as any).blur();\n        }\n      },\n    }),\n    dom.create((owner) => {\n      // Whenever focus returns to the Clipboard component, close the editor by saving the value.\n      function saveEdit() {\n        if (!editMode.isDisposed() && editMode.get()) {\n          save(true);\n          editMode.set(false);\n        }\n      }\n      const app = (window as any).gristApp as App;\n      app.on(\"clipboard_focus\", saveEdit);\n      owner.onDispose(() => app.off(\"clipboard_focus\", saveEdit));\n    }),\n  ];\n}\n"
  },
  {
    "path": "app/client/components/FormulaTransform.ts",
    "content": "/**\n * FormulaTransform extends ColumnTransform, creating the transform dom in the field config tab\n * used to transform a column of data using a formula. Allows the user to easily and quickly clean\n * data or change data to a more useful form.\n */\n\n// Client libraries\nimport { ColumnTransform } from \"app/client/components/ColumnTransform\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { cssButtonRow } from \"app/client/ui/RightPanelStyles\";\nimport { basicButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { FieldBuilder } from \"app/client/widgets/FieldBuilder\";\n\nimport { dom } from \"grainjs\";\n\nconst t = makeT(\"FormulaTransform\");\n\n/**\n * Creates an instance of FormulaTransform for a single field. Extends ColumnTransform.\n */\nexport class FormulaTransform extends ColumnTransform {\n  constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) {\n    super(gristDoc, fieldBuilder);\n  }\n\n  /**\n   * Build the transform menu for a formula transform\n   */\n  public buildDom() {\n    return [\n      dom(\"div.transform_menu\",\n        dom(\"div.transform_editor\",\n          this.buildEditorDom(this.getIdentityFormula()),\n          testId(\"formula-transform-top\"),\n        ),\n      ),\n      cssButtonRow(\n        basicButton(dom.on(\"click\", () => this.cancel()),\n          t(\"Cancel\"), testId(\"formula-transform-cancel\")),\n        basicButton(dom.on(\"click\", () => this.preview()),\n          t(\"Preview\"),\n          dom.cls(\"disabled\", this.formulaUpToDate),\n          { title: \"Update formula (Shift+Enter)\" },\n          testId(\"formula-transform-update\")),\n        primaryButton(dom.on(\"click\", () => this.execute()),\n          t(\"Apply\"), testId(\"formula-transform-apply\")),\n      ),\n    ];\n  }\n}\n"
  },
  {
    "path": "app/client/components/GridView.css",
    "content": ".gridview_data_pane {\n  background-color: var(--grist-theme-table-body-bg, white);\n  position: relative;\n  width: 100%;\n  overflow: hidden;\n  flex-grow: 1;\n  /* make sure that this element is at the back of the stack */\n  z-index: 0;\n\n  /* prevent browser selection of cells */\n  user-select: none;\n  -moz-user-select: none;\n  -webkit-user-select: none;\n  --gridview-header-height: 24px;\n}\n\n.gridview_data_scroll {\n  /* Make it position properly */\n  position: absolute;\n  height: 100%;\n  width: 100%;\n  overflow: auto;\n  overscroll-behavior: none;\n\n  z-index: 20; /* scrollbar should be over the overlay background */\n  border-top: 1px solid var(--grist-theme-table-header-border, lightgrey);\n}\n\n.gridview_data_pane > .gridview_data_scroll {\n  border-top: none;\n}\n\n/* ====== Col header stuff */\n\n.gridview_stick-top{\n  position: -webkit-sticky;\n  position: sticky;\n  top: 0px;\n  z-index: 20; /* z-index must be here, doesnt work on children*/\n  border-bottom: 1px solid var(--grist-theme-table-header-border, lightgray);\n  width: fit-content;\n}\n\n.gridview_data_header {\n  position: relative;\n}\n\n.gridview_corner_spacer { /* spacer in .gridview_data_header */\n  width: 52px; /* matches row_num width */\n  flex: none;\n}\n\n.field.column_name {\n  line-height: var(--gridview-header-height);\n  height: var(--gridview-header-height); /* Also should match height for overlay elements */\n}\n\n/* also .field.column_name, style set in viewCommon */\n\n/* ====== Row stuff */\n/* (more styles in viewCommon.css for .field, .record, etc) */\n\n.gridview_row {\n  display:flex;\n}\n\n.gridview_data_row_num { /* Row nums, stick to the left side */\n  position: -webkit-sticky;\n  position: sticky;\n  left: 0px;\n  overflow: hidden;\n  width: 52px; /* Also should match width for .gridview_header_corner, and the overlay elements */\n  flex: none;\n\n  border-bottom: 1px solid var(--grist-theme-table-header-border, lightgray);\n  color: var(--grist-theme-table-header-fg, unset);\n  background-color: var(--grist-theme-table-header-bg, var(--grist-color-light-grey));\n  z-index: 20; /* goes over data cells */\n\n  padding-top: 2px;\n  text-align: center;\n  font-size: 1rem;\n  cursor: pointer;\n}\n\n/* Menu toggle on a row */\n.gridview_data_row_num .menu_toggle {\n  visibility: hidden;\n  position: absolute;\n  top: 2px;\n  right: 0px;\n}\n\n/* Show on hover or when menu is opened */\n.gridview_data_row_num:hover .menu_toggle,\n.gridview_data_row_num .menu_toggle.weasel-popup-open  {\n  visibility: visible;\n}\n\n\n@media print {\n  /* For printing, !important tag is needed for background colors to be respected; but normally,\n   * do not want !important, as it interferes with row selection.\n   */\n  .gridview_data_row_num {\n    color: var(--grist-theme-table-header-fg, unset) !important;\n    background-color: var(--grist-theme-table-header-bg, var(--grist-color-light-grey)) !important;\n  }\n  .gridview_header_backdrop_top {\n    display: none;\n  }\n  .column_name.mod-add-column {\n    display: none;\n  }\n  .gridview_data_header {\n    background-color: var(--grist-color-light-grey) !important;\n  }\n  .print-widget .gridview_header_backdrop_left, .print-widget .gridview_data_corner_overlay {\n    display: none;\n  }\n  .print-widget .gridview_data_scroll {\n    display: table;\n    border-collapse: collapse;\n    position: relative !important;\n    height: max-content !important;\n  }\n  .print-widget .gridview_stick-top {\n    /* The next two styles *together* tell Chrome to repeat this header on each page */\n    display: table-header-group;\n    break-inside: avoid;\n    position: static;\n    border-top: 1px solid var(--grist-color-dark-grey);\n    border-left: 1px solid var(--grist-color-dark-grey);\n  }\n  .print-widget .gridview_data_header {\n    padding-left: 52px !important;\n  }\n  .print-widget .gridview_data_pane .print-all-rows {\n    display: table-row-group;\n    border-left: 1px solid var(--grist-color-dark-grey);\n  }\n  .print-widget .gridview_data_pane .print-row {\n    display: table-row;\n  }\n}\n\n/* ========= Overlay styles ========== */\n/* Positioned outside scrollpane, purely visual */\n\n.gridview_data_corner_overlay,\n.gridview_header_backdrop_top,\n.gridview_header_backdrop_left,\n.scroll_shadow_top,\n.scroll_shadow_left {\n  position:absolute;\n  background-color: var(--grist-theme-table-header-bg, var(--grist-color-light-grey)) !important;\n}\n\n.gridview_data_corner_overlay {\n  width: 52px;\n  height: var(--gridview-header-height);\n  z-index: 30;\n  cursor: pointer;\n}\n\n/* Left most shadow - displayed next to row numbers or when columns are frozen - after last frozen column */\n.scroll_shadow_left {\n  height: 100%;\n  width: 0px;\n  /* Unfortunately we need to calculate this using scroll position.\n     We could use sticky position here, but we would need to move this component inside the\n     scroll pane. We don't want to do this, because we want the scroll shadow to be render\n     on top of the scroll bar. Fortunately it doesn't jitter on firefox - where scroll event is asynchronous.\n     Variables used here:\n     - frozen-width : total width of frozen columns plus row numbers width\n     - scroll-offset: current left offset of the scroll pane\n     - frozen-offset: when frozen columns are wider then the screen, we want them to move left initially,\n                      this value is the position where this movement should stop.\n   */\n  left: calc(52px + (var(--frozen-width, 0) - min(var(--frozen-scroll-offset, 0), var(--frozen-offset, 0))) * 1px);\n  box-shadow: -6px 0 6px 6px var(--grist-theme-table-scroll-shadow, #444);\n  /* shadow should only show to the right of it (10px should be enough) */\n  -webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);\n  clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);\n  z-index: 30;\n}\n\n/* Right shadow - normally not displayed - activated when grid has frozen columns */\n.scroll_shadow_frozen {\n  height: 100%;\n  width: 0px;\n  left: 52px;\n  box-shadow: -8px 0 14px 4px var(--grist-theme-table-scroll-shadow, #444);\n  -webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);\n  clip-path: polygon(0 0, 28px 0, 24px 100%, 0 100%);\n  z-index: 30;\n  position: absolute;\n}\n\n/* line that indicates where the frozen columns end */\n.frozen_line {\n  position:absolute;\n  height: 100%;\n  width: 2px;\n  /* this value is the same as for the left shadow - but doesn't need to really on the scroll offset\n     as this component will be hidden when the scroll starts\n   */\n  left: calc(52px + var(--frozen-width, 0) * 1px);\n  background-color: var(--grist-theme-table-frozen-columns-border, #999999);\n  z-index: 30;\n  user-select: none;\n  pointer-events: none\n}\n\n.scroll_shadow_top {\n  left: 0;\n  height: 0;\n  width: 100%; /* needs to be wide enough to flow off the side*/\n  top: var(--gridview-header-height); /* matches gridview_data_header height */\n  box-shadow: 0 -6px 6px 6px var(--grist-theme-table-scroll-shadow, #444);\n\n  /* should only show below it (10px should be enough) */\n  -webkit-clip-path: polygon(0 0, 0 10px, 100% 10px, 100% 0);\n  clip-path: polygon(0 0, 0 10px, 100% 10px, 100% 0);\n  z-index: 30;\n}\n\n.gridview_header_backdrop_left {\n  width: calc(52px + 1px); /* Matches rowid width (+border) */\n  height:100%;\n  top: 1px; /* go under 1px border on scrollpane */\n  z-index: 10;\n  border-right: 1px solid var(--grist-theme-table-header-border, lightgray);\n}\n\n.gridview_left_border {\n  position: absolute;\n  width: 0px; /* Matches rowid width (+border) */\n  height: 100%;\n  z-index: 30;\n  border-right: 1px solid var(--grist-theme-table-body-border, var(--grist-color-dark-grey)) !important;\n  user-select: none;\n  pointer-events: none\n}\n\n.gridview_header_backdrop_top {\n  width: 100%;\n  height: var(--gridview-header-height);\n  z-index: 10;\n}\n\n.gridview_data_pane > .scroll_shadow_top {\n  top: var(--gridview-header-height);\n}\n\n.gridview_data_pane > .gridview_data_corner_overlay,\n.gridview_data_pane > .gridview_header_backdrop_top {\n  top: 0px;\n}\n\n/* End overlay styles */\n\n/* ================ Row/col drag styles*/\n\n.col_indicator_line{\n  width: 0px;\n  height: 100%;\n  position: absolute;\n  border: 2px solid var(--grist-theme-table-drag-drop-indicator, gray);\n  z-index: 200;\n  top: 0px;\n}\n\n.column_shadow{\n  width: 0px;\n  height: 100%;\n  position: absolute;\n  border: 1px solid var(--grist-theme-table-drag-drop-indicator, gray);\n  z-index: 150;\n  top: 0px;\n  background-color: var(--grist-theme-table-drag-drop-shadow, #F0F0F0);\n  opacity: 0.5;\n  pointer-events: none;   /* allow scrolling the grid while column_shadow is under the cursor */\n}\n\n.row_indicator_line{\n  width: 100%;\n  height: 0px;\n  position: absolute;\n  border: 2px solid var(--grist-theme-table-drag-drop-indicator, gray);\n  z-index: 200;\n  left: 0px;\n}\n\n.row_shadow{\n  width: 100%;\n  height: 0px;\n  position: absolute;\n  border: 1px solid var(--grist-theme-table-drag-drop-indicator, gray);\n  z-index: 150;\n  left: 0px;\n  background-color: var(--grist-theme-table-drag-drop-shadow, #F0F0F0);\n  opacity: 0.5;\n  pointer-events: none; /* prevents row drag shadow from stealing row headers clicks */\n}\n\n/* ================ Freezing columns */\n\n/* style header and a data field */\n.record .field.frozen {\n  position: sticky;\n  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*/\n  z-index: 10;\n}\n/* for data field we need to reuse color from record (add-row and zebra stripes) */\n.gridview_row .record .field.frozen {\n  background-color: var(--field-background-color, inherit);\n}\n\n.gridview_row .record.record-add .field.frozen {\n  background-color: inherit !important;  /* important to win over zebra stripes */\n}\n\n/* HACK: add box shadow to fix outline overflow from active cursor */\n.gridview_row .record .field.frozen {\n  box-shadow: 0px 1px 0px white;\n}\n\n.gridview_row .record.record-hlines .field.frozen {\n  box-shadow: 0px 1px 0px var(--grist-theme-table-body-border, var(--grist-color-dark-grey));\n}\n\n/* make room for a frozen line by adding margin to first not frozen field - in header and in data */\n.field.frozen + .field:not(.frozen) {\n  margin-left: 1px;\n}\n\n/* printing frozen fields is straightforward - just need to remove transparency */\n@media print {\n  .field.frozen  {\n    background: white !important;\n  }\n  .column_names .column_name.frozen {\n    background: var(--grist-theme-table-header-bg, var(--grist-color-light-grey)) !important;\n  }\n}\n\n/* Highlight the entire column when the \"Click to insert\" tooltip is shown. */\n.column_name.hover-column > .selection,\n.column_name.hover-column.selected > .selection,\n.gridview_row .field.hover-column > .selection {\n  background-color: var(--grist-theme-selection, var(--grist-color-selection));\n  inset: 0;\n  position: absolute;\n}\n\n/* Use a darker highlight if the column is being transformed. */\n.gridview_row .field.transform_field.hover-column > .selection {\n  background-color: var(--grist-theme-selection-darkest, rgba(22,179,120,0.35));\n  inset: 0;\n  position: absolute;\n}\n\n/* And hide the column menu button. */\n.column_name.hover-column .menu_toggle {\n  visibility: hidden;\n}\n\n.column_name .menu_toggle {\n  z-index: 1;\n}\n/* Etc */\n\n.g-column-main-menu {\n  position: absolute;\n  top: 3px;\n  right: 2px;\n}\n\n\n.validation_error_number {\n  position: absolute;\n  top: -12px;\n  right: -12px;\n  width: 24px;\n  height: 24px;\n  padding-top: 10px;\n  padding-right: 10px;\n  border-radius: 12px;\n  text-align: center;\n  font-size: 10px;\n  font-weight: bold;\n  background: red;\n  color: white;\n}\n\n.column_name.mod-add-column {\n  border-right-width: 1px;\n  min-width: 40px;\n  padding-right: 12px;\n}\n\n.g-column-label {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.g-column-label .info_toggle_icon {\n  width: 13px;\n  height: 13px;\n  margin-right: 4px;\n  z-index: 1;\n}\n"
  },
  {
    "path": "app/client/components/GridView.ts",
    "content": "import BaseView, { ViewOptions } from \"app/client/components/BaseView\";\nimport { parsePasteForView } from \"app/client/components/BaseView2\";\nimport * as selector from \"app/client/components/CellSelector\";\nimport { ElemType } from \"app/client/components/CellSelector\";\nimport { CutCallback } from \"app/client/components/Clipboard\";\nimport * as commands from \"app/client/components/commands\";\nimport { CopySelection } from \"app/client/components/CopySelection\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { reportUndo } from \"app/client/components/modals\";\nimport { renderAllRows } from \"app/client/components/Printing\";\nimport { viewCommands } from \"app/client/components/RegionFocusSwitcher\";\nimport { SelectionSummary } from \"app/client/components/SelectionSummary\";\nimport viewCommon from \"app/client/components/viewCommon\";\nimport { onDblClickMatchElem } from \"app/client/lib/dblclick\";\nimport { testId as oldTestId } from \"app/client/lib/dom\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { KoArray } from \"app/client/lib/koArray\";\nimport * as kd from \"app/client/lib/koDom\";\nimport koDomScrolly from \"app/client/lib/koDomScrolly\";\nimport koUtil from \"app/client/lib/koUtil\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { addToSort, sortBy } from \"app/client/lib/sortUtil\";\nimport { PasteData } from \"app/client/lib/tableUtil\";\nimport * as tableUtil from \"app/client/lib/tableUtil\";\nimport BaseRowModel from \"app/client/models/BaseRowModel\";\nimport { NEW_FILTER_JSON } from \"app/client/models/ColumnFilter\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { ColInfo, NewColInfo, ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport { reportWarning } from \"app/client/models/errors\";\nimport { CombinedStyle } from \"app/client/models/Styles\";\nimport { CellContextMenu, ICellContextMenu } from \"app/client/ui/CellContextMenu\";\nimport { IColumnFilterMenuOptions } from \"app/client/ui/ColumnFilterMenu\";\nimport { buildRenameColumn, columnHeaderWithInfo } from \"app/client/ui/ColumnTitle\";\nimport { contextMenu } from \"app/client/ui/contextMenu\";\nimport {\n  buildAddColumnMenu,\n  buildColumnContextMenu,\n  buildMultiColumnMenu,\n  calcFieldsCondition,\n  freezeAction,\n  IMultiColumnContextMenu,\n} from \"app/client/ui/GridViewMenus\";\nimport { menuToggle } from \"app/client/ui/MenuToggle\";\nimport { mouseDragMatchElem } from \"app/client/ui/mouseDrag\";\nimport { IRowContextMenu, RowContextMenu } from \"app/client/ui/RowContextMenu\";\nimport { applyRowHeightLimit } from \"app/client/ui/RowHeightConfig\";\nimport { ITooltipControl, showTooltip } from \"app/client/ui/tooltips\";\nimport { isNarrowScreen, testId } from \"app/client/ui2018/cssVars\";\nimport { closeRegisteredMenu, menu } from \"app/client/ui2018/menus\";\nimport BinaryIndexedTree from \"app/common/BinaryIndexedTree\";\nimport { BulkColValues, CellValue, UserAction } from \"app/common/DocActions\";\nimport { isList } from \"app/common/gristTypes\";\nimport * as gutil from \"app/common/gutil\";\nimport { Sort } from \"app/common/SortSpec\";\nimport { CursorPos, UIRowId } from \"app/plugin/GristAPI\";\n\nimport convert from \"color-convert\";\nimport { BindableValue, bundleChanges, Computed, Disposable, Holder, MultiHolder, Observable } from \"grainjs\";\nimport { dom, DomElementArg, DomElementMethod, subscribeElem } from \"grainjs\";\nimport ko from \"knockout\";\nimport debounce from \"lodash/debounce\";\nimport identity from \"lodash/identity\";\nimport { IOpenController, PopupControl, setPopupToCreateDom } from \"popweasel\";\nimport _ from \"underscore\";\n\n// Disable member-ordering linting temporarily, so that it's easier to review the conversion to\n// typescript. It would be reasonable to reorder methods and re-enable this lint check.\n/* eslint-disable @typescript-eslint/member-ordering */\n\nconst t = makeT(\"GridView\");\n\n// A threshold for interpreting a motionless click as a click rather than a drag.\n// Anything longer than this time (in milliseconds) should be interpreted as a drag\n// even if there is no movement.\n// This is relevant for distinguishing clicking an already-selected column in order\n// to rename it, and starting to drag that column and then deciding to leave it where\n// it was.\nconst SHORT_CLICK_IN_MS = 500;\n\n/**\n * A function that renders the index cell for a row, can be overridden in GridViewOptions.\n */\nexport type RowIndexRenderer = (row: DataRowModel) => DomElementArg | null;\n\n/**\n * A function that renders the corner cell, can be overridden in GridViewOptions.\n */\nexport type CornerRenderer = (el: Element) => DomElementArg | null;\n\n// size of the plus width\nconst PLUS_WIDTH = 40;\n// size of the row number field\nconst ROW_NUMBER_WIDTH = 52;\n\ninterface InsertColOptions {\n  colInfo?: ColInfo;\n  index?: number;\n  skipPopup?: boolean;\n  onPopupClose?: () => void;\n}\n\ntype Direction = \"left\" | \"right\" | \"up\" | \"down\";\n\nexport interface GridViewOptions extends ViewOptions {\n  inline?: boolean; // If true, grid will try to auto size to fit contents within maxInlineWidth/Height, used in\n  // VirtualDoc and change proposals. This is still an experimental feature.\n  maxInlineWidth?: number; // In inline mode the default is 800px.\n  maxInlineHeight?: number; // In inline mode the default is 600px.\n  isPreview?: boolean; // default false, used for imports previews.\n  rowMenu?: boolean; // default true, controls if row context menu will be shown.\n  colMenu?: boolean; // default true, controls if column context menu will be shown.\n  rowIndexRenderer?: RowIndexRenderer; // function to render the row index.\n  cornerRenderer?: CornerRenderer; // function to render the corner cell.\n  onCellDblClick?: (pos: CursorPos) => void; // function to call on cell double click, instead of default\n  // editor activation action.\n}\n\n/**\n * GridView component implements the view of a grid of cells.\n */\nexport default class GridView extends BaseView {\n  protected isReadonly: boolean;\n  protected dragX: ko.Observable<number>;\n  protected dragY: ko.Observable<number>;\n  protected rowShadowAdjust;\n  protected colShadowAdjust;\n  protected scrollLeft: ko.Observable<number>;\n  protected isScrolledLeft: ko.Computed<boolean>;\n  protected scrollTop: ko.Observable<number>;\n  protected isScrolledTop: ko.Computed<boolean>;\n  protected cellSelector: selector.CellSelector;\n  protected customCellMenu: (menu: DomElementArg[], options: ICellContextMenu) => Element[];\n  protected customRowMenu: (menu: DomElementArg[], options: IRowContextMenu) => Element[];\n  protected colRightOffsets: ko.Computed<BinaryIndexedTree>;\n  protected visibleRowIndex: ko.Observable<number | null>;\n  protected currentPosition: Computed<{ rowIndex: number | null, fieldIndex: number }>;\n  protected scrollShadow: { left: ko.Computed<boolean>, top: ko.Computed<boolean> };\n  protected ctxMenuHolder: Holder<IOpenController>;\n  protected width: ko.Observable<number>;\n  protected numFrozen: ko.Computed<number>;\n  protected frozenWidth: ko.Computed<number>;\n  protected frozenLine: ko.Computed<boolean>;\n  protected frozenOffset: ko.Computed<number>;\n\n  protected frozenScrollOffset: ko.Computed<number>;\n  protected frozenShadow: ko.Computed<boolean>;\n  protected frozenPositions: KoArray<ko.Computed<number>>;\n  protected frozenMap: KoArray<ko.Computed<boolean>>;\n  protected hoverColumn: ko.Observable<number>;\n  private _insertColumnIndex: ko.Observable<number | null>;\n  protected editingFormula: ko.Computed<boolean>;\n  protected changeHover: (index: number) => void;\n  protected isColSelected: KoArray<ko.Computed<boolean>>;\n  protected header: HTMLElement;\n  private _cornerDom: HTMLElement;\n  protected scrollPane: HTMLElement;\n  protected scrolly: any;\n  protected _colClickTime: number;  // Units: milliseconds.\n  private _assignCursorTimeoutId: ReturnType<typeof setTimeout> | undefined;\n  protected colLine: HTMLElement;\n  protected colShadow: HTMLElement;\n  protected rowLine: HTMLElement;\n  protected rowShadow: HTMLElement;\n  private _rowHeights: number[] = [];\n  private _inline: boolean;\n  private _rowIndexRenderer: RowIndexRenderer;\n  private _cornerRenderer: CornerRenderer;\n  private _autoWidthHolder: Holder<Disposable>;\n\n  constructor(gristDoc: GristDoc, viewSectionModel: ViewSectionRec, protected gridOptions?: GridViewOptions) {\n    super(gristDoc, viewSectionModel, { ...gridOptions, addNewRow: gridOptions?.addNewRow ?? true });\n\n    this.isPreview = gridOptions?.isPreview || false;\n    this._inline = gridOptions?.inline ?? false;\n    this._autoWidthHolder = this.autoDispose(new Holder());\n\n    this._rowIndexRenderer = gridOptions?.rowIndexRenderer ??\n      (row => dom.text(use => String(use(row._index)! + 1)));\n    this._cornerRenderer = gridOptions?.cornerRenderer ??\n      (() => dom.on(\"click\", () => this.selectAll()));\n    this.viewSection = viewSectionModel;\n    this.isReadonly = this.gristDoc.isReadonly.get() ||\n      this.viewSection.isVirtual() ||\n      this.isPreview;\n\n    // --------------------------------------------------\n    // Observables local to this view\n\n    // Some observables/variables used for select and drag/drop\n    this.dragX = ko.observable(0); // x coord of mouse during drag mouse down\n    this.dragY = ko.observable(0); // ^ for y coord\n    this.rowShadowAdjust = 0; // pixel dist from mouse click y-coord and the clicked row's top offset\n    this.colShadowAdjust = 0; // ^ for x-coord and clicked col's left offset\n    this.scrollLeft = ko.observable(0);\n    this.isScrolledLeft = this.autoDispose(ko.computed(() => this.scrollLeft() > 0));\n    this.scrollTop = ko.observable(0);\n    this.isScrolledTop = this.autoDispose(ko.computed(() => this.scrollTop() > 0));\n\n    this.cellSelector = selector.CellSelector.create(this, this);\n\n    // A handler that can amend the custom cell/row menu with additional items.\n    // It is a function (menuItems: Element[]) => Element[]. Primarily used by virtual tables.\n    this.customCellMenu = identity;\n    this.customRowMenu = identity;\n\n    if (!this.isPreview && !this.gristDoc.comparison && !this._inline) {\n      this.selectionSummary = SelectionSummary.create(this,\n        this.cellSelector, this.tableModel.tableData, this.sortedRows, this.viewSection.viewFields);\n    }\n\n    this.selectedColumns = this.autoDispose(ko.pureComputed(() => {\n      const result = this.viewSection.viewFields().all().filter((field, index) => {\n        // During column removal or restoring (with undo), some columns fields\n        // might be disposed.\n        if (field.isDisposed() || field.column().isDisposed()) { return false; }\n        return this.cellSelector.containsCol(index);\n      });\n      return result;\n    }));\n\n    // Cache of column right offsets, used to determine the col select range\n    this.colRightOffsets = this.autoDispose(ko.computed(() => {\n      const fields = this.viewSection.viewFields();\n      const tree = new BinaryIndexedTree(0);\n      tree.fillFromValues(fields.all().map(field => field.widthDef()));\n      return tree;\n    }));\n\n    // Create observable holding current rowIndex that the view should be scrolled to.\n    // We will always notify, because we want to scroll to the row even when only the\n    // column is changed (in situation when the row is not visible).\n    this.visibleRowIndex = ko.observable(this.cursor.rowIndex()).extend({ notify: \"always\" });\n    // Create grain's Computed with current cursor position (we need it to examine position\n    // before the change and after).\n    this.currentPosition = Computed.create(this, use => ({\n      rowIndex: use(this.cursor.rowIndex),\n      fieldIndex: use(this.cursor.fieldIndex),\n    }));\n    // Add listener, and check if the cursor is indeed changed, if so, update the row\n    // and scroll it into view (using kd.scrollChildIntoView in buildDom function).\n    this.autoDispose(this.currentPosition.addListener((cur, prev) => {\n      if (cur.rowIndex !== prev.rowIndex || cur.fieldIndex !== prev.fieldIndex) {\n        this.visibleRowIndex(cur.rowIndex);\n      }\n    }));\n\n    this.autoDispose(this.cursor.fieldIndex.subscribe((idx) => {\n      this._scrollColumnIntoView(idx);\n    }));\n\n    // Some observables for the scroll markers that show that the view is cut off on a side.\n    this.scrollShadow = {\n      left: this.isScrolledLeft,\n      top: this.isScrolledTop,\n    };\n\n    // --------------------------------------------------\n    // Set up row and column context menus.\n    this.ctxMenuHolder = Holder.create(this);\n\n    // --------------------------------------------------\n    // Set frozen columns variables\n\n    // keep track of the width for this component\n    this.width = ko.observable(0);\n    // helper for clarity\n    this.numFrozen = this.viewSection.numFrozen;\n    // calculate total width of all frozen columns\n    this.frozenWidth = this.autoDispose(ko.pureComputed(() => this.colRightOffsets().getSumTo(this.numFrozen())));\n    // show frozenLine when have some frozen columns and not scrolled left\n    this.frozenLine = this.autoDispose(ko.pureComputed(() => Boolean(this.numFrozen()) && !this.isScrolledLeft()));\n    // even if some columns are frozen, we still want to move them left\n    // when screen is too narrow - here we will calculate how much space\n    // is needed to move all the frozen columns left in order to show some\n    // unfrozen columns to user (by default we will try to show at least one not\n    // frozen column and a plus button)\n    this.frozenOffset = this.autoDispose(ko.computed(() => {\n      // get the last field\n      const fields = this.viewSection.viewFields().all();\n      const lastField = fields[fields.length - 1];\n      // get the last field width (or zero - grid can have zero columns)\n      const revealWidth = lastField ? lastField.widthDef() : 0;\n      // calculate the offset: start from zero, then move all left to hide frozen columns,\n      // then to right to fill whole width, then to left to reveal last column and plus button\n      const initialOffset = -this.frozenWidth() - ROW_NUMBER_WIDTH + this.width() - revealWidth - PLUS_WIDTH;\n      // Final check - we actually don't want to have\n      // the split (between frozen and normal columns) be moved left too far,\n      // it should stop at the middle of the available grid space (whole width - row number width).\n      // This can happen when last column is too wide, and we are not able to show it in a full width.\n      // To calculate the middle point: hide all frozen columns (by moving them maximum to the left)\n      // and then move them to right by half width of the section.\n      const middleOffset = -this.frozenWidth() - ROW_NUMBER_WIDTH + this.width() / 2;\n      // final offset is the bigger number of those two (offsets are negative - so take\n      // the number that is closer to 0)\n      const offset = Math.floor(Math.max(initialOffset, middleOffset));\n      // offset must be negative (we are moving columns left), if we ended up moving\n      // frozen columns to the right, don't move them at all\n      return offset > 0 ? 0 : Math.abs(offset);\n    }));\n    // observable for left scroll - but return left only when columns are frozen\n    // this will be used to move frozen border alongside with the scrollpane\n    this.frozenScrollOffset = this.autoDispose(ko.computed(() => this.numFrozen() ? this.scrollLeft() : 0));\n    // observable that will indicate if shadow is needed on top of frozen columns\n    this.frozenShadow = this.autoDispose(ko.computed(() => {\n      return Boolean(this.numFrozen() && this.frozenOffset()) && this.isScrolledLeft();\n    }));\n    // calculate column right offsets\n    this.frozenPositions = this.autoDispose(this.viewSection.viewFields().map((field) => {\n      return ko.pureComputed(() => this.colRightOffsets().getSumTo(field._index()!));\n    }));\n    // calculate frozen state for all columns\n    this.frozenMap = this.autoDispose(this.viewSection.viewFields().map((field) => {\n      return ko.pureComputed(() => field._index()! < this.numFrozen());\n    }));\n\n    // Holds column index that is hovered, works only in full-edit formula mode.\n    this.hoverColumn = ko.observable(-1);\n\n    this._insertColumnIndex = ko.observable<number | null>(null);\n\n    // Checks if there is active formula editor for a column in this table.\n    this.editingFormula = ko.pureComputed(() => {\n      const isEditing = this.gristDoc.docModel.editingFormula();\n      if (!isEditing) { return false; }\n      return this.viewSection.viewFields().all().some(field => field.editingFormula());\n    });\n\n    // Debounced method to change current hover column, this is needed\n    // as mouse when moved from field to field will switch the hover-column\n    // observable from current index to -1 and then immediately back to current index.\n    // With debounced version, call to set -1 that is followed by call to set back to the field index\n    // will be discarded.\n    this.changeHover = debounce((index) => {\n      if (this.isDisposed()) { return; }\n      if (this.editingFormula()) {\n        this.hoverColumn(index);\n      }\n    }, 0);\n\n    // --------------------------------------------------\n    // Create and attach the DOM for the view.\n\n    this.isColSelected = this.autoDispose(this.viewSection.viewFields().map((field) => {\n      return this._createColSelectedObs(field);\n    }));\n    this.viewPane = this.buildDom();\n    this.onDispose(() => { dom.domDispose(this.viewPane); this.viewPane.remove(); });\n    this.attachSelectorHandlers();\n    this.scrolly = koDomScrolly.getInstance(this.viewData);\n\n    // --------------------------------------------------\n    // Set up DOM event handling.\n    onDblClickMatchElem(this.scrollPane, \".field:not(.column_name)\", (event) => {\n      if (this.gridOptions?.onCellDblClick) {\n        this.gridOptions.onCellDblClick(this.cursor.getCursorPos());\n      } else {\n        this.activateEditorAtCursor({ event });\n      }\n    });\n    if (!this.isPreview) {\n      dom.onMatchElem(this.scrollPane, \".field:not(.column_name)\", \"contextmenu\",\n        (ev, elem) => this.onCellContextMenu(ev, elem as Element), { useCapture: true },\n      );\n    }\n    this.autoDispose(dom.onElem(this.scrollPane, \"scroll\", () => this.onScroll()));\n\n    // --------------------------------------------------\n    // Command groups implementing all grid level commands (except cancel)\n    this.autoDispose(commands.createGroup(viewCommands(GridView.gridCommands, this), this, this.viewSection.hasFocus));\n    this.autoDispose(commands.createGroup(GridView.gridFocusedCommands, this, this.viewSection.hasRegionFocus));\n\n    // Cancel command is registered conditionally, only when there is an active\n    // cell selection. This command is also used by Raw Data Views, to close the Grid popup.\n    const hasSelection = this.autoDispose(ko.pureComputed(() =>\n      Boolean(!this.cellSelector.isCurrentSelectType(\"\") || this.copySelection())));\n    this.autoDispose(commands.createGroup(GridView.selectionCommands, this, hasSelection));\n\n    // Timer to allow short, otherwise non-actionable clicks on column names to trigger renaming.\n    this._colClickTime = 0;  // Units: milliseconds.\n  }\n\n  // ======================================================================================\n  // GRID-LEVEL COMMANDS\n\n  // Moved out of all commands to support Raw Data Views (which use this command to close\n  // the Grid popup).\n  protected static selectionCommands: { [key: string]: Function } & ThisType<GridView> = {\n    clearCopySelection: function() { this._clearCopySelection(); },\n    cancel: function() { this.clearSelection(); },\n  };\n\n  // TODO: move commands with modifications to gridEditCommands and use a single guard for\n  // readonly state.\n  // GridView commands, enabled when the view is the active one.\n  // See BaseView.commonCommands for more details.\n  protected static gridCommands: { [key: string]: Function } & ThisType<GridView> = {\n    fillSelectionDown: function() {\n      tableUtil.fillSelectionDown(this.getSelection(), this.tableModel)?.catch(reportError);\n    },\n    selectAll: function() { this.selectAll(); },\n    insertFieldBefore: function(event?: KeyboardEvent) {\n      this._insertField(event, this.cursor.fieldIndex())?.catch(reportError);\n    },\n    insertFieldAfter: function(event?: KeyboardEvent) {\n      this._insertField(event, this.cursor.fieldIndex() + 1)?.catch(reportError);\n    },\n    makeHeadersFromRow: function() {\n      this.makeHeadersFromRow(this.getSelection()).catch(reportError);\n    },\n    renameField: function() { this.renameColumn(this.cursor.fieldIndex()); },\n    hideFields: function() { this.hideFields(this.getSelection())?.catch(reportError); },\n    deleteFields: function() { this._deleteFields()?.catch(reportError); },\n    clearColumns: function() { this._clearColumns(this.getSelection())?.catch(reportError); },\n    convertFormulasToData: function() { this._convertFormulasToData(this.getSelection())?.catch(reportError); },\n    sortAsc: function() {\n      sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);\n    },\n    sortDesc: function() {\n      sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);\n    },\n    addSortAsc: function() {\n      addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);\n    },\n    addSortDesc: function() {\n      addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);\n    },\n    toggleFreeze: function() {\n      // get column selection\n      const selection = this.getSelection();\n      // convert it menu option\n      const options = this._getColumnMenuOptions(selection);\n      // generate action that is available for freeze toggle\n      const action = freezeAction(options);\n      // if no action, do nothing\n      if (!action) { return; }\n      // if grist document is in readonly - simply change the value\n      // without saving\n      if (this.isReadonly) {\n        this.viewSection.rawNumFrozen(action.numFrozen);\n        return;\n      }\n      this.viewSection.rawNumFrozen.setAndSave(action.numFrozen).catch(reportError);\n    },\n    copy: function() { return this.copy(this.getSelection()); },\n    cut: function() { return this.cut(this.getSelection()); },\n    paste: async function(pasteObj: PasteData, cutCallback: CutCallback | null) {\n      if (this.gristDoc.isReadonly.get()) { return; }\n      await this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback));\n      await this.scrollToCursor(false);\n    },\n  };\n\n  // These commands are enabled only when the grid is the user-focused region.\n  // See BaseView.commonCommands and BaseView.commonFocusedCommands for more details.\n  protected static gridFocusedCommands: { [key: string]: Function } & ThisType<GridView> = {\n    cursorDown: function() {\n      if (this.cursor.rowIndex() === this.viewData.peekLength - 1) {\n        // When the cursor is in the bottom row, the view may not be scrolled all the way to\n        // the bottom (i.e. in the case of a tall row).\n        this.scrollPaneBottom();\n      }\n      this.cursor.rowIndex(this.cursor.rowIndex()! + 1);\n    },\n    cursorUp: function() {\n      if (this.cursor.rowIndex() === 0) {\n        // When the cursor is in the top row, the view may not be scrolled all the way to\n        // the top (i.e. in the case of a tall row).\n        this.scrollPaneTop();\n      }\n      this.cursor.rowIndex(this.cursor.rowIndex()! - 1);\n    },\n    cursorRight: function() {\n      if (this.cursor.fieldIndex() === this.viewSection.viewFields().peekLength - 1) {\n        // When the cursor is in the rightmost column, the view may not be scrolled all the way to\n        // the right (i.e. in the case of a wide column).\n        this.scrollPaneRight();\n      }\n      this.cursor.fieldIndex(this.cursor.fieldIndex() + 1);\n    },\n    cursorLeft: function() {\n      if (this.cursor.fieldIndex() === 0) {\n        // When the cursor is in the leftmost column, the view may not be scrolled all the way to\n        // the left (i.e. in the case of a wide column).\n        this.scrollPaneLeft();\n      }\n      this.cursor.fieldIndex(this.cursor.fieldIndex() - 1);\n    },\n    shiftDown: function() { this._shiftSelect({ step: 1, direction: \"down\" }); },\n    shiftUp: function() { this._shiftSelect({ step: 1, direction: \"up\" }); },\n    shiftRight: function() { this._shiftSelect({ step: 1, direction: \"right\" }); },\n    shiftLeft: function() { this._shiftSelect({ step: 1, direction: \"left\" }); },\n    ctrlShiftDown: function() { this._shiftSelectUntilFirstOrLastNonEmptyCell({ direction: \"down\" }); },\n    ctrlShiftUp: function() { this._shiftSelectUntilFirstOrLastNonEmptyCell({ direction: \"up\" }); },\n    ctrlShiftRight: function() { this._shiftSelectUntilFirstOrLastNonEmptyCell({ direction: \"right\" }); },\n    ctrlShiftLeft: function() { this._shiftSelectUntilFirstOrLastNonEmptyCell({ direction: \"left\" }); },\n    fieldEditSave: function() { this.cursor.rowIndex(this.cursor.rowIndex()! + 1); },\n    // Re-define editField after fieldEditSave to make it take precedence for the Enter key.\n    editField: function(event?: KeyboardEvent) {\n      closeRegisteredMenu();\n      this.scrollToCursor(true).catch(reportError);\n      this.activateEditorAtCursor({ event });\n    },\n    clearValues: function() { this.clearValues(this.getSelection())?.catch(reportError); },\n    viewAsCard() {\n      const selectedRows = this.selectedRows();\n      if (selectedRows.length !== 1) { return; }\n\n      this.viewSelectedRecordAsCard();\n    },\n  };\n\n  protected onTableLoaded() {\n    super.onTableLoaded();\n    this.onScroll();\n\n    // Initialize scroll position.\n    this.scrollPane.scrollLeft = this.viewSection.lastScrollPos.scrollLeft;\n    this.scrolly.scrollToSavedPos(this.viewSection.lastScrollPos);\n\n    if (this._inline) {\n      this.applyAutoSize();\n    }\n  }\n\n  protected applyAutoSize() {\n    // Autosize will be done in next tick after rows are rendered.\n    const scope = this._autoWidthHolder.autoDispose(new MultiHolder());\n    const timeout = setTimeout(() => {\n      if (this.isDisposed()) { return; }\n      // Auto-size columns on initial load if enabled\n      if (this._rowHeights.length > 0) {\n        this._applyAutoWidth();\n        this.scrolly.updateSize();\n\n        // Scrollbars have different widths on different platforms, so we need to re-apply\n\n        const scrollSize = scrollBar();\n        const borderWidth = 2;\n        const headerHeight = 23;\n        const maxHeight = this.gridOptions?.maxInlineHeight || 600;\n        const maxWidth = this.gridOptions?.maxInlineWidth || 800;\n        const totalHeight = this._rowHeights.reduce((sum, value) => sum + value, 0) +\n          headerHeight + borderWidth + scrollSize.height;\n        const targetHeight = Math.min(totalHeight, maxHeight);\n        const widthObs = Observable.create<number>(this, 0);\n\n        const updateWidth = () => {\n          if (this.isDisposed()) { return; }\n          const fields = this.viewSection.viewFields().all();\n          const fieldsWidthSum = fields.reduce((sum, field) => sum + field.widthDef.peek(), 0);\n\n          const targetWidth =  Math.min(fieldsWidthSum + ROW_NUMBER_WIDTH + scrollSize.width, maxWidth);\n          widthObs.set(targetWidth);\n        };\n        updateWidth();\n        dom.update(this.viewPane,\n          dom.style(\"width\", use => `${use(widthObs)}px`),\n          dom.style(\"height\", `${targetHeight}px`),\n        );\n        this.scrolly.updateSize();\n        const paneElem = this.viewPane.querySelector(\".gridview_data_header\")!;\n        const resizeObserver = new ResizeObserver(updateWidth);\n        resizeObserver.observe(paneElem);\n        scope.onDispose(() => resizeObserver.disconnect());\n      }\n    });\n    scope.onDispose(() => clearTimeout(timeout));\n  }\n\n  /**\n   * Update the bounds of the cell selector's selected range for Shift+Direction keyboard shortcuts.\n   */\n  protected _shiftSelect({ step, direction}: { step: number, direction: Direction }) {\n    const type = [\"up\", \"down\"].includes(direction) ? selector.ROW : selector.COL;\n    const exemptType = type === selector.ROW ? selector.COL : selector.ROW;\n    if (this.cellSelector.isCurrentSelectType(exemptType)) { return; }\n\n    if (this.cellSelector.isCurrentSelectType(selector.NONE)) {\n      this.cellSelector.currentSelectType(selector.CELL);\n    }\n    let selectObs;\n    let maxVal;\n    if (type === \"row\") {\n      selectObs = this.cellSelector.row.end;\n      maxVal = this.getLastDataRowIndex();\n    } else {\n      selectObs = this.cellSelector.col.end;\n      maxVal = this.viewSection.viewFields().peekLength - 1;\n    }\n    step = [\"up\", \"left\"].includes(direction) ? -step : step;\n    const newVal = gutil.clamp(selectObs() + step, 0, maxVal);\n    selectObs(newVal);\n    if (type === \"row\") {\n      this.scrolly.scrollRowIntoView(newVal);\n    } else {\n      this._scrollColumnIntoView(newVal);\n    }\n  }\n\n  /**\n   * Shifts the current selection in the specified `direction` until the first or last\n   * non-empty cell.\n   *\n   * If the current selection ends on an empty cell, the selection will be shifted to\n   * the first non-empty cell in the specified direction. Otherwise, the selection\n   * will be shifted to the last non-empty cell.\n   */\n  protected _shiftSelectUntilFirstOrLastNonEmptyCell({ direction}: { direction: Direction }) {\n    const steps = this._stepsToContent({ direction });\n    if (steps > 0) { this._shiftSelect({ step: steps, direction }); }\n  }\n\n  /**\n   * Gets the number of rows/columns until the first or last non-empty cell in the specified\n   * `direction`.\n   */\n  protected _stepsToContent({ direction}: { direction: Direction }) {\n    const colEnd = this.cellSelector.col.end();\n    const rowEnd = this.cellSelector.row.end();\n    const cursorCol = this.cursor.fieldIndex();\n    const cursorRow = this.cursor.rowIndex()!;\n    const type = [\"up\", \"down\"].includes(direction) ? selector.ROW : selector.COL;\n    const maxVal = type === selector.ROW ?\n      this.getLastDataRowIndex() :\n      this.viewSection.viewFields().peekLength - 1;\n\n    // Get table data for the current selection plus additional data in the specified `direction`.\n    let selectionData;\n    switch (direction) {\n      case \"right\": {\n        if (colEnd + 1 > maxVal) { return 0; }\n\n        selectionData = this._selectionData({\n          colStart: colEnd, colEnd: maxVal, rowStart: cursorRow, rowEnd: cursorRow,\n        });\n        break;\n      }\n      case \"left\": {\n        if (colEnd - 1 < 0) { return 0; }\n\n        selectionData = this._selectionData({ colStart: 0, colEnd, rowStart: cursorRow, rowEnd: cursorRow });\n        break;\n      }\n      case \"up\": {\n        if (rowEnd - 1 > maxVal) { return 0; }\n\n        selectionData = this._selectionData({ colStart: cursorCol, colEnd: cursorCol, rowStart: 0, rowEnd });\n        break;\n      }\n      case \"down\": {\n        if (rowEnd + 1 > maxVal) { return 0; }\n\n        selectionData = this._selectionData({\n          colStart: cursorCol, colEnd: cursorCol, rowStart: rowEnd, rowEnd: maxVal,\n        });\n        break;\n      }\n    }\n\n    const { fields, rowIndices } = selectionData;\n    if (direction === \"left\") {\n      // When moving selection left, we step through fields in reverse order.\n      fields.reverse();\n    }\n    if (direction === \"up\") {\n      // When moving selection up, we step through rows in reverse order.\n      rowIndices.reverse();\n    }\n\n    // Prepare a map of field indexes to their respective column values. We'll consult these\n    // values below when looking for the first (or last) non-empty cell value in the direction\n    // of the new selection.\n    const colValuesByIndex: { [key: number]: readonly CellValue[] } = {};\n    for (const field of fields) {\n      const displayColId = field.displayColModel.peek().colId.peek();\n      colValuesByIndex[field._index()!] = this.tableModel.tableData.getColValues(displayColId)!;\n    }\n\n    // Count the number of steps until the first or last non-empty cell.\n    let steps = 0;\n    if (type === selector.COL) {\n      // The selection is changing on the x-axis (i.e. the selected columns changed).\n      const rowIndex = rowIndices[0];\n      const isLastColEmpty = this._isCellValueEmpty(colValuesByIndex[colEnd][rowIndex]);\n      const isNextColEmpty = this._isCellValueEmpty(\n        colValuesByIndex[colEnd + (direction === \"right\" ? 1 : -1)][rowIndex]);\n      const shouldStopOnEmptyValue = !isLastColEmpty && !isNextColEmpty;\n      for (let i = 1; i < fields.length; i++) {\n        const hasEmptyValues = this._isCellValueEmpty(colValuesByIndex[fields[i]._index()!][rowIndex]);\n        if (hasEmptyValues && shouldStopOnEmptyValue) {\n          return steps;\n        } else if (!hasEmptyValues && !shouldStopOnEmptyValue) {\n          return steps + 1;\n        }\n\n        steps += 1;\n      }\n    } else {\n      // The selection is changing on the y-axis (i.e. the selected rows changed).\n      const colValues = colValuesByIndex[fields[0]._index()!];\n      const isLastRowEmpty = this._isCellValueEmpty(colValues[rowIndices[0]]);\n      const isNextRowEmpty = this._isCellValueEmpty(colValues[rowIndices[1]]);\n      const shouldStopOnEmptyValue = !isLastRowEmpty && !isNextRowEmpty;\n      for (let i = 1; i < rowIndices.length; i++) {\n        const hasEmptyValues = this._isCellValueEmpty(colValues[rowIndices[i]]);\n        if (hasEmptyValues && shouldStopOnEmptyValue) {\n          return steps;\n        } else if (!hasEmptyValues && !shouldStopOnEmptyValue) {\n          return steps + 1;\n        }\n\n        steps += 1;\n      }\n    }\n\n    return steps;\n  }\n\n  protected _selectionData(\n    { colStart, colEnd, rowStart, rowEnd}: { colStart: number, colEnd: number, rowStart: number, rowEnd: number },\n  ): { fields: ViewFieldRec[], rowIndices: number[] } {\n    const fields = [];\n    for (let i = colStart; i <= colEnd; i++) {\n      const field = this.viewSection.viewFields().at(i);\n      if (!field) { continue; }\n\n      fields.push(field);\n    }\n\n    const rowIndices: number[] = [];\n    for (let i = rowStart; i <= rowEnd; i++) {\n      const rowId = this.viewData.getRowId(i);\n      if (!rowId) { continue; }\n\n      rowIndices.push(this.tableModel.tableData.getRowIdIndex(rowId)!);\n    }\n\n    return { fields, rowIndices };\n  }\n\n  protected _isCellValueEmpty(value: CellValue | undefined) {\n    return value === null || value === undefined || value === \"\" || value === \"false\";\n  }\n\n  /**\n   * Pastes the provided data at the current cursor.\n   *\n   * TODO: Handle the edge case where more columns are pasted than available.\n   *\n   * @param {Array} data - Array of arrays of data to be pasted. Each array represents a row.\n   * i.e.  [[\"1-1\", \"1-2\", \"1-3\"],\n   *        [\"2-1\", \"2-2\", \"2-3\"]]\n   * @param {Function} cutCallback - If provided returns the record removal action needed for\n   *  a cut.\n   */\n  protected async paste(data: PasteData, cutCallback: CutCallback | null) {\n    // TODO: If pasting into columns by which this view is sorted, rows may jump. It is still better\n    // to allow it, but we should \"freeze\" the affected rows to prevent them from jumping, until the\n    // user re-applies the sort manually. (This is a particularly bad experience when rows get\n    // dispersed by the sorting after paste.) We do attempt to keep the cursor in the same row as\n    // before even if it jumped. Note when addressing it: currently selected rows should be treated\n    // as frozen (and get marked as unsorted if necessary) for any update even if the update comes\n    // from a different peer.\n\n    // convert row-wise data to column-wise so that it better resembles a user action\n    let pasteData = unzipPasteData(data);\n    const pasteHeight = pasteData[0].length;\n    const pasteWidth = pasteData.length;\n    // figure out the size of the paste area\n    const outputHeight = Math.max(gutil.roundDownToMultiple(this.cellSelector.rowCount(), pasteHeight), pasteHeight);\n    const outputWidth = Math.min(\n      Math.max(gutil.roundDownToMultiple(this.cellSelector.colCount(), pasteWidth), pasteWidth),\n      // We will add more rows, but not more columns.\n      this.viewSection.viewFields().peekLength,\n    );\n    // get the row ids that cover the paste\n    const topIndex = this.cellSelector.rowLower();\n    const updateRowIndices = _.range(topIndex, topIndex + outputHeight);\n    const updateRowIds = updateRowIndices.map(r => this.viewData.getRowId(r));\n    // get the col ids that cover the paste\n    const leftIndex = this.cellSelector.colLower();\n    const updateColIndices = _.range(leftIndex, leftIndex + outputWidth);\n\n    pasteData = growPasteDataMatrix(pasteData, updateColIndices.length, updateRowIds.length);\n\n    const fields = this.viewSection.viewFields().peek();\n    const pasteFields = updateColIndices.map(i => fields[i] || null);\n\n    const richData = await parsePasteForView(pasteData, pasteFields, this.gristDoc);\n    const actions = this._createBulkActionsFromPaste(updateRowIds, richData);\n\n    if (actions.length > 0) {\n      const cursorPos = this.cursor.getCursorPos();\n      const results = await this.sendPasteActions(cutCallback, actions);\n      // If rows were added, get their rowIds from the action results.\n      const addRowIds = (actions[0][0] === \"BulkAddRecord\" ? results[0] : []);\n      console.assert(addRowIds.length <= updateRowIds.length,\n        `Unexpected number of added rows: ${addRowIds.length} of ${updateRowIds.length}`);\n      const newRowIds = updateRowIds.slice(0, updateRowIds.length - addRowIds.length)\n        .concat(addRowIds);\n\n      // Restore the cursor to the right rowId, even if it jumped.\n      this.cursor.setCursorPos({ rowId: cursorPos.rowId === \"new\" ? addRowIds[0] : cursorPos.rowId });\n\n      // Restore the selection if it would select the correct rows.\n      const topRowIndex = this.viewData.getRowIndex(newRowIds[0]);\n      if (newRowIds.every((r, i) => r === this.viewData.getRowId(topRowIndex + i))) {\n        this.cellSelector.selectArea(topRowIndex, leftIndex,\n          topRowIndex + outputHeight - 1, leftIndex + outputWidth - 1);\n      }\n\n      await commands.allCommands.clearCopySelection.run();\n    }\n  }\n\n  /**\n   * Given a matrix of values, and an array of colIds and rowId targets, this function returns\n   * an array of user actions needed to update the targets to the values in the matrix\n   * @param {Array} rowIds - An array of numbers, 'new' or null corresponding to the row ids will\n   * be updated or added. Numerical (proper) rowIds must come before special ones.\n   * @param {Object<string, Array<string>} bulkUpdate - Object from colId to array of column values.\n   */\n  protected _createBulkActionsFromPaste(rowIds: UIRowId[], bulkUpdate: BulkColValues): UserAction[] {\n    if (_.isEmpty(bulkUpdate)) {\n      return [];\n    }\n\n    const addRows = rowIds.filter(rowId => rowId === null || rowId === \"new\").length;\n    const updateRows = rowIds.length - addRows;\n\n    const actions = [];\n    if (addRows > 0) {\n      actions.push([\"BulkAddRecord\", gutil.arrayRepeat(addRows, null),\n        _.mapObject(bulkUpdate, values => values.slice(-addRows)),\n      ]);\n    }\n    if (updateRows > 0) {\n      actions.push([\"BulkUpdateRecord\", rowIds.slice(0, updateRows),\n        _.mapObject(bulkUpdate, values => values.slice(0, updateRows)),\n      ]);\n    }\n    return this.prepTableActions(actions);\n  }\n\n  /**\n   * Returns a CopySelection of the selected rows and cols\n   * @returns {Object} CopySelection\n   */\n  protected getSelection() {\n    const rowIds = [], fields = [];\n    const rowStyle: { [r: number]: object } = {};\n    const colStyle: { [c: string]: object } = {};\n    let colStart = this.cellSelector.colLower();\n    let colEnd = this.cellSelector.colUpper();\n    let rowStart = this.cellSelector.rowLower();\n    let rowEnd = this.cellSelector.rowUpper();\n\n    // If there is no selection, just copy/paste the cursor cell\n    if (this.cellSelector.isCurrentSelectType(selector.NONE)) {\n      rowStart = rowEnd = this.cursor.rowIndex()!;\n      colStart = colEnd = this.cursor.fieldIndex();\n    }\n\n    // Get all the cols if rows are selected, and viceversa\n    if (this.cellSelector.isCurrentSelectType(selector.ROW)) {\n      colStart = 0;\n      colEnd = this.viewSection.viewFields().peekLength - 1;\n    } else if (this.cellSelector.isCurrentSelectType(selector.COL)) {\n      rowStart = 0;\n      rowEnd = this.getLastDataRowIndex();\n    }\n\n    // Start or end will be null if no fields are visible.\n    if (colStart !== null && colEnd !== null) {\n      for (let i = colStart; i <= colEnd; i++) {\n        const field = this.viewSection.viewFields().at(i)!;\n        fields.push(field);\n        colStyle[field.colId()] = this._getColStyle(i);\n      }\n    }\n\n    let rowId;\n    for (let j = rowStart; j <= rowEnd; j++) {\n      rowId = this.viewData.getRowId(j);\n      rowIds.push(rowId);\n      rowStyle[rowId] = this._getRowStyle(j);\n    }\n    return new CopySelection(this.tableModel.tableData, rowIds, fields, {\n      rowStyle: rowStyle,\n      colStyle: colStyle,\n    });\n  }\n\n  /**\n   * Deselects the currently selected cells.\n   */\n  protected clearSelection() {\n    this.copySelection(null); // Unset the selection observable\n    this.cellSelector.setToCursor();\n  }\n\n  /**\n   * Given a selection object, sets all cells referred to by the selection to the empty string. If\n   * only formula columns are selected, only open the formula editor to the empty formula.\n   * @param {CopySelection} selection\n   */\n  protected clearValues(selection: CopySelection) {\n    if (this.isReadonly) {\n      return;\n    }\n\n    const options = this._getColumnMenuOptions(selection);\n    if (options.isFormula === true) {\n      this.activateEditorAtCursor({ init: \"\" });\n    } else {\n      const clearAction = tableUtil.makeDeleteAction(selection);\n      if (clearAction) {\n        return this.gristDoc.docData.sendAction(clearAction);\n      }\n    }\n  }\n\n  protected _clearColumns(selection: CopySelection) {\n    if (this.isReadonly) {\n      return;\n    }\n    const fields = selection.fields;\n    return this.gristDoc.docModel.clearColumns(fields.map(f => f.colRef.peek()));\n  }\n\n  protected _convertFormulasToData(selection: CopySelection) {\n    // Convert all isFormula columns to data, including empty columns. This is sometimes useful\n    // (e.g. since a truly empty column undergoes a conversion on first data entry, which may be\n    // prevented by ACL rules).\n    const fields = selection.fields.filter(f => f.column.peek().isFormula.peek());\n    if (!fields.length) { return null; }\n    return this.gristDoc.docModel.convertIsFormula(fields.map(f => f.colRef.peek()), { toFormula: false });\n  }\n\n  protected selectAll() {\n    this.cellSelector.selectArea(0, 0, Math.max(0, this.getLastDataRowIndex()),\n      this.viewSection.viewFields().peekLength - 1);\n  }\n\n  // End of actions\n\n  // ======================================================================================\n  // GRIDVIEW PRIMITIVES (for manipulating grid, rows/cols, selections)\n\n  /**\n   * Assigns the cursor.rowIndex and cursor.fieldIndex observable to the correct row/column/cell\n   * depending on the supplied dom element.\n   * @param {DOM element} elem - extract the col/row index from the element\n   * @param {Selector.ROW/COL/CELL} elemType - denotes whether the clicked element was\n   *                                           a row header, col header or cell\n   */\n  protected assignCursor(elem: Element, elemType: ElemType) {\n    // Change focus before running command so that the correct viewsection's cursor is moved.\n    this.viewSection.hasFocus(true);\n\n    try {\n      const row = this.domToRowModel(elem, elemType);\n      const col = this.domToColModel(elem, elemType);\n      commands.allCommands.setCursor.run(row, col);\n\n      // Trigger custom dom event that will bubble up. View components might not be rendered\n      // inside a virtual table which don't register this global handler (as there might be\n      // multiple instances of the virtual table component).\n      const event = new CustomEvent(\"setCursor\", { detail: [row, col], bubbles: true });\n      this.scrollPane.dispatchEvent(event);\n    } catch (e) {\n      console.error(e);\n      console.error(\"GridView.assignCursor expects a row/col header, or cell as an input.\");\n    }\n\n    /* CellSelector already updates the selection whenever rowIndex/fieldIndex is changed, but\n     * since those observables don't currently notify subscribers when an unchanged value is\n     * written, there are cases where the selection doesn't get updated. For example, when doing\n     * a click and drag to select cells and then clicking the \"selected\" cell that's outlined in\n     * green, the row/column numbers remain highlighted as if they are still selected, while\n     * GridView indicates the cells are not selected. This causes bugs that range from the\n     * aformentioned visual discrepancy to incorrect copy/paste behavior due to out-of-date\n     * selection ranges.\n     *\n     * We address this by calling setToCursor here unconditionally, but another possible approach\n     * might be to extend rowIndex/fieldIndex to always notify their subscribers. Always notifying\n     * currently introduces some bugs, and we'd also need to check that it doesn't cause too\n     * much unnecessary UI recomputation elsewhere, so in the interest of time we use the first\n     * approach. */\n    this.cellSelector.setToCursor(elemType);\n  }\n\n  /**\n   * Schedules cursor assignment to happen at end of tick. Calling `preventAssignCursor()` before\n   * prevents assignment to happen. This was added to prevent cursor assignment on a `context click`\n   * on a cell that is already selected.\n   */\n  protected scheduleAssignCursor(elem: Element, elemType: ElemType) {\n    this._assignCursorTimeoutId = setTimeout(() => {\n      this.assignCursor(elem, elemType);\n      this._assignCursorTimeoutId = undefined;\n    }, 0);\n  }\n\n  /**\n   * See `scheduleAssignCursor()` for doc.\n   */\n  protected preventAssignCursor() {\n    clearTimeout(this._assignCursorTimeoutId);\n    this._assignCursorTimeoutId = undefined;\n  }\n\n  protected selectedRows() {\n    const selection = this.getSelection();\n    return selection.rowIds.filter((r): r is number => (r !== \"new\"));\n  }\n\n  protected async deleteRows(rowIds: number[]) {\n    const savedCursorPos = this.cursor.getCursorPos();\n    this.cursor.setLive(false);\n    try {\n      await super.deleteRows(rowIds);\n    } finally {\n      // Avoid setting cursor rowId, as the old rowId may have just been deleted!\n      this.cursor.setCursorPos({ rowIndex: savedCursorPos.rowIndex });\n      this.cursor.setLive(true);\n      this.clearSelection();\n    }\n  }\n\n  public async insertColumn(colId: string | null = null, options: InsertColOptions = {}): Promise<NewColInfo> {\n    const {\n      colInfo = {},\n      index = this.viewSection.viewFields().peekLength,\n      skipPopup = false,\n    } = options;\n    const newColInfo = await this.viewSection.insertColumn(colId, { colInfo, index });\n    this.selectColumn(index);\n    if (!skipPopup) { this.currentEditingColumnIndex(index); }\n    // we want to show creator panel in some cases, but only when \"rename panel\" is dismissed\n    const sub = this.currentEditingColumnIndex.subscribe((state) => {\n      // if no column is edited we can assume that rename panel is closed\n      if (state < 0) {\n        options.onPopupClose?.();\n        sub.dispose();\n      }\n    });\n    return newColInfo;\n  }\n\n  protected async makeHeadersFromRow(selection: CopySelection) {\n    if (this._getRowContextMenuOptions().disableMakeHeadersFromRow) {\n      return;\n    }\n    const record = this.tableModel.tableData.getRecord(selection.rowIds[0] as number)!;\n    const actions = this.viewSection.viewFields().peek().reduce((acc: UserAction[], field): UserAction[] => {\n      const col = field.column();\n      const colId = col.colId.peek();\n      let formatter = field.formatter();\n      let newColLabel = record[colId];\n      // Manage column that are references\n      if (col.refTable()) {\n        const refTableDisplayCol = this.gristDoc.docModel.columns.getRowModel(col.displayCol());\n        newColLabel =  record[refTableDisplayCol.colId()];\n        formatter = field.visibleColFormatter();\n      }\n      // Manage column that are lists\n      if (isList(newColLabel)) {\n        newColLabel = newColLabel[1];\n      }\n      if (typeof newColLabel === \"string\") {\n        newColLabel = newColLabel.trim();\n      }\n      // Check value is not empty but accept 0 and false as valid values\n      if (newColLabel !== null && newColLabel !== undefined && newColLabel !== \"\") {\n        return [...acc, [\"ModifyColumn\", colId, { label: formatter.formatAny(newColLabel) }]];\n      }\n      return acc;\n    }, []);\n    return this.tableModel.sendTableActions(actions, \"Use as table headers\");\n  }\n\n  protected renameColumn(index: number) {\n    // If this column is in transformation, renaming is disabled.\n    if (this.currentColumn.peek().isTransforming.peek()) {\n      console.warn(\"Renaming is disabled during column transformation.\");\n      return;\n    }\n    this.currentEditingColumnIndex(index);\n  }\n\n  protected scrollPaneBottom() {\n    this.scrollPane.scrollTop = this.scrollPane.scrollHeight;\n  }\n\n  protected scrollPaneTop() {\n    this.scrollPane.scrollTop = 0;\n  }\n\n  protected scrollPaneRight() {\n    this.scrollPane.scrollLeft = this.scrollPane.scrollWidth;\n  }\n\n  protected scrollPaneLeft() {\n    this.scrollPane.scrollLeft = 0;\n  }\n\n  protected selectColumn(colIndex: number) {\n    this.cursor.fieldIndex(colIndex);\n    this.cellSelector.currentSelectType(selector.COL);\n  }\n\n  public async showColumn(colRef: number,\n    index: number = this.viewSection.viewFields().peekLength,\n  ): Promise<void> {\n    await this.viewSection.showColumn(colRef, index);\n    this.selectColumn(index);\n  }\n\n  // TODO: Replace alerts with custom notifications\n  protected deleteColumns(selection: CopySelection) {\n    const fields = selection.fields;\n    if (fields.length === this.viewSection.viewFields().peekLength) {\n      reportWarning(\"You can't delete all the columns on the grid.\", {\n        key: \"delete-all-columns\",\n      });\n      return Promise.resolve(false);\n    }\n    const columns = fields.filter(col => !col.disableModify());\n    const colRefs = columns.map(col => col.colRef.peek());\n    if (colRefs.length > 0) {\n      return this.gristDoc.docData.sendAction(\n        [\"BulkRemoveRecord\", \"_grist_Tables_column\", colRefs],\n        `Removed columns ${columns.map(col => col.colId.peek()).join(\", \")} ` +\n        `from ${this.tableModel.tableData.tableId}.`,\n      ).then(() => this.clearSelection());\n    }\n    return Promise.resolve(false);\n  }\n\n  protected hideFields(selection: CopySelection) {\n    if (this.gristDoc.isReadonly.get()) {\n      return;\n    }\n\n    const actions = selection.fields.map(field => [\"RemoveRecord\", field.id()]);\n    return this.gristDoc.docModel.viewFields.sendTableActions(actions,\n      `Hide columns ${actions.map(a => a[1]).join(\", \")} ` +\n      `from ${this.tableModel.tableData.tableId}.`);\n  }\n\n  protected moveColumns(oldIndices: number[], newIndex: number) {\n    if (oldIndices.length === 0) { return; }\n    if (oldIndices[0] === newIndex || oldIndices[0] + 1 === newIndex) { return; }\n\n    const newPositions = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), newIndex,\n      oldIndices.length);\n    const vsfRowIds = oldIndices.map((i) => {\n      return this.viewSection.viewFields().at(i)!.id();\n    });\n    const colInfo = { parentPos: newPositions };\n    const vsfAction = [\"BulkUpdateRecord\", vsfRowIds, colInfo];\n    const viewFieldsTable =  this.gristDoc.docModel.viewFields;\n    const numCols = oldIndices.length;\n    const newPos = newIndex < this.cellSelector.colLower() ? newIndex : newIndex - numCols;\n    return viewFieldsTable.sendTableAction(vsfAction)!.then(() => {\n      this.cursor.fieldIndex(newPos);\n      this.cellSelector.currentSelectType(selector.COL);\n      this.cellSelector.col.start(newPos);\n      this.cellSelector.col.end(newPos + numCols - 1);\n    });\n  }\n\n  protected moveRows(oldIndices: number[], newIndex: number) {\n    if (oldIndices.length === 0) { return; }\n    if (oldIndices[0] === newIndex || oldIndices[0] + 1 === newIndex) { return; }\n\n    const newPositions = this._getRowInsertPos(newIndex, oldIndices.length);\n    const rowIds = oldIndices.map(i => this.viewData.getRowId(i));\n    const colInfo = { manualSort: newPositions };\n    const action = [\"BulkUpdateRecord\", rowIds, colInfo];\n    const numRows = oldIndices.length;\n    const newPos = newIndex < this.cellSelector.rowLower() ? newIndex : newIndex - numRows;\n    return this.tableModel.sendTableAction(action)!.then(() => {\n      this.cursor.rowIndex(newPos);\n      this.cellSelector.currentSelectType(selector.ROW);\n      this.cellSelector.row.start(newPos);\n      this.cellSelector.row.end(newPos + numRows - 1);\n    });\n  }\n\n  // ======================================================================================\n  // MISC HELPERS\n\n  /**\n   *  Returns the row index of the row whose top offset is closest to and\n   *  no greater than given y-position.\n   *  param{yCoord}: The mouse y-position (including any scroll top amount).\n   *  Assumes that scrolly.rowOffsetTree is up to date.\n   *  See the given examples in GridView.getMousePosCol.\n   **/\n  protected getMousePosRow(yCoord: number) {\n    const headerOffset = this.header.getBoundingClientRect().bottom;\n    return this.scrolly.rowOffsetTree.getIndex(yCoord - headerOffset);\n  }\n\n  /**\n   *  Returns the row index of the row whose top offset is closest to and\n   *  no greater than given y-position.\n   *  param{yCoord}: The mouse y-position on the screen.\n   **/\n  protected currentMouseRow(yCoord: number) {\n    return Math.min(\n      this.getMousePosRow(this.scrollTop() + yCoord),\n      Math.max(0, this.getLastDataRowIndex() + 1),\n    );\n  }\n\n  /**\n   *  Returns the column index of the column whose left position is closest to and\n   *  no greater than given x-position.\n   *  param{xCoord}: The mouse x-position (absolute position on a page).\n   *  Grid scroll offset and frozen columns are taken into account.\n   *  Assumes that this.colRightOffsets is up to date\n   *  In the following examples, let * denote the current mouse position.\n   *      * |0____|1____|2____|3____|       Returns 0\n   *        |0__*_|1____|2____|3____|       Returns 0\n   *        |0____|1__*_|2____|3____|       Returns 1\n   *        |0____|1____|2__*_|3____|       Returns 2\n   *        |0____|1____|2____|3__*_|       Returns 3\n   *        |0____|1____|2____|3____| *     Returns 4\n   *\n   * For frozen columns and a scrolled view:\n   *      * |0____|1____|..5|6____|         Returns 0\n   *        |0__*_|1____|..5|6____|         Returns 0\n   *        |0____|1__*_|..5|6____|         Returns 1\n   *        |0____|1____|*.5|6____|         Returns 5\n   *        |0____|1____|..5|6__*_|         Returns 6\n   *        |0____|1____|..5|6____| *       Returns 6\n   **/\n  protected getMousePosCol(mouseX: number) {\n    const scrollLeft = this.scrollLeft();\n    // Offset to left edge of gridView viewports\n    const headerOffset = this._cornerDom.getBoundingClientRect().right;\n    // Convert mouse x to grid x (not including scroll yet).\n    // GridX now has x position as if the grid pane is covering\n    // the whole screen, it still can be scrolled, so 0px is not equal to A column yet.\n    const gridX = mouseX - headerOffset;\n    // Total width of frozen columns (if zero, no frozen column set)\n    const frozenWidth = this.frozenWidth.peek();\n    // Frozen columns can be scrolled also, but not more then frozenOffset.\n    const frozenScroll = Math.min(this.frozenOffset.peek(), scrollLeft);\n    // If gridX is in frozen section or outside. Frozen section can be scrolled also\n    // on narrow screens so take this into account.\n    const inFrozen = this.numFrozen.peek() && gridX <= (frozenWidth - frozenScroll);\n    // If grid x (mouse converted to grid pane coordinates) is in frozen area\n    // we need to use frozenScroll value (how much frozen area is scrolled),\n    // but if it is outside we want to take the scroll offset into account.\n    // Here we wil calculate where exactly is mouse (over which column),\n    // to do that, we will pretend that nothing is scrolled - so we need\n    // to move gridX a little to the right, either by grid offset (how much whole grid\n    // is scrolled to the left) or a frozen set offset (how much frozen columns\n    // are scrolled to the left).\n    const scrollX = gridX + (inFrozen ? frozenScroll : scrollLeft);\n    return this.colRightOffsets.peek().getIndex(scrollX);\n  }\n\n  // Used for styling the paste data the same way the col/row is styled in the GridView.\n  protected _getRowStyle(rowIndex: number) {\n    return { height: this.scrolly.rowOffsetTree.getValue(rowIndex) + \"px\" };\n  }\n\n  protected _getColStyle(colIndex: number) {\n    return { width: this.viewSection.viewFields().at(colIndex)!.widthPx() };\n  }\n\n  // TODO: for now lets just assume you are clicking on a .field, .row, or .column\n  public domToRowModel(elem: Element, elemType: Omit<ElemType, \"col\">): DataRowModel;\n  public domToRowModel(elem: Element, elemType: ElemType): DataRowModel | undefined;\n  public domToRowModel(elem: Element, elemType: ElemType): DataRowModel | undefined {\n    switch (elemType) {\n      case selector.COL:\n        return undefined;\n      case selector.ROW: // row > row num: row has record model\n        return ko.utils.domData.get(elem.parentNode!, \"itemModel\");\n      case selector.NONE:\n      case selector.CELL: // cell: row > .record > .field, row holds row model\n        return ko.utils.domData.get(elem.parentNode!.parentNode!, \"itemModel\");\n      default:\n        throw Error(\"Unknown elemType in domToRowModel:\" + elemType);\n    }\n  }\n\n  public domToColModel(elem: Element, elemType: Omit<ElemType, \"row\">): DataRowModel;\n  public domToColModel(elem: Element, elemType: ElemType): DataRowModel | undefined;\n  public domToColModel(elem: Element, elemType: ElemType): DataRowModel | undefined {\n    switch (elemType) {\n      case selector.ROW:\n        return undefined;\n      case selector.NONE:\n      case selector.CELL: // cell: .field has col model\n      case selector.COL:  // col:  .column_name I think\n        return ko.utils.domData.get(elem, \"itemModel\");\n      default:\n        throw Error(\"Unknown elemType in domToRowModel\");\n    }\n  }\n\n  // ======================================================================================\n  // DOM STUFF\n\n  /**\n   * Recalculate various positioning variables.\n   */\n  // TODO : is this necessary? make passive. Also this could be removed soon I think\n  protected onScroll() {\n    const pane = this.scrollPane;\n    this.scrollLeft(pane.scrollLeft);\n    this.scrollTop(pane.scrollTop);\n    this.width(pane.clientWidth);\n  }\n\n  protected buildDom() {\n    const data = this.viewData;\n    const v = this.viewSection;\n    const editIndex = this.currentEditingColumnIndex;\n\n    // each row has toggle classes on these props, so grab them once to save on lookups\n    const vHorizontalGridlines = v.optionsObj.prop(\"horizontalGridlines\");\n    const vVerticalGridlines   = v.optionsObj.prop(\"verticalGridlines\");\n    const vZebraStripes        = v.optionsObj.prop(\"zebraStripes\");\n\n    const renameCommands = {\n      nextField: () => {\n        if (editIndex() === v.viewFields().peekLength - 1) {\n          // Turn off editing if we're on the last field.\n          editIndex(-1);\n        } else {\n          editIndex(editIndex() + 1);\n          this.selectColumn(editIndex.peek());\n        }\n      },\n      prevField: () => {\n        editIndex(editIndex() - 1);\n        this.selectColumn(editIndex.peek());\n      },\n    };\n\n    return dom(\n      \"div.gridview_data_pane.flexvbox\",\n      // offset for frozen columns - how much move them to the left\n      styleCustomVar(\"--frozen-offset\", this.frozenOffset),\n      // total width of frozen columns\n      styleCustomVar(\"--frozen-width\", this.frozenWidth),\n      // Corner, bars and shadows\n      // Corner and shadows (so it's fixed to the grid viewport)\n      this._cornerDom = dom(\n        \"div.gridview_data_corner_overlay\",\n        this._cornerRenderer,\n      ),\n      dom(\"div.scroll_shadow_top\", dom.show(this.scrollShadow.top)),\n      dom(\"div.scroll_shadow_left\",\n        dom.show(this.scrollShadow.left),\n        // pass current scroll position\n        styleCustomVar(\"--frozen-scroll-offset\", this.frozenScrollOffset)),\n      dom(\"div.frozen_line\", dom.show(this.frozenLine)),\n      dom(\"div.gridview_header_backdrop_left\"), // these hide behind the actual headers to keep them from flashing\n      dom(\"div.gridview_header_backdrop_top\"),\n      // When there are frozen columns, right border for number row will not be visible (as actually there is no border,\n      // it comes from the first cell in the grid) making a gap between row-number and actual column. So when we scroll\n      // the content of the scrolled columns will be visible to the user (as there is blank space there).\n      // This line fills the gap. NOTE that we are using number here instead of a boolean.\n      dom(\"div.gridview_left_border\", dom.show(use => Boolean(use(this.numFrozen))),\n        dom.style(\"left\", ROW_NUMBER_WIDTH + \"px\"),\n      ),\n      // left shadow that will be visible on top of frozen columns\n      dom(\"div.scroll_shadow_frozen\", dom.show(this.frozenShadow)),\n      // When cursor leaves the GridView, remove hover immediately (without debounce).\n      // This guards mouse leaving gridView from the top, as leaving from bottom or left, right, is\n      // guarded on the row level.\n      dom.on(\"mouseleave\", () => !this.isDisposed() && this.hoverColumn(-1)),\n      // Drag indicators\n      this.colLine = dom(\n        \"div.col_indicator_line\",\n        kd.show(() => this.cellSelector.isCurrentDragType(selector.COL)),\n        dom.style(\"left\", this.cellSelector.col.linePos),\n      ),\n      this.colShadow = dom(\n        \"div.column_shadow\",\n        kd.show(() => this.cellSelector.isCurrentDragType(selector.COL)),\n        dom.style(\"left\", use => (use(this.dragX) - this.colShadowAdjust) + \"px\"),\n      ),\n      this.rowLine = dom(\n        \"div.row_indicator_line\",\n        kd.show(() => this.cellSelector.isCurrentDragType(selector.ROW)),\n        dom.style(\"top\", this.cellSelector.row.linePos),\n      ),\n      this.rowShadow = dom(\n        \"div.row_shadow\",\n        kd.show(() => this.cellSelector.isCurrentDragType(selector.ROW)),\n        dom.style(\"top\", use => (use(this.dragY) - this.rowShadowAdjust) + \"px\"),\n      ),\n\n      applyRowHeightLimit(v),\n\n      this.scrollPane =\n        dom(\"div.grid_view_data.gridview_data_scroll.show_scrollbar\",\n          kd.scrollChildIntoView(this.visibleRowIndex),\n          dom.onDispose(() => {\n          // Save the previous scroll values to the section.\n            this.viewSection.lastScrollPos = _.extend({\n              scrollLeft: this.scrollPane.scrollLeft,\n            }, this.scrolly.getScrollPos());\n          }),\n\n          // COL HEADER BOX\n          dom(\"div.gridview_stick-top.flexhbox\",   // Sticks to top, flexbox makes child enclose its contents\n            dom(\"div.gridview_corner_spacer\"),\n\n            this.header = dom(\"div.gridview_data_header.flexhbox\", // main header, flexbox floats contents onto a line\n\n              dom(\"div.column_names.record\",\n                dom.style(\"minWidth\", \"100%\"),\n                dom.style(\"borderLeftWidth\", v.borderWidthPx),\n                kd.foreach(v.viewFields(), (field: ViewFieldRec) => {\n                  const canRename = ko.pureComputed(() => !field.column().disableEditData());\n                  const isEditingLabel = koUtil.withKoUtils(ko.pureComputed({\n                    read: () => {\n                      const goodIndex = () => editIndex() === field._index();\n                      const isReadonly = () => this.isReadonly || this.isPreview;\n                      return goodIndex() && !isReadonly();\n                    },\n                    write: (val) => {\n                      if (val) {\n                      // Turn on editing.\n                        editIndex(field._index()!);\n                      } else {\n                      // Turn off editing only if it wasn't changed to another field (e.g. by tabbing).\n                        const isCurrent = editIndex.peek() === field._index.peek();\n                        if (isCurrent) {\n                          editIndex(-1);\n                        }\n                      }\n                    },\n                  }).extend({ rateLimit: 0 })).onlyNotifyUnequal();\n\n                  let filterTriggerCtl: PopupControl;\n                  const isTooltip = ko.pureComputed(() =>\n                    this.editingFormula() && !this.isReadonly &&\n                    ko.unwrap(this.hoverColumn) === field._index(),\n                  );\n\n                  return dom(\n                    \"div.column_name.field\",\n                    dom.autoDispose(canRename),\n                    styleCustomVar(\"--grist-header-color\", use => use(field.headerTextColor) || \"\"),\n                    styleCustomVar(\"--grist-header-background-color\", use => use(field.headerFillColor) || \"\"),\n                    dom.cls(\"font-bold\", use => use(field.headerFontBold) || false),\n                    dom.cls(\"font-italic\", use => use(field.headerFontItalic) || false),\n                    dom.cls(\"font-underline\", use => use(field.headerFontUnderline) || false),\n                    dom.cls(\"font-strikethrough\", use => use(field.headerFontStrikethrough) || false),\n                    kd.style(\"--frozen-position\", () => ko.unwrap(this.frozenPositions.at(field._index()!)!)),\n                    kd.toggleClass(\"frozen\", () => ko.unwrap(this.frozenMap.at(field._index()!)!)),\n                    dom.autoDispose(isEditingLabel),\n                    dom.autoDispose(isTooltip),\n                    oldTestId(\"GridView_columnLabel\"),\n                    (el) => {\n                      const tooltip = new HoverColumnTooltip(el);\n                      return [\n                        dom.autoDispose(tooltip),\n                        dom.autoDispose(isTooltip.subscribe((show) => {\n                          if (show) {\n                            tooltip.show(t(`Click to insert`) + ` $${field.origCol.peek().colId.peek()}`);\n                          } else {\n                            tooltip.hide();\n                          }\n                        })),\n                      ];\n                    },\n                    dom.style(\"width\", field.widthPx),\n                    dom.style(\"borderRightWidth\", v.borderWidthPx),\n                    viewCommon.makeResizable(field.width, { shouldSave: !this.isReadonly }),\n                    kd.toggleClass(\"selected\", () => ko.unwrap(this.isColSelected.at(field._index()!)!)),\n                    dom.on(\"contextmenu\", (ev) => {\n                    // This is a little hack to position the menu the same way as with a click\n                      ev.preventDefault();\n                      const btn = (ev.currentTarget as HTMLElement).querySelector<HTMLElement>(\".g-column-menu-btn\")!;\n                      if (btn) { btn.click(); }\n                    }),\n                    dom(\"div.g-column-label\",\n                      columnHeaderWithInfo(\n                        this.isPreview ? field.label : field.displayLabel,\n                        field.description,\n                        \"column\",\n                      ),\n                      dom.on(\"mousedown\", ev => isEditingLabel() ? ev.stopPropagation() : true),\n                      buildRenameColumn({\n                        field,\n                        isEditing: isEditingLabel,\n                        optCommands: renameCommands,\n                        canRename,\n                      }),\n                    ),\n                    this._showTooltipOnHover(field, isTooltip),\n                    (this.isPreview || this.gridOptions?.colMenu === false) ? null : menuToggle(null,\n                      dom.cls(\"g-column-main-menu\"),\n                      dom.cls(\"g-column-menu-btn\"),\n                      // Prevent mousedown on the dropdown triangle from initiating column drag.\n                      dom.on(\"mousedown\", (ev) => { ev.stopPropagation(); ev.preventDefault(); }),\n                      // Select the column if it's not part of a multiselect.\n                      dom.on(\"click\", ev =>\n                        this.maybeSelectColumn((ev.currentTarget as HTMLElement).parentElement!, field)),\n                      (elem: Element) => {\n                        filterTriggerCtl = setPopupToCreateDom(\n                          elem,\n                          ctl => this._columnFilterMenu(ctl, field, { showAllFiltersButton: true }),\n                          {\n                            attach: \"body\",\n                            placement: \"bottom-start\",\n                            boundaries: \"viewport\",\n                            trigger: [],\n                          },\n                        );\n                      },\n                      menu(ctl => this.columnContextMenu(ctl, this.getSelection(), field, filterTriggerCtl)),\n                      testId(\"column-menu-trigger\"),\n                    ),\n                    dom(\"div.selection\"),\n                    this._buildInsertColumnMenu({ field }),\n                  );\n                }),\n                this.isPreview ? null : (this.isReadonly ? null : () => (\n                  dom(\"div.column_name.mod-add-column.field\",\n                    \"+\",\n                    dom.style(\"width\", PLUS_WIDTH + \"px\"),\n                    this._buildInsertColumnMenu(),\n                  )\n                )),\n              ),\n            ), // end hbox\n          ), // END COL HEADER BOX\n\n          koDomScrolly.scrolly(data, {\n            ...(this._inline ? {} : { paddingBottom: 80, paddingRight: 20 }),\n            cb: this._onRenderedVisibleRows.bind(this),\n          }, renderRow.bind(this)),\n\n          dom.maybe(this._isPrinting, () =>\n            renderAllRows(this.tableModel, this.sortedRows.getKoArray().peek(), renderRow.bind(this)),\n          ),\n        ), // end scrollpane\n    );// END MAIN VIEW BOX\n\n    function renderRow(this: GridView, row: DataRowModel) {\n      // TODO. There are several ways to implement a cursor; similar concerns may arise\n      // when implementing selection and cell editor.\n      // (1) Class on 'div.field.field_clip'. Fewest elements, seems possibly best for\n      //     performance. Problem is: it's impossible to get cursor exactly right with a\n      //     one-sided border. Attaching a cursor as additional element inside the cell\n      //     truncates the cursor to the cell's inside because of 'overflow: hidden'.\n      // (2) 'div.field' with 'div.field_clip' inside, on which a class is toggled. This\n      //     works well. The only concern is whether this slows down rendering. Would be\n      //     good to measure and compare rendering speed.\n      //     Related: perhaps the fastest rendering would be for a table.\n      // (3) Separate element attached to the row, absolutely positioned at left\n      //     position and width of the selected cell. This works too. Requires\n      //     maintaining a list of leftOffsets (or measuring the cell's), and feels less\n      //     clean and more complicated than (2).\n\n      // IsRowActive and isCellActive are a significant optimization. IsRowActive is called\n      // for all rows when cursor.rowIndex changes, but the value only changes for two of the\n      // rows. IsCellActive is only subscribed to columns for the active row. This way, when\n      // the cursor moves, there are (rows+2*columns) calls rather than rows*columns.\n      const isRowActive = ko.computed(() => row._index() === this.cursor.rowIndex());\n\n      const computedFlags = ko.pureComputed(() => {\n        return this.viewSection.rulesColsIds().map((colRef) => {\n          if (row.cells[colRef]) { return row.cells[colRef]() || false; }\n          return false;\n        });\n      });\n\n      const computedRule = koUtil.withKoUtils(ko.pureComputed(() => {\n        if (row._isAddRow() || !row.id()) { return null; }\n        const flags = computedFlags();\n        if (flags.length === 0) { return null; }\n        const styles = this.viewSection.rulesStyles() || [];\n        return { style: new CombinedStyle(styles, flags) };\n      }).extend({ deferred: true }));\n\n      const fillColor = buildStyleOption(this, computedRule, \"fillColor\", \"\");\n      const zebraColor = ko.pureComputed(() => calcZebra(fillColor()));\n      const textColor = buildStyleOption(this, computedRule, \"textColor\", \"\");\n      const fontBold = buildStyleOption(this, computedRule, \"fontBold\", false);\n      const fontItalic = buildStyleOption(this, computedRule, \"fontItalic\", false);\n      const fontUnderline = buildStyleOption(this, computedRule, \"fontUnderline\", false);\n      const fontStrikethrough = buildStyleOption(this, computedRule, \"fontStrikethrough\", false);\n      const hasContextMenu = !this.isPreview && !(\n        this.gridOptions?.rowMenu === false && this.gridOptions.colMenu === false\n      );\n\n      return dom(\"div.gridview_row\",\n        dom.autoDispose(isRowActive),\n        dom.autoDispose(computedFlags),\n        dom.autoDispose(computedRule),\n        dom.autoDispose(textColor),\n        dom.autoDispose(fillColor),\n        dom.autoDispose(zebraColor),\n        dom.autoDispose(fontBold),\n        dom.autoDispose(fontItalic),\n        dom.autoDispose(fontUnderline),\n        dom.autoDispose(fontStrikethrough),\n\n        dom.cls(\"link_selector_row\", use => use(this.isLinkSource) && use(isRowActive)),\n\n        // rowid dom\n        dom(\"div.gridview_data_row_num\",\n          dom.style(\"width\", ROW_NUMBER_WIDTH + \"px\"),\n          dom(\"div.gridview_data_row_info\",\n            dom.cls(\"linked_dst\", (use) => {\n              const myRowId = use(row.id);\n              const linkedRowId = use(this.linkedRowId);\n              // Must ensure that linkedRowId is not null to avoid drawing on rows whose\n              // row ids are null.\n              return Boolean(linkedRowId && linkedRowId === myRowId);\n            }),\n          ),\n          this._rowIndexRenderer(row),\n          dom.domComputed(use => use(row._validationFailures), (failures) => {\n            if (!row._isAddRow() && failures.length > 0) {\n              return dom(\"div.validation_error_number\", String(failures.length),\n                dom.attr(\"title\", (use) => {\n                  return \"Validation failed: \" +\n                    failures.map(val => use(val.name)).join(\", \");\n                }),\n              );\n            }\n          }),\n          dom.on(\"contextmenu\", (ev) => {\n            // This is a little hack to position the menu the same way as with a click,\n            // the same hack as on a column menu.\n            ev.preventDefault();\n            ((ev.currentTarget as HTMLElement).querySelector<HTMLButtonElement>(\".menu_toggle\"))?.click();\n          }),\n          (this.isPreview || this.gridOptions?.rowMenu === false) ? null : menuToggle(null,\n            dom.on(\"click\",\n              ev => this.maybeSelectRow((ev.currentTarget as HTMLElement).parentElement!, row.getRowId())),\n            menu((ctx) => {\n              ctx.autoDispose(isRowActive.subscribe(() => ctx.close()));\n              return this.rowContextMenu();\n            }, { trigger: [\"click\"] }),\n            // Prevent mousedown on the dropdown triangle from initiating row drag.\n            dom.on(\"mousedown\", (ev) => { ev.stopPropagation(); ev.preventDefault(); }),\n            testId(\"row-menu-trigger\"),\n          ),\n          kd.toggleClass(\"selected\", () => this.cellSelector.isRowSelected(row._index()!)),\n        ),\n        dom(\"div.record\",\n          dom.cls(\"record-add\", row._isAddRow),\n          dom.style(\"borderLeftWidth\", v.borderWidthPx),\n          dom.style(\"borderBottomWidth\", v.borderWidthPx),\n          dom.cls(\"font-bold\", fontBold),\n          dom.cls(\"font-underline\", fontUnderline),\n          dom.cls(\"font-italic\", fontItalic),\n          dom.cls(\"font-strikethrough\", fontStrikethrough),\n          styleCustomVar(\"--grist-row-rule-background-color\", fillColor),\n          styleCustomVar(\"--grist-row-rule-background-color-zebra\", zebraColor),\n          styleCustomVar(\"--grist-row-color\", textColor),\n          // These are grabbed from v.optionsObj at start of GridView buildDom\n          kd.toggleClass(\"record-hlines\", vHorizontalGridlines),\n          kd.toggleClass(\"record-vlines\", vVerticalGridlines),\n          kd.toggleClass(\"record-zebra\", vZebraStripes),\n          // even by 1-indexed rownum, so +1 (makes more sense for user-facing display stuff)\n          dom.cls(\"record-even\", use => (use(row._index)! + 1) % 2 === 0),\n\n          dom.on(\"mouseleave\", (ev) => {\n            // Leave only when leaving record row.\n            if (!ev.relatedTarget || !(ev.relatedTarget as HTMLElement).classList.contains(\"record\")) {\n              this.changeHover(-1);\n            }\n          }),\n          !hasContextMenu ? null : contextMenu((ctx) => {\n            // We need to close the menu when the row is removed, but the dom of the row is not\n            // disposed when the record is removed (this is probably due to how scrolly work). Hence,\n            // we need to subscribe to `isRowActive` to close the menu.\n            ctx.autoDispose(isRowActive.subscribe(() => ctx.close()));\n            return this.cellContextMenu();\n          }),\n          this.comparison ? kd.cssClass(() => {\n            const rowType = this.extraRows.getRowType(row.id());\n            return rowType && `diff-${rowType}` || \"\";\n          }) : null,\n\n          kd.foreach(v.viewFields(), (field: ViewFieldRec) => {\n            // Whether the cell has a cursor (possibly in an inactive view section).\n            const isCellSelected = ko.computed(() =>\n              isRowActive() &&\n              field._index() === this.cursor.fieldIndex() &&\n              this._insertColumnIndex() === null,\n            );\n\n            // Whether the cell is active: has the cursor in the active section.\n            const isCellActive = ko.computed(() => isCellSelected() && v.hasFocus());\n\n            // Whether the cell is part of an active copy-paste operation.\n            const isCopyActive = ko.computed(() => {\n              return this.copySelection()?.isCellSelected(row.id(), field.colId());\n            });\n            const fieldBuilder = this.fieldBuilders.at(field._index()!)!;\n            const isSelected = ko.computed(() => {\n              return !this.cellSelector.isCurrentSelectType(selector.NONE) &&\n                ko.unwrap(this.isColSelected.at(field._index()!)!) &&\n                this.cellSelector.isRowSelected(row._index()!);\n            });\n\n            const isTooltip = ko.pureComputed(() =>\n              this.editingFormula() && !this.isReadonly &&\n              ko.unwrap(this.hoverColumn) === field._index(),\n            );\n\n            return dom(\n              \"div.field\",\n              dom.cls(\"field-insert-before\", use =>\n                use(this._insertColumnIndex) === use(field._index)),\n              kd.style(\"--frozen-position\", () => ko.unwrap(this.frozenPositions.at(field._index()!)!)),\n              kd.toggleClass(\"frozen\", () => ko.unwrap(this.frozenMap.at(field._index()!)!)),\n              kd.toggleClass(\"scissors\", isCopyActive),\n              dom.autoDispose(isCopyActive),\n              dom.autoDispose(isCellSelected),\n              dom.autoDispose(isCellActive),\n              dom.autoDispose(isSelected),\n              dom.autoDispose(isTooltip),\n              this._showTooltipOnHover(field, isTooltip),\n              kd.style(\"width\", field.widthPx),\n              // TODO: Ensure that fields in a row resize when\n              // a cell in that row becomes larger\n              kd.style(\"borderRightWidth\", v.borderWidthPx),\n              kd.toggleClass(\"selected\", isSelected),\n              // Optional icon. Currently only use to show formula icon.\n              dom(\"div.field-icon\"),\n              fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected),\n              dom(\"div.selection\"),\n            );\n          }),\n        ),\n      );\n    }\n  }\n\n  public onNewRecordRequest() {\n    return this.insertRow();\n  }\n\n  public override onResize() {\n    const activeFieldBuilder = this.activeFieldBuilder();\n    let height: number | null = null;\n    if (isNarrowScreen()) {\n      height = window.outerHeight;\n    }\n    if (activeFieldBuilder?.isEditorActive()) {\n      // When the editor is active, the common case for a resize is if the virtual keyboard is being\n      // shown on mobile device. In that case, we need to scroll active cell into view, and need to\n      // do it synchronously, to allow repositioning the editor to it in response to the same event.\n      this.scrolly.updateSize(height);\n      this.scrolly.scrollRowIntoView(this.cursor.rowIndex.peek());\n    } else {\n      this.scrolly.scheduleUpdateSize(height);\n    }\n    this.width(this.scrollPane.clientWidth);\n  }\n\n  /** @inheritdoc */\n  public override onRowResize(rowModels: BaseRowModel[]): void {\n    this.scrolly.resetItemHeights(rowModels);\n  }\n\n  protected onLinkFilterChange() {\n    super.onLinkFilterChange();\n    this.clearSelection();\n  }\n\n  protected _onRenderedVisibleRows(heights?: number[]) {\n    // Store heights from scrolly for use in onTableLoaded\n    if (heights && heights.length > 0) {\n      this._rowHeights = heights;\n    }\n  }\n\n  protected onCellContextMenu(ev: Event, elem: Element) {\n    const row = this.domToRowModel(elem, selector.CELL);\n    const col = this.domToColModel(elem, selector.CELL);\n\n    if (this.cellSelector.containsCell(row._index()!, col._index()!)) {\n      // contextmenu event could be preceded by a mousedown event (ie: when ctrl+click on\n      // mac) which triggers a cursor assignment that we need to prevent.\n      this.preventAssignCursor();\n    } else {\n      this.assignCursor(elem, selector.NONE);\n    }\n  }\n\n  // ======================================================================================\n  // SELECTOR STUFF\n\n  /**\n   * Returns a pure computed boolean that determines whether the given column is selected.\n   * @param {view field object} col - the column to create an observable for\n   **/\n  protected _createColSelectedObs(col: ViewFieldRec) {\n    return ko.pureComputed(() => {\n      return this.cellSelector.isCurrentSelectType(selector.ROW) ||\n        gutil.between(col._index()!, this.cellSelector.col.start(),\n          this.cellSelector.col.end());\n    });\n  }\n\n  // Callbacks for mouse events for the selector object\n\n  protected cellMouseDown(elem: HTMLElement, event: MouseEvent) {\n    const col = this.domToColModel(elem, selector.CELL);\n    if (this.hoverColumn() === col._index()) {\n      return this._tooltipMouseDown(elem, selector.CELL);\n    }\n\n    if (event.shiftKey) {\n      // Change focus before running command so that the correct viewsection's cursor is moved.\n      this.viewSection.hasFocus(true);\n      const row = this.domToRowModel(elem, selector.CELL);\n      this.cellSelector.selectArea(this.cursor.rowIndex()!, this.cursor.fieldIndex(),\n        row._index()!, col._index()!);\n    } else {\n      this.assignCursor(elem, selector.NONE);\n    }\n  }\n\n  protected colMouseDown(elem: HTMLElement, event: MouseEvent) {\n    const col = this.domToColModel(elem, selector.COL);\n    if (this.hoverColumn() === col._index()) {\n      return this._tooltipMouseDown(elem, selector.COL);\n    }\n\n    this._colClickTime = Date.now();\n    this.assignCursor(elem, selector.COL);\n    // Clicking the column header selects all rows except the add row.\n    this.cellSelector.row.end(this.getLastDataRowIndex());\n  }\n\n  protected _tooltipMouseDown(elem: HTMLElement, elemType: ElemType) {\n    const row = this.domToRowModel(elem, elemType);\n    const col = this.domToColModel(elem, elemType);\n    // FormulaEditor.ts overrides this command to insert the column id of the clicked column.\n    commands.allCommands.setCursor.run(row, col);\n  }\n\n  protected rowMouseDown(elem: HTMLElement, event: MouseEvent) {\n    if (event.shiftKey) {\n      this.cellSelector.currentSelectType(selector.ROW);\n      this.cellSelector.row.end(this.currentMouseRow(event.pageY));\n    } else {\n      this.assignCursor(elem, selector.ROW);\n    }\n  }\n\n  protected rowMouseMove(event: MouseEvent) {\n    this.cellSelector.row.end(this.currentMouseRow(event.pageY));\n  }\n\n  protected colMouseMove(event: MouseEvent) {\n    if (this.editingFormula()) { return; }\n\n    const currentCol = Math.min(this.getMousePosCol(event.pageX),\n      this.viewSection.viewFields().peekLength - 1);\n    this.cellSelector.col.end(currentCol);\n  }\n\n  protected cellMouseMove(event: MouseEvent) {\n    if (this.editingFormula()) { return; }\n\n    this.colMouseMove(event);\n    this.rowMouseMove(event);\n    // Maintain single cells cannot be selected invariant\n    if (this.cellSelector.onlyCellSelected(this.cursor.rowIndex()!, this.cursor.fieldIndex())) {\n      this.cellSelector.currentSelectType(selector.NONE);\n    } else {\n      this.cellSelector.currentSelectType(selector.CELL);\n    }\n  }\n\n  protected createSelector() {\n    this.cellSelector = new selector.CellSelector(this);\n  }\n\n  // buildDom needs some of the row/col/cell selector observables to exist beforehand\n  // but we can't attach any of the mouse handlers in the Selector class until the\n  // dom elements exist so we attach the selector handlers separately from instantiation\n  protected attachSelectorHandlers() {\n    const ignoreEvent = (event: MouseEvent, elem: HTMLElement) => (\n      event.button !== 0 ||\n      (event.target as HTMLElement).classList.contains(\"ui-resizable-handle\") ||\n      // This is a bit of a hack to prevent dragging when there's an open column menu\n      // TODO: disable dragging when there is an open cell context menu as well\n      !this.ctxMenuHolder.isEmpty()\n    );\n\n    this.autoDispose(mouseDragMatchElem(this.viewPane, \".gridview_data_row_num\", (event, elem) => {\n      if (!ignoreEvent(event, elem)) {\n        if (!this.cellSelector.isSelected(elem, selector.ROW)) {\n          this.rowMouseDown(elem, event);\n          return {\n            onMove: ev => this.rowMouseMove(ev),\n            onStop: (ev) => {},\n          };\n        } else if (!this.viewSection.disableDragRows()) {\n          this.styleRowDragElements(elem, event);\n          return {\n            onMove: ev => this.dragRows(ev),\n            onStop: ev => this.dropRows(),\n          };\n        }\n      }\n      return null;\n    }));\n\n    // Trigger on column headings but not on the add column button\n    this.autoDispose(mouseDragMatchElem(this.viewPane, \".column_name.field:not(.mod-add-column)\", (event, elem) => {\n      if (!ignoreEvent(event, elem)) {\n        if (!this.cellSelector.isSelected(elem, selector.COL)) {\n          this.colMouseDown(elem, event);\n          return {\n            onMove: ev => this.colMouseMove(ev),\n            onStop: (ev) => {},\n          };\n        } else {\n          this.styleColDragElements(elem, event);\n          return {\n            onMove: ev => this.dragCols(ev),\n            onStop: ev => this.dropCols(),\n          };\n        }\n      }\n      return null;\n    }));\n\n    this.autoDispose(mouseDragMatchElem(this.scrollPane, \".field:not(.column_name)\", (event, elem) => {\n      if (!ignoreEvent(event, elem)) {\n        this.cellMouseDown(elem, event);\n        return {\n          onMove: ev => this.cellMouseMove(ev),\n          onStop: (ev) => {},\n        };\n      }\n      return null;\n    }));\n  }\n\n  // End of Selector stuff\n\n  // ============================================================================\n  // DRAGGING LOGIC\n\n  protected styleRowDragElements(elem: HTMLElement, event: MouseEvent) {\n    const rowStart = this.cellSelector.rowLower();\n    const rowEnd = this.cellSelector.rowUpper();\n    const shadowHeight = this.scrolly.rowOffsetTree.getCumulativeValueRange(rowStart, rowEnd + 1);\n    const shadowTop = (this.header.getBoundingClientRect().height +\n      this.scrolly.rowOffsetTree.getSumTo(rowStart) - this.scrollTop());\n\n    this.rowLine.style.top = shadowTop + \"px\";\n    this.rowShadow.style.top = shadowTop + \"px\";\n    this.rowShadow.style.height = shadowHeight + \"px\";\n    this.rowShadowAdjust = event.pageY - shadowTop;\n    this.cellSelector.currentDragType(selector.ROW);\n    this.cellSelector.row.dropIndex(this.cellSelector.rowLower());\n  }\n\n  protected styleColDragElements(elem: HTMLElement, event: MouseEvent) {\n    this._colClickTime = Date.now();\n    const colStart = this.cellSelector.colLower();\n    const colEnd = this.cellSelector.colUpper();\n    const shadowWidth = this.colRightOffsets.peek().getCumulativeValueRange(colStart, colEnd + 1);\n    const shadowLeft = (ROW_NUMBER_WIDTH + this.colRightOffsets.peek().getSumTo(colStart) - this.scrollLeft());\n\n    this.colLine.style.left = shadowLeft + \"px\";\n    this.colShadow.style.left = shadowLeft + \"px\";\n    this.colShadow.style.width = shadowWidth + \"px\";\n    this.colShadowAdjust = event.pageX - shadowLeft;\n    this.cellSelector.currentDragType(selector.COL);\n    this.cellSelector.col.dropIndex(this.cellSelector.colLower());\n  }\n\n  /**\n   * GridView.dragRows/dragCols update the row/col shadow and row/col indicator line on mousemove events.\n   * Rules for determining where the indicator line should show while dragging cols/rows:\n   * 0) The indicator line should not appear after the special add-row.\n   * 1) If the mouse position is within the selected range -> the indicator line should show\n   *    at the left offset of the start of the select range\n   * 2) If the mouse position comes after the select range -> increment the computed dropIndex by 1\n   * 3) If the last col/row is in the select range, the indicator line should be clamped to the start of the\n   *    select range.\n   **/\n  protected dragRows(event: MouseEvent) {\n    let dropIndex = Math.min(this.getMousePosRow(event.pageY + this.scrollTop()),\n      this.getLastDataRowIndex());\n    if (this.cellSelector.containsRow(dropIndex)) {\n      dropIndex = this.cellSelector.rowLower();\n    } else if (dropIndex > this.cellSelector.rowUpper()) {\n      dropIndex += 1;\n    }\n    if (this.cellSelector.rowUpper() === this.viewData.peekLength - 1) {\n      dropIndex = Math.min(dropIndex, this.cellSelector.rowLower());\n    }\n\n    const linePos = this.scrolly.rowOffsetTree.getSumTo(dropIndex) +\n      this.header.getBoundingClientRect().height - this.scrollTop();\n    this.cellSelector.row.linePos(linePos + \"px\");\n    this.cellSelector.row.dropIndex(dropIndex);\n    this.dragY(event.pageY);\n  }\n\n  protected dragCols(event: MouseEvent) {\n    let dropIndex = Math.min(this.getMousePosCol(event.pageX),\n      this.viewSection.viewFields().peekLength - 1);\n    if (this.cellSelector.containsCol(dropIndex)) {\n      dropIndex = this.cellSelector.colLower();\n    } else if (dropIndex > this.cellSelector.colUpper()) {\n      dropIndex += 1;\n    }\n    if (this.cellSelector.colUpper() === this.viewSection.viewFields().peekLength - 1) {\n      dropIndex = Math.min(dropIndex, this.cellSelector.colLower());\n    }\n\n    let linePos = ROW_NUMBER_WIDTH + this.colRightOffsets.peek().getSumTo(dropIndex);\n    // If there are frozen columns and dropIndex (column index) is inside the frozen set.\n    const frozenCount = this.numFrozen();\n    const inFrozen = frozenCount > 0 && dropIndex < frozenCount;\n    const scrollLeft = this.scrollLeft();\n    // Move line left by the number of pixels the frozen set is scrolled.\n    if (inFrozen) {\n      linePos -= Math.min(this.frozenOffset.peek(), scrollLeft);\n    } else {\n      // Else move left by the whole amount.\n      linePos -= scrollLeft;\n    }\n    this.cellSelector.col.linePos(linePos + \"px\");\n    this.cellSelector.col.dropIndex(dropIndex);\n    this.dragX(event.pageX);\n  }\n\n  protected dropRows() {\n    const oldIndices = _.range(this.cellSelector.rowLower(), this.cellSelector.rowUpper() + 1);\n    this.moveRows(oldIndices, this.cellSelector.row.dropIndex())?.catch(reportError);\n    this.cellSelector.currentDragType(selector.NONE);\n  }\n\n  protected dropCols() {\n    const oldIndices = _.range(this.cellSelector.colLower(), this.cellSelector.colUpper() + 1);\n    const idx = this.cellSelector.col.dropIndex();\n    this.moveColumns(oldIndices, idx)?.catch(reportError);\n    // If this was a short click on a single already-selected column that results in no\n    // column movement, propose renaming the column.\n    if (Date.now() - this._colClickTime < SHORT_CLICK_IN_MS && oldIndices.length === 1 &&\n      idx === oldIndices[0]) {\n      commands.allCommands.renameField.run();\n    }\n    this._colClickTime = 0;\n    this.cellSelector.currentDragType(selector.NONE);\n  }\n\n  // End of Dragging logic\n\n  // ===========================================================================\n  // CONTEXT MENUS\n\n  protected columnContextMenu(\n    ctl: IOpenController, copySelection: CopySelection, field: ViewFieldRec, filterTriggerCtl: PopupControl,\n  ) {\n    const selectedColIds = copySelection.colIds;\n    this.ctxMenuHolder.autoDispose(ctl);\n    const options = this._getColumnMenuOptions(copySelection);\n\n    if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) {\n      return buildMultiColumnMenu(options);\n    } else {\n      return buildColumnContextMenu({\n        filterOpenFunc: () => filterTriggerCtl.open(),\n        sortSpec: this.gristDoc.viewModel.activeSection.peek().activeSortSpec.peek(),\n        colRowId: field.column.peek().id.peek(),\n        ...options,\n      });\n    }\n  }\n\n  protected _getColumnMenuOptions(copySelection: CopySelection): IMultiColumnContextMenu {\n    return {\n      columnIndices: copySelection.fields.map(f => f._index()!),\n      totalColumnCount: this.viewSection.viewFields.peek().peekLength,\n      numColumns: copySelection.fields.length,\n      numFrozen: this.viewSection.numFrozen.peek(),\n      disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()),\n      isReadonly: this.isReadonly || this.isPreview,\n      isRaw: this.viewSection.isRaw(),\n      isFiltered: this.isFiltered(),\n      isFormula: calcFieldsCondition(copySelection.fields, f => f.column.peek().isRealFormula.peek()),\n    };\n  }\n\n  protected _columnFilterMenu(ctl: IOpenController, field: ViewFieldRec, options: IColumnFilterMenuOptions) {\n    this.ctxMenuHolder.autoDispose(ctl);\n    const filterInfo = this.viewSection.filters()\n      .find(({ fieldOrColumn }) => fieldOrColumn.origCol().origColRef() === field.column().origColRef())!;\n    if (!filterInfo.isFiltered.peek()) {\n      // This is a new filter - initialize its spec and pin it.\n      this.viewSection.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), {\n        filter: NEW_FILTER_JSON,\n        pinned: true,\n      });\n    }\n    return this.createFilterMenu(ctl, filterInfo, options);\n  }\n\n  protected maybeSelectColumn(elem: Element, field: ViewFieldRec) {\n    // Change focus before running command so that the correct viewsection's cursor is moved.\n    this.viewSection.hasFocus(true);\n    const selectedColIds = this.getSelection().colIds;\n    if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) {\n      return; // No need to select the column because it's included in the multi-selection\n    }\n    this.assignCursor(elem, selector.COL);\n  }\n\n  protected maybeSelectRow(elem: Element, rowId: number) {\n    // Change focus before running command so that the correct viewsection's cursor is moved.\n    this.viewSection.hasFocus(true);\n    // If the clicked row was not already in the selection, move the selection to the row.\n    if (!this.getSelection().rowIds.includes(rowId)) {\n      this.assignCursor(elem, selector.ROW);\n    }\n  }\n\n  protected rowContextMenu() {\n    const options = this._getRowContextMenuOptions();\n    return this.customRowMenu(RowContextMenu(options), options);\n  }\n\n  protected _getRowContextMenuOptions(): IRowContextMenu {\n    return {\n      ...this._getCellContextMenuOptions(),\n      disableShowRecordCard: this.isRecordCardDisabled(),\n      disableAnchorLink: this.viewSection.isVirtual(),\n      disableMakeHeadersFromRow: Boolean(\n        this.isReadonly ||\n        this.getSelection().rowIds.length !== 1 ||\n        this.getSelection().onlyAddRowSelected() ||\n        this.viewSection.table().summarySourceTable() !== 0,\n      ),\n    };\n  }\n\n  public isRecordCardDisabled(): boolean {\n    return super.isRecordCardDisabled() ||\n      this.getSelection().onlyAddRowSelected() ||\n      this.viewSection.isVirtual();\n  }\n\n  protected cellContextMenu() {\n    const options = this._getCellContextMenuOptions();\n    return this.customCellMenu(\n      CellContextMenu(\n        options,\n        this._getColumnMenuOptions(this.getSelection()),\n      ),\n      options,\n    );\n  }\n\n  protected _getCellContextMenuOptions(): ICellContextMenu {\n    return {\n      disableInsert: Boolean(\n        this.isReadonly ||\n        this.viewSection.disableAddRemoveRows() ||\n        this.tableModel.tableMetaRow.onDemand(),\n      ),\n      disableDelete: Boolean(\n        this.isReadonly ||\n        this.viewSection.disableAddRemoveRows() ||\n        this.getSelection().onlyAddRowSelected(),\n      ),\n      disableAnchorLink: this.viewSection.isVirtual(),\n      isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,\n      numRows: this.getSelection().rowIds.length,\n      onlyAddRowSelected: this.getSelection().onlyAddRowSelected(),\n    };\n  }\n\n  // End Context Menus\n\n  public async scrollToCursor(sync = true) {\n    return kd.doScrollChildIntoView(this.scrollPane, this.cursor.rowIndex(), sync);\n  }\n\n  protected async _duplicateRows(): Promise<number[] | undefined> {\n    const addRowIds = await super._duplicateRows();\n    if (!addRowIds || addRowIds.length === 0) {\n      return;\n    }\n\n    // Highlight duplicated rows if the grid is not sorted (or the sort doesn't affect rowIndex).\n    const topRowIndex = this.viewData.getRowIndex(addRowIds[0]);\n    // Set row on the first record added.\n    this.setCursorPos({ rowId: addRowIds[0] });\n    // Highlight inserted area (if we inserted rows in correct order)\n    if (addRowIds.every((r, i) => r === this.viewData.getRowId(topRowIndex + i))) {\n      this.cellSelector.selectArea(topRowIndex, 0,\n        topRowIndex + addRowIds.length - 1, this.viewSection.viewFields().peekLength - 1);\n    }\n  }\n\n  protected _clearCopySelection() {\n    this.copySelection(null);\n  }\n\n  protected _showTooltipOnHover(field: ViewFieldRec, isShowingTooltip: ko.Computed<boolean>) {\n    return [\n      kd.toggleClass(\"hover-column\", isShowingTooltip),\n      dom.on(\"mouseenter\", () => {\n        this.changeHover(field._index()!);\n      }),\n      dom.on(\"mousedown\", (ev) => {\n        if (isShowingTooltip()) {\n          ev.preventDefault();\n        }\n      }),\n    ];\n  }\n\n  protected _scrollColumnIntoView(colIndex: number) {\n    // If there are some frozen columns.\n    if (this.numFrozen.peek() && colIndex < this.numFrozen.peek()) { return; }\n\n    if (colIndex === 0) {\n      this.scrollPaneLeft();\n    } else if (colIndex === this.viewSection.viewFields().peekLength - 1) {\n      this.scrollPaneRight();\n    } else {\n      const offset = this.colRightOffsets.peek().getSumTo(colIndex);\n\n      const rowNumsWidth = this._cornerDom.clientWidth;\n      const viewWidth = this.scrollPane.clientWidth - rowNumsWidth;\n      const fieldWidth = this.colRightOffsets.peek().getValue(colIndex) + 1; // +1px border\n\n      // Left and right pixel edge of 'viewport', starting from edge of row nums.\n      const frozenWidth = this.frozenWidth.peek();\n      const leftEdge = this.scrollPane.scrollLeft + frozenWidth;\n      const rightEdge = leftEdge + (viewWidth - frozenWidth);\n\n      // If cell doesn't fit onscreen, scroll to fit.\n      const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth);\n      this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift;\n    }\n  }\n\n  /**\n   * Attaches the Add Column menu.\n   *\n   * The menu can be triggered in two ways, depending on the presence of a `field`\n   * in `options`.\n   *\n   * If a field is present, the menu is triggered only when `_insertColumnIndex` is set\n   * to the index of the field the menu is attached to.\n   *\n   * If a field is not present, the menu is triggered either when `_insertColumnIndex`\n   * is set to `-1` or when the attached element is clicked. In practice, there will\n   * only be one element attached this way: the \"+\" field, which appears at the end of\n   * the GridView.\n   */\n  protected _buildInsertColumnMenu(options: { field?: ViewFieldRec } = {}) {\n    const { field } = options;\n    const triggers: \"click\"[] = [];\n    if (!field) { triggers.push(\"click\"); }\n\n    return [\n      field ? kd.toggleClass(\"field-insert-before\", () =>\n        this._insertColumnIndex() === field._index()) : null,\n      menu(\n        (ctl) => {\n          ctl.onDispose(() => this._insertColumnIndex(null));\n\n          let index: number | null | undefined = this._insertColumnIndex.peek();\n          if (index === null || index === -1) {\n            index = undefined;\n          }\n\n          return [\n            buildAddColumnMenu(this, index),\n            (elem) => { FocusLayer.create(ctl, { defaultFocusElem: elem, pauseMousetrap: true }); },\n            testId(\"new-columns-menu\"),\n          ];\n        },\n        {\n          modifiers: {\n            offset: {\n              offset: \"8,8\",\n            },\n          },\n          selectOnOpen: true,\n          trigger: [\n            ...triggers,\n            (elem, ctl) => {\n              ctl.autoDispose(this._insertColumnIndex.subscribe((index) => {\n                if (field?._index() === index || (!field && index === -1)) {\n                  ctl.open();\n                } else if (!ctl.isDisposed()) {\n                  ctl.close();\n                }\n              }));\n            },\n          ],\n        },\n      ),\n    ];\n  }\n\n  protected _openInsertColumnMenu(columnIndex: number) {\n    if (columnIndex < this.viewSection.viewFields().peekLength) {\n      this._scrollColumnIntoView(columnIndex);\n      this._insertColumnIndex(columnIndex);\n    } else {\n      this.scrollPaneRight();\n      this._insertColumnIndex(-1);\n    }\n  }\n\n  protected _insertField(event: KeyboardEvent | undefined, index: number) {\n    if (this.gristDoc.isReadonly.get()) {\n      return;\n    }\n\n    if (!event) {\n      this._openInsertColumnMenu(index);\n    } else {\n      return this.insertColumn(null, { index });\n    }\n  }\n\n  protected _deleteFields() {\n    if (this.gristDoc.isReadonly.get()) {\n      return;\n    }\n\n    const selection = this.getSelection();\n    const count = selection.colIds.length;\n    return this.deleteColumns(selection).then((result) => {\n      if (result !== false) {\n        reportUndo(this.gristDoc, `You deleted ${count} column${count > 1 ? \"s\" : \"\"}.`);\n      }\n    });\n  }\n\n  protected _applyAutoWidth() {\n    const viewPane = this.viewPane as HTMLElement | undefined;\n    if (!viewPane) { return; }\n    const fields = this.viewSection.viewFields.peek().all();\n    if (!fields.length) { return; }\n    const clips = Array.from(viewPane.querySelectorAll<HTMLElement>(\".field_clip, .column_name\"));\n    if (clips.length === 0) { return; }\n    // Very crude way of measuring widths of each column by measuring each cell content.\n    const widthsList = clips.map((elem) => {\n      const range = document.createRange();\n      range.selectNodeContents(elem);\n      const rect = range.getBoundingClientRect();\n      return rect.width;\n    });\n\n    const numFields = fields.length;\n    const fieldWidths = new Map<number, number[]>();\n    for (let i = 0; i < widthsList.length; i++) {\n      const fieldIndex = i % numFields;\n      if (!fieldWidths.has(fieldIndex)) {\n        fieldWidths.set(fieldIndex, []);\n      }\n      fieldWidths.get(fieldIndex)!.push(widthsList[i]);\n    }\n\n    const fieldMaxWidths = new Map<number, number>();\n    for (const [fieldIndex, widths] of fieldWidths.entries()) {\n      const maxWidth = Math.ceil(widths.reduce((a, b) => Math.max(a, b), 0));\n      fieldMaxWidths.set(fieldIndex, maxWidth);\n    }\n\n    bundleChanges(() => {\n      fields.forEach((field, index) => {\n        const maxWidth = fieldMaxWidths.get(index) || 100;\n        field.width(maxWidth + 20);\n      });\n    });\n  }\n}\n\n// Provide a type-safe wrapper around _.unzip\nfunction unzipPasteData(pasteData: PasteData): PasteData {\n  return _.unzip<any>(pasteData) as PasteData;\n}\n\n// Provide a type-safe wrapper around growMatrix\nfunction growPasteDataMatrix(pasteData: PasteData, r: number, c: number): PasteData {\n  return gutil.growMatrix<any>(pasteData, r, c) as PasteData;\n}\n\ninterface ComputedRule {\n  style: CombinedStyle;\n}\n\nfunction buildStyleOption<Name extends keyof CombinedStyle, T>(\n  owner: Disposable, computedRule: ko.Computed<ComputedRule | null>, optionName: Name, defValue: T,\n): ko.Computed<Exclude<CombinedStyle[Name], undefined> | T> {\n  return ko.computed(() => {\n    if (owner.isDisposed()) { return defValue; }\n    const rule = computedRule();\n    if (!rule?.style) { return defValue; }\n    return (rule.style[optionName] as Exclude<CombinedStyle[Name], undefined>) || defValue;\n  });\n}\n\n// Helper to show tooltip over column selection in the full edit mode.\nclass HoverColumnTooltip {\n  public tooltip: ITooltipControl | null = null;\n  constructor(public el: HTMLElement) {\n  }\n\n  public show(text: string) {\n    this.hide();\n    this.tooltip = showTooltip(this.el, () => dom(\"span\", text, testId(\"column-formula-tooltip\")));\n  }\n\n  public hide() {\n    if (this.tooltip) {\n      this.tooltip.close();\n      this.tooltip = null;\n    }\n  }\n\n  public dispose() {\n    this.hide();\n  }\n}\n\n// Simple function that calculates good color for zebra stripes.\nfunction calcZebra(hex: string) {\n  if (hex?.length !== 7) { return hex; }\n  // HSL: [HUE, SATURATION, LIGHTNESS]\n  const hsl = convert.hex.hsl(hex.substr(1));\n  // For bright color, we will make it darker. Value was picked by hand, to\n  // produce #f8f8f8f out of #ffffff.\n  if (hsl[2] > 50) {\n    hsl[2] -= 2.6;\n  } else if (hsl[2] > 1) {\n    // For darker color, we will make it brighter. Value was picked by hand to look\n    // good for the darkest colors in our palette.\n    hsl[2] += 11;\n  } else {\n    // For very dark colors\n    hsl[2] += 16;\n  }\n  return `#${convert.hsl.hex(hsl)}`;\n}\n\n// Currently dom.style('--custom-prop', value) from grainjs doesn't work for \"custom variable\"\n// properties, so we add a helper to do that. TODO: fix grainjs to support this.\nfunction styleCustomVar(property: string, valueObs: BindableValue<string | number>): DomElementMethod {\n  return elem => subscribeElem(elem, valueObs, val => elem.style.setProperty(property, String(val)));\n}\n\nfunction scrollBar(): { width: number; height: number } {\n  // Currently this feature is limited to suggestions only in virtual tables, that are showing\n  // inline grids. For this sake we can assume that scrollbars are always visible and have\n  // standard width/height across all browsers.\n  // Tested on Chrome/FF Linux/Windows/MacOS.\n  return { width: 13, height: 13 };\n}\n"
  },
  {
    "path": "app/client/components/GristClientSocket.ts",
    "content": "import { Socket as EIOSocket } from \"engine.io-client\";\nimport WS from \"ws\";\n\nexport interface GristClientSocketOptions {\n  headers?: Record<string, string>;\n}\n\nexport class GristClientSocket {\n  // Exactly one of _wsSocket and _eioSocket will be set at any one time.\n  private _wsSocket: WS.WebSocket | WebSocket | undefined;\n  private _eioSocket: EIOSocket | undefined;\n\n  // Set to true if a WebSocket connection (in _wsSocket) was succesfully\n  // established. Errors from the underlying WebSocket are not forwarded to\n  // the client until that point, in case we end up downgrading to Engine.IO.\n  private _wsConnected: boolean = false;\n\n  private _messageHandler: null | ((data: string) => void);\n  private _openHandler: null | (() => void);\n  private _errorHandler: null | ((err: Error) => void);\n  private _closeHandler: null | (() => void);\n\n  constructor(private _url: string, private _options?: GristClientSocketOptions) {\n    this._createWSSocket();\n  }\n\n  public set onmessage(cb: null | ((data: string) => void)) {\n    this._messageHandler = cb;\n  }\n\n  public set onopen(cb: null | (() => void)) {\n    this._openHandler = cb;\n  }\n\n  public set onerror(cb: null | ((err: Error) => void)) {\n    this._errorHandler = cb;\n  }\n\n  public set onclose(cb: null | (() => void)) {\n    this._closeHandler = cb;\n  }\n\n  public close() {\n    if (this._wsSocket) {\n      this._wsSocket.close();\n    } else {\n      this._eioSocket!.close();\n    }\n  }\n\n  public send(data: string) {\n    if (this._wsSocket) {\n      this._wsSocket.send(data);\n    } else {\n      this._eioSocket!.send(data);\n    }\n  }\n\n  // pause(), resume(), and isOpen() are only used by tests and assume\n  // a WS.WebSocket transport.\n  public pause() {\n    (this._wsSocket as WS.WebSocket)?.pause();\n  }\n\n  public resume() {\n    (this._wsSocket as WS.WebSocket)?.resume();\n  }\n\n  public isOpen() {\n    return (this._wsSocket as WS.WebSocket)?.readyState === WS.OPEN;\n  }\n\n  private _createWSSocket() {\n    // We used to check if WebSocket was defined here, and use it\n    // if so, secure in the fact that we were in the browser and\n    // the browser would pass along cookie information. But recent\n    // node defines WebSocket, so we narrow down this path to when\n    // a global document is defined (window doesn't work because\n    // some tests mock it).\n    if (typeof document !== \"undefined\") {\n      this._wsSocket = new WebSocket(this._url);\n    } else {\n      this._wsSocket = new WS(this._url, undefined, this._options);\n    }\n    this._wsSocket.onmessage = this._onWSMessage.bind(this);\n    this._wsSocket.onopen = this._onWSOpen.bind(this);\n    this._wsSocket.onerror = this._onWSError.bind(this);\n    this._wsSocket.onclose = this._onWSClose.bind(this);\n  }\n\n  private _destroyWSSocket() {\n    if (this._wsSocket) {\n      this._wsSocket.onmessage = null;\n      this._wsSocket.onopen = null;\n      this._wsSocket.onerror = null;\n      this._wsSocket.onclose = null;\n      this._wsSocket = undefined;\n    }\n  }\n\n  private _onWSMessage(event: WS.MessageEvent | MessageEvent<any>) {\n    // event.data is guaranteed to be a string here because we only send text frames.\n    // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event#event_properties\n    this._messageHandler?.(event.data);\n  }\n\n  private _onWSOpen() {\n    // The connection was established successfully. Any future events can now\n    // be forwarded to the client.\n    this._wsConnected = true;\n    this._openHandler?.();\n  }\n\n  private _onWSError(ev: Event) {\n    if (!this._wsConnected) {\n      // The WebSocket connection attempt failed. Switch to Engine.IO.\n      this._destroyWSSocket();\n      this._createEIOSocket();\n      return;\n    }\n\n    // WebSocket error events are deliberately void of information,\n    // see https://websockets.spec.whatwg.org/#eventdef-websocket-error,\n    // so we ignore the incoming event.\n    this._errorHandler?.(new Error(\"WebSocket error\"));\n  }\n\n  private _onWSClose() {\n    this._closeHandler?.();\n  }\n\n  private _createEIOSocket() {\n    this._eioSocket = new EIOSocket(this._url, {\n      path: new URL(this._url).pathname,\n      addTrailingSlash: false,\n      transports: [\"polling\"],\n      upgrade: false,\n      extraHeaders: this._options?.headers,\n      withCredentials: true,\n    });\n\n    this._eioSocket.on(\"message\", this._onEIOMessage.bind(this));\n    this._eioSocket.on(\"open\", this._onEIOOpen.bind(this));\n    this._eioSocket.on(\"error\", this._onEIOError.bind(this));\n    this._eioSocket.on(\"close\", this._onEIOClose.bind(this));\n  }\n\n  private _onEIOMessage(data: string) {\n    this._messageHandler?.(data);\n  }\n\n  private _onEIOOpen() {\n    this._openHandler?.();\n  }\n\n  private _onEIOError(err: string | Error) {\n    this._errorHandler?.(typeof err === \"string\" ? new Error(err) : err);\n  }\n\n  private _onEIOClose() {\n    this._closeHandler?.();\n  }\n}\n"
  },
  {
    "path": "app/client/components/GristDoc.css",
    "content": "/* container for main buttons */\n.g-doc-menu-main {\n  flex: 1;\n  -webkit-flex: 1;\n}\n\n.btn.g_toolbar_symbol {\n  font-family: \"Apple Symbols\", \"Arial Unicode MS\";\n  font-size: 2rem;\n  line-height: 15px;\n  padding-top: 4px;\n}\n\n.big_symbol {\n  font-size: 2em;\n  line-height: 0.5;\n  vertical-align: middle;\n}\n\n.grist-doc-menu__view-title {\n  margin: auto; /* center */\n  text-align: center;\n}\n\n.view_main_pane {\n  width: 100%;\n  position: relative;\n}\n\n.view_main_pane.open_side_pane {\n  width: 75%;\n  min-width: 50%;\n}\n\n.add_section_btn {\n  width: 9.5rem;\n  text-align: left;\n}\n\n.add_section_icon {\n  position: relative;\n  background-color: white;\n  width: 1.6rem;\n  height: 1.2rem;\n  margin-left: 4px;\n}\n\n.section_icon {\n  position: absolute;\n  font-size: 1.05rem;\n  top: .2rem;\n  left: 0;\n  transform: scale(.9, 1);\n}\n.plus_icon {\n  position: absolute;\n  top: .35rem;\n  left: 1.1rem;\n  font-size: .5rem;\n}\n\n.download_btn {\n  font-size: 1.0rem;\n  color: black;\n}\n\n.relative {\n  position: relative;\n}\n"
  },
  {
    "path": "app/client/components/GristDoc.ts",
    "content": "/**\n * GristDoc manages an open Grist document on the client side.\n */\n\nimport { AccessRules } from \"app/client/aclui/AccessRules\";\nimport { ActionCounter } from \"app/client/components/ActionCounter\";\nimport { ActionLog } from \"app/client/components/ActionLog\";\nimport BaseView from \"app/client/components/BaseView\";\nimport { isNumericLike, isNumericOnly } from \"app/client/components/ChartView\";\nimport { CodeEditorPanel } from \"app/client/components/CodeEditorPanel\";\nimport * as commands from \"app/client/components/commands\";\nimport { CursorMonitor, ViewCursorPos } from \"app/client/components/CursorMonitor\";\nimport { DocComm } from \"app/client/components/DocComm\";\nimport { Drafts } from \"app/client/components/Drafts\";\nimport { EditorMonitor } from \"app/client/components/EditorMonitor\";\nimport { buildDefaultFormLayout } from \"app/client/components/Forms/FormView\";\nimport GridView from \"app/client/components/GridView\";\nimport { importFromFile, selectAndImport } from \"app/client/components/Importer\";\nimport { RawDataPage, RawDataPopup } from \"app/client/components/RawDataPage\";\nimport { RecordCardPopup } from \"app/client/components/RecordCardPopup\";\nimport { RegionFocusSwitcher } from \"app/client/components/RegionFocusSwitcher\";\nimport { ActionGroupWithCursorPos, UndoStack } from \"app/client/components/UndoStack\";\nimport { ViewLayout } from \"app/client/components/ViewLayout\";\nimport { startDocAirtableImport } from \"app/client/lib/airtable/startDocAirtableImport\";\nimport { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { copyToClipboard } from \"app/client/lib/clipboardUtils\";\nimport { DocPluginManager } from \"app/client/lib/DocPluginManager\";\nimport { ImportSourceElement } from \"app/client/lib/ImportSourceElement\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { createSessionObs } from \"app/client/lib/sessionObs\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { setTestState } from \"app/client/lib/testState\";\nimport { getLoginOrSignupUrl } from \"app/client/lib/urlUtils\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport BaseRowModel from \"app/client/models/BaseRowModel\";\nimport DataTableModel from \"app/client/models/DataTableModel\";\nimport { DataTableModelWithDiff } from \"app/client/models/DataTableModelWithDiff\";\nimport { DocData } from \"app/client/models/DocData\";\nimport { DocInfoRec, DocModel, ViewFieldRec, ViewRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { reportError, reportSuccess, UserError } from \"app/client/models/errors\";\nimport { getMainOrgUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { getFilterFunc, QuerySetManager } from \"app/client/models/QuerySet\";\nimport { getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen } from \"app/client/models/UserPrefs\";\nimport { UserPresenceModel, UserPresenceModelImpl } from \"app/client/models/UserPresenceModel\";\nimport { App } from \"app/client/ui/App\";\nimport { showCustomWidgetGallery } from \"app/client/ui/CustomWidgetGallery\";\nimport { DocHistory } from \"app/client/ui/DocHistory\";\nimport { startDocTour } from \"app/client/ui/DocTour\";\nimport { DocTutorial } from \"app/client/ui/DocTutorial\";\nimport { DocSettingsPage } from \"app/client/ui/DocumentSettings\";\nimport { isTourActive, isTourActiveObs } from \"app/client/ui/OnBoardingPopups\";\nimport { DefaultPageWidget, IPageWidget, toPageWidget } from \"app/client/ui/PageWidgetPicker\";\nimport { ProposedChangesPage } from \"app/client/ui/ProposedChangesPage\";\nimport { linkFromId, NoLink, selectBy } from \"app/client/ui/selectBy\";\nimport { TimingPage } from \"app/client/ui/TimingPage\";\nimport { WebhookPage } from \"app/client/ui/WebhookPage\";\nimport { startWelcomeTour } from \"app/client/ui/WelcomeTour\";\nimport { getTelemetryWidgetTypeFromPageWidget } from \"app/client/ui/widgetTypesMap\";\nimport { PlayerState, YouTubePlayer } from \"app/client/ui/YouTubePlayer\";\nimport { isNarrowScreen, mediaSmall, mediaXSmall, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { invokePrompt } from \"app/client/ui2018/modals\";\nimport { buildAssistantPopup } from \"app/client/widgets/AssistantPopup\";\nimport { CommentMonitor, DiscussionPanel } from \"app/client/widgets/DiscussionEditor\";\nimport { FieldEditor } from \"app/client/widgets/FieldEditor\";\nimport { MinimalActionGroup } from \"app/common/ActionGroup\";\nimport { ClientQuery, FilterColValues } from \"app/common/ActiveDocAPI\";\nimport { CommDocChatter, CommDocUsage, CommDocUserAction } from \"app/common/CommTypes\";\nimport { delay } from \"app/common/delay\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { isSchemaAction, UserAction } from \"app/common/DocActions\";\nimport { OpenLocalDocResult } from \"app/common/DocListAPI\";\nimport { DocState, DocStateComparison } from \"app/common/DocState\";\nimport { isList, isListType, isRefListType } from \"app/common/gristTypes\";\nimport {\n  CompareEmphasis,\n  HashLink,\n  IDocPage,\n  isFeatureEnabled,\n  isViewDocPage,\n  parseUrlId,\n  SpecialDocPage,\n  ViewDocPage,\n} from \"app/common/gristUrls\";\nimport { undef, waitObs } from \"app/common/gutil\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { TableData } from \"app/common/TableData\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { AttachmentTransferStatus, DocAPI, ExtendedUser } from \"app/common/UserAPI\";\nimport { AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType, WidgetType } from \"app/common/widgetTypes\";\nimport { CursorPos } from \"app/plugin/GristAPI\";\n\nimport {\n  bundleChanges,\n  Computed,\n  dom,\n  DomContents,\n  Emitter,\n  fromKo,\n  Holder,\n  IDisposable,\n  IDomComponent,\n  IKnockoutObservable,\n  keyframes,\n  Observable,\n  styled,\n  subscribe,\n  toKo,\n} from \"grainjs\";\nimport * as ko from \"knockout\";\nimport cloneDeepWith from \"lodash/cloneDeepWith\";\nimport isEqual from \"lodash/isEqual\";\nimport omit from \"lodash/omit\";\nimport pick from \"lodash/pick\";\n\nimport type { BehavioralPromptsManager } from \"app/client/components/BehavioralPromptsManager\";\nimport type { UserOrgPrefs } from \"app/common/Prefs\";\n\nconst RICK_ROLL_YOUTUBE_EMBED_ID = \"dQw4w9WgXcQ\";\n\nconst t = makeT(\"GristDoc\");\n\nconst G = getBrowserGlobals(\"document\", \"window\");\n\n// Re-export some tools to move them from main webpack bundle to the one with GristDoc.\nexport { DocComm, startDocTour };\n\nexport interface TabContent {\n  showObs?: any;\n  header?: boolean;\n  label?: any;\n  items?: any;\n  buildDom?: any;\n  keywords?: any;\n}\n\nexport interface TabOptions {\n  shortLabel?: string;\n  hideSearchContent?: boolean;\n  showObs?: any;\n  category?: any;\n}\n\nconst RightPanelTool = StringUnion(\"none\", \"docHistory\", \"validations\", \"discussion\");\n\nexport interface IExtraTool {\n  icon: IconName;\n  label: DomContents;\n  content: TabContent[] | IDomComponent;\n}\n\ninterface PopupSectionOptions {\n  viewSection: ViewSectionRec;\n  hash: HashLink;\n  close: () => void;\n}\n\ninterface AddSectionOptions {\n  /** If focus should move to the new section. Defaults to `true`. */\n  focus?: boolean;\n  /** If popups should be shown (e.g. Card Layout tip). Defaults to `true`. */\n  popups?: boolean;\n}\n\nexport interface GristDoc extends DisposableWithEvents {\n  app: App;\n  appModel: AppModel;\n  docComm: DocComm;\n  docPageModel: DocPageModel;\n  docModel: DocModel;\n  viewModel: ViewRec;\n  userPresenceModel: UserPresenceModel;\n  activeViewId: Observable<IDocPage>;\n  currentPageName: Observable<string>;\n  docData: DocData;\n  docInfo: DocInfoRec;\n  docPluginManager: DocPluginManager;\n  querySetManager: QuerySetManager;\n  rightPanelTool: Observable<IExtraTool | null>;\n  isReadonly: Observable<boolean>;\n  isReadonlyKo: IKnockoutObservable<boolean>;\n  readonly comparison: DocStateComparison | null;\n  cursorMonitor: CursorMonitor;\n  editorMonitor?: EditorMonitor;\n  commentMonitor?: CommentMonitor;\n  hasCustomNav: Observable<boolean>;\n  resizeEmitter: Emitter;\n  fieldEditorHolder: Holder<IDisposable>;\n  activeEditor: Observable<FieldEditor | null>;\n  currentView: Observable<BaseView | null>;\n  cursorPosition: Observable<ViewCursorPos | undefined>;\n  userOrgPrefs: Observable<UserOrgPrefs>;\n  behavioralPromptsManager: BehavioralPromptsManager;\n  maximizedSectionId: Observable<number | null>;\n  externalSectionId: Observable<number | null>;\n  viewLayout: ViewLayout | null;\n  docApi: DocAPI;\n  isTimingOn: Observable<boolean>;\n  attachmentTransfer: Observable<AttachmentTransferStatus | null>;\n  canShowRawData: Observable<boolean>;\n  currentUser: Observable<ExtendedUser | null>;\n  // Keep track of the actionNum/actionHash of the document.\n  latestActionState: Observable<DocState | null>;\n  regionFocusSwitcher?: RegionFocusSwitcher;\n\n  docId(): string;\n  openDocPage(viewId: IDocPage): Promise<void>;\n  showTool(tool: typeof RightPanelTool.type): void;\n  onSetCursorPos(rowModel: BaseRowModel | undefined, fieldModel?: ViewFieldRec): Promise<void>;\n  moveToCursorPos(cursorPos?: CursorPos, optActionGroup?: MinimalActionGroup): Promise<void>;\n  getUndoStack(): UndoStack;\n  getActionCounter(): ActionCounter;\n  getTableModel(tableId: string): DataTableModel;\n  getTableModelMaybeWithDiff(tableId: string): DataTableModel;\n  addEmptyTable(): Promise<void>;\n  addWidgetToPage(widget: IPageWidget): Promise<void>;\n  addNewPage(val: IPageWidget): Promise<void>;\n  saveViewSection(section: ViewSectionRec, newVal: IPageWidget): Promise<ViewSectionRec>;\n  saveLink(linkId: string, sectionId?: number): Promise<any>;\n  selectBy(widget: IPageWidget): any[];\n  forkIfNeeded(): Promise<void>;\n\n  getCsvLink(): string;\n  getTsvLink(): string;\n  getDsvLink(): string;\n  getXlsxActiveViewLink(): string;\n  recursiveMoveToCursorPos(\n    cursorPos: CursorPos,\n    setAsActiveSection: boolean,\n    silent?: boolean,\n    visitedSections?: number[]\n  ): Promise<boolean>;\n  activateEditorAtCursor(options?: { init?: string; state?: any }): Promise<void>;\n  copyAnchorLink(anchorInfo: HashLink & CursorPos): Promise<void>;\n  getActionLog(): ActionLog;\n  setComparison(comparison: DocStateComparison | null): void;\n}\n\nexport class GristDocImpl extends DisposableWithEvents implements GristDoc {\n  public docModel: DocModel;\n  public viewModel: ViewRec;\n  public userPresenceModel: UserPresenceModel;\n  public activeViewId: Observable<IDocPage>;\n  public currentPageName: Observable<string>;\n  public docData: DocData;\n  public docInfo: DocInfoRec;\n  public docPluginManager: DocPluginManager;\n  public querySetManager: QuerySetManager;\n  public rightPanelTool: Observable<IExtraTool | null>;\n  public isReadonly = this.docPageModel.isReadonly;\n  public isReadonlyKo = toKo(ko, this.isReadonly);\n  public comparison: DocStateComparison | null;\n  public compareEmphasis: CompareEmphasis;\n  public get regionFocusSwitcher() { return this.app.regionFocusSwitcher; }\n  // component for keeping track of latest cursor position\n  public cursorMonitor: CursorMonitor;\n  // component for keeping track of a cell that is being edited\n  public editorMonitor?: EditorMonitor;\n  // component for keeping track of a cell that is being edited\n  public draftMonitor: Drafts;\n  // component for keeping track of discarded comments.\n  public commentMonitor: CommentMonitor;\n  // will document perform its own navigation (from anchor link)\n  public hasCustomNav: Observable<boolean>;\n  // Emitter triggered when the main doc area is resized.\n  public readonly resizeEmitter = this.autoDispose(new Emitter());\n\n  // This holds a single FieldEditor. When a new FieldEditor is created (on edit), it replaces the\n  // previous one if any. The holder is maintained by GristDoc, so that we are guaranteed at\n  // most one instance of FieldEditor at any time.\n  public readonly fieldEditorHolder = Holder.create(this);\n  // active field editor\n  public readonly activeEditor: Observable<FieldEditor | null> = Observable.create(this, null);\n\n  // Holds current view that is currently rendered\n  public currentView: Observable<BaseView | null>;\n\n  // Holds current cursor position with a view id\n  public cursorPosition: Computed<ViewCursorPos | undefined>;\n\n  public readonly userOrgPrefs = getUserOrgPrefsObs(this.docPageModel.appModel);\n\n  public readonly behavioralPromptsManager = this.docPageModel.appModel.behavioralPromptsManager;\n  // One of the section can be expanded (as requested from the Layout), we will\n  // store its id in this variable. NOTE: expanded section looks exactly the same as a section\n  // in the popup. But they are rendered differently, as section in popup is probably an external\n  // section (or raw data section) that is not part of this view. Maximized section is a section\n  // in the view, so there is no need to render it twice, layout just hides all other sections to make\n  // the space.\n  public maximizedSectionId: Observable<number | null> = Observable.create(this, null);\n  // This is id of the section that is currently shown in the popup. Probably this is an external\n  // section, like raw data view, or a section from another view.\n  public externalSectionId: Observable<number | null>;\n  public viewLayout: ViewLayout | null = null;\n\n  // Holder for the popped up formula editor.\n  public readonly formulaPopup = Holder.create(this);\n\n  public get docApi() {\n    return this.docPageModel.appModel.api.getDocAPI(this.docPageModel.currentDocId.get()!);\n  }\n\n  public isTimingOn = Observable.create(this, false);\n\n  /**\n   * Observable for the attachment transfer status. It is send by the ActiveDoc whenever attachments'\n   * transfer job is started or finished or when the external storage is changed through the API.\n   * Note: direct json manipulation (in the DocInfoRec) are not notified by the ActiveDoc.\n   * Note: GristDoc doesn't load it at the start, it just listens to changes, so the user of this API\n   * needs to load the status manually if it is not yet set.\n   */\n  public attachmentTransfer = Observable.create(this, null as AttachmentTransferStatus | null);\n\n  /**\n   * Checks if it is ok to show raw data popup for currently selected section.\n   * We can't show raw data if:\n   * - we already have full screen section (which looks the same)\n   * - we are already showing raw data\n   *\n   * Extracted to single computed as it is used here and in menus.\n   */\n  public canShowRawData: Computed<boolean>;\n  public currentUser: Observable<ExtendedUser | null>;\n  public latestActionState: Observable<DocState | null>;\n\n  private _actionLog: ActionLog;\n  private _actionCounter: ActionCounter;\n  private _undoStack: UndoStack;\n  private _lastOwnActionGroup: ActionGroupWithCursorPos | null = null;\n  private _rightPanelTabs = new Map<string, TabContent[]>();\n  private _docHistory: DocHistory;\n  private _discussionPanel: DiscussionPanel;\n  private _rightPanelTool = createSessionObs(this, \"rightPanelTool\", \"none\", RightPanelTool.guard);\n  private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, \"showGristTour\");\n  private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, \"seenDocTours\");\n  private _popupSectionOptions: Observable<PopupSectionOptions | null> = Observable.create(this, null);\n  private _activeContent: Computed<IDocPage>;\n  private _docTutorialHolder = Holder.create<DocTutorial>(this);\n  private _isRickRowing: Observable<boolean> = Observable.create(this, false);\n  private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false);\n  private _backgroundVideoPlayerHolder: Holder<YouTubePlayer> = Holder.create(this);\n  private _disableAutoStartingTours: boolean = false;\n  private _isShowingPopupSection = false;\n  private _prevSectionId: number | null = null;\n  private _assistantPopup = buildAssistantPopup(this);\n  private _diffModels: Record<string, DataTableModelWithDiff> = {};\n\n  constructor(\n    public readonly app: App,\n    public readonly appModel: AppModel,\n    public readonly docComm: DocComm,\n    public readonly docPageModel: DocPageModel,\n    openDocResponse: OpenLocalDocResult,\n    plugins: LocalPlugin[],\n    options: {\n      comparison?: DocStateComparison,  // initial comparison with another document\n      compareEmphasis?: CompareEmphasis,\n    } = {},\n  ) {\n    super();\n    console.log(\"RECEIVED DOC RESPONSE\", openDocResponse);\n    this.isTimingOn.set(openDocResponse.isTimingOn);\n    this.docData = new DocData(this.docComm, openDocResponse.doc);\n    this.docModel = this.autoDispose(new DocModel(this.docData, this.docPageModel));\n    this.userPresenceModel = UserPresenceModelImpl.create(this, this.docComm, this.app.comm);\n    this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);\n    this.docPluginManager = new DocPluginManager({\n      plugins,\n      untrustedContentOrigin: app.topAppModel.getUntrustedContentOrigin(),\n      docComm: this.docComm,\n      clientScope: app.clientScope,\n    });\n\n    // Maintain the MetaRowModel for the global document info, including docId and peers.\n    this.docInfo = this.docModel.docInfoRow;\n\n    this.currentUser = Computed.create(this, (use) => {\n      return use(this.app.topAppModel.appObs)?.currentUser ?? null;\n    });\n\n    this.latestActionState = Observable.create<DocState | null>(this, null);\n\n    const defaultViewId = this.docInfo.newDefaultViewId;\n\n    // Grainjs observable for current view id, which may be a string such as 'code'.\n    this.activeViewId = Computed.create(this, (use) => {\n      const { docPage } = use(urlState().state);\n\n      // Return most special pages like 'code' and 'acl' as is\n      if (typeof docPage === \"string\" && docPage !== \"GristDocTour\" && SpecialDocPage.guard(docPage)) {\n        return docPage;\n      }\n\n      // GristDocTour is a special table that is usually hidden from users, but putting /p/GristDocTour\n      // in the URL navigates to it and makes it visible in the list of pages in the sidebar\n      // For GristDocTour, find the view with that name.\n      // Otherwise find the view with the given row ID, because letting a non-existent row ID pass through here is bad.\n      // If no such view exists, return the default view.\n      const viewId = this.docModel.views.tableData.findRow(docPage === \"GristDocTour\" ? \"name\" : \"id\", docPage!);\n      return viewId || use(defaultViewId);\n    });\n    this._activeContent = Computed.create(this, use => use(this.activeViewId));\n    this.externalSectionId = Computed.create(this, (use) => {\n      const externalContent = use(this._popupSectionOptions);\n      return externalContent ? use(externalContent.viewSection.id) : null;\n    });\n    // This viewModel reflects the currently active view, relying on the fact that\n    // createFloatingRowModel() supports an observable rowId for its argument.\n    // Although typings don't reflect it, createFloatingRowModel() accepts non-numeric values,\n    // which yield an empty row, which is why we can cast activeViewId.\n    this.viewModel = this.autoDispose(\n      this.docModel.views.createFloatingRowModel(toKo(ko, this.activeViewId) as ko.Computed<number>));\n\n    // When active section is changed, clear the maximized state.\n    this.autoDispose(this.viewModel.activeSectionId.subscribe((id) => {\n      if (id === this.maximizedSectionId.get()) {\n        return;\n      }\n      this.maximizedSectionId.set(null);\n      // If we have layout, update it.\n      if (!this.viewLayout?.isDisposed()) {\n        this.viewLayout?.maximized.set(null);\n      }\n    }));\n\n    // Grainjs observable reflecting the name of the current document page.\n    this.currentPageName = Computed.create(this, this.activeViewId,\n      (use, docPage) => typeof docPage === \"number\" ? use(this.viewModel.name) : docPage);\n\n    // Whenever the active viewModel is deleted, switch to the default view.\n    this.autoDispose(this.viewModel._isDeleted.subscribe((isDeleted) => {\n      if (isDeleted) {\n        // This should not be done synchronously, as that affects the same viewModel that triggered\n        // this callback, and causes some obscure effects on knockout subscriptions.\n        Promise.resolve().then(() => urlState().pushUrl({ docPage: undefined })).catch(() => null);\n      }\n    }));\n\n    // Subscribe to URL state, and navigate to anchor or open a popup if necessary.\n    // If state.hash.anchor is set, we use it as a \"normal\" non-disappearing link hash,\n    // useful to linking to more normal, more web-like and less app-like pages.\n    this.autoDispose(subscribe(urlState().state, async (use, state) => {\n      if (!state.hash || state.hash.anchor) {\n        return;\n      }\n\n      try {\n        if (state.hash.popup || state.hash.recordCard) {\n          await this._openPopup(state.hash);\n        } else {\n          // Navigate to an anchor if one is present in the url hash.\n          const cursorPos = this._getCursorPosFromHash(state.hash);\n          await this.recursiveMoveToCursorPos(cursorPos, true);\n\n          if (state.hash.comments) {\n            const section = this.viewModel.activeSection.peek();\n            // Wait for the view to load and be rendered (which is async operation already scheduled at 0).\n            this._waitForView(section).then(async () => {\n              // Sanity check that section is still there.\n              if (section.isDisposed() || !section.hasFocus.peek()) { return; }\n              // And the cursor position wasn't changed (it shouldn't as we were loaded by hash, so Grist\n              // should keep the position no matter what).\n              const current = this._getCursorPos();\n              const cell = (pos: CursorPos) => pick(pos, [\"rowId\", \"fieldId\"]);\n              if (!isEqual(cell(cursorPos), cell(current))) { return; }\n              // Open up the discussion panel.\n              commands.allCommands.openDiscussion.run();\n            }).catch(reportError);\n          }\n        }\n\n        const isTourOrTutorialActive = isTourActive() || this.docModel.isTutorial();\n        if (state.hash.rickRow && !this._isRickRowing.get() && !isTourOrTutorialActive) {\n          YouTubePlayer.create(this._backgroundVideoPlayerHolder, RICK_ROLL_YOUTUBE_EMBED_ID, {\n            height: \"100%\",\n            width: \"100%\",\n            origin: getMainOrgUrl(),\n            playerVars: {\n              controls: 0,\n              disablekb: 1,\n              fs: 0,\n              iv_load_policy: 3,\n              modestbranding: 1,\n            },\n            onPlayerStateChange: (_player, event) => {\n              if (event.data === PlayerState.Playing) {\n                this._isRickRowing.set(true);\n              }\n            },\n          }, cssYouTubePlayer.cls(\"\"));\n          this._showBackgroundVideoPlayer.set(true);\n          this._waitForView()\n            .then(() => {\n              const cursor = document.querySelector(\".selected_cursor.active_cursor\");\n              if (!cursor) {\n                return;\n              }\n\n              this.behavioralPromptsManager.showPopup(cursor, \"rickRow\", {\n                onDispose: () => this._playRickRollVideo(),\n              });\n            })\n            .catch(reportError);\n        }\n      } catch (e) {\n        reportError(e);\n      } finally {\n        setTimeout(finalizeAnchor, 0);\n      }\n    }));\n\n    this.autoDispose(subscribe(\n      urlState().state,\n      isTourActiveObs(),\n      fromKo(this.docModel.isTutorial),\n      async (_use, state, hasActiveTour, isTutorial) => {\n        // Tours and tutorials can interfere with in-product tips and announcements.\n        const hasPendingDocTour = state.docTour || await this._shouldAutoStartDocTour();\n        const hasPendingWelcomeTour = state.welcomeTour || this._shouldAutoStartWelcomeTour();\n        const isPopupManagerDisabled = this.behavioralPromptsManager.isDisabled();\n        if (\n          (hasPendingDocTour || hasPendingWelcomeTour || hasActiveTour || isTutorial) &&\n          !isPopupManagerDisabled\n        ) {\n          this.behavioralPromptsManager.disable();\n        } else if (isPopupManagerDisabled) {\n          this.behavioralPromptsManager.enable();\n        }\n      },\n    ));\n\n    let isStartingTourOrTutorial = false;\n    this.autoDispose(subscribe(urlState().state, async (_use, state) => {\n      // Only start a tour or tutorial when the full interface is showing, i.e. not when in\n      // embedded mode.\n      if (state.params?.style === \"singlePage\") {\n        return;\n      }\n\n      const isTutorial = this.docModel.isTutorial();\n      // Onboarding tours were not designed with mobile support in mind. Disable until fixed.\n      if (isNarrowScreen() && !isTutorial) {\n        return;\n      }\n\n      // Onboarding tours can conflict with hash links and the assistant.\n      if (state.hash || state.params?.assistantState) {\n        this._disableAutoStartingTours = true;\n      }\n\n      // If we have an active tour or tutorial (or are in the process of starting one), don't start\n      // a new one.\n      const hasActiveTourOrTutorial = isTourActive() || !this._docTutorialHolder.isEmpty();\n      if (isStartingTourOrTutorial || hasActiveTourOrTutorial) {\n        return;\n      }\n\n      const shouldStartTutorial = isTutorial;\n      const shouldStartDocTour = state.docTour || await this._shouldAutoStartDocTour();\n      const shouldStartWelcomeTour = state.welcomeTour || this._shouldAutoStartWelcomeTour();\n      if (shouldStartTutorial || shouldStartDocTour || shouldStartWelcomeTour) {\n        isStartingTourOrTutorial = true;\n        try {\n          await this._waitForView();\n\n          // Remove any tour-related hash-tags from the URL. So #repeat-welcome-tour and\n          // #repeat-doc-tour are used as triggers, but will immediately disappear.\n          await urlState().pushUrl({ welcomeTour: false, docTour: false },\n            { replace: true, avoidReload: true });\n\n          if (shouldStartTutorial) {\n            await DocTutorial.create(this._docTutorialHolder, this).start();\n          } else if (shouldStartDocTour) {\n            const onFinishCB = () => (\n              !this._seenDocTours.get()?.includes(this.docId()) &&\n              markAsSeen(this._seenDocTours, this.docId())\n            );\n            await startDocTour(this.docData, this.docComm, onFinishCB);\n            if (this.docPageModel.isTemplate.get()) {\n              const doc = this.docPageModel.currentDoc.get();\n              if (!doc) { return; }\n\n              logTelemetryEvent(\"openedTemplateTour\", {\n                full: {\n                  templateId: parseUrlId(doc.urlId || doc.id).trunkId,\n                },\n              });\n            }\n          } else {\n            startWelcomeTour(() => this._showGristTour.set(false));\n          }\n        } finally {\n          isStartingTourOrTutorial = false;\n        }\n      }\n    }));\n\n    // Importer takes a function for creating previews.\n    const createPreview = (vs: ViewSectionRec) => {\n      const preview = GridView.create(this, this, vs, { isPreview: true });\n      // We need to set the instance to the newly created section. This is important, as\n      // GristDoc is responsible for changing the cursor position not the cursor itself. Final\n      // cursor position is determined by finding active (or visible) section and passing this\n      // command (setCursor) to its instance.\n      vs.viewInstance(preview);\n      preview.onDispose(() => vs.viewInstance(null));\n      return preview;\n    };\n\n    const importSourceElems = ImportSourceElement.fromArray(this.docPluginManager.pluginsList);\n    const importMenuItems = [\n      {\n        label: t(\"Import from file\"),\n        action: () => importFromFile(this, createPreview),\n      },\n      ...importSourceElems.map(importSourceElem => ({\n        label: importSourceElem.importSource.label,\n        action: () => selectAndImport(this, importSourceElems, importSourceElem, createPreview),\n      })),\n      ...(isFeatureEnabled(\"importFromAirtable\") && [{\n        label: t(\"Import from Airtable\"),\n        action: async () => {\n          if (this.docPageModel.appModel.currentValidUser) {\n            await startDocAirtableImport(this);\n          } else {\n            // Don't show the modal about unsaved changes; saving a document requires an\n            // account, and the redirect below should automatically save the document upon\n            // sign-up.\n            this.docPageModel.clearUnsavedChanges();\n            window.location.href = getLoginOrSignupUrl({ srcDocId: urlState().state.get().doc });\n          }\n        },\n      }] || []),\n    ];\n\n    // Set the available import sources in the DocPageModel.\n    this.docPageModel.importSources = importMenuItems;\n\n    this._actionLog = this.autoDispose(ActionLog.create({ gristDoc: this }));\n    this._actionCounter = this.autoDispose(ActionCounter.create(openDocResponse.log, this.docData));\n    this._undoStack = this.autoDispose(UndoStack.create(openDocResponse.log, {\n      gristDoc: this,\n      isUndoBlocked: this._actionCounter.isUndoBlocked,\n    }));\n    if (openDocResponse.log.length > 0) {\n      const latestAction = openDocResponse.log[openDocResponse.log.length - 1];\n      this.latestActionState.set({\n        h: latestAction.actionHash,\n        n: latestAction.actionNum,\n      });\n    }\n    this._docHistory = DocHistory.create(this, this.docPageModel, this._actionLog);\n    this._discussionPanel = DiscussionPanel.create(this, this);\n\n    // Tap into docData's sendActions method to save the cursor position with every action, so that\n    // undo/redo can jump to the right place.\n    this.autoDispose(this.docData.sendActionsEmitter.addListener(this._onSendActionsStart, this));\n    this.autoDispose(this.docData.sendActionsDoneEmitter.addListener(this._onSendActionsEnd, this));\n\n    /* Command binding */\n    this.autoDispose(commands.createGroup({\n      undo() {\n        this._undoStack.sendUndoAction().catch(reportError);\n      },\n      redo() {\n        this._undoStack.sendRedoAction().catch(reportError);\n      },\n      reloadPlugins() {\n        void this.docComm.reloadPlugins().then(() => G.window.location.reload(false));\n      },\n      async showRawData(sectionId: number = 0) {\n        if (!this.canShowRawData.get()) {\n          return;\n        }\n        if (!sectionId) {\n          const viewSection = this.viewModel.activeSection();\n          if (viewSection?.isDisposed()) { return; }\n          if (viewSection.isRaw.peek()) {\n            return;\n          }\n          sectionId = viewSection.id.peek();\n        }\n        const anchorUrlState = { hash: { sectionId, popup: true } };\n        await urlState().pushUrl(anchorUrlState, { replace: true });\n      },\n      // Command to be manually triggered on cell selection. Moves the cursor to the selected cell.\n      // This is overridden by the formula editor to insert \"$col\" variables when clicking cells.\n      setCursor: this.onSetCursorPos.bind(this),\n      createForm: this._onCreateForm.bind(this),\n      pushUndoAction: this._undoStack.pushAction.bind(this._undoStack),\n      activateAssistant: () => this._assistantPopup?.open(),\n    }, this, true));\n\n    this.userPresenceModel.initialize().catch(reportError);\n\n    this.listenTo(app.comm, \"docUserAction\", this._onDocUserAction);\n\n    this.listenTo(app.comm, \"docUsage\", this._onDocUsageMessage);\n\n    this.listenTo(app.comm, \"docChatter\", this._onDocChatter);\n\n    this._handleTriggerQueueOverflowMessage();\n\n    this.rightPanelTool = Computed.create(this, use => this._getToolContent(use(this._rightPanelTool)));\n\n    this.comparison = options.comparison || null;\n    this.compareEmphasis = options.compareEmphasis ?? \"remote\";\n\n    // We need prevent default here to allow drop events to fire.\n    this.autoDispose(dom.onElem(window, \"dragover\", ev => ev.preventDefault()));\n    // The default action is to open dragged files as a link, navigating out of the app.\n    this.autoDispose(dom.onElem(window, \"drop\", ev => ev.preventDefault()));\n\n    // On window resize, trigger the resizeEmitter to update ViewLayout and individual BaseViews.\n    this.autoDispose(dom.onElem(window, \"resize\", () => this.resizeEmitter.emit()));\n\n    // create current view observer\n    this.currentView = Observable.create<BaseView | null>(this, null);\n\n    // create computed observable for viewInstance - if it is loaded or not\n\n    // GrainJS will not recalculate section.viewInstance correctly because it will be\n    // modified (updated from null to a correct instance) in the same tick. We need to\n    // switch for a moment to knockout to fix this.\n    const viewInstance = fromKo(this.autoDispose(ko.pureComputed(() => {\n      const viewId = toKo(ko, this.activeViewId)();\n      if (!isViewDocPage(viewId)) {\n        return null;\n      }\n      const section = this.viewModel.activeSection();\n      if (section?.isDisposed()) { return null; }\n      const view = section.viewInstance();\n      return view;\n    })));\n\n    // then listen if the view is present, because we still need to wait for it load properly\n    this.autoDispose(viewInstance.addListener(async (view) => {\n      if (view) {\n        await view.getLoadingDonePromise();\n      }\n      if (view?.isDisposed()) {\n        return;\n      }\n      // finally set the current view as fully loaded\n      this.currentView.set(view);\n    }));\n\n    // create observable for current cursor position\n    this.cursorPosition = Computed.create<ViewCursorPos | undefined>(this, (use) => {\n      // get the BaseView\n      const view = use(this.currentView);\n      if (!view) {\n        return undefined;\n      }\n      const viewId = use(this.activeViewId);\n      if (!isViewDocPage(viewId)) {\n        return undefined;\n      }\n      // read latest position\n      const currentPosition = use(view.cursor.currentPosition);\n      if (currentPosition) {\n        return { ...currentPosition, viewId };\n      }\n      return undefined;\n    });\n\n    this.hasCustomNav = Computed.create(this, urlState().state, (_, state) => {\n      const hash = state.hash;\n      return !!(hash && (undef(hash.colRef, hash.rowId, hash.sectionId) !== undefined));\n    });\n\n    this.draftMonitor = Drafts.create(this, this);\n    this.cursorMonitor = CursorMonitor.create(this, this);\n    this.editorMonitor = EditorMonitor.create(this, this);\n    this.commentMonitor = CommentMonitor.create(this, this);\n\n    // When active section is changed to a chart or custom widget, change the tab in the creator\n    // panel to the table.\n    this.autoDispose(this.viewModel.activeSection.subscribe((section) => {\n      if (section.isDisposed() || section._isDeleted.peek()) {\n        return;\n      }\n      if ([\"chart\", \"custom\"].includes(section.parentKey.peek())) {\n        commands.allCommands.viewTabFocus.run();\n      }\n    }));\n\n    this.autoDispose(this._popupSectionOptions.addListener((popupOptions) => {\n      if (!popupOptions) {\n        this._isShowingPopupSection = false;\n        this._prevSectionId = null;\n      }\n    }));\n\n    this.canShowRawData = Computed.create(this, (use) => {\n      const isSinglePage = use(urlState().state).params?.style === \"singlePage\";\n      if (isSinglePage || use(this.maximizedSectionId)) {\n        return false;\n      }\n      return true;\n    });\n\n    this.autoDispose(subscribe(urlState().state, async (_use, state) => {\n      const { params } = state;\n      if (!params?.assistantState) {\n        return;\n      }\n\n      await urlState().pushUrl(\n        { params: omit(params, \"assistantState\") },\n        { replace: true, avoidReload: true },\n      );\n\n      const assistantState = await this.docComm.getAssistantState(params.assistantState);\n      if (this.isDisposed() || !assistantState) { return; }\n\n      this._assistantPopup?.setState(assistantState);\n      this._assistantPopup?.open();\n    }));\n  }\n\n  /**\n   * Returns current document's id\n   */\n  public docId() {\n    return this.docPageModel.currentDocId.get()!;\n  }\n\n  /**\n   * Builds the DOM for this GristDoc.\n   */\n  public buildDom() {\n    return cssViewContentPane(\n      testId(\"gristdoc\"),\n      cssViewContentPane.cls(\"-special-page\", use =>\n        [\"data\", \"settings\", \"code\"].includes(use(this.activeViewId) as string)),\n      dom.cls(\"diff-emphasize-local\", this.compareEmphasis === \"local\"),\n      dom.maybe(this._isRickRowing, () => cssStopRickRowingButton(\n        cssCloseIcon(\"CrossBig\"),\n        dom.on(\"click\", () => {\n          this._isRickRowing.set(false);\n          this._showBackgroundVideoPlayer.set(false);\n        }),\n        testId(\"gristdoc-stop-rick-rowing\"),\n      )),\n      dom.domComputed(this._activeContent, (content) => {\n        return  (\n          content === \"code\" ? dom.create(CodeEditorPanel, this) :\n            content === \"acl\" ? dom.create(AccessRules, this) :\n              content === \"data\" ? dom.create(RawDataPage, this) :\n                content === \"suggestions\" ? dom.create(ProposedChangesPage, this) :\n                  content === \"settings\" ? dom.create(DocSettingsPage, this) :\n                    content === \"webhook\" ? dom.create(WebhookPage, this) :\n                      content === \"timing\" ? dom.create(TimingPage, this) :\n                        content === \"GristDocTour\" ? null :\n                          [\n                            dom.create((owner) => {\n                              this.viewLayout = ViewLayout.create(owner, this, content);\n                              this.viewLayout.maximized.addListener((sectionId) => {\n                                this.maximizedSectionId.set(sectionId);\n\n                                if (sectionId === null && !this._isShowingPopupSection) {\n                                  // If we didn't navigate to another section in the popup, move focus\n                                  // back to the previous section.\n                                  this._focusPreviousSection();\n                                }\n                              });\n                              owner.onDispose(() => this.viewLayout = null);\n                              return this.viewLayout;\n                            }),\n                            dom.maybe(this._popupSectionOptions, (popupOptions) => {\n                              return dom.create((owner) => {\n                                // In case user changes a page, close the popup.\n                                owner.autoDispose(this.activeViewId.addListener(popupOptions.close));\n\n                                // In case the section is removed, close the popup.\n                                popupOptions.viewSection.autoDispose({ dispose: popupOptions.close });\n\n                                const { recordCard, rowId } = popupOptions.hash;\n                                if (recordCard) {\n                                  if (!rowId || rowId === \"new\") {\n                                    // Should be unreachable, but just to be sure (and to satisfy type checking)...\n                                    throw new Error(\"Unable to open Record Card: undefined row id\");\n                                  }\n\n                                  return dom.create(RecordCardPopup, {\n                                    gristDoc: this,\n                                    rowId,\n                                    viewSection: popupOptions.viewSection,\n                                    onClose: popupOptions.close,\n                                  });\n                                } else {\n                                  return dom.create(RawDataPopup, this, popupOptions.viewSection, popupOptions.close);\n                                }\n                              });\n                            }),\n                          ]\n        );\n      }),\n      dom.maybe(this._showBackgroundVideoPlayer, () => [\n        cssBackgroundVideo(\n          this._backgroundVideoPlayerHolder.get()?.buildDom(),\n          cssBackgroundVideo.cls(\"-fade-in-and-out\", this._isRickRowing),\n          testId(\"gristdoc-background-video\"),\n        ),\n      ]),\n    );\n  }\n\n  // Open the given page. Note that links to pages should use <a> elements together with setLinkUrl().\n  public openDocPage(viewId: IDocPage) {\n    return urlState().pushUrl({ docPage: viewId });\n  }\n\n  public setComparison(comparison: DocStateComparison | null) {\n    this.comparison = comparison;\n    for (const model of Object.values(this._diffModels)) {\n      model.dispose();\n    }\n    this._diffModels = {};\n  }\n\n  public showTool(tool: typeof RightPanelTool.type): void {\n    this._rightPanelTool.set(tool);\n  }\n\n  public async onSetCursorPos(rowModel: BaseRowModel | undefined, fieldModel?: ViewFieldRec) {\n    return this._setCursorPos({\n      rowIndex: rowModel?._index() || 0,\n      fieldIndex: fieldModel?._index() || 0,\n      sectionId: fieldModel?.viewSection().getRowId(),\n    });\n  }\n\n  /**\n   * Switch to the view/section and scroll to the record indicated by cursorPos. If cursorPos is\n   * null, then moves to a position best suited for optActionGroup (not yet implemented).\n   */\n  public async moveToCursorPos(cursorPos?: CursorPos, optActionGroup?: MinimalActionGroup): Promise<void> {\n    if (!cursorPos?.sectionId) {\n      // TODO We could come up with a suitable cursorPos here based on the action itself.\n      // This should only come up if trying to undo/redo after reloading a page (since the cursorPos\n      // associated with the action is only stored in memory of the current JS process).\n      // A function like `getCursorPosForActionGroup(ag)` would also be useful to jump to the best\n      // place from any action in the action log.\n      // When user deletes table from Raw Data view, the section id will be 0 and undoing that\n      // operation will move cursor to the empty section row (with id 0).\n      return;\n    }\n    try {\n      await this._setCursorPos(cursorPos);\n    } catch (e) {\n      reportError(e);\n    }\n  }\n\n  public getUndoStack() {\n    return this._undoStack;\n  }\n\n  public getActionCounter() {\n    return this._actionCounter;\n  }\n\n  public getTableModel(tableId: string): DataTableModel {\n    return this.docModel.getTableModel(tableId);\n  }\n\n  // Get a DataTableModel, possibly wrapped to include diff data if a comparison is\n  // in effect.\n  public getTableModelMaybeWithDiff(tableId: string): DataTableModel {\n    const tableModel = this.getTableModel(tableId);\n    if (!this.comparison?.details) {\n      return tableModel;\n    }\n    if (!this._diffModels[tableId]) {\n      this._diffModels[tableId] = this.autoDispose(new DataTableModelWithDiff(tableModel, this.comparison.details));\n    }\n    return this._diffModels[tableId];\n  }\n\n  /**\n   * Sends an action to create a new empty table and switches to that table's primary view.\n   */\n  public async addEmptyTable(): Promise<void> {\n    const name = await this._promptForName();\n    if (name === undefined) {\n      return;\n    }\n    const tableInfo = await this.docData.sendAction([\"AddEmptyTable\", name || null]);\n    await this.openDocPage(this.docModel.tables.getRowModel(tableInfo.id).primaryViewId());\n  }\n\n  /**\n   * Adds a view section described by val to the current page.\n   */\n  public async addWidgetToPage(widget: IPageWidget): Promise<void> {\n    const { table, type } = widget;\n    let tableId: string | null | undefined;\n    if (table === \"New Table\") {\n      tableId = await this._promptForName();\n      if (tableId === undefined) {\n        return;\n      }\n    }\n    if (type === \"custom\") {\n      return showCustomWidgetGallery(this, {\n        addWidget: () => this._addWidgetToPage(widget, tableId),\n      });\n    }\n\n    const viewName = this.viewModel.name.peek();\n    await this.docData.bundleActions(\n      t(\"Added new linked section to view {{viewName}}\", { viewName }),\n      () => this._addWidgetToPage(widget, tableId ?? null),\n    );\n    return;\n  }\n\n  /**\n   * Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`.\n   */\n  public async addNewPage(val: IPageWidget): Promise<void> {\n    const { table, type } = val;\n    let tableId: string | null | undefined;\n    if (table === \"New Table\") {\n      tableId = await this._promptForName();\n      if (tableId === undefined) { return; }\n    }\n    if (type === \"custom\") {\n      return showCustomWidgetGallery(this, {\n        addWidget: () => this._addPage(val, tableId ?? null) as Promise<{\n          viewRef: number;\n          sectionRef: number;\n        }>,\n      });\n    }\n\n    const { sectionRef, viewRef } = await this.docData.bundleActions(\n      \"Add new page\",\n      () => this._addPage(val, tableId ?? null),\n    );\n    await this._focus({ sectionRef, viewRef });\n    this._showNewWidgetPopups(type);\n  }\n\n  public async saveViewSection(section: ViewSectionRec, newVal: IPageWidget) {\n    const docData = this.docModel.docData;\n    const oldVal: IPageWidget = toPageWidget(section);\n    const viewModel = section.view();\n    const colIds = section.viewFields().all().map(f => f.column().colId());\n\n    if (isEqual(oldVal, newVal)) {\n      // nothing to be done\n      return section;\n    }\n\n    return await this.viewLayout!.freezeUntil(docData.bundleActions(\n      t(\"Saved linked section {{title}} in view {{name}}\", { title: section.title(), name: viewModel.name() }),\n      async () => {\n        // if table changes or a table is made a summary table, let's replace the view section by a\n        // new one, and return.\n        if (oldVal.table !== newVal.table || oldVal.summarize !== newVal.summarize) {\n          return await this._replaceViewSection(section, oldVal, newVal);\n        }\n\n        // if type changes, let's save it.\n        if (oldVal.type !== newVal.type) {\n          await section.parentKey.saveOnly(newVal.type);\n        }\n\n        // if grouped by column changes, let's use the specific user action.\n        if (!isEqual(oldVal.columns, newVal.columns)) {\n          await docData.sendAction(\n            [\"UpdateSummaryViewSection\", section.getRowId(), newVal.columns],\n          );\n          // Charts needs to keep view fields consistent across update.\n          if (newVal.type === \"chart\" && oldVal.type === \"chart\") {\n            await this._setSectionViewFieldsFromArray(section, colIds);\n          }\n        }\n\n        // update link\n        if (oldVal.link !== newVal.link) {\n          await this.saveLink(newVal.link);\n        }\n        return section;\n      },\n      { nestInActiveBundle: true },\n    ));\n  }\n\n  // Save link for a given section, by default the active section.\n  public async saveLink(linkId: string, sectionId?: number) {\n    sectionId = sectionId || this.viewModel.activeSection.peek().getRowId();\n    const link = linkFromId(linkId);\n    if (link.targetColRef) {\n      const targetTable = this.docModel.viewSections.getRowModel(sectionId).table();\n      const targetCol = this.docModel.columns.getRowModel(link.targetColRef);\n      if (targetTable.id() !== targetCol.table().id()) {\n        // targetColRef is actually not a column in the target table.\n        // This should mean that the target table is a summary table (which didn't exist when the\n        // option was selected) and targetColRef is from the source table.\n        // Change it to the corresponding summary table column instead.\n        link.targetColRef = targetTable.columns().all().find(c => c.summarySourceCol() === link.targetColRef)!.id();\n      }\n    }\n    return this.docData.sendAction(\n      [\"UpdateRecord\", \"_grist_Views_section\", sectionId, {\n        linkSrcSectionRef: link.srcSectionRef,\n        linkSrcColRef: link.srcColRef,\n        linkTargetColRef: link.targetColRef,\n      }],\n    );\n  }\n\n  // Returns the list of all the valid links to link from one of the sections in the active view to\n  // the page widget 'widget'.\n  public selectBy(widget: IPageWidget) {\n    const viewSections = this.viewModel.viewSections.peek().peek();\n    return selectBy(this.docModel, viewSections, widget);\n  }\n\n  // Fork the document if it is in prefork mode.\n  public async forkIfNeeded() {\n    if (this.docPageModel.isPrefork.get()) {\n      await this.docComm.forkAndUpdateUrl();\n    }\n  }\n\n  public getCsvLink() {\n    const params = this._getDocApiDownloadParams();\n    return this.docPageModel.appModel.api.getDocAPI(this.docId()).getDownloadCsvUrl(params);\n  }\n\n  public getTsvLink() {\n    const params = this._getDocApiDownloadParams();\n    return this.docPageModel.appModel.api.getDocAPI(this.docId()).getDownloadTsvUrl(params);\n  }\n\n  public getDsvLink() {\n    const params = this._getDocApiDownloadParams();\n    return this.docPageModel.appModel.api.getDocAPI(this.docId()).getDownloadDsvUrl(params);\n  }\n\n  public getXlsxActiveViewLink() {\n    const params = this._getDocApiDownloadParams();\n    return this.docPageModel.appModel.api.getDocAPI(this.docId()).getDownloadXlsxUrl(params);\n  }\n\n  /**\n   * Move to the desired cursor position.  If colRef is supplied, the cursor will be\n   * moved to a field with that colRef.  Any linked sections that need their cursors\n   * moved in order to achieve the desired outcome are handled recursively.\n   * If setAsActiveSection is true, the section in cursorPos is set as the current\n   * active section.\n   */\n  public async recursiveMoveToCursorPos(\n    cursorPos: CursorPos,\n    setAsActiveSection: boolean,\n    silent: boolean = false,\n    visitedSections: number[] = []): Promise<boolean> {\n    try {\n      if (!cursorPos.sectionId) {\n        throw new Error(\"sectionId required\");\n      }\n      if (!cursorPos.rowId) {\n        throw new Error(\"rowId required\");\n      }\n      const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId);\n      if (!section.id.peek()) {\n        throw new Error(`Section ${cursorPos.sectionId} does not exist`);\n      }\n\n      if (visitedSections.includes(section.id.peek())) {\n        // We've already been here (we hit a cycle), just return immediately\n        return true;\n      }\n\n      const srcSection = section.linkSrcSection.peek();\n      const linkingRowId = cursorPos.linkingRowIds?.[0];\n      const linkingRowIds = cursorPos.linkingRowIds?.slice(1);\n      if (srcSection.id.peek()) {\n        // We're in a linked section, so we need to recurse to make sure the row we want\n        // will be visible.\n        const linkTargetCol = section.linkTargetCol.peek();\n        let controller: any;\n        if (linkTargetCol.colId.peek()) {\n          const destTable = await this._getTableData(section);\n          if (cursorPos.rowId === \"new\") {\n            controller = \"new\";\n          } else {\n            controller = destTable.getValue(cursorPos.rowId, linkTargetCol.colId.peek());\n          }\n        } else {\n          controller = cursorPos.rowId;\n        }\n        const colId = section.linkSrcCol.peek().colId.peek();\n        let srcRowId: any;\n        const isSrcSummary = srcSection.table.peek().summarySource.peek().id.peek();\n        if (!colId && !isSrcSummary) {\n          // Simple case - source linked by rowId, not a summary.\n          if (isList(controller)) {\n            // Should be a reference list. Use linkingRowId if available and present in the list,\n            if (linkingRowId && controller.indexOf(linkingRowId) > 0) {\n              controller = linkingRowId;\n            } else {\n              // Otherwise, pick the first reference.\n              controller = controller[1];  // [0] is the L type code, [1] is the first value\n            }\n          } else if (controller === \"new\" && linkingRowId) {\n            controller = linkingRowId;\n          }\n          srcRowId = controller;\n        } else {\n          const srcTable = await this._getTableData(srcSection);\n          const query: ClientQuery = { tableId: srcTable.tableId, filters: {}, operations: {} };\n          if (colId) {\n            query.operations[colId] = isRefListType(section.linkSrcCol.peek().type.peek()) ? \"intersects\" : \"in\";\n            query.filters[colId] = isList(controller) ? controller.slice(1) : [controller];\n          } else {\n            // must be a summary -- otherwise dealt with earlier.\n            const destTable = await this._getTableData(section);\n            for (const srcCol of srcSection.table.peek().groupByColumns.peek()) {\n              const filterCol = srcCol.summarySource.peek();\n              const filterColId = filterCol.colId.peek();\n              controller = destTable.getValue(cursorPos.rowId, filterColId);\n              // If the source groupby column is a ChoiceList or RefList, then null or '' in the summary table\n              // should match against an empty list in the source table.\n              query.operations[filterColId] = isListType(filterCol.type.peek()) && !controller ? \"empty\" : \"in\";\n              query.filters[filterColId] = isList(controller) ? controller.slice(1) : [controller];\n            }\n          }\n          srcRowId = srcTable.getRowIds().find(getFilterFunc(this.docData, query));\n        }\n        if (!srcRowId || (typeof srcRowId !== \"number\" && srcRowId !== \"new\")) {\n          throw new Error(\"cannot trace rowId\");\n        }\n        await this.recursiveMoveToCursorPos({\n          rowId: srcRowId,\n          sectionId: srcSection.id.peek(),\n          linkingRowIds,\n        }, false, silent, visitedSections.concat([section.id.peek()]));\n      }\n      const view: ViewRec = section.view.peek();\n      const isRawOrRecordCardView = section.isRaw.peek() || section.isRecordCard.peek();\n      const docPage: ViewDocPage = isRawOrRecordCardView ? \"data\" : view.getRowId();\n      if (docPage != this.activeViewId.get()) {\n        await this.openDocPage(docPage);\n      }\n      if (setAsActiveSection) {\n        view.activeSectionId(cursorPos.sectionId);\n      }\n      const fieldIndex = cursorPos.fieldIndex;\n      const viewInstance = await waitObs(section.viewInstance);\n      if (!viewInstance) {\n        throw new Error(\"view not found\");\n      }\n      // Give any synchronous initial cursor setting a chance to happen.\n      await delay(0);\n      viewInstance.setCursorPos({ ...cursorPos, fieldIndex });\n      // TODO: column selection not working on card/detail view, or getting overridden -\n      // look into it (not a high priority for now since feature not easily discoverable\n      // in this view).\n\n      // even though the cursor is at right place, the scroll could not have yet happened\n      // wait for a bit (scroll is done in a setTimeout 0)\n      await delay(0);\n      return true;\n    } catch (e) {\n      console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`);\n      if (!silent) {\n        throw new UserError(\"There was a problem finding the desired cell.\");\n      }\n      return false;\n    }\n  }\n\n  /**\n   * Opens up an editor at cursor position\n   * @param input Optional. Cell's initial value\n   */\n  public async activateEditorAtCursor(options?: { init?: string, state?: any }) {\n    const view = await this._waitForView();\n    view?.activateEditorAtCursor(options);\n  }\n\n  /**\n   * Copy an anchor link for the current row (or comment) to the clipboard.\n   */\n  public async copyAnchorLink(anchorInfo: HashLink & CursorPos) {\n    const hash: HashLink = anchorInfo;\n    if (!hash.colRef && anchorInfo.fieldIndex && anchorInfo.sectionId) {\n      const section = this.docModel.viewSections.getRowModel(anchorInfo.sectionId);\n      const column = section.viewFields.peek().peek()[anchorInfo.fieldIndex].column.peek();\n      hash.colRef = column.id.peek();\n    }\n    try {\n      const link = urlState().makeUrl({ hash });\n      await copyToClipboard(link);\n      setTestState({ clipboard: link });\n      reportSuccess(\"Link copied to clipboard\", { key: \"clipboard\" });\n    } catch (e) {\n      throw new Error(\"cannot copy to clipboard\");\n    }\n  }\n\n  /**\n   * Renames table. Method exposed primarily for tests.\n   */\n  public async testRenameTable(tableId: string, newTableName: string) {\n    const tableRec = this.docModel.visibleTables.all().find(tb => tb.tableId.peek() === tableId);\n    if (!tableRec) {\n      throw new UserError(`No table with id ${tableId}`);\n    }\n    await tableRec.tableName.saveOnly(newTableName);\n  }\n\n  public getActionLog(): ActionLog {\n    return this._actionLog;\n  }\n\n  // Set section's viewFields to be colIds in that order. Omit any column id that do not belong to\n  // section's table.\n  private async _setSectionViewFieldsFromArray(section: ViewSectionRec, colIds: string[]) {\n    // remove old view fields\n    await Promise.all(section.viewFields.peek().all().map(viewField => (\n      this.docModel.viewFields.sendTableAction([\"RemoveRecord\", viewField.id()]) ?? Promise.resolve(undefined)\n    )));\n\n    // create map\n    const mapColIdToColumn = new Map();\n    for (const col of section.table().columns().all()) {\n      mapColIdToColumn.set(col.colId(), col);\n    }\n\n    // If split series and/or x-axis do not exist any more in new table, update options to make them\n    // undefined\n    if (colIds.length) {\n      if (section.optionsObj.prop(\"multiseries\")()) {\n        if (!mapColIdToColumn.has(colIds[0])) {\n          await section.optionsObj.prop(\"multiseries\").saveOnly(false);\n        }\n        if (colIds.length > 1 && !mapColIdToColumn.has(colIds[1])) {\n          await section.optionsObj.prop(\"isXAxisUndefined\").saveOnly(true);\n        }\n      } else if (!mapColIdToColumn.has(colIds[0])) {\n        await section.optionsObj.prop(\"isXAxisUndefined\").saveOnly(true);\n      }\n    }\n\n    // adds new view fields; ignore colIds that do not exist in new table.\n    await Promise.all(colIds.map((colId, i) => {\n      if (!mapColIdToColumn.has(colId)) {\n        return Promise.resolve(undefined);\n      }\n      const colInfo = {\n        parentId: section.id(),\n        colRef: mapColIdToColumn.get(colId).id(),\n        parentPos: i,\n      };\n      const action = [\"AddRecord\", null, colInfo];\n      return this.docModel.viewFields.sendTableAction(action) ?? Promise.resolve(undefined);\n    }));\n  }\n\n  private async _onCreateForm() {\n    const table = this.currentView.get()?.viewSection.tableRef.peek();\n    if (!table) {\n      return;\n    }\n    await this.addWidgetToPage({\n      ...DefaultPageWidget(),\n      table,\n      type: WidgetType.Form,\n    });\n    commands.allCommands.expandSection.run();\n  }\n\n  private _onDocChatter(message: CommDocChatter) {\n    if (!this.docComm.isActionFromThisDoc(message)) {\n      return;\n    }\n\n    if (message.data.webhooks) {\n      if (message.data.webhooks.type == \"webhookOverflowError\") {\n        this.trigger(\"webhookOverflowError\",\n          t(\"New changes are temporarily suspended. Webhooks queue overflowed. \\\nPlease check webhooks settings, remove invalid webhooks, and clean the queue.\"));\n      } else {\n        this.trigger(\"webhooks\", message.data.webhooks);\n      }\n    } else if (message.data.timing) {\n      this.isTimingOn.set(message.data.timing.status !== \"disabled\");\n    } else if (message.data.attachmentTransfer) {\n      // This is message about the attachments transfer job. Look at the comment\n      // for the observable for more info.\n      this.attachmentTransfer.set(message.data.attachmentTransfer);\n    }\n  }\n\n  /**\n   * Process usage and product received from the server by updating their respective\n   * observables.\n   */\n  private _onDocUsageMessage(message: CommDocUsage) {\n    if (!this.docComm.isActionFromThisDoc(message)) {\n      return;\n    }\n\n    bundleChanges(() => {\n      this.docPageModel.updateCurrentDocUsage(message.data.docUsage);\n      this.docPageModel.currentProduct.set(message.data.product ?? null);\n    });\n  }\n\n  /**\n   * Process actions received from the server by forwarding them to `docData.receiveAction()` and\n   * pushing them to actionLog.\n   */\n  private _onDocUserAction(message: CommDocUserAction) {\n    console.log(\"GristDoc.onDocUserAction\", message);\n    let schemaUpdated = false;\n    /**\n     * If an operation is applied successfully to a document, and then information about\n     * it is broadcast to clients, and one of those broadcasts has a failure (due to\n     * granular access control, which is client-specific), then that error is logged on\n     * the server and also sent to the client via an `error` field.  Under normal operation,\n     * there should be no such errors, but if they do arise it is best to make them as visible\n     * as possible.\n     */\n    if (message.data.error) {\n      reportError(new Error(message.data.error));\n      return;\n    }\n    if (this.docComm.isActionFromThisDoc(message)) {\n      const docActions = message.data.docActions;\n      for (let i = 0, len = docActions.length; i < len; i++) {\n        console.log(\"GristDoc applying #%d\", i, docActions[i]);\n        this.docData.receiveAction(docActions[i]);\n        this.docPluginManager.receiveAction(docActions[i]);\n\n        if (!schemaUpdated && isSchemaAction(docActions[i])) {\n          schemaUpdated = true;\n        }\n      }\n      // Add fromSelf property to actionGroup indicating if it's from the current session.\n      const actionGroup = message.data.actionGroup;\n      actionGroup.fromSelf = message.fromSelf || false;\n      // Push to the actionLog and the undoStack.\n      if (!actionGroup.internal) {\n        this._actionLog.pushAction(actionGroup);\n        this._undoStack.pushAction(actionGroup);\n        this._actionCounter.pushAction(actionGroup);\n        if (actionGroup.fromSelf) {\n          this._lastOwnActionGroup = actionGroup;\n        }\n      }\n      // Set latestActionState once we've processed the action.\n      this.latestActionState.set({\n        h: message.data.actionGroup.actionHash,\n        n: message.data.actionGroup.actionNum,\n      });\n      if (schemaUpdated) {\n        this.trigger(\"schemaUpdateAction\", docActions);\n      }\n      this.docPageModel.updateCurrentDocUsage(message.data.docUsage);\n      this.trigger(\"onDocUserAction\", docActions);\n    }\n  }\n\n  private async _setCursorPos(cursorPos: CursorPos) {\n    if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) {\n      const desiredSection: ViewSectionRec = this.docModel.viewSections.getRowModel(cursorPos.sectionId);\n      // If the section id is 0, the section doesn't exist (can happen during undo/redo), and should\n      // be fixed there. For now ignore it, to not create empty sections or views (peeking a view will create it).\n      if (!desiredSection.id.peek()) {\n        return;\n      }\n      // If this is completely unknown section (without a parent), it is probably an import preview.\n      if (\n        !desiredSection.parentId.peek() &&\n        !desiredSection.isRaw.peek() &&\n        !desiredSection.isRecordCard.peek()\n      ) {\n        const view = desiredSection.viewInstance.peek();\n        // Make sure we have a view instance here - it will prove our assumption that this is\n        // an import preview. Section might also be disconnected during undo/redo.\n        if (view && !view.isDisposed()) {\n          view.setCursorPos(cursorPos);\n          return;\n        }\n      }\n      if (desiredSection.view.peek().getRowId() !== this.activeViewId.get()) {\n        // This may be asynchronous. In other cases, the change is synchronous, and some code\n        // relies on it (doesn't wait for this function to resolve).\n        await this._switchToSectionId(cursorPos.sectionId);\n      } else if (desiredSection !== this.viewModel.activeSection.peek()) {\n        this.viewModel.activeSectionId(cursorPos.sectionId);\n      }\n    }\n    const viewInstance = this.viewModel.activeSection.peek().viewInstance.peek();\n    viewInstance?.setCursorPos(cursorPos);\n  }\n\n  /**\n   * Returns an object representing the position of the cursor, including the section. It will have\n   * fields { sectionId, rowId, fieldIndex }. Fields may be missing if no section is active.\n   */\n  private _getCursorPos(): CursorPos {\n    const pos = { sectionId: this.viewModel.activeSectionId() };\n    const viewInstance = this.viewModel.activeSection.peek().viewInstance.peek();\n    return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {});\n  }\n\n  private async _addWidgetToPage(\n    widget: IPageWidget,\n    tableId: string | null = null,\n    { focus = true, popups = true }: AddSectionOptions = {},\n  ) {\n    const { columns, link, summarize, table, type } = widget;\n    const viewRef = this.activeViewId.get();\n    if (typeof viewRef !== \"number\") {\n      // Report to make it easier to debug, but we shouldn't offer any UI options that lead here.\n      throw new Error(`Cannot add a widget to this type of page (${viewRef})`);\n    }\n    const tableRef = table === \"New Table\" ? 0 : table;\n    const result: { viewRef: number, sectionRef: number } = await this.docData.sendAction(\n      [\"CreateViewSection\", tableRef, viewRef, type, summarize ? columns : null, tableId],\n    );\n    if (type === \"chart\") {\n      await this._ensureOneNumericSeries(result.sectionRef);\n    }\n    if (type === \"form\") {\n      await this._setDefaultFormLayoutSpec(result.sectionRef);\n    }\n    await this.saveLink(link, result.sectionRef);\n    const widgetType = getTelemetryWidgetTypeFromPageWidget(widget);\n    logTelemetryEvent(\"addedWidget\", { full: { docIdDigest: this.docId(), widgetType } });\n    if (link !== NoLink) {\n      logTelemetryEvent(\"linkedWidget\", { full: { docIdDigest: this.docId(), widgetType } });\n    }\n    if (focus) { await this._focus({ sectionRef: result.sectionRef }); }\n    if (popups) { this._showNewWidgetPopups(type); }\n    return result;\n  }\n\n  private async _addPage(\n    widget: IPageWidget,\n    tableId: string | null = null,\n    { focus = true, popups = true }: AddSectionOptions = {},\n  ) {\n    const { columns, summarize, table, type } = widget;\n    let viewRef: number;\n    let sectionRef: number | undefined;\n    if (table === \"New Table\") {\n      if (type === WidgetType.Table) {\n        const result = await this.docData.sendAction([\"AddEmptyTable\", tableId]);\n        viewRef = result.views[0].id;\n      } else {\n        // This will create a new table and page.\n        const result = await this.docData.sendAction(\n          [\"CreateViewSection\", 0, 0, type, null, tableId],\n        );\n        [viewRef, sectionRef] = [result.viewRef, result.sectionRef];\n      }\n    } else {\n      const result = await this.docData.sendAction(\n        [\"CreateViewSection\", table, 0, type, summarize ? columns : null, null],\n      );\n      [viewRef, sectionRef] = [result.viewRef, result.sectionRef];\n      if (type === \"chart\") {\n        await this._ensureOneNumericSeries(sectionRef!);\n      }\n    }\n    if (type === \"form\") {\n      await this._setDefaultFormLayoutSpec(sectionRef!);\n    }\n    logTelemetryEvent(\"addedPage\", { full: { docIdDigest: this.docId() } });\n    logTelemetryEvent(\"addedWidget\", {\n      full: {\n        docIdDigest: this.docId(),\n        widgetType: getTelemetryWidgetTypeFromPageWidget(widget),\n      },\n    });\n    if (focus) { await this._focus({ viewRef, sectionRef }); }\n    if (popups) { this._showNewWidgetPopups(type); }\n    return { viewRef, sectionRef };\n  }\n\n  private async _focus({ viewRef, sectionRef}: { viewRef?: number, sectionRef?: number }) {\n    if (viewRef) { await this.openDocPage(viewRef); }\n    if (sectionRef) { this.viewModel.activeSectionId(sectionRef); }\n  }\n\n  private _showNewWidgetPopups(type: IWidgetType) {\n    this._maybeShowEditCardLayoutTip(type).catch(reportError);\n\n    if (AttachedCustomWidgets.guard(type)) {\n      this._handleNewAttachedCustomWidget(type).catch(reportError);\n    }\n  }\n\n  /**\n   * Opens popup with a section data (used by Raw Data view).\n   */\n  private async _openPopup(hash: HashLink) {\n    // We can only open a popup for a section.\n    if (!hash.sectionId) {\n      return;\n    }\n    if (!this._prevSectionId) {\n      this._prevSectionId = this.viewModel.activeSection.peek().id();\n    }\n    // We might open popup either for a section in this view or some other section (like Raw Data Page).\n    if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId)) {\n      this.viewModel.activeSectionId(hash.sectionId);\n      // If the anchor link is valid, set the cursor.\n      if (hash.colRef || hash.rowId) {\n        const activeSection = this.viewModel.activeSection.peek();\n        const { rowId } = hash;\n        let fieldIndex = undefined;\n        if (hash.colRef) {\n          const maybeFieldIndex = activeSection.viewFields.peek().all()\n            .findIndex(f => f.colRef.peek() === hash.colRef);\n          if (maybeFieldIndex !== -1) { fieldIndex = maybeFieldIndex; }\n        }\n        const view = await this._waitForView(activeSection);\n        view?.setCursorPos({ rowId, fieldIndex });\n      }\n      this.viewLayout?.maximized.set(hash.sectionId);\n      return;\n    }\n    this._isShowingPopupSection = true;\n    // We will borrow active viewModel and will trick him into believing that\n    // the section from the link is his viewSection and it is active. Fortunately\n    // he doesn't care. After popup is closed, we will restore the original.\n    this.viewModel.activeSectionId(hash.sectionId);\n    // Now we have view section we want to show in the popup.\n    const popupSection = this.viewModel.activeSection.peek();\n    // We need to make it active, so that cursor on this section will be the\n    // active one. This will change activeViewSectionId on a parent view of this section,\n    // which might be a different view from what we currently have. If the section is\n    // a raw data or record card section, it will use `EmptyRowModel` as these sections\n    // don't currently have parent views.\n    popupSection.hasFocus(true);\n    this._popupSectionOptions.set({\n      hash,\n      viewSection: popupSection,\n      close: () => {\n        // In case we are already closed, do nothing.\n        if (!this._popupSectionOptions.get()) {\n          return;\n        }\n        if (popupSection.id() !== this._prevSectionId) {\n          // We need to blur the popup section. Otherwise it will automatically be opened\n          // on raw data view. Note: raw data and record card sections don't have parent views;\n          // they use the empty row model as a parent (which feels like a hack).\n          if (!popupSection.isDisposed()) {\n            popupSection.hasFocus(false);\n          }\n          // When this popup was opened we tricked active view by setting its activeViewSection\n          // to our viewSection (which might be a completely different section or a raw data section) not\n          // connected to this view. We need to return focus back to the previous section.\n          this._focusPreviousSection();\n        }\n        // Clearing popup section data will close this popup.\n        this._popupSectionOptions.set(null);\n      },\n    });\n    // If the anchor link is valid, set the cursor.\n    if (hash.rowId || hash.colRef) {\n      const { rowId } = hash;\n      let fieldIndex;\n      if (hash.colRef) {\n        const maybeFieldIndex = popupSection.viewFields.peek().all()\n          .findIndex(f => f.colRef.peek() === hash.colRef);\n        if (maybeFieldIndex !== -1) { fieldIndex = maybeFieldIndex; }\n      }\n      const view = await this._waitForView(popupSection);\n      view?.setCursorPos({ rowId, fieldIndex });\n    }\n  }\n\n  /**\n   * Starts playing the music video for Never Gonna Give You Up in the background.\n   */\n  private async _playRickRollVideo() {\n    const backgroundVideoPlayer = this._backgroundVideoPlayerHolder.get();\n    if (!backgroundVideoPlayer) {\n      return;\n    }\n\n    await backgroundVideoPlayer.isLoaded();\n    backgroundVideoPlayer.play();\n\n    const setVolume = async (start: number, end: number, step: number) => {\n      let volume: number;\n      const condition = start <= end ?\n        () => volume <= end :\n        () => volume >= end;\n      const afterthought = start <= end ?\n        () => volume += step :\n        () => volume -= step;\n      for (volume = start; condition(); afterthought()) {\n        backgroundVideoPlayer.setVolume(volume);\n        await delay(250);\n      }\n    };\n\n    await setVolume(0, 100, 5);\n\n    await delay(190 * 1000);\n    if (!this._isRickRowing.get()) {\n      return;\n    }\n\n    await setVolume(100, 0, 5);\n\n    this._isRickRowing.set(false);\n    this._showBackgroundVideoPlayer.set(false);\n  }\n\n  private _focusPreviousSection() {\n    const prevSectionId = this._prevSectionId;\n    if (!prevSectionId) { return; }\n\n    if (\n      this.viewModel.viewSections.peek().all().some(s =>\n        !s.isDisposed() && s.id.peek() === prevSectionId)\n    ) {\n      this.viewModel.activeSectionId(prevSectionId);\n    }\n    this._prevSectionId = null;\n  }\n\n  /**\n   * Waits for a view to be ready\n   */\n  private async _waitForView(popupSection?: ViewSectionRec) {\n    const sectionToCheck = popupSection ?? this.viewModel.activeSection.peek();\n    // For pages like ACL's, there isn't a view instance to wait for.\n    if (!sectionToCheck.getRowId()) {\n      return null;\n    }\n\n    async function singleWait(s: ViewSectionRec): Promise<BaseView> {\n      const view = await waitObs(\n        sectionToCheck.viewInstance,\n        vsi => Boolean(vsi && !vsi.isDisposed()),\n      );\n      return view!;\n    }\n\n    let view = await singleWait(sectionToCheck);\n    if (view.isDisposed()) {\n      // If the view is disposed (it can happen, as wait is not reliable enough, because it uses\n      // subscription for testing the predicate, which might dispose object before we have a chance to test it).\n      // This can happen when section is recreating itself on a popup.\n      if (popupSection) {\n        view = await singleWait(popupSection);\n      }\n      if (view.isDisposed()) {\n        return null;\n      }\n    }\n    await view.getLoadingDonePromise();\n    // Wait extra bit for scroll to happen.\n    await delay(0);\n    return view;\n  }\n\n  private _getToolContent(tool: typeof RightPanelTool.type): IExtraTool | null {\n    switch (tool) {\n      case \"docHistory\": {\n        return { icon: \"Log\", label: \"Document History\", content: this._docHistory };\n      }\n      case \"validations\": {\n        const content = this._rightPanelTabs.get(\"Validate Data\");\n        return content ? { icon: \"Validation\", label: \"Validation Rules\", content } : null;\n      }\n      case \"discussion\": {\n        return { icon: \"Chat\", label: this._discussionPanel.buildMenu(), content: this._discussionPanel };\n      }\n      case \"none\":\n      default: {\n        return null;\n      }\n    }\n  }\n\n  private async _maybeShowEditCardLayoutTip(selectedWidgetType: IWidgetType) {\n    if (\n      // Don't show the tip if a non-card widget was selected.\n      ![\"single\", \"detail\"].includes(selectedWidgetType) ||\n      // Or if we shouldn't see the tip.\n      !this.behavioralPromptsManager.shouldShowPopup(\"editCardLayout\")\n    ) {\n      return;\n    }\n\n    // Open the right panel to the widget subtab.\n    commands.allCommands.viewTabOpen.run();\n\n    // Wait for the right panel to finish animation if it was collapsed before.\n    await commands.allCommands.rightPanelOpen.run();\n\n    const editLayoutButton = document.querySelector(\".behavioral-prompt-edit-card-layout\");\n    if (!editLayoutButton) {\n      throw new Error(\"GristDoc failed to find edit card layout button\");\n    }\n\n    this.behavioralPromptsManager.showPopup(editLayoutButton, \"editCardLayout\", {\n      popupOptions: {\n        placement: \"left-start\",\n      },\n    });\n  }\n\n  private async _handleNewAttachedCustomWidget(widget: IAttachedCustomWidget) {\n    switch (widget) {\n      case \"custom.calendar\": {\n        if (this.behavioralPromptsManager.shouldShowPopup(\"calendarConfig\")) {\n          // Open the right panel to the calendar subtab.\n          commands.allCommands.viewTabOpen.run();\n\n          // Wait for the right panel to finish animation if it was collapsed before.\n          await commands.allCommands.rightPanelOpen.run();\n        }\n        break;\n      }\n    }\n  }\n\n  private async _promptForName() {\n    return await invokePrompt(\"Table name\", {\n      btnText: \"Create\",\n      initial: \"\",\n      placeholder: \"Default table name\",\n    });\n  }\n\n  private async _replaceViewSection(\n    section: ViewSectionRec,\n    oldVal: IPageWidget,\n    newVal: IPageWidget,\n  ) {\n    const docModel = this.docModel;\n    const viewModel = section.view();\n    const docData = this.docModel.docData;\n    const options = section.options();\n    const colIds = section.viewFields().all().map(f => f.column().colId());\n    const chartType = section.chartType();\n    const sectionTheme = section.theme();\n\n    // we must read the current layout from the view layout because it can override the one in\n    // `section.layoutSpec` (in particular it provides a default layout when missing from the\n    // latter).\n    const layoutSpec = this.viewLayout!.getFullLayoutSpec();\n\n    const sectionTitle = section.title();\n    const sectionId = section.id();\n\n    // create a new section\n    const sectionCreationResult = await this._addWidgetToPage(newVal, null, { focus: false, popups: false });\n\n    // update section name\n    const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);\n    await newSection.title.saveOnly(sectionTitle);\n\n    // replace old section id with new section id in the layout spec and save\n    const newLayoutSpec = cloneDeepWith(layoutSpec, (val) => {\n      if (typeof val === \"object\" && val.leaf === sectionId) {\n        return { ...val, leaf: newSection.id() };\n      }\n    });\n    await viewModel.layoutSpec.saveOnly(JSON.stringify(newLayoutSpec));\n\n    // persist options\n    await newSection.options.saveOnly(options);\n\n    // charts needs to keep view fields consistent across updates\n    if (oldVal.type === \"chart\" && newVal.type === \"chart\") {\n      await this._setSectionViewFieldsFromArray(newSection, colIds);\n    }\n\n    // update theme, and chart type\n    await newSection.theme.saveOnly(sectionTheme);\n    await newSection.chartType.saveOnly(chartType);\n\n    // The newly-added section should be given focus.\n    this.viewModel.activeSectionId(newSection.getRowId());\n\n    // remove old section\n    await docData.sendAction([\"RemoveViewSection\", sectionId]);\n    return newSection;\n  }\n\n  /**\n   * Helper called before an action is sent to the server. It saves cursor position to come back to\n   * in case of Undo.\n   */\n  private _onSendActionsStart(ev: { cursorPos: CursorPos }) {\n    this._lastOwnActionGroup = null;\n    ev.cursorPos = this._getCursorPos();\n  }\n\n  /**\n   * Helper called when server responds to an action. It attaches the saved cursor position to the\n   * received action (if any), and stores also the resulting position.\n   */\n  private _onSendActionsEnd(ev: { cursorPos: CursorPos }) {\n    const a = this._lastOwnActionGroup;\n    if (a) {\n      a.cursorPos = ev.cursorPos;\n      if (a.rowIdHint) {\n        a.cursorPos.rowId = a.rowIdHint;\n      }\n    }\n  }\n\n  private _getDocApiDownloadParams() {\n    const activeSection = this.viewModel.activeSection();\n    const filters = activeSection.activeFilters.get().map(filterInfo => ({\n      colRef: filterInfo.fieldOrColumn.origCol().origColRef(),\n      filter: filterInfo.filter(),\n    }));\n    const linkingFilter: FilterColValues = activeSection.linkingFilter();\n    const userOverride = this.docPageModel.userOverride.get();\n\n    return {\n      viewSection: this.viewModel.activeSectionId(),\n      tableId: activeSection.table().tableId(),\n      activeSortSpec: JSON.stringify(activeSection.activeSortSpec()),\n      filters: JSON.stringify(filters),\n      linkingFilter: JSON.stringify(linkingFilter),\n      ...(userOverride ? { aclAsUser_: userOverride.user?.email } : {}),\n    };\n  }\n\n  /**\n   * Switch to a given sectionId, wait for it to load, and return a Promise for the instantiated\n   * viewInstance (such as an instance of GridView or DetailView).\n   */\n  private async _switchToSectionId(sectionId: number) {\n    const section: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionId);\n    if (section.isRaw.peek() || section.isRecordCard.peek()) {\n      // This is a raw data or record card view.\n      await urlState().pushUrl({ docPage: \"data\" });\n      this.viewModel.activeSectionId(sectionId);\n    } else if (section.isVirtual.peek()) {\n      // this is a virtual table, and therefore a webhook page (that is the only\n      // place virtual tables are used so far)\n      await urlState().pushUrl({ docPage: \"webhook\" });\n      this.viewModel.activeSectionId(sectionId);\n    } else {\n      const view: ViewRec = section.view.peek();\n      await this.openDocPage(view.getRowId());\n      view.activeSectionId(sectionId);  // this.viewModel will reflect this with a delay.\n    }\n\n    // Returns the value of section.viewInstance() as soon as it is truthy.\n    return waitObs(section.viewInstance);\n  }\n\n  private async _getTableData(section: ViewSectionRec): Promise<TableData> {\n    const viewInstance = await waitObs(section.viewInstance);\n    if (!viewInstance) {\n      throw new Error(\"view not found\");\n    }\n    await viewInstance.getLoadingDonePromise();\n    const table = this.docData.getTable(section.table.peek().tableId.peek());\n    if (!table) {\n      throw new Error(\"no section table\");\n    }\n    return table;\n  }\n\n  /**\n   * Convert a url hash to a cursor position.\n   */\n  private _getCursorPosFromHash(hash: HashLink): CursorPos {\n    const cursorPos: CursorPos = { rowId: hash.rowId, sectionId: hash.sectionId };\n    if (cursorPos.sectionId != undefined && hash.colRef !== undefined) {\n      // translate colRef to a fieldIndex\n      const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId);\n      const fieldIndex = section.viewFields.peek().all()\n        .findIndex(x => x.colRef.peek() == hash.colRef);\n      if (fieldIndex >= 0) {\n        cursorPos.fieldIndex = fieldIndex;\n      }\n      cursorPos.linkingRowIds = hash.linkingRowIds;\n    }\n    return cursorPos;\n  }\n\n  /**\n   * Returns whether a doc tour should automatically be started.\n   *\n   * Currently, tours are started if a non-empty GristDocTour table exists and the\n   * user hasn't seen the tour before.\n   */\n  private async _shouldAutoStartDocTour(): Promise<boolean> {\n    if (\n      this._disableAutoStartingTours ||\n      this.docModel.isTutorial() ||\n      !this.docModel.hasDocTour() ||\n      this._seenDocTours.get()?.includes(this.docId())\n    ) {\n      return false;\n    }\n\n    const tableData = this.docData.getTable(\"GristDocTour\")!;\n    await this.docData.fetchTable(\"GristDocTour\");\n    return tableData.numRecords() > 0;\n  }\n\n  /**\n   * Returns whether a welcome tour should automatically be started.\n   *\n   * Currently, tours are started for first-time users on a personal org, as long as\n   * a doc tutorial or tour isn't available.\n   */\n  private _shouldAutoStartWelcomeTour(): boolean {\n    // For non-SaaS flavors of Grist, don't show the tour if the Help Center is explicitly\n    // disabled. A separate opt-out feature could be added down the road for more granularity,\n    // but will require communication in advance to avoid disrupting users.\n    const { features } = getGristConfig();\n    if (!features?.includes(\"helpCenter\")) {\n      return false;\n    }\n\n    // If a doc tutorial or tour are available, leave the welcome tour for another\n    // doc (e.g. a new one).\n    if (this._disableAutoStartingTours || this.docModel.isTutorial() || this.docModel.hasDocTour()) {\n      return false;\n    }\n\n    // Only show the tour if one is on a personal org and can edit. This excludes templates (on\n    // the Templates org, which may have their own tour) and team sites (where user's intended\n    // role is often other than document creator).\n    const appModel = this.docPageModel.appModel;\n    if (!appModel.currentOrg?.owner || this.isReadonly.get()) {\n      return false;\n    }\n    // Use the showGristTour pref if set; otherwise default to true for anonymous users, and false\n    // for real returning users.\n    return this._showGristTour.get() ?? (!appModel.currentValidUser);\n  }\n\n  /**\n   * Makes sure that the first y-series (ie: the view fields at index 1) is a numeric series. Does\n   * not handle chart with the group by option on: it is only intended to be used to make sure that\n   * newly created chart do have a visible y series.\n   */\n  private async _ensureOneNumericSeries(id: number) {\n    const viewSection = this.docModel.viewSections.getRowModel(id);\n    const viewFields = viewSection.viewFields.peek().peek();\n\n    // If no y-series, then simply return.\n    if (viewFields.length === 1) {\n      return;\n    }\n\n    const field = viewSection.viewFields.peek().peek()[1];\n    if (isNumericOnly(viewSection.chartTypeDef.peek()) &&\n      !isNumericLike(field.column.peek())) {\n      const actions: UserAction[] = [];\n\n      // remove non-numeric field\n      actions.push([\"RemoveRecord\", field.id.peek()]);\n\n      // add new field\n      const newField = viewSection.hiddenColumns.peek().find(col => isNumericLike(col));\n      if (newField) {\n        const colInfo = {\n          parentId: viewSection.id.peek(),\n          colRef: newField.id.peek(),\n        };\n        actions.push([\"AddRecord\", null, colInfo]);\n      }\n\n      // send actions\n      await this.docModel.viewFields.sendTableActions(actions);\n    }\n  }\n\n  private async _setDefaultFormLayoutSpec(viewSectionId: number) {\n    const viewSection = this.docModel.viewSections.getRowModel(viewSectionId);\n    const viewFields = viewSection.viewFields.peek().peek();\n    await viewSection.layoutSpecObj.setAndSave(buildDefaultFormLayout(viewFields));\n  }\n\n  private _handleTriggerQueueOverflowMessage() {\n    this.listenTo(this, \"webhookOverflowError\", (err: any) => {\n      this.app.topAppModel.notifier.createNotification({\n        message: err.toString(),\n        canUserClose: false,\n        level: \"error\",\n        badgeCounter: true,\n        expireSec: 5,\n        key: \"webhookOverflowError\",\n        actions: [{\n          label: t(\"go to webhook settings\"), action: async () => {\n            await urlState().pushUrl({ docPage: \"webhook\" });\n          },\n        }],\n      });\n    });\n  }\n}\n\nasync function finalizeAnchor() {\n  await urlState().pushUrl({ hash: {} }, { replace: true });\n  setTestState({ anchorApplied: true });\n}\n\nconst cssViewContentPane = styled(\"div\", `\n  --view-content-page-padding: 12px;\n  flex: auto;\n  display: flex;\n  flex-direction: column;\n  overflow: visible;\n  position: relative;\n  min-width: 240px;\n  padding: var(--view-content-page-padding, 12px);\n  @media ${mediaSmall} {\n    & {\n      padding: 4px;\n    }\n  }\n  @media print {\n    & {\n      padding: 0px;\n    }\n  }\n  &-special-page {\n    overflow: hidden;\n    padding: 0px;\n  }\n`);\n\nconst fadeInAndOut = keyframes(`\n  0% {\n    opacity: 0.01;\n  }\n  5%, 95% {\n    opacity: 0.2;\n  }\n  100% {\n    opacity: 0.01;\n  }\n`);\n\nconst cssBackgroundVideo = styled(\"div\", `\n  position: fixed;\n  top: 0;\n  right: 0;\n  height: 100%;\n  width: 100%;\n  opacity: 0;\n  pointer-events: none;\n\n  &-fade-in-and-out {\n    animation: ${fadeInAndOut} 200s;\n  }\n`);\n\nconst cssYouTubePlayer = styled(\"div\", `\n  position: absolute;\n  width: 450%;\n  height: 450%;\n  top: -175%;\n  left: -175%;\n\n  @media ${mediaXSmall} {\n    & {\n      width: 450%;\n      height: 450%;\n      top: -175%;\n      left: -175%;\n    }\n  }\n`);\n\nconst cssStopRickRowingButton = styled(\"div\", `\n  position: fixed;\n  top: 0;\n  right: 0;\n  padding: 8px;\n  margin: 16px;\n  border-radius: 24px;\n  background-color: ${theme.toastBg};\n  cursor: pointer;\n`);\n\nconst cssCloseIcon = styled(icon, `\n  height: 24px;\n  width: 24px;\n  --icon-color: ${theme.toastControlFg};\n`);\n"
  },
  {
    "path": "app/client/components/GristWSConnection.ts",
    "content": "import { GristClientSocket } from \"app/client/components/GristClientSocket\";\nimport { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { guessTimezone } from \"app/client/lib/guessTimezone\";\nimport { getSessionStorage } from \"app/client/lib/storage\";\nimport { newUserAPIImpl } from \"app/client/models/AppModel\";\nimport { getWorker } from \"app/client/models/gristConfigCache\";\nimport { CommResponseBase } from \"app/common/CommTypes\";\nimport * as gutil from \"app/common/gutil\";\nimport { addOrgToPath, docUrl, getGristConfig } from \"app/common/urlUtils\";\nimport { UserAPI } from \"app/common/UserAPI\";\n\nimport { Events as BackboneEvents } from \"backbone\";\nimport { Disposable } from \"grainjs\";\n\nconst G = getBrowserGlobals(\"window\");\nconst reconnectInterval = [1000, 1000, 2000, 5000, 10000];\n\n// Time that may elapse prior to triggering a heartbeat message.  This is a message\n// sent in order to keep the websocket from being closed by an intermediate load\n// balancer.\nconst HEARTBEAT_PERIOD_IN_SECONDS = 45;\n\n// Find the correct worker to connect to for the currently viewed doc,\n// returning a base url for endpoints served by that worker.  The url\n// may need to change again in future.\nasync function getDocWorkerUrl(assignmentId: string | null): Promise<string | null> {\n  // Currently, a null assignmentId happens only in classic Grist, where the server\n  // never changes.\n  if (assignmentId === null) { return docUrl(null); }\n\n  const api: UserAPI = newUserAPIImpl();\n  return getWorker(api, assignmentId);\n}\n\n/**\n * Settings for the Grist websocket connection.  Includes timezone, urls, and client id,\n * and various services needed for the connection.\n */\nexport interface GristWSSettings {\n  // A factory function for creating the WebSocket so that we can use from node\n  // or browser.\n  makeWebSocket(url: string): GristClientSocket;\n\n  // A function for getting the timezone, so the code can be used outside webpack -\n  // currently a timezone library is lazy loaded in a way that doesn't quite work\n  // with ts-node.\n  getTimezone(): Promise<string>;\n\n  // Get the page url - this is how the organization is currently determined.\n  getPageUrl(): string;\n\n  // Get the URL for the worker serving the given assignmentId (which is usually a docId).\n  getDocWorkerUrl(assignmentId: string | null): Promise<string | null>;\n\n  // Get an id associated with the client, null for \"no id set yet\".\n  getClientId(assignmentId: string | null): string | null;\n\n  // Get selector for user, so if cookie auth allows multiple the correct one will be picked.\n  // Selector is currently just the email address.\n  getUserSelector(): string;\n\n  // Update the id associated with the client.  Future calls to getClientId should return this.\n  updateClientId(assignmentId: string | null, clentId: string): void;\n\n  // Returns the next identifier for a new GristWSConnection object, and advance the counter.\n  advanceCounter(): string;\n\n  // Called with messages to log.\n  log(...args: any[]): void;\n  warn(...args: any[]): void;\n}\n\n/**\n * An implementation of Grist websocket connection settings for the browser.\n */\nexport class GristWSSettingsBrowser implements GristWSSettings {\n  private _sessionStorage = getSessionStorage();\n\n  public makeWebSocket(url: string) { return new GristClientSocket(url); }\n  public getTimezone()              { return guessTimezone(); }\n  public getPageUrl()               { return G.window.location.href; }\n  public async getDocWorkerUrl(assignmentId: string | null) {\n    return getDocWorkerUrl(assignmentId);\n  }\n\n  public getClientId(assignmentId: string | null) {\n    return this._sessionStorage.getItem(`clientId_${assignmentId}`) || null;\n  }\n\n  public getUserSelector(): string {\n    // TODO: find/create a more official way to get the user.\n    return window.gristDocPageModel?.appModel.currentUser?.email || \"\";\n  }\n\n  public updateClientId(assignmentId: string | null, id: string) {\n    this._sessionStorage.setItem(`clientId_${assignmentId}`, id);\n  }\n\n  public advanceCounter(): string {\n    const value = parseInt(this._sessionStorage.getItem(\"clientCounter\")!, 10) || 0;\n    this._sessionStorage.setItem(\"clientCounter\", String(value + 1));\n    return String(value);\n  }\n\n  public log(...args: any[]): void {\n    console.log(...args);\n  }\n\n  public warn(...args: any[]): void {\n    console.warn(...args);\n  }\n}\n\n/**\n * GristWSConnection establishes a connection to the server and keep reconnecting\n * in the event that it loses the connection.\n */\nexport class GristWSConnection extends Disposable {\n  public useCount: number = 0;\n  public on: BackboneEvents[\"on\"];    // set by Backbone\n  public off: BackboneEvents[\"off\"];    // set by Backbone\n\n  protected trigger: BackboneEvents[\"trigger\"]; // set by Backbone\n\n  private _clientId: string | null;\n  private _clientCounter: string;     // Identifier of this GristWSConnection object in this browser tab session\n  private _assignmentId: string | null;\n  private _docWorkerUrl: string | null = null;\n  private _initialConnection: Promise<void>;\n  private _established: boolean = false;     // This is set once the server sends us a 'clientConnect' message.\n  private _firstConnect: boolean = true;\n  private _heartbeatTimeout: ReturnType<typeof setTimeout> | null = null;\n  private _reconnectTimeout: ReturnType<typeof setTimeout> | null = null;\n  private _reconnectAttempts: number = 0;\n  private _wantReconnect: boolean = true;\n  private _ws: GristClientSocket | null = null;\n\n  // The server sends incremental seqId numbers with each message on the connection, starting with\n  // 0. We keep track of them to allow for seamless reconnects.\n  private _lastReceivedSeqId: number | null = null;\n\n  constructor(private _settings: GristWSSettings = new GristWSSettingsBrowser()) {\n    super();\n    this._clientCounter = _settings.advanceCounter();\n    this.onDispose(() => this.disconnect());\n  }\n\n  public initialize(assignmentId: string | null) {\n    // For reconnections, squirrel away the id of the resource we are committed to (if any).\n    this._assignmentId = assignmentId;\n    // clientId is associated with a session. We try to persist it within a tab across navigation\n    // and reloads, but the server may reset it if it doesn't recognize it.\n    this._clientId = this._settings.getClientId(assignmentId);\n    // For the DocMenu, identified as a page served with a homeUrl but no getWorker cache, we will\n    // simply not hook up the websocket.  The client is not ready to use it, and the server is not\n    // ready to serve it.  And the errors in the logs of both are distracting.  However, it\n    // doesn't really make sense to rip out the websocket code entirely, since the plan is\n    // to eventually bring it back for smoother serving.  Hence this compromise of simply\n    // not trying to make the connection.\n    // TODO: serve and use websockets for the DocMenu.\n    if (getGristConfig().getWorker) {\n      this.trigger(\"connectState\", false);\n      this._initialConnection = this.connect();\n    } else {\n      this._log(\"GristWSConnection not activating for hosted grist page with no document present\");\n    }\n  }\n\n  /**\n   * Method that opens a websocket connection and continuously tries to reconnect if the connection\n   * is closed.\n   * @param isReconnecting - Flag set when attempting to reconnect\n   */\n  public async connect(isReconnecting: boolean = false): Promise<void> {\n    await this._updateDocWorkerUrl();\n    this._wantReconnect = true;\n    this._connectImpl(isReconnecting, await this._settings.getTimezone());\n  }\n\n  // Disconnect websocket if currently connected, and reset to initial state.\n  public disconnect() {\n    this._log(\"GristWSConnection: disconnect\");\n    this._wantReconnect = false;\n    this._established = false;\n    if (this._ws) {\n      this._ws.close();\n      this._ws = null;\n      this._clientId = null;\n    }\n    this._clearHeartbeat();\n    if (this._reconnectTimeout) {\n      clearTimeout(this._reconnectTimeout);\n    }\n    this._firstConnect = true;\n    this._reconnectAttempts = 0;\n  }\n\n  public get established(): boolean {\n    return this._established;\n  }\n\n  public get clientId(): string | null {\n    return this._clientId;\n  }\n\n  /**\n   * Returns the URL of the doc worker, or throws if we don't have one.\n   */\n  public get docWorkerUrl(): string {\n    if (!this._docWorkerUrl) { throw new Error(\"server for document not known\"); }\n    return this._docWorkerUrl;\n  }\n\n  /**\n   * Returns the URL of the doc worker, or null if we don't have one.\n   */\n  public getDocWorkerUrlOrNull(): string | null {\n    return this._docWorkerUrl;\n  }\n\n  /**\n   * Triggered when a message arrives from the server.\n   */\n  public onmessage(data: string) {\n    if (!this._ws) {\n      // It's possible to receive a message after we disconnect, at least in tests (where\n      // WebSocket is a node library). Ignoring is easier than unsubscribing properly.\n      return;\n    }\n    this._scheduleHeartbeat();\n    this._processReceivedMessage(data, true);\n  }\n\n  public send(message: any) {\n    this._log(`GristWSConnection.send[${this.established}]`, message);\n    if (!this._established) {\n      return false;\n    }\n    this._ws!.send(message);\n    this._scheduleHeartbeat();\n    return true;\n  }\n\n  private _processReceivedMessage(msgData: string, processClientConnect: boolean) {\n    this._log(\"GristWSConnection: onmessage (%d bytes)\", msgData.length);\n    const message: CommResponseBase & { seqId: number } = JSON.parse(msgData);\n\n    if (typeof message.seqId === \"number\") {\n      // For sequenced messages (all except clientConnect), check that seqId is as expected, and\n      // update this._lastReceivedSeqId.\n      if (this._lastReceivedSeqId !== null && message.seqId !== this._lastReceivedSeqId + 1) {\n        this._log(\"GristWSConnection: unexpected seqId after %s: %s\", this._lastReceivedSeqId, message.seqId);\n        this.disconnect();\n        return;\n      }\n      this._lastReceivedSeqId = message.seqId;\n    }\n\n    // clientConnect is the first message from the server that sets the clientId. We only consider\n    // the connection established once we receive it.\n    let needReload = false;\n    if (\"type\" in message && message.type === \"clientConnect\" && processClientConnect) {\n      if (this._established) {\n        this._log(\"GristWSConnection skipping duplicate 'clientConnect' message\");\n        return;\n      }\n      this._established = true;\n\n      // Update flag to indicate if the active session changed, and warrants a reload. (The server\n      // should be setting needReload too, so this shouldn't be strictly needed.)\n      if (message.clientId !== this._clientId && !this._firstConnect) {\n        message.needReload = true;\n      }\n      needReload = Boolean(message.needReload);\n      this._log(`GristWSConnection established: clientId ${message.clientId} counter ${this._clientCounter}` +\n        ` needReload ${needReload}`);\n      if (message.dup) {\n        this._warn(\"GristWSConnection missed initial 'clientConnect', processing its duplicate\");\n      }\n      if (message.clientId !== this._clientId) {\n        this._clientId = message.clientId;\n        this._settings.updateClientId(this._assignmentId, message.clientId);\n      }\n      this._firstConnect = false;\n      this.trigger(\"connectState\", true);\n\n      // Process any missed messages. (Should only have any if needReload is false.)\n      if (!needReload && message.missedMessages) {\n        for (const msg of message.missedMessages) {\n          this._processReceivedMessage(msg, false);\n        }\n      }\n    }\n    if (!this._established) {\n      this._log(\"GristWSConnection not yet established; ignoring message\", message);\n      return;\n    }\n\n    if (needReload) {\n      // If we are unable to resume this connection, disconnect to avoid accept more messages on\n      // this connection, they are likely to only cause errors. Elsewhere, the app will reload.\n      this._log(\"GristWSConnection: needReload\");\n      this.disconnect();\n    }\n    this.trigger(\"serverMessage\", message);\n  }\n\n  // unschedule any pending heartbeat message\n  private _clearHeartbeat() {\n    if (this._heartbeatTimeout) {\n      clearTimeout(this._heartbeatTimeout);\n      this._heartbeatTimeout = null;\n    }\n  }\n\n  // schedule a heartbeat message for HEARTBEAT_PERIOD_IN_SECONDS seconds from now\n  private _scheduleHeartbeat() {\n    this._clearHeartbeat();\n    this._heartbeatTimeout = setTimeout(this._sendHeartbeat.bind(this),\n      Math.round(HEARTBEAT_PERIOD_IN_SECONDS * 1000));\n  }\n\n  // send a heartbeat message, including the document url for server-side logs\n  private _sendHeartbeat() {\n    this.send(JSON.stringify({\n      beat: \"alive\",\n      url: this._settings.getPageUrl(),\n      docId: this._assignmentId,\n    }));\n  }\n\n  private _connectImpl(isReconnecting: boolean, timezone: any) {\n    if (!this._wantReconnect) { return; }\n\n    if (isReconnecting) {\n      this._reconnectAttempts++;\n    }\n\n    let url: string;\n    try {\n      url = this._buildWebsocketUrl(isReconnecting, timezone);\n    } catch (e) {\n      this._warn(\"Failed to get the URL for the worker serving the document\");\n      this._scheduleReconnect(isReconnecting);\n      return;\n    }\n\n    // Note that if a WebSocket can't establish a connection it will trigger onclose()\n    // As per http://dev.w3.org/html5/websockets/\n    // \"If the establish a WebSocket connection algorithm fails,\n    // it triggers the fail the WebSocket connection algorithm,\n    // which then invokes the close the WebSocket connection algorithm,\n    // which then establishes that the WebSocket connection is closed,\n    // which fires the close event.\"\n    this._log(\"GristWSConnection connecting to: \" + url);\n    this._ws = this._settings.makeWebSocket(url);\n\n    this._ws.onopen = () => {\n      const connectMessage = isReconnecting ? \"Reconnected\" : \"Connected\";\n      this._log(\"GristWSConnection: onopen: \" + connectMessage);\n\n      this.trigger(\"connectionStatus\", connectMessage, \"OK\");\n      this._reconnectAttempts = 0; // reset reconnection information\n      this._scheduleHeartbeat();\n    };\n\n    this._ws.onmessage = this.onmessage.bind(this);\n\n    this._ws.onerror = (err: Error) => {\n      this._log(\"GristWSConnection: onerror\", String(err));\n    };\n\n    this._ws.onclose = () => {\n      if (this.isDisposed()) {\n        return;\n      }\n\n      this._log(\"GristWSConnection: onclose\");\n      this._established = false;\n      this._ws = null;\n      this.trigger(\"connectState\", false);\n\n      if (!this._wantReconnect) { return; }\n      this._scheduleReconnect(true);\n    };\n  }\n\n  private _scheduleReconnect(isReconnecting: boolean) {\n    const reconnectTimeout = gutil.getReconnectTimeout(this._reconnectAttempts, reconnectInterval);\n    this._log(\"Trying to reconnect in\", reconnectTimeout, \"ms\");\n    this.trigger(\"connectionStatus\", \"Trying to reconnect...\", \"WARNING\");\n    this._reconnectTimeout = setTimeout(async () => {\n      this._reconnectTimeout = null;\n      // Make sure we've gotten through all lazy-loading.\n      await this._initialConnection;\n      await this.connect(isReconnecting);\n    }, reconnectTimeout);\n  }\n\n  private _buildWebsocketUrl(isReconnecting: boolean, timezone: any): string {\n    const url = new URL(this.docWorkerUrl);\n    url.protocol = (url.protocol === \"https:\") ? \"wss:\" : \"ws:\";\n    url.searchParams.append(\"clientId\", this._clientId || \"0\");\n    url.searchParams.append(\"counter\", this._clientCounter);\n    url.searchParams.append(\"newClient\", String(isReconnecting ? 0 : 1));\n    if (isReconnecting && this._lastReceivedSeqId !== null) {\n      url.searchParams.append(\"lastSeqId\", String(this._lastReceivedSeqId));\n    }\n    url.searchParams.append(\"browserSettings\", JSON.stringify({ timezone }));\n    url.searchParams.append(\"user\", this._settings.getUserSelector());\n    return url.href;\n  }\n\n  private async _updateDocWorkerUrl() {\n    try {\n      const url: string | null = await this._settings.getDocWorkerUrl(this._assignmentId);\n      // Doc worker urls in general will need to have org information in them, since\n      // the doc worker will check for that.  The home service doesn't currently do\n      // that for us, although it could.  TODO: update home server to produce\n      // standalone doc worker urls.\n      this._docWorkerUrl = url ? addOrgToPath(url, this._settings.getPageUrl()) : url;\n    } catch (e) {\n      this._warn(\"Failed to connect to server for document\");\n    }\n  }\n\n  private _log = (...args: any[]) => this._settings.log(...args);\n  private _warn = (...args: any[]) => this._settings.warn(...args);\n}\n\nObject.assign(GristWSConnection.prototype, BackboneEvents);\n"
  },
  {
    "path": "app/client/components/Importer.ts",
    "content": "/**\n * Importer manages an import files to Grist tables\n * TODO: hidden tables should be also deleted on page refresh, error...\n */\n\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { buildParseOptionsForm, ParseOptionValues } from \"app/client/components/ParseOptions\";\nimport { PluginScreen } from \"app/client/components/PluginScreen\";\nimport { makeTestId } from \"app/client/lib/domUtils\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { ImportSourceElement } from \"app/client/lib/ImportSourceElement\";\nimport { makeT } from \"app/client/lib/localization\";\nimport {\n  EXTENSIONS_IMPORTABLE_WITHIN_DOC, fetchURL, isDriveUrl, selectFiles, uploadFiles,\n} from \"app/client/lib/uploads\";\nimport { reportError } from \"app/client/models/AppModel\";\nimport { ColumnRec, ViewFieldRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { SortedRowSet } from \"app/client/models/rowset\";\nimport { buildHighlightedCode } from \"app/client/ui/CodeHighlight\";\nimport { openFilePicker } from \"app/client/ui/FileDialog\";\nimport {\n  ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading,\n} from \"app/client/ui/googleAuth\";\nimport { cssPageIcon } from \"app/client/ui/LeftPanelCommon\";\nimport { hoverTooltip, overflowTooltip } from \"app/client/ui/tooltips\";\nimport { bigBasicButton, bigPrimaryButton, textButton } from \"app/client/ui2018/buttons\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { testId as baseTestId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { IOptionFull, menuDivider, menuItem, multiSelect, selectMenu, selectOption } from \"app/client/ui2018/menus\";\nimport { cssModalTitle } from \"app/client/ui2018/modals\";\nimport { openFormulaEditor } from \"app/client/widgets/FormulaEditor\";\nimport {\n  DataSourceTransformed,\n  DestId,\n  ImportResult,\n  ImportTableResult,\n  MergeOptions,\n  MergeOptionsMap,\n  MergeStrategy,\n  NEW_TABLE,\n  SKIP_TABLE,\n  TransformColumn,\n  TransformRule,\n  TransformRuleMap,\n} from \"app/common/ActiveDocAPI\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { byteString, not } from \"app/common/gutil\";\nimport { FetchUrlOptions, UploadResult } from \"app/common/uploads\";\nimport { ParseOptions, ParseOptionSchema } from \"app/plugin/FileParserAPI\";\n\nimport {\n  Computed,\n  Disposable,\n  dom,\n  DomContents,\n  fromKo,\n  Holder,\n  IDisposable,\n  MultiHolder,\n  MutableObsArray,\n  obsArray,\n  Observable,\n  styled,\n  UseCBOwner,\n} from \"grainjs\";\nimport debounce from \"lodash/debounce\";\n\nconst t = makeT(\"Importer\");\n// Custom testId that can be appended conditionally.\nconst testId = makeTestId(\"test-importer-\");\n\n// We expect a function for creating the preview GridView, to avoid the need to require the\n// GridView module here. That brings many dependencies, making a simple test fixture difficult.\ntype CreatePreviewFunc = (vs: ViewSectionRec) => GridView;\ntype GridView = IDisposable & { viewPane: HTMLElement, sortedRows: SortedRowSet };\nconst TABLE_MAPPING = 1;\nconst COLUMN_MAPPING = 2;\ntype ViewType = typeof TABLE_MAPPING | typeof COLUMN_MAPPING;\n\n/**\n * Information returned by the backend of the current import state, and how the table and sections look there.\n * Also contains some UI state, so it is updated with the data that comes from the backend.\n */\nexport interface SourceInfo {\n  /** Table id that holds the imported data. */\n  hiddenTableId: string;\n  /** Uploaded file index */\n  uploadFileIndex: number;\n  /** Table name that was figured out by the backend. File name or tab in excel name */\n  origTableName: string;\n  /**\n   * Section that contains only imported columns. It is not shown to the user.\n   * Table besides the imported data have formula columns that are used to finalize import. Those formula\n   * columns are not part of this section.\n   */\n  sourceSection: ViewSectionRec;\n  /**\n   * A viewSection containing transform (formula) columns pointing to the original source columns.\n   * When user selects New table, they are basically formulas pointing to the source columns.\n   * When user selects Existing table, new formula columns are created that look like the selected table, and this\n   * section contains those formula columns.\n   */\n  transformSection: Observable<ViewSectionRec | null>;\n  /** The destination table id, selected by the user. Can be null for skip and empty string for `New table`  */\n  destTableId: Observable<DestId>;\n  /** True if there is at least one request in progress to create a new transform section. */\n  isLoadingSection: Observable<boolean>;\n  /** Reference to last promise for the GenImporterView action (which creates `transformSection`). */\n  lastGenImporterViewPromise: Promise<any> | null;\n  /** Selected view, can be table mapping or column mapping, used only in UI. */\n  selectedView: Observable<ViewType>;\n  /** List of columns that were customized (have custom formulas) */\n  customizedColumns: Observable<Set<string>>;\n}\n\n/** Changes the customization flag for the column */\nfunction toggleCustomized(info: SourceInfo, colId: string, on: boolean): void {\n  const customizedColumns = info.customizedColumns.get();\n  if (!on) {\n    customizedColumns.delete(colId);\n  } else {\n    customizedColumns.add(colId);\n  }\n  info.customizedColumns.set(new Set(customizedColumns));\n}\n\n/**\n * UI state for each imported table (file). Maps table id to the info object.\n */\ninterface MergeOptionsStateMap {\n  [hiddenTableId: string]: MergeOptionsState | undefined;\n}\n\n/**\n * UI state of merge options for a SourceInfo.\n */\ninterface MergeOptionsState {\n  /**\n   * Whether to update existing records or only add new ones. If false, mergeCols is empty.\n   */\n  updateExistingRecords: Observable<boolean>;\n  /**\n   * List of column ids to merge on if user set `updateExistingRecords` to true. Those are columns from the\n   * target table.\n   */\n  mergeCols: MutableObsArray<string>;\n  /**\n   * Merge strategy to use, not used currently.\n   */\n  mergeStrategy: Observable<MergeStrategy>;\n  /**\n   * Whether mergeCols contains invalid columns (set in the code to show error message).\n   */\n  hasInvalidMergeCols: Observable<boolean>;\n}\n\n/**\n * Imports using the given plugin importer.\n */\nexport async function selectAndImport(\n  gristDoc: GristDoc,\n  imports: ImportSourceElement[],\n  importSourceElem: ImportSourceElement,\n  createPreview: CreatePreviewFunc,\n) {\n  // HACK: The url plugin does not support importing from google drive, and we don't want to\n  // ask a user for permission to access all his files (needed to download a single file from an URL).\n  // So to have a nice user experience, we will switch to the built-in google drive plugin and allow\n  // user to chose a file manually.\n  // Suggestion for the future is:\n  // (1) ask the user for the greater permission,\n  // (2) detect when the permission is not granted, and open the picker-based plugin in that case.\n  try {\n    // Importer disposes itself when its dialog is closed, so we do not take ownership of it.\n    await Importer.create(null, gristDoc, importSourceElem, createPreview).pickAndUploadSource(null);\n  } catch (err1) {\n    // If the url was a Google Drive Url, run the google drive plugin.\n    if (!(err1 instanceof GDriveUrlNotSupported)) {\n      reportError(err1);\n    } else {\n      const gdrivePlugin = imports.find(p => p.plugin.definition.id === \"builtIn/gdrive\" && p !== importSourceElem);\n      if (!gdrivePlugin) {\n        reportError(err1);\n      } else {\n        try {\n          await Importer.create(null, gristDoc, gdrivePlugin, createPreview).pickAndUploadSource(null);\n        } catch (err2) {\n          reportError(err2);\n        }\n      }\n    }\n  }\n}\n\n/**\n * Imports from file.\n */\nexport async function importFromFile(gristDoc: GristDoc, createPreview: CreatePreviewFunc) {\n  // In case of using built-in file picker we want to get upload result before instantiating Importer\n  // because if the user dismisses the dialog without picking a file,\n  // there is no good way to detect this and dispose Importer.\n  let uploadResult: UploadResult | null = null;\n  // Use the built-in file picker. On electron, it uses the native file selector (without\n  // actually uploading anything), which is why this requires a slightly different flow.\n  const files: File[] = await openFilePicker({\n    multiple: true,\n    accept: EXTENSIONS_IMPORTABLE_WITHIN_DOC.join(\",\"),\n  });\n  // Important to fork first before trying to import, so we end up uploading to a\n  // consistent doc worker.\n  await gristDoc.forkIfNeeded();\n  const label = files.map(f => f.name).join(\", \");\n  const size = files.reduce((acc, f) => acc + f.size, 0);\n  const app = gristDoc.app.topAppModel.appObs.get();\n  const progress = app ? app.notifier.createProgressIndicator(label, byteString(size)) : null;\n  const onProgress = (percent: number) => progress?.setProgress(percent);\n  try {\n    onProgress(0);\n    uploadResult = await uploadFiles(files, { docWorkerUrl: gristDoc.docComm.docWorkerUrl,\n      sizeLimit: \"import\" }, onProgress);\n    onProgress(100);\n  } finally {\n    if (progress) {\n      progress.dispose();\n    }\n  }\n  // Importer disposes itself when its dialog is closed, so we do not take ownership of it.\n  await Importer.create(null, gristDoc, null, createPreview).pickAndUploadSource(uploadResult);\n}\n\n/**\n * Importer manages an import files to Grist tables and shows Preview\n */\nexport class Importer extends DisposableWithEvents {\n  private _docComm = this._gristDoc.docComm;\n  private _uploadResult?: UploadResult;\n\n  private _screen: PluginScreen;\n  private _optionsScreenHolder = Holder.create(this);\n  /**\n   * Merge information (for updating existing rows).\n   */\n  private _mergeOptions: MergeOptionsStateMap = {};\n  /**\n   * Parsing options (for parsing the file), passed to the backend directly.\n   */\n  private _parseOptions = Observable.create<ParseOptions>(this, {});\n  /**\n   * Info about the data that was parsed from the imported files (or tabs in excel).\n   */\n  private _sourceInfoArray = Observable.create<SourceInfo[]>(this, []);\n  /**\n   * Currently selected table to import (a file or a tab in excel).\n   */\n  private _sourceInfoSelected = Observable.create<SourceInfo | null>(this, null);\n\n  // Owner of the observables in the _sourceInfoArray\n  private readonly _sourceInfoHolder = Holder.create(this);\n\n  // Holder for the column mapping formula editor.\n  private readonly _formulaEditorHolder = Holder.create(this);\n\n  /**\n   * Helper for the preview section (the transformSection from the backend). The naming is misleading a bit, sorry\n   * about that, but this transform section is shown to the user as a Grid.\n   *\n   * We need a helper to make sure section is in good state before showing it to the user.\n   */\n  private _previewViewSection: Observable<ViewSectionRec | null> =\n    Computed.create(this, this._sourceInfoSelected, (use, info) => {\n      if (!info) { return null; }\n\n      const isLoading = use(info.isLoadingSection);\n      if (isLoading) { return null; }\n\n      const viewSection = use(info.transformSection);\n      return viewSection && !viewSection.isDisposed() && !use(viewSection._isDeleted) ? viewSection : null;\n    });\n\n  /**\n   * True if there is at least one request in progress to generate an import diff.\n   */\n  private _isLoadingDiff = Observable.create(this, false);\n  // Promise for the most recent generateImportDiff action.\n  private _lastGenImportDiffPromise: Promise<any> | null = null;\n\n  private _debouncedUpdateDiff = debounce(this._updateDiff, 1000, { leading: true, trailing: true });\n\n  /**\n   * Flag that is set when _updateImportDiff is called, and unset when _debouncedUpdateDiff begins executing.\n   *\n   * This is a workaround until Lodash's next release, which supports checking if a debounced function is\n   * pending. We need to know if more debounced calls are pending so that we can decide to take down the\n   * loading spinner over the preview table, or leave it up until all scheduled calls settle.\n   */\n  private _hasScheduledDiffUpdate = false;\n\n  /**\n   * destTables is a list of tables user can choose to import data into, in the format suitable for the UI to consume.\n   */\n  private _destTables = Computed.create<IOptionFull<DestId>[]>(this, use => [\n    ...use(this._gristDoc.docModel.visibleTableIds.getObservable()).map(id => ({ value: id, label: id })),\n  ]);\n\n  /**\n   * List of transform fields, i.e. those formula fields of the transform section whose values will be used to\n   * populate the destination columns.\n   * For `New table` those fields are 1-1 with columns imported from the file.\n   * For `Existing table` those are fields that simulate the target table columns.\n   * In UI we will call it `GRIST COLUMNS`, whereas source columns will be called `SOURCE COLUMNS`.\n   *\n   * This is helper that makes sure that those fields from the transformSection are in a good state to show.\n   */\n  private _transformFields: Computed<ViewFieldRec[] | null> = Computed.create(\n    this, this._sourceInfoSelected, (use, info) => {\n      const section = info && use(info.transformSection);\n      if (!section || use(section._isDeleted)) { return null; }\n      return use(use(section.viewFields).getObservable());\n    });\n\n  /**\n   * Prepare a Map, mapping of colRef of each transform column to the set of options to offer in\n   * the dropdown. The options are represented as a Map too, mapping formula to label.\n   *\n   * It only matters for importing into existing table. Transform column are perceived as GRIST COLUMNS, so those\n   * columns that will be updated or imported into.\n   *\n   * For each of such column, this will create a map of possible options to choose in from (except SKIP).\n   * The result is a map (treated as just list of Records), with a formula and label to show in the UI.\n   * This formula will be used to update the target helper column, when user selects it.\n   *\n   * For example:\n   * File has those columns: `Name`, `Age`, `City`, `Country`\n   * Existing table has those: `First name`, `Last name`.\n   *\n   * So for `First name` (and `Last name`) we will have a map of options:\n   * - `$Name` -> `Name`\n   * - `$City` -> `City`\n   * - `$Country` -> `Country`\n   * - `$Age` -> `Age`\n   * (and skip added in the UI).\n   *\n   * There are some special cases for References and column ids.\n   */\n  private _transformColImportOptions: Computed<Map<number, Map<string, string>>> = Computed.create(\n    this, this._transformFields, this._sourceInfoSelected, (use, fields, info) => {\n      if (!fields || !info) { return new Map(); }\n      return new Map(fields.map(f =>\n        [use(f.colRef), this._makeImportOptionsForCol(use(f.column), info)]));\n    });\n\n  /**\n   * List of labels of destination columns that aren't mapped to a source column, i.e. transform\n   * columns with empty formulas.\n   *\n   * In other words, this is a list of GRIST COLUMNS that are not mapped to any SOURCE COLUMNS, so\n   * columns that won't be imported.\n   */\n  private _unmatchedFieldsMap: Computed<Map<SourceInfo, string[] | null>> = Computed.create(this, (use) => {\n    const sources = use(this._sourceInfoArray);\n    const result = new Map<SourceInfo, string[] | null>();\n    const unmatched = (info: SourceInfo) => {\n      // If Skip import selected, ignore.\n      if (use(info.destTableId) === SKIP_TABLE) { return null; }\n      // If New table selected, ignore.\n      if (use(info.destTableId) === NEW_TABLE) { return null; }\n      // Otherwise, return list of labels of unmatched fields.\n      const section = info && use(info.transformSection);\n      if (!section || section.isDisposed() || use(section._isDeleted)) { return null; }\n      const fields = use(use(section.viewFields).getObservable());\n      const labels = fields?.filter(f => (use(use(f.column).formula).trim() === \"\"))\n        .map(f => use(f.label)) ?? null;\n      return labels?.length ? labels : null;\n    };\n    for (const info of sources) {\n      result.set(info, unmatched(info));\n    }\n    return result;\n  });\n\n  constructor(private _gristDoc: GristDoc,\n    // null tells to use the built-in file picker.\n    private _importSourceElem: ImportSourceElement | null,\n    private _createPreview: CreatePreviewFunc) {\n    super();\n    const label = _importSourceElem?.importSource.label || t(\"Import from file\");\n    this._screen = PluginScreen.create(this, label);\n\n    this.onDispose(() => {\n      this._resetImportDiffState();\n    });\n\n    this._setupGlobalEditorCleanup();\n  }\n\n  /*\n   * Uploads file to the server using the built-in file picker or a plugin instance.\n   */\n  public async pickAndUploadSource(uploadResult: UploadResult | null = null) {\n    try {\n      if (!this._importSourceElem) {\n        // Use upload result if it was passed in or the built-in file picker.\n        // On electron, it uses the native file selector (without actually uploading anything),\n        // which is why this requires a slightly different flow.\n        uploadResult = uploadResult || await selectFiles({ docWorkerUrl: this._docComm.docWorkerUrl,\n          multiple: true, sizeLimit: \"import\" });\n      } else {\n        // Need to use plugin to get the data, and manually upload it.\n        const plugin = this._importSourceElem.plugin;\n        const handle = this._screen.renderPlugin(plugin);\n        const importSource = await this._importSourceElem.importSourceStub.getImportSource(handle);\n        plugin.removeRenderTarget(handle);\n        this._screen.renderSpinner();\n\n        if (importSource) {\n          // If data has been picked, upload it.\n          const item = importSource.item;\n          if (item.kind === \"fileList\") {\n            const files = item.files.map(({ content, name }) => new File([content], name));\n            uploadResult = await uploadFiles(files, { docWorkerUrl: this._docComm.docWorkerUrl,\n              sizeLimit: \"import\" });\n          } else if (item.kind ===  \"url\") {\n            if (isDriveUrl(item.url)) {\n              uploadResult = await this._fetchFromDrive(item.url);\n            } else {\n              uploadResult = await fetchURL(this._docComm, item.url);\n            }\n          } else {\n            throw new Error(`Import source of kind ${(item as any).kind} are not yet supported!`);\n          }\n        }\n      }\n    } catch (err) {\n      if (err instanceof CancelledError) {\n        await this._cancelImport();\n        return;\n      }\n      if (err instanceof GDriveUrlNotSupported) {\n        await this._cancelImport();\n        throw err;\n      }\n      this._screen.renderError(err.message);\n      return;\n    }\n\n    if (uploadResult) {\n      this._uploadResult = uploadResult;\n      await this._reImport(uploadResult);\n    } else {\n      await this._cancelImport();\n    }\n  }\n\n  private _setupGlobalEditorCleanup() {\n    // Whenever we get focus, close also the main global formula editor that might get activated\n    // by the preview itself.\n    // TODO: refactor this code, the formula editor here should reuse global cleanup code.\n    const closeGlobalEditor = () => this._gristDoc.fieldEditorHolder.clear();\n    this.on(\"importer_focus\", closeGlobalEditor);\n    this.onDispose(() => {\n      this.off(\"importer_focus\", closeGlobalEditor);\n      if (this._gristDoc.isDisposed()) { return; }\n      closeGlobalEditor();\n    });\n  }\n\n  private _getPrimaryViewSection(tableId: string): ViewSectionRec {\n    const tableModel = this._gristDoc.getTableModel(tableId);\n    const viewRow = tableModel.tableMetaRow.primaryView.peek();\n    return viewRow.viewSections.peek().peek()[0];\n  }\n\n  private _getSectionByRef(sectionRef: number): ViewSectionRec {\n    return this._gristDoc.docModel.viewSections.getRowModel(sectionRef);\n  }\n\n  private async _updateTransformSection(sourceInfo: SourceInfo) {\n    this._resetImportDiffState();\n\n    sourceInfo.isLoadingSection.set(true);\n    sourceInfo.transformSection.set(null);\n\n    const genImporterViewPromise = this._gristDoc.docData.sendAction(\n      [\"GenImporterView\", sourceInfo.hiddenTableId, sourceInfo.destTableId.get(), null, null]);\n    sourceInfo.lastGenImporterViewPromise = genImporterViewPromise;\n    const transformSectionRef = (await genImporterViewPromise).viewSectionRef;\n\n    // If the request is superseded by a newer request, or the Importer is disposed, do nothing.\n    if (this.isDisposed() || sourceInfo.lastGenImporterViewPromise !== genImporterViewPromise) {\n      return;\n    }\n\n    // Otherwise, update the transform section for `sourceInfo`.\n    sourceInfo.transformSection.set(this._gristDoc.docModel.viewSections.getRowModel(transformSectionRef));\n    sourceInfo.isLoadingSection.set(false);\n\n    // Change the active section to the transform section, so that formula autocomplete works.\n    this._gristDoc.viewModel.activeSectionId(transformSectionRef);\n  }\n\n  /**\n   * Reads the configuration from the temporary table and creates a configuration map for each table.\n   */\n  private _getTransformedDataSource(upload: UploadResult): DataSourceTransformed {\n    const transforms: TransformRuleMap[] = upload.files.map((file, i) => this._createTransformRuleMap(i));\n    return { uploadId: upload.uploadId, transforms };\n  }\n\n  private _getMergeOptionMaps(upload: UploadResult): MergeOptionsMap[] {\n    return upload.files.map((_file, i) => this._createMergeOptionsMap(i));\n  }\n\n  private _createTransformRuleMap(uploadFileIndex: number): TransformRuleMap {\n    const result: TransformRuleMap = {};\n    for (const sourceInfo of this._sourceInfoArray.get()) {\n      if (sourceInfo.uploadFileIndex === uploadFileIndex) {\n        result[sourceInfo.origTableName] = this._createTransformRule(sourceInfo);\n      }\n    }\n    return result;\n  }\n\n  private _createMergeOptionsMap(uploadFileIndex: number): MergeOptionsMap {\n    const result: MergeOptionsMap = {};\n    for (const sourceInfo of this._sourceInfoArray.get()) {\n      if (sourceInfo.uploadFileIndex === uploadFileIndex) {\n        result[sourceInfo.origTableName] = this._getMergeOptionsForSource(sourceInfo);\n      }\n    }\n    return result;\n  }\n\n  private _createTransformRule(sourceInfo: SourceInfo): TransformRule {\n    const transformSection = sourceInfo.transformSection.get();\n    if (!transformSection) {\n      throw new Error(`Table ${sourceInfo.hiddenTableId} is missing transform section`);\n    }\n\n    const transformFields = transformSection.viewFields().peek();\n    const sourceFields = sourceInfo.sourceSection.viewFields().peek();\n\n    const destTableId: DestId = sourceInfo.destTableId.get();\n    return {\n      destTableId,\n      destCols: transformFields.map<TransformColumn>(field => ({\n        label: field.label(),\n        colId: destTableId ? field.colId() : null, // if inserting into new table, colId isn't defined\n        type: field.column().type(),\n        widgetOptions: field.column().widgetOptions(),\n        formula: field.column().formula(),\n      })),\n      sourceCols: sourceFields.map(field => field.colId()),\n    };\n  }\n\n  private _getMergeOptionsForSource(sourceInfo: SourceInfo): MergeOptions | undefined {\n    const mergeOptions = this._mergeOptions[sourceInfo.hiddenTableId];\n    if (!mergeOptions) { return undefined; }\n\n    const { updateExistingRecords, mergeCols, mergeStrategy } = mergeOptions;\n    return {\n      mergeCols: updateExistingRecords.get() ? mergeCols.get() : [],\n      mergeStrategy: mergeStrategy.get(),\n    };\n  }\n\n  private _getHiddenTableIds(): string[] {\n    return this._sourceInfoArray.get().map((si: SourceInfo) => si.hiddenTableId);\n  }\n\n  private async _reImport(upload: UploadResult) {\n    this._screen.renderSpinner();\n    this._resetImportDiffState();\n    try {\n      // Initialize parsing options with NUM_ROWS=0 (a whole file).\n      const parseOptions = { ...this._parseOptions.get(), NUM_ROWS: 0 };\n\n      // Create the temporary tables and import the files into it.\n      const importResult: ImportResult = await this._docComm.importFiles(\n        this._getTransformedDataSource(upload), parseOptions, this._getHiddenTableIds());\n\n      // Update the parsing options with the actual one used by the importer (it might have changed)\n      this._parseOptions.set(importResult.options);\n\n      this._sourceInfoHolder.clear();\n      const owner = MultiHolder.create(this._sourceInfoHolder);\n\n      // Read the information from what was imported in a better representation and some metadata, we\n      // will allow to change by the user.\n      this._sourceInfoArray.set(importResult.tables.map((info: ImportTableResult) => ({\n        hiddenTableId: info.hiddenTableId,\n        uploadFileIndex: info.uploadFileIndex,\n        origTableName: info.origTableName,\n        // This is the section with the data imported.\n        sourceSection: this._getPrimaryViewSection(info.hiddenTableId),\n        // This is the section created every time user changes the configuration, used for the preview.\n        transformSection: Observable.create(owner, this._getSectionByRef(info.transformSectionRef)),\n        // This is the table where the data will be imported, either a new table or an existing one.\n        // If a new one, it will be hidden for a while, until the user confirms the import.\n        destTableId: Observable.create<DestId>(owner, info.destTableId ?? NEW_TABLE),\n        // Helper to show the spinner.\n        isLoadingSection: Observable.create(owner, false),\n        // and another one.\n        lastGenImporterViewPromise: null,\n        // Which view to show or was shown previously.\n        selectedView: Observable.create(owner, TABLE_MAPPING),\n        // List of customized\n        customizedColumns: Observable.create(owner, new Set<string>()),\n      })));\n\n      if (this._sourceInfoArray.get().length === 0) {\n        throw new Error(\"No data was imported\");\n      }\n\n      this._prepareMergeOptions();\n\n      // Select the first sourceInfo to show in preview.\n      this._sourceInfoSelected.set(this._sourceInfoArray.get()[0] || null);\n\n      // And finally render the main screen.\n      this._renderMain(upload);\n    } catch (e) {\n      console.warn(\"Import failed\", e);\n      this._screen.renderError(e.message);\n    }\n  }\n\n  /**\n   * Create a merging options. This is an extension to the configuration above (_sourceInfoArray).\n   * By default, we are pointing to new tables, so it is empty. This method is used to communicate\n   * with the user about what they want and how they want to merge the data.\n   * For an existing table, it will be filled by the user with columns to merge on (how to identify\n   * existing rows).\n   */\n  private _prepareMergeOptions() {\n    this._mergeOptions = {};\n    this._getHiddenTableIds().forEach((tableId) => {\n      this._mergeOptions[tableId] = {\n        // By default no, as we are importing into new tables.\n        updateExistingRecords: Observable.create(null, false),\n        // Empty, user will select it for existing table.\n        mergeCols: obsArray(),\n        // Strategy for the backend (from UI we don't care about it).\n        mergeStrategy: Observable.create(null, { type: \"replace-with-nonblank-source\" }),\n        // Helper to show the validation that something is wrong with the columns selected to merge.\n        hasInvalidMergeCols: Observable.create(null, false),\n      };\n    });\n  }\n\n  private async _maybeFinishImport(upload: UploadResult) {\n    const isConfigValid = this._validateImportConfiguration();\n    if (!isConfigValid) { return; }\n\n    this._screen.renderSpinner();\n    this._resetImportDiffState();\n\n    const parseOptions = { ...this._parseOptions.get(), NUM_ROWS: 0 };\n    const mergeOptionMaps = this._getMergeOptionMaps(upload);\n\n    const importResult: ImportResult = await this._docComm.finishImportFiles(\n      this._getTransformedDataSource(upload), this._getHiddenTableIds(), { mergeOptionMaps, parseOptions });\n\n    // This is not hidden table anymore, it was renamed to the name of the final table.\n    if (importResult.tables[0]?.hiddenTableId) {\n      const tableRowModel = this._gristDoc.docModel.dataTables[importResult.tables[0].hiddenTableId].tableMetaRow;\n      const primaryViewId = tableRowModel.primaryViewId();\n      if (primaryViewId) {\n        // Switch page if there is a sensible one to switch to.\n        await this._gristDoc.openDocPage(primaryViewId);\n      }\n    }\n    this._screen.close();\n    this.dispose();\n  }\n\n  private async _cancelImport() {\n    this._resetImportDiffState();\n    // Formula editor cleanup needs to happen before the hidden tables are removed.\n    this._formulaEditorHolder.dispose();\n    if (this._uploadResult) {\n      await this._docComm.cancelImportFiles(this._uploadResult.uploadId, this._getHiddenTableIds());\n    }\n    this._screen.close();\n    this.dispose();\n  }\n\n  private _resetTableMergeOptions(tableId: string) {\n    this._mergeOptions[tableId]?.mergeCols.set([]);\n  }\n\n  private _validateImportConfiguration(): boolean {\n    let isValid = true;\n\n    const selectedSourceInfo = this._sourceInfoSelected.get();\n    if (!selectedSourceInfo) { return isValid; } // No configuration to validate.\n\n    const mergeOptions = this._mergeOptions[selectedSourceInfo.hiddenTableId];\n    if (!mergeOptions) { return isValid; } // No configuration to validate.\n\n    const destTableId = selectedSourceInfo.destTableId.get();\n    const { updateExistingRecords, mergeCols, hasInvalidMergeCols } = mergeOptions;\n\n    // Check that at least one merge column was selected (if merging into an existing table).\n    if (destTableId !== null && updateExistingRecords.get() && mergeCols.get().length === 0) {\n      hasInvalidMergeCols.set(true);\n      isValid = false;\n    }\n\n    return isValid;\n  }\n\n  private _buildModalTitle(rightElement?: DomContents) {\n    const title =  this._importSourceElem ? this._importSourceElem.importSource.label : t(\"Import from file\");\n    return cssModalHeader(cssModalTitle(title), rightElement);\n  }\n\n  private _buildStaticTitle() {\n    return cssStaticHeader(cssModalTitle(t(\"Import from file\")));\n  }\n\n  /**\n   * Triggers an update of the import diff in the preview table. When called in quick succession,\n   * only the most recent call will result in an update being made to the preview table.\n   *\n   * @param {SourceInfo} info The source to update the diff for.\n   */\n  private async _updateImportDiff(info: SourceInfo) {\n    const { updateExistingRecords, mergeCols } = this._mergeOptions[info.hiddenTableId]!;\n    const isMerging = info.destTableId && updateExistingRecords.get() && mergeCols.get().length > 0;\n    if (!isMerging && this._gristDoc.comparison) {\n      // If we're not merging but diffing is enabled, disable it; since `comparison` isn't\n      // currently observable, we'll wrap the modification around the `_isLoadingDiff`\n      // flag, which will force the preview table to re-render with diffing disabled.\n      this._isLoadingDiff.set(true);\n      this._gristDoc.setComparison(null);\n      this._isLoadingDiff.set(false);\n    }\n\n    // If we're not merging, no diff is shown, so don't schedule an update for one.\n    if (!isMerging) { return; }\n\n    this._hasScheduledDiffUpdate = true;\n    this._isLoadingDiff.set(true);\n    await this._debouncedUpdateDiff(info);\n  }\n\n  /**\n   * NOTE: This method should not be called directly. Instead, use _updateImportDiff above, which\n   * wraps this method and calls a debounced version of it.\n   *\n   * Triggers an update of the import diff in the preview table. When called in quick succession,\n   * only the most recent call will result in an update being made to the preview table.\n   *\n   * @param {SourceInfo} info The source to update the diff for.\n   */\n  private async _updateDiff(info: SourceInfo) {\n    // Reset the flag tracking scheduled updates since the debounced update has started.\n    this._hasScheduledDiffUpdate = false;\n\n    // Request a diff of the current source and wait for a response.\n    const genImportDiffPromise = this._docComm.generateImportDiff(info.hiddenTableId,\n      this._createTransformRule(info), this._getMergeOptionsForSource(info)!);\n    this._lastGenImportDiffPromise = genImportDiffPromise;\n    const diff = await genImportDiffPromise;\n\n    // If the request is superseded by a newer request, or the Importer is disposed, do nothing.\n    if (this.isDisposed() || genImportDiffPromise !== this._lastGenImportDiffPromise) { return; }\n\n    // Put the document in comparison mode with the diff data.\n    this._gristDoc.setComparison(diff);\n\n    // If more updates where scheduled since we started the update, leave the loading spinner up.\n    if (!this._hasScheduledDiffUpdate) {\n      this._isLoadingDiff.set(false);\n    }\n  }\n\n  /**\n   * Resets all state variables related to diffs to their default values.\n   */\n  private _resetImportDiffState() {\n    this._cancelPendingDiffRequests();\n    this._gristDoc.setComparison(null);\n  }\n\n  /**\n   * Effectively cancels all pending diff requests by causing their fulfilled promises to\n   * be ignored by their attached handlers. Since we can't natively cancel the promises, this\n   * is functionally equivalent to canceling the outstanding requests.\n   */\n  private _cancelPendingDiffRequests() {\n    this._debouncedUpdateDiff.cancel();\n    this._lastGenImportDiffPromise = null;\n    this._hasScheduledDiffUpdate = false;\n    this._isLoadingDiff.set(false);\n  }\n\n  // The importer state showing import in progress, with a list of tables, and a preview.\n  private _renderMain(upload: UploadResult) {\n    const schema = this._parseOptions.get().SCHEMA;\n    const header = this._buildModalTitle();\n    const options = schema ? cssActionLink(cssLinkIcon(\"Settings\"), t(\"Import options\"),\n      testId(\"options-link\"),\n      dom.on(\"click\", () => this._renderParseOptions(schema, upload)),\n    ) : null;\n\n    const selectTab = async (info: SourceInfo) => {\n      // Ignore click if source is already selected.\n      if (info === this._sourceInfoSelected.get()) { return; }\n      // Prevent changing selected source if current configuration is invalid.\n      if (!this._validateImportConfiguration()) { return; }\n      this._cancelPendingDiffRequests();\n      this._sourceInfoSelected.set(info);\n      await this._updateImportDiff(info);\n    };\n\n    const tabs = cssTableList(\n      dom.forEach(this._sourceInfoArray, (info) => {\n        const owner = MultiHolder.create(null);\n        const destTableId = Computed.create(owner, use => use(info.destTableId));\n        destTableId.onWrite(async (destId) => {\n          // Prevent changing destination of un-selected sources if current configuration is invalid.\n          if (info !== this._sourceInfoSelected.get() && !this._validateImportConfiguration()) {\n            return;\n          }\n          info.destTableId.set(destId);\n          this._resetTableMergeOptions(info.hiddenTableId);\n          if (destId !== SKIP_TABLE) {\n            await this._updateTransformSection(info);\n          }\n        });\n\n        // If this is selected source.\n        const isSelected = Computed.create(owner, use => use(this._sourceInfoSelected) === info);\n\n        const unmatchedCount = Computed.create(owner, (use) => {\n          const map = use(this._unmatchedFieldsMap);\n          return map.get(info)?.length ?? 0;\n        });\n\n        return cssTabItem(\n          dom.autoDispose(owner),\n          cssBorderBottom(),\n          cssTabItem.cls(\"-not-selected\", not(isSelected)),\n          testId(\"source\"),\n          testId(\"source-selected\", isSelected),\n          testId(\"source-not-selected\", not(isSelected)),\n          cssTabItemContent(\n            cssFileTypeIcon(getSourceFileExtension(info, upload),\n              cssFileTypeIcon.cls(\"-active\", isSelected),\n            ),\n            cssTabItemContent.cls(\"-selected\", isSelected),\n            cssTableLine(cssTableSource(\n              getSourceDescription(info, upload),\n              testId(\"from\"),\n              overflowTooltip(),\n            )),\n            dom.on(\"click\", () => selectTab(info)),\n          ),\n          dom.maybe(unmatchedCount, count => cssError(\n            \"Exclamation\",\n            testId(\"error\"),\n            hoverTooltip(t(\"{{count}} unmatched field\", { count })),\n          )),\n        );\n      }),\n    );\n    const previewAndConfig = dom.maybeOwned(this._sourceInfoSelected, (owner, info) => {\n      const { mergeCols, updateExistingRecords, hasInvalidMergeCols } = this._mergeOptions[info.hiddenTableId]!;\n\n      // Computed for transform section if we have destination table selected.\n      const configSection = Computed.create(owner,\n        use => use(info.destTableId) && use(info.transformSection) ? use(info.transformSection) : null);\n\n      // Computed to show the loader while we are waiting for the preview.\n      const showLoader = Computed.create(owner, (use) => {\n        return use(this._isLoadingDiff) || !use(this._previewViewSection);\n      });\n\n      // The same computed as configSection, but will evaluate to null while we are waiting for the preview\n      const previewSection = Computed.create(owner, (use) => {\n        return use(showLoader) ? null : use(this._previewViewSection);\n      });\n\n      // Use helper for checking if destination is selected.\n      const isSelected = (destId: DestId) => (use: UseCBOwner) => use(info.destTableId) === destId;\n\n      // True if user selected `Skip import`\n      const isSkipTable = Computed.create(owner, isSelected(SKIP_TABLE));\n\n      // True if user selected a valid destination table.\n      const isMergeTable = Computed.create(owner, use => ![NEW_TABLE, SKIP_TABLE].includes(use(info.destTableId)));\n\n      // Changes the class if the item is selected. Creates a dom method that can be attached to element.\n      const selectIfDestIs = (destId: DestId) => cssDestination.cls(\"-selected\", isSelected(destId));\n\n      // Helper to toggle visibility if target is selected.\n      const visibleIfDestIs = (destId: DestId) => dom.show(isSelected(destId));\n\n      // Creates a click handler that changes the destination table to the given value.\n      const onClickChangeDestTo = (destId: DestId) => dom.on(\"click\", async () => {\n        if (info !== this._sourceInfoSelected.get() && !this._validateImportConfiguration()) {\n          return;\n        }\n        info.selectedView.set(TABLE_MAPPING);\n        info.destTableId.set(destId);\n        this._resetTableMergeOptions(info.hiddenTableId);\n        if (destId !== SKIP_TABLE) {\n          await this._updateTransformSection(info);\n        }\n      });\n\n      // Should we show the right panel with the column mapping.\n      const showRightPanel = Computed.create(owner, (use) => {\n        return use(isMergeTable) && use(info.selectedView) === COLUMN_MAPPING;\n      });\n\n      // Handler to switch the view, between destination and column mapping panes.\n      const onClickShowView = (view: ViewType) => dom.on(\"click\", () => {\n        info.selectedView.set(view);\n      });\n\n      // Pattern to create a computed value that can create and dispose objects in its callback.\n      Computed.create(owner, (use) => {\n        // This value must be returned for this pattern to work.\n        const holder = MultiHolder.create(use.owner);\n        // Now we can safely take ownership of things we create here - the subscriber.\n        if (use(configSection)) {\n          holder.autoDispose(updateExistingRecords.addListener(async () => {\n            if (holder.isDisposed()) { return; }\n            await this._updateImportDiff(info);\n          }));\n        }\n        return holder;\n      });\n\n      return cssConfigAndPreview(\n        cssConfigPanel(\n          cssConfigPanel.cls(\"-right\", showRightPanel),\n          cssConfigLeft(\n            cssTitle(t(\"Destination table\"), testId(\"target-top\")),\n            cssDestinationWrapper(cssDestination(\n              cssPageIcon(\"Plus\"),\n              dom(\"span\", t(\"New Table\")),\n              selectIfDestIs(NEW_TABLE),\n              onClickChangeDestTo(NEW_TABLE),\n              testId(\"target\"),\n              testId(\"target-new-table\"),\n              testId(\"target-selected\", isSelected(NEW_TABLE)),\n            )),\n            dom.maybe(use => use(this._sourceInfoArray).length > 1, () => [\n              cssDestinationWrapper(cssDestination(\n                cssPageIcon(\"CrossBig\"),\n                dom(\"span\", t(\"Skip Import\")),\n                selectIfDestIs(SKIP_TABLE),\n                onClickChangeDestTo(SKIP_TABLE),\n                testId(\"target\"),\n                testId(\"target-skip\"),\n                testId(\"target-selected\", isSelected(SKIP_TABLE)),\n              )),\n            ]),\n            dom.forEach(this._destTables, (destTable) => {\n              return cssDestinationWrapper(\n                testId(\"target\"),\n                testId(\"target-existing-table\"),\n                testId(\"target-selected\", isSelected(destTable.value)),\n                cssDestination(\n                  cssPageIcon(\"TypeTable\"),\n                  dom(\"span\", destTable.label),\n                  selectIfDestIs(destTable.value),\n                  onClickChangeDestTo(destTable.value),\n                  onClickShowView(COLUMN_MAPPING),\n                ),\n                cssDetailsIcon(\"ArrowRight\",\n                  onClickShowView(COLUMN_MAPPING),\n                  visibleIfDestIs(destTable.value),\n                  hoverTooltip(t(\"Column mapping\")),\n                  testId(\"target-column-mapping\"),\n                ),\n              );\n            }),\n          ),\n          cssConfigRight(\n            cssNavigation(\n              cssFlexBaseline(\n                cssDestinationTableSecondary(\n                  cssNavigationIcon(\"ArrowLeft\"),\n                  t(\"Destination table\"),\n                  onClickShowView(TABLE_MAPPING),\n                  testId(\"table-mapping\"),\n                ),\n                cssSlash(\" / \"),\n                cssColumnMappingNav(t(\"Column Mapping\")),\n              ),\n            ),\n            cssMergeOptions(\n              dom.maybe(isMergeTable, () => cssMergeOptionsToggle(labeledSquareCheckbox(\n                updateExistingRecords,\n                t(\"Update existing records\"),\n                testId(\"update-existing-records\"),\n              ))),\n              dom.maybe(configSection, (section) => {\n                return dom.maybeOwned(updateExistingRecords, (owner2) => {\n                  owner2.autoDispose(mergeCols.addListener(async (val) => {\n                    // Reset the error state of the multiSelect on change.\n                    if (val.length !== 0 && hasInvalidMergeCols.get()) {\n                      hasInvalidMergeCols.set(false);\n                    }\n                    await this._updateImportDiff(info);\n                  }));\n                  return [\n                    cssMergeOptionsMessage(\n                      t(\"Merge rows that match these fields:\"),\n                      testId(\"merge-fields-message\"),\n                    ),\n                    multiSelect(\n                      mergeCols,\n                      section.viewFields().peek().map(f => ({ label: f.label(), value: f.colId() })) ?? [],\n                      {\n                        placeholder: t(\"Select fields to match on\"),\n                        error: hasInvalidMergeCols,\n                      },\n                      testId(\"merge-fields-select\"),\n                    ),\n                  ];\n                });\n              }),\n            ),\n            dom.maybeOwned(configSection, (owner1, section) => {\n              owner1.autoDispose(updateExistingRecords.addListener(async () => {\n                await this._updateImportDiff(info);\n              }));\n              return dom(\"div\",\n                cssColumnMatchHeader(\n                  dom(\"span\", t(\"Grist column\")),\n                  dom(\"div\", null),\n                  dom(\"span\", t(\"Source column\")),\n                ),\n                dom.forEach(fromKo(section.viewFields().getObservable()), (field) => {\n                  const owner2 = MultiHolder.create(null);\n                  const isCustomFormula = Computed.create(owner2, (use) => {\n                    return use(info.customizedColumns).has(field.colId());\n                  });\n                  return cssColumnMatchRow(\n                    testId(\"column-match-source-destination\"),\n                    dom.autoDispose(owner2),\n                    dom.domComputed(field.label, () => cssDestinationFieldLabel(\n                      dom.text(field.label),\n                      overflowTooltip(),\n                      testId(\"column-match-destination\"),\n                    )),\n                    cssIcon180(\"ArrowRightOutlined\"),\n                    dom.domComputedOwned(isCustomFormula, (owner3, isCustom) => {\n                      if (isCustom) {\n                        return this._buildCustomFormula(owner3, field, info);\n                      } else {\n                        return this._buildSourceSelector(owner3, field, info);\n                      }\n                    }),\n                    dom(\"div\",\n                      dom.maybe(isCustomFormula, () => icon(\"Revert\",\n                        dom.style(\"cursor\", \"pointer\"),\n                        hoverTooltip(t(\"Revert\")),\n                        dom.on(\"click\", async () => {\n                          toggleCustomized(info, field.colId(), false);\n                          // Try to set the default label.\n                          const transformCol = field.column.peek();\n                          const possibilities = this._transformColImportOptions.get().get(transformCol.getRowId()) ??\n                            new Map<string, string>();\n                          const matched = [...possibilities.entries()].find(([, v]) => v === transformCol.label.peek());\n                          if (matched) {\n                            await this._setColumnFormula(transformCol, matched[0], info);\n                          } else {\n                            await this._gristDoc.docModel.clearColumns([field.colRef()]);\n                          }\n                        }),\n                      )),\n                    ),\n                  );\n                }),\n                testId(\"column-match-options\"),\n              );\n            }),\n          ),\n        ),\n        cssPreviewColumn(\n          dom.maybe(showLoader, () => cssPreviewSpinner(loadingSpinner(), testId(\"preview-spinner\"))),\n          dom.maybe(previewSection, () => [\n            cssOptions(\n              dom.domComputed(info.destTableId, destId => cssTableName(\n                destId === NEW_TABLE ? t(\"New Table\") :\n                  destId === SKIP_TABLE ? t(\"Skip Import\") :\n                    dom.domComputed(this._destTables, list =>\n                      list.find(dt => dt.value === destId)?.label ?? t(\"New Table\"),\n                    ),\n              )),\n              options,\n            ),\n          ]),\n          cssWarningText(dom.text(use => use(this._parseOptions)?.WARNING || \"\"), testId(\"warning\")),\n          dom.domComputed((use) => {\n            if (use(isSkipTable)) {\n              return cssOverlay(t(\"Skip Table on Import\"), testId(\"preview-overlay\"));\n            }\n            const section = use(previewSection);\n            if (!section || section.isDisposed()) { return null; }\n            const gridView = this._createPreview(section);\n            return cssPreviewGrid(\n              dom.autoDispose(gridView),\n              gridView.viewPane,\n              testId(\"preview\"),\n            );\n          }),\n        ),\n      );\n    });\n\n    const buttons = cssImportButtons(cssImportButtonsLine(\n      bigPrimaryButton(t(\"Import\"),\n        dom.on(\"click\", () => this._maybeFinishImport(upload)),\n        dom.boolAttr(\"disabled\", (use) => {\n          return use(this._previewViewSection) === null ||\n            use(this._sourceInfoArray).every(i => use(i.destTableId) === SKIP_TABLE);\n        }),\n        baseTestId(\"modal-confirm\"),\n      ),\n      bigBasicButton(t(\"Cancel\"),\n        dom.on(\"click\", () => this._cancelImport()),\n        baseTestId(\"modal-cancel\"),\n      ),\n      dom.domComputed(this._unmatchedFieldsMap, (fields) => {\n        const piles: HTMLElement[] = [];\n        let count = 0;\n        for (const [info, list] of fields) {\n          if (!list?.length) { continue; }\n          count += list.length;\n          piles.push(cssUnmatchedFieldsList(\n            list.join(\", \"),\n            dom.on(\"click\", () => selectTab(info)),\n            hoverTooltip(getSourceDescription(info, upload)),\n          ));\n        }\n        if (!count) { return null; }\n        return cssUnmatchedFields(\n          cssUnmatchedFieldsIntro(\n            cssUnmatchedIcon(\"Exclamation\"),\n            t(\"{{count}} unmatched field in import\", { count }), \": \",\n          ),\n          ...piles,\n          testId(\"unmatched-fields\"),\n        );\n      }),\n    ));\n    const body = cssContainer(\n      { tabIndex: \"-1\" },\n      header,\n      cssPreviewWrapper(\n        cssTabsWrapper(\n          tabs,\n        ),\n        previewAndConfig,\n      ),\n      buttons,\n    );\n    this._addFocusLayer(body);\n    this._screen.render(body, {\n      fullscreen: true,\n      fullbody: true,\n    });\n  }\n\n  private _makeImportOptionsForCol(gristCol: ColumnRec, info: SourceInfo) {\n    const options = new Map<string, string>();  // Maps formula to label.\n    const sourceFields = info.sourceSection.viewFields.peek().peek();\n\n    // Reference columns are populated using lookup formulas, so figure out now if this is a\n    // reference column, and if so, its destination table and the lookup column ID.\n    const refTable = gristCol.refTable.peek();\n    const refTableId = refTable ? refTable.tableId.peek() : undefined;\n\n    const visibleColId = gristCol.visibleColModel.peek().colId.peek();\n    const isRefDest = Boolean(info.destTableId.get() && gristCol.pureType.peek() === \"Ref\");\n\n    for (const sourceField of sourceFields) {\n      const sourceCol = sourceField.column.peek();\n      const sourceId = sourceCol.colId.peek();\n      const sourceLabel = sourceCol.label.peek();\n      if (isRefDest && visibleColId) {\n        const formula = `${refTableId}.lookupOne(${visibleColId}=$${sourceId}) or ($${sourceId} and str($${sourceId}))`;\n        options.set(formula, sourceLabel);\n      } else {\n        options.set(`$${sourceId}`, sourceLabel);\n      }\n      if (isRefDest && [\"Numeric\", \"Int\"].includes(sourceCol.type.peek())) {\n        options.set(`${refTableId}.lookupOne(id=NUM($${sourceId})) or ($${sourceId} and str(NUM($${sourceId})))`,\n          `${sourceLabel} (as row ID)`);\n      }\n    }\n    return options;\n  }\n\n  private _makeImportOptionsMenu(transformCol: ColumnRec, others: [string, string][], info: SourceInfo) {\n    return [\n      menuItem(() => this._setColumnFormula(transformCol, null, info),\n        \"Skip\",\n        testId(\"column-match-menu-item\")),\n      others.length ? menuDivider() : null,\n      ...others.map(([formula, label]) =>\n        menuItem(() => this._setColumnFormula(transformCol, formula, info),\n          label,\n          testId(\"column-match-menu-item\")),\n      ),\n    ];\n  }\n\n  private _addFocusLayer(container: HTMLElement) {\n    dom.autoDisposeElem(container, new FocusLayer({\n      defaultFocusElem: container,\n      allowFocus: elem => (elem !== document.body),\n      onDefaultFocus: () => this.trigger(\"importer_focus\"),\n    }));\n  }\n\n  /**\n   * Updates the formula on column `colRef` to `formula`, when user wants to match it to a source column.\n   */\n  private async _setColumnFormula(transformCol: ColumnRec, formula: string | null, info: SourceInfo) {\n    const transformColRef = transformCol.id();\n    const customized = info.customizedColumns.get();\n    customized.delete(transformCol.colId());\n    info.customizedColumns.set(customized);\n    if (formula === null) {\n      await this._gristDoc.docModel.clearColumns([transformColRef], { keepType: true });\n    } else {\n      await this._gristDoc.docModel.columns.sendTableAction(\n        [\"UpdateRecord\", transformColRef, { formula, isFormula: true }]);\n    }\n    await this._updateImportDiff(info);\n  }\n\n  /**\n   * Opens a formula editor for `field` over `refElem`.\n   */\n  private _activateFormulaEditor(refElem: Element, field: ViewFieldRec, onSave: (formula: string) => Promise<void>) {\n    const vsi = this._gristDoc.viewModel.activeSection().viewInstance();\n    const editRow = vsi?.moveEditRowToCursor();\n    const editorHolder = openFormulaEditor({\n      gristDoc: this._gristDoc,\n      column: field.column(),\n      editingFormula: field.editingFormula,\n      refElem,\n      editRow,\n      canDetach: false,\n      setupCleanup: this._setupFormulaEditorCleanup.bind(this),\n      onSave: async (column, formula) => {\n        if (formula === column.formula.peek()) { return; }\n        // Sorry for this hack. We need to store somewhere an info that the formula was edited\n        // unfortunately, we don't have a better place to store it. So we will save this by setting\n        // display column to the same column. This won't break anything as this is a default value.\n        await column.updateColValues({ formula });\n        await onSave(formula);\n      },\n    });\n    this._formulaEditorHolder.autoDispose(editorHolder);\n  }\n\n  /**\n   * Called by _activateFormulaEditor to initialize cleanup\n   * code for when the formula editor is closed. Registers and\n   * unregisters callbacks for saving edits when the editor loses\n   * focus.\n   */\n  private _setupFormulaEditorCleanup(\n    owner: Disposable, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>,\n  ) {\n    const saveEdit = () => _saveEdit().catch(reportError);\n\n    // Whenever focus returns to the dialog, close the editor by saving the value.\n    this.on(\"importer_focus\", saveEdit);\n\n    owner.onDispose(() => {\n      this.off(\"importer_focus\", saveEdit);\n      editingFormula(false);\n    });\n  }\n\n  /**\n   * Builds an editable formula component that is displayed\n   * in the column mapping section of Importer. On click, opens\n   * an editor for the formula for `column`.\n   */\n  private _buildSourceSelector(owner: MultiHolder, field: ViewFieldRec, info: SourceInfo) {\n    const anyOtherColumns = Computed.create(owner, (use) => {\n      const transformCol = field.column.peek();\n      const options = use(this._transformColImportOptions).get(transformCol.getRowId()) ?? new Map<string, string>();\n      const otherFilter = ([formula]: [string, string]) => {\n        // Notice how this is only reactive to the formula value, every other observable is\n        // just picked without being tracked. This is because we only want to recompute this\n        // when the formula is changed (so the target column is changed). If anything other is\n        // changed, we don't care here as this whole computed will be recreated by the caller.\n        const myFormula = use(transformCol.formula);\n        const anyOther = info.transformSection.get()?.viewFields.peek().all()\n          .filter(f => f.column.peek() !== transformCol)\n          .map(f => use(f.column.peek().formula));\n        // If we picked this formula thats ok.\n        if (formula === myFormula) { return true; }\n        // If any other column picked this formula, then we should not show it.\n        if (anyOther?.includes(formula)) { return false; }\n        // Otherwise, show it.\n        return true;\n      };\n      const possibleSources = Array.from(options).filter(otherFilter);\n\n      return this._makeImportOptionsMenu(transformCol, possibleSources, info);\n    });\n\n    const selectedSource = Computed.create(owner, (use) => {\n      const column = use(field.column);\n      const importOptions = use(this._transformColImportOptions).get(column.getRowId());\n      // Now translate the formula generated (which is unique) to the source label.\n      const label = importOptions?.get(use(column.formula)) || null;\n      return label;\n    });\n    const selectedSourceText = Computed.create(owner, use => use(selectedSource) || t(\"Skip\"));\n\n    const selectedOption = cssSelected(\n      dom.text(selectedSourceText),\n      testId(\"column-match-formula\"),\n      cssSelected.cls(\"-skip\", not(selectedSource)),\n      overflowTooltip(),\n    );\n    const otherColsOptions = dom.domComputed(anyOtherColumns, x => x);\n    const formulaOption = selectOption(\n      () => {\n        this._activateFormulaEditor(selectMenuElement, field, async (newFormula) => {\n          toggleCustomized(info, field.colId.peek(), !!newFormula);\n          await this._updateImportDiff(info);\n        });\n      },\n      \"Apply Formula\",\n      \"Lighting\",\n      testId(\"apply-formula\"),\n      cssGreenIcon.cls(\"\"),\n    );\n    const selectMenuElement = selectMenu(selectedOption, () => [\n      otherColsOptions,\n      menuDivider(),\n      formulaOption,\n    ], testId(\"column-match-source\"));\n    return selectMenuElement;\n  }\n\n  /**\n   * Builds an editable formula component that is displayed\n   * in the column mapping section of Importer. On click, opens\n   * an editor for the formula for `column`.\n   */\n  private _buildCustomFormula(owner: MultiHolder, field: ViewFieldRec, info: SourceInfo) {\n    const formula = Computed.create(owner, (use) => {\n      const column = use(field.column);\n      return use(column.formula);\n    });\n    const codeOptions = { placeholder: \"Skip\", maxLines: 1 };\n    return dom.create(buildHighlightedCode, formula, codeOptions,\n      dom.cls(cssFieldFormula.className),\n      dom.cls(\"disabled\"),\n      dom.cls(\"formula_field_sidepane\"),\n      { tabIndex: \"-1\" },\n      dom.on(\"focus\", (_ev, elem) => this._activateFormulaEditor(elem, field, async (newFormula) => {\n        toggleCustomized(info, field.colId.peek(), !!newFormula);\n        await this._updateImportDiff(info);\n      })),\n      testId(\"column-match-formula\"),\n    );\n  }\n\n  // The importer state showing parse options that may be changed.\n  private _renderParseOptions(schema: ParseOptionSchema[], upload: UploadResult) {\n    const anotherScreen = PluginScreen.create(this._optionsScreenHolder, \"Import from file\");\n    anotherScreen.showImportDialog({\n      noClickAway: false,\n      noEscapeKey: false,\n    });\n    anotherScreen.render([\n      this._buildStaticTitle(),\n      dom.create(buildParseOptionsForm, schema, this._parseOptions.get() as ParseOptionValues,\n        (p: ParseOptions) => {\n          anotherScreen.dispose();\n          this._parseOptions.set(p);\n          // Drop what we previously matched because we may have different columns.\n          // If user manually matched, then changed import options, they'll have to re-match; when\n          // columns change at all, the alternative has incorrect columns in UI and is more confusing.\n          this._sourceInfoArray.set([]);\n          this._reImport(upload).catch(err => reportError(err));\n        },\n        () => {\n          anotherScreen.dispose();\n          this._renderMain(upload);\n        },\n      ),\n    ]);\n  }\n\n  private async _fetchFromDrive(itemUrl: string) {\n    // First we will assume that this is public file, so no need to ask for permissions.\n    try {\n      return await fetchURL(this._docComm, itemUrl);\n    } catch (err) {\n      // It is not a public file or the file id in the url is wrong,\n      // but we have no way to check it, so we assume that it is private file\n      // and ask the user for the permission (if we are configured to do so)\n      if (canReadPrivateFiles()) {\n        const options: FetchUrlOptions = {};\n        try {\n          // Request for authorization code from Google.\n          const code = await getGoogleCodeForReading(this);\n          options.googleAuthorizationCode = code;\n        } catch (permError) {\n          if (permError?.message === ACCESS_DENIED) {\n            // User declined to give us full readonly permission, fallback to GoogleDrive plugin\n            // or cancel import if GoogleDrive plugin is not configured.\n            throw new GDriveUrlNotSupported(itemUrl);\n          } else if (permError?.message === AUTH_INTERRUPTED) {\n            // User closed the window - we assume he doesn't want to continue.\n            throw new CancelledError();\n          } else {\n            // Some other error happened during authentication, report to user.\n            throw err;\n          }\n        }\n        // Download file from private drive, if it fails, report the error to user.\n        return await fetchURL(this._docComm, itemUrl, options);\n      } else {\n        // We are not allowed to ask for full readonly permission, fallback to GoogleDrive plugin.\n        throw new GDriveUrlNotSupported(itemUrl);\n      }\n    }\n  }\n}\n\n// Used for switching from URL plugin to Google drive plugin.\nclass GDriveUrlNotSupported extends Error {\n  constructor(public url: string) {\n    super(`This url ${url} is not supported`);\n  }\n}\n\n// Used to cancel import (close the dialog without any error).\nclass CancelledError extends Error {\n}\n\nfunction getSourceDescription(sourceInfo: SourceInfo, upload: UploadResult) {\n  const origName = upload.files[sourceInfo.uploadFileIndex].origName;\n  return sourceInfo.origTableName ? `${sourceInfo.origTableName} - ${origName}` : origName;\n}\n\nfunction getSourceFileExtension(sourceInfo: SourceInfo, upload: UploadResult) {\n  const origName = upload.files[sourceInfo.uploadFileIndex].origName;\n  return origName.includes(\".\") ? origName.split(\".\").pop() : \"file\";\n}\n\nconst cssContainer = styled(\"div\", `\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  outline: unset;\n`);\n\nconst cssActionLink = styled(\"div\", `\n  display: inline-flex;\n  align-items: center;\n  cursor: pointer;\n  color: ${theme.controlFg};\n  --icon-color: ${theme.controlFg};\n  &:hover {\n    color: ${theme.controlHoverFg};\n    --icon-color: ${theme.controlHoverFg};\n  }\n`);\n\nconst cssLinkIcon = styled(icon, `\n  flex: none;\n  margin-right: 4px;\n`);\n\nconst cssStaticHeader = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 16px;\n  & > .${cssModalTitle.className} {\n    margin-bottom: 0px;\n  }\n`);\n\nconst cssModalHeader = styled(cssStaticHeader, `\n  padding-left: var(--css-modal-dialog-padding-horizontal, 0px);\n  padding-right: var(--css-modal-dialog-padding-horizontal, 0px);\n  padding-top: var(--css-modal-dialog-padding-vertical, 0px);\n`);\n\nconst cssPreviewWrapper = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex-grow: 1;\n`);\n\nconst cssBorderBottom = styled(\"div\", `\n  border-bottom: 1px solid ${theme.importerTableInfoBorder};\n  display: none;\n  height: 0px;\n  bottom: 0px;\n  position: absolute;\n  width: 100%;\n`);\n\nconst cssFileTypeIcon = styled(\"div\", `\n  background: ${theme.importerInactiveFileBg};\n  color: ${theme.importerInactiveFileFg};\n  border-radius: 4px;\n  height: 2em;\n  text-align: center;\n  display: flex;\n  align-items: center;\n  padding: 1em;\n  font-size: 15px;\n  font-weight: 600;\n  text-transform: uppercase;\n  &-active{\n    background: ${theme.importerActiveFileBg};\n    color: ${theme.importerActiveFileFg};\n  }\n`);\n\nconst cssTabsWrapper = styled(\"div\", `\n  border-bottom: 1px solid ${theme.importerTableInfoBorder};\n  display: flex;\n  flex-direction: column;\n`);\n\nconst cssWarningText = styled(\"div\", `\n  margin-bottom: 8px;\n  color: ${theme.errorText};\n  white-space: pre-line;\n`);\n\nconst cssTableList = styled(\"div\", `\n  align-self: flex-start;\n  max-width: 100%;\n  display: flex;\n  padding: 0px var(--css-modal-dialog-padding-horizontal, 0px);\n`);\n\nconst cssTabItemContent = styled(\"div\", `\n  border: 1px solid transparent;\n  padding-left: 20px;\n  padding-right: 20px;\n  display: flex;\n  align-items: center;\n  align-content: flex-end;\n  overflow: hidden;\n  border-radius: 4px 4px 0px 0px;\n  height: 56px;\n  column-gap: 8px;\n  &-selected {\n    border: 1px solid ${theme.importerTableInfoBorder};\n    border-bottom-color: ${theme.importerMainContentBg};\n    background-color: ${theme.importerMainContentBg};\n  }\n`);\n\nconst cssTabItem = styled(\"div\", `\n  background: ${theme.importerOutsideBg};\n  position: relative;\n  cursor: pointer;\n  margin-bottom: -2px;\n  border-bottom: 1px solid ${theme.importerMainContentBg};\n  flex: 1;\n  &-not-selected + &-not-selected::after{\n    content: '';\n    position: absolute;\n    left: 0px;\n    top: 20%;\n    height: 60%;\n    border-left: 1px solid ${theme.importerTableInfoBorder};\n  }\n  &-not-selected .${cssBorderBottom.className} {\n    display: block;\n  }\n  &-not-selected .${cssFileTypeIcon.className} {\n    display: none;\n  }\n  &-not-selected {\n    min-width: 0px;\n  }\n  &-not-selected:first-child .${cssTabItemContent.className} {\n    padding-left: 0px;\n  }\n`);\n\nconst cssTableLine = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  overflow: hidden;\n  flex-shrink: 1;\n  height: 100%;\n`);\n\nconst cssTableSource = styled(\"div\", `\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  flex-shrink: 1;\n`);\n\nconst cssConfigAndPreview = styled(\"div\", `\n  display: flex;\n  gap: 8px;\n  flex-grow: 1;\n  height: 0px;\n  background-color: ${theme.importerMainContentBg};\n  padding-right: var(--css-modal-dialog-padding-horizontal, 0px);\n`);\n\nconst cssConfigLeft = styled(\"div\", `\n  padding-right: 8px;\n  padding-top: 16px;\n  position: absolute;\n  inset: 0;\n  display: flex;\n  flex-direction: column;\n  overflow-y: auto;\n  width: 100%;\n  transition: transform 0.2s ease-in-out;\n`);\n\nconst cssConfigRight = styled(cssConfigLeft, `\n  left: 100%;\n  padding-left: var(--css-modal-dialog-padding-horizontal, 0px);\n`);\n\nconst cssConfigPanel = styled(\"div\", `\n  width: 360px;\n  height: 100%;\n  position: relative;\n  overflow-x: hidden;\n  &-right .${cssConfigLeft.className} {\n    transform: translateX(-100%);\n  }\n  &-right .${cssConfigRight.className} {\n    transform: translateX(-100%);\n  }\n`);\n\nconst cssPreviewColumn = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex-grow: 1;\n`);\n\nconst cssPreview = styled(\"div\", `\n  display: flex;\n  flex-grow: 1;\n`);\n\nconst cssPreviewSpinner = styled(cssPreview, `\n  align-items: center;\n  justify-content: center;\n`);\n\nconst cssOverlay = styled(\"div\", `\n  background: ${theme.importerSkippedTableOverlay};\n  flex: 1;\n  display: grid;\n  place-items: center;\n`);\n\nconst cssPreviewGrid = styled(cssPreview, `\n  border: 1px solid ${theme.importerPreviewBorder};\n  position: relative;\n`);\n\nconst cssMergeOptions = styled(\"div\", `\n  margin-bottom: 16px;\n`);\n\nconst cssMergeOptionsToggle = styled(\"div\", `\n  margin-bottom: 8px;\n  margin-top: 8px;\n`);\n\nconst cssMergeOptionsMessage = styled(\"div\", `\n  color: ${theme.lightText};\n  margin-bottom: 8px;\n`);\n\nconst cssColumnMatchHeader = styled(\"div\", `\n  display: grid;\n  grid-template-columns: 1fr 20px 1fr;\n  text-transform: uppercase;\n  color: ${theme.lightText};\n  letter-spacing: 1px;\n  font-size: ${vars.xsmallFontSize};\n  margin-bottom: 12px;\n`);\n\nconst cssColumnMatchRow = styled(\"div\", `\n  display: grid;\n  grid-template-columns: 1fr 20px 1fr 20px;\n  gap: 4px;\n  align-items: center;\n  --icon-color: ${theme.iconDisabled};\n  & + & {\n    margin-top: 16px;\n  }\n`);\n\nconst cssFieldFormula = styled(\"div\", `\n  flex: auto;\n  cursor: pointer;\n  margin-top: 1px;\n  padding-left: 24px;\n  --icon-color: ${theme.accentIcon};\n`);\n\nconst cssDestinationFieldLabel = styled(\"div\", `\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  padding-left: 4px;\n  cursor: unset;\n  background-color: ${theme.pageBg};\n  color: ${theme.text};\n  width: 100%;\n  height: 30px;\n  line-height: 16px;\n  font-size: ${vars.mediumFontSize};\n  padding: 5px;\n  border: 1px solid ${theme.selectButtonBorder};\n  border-radius: 3px;\n  user-select: none;\n  outline: none;\n`);\n\nconst cssUnmatchedIcon = styled(icon, `\n  height: 12px;\n  --icon-color: ${theme.lightText};\n  vertical-align: bottom;\n  margin-bottom: 2px;\n`);\n\nconst cssUnmatchedFields = styled(\"div\", `\n  display: flex;\n  flex-wrap: wrap;\n  row-gap: 2px;\n  column-gap: 4px;\n  align-items: flex-start;\n`);\n\nconst cssUnmatchedFieldsIntro = styled(\"div\", `\n  padding: 4px 8px;\n`);\n\nconst cssUnmatchedFieldsList = styled(\"div\", `\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  overflow: hidden;\n  padding-right: 16px;\n  color: ${theme.text};\n  border-radius: 8px;\n  padding: 4px 8px;\n  background-color: ${theme.pagePanelsBorder};\n  max-width: 160px;\n  cursor: pointer;\n`);\n\nconst cssImportButtons = styled(\"div\", `\n  padding-top: 40px;\n  padding-left: var(--css-modal-dialog-padding-horizontal, 0px);\n  padding-right: var(--css-modal-dialog-padding-horizontal, 0px);\n  padding-bottom: calc(var(--css-modal-dialog-padding-vertical, 0px) - 12px);\n  background-color: ${theme.importerMainContentBg};\n`);\n\nconst cssImportButtonsLine = styled(\"div\", `\n  height: 52px;\n  overflow: hidden;\n  display: flex;\n  gap: 8px;\n  align-items: flex-start;\n`);\n\nconst cssTitle = styled(\"span._cssToFrom\", `\n  color: ${theme.darkText};\n  text-transform: uppercase;\n  font-weight: 600;\n  font-size: ${vars.smallFontSize};\n  letter-spacing: 0.5px;\n  padding-left: var(--css-modal-dialog-padding-horizontal, 0px);\n  text-align: left;\n  margin-bottom: 16px;\n`);\n\nconst cssDestinationWrapper = styled(\"div\", `\n  margin-bottom: 1px;\n  /* Reuse the modal padding but move 16px to left if possible */\n  margin-left: max(0px, calc(var(--css-modal-dialog-padding-horizontal, 0px) - 16px));\n  display: flex;\n  align-items: center;\n`);\n\nconst cssDestination = styled(\"div\", `\n  --icon-color: ${theme.lightText};\n  align-items: center;\n  border-radius: 0 3px 3px 0;\n  padding-left: 16px;\n  color: ${theme.text};\n  cursor: pointer;\n  display: flex;\n  height: 32px;\n  line-height: 32px;\n  flex: 1;\n  &:hover {\n    background-color: ${theme.pageHoverBg};\n  }\n  &-selected, &-selected:hover {\n    background-color: ${theme.activePageBg};\n    color: ${theme.activePageFg};\n    --icon-color: ${theme.activePageFg};\n  }\n`);\n\nconst cssOptions = styled(\"div\", `\n  display: flex;\n  align-items: flex-end;\n  padding-bottom: 8px;\n  justify-content: space-between;\n  height: 36px;\n`);\n\nconst cssTableName = styled(\"span\", `\n  font-weight: 600;\n`);\n\nconst cssNavigation = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  margin-bottom: 8px;\n`);\n\nconst cssDetailsIcon = styled(icon, `\n  flex: none;\n  color: ${theme.controlFg};\n  --icon-color: ${theme.controlFg};\n  margin-left: 4px;\n  margin-top: -4px;\n  cursor: pointer;\n  &:hover {\n    --icon-color: ${theme.controlHoverFg};\n  }\n`);\n\nconst cssError = styled(icon, `\n  --icon-color: ${theme.iconError};\n  right: 2px;\n  position: absolute;\n  z-index: 1;\n  top: calc(50% - 8px);\n`);\n\nconst cssNavigationIcon = styled(icon, `\n  flex: none;\n  color: ${theme.controlFg};\n  --icon-color: ${theme.controlFg};\n  margin-right: 4px;\n  margin-top: -3px;\n  width: 12px;\n`);\n\nconst cssFlexBaseline = styled(\"div\", `\n  display: flex;\n  align-items: baseline;\n`);\n\nconst cssSelected = styled(cssTableSource, `\n  &-skip {\n    color: ${theme.lightText};\n  }\n`);\n\nconst cssIcon180 = styled(icon, `\n  transform: rotate(180deg);\n`);\n\nconst cssGreenIcon = styled(`div`, `\n--icon-color: ${theme.accentIcon};\n`);\n\nconst cssColumnMappingNav = styled(\"span\", `\n  text-transform: uppercase;\n  color: ${theme.darkText};\n  text-transform: uppercase;\n  font-weight: 600;\n  font-size: ${vars.smallFontSize};\n  letter-spacing: 0.5px;\n`);\n\nconst cssSlash = styled(\"div\", `\n  padding: 0px 4px;\n  font-size: ${vars.xsmallFontSize};\n  color: ${theme.lightText};\n`);\n\nconst cssDestinationTableSecondary = styled(textButton, `\n  text-transform: uppercase;\n  font-size: ${vars.smallFontSize};\n  letter-spacing: 0.5px;\n  text-align: left;\n  margin-bottom: 16px;\n  color: ${theme.lightText};\n`);\n"
  },
  {
    "path": "app/client/components/KeyboardFocusHighlighter.ts",
    "content": "/**\n * KeyboardFocusHighlighter helps kb users view what interactive elements they have focus on.\n *\n * In an ideal world, this would not exist and our css styling would normally highlight\n * focused elements. But for now, lots of css rules disable focus rings.\n *\n * This is done as a quick way to make sure focus rings are correctly visible when using a kb,\n * without impacting touch/mouse users, and without having to change the whole codebase.\n */\nimport { components } from \"app/common/ThemePrefs\";\n\nimport { Disposable, dom, styled } from \"grainjs\";\n\nexport class KeyboardFocusHighlighter extends Disposable {\n  constructor() {\n    super();\n    this.autoDispose(dom.onElem(window, \"keydown\", this._onKeyDown));\n    this.autoDispose(dom.onElem(window, \"touchstart\", this._clear));\n    this.autoDispose(dom.onElem(window, \"mousedown\", this._clear));\n  }\n\n  public isKeyboardUser = () => {\n    return document.documentElement.classList.contains(cssKeyboardUser.className);\n  };\n\n  private _onKeyDown = (event: KeyboardEvent) => {\n    if (event.key === \"Tab\") {\n      document.documentElement.classList.add(cssKeyboardUser.className);\n    }\n  };\n\n  private _clear = () => {\n    document.documentElement.classList.remove(cssKeyboardUser.className);\n  };\n}\n\nexport const kbFocusHighlighterClass = \"kb-focus-highlighter-group\";\n\nconst cssKeyboardUser = styled(\"div\", `\n  & .${kbFocusHighlighterClass} :is(a, input, textarea, select, button, [tabindex=\"0\"]):focus-visible {\n    outline: 3px solid ${components.kbFocusHighlight} !important;\n  }\n`);\n"
  },
  {
    "path": "app/client/components/Layout.css",
    "content": ".layout_root {\n  position: relative;\n  width: 100%;\n  height: 100%;\n}\n\n.diff .layout_root {\n  width: 100%;\n}\n\n.layout_root.layout_fill_window {\n  position: absolute;\n}\n\n.layout_root > .layout_box {\n  height: 100%;\n}\n\n.layout_box {\n  position: relative;\n  display: -webkit-flex;\n  display: flex;\n  min-width: 0px;\n  flex-grow: var(--flex-grow, 1) !important;\n}\n\n.layout_hbox.layout_fill_window {\n  -webkit-flex: 1 1 0px;\n  flex: 1 1 0px;\n}\n\n/* We can't use ':last-child' because of resize-handle elements tacked on beyond it. */\n.layout_hbox.layout_last_child {\n  -webkit-flex: 1 1 auto;\n  flex: 1 1 auto;\n}\n\n.layout_vbox {\n  -webkit-flex-direction: column;\n  flex-direction: column;\n  -webkit-flex: 1 1 0px;\n  flex: 1 1 0px;\n}\n\n/* not so much to specify the look, as to simplify filtering events */\n.layout_leaf {\n  -webkit-flex-direction: column;\n  flex-direction: column;\n}\n\n.layout_new, .layout_trash {\n  min-height: 2rem;\n  line-height: 2rem;\n  padding: 0.5rem 1rem;\n  margin: 0.5rem 0;\n  cursor: default;\n}\n\n.layout_trash:hover, .layout_new:hover {\n  background-color: #F8F8F8;\n}\n\n.layout_new {\n  border-left: 1px solid lightgrey;\n  border-top: 1px solid lightgrey;\n  border-right: 1px solid grey;\n  border-bottom: 1px solid grey;\n  color: grey;\n}\n\n.layout_trash {\n  border: 1px solid lightgrey;\n  border-radius: 3px;\n  color: red;\n}\n\n.layout_leaf_test {\n  border-left: 1px solid lightgrey;\n  border-top: 1px solid lightgrey;\n  border-right: 1px solid grey;\n  border-bottom: 1px solid grey;\n  color: grey;\n  width: 100%;\n  -webkit-flex: 1 1 0px;\n  flex: 1 1 0px;\n  min-height: 5rem;\n  line-height: 5rem;\n  justify-content: center;\n  text-align: center;\n}\n\n.layout_leaf_test_big {\n  min-height: 7rem;\n}\n\n.layout_hidden {\n  display: none;\n}\n"
  },
  {
    "path": "app/client/components/Layout.ts",
    "content": "/**\n * This module provides the ability to render and edit hierarchical layouts of boxes. Each box may\n * contain a list of other boxes, and horizontally- and vertically-arranged lists alternating with\n * the depth in the hierarchy.\n *\n * Layout\n *    Layout is a tree of LayoutBoxes (HBoxes and VBoxes). It consists of HBoxes and VBoxes in\n *    alternating levels. The leaves of the tree are LeafBoxes, and those are the only items that\n *    may be moved around, with the structure of Boxes above them changing to accommodate.\n *\n * LayoutBox\n *    A LayoutBox is a node in the Layout tree. LayoutBoxes should typically have nothing visual\n *    about them (e.g. no borders) except their dimensions: they serve purely for layout purposes.\n *\n *    A LayoutBox may be an HBox or a VBox. An HBox may contain multiple VBoxes arranged in a row.\n *    A VBox may contain multiple HBoxes one under the other. Either kind of LayoutBox may contain\n *    a single LeafBox instead of child LayoutBoxes. No LayoutBox may be empty, and no LayoutBox\n *    may contain a single LayoutBox as a child: it must contain either multiple LayoutBox\n *    children, or a single LeafBox.\n *\n * LeafBox\n *    A LeafBox is the container for user content, i.e. what needs to be laid out, for example\n *    form elements. LeafBoxes are what the user can drag around to other location in the layout.\n *    All the LeafBoxes in a Layout together fill the entire Layout rectangle. If some parts of\n *    the layout are to be empty, they should still contain an empty LeafBox.\n *\n *    There is no separate JS class for LeafBoxes, they are simply LayoutBoxes with .layout_leaf\n *    class and set leafId and leafContent member observables.\n *\n * Floater\n *    A Floater is a rectangle that floats over the layout with the mouse pointer while the user is\n *    dragging a LeafBox. It contains the content of the LeafBox being dragged, so that the user\n *    can see what is being repositioned.\n *\n * DropOverlay\n *    An DropOverlay is a visual aid to the user to indicate area over the current LeafBox where a\n *    drop may be attempted. It also computes the \"affinity\": which border of the current LeafBox\n *    the user is trying to target as the insertion point.\n *\n * DropTargeter\n *    DropTargeter displays a set of rectangles, each of which represents a particular allowed\n *    insertion point for the element being dragged. E.g. dragging an element to the right side of\n *    a LeafBox would display a drop target for each LayoutBox up the tree that allows a sibling\n *    to be inserted on the right.\n *\n * Saving Changes\n * --------------\n *    We don't attempt to save granular changes to the layout, for each drag operation, because\n *    for the user, it's better to finish editing the layout, and only save the end result. Also,\n *    it's not so easy (the structure changes many times while dragging, and a single drag\n *    operation results in a non-trivial diff of the 'before' and 'after' layouts). So instead, we\n *    just have a way to serialize the layout to and from a JSON blob.\n */\n\nimport { BoxSpec } from \"app/client/lib/BoxSpec\";\nimport { Disposable } from \"app/client/lib/dispose\";\nimport dom, { detachNode, findAncestor } from \"app/client/lib/dom\";\nimport koArray, { isKoArray, KoArray } from \"app/client/lib/koArray\";\nimport { cssClass, domData, foreach, scope, style, toggleClass } from \"app/client/lib/koDom\";\n\nimport assert from \"assert\";\n\nimport { Events as BackboneEvents } from \"backbone\";\nimport * as ko from \"knockout\";\nimport { computed, isObservable, observable, utils } from \"knockout\";\nimport { identity, isEqual, last, uniqueId } from \"underscore\";\n\nexport interface ContentBox {\n  leafId: ko.Observable<any>;\n  leafContent: ko.Observable<Element | null>;\n  dom: HTMLElement | null;\n}\n\n/**\n * A LayoutBox is the node in the hierarchy of boxes comprising the layout. This class is used for\n * rendering as well as for the code editor. Since it may be rendered many times on a page, it's\n * important for it to be efficient.\n * @param {Layout} layout: The Layout object that manages this LayoutBox.\n */\nexport class LayoutBox extends Disposable implements ContentBox {\n  public layout: Layout;\n  public dom: HTMLElement | null = null;\n  public leafId: ko.Observable<any>; // probably number for section id\n  public parentBox: ko.Observable<LayoutBox | null>;\n  public childBoxes: KoArray<LayoutBox>;\n  public leafContent: ko.Observable<Element | null>;\n  public uniqueId: string;\n  public isVBox: ko.Computed<boolean>;\n  public isHBox: ko.Computed<boolean>;\n  public isLeaf: ko.Computed<boolean>;\n  public isMaximized: ko.Computed<boolean>;\n  public isHidden: ko.Computed<boolean>;\n  public flexSize: ko.Observable<number>;\n  private _parentBeingDisposed: boolean;\n\n  public create(layout: Layout) {\n    this.layout = layout;\n    this.parentBox = observable(null as any);\n    this.childBoxes = koArray();\n    this.leafId = observable(null);\n    this.leafContent = observable(null as any);\n    this.uniqueId = uniqueId(\"lb\"); // For logging and debugging.\n\n    this.isVBox = this.autoDispose(computed(() => {\n      return this.parentBox() ? !this.parentBox()!.isVBox() : true;\n    }, this));\n    this.isHBox = this.autoDispose(computed(() => { return !this.isVBox(); }));\n    this.isLeaf = this.autoDispose(computed(() => { return this.leafId() !== null; },\n      this));\n\n    this.isMaximized = this.autoDispose(ko.pureComputed(() => {\n      const leafId = this.layout?.maximizedLeaf();\n      if (!leafId) { return false; }\n      if (leafId === this.leafId()) { return true; }\n      return this.childBoxes.all().some(function(child) { return child.isMaximized(); });\n    }, this));\n    this.isHidden = this.autoDispose(ko.pureComputed(() => {\n      // If there isn't any maximized box, then no box is hidden.\n      const maximized = this.layout?.maximizedLeaf();\n      if (!maximized) { return false; }\n      return !this.isMaximized();\n    }, this));\n\n    // flexSize represents flexWidth for VBoxes and flexHeight for HBoxes.\n    // Undesirable transition effects are likely when <1, so we set average value\n    // to 100 so that reduction below 1 is rare.\n    this.flexSize = observable(100);\n\n    this.dom = null;\n\n    // This is an optimization to avoid the wasted cost of removeFromParent during disposal.\n    this._parentBeingDisposed = false;\n\n    this.autoDisposeCallback(() => {\n      if (!this._parentBeingDisposed) {\n        this.removeFromParent();\n      }\n      this.childBoxes.peek().forEach(function(child) {\n        child._parentBeingDisposed = true;\n        child.dispose();\n      });\n    });\n  }\n\n  public getDom() {\n    return this.dom || (this.dom = this.autoDispose(this.buildDom()));\n  }\n\n  public maximize() {\n    if (this.layout.maximizedLeaf.peek() !== this.leafId.peek()) {\n      this.layout.maximizedLeaf(this.leafId());\n    } else {\n      this.layout.maximizedLeaf(null);\n    }\n  }\n\n  public buildDom() {\n    const self = this;\n    const wrap = this.layout.needDynamic ? identity : makeStatic;\n\n    return dom(\"div.layout_box\",\n      toggleClass(\"layout_leaf\", wrap(this.isLeaf)),\n      toggleClass(\"layout_hidden\", this.isHidden),\n      toggleClass(this.layout.leafId, wrap(this.isLeaf)),\n      cssClass(wrap(function() { return self.isVBox() ? \"layout_vbox\" : \"layout_hbox\"; })),\n      cssClass(wrap(function() {\n        return (self.layout.fillWindow ? \"layout_fill_window\" :\n          (self.isLastChild() ? \"layout_last_child\" : null));\n      })),\n      style(\"--flex-grow\", wrap(function() {\n        return (self.isVBox() || (self.isHBox() && self.layout.fillWindow)) ? self.flexSize() : \"\";\n      })),\n      domData(\"layoutBox\", this),\n      foreach(wrap(this.childBoxes), function(layoutBox: LayoutBox) {\n        return layoutBox.getDom();\n      }),\n      scope(wrap(this.leafContent), function(leafContent: any) {\n        return leafContent;\n      }),\n    );\n  }\n\n  /**\n   * Moves the leaf id and content from another layoutBox, unsetting them in the source one.\n   */\n  public takeLeafFrom(sourceLayoutBox: ContentBox) {\n    this.leafId(sourceLayoutBox.leafId.peek());\n    // Note that we detach the node, so that the old box doesn't destroy its DOM.\n    this.leafContent(detachNode(sourceLayoutBox.leafContent.peek()));\n    sourceLayoutBox.leafId(null);\n    sourceLayoutBox.leafContent(null);\n  }\n\n  public setChildren(children: LayoutBox[]) {\n    children.forEach(child => child.parentBox(this));\n    this.childBoxes.assign(children);\n  }\n\n  public isFirstChild() {\n    return this.parentBox() ? this.parentBox()!.childBoxes.peek()[0] === this : true;\n  }\n\n  public isLastChild() {\n    // Use .all() rather than .peek() because it's used in kd.toggleClass('layout_last_child'), and\n    // we want it to automatically stay correct when childBoxes array changes.\n    return this.parentBox() ? last(this.parentBox()!.childBoxes.all()) === this : true;\n  }\n\n  public isDomDetached() {\n    return !(this.dom?.parentNode);\n  }\n\n  public getSiblingBox(isAfter: boolean) {\n    if (!this.parentBox()) {\n      return null;\n    }\n    const siblings = this.parentBox()!.childBoxes.peek();\n    let index = siblings.indexOf(this);\n    if (index < 0) {\n      return null;\n    }\n    index += (isAfter ? 1 : -1);\n    return (index < 0 || index >= siblings.length ? null : siblings[index]);\n  }\n\n  public _addChild(childBox: LayoutBox, isAfter: boolean, optNextSibling?: LayoutBox) {\n    assert(childBox.parentBox() === null, \"LayoutBox._addChild: child already has parentBox set\");\n    let index;\n    if (optNextSibling) {\n      index = this.childBoxes.peek().indexOf(optNextSibling) + (isAfter ? 1 : 0);\n    } else {\n      index = isAfter ? this.childBoxes.peekLength : 0;\n    }\n    childBox.parentBox(this);\n    this.childBoxes.splice(index, 0, childBox);\n  }\n\n  public addSibling(childBox: LayoutBox, isAfter: boolean) {\n    childBox.removeFromParent();\n    const parentBox = this.parentBox();\n    if (parentBox) {\n      // Normally, we just add a sibling as requested.\n      parentBox._addChild(childBox, isAfter, this);\n    } else {\n      // If adding a sibling to the root node (another VBox), we need to create a new root and push\n      // things down two levels (HBox and VBox), and add the sibling to the lower VBox.\n      if (this.childBoxes.peekLength === 1) {\n        // Except when the root has a single child, in which case there is already a good place to\n        // add the new node two levels lower. And we should not create another level because the\n        // root is the only place that can have a single child.\n        const lowerBox = this.childBoxes.peek()[0];\n        assert(!lowerBox.isLeaf(), \"LayoutBox.addSibling: should not have leaf as a single child\");\n        lowerBox._addChild(childBox, isAfter);\n      } else {\n        // Create a new root, and add the sibling two levels lower.\n        const vbox = LayoutBox.create(this.layout);\n        const hbox = LayoutBox.create(this.layout);\n        // We don't need removeFromParent here because this only runs when there is no parent.\n        vbox._addChild(hbox, false);\n        hbox._addChild(this, false);\n        hbox._addChild(childBox, isAfter);\n        this.layout.setRoot(vbox);\n      }\n    }\n    this.layout.trigger(\"layoutChanged\");\n  }\n\n  public addChild(childBox: LayoutBox, isAfter: boolean) {\n    childBox.removeFromParent();\n    if (this.isLeaf()) {\n      // Move the leaf data into a new child, then add the requested childBox.\n      const newBox = LayoutBox.create(this.layout);\n      newBox.takeLeafFrom(this);\n      this._addChild(newBox, false);\n    }\n    this._addChild(childBox, isAfter);\n    this.layout.trigger(\"layoutChanged\");\n  }\n\n  public toString(): string {\n    return this.isDisposed() ? this.uniqueId + \"[disposed]\" : (this.uniqueId +\n      (this.isHBox() ? \"H\" : \"V\") +\n      (this.isLeaf() ? \"(\" + this.leafId() + \")\" :\n        \"[\" + this.childBoxes.peek().map(function(b) { return b.toString(); }).join(\",\") + \"]\")\n    );\n  }\n\n  public _removeChildBox(childBox: LayoutBox) {\n    // console.log(\"_removeChildBox %s from %s\", childBox.toString(), this.toString());\n    let index = this.childBoxes.peek().indexOf(childBox);\n    childBox.parentBox(null);\n    if (index >= 0) {\n      this.childBoxes.splice(index, 1);\n      this.rescaleFlexSizes();\n    }\n    if (this.childBoxes.peekLength === 1) {\n      // If we now have a single child, then something needs to collapse.\n      const lowerBox = this.childBoxes.peek()[0];\n      const parentBox = this.parentBox();\n      if (lowerBox.isLeaf()) {\n        // Move the leaf data into ourselves, and remove the lower box.\n        this.takeLeafFrom(lowerBox);\n        lowerBox.dispose();\n      } else if (parentBox) {\n        // Move grandchildren into our place within our parent, and collapse two levels.\n        // (Unless we are the root, in which case it's OK for us to have a single non-leaf child.)\n        index = parentBox.childBoxes.peek().indexOf(this);\n        assert(index >= 0, \"LayoutBox._removeChildBox: box not found in parent\");\n\n        const grandchildBoxes = lowerBox.childBoxes.peek();\n        grandchildBoxes.forEach(function(box) { box.parentBox(parentBox); });\n        parentBox.childBoxes.arraySplice(index, 0, grandchildBoxes);\n\n        lowerBox.childBoxes.splice(0, lowerBox.childBoxes.peekLength);\n        this.removeFromParent();\n\n        lowerBox.dispose();\n        this.dispose();\n      }\n    }\n  }\n\n  /**\n   * Helper to detach a box from its parent without disposing it. If you no longer plan to reattach\n   * the box, you should probably call box.dispose().\n   */\n  public removeFromParent() {\n    if (this.parentBox()) {\n      this.parentBox()!._removeChildBox(this);\n      this.layout.trigger(\"layoutChanged\");\n    }\n  }\n\n  /**\n   * Adjust flexSize values of the children so that they add up to at least 1.\n   * Otherwise, Firefox will not stretch them to the full size of the container.\n   */\n  public rescaleFlexSizes() {\n    // Just scale so that the smallest value is 1.\n    const children = this.childBoxes.peek();\n    const minSize = Math.min.apply(null, children.map(function(b) { return b.flexSize(); }));\n    if (minSize < 1) {\n      children.forEach(function(b) {\n        b.flexSize(b.flexSize() / minSize);\n      });\n    }\n  }\n}\n\n/**\n * This helper turns a value, observable, or function (as accepted by koDom functions) into a\n * plain value. It's used to build a static piece of DOM without subscribing to any of the\n * observables, to avoid the performance cost of subscribing/unsubscribing.\n */\nfunction makeStatic(valueOrFunc: any) {\n  if (isObservable(valueOrFunc) || isKoArray(valueOrFunc)) {\n    return valueOrFunc.peek();\n  } else if (typeof valueOrFunc === \"function\") {\n    return valueOrFunc();\n  } else {\n    return valueOrFunc;\n  }\n}\n\n// ----------------------------------------------------------------------\n\n/**\n * @event layoutChanged: Triggered on changes to the structure of the layout.\n * @event layoutResized: Triggered on non-structural changes that may affect the size of rootElem.\n */\nexport class Layout extends Disposable {\n  /**\n   * You can also find the nearest containing LayoutBox without having the Layout object itself by\n   * using Layout.Layout.getContainingBox. The Layout object is then accessible as box.layout.\n   */\n  public static getContainingBox(elem: Element | null, optContainer: any) {\n    const boxElem = findAncestor(elem, optContainer, \".layout_box\");\n    return boxElem ? utils.domData.get(boxElem, \"layoutBox\") : null;\n  }\n\n  public listenTo: BackboneEvents[\"listenTo\"];            // set by Backbone\n  public trigger: BackboneEvents[\"trigger\"];              // set by Backbone\n  public stopListening: BackboneEvents[\"stopListening\"];  // set by Backbone\n\n  public maximizedLeaf: ko.Observable<string | null>;\n  public rootBox: ko.Observable<LayoutBox | null>;\n  public createLeafFunc: (id: string) => HTMLElement;\n  public fillWindow: boolean;\n  public needDynamic: boolean;\n  public rootElem: HTMLElement;\n  public leafId: string;\n  private _leafIdMap: Map<any, LayoutBox> | null;\n\n  public create(boxSpec: BoxSpec, createLeafFunc: (id: string) => HTMLElement, optFillWindow: boolean) {\n    this.maximizedLeaf = observable(null as (string | null));\n    this.rootBox = observable(null as any);\n    this.createLeafFunc = createLeafFunc;\n    this._leafIdMap = null;\n    this.fillWindow = optFillWindow || false;\n    this.needDynamic = false;\n    this.rootElem = this.autoDispose(this.buildDom());\n\n    // Generates a unique id class so boxes can only be placed next to other boxes in this layout.\n    this.leafId = uniqueId(\"layout_leaf_\");\n\n    this.buildLayout(boxSpec || {});\n\n    // Invalidate the _leafIdMap when the layout is adjusted.\n    this.listenTo(this, \"layoutChanged\", () => { this._leafIdMap = null; });\n\n    this.autoDisposeCallback(() => {\n      if (this.rootBox()) {\n        this.rootBox()!.dispose();\n      }\n    });\n  }\n\n  /**\n   * Finds and returns the leaf layout box containing the content for the given leafId.\n   */\n  public getLeafBox(leafId: string | number) {\n    return this.getLeafIdMap().get(leafId);\n  }\n\n  /**\n   * Returns the list of all leafIds present in this layout.\n   */\n  public getAllLeafIds() {\n    return Array.from(this.getLeafIdMap().keys());\n  }\n\n  public setRoot(layoutBox: LayoutBox) {\n    this.rootBox(layoutBox);\n  }\n\n  public buildDom() {\n    return dom(\"div.layout_root\",\n      domData(\"layoutModel\", this),\n      toggleClass(\"layout_fill_window\", this.fillWindow),\n      toggleClass(\"layout_box_maximized\", this.maximizedLeaf),\n      scope(this.rootBox, (rootBox: LayoutBox) => {\n        return rootBox ? rootBox.getDom() : null;\n      }),\n    );\n  }\n\n  /**\n   * Calls cb on each box in the layout recursively.\n   */\n  public forEachBox(cb: (box: LayoutBox) => void, optContext?: any) {\n    if (!this.rootBox.peek()) {\n      return;\n    }\n    function iter(box: any) {\n      cb.call(optContext, box);\n      box.childBoxes.peek().forEach(iter);\n    }\n    iter(this.rootBox.peek());\n  }\n\n  public buildLayoutBox(boxSpec: BoxSpec) {\n    // Note that this is hot code: it runs when rendering a layout for each record, not only for the\n    // layout editor.\n    const box = LayoutBox.create(this);\n    if (boxSpec.size) {\n      box.flexSize(boxSpec.size);\n    }\n    if (boxSpec.leaf) {\n      box.leafId(boxSpec.leaf);\n      box.leafContent(this.createLeafFunc(box.leafId()));\n    } else if (boxSpec.children) {\n      box.setChildren(boxSpec.children.map(this.buildLayoutBox, this));\n    }\n    return box;\n  }\n\n  public buildLayout(boxSpec: BoxSpec, needDynamic = false) {\n    if (needDynamic === this.needDynamic &&\n      this.rootBox() &&\n      isEqual(boxSpec, this.getLayoutSpec())) {\n      // Nothing has changed, and we already have a layout. No need to rebuild.\n      return;\n    }\n    this.needDynamic = needDynamic;\n    const oldRootBox = this.rootBox();\n    this.rootBox(this.buildLayoutBox(boxSpec));\n    this.trigger(\"layoutChanged\");\n    if (oldRootBox) {\n      oldRootBox.dispose();\n    }\n  }\n\n  public _getBoxSpec(layoutBox: LayoutBox) {\n    const spec: BoxSpec = {};\n    if (layoutBox.isDisposed()) {\n      return spec;\n    }\n    if (layoutBox.flexSize() && layoutBox.flexSize() !== 100) {\n      spec.size = layoutBox.flexSize();\n    }\n    if (layoutBox.isLeaf()) {\n      spec.leaf = layoutBox.leafId();\n    } else {\n      spec.children = layoutBox.childBoxes.peek().map(this._getBoxSpec, this);\n    }\n    return spec;\n  }\n\n  public getLayoutSpec() {\n    const rootBox = this.rootBox();\n    return rootBox ? this._getBoxSpec(rootBox) : {};\n  }\n\n  /**\n   * Returns a Map object mapping leafId to its LayoutBox. This gets invalidated on layoutAdjust\n   * events, and rebuilt on next request.\n   */\n  public getLeafIdMap() {\n    if (!this._leafIdMap) {\n      this._leafIdMap = new Map<number | string, LayoutBox>();\n      this.forEachBox((box) => {\n        const leafId = box.leafId.peek();\n        if (leafId !== null) {\n          this._leafIdMap!.set(leafId, box);\n        }\n      }, this);\n    }\n    return this._leafIdMap;\n  }\n\n  /**\n   * Returns a LayoutBox object containing the given DOM element, or null if not found.\n   */\n  public getContainingBox(elem: Element | null) {\n    return Layout.getContainingBox(elem, this.rootElem);\n  }\n}\n\nObject.assign(Layout.prototype, BackboneEvents);\n"
  },
  {
    "path": "app/client/components/LayoutEditor.css",
    "content": ".layout_editor_floater {\n  position: absolute;\n  overflow: hidden;\n  pointer-events: none;\n  z-index: 10;\n  -webkit-transform: rotate(5deg) scale(0.8);\n  transform: rotate(5deg) scale(0.8);\n\n  display: -webkit-flex;\n  display: flex;\n}\n\n/* Invisible div, into which we can place content that needs to be measured. */\n.layout_editor_measuring_box {\n  position: absolute;\n  left: 0px;\n  top: 0px;\n  border: none;\n  visibility: hidden;\n}\n\n.layout_editor_drop_overlay {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  opacity: 0.1;\n  border-top: 0px solid #66F;\n  border-bottom: 0px solid #66F;\n  border-left: 0px solid #6F6;\n  border-right: 0px solid #6F6;\n  pointer-events: none;\n}\n\n.layout_editor_drop_targeter {\n  position: absolute;\n  top: 0px;\n  left: 0px;\n}\n.layout_editor_drop_target {\n  position: absolute;\n  border: 2px dashed black;\n  z-index: 10;\n}\n.layout_editor_drop_target.layout_hover {\n  border: 2px dashed #798AF1;\n}\n\n.layout_editor_empty_space {\n  background-color: rgba(0,0,0,0.1);\n  border-radius: 2px;\n  -webkit-flex: 1 1 0px;\n  flex: 1 1 0px;\n}\n\n.layout_editor_resize_transition {\n  -webkit-transition: height .4s cubic-bezier(0.4, 0, 0.2, 1), width .4s cubic-bezier(0.4, 0, 0.2, 1), opacity .8s;\n  transition: height .4s cubic-bezier(0.4, 0, 0.2, 1), width .4s cubic-bezier(0.4, 0, 0.2, 1), opacity .8s;\n  min-height: 0px !important;\n  /* Important tags necessary for .layout_hbox.layout_fill_window flex boxes */\n  -webkit-flex-basis: auto !important;\n  flex-basis: auto !important;\n}\n\n.layout_box > .ui-resizable-handle {\n  opacity: 0.0;\n  -webkit-transition: opacity .2s;\n  transition: opacity .2s;\n}\n\n.layout_box > .ui-resizable-w,\n.layout_box > .ui-resizable-e {\n  cursor: ew-resize;\n  border-left: 1px dashed #a9a9a9;\n  margin-right: -1px;\n}\n\n.layout_box > .ui-resizable-s {\n  cursor: ns-resize;\n  border-top: 1px dashed #a9a9a9;\n  margin-bottom: -1px;\n}\n\n.layout_box > .ui-resizable-handle:hover {\n  opacity: 1.0;\n}\n\n.layout_grabbable:hover {\n  cursor: -webkit-grab;\n  cursor: grab;\n}\n\n/* TODO: Grabbing cursor does not show in Firefox */\n.layout_grabbable:active {\n  cursor: -webkit-grabbing;\n}\n"
  },
  {
    "path": "app/client/components/LayoutEditor.ts",
    "content": "/**\n * The LayoutEditor can be attached to a Layout object to allow changing it.\n *\n * Issues:\n * TODO: Hitting ESC while dragging should revert smoothly. We can collapse the original leaf, but\n * not remove it. On Cancel, we would uncollapse it, and remove the newly-inserted targetBox.\n * TODO: UNDO should work. It's OK to just rebuild the old layout without any transition. In other\n * words, this may be fine to do fully outside of LayoutEditor.\n * TODO: if mouseup over an active hint of the DropTargeter, it might be a better experience to\n * reposition to that spot.\n *\n * TEST CASES THAT SHOULD BE VERIFIED AFTER ANY CHANGE.\n * These refer to test/client/components/sampleLayout.js, testable at\n * http://localhost:8080/testKoForm.html#topTab=4.\n * 1. Drag #1 down and up its container element, pausing at borders. Elements around that border\n * should smoothly float to open space for it. Dropping it should cause no jumps.\n * 2. Drag #1 down to top of #6. A grey \"drop target\" rectangle should appear. Hovering over it\n * should open space over #6. After that, dragging to bottom of #6 and back to top of #6 should\n * open the space automatically without the \"drop target\".\n * 3. Drag #3 right and left in its container, pausing at borders. Elements should again smoothly\n * float to open space for it. Dropping it should cause no jumps.\n * 4. Drag #4 down into #5, positioning above #5, below, to the left (splitting #5 horizontally)\n * or to the right.\n * 5. Drop #4 onto the leftmost \"drop target\" on the left side of #5. It should end up as 1/3 of\n * the width of the entire layout, spanning the full height above #6. Drop it back to its place\n * between #3 and #9.\n * 6. Resizing: every vertical line should allow dragging it left or right to resize. The \"resize\"\n * mouse pointer should appear over a few pixels to the left and right of the border, it should\n * not be a difficult area to target. (This gets messed up if overflow:hidden is set on the box\n * elements.)\n * 7. Drag box 3 to trash; hovering should make it disappear from Layout, mousing back should\n * bring it back. Mouse-up over the trash icon should leave it out of the layout.\n * 8. Drag boxes 3, 9, 10, 2, 7, 1 (8 should stretch vertically), 5 to trash. They should\n * disappear with other elements shrinking or expanding to close the gap.\n * 9. Adding a new element: Drag \"+ Add New\" box to between 1 and 2. A \"drop target\" should\n * appear, allowing you to insert it. Same for adding between 3 and 4. Should be no jumps.\n * 10. Drag new element to above #3: three possible drop targets should appear. Hover over each in\n * turn, starting from the bottommost part, and make sure it gets inserted in the right level.\n */\n\nimport { ContentBox, Layout, LayoutBox } from \"app/client/components/Layout\";\nimport { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { Delay } from \"app/client/lib/Delay\";\nimport { Disposable, emptyNode } from \"app/client/lib/dispose\";\nimport dom from \"app/client/lib/dom\";\nimport koDom from \"app/client/lib/koDom\";\n\nimport assert from \"assert\";\n\nimport { Events as BackboneEvents } from \"backbone\";\nimport Promise from \"bluebird\";\nimport * as ko from \"knockout\";\nimport { observable, removeNode, utils } from \"knockout\";\nimport { extend, noop, pick } from \"underscore\";\n\n/**\n * Use the browser globals in a way that allows replacing them with mocks in tests.\n */\nconst G = getBrowserGlobals(\"document\", \"window\", \"$\");\n\ntype JQMouseEvent = JQuery.MouseEventBase | MouseEvent;\n\n// ----------------------------------------------------------------------\n\nclass HelperBox {\n  public box!: LayoutBox;\n  public scalePerFlexUnit: number = 0;\n  public nextSiblings: LayoutBox[] = [];\n  public origNextSizes: number[] = [];\n  public origSize: number = 0;\n  public sumAll: number = 0;\n  public sumPrev: number = 0;\n  public sumNext: number = 0;\n  constructor(data?: Partial<HelperBox>) {\n    if (data) {\n      extend(this, data);\n    }\n  }\n}\n\ninterface TargetPart {\n  box: LayoutBox;\n  isChild: boolean;\n  isAfter: boolean;\n}\n\ninterface JqueryUI {\n  size: { width: number, height: number };\n  position: { left: number, top: number };\n  originalPosition: { left: number, top: number };\n  originalSize: { width: number, height: number };\n}\n\ntype LeafId = string | number;\n\n/**\n * The Floater class represents a floating version of the element being dragged around. Its size\n * corresponds to the box being dragged. It lets the user see what's being repositioned.\n */\nclass Floater extends Disposable implements ContentBox {\n  public leafId: ko.Observable<LeafId | null>;\n  public leafContent: ko.Observable<Element | null>;\n  public fillWindow: boolean;\n  public dom: HTMLElement;\n  public mouseOffsetX: number;\n  public mouseOffsetY: number;\n  public lastMouseEvent: JQMouseEvent | null;\n\n  public create(fillWindow?: boolean) {\n    this.leafId = observable<LeafId | null>(null);\n    this.leafContent = observable<Element | null>(null);\n    this.fillWindow = fillWindow || false;\n\n    this.dom = this.autoDispose(dom(\"div.layout_editor_floater\",\n      koDom.show(this.leafContent),\n      koDom.scope(this.leafContent, (leafContent: Element) => {\n        return leafContent;\n      }),\n    ));\n    G.document.body.appendChild(this.dom);\n\n    this.mouseOffsetX = 0;\n    this.mouseOffsetY = 0;\n    this.lastMouseEvent = null;\n  }\n\n  public onInitialMouseMove(mouseEvent: JQMouseEvent, sourceBox: ContentBox) {\n    const rect = sourceBox.dom!.getBoundingClientRect();\n    this.dom.style.width = rect.width + \"px\";\n    this.dom.style.height = rect.height + \"px\";\n    this.mouseOffsetX = 0.2 * rect.width;\n    this.mouseOffsetY = 0.1 * rect.height;\n    this.onMouseMove(mouseEvent);\n\n    this.leafId(sourceBox.leafId());\n    this.leafContent(sourceBox.leafContent());\n    // We use a dummy non-null leafId here, to ensure that sourceBox remains considered a leaf.\n    sourceBox.leafId(\"empty\");\n    sourceBox.leafContent(dom(\"div.layout_editor_empty_space\",\n      koDom.style(\"margin\", (rect.height * 0.02) + \"px\"),\n      koDom.style(\"min-height\", (rect.height * 0.96) + \"px\"),\n    ));\n  }\n\n  public onMouseUp() {\n    this.lastMouseEvent = null;\n  }\n\n  public onMouseMove(mouseEvent: JQMouseEvent) {\n    this.lastMouseEvent = mouseEvent;\n    this.dom.style.left = (mouseEvent.clientX - this.mouseOffsetX) + \"px\";\n    this.dom.style.top = (mouseEvent.clientY - this.mouseOffsetY) + \"px\";\n  }\n}\n\n// ----------------------------------------------------------------------\n\n/**\n * DropOverlay is a rectangular indicator that's displayed over a leaf box under the mouse\n * pointer, and shows regions of affinity towards one of the borders. It also computes which\n * region the user is targeting, and returns an affinity value.\n */\nclass DropOverlay extends Disposable {\n  public overlayElem: HTMLElement;\n  public overlayRect: DOMRect | null;\n  public hBorder: number | null;\n  public vBorder: number | null;\n  public create() {\n    this.overlayElem = this.autoDispose(dom(\"div.layout_editor_drop_overlay\"));\n    this.overlayRect = null;\n    this.hBorder = null;\n    this.vBorder = null;\n  }\n\n  /**\n   * Hides the overlay box by detaching it from the current element, if any.\n   */\n  public detach() {\n    if (this.overlayElem.parentNode) {\n      this.overlayElem.parentNode.removeChild(this.overlayElem);\n    }\n  }\n\n  /**\n   * Shows the overlay box over the given element.\n   */\n  public attach(targetElem: HTMLElement) {\n    const rect = this.overlayRect = targetElem.getBoundingClientRect();\n    /*\n    // If uncommented, this will show areas of affinity when hovering over a box. This is helpful in\n    // debugging, and may be helpful to users too, but makes the interface feel more cluttered.\n    if (this.overlayElem.parentNode !== targetElem) {\n      // This also automatically removes it from the old parent, if any.\n      targetElem.appendChild(this.overlayElem);\n    }\n    */\n    // Areas of affinity are essentially fat borders, proportional to width and height. In addition,\n    // to avoid overly disproportionate regions, we use twice the smaller dimension to limit the\n    // larger dimension.\n    this.hBorder = Math.floor(Math.min(rect.height, rect.width * 2) / 3);\n    this.vBorder = Math.floor(Math.min(rect.width, rect.height * 2) / 3);\n    const s = this.overlayElem.style;\n    s.borderTopWidth = s.borderBottomWidth = this.hBorder + \"px\";\n    s.borderLeftWidth = s.borderRightWidth = this.vBorder + \"px\";\n  }\n\n  /**\n   * If the mouse is over a region of affinity, returns the affinity as an 0-3 integer (see\n   * AFFINITY_NAMES above). Otherwise, returns -1.\n   */\n  public getAffinity(mouseEvent: JQMouseEvent) {\n    const rect = this.overlayRect!;\n    const x = mouseEvent.clientX - rect.left, y = mouseEvent.clientY - rect.top;\n    const top = getFrac(y, this.hBorder!), down = getFrac(rect.height - y, this.hBorder!);\n    const left = getFrac(x, this.vBorder!), right = getFrac(rect.width - x, this.vBorder!);\n    const minValue = Math.min(top, down, left, right);\n\n    return (minValue === Infinity ? -1 : [top, down, left, right].indexOf(minValue));\n  }\n}\n\n// ----------------------------------------------------------------------\n\n/**\n * DropTargeter displays a set of rectangles, each of which represents a particular allowed\n * insertion point for the element being dragged. It only shows the insertion points at the edge\n * of a particular layoutBox as indicated by DropOverlay.\n */\nclass DropTargeter extends Disposable {\n  public listenTo: BackboneEvents[\"listenTo\"];\n  public trigger: BackboneEvents[\"trigger\"];\n  public stopListening: BackboneEvents[\"stopListening\"];\n  public rootElem: HTMLElement;\n  public targetsDom: HTMLElement | null;\n  public currentBox: LayoutBox | null;\n  public currentAffinity: number | null;\n  public delayedInsertion: Delay;\n  public activeTarget: TargetPart | null;\n\n  public create(rootElem: HTMLElement) {\n    this.rootElem = rootElem;\n    this.targetsDom = null;\n    this.currentBox = null;\n    this.currentAffinity = null;\n    this.delayedInsertion = Delay.create();\n    this.activeTarget = null;\n    this.autoDisposeCallback(this.removeTargetHints);\n  }\n\n  public removeTargetHints() {\n    if (this.activeTarget?.box?.dom) {\n      this.activeTarget.box.dom.style.transition = \"\";\n      this.activeTarget.box.dom.style.padding = \"0\";\n    }\n    this.activeTarget = null;\n    this.delayedInsertion.cancel();\n    if (this.targetsDom) {\n      removeNode(this.targetsDom);\n      this.targetsDom = null;\n    }\n    this.currentBox = null;\n    this.currentAffinity = null;\n  }\n\n  public updateTargetHints(\n    layoutBox: LayoutBox | null,\n    affinity: number,\n    overlay: DropOverlay,\n    prevTargetBox?: LayoutBox,\n  ) {\n    // Nothing to update.\n    if (!layoutBox || (layoutBox === this.currentBox && affinity === this.currentAffinity)) {\n      return;\n    }\n    this.removeTargetHints();\n    if (affinity === -1) {\n      return;\n    }\n    this.currentBox = layoutBox;\n    this.currentAffinity = affinity;\n\n    const upDown = isAffinityUpDown(affinity);\n    const isAfter = isAffinityAfter(affinity);\n\n    const targetParts: TargetPart[] = [];\n    // Allow dragging a leaf into another leaf as a child, splitting the latter into two.\n    // But don't allow dragging a leaf box into itself, that makes no sense.\n    if (upDown === layoutBox.isVBox() && layoutBox !== prevTargetBox) {\n      targetParts.push({ box: layoutBox, isChild: true, isAfter: isAfter });\n    }\n    while (layoutBox) {\n      if (upDown === layoutBox.isHBox()) {\n        const children = layoutBox.childBoxes.peek();\n        // If one of two children is prevTargetBox, replace the last target hint since it\n        // will be redundant once prevTargetBox is removed.\n        if (children.length === 2 && prevTargetBox?.parentBox() === layoutBox) {\n          targetParts.splice(targetParts.length - 1, 1,\n            { box: layoutBox, isChild: false, isAfter: isAfter });\n        } else if (prevTargetBox !== layoutBox && prevTargetBox !== layoutBox.getSiblingBox(isAfter) &&\n          children.length !== 1) {\n          // If there is only one child (which may happen for the root box), the target hint\n          // is redundant.\n\n          targetParts.push({ box: layoutBox, isChild: false, isAfter: isAfter });\n        }\n        if (isAfter && !layoutBox.isLastChild()) { break; }\n        if (!isAfter && !layoutBox.isFirstChild()) { break; }\n      }\n      layoutBox = layoutBox.parentBox();\n    }\n    if (targetParts.length === 0) {\n      return;\n    }\n\n    // Render the hint parts.\n    if (!isAfter) {\n      targetParts.reverse();\n    }\n\n    // The same code works for both horizontal and vertical situation. For ease of thinking about\n    // it, we pretend below that we are dealing with an up-down situation (drop hints are horizontal\n    // wide boxes stacked vertically), and use properties that are named using the up-down\n    // situation, but whose values might reflect a left-right situation.\n    const pTop = upDown ? \"top\" : \"left\", pHeight = upDown ? \"height\" : \"width\",\n      pLeft = upDown ? \"left\" : \"top\", pWidth = upDown ? \"width\" : \"height\";\n    let totalHeight = upDown ? overlay.hBorder! : overlay.vBorder!;\n    const singleHeight = Math.floor(totalHeight / targetParts.length);\n\n    // Adjust to account for the rounding-down above.\n    totalHeight = singleHeight * targetParts.length;\n\n    const outerRect = this.rootElem.getBoundingClientRect();\n    const innerRect = this.currentBox.dom!.getBoundingClientRect();\n\n    const self = this;\n    this.targetsDom = dom(\"div.layout_editor_drop_targeter\",\n      koDom.style(pTop,\n        (innerRect[pTop] - outerRect[pTop] +\n          (isAfter ? innerRect[pHeight] - totalHeight : 0)) + \"px\",\n      ),\n      targetParts.map((part, index) => {\n        const rect = part.box.dom!.getBoundingClientRect();\n        return dom(\"div.layout_editor_drop_target\", (elem: HTMLDivElement) => {\n          elem.style[pHeight] = (singleHeight + 1) + \"px\"; // 1px of overlap for better looks\n          elem.style[pWidth] = rect[pWidth] + \"px\";\n          elem.style[pLeft] = (rect[pLeft] - outerRect[pLeft]) + \"px\";\n          elem.style[pTop] = (singleHeight * index) + \"px\";\n        },\n        dom.on(\"mouseenter\", function(this: HTMLElement) {\n          this.classList.add(\"layout_hover\");\n          self.activeTarget = part;\n          const padDir = upDown ? (isAfter ? \"Bottom\" : \"Top\") : (isAfter ? \"Right\" : \"Left\");\n          const padding = \"padding\" + padDir;\n          part.box.dom!.style.transition = \"padding .3s\";\n          part.box.dom!.style[padding as any] = \"20px\";\n        }),\n        dom.on(\"mouseleave\", function(this: HTMLElement) {\n          this.classList.remove(\"layout_hover\");\n          self.activeTarget = null;\n          part.box.dom!.style.padding = \"0\";\n        }),\n        dom.on(\"transitionend\", this.triggerInsertion.bind(this, part)),\n        );\n      }),\n    );\n    this.rootElem.appendChild(this.targetsDom!);\n  }\n\n  public triggerInsertion(part: TargetPart) {\n    this.removeTargetHints();\n    this.trigger(\"insertBox\", (box: LayoutBox) => {\n      if (part.isChild) {\n        part.box.addChild(box, part.isAfter);\n      } else {\n        part.box.addSibling(box, part.isAfter);\n      }\n    });\n  }\n\n  public accelerateInsertion() {\n    if (this.activeTarget) {\n      this.activeTarget.box.dom!.style.transition = \"\";\n      this.activeTarget.box.dom!.style.padding = \"0\";\n      this.triggerInsertion(this.activeTarget);\n    }\n  }\n}\n\nextend(DropTargeter.prototype, BackboneEvents);\n\n// ----------------------------------------------------------------------\n\n/**\n * When a LayoutEditor is created for a given Layout object, it makes it possible to drag\n * LayoutBoxes to change the layout.\n *\n * When a user drags a box, its content migrates temporarily to the Floater element, which moves\n * with the mouse cursor. As the user drags, the space for the element will open up here or there,\n * by adding an appropriate empty targetBox. DropOverlay and DropTargeter together decide the\n * insertion point for the drag operations.\n *\n * NOTES:\n *  There is some awkwardness in sizing: in a vertically laid out box, the last box takes up all\n *  available space, so moving it away does not show a transition (the box transitions to empty in\n *  theory, but it still takes all the same available space).\n */\nexport class LayoutEditor extends Disposable {\n  public layout: Layout;\n  public rootElem: HTMLElement;\n  public floater: Floater;\n  public dropOverlay: DropOverlay;\n  public dropTargeter: DropTargeter;\n  public measuringBox: HTMLElement;\n\n  public listenTo: BackboneEvents[\"listenTo\"];\n  public trigger: BackboneEvents[\"trigger\"];\n  public stopListening: BackboneEvents[\"stopListening\"];\n\n  public transitionPromise: Promise<void>;\n  public trashDelay: Delay;\n  public originalBox: LayoutBox | null;\n  public targetBox: LayoutBox | null;\n  public boundMouseDown: (ev: JQMouseEvent, el: HTMLElement) => void;\n  public boundMouseMove: (ev: JQMouseEvent, el: HTMLElement) => void;\n  public boundMouseUp: (ev: JQMouseEvent, el: HTMLElement) => void;\n  public initialMouseDown: boolean;\n  public lastTriggered: string;\n\n  public create(layout: Layout) {\n    this.layout = layout;\n    this.rootElem = layout.rootElem;\n\n    this.layout.buildLayout(this.layout.getLayoutSpec(), true);\n    this.floater = this.autoDispose(Floater.create(this.layout.fillWindow));\n    this.dropOverlay = this.autoDispose(DropOverlay.create());\n    this.dropTargeter = this.autoDispose(DropTargeter.create(this.rootElem));\n    this.listenTo(this.dropTargeter, \"insertBox\", this.onInsertBox);\n\n    // This is a place to put LayoutBoxes that should NOT be shown, but SHOULD be possible to\n    // measure. It's used when a new box is being moved into the editor.\n    this.measuringBox = this.autoDispose(dom(\"div.layout_editor_measuring_box\"));\n    this.rootElem.appendChild(this.measuringBox);\n\n    // For better experience, we prevent new repositions while a transition is active, and we\n    // require some work (leaving and re-entering affinity area) after a previous transition ends.\n    this.transitionPromise = Promise.resolve();\n    this.trashDelay = Delay.create();\n\n    // TODO: We don't use originalBox at the moment, but may want to, specifically to collapse it\n    // without removing, and restore if the user hits \"Escape\".\n    // This is the box the user clicked, to move its content elsewhere.\n    this.originalBox = null;\n\n    // The new box into which the content is to be inserted. During a move operation, it starts out\n    // with this.originalBox.\n    this.targetBox = null;\n\n    // Make all LayoutBoxes resizable. Update whenever the layout changes.\n    this.layout.forEachBox(this.makeResizable, this);\n    this.listenTo(this.layout, \"layoutChanged\", () => {\n      this.layout.forEachBox(this.makeResizable, this);\n    });\n\n    const self = this;\n    this.boundMouseDown = function(this: HTMLElement, ev: JQMouseEvent) {\n      return self.handleMouseDown(ev, this);\n    };\n    this.boundMouseMove = this.handleMouseMove.bind(this);\n    this.boundMouseUp = this.handleMouseUp.bind(this);\n    G.$(this.rootElem).on(\"mousedown\", \".layout_leaf\", this.boundMouseDown);\n\n    this.initialMouseDown = false;\n\n    this.lastTriggered = \"stop\";\n\n    this.autoDisposeCallback(() => {\n      G.$(G.window).off(\"mouseup\", this.boundMouseUp);\n      G.$(G.window).off(\"mousemove\", this.boundMouseMove);\n      G.$(this.rootElem).off(\"mousedown\", this.boundMouseDown);\n      if (!this.layout.isDisposed()) {\n        this.layout.buildLayout(this.layout.getLayoutSpec(), false);\n        this.layout.forEachBox(this.unmakeResizable, this);\n      }\n    });\n  }\n\n  public triggerUserEditStart() {\n    assert(this.lastTriggered === \"stop\", \"UserEditStart triggered twice in succession\");\n    this.lastTriggered = \"start\";\n    // This attribute allows browser tests to tell when an edit is in progress.\n    this.rootElem.setAttribute(\"data-useredit\", \"start\");\n    this.layout.trigger(\"layoutUserEditStart\");\n  }\n\n  public triggerUserEditStop() {\n    assert(this.lastTriggered === \"start\", \"UserEditStop triggered twice in succession\");\n    this.lastTriggered = \"stop\";\n    this.layout.trigger(\"layoutUserEditStop\");\n    // This attribute allows browser tests to tell when an edit is finished.\n    this.rootElem.setAttribute(\"data-useredit\", \"stop\");\n  }\n\n  public makeResizable(box: LayoutBox) {\n    // Do not add resizable if:\n    // Box already resizable, box is not vertically resizable, box is last in it`s group.\n    if (G.$(box.dom!).resizable(\"instance\") || (box.isHBox() && !this.layout.fillWindow) ||\n      box.isLastChild()) {\n      return;\n    }\n    const helperObj = new HelperBox({ box });\n    const isWidth = box.isVBox();\n    G.$(box.dom!).resizable({\n      handles: isWidth ? \"e\" : \"s\",\n      start: this.onResizeStart.bind(this, helperObj, isWidth),\n      resize: this.onResizeMove.bind(this, helperObj, isWidth),\n      stop: this.triggerUserEditStop.bind(this),\n    });\n  }\n\n  public unmakeResizable(box: LayoutBox) {\n    if (G.$(box.dom!).resizable(\"instance\")) {\n      // Resizable widget is set for this box.\n      G.$(box.dom!).resizable(\"destroy\");\n    }\n  }\n\n  public onResizeStart(helperObj: HelperBox, isWidth: boolean, event: JQMouseEvent, ui: JqueryUI) {\n    this.triggerUserEditStart();\n    const size = isWidth ? ui.originalSize.width : ui.originalSize.height;\n    helperObj.scalePerFlexUnit = size / (helperObj.box.flexSize() || 1);\n    const allSiblings = helperObj.box.parentBox()!.childBoxes.peek();\n    const index = allSiblings.indexOf(helperObj.box);\n    helperObj.nextSiblings = allSiblings.slice(index + 1);\n    helperObj.origNextSizes = helperObj.nextSiblings.map(function(b) { return b.flexSize(); });\n    helperObj.origSize = helperObj.box.flexSize();\n    helperObj.sumPrev = allSiblings.slice(0, index).reduce(adder, 0);\n    helperObj.sumAll = allSiblings.reduce(adder, 0);\n    helperObj.sumNext = helperObj.sumAll - helperObj.sumPrev;\n  }\n\n  public onResizeMove(helperObj: HelperBox, isWidth: boolean, event: JQMouseEvent, ui: JqueryUI) {\n    const sizePx = isWidth ? ui.size.width : ui.size.height;\n    let newSize = sizePx / helperObj.scalePerFlexUnit;\n\n    // We need some amount of snapping to make it easier to align boxes. The way we'll do it is to\n    // adjust flexSize of the box being resized and all following boxes so that boundaries end up at\n    // multiples of fullSize / NumSteps.\n    newSize = snap(newSize, helperObj.sumPrev, helperObj.sumAll);\n    const siblingsFactor = (helperObj.sumNext - newSize) / (helperObj.sumNext - helperObj.origSize);\n    let sumPrev = helperObj.sumPrev + newSize;\n    const newSizes: number[] = [];\n    helperObj.origNextSizes.forEach(function(size) {\n      const s = snap(size * siblingsFactor, sumPrev, helperObj.sumAll);\n      sumPrev += s;\n      newSizes.push(s);\n    });\n\n    if (newSize <= 0 || newSizes.some(size => size <= 0)) {\n      return; // This isn't an acceptable position.\n    }\n    if (newSize !== helperObj.box.flexSize.peek()) {\n      helperObj.box.flexSize(newSize);\n      helperObj.nextSiblings.forEach(function(b, i) {\n        b.flexSize(newSizes[i]);\n      });\n      this.layout.trigger(\"layoutResized\");\n    }\n  }\n\n  public handleMouseDown(event: JQMouseEvent, elem: HTMLElement) {\n    const target = event.target as HTMLElement;\n    if (event.button !== 0 || target?.classList.contains(\"ui-resizable-handle\")) {\n      return;\n    }\n    if (target?.classList.contains(\"layout_grabbable\")) {\n      this.initialMouseDown = true;\n      this.originalBox = utils.domData.get(elem, \"layoutBox\");\n      assert(this.originalBox, \"MouseDown on element without an associated layoutBox\");\n      G.$(G.window).on(\"mousemove\", this.boundMouseMove);\n      G.$(G.window).on(\"mouseup\", this.boundMouseUp);\n      return false;\n    }\n  }\n\n  // Exposed for tests\n  public dragInNewBox(event: JQMouseEvent, leafId: number) {\n    const box = this.layout.buildLayoutBox({ leaf: leafId });\n\n    // Place this box into a measuring div.\n    this.measuringBox.appendChild(box.getDom());\n\n    this.handleMouseDown(event, box.dom!);\n  }\n\n  public startDragBox(event: JQMouseEvent, box: LayoutBox) {\n    this.triggerUserEditStart();\n    this.targetBox = box;\n    this.floater.onInitialMouseMove(event, box);\n    this.trigger(\"dragStart\", this.originalBox);\n  }\n\n  public handleMouseUp(event: JQMouseEvent) {\n    G.$(G.window).off(\"mousemove\", this.boundMouseMove);\n    G.$(G.window).off(\"mouseup\", this.boundMouseUp);\n\n    if (this.initialMouseDown) {\n      this.initialMouseDown = false;\n      return;\n    }\n\n    // We stopped dragging, any listener can clean its modification\n    // to the floater element.\n    this.trigger(\"dragStop\");\n    this.targetBox!.takeLeafFrom(this.floater);\n    // We dropped back the box to its original position, now\n    // anyone can hijack the box.\n    this.trigger(\"dragDrop\", this.targetBox);\n\n    // Check if the box was hijacked by a drop target.\n    if (this.originalBox?.leafId() !== \"empty\") {\n      if (this.dropTargeter.activeTarget) {\n        this.dropTargeter.accelerateInsertion();\n      } else {\n        resizeLayoutBox(this.targetBox!, \"reset\");\n      }\n    }\n\n    this.dropTargeter.removeTargetHints();\n    this.dropOverlay.detach();\n    this.trigger(\"dragEnd\");\n    // Cleanup for any state.\n    void this.transitionPromise.finally(() => {\n      this.floater.onMouseUp();\n      resizeLayoutBox(this.targetBox!, \"reset\");\n      this.targetBox = this.originalBox = null;\n      emptyNode(this.measuringBox);\n      this.triggerUserEditStop();\n    });\n  }\n\n  public getBoxFromElement(elem: HTMLElement) {\n    const box = this.layout.getContainingBox(elem);\n    if (box && !box.isDomDetached()) {\n      return box;\n    }\n    return null;\n  }\n\n  public getBox(leafId: number) {\n    return this.layout.getLeafBox(leafId);\n  }\n\n  public removeContainingBox(box: LayoutBox) {\n    if (box && !box.isDomDetached()) {\n      this.triggerUserEditStart();\n      this.targetBox = box;\n      this.doRemoveBox(box);\n      this.triggerUserEditStop();\n    }\n  }\n\n  public doRemoveBox(box: ContentBox) {\n    const rect = box.dom!.getBoundingClientRect();\n    box.leafId(\"empty\");\n    box.leafContent(dom(\"div.layout_editor_empty_space\",\n      koDom.style(\"min-height\", rect.height + \"px\"),\n    ));\n    this.onInsertBox(noop).catch(noop);\n  }\n\n  public handleMouseMove(event: JQMouseEvent) {\n    // Make sure the grabbed box still exists\n    if (!this.originalBox || this.originalBox?.isDisposed()) {\n      return;\n    }\n\n    if (this.initialMouseDown) {\n      this.initialMouseDown = false;\n      this.startDragBox(event, this.originalBox);\n    }\n    this.floater.onMouseMove(event);\n\n    this.trigger(\"dragMove\", event, this.originalBox);\n\n    if (this.transitionPromise.isPending()) {\n      // Don't attempt to do any repositioning while another reposition is happening.\n      return;\n    }\n\n    // Handle dragging to trash.\n    if (dom.findAncestor(event.target, null, \".layout_trash\")) {\n      const isTrashed = this.targetBox?.isDomDetached();\n      if (!this.trashDelay.isPending() && !isTrashed) {\n        // To \"trash\" a box, we call onInsertBox with noop for the inserter function. The new box\n        // will still be created, just not attached to anything.\n        this.trashDelay.schedule(100, this.onInsertBox, this, noop);\n      }\n      return;\n    }\n    this.trashDelay.cancel();\n    this.updateTargets(event);\n  }\n\n  public updateTargets(event: JQMouseEvent) {\n    if (this.transitionPromise.isPending()) {\n      // Don't attempt to do any repositioning while another reposition is happening.\n      return;\n    }\n    // See if we are over a layout_leaf, and that the leaf is in the same layout as the dragged\n    // element. If so, we are dealing with repositioning.\n    const elem = dom.findAncestor(event.target, this.rootElem, \".\" + this.layout.leafId);\n    if (elem) {\n      const hoverBox = utils.domData.get(elem, \"layoutBox\");\n      this.dropOverlay.attach(elem);\n      const affinity = this.dropOverlay.getAffinity(event);\n      this.dropTargeter.updateTargetHints(hoverBox, affinity, this.dropOverlay, this.targetBox!);\n    } else if (!dom.findAncestor(event.target, this.rootElem, \".layout_editor_drop_target\")) {\n      this.dropTargeter.removeTargetHints();\n    }\n  }\n\n  public async onInsertBox(inserterFunc: (box: LayoutBox) => void) {\n    // Create a new LayoutBox, and insert it using inserterFunc.\n    // Shrink prevTargetBox to 0. Create a new target box, initially shrunk, and grow it.\n    const prevTargetBox = this.targetBox!;\n\n    this.targetBox = LayoutBox.create(this.layout);\n    this.targetBox.takeLeafFrom(prevTargetBox);\n    this.targetBox.flexSize(prevTargetBox.flexSize());\n\n    // Sizing boxes vertically requires extra care that the sum of values doesn't change.\n    this.targetBox.getDom(); // Make sure its dom is created.\n\n    // console.log(\"onInsertBox %s -> %s\", prevTargetBox, this.targetBox);\n    let transitionPromiseResolve!: () => void;\n    this.transitionPromise = new Promise(function(resolve, reject) {\n      transitionPromiseResolve = resolve;\n    });\n\n    inserterFunc(this.targetBox);\n\n    const prevRect = prevTargetBox.dom!.getBoundingClientRect();\n\n    // Set previous box size to 0 for accurate measurement of new target box\n    const prevFlexGrow = prevTargetBox.dom!.style.flexGrow;\n    prevTargetBox.dom!.style.flexGrow = \"0\";\n\n    const targetRect = this.targetBox.dom!.getBoundingClientRect();\n\n    prevTargetBox.dom!.style.flexGrow = prevFlexGrow;\n\n    await Promise.all([\n      resizeLayoutBoxSmoothly(prevTargetBox, prevRect, \"collapse\"),\n      resizeLayoutBoxSmoothly(this.targetBox, \"collapse\", targetRect),\n    ]);\n    prevTargetBox.dispose();\n    if (this.targetBox) {\n      resizeLayoutBox(this.targetBox, \"reset\");\n      this.dropOverlay.attach(this.targetBox.dom!);\n    }\n    transitionPromiseResolve();\n    this.layout.trigger(\"layoutResized\");\n  }\n}\n\nextend(LayoutEditor.prototype, BackboneEvents);\n\n// ----------------------------------------------------------------------\n\n/**\n * When the user hovers near the edge of a box, we call the direction the \"affinity\", and it\n * indicates where an insertion is to happen. Affinities are represented by numbers 0 - 3. The\n * functions below distinguish top-down vs left-right, and top/left vs down/right.\n */\n// const AFFINITY_NAMES = { 0: 'TOP', 1: 'DOWN', 2: 'LEFT', 3: 'RIGHT' };\nfunction isAffinityUpDown(affinity: number): boolean {\n  return (affinity >> 1) === 0;\n}\n\nfunction isAffinityAfter(affinity: number): boolean {\n  return (affinity & 1) === 1;\n}\n\nfunction getFrac(distance: number, max: number): number {\n  return distance < max ? distance / max : Infinity;\n}\n\n// We'll snap to 1/NumSteps of total size. The choice of 60 allows many evenly-sized layouts.\nconst NumSteps = 60;\n\nfunction round(value: number, multipleOf: number) {\n  return Math.round(value / multipleOf) * multipleOf;\n}\n\nfunction snap(flexSize: number, sumPrev: number, sumAll: number) {\n  const endEdge = round(sumPrev + flexSize, sumAll / NumSteps);\n  return Math.min(endEdge, sumAll) - sumPrev;\n}\n\n/**\n * Resizes the given LayoutBox to transition it when it's supposed to expand or collapse. It only\n * affects the height for HBoxes, and only the width for VBoxes. For rows, we use an explicit\n * height. For columns we rely on 'flex-grow' property.\n *    A rectangle object: set the relevant style according to the values there.\n *    'reset': unset the relevant style, to revert to the values associated with CSS classes.\n *    'collapse': collapse to empty size.\n *    'current': set and explicit value for the relevant style, which is needed for transitions.\n */\nfunction resizeLayoutBox(layoutBox: LayoutBox, sizeRect: string | DOMRect) {\n  const reset = (sizeRect === \"reset\");\n  const collapse = (sizeRect === \"collapse\");\n  if (sizeRect === \"current\") {\n    sizeRect = layoutBox.dom!.getBoundingClientRect();\n  }\n  if (layoutBox.isHBox()) {\n    layoutBox.dom!.style.height = (reset ? \"\" : (collapse ? \"0px\" : (sizeRect as DOMRect).height + \"px\"));\n  } else {\n    layoutBox.dom!.style.width = (reset ? \"\" : (collapse ? \"0px\" : (sizeRect as DOMRect).width + \"px\"));\n  }\n  layoutBox.dom!.style.opacity = collapse ? \"0.0\" : \"1.0\";\n}\n\nfunction rectDesc(rect: string | DOMRect) {\n  return (typeof rect === \"string\") ? rect :\n    Math.floor(rect.width) + \"x\" + Math.floor(rect.height);\n}\n\n/**\n * Resizes the given LayoutBox smoothly from starting to ending position, where startRect and\n * endRect are one of the values documented in 'resizeLayoutBox'.\n */\nfunction resizeLayoutBoxSmoothly(layoutBox: LayoutBox, startRect: string | DOMRect, endRect: string | DOMRect) {\n  if (layoutBox.isDomDetached()) {\n    return Promise.resolve();\n  }\n  const prevFlexGrow = layoutBox.dom!.style.flexGrow;\n  layoutBox.dom!.style.flexGrow = \"0\";\n  resizeLayoutBox(layoutBox, startRect);\n\n  // Force the layout engine to compute the current state of the layoutBox.dom element before\n  // applying the transition. This follows the recommendation here, and seems to work:\n  // https://timtaubert.de/blog/2012/09/css-transitions-for-dynamically-created-dom-elements/\n  pick(G.window.getComputedStyle(layoutBox.dom!), \"height\", \"width\");\n\n  // Start the transition.\n  layoutBox.dom!.classList.add(\"layout_editor_resize_transition\");\n  return new Promise(function(resolve, reject) {\n    dom.once(layoutBox.dom, \"transitionend\", function() { resolve(); });\n    resizeLayoutBox(layoutBox, endRect);\n  })\n    .timeout(600)    // Transitions are only 400ms long, so complain if nothing happened for longer.\n    .catch(Promise.TimeoutError, function() {\n      console.error(\"LayoutEditor.resizeLayoutBoxSmoothly %s %s->%s: transition didn't run\",\n        layoutBox, rectDesc(startRect), rectDesc(endRect));\n    // We keep going. It should look like something's wrong and jumpy, but it should still be\n    // usable and not cause errors elsewhere.\n    })\n    .finally(function() {\n      if (!layoutBox.dom) {\n        return;\n      }\n      layoutBox.dom.classList.remove(\"layout_editor_resize_transition\");\n      layoutBox.dom.style.flexGrow = prevFlexGrow;\n    });\n}\n\nfunction adder(sum: number, box: LayoutBox) {\n  return sum + box.flexSize.peek();\n}\n"
  },
  {
    "path": "app/client/components/LayoutTray.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport { buildCollapsedSectionDom, buildViewSectionDom } from \"app/client/components/buildViewSectionDom\";\nimport * as commands from \"app/client/components/commands\";\nimport { ContentBox } from \"app/client/components/Layout\";\nimport { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { Signal } from \"app/client/lib/Signal\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { TransitionWatcher } from \"app/client/ui/transitions\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { isNonNullish } from \"app/common/gutil\";\n\nimport { Computed, Disposable, dom, IDisposable, IDisposableOwner,\n  makeTestId, obsArray, Observable, styled } from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\n\nimport type { ViewLayout } from \"app/client/components/ViewLayout\";\n\nconst testId = makeTestId(\"test-layoutTray-\");\n\nconst G = getBrowserGlobals(\"document\", \"window\", \"$\");\n\ntype JQMouseEvent = JQuery.MouseEventBase | MouseEvent;\n\n/**\n * Adds a tray for minimizing and restoring sections. It is built as a plugin for the ViewLayout component.\n */\nexport class LayoutTray extends DisposableWithEvents {\n  // We and LayoutEditor will emit this event with the box that is being dragged. When the\n  // drag is over there will be another event with null.\n  public drag = Signal.create<Dropped | null>(this, null);\n  // Event for dropping, contains a dropped element.\n  public drop = Signal.create<Dropped | null>(this, null);\n  // Monitor if the cursor is over the our tray.\n  public hovering = Signal.create(this, false);\n  // If the drag is active and the mouse is over the tray make a signal..\n  public over = Signal.compute(this, on => Boolean(on(this.drag) && on(this.hovering)));\n  // Mouse events during dragging (without a state).\n  public dragging = Signal.create<MouseEvent | null>(this, null);\n  // Create a layout to actually render the collapsed sections.\n  public layout = CollapsedLayout.create(this, this);\n  // Whether we are active (have a dotted border, that indicates we are ready to receive a drop)\n  public active = Signal.create(this, false);\n\n  private _rootElement: HTMLElement;\n\n  constructor(public viewLayout: ViewLayout) {\n    super();\n    // Create a proxy for the LayoutEditor. It will mimic the same interface as CollapsedLeaf.\n    const externalLeaf = ExternalLeaf.create(this, this);\n\n    // Build layout using saved settings.\n    this.layout.buildLayout(this.viewLayout.viewModel.collapsedSections.peek());\n\n    this._registerCommands();\n\n    // Override the drop event, to detect if we are dropped on the tray, and no one else\n    // gets the value.\n    this.drop.before((value, emit) => {\n      // Emit the value, if someone else will handle it, he should grab the state from it.\n      emit(value);\n      // See if the state is still there.\n      if (value && this.drop.state.get()) {\n        // No one took it, so we should handle it if we are over the tray.\n        if (this.over.state.get()) {\n          const leafId = value.leafId();\n          // Add it as a last element.\n          this.layout.addBox(leafId);\n          // Ask it to remove itself from the target.\n          value.removeFromLayout();\n        }\n      }\n      // Clear the state, any other listener will get null.\n      this.drop.state.set(null);\n    });\n\n    // Now wire up active state.\n\n    // When a drag is started, get the top point of the tray, over which we will activate.\n    let topPoint = 48; // By default it is 48 pixels.\n    this.autoDispose(externalLeaf.drag.listen((d) => {\n      if (!d) { return; }\n      topPoint = (this._rootElement.parentElement?.getBoundingClientRect().top ?? 61) - 13;\n    }));\n\n    // First we can be activated when a drag has started and we have some boxes.\n    this.drag.map(drag => drag && this.layout.count.get() > 0)\n      .flag() // Map to a boolean, and emit only when the value changes.\n      .filter(Boolean) // Only emit when it is set to true\n      .pipe(this.active);\n\n    // Second, we can be activated when the drag has started by the main layout, and we don't have any boxes yet, but\n    // mouse pointer is relatively high on the screen.\n    Signal.compute(this, (on) => {\n      const drag = on(externalLeaf.drag);\n      if (!drag) { return false; }\n      const mouseEvent = on(externalLeaf.dragMove);\n      const over = mouseEvent && mouseEvent.clientY < topPoint;\n      return !!over;\n    }).flag().filter(Boolean).pipe(this.active);\n\n    // If a drag has ended, we should deactivate.\n    this.drag.flag().filter(d => !d).pipe(this.active);\n  }\n\n  public replaceLayout() {\n    const savedSections = this.viewLayout.viewModel.collapsedSections.peek();\n    this.viewLayout.viewModel.activeCollapsedSections(savedSections);\n    const boxes = this.layout.buildLayout(savedSections);\n    return {\n      dispose() {\n        boxes.forEach(box => box.dispose());\n        boxes.length = 0;\n      },\n    };\n  }\n\n  /**\n   * Builds a popup for a maximized section.\n   */\n  public buildPopup(owner: IDisposableOwner, selected: Observable<number | null>, close: () => void) {\n    const section = Observable.create<number | null>(owner, null);\n    owner.autoDispose(selected.addListener((cur, prev) => {\n      if (prev) {\n        this.layout.getBox(prev)?.attach();\n      }\n      if (cur) {\n        this.layout.getBox(cur)?.detach();\n      }\n      section.set(cur);\n    }));\n    return dom.domComputed(section, (id) => {\n      if (!id) { return null; }\n      return dom.update(\n        buildViewSectionDom({\n          gristDoc: this.viewLayout.gristDoc,\n          sectionRowId: id,\n          draggable: false,\n          focusable: false,\n        }),\n      );\n    });\n  }\n\n  public buildDom() {\n    return this._rootElement = cssCollapsedTray(\n      testId(\"editor\"),\n      // When drag is active we should show a dotted border around the tray.\n      cssCollapsedTray.cls(\"-is-active\", this.active.state),\n      // If element is over the tray, we should indicate that we are ready by changing a color.\n      cssCollapsedTray.cls(\"-is-target\", this.over.state),\n      // Synchronize the hovering state with the event.\n      syncHover(this.hovering),\n      // Create a drop zone (below actual sections)\n      dom.create(CollapsedDropZone, this),\n      // Build the layout.\n      this.layout.buildDom(),\n      // But show only if there are any sections in the tray (even if those are empty or drop target sections)\n      // or we can accept a drop.\n      dom.show(use => use(this.layout.count) > 0 || use(this.active.state)),\n    );\n  }\n\n  public buildContentDom(id: string | number) {\n    return buildCollapsedSectionDom({\n      gristDoc: this.viewLayout.gristDoc,\n      sectionRowId: id,\n    });\n  }\n\n  private _registerCommands() {\n    const viewLayout = this.viewLayout;\n    // Add custom commands for options in the menu.\n    const commandGroup = {\n      // Collapse visible section.\n      collapseSection: () => {\n        const leafId = viewLayout.viewModel.activeSectionId();\n        if (!leafId) { return; }\n\n        // Find the box for this section in the layout.\n        const box = viewLayout.layoutEditor.getBox(leafId);\n        if (!box) { return; }\n\n        // Change the active section now. This is important as this will destroy the view before we\n        // remove the box from the dom. Charts are very sensitive for this.\n        viewLayout.viewModel.activeSectionId(\n          // We can't collapse last section, so the main layout will always have at least one section.\n          viewLayout.layoutEditor.layout.getAllLeafIds().find(x => x !== leafId),\n        );\n\n        // Add the box to our collapsed editor (it will transfer the viewInstance).\n        this.layout.addBox(leafId);\n\n        // Remove it from the main layout.\n        box.dispose();\n\n        // And ask the viewLayout to save the specs.\n        viewLayout.saveLayoutSpec().catch(reportError);\n      },\n      restoreSection: () => {\n        // Get the section that is collapsed and clicked (we are setting this value).\n        const leafId = viewLayout.viewModel.activeCollapsedSectionId();\n        if (!leafId) { return; }\n        viewLayout.viewModel.activeCollapsedSectionId(0);\n        viewLayout.viewModel.activeCollapsedSections(\n          viewLayout.viewModel.activeCollapsedSections.peek().filter(x => x !== leafId),\n        );\n        viewLayout.viewModel.activeSectionId(leafId);\n        viewLayout.saveLayoutSpec().catch(reportError);\n      },\n      // Delete collapsed section.\n      deleteCollapsedSection: async () => {\n        // This section is still in the view (but not in the layout). So we can just remove it.\n        const leafId = viewLayout.viewModel.activeCollapsedSectionId();\n        if (!leafId) { return; }\n\n        viewLayout.docModel.docData.bundleActions(\"removing section\", async () => {\n          if (!await this.viewLayout.removeViewSection(leafId)) {\n            return;\n          }\n          // We need to manually update the layout. Main layout editor doesn't care about missing sections.\n          // but we can't afford that. Without removing it, user can add another section that will be collapsed\n          // from the start, as the id will be the same as the one we just removed.\n          const currentSpec = viewLayout.viewModel.layoutSpecObj();\n          const validSections = new Set(viewLayout.viewModel.viewSections.peek().peek().map(vs => vs.id.peek()));\n          validSections.delete(leafId);\n          currentSpec.collapsed = currentSpec.collapsed\n            ?.filter(x => typeof x.leaf === \"number\" && validSections.has(x.leaf));\n          await viewLayout.saveLayoutSpec(currentSpec);\n        }).catch(reportError);\n      },\n    };\n    this.autoDispose(commands.createGroup(commandGroup, this, true));\n  }\n}\n\n/**\n * Main component that detects where the section should be dropped.\n */\nclass CollapsedDropZone extends Disposable {\n  private _rootElement: HTMLElement;\n  // Some operations will be blocked when we are waiting for an animation to finish.\n  private _animation = Observable.create(this, 0);\n  private _lastTarget: TargetLeaf | undefined;\n  private _lastIndex = -1;\n\n  constructor(protected model: LayoutTray) {\n    super();\n    // When the drag has started or has finished we will add an empty leaf that can accept\n    // dragged section. Event is fire only once, and it will be fired with a null when the draggable\n    // has finished.\n    let pushedLeaf: EmptyLeaf | undefined;\n    const layout = model.layout;\n\n    this.autoDispose(model.active.distinct().listen((ok) => {\n      if (ok) {\n        pushedLeaf = EmptyLeaf.create(null, this.model);\n        layout.addBox(pushedLeaf);\n      } else if (pushedLeaf) {\n        layout.destroy(pushedLeaf);\n      }\n    }));\n  }\n\n  public buildDom() {\n    const obsRects = Observable.create(this, [] as (VRect | null)[]);\n    return (this._rootElement = cssVirtualZone(\n      // We are only rendered when mouse is over the tray and it has some dragged leaf with it.\n      dom.maybeOwned(this.model.over.state, (owner) => {\n        // Get the bounding rect of the rootElement, virtual rects are relative, so we will be\n        // adjusting coordinates.\n        const root = this._rootElement.getBoundingClientRect();\n        // We store rects in an observable, that might be used to visualize the zones.\n        // Create the mouseMove listener.\n        const listener = async (e: MouseEvent) => {\n          if (owner.isDisposed() || this._isAnimating()) {\n            return;\n          }\n          // If there are some previous rects (from previous calculation), test if we are still in one of them.\n          if (this._lastTarget) {\n            const stillThere = obsRects.get()[this._lastIndex]?.contains(e);\n            if (stillThere) {\n              return;\n            }\n          }\n          // Calculate the virtual zones.\n          obsRects.set(this._calculate(root));\n          // Find the one under the mouse.\n          const underMouse = obsRects.get().findIndex(x => x?.contains(e));\n          // If it is still the same, do nothing.\n          if (underMouse === this._lastIndex) { return; }\n          // If we found something, insert a drop target.\n          if (underMouse !== -1) {\n            this._insertDropTarget(underMouse)\n              .catch(err => console.error(`Failed to insert zone:`, err)); // This should not happen.\n            return;\n          }\n          // We haven't found anything, remove the last drop target.\n          this._removeDropZone().catch(err => console.error(`Failed to remove zone:`, err));// This should not happen.\n        };\n        G.window.addEventListener(\"mousemove\", listener);\n        // When mouse leaves, we need to remove the last drop target.\n        owner.onDispose(() => {\n          this._removeDropZone().catch(err => console.error(`Failed to remove zone:`, err));// This should not happen.\n        });\n        owner.onDispose(() => G.window.removeEventListener(\"mousemove\", listener));\n        // For debugging, we can show the virtual zones.\n        const show = false;\n        return !show ? null : dom.domComputed(\n          obsRects,\n          rects => rects.filter(isNonNullish).map((rect: VRect) => cssVirtualPart(\n            { style: `left: ${rect.left}px; width: ${rect.width}px; top: ${rect.top}px; height: ${rect.height}px;` },\n          )));\n      }),\n    ));\n  }\n\n  private _start() {\n    this._animation.set(this._animation.get() + 1);\n  }\n\n  private _stop() {\n    this._animation.set(this._animation.get() - 1);\n  }\n\n  private _isAnimating() {\n    return this._animation.get() > 0;\n  }\n\n  private _calculate(parentRect: DOMRect) {\n    const boxes = this.model.layout.all();\n    const rects: (VRect | null)[] = [];\n    // Boxes can be wrapped, we will detect the line offset.\n    let lineOffset = 12;\n    // We will always have at least one box, so we can use it to get the height.\n    const height = boxes[0]?.rootElement.getBoundingClientRect().height;\n    for (let i = 0; i < boxes.length; i++) {\n      const box = boxes[i];\n      const prev = boxes[i - 1];\n      const next = boxes[i + 1];\n\n      // First handle edge cases (don't add targets for first elements in next lines), it will mess up the wrapping.\n      if (prev && prev?.rootElement.offsetTop !== box.rootElement.offsetTop) {\n        rects.push(null);\n        continue;\n      }\n\n      // Now handle normal cases.\n      const root = box.rootElement;\n      lineOffset = root.offsetTop;\n\n      if (i === 0 && box instanceof CollapsedLeaf) {\n        // For the first one, we have very little rectangle, from the left + 50px past the left border.\n        const left = 0;\n        const right = root.offsetLeft + 50;\n        rects.push(new VRect(parentRect, { left, top: lineOffset, right, height }));\n      } else if (box instanceof CollapsedLeaf && i === boxes.length - 1) {\n        // Last one is very similar, little rectangle on the left part.\n        const left = root.offsetLeft + root.offsetWidth - 30;\n        const right = root.offsetLeft + root.offsetWidth + 30;\n        rects.push(new VRect(parentRect, { left, top: lineOffset, right, height }));\n      } else if (box instanceof CollapsedLeaf && prev instanceof CollapsedLeaf) {\n        // In between, we have a rectangle from the left border to the right border.\n        const leftRoot = prev.rootElement;\n        const rightRoot = root;\n        const left = leftRoot.offsetLeft + leftRoot.offsetWidth - 30;\n        const right = rightRoot.offsetLeft + 30;\n        rects.push(new VRect(parentRect, { left, top: lineOffset, right, height }));\n      } else if (next && box instanceof TargetLeaf && i === 0) {\n        // If this is a first box and it is a target, the first rectangle will be much larger, it should cover\n        // the TargetLeaf width.\n        const left = 0;\n        const right = next.rootElement.offsetLeft;\n        rects.push(new VRect(parentRect, { left, top: lineOffset, right, height }));\n      } else if (box instanceof TargetLeaf && prev instanceof CollapsedLeaf && next instanceof CollapsedLeaf) {\n        // If this box is target between two collapsed boxes, we will have a rectangle from the prev to next\n        // covering the whole target leaf.\n        const left = prev.rootElement.offsetLeft + prev.rootElement.offsetWidth - 30;\n        const right = next.rootElement.offsetLeft + 30;\n        rects.push(new VRect(parentRect, { left, top: lineOffset, right, height }));\n      }\n    }\n    return rects;\n  }\n\n  private async _insertDropTarget(index: number) {\n    this._start();\n    try {\n      await this._lastTarget?.remove();\n      this._lastTarget = TargetLeaf.create(null, this.model);\n      await this._lastTarget.insert(index);\n      this._lastIndex = index;\n    } finally {\n      this._stop();\n    }\n  }\n\n  private async _removeDropZone() {\n    if (!this._lastTarget) { return; }\n    this._start();\n    try {\n      await this._lastTarget?.remove();\n      this._lastTarget = undefined;\n      this._lastIndex = -1;\n    } finally {\n      this._stop();\n    }\n  }\n}\n\n/**\n * UI component that renders and owns all the collapsed leaves.\n */\nclass CollapsedLayout extends Disposable {\n  public rootElement: HTMLElement;\n  /**\n   * Leaves owner. Adding or removing leaves will not dispose them automatically, as they are released and\n   * return to the caller. Only those leaves that were not removed will be disposed with the layout.\n   */\n  public holder = ArrayHolder.create(this);\n  /**\n   * Number of leaves in the layout.\n   */\n  public count: Computed<number>;\n\n  private _boxes = this.autoDispose(obsArray<Leaf>());\n\n  constructor(protected model: LayoutTray) {\n    super();\n\n    // Whenever we add or remove box, update the model. This is used to test if the section is collapsed or not.\n    this._boxes.addListener(l => model.viewLayout.viewModel.activeCollapsedSections(this.leafIds()));\n\n    this.count = Computed.create(this, use => use(this._boxes).length);\n  }\n\n  public all() {\n    return this._boxes.get();\n  }\n\n  public buildLayout(leafs: number[]) {\n    if (isEqual(leafs, this._boxes.get().map(box => box.id.get()))) { return []; }\n    const removed = this._boxes.splice(0, this._boxes.get().length,\n      ...leafs.map(id => CollapsedLeaf.create(this.holder, this.model, id)));\n    removed.forEach(box => this.holder.release(box));\n    return removed;\n  }\n\n  public addBox(id: number | Leaf, index?: number) {\n    index ??= -1;\n    const box = typeof id === \"number\" ? CollapsedLeaf.create(this.holder, this.model, id) : id;\n    if (typeof id !== \"number\") {\n      this.holder.autoDispose(box);\n    }\n    return this.insert(index, box);\n  }\n\n  public indexOf(box: Leaf) {\n    return this._boxes.get().indexOf(box);\n  }\n\n  public insert(index: number, leaf: Leaf) {\n    this.holder.autoDispose(leaf);\n    if (index < 0) {\n      this._boxes.push(leaf);\n    } else {\n      this._boxes.splice(index, 0, leaf);\n    }\n    return leaf;\n  }\n\n  /**\n   * Removes the leaf from the list but doesn't dispose it.\n   */\n  public remove(leaf: Leaf) {\n    const index = this._boxes.get().indexOf(leaf);\n    if (index >= 0) {\n      const removed = this._boxes.splice(index, 1)[0];\n      if (removed) {\n        this.holder.release(removed);\n      }\n      return removed || null;\n    }\n    return null;\n  }\n\n  /**\n   * Removes and dispose the leaf from the list.\n   */\n  public destroy(leaf: Leaf) {\n    this.remove(leaf)?.dispose();\n  }\n\n  public leafIds() {\n    return this._boxes.get().map(l => l.id.get()).filter(x => x && typeof x === \"number\");\n  }\n\n  public getBox(leaf: number): CollapsedLeaf | undefined {\n    return this._boxes.get().find(l => l.id.get() === leaf) as CollapsedLeaf | undefined;\n  }\n\n  public buildDom() {\n    return (this.rootElement = cssLayout(\n      testId(\"layout\"),\n      useDragging(),\n      dom.hide(use => use(this._boxes).length === 0),\n      dom.forEach(this._boxes, line => line.buildDom()),\n    ));\n  }\n}\n\ninterface Draggable {\n  dragStart?: (ev: DragEvent, floater: MiniFloater) => Draggable | null;\n  dragEnd?: (ev: DragEvent, floater: MiniFloater) => void;\n  drag?: (ev: DragEvent, floater: MiniFloater) => void;\n  drop?: (ev: DragEvent, floater: MiniFloater) => void;\n}\n\ninterface Dropped {\n  removeFromLayout(): void;\n  leafId(): number;\n}\n\n/**\n * Base class for all the leaves in the layout tray.\n */\nabstract class Leaf extends Disposable {\n  public id = Observable.create(this, 0);\n  public rootElement: HTMLElement;\n  public buildDom(): HTMLElement | null {\n    return null;\n  }\n}\n\n/**\n * Empty leaf that is used to represent the empty space in the collapsed layout. Can be used to drop boxes.\n */\nclass EmptyLeaf extends Leaf {\n  public name = Observable.create(this, \"empty\");\n\n  // If we are hovering over the empty leaf.\n  private _onHover = Signal.create(this, false);\n\n  constructor(protected model: LayoutTray) {\n    super();\n    this.monitorDrop();\n  }\n\n  public monitorDrop() {\n    this.autoDispose(\n      this.model.drop.listen((box) => {\n        // If some box was dropped, and the cursor is over this leaf, we will add the box to the layout.\n        if (!box || !this._onHover.state.get()) {\n          return;\n        }\n        this.model.drop.state.set(null);\n        // Replace the empty leaf with the dropped box.\n        const myIndex = this.model.layout.indexOf(this);\n        const leafId = box.leafId();\n        this.model.layout.addBox(leafId, myIndex);\n        box.removeFromLayout();\n      }),\n    );\n  }\n\n  public buildDom() {\n    return (this.rootElement = cssEmptyBox(\n      cssEmptyBox.cls(\"-can-accept\", this._onHover.state),\n      syncHover(this._onHover),\n      testId(\"empty-box\"),\n    ));\n  }\n}\n\n/**\n * This is an empty leaf that supports animation when added to the list.\n */\nclass TargetLeaf extends EmptyLeaf {\n  public buildDom() {\n    this.name.set(\"target\");\n    const element = super.buildDom();\n    dom.update(element,\n      testId(\"target-box\"),\n      dom.cls(cssProbe.className),\n      { style: \"width: 2px;\" },\n    );\n    return element;\n  }\n\n  public insert(index: number) {\n    // First insert the drop target leaf.\n    this.model.layout.insert(index, this);\n    // Force the reflow, so that we can start the animation.\n    this.rootElement.getBoundingClientRect();\n    // Start and wait for the animation to finish.\n    return new Promise((resolve) => {\n      const watcher = new TransitionWatcher(this.rootElement);\n      watcher.onDispose(() => {\n        resolve(undefined);\n      });\n      this.rootElement.style.width = \"\";\n    });\n  }\n\n  public remove() {\n    return new Promise((resolve) => {\n      const watcher = new TransitionWatcher(this.rootElement);\n      watcher.onDispose(() => {\n        this.model.layout.destroy(this);\n        resolve(undefined);\n      });\n      this.rootElement.style.width = \"0px\";\n    });\n  }\n}\n\n/**\n * This is the collapsed widget that is shown in the collapsed layout. It can be dragged and dropped.\n */\nclass CollapsedLeaf extends Leaf implements Draggable, Dropped {\n  // The content of the leaf that is rendered. Stored in an observable so that we can update it when the\n  // content changes or put it in the floater.\n  private _content: Observable<HTMLElement | null> = Observable.create(this, null);\n\n  // Computed to get the view instance from the viewSection.\n  private _viewInstance: Computed<BaseView | null>;\n\n  // An observable for the dom that holds the viewInstance and displays it in a hidden element.\n  // This is owned by this leaf and is disposed separately from the dom that is returned by buildDom. Like a\n  // singleton, this element will be moved from one \"instance\" (a result of buildDom) to another.\n  // When a leaf is removed from the dom (e.g. when we remove the collapsed section or move it to the main area)\n  // the dom of this element is disposed, but the hidden element stays with this instance and can be disposed\n  // later on, giving anyone a chance to grab the viewInstance and display it somewhere else.\n  private _hiddenViewInstance: Observable<HTMLElement | null> = Observable.create(this, null);\n\n  // Helper to keeping track of the index of the leaf in the layout.\n  private _indexWhenDragged = 0;\n\n  // A helper variable that indicates that this section is in a popup, and we should\n  // make any attempt to grab it and attach to our dom. Note: this is not a computed variable.\n  private _detached = false;\n\n  constructor(protected model: LayoutTray, id: number) {\n    super();\n    this.id.set(id);\n    this._viewInstance = Computed.create(this, (use) => {\n      const sections = use(use(this.model.viewLayout.viewModel.viewSections).getObservable());\n      const view = sections.find(s => use(s.id) === use(this.id));\n      if (!view) { return null; }\n      const instance = use(view.viewInstance);\n      return instance;\n    });\n    this._buildHidden();\n    this.onDispose(() => {\n      const instance = this._hiddenViewInstance.get();\n      if (instance) {\n        dom.domDispose(instance);\n      }\n    });\n  }\n\n  public detach() {\n    this._detached = true;\n  }\n\n  public attach() {\n    this._detached = false;\n    const previous = this._hiddenViewInstance.get();\n    this._buildHidden();\n    if (previous) {\n      dom.domDispose(previous);\n    }\n  }\n\n  public buildDom() {\n    this._content.set(this.model.buildContentDom(this.id.get()));\n    return this.rootElement = cssBox(\n      testId(\"leaf-box\"),\n      dom.domComputed(this._content, c => c),\n      // Add draggable interface.\n      asDraggable(this),\n      dom.on(\"click\", (e) => {\n        this.model.viewLayout.viewModel.activeCollapsedSectionId(this.id.get());\n        // Sanity (and type) check.\n        if (!(e.target instanceof HTMLElement)) {\n          return;\n        }\n        // If the click not landed in a draggable-handle ignore it. Might be a click to open the menu.\n        if (!e.target.closest(\".draggable-handle\")) {\n          return;\n        }\n        // Apparently the click was to open the section in the popup. Use the anchor link to do that.\n        // Show my section on a popup using anchor link. We can't use maximize section for it, as we\n        // would need to rebuild the layout (as this is not a part of it).\n        urlState().pushUrl({\n          hash: {\n            sectionId: this.id.get(),\n            popup: true,\n          },\n        }).catch(() => {});\n        e.preventDefault();\n        e.stopPropagation();\n      }),\n      detachedNode(this._hiddenViewInstance),\n    );\n  }\n\n  // Implement the drag interface. All those methods are called by the draggable helper.\n\n  public dragStart(ev: DragEvent, floater: MiniFloater) {\n    // Get the element.\n    const myElement = this._content.get();\n    this._content.set(null);\n    floater.content.set(myElement);\n    // Create a clone.\n    const clone = CollapsedLeaf.create(floater, this.model, this.id.get());\n    clone._indexWhenDragged = this.model.layout.indexOf(this);\n    this.model.drag.emit(clone);\n\n    // Remove self from the layout (it will dispose this instance, but the viewInstance was moved to the floater)\n    this.model.layout.destroy(this);\n    return clone;\n  }\n\n  public dragEnd(ev: DragEvent) {\n    this.model.drag.emit(null);\n  }\n\n  public drag(ev: DragEvent) {\n    this.model.dragging.emit(ev);\n  }\n\n  public drop(ev: DragEvent, floater: MiniFloater) {\n    // Take back the element.\n    const element = floater.content.get();\n    floater.content.set(null);\n    this._content.set(element);\n    this.model.drop.emit(this);\n    // If I wasn't moved somewhere else, read myself back.\n    if (this.id.get() !== 0) {\n      this.model.layout.addBox(this.id.get(), this._indexWhenDragged);\n    }\n  }\n\n  public removeFromLayout() {\n    // Set the id to 0 so that the layout doesn't try to read me back.\n    this.id.set(0);\n    this.model.layout.destroy(this);\n  }\n\n  public leafId() {\n    return this.id.get();\n  }\n\n  private _buildHidden() {\n    this._hiddenViewInstance.set(cssHidden(dom.maybe(this._viewInstance, (view) => {\n      return this._detached ? null : view.viewPane;\n    })));\n  }\n}\n\n/**\n * This is analogous component to the main Floater in the LayoutEditor. It holds the little preview of a widget,\n * while it is dragged.\n */\nclass MiniFloater extends Disposable {\n  public content: Observable<HTMLElement | null> = Observable.create(this, null);\n  public rootElement: HTMLElement;\n  constructor() {\n    super();\n    this.rootElement = this.buildDom();\n    G.document.body.appendChild(this.rootElement);\n    this.onDispose(() => {\n      this.rootElement.remove();\n      dom.domDispose(this.rootElement);\n    });\n  }\n\n  public buildDom() {\n    return cssMiniFloater(\n      dom.show(use => Boolean(use(this.content))),\n      // dom.cls('layout_editor_floater'),\n      dom.domComputed(this.content, c => c),\n    );\n  }\n\n  public onMove(ev: JQMouseEvent) {\n    if (this.content.get()) {\n      this.rootElement.style.left = `${ev.clientX}px`;\n      this.rootElement.style.top = `${ev.clientY}px`;\n    }\n  }\n}\n\n/**\n * ExternalLeaf pretends that it is a collapsed leaf and acts as a proxy between collapsed tray and the\n * ViewLayout.\n */\nclass ExternalLeaf extends Disposable implements Dropped {\n  // If external element is in drag mode\n  public drag: Signal<Dropped>;\n  // Event when external leaf is being dragged.\n  public dragMove: Signal<MouseEvent>;\n\n  // Event when external leaf is dropped.\n  private _drop: Signal<ContentBox>;\n\n  constructor(protected model: LayoutTray) {\n    super();\n    // Wire up external events to mimic that we are a part.\n\n    // First we will replace all events, so that they won't emit anything if we are the only leaf\n    // in the layout.\n    const multipleLeaves = () => this.model.viewLayout.layout.getAllLeafIds().length > 1;\n\n    this.drag = Signal.fromEvents(this, this.model.viewLayout.layoutEditor, \"dragStart\", \"dragEnd\")\n      .filter(multipleLeaves);\n\n    this._drop = Signal.fromEvents(this, this.model.viewLayout.layoutEditor, \"dragDrop\")\n      .filter(multipleLeaves);\n\n    this.dragMove = Signal.fromEvents(this, this.model.viewLayout.layoutEditor, \"dragMove\")\n      .filter(multipleLeaves);\n\n    // Now bubble up those events to the model.\n\n    // For dragging we just need to know that it is on or off.\n    this.drag.map((box) => {\n      // We are tricking the model, we report that we are dragged, not the external leaf.\n      return box ? this as Dropped : null;\n    }).distinct().pipe(this.model.drag);\n\n    // When the external box is dropped, we will pretend that we were dropped.\n    this._drop.map(x => this as Dropped | null).pipe(this.model.drop);\n\n    // Listen to the inDrag state in the model, if the dragged element is not us, update\n    // target hits. Otherwise target hits will be updated by the viewLayout.\n    this.autoDispose(model.dragging.listen((ev) => {\n      // If the dragged box is not us, we need to update the targets.\n      if (ev && model.drag.state.get() !== this) {\n        this.model.viewLayout.layoutEditor.updateTargets(ev);\n      }\n    }));\n\n    // When drag is started by tray, we need to fire up user edit event. This is only needed\n    // because the viewLayout has a different UI when user is editing.\n    const miniDrag = Signal.compute(this, on => on(model.drag) && !on(this.drag)).map(Boolean).distinct();\n    this.autoDispose(miniDrag.listen((box) => {\n      if (box) {\n        this.model.viewLayout.layoutEditor.triggerUserEditStart();\n      } else {\n        const dropTargeter = this.model.viewLayout.layoutEditor.dropTargeter;\n        dropTargeter.removeTargetHints();\n        // Save the layout immediately after the drop. Otherwise we would wait a bit,\n        // and the section won't be created on time.\n        this.model.viewLayout.layoutEditor.triggerUserEditStop();\n        // Manually save the layout.\n        this.model.viewLayout.saveLayoutSpec().catch(reportError);\n      }\n    }));\n\n    // We are responsible for saving the layout, when section is collapsed or expanded.\n\n    // Also we need to monitor when mini leaf is dropped, it will trigger a drop event,\n    // but non-one will listen to it.\n    this.autoDispose(\n      model.drop.listen((dropped) => {\n        if (!dropped) {\n          return;\n        }\n        // If I was dropped (collapsed) over the tray, we don't need to do anything here.\n        // Our leaf was removed already and the layout will be saved by the miniDrag event.\n\n        // If I was dropped anywhere else, we don't need to do anything either, viewLayout will\n        // take care of it.\n        if (dropped === this) {\n          return;\n        }\n        // We only care when collapsed widget was dropped over the main area.\n        const externalEditor = this.model.viewLayout.layoutEditor;\n        const dropTargeter = this.model.viewLayout.layoutEditor.dropTargeter;\n        // Check that it was dropped over the main area.\n        if (dropTargeter?.activeTarget && !dropTargeter?.activeTarget?.box.isDisposed()) {\n          // Remove the widget from the tray, and at new leaf to the layout.\n          const part = dropTargeter.activeTarget;\n          dropTargeter.removeTargetHints();\n          const leaf = dropped.leafId();\n          const box = externalEditor.layout.buildLayoutBox({ leaf });\n          dropped.removeFromLayout();\n          if (part.isChild) {\n            part.box.addChild(box, part.isAfter);\n          } else {\n            part.box.addSibling(box, part.isAfter);\n          }\n          this.model.viewLayout.viewModel.activeSectionId(leaf);\n          this.model.drop.state.set(null);\n        }\n      }),\n    );\n    this._replaceFloater();\n  }\n\n  /**\n   * Dropped interface implementation, it is called only when a section in the main area is collapsed (dragged\n   * onto the valid target in the tray).\n   */\n  public removeFromLayout() {\n    const droppedBox = this._drop.state.get();\n    if (!droppedBox) { return; }\n    const leafId = this.leafId();\n    const otherSection = this.model.viewLayout.layoutEditor\n      .layout.getAllLeafIds().find(x => typeof x === \"number\" && x !== leafId);\n    this.model.viewLayout.viewModel.activeSectionId(otherSection);\n    // We can safely remove the box, because we should be called after viewInstance is grabbed by\n    // the tray.\n    this.model.viewLayout.layoutEditor.doRemoveBox(droppedBox);\n  }\n\n  public leafId() {\n    return this._drop.state.get()?.leafId.peek() || 0;\n  }\n\n  /**\n   * Monitors the external floater element, and if it is on top of the collapsed tray, replaces its content.\n   */\n  private _replaceFloater() {\n    const model = this.model;\n    // We will replace floater just after it starts till it is about to be dropped.\n    const period = Signal.fromEvents(model, model.viewLayout.layoutEditor, \"dragStart\", \"dragStop\");\n    const overEditor = Signal.compute(model, on => Boolean(on(period) && on(model.over))).distinct();\n    let lastContent: HTMLElement | null = null;\n    let lastTransform: string | null = null;\n    let lastX: number | null = null;\n    let lastY: number | null = null;\n    // When the external box is on top of the tray, we need to replace the content to be much smaller.\n    model.autoDispose(\n      overEditor.listen((over) => {\n        if (over) {\n          const floater = model.viewLayout.layoutEditor.floater;\n          const leafId = floater.leafId.peek();\n          if (typeof leafId !== \"number\") {\n            return;\n          }\n          const content = floater.leafContent.peek() as HTMLElement;\n          if (content) {\n            lastContent = content;\n            // Hide this element.\n            content.style.display = \"none\";\n            // Create another element to show in the floater.\n            const newContent = cssFloaterWrapper(content, buildCollapsedSectionDom({\n              gristDoc: model.viewLayout.gristDoc,\n              sectionRowId: leafId,\n            }));\n            floater.leafContent(newContent);\n            lastTransform = floater.dom.style.transform;\n            lastX = floater.mouseOffsetX;\n            lastY = floater.mouseOffsetY;\n            floater.dom.style.transform = \"none\";\n            floater.mouseOffsetX = 0;\n            floater.mouseOffsetY = 0;\n          }\n        } else if (lastContent) {\n          lastContent.style.display = \"\";\n          const floater = model.viewLayout.layoutEditor.floater;\n          const currentContent = floater.leafContent.peek() as HTMLElement;\n          floater.leafContent(lastContent);\n          if (currentContent) {\n            dom.domDispose(currentContent);\n          }\n          lastContent = null;\n          floater.dom.style.transform = lastTransform!;\n          floater.mouseOffsetX = lastX!;\n          floater.mouseOffsetY = lastY!;\n        }\n      }),\n    );\n  }\n}\n\n/**\n * A class that holds an array of IDisposable objects, and disposes them all when it is disposed.\n * The difference from a MultipleHolder is that it can release individual disposables from the array.\n */\nclass ArrayHolder extends Disposable {\n  private _array: IDisposable[] = [];\n\n  constructor() {\n    super();\n    this.onDispose(() => {\n      const seen = new Set();\n      for (const obj of this._array) {\n        if (!seen.has(obj)) {\n          seen.add(obj);\n          obj.dispose();\n        }\n      }\n      this._array = [];\n    });\n  }\n\n  public autoDispose<T extends IDisposable>(obj: T): T {\n    this._array.push(obj);\n    return obj;\n  }\n\n  public release(obj: IDisposable) {\n    const index = this._array.indexOf(obj);\n    if (index >= 0) {\n      return this._array.splice(index, 1);\n    }\n    return null;\n  }\n}\n\nfunction syncHover(obs: Signal) {\n  return [dom.on(\"mouseenter\", () => obs.emit(true)), dom.on(\"mouseleave\", () => obs.emit(false))];\n}\n\n/**\n * Helper function that renders an element from an observable, but prevents it from being disposed.\n * Used to keep viewInstance from being disposed when it is added as a child in various containers.\n */\nfunction detachedNode(node: Observable<HTMLElement | null>) {\n  return [\n    // When disposing DOM, grainjs goes over children first, then the parent. This dummy node will\n    // gets its disposer called, which will detach node before the disposer gets to it.\n    cssHidden(dom.onDispose(() => node.get()?.remove())),\n    dom.maybe(node, n => n),\n  ];\n}\n\n/**\n * Finds element that is marked as draggable from the mouse event.\n */\nfunction findDraggable(ev: EventTarget | null) {\n  if (ev instanceof HTMLElement) {\n    const target = ev.closest(\".draggable-handle\")?.closest(\".draggable\");\n    return !target ? null : dom.getData(target, \"draggable\") as Draggable;\n  }\n  return null;\n}\n\n/**\n * Marks a dom element as draggable. It sets a class and a data attribute that is looked up by the useDragging helper.\n */\nfunction asDraggable(item: Draggable) {\n  return [\n    dom.cls(\"draggable\"),\n    dom.data(\"draggable\", item),\n  ];\n}\n\n/**\n * Attaches a mouse events for dragging to a parent container. This way we have a single mouse event listener\n * for all draggable elements. All events are then delegated to the draggable elements.\n *\n * When a drag is started a MiniFloater is created, and the draggable element can be moved to the floater.\n */\nfunction useDragging() {\n  return (el: HTMLElement) => {\n    // Implement them by hand, using mouseenter, mouseleave, and mousemove events.\n    // This is a inspired by LayoutEditor.ts.\n    let justStarted = false;\n    let isDragging = false;\n    let dragged: Draggable | null = null;\n    let floater: MiniFloater | null = null;\n    let downX: number | null = null;\n    let downY: number | null = null;\n    const listener = (ev: JQMouseEvent) => {\n      switch (ev.type) {\n        case \"mousedown\":\n          // Only handle left button.\n          if (ev.button !== 0) {\n            return;\n          }\n          // If we haven't found a draggable element, return.\n          dragged = findDraggable(ev.target);\n          if (!dragged) {\n            return;\n          }\n          // If we had floater, dispose it.\n          floater?.dispose();\n          floater = new MiniFloater();\n          // Start drag and attach mousemove and mouseup listeners.\n          justStarted = true;\n          G.$(G.window).on(\"mousemove\", mouseMoveListener);\n          G.$(G.window).on(\"mouseup\", mouseUpListener);\n          downX = ev.clientX;\n          downY = ev.clientY;\n          return false;\n        case \"mouseup\":\n          if (!dragged) {\n            return;\n          }\n          justStarted = false;\n          G.$(G.window).off(\"mousemove\", mouseMoveListener);\n          G.$(G.window).off(\"mouseup\", mouseUpListener);\n\n          if (isDragging) {\n            isDragging = false;\n            if (dragged?.drop) {\n              dragged.drop(ev as DragEvent, floater!);\n            }\n            if (dragged?.dragEnd) {\n              dragged.dragEnd(ev as DragEvent, floater!);\n            }\n          }\n          dragged = null;\n          floater?.dispose();\n          floater = null;\n          return false;\n        case \"mousemove\":\n          if (justStarted) {\n            const slightMove = downX && downY &&\n              (Math.abs(ev.clientX - downX) > 3 || Math.abs(ev.clientY - downY) > 3);\n            if (slightMove) {\n              justStarted = false;\n              if (dragged?.dragStart) {\n                // Drag element has an opportunity to return a new draggable object.\n                dragged = dragged.dragStart(ev as DragEvent, floater!);\n                if (!dragged) {\n                  return;\n                }\n              }\n              // Now we are dragging.\n              isDragging = true;\n            }\n          }\n          if (!isDragging) {\n            return;\n          }\n          if (dragged?.drag) {\n            dragged.drag(ev as DragEvent, floater!);\n          }\n          floater!.onMove(ev);\n          return false;\n      }\n    };\n    const mouseMoveListener = (ev: JQMouseEvent) => listener(ev);\n    const mouseUpListener = (ev: JQMouseEvent) => listener(ev);\n    dom.autoDisposeElem(el, dom.onElem(G.window, \"mousedown\", e => listener(e)));\n    dom.onDisposeElem(el, () => (floater?.dispose(), floater = null));\n  };\n}\n\n/**\n * A virtual rectangle that is relative to a DOMRect.\n */\nclass VRect {\n  public left: number;\n  public width: number;\n  public top: number;\n  public right: number;\n  public height: number;\n  constructor(offset: DOMRect, params: Partial<VRect>) {\n    Object.assign(this, params);\n    this.left += offset.left;\n    this.right += offset.left;\n    this.top += offset.top;\n    this.width = this.right - this.left;\n  }\n\n  public contains(ev: MouseEvent) {\n    return ev.clientX >= this.left && ev.clientX <= this.right &&\n      ev.clientY >= this.top && ev.clientY <= this.top + this.height;\n  }\n}\n\nconst cssVirtualZone = styled(\"div\", `\n  position: absolute;\n  inset: 0;\n`);\n\nconst cssFloaterWrapper = styled(\"div\", `\n  height: 40px;\n  width: 140px;\n  max-width: 140px;\n  background: ${theme.tableBodyBg};\n  border: 1px solid ${theme.widgetBorder};\n  border-radius: 4px;\n  -webkit-transform: rotate(5deg) scale(0.8) translate(-10px, 0px);\n  transform: rotate(5deg) scale(0.8) translate(-10px, 0px);\n  & .mini_section_container {\n    overflow: hidden;\n    white-space: nowrap;\n  }\n`);\n\nconst cssCollapsedTray = styled(\"div.collapsed_layout\", `\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n  transition: height 0.2s;\n  position: relative;\n  margin: calc(-1 * var(--view-content-page-padding, 12px));\n  margin-bottom: 0;\n  user-select: none;\n  background-color: ${theme.pageBg};\n  border-bottom: 1px solid ${theme.pagePanelsBorder};\n  outline-offset: -1px;\n\n  &-is-active {\n    outline: 2px dashed ${theme.widgetBorder};\n  }\n  &-is-target {\n    outline: 2px dashed #7B8CEA;\n    background: rgba(123, 140, 234, 0.1);\n  }\n  @media print {\n    & {\n      display: none;\n    }\n  }\n`,\n);\n\nconst cssRow = styled(\"div\", `display: flex`);\nconst cssLayout = styled(cssRow, `\n  padding: 8px 24px;\n  column-gap: 16px;\n  row-gap: 8px;\n  flex-wrap: wrap;\n  position: relative;\n`);\n\nconst cssBox = styled(\"div\", `\n  border: 1px solid ${theme.widgetBorder};\n  border-radius: 3px;\n  background: ${theme.widgetBg};\n  min-width: 120px;\n  min-height: 34px;\n  cursor: pointer;\n`);\n\nconst cssEmptyBox = styled(\"div\", `\n  text-align: center;\n  text-transform: uppercase;\n  color: ${theme.widgetBorder};\n  font-weight: bold;\n  letter-spacing: 1px;\n  border: 2px dashed ${theme.widgetBorder};\n  border-radius: 3px;\n  padding: 8px;\n  width: 120px;\n  min-height: 34px;\n  &-can-accept {\n    border: 2px dashed #7B8CEA;\n    background: rgba(123, 140, 234, 0.1);\n  }\n`);\n\nconst cssProbe = styled(\"div\", `\n  min-width: 0px;\n  padding: 0px;\n  transition: width 0.2s ease-out;\n`);\n\nconst cssMiniFloater = styled(cssBox, `\n  pointer-events: none;\n  position: absolute;\n  overflow: hidden;\n  pointer-events: none;\n  z-index: 10;\n  -webkit-transform: rotate(5deg) scale(0.8);\n  transform: rotate(5deg) scale(0.8);\n  transform-origin: top left;\n`);\n\nconst cssVirtualPart = styled(\"div\", `\n  outline: 1px solid blue;\n  position: absolute;\n  z-index: 10;\n  background: rgba(0, 0, 0, 0.1);\n`);\n\nconst cssHidden = styled(\"div\", `display: none;`);\n"
  },
  {
    "path": "app/client/components/LinkingState.ts",
    "content": "import { SequenceNEVER, SequenceNum } from \"app/client/components/Cursor\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport DataTableModel from \"app/client/models/DataTableModel\";\nimport { DocModel } from \"app/client/models/DocModel\";\nimport { ColumnRec } from \"app/client/models/entities/ColumnRec\";\nimport { TableRec } from \"app/client/models/entities/TableRec\";\nimport { ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport { LinkConfig } from \"app/client/ui/LinkConfig\";\nimport { FilterColValues, QueryOperation } from \"app/common/ActiveDocAPI\";\nimport { isList, isListType, isRefListType } from \"app/common/gristTypes\";\nimport * as gutil from \"app/common/gutil\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\nimport { CellValue } from \"app/plugin/GristData\";\nimport { encodeObject } from \"app/plugin/objtypes\";\n\nimport { Disposable, Holder, MultiHolder } from \"grainjs\";\nimport * as  ko from \"knockout\";\nimport mapValues from \"lodash/mapValues\";\nimport merge from \"lodash/merge\";\nimport pick from \"lodash/pick\";\nimport pickBy from \"lodash/pickBy\";\n\n// Descriptive string enum for each case of linking\n// Currently used for rendering user-facing link info\n// TODO JV: Eventually, switching the main block of linking logic in LinkingState constructor to be a big\n//          switch(linkType){} would make things cleaner.\n// TODO JV: also should add \"Custom-widget-linked\" to this, but holding off until Jarek's changes land\ntype LinkType = \"Filter:Summary-Group\" |\n  \"Filter:Col->Col\" |\n  \"Filter:Row->Col\" |\n  \"Summary\" |\n  \"Show-Referenced-Records\" |\n  \"Cursor:Same-Table\" |\n  \"Cursor:Reference\" |\n  \"Error:Invalid\";\n\n// If this LinkingState represents a filter link, it will set its filterState to this object\n// The filterColValues portion is just the data needed for filtering (same as manual filtering), and is passed\n// to the backend in some cases (CSV export)\n// The filterState includes extra info to display filter state to the user\ntype FilterState = FilterColValues & {\n  filterLabels: {  [colId: string]: string[] }; // formatted and displayCol-ed values to show to user\n  colTypes: { [colId: string]: string; }\n};\nfunction FilterStateToColValues(fs: FilterState) { return pick(fs, [\"filters\", \"operations\"]); }\n\n// Since we're not making full objects for these, need to define sensible \"empty\" values here\nexport const EmptyFilterState: FilterState = { filters: {}, filterLabels: {}, operations: {}, colTypes: {} };\nexport const EmptyFilterColValues: FilterColValues = FilterStateToColValues(EmptyFilterState);\n\nexport class LinkingState extends Disposable {\n  // If linking affects target section's cursor, this will be a computed for the cursor rowId.\n  // Is undefined if not cursor-linked\n  public readonly cursorPos?: ko.Computed<UIRowId | null>;\n\n  // Cursor-links can be cyclic, need to keep track of both rowId and the lastCursorEdit that it came from to\n  // resolve it correctly, (use just one observable so they update at the same time)\n  // NOTE: observables don't do deep-equality check, so need to replace the whole array when updating\n  public readonly incomingCursorPos: ko.Computed<[UIRowId | null, SequenceNum]>;\n\n  // If linking affects filtering, this is a computed for the current filtering state, including user-facing\n  // labels for filter values and types of the filtered columns\n  // with a dependency on srcSection.activeRowId()\n  // Is undefined if not link-filtered\n  public readonly filterState?: ko.Computed<FilterState>;\n\n  // filterColValues is a subset of the current filterState needed for filtering (subset of ClientQuery)\n  // {[colId]: colValues, [colId]: operations} mapping,\n  public readonly filterColValues?: ko.Computed<FilterColValues>;\n\n  // Get default values for a new record so that it continues to satisfy the current linking filters\n  public readonly getDefaultColValues: () => any;\n\n  // Which case of linking we've got, this is a descriptive string-enum.\n  public readonly linkTypeDescription: ko.Computed<LinkType>;\n\n  private _docModel: DocModel;\n  private _srcSection: ViewSectionRec;\n  private _srcTableModel: DataTableModel;\n  private _srcColId: string | undefined;\n\n  constructor(docModel: DocModel, linkConfig: LinkConfig) {\n    super();\n    const { srcSection, srcCol, srcColId, tgtSection, tgtCol, tgtColId } = linkConfig;\n    this._docModel = docModel;\n    this._srcSection = srcSection;\n    this._srcColId = srcColId;\n    this._srcTableModel = docModel.dataTables[srcSection.table().tableId()];\n    const srcTableData = this._srcTableModel.tableData;\n\n    // === IMPORTANT NOTE! (this applies throughout this file)\n    // srcCol and tgtCol can be the \"empty column\"\n    //  - emptyCol.getRowId() === 0\n    //  - emptyCol.colId() === undefined\n    // The typical pattern to deal with this is to use `srcColId = col?.colId()`, and test for `if (srcColId) {...}`\n\n    this.linkTypeDescription = this.autoDispose(ko.computed((): LinkType => {\n      if (srcSection.isDisposed()) {\n        // srcSection disposed can happen transiently. Can happen when deleting tables and then undoing?\n        // nbrowser tests: LinkingErrors and RawData seem to hit this case\n        console.warn(\"srcSection disposed in linkingState: linkTypeDescription\");\n        return \"Error:Invalid\";\n      }\n\n      if (srcSection.table().summarySourceTable() && srcColId === \"group\") {\n        return \"Filter:Summary-Group\"; // implemented as col->col, but special-cased in select-by\n      } else if (srcColId && tgtColId) {\n        return \"Filter:Col->Col\";\n      } else if (!srcColId && tgtColId) {\n        return \"Filter:Row->Col\";\n      } else if (srcColId && !tgtColId) { // Col->Row, i.e. show a ref\n        if (isRefListType(srcCol.type())) { // TODO: fix this once ref-links are unified, both could be show-ref-rec\n          return \"Show-Referenced-Records\";\n        } else { return \"Cursor:Reference\"; }\n      } else if (!srcColId && !tgtColId) { // Either same-table cursor link OR summary link\n        if (isSummaryOf(srcSection.table(), tgtSection.table())) {\n          return \"Summary\";\n        } else {\n          return \"Cursor:Same-Table\";\n        }\n      } else { // This case shouldn't happen, but just check to be safe\n        return \"Error:Invalid\";\n      }\n    }));\n\n    if (srcSection.selectedRowsActive()) { // old, special-cased custom filter\n      const operation = (tgtColId && isRefListType(tgtCol.type())) ? \"intersects\" : \"in\";\n      this.filterState = this._srcCustomFilter(tgtCol, operation); // works whether tgtCol is the empty col or not\n    } else if (tgtColId) { // Standard filter link\n      // If srcCol is the empty col, is a row->col filter (i.e. id -> tgtCol)\n      // else is a col->col filter (srcCol -> tgtCol)\n      // MakeFilterObs handles it either way\n      this.filterState = this._makeFilterObs(srcCol, tgtCol);\n    } else if (srcColId && isRefListType(srcCol.type())) {  // \"Show Referenced Records\" link\n      // tgtCol is the emptycol (i.e. the id col)\n      // srcCol must be a reference to the tgt table\n      // Link will filter tgt section to show exactly the set of rowIds referenced by the srcCol\n      // (NOTE: currently we only do this for reflists, single refs handled as cursor links for now)\n      this.filterState = this._makeFilterObs(srcCol, undefined);\n    } else if (!srcColId && isSummaryOf(srcSection.table(), tgtSection.table())) { // Summary linking\n      // We do summary filtering if no cols specified and summary section is linked to a more detailed summary\n      // (or to the summarySource table)\n      // Implemented as multiple column filters, one for each groupByCol of the src table\n\n      // temp vars for _update to use (can't set filterState directly since it's gotta be a computed)\n      const _filterState = ko.observable<FilterState>();\n      this.filterState = this.autoDispose(ko.computed(() => _filterState()));\n\n      // update may be called multiple times, so need a holder to handle disposal\n      // Note: grainjs MultiHolder can't actually be cleared. To be able to dispose of multiple things, we need\n      //       to make a MultiHolder in a Holder, which feels ugly but works.\n      // TODO: Update this if we ever patch grainjs to allow multiHolder.clear()\n      const updateHolder = Holder.create(this);\n\n      // source data table could still be loading (this could happen after changing the group-by\n      // columns of a linked summary table for instance). Define an _update function to be called when data loads\n      const _update = () => {\n        if (srcSection.isDisposed() || srcSection.table().groupByColumns().length === 0) {\n          // srcSection disposed can happen transiently. Can happen when deleting tables and then undoing?\n          // Tests nbrowser/LinkingErrors and RawData might hit this case\n          // groupByColumns === [] can happen if we make a summary tab [group by nothing]. (in which case: don't filter)\n          _filterState(EmptyFilterState);\n          return;\n        }\n\n        // Make a MultiHolder to own this invocation's objects (disposes of old one)\n        // TODO (MultiHolder in a Holder is a bit of a hack, but needed to hold multiple objects I think)\n        const updateMultiHolder = MultiHolder.create(updateHolder);\n\n        // Make one filter for each groupBycolumn of srcSection\n        const resultFilters: (ko.Computed<FilterState> | undefined)[] = srcSection.table().groupByColumns()\n          .map(srcGCol =>\n            this._makeFilterObs(srcGCol, summaryGetCorrespondingCol(srcGCol, tgtSection.table()), updateMultiHolder),\n          );\n\n        // If any are undef (i.e. error in makeFilterObs), error out\n        if (resultFilters.some(f => f === undefined)) {\n          console.warn(\"LINKINGSTATE: some of filters are undefined\", resultFilters);\n          _filterState(EmptyFilterState);\n          return;\n        }\n\n        // Merge them together in a computed\n        const resultComputed = updateMultiHolder.autoDispose(ko.computed(() => {\n          return merge({}, ...resultFilters.map(filtObs => filtObs!())) as FilterState;\n        }));\n        _filterState(resultComputed());\n        resultComputed.subscribe(val => _filterState(val));\n      }; // End of update function\n\n      // Call update when data loads, also call now to be safe\n      this.autoDispose(srcTableData.dataLoadedEmitter.addListener(_update));\n      _update();\n\n      // ================ CURSOR LINKS: =================\n    } else { // !tgtCol && !summary-link && (!lookup-link || !reflist),\n      //        either same-table cursor-link (!srcCol && !tgtCol, so do activeRowId -> cursorPos)\n      //        or cursor-link by reference   ( srcCol && !tgtCol, so do srcCol -> cursorPos)\n\n      // Cursor linking notes:\n      //\n      // If multiple viewSections are cursor-linked together A->B->C, we need to propagate the linked cursorPos along.\n      // The old way was to have: A.activeRowId -> (sets by cursor-link) -> B.activeRowId, and so on\n      //                                                                                                               |\n      //                                   -->  [B.LS]                    --> [C.LS]                                   |\n      //                                  /        | B.LS.cursorPos      /       | C.LS.cursorPos                      |\n      //                                 /         v                    /        v                                     |\n      //                   [ A ]--------/        [ B ]   --------------/       [ C ]                                   |\n      //                        A.actRowId                B.actRowId                                                   |\n      //\n      // However, if e.g. viewSec B is filtered, the correct rowId might not exist in B, and so its activeRowId would be\n      // on a different row, and therefore the cursor linking would set C to a different row from A, even if it existed\n      // in C\n      //\n      // Normally this wouldn't be too bad, but to implement bidirectional linking requires allowing cycles of\n      // cursor-links, in which case this behavior becomes extra-problematic, both in being more unexpected from a UX\n      // perspective and because a section will eventually be linked to itself, which is an unstable loop.\n      //\n      // A better solution is to propagate the linked rowId directly through the chain of linkingStates without passing\n      // through the activeRowIds of the sections, so whether a section is filtered or not doesn't affect propagation.\n      //\n      //                                                B.LS.incCursPos                                                |\n      //                                 -->  [B.LS]   -------------->   [C.LS]                                        |\n      //                                /        |                          |                                          |\n      //                               /         v B.LS.cursorPos           v C.LS.cursorPos                           |\n      //                 [ A ]--------/        [ B ]                      [ C ]                                        |\n      //                      A.actRowId                                                                               |\n      //\n      // If the previous section has a linkingState, we use the previous LS's incomingCursorPos\n      // (i.e. two sections back) instead of looking at our srcSection's activeRowId. This way it doesn't matter how\n      // section B is filtered, since we're getting our cursorPos straight from A (through a computed in B.LS)\n      //\n      // However, each linkingState needs to decide whether to use the cursorPos from the srcSec (i.e. its activeRowId),\n      // or to use the previous linkState's incomingCursorPos. We want to use whichever section the user most recently\n      // interacted with, i.e. whichever cursor update was most recent. For this we use, the cursor version (given in\n      // viewSection.lastCursorEdit). incomingCursorPos is a pair of [rowId, sequenceNum], so each linkingState sets its\n      // incomingCursorPos to whichever is most recent between its srcSection, and the previous LS's incCursPos.\n      //\n      // If we do this right, the end result is that because the lastCursorEdits are guaranteed to be unique,\n      // there is always a stable configuration of links, where even in the case of a cycle the incomingCursorPos-es\n      // will all take their rowId and version from the most recently edited viewSection in the cycle,\n      // which is what the user expects\n      //\n      //               ...from C--> [A.LS] -------->  [B.LS]               --> [C.LS] ----->...to A                    |\n      //                               |                 |                /       |                                    |\n      //                               v                 v               /        v                                    |\n      //                             [ A ]             [ B ]   ---------/       [ C ]                                  |\n      //                                          (most recently edited)                                               |\n      //\n      // Once the incomingCursorPos-es are determined correctly, the cursorPos-es just need to pull out the rowId,\n      // and that will drive the cursors of the associated tgt section for each LS.\n      //\n      // NOTE: setting cursorPos *WILL* change the viewSections' cursor, but it's special-cased to\n      // so that cursor-driven linking doesn't modify their lastCursorEdit times, so that lastCursorEdit\n      // reflects only changes driven by external factors\n      // (e.g. page load, user moving cursor, user changing linking settings/filter settings)\n      // =============================\n\n      // gets the relevant col value for the passed-in rowId, or return rowId unchanged if same-table link\n      const srcValueFunc = this._makeValGetter(this._srcSection.table(), this._srcColId);\n\n      // check for failure\n      if (srcValueFunc) {\n        // Incoming-cursor-pos determines what the linked cursor position should be, considering the previous\n        // linked section (srcSection) and all upstream sections (through srcSection.linkingState)\n        this.incomingCursorPos = this.autoDispose((ko.computed(() => {\n          // NOTE: This computed primarily decides between srcSec and prevLink. Here's what those mean:\n          // e.g. consider sections A->B->C, (where this === C)\n          // We need to decide between taking cursor info from B, our srcSection (1 hop back)\n          //    vs taking cursor info from further back, e.g. A, or before (2+ hops back)\n          // To take cursor info from further back, we rely on B's linkingState, since B's linkingState will\n          //    be looking at the preceding sections, either A or whatever is behind A.\n          // Therefore: we either use srcSection (1 back), or prevLink = srcSection.linkingState (2+ back)\n\n          // Get srcSection's info (1 hop back)\n          const srcSecPos = this._srcSection.activeRowId.peek(); // we don't depend on this, only on its cursor version\n          const srcSecVersion = this._srcSection.lastCursorEdit();\n\n          // If cursors haven't been initialized, cursor-linking doesn't make sense, so don't do it\n          if (srcSecVersion === SequenceNEVER) {\n            return [null, SequenceNEVER] as [UIRowId | null, SequenceNum];\n          }\n\n          // Get previous linkingstate's info, if applicable (2 or more hops back)\n          const prevLink = this._srcSection.linkingState?.();\n          const prevLinkHasCursor = prevLink?.incomingCursorPos &&\n            (prevLink.linkTypeDescription() === \"Cursor:Same-Table\" ||\n              prevLink.linkTypeDescription() === \"Cursor:Reference\");\n          const [prevLinkedPos, prevLinkedVersion] = prevLinkHasCursor ? prevLink.incomingCursorPos() :\n            [null, SequenceNEVER];\n\n          // ==== Determine whose info to use:\n          // If prevLinkedVersion < srcSecVersion, then the prev linked data is stale, don't use it\n          // If prevLinkedVersion == srcSecVersion, then srcSec is the driver for this link cycle (i.e. we're its first\n          //                                        outgoing link), AND the link cycle has come all the way around\n          const usePrev = prevLinkHasCursor && prevLinkedVersion > srcSecVersion;\n\n          // srcSec/prevLinkedPos is rowId from srcSec. However if \"Cursor:Reference\", we must follow the ref in srcCol\n          // srcValueFunc will get the appropriate value based on this._srcColId if that's the case\n          const tgtCursorPos = (srcValueFunc(usePrev ? prevLinkedPos : srcSecPos) || \"new\") as UIRowId;\n          // NOTE: srcValueFunc returns 'null' if rowId is the add-row, so we coerce that back into || \"new\"\n          // NOTE: cursor linking is only ever done by the id column (for same-table) or by single Ref col (cursor:ref),\n          //     so we'll never have to worry about `null` showing up as an actual cell-value. (A blank Ref is just `0`)\n\n          return [\n            tgtCursorPos,\n            usePrev ? prevLinkedVersion : srcSecVersion, // propagate which version our cursorPos is from\n          ] as [UIRowId | null, SequenceNum];\n        })));\n\n        // Pull out just the rowId from incomingCursor Pos\n        // (This get applied directly to tgtSection's cursor),\n        this.cursorPos = this.autoDispose(ko.computed(() => this.incomingCursorPos()[0]));\n      }\n\n      if (!srcColId) { // If same-table cursor-link, copy getDefaultColValues from the source if possible\n        const getDefaultColValues = srcSection.linkingState()?.getDefaultColValues;\n        if (getDefaultColValues) {\n          this.getDefaultColValues = getDefaultColValues;\n        }\n      }\n    }\n    // ======= End of cursor linking\n\n    // Make filterColValues, which is just the filtering-relevant parts of filterState\n    // (it's used in places that don't need the user-facing labels, e.g. CSV export)\n    this.filterColValues = (this.filterState) ?\n      ko.computed(() => FilterStateToColValues(this.filterState!())) :\n      undefined;\n\n    if (!this.getDefaultColValues) {\n      this.getDefaultColValues = () => {\n        if (!this.filterState) {\n          return {};\n        }\n        const { filters, operations } = this.filterState.peek();\n        return mapValues(\n          pickBy(filters, (value: any[], key: string) => value.length > 0 && key !== \"id\"),\n          (value, key) => operations[key] === \"intersects\" ? encodeObject(value) : value[0],\n        );\n      };\n    }\n  }\n\n  /**\n   * Returns a boolean indicating whether editing should be disabled in the destination section.\n   */\n  public disableEditing(): boolean {\n    if (!this.filterState) {\n      return false;\n    }\n    const srcRowId = this._srcSection.activeRowId();\n    return srcRowId === \"new\" || srcRowId === null;\n  }\n\n  /**\n   * Makes a standard filter link (summary tables and cursor links handled separately)\n   * treats (srcCol === undefined) as srcColId === \"id\", same for tgt\n   *\n   * if srcColId === \"id\", uses src activeRowId as the selector value (i.e. a ref to that row)\n   * else, gets the current value in selectedRow's SrcCol\n   *\n   * Returns a FilterColValues with a single filter {[tgtColId|\"id\":string] : (selectorVals:val[])}\n   * note: selectorVals is always a list of values: if reflist the leading \"L\" is trimmed, if single val then [val]\n   *\n   * If unable to initialize (sometimes happens when things are loading?), returns undefined\n   *\n   * NOTE: srcColId and tgtColId MUST NOT both be undefined, that implies either cursor linking or summary linking,\n   * which this doesn't handle\n   *\n   * @param srcCol srcCol for the filter, or undefined/the empty column to mean the entire record\n   * @param tgtCol tgtCol for the filter, or undefined/the empty column to mean the entire record\n   * @param [owner=this] Owner for all created disposables\n   * @private\n   */\n  private _makeFilterObs(\n    srcCol: ColumnRec | undefined,\n    tgtCol: ColumnRec | undefined,\n    owner: MultiHolder = this): ko.Computed<FilterState> | undefined {\n    const srcColId = srcCol?.colId();\n    const tgtColId = tgtCol?.colId();\n\n    // Assert: if both are null then it's a summary filter or same-table cursor-link, neither of which should go here\n    if (!srcColId && !tgtColId) {\n      throw Error(\"ERROR in _makeFilterObs: srcCol and tgtCol can't both be empty\");\n    }\n\n    // if (srcCol), selectorVal is the value in activeRowId[srcCol].\n    // if (!srcCol), then selectorVal is the entire record, so func just returns the rowId,\n    // or null if the rowId is \"new\"\n    const selectorValGetter = this._makeValGetter(this._srcSection.table(), srcColId);\n\n    // Figure out display val to show for the selector (if selector is a Ref)\n    // - if srcCol is a ref, we display its displayColModel(), which is what is shown in the cell\n    // - However, if srcColId === 'id', there is no srcCol.displayColModel.\n    //   We also can't use tgtCol.displayColModel, since we're getting values from the source section.\n    //   Therefore: The value we want to display is srcRow[tgtCol.visibleColModel.colId]\n    //\n    // Note: if we've gotten here, tgtCol is guaranteed to be a ref/reflist if srcColId === undefined\n    //       (because we ruled out the undef/undef case above)\n    // Note: tgtCol.visibleCol.colId can be undefined, iff visibleCol is rowId. makeValGetter handles that implicitly\n    const displayColId = srcColId ?\n      srcCol!.displayColModel().colId() :\n      tgtCol!.visibleColModel().colId();\n    const displayValGetter = this._makeValGetter(this._srcSection.table(), displayColId);\n\n    // Note: if src is a reflist, its displayVal will be a list of the visibleCol vals,\n    // i.e [\"L\", visVal1, visVal2], but they won't be formatter()-ed\n\n    // Grab the formatter (for numerics, dates, etc)\n    const displayValFormatter = srcColId ? srcCol!.visibleColFormatter() : tgtCol!.visibleColFormatter();\n\n    const isSrcRefList = srcColId && isRefListType(srcCol!.type());\n    const isTgtRefList = tgtColId && isRefListType(tgtCol!.type());\n\n    if (!selectorValGetter || !displayValGetter) {\n      console.error(\"ERROR in _makeFilterObs: couldn't create valGetters for srcSection\");\n      return undefined;\n    }\n\n    // Now, create the actual observable that updates with activeRowId\n    // (we autodispose/return it at the end of the function) is this right? TODO JV\n    return owner.autoDispose(ko.computed(() => {\n      if (this._srcSection.isDisposed()) {\n        // srcSection disposed can happen transiently. Can happen when deleting tables and then undoing?\n        // nbrowser tests: LinkingErrors and RawData seem to hit this case\n        console.warn(\"srcSection disposed in LinkingState._makeFilterObs\");\n        return EmptyFilterState;\n      }\n\n      if (this._srcSection.isDisposed()) {\n        // happened transiently in test: \"RawData should remove all tables except one (...)\"\n        console.warn(\"LinkingState._makeFilterObs: srcSectionDisposed\");\n        return EmptyFilterState;\n      }\n\n      // Get selector-rowId\n      const srcRowId = this._srcSection.activeRowId();\n\n      // Get values from selector row\n      const selectorCellVal = selectorValGetter(srcRowId);\n      const displayCellVal  = displayValGetter(srcRowId);\n\n      // Coerce values into lists (FilterColValues wants output as a list, even if only 1 val)\n      let filterValues: any[];\n      let displayValues: any[];\n      if (!isSrcRefList) {\n        filterValues = [selectorCellVal];\n        displayValues = [displayCellVal];\n      } else if (isSrcRefList && isList(selectorCellVal)) { // Reflists are: [\"L\", ref1, ref2, ...], slice off the L\n        filterValues = selectorCellVal.slice(1);\n\n        // selectorValue and displayValue might not match up? Shouldn't happen, but let's yell loudly if it does\n        if (isList(displayCellVal) && displayCellVal.length === selectorCellVal.length) {\n          displayValues = displayCellVal.slice(1);\n        } else {\n          console.warn(\"Error in LinkingState: displayVal list doesn't match selectorVal list \");\n          displayValues = filterValues; // fallback to unformatted values\n        }\n      } else { // isSrcRefList && !isList(val), probably null.\n        // Happens with blank reflists, or if cursor on the 'new' row\n\n        filterValues = [];\n        displayValues = [];\n        if (selectorCellVal !== null) { // should be null, but let's warn if it's not\n          console.warn(\"Error in LinkingState.makeFilterObs(), srcVal is reflist but has non-list non-null value\");\n        }\n      }\n\n      // ==== Determine operation to use for filter ====\n      // Common case: use 'in' for single vals, or 'intersects' for ChoiceLists & RefLists\n      let operation = (tgtColId && isListType(tgtCol!.type())) ? \"intersects\" : \"in\";\n\n      // # Special case 1:\n      // Blank selector shouldn't mean \"show no records\", it should mean \"show records where tgt column is also blank\"\n      // This is the default behavior for single-ref -> single-ref links\n      // However, if tgtCol is a list and the selectorVal is blank/empty, the default behavior ([] intersects tgtlist)\n      //    doesn't work, we need to explicitly specify the operation to be 'empty', to select empty cells\n      if (tgtCol?.type() === \"ChoiceList\" && !isSrcRefList && selectorCellVal === \"\") {\n        operation = \"empty\";\n      } else if (isTgtRefList && !isSrcRefList && selectorCellVal === 0) {\n        operation = \"empty\";\n      } else if (isTgtRefList &&  isSrcRefList && filterValues.length === 0) {\n        operation = \"empty\";\n      } // eslint-disable-line @stylistic/brace-style\n      // Note, we check each case separately since they have different \"blank\" values\"\n      // Other types can have different falsey values when non-blank (e.g. a Ref=0 is a blank cell, but for numbers,\n      //      0 would be a valid value, and to check for an empty number-cell you'd check for null)\n      // However, we don't need to check for those here, since they can't be linked to list types\n\n      // NOTES ON CHOICELISTS: they only show up in a few cases.\n      // - ChoiceList can only ever appear in links as the tgtcol\n      //   (ChoiceLists can only be linked from summ. tables, and summary flattens lists, so srcCol would be 'Choice')\n      // - empty Choice is [\"\"].\n\n      // # Special case 2:\n      //  If tgtCol is a single ref, blankness is represented by [0]\n      //  However if srcCol is a RefList, blankness is represented by [], which won't match the [0].\n      //  We create the 0 explicitly so the filter will select the blank Refs\n      else if (!isTgtRefList && isSrcRefList && filterValues.length === 0) {\n        filterValues = [0];\n        displayValues = [\"\"];\n      }\n\n      // # Special case 3:\n      // If the srcSection has no row selected (cursor on the add-row, or no data in srcSection), we should\n      //    show no rows in tgtSection. (we also gray it out and show the \"No row selected in $SRCSEC\" msg)\n      // This should line up with when this.disableEditing() returns true\n      if (srcRowId === \"new\" || srcRowId === null) {\n        operation = \"in\";\n        filterValues = [];\n        displayValues = [];\n      }\n\n      // Run values through formatters (for dates, numerics, Refs with visCol = rowId)\n      const filterLabelVals: string[] = displayValues.map(v => displayValFormatter.formatAny(v));\n\n      return {\n        filters: { [tgtColId || \"id\"]: filterValues },\n        filterLabels: { [tgtColId || \"id\"]: filterLabelVals },\n        operations: { [tgtColId || \"id\"]: operation },\n        colTypes: { [tgtColId || \"id\"]: (tgtCol || srcCol)!.type() },\n        // at least one of tgt/srcCol is guaranteed to be non-null, and they will have the same type\n      } as FilterState;\n    }));\n  }\n\n  // Value for this.filterColValues based on the values in srcSection.selectedRows\n  // \"null\" for column implies id column\n  private _srcCustomFilter(\n    column: ColumnRec | undefined, operation: QueryOperation): ko.Computed<FilterState> {\n    // Note: column may be the empty column, i.e. column != undef, but column.colId() is undefined\n    const colId = (column?.colId() === undefined) ? \"id\" : column.colId();\n    return this.autoDispose(ko.computed(() => {\n      const values = this._srcSection.selectedRows();\n      return {\n        filters: { [colId]: values },\n        filterLabels: { [colId]: values?.map(v => String(v)) }, // selectedRows should never be null if customFiltered\n        operations: { [colId]: operation },\n        colTypes: { [colId]: column?.type() || `Ref:${column?.table().tableId}` },\n      } as FilterState; // TODO: fix this once we have cases of customwidget linking to test with\n    }));\n  }\n\n  // Returns a ValGetter function, i.e. (rowId) => cellValue(rowId, colId), for the specified table and colId,\n  // Or null if there's an error in making the valgetter\n  // Note:\n  // - Uses a row model to create a dependency on the cell's value, so changes to the cell value will notify observers\n  // - ValGetter returns null for the 'new' row\n  // - An undefined colId means to use the 'id' column, i.e. Valgetter is (rowId)=>rowId\n  private _makeValGetter(\n    table: TableRec, colId: string | undefined, owner: MultiHolder = this,\n  ): (null | ((r: UIRowId | null) => CellValue | null)) { // (null | ValGetter)\n    if (colId === undefined) { // passthrough for id cols\n      return (rowId: UIRowId | null) => { return rowId === \"new\" ? null : rowId; };\n    }\n\n    const tableModel = this._docModel.dataTables[table.tableId()];\n    const rowModel = (tableModel.createFloatingRowModel()) as DataRowModel;\n    owner.autoDispose(rowModel);\n    const cellObs = rowModel.cells[colId];\n    // If no cellObs, can't make a val getter. This shouldn't happen, but may happen\n    // transiently while the separate linking-related observables get updated.\n    if (!cellObs) {\n      console.warn(`Issue in LinkingState._makeValGetter(${table.tableId()},${colId}): cellObs is nullish`);\n      return null;\n    }\n\n    return (rowId: UIRowId | null) => { // returns cellValue | null\n      rowModel.assign(rowId);\n      if (rowId === \"new\") { return null; } // used to return \"new\", hopefully the change doesn't come back to haunt us\n      return cellObs();\n    };\n  }\n}\n\n// === Helpers:\n\n/**\n * Returns whether the first table is a summary of the second. If both are summary tables, returns true\n * if the second table is a more detailed summary, i.e. has additional group-by columns.\n * @param summary: TableRec for the table to check for being the summary table.\n * @param detail: TableRec for the table to check for being the detailed version.\n * @returns {Boolean} Whether the first argument is a summarized version of the second.\n */\nfunction isSummaryOf(summary: TableRec, detail: TableRec): boolean {\n  const summarySource = summary.summarySourceTable();\n  if (summarySource === detail.getRowId()) { return true; }\n  const detailSource = detail.summarySourceTable();\n  return (Boolean(summarySource) &&\n    detailSource === summarySource &&\n    summary.getRowId() !== detail.getRowId() &&\n    gutil.isSubset(summary.summarySourceColRefs(), detail.summarySourceColRefs()));\n}\n\n/**\n * When TableA is a summary of TableB, each of TableA.groupByCols corresponds to a specific col of TableB\n * This function returns the column of B that corresponds to a particular groupByCol of A\n * - If A is a direct summary of B, then the corresponding col for A.someCol is A.someCol.summarySource()\n * - However if A and B are both summaries of C, then A.someCol.summarySource() would\n *   give us C.someCol, but what we actually want is B.someCol.\n * - Since we know A is a summary of B, then B's groupByCols must include all of A's groupbycols,\n *   so we can get B.someCol by matching on colId.\n * @param srcGBCol: ColumnRec, must be a groupByColumn, and srcGBCol.table() must be a summary of tgtTable\n * @param tgtTable: TableRec to get corresponding column from\n * @returns {ColumnRec} The corresponding column of tgtTable\n */\nfunction summaryGetCorrespondingCol(srcGBCol: ColumnRec, tgtTable: TableRec): ColumnRec {\n  if (!isSummaryOf(srcGBCol.table(), tgtTable)) {\n    throw Error(\"ERROR in LinkingState summaryGetCorrespondingCol: srcTable must be summary of tgtTable\");\n  }\n\n  if (tgtTable.summarySourceTable() === 0) { // if direct summary\n    return srcGBCol.summarySource();\n  } else { // else summary->summary, match by colId\n    const srcColId = srcGBCol.colId();\n    const retVal = tgtTable.groupByColumns().find(tgtCol => tgtCol.colId() === srcColId); // should always exist\n    if (!retVal) { throw Error(\"ERROR in LinkingState summaryGetCorrespondingCol: summary table lacks groupby col\"); }\n    return retVal;\n  }\n}\n"
  },
  {
    "path": "app/client/components/Login.css",
    "content": ".login-services {\n  margin: 0 15%;\n}\n\n.login-btns > .kf_elem {\n  flex: 1 1 100%;\n}\n\n.login-spacer {\n  height: 10px;\n}\n\n.login-divider {\n  height: 0;\n  width: 100%;\n  margin-top: 20px;\n  border-bottom: 1px solid #ccc;\n  margin-bottom: 20px;\n}\n\n.login-divider-text {\n  text-align: center;\n  display: inline-block;\n  position: absolute;\n  font-size: 1.2rem;\n  margin: -0.7rem auto 0 auto;\n  left: 0;\n  right: 0;\n  background-color: white;\n  width: 50px;\n  color: #aaa;\n}\n\n.login-error-notify {\n  background-color: #fdd;\n  padding: 10px;\n  margin: 10px 20px;\n  border: 1px solid #daa;\n}\n\n.login-success-notify {\n  background-color: #dfd;\n  padding: 10px;\n  margin: 10px 20px;\n  border: 1px solid #ada;\n}\n\n.login-send-code-box {\n  text-align: center;\n  margin-top: 10px;\n}\n\n.login-send-code {\n  display: inline-block;\n  color: #337ab7;\n  cursor: pointer;\n}\n\n.profile-row {\n  font-size: 1.2rem;\n}\n\n.edit-profile.btn {\n  background: none;\n  box-shadow: none;\n}\n\n.edit-profile.btn:hover {\n  color: #aaa;\n}\n\n.edit-profile.btn:active {\n  outline: none;\n}\n\n.edit-profile-form {\n  margin: 10px;\n  padding: 10px;\n  border-top: 1px solid #ccc;\n  border-bottom: 1px solid #ccc;\n}\n"
  },
  {
    "path": "app/client/components/ParseOptions.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { markdown } from \"app/client/lib/markdown\";\nimport { bigBasicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { squareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { cssModalButtons } from \"app/client/ui2018/modals\";\nimport { ParseOptionSchema } from \"app/plugin/FileParserAPI\";\n\nimport { Computed, dom, DomContents, IDisposableOwner, input, Observable, styled } from \"grainjs\";\nimport fromPairs from \"lodash/fromPairs\";\nimport invert from \"lodash/invert\";\n\nexport type ParseOptionValueType = boolean | string | number;\n\nconst t = makeT(\"ParseOptions\");\n\nexport interface ParseOptionValues {\n  [name: string]: ParseOptionValueType;\n}\n\n/**\n * EscapeChars contains mapping for some escape characters that we need to convert\n * for displaying in input fields\n */\ninterface EscapeChars {\n  [char: string]: string;\n}\n\nconst escapeCharDict: EscapeChars = {\n  \"\\n\": \"\\\\n\",\n  \"\\r\": \"\\\\r\",\n  \"\\t\": \"\\\\t\",\n};\nconst invertedEscapeCharDict: EscapeChars = invert(escapeCharDict);\n\n// Helpers to escape and unescape certain non-printable characters that are useful in parsing\n// options, e.g. as separators.\nfunction escapeChars(value: string) {\n  return value.replace(/[\\n\\r\\t]/g, match => escapeCharDict[match]);\n}\nfunction unescapeChars(value: string) {\n  return value.replace(/\\\\[nrt]/g, match => invertedEscapeCharDict[match]);\n}\n\n/**\n * Builds a DOM form consisting of inputs built according to schema, with the passed-in values.\n * The included \"Update\" button is enabled if any value has changed, and calls doUpdate() with the\n * current values.\n */\nexport function buildParseOptionsForm(\n  owner: IDisposableOwner,\n  schema: ParseOptionSchema[],\n  values: ParseOptionValues,\n  doUpdate: (v: ParseOptionValues) => void,\n  doCancel: () => void,\n): DomContents {\n  const items = schema.filter(item => item.visible);\n  const optionsMap = new Map<string, Observable<ParseOptionValueType>>(\n    items.map(item => [item.name, Observable.create(owner, values[item.name])]));\n\n  function collectParseOptions(): ParseOptionValues {\n    return fromPairs(items.map(item => [item.name, optionsMap.get(item.name)!.get()]));\n  }\n\n  const labelsByName: { [key: string]: string } = {\n    lineterminator: t(\"Line terminator\"),\n    include_col_names_as_headers: t(\"First row contains headers\"),\n    delimiter: t(\"Field separator\"),\n    skipinitialspace: t(\"Skip leading whitespace\"),\n    quotechar: t(\"Quote character\"),\n    doublequote: t(\"Quotes in fields are doubled\"),\n    quoting: t(\"Convert quoted fields\"),\n    escapechar: t(\"Escape character\"),\n    start_with_row: t(\"Start with row\"),\n    NUM_ROWS: t(\"Number of rows\"),\n    encoding: t(\"Character encoding. See [the supported codecs]({{link}})\", { link: \"https://tinyurl.com/py3codecs\" }),\n  };\n\n  return [\n    cssParseOptionForm(\n      items.map(item => cssParseOption(\n        cssParseOptionName(markdown(labelsByName[item.name])),\n        optionToInput(owner, item.type, optionsMap.get(item.name)!),\n        testId(\"parseopts-opt\"),\n      )),\n    ),\n    cssModalButtons(\n      dom.domComputed(use => items.every(item => use(optionsMap.get(item.name)!) === values[item.name]),\n        unchanged => (unchanged ?\n          bigBasicButton(t(\"Close\"), dom.on(\"click\", doCancel), testId(\"parseopts-back\")) :\n          bigPrimaryButton(t(\"Update preview\"), dom.on(\"click\", () => doUpdate(collectParseOptions())),\n            testId(\"parseopts-update\"))\n        ),\n      ),\n    ),\n  ];\n}\n\nfunction optionToInput(owner: IDisposableOwner, type: string, value: Observable<ParseOptionValueType>): HTMLElement {\n  switch (type) {\n    case \"boolean\": return squareCheckbox(value as Observable<boolean>);\n    default: {\n      const obs = Computed.create(owner, use => escapeChars(String(use(value) || \"\")))\n        .onWrite(val => value.set(unescapeChars(val)));\n      return cssInputText(obs, { onInput: true },\n        dom.on(\"focus\", (ev, elem) => elem.select()));\n    }\n  }\n}\n\nconst cssParseOptionForm = styled(\"div\", `\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: space-between;\n  padding: 16px 0;\n  width: 400px;\n  overflow-y: auto;\n`);\nconst cssParseOption = styled(\"div\", `\n  flex: none;\n  margin: 8px 0;\n  width: calc(50% - 16px);\n  font-weight: initial;   /* negate bootstrap */\n`);\nconst cssParseOptionName = styled(\"div\", `\n  margin-bottom: 8px;\n`);\nconst cssInputText = styled(input, `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  position: relative;\n  display: inline-block;\n  outline: none;\n  height: 28px;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  padding: 0 6px;\n  width: 100%;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/components/PluginScreen.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { bigBasicButton } from \"app/client/ui2018/buttons\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { cssModalButtons, cssModalTitle, IModalControl, IModalOptions, modal } from \"app/client/ui2018/modals\";\nimport { PluginInstance } from \"app/common/PluginInstance\";\nimport { RenderTarget } from \"app/plugin/RenderOptions\";\n\nimport { Disposable, dom, DomContents, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"PluginScreen\");\n\n/**\n * Rendering options for the PluginScreen modal.\n */\nexport interface RenderOptions {\n  // Maximizes modal to fill the viewport.\n  fullscreen?: boolean;\n  fullbody?: boolean;\n}\n\n/**\n * Helper for showing plugin components during imports.\n */\nexport class PluginScreen extends Disposable {\n  private _openModalCtl: IModalControl | null = null;\n  private _importerContent = Observable.create<DomContents>(this, null);\n  private _fullscreen = Observable.create(this, false);\n  private _fullbody = Observable.create(this, false);\n\n  constructor(private _title: string) {\n    super();\n  }\n\n  // The importer state showing the inline element from the plugin (e.g. to enter URL in case of\n  // import-from-url).\n  public renderContent(inlineElement: HTMLElement) {\n    this.render([this._buildModalTitle(), inlineElement]);\n  }\n\n  // registers a render target for plugin to render inline.\n  public renderPlugin(plugin: PluginInstance): RenderTarget {\n    const handle: RenderTarget = plugin.addRenderTarget((el, opt = {}) => {\n      el.style.width = \"100%\";\n      el.style.height = opt.height || \"200px\";\n      this.renderContent(el);\n    });\n    return handle;\n  }\n\n  public render(content: DomContents, options?: RenderOptions) {\n    this._fullscreen.set(Boolean(options?.fullscreen));\n    this._fullbody.set(Boolean(options?.fullbody));\n    this.showImportDialog();\n    this._importerContent.set(content);\n  }\n\n  // The importer state showing just an error.\n  public renderError(message: string) {\n    this._fullbody.set(false);\n    this.render([\n      this._buildModalTitle(),\n      cssModalBody(t(\"Import failed: \"), message, testId(\"importer-error\")),\n      cssModalButtons(\n        bigBasicButton(\"Close\",\n          dom.on(\"click\", () => this.close()),\n          testId(\"modal-cancel\"))),\n    ]);\n  }\n\n  // The importer state showing just a spinner, when the user has to wait. We don't even let the\n  // user cancel it, because the cleanup can only happen properly once the wait completes.\n  public renderSpinner() {\n    this._fullbody.set(false);\n    this.render([this._buildModalTitle(), cssSpinner(loadingSpinner())]);\n  }\n\n  public close() {\n    this._openModalCtl?.close();\n    this._openModalCtl = null;\n  }\n\n  public showImportDialog(options?: IModalOptions) {\n    if (this._openModalCtl) { return; }\n    modal((ctl, ctlOwner) => {\n      this._openModalCtl = ctl;\n\n      // Make sure we are close when parent is closed.\n      this.onDispose(() => {\n        if (ctlOwner.isDisposed()) { return; }\n        ctl.close();\n      });\n\n      return [\n        cssModalOverrides.cls(\"\"),\n        cssModalOverrides.cls(\"-fullscreen\", this._fullscreen),\n        cssModalOverrides.cls(\"-fullbody\", this._fullbody),\n        dom.domComputed(this._importerContent),\n        testId(\"importer-dialog\"),\n      ];\n    }, {\n      noClickAway: true,\n      noEscapeKey: true,\n      ...options,\n    });\n  }\n\n  private _buildModalTitle(rightElement?: DomContents) {\n    return cssModalHeader(cssModalTitle(this._title), rightElement);\n  }\n}\n\nconst cssModalOverrides = styled(\"div\", `\n  max-height: calc(100% - 32px);\n  display: flex;\n  flex-direction: column;\n  & > .${cssModalButtons.className} {\n    margin-top: 16px;\n  }\n\n  &-fullscreen {\n    height: 100%;\n    margin: 32px;\n  }\n\n  &-fullbody {\n    padding: 0px;\n    background-color: ${theme.importerOutsideBg};\n  }\n`);\n\nconst cssModalBody = styled(\"div\", `\n  padding: 16px 0;\n  overflow-y: auto;\n  max-width: 470px;\n  white-space: pre-line;\n`);\n\nconst cssModalHeader = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 16px;\n  & > .${cssModalTitle.className} {\n    margin-bottom: 0px;\n  }\n`);\n\nconst cssSpinner = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  height: 80px;\n  margin: auto;\n`);\n"
  },
  {
    "path": "app/client/components/Printing.css",
    "content": "@media print {\n  /* Various style overrides needed to print a single section (page widget). */\n\n  .print-hide {\n    display: none;\n  }\n\n  .print-force-hide {\n    display: none !important;\n  }\n\n  .print-parent {\n    display: block !important;\n    position: relative !important;\n    height: max-content !important;\n    overflow: visible !important;\n  }\n\n  .print-widget {\n    margin: 0px !important;\n  }\n  .print-row {\n    break-inside: avoid;\n  }\n\n  .print-widget .viewsection_title {\n    display: none !important;\n  }\n  .print-widget .view_data_pane_container {\n    border: none !important;\n  }\n  .print-widget .viewsection_content {\n    margin: 0px !important;\n  }\n\n  .print-widget .detailview_single {\n    overflow: visible;\n  }\n\n  .print-widget .gridview_data_pane {\n    display: block !important;\n    position: relative !important;\n    height: max-content !important;\n    overflow: visible !important;\n  }\n\n  .print-widget .scrolly_outer {\n    display: none;\n  }\n\n  .print-widget .custom_view {\n    height: calc(100vh - 24px);\n  }\n\n  .ui-resizable-handle {\n    display: none !important;\n  }\n\n  .viewsection_content .filter_bar {\n    display: none !important;\n  }\n}\n\n/*\n * The chart div needs to be measured before its relayout() call, and \"@media print\" is not in\n * effect for that measurement, so we temporarily resize the chart for all @media, to a plausible\n * size for printing.\n */\n.print-widget .chart_container {\n  width: 6.5in !important;\n  height: 6.5in !important;\n  overflow: visible !important;\n}\n\n@media not print {\n  .print-all-rows {\n    display: none;\n  }\n\n  .screen-force-hide {\n    display: none !important;\n  }\n}\n"
  },
  {
    "path": "app/client/components/Printing.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport { CustomView } from \"app/client/components/CustomView\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport DataTableModel from \"app/client/models/DataTableModel\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { prefersColorSchemeDark, prefersColorSchemeDarkObs } from \"app/client/ui2018/theme\";\n\nimport { dom } from \"grainjs\";\n\ntype RowId = number | \"new\";\n\nfunction getViewSectionContent(viewInstance: BaseView | null) {\n  const sectionElem = viewInstance?.viewPane?.closest(\".viewsection_content\");\n  if (!sectionElem) {\n    throw new Error(\"No page widget to print\");\n  }\n  return sectionElem;\n}\n\n/**\n * Print the specified viewSection (aka page widget). We use the existing view instance rather\n * than render a new one, since it may have state local to this instance view, such as current\n * filters.\n *\n * Views get a chance to render things specially for printing (which is needed when they use\n * scrolly for normal rendering).\n *\n * To let an existing view print across multiple pages, we can't have it nested in a flexbox or a\n * div with 'height: 100%'. We achieve it by forcing all parents of our view to have a simple\n * layout. This is potentially fragile.\n */\nexport async function printViewSection(layout: any, viewSection: ViewSectionRec) {\n  const viewInstance = viewSection.viewInstance.peek();\n  const sectionElem = getViewSectionContent(viewInstance);\n  if (viewInstance instanceof CustomView) {\n    try {\n      await viewInstance.triggerPrint();\n      return;\n    } catch (e) {\n      console.warn(`Failed to trigger print in CustomView: ${e}`);\n      // continue on to trying to print from outside, which should work OK for a single page.\n    }\n  }\n\n  function prepareToPrint(onOff: boolean) {\n    // Make it known to other code that we are printing. This is currently only relied on for a\n    // workaround to a Chrome printing bug in sanitizeHTML.ts.\n    (window as any).isCurrentlyPrinting = onOff;\n\n    // window.print() is a blocking call, which means our listener for the\n    // `prefers-color-scheme: dark` media feature will not receive any updates for the\n    // duration that the print dialog is shown. This proves problematic since an event is\n    // sent just before the blocking call containing a value of false, regardless of the\n    // user agent's color scheme preference. It's not clear why this happens, but the result\n    // is Grist temporarily reverting to the light theme until the print dialog is dismissed.\n    // As a workaround, we'll temporarily pause our listener, and unpause after the print dialog\n    // is dismissed.\n    prefersColorSchemeDarkObs().pause();\n\n    // Hide all layout boxes that do NOT contain the section to be printed.\n    layout?.forEachBox((box: any) => {\n      if (!box.dom.contains(sectionElem)) {\n        box.dom.classList.toggle(\"print-hide\", onOff);\n      }\n    });\n\n    // Mark the section to be printed.\n    sectionElem.classList.toggle(\"print-widget\", onOff);\n\n    // Let the view instance update its rendering, e.g. to render all rows when scrolly is in use.\n    viewInstance?.prepareToPrint(onOff);\n\n    // If .print-all-rows element is present (created for scrolly-based views), use it as the\n    // start element for the loop below, to ensure it's rendered flexbox-free.\n    const keyElem = sectionElem.querySelector(\".print-all-rows\") || sectionElem;\n\n    // Go through all parents of the element to be printed. For @media print, we override their\n    // layout in a heavy-handed way, forcing them all to be non-flexbox and sized to content,\n    // since our normal flexbox-based layout is sized to screen and would not print multiple pages.\n    let elem = keyElem.parentElement;\n    while (elem) {\n      elem.classList.toggle(\"print-parent\", onOff);\n      elem = elem.parentElement;\n    }\n  }\n\n  const sub1 = dom.onElem(window, \"beforeprint\", () => prepareToPrint(true));\n  const sub2 = dom.onElem(window, \"afterprint\", (window as any).afterPrintCallback = () => {\n    sub1.dispose();\n    sub2.dispose();\n    // To debug printing, set window.debugPrinting=1 in the console, then print a section, dismiss\n    // the print dialog, switch to \"@media print\" emulation, and you can explore the styles. You'd\n    // need to call window.finishPrinting() or reload the page to do it again.\n    if ((window as any).debugPrinting) {\n      (window as any).finishPrinting = () => prepareToPrint(false);\n    } else {\n      prepareToPrint(false);\n    }\n    delete (window as any).afterPrintCallback;\n    prefersColorSchemeDarkObs().pause(false);\n\n    // This may have changed while window.print() was blocking.\n    prefersColorSchemeDarkObs().set(prefersColorSchemeDark());\n  });\n\n  // Running print on a timeout makes it possible to test printing using selenium, and doesn't\n  // seem to affect normal printing.\n  setTimeout(() => window.print(), 0);\n}\n\n/**\n * Produces a div with all requested rows using the same renderRow() function as used with scrolly\n * for dynamically rendered views. This is used for printing, so these rows do not subscribe to\n * data.\n *\n * To avoid creating a lot of subscriptions when rendering rows this way, we render one DOM row at\n * a time, copy the produced HTML, and dispose the produced DOM.\n */\nexport function renderAllRows(\n  tableModel: DataTableModel, rowIds: RowId[], renderRow: (r: DataRowModel) => Element,\n) {\n  const rowModel = tableModel.createFloatingRowModel(null) as DataRowModel;\n  const html: string[] = [];\n  rowIds.forEach((rowId, index) => {\n    if (rowId !== \"new\") {\n      rowModel._index(index);\n      rowModel.assign(rowId);\n      const elem = renderRow(rowModel);\n      html.push(`<div class=\"print-row\">${elem.outerHTML}</div>`);\n      dom.domDispose(elem);\n    }\n  });\n  rowModel.dispose();\n  const result = dom(\"div.print-all-rows\");\n  result.innerHTML = html.join(\"\\n\");\n  return result;\n}\n"
  },
  {
    "path": "app/client/components/RawDataPage.ts",
    "content": "import { buildViewSectionDom } from \"app/client/components/buildViewSectionDom\";\nimport * as commands from \"app/client/components/commands\";\nimport { DataTables } from \"app/client/components/DataTables\";\nimport { DocumentUsage } from \"app/client/components/DocumentUsage\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { printViewSection } from \"app/client/components/Printing\";\nimport { ViewSectionHelper } from \"app/client/components/ViewLayout\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { reportError } from \"app/client/models/errors\";\nimport { getTelemetryWidgetTypeFromVS } from \"app/client/ui/widgetTypesMap\";\nimport { mediaSmall, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\n\nimport { Computed, Disposable, dom, fromKo, makeTestId, Observable, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-raw-data-\");\n\nexport class RawDataPage extends Disposable {\n  private _lightboxVisible: Observable<boolean>;\n  constructor(private _gristDoc: GristDoc) {\n    super();\n    const commandGroup = {\n      printSection: () => { printViewSection(null, this._gristDoc.viewModel.activeSection()).catch(reportError); },\n    };\n    this.autoDispose(commands.createGroup(commandGroup, this, true));\n    this._lightboxVisible = Computed.create(this, (use) => {\n      const section = use(this._gristDoc.viewModel.activeSection);\n      return Boolean(use(section.id)) && (use(section.isRaw) || use(section.isRecordCard));\n    });\n    // When we are disposed, we want to clear active section in the viewModel we got (which is an empty model)\n    // to not restore the section when user will come back to Raw Data page.\n    // But by the time we are gone (disposed), active view will be changed, so here we will save the reference.\n    // TODO: empty view should rather have id = 0, not undefined. Should be fixed soon.\n    const emptyView = this._gristDoc.docModel.views.rowModels.find(x => x.id.peek() === undefined);\n    this.autoDispose(this._gristDoc.activeViewId.addListener(() => {\n      emptyView?.activeSectionId(0);\n    }));\n    // Whenever we close lightbox, clear cursor monitor state.\n    this.autoDispose(this._lightboxVisible.addListener((state) => {\n      if (!state) {\n        this._gristDoc.cursorMonitor.clear();\n      }\n    }));\n  }\n\n  public buildDom() {\n    return cssContainer(\n      cssPage(\n        dom(\"div\", this._gristDoc.behavioralPromptsManager.attachPopup(\"rawDataPage\", { hideArrow: true })),\n        dom(\"div\",\n          dom.create(DataTables, this._gristDoc),\n          dom.create(DocumentUsage, this._gristDoc.docPageModel),\n        ),\n        // We are hiding it, because overlay doesn't have a z-index (it conflicts with a searchbar and list buttons)\n        dom.hide(this._lightboxVisible),\n      ),\n      /** *************  Lightbox section **********/\n      dom.domComputed(fromKo(this._gristDoc.viewModel.activeSection), (viewSection) => {\n        const sectionId = viewSection.getRowId();\n        if (!sectionId || (!viewSection.isRaw.peek() && !viewSection.isRecordCard.peek())) {\n          return null;\n        }\n        return dom.create(RawDataPopup, this._gristDoc, viewSection, () => this._close());\n      }),\n    );\n  }\n\n  private _close() {\n    this._gristDoc.viewModel.activeSectionId(0);\n  }\n}\n\nexport class RawDataPopup extends Disposable {\n  constructor(\n    private _gristDoc: GristDoc,\n    private _viewSection: ViewSectionRec,\n    private _onClose: () => void,\n  ) {\n    super();\n    const commandGroup = {\n      cancel: () => { this._onClose(); },\n      deleteSection: () => {\n        // Normally this command is disabled on the menu, but for collapsed section it is active.\n        if (this._viewSection.isRaw.peek()) {\n          throw new Error(\"Can't delete a raw section\");\n        }\n\n        const widgetType = getTelemetryWidgetTypeFromVS(this._viewSection);\n        logTelemetryEvent(\"deletedWidget\", { full: { docIdDigest: this._gristDoc.docId(), widgetType } });\n\n        this._gristDoc.docData.sendAction([\"RemoveViewSection\", this._viewSection.id.peek()]).catch(reportError);\n      },\n    };\n    this.autoDispose(commands.createGroup(commandGroup, this, true));\n  }\n\n  public buildDom() {\n    ViewSectionHelper.create(this, this._gristDoc, this._viewSection);\n    return cssOverlay(\n      testId(\"overlay\"),\n      cssSectionWrapper(\n        buildViewSectionDom({\n          gristDoc: this._gristDoc,\n          sectionRowId: this._viewSection.getRowId(),\n          draggable: false,\n          focusable: false,\n          // Expanded, non-raw widgets are also rendered in RawDataPopup.\n          widgetNameHidden: this._viewSection.isRaw.peek(),\n          renamable: !this._viewSection.isRecordCard.peek(),\n        }),\n      ),\n      cssCloseButton(\"CrossBig\",\n        testId(\"close-button\"),\n        dom.on(\"click\", () => this._onClose()),\n      ),\n      // Close the lightbox when user clicks exactly on the overlay.\n      dom.on(\"click\", (ev, elem) => void (ev.target === elem ? this._onClose() : null)),\n    );\n  }\n}\n\nconst cssContainer = styled(\"div\", `\n  height: 100%;\n  overflow: hidden;\n  inset: 0px;\n  position: absolute;\n`);\n\nconst cssPage = styled(\"div\", `\n  overflow-y: auto;\n  height: 100%;\n  padding: 32px 64px 24px 64px;\n  @media ${mediaSmall} {\n    & {\n      padding: 32px 24px 24px 24px;\n    }\n  }\n`);\n\nexport const cssOverlay = styled(\"div\", `\n  background-color: ${theme.modalBackdrop};\n  inset: 0px;\n  padding: 20px 56px 20px 56px;\n  position: absolute;\n  z-index: ${vars.popupSectionBackdropZIndex};\n  @media ${mediaSmall} {\n    & {\n      padding: 22px;\n      padding-top: 30px;\n    }\n  }\n`);\n\nconst cssSectionWrapper = styled(\"div\", `\n  background: ${theme.mainPanelBg};\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  border-radius: 5px;\n  border-bottom-left-radius: 0px;\n  border-bottom-right-radius: 0px;\n  & .viewsection_content {\n    margin: 0px;\n    margin-top: 8px;\n  }\n  & .viewsection_title {\n    padding: 0px 12px;\n  }\n  & .filter_bar {\n    margin-left: 6px;\n  }\n`);\n\nexport const cssCloseButton = styled(icon, `\n  position: absolute;\n  top: 16px;\n  right: 16px;\n  height: 24px;\n  width: 24px;\n  cursor: pointer;\n  --icon-color: ${theme.modalBackdropCloseButtonFg};\n  &:hover {\n    --icon-color: ${theme.modalBackdropCloseButtonHoverFg};\n  }\n  @media ${mediaSmall} {\n    & {\n      top: 6px;\n      right: 6px;\n    }\n  }\n`);\n"
  },
  {
    "path": "app/client/components/RecordCardPopup.ts",
    "content": "import { buildViewSectionDom } from \"app/client/components/buildViewSectionDom\";\nimport * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { cssCloseButton, cssOverlay } from \"app/client/components/RawDataPage\";\nimport { ViewSectionHelper } from \"app/client/components/ViewLayout\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { ChangeType, RowList } from \"app/client/models/rowset\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\n\nimport { dom, makeTestId, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-record-card-popup-\");\n\ninterface RecordCardPopupOptions {\n  gristDoc: GristDoc;\n  rowId: number;\n  viewSection: ViewSectionRec;\n  onClose(): void;\n}\n\nexport class RecordCardPopup extends DisposableWithEvents {\n  private _gristDoc = this._options.gristDoc;\n  private _rowId = this._options.rowId;\n  private _viewSection = this._options.viewSection;\n  private _tableModel = this._gristDoc.getTableModel(this._viewSection.table().tableId());\n  private _handleClose = this._options.onClose;\n\n  constructor(private _options: RecordCardPopupOptions) {\n    super();\n    const commandGroup = {\n      cancel: () => { this._handleClose(); },\n    };\n    this.autoDispose(commands.createGroup(commandGroup, this, true));\n\n    // Close the popup if the underlying row is removed.\n    const onRowChange = this._onRowChange.bind(this);\n    this._tableModel.on(\"rowChange\", onRowChange);\n    this.onDispose(() => this._tableModel.off(\"rowChange\", onRowChange));\n  }\n\n  public buildDom() {\n    ViewSectionHelper.create(this, this._gristDoc, this._viewSection);\n    return cssOverlay(\n      testId(\"overlay\"),\n      cssSectionWrapper(\n        buildViewSectionDom({\n          gristDoc: this._gristDoc,\n          sectionRowId: this._viewSection.getRowId(),\n          draggable: false,\n          focusable: false,\n          renamable: false,\n        }),\n        testId(\"wrapper\"),\n      ),\n      cssCloseButton(\"CrossBig\",\n        dom.on(\"click\", () => this._handleClose()),\n        testId(\"close\"),\n      ),\n      dom.on(\"click\", (ev, elem) => void (ev.target === elem ? this._handleClose() : null)),\n    );\n  }\n\n  private _onRowChange(type: ChangeType, rows: RowList) {\n    if (type === \"remove\" && [...rows].includes(this._rowId)) {\n      this._handleClose();\n    }\n  }\n}\n\nconst cssSectionWrapper = styled(\"div\", `\n  background: ${theme.mainPanelBg};\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  border-radius: 5px;\n  border-bottom-left-radius: 0px;\n  border-bottom-right-radius: 0px;\n  & .viewsection_content {\n    margin: 0px;\n    margin-top: 8px;\n  }\n  & .viewsection_title {\n    padding: 0px 12px;\n  }\n`);\n"
  },
  {
    "path": "app/client/components/RecordLayout.css",
    "content": ".g_record_layout_leaf {\n  width: 100%;\n}\n\n.g_record_layout_editing {\n  position: absolute;\n  top: 0px;\n  left: 0px;\n  width: 100%;\n  height: 100%;\n  cursor: move;\n  z-index: 5;\n\n  background-color: var(--grist-theme-card-editing-layout-bg, rgba(192, 192, 192, 0.2));\n  border-left: 1px solid var(--grist-theme-card-editing-layout-border, white);\n  border-top: 1px solid var(--grist-theme-card-editing-layout-border, white);\n  border-right: 1px solid var(--grist-theme-card-editing-layout-border, var(--grist-color-dark-grey));\n  border-bottom: 1px solid var(--grist-theme-card-editing-layout-border, var(--grist-color-dark-grey));\n}\n\n.dropdown-menu .g_record_layout_newfield {\n  margin: 2px 1rem;\n  padding: 0px 0.5rem;\n  border: 2px outset rgba(160, 160, 255, 0.5);\n  background-color: rgba(233, 233, 233, 0.5);\n  cursor: move;\n  color: #666;\n  font-size: 1.2rem;\n}\n\n.g_record_delete_field {\n  position: absolute;\n  top: 2px;\n  right: 2px;\n  border-radius: 1rem;\n  color: var(--grist-theme-control-secondary-fg, #404040);\n  cursor: pointer;\n\n  display: none;\n}\n\n.g_record_layout_editing:hover > .g_record_delete_field {\n  display: block;\n}\n"
  },
  {
    "path": "app/client/components/RecordLayout.js",
    "content": "/**\n * Module for displaying a record of user data in a two-dimentional editable layout.\n */\n\n\n// TODO:\n// 1. Consider a way to upgrade a file to add layoutSpec column to the ViewSections meta table.\n//    Plan: add docInfo schemaVersion field.\n//          when opening a file, let the sandbox check the version and check if loaded metadata matches the schema.\n//          sandbox should return doc-version, current-version, and match status.\n//          if current-version != doc_version [AND mismatch] (this is optional, let's think if we\n//              want that), then\n//            Sandbox creates new temp document\n//            Replays action log into it.\n//            Renames it over the old document. [Would be nice to ask the user first]\n//            Reopen document\n// 1. [LATER] Create RecordLayout file with APIs to support more efficient big list of laid-out\n//    records (so that a single RecordLayout can maintain many Layout instances).\n// 2. [LATER] Allow dragging in boxes from the view config.\n// 3. [LATER] Allow creating new field and inserting at the bottom.\n// 4. [LATER] Allow selecting existing field from context menu and inserting.\n// 5. [LATER] Add interface to Layout to tab forward and back, left, right, up, down, and use that in\n//    detail view.\n// 6. [LATER] Implement saving and loading of widths in the layout spec.\n\nvar _ = require(\"underscore\");\nvar ko = require(\"knockout\");\nvar Promise = require(\"bluebird\");\n\nvar gutil = require(\"app/common/gutil\");\nvar dispose = require(\"../lib/dispose\");\nvar dom = require(\"../lib/dom\");\nvar {Delay} = require(\"../lib/Delay\");\nvar kd = require(\"../lib/koDom\");\nvar {makeT} = require(\"../lib/localization\");\nvar Layout = require(\"./Layout\");\nvar RecordLayoutEditor = require(\"./RecordLayoutEditor\");\nvar commands = require(\"./commands\");\nvar {menuToggle} = require(\"app/client/ui/MenuToggle\");\nvar {menu} = require(\"../ui2018/menus\");\nvar {testId} = require(\"app/client/ui2018/cssVars\");\nvar {contextMenu} = require(\"app/client/ui/contextMenu\");\n\nconst t = makeT(\"RecordLayout\");\n\n/**\n * Construct a RecordLayout.\n * @param {MetaRowModel} options.viewSection: The model for the viewSection represented.\n * @param {Function} options.buildFieldDom: Function called with (viewField) that should\n *    return the DOM for that field.\n * @param {Function} options.resizeCallback: Optional function called with no arguments when\n *    the RecordLayout is modified in a way that may require resizing.\n */\nfunction RecordLayout(options) {\n  this.viewSection = options.viewSection;\n  this.buildFieldDom = options.buildFieldDom;\n  this.buildCardContextMenu = options.buildCardContextMenu;\n  this.buildFieldContextMenu = options.buildFieldContextMenu;\n  this.isEditingLayout = ko.observable(false);\n  this.editIndex = ko.observable(0);\n  this.layoutEditor = ko.observable(null);    // RecordLayoutEditor when one is active.\n\n  if (options.resizeCallback) {\n    this._resizeCallback = options.resizeCallback;\n    this._delayedResize = this.autoDispose(Delay.create());\n  }\n\n  // Observable object that will be rebuilt whenever the list of viewFields changes.\n  this.fieldsById = this.autoDispose(ko.computed(function() {\n    return _.indexBy(this.viewSection.viewFields().all(),\n      function(field) { return field.getRowId(); });\n  }, this));\n\n  // Update the stored layoutSpecObj with any missing fields that are present in viewFields.\n  this.layoutSpec = this.autoDispose(ko.computed(function() {\n    if (this.viewSection.isDisposed()) { return null; }\n    return RecordLayout.updateLayoutSpecWithFields(\n      this.viewSection.layoutSpecObj(), this.viewSection.viewFields().all());\n  }, this).extend({rateLimit: 0})); // layoutSpecObj and viewFields should be updated together.\n  this.autoDispose(this.layoutSpec.subscribe(() => this.resizeCallback()));\n\n  // TODO: We may want a context menu for each record, but the previous implementation wasn't\n  // working, and was creating a separate context menu for each row, which is very expensive. A\n  // better approach is to create a single context menu for the view section, as GridView does.\n}\ndispose.makeDisposable(RecordLayout);\n\nRecordLayout.prototype.resizeCallback = function() {\n  // Note that while editing layout, scrolly is hidden, and resizeCallback is unhelpful. We rely\n  // on explicit resizing when isEditLayout is reset.\n  if (!this.isDisposed() && this._delayedResize && !this.isEditingLayout.peek()) {\n    this._delayedResize.schedule(0, this._resizeCallback);\n  }\n};\n\nRecordLayout.prototype.getField = function(fieldRowId) {\n  // If fieldRowId is a string which includes \":\", then it's actually \"colRef:label:value\"\n  // placeholder that we use when adding a new field. If so, return a special object with the fields\n  // available. Note that virtual tables also produces string fieldRowId but they have no \":\".\n  if (typeof fieldRowId === \"string\" && fieldRowId.includes(\":\")) {\n    var parts = gutil.maxsplit(fieldRowId, \":\", 2);\n    return {\n      isNewField: true,        // To make it easy to distinguish from a ViewField MetaRowModel\n      colRef: parseInt(parts[0], 10),\n      label: parts[1],\n      value: parts[2]\n    };\n  }\n  return this.fieldsById()[fieldRowId];\n};\n\n\n/**\n * Sets the layout to being edited.\n */\nRecordLayout.prototype.editLayout = function(rowIndex) {\n  this.editIndex(rowIndex);\n  this.isEditingLayout(true);\n};\n\n/**\n * Ends layout editing, without updating the layout on the server.\n */\nRecordLayout.prototype.onEditLayoutCancel = function(layoutSpec) {\n  this.isEditingLayout(false);\n  // Call resizeCallback here, since it's possible that theme was also changed (and auto-saved)\n  // even though the layout itself was reverted.\n  this.resizeCallback();\n};\n\n/**\n * Ends layout editing, and saves the given layoutSpec to the server.\n */\nRecordLayout.prototype.onEditLayoutSave = async function(layoutSpec) {\n  try {\n    await this.saveLayoutSpec(layoutSpec);\n  } finally {\n    this.isEditingLayout(false);\n    this.resizeCallback();\n  }\n};\n\n/**\n * If there is no layout saved, we can create a default layout just from the list of fields for\n * this view section. By default we just arrange them into a list of rows, two fields per row.\n */\nRecordLayout.updateLayoutSpecWithFields = function(spec, viewFields) {\n  // We use tmpLayout as a way to manipulate the layout before we get a final spec from it.\n  var tmpLayout = Layout.Layout.create(spec, function(leafId) { return dom(\"div\"); });\n\n  var specFieldIds = tmpLayout.getAllLeafIds();\n  var viewFieldIds = viewFields.map(function(f) { return f.getRowId(); });\n\n  // For any stale fields (no longer among viewFields), remove them from tmpLayout.\n  _.difference(specFieldIds, viewFieldIds).forEach(function(leafId) {\n    tmpLayout.getLeafBox(leafId).dispose();\n  });\n\n  // For all fields that should be in the spec but aren't, add them to tmpLayout. We maintain a\n  // two-column layout, so add a new row, or a second box to the last row if it's a leaf.\n  _.difference(viewFieldIds, specFieldIds).forEach(function(leafId) {\n    var newBox = tmpLayout.buildLayoutBox({ leaf: leafId });\n    var rows = tmpLayout.rootBox().childBoxes.peek();\n    if (rows.length >= 1 && _.last(rows).isLeaf()) {\n      // Add a new child to the last row.\n      _.last(rows).addChild(newBox, true);\n    } else {\n      // Add a new row.\n      tmpLayout.rootBox().addChild(newBox, true);\n    }\n  });\n\n  spec = tmpLayout.getLayoutSpec();\n  tmpLayout.dispose();\n  return spec;\n};\n\n/**\n * Saves the layout spec as build by the user. This is quite involved, because it may need to\n * remove fields as well as create fields and possibly new columns. And it needs the results of\n * these operations to update the spec before saving it.\n */\nRecordLayout.prototype.saveLayoutSpec = async function(layoutSpec) {\n  // The layout hasn't actually changed. Skip the rest to avoid creating no-op actions (the\n  // resulting no-op undo would be particularly confusing).\n  if (JSON.stringify(layoutSpec) === this.viewSection.layoutSpec.peek()) {\n    return;\n  }\n\n  const docModel = this.viewSection._table.docModel;\n  const docData = docModel.docData;\n  const tableId = this.viewSection.table().tableId();\n  const getField = fieldRef => this.getField(fieldRef);\n  const addColAction = [\"AddColumn\", null, {}];\n\n  // Build a set of fieldRefs (i.e. rowIds) that are currently stored. Also build a map of colRef\n  // to fieldRef, so that we can restore a field that got removed and re-added (as a colRef).\n  var origRefs = [];\n  var colRefToFieldRef = new Map();\n  this.viewSection.viewFields().all().forEach(f => {\n    origRefs.push(f.getRowId());\n    colRefToFieldRef.set(f.colRef(), f.getRowId());\n  });\n\n  // Initialize leaf index counter and num cols to be added counter.\n  var nextPos = 0;\n  var addColNum = 0;\n\n  // Initialize arrays to keep track of existing field refs and their updated positions.\n  var existingRefs = [];\n  var existingPositions = [];\n\n  // Initialize arrays to keep track of added fields for existing but hidden columns.\n  var hiddenColRefs = [];\n  var hiddenCallbacks = [];\n  var hiddenPositions = [];\n\n  // Initialize arrays to keep track of newly added columns.\n  var addedCallbacks = [];\n  var addedPositions = [];\n\n  // Recursively process all layoutBoxes in the spec. Sets up bookkeeping arrays for\n  // existing fields and added fields for new/hidden cols from which the action bundle will\n  // be created.\n  function processBox(spec) {\n    // \"empty\" is a temporary placeholder used by LayoutEditor, and not a valid leaf.\n    if (spec.leaf && spec.leaf !== \"empty\") {\n      let pos = nextPos++;\n      let field = getField(spec.leaf);\n      let updateLeaf = ref => { spec.leaf = ref; };\n      if (!field.isNewField) {\n        // Existing field.\n        existingRefs.push(field.getRowId());\n        existingPositions.push(pos);\n      } else if (colRefToFieldRef.has(field.colRef)) {\n        // Existing field that got removed and re-added.\n        let fieldRef = colRefToFieldRef.get(field.colRef);\n        existingRefs.push(fieldRef);\n        existingPositions.push(pos);\n        updateLeaf(fieldRef);\n      } else if (Number.isNaN(field.colRef)) {\n        // We need to add a new column AND field.\n        addColNum++;\n        addedCallbacks.push(updateLeaf);\n        addedPositions.push(pos);\n      } else {\n        // We need to add a field for an existing column.\n        hiddenColRefs.push(field.colRef);\n        hiddenCallbacks.push(updateLeaf);\n        hiddenPositions.push(pos);\n      }\n    }\n    if (spec.children) {\n      spec.children.map(processBox);\n    }\n  }\n  processBox(layoutSpec);\n\n  // Combine data for item which require both new columns and new fields and only new fields,\n  // with items which require new columns first.\n  let callbacks = addedCallbacks.concat(hiddenCallbacks);\n  let positions = addedPositions.concat(hiddenPositions);\n\n  // Use separate copies of addColAction, since sendTableActions modified each in-place.\n  let addActions = gutil.arrayRepeat(addColNum, 0).map(() => addColAction.slice());\n\n  await docData.bundleActions(t(\"Updating record layout.\"), () => {\n    return Promise.try(() => {\n      return addColNum > 0 ? docModel.dataTables[tableId].sendTableActions(addActions) : [];\n    })\n      .then(results => {\n        let colRefs = results.map(r => r.colRef).concat(hiddenColRefs);\n        const addFieldNum = colRefs.length;\n        // Add fields for newly added columns and previously hidden columns.\n        return addFieldNum > 0 ?\n          docModel.viewFields.sendTableAction([\"BulkAddRecord\", gutil.arrayRepeat(addFieldNum, null), {\n            parentId: gutil.arrayRepeat(addFieldNum, this.viewSection.getRowId()),\n            colRef: colRefs,\n            parentPos: positions\n          }]) : [];\n      })\n      .each((fieldRef, i) => {\n      // Call the stored callback for each fieldRef, which each set the correct layoutSpec leaf\n      // to the newly obtained fieldRef.\n        callbacks[i](fieldRef);\n      })\n      .then(addedRefs => {\n        let actions = [];\n\n        // Records present before that were not present after editing must be removed.\n        let finishedRefs = new Set(existingRefs.concat(addedRefs));\n        let removed = origRefs.filter(fieldRef => !finishedRefs.has(fieldRef));\n        if (removed.length > 0) {\n          actions.push([\"BulkRemoveRecord\", \"_grist_Views_section_field\", removed]);\n        }\n\n        // Positions must be updated for fields which were not added/removed.\n        if (existingRefs.length > 0) {\n          actions.push([\"BulkUpdateRecord\", \"_grist_Views_section_field\", existingRefs, {\n            \"parentPos\": existingPositions\n          }]);\n        }\n\n        // And update the layoutSpecObj itself.\n        actions.push([\"UpdateRecord\", \"_grist_Views_section\", this.viewSection.getRowId(), {\n          \"layoutSpec\": JSON.stringify(layoutSpec)\n        }]);\n\n        return docData.sendActions(actions);\n      });\n  });\n};\n\n/**\n * Builds the Layout dom for a single record.\n */\nRecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {\n  const createEditor = Boolean(optCreateEditor && !this.layoutEditor.peek());\n\n  const layout = Layout.Layout.create(this.layoutSpec(), (fieldRowId) =>\n    dom(\"div.g_record_layout_leaf.flexhbox.flexauto\",\n      this.buildFieldDom(this.getField(fieldRowId), row),\n      (createEditor ?\n        kd.maybe(this.layoutEditor, editor => editor.buildLeafDom()) :\n        null\n      )\n    )\n  );\n\n  const sub = this.layoutSpec.subscribe((spec) => { layout.buildLayout(spec, createEditor); });\n\n  if (createEditor) {\n    this.layoutEditor(RecordLayoutEditor.create(this, layout));\n  }\n\n  return dom(\"div.g_record_detail.flexauto\",\n    dom.autoDispose(layout),\n    dom.autoDispose(sub),\n    createEditor ? dom.onDispose(() => {\n      this.layoutEditor.peek().dispose();\n      this.layoutEditor(null);\n    }) : null,\n    // enables field context menu anywhere on the card\n    contextMenu(() => this.buildFieldContextMenu()),\n    dom(\"div.detail_row_num\",\n      kd.text(() => (row._index() + 1)),\n      dom.on(\"contextmenu\", ev => {\n        // This is a little hack to position the menu the same way as with a click,\n        // the same hack as on a column menu.\n        ev.preventDefault();\n        // prevent 2nd context menu to show up\n        ev.stopPropagation();\n        ev.currentTarget.querySelector(\".menu_toggle\").click();\n      }),\n      menuToggle(null,\n        dom.on(\"click\", () => {\n          this.viewSection.hasFocus(true);\n          commands.allCommands.setCursor.run(row);\n        }),\n        menu(() => this.buildCardContextMenu(row)),\n        testId(\"card-menu-trigger\")\n      )\n    ),\n    dom(\"div.g_record_detail_inner\", layout.rootElem)\n  );\n};\n\n/**\n * Returns the viewField row model for the field that the given DOM element belongs to.\n */\nRecordLayout.prototype.getContainingField = function(elem, optContainer) {\n  return this.getField(Layout.Layout.getContainingBox(elem, optContainer).leafId());\n};\n\n/**\n * Returns the RowModel for the record that the given DOM element belongs to.\n */\nRecordLayout.prototype.getContainingRow = function(elem, optContainer) {\n  var itemElem = dom.findAncestor(elem, optContainer, \".g_record_detail\");\n  return ko.utils.domData.get(itemElem, \"itemModel\");\n};\n\nmodule.exports = RecordLayout;\n"
  },
  {
    "path": "app/client/components/RecordLayoutEditor.js",
    "content": "var _ = require(\"underscore\");\nvar BackboneEvents = require(\"backbone\").Events;\n\nvar dispose = require(\"app/client/lib/dispose\");\nvar {makeT} = require(\"app/client/lib/localization\");\nvar commands = require(\"./commands\");\nvar LayoutEditor = require(\"./LayoutEditor\");\n\nconst t = makeT(\"RecordLayoutEditor\");\nconst {basicButton, cssButton, primaryButton} = require(\"app/client/ui2018/buttons\");\nconst {icon} = require(\"app/client/ui2018/icons\");\nconst {menu, menuDivider, menuItem} = require(\"app/client/ui2018/menus\");\nconst {testId} = require(\"app/client/ui2018/cssVars\");\nconst {dom, Observable, styled} = require(\"grainjs\");\n\n//----------------------------------------------------------------------\n\n/**\n * An extension of LayoutEditor which includes commands and the option for a callback function.\n *\n * Used by RecordLayout.js\n *\n * @param {layoutSpec} observable - An observable evaluating to the original layoutSpec of the layout.\n * @param {optResizeCallback} Function - An optional function to be called after every resize during\n *  layout editing.\n */\nfunction RecordLayoutEditor(recordLayout, layout, optResizeCallback) {\n  this.recordLayout = recordLayout;\n  this.layout = layout;\n  this.layoutEditor = this.autoDispose(LayoutEditor.LayoutEditor.create(layout));\n  this._hiddenColumns = this.autoDispose(Observable.create(null, this.getHiddenColumns()));\n\n  this.listenTo(layout, \"layoutChanged\", function() {\n    this._hiddenColumns.set(this.getHiddenColumns());\n  });\n\n  if (optResizeCallback) {\n    this.listenTo(layout, \"layoutChanged\", optResizeCallback);\n    this.listenTo(layout, \"layoutResized\", optResizeCallback);\n  }\n\n  // Command group implementing the commands available while editing the layout.\n  this.autoDispose(commands.createGroup(RecordLayoutEditor.editLayoutCommands, this, true));\n}\ndispose.makeDisposable(RecordLayoutEditor);\n_.extend(RecordLayoutEditor.prototype, BackboneEvents);\n\n\n/**\n * Commands active while editing the record layout.\n */\nRecordLayoutEditor.editLayoutCommands = {\n  accept: function() {\n    this.recordLayout.onEditLayoutSave(this.layout.getLayoutSpec());\n  },\n  cancel: function() {\n    this.layout.buildLayout(this.recordLayout.layoutSpec());\n    this.recordLayout.onEditLayoutCancel();\n  },\n};\n\n/**\n * Returns the list of columns that are not included in the current layout.\n */\nRecordLayoutEditor.prototype.getHiddenColumns = function() {\n  var included = new Set(this.layout.getAllLeafIds().map(function(leafId) {\n    var f = this.recordLayout.getField(leafId);\n    return f.isNewField ? f.colRef : f.colRef.peek();\n  }, this));\n  return this.recordLayout.viewSection.table().columns().all().filter(function(col) {\n    return !included.has(col.getRowId()) && !col.isHiddenCol();\n  });\n};\n\nRecordLayoutEditor.prototype._addField = function(leafId) {\n  var newBox = this.layout.buildLayoutBox({ leaf: leafId });\n  var rows = this.layout.rootBox().childBoxes.peek();\n  if (rows.length >= 1 && _.last(rows).isLeaf()) {\n    // Add a new child to the last row.\n    _.last(rows).addChild(newBox, true);\n  } else {\n    // Add a new row.\n    this.layout.rootBox().addChild(newBox, true);\n  }\n};\n\nRecordLayoutEditor.prototype.buildEditorDom = function() {\n  const addNewField = () => { this._addField(\":New_Field:\"); };\n  const showField = (col) => {\n    // Use setTimeout, since showing a field synchronously removes it from the list, which would\n    // prevent the menu from closing if we don't let the event to run its course.\n    setTimeout(() => this._addField(col.getRowId() + \":\" + col.label()), 0);\n  };\n\n  return cssControls(\n    basicButton(t(\"Add field\"), cssCollapseIcon(\"Collapse\"),\n      menu((ctl) => [\n        menuItem(() => addNewField(), t(\"Create new field\")),\n        dom.maybe((use) => use(this._hiddenColumns).length > 0,\n          () => menuDivider()),\n        dom.forEach(this._hiddenColumns, (col) =>\n          menuItem(() => showField(col), t(\"Show field {{- label}}\", {label:col.label()}))\n        ),\n        testId(\"edit-layout-add-menu\"),\n      ]),\n    ),\n\n    dom(\"div.flexauto\", {style: \"margin-left: 8px\"}),\n    this.buildFinishButtons(),\n    testId(\"edit-layout-controls\"),\n  );\n};\n\nRecordLayoutEditor.prototype.buildFinishButtons = function() {\n  return [\n    primaryButton(t(\"Save layout\"),\n      dom.on(\"click\", () => commands.allCommands.accept.run()),\n    ),\n    basicButton(t(\"Cancel\"),\n      dom.on(\"click\", () => commands.allCommands.cancel.run()),\n      {style: \"margin-left: 8px\"},\n    ),\n  ];\n};\n\nRecordLayoutEditor.prototype.buildLeafDom = function() {\n  return dom(\"div.layout_grabbable.g_record_layout_editing\",\n    cssIconEyeClose(\n      dom.on(\"mousedown\", (ev) => ev.stopPropagation()),\n      dom.on(\"click\", (ev, elem) => {\n        ev.preventDefault();\n        ev.stopPropagation();\n        const box = this.layoutEditor.getBoxFromElement(elem);\n        this.layoutEditor.removeContainingBox(box);\n      })\n    )\n  );\n};\n\nconst cssControls = styled(\"div\", `\n  display: flex;\n  align-items: flex-start;\n\n  & > .${cssButton.className} {\n    white-space: nowrap;\n    overflow: hidden;\n  }\n`);\n\nconst cssCollapseIcon = styled(icon, `\n  margin: -3px -2px -2px 2px;\n`);\n\nconst cssIconEyeClose = styled(\"div.g_record_delete_field\", `\n  &::before {\n    display: block;\n    background-color: var(--grist-color-dark-text);\n    content: ' ';\n    mask-image: var(--icon-EyeHide);\n    width: 14px;\n    height: 14px;\n    mask-size: contain;\n    mask-repeat: no-repeat;\n  }\n`\n);\n\nmodule.exports = RecordLayoutEditor;\n"
  },
  {
    "path": "app/client/components/RefSelect.ts",
    "content": "import { KoArray } from \"app/client/lib/koArray\";\nimport * as koArray from \"app/client/lib/koArray\";\nimport { makeT } from \"app/client/lib/localization\";\nimport * as tableUtil from \"app/client/lib/tableUtil\";\nimport { ColumnRec, DocModel, ViewFieldRec } from \"app/client/models/DocModel\";\nimport { KoSaveableObservable } from \"app/client/models/modelUtil\";\nimport { cssFieldEntry, cssFieldLabel } from \"app/client/ui/VisibleFieldsConfig\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu, menuItem, menuText } from \"app/client/ui2018/menus\";\nimport { FieldBuilder } from \"app/client/widgets/FieldBuilder\";\nimport * as gutil from \"app/common/gutil\";\n\nimport { Disposable, dom, fromKo, styled } from \"grainjs\";\nimport ko from \"knockout\";\n\nconst t = makeT(\"RefSelect\");\n\ninterface Item {\n  label: string;\n  value: string;\n}\n\n/**\n * Builder for the reference display multiselect.\n */\nexport class RefSelect extends Disposable {\n  public isForeignRefCol: ko.Computed<boolean>;\n  private _docModel: DocModel;\n  private _origColumn: ColumnRec;\n  private _colId: KoSaveableObservable<string>;\n  private _fieldObs: ko.Computed<ViewFieldRec | null>;\n  private _validCols: ko.Computed<ColumnRec[]>;\n  private _added: KoArray<Item>;\n  private _addedSet: ko.Computed<Set<string>>;\n\n  constructor(options: {\n    docModel: DocModel,\n    origColumn: ColumnRec,\n    fieldBuilder: ko.Computed<FieldBuilder | null>,\n  }) {\n    super();\n    this._docModel = options.docModel;\n    this._origColumn = options.origColumn;\n    this._colId = this._origColumn.colId;\n\n    // Indicates whether this is a ref col that references a different table.\n    // (That's the only time when RefSelect is offered.)\n    this.isForeignRefCol = this.autoDispose(ko.computed(() => {\n      const table = this._origColumn.refTable();\n      return Boolean(table && table.getRowId() !== this._origColumn.parentId());\n    }));\n\n    // Computed for the current fieldBuilder's field, if it exists.\n    this._fieldObs = this.autoDispose(ko.computed(() => {\n      const builder = options.fieldBuilder();\n      return builder ? builder.field : null;\n    }));\n\n    // List of valid cols in the currently referenced table.\n    this._validCols = this.autoDispose(ko.computed(() => {\n      const refTable = this._origColumn.refTable();\n      if (refTable) {\n        return refTable.columns().all().filter(col => !col.isHiddenCol() &&\n          !gutil.startsWith(col.type(), \"Ref:\"));\n      }\n      return [];\n    }));\n\n    // Returns the array of columns added to the multiselect. Used as a helper to create a synced KoArray.\n    const _addedObs = this.autoDispose(ko.computed(() => {\n      return this.isForeignRefCol() && this._fieldObs() ?\n        this._getReferencedCols(this._fieldObs()!).map(c => ({ label: c.label(), value: c.colId() })) : [];\n    }));\n\n    // KoArray of columns displaying data from the referenced table in the current section.\n    this._added = this.autoDispose(koArray.syncedKoArray(_addedObs));\n\n    // Set of added colIds.\n    this._addedSet = this.autoDispose(ko.computed(() => new Set(this._added.all().map(item => item.value))));\n  }\n\n  /**\n   * Builds the multiselect dom to select columns to added to the table to show data from the\n   * referenced table.\n   */\n  public buildDom() {\n    return cssFieldList(\n      testId(\"ref-select\"),\n      dom.forEach(fromKo(this._added.getObservable()), col =>\n        cssFieldEntry(\n          cssColumnLabel(dom.text(col.label)),\n          cssRemoveIcon(\"Remove\",\n            dom.on(\"click\", () => this._removeFormulaField(col)),\n            testId(\"ref-select-remove\"),\n          ),\n          testId(\"ref-select-item\"),\n        ),\n      ),\n      cssAddLink(cssAddIcon(\"Plus\"), t(\"Add column\"),\n        menu(() => [\n          ...this._validCols.peek()\n            .filter(col => !this._addedSet.peek().has(col.colId.peek()))\n            .map(col =>\n              menuItem(() => this._addFormulaField({ label: col.label(), value: col.colId() }),\n                col.label.peek()),\n            ),\n          cssEmptyMenuText(t(\"No columns to add\")),\n          testId(\"ref-select-menu\"),\n        ]),\n        testId(\"ref-select-add\"),\n      ),\n    );\n  }\n\n  /**\n   * Adds the column item to the multiselect. If the visibleCol is 'id', sets the visibleCol.\n   * Otherwise, adds a field which refers to the column to the table. If a column with the\n   * necessary formula exists, only adds a field to this section, otherwise adds the necessary\n   * column and field.\n   */\n  private async _addFormulaField(item: Item) {\n    const field = this._fieldObs();\n    if (!field) {\n      return;\n    }\n    const tableData = this._docModel.dataTables[this._origColumn.table().tableId()].tableData;\n    // Check if column already exists in the table\n    const cols = this._origColumn.table().columns().all();\n    const colMatch = cols.find(c => c.formula() === `$${this._colId()}.${item.value}` && !c.isHiddenCol());\n    // Get field position, so that the new field is inserted just after the current field.\n    const fields = field.viewSection().viewFields();\n    const index = fields.all()\n      .sort((a, b) => a.parentPos() > b.parentPos() ? 1 : -1)\n      .findIndex(f => f.getRowId() === field.getRowId());\n    const pos = tableUtil.fieldInsertPositions(fields, index + 1)[0];\n    let colAction: Promise<any> | undefined;\n    if (colMatch) {\n      // If column exists, use it.\n      colAction = Promise.resolve({ colRef: colMatch.getRowId(), colId: colMatch.colId() });\n    } else {\n      // If column doesn't exist, add it (without fields).\n      colAction = tableData.sendTableAction([\"AddColumn\", `${this._colId()}_${item.value}`, {\n        type: \"Any\",\n        isFormula: true,\n        formula: `$${this._colId()}.${item.value}`,\n        _position: pos,\n      }])!;\n    }\n    const colInfo = await colAction;\n    // Add field to the current section (if it isn't a raw data section - as this one will have\n    // this field already)\n    if (field.viewSection().isRaw()) { return; }\n    const fieldInfo = {\n      colRef: colInfo.colRef,\n      parentId: field.viewSection().getRowId(),\n      parentPos: pos,\n    };\n    return this._docModel.viewFields.sendTableAction([\"AddRecord\", null, fieldInfo]);\n  }\n\n  /**\n   * Removes the column item from the multiselect. If the item is the visibleCol, clears to show\n   * row id. Otherwise, removes all fields which refer to the column from the table.\n   */\n  private _removeFormulaField(item: Item) {\n    const tableData = this._docModel.dataTables[this._origColumn.table().tableId()].tableData;\n    // Iterate through all display fields in the current section.\n    this._getReferrerFields(item.value).forEach((refField) => {\n      const sectionId = this._fieldObs()!.viewSection().getRowId();\n      if (refField.column().viewFields().all()\n        .filter(field => !field.viewSection().isRaw() && !field.viewSection().isRecordCard())\n        .some(field => field.parentId() !== sectionId)) {\n        // The col has fields in other sections, remove only the fields in this section.\n        return this._docModel.viewFields.sendTableAction([\"RemoveRecord\", refField.getRowId()]);\n      } else {\n        // The col is only displayed in this section, remove the column.\n        return tableData.sendTableAction([\"RemoveColumn\", refField.column().colId()]);\n      }\n    });\n  }\n\n  /**\n   * Returns a list of fields in the current section whose formulas refer to 'colId' in the table this\n   * reference column refers to.\n   */\n  private _getReferrerFields(colId: string) {\n    const re = new RegExp(\"^\\\\$\" + this._colId() + \"\\\\.\" + colId + \"$\");\n    return this._fieldObs()!.viewSection().viewFields().all()\n      .filter(field => re.exec(field.column().formula()));\n  }\n\n  /**\n   * Returns a non-repeating list of columns in the referenced table referred to by fields in\n   * the current section.\n   */\n  private _getReferencedCols(field: ViewFieldRec) {\n    const matchesSet = this._getFormulaMatchSet(field);\n    return this._validCols().filter(c => matchesSet.has(c.colId()));\n  }\n\n  /**\n   * Helper function for getReferencedCols. Iterates through fields in\n   * the current section, returning a set of colIds which those fields' formulas refer to.\n   */\n  private _getFormulaMatchSet(field: ViewFieldRec) {\n    const fields = field.viewSection().viewFields().all();\n    const re = new RegExp(\"^\\\\$\" + this._colId() + \"\\\\.(\\\\w+)$\");\n    return new Set(fields.map((f) => {\n      const found = re.exec(f.column().formula());\n      return found ? found[1] : null;\n    }));\n  }\n}\n\nconst cssFieldList = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n\n  & > .${cssFieldEntry.className} {\n    margin: 2px 0;\n  }\n`);\n\nconst cssEmptyMenuText = styled(menuText, `\n  font-size: inherit;\n  &:not(:first-child) {\n    display: none;\n  }\n`);\n\nconst cssAddLink = styled(\"div\", `\n  display: flex;\n  cursor: pointer;\n  color: ${theme.controlFg};\n  --icon-color: ${theme.controlFg};\n\n  &:not(:first-child) {\n    margin-top: 8px;\n  }\n  &:hover, &:focus, &:active {\n    color: ${theme.controlHoverFg};\n    --icon-color: ${theme.controlHoverFg};\n  }\n`);\n\nconst cssAddIcon = styled(icon, `\n  margin-right: 4px;\n`);\n\nconst cssRemoveIcon = styled(icon, `\n  display: none;\n  cursor: pointer;\n  flex: none;\n  margin-left: 8px;\n  .${cssFieldEntry.className}:hover & {\n    display: block;\n  }\n`);\n\nconst cssColumnLabel = styled(cssFieldLabel, `\n  line-height: 16px;\n`);\n"
  },
  {
    "path": "app/client/components/RegionFocusSwitcher.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { kbFocusHighlighterClass } from \"app/client/components/KeyboardFocusHighlighter\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { isFocusable } from \"app/client/lib/isFocusable\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { trapTabKey } from \"app/client/lib/trapTabKey\";\nimport { App } from \"app/client/ui/App\";\nimport { SpecialDocPage } from \"app/common/gristUrls\";\nimport { mod } from \"app/common/gutil\";\nimport { components } from \"app/common/ThemePrefs\";\n\nimport { Disposable, dom, Holder, Observable, styled, UseCBOwner } from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\n\nconst t = makeT(\"RegionFocusSwitcher\");\n\nexport type Panel = \"left\" | \"top\" | \"right\" | \"main\";\ninterface PanelRegion {\n  type: \"panel\",\n  id: Panel // this matches a dom element id\n}\ninterface SectionRegion {\n  type: \"section\",\n  id?: number // this matches a grist document view section id. If none is provided, it means \"view layout\" is focused.\n}\ntype Region = PanelRegion | SectionRegion;\ntype StateUpdateInitiator = { type: \"cycle\" } | { type: \"mouse\", event?: MouseEvent };\ninterface State {\n  region?: Region;\n  initiator?: StateUpdateInitiator;\n}\n\n/**\n * RegionFocusSwitcher enables keyboard navigation between app panels and doc widgets.\n *\n * It also follow mouse clicks to focus panels accordingly.\n */\nexport class RegionFocusSwitcher extends Disposable {\n  // State with currently focused region\n  private readonly _state: Observable<State> = Observable.create(this, {\n    region: undefined,\n    initiator: undefined,\n  });\n\n  private get _gristDocObs() { return this._app?.pageModel?.gristDoc; }\n  // Previously focused elements for each panel (not used for view section ids)\n  private _prevFocusedElements: Record<Panel, Element | null> = {\n    left: null,\n    top: null,\n    right: null,\n    main: null,\n  };\n\n  // Command history exclusively here to warn the user about the creator panel shortcut if needed\n  private _commandsHistory: {\n    name: \"nextRegion\" | \"prevRegion\" | \"creatorPanel\",\n    timestamp: number\n  }[] = [];\n\n  private _warnedAboutCreatorPanel = false;\n\n  constructor(private _app?: App) {\n    super();\n    this.autoDispose(commands.createGroup({\n      nextRegion: () => {\n        this._logCommand(\"nextRegion\");\n        this._maybeNotifyAboutCreatorPanel();\n        return this._cycle(\"next\");\n      },\n      prevRegion: () => {\n        this._logCommand(\"prevRegion\");\n        this._maybeNotifyAboutCreatorPanel();\n        return this._cycle(\"prev\");\n      },\n      creatorPanel: () => {\n        this._logCommand(\"creatorPanel\");\n        return this._toggleCreatorPanel();\n      },\n      cancel: this._onEscapeKeypress.bind(this),\n    }, this, true));\n\n    this.autoDispose(this._state.addListener(this._onStateChange.bind(this)));\n\n    const focusActiveSection = () => this.focusActiveSection();\n    this._app?.on(\"clipboard_focus\", focusActiveSection);\n    this.onDispose(() => {\n      this._app?.off(\"clipboard_focus\", focusActiveSection);\n      this.reset();\n    });\n  }\n\n  /**\n   * This is expected to be called by an external component responsible for rendering the page.\n   *\n   * When calling this, we register the required dom event listeners on the page container when it's loaded.\n   * @param el - the element containing the page contents.\n   */\n  public onPageDomLoaded(el: HTMLElement) {\n    if (this._gristDocObs) {\n      const onClick = this._onClick.bind(this);\n      el.addEventListener(\"mouseup\", onClick);\n      this.onDispose(() => el.removeEventListener(\"mouseup\", onClick));\n    }\n  }\n\n  public panelAttrs(id: Panel, ariaLabel: string) {\n    return [\n      dom.attr(\"role\", id === \"main\" ?\n        \"main\" :\n        id === \"top\" ?\n          \"banner\" :\n          \"region\"),\n      dom.attr(\"aria-label\", ariaLabel),\n      dom.attr(ATTRS.regionId, id),\n      dom.cls(kbFocusHighlighterClass, (use) => {\n        // highlight focused elements everywhere except in the grist doc views\n        return id !== \"main\" ?\n          true :\n          this._canTabThroughMainRegion(use);\n      }),\n      dom.cls(\"clipboard_group_focus\", (use) => {\n        const gristDoc = this._gristDocObs ? use(this._gristDocObs) : null;\n        // if we are not on a grist doc, whole page is always focusable\n        if (!gristDoc) {\n          return true;\n        }\n        // on a grist doc, panel content is focusable only if it's the current region\n        const current = use(this._state).region;\n        if (current?.type === \"panel\" && current.id === id) {\n          return true;\n        }\n        // on a grist doc, main panel is focusable only if we are not the actual document view\n        if (id === \"main\") {\n          return this._canTabThroughMainRegion(use);\n        }\n        return false;\n      }),\n      cssFocusedPanel.cls(\"-focused\", (use) => {\n        const current = use(this._state);\n        return current.initiator?.type === \"cycle\" && current.region?.type === \"panel\" && current.region.id === id;\n      }),\n    ];\n  }\n\n  /**\n   * Get a normalized region id for the given region (or the current region if none given).\n   *\n   * Note: an active section, which is a grist doc view section, is exposed as the 'main' region.\n   */\n  public getRegionId(region?: Region) {\n    const state = region ?? this._state.get().region;\n    if (state?.type === \"panel\") {\n      return state.id;\n    }\n    return \"main\";\n  }\n\n  /**\n   * Focus a given region by id.\n   *\n   * If we want to focus the 'main' region on a grist doc, we actually focus the active view section.\n   */\n  public focusRegion(id: Panel) {\n    const gristDoc = this._getGristDoc();\n    if (gristDoc && id === \"main\") {\n      this.focusActiveSection();\n      return;\n    }\n    this._focusRegion({ type: \"panel\", id });\n  }\n\n  /**\n   * Focus the active section of the current grist doc.\n   *\n   * Difference with `focusRegion('main')` is that focus won't change if don't detect any active section.\n   */\n  public focusActiveSection() {\n    const gristDoc = this._getGristDoc();\n    if (gristDoc) {\n      this._focusRegion({ type: \"section\", id: gristDoc.viewModel.activeSectionId() });\n    }\n  }\n\n  /**\n   * Add a listener to the current region change.\n   * Exposes only the normalized region ids (current and previous)\n   */\n  public addListener(listener: (regionId: Panel, prevRegionId: Panel) => void) {\n    return this._state.addListener((state, prev) => {\n      const currentRegionId = this.getRegionId(state.region);\n      const prevRegionId = this.getRegionId(prev.region);\n      if (currentRegionId === prevRegionId) {\n        return;\n      }\n      listener(currentRegionId, prevRegionId);\n    });\n  }\n\n  public reset() {\n    this._focusRegion(undefined);\n  }\n\n  private _focusRegion(\n    region: Region | undefined,\n    options: { initiator?: StateUpdateInitiator } = {},\n  ) {\n    if (region?.type === \"panel\" && !getPanelElement(region.id)) {\n      return;\n    }\n\n    const gristDoc = this._getGristDoc();\n    if (gristDoc && region?.type === \"panel\" && region?.id === \"main\") {\n      console.error(\"main panel is not supported when a view layout is rendered\");\n      return;\n    }\n    if (!gristDoc && region?.type === \"section\") {\n      console.error(\"view section id is not supported when no view layout is rendered\");\n      return;\n    }\n\n    this._state.set({ region, initiator: options.initiator });\n  }\n\n  private _cycle(direction: \"next\" | \"prev\") {\n    const gristDoc = this._getGristDoc();\n    const cycleRegions = getCycleRegions(gristDoc);\n    this._focusRegion(getSibling(\n      this._state.get().region,\n      cycleRegions,\n      direction,\n      gristDoc,\n    ), { initiator: { type: \"cycle\" } });\n  }\n\n  /**\n   * When clicking on a grist doc page:\n   *   - if necessary, make it easier to tab through things inside panels by \"unfocusing\" the view section,\n   *   - make sure the internal current region info is set when user clicks on the view layout.\n   */\n  private _onClick(event: MouseEvent) {\n    const gristDoc = this._getGristDoc();\n    if (!gristDoc) {\n      return;\n    }\n    this._commandsHistory = [];\n    const closestRegion = (event.target as HTMLElement)?.closest(`[${ATTRS.regionId}]`);\n    if (!closestRegion) {\n      return;\n    }\n    const targetRegionId = closestRegion.getAttribute(ATTRS.regionId);\n    const targetsMain = targetRegionId === \"main\";\n\n    // When not targeting the main panel, we don't always want to focus the given region _on click_.\n    //\n    // We only do it if clicking an empty area in the panel, or a focusable element like an input.\n    // Because we kind of expect these behaviors usually on the web: I click on\n    // an empty space, and I can start using Tab to navigate around the area I clicked ;\n    // I click inside an input, and I can use Tab to navigate to the following ones.\n    //\n    // Otherwise, we assume[*] clicks are on elements like buttons or links,\n    // and we don't want to lose focus of current section in this case.\n    // For example I don't want to focus out current table if just click the \"undo\" button in the header.\n    //\n    // [*]: for now, we \"assume\" because lots of interactive elements in Grist are divs with click handlers.\n    // So we can't reliably consider that clicking on a div is clicking on a \"empty area\".\n    // Ideally (WIP) we'd have a more reliable way to detect \"buttons\" and this code could be simplified.\n    const isFocusableElement = isMouseFocusableElement(event.target) || closestRegion === event.target;\n\n    if (targetsMain || !isFocusableElement) {\n      // don't specify a section id here: we just want to focus back the view layout,\n      // we don't specifically know which section, the view layout will take care of that.\n      this._focusRegion({ type: \"section\" }, { initiator: { type: \"mouse\", event } });\n    } else {\n      this._focusRegion({ type: \"panel\", id: targetRegionId as Panel }, { initiator: { type: \"mouse\", event } });\n    }\n  }\n\n  /**\n   * This is registered as a `cancel` command when the RegionFocusSwitcher is created.\n   *\n   * That means this is called when pressing Escape in no particular setting.\n   * Any `cancel` command registered by other code after loading the page will take precedence over this one.\n   * So, this doesn't get called when in a modal, a popup menu, etc., as those have their own cancel callback.\n   */\n  private _onEscapeKeypress() {\n    const { region: current, initiator } = this._state.get();\n    // Do nothing if we are not focused on a panel\n    if (current?.type !== \"panel\") {\n      return;\n    }\n    const comesFromKeyboard = initiator?.type === \"cycle\";\n    const panelElement = getPanelElement(current.id);\n    if (!panelElement) {\n      return;\n    }\n\n    // …Reset region focus switch if already on the panel itself\n    if (document.activeElement === panelElement) {\n      this.reset();\n      return;\n    }\n\n    const activeElement = document.activeElement;\n    const activeElementIsInPanel = containsActiveElement(panelElement);\n\n    if (\n      // Focus back the panel element itself if currently focused element is a child\n      activeElementIsInPanel ||\n      // Specific case: when we escape inputs from panels, this isn't called, and focus switches back to body.\n      // If user presses escape again, we also want to focus the panel.\n      (activeElement === document.body)\n    ) {\n      if (comesFromKeyboard) {\n        focusPanelElement(panelElement);\n        if (activeElementIsInPanel) {\n          this._prevFocusedElements[current.id] = null;\n        }\n      } else {\n        this.reset();\n      }\n      return;\n    }\n  }\n\n  /**\n   * Save previous panel's focused element for later. Not necessary for view sections\n   */\n  private _savePrevElementState(prev: Region | undefined) {\n    const prevIsPanel = prev?.type === \"panel\";\n    if (!prevIsPanel) {\n      return;\n    }\n    const prevPanelElement = getPanelElement(prev.id);\n    const isChildOfPanel = containsActiveElement(prevPanelElement);\n    if (!isChildOfPanel) {\n      return;\n    }\n    this._prevFocusedElements[prev.id] = document.activeElement;\n  }\n\n  private _onStateChange(current: State, prev: State) {\n    if (isEqual(current.region, prev.region)) {\n      return;\n    }\n\n    const gristDoc = this._getGristDoc();\n    const mouseEvent = current.initiator?.type === \"mouse\" ?\n      current.initiator.event :\n      undefined;\n\n    clearCurrentFocusLock();\n    removeFocusRings();\n    removeTabIndexes();\n    if (!mouseEvent) {\n      this._savePrevElementState(prev.region);\n      if (prev.region?.type === \"panel\") {\n        blurPanelChild(prev.region);\n      }\n    }\n\n    const isPanel = current.region?.type === \"panel\";\n    const panelElement = isPanel && current.region?.id && getPanelElement((current.region as PanelRegion).id);\n\n    // If kb-focusing a panel:\n    //   - actually focus the panel dom element, or its previously focused child,\n    //   - trap the Tab key inside it (see `enableFocusLock`).\n    //   - make the Tab key available for normal browser navigation in the panel (see `escapeViewLayout`)\n    if (!mouseEvent && isPanel && panelElement && current.region) {\n      focusPanel(\n        current.region as PanelRegion,\n        this._prevFocusedElements[current.region.id as Panel] as HTMLElement | null,\n        gristDoc,\n      );\n\n    // If clicking on a panel: only make sure view layout commands are disabled,\n    // making the Tab key available for normal browser navigation (see `escapeViewLayout`)\n    } else if (mouseEvent && isPanel && panelElement && gristDoc) {\n      escapeViewLayout(gristDoc, !!(mouseEvent.target as Element)?.closest(`[${ATTRS.regionId}=\"right\"]`));\n\n    // If clicking or kb-focusing a section: focus the section,\n    // enabling back the view layout commands (see `focusSection`).\n    } else if (current.region?.type === \"section\" && gristDoc) {\n      focusSection(current.region, gristDoc);\n    }\n\n    // If we reset the focus switch, clean all necessary state\n    if (current.region === undefined) {\n      if (gristDoc) {\n        focusViewLayout(gristDoc);\n      }\n      this._prevFocusedElements = {\n        left: null,\n        top: null,\n        right: null,\n        main: null,\n      };\n    }\n  }\n\n  private _toggleCreatorPanel() {\n    const current = this._state.get().region;\n    const gristDoc = this._getGristDoc();\n    if (current?.type === \"panel\" && current.id === \"right\") {\n      return this._focusRegion(\n        gristDoc ? { type: \"section\" } : { type: \"panel\", id: \"main\" },\n        { initiator: { type: \"cycle\" } },\n      );\n    }\n    commands.allCommands.rightPanelOpen.run();\n    return this._focusRegion({ type: \"panel\", id: \"right\" }, { initiator: { type: \"cycle\" } });\n  }\n\n  private _canTabThroughMainRegion(use: UseCBOwner) {\n    const gristDoc = this._gristDocObs ? use(this._gristDocObs) : null;\n    if (!gristDoc) {\n      return true;\n    }\n    if (gristDoc) {\n      use(gristDoc.activeViewId);\n    }\n    return isSpecialPage(gristDoc);\n  }\n\n  /**\n   * Returns the grist doc only if its has a view layout, meaning it has view sections.\n   *\n   * If there is a grist doc but no view sections, it certainly means we are on a grist-doc special page and\n   * we want to handle kb focus like non-docs pages.\n   */\n  private _getGristDoc() {\n    const doc = !!this._gristDocObs && !this._gristDocObs.isDisposed() ?\n      this._gristDocObs.get() :\n      null;\n    if (!isSpecialPage(doc)) {\n      return doc;\n    }\n    return null;\n  }\n\n  private _logCommand(name: \"nextRegion\" | \"prevRegion\" | \"creatorPanel\") {\n    if (this._commandsHistory.length > 20) {\n      this._commandsHistory.shift();\n    }\n    this._commandsHistory.push({ name, timestamp: Date.now() });\n  }\n\n  /**\n   * As a user, it's not obvious that the creator panel needs a different shortcut than the other regions.\n   *\n   * So the user might try to use the next/prevRegion shortcut to access the creator panel.\n   * We show a warning letting him now about the specific creator panel shortcut when we think he is \"searching\" for it.\n   */\n  private _maybeNotifyAboutCreatorPanel() {\n    if (this._warnedAboutCreatorPanel) {\n      return;\n    }\n    const usedCreatorPanelCommand = this._commandsHistory.some(cmd => cmd.name === \"creatorPanel\");\n    if (usedCreatorPanelCommand) {\n      return;\n    }\n    const gristDoc = this._getGristDoc();\n    if (!gristDoc) {\n      return;\n    }\n    const now = Date.now();\n    const commandsInLast20Secs = this._commandsHistory.filter(cmd => cmd.timestamp > now - (1000 * 20));\n    const cycleRegions = getCycleRegions(gristDoc);\n    // the logic is: if in the last 20 seconds, the user pressed the same cycle shortcut enough times\n    // to do 2 full cycles through the regions, we assume he is trying to access the creator panel.\n    const warn = commandsInLast20Secs.length > ((cycleRegions.length * 2) - 1) &&\n      (\n        commandsInLast20Secs.every(cmd => cmd.name === \"nextRegion\") ||\n        commandsInLast20Secs.every(cmd => cmd.name === \"prevRegion\")\n      );\n    if (warn) {\n      this._app?.topAppModel.notifier.createUserMessage(\n        t(\n          \"Trying to access the creator panel? Use {{key}}.\",\n          { key: commands.allCommands.creatorPanel.humanKeys },\n        ),\n        {\n          level: \"info\",\n          key: \"rfs-cp-warn\",\n        },\n      );\n      this._warnedAboutCreatorPanel = true;\n    }\n  }\n}\n\n/**\n * Helper to declare view commands that should also focus current view.\n *\n * Used by a view when registering command groups.\n */\nexport const viewCommands = (commandsObject: Record<string, Function>, context: BaseView) => {\n  return Object.keys(commandsObject).reduce<Record<string, Function>>((acc, key) => {\n    const originalCommand = commandsObject[key];\n    acc[key] = function(...args: any[]) {\n      context.gristDoc.regionFocusSwitcher?.focusActiveSection();\n      return originalCommand.apply(context, args);\n    };\n    return acc;\n  }, {});\n};\n\nconst ATTRS = {\n  regionId: \"data-grist-region-id\",\n  focusedElement: \"data-grist-region-focused-el\",\n};\n\n/**\n * Focus the given panel dom element (or the given element inside it, if any), and let the grist doc view know about it.\n *\n * When focusing a panel, the tab key is trapped inside it (see `enableFocusLock`).\n */\nconst focusPanel = (panel: PanelRegion, child: HTMLElement | null, gristDoc: GristDoc | null) => {\n  const panelElement = getPanelElement(panel.id);\n  if (!panelElement) {\n    return;\n  }\n  enableFocusLock(panelElement);\n\n  // Child element found: focus it if we actually can\n  if (child && child !== panelElement && child.isConnected && isFocusable(child)) {\n    // Visually highlight the element with similar styles than panel focus,\n    // only for this time. This is here just to help the user better see the visual change when he switches panels.\n    child.setAttribute(ATTRS.focusedElement, \"true\");\n    child.addEventListener(\"blur\", () => {\n      child.removeAttribute(ATTRS.focusedElement);\n    }, { once: true });\n    child.focus?.();\n  } else {\n    // No child to focus found: just focus the panel\n    focusPanelElement(panelElement);\n  }\n\n  if (gristDoc) {\n    // Creator panel is a special case \"related to the view\"\n    escapeViewLayout(gristDoc, panel.id === \"right\");\n  }\n};\n\nconst focusPanelElement = (panelElement: HTMLElement) => {\n  // tabindex is set here and removed later with removeTabIndexes(), instead of\n  // directly set on the element on creation, for a reason:\n  // if we happen to just click on a non-focusable element inside a panel,\n  // browser default behavior is to make document.activeElement the closest focusable parent (the panel).\n  // We don't want this behavior, so we add/remove the tabindex attribute as needed.\n  panelElement.setAttribute(\"tabindex\", \"-1\");\n  panelElement.focus();\n};\n\nconst focusViewLayout = (gristDoc: GristDoc) => {\n  FocusLayer.grabFocus();\n  gristDoc.viewModel.focusedRegionState(\"in\");\n};\n\n/**\n * Let the given grist doc know that the current region is not the view layout anymore.\n *\n * When escaping the view layout:\n *  - view layout keyboard commands are disabled[*]\n *  - active section border (the left, green border of the widget) gets hidden\n *\n * Setting `isRelated` to true is a special case made for when focusing panels \"related\" to the view layout:\n * instead of hiding the active section border, it dims it but keeps it slightly visible,\n * so that the user understands what view layout section the current panel is related to.\n *\n * [*] Disabling the view keyboard commands is a crucial step for enabling keyboard navigation with Tab key in a panel.\n * This is because amongst the disabled view commands are the `nextField` and `prevField` commands,\n * which are the ones overriding the Tab key normal browser behavior and trapping the Tab key usage in a Table/Card/etc.\n */\nconst escapeViewLayout = (gristDoc: GristDoc, isRelated = false) => {\n  gristDoc.viewModel.focusedRegionState(isRelated ? \"related\" : \"out\");\n};\n\n/**\n * Focus the given doc view section id, or only the view layout if no id is provided.\n *\n * This enables the view layout keyboard commands, noticeably making the Tab key\n * respond to the `nextField` and `prevField` commands instead of normal browser behavior.\n */\nconst focusSection = (section: SectionRegion, gristDoc: GristDoc) => {\n  focusViewLayout(gristDoc);\n  if (section.id) {\n    gristDoc.viewModel.activeSectionId(section.id);\n  }\n};\n\n/**\n * Get all regions we can currently cycle through.\n *\n * Depending on whether a view layout is currently rendered, it returns only panels, or panels and sections.\n */\nconst getCycleRegions = (gristDoc: GristDoc | null): Region[] => {\n  const commonPanels = [\n    getPanelElement(\"left\") ? { type: \"panel\", id: \"left\" } as PanelRegion : null,\n    getPanelElement(\"top\") ? { type: \"panel\", id: \"top\" } as PanelRegion : null,\n  ].filter((x): x is PanelRegion => Boolean(x));\n\n  // If there is no doc with layout, just cycle through panels\n  if (!gristDoc) {\n    return [\n      ...commonPanels,\n      getPanelElement(\"main\") ? { type: \"panel\", id: \"main\" } as PanelRegion : null,\n    ].filter((x): x is PanelRegion => Boolean(x));\n  }\n\n  // If there is a doc, also cycle through section ids\n  return [\n    ...gristDoc.viewLayout?.layout.getAllLeafIds().map(id => ({ type: \"section\", id } as SectionRegion)) ?? [],\n    ...commonPanels,\n  ];\n};\n\n/**\n * Get the sibling region to focus in the regions given, compared to the current region and the direction.\n *\n * Exceptions:\n *   - If we happen to be on the creator panel, focus back to the view layout active section,\n *   - If we don't find anything, focus the first region in the cycle.\n */\nconst getSibling = (\n  current: Region | undefined,\n  regions: Region[],\n  direction: \"next\" | \"prev\",\n  gristDoc: GristDoc | null,\n): Region | undefined => {\n  const isCreatorPanel = current?.type === \"panel\" && current.id === \"right\";\n\n  // First normally try to get current region in the cycle\n  let currentIndexInCycle = findRegionIndex(regions, current);\n\n  // If it's not found, it certainly means there is no current region set yet.\n  // In case of a grist doc, we can use the active section id as the \"current index\"\n  if ((currentIndexInCycle === -1 || isCreatorPanel) && gristDoc) {\n    currentIndexInCycle = findRegionIndex(regions, { type: \"section\", id: gristDoc.viewModel.activeSectionId() });\n  }\n  // If we still don't find anything, it means we never set the current region before on a non-doc page,\n  // or we didn't find any current doc section. Return the first region as default.\n  if (currentIndexInCycle === -1) {\n    return regions[0];\n  }\n\n  // Normal case: just return the next or previous region in the cycle, wrapping around\n  const sibling = regions[mod(currentIndexInCycle + (direction === \"next\" ? 1 : -1), regions.length)];\n  return sibling;\n};\n\n/**\n * Blur the currently focused element in the given panel, if any.\n */\nconst blurPanelChild = (panel: PanelRegion) => {\n  const panelElement = getPanelElement(panel.id);\n  if (containsActiveElement(panelElement)) {\n    (document.activeElement as HTMLElement).blur();\n  }\n};\n\nconst _focusLockHolder = Holder.create(null);\n\nconst clearCurrentFocusLock = () => _focusLockHolder.clear();\n\n/**\n * Trap the tab key inside the given panel element.\n *\n * That makes pressing tab and shift+tab loop exclusively through focusable elements that are *in* the panel.\n */\nconst enableFocusLock = (panelElement: HTMLElement) => {\n  clearCurrentFocusLock();\n  _focusLockHolder.autoDispose(dom.onElem(panelElement, \"keydown\", (event, elem) => {\n    if (event.key === \"Tab\") {\n      trapTabKey(elem, event);\n    }\n  }));\n};\n\nconst getPanelElement = (id: Panel): HTMLElement | null => {\n  return document.querySelector(getPanelElementId(id));\n};\n\nconst getPanelElementId = (id: Panel): string => {\n  return `[${ATTRS.regionId}=\"${id}\"]`;\n};\n\nconst isMouseFocusableElement = (el: EventTarget | null): boolean => {\n  if (!el) {\n    return false;\n  }\n  if (el instanceof HTMLElement && [\"input\", \"textarea\", \"select\", \"iframe\"].includes(el.tagName.toLocaleLowerCase())) {\n    return true;\n  }\n  // Sometimes, we don't want to consider an element with tabindex as something we want to keep focus on with the mouse,\n  // so we use the 'ignore_tabindex' class to bypass the default behavior.\n  if (el instanceof HTMLElement && el.getAttribute(\"tabindex\") === \"0\" && !el.classList.contains(\"ignore_tabindex\")) {\n    return true;\n  }\n  return false;\n};\n\n/**\n * Check if the document.activeElement is a child of the given element.\n */\nconst containsActiveElement = (el: HTMLElement | null): boolean => {\n  return el?.contains(document.activeElement) && document.activeElement !== el || false;\n};\n\n/**\n * Remove the visual highlight on elements that are styled as focused elements of panels.\n */\nconst removeFocusRings = () => {\n  document.querySelectorAll(`[${ATTRS.focusedElement}]`).forEach((el) => {\n    el.removeAttribute(ATTRS.focusedElement);\n  });\n};\n\nconst removeTabIndexes = () => {\n  document.querySelectorAll(`[${ATTRS.regionId}]`).forEach((el) => {\n    el.removeAttribute(\"tabindex\");\n  });\n};\n\nconst findRegionIndex = (regions: Region[], region: Region | undefined) => {\n  if (!region) {\n    return -1;\n  }\n  return regions.findIndex(r => isEqual(r, region));\n};\n\nconst isSpecialPage = (doc: GristDoc | null) => {\n  if (!doc) {\n    return false;\n  }\n  const activeViewId = doc.activeViewId.get();\n  if (typeof activeViewId === \"string\" && SpecialDocPage.guard(activeViewId)) {\n    return true;\n  }\n  return false;\n};\n\nexport const cssFocusedPanel = styled(\"div\", `\n  &-focused:focus {\n    outline: 3px solid ${components.kbFocusHighlight} !important;\n    outline-offset: -3px !important;\n  }\n\n  &-focused [${ATTRS.focusedElement}]:focus {\n    outline: 3px solid ${components.kbFocusHighlight} !important;\n  }\n`);\n"
  },
  {
    "path": "app/client/components/SearchBar.css",
    "content": ".searchbar-box.grist-navbar-pfx.part-toolbar-group__item {\n  display: flex;\n  width: 15rem;\n  padding: 0;\n  color: grey;\n}\n\n.searchbar-box.grist-navbar-pfx.part-toolbar-group__item:focus-within {\n  box-shadow: 0 0 3px 2px var(--grist-color-cursor);\n  color: black;\n}\n\n.searchbar-box.grist-navbar-pfx.part-toolbar-group__item:hover {\n  /* undo the effect of hover in .part-toolbar-group__item */\n  background-color: var(--color-navbar-btn-bg);\n}\n\n.searchbar-button.grist-navbar-pfx.part-toolbar-group__item {\n  padding: 0 3px;\n}\n\n.searchbar-icon {\n  flex: none;\n  font-size: 1.2rem;\n  color: grey;\n  margin: 0 2px 0 4px;\n  top: 2px;\n  line-height: inherit;\n}\n\n.searchbar-icon-indicator {\n  animation: searchbar_flip 1s ease-in-out infinite;\n}\n\n.searchbar-input {\n  display: block;\n  border: none;\n  outline: none;\n  background-color: transparent;\n  height: 22px;\n  min-width: 0;\n}\n\n.searchbar-buttons {\n  flex: none;\n  align-self: center;\n  margin: 0 2px 0 0 !important;\n}\n\n.searchbar-buttons > .kf_button {\n  height: 1.6rem;\n  padding: 0.3rem 0.6rem;\n}\n\n.searchbar-buttons > .disabled {\n  opacity: 0.5;\n}\n\n@keyframes searchbar_flip {\n  0% { transform: scaleX(1); }\n  50% { transform: scaleX(-1); }\n  100% { transform: scaleX(1); }\n}\n\n/* applies to the cursor element, added and quickly removed to trigger a highlight animation */\n.selected_cursor {\n  transition: background-color 500ms linear;\n}\n\n.search-match {\n  transition: none;\n  background-color: rgba(0, 255, 0, 0.4);\n}\n"
  },
  {
    "path": "app/client/components/SelectionSummary.ts",
    "content": "import { CellSelector, COL, ROW } from \"app/client/components/CellSelector\";\nimport { copyToClipboard } from \"app/client/lib/clipboardUtils\";\nimport { Delay } from \"app/client/lib/Delay\";\nimport { KoArray } from \"app/client/lib/koArray\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { UserError } from \"app/client/models/errors\";\nimport { ALL, RowsChanged, SortedRowSet } from \"app/client/models/rowset\";\nimport { showTransientTooltip } from \"app/client/ui/tooltips\";\nimport { isNarrowScreen, isNarrowScreenObs, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { isEmptyList, isListType, isRefListType } from \"app/common/gristTypes\";\nimport { TableData } from \"app/common/TableData\";\nimport { BaseFormatter } from \"app/common/ValueFormatter\";\n\nimport { Computed, Disposable, dom, makeTestId, Observable, styled, subscribe } from \"grainjs\";\nimport ko from \"knockout\";\n\nconst t = makeT(\"SelectionSummary\");\n\n/**\n * A beginning and end index for a range of columns or rows.\n */\ninterface Range {\n  begin: number;\n  end: number;\n}\n\n/**\n * A single part of the cell selection summary.\n */\ninterface SummaryPart {\n  /** Identifier for the summary part. */\n  id: \"sum\" | \"count\" | \"dimensions\";\n  /** Label that's shown to the left of `value`. */\n  label: string;\n  /** Value of the summary part. */\n  value: string;\n  /** If true, displays a copy button on hover. Defaults to false. */\n  clickToCopy?: boolean;\n}\n\nconst testId = makeTestId(\"test-selection-summary-\");\n\n// We can handle a million cells in under 60ms on a good laptop. Much beyond that, and we'll break\n// selection with the bad performance. Instead, skip the counting and summing for too many cells.\nconst MAX_CELLS_TO_SCAN = 1_000_000;\n\nexport class SelectionSummary extends Disposable {\n  private _colTotalCount = Computed.create(this, use =>\n    use(use(this._viewFields).getObservable()).length);\n\n  private _rowTotalCount = Computed.create(this, (use) => {\n    const rowIds = use(this._sortedRows.getKoArray().getObservable());\n    const includesNewRow = (rowIds.length > 0 && rowIds[rowIds.length - 1] === \"new\");\n    return rowIds.length - (includesNewRow ? 1 : 0);\n  });\n\n  // In CellSelector, start and end are 0-based, inclusive, and not necessarily in order.\n  // It's not good for representing an empty range. Here, we convert ranges as [begin, end),\n  // with end >= begin.\n  private _rowRange = Computed.create<Range>(this, (use) => {\n    const type = use(this._cellSelector.currentSelectType);\n    if (type === COL) {\n      return { begin: 0, end: use(this._rowTotalCount) };\n    } else {\n      const start = use(this._cellSelector.row.start);\n      const end = use(this._cellSelector.row.end);\n      return {\n        begin: Math.min(start, end),\n        end: Math.max(start, end) + 1,\n      };\n    }\n  });\n\n  private _colRange = Computed.create<Range>(this, (use) => {\n    const type = use(this._cellSelector.currentSelectType);\n    if (type === ROW) {\n      return { begin: 0, end: use(this._colTotalCount) };\n    } else {\n      const start = use(this._cellSelector.col.start);\n      const end = use(this._cellSelector.col.end);\n      return {\n        begin: Math.min(start, end),\n        end: Math.max(start, end) + 1,\n      };\n    }\n  });\n\n  private _summary = Observable.create<SummaryPart[]>(this, []);\n  private _delayedRecalc = this.autoDispose(Delay.create());\n\n  constructor(\n    private _cellSelector: CellSelector,\n    private _tableData: TableData,\n    private _sortedRows: SortedRowSet,\n    private _viewFields: ko.Computed<KoArray<ViewFieldRec>>,\n  ) {\n    super();\n\n    this.autoDispose(this._sortedRows.getKoArray().subscribe(this._onSpliceChange, this, \"spliceChange\"));\n    const onRowNotify = this._onRowNotify.bind(this);\n    this._sortedRows.on(\"rowNotify\", onRowNotify);\n    this.onDispose(() => this._sortedRows.off(\"rowNotify\", onRowNotify));\n    this.autoDispose(subscribe(this._rowRange, this._colRange,\n      () => this._scheduleRecalc()));\n    this.autoDispose(isNarrowScreenObs().addListener((isNarrow) => {\n      if (isNarrow) { return; }\n      // No calculations occur while the screen is narrow, so we need to schedule one.\n      this._scheduleRecalc();\n    }));\n  }\n\n  public buildDom() {\n    return cssSummary(\n      dom.forEach(this._summary, ({ id, label, value, clickToCopy }) =>\n        cssSummaryPart(\n          label ? dom(\"span\", cssLabelText(label), cssCopyIcon(\"Copy\")) : null,\n          value,\n          cssSummaryPart.cls(\"-copyable\", Boolean(clickToCopy)),\n          (clickToCopy ? dom.on(\"click\", (ev, elem) => doCopy(value, elem)) : null),\n          testId(id),\n        ),\n      ),\n    );\n  }\n\n  private _onSpliceChange(splice: { start: number }) {\n    const rowRange = this._rowRange.get();\n    const rowCount = rowRange.end - rowRange.begin;\n    if (rowCount === 1) { return; }\n    if (splice.start >= rowRange.end) { return; }\n    // We could be smart here and only recalculate when the splice affects our selection. But for\n    // that to make sense, the selection itself needs to be smart. Currently, the selection is\n    // lost whenever the cursor is affected. For example, when you have a selection and another\n    // user adds/removes columns or rows before the selection, the selection won't be shifted\n    // with the cursor, and will instead be cleared. Since we can't always rely on the selection\n    // being there, we'll err on the safe side and always schedule a recalc.\n    this._scheduleRecalc();\n  }\n\n  private _onRowNotify(rows: RowsChanged) {\n    const rowRange = this._rowRange.get();\n    if (rows === ALL) {\n      this._scheduleRecalc();\n    } else {\n      const rowArray = this._sortedRows.getKoArray().peek();\n      const rowIdSet = new Set(rows);\n      for (let r = rowRange.begin; r < rowRange.end; r++) {\n        if (rowIdSet.has(rowArray[r])) {\n          this._scheduleRecalc();\n          break;\n        }\n      }\n    }\n  }\n\n  /**\n   * Schedules a re-calculation to occur in the immediate future.\n   *\n   * May be called repeatedly, but only a single re-calculation will be scheduled, to\n   * avoid queueing unnecessary amounts of work.\n   */\n  private _scheduleRecalc() {\n    // `_recalc` may take a non-trivial amount of time, so we defer until the stack is clear.\n    this._delayedRecalc.schedule(0, () => this._recalc());\n  }\n\n  private _recalc() {\n    const rowRange = this._rowRange.get();\n    const colRange = this._colRange.get();\n    let rowCount = rowRange.end - rowRange.begin;\n    let colCount = colRange.end - colRange.begin;\n    const cellCount = rowCount * colCount;\n    const summary: SummaryPart[] = [];\n    // Do nothing on narrow screens, because we haven't come up with a place to render sum anyway.\n    if (cellCount > 1 && !isNarrowScreen()) {\n      if (cellCount <= MAX_CELLS_TO_SCAN) {\n        const rowArray = this._sortedRows.getKoArray().peek();\n        const fields = this._viewFields.peek().peek();\n        let countNumeric = 0;\n        let countNonEmpty = 0;\n        let sum = 0;\n        let sumFormatter: BaseFormatter | null = null;\n        const rowIndices: number[] = [];\n        for (let r = rowRange.begin; r < rowRange.end; r++) {\n          const rowId = rowArray[r];\n          if (rowId === undefined || rowId === \"new\") {\n            // We can run into this whenever the selection gets out of sync due to external\n            // changes, like another user removing some rows. For now, we'll skip rows that are\n            // still selected and no longer exist, but the real TODO is to better update the\n            // selection so that it doesn't have out-of-date and invalid ranges.\n            rowCount -= 1;\n            continue;\n          }\n          rowIndices.push(this._tableData.getRowIdIndex(rowId)!);\n        }\n        for (let c = colRange.begin; c < colRange.end; c++) {\n          const field = fields[c];\n          if (field === undefined) {\n            // Like with rows (see comment above), we need to watch out for out-of-date ranges.\n            colCount -= 1;\n            continue;\n          }\n          const col = fields[c].column.peek();\n          const displayCol = fields[c].displayColModel.peek();\n          const colType = col.type.peek();\n          const visibleColType = fields[c].visibleColModel.peek().type.peek();\n          const effectiveColType = visibleColType ?? colType;\n          const displayColId = displayCol.colId.peek();\n          // Note: we get values from the display column so that reference columns displaying\n          // numbers are included in the computed sum. Unfortunately, that also means we can't\n          // show a count of non-empty references. For now, that's a trade-off we'll have to make,\n          // but in the future it should be possible to allow showing multiple summary parts with\n          // some level of configurability.\n          const values = this._tableData.getColValues(displayColId);\n          if (!values) {\n            throw new UserError(`Invalid column ${this._tableData.tableId}.${displayColId}`);\n          }\n          const isNumeric = [\"Numeric\", \"Int\", \"Any\"].includes(effectiveColType);\n          const isEmpty: undefined | ((value: CellValue) => boolean) = (\n            colType.startsWith(\"Ref:\") && !visibleColType ? value => (value === 0) :\n              isRefListType(colType) || isListType(effectiveColType) ? isEmptyList :\n                undefined\n          );\n          // The loops below are optimized, minimizing the amount of work done per row. For\n          // example, column values are retrieved in bulk above instead of once per row. In one\n          // unscientific test, they take 30-60ms per million numeric cells.\n          //\n          // TODO: Add a benchmark test suite that automates checking for performance regressions.\n          if (isNumeric) {\n            if (!sumFormatter) {\n              sumFormatter = fields[c].formatter.peek();\n            }\n            for (const i of rowIndices) {\n              const value = values[i];\n              if (typeof value === \"number\") {\n                countNumeric++;\n                sum += value;\n              } else if (value !== null && value !== undefined && value !== \"\" && !isEmpty?.(value)) {\n                countNonEmpty++;\n              }\n            }\n          } else {\n            for (const i of rowIndices) {\n              const value = values[i];\n              if (value !== null && value !== undefined && value !== \"\" && value !== false && !isEmpty?.(value)) {\n                countNonEmpty++;\n              }\n            }\n          }\n        }\n\n        if (countNumeric > 0) {\n          const sumValue = sumFormatter ? sumFormatter.formatAny(sum) : String(sum);\n          summary.push({ id: \"sum\", label: \"Sum \", value: sumValue, clickToCopy: true });\n        } else {\n          summary.push({ id: \"count\", label: \"Count \", value: String(countNonEmpty), clickToCopy: true });\n        }\n      }\n      summary.push({ id: \"dimensions\", label: \"\", value: `${rowCount}⨯${colCount}` });\n    }\n    this._summary.set(summary);\n  }\n}\n\nasync function doCopy(value: string, elem: Element) {\n  await copyToClipboard(value);\n  showTransientTooltip(elem, t(\"Copied to clipboard\"), { key: \"copy-selection-summary\" });\n}\n\nconst cssSummary = styled(\"div\", `\n  position: absolute;\n  bottom: -18px;\n  height: 18px;\n  line-height: 18px;\n  display: flex;\n  column-gap: 8px;\n  width: 100%;\n  justify-content: end;\n  color: ${theme.text};\n  font-family: ${vars.fontFamilyData};\n\n  @media print {\n    & {\n      display: none;\n    }\n  }\n`);\n\n// Note: the use of an extra element for the background is to set its opacity, to make it a bit\n// lighter (or darker, in dark-mode) than actual mediumGrey, without defining a special color.\nconst cssSummaryPart = styled(\"div\", `\n  padding: 0 8px;\n  border-radius: 4px;\n  border-top-left-radius: 0px;\n  border-top-right-radius: 0px;\n  border-top: none;\n  z-index: 100;\n  position: relative;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  /* Set explicit backdrop to improve visibility in raw data views. */\n  background-color: ${theme.mainPanelBg};\n\n  &-copyable:hover {\n    cursor: pointer;\n  }\n  &::before {\n    content: \"\";\n    position: absolute;\n    top: 0;\n    left: 0;\n    bottom: 0;\n    right: 0;\n    background-color: ${theme.tableCellSummaryBg};\n    opacity: 0.8;\n    z-index: -1;\n  }\n`);\n\nconst cssLabelText = styled(\"span\", `\n  font-size: ${vars.xsmallFontSize};\n  text-transform: uppercase;\n  position: relative;\n  margin-right: 4px;\n  .${cssSummaryPart.className}-copyable:hover & {\n    visibility: hidden;\n  }\n`);\n\nconst cssCopyIcon = styled(icon, `\n  position: absolute;\n  top: 0;\n  margin: 1px 0 0 4px;\n  --icon-color: ${theme.controlFg};\n  display: none;\n  .${cssSummaryPart.className}-copyable:hover & {\n    display: block;\n  }\n`);\n"
  },
  {
    "path": "app/client/components/TypeConversion.ts",
    "content": "/**\n * This module contains various logic for converting columns between types. It is used from\n * TypeTransform.js.\n */\n\nimport { isString } from \"app/client/lib/sessionObs\";\nimport { DocModel } from \"app/client/models/DocModel\";\nimport { ColumnRec } from \"app/client/models/entities/ColumnRec\";\nimport { csvDecodeRow } from \"app/common/csvFormat\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { isFullReferencingType } from \"app/common/gristTypes\";\nimport * as gutil from \"app/common/gutil\";\nimport { isNonNullish } from \"app/common/gutil\";\nimport NumberParse from \"app/common/NumberParse\";\nimport { dateTimeWidgetOptions, guessDateFormat, timeFormatOptions } from \"app/common/parseDate\";\nimport { TableData } from \"app/common/TableData\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\ninterface PrepColInfo {\n  type: string;\n  isFormula: boolean;\n  formula?: string;\n  visibleCol: number;\n  widgetOptions?: string;\n  rules: gristTypes.RefListValue\n}\n\n/**\n * Returns the suggested full type for `column` given a desired pure type to convert it to.\n * Specifically, a pure type of \"DateTime\" returns a full type of \"DateTime:{timezone}\", and \"Ref\"\n * returns a full type of \"Ref:{TableId}\". A `type` that's already complete is returned unchanged.\n */\nexport function addColTypeSuffix(type: string, column: ColumnRec, docModel: DocModel) {\n  switch (type) {\n    case \"Ref\":\n    case \"RefList\":\n    {\n      const refTableId = getRefTableIdFromData(docModel, column) || column.table().primaryTableId();\n      return `${type}:${refTableId}`;\n    }\n    case \"DateTime\":\n      return \"DateTime:\" + docModel.docInfoRow.timezone();\n    default:\n      return type;\n  }\n}\n\n/**\n * Infers the suffix for a column type, based on the type of the column and the type to convert it to.\n * Currently only used for Ref and RefList types, where the suffix is the tableId of the reference.\n */\nexport function inferColTypeSuffix(newPure: string, column: ColumnRec) {\n  // We can infer only for Ref and RefList types.\n  if (newPure !== \"Ref\" && newPure !== \"RefList\") {\n    return null;\n  }\n\n  // If the old type was also Ref/RefList, just return the tableId from the old type.\n  const existingTable = column.type.peek().split(\":\")[1];\n  const oldPure = gristTypes.extractTypeFromColType(column.type.peek());\n  if (existingTable && (oldPure === \"Ref\" || oldPure === \"RefList\")) {\n    return `${newPure}:${existingTable}`;\n  }\n  return null;\n}\n\n/**\n * Looks through the data of the given column to find the first value of the form\n * [R|r, <tableId>, <rowId>] (a Reference(List) value returned from a formula), and returns the tableId\n * from that.\n */\nfunction getRefTableIdFromData(docModel: DocModel, column: ColumnRec): string | null {\n  const tableData = docModel.docData.getTable(column.table().tableId());\n  const columnData = tableData?.getColValues(column.colId());\n  if (columnData) {\n    for (const value of columnData) {\n      if (gristTypes.isReferencing(value)) {\n        return value[1];\n      } else if (gristTypes.isList(value)) {\n        const item = value[1];\n        if (gristTypes.isReference(item)) {\n          return item[1];\n        }\n      } else if (typeof value === \"string\") {\n        // If it looks like a formatted Ref(List) value, e.g:\n        //   - Table1[123]\n        //   - [Table1[1], Table1[2], Table1[3]]\n        //   - Table1[[1, 2, 3]]\n        // and the tableId is valid,\n        // use it. (This helps if a Ref-returning formula column got converted to Text first.)\n        const match = value.match(/^\\[?(\\w+)\\[/);\n        if (match && docModel.docData.getTable(match[1])) {\n          return match[1];\n        }\n      }\n    }\n  }\n  return null;\n}\n\n// Given info about the original column, and the type of the new one, returns a promise for the\n// ColInfo to use for the transform column. Note that isFormula will be set to true, and formula\n// will be set to the expression to compute the new values from the old ones.\n// @param toTypeMaybeFull: Type to convert the column to, either full ('Ref:Foo') or pure ('Ref').\nexport async function prepTransformColInfo(options: {\n  docModel: DocModel;\n  origCol: ColumnRec;\n  origDisplayCol: ColumnRec;\n  toTypeMaybeFull: string;\n  convertedRef?: string\n}): Promise<PrepColInfo> {\n  const { docModel, origCol, origDisplayCol, toTypeMaybeFull, convertedRef } = options;\n  const toType = gristTypes.extractTypeFromColType(toTypeMaybeFull);\n  const tableData: TableData = docModel.docData.getTable(origCol.table().tableId())!;\n\n  const visibleCol = origCol.visibleColModel();\n  // Column used to derive previous widget options and sample values for guessing\n  const sourceCol = visibleCol.getRowId() !== 0 ? visibleCol : origCol;\n  let widgetOptions = { ...(sourceCol.widgetOptionsJson() || {}) };\n\n  if (isReferenceCol(origCol)) {\n    // While reference columns copy most options from their visible column, conditional style rules are kept\n    // from the original reference column for a few reasons:\n    // 1. The rule formula of the visible column is less likely to make sense after conversion,\n    //    especially if the reference points to a different table.\n    // 2. Overwriting the conditional styles of the original reference column could be annoying, whereas\n    //    most other widget options in reference columns aren't particularly valuable.\n    // 3. The `rules` column (i.e. a reflist to other formula columns) can't simply be copied because those rule columns\n    //    can't currently be shared by multiple columns.\n    // So in general we keep `rules: origCol.rules()` (further below) and the corresponding\n    // `widgetOptions.rulesOptions`.\n    // A quirk of this is that the default (non-conditional) cell style can still be copied from the visible column,\n    // so a subset of the overall cell styling can change.\n    delete widgetOptions.rulesOptions;\n    const { rulesOptions } = origCol.widgetOptionsJson() || {};\n    if (rulesOptions) {\n      widgetOptions.rulesOptions = rulesOptions;\n    }\n  }\n\n  const colInfo: PrepColInfo = {\n    type: addColTypeSuffix(toTypeMaybeFull, origCol, docModel),\n    isFormula: true,\n    visibleCol: 0,\n    formula: `rec.${convertedRef}`,\n    rules: origCol.rules(),\n  };\n\n  switch (toType) {\n    case \"Ref\":\n    case \"RefList\":\n    {\n      // Set suggested destination table and visible column.\n      // Undefined if toTypeMaybeFull is a pure type (e.g. converting to Ref before a table is chosen).\n      const optTableId = gutil.removePrefix(toTypeMaybeFull, `${toType}:`) || undefined;\n\n      let suggestedColRef: number;\n      let suggestedTableId: string;\n      const origColTypeInfo = gristTypes.extractInfoFromColType(origCol.type.peek());\n      if (!optTableId && (origColTypeInfo.type === \"Ref\" || origColTypeInfo.type === \"RefList\")) {\n        // When converting between Ref and Reflist, initially suggest the same table and visible column.\n        // When converting, if the table is the same, it's a special case.\n        // The visible column will not affect conversion.\n        // It will simply wrap the reference (row ID) in a list or extract the one element of a reference list.\n        suggestedColRef = origCol.visibleCol.peek();\n        suggestedTableId = origColTypeInfo.tableId;\n      } else {\n        // Finds a reference suggestion column and sets it as the current reference value.\n        const columnData = tableData.getDistinctValues(origDisplayCol.colId(), 100);\n        if (!columnData) { break; }\n        columnData.delete(gristTypes.getDefaultForType(origCol.type()));\n\n        // 'findColFromValues' function requires an array since it sends the values to the sandbox.\n        const matches: number[] = await docModel.docData.findColFromValues(Array.from(columnData), 2, optTableId);\n        suggestedColRef = matches.find(match => match !== origCol.getRowId())!;\n        if (!suggestedColRef) { break; }\n        const suggestedCol = docModel.columns.getRowModel(suggestedColRef);\n        suggestedTableId = suggestedCol.table().tableId();\n        if (optTableId && suggestedTableId !== optTableId) {\n          console.warn(\"Inappropriate column received from findColFromValues\");\n          break;\n        }\n      }\n      colInfo.type = `${toType}:${suggestedTableId}`;\n      colInfo.visibleCol = suggestedColRef;\n      break;\n    }\n    default:\n      widgetOptions = guessWidgetOptionsSync({ docModel, origCol, toTypeMaybeFull, widgetOptions });\n  }\n\n  if (Object.keys(widgetOptions).length) {\n    colInfo.widgetOptions = JSON.stringify(widgetOptions);\n  }\n  return colInfo;\n}\n\n/**\n * Tries to guess widget options for a given column, based on the type it's being converted to.\n * It works synchronously, so it can't reason about options that require async calls to the data-engine.\n */\nexport function guessWidgetOptionsSync(options: {\n  docModel: DocModel;\n  origCol: ColumnRec;\n  toTypeMaybeFull: string;\n  widgetOptions?: any;\n}): object {\n  const { docModel, origCol, toTypeMaybeFull } = options;\n  const toType = gristTypes.extractTypeFromColType(toTypeMaybeFull);\n  let widgetOptions = { ...(options.widgetOptions ?? {}) };\n  const tableData: TableData = docModel.docData.getTable(origCol.table().tableId())!;\n  const visibleCol = origCol.visibleColModel();\n  const sourceCol = visibleCol.getRowId() !== 0 ? visibleCol : origCol;\n  switch (toType) {\n    case \"Bool\":\n      // Most types use a TextBox as the default widget.\n      // We don't want to reuse that for Toggle columns, which should be a CheckBox by default.\n      delete widgetOptions.widget;\n      break;\n    case \"Date\":\n    case \"DateTime\": {\n      let { dateFormat } = widgetOptions;\n      if (!dateFormat) {\n        // Guess date and time format if there aren't any already\n        const colValues = tableData.getColValues(sourceCol.colId()) || [];\n        const strValues = colValues.map(v => isNonNullish(v) ? String(v) : null);\n        dateFormat = guessDateFormat(strValues);\n        widgetOptions = { ...widgetOptions, ...(dateTimeWidgetOptions(dateFormat, true)) };\n      }\n      if (toType === \"DateTime\" && !widgetOptions.timeFormat) {\n        // Ensure DateTime columns have a time format. This is needed when converting from a Date column.\n        widgetOptions.timeFormat = timeFormatOptions[0];\n        widgetOptions.isCustomTimeFormat = false;\n      }\n      break;\n    }\n    case \"Numeric\":\n    case \"Int\": {\n      if (![\"Numeric\", \"Int\"].includes(sourceCol.type())) {\n        const numberParse = NumberParse.fromSettings(docModel.docData.docSettings());\n        const colValues = tableData.getColValues(sourceCol.colId()) || [];\n        widgetOptions = { ...widgetOptions, ...numberParse.guessOptions(colValues.filter(isString)) };\n      }\n      break;\n    }\n    case \"Choice\": {\n      // Use previous choices if they are set, e.g. if converting from ChoiceList\n      if (!Array.isArray(widgetOptions.choices)) {\n        // Set suggested choices. Limit to 100, since too many choices is more likely to cause\n        // trouble than desired behavior. For many choices, recommend using a Ref to helper table.\n        const columnData = tableData.getDistinctValues(sourceCol.colId(), 100);\n        if (columnData) {\n          const choices = Array.from(columnData).filter(isNonNullish)\n            .map(v => String(v).trim())\n            .filter(Boolean);\n          widgetOptions = { ...widgetOptions, choices };\n        }\n      }\n      break;\n    }\n    case \"ChoiceList\": {\n      // Use previous choices if they are set, e.g. if converting from Choice\n      if (!Array.isArray(widgetOptions.choices)) {\n        // Set suggested choices. This happens before the conversion to ChoiceList, so we do some\n        // light guessing for likely choices to suggest.\n        const choices = new Set<string>();\n        for (let value of tableData.getColValues(sourceCol.colId()) || []) {\n          if (value === null) { continue; }\n          value = String(decodeObject(value)).trim();\n          const tags: unknown[] = (value.startsWith(\"[\") && gutil.safeJsonParse(value, null)) || csvDecodeRow(value);\n          for (const tag of tags) {\n            const choice = !tag ? \"\" : String(tag).trim();\n            if (choice === \"\") { continue; }\n            choices.add(choice);\n            if (choices.size > 100) { break; }    // Don't suggest excessively many choices.\n          }\n        }\n        widgetOptions = { ...widgetOptions, choices: Array.from(choices) };\n      }\n      break;\n    }\n  }\n  return widgetOptions;\n}\n\n// Given the transformCol, calls (if needed) a user action to update its displayCol.\nexport async function setDisplayFormula(\n  docModel: DocModel, transformCol: ColumnRec, visibleCol?: number,\n): Promise<void> {\n  const vcolRef = (visibleCol == null) ? transformCol.visibleCol() : visibleCol;\n  if (isReferenceCol(transformCol)) {\n    const vcol = getVisibleColName(docModel, vcolRef);\n    const tcol = transformCol.colId();\n    const displayFormula = (vcolRef === 0 ? \"\" : `$${tcol}.${vcol}`);\n    return transformCol.saveDisplayFormula(displayFormula);\n  }\n}\n\n// Returns the name of the visibleCol given its rowId.\nfunction getVisibleColName(docModel: DocModel, visibleColRef: number): string | undefined {\n  return visibleColRef ? docModel.columns.getRowModel(visibleColRef).colId() : undefined;\n}\n\n// Returns whether the given column model is of type Ref or RefList.\nfunction isReferenceCol(colModel: ColumnRec) {\n  return isFullReferencingType(colModel.type());\n}\n"
  },
  {
    "path": "app/client/components/TypeTransform.ts",
    "content": "/**\n * TypeTransform extends ColumnTransform, creating the transform dom prompt that is shown when the\n * user changes the type of a data column. The purpose is to aid the user in converting data to the new\n * type by allowing a formula to be applied prior to conversion. It also allows for program-generated formulas\n * to be pre-entered for certain transforms (to Reference / Date) which the user can modify via dropdown menus.\n */\n\nimport { ColumnTransform } from \"app/client/components/ColumnTransform\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport * as TypeConversion from \"app/client/components/TypeConversion\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ColumnRec } from \"app/client/models/DocModel\";\nimport { reportError } from \"app/client/models/errors\";\nimport { cssButtonRow } from \"app/client/ui/RightPanelStyles\";\nimport { basicButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { FieldBuilder } from \"app/client/widgets/FieldBuilder\";\nimport { NewAbstractWidget } from \"app/client/widgets/NewAbstractWidget\";\nimport { UserAction } from \"app/common/DocActions\";\nimport { WidgetType } from \"app/common/widgetTypes\";\n\nimport { Computed, dom, fromKo, Observable } from \"grainjs\";\n\nconst t = makeT(\"TypeTransform\");\n\n// To simplify diff (avoid rearranging methods to satisfy private/public order).\n/* eslint-disable @typescript-eslint/member-ordering */\n\n/**\n * Creates an instance of TypeTransform for a single field. Extends ColumnTransform.\n */\nexport class TypeTransform extends ColumnTransform {\n  private _reviseTypeChange = Observable.create(this, false);\n  private _transformWidget: Computed<NewAbstractWidget | null>;\n  private _isFormWidget: Computed<boolean>;\n  private _convertColumn: ColumnRec;                 // Set in prepare()\n\n  constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) {\n    super(gristDoc, fieldBuilder);\n    this._shouldExecute = true;\n\n    // The display widget of the new transform column. Used to build the transform config menu.\n    // Only set while transforming.\n    this._transformWidget = Computed.create(this, fromKo(fieldBuilder.widgetImpl), (use, widget) => {\n      return use(this.origColumn.isTransforming) ? widget : null;\n    });\n\n    this._isFormWidget = Computed.create(this, use => use(use(this.field.viewSection).parentKey) === WidgetType.Form);\n  }\n\n  /**\n   * Build the transform menu for a type transform\n   */\n  public buildDom() {\n    // An observable to disable all buttons before the dom get removed.\n    const disableButtons = Observable.create(null, false);\n    this._reviseTypeChange.set(false);\n    return dom(\"div\",\n      testId(\"type-transform-top\"),\n      dom.domComputed((use) => {\n        const transformWidget = use(this._transformWidget);\n        if (!transformWidget) { return null; }\n\n        if (use(this._isFormWidget)) {\n          return transformWidget.buildFormTransformConfigDom();\n        } else {\n          return transformWidget.buildTransformConfigDom(this.gristDoc);\n        }\n      }),\n      dom.maybe(this._reviseTypeChange, () =>\n        dom(\"div.transform_editor\", this.buildEditorDom(),\n          testId(\"type-transform-formula\"),\n        ),\n      ),\n      cssButtonRow(\n        basicButton(dom.on(\"click\", () => { this.cancel().catch(reportError); disableButtons.set(true); }),\n          t(\"Cancel\"), testId(\"type-transform-cancel\"),\n          dom.cls(\"disabled\", disableButtons),\n        ),\n        dom.domComputed(this._reviseTypeChange, (revising) => {\n          if (revising) {\n            return basicButton(dom.on(\"click\", () => this.preview()),\n              t(\"Preview\"), testId(\"type-transform-update\"),\n              dom.cls(\"disabled\", use => use(disableButtons) || use(this.formulaUpToDate)),\n              { title: t(\"Update formula (Shift+Enter)\") },\n            );\n          } else {\n            return basicButton(dom.on(\"click\", () => { this._reviseTypeChange.set(true); }),\n              t(\"Revise\"), testId(\"type-transform-revise\"),\n              dom.cls(\"disabled\", disableButtons),\n            );\n          }\n        }),\n        primaryButton(dom.on(\"click\", () => { this.execute().catch(reportError); disableButtons.set(true); }),\n          t(\"Apply\"), testId(\"type-transform-apply\"),\n          dom.cls(\"disabled\", disableButtons),\n        ),\n      ),\n    );\n  }\n\n  /**\n   * Overrides parent method to initialize the transform column with guesses as to the particular\n   * type and column options.\n   * @param {String} toType: A pure or complete type for the transformed column.\n   */\n  protected async addTransformColumn(toType: string) {\n    const docModel = this.gristDoc.docModel;\n    const newColInfos = await this._tableData.sendTableActions([\n      [\"AddColumn\", \"gristHelper_Converted\", { type: \"Any\" }],\n      [\"AddColumn\", \"gristHelper_Transform\", { type: \"Any\" }],\n    ]);\n    const gristHelper_ConvertedRef = newColInfos[0].colRef;\n    const gristHelper_TransformRef = newColInfos[1].colRef;\n    this.transformColumn = docModel.columns.getRowModel(gristHelper_TransformRef);\n    this._convertColumn = docModel.columns.getRowModel(gristHelper_ConvertedRef);\n    const colInfo = await TypeConversion.prepTransformColInfo({\n      docModel,\n      origCol: this.origColumn,\n      origDisplayCol: this.origDisplayCol,\n      toTypeMaybeFull: toType,\n      convertedRef: this._convertColumn.colId.peek(),\n    });\n    // NOTE: We could add rules with AddColumn action, but there are some optimizations that converts array values.\n    const rules = colInfo.rules;\n    delete (colInfo as any).rules;\n    await this._tableData.sendTableActions([\n      [\"ModifyColumn\", this._convertColumn.colId.peek(), { ...colInfo, isFormula: false, formula: \"\" }],\n      [\"ModifyColumn\", this.transformColumn.colId.peek(), colInfo],\n    ]);\n    if (rules) {\n      await this.gristDoc.docData.sendActions([\n        [\"UpdateRecord\", \"_grist_Tables_column\", gristHelper_TransformRef, { rules }],\n      ]);\n    }\n    await this.convertValues();\n    return gristHelper_TransformRef;\n  }\n\n  protected convertValuesActions(): UserAction[] {\n    const tableId = this._tableData.tableId;\n    const srcColId = this.origColumn.colId.peek();\n    const dstColId = this._convertColumn.colId.peek();\n    const type = this.transformColumn.type.peek();\n    const widgetOptions = this.transformColumn.widgetOptions.peek();\n    const visibleColRef = this.transformColumn.visibleCol.peek();\n    return [[\"ConvertFromColumn\", tableId, srcColId, dstColId, type, widgetOptions, visibleColRef]];\n  }\n\n  protected async convertValues() {\n    await Promise.all([\n      this.gristDoc.docData.sendActions(this.convertValuesActions()),\n      TypeConversion.setDisplayFormula(this.gristDoc.docModel, this.transformColumn),\n    ]);\n  }\n\n  protected executeActions(): UserAction[] {\n    return [...this.convertValuesActions(), ...super.executeActions()];\n  }\n\n  /**\n   * Overrides parent method to subscribe to changes to the transform column.\n   */\n  protected postAddTransformColumn() {\n    // When a user-initiated change is saved to type or widgetOptions, reconvert the values\n    // Need to subscribe to both 'change' and 'save' for type which can come from setting the type itself\n    // or e.g. a change to DateTime timezone.\n    this.autoDispose(this.transformColumn.type.subscribe(this.convertValues, this, \"change\"));\n    this.autoDispose(this.transformColumn.type.subscribe(this.convertValues, this, \"save\"));\n    this.autoDispose(this.transformColumn.visibleCol.subscribe(this.convertValues, this, \"save\"));\n    this.autoDispose(this.field.widgetOptionsJson.subscribe(this.convertValues, this, \"save\"));\n  }\n\n  /**\n   * Overrides parent method to delete extra column\n   */\n  protected cleanup() {\n    void this._tableData.sendTableAction([\"RemoveColumn\", this._convertColumn.colId.peek()]);\n  }\n\n  /**\n   * When a type is changed, again guess appropriate column options.\n   */\n  public async setType(toType: string) {\n    const docModel = this.gristDoc.docModel;\n    const colInfo = await TypeConversion.prepTransformColInfo({\n      docModel,\n      origCol: this.origColumn,\n      origDisplayCol: this.origDisplayCol,\n      toTypeMaybeFull: toType,\n      convertedRef: this._convertColumn.colId.peek(),\n    });\n    const tcol = this.transformColumn;\n    await tcol.updateColValues(colInfo as any);\n  }\n}\n"
  },
  {
    "path": "app/client/components/UndoStack.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport * as dispose from \"app/client/lib/dispose\";\nimport { MinimalActionGroup } from \"app/common/ActionGroup\";\nimport { PromiseChain, setDefault } from \"app/common/gutil\";\nimport { CursorPos } from \"app/plugin/GristAPI\";\n\nimport { Computed, fromKo, Observable } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport sortBy from \"lodash/sortBy\";\n\nexport interface ActionGroupWithCursorPos extends MinimalActionGroup {\n  cursorPos?: CursorPos;\n  // For operations not done by the server, we supply a function to\n  // handle them.\n  op?: (ag: MinimalActionGroup, isUndo: boolean) => Promise<void>;\n}\n\n// Provides observables indicating disabled state for undo/redo.\nexport interface IUndoState {\n  isUndoDisabled: Observable<boolean>;\n  isRedoDisabled: Observable<boolean>;\n}\n\n/**\n * Maintains the stack of actions which can be undone and redone, and maintains the\n * position in this stack. Undo and redo actions are generated and sent to the server here.\n */\nexport class UndoStack extends dispose.Disposable {\n  public isDisabled: Observable<boolean>;\n  public undoDisabledObs: ko.Observable<boolean>;\n  public redoDisabledObs: ko.Observable<boolean>;\n  private _gristDoc: GristDoc;\n  private _stack: ActionGroupWithCursorPos[];\n  private _pointer: number;\n  private _linkMap: Map<number, ActionGroupWithCursorPos[]>;\n  private _undoDisabledOrBlockedObs: Computed<boolean>;\n\n  // Chain of promises which send undo actions to the server. This delays the execution of the\n  // next action until the current one has been received and moved the pointer index.\n  private _undoChain = new PromiseChain<void>();\n\n  public create(log: MinimalActionGroup[], options: {\n    gristDoc: GristDoc,\n    // if supplied, allow undos to be blocked for some external reason.\n    isUndoBlocked?: Observable<boolean>,\n  }) {\n    this._gristDoc = options.gristDoc;\n\n    this.isDisabled = Observable.create(this, false);\n\n    // TODO: _stack and _linkMap grow without bound within a single session.\n    // The top of the stack is stack.length - 1. The pointer points above the most\n    // recently applied (not undone) action.\n    this._stack = [];\n    this._pointer = 0;\n\n    // Map leading from actionNums to the action groups which link to them.\n    this._linkMap = new Map();\n\n    // Observables for when there is nothing to undo/redo.\n    this.undoDisabledObs = ko.observable(true);\n    this.redoDisabledObs = ko.observable(true);\n\n    this._undoDisabledOrBlockedObs = Computed.create(\n      this,\n      fromKo(this.undoDisabledObs),\n      options.isUndoBlocked || fromKo(ko.observable(false)),\n      (_, undoDisabled, blocked) => undoDisabled || blocked,\n    );\n\n    // Set the history nav interface in the DocPageModel to properly enable/disabled undo/redo.\n    if (this._gristDoc.docPageModel) {\n      this._gristDoc.docPageModel.undoState.set({\n        isUndoDisabled: this._undoDisabledOrBlockedObs,\n        isRedoDisabled: fromKo(this.redoDisabledObs),\n      });\n    }\n\n    // Initialize the stack from the log of recent actions from the server.\n    log.forEach((ag) => { this.pushAction(ag); });\n  }\n\n  /**\n   * Should only be given own actions. Pays attention to actionNum, otherId, linkId, and\n   * uses those to adjust undo index.\n   */\n  public pushAction(ag: MinimalActionGroup): void {\n    if (!ag.fromSelf) {\n      return;\n    }\n    const otherIndex = ag.otherId ?\n      this._stack.findIndex(a => a.actionNum === ag.otherId) : -1;\n\n    if (ag.linkId) {\n      // Link action. Add the action to the linkMap, but not to any stacks.\n      setDefault(this._linkMap, ag.linkId, []).push(ag);\n    } else if (otherIndex > -1) {\n      // Undo/redo action from the current session.\n      this._pointer = ag.isUndo ? otherIndex : otherIndex + 1;\n    } else {\n      // Either a normal action from the current session, or an undo/redo which\n      // applies to a non-recent action. Bury all undone actions.\n      if (!this.redoDisabledObs()) {\n        this._stack.splice(this._pointer);\n      }\n      // Reset pointer and add to the stack (if not an undo action).\n      if (!ag.otherId) {\n        this._stack.push(ag);\n      }\n      this._pointer = this._stack.length;\n    }\n    this.undoDisabledObs(this._pointer <= 0);\n    this.redoDisabledObs(this._pointer >= this._stack.length);\n  }\n\n  // Send an undo action. This should be called when the user presses 'undo'.\n  public async sendUndoAction(): Promise<void> {\n    if (this.isDisabled.get()) { return; }\n\n    return this._undoChain.add(() => this._sendAction(true));\n  }\n\n  // Send a redo action. This should be called when the user presses 'redo'.\n  public async sendRedoAction(): Promise<void> {\n    if (this.isDisabled.get()) { return; }\n\n    return this._undoChain.add(() => this._sendAction(false));\n  }\n\n  public enable(): void {\n    this.isDisabled.set(false);\n  }\n\n  public disable(): void {\n    this.isDisabled.set(true);\n  }\n\n  private async _sendAction(isUndo: boolean): Promise<void> {\n    // Pick the action group to undo or redo.\n    const ag = this._stack[isUndo ? this._pointer - 1 : this._pointer];\n    if (!ag) { return; }\n\n    try {\n      // Get all actions in the bundle that starts at the current index. Typically, an array with a\n      // single action group is returned.\n      const actionGroups = this._findActionBundle(ag);\n      // rowId may not exist when we're undoing certain changes (e.g. add record), meaning no row\n      // ends up selected. Removing rowId makes it use rowIndex instead, which gets us to the closest\n      // valid row.\n      const returnCursorPos = ag.cursorPos && { ...ag.cursorPos, rowId: undefined };\n      // When we undo/redo, jump to the place where this action occurred, to bring the user to the\n      // context where the change was originally made. We jump first immediately to feel more\n      // responsive, then again when the action is done. The second jump matters more for most\n      // changes, but the first is the important one when Undoing an AddRecord.\n      this._gristDoc.moveToCursorPos(returnCursorPos, ag).catch(() => { /* do nothing */ });\n      if (actionGroups.length === 1 && actionGroups[0].op) {\n        // this is an internal operation, rather than one done by the server,\n        // so we can't ask the server to undo it.\n        await actionGroups[0].op(actionGroups[0], isUndo);\n      } else {\n        await this._gristDoc.docComm.applyUserActionsById(\n          actionGroups.map(a => a.actionNum),\n          actionGroups.map(a => a.actionHash),\n          isUndo,\n          { otherId: ag.actionNum });\n      }\n      this._gristDoc.moveToCursorPos(returnCursorPos, ag).catch(() => { /* do nothing */ });\n    } catch (err) {\n      err.message = `Failed to apply ${isUndo ? \"undo\" : \"redo\"} action: ${err.message}`;\n      throw err;\n    }\n  }\n\n  /**\n   * Find all actionGroups in the bundle that starts with the given action group.\n   */\n  private _findActionBundle(ag: ActionGroupWithCursorPos) {\n    const prevNums = new Set();\n    const actionGroups = [];\n    const queue = [ag];\n    // Follow references through the linkMap adding items to the array bundle.\n    while (queue.length) {\n      ag = queue.pop()!;\n      // Checking that actions are only accessed once prevents an infinite circular loop.\n      if (prevNums.has(ag.actionNum)) {\n        break;\n      }\n      actionGroups.push(ag);\n      prevNums.add(ag.actionNum);\n      queue.push(...this._linkMap.get(ag.actionNum) || []);\n    }\n    return sortBy(actionGroups, group => group.actionNum);\n  }\n}\n"
  },
  {
    "path": "app/client/components/UnsavedChanges.ts",
    "content": "/**\n * Module to help deal with unsaved changes when closing a page.\n */\nimport { Disposable } from \"grainjs\";\n\n/**\n * Create an UnsavedChanges object to indicate there are UnsavedChanges. Dispose it when this is\n * no longer the case. The optional callback will be called to confirm there are indeed unsaved\n * changes. If omitted, it is assumed that there are.\n */\nexport class UnsavedChange extends Disposable {\n  constructor(\n    // If given, saveChanges() will call it to save changes.\n    private _saveCB?: () => Promise<void>,\n    // If given, it may return false to indicate that actually nothing has changed.\n    private _haveChanges?: () => boolean,\n  ) {\n    super();\n    unsavedChanges.add(this);\n    this.onDispose(() => unsavedChanges.delete(this));\n  }\n\n  public haveUnsavedChanges() { return !this._haveChanges || this._haveChanges(); }\n  public async save(): Promise<void> { return this._saveCB?.(); }\n}\n\nexport class UnsavedChangeSet {\n  private _changes = new Set<UnsavedChange>();\n\n  /**\n   * Check if there are any unsaved changes out there.\n   */\n  public haveUnsavedChanges(): boolean {\n    return Array.from(this._changes).some(c => c.haveUnsavedChanges());\n  }\n\n  /**\n   * Save any unsaved changes out there.\n   */\n  public async saveChanges(): Promise<void> {\n    await Promise.all(Array.from(this._changes).map(c => c.save()));\n  }\n\n  public add(unsaved: UnsavedChange) { this._changes.add(unsaved); }\n  public delete(unsaved: UnsavedChange) { this._changes.delete(unsaved); }\n}\n\n// Global set of UnsavedChanges, checked on page unload.\nexport const unsavedChanges = new UnsavedChangeSet();\n"
  },
  {
    "path": "app/client/components/VersionUpdateBanner.ts",
    "content": "import { Banner, buildBannerMessage } from \"app/client/components/Banner\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { localStorageJsonObs } from \"app/client/lib/localStorageObs\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Disposable, dom, makeTestId, Observable } from \"grainjs\";\n\nconst t = makeT(\"VersionUpdateBanner\");\nconst testId = makeTestId(\"test-version-update-banner-\");\n\ninterface ShowVersionUpdateBannerPrefer {\n  dismissed: boolean,\n  version?: string,\n}\n\nexport class VersionUpdateBanner extends Disposable {\n  // Session storage observable. Set to false to dismiss the banner for the session.\n  private _showVersionUpdateBannerPref: Observable<ShowVersionUpdateBannerPrefer>;\n\n  constructor(private _appModel: AppModel) {\n    super();\n    const userId = this._appModel.currentUser?.id ?? 0;\n    const { latestVersionAvailable } = getGristConfig();\n\n    this._showVersionUpdateBannerPref = localStorageJsonObs(\n      `u=${userId}:showVersionUpdateBanner`,\n      {\n        dismissed: false,\n        version: latestVersionAvailable?.version,\n      },\n    );\n  }\n\n  public buildDom() {\n    return dom.maybe(this._appModel.isInstallAdmin(), () => {\n      return dom.domComputed((use) => {\n        const { latestVersionAvailable } = getGristConfig();\n        if (!latestVersionAvailable?.isNewer) {\n          return null;\n        }\n\n        const bannerPref = use(this._showVersionUpdateBannerPref);\n        // Need to check that *this* specific version has already been\n        // dismissed.\n        //\n        // Although we only store one version as being dismissed at a\n        // time, that should be okay. Conceptually, there is only one\n        // \"latest\" version at a time that needs to be dismissed.\n        if (bannerPref.version === latestVersionAvailable.version && bannerPref.dismissed) {\n          return null;\n        }\n\n        const versionParam = { version: latestVersionAvailable.version };\n        const msg = latestVersionAvailable.isCritical ?\n          t(\n            `There is a critical Grist update available.\nConsider upgrading to version {{version}} as soon as possible.`, versionParam) :\n          t(\n            `Your Grist version is outdated.\nConsider upgrading to version {{version}} as soon as possible.`, versionParam);\n\n        return dom.create(Banner, {\n          content: buildBannerMessage(msg, testId(\"text\")),\n          style: latestVersionAvailable.isCritical ? \"error\" : \"warning\",\n          showCloseButton: true,\n          onClose: () => this._showVersionUpdateBannerPref.set({\n            dismissed: true,\n            version: latestVersionAvailable.version,\n          }),\n        });\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "app/client/components/ViewAsBanner.ts",
    "content": "import { ACLUsersPopup } from \"app/client/aclui/ACLUsers\";\nimport { Banner } from \"app/client/components/Banner\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/AppModel\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { cssInfoTooltipButton, withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { primaryButtonLink } from \"app/client/ui2018/buttons\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssSelectBtn } from \"app/client/ui2018/select\";\nimport { PermissionDataWithExtraUsers } from \"app/common/ActiveDocAPI\";\nimport { UserOverride } from \"app/common/DocListAPI\";\nimport { userOverrideParams } from \"app/common/gristUrls\";\nimport { waitGrainObs } from \"app/common/gutil\";\nimport { getUserRoleText } from \"app/common/UserAPI\";\n\nimport { Disposable, dom, styled } from \"grainjs\";\nimport { cssMenuItem } from \"popweasel\";\n\nconst t = makeT(\"ViewAsBanner\");\nconst userT = makeT(\"UserManagerModel\");\n\nexport class ViewAsBanner extends Disposable {\n  private _userOverride = this._docPageModel.userOverride;\n  private _usersPopup = ACLUsersPopup.create(this, this._docPageModel, this._getUsersForViewAs.bind(this));\n\n  constructor(private _docPageModel: DocPageModel) {\n    super();\n  }\n\n  public buildDom() {\n    return dom.maybe(this._userOverride, (userOverride) => {\n      this._initViewAsUsers().catch(reportError);\n      return dom.create(Banner, {\n        content: this._buildContent(userOverride),\n        style: \"info\",\n        showCloseButton: false,\n        showExpandButton: false,\n        bannerCssClass: cssBanner.className,\n      });\n    });\n  }\n\n  private _buildContent(userOverride: UserOverride) {\n    const { user, access } = userOverride;\n    const sharedUser = user && user.id > 0;\n    return cssContent(\n      cssMessageText(\n        cssMessageIcon(\"EyeShow\"),\n        sharedUser ? t(\"You are viewing this document as\") :\n          t(\"You're seeing what this user would see if given access\"),\n      ),\n      cssSelectBtn(\n        { tabIndex: \"0\" },\n        cssBtnText(\n          user ? cssMember(\n            user.name || user.email,\n            cssRole(\"(\", userT(getUserRoleText({ ...user, access })), \")\", dom.show(Boolean(access))),\n          ) : t(\"UnknownUser\"),\n        ),\n        dom(\n          \"div\", { style: \"flex: none;\" },\n          cssInlineCollapseIcon(\"Collapse\"),\n        ),\n        elem => this._usersPopup.attachPopup(elem, {}),\n        testId(\"select-open\"),\n      ),\n      withInfoTooltip(\n        cssPrimaryButtonLink(\n          t(\"View as Yourself\"), cssIcon(\"Convert\"),\n          urlState().setHref(userOverrideParams(null)),\n          testId(\"revert\"),\n        ),\n        \"viewAsBanner\",\n        {\n          iconDomArgs: [\n            cssInfoTooltipButton.cls(\"-in-banner\"),\n            testId(\"view-as-help-tooltip\"),\n          ],\n        },\n      ),\n      testId(\"view-as-banner\"),\n    );\n  }\n\n  private async _initViewAsUsers() {\n    await waitGrainObs(this._docPageModel.gristDoc);\n    await this._usersPopup.load();\n  }\n\n  private _getUsersForViewAs(): Promise<PermissionDataWithExtraUsers> {\n    const docId = this._docPageModel.currentDocId.get()!;\n    const docApi = this._docPageModel.appModel.api.getDocAPI(docId);\n    return docApi.getUsersForViewAs();\n  }\n}\n\nconst cssContent = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  width: 100%;\n  column-gap: 13px;\n  align-items: center;\n  & .${cssSelectBtn.className} {\n    width: 184px;\n  }\n`);\nconst cssIcon = styled(icon, `\n  margin-left: 10px;\n`);\nconst cssMember = styled(\"span\", `\n  font-weight: 500;\n  color: ${theme.text};\n\n  .${cssMenuItem.className}-sel & {\n    color: ${theme.menuItemSelectedFg};\n  }\n`);\nconst cssRole = styled(\"span\", `\n  font-weight: 400;\n  margin-left: 1ch;\n`);\nconst cssMessageText = styled(\"span\", `\n`);\nconst cssMessageIcon = styled(icon, `\n  margin-right: 10px;\n`);\nconst cssPrimaryButtonLink = styled(primaryButtonLink, `\n  margin-left: 5px;\n`);\nconst cssBtnText = styled(\"div\", `\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`);\nconst cssInlineCollapseIcon = styled(icon, `\n  margin: 0 2px;\n  pointer-events: none;\n`);\nconst cssBanner = styled(\"div\", `\n  border-bottom: 1px solid ${theme.pagePanelsBorder};\n  height: 45px;\n`);\n"
  },
  {
    "path": "app/client/components/ViewConfigTab.css",
    "content": ".view_config_draggable_field {\n  position: relative;\n  margin: .2rem .5rem;\n  padding: .2rem;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.view_config_draggable_field:hover {\n  background-color: var(--color-list-item-hover);\n}\n\n.view_config_draggable_field > .drag_delete {\n  float: none;\n  position: absolute;\n  top: 0.3rem;\n  right: 0.2rem;\n\n  background-color: white;\n  padding: 0.2rem;\n  border-radius: 1rem;\n  margin: 0;\n}\n\n.view_config_draggable_field > .drag_delete:hover {\n  color: black;\n}\n\n.view_config_field_group.kf_collapser {\n  font-size: inherit;\n  font-weight: bold;\n  margin: 1rem .5rem;\n}\n\n.view_config_draggable_field > .kf_draggable_content {\n  display: inline;\n}\n"
  },
  {
    "path": "app/client/components/ViewConfigTab.js",
    "content": "var _ = require(\"underscore\");\nvar ko = require(\"knockout\");\nvar dispose = require(\"../lib/dispose\");\nvar dom = require(\"../lib/dom\");\nvar kd = require(\"../lib/koDom\");\nvar kf = require(\"../lib/koForm\");\nvar koArray = require(\"../lib/koArray\");\nvar commands = require(\"./commands\");\nvar {CustomSectionElement} = require(\"../lib/CustomSectionElement\");\nconst {ChartConfig} = require(\"./ChartView\");\nconst {Computed, dom: grainjsDom, makeTestId, Holder} = require(\"grainjs\");\n\nconst {cssRow, cssWarningBox, cssWarningHeader} = require(\"app/client/ui/RightPanelStyles\");\nconst {SortFilterConfig} = require(\"app/client/ui/SortFilterConfig\");\nconst {primaryButton} = require(\"app/client/ui2018/buttons\");\nconst {select} = require(\"app/client/ui2018/menus\");\nconst {confirmModal} = require(\"app/client/ui2018/modals\");\nconst {makeT} = require(\"app/client/lib/localization\");\n\nconst testId = makeTestId(\"test-vconfigtab-\");\n\nconst t = makeT(\"ViewConfigTab\");\n\n/**\n * Helper class that combines one ViewSection's data for building dom.\n */\nfunction ViewSectionData(section) {\n  this.section = section;\n\n  // A koArray reflecting the columns (RowModels) that are not present in the current view.\n  this.hiddenFields = this.autoDispose(koArray.syncedKoArray(section.hiddenColumns));\n}\ndispose.makeDisposable(ViewSectionData);\n\n\nfunction ViewConfigTab(options) {\n  var self = this;\n  this.gristDoc = options.gristDoc;\n  this.viewModel = options.viewModel;\n  this._viewSectionDataHolder = Holder.create(this);\n\n  // viewModel may point to different views, but viewSectionData is a single koArray reflecting\n  // the sections of the current view.\n  this.viewSectionData = this.autoDispose(\n    koArray.syncedKoArray(this.viewModel.viewSections, function(section) {\n      return ViewSectionData.create(section);\n    })\n      .setAutoDisposeValues()\n  );\n\n  this.isDetail = this.autoDispose(ko.computed(function() {\n    return [\"detail\", \"single\"].includes(this.viewModel.activeSection().parentKey());\n  }, this));\n  this.isChart = this.autoDispose(ko.computed(function() {\n    return this.viewModel.activeSection().parentKey() === \"chart\";\n  }, this));\n  this.isGrid = this.autoDispose(ko.computed(function() {\n    return this.viewModel.activeSection().parentKey() === \"record\";\n  }, this));\n  this.isCustom = this.autoDispose(ko.computed(function() {\n    return this.viewModel.activeSection().parentKey() === \"custom\";\n  }, this));\n  this.isRaw = this.autoDispose(ko.computed(function() {\n    return this.viewModel.activeSection().isRaw();\n  }, this));\n  this.isRecordCard = this.autoDispose(ko.computed(function() {\n    return this.viewModel.activeSection().isRecordCard();\n  }, this));\n\n  this.activeRawOrRecordCardSectionData = this.autoDispose(ko.computed(function() {\n    return self.isRaw() || self.isRecordCard()\n      ? self._viewSectionDataHolder.autoDispose(ViewSectionData.create(self.viewModel.activeSection()))\n      : null;\n  }));\n  this.activeSectionData = this.autoDispose(ko.computed(function() {\n    return (\n      _.find(self.viewSectionData.all(), function(sectionData) {\n        return sectionData.section &&\n          sectionData.section.getRowId() === self.viewModel.activeSectionId();\n      })\n      || self.activeRawOrRecordCardSectionData()\n      || self.viewSectionData.at(0)\n    );\n  }));\n}\ndispose.makeDisposable(ViewConfigTab);\n\n\nViewConfigTab.prototype.buildSortFilterDom = function() {\n  return grainjsDom.maybe(this.activeSectionData, ({section}) => {\n    return grainjsDom.create(SortFilterConfig, section, this.gristDoc);\n  });\n};\n\nViewConfigTab.prototype._makeOnDemand = function(table) {\n  // After saving the changed setting, force the reload of the document.\n  const onConfirm = () => {\n    return table.onDemand.saveOnly(!table.onDemand.peek())\n      .then(() => {\n        return this.gristDoc.docComm.reloadDoc()\n          .catch((err) => {\n            // Ignore the expected error from the socket shutdown that we asked for.\n            if (!err.message.includes(\"GristWSConnection disposed\")) {\n              throw err;\n            }\n          });\n      });\n  };\n\n  if (table.onDemand()) {\n    confirmModal(\"Unmark table On-Demand?\", \"Unmark On-Demand\", onConfirm, {\n      explanation: dom(\"div\", \"If you unmark table \", dom(\"b\", table), \" as On-Demand, \" +\n        \"its data will be loaded into the calculation engine and will be available \" +\n        \"for use in formulas. For a big table, this may greatly increase load times.\",\n      dom(\"br\"), dom(\"br\"), \"Changing this setting will reload the document for all users.\"),\n    });\n  } else {\n    confirmModal(\"Make table On-Demand?\", \"Make On-Demand\", onConfirm, {\n      explanation: dom(\"div\", \"If you make table \", dom(\"b\", table), \" On-Demand, \" +\n        \"its data will no longer be loaded into the calculation engine and will not be available \" +\n        \"for use in formulas. It will remain available for viewing and editing.\",\n      dom(\"br\"), dom(\"br\"), \"Changing this setting will reload the document for all users.\"),\n    });\n  }\n};\n\nViewConfigTab.prototype._buildAdvancedSettingsDom = function() {\n  return kd.maybe(() => {\n    const s = this.activeSectionData();\n    return s && !s.section.table().summarySourceTable() ? s : null;\n  }, (sectionData) => {\n\n    const table = sectionData.section.table();\n    const isCollapsed = ko.observable(true);\n    return [\n      kf.collapserLabel(isCollapsed, t(\"Advanced settings\"), dom.testId(\"ViewConfig_advanced\")),\n      kf.helpRow(kd.hide(isCollapsed),\n        t(\"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\"),\n        kd.style(\"text-align\", \"left\"),\n        kd.style(\"margin-top\", \"1.5rem\")\n      ),\n\n      kf.helpRow(kd.hide(isCollapsed),\n        cssWarningBox(\n          cssWarningHeader(\n            t(\"⚠️ Deprecated Feature\"),\n          ),\n          t(\"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\"),\n        )\n      ),\n\n      kf.row(kd.hide(isCollapsed),\n        dom(\"div\", primaryButton(\n          kd.text(() => table.onDemand() ? t(\"Unmark On-Demand\") : t(\"Make On-Demand\")),\n          kd.style(\"margin-top\", \"1rem\"),\n          dom.on(\"click\", () => this._makeOnDemand(table)),\n          dom.testId(\"ViewConfig_onDemandBtn\"),\n        )),\n      ),\n    ];\n  });\n};\n\nViewConfigTab.prototype._buildThemeDom = function() {\n  return kd.maybe(() => this.isDetail() ? this.activeSectionData() : null, (sectionData) => {\n    const section = sectionData.section;\n    const theme = Computed.create(null, (use) => use(section.themeDef));\n    theme.onWrite(val => section.themeDef.setAndSave(val));\n    return cssRow(\n      dom.autoDispose(theme),\n      select(theme, [\n        {label: t(\"Form\"),        value: \"form\"   },\n        {label: t(\"Compact\"),     value: \"compact\"},\n        {label: t(\"Blocks\"),      value: \"blocks\"  },\n      ]),\n      testId(\"detail-theme\")\n    );\n  });\n};\n\nViewConfigTab.prototype._buildChartConfigDom = function() {\n  return grainjsDom.maybe(this.viewModel.activeSection, (section) => grainjsDom.create(ChartConfig, this.gristDoc, section));\n};\n\nViewConfigTab.prototype._buildLayoutDom = function() {\n  return kd.maybe(() => this.isDetail() ? this.activeSectionData() : null, (sectionData) => {\n    const view = sectionData.section.viewInstance.peek();\n    const layoutEditorObs = ko.computed(() => view && view.recordLayout && view.recordLayout.layoutEditor());\n    return cssRow({style: \"margin-top: 16px;\"},\n      kd.maybe(layoutEditorObs, (editor) => editor.buildFinishButtons()),\n      primaryButton(t(\"Edit card layout\"),\n        dom.autoDispose(layoutEditorObs),\n        dom.on(\"click\", () => commands.allCommands.editLayout.run()),\n        grainjsDom.hide(layoutEditorObs),\n        grainjsDom.cls(\"behavioral-prompt-edit-card-layout\"),\n        testId(\"detail-edit-layout\"),\n      )\n    );\n  });\n};\n\n/**\n * Builds the three items for configuring a `Custom View`:\n *  1) Mode picker: let user choose between 'url' and 'plugin' mode\n *  2) Show if 'url' mode: let user enter the url\n *  3) Show if 'plugin' mode: let user pick a plugin and a section from the list of available plugin.\n */\nViewConfigTab.prototype._buildCustomTypeItems = function() {\n  const docPluginManager = this.gristDoc.docPluginManager;\n  const activeSection = this.viewModel.activeSection;\n\n  // all available custom sections grouped by their plugin id\n  const customSections = _.groupBy(CustomSectionElement.getSections(docPluginManager.pluginsList), s => s.pluginId);\n\n  // all plugin ids which have custom sections\n  const allPlugins = Object.keys(customSections);\n\n  // the list of customSections of the selected plugin (computed)\n  const customSectionIds = ko.pureComputed(() => {\n    const sections = customSections[this.viewModel.activeSection().customDef.pluginId()] || [];\n    return sections.map(({sectionId}) => sectionId);\n  });\n\n  return [{\n\n    // 1)\n    buildDom: () => kd.scope(activeSection, ({customDef}) => kf.buttonSelect(customDef.mode,\n      kf.optionButton(\"url\", \"URL\", dom.testId(\"ViewConfigTab_customView_url\")),\n      kf.optionButton(\"plugin\", \"Plugin\", dom.testId(\"ViewConfigTab_customView_plugin\"))))\n  }, {\n\n    // 2)\n    // TODO: refactor this part, Custom Widget moved to separate file.\n  }, {\n\n    // 3)\n    showObs: () => activeSection().customDef.mode() === \"plugin\",\n    buildDom: () => kd.scope(activeSection, ({customDef}) => dom(\"div\",\n      kf.row(5, t(\"Plugin: \"), 13, kf.text(customDef.pluginId, {}, {list: \"list_plugin\"}, dom.testId(\"ViewConfigTab_customView_pluginId\"))),\n      kf.row(5, t(\"Section: \"), 13, kf.text(customDef.sectionId, {}, {list: \"list_section\"},  dom.testId(\"ViewConfigTab_customView_sectionId\"))),\n      // For both `customPlugin` and `selectedSection` it is possible for the value not to be in the\n      // list of options. Combining <datalist> and <input> allows both to freely edit the value with\n      // keyboard and to select it from a list. Although the content of the list seems to be\n      // filtered by the current value, which could confuse user into thinking that there are no\n      // available options. I think it would be better to have the full list always, but it seems\n      // harder to accomplish and is left as a TODO.\n      dom(\"datalist#list_plugin\",  kd.foreach(koArray(allPlugins), value => dom(\"option\", {value}))),\n      dom(\"datalist#list_section\", kd.scope(customSectionIds, sections => kd.foreach(koArray(sections), (value) => dom(\"option\", {value}))))\n    ))\n  }];\n};\n\nmodule.exports = ViewConfigTab;\n"
  },
  {
    "path": "app/client/components/ViewLayout.css",
    "content": ".view_leaf {\n  position: relative;\n  flex: 1 1 0px;\n}\n\n.viewsection_buttons {\n  margin-left: 4px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  align-self: flex-start;\n}\n\n.viewsection_title {\n  align-items: center;\n  flex-shrink: 0;\n  cursor: default;\n  min-height: 24px;\n  margin-left: -16px;   /* to include drag handle that shows up on hover */\n  margin-bottom: 4px;\n  white-space: nowrap;\n  overflow: hidden;\n}\n\n.viewsection_title, .viewsection_title_font {\n  color: var(--grist-theme-text-light, var(--grist-color-slate));\n  font-size: var(--grist-small-font-size);\n  font-weight: 500;\n}\n\n.viewsection_content {\n  overflow: visible;\n  margin: 12px;\n  background-color: var(--grist-theme-widget-bg, white);\n}\n\n.viewsection_btn {\n  display: inline-block;\n  padding: 0px 4px;\n}\n\n.viewsection_settings {\n  display: inline-block;\n  vertical-align: middle;\n}\n\n.viewsection_status_icons {\n  color: #999999;\n}\n\n.viewsection_status_icons > .status_icon:hover {\n  color: black;\n}\n\n.viewsection_status_icons.active_section {\n  color: #AEC6FE;\n}\n\n.viewsection_status_icons.active_section  > .status_icon:hover {\n  color: white;\n}\n\n.viewsection_truncated {\n  position: absolute;\n  right: 8px;\n  bottom: 8px;\n  background-color: red;\n  color: white;\n  z-index: 1;\n}\n\n.link_direction_icon {\n  display: inline-block;\n  position: relative;\n  vertical-align: top;\n  width: 1.9rem;\n  height: 1.2rem;\n  margin: .25rem -.1rem 0 -.2rem;\n}\n\n.viewsection_status_icons.active_section  > .status_icon.unsaved_changes {\n  text-shadow: 0px 0px 5px #fff;\n  color: #FFFFFF;\n}\n\n.view_data_pane_container {\n  position: relative;\n  flex: auto;\n  border: 1px solid var(--grist-theme-widget-border, var(--grist-color-dark-grey));\n}\n\n@media not print {\n.active_section > .view_data_pane_container {\n  box-shadow: -2px 0 0 0px var(--grist-theme-widget-active-border, var(--grist-color-light-green));\n  border-left: 1px solid var(--grist-theme-widget-active-border, var(--grist-color-light-green));\n}\n\n.active_section--no-focus > .view_data_pane_container {\n  box-shadow: -2px 0 0 0px var(--grist-theme-widget-active-non-focused-border);\n  border-left: 1px solid var(--grist-theme-widget-active-non-focused-border);\n}\n\n.active_section > .view_data_pane_container.viewsection_type_detail {\n  /* this color is a translucent version of grist-color-light-green */\n  box-shadow: -2px 0 0 0px var(--grist-theme-cursor-inactive, var(--grist-color-inactive-cursor));\n  border-left: 1px solid var(--grist-theme-cursor-inactive, var(--grist-color-inactive-cursor));\n}\n}\n\n/* Used by Raw Data UI */\n.active_section--no-indicator > .view_data_pane_container,\n.active_section--no-indicator > .view_data_pane_container.viewsection_type_detail {\n  box-shadow: none;\n  border-left: 1px solid var(--grist-theme-widget-border, var(--grist-color-dark-grey));\n}\n\n/* Used by full screen section. Removes the green box-shadow and restores normal color of the border.\n   It still leaves the indicator for the cardlist selection (the green box shadow in card) which looks nice.\n*/\n.layout_box_maximized .active_section > .view_data_pane_container{\n  box-shadow: none;\n  border: 1px solid var(--grist-theme-widget-border, var(--grist-color-dark-grey));\n}\n/* Remove the drag indicator */\n.layout_box_maximized .active_section .viewsection_drag_indicator {\n  visibility: hidden !important;\n}\n\n\n.disable_viewpane {\n  justify-content: center;\n  text-align: center;\n  position: absolute;\n  z-index: 1;\n  width: 100%;\n  height: 100%;\n  color: var(--grist-theme-text-light, var(--grist-color-slate));\n  background-color: rgba(0, 0, 0, 0.1);\n  font-size: 12pt;\n}\n\n.status_icon.unsaved_changes {\n  text-shadow: 0px 0px 5px #8A8A8A;\n  color: #FFFFFF\n}\n\n.link_direction_icon.has_in_arrow {\n  margin-left: .3rem;\n}\n\n.link_direction_icon.has_out_arrow {\n  margin-right: .2rem;\n}\n\n.link_icon {\n  position: absolute;\n  font-size: 1.05rem;\n  left: .45rem;\n}\n\n.link_out_arrow {\n  position: absolute;\n  top: .5rem;\n  left: 1.3rem;\n  font-size: .65rem;\n  transform: scale(.8, 1);\n}\n\n.link_in_arrow {\n  position: absolute;\n  top: .05rem;\n  left: 0;\n  font-size: .65rem;\n  transform: scale(.8, 1);\n}\n\n.sort_icon {\n  display: inline-block;\n  position: relative;\n  vertical-align: top;\n  width: 1.2rem;\n  height: 1.2rem;\n  font-size: 1.0rem;\n  margin: .25rem .1rem 0 .3rem;\n}\n\n.filter_icon {\n  display: inline-block;\n  position: relative;\n  vertical-align: top;\n  width: 1.2rem;\n  height: 1.2rem;\n  font-size: 1.0rem;\n  margin: .25rem .1rem 0 .3rem;\n}\n\n.shaking {\n  animation: shake 0.4s ease;\n  transform: translate(0, 0);\n}\n\n@keyframes shake {\n  10%, 90% {\n    transform: translate(2px, 0);\n  }\n  30%, 70% {\n    transform: translate(-3px, 0);\n  }\n  50% {\n    transform: translate(3px, 0);\n  }\n}\n"
  },
  {
    "path": "app/client/components/ViewLayout.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport { buildViewSectionDom } from \"app/client/components/buildViewSectionDom\";\nimport { ChartView } from \"app/client/components/ChartView\";\nimport * as commands from \"app/client/components/commands\";\nimport { CustomCalendarView } from \"app/client/components/CustomCalendarView\";\nimport { CustomView } from \"app/client/components/CustomView\";\nimport DetailView from \"app/client/components/DetailView\";\nimport { buildDuplicateWidgetModal } from \"app/client/components/duplicateWidget\";\nimport { FormView } from \"app/client/components/Forms/FormView\";\nimport GridView from \"app/client/components/GridView\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { Layout } from \"app/client/components/Layout\";\nimport { LayoutEditor } from \"app/client/components/LayoutEditor\";\nimport { LayoutTray } from \"app/client/components/LayoutTray\";\nimport { printViewSection } from \"app/client/components/Printing\";\nimport { BoxSpec, purgeBoxSpec } from \"app/client/lib/BoxSpec\";\nimport { Delay } from \"app/client/lib/Delay\";\nimport { createObsArray } from \"app/client/lib/koArrayWrap\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { ViewRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { reportError } from \"app/client/models/errors\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { getTelemetryWidgetTypeFromVS } from \"app/client/ui/widgetTypesMap\";\nimport { cssRadioCheckboxOptions, radioCheckboxOption } from \"app/client/ui2018/checkbox\";\nimport { isNarrowScreen, mediaSmall, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { ISaveModalOptions, saveModal } from \"app/client/ui2018/modals\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\n\nimport {\n  Computed,\n  computedArray,\n  Disposable,\n  dom,\n  fromKo,\n  Holder,\n  IDomComponent,\n  MultiHolder,\n  Observable,\n  styled,\n  subscribe,\n} from \"grainjs\";\nimport * as ko from \"knockout\";\nimport debounce from \"lodash/debounce\";\n\nconst t = makeT(\"ViewLayout\");\n\nconst viewSectionTypes: { [key: string]: any } = {\n  \"record\": GridView,\n  \"detail\": DetailView,\n  \"chart\": ChartView,\n  \"single\": DetailView,\n  \"custom\": CustomView,\n  \"form\": FormView,\n  \"custom.calendar\": CustomCalendarView,\n};\n\nfunction getInstanceConstructor(parentKey: string) {\n  const Cons = viewSectionTypes[parentKey];\n  if (!Cons) {\n    console.error(\"ViewLayout error: requested an unsupported section type:\", parentKey);\n  }\n  // Default to GridView if no valid constructor\n  return Cons || viewSectionTypes.record;\n}\n\nexport class ViewSectionHelper extends Disposable {\n  private _instance = Holder.create<BaseView>(this);\n\n  constructor(gristDoc: GristDoc, vs: ViewSectionRec, options?: any) {\n    super();\n    this.onDispose(() => {\n      if (vs.isDisposed()) { return; }\n      vs.viewInstance(null);\n    });\n\n    this.autoDispose(subscribe((use) => {\n      if (vs.isDisposed()) { return; }\n      // Rebuild the section when its type changes or its underlying table.\n      const table = use(vs.table);\n      const key = use(vs.parentKey);\n      const Cons = getInstanceConstructor(key);\n      const viewOptions = options?.[key] || {};\n      this._instance.clear();\n      if (table.getRowId()) {\n        this._instance.autoDispose(Cons.create(null, gristDoc, vs, viewOptions));\n      }\n      vs.viewInstance(this._instance.get());\n    }));\n  }\n}\n\n/**\n * ViewLayout - Handles layout for a single page.\n */\nexport class ViewLayout extends DisposableWithEvents implements IDomComponent {\n  public docModel = this.gristDoc.docModel;\n  public viewModel: ViewRec;\n  public layoutSpec: ko.Computed<BoxSpec>;\n  public maximized: Observable<number | null>;\n  public isResizing = Observable.create(this, false);\n  public layout: Layout;\n  public layoutEditor: LayoutEditor;\n  public layoutTray: LayoutTray;\n  public layoutSaveDelay = this.autoDispose(new Delay());\n\n  private _freeze = false;\n  // Exposed for test to indicate that save has not yet been called.\n  private _savePending = Observable.create(this, false);\n  constructor(public readonly gristDoc: GristDoc, viewId: number) {\n    super();\n    this.viewModel = this.docModel.views.getRowModel(viewId);\n\n    // A Map from viewSection RowModels to corresponding View class instances.\n    // TODO add a test that creating / deleting a section creates/destroys one instance, and\n    // switching pages destroys all instances.\n    const viewSectionObs = createObsArray(this, this.viewModel.viewSections());\n    this.autoDispose(computedArray(viewSectionObs, (vs, i, compArr) =>\n      ViewSectionHelper.create(compArr, gristDoc, vs)));\n\n    // Update the stored layoutSpecObj with any missing fields that are present in viewFields.\n    this.layoutSpec = this.autoDispose(ko.computed(\n      () => this._updateLayoutSpecWithSections(this.viewModel.layoutSpecObj()))\n      .extend({ rateLimit: 0 }));\n\n    this.layout = this.autoDispose(Layout.create(this.layoutSpec(),\n      this._buildLeafContent.bind(this), true));\n\n    // When the layoutSpec changes by some means other than the layout editor, rebuild.\n    // This includes adding/removing sections and undo/redo.\n    this.autoDispose(this.layoutSpec.subscribe(spec => this._freeze || this.rebuildLayout(spec)));\n\n    this.listenTo(this.layout, \"layoutUserEditStop\", () => {\n      this.isResizing.set(false);\n      this.layoutSaveDelay.schedule(1000, () => {\n        this.saveLayoutSpec().catch(reportError);\n      });\n    });\n\n    // Do not save if the user has started editing again.\n    this.listenTo(this.layout, \"layoutUserEditStart\", () => {\n      this.layoutSaveDelay.cancel();\n      this._savePending.set(true);\n      this.isResizing.set(true);\n    });\n\n    this.layoutEditor = this.autoDispose(LayoutEditor.create(this.layout));\n    this.layoutTray = LayoutTray.create(this, this);\n\n    // Add disposal of this._layout after layoutEditor, so that it gets disposed first, and\n    // layoutEditor doesn't attempt to update it in its own disposal logic.\n    this.onDispose(() => this.layout.dispose());\n\n    this.autoDispose(this.gristDoc.resizeEmitter.addListener(this._onResize, this));\n\n    // It's hard to detect a click or mousedown on a third-party iframe\n    // (See https://stackoverflow.com/questions/2381336/detect-click-into-iframe-using-javascript).\n    this.listenTo(this.gristDoc.app, \"clipboard_blur\", this._maybeFocusInSection);\n\n    // On narrow screens (e.g. mobile), we need to resize the section after a transition.\n    // There will two transition events (one from section one from row), so we debounce them after a tick.\n    const handler = debounce((e: TransitionEvent) => {\n      // We work only on the transition of the flex-grow property, and only on narrow screens.\n      if (e.propertyName !== \"flex-grow\" || !isNarrowScreen()) { return; }\n      // Make sure the view is still active.\n      if (this.viewModel.isDisposed() || !this.viewModel.activeSection) { return; }\n      const section = this.viewModel.activeSection.peek();\n      if (!section || section.isDisposed()) { return; }\n      const view = section.viewInstance.peek();\n      if (!view || view.isDisposed()) { return; }\n      // Make resize.\n      view.onResize();\n    }, 0);\n    this.layout.rootElem.addEventListener(\"transitionend\", handler);\n    // Don't need to dispose the listener, as the rootElem is disposed with the layout.\n\n    const classActive = cssLayoutBox.className + \"-active\";\n    const classInactive = cssLayoutBox.className + \"-inactive\";\n    this.autoDispose(subscribe(fromKo(this.viewModel.activeSection), (use, section) => {\n      const id = section.getRowId();\n      this.layout.forEachBox((box) => {\n        box.dom!.classList.add(classInactive);\n        box.dom!.classList.remove(classActive);\n        box.dom!.classList.remove(\"transition\");\n      });\n      let elem: Element | null = this.layout.getLeafBox(id)?.dom || null;\n      while (elem?.matches(\".layout_box\")) {\n        elem.classList.remove(classInactive);\n        elem.classList.add(classActive);\n        elem = elem.parentElement;\n      }\n      if (!isNarrowScreen()) {\n        section.viewInstance.peek()?.onResize();\n      }\n    }));\n\n    const commandGroup = {\n      deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()).catch(reportError); },\n      duplicateSection: () => {\n        buildDuplicateWidgetModal(this.gristDoc, this.viewModel.activeSectionId()).catch(reportError);\n      },\n      printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); },\n      sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); },\n      expandSection: () => { this._expandSection(); },\n    };\n    // Register the cancel command only when necessary to prevent collapsing with other common \"escape\" usages.\n    // See commit message for detailed description of why it's important to deal with that this way, instead of simply\n    // testing whether this.maximized.get() is null in a cancel command registered through the commandGroup object.\n    const whenMaximizedCommandGroup = {\n      cancel: () => {\n        this.maximized.set(null);\n      },\n    };\n    this.autoDispose(commands.createGroup(\n      commandGroup,\n      this,\n      ko.pureComputed(() => this.viewModel.focusedRegionState() === \"in\"),\n    ));\n    this.autoDispose(commands.createGroup(\n      whenMaximizedCommandGroup,\n      this,\n      ko.pureComputed(() => this.viewModel.focusedRegionState() === \"in\" && this.layout.maximizedLeaf() !== null),\n    ));\n\n    this.maximized = fromKo(this.layout.maximizedLeaf) as any;\n    this.autoDispose(this.maximized.addListener((sectionId, prev) => {\n      // If we are closing popup, resize all sections.\n      if (!sectionId) {\n        this._onResize();\n      } else {\n        // Otherwise resize only active one (the one in popup).\n        const section = this.viewModel.activeSection.peek();\n        if (!section.isDisposed() && section.id.peek()) {\n          section?.viewInstance.peek()?.onResize();\n        }\n      }\n    }));\n  }\n\n  public buildDom() {\n    const owner = MultiHolder.create(null);\n    const close = () => this.maximized.set(null);\n    const mainBoxInPopup = Computed.create(owner, use => this.layout.getAllLeafIds().includes(use(this.maximized)));\n    const miniBoxInPopup = Computed.create(owner, use => use(mainBoxInPopup) ? null : use(this.maximized));\n    return cssOverlay(\n      dom.autoDispose(owner),\n      cssOverlay.cls(\"-active\", use => !!use(this.maximized)),\n      testId(\"viewLayout-overlay\"),\n      cssVFull(\n        this.layoutTray.buildDom(),\n        cssLayoutWrapper(\n          cssLayoutWrapper.cls(\"-active\", use => Boolean(use(this.maximized))),\n          dom.update(\n            this.layout.rootElem,\n            dom.hide(use => Boolean(use(miniBoxInPopup))),\n          ),\n          this.layoutTray.buildPopup(owner, miniBoxInPopup, close),\n        ),\n      ),\n      dom.maybe(use => !!use(this.maximized), () =>\n        cssCloseButton(\"CrossBig\",\n          testId(\"close-button\"),\n          dom.on(\"click\", () => close()),\n        ),\n      ),\n      // Close the lightbox when user clicks exactly on the overlay.\n      dom.on(\"click\", (ev, elem) => void (ev.target === elem && this.maximized.get() ? close() : null)),\n      dom.cls(\"test-viewLayout-save-pending\", this._savePending),\n    );\n  }\n\n  // Freezes the layout until the passed in promise resolves. This is useful to achieve a single\n  // layout rebuild when multiple user actions needs to apply, simply pass in a promise that resolves\n  // when all user actions have resolved.\n  public async freezeUntil<T>(promise: Promise<T>): Promise<T> {\n    this._freeze = true;\n    try {\n      return await promise;\n    } finally {\n      this._freeze = false;\n      this.rebuildLayout(this.layoutSpec.peek());\n    }\n  }\n\n  /**\n   * Returns the full layout spec, including collapsed sections.\n   */\n  public getFullLayoutSpec() {\n    const specs = this.layout.getLayoutSpec();\n    specs.collapsed = this.viewModel.activeCollapsedSections.peek().map(leaf => ({ leaf }));\n    return specs;\n  }\n\n  public saveLayoutSpec(specs?: BoxSpec) {\n    this._savePending.set(false);\n    // Cancel the automatic delay.\n    this.layoutSaveDelay.cancel();\n    if (!this.layout) { return Promise.resolve(); }\n    // Only save layout changes when the document isn't read-only.\n    if (!this.gristDoc.isReadonly.get()) {\n      specs ??= this.getFullLayoutSpec();\n      return this.viewModel.layoutSpecObj.setAndSave(specs).catch(reportError);\n    }\n    this._onResize();\n    return Promise.resolve();\n  }\n\n  /**\n   * Removes a view section from the current view. Should only be called if there is more than\n   * one viewsection in the view.\n   * @returns A promise that resolves with true when the view section is removed. If user was\n   * prompted and decided to cancel, the promise resolves with false.\n   */\n  public async removeViewSection(viewSectionRowId: number) {\n    this.maximized.set(null);\n    const viewSection = this.viewModel.viewSections().all().find(s => s.getRowId() === viewSectionRowId);\n    if (!viewSection) {\n      throw new Error(`Section not found: ${viewSectionRowId}`);\n    }\n    const tableId = viewSection.table.peek().tableId.peek();\n\n    // Check if this is a UserTable (not summary) and if so, if it is available on any other page\n    // we have access to (or even on this page but in different widget). If yes, then we are safe\n    // to remove it, otherwise we need to warn the user.\n\n    const logTelemetry = () => {\n      const widgetType = getTelemetryWidgetTypeFromVS(viewSection);\n      logTelemetryEvent(\"deletedWidget\", { full: { docIdDigest: this.gristDoc.docId(), widgetType } });\n    };\n\n    const isUserTable = () => viewSection.table.peek().isSummary.peek() === false;\n\n    const notInAnyOtherSection = () => {\n      // Get all viewSection we have access to, and check if the table is used in any of them.\n      const others = this.gristDoc.docModel.viewSections.rowModels\n        .filter(vs => !vs.isDisposed())\n        .filter(vs => vs.id.peek() !== viewSectionRowId)\n        .filter(vs => vs.isRaw.peek() === false)\n        .filter(vs => vs.isRecordCard.peek() === false)\n        .filter(vs => vs.tableId.peek() === viewSection.tableId.peek());\n      return others.length === 0;\n    };\n\n    const REMOVED = true, IGNORED = false;\n\n    const possibleActions = {\n      [DELETE_WIDGET]: async () => {\n        logTelemetry();\n        await this.gristDoc.docData.sendAction([\"RemoveViewSection\", viewSectionRowId]);\n        return REMOVED;\n      },\n      [DELETE_DATA]: async () => {\n        logTelemetry();\n        await this.gristDoc.docData.sendActions([\n          [\"RemoveViewSection\", viewSectionRowId],\n          [\"RemoveTable\", tableId],\n        ]);\n        return REMOVED;\n      },\n      [CANCEL]: async () => IGNORED,\n    };\n\n    const tableName = () => viewSection.table.peek().tableNameDef.peek();\n\n    const needPrompt = isUserTable() && notInAnyOtherSection();\n\n    const decision = needPrompt ?\n      widgetRemovalPrompt(tableName()) :\n      Promise.resolve(DELETE_WIDGET as PromptAction);\n\n    return possibleActions[await decision]();\n  }\n\n  public rebuildLayout(layoutSpec: BoxSpec) {\n    // Rebuild the collapsed section layout. In return we will get all leaves that were\n    // removed from collapsed dom. Some of them will hold a view instance dom.\n    const oldTray = this.layoutTray.replaceLayout();\n    // Build the normal layout. While building, some leaves will grab the view instance dom\n    // and attach it to their dom (and detach them from the old layout in the process).\n    this.layout.buildLayout(layoutSpec, true);\n    this._onResize();\n    // Dispose the old layout. This will dispose the view instances that were not reused.\n    oldTray.dispose();\n  }\n\n  private _expandSection() {\n    const activeSection = this.viewModel.activeSection();\n    const activeSectionId = activeSection.getRowId();\n    const activeSectionBox = this.layout.getLeafBox(activeSectionId);\n    if (!activeSectionBox) { return; }\n    activeSectionBox.maximize();\n  }\n\n  private _buildLeafContent(sectionRowId: number) {\n    return buildViewSectionDom({\n      gristDoc: this.gristDoc,\n      sectionRowId,\n      isResizing: this.isResizing,\n      viewModel: this.viewModel,\n    });\n  }\n\n  /**\n   * If there is no layout saved, we can create a default layout just from the list of fields for\n   * this view section. By default we just arrange them into a list of rows, two fields per row.\n   */\n  private _updateLayoutSpecWithSections(spec: BoxSpec) {\n    const viewSectionIds = this.viewModel.viewSections().all().map(function(f) { return f.getRowId(); });\n    return purgeBoxSpec({ spec, validLeafIds: viewSectionIds });\n  }\n\n  // Resizes the scrolly windows of all viewSection classes with a 'scrolly' property.\n  private _onResize() {\n    this.viewModel.viewSections().all().forEach((vs) => {\n      const inst = vs.viewInstance.peek();\n      if (inst) {\n        inst.onResize();\n      }\n    });\n  }\n\n  private _maybeFocusInSection()  {\n    // If the focused element is inside a view section, make that section active.\n    const layoutBox = this.layout.getContainingBox(document.activeElement);\n    if (layoutBox?.leafId) {\n      this.gristDoc.viewModel.activeSectionId(layoutBox.leafId.peek());\n    }\n  }\n\n  /**\n   * Opens the sort and filter menu of the active view section.\n   *\n   * Optionally accepts a `sectionId` for opening a specific section's menu.\n   */\n  private _openSortFilterMenu(sectionId?: number)  {\n    const id = sectionId ?? this.viewModel.activeSectionId();\n    const leafBoxDom = this.layout.getLeafBox(id)?.dom;\n    if (!leafBoxDom) { return; }\n\n    const menu: HTMLElement | null = leafBoxDom.querySelector(\".test-section-menu-sortAndFilter\");\n    menu?.click();\n  }\n}\n\nconst DELETE_WIDGET = \"deleteOnlyWidget\";\nconst DELETE_DATA = \"deleteDataAndWidget\";\nconst CANCEL = \"cancel\";\ntype PromptAction = typeof DELETE_WIDGET | typeof DELETE_DATA | typeof CANCEL;\n\nfunction widgetRemovalPrompt(tableName: string): Promise<PromptAction> {\n  return new Promise<PromptAction>((resolve) => {\n    saveModal((ctl, owner): ISaveModalOptions => {\n      const selected = Observable.create<PromptAction | \"\">(owner, \"\");\n      const saveDisabled = Computed.create(owner, use => use(selected) === \"\");\n      const saveFunc = async () => selected.get() && resolve(selected.get() as PromptAction);\n      owner.onDispose(() => resolve(CANCEL));\n      return {\n        title: t(\"Table {{tableName}} will no longer be visible\", { tableName }),\n        body: dom(\"div\",\n          testId(\"removePopup\"),\n          cssRadioCheckboxOptions(\n            radioCheckboxOption(selected, DELETE_DATA, t(\"Delete data and this widget.\")),\n            radioCheckboxOption(selected, DELETE_WIDGET,\n              t(\n                `Keep data and delete widget. Table will remain available in {{rawDataLink}}`,\n                {\n                  rawDataLink: cssLink(\n                    t(\"Raw Data page\"),\n                    urlState().setHref({ docPage: \"data\" }),\n                    { target: \"_blank\" },\n                  ),\n                },\n              ),\n            ),\n          ),\n        ),\n        saveDisabled,\n        saveLabel: t(\"Delete\"),\n        saveFunc,\n        width: \"fixed-wide\",\n      };\n    });\n  });\n}\n\nconst cssLayoutBox = styled(\"div\", `\n  @media screen and ${mediaSmall} {\n    &-active, &-inactive {\n      transition: flex-grow var(--grist-layout-animation-duration, 0.4s); // Exposed for tests\n    }\n    &-active > &-inactive,\n    &-active > &-inactive.layout_hbox .layout_hbox,\n    &-active > &-inactive.layout_vbox .layout_vbox {\n      flex: none !important;\n    }\n\n    &-active > &-inactive.layout_hbox.layout_leaf,\n    &-active > &-inactive.layout_hbox .layout_hbox.layout_leaf {\n      height: 40px;\n    }\n\n    &-active > &-inactive.layout_vbox.layout_leaf,\n    &-active > &-inactive.layout_vbox .layout_vbox.layout_leaf {\n      width: 40px;\n    }\n\n    &-inactive.layout_leaf {\n      min-height: 40px;\n      min-width: 40px;\n    }\n  }\n`);\n\nconst cssLayoutWrapper = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  position: relative;\n  flex-grow: 1;\n  @media not print {\n    &-active {\n      background: ${theme.mainPanelBg};\n      height: 100%;\n      width: 100%;\n      border-radius: 5px;\n      border-bottom-left-radius: 0px;\n      border-bottom-right-radius: 0px;\n      position: relative;\n    }\n    &-active .viewsection_content {\n      margin: 0px;\n      margin-top: 8px;\n    }\n    &-active .viewsection_title {\n      padding: 0px 12px;\n    }\n    &-active .filter_bar {\n      margin-left: 6px;\n    }\n  }\n`);\n\nconst cssOverlay = styled(\"div\", `\n  height: 100%;\n  @media screen {\n    &-active {\n      background-color: ${theme.modalBackdrop};\n      inset: 0px;\n      height: 100%;\n      width: 100%;\n      padding: 20px 56px 20px 56px;\n      position: absolute;\n    }\n    &-active .collapsed_layout {\n      display: none !important;\n    }\n  }\n  @media screen and ${mediaSmall} {\n    &-active {\n      padding: 22px;\n      padding-top: 30px;\n    }\n  }\n`);\n\nconst cssCloseButton = styled(icon, `\n  position: absolute;\n  top: 16px;\n  right: 16px;\n  height: 24px;\n  width: 24px;\n  cursor: pointer;\n  --icon-color: ${theme.modalBackdropCloseButtonFg};\n  &:hover {\n    --icon-color: ${theme.modalBackdropCloseButtonHoverFg};\n  }\n  @media ${mediaSmall} {\n    & {\n      top: 6px;\n      right: 6px;\n    }\n  }\n`);\n\nconst cssVFull = styled(\"div\", `\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n`);\n"
  },
  {
    "path": "app/client/components/ViewLinker.css",
    "content": ".g_record_layout_linking {\n  position: absolute;\n  display: -webkit-flex;\n  display: flex;\n  -webkit-flex-direction: column;\n  justify-content: center;\n  -webkit-justify-content: center;\n  align-items: center;\n  -webkit-align-items: center;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  background-color: rgba(0, 0, 0, 0.5);\n  z-index: 5;\n}\n\n.linker_canvas {\n  position: absolute;\n  left: 0;\n  top: 0;\n  pointer-events: none;\n  z-index: 10;\n}\n\n.linker_save_btns {\n  position: absolute;\n  right: 10px;\n  top: 10px;\n  z-index: 100;\n  display: flex;\n}\n\n.linker_btn {\n  width: 90px;\n  margin: 4px;\n  background-color: black;\n  padding: 5px;\n  border-radius: 2px;\n  text-align: center;\n  color: white;\n  cursor: pointer;\n  border: 1px solid #cdcbcb;\n}\n\n.linker_btn.disabled {\n  color: #555;\n  cursor: default;\n}\n\n.linker_btn_cancel {\n  color: #aaa;\n}\n\n.linker_box {\n  display: -webkit-flex;\n  display: flex;\n  -webkit-flex-direction: column;\n  flex-direction: column;\n  font-size: 1.5rem;\n}\n\n.section_link {\n  margin: 4px 0;\n  background-color: black;\n  padding: 5px 10px;\n  border-radius: 2px;\n  cursor: default;\n  width: 100%;\n}\n\n.view_link {\n  position: absolute;\n}\n\n.view_link_icon {\n  color: #aaa;\n  cursor: pointer;\n}\n\n.linker_box_header {\n  visibility: hidden;\n  font-size: 1.2rem;\n  font-weight: bold;\n  margin: 2px 0 -4px 0;\n  color: white;\n}\n\n.linker_box_header.visible {\n  visibility: visible;\n}\n\n.link_text {\n  margin-left: 25px;\n  color: #666;\n}\n\n.selected_text,\n.available_text {\n  color: white;\n}\n\n.remove_link_icon:hover,\n.view_link_icon:hover,\n.selected_link {\n  color: white;\n}\n\n.remove_link_icon {\n  color: #aaa;\n  margin: 0 0 0 8px;\n  font-size: 1.2rem;\n  cursor: pointer;\n}\n"
  },
  {
    "path": "app/client/components/ViewPane.ts",
    "content": "// This module is unused except to group some modules for a webpack bundle.\n// TODO It is a vestige of the old ViewPane.js, and can go away with some bundling improvements.\n\nimport ViewConfigTab from \"app/client/components/ViewConfigTab\";\nimport * as FieldConfig from \"app/client/ui/FieldConfig\";\nexport { ViewConfigTab, FieldConfig };\nexport { ConditionalStyle } from \"app/client/widgets/ConditionalStyle\";\n"
  },
  {
    "path": "app/client/components/VirtualDoc.ts",
    "content": "import { ActionCounter } from \"app/client/components/ActionCounter\";\nimport { ActionLog } from \"app/client/components/ActionLog\";\nimport { BehavioralPromptsManager } from \"app/client/components/BehavioralPromptsManager\";\nimport { buildViewSectionDom } from \"app/client/components/buildViewSectionDom\";\nimport { ClientScope } from \"app/client/components/ClientScope\";\nimport { Comm } from \"app/client/components/Comm\";\nimport * as commands from \"app/client/components/commands\";\nimport { CursorMonitor } from \"app/client/components/CursorMonitor\";\nimport { GridViewOptions } from \"app/client/components/GridView\";\nimport { DocComm, GristDoc, IExtraTool } from \"app/client/components/GristDoc\";\nimport { UndoStack } from \"app/client/components/UndoStack\";\nimport { ViewLayout, ViewSectionHelper } from \"app/client/components/ViewLayout\";\nimport { DocPluginManager } from \"app/client/lib/DocPluginManager\";\nimport BaseRowModel from \"app/client/models/BaseRowModel\";\nimport { DataTableModelWithDiff } from \"app/client/models/DataTableModelWithDiff\";\nimport { DocData } from \"app/client/models/DocData\";\nimport { DocInfoRec, DocModel, ViewFieldRec, ViewRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { DocPageModel, DocPageModelImpl } from \"app/client/models/DocPageModel\";\nimport { QuerySetManager } from \"app/client/models/QuerySet\";\nimport { UserPresenceModel, UserPresenceModelStub } from \"app/client/models/UserPresenceModel\";\nimport { IExternalTable, VirtualTableData, VirtualTableRegistration } from \"app/client/models/VirtualTable\";\nimport { META_TABLES } from \"app/client/models/VirtualTableMeta\";\nimport { ICellContextMenu } from \"app/client/ui/CellContextMenu\";\nimport { IPageWidget } from \"app/client/ui/PageWidgetPicker\";\nimport { IRowContextMenu } from \"app/client/ui/RowContextMenu\";\nimport { WidgetType } from \"app/client/widgets/UserType\";\nimport { MinimalActionGroup } from \"app/common/ActionGroup\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { CellValue, DocAction, getColValues, TableDataAction, TableRecordValues } from \"app/common/DocActions\";\nimport { DocDataCache } from \"app/common/DocDataCache\";\nimport { DocStateComparison } from \"app/common/DocState\";\nimport { IDocPage } from \"app/common/gristUrls\";\nimport { useBindable } from \"app/common/gutil\";\nimport { VirtualId } from \"app/common/SortSpec\";\nimport { DocAPI, ExtendedUser } from \"app/common/UserAPI\";\nimport { CursorPos, UIRowId } from \"app/plugin/GristAPI\";\n\nimport camelCase from \"camelcase\";\nimport {\n  BaseObservable,\n  BindableValue,\n  bundleChanges,\n  Computed,\n  Disposable,\n  dom,\n  Emitter,\n  Holder,\n  IDisposable,\n  MaybeObsArray,\n  Observable,\n  toKo,\n  UseCB,\n} from \"grainjs\";\nimport * as ko from \"knockout\";\nimport difference from \"lodash/difference\";\nimport omit from \"lodash/omit\";\n\nimport type { BoxSpec } from \"app/client/lib/BoxSpec\";\nimport type { AppModel, TopAppModel } from \"app/client/models/AppModel\";\nimport type { App } from \"app/client/ui/App\";\nimport type { ApplyUAOptions, ApplyUAResult } from \"app/common/ActiveDocAPI\";\nimport type { UserAction } from \"app/common/DocActions\";\nimport type { ISupportedFeatures } from \"app/common/UserConfig\";\nimport type { GristType, RowRecord } from \"app/plugin/GristData\";\nimport type { MaybePromise } from \"app/plugin/gutil\";\n\n/**\n * Minimal implementation of the GristDoc interface that is suitable for virtual tables. The GristDoc created\n * appears as readonly version of the document.\n *\n * Currently it only supports rendering readonly view of external data.\n *\n * TODO: Factor out components from GristDoc for easier subtyping.\n */\nexport class VirtualDoc extends DisposableWithEvents implements GristDoc {\n  // All of those props are only here to satisfy the interface.\n  public app: App;\n  public docComm: DocComm;\n  public docPageModel: DocPageModel;\n  public docData: DocData;\n  public isReadonly = Observable.create(this, true);\n  public isReadonlyKo = toKo(ko, this.isReadonly);\n  // Currently we don't support this feature.\n  public maximizedSectionId = Observable.create(this, null);\n  public externalSectionId = Observable.create(this, null);\n  public comparison: any = null;\n  public docInfo: DocInfoRec = { timezone: Observable.create(null, \"UTC\") } as any;\n  public docModel: DocModel;\n  public viewModel: ViewRec;\n  public userPresenceModel: UserPresenceModel = new UserPresenceModelStub();\n  public activeViewId = Observable.create(this, 0);\n  public currentPageName: Observable<string>;\n  public docPluginManager: DocPluginManager;\n  public querySetManager: QuerySetManager;\n  public rightPanelTool: Observable<IExtraTool | null> = Observable.create(this, null);\n  public cursorMonitor: CursorMonitor;\n  public hasCustomNav: Observable<boolean> = Observable.create(this, false);\n  public resizeEmitter: Emitter = this.autoDispose(new Emitter());\n  public fieldEditorHolder: Holder<IDisposable> = Holder.create(this);\n  public activeEditor = Observable.create(this, null);\n  public currentView = Observable.create(this, null);\n  public latestActionState = Observable.create(this, null);\n  public cursorPosition = Observable.create(this, undefined);\n  public userOrgPrefs = Observable.create(this, {});\n  public behavioralPromptsManager: BehavioralPromptsManager;\n  public viewLayout = null;\n  public docApi: DocAPI;\n  public isTimingOn = Observable.create(this, false);\n  public attachmentTransfer = Observable.create(this, null);\n  public canShowRawData = Observable.create(this, false);\n  public activeSectionId: ko.Computed<number | string>;\n  public currentUser: Observable<ExtendedUser | null>;\n  private _tables = new Map<string, TableSpec>();\n  constructor(public appModel: AppModel) {\n    super();\n\n    // Find or create a reference to the App object. It is mostly used to trigger events and to access\n    // Comm object by on demand tables.\n    if (window.gristApp) {\n      // If we have gristApp, we use directly.\n      this.app = window.gristApp as App;\n    } else {\n      // Otherwise, we create a new InMemoryApp, suitable for tests.\n      this.app = this.autoDispose(new InMemoryApp(appModel.topAppModel));\n    }\n\n    this.currentUser = Computed.create(this, (use) => {\n      return use(this.app.topAppModel.appObs)?.currentUser ?? null;\n    });\n\n    // Create a true DocPageModel that just don't wires up url state transition (that normally is used to switch\n    // docs without reloading)\n    // TODO: check if that actually works at all or is tested.\n    this.docPageModel = this.autoDispose(new InMemoryDocPageModel(this.app, appModel));\n\n    // Create an in-memory doc model that is able to translate UserActions to DocActions (by leveraging on-demand\n    // tables functions). This allows us to treat this in memory document as on demand table that can be used and\n    // modified by sending actions.\n    this.docModel = InMemoryDocModel.create(this);\n    this.docData = this.docModel.docData;\n\n    // Wire up things needed for viewInstance component. The bare minimum requires a floating row model for\n    // active view and a query set manager for getting data (and filtering/sorting).\n    const viewId = toKo(ko, this.activeViewId) as ko.Computed<number>;\n    this.viewModel = this.autoDispose(this.docModel.views.createFloatingRowModel(viewId));\n    this.querySetManager = this.autoDispose(new QuerySetManager(\n      this.docModel,\n      this.docComm, // docComm is only needed for on-demand tables, otherwise it is not used at all.\n    ));\n\n    // Create a prompt manager (tooltips with hints), that is used to show hints and tips to the logged in user.\n    // NOTE: this is not a stub or minimal implementation, it is the same thing that is used in the real GristDoc and\n    // all discards are remembered by home db.\n    this.behavioralPromptsManager = this.autoDispose(\n      new BehavioralPromptsManager(appModel),\n    );\n\n    // Since we are a read only document, the field editor won't be disposed (known bug in Grist, same things happen On\n    // snapshots or import previews).\n    // To fix it, we will listen for active section change event, and manually trigger focus.\n    // TODO: This is a hack, and should be fixed in Grist.\n    this.autoDispose(this.viewModel.activeSectionId.subscribe(() => {\n      this.focus();\n    }));\n\n    // By default we want to have a single page with all view sections on it, this way we the layout manager used to\n    // render each section can show the green border around active one. But it is entirely possible to have multiple\n    // pages and render multiple sections on each page.\n    this.docData.receiveAction([\n      \"AddRecord\", \"_grist_Views\", \"main\" as any as number, {\n        name: \"main\",\n        type: \"raw_data\",\n      },\n    ]);\n\n    this.activeViewId.set(\"main\" as any);\n\n    this.activeSectionId = this.viewModel.activeSectionId as any;\n  }\n\n  /**\n   * Emits the `clipboard_focus` event. Some components (like editors) listen to this event to close themselves.\n   */\n  public focus() {\n    this.app?.trigger(\"clipboard_focus\", null);\n  }\n\n  /**\n   * Renders default page. While the main GristDoc (and we as a result) contains a lot of things for left and right\n   * panel, the dom is actually rendered only in the middle section of the UI. So all the other components are just\n   * attached to GristDoc for easy access by other components.\n   */\n  public buildDom() {\n    return dom(\"div\",\n      dom.style(\"flex\", \"1\"),\n      dom.on(\"setCursor\", (ev: any) => {\n        // This is a custom event triggered by Detail/GridView component. GristDoc normally registers a global\n        // command handler to handle cursor change. But this won't work if there will be more then one VirtualDoc\n        // on a page (we would need to somehow synchronize those two). So we will just listen to this event more\n        // in \"dom way\".\n        const [row, col] = ev.detail;\n        this.onSetCursorPos(row, col).catch(reportError);\n        ev.stopPropagation();\n        ev.preventDefault();\n      }),\n      dom.domComputed(this.activeViewId, viewId => dom.create(ViewLayout, this, viewId)),\n    );\n  }\n\n  public tableDef(tableId: string) {\n    return this._tables.get(tableId) ?? null;\n  }\n\n  /** Register and loads external table into the document */\n  public addTable(table: TableSpec) {\n    // Figure out tableId if not provided.\n    const suggestedTableId = table.tableId || properId(table.name);\n\n    // If table is hidden (not shown in the UI), we will prefix it with GristHidden_. Some UI components\n    // are sensitive to it.\n    const tableId = table.hidden ? `GristHidden_${suggestedTableId}` : suggestedTableId;\n\n    // Skip if we are already registered, we allow multiple registrations of the same table.\n    if (this._tables.has(tableId)) {\n      return;\n    }\n\n    this._tables.set(tableId, table);\n\n    // Now wire up the external table with the low level IExternalTable interface.\n    const ext: IExternalTable = {\n      name: tableId,\n      // Auto-generate initial actions to create the table.\n      initialActions: () => generateInitialActions(table),\n      // Fetch handlers replaces the data in the table with the data from the external source.\n      fetchAll: async () => {\n        // Get the data from the external source.\n        const data = await maybePeek(table.data).getData();\n\n        const definedColumns = (table.columns || []).map(c => c.colId);\n\n        // If it requires formatting, reformat it to the Grist format.\n        // Action looks like ['TableData', 'tableId', [rowIds], {colId: [values]}]\n        const formatted: TableDataAction = table.format ? table.format.convert(tableId, data, definedColumns) : data;\n        if (!Array.isArray(formatted) || formatted.length !== 4 || !Array.isArray(formatted[2]) || !formatted[3]) {\n          throw new Error(\"Invalid data format\");\n        }\n\n        // Some columns may require adjustments (like converting ms to s)\n        const rows = formatted[2]; // array of row ids (or nulls for adding)\n        const cols = formatted[3]; // object with colId -> array of values\n        const colIds = Object.keys(cols);\n        for (const def of table.columns || []) {\n          if (!def.transform) {\n            // Filter out not transformed columns.\n            continue;\n          }\n          // Figure out proper colId if not defined.\n          const colId = def.colId || properId(def.label);\n\n          // We might have columns that are not in the external data, but we want to generate (like trigger formula)\n          if (!cols[colId] && !!def.transform) {\n            // In that case fill it up with nulls first.\n            cols[colId] = Array(rows.length).fill(null);\n          } else if (!cols[colId]) {\n            throw new Error(`Column ${colId} not found in external data`);\n          }\n\n          // Now go through each row and apply transformation.\n          for (let rowIndex = 0; rowIndex < cols[colId].length; rowIndex++) {\n            const rowId = rows[rowIndex];\n            // Some transformation needs access to the whole record (with raw data), so we will provide it.\n            // This is somehow very similar to `rec` in formula.\n            const rec = Object.fromEntries(colIds.map(c => [c, cols[c][rowIndex]]));\n            Object.assign(rec, { id: rowId });\n            // Apply transformation, very similar concept to cleaning trigger formula (it has access to current value)\n            // and record.\n            cols[colId][rowIndex] = def.transform(cols[colId][rowIndex], rec);\n          }\n        }\n        return formatted;\n      },\n    };\n\n    // Some column might be hidden with is an observable value. We will listen to it and hide/show columns as needed.\n    if (table.columns) {\n      const dynamicHidden = table.columns.filter(c => c.hidden && typeof c.hidden !== \"boolean\");\n      for (const col of dynamicHidden) {\n        if (col.hidden === undefined) {\n          continue;\n        }\n        const origHidden = col.hidden;\n        const obs = Computed.create(this, use => useBindable(use, origHidden));\n        const coldId = col.colId || properId(col.label);\n        col.hidden = obs.get();\n        this.autoDispose(obs.addListener(async (isHidden) => {\n          if (!isHidden) {\n            await this.showColumn(tableId, coldId);\n          } else {\n            await this.hideColumn(tableId, coldId);\n          }\n        }));\n      }\n    }\n\n    // Now register this table with the docModel.\n    this.autoDispose(new VirtualTableRegistration(this.docModel, ext));\n\n    // The caller of this method might want to refresh the table if some external event happens.\n    // This is modeled using an observable. If it value changes we will force this table to reload itself (probably\n    // with some different filter args).\n    if (table.watch) {\n      this.autoDispose(table.watch.addListener(async (val) => {\n        await this.refreshTableData(tableId);\n      }));\n    }\n\n    // Same thing for the data itself, the fetch function can also be an observable. But in this case\n    // we don't allow those two combined.\n    if (table.data instanceof Observable) {\n      if (table.watch) {\n        throw new Error(\"Table data and watch cannot be both observables\");\n      }\n      this.autoDispose(table.data.addListener(async () => {\n        await this.refreshTableData(tableId);\n      }));\n    }\n\n    // And adjust the UI a bit.\n    const tableRec = this.docModel.allTables.peek().find(t => t.tableId.peek() === tableId);\n    const sectionRec = this.docModel.viewSections.tableData.filterRecords({ tableRef: tableRec?.id.peek() })[0];\n    const sectionId = sectionRec?.id;\n    const viewSectionModel = this.docModel.viewSections.rowModels[sectionId as any as number];\n    // Hide view menu on the right.\n    viewSectionModel.hideViewMenu(true);\n    // Disable renaming\n    // TODO: this should be disable by default if doc is readonly.\n    viewSectionModel.canRename(false);\n\n    if (table.initialFocus) {\n      viewSectionModel.hasFocus(true);\n      this.setView(table.name);\n    }\n  }\n\n  /**\n   * Hides column on a main section for a given table.\n   * Main section is the first one we have. Currently VirtualDoc assumes we have only one section per table.\n   */\n  public async hideColumn(tableId: string, colId: string) {\n    const sectionRec = this.getMainSectionRec(tableId);\n    const columnRec = this.getColumnRec(tableId, colId);\n    if (!columnRec) {\n      return;\n    }\n\n    // Check if the viewField is actually added, maybe it is already hidden.\n    const hasField = sectionRec.viewFields.peek()\n      .all().find(f => f.colRef.peek() === columnRec.id.peek());\n    if (!hasField) {\n      return;\n    }\n    // Hide using meta action.\n    await this.docData.sendActions([\n      [\"RemoveRecord\", \"_grist_Views_section_field\", hasField.id.peek()],\n    ]);\n  }\n\n  /**\n   * Shows column on a main section for a given table.\n   */\n  public async showColumn(tableId: string, colId: string) {\n    const sectionRec = this.getMainSectionRec(tableId);\n    const columnRec = this.getColumnRec(tableId, colId)!;\n\n    // Check if that column is already there.\n    const hasField = sectionRec.viewFields.peek()\n      .all().find(f => f.colRef.peek() === columnRec.id.peek());\n    if (hasField) {\n      return;\n    }\n    // Else generate action.\n    const fieldId = VirtualId();\n    await this.docData.sendActions([\n      [\"AddRecord\", \"_grist_Views_section_field\", fieldId, {\n        colRef: columnRec.id.peek(),\n        parentId: sectionRec.id.peek(),\n        parentPos: 0, // move first\n      }],\n    ]);\n  }\n\n  /** Returns ColumnRec row model */\n  public getColumnRec(tableId: string, colId: string) {\n    // Note: rowModels for virtual tables are not stored as normal array. This is more map rowId -> rowModel.\n    // and since, rowIds are string, we can't use them as indexes or just iterated on it.\n    return Object.values(this.docModel.columns.rowModels)\n      .find(r => r.table.peek().tableId.peek() === tableId && r.colId.peek() === colId);\n  }\n\n  /** Returns TableRec record. */\n  public getTableRec(tableId: string) {\n    return this.docModel.allTables.peek().find(t => t.tableId.peek() === tableId);\n  }\n\n  /** Returns first section for a table. */\n  public getMainSectionRec(tableId: string) {\n    const tableRec = this.getTableRec(tableId);\n    const rows = this.docModel.viewSections.tableData.filterRecords({ tableRef: tableRec?.id.peek() });\n    const row = rows[0];\n    if (rows.length > 1) {\n      throw new Error(\"Multiple sections per table not supported\");\n    }\n    return this.docModel.viewSections.getRowModel(row.id);\n  }\n\n  /** Forces virtual table to reload data. */\n  public async refreshTableData(tableId: string) {\n    const virt = this.docData.getTable(tableId)! as VirtualTableData;\n    await virt.fetchData();\n  }\n\n  /** Changes active view. */\n  public setView(label: string) {\n    // Find view with this name.\n    const viewId = this.docModel.views.tableData.findMatchingRowId({ name: label });\n    if (!viewId) {\n      throw new Error(`View with name or id ${label} not found`);\n    }\n    this.activeViewId.set(viewId);\n  }\n\n  public getRecords(table: string) {\n    const tableData = this.docData.getTable(table)!.getTableDataAction();\n    const rowIds = tableData[2];\n    const columns = tableData[3];\n    return rowIds.map((rowId) => {\n      const record: RowRecord = { id: rowId };\n      for (const colId of Object.keys(columns)) {\n        record[colId] = columns[colId][rowId];\n      }\n      return record;\n    });\n  }\n\n  public async onSetCursorPos(rowModel: BaseRowModel | undefined, fieldModel?: ViewFieldRec) {\n    const cursorPos = {\n      rowIndex: rowModel?._index() || 0,\n      fieldIndex: fieldModel?._index() || 0,\n      sectionId: fieldModel?.viewSection().getRowId(),\n    };\n    const viewInstance = this.viewModel.activeSection.peek().viewInstance.peek();\n    viewInstance?.setCursorPos(cursorPos);\n    this.app?.trigger(\"clipboard_focus\", null);\n  }\n\n  ///////////////////////\n  // Rest of the methods are not implemented and not needed or used by virtual tables.\n\n  public getTableModelMaybeWithDiff(tableId: string) {\n    const tableModel = this.getTableModel(tableId);\n    if (!this.comparison?.details) {\n      return tableModel;\n    }\n    // TODO: cache wrapped models and share between views.\n    return new DataTableModelWithDiff(tableModel, this.comparison.details);\n  }\n\n  public getTableModel(tableId: string) {\n    return this.docModel.getTableModel(tableId);\n  }\n\n  public docId(): string {\n    return \"disconnected-doc\";\n  }\n\n  public async openDocPage(viewId: IDocPage): Promise<void> {\n    return Promise.resolve();\n  }\n\n  public showTool(tool: \"none\" | \"docHistory\" | \"validations\" | \"discussion\"): void {\n  }\n\n  public async moveToCursorPos(cursorPos?: CursorPos, optActionGroup?: MinimalActionGroup): Promise<void> {\n    return Promise.resolve();\n  }\n\n  public getUndoStack(): UndoStack {\n    return new UndoStack(); // Return empty undo stack\n  }\n\n  public getActionCounter(): ActionCounter {\n    throw new Error(\"no action counter\");\n  }\n\n  public async addEmptyTable(): Promise<void> {\n    return Promise.resolve();\n  }\n\n  public async addWidgetToPage(widget: IPageWidget): Promise<void> {\n    return Promise.resolve();\n  }\n\n  public async addNewPage(val: IPageWidget): Promise<void> {\n    return Promise.resolve();\n  }\n\n  public async saveViewSection(section: ViewSectionRec, newVal: IPageWidget): Promise<ViewSectionRec> {\n    return Promise.resolve(section);\n  }\n\n  public async saveLink(linkId: string, sectionId?: number): Promise<any> {\n    return Promise.resolve(null);\n  }\n\n  public selectBy(widget: IPageWidget): any[] {\n    return [];\n  }\n\n  public async forkIfNeeded(): Promise<void> {\n    return Promise.resolve();\n  }\n\n  public async recursiveMoveToCursorPos(\n    cursorPos: CursorPos,\n    setAsActiveSection: boolean,\n    silent?: boolean,\n    visitedSections?: number[],\n  ): Promise<boolean> {\n    return Promise.resolve(false);\n  }\n\n  public async activateEditorAtCursor(options?: { init?: string; state?: any }): Promise<void> {\n    return Promise.resolve();\n  }\n\n  public async copyAnchorLink(_anchorInfo: unknown) {}\n\n  public getCsvLink() {\n    return \"\";\n  }\n\n  public getTsvLink() {\n    return \"\";\n  }\n\n  public getDsvLink() {\n    return \"\";\n  }\n\n  public getXlsxActiveViewLink() {\n    return \"\";\n  }\n\n  public async sendTableAction() {}\n  public async sendTableActions() {}\n  public getActionLog(): ActionLog {\n    throw new Error(\"no ActionLog available\");\n  }\n\n  public setComparison(comparison: DocStateComparison | null) {\n    this.comparison = comparison;\n  }\n}\n\n/**\n * Interface for an object that should provide full data for a table.\n */\ninterface ExternalData {\n  getData(): MaybePromise<any>;\n}\n\n/**\n * Interface for an object that should convert data from external source to TableDataAction format.\n */\ninterface ExternalFormat {\n  convert(tableId: string, data: any, colIds: string[]): TableDataAction;\n}\n\n/**\n * Extends UIRowId to allow rows to use string ids.\n */\nexport type VirtualRowId = string | UIRowId; // eslint-disable-line @typescript-eslint/no-redundant-type-constituents\n\n/**\n * UI component for rendering single section (from VirtualDoc) in the UI.\n */\nexport class VirtualSection extends Disposable {\n  private _sectionRec: ViewSectionRec;\n  private _sectionId: string | number;\n  private _columns: Computed<string[]>;\n\n  constructor(protected _doc: VirtualDoc, protected props: {\n    /** Table id to render */\n    tableId: string,\n    /** Optional section id to use. Useful for linking sections together */\n    sectionId?: string | number,\n    /** Grid or Detail view */\n    type?: \"single\" | \"record\",\n    /** Optional label for the section, defaults to table name */\n    label?: string,\n    /** Sorted list of fields to render */\n    columns?: MaybeObsArray<string>,\n    /** List of columns to hide */\n    hiddenColumns?: MaybeObsArray<string>,\n    /* Initial focus when creating this section. */\n    initialFocus?: boolean,\n    /** Function to be called when focus is changed in this section */\n    onFocus?: (on: boolean) => void,\n    /** Observable for currently selected row, support two-way binding */\n    selectedRow?: Observable<VirtualRowId | undefined>,\n    /** Optional function to call when cursor is changed (for convenience, as there is an observer above ) */\n    rowChanged?: (rowId?: string | number) => void,\n    /** Linking configuration to other sections in the same view */\n    selectBy?: {\n      sectionId: string,\n      colId: string,\n    },\n    /** Handler that is called when user wants to show card view */\n    onCard?: (rowId?: string | number) => void,\n    /** A function that can change items visible in the cell context menu */\n    cellMenu?: (items: Element[], options: ICellContextMenu) => Element[],\n    /** A function that can change items visible in the row context menu */\n    rowMenu?: (items: Element[], options: IRowContextMenu) => Element[],\n    // TODO: add some clever way for detecting visibility.\n    /** If this view section is visible or not. Used for resizing when the parent element is initially hidden */\n    isVisible?: Observable<boolean>,\n    disableAddRemove?: boolean,\n    hideViewButtons?: boolean,\n    gridOptions?: Partial<GridViewOptions>,\n  }) {\n    super();\n\n    const { tableId } = this.props;\n    const tableRec = this._doc.getTableRec(tableId);\n    if (!tableRec) {\n      throw new Error(`Table ${tableId} not found`);\n    }\n\n    const sectionId = this.props.sectionId ?? tableId;\n    this._sectionId = sectionId;\n\n    const linkSrcSectionRef = this.props.selectBy?.sectionId ?? 0;\n    this._doc.docData.receiveAction([\n      \"AddRecord\", \"_grist_Views_section\", this._sectionId as any as number, {\n        tableRef: tableRec.id.peek(),\n        parentId: \"main\" as any as number,\n        parentKey: this.props.type ?? \"record\",\n        title: this.props?.label ?? tableRec.tableName.peek(),\n        borderWidth: 1,\n        linkSrcSectionRef,\n        ...(props.gridOptions?.inline ? {} : { defaultWidth: 100 }),\n      },\n    ]);\n    this.onDispose(() => {\n      const fieldsIds = this._doc.docModel.viewFields.tableData.filterRowIds(\n        { parentId: sectionId as any as number });\n\n      this._doc.docData.receiveAction([\n        \"BulkRemoveRecord\", \"_grist_Views_section_field\", fieldsIds,\n      ]);\n      this._doc.docData.receiveAction([\n        \"RemoveRecord\", \"_grist_Views_section\", sectionId as any as number,\n      ]);\n    });\n\n    const tableCols = tableRec.columns.peek().all().map(c => c.colId.peek());\n\n    this._columns = Computed.create(this, (use) => {\n      const hidden = this.props.hiddenColumns ? maybeUse(use, this.props.hiddenColumns) : [];\n      const columns = props.columns ? maybeUse(use, props.columns) : tableCols;\n      return difference(columns, hidden);\n    });\n\n    this._syncColumns();\n\n    this.autoDispose(this._columns.addListener(this._syncColumns.bind(this)));\n\n    const viewSectionRec = this._doc.docModel.viewSections.getRowModel(sectionId as any as number);\n    ViewSectionHelper.create(this, this._doc as any, viewSectionRec, {\n      record: {\n        ...this.props.gridOptions,\n        addNewRow: false,\n      } as GridViewOptions,\n    });\n\n    viewSectionRec.hideViewMenu(true);\n    viewSectionRec.canRename(false);\n    viewSectionRec.canExpand(false);\n    viewSectionRec.overrideDisableAddRemoveRows(props.disableAddRemove);\n\n    this._sectionRec = viewSectionRec;\n\n    if (props.initialFocus) {\n      viewSectionRec.hasFocus(true);\n    }\n\n    const viewInstance = viewSectionRec.viewInstance.peek() as any;\n    // Additional elements to add to the cell context menu.\n    if (props.cellMenu && viewInstance) {\n      viewInstance.customCellMenu = props.cellMenu;\n    }\n    // Additional elements to add to the row context menu.\n    if (props.rowMenu && viewInstance) {\n      viewInstance.customRowMenu = props.rowMenu;\n    }\n\n    if (props.onFocus) {\n      this.autoDispose(viewSectionRec.hasFocus.subscribe((on) => {\n        if (props.onFocus) {\n          props.onFocus(on);\n        }\n      }));\n    }\n\n    // The viewInstance is already created, now we can override some commands.\n    // Commands disabled in the menus, but still runnable with keyboard shortcuts.\n    this.autoDispose(commands.createGroup({\n      copyLink: () => {},\n      viewAsCard: (ev?: Event) => {\n        props.onCard?.(viewSectionRec.viewInstance.peek()?.cursor.getCursorPos().rowId);\n        if (ev instanceof KeyboardEvent) {\n          ev.stopPropagation();\n          ev.preventDefault();\n        }\n        this._doc.app?.trigger(\"clipboard_focus\", null);\n        return true;\n      },\n    }, this, viewSectionRec.hasFocus));\n\n    if (props.selectedRow) {\n      const setRowIdInInstance = (virtualRowId?: VirtualRowId) => {\n        // String IDs are allowed in virtual documents, and are intended to be supported by the Grist UI.\n        // The type system doesn't allow that, so forcibly cast the string as number.\n        // Only cast strings to retain as much type safety as possible.\n        const rowId = typeof virtualRowId === \"string\" ? virtualRowId as unknown as number : virtualRowId;\n        const pos = !rowId || rowId === 0 ? { rowIndex: 0 } : { rowId };\n        viewSectionRec.viewInstance.peek()?.setCursorPos(pos);\n      };\n      setRowIdInInstance(props.selectedRow.get());\n      this.autoDispose(props.selectedRow.addListener((val) => {\n        setRowIdInInstance(val);\n      }));\n      const rowId = viewSectionRec.viewInstance.peek()?.cursor.rowId;\n      if (rowId) {\n        this.autoDispose(rowId.subscribe((id: VirtualRowId | null) => {\n          props.selectedRow?.set(id ?? undefined);\n        }));\n      }\n    }\n\n    if (props.isVisible) {\n      this.autoDispose(props.isVisible.addListener((visible) => {\n        if (visible) {\n          viewSectionRec.viewInstance.peek()?.onResize();\n        }\n      }));\n    }\n  }\n\n  public buildDom() {\n    const vs = this._sectionRec;\n    const visible = Observable.create(this, true);\n    return dom(\"div.layout_root\",\n      // Catch custom CustomEvent('setCursor', {detail: {row, col}}) event and set cursor position.\n      dom.on(\"setCursor\", (ev: any) => {\n        vs.hasFocus(true);\n        const [rowModel, fieldModel] = ev.detail;\n        const cursorPos = {\n          rowIndex: rowModel?._index() || 0,\n          fieldIndex: fieldModel?._index() || 0,\n          sectionId: fieldModel?.viewSection().getRowId(),\n        };\n        const viewInstance = vs.viewInstance.peek();\n        viewInstance?.setCursorPos(cursorPos);\n        this._doc.focus();\n        this.props.rowChanged?.(viewInstance?.cursor.getCursorPos().rowId);\n        ev.stopPropagation();\n        ev.preventDefault();\n      }),\n      dom.style(\"flex\", \"1\"),\n      dom(\"div.layout_box layout_vbox\",\n        dom.show(visible),\n        dom(\"div.layout_box layout_leaf\",\n          dom.style(\"--flex-grow\", \"100\"),\n          buildViewSectionDom({\n            gristDoc: this._doc,\n            sectionRowId: this._sectionId as number,\n            viewModel: vs.view.peek(),\n            hideTitleControls: this.props.hideViewButtons,\n          }),\n        ),\n      ),\n    );\n  }\n\n  private _syncColumns() {\n    const columns = this._columns.get();\n    const tableId = this.props.tableId;\n    const sectionId = this._sectionId;\n    const columnDefs = this._doc.tableDef(tableId)?.columns || [];\n    const widths = new Map<string, number | null>(columnDefs.map(c => [c.colId, c.width ?? null]));\n\n    bundleChanges(() => {\n      const fieldsIds = this._doc.docModel.viewFields.tableData.filterRowIds(\n        { parentId: sectionId as any as number });\n\n      this._doc.docData.receiveAction([\n        \"BulkRemoveRecord\", \"_grist_Views_section_field\", fieldsIds,\n      ]);\n      const newFieldIds = columns.map(VirtualId.bind(null, undefined)) as any as number[];\n      this._doc.docData.receiveAction([\n        \"BulkAddRecord\", \"_grist_Views_section_field\", newFieldIds, {\n          colRef: columns.map(c => this._doc.getColumnRec(tableId, c)!.id.peek()),\n          parentId: columns.map(() => sectionId as any as number),\n          parentPos: columns.map((_, i) => i + 1),\n          width: columns.map(c => widths.get(c) ?? null),\n        },\n      ]);\n    });\n  }\n}\n\n/**\n * Default implementation for ExternalData interface. Just a wrapper around a function that returns data.\n */\nexport class ApiData implements ExternalData {\n  constructor(private _fun: () => MaybePromise<any>) {\n  }\n\n  public async getData() {\n    return await this._fun();\n  }\n}\n\n/**\n * Converts the Records format ({records: {id, fields}[]}) to TableDataAction.\n */\nexport class RecordsFormat implements ExternalFormat {\n  public convert(tableId: string, data: TableRecordValues, keys: string[]): TableDataAction {\n    if (!data.records.length) {\n      return [\"TableData\", tableId, [], {}];\n    }\n    const rows = data.records.map(r => r.id) as number[];\n    const cols = Object.fromEntries(keys\n      .filter(k => k !== \"id\")\n      .map(k => [k, data.records.map(r => r.fields[k] ?? null)]));\n    return [\"TableData\", tableId, rows, cols];\n  }\n}\n\n/**\n * Converts plain object to TableDataAction.\n */\nexport class RawFormat implements ExternalFormat {\n  public convert(tableId: string, data: any[], keys: string[]): TableDataAction {\n    if (!data.length) {\n      return [\"TableData\", tableId, [], {}];\n    }\n    const colIds = keys.filter(k => k !== \"id\");\n    const rowIds = data.map((row, index) => row.id ?? (index + 1));\n    const cols = Object.fromEntries(colIds.map(k => [k, data.map(r => r[k] ?? null)]));\n    return [\"TableData\", tableId, rowIds, cols];\n  }\n}\n\n/**\n * Description of external table. It is used to register the table with the VirtualDoc.\n */\nexport interface TableSpec {\n  name: string;\n  data: ExternalData | Observable<ExternalData>;\n  watch?: Observable<any>;\n  type?: \"record\" | \"single\" | \"detail\"; // default 'record'\n  tableId?: string;\n  fields?: string[];\n  columns?: ColumnSpec[];\n  format?: ExternalFormat;\n  hidden?: boolean;\n  initialFocus?: boolean;\n  defaultWidth?: number;\n}\n\n/**\n * Description of a column, used in TableSpec. Almost 1-1 to what is stored in _grist_Tables_column.\n */\nexport interface ColumnSpec<T = string> {\n  /** Name of the column (also used to match a property from external source) */\n  colId: T;\n  /** Type of the column to create in Grist */\n  type: GristType;\n  label: string;\n  hidden?: BindableValue<boolean>; // should this be hidden at start, by default not.\n  widgetOptions?: {\n    // Bare minimum to support Markdown and Choice widgets.\n    widget?: WidgetType;\n    choices?: string[];\n    choiceOptions?: Record<string, any>[];\n    alignment?: \"left\" | \"right\" | \"center\";\n  };\n  // Optional col id, if not provided it will be autogenerated. Useful fo linking two sections together.\n  colRef?: string | number;\n  // Default width for the field.\n  width?: number;\n  // An optional method that will convert this column to a Grist format (liek seconds).\n  transform?: (value: any, rec: Record<string, any>) => CellValue;\n}\n\n/**\n * Version of DocModel that is connected to DocDataCache, a version of DocData that can translate UserActions\n * (limited set) to DocActions, that is also used for on-demand tables.\n */\nclass InMemoryDocModel extends DocModel {\n  constructor() {\n    // First is the DocComm. We don't need to implement all methods, just the ones that are used by the VirtualTable.\n    const docComm: DocComm = {\n      fetchTable: async () => null,\n      // We are routing all actions to a DocDataCache, an in-memory implementation of DocData that can convert\n      // user actions to DocActions and keep the in-memory state of the tables.\n      async applyUserActions(actions: UserAction[], options?: ApplyUAOptions): Promise<ApplyUAResult> {\n        const processed = await docDataCache.sendTableActions(actions);\n        const retValues = processed.flatMap(action => action.retValues);\n        return { retValues, actionHash: \"\", actionNum: 1, isModification: true };\n      },\n    } as any;\n\n    // docData needs at least one record in doc info.\n    const metaWithData: typeof META_TABLES = {\n      ...META_TABLES,\n      _grist_DocInfo: [\"TableData\", \"_grist_DocInfo\", [1], {\n        docId: [\"1\"],\n        documentSettings: [\"{}\"],\n      }],\n    };\n    const docData = new DocData(docComm, metaWithData);\n    const docDataCache = new DocDataCache();\n    docDataCache.docData = docData;\n\n    // Sorry for this late constructor call, but the super class is doing a lot of actual work in\n    // the constructor, besides pure initialization.\n    // TODO: Remove code for the main constructor of DocModel.\n    super(docData);\n  }\n}\n\n/**\n * Prepare a empty App representation if one is not already created, empty objects are enough for us. Virtual tables\n * don't need the full App object yet (used for attachments for example, or custom plugins (not supported anyway)).\n */\nclass InMemoryApp extends DisposableWithEvents implements App {\n  public allCommands = commands.allCommands;\n  public comm = this.autoDispose(Comm.create());\n  public clientScope = this.autoDispose(ClientScope.create());\n  public features = ko.computed(() => ({} as ISupportedFeatures));\n  constructor(public topAppModel: TopAppModel) {\n    super();\n  }\n}\n\n/**\n * This is a version of docModel that is suitable for virtual tables. It is not initialized, but the super class\n * just subscribes to urlState reload the document. We don't need to do it.\n */\nclass InMemoryDocPageModel extends DocPageModelImpl {\n  public override initialize(): void {\n    // Ignore the initialization, for now it just subscribe itself to the url state.\n    // TODO: Refactor DocPageModelImpl for easier subtyping.\n  }\n}\n\n/**\n * Generate initial actions for a virtual table based on the TableSpec.\n */\nfunction generateInitialActions(tabDef: TableSpec): DocAction[] {\n  const tableId = tabDef.tableId ?? properId(tabDef.name);\n  const columnDefs = (tabDef.columns || []).map(col => ({ ...col, id: col.colRef ?? VirtualId() }));\n  const tableRowId = VirtualId();\n  const viewId = VirtualId();\n  const sectionRowId = VirtualId();\n  const fields = tabDef.fields ?? columnDefs.filter(c => !c.hidden).map(col => col.colId);\n  const fieldsIds = fields.map(VirtualId.bind(null, undefined)) as any as number[];\n  const widths = new Map(columnDefs.map(col => [col.colId, col.width ?? null]));\n  return [\n    [\n      // Add the virtual table.\n      \"AddTable\", tableId, columnDefs.map(col => ({\n        id: col.colId,\n        label: col.label,\n        type: col.type,\n        isFormula: false,\n        formula: \"\",\n        widgetOptions: col.widgetOptions ? JSON.stringify(col.widgetOptions) : \"\",\n      })),\n    ], [\n      // Add an entry for the virtual table.\n      \"AddRecord\", \"_grist_Tables\", tableRowId as any, { tableId, primaryViewId: viewId },\n    ], [\n      // Add entries for the columns of the virtual table.\n      \"BulkAddRecord\", \"_grist_Tables_column\",\n      columnDefs.map(col => col.id as any), getColValues(columnDefs.map(col =>\n        Object.assign({\n          isFormula: false,\n          formula: \"\",\n          parentId: tableRowId as any,\n          widgetOptions: col.widgetOptions ? JSON.stringify(col.widgetOptions) : \"\",\n        }, omit(col, [\"id\", \"widgetOptions\"]) as any))),\n    ],\n    [\n      // Add view instance.\n      \"AddRecord\", \"_grist_Views\", viewId as any, {\n        name: tabDef.name,\n        type: \"raw_data\",\n      },\n    ],\n    [\n      // Add a view section.\n      \"AddRecord\", \"_grist_Views_section\", sectionRowId as any,\n      {\n        tableRef: tableRowId,\n        parentId: viewId,\n        parentKey: tabDef.type ?? \"record\",\n        title: tabDef.name,\n        // By default virtual table are producing vertical layouts (where fields are just below each other).\n        layoutSpec: JSON.stringify({\n          children: fieldsIds.map(id => ({ leaf: id })),\n        } as BoxSpec),\n        showHeader: true,\n        borderWidth: 1,\n        defaultWidth: tabDef.defaultWidth ?? 100,\n      },\n    ],\n    [\n      // List the fields shown in the view section.\n      \"BulkAddRecord\", \"_grist_Views_section_field\", fieldsIds, {\n        colRef: fields.map(colId => columnDefs.find(r => r.colId === colId)!.id),\n        parentId: fields.map(() => sectionRowId),\n        parentPos: fields.map((_, i) => i + 1),\n        width: fields.map(colId => widths.get(colId) ?? null),\n      },\n    ],\n  ];\n}\n\nfunction properId(label: string) {\n  return camelCase(label.replace(/[^a-zA-Z0-9]/g, \"\"));\n}\n\nfunction maybePeek<T>(value: T | Observable<T>) {\n  return value instanceof BaseObservable ? value.get() : value;\n}\n\nfunction maybeUse<T>(use: UseCB, obs: BaseObservable<T> | T): T {\n  return obs instanceof BaseObservable ? use(obs) : obs;\n}\n"
  },
  {
    "path": "app/client/components/VirtualTable.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { ViewLayout, ViewSectionHelper } from \"app/client/components/ViewLayout\";\nimport { DocData } from \"app/client/models/DocData\";\nimport { DocModel, ViewFieldRec, ViewRec } from \"app/client/models/DocModel\";\nimport { QuerySetManager } from \"app/client/models/QuerySet\";\nimport { IEdit, IExternalTable, VirtualTableRegistration } from \"app/client/models/VirtualTable\";\nimport { META_TABLES } from \"app/client/models/VirtualTableMeta\";\nimport { WidgetType } from \"app/client/widgets/UserType\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { DocAction, getColValues, TableDataAction, UserAction } from \"app/common/DocActions\";\nimport { DocDataCache } from \"app/common/DocDataCache\";\nimport { VirtualId } from \"app/common/SortSpec\";\nimport { GristType } from \"app/plugin/GristData\";\n\nimport camelCase from \"camelcase\";\nimport { Disposable, dom, Emitter, Holder, Observable, toKo } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport omit from \"lodash/omit\";\nimport range from \"lodash/range\";\n\nimport type { DocComm, GristDoc } from \"app/client/components/GristDoc\";\nimport type BaseRowModel from \"app/client/models/BaseRowModel\";\nimport type { App } from \"app/client/ui/App\";\nimport type { FieldEditor } from \"app/client/widgets/FieldEditor\";\nimport type { ApplyUAOptions, ApplyUAResult } from \"app/common/ActiveDocAPI\";\nimport type { UIRowId } from \"app/plugin/GristAPI\";\n\n/**\n * This is a simple wrapper around VirtualTableRegistration and ExternalTable. It exposes\n * simple API to create a GridView component that is backed by external API source.\n *\n * Sample usage:\n *\n * const table = new VirtualTable({name: 'MyTable'});\n * table.addColumn({label: 'Name', type: 'Text'});\n * table.addColumn({label: 'Age', type: 'Numeric'});\n * table.setData([{Name: 'Alice', Age: 30}, {Name: 'Bob', Age: 40}]);\n *\n * return dom('div', table.buildDom());\n *\n * Or in more functional way\n *\n * return dom('div', dom.create(VirtualTable, {\n *  name: 'MyTable',\n *  columns: [{label: 'Name', type: 'Text'}, {label: 'Age', type: 'Numeric'}],\n *  data: [{Name: 'Alice', Age: 30}, {Name: 'Bob', Age: 40}],\n * });\n *\n * Note: This is first iteration, it will be refined more to completely remove GristDoc dependency.\n */\nexport class VirtualTable extends Disposable {\n  /** JSON array for plain JS objects to show on GridView */\n  private _data: Record<string, any>[] = [];\n  /** Columns definition. */\n  private _columns: ColDef[] = [];\n  /** In-memory GristDoc created ad hoc just for this virtual table. */\n  private _gristDoc: GristDoc;\n  /** Name of the table */\n  private _name: string;\n  /** Function to fetch data from external source */\n  private _getData?: () => Promise<any>;\n\n  /** Virtual ids of elements stored in the DocData */\n  private _viewId = VirtualId();\n  private _sectionId = VirtualId();\n  private _tableId = VirtualId();\n\n  private _transformColumns: ColDef[] = [];\n\n  constructor(options: {\n    name: string;\n    columns?: (Partial<ColDef> & { label: string })[];\n    data?: Record<string, any>[];\n    getData?: () => Promise<any>;\n  }) {\n    super();\n    this._name = options.name;\n    this._data = options.data || [];\n    this._getData = options.getData;\n    if (options.columns) {\n      options.columns.forEach(col => this.addColumn(col));\n    }\n  }\n\n  /**\n   * Changes the name of the table.\n   */\n  public rename(name: string) {\n    this._name = name;\n  }\n\n  /**\n   * Adds a column to the virtual table. Only the label is required, other properties are optional.\n   */\n  public addColumn(...cols: (Partial<ColDef> & { label: string })[]) {\n    this._columns ??= [];\n    cols.forEach(col => this._columns.push({ type: col.type || \"Any\", colId: toId(col.label), ...col }));\n  }\n\n  /**\n   * Sets the static data for the virtual table.\n   */\n  public setData(recs: any[]): void;\n  /**\n   * Sets the function to fetch data from external source.\n   */\n  public setData(func: () => Promise<any>): void;\n  public setData(args: any) {\n    if (typeof args === \"function\") {\n      this._getData = args;\n      return;\n    } else {\n      this._data = args;\n    }\n  }\n\n  public buildDom() {\n    this._build();\n    return dom(\"div\",\n      dom.style(\"flex\", \"1\"),\n      dom.create(ViewLayout, this._gristDoc, this._viewId as any),\n    );\n  }\n\n  private _build() {\n    // Check if we were already built.\n    if (this._gristDoc) {\n      return;\n    }\n\n    // Attach virtual id for each column, and convert widgetOptions to a string.\n    const columnDefs = this._columns.map(col => ({\n      id: VirtualId(),\n      ...col,\n      widgetOptions: col.widgetOptions ? JSON.stringify(col.widgetOptions) : undefined,\n    }));\n\n    this._transformColumns = this._columns.filter(col => col.transform);\n\n    // Prepare fields definition for the view section. By default we show all columns.\n    const fieldsDefs = columnDefs.map(col => col.colId);\n\n    // Prepare in memory structures for managing data, the code below is subject to change. It was created\n    // by reverse engineering the calls that are made in the Grist codebase. The goal here is to remove this code\n    // completely and allow basic Grist components to work without GristDoc.\n\n    // First is the DocComm. We don't need to implement all methods, just the ones that are used by the VirtualTable.\n    const docComm: DocComm = {\n      fetchTable: async () => null,\n      // We are routing all actions to a DocDataCache, an in-memory implementation of DocData that can convert\n      // user actions to DocActions and keep the in-memory state of the tables.\n      async applyUserActions(actions: UserAction[], options?: ApplyUAOptions): Promise<ApplyUAResult> {\n        const processed = await docDataCache.sendTableActions(actions);\n        const retValues = processed.flatMap(action => action.retValues);\n        return { retValues, actionHash: \"\", actionNum: 1, isModification: true };\n      },\n    } as any;\n\n    // Next is the DocData object, it will be managed by the DocDataCache.\n\n    // docData needs at least one record in doc info.\n    const metaWithData: typeof META_TABLES = {\n      ...META_TABLES,\n      _grist_DocInfo: [\"TableData\", \"_grist_DocInfo\", [1], {\n        docId: [\"1\"],\n        documentSettings: [\"{}\"],\n      }],\n    };\n    const docData = new DocData(docComm, metaWithData);\n    const docDataCache = new DocDataCache();\n    docDataCache.docData = docData;\n\n    // _grist_DocInfo\n\n    // Last one is the DocModel. GridView needs this the most, plus some extra methods from GristDoc.\n    const docModel = this.autoDispose(new DocModel(docData));\n\n    // Next is wiring up the ExternalTable and VirtualTableRegistration.\n    const ext = this.autoDispose(new ExternalTable());\n    ext.label = this._name;\n    ext.name = `GristHidden_${toId(this._name)}Table`;\n\n    // Before returning data to the ExternalTable to process we will transform it a little bit and then convert\n    // it back to TableData format. API we work with, will likely return records (plain JS objects) instead of TableData\n    // format.\n    ext.fetchAll = () => Promise.resolve(toTableData(ext.name, this._transform(this._data)));\n    if (this._getData) {\n      // In case we have a function to fetch data, we will use it instead of the static data.\n      ext.fetchAll = async () => toTableData(ext.name, this._transform(await this._getData!()));\n    }\n\n    // Next are the initial actions that are needed to create the virtual table.\n    // TODO: this should be set as a default in the ExternalTable. Currently each VirtualTable has the same initial\n    // actions, so it can be a default for all.\n    ext.initialActions = () => {\n      const tableId = ext.name;\n      return [\n        [\n          // Add the virtual table.\n          \"AddTable\", tableId, columnDefs.map(col => ({\n            id: col.colId,\n            label: col.label,\n            type: col.type,\n            isFormula: false,\n            formula: \"\",\n            widgetOptions: col.widgetOptions,\n          })),\n        ], [\n          // Add an entry for the virtual table.\n          \"AddRecord\", \"_grist_Tables\", this._tableId as any, { tableId, primaryViewId: 0 },\n        ], [\n          // Add entries for the columns of the virtual table.\n          \"BulkAddRecord\", \"_grist_Tables_column\",\n          columnDefs.map(col => col.id) as any, getColValues(columnDefs.map(rec =>\n            Object.assign({\n              isFormula: false,\n              formula: \"\",\n              parentId: this._tableId as any,\n            }, omit(rec, [\"id\"]) as any))),\n        ],\n        [\n          // Add view instance.\n          \"AddRecord\", \"_grist_Views\", this._viewId as any, {\n            name: this._name,\n            type: \"raw_data\",\n          },\n        ],\n        [\n          // Add a view section.\n          \"AddRecord\", \"_grist_Views_section\", this._sectionId as any,\n          {\n            tableRef: this._tableId,\n            parentId: this._viewId,\n            parentKey: \"record\",\n            title: this._name, layout: \"vertical\", showHeader: true,\n            borderWidth: 1, defaultWidth: 100,\n          },\n        ],\n        [\n          // List the fields shown in the view section.\n          \"BulkAddRecord\", \"_grist_Views_section_field\", fieldsDefs.map(VirtualId.bind(null, undefined)) as any, {\n            colRef: fieldsDefs.map(colId => columnDefs.find(r => r.colId === colId)!.id),\n            parentId: fieldsDefs.map(() => this._sectionId),\n            parentPos: fieldsDefs.map((_, i) => i),\n          },\n        ],\n      ];\n    };\n\n    // Now register it inside docModel.\n    this.autoDispose(new VirtualTableRegistration(docModel, ext));\n\n    // Amend viewSectionModel to hide view menu.\n    const viewSectionModel = docModel.viewSections.rowModels[this._sectionId as any as number];\n    viewSectionModel.hideViewMenu(true);\n    viewSectionModel.canRename(false);\n\n    // Create a minimal GristDoc like object that can be used by the Grid and other components.\n    // TODO: this should be removed by refactoring GridView and other Layout components to not depend on GristDoc.\n    this._gristDoc = new InMemoryGristDoc(docModel, this._viewId) as any as GristDoc;\n\n    // Initialize the view section using the helper.\n    ViewSectionHelper.create(this, this._gristDoc, viewSectionModel);\n  }\n\n  /**\n   * Some columns can be registered with an additional function `transform` that will be used to transform the\n   * data before it is displayed in the GridView. Most likely data that come from the API will have different\n   * format to the one that is expected by the GridView.\n   */\n  private _transform(rows: any[]): any {\n    // Find columns with transform function.\n    if (!this._transformColumns.length) {\n      return rows;\n    }\n    return rows.map((row) => {\n      const ret: Record<string, any> = { ...row };\n      this._transformColumns.forEach((col) => {\n        ret[col.colId] = col.transform!(row[col.colId]);\n      });\n      return ret;\n    });\n  }\n}\n\n/** This is default empty implementation of an external table used only for viewing */\nclass ExternalTable extends Disposable implements IExternalTable {\n  public name = \"\";\n  public label = \"\";\n  public saveableFields = [];\n  public fetchAll: () => Promise<TableDataAction>;\n  public initialActions: () => DocAction[];\n  public async beforeEdit(editor: IEdit) {}\n  public async afterEdit(editor: IEdit) {}\n  public async afterAnySchemaChange(editor: IEdit) {}\n  public async sync(editor: IEdit): Promise<void> {}\n}\n\n/** Converts any kind of string to an identifier */\nfunction toId(label: string) {\n  return camelCase(label.replace(/[^a-zA-Z0-9]/g, \"\"));\n}\n\n/** Converts arbitrary records to a TableData action */\nfunction toTableData(name: string, data: Record<string, any>[]): TableDataAction {\n  const indices = range(data.length).map(i => i + 1);\n  return [\"TableData\", name, indices,\n    getColValues(\n      indices.map(rowId => ({\n        id: rowId,\n        ...data[rowId - 1],\n      })),\n    )];\n}\n\n/**\n * This is hacky, sorry for that. This is a minimal implementation of GristDoc that makes it possible to use\n * VirtualTable without the need to have a full GristDoc implementation.\n * TODO: remove this code by refactoring GridView and other components to not depend on GristDoc.\n */\nclass InMemoryGristDoc extends Disposable {\n  public viewModel: ViewRec;\n  public app: App;\n  public isReadonly = Observable.create(this, true);\n  public isReadonlyKo = toKo(ko, this.isReadonly);\n  public maximizedSectionId = Observable.create(this, null);\n  public externalSectionId = Observable.create(this, null);\n  public readonly resizeEmitter = this.autoDispose(new Emitter());\n  public readonly fieldEditorHolder = Holder.create(this);\n  public readonly activeEditor: Observable<FieldEditor | null> = Observable.create(this, null);\n  public comparison = false;\n  public docData: DocData = {} as any; // Don't need anything from it here.\n  public docInfo = { timezone: Observable.create(null, \"UTC\") };\n  public querySetManager = new QuerySetManager(this.docModel, {} as any);\n  public docPageModel = {\n    appModel: {\n      dismissedPopups: Observable.create(null, []),\n    },\n  };\n\n  public behavioralPromptsManager = {\n    attachPopup: () => {},\n  };\n\n  constructor(public docModel: DocModel, viewId: any) {\n    super();\n    if (window.gristApp) {\n      this.app = window.gristApp as App;\n    } else {\n      this.app = this.autoDispose(new DisposableWithEvents()) as any;\n    }\n    this.viewModel = this.autoDispose(this.docModel.views.createFloatingRowModel(ko.observable(viewId)));\n    this.autoDispose(commands.createGroup({\n      setCursor: this.onSetCursorPos.bind(this),\n    }, this, true));\n  }\n\n  public getCsvLink() {\n    return \"\";\n  }\n\n  public getTsvLink() {\n    return \"\";\n  }\n\n  public getDsvLink() {\n    return \"\";\n  }\n\n  public getXlsxActiveViewLink() {\n    return \"\";\n  }\n\n  public async clearColumns() {}\n  public async convertIsFormula() {}\n  public async sendTableAction() {}\n  public async sendTableActions() {}\n  public convertToCard() {}\n  public getTableModelMaybeWithDiff(tableId: string) {\n    return this.docModel.getTableModel(tableId);\n  }\n\n  public getTableModel(tableId: string) {\n    return this.docModel.getTableModel(tableId);\n  }\n\n  public async onSetCursorPos(rowModel: BaseRowModel | undefined, fieldModel?: ViewFieldRec) {\n    const cursorPos = {\n      rowIndex: rowModel?._index() || 0,\n      fieldIndex: fieldModel?._index() || 0,\n      sectionId: fieldModel?.viewSection().getRowId(),\n    };\n    const viewInstance = this.viewModel.activeSection.peek().viewInstance.peek();\n    viewInstance?.setCursorPos(cursorPos);\n    this.app?.trigger(\"clipboard_focus\", null);\n  }\n\n  public getLinkingRowIds(sectionId: number): UIRowId[] | undefined {\n    throw new Error(\"Anchor links are not supported in virtual tables.\");\n  }\n}\n\n/**\n * This is a minimal representation of the Column definition that is used by the VirtualTable.\n * It more or less follows app/common/schema.ts, but it is simplified to only include the properties\n * that are needed by the VirtualTable.\n */\ninterface ColDef {\n  colId: string;\n  type: GristType;\n  label: string;\n  widgetOptions?: {\n    widget?: WidgetType;\n    choices?: string[];\n    choiceOptions?: Record<string, any>[];\n    alignment?: \"left\" | \"right\" | \"center\";\n  };\n  /**\n   * If set, Virtual table will use this function to convert value before displaying it in the GridView.\n   */\n  transform?: (value: any) => any;\n}\n"
  },
  {
    "path": "app/client/components/WidgetFrame.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport { CommandName } from \"app/client/components/commandList\";\nimport * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { hooks } from \"app/client/Hooks\";\nimport { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { makeTestId } from \"app/client/lib/domUtils\";\nimport { sanitizeHttpUrl } from \"app/client/lib/sanitizeUrl\";\nimport { ColumnRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { reportError } from \"app/client/models/errors\";\nimport { gristThemeObs } from \"app/client/ui2018/theme\";\nimport { AccessLevel, ICustomWidget, isSatisfied, matchWidget } from \"app/common/CustomWidget\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { BulkColValues, CellValue, fromTableDataAction, RowRecord } from \"app/common/DocActions\";\nimport { extractInfoFromColType, GristTypeInfo, reencodeAsTypedCellValue } from \"app/common/gristTypes\";\nimport { convertThemeKeysToCssVars } from \"app/common/ThemePrefs\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport {\n  AccessTokenOptions, CursorPos, CustomSectionAPI, FetchSelectedOptions, GristDocAPI, GristView,\n  InteractionOptionsRequest, WidgetAPI, WidgetColumnMap,\n} from \"app/plugin/grist-plugin-api\";\nimport { CellFormatType } from \"app/plugin/GristAPI\";\nimport { GristObjCode } from \"app/plugin/GristData\";\n\nimport { MsgType, Rpc } from \"grain-rpc\";\nimport { Computed, Disposable, dom, Observable } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\nimport flatMap from \"lodash/flatMap\";\nimport identity from \"lodash/identity\";\nimport isEqual from \"lodash/isEqual\";\nimport noop from \"lodash/noop\";\n\nconst testId = makeTestId(\"test-custom-widget-\");\n\n/**\n * This file contains a WidgetFrame and all its components.\n *\n * WidgetFrame embeds an external Custom Widget (external webpage) in an iframe. It is used on a CustomView,\n * to display widget content, and on the configuration screen to display widget's configuration screen.\n *\n * Beside exposing widget content, it also exposes some of the API's that Grist offers via grist-rpc.\n * API are defined in the core/app/plugin/grist-plugin-api.ts.\n */\n\nconst G = getBrowserGlobals(\"window\");\n\n/**\n * Options for WidgetFrame\n */\nexport interface WidgetFrameOptions {\n  /**\n   * Url of external page. Iframe is rebuild each time the URL changes.\n   */\n  url: string | null;\n  /**\n   * ID of widget, if known. When set, the url for the specified widget\n   * in the WidgetRepository, if found, will take precedence.\n   */\n  widgetId?: string | null;\n  /**\n   * ID of the plugin that provided the widget (if it came from a plugin).\n   */\n  pluginId?: string;\n  /**\n   * Assigned access level. Iframe is rebuild each time access level is changed.\n   */\n  access: AccessLevel;\n  /**\n   * If document is in readonly mode.\n   */\n  readonly: boolean;\n  /**\n   * If set, show the iframe after `grist.ready()`.\n   *\n   * This is used to defer showing a widget on initial load until it has finished\n   * applying the Grist theme.\n   */\n  showAfterReady?: boolean;\n  /**\n   * Optional callback to configure exposed API.\n   */\n  configure?: (frame: WidgetFrame) => void;\n  /**\n   * Handler to modify the iframe.\n   */\n  onElem: (iframe: HTMLIFrameElement) => void;\n  /**\n   * Optional language to use for the widget.\n   */\n  preferences: { language?: string, timeZone?: any, currency?: string, culture?: string };\n  /**\n   * The containing document.\n   */\n  gristDoc: GristDoc;\n}\n\n/**\n * Iframe that embeds Custom Widget page and exposes Grist API.\n */\nexport class WidgetFrame extends DisposableWithEvents {\n  // A grist-rpc object, encapsulated to prevent direct access.\n  private _rpc: Rpc;\n  // Created iframe element, used to receive and post messages via Rpc\n  private _iframe: HTMLIFrameElement | null;\n  // If widget called ready() method, this will be set to true.\n  private _readyCalled = Observable.create(this, false);\n  // Whether the iframe is visible.\n  private _visible = Observable.create(this, !this._options.showAfterReady);\n  private readonly _widget = Observable.create<ICustomWidget | null>(this, null);\n\n  private _url: Observable<string>;\n  /**\n   * If the widget URL is empty, it also means that we are showing the empty page.\n   */\n  private _isEmpty: Observable<boolean>;\n\n  constructor(private _options: WidgetFrameOptions) {\n    super();\n    _options.access = _options.access || AccessLevel.none;\n    // Build RPC object and connect it to iframe.\n    this._rpc = new Rpc({});\n\n    // queue until iframe's content emit ready() message\n    this._rpc.queueOutgoingUntilReadyMessage();\n\n    // Register outgoing message handler.\n    this._rpc.setSendMessage(msg => this._iframe?.contentWindow!.postMessage(msg, \"*\"));\n\n    // Register incoming message handler.\n    const listener = this._onMessage.bind(this);\n    // 'message' is an event's name used by Rpc in window to iframe communication.\n    G.window.addEventListener(\"message\", listener);\n    this.onDispose(() => {\n      // Stop listening for events from the iframe.\n      G.window.removeEventListener(\"message\", listener);\n      // Stop sending messages to the iframe.\n      this._rpc.setSendMessage(noop);\n    });\n\n    // Call custom configuration handler.\n    _options.configure?.(this);\n\n    this._checkWidgetRepository().catch(reportError);\n\n    // Url if set.\n    const maybeUrl = Computed.create(this, use => use(this._widget)?.url || this._options.url);\n\n    // Url to widget or empty page with access level and preferences.\n    this._url = Computed.create(\n      this,\n      use => this._urlWithAccess(use(maybeUrl)) || this._getEmptyWidgetPage(),\n    );\n\n    // Iframe is empty when url is not set.\n    this._isEmpty = Computed.create(this, use => !use(maybeUrl));\n\n    // When isEmpty is switched to true, reset the ready state.\n    this.autoDispose(this._isEmpty.addListener((isEmpty) => {\n      if (isEmpty) {\n        this._readyCalled.set(false);\n      }\n    }));\n  }\n\n  /**\n   * Attach an EventSource with desired access level.\n   */\n  public useEvents(source: IEventSource, access: AccessChecker) {\n    // Wrap event handler with access check.\n    const handler = async (data: any) => {\n      if (access.check(this._options.access)) {\n        await this._rpc.postMessage(data);\n      }\n    };\n    this.listenTo(source, \"event\", handler);\n    // Give EventSource a chance to attach to WidgetFrame events.\n    source.attach(this);\n  }\n\n  /**\n   * Exposes API for Custom Widget.\n   * TODO: add ts-interface support. Currently all APIs are written in typescript,\n   * so those checks are not that needed.\n   */\n  public exposeAPI(name: string, api: any, access: AccessChecker) {\n    this._rpc.registerImpl(name, wrapObject(api, access, this._options.access));\n    this.onDispose(() => this._rpc.unregisterImpl(name));\n  }\n\n  /**\n   * Expose a method for Custom Widget.\n   */\n  public exposeMethod(name: string, handler: (...args: any[]) => any, access: AccessChecker) {\n    this._rpc.registerFunc(name, (...args: any[]) => {\n      if (access.check(this._options.access, \"invoke\")) {\n        return handler(...args);\n      } else {\n        throwError(this._options.access);\n      }\n    });\n  }\n\n  /**\n   * Make configure call to the widget. Widget should open some configuration screen or ignore it.\n   */\n  public editOptions() {\n    return this.callRemote(\"editOptions\");\n  }\n\n  /**\n   * Call remote function that is exposed by the widget.\n   */\n  public callRemote(name: string, ...args: any[]) {\n    return this._rpc.callRemoteFunc(name, ...args);\n  }\n\n  public buildDom() {\n    this._iframe = dom(\n      \"iframe\",\n      dom.style(\"visibility\", use => use(this._visible) ? \"visible\" : \"hidden\"),\n      dom.cls(\"clipboard_allow_focus\"),\n      dom.cls(\"custom_view\"),\n      dom.attr(\"src\", this._url),\n      // Allow widgets to write to the clipboard via the Clipboard API.\n      { allow: \"clipboard-write\" },\n      hooks.iframeAttributes,\n      testId(\"ready\", use => use(this._readyCalled) && !use(this._isEmpty)),\n      (elem) => { this._options.onElem(elem); },\n    );\n    return this._iframe;\n  }\n\n  // Appends access level to query string.\n  private _urlWithAccess(url: string | null): string | null {\n    if (!url) {\n      return url;\n    }\n\n    let urlObj: URL;\n    try {\n      urlObj = new URL(url);\n    } catch (e) {\n      console.error(e);\n      return null;\n    }\n    urlObj.searchParams.append(\"access\", this._options.access);\n    urlObj.searchParams.append(\"readonly\", String(this._options.readonly));\n    // Append user and document preferences to query string.\n    const settingsParams = new URLSearchParams(this._options.preferences);\n    settingsParams.forEach((value, key) => urlObj.searchParams.append(key, value));\n    return sanitizeHttpUrl(urlObj.href);\n  }\n\n  private _getEmptyWidgetPage(): string {\n    return new URL(\"custom-widget.html\", getGristConfig().homeUrl!).href;\n  }\n\n  private _onMessage(event: MessageEvent) {\n    if (this._iframe && event.source === this._iframe.contentWindow && !this.isDisposed()) {\n      // Previously, we forwarded messages targeted at \"grist\" to the back-end.\n      // Now, we process them immediately in the context of the client for access\n      // control purposes.  To do that, any message that comes in with mdest of\n      // \"grist\" will have that destination wiped, and we provide a local\n      // implementation of the interface.\n      // It feels like it should be possible to deal with the mdest more cleanly,\n      // with a rpc.registerForwarder('grist', { ... }), but it seems somehow hard\n      // to call a locally registered interface of an rpc object?\n      if (event.data.mdest === \"grist\") {\n        event.data.mdest = \"\";\n      }\n      if (event.data.mtype === MsgType.Ready) {\n        this.trigger(\"ready\", this);\n        this._readyCalled.set(true);\n      }\n      if (event.data.data?.message === \"themeInitialized\") {\n        this._visible.set(true);\n      }\n      this._rpc.receiveMessage(event.data);\n    }\n  }\n\n  /**\n   * If we have a widgetId, look it up in the WidgetRepository and\n   * get the best URL we can for it.\n   */\n  private async _checkWidgetRepository() {\n    const { widgetId, pluginId } = this._options;\n    if (this.isDisposed() || !widgetId) { return; }\n    const widgets = await this._options.gristDoc.app.topAppModel.getWidgets();\n    if (this.isDisposed()) { return; }\n    const widget = matchWidget(widgets, { widgetId, pluginId });\n    this._widget.set(widget || null);\n  }\n}\n\nconst throwError = (access: AccessLevel) => {\n  throw new Error(\"Access not granted. Current access level \" + access);\n};\n\n/**\n * Wraps an object to check access level before it is called.\n * TODO: grain-rpc exposes callWrapper which could be used for this purpose,\n * but currently it doesn't have access to the incoming message.\n */\nfunction wrapObject<T extends object>(impl: T, accessChecker: AccessChecker, access: AccessLevel): T {\n  return new Proxy(impl, {\n    // This proxies all the calls to methods on the API.\n    get(target: any, methodName: string) {\n      return function() {\n        if (methodName === \"then\") {\n          // Making a proxy for then invocation is not a good idea.\n          return undefined;\n        }\n        if (accessChecker.check(access, methodName)) {\n          return target[methodName](...arguments);\n        } else {\n          throwError(access);\n        }\n      };\n    },\n  });\n}\n\n/**\n * Interface for custom access rules.\n */\nexport interface AccessChecker {\n  /**\n   * Checks if the incoming call can be served on current access level.\n   * @param access Current access level\n   * @param method Method called on the interface, can use * or undefined to match all methods.\n   */\n  check(access: AccessLevel, method?: string): boolean;\n}\n\n/**\n * Checks if current access level is enough.\n */\nexport class MinimumLevel implements AccessChecker {\n  constructor(private _minimum: AccessLevel) {}\n  public check(access: AccessLevel): boolean {\n    return isSatisfied(access, this._minimum);\n  }\n}\n\ntype MethodMatcher<T> = keyof T | \"*\";\n/**\n * Helper object that allows assigning access level to a particular method in the interface.\n *\n * Example:\n *\n * 1. Expose two methods, all other will be denied (even in full access mode)\n * new ApiGranularAccess<GristDocAPI>()\n *  .require(\"read_table\", \"method1\") // for method1 we need at least read_table\n *  .require(\"none\", \"method2\") // for method2 no access level is needed\n *\n * 2. Expose two methods, all other will require full access (effectively the same as ex. 1)\n * new ApiGranularAccess<GristDocAPI>()\n *  .require(\"read_table\", \"method1\") // for method1 we need at least read_table\n *  .require(\"none\", \"method2\") // for method2 no access level is needed\n *  .require(\"full\", \"*\") // for any other, require full\n *\n * 3. Expose all methods on read_table access, but one can have none\n * new ApiGranularAccess<GristDocAPI>()\n *  .require(\"none\", \"method2\") // for method2 we are ok with none access\n *  .require(\"read_table\", \"*\") // for any other, require read_table\n */\nexport class MethodAccess<T> implements AccessChecker {\n  private _accessMap = new Map<MethodMatcher<T>, AccessLevel>();\n  constructor() {}\n  public require(level: AccessLevel, method: MethodMatcher<T> = \"*\") {\n    this._accessMap.set(method, level);\n    return this;\n  }\n\n  public check(access: AccessLevel, method?: string): boolean {\n    if (!method) {\n      throw new Error(\"Method name is required for MethodAccess check\");\n    }\n    // Check if the iface was registered.\n    if (this._accessMap.has(method as MethodMatcher<T>)) {\n      // If it was, check that minimum access level is granted.\n      const minimum = this._accessMap.get(method as MethodMatcher<T>)!;\n      return isSatisfied(access, minimum);\n    } else if (this._accessMap.has(\"*\")) {\n      // If there is a default rule, check if it permits the access.\n      const minimum = this._accessMap.get(\"*\")!;\n      return isSatisfied(access, minimum);\n    } else {\n      // By default, don't allow anything on this interface.\n      return false;\n    }\n  }\n}\n\n/***********************\n * Exposed APIs for Custom Widgets.\n *\n * Currently we expose 3 APIs\n * - GristDocAPI - full access to document.\n * - ViewAPI - access to current table.\n * - WidgetAPI - access to widget configuration.\n ***********************/\n\n/**\n * GristDocApi implemented over active GristDoc.\n */\nexport class GristDocAPIImpl implements GristDocAPI {\n  public static readonly defaultAccess = new MethodAccess<GristDocAPI>()\n    .require(AccessLevel.read_table, \"getDocName\")\n    .require(AccessLevel.full); // for any other, require full Access.\n\n  constructor(private _doc: GristDoc) {}\n\n  public async getDocName() {\n    return this._doc.docId();\n  }\n\n  public async listTables(): Promise<string[]> {\n    // Could perhaps read tableIds from this.gristDoc.docModel.visibleTableIds.all()?\n    const { tableData } = await this._doc.docComm.fetchTable(\"_grist_Tables\");\n    // Tables the user doesn't have access to are just blanked out.\n    return tableData[3].tableId.filter(tableId => tableId !== \"\") as string[];\n  }\n\n  public async fetchTable(tableId: string) {\n    return fromTableDataAction(await this._doc.docComm.fetchTable(tableId));\n  }\n\n  public async applyUserActions(actions: any[][], options?: any) {\n    return this._doc.docComm.applyUserActions(actions, { desc: undefined, ...options });\n  }\n\n  // Get a token for out-of-band access to the document.\n  // Currently will require the custom widget to have full access to the\n  // document.\n  // It would be great to support this with read_table rights. This could be\n  // possible to do by adding a tableId setting to AccessTokenOptions,\n  // encoding that limitation in the access token, and ensuring the back-end\n  // respects it. But the current motivating use for adding access tokens is\n  // showing attachments, and they aren't currently something that logically\n  // lives within a specific table.\n  public async getAccessToken(options: AccessTokenOptions) {\n    return this._doc.docComm.getAccessToken({\n      readOnly: options.readOnly,\n    });\n  }\n}\n\n/**\n * GristViewAPI implemented over BaseView.\n */\nexport class GristViewImpl implements GristView {\n  constructor(private _baseView: BaseView, private _access: AccessLevel) {\n  }\n\n  public async fetchSelectedTable(options: FetchSelectedOptions = {}): Promise<any> {\n    // If widget has a custom columns mapping, we will ignore hidden columns section.\n    // Hidden/Visible columns will eventually reflect what is available, but this operation\n    // is not instant - and widget can receive rows with fields that are not in the mapping.\n    const columns: ColumnRec[] = this._visibleColumns(options);\n    const rowIds = this._baseView.sortedRows.getKoArray().peek().filter(id => id != \"new\");\n    const data: BulkColValues = {};\n    const expandRefs = options.expandRefs ?? (options.cellFormat === \"typed\" ? false : true);\n    const reencode = getReencode(options.cellFormat);\n    for (const column of columns) {\n      // Use the colId of the displayCol when expanding references, so\n      // we don't get the underlying refId instead.\n      const colId: string = expandRefs ? column.displayColModel.peek().colId.peek() : column.colId.peek();\n      const getter = this._baseView.tableModel.tableData.getRowPropFunc(colId);\n      const typeInfo = extractInfoFromColType(column.type.peek());\n      data[column.colId.peek()] = rowIds.map(r => reencode(getter(r)!, typeInfo));\n    }\n    data.id = rowIds;\n    return data;\n  }\n\n  public async fetchSelectedRecord(rowId: number, options: FetchSelectedOptions = {}): Promise<any> {\n    // Prepare an object containing the fields available to the view\n    // for the specified row.  A RECORD()-generated rendering would be\n    // more useful. but the data engine needs to know what information\n    // the custom view depends on, so we shouldn't volunteer any untracked\n    // information here.\n    const columns: ColumnRec[] = this._visibleColumns(options);\n    const data: RowRecord = { id: rowId };\n    const expandRefs = options.expandRefs ?? (options.cellFormat === \"typed\" ? false : true);\n    const reencode = getReencode(options.cellFormat);\n    for (const column of columns) {\n      const typeInfo = extractInfoFromColType(column.type.peek());\n      const colId: string = expandRefs ? column.displayColModel.peek().colId.peek() : column.colId.peek();\n      data[column.colId.peek()] = reencode(\n        this._baseView.tableModel.tableData.getValue(rowId, colId)!,\n        typeInfo,\n      );\n    }\n    return data;\n  }\n\n  /**\n   * This is deprecated method to turn on cursor linking. Previously it was used\n   * to create a custom row id filter. Now widgets can be treated as normal source of linking.\n   * Now allowSelectBy should be set using the ready event.\n   */\n  public async allowSelectBy(): Promise<void> {\n    this._baseView.viewSection.allowSelectBy(true);\n    // This is to preserve a legacy behavior, where when allowSelectBy is called widget expected\n    // that the filter was already applied to clear all rows.\n    this._baseView.viewSection.selectedRows([]);\n  }\n\n  public async setSelectedRows(rowIds: number[] | null): Promise<void> {\n    this._baseView.viewSection.selectedRows(rowIds);\n  }\n\n  public setCursorPos(cursorPos: CursorPos): Promise<void> {\n    this._baseView.setCursorPos(cursorPos);\n    return Promise.resolve();\n  }\n\n  private _visibleColumns(options: FetchSelectedOptions): ColumnRec[] {\n    const columns: ColumnRec[] = this._baseView.viewSection.columns.peek();\n    // If columns are mapped, return only those that are mapped.\n    const mappings = this._baseView.viewSection.mappedColumns.peek();\n    if (mappings) {\n      const mappedColumns = new Set(flatMap(Object.values(mappings)));\n      const mapped = (col: ColumnRec) => mappedColumns.has(col.colId.peek());\n      return columns.filter(mapped);\n    } else if (options.includeColumns === \"shown\" || !options.includeColumns) {\n      // Return columns that have been shown by the user, i.e. have a corresponding view field.\n      const hiddenCols = this._baseView.viewSection.hiddenColumns.peek().map(c => c.id.peek());\n      const notHidden = (col: ColumnRec) => !hiddenCols.includes(col.id.peek());\n      return columns.filter(notHidden);\n    }\n    // These options are newer and expose more data than the user may have intended,\n    // so they require full access.\n    if (this._access !== AccessLevel.full) {\n      throw new Error(\n        `Setting includeColumns to ${options.includeColumns} requires full access.` +\n        ` Current access level is ${this._access}`);\n    }\n    if (options.includeColumns === \"normal\") {\n      // Return all 'normal' columns of the table, regardless of whether the user has shown them.\n      return columns;\n    } else {\n      // Return *all* columns, including special invisible columns like manualSort.\n      return this._baseView.viewSection.table.peek().columns.peek().all();\n    }\n  }\n}\n\n/**\n * WidgetAPI implemented over active section.\n */\nexport class WidgetAPIImpl implements WidgetAPI {\n  constructor(private _section: ViewSectionRec) {}\n\n  /**\n   * Stores options in viewSection.customDef.widgetDef json field.\n   * This way whenever widget is changed, options are removed and not shared\n   * between widgets by design.\n   */\n  public async setOptions(options: object): Promise<void> {\n    if (options === null || options === undefined || typeof options !== \"object\") {\n      throw new Error(\"options must be a valid JSON object\");\n    }\n    this._section.activeCustomOptions(options);\n  }\n\n  public async getOptions(): Promise<Record<string, unknown> | null> {\n    return this._section.activeCustomOptions.peek() ?? null;\n  }\n\n  public async clearOptions(): Promise<void> {\n    this._section.activeCustomOptions(null);\n  }\n\n  public async setOption(key: string, value: any): Promise<void> {\n    const options = { ...this._section.activeCustomOptions.peek() };\n    options[key] = value;\n    this._section.activeCustomOptions(options);\n  }\n\n  public getOption(key: string): Promise<unknown> {\n    const options = this._section.activeCustomOptions.peek();\n    return options?.[key];\n  }\n}\n\nconst COMMAND_MINIMUM_ACCESS_LEVELS = new Map<CommandName, AccessLevel>([\n  [\"undo\", AccessLevel.full],\n  [\"redo\", AccessLevel.full],\n  [\"viewAsCard\", AccessLevel.read_table],\n]);\n\nexport class CommandAPI {\n  constructor(private _currentAccess: AccessLevel) {}\n\n  public async run(commandName: CommandName): Promise<unknown> {\n    const minimumAccess = COMMAND_MINIMUM_ACCESS_LEVELS.get(commandName);\n    if (minimumAccess === undefined || !isSatisfied(this._currentAccess, minimumAccess)) {\n      // If the command name is unrecognized, or the current access level doesn't meet the\n      // command's minimum access level, do nothing.\n      return;\n    }\n\n    return await commands.allCommands[commandName].run();\n  }\n}\n\n/************************\n * Events that are sent to the CustomWidget.\n *\n * Currently:\n * - onRecord, implemented by RecordNotifier, sends a message each time active row is changed.\n * - onRecords, implemented by TableNotifier, sends a message each time table is changed\n * - onOptions, implemented by ConfigNotifier, sends a message each time configuration is changed\n *\n * All of those events are also sent when CustomWidget sends its ready message.\n ************************/\n\n/**\n * EventSource should trigger event called \"event\" that will be send to the Custom Widget.\n */\nexport interface IEventSource extends DisposableWithEvents {\n  /**\n   * Called by WidgetFrame, allowing EventSource to attach to its ready event.\n   */\n  attach(frame: WidgetFrame): void;\n}\n\nexport class BaseEventSource extends DisposableWithEvents implements IEventSource {\n  // Attaches to WidgetFrame ready event.\n  public attach(frame: WidgetFrame): void {\n    this.listenTo(frame, \"ready\", this._ready.bind(this));\n  }\n\n  protected _ready() {\n    // To override if needed to react on the ready event.\n  }\n\n  protected _notify(data: any) {\n    if (this.isDisposed()) {\n      return;\n    }\n    this.trigger(\"event\", data);\n  }\n}\n\n/**\n * Notifies about cursor position change. Exposed in the API as a onRecord handler.\n */\nexport class RecordNotifier extends BaseEventSource {\n  private _debounced: () => void; // debounced call to let the view know linked cursor changed.\n  constructor(private _baseView: BaseView) {\n    super();\n    this._debounced = debounce(() => this._update(), 0);\n    this.autoDispose(_baseView.cursor.rowIndex.subscribe(this._debounced));\n  }\n\n  private _update() {\n    if (this.isDisposed()) {\n      return;\n    }\n    const state = {\n      tableId: this._baseView.viewSection.table().tableId(),\n      rowId: this._baseView.cursor.getCursorPos().rowId || undefined,\n      dataChange: false,\n    };\n    this._notify(state);\n  }\n}\n\nexport interface ConfigNotifierOptions {\n  access: AccessLevel;\n}\n\n/**\n * Notifies about options changes. Exposed in the API as `onOptions`.\n */\nexport class ConfigNotifier extends BaseEventSource {\n  private _accessLevel = this._options.access;\n  private _currentConfig = Computed.create(this, (use) => {\n    const options = use(this._section.activeCustomOptions);\n    return options;\n  });\n\n  // Debounced call to let the view know linked cursor changed.\n  private _debounced = debounce((options?: { fromReady?: boolean }) => this._update(options), 0);\n\n  constructor(private _section: ViewSectionRec, private _options: ConfigNotifierOptions) {\n    super();\n    this.autoDispose(\n      this._currentConfig.addListener((newConfig, oldConfig) => {\n        if (isEqual(newConfig, oldConfig)) { return; }\n\n        this._debounced();\n      }),\n    );\n  }\n\n  protected _ready() {\n    // On ready, send initial configuration.\n    this._debounced({ fromReady: true });\n  }\n\n  private _update({ fromReady}: { fromReady?: boolean } = {}) {\n    if (this.isDisposed()) { return; }\n\n    this._notify({\n      options: this._currentConfig.get(),\n      settings: {\n        accessLevel: this._accessLevel,\n      },\n      fromReady,\n    });\n  }\n}\n\n/**\n * Notifies about theme changes. Exposed in the API as `onThemeChange`.\n */\nexport class ThemeNotifier extends BaseEventSource {\n  constructor() {\n    super();\n    this.autoDispose(\n      gristThemeObs().addListener((newTheme, oldTheme) => {\n        if (isEqual(newTheme, oldTheme)) { return; }\n\n        this._update();\n      }),\n    );\n  }\n\n  protected _ready() {\n    this._update({ fromReady: true });\n  }\n\n  private _update({ fromReady}: { fromReady?: boolean } = {}) {\n    if (this.isDisposed()) { return; }\n\n    this._notify({\n      theme: convertThemeKeysToCssVars(gristThemeObs().get()),\n      fromReady,\n    });\n  }\n}\n\n/**\n * Notifies about cursor table data or structure change.\n * Exposed in the API as a onRecords handler.\n * This Notifier sends an initial event when subscribed\n */\nexport class TableNotifier extends BaseEventSource {\n  private _debounced: () => void;\n  private _updateMapping = true;\n  constructor(private _baseView: BaseView) {\n    super();\n    this._debounced = debounce(() => this._update(), 0);\n    this.autoDispose(_baseView.viewSection.viewFields().subscribe(this._debounced.bind(this)));\n    this.listenTo(_baseView.sortedRows, \"rowNotify\", this._debounced.bind(this));\n    this.autoDispose(_baseView.sortedRows.getKoArray().subscribe(this._debounced.bind(this)));\n    this.autoDispose(_baseView.viewSection.mappedColumns\n      .subscribe(() => {\n        this._updateMapping = true;\n        this._debounced();\n      }),\n    );\n  }\n\n  protected _ready() {\n    // On ready, send initial table information.\n    this._debounced();\n  }\n\n  private _update() {\n    if (this.isDisposed()) {\n      return;\n    }\n    const state = {\n      tableId: this._baseView.viewSection.table().tableId(),\n      rowId: this._baseView.cursor.getCursorPos().rowId || undefined,\n      dataChange: true,\n      mappingsChange: this._updateMapping,\n    };\n    this._updateMapping = false;\n    this._notify(state);\n  }\n}\n\nexport class CustomSectionAPIImpl extends Disposable implements CustomSectionAPI {\n  constructor(\n    private _section: ViewSectionRec,\n    private _currentAccess: AccessLevel,\n    private _promptCallback: (access: AccessLevel) => void,\n  ) {\n    super();\n  }\n\n  public async mappings(): Promise<WidgetColumnMap | null> {\n    return this._section.mappedColumns.peek();\n  }\n\n  /**\n   * Method called as part of ready message. Allows widget to request for particular features or inform about\n   * capabilities.\n   */\n  public async configure(settings: InteractionOptionsRequest): Promise<void> {\n    if (settings.hasCustomOptions !== undefined) {\n      this._section.hasCustomOptions(settings.hasCustomOptions);\n    }\n    if (settings.requiredAccess && settings.requiredAccess !== this._currentAccess) {\n      this._promptCallback(settings.requiredAccess as AccessLevel);\n    }\n    if (settings.columns !== undefined) {\n      this._section.columnsToMap(settings.columns);\n    } else {\n      this._section.columnsToMap(null);\n    }\n    if (settings.allowSelectBy !== undefined) {\n      this._section.allowSelectBy(settings.allowSelectBy);\n    }\n  }\n}\n\nfunction getReencode(cellFormat: CellFormatType | undefined): typeof reencodeAsAny {\n  switch (cellFormat) {\n    case \"normal\": return identity;\n    case \"typed\": return reencodeAsTypedCellValue;\n    default: return reencodeAsAny;\n  }\n}\n\n/**\n * This function has been used by the Custom Widget API, but it has a defect in that it doesn't\n * re-encode values of type RefList or Attachments. But changing it may break existing widgets.\n *\n * For new code, it's recommended to use `cellFormat: \"typed\"` option, which uses the more\n * complete reencodeAsTypedCell() in gristTypes.ts. REST API now supports `?cellFormat=typed` too.\n *\n * Re-encodes a CellValue of a given Grist type as a value suitable to use in an Any column. E.g.\n *    reencodeAsAny(123, 'Numeric') -> 123\n *    reencodeAsAny(123, 'Date') -> ['d', 123]\n *    reencodeAsAny(123, 'Reference', 'Table1') -> ['R', 'Table1', 123]\n */\nfunction reencodeAsAny(value: CellValue, typeInfo: GristTypeInfo): CellValue {\n  if (typeof value === \"number\") {\n    switch (typeInfo.type) {\n      case \"Date\": return [GristObjCode.Date, value];\n      case \"DateTime\": return [GristObjCode.DateTime, value, typeInfo.timezone];\n      case \"Ref\": return [GristObjCode.Reference, typeInfo.tableId, value];\n    }\n  }\n  return value;\n}\n"
  },
  {
    "path": "app/client/components/buildViewSectionDom.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { filterBar } from \"app/client/ui/FilterBar\";\nimport { maybeShowNewRecordExperiment } from \"app/client/ui/NewRecordButton\";\nimport { cssIcon } from \"app/client/ui/RightPanelStyles\";\nimport { makeCollapsedLayoutMenu } from \"app/client/ui/ViewLayoutMenu\";\nimport { cssDotsIconWrapper, cssMenu, viewSectionMenu } from \"app/client/ui/ViewSectionMenu\";\nimport { buildWidgetTitle } from \"app/client/ui/WidgetTitle\";\nimport { getWidgetTypes } from \"app/client/ui/widgetTypesMap\";\nimport { isNarrowScreenObs, mediaSmall, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu } from \"app/client/ui2018/menus\";\nimport { undef } from \"app/common/gutil\";\n\nimport { Computed, dom, DomElementArg, Observable, styled } from \"grainjs\";\nimport { defaultMenuOptions } from \"popweasel\";\n\nconst t = makeT(\"ViewSection\");\n\nexport function buildCollapsedSectionDom(options: {\n  gristDoc: GristDoc,\n  sectionRowId: number | string,\n}, ...domArgs: DomElementArg[]) {\n  const { gristDoc, sectionRowId } = options;\n  if (typeof sectionRowId === \"string\") {\n    return cssMiniSection(\n      dom(\"span.viewsection_title_font\",\n        \"Empty\",\n      ),\n    );\n  }\n  const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);\n  const typeComputed = Computed.create(null, use => getWidgetTypes(use(vs.parentKey) as any).icon);\n  return cssMiniSection(\n    testId(`collapsed-section-${sectionRowId}`),\n    testId(`collapsed-section`),\n    cssDragHandle(\n      dom.domComputed(typeComputed, type => icon(type)),\n      dom(\"div\", { style: \"margin-right: 16px;\" }),\n      dom.maybe(use => use(use(vs.table).summarySourceTable), () => cssSigmaIcon(\"Pivot\", testId(\"sigma\"))),\n      dom(\"span.viewsection_title_font\", testId(\"collapsed-section-title\"),\n        dom.text(vs.titleDef),\n      ),\n    ),\n    cssMenu(\n      testId(\"section-menu-viewLayout\"),\n      cssDotsIconWrapper(cssIcon(\"Dots\")),\n      menu(_ctl => makeCollapsedLayoutMenu(vs, gristDoc), {\n        ...defaultMenuOptions,\n        placement: \"bottom-end\",\n      }),\n    ),\n    ...domArgs,\n  );\n}\n\nexport function buildViewSectionDom(options: {\n  gristDoc: GristDoc,\n  sectionRowId: number,\n  isResizing?: Observable<boolean>\n  viewModel?: ViewRec,\n  // Should show drag anchor.\n  draggable?: boolean, /* defaults to true */\n  // Should show green bar on the left (but preserves active-section class).\n  focusable?: boolean, /* defaults to true */\n  headerVisible?: boolean, /* defaults to true, if title and filters are visible at all */\n  tableNameHidden?: boolean,\n  widgetNameHidden?: boolean,\n  renamable?: boolean,\n  hideTitleControls?: boolean,\n}) {\n  const isResizing = options.isResizing ?? Observable.create(null, false);\n  const {\n    gristDoc,\n    sectionRowId,\n    viewModel,\n    draggable = true,\n    focusable = true,\n    hideTitleControls = false,\n    tableNameHidden,\n    widgetNameHidden,\n  } = options;\n\n  // Creating normal section dom\n  const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);\n  const renamable = undef(vs.canRename.peek(), options.renamable, true);\n\n  const selectedBySectionTitle = Computed.create(null, (use) => {\n    if (!use(vs.linkSrcSectionRef)) { return null; }\n    return use(use(vs.linkSrcSection).titleDef);\n  });\n  return dom(\"div.view_leaf.viewsection_content.flexvbox.flexauto\",\n    testId(`viewlayout-section-${sectionRowId}`),\n    dom.autoDispose(selectedBySectionTitle),\n    !options.isResizing ? dom.autoDispose(isResizing) : null,\n    cssViewLeaf.cls(\"\"),\n    cssViewLeafInactive.cls(\"\", use => !vs.isDisposed() && !use(vs.hasVisibleFocus)),\n    dom.cls(\"active_section\", vs.hasFocus),\n    dom.cls(\"active_section--no-focus\", use => !vs.isDisposed() && use(vs.hasFocus) && !use(vs.hasRegionFocus)),\n    dom.cls(\"active_section--no-indicator\", use => !focusable || (!vs.isDisposed() && !use(vs.hasVisibleFocus))),\n    dom.maybe<BaseView | null>(use => use(vs.viewInstance), viewInstance => dom(\"div.viewsection_title.flexhbox\",\n      cssDragIcon(\"DragDrop\",\n        dom.cls(\"viewsection_drag_indicator\"),\n        // Makes element grabbable only if grist is not readonly.\n        dom.cls(\"layout_grabbable\", use => !use(gristDoc.isReadonlyKo)),\n        !draggable ? dom.style(\"visibility\", \"hidden\") : null,\n      ),\n      dom.maybe(use => use(use(viewInstance.viewSection.table).summarySourceTable), () =>\n        cssSigmaIcon(\"Pivot\", testId(\"sigma\"))),\n      buildWidgetTitle(\n        vs,\n        { tableNameHidden, widgetNameHidden, disabled: !renamable },\n        testId(\"viewsection-title\"),\n        cssTestClick(testId(\"viewsection-blank\")),\n      ),\n      viewInstance.buildTitleControls(),\n      hideTitleControls === true ? null :\n        dom(\"div.viewsection_buttons\",\n          dom.create(viewSectionMenu, gristDoc, vs),\n        ),\n    )),\n    hideTitleControls === true ? null : dom.create(filterBar, gristDoc, vs),\n    dom.maybe<BaseView | null>(vs.viewInstance, viewInstance => [\n      dom(\"div.view_data_pane_container.flexvbox\",\n        cssResizing.cls(\"\", isResizing),\n        dom.maybe(viewInstance.disableEditing, () =>\n          dom(\"div.disable_viewpane.flexvbox\",\n            dom.domComputed(selectedBySectionTitle, title => title ?\n              t(`No row selected in {{title}}`, { title }) :\n              t(\"No data\")),\n          ),\n        ),\n        dom.maybe(viewInstance.isTruncated, () =>\n          dom(\"div.viewsection_truncated\", t(\"Not all data is shown\")),\n        ),\n        dom.cls(use => \"viewsection_type_\" + use(vs.parentKey)),\n        viewInstance.viewPane,\n        maybeShowNewRecordExperiment(viewInstance),\n      ),\n      dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()),\n    ]),\n    dom.on(\"mousedown\", () => { viewModel?.activeSectionId(sectionRowId); }),\n  );\n}\n\n// With new widgetPopup it is hard to click on viewSection without a activating it, hence we\n// add a little blank space to use in test.\nconst cssTestClick = styled(`div`, `\n  min-width: 2px;\n`);\n\nconst cssSigmaIcon = styled(icon, `\n  margin-right: 5px;\n  background-color: ${theme.lightText}\n`);\n\nconst cssViewLeaf = styled(\"div\", `\n  @media ${mediaSmall} {\n    & {\n      margin: 4px;\n    }\n  }\n`);\n\nconst cssViewLeafInactive = styled(\"div\", `\n  @media screen and ${mediaSmall} {\n    & {\n      overflow: hidden;\n      background: repeating-linear-gradient(\n        -45deg,\n        ${theme.widgetInactiveStripesDark},\n        ${theme.widgetInactiveStripesDark} 10px,\n        ${theme.widgetInactiveStripesLight} 10px,\n        ${theme.widgetInactiveStripesLight} 20px\n      );\n      border: 1px solid ${theme.widgetBorder};\n      border-radius: 4px;\n      padding: 0 2px;\n    }\n    &::after {\n      content: '';\n      position: absolute;\n      top: 0;\n      left: 0;\n      right: 0;\n      bottom: 0;\n    }\n    &.layout_vbox {\n      max-width: 32px;\n    }\n    &.layout_hbox {\n      max-height: 32px;\n    }\n    & > .viewsection_title.flexhbox {\n      position: absolute;\n    }\n    & > .view_data_pane_container,\n    & .viewsection_buttons,\n    & .grist-single-record__menu,\n    & > .filter_bar {\n      display: none;\n    }\n  }\n`);\n\n// z-index ensure it's above the resizer line, since it's hard to grab otherwise\nconst cssDragIcon = styled(icon, `\n  visibility: hidden;\n  --icon-color: ${theme.lightText};\n  z-index: 100;\n\n  .viewsection_title:hover &.layout_grabbable {\n    visibility: visible;\n  }\n`);\n\n// This class is added while sections are being resized (or otherwise edited), to ensure that the\n// content of the section (such as an iframe) doesn't interfere with mouse drag-related events.\n// (It assumes that contained elements do not set pointer-events to another value; if that were\n// important then we'd need to use an overlay element during dragging.)\nconst cssResizing = styled(\"div\", `\n  pointer-events: none;\n`);\n\nconst cssMiniSection = styled(\"div.mini_section_container\", `\n  --icon-color: ${theme.accentIcon};\n  display: flex;\n  align-items: center;\n  padding-right: 8px;\n`);\n\nconst cssDragHandle = styled(\"div.draggable-handle\", `\n  display: flex;\n  padding: 8px;\n  flex: 1;\n  padding-right: 16px;\n`);\n"
  },
  {
    "path": "app/client/components/commandList.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\n\nconst t = makeT(\"commandList\");\n\nexport type CommandName =\n  | \"accessibility\" |\n  \"shortcuts\" |\n  \"help\" |\n  \"undo\" |\n  \"redo\" |\n  \"accept\" |\n  \"cancel\" |\n  \"find\" |\n  \"findNext\" |\n  \"findPrev\" |\n  \"closeSearchBar\" |\n  \"historyBack\" |\n  \"historyForward\" |\n  \"reloadPlugins\" |\n  \"closeActiveMenu\" |\n  \"docTabOpen\" |\n  \"viewTabOpen\" |\n  \"viewTabFocus\" |\n  \"fieldTabOpen\" |\n  \"sortFilterTabOpen\" |\n  \"sortFilterMenuOpen\" |\n  \"dataSelectionTabOpen\" |\n  \"printSection\" |\n  \"showRawData\" |\n  \"openWidgetConfiguration\" |\n  \"expandSection\" |\n  \"leftPanelOpen\" |\n  \"rightPanelOpen\" |\n  \"cursorDown\" |\n  \"cursorUp\" |\n  \"cursorRight\" |\n  \"cursorLeft\" |\n  \"nextField\" |\n  \"prevField\" |\n  \"pageDown\" |\n  \"pageUp\" |\n  \"moveToFirstRecord\" |\n  \"moveToLastRecord\" |\n  \"moveToFirstField\" |\n  \"moveToLastField\" |\n  \"skipDown\" |\n  \"skipUp\" |\n  \"setCursor\" |\n  \"openDocumentList\" |\n  \"nextPage\" |\n  \"prevPage\" |\n  \"nextRegion\" |\n  \"prevRegion\" |\n  \"creatorPanel\" |\n  \"shiftDown\" |\n  \"shiftUp\" |\n  \"shiftRight\" |\n  \"shiftLeft\" |\n  \"ctrlShiftDown\" |\n  \"ctrlShiftUp\" |\n  \"ctrlShiftRight\" |\n  \"ctrlShiftLeft\" |\n  \"selectAll\" |\n  \"copyLink\" |\n  \"editField\" |\n  \"fieldEditSave\" |\n  \"fieldEditSaveHere\" |\n  \"fieldEditCancel\" |\n  \"copy\" |\n  \"cut\" |\n  \"paste\" |\n  \"contextMenuCopy\" |\n  \"contextMenuCopyWithHeaders\" |\n  \"contextMenuCut\" |\n  \"contextMenuPaste\" |\n  \"fillSelectionDown\" |\n  \"clearValues\" |\n  \"input\" |\n  \"editLabel\" |\n  \"editLayout\" |\n  \"historyPrevious\" |\n  \"toggleCheckbox\" |\n  \"historyNext\" |\n  \"makeFormula\" |\n  \"unmakeFormula\" |\n  \"insertCurrentDate\" |\n  \"insertCurrentDateTime\" |\n  \"datepickerFocus\" |\n  \"openDiscussion\" |\n  \"insertRecordBefore\" |\n  \"insertRecordAfter\" |\n  \"deleteRecords\" |\n  \"insertFieldBefore\" |\n  \"insertFieldAfter\" |\n  \"makeHeadersFromRow\" |\n  \"renameField\" |\n  \"hideFields\" |\n  \"hideCardFields\" |\n  \"toggleFreeze\" |\n  \"deleteFields\" |\n  \"clearColumns\" |\n  \"clearCardFields\" |\n  \"convertFormulasToData\" |\n  \"addSection\" |\n  \"deleteSection\" |\n  \"duplicateSection\" |\n  \"collapseSection\" |\n  \"restoreSection\" |\n  \"deleteCollapsedSection\" |\n  \"duplicateRows\" |\n  \"sortAsc\" |\n  \"sortDesc\" |\n  \"showPopup\" |\n  \"addSortAsc\" |\n  \"addSortDesc\" |\n  \"filterByThisCellValue\" |\n  \"enterLinkMode\" |\n  \"exitLinkMode\" |\n  \"saveLinks\" |\n  \"revertLinks\" |\n  \"clearLinks\" |\n  \"clearSectionLinks\" |\n  \"transformUpdate\" |\n  \"clearCopySelection\" |\n  \"detachEditor\" |\n  \"activateAssistant\" |\n  \"viewAsCard\" |\n  \"showColumns\" |\n  \"createForm\" |\n  \"insertField\" |\n  \"pushUndoAction\"\n  ;\n\nexport interface CommandDef {\n  name: CommandName;\n  keys: string[];\n  desc: (() => string) | null;\n  bindKeys?: boolean;\n  /**\n   * When true, the command is always enabled, even in form inputs.\n   */\n  alwaysOn?: boolean;\n  deprecated?: boolean;\n}\n\nexport interface MenuCommand {\n  humanKeys: string[];\n  run: (...args: any[]) => any;\n}\n\nexport interface CommendGroupDef {\n  group: string;\n  commands: CommandDef[];\n}\n\n// The top-level groups, and the ordering within them are for user-facing documentation.\nexport const groups: CommendGroupDef[] = [{\n  group: \"General\",\n  commands: [\n    {\n      name: \"accessibility\",\n      keys: [\"F4\"],\n      desc: () => t(\"Show accessibility options\"),\n    },\n    {\n      name: \"shortcuts\",\n      keys: [\"F1\", \"Mod+/\"],\n      desc: () => t(\"Display shortcuts pane\"),\n    }, {\n      name: \"help\",\n      keys: [],\n      desc: () => t(\"Display Grist documentation\"),\n    }, {\n      name: \"undo\",\n      keys: [\"Mod+z\"],\n      desc: () => t(\"Undo last action\"),\n    }, {\n      name: \"redo\",\n      keys: [\"Mod+Shift+Z\", \"Ctrl+y\"],\n      desc: () => t(\"Redo last action\"),\n    }, {\n      name: \"accept\",\n      keys: [\"Enter\"],\n      desc: null, // Accept the action of the dialog box\n    }, {\n      name: \"cancel\",\n      keys: [\"Escape\"],\n      desc: null, // Cancel the action of the dialog box\n    }, {\n      name: \"find\",\n      keys: [\"Mod+f\"],\n      desc: () => t(\"Find\"),\n    }, {\n      name: \"findNext\",\n      keys: [\"Mod+g\"],\n      desc: () => t(\"Find next occurrence\"),\n    }, {\n      name: \"findPrev\",\n      keys: [\"Mod+Shift+G\"],\n      desc: () => t(\"Find previous occurrence\"),\n    }, {\n      name: \"closeSearchBar\",\n      keys: [\"Mod+Enter\"],\n      desc: () => t(\"When in the search bar, close it and focus the current match\"),\n      alwaysOn: true,\n    }, {\n      // Without this, when focus in on Clipboard, this shortcut would only move the cursor.\n      name: \"historyBack\",\n      keys: [\"Mod+Left\"],\n      desc: null, // Move back in history, same as clicking the Back button\n    }, {\n      // Without this, when focus in on Clipboard, this shortcut would only move the cursor.\n      name: \"historyForward\",\n      keys: [\"Mod+Right\"],\n      desc: null, // Move forward in history, same as clicking the Forward button\n    }, {\n      name: \"reloadPlugins\",\n      keys: [\"Mod+Alt+P\"],\n      desc: null, // reload plugins\n    },\n\n  ],\n}, {\n  group: \"Menu shortcuts\",\n  commands: [\n    {\n      name: \"closeActiveMenu\",\n      keys: [\"Esc\"],\n      desc: null, // Shortcut to close active menu\n    },\n    {\n      name: \"docTabOpen\",\n      keys: [],\n      desc: () => t(\"Shortcut to open document tab\"),\n    },\n    {\n      name: \"viewTabOpen\",\n      keys: [],\n      desc: () => t(\"Shortcut to open view tab\"),\n    },\n    {\n      name: \"viewTabFocus\",\n      keys: [],\n      desc: () => t(\"Shortcut to focus view tab if creator panel is open\"),\n    },\n    {\n      name: \"fieldTabOpen\",\n      keys: [],\n      desc: () => t(\"Shortcut to open field tab\"),\n    },\n    {\n      name: \"sortFilterTabOpen\",\n      keys: [],\n      desc: () => t(\"Shortcut to sort & filter tab\"),\n    },\n    {\n      name: \"sortFilterMenuOpen\",\n      keys: [],\n      desc: () => t(\"Shortcut to open sort & filter menu\"),\n    },\n    {\n      name: \"dataSelectionTabOpen\",\n      keys: [],\n      desc: () => t(\"Shortcut to data selection tab\"),\n    },\n    {\n      name: \"printSection\",\n      keys: [],\n      desc: () => t(\"Print currently selected page widget\"),\n    },\n    {\n      name: \"showRawData\",\n      keys: [],\n      desc: () => t(\"Show raw data widget for table of currently selected page widget\"),\n    },\n    {\n      name: \"openWidgetConfiguration\",\n      keys: [],\n      desc: () => t(\"Open Custom widget configuration screen\"),\n    },\n    {\n      name: \"expandSection\",\n      keys: [],\n      desc: () => t(\"Maximize the active section\"),\n    },\n    {\n      name: \"leftPanelOpen\",\n      keys: [],\n      desc: () => t(\"Shortcut to open the left panel\"),\n    },\n    {\n      name: \"rightPanelOpen\",\n      keys: [],\n      desc: () => t(\"Shortcut to open the right panel\"),\n    },\n    {\n      name: \"activateAssistant\",\n      keys: [],\n      desc: () => t(\"Activate assistant\"),\n    },\n    {\n      name: \"showPopup\",\n      keys: [],\n      desc: () => t(\"showing a behavioral popup\"),\n    },\n    {\n      name: \"createForm\",\n      keys: [],\n      desc: () => t(\"Creates form for active table\"),\n    },\n    {\n      name: \"insertField\",\n      keys: [],\n      desc: () => t(\"Insert new column in default location\"),\n    },\n  ],\n}, {\n  group: \"Navigation\",\n  commands: [\n    {\n      name: \"cursorDown\",\n      keys: [\"Down\"],\n      desc: () => t(\"Move downward to next record or field\"),\n    }, {\n      name: \"cursorUp\",\n      keys: [\"Up\"],\n      desc: () => t(\"Move upward to previous record or field\"),\n    }, {\n      name: \"cursorRight\",\n      keys: [\"Right\"],\n      desc: () => t(\"Move right to the next field\"),\n    }, {\n      name: \"cursorLeft\",\n      keys: [\"Left\"],\n      desc: () => t(\"Move left to the previous field\"),\n    }, {\n      name: \"nextField\",\n      keys: [\"Tab\"],\n      desc: () => t(\"Move to the next field, saving changes if editing a value\"),\n    }, {\n      name: \"prevField\",\n      keys: [\"Shift+Tab\"],\n      desc: () => t(\"Move to the previous field, saving changes if editing a value\"),\n    }, {\n      name: \"pageDown\",\n      keys: [\"PageDown\"],\n      desc: () => t(\"Move down one page of records, or to next record in a card list\"),\n    }, {\n      name: \"pageUp\",\n      keys: [\"PageUp\"],\n      desc: () => t(\"Move up one page of records, or to previous record in a card list\"),\n    }, {\n      name: \"moveToFirstRecord\",\n      keys: [\"Mod+Up\"],\n      desc: () => t(\"Move up to the first record\"),\n    }, {\n      name: \"moveToLastRecord\",\n      keys: [\"Mod+Down\"],\n      desc: () => t(\"Move down to the last record\"),\n    }, {\n      name: \"moveToFirstField\",\n      keys: [\"Home\"],\n      desc: () => t(\"Move to the first field or the beginning of a row\"),\n    }, {\n      name: \"moveToLastField\",\n      keys: [\"End\"],\n      desc: () => t(\"Move to the last field or the end of a row\"),\n    }, {\n      // no longer used\n      name: \"skipDown\",\n      keys: [],\n      desc: () => t(\"Move downward five records\"),\n    }, {\n      // no longer used\n      name: \"skipUp\",\n      keys: [],\n      desc: () => t(\"Move upward five records\"),\n    }, {\n      name: \"setCursor\",\n      keys: [],\n      desc: () => t(\"Moves the cursor to the correct location\"),\n    }, {\n      name: \"openDocumentList\",\n      keys: [],\n      desc: () => t(\"Opens document list\"),\n    }, {\n      name: \"nextPage\",\n      keys: [\"Alt+Down\"],\n      desc: () => t(\"Open next page\"),\n    }, {\n      name: \"prevPage\",\n      keys: [\"Alt+Up\"],\n      desc: () => t(\"Open previous page\"),\n    }, {\n      name: \"nextRegion\",\n      keys: [\"Mod+o\"],\n      desc: () => t(\"Focus next page panel or widget\"),\n      alwaysOn: true,\n    }, {\n      name: \"prevRegion\",\n      keys: [\"Mod+Shift+O\"],\n      desc: () => t(\"Focus previous page panel or widget\"),\n      alwaysOn: true,\n    }, {\n      name: \"creatorPanel\",\n      keys: [\"Mod+Alt+o\"],\n      desc: () => t(\"Toggle creator panel keyboard focus\"),\n      alwaysOn: true,\n    }, {\n      name: \"viewAsCard\",\n      keys: [\"Space\"],\n      desc: () => t(\"Show the record card widget of the selected record\"),\n    },\n  ],\n}, {\n  group: \"Selection\",\n  commands: [\n    {\n      name: \"shiftDown\",\n      keys: [\"Shift+Down\"],\n      desc: () => t(\"Adds the element below the cursor to the selected range\"),\n    }, {\n      name: \"shiftUp\",\n      keys: [\"Shift+Up\"],\n      desc: () => t(\"Adds the element above the cursor to the selected range\"),\n    }, {\n      name: \"shiftRight\",\n      keys: [\"Shift+Right\"],\n      desc: () => t(\"Adds the element to the right of the cursor to the selected range\"),\n    }, {\n      name: \"shiftLeft\",\n      keys: [\"Shift+Left\"],\n      desc: () => t(\"Adds the element to the left of the cursor to the selected range\"),\n    }, {\n      name: \"ctrlShiftDown\",\n      keys: [\"Mod+Shift+Down\"],\n      desc: () => t(\"Adds all elements below the cursor to the selected range\"),\n    }, {\n      name: \"ctrlShiftUp\",\n      keys: [\"Mod+Shift+Up\"],\n      desc: () => t(\"Adds all elements above the cursor to the selected range\"),\n    }, {\n      name: \"ctrlShiftRight\",\n      keys: [\"Mod+Shift+Right\"],\n      desc: () => t(\"Adds all elements to the right of the cursor to the selected range\"),\n    }, {\n      name: \"ctrlShiftLeft\",\n      keys: [\"Mod+Shift+Left\"],\n      desc: () => t(\"Adds all elements to the left of the cursor to the selected range\"),\n    }, {\n      name: \"selectAll\",\n      keys: [\"Mod+A\"],\n      desc: () => t(\"Selects all currently displayed cells\"),\n    }, {\n      name: \"copyLink\",\n      keys: [\"Mod+Shift+A\"],\n      desc: () => t(\"Copy anchor link\"),\n    }, {\n      name: \"clearCopySelection\",\n      keys: [],\n      desc: () => t(\"Clears the current copy selection, if any\"),\n    },\n  ],\n}, {\n  group: \"Editing\",\n  commands: [\n    {\n      name: \"editField\",\n      keys: [\"Enter\", \"F2\"],\n      desc: () => t(\"Start editing the currently-selected cell\"),\n    }, {\n      name: \"fieldEditSave\",\n      keys: [\"Enter\"],\n      desc: () => t(\"Finish editing a cell, saving the value\"),\n    }, {\n      // This only gets its own command so it can be listed as separate keyboard shortcut.\n      name: \"toggleCheckbox\",\n      keys: [\"Enter\"],\n      desc: () => t(\"Toggle the currently selected checkbox or switch cell\"),\n    }, {\n      name: \"detachEditor\",\n      keys: [],\n      desc: () => t(\"Detach active editor\"),\n    }, {\n      name: \"fieldEditSaveHere\",\n      keys: [],\n      desc: () => t(\"Finish editing a cell and save without moving to next record\"),\n    }, {\n      name: \"fieldEditCancel\",\n      keys: [\"Escape\"],\n      desc: () => t(\"Discard changes to a cell value\"),\n    }, {\n      name: \"copy\",\n      keys: [],\n      desc: () => t(\"Copy current selection to clipboard\"),\n    }, {\n      name: \"cut\",\n      keys: [],\n      desc: () => t(\"Cut current selection to clipboard\"),\n    }, {\n      name: \"paste\",\n      keys: [],\n      desc: () => t(\"Paste clipboard contents at cursor\"),\n    }, {\n      name: \"contextMenuCopy\",\n      keys: [\"Mod+C\"],\n      desc: () => t(\"Copy current selection to clipboard\"),\n      bindKeys: false,\n    }, {\n      name: \"contextMenuCopyWithHeaders\",\n      keys: [],\n      desc: () => t(\"Copy current selection to clipboard including headers\"),\n    }, {\n      name: \"contextMenuCut\",\n      keys: [\"Mod+X\"],\n      desc: () => t(\"Cut current selection to clipboard\"),\n      bindKeys: false,\n    }, {\n      name: \"contextMenuPaste\",\n      keys: [\"Mod+V\"],\n      desc: () => t(\"Paste clipboard contents at cursor\"),\n      bindKeys: false,\n    }, {\n      name: \"fillSelectionDown\",\n      keys: [\"Mod+D\"],\n      desc: () => t(\"Fills current selection with the contents of the top row in the selection\"),\n    }, {\n      name: \"clearValues\",\n      keys: [\"Backspace\", \"Del\"],\n      desc: () => t(\"Clears the currently selected cells\"),\n    }, {\n      name: \"input\",\n      keys: [],\n      desc: () => t(\"Enter text into currently-selected cell and start editing\"),\n    }, {\n      name: \"editLabel\",\n      keys: [],\n      desc: () => t(\"Edit label of the currently-selected field\"),\n    }, {\n      name: \"editLayout\",\n      keys: [],\n      desc: () => t(\"Edit record layout\"),\n    }, {\n      name: \"historyPrevious\",\n      keys: [\"Up\"],\n      desc: null, // Fetches the previous command from the history list, moving back in the list\n    }, {\n      name: \"historyNext\",\n      keys: [\"Down\"],\n      desc: null, // Fetches the next command from the history list, moving forward in the list\n    }, {\n      name: \"makeFormula\",\n      keys: [\"=\"],\n      desc: () => t(\"When typed at the start of a cell, make this a formula column\"),\n    }, {\n      name: \"unmakeFormula\",\n      keys: [\"Backspace\"],\n      desc: null, // Undoes turning of column into a formula column, when pressed at start of a cell\n    }, {\n      name: \"insertCurrentDate\",\n      keys: [\"Mod+;\"],\n      desc: () => t(\"Insert the current date\"),\n    }, {\n      name: \"insertCurrentDateTime\",\n      keys: [\"Mod+Shift+;\"],\n      desc: () => t(\"Insert the current date and time\"),\n    }, {\n      name: \"datepickerFocus\",\n      keys: [\"Up\", \"Down\"],\n      desc: null, // While editing a date cell, switch keyboard focus to the datepicker\n    }, {\n      name: \"openDiscussion\",\n      keys: [\"Mod+Alt+M\"],\n      desc: () => t(\"Open comment thread\"),\n    },\n  ],\n}, {\n  group: \"Data manipulation\",\n  commands: [\n    {\n      name: \"insertRecordBefore\",\n      keys: [\"Mod+Shift+Enter\"],\n      desc: () => t(\"Insert a new record, before the currently selected one in an unsorted table\"),\n    }, {\n      name: \"insertRecordAfter\",\n      keys: [\"Mod+Enter\"],\n      desc: () => t(\"Insert a new record, after the currently selected one in an unsorted table\"),\n    }, {\n      name: \"duplicateRows\",\n      keys: [\"Mod+Shift+d\"],\n      desc: () => t(\"Duplicate the currently selected record(s)\"),\n    }, {\n      name: \"deleteRecords\",\n      keys: [\"Mod+Backspace\", \"Mod+Del\"],\n      desc: () => t(\"Delete the currently selected record(s)\"),\n    }, {\n      name: \"insertFieldBefore\",\n      keys: [\"Alt+Shift+=\"],\n      desc: () => t(\"Insert a new column, before the currently selected one\"),\n    }, {\n      name: \"insertFieldAfter\",\n      keys: [\"Alt+=\"],\n      desc: () => t(\"Insert a new column, after the currently selected one\"),\n    }, {\n      name: \"makeHeadersFromRow\",\n      keys: [],\n      desc: () => t(\"Use the currently selected row as table headers\"),\n    }, {\n      name: \"renameField\",\n      keys: [\"Ctrl+m\"],\n      desc: () => t(\"Rename the currently selected column\"),\n    }, {\n      name: \"hideFields\",\n      keys: [\"Alt+Shift+-\"],\n      desc: () => t(\"Hide the currently selected columns\"),\n    }, {\n      name: \"hideCardFields\",\n      keys: [],\n      desc: () => t(\"Hide the currently selected fields\"),\n    }, {\n      name: \"toggleFreeze\",\n      keys: [],\n      desc: () => t(\"Freeze or unfreeze selected columns\"),\n    }, {\n      name: \"deleteFields\",\n      keys: [\"Alt+-\"],\n      desc: () => t(\"Delete the currently selected columns\"),\n    }, {\n      name: \"clearColumns\",\n      keys: [],\n      desc: () => t(\"Clear the selected columns\"),\n    }, {\n      name: \"convertFormulasToData\",\n      keys: [],\n      desc: () => t(\"Convert the selected columns from formula to data\"),\n    }, {\n      name: \"addSection\",\n      keys: [],\n      desc: () => t(\"Add a new viewsection to the currently active view\"),\n    }, {\n      name: \"deleteSection\",\n      keys: [],\n      desc: () => t(\"Delete the currently active viewsection\"),\n    }, {\n      name: \"duplicateSection\",\n      keys: [],\n      desc: () => t(\"Duplicate the currently active viewsection\"),\n    }, {\n      name: \"collapseSection\",\n      keys: [],\n      desc: () => t(\"Collapse the currently active viewsection\"),\n    }, {\n      name: \"restoreSection\",\n      keys: [],\n      desc: () => t(\"Expand collapsed viewsection\"),\n    }, {\n      name: \"deleteCollapsedSection\",\n      keys: [],\n      desc: () => t(\"Delete collapsed viewsection\"),\n    }, {\n      name: \"showColumns\",\n      keys: [],\n      desc: () => t(\"Show hidden columns\"),\n    }, {\n      name: \"pushUndoAction\",\n      keys: [],\n      desc: () => t(\"Push an undo action\"),\n    },\n  ],\n}, {\n  group: \"Sorting\",\n  commands: [\n    {\n      name: \"sortAsc\",\n      keys: [],\n      desc: () => t(\"Sort the view data by the currently selected field in ascending order\"),\n    }, {\n      name: \"sortDesc\",\n      keys: [],\n      desc: () => t(\"Sort the view data by the currently selected field in descending order\"),\n    }, {\n      name: \"addSortAsc\",\n      keys: [],\n      desc: () => t(\"Adds the currently selected column(ascending) to the current view's sort spec\"),\n    }, {\n      name: \"addSortDesc\",\n      keys: [],\n      desc: () => t(\"Adds the currently selected column(descending) to the current view's sort spec\"),\n    },\n\n  ],\n}, {\n  group: \"Filtering\",\n  commands: [\n    {\n      name: \"filterByThisCellValue\",\n      keys: [],\n      desc: () => t(\"Filter this column by just this cell's value\"),\n    },\n  ],\n}, {\n  group: \"Linking\",\n  commands: [\n    {\n      name: \"enterLinkMode\",\n      keys: [],\n      desc: () => t(\"Enters section linking mode in the current view\"),\n    }, {\n      name: \"exitLinkMode\",\n      keys: [],\n      desc: () => t(\"Exits section linking mode in the current view\"),\n    }, {\n      name: \"saveLinks\",\n      keys: [],\n      desc: () => t(\"Saves the sections links in the current view\"),\n    }, {\n      name: \"revertLinks\",\n      keys: [],\n      desc: () => t(\"Reverts the sections links to the saved links the current view\"),\n    }, {\n      name: \"clearLinks\",\n      keys: [],\n      desc: () => t(\"Clears the section links in the current view\"),\n    }, {\n      name: \"clearSectionLinks\",\n      keys: [],\n      desc: () => t(\"Clears the section links in the current viewsection\"),\n    },\n  ],\n}, {\n  group: \"Transforming\",\n  commands: [\n    {\n      // TODO: Use AceEditor internal save command instead of custom transform save command\n      name: \"transformUpdate\",\n      keys: [\"Shift+Enter\"],\n      desc: null, // Updates the transform formula\n    },\n  ],\n}];\n"
  },
  {
    "path": "app/client/components/commands.css",
    "content": ".shortcut_keys {\n  display: inline-block;\n}\n\n.context-menu-item .shortcut_keys {\n  font-size: 1.2rem;\n}\n\n.shortcut_key_image {\n  display: inline-block;\n  border-left: 2px solid #eee;\n  border-top: 2px solid #eee;\n  border-right: 2px solid #aaa;\n  border-bottom: 2px solid #aaa;\n  box-shadow: inset 0px 0px 0px 1px #fff, inset 3px 1px 0.5rem 2px #eee;\n  border-radius: 3px;\n  margin: 1px 0.2rem;\n  padding: 1px 6px;\n  font-size: 0.9em;\n  color: #666;\n  background-color: white;\n}\n\n.shortcut_key_image.pressed {\n  border-left: 2px solid #aba;\n  border-top: 2px solid #aba;\n  border-right: 2px solid #efe;\n  border-bottom: 2px solid #efe;\n  box-shadow: inset 0px 0px 0px 1px #efe, inset 3px 1px 0.5rem 2px #efe;\n}\n\n.shortcut_key_image.highlight {\n  border-left: 2px solid #cfc;\n  border-top: 2px solid #cfc;\n  border-right: 2px solid #8b8;\n  border-bottom: 2px solid #8b8;\n  box-shadow: inset 0px 0px 0px 1px #bfb, inset 3px 1px 0.5rem 2px #bfb;\n}\n\n.g-help .shortcut_key_image {\n  display: inline-block;\n  border-left: 2px solid #777;\n  border-top: 2px solid #777;\n  border-right: 2px solid #444;\n  border-bottom: 2px solid #444;\n  box-shadow: inset 0px 0px 0px 1px #555, inset -3px -1px 0.5rem 2px #777;\n  border-radius: 3px;\n  margin: 1px 0.2rem;\n  padding: 1px 6px;\n  font-size: 0.9em;\n  color: #cf0;\n  background-color: #555;\n}\n"
  },
  {
    "path": "app/client/components/commands.ts",
    "content": "/**\n * Commands are invoked by the user via keyboard shortcuts or mouse clicks, for example, to move\n * the cursor or to delete the selected records.\n *\n * This module provides APIs for other components to implement groups of commands. Any given\n * command may be implemented by different components, but at most one implementation of any\n * command is active at any time.\n */\n\nimport { CommandDef, CommandName, CommendGroupDef, groups } from \"app/client/components/commandList\";\nimport { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport dom from \"app/client/lib/dom\";\nimport * as Mousetrap from \"app/client/lib/Mousetrap\";\nimport { arrayRemove, unwrap } from \"app/common/gutil\";\n\nimport { Disposable, Observable } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport * as _ from \"underscore\";\n\nconst G = getBrowserGlobals(\"window\");\ntype BoolLike = boolean | ko.Observable<boolean> | ko.Computed<boolean> | Observable<boolean>;\n\n/**\n * A helper method that can create a subscription to ko or grains observables.\n */\nfunction subscribe(value: Exclude<BoolLike, boolean>, fn: (value: boolean) => void) {\n  if (ko.isObservable(value)) {\n    return value.subscribe(fn);\n  } else if (value instanceof Observable) {\n    return value.addListener(fn);\n  } else {\n    throw new Error(\"Expected an observable\");\n  }\n}\n\n// Same logic as used by mousetrap to map 'Mod' key to platform-specific key.\nexport const isMac = (typeof navigator !== \"undefined\" && navigator &&\n  /Mac|iPod|iPhone|iPad/.test(navigator.platform));\n\n/**\n * Globally-exposed map of command names to Command objects. E.g. typing \"cmd.cursorDown.run()\" in\n * the browser console should move the cursor down as long as it makes sense in the currently\n * shown view. If the command is inactive, its run() function is a no-op.\n *\n * See also Command object below.\n */\nexport const allCommands: { [key in CommandName]: Command } = {} as any;\n\n/**\n * This is an internal variable, mapping key combinations to the stack of CommandGroups which\n * include them (see also CommandGroup.knownKeys). It's used for deciding which CommandGroup to\n * use when different Commands use the same key.\n */\nconst _allKeys: Record<string, CommandGroup[]> = {};\n\n/**\n * Populate allCommands from those provided, or listed in commandList.js. Also populates the\n * globally exposed `cmd` object whose properties invoke commands: e.g. typing `cmd.cursorDown` in\n * the browser console will run allCommands.cursorDown.run().\n */\nexport function init(optCommandGroups?: CommendGroupDef[]) {\n  const commandGroups = optCommandGroups || groups;\n\n  // Clear out the objects holding the global state.\n  Object.keys(allCommands).forEach(function(c) {\n    delete allCommands[c as CommandName];\n  });\n  Object.keys(_allKeys).forEach(function(k) {\n    delete _allKeys[k as CommandName];\n  });\n\n  commandGroups.forEach(function(commandGroup) {\n    commandGroup.commands.forEach(function(c) {\n      if (allCommands[c.name]) {\n        console.error(\"Ignoring duplicate command %s in commandList\", c.name);\n      } else {\n        allCommands[c.name] = new Command(c.name, c.desc, c.keys, {\n          bindKeys: c.bindKeys,\n          alwaysOn: c.alwaysOn,\n          deprecated: c.deprecated,\n        });\n      }\n    });\n  });\n\n  // Define the browser console interface.\n  G.window.cmd = {};\n  _.each(allCommands, function(cmd, name) {\n    Object.defineProperty(G.window.cmd, name, { get: cmd.run });\n  });\n}\n\n// ----------------------------------------------------------------------\n\nconst KEY_MAP_MAC = {\n  Mod: \"⌘\",\n  Alt: \"⌥\",\n  Shift: \"⇧\",\n  Ctrl: \"⌃\",\n  Left: \"←\",\n  Right: \"→\",\n  Up: \"↑\",\n  Down: \"↓\",\n};\n\nconst KEY_MAP_WIN = {\n  Mod: \"Ctrl\",\n  Left: \"←\",\n  Right: \"→\",\n  Up: \"↑\",\n  Down: \"↓\",\n};\n\nexport function getHumanKey(key: string, mac: boolean): string {\n  const keyMap = mac ? KEY_MAP_MAC : KEY_MAP_WIN;\n  let keys = key.split(\"+\").map(s => s.trim());\n  keys = keys.map((k) => {\n    if (k in keyMap) { return (keyMap as any)[k]; }\n    if (k.length === 1) { return k.toUpperCase(); }\n    return k;\n  });\n  return keys.join(mac ? \"\" : \" + \");\n}\n\nexport interface CommandOptions {\n  bindKeys?: boolean;\n  deprecated?: boolean;\n  /**\n   * When true, the command is always enabled, even in form inputs.\n   */\n  alwaysOn?: boolean;\n}\n\n/**\n * Command represents a single command. It is exposed via the `allCommands` map.\n * @property {String} name: The name of the command, same as the key into the `allCommands` map.\n * @property {String} desc: The description of the command.\n * @property {Array}  keys: The array of keyboard shortcuts for the command.\n * @property {Function} run: A bound function that will run the currently active implementation.\n * @property {Observable} isActive: Knockout observable for whether this command is active.\n */\nexport class Command implements CommandDef {\n  public name: CommandName;\n  public desc: (() => string) | null;\n  public humanKeys: string[];\n  public keys: string[];\n  public bindKeys: boolean;\n  public alwaysOn: boolean;\n  public isActive: ko.Observable<boolean>;\n  public deprecated: boolean;\n  public run: (...args: any[]) => any;\n  private _implGroupStack: CommandGroup[] = [];\n  private _activeFunc: (...args: any[]) => any = _.noop;\n\n  constructor(name: CommandName, desc: (() => string) | null, keys: string[], options: CommandOptions = {}) {\n    this.name = name;\n    this.desc = desc;\n    this.humanKeys = keys.map(key => getHumanKey(key, isMac));\n    this.keys = keys.map(function(k) { return k.trim().toLowerCase().replace(/ *\\+ */g, \"+\"); });\n    this.bindKeys = options.bindKeys ?? true;\n    this.alwaysOn = options.alwaysOn ?? false;\n    this.isActive = ko.observable(false);\n    this._implGroupStack = [];\n    this._activeFunc = _.noop; // The function to run when this command is invoked.\n    this.deprecated = options.deprecated || false;\n    // Let .run bind the Command object, so that it can be used as a stand-alone callback.\n    this.run = this._run.bind(this);\n  }\n\n  /**\n   * Returns a comma-separated string of all keyboard shortcuts, or `null` if no\n   * shortcuts exist.\n   */\n  public getKeysDesc() {\n    if (this.humanKeys.length === 0) { return null; }\n\n    return `(${this.humanKeys.join(\", \")})`;\n  }\n\n  /**\n   * Returns the text description for the command, including the keyboard shortcuts.\n   */\n  public getDesc() {\n    const parts = [this.desc?.() ?? \"\"];\n    const keysDesc = this.getKeysDesc();\n    if (keysDesc) { parts.push(keysDesc); }\n\n    return parts.join(\" \");\n  }\n\n  /**\n   * Returns DOM for the keyboard shortcuts, wrapped in cute boxes that look like keyboard keys.\n   */\n  public getKeysDom(separator?: ko.Observable<string>) {\n    return dom(\"span.shortcut_keys\",\n      separator ? this.humanKeys.map((key, i) => [i ? separator() : null, dom(\"span.shortcut_key_image\", key)]) :\n        this.humanKeys.map(key => dom(\"span.shortcut_key_image\", key)),\n    );\n  }\n\n  /**\n   * Adds a CommandGroup that implements this Command to the top of the stack of groups.\n   */\n  public addGroup(cmdGroup: CommandGroup) {\n    this._implGroupStack.push(cmdGroup);\n    this._updateActive();\n  }\n\n  /**\n   * Removes a CommandGroup from the stack of groups implementing this Command.\n   */\n  public removeGroup(cmdGroup: CommandGroup) {\n    arrayRemove(this._implGroupStack, cmdGroup);\n    this._updateActive();\n  }\n\n  /**\n   * Updates the command's state to reflect the currently active group, if any.\n   */\n  private _updateActive() {\n    if (this._implGroupStack.length > 0) {\n      this.isActive(true);\n      this._activeFunc = _.last(this._implGroupStack)!.commands[this.name];\n    } else {\n      this.isActive(false);\n      this._activeFunc = _.noop;\n    }\n\n    if (this.bindKeys) {\n      // Now bind or unbind the affected key combinations.\n      this.keys.forEach((key) => {\n        const keyGroups = _allKeys[key];\n        if (keyGroups && keyGroups.length > 0) {\n          const commandGroup = _.last(keyGroups)!;\n          // Command name might be different from this.name in case we are deactivating a command, and\n          // the previous meaning of the key points to a different command.\n          const commandName = commandGroup.knownKeys[key];\n          if (this.alwaysOn) {\n            Mousetrap.markAlwaysOnShortcut(key);\n          }\n          Mousetrap.bind(key, wrapKeyCallback(commandGroup.commands[commandName]));\n        } else {\n          Mousetrap.unbind(key);\n        }\n      });\n    }\n  }\n\n  private _run(...args: any[]) {\n    return this._activeFunc(...args);\n  }\n}\n\n/**\n * Helper for mousetrap callbacks, which returns a version of the callback that by default stops\n * the propagation of the keyboard event (unless the callback returns a true value).\n */\nfunction wrapKeyCallback(callback: Func) {\n  return function() {\n    return callback(...arguments) || false;\n  };\n}\n\n// ----------------------------------------------------------------------\n\ntype Func = (...args: any[]) => any;\ntype CommandMap = { [key in CommandName]?: Func };\n\n/**\n * CommandGroup is the way for other components to provide implementations for a group of\n * commands. Note that CommandGroups are stacked, with groups activated later having priority over\n * groups activated earlier.\n * @param {String->Function} commands: The map of command names to implementations.\n * @param {Object} context: \"this\" context with which to invoke implementation functions.\n * @param {Boolean|Observable<boolean>} activate: Whether to activate this group immediately, false if\n *      omitted. This may be an Observable.\n */\nexport class CommandGroup extends Disposable {\n  public commands: Record<string, Func>;\n  public isActive: boolean;\n  public knownKeys: Record<string, string>;\n  /**\n   * Attach this CommandGroup to a DOM element, to allow it to accept key events, limiting them to\n   * this group only. This is useful for inputs and textareas, where only a limited set of keyboard\n   * shortcuts should be applicable and where by default mousetrap ignores shortcuts completely.\n   *\n   * See also stopCallback in app/client/lib/Mousetrap.js.\n   */\n  public attach = dom.inlinable(function(this: any, elem: any) {\n    Mousetrap.setCustomStopCallback(elem, (combo: any) => !this.knownKeys.hasOwnProperty(combo));\n  });\n\n  constructor(commands: CommandMap, context: any, activate?: BoolLike) {\n    super();\n    // Keep only valid commands, so that we don't have to check for validity elsewhere, and bind\n    // each to the passed-in context object.\n    this.commands = {};\n    this.isActive = false;\n\n    for (const name in commands) {\n      if (allCommands[name as CommandName]) {\n        this.commands[name] = commands[name as CommandName]!.bind(context);\n      } else {\n        console.warn(\"Ignoring unknown command %s\", name);\n      }\n    }\n\n    // Map recognized key combinations to the corresponding command names.\n    this.knownKeys = {};\n    for (const name in this.commands) {\n      const keys = allCommands[name as CommandName].keys;\n      for (const key of keys) {\n        this.knownKeys[key] = name;\n      }\n    }\n\n    // On disposal, remove the CommandGroup from all the commands and keys.\n    this.onDispose(this._removeGroup.bind(this));\n\n    // Finally, set the activation status of the command group, subscribing if an observable.\n    if (typeof activate === \"boolean\" || activate === undefined) {\n      this.activate(activate ?? false);\n    } else if (activate) {\n      this.autoDispose(subscribe(activate, val => this.activate(val)));\n      this.activate(unwrap(activate));\n    }\n  }\n\n  /**\n   * Activate or deactivate this implementation group.\n   */\n  public activate(yesNo: boolean) {\n    if (yesNo) {\n      this._addGroup();\n    } else {\n      this._removeGroup();\n    }\n  }\n\n  private _addGroup() {\n    if (!this.isActive) {\n      this.isActive = true;\n      // Add this CommandGroup to each key combination that it recognizes.\n      for (const key in this.knownKeys) {\n        (_allKeys[key] || (_allKeys[key] = [])).push(this);\n      }\n      // Add this CommandGroup to each command that it implements.\n      for (const name in this.commands) {\n        allCommands[name as CommandName].addGroup(this);\n      }\n    }\n  }\n\n  private _removeGroup() {\n    if (this.isActive) {\n      // On disposal, remove the CommandGroup from all the commands and keys.\n      for (const key in this.knownKeys) {\n        arrayRemove(_allKeys[key], this);\n      }\n      for (const name in this.commands) {\n        allCommands[name as CommandName].removeGroup(this);\n      }\n      this.isActive = false;\n    }\n  }\n}\n\ntype BoundedFunc<T> = (this: T, ...args: any[]) => any;\ntype BoundedMap<T> = { [key in CommandName]?: BoundedFunc<T> };\n\n/**\n * Just a shorthand for CommandGroup.create constructor.\n */\nexport function createGroup<T>(commands: BoundedMap<T> | null, context: T, activate?: BoolLike) {\n  return CommandGroup.create(null, commands ?? {}, context, activate);\n}\n\n// ----------------------------------------------------------------------\n\n/**\n * Tie the button to an command listed in commandList.js, triggering the callback from the\n * currently active CommandLayer (if any), and showing a description and keyboard shortcuts in its\n * tooltip.\n *\n * You may use this inline while building dom, as in\n *      dom('button', commands.setButtonCommand(dom, 'command'))\n */\nexport const setButtonCommand = dom.inlinable(function(elem: Element, commandName: CommandName) {\n  const cmd = allCommands[commandName];\n  elem.setAttribute(\"title\", cmd.getDesc());\n  dom.on(elem, \"click\", cmd.run);\n});\n"
  },
  {
    "path": "app/client/components/duplicatePage.ts",
    "content": "import { duplicateWidgets } from \"app/client/components/duplicateWidget\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { cssInput } from \"app/client/ui/cssInput\";\nimport { cssField, cssLabel } from \"app/client/ui/MakeCopyMenu\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\n\nimport { dom } from \"grainjs\";\n\nconst t = makeT(\"duplicatePage\");\n\n// Duplicate page with pageId. Starts by prompting user for a new name.\nexport async function buildDuplicatePageDialog(gristDoc: GristDoc, pageId: number) {\n  const pagesTable = gristDoc.docModel.pages;\n  const pageName = pagesTable.rowModels[pageId].view.peek().name.peek();\n  let inputEl: HTMLInputElement;\n  setTimeout(() => { inputEl.focus(); inputEl.select(); }, 100);\n\n  confirmModal(\"Duplicate page\", \"Save\", () => duplicatePage(gristDoc, pageId, inputEl.value), {\n    explanation: dom(\"div\", [\n      cssField(\n        cssLabel(\"Name\"),\n        inputEl = cssInput({ value: pageName + \" (copy)\" }),\n      ),\n      t(\"Note that this does not copy data, but creates another view of the same data.\"),\n    ]),\n  });\n}\n\n/**\n * Duplicates page recreating all sections that are on it.\n */\nasync function duplicatePage(gristDoc: GristDoc, pageId: number, pageName: string = \"\") {\n  const sourceView = gristDoc.docModel.pages.rowModels[pageId].view.peek();\n  pageName = pageName || `${sourceView.name.peek()} (copy)`;\n  const viewSections = sourceView.viewSections.peek().peek();\n  let viewRef = 0;\n  await gristDoc.docData.bundleActions(\n    t(\"Duplicate page {{pageName}}\", { pageName }),\n    async () => {\n      logTelemetryEvent(\"addedPage\", { full: { docIdDigest: gristDoc.docId() } });\n\n      const duplicateWidgetsResult = await duplicateWidgets(\n        gristDoc,\n        viewSections.map(viewSection => viewSection.id.peek()),\n        viewRef,\n      );\n\n      // Update viewRef to the newly created page.\n      viewRef = duplicateWidgetsResult.viewId;\n\n      // give it a better name\n      await gristDoc.docModel.views.rowModels[viewRef].name.saveOnly(pageName);\n    });\n\n  // Give copy focus\n  await gristDoc.openDocPage(viewRef);\n}\n"
  },
  {
    "path": "app/client/components/duplicateWidget.ts",
    "content": "import { cleanFormLayoutSpec } from \"app/client/components/FormRenderer\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { BoxSpec, purgeBoxSpec } from \"app/client/lib/BoxSpec\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { ViewFieldRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { cssField, cssLabel } from \"app/client/ui/MakeCopyMenu\";\nimport { IPageWidget, toPageWidget } from \"app/client/ui/PageWidgetPicker\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { IOption, select } from \"app/client/ui2018/menus\";\nimport { saveModal } from \"app/client/ui2018/modals\";\nimport { BulkColValues, getColValues, RowRecord, UserAction } from \"app/common/DocActions\";\nimport { arrayRepeat } from \"app/common/gutil\";\nimport { schema } from \"app/common/schema\";\n\nimport { dom, fromKo, Observable } from \"grainjs\";\nimport ko from \"knockout\";\nimport cloneDeepWith from \"lodash/cloneDeepWith\";\nimport flatten from \"lodash/flatten\";\nimport forEach from \"lodash/forEach\";\nimport fromPairs from \"lodash/fromPairs\";\nimport sortBy from \"lodash/sortBy\";\n\nconst t = makeT(\"duplicateWidget\");\n\ntype PageSelectOption = IOption<number> & { isActivePage: boolean };\n\nexport async function buildDuplicateWidgetModal(gristDoc: GristDoc, viewSectionId: number) {\n  saveModal((ctl, owner) => {\n    const activeView = gristDoc.activeViewId.get();\n    const pageSelectOptions = fromKo(ko.pureComputed(() => {\n      const allPages = gristDoc.docModel.allPages();\n      const validPages = allPages.filter(page => !page.isHidden());\n      const sortedPages = sortBy(validPages, [page => page.pagePos()]);\n      const views = sortedPages.map(page => page.view());\n\n      const options: PageSelectOption[] = views.map((view) => {\n        const isActivePage = view.getRowId() === activeView;\n        const suffix = isActivePage ? (\" (\" + t(\"Active\") + \")\") : \"\";\n        return {\n          label: `${view.name()}${suffix}`,\n          value: view.getRowId(),\n          isActivePage: isActivePage,\n        };\n      });\n      options.push({ label: t(\"Create new page\"), value: 0, isActivePage: false, icon: \"Plus\" });\n      return options;\n    }));\n\n    const initialSelectedPage =\n      pageSelectOptions.get().find(option => option.isActivePage)?.value ?? pageSelectOptions.get()[0].value;\n    const pageSelectObs = Observable.create<number>(owner, initialSelectedPage);\n\n    return {\n      title: t(\"Duplicate widget\"),\n      body: dom(\"div\", [\n        cssField(\n          cssLabel(\"Page\"),\n          select(pageSelectObs, pageSelectOptions),\n          testId(\"duplicate-widget-page-select\"),\n        ),\n      ]),\n      async saveFunc() {\n        await duplicateWidgets(\n          gristDoc, [viewSectionId], pageSelectObs.get(),\n        );\n      },\n    };\n  });\n}\n\nexport async function duplicateWidgets(gristDoc: GristDoc, srcViewSectionIds: number[], destViewId: number) {\n  const allViewSectionModels = gristDoc.docModel.viewSections.rowModels;\n  const sourceViewSections = srcViewSectionIds.map(id => allViewSectionModels[id]).filter(viewSec => viewSec);\n\n  // Generally this shouldn't happen, but it catches a theoretically possible edge case.\n  if (sourceViewSections.length === 0) {\n    throw new Error(\"Unable to duplicate widgets as no valid source widget ids were provided\");\n  }\n  const sourceView = sourceViewSections[0].view.peek();\n  const isNewView = destViewId < 1;\n  let resolvedDestViewId = destViewId;\n\n  logTelemetryEvent(\"duplicatedWidget\", {\n    full: {\n      docIdDigest: gristDoc.docId(),\n      destPage: isNewView ? \"NEW\" : (destViewId === sourceView.getRowId() ? \"SAME\" : \"OTHER\"),\n    },\n  });\n\n  await gristDoc.docData.bundleActions(\n    t(\"Duplicate widgets\"),\n    async () => {\n      // Creates new view sections using the existing ones as a template. This isn't a perfect\n      // copy, as only certain options can be passed when creating a new view section.\n      const {\n        duplicatedViewSections,\n        viewRef,\n        viewSectionIdMap,\n      } = await createNewViewSections(gristDoc, sourceViewSections, destViewId);\n      resolvedDestViewId = viewRef;\n\n      // Gets the IDs of the view fields that were automatically created above when we created\n      // the new view sections. These shouldn't be in the final duplicated widget, so they'll need\n      // to be removed later. They can't be removed now due as it breaks the widget's layout spec.\n      const autoCreatedViewFieldIds = listAllViewFields(duplicatedViewSections.map(pair => pair.destViewSection));\n      // Adds copies of the original fields to the new view sections. Retain a map of fields in the source\n      // view section to their copies in the new section, so that we can update the widget layout later with\n      // the new fields.\n      const viewFieldsIdMap = await copyOriginalViewFields(gristDoc, duplicatedViewSections);\n\n      // If we're creating a new page, we should copy the page layout over, updating its ids\n      // to match the newly created view sections.\n      let layoutSpecUpdatePromise = Promise.resolve();\n      if (isNewView) {\n        const newLayoutSpec = patchLayoutSpec(sourceView.layoutSpecObj.peek(), viewSectionIdMap);\n        layoutSpecUpdatePromise = gristDoc.docData.sendAction(\n          [\"UpdateRecord\", \"_grist_Views\", resolvedDestViewId, { layoutSpec: JSON.stringify(newLayoutSpec) }],\n        );\n      }\n      await Promise.all([\n        layoutSpecUpdatePromise,\n        updateViewSections(gristDoc, duplicatedViewSections, viewFieldsIdMap, viewSectionIdMap),\n        copyFilters(gristDoc, sourceViewSections, viewSectionIdMap),\n      ]);\n\n      // Remove the fields that were automatically created when the view sections were created,.\n      // only after we've patched the widgets' layout specs.\n      // These specs are JSON representations of how the fields are laid out, and reference the ids\n      // of the automatically created fields.\n      // If we remove those fields before the removing the references in the layout spec,\n      // the UI will either break, throw errors, or both, due to it temporarily being in an invalid\n      // state (referencing non-existent fields).\n      await removeViewFields(gristDoc, autoCreatedViewFieldIds);\n    },\n    // If called from duplicatePage (or similar), we don't want to start a new bundle.\n    { nestInActiveBundle: true },\n  );\n\n  // Give copy focus\n  await gristDoc.openDocPage(resolvedDestViewId);\n\n  return {\n    viewId: resolvedDestViewId,\n  };\n}\n\n/**\n * Copies _grist_Filters from source sections.\n */\nasync function copyFilters(\n  gristDoc: GristDoc,\n  srcViewSections: ViewSectionRec[],\n  viewSectionMap: { [id: number]: number }) {\n  // Get all filters for selected sections.\n  const filters: RowRecord[] = [];\n  const table = gristDoc.docData.getMetaTable(\"_grist_Filters\");\n  for (const srcViewSection of srcViewSections) {\n    const sectionFilters = table\n      .filterRecords({ viewSectionRef: srcViewSection.id.peek() })\n      .map(filter => ({\n        // Replace section ref with destination ref.\n        ...filter, viewSectionRef: viewSectionMap[srcViewSection.id.peek()],\n      }));\n    filters.push(...sectionFilters);\n  }\n  if (filters.length) {\n    const filterInfo = getColValues(filters);\n    await gristDoc.docData.sendAction([\"BulkAddRecord\", \"_grist_Filters\",\n      new Array(filters.length).fill(null), filterInfo]);\n  }\n}\n\n/**\n * Update all of destViewSections with srcViewSections, use fieldsMap to patch the section layout\n * (for detail/cardlist sections), use viewSectionMap to patch the sections ids for linking.\n */\nasync function updateViewSections(gristDoc: GristDoc, duplicatedViewSections: DuplicatedViewSection[],\n  fieldsMap: { [id: number]: number }, viewSectionMap: { [id: number]: number }) {\n  const destRowIds: number[] = [];\n  const records: RowRecord[] = [];\n  for (const { srcViewSection, destViewSection } of duplicatedViewSections) {\n    const viewSectionLayoutSpec =\n      srcViewSection.parentKey.peek() === \"form\" ?\n        cleanFormLayoutSpec(srcViewSection.layoutSpecObj.peek(), fieldsMap) :\n        patchLayoutSpec(srcViewSection.layoutSpecObj.peek(), fieldsMap);\n    const record = gristDoc.docData.getMetaTable(\"_grist_Views_section\").getRecord(srcViewSection.getRowId())!;\n\n    const isNewView = srcViewSection.view.peek().id.peek() != destViewSection.view.peek().id.peek();\n    const originalLinkRef = srcViewSection.linkSrcSectionRef.peek();\n    const linkRef = isNewView ? viewSectionMap[originalLinkRef] : originalLinkRef;\n\n    destRowIds.push(destViewSection.getRowId());\n    records.push({\n      ...record,\n      layoutSpec: JSON.stringify(viewSectionLayoutSpec),\n      linkSrcSectionRef: linkRef ?? false,\n      shareOptions: \"\",\n    });\n  }\n\n  // transpose data\n  const sectionsInfo = getColValues(records);\n\n  delete sectionsInfo.parentId;\n\n  await gristDoc.docData.sendAction([\"BulkUpdateRecord\", \"_grist_Views_section\", destRowIds, sectionsInfo]);\n}\n\nasync function copyOriginalViewFields(gristDoc: GristDoc, viewSectionPairs: DuplicatedViewSection[]) {\n  const docData = gristDoc.docData;\n\n  // collect all the fields to add\n  const srcViewFieldIds: number[] = [];\n  const fieldsToAdd: RowRecord[] = [];\n  for (const { srcViewSection, destViewSection } of viewSectionPairs) {\n    const srcViewFields: ViewFieldRec[] = srcViewSection.viewFields.peek().peek();\n    const parentId = destViewSection.getRowId();\n    for (const field of srcViewFields) {\n      const record = docData.getMetaTable(\"_grist_Views_section_field\").getRecord(field.getRowId())!;\n      fieldsToAdd.push({ ...record, parentId });\n      srcViewFieldIds.push(field.getRowId());\n    }\n  }\n\n  // transpose data\n  const fieldsInfo = {} as BulkColValues;\n  forEach(schema._grist_Views_section_field, (val, key) => fieldsInfo[key] = fieldsToAdd.map(rec => rec[key]));\n  const rowIds = arrayRepeat(fieldsInfo.parentId.length, null);\n\n  const addAction: UserAction = [\"BulkAddRecord\", \"_grist_Views_section_field\", rowIds, fieldsInfo];\n  // Add then remove to workaround a bug, where fields won't work in the UI when a duplicate widget\n  // has a 'SelectBy' set and all fields are showing.\n  // This looks to be an issue deep within the computed values, where something isn't updating\n  // correctly / at the right time. Possibly due to the widget IDs being re-used if remove\n  // occurs before add.\n  const results = await gristDoc.docData.sendActions([\n    addAction,\n  ]);\n\n  const newFieldIds: number[] = results[0];\n  return fromPairs(srcViewFieldIds.map((srcId, index) => [srcId, newFieldIds[index]]));\n}\n\nfunction listAllViewFields(viewSections: ViewSectionRec[]) {\n  return flatten(viewSections.map(\n    viewSection => viewSection.viewFields.peek().peek().map(field => field.getRowId()),\n  ));\n}\n\nasync function removeViewFields(gristDoc: GristDoc, fieldIds: number[]) {\n  await gristDoc.docData.sendAction([\"BulkRemoveRecord\", \"_grist_Views_section_field\", fieldIds]);\n}\n\n/**\n * Create a new view containing all of the viewSections. Note that it doesn't copy view fields, for\n * which you can use `updateViewFields`.\n */\nasync function createNewViewSections(gristDoc: GristDoc, viewSections: ViewSectionRec[], viewId: number) {\n  const [first, ...rest] = viewSections.map(toPageWidget);\n\n  // Passing a viewId of 0 will create a new view.\n  const createdViewSectionResults = [\n    await gristDoc.docData.sendAction(newViewSectionAction(first, viewId)),\n  ];\n  const targetViewRef = createdViewSectionResults[0].viewRef;\n\n  // Other view sections are added to the newly created view.\n  const otherViewSectionActions = rest.map(widget => newViewSectionAction(widget, targetViewRef));\n\n  // Avoid sending an empty list of actions - it causes a bug in the bundling code that results\n  // in the bundle being split into two bundles (2025-07-18).\n  if (otherViewSectionActions.length > 0) {\n    createdViewSectionResults.push(...(await gristDoc.docData.sendActions(otherViewSectionActions)));\n  }\n\n  // Technically a race condition can here, where the viewSections model isn't up to date with the\n  // backend. In practice, this typically won't occur, and a more correct solution would require major work.\n  // Either moving duplicate to the backend, or not using viewSection models at all (only ids).\n  const newViewSections = createdViewSectionResults.map(\n    result => gristDoc.docModel.viewSections.rowModels[result.sectionRef],\n  );\n\n  const duplicatedViewSections: DuplicatedViewSection[] =\n    viewSections.map((srcSection, index) => ({ srcViewSection: srcSection, destViewSection: newViewSections[index] }));\n\n  return {\n    duplicatedViewSections,\n    viewSectionIdMap: fromPairs(duplicatedViewSections.map((\n      { srcViewSection, destViewSection }) => [srcViewSection.getRowId(), destViewSection.getRowId()],\n    )),\n    viewRef: targetViewRef,\n  };\n}\n\n// Helper to create an action that add widget to the view with viewId.\nfunction newViewSectionAction(widget: IPageWidget, viewId: number) {\n  return [\"CreateViewSection\", widget.table, viewId, widget.type, widget.summarize ? widget.columns : null, null];\n}\n\n/**\n * Replaces each `leaf` id in layoutSpec by its corresponding id in mapIds. Leave unchanged if id\n * is\n * missing from mapIds.\n * LayoutSpec is a tree structure with leaves (that have `leaf` property) or containers of leaves.\n * The root container (or leaf) also includes a list of collapsed leaves in `collapsed` property.\n *\n * Example use:\n *   patchLayoutSpec({\n *     leaf: 1,\n*      collapsed: [{leaf: 2}]\n *   }, {1: 10, 2: 20})\n */\nfunction patchLayoutSpec(layoutSpec: BoxSpec, mapIds: { [id: number]: number }) {\n  // First remove any invalid ids from the layoutSpec. We are doing the same thing what\n  // `ViewLayout` does when it load itself.\n  layoutSpec = purgeBoxSpec({\n    spec: layoutSpec,\n    validLeafIds: Object.keys(mapIds).map(Number),\n    restoreCollapsed: true,\n  });\n  return cloneDeepWith(layoutSpec, (val, key) => {\n    if (key === \"leaf\" && mapIds[val]) {\n      return mapIds[val];\n    }\n  });\n}\n\ninterface DuplicatedViewSection {\n  srcViewSection: ViewSectionRec,\n  destViewSection: ViewSectionRec,\n}\n"
  },
  {
    "path": "app/client/components/modals.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportSuccess } from \"app/client/models/errors\";\nimport { basicButton, bigPrimaryButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { mediaXSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssModalTooltip, modalTooltip } from \"app/client/ui2018/modals\";\n\nimport { dom, DomContents, keyframes, observable, styled, svg } from \"grainjs\";\nimport merge from \"lodash/merge\";\nimport { IPopupOptions } from \"popweasel\";\n\nconst t = makeT(\"modals\");\n\n/**\n * This is a file for all custom and pre-configured popups, modals, toasts and tooltips, used\n * in more then one component.\n */\n\n/**\n * Tooltip or popup to confirm row deletion.\n */\nexport function buildConfirmDelete(\n  refElement: Element,\n  onSave: (remember: boolean) => void,\n  single = true,\n) {\n  const remember = observable(false);\n  const tooltip = modalTooltip(refElement, ctl =>\n    cssContainer(\n      dom.autoDispose(remember),\n      testId(\"confirm-deleteRows\"),\n      testId(\"confirm-popup\"),\n      (elem) => { FocusLayer.create(ctl, { defaultFocusElem: elem, pauseMousetrap: true }); },\n      dom.onKeyDown({\n        Escape: () => ctl.close(),\n        Enter: () => { onSave(remember.get()); ctl.close(); },\n      }),\n      dom(\"div\", single ?\n        t(`Are you sure you want to delete this record?`) :\n        t(`Are you sure you want to delete these records?`),\n      dom.style(\"margin-bottom\", \"10px\"),\n      ),\n      dom(\"div\",\n        labeledSquareCheckbox(remember, t(\"Don't ask again.\"), testId(\"confirm-remember\")),\n        dom.style(\"margin-bottom\", \"10px\"),\n      ),\n      cssButtons(\n        primaryButton(t(\"Delete\"), testId(\"confirm-save\"), dom.on(\"click\", () => {\n          onSave(remember.get());\n          ctl.close();\n        })),\n        basicButton(t(\"Cancel\"), testId(\"confirm-cancel\"), dom.on(\"click\", () => ctl.close())),\n      ),\n    ), {},\n  );\n  // Attach this tooltip to a cell so that it is automatically closed when the cell is disposed.\n  // or scrolled out of view (and then disposed).\n  dom.onDisposeElem(refElement, () => {\n    if (!tooltip.isDisposed()) {\n      tooltip.close();\n    }\n  });\n  return tooltip;\n}\n\nexport function showDeprecatedWarning(\n  refElement: Element,\n  content: DomContents,\n  onClose: (checked: boolean) => void,\n) {\n  const remember = observable(false);\n  const tooltip = modalTooltip(refElement, ctl =>\n    cssWideContainer(\n      testId(\"popup-warning-deprecated\"),\n      (elem) => { FocusLayer.create(ctl, { defaultFocusElem: elem, pauseMousetrap: true }); },\n      dom.onKeyDown({\n        Escape: () => { ctl.close(); onClose(remember.get()); },\n        Enter: () => { ctl.close(); onClose(remember.get()); },\n      }),\n      content,\n      cssButtons(\n        dom.style(\"margin-top\", \"12px\"),\n        dom.style(\"justify-content\", \"space-between\"),\n        dom.style(\"align-items\", \"center\"),\n        dom(\"div\",\n          labeledSquareCheckbox(remember, t(\"Don't show again.\"), testId(\"confirm-remember\")),\n        ),\n        basicButton(t(\"Dismiss\"), testId(\"confirm-save\"),\n          dom.on(\"click\", () => { ctl.close(); onClose(remember.get()); }),\n        ),\n      ),\n    ),\n  );\n  // Attach this warning to a cell so that it is automatically closed when the cell is disposed.\n  // or scrolled out of view (and then disposed).\n  dom.onDisposeElem(refElement, () => {\n    if (!tooltip.isDisposed()) {\n      tooltip.close();\n    }\n  });\n  return tooltip;\n}\n\n/**\n * Shows notification with a single button 'Undo' delete.\n */\nexport function reportUndo(\n  doc: GristDoc,\n  messageLabel: string,\n  buttonLabel = t(\"Undo to restore\"),\n) {\n  // First create a notification with a button to undo the delete.\n  let notification = reportSuccess(messageLabel, {\n    key: \"undo\",\n    actions: [{\n      label: buttonLabel,\n      action: () => {\n        // When user clicks on the button, undo the last action.\n        commands.allCommands.undo.run();\n        // And remove this notification.\n        close();\n      },\n    }],\n  });\n\n  // When we received some actions from the server, cancel this popup,\n  // as the undo might do something else.\n  doc.on(\"onDocUserAction\", close);\n  notification?.onDispose(() => doc.off(\"onDocUserAction\", close));\n\n  function close() {\n    if (notification && !notification?.isDisposed()) {\n      notification.dispose();\n      notification = undefined;\n    }\n  }\n}\n\nexport interface ShowTipPopupOptions {\n  onClose: (dontShowTips: boolean) => void;\n  /** Defaults to false. */\n  hideArrow?: boolean;\n  /** Defaults to false. */\n  hideDontShowTips?: boolean;\n  popupOptions?: IPopupOptions;\n}\n\nexport function showTipPopup(\n  refElement: Element,\n  title: DomContents,\n  content: DomContents,\n  options: ShowTipPopupOptions,\n) {\n  const { onClose, hideArrow = false, hideDontShowTips = false, popupOptions } = options;\n  const arrow = hideArrow ? null : buildArrow();\n  const dontShowTips = observable(false);\n  const tooltip = modalTooltip(refElement,\n    ctl => [\n      cssBehavioralPromptModal.cls(\"\"),\n      arrow,\n      cssBehavioralPromptContainer(\n        dom.autoDispose(dontShowTips),\n        testId(\"behavioral-prompt\"),\n        (elem) => { FocusLayer.create(ctl, { defaultFocusElem: elem, pauseMousetrap: true }); },\n        dom.onKeyDown({\n          Escape: () => ctl.close(),\n          Enter: () => { onClose(dontShowTips.get()); ctl.close(); },\n        }),\n        cssBehavioralPromptHeader(\n          cssHeaderIconAndText(\n            icon(\"Idea\"),\n            cssHeaderText(t(\"TIP\")),\n          ),\n        ),\n        cssBehavioralPromptBody(\n          cssBehavioralPromptTitle(title, testId(\"behavioral-prompt-title\")),\n          content,\n          cssButtons(\n            dom.style(\"margin-top\", \"12px\"),\n            dom.style(\"justify-content\", \"space-between\"),\n            dom.style(\"align-items\", \"center\"),\n            dom(\"div\",\n              cssSkipTipsCheckbox(dontShowTips,\n                cssSkipTipsCheckboxLabel(t(\"Don't show tips\")),\n                testId(\"behavioral-prompt-dont-show-tips\"),\n              ),\n              dom.style(\"visibility\", hideDontShowTips ? \"hidden\" : \"\"),\n            ),\n            cssDismissPromptButton(t(\"Got it\"), testId(\"behavioral-prompt-dismiss\"),\n              dom.on(\"click\", () => { onClose(dontShowTips.get()); ctl.close(); }),\n            ),\n          ),\n        ),\n      ),\n    ],\n    merge({}, defaultPopupOptions, popupOptions),\n  );\n  dom.onDisposeElem(refElement, () => {\n    if (!tooltip.isDisposed()) {\n      tooltip.close();\n    }\n  });\n  return tooltip;\n}\n\nexport interface ShowNewsPopupOptions {\n  popupOptions?: IPopupOptions;\n}\n\nexport function showNewsPopup(\n  refElement: Element,\n  title: DomContents,\n  content: DomContents,\n  options: ShowNewsPopupOptions = {},\n) {\n  const { popupOptions } = options;\n  const popup = modalTooltip(refElement,\n    ctl => [\n      cssNewsPopupModal.cls(\"\"),\n      cssNewsPopupContainer(\n        testId(\"behavioral-prompt\"),\n        (elem) => { FocusLayer.create(ctl, { defaultFocusElem: elem, pauseMousetrap: true }); },\n        dom.onKeyDown({\n          Escape: () => { ctl.close(); },\n          Enter: () => { ctl.close(); },\n        }),\n        cssNewsPopupCloseButton(\n          icon(\"CrossBig\"),\n          dom.on(\"click\", () => ctl.close()),\n          testId(\"behavioral-prompt-dismiss\"),\n        ),\n        cssNewsPopupBody(\n          cssNewsPopupTitle(title, testId(\"behavioral-prompt-title\")),\n          content,\n        ),\n      ),\n    ],\n    merge({}, defaultPopupOptions, popupOptions),\n  );\n  dom.onDisposeElem(refElement, () => {\n    if (!popup.isDisposed()) {\n      popup.close();\n    }\n  });\n  return popup;\n}\n\nconst defaultPopupOptions = {\n  modifiers: {\n    offset: {\n      offset: \"0,12\",\n    },\n    preventOverflow: {\n      boundariesElement: \"viewport\",\n      padding: 32,\n    },\n    computeStyle: {\n      // GPU acceleration makes text look blurry.\n      gpuAcceleration: false,\n    },\n  },\n};\n\nfunction buildArrow() {\n  return cssArrowContainer(\n    svg(\"svg\",\n      { style: \"width: 13px; height: 18px;\" },\n      svg(\"path\", { d: \"M 0 0 h 13 v 18 Z\" }),\n    ),\n  );\n}\n\nfunction sideSelectorChunk(side: \"top\" | \"bottom\" | \"left\" | \"right\") {\n  return `.${cssModalTooltip.className}[x-placement^=${side}]`;\n}\n\nfunction fadeInFromSide(side: \"top\" | \"bottom\" | \"left\" | \"right\") {\n  let startPosition: string;\n  switch (side) {\n    case \"top\": {\n      startPosition = \"0px -25px\";\n      break;\n    }\n    case \"bottom\": {\n      startPosition = \"0px 25px\";\n      break;\n    }\n    case \"left\": {\n      startPosition = \"-25px 0px\";\n      break;\n    }\n    case \"right\": {\n      startPosition = \"25px 0px\";\n      break;\n    }\n  }\n  return keyframes(`\n  from {translate: ${startPosition}; opacity: 0;}\n  to {translate: 0px 0px; opacity: 1;}\n  `);\n}\n\nconst HEADER_HEIGHT_PX = 30;\n\nconst cssArrowContainer = styled(\"div\", `\n  position: absolute;\n\n  & path {\n    stroke: ${theme.popupBg};\n    stroke-width: 2px;\n    fill: ${theme.popupBg};\n  }\n\n  ${sideSelectorChunk(\"bottom\")} > & path {\n    stroke: ${theme.controlPrimaryBg};\n    fill: ${theme.controlPrimaryBg};\n  }\n\n  ${sideSelectorChunk(\"top\")} > & {\n    bottom: -17px;\n    margin: 0px 16px;\n  }\n\n  ${sideSelectorChunk(\"bottom\")} > & {\n    top: -14px;\n    margin: 0px 16px;\n  }\n\n  ${sideSelectorChunk(\"right\")} > & {\n    left: -12px;\n    margin: ${HEADER_HEIGHT_PX}px 0px ${HEADER_HEIGHT_PX}px 0px;\n  }\n\n  ${sideSelectorChunk(\"left\")} > & {\n    right: -12px;\n    margin: ${HEADER_HEIGHT_PX}px 0px ${HEADER_HEIGHT_PX}px 0px;\n  }\n\n  ${sideSelectorChunk(\"top\")} svg {\n    transform: rotate(-90deg);\n  }\n\n  ${sideSelectorChunk(\"bottom\")} svg {\n    transform: rotate(90deg);\n  }\n\n  ${sideSelectorChunk(\"left\")} svg {\n    transform: scalex(-1);\n  }\n`);\n\nconst cssTheme = styled(\"div\", `\n  color: ${theme.text};\n`);\n\nconst cssButtons = styled(\"div\", `\n  display: flex;\n  gap: 6px;\n`);\n\nconst cssContainer = styled(cssTheme, `\n  max-width: 270px;\n`);\n\nconst cssWideContainer = styled(cssTheme, `\n  max-width: 340px;\n`);\n\nconst cssFadeInFromTop = fadeInFromSide(\"top\");\n\nconst cssFadeInFromBottom = fadeInFromSide(\"bottom\");\n\nconst cssFadeInFromLeft = fadeInFromSide(\"left\");\n\nconst cssFadeInFromRight = fadeInFromSide(\"right\");\n\nconst cssBehavioralPromptModal = styled(\"div\", `\n  margin: 0px;\n  padding: 0px;\n  width: 400px;\n  border-radius: 4px;\n\n  animation-duration: 0.4s;\n  position: absolute;\n\n  &[x-placement^=top] {\n    animation-name: ${cssFadeInFromTop};\n  }\n\n  &[x-placement^=bottom] {\n    animation-name: ${cssFadeInFromBottom};\n  }\n\n  &[x-placement^=left] {\n    animation-name: ${cssFadeInFromLeft};\n  }\n\n  &[x-placement^=right] {\n    animation-name: ${cssFadeInFromRight};\n  }\n\n  @media ${mediaXSmall} {\n    & {\n      /* Allocate 32px of space for the left and right margins. */\n      width: calc(100% - 64px);\n    }\n  }\n`);\n\nconst cssNewsPopupModal = cssBehavioralPromptModal;\n\nconst cssBehavioralPromptContainer = styled(cssTheme, `\n  line-height: 18px;\n`);\n\nconst cssNewsPopupContainer = styled(\"div\", `\n  background: linear-gradient(to right, #29a3a3, #16a772);\n  color: white;\n  border-radius: 4px;\n`);\n\nconst cssBehavioralPromptHeader = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  background-color: ${theme.controlPrimaryBg};\n  color: ${theme.controlPrimaryFg};\n  --icon-color: ${theme.controlPrimaryFg};\n  border-radius: 4px 4px 0px 0px;\n  line-height: ${HEADER_HEIGHT_PX}px;\n`);\n\nconst cssBehavioralPromptBody = styled(\"div\", `\n  padding: 16px;\n`);\n\nconst cssNewsPopupBody = styled(\"div\", `\n  font-size: 14px;\n  line-height: 23px;\n  padding: 16px;\n`);\n\nconst cssHeaderIconAndText = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  column-gap: 8px;\n`);\n\nconst cssHeaderText = styled(\"div\", `\n  font-weight: 600;\n`);\n\nconst cssDismissPromptButton = styled(bigPrimaryButton, `\n  margin-right: 8px;\n`);\n\nconst cssBehavioralPromptTitle = styled(\"div\", `\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n  color: ${theme.text};\n  margin: 0 0 16px 0;\n  line-height: 32px;\n`);\n\nconst cssNewsPopupTitle = styled(\"div\", `\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n  --icon-color: white;\n  margin: 0 16px 16px 0;\n  line-height: 32px;\n`);\n\nconst cssNewsPopupCloseButton = styled(\"div\", `\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  padding: 4px;\n  border-radius: 4px;\n  cursor: pointer;\n  --icon-color: white;\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssSkipTipsCheckbox = styled(labeledSquareCheckbox, `\n  line-height: normal;\n`);\n\nconst cssSkipTipsCheckboxLabel = styled(\"span\", `\n  color: ${theme.lightText};\n`);\n"
  },
  {
    "path": "app/client/components/viewCommon.css",
    "content": "/*\n  record class is used for grid view header and rows\n */\n.record {\n  display: -webkit-flex;\n  display: flex;\n  position: relative;\n  box-sizing: border-box;\n  -moz-box-sizing: border-box;\n\n  border-width: 0px;\n  border-style: none;\n  border-color: var(--grist-theme-table-body-border, var(--grist-color-dark-grey));\n  border-left-style: solid;  /* left border, against rownumbers div, always on */\n  border-left-color: var(--grist-theme-table-header-border, lightgray);\n  border-bottom-width: 1px; /* style: none, set by record-hlines*/\n  /* Record background is white (or theme default) by default.\n     It gets overridden by the add row, zebra stripes.\n     It also gets overridden by selecting rows - but in that case background comes from\n     selected fields.\n  */\n  background-color: var(--grist-diff-background-color, /* diffing view */\n                    var(--grist-row-rule-background-color, /* conditional row style */\n                    var(--grist-theme-cell-bg, white))); /* default, not transparent */\n  color: var(--grist-row-color, var(--grist-theme-cell-fg, black));\n}\n\n.record.record-hlines {  /* Overwrites style, width set on element */\n  border-bottom-style: solid;\n}\n\n.record.record-zebra.record-even {\n  background-color: var(--grist-diff-background-color,\n                    var(--grist-row-rule-background-color-zebra,\n                    var(--grist-row-rule-background-color,\n                    var(--grist-theme-cell-zebra-bg, #f8f8f8))));\n}\n\n.record.record-add {\n  background-color: var(--grist-theme-table-add-new-bg, #f6f6ff) !important;  /* important to win over every thing */\n}\n\n.field {\n  position: relative;\n  height: 100%;\n  -webkit-flex: none;\n  flex: none;\n  min-height: 22px;\n  white-space: pre;\n  /* make border exist always so content doesn't shift on v-gridline toggle */\n  border: 0px solid transparent;  /* width set by js, border exists but is transparent */\n/**\n* Order of precedence for field is as follows: diff color, column rule, row rule, static (default) style, transparent.\n* We can't use background inheritance, because row background color is more important then static (aka default)\n* column color defined on a field (so lower in the dom).\n*/\n  --field-background-color: var(--grist-diff-background-color,\n                            var(--grist-column-rule-background-color,\n                            var(--grist-row-rule-background-color,\n                            var(--grist-cell-background-color))));\n  background-color: var(--field-background-color, unset);\n}\n\n/* The vertical line indicating where a column will be inserted when the\n * Add Column menu is open. */\n.field.field-insert-before::before {\n  content: '';\n  position: absolute;\n  left: 0px;\n  top: 0px;\n  /* Overlap the top/bottom table borders so that the line appears uninterrupted. */\n  bottom: -1px;\n  z-index: var(--grist-insert-column-line-z-index);\n  width: 3px;\n  background-color: var(--grist-theme-widget-active-border, #16B378);\n}\n\n/** Similar order is for detail view, but there is no row rules */\n.g_record_detail_value {\n  background-color: var(--grist-diff-background-color,\n                    var(--grist-column-rule-background-color,\n                    var(--grist-cell-background-color, unset)));\n}\n\n.record.record-zebra.record-even .field {\n  --field-background-color: var(--grist-diff-background-color,\n                            var(--grist-column-rule-background-color,\n                            var(--grist-row-rule-background-color-zebra,\n                            var(--grist-row-rule-background-color,\n                            var(--grist-cell-background-color)))));\n}\n\n.record.record-add .field {\n  background-color: unset !important;  /* important to win over zebra stripes */\n}\n\n.record-vlines > .field {\n  /* set border visibility */\n  border-right-color: var(--grist-theme-table-body-border, var(--grist-color-dark-grey));\n}\n\n.field.scissors {\n  outline: 2px dashed var(--grist-theme-cursor, var(--grist-color-cursor));\n}\n\n.field.draft {\n  padding-right: 18px;\n}\n\n.field_clip {\n  padding: 3px 3px 0px 3px;\n  font-family: var(--grist-font-family-data);\n  line-height: 18px;\n  min-height: 21px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  width: 100%;\n  height: 100%;\n  /* We need to repeat background color here, as it might also be applied on a widget level (through DiffBox.ts)*/\n  background-color: var(--grist-diff-background-color, inherit);\n  --grist-actual-cell-color: var(--grist-diff-color,\n                             var(--grist-rule-color,\n                             var(--grist-row-color,\n                             var(--grist-cell-color))));\n  color: var(--grist-actual-cell-color, unset);\n}\n\n/**\n * Implement row-height modes.\n * Note: the height calculation here is manual, based on \"line-height: 18px\" + padding in viewCommon.css.\n * The -webkit-* settings allow cells to show an overflow ellipsis. See https://css-tricks.com/line-clampin/.\n */\n.row_height_set .field_clip {\n  max-height: calc(3px + var(--row-height-lines) * 18px);\n\n  display: -webkit-box;\n  -webkit-line-clamp: var(--row-height-lines);\n  -webkit-box-orient: vertical;\n\n  /* Same as .text_wrapping; consider wrapping to be on with .row_height_set. */\n  word-break: break-word;\n  white-space: pre-wrap;\n}\n\n.row_height_uniform .field_clip {\n  min-height: calc(3px + var(--row-height-lines) * 18px);\n}\n\n\n.gridview_row .field.selected > .selection {\n  background-color: var(--grist-theme-selection, var(--grist-color-selection));\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n}\n\n.field.transform_field > .selection {\n  background-color: var(--grist-theme-selection-darker, rgba(22,179,120,0.25));\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n}\n\n.field_clip.invalid, .field_clip.field-error-from-style {\n  background-color: #ffb6c1;\n  color: black;\n}\n\n.field_clip.invalid:empty {\n  background-color: unset;\n}\n\n.field.transform_field > .field_clip.invalid + .selection {\n  background-color: unset;\n}\n\n.field_clip.field-error-P {\n  color: #B0B0B0;\n  background-color: unset;\n}\n\n.field_clip.invalid.field-error-C {\n  background-color: unset;\n  color: var(--grist-color-dark-grey);\n  padding-left: 18px;\n}\n\n.field_clip.invalid.field-error-C::before {\n  /* based on standard icon styles */\n  content: \"\";\n  position: absolute;\n  top: 4px;\n  left: 2px;\n  width: 14px;\n  height: 14px;\n  background-color: var(--grist-color-dark-grey);\n  -webkit-mask-repeat: no-repeat;\n  -webkit-mask-position: center;\n  -webkit-mask-size: contain;\n  -webkit-mask-image: var(--icon-Lock);\n}\n\n.field_clip.field-error-U {\n  color: #6363a2;\n  background-color: unset;\n}\n\n.field_clip.field-error-S {\n  color: #aaa;\n  background-color: unset;\n}\n\n/* Insert a zero-width space into each cell, to size cells to at least one line of text. */\n.field_clip:empty::before { content: '\\200B'; }\n\n@media not print {\n.selected_cursor {\n  position: absolute;\n  left: 0px;\n  top: 0px;\n  width: 100%;\n  height: 100%;\n  pointer-events: none;\n}\n\n.active_cursor {\n  /* one pixel outline around the cell, and one inside the cell */\n  outline: 1px solid var(--grist-theme-cursor, var(--grist-color-cursor));\n  box-shadow: inset 0 0 0 1px var(--grist-theme-cursor, var(--grist-color-cursor));\n  z-index: 1;\n}\n}\n\n.column_name {\n  color: var(--grist-header-color,\n         var(--grist-theme-table-header-fg), unset);\n  background-color: var(--grist-header-background-color,\n                    var(--grist-theme-table-header-bg,\n                    var(--grist-color-light-grey)));\n  text-align: center;\n  cursor: pointer;\n  /* Column headers always show vertical gridlines, to make it clear how to resize them */\n  border-right-color: var(--grist-theme-table-header-border, lightgray);\n}\n\n.column_names.record {\n  border-left-color: var(--grist-theme-table-header-border, lightgray);\n}\n\n.column_name.selected > .selection {\n  background-color: var(--grist-theme-selection-header);\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n}\n\n.gridview_data_row_num.selected {\n  color: var(--grist-theme-table-header-selected-fg, unset);\n  background-color: var(--grist-theme-table-header-selected-bg, var(--grist-color-medium-grey-opaque));\n}\n\n.link_selector_row > .gridview_data_row_num {\n  color: var(--grist-theme-left-panel-active-page-fg, white);\n  background-color: var(--grist-theme-left-panel-active-page-bg, var(--grist-color-dark-bg));\n}\n\n@media not print {\n  .link_selector_row > .record::after {\n    content: \"\";\n    position: absolute;\n    inset: 0;\n    pointer-events: none;\n    background-color: var(--grist-theme-selection, var(--grist-color-selection));\n    /* z-index should be higher than '.record .field.frozen' (10) to show for frozen columns,\n     * but lower than '.gridview_stick-top' (20) to stay under column headers. */\n    z-index: 15;\n  }\n}\n\n.gridview_data_row_info.linked_dst::before {\n  position: absolute;\n  content: '\\25B8';\n  text-align: left;\n  left: 7px;\n}\n\n.text_wrapping {\n  word-break: break-word;\n  white-space: pre-wrap;\n}\n\n.diff-local, .diff-local-add {\n  background-color: #dfdfff;\n  --grist-diff-background-color: #dfdfff;\n  --grist-diff-color: black;\n}\n\n.diff-parent, .diff-remote-remove {\n  background-color: #ffdfdf;\n  --grist-diff-background-color: #ffdfdf;\n  --grist-diff-color: black;\n  text-decoration: line-through;\n}\n\n.diff-local-remove {\n  background-color: #dfdfdf;\n  --grist-diff-background-color: #dfdfdf;\n  --grist-diff-color: black;\n  text-decoration: line-through;\n}\n\n.diff-remote, .diff-remote-add {\n  background-color: #afffaf;\n  --grist-diff-background-color: #afffaf;\n  --grist-diff-color: black;\n}\n\n.diff-emphasize-local .diff-local,\n.diff-emphasize-local .diff-local-add {\n  background-color: #afffaf;\n  --grist-diff-background-color: #afffaf;\n}\n\n.diff-emphasize-local .diff-remote,\n.diff-emphasize-local .diff-remote-add {\n  background-color: #dfdfff;\n  --grist-diff-background-color: #dfdfff;\n}\n\n.diff-emphasize-local .diff-local-remove {\n  background-color: #ffdfdf;\n  --grist-diff-background-color: #ffdfdf;\n}\n\n.diff-emphasize-local .diff-parent,\n.diff-emphasize-local .diff-remote-remove {\n  background-color: #dfdfdf;\n  --grist-diff-background-color: #dfdfdf;\n}\n\n.diff-common {\n  color: var(--grist-theme-text);\n  --grist-diff-color: var(--grist-theme-text);\n}\n"
  },
  {
    "path": "app/client/components/viewCommon.js",
    "content": "/* global $ */\n\nvar koDom = require(\"../lib/koDom\");\n\n/**\n * This adds `.isFlex` option to JQuery's $.ui.resizable to make it work better with flexbox.\n * Specifically, when resizing to the left, JQuery adjusts both `width` and `left` properties. If\n * the element is part of a flexbox, it's wrong to adjust `left`. This widget adds `.isFlex`\n * option: when set to true, the `left` (also `top`) adjustments get ignored.\n */\nvar _respectSize = $.ui.resizable.prototype._respectSize;\n$.ui.resizable.prototype._respectSize = function() {\n  var data = _respectSize.apply(this, arguments);\n  if (this.options.isFlex) {\n    console.log(\"Ignoring left, top\");\n    data.left = data.top = undefined;\n  }\n  return data;\n};\n\n/**\n * When used as an argument to dom() function, makes the containing element resizable, with the\n * size written into the given observable. If the observable has a .save() method, it's called\n * by default when the resize is complete (to save the new size to the server).\n * @param {Object} options.enabled: An observable, a constant, or a function for a computed\n *      observable. The value is treated as a boolean, and determined whether resizable\n *      functionality is enabled.\n * @param {String} options.handles: Same as for jqueryui's `resizable`, e.g. 'e' to resize right\n *      edge (east), 'w' to resize left edge (west).\n * @param {Function} options.stop: Additional callback to call when resizing stops.\n * @param {Boolean} options.isFlex: If true, will avoid changing 'left' when resizing the left edge.\n * @param {Number} options.minWidth: The minimum width the element can be resized to.\n *      Defaults to 10 (JQuery default).\n * @param {Boolean} options.shouldSave: Whether .save() on `widthObservable` should be called.\n *      Defaults to true.\n */\nfunction makeResizable(widthObservable, options) {\n  options = options || {};\n  function onEvent(e, ui) {\n    widthObservable(ui.size.width);\n    if (e.type === \"resizestop\") {\n      if (options.stop) {\n        options.stop(e, ui);\n      }\n      if (widthObservable.save && options.shouldSave !== false) {\n        widthObservable.save();\n      }\n    }\n  }\n\n  return function(elem) {\n    $(elem).resizable({\n      handles: options.handles || \"e\",\n      resize: onEvent,\n      stop: onEvent,\n      isFlex: options.isFlex,\n      minWidth: options.minWidth || 10\n    });\n\n    if (options.hasOwnProperty(\"enabled\")) {\n      koDom.setBinding(elem, options.enabled, function(elem, value) {\n        if (value) {\n          $(elem).resizable(\"enable\");\n        } else {\n          $(elem).resizable(\"disable\").removeClass(\"ui-state-disabled\");\n        }\n      });\n    }\n  };\n}\nexports.makeResizable = makeResizable;\n"
  },
  {
    "path": "app/client/declarations.d.ts",
    "content": "declare module \"app/client/components/AceEditor\";\ndeclare module \"app/client/components/CodeEditorPanel\";\ndeclare module \"app/client/lib/Mousetrap\";\ndeclare module \"app/client/lib/dom\";\ndeclare module \"app/client/lib/koDom\";\ndeclare module \"app/client/lib/koForm\";\n\ndeclare module \"app/client/components/RecordLayout\" {\n  import { Disposable } from \"app/client/lib/dispose\";\n\n  namespace RecordLayout {\n    interface NewField {\n      isNewField: true;\n      colRef: number;\n      label: string;\n      value: string;\n    }\n  }\n\n  class RecordLayout extends Disposable {\n    public static create(...args: any[]): any;\n\n    public isEditingLayout: ko.Observable<boolean>;\n    public editIndex: ko.Observable<number>;\n    public layoutEditor: ko.Observable<unknown>;\n\n    public getContainingRow(elem: Element, optContainer?: Element): DataRowModel;\n    public getContainingField(elem: Element, optContainer?: Element): ViewFieldRec;\n    public editLayout(rowIndex: number): void;\n    // FIXME: DataRowModel is unresolved.\n    // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents\n    public buildLayoutDom(row: DataRowModel | undefined, optCreateEditor?: boolean): HTMLElement;\n  }\n  export = RecordLayout;\n}\n\ndeclare module \"app/client/components/ViewConfigTab\" {\n  import { GristDoc } from \"app/client/components/GristDoc\";\n  import { Disposable } from \"app/client/lib/dispose\";\n  import { KoArray } from \"app/client/lib/koArray\";\n  import { ColumnRec, ViewRec, ViewSectionRec } from \"app/client/models/DocModel\";\n\n  import { DomArg, DomContents } from \"grainjs\";\n\n  namespace ViewConfigTab {\n    interface ViewSectionData {\n      section: ViewSectionRec;\n      hiddenFields: KoArray<ColumnRec>;\n    }\n  }\n\n  class ViewConfigTab extends Disposable {\n    constructor(options: { gristDoc: GristDoc, viewModel: ViewRec });\n    public buildSortFilterDom(): DomContents;\n    /**\n     * @deprecated On-demand tables where deprecated as of 2025-05-01.\n     */\n    public _buildAdvancedSettingsDom(): DomArg;\n    // TODO: these should be made private or renamed.\n    public _buildThemeDom(): DomArg;\n    public _buildChartConfigDom(): DomContents;\n    public _buildLayoutDom(): DomArg;\n    public _buildCustomTypeItems(): DomArg;\n  }\n  export = ViewConfigTab;\n}\n\ndeclare module \"app/client/models/BaseRowModel\" {\n  import { Disposable } from \"app/client/lib/dispose\";\n  import TableModel from \"app/client/models/TableModel\";\n  import { ColValues } from \"app/common/DocActions\";\n\n  namespace BaseRowModel {}\n  class BaseRowModel extends Disposable {\n    public id: ko.Computed<number>;\n    public _index: ko.Observable<number | null>;\n    public _table: TableModel;\n    protected _rowId: number | \"new\" | null;\n    protected _fields: string[];\n    public getRowId(): number;\n    public updateColValues(colValues: ColValues): Promise<void>;\n  }\n  export = BaseRowModel;\n}\n\ndeclare module \"app/client/models/MetaRowModel\" {\n  import BaseRowModel from \"app/client/models/BaseRowModel\";\n  import { ColValues } from \"app/common/DocActions\";\n  import { SchemaTypes } from \"app/common/schema\";\n\n  type NPartial<T> = {\n    [P in keyof T]?: T[P] | null;\n  };\n  type Values<T> = T extends keyof SchemaTypes ? NPartial<SchemaTypes[T]> : ColValues;\n\n  namespace MetaRowModel {}\n  class MetaRowModel<TName extends (keyof SchemaTypes) | undefined = undefined> extends BaseRowModel {\n    public _isDeleted: ko.Observable<boolean>;\n    public events: { trigger: (key: string) => void };\n    public updateColValues(colValues: Values<TName>): Promise<void>;\n  }\n  export = MetaRowModel;\n}\n\ndeclare module \"app/client/models/modelUtil\" {\n  interface SaveInterface<T> {\n    saveOnly(value: T): Promise<void>;\n    save(): Promise<void>;\n    setAndSave(value: T): Promise<void>;\n    setAndSaveOrRevert(value: T): Promise<void>;\n  }\n\n  type KoSaveableObservable<T> = ko.Observable<T> & SaveInterface<T>;\n  type KoSaveableComputed<T> = ko.Computed<T> & SaveInterface<T>;\n\n  interface CustomComputed<T> extends KoSaveableComputed<T> {\n    isSaved: ko.Computed<boolean>;\n    revert(): void;\n  }\n\n  function addSaveInterface<T>(\n    obs: ko.Observable<T> | ko.Computed<T>,\n    saveFunc: (value: T) => Promise<void>): KoSaveableObservable<T>;\n\n  interface ObjObservable<T extends object> extends ko.Observable<T> {\n    update(obj: T): void;\n    prop<Key extends keyof T>(propName: Key): KoSaveableObservable<T[Key]>;\n  }\n\n  interface SaveableObjObservable<T extends object> extends ko.Observable<T>, SaveInterface<T> {\n    update(obj: T): void;\n    prop<Key extends keyof T>(propName: Key): KoSaveableObservable<T[Key]>;\n  }\n\n  function objObservable<T>(obs: ko.KoSaveableObservable<T>): SaveableObjObservable<T>;\n  function objObservable<T>(obs: ko.Observable<T>): ObjObservable<T>;\n  function jsonObservable(obs: KoSaveableObservable<string | undefined>,\n    modifierFunc?: any, optContext?: any): SaveableObjObservable<any>;\n  function jsonObservable(obs: ko.Observable<string> | ko.Computed<string>,\n    modifierFunc?: any, optContext?: any): ObjObservable<any>;\n\n  function fieldWithDefault<T>(fieldObs: KoSaveableObservable<T | undefined>, defaultOrFunc: T | (() => T)):\n  KoSaveableObservable<T>;\n\n  function customValue<T>(obs: KoSaveableObservable<T>): CustomComputed<T>;\n\n  function savingComputed<T>(options: {\n    read: () => T,\n    write: (setter: (obs: ko.Observable<T | undefined>, val: T) => void, val: T) => void;\n  }): KoSaveableObservable<T>;\n\n  function customComputed<T>(options: {\n    read: () => T,\n    save?: (val: T) => Promise<void>;\n  }): CustomComputed<T>;\n\n  function setSaveValue<T>(obs: KoSaveableObservable<T>, val: T): Promise<void>;\n}\n\ndeclare module \"app/client/models/TableModel\" {\n  import { DocModel } from \"app/client/models/DocModel\";\n  import { RowGrouping, RowSource } from \"app/client/models/rowset\";\n  import { TableData } from \"app/client/models/TableData\";\n  import { CellValue, UserAction } from \"app/common/DocActions\";\n\n  namespace TableModel {}\n  class TableModel extends RowSource {\n    public docModel: DocModel;\n    public tableData: TableData;\n    public isLoaded: ko.Observable<boolean>;\n\n    constructor(docModel: DocModel, tableData: TableData);\n    public fetch(force?: boolean): Promise<void>;\n    public getAllRows(): readonly number[];\n    public getNumRows(): number;\n    public getRowGrouping(groupByCol: string): RowGrouping<CellValue>;\n    public sendTableActions(actions: UserAction[], optDesc?: string): Promise<any[]>;\n    public sendTableAction(action: UserAction, optDesc?: string): Promise<any> | undefined;\n    public getExtraRows?(): ExtraRows;\n  }\n  export = TableModel;\n}\n\ndeclare module \"app/client/models/MetaTableModel\" {\n  import { KoArray } from \"app/client/lib/koArray\";\n  import { DocModel } from \"app/client/models/DocModel\";\n  import MetaRowModel from \"app/client/models/MetaRowModel\";\n  import { RowSource } from \"app/client/models/rowset\";\n  import { TableData } from \"app/client/models/TableData\";\n  import TableModel from \"app/client/models/TableModel\";\n  import { CellValue } from \"app/common/DocActions\";\n\n  namespace MetaTableModel {}\n  class MetaTableModel<RowModel extends MetaRowModel> extends TableModel {\n    public rowModels: RowModel[];\n\n    constructor(docModel: DocModel, tableData: TableData, fields: string[], rowConstructor: (dm: DocModel) => void);\n    public loadData(): void;\n    public getRowModel(rowId: number, dependOnVersion?: boolean): RowModel;\n    public getEmptyRowModel(): RowModel;\n    public createFloatingRowModel(rowIdObs: ko.Observable<number> | ko.Computed<number>): RowModel;\n    public createRowGroupModel(groupValue: CellValue, options: { groupBy: string, sortBy: string }): KoArray<RowModel>;\n    public createAllRowsModel(sortColId: string): KoArray<RowModel>;\n    public _createRowSetModel(rowSource: RowSource, sortColId: string): KoArray<RowModel>;\n  }\n  export = MetaTableModel;\n}\n\ndeclare module \"app/client/models/DataTableModel\" {\n  import { KoArray } from \"app/client/lib/koArray\";\n  import { DocModel, TableRec } from \"app/client/models/DocModel\";\n  import { TableQuerySets } from \"app/client/models/QuerySet\";\n  import { SortedRowSet } from \"app/client/models/rowset\";\n  import { TableData } from \"app/client/models/TableData\";\n  import TableModel from \"app/client/models/TableModel\";\n  import { UIRowId } from \"app/common/UIRowId\";\n\n  namespace DataTableModel {\n    interface LazyArrayModel<T> extends KoArray<T | null> {\n      getRowId(index: number): UIRowId;\n      getRowIndex(rowId: UIRowId): number;\n      getRowIndexWithSub(rowId: UIRowId): number;\n      getRowModel(rowId: UIRowId): T | undefined;\n      setFloatingRowModel(rowModel: T, index: number | null): void;\n    }\n  }\n\n  class DataTableModel extends TableModel {\n    public tableMetaRow: TableRec;\n    public tableQuerySets: TableQuerySets;\n\n    constructor(docModel: DocModel, tableData: TableData, tableMetaRow: TableRec);\n    public createLazyRowsModel(sortedRowSet: SortedRowSet, optRowModelClass?: any):\n    DataTableModel.LazyArrayModel<DataRowModel>;\n    public createFloatingRowModel(optRowModelClass?: any): DataRowModel;\n  }\n  export = DataTableModel;\n}\n\ndeclare module \"app/client/lib/koUtil\" {\n  export interface ComputedWithKoUtils<T> extends ko.Computed<T> {\n    onlyNotifyUnequal(): this;\n    previousOnUndefined(): this;\n  }\n  export interface ObservableWithKoUtils<T> extends ko.Observable<T> {\n    assign(value: unknown): this;\n  }\n  export function withKoUtils<T>(computed: ko.Computed<T>): ComputedWithKoUtils<T>;\n  export function withKoUtils<T>(computed: ko.Observable<T>): ObservableWithKoUtils<T>;\n  export function computedBuilder(callback: any, optContext: any): any;\n  export function observableWithDefault(obs: any, defaultOrFunc: any, optContext?: any): any;\n  export function computedAutoDispose(optionsOrReadFunc: any, target: any, options: any): any;\n}\n\n// Used in browser check.  Bowser does in fact have types, but not the bundled version\n// with polyfills for old browsers.\ndeclare module \"bowser/bundled\";\ndeclare module \"randomcolor\";\n\ninterface Location {\n  // We use reload(true) in places, which has an effect in Firefox, but may be more of a\n  // historical accident than an intentional choice.\n  reload(forceGet?: boolean): void;\n}\n\ninterface JQuery {\n  datepicker(options: unknown): JQuery;\n  resizable(options?: ResizableOptions): JQuery;\n  resizable(method: string): JQuery;\n}\n\ninterface ResizableOptions {\n  disabled?: boolean;\n  handles?: \"n\" | \"e\" | \"s\" | \"w\" | \"ne\" | \"se\" | \"sw\" | \"nw\" | \"all\";\n  minHeight?: number;\n  minWidth?: number;\n  maxHeight?: number;\n  maxWidth?: number;\n  start?: (event: JQuery.MouseBaseEvent, ui: JQueryUI) => void,\n  resize?: (event: JQuery.MouseBaseEvent, ui: JQueryUI) => void,\n  stop?: (event: JQuery.MouseBaseEvent, ui: JQueryUI) => void,\n}\n\ninterface JQueryUI {\n  element: JQuery;\n  helper: JQuery;\n  originalElement: JQuery;\n  originalPosition: Position;\n  originalSize: Size;\n  position: Position;\n  size: Size;\n}\n\ninterface Position {\n  left: number;\n  top: number;\n}\n\ninterface Size {\n  width: number;\n  height: number;\n}\n"
  },
  {
    "path": "app/client/errorMain.ts",
    "content": "import { createAppPage } from \"app/client/ui/createAppPage\";\nimport { createErrPage } from \"app/client/ui/errorPages\";\n\ncreateAppPage(appModel => createErrPage(appModel));\n"
  },
  {
    "path": "app/client/exposeModulesForTests.js",
    "content": "/* global window */\n\n// These modules are exposed for the sake of browser tests.\nObject.assign(window.exposedModules, {\n  dom: require(\"./lib/dom\"),\n  grainjs: require(\"grainjs\"),\n  ko: require(\"knockout\"),\n  moment: require(\"moment-timezone\"),\n  Comm: require(\"app/client/components/Comm\"),\n  loadScript: require(\"./lib/loadScript\"),\n  ConnectState: require(\"./models/ConnectState\"),\n});\n"
  },
  {
    "path": "app/client/formMain.ts",
    "content": "import { createPage } from \"app/client/ui/createPage\";\nimport { FormPage } from \"app/client/ui/FormPage\";\n\nimport { dom } from \"grainjs\";\n\ncreatePage(() => {\n  document.documentElement.setAttribute(\"data-grist-form\", \"\");\n  return dom.create(FormPage);\n}, { disableTheme: true });\n"
  },
  {
    "path": "app/client/lib/ACIndex.ts",
    "content": "/**\n * A search index for auto-complete suggestions.\n *\n * This implementation indexes words, and suggests items based on a best-match score, including\n * amount of overlap and position of words. It searches case-insensitively and only at the start\n * of words. E.g. searching for \"Blue\" would match \"Blu\" in \"Lavender Blush\", but searching for\n * \"lush\" would only match the \"L\" in \"Lavender\".\n */\n\nimport { localeCompare, nativeCompare, sortedIndex } from \"app/common/gutil\";\n\nimport { DomContents } from \"grainjs\";\nimport deburr from \"lodash/deburr\";\nimport escapeRegExp from \"lodash/escapeRegExp\";\nimport split from \"lodash/split\";\n\nexport interface ACItem {\n  // This should be a trimmed lowercase version of the item's text. It may be an accessor.\n  cleanText: string;\n}\n\n// Returns a trimmed, lowercase version of a string,\n// from which accents and other diacritics have been removed,\n// so that autocomplete is case- and accent-insensitive.\nexport function normalizeText(text: string): string {\n  return deburr(text).trim().toLowerCase();\n}\n\n// Regexp used to split text into words; includes nearly all punctuation. This means that\n// \"foo-bar\" may be searched by \"bar\", but it's impossible to search for punctuation itself (e.g.\n// \"a-b\" and \"a+b\" are not distinguished). (It's easy to exclude unicode punctuation too if the\n// need arises, see https://stackoverflow.com/a/25575009/328565).\nconst wordSepRegexp = /[\\s!\"#$%&'()*+,\\-./:;<=>?@[\\\\\\]^_`{|}~]+/;\n\n/**\n * An auto-complete index, which simply allows searching for a string.\n */\nexport interface ACIndex<Item extends ACItem> {\n  search(searchText: string): ACResults<Item>;\n}\n\n// Splits text into an array of pieces, with odd-indexed pieces being the ones to highlight.\nexport type HighlightFunc = (text: string) => string[];\n\nexport const highlightNone: HighlightFunc = text => [text];\n\n/**\n * AutoComplete results include the suggested items, which one to highlight, and a function for\n * highlighting the matched portion of each item.\n */\nexport interface ACResults<Item extends ACItem> {\n  /** Matching items in order from best match to worst. */\n  items: Item[];\n\n  /** Additional items to show (e.g. the \"Add New\" item, for Choice and Reference fields). */\n  extraItems: Item[];\n\n  /** May be used to highlight matches using buildHighlightedDom(). */\n  highlightFunc: HighlightFunc;\n\n  /** index of a good match (normally 0), or -1 if no great match */\n  selectIndex: number;\n}\n\ninterface Word {\n  word: string;     // The indexed word\n  index: number;    // Index into _allItems for the item containing this word.\n  pos: number;      // Position of the word within the item where it occurred.\n}\n\nexport interface ACIndexOptions {\n  /** The max number of items to suggest. Defaults to 50. */\n  maxResults?: number;\n  /**\n   * Suggested matches in the same relative order as items, rather than by score.\n   *\n   * Defaults to false.\n   */\n  keepOrder?: boolean;\n  /** Show items with an empty `cleanText`. Defaults to false. */\n  showEmptyItems?: boolean;\n}\n\n/**\n * Implements a search index. It doesn't currently support updates; when any values change, the\n * index needs to be rebuilt from scratch.\n */\nexport class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {\n  private _allItems: Item[];\n\n  // All words from _allItems, sorted.\n  private _words: Word[];\n\n  private _maxResults = this._options.maxResults ?? 50;\n  private _keepOrder = this._options.keepOrder ?? false;\n  private _showEmptyItems = this._options.showEmptyItems ?? false;\n\n  public get totalItems() {\n    return this._allItems.length;\n  }\n\n  // Creates an index for the given list of items.\n  constructor(items: Item[], private _options: ACIndexOptions = {}) {\n    this._allItems = items.slice(0);\n\n    // Collects [word, occurrence, position] tuples for all words in _allItems.\n    const allWords: Word[] = [];\n    for (let index = 0; index < this._allItems.length; index++) {\n      const item = this._allItems[index];\n      const words = item.cleanText.split(wordSepRegexp).filter(w => w);\n      for (let pos = 0; pos < words.length; pos++) {\n        allWords.push({ word: words[pos], index, pos });\n      }\n    }\n\n    allWords.sort((a, b) => localeCompare(a.word, b.word));\n    this._words = allWords;\n  }\n\n  // The main search function. SearchText will be cleaned (trimmed and lowercased) at the start.\n  // Empty search text returns the first N items in the search universe.\n  public search(searchText: string): ACResults<Item> {\n    const cleanedSearchText = normalizeText(searchText);\n    const searchWords = cleanedSearchText.split(wordSepRegexp).filter(w => w);\n\n    // Maps item index in _allItems to its score.\n    const myMatches = new Map<number, number>();\n\n    if (searchWords.length > 0) {\n      // For each of searchWords, go through items with an overlap, and update their scores.\n      for (let k = 0; k < searchWords.length; k++) {\n        const searchWord = searchWords[k];\n        for (const [itemIndex, score] of this._findOverlaps(searchWord, k)) {\n          myMatches.set(itemIndex, (myMatches.get(itemIndex) || 0) + score);\n        }\n      }\n\n      // Give an extra point to items that start with the searchText.\n      for (const [itemIndex, score] of myMatches) {\n        if (this._allItems[itemIndex].cleanText.startsWith(cleanedSearchText)) {\n          myMatches.set(itemIndex, score + 1);\n        }\n      }\n    }\n\n    // Array of pairs [itemIndex, score], sorted by score (desc) and itemIndex.\n    const sortedMatches = Array.from(myMatches)\n      .sort((a, b) => nativeCompare(b[1], a[1]) || nativeCompare(a[0], b[0]))\n      .slice(0, this._maxResults);\n\n    const itemIndices: number[] = sortedMatches.map(([index, score]) => index);\n\n    // Append enough non-matching indices to reach maxResults.\n    for (let i = 0; i < this._allItems.length && itemIndices.length < this._maxResults; i++) {\n      if (myMatches.has(i)) { continue; }\n\n      if (this._allItems[i].cleanText || this._showEmptyItems) {\n        itemIndices.push(i);\n      }\n    }\n\n    if (this._keepOrder) {\n      itemIndices.sort(nativeCompare);\n    }\n    const items = itemIndices.map(index => this._allItems[index]);\n\n    if (!cleanedSearchText) {\n      // In this case we are just returning the first few items.\n      return { items, extraItems: [], highlightFunc: highlightNone, selectIndex: -1 };\n    }\n\n    const highlightFunc = highlightMatches.bind(null, searchWords);\n\n    // If we have a best match, and any word in it actually starts with the search text, report it\n    // as a default selection for highlighting. Otherwise, no item will be auto-selected.\n    let selectIndex = sortedMatches.length > 0 ? itemIndices.indexOf(sortedMatches[0][0]) : -1;\n    if (selectIndex >= 0 && !startsWithText(items[selectIndex], cleanedSearchText, searchWords)) {\n      selectIndex = -1;\n    }\n    return { items, extraItems: [], highlightFunc, selectIndex };\n  }\n\n  /**\n   * Given one of the search words, looks it up in the indexed list of words and searches up and\n   * down the list for all words that share a prefix with it. Each such word contributes something\n   * to the score of the index entry it is a part of.\n   *\n   * Returns a Map from the index entry (index into _allItems) to the score which this searchWord\n   * contributes to it.\n   *\n   * The searchWordPos argument is the position of searchWord in the overall search text (e.g. 0\n   * if it's the first word). It is used for the position bonus, to give higher scores to entries\n   * whose words occur in the same order as in the search text.\n   */\n  private _findOverlaps(searchWord: string, searchWordPos: number): Map<number, number> {\n    const insertIndex = sortedIndex<{ word: string }>(this._words, { word: searchWord },\n      (a, b) => localeCompare(a.word, b.word));\n\n    // Maps index of item to its score.\n    const scored = new Map<number, number>();\n\n    // Search up and down the list, accepting smaller and smaller overlap.\n    for (const step of [1, -1]) {\n      let prefix = searchWord;\n      let index = insertIndex + (step > 0 ? 0 : -1);\n      while (prefix && index >= 0 && index < this._words.length) {\n        for (; index >= 0 && index < this._words.length; index += step) {\n          const wordEntry = this._words[index];\n          // Once we reach a word that doesn't start with our prefix, break this loop, so we can\n          // reduce the length of the prefix and keep scanning.\n          if (!wordEntry.word.startsWith(prefix)) { break; }\n\n          // The contribution of this word's to the score consists primarily of the length of\n          // overlap (i.e. length for the current prefix).\n          const baseScore = prefix.length;\n\n          // To this we add 1 if the word matches exactly.\n          const fullWordBonus = (wordEntry.word === searchWord ? 1 : 0);\n\n          // To prefer matches where words occur in the same order as searched (e.g. searching for\n          // \"Foo B\" should prefer \"Foo Bar\" over \"Bar Foo\"), we give a bonus based on the\n          // position of the word in the search text and the entry text. (If positions match as\n          // 0:0 and 1:1, the total position bonus is 2^0+2^(-2)=1.25; while the bonus from 0:1\n          // and 1:0 would be 2^(-1) + 2^(-1)=1.0.)\n          const positionBonus = Math.pow(2, -(searchWordPos + wordEntry.pos));\n\n          const itemScore = baseScore + fullWordBonus + positionBonus;\n          // Each search word contributes only one score (e.g. a search for \"Foo\" will partially\n          // match both words in \"forty five\", but only the higher of the matches will count).\n          if (itemScore >= (scored.get(wordEntry.index) || 0)) {\n            scored.set(wordEntry.index, itemScore);\n          }\n        }\n        prefix = prefix.slice(0, -1);\n      }\n    }\n    return scored;\n  }\n}\n\nexport type BuildHighlightFunc = (match: string) => DomContents;\n\n/**\n * Converts text to DOM with matching bits of text rendered using highlight(match) function.\n */\nexport function buildHighlightedDom(\n  text: string, highlightFunc: HighlightFunc, highlight: BuildHighlightFunc,\n): DomContents {\n  if (!text) { return text; }\n  const parts = highlightFunc(text);\n  return parts.map((part, k) => k % 2 ? highlight(part) : part);\n}\n\n// Same as wordSepRegexp, but with capturing parentheses.\nconst wordSepRegexpParen = new RegExp(`(${wordSepRegexp.source})`);\n\n/**\n * Splits text into pieces, with odd-numbered pieces the ones matching a prefix of some\n * searchWord, i.e. the ones to highlight.\n */\nfunction highlightMatches(searchWords: string[], text: string): string[] {\n  const textParts = text.split(wordSepRegexpParen);\n  const outputs = [\"\"];\n  for (let i = 0; i < textParts.length; i += 2) {\n    const word = textParts[i];\n    const separator = textParts[i + 1] || \"\";\n    // deburr (remove diacritics) was used to produce searchWords, so `word` needs to match that.\n    const prefixLen = findLongestPrefixLen(deburr(word).toLowerCase(), searchWords);\n    if (prefixLen === 0) {\n      outputs[outputs.length - 1] += word + separator;\n    } else {\n      // Split into unicode 'characters' that keep diacritics combined\n      const chars = split(word, \"\");\n      outputs.push(\n        chars.slice(0, prefixLen).join(\"\"),\n        chars.slice(prefixLen).join(\"\") + separator,\n      );\n    }\n  }\n  return outputs;\n}\n\nfunction findLongestPrefixLen(text: string, choices: string[]): number {\n  return choices.reduce((max, choice) => Math.max(max, findCommonPrefixLength(text, choice)), 0);\n}\n\nfunction findCommonPrefixLength(text1: string, text2: string): number {\n  let i = 0;\n  while (i < text1.length && text1[i] === text2[i]) { ++i; }\n  return i;\n}\n\n/**\n * Checks whether `item` starts with `text`, or whether all words of text are prefixes of the\n * words of `item`. (E.g. it would return true if item is \"New York\", and text is \"ne yo\".)\n */\nfunction startsWithText(item: ACItem, text: string, searchWords: string[]): boolean {\n  if (item.cleanText.startsWith(text)) { return true; }\n\n  const regexp = new RegExp(searchWords.map(w => `\\\\b` + escapeRegExp(w)).join(\".*\"));\n  const cleanText = item.cleanText.split(wordSepRegexp).join(\" \");\n  return regexp.test(cleanText);\n}\n"
  },
  {
    "path": "app/client/lib/ACSelect.ts",
    "content": "import { ACIndex, ACItem, buildHighlightedDom } from \"app/client/lib/ACIndex\";\nimport { Autocomplete, IAutocompleteOptions } from \"app/client/lib/autocomplete\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\n\nimport { dom, DomElementArg, Holder, IDisposableOwner, Observable, styled } from \"grainjs\";\n\nexport interface ACSelectItem extends ACItem {\n  value: string;\n  label: string;\n}\n\n/**\n * Builds a text input with an autocomplete dropdown.\n * Note that because it is currently only used in the right-side panel, it is designed to avoid\n * keeping focus.\n */\nexport function buildACSelect(\n  owner: IDisposableOwner,\n  options: {\n    disabled?: Observable<boolean>,\n    acIndex: ACIndex<ACSelectItem>,\n    valueObs: Observable<string>,\n    save: (value: string, item: ACSelectItem | undefined) => Promise<void> | void\n  },\n  ...args: DomElementArg[]\n) {\n  const { acIndex, valueObs, save } = options;\n  const acHolder = Holder.create<Autocomplete<ACSelectItem>>(owner);\n  let textInput: HTMLInputElement;\n\n  const isOpen = () => !acHolder.isEmpty();\n  const acOpen = () => acHolder.isEmpty() && Autocomplete.create(acHolder, textInput, acOptions);\n  const acClose = () => acHolder.clear();\n  const finish = () => { acClose(); textInput.blur(); };\n  const revert = () => { textInput.value = valueObs.get(); finish(); };\n  const commitOrRevert = async () => {\n    const isValid = await commitIfValid();\n    if (!isValid) {\n      revert();\n    }\n  };\n  const openOrCommit = () => {\n    if (isOpen()) {\n      commitOrRevert().catch(() => {});\n    } else {\n      acOpen();\n    }\n  };\n\n  const commitIfValid = async () => {\n    const item = acHolder.get()?.getSelectedItem();\n    if (item) {\n      textInput.value = item.value;\n    }\n    textInput.disabled = true;\n    try {\n      await save(textInput.value, item);\n      finish();\n      return true;\n    } catch (e) {\n      return false;\n    } finally {\n      textInput.disabled = false;\n    }\n  };\n\n  const onMouseDown = (ev: MouseEvent) => {\n    ev.preventDefault();    // Don't let it affect focus, since we focus/blur manually.\n    if (options.disabled?.get()) {\n      return;\n    }\n    if (!isOpen()) { textInput.focus(); }\n    openOrCommit();\n  };\n\n  const acOptions: IAutocompleteOptions<ACSelectItem> = {\n    menuCssClass: `${menuCssClass} test-acselect-dropdown`,\n    search: async (term: string) => acIndex.search(term),\n    renderItem: (item, highlightFunc) =>\n      cssSelectItem(buildHighlightedDom(item.label, highlightFunc, cssMatchText)),\n    getItemText: item => item.value,\n    onClick: commitIfValid,\n  };\n\n  return cssSelectBtn(\n    textInput = cssInput({ type: \"text\" },\n      dom.prop(\"value\", valueObs),\n      dom.on(\"focus\", (ev, elem) => elem.select()),\n      dom.on(\"blur\", commitOrRevert),\n      dom.prop(\"disabled\", use => options.disabled ? use(options.disabled) : false),\n      dom.onKeyDown({\n        Escape: revert,\n        Enter: openOrCommit,\n        ArrowDown: acOpen,\n        Tab: commitIfValid,\n      }),\n      dom.on(\"input\", acOpen),\n    ),\n    dom.on(\"mousedown\", onMouseDown),\n    cssIcon(\"Dropdown\"),\n    ...args,\n  );\n}\n\nconst cssSelectBtn = styled(\"div\", `\n  position: relative;\n  width: 100%;\n  height: 30px;\n  color: ${theme.selectButtonFg};\n  --icon-color: ${theme.selectButtonFg};\n`);\n\nexport const cssSelectItem = styled(\"li\", `\n  color: ${theme.menuItemFg};\n  display: block;\n  white-space: pre;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  outline: none;\n  padding: var(--weaseljs-menu-item-padding, 8px 24px);\n  cursor: pointer;\n\n  &.selected {\n    background-color: ${theme.menuItemSelectedBg};\n    color:            ${theme.menuItemSelectedFg};\n  }\n`);\n\nconst cssInput = styled(\"input\", `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  appearance: none;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  height: 100%;\n  width: 100%;\n  padding: 0 6px;\n  outline: none;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  cursor: pointer;\n  line-height: 16px;\n  cursor: pointer;\n\n  &:disabled {\n    color: ${theme.inputDisabledFg};\n    background-color: ${theme.inputDisabledBg};\n  }\n  &:focus {\n    cursor: initial;\n    outline: none;\n    box-shadow: 0px 0px 2px 2px ${theme.inputFocus};\n  }\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\nconst cssIcon = styled(icon, `\n  position: absolute;\n  right: 6px;\n  top: calc(50% - 8px);\n`);\n\nconst cssMatchText = styled(\"span\", `\n  color: ${theme.autocompleteMatchText};\n  .selected > & {\n    color: ${theme.autocompleteSelectedMatchText};\n  }\n`);\n"
  },
  {
    "path": "app/client/lib/ACUserManager.ts",
    "content": "import { ACIndex, ACItem, ACResults, buildHighlightedDom, normalizeText } from \"app/client/lib/ACIndex\";\nimport { cssSelectItem } from \"app/client/lib/ACSelect\";\nimport { Autocomplete, IAutocompleteOptions } from \"app/client/lib/autocomplete\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { createUserImage, cssUserImage } from \"app/client/ui/UserImage\";\nimport {\n  cssEmailInput,\n  cssEmailInputContainer,\n  cssMailIcon,\n  cssMemberImage,\n  cssMemberListItem,\n  cssMemberPrimary,\n  cssMemberSecondary,\n  cssMemberText,\n} from \"app/client/ui/UserItem\";\nimport { colors, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Computed, computed, dom, DomElementArg, Holder, IDisposableOwner, Observable, styled } from \"grainjs\";\nimport { cssMenuItem } from \"popweasel\";\n\nconst t = makeT(\"ACUserManager\");\n\nexport interface ACUserItem extends ACItem {\n  value: string;\n  label: string;\n  name: string;\n  email: string;\n  id: number;\n  picture?: string | null; // when present, a url to a public image of unspecified dimensions.\n  isNew?: boolean;\n}\n\nexport function buildACMemberEmail(\n  owner: IDisposableOwner,\n  options: {\n    acIndex: ACIndex<ACUserItem>;\n    emailObs: Observable<string>;\n    save: (value: string) => void;\n    prompt?: { email: string },\n  },\n  ...args: DomElementArg[]\n) {\n  const { acIndex, emailObs, save, prompt } = options;\n  const acHolder = Holder.create<Autocomplete<ACUserItem>>(owner);\n  let emailInput: HTMLInputElement;\n\n  const isValid = Observable.create(owner, true);\n\n  const isOpen = () => !acHolder.isEmpty();\n  const acOpen = () => acHolder.isEmpty() && Autocomplete.create(acHolder, emailInput, acOptions);\n  const acClose = () => acHolder.clear();\n  const finish = () => {\n    acClose();\n    emailObs.set(\"\");\n    emailInput.value = emailObs.get();\n    emailInput.focus();\n  };\n  const onEnter = () => {\n    if (isOpen()) {\n      commitIfValid();\n    } else {\n      acOpen();\n    }\n  };\n\n  const commitIfValid = () => {\n    const item = acHolder.get()?.getSelectedItem();\n    if (item) {\n      emailObs.set(item.value);\n    }\n    emailInput.setCustomValidity(\"\");\n    isValid.set(emailInput.checkValidity());\n\n    const selectedEmail = item?.value || emailObs.get();\n    try {\n      if (selectedEmail && isValid.get()) {\n        save(emailObs.get());\n        finish();\n      }\n    } catch (e) {\n      emailInput.setCustomValidity(e.message);\n    } finally {\n      emailInput.reportValidity();\n    }\n  };\n\n  const maybeShowAddNew = async (results: ACResults<ACUserItem>, text: string): Promise<ACResults<ACUserItem>> => {\n    const cleanText = normalizeText(text);\n    const items = results.items\n      .filter(item => item.cleanText.includes(cleanText))\n      .sort((a, b) => a.cleanText.localeCompare(b.cleanText));\n    results.items = items;\n    if (!results.items.length && cleanText) {\n      const newObject = {\n        value: text,\n        cleanText,\n        name: \"\",\n        email: \"\",\n        isNew: true,\n        label: text,\n        id: 0,\n      };\n      results.extraItems.push(newObject);\n    }\n    return results;\n  };\n\n  const renderSearchItem = (item: ACUserItem, highlightFunc: any): HTMLLIElement => (item?.isNew ? cssSelectItem(\n    cssMemberListItem(\n      cssUserImagePlus(\n        cssPlusIcon(\"Plus\"),\n        cssUserImage.cls(\"-large\"),\n        cssUserImagePlus.cls(\"-invalid\", use => !use(enableAdd),\n        )),\n      cssMemberText(\n        cssMemberPrimaryPlus(t(\"Invite new member\")),\n        getGristConfig().notifierEnabled ? cssMemberSecondaryPlus(\n          dom.text(use => t(\"We'll email an invite to {{email}}\", { email: use(emailObs) })),\n        ) : null,\n      ),\n      testId(\"um-add-email\"),\n    ),\n  ) : cssSelectItem(\n    cssMemberListItem(\n      cssMemberImage(createUserImage(item, \"large\")),\n      cssMemberText(\n        cssMemberPrimaryPlus(item.name, testId(\"um-member-name\")),\n        cssMemberSecondaryPlus(buildHighlightedDom(item.label, highlightFunc, cssMatchText)),\n      ),\n    ),\n  ));\n\n  const enableAdd: Computed<boolean> = computed(use => Boolean(use(emailObs) && use(isValid)));\n\n  const acOptions: IAutocompleteOptions<ACUserItem> = {\n    attach: null,\n    menuCssClass: `${menuCssClass} test-acselect-dropdown`,\n    search: term => maybeShowAddNew(acIndex.search(term), term),\n    renderItem: renderSearchItem,\n    getItemText: item => item.value,\n    onClick: commitIfValid,\n  };\n\n  const result = cssEmailInputContainer(\n    cssMailIcon(\"Mail\"),\n    (emailInput = cssEmailInput(\n      emailObs,\n      { onInput: true, isValid },\n      { type: \"email\", placeholder: t(\"Enter email address\") },\n      dom.on(\"input\", acOpen),\n      dom.on(\"focus\", acOpen),\n      dom.on(\"click\", acOpen),\n      dom.on(\"blur\", acClose),\n      dom.onKeyDown({\n        Escape: finish,\n        Enter: onEnter,\n        ArrowDown: acOpen,\n        Tab: commitIfValid,\n      }),\n    )),\n    cssEmailInputContainer.cls(\"-green\", enableAdd),\n    ...args,\n  );\n\n  // Reset custom validity that we sometimes set.\n  owner.autoDispose(emailObs.addListener(() => emailInput.setCustomValidity(\"\")));\n\n  if (prompt) { setTimeout(() => emailInput.focus(), 0); }\n\n  return result;\n}\n\nconst cssMemberPrimaryPlus = styled(cssMemberPrimary, `\n  .${cssSelectItem.className}.selected & {\n    color: ${theme.menuItemSelectedFg};\n  }\n`);\n\nconst cssMemberSecondaryPlus = styled(cssMemberSecondary, `\n  .${cssSelectItem.className}.selected & {\n    color: ${theme.menuItemSelectedFg};\n  }\n`);\n\nconst cssMatchText = styled(\"span\", `\n  color: ${theme.autocompleteMatchText};\n  .${cssSelectItem.className}.selected & {\n    color: ${theme.autocompleteSelectedMatchText};\n  }\n`);\n\nconst cssUserImagePlus = styled(cssUserImage, `\n  background-color: ${colors.lightGreen};\n  margin: auto 0;\n\n  &-invalid {\n    background-color: ${colors.mediumGrey};\n  }\n\n  .${cssMenuItem.className}-sel & {\n    background-color: ${theme.menuItemIconSelectedFg};\n    color: ${theme.menuItemSelectedBg};\n  }\n`);\n\nconst cssPlusIcon = styled(icon, `\n  width: 20px;\n  height: 20px;\n`);\n"
  },
  {
    "path": "app/client/lib/BoxSpec.ts",
    "content": "import { Layout } from \"app/client/components/Layout\";\n\nimport { dom } from \"grainjs\";\nimport * as _ from \"underscore\";\n\nexport interface BoxSpec {\n  leaf?: string | number;\n  size?: number;\n  children?: BoxSpec[];\n  collapsed?: BoxSpec[];\n}\n\nexport function purgeBoxSpec(options: {\n  spec: BoxSpec;\n  validLeafIds: number[];\n  restoreCollapsed?: boolean;\n}): BoxSpec {\n  const { spec, validLeafIds, restoreCollapsed } = options;\n  // We use tmpLayout as a way to manipulate the layout before we get a final spec from it.\n  const tmpLayout = Layout.create(spec, () => dom(\"div\"), true);\n  const specFieldIds = tmpLayout.getAllLeafIds();\n\n  // For any stale fields (no longer among validLeafIds), remove them from tmpLayout.\n  _.difference(specFieldIds, validLeafIds).forEach(function(leafId: string | number) {\n    tmpLayout.getLeafBox(leafId)?.dispose();\n  });\n\n  // For all fields that should be in the spec but aren't, add them to tmpLayout. We maintain a\n  // two-column layout, so add a new row, or a second box to the last row if it's a leaf.\n  const missingLeafs = _.difference(validLeafIds, specFieldIds);\n  const collapsedLeafs = new Set((spec.collapsed || []).map(c => c.leaf));\n  missingLeafs.forEach(function(leafId: any) {\n    // Omit collapsed leafs from the spec.\n    if (!collapsedLeafs.has(leafId)) {\n      addToSpec(tmpLayout, leafId);\n    }\n  });\n\n  const newSpec = tmpLayout.getLayoutSpec();\n\n  // Restore collapsed state, omitting any leafs that are no longer valid.\n  if (spec.collapsed && restoreCollapsed) {\n    newSpec.collapsed = spec.collapsed.filter(c => c.leaf && validLeafIds.includes(c.leaf as number));\n  }\n\n  tmpLayout.dispose();\n  return newSpec;\n}\n\nfunction addToSpec(tmpLayout: Layout, leafId: number) {\n  const newBox = tmpLayout.buildLayoutBox({ leaf: leafId });\n  const root = tmpLayout.rootBox();\n  if (!root || root.isDisposed()) {\n    tmpLayout.setRoot(newBox);\n    return newBox;\n  }\n  const rows = root.childBoxes.peek();\n  const lastRow = rows[rows.length - 1];\n  if (rows.length >= 1 && lastRow.isLeaf()) {\n    // Add a new child to the last row.\n    lastRow.addChild(newBox, true);\n  } else {\n    // Add a new row.\n    tmpLayout.rootBox()!.addChild(newBox, true);\n  }\n  return newBox;\n}\n"
  },
  {
    "path": "app/client/lib/CellDiffTool.ts",
    "content": "import { isVersions } from \"app/common/gristTypes\";\nimport { BaseFormatter } from \"app/common/ValueFormatter\";\nimport { CellValue } from \"app/plugin/GristData\";\n\nimport { Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch as DiffMatchPatch } from \"diff-match-patch\";\n\nexport class CellDiffTool {\n  private _diffTool = new DiffMatchPatch();\n\n  /**\n   * Given the cell value and the formatter, construct a list of fragments in\n   * diff-match-patch format expressing the difference between versions.\n   * The format is a list of [CODE, STRING] pairs, where the possible values of\n   * CODE are:\n   *   -1  -- meaning DELETION of the parent value.\n   *    0  -- meaning text common to all versions.\n   *    1  -- meaning INSERTION of the remote value.\n   *    2  -- meaning INSERTION of the local value.\n   *\n   * When a change is made only locally or remotely, then the list returned may\n   * include common text, deletions and insertions in any order.\n   *\n   * When a change is made both locally and remotely, the list returned does not\n   * include any common text, but just reports the parent value, then the local value,\n   * then the remote value.  This may be optimized in future.\n   */\n  public prepareCellDiff(value: CellValue, formatter: BaseFormatter): Diff[] {\n    if (!isVersions(value)) {\n      // This can happen for reference columns, where the diff widget is\n      // selected on the basis of one column, but we are displaying the\n      // content of another.  We have more version information for the\n      // reference column than for its display column.\n      return [[DIFF_EQUAL, formatter.formatAny(value)]];\n    }\n    const versions = value[1];\n    if (!(\"local\" in versions)) {\n      // Change was made remotely only.\n      return this._prepareTextDiff(\n        formatter.formatAny(versions.parent),\n        formatter.formatAny(versions.remote));\n    } else if (!(\"remote\" in versions)) {\n      // Change was made locally only.\n      return this._prepareTextDiff(\n        formatter.formatAny(versions.parent),\n        formatter.formatAny(versions.local))\n        .map(([code, txt]) => [code === DIFF_INSERT ? DIFF_LOCAL : code, txt]);\n    }\n    // Change was made both locally and remotely.\n    return [[DIFF_DELETE, formatter.formatAny(versions.parent)],\n      [DIFF_LOCAL, formatter.formatAny(versions.local)],\n      [DIFF_INSERT, formatter.formatAny(versions.remote)]];\n  }\n\n  // Run diff-match-patch on the text, do its cleanup, and then some extra\n  // ad-hoc cleanup of our own.  Diffs are hard to read if they are too\n  // \"choppy\".\n  private _prepareTextDiff(txt1: string, txt2: string): Diff[] {\n    const diffs = this._diffTool.diff_main(txt1, txt2);\n    this._diffTool.diff_cleanupSemantic(diffs);\n    if (diffs.length > 2 && this._notDiffWorthy(txt1, diffs.length) &&\n      this._notDiffWorthy(txt2, diffs.length)) {\n      return [[DIFF_DELETE, txt1], [DIFF_INSERT, txt2]];\n    }\n    if (diffs.length === 1 && diffs[0][0] === DIFF_DELETE) {\n      // Add an empty set symbol, since otherwise it will be ambiguous\n      // whether the deletion was done locally or remotely.\n      diffs.push([1, \"\\u2205\"]);\n    }\n    return diffs;\n  }\n\n  // Heuristic for whether to show common parts of versions, or to treat them\n  // as entirely distinct.\n  private _notDiffWorthy(txt: string, parts: number) {\n    return txt.length < 5 * parts || this._isMostlyNumeric(txt);\n  }\n\n  // Check is text has a lot of numeric content.\n  private _isMostlyNumeric(txt: string) {\n    return [...txt].filter(c => c >= \"0\" && c <= \"9\").length > txt.length / 2;\n  }\n}\n\n// A constant marking text fragments present locally but not in parent (or remote).\n// Must be distinct from DiffMatchPatch.DIFF_* constants (-1, 0, 1).\nexport const DIFF_LOCAL = 2;\n"
  },
  {
    "path": "app/client/lib/CustomSectionElement.ts",
    "content": "import { SafeBrowser, ViewProcess } from \"app/client/lib/SafeBrowser\";\nimport { PluginInstance } from \"app/common/PluginInstance\";\n\nexport { ViewProcess } from \"app/client/lib/SafeBrowser\";\n\n/**\n * A PluginCustomSection identifies one custom section in a plugin.\n */\nexport interface PluginCustomSection {\n  pluginId: string;\n  sectionId: string;\n}\n\nexport class CustomSectionElement {\n  /**\n   * Get the list of all available custom sections in all plugins' contributions.\n   */\n  public static getSections(plugins: PluginInstance[]): PluginCustomSection[] {\n    return plugins.reduce<PluginCustomSection[]>((acc, plugin) => {\n      const customSections = plugin.definition.manifest.contributions.customSections;\n      const pluginId = plugin.definition.id;\n      if (customSections) {\n        // collect identifiers\n        const sectionIds = customSections.map(section => ({ sectionId: section.name, pluginId }));\n        // concat to the accumulator\n        return acc.concat(sectionIds);\n      }\n      return acc;\n    }, []);\n  }\n\n  /**\n   * Find a section matching sectionName in the plugin instances' constributions and returns\n   * it. Returns `undefined` if not found.\n   */\n  public static find(plugin: PluginInstance, sectionName: string): ViewProcess | undefined {\n    const customSections = plugin.definition.manifest.contributions.customSections;\n    if (customSections) {\n      const section = customSections.find(({ name }) => name === sectionName);\n      if (section) {\n        const safeBrowser = plugin.safeBrowser as SafeBrowser;\n        return safeBrowser.createViewProcess(section.path);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/lib/Delay.ts",
    "content": "/**\n * A little class to make it easier to work with setTimeout/clearTimeout when it may need to get\n * cancelled or rescheduled.\n */\n\nimport { Disposable } from \"app/client/lib/dispose\";\n\nexport class Delay extends Disposable {\n  /**\n   * Returns a function which will schedule a call to cb(), forwarding the arguments.\n   * This is a static method that may be used without a Delay object.\n   * E.g. wrapWithDelay(10, cb)(1,2,3) will call cb(1,2,3) in 10ms.\n   */\n  public static wrapWithDelay(ms: number, cb: (this: void, ...args: any[]) => any,\n    optContext?: any): (...args: any[]) => void;\n  public static wrapWithDelay<T>(ms: number, cb: (this: T, ...args: any[]) => any,\n    optContext: T): (...args: any[]) => void {\n    return function(this: any, ...args: any[]) {\n      const ctx = optContext || this;\n      setTimeout(() => cb.apply(ctx, args), ms);\n    };\n  }\n\n  /**\n   * Returns a wrapped callback whose execution is delayed until the next animation frame. The\n   * returned callback may be disposed to cancel the delayed execution.\n   */\n  public static untilAnimationFrame(cb: (this: void, ...args: any[]) => void,\n    optContext?: any): DisposableCB;\n  public static untilAnimationFrame<T>(cb: (this: T, ...args: any[]) => void,\n    optContext: T): DisposableCB {\n    let reqId: number | null = null;\n    const f = function(...args: any[]) {\n      if (reqId === null) {\n        reqId = window.requestAnimationFrame(() => {\n          reqId = null;\n          cb.apply(optContext, args);\n        });\n      }\n    };\n    f.dispose = function() {\n      if (reqId !== null) {\n        window.cancelAnimationFrame(reqId);\n      }\n    };\n    return f;\n  }\n\n  private _timeoutId: ReturnType<typeof setTimeout> | null = null;\n\n  public create() {\n    this.autoDisposeCallback(this.cancel);\n  }\n\n  /**\n   * If there is a scheduled callback, clear it.\n   */\n  public cancel() {\n    if (this._timeoutId !== null) {\n      clearTimeout(this._timeoutId);\n      this._timeoutId = null;\n    }\n  }\n\n  /**\n   * Returns whether there is a scheduled callback.\n   */\n  public isPending() {\n    return this._timeoutId !== null;\n  }\n\n  /**\n   * Schedule a new callback, to be called in ms milliseconds, optionally bound to the passed-in\n   * arguments. If another callback was scheduled, it is cleared first.\n   */\n\n  public schedule(ms: number, cb: (this: void, ...args: any[]) => any, optContext?: any, ...optArgs: any[]): void;\n  public schedule<T>(ms: number, cb: (this: T, ...args: any[]) => any, optContext: T, ...optArgs: any[]): void {\n    this.cancel();\n    this._timeoutId = setTimeout(() => {\n      this._timeoutId = null;\n      cb.apply(optContext, optArgs);\n    }, ms);\n  }\n}\n\nexport interface DisposableCB {\n  (...args: any[]): void;\n  dispose(): void;\n}\n"
  },
  {
    "path": "app/client/lib/DocPluginManager.ts",
    "content": "import { ClientScope } from \"app/client/components/ClientScope\";\nimport { SafeBrowser } from \"app/client/lib/SafeBrowser\";\nimport { ActiveDocAPI } from \"app/common/ActiveDocAPI\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { createRpcLogger, PluginInstance } from \"app/common/PluginInstance\";\n\nimport { Rpc } from \"grain-rpc\";\n\n/**\n * DocPluginManager's Client side implementation.\n */\nexport class DocPluginManager {\n  public pluginsList: PluginInstance[];\n\n  private _clientScope = this._options.clientScope;\n  private _docComm = this._options.docComm;\n  private _localPlugins = this._options.plugins;\n  private _untrustedContentOrigin = this._options.untrustedContentOrigin;\n\n  constructor(private _options: {\n    plugins: LocalPlugin[],\n    untrustedContentOrigin: string,\n    docComm: ActiveDocAPI,\n    clientScope: ClientScope,\n  }) {\n    this.pluginsList = [];\n    for (const plugin of this._localPlugins) {\n      try {\n        const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `PLUGIN ${plugin.id}:`));\n        const components = plugin.manifest.components || {};\n        const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser({\n          pluginInstance,\n          clientScope: this._clientScope,\n          untrustedContentOrigin: this._untrustedContentOrigin,\n          mainPath: components.safeBrowser,\n        });\n        if (components.safeBrowser) {\n          pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);\n        }\n\n        // Forward calls to the server, if no matching forwarder.\n        pluginInstance.rpc.registerForwarder(\"*\", {\n          forwardCall: call => this._docComm.forwardPluginRpc(plugin.id, call),\n          forwardMessage: msg => this._docComm.forwardPluginRpc(plugin.id, msg),\n        });\n        this.pluginsList.push(pluginInstance);\n      } catch (err) {\n        console.error(`DocPluginManager: failed to instantiate ${plugin.id}: ${err.message}`);\n      }\n    }\n  }\n\n  /**\n   * `receiveAction` handles an action received from the server by forwarding it to all safe browser component.\n   */\n  public receiveAction(action: any[]) {\n    for (const plugin of this.pluginsList) {\n      const safeBrowser = plugin.safeBrowser as SafeBrowser;\n      if (safeBrowser) {\n        safeBrowser.receiveAction(action);\n      }\n    }\n  }\n\n  /**\n   * Make an Rpc object to call server methods from a url-flavored custom view.\n   */\n  public makeAnonForwarder() {\n    const rpc = new Rpc({});\n    rpc.queueOutgoingUntilReadyMessage();\n    rpc.registerForwarder(\"*\", {\n      forwardCall: call => this._docComm.forwardPluginRpc(\"builtIn/core\", call),\n      forwardMessage: msg => this._docComm.forwardPluginRpc(\"builtIn/core\", msg),\n    });\n    return rpc;\n  }\n}\n"
  },
  {
    "path": "app/client/lib/DocSchemaImport.ts",
    "content": "import { tablesToSchema } from \"app/common/DocSchemaImport\";\nimport { ExistingDocSchema } from \"app/common/DocSchemaImportTypes\";\nimport { DocAPI } from \"app/common/UserAPI\";\n\nexport async function getExistingDocSchema(docApi: DocAPI): Promise<ExistingDocSchema> {\n  const { tables } = await docApi.getTables({ expand: [\"column\"] });\n  return tablesToSchema(tables);\n}\n"
  },
  {
    "path": "app/client/lib/FocusLayer.ts",
    "content": "/**\n * FocusLayer addresses the issue of where focus goes \"by default\". In most of Grist operation,\n * the focus is on the special Clipboard element to support typing into cells, and copy-pasting.\n * When a modal is open, the focus is on the modal.\n *\n * When the focus moves to some specific element such as a textbox or a dropdown menu, the\n * FocusLayerManager will watch for this element to lose focus or to get disposed, and will\n * restore focus to the default element.\n */\nimport * as Mousetrap from \"app/client/lib/Mousetrap\";\nimport { arrayRemove } from \"app/common/gutil\";\nimport { RefCountMap } from \"app/common/RefCountMap\";\n\nimport { Disposable, dom, DomMethod } from \"grainjs\";\n\n/**\n * The default focus is organized into layers. A layer determines when focus should move to the\n * default element, and what that element should be. Only the top (most recently created) layer is\n * active at any given time.\n */\nexport interface FocusLayerOptions {\n  // The default element that should have focus while this layer is active.\n  defaultFocusElem: HTMLElement;\n\n  // When true for an element, that element may hold focus even while this layer is active.\n  // Defaults to any element except document.body.\n  allowFocus?: (elem: Element) => boolean;\n\n  // If set, pause mousetrap keyboard shortcuts while this FocusLayer is active. Without it, arrow\n  // keys will navigate in a grid underneath this layer, and Enter may open a cell there.\n  pauseMousetrap?: boolean;\n\n  // Called when the defaultFocusElem gets focused.\n  onDefaultFocus?: () => void;\n\n  // Called when the defaultFocusElem gets blurred.\n  onDefaultBlur?: () => void;\n}\n\n// Use RefCountMap to have a reference-counted instance of the global FocusLayerManager. It will\n// be active as long as at least one FocusLayer is active (i.e. not disposed).\nconst _focusLayerManager = new RefCountMap<null, FocusLayerManager>({\n  create: key => FocusLayerManager.create(null),\n  dispose: (key, value) => value.dispose(),\n  gracePeriodMs: 10,\n});\n\n/**\n * The FocusLayerManager implements the functionality, using the top (most recently created) layer\n * to determine when and to what to move focus.\n */\nclass FocusLayerManager extends Disposable {\n  private _timeoutId: ReturnType<typeof setTimeout> | null = null;\n  private _focusLayers: FocusLayer[] = [];\n\n  constructor() {\n    super();\n\n    const grabFocus = this.grabFocus.bind(this);\n\n    this.autoDispose(dom.onElem(window, \"focus\", grabFocus));\n    this.grabFocus();\n\n    // The following block of code deals with what happens when the window is in the background.\n    // When it is, focus and blur events are unreliable, and we'll watch explicitly for events which\n    // may cause a change in focus. These wouldn't happen normally for a background window, but do\n    // happen in Selenium Webdriver testing.\n    function setBackgroundCapture(onOff: boolean) {\n      const addRemove = onOff ? window.addEventListener : window.removeEventListener;\n      // Note the third argument useCapture=true, which lets us notice these events before other\n      // code that might call .stopPropagation on them.\n      addRemove.call(window, \"click\", grabFocus, true);\n      addRemove.call(window, \"mousedown\", grabFocus, true);\n      addRemove.call(window, \"keydown\", grabFocus, true);\n    }\n    this.autoDispose(dom.onElem(window, \"blur\", setBackgroundCapture.bind(null, true)));\n    this.autoDispose(dom.onElem(window, \"focus\", setBackgroundCapture.bind(null, false)));\n    setBackgroundCapture(!document.hasFocus());\n  }\n\n  public addLayer(layer: FocusLayer) {\n    this.getCurrentLayer()?.onDefaultBlur();\n    this._focusLayers.push(layer);\n    // Move the focus to the new layer. Not just grabFocus, because if the focus is on the previous\n    // layer's defaultFocusElem, the new layer might consider it \"allowed\" and never get the focus.\n    setTimeout(() => layer.defaultFocusElem.focus({ preventScroll: true }), 0);\n  }\n\n  public removeLayer(layer: FocusLayer) {\n    arrayRemove(this._focusLayers, layer);\n    // Give the remaining layers a chance to check focus.\n    this.grabFocus();\n  }\n\n  public getCurrentLayer(): FocusLayer | undefined {\n    return this._focusLayers[this._focusLayers.length - 1];\n  }\n\n  /**\n   * Select the default focus element, or wait until the current element loses focus.\n   */\n  public grabFocus() {\n    if (!this._timeoutId) {\n      this._timeoutId = setTimeout(() => this._doGrabFocus(), 0);\n    }\n  }\n\n  private _doGrabFocus() {\n    if (this.isDisposed()) { return; }\n    this._timeoutId = null;\n    const layer = this.getCurrentLayer();\n    if (!layer || document.activeElement === layer.defaultFocusElem) {\n      layer?.onDefaultFocus();\n      return;\n    }\n    // If the window doesn't have focus, don't rush to grab it, or we can interfere with focus\n    // outside the frame when embedded. We'll grab focus when setBackgroundCapture tells us to.\n    if (!document.hasFocus()) {\n      return;\n    }\n    if (document.activeElement && layer.allowFocus(document.activeElement)) {\n      watchElementForBlur(document.activeElement, () => this.grabFocus());\n      layer.onDefaultBlur();\n    } else {\n      layer.defaultFocusElem.focus({ preventScroll: true });\n      layer.onDefaultFocus();\n    }\n  }\n}\n\n/**\n * An individual FocusLayer determines where focus should default to while this layer is active.\n */\nexport class FocusLayer extends Disposable implements FocusLayerOptions {\n  // FocusLayer.grabFocus() allows triggering the focus check manually.\n  public static grabFocus() {\n    _focusLayerManager.get(null)?.grabFocus();\n  }\n\n  /**\n   * Creates a new FocusLayer and attaches it to the given element. The layer will be disposed\n   * automatically when the element is removed from the DOM.\n   */\n  public static attach(options: Partial<FocusLayerOptions>): DomMethod<HTMLElement> {\n    return (element: HTMLElement) => {\n      const layer = FocusLayer.create(null, { defaultFocusElem: element, ...options });\n      dom.autoDisposeElem(element, layer);\n    };\n  }\n\n  public defaultFocusElem: HTMLElement;\n  public allowFocus: (elem: Element) => boolean;\n  public _onDefaultFocus?: () => void;\n  public _onDefaultBlur?: () => void;\n  private _isDefaultFocused: boolean | null = null;\n\n  constructor(options: FocusLayerOptions) {\n    super();\n    this.defaultFocusElem = options.defaultFocusElem;\n    this.allowFocus = options.allowFocus || (elem => elem !== document.body);\n    this._onDefaultFocus = options.onDefaultFocus;\n    this._onDefaultBlur = options.onDefaultBlur;\n\n    // Make sure the element has a tabIndex attribute, to make it focusable.\n    if (!this.defaultFocusElem.hasAttribute(\"tabindex\")) {\n      this.defaultFocusElem.setAttribute(\"tabindex\", \"-1\");\n    }\n\n    if (options.pauseMousetrap) {\n      Mousetrap.setPaused(true);\n      this.onDispose(() => Mousetrap.setPaused(false));\n    }\n\n    const managerRefCount = this.autoDispose(_focusLayerManager.use(null));\n    const manager = managerRefCount.get();\n    manager.addLayer(this);\n    this.onDispose(() => manager.removeLayer(this));\n    this.autoDispose(dom.onElem(this.defaultFocusElem, \"blur\", () => manager.grabFocus()));\n  }\n\n  public onDefaultFocus() {\n    // Only trigger onDefaultFocus() callback when the focus status actually changed.\n    if (this._isDefaultFocused) { return; }\n    this._isDefaultFocused = true;\n    this._onDefaultFocus?.();\n  }\n\n  public onDefaultBlur() {\n    // Only trigger onDefaultBlur() callback when the focus status actually changed.\n    if (this._isDefaultFocused === false) { return; }\n    this._isDefaultFocused = false;\n    this._onDefaultBlur?.();\n  }\n}\n\n/**\n * Helper to watch a focused element to lose focus, at which point callback() will get called.\n * Because elements getting removed from the DOM don't always trigger 'blur' event, this also\n * uses MutationObserver to watch for the element to get removed from DOM.\n */\nexport function watchElementForBlur(elem: Element, callback: () => void) {\n  const maybeDone = () => {\n    if (document.activeElement !== elem) {\n      lis.dispose();\n      observer.disconnect();\n      callback();\n    }\n  };\n  const lis = dom.onElem(elem, \"blur\", maybeDone);\n\n  // Watch for the removal of elem by observing the childList of all its ancestors.\n  // (Just guessing that it is more efficient than watching document.body with {subtree: true}).\n  const observer = new MutationObserver(maybeDone);\n  let parent = elem.parentNode;\n  while (parent) {\n    observer.observe(parent, { childList: true });\n    parent = parent.parentNode;\n  }\n}\n"
  },
  {
    "path": "app/client/lib/GristWindow.ts",
    "content": "/**\n * Some client-side code sets global properties (on the global Window object). This isn't a\n * great practice, and should normally be avoided. But on occasion it's used to simplify testing,\n * make debugging easier, and initialization (e.g. gristConfig).\n *\n * This file collects most of the properties we use, for typings and visibility.\n */\nimport {\n  AirtableImportOptions,\n} from \"app/client/lib/airtable/AirtableImporter\";\n\nimport type { TopAppModel } from \"app/client/models/AppModel\";\nimport type { DocPageModel } from \"app/client/models/DocPageModel\";\nimport type { GristLoadConfig } from \"app/common/gristUrls\";\nimport type { TestState } from \"app/common/TestState\";\n\ndeclare global {\n  export interface Window {\n    $?: JQueryStatic;    // Some old code still uses JQuery events.\n    gristConfig?: GristLoadConfig;\n    gristNotify?: (message: string) => void;\n    getAppErrors?: () => string[];\n    gristDocPageModel?: DocPageModel;\n    gristApp?: {\n      topAppModel?: TopAppModel;\n      testNumPendingApiRequests?: () => number;\n    };\n    cmd?: { [name: string]: () => void };\n    isRunningUnderElectron?: boolean;\n    resetDismissedPopups?: (seen?: boolean) => void;\n    resetOnboarding?: () => void;\n    gristAirtableImport?: (\n      apiKey: string, base: string, options: AirtableImportOptions,\n    ) => Promise<any>;\n    testGrist?: Partial<TestState>;\n  }\n}\n"
  },
  {
    "path": "app/client/lib/HomePluginManager.ts",
    "content": "import { ClientScope } from \"app/client/components/ClientScope\";\nimport { SafeBrowser } from \"app/client/lib/SafeBrowser\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { createRpcLogger, PluginInstance } from \"app/common/PluginInstance\";\n\n/**\n * Home plugins are all plugins that contributes to a general Grist management tasks.\n * They operate on Grist as a whole, without current document context.\n * TODO: currently it is used primary for importing documents on home screen and supports\n * only safeBrowser components without any access to Grist.\n */\nexport class HomePluginManager {\n  public pluginsList: PluginInstance[];\n\n  constructor(options: {\n    localPlugins: LocalPlugin[],\n    untrustedContentOrigin: string,\n    clientScope: ClientScope,\n  }) {\n    const { localPlugins, untrustedContentOrigin, clientScope } = options;\n    this.pluginsList = [];\n    for (const plugin of localPlugins) {\n      try {\n        const components = plugin.manifest.components || {};\n        // Home plugins supports only safeBrowser components\n        if (components.safePython || components.unsafeNode) {\n          continue;\n        }\n        // and currently implements only safe imports\n        const importSources = plugin.manifest.contributions.importSources;\n        if (!importSources?.some(i => i.safeHome)) {\n          continue;\n        }\n        const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `HOME PLUGIN ${plugin.id}:`));\n        const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser({\n          pluginInstance,\n          clientScope,\n          untrustedContentOrigin,\n          mainPath: components.safeBrowser,\n        });\n        if (components.safeBrowser) {\n          pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);\n        }\n        const forwarder = new NotAvailableForwarder();\n        // Block any calls to internal apis.\n        pluginInstance.rpc.registerForwarder(\"*\", {\n          forwardCall: call => forwarder.forwardPluginRpc(plugin.id, call),\n          forwardMessage: msg => forwarder.forwardPluginRpc(plugin.id, msg),\n        });\n        this.pluginsList.push(pluginInstance);\n      } catch (err) {\n        console.error(`HomePluginManager: failed to instantiate ${plugin.id}: ${err.message}`);\n      }\n    }\n  }\n}\n\nclass NotAvailableForwarder {\n  public async forwardPluginRpc(pluginId: string, msg: any) {\n    throw new Error(\"This api is not available\");\n  }\n}\n"
  },
  {
    "path": "app/client/lib/ImportSourceElement.ts",
    "content": "import { PluginInstance } from \"app/common/PluginInstance\";\nimport { InternalImportSourceAPI } from \"app/plugin/InternalImportSourceAPI\";\nimport { ImportSource } from \"app/plugin/PluginManifest\";\nimport { checkers } from \"app/plugin/TypeCheckers\";\n\n/**\n * Encapsulate together an import source contribution with its plugin instance and a callable stub\n * for the ImportSourceAPI. Exposes as well a `fromArray` static method to get all the import\n * sources from an array of plugins instances.\n */\nexport class ImportSourceElement {\n  /**\n   * Get all import sources from an array of plugin instances.\n   */\n  public static fromArray(pluginInstances: PluginInstance[]): ImportSourceElement[] {\n    const importSources: ImportSourceElement[] = [];\n    for (const plugin of pluginInstances) {\n      const definitions = plugin.definition.manifest.contributions.importSources;\n      if (definitions) {\n        for (const importSource of definitions) {\n          importSources.push(new ImportSourceElement(plugin, importSource));\n        }\n      }\n    }\n    return importSources;\n  }\n\n  public importSourceStub: InternalImportSourceAPI;\n\n  private constructor(public plugin: PluginInstance, public importSource: ImportSource) {\n    this.importSourceStub = plugin.getStub<InternalImportSourceAPI>(importSource.importSource,\n      checkers.InternalImportSourceAPI);\n  }\n}\n"
  },
  {
    "path": "app/client/lib/Mousetrap.js",
    "content": "/**\n * This file adds some includes tweaks to the behavior of Mousetrap.js, the keyboard bindings\n * library. It exports the mousetrap library itself, so you may use it in mousetrap's place.\n */\n\n\n/* global document */\n\nif (typeof window === \"undefined\") {\n  // We can't require('mousetrap') in a browserless environment (specifically for unittests)\n  // because it uses global variables right on require, which are not available with jsdom.\n  // So to use mousetrap in unittests, we need to stub it out.\n  module.exports = {\n    bind: function() {},\n    unbind: function() {},\n  };\n} else {\n\n  var Mousetrap = require(\"mousetrap\");\n\n  // Minus is different on Gecko:\n  // see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode\n  // and https://github.com/ccampbell/mousetrap/pull/215\n  Mousetrap.addKeycodes({173: \"-\"});\n\n  var customStopCallbacks = new WeakMap();\n\n  var MousetrapProtype = Mousetrap.prototype;\n  var origStopCallback = MousetrapProtype.stopCallback;\n\n  var alwaysOnCallbacks = {};\n\n  /**\n   * Enhances Mousetrap's stopCallback filter. Normally, mousetrap ignores key events in input\n   * fields and textareas. This replacement allows individual CommandGroups to be activated in such\n   * elements. See also 'attach' method of commands.CommandGroup.\n   */\n  MousetrapProtype.stopCallback = function(e, element, combo, sequence) {\n    if (mousetrapBindingsPaused) {\n      return true;\n    }\n\n    // If the keyboard shortcut is meant to be always active, we never stop it.\n    if (alwaysOnCallbacks[combo] || alwaysOnCallbacks[sequence]) {\n      return false;\n    }\n\n    // If we have a custom stopCallback, use it now.\n    const custom = customStopCallbacks.get(element);\n    if (custom) {\n      return custom(combo);\n    }\n    try {\n      return origStopCallback.call(this, e, element, combo, sequence);\n    } catch (err) {\n      if (!document.body.contains(element)) {\n        // Mousetrap throws a pointless error in this case, which we ignore. It happens when\n        // element gets removed by a non-mousetrap keyboard handler.\n        return;\n      }\n      throw err;\n    }\n  };\n\n\n  var mousetrapBindingsPaused = false;\n\n  /**\n   * Globally pause or unpause mousetrap bindings. This is useful e.g. while a context menu is being\n   * shown, which has its own keyboard handling.\n   */\n  Mousetrap.setPaused = function(yesNo) {\n    mousetrapBindingsPaused = yesNo;\n  };\n\n  /**\n   * Set a custom stopCallback for an element. When a key combo is pressed for this element,\n   * callback(combo) is called. If it returns true, Mousetrap should NOT process the combo.\n   */\n  Mousetrap.setCustomStopCallback = function(element, callback) {\n    customStopCallbacks.set(element, callback);\n  };\n\n  Mousetrap.markAlwaysOnShortcut = function(combo) {\n    alwaysOnCallbacks[combo] = true;\n  };\n\n  module.exports = Mousetrap;\n}\n"
  },
  {
    "path": "app/client/lib/MultiUserManager.ts",
    "content": "import { IOrgMemberSelectOption, UserManagerModel } from \"app/client/models/UserManagerModel\";\nimport { textarea } from \"app/client/ui/inputs\";\nimport { bigBasicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { mediaXSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu, menuItem } from \"app/client/ui2018/menus\";\nimport { cssAnimatedModal, cssModalBody, cssModalButtons, cssModalTitle,\n  IModalControl, modal } from \"app/client/ui2018/modals\";\nimport { BasicRole, isBasicRole, NonGuestRole, VIEWER } from \"app/common/roles\";\n\nimport { computed, Computed, dom, DomElementArg, IDisposableOwner, Observable, styled } from \"grainjs\";\n\nfunction parseEmailList(emailListRaw: string): string[] {\n  return emailListRaw\n    .split(\"\\n\")\n    .map(email => email.trim().toLowerCase())\n    .filter(email => email !== \"\");\n}\n\nfunction validateEmail(email: string): boolean {\n  const mailformat = /\\S+@\\S+\\.\\S+/;\n  return mailformat.test(email);\n}\n\nexport function buildMultiUserManagerModal(\n  owner: IDisposableOwner,\n  model: UserManagerModel,\n  onAdd: (email: string, role: NonGuestRole) => void,\n) {\n  const emailListObs = Observable.create(owner, \"\");\n  const rolesObs = Observable.create<BasicRole>(owner, VIEWER);\n  const isValidObs = Observable.create(owner, true);\n\n  const enableAdd: Computed<boolean> = computed(\n    use => Boolean(use(emailListObs) && use(rolesObs) && use(isValidObs)),\n  );\n\n  const save = (ctl: IModalControl) => {\n    const emailList = parseEmailList(emailListObs.get());\n    const role = rolesObs.get();\n    if (emailList.some(email => !validateEmail(email))) {\n      isValidObs.set(false);\n    } else {\n      emailList.forEach(email => onAdd(email, role));\n      ctl.close();\n    }\n  };\n\n  return modal(ctl => [\n    { style: \"padding: 0;\" },\n    dom.cls(cssAnimatedModal.className),\n    cssTitle(\n      \"Invite Users\",\n      testId(\"um-header\"),\n    ),\n    cssModalBody(\n      cssUserManagerBody(\n        buildEmailsTextarea(emailListObs, isValidObs),\n        dom.maybe(use => !use(isValidObs), () => cssErrorMessage(\"At least one email is invalid\")),\n        cssInheritRoles(\n          dom(\"span\", \"Access: \"),\n          buildRolesSelect(rolesObs, model),\n        ),\n      ),\n    ),\n    cssModalButtons(\n      { style: \"margin: 32px 64px; display: flex;\" },\n      bigPrimaryButton(\"Confirm\",\n        dom.boolAttr(\"disabled\", use => !use(enableAdd)),\n        dom.on(\"click\", () => save(ctl)),\n        testId(\"um-confirm\"),\n      ),\n      bigBasicButton(\n        \"Cancel\",\n        dom.on(\"click\", () => ctl.close()),\n        testId(\"um-cancel\"),\n      ),\n    ),\n  ]);\n}\n\nfunction buildRolesSelect(\n  roleSelectedObs: Observable<BasicRole>,\n  model: UserManagerModel,\n) {\n  const allRoles = (model.isOrg ? model.orgUserSelectOptions : model.userSelectOptions)\n    .filter((x): x is { value: BasicRole, label: string } => isBasicRole(x.value));\n  return cssOptionBtn(\n    menu(() => [\n      dom.forEach(allRoles, _role =>\n        menuItem(() => roleSelectedObs.set(_role.value), _role.label,\n          testId(`um-role-option`),\n        ),\n      ),\n    ]),\n    dom.text((use) => {\n      // Get the label of the active role.\n      const activeRole = allRoles.find((_role: IOrgMemberSelectOption) => use(roleSelectedObs) === _role.value);\n      return activeRole ? activeRole.label : \"\";\n    }),\n    cssCollapseIcon(\"Collapse\"),\n    testId(\"um-role-select\"),\n  );\n}\n\nfunction buildEmailsTextarea(\n  emailListObs: Observable<string>,\n  isValidObs: Observable<boolean>,\n  ...args: DomElementArg[]\n) {\n  return cssTextarea(emailListObs,\n    { onInput: true, isValid: isValidObs },\n    { placeholder: \"Enter one email address per line\" },\n    dom.on(\"change\", _ev => isValidObs.set(true)),\n    ...args,\n  );\n}\n\nconst cssTitle = styled(cssModalTitle, `\n  margin: 40px 64px 0 64px;\n\n  @media ${mediaXSmall} {\n    & {\n      margin: 16px;\n    }\n  }\n`);\n\nconst cssInheritRoles = styled(\"span\", `\n  margin: 13px 63px 42px;\n`);\n\nconst cssErrorMessage = styled(\"span\", `\n  margin: 0 63px;\n  color: ${theme.errorText};\n`);\n\nconst cssOptionBtn = styled(\"span\", `\n  display: inline-flex;\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.controlFg};\n  cursor: pointer;\n`);\n\nconst cssCollapseIcon = styled(icon, `\n  margin-top: 1px;\n  background-color: ${theme.controlFg};\n`);\n\nconst cssAccessDetailsBody = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  width: 600px;\n  font-size: ${vars.mediumFontSize};\n`);\n\nconst cssUserManagerBody = styled(cssAccessDetailsBody, `\n  height: 374px;\n  border-bottom: 1px solid ${theme.modalBorderDark};\n`);\n\nconst cssTextarea = styled(textarea, `\n  margin: 16px 63px;\n  padding: 12px 10px;\n  border-radius: 3px;\n  resize: none;\n  border: 1px solid ${theme.inputBorder};\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  flex: 1 1 0;\n  font-size: ${vars.mediumFontSize};\n  font-family: ${vars.fontFamily};\n  outline: none;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/lib/ObservableMap.js",
    "content": "var ko      = require(\"knockout\");\n\nvar dispose = require(\"./dispose\");\n\n/**\n * ObservableMap provides a structure to keep track of values that need to recalculate in\n * response to a key change or a mapping function change.\n *\n * @example\n * let factor = ko.observable(2);\n * let myFunc = ko.computed(() => {\n *   let f = factor();\n *   return (keyId) => key * f;\n * });\n *\n * let myMap = ObservableMap.create(myFunc);\n * let inObs1 = ko.observable(2);\n * let inObs2 = ko.observable(3);\n *\n * let outObs1 = myMap.add(inObs1);\n * let outObs2 = myMap.add(inObs2);\n * outObs1(); // 4\n * outObs2(); // 6\n *\n * inObs1(5);\n * outObs1(); // 10\n *\n * factor(3);\n * outObs1(); // 15\n * outObs2(); // 9\n *\n *\n * @param {Function} mapFunc - Computed that returns a mapping function that takes in a key and\n *  returns a value. Whenever `mapFunc` is updated, all the current values in the map will be\n *  recalculated using the new function.\n */\nfunction ObservableMap(mapFunc) {\n  this.store = new Map();\n  this.mapFunc = mapFunc;\n\n  // Recalculate all values on changes to mapFunc\n  let mapFuncSub = mapFunc.subscribe(() => {\n    this.updateAll();\n  });\n\n  // Disposes all stored observable and clears the map.\n  this.autoDisposeCallback(() => {\n    // Unsbuscribe from mapping function\n    mapFuncSub.dispose();\n    // Clear the store\n    this.store.forEach((val, key) => val.forEach(obj => obj.dispose()));\n    this.store.clear();\n  });\n}\ndispose.makeDisposable(ObservableMap);\n\n/**\n * Takes an observable for the key value and returns an observable for the output.\n * Subscribes to the given observable so that whenever it changes the output observable is\n * updated to the value returned by `mapFunc` when provided the new key as input.\n * If user disposes of the returned observable, it will be removed from the map.\n *\n * @param {ko.observable} obsKey\n * @return {ko.observble} Observable value equal to `mapFunc(obsKey())` that will be updated on\n *  updates to `obsKey` and `mapFunc`.\n */\nObservableMap.prototype.add = function (obsKey) {\n  let currKey = obsKey();\n  let ret = ko.observable(this.mapFunc()(currKey));\n\n  // Add to map\n  this._addKeyValue(currKey, ret);\n\n  // Subscribe to changes to key\n  let subs = obsKey.subscribe(newKey => {\n    ret(this.mapFunc()(newKey));\n\n    if (currKey !== newKey) {\n      // If the key changed, add it to the new bucket and delete from the old one\n      this._addKeyValue(newKey, ret);\n      this._delete(currKey, ret);\n      // And update the key\n      currKey = newKey;\n    }\n  });\n  ret.dispose = () => {\n    // On dispose, delete from map unless the whole map is being disposed\n    if (!this.isDisposed()) {\n      this._delete(currKey, ret);\n    }\n    subs.dispose();\n  };\n\n  return ret;\n};\n\n/**\n * Returns the Set of observable values for the given key.\n */\nObservableMap.prototype.get = function (key) {\n  return this.store.get(key);\n};\n\nObservableMap.prototype._addKeyValue = function (key, value) {\n  if (!this.store.has(key)) {\n    this.store.set(key, new Set([value]));\n  } else {\n    this.store.get(key).add(value);\n  }\n};\n\n/**\n * Triggers an update for all keys.\n */\nObservableMap.prototype.updateAll = function () {\n  this.store.forEach((val, key) => this.updateKey(key));\n};\n\n/**\n * Triggers an update for all observables for given keys in the map.\n * @param {Array} keys\n */\nObservableMap.prototype.updateKeys = function (keys) {\n  keys.forEach(key => this.updateKey(key));\n};\n\n/**\n * Triggers an update for all observables for the given key in the map.\n * @param {Any} key\n */\nObservableMap.prototype.updateKey = function (key) {\n  if (this.store.has(key) && this.store.get(key).size > 0) {\n    this.store.get(key).forEach(obj => {\n      obj(this.mapFunc()(key));\n    });\n  }\n};\n\n/**\n * Given a key and an observable, deletes the observable from that key's bucket.\n *\n * @param {Any} key - Current value of the key.\n * @param {Any} obsValue - An observable previously returned by `add`.\n */\nObservableMap.prototype._delete = function (key, obsValue) {\n  if (this.store.has(key) && this.store.get(key).size > 0) {\n    this.store.get(key).delete(obsValue);\n    // Clean up empty buckets\n    if (this.store.get(key).size === 0) {\n      this.store.delete(key);\n    }\n  }\n};\n\nmodule.exports = ObservableMap;\n"
  },
  {
    "path": "app/client/lib/ObservableSet.js",
    "content": "var _ = require(\"underscore\");\nvar ko = require(\"knockout\");\nvar dispose = require(\"./dispose\");\n\n/**\n * An ObservableSet keeps track of a set of values whose membership is controlled by a boolean\n * observable.\n * @property {ko.observable<Number>} count: Count of items that are currently included.\n */\nfunction ObservableSet() {\n  this._items = {};\n  this.count = ko.observable(0);\n}\ndispose.makeDisposable(ObservableSet);\n\n/**\n * Adds an item to keep track of. The value is added to the set whenever isIncluded observable is\n * true. To stop keeping track of this item, call dispose() on the returned object.\n *\n * @param {ko.observable<Boolean>} isIncluded: observable for whether to include the value.\n * @param {Object} value: Arbitrary value. May be omitted if you only care about the count.\n * @return {Object} Object with dispose() method, which can be called to unsubscribe from\n *    isIncluded, and remove the value from the set.\n */\nObservableSet.prototype.add = function(isIncluded, value) {\n  var uniqueKey = _.uniqueId();\n  var sub = this.autoDispose(isIncluded.subscribe(function(include) {\n    if (include) {\n      this._add(uniqueKey, value);\n    } else {\n      this._remove(uniqueKey);\n    }\n  }, this));\n\n  if (isIncluded.peek()) {\n    this._add(uniqueKey, value);\n  }\n\n  return {\n    dispose: function() {\n      this._remove(uniqueKey);\n      this.disposeDiscard(sub);\n    }.bind(this)\n  };\n};\n\n/**\n * Returns an array of all the values that are currently included in the set.\n */\nObservableSet.prototype.all = function() {\n  return _.values(this._items);\n};\n\n/**\n * Internal helper to add a value to the set.\n */\nObservableSet.prototype._add = function(key, value) {\n  if (!this._items.hasOwnProperty(key)) {\n    this._items[key] = value;\n    this.count(this.count() + 1);\n  }\n};\n\n/**\n * Internal helper to remove a value from the set.\n */\nObservableSet.prototype._remove = function(key) {\n  if (this._items.hasOwnProperty(key)) {\n    delete this._items[key];\n    this.count(this.count() - 1);\n  }\n};\n\nmodule.exports = ObservableSet;\n"
  },
  {
    "path": "app/client/lib/ReferenceUtils.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { ACIndex, ACResults } from \"app/client/lib/ACIndex\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ICellItem } from \"app/client/models/ColumnACIndexes\";\nimport { ColumnCache } from \"app/client/models/ColumnCache\";\nimport { ColumnRec } from \"app/client/models/entities/ColumnRec\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { TableData } from \"app/client/models/TableData\";\nimport { getReferencedTableId, isRefListType } from \"app/common/gristTypes\";\nimport { EmptyRecordView } from \"app/common/RecordView\";\nimport { BaseFormatter } from \"app/common/ValueFormatter\";\n\nimport { Disposable, dom, Observable } from \"grainjs\";\n\nconst t = makeT(\"ReferenceUtils\");\n\n/**\n * Utilities for common operations involving Ref[List] fields.\n */\nexport class ReferenceUtils extends Disposable {\n  public readonly refTableId: string;\n  public readonly tableData: TableData;\n  public readonly visibleColFormatter: BaseFormatter;\n  public readonly visibleColModel: ColumnRec;\n  public readonly visibleColId: string;\n  public readonly isRefList: boolean;\n  public readonly hasDropdownCondition = Boolean(this.field.dropdownCondition.peek()?.text);\n\n  private readonly _columnCache: ColumnCache<ACIndex<ICellItem>>;\n  private readonly _docData = this._gristDoc.docData;\n  private _dropdownConditionError = Observable.create<string | null>(this, null);\n\n  constructor(public readonly field: ViewFieldRec, private readonly _gristDoc: GristDoc) {\n    super();\n\n    const colType = field.column().type();\n    const refTableId = getReferencedTableId(colType);\n    if (!refTableId) {\n      throw new Error(\"Non-Reference column of type \" + colType);\n    }\n    this.refTableId = refTableId;\n\n    const tableData = this._docData.getTable(refTableId);\n    if (!tableData) {\n      throw new Error(\"Invalid referenced table \" + refTableId);\n    }\n    this.tableData = tableData;\n\n    this.visibleColFormatter = field.visibleColFormatter();\n    this.visibleColModel = field.visibleColModel();\n    this.visibleColId = this.visibleColModel.colId() || \"id\";\n    this.isRefList = isRefListType(colType);\n\n    this._columnCache = new ColumnCache<ACIndex<ICellItem>>(this.tableData);\n  }\n\n  public idToText(value: unknown) {\n    if (typeof value === \"number\") {\n      return this.visibleColFormatter.formatAny(this.tableData.getValue(value, this.visibleColId));\n    }\n    return String(value || \"\");\n  }\n\n  /**\n   * Searches the autocomplete index for the given `text`, returning\n   * all matching results and related metadata.\n   *\n   * If a dropdown condition is set, results are dependent on the `rowId`\n   * that the autocomplete dropdown is open in. Otherwise, `rowId` has no\n   * effect.\n   */\n  public autocompleteSearch(text: string, rowId: number): ACResults<ICellItem> {\n    let acIndex: ACIndex<ICellItem>;\n    if (this.hasDropdownCondition) {\n      try {\n        acIndex = this._getDropdownConditionACIndex(rowId);\n      } catch (e) {\n        this._dropdownConditionError?.set(e);\n        return { items: [], extraItems: [], highlightFunc: () => [], selectIndex: -1 };\n      }\n    } else {\n      acIndex = this.tableData.columnACIndexes.getColACIndex(\n        this.visibleColId,\n        this.visibleColFormatter,\n      );\n    }\n    return acIndex.search(text);\n  }\n\n  public buildNoItemsMessage() {\n    return dom.domComputed((use) => {\n      const error = use(this._dropdownConditionError);\n      if (error) { return t(\"Error in dropdown condition\"); }\n\n      return this.hasDropdownCondition ?\n        t(\"No choices matching condition\") :\n        t(\"No choices to select\");\n    });\n  }\n\n  /**\n   * Returns a column index for the visible column, filtering the items in the\n   * index according to the set dropdown condition.\n   *\n   * This method is similar to `this.tableData.columnACIndexes.getColACIndex`,\n   * but whereas that method caches indexes globally, this method does so\n   * locally (as a new instances of this class is created each time a Reference\n   * or Reference List editor is created).\n   *\n   * It's important that this method be used when a dropdown condition is set,\n   * as items in indexes that don't satisfy the dropdown condition need to be\n   * filtered.\n   */\n  private _getDropdownConditionACIndex(rowId: number) {\n    return this._columnCache.getValue(\n      this.visibleColId,\n      () => this.tableData.columnACIndexes.buildColACIndex(\n        this.visibleColId,\n        this.visibleColFormatter,\n        this._buildDropdownConditionACFilter(rowId),\n      ),\n    );\n  }\n\n  private _buildDropdownConditionACFilter(rowId: number) {\n    const dropdownConditionCompiled = this.field.dropdownConditionCompiled.get();\n    if (dropdownConditionCompiled?.kind !== \"success\") {\n      throw new Error(\"Dropdown condition is not compiled\");\n    }\n\n    const tableId = this.field.tableId.peek();\n    const table = this._docData.getTable(tableId);\n    if (!table) { throw new Error(`Table ${tableId} not found`); }\n\n    const { result: predicate } = dropdownConditionCompiled;\n    const user = this._gristDoc.docPageModel.user.get() ?? undefined;\n    const rec = table.getRecord(rowId) || new EmptyRecordView();\n    return (item: ICellItem) => {\n      const choice = item.rowId === \"new\" ? new EmptyRecordView() : this.tableData.getRecord(item.rowId);\n      if (!choice) { throw new Error(`Reference ${item.rowId} not found`); }\n\n      return predicate({ user, rec, choice });\n    };\n  }\n}\n\nexport function nocaseEqual(a: string, b: string) {\n  return a.trim().toLowerCase() === b.trim().toLowerCase();\n}\n"
  },
  {
    "path": "app/client/lib/SafeBrowser.ts",
    "content": "/**\n * The SafeBrowser component implementation is responsible for executing the safeBrowser component\n * of a plugin.\n *\n * A plugin's safeBrowser component is made of one main entry point (the javascript files declares\n * in the manifest), html files and any resources included by the html files (css, scripts, images\n * ...). The main script is the main entry point which uses the Grist API to render the views,\n * communicate with them en dispose them.\n *\n * The main script is executed within a WebWorker, and the html files are rendered within webviews\n * if run within electron, or iframe in case of the browser.\n *\n * Communication between the main process and the views are handle with rpc.\n *\n * If the plugins includes as well an unsafeNode component or a safePython component and if one of\n * them registers a function using the Grist Api, this function can then be called from within the\n * safeBrowser main script using the Grist API, as described in `app/plugin/Grist.ts`.\n *\n * The grist API available to safeBrowser components is implemented in `app/plugin/PluginImpl.ts`.\n *\n * All the safeBrowser's component resources, including the main script, the html files and any\n * other resources needed by the views, should be placed within one plugins' subfolder, and Grist\n * should serve only this folder. However, this is not yet implemented and is left as a TODO, as of\n * now the whole plugin's folder is served.\n *\n */\n// Todo: plugin resources should not be made available on the server by default, but only after\n// activation.\n\nimport { ClientScope } from \"app/client/components/ClientScope\";\nimport { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { Disposable } from \"app/client/lib/dispose\";\nimport dom from \"app/client/lib/dom\";\nimport * as Mousetrap from \"app/client/lib/Mousetrap\";\nimport { gristThemeObs } from \"app/client/ui2018/theme\";\nimport { ActionRouter } from \"app/common/ActionRouter\";\nimport { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from \"app/common/PluginInstance\";\nimport { tbind } from \"app/common/tbind\";\nimport { convertThemeKeysToCssVars, Theme } from \"app/common/ThemePrefs\";\nimport { getOriginUrl } from \"app/common/urlUtils\";\nimport { GristAPI, RPC_GRISTAPI_INTERFACE } from \"app/plugin/GristAPI\";\nimport { RenderOptions, RenderTarget } from \"app/plugin/RenderOptions\";\nimport { checkers } from \"app/plugin/TypeCheckers\";\n\nimport { IMsgCustom, IMsgRpcCall, IRpcLogger, MsgType, Rpc } from \"grain-rpc\";\nimport { dom as grainjsDom, Observable } from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\nconst G = getBrowserGlobals(\"document\", \"window\");\n\n/**\n * The SafeBrowser component implementation. Responsible for running the script, rendering the\n * views, settings up communication channel.\n */\n// todo: it is unfortunate that SafeBrowser had to expose both `renderImpl` and `disposeImpl` which\n// really have no business outside of this module. What could be done, is to have an internal class\n// ProcessManager which will be created by SafeBrowser as a private field. It will manage the\n// client processes and among other thing will expose both renderImpl and\n// disposeImpl. ClientProcess will hold a reference to ProcessManager instead of SafeBrowser.\nexport class SafeBrowser extends BaseComponent {\n  /**\n   * Create a webview ClientProcess to render safe browser process in electron.\n   */\n  public static createWorker(safeBrowser: SafeBrowser, rpc: Rpc, src: string): WorkerProcess {\n    return new WorkerProcess(safeBrowser, rpc, src);\n  }\n\n  /**\n   * Create either an iframe or a webview ClientProcess depending on wether running electron or not.\n   */\n  public static createView(safeBrowser: SafeBrowser, rpc: Rpc, src: string): ViewProcess {\n    return G.window.isRunningUnderElectron ?\n      new WebviewProcess(safeBrowser, rpc, src) :\n      new IframeProcess(safeBrowser, rpc, src);\n  }\n\n  // All view processes. This is not used anymore to dispose all processes on deactivation (this is\n  // now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch\n  // events to all processes (such as doc actions which will need soon).\n  private _viewProcesses = new Map<number, ClientProcess>();\n  private _pluginId: string;\n  private _pluginRpc: Rpc;\n  private _mainProcess: WorkerProcess | undefined;\n  private _viewCount: number = 0;\n\n  private _plugin = this._options.pluginInstance;\n  private _clientScope = this._options.clientScope;\n  private _untrustedContentOrigin = this._options.untrustedContentOrigin;\n  private _mainPath = this._options.mainPath ?? \"\";\n  private _baseLogger = this._options.baseLogger ?? console;\n\n  constructor(private _options: {\n    pluginInstance: PluginInstance,\n    clientScope: ClientScope,\n    untrustedContentOrigin: string,\n    mainPath?: string,\n    baseLogger?: BaseLogger,\n    rpcLogger?: IRpcLogger,\n  }) {\n    super(\n      _options.pluginInstance.definition.manifest,\n      _options.rpcLogger ?? createRpcLogger(\n        _options.baseLogger ?? console,\n        `PLUGIN ${_options.pluginInstance.definition.id} SafeBrowser:`,\n      ),\n    );\n    this._pluginId = this._plugin.definition.id;\n    this._pluginRpc = this._plugin.rpc;\n  }\n\n  /**\n   * Render the file at path in an iframe or webview and returns its ViewProcess.\n   */\n  public createViewProcess(path: string): ViewProcess {\n    return this._createViewProcess(path)[0];\n  }\n\n  /**\n   * `receiveAction` handles an action received from the server by forwarding it to the view processes.\n   */\n  public receiveAction(action: any[]) {\n    for (const view of this._viewProcesses.values()) {\n      view.receiveAction(action);\n    }\n  }\n\n  /**\n   * Renders the file at path and returns its proc id. This is the SafeBrowser implementation for\n   * the GristAPI's render(...) method, more details can be found at app/plugin/GristAPI.ts.\n   */\n  public async renderImpl(path: string, target: RenderTarget, options: RenderOptions): Promise<number> {\n    const [proc, viewId] = this._createViewProcess(path);\n    const renderFunc = this._plugin.getRenderTarget(target, options);\n    renderFunc(proc.element);\n    if (this._mainProcess) {\n      // Disposing the web worker should dispose all view processes that created using the\n      // gristAPI. There is a flaw here: please read [1].\n      this._mainProcess.autoDispose(proc);\n    }\n    return viewId;\n    // [1]: When a process, which is not owned by the mainProcess (ie: a process which was created\n    // using `public createViewProcess(...)'), creates a view process using the gristAPI, the\n    // rendered view will be owned by the main process. This is not correct and could cause views to\n    // suddently disappear from the screen. This is pretty nasty. But for following reason I think\n    // it's ok to leave it for now: (1) fixing this would require (yet) another refactoring of\n    // SafeBrowser and (2) at this point it is not sure wether we want to keep `render()` in the\n    // future (we could as well directly register contribution using files directly in the\n    // manifest), and (3) plugins are only developed by us, we only have to remember that using\n    // `render()` is only supported from within the main process (which cover all our use cases so\n    // far).\n  }\n\n  /**\n   * Dispose the process using it's proc id. This is the SafeBrowser implementation for the\n   * GristAPI's dispose(...) method, more details can be found at app/plugin/GristAPI.ts.\n   */\n  public async disposeImpl(procId: number): Promise<void> {\n    const proc = this._viewProcesses.get(procId);\n    if (proc) {\n      this._viewProcesses.delete(procId);\n      proc.dispose();\n    }\n  }\n\n  protected doForwardCall(c: IMsgRpcCall): Promise<any> {\n    if (this._mainProcess) {\n      return this._mainProcess.rpc.forwardCall(c);\n    }\n    // should not happen.\n    throw new Error(\"Using SafeBrowser as an IForwarder requires a main script\");\n  }\n\n  protected doForwardMessage(c: IMsgCustom): Promise<any> {\n    if (this._mainProcess) {\n      return this._mainProcess.rpc.forwardMessage(c);\n    }\n    // should not happen.\n    throw new Error(\"Using SafeBrowser as an IForwarder requires a main script\");\n  }\n\n  protected async activateImplementation(): Promise<void> {\n    if (this._mainPath) {\n      const rpc = this._createRpc(this._mainPath);\n      const src = `plugins/${this._pluginId}/${this._mainPath}`;\n      // This SafeBrowser object is registered with _pluginRpc as _mainPath forwarder, and\n      // forwards calls to _mainProcess in doForward* methods (called from BaseComponent.forward*\n      // methods). Note that those calls are what triggers component activation.\n      this._mainProcess = SafeBrowser.createWorker(this, rpc, src);\n    }\n  }\n\n  protected async deactivateImplementation(): Promise<void> {\n    if (this._mainProcess) {\n      this._mainProcess.dispose();\n    }\n  }\n\n  /**\n   * Creates an iframe or a webview embedding the file at path. And adds it to `this._viewProcesses`\n   * using `viewId` as key, and registers it as forwarder to the `pluginRpc` using name\n   * `path`. Unregister both on disposal.\n   */\n  private _createViewProcess(path: string): [ViewProcess, number] {\n    const rpc = this._createRpc(path);\n    const url = `${this._untrustedContentOrigin}/plugins/${this._plugin.definition.id}/${path}` +\n      `?host=${G.window.location.origin}`;\n    const viewId = this._viewCount++;\n    const process = SafeBrowser.createView(this, rpc, url);\n    this._viewProcesses.set(viewId, process);\n    this._pluginRpc.registerForwarder(path, rpc);\n    process.autoDisposeCallback(() => {\n      this._pluginRpc.unregisterForwarder(path);\n      this._viewProcesses.delete(viewId);\n    });\n    return [process, viewId];\n  }\n\n  /**\n   * Create an rpc instance and set it up for communicating with a ClientProcess:\n   *  - won't send any message before receiving a ready message\n   *  - has the '*' forwarder set to the plugin's instance rpc\n   *  - has registered an implementation of the gristAPI.\n   * Returns the rpc instance.\n   */\n  private _createRpc(path: string): Rpc {\n    const rpc = new Rpc({ logger: createRpcLogger(this._baseLogger, `PLUGIN ${this._pluginId}/${path} SafeBrowser:`) });\n    rpc.queueOutgoingUntilReadyMessage();\n    warnIfNotReady(rpc, 3000, \"Plugin isn't ready; be sure to call grist.ready() from plugin\");\n    rpc.registerForwarder(\"*\", this._pluginRpc);\n    // TODO: we should be able to stop serving plugins, it looks like there are some resources\n    // required that should be disposed on component deactivation.\n    this._clientScope.servePlugin(this._pluginId, rpc);\n    return rpc;\n  }\n}\n\n/**\n * Base class for any client process. `onDispose` allows to register a callback that will be\n * triggered when dispose() is called. This is for internally use.\n */\nexport class ClientProcess extends Disposable {\n  public rpc: Rpc;\n\n  private _safeBrowser: SafeBrowser;\n  private _src: string;\n  private _actionRouter: ActionRouter;\n\n  public create(...args: any[]): void;\n  public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {\n    this.rpc = rpc;\n    this._safeBrowser = safeBrowser;\n    this._src = src;\n    this._actionRouter = new ActionRouter(this.rpc);\n    const gristAPI: GristAPI = {\n      subscribe: tbind(this._actionRouter.subscribeTable, this._actionRouter),\n      unsubscribe: tbind(this._actionRouter.unsubscribeTable, this._actionRouter),\n      render: tbind(this._safeBrowser.renderImpl, this._safeBrowser),\n      dispose: tbind(this._safeBrowser.disposeImpl, this._safeBrowser),\n    };\n    rpc.registerImpl<GristAPI>(RPC_GRISTAPI_INTERFACE, gristAPI, checkers.GristAPI);\n    this.autoDisposeCallback(() => {\n      this.rpc.unregisterImpl(RPC_GRISTAPI_INTERFACE);\n    });\n  }\n\n  public receiveAction(action: any[]) {\n    this._actionRouter.process(action)\n      .catch((err: any) => console.warn(\"ClientProcess[%s] receiveAction: failed with %s\", this._src, err));\n  }\n}\n\n/**\n * The web worker client process, used to execute safe browser main script.\n */\nclass WorkerProcess extends ClientProcess  {\n  public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {\n    super.create(safeBrowser, rpc, src);\n    // Serve web worker script from same host as current page\n    const worker = new Worker(getOriginUrl(`/${src}`));\n    worker.addEventListener(\"message\", (e: MessageEvent) => this.rpc.receiveMessage(e.data));\n    this.rpc.setSendMessage(worker.postMessage.bind(worker));\n    this.autoDisposeCallback(() => worker.terminate());\n  }\n}\n\nexport class ViewProcess extends ClientProcess {\n  public element: HTMLElement;\n\n  // Set once all of the plugin's onThemeChange handlers have been called.\n  protected _themeInitialized: Observable<boolean>;\n}\n\n/**\n * The Iframe ClientProcess used to render safe browser content in the browser.\n */\nclass IframeProcess extends ViewProcess {\n  public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {\n    super.create(safeBrowser, rpc, src);\n    this._themeInitialized = Observable.create(this, false);\n    const iframe = this.element = this.autoDispose(\n      grainjsDom(`iframe.safe_browser_process.clipboard_allow_focus`,\n        { src },\n        grainjsDom.style(\"visibility\", use => use(this._themeInitialized) ? \"visible\" : \"hidden\"),\n      ) as HTMLIFrameElement,\n    );\n    const listener = async (event: MessageEvent) => {\n      if (event.source === iframe.contentWindow) {\n        if (event.data.mtype === MsgType.Ready) {\n          await this._sendTheme({ theme: gristThemeObs().get(), fromReady: true });\n        }\n\n        if (event.data.data?.message === \"themeInitialized\") {\n          this._themeInitialized.set(true);\n        }\n\n        this.rpc.receiveMessage(event.data);\n      }\n    };\n    G.window.addEventListener(\"message\", listener);\n    this.autoDisposeCallback(() => {\n      G.window.removeEventListener(\"message\", listener);\n    });\n    this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, \"*\"));\n\n    this.autoDispose(gristThemeObs().addListener(async (newTheme, oldTheme) => {\n      if (isEqual(newTheme, oldTheme)) { return; }\n\n      await this._sendTheme({ theme: newTheme });\n    }));\n  }\n\n  private async _sendTheme({ theme, fromReady = false}: { theme: Theme, fromReady?: boolean }) {\n    await this.rpc.postMessage({ theme: convertThemeKeysToCssVars(theme), fromReady });\n  }\n}\n\n/**\n * The webview ClientProcess to render safe browser process in electron.\n */\nclass WebviewProcess extends ViewProcess {\n  public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {\n    super.create(safeBrowser, rpc, src);\n    const webview = this.element = this.autoDispose(dom(\"webview.safe_browser_process.clipboard_allow_focus\", {\n      src,\n      allowpopups: \"\",\n      // Requests with this partition get an extra header (see main.js) to get access to plugin content.\n      partition: \"plugins\",\n    }));\n    // Temporaily disable \"mousetrap\" keyboard stealing for the duration of this webview.\n    // This is acceptable since webviews are currently full-screen modals.\n    // TODO: find a way for keyboard events to play nice when webviews are non-modal.\n    Mousetrap.setPaused(true);\n    this.autoDisposeCallback(() => Mousetrap.setPaused(false));\n    webview.addEventListener(\"ipc-message\", (event: any /* IpcMessageEvent */) => {\n      // The event object passed to the listener is missing proper documentation. In the examples\n      // listed in https://electronjs.org/docs/api/ipc-main the arguments should be passed to the\n      // listener after the event object, but this is not happening here. Only we know it is a\n      // DOMEvent with some extra porperties including a `channel` property of type `string` and an\n      // `args` property of type `any[]`.\n      if (event.channel === \"grist\") {\n        rpc.receiveMessage(event.args[0]);\n      }\n    });\n    this.rpc.setSendMessage(msg => webview.send(\"grist\", msg));\n  }\n}\n"
  },
  {
    "path": "app/client/lib/SafeBrowserProcess.css",
    "content": ".plugin_instance_fullscreen {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  left: 0;\n  top: 0;\n  z-index:9999;\n}\n\n.safe_browser_process{\n  border: none;\n}\n"
  },
  {
    "path": "app/client/lib/Signal.ts",
    "content": "import { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\n\nimport { Disposable, IDisposable, IDisposableOwner, Observable } from \"grainjs\";\n\n/**\n * A simple abstraction for events composition. It is an object that can emit a single value of type T,\n * and holds the last value emitted. It can be used to compose events from other events.\n *\n * Simple observables can't be used for this purpose because they are not reentrant. We can't update\n * an observable from within a listener, because it won't trigger a new event.\n *\n * This class is basically a wrapper around Observable, that emits events when the value changes after it is\n * set.\n *\n * Example:\n *  const signal = Signal.create(null, 0);\n *  signal.listen(value => console.log(value));\n *  const onlyEven = signal.filter(value => value % 2 === 0);\n *  onlyEven.listen(value => console.log('even', value));\n *\n *  const flag1 = Signal.create(null, false);\n *  const flag2 = Signal.create(null, false);\n *  const flagAnd = Signal.compute(null, on => on(flag1) && on(flag2));\n *  // This will still emit multiple times with the same value repeated.\n *  flagAnd.listen(value => console.log('Both are true', value));\n *\n *  // This will emit only when both are true, and will ignore further changes while both are true.\n *  const toggle = flagAnd.distinct();\n *\n *  // Current value can be accessed via signal.state.get()\n *  const emitter = Signal.from(null, 0);\n *  // Emit values only when the toggle is true.\n *  const emitterWhileAnd = emitter.filter(() => toggle.state.get());\n *  // Equivalent to:\n *  const emitterWhileAnd = Signal.compute(null, on => on(toggle) ? on(emitter) : null).distinct();\n */\nexport class Signal<T = any> implements IDisposable, IDisposableOwner {\n  /**\n   * Creates a new event with a default value. A convenience method for creating an event that supports\n   * generic attribute.\n   */\n  public static create<T>(owner: IDisposableOwner | null, value: T) {\n    return new Signal(owner, value);\n  }\n\n  /**\n   * Creates an event from a set of events. Holds last value emitted by any of the events.\n   */\n  public static fromEvents<T = any>(\n    owner: Disposable | null,\n    emitter: any,\n    first: string,\n    ...rest: string[]\n  ) {\n    const signal = Signal.create(owner, null);\n    for (const event of [first, ...rest]) {\n      signal._emitter.listenTo(emitter, event, (value: any) => signal.emit(value));\n    }\n    return signal as Signal<T | null>;\n  }\n\n  /**\n   * Helper methods that creates a signal that emits the result of a function that takes a function\n   */\n  public static compute<T>(owner: Disposable | null, compute: ComputeFunction<T>) {\n    const signal = Signal.create(owner, null as any);\n    const on: any = (s: Signal) => {\n      if (!signal._listeners.has(s)) {\n        signal._listeners.add(s);\n        signal._emitter.listenTo(s._emitter, \"signal\", () => signal.emit(compute(on)));\n      }\n      return s.state.get();\n    };\n    signal.state.set(compute(on));\n    return signal as Signal<T>;\n  }\n\n  /**\n   * Last value emitted if any.\n   */\n  public state: Observable<T>;\n\n  /**\n   * List of signals that we are listening to. Stored in a WeakSet to avoid memory leaks.\n   */\n  private _listeners = new WeakSet<Signal>();\n\n  /**\n   * Flag that can be changed by stateless() function. It won't hold last value (but can't be used in compute function).\n   */\n  private _emitter: DisposableWithEvents;\n\n  private _beforeHandler: CustomEmitter<T>;\n\n  constructor(owner: IDisposableOwner | null, initialValue: T) {\n    this._emitter = DisposableWithEvents.create(owner);\n    this.state = Observable.create(this, initialValue);\n  }\n\n  public dispose() {\n    this._emitter.dispose();\n  }\n\n  public autoDispose(disposable: IDisposable) {\n    this._emitter.autoDispose(disposable);\n  }\n\n  /**\n   * Push all events from this signal to another signal.\n   */\n  public pipe(signal: Signal<T>) {\n    this.autoDispose(this.listen(value => signal.emit(value)));\n    return this;\n  }\n\n  /**\n   * Modify all values emitted by this signal.\n   */\n  public map<Z>(selector: (value: T) => Z): Signal<Z> {\n    const signal = Signal.create(this, selector(this.state.get()));\n    this.listen((value) => {\n      signal.emit(selector(value));\n    });\n    return signal;\n  }\n\n  /**\n   * Creates a new signal with the same state, but it will only\n   * emit those values that pass the test implemented by the provided function.\n   */\n  public filter(selector: (value: T) => boolean): Signal<T> {\n    const signal = Signal.create(this, this.state.get());\n    this.listen((value) => {\n      if (selector(value)) {\n        signal.emit(value);\n      }\n    });\n    return signal;\n  }\n\n  /**\n   * Emit only the value that is different from the previous one.\n   */\n  public distinct(): Signal<T> {\n    let last = this.state.get();\n    const signal = this.filter((value: any) => {\n      if (value !== last) {\n        last = value;\n        return true;\n      }\n      return false;\n    });\n    signal.state.set(last);\n    return signal;\n  }\n\n  /**\n   * Emits true or false only when the value is changed from truthy to falsy or vice versa.\n   */\n  public flag() {\n    return this.map(Boolean).distinct();\n  }\n\n  /**\n   * Listen to changes of the signal.\n   */\n  public listen(handler: (value: T) => any) {\n    const stateHandler = () => {\n      handler(this.state.get());\n    };\n    this._emitter.on(\"signal\", stateHandler);\n    return {\n      dispose: () => this._emitter.off(\"signal\", stateHandler),\n    };\n  }\n\n  public emit(value: T) {\n    if (this._beforeHandler) {\n      this._beforeHandler(value, (emitted: T) => {\n        this.state.set(emitted);\n        this._emitter.trigger(\"signal\", emitted);\n      });\n    } else {\n      this.state.set(value);\n      this._emitter.trigger(\"signal\", value);\n    }\n  }\n\n  public before(handler: CustomEmitter<T>) {\n    this._beforeHandler = handler;\n  }\n}\n\ntype ComputeFunction<T> = (on: <TS>(s: Signal<TS>) => TS) => T;\ntype CustomEmitter<T> = (value: T, emit: (value: T) => void) => any;\n"
  },
  {
    "path": "app/client/lib/Suggestions.ts",
    "content": "/**\n * This is a helper for producing autocomplete suggestions (aka completions) for the ACE code\n * editor. In particular, it's used for Access Rules formulas.\n */\n\n// Suggestions are based on a prefix that's a dot-separated chain of attribute lookups (like\n// \"foo.bar.baz\"). Each suggestion may offer additional attribute lookups via subAttributes().\nexport interface ISuggestionWithSubAttrs {\n  value: string;        // The suggestion itself.\n  example?: string;     // An optional example value to show on the right.\n\n  // Once this suggestion is the prefix, its subAttributes() will be offered.\n  subAttributes?: () => ISuggestionWithSubAttrs[];\n}\n\n/**\n * Expands a list of suggestions with sub-attributes, and filters it by prefix. E.g. if\n * \"user.Email\" suggestion includes a subAttributes() method that returns \"upper()\" and \"lower()\"\n * suggestions, then expanding \"user.Email.u\" would find and return \"user.Email.upper()\".\n */\nexport function expandAndFilterSuggestions(\n  prefix: string, suggestions: ISuggestionWithSubAttrs[],\n): ISuggestionWithSubAttrs[] {\n  const result: ISuggestionWithSubAttrs[] = [];\n\n  // Add all suggestions that start directly with prefix.\n  for (const s of suggestions) {\n    if (s.value.startsWith(prefix)) {\n      result.push(s);\n    }\n  }\n\n  // Next, look for suggestions that match some complete part of the prefix: those may have\n  // subattributes we should consider. We split prefix (e.g. \"foo.bar.ba\") into [expr, attr]\n  // e.g. [\"foo.bar\", \"ba\"], then look up the exact match for expr (\"foo.bar\") recursively, and\n  // see if that match includes a subAttributes() function that returns anything matching \"ba\".\n  const [expr, attr] = splitAttr(prefix);\n  const exprResult = findMatchingSuggestion(expr, suggestions);\n  const attrSuggestions = exprResult?.subAttributes?.();\n  if (attrSuggestions) {\n    for (const s of attrSuggestions) {\n      if (s.value.startsWith(attr)) {\n        // Prepend back the expr that precedes the attribute.\n        result.push({ ...s, value: expr + \".\" + s.value });\n      }\n    }\n  }\n  return result;\n}\n\n// Given a list of suggestions, finds if any is an exact match for the given text. This may\n// examine higher-level suggestions and their subattributes. E.g. if suggestions don't include\n// an exact match for \"foo.bar.baz\", but include an exact match for \"foo.bar\", then its\n// subAttributes() result will be checked for \"baz\", which would be considered an exact match.\nfunction findMatchingSuggestion(text: string, suggestions: ISuggestionWithSubAttrs[]): ISuggestionWithSubAttrs | null {\n  const match = suggestions.find(s => s.value === text);\n  if (match) { return match; }\n  if (!text.includes(\".\")) { return null; }\n  const [expr, attr] = splitAttr(text);\n  const exprResult = findMatchingSuggestion(expr, suggestions);\n  const attrSuggestions = exprResult?.subAttributes?.();\n  return attrSuggestions?.find(s => s.value === attr) || null;\n}\n\n// Splits a string like \"foo.bar.baz\" into [\"foo.bar\", \"baz\"]. Either half could be empty.\nfunction splitAttr(text: string): [string, string] {\n  const parts = text.split(\".\");\n  return [parts.slice(0, -1).join(\".\"), parts[parts.length - 1]];\n}\n"
  },
  {
    "path": "app/client/lib/TokenField.ts",
    "content": "/**\n * A full-featured implementation of tokenfield (aka \"pillbox\", \"tag list\", etc).\n *\n * Supported features:\n * - Each token includes an \"x\" button to delete it.\n * - Click on a token to select;\n *   Shift+click to extend selection;\n *   Ctrl+click for non-contigous selection.\n * - Arrow keys to move selection.\n *   Shift + arrow keys to extend selection.\n * - Cmd+A to select all options.\n * - Delete/Backspace delete selection. If no selection, Backspace deletes the last item.\n * - Copy-cut is supported for a selection. By default CSV-encodes token labels.\n * - Paste is supported into input textbox, or to replace a selection.\n * - Tokens or a selection of tokens may be dragged to move within the tokenfield.\n * - Supports undo/redo for token changes.\n */\nimport { ACItem } from \"app/client/lib/ACIndex\";\nimport { Autocomplete, IAutocompleteOptions } from \"app/client/lib/autocomplete\";\nimport { modKeyProp } from \"app/client/lib/browserInfo\";\nimport { colors, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { csvDecodeRow, csvEncodeRow } from \"app/common/csvFormat\";\n\nimport { computedArray, IDisposableCtor, IObsArraySplice, ObsArray, obsArray, Observable } from \"grainjs\";\nimport { Disposable, dom, DomElementArg, Holder, styled } from \"grainjs\";\n\nexport interface IToken {\n  label: string;\n}\n\nexport interface ITokenFieldOptions<Token extends IToken> {\n  initialValue: Token[];\n  renderToken: (token: Token) => DomElementArg;\n  createToken: (inputText: string) => Token | undefined;\n  acOptions?: IAutocompleteOptions<Token & ACItem>;\n  openAutocompleteOnFocus?: boolean;\n  styles?: ITokenFieldStyles;\n  readonly?: boolean;\n  trimLabels?: boolean;\n  keyBindings?: ITokenFieldKeyBindings;\n\n  // Allows overriding how tokens are copied to the clipboard, or retrieved from it.\n  // By default, tokens are placed into clipboard as text/plain comma-separated token labels, with\n  // CSV escaping, and pasted from clipboard by applying createToken() to parsed CSV text.\n  tokensToClipboard?: (tokens: Token[], clipboard: DataTransfer) => void;\n  clipboardToTokens?: (clipboard: DataTransfer) => Token[];\n\n  // Defaults to horizontal.\n  variant?: ITokenFieldVariant;\n}\n\n/**\n * Overrides for default TokenField shortcut bindings.\n *\n * Values should be Key Values (https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values).\n */\nexport interface ITokenFieldKeyBindings {\n  previous?: string;\n  next?: string;\n}\n\nexport type ITokenFieldVariant = \"horizontal\" | \"vertical\";\n\nconst defaultKeyBindings: Required<ITokenFieldKeyBindings> = {\n  previous: \"ArrowLeft\",\n  next: \"ArrowRight\",\n};\n\n// TokenWrap serves to distinguish multiple instances of the same token in the list.\nclass TokenWrap<Token extends IToken> {\n  constructor(public token: Token) {}\n}\n\nclass UndoItem {\n  constructor(public redo: () => void, public undo: () => void) {}\n}\n\nexport class TokenField<Token extends IToken = IToken> extends Disposable {\n  public static ctor<T extends IToken>(): IDisposableCtor<TokenField<T>, [ITokenFieldOptions<T>]> {\n    return this;\n  }\n\n  public tokensObs: ObsArray<Token>;\n\n  private _acHolder = Holder.create<Autocomplete<Token & ACItem>>(this);\n  private _acOptions: IAutocompleteOptions<Token & ACItem> | undefined;\n  private _rootElem: HTMLElement;\n  private _textInput: HTMLInputElement;\n  private _styles: Required<ITokenFieldStyles>;\n\n  // ClipboardAPI events work as expected only when the focus is in an actual input.\n  // This is where we place focus when we have some tokens selected.\n  private _hiddenInput: HTMLInputElement;\n\n  // Keys to navigate tokens. In a vertical list, these would be changed to Up/Down.\n  private _keyBindings: Required<ITokenFieldKeyBindings>;\n\n  private _tokens = this.autoDispose(obsArray<TokenWrap<Token>>());\n  private _selection = Observable.create(this, new Set<TokenWrap<Token>>());\n  private _selectionAnchor: TokenWrap<Token> | null = null;\n  private _undoStack: UndoItem[] = [];\n  private _undoIndex = 0;   // The last action done; next to undo.\n  private _inUndoRedo = false;\n  private _variant: ITokenFieldVariant = this._options.variant ?? \"horizontal\";\n\n  constructor(private _options: ITokenFieldOptions<Token>) {\n    super();\n    const addSelectedItem = this._addSelectedItem.bind(this);\n    const openAutocomplete = this._openAutocomplete.bind(this);\n    this._acOptions = _options.acOptions && { ..._options.acOptions, onClick: addSelectedItem };\n\n    this.setTokens(_options.initialValue);\n    this.tokensObs = this.autoDispose(computedArray(this._tokens, t => t.token));\n    this._keyBindings = { ...defaultKeyBindings, ..._options.keyBindings };\n\n    // We can capture undo info in a consistent way as long as we change _tokens using its\n    // obsArray interface, by listening to the splice events.\n    this.autoDispose(this._tokens.addListener(this._recordUndo.bind(this)));\n\n    // Use overridden styles if any were provided.\n    this._styles = { ...tokenFieldStyles, ..._options.styles };\n    const { cssTokenField, cssToken, cssInputWrapper, cssTokenInput, cssDeleteButton, cssDeleteIcon } = this._styles;\n\n    function stop(ev: Event) {\n      ev.stopPropagation();\n      ev.preventDefault();\n    }\n\n    this._rootElem = cssTokenField(\n      { tabIndex: \"-1\" },\n      dom.forEach(this._tokens, t =>\n        cssToken(this._options.renderToken(t.token),\n          dom.cls(\"selected\", use => use(this._selection).has(t)),\n          _options.readonly ? null : [\n            cssDeleteButton(\n              // Ignore mousedown events, so that tokens aren't draggable by the delete button.\n              dom.on(\"mousedown\", ev => ev.stopPropagation()),\n              cssDeleteIcon(\"CrossSmall\"),\n              testId(\"tokenfield-delete\"),\n            ),\n            dom.on(\"click\", ev =>  this._onTokenClick(ev, t)),\n            dom.on(\"mousedown\", ev => this._onMouseDown(ev, t)),\n          ],\n          testId(\"tokenfield-token\"),\n        ),\n      ),\n      cssInputWrapper(\n        this._textInput = cssTokenInput(\n          dom.boolAttr(\"readonly\", this._options.readonly ?? false),\n          dom.on(\"focus\", this._onInputFocus.bind(this)),\n          dom.on(\"blur\", () => { this._acHolder.clear(); }),\n          (this._acOptions ?\n            // Toggle the autocomplete on clicking the input box.\n            dom.on(\"click\", () => this._acHolder.isEmpty() ? openAutocomplete() : this._acHolder.clear()) :\n            null\n          ),\n          dom.onKeyDown({\n            Escape$: (ev) => { this._acHolder.clear(); },\n            Enter$: ev => addSelectedItem() && stop(ev),\n            ArrowDown$: openAutocomplete,\n            Tab$: ev => addSelectedItem() && stop(ev),\n          }),\n          dom.on(\"input\", openAutocomplete),\n          testId(\"tokenfield-input\"),\n        ),\n      ),\n      dom.onKeyDown({\n        a$: this._maybeSelectAllTokens.bind(this),\n        Backspace$: this._maybeBackspace.bind(this),\n        Delete$: this._maybeDelete.bind(this),\n        [this._keyBindings.previous + \"$\"]: ev => this._maybeAdvance(ev, -1),\n        [this._keyBindings.next + \"$\"]: ev => this._maybeAdvance(ev, +1),\n        // ['Mod+z'] triggers undo; ['Mod+Shift+Z', 'Ctrl+y' ] trigger redo\n        z$: (ev) => {\n          if (ev[modKeyProp()]) {\n            if (ev.shiftKey) {\n              this._redo(ev);\n            } else {\n              this._undo(ev);\n            }\n          }\n        },\n        y$: (ev) => { if (ev.ctrlKey && !ev.shiftKey) { this._redo(ev); } },\n      }),\n      this._hiddenInput = cssHiddenInput({ type: \"text\", tabIndex: \"-1\" },\n        dom.on(\"blur\", (ev) => {\n          if (ev.relatedTarget && ev.relatedTarget !== this._rootElem) {\n            this._selectionAnchor = null;\n            this._selection.set(new Set());\n          }\n        }),\n      ),\n      dom.on(\"focus\", () => this._hiddenInput.focus({ preventScroll: true })),\n      dom.on(\"copy\", this._onCopyEvent.bind(this)),\n      dom.on(\"cut\", this._onCutEvent.bind(this)),\n      dom.on(\"paste\", this._onPasteEvent.bind(this)),\n      testId(\"tokenfield\"),\n    );\n  }\n\n  public attach(elem: HTMLElement): void {\n    elem.appendChild(this._rootElem);\n  }\n\n  // Outer container for the tokens and new-entry input field.\n  public getRootElem(): HTMLElement {\n    return this._rootElem;\n  }\n\n  // The new-entry input field.\n  public getTextInput(): HTMLInputElement {\n    return this._textInput;\n  }\n\n  /**\n   * Returns the current value of the text input.\n   */\n  public getTextInputValue(): string {\n    return this._options.trimLabels ? this._textInput.value.trim() : this._textInput.value;\n  }\n\n  // The invisible input that has focus while we have some tokens selected.\n  public getHiddenInput(): HTMLInputElement {\n    return this._hiddenInput;\n  }\n\n  /**\n   * Returns the Autocomplete instance used by the TokenField.\n   */\n  public getAutocomplete(): Autocomplete<Token & ACItem> | null {\n    return this._acHolder.get();\n  }\n\n  /**\n   * Sets the `tokens` that the TokenField should be populated with.\n   *\n   * Can be called after the TokenField is created to override the\n   * stored tokens. This is useful for delayed token initialization,\n   * where `tokens` may need to be set shortly after the TokenField\n   * is opened (e.g. ReferenceListEditor).\n   */\n  public setTokens(tokens: Token[]): void {\n    const formattedTokens = this._maybeTrimTokens(tokens);\n    this._tokens.set(formattedTokens.map(t => new TokenWrap(t)));\n  }\n\n  // Replaces a token (if it exists).\n  public replaceToken(label: string, newToken: Token): void {\n    const tokenIdx = this._tokens.get().findIndex(t => t.token.label === label);\n    if (tokenIdx === -1) { return; }\n    this._tokens.splice(tokenIdx, 1, new TokenWrap(newToken));\n  }\n\n  // Open the autocomplete dropdown, if autocomplete was configured in the options.\n  private _openAutocomplete() {\n    // don't open dropdown in a readonly mode\n    if (this._options.readonly) { return; }\n    if (this._acOptions && this._acHolder.isEmpty()) {\n      Autocomplete.create(this._acHolder, this._textInput, this._acOptions);\n    }\n  }\n\n  // Adds the typed-in or selected item. If an item is selected in autocomplete dropdown, adds\n  // that; otherwise if options.createToken is present, creates a token from text input value.\n  private _addSelectedItem(): boolean {\n    let item: Token | undefined = this._acHolder.get()?.getSelectedItem();\n    const textInput = this.getTextInputValue();\n    if (!item && this._options.createToken && textInput) {\n      item = this._options.createToken(textInput);\n    }\n    if (item) {\n      this._tokens.push(new TokenWrap(item));\n      this._textInput.value = \"\";\n      this._acHolder.clear();\n      return true;\n    }\n    return false;\n  }\n\n  // Handler for when text input is focused: clears selection, optionally opens dropdown.\n  private _onInputFocus() {\n    this._selectionAnchor = null;\n    this._selection.set(new Set());\n    if (this._options.openAutocompleteOnFocus) {\n      this._openAutocomplete();\n    }\n  }\n\n  // Handle for a click on a token or the token's delete button. This handles selection, including\n  // Shift+Click and Ctrl+Click.\n  private _onTokenClick(ev: MouseEvent, t: TokenWrap<Token>) {\n    ev.stopPropagation();\n    const idx = this._tokens.get().indexOf(t);\n    if (idx < 0) { return; }\n    if (ev.target && (ev.target as HTMLElement).matches(\".\" + this._styles.cssDeleteIcon.className)) {\n      // Delete token.\n      this._tokens.splice(idx, 1);\n    } else {\n      const fromIdx = this._selectionAnchor ? this._tokens.get().indexOf(this._selectionAnchor) : -1;\n      if (ev.shiftKey && fromIdx >= 0) {\n        // Shift+Click selects range from selectionAnchor to the clicked token.\n        const [first, last] = fromIdx <= idx ? [fromIdx, idx] : [idx, fromIdx];\n        this._selection.set(new Set(this._tokens.get().slice(first, last + 1)));\n      } else if (ev[modKeyProp()] && fromIdx >= 0) {\n        // Ctrl+Click (or Command+Click on mac) toggles the clicked token.\n        this._toggleTokenSelection(t);\n      } else {\n        // Plain click, or any click in the absence of an anchor element, sets the anchor and\n        // selects just this element.\n        this._resetTokenSelection(t);\n      }\n    }\n    this._setFocus();\n  }\n\n  private _maybeSelectAllTokens(ev: KeyboardEvent) {\n    if (ev[modKeyProp()] && this._textInput.value === \"\") {\n      ev.stopPropagation();\n      ev.preventDefault();\n      const tokens = this._tokens.get();\n      this._selection.set(new Set(tokens));\n      this._selectionAnchor = tokens ? tokens[0] : null;\n      this._setFocus();\n    }\n  }\n\n  // Set focus appropriately to the textInput or to the outer container.\n  private _setFocus() {\n    if (this._selection.get().size === 0) {\n      this._textInput.focus();\n    } else {\n      this._hiddenInput.focus();\n    }\n  }\n\n  private _maybeBackspace(ev: KeyboardEvent) {\n    if (this._textInput.value === \"\") {\n      ev.stopPropagation();\n      ev.preventDefault();\n      if (ev.repeat) { return; }\n      if (this._selection.get().size === 0) {\n        this._tokens.pop();\n      } else {\n        this._deleteTokens(this._selection.get(), -1);\n      }\n    }\n  }\n\n  private _maybeDelete(ev: KeyboardEvent) {\n    if (this._textInput.value === \"\" && this._selection.get().size > 0) {\n      ev.stopPropagation();\n      ev.preventDefault();\n      this._deleteTokens(this._selection.get(), 1);\n    }\n  }\n\n  // Handle arrow and shift+arrow keys, when the text input is empty.\n  private _maybeAdvance(ev: KeyboardEvent, advance: 1 | -1): void {\n    if (this._textInput.value !== \"\") {\n      return;\n    }\n    const tokens = this._tokens.get();\n    const anchorIdx = this._selectionAnchor ? tokens.indexOf(this._selectionAnchor) : -1;\n\n    if (ev.shiftKey && this._selection.get().size > 0 && anchorIdx >= 0) {\n      // For shift+arrows, we either extend or reduce the selection, depending on whether we are\n      // walking away from the anchor or back towards it.\n      const [first, last] = this._getSelectedIndexRange(this._selection.get());\n      if (last < 0) { return; }\n      const toggleIdx = (advance > 0) ?\n        (last === anchorIdx && first < anchorIdx ? first : last + 1) :\n        (first === anchorIdx && last > anchorIdx ? last : first - 1);\n      const t = tokens[toggleIdx];\n      if (t) {\n        ev.stopPropagation();\n        ev.preventDefault();\n        this._toggleTokenSelection(t);\n        this._setFocus();\n      }\n    } else {\n      // For arrow keys, move to the next token after the selection.\n      let next: TokenWrap<Token> | null = null;\n      if (this._selection.get().size > 0) {\n        next = this._getNextToken(this._selection.get(), advance);\n      } else if (advance < 0 && tokens.length > 0) {\n        next = tokens[tokens.length - 1];\n      }\n      // If no next token and we are moving to the right, we should end up back in the text input.\n      if (next || advance > 0) {\n        ev.stopPropagation();\n        ev.preventDefault();\n        this._resetTokenSelection(next);\n        this._setFocus();\n      }\n    }\n  }\n\n  private _toggleTokenSelection(token: TokenWrap<Token>) {\n    const selection = this._selection.get();\n    if (selection.has(token)) {\n      selection.delete(token);\n    } else {\n      selection.add(token);\n    }\n    // We use .setAndTrigger() to set a value that's identical (by reference) to the previous one.\n    this._selection.setAndTrigger(selection);\n  }\n\n  private _resetTokenSelection(token: TokenWrap<Token> | null) {\n    this._selectionAnchor = token;\n    this._selection.set(token ? new Set([token]) : new Set());\n  }\n\n  // Delete the given set of tokens, and select either the following or the preceding one.\n  private _deleteTokens(toDelete: Set<TokenWrap<Token>>, advance: 1 | -1 | 0) {\n    if (this._selection.get().size === 0) { return; }\n    const selectAfter = advance ? this._getNextToken(toDelete, advance) : null;\n    this._tokens.set(this._tokens.get().filter(t => !toDelete.has(t)));\n    this._resetTokenSelection(selectAfter);\n    this._setFocus();\n  }\n\n  private _getNextToken(selection: Set<TokenWrap<Token>>, advance: 1 | -1): TokenWrap<Token> | null {\n    const [first, last] = this._getSelectedIndexRange(selection);\n    if (last < 0) { return null; }\n    return this._tokens.get()[advance > 0 ? last + 1 : first - 1] || null;\n  }\n\n  private _getSelectedIndexRange(selection: Set<TokenWrap<Token>>): [number, number] {\n    const tokens = this._tokens.get();\n    let first = -1, last = -1;\n    for (let i = 0; i < tokens.length; i++) {\n      if (selection.has(tokens[i])) {\n        if (first === -1) { first = i; }\n        last = i;\n      }\n    }\n    return [first, last];\n  }\n\n  private _onCopyEvent(ev: ClipboardEvent): boolean {\n    if (!ev.clipboardData || !this._selection.get().size) { return false; }\n    ev.preventDefault();  // Required for overriding: https://www.w3.org/TR/clipboard-apis/#override-copy\n\n    const selected = this._selection.get();\n    const tokens = this._tokens.get().filter(t => selected.has(t));\n    if (this._options.tokensToClipboard) {\n      this._options.tokensToClipboard(tokens.map(t => t.token), ev.clipboardData);\n    } else {\n      const values = tokens.map(t => t.token.label);\n      ev.clipboardData.setData(\"text/plain\", csvEncodeRow(values, { prettier: true }));\n    }\n    return true;\n  }\n\n  private _onCutEvent(ev: ClipboardEvent) {\n    if (this._onCopyEvent(ev)) {\n      this._deleteTokens(this._selection.get(), 0);\n    }\n  }\n\n  private _onPasteEvent(ev: ClipboardEvent) {\n    if (!ev.clipboardData) { return; }\n    ev.preventDefault();\n    let tokens: Token[];\n    if (this._options.clipboardToTokens) {\n      tokens = this._options.clipboardToTokens(ev.clipboardData);\n    } else {\n      const text = ev.clipboardData.getData(\"text/plain\");\n      const values = csvDecodeRow(text);\n      tokens = values.map(v => this._options.createToken(v)).filter((t): t is Token => Boolean(t));\n    }\n    if (!tokens.length) { return; }\n    tokens = this._maybeTrimTokens(tokens);\n    tokens = this._getNonEmptyTokens(tokens);\n    const wrappedTokens = tokens.map(t => new TokenWrap(t));\n    this._combineUndo(() => {\n      this._deleteTokens(this._selection.get(), 1);\n      const anchorIdx = this._selectionAnchor ? this._tokens.get().indexOf(this._selectionAnchor) : -1;\n      if (anchorIdx >= 0) {\n        this._tokens.splice(anchorIdx, 0, ...wrappedTokens);\n        this._selectionAnchor = wrappedTokens[0];\n        this._selection.set(new Set(wrappedTokens));\n      } else {\n        this._tokens.push(...wrappedTokens);\n        this._resetTokenSelection(null);\n      }\n    });\n    this._setFocus();\n  }\n\n  // For a mousedown on a token, register events for mousemove/mouseup, and start dragging as soon\n  // as mousemove occurs.\n  private _onMouseDown(startEvent: MouseEvent, t: TokenWrap<Token>) {\n    const xInitial = startEvent.clientX;\n    const yInitial = startEvent.clientY;\n    const dragTargetSelector = `.${this._styles.cssToken.className}, .${this._styles.cssInputWrapper.className}`;\n    const dragTargetStyle = this._variant === \"horizontal\" ? cssDragTarget : cssVerticalDragTarget;\n\n    let started = false;\n    let allTargets: HTMLElement[];\n    let tokenList: HTMLElement[];\n    let nextUnselectedToken: HTMLElement | undefined;\n\n    const onMove = (ev: MouseEvent) => {\n      if (!started) {\n        started = true;\n        // If we started dragging an element that's not part of the selection, reset the selection\n        // to just that element. After this, we are always dragging the active selection.\n        if (!this._selection.get().has(t)) {\n          this._resetTokenSelection(t);\n        }\n\n        this._rootElem.classList.add(\"token-dragactive\");\n\n        // Get a list of all drag targets, and add a CSS class that shows drop location on hover.\n        allTargets = Array.prototype.filter.call(this._rootElem.children, el => el.matches(dragTargetSelector));\n        allTargets.forEach(el => el.classList.add(dragTargetStyle.className));\n\n        // Get a list of element we are dragging, and add a CSS class to show them as dragged.\n        tokenList = allTargets.filter(el => el.matches(\".selected\"));\n        tokenList.forEach(el => el.classList.add(\"token-dragging\"));\n\n        // Add a CSS class to the first unselected token after the current selection; we use it for showing\n        // the drag/drop markers when hovering over a token.\n        nextUnselectedToken = allTargets.find(el => el.previousElementSibling === tokenList[tokenList.length - 1]);\n        nextUnselectedToken?.classList.add(dragTargetStyle.className + \"-next\");\n        nextUnselectedToken?.style.setProperty(\"--count\", String(tokenList.length));\n      }\n      const xOffset = ev.clientX - xInitial;\n      const yOffset = ev.clientY - yInitial;\n      const transform = `translate(${xOffset}px, ${yOffset}px)`;\n      tokenList.forEach((el) => { el.style.transform = transform; });\n    };\n\n    const onStop = (ev: MouseEvent) => {\n      moveLis.dispose();\n      stopLis.dispose();\n\n      // Stop here if dragging never started.\n      if (!started) { return; }\n\n      // Restore all style changes.\n      this._rootElem.classList.remove(\"token-dragactive\");\n      allTargets.forEach(el => el.classList.remove(dragTargetStyle.className));\n      tokenList.forEach(el => el.classList.remove(\"token-dragging\"));\n      tokenList.forEach((el) => { el.style.transform = \"\"; });\n      nextUnselectedToken?.classList.remove(dragTargetStyle.className + \"-next\");\n\n      // Find the token before which we are inserting the dragged elements. If inserting at the\n      // end (just before or over the input box), destToken will be undefined.\n      const index = allTargets.findIndex(target => target.contains(ev.target as Node));\n      if (index < 0) { return; }\n\n      const destToken: TokenWrap<Token> | undefined = this._tokens.get()[index];\n\n      const selection = this._selection.get();\n      if (selection.has(destToken)) { return; }   // Not actually moving anywhere new.\n\n      const movedTokens = this._tokens.get().filter(tok => selection.has(tok));\n      if (!movedTokens.length) { return; }        // Didn't find any tokens to move.\n\n      this._combineUndo(() => {\n        this._deleteTokens(selection, 0);\n        // Find destination again after the deletion (it's likely to have changed).\n        const destIndex = destToken ? this._tokens.get().indexOf(destToken) : this._tokens.get().length;\n        // Move the tokens and mark them as selected.\n        this._tokens.splice(destIndex, 0, ...movedTokens);\n        this._selectionAnchor = movedTokens[0];\n        this._selection.set(new Set(movedTokens));\n      });\n    };\n\n    const moveLis = dom.onElem(document, \"mousemove\", onMove, { useCapture: true });\n    const stopLis = dom.onElem(document, \"mouseup\", onStop, { useCapture: true });\n  }\n\n  private _recordUndo(val: TokenWrap<Token>[], prev: TokenWrap<Token>[], change?: IObsArraySplice<TokenWrap<Token>>) {\n    if (this._inUndoRedo) { return; }\n    const splice = change || { start: 0, numAdded: val.length, deleted: [...prev] };\n    const newTokens = val.slice(splice.start, splice.start + splice.numAdded);\n    const redo = () => this._tokens.splice(splice.start, splice.deleted.length, ...newTokens);\n    const undo = () => this._tokens.splice(splice.start, splice.numAdded, ...splice.deleted);\n    this._undoIndex = Math.min(this._undoIndex + 1, this._undoStack.length);\n    this._undoStack.splice(this._undoIndex, this._undoStack.length, new UndoItem(redo, undo));\n  }\n\n  private _combineUndo(callback: () => void) {\n    const nextAction = this._undoIndex + 1;\n    try {\n      callback();\n    } finally {\n      if (this._undoStack.length > nextAction + 1) {\n        // If multiple actions were added, combine them into one.\n        const actions = this._undoStack.slice(nextAction);\n        const redo = () => actions.forEach(a => a.redo());\n        const undo = () => actions.slice().reverse().forEach(a => a.undo());\n        this._undoIndex = nextAction;\n        this._undoStack.splice(this._undoIndex, actions.length, new UndoItem(redo, undo));\n      }\n    }\n  }\n\n  private _undo(ev: KeyboardEvent): void {\n    if (this._textInput.value === \"\" && this._undoIndex >= 0 && this._undoIndex < this._undoStack.length) {\n      ev.stopPropagation();\n      ev.preventDefault();\n      this._inUndoRedo = true;\n      try {\n        this._undoStack[this._undoIndex].undo();\n        this._undoIndex--;\n      } finally {\n        this._inUndoRedo = false;\n      }\n    }\n  }\n\n  private _redo(ev: KeyboardEvent): void {\n    if (this._undoIndex + 1 < this._undoStack.length) {\n      ev.stopPropagation();\n      ev.preventDefault();\n      this._inUndoRedo = true;\n      try {\n        this._undoIndex += 1;\n        this._undoStack[this._undoIndex].redo();\n      } finally {\n        this._inUndoRedo = false;\n      }\n    }\n  }\n\n  /**\n   * Returns an array of tokens formatted according to the `trimLabels` option.\n   */\n  private _maybeTrimTokens(tokens: Token[]): Token[] {\n    if (!this._options.trimLabels) { return tokens; }\n    return tokens.map(t => ({ ...t, label: t.label.trim() }));\n  }\n\n  /**\n   * Returns a filtered array of tokens that don't have empty labels.\n   */\n  private _getNonEmptyTokens(tokens: Token[]): Token[] {\n    return tokens.filter(t => t.label !== \"\");\n  }\n}\n\nconst cssTokenField = styled(\"div\", `\n  display: flex;\n  border: 1px solid ${colors.darkGrey};\n  border-radius: 3px;\n  padding: 0 4px;\n  line-height: 16px;\n\n  &.token-dragactive {\n    cursor: grabbing;\n  }\n`);\n\nconst cssToken = styled(\"div\", `\n  position: relative;\n  flex: none;\n  border-radius: 3px;\n  background-color: ${theme.choiceTokenBg};\n  padding: 4px;\n  margin: 3px 2px;\n  user-select: none;\n  cursor: grab;\n\n  &.selected {\n    background-color: ${theme.choiceTokenSelectedBg};\n  }\n  &.token-dragging {\n    pointer-events: none;\n    z-index: 1;\n    opacity: 0.7;\n  }\n  .${cssTokenField.className}.token-dragactive & {\n    cursor: unset;\n  }\n`);\n\nconst cssInputWrapper = styled(\"div\", `\n  position: relative;\n  flex: auto;\n  margin: 3px 2px;\n  display: flex;\n`);\n\nconst cssTokenInput = styled(\"input\", `\n  color: ${theme.cellEditorFg};\n  background-color: ${theme.cellEditorBg};\n  flex: auto;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  padding: 0;\n  border: none;\n  outline: none;\n  line-height: inherit;\n`);\n\n// This class is applied to tokens and the input box on start of dragging, to use them as drag\n// targets. Insertion point will always be to the left of them. While dragging, these include a\n// transparent pseudo-element to cover some area to the left, to know when it's a suitable drop\n// position. While the drag is over the element (or its extension), it gets shifted to show\n// the user the location of the drop using another pseudo-element.\nconst cssDragTarget = styled(\"div\", `\n  &::before {\n    content: \"\";\n    position: absolute;\n    left: -8px;\n    right: 50%;\n    top: 0px;\n    bottom: 0px;\n  }\n  &:hover {\n    transform: translateX(2px);\n  }\n  &:hover::after {\n    content: \"\";\n    position: absolute;\n    background-color: ${theme.controlFg};\n    width: 2px;\n    top: 0px;\n    bottom: 0px;\n    left: -4px;\n  }\n`);\n\nconst cssVerticalDragTarget = styled(\"div\", `\n  /* This pseudo-element prevents small, flickering height changes when\n   * dragging the selection over targets. */\n  &::before {\n    content: \"\";\n    position: absolute;\n    top: -8px;\n    bottom: 0px;\n    left: 0px;\n    right: 0px;\n  }\n  &-next::before {\n    /* 27.75px is the height of a token. */\n    top: calc(-27.75px * var(--count, 1) - 8px);\n  }\n  &:hover {\n    transform: translateY(4px);\n    margin-bottom: 8px;\n  }\n  &:hover::after {\n    content: \"\";\n    position: absolute;\n    background-color: ${theme.controlFg};\n    height: 2px;\n    top: -5px;\n    bottom: 0px;\n    left: 0px;\n    right: 0px;\n  }\n`);\n\nconst cssHiddenInput = styled(\"input\", `\n  left: -10000px;\n  width: 1px;\n  position: absolute;\n`);\n\nconst cssDeleteButton = styled(\"div\", `\n  display: inline;\n  margin-left: 4px;\n  vertical-align: bottom;\n  line-height: 1;\n  cursor: pointer;\n  .${cssTokenField.className}.token-dragactive & {\n    cursor: unset;\n  }\n`);\n\nconst cssDeleteIcon = styled(icon, `\n  --icon-color: ${colors.slate};\n  &:hover {\n    --icon-color: ${colors.dark};\n  }\n`);\n\nexport const tokenFieldStyles = {\n  cssTokenField,\n  cssToken,\n  cssInputWrapper,\n  cssTokenInput,\n  cssDeleteButton,\n  cssDeleteIcon,\n};\n\nexport type ITokenFieldStyles = Partial<typeof tokenFieldStyles>;\n"
  },
  {
    "path": "app/client/lib/UrlState.ts",
    "content": "/**\n * Generic support of observable state represented by the current page's URL. The state is\n * initialized on first use, and updated on navigation events, such as Back/Forward button clicks,\n * and on calls to pushUrl().\n *\n * Application-specific module should instantiate UrlState with the desired way to encode state in\n * URLs. Other code may then use the UrlState object exposed by that app-specific module.\n *\n * UrlState also provides functions to navigate: makeUrl(), pushUrl(), and setLinkUrl(). The\n * preferred option is to use actual <a> links for navigation, creating them like so:\n *\n *    import {urlState} from '...appUrlState';\n *    dom('a', urlState().setLinkUrl({org: 'foo'}))\n *    dom('a', urlState().setLinkUrl({docPage: pageId}))\n *\n * These will set actual hrefs (e.g. allowing links to be opened in a new tab), and also will\n * intercept clicks and update history (using pushUrl()) without reloading the page.\n */\nimport * as log from \"app/client/lib/log\";\n\nimport { BaseObservable, Disposable, dom, DomElementMethod, observable } from \"grainjs\";\n\nexport interface UrlStateSpec<IUrlState> {\n  encodeUrl(state: IUrlState, baseLocation: Location | URL): string;\n  decodeUrl(location: Location | URL): IUrlState;\n  updateState(prevState: IUrlState, newState: IUrlState): IUrlState;\n\n  // If present, the return value is checked by pushUrl() to decide if we can stay on the page or\n  // need to load the new URL. The new URL is always loaded if origin changes.\n  needPageLoad(prevState: IUrlState, newState: IUrlState): boolean;\n\n  // Give the implementation a chance to complete outstanding work, e.g. if there is unsaved\n  // data in the page state that would get destroyed.\n  delayPushUrl(prevState: IUrlState, newState: IUrlState): Promise<void>;\n}\n\nexport type UpdateFunc<IUrlState> = (prevState: IUrlState) => IUrlState;\n\n/**\n * Represents the state of a page in browser history, as encoded in window.location URL.\n */\nexport class UrlState<IUrlState extends object> extends Disposable {\n  // Current state. This gets initialized in the constructor, and updated on navigation events.\n  public state = observable<IUrlState>(this._getState());\n\n  constructor(private _window: HistWindow, private _stateImpl: UrlStateSpec<IUrlState>) {\n    super();\n\n    // Create a hook for navigation. It's exposed on the window for overriding in tests.\n    if (!_window._urlStateLoadPage) {\n      _window._urlStateLoadPage = (href) => { _window.location.href = href; };\n    }\n\n    // On navigation events, update our current state, including the observables.\n    this.autoDispose(dom.onElem(this._window, \"popstate\", ev => this.loadState()));\n  }\n\n  /**\n   * Creates a new history entry (navigable with Back/Forward buttons), encoding the given state\n   * in the URL. This is similar to navigating to a new URL, but does not reload the page.\n   */\n  public async pushUrl(urlState: IUrlState | UpdateFunc<IUrlState>,\n    options: { replace?: boolean, avoidReload?: boolean } = {}) {\n    const prevState = this.state.get();\n    const newState = this._mergeState(prevState, urlState);\n\n    const newUrl = this._stateImpl.encodeUrl(newState, this._window.location);\n\n    // Don't create a new history entry if nothing changed as it would only be annoying.\n    if (newUrl === this._window.location.href) { return; }\n\n    const oldOrigin = this._window.location.origin;\n    const newOrigin = new URL(newUrl).origin;\n\n    // We can only pushState() without reloading the page if going to a same-origin URL.\n    const samePage = (oldOrigin === newOrigin &&\n      (options.avoidReload || !this._stateImpl.needPageLoad(prevState, newState)));\n\n    if (samePage) {\n      await this._stateImpl.delayPushUrl(prevState, newState);\n      try {\n        if (options.replace) {\n          this._window.history.replaceState(null, \"\", newUrl);\n        } else {\n          this._window.history.pushState(null, \"\", newUrl);\n        }\n        // pushState/replaceState above do not trigger 'popstate' event, so we call loadState() manually.\n        this.loadState();\n      } catch (e) {\n        // If we fail, we may be in a context where Grist doesn't have\n        // control over history, e.g. an iframe with srcdoc. Go ahead\n        // and apply the application state change (e.g. switching to a\n        // different Grist page). The back button won't work, but what\n        // it should do in an embedded context is full of nuance anyway.\n        log.debug(`pushUrl failure: ${e}`);\n        this.state.set(this._stateImpl.decodeUrl(new URL(newUrl)));\n      }\n    } else {\n      this._window._urlStateLoadPage!(newUrl);\n    }\n  }\n\n  /**\n   * Creates a URL (e.g. to use in a link's href) encoding the given state. The `use` argument\n   * allows for this to be used in a computed, and is used by setLinkUrl() and setHref().\n   *\n   * If urlState is an object (such as IGristUrlState), it gets merged with previous state\n   * according to rules (in gristUrlState's updateState). Alternatively, it can be a function that\n   * takes previous state and returns the new one (without mutating the previous state).\n   */\n  public makeUrl(urlState: IUrlState | UpdateFunc<IUrlState>, use: UseCB = unwrap): string {\n    const fullState = this._mergeState(use(this.state), urlState);\n    return this._stateImpl.encodeUrl(fullState, this._window.location);\n  }\n\n  /**\n   * Sets href on a dom element, e.g. dom('a', setHref({...})).\n   * This is similar to {href: makeUrl(urlState)}, but the destination URL will reflect the\n   * current url state (e.g. due to switching pages).\n   */\n  public setHref(urlState: IUrlState | UpdateFunc<IUrlState>): DomElementMethod {\n    return dom.attr(\"href\", use => this.makeUrl(urlState, use));\n  }\n\n  /**\n   * Applies to an <a> element to create a smart link, e.g. dom('a', setLinkUrl({ws: wsId})). It\n   * both sets the href (e.g. to allow the link to be opened to a new tab), AND intercepts plain\n   * clicks on it to \"follow\" the link without reloading the page.\n   *\n   * If a \"beforeChange\" option is passed in, it will be run just before changing the URL.\n   * It was added to allow clearing any unsaved state before navigating away, in cases where\n   * this makes sense.\n   */\n  public setLinkUrl(\n    urlState: IUrlState | UpdateFunc<IUrlState>,\n    options?: {\n      replace?: boolean,\n      avoidReload?: boolean,\n      beforeChange?: () => void;\n    },\n  ): DomElementMethod[] {\n    return [\n      dom.attr(\"href\", use => this.makeUrl(urlState, use)),\n      dom.on(\"click\", (ev) => {\n        // Only override plain-vanilla clicks.\n        if (ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) { return; }\n        ev.preventDefault();\n        options?.beforeChange?.();\n        return this.pushUrl(urlState, options);\n      }),\n    ];\n  }\n\n  /**\n   * Reset the state from the current URL. This shouldn't normally need to get called. It's called\n   * automatically when needed. It's also used by tests.\n   */\n  public loadState() {\n    log.debug(`loadState ${this._window.location.href}`);\n    this.state.set(this._getState());\n  }\n\n  private _getState(): IUrlState {\n    return this._stateImpl.decodeUrl(this._window.location);\n  }\n\n  private _mergeState(prevState: IUrlState, newState: IUrlState | UpdateFunc<IUrlState>): IUrlState {\n    return (typeof newState === \"object\") ?\n      this._stateImpl.updateState(prevState, newState) :\n      newState(prevState);\n  }\n}\n\n// This is what we expect from the global Window object. Tests may override with a mock.\nexport interface HistWindow extends EventTarget {\n  history: History;\n  location: Location;\n\n  // This is a hook we create, to allow stubbing or overriding in tests.\n  _urlStateLoadPage?: (href: string) => void;\n}\n\n// The type of a 'use' callback as used in a computed(). It's what makes a computed subscribe to\n// its dependencies. The unwrap() helper allows using a dependency without any subscribing.\ntype UseCB = <T>(obs: BaseObservable<T>) => T;\nconst unwrap: UseCB = obs => obs.get();\n"
  },
  {
    "path": "app/client/lib/Validator.ts",
    "content": "import { theme } from \"app/client/ui2018/cssVars\";\n\nimport { Disposable, dom, Observable, styled } from \"grainjs\";\n\n/**\n * Simple validation controls. Renders as a red text with a validation message.\n *\n * Sample usage:\n *\n *    const group = new ValidationGroup();\n *    async function save() {\n *     if (await group.validate()) {\n *       api.save(....)\n *     }\n *    }\n *    ....\n *    dom('div',\n *     dom('Login', 'Enter login', input(login), group.resetInput()),\n *     dom.create(Validator, accountGroup, 'Login is required', () => Boolean(login.get()) === true)),\n *     dom.create(Validator, accountGroup, 'Login must by unique', async () => await throwsIfLoginIsTaken(login.get())),\n *     dom('button', dom.on('click', save))\n *    )\n */\n\n/**\n * Validation function. Can return either boolean value or throw an error with a message that will be displayed\n * in a validator instance.\n */\ntype ValidationFunction = () => (boolean | Promise<boolean> | void | Promise<void>);\n\n/**\n * Validation groups allow you to organize validator controls on a page as a set.\n * Each validation group can perform validation independently from other validation groups on the page.\n */\nexport class ValidationGroup {\n  // List of attached validators.\n  private _validators: Validator[] = [];\n  /**\n   * Runs all validators check functions. Returns result of the validation.\n   */\n  public async validate() {\n    let valid = true;\n    for (const val of this._validators) {\n      try {\n        const result = await val.check();\n        // Validator can either return boolean, Promise<boolean> or void. Booleans are straightforwards.\n        // When validator has a void/Promise<void> result it means that it just asserts certain invariant, and should\n        // throw an exception when this invariant is not met. Error message can be used to amend the message in the\n        // validator instance.\n        const isValid = typeof result === \"boolean\" ? result : true;\n        val.set(isValid);\n        if (!isValid) { valid = false; break; }\n      } catch (err) {\n        valid = false;\n        val.set((err as Error).message);\n        break;\n      }\n    }\n    return valid;\n  }\n\n  /**\n   * Attaches single validator instance to this group. Validator can be in multiple groups\n   * at the same time.\n   */\n  public add(validator: Validator) {\n    this._validators.push(validator);\n  }\n\n  /**\n   * Helper that can be attached to the input element to reset validation status.\n   */\n  public inputReset() {\n    return dom.on(\"input\", this.reset.bind(this));\n  }\n\n  /**\n   * Reset all validators statuses.\n   */\n  public reset() {\n    this._validators.forEach(val => val.set(true));\n  }\n}\n\n/**\n * Validator instance. When triggered shows a red text with an error message.\n */\nexport class Validator extends Disposable {\n  private _isValid = Observable.create(this, true);\n  private _message = Observable.create(this, \"\");\n  constructor(public group: ValidationGroup, message: string, public check: ValidationFunction) {\n    super();\n    group.add(this);\n    this._message.set(message);\n  }\n\n  /**\n   * Helper that can be attached to the input element to reset validation status.\n   */\n  public inputReset() {\n    return dom.on(\"input\", this.set.bind(this, true));\n  }\n\n  /**\n   * Sets the validation status. If isValid is a string it is treated as a falsy value, and will\n   * mark this validator as invalid.\n   */\n  public set(isValid: boolean | string) {\n    if (this.isDisposed()) { return; }\n    if (typeof isValid === \"string\") {\n      this._message.set(isValid);\n      this._isValid.set(!isValid);\n    } else {\n      this._isValid.set(isValid ? true : false);\n    }\n  }\n\n  public buildDom() {\n    return cssError(\n      dom.text(this._message),\n      dom.hide(this._isValid),\n    );\n  }\n}\n\nconst cssError = styled(\"div.validator\", `\n  color: ${theme.errorText};\n`);\n"
  },
  {
    "path": "app/client/lib/airtable/AirtableImportUI.ts",
    "content": "import {\n  AirtableImportDestination,\n  AirtableImportResult,\n  applyAirtableImportSchemaAndImportData, ExistingDoc, NewDoc,\n  validateAirtableSchemaImport,\n} from \"app/client/lib/airtable/AirtableImporter\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { cssMarkdownSpan, markdown } from \"app/client/lib/markdown\";\nimport { reportError } from \"app/client/models/errors\";\nimport { getHomeUrl } from \"app/client/models/homeUrl\";\nimport { cssWell } from \"app/client/ui/AdminPanelCss\";\nimport { cssCodeBlock } from \"app/client/ui/CodeHighlight\";\nimport { textInput } from \"app/client/ui/inputs\";\nimport { shadowScroll } from \"app/client/ui/shadowScroll\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { bigBasicButton, bigPrimaryButton, textButton } from \"app/client/ui2018/buttons\";\nimport { cssLabelText, cssRadioCheckboxOptions, radioCheckboxOption } from \"app/client/ui2018/checkbox\";\nimport { isNarrowScreenObs, mediaSmall, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { cssIconButton, icon } from \"app/client/ui2018/icons\";\nimport { cssNestedLinks } from \"app/client/ui2018/links\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport {\n  menu,\n  menuDivider,\n  menuIcon,\n  menuItem,\n  menuSubHeader,\n  menuText,\n  selectMenu,\n  selectOption,\n} from \"app/client/ui2018/menus\";\nimport { cssModalBody, cssModalButtons } from \"app/client/ui2018/modals\";\nimport { AirtableAPI } from \"app/common/airtable/AirtableAPI\";\nimport { AirtableBaseSchema } from \"app/common/airtable/AirtableAPITypes\";\nimport { AirtableImportProgress } from \"app/common/airtable/AirtableDataImporterTypes\";\nimport { gristDocSchemaFromAirtableSchema } from \"app/common/airtable/AirtableSchemaImporter\";\nimport { BaseAPI } from \"app/common/BaseAPI\";\nimport { DocSchemaImportWarning, ImportSchemaTransformParams, transformImportSchema } from \"app/common/DocSchemaImport\";\nimport { ExistingDocSchema } from \"app/common/DocSchemaImportTypes\";\nimport { commonUrls } from \"app/common/gristUrls\";\nimport { components, tokens } from \"app/common/ThemePrefs\";\nimport { addCurrentOrgToPath } from \"app/common/urlUtils\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport { MaybePromise } from \"app/plugin/gutil\";\n\nimport { Computed, Disposable, dom, DomElementArg, IDisposableOwner, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"AirtableImport\");\n\ninterface AirtableBase {\n  id: string;                    // Base ID (e.g., \"appXXXXXXXXXXXXXX\")\n  name: string;                  // Base name\n  permissionLevel: string;       // Permission level (e.g., \"owner\", \"create\", \"edit\", \"comment\", \"read\", \"none\")\n}\n\ninterface AirtableToGristMapping {\n  tableId: string;\n  tableName: string;\n  destination: Observable<NewTable | ExistingTable | null>;\n}\n\ninterface NewTable {\n  type: \"new-table\";\n  structureOnly: boolean;\n}\n\ninterface ExistingTable {\n  type: \"existing-table\";\n  tableId: string;\n}\n\ninterface TokenPayload {\n  access_token?: string;\n  expires_at?: number;\n  error?: string;\n};\n\ntype AirtableImportStep = \"auth\" | \"select-base\" | \"select-tables\";\n\ntype Destination = ExistingDoc & { docSchema?: Computed<ExistingDocSchema> } | Omit<NewDoc, \"name\">;\n\nexport interface AirtableImportOptions {\n  api: UserAPI;\n  destination: Destination;\n  onSuccess(result: AirtableImportResult): MaybePromise<void>;\n  onError(error: unknown): void;\n  onCancel(): void;\n}\n\nexport class AirtableImport extends Disposable {\n  // Check for a base URL override, for use in tests.\n  public static AIRTABLE_API_BASE = (window as any).testAirtableImportBaseUrlOverride ||\n    \"https://api.airtable.com\";\n\n  private _authMethod = Observable.create<\"oauth\" | \"personal-access-token\" | null>(this, null);\n  private _currentStep = Observable.create<AirtableImportStep>(this, \"auth\");\n  private _isOAuthConfigured = Observable.create<boolean | null>(this, null);\n  private _showPersonalAccessTokenInput = Observable.create(this, false);\n  private _personalAccesToken = Observable.create(this, \"\");\n  private _accessToken = Observable.create<string | null>(this, null);\n  private _bases = Observable.create<AirtableBase[]>(this, []);\n  private _connecting = Observable.create(this, false);\n  private _loadingBases = Observable.create(this, false);\n  private _base = Observable.create<AirtableBase | null>(this, null);\n  private _baseSchema = Observable.create<AirtableBaseSchema | null>(this, null);\n  private _loadingBaseSchema = Observable.create(this, false);\n  private _importing = Observable.create(this, false);\n  private _importProgress: Observable<AirtableImportProgress> =  Observable.create(this, { percent: 0 });\n  private _error = Observable.create<string | null>(this, null);\n  private _oauth2ClientsApi = new OAuth2ClientsAPI();\n  private _api = this._options.api;\n  private _existingDocSchema = this._options.destination.type === \"existing-doc\" ?\n    this._options.destination.docSchema : undefined;\n\n  private _existingTables = Computed.create(this, (use) => {\n    const existingDocSchema = this._existingDocSchema ? use(this._existingDocSchema) : null;\n    return existingDocSchema ? existingDocSchema.tables : [];\n  });\n\n  private _existingTablesById = Computed.create(this, (use) => {\n    const existingTables = use(this._existingTables);\n    return new Map(existingTables.map(t => [t.id, t]));\n  });\n\n  private _tableMappings = Observable.create<AirtableToGristMapping[]>(this, []);\n\n  private _skipTableIds = Computed.create(this, (use) => {\n    const mappings = use(this._tableMappings);\n    return mappings.filter(m => !use(m.destination)).map(d => d.tableId);\n  });\n\n  private _mapExistingTableIds = Computed.create(this, (use) => {\n    const existingTablesById = use(this._existingTablesById);\n    const mappings = use(this._tableMappings);\n    const existingTableMappings = mappings.filter((m) => {\n      const d = use(m.destination);\n      return d?.type === \"existing-table\" && existingTablesById?.has(d.tableId);\n    });\n\n    return new Map(existingTableMappings.map((m) => {\n      const d = use(m.destination) as ExistingTable;\n      return [m.tableId, d.tableId];\n    }));\n  });\n\n  private _newTables = Computed.create(this, (use) => {\n    const mappings = use(this._tableMappings);\n    return mappings.filter(m => use(m.destination)?.type === \"new-table\");\n  });\n\n  private _structureOnlyTableIds = Computed.create(this, (use) => {\n    const newTables = use(this._newTables);\n    return newTables.filter(m => (use(m.destination) as NewTable).structureOnly).map(m => m.tableId);\n  });\n\n  private _importTablesCount = Computed.create(this, (use) => {\n    const existingTablesCount = use(this._mapExistingTableIds).size;\n    const newTablesCount = use(this._newTables).length;\n    return existingTablesCount + newTablesCount;\n  });\n\n  private _warningsByTableId = Computed.create(this, (use) => {\n    const warningsByTableId = new Map<string, DocSchemaImportWarning[]>();\n\n    const baseSchema = use(this._baseSchema);\n    if (!baseSchema) { return warningsByTableId; }\n\n    const existingDocSchema = this._existingDocSchema ? use(this._existingDocSchema) : undefined;\n\n    const skipTableIds = use(this._skipTableIds);\n    const mapExistingTableIds = use(this._mapExistingTableIds);\n    const transformations: ImportSchemaTransformParams = {\n      skipTableIds,\n      mapExistingTableIds,\n    };\n\n    const warnings = validateAirtableSchemaImport(baseSchema, existingDocSchema, transformations);\n\n    for (const warning of warnings) {\n      const tableId = warning.ref?.originalTableId;\n      if (tableId) {\n        const tableWarnings = warningsByTableId.get(tableId) || [];\n        tableWarnings.push(warning);\n        warningsByTableId.set(tableId, tableWarnings);\n      }\n    }\n\n    return warningsByTableId;\n  });\n\n  private _onSuccess = this._options.onSuccess.bind(this);\n\n  private _onError = this._options.onError.bind(this);\n\n  private _onCancel = this._options.onCancel.bind(this);\n\n  constructor(private _options: AirtableImportOptions) {\n    super();\n    this._checkForToken();\n  }\n\n  public buildDom() {\n    return dom.domComputed(this._currentStep, (step) => {\n      switch (step) {\n        case \"auth\": {\n          return this._buildAuthDialog();\n        }\n        case \"select-base\": {\n          return dom.create(this._basesList.bind(this));\n        }\n        case \"select-tables\": {\n          return this._buildBaseTables();\n        }\n      }\n    });\n  }\n\n  // Auth Dialog Component\n  private _buildAuthDialog() {\n    return cssNestedLinks(cssMainContent(\n      dom(\"div\", t(\"Connect your Airtable account to access your bases.\")),\n\n      dom.maybe(this._error, err => cssError(err)),\n\n      dom.domComputed(this._showPersonalAccessTokenInput, (showPersonalAccessTokenInput) => {\n        if (!showPersonalAccessTokenInput) {\n          return this._buildSelectAuthMethodScreen();\n        } else {\n          return this._buildPersonalAccessTokenScreen();\n        }\n      }),\n    ));\n  }\n\n  private _buildSelectAuthMethodScreen() {\n    return dom.domComputed(this._isOAuthConfigured, (isOAuthConfigured) => {\n      if (isOAuthConfigured) {\n        return [\n          bigPrimaryButton(\n            dom.text(use => use(this._connecting) ? t(\"Connecting...\") : t(\"Connect with Airtable\")),\n            dom.prop(\"disabled\", this._connecting),\n            dom.on(\"click\", this._handleOAuthLogin.bind(this)),\n            testId(\"import-airtable-connect\"),\n          ),\n          cssDivider(cssDividerLine(), t(\"or\"), cssDividerLine()),\n          bigBasicButton(\n            t(\"Use personal access token instead\"),\n            dom.on(\"click\", () => this._showPersonalAccessTokenInput.set(true)),\n            testId(\"import-airtable-use-personal-access-token\"),\n          ),\n        ];\n      } else {\n        return [\n          bigPrimaryButton(\n            t(\"Use personal access token\"),\n            dom.on(\"click\", () => this._showPersonalAccessTokenInput.set(true)),\n            testId(\"import-airtable-use-personal-access-token\"),\n          ),\n          cssHelperText(\n            cssMarkdownSpan(\n              t(`The more convenient ‘Connect with Airtable’ option can be configured by \\\nthe installation administrator. [Learn more.]({{url}})`, {\n                url: commonUrls.helpAirtableIntegration,\n              }),\n            ),\n            testId(\"import-airtable-connect-hint\"),\n          ),\n        ];\n      }\n    });\n  }\n\n  private _buildPersonalAccessTokenScreen() {\n    return [\n      cssInputGroup(\n        cssLabel(t(\"Personal access token\")),\n        cssTextInput(this._personalAccesToken, { type: \"password\", placeholder: \"patXXXXXXXXXXXXXXXX\" },\n          dom.onKeyPress({ Enter: this._handlePersonalAccessTokenLogin.bind(this) }),\n        ),\n        cssHelperText(markdown(\n          t(`[Generate a token]({{url}}) in your Airtable \\\naccount with scopes that include at least **\\`schema.bases:read\\`** and **\\`data.records:read\\`**.\n\nYour token is never sent to Grist's servers, and is only used to make API calls to Airtable from your browser.`,\n          { url: \"https://airtable.com/create/tokens\" }),\n        )),\n      ),\n      bigPrimaryButton(\n        dom.text(use => use(this._connecting) ? t(\"Connecting...\") : t(\"Connect\")),\n        dom.prop(\"disabled\", use => use(this._connecting) || !use(this._personalAccesToken).trim()),\n        dom.on(\"click\", this._handlePersonalAccessTokenLogin.bind(this)),\n      ),\n      cssTextButton(\n        t(\"Back\"),\n        dom.on(\"click\", () => {\n          this._showPersonalAccessTokenInput.set(false);\n          this._personalAccesToken.set(\"\");\n          this._error.set(null);\n        }),\n      ),\n    ];\n  }\n\n  private _connectionMenu() {\n    return menu(() => [\n      cssMenuText(\n        menuIcon(\"Info\"),\n        dom.text(use =>\n          t(\"Connected via {{method}}\", {\n            method: use(this._authMethod) === \"oauth\" ? t(\"OAuth\") : t(\"Personal access token\"),\n          }),\n        ),\n      ),\n      menuDivider(),\n      menuItem(this._handleRefresh.bind(this), menuIcon(\"Convert\"), t(\"Refresh\")),\n      menuItem(this._handleLogout.bind(this), menuIcon(\"Remove\"), t(\"Disconnect\")),\n    ]);\n  }\n\n  private _basesList(owner: IDisposableOwner) {\n    const selected = Observable.create<string | null>(owner, null);\n    return [\n      cssChooseBase(t(\"Choose an Airtable base to import from\"),\n        cssSettingsButton(icon(\"Settings\"), this._connectionMenu(), testId(\"import-airtable-settings\")),\n      ),\n      dom.maybe(this._error, err => cssError(err)),\n\n      cssScrollableContent(\n        dom.domComputed(use => [use(this._loadingBases), use(this._bases)] as const, ([isLoading, basesList]) => [\n          (isLoading ?\n            cssLoading(\n              loadingSpinner(),\n              cssHelperText(t(\"loading your bases...\")),\n            ) :\n            (basesList.length === 0 ?\n              cssWarning(\n                t(\"No bases found\"),\n                cssHelperText(t(\"Make sure your token has the correct permissions.\")),\n              ) :\n              cssRadioOptions(\n                basesList.map(base =>\n                  radioCheckboxOption(selected, base.id, [\n                    cssBaseName(base.name, testId(\"import-airtable-name\")),\n                    cssBaseId(base.id, testId(\"import-airtable-id\")),\n                  ]),\n                ),\n              )\n            )\n          ),\n        ]),\n        testId(\"import-airtable-bases\"),\n      ),\n      cssFooterButtons(\n        bigPrimaryButton(\n          t(\"Continue\"),\n          dom.prop(\"disabled\", use => use(this._loadingBases) || !use(selected)),\n          dom.on(\"click\", () => this._handleSelectBase(selected.get()!)),\n          testId(\"import-airtable-continue\"),\n        ),\n        bigBasicButton(t(\"Cancel\"), dom.on(\"click\", this._onCancel)),\n      ),\n    ];\n  }\n\n  private _buildBaseTables() {\n    return [\n      cssSelectTables(\n        dom.domComputed(this._importing, importing => importing ?\n          t(\"Import from {{baseName}} in progress. Do not navigate away from this page.\", {\n            baseName: dom(\"strong\", this._base.get()!.name),\n          }) :\n          t(\"Select tables to import from {{baseName}}\", {\n            baseName: dom(\"strong\", this._base.get()!.name),\n          }),\n        ),\n      ),\n      dom.maybe(this._error, err => cssError(err)),\n\n      cssScrollableContent(\n        dom.domComputed(\n          use => [use(this._loadingBaseSchema), use(this._tableMappings), use(this._importing)] as const,\n          ([isLoadingSchema, mappings, isImporting]) => [\n            (isLoadingSchema || isImporting ?\n              cssLoading(\n                loadingSpinner(dom.hide(isImporting)),\n                cssHelperText(\n                  isImporting ?\n                    dom.text(use => use(this._importProgress).status ?? \"\") :\n                    t(\"loading your tables...\"),\n                ),\n                isImporting ? cssProgressBarContainer(\n                  cssProgressBarFill(\n                    dom.style(\"width\", use => `${use(this._importProgress).percent}%`),\n                  ),\n                ) : null,\n              ) :\n              this._tableMappingsList(mappings)\n            ),\n          ],\n        ),\n      ),\n      cssFooterButtons(\n        bigPrimaryButton(\n          dom.text((use) => {\n            const count = use(this._importTablesCount);\n            return count > 0 ? t(\"Import {{count}} tables\", { count }) : t(\"Import tables\");\n          }),\n          dom.prop(\"disabled\", use =>\n            use(this._loadingBaseSchema) ||\n            use(this._importTablesCount) === 0,\n          ),\n          dom.hide(this._importing),\n          dom.on(\"click\", () => this._handleImport()),\n          testId(\"import-airtable-import\"),\n        ),\n        // TODO: Cancel currently only closes the modal. Make it also stop any import in progress.\n        bigBasicButton(t(\"Cancel\"), dom.on(\"click\", this._onCancel)),\n      ),\n    ];\n  }\n\n  private _tableMappingsList(mappings: AirtableToGristMapping[]) {\n    return cssMappingsGrid(\n      cssMappingsHeaderColumn(t(\"Source tables\")),\n      cssMappingsHeaderColumn(t(\"Destination\")),\n      mappings.map(m => this._tableMapping(m)),\n      testId(\"import-airtable-mappings\"),\n    );\n  }\n\n  private _tableMapping(mapping: AirtableToGristMapping) {\n    return [\n      cssTableNameColumn(\n        cssTableIconAndName(\n          cssTableIcon(\"TypeTable\"),\n          cssTableName(\n            mapping.tableName,\n            testId(`import-airtable-table-name`),\n          ),\n        ),\n      ),\n      this._destinationMenu(mapping),\n      this._tableWarnings(mapping),\n    ];\n  }\n\n  private _destinationMenu(mapping: AirtableToGristMapping) {\n    return selectMenu(\n      cssDestinationIconAndName(\n        this._destinationMenuLabel(mapping),\n        testId(\"import-airtable-destination-label\"),\n      ),\n      () => this._destinationMenuOptions(mapping),\n      cssDestinationMenu.cls(\"\"),\n      testId(`import-airtable-table-${mapping.tableId}-destination`),\n    );\n  }\n\n  private _destinationMenuLabel(mapping: AirtableToGristMapping) {\n    return dom.domComputed((use) => {\n      const destination = use(mapping.destination);\n      const existingTablesById = use(this._existingTablesById);\n\n      if (destination?.type === \"existing-table\" && existingTablesById.has(destination.tableId)) {\n        const { name, id } = existingTablesById.get(destination.tableId)!;\n        return [\n          cssDestinationIcon(\"FieldTable\"),\n          cssDestinationName(name || id),\n        ];\n      } else if (destination?.type === \"new-table\" && !destination.structureOnly) {\n        return [\n          cssDestinationIcon(\"Plus\"),\n          cssDestinationName(t(\"New table\")),\n        ];\n      } else if (destination?.type === \"new-table\" && destination.structureOnly) {\n        return [\n          cssDestinationIcon(\"Plus\"),\n          cssDestinationName(t(\"Structure only\")),\n        ];\n      } else if (!destination) {\n        return [\n          cssDestinationIcon(\"CrossBig\"),\n          cssDestinationName(t(\"Skip\")),\n        ];\n      }\n    });\n  }\n\n  private _destinationMenuOptions(mapping: AirtableToGristMapping) {\n    return [\n      menuSubHeader(t(\"Choose destination\")),\n      selectOption(\n        () => {\n          mapping.destination.set({ type: \"new-table\", structureOnly: false });\n        },\n        t(\"New table\"),\n        \"Plus\",\n        cssAccentIconColor.cls(\"\"),\n      ),\n      selectOption(\n        () => {\n          mapping.destination.set({ type: \"new-table\", structureOnly: true });\n        },\n        t(\"New table: structure only\"),\n        \"Plus\",\n        cssAccentIconColor.cls(\"\"),\n      ),\n      selectOption(\n        () => {\n          mapping.destination.set(null);\n        },\n        t(\"Skip\"),\n        \"CrossBig\",\n        cssAccentIconColor.cls(\"\"),\n      ),\n      dom.domComputed(this._existingTables, existingTables => existingTables && existingTables.length > 0 ? [\n        menuDivider(),\n        menuSubHeader(t(\"Existing tables\")),\n        existingTables.map(({ id, name }) =>\n          selectOption(\n            () => {\n              mapping.destination.set({ type: \"existing-table\", tableId: id });\n            },\n            name || id,\n            \"FieldTable\",\n            cssAccentIconColor.cls(\"\"),\n          ),\n        ),\n      ] : null),\n    ];\n  }\n\n  private _tableWarnings({ tableId }: AirtableToGristMapping) {\n    return dom.domComputed((use) => {\n      const warningsByTableId = use(this._warningsByTableId);\n      const warnings = warningsByTableId.get(tableId);\n      if (warnings && warnings.length > 0) {\n        return cssTableWarnings(\n          cssWarningIcon(\"Warning2\"),\n          cssWarningsLabel(t(\"{{count}} warnings\", { count: String(warnings.length) })),\n          hoverTooltip(() => cssWarningsList(\n            dom.forEach(warnings, w => dom(\"li\", w.message)),\n          )),\n        );\n      } else {\n        return cssTableWarnings(dom.hide(isNarrowScreenObs()));\n      }\n    });\n  }\n\n  private _checkForToken() {\n    void this._doAsyncWork(async () => {\n      try {\n        const payload = await this._oauth2ClientsApi.fetchToken();\n        this._isOAuthConfigured.set(true);\n        if (this.isDisposed()) { return; }\n        this._handleTokenPayload(payload);\n      } catch (err) {\n        if (this.isDisposed()) { return; }\n        this._accessToken.set(null);\n        this._authMethod.set(null);\n        if ([400, 404].includes(err.status)) {\n          // OAuth endpoint unavailable or integration not configured.\n          this._isOAuthConfigured.set(false);\n        } else if (err.status === 401) {\n          // No tokens. That's not an error!\n          this._isOAuthConfigured.set(true);\n        } else {\n          this._error.set(String(err));\n          this._isOAuthConfigured.set(true);\n        }\n      }\n    }, { loading: this._connecting });\n  }\n\n  private _handleOAuthLogin() {\n    const baseUrl = addCurrentOrgToPath(getHomeUrl());\n    const authUrl = new URL(`${baseUrl}/oauth2/airtable/authorize`);\n    authUrl.searchParams.set(\"openerOrigin\", location.origin);\n    const lis = dom.onElem(window, \"message\", (event: Event) => {\n      const homeOrigin = new URL(getHomeUrl()).origin;\n      const eventOrigin = (event as MessageEvent).origin;\n      if (eventOrigin !== homeOrigin) {\n        console.warn(`Not trusting event from an unrecognized origin: ${eventOrigin}`);\n        return;\n      }\n      lis.dispose();\n      if (this.isDisposed()) { return; }\n\n      // The payload may contain an error, but we avoid sending tokens, to keep fewer paths for\n      // sensitive data. If no error, the API call should succeed in returning a token.\n      const payload = (event as MessageEvent).data;\n      if (payload && typeof payload === \"object\" && typeof payload.error === \"string\") {\n        this._error.set(payload.error);\n      } else {\n        this._checkForToken();\n      }\n    });\n    window.open(authUrl, \"_blank\");\n  }\n\n  private _handleTokenPayload(payload: TokenPayload) {\n    if (payload.error) {\n      console.error(\"OAuth error:\", payload.error);\n      this._error.set(String(payload.error));\n    } else {\n      this._accessToken.set(payload.access_token!);\n      this._authMethod.set(\"oauth\");\n      this._currentStep.set(\"select-base\");\n      this._fetchBases(payload.access_token!);\n    }\n  }\n\n  private async _handlePersonalAccessTokenLogin() {\n    const token = this._personalAccesToken.get().trim();\n    if (!token) {\n      this._error.set(t(\"Please enter a personal access token\"));\n    } else {\n      this._accessToken.set(token);\n      this._authMethod.set(\"personal-access-token\");\n      this._currentStep.set(\"select-base\");\n      this._fetchBases(token);\n    }\n  }\n\n  private async _handleSelectBase(baseId: string) {\n    const base = this._bases.get().find(b => b.id === baseId)!;\n    this._base.set(base);\n    this._currentStep.set(\"select-tables\");\n    this._fetchBaseSchema();\n  }\n\n  private _destination(): AirtableImportDestination {\n    const destination = this._options.destination;\n    if (destination.type === \"existing-doc\") {\n      return destination;\n    }\n    return {\n      ...destination,\n      name: this._base.get()?.name,\n    };\n  }\n\n  private _handleImport() {\n    void this._doAsyncWork(async () => {\n      try {\n        const { schema } = gristDocSchemaFromAirtableSchema(this._baseSchema.get()!);\n        const transformations: ImportSchemaTransformParams = {\n          skipTableIds: this._skipTableIds.get(),\n          mapExistingTableIds: this._mapExistingTableIds.get(),\n        };\n        const { schema: importSchema } = transformImportSchema(schema, transformations, this._existingDocSchema?.get());\n        const result = await applyAirtableImportSchemaAndImportData({\n          importSchema,\n          dataSource: {\n            api: new AirtableAPI({\n              apiKey: this._accessToken.get()!,\n              endpointUrl: AirtableImport.AIRTABLE_API_BASE,\n            }),\n            baseId: this._base.get()!.id,\n          },\n          userApi: this._api,\n          options: {\n            destination: this._destination(),\n            transformations,\n            structureOnlyTableIds: this._structureOnlyTableIds.get(),\n            onProgress: (progress) => {\n              if (this.isDisposed()) { return; }\n\n              this._importProgress.set(progress);\n            },\n          },\n        });\n        if (this.isDisposed()) { return; }\n        await this._onSuccess(result);\n      } catch (err) {\n        if (this.isDisposed()) { return; }\n        this._onError(err);\n      }\n    }, { loading: this._importing });\n  }\n\n  private async _doAsyncWork(\n    doWork: () => Promise<void>,\n    options: { loading?: Observable<boolean> } = {},\n  ) {\n    const { loading } = options;\n    if (loading) { loading.set(true); }\n    this._error.set(null);\n    try {\n      await doWork();\n    } catch (err) {\n      if (!this.isDisposed()) {\n        this._error.set(err.message);\n      }\n    } finally {\n      if (!this.isDisposed() && loading) {\n        loading.set(false);\n      }\n    }\n  }\n\n  private _fetchBases(token: string) {\n    void this._doAsyncWork(async () => {\n      const response = await fetch(`${AirtableImport.AIRTABLE_API_BASE}/v0/meta/bases`, {\n        headers: { Authorization: `Bearer ${token}` },\n      });\n      if (!response.ok) { throw new Error(t(\"Failed to fetch bases\")); }\n      const data = await response.json();\n      if (this.isDisposed()) { return; }\n      this._bases.set(data.bases || []);\n    }, { loading: this._loadingBases });\n  }\n\n  private _fetchBaseSchema() {\n    void this._doAsyncWork(async () => {\n      const api = new AirtableAPI({\n        apiKey: this._accessToken.get()!,\n        endpointUrl: AirtableImport.AIRTABLE_API_BASE,\n      });\n      try {\n        const baseSchema = await api.getBaseSchema(this._base.get()!.id);\n        if (this.isDisposed()) { return; }\n        this._baseSchema.set(baseSchema);\n        this._tableMappings.set(baseSchema.tables.map(table => ({\n          tableId: table.id,\n          tableName: table.name,\n          destination: Observable.create(this, {\n            type: \"new-table\" as const,\n            structureOnly: false,\n          }),\n        })));\n      } catch {\n        throw new Error(t(\"Failed to fetch base schema\"));\n      }\n    }, { loading: this._loadingBaseSchema });\n  }\n\n  private _handleLogout() {\n    this._oauth2ClientsApi.deleteToken().catch(reportError);\n    this._accessToken.set(null);\n    this._authMethod.set(null);\n    this._bases.set([]);\n    this._personalAccesToken.set(\"\");\n    this._showPersonalAccessTokenInput.set(false);\n    this._error.set(null);\n    this._currentStep.set(\"auth\");\n  }\n\n  private _handleRefresh() {\n    const token = this._accessToken.get();\n    if (token) { this._fetchBases(token); }\n  }\n}\n\n/**\n * Helper to make requests to OAuth2Clients API endpoints.\n * TODO This should be moved to a shared place once there is other code that may benefit.\n */\nclass OAuth2ClientsAPI extends BaseAPI {\n  private _homeUrl: string;   // Home URL, guaranteed to be without trailing slashes.\n  constructor(homeUrl: string = getHomeUrl()) {\n    super();\n    this._homeUrl = addCurrentOrgToPath(homeUrl.replace(/\\/+$/, \"\"));\n  }\n\n  public fetchToken(): Promise<TokenPayload> { return this.requestJson(`${this._homeUrl}/oauth2/airtable/token`); }\n  public deleteToken() { return this.requestJson(`${this._homeUrl}/oauth2/airtable/token`, { method: \"DELETE\" }); }\n}\n\nfunction cssWarning(...args: DomElementArg[]) {\n  return cssWell(cssWell.cls(\"-warning\"), cssIcon(icon(\"Warning\")), dom(\"div\", ...args),\n    testId(\"import-airtable-warning\"));\n}\n\nfunction cssError(...args: DomElementArg[]) {\n  return cssWell(cssWell.cls(\"-error\"), cssIcon(icon(\"Warning\")), dom(\"div\", ...args),\n    testId(\"import-airtable-error\"));\n}\n\n// Styled Components\n\nconst cssMainContent = styled(cssModalBody, `\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n`);\n\nconst cssIcon = styled(\"div\", `\n  flex-shrink: 0;\n`);\n\nconst cssDivider = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n`);\n\nconst cssDividerLine = styled(\"div\", `\n  border-bottom: 1px solid ${theme.menuBorder};\n  flex-grow: 1;\n`);\n\nconst cssInputGroup = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n`);\n\nconst cssLabel = styled(\"label\", `\n  font-weight: bold;\n`);\n\nconst cssTextInput = styled(textInput, `\n  height: 28px;\n`);\n\nconst cssHelperText = styled(\"div\", `\n  color: ${theme.lightText};\n`);\n\nconst cssTextButton = styled(textButton, `\n  align-self: center;\n`);\n\nconst cssLoading = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n  align-items: center;\n`);\n\nconst cssScrollableContent = styled(shadowScroll, `\n  flex: 1 1 auto;\n  width: auto;\n  margin: 0 -64px;\n  padding: 16px 64px 24px 64px;\n  border-bottom: 1px solid ${theme.modalBorderDark};\n\n  @media ${mediaSmall} {\n    & {\n      margin: 0 -16px;\n      padding: 16px;\n    }\n  }\n`);\n\nconst cssChooseBase = styled(\"div\", `\n  color: ${components.mediumText};\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  gap: 16px;\n  margin-bottom: 8px;\n`);\n\nconst cssSelectTables = styled(\"div\", `\n  color: ${components.mediumText};\n  margin-bottom: 8px;\n`);\n\nconst cssSettingsButton = styled(cssIconButton, `\n  --icon-color: ${theme.controlFg};\n`);\n\nconst cssMenuText = styled(menuText, `\n  font-size: revert;\n  --icon-color: ${theme.lightText};\n`);\n\nconst cssRadioOptions = styled(cssRadioCheckboxOptions, `\n  & .${cssLabelText.className} {\n    display: flex;\n    justify-content: space-between;\n    gap: 16px;\n    width: 100%;\n  }\n`);\n\nconst cssBaseName = styled(\"span\", `\n  font-weight: bold;\n`);\n\nconst cssBaseId = styled(cssCodeBlock, `\n  color: ${theme.lightText};\n`);\n\nconst cssFooterButtons = styled(cssModalButtons, `\n  margin: 16px 0 -16px 0;\n`);\n\nconst cssMappingsGrid = styled(\"div\", `\n  align-items: baseline;\n  display: grid;\n  gap: 16px;\n  grid-template-columns: minmax(220px, auto) minmax(160px, auto) auto;\n\n  @media ${mediaSmall} {\n    & {\n      grid-template-columns: minmax(160px, auto) minmax(120px, auto);\n    }\n  }\n`);\n\nconst cssMappingsHeaderColumn = styled(\"div\", `\n  color: ${components.mediumText};\n  font-size: ${tokens.smallFontSize};\n  text-transform: uppercase;\n`);\n\nconst cssTableNameColumn = styled(\"div\", `\n  font-weight: bold;\n  grid-column: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n`);\n\nconst cssTableIconAndName = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 16px;\n`);\n\nconst cssTableIcon = styled(icon, `\n  flex-shrink: 0;\n  --icon-color: ${theme.accentIcon};\n`);\n\nconst cssTableName = styled(\"div\", `\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n`);\n\nconst cssDestinationIconAndName = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 4px;\n`);\n\nconst cssDestinationIcon = styled(icon, `\n  flex-shrink: 0;\n  --icon-color: ${theme.accentIcon};\n`);\n\nconst cssDestinationName = styled(\"div\", `\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n`);\n\nconst cssAccentIconColor = styled(\"div\", `\n  --icon-color: ${theme.accentIcon};\n`);\n\nconst cssTableWarnings = styled(\"div\", `\n  align-items: center;\n  display: flex;\n  gap: 4px;\n\n  @media ${mediaSmall} {\n    & {\n      grid-column-start: 2;\n      grid-column-end: 3;\n      margin-bottom: 8px;\n    }\n  }\n`);\n\nconst cssWarningIcon = styled(icon, `\n  flex-shrink: 0;\n  height: 20px;\n  width: 20px;\n  --icon-color: ${theme.iconError};\n`);\n\nconst cssWarningsLabel = styled(\"div\", `\n  cursor: default;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n`);\n\nconst cssWarningsList = styled(\"ul\", `\n  margin: 0px;\n  max-width: 400px;\n  padding: 8px 16px;\n  text-align: left;\n`);\n\nconst cssProgressBarContainer = styled(\"div\", `\n  width: 100%;\n  height: 4px;\n  border-radius: 5px;\n  background: ${theme.progressBarBg};\n`);\n\nconst cssProgressBarFill = styled(cssProgressBarContainer, `\n  background: ${theme.progressBarFg};\n  transition: width 0.4s linear;\n\n  &-approaching-limit {\n    background: ${theme.progressBarErrorFg};\n  }\n`);\n\nconst cssDestinationMenu = styled(\"div\", `\n  grid-column: 2;\n`);\n"
  },
  {
    "path": "app/client/lib/airtable/AirtableImporter.ts",
    "content": "import { getExistingDocSchema } from \"app/client/lib/DocSchemaImport\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { AirtableAPI, listRecords } from \"app/common/airtable/AirtableAPI\";\nimport { AirtableBaseSchema } from \"app/common/airtable/AirtableAPITypes\";\nimport { AirtableCrosswalkWarning, createAirtableBaseToGristDocCrosswalk } from \"app/common/airtable/AirtableCrosswalk\";\nimport { importDataFromAirtableBase } from \"app/common/airtable/AirtableDataImporter\";\nimport { AirtableImportProgress } from \"app/common/airtable/AirtableDataImporterTypes\";\nimport { gristDocSchemaFromAirtableSchema } from \"app/common/airtable/AirtableSchemaImporter\";\nimport {\n  DocSchemaImportTool,\n  DocSchemaImportWarning,\n  ImportSchema,\n  ImportSchemaTransformParams,\n  transformImportSchema,\n  validateImportSchema,\n} from \"app/common/DocSchemaImport\";\nimport { ExistingDocSchema } from \"app/common/DocSchemaImportTypes\";\nimport { UserAPI } from \"app/common/UserAPI\";\n\nexport interface ExistingDoc {\n  type: \"existing-doc\";\n  docId: string;\n}\n\nexport interface NewDoc {\n  type: \"new-doc\";\n  workspaceId: number;\n  name?: string;\n}\n\nexport type AirtableImportDestination = NewDoc | ExistingDoc;\n\nexport interface AirtableImportOptions {\n  destination: AirtableImportDestination,\n  transformations?: ImportSchemaTransformParams,\n  structureOnlyTableIds?: string[],\n  onProgress?(progress: AirtableImportProgress): void,\n}\n\nexport interface AirtableImportResult {\n  docId: string;\n  creationWarnings: DocSchemaImportWarning[];\n  crosswalkWarnings?: AirtableCrosswalkWarning[];\n}\n\nconst t = makeT(\"AirtableImport\");\n\nexport async function applyAirtableImportSchemaAndImportData(params: {\n  importSchema: ImportSchema,\n  dataSource: { api: AirtableAPI, baseId: string },\n  userApi: UserAPI,\n  options: AirtableImportOptions,\n}): Promise<AirtableImportResult> {\n  const { dataSource, importSchema, userApi, options } = params;\n  const { api, baseId } = dataSource;\n  const { destination, transformations, structureOnlyTableIds = [], onProgress } = options;\n\n  onProgress?.({ percent: 0, status: t(\"Preparing to import base from Airtable...\") });\n\n  const baseSchema = await api.getBaseSchema(baseId);\n\n  if (destination.type === \"new-doc\") { onProgress?.({ percent: 10, status: t(\"Creating a new Grist document...\") }); }\n\n  const docId = destination.type === \"existing-doc\" ? destination.docId : await userApi.newDoc(\n    { name: destination.name }, destination.workspaceId,\n  );\n  const docApi = userApi.getDocAPI(docId);\n\n  const existingDocSchema = await getExistingDocSchema(docApi);\n  const initialTables = existingDocSchema.tables.map(table => table.id);\n\n  const docSchemaCreator = new DocSchemaImportTool(actions => docApi.applyUserActions((actions)));\n\n  onProgress?.({ percent: 25, status: t(\"Setting up tables...\") });\n\n  const { tableIdsMap, warnings: creationWarnings } = await docSchemaCreator.createTablesFromSchema(importSchema);\n\n  // TODO - Update this to show the creation warnings to user before starting data import.\n  if (creationWarnings.length > 0) {\n    console.warn({\n      message: `Warnings were emitted while creating the tables for airtable base ${baseId} and grist doc ${docId}`,\n      docId,\n      baseId,\n      warnings: creationWarnings,\n    });\n  }\n\n  // Only remove the initial tables if the Grist document was newly created.\n  if (destination.type === \"new-doc\") {\n    await docSchemaCreator.removeTables(initialTables);\n  }\n\n  const finalGristDocSchema = await getExistingDocSchema(docApi);\n\n  const skipDataTableIds = new Set(structureOnlyTableIds);\n  const dataTableInfo = Array.from(tableIdsMap.values()).filter(({ originalId: id }) => !skipDataTableIds.has(id));\n  const dataTableMapping = new Map(dataTableInfo.map(({ originalId, gristId }) => [originalId, gristId]));\n\n  // tableIdsMap only contains newly created tables - need to add the table mapping supplied by the\n  // user when building the crosswalk.\n  const existingTableIdMap = transformations?.mapExistingTableIds;\n  if (existingTableIdMap) {\n    for (const [airtableTableId, gristTableId] of existingTableIdMap.entries()) {\n      dataTableMapping.set(airtableTableId, gristTableId);\n    }\n  }\n\n  if (dataTableMapping.size === 0) {\n    return {\n      docId,\n      creationWarnings,\n    };\n  }\n\n  const { schemaCrosswalk, warnings: crosswalkWarnings } =\n    createAirtableBaseToGristDocCrosswalk(baseSchema, finalGristDocSchema, dataTableMapping);\n\n  // TODO - Update these steps to show the crosswalk warnings to user before starting data import.\n  if (crosswalkWarnings.length > 0) {\n    console.warn({\n      message: `Warnings were emitted while generating the crosswalk between airtable base ${baseId} and grist doc ${docId}`,\n      docId,\n      baseId,\n      warnings: crosswalkWarnings,\n    });\n  }\n\n  await importDataFromAirtableBase({\n    listRecords: tableId => listRecords(api.base(baseId), tableId, {}),\n    addRows: docApi.addRows.bind(docApi),\n    updateRows: docApi.updateRows.bind(docApi),\n    uploadAttachment: docApi.uploadAttachment.bind(docApi),\n    schemaCrosswalk,\n    onProgress: ({ percent, status }) => {\n      onProgress?.({ percent: 50 + (percent * 0.50), status });\n    },\n  });\n\n  return {\n    creationWarnings,\n    crosswalkWarnings,\n    docId,\n  };\n}\n\nexport function validateAirtableSchemaImport(\n  baseSchema: AirtableBaseSchema,\n  existingDocSchema?: ExistingDocSchema,\n  transformations?: ImportSchemaTransformParams,\n): DocSchemaImportWarning[] {\n  const warnings: DocSchemaImportWarning[] = [];\n\n  const { schema: importSchema, warnings: airtableWarnings } = gristDocSchemaFromAirtableSchema(baseSchema);\n  warnings.push(...airtableWarnings);\n\n  const transformedSchema = transformImportSchema(importSchema, transformations ?? {}, existingDocSchema);\n  warnings.push(...transformedSchema.warnings, ...validateImportSchema(transformedSchema.schema));\n\n  return warnings;\n}\n"
  },
  {
    "path": "app/client/lib/airtable/startDocAirtableImport.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { loadAirtableImportUI } from \"app/client/lib/imports\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { cssModalTitle, cssModalWidth, modal } from \"app/client/ui2018/modals\";\nimport { ExistingDocSchema } from \"app/common/DocSchemaImportTypes\";\n\nimport { Computed, styled } from \"grainjs\";\n\nconst t = makeT(\"AirtableImport\");\n\nexport async function startDocAirtableImport(gristDoc: GristDoc) {\n  const { AirtableImport } = await loadAirtableImportUI();\n\n  return modal((ctl, owner) => {\n    const existingDocSchema: Computed<ExistingDocSchema> = Computed.create(owner, (use) => {\n      const tables = use(gristDoc.docModel.visibleTables.getObservable());\n      return {\n        tables: tables.map(t => ({\n          id: use(t.tableId),\n          name: use(t.tableName),\n          columns: use(t.visibleColumns).map(c => ({\n            id: use(c.colId),\n            ref: use(c.id),\n            label: use(c.label),\n            isFormula: use(c.isFormula),\n          })),\n        })),\n      };\n    });\n\n    const airtableImport = AirtableImport.create(owner, {\n      api: gristDoc.docPageModel.appModel.api,\n      destination: {\n        type: \"existing-doc\",\n        docId: gristDoc.docId(),\n        docSchema: existingDocSchema,\n      },\n      onSuccess: () => ctl.close(),\n      onError: (error: unknown) => {\n        ctl.close();\n        reportError(error);\n      },\n      onCancel: () => ctl.close(),\n    });\n\n    return [\n      cssModalStyle.cls(\"\"),\n      cssModalWidth(\"fixed-wide\"),\n      cssModalTitle(t(\"Import from Airtable\")),\n      airtableImport.buildDom(),\n    ];\n  });\n}\n\nconst cssModalStyle = styled(\"div\", `\n  max-height: 90vh;\n  display: flex;\n  flex-direction: column;\n`);\n"
  },
  {
    "path": "app/client/lib/airtable/startHomeAirtableImport.ts",
    "content": "import { loadAirtableImportUI } from \"app/client/lib/imports\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { HomeModel } from \"app/client/models/HomeModel\";\nimport { cssModalTitle, cssModalWidth, modal } from \"app/client/ui2018/modals\";\n\nimport { styled } from \"grainjs\";\n\nimport type { AirtableImportResult } from \"app/client/lib/airtable/AirtableImporter\";\n\nconst t = makeT(\"AirtableImport\");\n\nexport async function startHomeAirtableImport(home: HomeModel) {\n  const { AirtableImport } = await loadAirtableImportUI();\n\n  const workspace = home.newDocWorkspace.get();\n  if (typeof workspace !== \"object\" || workspace === null) {\n    throw new Error(t(\"The current workspace can't be imported to.\"));\n  }\n\n  return modal((ctl, owner) => {\n    const airtableImport = AirtableImport.create(owner, {\n      api: home.app.api,\n      destination: {\n        type: \"new-doc\",\n        workspaceId: workspace.id,\n      },\n      onSuccess: async ({ docId }: AirtableImportResult) => {\n        ctl.close();\n        await urlState().pushUrl({ doc: docId });\n      },\n      onError: (error: unknown) => {\n        ctl.close();\n        reportError(error);\n      },\n      onCancel: () => ctl.close(),\n    });\n\n    return [\n      cssModalStyle.cls(\"\"),\n      cssModalWidth(\"fixed-wide\"),\n      cssModalTitle(t(\"Import from Airtable\")),\n      airtableImport.buildDom(),\n    ];\n  });\n}\n\nconst cssModalStyle = styled(\"div\", `\n  max-height: 90vh;\n  display: flex;\n  flex-direction: column;\n`);\n"
  },
  {
    "path": "app/client/lib/autocomplete.ts",
    "content": "/**\n * Implements an autocomplete dropdown.\n */\nimport { ACItem, ACResults, HighlightFunc } from \"app/client/lib/ACIndex\";\nimport { attachMouseOverOnMove, findAncestorChild } from \"app/client/lib/domUtils\";\nimport { reportError } from \"app/client/models/errors\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { MaybePromise } from \"app/plugin/gutil\";\n\nimport { createPopper, Instance as Popper, Modifier, Options as PopperOptions } from \"@popperjs/core\";\nimport { Disposable, dom, DomContents } from \"grainjs\";\nimport { obsArray, onKeyElem, styled } from \"grainjs\";\nimport merge from \"lodash/merge\";\nimport maxSize from \"popper-max-size-modifier\";\nimport { cssMenu } from \"popweasel\";\n\nexport interface IAutocompleteOptions<Item extends ACItem> {\n  // If provided, applies the css class to the menu container. Could be multiple, space-separated.\n  menuCssClass?: string;\n\n  // A single class name to add for the selected item, or 'selected' by default.\n  selectedCssClass?: string;\n\n  // Popper options for positioning the popup.\n  popperOptions?: Partial<PopperOptions>;\n\n  // To which element to append the popup content. Null means triggerElem.parentNode; string is\n  // a selector for the closest matching ancestor of triggerElem, e.g. 'body'.\n  // Defaults to the document body.\n  attach?: Element | string | null;\n\n  // Defaults to true. If true, updates the input during selection (e.g. when using arrow keys or hovers over element).\n  liveUpdate?: boolean;\n\n  // If provided, builds and shows the message when there are no items (excluding any extra items).\n  buildNoItemsMessage?: () => DomContents;\n\n  // Given a search term, return the list of Items to render.\n  search(searchText: string): MaybePromise<ACResults<Item>>;\n\n  // Function to render a single item.\n  renderItem(item: Item, highlightFunc: HighlightFunc): HTMLElement;\n\n  // Get text for the text input for a selected item, i.e. the text to present to the user.\n  getItemText?(item: Item): string;\n\n  // A callback triggered when user clicks one of the choices.\n  onClick?(): void;\n}\n\n/**\n * An instance of an open Autocomplete dropdown.\n */\nexport class Autocomplete<Item extends ACItem> extends Disposable {\n  // The UL element containing the actual menu items.\n  protected _menuContent: HTMLElement;\n\n  // Index into _menuContent, -1 if nothing selected.\n  protected _selectedIndex: number = -1;\n\n  // Currently selected element.\n  protected _selected: HTMLElement | null = null;\n\n  private _popper: Popper;\n  private _mouseOver: { reset(): void };\n  private _lastAsTyped: string;\n  private _items = this.autoDispose(obsArray<Item>([]));\n  private _extraItems = this.autoDispose(obsArray<Item>([]));\n  private _highlightFunc: HighlightFunc;\n  private _liveUpdate = this._options.liveUpdate ?? true;\n\n  constructor(\n    private _triggerElem: HTMLElement,\n    private readonly _options: IAutocompleteOptions<Item>,\n  ) {\n    super();\n\n    const content = cssMenuWrap(\n      cssMenu(\n        { class: _options.menuCssClass || \"\" },\n        dom.style(\"min-width\", _triggerElem.getBoundingClientRect().width + \"px\"),\n        this._maybeShowNoItemsMessage(),\n        this._menuContent = dom(\"div\",\n          dom.forEach(this._items, item => _options.renderItem(item, this._highlightFunc)),\n          dom.forEach(this._extraItems, item => _options.renderItem(item, this._highlightFunc)),\n          dom.on(\"mouseleave\", ev => this._setSelected(-1, this._liveUpdate)),\n          dom.on(\"click\", (ev) => {\n            this._setSelected(this._findTargetItem(ev.target), this._liveUpdate);\n            if (_options.onClick) { _options.onClick(); }\n          }),\n        ),\n      ),\n      // Prevent trigger element from being blurred on click.\n      dom.on(\"mousedown\", ev => ev.preventDefault()),\n    );\n\n    this._mouseOver = attachMouseOverOnMove(this._menuContent,\n      ev => this._setSelected(this._findTargetItem(ev.target), this._liveUpdate));\n\n    // Add key handlers to the trigger element as well as the menu if it is an input.\n    this.autoDispose(onKeyElem(_triggerElem, \"keydown\", {\n      ArrowDown: () => this._setSelected(this._getNext(1), this._liveUpdate),\n      ArrowUp: () => this._setSelected(this._getNext(-1), this._liveUpdate),\n    }));\n\n    // Keeps track of the last value as typed by the user.\n    this.search();\n    this.autoDispose(dom.onElem(_triggerElem, \"input\", () => this.search()));\n\n    const attachElem = _options.attach === undefined ? document.body : _options.attach;\n    const containerElem = getContainer(_triggerElem, attachElem) ?? document.body;\n    containerElem.appendChild(content);\n\n    this.onDispose(() => { dom.domDispose(content); content.remove(); });\n\n    // Prepare and create the Popper instance, which places the content according to the options.\n    const popperOptions = merge({}, defaultPopperOptions, _options.popperOptions);\n    this._popper = createPopper(_triggerElem, content, popperOptions);\n    this.onDispose(() => this._popper.destroy());\n  }\n\n  public getSelectedItem(): Item | undefined {\n    return this._allItems[this._selectedIndex];\n  }\n\n  public search(findMatch?: (items: Item[]) => number) {\n    this._updateChoices(this._value, findMatch).catch(reportError);\n  }\n\n  private get _value() {\n    if (this._triggerElem instanceof HTMLInputElement) {\n      return this._triggerElem.value;\n    } else if (this._triggerElem instanceof HTMLTextAreaElement) {\n      return this._triggerElem.value;\n    }\n    return this._triggerElem.innerText;\n  }\n\n  private set _value(value: string) {\n    if (this._triggerElem instanceof HTMLInputElement) {\n      this._triggerElem.value = value;\n    } else if (this._triggerElem instanceof HTMLTextAreaElement) {\n      this._triggerElem.value = value;\n    } else {\n      this._triggerElem.innerText = value;\n    }\n  }\n\n  // When the selected element changes, update the classes of the formerly and newly-selected\n  // elements and optionally update the text input.\n  private _setSelected(index: number, updateValue: boolean) {\n    const elem = (this._menuContent.children[index] as HTMLElement) || null;\n    const prev = this._selected;\n    if (elem !== prev) {\n      const clsName = this._options.selectedCssClass || \"selected\";\n      if (prev) { prev.classList.remove(clsName); }\n      if (elem) {\n        elem.classList.add(clsName);\n        elem.scrollIntoView({ block: \"nearest\" });\n      }\n    }\n    this._selected = elem;\n    this._selectedIndex = elem ? index : -1;\n\n    if (updateValue) {\n      // Update trigger's value with the selected choice, or else with the last typed value.\n      if (elem && this._options.getItemText) {\n        this._value = this._options.getItemText(this.getSelectedItem()!);\n      } else {\n        this._value = this._lastAsTyped;\n      }\n    }\n  }\n\n  private _findTargetItem(target: EventTarget | null): number {\n    // Find immediate child of this._menuContent which is an ancestor of ev.target.\n    const elem = findAncestorChild(this._menuContent, target as Element | null);\n    return Array.prototype.indexOf.call(this._menuContent.children, elem);\n  }\n\n  private _getNext(step: 1 | -1): number {\n    // Pretend there is an extra element at the end to mean \"nothing selected\".\n    const xsize = this._allItems.length + 1;\n    const next = (this._selectedIndex + step + xsize) % xsize;\n    return (next === xsize - 1) ? -1 : next;\n  }\n\n  private async _updateChoices(inputVal: string, findMatch?: (items: Item[]) => number): Promise<void> {\n    this._lastAsTyped = inputVal;\n    // TODO We should perhaps debounce the search() call in some clever way, to avoid unnecessary\n    // searches while typing. Today, search() is synchronous in practice, so it doesn't matter.\n    const acResults = await this._options.search(inputVal);\n    this._highlightFunc = acResults.highlightFunc;\n    this._items.set(acResults.items);\n    this._extraItems.set(acResults.extraItems);\n\n    // Plain update() (which is deferred) may be better, but if _setSelected() causes scrolling\n    // before the positions are updated, it causes the entire page to scroll horizontally.\n    this._popper.forceUpdate();\n\n    this._mouseOver.reset();\n\n    let index: number;\n    if (findMatch) {\n      index = findMatch(this._allItems);\n    } else {\n      index = inputVal ? acResults.selectIndex : -1;\n    }\n    this._setSelected(index, false);\n  }\n\n  private get _allItems() {\n    return [...this._items.get(), ...this._extraItems.get()];\n  }\n\n  private _maybeShowNoItemsMessage() {\n    const { buildNoItemsMessage } = this._options;\n    if (!buildNoItemsMessage) { return null; }\n\n    return dom.maybe(use => use(this._items).length === 0, () =>\n      cssNoItemsMessage(buildNoItemsMessage(), testId(\"autocomplete-no-items-message\")));\n  }\n}\n\n// The maxSize modifiers follow recommendations at https://www.npmjs.com/package/popper-max-size-modifier\nconst calcMaxSize = {\n  ...maxSize,\n  options: { padding: 4 },\n};\n\nconst applyMaxSize: Modifier<any, any> = {\n  name: \"applyMaxSize\",\n  enabled: true,\n  phase: \"beforeWrite\",\n  requires: [\"maxSize\"],\n  fn({ state }: any) {\n    // The `maxSize` modifier provides this data\n    const { height } = state.modifiersData.maxSize;\n    Object.assign(state.styles.popper, {\n      maxHeight: `${Math.max(160, height)}px`,\n    });\n  },\n};\n\nexport const defaultPopperOptions: Partial<PopperOptions> = {\n  placement: \"bottom-start\",\n  modifiers: [\n    calcMaxSize,\n    applyMaxSize,\n    { name: \"computeStyles\", options: { gpuAcceleration: false } },\n  ],\n};\n\n/**\n * Helper that finds the container according to attachElem. Null means\n * elem.parentNode; string is a selector for the closest matching ancestor, e.g. 'body'.\n */\nfunction getContainer(elem: Element, attachElem: Element | string | null): Node | null {\n  return (typeof attachElem === \"string\") ? elem.closest(attachElem) :\n    (attachElem || elem.parentNode);\n}\n\nconst cssMenuWrap = styled(\"div\", `\n  position: absolute;\n  display: flex;\n  flex-direction: column;\n  outline: none;\n`);\n\nconst cssNoItemsMessage = styled(\"div\", `\n  color: ${theme.lightText};\n  padding: var(--weaseljs-menu-item-padding, 8px 24px);\n  text-align: center;\n  user-select: none;\n`);\n"
  },
  {
    "path": "app/client/lib/browserGlobals.ts",
    "content": "/**\n * Module that allows client-side code to use browser globals (such as `document` or `Node`) in a\n * way that allows those globals to be replaced by mocks in browser-less tests.\n *\n * E.g. test/client/clientUtil.js can replace globals with those provided by jsdom.\n */\n\ntype OrigGlobals = typeof globalThis;\n\ninterface Globals extends Partial<OrigGlobals> {\n  $?: JQueryStatic;           // Some old code still uses JQuery events.\n  globalThis?: OrigGlobals;   // Workaround for a typings error due to OrigGlobals.globalThis being readonly\n}\n\ntype PossibleNames = keyof Globals;\n\ninterface RequestedGlobals {\n  neededNames: PossibleNames[];\n  globals: Globals;\n}\n\nconst allGlobals: RequestedGlobals[] = [];\n\nlet globalVars: Globals = (typeof window !== \"undefined\" ? window : {});\n\n/**\n * Usage: to get access to global variables like `document` and `window`, call:\n *\n *    import {getBrowserGlobals} from 'app/client/lib/browserGlobals';\n *    const G = getBrowserGlobals('document', 'window');\n *\n * and use G.document and G.window.\n *\n * This modules stores a reference to G, so that setGlobals() call can replace the values to which\n * G.document and G.window refer.\n */\nexport function get<Names extends PossibleNames[]>(...neededNames: Names): Required<Pick<Globals, Names[number]>> {\n  const obj = {\n    neededNames,\n    globals: {},\n  };\n  updateGlobals(obj);\n  allGlobals.push(obj);\n  return obj.globals as Required<Pick<Globals, Names[number]>>;\n}\n\nexport const getBrowserGlobals = get;\n\n/**\n * Internal helper which updates properties of all globals objects created with get().\n */\nfunction updateGlobals(obj: RequestedGlobals) {\n  for (const key of obj.neededNames) {\n    obj.globals[key] = globalVars[key];\n  }\n}\n\n/**\n * Replace globals with those from the given object. The previous mapping of global values is\n * returned, so that it can be restored later.\n */\nexport function setGlobals(globals: Globals) {\n  const oldVars = globalVars;\n  globalVars = globals;\n  for (const obj of allGlobals) {\n    updateGlobals(obj);\n  }\n  return oldVars;\n}\n"
  },
  {
    "path": "app/client/lib/browserInfo.ts",
    "content": "import * as Bowser from \"bowser\"; // TypeScript\n\nlet parser: Bowser.Parser.Parser | undefined;\n\nfunction getParser() {\n  return parser || (parser = Bowser.getParser(window.navigator.userAgent));\n}\n\n// Returns whether the browser we are in is a desktop browser.\nexport function isDesktop() {\n  const platformType = getParser().getPlatformType();\n  return (!platformType || platformType === \"desktop\");\n}\n\n// Returns whether the browser is on mobile iOS.\n// This is used in particular in viewport.ts to set maximum-scale=1 (to prevent iOS auto-zoom when\n// an input is focused, without preventing manual pinch-to-zoom).\nexport function isIOS() {\n  return navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform);\n}\n\n// Returns 'metaKey' on Mac / iOS, and 'ctrlKey' elsewhere. This is useful for various keyboard\n// interactions that use Control on Windows and Linux, and Command key on Mac for the same\n// purpose. Suitable to use with KeyboardEvent and MouseEvent.\n// (Note: Mousetrap.js uses the same logic to interpret its \"mod\" key alias.)\nexport function modKeyProp(): \"metaKey\" | \"ctrlKey\" {\n  return /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? \"metaKey\" : \"ctrlKey\";\n}\n\nexport function isWin() {\n  const os = getParser().getOSName();\n  return os === \"Windows\";\n}\n"
  },
  {
    "path": "app/client/lib/chartUtil.ts",
    "content": "import { typedCompare } from \"app/common/SortFunc\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nimport flatten from \"lodash/flatten\";\nimport range from \"lodash/range\";\nimport uniqBy from \"lodash/uniqBy\";\nimport { Datum } from \"plotly.js\";\n\n/**\n * Sort all values in a list of series according to the values in the first one.\n */\nexport function sortByXValues(series: { values: Datum[] }[]): void {\n  // The order of points matters for graph types that connect points with lines: the lines are\n  // drawn in order in which the points appear in the data. For the chart types we support, it\n  // only makes sense to keep the points sorted. (The only downside is that Grist line charts can\n  // no longer produce arbitrary line drawings.)\n  if (!series[0]) { return; }\n  const xValues = series[0].values;\n  const indices = xValues.map((val, i) => i);\n  indices.sort((a, b) => typedCompare(xValues[a], xValues[b]));\n  for (const s of series) {\n    const values = s.values;\n    s.values = indices.map(i => values[i]);\n  }\n}\n\n// Makes series so that the values of series[0] are duplicate free.\nexport function uniqXValues<T extends { values: Datum[] }>(series: T[]) {\n  if (!series[0]) { return; }\n  const n = series[0].values.length;\n  const indexToKeep = new Set(uniqBy(range(n), i => series[0].values[i]));\n  series.forEach((line: T) => {\n    line.values = line.values.filter((_val, i) => indexToKeep.has(i));\n  });\n}\n\n// Creates new version of series that split any entry whose value in the first series is a list into\n// multiple entries, one entry for each list's item. For all other series, newly created entries have\n// the same value as the original.\nexport function splitValues<T extends { values: Datum[] }>(series: T[]): T[] {\n  return splitValuesByIndex(series, 0);\n}\n\n// This method is like splitValues except it splits according to the values of the series at position index.\nexport function splitValuesByIndex<T extends { values: Datum[] }>(series: T[], index: number): T[] {\n  const decoded = (series[index].values as any[]).map(decodeObject);\n\n  return series.map((s, si) => {\n    if (si === index) {\n      return { ...series[index], values: flatten(decoded) };\n    }\n    let values: Datum[] = [];\n    for (const [i, splitByValue] of decoded.entries()) {\n      if (Array.isArray(splitByValue)) {\n        values = values.concat(Array(splitByValue.length).fill(s.values[i]));\n      } else {\n        values.push(s.values[i]);\n      }\n    }\n    return { ...s, values };\n  });\n}\n\n/**\n * Makes sure series[0].values includes all of the values in xvalues and that they appears in the\n * same order. 0 is used to fill missing values in series[i].values for i > 1 (making function\n * suited only for numeric series AND only to use with for bar charts). Function does mutate series.\n *\n * Note it would make more sense to pad missing values with `null`, but plotly handles null the same\n * as missing values. Hence we're padding with 0.\n */\nexport function consolidateValues(series: { values: Datum[] }[], xvalues: Datum[]) {\n  let i = 0;\n  for (const xval of xvalues) {\n    if (i < series[0].values.length && xval !== series[0].values[i] ||\n      i > series[0].values.length - 1) {\n      series[0].values.splice(i, 0, xval);\n      for (let j = 1; j < series.length; ++j) {\n        series[j].values.splice(i, 0, 0);\n      }\n    }\n    while (xval === series[0].values[i] && i < series[0].values.length) {\n      i++;\n    }\n  }\n  return series;\n}\n\nexport function formatPercent(val: number) {\n  return Math.floor(val * 100) + \" %\";\n}\n"
  },
  {
    "path": "app/client/lib/clipboardUtils.ts",
    "content": "import { getBrowserGlobals } from \"app/client/lib/browserGlobals\";\n\nconst G = getBrowserGlobals(\"document\", \"window\");\n\n/**\n * Copy text or data to the clipboard.\n */\nexport async function copyToClipboard(data: string | ClipboardItem) {\n  if (typeof data === \"string\") {\n    await copyTextToClipboard(data);\n  } else {\n    await copyDataToClipboard(data);\n  }\n}\n\n/**\n * Copy text to the clipboard.\n */\nasync function copyTextToClipboard(txt: string) {\n  // If present and we have permission to use it, the navigator.clipboard interface\n  // is convenient.  This method works in non-headless tests, and regular chrome\n  // and firefox.\n  if (G.window.navigator?.clipboard?.writeText) {\n    try {\n      await G.window.navigator.clipboard.writeText(txt);\n      return;\n    } catch (e) {\n      // no joy, try another way.\n    }\n  }\n  // Otherwise fall back on document.execCommand('copy'), which requires text in\n  // the dom to be selected.  Implementation here based on:\n  //   https://hackernoon.com/copying-text-to-clipboard-with-javascript-df4d4988697f\n  // This fallback takes effect at least in headless tests, and in Safari.\n  const stash = G.document.createElement(\"textarea\");\n  stash.value = txt;\n  stash.setAttribute(\"readonly\", \"\");\n  stash.style.position = \"absolute\";\n  stash.style.left = \"-10000px\";\n  G.document.body.appendChild(stash);\n  const selection = G.document.getSelection()!.rangeCount > 0 && G.document.getSelection()!.getRangeAt(0);\n  stash.select();\n  G.document.execCommand(\"copy\");\n  G.document.body.removeChild(stash);\n  if (selection) {\n    G.document.getSelection()!.removeAllRanges();\n    G.document.getSelection()!.addRange(selection);\n  }\n}\n\n/**\n * Copy data to the clipboard.\n */\nasync function copyDataToClipboard(data: ClipboardItem) {\n  if (!G.window.navigator?.clipboard?.write) {\n    throw new Error(\"navigator.clipboard.write is not supported on this browser\");\n  }\n\n  await G.window.navigator.clipboard.write([data]);\n}\n\n/**\n * Read text from the clipboard.\n */\nexport function readTextFromClipboard(): Promise<string> {\n  if (!G.window.navigator?.clipboard?.readText) {\n    throw new Error(\"navigator.clipboard.readText is not supported on this browser\");\n  }\n\n  return G.window.navigator.clipboard.readText();\n}\n\n/**\n * Read data from the clipboard.\n */\nexport function readDataFromClipboard(): Promise<ClipboardItem[]> {\n  if (!G.window.navigator?.clipboard?.read) {\n    throw new Error(\"navigator.clipboard.read is not supported on this browser\");\n  }\n\n  return G.window.navigator.clipboard.read();\n}\n"
  },
  {
    "path": "app/client/lib/dblclick.ts",
    "content": "import { dom, EventCB } from \"grainjs\";\n\nconst DOUBLE_TAP_INTERVAL_MS = 500;\n\n/**\n * Helper to handle 'dblclick' events on either browser or mobile.\n *\n * This is equivalent to a 'dblclick' handler when touch events are not supported. When they are,\n * the callback will be called on second touch within a short time of a first one. (In that case,\n * preventDefault() prevents a 'dblclick' event from being emulated.)\n *\n * Background: though mobile browsers we care about already generate 'click' and 'dblclick' events\n * in response to touch events, it doesn't seem to be treated as a direct user interaction. E.g.\n * double-click to edit a cell should focus the editor and open the mobile keyboard, but a\n * JS-issued focus() call only works when triggered by a direct user interaction, and synthesized\n * dblclick doesn't seem to do that.\n *\n * Helpful links on emulated (synthesized) events:\n * - https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Supporting_both_TouchEvent_and_MouseEvent\n * - https://github.com/w3c/pointerevents/issues/171\n */\nexport function onDblClickMatchElem(elem: EventTarget, selector: string, callback: EventCB): void {\n  // According to https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action, this \"removes the\n  // need for browsers to delay the generation of click events when the user taps the screen\".\n  // Without it, the delay (e.g. on mobile Chrome) prevents cursor from moving on double-tap.\n  dom.styleElem(elem as HTMLElement, \"touch-action\", \"manipulation\");\n  dom.onMatchElem(elem, selector, \"dblclick\", (ev, _elem) => {\n    callback(ev, _elem);\n  });\n\n  let lastTapTime = 0;\n  let lastTapElem: EventTarget | null = null;\n  dom.onMatchElem(elem, selector, \"touchend\", (ev, _elem) => {\n    const currentTime = Date.now();\n    const tapLength = currentTime - lastTapTime;\n    const sameElem = (_elem === lastTapElem);\n    lastTapTime = currentTime;\n    lastTapElem = _elem;\n    // Only consider a gesture a double-tap if it's on the same cell. Otherwise, two-finger\n    // gestures, such as zooming, may trigger this too.\n    if (sameElem && tapLength < DOUBLE_TAP_INTERVAL_MS && tapLength > 0) {\n      ev.preventDefault();\n      callback(ev, _elem);\n    }\n  });\n}\n"
  },
  {
    "path": "app/client/lib/dispose.d.ts",
    "content": "// TODO: add remaining Disposable method\nexport abstract class Disposable {\n  public static create<T extends new (...args: any[]) => any>(\n    this: T, ...args: ConstructorParameters<T>): InstanceType<T>;\n\n  constructor(...args: any[]);\n  public dispose(): void;\n  public isDisposed(): boolean;\n  public autoDispose<T>(obj: T): T;\n  public autoDisposeCallback(callback: () => void): void;\n  public disposeRelease<T>(obj: T): T;\n  public disposeDiscard(obj: any): void;\n  public makeDisposable(obj: any): void;\n}\n\nexport function emptyNode(node: Node): void;\n"
  },
  {
    "path": "app/client/lib/dispose.js",
    "content": "/**\n * dispose.js provides tools to components that needs to dispose of resources, such as\n * destroy DOM, and unsubscribe from events. The motivation with examples is presented here:\n *\n *    /documentation/disposal/disposal.md\n */\n\n\nvar ko = require(\"knockout\");\nvar _ = require(\"underscore\");\n\n// Use the browser globals in a way that allows replacing them with mocks in tests.\nvar G = require(\"./browserGlobals\").get(\"DocumentFragment\", \"Node\");\n\n/**\n * Disposable is a base class for components that need cleanup (e.g. maintain DOM, listen to\n * events, subscribe to anything). It provides a .dispose() method that should be called to\n * destroy the component, and .autoDispose() method that the component should use to take\n * responsibility for other pieces that require cleanup.\n *\n * To define a disposable prototype:\n *    function Foo() { ... }\n *    dispose.makeDisposable(Foo);\n *\n * To define a disposable ES6 class:\n *    class Foo extends dispose.Disposable { create() {...} }\n *\n *    NB: Foo should not have its construction logic in a constructor but in a `create` method\n *    instead. If Foo defines a constructor (for taking advantage of type checking) the constructor\n *    should only call super `super(arg1, arg2 ...)`. Any way calling `new Foo(...args)` safely\n *    construct the component.\n *\n * In Foo's constructor or methods, take ownership of other objects:\n *    this.bar = this.autoDispose(Bar.create(...));\n * The argument will get disposed when `this` is disposed. If it's a DOM node, it will get removed\n * using ko.removeNode(). If it has a `dispose` method, it will be called.\n *\n * For more customized disposal:\n *    this.baz = this.autoDisposeWith('destroy', new Baz());\n *    this.elem = this.autoDisposeWith(ko.cleanNode, document.createElement(...));\n * When `this` is disposed, will call this.baz.destroy(), and ko.cleanNode(this.elem).\n *\n * To call another method on disposal (e.g. to add custom disposal logic):\n *    this.autoDisposeCallback(this.myUnsubscribeAllMethod);\n * The method will be called with `this` as context, and no arguments.\n *\n * To create Foo:\n *    var foo = Foo.create(args...);\n * `Foo.create` ensures that if the constructor throws an exception, any calls to .autoDispose()\n * that happened before that are honored.\n *\n * To dispose of Foo:\n *    foo.dispose();\n * Owned objects will be disposed in reverse order from which `autoDispose` were called. Note that\n * `foo` is no longer usable afterwards, and all its properties are wiped.\n * If Foo has a `stopListening` method (e.g. inherits from Backbone.Events), `dispose` will call\n * it automatically, as if it were added with `this.autoDisposeCallback(this.stopListening)`.\n *\n * To release an owned object:\n *    this.disposeRelease(this.bar);\n *\n * To dispose of an owned object early:\n *    this.disposeDiscard(this.bar);\n *\n * To determine if a reference refers to object that has already been disposed:\n *    foo.isDisposed()\n */\nclass Disposable {\n  /**\n   * A safe constructor which calls dispose() in case the creation throws an exception.\n   */\n  constructor(...args) {\n    safelyConstruct(this.create, this, args);\n  }\n\n  /**\n   * Static method to allow rewriting old classes into ES6 without modifying their\n   * instantiation to use `new Foo()` (i.e. you can continue to use `Foo.create()`).\n   */\n  static create(...args) {\n    return new this(...args);\n  }\n}\n\nObject.assign(Disposable.prototype, {\n  /**\n   * Take ownership of `obj`, and dispose it when `this.dispose` is called.\n   * @param {Object} obj: Object to take ownership of. It can be a DOM node or an object with a\n   *    `dispose` method.\n   * @returns {Object} obj\n   */\n  autoDispose: function(obj) {\n    return this.autoDisposeWith(defaultDisposer, obj);\n  },\n\n  /**\n   * As for autoDispose, but we receive a promise of an object.  We wait for it to\n   * resolve and then take ownership of it.  We return a promise that resolves to\n   * the object, or to null if the owner is disposed in the meantime.\n   */\n  autoDisposePromise: function(objPromise) {\n    return objPromise.then(obj => {\n      if (this.isDisposed()) {\n        defaultDisposer(obj);\n        return null;\n      }\n      this.autoDispose(obj);\n      return obj;\n    });\n  },\n\n  /**\n   * Take ownership of `obj`, and dispose it when `this.dispose` is called by calling the\n   * specified function.\n   * @param {Function|String} disposer: If a function, disposer(obj) will be called to dispose the\n   *    object, with `this` as the context. If a string, then obj[disposer]() will be called. E.g.\n   *        this.autoDisposeWith('destroy', a);     // will call a.destroy()\n   *        this.autoDisposeWith(ko.cleanNode, b);  // will call ko.cleanNode(b)\n   * @param {Object} obj: Object to take ownership of, on which `disposer` will be called.\n   * @returns {Object} obj\n   */\n  autoDisposeWith: function(disposer, obj) {\n    var list = this._disposalList || (this._disposalList = []);\n    list.push({ obj: obj,\n      disposer: typeof disposer === \"string\" ? methodDisposer(disposer) : disposer });\n    return obj;\n  },\n\n  /**\n   * Adds the given callback to be called when `this.dispose` is called.\n   * @param {Function} callback: Called on disposal with `this` as the context and no arguments.\n   * @returns nothing\n   */\n  autoDisposeCallback: function(callback) {\n    this.autoDisposeWith(callFuncHelper, callback);\n  },\n\n  /**\n   * Remove `obj` from the list of owned objects; it will not be disposed on `this.dispose`.\n   * @param {Object} obj: Object to release.\n   * @returns {Object} obj\n   */\n  disposeRelease: function(obj) {\n    removeObjectToDispose(this._disposalList, obj);\n    return obj;\n  },\n\n  /**\n   * Dispose of an owned object `obj` now, and remove it from the list of owned objects.\n   * @param {Object} obj: Object to release.\n   * @returns nothing\n   */\n  disposeDiscard: function(obj) {\n    var entry = removeObjectToDispose(this._disposalList, obj);\n    if (entry) {\n      entry.disposer.call(this, obj);\n    }\n  },\n\n  /**\n   * Returns whether this object has already been disposed.\n   */\n  isDisposed: function() {\n    return this._disposalList === WIPED_VALUE;\n  },\n\n  /**\n   * Clean up `this` by disposing of all owned objects, and calling `stopListening()` if defined.\n   */\n  dispose: function() {\n    if (this.isDisposed()) {\n      return;\n    }\n\n    var disposalList = this._disposalList;\n    this._disposalList = WIPED_VALUE; // This makes isDisposed() true.\n    if (disposalList) {\n      // Go backwards through the disposal list, and dispose of everything.\n      for (var i = disposalList.length - 1; i >= 0; i--) {\n        var entry = disposalList[i];\n        disposeHelper(this, entry.disposer, entry.obj);\n      }\n    }\n\n    // Call stopListening if it exists. This is a convenience when using Backbone.Events. It's\n    // equivalent to calling this.autoDisposeCallback(this.stopListening) in constructor.\n    if (typeof this.stopListening === \"function\") {\n      // Wrap in disposeHelper so that errors get caught.\n      disposeHelper(this, callFuncHelper, this.stopListening);\n    }\n\n    // Finish by wiping out the object, since nothing should use it after dispose().\n    // See /documentation/disposal.md for more motivation.\n    wipeOutObject(this);\n  }\n});\nexports.Disposable = Disposable;\n\n\n/**\n * The recommended way to make an object disposable. It simply adds the methods of `Disposable` to\n * its prototype, and also adds a `Class.create()` function, for a safer way to construct objects\n * (see `safeCreate` for explanation). For instance,\n *    function Foo(args...) {...}\n *    dispose.makeDisposable(Foo);\n * Now you can create Foo objects with:\n *    var foo = Foo.create(args...);\n * And dispose of them with:\n *    foo.dispose();\n */\nfunction makeDisposable(Constructor) {\n  Object.assign(Constructor.prototype, Disposable.prototype);\n  Constructor.create = safeConstructor;\n}\nexports.makeDisposable = makeDisposable;\n\n\n/**\n * Helper to create and construct an object safely: `safeCreate(Foo, ...)` is similar to `new\n * Foo(...)`. The difference is that in case of an exception in the constructor, the dispose()\n * method will be called on the partially constructed object.\n * If you call makeDisposable(Foo), then Foo.create(...) is equivalent and more convenient.\n * @returns {Object} the newly constructed object.\n */\nfunction safeCreate(Constructor, varArgs) {\n  return safeConstructor.apply(Constructor, Array.prototype.slice.call(arguments, 1));\n}\nexports.safeCreate = safeCreate;\n\n\n/**\n * Helper used by makeDisposable() for the `create` property of a disposable class. E.g. when\n * assigned to Foo.create, the call `Foo.create(args)` becomes similar to `new Foo(args)`, but\n * calls dispose() in case the constructor throws an exception.\n */\nvar safeConstructor = function(varArgs) {\n  var Constructor = this;\n  var obj = Object.create(Constructor.prototype);\n  return safelyConstruct(Constructor, obj, arguments);\n};\n\nvar safelyConstruct = function(Constructor, obj, args) {\n  try {\n    Constructor.apply(obj, args);\n    return obj;\n  } catch (e) {\n    // Be a bit more helpful and concise in reporting errors: print error as an object (that\n    // includes its stacktrace in FF and Chrome), and avoid printing it multiple times as it\n    // bubbles up through the stack of safeConstructor calls.\n    if (!e.printed) {\n      let name = obj.constructor.name || Constructor.name;\n      console.error(\"Error constructing %s:\", name, e);\n      // assigning printed to a string throws: TypeError: Cannot create property 'printed' on [...]\n      if (_.isObject(e)) {\n        e.printed = true;\n      }\n    }\n    obj.dispose();\n    throw e;\n  }\n};\n\n// It doesn't matter what the value is, but some values cause more helpful errors than others.\n// E.g. if x = \"disposed\", then x.foo() throws \"undefined is not a function\", while when x = null,\n// x.foo() throws \"Cannot read property 'foo' of null\", which seems more helpful.\nvar WIPED_VALUE = null;\n\n\n/**\n * Wipe out the given object by setting each property to a dummy value. This is helpful for\n * objects that are disposed and should be ready to be garbage-collected. The goals are:\n * - If anything still refers to the object and uses it, we'll get an early error, rather than\n *   silently keep going, potentially doing useless work (or worse) and wasting resources.\n * - If anything still refers to the object but doesn't use it, the fields of the object can\n *   still be garbage-collected.\n * - If there are circular references between the object and its properties, they get broken,\n *   making the job easier for the garbage collector.\n */\nfunction wipeOutObject(obj) {\n  for (var k in obj) {\n    if (obj.hasOwnProperty(k)) {\n      obj[k] = WIPED_VALUE;\n    }\n  }\n}\n\n/**\n * Internal helper used by disposeDiscard() and disposeRelease(). It finds, removes, and returns\n * an entry from the given disposalList.\n */\nfunction removeObjectToDispose(disposalList, obj) {\n  if (disposalList) {\n    for (var i = 0; i < disposalList.length; i++) {\n      if (disposalList[i].obj === obj) {\n        var entry = disposalList[i];\n        disposalList.splice(i, 1);\n        return entry;\n      }\n    }\n  }\n  return null;\n}\n\n/**\n * Internal helper to allow adding cleanup callbacks to the disposalList. It acts as the\n * \"disposer\" for callback, by simply calling them with the same context that it is called with.\n */\nvar callFuncHelper = function(callback) {\n  callback.call(this);\n};\n\n/**\n * Internal helper to dispose objects that need a differently-named method to be called on them.\n * It's used by `autoDisposeWith` when the disposer is a string method name.\n */\nfunction methodDisposer(methodName) {\n  return function(obj) {\n    obj[methodName]();\n  };\n}\n\n/**\n * Internal helper to call a disposer on an object. It swallows errors (but reports them) to make\n * sure that when we dispose of an object, an error in disposing of one owned part doesn't stop\n * the disposal of the other parts.\n */\nfunction disposeHelper(owner, disposer, obj) {\n  try {\n    disposer.call(owner, obj);\n  } catch (e) {\n    console.warn(\"Error while disposing\", e);\n  }\n}\n\n/**\n * Internal helper that implements the default disposal for an object. It just supports removing\n * DOM nodes with ko.removeNode, and calling dispose() on any part that has a `dispose` method.\n */\nfunction defaultDisposer(obj) {\n  if (obj instanceof G.Node) {\n    // This does both knockout- and jquery-related cleaning, and removes the node from the DOM.\n    ko.removeNode(obj);\n  } else if (typeof obj.dispose === \"function\") {\n    obj.dispose();\n  } else {\n    throw new Error(\"Object has no 'dispose' method\");\n  }\n}\n\n/**\n * Removes all children of the given node, and all knockout bindings. You can use it as\n *    this.autoDisposeWith(dispose.emptyNode, node);\n */\nfunction emptyNode(node) {\n  ko.virtualElements.emptyNode(node);\n  ko.cleanNode(node);\n}\n\nexports.emptyNode = emptyNode;\n"
  },
  {
    "path": "app/client/lib/dom.js",
    "content": "// Builds a DOM tree or document fragment, easily.\n//\n// Usage:\n//  dom('a#link.c1.c2', {href:url}, 'Hello ', dom('span', 'world'));\n//      creates Node <a id=\"link\" class=\"c1 c2\" href={{url}}>Hello <span>world</span></a>.\n//  dom.frag(dom('span', 'Hello'), ['blah', dom('div', 'world')])\n//      creates document fragment with <span>Hello</span>blah<div>world</div>.\n//\n// Arrays among child arguments get flattened. Objects are turned into attributes.\n//\n// If an argument is a function it will be called with elem as the argument,\n// which may be a convenient way to modify elements, set styles, or attach events.\n\n\n\nvar ko = require(\"knockout\");\n\n/**\n * Use the browser globals in a way that allows replacing them with mocks in tests.\n */\nvar G = require(\"./browserGlobals\").get(\"document\", \"Node\", \"$\", \"window\");\n\n/**\n * dom('tag#id.class1.class2' | Node, other args)\n *  The first argument is typically a string consisting of a tag name, with optional #foo suffix\n *  to add the ID 'foo', and zero or more .bar suffixes to add a css class 'bar'. If the first\n *  argument is a Node, that node is used for subsequent changes without creating a new one.\n *\n *  The rest of the arguments are optional and may be:\n *\n *    Nodes - which become children of the created element;\n *    strings - which become text node children;\n *    objects - of the form {attr: val} to set additional attributes on the element;\n *    Arrays of Nodes - which are flattened out and become children of the created element;\n *    functions - which are called with elem as the argument, for a chance to modify the\n *        element as it's being created. When functions return values (other than undefined),\n *        these return values get applied to the containing element recursively.\n */\nfunction dom(firstArg, ...args) {\n  let elem;\n  if (firstArg instanceof G.Node) {\n    elem = firstArg;\n  } else {\n    elem = createElemFromString(firstArg, createDOMElement);\n  }\n\n  return handleChildren(elem, arguments, 1);\n}\n\n/**\n * dom.svg('tag#id.class1.class2', other args) behaves much like `dom`, but does not accept Node\n * as a first argument--only a tag string. Because SVG elements are created in a different\n * namespace, `dom.svg` should be used for creating SVG elements such as `polygon`.\n */\ndom.svg = function (firstArg, ...args) {\n  let elem = createElemFromString(firstArg, createSVGElement);\n  return handleChildren(elem, arguments, 1);\n};\n\n/**\n * Given a tag string of the form 'tag#id.class1.class2' and an element creator function, returns\n * a new tag element with the id and classes properly set.\n */\nfunction createElemFromString(tagString, elemCreator) {\n  // We do careful hand-written parsing rather than use a regexp for speed. Using a regexp is\n  // significantly more expensive.\n  let tag, id, classes;\n  let dotPos = tagString.indexOf(\".\");\n  let hashPos = tagString.indexOf(\"#\");\n  if (dotPos === -1) {\n    dotPos = tagString.length;\n  } else {\n    classes = tagString.substring(dotPos + 1).replace(/\\./g, \" \");\n  }\n  if (hashPos === -1) {\n    tag = tagString.substring(0, dotPos);\n  } else if (hashPos > dotPos) {\n    throw new Error('ID must come before classes in dom(\"' + tagString + '\")');\n  } else {\n    tag = tagString.substring(0, hashPos);\n    id = tagString.substring(hashPos + 1, dotPos);\n  }\n\n  let elem = elemCreator(tag);\n  if (id)      { elem.setAttribute(\"id\",    id); }\n  if (classes) { elem.setAttribute(\"class\", classes); }\n\n  return elem;\n}\n\nfunction createDOMElement(tagName) {\n  return G.document.createElement(tagName);\n}\n\nfunction createSVGElement(tagName) {\n  return G.document.createElementNS(\"http://www.w3.org/2000/svg\", tagName);\n}\n\n// Append the rest of the arguments as children, flattening arrays\nfunction handleChildren(elem, children, index) {\n  for (var i = index, len = children.length; i < len; i++) {\n    var child = children[i];\n    if (Array.isArray(child)) {\n      child = handleChildren(elem, child, 0);\n    } else if (typeof child == \"function\") {\n      child = child(elem);\n      if (typeof child !== \"undefined\") {\n        handleChildren(elem, [child], 0);\n      }\n    } else if (child === null || child === void 0) {\n      // nothing\n    } else if (child instanceof G.Node) {\n      elem.appendChild(child);\n    } else if (typeof child === \"object\") {\n      for (var key in child) {\n        elem.setAttribute(key, child[key]);\n      }\n    } else {\n      elem.appendChild(G.document.createTextNode(child));\n    }\n  }\n  return elem;\n}\n\n/**\n * Creates a DocumentFragment consisting of all arguments, flattening any arguments that are\n * arrays. If any arguments or array elements are strings, those are turned into text nodes.\n * All argument types supported by the dom() function are supported by dom.frag() as well.\n */\ndom.frag = function(varArgNodes) {\n  var elem = G.document.createDocumentFragment();\n  return handleChildren(elem, arguments, 0);\n};\n\n\n/**\n * Forward all or some arguments to the dom() call. E.g.\n *\n *    dom(a, b, c, dom.fwdArgs(arguments, 2));\n *\n *      is equivalent to:\n *\n *    dom(a, b, c, arguments[2], arguments[3], arguments[4], ...)\n *\n * It is very convenient to use in other functions which want to accept arbitrary arguments for\n * dom() and forward them. See koForm.js for many examples.\n *\n *  @param {Array|Arguments} args: Array or Arguments object containing arguments to forward.\n *  @param {Number} startIndex: The index of the first element to forward.\n */\ndom.fwdArgs = function(args, startIndex) {\n  return function(elem) {\n    handleChildren(elem, args, startIndex);\n  };\n};\n\n\n/**\n * Wraps the given function to make it easy to use as an argument to dom(). The passed-in function\n * must take a DOM Node as the first argument, and the returned wrapped function may be called\n * without this argument when used as an argument to dom(), in which case the original function\n * will be called with the element being constructed.\n *\n * For example, if we define:\n *    foo.method = dom.inlinable(function(elem, a, b) { ... });\n * then the call\n *    dom('div', foo.method(1, 2))\n * translates to\n *    dom('div', function(elem) { foo.method(elem, 1, 2); })\n * which causes foo.method(elem, 1, 2) to be called with elem set to the DIV being constructed.\n *\n * When the first argument is a DOM Node, calls to the wrapped function proceed as usual. In both\n * cases, `this` context is passed along to the wrapped function as expected.\n */\ndom.inlinable = dom.inlineable = function inlinable(func) {\n  return function(optElem) {\n    if (optElem instanceof G.Node) {\n      return func.apply(this, arguments);\n    } else {\n      return wrapInlinable(func, this, arguments);\n    }\n  };\n};\n\nfunction wrapInlinable(func, context, args) {\n  // The switch is an optimization which speeds things up substantially.\n  switch (args.length) {\n    case 0: return function(elem) { return func.call(context, elem); };\n    case 1: return function(elem) { return func.call(context, elem, args[0]); };\n    case 2: return function(elem) { return func.call(context, elem, args[0], args[1]); };\n    case 3: return function(elem) { return func.call(context, elem, args[0], args[1], args[2]); };\n  }\n  return function(elem) {\n    Array.prototype.unshift.call(args, elem);\n    return func.apply(context, args);\n  };\n}\n\n\n/**\n * Shortcut for document.getElementById.\n */\ndom.id = function(id) {\n  return G.document.getElementById(id);\n};\n\n/**\n * Hides the given element. Can be passed into dom(), e.g. dom('div', dom.hide, ...).\n */\ndom.hide = function(elem) {\n  elem.style.display = \"none\";\n};\n\n/**\n * Shows the given element, assuming that it's not hidden by a class.\n */\ndom.show = function(elem) {\n  elem.style.display = \"\";\n};\n\n/**\n * Toggles the given element, assuming that it's not hidden by a class. The second argument is\n * optional, and if provided will make toggle() behave as either show() or hide().\n * @returns {Boolean} Whether the element is visible after toggle.\n */\ndom.toggle = function(elem, optYesNo) {\n  if (optYesNo === undefined)\n    optYesNo = (elem.style.display === \"none\");\n  elem.style.display = optYesNo ? \"\" : \"none\";\n  return elem.style.display !== \"none\";\n};\n\n\n/**\n * Set the given className on the element while it is being dragged over.\n * Can be used inlined as in `dom(..., dom.dragOverClass('foo'))`.\n * @param {String} className: Class name to set while a drag-over is in progress.\n */\ndom.dragOverClass = dom.inlinable(function(elem, className) {\n  // Note: This is hard to get correct on both FF and Chrome because of dragenter/dragleave events that\n  // occur for contained elements. See\n  // http://stackoverflow.com/questions/7110353/html5-dragleave-fired-when-hovering-a-child-element.\n  // Here we use a reference count, and filter out duplicate dragenter events on the same target.\n  let counter = 0;\n  let lastTarget = null;\n\n  dom.on(elem, \"dragenter\", ev => {\n    if (Array.from(ev.originalEvent.dataTransfer.types).includes(\"text/html\")) {\n      // This would not be present when dragging in an actual file. We return undefined, to avoid\n      // suppressing normal behavior (which is suppressed below when we return false).\n      return;\n    }\n    if (ev.target !== lastTarget) {\n      lastTarget = ev.target;\n      ev.originalEvent.dataTransfer.dropEffect = \"copy\";\n      if (!counter) { elem.classList.add(className); }\n      counter++;\n    }\n    return false;\n  });\n\n  dom.on(elem, \"dragleave\", () => {\n    lastTarget = null;\n    counter = Math.max(0, counter - 1);\n    if (!counter) { elem.classList.remove(className); }\n  });\n\n  dom.on(elem, \"drop\", () => {\n    lastTarget = null;\n    counter = 0;\n    elem.classList.remove(className);\n  });\n});\n\n\n/**\n * Change a Node's childNodes similarly to Array splice. This allows removing and adding nodes.\n * It translates to calls to replaceChild, insertBefore, removeChild, and appendChild, as\n * appropriate.\n * @param {Number} index Index at which to start changing the array.\n * @param {Number} howMany Number of old array elements to remove or replace.\n * @param {Node} optNewChildren This is an optional parameter specifying a new node to insert,\n *    and may be repeated to insert multiple nodes. Null values are ignored.\n * @returns {Array[Node]} array of removed nodes.\n * TODO: this desperately needs a unittest.\n */\ndom.splice = function(node, index, howMany, optNewChildren) {\n  var end = Math.min(index + howMany, node.childNodes.length);\n  for (var i = 3; i < arguments.length; i++) {\n    if (arguments[i] !== null) {\n      if (index < end) {\n        node.replaceChild(arguments[i], node.childNodes[index]);\n        index++;\n      } else if (index < node.childNodes.length) {\n        node.insertBefore(arguments[i], node.childNodes[index]);\n      } else {\n        node.appendChild(arguments[i]);\n      }\n    }\n  }\n  var ret = Array.prototype.slice.call(node.childNodes, index, end);\n  while (end > index) {\n    node.removeChild(node.childNodes[--end]);\n  }\n  return ret;\n};\n\n/**\n * Returns the index of the given node among its parent's children (i.e. its siblings).\n */\ndom.childIndex = function(node) {\n  return Array.prototype.indexOf.call(node.parentNode.childNodes, node);\n};\n\n\nfunction makeFilterFunc(selectorOrFunc) {\n  if (typeof selectorOrFunc === \"string\") {\n    return function(elem) { return elem.matches && elem.matches(selectorOrFunc); };\n  }\n  return selectorOrFunc;\n}\n\n/**\n * Iterates backwards through the children of `parent`, returning the first one matching the given\n * selector or filter function. Returns null if no matching node is found.\n */\ndom.findLastChild = function(parent, selectorOrFunc) {\n  var filterFunc = makeFilterFunc(selectorOrFunc);\n  for (var c = parent.lastChild; c; c = c.previousSibling) {\n    if (filterFunc(c)) {\n      return c;\n    }\n  }\n  return null;\n};\n\n\n/**\n * Iterates up the DOM tree from `child` to `container`, returning the first Node matching the\n * given selector or filter function. Returns null for no match.\n * If `container` is given, the returned node will be non-null only if contained in it.\n * If `container` is omitted, the search will go all the way up.\n */\ndom.findAncestor = function(child, optContainer, selectorOrFunc) {\n  if (arguments.length === 2) {\n    selectorOrFunc = optContainer;\n    optContainer = null;\n  }\n  var filterFunc = makeFilterFunc(selectorOrFunc);\n  var match = null;\n  while (child) {\n    if (!match && filterFunc(child)) {\n      match = child;\n      if (!optContainer) {\n        return match;\n      }\n    }\n    if (child === optContainer) {\n      return match;\n    }\n    child = child.parentNode;\n  }\n  return null;\n};\n\n\n/**\n * Detaches a Node from its parent, and returns the Node passed in.\n */\ndom.detachNode = function(node) {\n  if (node.parentNode) {\n    node.parentNode.removeChild(node);\n  }\n  return node;\n};\n\n\n/**\n * Use JQuery to attach an event handler to the given element. For documentation, see\n * http://api.jquery.com/on/. You may use this inline while building dom, as:\n *    dom(..., dom.on(events, args...))\n * E.g.\n *    dom('div',\n *      dom.on('click', function(ev) {\n *        console.log(ev);\n *      })\n *    );\n */\ndom.on = dom.inlinable(function(elem, events, optSelector, optData, handler) {\n  G.$(elem).on(events, optSelector, optData, handler);\n});\n\ndom.once = dom.inlinable(function(elem, events, optSelector, optData, handler) {\n  G.$(elem).one(events, optSelector, optData, handler);\n});\n\n/**\n * Helper to do some processing on a DOM element after the current call stack has cleared. E.g.\n *    dom('input',\n *      dom.defer(function(elem) {\n *        elem.focus();\n *      })\n *    );\n * will cause elem.focus() to be called for the INPUT element after a setTimeout of 0.\n *\n * This is often useful for dealing with focusing and selection.\n */\ndom.defer = function(func, optContext) {\n  return function(elem) {\n    setTimeout(func.bind(optContext, elem), 0);\n  };\n};\n\n\n/**\n * Call the given function with the given context when the element is cleaned up using\n * ko.removeNode or ko.cleanNode. This may be used inline as an argument to dom(), without the\n * first argument, to apply to the element being constructed. The function called will receive the\n * element as the sole argument.\n * @param {Node} elem Element whose destruction should trigger a call to func. It\n *    should be omitted when used as an argument to dom().\n * @param {Function} func Function to call, with elem as an argument, when elem is cleaned up.\n * @param {Object} optContext Optionally `this` context to call the function with.\n */\ndom.onDispose = dom.inlinable(function(elem, func, optContext) {\n  ko.utils.domNodeDisposal.addDisposeCallback(elem, func.bind(optContext));\n});\n\n\n/**\n * Tie the disposal of the given value to the given element, so that value.dispose() gets called\n * when the element is cleaned up using ko.removeNode or ko.cleanNode. This may be used inline as\n * an argument to dom(), without the first argument, to apply to the element being constructed.\n * @param {Node} elem Element whose destruction should trigger the disposal of the value. It\n *    should be omitted when used as an argument to dom().\n * @param {Object} disposableValue A value with a dispose() method, such as a computed observable.\n */\ndom.autoDispose = dom.inlinable(function(elem, disposableValue) {\n  ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {\n    disposableValue.dispose();\n  });\n});\n\n\n/**\n * Set an identifier for the given element for identifying the element in automated browser tests.\n * @param {String} ident: Arbitrary string; convention is to name it as \"ModuleName.nameInModule\".\n */\ndom.testId = dom.inlinable(function(elem, ident) {\n  elem.setAttribute(\"data-test-id\", ident);\n});\n\nmodule.exports = dom;\n"
  },
  {
    "path": "app/client/lib/domAsync.ts",
    "content": "import { reportError } from \"app/client/models/errors\";\n\nimport { DomContents, onDisposeElem, replaceContent } from \"grainjs\";\n// grainjs annoyingly doesn't export browserGlobals tools, useful for testing in a simulated environment.\nimport { G } from \"grainjs/dist/cjs/lib/browserGlobals\";\n\n/**\n * Insert DOM contents produced by a Promise. Until the Promise is fulfilled, nothing shows up.\n * TODO: This would be a handy place to support options to show a loading spinner (perhaps\n * showing up if the promise takes more than a bit to show).\n */\nexport function domAsync(promiseForDomContents: Promise<DomContents>, onError = reportError): DomContents {\n  const markerPre = G.document.createComment(\"a\");\n  const markerPost = G.document.createComment(\"b\");\n\n  // Function is added after the markers, to run once they have been attached to elem (the parent).\n  return [markerPre, markerPost, (elem: Node) => {\n    let disposed = false;\n    promiseForDomContents\n      .then(contents => disposed || replaceContent(markerPre, markerPost, contents))\n      .catch(onError);\n\n    // If markerPost is disposed before the promise resolves, set insertContent to noop.\n    onDisposeElem(markerPost, () => { disposed = true; });\n  }];\n}\n"
  },
  {
    "path": "app/client/lib/domUtils.ts",
    "content": "import { useBindable } from \"app/common/gutil\";\n\nimport { BindableValue, Computed, dom, EventCB, IDisposable, IDisposableOwner, Observable, UseCB } from \"grainjs\";\n\n/**\n * Version of makeTestId that can be appended conditionally.\n */\nexport function makeTestId(prefix: string) {\n  return (id: BindableValue<string>, obs?: BindableValue<boolean>) => {\n    return dom.cls((use) => {\n      if (obs !== undefined && !useBindable(use, obs)) {\n        return \"\";\n      }\n      return `${useBindable(use, prefix)}${useBindable(use, id)}`;\n    });\n  };\n}\n\nexport function autoFocus() {\n  return (el: HTMLElement) => void setTimeout(() => el.focus(), 10);\n}\n\nexport function autoSelect() {\n  return (el: HTMLElement) => void setTimeout(() => (el as any).select?.(), 10);\n}\n\n/**\n * Async computed version of Computed.\n */\nexport const AsyncComputed = {\n  create<T>(owner: IDisposableOwner, cb: (use: UseCB) => Promise<T>): AsyncComputed<T> {\n    const backend: Observable<T | undefined> = Observable.create(owner, undefined);\n    const dirty = Observable.create(owner, true);\n    const computed: Computed<Promise<T>> = Computed.create(owner, cb as any);\n    let ticket = 0;\n    const listener = (prom: Promise<T>): void => {\n      dirty.set(true);\n      const myTicket = ++ticket;\n      prom.then((v) => {\n        if (ticket !== myTicket) { return; }\n        if (backend.isDisposed()) { return; }\n        dirty.set(false);\n        backend.set(v);\n      }).catch(reportError);\n    };\n    owner?.autoDispose(computed.addListener(listener));\n    listener(computed.get());\n    return Object.assign(backend, {\n      dirty,\n    });\n  },\n};\nexport interface AsyncComputed<T> extends Observable<T | undefined> {\n  /**\n   * Whether computed wasn't updated yet.\n   */\n  dirty: Observable<boolean>;\n}\n\n/**\n * Stops propagation of the event, and prevents default action.\n */\nexport function stopEvent(ev: Event) {\n  ev.stopPropagation();\n  ev.preventDefault();\n  ev.stopImmediatePropagation();\n}\n\n/**\n * Adds a handler for a custom event triggered by `domDispatch` function below.\n */\nexport function domOnCustom(name: string, handler: (args: any, event: Event, element: Element) => void) {\n  return (el: Element) => {\n    dom.onElem(el, name, (ev, target) => {\n      const cv = ev as CustomEvent;\n      handler(cv.detail, ev, target);\n    });\n  };\n}\n\n/**\n * Triggers a custom event on an element.\n */\nexport function domDispatch(element: Element, name: string, args?: any) {\n  element.dispatchEvent(new CustomEvent(name, {\n    bubbles: true,\n    detail: args,\n  }));\n}\n\n/**\n * Helper function to bind a click handler that will be called when the user clicks outside.\n * NOTE: There is a similar mechanism available in GristDoc/App, which should be used when a\n * component is tightly integrated with the one of the basic views.\n * ```\n * gristDoc.app.on('clipboard_focus', handler);\n * ```\n */\nexport function onClickOutside(click: () => void) {\n  return (content: HTMLElement) => {\n    dom.autoDisposeElem(content, onClickOutsideElem(content, click));\n  };\n}\n\n/**\n * Helper function to bind a click handler that will be called when the user clicks outside.\n * NOTE: There is a similar mechanism available in GristDoc/App, which should be used when a\n * component is tightly integrated with the one of the basic views.\n * ```\n * gristDoc.app.on('clipboard_focus', handler);\n * ```\n */\nexport function onClickOutsideElem(elem: Node, click: () => void) {\n  const onClick = (evt: MouseEvent) => {\n    const target: Node | null = evt.target as Node;\n    if (target && !elem.contains(target)) {\n      // Check if any parent of target has class grist-floating-menu, if so, don't close.\n      if (target.parentElement?.closest(\".grist-floating-menu\")) {\n        return;\n      }\n      click();\n    }\n  };\n  return dom.onElem(document, \"click\", onClick, { useCapture: true });\n}\n\n/**\n * Helper function which returns the direct child of ancestor which is an ancestor of elem, or\n * null if elem is not a descendant of ancestor.\n */\nexport function findAncestorChild(ancestor: Element, elem: Element | null): Element | null {\n  while (elem && elem.parentElement !== ancestor) {\n    elem = elem.parentElement;\n  }\n  return elem;\n}\n\n/**\n * A version of dom.onElem('mouseover') that doesn't start firing until there is first a 'mousemove'.\n * This way if an element is created under the mouse cursor (triggered by the keyboard, for\n * instance) it's not immediately highlighted, but only when a user moves the mouse.\n * Returns an object with a reset() method, which restarts the wait for mousemove.\n */\nexport function attachMouseOverOnMove<T extends EventTarget>(elem: T, callback: EventCB<MouseEvent, T>) {\n  let lis: IDisposable | undefined;\n  function setListener(eventType: \"mouseover\" | \"mousemove\", cb: EventCB<MouseEvent, T>) {\n    if (lis) { lis.dispose(); }\n    lis = dom.onElem(elem, eventType, cb);\n  }\n  function reset() {\n    setListener(\"mousemove\", (ev, _elem) => {\n      setListener(\"mouseover\", callback);\n      callback(ev, _elem);\n    });\n  }\n  reset();\n  return { reset };\n}\n"
  },
  {
    "path": "app/client/lib/download.js",
    "content": "const G = require(\"../lib/browserGlobals\").get(\"document\");\nconst dom = require(\"../lib/dom\");\n\n/**\n * Note about testing\n * It is difficult to test file downloads as Selenuim and javascript do not provide\n * an easy way to control native dialogs.\n * One approach would be to configure the test browser to automatically start the download and\n * save the file in a specific place. Then check that the file exists at that location.\n * Firefox documentation: http://kb.mozillazine.org/File_types_and_download_actions\n * Approach detailed here in java: https://www.seleniumeasy.com/selenium-tutorials/verify-file-after-downloading-using-webdriver-java\n */\n\nlet _download = null;\n/**\n * Trigger a download on the file at the given url.\n * @param {String} href: The url of the download.\n */\nfunction download(href) {\n  if (!_download) {\n    _download = dom(\"a\", {\n      style: \"position: absolute; top: 0; display: none\",\n      download: \"\"\n    });\n    G.document.body.appendChild(_download);\n  }\n  _download.setAttribute(\"href\", href);\n  _download.click();\n}\n\nmodule.exports = download;\n"
  },
  {
    "path": "app/client/lib/formUtils.ts",
    "content": "import { reportError } from \"app/client/models/errors\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { BaseAPI } from \"app/common/BaseAPI\";\nimport { MaybePromise } from \"app/plugin/gutil\";\n\nimport { dom, Observable } from \"grainjs\";\nimport noop from \"lodash/noop\";\n\ninterface SubmitOptions<T> {\n  pending?: Observable<boolean>;\n  disabled?: Observable<boolean>;\n  onSubmit?: (\n    fields: { [key: string]: string },\n    form: HTMLFormElement,\n    event: SubmitEvent,\n  ) => MaybePromise<T>;\n  onSuccess?: (v: T) => void;\n  onError?: (e: unknown) => void;\n}\n\n/**\n * Handles submission of an HTML form element.\n *\n * When the form is submitted, `onSubmit` will be called, followed by\n * either `onSuccess` or `onError`, depending on whether `onSubmit` threw any\n * unhandled errors. The `pending` observable is set to true until `onSubmit`\n * resolves.\n */\nexport function handleSubmit<T>(\n  options: SubmitOptions<T>,\n): (elem: HTMLFormElement) => void {\n  const {\n    pending,\n    disabled,\n    onSubmit = submitForm,\n    onSuccess = noop,\n    onError = e => reportError(e as string | Error),\n  } = options;\n  return dom.on(\"submit\", async (e, form) => {\n    e.preventDefault();\n    if (pending?.get() || disabled?.get()) {\n      return;\n    }\n\n    pending?.set(true);\n    try {\n      const result = await onSubmit(formDataToObj(form), form, e);\n      onSuccess(result);\n    } catch (err) {\n      onError(err);\n    } finally {\n      if (pending && !pending.isDisposed()) {\n        pending.set(false);\n      }\n    }\n  });\n}\n\n/**\n * Convert a form to a JSON-stringifiable object, ignoring any File fields.\n */\nexport function formDataToObj(formElem: HTMLFormElement): { [key: string]: string } {\n  // Use FormData to collect values (rather than e.g. finding <input> elements) to ensure we get\n  // values from all form items correctly (e.g. checkboxes and textareas).\n  const formData = new FormData(formElem);\n  const data: { [key: string]: string } = {};\n  for (const [name, value] of formData.entries()) {\n    if (typeof value === \"string\") {\n      data[name] = value;\n    }\n  }\n  return data;\n}\n\n/**\n * Submit a form using BaseAPI. Send inputs as JSON, and interpret any reply as JSON.\n */\nasync function submitForm(fields: { [key: string]: string }, form: HTMLFormElement): Promise<any> {\n  return BaseAPI.requestJson(form.action, { method: \"POST\", body: JSON.stringify(fields) });\n}\n\n/**\n * Sets the error details on `errObs` if `err` is a 4XX error. Otherwise, reports the\n * error via the Notifier instance.\n */\nexport function handleFormError(err: unknown, errObs: Observable<string | null>) {\n  if (\n    err instanceof ApiError &&\n    err.status >= 400 &&\n    err.status < 500\n  ) {\n    errObs.set(err.details?.userError ?? err.message);\n  } else {\n    reportError(err as Error | string);\n  }\n}\n\n/**\n * A wrapper around FormData that provides type information for fields.\n */\nexport class TypedFormData {\n  private _formData: FormData = new FormData(this._formElement);\n\n  constructor(private _formElement: HTMLFormElement) {\n\n  }\n\n  public keys() {\n    const keys = Array.from(this._formData.keys());\n    // Don't return keys for scalar values that just return empty strings.\n    // Otherwise, Grist won't fire trigger formulas.\n    return keys.filter((key) => {\n      // If there are multiple values, return the key as is.\n      if (this._formData.getAll(key).length !== 1) { return true; }\n\n      // If the value is an empty string or null, don't return the key.\n      const value = this._formData.get(key);\n      return value !== \"\" && value !== null;\n    });\n  }\n\n  public type(key: string) {\n    return this._formElement.querySelector(`[name=\"${key}\"]`)?.getAttribute(\"data-grist-type\");\n  }\n\n  public get(key: string) {\n    const value = this._formData.get(key);\n    if (value === null) { return null; }\n\n    const type = this.type(key);\n    return type === \"Ref\" || type === \"RefList\" ? Number(value) : value;\n  }\n\n  public set(key: string, value: any) {\n    this._formData.set(key, JSON.stringify(value));\n  }\n\n  public getAll(key: string) {\n    const values = Array.from(this._formData.getAll(key));\n    if ([\"Ref\", \"RefList\"].includes(String(this.type(key)))) {\n      return values.map(v => Number(v));\n    } else {\n      return values;\n    }\n  }\n}\n\n/**\n * Converts TypedFormData into a JSON mapping of Grist fields.\n */\nexport function typedFormDataToJson(formData: TypedFormData) {\n  return Object.fromEntries(Array.from(formData.keys()).map(k =>\n    k.endsWith(\"[]\") ? [k.slice(0, -2), [\"L\", ...formData.getAll(k)]] : [k, formData.get(k)]));\n}\n"
  },
  {
    "path": "app/client/lib/formatUtils.ts",
    "content": "/**\n * Formats a date as a string. Omitting the year if it's the current year.\n * @param timestamp A unix timestamp in milliseconds, Date object, or ISO 8601 string or null.\n * @returns A string like (depending on locale) \"Jan 1\" or \"January 1, 2020\" or \"unknown\".\n */\nexport function dateFmt(timestamp: number | null | string | Date): string {\n  if (!timestamp) { return \"unknown\"; }\n  const date = new Date(timestamp);\n  if (date.getFullYear() !== new Date().getFullYear()) {\n    return dateFmtFull(timestamp);\n  }\n  return new Date(timestamp).toLocaleDateString(\"default\", { month: \"long\", day: \"numeric\" });\n}\n\n/**\n * Formats a date as a string with the full year.\n * @param timestamp A unix timestamp in milliseconds, Date object, or ISO 8601 string or null.\n * @returns A string like (depending on locale) \"January 1, 2020\" or \"unknown\".\n */\nexport function dateFmtFull(timestamp: number | null | string | Date): string {\n  if (!timestamp) { return \"unknown\"; }\n  return new Date(timestamp).toLocaleDateString(\"default\", { month: \"short\", day: \"numeric\", year: \"numeric\" });\n}\n\n/**\n * Formats a timestamp in milliseconds to a time string.\n */\nexport function timeFmt(timestampMs: number): string {\n  return new Date(timestampMs).toLocaleString(\"default\",\n    { month: \"short\", day: \"numeric\", hour: \"numeric\", minute: \"numeric\" });\n}\n"
  },
  {
    "path": "app/client/lib/fromKoSave.ts",
    "content": "/**\n * Replicates some of grainjs's fromKo, except that the returned observables have a set() method\n * which calls koObs.saveOnly(val) rather than koObs(val).\n */\nimport { IKnockoutObservable, KoWrapObs, Observable } from \"grainjs\";\n\nconst wrappers = new WeakMap<IKnockoutObservable<any>, Observable<any>>();\n\n/**\n * Returns a Grain.js observable which mirrors a Knockout observable.\n *\n * Do not dispose this wrapper, as it is shared by all code using koObs, and its lifetime is tied\n * to the lifetime of koObs. If unused, it consumes minimal resources, and should get garbage\n * collected along with koObs.\n */\nexport function fromKoSave<T>(koObs: IKnockoutObservable<T>): Observable<T> {\n  return wrappers.get(koObs) || wrappers.set(koObs, new KoSaveWrapObs(koObs)).get(koObs)!;\n}\n\nexport class KoSaveWrapObs<T> extends KoWrapObs<T> {\n  constructor(_koObs: IKnockoutObservable<T>) {\n    if (!(\"saveOnly\" in _koObs)) {\n      throw new Error(\"fromKoSave needs a saveable observable\");\n    }\n    super(_koObs);\n  }\n\n  public set(value: T): void {\n    // Hacky cast to get a private member. TODO: should make it protected instead.\n    (this as any)._koObs.saveOnly(value);\n  }\n}\n"
  },
  {
    "path": "app/client/lib/getOrCreateStyleElement.ts",
    "content": "/**\n * Gets or creates a style element in the head of the document with the given `id`.\n *\n * Useful for grouping CSS values such as theme custom properties without needing to\n * pollute the document with in-line styles.\n *\n * @param id - The id of the style element to create.\n * @param insertOptions - insertAdjacentElement options to specify where to insert the style element.\n *                        Defaults to before the end of the head.\n */\nexport function getOrCreateStyleElement(id: string, insertOptions: {\n  position: \"beforebegin\" | \"afterbegin\" | \"beforeend\" | \"afterend\"\n  element: Element | null\n} = { position: \"beforeend\", element: null }): HTMLElement {\n  let style = document.getElementById(id);\n  if (style) {\n    return style;\n  }\n\n  style = document.createElement(\"style\");\n  style.setAttribute(\"id\", id);\n\n  (insertOptions.element || document.head).insertAdjacentElement(\n    insertOptions.element ?\n      insertOptions.position :\n      \"beforeend\",\n    style,\n  );\n  return style;\n}\n"
  },
  {
    "path": "app/client/lib/guessTimezone.ts",
    "content": "import { loadMomentTimezone } from \"app/client/lib/imports\";\n\n/**\n * Returns the browser timezone, using moment.tz.guess(), allowing overriding it via a \"timezone\"\n * URL parameter, for the sake of tests.\n */\nexport async function guessTimezone() {\n  const moment = await loadMomentTimezone();\n  const searchParams = new URLSearchParams(window.location.search);\n  return searchParams.get(\"timezone\") || moment.tz.guess();\n}\n"
  },
  {
    "path": "app/client/lib/hashUtils.ts",
    "content": "/**\n * Hash a string into an integer. From https://stackoverflow.com/a/7616484/328565.\n */\nexport function hashCode(str: string): number {\n  let hash: number = 0;\n  for (let i = 0; i < str.length; i++) {\n    hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;\n  }\n  return hash;\n}\n"
  },
  {
    "path": "app/client/lib/helpScout.ts",
    "content": "/**\n * This module contains tools and helpers to open HelpScout \"Beacon\" -- a popup which may contain\n * an email form, chat, and help docs -- and to include info relevant to support requests.\n *\n * Usage:\n *    import {Beacon} from 'app/client/lib/helpScout';\n *    Beacon('open')\n *    Beacon('prefill', {...})\n * It takes care of initialization automatically.\n *\n * This is essentially a prettified typescript version of the snippet for the HelpScout Beacon\n * available under Beacon settings in HelpScout. It offers the API documented at\n * https://developer.helpscout.com/beacon-2/web/javascript-api/\n */\n\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { reportWarning } from \"app/client/models/errors\";\nimport { IAppError } from \"app/client/models/NotifyModel\";\nimport { GristLoadConfig } from \"app/common/gristUrls\";\nimport { timeFormat } from \"app/common/timeFormat\";\nimport * as version from \"app/common/version\";\n\nimport { dom } from \"grainjs\";\nimport identity from \"lodash/identity\";\nimport pickBy from \"lodash/pickBy\";\n\nexport type BeaconCmd = \"init\" | \"destroy\" | \"open\" | \"close\" | \"toggle\" | \"search\" | \"suggest\" |\n  \"article\" | \"navigate\" | \"identify\" | \"prefill\" | \"reset\" | \"logout\" | \"config\" | \"on\" | \"off\" |\n  \"once\" | \"event\" | \"session-data\";\n\nexport type BeaconRoute = \"/ask/message/\" | \"/answers/\";\n\nexport interface IUserObj {\n  name?: string;\n  email?: string;\n  company?: string;\n  jobTitle?: string;\n  avatar?: string;\n  signature?: string;\n  [customKey: string]: string | number | boolean | null | undefined;\n}\n\ninterface IFormObj {\n  name?: string;\n  email?: string;\n  subject?: string;\n  text?: string;\n  fields?: { id: number, value: string | number | boolean }[];\n}\n\ninterface ISessionData {\n  [key: string]: string;\n}\n\ninterface ICallbackAttributes {\n  id?: string;\n  query?: string;\n}\n\n/**\n * This provides the HelpScout Beacon API, taking care of initializing Beacon on first use.\n */\nexport function Beacon(method: \"init\", beaconId: string): void;\nexport function Beacon(method: \"search\", query: string): void;\nexport function Beacon(method: \"suggest\", articles?: string[]): void;\nexport function Beacon(method: \"article\", articleId: string, options?: unknown): void;\nexport function Beacon(method: \"navigate\", route: string): void;\nexport function Beacon(method: \"identify\", userObj: IUserObj): void;\nexport function Beacon(method: \"prefill\", formObj: IFormObj): void;\nexport function Beacon(method: \"config\", configObj: object): void;\nexport function Beacon(method: \"on\" | \"once\", event: string,\n  callback: (attrs?: ICallbackAttributes) => void): void;\nexport function Beacon(method: \"off\", event: string, callback?: () => void): void;\nexport function Beacon(method: \"session-data\", data: ISessionData): void;\nexport function Beacon(method: BeaconCmd): void;\nexport function Beacon(method: BeaconCmd, options?: unknown, data?: unknown) {\n  initBeacon();\n  (window as any).Beacon(method, options, data);\n}\n\n// This is essentially what's done by the code snippet that HelpScout suggests to install in every\n// page. In Grist app pages, we only load HelpScout code when the beacon is opened.\nfunction _beacon(method: BeaconCmd, options?: unknown, data?: unknown) {\n  _beacon.readyQueue.push({ method, options, data });\n}\n_beacon.readyQueue = [] as unknown[];\n\nfunction initBeacon(): void {\n  if (!(window as any).Beacon) {\n    const gristConfig: GristLoadConfig | undefined = window.gristConfig;\n    const beaconId = gristConfig?.helpScoutBeaconId;\n    if (beaconId) {\n      (window as any).Beacon = _beacon;\n      document.head.appendChild(dom(\"script\",\n        {\n          type: \"text/javascript\",\n          src: \"https://beacon-v2.helpscout.net\",\n          async: true,\n        },\n        // Report when the beacon fails to load so that the user knows something is wrong, and we\n        // have a log of the error. (Note: might not report all failures due to ad-blockers.)\n        dom.on(\"error\", (e) => {\n          reportWarning(\"Support form failed to load. \" +\n            \"Please email support@getgrist.com with questions instead.\");\n        }),\n      ));\n      _beacon(\"init\", beaconId);\n      _beacon(\"config\", { display: { style: \"manual\" } });\n    } else {\n      (window as any).Beacon = () => null;\n      reportWarning(\"Support form is not configured\");\n    }\n  }\n}\n\nlet lastOpenType: \"error\" | \"message\" = \"message\";\nlet lastRoute: BeaconRoute | null = null;\n\n/**\n * Helper to open a beacon, taking care of setting focus appropriately. Calls optional onOpen\n * callback when the beacon has opened.\n * If errors is given, prepares a form for submitting an error report, and includes stack traces\n * into the session-data.\n */\nfunction _beaconOpen(userObj: IUserObj | null, options: IBeaconOpenOptions) {\n  const { onOpen, errors } = options;\n\n  // The beacon remembers its content, so reset it when switching between reporting errors and\n  // sending a message.\n  const openType = errors?.length ? \"error\" : \"message\";\n  if (openType !== lastOpenType) {\n    Beacon(\"reset\");\n    lastOpenType = openType;\n  }\n\n  const route: BeaconRoute = options.route || (errors?.length ? \"/ask/message/\" : \"/answers/\");\n  // If beacon was and still is being opened for help articles, avoid the 'navigate' call\n  // altogether, to keep the beacon at the last article it was on.\n  const skipNav = (route === lastRoute && route === \"/answers/\");\n  lastRoute = route;\n\n  Beacon(\"once\", \"open\", () => {\n    const iframe = document.querySelector<HTMLIFrameElement>(\"#beacon-container iframe\")!;\n    if (iframe) { iframe.focus(); }\n    if (onOpen) { onOpen(); }\n  });\n  // Fix base-href tag when opening an article.\n  Beacon(\"once\", \"article-viewed\", () => fixBeaconBaseHref());\n  // We duplicate this check for 'ready' event, because 'open' and 'article-viewed' events don't\n  // trigger on page reload when a beacon article is already open (seems to be a HelpScout bug).\n  Beacon(\"once\", \"ready\", () => fixBeaconBaseHref());\n\n  Beacon(\"once\", \"close\", () => {\n    const iframe = document.querySelector<HTMLIFrameElement>(\"#beacon-container iframe\")!;\n    if (iframe) { iframe.blur(); }\n    Beacon(\"off\", \"article-viewed\");\n  });\n  if (userObj) {\n    Beacon(\"identify\", userObj);\n  }\n\n  const attrs: ISessionData = {};\n  if (errors?.length) {\n    // If sending errors, prefill part of the message (the user sees this and can add to it), and\n    // include more detailed errors with stack traces into session-data.\n    const messages = errors.map(({ error, timestamp }) =>\n      (timeFormat(\"T\", new Date(timestamp)) + \" \" + error.message));\n    const lastMessage = errors.length > 0 ? errors[errors.length - 1].error.message : \"\";\n    const prefill: IFormObj = {\n      subject: `Application Error: ${lastMessage}`.slice(0, 250), // subject has max-length of 250\n      text: `\\n-- Include your description above --\\nErrors encountered:\\n${messages.join(\"\\n\")}\\n`,\n    };\n    Beacon(\"prefill\", prefill);\n    Beacon(\"config\", { messaging: { contactForm: { showSubject: false } } });\n\n    errors.forEach(({ error, timestamp }, i) => {\n      attrs[`error-${i}`] =  timeFormat(\"D T\", new Date(timestamp)) + \" \" + error.message;\n      if (error.stack) {\n        attrs[`error-${i}-stack`] = JSON.stringify(error.stack.trim().split(\"\\n\"));\n      }\n    });\n  } else {\n    Beacon(\"config\", { messaging: { contactForm: { showSubject: true } } });\n  }\n\n  Beacon(\"session-data\", {\n    \"Grist Version\": `${version.version} (${version.gitcommit})`,\n    ...attrs,\n  });\n  Beacon(\"open\");\n  if (!skipNav) {\n    Beacon(\"navigate\", route);\n  }\n\n  Beacon(\"once\", \"open\", () => logTelemetryEvent(\"beaconOpen\"));\n  Beacon(\"on\", \"article-viewed\", article => logTelemetryEvent(\"beaconArticleViewed\", {\n    full: { articleId: article!.id },\n  }));\n  Beacon(\"on\", \"email-sent\", () => logTelemetryEvent(\"beaconEmailSent\"));\n  Beacon(\"on\", \"search\", search => logTelemetryEvent(\"beaconSearch\", {\n    full: { searchQuery: search!.query },\n  }));\n}\n\nfunction fixBeaconBaseHref() {\n  // HelpScout creates an iframe with an empty 'src' attribute, then writes to it. In such an\n  // iframe, different browsers interpret relative links differently: Chrome's are relative to\n  // the parent page's URL; Firefox's are relative to the parent page's <base href>.\n  //\n  // Here we set a <base href> explicitly in the iframe to get consistent behavior of links\n  // relative to the top page's URL (HelpScout then seems to handle clicks on them correctly).\n  const iframe = document.querySelector<HTMLIFrameElement>(\"#beacon-container iframe\")!;\n  const iframeDoc = iframe?.contentDocument;\n  if (iframeDoc && !iframeDoc.querySelector(\"head > base\")) {\n    iframeDoc.head.appendChild(dom(\"base\", { href: \"\" }));\n  }\n}\n\nexport interface IBeaconOpenOptions {\n  appModel: AppModel | null;\n  includeAppErrors?: boolean;\n  onOpen?: () => void;\n  errors?: IAppError[];\n  route?: BeaconRoute;\n}\n\n/**\n * Open the helpScout beacon to send us a message. Calls optional onOpen callback when the beacon\n * has opened. The topAppModel is used to get the current user.\n *\n * If includeAppErrors or errors is set, the beacon will open to submit an error report. With\n * includeAppErrors, it will include stack traces of errors in the notifier into the session-data.\n * If errors is set, it will include the specified errors.\n */\nexport function beaconOpenMessage(options: IBeaconOpenOptions) {\n  const app = options.appModel;\n  const errors = options.errors || [];\n  if (options.includeAppErrors && app) {\n    errors.push(...app.notifier.getFullAppErrors());\n  }\n  _beaconOpen(getBeaconUserObj(app), { ...options, errors });\n}\n\nfunction getBeaconUserObj(appModel: AppModel | null): IUserObj | null {\n  if (!appModel) { return null; }\n\n  // ActiveSessionInfo[\"user\"] includes optional helpScoutSignature too.\n  const user = appModel.currentValidUser;\n\n  // For anon user, don't attempt to identify anything. Even the \"company\" field (when anon on a\n  // team doc) isn't useful, because the user may be external to the company.\n  if (!user) { return null; }\n\n  // Use the company name only when it's not a personal org. Otherwise, it adds no information and\n  // overrides more useful company name gleaned by HelpScout from the web.\n  const org = appModel.currentOrg;\n  const company = org && !org.owner ? appModel.currentOrgName : undefined;\n\n  return pickBy({\n    name: user.name,\n    email: user.email,\n    company,\n    avatar: user.picture,\n    signature: user.helpScoutSignature,\n  }, identity);\n}\n"
  },
  {
    "path": "app/client/lib/imports.d.ts",
    "content": "import * as GristDocModule from \"app/client/components/GristDoc\";\nimport * as ViewPane from \"app/client/components/ViewPane\";\nimport * as AirtableImportUI from \"app/client/lib/airtable/AirtableImportUI\";\nimport * as AccountPageModule from \"app/client/ui/AccountPage\";\nimport * as ActivationPageModule from \"app/client/ui/ActivationPage\";\nimport * as AdminPanelModule from \"app/client/ui/AdminPanel\";\nimport * as AuditLogsPageModule from \"app/client/ui/AuditLogsPage\";\nimport * as BillingPageModule from \"app/client/ui/BillingPage\";\nimport * as EmojiPickerModule from \"app/client/ui/EmojiPicker\";\nimport * as UserManagerModule from \"app/client/ui/UserManager\";\nimport * as searchModule from \"app/client/ui2018/search\";\n\nimport * as ace from \"ace-builds\";\nimport * as momentTimezone from \"moment-timezone\";\nimport * as plotly from \"plotly.js\";\n\nexport type Ace = typeof ace;\nexport type MomentTimezone = typeof momentTimezone;\nexport type PlotlyType = typeof plotly;\n\nexport function loadAccountPage(): Promise<typeof AccountPageModule>;\nexport function loadActivationPage(): Promise<typeof ActivationPageModule>;\nexport function loadAirtableImportUI(): Promise<typeof AirtableImportUI>;\nexport function loadAuditLogsPage(): Promise<typeof AuditLogsPageModule>;\nexport function loadBillingPage(): Promise<typeof BillingPageModule>;\nexport function loadAdminPanel(): Promise<typeof AdminPanelModule>;\nexport function loadGristDoc(): Promise<typeof GristDocModule>;\nexport function loadAce(): Promise<Ace>;\nexport function loadEmojiPicker(): Promise<typeof EmojiPickerModule>;\nexport function loadMomentTimezone(): Promise<MomentTimezone>;\nexport function loadPlotly(): Promise<PlotlyType>;\nexport function loadSearch(): Promise<typeof searchModule>;\nexport function loadUserManager(): Promise<typeof UserManagerModule>;\nexport function loadViewPane(): Promise<typeof ViewPane>;\n"
  },
  {
    "path": "app/client/lib/imports.js",
    "content": "/**\n *\n * Dynamic imports from js work fine with webpack; from typescript we need to upgrade\n * our \"module\" setting, which has a lot of knock-on effects.  To work around that for\n * the moment, importing can be done from this js file.\n *\n */\n\nexports.loadAccountPage = () => import(\"app/client/ui/AccountPage\" /* webpackChunkName: \"AccountPage\" */);\nexports.loadActivationPage = () => import(\"app/client/ui/ActivationPage\" /* webpackChunkName: \"ActivationPage\" */);\nexports.loadAirtableImportUI = () => import(\"app/client/lib/airtable/AirtableImportUI\" /* webpackChunkName: \"AirtableImport\" */);\nexports.loadAuditLogsPage = () => import(\"app/client/ui/AuditLogsPage\" /* webpackChunkName: \"AuditLogsPage\" */);\nexports.loadBillingPage = () => import(\"app/client/ui/BillingPage\" /* webpackChunkName: \"BillingModule\" */);\nexports.loadAdminPanel = () => import(\"app/client/ui/AdminPanel\" /* webpackChunkName: \"AdminPanel\" */);\nexports.loadGristDoc = () => import(\"app/client/components/GristDoc\" /* webpackChunkName: \"GristDoc\" */);\n// When importing this way, the module is under the \"default\" member, not sure why (maybe\n// esbuild-loader's doing).\nexports.loadAce = () => import(\"ace-builds\")\n  .then(async (m) => {\n    await Promise.all([\n      import(\"ace-builds/src-noconflict/ext-static_highlight\"),\n      import(\"ace-builds/src-noconflict/mode-python\"),\n      import(\"ace-builds/src-noconflict/theme-chrome\"),\n      import(\"ace-builds/src-noconflict/theme-dracula\"),\n    ]);\n\n    return m.default;\n  });\nexports.loadEmojiPicker = () => import(\"app/client/ui/EmojiPicker\" /* webpackChunkName: \"emojipicker\" */);\nexports.loadMomentTimezone = () => import(\"moment-timezone\").then(m => m.default);\nexports.loadPlotly = () => import(\"plotly.js-basic-dist\" /* webpackChunkName: \"plotly\" */);\nexports.loadSearch = () => import(\"app/client/ui2018/search\" /* webpackChunkName: \"search\" */);\nexports.loadUserManager = () => import(\"app/client/ui/UserManager\" /* webpackChunkName: \"usermanager\" */);\nexports.loadViewPane = () => import(\"app/client/components/ViewPane\" /* webpackChunkName: \"viewpane\" */);\n"
  },
  {
    "path": "app/client/lib/isFocusable.ts",
    "content": "/**\n * Code authored by Kitty Giraudel for a11y-dialog https://github.com/KittyGiraudel/a11y-dialog, thanks to her!\n *\n * This was split from the trapTabKey file to expose the `isFocusable` function that is useful on its own.\n *\n * The MIT License (MIT)\n *\n * Copyright (c) 2025 Kitty Giraudel\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated\n * documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation\n * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,\n * and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\n * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL\n * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF\n * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n * IN THE SOFTWARE.\n */\n\n/**\n * Determine if an element is focusable and has user-visible painted dimensions.\n */\nexport const isFocusable = (el: HTMLElement) => {\n  // A shadow host that delegates focus will never directly receive focus,\n  // even with `tabindex=0`. Consider our <fancy-button> custom element, which\n  // delegates focus to its shadow button:\n  //\n  // <fancy-button tabindex=\"0\">\n  //  #shadow-root\n  //  <button><slot></slot></button>\n  // </fancy-button>\n  //\n  // The browser acts as as if there is only one focusable element – the shadow\n  // button. Our library should behave the same way.\n  if (el.shadowRoot?.delegatesFocus) { return false; }\n\n  return el.matches(focusableSelectorsString) && !isHidden(el);\n};\n\n/**\n * Determine if an element is hidden from the user.\n */\nconst isHidden = (el: HTMLElement) => {\n  // Browsers hide all non-<summary> descendants of closed <details> elements\n  // from user interaction, but those non-<summary> elements may still match our\n  // focusable-selectors and may still have dimensions, so we need a special\n  // case to ignore them.\n  if (\n    el.matches(\"details:not([open]) *\") &&\n    !el.matches(\"details>summary:first-of-type\")\n  ) { return true; }\n\n  // If this element has no painted dimensions, it's hidden.\n  return !(el.offsetWidth || el.offsetHeight || el.getClientRects().length);\n};\n\nconst notInert = \":not([inert]):not([inert] *)\";\nconst notNegTabIndex = ':not([tabindex^=\"-\"])';\nconst notDisabled = \":not(:disabled)\";\n\nconst focusableSelectors = [\n  `a[href]${notInert}${notNegTabIndex}`,\n  `area[href]${notInert}${notNegTabIndex}`,\n  `input:not([type=\"hidden\"]):not([type=\"radio\"])${notInert}${notNegTabIndex}${notDisabled}`,\n  `input[type=\"radio\"]${notInert}${notNegTabIndex}${notDisabled}`,\n  `select${notInert}${notNegTabIndex}${notDisabled}`,\n  `textarea${notInert}${notNegTabIndex}${notDisabled}`,\n  `button${notInert}${notNegTabIndex}${notDisabled}`,\n  `details${notInert} > summary:first-of-type${notNegTabIndex}`,\n  // Discard until Firefox supports `:has()`\n  // See: https://github.com/KittyGiraudel/focusable-selectors/issues/12\n  // `details:not(:has(> summary))${notInert}${notNegTabIndex}`,\n  `iframe${notInert}${notNegTabIndex}`,\n  `audio[controls]${notInert}${notNegTabIndex}`,\n  `video[controls]${notInert}${notNegTabIndex}`,\n  `[contenteditable]${notInert}${notNegTabIndex}`,\n  `[tabindex]${notInert}${notNegTabIndex}`,\n];\n\nconst focusableSelectorsString = focusableSelectors.join(\",\");\n"
  },
  {
    "path": "app/client/lib/koArray.d.ts",
    "content": "import * as ko from \"knockout\";\n\ndeclare class KoArray<T> {\n  public static syncedKoArray(...args: any[]): any;\n  public peekLength: number;\n  public subscribe: ko.Observable[\"subscribe\"];\n\n  public dispose(): void;\n  public at(index: number): T | null;\n  public all(): T[];\n  public map<T2>(op: (x: T) => T2): KoArray<T2>;\n  public peek(): T[];\n  public getObservable(): ko.Observable<T[]>;\n  public push(...items: T[]): void;\n  public unshift(...items: T[]): void;\n  public assign(newValues: T[]): void;\n  public splice(start: number, optDeleteCount?: number, ...values: T[]): T[];\n  public subscribeForEach(options: {\n    add?: (item: T, index: number, arr: KoArray<T>) => void;\n    remove?: (item: T, arr: KoArray<T>) => void;\n    addDelay?: number;\n  }): ko.Subscription;\n\n  public clampIndex(index: number): number | null;\n  public makeLiveIndex(index?: number): ko.Observable<number> & { setLive(live: boolean): void };\n  public setAutoDisposeValues(): this;\n  public arraySplice(start: number, deleteCount: number, items: T[]): T[];\n}\n\ndeclare function syncedKoArray(...args: any[]): any;\n\nexport default function koArray<T>(initialValue?: T[]): KoArray<T>;\nexport function isKoArray(obj: any): obj is KoArray<any>;\n"
  },
  {
    "path": "app/client/lib/koArray.js",
    "content": "/**\n * Our version of knockout's ko.observableArray(), similar but more efficient. It\n * supports fewer methods (mainly because we don't need other methods at the moment). Instead of\n * emitting 'arrayChange' events, it emits 'spliceChange' events.\n */\n\n\nvar ko = require(\"knockout\");\nvar Promise = require(\"bluebird\");\nvar dispose = require(\"./dispose\");\nvar gutil = require(\"app/common/gutil\");\n\nrequire(\"./koUtil\");   // adds subscribeInit method to observables.\n\n/**\n * Event indicating that a koArray has been modified. This reflects changes to which objects are\n * in the array, not the state of those objects. A `spliceChange` event is emitted after the array\n * has been modified.\n * @event spliceChange\n * @property {Array} data - The underlying array, already modified.\n * @property {Number} start - The start index at which items were inserted or deleted.\n * @property {Number} added - The number of items inserted.\n * @property {Array} deleted - The array of items that got deleted.\n */\n\n/**\n * Creates and returns a new koArray, either empty or with the given initial values.\n * Unlike a ko.observableArray(), you access the values using array.all(), and set values using\n * array.assign() (or better, by using push() and splice()).\n */\nfunction koArray(optInitialValues) {\n  return KoArray.create(optInitialValues);\n}\n\n// The koArray function is the main export.\nmodule.exports = exports = koArray;\nexports.default = koArray;\n\n/**\n * Checks if an object is an instance of koArray.\n */\nkoArray.isKoArray = function(obj) {\n  return (obj && typeof obj.subscribe === \"function\" && typeof obj.all === \"function\");\n};\nexports.isKoArray = koArray.isKoArray;\n\n/**\n * Given an observable which evaluates to different arrays or koArrays, returns a single koArray\n * observable which mirrors whichever array is the current value of the observable. If a callback\n * is given, all elements are mapped through it. See also map().\n * @param {ko.observable} koArrayObservable: observable whose value is a koArray or plain array.\n * @param {Function} optCallback: If given, maps elements from original arrays.\n * @param {Object} optCallbackTarget: If callback is given, this becomes the `this` value for it.\n * @returns {koArray} a single koArray that mirrors the current value of koArrayObservable,\n *    optionally mapping them through optCallback.\n */\nkoArray.syncedKoArray = function(koArrayObservable, optCallback, optCallbackTarget) {\n  var ret = koArray();\n  optCallback = optCallback || identity;\n  ret.autoDispose(koArrayObservable.subscribeInit(function(currentArray) {\n    if (koArray.isKoArray(currentArray)) {\n      ret.syncMap(currentArray, optCallback, optCallbackTarget);\n    } else if (currentArray) {\n      ret.syncMapDisable();\n      ret.assign(currentArray.map(function(item, i) {\n        return optCallback.call(optCallbackTarget, item, i);\n      }));\n    }\n  }));\n  return ret;\n};\nexports.syncedKoArray = koArray.syncedKoArray;\n\n\nfunction SyncedState(constructFunc, key) {\n  constructFunc(this, key);\n}\ndispose.makeDisposable(SyncedState);\n\n/**\n * Create and return a new Map that's kept in sync with koArrayObj. The keys are the array items\n * themselves. The values are constructed using constructFunc(state, item), where state is a new\n * Disposable object, allowing to associate other disposable state with the item. The returned Map\n * should itself be disposed when no longer needed.\n * @param {KoArray} koArrayObj: A KoArray object to watch.\n * @param {Function} constructFunc(state, item): called for each item in the array, with a new\n *    disposable state object, on which all Disposable methods are available. The state object\n *    will be disposed when an item is removed or the returned map itself disposed.\n * @param [Number] options.addDelay: (optional) If numeric, delay calls to add items\n *    by this many milliseconds (except initialization, which is always immediate).\n * @return {Map} map object mapping array items to state objects, and with a dispose() method.\n */\nkoArray.syncedMap = function(koArrayObj, constructFunc, options) {\n  var map = new Map();\n  var sub = koArrayObj.subscribeForEach({\n    add: item => map.set(item, SyncedState.create(constructFunc, item)),\n    remove: item => gutil.popFromMap(map, item).dispose(),\n    addDelay: options && options.addDelay\n  });\n  map.dispose = () => {\n    sub.dispose();\n    map.forEach((stateObj, item) => stateObj.dispose());\n  };\n  return map;\n};\n\n\n/**\n * The actual constructor for koArray. To create a new instance, simply use koArray() (without\n * `new`). The constructor might be needed, however, to inherit from this class.\n */\nfunction KoArray(initialValues) {\n  this._array = ko.observable(initialValues || []);\n  this._preparedSpliceEvent = null;\n  this._syncSubscription = null;\n  this._disposeElements = noop;\n\n  this.autoDispose(this._array.subscribe(this._emitPreparedEvent, this, \"spectate\"));\n\n  this.autoDisposeCallback(function() {\n    this._disposeElements(this.peek());\n  });\n}\nexports.KoArray = KoArray;\n\ndispose.makeDisposable(KoArray);\n\n/**\n * If called on a koArray, it will dispose of its contained items as they are removed or when the\n * array is itself disposed.\n * @returns {koArray} itself.\n */\nKoArray.prototype.setAutoDisposeValues = function() {\n  this._disposeElements = this._doDisposeElements;\n  return this;\n};\n\n/**\n * Returns the underlying array, creating a dependency when used from a computed observable.\n * Note that you must not modify the returned array directly; you should use koArray methods.\n */\nKoArray.prototype.all = function() {\n  return this._array();\n};\n\n/**\n * Returns the underlying array without creating a dependency on it.\n * Note that you must not modify the returned array directly; you should use koArray methods.\n */\nKoArray.prototype.peek = function() {\n  return this._array.peek();\n};\n\n/**\n * Returns the underlying observable whose value is a plain array.\n */\nKoArray.prototype.getObservable = function() {\n  return this._array;\n};\n\n/**\n * The `peekLength` property evaluates to the length of the underlying array. Using it does NOT\n * create a dependency on the array. Use array.all().length to create a dependency.\n */\nObject.defineProperty(KoArray.prototype, \"peekLength\", {\n  configurable: false,\n  enumerable: false,\n  get: function() { return this._array.peek().length; },\n});\n\n/**\n * A shorthand for the itemModel at a given index. Returns null if the index is invalid or out of\n * range. Create a dependency on the array itself.\n */\nKoArray.prototype.at = function(index) {\n  var arr = this._array();\n  return index >= 0 && index < arr.length ? arr[index] : null;\n};\n\n/**\n * Assigns a new underlying array. This is analogous to observableArray(newValues).\n */\nKoArray.prototype.assign = function(newValues) {\n  var oldArray = this.peek();\n  this._prepareSpliceEvent(0, newValues.length, oldArray);\n  this._array(newValues.slice());\n  this._disposeElements(oldArray);\n};\n\n\n/**\n * Subscribe to events for this koArray. To be notified of splice details, subscribe to\n * 'spliceChange', which will always follow the plain 'change' events.\n */\nKoArray.prototype.subscribe = function(callback, callbackTarget, event) {\n  return this._array.subscribe(callback, callbackTarget, event);\n};\n\n\n/**\n * @private\n * Internal method to prepare a 'spliceChange' event.\n */\nKoArray.prototype._prepareSpliceEvent = function(start, numAdded, deleted) {\n  this._preparedSpliceEvent = {\n    array: null,\n    start: start,\n    added: numAdded,\n    deleted: deleted\n  };\n};\n\n/**\n * @private\n * Internal method to emit and reset a prepared 'spliceChange' event, if there is one.\n */\nKoArray.prototype._emitPreparedEvent = function() {\n  var event = this._preparedSpliceEvent;\n  if (event) {\n    event.array = this.peek();\n    this._preparedSpliceEvent = null;\n    this._array.notifySubscribers(event, \"spliceChange\");\n  }\n};\n\n/**\n * @private\n * Internal method called before the underlying array is modified. This copies how knockout emits\n * its default events internally.\n */\nKoArray.prototype._preChange = function() {\n  this._array.valueWillMutate();\n};\n\n/**\n * @private\n * Internal method called before the underlying array is modified. This copies how knockout emits\n * its default events internally.\n */\nKoArray.prototype._postChange = function() {\n  this._array.valueHasMutated();\n};\n\n/**\n * @private\n * Internal method to call dispose() for each item in the passed-in array. It's only used when\n * autoDisposeValues option is given to koArray.\n */\nKoArray.prototype._doDisposeElements = function(elements) {\n  for (var i = 0; i < elements.length; i++) {\n    elements[i].dispose();\n  }\n};\n\n/**\n * The standard array `push` method, which emits all expected events.\n */\nKoArray.prototype.push = function() {\n  var array = this.peek();\n  var start = array.length;\n\n  this._preChange();\n  var ret = array.push.apply(array, arguments);\n  this._prepareSpliceEvent(start, arguments.length, []);\n  this._postChange();\n  return ret;\n};\n\n/**\n * The standard array `unshift` method, which emits all expected events.\n */\nKoArray.prototype.unshift = function() {\n  var array = this.peek();\n  this._preChange();\n  var ret = array.unshift.apply(array, arguments);\n  this._prepareSpliceEvent(0, arguments.length, []);\n  this._postChange();\n  return ret;\n};\n\n/**\n * The standard array `splice` method, which emits all expected events.\n */\nKoArray.prototype.splice = function(start, optDeleteCount) {\n  return this.arraySplice(start, optDeleteCount, Array.prototype.slice.call(arguments, 2));\n};\n\nKoArray.prototype.arraySplice = function(start, optDeleteCount, arrToInsert) {\n  var array = this.peek();\n  var len = array.length;\n  var startIndex = Math.min(len, Math.max(0, start < 0 ? len + start : start));\n\n  this._preChange();\n  var ret = (optDeleteCount === void 0 ? array.splice(start) :\n    array.splice(start, optDeleteCount));\n  gutil.arraySplice(array, startIndex, arrToInsert);\n  this._prepareSpliceEvent(startIndex, arrToInsert.length, ret);\n  this._postChange();\n  this._disposeElements(ret);\n  return ret;\n};\n\n/**\n * The standard array `slice` method. Creates a dependency when used from a computed observable.\n */\nKoArray.prototype.slice = function() {\n  var array = this.all();\n  return array.slice.apply(array, arguments);\n};\n\n\n/**\n * Returns a new KoArray instance, subscribed to the current one to stay parallel to it. The new\n * element are set to the result of calling `callback(orig, i)` on each original element. Note\n * that the index argument is only correct as of the time the callback got called.\n */\nKoArray.prototype.map = function(callback, optThis) {\n  var newArray = new KoArray();\n  newArray.syncMap(this, callback, optThis);\n  return newArray;\n};\n\n\nfunction noop() {}\nfunction identity(x) { return x; }\n\n/**\n * Keep this array in sync with another koArray, optionally mapping all elements through the given\n * callback. If callback is omitted, the current array will just mirror otherKoArray.\n * See also map().\n *\n * The subscription is disposed when the koArray is disposed.\n */\nKoArray.prototype.syncMap = function(otherKoArray, optCallback, optCallbackTarget) {\n  this.syncMapDisable();\n\n  optCallback = optCallback || identity;\n\n  this.assign(otherKoArray.peek().map(function(item, i) {\n    return optCallback.call(optCallbackTarget, item, i);\n  }));\n\n  this._syncSubscription = this.autoDispose(otherKoArray.subscribe(function(splice) {\n    var arr = splice.array;\n    var newValues = [];\n    for (var i = splice.start, n = 0; n < splice.added; i++, n++) {\n      newValues.push(optCallback.call(optCallbackTarget, arr[i], i));\n    }\n    this.arraySplice(splice.start, splice.deleted.length, newValues);\n  }, this, \"spliceChange\"));\n};\n\n/**\n * Disable previously created syncMap subscription, if any.\n */\nKoArray.prototype.syncMapDisable = function() {\n  if (this._syncSubscription) {\n    this.disposeDiscard(this._syncSubscription);\n    this._syncSubscription = null;\n  }\n};\n\n\n/**\n * Analog to forEach for regular arrays, but that stays in sync with array changes.\n * @param {Function} options.add: func(item, index, koarray) is called for each item present,\n *    and whenever an item is added.\n * @param {Function} options.remove: func(item, koarray) is called whenever an item is removed.\n * @param [Object] options.context: (optional) `this` value to use in add/remove callbacks.\n * @param [Number] options.addDelay: (optional) If numeric, delay calls to the add\n *    callback by this many milliseconds (except initialization calls which are always immediate).\n */\nKoArray.prototype.subscribeForEach = function(options) {\n  var context = options.context;\n  var onAdd = options.add || noop;\n  var onRemove = options.remove || noop;\n  var shouldDelay = (typeof options.addDelay === \"number\");\n\n  var subscription = this.subscribe(function(splice) {\n    var i, arr = splice.array;\n    for (i = 0; i < splice.deleted.length; i++) {\n      onRemove.call(context, splice.deleted[i], this);\n    }\n    var callAdd = () => {\n      var end = splice.start + splice.added;\n      for (i = splice.start; i < end; i++) {\n        onAdd.call(context, arr[i], i, this);\n      }\n    };\n    if (!shouldDelay) {\n      callAdd();\n    } else if (options.addDelay > 0) {\n      setTimeout(callAdd, options.addDelay);\n    } else {\n      // Promise library invokes the callback much sooner than setTimeout does, i.e. it's much\n      // closer to \"nextTick\", which is what we want here.\n      Promise.resolve(null).then(callAdd);\n    }\n  }, this, \"spliceChange\");\n\n  this.peek().forEach(function(item, i) {\n    onAdd.call(context, item, i, this);\n  }, this);\n\n  return subscription;\n};\n\n/**\n * Given a numeric index, returns an index that's valid for this array, clamping it if needed.\n * If the array is empty, returns null. If the index given is null, treats it as 0.\n */\nKoArray.prototype.clampIndex = function(index) {\n  var len = this.peekLength;\n  return len === 0 ? null : gutil.clamp(index || 0, 0, len - 1);\n};\n\n/**\n * Returns a new observable representing an index into this array. It can be read and written, and\n * its value is clamped to be a valid index. The index is only null if the array is empty.\n *\n * As the array changes, the index is adjusted to continue pointing to the same element. If the\n * pointed element is deleted, the index is adjusted to after the deletion point.\n *\n * The returned observable has an additional .setLive(bool) method. While set to false, the\n * observale will not be adjusted as the array changes, except to keep it valid.\n */\nKoArray.prototype.makeLiveIndex = function(optInitialIndex) {\n  // The underlying observable index. Not exposed directly.\n  var index = ko.observable(this.clampIndex(optInitialIndex));\n  var isLive = true;\n\n  // Adjust the index when data is spliced before it.\n  this.subscribe(function(splice) {\n    var idx = index.peek();\n    if (!isLive) {\n      index(this.clampIndex(idx));\n    } else if (idx === null) {\n      index(this.clampIndex(0));\n    } else if (idx >= splice.start + splice.deleted.length) {\n      // Adjust the index if it was beyond the deleted region.\n      index(this.clampIndex(idx + splice.added - splice.deleted.length));\n    } else if (idx >= splice.start + splice.added) {\n      // Adjust the index if it was inside the deleted region (and not replaced).\n      index(this.clampIndex(splice.start + splice.added));\n    }\n  }, this, \"spliceChange\");\n\n  // The returned value, which is a writable computable, constraining the value to the valid range\n  // (or null if the range is empty).\n  var ret = ko.pureComputed({\n    read: index,\n    write: function(val) { index(this.clampIndex(val)); },\n    owner: this\n  });\n  ret.setLive = (val => { isLive = val; });\n  return ret;\n};\n"
  },
  {
    "path": "app/client/lib/koArrayWrap.ts",
    "content": "import { KoArray } from \"app/client/lib/koArray\";\n\nimport { IDisposableOwnerT, MutableObsArray, ObsArray, setDisposeOwner } from \"grainjs\";\n\n/**\n * Returns a grainjs ObsArray that reflects the given koArray, mapping small changes using\n * similarly efficient events.\n *\n * (Note that for both ObsArray and koArray, the main purpose in life is to be more efficient than\n * an array-valued observable by handling small changes more efficiently.)\n */\nexport function createObsArray<T>(\n  owner: IDisposableOwnerT<ObsArray<T>> | null,\n  koArray: KoArray<T>,\n): ObsArray<T> {\n  return setDisposeOwner(owner, new KoWrapObsArray(koArray));\n}\n\n/**\n * An Observable that wraps a Knockout observable, created via fromKo(). It keeps minimal overhead\n * when unused by only subscribing to the wrapped observable while it itself has subscriptions.\n *\n * This way, when unused, the only reference is from the wrapper to the wrapped object. KoWrapObs\n * should not be disposed; its lifetime is tied to that of the wrapped object.\n */\nclass KoWrapObsArray<T> extends MutableObsArray<T> {\n  private _koSub: any = null;\n\n  constructor(_koArray: KoArray<T>) {\n    super(Array.from(_koArray.peek()));\n\n    this._koSub = _koArray.subscribe((splice: any) => {\n      const newValues = splice.array.slice(splice.start, splice.start + splice.added);\n      this.splice(splice.start, splice.deleted.length, ...newValues);\n    }, null, \"spliceChange\");\n  }\n\n  public dispose(): void {\n    this._koSub.dispose();\n    super.dispose();\n  }\n}\n"
  },
  {
    "path": "app/client/lib/koDom.js",
    "content": "/**\n * koDom.js is an analog to Knockout.js bindings that works with our dom.js library.\n * koDom provides a suite of bindings between the DOM and knockout observables.\n *\n * For example, here's how we can create som DOM with some bindings, given a view-model object vm:\n *    dom(\n *      'div',\n *      kd.toggleClass('active', vm.isActive),\n *      kd.text(function() {\n *        return vm.data()[vm.selectedRow()][part.value.index()];\n *      })\n *    );\n */\n\n\n/**\n * Use the browser globals in a way that allows replacing them with mocks in tests.\n */\nvar G = require(\"./browserGlobals\").get(\"document\", \"Node\");\n\nvar ko = require(\"knockout\");\nvar dom = require(\"./dom\");\nvar koArray = require(\"./koArray\");\n\n/**\n * Creates a binding between a DOM element and an observable value, making sure that\n * updaterFunc(elem, value) is called whenever the observable changes. It also registers disposal\n * callbacks on the element so that the binding is cleared when the element is disposed with\n * ko.cleanNode() or ko.removeNode().\n *\n * @param {Node} elem: DOM element.\n * @param {Object} valueOrFunc: Either an observable, a function (to create ko.computed() with),\n *      or a constant value.\n * @param {Function} updaterFunc: Called both initially and whenever the value changes as\n *      updaterFunc(elem, value). The value is already unwrapped (so is not an observable).\n */\nfunction setBinding(elem, valueOrFunc, updaterFunc) {\n  var subscription;\n  if (ko.isObservable(valueOrFunc)) {\n    subscription = valueOrFunc.subscribe(function(v) { updaterFunc(elem, v); });\n    ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {\n      subscription.dispose();\n    });\n    updaterFunc(elem, valueOrFunc.peek());\n  } else if (typeof valueOrFunc === \"function\") {\n    valueOrFunc = ko.computed(valueOrFunc);\n    subscription = valueOrFunc.subscribe(function(v) { updaterFunc(elem, v); });\n    ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {\n      subscription.dispose();\n      valueOrFunc.dispose();\n    });\n    updaterFunc(elem, valueOrFunc.peek());\n  } else {\n    updaterFunc(elem, valueOrFunc);\n  }\n}\nexports.setBinding = setBinding;\n\n/**\n * Internal helper to create a binding. Used by most simple bindings.\n * @param {Object} valueOrFunc: Either an observable, a function (to create ko.computed() with),\n *      or a constant value.\n * @param {Function} updaterFunc: Called both initially and whenever the value changes as\n *      updaterFunc(elem, value). The value is already unwrapped (so is not an observable).\n * @returns {Function} Function suitable to pass as an argument to dom(); i.e. one that takes an\n *      DOM element, and adds the bindings to it. It also registers disposal callbacks on the\n *      element, so that bindings are cleaned up when the element is disposed with ko.cleanNode()\n *      or ko.removeNode().\n */\nfunction makeBinding(valueOrFunc, updaterFunc) {\n  return function(elem) {\n    setBinding(elem, valueOrFunc, updaterFunc);\n  };\n}\nexports.makeBinding = makeBinding;\n\n/**\n * Keeps the text content of a DOM element in sync with an observable value.\n * Just like knockout's `text` binding.\n * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.\n */\nfunction text(valueOrFunc) {\n  return function(elem) {\n    // Since setting textContent property of an element removes all its other children, we insert\n    // a new text node, and change the content of that. However, we tie the binding to the parent\n    // elem, i.e. make it disposed along with elem, because text nodes don't get cleaned by\n    // ko.removeNode / ko.cleanNode.\n    var textNode = G.document.createTextNode(\"\");\n    setBinding(elem, valueOrFunc, function(elem, value) {\n      textNode.nodeValue = value;\n    });\n\n    elem.appendChild(textNode);\n  };\n}\nexports.text = text;\n\n// Used for replacing the static token span created by bootstrap tokenfield with the the same token\n// but with its text content tied to an observable.\n// To use bootstrapToken:\n// 1) Get the token to make a clone of and the observable desired.\n// 2) Create the new token by calling this function.\n// 3) Replace the original token with this newly created token in the DOM by doing\n// Ex:   var newToken = bootstrapToken(originalToken, observable);\n//       parentElement.replaceChild(originalToken, newToken);\n// TODO: Make templateToken optional. If not given, bind the observable to a manually created token.\nfunction bootstrapToken(templateToken, valueOrFunc) {\n  var clone = templateToken.cloneNode();\n  setBinding(clone, valueOrFunc, function(e, value) {\n    clone.textContent = value;\n  });\n  return clone;\n}\nexports.bootstrapToken = bootstrapToken;\n\n/**\n * Keeps the attribute `attrName` of a DOM element in sync with an observable value.\n * Just like knockout's `attr` binding. Removes the attribute when the value is null or undefined.\n * @param {String} attrName The name of the attribute to bind, e.g. 'href'.\n * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.\n */\nfunction attr(attrName, valueOrFunc) {\n  return makeBinding(valueOrFunc, function(elem, value) {\n    if (value === null || value === undefined) {\n      elem.removeAttribute(attrName);\n    } else {\n      elem.setAttribute(attrName, value);\n    }\n  });\n}\nexports.attr = attr;\n\n/**\n * Sets or removes a boolean attribute of a DOM element. According to the spec, empty string is a\n * valid true value for the attribute, and the false value is indicated by the attribute's absence.\n * @param {String} attrName The name of the attribute to bind, e.g. 'href'.\n * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.\n */\nfunction boolAttr(attrName, valueOrFunc) {\n  return makeBinding(valueOrFunc, function(elem, value) {\n    if (!value) {\n      elem.removeAttribute(attrName);\n    } else {\n      elem.setAttribute(attrName, \"\");\n    }\n  });\n}\nexports.boolAttr = boolAttr;\n\n/**\n * Keeps the style property `property` of a DOM element in sync with an observable value.\n * Just like knockout's `style` binding.\n * @param {String} property The name of the style property to bind, e.g. 'fontWeight'.\n * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.\n */\nfunction style(property, valueOrFunc) {\n  return makeBinding(valueOrFunc, function(elem, value) {\n    // `style.setProperty` must be use to set custom property (ie: properties starting with '--').\n    // However since it does not support camelCase property, we still need to use the other form\n    // `elem.style[prop] = val;` for other properties.\n    if (property.startsWith(\"--\")) {\n      elem.style.setProperty(property, value);\n    } else {\n      elem.style[property] = value;\n    }\n  });\n}\nexports.style = style;\n\n\n/**\n * Shows or hides the element depending on a boolean value. Note that the element must be visible\n * initially (i.e. unsetting style.display should show it).\n * @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed\n *      observable. The value is treated as a boolean.\n */\nfunction show(boolValueOrFunc) {\n  return makeBinding(boolValueOrFunc, function(elem, value) {\n    elem.style.display = value ? \"\" : \"none\";\n  });\n}\nexports.show = show;\n\n/**\n * The opposite of show, equivalent to show(function() { return !value(); }).\n * @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed\n *      observable. The value is treated as a boolean.\n */\nfunction hide(boolValueOrFunc) {\n  return makeBinding(boolValueOrFunc, function(elem, value) {\n    elem.style.display = value ? \"none\" : \"\";\n  });\n}\nexports.hide = hide;\n\n/**\n * Associates some data with the DOM element, using ko.utils.domData.\n */\nfunction domData(key, valueOrFunc) {\n  return makeBinding(valueOrFunc, function(elem, value) {\n    ko.utils.domData.set(elem, key, value);\n  });\n}\nexports.domData = domData;\n\n/**\n * Keeps the value of the given DOM form element in sync with an observable value.\n * Just like knockout's `value` binding, except that it is one-directional (for now).\n */\nfunction value(valueOrFunc) {\n  return makeBinding(valueOrFunc, function(elem, value) {\n    // This conditional shouldn't be necessary, but on Electron 1.7,\n    // setting unchanged value cause cursor to jump\n    if (elem.value !== value) { elem.value = value; }\n  });\n}\nexports.value = value;\n\n/**\n * Toggles a css class `className` according to the truthiness of an observable value.\n * Similar to knockout's `css` binding with a static class.\n * @param {String} className The name of the class to toggle.\n * @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed\n *      observable. The value is treated as a boolean.\n */\nfunction toggleClass(className, boolValueOrFunc) {\n  return makeBinding(boolValueOrFunc, function(elem, value) {\n    elem.classList.toggle(className, !!value);\n  });\n}\nexports.toggleClass = toggleClass;\n\n\n/**\n * Toggles the `disabled` attribute on when boolValueOrFunc evaluates true. When\n * it evaluates false, the attribute is removed.\n * @param  {[type]} boolValueOrFunc boolValueOrFunc An observable, a constant, or a function for a computed\n *                                  observable. The value is treated as a boolean.\n */\nfunction toggleDisabled(boolValueOrFunc) {\n  return makeBinding(boolValueOrFunc, function(elem, disabled) {\n    if (disabled) {\n      elem.setAttribute(\"disabled\", \"disabled\");\n    } else {\n      elem.removeAttribute(\"disabled\");\n    }\n  });\n}\nexports.toggleDisabled = toggleDisabled;\n\n/**\n * Adds a css class (one or many) named by an observable value. If the value changes, the previous class will be\n * removed and the new one added. The value may be empty to avoid adding any class.\n * Similar to knockout's `css` binding with a dynamic class.\n * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.\n */\nfunction cssClass(valueOrFunc) {\n  var prevClass;\n  return makeBinding(valueOrFunc, function(elem, value) {\n    if (prevClass) {\n      for(const name of prevClass.split(\" \")) {\n        elem.classList.remove(name);\n      }\n    }\n    prevClass = value;\n    if (value) {\n      for (const name of value.split(\" \")) {\n        elem.classList.add(name);\n      }\n    }\n  });\n}\nexports.cssClass = cssClass;\n\n/**\n * Scrolls a child element into view. The value should be the index of the child element to\n * consider. This function supports scrolly, and is mainly useful for scrollable container\n * elements, with a `foreach` or a `scrolly` inside.\n * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable\n *    whose value is the index of the child element to keep scrolled into view.\n */\nfunction scrollChildIntoView(valueOrFunc) {\n  return makeBinding(valueOrFunc, doScrollChildIntoView);\n}\n// Key at which we will store the index to scroll for async scrolling.\nconst indexKey = Symbol();\nfunction doScrollChildIntoView(elem, index, sync) {\n  if (index === null) {\n    return Promise.resolve();\n  }\n  const scrolly = ko.utils.domData.get(elem, \"scrolly\");\n  if (scrolly) {\n    if (sync) {\n      scrolly.scrollRowIntoView(index);\n      // Clear async index for scrolling.\n      elem[indexKey] = null;\n      return Promise.resolve();\n    } else {\n      // Delay this in case it's triggered while other changes are processed (e.g. splices).\n\n      // Scrolling is asynchronous, so in case there is already\n      // active scroll queued, we will change the target index.\n      // For example:\n      // doScrollChildIntoView(el, 10, false) # sets the index to 10 and queues a Promise1\n      // doScrollChildIntoView(el, 20, false) # updates index to 20 and queues a Promise2\n      // ....\n      // Promise1 moves to 20, and clears the index.\n      // Promise2 checks the index is null and just returns.\n      elem[indexKey] = index;\n      return new Promise((resolve, reject) => {\n        setTimeout(() => {\n          try {\n            // If scroll was cancelled (there was another call after, that finished\n            // and cleared the index) return.\n            if (elem[indexKey] === null) {\n              resolve();\n              return;\n            }\n            if (!scrolly.isDisposed()) {\n              scrolly.scrollRowIntoView(elem[indexKey]);\n            }\n            resolve();\n          } catch(err) {\n            reject(err);\n          } finally {\n            // Clear the index, any subsequent async scrolls will be cancelled (on the if test above).\n            elem[indexKey] = null;\n          }\n        }, 0);\n      });\n    }\n  } else {\n    const child = elem.children[index];\n    if (child) {\n      if (index === 0) {\n        // Scroll the container all the way if showing the first child.\n        elem.scrollTop = 0;\n      }\n      const childRect = child.getBoundingClientRect();\n      const parentRect = elem.getBoundingClientRect();\n      if (childRect.top < parentRect.top) {\n        child.scrollIntoView(true);             // Align with top if scrolling up..\n      } else if (childRect.bottom > parentRect.bottom) {\n        child.scrollIntoView(false);              // ..bottom if scrolling down.\n      }\n    }\n  }\n  return Promise.resolve();\n}\nexports.scrollChildIntoView = scrollChildIntoView;\nexports.doScrollChildIntoView = doScrollChildIntoView;\n\n\n/**\n * Adds to a DOM element the content returned by `contentFunc` called with the value of the given\n * observable. The content may be a Node, an array of Nodes, text, null or undefined.\n * Similar to knockout's `with` binding.\n * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.\n * @param {Function} contentFunc Called with the value of `valueOrFunc` whenever that value\n *    changes. The returned content will replace previous content among the children of the bound\n *    DOM element in the place where the scope() call was present among arguments to dom().\n */\nfunction scope(valueOrFunc, contentFunc) {\n  var marker, contentNodes = [];\n  return makeBinding(valueOrFunc, function(elem, value) {\n    // We keep a comment marker, so that we know where to insert the content, and numChildren, so\n    // that we know how many children are part of that content.\n    if (!marker) {\n      marker = elem.appendChild(G.document.createComment(\"\"));\n    }\n\n    // Create the new content before destroying the old, so that it is OK for the new content to\n    // include the old (by reattaching inside the new content). If we did it after, the old\n    // content would get destroyed before it gets moved. (Note that \"destroyed\" here means\n    // clearing associated bindings and event handlers, so it's not easily visible.)\n    var content = dom.frag(contentFunc(value));\n\n    // Remove any children added last time, cleaning associated data.\n    for (var i = 0; i < contentNodes.length; i++) {\n      if (contentNodes[i].parentNode === elem) {\n        ko.removeNode(contentNodes[i]);\n      }\n    }\n    contentNodes.length = 0;\n    var next = marker.nextSibling;\n    elem.insertBefore(content, next);\n    // Any number of children may have gotten added if content was a DocumentFragment.\n    for (var n = marker.nextSibling; n !== next; n = n.nextSibling) {\n      contentNodes.push(n);\n    }\n  });\n}\nexports.scope = scope;\n\n\n/**\n * Conditionally adds to a DOM element the content returned by `contentFunc()` depending on the\n * boolean value of the given observable. The content may be a Node, an array of Nodes, text, null\n * or undefined.\n * Similar to knockout's `if` binding.\n * @param {Object} boolValueOrFunc An observable, a constant, or a function for a computed\n *    observable. The value is checked as a boolean, and passed to the content function.\n * @param {Function} contentFunc A function called with the value of `boolValueOrFunc` whenever\n *    the observable changes from false to true. The returned content is added to the bound DOM\n *    element in the place where the maybe() call was present among arguments to dom().\n */\nfunction maybe(boolValueOrFunc, contentFunc) {\n  return scope(boolValueOrFunc, function(yesNo) {\n    return yesNo ? contentFunc(yesNo) : null;\n  });\n}\nexports.maybe = maybe;\n\n\n/**\n * Observes an observable array (koArray), and creates and adds as many children to the bound DOM\n * element as there are items in it. As the array is changed, children are added or removed. Also\n * works for a plain data array, creating a static list of children.\n *\n * Elements are typically added and removed by splicing data into or out of the data koArray. When\n * an item is removed, the corresponding node is removed using ko.removeNode (which also runs any\n * disposal tied to the node). If the caller retains a reference to a Node item, and removes it\n * from its parent, foreach will cope with it fine, but will not call ko.removeNode on that node\n * when the item from which it came is spliced out.\n *\n * @param {koArray} data An koArray instance.\n * @param {Function} itemCreateFunc A function called as `itemCreateFunc(item)` for each\n *    array element. Note: `index` is not passed to itemCreateFunc as it is only correct at the\n *    time the item is created, and does not reflect further changes to the array.\n */\nfunction foreach(data, itemCreateFunc) {\n  var marker;\n  var children = [];\n  return function(elem) {\n    if (!marker) {\n      marker = elem.appendChild(G.document.createComment(\"\"));\n    }\n    var spliceFunc = function(splice) {\n      var i, start = splice.start;\n\n      // Remove the elements that are gone.\n      var deletedElems = children.splice(start, splice.deleted.length);\n      for (i = 0; i < deletedElems.length; i++) {\n        // Some nodes may be null, or may have been removed elsewhere in the program. The latter\n        // are no longer our responsibility, and we should not clean them up.\n        if (deletedElems[i] && deletedElems[i].parentNode === elem) {\n          ko.removeNode(deletedElems[i]);\n        }\n      }\n\n      if (splice.added > 0) {\n        // Create and insert new elements.\n        var frag = G.document.createDocumentFragment();\n        var spliceArgs = [start, 0];\n        for (i = 0; i < splice.added; i++) {\n          var itemModel = splice.array[start + i];\n          var insertEl = itemCreateFunc(itemModel);\n          if (insertEl) {\n            ko.utils.domData.set(insertEl, \"itemModel\", itemModel);\n            frag.appendChild(insertEl);\n          }\n          spliceArgs.push(insertEl);\n        }\n\n        // Add new elements to the children array we maintain.\n        Array.prototype.splice.apply(children, spliceArgs);\n\n        // Find a valid child immediately preceding the start of the splice, for DOM insertion.\n        var baseElem = marker;\n        for (i = start - 1; i >= 0; i--) {\n          if (children[i] && children[i].parentNode === elem) {\n            baseElem = children[i];\n            break;\n          }\n        }\n        elem.insertBefore(frag, baseElem.nextSibling);\n      }\n    };\n\n    var array = data;\n    if (koArray.isKoArray(data)) {\n      var subscription = data.subscribe(spliceFunc, null, \"spliceChange\");\n      ko.utils.domNodeDisposal.addDisposeCallback(elem, function() {\n        subscription.dispose();\n      });\n\n      array = data.all();\n    } else if (!Array.isArray(data)) {\n      throw new Error(\"koDom.foreach applied to non-array: \" + data);\n    }\n    spliceFunc({ array: array, start: 0, added: array.length, deleted: [] });\n  };\n}\nexports.foreach = foreach;\n"
  },
  {
    "path": "app/client/lib/koDomScrolly.css",
    "content": ".scrolly_outer {\n  position: relative; /* Forces absolutely-positiong scrolly-div to be within scrolly outer*/\n}\n"
  },
  {
    "path": "app/client/lib/koDomScrolly.js",
    "content": "/**\n * Scrolly is a class that allows scrolling a very long list of rows by rendering only those\n * that are visible. Note that the elements rendered by scrolly should have box-sizing set to\n * border-box.\n */\n\n\n\nvar _ = require(\"underscore\");\nvar ko = require(\"knockout\");\nvar assert = require(\"assert\");\nvar gutil = require(\"app/common/gutil\");\nvar BinaryIndexedTree = require(\"app/common/BinaryIndexedTree\");\nvar {Delay} = require(\"./Delay\");\nvar dispose = require(\"./dispose\");\nvar kd = require(\"./koDom\");\nvar dom = require(\"./dom\");\n\n/**\n * Use the browser globals in a way that allows replacing them with mocks in tests.\n */\nvar G = require(\"./browserGlobals\").get(\"window\", \"$\");\n\n/**\n * Scrolly may contain multiple panes scrolling in parallel (e.g. for row numbers). The UI for\n * each pane consists of two nested pieces: a scrollDiv and a blockDiv. The scrollDiv is very tall\n * and mostly empty; the blockDiv contains the actual rendered rows, and is absolutely positioned\n * inside its scrollDiv.\n */\nfunction ScrollyPane(scrolly, paneIndex, container, options, itemCreateFunc) {\n  this.scrolly = scrolly;\n  this.paneIndex = paneIndex;\n  this.container = container;\n  this.itemCreateFunc = itemCreateFunc;\n  this.preparedRows = [];\n\n  _.extend(this.scrolly.options, options);\n\n  this.container.appendChild(\n    this.scrollDiv = dom(\n      \"div.scrolly_outer\",\n      kd.style(\"height\", this.scrolly.totalHeightPx),\n      this.blockDiv = dom(\n        \"div\",\n        kd.style(\"position\", \"absolute\"),\n        kd.style(\"top\", this.scrolly.blockTopPx),\n        kd.style(\"width\", options.fitToWidth ? \"100%\" : \"\"),\n        kd.style(\"padding-right\", options.paddingRight + \"px\")\n      )\n    )\n  );\n\n  ko.utils.domNodeDisposal.addDisposeCallback(container, () => {\n    this.scrolly.destroyPane(this);\n    // Delete all members, to break cycles.\n    for (var k in this) {\n      delete this[k];\n    }\n  });\n\n  G.$(this.container).on(\"scroll\", () => this.scrolly.onScroll(this) );\n}\n\n/**\n * Prepares the DOM for rows in scrolly's [begin, end) range, reusing currently active rows as\n * much as possible. New rows are saved in this.preparedRows, and also added to the end of\n * blockDiv so that they may be measured.\n */\nScrollyPane.prototype.prepareNewRows = function() {\n  var i, item, row,\n    begin = this.scrolly.begin,\n    count = this.scrolly.end - begin,\n    array = this.scrolly.data.peek(),\n    prevItemModels = this.scrolly.activeItemModels,\n    prevRows = this.preparedRows;\n\n  if (prevRows.length > 0) {\n    // Skip this check if there are no rows, maybe we just added this pane.\n    assert.equal(prevRows.length, prevItemModels.length,\n      \"Rows and models not in sync: \" + prevRows.length + \"!=\" + prevItemModels.length);\n  }\n\n  this.preparedRows = [];\n\n  // Reuse any reusable old rows. They must be tied to an active model.\n  for (i = 0; i < prevRows.length; i++) {\n    row = prevRows[i];\n    item = prevItemModels[i];\n    if (item._index() === null) {\n      ko.removeNode(row);\n    } else {\n      var relIndex = item._index() - begin;\n      assert(relIndex >= 0 && relIndex < count, \"prepareNewRows saw out-of-range model\");\n      this.preparedRows[relIndex] = row;\n    }\n  }\n\n  // Create any missing rows.\n  for (i = 0; i < count; i++) {\n    if (!this.preparedRows[i]) {\n      item = array[begin + i];\n      assert(item, \"ScrollyPane item missing at index \" + (begin + i));\n      item._rowHeightPx(\"\");    // Mark this row as in need of measuring.\n      row = this.itemCreateFunc(item);\n      kd.style(\"height\", item._rowHeightPx)(row);\n      ko.utils.domData.set(row, \"itemModel\", item);\n      this.preparedRows[i] = row;\n      // The row may not end up at the end of blockDiv, but we need to add it to the document in\n      // order to measure it. We'll move it to the right place in arrangePreparedRows().\n      this.blockDiv.appendChild(row);\n    }\n  }\n};\n\n/**\n * Returns the measured height of the given prepared row.\n */\nScrollyPane.prototype.measurePreparedRow = function(rowIndex) {\n  var row = this.preparedRows[rowIndex];\n  var rect = row.getBoundingClientRect();\n  return rect.bottom - rect.top;\n};\n\n/**\n * Update the DOM with the prepared rows in the correct order.\n */\nScrollyPane.prototype.arrangePreparedRows = function() {\n  // Note that everything that was in blockDiv previously is now either gone or is in\n  // preparedRows. So placing all preparedRows into blockDiv automatically removes them from their\n  // old positions.\n  //\n  // For a slight speedup in rendering, we try to avoid removing and reinserting rows\n  // unnecessarily, as that slows down subsequent rendering. We could try harder, by finding the\n  // longest common subsequence, but that's quite a bit harder.\n  for (var i = 0; i < this.preparedRows.length; i++) {\n    var row = this.preparedRows[i];\n    var current = this.blockDiv.childNodes[i];\n    if (row !== current) {\n      this.blockDiv.insertBefore(row, current);\n    }\n  }\n};\n\n//----------------------------------------------------------------------\n\n/**\n * The Scrolly class is used internally to manage the state of the scrolly. It keeps track of the\n * data items being rendered, of the heights of all rows (including cumulative heights, in a\n * BinaryIndexedTree), and various other counts and positions.\n *\n * The actual DOM elements are managed by ScrollyPane class. There may be more than one instance,\n * if there are multiple panes scrolling together (e.g. for row numbers).\n */\nfunction Scrolly(dataModel) {\n  // In the constructor we only initialize the parts shared by all ScrollyPanes.\n  this.data = dataModel;\n  this.numRows = 0;\n  this.options = {\n    paddingBottom: 0\n  };\n\n  this.panes = [];\n\n  // The items currently rendered. Same as this.data._itemModels, but we manage it manually\n  // to maintain the invariant that rendered DOM elements match this.activeItemModels.\n  this.activeItemModels = [];\n\n  // Data structure to store row heights and cumulative offsets of all rows.\n  this.rowHeights = [];\n  this.rowOffsetTree = new BinaryIndexedTree();\n  // TODO: Reconsider row height for rendering layouts / other tall elements in a scrolly.\n  this.minRowHeight = 23;   // In pixels. Rows will be forced to be at least this tall.\n\n  this.numBuffered = 1;     // How many rows to render outside the visible area.\n  this.numRendered = 1;     // Total rows to render.\n\n  this.begin = 0;       // Index of the first rendered row\n  this.end = 0;         // Index of the row after the last rendered one\n\n  this.scrollTop = 0;   // The scrollTop position of all panes.\n  this.shownHeight = 0; // The clientHeight of all panes.\n  this.blockBottom = 0; // Bottom of the rendered block, i.e. rowOffsetTree.getSumTo(this.end)\n\n  // Top in px of the rendered block; rowOffsetTree.getSumTo(this.begin)\n  this.blockTop = ko.observable(0);\n  this.blockTopPx = ko.computed(function() { return this.blockTop() + \"px\"; }, this);\n\n  // The height of the scrolly_outer div\n  this.totalHeight = ko.observable(0);\n  this.totalHeightPx = ko.computed(function() { return this.totalHeight() + \"px\"; }, this);\n\n  // Subscribe to data changes, and initialize with the current data.\n  this.subscription = this.autoDispose(\n    this.data.subscribe(this.onDataSplice, this, \"spliceChange\"));\n\n  // The delayedUpdateSize helper is used by scheduleUpdateSize.\n  this.delayedUpdateSize = this.autoDispose(Delay.create());\n\n  // Initialize with the current data.\n  var array = this.data.all();\n  this.onDataSplice({ array: array, start: 0, added: array.length, deleted: [] });\n\n  //T198: Scrolly should have its own handler to remove, so that when removing handlers it does not\n  //remove other's handler.\n  let onResize = () => {\n    this.scheduleUpdateSize();\n  };\n\n  G.$(G.window).on(\"resize.scrolly\", onResize);\n\n  this.autoDisposeCallback(() => G.$(G.window).off(\"resize.scrolly\", onResize));\n\n}\nexports.Scrolly = Scrolly;\n\ndispose.makeDisposable(Scrolly);\n\n\nScrolly.prototype.debug = function() {\n  console.log(\"Scrolly: numRows \" + this.numRows + \"; panes \" + this.panes.length +\n              \"; numRendered \" + this.numRendered + \" [\" + this.begin + \", \" + this.end + \")\" +\n              \"; block at \" + this.blockTop() + \" of \" + this.totalHeight() +\n              \"; scrolled to \" + this.scrollTop + \"; shownHeight \" + this.shownHeight);\n  console.assert(this.numRows, this.data.peekLength,\n    \"Wrong numRows; data is \" + this.data.peekLength);\n  console.assert(this.numRows, this.rowHeights.length,\n    \"Wrong rowHeights size \" + this.rowHeights.length);\n  console.assert(this.numRows, this.rowOffsetTree.size(),\n    \"Wrong rowOffsetTree size \" + this.rowOffsetTree.size());\n  var count = Math.min(this.numRendered, this.numRows);\n  console.assert(this.end - this.begin, count,\n    \"Wrong range size \" + (this.end - this.begin));\n  console.assert(this.activeItemModels.length, count,\n    \"Wrong activeItemModels.size \" + this.activeItemModels.length);\n\n  var expectedHeight = this.blockBottom - this.blockTop();\n  if (count > 0) {\n    for (var p = 0; p < this.panes.length; p++) {\n      var topRow = this.panes[p].preparedRows[0].getBoundingClientRect();\n      var bottomRow = _.last(this.panes[p].preparedRows).getBoundingClientRect();\n      var blockHeight = bottomRow.bottom - topRow.top;\n      if (blockHeight !== expectedHeight) {\n        console.warn(\"Scrolly render pane #%d %dpx bigger from expected (%dpx per row). Ensure items have no margins\",\n          p, blockHeight - expectedHeight, (blockHeight - expectedHeight) / count);\n      }\n    }\n  }\n};\n\n/**\n * Helper that returns the Scrolly object currently associate with the given LazyArrayModel. It\n * feels a bit wrong that the model knows about its user, but a LazyArrayModel generally only\n * supports a single user (e.g. a single Scrolly), so it makes sense.\n */\nfunction getInstance(dataModel) {\n  if (!dataModel._scrollyObj) {\n    dataModel._scrollyObj = Scrolly.create(dataModel);\n    dataModel._scrollyObj.autoDisposeCallback(() => delete dataModel._scrollyObj);\n  }\n  return dataModel._scrollyObj;\n}\nexports.getInstance = getInstance;\n\n/**\n * Adds a new pane that scrolls as part of this Scrolly object. This call itself does no\n * rendering of the pane.\n */\nScrolly.prototype.addPane = function(containerElem, options, itemCreateFunc) {\n  var pane = new ScrollyPane(this, this.panes.length, containerElem, options, itemCreateFunc);\n  this.panes.push(pane);\n  this.scheduleUpdateSize();\n};\n\n/**\n * Tells Scrolly to call updateSize after things have had a chance to render.\n */\nScrolly.prototype.scheduleUpdateSize = function(overrideHeight, callback) {\n  if (callback) {\n    this.cb = callback;\n  }\n  if (!this.isDisposed() && !this.delayedUpdateSize.isPending()) {\n    this.delayedUpdateSize.schedule(0, this.updateSize.bind(this, overrideHeight), this);\n  }\n};\n\n/**\n * Measures the size of the panes and adjusts Scrolly parameters for how many rows to render.\n * This should be called as soon as all Scrolly panes have been attached to the Document, and any\n * time their outer size changes.\n * Pass in an overrideHeight to use instead of the current height of the panes.\n */\nScrolly.prototype.updateSize = function(overrideHeight) {\n  this.resetHeights();\n  this.shownHeight = Math.max(0, Math.max.apply(null, this.panes.map(function(pane) {\n    return pane.container.clientHeight;\n  })));\n\n  // Update counts of rows that are shown.\n  var numVisible = Math.max(1, Math.ceil((overrideHeight ?? this.shownHeight) / this.minRowHeight));\n  this.numBuffered = 5;\n  this.numRendered = numVisible + 2 * this.numBuffered;\n\n  // Re-render everything.\n  this._updateRange();\n  this.render();\n  this.syncScrollPosition();\n  this.cb?.(this.rowHeights);\n};\n\n/**\n * Called whenever any pane got scrolled. It syncs up all panes to the same scrollTop.\n */\nScrolly.prototype.onScroll = function(pane) {\n  this.scrollTo(pane.container.scrollTop);\n};\n\n/**\n * Actively scroll all panes to the given scrollTop position, adjusting what is rendered as\n * necessary.\n */\nScrolly.prototype.scrollTo = function(top) {\n  if (top === this.scrollTop) {\n    return;\n  }\n\n  this.scrollTop = top;\n  this.syncScrollPosition();\n\n  if (this.blockTop() <= top && this.blockBottom >= top + this.shownHeight) {\n    // Nothing needs to be re-rendered.\n    //console.log(\"scrollTo(%s): all elements already shown\", top);\n    return;\n  }\n\n  // If we are scrolled to the bottom, restore our bottom position at the end. This happens\n  // in particular when reloading a page scrolled to the bottom. This is in no way general; it's\n  // just particularly easy to come across.\n  var atEnd = (top + this.shownHeight >= this.panes[0].container.scrollHeight);\n\n  this._updateRange();\n  // Do the magic.\n  this.render();\n\n  // If we were scrolled to the bottom, stay that way.\n  if (atEnd) {\n    this.scrollTop = this.panes[0].container.scrollHeight - this.shownHeight;\n  }\n\n  // Sometimes render() affects scrollTop of some panes; restore it to what we want by always\n  // calling syncScrollPosition() once more after render.\n  this.syncScrollPosition();\n};\n\n/**\n * Called when the underlying data array changes.\n */\nScrolly.prototype.onDataSplice = function(splice) {\n  // We may need to adjust which rows are shown, but render does all the work of figuring out what\n  // changed and needs re-rendering.\n  this.numRows = this.data.peekLength;\n\n  // Update rowHeights: reproduce the splice, inserting minRowHeights for the new rows.\n  this.rowHeights.splice(splice.start, splice.deleted.length);\n  gutil.arraySplice(this.rowHeights, splice.start,\n    gutil.arrayRepeat(splice.added, this.minRowHeight));\n\n  // And rebuild the rowOffsetTree.\n  this.rowOffsetTree.fillFromValues(this.rowHeights);\n  this.totalHeight(this.rowOffsetTree.getTotal() + this.options.paddingBottom);\n\n  this._updateRange();\n\n  this.scheduleUpdateSize();\n};\n\n/**\n * Set all panes to the common scroll position.\n */\nScrolly.prototype.syncScrollPosition = function() {\n  // Note that setting scrollTop triggers more scroll events, but those get ignored in onScroll\n  // because top === this.scrollTop.\n  var top = this.scrollTop;\n  for (var p = 0; p < this.panes.length; p++) {\n    // Reading .scrollTop may cause a synchronous reflow, so may be worse than setting it.\n    this.panes[p].container.scrollTop = top;\n  }\n};\n\n/**\n * Creates a new item model. There is one for each rendered row. This uses the lazyArray to create\n * the model, but adds a _rowHeightPx observable, used for controlling the row height.\n */\nScrolly.prototype.createItemModel = function() {\n  var item = this.data.makeItemModel();\n  item._rowHeightPx = ko.observable(\"\");\n  return item;\n};\n\n/**\n * Render rows in [begin, end) range, reusing any currently rendered rows as much as possible.\n */\nScrolly.prototype.render = function() {\n  //var startTime = Date.now();\n  // console.log(\"Scrolly render (top \" + this.scrollTop + \"): [\" + this.begin + \", \" +\n  //            this.end + \") = \" + (this.end - this.begin) + \" rows\");\n\n  // Invariant: all panes contain DOM elements parallel to this.activeItemModels.\n  // At the end, this.activeItemModels and DOM in panes represent the range [begin, end).\n  var i, p, item, index, delta,\n    count = this.end - this.begin,\n    array = this.data.peek(),\n    freeList = [];\n\n  assert(this.end <= array.length, \"Scrolly render() exceeds data length of \" + array.length);\n\n  // If scrolling up, we may adjust heights of rows, pushing down the row at scrollTop.\n  // If that happens, we will adjust scrollTop correspondingly.\n  var rowAtScrollTop = this.rowOffsetTree.getIndex(this.scrollTop);\n  var sumToScrollTop = this.rowOffsetTree.getSumTo(rowAtScrollTop);\n\n  // Place out-of-range itemModels into a free list.\n  for (i = 0; i < this.activeItemModels.length; i++) {\n    item = this.activeItemModels[i];\n    index = item._index();\n    if (index === null || index < this.begin || index >= this.end) {\n      freeList.push(item);\n    }\n  }\n\n  // Go through the models we need, and fill any missing ones.\n  for (i = 0, index = this.begin; i < count; i++, index++) {\n    if (!array[index]) {\n      // Use the freeList if possible, or create a new model otherwise.\n      item = freeList.shift() || this.createItemModel();\n      this.data.setItemModel(item, index);\n      // Unset the explicit height so that we can measure what it would naturally be.\n      item._rowHeightPx(\"\");\n    }\n  }\n\n  // Unset anything else in the free list.\n  for (i = 0; i < freeList.length; i++) {\n    this.data.unsetItemModel(freeList[i]);\n  }\n\n  // Prepare DOM in all panes. This ensures that there is a DOM element for each active item.\n  // If prepareNewRows creates new DOM, it will unset _rowHeightPx, to mark it for measuring.\n  for (p = 0; p < this.panes.length; p++) {\n    this.panes[p].prepareNewRows();\n  }\n\n  // Measure the rows, and use the max across panes to update the stored heights.\n  // Note: this involves a reflow.\n  for (i = 0, index = this.begin; i < count; i++, index++) {\n    item = array[index];\n    if (item._rowHeightPx.peek() === \"\") {\n      var height = this.minRowHeight;\n      for (p = 0; p < this.panes.length; p++) {\n        height = Math.max(height, this.panes[p].measurePreparedRow(i));\n      }\n      height = Math.round(height);\n\n      delta = height - this.rowHeights[index];\n      if (delta !== 0) {\n        this.rowHeights[index] = height;\n        this.rowOffsetTree.addValue(index, delta);\n      }\n    }\n  }\n\n  // Set back the explicit heights of the rows. This is separate from the loop above to make sure\n  // we don't trigger additional reflows while measuring rows.\n  for (i = 0, index = this.begin; i < count; i++, index++) {\n    item = array[index];\n    item._rowHeightPx(this.rowHeights[index] + \"px\");\n  }\n\n  // Render the new rows in the new order in each pane.\n  for (p = 0; p < this.panes.length; p++) {\n    this.panes[p].arrangePreparedRows();\n  }\n\n  // Save the current activeItemModels.\n  this.activeItemModels = array.slice(this.begin, this.end);\n  // console.log(\"activeItemModels now \" + this.activeItemModels.length);\n  // console.log(\"rows in panes now are \" + this.panes.map(\n  //             function(p) { return p.blockDiv.childNodes.length; }).join(\", \"));\n\n  // Update heights and positions of the scrolling pane parts.\n  this.totalHeight(this.rowOffsetTree.getTotal() + this.options.paddingBottom);\n  this.blockTop(this.rowOffsetTree.getSumTo(this.begin));\n  this.blockBottom = this.rowOffsetTree.getSumTo(this.end);\n\n  // Adjust scrollTop if previously-shown top moved because of newly-rendered rows above.\n  delta = this.rowOffsetTree.getSumTo(rowAtScrollTop) - sumToScrollTop;\n  if (delta !== 0) {\n    //console.log(\"Adjusting scroll position by \" + delta);\n    this.scrollTop += delta;\n    this.syncScrollPosition();\n  }\n\n  // this.debug();\n\n  // Report after timeout, to include the browser rendering time.\n  //var midTime = Date.now();\n  //setTimeout(function() {\n  //  var endTime = Date.now();\n  //  console.log(\"Scrolly render took \" + (midTime - startTime) + \" + \" +\n  //              (endTime - midTime) + \" = \" + (endTime - startTime) + \" ms\");\n  //}, 0);\n};\n\n\n/**\n * Re-measure the given array of rows. Re-measures all rows if no array is given.\n */\nScrolly.prototype.resetHeights = function(optRowIndexList) {\n  var array = this.data.peek();\n  if (optRowIndexList) {\n    for (var i = 0; i < optRowIndexList.length; i++) {\n      var index = optRowIndexList[i];\n      var item = array[index];\n      if (item) {\n        item._rowHeightPx(\"\");\n      }\n    }\n  } else {\n    this.activeItemModels.forEach(function(item) {\n      item._rowHeightPx(\"\");\n    });\n  }\n  this.render();\n};\n\n/**\n * Re-measure the given array of items.\n * @param {Array[ItemModel]} items: The affected models (as returned by this.createItemModel).\n */\nScrolly.prototype.resetItemHeights = function(items) {\n  if (!this.isDisposed()) {\n    items.forEach(item => item._rowHeightPx(\"\"));\n    this.render();\n  }\n};\n\n/**\n * Scrolls to the position in pixels returned by calcPosition() function. The argument is a\n * function because after the initial re-render, some rows may get re-measured and require\n * an adjustment to the pixel position. So calcPosition() actually gets called twice.\n */\nScrolly.prototype.scrollToPosition = function(calcPosition) {\n  var scrollTop = calcPosition();\n  this.scrollTo(scrollTop);\n\n  // Repeat in case rows got re-measured during rendering and ended up being below the fold.\n  // We only may need to scroll a bit further, we should never have to re-render.\n  scrollTop = calcPosition();\n  if (scrollTop !== this.scrollTop) {\n    this.scrollTop = scrollTop;\n    this.syncScrollPosition();\n  }\n};\n\n/**\n * Scrolls the given row into view.\n */\nScrolly.prototype.scrollRowIntoView = function(rowIndex) {\n  this.scrollToPosition(() => {\n    var top = this.rowOffsetTree.getSumTo(rowIndex);\n    var bottom = top + this.rowHeights[rowIndex];\n    // 43 = 23px to adjust for header, + 20px space\n    return gutil.clamp(this.scrollTop, bottom - this.shownHeight + 43, top - 10);\n  });\n};\n\n/**\n * Takes a scroll position object, as stored in the section model, and scrolls to the saved\n * position.\n * @param {Integer} scrollPos.rowIndex: The index of the row to be scrolled to.\n * @param {Integer} scrollPos.offset: The pixel distance of the scroll from the top of the row.\n */\nScrolly.prototype.scrollToSavedPos = function(scrollPos) {\n  this.scrollToPosition(() => this.rowOffsetTree.getSumTo(scrollPos.rowIndex) + scrollPos.offset);\n};\n\n\n/**\n * Returns an object with the index of the first visible row in the view pane, and the\n * scroll offset from the top of that row.\n * Useful for recording the current state of the scrolly for later re-initialization.\n *\n * NOTE: There is a compelling case to scroll to the cursor after scrolling to the previous\n * scroll position in either the case where rows are added/rearranged/removed, or simply in\n * all cases. While this would likely prevent confusion in case changes push the cursor out\n * of view, the case that the user scrolled away from the cursor intentionally should also be\n * considered.\n */\nScrolly.prototype.getScrollPos = function() {\n  var rowIndex = this.rowOffsetTree.getIndex(this.scrollTop);\n  return {\n    rowIndex: rowIndex,\n    offset: this.scrollTop - this.rowOffsetTree.getSumTo(rowIndex)\n  };\n};\n\n/**\n * Destroys a scrolly pane.\n */\nScrolly.prototype.destroyPane = function(pane) {\n  // When the last pane is removed, destroy the scrolly.\n  gutil.arrayRemove(this.panes, pane);\n  if (this.panes.length === 0) {\n    this.dispose();\n  }\n};\n\n/**\n * Updates indexes of rows to render.\n */\nScrolly.prototype._updateRange = function() {\n  // If we are scrolled from the top, start at the first visible row with some buffer.\n  const begin = this.rowOffsetTree.getIndex(this.scrollTop) - this.numBuffered;\n  this.begin = gutil.clamp(begin, 0, this.numRows - this.numRendered);\n  this.end = gutil.clamp(this.begin + this.numRendered, 0, this.numRows);\n};\n\n//----------------------------------------------------------------------\n\n/**\n * Creates a virtual scrolling interface attached to a LazyArray. Multiple scrolly() calls used\n * with the same `data` array will create parallel scrolling panes (e.g. row numbers and data\n * scrolling together).\n *\n * The DOM for items is created using `itemCreateFunc`. As the user scrolls\n * around, the item models are assigned to different items, and the DOM is moved around the page,\n * to minimize rendering. This is intended to be used with koModel.mappedLazyArray.\n *\n * @param {LazyModelArray} data A LazyModelArray instance.\n * @param {Object} options - Supported options include:\n *    paddingBottom {number} - Number of pixels to add to bottom of scrolly\n *    paddingRight {number} - Number of pixels to add to right of scrolly\n *    fitToWidth {bool} - Whether the scrolly holds a list of layouts\n * @param {Function} itemCreateFunc A function called as `itemCreateFunc(item)` for a number of\n *    item models (which can get assigned to different items in `data`). Must return a single\n *    Node (not a DocumentFragment or null).\n */\nfunction scrolly(data, options, itemCreateFunc) {\n  assert.equal(typeof itemCreateFunc, \"function\");\n  options = options || {};\n  return function(elem) {\n    var scrollyObj = getInstance(data);\n    scrollyObj.addPane(elem, options, itemCreateFunc);\n    ko.utils.domData.set(elem, \"scrolly\", scrollyObj);\n    scrollyObj.cb = options.cb;\n  };\n}\nexports.scrolly = scrolly;\n"
  },
  {
    "path": "app/client/lib/koForm.css",
    "content": ".kf_elem {\n  margin: 0.4rem 5%;\n}\n\n.kf_button_group {\n  border-radius: 4px;\n  overflow: hidden;\n  user-select: none;\n  border: 1px solid #e0e0e0;\n}\n\n.kf_button_group:hover {\n  border: 1px solid #d0d0d0;\n}\n\n.kf_button_group:active {\n  border: 1px solid #d0d0d0;\n}\n\n.kf_button_group.accent {\n  border: 1px solid #d8955a;\n}\n\n.kf_button_group.accent:hover {\n  border: 1px solid #c38045;\n}\n\n.kf_button_group.accent:active {\n  border: 1px solid #c38045;\n}\n\n.kf_button_group.lite {\n  border: none;\n}\n\n.kf_tooltip {\n  text-shadow: none;\n  position: absolute;\n  z-index: 10;\n  visibility: hidden;\n}\n\n.kf_tooltip_pointer {\n  width: 0;\n  height: 0;\n  margin: 0 auto;\n  border-left: 5px solid transparent;\n  border-right: 5px solid transparent;\n  border-bottom: 5px solid rgba(60, 60, 60, .9);\n}\n\n.kf_tooltip_content {\n  cursor: default;\n  white-space: nowrap;\n  min-width: 16px;\n  min-height: 16px;\n  padding: 4px;\n  background-color: rgba(60, 60, 60, .9);\n  text-align: center;\n  color: #dadada;\n  border-radius: 5px;\n}\n\ndiv:hover > .kf_tooltip {\n  visibility: visible;\n}\n\n.kf_tooltip_info_text {\n  border-bottom: 1px solid #888;\n  margin-bottom: 3px;\n}\n\n.kf_tooltip_info_text > div {\n  padding-bottom: 4px;\n}\n\n.kf_tooltip_button {\n  cursor: pointer;\n  display: inline-block;\n  font-size: 1.2rem;\n  margin: 2px 4px;\n}\n\n.kf_tooltip_button:hover {\n  color: #fff;\n}\n\n.kf_tooltip_button.disabled {\n  cursor: default;\n  color: #222;\n}\n\n.kf_prompt {\n  position: relative;\n  width: 95%;\n  margin: 5px auto 10px auto;\n}\n\n.kf_prompt_content {\n  position: relative;\n  white-space: nowrap;\n  width: 100%;\n  min-width: 16px;\n  min-height: 16px;\n  padding: 4px;\n  background-color: var(--grist-theme-popup-bg, white);\n  border-radius: 2px;\n  box-shadow: 0 1px 1px 1px rgba(0,0,0,0.15);\n  line-height: 1.1rem;\n  font-size: 1rem;\n  color: var(--grist-theme-prompt-fg, #606060);\n  z-index: 10;\n}\n\n.kf_prompt_pointer {\n  position: absolute;\n  top: -5px;\n  right: 20px;\n  width: 10px;\n  height: 10px;\n  transform: rotate(45deg);\n  box-shadow: 0 1px 1px 1px rgba(0,0,0,0.15);\n  z-index: 8;\n}\n\n.kf_prompt_pointer_overlap {\n  position: absolute;\n  top: -5px;\n  right: 20px;\n  width: 10px;\n  height: 10px;\n  background-color: var(--grist-theme-popup-bg, white);\n  transform: rotate(45deg);\n  z-index: 11;\n}\n\n.kf_prompt_content:focus {\n  outline: none;\n}\n\n.kf_draggable {\n  display: inline-block;\n}\n\n.kf_draggable--vertical {\n  display: block;\n}\n\n/* Style the handle as grabbable, or the draggable element itself (if there is no handle). */\n.ui-sortable-handle,\n.kf_draggable:not(:has(.ui-sortable-handle)) {\n  cursor: grab;\n}\n\n.kf_draggable.ui-sortable-helper {\n  cursor: grabbing;\n}\n\n.kf_draggable.disabled {\n  cursor: default;\n}\n\n.kf_draggable__item {\n  margin: .2rem .5rem;\n  padding: .2rem;\n  background-color: var(--color-list-item);\n}\n\n.kf_draggable__item:hover {\n  background-color: var(--color-list-item-hover);\n}\n\n.kf_draggable__placeholder--horizontal {\n  display: inline-block;\n  height: 1px;\n}\n\n.kf_draggable__placeholder--vertical {\n  display: block;\n  width: 1px;\n}\n\n.kf_drag_indicator {\n  display: inline-block;\n  color: #777777;\n}\n\n.kf_draggable__icon::before {\n  display: block;\n  background-color: var(--grist-theme-control-secondary-fg, var(--grist-color-slate));\n  content: ' ';\n  width: 14px;\n  height: 14px;\n  mask-size: contain;\n  mask-repeat: no-repeat;\n}\n\n.kf_draggable__icon.icon-dragdrop::before {\n  mask-image: var(--icon-DragDrop);\n}\n\n.kf_draggable__icon.icon-remove::before {\n  mask-image: var(--icon-Remove);\n}\n\n.kf_draggable_content {\n  display: inline-block;\n  margin-left: 2px;\n}\n\n.kf_draggable:hover .drag_delete {\n  display: block;\n}\n\n.drag_delete {\n  display: none;\n  float: right;\n  cursor: pointer;\n  font-size: 1.0rem;\n  margin: 2px 2px 0 0;\n  color: #777777;\n}\n\n.kf_button {\n  text-align: center;\n  margin-left: -1px;\n  border-left: 1px solid #ddd;\n  padding: 0.5rem 0.5rem;\n  height: 2.3rem;\n  line-height: 1.1rem;\n  font-size: 1rem;\n  font-weight: bold;\n  color: #606060;\n  cursor: default;\n  user-select: none;\n  -moz-user-select: none;\n  background: linear-gradient(to bottom, #fafafa 0%,#f0f0f0 100%);\n}\n\n.kf_button.accent {\n  background: linear-gradient(to bottom, #f4a74e 0%,#ff9a00 100%);\n  color: #ffffff;\n}\n.kf_button.accent:active:not(.disabled) {\n  background: linear-gradient(to bottom, #ff9a00 0%,#f4a74e 100%);\n  color: #ffffff;\n}\n.kf_button.accent.disabled, .kf_button.accent.disabled:active {\n  color: #A0A0A0;\n  background: linear-gradient(to top, #fafafa 0%,#f0f0f0 100%);\n}\n\n.kf_button.lite {\n  height: 1.8rem;\n  padding: 0.4rem 0.2rem;\n  border: none;\n  background: none;\n}\n\n.kf_button.lite:hover:not(.disabled) {\n  background: #ddd;\n  color: black;\n  box-shadow: none;\n}\n\n.kf_check_button.lite.active:not(.disabled) {\n  background: #ddd;\n  color: black;\n  box-shadow: none;\n}\n\n.kf_check_button.lite:active:not(.disabled),\n.kf_check_button.lite.active:active:not(.disabled) {\n  box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2);\n  background: #ddd;\n}\n\n.kf_button:first-child {\n  margin-left: 0;\n  border-left: none;\n  border-top-left-radius: 3px;\n  border-bottom-left-radius: 3px;\n}\n.kf_button:last-child {\n  border-right: none;\n  border-top-right-radius: 3px;\n  border-bottom-right-radius: 3px;\n}\n.kf_button:active:not(.disabled) {\n  background: linear-gradient(to bottom, #f0f0f0 0%,#fafafa 100%);\n  box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2);\n}\n.kf_button.active {\n  box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2);\n  background: linear-gradient(to bottom, #ff9a00 0%,#f4a74e 100%);\n  color: #ffffff;\n}\n.kf_button.active:active:not(.disabled) {\n  box-shadow: inset 0px 0px 3px 0px rgba(0,0,0,0.4);\n  background: linear-gradient(to bottom, #ff9a00 0%,#f4a74e 100%);\n}\n.kf_button.disabled, .kf_button.disabled:active {\n  color: #A0A0A0;\n}\n\n.kf_logo_button {\n  height: 34px;\n}\n\n.kf_btn_logo {\n  height: 25px;\n  width: 25px;\n  background-repeat: no-repeat;\n  background-size: contain;\n  background-position: center;\n  margin-right: 5px;\n}\n\n.kf_btn_text {\n  font-size: 1.1rem;\n  height: 1.1rem;\n  margin: 0.7rem;\n}\n\n.kf_check_button.disabled, .kf_check_button.disabled:active {\n  color: #A0A0A0;\n  background: linear-gradient(to bottom, #f4f4f4 0%,#e8e8e8 100%);\n  box-shadow: none;\n}\n\n.kf_checkbox_label {\n}\n\n.kf_checkbox {\n  width: 1.6rem;\n  height: 1.6rem;\n  margin: 0 0 0 0 !important;\n  vertical-align: middle;\n  position: relative;\n}\n\n.kf_checkbox:focus {\n  outline: none !important;\n}\n\n.kf_radio_label {\n  font-weight: normal;\n  font-size: 1.1rem;\n  margin: 0;\n}\n\n.kf_radio {\n  margin: 0 0.5rem !important;\n  outline: none !important;\n  vertical-align: middle;\n}\n\n/** spinner **/\n.kf_spinner {\n  position: absolute;\n  box-sizing: content-box;\n  width: 9px;\n  height: 17px;\n  right: 1px;\n  top: -1px;\n\n  color: #606060;\n  overflow: hidden;\n  margin-top: 3px;\n  padding: 1px;\n}\n\n.kf_spinner:hover {\n  background: linear-gradient(to bottom, rgba(255,255,255,1) 0%, rgba(252,252,252,1) 29%, rgba(239,239,239,1) 50%, rgba(232,232,232,1) 50%, rgba(242,242,242,1) 100%);\n  border: 1px solid grey;\n  border-radius: 6px;\n  padding: 0px;\n}\n\n.kf_spinner_half {\n  height: 9px;\n  overflow: hidden;\n}\n\n.kf_spinner_half:active:not(.disabled) {\n  background: linear-gradient(to bottom, rgba(147,180,242,1) 0%, rgba(135,168,233,1) 10%, rgba(115,149,218,1) 25%, rgba(115,150,224,1) 37%, rgba(115,153,230,1) 50%, rgba(86,134,219,1) 51%, rgba(130,174,235,1) 83%, rgba(151,194,243,1) 100%);\n}\n\n.kf_spinner_arrow {\n  width: 0px;\n  height: 0px;\n  border-left: 3px solid transparent;\n  border-right: 3px solid transparent;\n}\n.kf_spinner_arrow.up {\n  border-top: none;\n  border-bottom: 5px solid var(--grist-theme-numeric-spinner-fg, #606060);\n  margin: 2px auto;\n}\n.kf_spinner_arrow.down {\n  border-top: 5px solid var(--grist-theme-numeric-spinner-fg, #606060);\n  border-bottom: none;\n  margin: 1px auto 2px auto;\n}\n\n.kf_collapser {\n  height: 2.2rem;\n  font-size: 1.1rem;\n  white-space: nowrap;\n  cursor: default;\n  user-select: none;\n  -moz-user-select: none;\n  margin: .5rem;\n}\n\n.kf_triangle_toggle {\n  display: inline-block;\n  font-size: .9rem;\n  width: 1.5rem;\n  color: #808080;\n}\n\n.kf_triangle_toggle:active {\n  color: #606060;\n}\n\n.kf_label {\n  color: var(--grist-theme-text, unset);\n  white-space: nowrap;\n  font-size: 1.1rem;\n  cursor: default;\n}\n\n.kf_light_label {\n  font-size: 1.0rem;\n  white-space: nowrap;\n}\n\n.kf_text {\n  width: 100%;\n}\n\n.kf_text:focus {\n  outline: none;\n  border: 2px solid #ff9a00;\n  box-shadow: inset 0px 0px 1px 0px rgba(0,0,0,0.2);\n}\n\n.kf_text:disabled {\n  color: #888;\n}\n\n/****/\n.kf_num_text {\n  display: block;\n  width: 100%;\n  text-align: right;\n}\n\n.kf_row {\n  margin: 0.4rem 2.5%;\n  align-items: center;\n  -webkit-align-items: center;\n}\n\n.kf_row > .kf_elem {\n  margin: 0 2.5%;\n}\n\n.kf_elem > .kf_elem {\n  margin: 0;\n}\n\n.kf_help_row {\n  margin-top: -0.2rem;\n  text-align: center;\n  font-size: 1.1rem;\n}\n\n.kf_help {\n  font-weight: normal;\n  font-size: 1.1rem;\n}\n\n.kf_left {\n  text-align: left;\n}\n\n.kf_right {\n  text-align: right;\n}\n\nfieldset:disabled {\n  color: #A0A0A0;\n}\n\n.kf_status_panel {\n  padding:0.5rem;\n  box-shadow:0 1px 2px #aaa;\n  background: white;\n  margin:0 0.5rem 0.5rem;\n  border-radius:3px;\n  overflow:hidden;\n}\n\n.kf_status_indicator {\n  border-right: 1px black;\n  font-size: 4rem;\n  flex-grow: 0;\n  -moz-user-select: none;\n  -webkit-user-select: none;\n  -ms-user-select: none;\n  user-select:none;\n}\n\n.kf_status_detail {\n  align-self: center;\n}\n\n.kf_status_indicator.kf_status_success {\n  color: forestgreen;\n}\n.kf_status_indicator.kf_status_info {\n  color: royalblue;\n}\n.kf_status_indicator.kf_status_warning {\n  color: orange;\n}\n.kf_status_indicator.kf_status_error {\n  color: firebrick;\n}\n\n.kf_scroll_shadow_outer {\n  height: 0px;\n  position: relative;\n}\n\n.kf_scroll_shadow {\n  position: absolute;\n  bottom: 0;\n  width: 100%;\n  height: 9px;\n  border-bottom: 1px solid #A0A0A0;\n  box-shadow: 0px 6px 3px -3px #A0A0A0;\n  z-index: 100;\n}\n\n.kf_scrollable {\n  overflow-x: hidden;\n  overflow-y: auto;\n}\n\n/* Based on scrollbox CSS detailed by Roman Komarov - http://kizu.ru/en/fun/shadowscroll/ */\n.scrollbox {\n  position: relative;\n  z-index: 1;\n  overflow: auto;\n  max-height: 200px;\n  background: #FFF no-repeat;\n  background-image:\n      radial-gradient(farthest-side at 50% 0, rgba(0,0,0,0.2), rgba(0,0,0,0)),\n      radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,0.2), rgba(0,0,0,0));\n  background-position: 0 0, 0 100%;\n  background-size: 100% 14px;\n}\n\n.scrollbox:before,\n.scrollbox:after {\n  content: \"\";\n  position: relative;\n  z-index: -1;\n  display: block;\n  height: 30px;\n  margin: 0 0 -30px;\n  background:   linear-gradient(to bottom,#FFF,#FFF 30%,rgba(255,255,255,0));\n}\n\n.scrollbox:after {\n  margin: -30px 0 0;\n  background:   linear-gradient(to bottom,rgba(255,255,255,0),#FFF 70%,#FFF);\n}\n\n.kf_select {\n  width: 100%;\n  height: 2.5rem;\n  border: 1px solid #e0e0e0;\n  padding: 0.5rem 0.5rem;\n  line-height: 1.1rem;\n  font-size: 1rem;\n  font-weight: bold;\n  color: #606060;\n  cursor: default;\n  border-radius: 4px;\n  background-image: none;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  appearance: none;\n  background: linear-gradient(to bottom, #fafafa 0%,#f0f0f0 100%);\n}\n\n.kf_select:hover {\n  border: 1px solid #d0d0d0;\n}\n\n.kf_select:active {\n  box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2);\n  border: 1px solid #d0d0d0;\n}\n\n.kf_select:focus {\n  outline: none;\n}\n\n.kf_select:-moz-focusring {\n  color: transparent;\n  text-shadow: 0 0 0 #000;\n}\n\n.kf_select:disabled {\n  color: #A0A0A0;\n}\n\n.kf_select_arrow:after {\n  content: '\\25bc';\n  margin-left: -1.4rem;\n  font-size: .7rem;\n  pointer-events:none;\n}\n\n.kf_separator {\n  color: #C8C8C8;\n  background-color: #C8C8C8;\n  border: 0;\n  height: 1px;\n  width: 100%;\n  margin: 1rem 0;\n}\n\n/*****************************************/\n/* CSS for midTabs and midTab functions */\n.kf_mid_tabs {\n  height: 100%;\n  position: relative;\n}\n\n.kf_mid_tab_labels {\n  padding: 0 4rem;\n}\n\n.kf_mid_tab_label {\n  margin-left: -1px;\n  border-left: 1px solid #e4e4e4;\n  text-align: center;\n  padding: 0.5rem 0.5rem;\n  font-size: 1.3rem;\n  font-weight: bold;\n  color: #bfbfbf;\n  cursor: pointer;\n  user-select: none;\n  -moz-user-select: none;\n  z-index: 1;\n}\n.kf_mid_tab_label:first-child {\n  border-left: none;\n}\n\n.kf_mid_tab_label:active, .kf_mid_tab_label.active:active {\n  color: black;\n}\n\n.kf_mid_tab_label.active {\n  color: black;\n  cursor: default;\n}\n\n.kf_mid_tab_content {\n  padding-top: 1rem;\n}\n\n/*****************************************/\n/* CSS for topTabs and topTab functions. */\n.kf_top_tabs {\n  height: 100%;\n}\n\n.kf_top_tab_labels {\n}\n\n.kf_top_tab_label {\n  margin-left: -1px;\n  border: 1px solid #C8C8C8;\n  text-align: center;\n  padding: 0.5rem 0.5rem;\n  font-weight: bold;\n  font-size: 1.1rem;\n  color: #606060;\n  cursor: default;\n  user-select: none;\n  -moz-user-select: none;\n  border-radius: 5px 5px 0 0;\n  background: #eee;\n}\n\n.kf_top_tab_label.active {\n  background: none;\n  border-bottom: none;\n  z-index: 10;\n}\n\n.kf_top_tab_label.active:active {\n  background: linear-gradient(to bottom, rgba(65,141,225,1) 0%,rgba(38,125,200,1) 100%);\n}\n\n.kf_top_tab_container {\n  height: 100%;\n  position: relative;\n}\n\n.kf_top_tab_content {\n  height: 100%;\n  padding-top: 1rem;\n  width: 100%;\n  position: relative;\n}\n\n.kf_drag_container {\n  margin: 0;\n  padding: 0;\n  list-style: none;\n}\n\n.kf_drag_container.ui-sortable {\n  overflow: auto;\n}\n"
  },
  {
    "path": "app/client/lib/koForm.js",
    "content": "/**\n * koForm provides a number of styled elements (buttons, checkbox, etc) that are tied to\n * observables to simplify and standardize the way we construct UI elements (e.g. forms).\n *\n * TODO: There is some divergence in class names that we use throughout Grist. For example,\n *    active vs mod-active and disabled vs mod-disabled. We should standardize.\n */\n\n// Use the browser globals in a way that allows replacing them with mocks in tests.\nvar G = require(\"./browserGlobals\").get(\"$\", \"window\", \"document\");\n\nconst identity = require(\"lodash/identity\");\nconst defaults = require(\"lodash/defaults\");\nconst debounce = require(\"lodash/debounce\");\nconst pick     = require(\"lodash/pick\");\nvar ko      = require(\"knockout\");\nvar Promise = require(\"bluebird\");\n\nvar gutil = require(\"app/common/gutil\");\n\nvar dom      = require(\"./dom\");\nvar kd       = require(\"./koDom\");\nvar koArray  = require(\"./koArray\");\n\nvar modelUtil = require(\"../models/modelUtil\");\n\nvar setSaveValue = modelUtil.setSaveValue;\n\n\n/**\n * Creates a button-looking div inside a buttonGroup; when clicked, clickFunc() will be called.\n * The button is not clickable if it contains the class 'disabled'.\n */\nexports.button = function(clickFunc, ...moreContentArgs) {\n  return dom(\"div.kf_button.flexitem\",\n    dom.on(\"click\", function() {\n      if (!this.classList.contains(\"disabled\")) {\n        clickFunc();\n      }\n    }),\n    moreContentArgs\n  );\n};\n\n/**\n * Creates a button with an accented appearance.\n * The button is not clickable if it contains the class 'disabled'.\n */\nexports.accentButton = function(clickFunc, ...moreContentArgs) {\n  return this.button(clickFunc,\n    {\"class\": \"kf_button flexitem accent\"},\n    moreContentArgs\n  );\n};\n\n/**\n * Creates a button with a minimal appearance for use in prompts.\n * The button is not clickable if it contains the class 'disabled'.\n */\nexports.liteButton = function(clickFunc, ...moreContentArgs) {\n  return this.button(clickFunc,\n    {\"class\": \"kf_button flexitem lite\"},\n    moreContentArgs\n  );\n};\n\n/**\n * Creates a bigger button with a logo, used for \"sign in with google/github/etc\" buttons.\n * The button is not clickable if it contains the class 'disabled'.\n */\nexports.logoButton = function(clickFunc, logoUrl, text, ...moreContentArgs) {\n  return this.button(clickFunc,\n    {\"class\": \"kf_button kf_logo_button flexitem flexhbox\"},\n    dom(\"div.kf_btn_logo\", { style: `background-image: url(${logoUrl})` }),\n    dom(\"div.kf_btn_text\", text),\n    moreContentArgs\n  );\n};\n\n/**\n * Creates a button group. Arguments should be `button` and `checkButton` objects.\n */\nexports.buttonGroup = function(moreButtonArgs) {\n  return dom(\"div.kf_button_group.kf_elem.flexhbox\",\n    dom.fwdArgs(arguments, 0));\n};\n\n/**\n * Creates a button group with an accented appearance.\n * Arguments should be `button` and `checkButton` objects.\n */\nexports.accentButtonGroup = function(moreButtonArgs) {\n  return this.buttonGroup(\n    [{\"class\": \"kf_button_group kf_elem flexhbox accent\"}].concat(dom.fwdArgs(arguments, 0))\n  );\n};\n\n/**\n * Creates a button group with a minimal appearance.\n * Arguments should be `button` and `checkButton` objects.\n */\nexports.liteButtonGroup = function(moreButtonArgs) {\n  return this.buttonGroup(\n    [{\"class\": \"kf_button_group kf_elem flexhbox lite\"}].concat(dom.fwdArgs(arguments, 0))\n  );\n};\n\n/**\n * Creates a button-looking div that acts as a checkbox, toggling `valueObservable` on click.\n */\nexports.checkButton = function(valueObservable, moreContentArgs) {\n  return dom(\"div.kf_button.kf_check_button.flexitem\",\n    kd.toggleClass(\"active\", valueObservable),\n    dom.on(\"click\", function() {\n      if (!this.classList.contains(\"disabled\")) {\n        setSaveValue(valueObservable, !valueObservable());\n      }\n    }),\n    dom.fwdArgs(arguments, 1));\n};\n\n/**\n * Creates a button-looking div that acts as a checkbox, toggling `valueObservable` on click.\n * Very similar to `checkButton` but looks flat and does not need to be in a group.\n *\n * TODO: checkButton and flatCheckButton are identical in function but differ in style and\n *    class name conventions. We should reconcile them.\n */\nexports.flatCheckButton = function(valueObservable, moreContentArgs) {\n  return dom(\"div.flexnone\",\n    kd.toggleClass(\"mod-active\", valueObservable),\n    dom.on(\"click\", function() {\n      if (!this.classList.contains(\"mod-disabled\")) {\n        setSaveValue(valueObservable, !valueObservable());\n      }\n    }),\n    dom.fwdArgs(arguments, 1));\n};\n\n/**\n * Creates a group of buttons of which only one may be chosen. Arguments should be `optionButton`\n * objects. The single `valueObservable` reflects the value of the selected `optionButton`.\n */\nexports.buttonSelect = function(valueObservable, moreButtonArgs) {\n  var groupElem = dom(\"div.kf_button_group.kf_elem.flexhbox\", dom.fwdArgs(arguments, 1));\n\n  // TODO: Is adding \":not(.disabled)\" the best way to avoid execution?\n  G.$(groupElem).on(\"click\", \".kf_button:not(.disabled)\", function() {\n    setSaveValue(valueObservable, ko.utils.domData.get(this, \"kfOptionValue\"));\n  });\n\n  kd.makeBinding(valueObservable, function(groupElem, value) {\n    Array.prototype.forEach.call(groupElem.querySelectorAll(\".kf_button\"), function(elem, i) {\n      var v = ko.utils.domData.get(elem, \"kfOptionValue\");\n      elem.classList.toggle(\"active\", v === value);\n    });\n  })(groupElem);\n\n  return groupElem;\n};\n\n/**\n * Creates a button-like div to use inside a `buttonSelect` group. The `value` will become the\n * value of the `buttonSelect` observable when this button is selected.\n */\nexports.optionButton = function(value, moreContentArgs) {\n  return dom(\"div.kf_button.flexitem\",\n    function(elem) { ko.utils.domData.set(elem, \"kfOptionValue\", value); },\n    dom.fwdArgs(arguments, 1));\n};\n\n/**\n * Creates a speech-bubble-like div intended to give more information and options affecting\n * its parent when hovered.\n */\nexports.toolTip = function(contentArgs) {\n  return dom(\"div.kf_tooltip\",\n    dom(\"div.kf_tooltip_pointer\"),\n    dom(\"div.kf_tooltip_content\", dom.fwdArgs(arguments, 0)),\n    dom.defer(function(elem) {\n      var elemWidth = elem.getBoundingClientRect().width;\n      var parentRect = elem.parentNode.getBoundingClientRect();\n      elem.style.left = (-elemWidth/2 + parentRect.width/2) + \"px\";\n      elem.style.top = parentRect.height + \"px\";\n    })\n  );\n};\n\n/**\n * Creates a prompt to provide feedback or request more information in the sidepane.\n */\nexports.prompt = function(contentArgs) {\n  return dom(\"div.kf_prompt\",\n    dom(\"div.kf_prompt_pointer\"),\n    dom(\"div.kf_prompt_pointer_overlap\"),\n    dom(\"div.kf_prompt_content\", dom.fwdArgs(arguments, 0))\n  );\n};\n\n/**\n * Checkbox which toggles `valueObservable`. Other arguments become part of the clickable label.\n */\nexports.checkbox = function(valueObservable, moreContentArgs) {\n  return dom(\"label.kf_checkbox_label.kf_elem\",\n    dom(\"input.kf_checkbox\", {type: \"checkbox\"},\n      kd.makeBinding(valueObservable, function(elem, value) {\n        elem.checked = value;\n      }),\n      dom.on(\"change\", function() {\n        setSaveValue(valueObservable, this.checked);\n      })\n    ),\n    dom.fwdArgs(arguments, 1));\n};\n\n\n/**\n * Radio button for a particular value of the given observable. It is checked when the observable\n * matches the value, and selecting it sets the observable to the value. Other arguments become\n * part of the clickable label.\n */\nexports.radio = function(value, valueObservable, ...domArgs) {\n  return dom(\"label.kf_radio_label\",\n    dom(\"input.kf_radio\", {type: \"radio\"},\n      kd.makeBinding(valueObservable, (elem, val) => { elem.checked = (val === value); }),\n      dom.on(\"change\", function() {\n        if (this.checked) {\n          setSaveValue(valueObservable, value);\n        }\n      })\n    ),\n    ...domArgs\n  );\n};\n\n/**\n * Create and return DOM for a spinner widget.\n *  valueObservable: observable for the value, may have save interface.\n *      This value is not displayed by the created widget.\n *  getNewValue(value, dir): called with the current value and 1 or -1 direction,\n *      should return the new value for valueObservable.\n *  shouldDisable(value, dir): called with current value and 1 or -1 direction,\n *      should return whether the button in that direction should be enabled.\n */\nfunction genSpinner(valueObservable, getNewValue, shouldDisable) {\n  let timeout = null;\n  let origValue = null;\n\n  function startChange(elem, direction) {\n    stopAutoRepeat();\n    G.$(G.window).on(\"mouseup\", onMouseUp);\n    origValue = valueObservable.peek();\n    doChange(direction, true);\n  }\n\n  function onMouseUp() {\n    G.$(G.window).off(\"mouseup\", onMouseUp);\n    stopAutoRepeat();\n    setSaveValue(valueObservable, valueObservable.peek(), origValue);\n  }\n  function doChange(direction, isFirst) {\n    const newValue = getNewValue(valueObservable.peek(), direction);\n    if (newValue !== valueObservable.peek()) {\n      valueObservable(newValue);\n      timeout = setTimeout(doChange, isFirst ? 600 : 100, direction, false);\n    }\n  }\n  function stopAutoRepeat() {\n    if (timeout) {\n      clearTimeout(timeout);\n      timeout = null;\n    }\n  }\n\n  return dom(\"div.kf_spinner\",\n    dom(\"div.kf_spinner_half\", dom(\"div.kf_spinner_arrow.up\"),\n      kd.toggleClass(\"disabled\", () => shouldDisable(valueObservable(), 1)),\n      dom.on(\"mousedown\", () => { startChange(this, 1); })\n    ),\n    dom(\"div.kf_spinner_half\", dom(\"div.kf_spinner_arrow.down\"),\n      kd.toggleClass(\"disabled\", () => shouldDisable(valueObservable(), -1)),\n      dom.on(\"mousedown\", () => { startChange(this, -1); })\n    ),\n    dom.on(\"dblclick\", () => false)\n  );\n}\n\n/**\n * Creates a spinner item linked to `valueObservable`.\n * @param {Number} optMin - Optional spinner lower bound\n * @param {Number} optMax - Optional spinner upper bound\n */\nexports.spinner = function(valueObservable, stepSizeObservable, optMin, optMax) {\n  var max = optMax !== undefined ? optMax : Infinity;\n  var min = optMin !== undefined ? optMin : -Infinity;\n\n  function getNewValue(value, direction) {\n    const step = (ko.unwrap(stepSizeObservable) || 1) * direction;\n    // Adding step quickly accumulates floating-point errors. We want to keep the value a multiple\n    // of step, as well as only keep significant decimal digits. The latter is done by converting\n    // to string and back using 15 digits of precision (max guaranteed to be significant).\n    value = value || 0;\n    value = Math.round(value / step) * step + step;\n    value = parseFloat(value.toPrecision(15));\n    return gutil.clamp(value, min, max);\n  }\n  function shouldDisable(value, direction) {\n    return (direction > 0) ? (value >= max) : (value <= min);\n  }\n  return genSpinner(valueObservable, getNewValue, shouldDisable);\n};\n\n/**\n * Creates a select spinner item to loop through the `optionObservable` array,\n * setting visible value to `valueObservable`.\n */\nexports.selectSpinner = function(valueObservable, optionObservable) {\n  function getNewValue(value, direction) {\n    const choices = optionObservable.peek();\n    const index = choices.indexOf(value);\n    const newIndex = gutil.mod(index + direction, choices.length);\n    return choices[newIndex];\n  }\n  function shouldDisable(value, direction) {\n    return optionObservable().length <= 1;\n  }\n  return genSpinner(valueObservable, getNewValue, shouldDisable);\n};\n\n/**\n * Label with a collapser triangle in front, which may be clicked to toggle `isCollapsedObs`\n * observable.\n */\nexports.collapserLabel = function(isCollapsedObs, moreContentArgs) {\n  return dom(\"div.kf_collapser.kf_elem\",\n    dom(\"span.kf_triangle_toggle\",\n      kd.text(function() {\n        return isCollapsedObs() ? \"\\u25BA\" : \"\\u25BC\";\n      })\n    ),\n    dom.on(\"click\", function() {\n      isCollapsedObs(!isCollapsedObs.peek());\n    }),\n    dom.fwdArgs(arguments, 1));\n};\n\n/**\n * Creates a collapsible section. The argument must be a function which takes a boolean observable\n * (isCollapsed) as input, and should return an array of elements. The first element is always\n * shown, while the rest will be toggled by `isCollapsed` observable. The isMountedCollapsed\n * parameter controls the initial state of the collapsible. When true or omitted, the collapsible\n * will be closed on load. Otherwise, the collapsible will initialize expanded/uncollapsed.\n *\n *    kf.collapsible(function(isCollapsed) {\n *      return [\n *        kf.collapserLabel(isCollapsed, 'Indents'),\n *        kf.row(...),\n *        kf.row(...)\n *      ];\n *    });\n *  Returns an array of two items: the always-shown element, and a div containing the rest.\n */\nexports.collapsible = function(contentFunc, isMountedCollapsed) {\n  var isCollapsed = ko.observable(isMountedCollapsed === undefined ? true : isMountedCollapsed);\n  var content = contentFunc(isCollapsed);\n  return [\n    content[0],\n    dom(\"div\",\n      kd.hide(isCollapsed),\n      dom.fwdArgs(content, 1))\n  ];\n};\n\n\n/**\n * Creates a draggable list of rows. The contentArray argument must be an observable array.\n * The callbackObj argument should include some or all of the following methods:\n * reorder, remove, and receive.\n * The reorder callback is executed if an item is dragged and dropped to a new position\n * within the same collection or draggable container. The remove and receive callbacks\n * are executed together only when an item from one collection is dropped on a different\n * collection. The remove callback may be executed alone when users click on the \"minus\" icon\n * for draggable items. The connectAllDraggables function must be called on draggables to\n * enable the remove/receive operation between separate draggables.\n *\n * Each callback must update the respective model tied to the draggable component,\n * or the equivalency between the UI and the observable array may be broken. When\n * a method is implemented, but the callback cannot update the model for any reason\n * (e.g., failure), then this failure should be communicated to the component either\n * by throwing an Error in the callback, or by returning a rejected Promise.\n *\n *\n *   reorder(item, nextItem)\n *     @param   {Object} item     The item being relocated/moved\n *     @param   {Object} nextItem The next item immediately following the new position,\n *                                or null, when the item is moved to the end of the collection.\n *   remove(item)\n *     @param   {Object} item     The item that should be removed from the collection.\n *     @returns {Object}          The item removed from the observable array. This\n *                                    value is passed to the receive function as the\n *                                    its item parameter. This value must include all the\n *                                    necessary data required for connected draggables\n *                                    to successfully insert the new value within their\n *                                    respective receive functions.\n *   receive(item, nextItem)\n *     @param   {Object} item      The item to insert in the collection.\n *     @param   {Object} nextItem  The next item from item's new position. This value\n *                                 will be null when item is moved to the end of the list.\n *\n * @param {Array}    contentArray         KoArray of model items\n * @param {Function} itemCreateFunc       Identical to koDom.foreach's itemCreateFunc, this\n *                                        function is called as `itemCreateFunc(item)` for each\n *                                        array element. Must return a single Node, or null or\n *                                        undefined to omit that node.\n * @param {Object}   options              An object containing the reorder, remove, receive\n *                                        callback functions, and all other draggable configuration\n *                                        options --\n * @param {Boolean}  options.removeButton Controls whether the clickable remove/minus icon is\n *                                        displayed. If true, this button triggers the remove\n *                                        function on click.\n * @param {String}   options.axis         Determines if the list is displayed vertically 'y' or\n *                                        horizontally 'x'.\n * @param {String}   options.handle       The handle of the draggable. Defaults to the element\n *                                        itself.\n * @param {Boolean|Function} drag_indicator Include the drag indicator. Defaults to true. Accepts\n *                                          also a function that returns a dom element. In which\n *                                          case, it will be used to create the drag indicator.\n * @returns {Node} The DOM Node for the draggable container\n */\nexports.draggableList = function(contentArray, itemCreateFunc, options) {\n  options = options || {};\n  defaults(options, {\n    removeButton: true,\n    axis: \"y\",\n    drag_indicator: true,\n    itemClass: \"kf_draggable__item\"\n  });\n\n  var reorderFunc, removeFunc;\n  itemCreateFunc = itemCreateFunc || identity;\n  var list = dom(\"ul.kf_drag_container\",\n    function(elem) {\n      if (options.reorder) {\n        reorderFunc = Promise.method(options.reorder);\n        ko.utils.domData.set(elem, \"reorderFunc\", reorderFunc);\n      }\n      if (options.remove) {\n        removeFunc = Promise.method(options.remove);\n        ko.utils.domData.set(elem, \"removeFunc\", removeFunc);\n      }\n      if (options.receive) {\n        ko.utils.domData.set(elem, \"receiveFunc\", Promise.method(options.receive));\n      }\n    },\n    kd.foreach(contentArray, item => {\n      var row = itemCreateFunc(item);\n      if (row) {\n        return dom(\"li.kf_draggable\",\n          // Fix for JQueryUI bug where mousedown on draggable elements fail to blur\n          // active element. See: https://bugs.jqueryui.com/ticket/4261\n          dom.on(\"mousedown\", () => G.document.activeElement.blur()),\n          kd.toggleClass(\"kf_draggable--vertical\", options.axis === \"y\"),\n          kd.cssClass(options.itemClass),\n          (options.drag_indicator ?\n            (typeof options.drag_indicator === \"boolean\" ?\n              dom(\"span.kf_drag_indicator.kf_draggable__icon.icon-dragdrop\") :\n              options.drag_indicator()\n            ) : null),\n          kd.domData(\"model\", item),\n          kd.maybe(removeFunc !== undefined && options.removeButton, function() {\n            return dom(\"span.drag_delete.kf_draggable__icon.icon-remove\",\n              dom.on(\"click\", function() {\n                removeFunc(item)\n                  .catch(function(err) {\n                    console.warn(\"Failed to remove item\", err);\n                  });\n              })\n            );\n          }),\n          dom(\"span.kf_draggable_content.flexauto\", row));\n      } else {\n        return null;\n      }\n    })\n  );\n\n  G.$(list).sortable({\n    axis: options.axis,\n    tolerance: \"pointer\",\n    forcePlaceholderSize: true,\n    placeholder: \"kf_draggable__placeholder--\" + (options.axis === \"x\" ? \"horizontal\" : \"vertical\"),\n    handle: options.handle,\n  });\n  if (reorderFunc === undefined) {\n    G.$(list).sortable(\"option\", {disabled: true});\n  }\n\n  G.$(list).on(\"sortstart\", function(e, ui) {\n    ko.utils.domData.set(ui.item[0], \"originalParent\", ui.item.parent());\n    ko.utils.domData.set(ui.item[0], \"originalPrev\", ui.item.prev());\n  });\n  G.$(list).on(\"sortstop\", function(e, ui) {\n    if (!ko.utils.domData.get(ui.item[0], \"crossedContainers\")) {\n      handleReorderStop.bind(null, list).call(this, e, ui);\n    } else {\n      handleConnectedStop.call(list, e, ui);\n    }\n  });\n\n  return list;\n};\n\nfunction handleReorderStop(container, e, ui) {\n  var reorderFunc = ko.utils.domData.get(container, \"reorderFunc\");\n  var originalPrev = ko.utils.domData.get(ui.item[0], \"originalPrev\");\n  if (reorderFunc && !ui.item.prev().is(originalPrev)) {\n    var movingItem = ko.utils.domData.get(ui.item[0], \"model\");\n    reorderFunc(movingItem, getNextDraggableItemModel(ui.item))\n      .catch(function(err) {\n        console.warn(\"Failed to reorder item\", err);\n        G.$(container).sortable(\"cancel\");\n      });\n  }\n  resetDraggedItem(ui.item[0]);\n}\n\n\nfunction handleConnectedStop(e, ui) {\n  var originalParent = ko.utils.domData.get(ui.item[0], \"originalParent\");\n  var removeOriginal = ko.utils.domData.get(originalParent[0], \"removeFunc\");\n  var receive = ko.utils.domData.get(ui.item.parent()[0], \"receiveFunc\");\n\n  if (removeOriginal && receive) {\n    removeOriginal(ko.utils.domData.get(ui.item[0], \"model\"))\n      .then(function(removedItem) {\n        return receive(removedItem, getNextDraggableItemModel(ui.item))\n          .then(function() {\n            ui.item.remove();\n          })\n          .catch(revertRemovedItem.bind(null, ui, originalParent, removedItem));\n      })\n      .catch(function(err) {\n        console.warn(\"Error removing item\", err);\n        G.$(originalParent).sortable(\"cancel\");\n      })\n      .finally(function() {\n        resetDraggedItem(ui.item[0]);\n      });\n  } else {\n    console.warn(\"Missing remove or receive\");\n  }\n}\n\nfunction revertRemovedItem(ui, parent, item, err) {\n  console.warn(\"Error receiving item. Trying to return removed item.\", err);\n  var originalReceiveFunc = ko.utils.domData.get(parent[0], \"receiveFunc\");\n  if (originalReceiveFunc) {\n    var originalPrev = ko.utils.domData.get(ui.item[0], \"originalPrev\");\n    var originalNextItem = originalPrev.length > 0 ?\n      getNextDraggableItemModel(originalPrev) :\n      getDraggableItemModel(parent.children(\".kf_draggable\").first());\n    originalReceiveFunc(item, originalNextItem)\n      .catch(function(err) {\n        console.warn(\"Failed to receive item in original collection.\", err);\n      }).finally(function() {\n        ui.item.remove();\n      });\n  }\n}\n\nfunction getDraggableItemModel(elem) {\n  if (elem.length && elem.length > 0) {\n    return ko.utils.domData.get(elem[0], \"model\");\n  }\n  return null;\n}\n\nfunction getNextDraggableItemModel(elem) {\n  return elem.next ? getDraggableItemModel(elem.next(\".kf_draggable\")) : null;\n}\n\nfunction resetDraggedItem(elem) {\n  ko.utils.domData.set(elem, \"originalPrev\", null);\n  ko.utils.domData.set(elem, \"originalParent\", null);\n  ko.utils.domData.set(elem, \"crossedContainers\", false);\n}\n\nfunction enableDraggableConnection(draggable) {\n  G.$(draggable).on(\"sortremove\", function(e, ui) {\n    ko.utils.domData.set(ui.item[0], \"crossedContainers\", true);\n    ko.utils.domData.set(ui.item[0], \"stopIndex\", ui.item.index());\n  });\n\n  if (G.$(draggable).sortable(\"option\", \"disabled\") && (\n    ko.utils.domData.get(draggable, \"receiveFunc\") ||\n      ko.utils.domData.get(draggable, \"removeFunc\")\n  )) {\n    G.$(draggable).sortable( \"option\", { disabled: false });\n  }\n}\n\nfunction connectDraggableToClass(draggable, className) {\n  enableDraggableConnection(draggable);\n  G.$(draggable).addClass(className);\n  G.$(draggable).sortable(\"option\", {connectWith: \".\" + className});\n}\n\n/**\n * Connects 2 or more draggableList components together. This connection allows any of the\n * draggable components to drag & drop items into and out of any other connected draggable.\n * @param  {Object} draggableArgs 2 or more draggableList objects\n */\nvar connectedDraggables = 0;\nexports.connectAllDraggables = function(draggableArgs) {\n  if (draggableArgs.length < 2) {\n    console.warn(\"connectAllDraggables requires at least 2 draggable components\");\n  }\n  var className = \"connected-draggable-\" + connectedDraggables++;\n  for (var i=0; i<arguments.length; i++) {\n    connectDraggableToClass(arguments[i], className);\n  }\n};\n\n/**\n * Connects 1 draggable to another, without the inverse. Elements may be dragged from\n * the fromDraggable to the toDraggable, but not in the other direction.\n * @param  {Object} fromDraggable A source draggableList object\n * @param  {Object} toDraggable   A destination draggableList object\n */\nexports.connectDraggableOneWay = function(fromDraggable, toDraggable) {\n  fromDraggable.id  = \"connected-draggable-\" + connectedDraggables++;\n  toDraggable.id    = \"connected-draggable-\" + connectedDraggables++;\n  enableDraggableConnection(fromDraggable);\n  enableDraggableConnection(toDraggable);\n  G.$(fromDraggable).sortable(\"option\", {connectWith: \"#\" + toDraggable.id});\n};\n\n/**\n * A bold label. Typically takes a string argument, but accepts any children.\n */\nexports.label = function(moreContentArgs) {\n  return dom(\"div.kf_label.kf_elem\", dom.fwdArgs(arguments, 0));\n};\n\n/**\n * A regular (not bold) label. Typically takes a string argument, but accepts any children.\n */\nexports.lightLabel = function(moreContentArgs) {\n  return dom(\"div.kf_light_label.kf_elem\", dom.fwdArgs(arguments, 0));\n};\n\n\n/**\n * Creates a set of tabs, with a look suitable for the middle of a pane. It takes no arguments,\n * and should be followed by `midTab()` calls under the same DOM element.\n * @param {Observable} optObservable The observable for the index of the selected tab, will be\n *    created if omitted.\n */\nexports.midTabs = function(optObservable) {\n  return _initTabs(optObservable, \".kf_mid_tab_label\",\n    dom(\"div.flexitem.kf_mid_tabs\",\n      dom(\"div.flexhbox.flexnone.kf_mid_tab_labels\"),\n      exports.scrollable()\n    )\n  );\n};\n\n/**\n * Adds a tab to the `midTabs` container created previously under the same DOM element. The\n * `label` is a label or Node for the tab label; the rest is the content of the tab.\n * The content is created once, but is hidden when a different tab is selected.\n */\nexports.midTab = function(label, moreContentArgs) {\n  return _addTab(\".kf_mid_tabs\",\n    dom(\"div.kf_mid_tab_label.flexitem\", label),\n    dom(\"div.kf_mid_tab_content\", dom.fwdArgs(arguments, 1)));\n};\n\n\n/**\n * A textbox for entering numbers, although the tied `valueObservable` is a string.\n * We may want to replace it with a widget with up-down arrows to change the values, and support\n * for units (such as px, in, %, etc).\n * @param {Object} options.placeholder  Placeholder text for the textbox\n * @param {Object} options.min  The minimum (numeric or date-time) value for the item, which must not be greater than its maximum (max attribute) value.\n * @param {Object} options.max The maximum (numeric or date-time) value for this item, which must not be less than its minimum (min attribute) value.\n */\nexports.numText = function(valueObservable, options) {\n  var attr = {type: \"number\"};\n  options = options || {};\n  if (options.placeholder) attr.placeholder = options.placeholder;\n  if (typeof options.min !== \"undefined\") attr.min = options.min;\n  if (typeof options.max !== \"undefined\") attr.max = options.max;\n  return dom(\"div.kf_elem\", dom(\"input.kf_num_text\", attr,\n    kd.value(valueObservable),\n    // while 'change' seems better suited, sometimes it does not fire when user click on the spinner\n    // arrow before it moves the cursor away.\n    dom.on(\"input\", function() {\n      setSaveValue(valueObservable, Number(this.value));\n    })\n  ));\n};\n\n/**\n * Helper function for textboxes tied to `valueObservable`.\n * @param {Number}    [options.maxSize]             The max length of the text, which\n *                                                  also affects the box size.\n * @param {String}    [options.placeholder]         Placeholder text for the textbox\n * @param {Function}  [options.disabled]            Plain boolean or observable\n *                                                  boolean value or Function returning\n *                                                  a Boolean that controls whether\n *                                                  the \"disabled\" attribute is true or false\n *                                                  for the input element.\n * @param {Number}    [option.delay]                Wait interval in milliseconds until user stops\n *                                                  typing before save its input. Using this options\n *                                                  allows user to not change focus for saving input.\n * @return  {Object}                                Constructed DOM\n */\nfunction textInput(valueObservable, options, moreArgs) {\n  var attr = {};\n  if (options) {\n    if (options.type) {\n      attr.type = options.type;\n    }\n    if (options.maxSize) {\n      attr.maxlength = options.maxSize;\n      attr.style = \"max-width: \" + (options.maxSize + 2) + \"em\";\n    }\n    if (options.placeholder) {\n      attr.placeholder = options.placeholder;\n    }\n  }\n\n  var saveValue = e => setSaveValue(valueObservable, e.target.value);\n  var debounced = debounce(saveValue, options.delay);\n\n  var setValue = elem => {\n    if (options && options.delay) {\n      dom(elem,\n        dom.on(\"input\", debounced),\n        dom.on(\"change\", e => {\n          debounced(e);\n          debounced.flush();\n        }));\n    } else {\n      dom(elem, dom.on(\"change\", saveValue));\n    }\n  };\n\n  return dom(\"div.kf_elem\",\n    dom(\"input.kf_text\",\n      attr,\n      kd.toggleDisabled(options.disabled || false),\n      kd.value(valueObservable),\n      setValue,\n      dom.fwdArgs(arguments, 2))\n  );\n}\n\n\n/**\n * A regular textbox tied to `valueObservable`.\n */\nexports.text = function(valueObservable, options, ...moreArgs) {\n  options = Object.assign({type: \"text\"}, options || {});\n  return textInput(valueObservable, options, moreArgs);\n};\n\n/**\n * A color picker tied to `valueObservable`.\n */\nexports.color = function(valueObservable, ...moreArgs) {\n  // On some machine (seen on chrome running on a Mac) the `change` event fires as many times as the `input` event, hence debounce.\n  const saveValue = debounce(e => setSaveValue(valueObservable, e.target.value), 300);\n  return dom(\"div.kf_elem\",\n    dom(\"input.kf_color\",\n      {type: \"color\"},\n      kd.value(valueObservable),\n      dom.on(\"change\", saveValue),\n      ...moreArgs\n    ));\n};\n\n/**\n * Identical to koForm.text, but with input type=password\n */\nexports.password = function(valueObservable, options, ...moreArgs) {\n  options = Object.assign({type: \"password\"}, options || {});\n  return textInput(valueObservable, options, moreArgs);\n};\n\n/**\n * A status panel which reflects 1 of 4 possible states based on the value of valueObservable.\n * Users provide a mapping between possible valueObservable values and the 4 possible states\n * of this status panel: `success`, `info`, `warning`, and `error`. The status panel\n * displays circle icon or indicator reflecting the state of valueObservable (success=green,\n * info=blue, warning=orange, error=red).\n *\n * Users may provide either a strings or an objects with \"value\" and \"label\" properties\n * for each of the state properties. Each of these strings (or object.value properties)\n * must represent a possible value of valueObservable.\n *\n * For example:\n *   statusPanel(\n *     ko.observable('OK'),\n *     {\n *       success: 'OK',\n *       info: {value: 'Unknown', label: 'Status unknown'}\n *     }\n *   )\n *\n * @param  {string}         valueObservable A knockout observable containing a string\n * @param  {string|Object}  options.success\n * @param  {string|Object}  options.info\n * @param  {string|Object}  options.warning\n * @param  {string|Object}  options.error\n * @param  {string}         options.heading When present, the heading string appears\n *                                          as a header within the panel above the\n *                                          status label (if any).\n * @return {Object}                         DOM\n */\nexports.statusPanel = function(valueObservable, options) {\n  var statusMap = {};\n  [\"success\", \"info\", \"warning\", \"error\"].forEach(function(key) {\n    var statusLookupValue;\n    if (options[key]) {\n      statusLookupValue = options[key].value !== undefined ? options[key].value : options[key];\n      statusMap[statusLookupValue] = {};\n      statusMap[statusLookupValue].className = \"kf_status_\" + key;\n      statusMap[statusLookupValue].label = options[key].label || null;\n    }\n  });\n  var hasLabel = ko.pureComputed(function() {\n    return statusMap[valueObservable()].label !== undefined;\n  });\n  return dom(\"div.kf_status_panel.flexhbox\",\n    dom.autoDispose(hasLabel),\n    dom(\"div.kf_status_indicator.flexauto\",\n      kd.cssClass(function() {\n        if (statusMap[valueObservable()]) {\n          return statusMap[valueObservable()].className;\n        }\n        console.error(\"Status must match an available status code\", Object.keys(statusMap));\n      }),\n      \"\\u25CF\" // solid circle\n    ),\n    dom(\"div.kf_status_detail.flexauto\",\n      kd.maybe(options.heading, function() {\n        return exports.row(exports.label(options.heading));\n      }),\n      kd.maybe(hasLabel, function() {\n        return exports.row(exports.lightLabel(\n          kd.text(ko.pureComputed(function() {\n            return statusMap[valueObservable()].label;\n          }))\n        ));\n      })\n    )\n  );\n};\n\n/**\n * Accepts any number of children. If an argument is numeric, it specifies the number of columns\n * the next child should occupy (defaults to 1). All columns are equally spaced.\n */\nexports.row = function(childOrColSpanArgs) {\n  var colSpan = 1;\n  var elem = dom(\"div.kf_row.flexhbox\");\n  for (var i = 0; i < arguments.length; i++) {\n    var arg = arguments[i];\n    if (typeof arg === \"number\") {\n      colSpan = arg;\n    } else if (typeof arg === \"function\") {\n      arg(elem);\n    } else if (typeof arg !== \"undefined\") {\n      if (typeof arg === \"string\" || Array.isArray(arg)) {\n        arg = dom(\"div\", arg);\n      }\n      arg.style.flex = arg.style.webkitFlex = colSpan + \" 1 0px\";\n      elem.appendChild(arg);\n      colSpan = 1;\n    }\n  }\n  return elem;\n};\n\n/**\n * Creates a row of help labels. Takes the same arguments as `row()`, but the content is typically\n * short descriptive strings. Use it immediately after a `row()` call, with same column-span\n * values, to place descriptions under the elements of the row.\n */\nexports.helpRow = function(childOrColSpan) {\n  var elem = exports.row.apply(null, arguments);\n  elem.classList.add(\"kf_help_row\");\n  return elem;\n};\n\n/**\n * Creates a scrollable pane, with a shadow over the top edge.\n */\nexports.scrollable = function(contentArgs) {\n  var elem, shadow;\n  return [\n    dom(\"div.flexnone.kf_scroll_shadow_outer\",\n      shadow = dom(\"div.kf_scroll_shadow\", dom.hide)\n    ),\n    elem = dom(\"div.flexitem.kf_scrollable\",\n      dom.on(\"scroll\", function() {\n        shadow.style.display = (elem.scrollTop > 0 ? \"\" : \"none\");\n      }),\n      dom.fwdArgs(arguments, 0)\n    )\n  ];\n};\n\n/**\n * Creates a select (or dropdown) widget. The `valueObservable` reflects the value of the selected\n * option, `optionArray` is an array (regular or observable) of option values and labels.\n * may be either strings (used for both values and labels) or objects with \"value\", \"label\", and\n * \"disabled\" properties.\n *\n * @param {observable} valueObservable - An observable whose value will be set to the value of the\n *    corresponding option from `optionArray` when the selection changes, or an array of sorted\n *    values if `options.multiple` is enabled. A string representation of the value is also made\n *    accessible as in the <option>'s value attribute, and is what would be submitted if, for\n *    example, the element is used in a submitted form.\n * @param {Array<string|Object>} optionArray - Array of options as strings or objects. If string,\n *    the same value will be used for key (i.e. value) and label. If object, its properties will be\n *    used to populate `value`, `label`, and `disabled`. A koArray may be used.\n * @property {Any} value - Value of the option that, when selected, will be set as the value of\n *    valueObservable (or included into an array of selected values with `multiple: true`).\n *    The string representation of this value will also be set on the `<option>` DOM, but\n *    valueObservable will receive the raw JavaScript value. This should not mutate.\n * @property {string|observable} label - Visible label for the option. Can be an observable.\n * @property {boolean|observable} disabled - Optional disabled flag. Can be an observable.\n *\n * @param {Object} options\n * @property {Number}  [options.size]       The number of rows in the select list that\n * @property {Boolean} [options.disabled]   Whether the select control is disabled.\n * @property {Boolean} [options.multiple]   Whether the select control supports multiple selection.\n *    If true, `valueObservable` should be an array of values.\n */\nexports.select = function(valueObservable, optionArray, options) {\n  // Wrap the returned element into a div, since otherwise it doesn't respect\n  // dimensions as well.\n  options = options || {};\n  // Sets elem.value to value. Useful for setting the displayed multiselect value.\n  var setValue = (elem, value) => {\n    let valuesSet = new Set(options.multiple ? value : [value]);\n    for (let option of elem.querySelectorAll(\"option\")) {\n      option.selected = valuesSet.has(ko.utils.domData.get(option, \"value\"));\n    }\n  };\n  return dom(\"div.kf_elem\",\n    dom(\"div.kf_select_arrow\",\n      dom(\"select.kf_select\",\n        pick(options, [\"size\", \"multiple\"]),\n        kd.toggleDisabled(options.disabled || false),\n        kd.foreach(optionArray, function(option) {\n          if (!option) {\n            return null;\n          }\n\n          let value = (typeof option === \"string\" ? option : option.value);\n          let label = (typeof option === \"string\" ? option : option.label);\n          let disabled = (typeof option === \"string\" ? false : option.disabled);\n\n          return dom(\n            \"option\",\n            { value }, // To keep older browser tests from breaking, store stringified value in DOM\n            kd.domData(\"value\", value),\n            kd.toggleDisabled(disabled),\n            kd.text(label)\n          );\n        }),\n        // If the optionArray changes, the selected option may become different than the\n        // option displayed. This is fixed by re-setting elem.value on optionArray changes.\n        kd.makeBinding(koArray.isKoArray(optionArray) ? optionArray.getObservable() : optionArray,\n          elem => setValue(elem, valueObservable())),\n        kd.makeBinding(valueObservable, (elem, value) => setValue(elem, value)),\n        dom.on(\"change\", function() {\n          let valuesArray = [];\n          let optionElements = this.querySelectorAll(\"option\");\n          for (let i = 0; i < optionElements.length; i++) {\n            if (optionElements[i].selected) {\n              let value = ko.utils.domData.get(optionElements[i], \"value\");\n              valuesArray.push(value);\n              if (!options.multiple) { break; }\n            }\n          }\n          valuesArray.sort();\n          setSaveValue(valueObservable, options.multiple ? valuesArray : valuesArray[0]);\n        })\n      )\n    )\n  );\n};\n\n/**\n * A separator (thin horizontal line).\n */\nexports.separator = function() {\n  return dom(\"hr.kf_separator\");\n};\n\n/**\n * Creates a set of tabs for the top of a pane. It takes no arguments, and should be followed by\n * `topTab()` calls under the same DOM element.\n * @param {Observable} optObservable The observable for the index of the selected tab, will be\n *    created if omitted.\n */\nexports.topTabs = function(optObservable) {\n  return _initTabs(optObservable, \".kf_top_tab_label\",\n    dom(\"div.flexvbox.kf_top_tabs\",\n      dom(\"div.flexhbox.flexnone.kf_top_tab_labels\"),\n      dom(\"div.flexitem.kf_top_tab_container\")\n    )\n  );\n};\n\n/**\n * Adds a tab to the `topTabs` container created previously under the same DOM element. The\n * `label` is a label or Node for the tab label; the rest is the content of the tab.\n * The content is created once, but is hidden when a different tab is selected.\n */\nexports.topTab = function(label, moreContentArgs) {\n  return _addTab(\".kf_top_tabs\",\n    dom(\"div.kf_top_tab_label.flexitem\", label),\n    dom(\"div.kf_top_tab_content.flexvbox\", dom.fwdArgs(arguments, 1)));\n};\n\n/**\n * Helper function for creating a set of tabs.\n * @param {Observable} optObservable The observable for the index of the selected tab, will be\n *    created if omitted.\n * @param {String} labelSelector The selector (e.g. \".className\") for the tab label elements that\n *    will be given to _addTab.\n * @param {Node} elem The tabs container element. Its first child must be the container for the\n *    labels, and its last child, the container for the content panes.\n */\nfunction _initTabs(optObservable, labelSelector, elem) {\n  var selectedTab = optObservable || ko.observable(0);\n  G.$(elem).on(\"click\", labelSelector, function() {\n    selectedTab(dom.childIndex(this));\n  });\n  ko.utils.domData.set(elem, \"kfSelectedTab\", selectedTab);\n  return elem;\n}\n\n/**\n * Helper function for adding a tab to a set of tabs created with _initTabs.\n * @param {String} tabsSelector The selector of the tabs container that was given to _initTab.\n *    It's needed to find that element.\n * @param {Node} labelElem The label element to add to the container of labels.\n * @param {Node} contentElem The content element to add to the container of content panes.\n */\nfunction _addTab(tabsSelector, labelElem, contentElem) {\n  return function(elem) {\n    var tabsEl = dom.findLastChild(elem, tabsSelector);\n    if (!tabsEl) {\n      console.log(\"koForm: Attempting to add tab without an existing tabs container\");\n      return;\n    }\n    var selectedTab = ko.utils.domData.get(tabsEl, \"kfSelectedTab\");\n    var labels = tabsEl.firstChild;\n    var container = tabsEl.lastChild;\n    var index = labels.childNodes.length;\n    var isSelected = ko.computed(function() { return selectedTab() === index; });\n\n    // These methods are indended to be used as arguments to dom() function, so they return a\n    // function that should be applied to the target element.\n    kd.toggleClass(\"active\", isSelected)(labelElem);\n    dom.autoDispose(labelElem, isSelected);\n    kd.show(isSelected)(contentElem);\n\n    labels.appendChild(labelElem);\n    container.appendChild(contentElem);\n  };\n}\n"
  },
  {
    "path": "app/client/lib/koUtil.js",
    "content": "var _ = require(\"underscore\");\nvar ko = require(\"knockout\");\n\n/**\n * This is typed to declare that the observable/computed supports subscribable.fn methods\n * added in this utility.\n */\nfunction withKoUtils(obj) {\n  return obj;\n}\nexports.withKoUtils = withKoUtils;\n\n/**\n * subscribeInit is a convenience method, equivalent to knockout's observable.subscribe(), but\n * also calls the callback immediately with the observable's current value.\n *\n * It is added to the prototype for all observables, as long as this module is included anywhere.\n */\nko.subscribable.fn.subscribeInit = function(callback, target, event) {\n  var sub = this.subscribe(callback, target, event);\n  callback.call(target, this.peek());\n  return sub;\n};\n\n/**\n * Add a named method `assign` to knockout subscribables (including observables) to assign a new\n * value. This way we can move away from using callable objects for everything, since callable\n * objects require hacking at prototypes.\n */\nko.subscribable.fn.assign = function(value) {\n  this(value);\n};\n\n\n/**\n * Convenience method to modify a non-primitive value and assign it back. E.g. if foo() is an\n * observable whose value is an array, then\n *\n *    foo.modifyAssign(function(array) { array.push(\"test\"); });\n *\n * is one-liner equivalent to:\n *\n *    var array = foo.peek();\n *    array.push(\"text\");\n *    foo(array);\n *\n * Whenever using a non-primitive value, be careful that it's not shared with other code, which\n * might modify it without any observable subscriptions getting triggered.\n */\nko.subscribable.fn.modifyAssign = function(modifierFunc) {\n  var value = this.peek();\n  modifierFunc(value);\n  this(value);\n};\n\n\n/**\n * Tells a computed observable which may return non-primitive values (e.g. objects) that it should\n * only notify subscribers when the computed value is not equal to the last one (using \"===\").\n */\nko.subscribable.fn.onlyNotifyUnequal = function() {\n  this.equalityComparer = function(a, b) { return a === b; };\n  return this;\n};\n\n/**\n * Notifies only about distinct defined values. If the first value is undefined it will still be\n * returned.\n */\nko.subscribable.fn.previousOnUndefined = function() {\n  this.equalityComparer = function(a, b) { return a === b || b === undefined; };\n  return this;\n};\n\nlet _handlerFunc = (err) => {};\nlet _origKoComputed = ko.computed;\n\n/**\n * If setComputedErrorHandler is used, this wrapper catches and swallows errors from the\n * evaluation of any computed. Any exception gets passed to _handlerFunc, and the computed\n * evaluates successfully to its previous value (or _handlerFunc may rethrow the error).\n */\nfunction _wrapComputedRead(readFunc) {\n  let lastValue;\n  return function() {\n    try {\n      return (lastValue = readFunc.call(this));\n    } catch (err) {\n      console.error(\"ERROR in ko.computed: %s\", err);\n      _handlerFunc(err);\n      return lastValue;\n    }\n  };\n}\n\n\n/**\n * If called, exceptions thrown while evaluating any ko.computed observable will get passed to\n * handlerFunc and swallowed. Unless the handlerFunc rethrows them, the computed will evaluate\n * successfully to its previous value.\n *\n * Note that this is merely an attempt to do the best we can to keep going in the face of\n * application bugs. The returned value is not actually correct, and relying on this incorrect\n * value may cause even worse bugs elsewhere in the application. It is important that any errors\n * caught via this mechanism get reported, debugged, and fixed.\n */\nfunction setComputedErrorHandler(handlerFunc) {\n  _handlerFunc = handlerFunc;\n\n  // Note that ko.pureComputed calls to ko.computed, so doesn't need its own override.\n  ko.computed = function(funcOrOptions, funcTarget, options) {\n    if (typeof funcOrOptions === \"function\") {\n      funcOrOptions = _wrapComputedRead(funcOrOptions);\n    } else {\n      funcOrOptions.read = _wrapComputedRead(funcOrOptions.read);\n    }\n    return _origKoComputed(funcOrOptions, funcTarget, options);\n  };\n}\nexports.setComputedErrorHandler = setComputedErrorHandler;\n\n\n/**\n * Returns an observable which mirrors the passed-in argument, but returns a default value if the\n * underlying field is falsy and has non-boolean type. Writes to the returned observable translate\n * directly to writes to the underlying one. The default may be a function, evaluated as for computed\n * observables, with optContext as the context.\n */\nfunction observableWithDefault(obs, defaultOrFunc, optContext) {\n  if (typeof defaultOrFunc !== \"function\") {\n    var def = defaultOrFunc;\n    defaultOrFunc = function() { return def; };\n  }\n  return ko.pureComputed({\n    read: function() {\n      const value = obs();\n      if (typeof value === \"boolean\") {\n        return value;\n      }\n      return value || defaultOrFunc.call(this);\n    },\n    write: function(val) { obs(val); },\n    owner: optContext\n  });\n}\nexports.observableWithDefault = observableWithDefault;\n\n/**\n * Return an observable which mirrors the passed-in argument, but convert to Number value. Write to\n * to the returned observable translate to write to the underlying one a Number value.\n */\nfunction observableNumber(obs) {\n  return ko.pureComputed({\n    read: () =>  Number(obs()),\n    write: (val) => {\n      obs(Number(val));\n    }\n  });\n}\nexports.observableNumber = observableNumber;\n\n/**\n * Same interface as ko.computed(), except that it disposes the values it evaluates to. If an\n * observable is set to values which are created on the fly and need to be disposed (e.g.\n * components), use foo = computedAutoDispose(...). Whenever the value of foo() changes (and when\n * foo itself is disposed), the previous value's `dispose` method gets called.\n */\nfunction computedAutoDispose(optionsOrReadFunc, target, options) {\n  // Note: this isn't quite possible to do as a knockout extender, specifically to get correct the\n  // pure vs non-pure distinction (sometimes the computed must be pure to avoid evaluation;\n  // sometimes it has side-effects and must not be pure).\n  var value = null;\n  function setNewValue(newValue) {\n    if (value && value !== newValue) {\n      ko.ignoreDependencies(value.dispose, value);\n    }\n    value = newValue;\n    return newValue;\n  }\n\n  var origRead;\n  if (typeof optionsOrReadFunc === \"object\") {\n    // Single-parameter syntax.\n    origRead = optionsOrReadFunc.read;\n    options = _.clone(optionsOrReadFunc);\n  } else {\n    origRead = optionsOrReadFunc;\n    options = _.defaults({ owner: target }, options || {});\n  }\n  options.read = function() {\n    return setNewValue(origRead.call(this));\n  };\n\n  var result = ko.computed(options);\n  var origDispose = result.dispose;\n  result.dispose = function() {\n    setNewValue(null);\n    origDispose.call(result);\n  };\n  return result;\n}\nexports.computedAutoDispose = computedAutoDispose;\n\n\n/**\n * Helper for building disposable components that depend on a few observables. The callback is\n * evaluated as for a knockout computed observable, which creates dependencies on any observables\n * mentioned in it. But the return value of the callback should be a function (\"builder\"), which\n * is called to build the resulting value. Observables mentioned in the evaluation of the builder\n * do NOT create dependencies. In addition, the built value gets disposed automatically when it\n * changes.\n *\n * The optContext argument serves as the context for the callback.\n *\n * For example,\n *    var foo = ko.observable();\n *    koUtil.computedBuilder(function() {\n *      return MyComponent.create.bind(MyComponent, foo());\n *    }, this);\n *\n * In this case, whenever foo() changes, MyComponent.create(foo()) gets called, and\n * previously-returned component gets disposed. Observables mentioned during MyComponent's\n * construction do not trigger its rebuilding (as they would if a plain ko.computed() were used).\n */\nfunction computedBuilder(callback, optContext) {\n  return computedAutoDispose(function() {\n    var builder = callback.call(optContext);\n    return builder ? ko.ignoreDependencies(builder) : null;\n  }, null, { pure: false });\n}\nexports.computedBuilder = computedBuilder;\n"
  },
  {
    "path": "app/client/lib/loadScript.ts",
    "content": "import { dom } from \"grainjs\";\n\n/**\n * Load dynamically an external JS script from the given URL. Returns a promise that is\n * resolved when the script is loaded.\n */\nexport function loadScript(url: string) {\n  return new Promise((resolve, reject) => {\n    const script = dom(\"script\", { type: \"text/javascript\", src: url, crossorigin: \"anonymous\" });\n    document.head.appendChild(Object.assign(script, { onload: resolve, onerror: reject }));\n  });\n}\n\n/**\n * Load dynamically an external CSS file from the given URL. Returns a promise that is\n * resolved when the file is loaded.\n */\nexport function loadCssFile(url: string): Promise<void> {\n  return new Promise((resolve, reject) => {\n    const link = dom(\"link\", { rel: \"stylesheet\", href: url });\n    document.head.appendChild(Object.assign(link, { onload: resolve, onerror: reject }));\n  });\n}\n"
  },
  {
    "path": "app/client/lib/localStorageObs.ts",
    "content": "import { getSessionStorage, getStorage } from \"app/client/lib/storage\";\nimport { safeJsonParse } from \"app/common/gutil\";\n\nimport { Observable } from \"grainjs\";\n\nfunction getStorageBoolObs(store: Storage, key: string, defValue: boolean) {\n  const storedNegation = defValue ? \"false\" : \"true\";\n  const obs = Observable.create(null, store.getItem(key) === storedNegation ? !defValue : defValue);\n  obs.addListener(val => val === defValue ? store.removeItem(key) : store.setItem(key, storedNegation));\n  return obs;\n}\n\n/**\n * Helper to create a boolean observable whose state is stored in localStorage.\n *\n * Optionally, a default value of true will make the observable start off as true. Note that the\n * same default value should be used for an observable every time it's created.\n */\nexport function localStorageBoolObs(key: string, defValue = false): Observable<boolean> {\n  return getStorageBoolObs(getStorage(), key, defValue);\n}\n\n/**\n * Similar to `localStorageBoolObs`, but always uses sessionStorage (or an in-memory equivalent).\n */\nexport function sessionStorageBoolObs(key: string, defValue = false): Observable<boolean> {\n  return getStorageBoolObs(getSessionStorage(), key, defValue);\n}\n\nfunction getStorageObs(store: Storage, key: string, defaultValue?: string) {\n  const obs = Observable.create<string | null>(null, store.getItem(key) ?? defaultValue ?? null);\n  obs.addListener(val => (val === null) ? store.removeItem(key) : store.setItem(key, val));\n  return obs;\n}\n\n/**\n * Helper to create a string observable whose state is stored in localStorage.\n */\nexport function localStorageObs(key: string, defaultValue?: string): Observable<string | null> {\n  return getStorageObs(getStorage(), key, defaultValue);\n}\n\n/**\n * Similar to `localStorageObs`, but always uses sessionStorage (or an in-memory equivalent).\n */\nexport function sessionStorageObs(key: string, defaultValue?: string): Observable<string | null> {\n  return getStorageObs(getSessionStorage(), key, defaultValue);\n}\n\nfunction getStorageJsonObs<T>(store: Storage, key: string, defaultValue: T): Observable<T> {\n  const currentValue = safeJsonParse(store.getItem(key) || \"\", defaultValue ?? null);\n  const obs = Observable.create<T>(null, currentValue);\n  obs.addListener(val => (val === null) ? store.removeItem(key) : store.setItem(key, JSON.stringify(val ?? null)));\n  return obs;\n}\n\n/**\n * Helper to create a JSON observable whose state is stored in localStorage.\n */\nexport function localStorageJsonObs<T>(key: string, defaultValue: T): Observable<T> {\n  return getStorageJsonObs(getStorage(), key, defaultValue);\n}\n\n/**\n * Similar to `localStorageJsonObs`, but always uses sessionStorage (or an in-memory equivalent).\n */\nexport function sessionStorageJsonObs<T>(key: string, defaultValue: T): Observable<T> {\n  return getStorageJsonObs(getSessionStorage(), key, defaultValue);\n}\n"
  },
  {
    "path": "app/client/lib/localization.ts",
    "content": "import { hooks } from \"app/client/Hooks\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { DomContents } from \"grainjs\";\nimport { G } from \"grainjs/dist/cjs/lib/browserGlobals\";\nimport i18next from \"i18next\";\n\nexport async function setupLocale() {\n  const now = Date.now();\n  const supportedLngs = getGristConfig().supportedLngs ?? [\"en\"];\n  const lng = detectCurrentLang();\n  const ns = getGristConfig().namespaces ?? [\"client\"];\n  // Initialize localization plugin\n  try {\n    // We don't await this promise, as it is resolved synchronously due to initImmediate: false.\n    i18next.init({\n      // By default we use english language.\n      fallbackLng: \"en\",\n      // We will load resources ourselves.\n      initImmediate: false,\n      // Read language from navigator object.\n      lng,\n      // By default we use client namespace.\n      defaultNS: \"client\",\n      // Read namespaces that are supported by the server.\n      // TODO: this can be converted to a dynamic list of namespaces, for async components.\n      // for now just import all what server offers.\n      // We can fallback to client namespace for any addons.\n      fallbackNS: \"client\",\n      ns,\n    }).catch((err: any) => {\n      // This should not happen, the promise should be resolved synchronously, without\n      // any errors reported.\n      console.error(\"i18next failed unexpectedly\", err);\n    });\n    // Detect what is resolved languages to load.\n    const languages = i18next.languages;\n    // Fetch all json files (all of which should be already preloaded);\n    const loadPath = `${hooks.baseURI || document.baseURI}locales/{{lng}}.{{ns}}.json`;\n    const pathsToLoad: Promise<any>[] = [];\n    async function load(lang: string, n: string) {\n      const resourceUrl = loadPath.replace(\"{{lng}}\", lang.replace(\"-\", \"_\")).replace(\"{{ns}}\", n);\n      const response = await fetch(resourceUrl);\n      if (!response.ok) {\n        // Throw only if we don't have any fallbacks.\n        if (lang === i18next.options.fallbackLng && n === i18next.options.defaultNS) {\n          throw new Error(`Failed to load ${resourceUrl}`);\n        } else {\n          console.warn(`Failed to load ${resourceUrl}`);\n          return;\n        }\n      }\n      i18next.addResourceBundle(lang, n, await response.json());\n    }\n    for (const lang of languages.filter(l => supportedLngs.includes(l))) {\n      for (const n of ns) {\n        pathsToLoad.push(load(lang, n));\n      }\n    }\n    await Promise.all(pathsToLoad);\n    console.log(\"Localization initialized in \" + (Date.now() - now) + \"ms\");\n  } catch (error: any) {\n    reportError(error);\n  }\n}\n\nexport function detectCurrentLang() {\n  const { userLocale, supportedLngs } = getGristConfig();\n  const detected = userLocale ||\n    document.cookie.match(/grist_user_locale=([^;]+)/)?.[1] ||\n    window.navigator.language ||\n    \"en\";\n  const supportedList = supportedLngs ?? [\"en\"];\n  // If we have this language in the list (or more general version) mark it as selected.\n  // Compare languages in lower case, as navigator.language can return en-US, en-us (for older Safari).\n  const selected = supportedList.find(supported => supported.toLowerCase() === detected.toLowerCase()) ??\n    supportedList.find(supported => supported === detected.split(/[-_]/)[0]) ?? \"en\";\n  return selected;\n}\n\nexport function setAnonymousLocale(lng: string) {\n  document.cookie = lng ? `grist_user_locale=${lng}; path=/; max-age=31536000` :\n    \"grist_user_locale=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC\";\n}\n\n/**\n * Resolves the translation of the given key using the given options.\n */\nexport function tString(key: string, args?: any, instance = i18next): string {\n  if (!instance.exists(key, args || undefined)) {\n    const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`);\n    reportError(error);\n  }\n  return instance.t(key, args);\n}\n\n// We will try to infer result from the arguments passed to `t` function.\n// For plain objects we expect string as a result. If any property doesn't look as a plain value\n// we assume that it might be a dom node and the result is DomContents.\ntype InferResult<T> = T extends Record<string, string | number | boolean> | undefined | null ? string : DomContents;\n\n/**\n * Resolves the translation of the given key and substitutes. Supports dom elements interpolation.\n */\nexport function t<T extends Record<string, any>>(key: string, args?: T | null, instance = i18next): InferResult<T> {\n  return domT(key, args, instance.t);\n}\n\nfunction domT(key: string, args: any, tImpl: typeof i18next.t) {\n  // If there are any DomElements in args, handle it with missingInterpolationHandler.\n  const domElements = !args ? [] : Object.entries(args).filter(([_, value]) => isLikeDomContents(value));\n  if (!args || !domElements.length) {\n    return tImpl(key, args || undefined);\n  } else {\n    // Make a copy of the arguments, and remove any dom elements from it. It will instruct\n    // i18next library to use `missingInterpolationHandler` handler.\n    const copy = { ...args };\n    domElements.forEach(([prop]) => delete copy[prop]);\n\n    // Passing `missingInterpolationHandler` will allow as to resolve all missing keys\n    // and replace them with a marker.\n    const result: string = tImpl(key, { ...copy, missingInterpolationHandler });\n\n    // Now replace all markers with dom elements passed as arguments.\n    const parts = result.split(/(\\[\\[\\[[^\\]]+?\\]\\]\\])/);\n    for (let i = 1; i < parts.length; i += 2) { // Every second element is our dom element.\n      const propName = parts[i].substring(3, parts[i].length - 3);\n      const domElement = args[propName] ?? `{{${propName}}}`; // If the prop is not there, simulate default behavior.\n      parts[i] = domElement;\n    }\n    return parts.filter(p => p !== \"\") as any; // Remove empty parts.\n  }\n}\n\n/**\n * Checks if the given key exists in the any supported language.\n */\nexport function hasTranslation(key: string) {\n  return i18next.exists(key);\n}\n\nfunction missingInterpolationHandler(key: string, value: any) {\n  return `[[[${value[1]}]]]`;\n}\n\n/**\n * Very naive detection if an element has DomContents type.\n */\nfunction isLikeDomContents(value: any): boolean {\n  // As null and undefined are valid DomContents values, we don't treat them as such.\n  if (value === null || value === undefined) { return false; }\n  return value instanceof G.Node || // Node\n    (Array.isArray(value) && isLikeDomContents(value[0])) || // DomComputed\n    typeof value === \"function\"; // DomMethod\n}\n\n/**\n * Helper function to create a scoped t function. Scoped t function is bounded to a specific\n * namespace and a key prefix (a scope).\n */\nexport function makeT(scope: string, instance?: typeof i18next) {\n  // Can't create the scopedInstance yet as it might not be initialized.\n  let scopedInstance: null | typeof i18next = null;\n  let scopedResolver: null | typeof i18next.t = null;\n  return function<T extends Record<string, any>>(key: string, args?: T | null) {\n    // Create a scoped instance with disabled namespace and nested features.\n    // This enables keys like `key1.key2:key3` to be resolved properly.\n    if (!scopedInstance) {\n      scopedInstance = (instance ?? i18next).cloneInstance({\n        keySeparator: false,\n        nsSeparator: false,\n        saveMissing: true,\n        missingKeyHandler: (lng, ns, _key) => console.warn(`Missing translation for key: ${_key}`),\n      });\n\n      // Create a version of `t` function that will use the provided prefix as default.\n      const fixedResolver = scopedInstance.getFixedT(null, null, scope);\n\n      // Override the resolver with a custom one, that will use the argument as a default.\n      // This will remove all the overloads from the function, but we don't need them.\n      scopedResolver = (_key: string, _args?: any) => fixedResolver(_key, { defaultValue: _key, ..._args });\n    }\n    return domT(key, args, scopedResolver!);\n  };\n}\n"
  },
  {
    "path": "app/client/lib/log.ts",
    "content": "/**\n * Client-side debug logging.\n * At the moment this simply logs to the browser console, but it's still useful to have dedicated\n * methods to allow collecting them in the future, or silencing them in production or in mocha\n * tests.\n */\n\nexport type LogMethod = (message: string, ...args: any[]) => void;\n\nexport const debug: LogMethod = console.debug.bind(console);\nexport const info: LogMethod = console.info.bind(console);\nexport const log: LogMethod = console.log.bind(console);\nexport const warn: LogMethod = console.warn.bind(console);\nexport const error: LogMethod = console.error.bind(console);\n"
  },
  {
    "path": "app/client/lib/markdown.ts",
    "content": "import { sanitizeHTMLIntoDOM } from \"app/client/ui/sanitizeHTML\";\nimport { theme } from \"app/client/ui2018/cssVars\";\n\nimport { BindableValue, dom, DomContents, IDomArgs, styled } from \"grainjs\";\nimport { marked } from \"marked\";\n\n/**\n * Helper function for using Markdown in grainjs elements. It accepts\n * both plain Markdown strings, as well as methods that use an observable.\n * Example usage:\n *\n *    cssSection(markdown(t(`# New Markdown Function\n *\n *      We can _write_ [the usual Markdown](https://markdownguide.org) *inside*\n *      a Grainjs element.`)));\n *\n * or\n *\n *   cssSection(markdown(use => use(toggle) ? t('The toggle is **on**') : t('The toggle is **off**'));\n *\n * Markdown strings are easier for our translators to handle, as it's possible\n * to include all of the context around a single markdown string without\n * breaking it up into separate strings for grainjs elements.\n *\n * Enable `inline` option to avoid wrapping results in `<p>` tags.\n */\nexport function markdown(markdownObs: BindableValue<string>, options: { inline?: boolean } = {}): DomContents {\n  return dom.domComputed(markdownObs, value => getMarkdownValue(value, options));\n}\n\n/**\n * HTML span element that creates a span element with the given markdown string, without\n * any surrounding paragraph tags. This is useful when you want to include markdown inside\n * a larger element as a single line.\n */\nexport function cssMarkdownSpan(\n  markdownObs: BindableValue<string>,\n  ...args: IDomArgs<HTMLSpanElement>\n): HTMLSpanElement {\n  return cssMarkdownLine(markdown(markdownObs), ...args);\n}\nconst cssMarkdownLine = styled(\"span\", `\n  & p {\n    margin: 0;\n  }\n  & a {\n    color: ${theme.link};\n    --icon-color: ${theme.link};\n    text-decoration: none;\n  }\n  & a:hover, & a:focus {\n    color: ${theme.linkHover};\n    --icon-color: ${theme.linkHover};\n    text-decoration: underline;\n  }\n`);\n\nexport function inlineMarkdown(markdownObs: BindableValue<string>): DomContents {\n  return markdown(markdownObs, { inline: true });\n}\n\nfunction getMarkdownValue(markdownValue: string, options: { inline?: boolean } = {}): DomContents {\n  const html = options.inline ?\n    marked.parseInline(markdownValue, { async: false }) :\n    marked(markdownValue, { async: false });\n  return sanitizeHTMLIntoDOM(html);\n}\n\n/**\n * Removes all links from markdown text replacing them with the plain label.\n */\nexport function stripLinks(markdownText: string) {\n  // This regex captures the link text in a form [......](......), it matches all new lines characters, even\n  // though markdown will not render them as links. For example [link\\n\\nlink](https://example.com) will be\n  // rendered as plain text.\n  const regex = /\\[(.*?)\\]\\(.*?\\)/gs;\n  return markdownText.replace(regex, \"$1\");\n}\n"
  },
  {
    "path": "app/client/lib/nameUtils.ts",
    "content": "/**\n * We allow alphanumeric characters and certain common whitelisted characters (except at the start),\n * plus everything non-ASCII (for non-English alphabets, which we want to allow but it's hard to be\n * more precise about what exactly to allow).\n */\n// eslint-disable-next-line no-control-regex\nconst VALID_NAME_REGEXP = /^(\\w|[^\\u0000-\\u007F])(\\w|[- ./'\"()]|[^\\u0000-\\u007F])*$/;\n\n/**\n * Test name against various rules to check if it is a valid username.\n */\nexport function checkName(name: string): boolean {\n  return VALID_NAME_REGEXP.test(name);\n}\n"
  },
  {
    "path": "app/client/lib/pausableObs.ts",
    "content": "import { IDisposableOwner, Observable } from \"grainjs\";\n\nexport interface PausableObservable<T> extends Observable<T> {\n  pause(shouldPause?: boolean): void;\n}\n\n/**\n * Creates and returns an `Observable` that can be paused, effectively causing all\n * calls to `set` to become noops until unpaused, at which point the last value\n * passed to set, if any, will be applied.\n *\n * NOTE: It's only advisable to use this when there are no other alternatives; pausing\n * updates and notifications to subscribers increases the chances of introducing bugs.\n */\nexport function createPausableObs<T>(\n  owner: IDisposableOwner | null,\n  value: T,\n): PausableObservable<T> {\n  let _isPaused = false;\n  let _lastValue: T | undefined = undefined;\n  const obs = Observable.create<T>(owner, value);\n  const set = Symbol(\"set\");\n  return Object.assign(obs, {\n    pause(shouldPause: boolean = true) {\n      _isPaused = shouldPause;\n      if (shouldPause) {\n        _lastValue = undefined;\n      } else if (_lastValue) {\n        obs.set(_lastValue);\n        _lastValue = undefined;\n      }\n    },\n    [set]: obs.set,\n    set(val: T) {\n      _lastValue = val;\n      if (_isPaused) { return; }\n\n      this[set](val);\n    },\n  });\n}\n"
  },
  {
    "path": "app/client/lib/popupControl.ts",
    "content": "/**\n *\n * Returns a popup control allowing to open/close a popup using as content the element returned by\n * the given func. Note that the `trigger` option is ignored by this function and that the default\n * of the `attach` option is `body` instead of `null`.\n *\n * It allows you to bind the creation of the popup to a menu item as follow:\n *   const ctl = popupControl(triggerElem, (ctl) => buildDom(ctl));\n *   ...\n *   menuItem(elem => ctl.open(), 'do stuff...')\n */\n\nimport { domDispose } from \"grainjs\";\nimport { IOpenController, IPopupDomCreator, IPopupOptions, PopupControl } from \"popweasel\";\n\nexport function popupControl(reference: Element, domCreator: IPopupDomCreator, options: IPopupOptions): PopupControl {\n  function openFunc(openCtl: IOpenController) {\n    const content = domCreator(openCtl);\n    function dispose() { domDispose(content); }\n    return { content, dispose };\n  }\n\n  const ctl = PopupControl.create(null);\n\n  ctl.attachElem(reference, openFunc, {\n    attach: \"body\",\n    boundaries: \"viewport\",\n    ...options,\n    trigger: undefined,\n  });\n\n  return ctl;\n}\n"
  },
  {
    "path": "app/client/lib/popupUtils.ts",
    "content": "import { dom, Holder, IDisposable, MultiHolder } from \"grainjs\";\n\n/**\n * Overrides the cursor style for the entire document.\n * @returns {Disposable} - a Disposable that restores the cursor style to its original value.\n */\nexport function documentCursor(type: \"ns-resize\" | \"grabbing\"): IDisposable {\n  const cursorStyle: HTMLStyleElement = document.createElement(\"style\");\n  cursorStyle.innerHTML = `*{cursor: ${type}!important;}`;\n  cursorStyle.id = \"cursor-style\";\n  document.head.appendChild(cursorStyle);\n  const cursorOwner = {\n    dispose() {\n      if (this.isDisposed()) { return; }\n      document.head.removeChild(cursorStyle);\n    },\n    isDisposed() {\n      return !cursorStyle.isConnected;\n    },\n  };\n  return cursorOwner;\n}\n\n/**\n * Helper function to create a movable element.\n * @param options Handlers for the movable element.\n */\nexport function movable<T>(options: {\n  onMove: (dx: number, dy: number, state: T) => void,\n  onStart: () => T,\n  onEnd?: () => void,\n}) {\n  return (el: HTMLElement) => {\n    // Remember the initial position of the mouse.\n    let startX = 0;\n    let startY = 0;\n    dom.onElem(el, \"mousedown\", (md) => {\n      // Only handle left mouse button.\n      if (md.button !== 0) { return; }\n      startX = md.clientX;\n      startY = md.clientY;\n      const state = options.onStart();\n\n      // We create a holder first so that we can dispose elements earlier on mouseup, and have a fallback\n      // in case of a situation when the dom is removed before mouseup.\n      const holder = new Holder();\n      const owner = MultiHolder.create(holder);\n      dom.autoDisposeElem(el, holder);\n\n      owner.autoDispose(dom.onElem(document, \"mousemove\", (mv) => {\n        const dx = mv.clientX - startX;\n        const dy = mv.clientY - startY;\n        options.onMove(dx, dy, state);\n      }));\n      owner.autoDispose(dom.onElem(document, \"mouseup\", () => {\n        options.onEnd?.();\n        holder.clear();\n      }));\n      owner.autoDispose(documentCursor(\"ns-resize\"));\n      md.stopPropagation();\n      md.preventDefault();\n    }, { useCapture: true });\n  };\n}\n"
  },
  {
    "path": "app/client/lib/sanitizeUrl.ts",
    "content": "import DOMPurify from \"dompurify\";\n\n// Export dependencies for stubbing in tests.\nexport const Deps = { DOMPurify };\n\n/**\n * Returns the provided URL if it is valid and safe to use in\n * HTTP-only contexts, such as form redirects and custom widget\n * URLs.\n *\n * Returns `null` if the URL is invalid or unsafe.\n *\n * For sanitizing hyperlink URLs, such as those used by `a`\n * elements, see `sanitizeLinkUrl`.\n */\nexport function sanitizeHttpUrl(url: string): string | null {\n  try {\n    const parsedUrl = new URL(url);\n    if (![\"http:\", \"https:\"].includes(parsedUrl.protocol)) {\n      return null;\n    }\n\n    return parsedUrl.href;\n  } catch (e) {\n    return null;\n  }\n}\n\n/**\n * Returns the provided URL if it is valid and safe to use for hyperlinks,\n * such as those used by `a` elements. This includes URLs prefixed with\n * `http[s]:`, `mailto:`, and `tel:`, and excludes URLs prefixed with\n * `javascript:`.\n *\n * Returns `null` if the URL is invalid or unsafe.\n *\n * For sanitizing HTTP-only URLs, such as those used for redirects, see\n * `sanitizeHttpUrl`.\n */\nexport function sanitizeLinkUrl(url: string): string | null {\n  return Deps.DOMPurify.isValidAttribute(\"a\", \"href\", url) ? url : null;\n}\n"
  },
  {
    "path": "app/client/lib/sessionObs.ts",
    "content": "/**\n * createSessionObs() creates an observable tied to window.sessionStorage, i.e. preserved for the\n * lifetime of a browser tab for the current origin.\n */\nimport { getSessionStorage } from \"app/client/lib/storage\";\nimport { safeJsonParse } from \"app/common/gutil\";\n\nimport { IDisposableOwner, Observable } from \"grainjs\";\n\nexport interface SessionObs<T> extends Observable<T> {\n  pauseSaving(yesNo: boolean): void;\n}\n\n/**\n * Creates and returns an Observable tied to sessionStorage, to make its value stick across\n * reloads and navigation, but differ across browser tabs. E.g. whether a side pane is open.\n *\n * The `key` isn't visible to the user, so pick any unique string name. You may include the\n * docId into the key, to remember a separate value for each doc.\n *\n * To use it, you must specify a default, and a validation function: this module exposes a few\n * helpful ones. Some examples:\n *\n *    panelWidth = createSessionObs(owner, \"panelWidth\", 240, isNumber);  // Has type Observable<number>\n *\n *    import {StringUnion} from 'app/common/StringUnion';\n *    const SomeTab = StringUnion(\"foo\", \"bar\", \"baz\");\n *    tab = createSessionObs(owner, \"tab\", \"baz\", SomeTab.guard);  // Type Observable<\"foo\"|\"bar\"|\"baz\">\n *\n * You can disable saving to sessionStorage:\n *    panelWidth.pauseSaving(true);\n *    doStuff();\n *    panelWidth.pauseSaving(false);\n *\n */\nexport function createSessionObs<T>(\n  owner: IDisposableOwner | null,\n  key: string,\n  _default: T,\n  isValid: (val: any) => val is T,\n): SessionObs<T> {\n  function fromString(value: string | null): T {\n    const parsed = value == null ? null : safeJsonParse(value, null);\n    return isValid(parsed) ? parsed : _default;\n  }\n  function toString(value: T): string | null {\n    return value === _default || !isValid(value) ? null : JSON.stringify(value);\n  }\n  let _pauseSaving = false;\n  const storage = getSessionStorage();\n  const obs = Observable.create<T>(owner, fromString(storage.getItem(key)));\n  obs.addListener((value: T) => {\n    if (_pauseSaving) { return; }\n    const stored = toString(value);\n    if (stored == null) {\n      storage.removeItem(key);\n    } else {\n      storage.setItem(key, stored);\n    }\n  });\n  return Object.assign(obs, { pauseSaving(yesNo: boolean) { _pauseSaving = yesNo; } });\n}\n\n/** Helper functions to check simple types, useful for the `isValid` argument to createSessionObs. */\nexport function isNumber(t: any): t is number { return typeof t === \"number\"; }\nexport function isBoolean(t: any): t is boolean { return typeof t === \"boolean\"; }\nexport function isString(t: any): t is string { return typeof t === \"string\"; }\n"
  },
  {
    "path": "app/client/lib/simpleList.ts",
    "content": "/**\n * SimpleList is a simple collection of item. Besides holding the items, it also knows which item is\n * selected, and allows selection via keyboard. In particular simpleList does not steal focus from\n * the trigger element, which makes it suitable to show a list of items next to an input element\n * while user is typing without interfering.\n *\n * const array = observable([{label: 'foo': value: 0, {label: 'bar', value: 1}]);\n * const ctl = popupControl(elem, ctl => SimpleList.create(null, ctl, array, action));\n *\n * // Enable keyboard navigation by listening to keys on the element that has focus.\n * ctl.listenKeys(elem)\n *\n * // toggle popup\n * dom('input', dom.on('click', () => ctl.toggle()));\n */\nimport { kbFocusHighlighterClass } from \"app/client/components/KeyboardFocusHighlighter\";\nimport { attachMouseOverOnMove, findAncestorChild } from \"app/client/lib/domUtils\";\nimport { menuCssClass, menuItem } from \"app/client/ui2018/menus\";\n\nimport { Disposable, dom, DomArg, Observable, styled } from \"grainjs\";\nimport { cssMenu, cssMenuItem, cssMenuWrap, getOptionFull, IOpenController, IOption } from \"popweasel\";\n\nexport type { IOption, IOptionFull } from \"popweasel\";\nexport { getOptionFull } from \"popweasel\";\n\nexport interface ISimpleListOpt<T, U extends IOption<T> = IOption<T>> {\n  matchTriggerElemWidth?: boolean;\n  headerDom?(): DomArg<HTMLElement>;\n  renderItem?(item: U): DomArg<HTMLElement>;\n}\n\nexport class SimpleList<T, U extends IOption<T> = IOption<T>> extends Disposable {\n  public readonly content: HTMLElement;\n  private _menuContent: HTMLElement;\n  private _selected: HTMLElement;\n  private _selectedIndex: number = -1;\n  private _mouseOver: { reset(): void };\n\n  constructor(private _ctl: IOpenController,\n    private _items: Observable<U[]>,\n    private _action: (value: T) => void,\n    opt: ISimpleListOpt<T, U> = {}) {\n    super();\n    const renderItem = opt.renderItem || ((item: U) => getOptionFull(item).label);\n    this.content = cssMenuWrap(\n      dom(\"div\",\n        (elem) => {\n          if (opt.matchTriggerElemWidth) {\n            const style = elem.style;\n            style.minWidth = _ctl.getTriggerElem().getBoundingClientRect().width + \"px\";\n            style.marginLeft = \"0px\";\n            style.marginRight = \"0px\";\n          }\n        },\n        { class: menuCssClass + \" grist-floating-menu \" + kbFocusHighlighterClass },\n        cssMenu.cls(\"\"),\n        cssMenuExt.cls(\"\"),\n        opt.headerDom?.(),\n        this._menuContent = cssMenuList(\n          dom.forEach(this._items, (i) => {\n            const item = getOptionFull(i);\n            return cssOptionRow(\n              { class: menuItem.className + \" \" + cssMenuItem.className },\n              dom.on(\"click\", () => this._doAction(item.value)),\n              renderItem(i),\n              dom.cls(\"disabled\", Boolean(item.disabled)),\n              dom.data(\"itemValue\", item.value),\n            );\n          }),\n        ),\n      ),\n      dom.on(\"mouseleave\", _ev => this.setSelected(-1)),\n    );\n    this.autoDispose(_items.addListener(() => this._update()));\n    this._mouseOver = attachMouseOverOnMove(\n      this._menuContent,\n      ev => this.setSelected(this._findTargetItem(ev.target)),\n    );\n    this._update();\n  }\n\n  public listenKeys(elem: HTMLElement) {\n    this.autoDispose(dom.onKeyElem(elem, \"keydown\", {\n      Escape: () => this._ctl.close(),\n      ArrowDown: () => this.setSelected(this._getNextSelectable(1)),\n      ArrowUp: () => this.setSelected(this._getNextSelectable(-1)),\n      Enter: () => this._doAction(this._getSelectedData()),\n    }));\n  }\n\n  // When the selected element changes, update the classes of the formerly and newly-selected\n  // elements.\n  public setSelected(index: number) {\n    const elem = (this._menuContent.children[index] as HTMLElement) || null;\n    const prev = this._selected;\n    if (elem !== prev) {\n      const clsName = cssMenuItem.className + \"-sel\";\n      if (prev) { prev.classList.remove(clsName); }\n      if (elem) {\n        elem.classList.add(clsName);\n        elem.scrollIntoView({ block: \"nearest\" });\n      }\n    }\n    this._selected = elem;\n    this._selectedIndex = elem ? index : -1;\n  }\n\n  private _update() {\n    this._mouseOver?.reset();\n  }\n\n  private _findTargetItem(target: EventTarget | null): number {\n    // Find immediate child of this._menuContent which is an ancestor of ev.target.\n    const elem = findAncestorChild(this._menuContent, target as Element | null);\n    if (elem?.classList.contains(\"disabled\")) { return -1; }\n    return Array.prototype.indexOf.call(this._menuContent.children, elem);\n  }\n\n  private _getSelectedData() {\n    return this._selected ? dom.getData(this._selected, \"itemValue\") : null;\n  }\n\n  private _doAction(value: T | null) {\n    // If value is null, simply close the menu. This happens when pressing enter with no element\n    // selected.\n    if (value !== null) { this._action(value); }\n    this._ctl.close();\n  }\n\n  private _getNext(index: number, step: 1 | -1): number {\n    // Pretend there is an extra element at the end to mean \"nothing selected\".\n    const xsize = this._items.get().length + 1;\n    const next = (index + step + xsize) % xsize;\n    return (next === xsize - 1) ? -1 : next;\n  }\n\n  private _getNextSelectable(step: 1 | -1): number {\n    let next = this._getNext(this._selectedIndex, step);\n    while (this._menuContent.children[next]?.classList.contains(\"disabled\")) {\n      next = this._getNext(next, step);\n    }\n    return next;\n  }\n}\nconst cssMenuList = styled(\"ul\", `\n  overflow: auto;\n  list-style: none;\n  outline: none;\n  padding: 0;\n  width: 100%;\n  margin: 0;\n`);\nconst cssOptionRow = styled(\"li\", `\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: block;\n`);\nconst cssMenuExt = styled(\"div\", `\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n`);\n"
  },
  {
    "path": "app/client/lib/sortUtil.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { ClientColumnGetters } from \"app/client/models/ClientColumnGetters\";\nimport { ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport * as rowset from \"app/client/models/rowset\";\nimport { MANUALSORT } from \"app/common/gristTypes\";\nimport { SortFunc } from \"app/common/SortFunc\";\nimport { Sort } from \"app/common/SortSpec\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\n\nimport * as ko from \"knockout\";\nimport range from \"lodash/range\";\n\n/**\n * Adds a column to the given sort spec, replacing its previous occurrence if\n * it's already in the sort spec.\n */\nexport function addToSort(sortSpecObs: ko.Observable<Sort.SortSpec>, colRef: number, direction: -1 | 1) {\n  const spec = sortSpecObs.peek();\n  const index = Sort.findColIndex(spec, colRef);\n  const withDirection = Sort.setColDirection(colRef, direction);\n  if (index !== -1) {\n    spec.splice(index, 1, withDirection);\n  } else {\n    spec.push(withDirection);\n  }\n  sortSpecObs(spec);\n}\n\nexport function sortBy(sortSpecObs: ko.Observable<Sort.SortSpec>, colRef: number, direction: -1 | 1) {\n  let spec = sortSpecObs.peek();\n  const colSpec = Sort.findCol(spec, colRef) ?? colRef;\n  spec = [Sort.setColDirection(colSpec, direction)];\n  sortSpecObs(spec);\n}\n\n// Updates the manual sort positions to the positions currently displayed in the view, sets the\n// view's default sort spec to be manual sort and broadcasts these changes.\n// This is excel/google sheets' sort behavior.\nexport async function updatePositions(gristDoc: GristDoc, section: ViewSectionRec): Promise<void> {\n  const tableId = section.table.peek().tableId.peek();\n  const tableModel = gristDoc.getTableModel(tableId);\n\n  // Build a sorted array of rowIds the way a view would, using the active sort spec. We just need\n  // the sorted list, and can dispose the observable array immediately.\n  const sortFunc = new SortFunc(new ClientColumnGetters(tableModel, { unversioned: true }));\n  sortFunc.updateSpec(section.activeDisplaySortSpec.peek());\n  const sortedRows = rowset.SortedRowSet.create(\n    null,\n    (a: UIRowId, b: UIRowId) => sortFunc.compare(a as number, b as number),\n    tableModel.tableData,\n  );\n  sortedRows.subscribeTo(tableModel);\n  const sortedRowIds = sortedRows.getKoArray().peek().slice(0);\n  sortedRows.dispose();\n\n  // The action just assigns consecutive positions to the sorted rows.\n  const colInfo = { [MANUALSORT]: range(0, sortedRowIds.length) };\n  await gristDoc.docData.sendActions(\n    [\n      // Update row positions and clear the saved sort spec as a single action bundle.\n      [\"BulkUpdateRecord\", tableId, sortedRowIds, colInfo],\n      [\"UpdateRecord\", \"_grist_Views_section\", section.getRowId(), { sortColRefs: \"[]\" }],\n    ],\n    `Updated table ${tableId} row positions.`,\n  );\n  // Finally clear out the local sort spec.\n  section.activeSortJson.revert();\n}\n"
  },
  {
    "path": "app/client/lib/storage.ts",
    "content": "/**\n * Expose localStorage and sessionStorage with fallbacks for cases when they don't work (e.g.\n * cross-domain embeds in Firefox and Safari).\n *\n * Usage:\n *    import {getStorage, getSessionStorage} from 'app/client/lib/storage';\n *    ... use getStorage() in place of localStorage...\n *    ... use getSessionStorage() in place of sessionStorage...\n */\n\n/**\n * Returns localStorage if functional, or sessionStorage, or an in-memory storage. The fallbacks\n * help with tests, and when Grist is embedded.\n */\nexport function getStorage(): Storage {\n  _storage ??= testStorage(\"localStorage\") || getSessionStorage();\n  return _storage;\n}\n\n/**\n * Return window.sessionStorage, or when not available, an in-memory storage.\n */\nexport function getSessionStorage(): Storage {\n  // If can't use sessionStorage, fall back to a Map-based non-persistent implementation.\n  _sessionStorage ??= testStorage(\"sessionStorage\") || createInMemoryStorage();\n  return _sessionStorage;\n}\n\nlet _storage: Storage | undefined;\nlet _sessionStorage: Storage | undefined;\n\n/**\n * Returns the result of window[storageName] if storage is functional, or null otherwise. In some\n * cases (e.g. when embedded), using localStorage may throw errors, in which case we return null.\n * This is similar to the approach taken by store.js.\n */\nfunction testStorage(storageName: \"localStorage\" | \"sessionStorage\"): Storage | null {\n  try {\n    const testStr = \"__localStorage_test\";\n    const storage = window[storageName];\n    storage.setItem(testStr, testStr);\n    const ok = (storage.getItem(testStr) === testStr);\n    storage.removeItem(testStr);\n    if (ok) {\n      return storage;\n    }\n  } catch (e) {\n    // Fall through\n  }\n  console.warn(`${storageName} is not available; will use fallback`);\n  return null;\n}\n\nfunction createInMemoryStorage(): Storage {\n  const values = new Map<string, string>();\n  return {\n    setItem(key: string, val: string) { values.set(key, val); },\n    getItem(key: string) { return values.get(key) ?? null; },\n    removeItem(key: string) { values.delete(key); },\n    clear() { values.clear(); },\n    get length() { return values.size; },\n    key(index: number): string | null { throw new Error(\"Not implemented\"); },\n  };\n}\n"
  },
  {
    "path": "app/client/lib/tableUtil.ts",
    "content": "import { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { simpleStringHash } from \"app/client/lib/textUtils\";\nimport TableModel from \"app/client/models/TableModel\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { tsvEncode } from \"app/common/tsvFormat\";\n\nimport { dom } from \"grainjs\";\nimport zipObject from \"lodash/zipObject\";\n\nimport type { CopySelection } from \"app/client/components/CopySelection\";\nimport type { KoArray } from \"app/client/lib/koArray\";\nimport type { ViewFieldRec } from \"app/client/models/DocModel\";\nimport type { BulkColValues, BulkUpdateRecord } from \"app/common/DocActions\";\nimport type { TableData } from \"app/common/TableData\";\n\nconst G = getBrowserGlobals(\"document\", \"DOMParser\");\n\n/**\n * Returns a sorted array of parentPos values for a viewField to be inserted just before index.\n * @param {koArray} viewFields - koArray of viewFields\n * @{param} {number} index - index in viewFields at which to insert the new fields\n * @{param} {number} numInserts - number of new fields to insert\n */\nexport function fieldInsertPositions(viewFields: KoArray<ViewFieldRec>, index: number, numInserts: number = 1,\n): (number | null)[] {\n  const rightPos = (index < viewFields.peekLength) ? viewFields.at(index)!.parentPos() : null;\n  return Array(numInserts).fill(rightPos);\n}\n\n/**\n * Returns tsv formatted values from TableData at the given rowIDs and columnIds.\n * @param {TableData} tableData - the table containing the values to convert\n * @param {CopySelection} selection - a CopySelection instance\n * @return {String}\n **/\nexport function makePasteText(tableData: TableData, selection: CopySelection, includeColHeaders: boolean) {\n  // tsvEncode expects data as a 2-d array with each a array representing a row\n  // i.e. [[\"1-1\", \"1-2\", \"1-3\"],[\"2-1\", \"2-2\", \"2-3\"]]\n  const result = [];\n  if (includeColHeaders) {\n    result.push(selection.fields.map(f => f.label()));\n  }\n  result.push(...selection.rowIds.map(rowId =>\n    selection.columns.map(col => col.fmtGetter(rowId))));\n  return tsvEncode(result);\n}\n\n/**\n * Hash of the current docId to allow checking if copying and pasting is happening in the same document,\n * without leaking the actual docId which may allow others to access the document.\n */\nexport function getDocIdHash(): string | undefined {\n  // We might not have global gristDocPageModel (e.g. for virtual tables).\n  const docId: string | undefined = window.gristDocPageModel?.currentDocId.get();\n  return docId && simpleStringHash(docId);\n}\n\n/**\n * Returns an html table of containing the cells denoted by the cross product of\n * the given rows and columns, styled by the given table/row/col style dictionaries.\n * @param {TableData} tableData - the table containing the values denoted by the grid selection\n * @param {CopySelection} selection - a CopySelection instance\n * @param {Boolean} includeColHeaders - whether to include a column header row\n * @return {String} The html for a table containing the given data.\n **/\nexport function makePasteHtml(tableData: TableData, selection: CopySelection, includeColHeaders: boolean): string {\n  const rowStyle = selection.rowStyle || {};    // Maps rowId to style object.\n  const colStyle = selection.colStyle || {};    // Maps colId to style object.\n\n  const elem = dom(\"table\",\n    { \"border\": \"1\", \"cellspacing\": \"0\", \"style\": \"white-space: pre\", \"data-grist-doc-id-hash\": getDocIdHash() || \"\" },\n    dom(\"colgroup\", selection.colIds.map((colId, idx) =>\n      dom(\"col\", {\n        \"style\": _styleAttr(colStyle[colId]),\n        \"data-grist-col-ref\": String(selection.colRefs[idx]),\n        \"data-grist-col-type\": tableData.getColType(colId),\n      }),\n    )),\n    // Include column headers if requested.\n    (includeColHeaders ?\n      dom(\"tr\", selection.fields.map(field => dom(\"th\", field.label()))) :\n      null\n    ),\n    // Fill with table cells.\n    selection.rowIds.map(rowId =>\n      dom(\"tr\",\n        { style: _styleAttr(rowStyle[rowId as number]) },\n        selection.columns.map((col) => {\n          const rawValue = col.rawGetter(rowId);\n          const fmtValue = col.fmtGetter(rowId);\n          const dataOptions = (rawValue === fmtValue) ? {} :\n            { \"data-grist-raw-value\": JSON.stringify(rawValue) };\n          return dom(\"td\", dataOptions, fmtValue);\n        }),\n      ),\n    ),\n  );\n  return elem.outerHTML;\n}\n\nexport interface RichPasteObject {\n  displayValue: string;\n  docIdHash?: string | null;\n  colType?: string | null;  // Column type of the source column.\n  colRef?: number | null;\n  rawValue?: unknown;     // Optional rawValue that should be used if colType matches destination.\n}\n\nexport type PasteData = string[][] | RichPasteObject[][] | File[][][];\n\n/**\n * Parses a 2-d array of objects from a text string containing an HTML table.\n * @param {string} data - String of an HTML table.\n * @return {Array<Array<RichPasteObj>>} - 2-d array of objects containing details of copied cells.\n */\nexport function parsePasteHtml(data: string): RichPasteObject[][] {\n  const parser: DOMParser = new G.DOMParser();\n  const doc = parser.parseFromString(data, \"text/html\");\n  const table = doc.querySelector(\"table\")!;\n  const docIdHash = table.getAttribute(\"data-grist-doc-id-hash\");\n\n  const cols = [...table.querySelectorAll(\"col\")];\n  const rows = [...table.querySelectorAll(\"tr\")];\n  const result = rows.map(row =>\n    Array.from(row.querySelectorAll(\"td, th\"), (cell, colIdx) => {\n      const col = cols[colIdx];\n      const colType = col?.getAttribute(\"data-grist-col-type\");\n      const colRef = col && Number(col.getAttribute(\"data-grist-col-ref\"));\n      const o: RichPasteObject = { displayValue: cell.textContent!, docIdHash, colType, colRef };\n\n      if (cell.hasAttribute(\"data-grist-raw-value\")) {\n        o.rawValue = safeJsonParse(cell.getAttribute(\"data-grist-raw-value\")!,\n          o.displayValue);\n      }\n\n      return o;\n    }))\n    .filter(row => (row.length > 0));\n  if (result.length === 0) {\n    throw new Error(\"Unable to parse data from text/html\");\n  }\n  return result;\n}\n\n// Helper function to add css style properties to an html tag\nfunction _styleAttr(style: object | undefined) {\n  if (typeof style !== \"object\") {\n    return \"\";\n  }\n  return Object.entries(style).map(([prop, value]) => `${prop}: ${value};`).join(\" \");\n}\n\n/**\n* Given a selection object, creates a action to set all references in the object to the empty string.\n* @param {Object} selection - an object with a list of selected row Ids, selected column Ids, a list of\n* column metaRowModels and other information about the currently selected cells.\n* See GridView.js getSelection and DetailView.js getSelection.\n* @returns {Object} BulkUpdateRecord action\n*/\nexport function makeDeleteAction(selection: CopySelection): BulkUpdateRecord | null {\n  // If the selection includes the \"new\" row, ignore that one.\n  const rowIds = selection.rowIds.filter((r): r is number => (typeof r === \"number\"));\n  if (rowIds.length === 0) {\n    return null;\n  }\n  const blankRow = rowIds.map(() => \"\");\n\n  const colIds = selection.fields\n    .filter(field => !field.column().isRealFormula() && !field.disableEditData())\n    .map(field => field.colId());\n\n  // Get the tableId from the first selected column.\n  const tableId = selection.fields[0].column().table().tableId();\n\n  if (colIds.length === 0) {\n    return null;\n  }\n  return [\"BulkUpdateRecord\", tableId, rowIds,\n    zipObject(colIds, colIds.map(() => blankRow))];\n}\n\n/**\n * Fills currently selected grid with the contents of the top row in that selection.\n */\nexport function fillSelectionDown(selection: CopySelection, tableModel: TableModel) {\n  const rowIds = selection.rowIds.filter((r): r is number => (typeof r === \"number\"));\n  if (rowIds.length <= 1) {\n    return;\n  }\n  const nonFormulaColumns = selection.fields.map(f => f.column.peek()).filter(col => !col.isFormula.peek());\n  if (nonFormulaColumns.length === 0) {\n    return;\n  }\n  const colInfo: BulkColValues = {};\n  for (const col of nonFormulaColumns) {\n    const colId = col.colId.peek();\n    const val = tableModel.tableData.getValue(rowIds[0], colId)!;\n    colInfo[colId] = rowIds.map(() => val);\n  }\n  return tableModel.sendTableAction([\"BulkUpdateRecord\", rowIds, colInfo]);\n}\n"
  },
  {
    "path": "app/client/lib/telemetry.ts",
    "content": "import { logError } from \"app/client/models/errors\";\nimport { Level, TelemetryContracts, TelemetryEvent, TelemetryMetadataByLevel } from \"app/common/Telemetry\";\nimport { fetchFromHome, getGristConfig, pageHasHome } from \"app/common/urlUtils\";\n\nexport function logTelemetryEvent(event: TelemetryEvent, metadata?: TelemetryMetadataByLevel) {\n  if (!pageHasHome()) { return; }\n\n  const { telemetry } = getGristConfig();\n  if (!telemetry) { return; }\n\n  const { telemetryLevel } = telemetry;\n  if (Level[telemetryLevel] < TelemetryContracts[event].minimumTelemetryLevel) { return; }\n\n  fetchFromHome(\"/api/telemetry\", {\n    method: \"POST\",\n    body: JSON.stringify({\n      event,\n      metadata,\n    }),\n    credentials: \"include\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"X-Requested-With\": \"XMLHttpRequest\",\n    },\n  }).catch((e: Error) => {\n    console.warn(`Failed to log telemetry event ${event}`, e);\n    logError(e);\n  });\n}\n"
  },
  {
    "path": "app/client/lib/testState.ts",
    "content": "import { getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { TestState } from \"app/common/TestState\";\n\nconst G = getBrowserGlobals(\"window\");\n\nexport function setTestState(state: Partial<TestState>) {\n  if (!G.window.testGrist) {\n    G.window.testGrist = {};\n  }\n  Object.assign(G.window.testGrist, state);\n}\n\nexport function getTestState(): TestState {\n  if (!G.window.testGrist) {\n    G.window.testGrist = {};\n  }\n  return G.window.testGrist;\n}\n"
  },
  {
    "path": "app/client/lib/textUtils.ts",
    "content": "// There are many regex for matching URL, but non seem to be the correct solution.\n// Here we will use very fast and simple one.\n// Tested most of the regex solutions mentioned in this post\n// https://stackoverflow.com/questions/37684/how-to-replace-plain-urls-with-links.\n// The best one was http://alexcorvi.github.io/anchorme.js/, which still wasn't perfect.\n// The best non regex solution was https://github.com/Hypercontext/linkifyjs, but it feels a little too heavy.\n// Some examples why this is better or worse from other solution:\n/**\n\nFor 'http://www.uk,http://www.uk'\n'OurRegex' [ 'http://www.uk', 'http://www.uk' ]\n'Anchrome' [ 'http://www.uk,http://www.uk' ]\n'linkify' [ 'http://www.uk,http://www.uk' ]\n'url-regex' [ 'http://www.uk', 'http://www.uk' ]\n\nFor 'might.it be a link'\n'OurRegex' []\n'Anchrome' [ 'might.it' ]\n'linkify' [ 'http://might.it' ]\n'url-regex' []\n\nFor 'Is this correct.No it is not'\n'OurRegex' []\n'Anchrome' [ 'correct.No' ]\n'linkify' [ 'http://correct.No' ]\n'url-regex' []\n\nFor 'Link (in http://www.uk?)'\n'OurRegex' [ 'http://www.uk' ]\n'Anchrome' [ 'http://www.uk' ]\n'linkify' [ 'http://www.uk' ]\n'url-regex' [ 'http://www.uk?)' ]\n*/\n\n// Match http or https then domain name (with optional port) then any text that ends with letter or number.\nexport const urlRegex = /(https?:\\/\\/[A-Za-z\\d][A-Za-z\\d-.]*(?!\\.)(?::\\d+)?(?:\\/[^\\s]*)?[\\w\\d/])/;\n\n/**\n * Detects URLs in a text and returns list of tokens { value, isLink }\n */\nexport function findLinks(text: string): { value: string, isLink: boolean }[] {\n  if (!text) {\n    return [{ value: text, isLink: false }];\n  }\n  // urls will be at odd-number indices\n  return text.split(urlRegex).map((value, i) => ({ value, isLink: (i % 2) === 1 }));\n}\n\n/**\n * Based on https://stackoverflow.com/a/22429679/2482744\n * -----------------------------------------------------\n * Calculate a 32 bit FNV-1a hash\n * Found here: https://gist.github.com/vaiorabbit/5657561\n * Ref.: http://isthe.com/chongo/tech/comp/fnv/\n */\nexport function hashFnv32a(str: string): string {\n  let hval = 0x811c9dc5;\n  for (let i = 0; i < str.length; i++) {\n    hval ^= str.charCodeAt(i);\n    hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24);\n  }\n  // Convert to 8 digit hex string\n  return (\"0000000\" + (hval >>> 0).toString(16)).substr(-8);\n}\n\n/**\n * A poor man's hash for when proper crypto isn't worth it.\n */\nexport function simpleStringHash(str: string) {\n  let result = \"\";\n  // Crudely convert 32 bits to 128 bits to reduce collisions\n  for (let i = 0; i < 4; i++) {\n    result += hashFnv32a(result + str);\n  }\n  return result;\n}\n"
  },
  {
    "path": "app/client/lib/timeUtils.ts",
    "content": "import moment from \"moment\";\n\n/**\n * Given a UTC Date ISO 8601 string (the doc updatedAt string), gives a reader-friendly\n * relative time to now - e.g. 'yesterday', '2 days ago'.\n */\nexport function getTimeFromNow(utcDateISO: string): string;\n/**\n * Given a unix timestamp (in milliseconds), gives a reader-friendly\n * relative time to now - e.g. 'yesterday', '2 days ago'.\n */\nexport function getTimeFromNow(ms: number): string;\nexport function getTimeFromNow(isoOrTimestamp: string | number): string {\n  const time = moment.utc(isoOrTimestamp);\n  const now = moment();\n  const diff = now.diff(time, \"s\");\n  if (diff < 0 && diff > -60) {\n    // If the time appears to be in the future, but less than a minute\n    // in the future, chalk it up to a difference in time\n    // synchronization and don't claim the resource will be changed in\n    // the future.  For larger differences, just report them\n    // literally, there's a more serious problem or lack of\n    // synchronization.\n    return now.fromNow();\n  }\n  return time.fromNow();\n}\n"
  },
  {
    "path": "app/client/lib/trapTabKey.ts",
    "content": "/**\n * Code is authored by Kitty Giraudel for a11y-dialog https://github.com/KittyGiraudel/a11y-dialog, thanks to her!\n *\n * As keyboard-handling is very specific in Grist, we'd rather copy/paste some base code that can be easily modified,\n * rather than relying on a library.\n *\n * The MIT License (MIT)\n *\n * Copyright (c) 2025 Kitty Giraudel\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated\n * documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation\n * the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,\n * and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\n * TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL\n * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF\n * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n * IN THE SOFTWARE.\n */\nimport { isFocusable } from \"app/client/lib/isFocusable\";\n\n/**\n * Trap the tab key within the given element.\n *\n * This only wraps focus when we detect the user tabs out of bounds (tab on the last focusable element,\n * or shift+tab on the first focusable element). It doesn't force focus any other way, so it doesn't\n * interfere with potential popups appearing or anything else.\n */\nexport function trapTabKey(container: HTMLElement, tabEvent: KeyboardEvent) {\n  const [firstFocusableEl, lastFocusableEl] = getFocusableEdges(container);\n\n  const activeEl = getActiveEl();\n\n  // If the SHIFT key is pressed while tabbing (moving backwards) and the\n  // currently focused item is the first one, move the focus to the last\n  // focusable item from the element\n  if (tabEvent.shiftKey && activeEl === firstFocusableEl) {\n    lastFocusableEl?.focus();\n    tabEvent.preventDefault();\n  } else if (!tabEvent.shiftKey && activeEl === lastFocusableEl) {\n    // If the SHIFT key is not pressed (moving forwards) and the currently focused\n    // item is the last one, move the focus to the first focusable item from the element\n    firstFocusableEl?.focus();\n    tabEvent.preventDefault();\n  }\n}\n\nfunction getFocusableEdges(el: HTMLElement) {\n  // Check for a focusable element within the subtree of the given element.\n  const firstEl = findFocusableEl(el, true);\n\n  // Only if we find the first element do we need to look for the last one. If\n  // there’s no last element, we set `lastEl` as a reference to `firstEl` so\n  // that the returned array is still always of length 2.\n  const lastEl = firstEl ? findFocusableEl(el, false) || firstEl : null;\n\n  return [firstEl, lastEl] as const;\n}\n\nfunction findFocusableEl(\n  el: HTMLElement,\n  forward: boolean,\n): HTMLElement | null {\n  // If we’re walking forward, check if this element is focusable, and return it\n  // immediately if it is.\n  if (forward && isFocusable(el)) { return el; }\n\n  // We should only search the subtree of this element if it can have focusable\n  // children.\n  if (canHaveFocusableChildren(el)) {\n    // Start walking the DOM tree, looking for focusable elements.\n    if (el.shadowRoot) {\n      // Case 1: If this element has a shadow root, search it recursively.\n\n      // Descend into this subtree.\n      let next = getNextChildEl(el.shadowRoot, forward);\n\n      // Traverse the siblings, searching the subtree of each one for focusable\n      // elements.\n      while (next) {\n        const focusableEl = findFocusableEl(next as HTMLElement, forward);\n        if (focusableEl) { return focusableEl; }\n        next = getNextSiblingEl(next as HTMLElement, forward);\n      }\n    } else if (el.localName === \"slot\") {\n      // Case 2: If this element is a slot for a Custom Element, search its\n      // assigned elements recursively.\n\n      const assignedElements = (el as HTMLSlotElement).assignedElements({\n        flatten: true,\n      }) as HTMLElement[];\n      if (!forward) { assignedElements.reverse(); }\n\n      for (const assignedElement of assignedElements) {\n        const focusableEl = findFocusableEl(assignedElement, forward);\n        if (focusableEl) { return focusableEl; }\n      }\n    } else {\n      // Case 3: this is a regular Light DOM element. Search its subtree.\n\n      // Descend into this subtree.\n      let next = getNextChildEl(el, forward);\n\n      // Traverse siblings, searching the subtree of each one\n      // for focusable elements.\n      while (next) {\n        const focusableEl = findFocusableEl(next as HTMLElement, forward);\n        if (focusableEl) { return focusableEl; }\n        next = getNextSiblingEl(next as HTMLElement, forward);\n      }\n    }\n  }\n\n  // If we’re walking backward, we want to check the element’s entire subtree\n  // before checking the element itself. If this element is focusable, return\n  // it.\n  if (!forward && isFocusable(el)) { return el; }\n\n  return null;\n}\n\n/**\n * Get the active element, accounting for Shadow DOM subtrees.\n * @author Cory LaViska\n * @see: https://www.abeautifulsite.net/posts/finding-the-active-element-in-a-shadow-root/\n */\nexport function getActiveEl(\n  root: Document | ShadowRoot = document,\n): Element | null {\n  const activeEl = root.activeElement;\n\n  if (!activeEl) { return null; }\n\n  // If there’s a shadow root, recursively find the active element within it.\n  // If the recursive call returns null, return the active element\n  // of the top-level Document.\n  if (activeEl.shadowRoot) { return getActiveEl(activeEl.shadowRoot) || document.activeElement; }\n\n  // If not, we can just return the active element\n  return activeEl;\n}\n\n/**\n * Determine if an element can have focusable children. Useful for bailing out\n * early when walking the DOM tree.\n * @example\n * This div is inert, so none of its children can be focused, even though they\n * meet our criteria for what is focusable. Once we check the div, we can skip\n * the rest of the subtree.\n * ```html\n * <div inert>\n *   <button>Button</button>\n *   <a href=\"#\">Link</a>\n * </div>\n * ```\n */\nfunction canHaveFocusableChildren(el: HTMLElement) {\n  // The browser will never send focus into a Shadow DOM if the host element\n  // has a negative tabindex. This applies to both slotted Light DOM Shadow DOM\n  // children\n  if (el.shadowRoot && el.getAttribute(\"tabindex\") === \"-1\") { return false; }\n\n  // Elemments matching this selector are either hidden entirely from the user,\n  // or are visible but unavailable for interaction. Their descentants can never\n  // receive focus.\n  return !el.matches(\":disabled,[hidden],[inert]\");\n}\n\nfunction getNextChildEl(el: ParentNode, forward: boolean) {\n  return forward ? el.firstElementChild : el.lastElementChild;\n}\n\nfunction getNextSiblingEl(el: HTMLElement, forward: boolean) {\n  return forward ? el.nextElementSibling : el.previousElementSibling;\n}\n"
  },
  {
    "path": "app/client/lib/uploads.ts",
    "content": "/**\n * This module contains several ways to create an upload on the server. In all cases, an\n * UploadResult is returned, with an uploadId which may be used in other server calls to identify\n * this upload.\n *\n * TODO: another proposed source for files is uploadUrl(url) which would fetch a file from URL and\n * upload, and if that fails due to CORS, would fetch the file on the server side instead.\n */\n\nimport { DocComm } from \"app/client/components/DocComm\";\nimport { getTestState } from \"app/client/lib/testState\";\nimport { UserError } from \"app/client/models/errors\";\nimport { FileDialogOptions, openFilePicker } from \"app/client/ui/FileDialog\";\nimport { GristLoadConfig } from \"app/common/gristUrls\";\nimport { byteString, safeJsonParse } from \"app/common/gutil\";\nimport { FetchUrlOptions, UPLOAD_URL_PATH, UploadResult } from \"app/common/uploads\";\nimport { docUrl } from \"app/common/urlUtils\";\n\nimport { basename } from \"path\";      // made available by webpack using path-browserify module.\n\nimport noop from \"lodash/noop\";\nimport trimStart from \"lodash/trimStart\";\n\ntype ProgressCB = (percent: number) => void;\n\nexport interface UploadOptions {\n  docWorkerUrl?: string;\n  sizeLimit?: \"import\" | \"attachment\";\n}\n\nexport interface SelectFileOptions extends UploadOptions {\n  multiple?: boolean;     // Whether multiple files may be selected.\n  extensions?: string[];  // Comma-separated list of extensions (with a leading period),\n  // e.g. [\".jpg\", \".png\"]\n}\n\n// This list coincides with the extensions defined in core/plugins/manifest.yml\nexport const EXTENSIONS_IMPORTABLE_WITHIN_DOC = [\".xlsx\", \".json\", \".csv\", \".tsv\", \".dsv\"];\n\nexport const EXTENSIONS_IMPORTABLE_AS_DOC = [\".grist\", \".csv\", \".tsv\", \".dsv\", \".txt\", \".xlsx\", \".xlsm\"];\n\n/**\n * Shows the file-picker dialog with the given options, and uploads the selected files. If under\n * electron, shows the native file-picker instead.\n *\n * If given, onProgress() callback will be called with 0 on initial call, and will go up to 100\n * after files are selected to indicate percentage of data uploaded.\n */\nexport async function selectFiles(options: SelectFileOptions,\n  onProgress: ProgressCB = noop): Promise<UploadResult | null> {\n  let result: UploadResult | null = null;\n  const electronSelectFiles: any = (window as any).electronSelectFiles;\n  if (typeof electronSelectFiles === \"function\") {\n    onProgress(0);\n    result = await electronSelectFiles(getElectronOptions(options));\n  } else {\n    const fileList = await selectPicker(options);\n    // start the progress bar only after the user selected the files\n    onProgress(0);\n    await maybeFakeSlowUploadsForTests();\n    result = await uploadFiles(fileList, options, onProgress);\n  }\n  onProgress(100);\n  return result;\n}\n\nexport async function selectPicker(options: SelectFileOptions) {\n  const files: File[] = await openFilePicker(getFileDialogOptions(options));\n  return files;\n}\n\n// Helper to convert SelectFileOptions to the browser's FileDialogOptions.\nfunction getFileDialogOptions(options: SelectFileOptions): FileDialogOptions {\n  const resOptions: FileDialogOptions = {};\n  if (options.multiple) {\n    resOptions.multiple = options.multiple;\n  }\n  if (options.extensions) {\n    resOptions.accept = options.extensions.join(\",\");\n  }\n  return resOptions;\n}\n\n// Helper to convert SelectFileOptions to electron's OpenDialogOptions.\nfunction getElectronOptions(options: SelectFileOptions) /* : OpenDialogOptions */ {\n  const resOptions /* : OpenDialogOptions */ = {\n    filters: [] as { name: string, extensions: any }[],\n    properties: [\"openFile\"],\n  };\n  if (options.extensions) {\n    // Electron does not expect leading period.\n    const extensions = options.extensions.map(e => trimStart(e, \".\"));\n    resOptions.filters.push({ name: \"Select files\", extensions });\n  }\n  if (options.multiple) {\n    resOptions.properties.push(\"multiSelections\");\n  }\n  return resOptions;\n}\n\n/**\n * Uploads a list of File objects to the server.\n */\nexport async function uploadFiles(\n  fileList: File[], options: UploadOptions, onProgress: ProgressCB = noop,\n): Promise<UploadResult | null> {\n  if (!fileList.length) { return null; }\n\n  const formData = new FormData();\n  for (const file of fileList) {\n    formData.append(\"upload\", file);\n  }\n\n  await maybeFakeSlowUploadsForTests();\n\n  // Check for upload limits.\n  const gristConfig: Partial<GristLoadConfig> = window.gristConfig || {};\n  const { maxUploadSizeImport, maxUploadSizeAttachment } = gristConfig;\n  if (options.sizeLimit === \"import\" && maxUploadSizeImport) {\n    // For imports, we limit the total upload size, but exempt .grist files from the upload limit.\n    // Grist docs can be uploaded to make copies or restore from backup, and may legitimately be\n    // very large (e.g. contain many attachments or on-demand tables).\n    const totalSize = fileList.reduce((acc, f) => acc + (f.name.endsWith(\".grist\") ? 0 : f.size), 0);\n    if (totalSize > maxUploadSizeImport) {\n      throw new UserError(`Imported files may not exceed ${byteString(maxUploadSizeImport)}`);\n    }\n  } else if (options.sizeLimit === \"attachment\" && maxUploadSizeAttachment) {\n    // For attachments, we limit the size of each attachment.\n    if (fileList.some(f => (f.size > maxUploadSizeAttachment))) {\n      throw new UserError(`Attachments may not exceed ${byteString(maxUploadSizeAttachment)}`);\n    }\n  }\n\n  return uploadFormData(docUrl(options.docWorkerUrl, UPLOAD_URL_PATH), formData, onProgress);\n}\n\n/**\n * POSTs the provided form data to the given endpoint.\n * Provides progress tracking, error handling and promises.\n * @param {string} url - Endpoint to send form data to.\n * @param {FormData} formData - Data to send\n * @param {ProgressCB} onProgress - Called periodically during the upload\n * @returns {Promise<any>} - Parsed JSON from the endpoint. Uses `any` as no validation is performed.\n */\nasync function uploadFormData(\n  url: string, formData: FormData, onProgress: ProgressCB = noop,\n): Promise<any> {\n  return new Promise<any>((resolve, reject) => {\n    const xhr = new XMLHttpRequest();\n    xhr.open(\"post\", url, true);\n    xhr.setRequestHeader(\"X-Requested-With\", \"XMLHttpRequest\");\n    xhr.withCredentials = true;\n    onProgress(0);\n    xhr.upload.addEventListener(\"progress\", (e) => {\n      if (e.lengthComputable) {\n        onProgress(e.loaded / e.total * 100);   // percentage complete\n      }\n    });\n    xhr.addEventListener(\"error\", (e: ProgressEvent) => {\n      console.warn(\"Upload error\", e); // The event does not seem to have any helpful info in it, to add to the message.\n      reject(new Error(\"Upload error\"));\n    });\n    xhr.addEventListener(\"load\", () => {\n      if (xhr.status !== 200) {\n        console.warn(\"Upload failed\", xhr.status, xhr.responseText);\n        const err = safeJsonParse(xhr.responseText, null);\n        reject(new UserError(\"Upload failed: \" + (err?.error || xhr.status)));\n      } else {\n        onProgress(100);\n        resolve(JSON.parse(xhr.responseText));\n      }\n    });\n    xhr.send(formData);\n  });\n}\n\n/**\n * Fetches resource from a url and returns an UploadResult. Tries to fetch from the client and\n * upload the file to the server. If unsuccessful, tries to fetch directly from the server. In both\n * case, it guesses the name of the file based on the response's content-type and the url.\n */\nexport async function fetchURL(\n  docComm: DocComm, url: string, options?: FetchUrlOptions, onProgress: ProgressCB = noop,\n): Promise<UploadResult> {\n  if (isDriveUrl(url)) {\n    // don't download from google drive, immediately fallback to server side.\n    return docComm.fetchURL(url, options);\n  }\n\n  let response: Response;\n  try {\n    response = await window.fetch(url);\n  } catch (err) {\n    console.log(`Could not fetch ${url} on the Client, falling back to server fetch: ${err.message}`,\n    );\n    return docComm.fetchURL(url, options);\n  }\n  // TODO: We should probably parse response.headers.get('content-disposition') when available\n  // (see content-disposition npm module).\n  const fileName = basename(url);\n  const mimeType = response.headers.get(\"content-type\");\n  const fileOptions = mimeType ? { type: mimeType } : {};\n  const fileObj = new File([await response.blob()], fileName, fileOptions);\n  const res = await uploadFiles([fileObj], { docWorkerUrl: docComm.docWorkerUrl }, onProgress);\n  return res!;\n}\n\nexport function isDriveUrl(url: string) {\n  if (!url) { return null; }\n  const match = /^https:\\/\\/(docs|drive).google.com\\/(spreadsheets|file)\\/d\\/([^/]*)/i.exec(url);\n  return !!match;\n}\n\nconst maybeFakeSlowUploadsForTests = (): Promise<void> => {\n  const fakeSlowUploads = getTestState().fakeSlowUploads;\n  if (fakeSlowUploads) {\n    return new Promise(resolve => setTimeout(resolve, 1200));\n  }\n  return Promise.resolve();\n};\n"
  },
  {
    "path": "app/client/lib/urlUtils.ts",
    "content": "import { parseFirstUrlPart } from \"app/common/gristUrls\";\nimport { addOrgToPath } from \"app/common/urlUtils\";\n\nexport interface URLOptions {\n  /**\n   * The base component of the URL.\n   *\n   * If an org is present in the path, it will be included in the path of\n   * the constructed URL.\n   *\n   * Defaults to `window.location.href`.\n   */\n  base?: string;\n  /**\n   * The hash component of the URL.\n   *\n   * If not set, the hash from {@link URLOptions.base} will be included\n   * in the constructed URL. A value of `null` or `\"\"` may be set to\n   * ensure the constructed URL does not include a hash.\n   */\n  hash?: string | null;\n  /**\n   * Params to include in the query string component of the URL.\n   *\n   * If not set, the query string from {@link URLOptions.base} will be\n   * included in the constructed URL. A value of `null` may be set to\n   * ensure the constructed URL does not include a query string.\n   */\n  searchParams?: URLSearchParams | null;\n}\n\n/**\n * Returns a URL to the given `path`.\n *\n * If {@link URLOptions.base} is not specified, the constructed URL will be\n * relative to the window location.\n *\n * Path accepts values with or without a leading \"/\". If {@link URLOptions.base}\n * includes an org in the path, it will be included in the constructed URL.\n *\n * Note: You should use `urlState` in `gristUrlState.ts` when constructing URLs\n * that should avoid reloading the page when not necessary. The URLs returned by\n * this function are only intended to be used in contexts involving a page reload\n * (e.g. login pages).\n */\nexport function buildURL(path: string, options: URLOptions = {}): URL {\n  const { base = window.location.href, hash, searchParams } = options;\n  const url = new URL(base);\n  url.pathname = addOrgToPath(\"\", base, true) + \"/\" + path.replace(/^\\//, \"\");\n  if (hash !== undefined) {\n    url.hash = hash ?? \"\";\n  }\n  if (searchParams !== undefined) {\n    url.search = searchParams?.toString() ?? \"\";\n  }\n  return url;\n}\n\nexport interface GetLoginOrSignupUrlOptions {\n  srcDocId?: string | null;\n  /** Defaults to the current URL. */\n  nextUrl?: string | null;\n}\n\n// Get URL for the login page.\nexport function getLoginUrl(options: GetLoginOrSignupUrlOptions = {}): string {\n  return getLoginPageUrl(\"login\", options);\n}\n\n// Get URL for the signup page.\nexport function getSignupUrl(options: GetLoginOrSignupUrlOptions = {}): string {\n  return getLoginPageUrl(\"signup\", options);\n}\n\n// Get URL for the logout page.\nexport function getLogoutUrl(): string {\n  return getLoginPageUrl(\"logout\");\n}\n\n// Get the URL that users are redirect to after deleting their account.\nexport function getAccountDeletedUrl(): string {\n  return getLoginPageUrl(\"account-deleted\", { nextUrl: \"\" });\n}\n\n// Get URL for the signin page.\nexport function getLoginOrSignupUrl(options: GetLoginOrSignupUrlOptions = {}): string {\n  return getLoginPageUrl(\"signin\", options);\n}\n\nexport function getWelcomeHomeUrl() {\n  const url = buildURL(\"/welcome/home\", {\n    hash: null,\n    searchParams: null,\n  });\n  return url.href;\n}\n\nconst FINAL_PATHS = [\"/signed-out\", \"/account-deleted\"];\n\n// Returns the relative URL (i.e. path) of the current page, except when it's the\n// \"/signed-out\" page or \"/account-deleted\", in which case it returns the home page (\"/\").\n// This is a good URL to use for a post-login redirect.\nfunction _getCurrentUrl(): string {\n  const { hash, pathname, search } = new URL(window.location.href);\n  if (FINAL_PATHS.some(final => pathname.endsWith(final))) { return \"/\"; }\n\n  return parseFirstUrlPart(\"o\", pathname).path + search + hash;\n}\n\n// Returns the URL for the given login page.\nfunction getLoginPageUrl(\n  page: \"login\" | \"logout\" | \"signin\" | \"signup\" | \"account-deleted\",\n  options: GetLoginOrSignupUrlOptions = {},\n): string {\n  const { srcDocId, nextUrl = _getCurrentUrl() } = options;\n  const startUrl = buildURL(`/${page}`, {\n    hash: null,\n    searchParams: null,\n  });\n  if (srcDocId) { startUrl.searchParams.set(\"srcDocId\", srcDocId); }\n  if (nextUrl) { startUrl.searchParams.set(\"next\", nextUrl); }\n  return startUrl.href;\n}\n"
  },
  {
    "path": "app/client/logo.css",
    "content": "#grist-logo-wrapper {\n  position: absolute;\n  width: 100vw;\n  height: 100vh;\n  display: flex;\n  background-color: var(--color-logo-bg);\n}\n\n.grist-logo {\n  background: var(--color-bg);\n  margin: auto;\n  padding: 20px 28px;\n}\n\n.grist-logo-grain {\n  display: inline-block;\n  width: 42px;\n  height: 40px;\n\n  margin: 1px;\n\n  border-radius: 22px 0 18px 0;\n}\n\n.grist-logo-grain.grain-flip {\n  border-radius: 0 22px 0 18px;\n}\n\n.grist-logo-grain.grain-empty {\n  visibility: hidden;\n}\n\n.grist-logo-grain.grain-col {\n  background: var(--color-logo-col);\n}\n\n.grist-logo-grain.grain-row {\n  background: var(--color-logo-row);\n}\n\n.grist-logo-grain.grain-cell {\n  background: var(--color-logo-cell);\n}\n\n.grist-logo-grain {\n  animation: spin-grain 3.2s linear infinite;\n}\n\n.grist-logo-grain.grain-2 { animation-delay: .4s; }\n.grist-logo-grain.grain-3 { animation-delay: .8s; }\n.grist-logo-grain.grain-4 { animation-delay: 1.2s; }\n.grist-logo-grain.grain-5 { animation-delay: 0s; }\n.grist-logo-grain.grain-6 { animation-delay: .2s; }\n.grist-logo-grain.grain-7 { animation-delay: .6s; }\n.grist-logo-grain.grain-8 { animation-delay: 1.0s; }\n.grist-logo-grain.grain-9 { animation-delay: 1.4s; }\n\n\n@keyframes spin-grain {\n  0% {\n    transform: rotateY(0deg);\n  }\n  25% {\n    transform: rotateY(180deg);\n  }\n  50% {\n    transform: rotateY(0deg);\n  }\n}\n"
  },
  {
    "path": "app/client/models/AdminChecks.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/errors\";\nimport { BootProbeIds, BootProbeInfo, BootProbeResult } from \"app/common/BootProbe\";\nimport { InstallAPI } from \"app/common/InstallAPI\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Disposable, Observable, UseCBOwner } from \"grainjs\";\n\nconst t = makeT(\"AdminChecks\");\n/**\n * Manage a collection of checks about the status of Grist, for\n * presentation on the admin panel or the boot page.\n */\nexport class AdminChecks {\n  // The back end will offer a set of probes (diagnostics) we\n  // can use. Probes have unique IDs.\n  public probes: Observable<BootProbeInfo[]>;\n\n  // Keep track of probe requests we are making, by probe ID.\n  private _requests: Map<string, AdminCheckRunner>;\n\n  // Keep track of probe results we have received, by probe ID.\n  private _results: Map<string, Observable<BootProbeResult>>;\n\n  constructor(private _parent: Disposable, private _installAPI: InstallAPI) {\n    this.probes = Observable.create(_parent, []);\n    this._results = new Map();\n    this._requests = new Map();\n  }\n\n  /**\n   * Fetch a list of available checks from the server.\n   */\n  public async fetchAvailableChecks() {\n    const config = getGristConfig();\n    const errMessage = config.errMessage;\n    if (!errMessage) {\n      const _probes = await this._installAPI.getChecks().catch(reportError);\n      if (!this._parent.isDisposed()) {\n        // Currently, probes are forbidden if not admin.\n        // TODO: May want to relax this to allow some probes that help\n        // diagnose some initial auth problems.\n        this.probes.set(_probes ? _probes.probes : []);\n      }\n      return _probes;\n    }\n    return [];\n  }\n\n  /**\n   * Request the result of one of the available checks. Returns information\n   * about the check and a way to observe the result when it arrives.\n   */\n  public requestCheck(probe: BootProbeInfo): AdminCheckRequest {\n    const { id } = probe;\n    let result = this._results.get(id);\n    if (!result) {\n      result = Observable.create(this._parent, { status: \"none\" });\n      this._results.set(id, result);\n    }\n    let request = this._requests.get(id);\n    if (!request) {\n      request = new AdminCheckRunner(this._installAPI, id, this._results, this._parent);\n      this._requests.set(id, request);\n    }\n    request.start();\n    return {\n      probe,\n      result,\n      details: probeDetails[id],\n    };\n  }\n\n  /**\n   * Request the result of a check, by its id.\n   */\n  public requestCheckById(use: UseCBOwner, id: BootProbeIds): AdminCheckRequest | undefined {\n    const probe = use(this.probes).find(p => p.id === id);\n    if (!probe) { return; }\n    return this.requestCheck(probe);\n  }\n}\n\n/**\n * Information about a check and a way to observe its result once available.\n */\nexport interface AdminCheckRequest {\n  probe: BootProbeInfo,\n  result: Observable<BootProbeResult>,\n  details: ProbeDetails,\n}\n\n/**\n * Manage a single check.\n */\nexport class AdminCheckRunner {\n  constructor(private _installAPI: InstallAPI,\n    public id: string,\n    public results: Map<string, Observable<BootProbeResult>>,\n    public parent: Disposable) {\n    this._installAPI.runCheck(id).then((result) => {\n      if (parent.isDisposed()) { return; }\n      const ob = results.get(id);\n      if (ob) {\n        ob.set(result);\n      }\n    }).catch(e => console.error(e));\n  }\n\n  public start() {\n    let result = this.results.get(this.id);\n    if (!result) {\n      result = Observable.create(this.parent, { status: \"none\" });\n      this.results.set(this.id, result);\n    }\n  }\n}\n\n/**\n * Basic information about diagnostics is kept on the server,\n * but it can be useful to show extra details and tips in the\n * client.\n */\nexport const probeDetails: Record<string, ProbeDetails> = {\n  \"boot-page\": {\n    info: t(\"This boot page should not be too easy to access. Either turn \\\nit off when configuration is ok (by unsetting GRIST_BOOT_KEY) \\\nor make GRIST_BOOT_KEY long and cryptographically secure.\"),\n  },\n\n  \"health-check\": {\n    info: t(\"Grist has a small built-in health check often used when running \\\nit as a container.\"),\n  },\n\n  \"host-header\": {\n    info: t(\"Requests arriving to Grist should have an accurate Host \\\nheader. This is essential when GRIST_SERVE_SAME_ORIGIN \\\nis set.\"),\n  },\n\n  \"sandboxing\": {\n    info: t(\"Grist allows for very powerful formulas, using Python. \\\nWe recommend setting the environment variable \\\nGRIST_SANDBOX_FLAVOR to gvisor if your hardware \\\nsupports it (most will), to run formulas in each document \\\nwithin a sandbox isolated from other documents and isolated \\\nfrom the network.\"),\n  },\n\n  \"system-user\": {\n    info: t(\"It is good practice not to run Grist as the root user.\"),\n  },\n\n  \"reachable\": {\n    info: t(\"The main page of Grist should be available.\"),\n  },\n\n  \"websockets\": {\n    // TODO: add a link to https://support.getgrist.com/self-managed/#how-do-i-run-grist-on-a-server\n    info: t(\"Websocket connections need HTTP 1.1 and the ability to pass a few \\\nextra headers in order to work. Sometimes a reverse proxy can \\\ninterfere with these requirements.\"),\n  },\n};\n\n/**\n * Information about the probe.\n */\nexport interface ProbeDetails {\n  info: string;\n}\n"
  },
  {
    "path": "app/client/models/AppModel.ts",
    "content": "import { BehavioralPromptsManager } from \"app/client/components/BehavioralPromptsManager\";\nimport { hooks } from \"app/client/Hooks\";\nimport { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { sessionStorageObs } from \"app/client/lib/localStorageObs\";\nimport { error } from \"app/client/lib/log\";\nimport { reportError, setErrorNotifier } from \"app/client/models/errors\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { getHomeUrl } from \"app/client/models/homeUrl\";\nimport { Notifier } from \"app/client/models/NotifyModel\";\nimport { getUserPrefObs, getUserPrefsObs, markAsSeen } from \"app/client/models/UserPrefs\";\nimport { getFlavor, ProductFlavor } from \"app/client/ui/CustomThemes\";\nimport { Experiments } from \"app/client/ui/Experiments\";\nimport { buildNewSiteModal, buildUpgradeModal } from \"app/client/ui/ProductUpgrades\";\nimport { gristThemePrefs } from \"app/client/ui2018/theme\";\nimport { AsyncCreate } from \"app/common/AsyncCreate\";\nimport { PlanSelection } from \"app/common/BillingAPI\";\nimport { ICustomWidget } from \"app/common/CustomWidget\";\nimport { OrgUsageSummary } from \"app/common/DocUsage\";\nimport { Features, isFreePlan, isLegacyPlan, mergedFeatures, Product } from \"app/common/Features\";\nimport { GristLoadConfig, IGristUrlState } from \"app/common/gristUrls\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { DismissedPopup, DismissedReminder, UserPrefs } from \"app/common/Prefs\";\nimport { isOwner, isOwnerOrEditor } from \"app/common/roles\";\nimport { getTagManagerScript } from \"app/common/tagManager\";\nimport { getDefaultThemePrefs, ThemePrefs } from \"app/common/ThemePrefs\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { ExtendedUser } from \"app/common/UserAPI\";\nimport { getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl } from \"app/common/UserAPI\";\n\nimport { bundleChanges, Computed, Disposable, Observable, subscribe } from \"grainjs\";\n\nconst t = makeT(\"AppModel\");\n\n// Reexported for convenience.\nexport { reportError } from \"app/client/models/errors\";\nexport { getHomeUrl } from \"app/client/models/homeUrl\";\n\nexport type PageType =\n  | \"doc\" |\n  \"home\" |\n  \"billing\" |\n  \"welcome\" |\n  \"account\" |\n  \"admin\" |\n  \"activation\" |\n  \"audit-logs\";\n\nconst G = getBrowserGlobals(\"document\", \"window\");\n\n// TopAppModel is the part of the app model that persists across org and user switches.\nexport interface TopAppModel {\n  api: UserAPI;\n  isSingleOrg: boolean;\n  productFlavor: ProductFlavor;\n  currentSubdomain: Observable<string | undefined>;\n\n  notifier: Notifier;\n  plugins: LocalPlugin[];\n\n  // Everything else gets fully rebuilt when the org/user changes. This is to ensure that\n  // different parts of the code aren't using different users/orgs while the switch is pending.\n  appObs: Observable<AppModel | null>;\n\n  orgs: Observable<Organization[]>;\n  users: Observable<FullUser[]>;\n\n  // Reinitialize the app. This is called when org or user changes.\n  initialize(): void;\n\n  // Rebuilds the AppModel and consequently the AppUI, without changing the user or the org.\n  reload(): void;\n\n  /**\n   * Returns the UntrustedContentOrigin use settings. Throws if not defined.\n   */\n  getUntrustedContentOrigin(): string;\n  /**\n   * Reloads orgs and accounts for current user.\n   */\n  fetchUsersAndOrgs(): Promise<void>;\n\n  /**\n   * Enumerate the widgets in the WidgetRepository for this installation\n   * of Grist.\n   */\n  getWidgets(): Promise<ICustomWidget[]>;\n\n  /**\n   * Reload cached list of widgets, for testing purposes.\n   */\n  testReloadWidgets(): Promise<void>;\n}\n\n/**\n * AppModel is specific to the currently loaded organization and active user. It gets rebuilt when\n * we switch the current organization or the current user.\n */\nexport interface AppModel {\n  topAppModel: TopAppModel;\n  api: UserAPI;\n\n  currentUser: ExtendedUser | null;\n  currentValidUser: ExtendedUser | null;      // Like currentUser, but null when anonymous\n\n  currentOrg: Organization | null;        // null if no access to currentSubdomain\n  currentOrgName: string;               // Our best guess for human-friendly name.\n  currentOrgUsage: Observable<OrgUsageSummary | null>;\n  isPersonal: boolean;                  // Is it a personal site?\n  isTeamSite: boolean;                  // Is it a team site?\n  isLegacySite: boolean;                // Is it a legacy site?\n  isTemplatesSite: boolean;             // Is it the templates site?\n  orgError?: OrgError;                  // If currentOrg is null, the error that caused it.\n  lastVisitedOrgDomain: Observable<string | null>;\n\n  currentProduct: Product | null;         // The current org's product.\n  currentPriceId: string | null;          // The current org's stripe plan id.\n  currentFeatures: Features | null;            // Features of the current org's product.\n\n  userPrefsObs: Observable<UserPrefs>;\n  themePrefs: Observable<ThemePrefs>;\n  experiments?: Experiments;\n  /**\n   * Popups that user has seen.\n   */\n  dismissedPopups: Observable<DismissedPopup[]>;\n  dismissedWelcomePopups: Observable<DismissedReminder[]>;\n\n  pageType: Observable<PageType>;\n  needsOrg: Observable<boolean>;\n\n  notifier: Notifier;\n  planName: string | null;\n\n  behavioralPromptsManager: BehavioralPromptsManager;\n\n  refreshOrgUsage(): Promise<void>;\n  showUpgradeModal(): Promise<void>;\n  showNewSiteModal(): Promise<void>;\n  isBillingManager(): boolean;          // If user is a billing manager for this org\n  isSupport(): boolean;                 // If user is a Support user\n  isOwner(): boolean;                   // If user is an owner of this org\n  isOwnerOrEditor(): boolean;           // If user is an owner or editor of this org\n  isInstallAdmin(): boolean;            // Is user an admin of this installation\n  dismissPopup(name: DismissedPopup, isSeen: boolean): void;  // Mark popup as dismissed or not.\n  switchUser(user: FullUser, org?: string): Promise<void>;\n  isFreePlan(): boolean;\n}\n\nexport interface TopAppModelOptions {\n  /** Defaults to true. */\n  useApi?: boolean;\n}\n\nexport class TopAppModelImpl extends Disposable implements TopAppModel {\n  public readonly isSingleOrg: boolean;\n  public readonly productFlavor: ProductFlavor;\n\n  public readonly currentSubdomain = Computed.create(this, urlState().state, (use, s) => s.org);\n  public readonly notifier = Notifier.create(this);\n  public readonly appObs = Observable.create<AppModel | null>(this, null);\n  public readonly orgs = Observable.create<Organization[]>(this, []);\n  public readonly users = Observable.create<FullUser[]>(this, []);\n  public readonly plugins: LocalPlugin[] = [];\n  private readonly _gristConfig? = this._window.gristConfig;\n  // Keep a list of available widgets, once requested, so we don't have to\n  // keep reloading it. Downside: browser page will need reloading to pick\n  // up new widgets - that seems ok.\n  private readonly _widgets: AsyncCreate<ICustomWidget[]>;\n\n  constructor(private _window: { gristConfig?: GristLoadConfig },\n    public readonly api: UserAPI = newUserAPIImpl(),\n    public readonly options: TopAppModelOptions = {},\n  ) {\n    super();\n    setErrorNotifier(this.notifier);\n    this.isSingleOrg = Boolean(this._gristConfig?.singleOrg);\n    this.productFlavor = getFlavor(this._gristConfig?.org);\n    this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {\n      if (this.options.useApi === false || !this._gristConfig?.enableWidgetRepository) {\n        return [];\n      }\n\n      return await this.api.getWidgets();\n    });\n\n    // Initially, and on any change to subdomain, call initialize() to get the full Organization\n    // and the FullUser to use for it (the user may change when switching orgs).\n    this.autoDispose(subscribe(this.currentSubdomain, use => this.initialize()));\n    this.plugins = this._gristConfig?.plugins || [];\n\n    if (this.options.useApi !== false) {\n      this.fetchUsersAndOrgs().catch(reportError);\n    }\n  }\n\n  public initialize(): void {\n    this._doInitialize().catch(reportError);\n  }\n\n  // Rebuilds the AppModel and consequently the AppUI, etc, without changing the user or the org.\n  public reload(): void {\n    const app = this.appObs.get();\n    if (app) {\n      const { currentUser, currentOrg, orgError } = app;\n      AppModelImpl.create(this.appObs, this, currentUser, currentOrg, orgError);\n    }\n  }\n\n  public async getWidgets(): Promise<ICustomWidget[]> {\n    return this._widgets.get();\n  }\n\n  public async testReloadWidgets() {\n    console.log(\"testReloadWidgets\");\n    this._widgets.clear();\n    console.log(\"testReloadWidgets cleared\");\n    const result = await this.getWidgets();\n    console.log(\"testReloadWidgets got\", { result });\n  }\n\n  public getUntrustedContentOrigin() {\n    if (G.window.isRunningUnderElectron) {\n      // when loaded within webviews it is safe to serve plugin's content from the same domain\n      return \"\";\n    }\n\n    const origin =  this._gristConfig?.pluginUrl;\n    if (!origin) {\n      throw new Error(\"Missing untrustedContentOrigin configuration\");\n    }\n    if (origin.match(/:[0-9]+$/)) {\n      // Port number already specified, no need to add.\n      return origin;\n    }\n    return origin + \":\" + G.window.location.port;\n  }\n\n  public async fetchUsersAndOrgs() {\n    const data = await this.api.getSessionAll();\n    if (this.isDisposed()) { return; }\n    bundleChanges(() => {\n      this.users.set(data.users);\n      this.orgs.set(data.orgs);\n    });\n  }\n\n  private async _doInitialize() {\n    this.appObs.set(null);\n    if (this.options.useApi === false) {\n      AppModelImpl.create(this.appObs, this, null, null, { error: \"no-api\", status: 500 });\n      return;\n    }\n    try {\n      const { user, org, orgError } = await this.api.getSessionActive();\n      if (this.isDisposed()) { return; }\n      if (org) {\n        // Check that our domain matches what the api returns.\n        const state = urlState().state.get();\n        if (state.org !== org.domain && org.domain !== null) {\n          // If not, redirect.  This is to allow vanity domains\n          // to \"stick\" only if paid for.\n          await urlState().pushUrl({ ...state, org: org.domain });\n        }\n        if (org.billingAccount?.product?.name === \"suspended\") {\n          this.notifier.createUserMessage(\n            t(\"This team site is suspended. Documents can be read, but not modified.\"),\n            { actions: [\"renew\", \"personal\"] },\n          );\n        }\n      }\n      AppModelImpl.create(this.appObs, this, user, org, orgError);\n    } catch (err) {\n      console.log(`getSessionActive() failed: ${err}`);\n      if (this.isDisposed()) { return; }\n      AppModelImpl.create(this.appObs, this, null, null, { error: err.message, status: err.status || 500 });\n    }\n  }\n}\n\nexport class AppModelImpl extends Disposable implements AppModel {\n  public readonly api: UserAPI = this.topAppModel.api;\n\n  // Compute currentValidUser, turning anonymous into null.\n  public readonly currentValidUser: ExtendedUser | null =\n    this.currentUser && !this.currentUser.anonymous ? this.currentUser : null;\n\n  // Figure out the org name, or blank if details are unavailable.\n  public readonly currentOrgName = getOrgNameOrGuest(this.currentOrg, this.currentUser);\n\n  public readonly currentOrgUsage: Observable<OrgUsageSummary | null> = Observable.create(this, null);\n\n  public readonly lastVisitedOrgDomain = this.autoDispose(sessionStorageObs(\"grist-last-visited-org-domain\"));\n\n  public readonly currentProduct = this.currentOrg?.billingAccount?.product ?? null;\n  public readonly currentPriceId = this.currentOrg?.billingAccount?.stripePlanId ?? null;\n  public readonly currentFeatures = mergedFeatures(\n    this.currentOrg?.billingAccount?.features ?? null,\n    this.currentProduct?.features ?? null,\n  );\n\n  public readonly isPersonal = Boolean(this.currentOrg?.owner);\n  public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal;\n  public readonly isLegacySite = Boolean(this.currentProduct && isLegacyPlan(this.currentProduct.name));\n  public readonly isTemplatesSite = isTemplatesOrg(this.currentOrg);\n\n  public readonly userPrefsObs = getUserPrefsObs(this);\n  public readonly themePrefs = getUserPrefObs(this.userPrefsObs, \"theme\", {\n    defaultValue: getDefaultThemePrefs(),\n  }) as Observable<ThemePrefs>;\n\n  public readonly experiments?: Experiments;\n\n  public readonly dismissedPopups = getUserPrefObs(this.userPrefsObs, \"dismissedPopups\",\n    { defaultValue: [] }) as Observable<DismissedPopup[]>;\n\n  public readonly dismissedWelcomePopups = getUserPrefObs(this.userPrefsObs, \"dismissedWelcomePopups\",\n    { defaultValue: [] }) as Observable<DismissedReminder[]>;\n\n  // Get the current PageType from the URL.\n  public readonly pageType: Observable<PageType> = Computed.create(this, urlState().state,\n    (_use, state) => {\n      if (state.doc) {\n        return \"doc\";\n      } else if (state.billing) {\n        return \"billing\";\n      } else if (state.welcome) {\n        return \"welcome\";\n      } else if (state.account) {\n        return \"account\";\n      } else if (state.adminPanel) {\n        return \"admin\";\n      } else if (state.activation) {\n        return \"activation\";\n      } else if (state.auditLogs) {\n        return \"audit-logs\";\n      } else {\n        return \"home\";\n      }\n    });\n\n  public readonly needsOrg: Observable<boolean> = Computed.create(\n    this, urlState().state, (use, state) => {\n      return !(\n        Boolean(state.welcome) ||\n        state.billing === \"scheduled\" ||\n        Boolean(state.account) ||\n        Boolean(state.activation) ||\n        Boolean(state.adminPanel)\n      );\n    });\n\n  public readonly notifier = this.topAppModel.notifier;\n\n  public readonly behavioralPromptsManager: BehavioralPromptsManager =\n    BehavioralPromptsManager.create(this, this);\n\n  constructor(\n    public readonly topAppModel: TopAppModel,\n    public readonly currentUser: ExtendedUser | null,\n    public readonly currentOrg: Organization | null,\n    public readonly orgError?: OrgError,\n  ) {\n    super();\n\n    // Whenever theme preferences change, update the global `gristThemePrefs` observable; this triggers\n    // an automatic update to the global `gristThemeObs` computed observable.\n    this.autoDispose(subscribe(this.themePrefs, (_use, themePrefs) => gristThemePrefs.set(themePrefs)));\n\n    this._recordSignUpIfIsNewUser();\n\n    const state = urlState().state.get();\n    if (state.createTeam) {\n      // Remove params from the URL.\n      urlState().pushUrl({ createTeam: false, params: {} }, { avoidReload: true, replace: true }).catch(() => {});\n      this.showNewSiteModal({\n        priceId: state.params?.billingPlan,\n        product: state.params?.planType,\n      }).catch(reportError);\n    } else if (state.upgradeTeam) {\n      // Remove params from the URL.\n      urlState().pushUrl({ upgradeTeam: false, params: {} }, { avoidReload: true, replace: true }).catch(() => {});\n      this.showUpgradeModal({\n        priceId: state.params?.billingPlan,\n        product: state.params?.planType,\n      }).catch(reportError);\n    }\n\n    G.window.resetDismissedPopups = (seen = false) => {\n      this.dismissedPopups.set(seen ? DismissedPopup.values : []);\n      this.behavioralPromptsManager.reset();\n    };\n\n    G.window.resetOnboarding = () => {\n      getUserPrefObs(this.userPrefsObs, \"showNewUserQuestions\").set(true);\n    };\n\n    this.autoDispose(subscribe(urlState().state, this.topAppModel.orgs, async (_use, s, orgs) => {\n      this._updateLastVisitedOrgDomain(s, orgs);\n    }));\n\n    this.experiments = Experiments.create(this, currentUser?.id || 0);\n    if (this.experiments.isRequested()) {\n      this.experiments.showModal(this.experiments.getCurrentRequest()!);\n    }\n  }\n\n  public get planName() {\n    return this.currentProduct?.name ?? null;\n  }\n\n  public async showUpgradeModal(plan?: PlanSelection) {\n    if (this.planName && this.currentOrg) {\n      if (this.isPersonal) {\n        await this.showNewSiteModal(plan);\n      } else if (this.isTeamSite) {\n        await buildUpgradeModal(this, {\n          appModel: this,\n          pickPlan: plan,\n          reason: \"upgrade\",\n        });\n      } else {\n        throw new Error(\"Unexpected state\");\n      }\n    }\n  }\n\n  public async showNewSiteModal(plan?: PlanSelection) {\n    if (this.planName) {\n      await buildNewSiteModal(this, {\n        appModel: this,\n        plan,\n        onCreate: () => this.topAppModel.fetchUsersAndOrgs().catch(reportError),\n      });\n    }\n  }\n\n  public isSupport() {\n    return Boolean(this.currentValidUser?.isSupport);\n  }\n\n  public isBillingManager() {\n    return Boolean(this.currentOrg?.billingAccount?.isManager);\n  }\n\n  public isOwner() {\n    return Boolean(this.currentOrg && isOwner(this.currentOrg));\n  }\n\n  public isOwnerOrEditor() {\n    return Boolean(this.currentOrg && isOwnerOrEditor(this.currentOrg));\n  }\n\n  public isInstallAdmin(): boolean {\n    return Boolean(this.currentUser?.isInstallAdmin);\n  }\n\n  /**\n   * Fetch and update the current org's usage.\n   */\n  public async refreshOrgUsage() {\n    if (!this.isOwner()) {\n      // Note: getOrgUsageSummary already checks for owner access; we do an early return\n      // here to skip making unnecessary API calls.\n      return;\n    }\n\n    const usage = await this.api.getOrgUsageSummary(this.currentOrg!.id);\n    if (!this.isDisposed()) {\n      this.currentOrgUsage.set(usage);\n    }\n  }\n\n  public dismissPopup(name: DismissedPopup, isSeen: boolean): void {\n    markAsSeen(this.dismissedPopups, name, isSeen);\n  }\n\n  public async switchUser(user: FullUser, org?: string) {\n    await this.api.setSessionActive(user.email, org);\n    this.lastVisitedOrgDomain.set(null);\n  }\n\n  public isFreePlan() {\n    return isFreePlan(this.planName || \"\");\n  }\n\n  private _updateLastVisitedOrgDomain({ doc, org }: IGristUrlState, availableOrgs: Organization[]) {\n    if (\n      !org ||\n      // Invalid or inaccessible sites shouldn't be counted as visited.\n      !this.currentOrg ||\n      // Visits to a document shouldn't be counted either.\n      doc\n    ) {\n      return;\n    }\n\n    // Only count sites that a user has access to (i.e. those listed in the Site Switcher).\n    if (!availableOrgs.some(({ domain }) => domain === org)) { return; }\n\n    this.lastVisitedOrgDomain.set(org);\n  }\n\n  /**\n   * If the current user is a new user, record a sign-up event via Google Tag Manager.\n   */\n  private _recordSignUpIfIsNewUser() {\n    const isNewUser = this.userPrefsObs.get().recordSignUpEvent;\n    if (!isNewUser) { return; }\n\n    // If Google Tag Manager isn't configured, don't record anything.\n    const { tagManagerId } = getGristConfig();\n    if (!tagManagerId) { return; }\n\n    let dataLayer = (window as any).dataLayer;\n    if (!dataLayer) {\n      // Load the Google Tag Manager script into the document.\n      const script = document.createElement(\"script\");\n      script.innerHTML = getTagManagerScript(tagManagerId);\n      document.head.appendChild(script);\n      dataLayer = (window as any).dataLayer;\n      if (!dataLayer) {\n        error(`_recordSignUpIfIsNewUser() failed to load Google Tag Manager`);\n      }\n    }\n\n    // Send the sign-up event, and remove the recordSignUpEvent flag from preferences.\n    dataLayer.push({ event: \"new-sign-up\" });\n    getUserPrefObs(this.userPrefsObs, \"recordSignUpEvent\").set(undefined);\n  }\n}\n\nexport function getOrgNameOrGuest(org: Organization | null, user: FullUser | null) {\n  if (!org) { return \"\"; }\n  if (user && user.anonymous && org.owner && org.owner.id === user.id) {\n    return \"@Guest\";\n  }\n  return getOrgName(org);\n}\n\nexport function newUserAPIImpl(): UserAPIImpl {\n  return new UserAPIImpl(getHomeUrl(), {\n    fetch: hooks.fetch,\n  });\n}\n"
  },
  {
    "path": "app/client/models/AuditLogsModel.ts",
    "content": "import { ConfigsAPI } from \"app/client/ui/ConfigsAPI\";\nimport {\n  AuditLogStreamingDestination,\n  AuditLogStreamingDestinations,\n} from \"app/common/Config\";\n\nimport { Disposable, Observable } from \"grainjs\";\nimport omit from \"lodash/omit\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nexport interface AuditLogsModel {\n  readonly streamingDestinations: Observable<AuditLogStreamingDestinations | null>;\n  fetchStreamingDestinations(): Promise<void>;\n  createStreamingDestination(\n    properties: Omit<AuditLogStreamingDestination, \"id\">\n  ): Promise<void>;\n  updateStreamingDestination(\n    id: AuditLogStreamingDestination[\"id\"],\n    properties: Partial<Omit<AuditLogStreamingDestination, \"id\">>\n  ): Promise<void>;\n  deleteStreamingDestination(\n    id: AuditLogStreamingDestination[\"id\"]\n  ): Promise<void>;\n}\n\nexport interface AuditLogsModelOptions {\n  configsAPI: ConfigsAPI;\n}\n\nexport class AuditLogsModelImpl extends Disposable implements AuditLogsModel {\n  public readonly streamingDestinations: Observable<AuditLogStreamingDestinations | null> =\n    Observable.create(this, null);\n\n  private readonly _configsAPI = this._options.configsAPI;\n\n  constructor(private _options: AuditLogsModelOptions) {\n    super();\n  }\n\n  public async fetchStreamingDestinations(): Promise<void> {\n    this.streamingDestinations.set(null);\n    try {\n      const { value } = await this._configsAPI.getConfig(\n        \"audit_log_streaming_destinations\",\n      );\n      if (this.isDisposed()) {\n        return;\n      }\n\n      this.streamingDestinations.set(value);\n    } catch (e) {\n      if (e.status === 404) {\n        this.streamingDestinations.set([]);\n      } else {\n        throw e;\n      }\n    }\n  }\n\n  public async createStreamingDestination(\n    properties: Omit<AuditLogStreamingDestination, \"id\">,\n  ): Promise<void> {\n    const destinations = this.streamingDestinations.get() ?? [];\n    const newDestinations = [\n      ...destinations,\n      {\n        ...properties,\n        id: uuidv4(),\n      },\n    ];\n    await this._updateStreamingDestinations(newDestinations);\n  }\n\n  public async updateStreamingDestination(\n    id: AuditLogStreamingDestination[\"id\"],\n    properties: Partial<Omit<AuditLogStreamingDestination, \"id\">>,\n  ): Promise<void> {\n    const destinations = this.streamingDestinations.get() ?? [];\n    const index = destinations.findIndex(d => d.id === id);\n    if (index === -1) {\n      throw new Error(\"streaming destination not found\");\n    }\n\n    const newDestinations = [\n      ...destinations.slice(0, index),\n      {\n        ...destinations[index],\n        ...omit(properties, \"id\"),\n      },\n      ...destinations.slice(index + 1),\n    ];\n    await this._updateStreamingDestinations(newDestinations);\n  }\n\n  public async deleteStreamingDestination(\n    id: AuditLogStreamingDestination[\"id\"],\n  ): Promise<void> {\n    const destinations = this.streamingDestinations.get() ?? [];\n    const newDestinations = destinations.filter(d => d.id !== id);\n    await this._updateStreamingDestinations(newDestinations);\n  }\n\n  private async _updateStreamingDestinations(\n    destinations: AuditLogStreamingDestinations,\n  ): Promise<void> {\n    const { value } = await this._configsAPI.updateConfig(\n      \"audit_log_streaming_destinations\",\n      destinations,\n    );\n    if (this.isDisposed()) {\n      return;\n    }\n\n    this.streamingDestinations.set(value);\n  }\n}\n"
  },
  {
    "path": "app/client/models/BaseRowModel.js",
    "content": "var _ = require(\"underscore\");\nvar ko = require(\"knockout\");\n\nvar gutil = require(\"app/common/gutil\");\n\nvar dispose = require(\"../lib/dispose\");\n\nvar modelUtil = require(\"./modelUtil\");\n\n\n/**\n * BaseRowModel is an observable model for a record (or row) of a data (DataRowModel) or meta\n * (MetaRowModel) table. It takes a reference to the containing TableModel, and a list of\n * column names, and creates an observable for each field.\n * TODO: We need to have a way to dispose RowModels, and have them dispose individual fields,\n * which should in turn unsubscribe from various events on disposal. And it all should be tested.\n *\n */\nfunction BaseRowModel(tableModel, colNames) {\n  this._table  = tableModel;\n  this._fields = colNames.slice(0);\n  this._index  = ko.observable(null);    // The index in the observable to which it belongs.\n  this._rowId  = null;\n\n  // Create a field for everything in `_fields`.\n  this._fields.forEach(function(colName) {\n    this._createField(colName);\n  }, this);\n}\ndispose.makeDisposable(BaseRowModel);\n\n// This adds the dispatchAction() method to RowModel.\n_.extend(BaseRowModel.prototype, modelUtil.ActionDispatcher);\n\n/**\n * Returns the rowId to which this RowModel is assigned. This is also normally available as the\n * `rowModel.id` observable.\n */\nBaseRowModel.prototype.getRowId = function() {\n  return this._rowId;\n};\n\n/**\n * Creates a field for colName. This is either a top level observable like this[colName]\n * for MetaRowModels or a property field like this[name][prop] for DataRowModels\n */\nBaseRowModel.prototype._createField = function(colName) {\n  this[colName] = modelUtil.addSaveInterface(ko.observable(), v => this._saveField(colName, v));\n};\n\n/**\n * Helper method to send a user action to save a field of the current row to the server.\n */\nBaseRowModel.prototype._saveField = function(colName, value) {\n  var colValues = {};\n  colValues[colName] = value;\n  return this.updateColValues(colValues);\n};\n\n/**\n * Send an update to the server to update multiple columns for this row.\n * @param {Object} colValues: Maps colIds to values.\n * @returns {Promise} Resolved when the update succeeds.\n */\nBaseRowModel.prototype.updateColValues = function(colValues) {\n  return this._table.sendTableAction([\"UpdateRecord\", this._rowId, colValues]);\n};\n\n/**\n * Assigns the field of this RowModel named by `colName` to its corresponding value.\n */\nBaseRowModel.prototype._assignColumn = function(colName) {\n  throw new Error(\"Not Implemented\");\n};\n\n//----------------------------------------------------------------------\n\n/**\n * Implements the interface expected by modelUtil.ActionDispatcher. We only implement the\n * actions that affect individual rows. Note that BulkUpdateRecord needs to be translated to individual\n * UpdateRecords for RowModel to know what to do. Messages not here must be implemented by subclasses.\n * Some of these require helper methods defined in subclasses\n */\n\nBaseRowModel.prototype._process_RemoveColumn = function(action, tableId, colId) {\n  if (!gutil.arrayRemove(this._fields, colId)) {\n    console.error(\"RowModel #RemoveColumn %s %s: column not found\", tableId, colId);\n  }\n  delete this[colId];\n};\n\nBaseRowModel.prototype._process_ModifyColumn = function(action, tableId, colId, colInfo) {\n  // No-op for us, because we don't care about any of the column properties.\n};\n\nBaseRowModel.prototype._process_UpdateRecord = function(action, tableId, rowId, columnValues) {\n  for (var colName in columnValues) {\n    this._assignColumn(colName);\n  }\n};\n\nBaseRowModel.prototype._process_BulkUpdateRecord = function(action, tableId, rowId, columnValues) {\n  // We get notified when a BulkUpdateRecord affects us, but since we just update all fields from\n  // the underlying data, we don't need to find our row in the action.\n  for (var colName in columnValues) {\n    this._assignColumn(colName);\n  }\n};\n\n// TODO: if AddColumn messages aren't sent for properties, we will need to find a different\n// way to create and set the properties than here\nBaseRowModel.prototype._process_AddColumn = function(action, tableId, colId, colInfo) {\n  this._fields.push(colId);\n  this._createField(colId);\n  this._assignColumn(colId);\n};\n\nBaseRowModel.prototype._process_RenameColumn = function(action, tableId, oldColId, newColId) {\n  // handle standard renames differently\n  if (this._fields.indexOf(newColId) !== -1) {\n    console.error(\"RowModel #RenameColumn %s %s %s: already exists\", tableId, oldColId, newColId);\n    return;\n  }\n  var index = this._fields.indexOf(oldColId);\n  if (index === -1) {\n    console.error(\"RowModel #RenameColumn %s %s %s: not found\", tableId, oldColId, newColId);\n    return;\n  }\n  this._fields[index] = newColId;\n\n  // Reuse the old observable, but replace its \"save\" family of functions.\n  this[newColId] = this[oldColId];\n  modelUtil.addSaveInterface(this[newColId], this._saveField.bind(this, newColId));\n  this._assignColumn(newColId);\n  delete this[oldColId];\n};\n\nmodule.exports = BaseRowModel;\n"
  },
  {
    "path": "app/client/models/ChatHistory.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { AssistanceState, DeveloperPromptVersion } from \"app/common/Assistance\";\n\n/**\n * The state of assistance.\n * ChatMessages are what are shown in the UI, whereas state is\n * how the back-end represents the conversation. The two are\n * similar but not the same because of post-processing.\n * It may be possible to reconcile them when things settle down\n * a bit?\n */\nexport interface ChatHistory {\n  messages: ChatMessage[];\n  conversationId?: string;\n  state?: AssistanceState;\n  developerPromptVersion?: DeveloperPromptVersion;\n}\n\n/**\n * A chat message. Either sent by the user or by the AI.\n */\nexport interface ChatMessage {\n  /**\n   * The message to display. It is a prompt typed by the user or a completion returned from the AI.\n   */\n  message: string;\n  /**\n   * The sender of the message. Either the user or the AI.\n   */\n  sender: \"user\" | \"ai\";\n  /**\n   * Error response from the AI, if any.\n   */\n  error?: ApiError;\n  /**\n   * The formula returned from the AI. It is only set when the sender is the AI.\n   *\n   * Only used by version 1 of the AI assistant.\n   */\n  formula?: string | null;\n  /**\n   * Suggested actions returned from the AI.\n   *\n   * Only used by version 1 of the AI assistant.\n   */\n  action?: any;\n}\n"
  },
  {
    "path": "app/client/models/ClientColumnGetters.ts",
    "content": "import DataTableModel from \"app/client/models/DataTableModel\";\nimport { ColumnGetter, ColumnGetters, ColumnGettersByColId } from \"app/common/ColumnGetters\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { choiceGetter } from \"app/common/SortFunc\";\nimport { Sort } from \"app/common/SortSpec\";\nimport { TableData } from \"app/common/TableData\";\n\n/**\n *\n * An implementation of ColumnGetters for the client, drawing\n * on the observables and models available in that context.\n *\n */\nexport class ClientColumnGetters implements ColumnGetters {\n  // If the \"unversioned\" option is set, then cells with multiple\n  // versions will be read as a single version - the first version\n  // available of parent, local, or remote.  This can make sense for\n  // sorting, so cells appear in a reasonably sensible place.\n  constructor(private _tableModel: DataTableModel, private _options: {\n    unversioned?: boolean } = {}) {\n  }\n\n  public getColGetter(colSpec: Sort.ColSpec): ColumnGetter | null {\n    const rowModel = this._tableModel.docModel.columns.getRowModel(\n      Sort.getColRef(colSpec) as number, /* HACK: for virtual tables */\n    );\n    const colId = rowModel.colId();\n    let getter: ColumnGetter | undefined = this._tableModel.tableData.getRowPropFunc(colId);\n    if (!getter) { return null; }\n    if (this._options.unversioned && this._tableModel.tableData.mayHaveVersions()) {\n      const valueGetter = getter;\n      getter = (rowId) => {\n        const value = valueGetter(rowId);\n        if (value && gristTypes.isVersions(value)) {\n          const versions = value[1];\n          return (\"parent\" in versions) ? versions.parent :\n            (\"local\" in versions) ? versions.local : versions.remote;\n        }\n        return value;\n      };\n    }\n    const details = Sort.specToDetails(colSpec);\n    if (details.orderByChoice) {\n      if (rowModel.pureType() === \"Choice\") {\n        const choices: string[] = rowModel.widgetOptionsJson.peek()?.choices || [];\n        getter = choiceGetter(getter, choices);\n      }\n    }\n    return getter;\n  }\n\n  public getManualSortGetter(): ((rowId: number) => any) | null {\n    const manualSortCol = this._tableModel.tableMetaRow.columns().peek().find(\n      (c: any) => c.colId() === gristTypes.MANUALSORT);\n    if (!manualSortCol) {\n      return null;\n    }\n    return this.getColGetter(manualSortCol.getRowId());\n  }\n}\n\nexport class ClientColumnGettersByColId implements ColumnGettersByColId {\n  constructor(private _tableData: TableData) {\n  }\n\n  public getColGetterByColId(colId: string): ColumnGetter {\n    return this._tableData.getRowPropFunc(colId);\n  }\n}\n"
  },
  {
    "path": "app/client/models/ColumnACIndexes.ts",
    "content": "/**\n * Implements a cache of ACIndex objects for columns in Grist table.\n *\n * The getColACIndex() function returns the corresponding ACIndex, building it if needed and\n * caching for subsequent calls. Any change to the column or a value in it invalidates the cache.\n *\n * It is available as tableData.columnACIndexes.\n *\n * It is currently used for auto-complete in the ReferenceEditor and ReferenceListEditor widgets.\n */\nimport { ACIndex, ACIndexImpl, normalizeText } from \"app/client/lib/ACIndex\";\nimport { ColumnCache } from \"app/client/models/ColumnCache\";\nimport { UserError } from \"app/client/models/errors\";\nimport { TableData } from \"app/client/models/TableData\";\nimport { localeCompare, nativeCompare } from \"app/common/gutil\";\nimport { BaseFormatter } from \"app/common/ValueFormatter\";\n\nexport interface ICellItem {\n  rowId: number | \"new\";\n  text: string;           // Formatted cell text.\n  cleanText: string;      // Trimmed lowercase text for searching.\n}\n\nexport class ColumnACIndexes {\n  private _columnCache = new ColumnCache<ACIndex<ICellItem>>(this._tableData);\n\n  constructor(private _tableData: TableData) {}\n\n  /**\n   * Returns the column index for the given column, using a cached one if available.\n   * The formatter should be created using field.visibleColFormatter(). It's assumed that\n   * getColACIndex() is called for the same column with the the same formatter.\n   */\n  public getColACIndex(colId: string, formatter: BaseFormatter): ACIndex<ICellItem> {\n    return this._columnCache.getValue(colId, () => this.buildColACIndex(colId, formatter));\n  }\n\n  public buildColACIndex(\n    colId: string,\n    formatter: BaseFormatter,\n    filter?: (item: ICellItem) => boolean,\n  ): ACIndex<ICellItem> {\n    const rowIds = this._tableData.getRowIds();\n    const valColumn = this._tableData.getColValues(colId);\n    if (!valColumn) {\n      throw new UserError(`Invalid column ${this._tableData.tableId}.${colId}`);\n    }\n    const items: ICellItem[] = valColumn\n      .map((val, i) => {\n        const rowId = rowIds[i];\n        const text = formatter.formatAny(val);\n        const cleanText = normalizeText(text);\n        return { rowId, text, cleanText };\n      })\n      .filter(item => filter?.(item) ?? true)\n      .sort(itemCompare);\n    return new ACIndexImpl(items);\n  }\n}\n\nfunction itemCompare(a: ICellItem, b: ICellItem) {\n  return localeCompare(a.cleanText, b.cleanText) ||\n    localeCompare(a.text, b.text) ||\n    nativeCompare(a.rowId, b.rowId);\n}\n"
  },
  {
    "path": "app/client/models/ColumnCache.ts",
    "content": "/**\n * Implements a cache of values computed from the data in a Grist column.\n */\nimport { TableData } from \"app/client/models/TableData\";\nimport { DocAction } from \"app/common/DocActions\";\nimport { isBulkUpdateRecord, isUpdateRecord } from \"app/common/DocActions\";\nimport { getSetMapValue } from \"app/common/gutil\";\n\nexport class ColumnCache<T> {\n  private _cachedColIndexes = new Map<string, T>();\n\n  constructor(private _tableData: TableData) {\n    // Whenever a table action is applied, consider invalidating per-column caches.\n    this._tableData.tableActionEmitter.addListener(this._invalidateCache, this);\n    this._tableData.dataLoadedEmitter.addListener(this._clearCache, this);\n  }\n\n  /**\n   * Returns the cached value for the given column, or calculates and caches the value using the\n   * provided calc() function.\n   */\n  public getValue(colId: string, calc: () => T): T {\n    return getSetMapValue(this._cachedColIndexes, colId, calc);\n  }\n\n  private _invalidateCache(action: DocAction): void {\n    if (isUpdateRecord(action) || isBulkUpdateRecord(action)) {\n      // If the update only affects existing records, only invalidate affected columns.\n      const colValues = action[3];\n      for (const colId of Object.keys(colValues)) {\n        this._cachedColIndexes.delete(colId);\n      }\n    } else {\n      // For add/delete actions and all schema changes, drop the cache entirely to be on the safe side.\n      this._clearCache();\n    }\n  }\n\n  private _clearCache(): void {\n    this._cachedColIndexes.clear();\n  }\n}\n"
  },
  {
    "path": "app/client/models/ColumnFilter.ts",
    "content": "import { ColumnFilterFunc, makeFilterFunc } from \"app/common/ColumnFilterFunc\";\nimport { CellValue } from \"app/common/DocActions\";\nimport {\n  FilterSpec, FilterState, IRelativeDateSpec, isRangeFilter, isRelativeBound, makeFilterState,\n} from \"app/common/FilterState\";\nimport { nativeCompare } from \"app/common/gutil\";\nimport { relativeDateToUnixTimestamp } from \"app/common/RelativeDates\";\n\nimport { Computed, Disposable, Observable } from \"grainjs\";\n\n/**\n * ColumnFilter implements a custom filter on a column, i.e. a filter that's diverged from what's\n * on the server. It has methods to modify the filter state, and exposes a public filterFunc\n * observable which gets triggered whenever the filter state changes.\n *\n * It does NOT listen to changes in the initial JSON, since it's only used when the filter has\n * been customized.\n */\nexport class ColumnFilter extends Disposable {\n  public min = Observable.create<number | undefined | IRelativeDateSpec>(this, undefined);\n  public max = Observable.create<number | undefined | IRelativeDateSpec>(this, undefined);\n\n  public readonly filterFunc = Observable.create<ColumnFilterFunc>(this, () => true);\n\n  // Computed that returns true if filter is an inclusion filter, false otherwise.\n  public readonly isInclusionFilter: Computed<boolean> = Computed.create(this, this.filterFunc, () => this._include);\n\n  // Computed that returns the current filter state.\n  public readonly state: Computed<FilterState> = Computed.create(this, this.filterFunc, () => this._getState());\n\n  public readonly isRange: Computed<boolean> = Computed.create(this, this.filterFunc, () => this._isRange());\n\n  private _include: boolean;\n  private _values: Set<CellValue>;\n\n  constructor(private _initialFilterJson: string, private _columnType: string = \"\",\n    public visibleColumnType: string = \"\", private _allValues: CellValue[] = []) {\n    super();\n    this.setState(_initialFilterJson);\n    this.autoDispose(this.min.addListener(() => this._updateState()));\n    this.autoDispose(this.max.addListener(() => this._updateState()));\n  }\n\n  public get columnType() {\n    return this._columnType;\n  }\n\n  public get initialFilterJson() {\n    return this._initialFilterJson;\n  }\n\n  public setState(filterJson: string | FilterSpec) {\n    const state = makeFilterState(filterJson);\n    if (isRangeFilter(state)) {\n      this.min.set(state.min);\n      this.max.set(state.max);\n      // Setting _include to false allows to make sure that the filter reverts to all values\n      // included when users delete one bound (min or max) while the other bound is already\n      // undefined (filter reverts to switching by value when both min and max are undefined).\n      this._include = false;\n      this._values = new Set();\n    } else {\n      this.min.set(undefined);\n      this.max.set(undefined);\n      this._include = state.include;\n      this._values = state.values;\n    }\n    this._updateState();\n  }\n\n  public includes(val: CellValue): boolean {\n    return this.filterFunc.get()(val);\n  }\n\n  public add(val: CellValue) {\n    this.addMany([val]);\n  }\n\n  public addMany(values: CellValue[]) {\n    this._toValues();\n    for (const val of values) {\n      if (this._include) {\n        this._values.add(val);\n      } else {\n        this._values.delete(val);\n      }\n    }\n    this._updateState();\n  }\n\n  public delete(val: CellValue) {\n    this.deleteMany([val]);\n  }\n\n  public deleteMany(values: CellValue[]) {\n    this._toValues();\n    for (const val of values) {\n      if (this._include) {\n        this._values.delete(val);\n      } else {\n        this._values.add(val);\n      }\n    }\n    this._updateState();\n  }\n\n  public clear() {\n    this._values.clear();\n    this._include = true;\n    this._updateState();\n  }\n\n  public selectAll() {\n    this._values.clear();\n    this._include = false;\n    this._updateState();\n  }\n\n  // For saving the filter value back.\n  public makeFilterJson(): string {\n    let filter: any;\n    if (this.min.get() !== undefined || this.max.get() !== undefined) {\n      filter = { min: this.min.get(), max: this.max.get() };\n    } else {\n      const values = Array.from(this._values).sort(nativeCompare);\n      filter = { [this._include ? \"included\" : \"excluded\"]: values };\n    }\n    return JSON.stringify(filter);\n  }\n\n  public hasChanged(): boolean {\n    return this.makeFilterJson() !== this._initialFilterJson;\n  }\n\n  // Retuns min or max as a numeric value.\n  public getBoundsValue(minMax: \"min\" | \"max\"): number {\n    const value = this[minMax].get();\n    if (value === undefined) { return minMax === \"min\" ? -Infinity : +Infinity; }\n    return isRelativeBound(value) ? relativeDateToUnixTimestamp(value) : value;\n  }\n\n  private _updateState(): void {\n    this.filterFunc.set(makeFilterFunc(this._getState(), this._columnType));\n  }\n\n  private _getState(): FilterState {\n    return { include: this._include, values: this._values, min: this.min.get(), max: this.max.get() };\n  }\n\n  private _isRange() {\n    return isRangeFilter(this._getState());\n  }\n\n  private _toValues() {\n    if (this._isRange()) {\n      const func = this.filterFunc.get();\n      const state = this._include ?\n        { included: this._allValues.filter(val => func(val)) } :\n        { excluded: this._allValues.filter(val => !func(val)) };\n      this.setState(state);\n    }\n  }\n}\n\n/**\n * A JSON-encoded filter spec that includes every value.\n */\nexport const ALL_INCLUSIVE_FILTER_JSON = '{\"excluded\":[]}';\n\n/**\n * A blank JSON-encoded filter spec.\n *\n * This is interpreted the same as `ALL_INCLUSIVE_FILTER_JSON` in the context\n * of parsing filters. However, it's still useful in scenarios where it's\n * necessary to discern between new filters and existing filters; initializing\n * a `ColumnFilter` with `NEW_FIlTER_JSON` makes it clear that a new filter\n * is being created.\n */\nexport const NEW_FILTER_JSON = \"{}\";\n"
  },
  {
    "path": "app/client/models/ColumnFilterMenuModel.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { normalizeText } from \"app/client/lib/ACIndex\";\nimport { ColumnFilter } from \"app/client/models/ColumnFilter\";\nimport { FilterInfo } from \"app/client/models/entities/ViewSectionRec\";\nimport { CellValue } from \"app/plugin/GristData\";\n\nimport { Computed, Disposable, Observable } from \"grainjs\";\nimport escapeRegExp from \"lodash/escapeRegExp\";\nimport isNull from \"lodash/isNull\";\n\nconst MAXIMUM_SHOWN_FILTER_ITEMS = 500;\n\nexport interface IFilterCount {\n\n  // label is the formatted value\n  label: string;\n\n  // number of occurences in the table\n  count: number;\n\n  // displayValue is the underlying value (from the display column, if any), useful to perform\n  // comparison\n  displayValue: any;\n}\n\ntype ICompare<T> = (a: T, b: T) => number;\n\nconst localeCompare = new Intl.Collator(\"en-US\", { numeric: true }).compare;\n\ninterface ColumnFilterMenuModelParams {\n  columnFilter: ColumnFilter;\n  filterInfo: FilterInfo;\n  valueCount: [CellValue, IFilterCount][];\n  gristDoc: GristDoc;\n  limitShow?: number;\n}\n\nexport class ColumnFilterMenuModel extends Disposable {\n  public readonly columnFilter = this._params.columnFilter;\n\n  public readonly filterInfo = this._params.filterInfo;\n\n  public readonly gristDoc = this._params.gristDoc;\n\n  public readonly initialPinned = this.filterInfo.isPinned.peek();\n\n  public readonly limitShown = this._params.limitShow ?? MAXIMUM_SHOWN_FILTER_ITEMS;\n\n  public readonly searchValue = Observable.create(this, \"\");\n\n  public readonly isSortedByCount = Observable.create(this, false);\n\n  // computes a set of all keys that matches the search text.\n  public readonly filterSet = Computed.create(this, this.searchValue, (_use, searchValue) => {\n    const searchRegex = new RegExp(escapeRegExp(normalizeText(searchValue)), \"i\");\n    const showAllOptions = [\"Bool\", \"Choice\", \"ChoiceList\"].includes(this.columnFilter.columnType);\n    return new Set(\n      this._params.valueCount\n        .filter(([_, { label, count }]) => (showAllOptions ? true : count) && searchRegex.test(normalizeText(label)))\n        .map(([key]) => key),\n    );\n  });\n\n  // computes the sorted array of all values (ie: pair of key and IFilterCount) that matches the search text.\n  public readonly filteredValues = Computed.create(\n    this, this.filterSet, this.isSortedByCount,\n    (_use, filter, isSortedByCount) => {\n      // Decide which property to sort the labels by.\n      // - For columns of type text use the label property (which is what we are showing)\n      // - For other types use the displayValue property (which is the underlying value)\n      // For text we need labels - which we assume are the same as values, because we for markdown widget\n      // the label is actually different (it doesn't have links, so the order can be different).\n      // TODO: The comparator below is not comparing labels (strings) but actual values (like boolean or numbers),\n      // as this is the value of the `displayValue` column (though it uses localeCompare for strings).\n      // For anyone reading this here is the context https://phab.getgrist.com/D3441 (sorry for the private repo).\n      const displayValue = this.columnFilter.visibleColumnType === \"Text\" ? \"label\" : \"displayValue\";\n      const prop: keyof IFilterCount = isSortedByCount ? \"count\" : displayValue;\n      let isShownFirst: (val: any) => boolean = isNull;\n      if ([\"Date\", \"DateTime\", \"Numeric\", \"Int\"].includes(this.columnFilter.visibleColumnType)) {\n        isShownFirst = val => isNull(val) || isNaN(val);\n      }\n\n      const comparator: ICompare<any> = (a, b) => {\n        if (isShownFirst(a)) { return -1; }\n        if (isShownFirst(b)) { return 1; }\n        return localeCompare(a,  b);\n      };\n\n      return this._params.valueCount\n        .filter(([key]) => filter.has(key))\n        .sort((a, b) => comparator(a[1][prop], b[1][prop]));\n    },\n  );\n\n  // computes the array of all values that does NOT matches the search text\n  public readonly otherValues = Computed.create(this, this.filterSet, (_use, filter) => {\n    return this._params.valueCount.filter(([key]) => !filter.has(key));\n  });\n\n  // computes the array of keys that matches the search text\n  public readonly filteredKeys = Computed.create(this, this.filterSet, (_use, filter) => {\n    return this._params.valueCount\n      .filter(([key]) => filter.has(key))\n      .map(([key]) => key);\n  });\n\n  public readonly valuesBeyondLimit = Computed.create(this, this.filteredValues, (_use, filteredValues) => {\n    return filteredValues.slice(this.limitShown);\n  });\n\n  constructor(private _params: ColumnFilterMenuModelParams) {\n    super();\n  }\n}\n"
  },
  {
    "path": "app/client/models/ColumnToMap.ts",
    "content": "import * as UserType from \"app/client/widgets/UserType\";\nimport { ColumnToMap } from \"app/plugin/CustomSectionAPI\";\n\n/**\n * Helper that wraps custom widget's column definition and expands all the defaults.\n */\nexport class ColumnToMapImpl implements Required<ColumnToMap> {\n  // Name of the column Custom Widget expects.\n  public name: string;\n  // Label to show instead of the name.\n  public title: string;\n  // Human description of the column.\n  public description: string;\n  // If column is optional (used only on the UI).\n  public optional: boolean;\n  // Type of the column that widget expects. Might be a single or a comma separated list of types.\n  // \"Any\" means that any type is allowed (unless strictType is true).\n  public type: string;\n  // If true, the column type is strict and cannot be any type.\n  public strictType: boolean;\n  // Description of the type (used to show a placeholder).\n  public typeDesc: string;\n  // Allow multiple column assignment (like Series in Charts).\n  public allowMultiple: boolean;\n  constructor(def: string | ColumnToMap) {\n    this.name = typeof def === \"string\" ? def : def.name;\n    this.title = typeof def === \"string\" ? def : (def.title ?? def.name);\n    this.description = typeof def === \"string\" ? \"\" : (def.description ?? \"\");\n    this.optional = typeof def === \"string\" ? false : (def.optional ?? false);\n    this.type = typeof def === \"string\" ? \"Any\" : (def.type ?? \"Any\");\n    this.type = this.type.split(\",\").map(t => t.trim()).filter(Boolean).join(\",\");\n    this.typeDesc = this.type.split(\",\")\n      .map(t => String(UserType.typeDefs[t]?.label ?? \"any\").toLowerCase()).join(\", \");\n    this.allowMultiple = typeof def === \"string\" ? false : (def.allowMultiple ?? false);\n    this.strictType = typeof def === \"string\" ? false : (def.strictType ?? false);\n  }\n\n  /**\n   * Does the column type matches this definition.\n   *\n   * Here are use case examples, for better understanding (Any is treated as a star):\n   * 1. Widget sets \"Text\", user can map to \"Text\" or \"Any\".\n   * 2. Widget sets \"Any\", user can map to \"Int\", \"Toggle\", \"Any\" and any other type.\n   * 3. Widget sets \"Text,Int\", user can map to \"Text\", \"Int\", \"Any\"\n   *\n   * With strictType, the Any in the widget is treated as Any, not a star.\n   * 1. Widget sets \"Text\", user can map to \"Text\".\n   * 2. Widget sets \"Any\", user can map to \"Any\". Not to \"Text\", \"Int\", etc. NOTICE: here Any in widget is not a star,\n   *    widget expects Any as a type so \"Toggle\" column won't be allowed.\n   * 3. Widget sets \"Text,Int\", user can only map to \"Text\", \"Int\".\n   * 4. Widget sets \"Text,Any\", user can only map to \"Text\", \"Any\".\n   */\n  public canByMapped(pureType: string) {\n    const isAny = pureType === \"Any\" || this.type === \"Any\";\n    return this.type.split(\",\").includes(pureType) || (isAny && !this.strictType);\n  }\n}\n"
  },
  {
    "path": "app/client/models/ConnectState.ts",
    "content": "/**\n * The ConnectStateManager helper class helps maintain the connection state. A disconnect goes\n * through multiple stages, to inform the user of long disconnects while minimizing the disruption\n * for short ones. This class manages these timings, and triggers ConnectState changes.\n */\nimport { Disposable, Observable } from \"grainjs\";\n\n// Describes the connection state, which is shown as part of the notifications UI.\n// See https://grist.quip.com/X92IAHZV3uoo/Notifications\nexport enum ConnectState { Connected, JustDisconnected, RecentlyDisconnected, ReallyDisconnected }\n\nexport class ConnectStateManager extends Disposable {\n  // On disconnect, ConnectState changes to JustDisconnected. These intervals set how long after\n  // the disconnect ConnectState should change to other values.\n  public static timeToRecentlyDisconnected = 5000;\n  public static timeToReallyDisconnected = 30000;\n\n  public readonly connectState = Observable.create<ConnectState>(this, ConnectState.Connected);\n\n  private _timers: ReturnType<typeof setTimeout>[] = [];\n\n  public setConnected(yesNo: boolean) {\n    if (yesNo) {\n      this._timers.forEach(t => clearTimeout(t));\n      this._timers = [];\n      this._setState(ConnectState.Connected);\n    } else if (this.connectState.get() === ConnectState.Connected) {\n      this._timers = [\n        setTimeout(() => this._setState(ConnectState.RecentlyDisconnected),\n          ConnectStateManager.timeToRecentlyDisconnected),\n        setTimeout(() => this._setState(ConnectState.ReallyDisconnected),\n          ConnectStateManager.timeToReallyDisconnected),\n      ];\n      this._setState(ConnectState.JustDisconnected);\n    }\n  }\n\n  private _setState(state: ConnectState) {\n    this.connectState.set(state);\n  }\n}\n"
  },
  {
    "path": "app/client/models/DataRowModel.ts",
    "content": "import { KoArray } from \"app/client/lib/koArray\";\nimport * as koUtil from \"app/client/lib/koUtil\";\nimport BaseRowModel from \"app/client/models/BaseRowModel\";\nimport DataTableModel from \"app/client/models/DataTableModel\";\nimport { IRowModel } from \"app/client/models/DocModel\";\nimport { ValidationRec } from \"app/client/models/entities/ValidationRec\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\nimport { buildReassignModal } from \"app/client/ui/buildReassignModal\";\nimport { CellValue, ColValues, DocAction } from \"app/common/DocActions\";\n\nimport * as ko from \"knockout\";\n\n/**\n * DataRowModel is a RowModel for a Data Table. It creates observables for each field in colNames.\n * A DataRowModel is initialized \"unassigned\", and can be assigned to any rowId using `.assign()`.\n */\nexport class DataRowModel extends BaseRowModel {\n  // Instances of this class are indexable, but that is a little awkward to type.\n  // The cells field gives typed access to that aspect of the instance.  This is a\n  // bit hacky, and should be cleaned up when BaseRowModel is ported to typescript.\n  public readonly cells: { [key: string]: modelUtil.KoSaveableObservable<CellValue> } = this as any;\n\n  public _validationFailures: ko.PureComputed<IRowModel<\"_grist_Validations\">[]>;\n  public _isAddRow: ko.Observable<boolean>;\n\n  // Observable that's set whenever a change to a row model is likely to be real, and unset when a\n  // row model is being reassigned to a different row. If a widget uses CSS transitions for\n  // changes, those should only be enabled when _isRealChange is true.\n  public _isRealChange: ko.Observable<boolean>;\n\n  public constructor(dataTableModel: DataTableModel, colNames: string[]) {\n    super(dataTableModel, colNames);\n\n    const allValidationsList: ko.Computed<KoArray<ValidationRec>> = dataTableModel.tableMetaRow.validations;\n\n    this._isAddRow = ko.observable(false);\n\n    // Observable that's set whenever a change to a row model is likely to be real, and unset when a\n    // row model is being reassigned to a different row. If a widget uses CSS transitions for\n    // changes, those should only be enabled when _isRealChange is true.\n    this._isRealChange = ko.observable(true);\n\n    this._validationFailures = this.autoDispose(ko.pureComputed(() => {\n      return allValidationsList().all().filter(\n        validation => !this.cells[this.getValidationNameFromId(validation.id())]());\n    }));\n  }\n\n  /**\n   * Helper method to get the column id of a validation associated with a given id\n   * No code other than this should need to know what\n   * naming scheme is used\n   */\n  public getValidationNameFromId(id: number) {\n    return \"validation___\" + id;\n  }\n\n  /**\n   * Overrides BaseRowModel.updateColValues(), which is used to save fields, to support the special\n   * \"add-row\" records, and to ensure values are up-to-date when the action completes.\n   */\n  public async updateColValues(colValues: ColValues) {\n    const action = this._isAddRow.peek() ?\n      [\"AddRecord\", null, colValues] : [\"UpdateRecord\", this._rowId, colValues];\n\n    try {\n      return await this._table.sendTableAction(action);\n    } catch (ex) {\n      if (ex.code === \"UNIQUE_REFERENCE_VIOLATION\") {\n        // Show modal to repeat the save.\n        await buildReassignModal({\n          docModel: this._table.docModel,\n          actions: [\n            action as DocAction,\n          ],\n        });\n        // Ignore the error here, no point in returning it.\n      } else {\n        throw ex;\n      }\n    } finally {\n      // If the action doesn't actually result in an update to a row, it's important to reset the\n      // observable to the data (if the data did get updated, this will be a no-op). This is also\n      // important for AddRecord: if after the update, this row is again the 'new' row, it needs to\n      // be cleared out.\n      // TODO: in the case when data reverts because an update didn't happen (e.g. typing in\n      // \"12.000\" into a numeric column that has \"12\" in it), there should be a visual indication.\n      Object.keys(colValues).forEach(colId => this._assignColumn(colId));\n    }\n  }\n\n  /**\n   * Assign the DataRowModel to a different row of the table. This is primarily used with koDomScrolly,\n   * when scrolling is accomplished by reusing a few rows of DOM and their underying RowModels.\n   */\n  public assign(rowId: number | \"new\" | null) {\n    this._rowId = rowId;\n    this._isAddRow(rowId === \"new\");\n\n    // When we reassign a row, unset _isRealChange momentarily (to disable CSS transitions).\n    // NOTE: it would be better to only set this flag when there is a data change (rather than unset\n    // it whenever we scroll), but Chrome will only run a transition if it's enabled before the\n    // actual DOM change, so setting this flag in the same tick as a change is not sufficient.\n    this._isRealChange(false);\n    // Include a check to avoid using the observable after the row model has been disposed.\n    setTimeout(() => this.isDisposed() || this._isRealChange(true), 0);\n\n    if (this._rowId !== null) {\n      this._fields.forEach(colName => this._assignColumn(colName));\n    }\n  }\n\n  /**\n   * Helper method to assign a particular column of this row to the associated tabledata.\n   */\n  private _assignColumn(colName: string) {\n    if (!this.isDisposed() && this.hasOwnProperty(colName)) {\n      const value =\n        (this._rowId === \"new\" || !this._rowId) ? \"\" : this._table.tableData.getValue(this._rowId, colName);\n      koUtil.withKoUtils(this.cells[colName]).assign(value);\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/models/DataTableModel.js",
    "content": "var _ = require(\"underscore\");\nvar assert = require(\"assert\");\nvar BackboneEvents = require(\"backbone\").Events;\n\n// Common\nvar gutil      = require(\"app/common/gutil\");\n\n// Libraries\nvar dispose = require(\"../lib/dispose\");\nvar koArray = require(\"../lib/koArray\");\n\n// Models\nvar rowset       = require(\"./rowset\");\nvar TableModel   = require(\"./TableModel\");\nvar {DataRowModel} = require(\"./DataRowModel\");\nconst {TableQuerySets} = require(\"./QuerySet\");\n\n/**\n * DataTableModel maintains the model for an arbitrary data table of a Grist document.\n */\nfunction DataTableModel(docModel, tableData, tableMetaRow) {\n  TableModel.call(this, docModel, tableData);\n\n  this.tableMetaRow = tableMetaRow;\n\n  this.tableQuerySets = new TableQuerySets(this.tableData);\n\n  // New RowModels are created by copying fields from this._newRowModel template. This way we can\n  // update the template on schema changes in the same way we update individual RowModels.\n  // Note that tableMetaRow is incomplete when we get a new table, so we don't rely on it here.\n  var fields = tableData.getColIds();\n  assert(fields.includes(\"id\"), \"Expecting tableData columns to include `id`\");\n\n  // This row model gets schema actions via rowNotify, and is used as a template for new rows.\n  this._newRowModel = this.autoDispose(new DataRowModel(this, fields));\n\n  // TODO: Disposed rows should be removed from the set.\n  this._floatingRows = new Set();\n\n  // Listen for notifications that affect all rows, and apply them to the template row.\n  this.listenTo(this, \"rowNotify\", function(rows, action) {\n    // TODO: (Important) Updates which affect a subset of rows should be handled more efficiently\n    // for _floatingRows.\n    // Ideally this._floatingRows would be a Map from rowId to RowModel, like in the LazyArrayModel.\n    if (rows === rowset.ALL) {\n      this._newRowModel.dispatchAction(action);\n      this._floatingRows.forEach(row => {\n        row.dispatchAction(action);\n      });\n    } else {\n      this._floatingRows.forEach(row => {\n        if (rows.includes(row.getRowId())) { row.dispatchAction(action); }\n      });\n    }\n  });\n\n  // TODO: In the future, we may need RowModel to support fields such as SubRecordList, containing\n  // collections of records from another table (probably using RowGroupings as in MetaTableModel).\n  // We'll need to pay attention to col.type() for that.\n}\n\ndispose.makeDisposable(DataTableModel);\n_.extend(DataTableModel.prototype, TableModel.prototype);\n\n/**\n * Creates and returns a LazyArrayModel of RowModels for the rows in the given sortedRowSet.\n * @param {Function} optRowModelClass: Class to use for a RowModel in place of DataRowModel.\n */\nDataTableModel.prototype.createLazyRowsModel = function(sortedRowSet, optRowModelClass) {\n  var RowModelClass = optRowModelClass || DataRowModel;\n  var self = this;\n  return new LazyArrayModel(sortedRowSet, function makeRowModel() {\n    return new RowModelClass(self, self._newRowModel._fields);\n  });\n};\n\n/**\n * Returns a new rowModel created using `optRowModelClass` or default `DataRowModel`.\n * It is the caller's responsibility to dispose of the returned rowModel.\n */\nDataTableModel.prototype.createFloatingRowModel = function(optRowModelClass) {\n  var RowModelClass = optRowModelClass || DataRowModel;\n  var model = new RowModelClass(this, this._newRowModel._fields);\n  this._floatingRows.add(model);\n  model.autoDisposeCallback(() => {\n    this._floatingRows.delete(model);\n  });\n  return model;\n};\n\n//----------------------------------------------------------------------\n\n/**\n * LazyArrayModel inherits from koArray, and stays parallel to sortedRowSet.getKoArray(),\n * maintaining RowModels for only *some* items, with nulls for the rest.\n *\n * It's tailored for use with koDomScrolly.\n *\n * You must not modify LazyArrayModel, but are free to use non-modifying koArray methods on it.\n * It also exposes methods:\n *    makeItemModel()\n *    setItemModel(rowModel, index)\n * And it takes responsibility for maintaining\n *    rowModel._index() - An observable equal to the current index of this item in the array.\n *\n * @param {rowset.SortedRowSet} sortedRowSet: SortedRowSet to mirror.\n * @param {Function} makeRowModelFunc: A function that creates and returns a DataRowModel.\n *\n * @event rowModelNotify(rowModels, action):\n *    Forwards the action from 'rowNotify' event, but with a list of affected RowModels rather\n *    than a list of affected rowIds. Only instantiated RowModels are included.\n */\nfunction LazyArrayModel(sortedRowSet, makeRowModelFunc) {\n  // The underlying koArray contains some rowModels, and nulls for other elements. We keep it in\n  // sync with rowIdArray. First, initialize a koArray of proper length with all nulls.\n  koArray.KoArray.call(this, sortedRowSet.getKoArray().peek().map(function(r) { return null; }));\n  this._rowIdArray = sortedRowSet.getKoArray();\n  this._makeRowModel = makeRowModelFunc;\n\n  this._assignedRowModels = new Map();    // Assigned rowModels by rowId.\n  this._allRowModels = new Set();         // All instantiated rowModels.\n\n  this.autoDispose(this._rowIdArray.subscribe(this._onSpliceChange, this, \"spliceChange\"));\n  this.listenTo(sortedRowSet, \"rowNotify\", this.onRowNotify);\n\n  // On disposal, dispose each instantiated RowModel.\n  this.autoDisposeCallback(function() {\n    for (let r of this._allRowModels) {\n      // TODO: Ideally, row models should be disposable.\n      if (typeof r.dispose === \"function\") {\n        r.dispose();\n      }\n    }\n  });\n}\n\n/**\n * LazyArrayModel inherits from koArray.\n */\nLazyArrayModel.prototype = Object.create(koArray.KoArray.prototype);\ndispose.makeDisposable(LazyArrayModel);\n_.extend(LazyArrayModel.prototype, BackboneEvents);\n\n\n/**\n * Returns a new item model, as needed by setItemModel(). It is the only way for a new item\n * model to get instantiated.\n */\nLazyArrayModel.prototype.makeItemModel = function() {\n  var rowModel = this._makeRowModel();\n  this._allRowModels.add(rowModel);\n  return rowModel;\n};\n\n/**\n * Unassigns a given rowModel, removing it from the LazyArrayModel.\n * @returns {Boolean} True if rowModel got unset, false if it was already unset.\n */\nLazyArrayModel.prototype.unsetItemModel = function(rowModel) {\n  this.setItemModel(rowModel, null);\n};\n\n/**\n * Assigns a given rowModel to the given index. If the rowModel was previously assigned to a\n * different index, the old index reverts to null. If index is null, unsets the rowModel.\n */\nLazyArrayModel.prototype.setItemModel = function(rowModel, index) {\n  var arr = this.peek();\n\n  // Remove the rowModel from its old index in the observable array, and in _assignedRowModels.\n  var oldIndex = rowModel._index.peek();\n  if (oldIndex !== null && arr[oldIndex] === rowModel) {\n    arr[oldIndex] = null;\n  }\n  if (rowModel._rowId !== null) {\n    this._assignedRowModels.delete(rowModel._rowId);\n  }\n\n  // Handles logic to set the rowModel to the given index.\n  this._setItemModel(rowModel, index);\n\n  if (index !== null && arr.length !== 0) {\n    // Ensure that index is in-range.\n    index = gutil.clamp(index, 0, arr.length - 1);\n\n    // If there is already a model at the destination index, unassign that one.\n    if (arr[index] !== null && arr[index] !== rowModel) {\n      this.unsetItemModel(arr[index]);\n    }\n\n    // Add the newly-assigned model in its place in the array and in _assignedRowModels.\n    arr[index] = rowModel;\n    this._assignedRowModels.set(rowModel._rowId, rowModel);\n  }\n};\n\n/**\n * Assigns a given floating rowModel to the given index.\n * If index is null, unsets the floating rowModel.\n */\nLazyArrayModel.prototype.setFloatingRowModel = function(rowModel, index) {\n  this._setItemModel(rowModel, index);\n};\n\n/**\n * Helper function to assign a given rowModel to the given index. Used by setItemModel\n * and setFloatingRowModel. Does not interact with the array, only the model itself.\n */\nLazyArrayModel.prototype._setItemModel = function(rowModel, index) {\n  var arr = this.peek();\n\n  if (index === null || arr.length === 0) {\n    // Unassign the rowModel if index is null or if there is no valid place to assign it to.\n    rowModel._index(null);\n    rowModel.assign(null);\n  } else {\n    // Otherwise, ensure that index is in-range.\n    index = gutil.clamp(index, 0, arr.length - 1);\n\n    // Assign the rowModel and set its index.\n    rowModel._index(index);\n    rowModel.assign(this._rowIdArray.peek()[index]);\n  }\n};\n\n/**\n * Called for any updates to rows, including schema changes. This may affect some or all of the\n * rows; in the latter case, rows will be the constant rowset.ALL.\n */\nLazyArrayModel.prototype.onRowNotify = function(rows, action) {\n  if (rows === rowset.ALL) {\n    for (let rowModel of this._allRowModels) {\n      rowModel.dispatchAction(action);\n    }\n    this.trigger(\"rowModelNotify\", this._allRowModels);\n  } else {\n    var affectedRowModels = [];\n    for (let r of rows) {\n      var rowModel = this._assignedRowModels.get(r);\n      if (rowModel) {\n        rowModel.dispatchAction(action);\n        affectedRowModels.push(rowModel);\n      }\n    }\n    this.trigger(\"rowModelNotify\", affectedRowModels);\n  }\n};\n\n/**\n * Internal helper called on any change in the underlying _rowIdArray. We mirror each new rowId\n * with a null. Removed rows are unassigned. We also update subsequent indices.\n */\nLazyArrayModel.prototype._onSpliceChange = function(splice) {\n  var numDeleted = splice.deleted.length;\n  var i, n;\n\n  // Unassign deleted models, and leave for the garbage collector to find.\n  var arr = this.peek();\n  for (i = splice.start, n = 0; n < numDeleted; i++, n++) {\n    if (arr[i]) {\n      this.unsetItemModel(arr[i]);\n    }\n  }\n\n  // Update indices for other affected elements.\n  var delta = splice.added - numDeleted;\n  if (delta !== 0) {\n    var firstToAdjust = splice.start + numDeleted;\n    for (let rowModel of this._assignedRowModels.values()) {\n      var index = rowModel._index.peek();\n      if (index >= firstToAdjust) {\n        rowModel._index(index + delta);\n      }\n    }\n  }\n\n  // Construct the arguments for the splice call to apply to ourselves.\n  var newSpliceArgs = new Array(2 + splice.added);\n  newSpliceArgs[0] = splice.start;\n  newSpliceArgs[1] = numDeleted;\n  for (i = 2; i < newSpliceArgs.length; i++) {\n    newSpliceArgs[i] = null;\n  }\n\n  // Apply the splice to ourselves, inserting nulls for the newly-added items.\n  this.arraySplice(splice.start, numDeleted, gutil.arrayRepeat(splice.added, null));\n};\n\n/**\n * Returns the rowId at the given index from the rowIdArray. (Subscribes if called in a computed.)\n */\nLazyArrayModel.prototype.getRowId = function(index) {\n  return this._rowIdArray.at(index);\n};\n\n/**\n * Returns the index of the given rowId, or -1 if not found. (Does not subscribe to array.)\n */\nLazyArrayModel.prototype.getRowIndex = function(rowId) {\n  return this._rowIdArray.peek().indexOf(rowId);\n};\n\n/**\n * Returns the index of the given rowId, or -1 if not found. (Subscribes if called in a computed.)\n */\nLazyArrayModel.prototype.getRowIndexWithSub = function(rowId) {\n  return this._rowIdArray.all().indexOf(rowId);\n};\n\n/**\n * Returns the rowModel for the given rowId.\n * Returns undefined when there is no rowModel for the given rowId, which is often the case\n *  when it is scrolled out of view.\n */\nLazyArrayModel.prototype.getRowModel = function(rowId) {\n  return this._assignedRowModels.get(rowId);\n};\n\nmodule.exports = DataTableModel;\n"
  },
  {
    "path": "app/client/models/DataTableModelWithDiff.ts",
    "content": "import BaseRowModel from \"app/client/models/BaseRowModel\";\nimport DataTableModel from \"app/client/models/DataTableModel\";\nimport { DocModel } from \"app/client/models/DocModel\";\nimport { TableRec } from \"app/client/models/entities/TableRec\";\nimport { TableQuerySets } from \"app/client/models/QuerySet\";\nimport { ChangeType, RowGrouping, RowList, RowsChanged, SortedRowSet } from \"app/client/models/rowset\";\nimport { TableData } from \"app/client/models/TableData\";\nimport { ActionSummarizer } from \"app/common/ActionSummarizer\";\nimport { createEmptyActionSummary, createEmptyTableDelta, getTableIdAfter,\n  getTableIdBefore, TableDelta } from \"app/common/ActionSummary\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { CellVersions, DocAction, UserAction } from \"app/common/DocActions\";\nimport { DocStateComparisonDetails } from \"app/common/DocState\";\nimport { CellDelta } from \"app/common/TabularDiff\";\nimport { CellValue, GristObjCode } from \"app/plugin/GristData\";\n\n// A special row id, representing omitted rows.\nconst ROW_ID_SKIP = -1;\n\n/**\n * Represent extra rows in a table that correspond to rows added in a remote (right) document,\n * or removed in the local (left) document relative to a common ancestor.\n *\n * We assign synthetic row ids for these rows somewhat arbitrarily as follows:\n *  - For rows added remotely, we map their id to - id * 2 - 1\n *  - For rows removed locally, we map their id to - id * 2 - 2\n *  - (id of -1 is left free for use in skipped rows)\n * This should be the only part of the code that knows that.\n */\nexport class ExtraRows {\n  /**\n   * Map back from a possibly synthetic row id to an original strictly-positive row id.\n   */\n  public static interpretRowId(\n    rowId: number,\n  ): { type: \"remote-add\" | \"local-remove\" | \"shared\" | \"skipped\", id: number } {\n    if (rowId >= 0) {\n      return { type: \"shared\", id: rowId };\n    } else if (rowId === ROW_ID_SKIP) {\n      return { type: \"skipped\", id: rowId };\n    } else if (rowId % 2 !== 0) {\n      return { type: \"remote-add\", id: -(rowId + 1) / 2 };\n    }\n    return { type: \"local-remove\", id: -(rowId + 2) / 2 };\n  }\n\n  public readonly leftTableDelta?: TableDelta;\n  public readonly rightTableDelta?: TableDelta;\n  public readonly rightAddRows: Set<number>;\n  public readonly rightRemoveRows: Set<number>;\n  public leftAddRows: Set<number>;\n  public leftRemoveRows: Set<number>;\n\n  public constructor(public readonly tableId?: string, public readonly comparison?: DocStateComparisonDetails) {\n    if (!tableId) {\n      this.rightAddRows = new Set();\n      this.rightRemoveRows = new Set();\n      this.leftAddRows = new Set();\n      this.leftRemoveRows = new Set();\n      return;\n    }\n    const remoteTableId = getRemoteTableId(tableId, comparison);\n    this.leftTableDelta = this.comparison?.leftChanges?.tableDeltas[tableId];\n    if (remoteTableId) {\n      this.rightTableDelta = this.comparison?.rightChanges?.tableDeltas[remoteTableId];\n    }\n    this.rightAddRows = new Set(this.rightTableDelta?.addRows.map(id => this.encodeRightAddRow(id)));\n    this.rightRemoveRows = new Set(this.rightTableDelta?.removeRows);\n    this.leftAddRows = new Set(this.leftTableDelta?.addRows);\n    this.leftRemoveRows = new Set(this.leftTableDelta?.removeRows.map(id => this.encodeLeftRemoveRow(id)));\n  }\n\n  public encodeLeftRemoveRow(id: number) {\n    return -id * 2 - 2;\n  }\n\n  public encodeRightAddRow(id: number) {\n    return -id * 2 - 1;\n  }\n\n  /**\n   * Get a list of extra synthetic row ids to add.\n   */\n  public getExtraRows(): readonly number[] {\n    return [...this.rightAddRows].concat([...this.leftRemoveRows]);\n  }\n\n  /**\n   * Classify the row as either remote-add, remote-remove, local-add, or local-remove.\n   */\n  public getRowType(rowId: number) {\n    if (this.rightAddRows.has(rowId)) {\n      return \"remote-add\";\n    } else if (this.leftAddRows.has(rowId)) {\n      return \"local-add\";\n    } else if (this.rightRemoveRows.has(rowId)) {\n      return \"remote-remove\";\n    } else if (this.leftRemoveRows.has(rowId)) {\n      return \"local-remove\";\n    }\n    // TODO: consider what should happen when a row is removed both locally and remotely.\n    return \"\";\n  }\n}\n\n/**\n *\n * A variant of DataTableModel that is aware of a comparison with another version of the table.\n * The constructor takes a DataTableModel and DocStateComparisonDetails.  We act as a proxy\n * for that DataTableModel, with the following changes to tableData:\n *\n *   - a cell changed remotely from A to B is given the value ['X', {parent: A, remote: B}].\n *   - a cell changed locally from A to B1 and remotely from A to B2 is given the value\n *     ['X', {parent: A, local: B1, remote: B2}].\n *   - negative rowIds are served from the remote table.\n *\n */\nexport class DataTableModelWithDiff extends DisposableWithEvents implements DataTableModel {\n  public docModel: DocModel;\n  public isLoaded: ko.Observable<boolean>;\n  public tableData: TableData;\n  public tableMetaRow: TableRec;\n  public tableQuerySets: TableQuerySets;\n  public extraRows: ExtraRows;\n\n  // For viewing purposes (LazyRowsModel), cells should have comparison info, so we will\n  // forward to a comparison-aware wrapper. Otherwise, the model is left substantially\n  // unchanged for now.\n  private _wrappedModel: DataTableModel;\n\n  /**\n   * The _comparison provided to this DataTableModelWithDiff may be mutated. It is used\n   * to store and track local changes.\n   */\n  public constructor(public core: DataTableModel, private _comparison: DocStateComparisonDetails) {\n    super();\n    this.tableMetaRow = core.tableMetaRow;\n    this.tableQuerySets = core.tableQuerySets;\n    this.docModel = core.docModel;\n    const tableId = core.tableData.tableId;\n    const remoteTableId = getRemoteTableId(tableId, _comparison) || tableId;\n    this.extraRows = new ExtraRows(this.core.tableData.tableId, this._comparison);\n    _comparison.leftChanges.tableDeltas[tableId] ||= createEmptyTableDelta();\n    _comparison.rightChanges.tableDeltas[remoteTableId] ||= createEmptyTableDelta();\n    const tableDataWithDiff = new TableDataWithDiff(\n      core.tableData,\n      _comparison.leftChanges.tableDeltas[tableId],\n      _comparison.rightChanges.tableDeltas[remoteTableId],\n      this.extraRows,\n    ) as any;\n    this.tableData = tableDataWithDiff;\n    this.isLoaded = core.isLoaded;\n    this._wrappedModel = this.autoDispose(new DataTableModel(this.docModel, this.tableData, this.tableMetaRow));\n\n    this.listenTo(this._wrappedModel, \"rowChange\", (changeType: ChangeType, rows: RowList) => {\n      this.trigger(\"rowChange\", changeType, rows);\n    });\n    this.listenTo(this._wrappedModel, \"rowNotify\", (rows: RowsChanged, notifyValue: any) => {\n      this.trigger(\"rowNotify\", rows, notifyValue);\n    });\n    // Listen for actions about to be applied, so we can snapshot cell values\n    // before mutation and track them as local changes in the diff.\n    this.autoDispose(core.tableData.preTableActionEmitter.addListener(\n      tableDataWithDiff.before.bind(tableDataWithDiff),\n    ));\n  }\n\n  public getExtraRows() {\n    return this.extraRows;\n  }\n\n  public createLazyRowsModel(sortedRowSet: SortedRowSet, optRowModelClass: any) {\n    return this._wrappedModel.createLazyRowsModel(sortedRowSet, optRowModelClass);\n  }\n\n  public createFloatingRowModel(optRowModelClass?: any): BaseRowModel {\n    return this._wrappedModel.createFloatingRowModel(optRowModelClass);\n  }\n\n  public fetch(force?: boolean): Promise<void> {\n    return this.core.fetch(force);\n  }\n\n  public getAllRows(): readonly number[] {\n    // Could add remote rows, but this method isn't used so it doesn't matter.\n    return this.core.getAllRows();\n  }\n\n  public getNumRows(): number {\n    return this.core.getNumRows();\n  }\n\n  public getRowGrouping(groupByCol: string): RowGrouping<CellValue> {\n    return this.core.getRowGrouping(groupByCol);\n  }\n\n  public sendTableActions(actions: UserAction[], optDesc?: string): Promise<any[]> {\n    return this.core.sendTableActions(actions, optDesc);\n  }\n\n  public sendTableAction(action: UserAction, optDesc?: string): Promise<any> | undefined {\n    return this.core.sendTableAction(action, optDesc);\n  }\n}\n\n/**\n * A variant of TableData that is aware of a comparison with another version of the table.\n * TODO: flesh out, just included essential members so far.\n */\nexport class TableDataWithDiff {\n  public dataLoadedEmitter: any;\n  public tableActionEmitter: any;\n  public preTableActionEmitter: any;\n\n  private _leftRemovals: Set<number>;\n  private _rightRemovals: Set<number>;\n  private _updates: Set<number>;\n\n  constructor(public core: TableData, public leftTableDelta: TableDelta,\n    public rightTableDelta: TableDelta, public extraRows: ExtraRows) {\n    this.dataLoadedEmitter = core.dataLoadedEmitter;\n    this.tableActionEmitter = core.tableActionEmitter;\n    this.preTableActionEmitter = core.preTableActionEmitter;\n    // Construct the set of all rows updated in either left/local or right/remote.\n    // Omit any rows that were deleted in the other version, for simplicity.\n    this._leftRemovals = new Set(leftTableDelta.removeRows);\n    this._rightRemovals = new Set(rightTableDelta.removeRows);\n    this._updates = new Set([\n      ...leftTableDelta.updateRows.filter(r => !this._rightRemovals.has(r)),\n      ...rightTableDelta.updateRows.filter(r => !this._leftRemovals.has(r)),\n    ]);\n  }\n\n  public getColIds(): string[] {\n    return this.core.getColIds();\n  }\n\n  public getColType(colId: string) {\n    return this.core.getColType(colId);\n  }\n\n  public sendTableActions(actions: UserAction[], optDesc?: string): Promise<any[]> {\n    return this.core.sendTableActions(actions, optDesc);\n  }\n\n  public sendTableAction(action: UserAction, optDesc?: string): Promise<any> | undefined {\n    return this.core.sendTableAction(action, optDesc);\n  }\n\n  public receiveAction(action: DocAction): boolean {\n    return this.core.receiveAction(action);\n  }\n\n  /**\n   * Make a variant of getter for a column that calls getValue for rows added remotely,\n   * or rows with updates.\n   */\n  public getRowPropFunc(colId: string) {\n    const fn = this.core.getRowPropFunc(colId);\n    if (!fn) { return fn; }\n    return (rowId: number | \"new\") => {\n      if (rowId !== \"new\" && (rowId < 0 || this._updates.has(rowId))) {\n        return this.getValue(rowId, colId);\n      }\n      return fn(rowId);\n    };\n  }\n\n  public getKeepFunc(): undefined | ((rowId: number | \"new\") => boolean) {\n    return (rowId: number | \"new\") => {\n      return rowId === \"new\" || this._updates.has(rowId) || rowId < 0 ||\n        this._leftRemovals.has(rowId) || this._rightRemovals.has(rowId);\n    };\n  }\n\n  public getSkipRowId(): number {\n    return ROW_ID_SKIP;\n  }\n\n  public mayHaveVersions() {\n    return true;\n  }\n\n  /**\n   * Intercept requests for updated cells or cells from remote rows.\n   */\n  public getValue(rowId: number, colId: string): CellValue | undefined {\n    if (rowId === ROW_ID_SKIP && colId !== \"id\") {\n      return [GristObjCode.Skip];\n    }\n    if (this._updates.has(rowId)) {\n      const left = this.leftTableDelta.columnDeltas[colId]?.[rowId];\n      const right = this.rightTableDelta.columnDeltas[colId]?.[rowId];\n      if (left !== undefined && right !== undefined) {\n        return [GristObjCode.Versions, {\n          parent: oldValue(left),\n          local: newValue(left),\n          remote: newValue(right),\n        } as CellVersions];\n      } else if (right !== undefined) {\n        return [GristObjCode.Versions, {\n          parent: oldValue(right),\n          remote: newValue(right),\n        } as CellVersions];\n      } else if (left !== undefined) {\n        return [GristObjCode.Versions, {\n          parent: oldValue(left),\n          local: newValue(left),\n        } as CellVersions];\n      }\n    } else {\n      // keep row.id consistent with rowId for convenience.\n      if (colId === \"id\") { return rowId; }\n      const { type, id } = ExtraRows.interpretRowId(rowId);\n      if (type === \"remote-add\") {\n        const cell = this.rightTableDelta.columnDeltas[colId]?.[id];\n        const value = (cell !== undefined) ? newValue(cell) : undefined;\n        return value;\n      } else if (type === \"local-remove\") {\n        const cell = this.leftTableDelta.columnDeltas[colId]?.[id];\n        const value = (cell !== undefined) ? oldValue(cell) : undefined;\n        return value;\n      }\n    }\n    return this.core.getValue(rowId, colId);\n  }\n\n  public get tableId() { return this.core.tableId; }\n\n  public numRecords() {\n    return this.core.numRecords();\n  }\n\n  /**\n   * Called via preTableActionEmitter, just before a DocAction is applied to the\n   * underlying table. When the user edits while viewing a comparison, those edits\n   * need to appear as local changes in the diff. The problem is that DocActions only\n   * carry *new* cell values. By running here — before the action mutates the table —\n   * we can use ActionSummarizer.addAction() to build a delta that pairs each new value\n   * with the current (soon-to-be-old) value read from this.core. The resulting delta\n   * is then folded into leftTableDelta so the diff display reflects the edit.\n   */\n  public before(action: DocAction): void {\n    const op = new ActionSummarizer();\n    const sum = createEmptyActionSummary();\n    op.addAction(sum, action, this.core);\n\n    const tableDelta = Object.values(sum.tableDeltas)[0];\n    if (!tableDelta) {\n      return;\n    }\n\n    this._processUpdateRows(tableDelta);\n    this._processAddRows(tableDelta);\n    this._processRemoveRows(tableDelta);\n  }\n\n  /**\n   * For each updated cell, record a delta in leftTableDelta. If this is the first\n   * local edit to this cell, snapshot the current value as the \"parent\" (index 0) —\n   * subsequent edits to the same cell keep that original parent, so the diff always\n   * shows the change from the comparison base, not from intermediate edits.\n   * The new value (index 1) is always overwritten with the latest.\n   */\n  private _processUpdateRows(tableDelta: TableDelta): void {\n    for (const rowId of tableDelta.updateRows) {\n      for (const colId of Object.keys(tableDelta.columnDeltas)) {\n        this._ensureColumnExists(colId);\n\n        if (!this.leftTableDelta.columnDeltas[colId][rowId]) {\n          const row = this.core.getRecord(rowId);\n          const cell = row?.[colId];\n          const nestedCell = cell === undefined ? null : [cell] as [any];\n\n          this.leftTableDelta.columnDeltas[colId][rowId] = [nestedCell, null];\n\n          if (!this.leftTableDelta.updateRows.includes(rowId)) {\n            this.leftTableDelta.updateRows.push(rowId);\n            this._updates.add(rowId);\n          }\n        }\n\n        this.leftTableDelta.columnDeltas[colId][rowId][1] =\n          tableDelta.columnDeltas[colId]?.[rowId]?.[1];\n      }\n    }\n  }\n\n  /**\n   * Record locally-added rows. The parent value (index 0) is null since the row\n   * didn't exist in the comparison base.\n   */\n  private _processAddRows(tableDelta: TableDelta): void {\n    for (const rowId of tableDelta.addRows) {\n      for (const colId of Object.keys(tableDelta.columnDeltas)) {\n        this._ensureColumnExists(colId);\n\n        if (!this.leftTableDelta.columnDeltas[colId][rowId]) {\n          this.leftTableDelta.columnDeltas[colId][rowId] = [null, null];\n\n          if (!this.leftTableDelta.addRows.includes(rowId)) {\n            this.leftTableDelta.addRows.push(rowId);\n          }\n        }\n\n        this.leftTableDelta.columnDeltas[colId][rowId][1] =\n          tableDelta.columnDeltas[colId]?.[rowId]?.[1];\n\n        this._updates.add(rowId);\n        this.extraRows.leftAddRows.add(rowId);\n      }\n    }\n  }\n\n  /**\n   * Record locally-removed rows. If a row was added locally and is now being\n   * removed, the add and remove cancel out — we just clean up the bookkeeping.\n   * Otherwise, we record the pre-removal cell values (index 0) so the diff can\n   * show what was deleted.\n   */\n  private _processRemoveRows(tableDelta: TableDelta): void {\n    for (const rowId of tableDelta.removeRows) {\n      if (this.extraRows.leftAddRows.has(rowId)) {\n        this._cleanupAddedRow(rowId);\n        continue;\n      }\n\n      for (const colId of Object.keys(tableDelta.columnDeltas)) {\n        this._ensureColumnExists(colId);\n\n        if (!this.leftTableDelta.columnDeltas[colId][rowId]) {\n          this.leftTableDelta.columnDeltas[colId][rowId] = [null, null];\n          if (!this.leftTableDelta.removeRows.includes(rowId)) {\n            this.leftTableDelta.removeRows.push(rowId);\n          }\n        }\n\n        this.leftTableDelta.columnDeltas[colId][rowId][0] =\n          tableDelta.columnDeltas[colId]?.[rowId]?.[0];\n\n        this._updates.add(rowId);\n        this.extraRows.leftRemoveRows.add(\n          this.extraRows.encodeLeftRemoveRow(rowId),\n        );\n      }\n    }\n  }\n\n  private _ensureColumnExists(colId: string): void {\n    if (!this.leftTableDelta.columnDeltas[colId]) {\n      this.leftTableDelta.columnDeltas[colId] = {};\n    }\n  }\n\n  private _cleanupAddedRow(rowId: number): void {\n    this.extraRows.leftAddRows.delete(rowId);\n    this._updates.delete(rowId);\n    this.leftTableDelta.addRows = this.leftTableDelta.addRows.filter(id => id !== rowId);\n  }\n}\n\n/**\n * Get original value from a cell change, if available.\n */\nfunction oldValue(delta: CellDelta) {\n  if (delta[0] === \"?\") { return null; }\n  return delta[0]?.[0];\n}\n\n/**\n * Get new value from a cell change, if available.\n */\nfunction newValue(delta: CellDelta) {\n  if (delta[1] === \"?\") { return null; }\n  return delta[1]?.[0];\n}\n\n/**\n * Figure out the id of the specified table in the remote document.\n * Returns null if table is deleted or unknown in the remote document.\n */\nfunction getRemoteTableId(tableId: string, comparison?: DocStateComparisonDetails) {\n  if (!comparison) { return tableId; }\n  const parentTableId = getTableIdBefore(comparison.leftChanges.tableRenames, tableId);\n  return getTableIdAfter(comparison.rightChanges.tableRenames, parentTableId);\n}\n"
  },
  {
    "path": "app/client/models/DocData.ts",
    "content": "/**\n * DocData maintains all underlying data for a Grist document, knows how to load it,\n * subscribes to actions which change it, and forwards those actions to individual tables.\n * It also provides the interface to apply actions to data.\n */\n\nimport { DocComm } from \"app/client/components/DocComm\";\nimport { MetaTableData, TableData } from \"app/client/models/TableData\";\nimport { ApplyUAOptions, ApplyUAResult } from \"app/common/ActiveDocAPI\";\nimport { CellValue, getTableId, isDataAction, TableDataAction, UserAction } from \"app/common/DocActions\";\nimport { DocData as BaseDocData } from \"app/common/DocData\";\nimport { SchemaTypes } from \"app/common/schema\";\nimport { ColTypeMap } from \"app/common/TableData\";\n\nimport * as bluebird from \"bluebird\";\nimport { Emitter } from \"grainjs\";\nimport defaults from \"lodash/defaults\";\n\nconst gristNotify = window.gristNotify!;\n\nexport class DocData extends BaseDocData {\n  public readonly sendActionsEmitter = new Emitter();\n  public readonly sendActionsDoneEmitter = new Emitter();\n\n  private _bundlesPending: number = 0;          // How many bundles are currently pending.\n  private _lastBundlePromise?: Promise<void>;   // Promise for completion of the last pending bundle.\n  private _triggerBundleFinalize?: () => void;  // When a bundle is pending, trigger its finalize() callback.\n\n  // When a bundle is pending and actions should be checked, the callback to check them.\n  private _shouldIncludeInBundle?: (actions: UserAction[]) => boolean;\n\n  private _nextDesc: string | null = null;        // The description for the next incoming action.\n  private _lastActionNum: number | null = null;   // ActionNum of the last action in the current bundle, or null.\n  private _bundleSender: BundleSender;\n\n  private _virtualTablesFunc: Map<string, Constructor<TableData>>;\n\n  /**\n   * Constructor for DocData.\n   * @param {Object} docComm: A map of server methods available on this document.\n   * @param {Object} metaTableData: A map from tableId to table data, presented as an action,\n   *      equivalent to BulkAddRecord, i.e. [\"TableData\", tableId, rowIds, columnValues].\n   */\n  constructor(public readonly docComm: DocComm, metaTableData: { [tableId: string]: TableDataAction }) {\n    super(tableId => docComm.fetchTable(tableId), metaTableData);\n    this._bundleSender = new BundleSender(this.docComm);\n    this._virtualTablesFunc = new Map();\n  }\n\n  public createTableData(tableId: string, tableData: TableDataAction | null, colTypes: ColTypeMap): TableData {\n    const Cons = this._virtualTablesFunc?.get(tableId) || TableData;\n    return new Cons(this, tableId, tableData, colTypes);\n  }\n\n  // Version of inherited getTable() which returns the enhance TableData type.\n  public getTable(tableId: string): TableData | undefined {\n    return super.getTable(tableId) as TableData;\n  }\n\n  // Version of inherited getMetaTable() which returns the enhanced TableData type.\n  public getMetaTable<TableId extends keyof SchemaTypes>(tableId: TableId): MetaTableData<TableId> {\n    return super.getMetaTable(tableId) as any;\n  }\n\n  /**\n   * Finds up to n most likely target columns for the given values in the document.\n   */\n  public async findColFromValues(values: any[], n: number, optTableId?: string): Promise<number[]> {\n    try {\n      return await this.docComm.findColFromValues(values, n, optTableId);\n    } catch (e) {\n      gristNotify(`Error finding matching columns: ${e.message}`);\n      return [];\n    }\n  }\n\n  /**\n   * Returns error message (traceback) for one invalid formula cell.\n   */\n  public getFormulaError(tableId: string, colId: string, rowId: number): Promise<CellValue> {\n    return this.docComm.getFormulaError(tableId, colId, rowId);\n  }\n\n  // Sets a bundle to collect all incoming actions. Throws an error if any actions which\n  // do not match the verification callback are sent.\n  public startBundlingActions<T>(options: BundlingOptions<T>): BundlingInfo<T> {\n    if (this._bundlesPending >= 2) {\n      // We don't expect a full-blown queue of bundles or actions at any point. If a bundle is\n      // pending, a new bundle should immediately finalize it. Here we refuse to queue up more\n      // actions than that. (This could crop up in theory while disconnected, but is hard to\n      // trigger to test.)\n      throw new Error(\"Too many actions already pending\");\n    }\n    this._bundlesPending++;\n\n    // Promise to allow waiting for the result of prepare() callback before it's even called.\n    let prepareResolve!: (value: T | Promise<T>) => void;\n    const preparePromise = new Promise<T>((resolve) => { prepareResolve = resolve; });\n\n    // Manually-triggered promise for when finalize() should be called. It's triggered by user,\n    // and when an unrelated action or a new bundle is started.\n    let triggerFinalize!: () => void;\n    const triggerFinalizePromise = new Promise<void>((resolve) => { triggerFinalize = resolve; });\n\n    const doBundleActions = async () => {\n      if (this._lastBundlePromise) {\n        this._triggerBundleFinalize?.();\n        await this._lastBundlePromise;\n      }\n      try {\n        this._nextDesc = options.description;\n        this._lastActionNum = null;\n        this._triggerBundleFinalize = triggerFinalize;\n        prepareResolve(options.prepare());\n        this._shouldIncludeInBundle = options.shouldIncludeInBundle;\n\n        // If finalize is triggered, we must wait for preparePromise to fulfill before proceeding.\n        await Promise.all([triggerFinalizePromise, preparePromise]);\n\n        // Unset _shouldIncludeInBundle so that actions sent by finalize() are included in the\n        // bundle. If they were checked and incorrectly failed the check, we'd have a deadlock.\n        // TODO The downside is that when sending multiple unrelated actions quickly, the first\n        // can trigger finalize, and subsequent ones can get bundled in while finalize() is\n        // running. This changes the order of actions and may create problems (e.g. with undo).\n        this._shouldIncludeInBundle = undefined;\n        await options.finalize();\n      } finally {\n        // In all cases, reset the bundle-specific values we set above\n        this._shouldIncludeInBundle = undefined;\n        this._triggerBundleFinalize = undefined;\n        this._bundlesPending--;\n        if (this._bundlesPending === 0) {\n          this._lastBundlePromise = undefined;\n        }\n      }\n    };\n\n    const completionPromise = this._lastBundlePromise = doBundleActions();\n    return { preparePromise, triggerFinalize, completionPromise };\n  }\n\n  // Execute a callback that may send multiple actions, and bundle those actions together. The\n  // callback may return a promise, in which case bundleActions() will wait for it to resolve.\n  // If nestInActiveBundle is true, and there is an active bundle, then simply calls callback()\n  // without starting a new bundle.\n  public async bundleActions<T>(desc: string | null, callback: () => T | Promise<T>,\n    options: { nestInActiveBundle?: boolean } = {}): Promise<T> {\n    if (options.nestInActiveBundle && this._bundlesPending) {\n      return await callback();\n    }\n    const bundlingInfo = this.startBundlingActions<T>({\n      description: desc,\n      shouldIncludeInBundle: () => true,\n      prepare: callback,\n      finalize: async () => undefined,\n    });\n    try {\n      return await bundlingInfo.preparePromise;\n    } finally {\n      bundlingInfo.triggerFinalize();\n      await bundlingInfo.completionPromise;\n    }\n  }\n\n  /**\n   * Sends actions to the server to be applied.\n   * @param {String} optDesc: Optional description of the actions to be shown in the log.\n   *\n   * sendActions also emits two events:\n   * 'sendActions': emitted before the action is sent, with { actions } object as data.\n   * 'sendActionsDone': emitted on success, with the same data object.\n   *   Note that it allows a handler for 'sendActions' to pass along information to the handler\n   *   for the corresponding 'sendActionsDone', by tacking it onto the event data object.\n   */\n  public sendActions(actions: UserAction[], optDesc?: string): Promise<any[]> {\n    // Some old code relies on this promise being a bluebird Promise.\n    // TODO Remove bluebird and this cast.\n    return bluebird.Promise.resolve(this._sendActionsImpl(actions, optDesc)) as unknown as Promise<any[]>;\n  }\n\n  /**\n   * Sends a single action to the server to be applied. Calls this.sendActions to manage the\n   * optional bundle.\n   * @param {String} optDesc: Optional description of the actions to be shown in the log.\n   */\n  public sendAction(action: UserAction, optDesc?: string): Promise<any> {\n    return this.sendActions([action], optDesc).then(retValues => retValues[0]);\n  }\n\n  public registerVirtualTableFactory(tableId: string, Cons: typeof TableData) {\n    this._virtualTablesFunc.set(tableId, Cons);\n  }\n\n  public unregisterVirtualTableFactory(tableId: string) {\n    this._virtualTablesFunc.delete(tableId);\n  }\n\n  // See documentation of sendActions().\n  private async _sendActionsImpl(actions: UserAction[], optDesc?: string): Promise<any[]> {\n    const tableName = String(actions[0]?.[1]);\n    if (this._virtualTablesFunc?.has(tableName)) {\n      // Actions applying to virtual tables are handled directly by their TableData instance.\n      for (const action of actions) {\n        if (!isDataAction(action)) {\n          throw new Error(\"virtual table received an action it cannot handle\");\n        }\n        if (getTableId(action) !== tableName) {\n          throw new Error(\"virtual table actions mixed with other actions\");\n        }\n      }\n      const tableActions = actions.map(a => [a[0], ...a.slice(2)]);\n      // The type on sendTableActions seems kind of misleading, and\n      // only working because UserAction is defined weakly. The first\n      // thing the method does is splice back in the table names...\n      return this.getTable(tableName)!.sendTableActions(tableActions, optDesc);\n    }\n    const eventData = { actions };\n    this.sendActionsEmitter.emit(eventData);\n    const options = { desc: optDesc };\n    if (this._shouldIncludeInBundle && !this._shouldIncludeInBundle(actions)) {\n      this._triggerBundleFinalize?.();\n      await this._lastBundlePromise;\n    }\n    if (this._bundlesPending) {\n      defaults(options, {\n        desc: this._nextDesc,\n        linkId: this._lastActionNum,\n      });\n      this._nextDesc = null;\n    }\n\n    const result: ApplyUAResult = await this._bundleSender.applyUserActions(actions, options);\n    this._lastActionNum = result.actionNum;\n    this.sendActionsDoneEmitter.emit(eventData);\n    return result.retValues;\n  }\n}\n\n/**\n * BundleSender helper class collects multiple applyUserActions() calls that happen on the same\n * tick, and sends them to the server all at once.\n */\nclass BundleSender {\n  private _options = {};\n  private _actions: UserAction[] = [];\n  private _sendPromise?: Promise<ApplyUAResult>;\n\n  constructor(private _docComm: DocComm) {}\n\n  public applyUserActions(actions: UserAction[], options: ApplyUAOptions): Promise<ApplyUAResult> {\n    defaults(this._options, options);\n    const start = this._actions.length;\n    this._actions.push(...actions);\n    const end = this._actions.length;\n    return this._getSendPromise()\n      .then(result => ({\n        actionNum: result.actionNum,\n        actionHash: result.actionHash,\n        retValues: result.retValues.slice(start, end),\n        isModification: result.isModification,\n      }));\n  }\n\n  public _getSendPromise(): Promise<ApplyUAResult> {\n    if (!this._sendPromise) {\n      // Note that the first Promise.resolve() ensures that the next step (actual send) happens on\n      // the next tick. By that time, more actions may have been added to this._actions array.\n      this._sendPromise = Promise.resolve()\n        .then(() => {\n          this._sendPromise = undefined;\n          const ret = this._docComm.applyUserActions(this._actions, this._options);\n          this._options = {};\n          this._actions = [];\n          return ret;\n        });\n    }\n    return this._sendPromise;\n  }\n}\n\n/**\n * Options to startBundlingAction().\n */\nexport interface BundlingOptions<T = unknown> {\n  // Description of the action bundle.\n  description: string | null;\n\n  // Checker for whether an action belongs in the current bundle. If not, finalize() will be\n  // called immediately. Note that this checker is NOT applied for actions sent from prepare()\n  // or finalize() callbacks, only those in between.\n  shouldIncludeInBundle: (actions: UserAction[]) => boolean;\n\n  // Callback to start this action bundle.\n  prepare: () => T | Promise<T>;\n\n  // Callback to finalize this action bundle.\n  finalize: () => Promise<void>;\n}\n\n/**\n * Result of startBundlingActions(), to allow waiting for prepare() to complete, and to trigger\n * finalize() manually, and to wait for the full bundle to complete.\n */\nexport interface BundlingInfo<T = unknown> {\n  // Promise for when the prepare() has completed. Note that sometimes it's delayed until the\n  // previous bundle has been finalized.\n  preparePromise: Promise<T>;\n\n  // Ask DocData to call the finalize callback immediately.\n  triggerFinalize: () => void;\n\n  // Promise for when the bundle has been finalized.\n  completionPromise: Promise<void>;\n}\n\ntype Constructor<T> = new (...args: any[]) => T;\n"
  },
  {
    "path": "app/client/models/DocModel.ts",
    "content": "/**\n * DocModel describes the observable models for all document data, including the built-in tables\n * (aka metatables), which are used in the Grist application itself (e.g. to render views).\n *\n * Since all data is structured as tables, we have several levels of models:\n * (1) DocModel maintains all tables\n * (2) MetaTableModel maintains data for a built-in table.\n * (3) DataTableModel maintains data for a user-defined table.\n * (4) RowModels (defined in {Data,Meta}TableModel.js) maintains data for one record in a table.\n *     For built-in tables, the records are defined in this module, below.\n */\nimport { KoArray } from \"app/client/lib/koArray\";\nimport * as koArray from \"app/client/lib/koArray\";\nimport * as koUtil from \"app/client/lib/koUtil\";\nimport DataTableModel from \"app/client/models/DataTableModel\";\nimport { DocData } from \"app/client/models/DocData\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { ACLRuleRec, createACLRuleRec } from \"app/client/models/entities/ACLRuleRec\";\nimport { CellRec, createCellRec } from \"app/client/models/entities/CellRec\";\nimport { ColumnRec, createColumnRec } from \"app/client/models/entities/ColumnRec\";\nimport { createDocInfoRec, DocInfoRec } from \"app/client/models/entities/DocInfoRec\";\nimport { createFilterRec, FilterRec } from \"app/client/models/entities/FilterRec\";\nimport { createPageRec, PageRec } from \"app/client/models/entities/PageRec\";\nimport { createShareRec, ShareRec } from \"app/client/models/entities/ShareRec\";\nimport { createTabBarRec, TabBarRec } from \"app/client/models/entities/TabBarRec\";\nimport { createTableRec, TableRec } from \"app/client/models/entities/TableRec\";\nimport { createValidationRec, ValidationRec } from \"app/client/models/entities/ValidationRec\";\nimport { createViewFieldRec, ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { createViewRec, ViewRec } from \"app/client/models/entities/ViewRec\";\nimport { createViewSectionRec, ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport MetaRowModel from \"app/client/models/MetaRowModel\";\nimport MetaTableModel from \"app/client/models/MetaTableModel\";\nimport { KoSaveableObservable } from \"app/client/models/modelUtil\";\nimport * as rowset from \"app/client/models/rowset\";\nimport { TableData } from \"app/client/models/TableData\";\nimport { isRefListType, RecalcWhen, RefListValue } from \"app/common/gristTypes\";\nimport { isNonNullish } from \"app/common/gutil\";\nimport { isHiddenTable, isSummaryTable } from \"app/common/isHiddenTable\";\nimport { canEdit } from \"app/common/roles\";\nimport { RowFilterFunc } from \"app/common/RowFilterFunc\";\nimport { schema, SchemaTypes } from \"app/common/schema\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nimport { Disposable, toKo } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport memoize from \"lodash/memoize\";\n\n// Re-export all the entity types available. The recommended usage is like this:\n//    import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';\nexport type { ColumnRec, DocInfoRec, FilterRec, PageRec, TabBarRec, TableRec, ValidationRec,\n  ViewFieldRec, ViewRec, ViewSectionRec, CellRec };\n\n/**\n * Creates the type for a MetaRowModel containing a KoSaveableObservable for each field listed in\n * the auto-generated app/common/schema.ts. It represents the metadata record in the database.\n * Particular DocModel entities derive from this, and add other helpful computed values.\n */\nexport type IRowModel<TName extends keyof SchemaTypes> = MetaRowModel<TName> & {\n  [ColId in keyof SchemaTypes[TName]]: KoSaveableObservable<SchemaTypes[TName][ColId]>;\n};\n\n/**\n * Returns an observable for an observable array of records from the given table.\n *\n * @param {RowModel} rowModel: RowModel that owns this recordSet.\n * @param {TableModel} tableModel: The model for the table to return records from.\n * @param {String} groupByField: The name of the field in the other table by which to group. The\n *    returned observable arrays will be for the group matching the value of rowModel.id().\n * @param {String} [options.sortBy]: Keep the returned array sorted by this key. If omitted, the\n *    returned array will be sorted by rowId.\n */\nexport function recordSet<TRow extends MetaRowModel>(\n  rowModel: MetaRowModel, tableModel: MetaTableModel<TRow>, groupByField: string, options?: { sortBy: string },\n): ko.Computed<KoArray<TRow>> {\n  const opts = { groupBy: groupByField, sortBy: \"id\", ...options };\n  return koUtil.computedAutoDispose(() => {\n    const id = rowModel.id();\n    return id ? tableModel.createRowGroupModel(id, opts) : new KoArray();\n  }, null, { pure: true });\n}\n\n/**\n * Returns an observable for a record from another table, selected using the passed-in observable\n * for a rowId. If rowId is invalid, returns the row model for the fake empty record.\n * @param {TableModel} tableModel: The model for the table to return a record from.\n * @param {ko.observable} rowIdObs: An observable for the row id to look up.\n */\nexport function refRecord<TRow extends MetaRowModel>(\n  tableModel: MetaTableModel<TRow>, rowIdObs: ko.Observable<number> | ko.Computed<number>,\n): ko.Computed<TRow> {\n  // Pass 'true' to getRowModel() to depend on the row version.\n  return ko.pureComputed(() => tableModel.getRowModel(rowIdObs() || 0, true));\n}\n\n/**\n * Returns an observable with a list of records from another table, selected using RefList column.\n * @param {TableModel} tableModel: The model for the table to return a record from.\n * @param {ko.observable} rowsIdObs: An observable with a RefList value.\n */\nexport function refListRecords<TRow extends MetaRowModel>(\n  tableModel: MetaTableModel<TRow>, rowsIdObs: ko.Observable<RefListValue> | ko.Computed<RefListValue>,\n) {\n  return ko.pureComputed(() => {\n    const ids = decodeObject(rowsIdObs()) as number[] | null;\n    if (!Array.isArray(ids)) {\n      return [];\n    }\n    return ids.map(id => tableModel.getRowModel(id, true));\n  });\n}\n\n// Use an alias for brevity.\ntype MTM<RowModel extends MetaRowModel> = MetaTableModel<RowModel>;\n\nexport class DocModel extends Disposable {\n  // MTM is a shorthand for MetaTableModel below, to keep each item to one line.\n  public docInfo: MTM<DocInfoRec> = this._metaTableModel(\"_grist_DocInfo\", createDocInfoRec);\n  public tables: MTM<TableRec> = this._metaTableModel(\"_grist_Tables\", createTableRec);\n  public columns: MTM<ColumnRec> = this._metaTableModel(\"_grist_Tables_column\", createColumnRec);\n  public views: MTM<ViewRec> = this._metaTableModel(\"_grist_Views\", createViewRec);\n  public viewSections: MTM<ViewSectionRec> = this._metaTableModel(\"_grist_Views_section\", createViewSectionRec);\n  public viewFields: MTM<ViewFieldRec> = this._metaTableModel(\"_grist_Views_section_field\", createViewFieldRec);\n  public tabBar: MTM<TabBarRec> = this._metaTableModel(\"_grist_TabBar\", createTabBarRec);\n  public validations: MTM<ValidationRec> = this._metaTableModel(\"_grist_Validations\", createValidationRec);\n  public pages: MTM<PageRec> = this._metaTableModel(\"_grist_Pages\", createPageRec);\n  public shares: MTM<ShareRec> = this._metaTableModel(\"_grist_Shares\", createShareRec);\n  public rules: MTM<ACLRuleRec> = this._metaTableModel(\"_grist_ACLRules\", createACLRuleRec);\n  public filters: MTM<FilterRec> = this._metaTableModel(\"_grist_Filters\", createFilterRec);\n  public cells: MTM<CellRec> = this._metaTableModel(\"_grist_Cells\", createCellRec);\n\n  public docInfoRow: DocInfoRec;\n\n  public allTables: KoArray<TableRec>;\n  public visibleTables: KoArray<TableRec>;\n  public rawDataTables: KoArray<TableRec>;\n  public rawSummaryTables: KoArray<TableRec>;\n\n  public allTableIds: KoArray<string>;\n  public visibleTableIds: KoArray<string>;\n\n  // A mapping from tableId to DataTableModel for user-defined tables.\n  public dataTables: { [tableId: string]: DataTableModel } = {};\n\n  // Another map, this one mapping tableRef (rowId) to DataTableModel.\n  public dataTablesByRef = new Map<number, DataTableModel>();\n\n  public allTabs: KoArray<TabBarRec> = this.autoDispose(this.tabBar.createAllRowsModel(\"tabPos\"));\n\n  public allPages: ko.Computed<PageRec[]>;\n  /** Pages that are shown in the menu. These can include censored pages if they have children. */\n  public menuPages: ko.Computed<PageRec[]>;\n  // Excludes pages hidden by ACL rules or other reasons (e.g. doc-tour)\n  public visibleDocPages: ko.Computed<PageRec[]>;\n\n  // Flag for tracking whether document is in formula-editing mode\n  public editingFormula: ko.Observable<boolean> = ko.observable(false);\n\n  // If the doc has a docTour. Used also to enable the UI button to restart the tour.\n  public readonly hasDocTour: ko.Computed<boolean>;\n\n  public readonly isTutorial: ko.Computed<boolean>;\n\n  // TODO This is a temporary solution until we expose creation of doc-tours to users. This flag\n  // is initialized once on page load. If set, then the tour page (if any) will be visible.\n  public showDocTourTable: boolean = (urlState().state.get().docPage === \"GristDocTour\");\n\n  // Whether the GristDocTutorial table should be shown. Initialized once on page load.\n  public showDocTutorialTable: boolean =\n    // We skip subscribing to the observables below since they normally shouldn't change during\n    // this object's lifetime. If that changes, this should be made into a computed observable.\n    !this._docPageModel?.isTutorialFork.get() ||\n    canEdit(this._docPageModel.currentDoc.get()?.trunkAccess ?? null);\n\n  // List of all the metadata tables.\n  private _metaTables: MetaTableModel<any>[];\n\n  constructor(public readonly docData: DocData, private readonly _docPageModel?: DocPageModel) {\n    super();\n\n    // This ensures we drop references to held objects when disposed (like DocData). This helps\n    // avoid memory leaks if a reference to the DocModel itself is retained anywhere.\n    this.wipeOnDispose();\n\n    // When DocModel is disposed, disposed all the held DataTableModels.\n    this.onDispose(() => {\n      for (const dt of Object.values(this.dataTables)) {\n        dt.dispose();\n      }\n    });\n\n    // For all the metadata tables, load their data (and create the RowModels).\n    for (const model of this._metaTables) {\n      model.loadData();\n    }\n\n    this.docInfoRow = this.docInfo.getRowModel(1);\n\n    // An observable array of all tables, sorted by tableId, with no exclusions.\n    this.allTables = this.autoDispose(this._createAllTablesArray());\n\n    // An observable array of user-visible tables, sorted by tableId, excluding summary tables.\n    // This is a publicly exposed member.\n    this.visibleTables = this.autoDispose(this._createVisibleTablesArray());\n\n    // Observable arrays of raw data and summary tables, sorted by tableId.\n    this.rawDataTables = this.autoDispose(this._createRawDataTablesArray());\n    this.rawSummaryTables = this.autoDispose(this._createRawSummaryTablesArray());\n\n    // An observable array of all tableIds. A shortcut mapped from allTables.\n    const allTableIds = this.autoDispose(ko.computed(() => this.allTables.all().map(t => t.tableId())));\n    this.allTableIds = koArray.syncedKoArray(allTableIds);\n\n    // An observable array of user-visible tableIds. A shortcut mapped from visibleTables.\n    const visibleTableIds = this.autoDispose(ko.computed(() => this.visibleTables.all().map(t => t.tableId())));\n    this.visibleTableIds = koArray.syncedKoArray(visibleTableIds);\n\n    // Create an observable array of RowModels for all the data tables. We'll trigger\n    // onAddTable/onRemoveTable in response to this array's splice events below.\n    const allTableMetaRows = this.autoDispose(this.tables.createAllRowsModel(\"id\"));\n\n    // For a new table, we get AddTable action followed by metadata actions to add a table record\n    // (which triggers this subscribeForEach) and to add all the column records. So we have to keep\n    // in mind that metadata for columns isn't available yet.\n    this.autoDispose(allTableMetaRows.subscribeForEach({\n      add: r => this._onAddTable(r),\n      remove: r => this._onRemoveTable(r),\n    }));\n\n    // Get a list of only the visible pages.\n    const allPages = this.autoDispose(this.pages.createAllRowsModel(\"pagePos\"));\n    this.allPages = this.autoDispose(ko.computed(() => allPages.all()));\n    this.menuPages = this.autoDispose(ko.computed(() => {\n      const pagesToShow = this.allPages().filter(p => !p.isSpecial()).sort((a, b) => a.pagePos() - b.pagePos());\n      const parent = memoize((page: PageRec) => {\n        const myIdentation = page.indentation();\n        if (myIdentation === 0) { return null; }\n        const idx = pagesToShow.indexOf(page);\n        // Find first page starting from before that has lower indentation then mine.\n        const beforeMe = pagesToShow.slice(0, idx).reverse();\n        return beforeMe.find(p => p.indentation() < myIdentation) ?? null;\n      });\n      const ancestors = memoize((page: PageRec): PageRec[] => {\n        const anc = parent(page);\n        return anc ? [anc, ...ancestors(anc)] : [];\n      });\n      // Helper to test if the page is hidden or is in a hidden branch.\n      const hidden = memoize((page: PageRec): boolean => page.isHidden() || ancestors(page).some(p => p.isHidden()));\n      return pagesToShow.filter(p => !hidden(p));\n    }));\n    this.visibleDocPages = this.autoDispose(ko.computed(() => this.allPages().filter(p => !p.isHidden())));\n\n    this.hasDocTour = this.autoDispose(ko.computed(() => this.visibleTableIds.all().includes(\"GristDocTour\")));\n\n    this.isTutorial = this.autoDispose(ko.computed(() =>\n      isNonNullish(this._docPageModel) &&\n      toKo(ko, this._docPageModel.isTutorialFork)() &&\n      this.allTableIds.all().includes(\"GristDocTutorial\")));\n  }\n\n  public getTableModel(tableId: string) {\n    return this.dataTables[tableId];\n  }\n\n  /**\n   * If the given section is the target of linking, collect and return the active rowIDs up the\n   * chain of links, returning the list of rowIds starting with the current section's parent. This\n   * method is intended for when there is ambiguity such as when RefList linking is involved.\n   * In other cases, returns undefined.\n   */\n  public getLinkingRowIds(sectionId: number): UIRowId[] | undefined {\n    const linkingRowIds: UIRowId[] = [];\n    let anyAmbiguity = false;\n    let section = this.viewSections.getRowModel(sectionId);\n    const seen = new Set<number>();\n    while (section?.id.peek() && !seen.has(section.id.peek())) {\n      seen.add(section.id.peek());\n      const rowId = section.activeRowId.peek() || \"new\";\n      if (isRefListType(section.linkTargetCol.peek().type.peek()) || rowId === \"new\") {\n        anyAmbiguity = true;\n      }\n      linkingRowIds.push(rowId);\n      section = section.linkSrcSection.peek();\n    }\n    return anyAmbiguity ? linkingRowIds.slice(1) : undefined;\n  }\n\n  // Turn the given columns into empty columns, losing any data stored in them.\n  public async clearColumns(colRefs: number[], { keepType}: { keepType?: boolean } = {}): Promise<void> {\n    await this.columns.sendTableAction(\n      [\"BulkUpdateRecord\", colRefs, {\n        isFormula: colRefs.map(f => true),\n        formula: colRefs.map(f => \"\"),\n        ...(keepType ? {} : {\n          type: colRefs.map(f => \"Any\"),\n          widgetOptions: colRefs.map(f => \"\"),\n          visibleCol: colRefs.map(f => null),\n          displayCol: colRefs.map(f => null),\n          rules: colRefs.map(f => null),\n        }),\n        // Set recalc settings to defaults when emptying a column.\n        recalcWhen: colRefs.map(f => RecalcWhen.DEFAULT),\n        recalcDeps: colRefs.map(f => null),\n      }],\n    );\n  }\n\n  // Convert the given columns to data, saving the calculated values and unsetting the formulas.\n  public async convertIsFormula(colRefs: number[], opts: { toFormula: boolean, noRecalc?: boolean }): Promise<void> {\n    return this.columns.sendTableAction(\n      [\"BulkUpdateRecord\", colRefs, {\n        isFormula: colRefs.map(f => opts.toFormula),\n        recalcWhen: colRefs.map(f => opts.noRecalc ? RecalcWhen.NEVER : RecalcWhen.DEFAULT),\n        recalcDeps: colRefs.map(f => null),\n      }],\n    );\n  }\n\n  // Updates formula for a column.\n  public async updateFormula(colRef: number, formula: string): Promise<void> {\n    return this.columns.sendTableAction(\n      [\"UpdateRecord\", colRef, {\n        formula,\n      }],\n    );\n  }\n\n  // Convert column to pure formula column.\n  public async convertToFormula(colRef: number, formula: string): Promise<void> {\n    return this.columns.sendTableAction(\n      [\"UpdateRecord\", colRef, {\n        isFormula: true,\n        formula,\n        recalcWhen: RecalcWhen.DEFAULT,\n        recalcDeps: null,\n      }],\n    );\n  }\n\n  // Convert column to data column with a trigger formula\n  public async convertToTrigger(\n    colRefs: number,\n    formula: string,\n    recalcWhen: RecalcWhen = RecalcWhen.DEFAULT): Promise<void> {\n    return this.columns.sendTableAction(\n      [\"UpdateRecord\", colRefs, {\n        isFormula: false,\n        formula,\n        recalcWhen: recalcWhen,\n        recalcDeps: null,\n      }],\n    );\n  }\n\n  private _metaTableModel<TName extends keyof SchemaTypes, TRow extends IRowModel<TName>>(\n    tableId: TName,\n    rowConstructor: (this: TRow, docModel: DocModel) => void,\n  ): MetaTableModel<TRow> {\n    const fields = Object.keys(schema[tableId]);\n    const model = new MetaTableModel<TRow>(this, this.docData.getTable(tableId)!, fields, rowConstructor);\n    // To keep _metaTables private member listed after public ones, initialize it on first use.\n    if (!this._metaTables) { this._metaTables = []; }\n    this._metaTables.push(model);\n    return this.autoDispose(model);\n  }\n\n  private _onAddTable(tableMetaRow: TableRec) {\n    let tid = tableMetaRow.tableId();\n    const dtm = new DataTableModel(this, this.docData.getTable(tid)!, tableMetaRow);\n    this.dataTables[tid] = dtm;\n    this.dataTablesByRef.set(tableMetaRow.getRowId(), dtm);\n\n    // Subscribe to tableMetaRow.tableId() to handle table renames.\n    tableMetaRow.tableId.subscribe((newTableId) => {\n      this.dataTables[newTableId] = this.dataTables[tid];\n      delete this.dataTables[tid];\n      tid = newTableId;\n    });\n  }\n\n  private _onRemoveTable(tableMetaRow: TableRec) {\n    const tid = tableMetaRow.tableId();\n    this.dataTables[tid].dispose();\n    delete this.dataTables[tid];\n    this.dataTablesByRef.delete(tableMetaRow.getRowId());\n  }\n\n  /**\n   * Returns an observable array of all tables, sorted by tableId.\n   */\n  private _createAllTablesArray(): KoArray<TableRec> {\n    return createTablesArray(this.tables);\n  }\n\n  /**\n   * Returns an observable array of user tables, sorted by tableId, and excluding hidden/summary\n   * tables.\n   */\n  private _createVisibleTablesArray(): KoArray<TableRec> {\n    return createTablesArray(this.tables, r =>\n      !isHiddenTable(this.tables.tableData, r) &&\n      !isVirtualTable(this.tables.tableData, r) &&\n      (!isTutorialTable(this.tables.tableData, r) || this.showDocTutorialTable),\n    );\n  }\n\n  /**\n   * Returns an observable array of raw data tables, sorted by tableId, and excluding summary\n   * tables.\n   */\n  private _createRawDataTablesArray(): KoArray<TableRec> {\n    return createTablesArray(this.tables, r =>\n      !isSummaryTable(this.tables.tableData, r) &&\n      (!isTutorialTable(this.tables.tableData, r) || this.showDocTutorialTable),\n    );\n  }\n\n  /**\n   * Returns an observable array of raw summary tables, sorted by tableId.\n   */\n  private _createRawSummaryTablesArray(): KoArray<TableRec> {\n    return createTablesArray(this.tables, r => isSummaryTable(this.tables.tableData, r));\n  }\n}\n\n/**\n * Creates an observable array of tables, sorted by tableId.\n *\n * An optional `filterFunc` may be specified to filter tables.\n */\nfunction createTablesArray(\n  tablesModel: MetaTableModel<TableRec>,\n  filterFunc: RowFilterFunc<UIRowId> = _row => true,\n) {\n  const rowSource = new rowset.FilteredRowSource(filterFunc);\n  rowSource.subscribeTo(tablesModel);\n  // Create an observable RowModel array based on this rowSource, sorted by tableId.\n  return tablesModel._createRowSetModel(rowSource, \"tableId\");\n}\n\n/**\n * Return whether a table (identified by the rowId of its metadata record) is\n * the special GristDocTutorial table.\n */\nfunction isTutorialTable(tablesData: TableData, tableRef: UIRowId): boolean {\n  return tablesData.getValue(tableRef, \"tableId\") === \"GristDocTutorial\";\n}\n\n/**\n * Check whether a table is virtual - currently that is done\n * by having a string rowId rather than the expected integer.\n */\nfunction isVirtualTable(tablesData: TableData, tableRef: UIRowId): boolean {\n  return typeof (tableRef) === \"string\";\n}\n"
  },
  {
    "path": "app/client/models/DocPageModel.ts",
    "content": "import { GristDoc, GristDocImpl } from \"app/client/components/GristDoc\";\nimport { IUndoState } from \"app/client/components/UndoStack\";\nimport { UnsavedChange } from \"app/client/components/UnsavedChanges\";\nimport { loadGristDoc } from \"app/client/lib/imports\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { AppModel, getOrgNameOrGuest, reportError } from \"app/client/models/AppModel\";\nimport { getDoc } from \"app/client/models/gristConfigCache\";\nimport { docUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { addNewButton, cssAddNewButton } from \"app/client/ui/AddNewButton\";\nimport { App } from \"app/client/ui/App\";\nimport { cssLeftPanel, cssScrollPane } from \"app/client/ui/LeftPanelCommon\";\nimport { buildPagesDom } from \"app/client/ui/Pages\";\nimport { openPageWidgetPicker } from \"app/client/ui/PageWidgetPicker\";\nimport { tools } from \"app/client/ui/Tools\";\nimport { bigBasicButton } from \"app/client/ui2018/buttons\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { menu, menuDivider, menuIcon, menuItem, menuText } from \"app/client/ui2018/menus\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\nimport { mapGetOrSet, MapWithTTL } from \"app/common/AsyncCreate\";\nimport { AsyncFlow, CancelledError, FlowRunner } from \"app/common/AsyncFlow\";\nimport { delay } from \"app/common/delay\";\nimport { OpenDocMode, OpenDocOptions, OpenLocalDocResult, UserOverride } from \"app/common/DocListAPI\";\nimport { createEmptyDocStateComparison } from \"app/common/DocState\";\nimport { FilteredDocUsageSummary } from \"app/common/DocUsage\";\nimport { Features, mergedFeatures, Product } from \"app/common/Features\";\nimport { buildUrlId, CompareEmphasis, IGristUrlState, parseUrlId, UrlIdParts } from \"app/common/gristUrls\";\nimport { getReconnectTimeout } from \"app/common/gutil\";\nimport { canEdit, isOwner } from \"app/common/roles\";\nimport { UserInfo } from \"app/common/User\";\nimport {\n  DOCTYPE_TEMPLATE,\n  DOCTYPE_TUTORIAL,\n  Document,\n  DocumentType,\n  NEW_DOCUMENT_CODE,\n  Organization,\n  PermissionData,\n  Proposal,\n  UserAPI,\n  Workspace,\n} from \"app/common/UserAPI\";\n\nimport { Computed, Disposable, dom, DomArg, DomElementArg, Holder, Observable, subscribe } from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\n\nconst t = makeT(\"DocPageModel\");\n\nexport interface DocInfo extends Document {\n  isReadonly: boolean;\n  isPreFork: boolean;\n  isFork: boolean;\n  isRecoveryMode: boolean;\n  user: UserInfo | null;\n  userOverride: UserOverride | null;\n  isBareFork: boolean;  // a document created without logging in, which is treated as a\n  // fork without an original.\n  isSnapshot: boolean;\n  isTutorialTrunk: boolean;\n  isTutorialFork: boolean;\n  isTemplate: boolean;\n  idParts: UrlIdParts;\n  openMode: OpenDocMode;\n}\n\nexport interface DocPageModel {\n  pageType: \"doc\";\n\n  appModel: AppModel;\n  currentDoc: Observable<DocInfo | null>;\n  currentDocUsage: Observable<FilteredDocUsageSummary | null>;\n\n  /**\n   * Initially set to the product referenced by `currentDoc`, and updated whenever `currentDoc`\n   * changes, or a doc usage message is received from the server.\n   */\n  currentProduct: Observable<Product | null>;\n  /**\n   * Current features of the product\n   */\n  currentFeatures: Observable<Features | null>;\n\n  // This block is to satisfy previous interface, but usable as this.currentDoc.get().id, etc.\n  currentDocId: Observable<string | undefined>;\n  currentWorkspace: Observable<Workspace | null>;\n  // We may be given information about the org, because of our access to the doc, that\n  // we can't get otherwise.\n  currentOrg: Observable<Organization | null>;\n  currentOrgName: Observable<string>;\n  currentDocTitle: Observable<string>;\n  isReadonly: Observable<boolean>;\n  isPrefork: Observable<boolean>;\n  isFork: Observable<boolean>;\n  isRecoveryMode: Observable<boolean>;\n  user: Observable<UserInfo | null>;\n  userOverride: Observable<UserOverride | null>;\n  isBareFork: Observable<boolean>;\n  isSnapshot: Observable<boolean>;\n  isTutorialTrunk: Observable<boolean>;\n  isTutorialFork: Observable<boolean>;\n  isTemplate: Observable<boolean>;\n  type: Observable<DocumentType>;\n  importSources: ImportSource[];\n  currentProposal: Observable<Proposal | \"empty\" | null>;\n  proposalNewChangesCount: Observable<number | \"...\" | null>;\n\n  undoState: Observable<IUndoState | null>;          // See UndoStack for details.\n\n  gristDoc: Observable<GristDoc | null>;             // Instance of GristDoc once it exists.\n\n  /** List of users with access to the document, null if not initialized. */\n  docUsers: Observable<PermissionData | null>;\n\n  createLeftPane(leftPanelOpen: Observable<boolean>): DomArg;\n  renameDoc(value: string): Promise<void>;\n  refreshCurrentDoc(doc: DocInfo): Promise<Document>;\n  updateCurrentDocUsage(docUsage: FilteredDocUsageSummary): void;\n  refreshProposal(): Promise<Proposal | \"empty\" | undefined>;\n  // Offer to open document in recovery mode, if user is owner, and report\n  // the error that prompted the offer. If user is not owner, just flag that\n  // document needs attention of an owner.\n  offerRecovery(err: Error): void;\n  clearUnsavedChanges(): void;\n  /**\n   * Fetches user with access to the document, and updates `docUsers` observable. Caches result\n   * for 60 seconds.\n   */\n  refreshDocumentAccess(): Promise<void>;\n}\n\nexport interface ImportSource {\n  label: string;\n  action: () => void;\n}\n\nexport class DocPageModelImpl extends Disposable implements DocPageModel {\n  // Observable set to the instance of GristDoc once it's created.\n  public readonly gristDoc = Observable.create<GristDocImpl | null>(this, null);\n\n  public readonly pageType = \"doc\";\n\n  public readonly currentDoc = Observable.create<DocInfo | null>(this, null);\n  public readonly currentDocUsage = Observable.create<FilteredDocUsageSummary | null>(this, null);\n\n  /**\n   * Initially set to the product referenced by `currentDoc`, and updated whenever `currentDoc`\n   * changes, or a doc usage message is received from the server.\n   */\n  public readonly currentProduct = Observable.create<Product | null>(this, null);\n  public readonly currentFeatures: Computed<Features | null>;\n\n  public readonly currentUrlId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.urlId : undefined);\n  public readonly currentDocId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.id : undefined);\n  public readonly currentWorkspace = Computed.create(this, this.currentDoc, (use, doc) => doc?.workspace ?? null);\n  public readonly currentOrg = Computed.create(this, this.currentWorkspace, (use, ws) => ws?.org ?? null);\n  public readonly currentOrgName = Computed.create(this, this.currentOrg,\n    (use, org) => getOrgNameOrGuest(org, this.appModel.currentUser));\n\n  public readonly currentDocTitle = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.name : \"\");\n  public readonly isReadonly = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isReadonly : false);\n  public readonly isPrefork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isPreFork : false);\n  public readonly isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false);\n  public readonly isRecoveryMode = Computed.create(this, this.currentDoc,\n    (use, doc) => doc ? doc.isRecoveryMode : false);\n\n  public readonly user = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.user : null);\n  public readonly userOverride = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.userOverride : null);\n  public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false);\n  public readonly isSnapshot = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSnapshot : false);\n  public readonly isTutorialTrunk = Computed.create(this, this.currentDoc,\n    (use, doc) => doc ? doc.isTutorialTrunk : false);\n\n  public readonly isTutorialFork = Computed.create(this, this.currentDoc,\n    (use, doc) => doc ? doc.isTutorialFork : false);\n\n  public readonly isTemplate = Computed.create(this, this.currentDoc,\n    (use, doc) => doc ? doc.isTemplate : false);\n\n  public readonly type = Computed.create(this, this.currentDoc,\n    (use, doc) => doc?.type ?? null);\n\n  public readonly currentProposal = Observable.create<Proposal | \"empty\" | null>(this, null);\n  public readonly proposalNewChangesCount = Observable.create<number | \"...\" | null>(this, null);\n\n  public readonly importSources: ImportSource[] = [];\n\n  // Contains observables indicating whether undo/redo are disabled. See UndoStack for details.\n  public readonly undoState: Observable<IUndoState | null> = Observable.create(this, null);\n\n  public readonly docUsers = Observable.create<PermissionData | null>(this, null);\n\n  private readonly _docUsersData = new MapWithTTL<\"users\", Promise<PermissionData>>(60 * 1000);\n\n  // Combination of arguments needed to open a doc (docOrUrlId + openMod). It's obtained from the\n  // URL, and when it changes, we need to re-open.\n  // If making a comparison, the id of the document we are comparing with is also included\n  // in the openerDocKey.\n  private _openerDocKey: string = \"\";\n\n  // Holds a FlowRunner for _openDoc, which is essentially a cancellable promise. It gets replaced\n  // (with the previous promise cancelled) when _openerDocKey changes.\n  private _openerHolder = Holder.create<FlowRunner>(this);\n\n  private readonly _unsavedChangeHolder = Holder.create<UnsavedChange>(this);\n\n  private readonly _isUnsavedFork = Computed.create(this,\n    this.isFork,\n    this.isSnapshot,\n    this.isTutorialFork,\n    (use, isFork, isSnapshot, isTutorialFork) => isFork && !isSnapshot && !isTutorialFork,\n  );\n\n  constructor(private _appObj: App, public readonly appModel: AppModel, private _api: UserAPI = appModel.api) {\n    super();\n\n    this.currentFeatures = Computed.create(this, (use) => {\n      const product = use(this.currentProduct);\n      if (!product) { return null; }\n      const ba = use(this.currentOrg)?.billingAccount?.features ?? {};\n      const merged = mergedFeatures(ba, product.features);\n      return merged;\n    });\n\n    this.initialize();\n  }\n\n  public initialize() {\n    this.autoDispose(subscribe(this.currentDoc, this.gristDoc, (use, currentDoc, gristDoc) => {\n      if (!gristDoc || !currentDoc) { return; }\n      const acceptProposals = currentDoc?.options?.proposedChanges?.acceptProposals;\n      if (!acceptProposals || !currentDoc.isFork) { return null; }\n      this.refreshProposal().catch(reportError);\n    }));\n    this.autoDispose(subscribe(urlState().state, (use, state) => {\n      const urlId = state.doc;\n      const urlOpenMode = state.mode;\n      const linkParameters = state.params?.linkParameters;\n      const docKey = this._getDocKey(state);\n      if (docKey !== this._openerDocKey) {\n        this._openerDocKey = docKey;\n        this.gristDoc.set(null);\n        this.currentDoc.set(null);\n        this.undoState.set(null);\n        if (!urlId) {\n          this._openerHolder.clear();\n        } else {\n          FlowRunner.create(\n            this._openerHolder,\n            (flow: AsyncFlow) => this.appModel.notifier.slowNotification(this._openDoc(flow, urlId, {\n              openMode: urlOpenMode,\n              linkParameters,\n              originalUrlId: state.doc,\n            }, {\n              comparisonUrlId: state.params?.compare,\n              compareEmphasis: state.params?.compareEmphasis,\n            })),\n          )\n            .resultPromise.catch(err => this._onOpenError(err));\n        }\n      }\n    }));\n\n    this.autoDispose(this.currentOrg.addListener((org) => {\n      // Whenever the current doc is updated, set the current product to be the\n      // one referenced by the updated doc.\n      if (org?.billingAccount?.product.name !== this.currentProduct.get()?.name) {\n        this.currentProduct.set(org?.billingAccount?.product ?? null);\n      }\n    }));\n\n    this.autoDispose(this._isUnsavedFork.addListener((isUnsavedFork) => {\n      if (isUnsavedFork) {\n        UnsavedChange.create(this._unsavedChangeHolder);\n      } else {\n        this._unsavedChangeHolder.clear();\n      }\n    }));\n  }\n\n  public async refreshDocumentAccess() {\n    const data = await mapGetOrSet(this._docUsersData, \"users\", () =>\n      this.appModel.api.getDocAccess(this.currentDocId.get()!),\n    );\n    if (this.isDisposed()) { return; }\n    const existing = this.docUsers.get();\n    if (!existing || !isEqual(existing, data)) {\n      this.docUsers.set(data);\n    }\n  }\n\n  public async refreshProposal(): Promise<Proposal | \"empty\" | undefined> {\n    const gristDoc = this.gristDoc.get();\n    if (!gristDoc) { return; }\n    const urlId = gristDoc.docPageModel.currentDocId.get();\n    if (!urlId) { return; }\n    const proposals = await gristDoc.appModel.api.getDocAPI(urlId).getProposals({\n      outgoing: true,\n    });\n    if (this.isDisposed()) { return; }\n    const proposal = (proposals.proposals[0] ?? \"empty\") as Proposal | \"empty\";\n    this.currentProposal.set(proposal);\n    if (proposal === \"empty\" || proposal.status.status === \"retracted\") {\n      this.gristDoc.get()?.getActionCounter().setMarkToBaseAction();\n    } else {\n      this.gristDoc.get()?.getActionCounter().setMark(proposal.comparison.comparison?.left);\n    }\n    return proposal;\n  }\n\n  public createLeftPane(leftPanelOpen: Observable<boolean>) {\n    return cssLeftPanel(\n      dom.maybe(this.gristDoc, activeDoc => [\n        addNewButton({ isOpen: leftPanelOpen },\n          menu(() => addMenu(this.importSources, activeDoc, this.isReadonly.get()), {\n            placement: \"bottom-start\",\n            // \"Add New\" menu should have the same width as the \"Add New\" button that opens it.\n            stretchToSelector: `.${cssAddNewButton.className}`,\n          }),\n          testId(\"dp-add-new\"),\n          dom.cls(\"tour-add-new\"),\n        ),\n        cssScrollPane(\n          dom.create(buildPagesDom, activeDoc, leftPanelOpen),\n          dom.create(tools, activeDoc, leftPanelOpen),\n        ),\n      ]),\n    );\n  }\n\n  public async renameDoc(value: string): Promise<void> {\n    // The docId should never be unset when this option is available.\n    const doc = this.currentDoc.get();\n    if (doc) {\n      if (value.length > 0) {\n        await this._api.renameDoc(doc.id, value).catch(reportError);\n        const newDoc = await this.refreshCurrentDoc(doc);\n        // a \"slug\" component of the URL may change when the document name is changed.\n        await urlState().pushUrl(\n          { ...urlState().state.get(), ...docUrl(newDoc) },\n          { replace: true, avoidReload: true },\n        );\n      } else {\n        // This error won't be shown to user (caught by editableLabel).\n        throw new Error(`doc name should not be empty`);\n      }\n    }\n  }\n\n  public async refreshCurrentDoc(doc: DocInfo) {\n    return this._updateCurrentDoc(doc.urlId || doc.id, doc.openMode);\n  }\n\n  public updateCurrentDocUsage(docUsage: FilteredDocUsageSummary) {\n    this.currentDocUsage.set(docUsage);\n  }\n\n  // Replace the URL without reloading the doc.\n  public updateUrlNoReload(\n    urlId: string,\n    urlOpenMode: OpenDocMode,\n    options: { removeSlug?: boolean, replaceUrl?: boolean } = {},\n  ) {\n    const { removeSlug = false, replaceUrl = true } = options;\n    const state = urlState().state.get();\n    const nextState = {\n      ...state,\n      doc: urlId,\n      ...(removeSlug ? { slug: undefined } : undefined),\n      mode: urlOpenMode === \"default\" ? undefined : urlOpenMode,\n    };\n    // We preemptively update _openerDocKey so that the URL update doesn't trigger a reload.\n    this._openerDocKey = this._getDocKey(nextState);\n    return urlState().pushUrl(nextState, { avoidReload: true, replace: replaceUrl });\n  }\n\n  public offerRecovery(err: Error) {\n    const isDenied = (err as any).code === \"ACL_DENY\";\n    const isDocOwner = isOwner(this.currentDoc.get());\n    confirmModal(\n      t(\"Error accessing document\"),\n      t(\"Reload\"),\n      async () => window.location.reload(true),\n      {\n        explanation: (\n          isDocOwner ?\n            t(\"You can try reloading the document, or using recovery mode. \\\nRecovery mode opens the document to be fully accessible to owners, and inaccessible to others. \\\nIt also disables formulas. [{{error}}]\", { error: err.message }) :\n            isDenied ?\n              t(\"Sorry, access to this document has been denied. [{{error}}]\", { error: err.message }) :\n              t(\"Please reload the document and if the error persist, \\\ncontact the document owners to attempt a document recovery. [{{error}}]\", { error: err.message })\n        ),\n        hideCancel: true,\n        extraButtons: !(isDocOwner && !isDenied) ? null : bigBasicButton(\n          t(\"Enter recovery mode\"),\n          dom.on(\"click\", async () => {\n            await this._api.getDocAPI(this.currentDocId.get()!).recover(true);\n            window.location.reload(true);\n          }),\n          testId(\"modal-recovery-mode\"),\n        ),\n      },\n    );\n  }\n\n  public clearUnsavedChanges(): void {\n    this._unsavedChangeHolder.clear();\n  }\n\n  private async _updateCurrentDoc(urlId: string, openMode: OpenDocMode) {\n    // TODO It would be bad if a new doc gets opened while this getDoc() is pending...\n    const newDoc = await getDoc(this._api, urlId);\n    const isRecoveryMode = Boolean(this.currentDoc.get()?.isRecoveryMode);\n    const user = this.currentDoc.get()?.user || null;\n    const userOverride = this.currentDoc.get()?.userOverride || null;\n    this.currentDoc.set({ ...buildDocInfo(newDoc, openMode), isRecoveryMode, user, userOverride });\n    return newDoc;\n  }\n\n  private _onOpenError(err: Error) {\n    if (err instanceof CancelledError) {\n      // This means that we started loading a new doc before the previous one finished loading.\n      console.log(\"DocPageModel _openDoc cancelled\");\n      return;\n    }\n    // Expected errors (e.g. Access Denied) produce a separate error page. For unexpected errors,\n    // show a modal, and include a toast for the sake of the \"Report error\" link.\n    reportError(err);\n    this.offerRecovery(err);\n  }\n\n  private async _openDoc(flow: AsyncFlow, urlId: string, options: OpenDocOptions, comparisonOptions: {\n    comparisonUrlId?: string,\n    compareEmphasis?: CompareEmphasis\n  }): Promise<void> {\n    const { openMode: urlOpenMode, linkParameters } = options;\n    const { comparisonUrlId, compareEmphasis } = comparisonOptions;\n    console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` +\n      (comparisonUrlId ? ` (compare ${comparisonUrlId})` : \"\"));\n    const gristDocModulePromise = loadGristDoc();\n\n    const docResponse = await retryOnNetworkError(flow, getDoc.bind(null, this._api, urlId));\n    flow.checkIfCancelled();\n\n    let doc = buildDocInfo(docResponse, urlOpenMode);\n    if (doc.isTutorialTrunk) {\n      // We're loading a tutorial, so we need to prepare a URL to a fork of the\n      // tutorial. The URL will either be to an existing fork, or a new fork if this\n      // is the first time the user is opening the tutorial.\n      const fork = doc.forks?.[0];\n      let forkUrlId: string | undefined;\n      if (fork) {\n        // If a fork of this tutorial already exists, prepare to navigate to it.\n        forkUrlId = buildUrlId({\n          trunkId: doc.urlId || doc.id,\n          forkId: fork.id,\n          forkUserId: this.appModel.currentValidUser!.id,\n        });\n      } else {\n        // Otherwise, create a new fork and prepare to navigate to it.\n        const forkResult = await this._api.getDocAPI(doc.id).fork();\n        flow.checkIfCancelled();\n        forkUrlId = forkResult.urlId;\n      }\n      // Remove the slug from the fork URL - they don't work with slugs.\n      await this.updateUrlNoReload(forkUrlId, \"default\", { removeSlug: true });\n      await this._updateCurrentDoc(forkUrlId, \"default\");\n      flow.checkIfCancelled();\n      doc = this.currentDoc.get()!;\n    } else {\n      if (doc.urlId && doc.urlId !== urlId) {\n        // Replace the URL to reflect the canonical urlId.\n        await this.updateUrlNoReload(doc.urlId, doc.openMode);\n      }\n\n      if (doc.isTemplate) {\n        logTelemetryEvent(\"openedTemplate\", {\n          full: {\n            templateId: parseUrlId(doc.urlId || doc.id).trunkId,\n          },\n        });\n      }\n\n      this.currentDoc.set(doc);\n    }\n\n    // Maintain a connection to doc-worker while opening a document. After it's opened, the DocComm\n    // object created by GristDoc will maintain the connection.\n    const comm = this._appObj.comm;\n    comm.useDocConnection(doc.id);\n    flow.onDispose(() => comm.releaseDocConnection(doc.id));\n\n    const openDocResponse = await comm.openDoc(doc.id, {\n      openMode: doc.openMode,\n      linkParameters,\n      originalUrlId: options.originalUrlId,\n    });\n    const { user, recoveryMode, userOverride } = openDocResponse;\n    doc.user = user;\n    if (recoveryMode || userOverride) {\n      doc.isRecoveryMode = Boolean(recoveryMode);\n      doc.userOverride = userOverride || null;\n    }\n    this.currentDoc.set({ ...doc });\n    if (openDocResponse.docUsage) {\n      this.updateCurrentDocUsage(openDocResponse.docUsage);\n    }\n    const gdModule = await gristDocModulePromise;\n    const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier);\n    flow.checkIfCancelled();\n\n    docComm.changeUrlIdEmitter.addListener(async (newUrlId: string, openResponse: OpenLocalDocResult) => {\n      // The current document has been forked, and should now be referred to using a new docId.\n      const currentDoc = this.currentDoc.get();\n      if (currentDoc) {\n        // Remove the slug from the fork URL - they don't work with slugs.\n        await this.updateUrlNoReload(newUrlId, \"default\", { removeSlug: true, replaceUrl: false });\n        await this._updateCurrentDoc(newUrlId, \"default\");\n      }\n      // The baseAction in docInfo may have changed.\n      const docInfo = openResponse.doc._grist_DocInfo;\n      this.gristDoc.get()?.docData.receiveAction([\n        \"ReplaceTableData\", docInfo[1], docInfo[2], docInfo[3],\n      ]);\n      this.gristDoc.get()?.getActionCounter().setMarkToBaseAction();\n    });\n\n    // If a document for comparison is given, load the comparison, and provide it to the Gristdoc.\n    let comparison = comparisonUrlId ?\n      await this._api.getDocAPI(urlId).compareDoc(comparisonUrlId, { detail: true }) : undefined;\n    let effectiveCompareEmphasis = compareEmphasis;\n\n    // In suggestion mode, automatically set up comparison to highlight changes.\n    if (!comparison) {\n      const isProposable = Boolean(doc.options?.proposedChanges?.acceptProposals);\n      if (isProposable && doc.isFork) {\n        // Returning to an existing suggestion fork: fetch comparison against trunk.\n        comparison = await this._api.getDocAPI(urlId).compareDoc(\n          doc.idParts.trunkId, { detail: true });\n      } else if (isProposable && doc.isPreFork) {\n        // Fresh suggestion session: empty comparison that will track live edits.\n        comparison = createEmptyDocStateComparison();\n      }\n      if (comparison) {\n        effectiveCompareEmphasis = \"local\";\n      }\n    }\n\n    const gristDoc = gdModule.GristDocImpl.create(flow, this._appObj, this.appModel, docComm, this, openDocResponse,\n      this.appModel.topAppModel.plugins,\n      { comparison, compareEmphasis: effectiveCompareEmphasis });\n\n    // Move ownership of docComm to GristDoc.\n    gristDoc.autoDispose(flow.release(docComm));\n\n    // Move ownership of GristDoc to its final owner.\n    this.gristDoc.autoDispose(flow.release(gristDoc));\n    gristDoc.autoDispose(\n      subscribe(\n        gristDoc.getActionCounter().countFromMark,\n        (_, count) => {\n          this.proposalNewChangesCount.set(count);\n        }),\n    );\n    this.proposalNewChangesCount.set(gristDoc.getActionCounter().countFromMark.get());\n  }\n\n  private _getDocKey(state: IGristUrlState) {\n    const urlId = state.doc;\n    const urlOpenMode = state.mode || \"default\";\n    const compareUrlId = state.params?.compare;\n    const docKey = `${urlOpenMode}:${urlId}:${compareUrlId}`;\n    return docKey;\n  }\n}\n\nfunction addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly: boolean): DomElementArg[] {\n  const selectBy = gristDoc.selectBy.bind(gristDoc);\n  return [\n    menuItem(\n      elem => openPageWidgetPicker(elem, gristDoc, val => gristDoc.addNewPage(val).catch(reportError),\n        { isNewPage: true, buttonLabel: t(\"Add page\") }),\n      menuIcon(\"Page\"), t(\"Add page\"), testId(\"dp-add-new-page\"),\n      dom.cls(\"disabled\", isReadonly),\n    ),\n    menuItem(\n      elem => openPageWidgetPicker(elem, gristDoc, val => gristDoc.addWidgetToPage(val).catch(reportError),\n        { isNewPage: false, selectBy }),\n      menuIcon(\"Widget\"), t(\"Add widget to page\"), testId(\"dp-add-widget-to-page\"),\n      // disable for readonly doc and all special views\n      dom.cls(\"disabled\", use => typeof use(gristDoc.activeViewId) !== \"number\" || isReadonly),\n    ),\n    menuItem(() => gristDoc.addEmptyTable().catch(reportError),\n      menuIcon(\"TypeTable\"), t(\"Add empty table\"), testId(\"dp-empty-table\"),\n      dom.cls(\"disabled\", isReadonly),\n    ),\n    menuDivider(),\n    ...importSources.map((importSource, i) =>\n      menuItem(importSource.action,\n        menuIcon(\"Import\"),\n        importSource.label,\n        testId(`dp-import-option`),\n        dom.cls(\"disabled\", isReadonly),\n      ),\n    ),\n    isReadonly ? menuText(t(\"You do not have edit access to this document\")) : null,\n    testId(\"dp-add-new-menu\"),\n  ];\n}\n\nfunction buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {\n  const idParts = parseUrlId(doc.urlId || doc.id);\n  const isFork = Boolean(idParts.forkId || idParts.snapshotId);\n  const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE;\n  const isSnapshot = Boolean(idParts.snapshotId);\n  const type = doc.type;\n  const isTutorial = type === DOCTYPE_TUTORIAL;\n  const isTutorialTrunk = isTutorial && !isFork && mode !== \"default\";\n  const isTutorialFork = isTutorial && isFork;\n\n  const acceptProposals = doc.options?.proposedChanges?.acceptProposals;\n  const shouldSuggest = Boolean(!canEdit(doc.access) && !isFork && acceptProposals);\n\n  let openMode = mode;\n  if (!openMode) {\n    if (isFork || isTutorialTrunk || isTutorialFork) {\n      // Tutorials (if no explicit /m/default mode is set) automatically get or\n      // create a fork on load, which then behaves as a document that is in default\n      // mode. Since the document's 'openMode' has no effect, don't bother trying\n      // to set it here, as it'll potentially be confusing for other code reading it.\n      openMode = \"default\";\n    } else if (!isFork && (type === DOCTYPE_TEMPLATE || shouldSuggest)) {\n      // Templates should always open in fork mode by default.\n      // A doc soliciting suggestions should also open in fork mode\n      // when user doesn't have write access.\n      openMode = \"fork\";\n    } else {\n      // Try to use the document's 'openMode' if it's set.\n      openMode = doc.options?.openMode ?? \"default\";\n    }\n  }\n\n  const isPreFork = openMode === \"fork\";\n  const isTemplate = type === DOCTYPE_TEMPLATE && (isFork || isPreFork);\n  const isEditable = !isSnapshot && (canEdit(doc.access) || isPreFork);\n  return {\n    ...doc,\n    isFork,\n    isRecoveryMode: false,  // we don't know yet, will learn when doc is opened.\n    user: null,             // ditto.\n    userOverride: null,     // ditto.\n    isPreFork,\n    isBareFork,\n    isSnapshot,\n    isTutorialTrunk,\n    isTutorialFork,\n    type,\n    isTemplate,\n    isReadonly: !isEditable,\n    idParts,\n    openMode,\n  };\n}\n\nconst reconnectIntervals = [1000, 1000, 2000, 5000, 10000];\n\nasync function retryOnNetworkError<R>(flow: AsyncFlow, func: () => Promise<R>): Promise<R> {\n  for (let attempt = 0; ; attempt++) {\n    try {\n      return await func();\n    } catch (err) {\n      // fetch() promises that network errors are reported as TypeError. We'll accept NetworkError too.\n      if (err.name !== \"TypeError\" && err.name !== \"NetworkError\") {\n        throw err;\n      }\n      const reconnectTimeout = getReconnectTimeout(attempt, reconnectIntervals);\n      console.warn(`Call to ${func.name} failed, will retry in ${reconnectTimeout} ms`, err);\n      await delay(reconnectTimeout);\n      flow.checkIfCancelled();\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/models/FormModel.ts",
    "content": "import { cleanFormLayoutSpec, FormLayoutNode } from \"app/client/components/FormRenderer\";\nimport { TypedFormData, typedFormDataToJson } from \"app/client/lib/formUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { getHomeUrl } from \"app/client/models/AppModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { Form, FormAPI, FormAPIImpl } from \"app/client/ui/FormAPI\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { safeJsonParse } from \"app/common/gutil\";\n\nimport { bundleChanges, Computed, Disposable, Observable } from \"grainjs\";\n\nconst t = makeT(\"FormModel\");\n\nexport interface FormModel {\n  readonly form: Observable<Form | null>;\n  readonly formLayout: Computed<FormLayoutNode | null>;\n  readonly submitting: Observable<boolean>;\n  readonly submitted: Observable<boolean>;\n  readonly error: Observable<string | null>;\n  fetchForm(): Promise<void>;\n  submitForm(formData: TypedFormData): Promise<void>;\n}\n\nexport class FormModelImpl extends Disposable implements FormModel {\n  public readonly form = Observable.create<Form | null>(this, null);\n  public readonly formLayout = Computed.create(this, this.form, (_use, form) => {\n    if (!form) { return null; }\n\n    const layout = safeJsonParse(form.formLayoutSpec, null) as FormLayoutNode | null;\n    if (!layout) { throw new Error(\"invalid formLayoutSpec\"); }\n\n    const patchedLayout = cleanFormLayoutSpec(layout, new Set(Object.keys(form.formFieldsById).map(Number)));\n    if (!patchedLayout) { throw new Error(\"invalid formLayoutSpec\"); }\n\n    return patchedLayout;\n  });\n\n  public readonly submitting = Observable.create<boolean>(this, false);\n  public readonly submitted = Observable.create<boolean>(this, false);\n  public readonly error = Observable.create<string | null>(this, null);\n\n  private readonly _formAPI: FormAPI = new FormAPIImpl(getHomeUrl());\n\n  constructor() {\n    super();\n  }\n\n  public async fetchForm(): Promise<void> {\n    try {\n      bundleChanges(() => {\n        this.form.set(null);\n        this.submitted.set(false);\n        this.error.set(null);\n      });\n      this.form.set(await this._formAPI.getForm(this._getFetchFormParams()));\n    } catch (e: unknown) {\n      let error: string | undefined;\n      if (e instanceof ApiError) {\n        const code = e.details?.code;\n        if (code === \"FormNotFound\") {\n          error = t(\"Oops! The form you're looking for doesn't exist.\");\n        } else if (code === \"FormNotPublished\") {\n          error = t(\"Oops! This form is no longer published.\");\n        } else if (e.status === 401 || e.status === 403) {\n          error = t(\"You don't have access to this form.\");\n        } else if (e.status === 404) {\n          error = t(\"Oops! The form you're looking for doesn't exist.\");\n        }\n      }\n\n      this.error.set(error || t(\"There was a problem loading the form.\"));\n      if (!(e instanceof ApiError && (e.status >= 400 && e.status < 500))) {\n        // Re-throw if the error wasn't a user error (i.e. a 4XX HTTP response).\n        throw e;\n      }\n    }\n  }\n\n  public async submitForm(formData: TypedFormData): Promise<void> {\n    const form = this.form.get();\n    if (!form) { throw new Error(\"form is not defined\"); }\n\n    for (const key of formData.keys()) {\n      const value = formData.getAll(key);\n      if (value.length > 0 && value[0] instanceof File) {\n        const uploadResult = await this._formAPI.createAttachments({\n          ...this._getDocIdOrShareKeyParam(),\n          upload: value as File[],\n        });\n        formData.set(key, uploadResult);\n      }\n    }\n\n    const colValues = typedFormDataToJson(formData);\n    try {\n      this.submitting.set(true);\n      // we virtually wait for at least a second to actually consider the form submitted;\n      // this makes for a tiny bit of a delay allowing users to see the \"submitting…\" state of the FormRenderer\n      await Promise.all([\n        this._formAPI.createRecord({\n          ...this._getDocIdOrShareKeyParam(),\n          tableId: form.formTableId,\n          colValues,\n        }),\n        new Promise(resolve => setTimeout(resolve, 1000)),\n      ]);\n    } finally {\n      this.submitting.set(false);\n    }\n  }\n\n  private _getFetchFormParams() {\n    const { form } = urlState().state.get();\n    if (!form) { throw new Error('invalid urlState: undefined \"form\"'); }\n\n    return { ...this._getDocIdOrShareKeyParam(), vsId: form.vsId };\n  }\n\n  private _getDocIdOrShareKeyParam() {\n    const { doc, form } = urlState().state.get();\n    if (!form) { throw new Error('invalid urlState: undefined \"form\"'); }\n\n    if (doc) {\n      return { docId: doc };\n    } else if (form.shareKey) {\n      return { shareKey: form.shareKey };\n    } else {\n      throw new Error('invalid urlState: undefined \"doc\" or \"shareKey\"');\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/models/HomeModel.ts",
    "content": "import { ClientScope } from \"app/client/components/ClientScope\";\nimport { guessTimezone } from \"app/client/lib/guessTimezone\";\nimport { HomePluginManager } from \"app/client/lib/HomePluginManager\";\nimport { ImportSourceElement } from \"app/client/lib/ImportSourceElement\";\nimport { localStorageObs } from \"app/client/lib/localStorageObs\";\nimport { AppModel, reportError } from \"app/client/models/AppModel\";\nimport { reportMessage, UserError } from \"app/client/models/errors\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { getUserPrefObs } from \"app/client/models/UserPrefs\";\nimport { ownerName } from \"app/client/models/WorkspaceInfo\";\nimport { IHomePage, isFeatureEnabled } from \"app/common/gristUrls\";\nimport { isLongerThan } from \"app/common/gutil\";\nimport { SortPref, UserOrgPrefs, ViewPref } from \"app/common/Prefs\";\nimport * as roles from \"app/common/roles\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { Document, Organization, RenameDocOptions, Workspace } from \"app/common/UserAPI\";\n\nimport { bundleChanges, Computed, Disposable, Observable, subscribe } from \"grainjs\";\nimport flatten from \"lodash/flatten\";\nimport sortBy from \"lodash/sortBy\";\n\nconst DELAY_BEFORE_SPINNER_MS = 500;\n\nexport interface HomeModel {\n  // PageType value, one of the discriminated union values used by AppModel.\n  pageType: \"home\";\n\n  app: AppModel;\n  currentPage: Observable<IHomePage>;\n  currentWSId: Observable<number | undefined>;    // should be set when currentPage is 'workspace'\n\n  // Note that Workspace contains its documents in .docs.\n  workspaces: Observable<Workspace[]>;\n  loading: Observable<boolean | \"slow\">;          // Set to \"slow\" when loading for a while.\n  available: Observable<boolean>;               // set if workspaces loaded correctly.\n  empty: Observable<boolean>;                   // set if no docs.\n  singleWorkspace: Observable<boolean>;         // set if workspace name should be hidden.\n  trashWorkspaces: Observable<Workspace[]>;     // only set when viewing trash\n  templateWorkspaces: Observable<Workspace[]>;  // Only set when viewing templates or all documents.\n\n  // currentWS is undefined when currentPage is not \"workspace\" or if currentWSId doesn't exist.\n  currentWS: Observable<Workspace | undefined>;\n\n  // List of docs to show for currentWS.\n  currentWSDocs: Observable<Document[]>;\n\n  // List of featured templates from templateWorkspaces.\n  featuredTemplates: Observable<Document[]>;\n\n  // List of other sites (orgs) user can access. Only populated on All Documents, and only when\n  // the current org is a personal org, or the current org is view access only.\n  otherSites: Observable<Organization[]>;\n\n  onlyShowDocuments: Observable<boolean>;\n\n  currentSort: Observable<SortPref>;\n  currentView: Observable<ViewPref>;\n  importSources: Observable<ImportSourceElement[]>;\n\n  // The workspace for new docs, or \"unsaved\" to only allow unsaved-doc creation, or null if the\n  // user isn't allowed to create a doc.\n  newDocWorkspace: Observable<Workspace | null | \"unsaved\">;\n\n  shouldShowAddNewTip: Observable<boolean>;\n\n  onboardingTutorial: Observable<Document | null>;\n\n  createWorkspace(name: string): Promise<void>;\n  renameWorkspace(id: number, name: string): Promise<void>;\n  deleteWorkspace(id: number, forever: boolean): Promise<void>;\n  restoreWorkspace(ws: Workspace): Promise<void>;\n\n  createDoc(name: string, workspaceId: number | \"unsaved\"): Promise<string>;\n  renameDoc(docId: string, name: string, options?: RenameDocOptions): Promise<void>;\n  deleteDoc(docId: string, forever: boolean): Promise<void>;\n  restoreDoc(doc: Document): Promise<void>;\n  pinUnpinDoc(docId: string, pin: boolean): Promise<void>;\n  moveDoc(docId: string, workspaceId: number): Promise<void>;\n  updateWorkspaces(): Promise<void>;\n}\n\nexport interface ViewSettings {\n  currentSort: Observable<SortPref>;\n  currentView: Observable<ViewPref>;\n}\n\nexport class HomeModelImpl extends Disposable implements HomeModel, ViewSettings {\n  public readonly pageType = \"home\";\n  public readonly currentPage = Computed.create(this, urlState().state, (use, s) =>\n    s.homePage || (s.ws !== undefined ? \"workspace\" : \"all\"));\n\n  public readonly currentWSId = Computed.create(this, urlState().state, (use, s) => s.ws);\n  public readonly workspaces = Observable.create<Workspace[]>(this, []);\n  public readonly loading = Observable.create<boolean | \"slow\">(this, true);\n  public readonly available = Observable.create(this, false);\n  public readonly singleWorkspace = Observable.create(this, true);\n  public readonly trashWorkspaces = Observable.create<Workspace[]>(this, []);\n  public readonly templateWorkspaces = Observable.create<Workspace[]>(this, []);\n  public readonly importSources = Observable.create<ImportSourceElement[]>(this, []);\n\n  // Get the workspace details for the workspace with id of currentWSId.\n  public readonly currentWS = Computed.create(this, use =>\n    use(this.workspaces).find(ws => (ws.id === use(this.currentWSId))));\n\n  public readonly currentWSDocs = Computed.create(\n    this,\n    this.currentPage,\n    this.currentWS,\n    (use, page, ws) => {\n      if (page === \"all\") {\n        return flatten(\n          use(this.workspaces)\n            .filter(w => !w.isSupportWorkspace)\n            .map(w => w.docs),\n        );\n      } else if (ws) {\n        return ws.docs;\n      } else {\n        return [];\n      }\n    },\n  );\n\n  public readonly featuredTemplates = Computed.create(this, this.templateWorkspaces, (_use, templates) => {\n    const featuredTemplates = flatten((templates).map(t => t.docs)).filter(t => t.isPinned);\n    return sortBy(featuredTemplates, t => t.name.toLowerCase());\n  });\n\n  public readonly otherSites = Computed.create(this, this.currentPage, this.app.topAppModel.orgs,\n    (_use, page, orgs) => {\n      if (page !== \"all\") { return []; }\n\n      const currentOrg = this._app.currentOrg;\n      if (!currentOrg) { return []; }\n\n      const isPersonalOrg = currentOrg.owner;\n      if (!isPersonalOrg && (currentOrg.access !== \"viewers\" || !currentOrg.public)) {\n        return [];\n      }\n\n      return orgs.filter(org => org.id !== currentOrg.id);\n    });\n\n  public readonly onlyShowDocuments = getUserPrefObs(this.app.userPrefsObs, \"onlyShowDocuments\", {\n    defaultValue: false,\n  }) as Observable<boolean>;\n\n  public readonly currentSort: Observable<SortPref>;\n  public readonly currentView: Observable<ViewPref>;\n\n  // The workspace for new docs, or \"unsaved\" to only allow unsaved-doc creation, or null if the\n  // user isn't allowed to create a doc.\n  public readonly newDocWorkspace = Computed.create(this, this.currentPage, this.currentWS, (use, page, ws) => {\n    if (!this.app.currentValidUser) {\n      // Anonymous user can create docs, but in unsaved mode and only when enabled.\n      return getGristConfig().enableAnonPlayground ? \"unsaved\" : null;\n    }\n    if (page === \"trash\") { return null; }\n    const destWS = ([\"all\", \"templates\"].includes(page)) ? (use(this.workspaces)[0] || null) : ws;\n    return destWS && roles.canEdit(destWS.access) ? destWS : null;\n  });\n\n  public readonly empty = Computed.create(this, this.workspaces, (use, wss) => (\n    wss.every(ws => ws.isSupportWorkspace || ws.docs.length === 0)));\n\n  public readonly shouldShowAddNewTip = Observable.create(this,\n    !this._app.behavioralPromptsManager.hasSeenPopup(\"addNew\"));\n\n  public readonly onboardingTutorial = Observable.create<Document | null>(this, null);\n\n  private _userOrgPrefs = Observable.create<UserOrgPrefs | undefined>(this, this._app.currentOrg?.userOrgPrefs);\n\n  constructor(private _app: AppModel, clientScope: ClientScope) {\n    super();\n\n    if (!this.app.currentValidUser) {\n      // For the anonymous user, use local settings, don't attempt to save anything to the server.\n      const viewSettings = makeLocalViewSettings(null, \"all\");\n      this.currentSort = viewSettings.currentSort;\n      this.currentView = viewSettings.currentView;\n    } else {\n      // Preference for sorting. Defaults to 'name'. Saved to server on write.\n      this.currentSort = Computed.create(this, this._userOrgPrefs,\n        (use, prefs) => SortPref.parse(prefs?.docMenuSort) || \"name\")\n        .onWrite(s => this._saveUserOrgPref(\"docMenuSort\", s));\n\n      // Preference for view mode. The default is somewhat complicated. Saved to server on write.\n      this.currentView = Computed.create(this, this._userOrgPrefs,\n        (use, prefs) => ViewPref.parse(prefs?.docMenuView) || getViewPrefDefault(use(this.workspaces)))\n        .onWrite(s => this._saveUserOrgPref(\"docMenuView\", s));\n    }\n\n    this.autoDispose(subscribe(this.currentPage, this.currentWSId, use =>\n      this.updateWorkspaces().catch(reportError)));\n\n    // Defer home plugin initialization\n    const pluginManager = new HomePluginManager({\n      localPlugins: _app.topAppModel.plugins,\n      untrustedContentOrigin: _app.topAppModel.getUntrustedContentOrigin(),\n      clientScope,\n    });\n    const importSources = ImportSourceElement.fromArray(pluginManager.pluginsList);\n    this.importSources.set(importSources);\n\n    this._app.refreshOrgUsage().catch(reportError);\n\n    this._loadWelcomeTutorial().catch(reportError);\n  }\n\n  // Accessor for the AppModel containing this HomeModel.\n  public get app(): AppModel { return this._app; }\n\n  public async createWorkspace(name: string) {\n    const org = this._app.currentOrg;\n    if (!org) { return; }\n    this._checkForDuplicates(name);\n    await this._app.api.newWorkspace({ name }, org.id);\n    await this.updateWorkspaces();\n  }\n\n  public async renameWorkspace(id: number, name: string) {\n    this._checkForDuplicates(name);\n    await this._app.api.renameWorkspace(id, name);\n    await this.updateWorkspaces();\n  }\n\n  public async deleteWorkspace(id: number, forever: boolean) {\n    // TODO: Prevent the last workspace from being removed.\n    await (forever ? this._app.api.deleteWorkspace(id) : this._app.api.softDeleteWorkspace(id));\n    await this.updateWorkspaces();\n  }\n\n  public async restoreWorkspace(ws: Workspace) {\n    await  this._app.api.undeleteWorkspace(ws.id);\n    await this.updateWorkspaces();\n    reportMessage(`Workspace \"${ws.name}\" restored`);\n  }\n\n  // Creates a new doc by calling the API, and returns its docId.\n  public async createDoc(name: string, workspaceId: number | \"unsaved\"): Promise<string> {\n    if (workspaceId === \"unsaved\") {\n      const timezone = await guessTimezone();\n      return await this._app.api.newUnsavedDoc({ timezone });\n    }\n    const id = await this._app.api.newDoc({ name }, workspaceId);\n    await this.updateWorkspaces();\n    return id;\n  }\n\n  public async renameDoc(\n    docId: string,\n    name: string,\n    options?: RenameDocOptions,\n  ): Promise<void> {\n    await this._app.api.renameDoc(docId, name, options);\n    await this.updateWorkspaces();\n  }\n\n  public async deleteDoc(docId: string, forever: boolean): Promise<void> {\n    await (forever ? this._app.api.deleteDoc(docId) : this._app.api.softDeleteDoc(docId));\n    await this.updateWorkspaces();\n  }\n\n  public async restoreDoc(doc: Document): Promise<void> {\n    await this._app.api.undeleteDoc(doc.id);\n    await this.updateWorkspaces();\n    reportMessage(`Document \"${doc.name}\" restored`);\n  }\n\n  public async pinUnpinDoc(docId: string, pin: boolean): Promise<void> {\n    await (pin ? this._app.api.pinDoc(docId) : this._app.api.unpinDoc(docId));\n    await this.updateWorkspaces();\n  }\n\n  public async moveDoc(docId: string, workspaceId: number): Promise<void> {\n    await this._app.api.moveDoc(docId, workspaceId);\n    await this.updateWorkspaces();\n  }\n\n  // Fetches and updates workspaces, which include contained docs as well.\n  public async updateWorkspaces() {\n    if (this.isDisposed()) {\n      return;\n    }\n    const org = this._app.currentOrg;\n    if (!org) {\n      this.workspaces.set([]);\n      this.trashWorkspaces.set([]);\n      this.templateWorkspaces.set([]);\n      return;\n    }\n\n    this.loading.set(true);\n    const currentPage = this.currentPage.get();\n    const promises = [\n      this._fetchWorkspaces(org.id, false).catch(reportError),\n      currentPage === \"trash\" ? this._fetchWorkspaces(org.id, true).catch(reportError) : Promise.resolve(null),\n      this._maybeFetchTemplates(),\n    ] as const;\n\n    const promise = Promise.all(promises);\n    if (await isLongerThan(promise, DELAY_BEFORE_SPINNER_MS)) {\n      this.loading.set(\"slow\");\n    }\n    const [wss, trashWss, templateWss] = await promise;\n    if (this.isDisposed()) {\n      return;\n    }\n    // bundleChanges defers computeds' evaluations until all changes have been applied.\n    bundleChanges(() => {\n      this.workspaces.set(wss || []);\n      this.trashWorkspaces.set(trashWss || []);\n      this.templateWorkspaces.set(templateWss || []);\n      this.loading.set(false);\n      this.available.set(!!wss);\n      // Hide workspace name if we are showing a single (non-support) workspace, and active\n      // product doesn't allow adding workspaces.  It is important to check both conditions because:\n      //   * A personal org, where workspaces can't be added, can still have multiple\n      //     workspaces via documents shared by other users.\n      //   * An org with workspace support might happen to just have one workspace right\n      //     now, but it is good to show names to highlight the possibility of adding more.\n      const nonSupportWss = Array.isArray(wss) ? wss.filter(ws => !ws.isSupportWorkspace) : null;\n      this.singleWorkspace.set(\n        // The anon personal site always has 0 non-support workspaces.\n        nonSupportWss?.length === 0 ||\n        nonSupportWss?.length === 1 && _isSingleWorkspaceMode(this._app),\n      );\n    });\n  }\n\n  private _checkForDuplicates(name: string): void {\n    if (this.workspaces.get().find(ws => ws.name === name)) {\n      throw new UserError(\"Name already exists. Please choose a different name.\");\n    }\n  }\n\n  private async _fetchWorkspaces(orgId: number, forRemoved: boolean) {\n    let api = this._app.api;\n    if (forRemoved) {\n      api = api.forRemoved();\n    }\n    const wss = await api.getOrgWorkspaces(orgId);\n    if (this.isDisposed()) { return null; }\n    for (const ws of wss) {\n      ws.docs = sortBy(ws.docs, doc => doc.name.toLowerCase());\n\n      // Populate doc.removedAt for soft-deleted docs even when deleted along with a workspace.\n      if (forRemoved) {\n        for (const doc of ws.docs) {\n          doc.removedAt = doc.removedAt || ws.removedAt;\n        }\n      }\n\n      // Populate doc.workspace, which is used by DocMenu/PinnedDocs and\n      // is useful in cases where there are multiple workspaces containing\n      // pinned documents that need to be sorted in alphabetical order.\n      for (const doc of ws.docs) {\n        doc.workspace = doc.workspace ?? ws;\n      }\n    }\n    // Sort workspaces such that workspaces from the personal orgs of others\n    // come after workspaces from our own personal org; workspaces from personal\n    // orgs are grouped by personal org and the groups are ordered alphabetically\n    // by owner name; and all else being equal workspaces are ordered alphabetically\n    // by their name.  All alphabetical ordering is case-insensitive.\n    // Workspaces shared from support account (e.g. samples) are put last.\n    return sortBy(wss, ws => [ws.isSupportWorkspace,\n      ownerName(this._app, ws).toLowerCase(),\n      ws.name.toLowerCase()]);\n  }\n\n  /**\n   * Fetches templates if on the Templates page.\n   */\n  private async _maybeFetchTemplates(): Promise<Workspace[] | null> {\n    if (!getGristConfig().templateOrg || this.currentPage.get() !== \"templates\") {\n      return null;\n    }\n\n    let templateWss: Workspace[] = [];\n    try {\n      templateWss = await this._app.api.getTemplates();\n    } catch {\n      reportError(\"Failed to load templates\");\n    }\n    if (this.isDisposed()) { return null; }\n\n    for (const ws of templateWss) {\n      for (const doc of ws.docs) {\n        // Populate doc.workspace, which is used by DocMenu/PinnedDocs and\n        // is useful in cases where there are multiple workspaces containing\n        // pinned documents that need to be sorted in alphabetical order.\n        doc.workspace = doc.workspace ?? ws;\n      }\n      ws.docs = sortBy(ws.docs, doc => doc.name.toLowerCase());\n    }\n    return templateWss;\n  }\n\n  private async _loadWelcomeTutorial() {\n    const { templateOrg, onboardingTutorialDocId } = getGristConfig();\n    if (\n      !isFeatureEnabled(\"tutorials\") ||\n      !templateOrg ||\n      !onboardingTutorialDocId\n    ) {\n      return;\n    }\n\n    try {\n      const doc = await this._app.api.getTemplate(onboardingTutorialDocId);\n      if (this.isDisposed()) { return; }\n\n      this.onboardingTutorial.set(doc);\n    } catch (e) {\n      console.error(e);\n      reportError(\"Failed to load welcome tutorial\");\n    }\n  }\n\n  private async _saveUserOrgPref<K extends keyof UserOrgPrefs>(key: K, value: UserOrgPrefs[K]) {\n    const org = this._app.currentOrg;\n    if (org) {\n      org.userOrgPrefs = { ...org.userOrgPrefs, [key]: value };\n      this._userOrgPrefs.set(org.userOrgPrefs);\n      await this._app.api.updateOrg(\"current\", { userOrgPrefs: org.userOrgPrefs });\n    }\n  }\n}\n\n// Check if active product allows just a single workspace.\nfunction _isSingleWorkspaceMode(app: AppModel): boolean {\n  return app.currentFeatures?.maxWorkspacesPerOrg === 1;\n}\n\n// Returns a default view mode preference. We used to show 'list' for everyone. We now default to\n// 'icons' for new or light users. But if a user has more than 4 docs or any pinned docs, we'll\n// switch to 'list'. This will also avoid annoying existing users who may prefer a list.\nfunction getViewPrefDefault(workspaces: Workspace[]): ViewPref {\n  const userWorkspaces = workspaces.filter(ws => !ws.isSupportWorkspace);\n  const numDocs = userWorkspaces.reduce((sum, ws) => sum + ws.docs.length, 0);\n  const pinnedDocs = userWorkspaces.some(ws => ws.docs.some(doc => doc.isPinned));\n  return (numDocs > 4 || pinnedDocs) ? \"list\" : \"icons\";\n}\n\n/**\n * Create observables for per-workspace view settings which default to org-wide settings, but can\n * be changed independently and persisted in localStorage.\n */\nexport function makeLocalViewSettings(\n  home: HomeModel | null, wsId: number | \"trash\" | \"all\" | \"templates\",\n): ViewSettings {\n  const userId = home?.app.currentUser?.id || 0;\n  const sort = localStorageObs(`u=${userId}:ws=${wsId}:sort`);\n  const view = localStorageObs(`u=${userId}:ws=${wsId}:view`);\n\n  return {\n    currentSort: Computed.create(null,\n      // If no value in localStorage, use sort of All Documents.\n      use => SortPref.parse(use(sort)) || (home ? use(home.currentSort) : \"name\"))\n      .onWrite(val => sort.set(val)),\n    currentView: Computed.create(null,\n      // If no value in localStorage, use mode of All Documents, except Trash which defaults to 'list'.\n      use => ViewPref.parse(use(view)) || (wsId === \"trash\" ? \"list\" : (home ? use(home.currentView) : \"icons\")))\n      .onWrite(val => view.set(val)),\n  };\n}\n"
  },
  {
    "path": "app/client/models/MetaRowModel.js",
    "content": "var _ = require(\"underscore\");\nvar ko = require(\"knockout\");\nvar dispose = require(\"../lib/dispose\");\nvar BaseRowModel = require(\"./BaseRowModel\");\nvar modelUtil = require(\"./modelUtil\");\nvar BackboneEvents = require(\"backbone\").Events;\n\n/**\n * MetaRowModel is a RowModel for built-in (Meta) tables. It takes a list of field names, and an\n * additional constructor called with (docModel, tableModel) arguments (and `this` context), which\n * can add arbitrary additional properties to this RowModel.\n */\nfunction MetaRowModel(tableModel, fieldNames, rowConstructor, rowId) {\n  var colNames = [\"id\"].concat(fieldNames);\n  BaseRowModel.call(this, tableModel, colNames);\n  this._rowId = rowId;\n\n  // MetaTableModel#_createRowModelItem creates lightweight objects that all reference the same MetaRowModel but are slightly different.\n  // We don't derive from BackboneEvents directly so that the lightweight objects created share the same Events object even though they are distinct.\n  this.events = this.autoDisposeWith(\"stopListening\", BackboneEvents);\n\n  // Changes to true when this row gets deleted. This also likely means that this model is about\n  // to get disposed, except for a floating row model.\n  this._isDeleted = ko.observable(false);\n\n  // Populate all fields. Note that MetaRowModels are never get reassigned after construction.\n  this._fields.forEach(function(colName) {\n    this._assignColumn(colName);\n  }, this);\n\n  // Customize the MetaRowModel with a custom additional constructor.\n  if (rowConstructor) {\n    rowConstructor.call(this, tableModel.docModel, tableModel);\n  }\n}\ndispose.makeDisposable(MetaRowModel);\n_.extend(MetaRowModel.prototype, BaseRowModel.prototype);\n\nMetaRowModel.prototype._assignColumn = function(colName) {\n  if (this.hasOwnProperty(colName)) {\n    this[colName].assign(this._table.tableData.getValue(this._rowId, colName));\n  }\n};\n\n//----------------------------------------------------------------------\n\n/**\n * MetaRowModel.Floater is an object designed to look like a MetaRowModel. It contains observables\n * that mirror some particular MetaRowModel. The MetaRowModel currently being mirrored is the one\n * corresponding to the value of `rowIdObs`.\n *\n * Mirrored fields are computed observables that support reading, writing, and saving.\n */\nMetaRowModel.Floater = function(tableModel, rowIdObs) {\n  this._table = tableModel;\n  this.rowIdObs = rowIdObs;\n\n  // Some tsc error prevents me from adding this at the module level.\n  // This method is part of the interface of MetaRowModel.\n  // TODO: Fix the tsc error and move this to the module level.\n  if (!this.constructor.prototype.getRowId) {\n    this.constructor.prototype.getRowId = function() {\n      return this.rowIdObs();\n    };\n  }\n\n  // Note that ._index isn't supported because it doesn't make sense for a floating row model.\n\n  this._underlyingRowModel = this.autoDispose(ko.computed(function() {\n    // This was added here because the Fork.ts test (nbrowser) is failing without it. Test started failing after\n    // DocModel was converted to a proper DisposableOwner that was disposing all of its disposable children. Before\n    // that DocModel was a long living object that was never disposed.\n    // It is hard to diagnose the problem here. During GristDoc disposal, the order of disposal for those two objects\n    // [MetaRowModel.Floater, MetaRowModel] is reversed.\n    // TODO: Investigate why this is happening.\n    if (tableModel.isDisposed()) {\n      return null;\n    }\n    return tableModel.getRowModel(rowIdObs());\n  }));\n\n  _.each(this._underlyingRowModel(), function(propValue, propName) {\n    if (ko.isObservable(propValue)) {\n      // Forward read/write calls to the observable on the currently-active underlying model.\n      this[propName] = this.autoDispose(ko.pureComputed({\n        owner: this,\n        read: function() { return this._underlyingRowModel()[propName](); },\n        write: function(val) { this._underlyingRowModel()[propName](val); }\n      }));\n\n      // If the underlying observable supports saving, forward save calls too.\n      if (propValue.saveOnly) {\n        modelUtil.addSaveInterface(this[propName], (value =>\n          this._underlyingRowModel()[propName].saveOnly(value)));\n      }\n    }\n  }, this);\n};\ndispose.makeDisposable(MetaRowModel.Floater);\n\n\nmodule.exports = MetaRowModel;\n"
  },
  {
    "path": "app/client/models/MetaTableModel.js",
    "content": "/**\n * MetaTableModel maintains the model for a built-in table, with MetaRowModels. It provides\n * access to individual row models, as well as to collections of rows in that table.\n */\n\n\nvar _ = require(\"underscore\");\nvar ko = require(\"knockout\");\nvar dispose = require(\"../lib/dispose\");\nvar MetaRowModel = require(\"./MetaRowModel\");\nvar TableModel = require(\"./TableModel\");\nvar rowset = require(\"./rowset\");\nvar assert = require(\"assert\");\nvar gutil = require(\"app/common/gutil\");\n\n/**\n * MetaTableModel maintains observables for one table's rows. It accepts a list of fields to\n * include into each RowModel, and an additional constructor to call when constructing RowModels.\n * It exposes all rows, as well as groups of rows, as observable collections.\n */\nfunction MetaTableModel(docModel, tableData, fields, rowConstructor) {\n  TableModel.call(this, docModel, tableData);\n\n  this._fields = fields;\n  this._rowConstructor = rowConstructor;\n\n  // Start out with empty list of row models. It's populated in loadData().\n  this.rowModels = [];\n\n  // It is possible for a new rowModel to be deleted and replaced with a new one for the same\n  // rowId. To allow a computed() to depend on the row version, we keep a permanent observable\n  // \"version\" associated with each rowId, which is incremented any time a rowId is replaced.\n  this._rowModelVersions = [];\n\n  // Whenever rowNotify is triggered, also send the action to all row RowModels that we maintain.\n  this.listenTo(this, \"rowNotify\", function(rows, action) {\n    assert(rows !== rowset.ALL, \"Unexpected schema action on a metadata table\");\n    for (let r of rows) {\n      if (this.rowModels[r]) {\n        this.rowModels[r].dispatchAction(action);\n      }\n    }\n  });\n}\ndispose.makeDisposable(MetaTableModel);\n_.extend(MetaTableModel.prototype, TableModel.prototype);\n\n/**\n * This is called from DocModel as soon as all the MetaTableModel objects have been created.\n */\nMetaTableModel.prototype.loadData = function() {\n  // Whereas user-defined tables may not be initially loaded, MetaTableModels should only exist\n  // for built-in tables, which *should* already be loaded (and should never be reloaded).\n  assert(this.tableData.isLoaded, \"MetaTableModel: tableData not yet loaded\");\n\n  // Create and populate the array mapping rowIds to RowModels.\n  this.getAllRows().forEach(function(rowId) {\n    this._createRowModel(rowId);\n  }, this);\n};\n\n/**\n * Returns an existing or a blank row. Used for `recordRef` descriptor in DocModel.\n *\n * A computed() that uses getRowModel() may not realize if a rowId gets deleted and later re-used\n * for another row. If optDependOnVersion is set, then a dependency on the row version gets\n * created automatically. It is only relevant when the computed is pure and may not get updated\n * when the row is deleted; in that case lacking such dependency may cause subtle rare bugs.\n */\nMetaTableModel.prototype.getRowModel = function(rowId, optDependOnVersion) {\n  const rowIdModel = this.rowModels[rowId];\n  const r = rowIdModel || this.getEmptyRowModel();\n  if (optDependOnVersion) {\n    // Versions are never deleted, so even if the rowModel is deleted, we still have its version\n    // in this list.\n    const version = this._rowModelVersions[rowId];\n    if (version) {\n      // Subscribe to updates for rowModel at rowId.\n      version();\n    } else {\n      // It shouldn't happen, but maybe it would be better to add an empty version observable at rowId.\n      // If it happens, it means we tried to get non existing row (row that wasn't created previously).\n    }\n  }\n  return r;\n};\n\n/**\n * Returns the RowModel to use for invalid rows.\n */\nMetaTableModel.prototype.getEmptyRowModel = function() {\n  return this._createRowModel(0);\n};\n\n/**\n * Private helper to create a MetaRowModel for the given rowId. For public use, there are\n * getRowModel(rowId) and createFloatingRowModel(rowIdObs).\n */\nMetaTableModel.prototype._createRowModel = function(rowId) {\n  if (!this.rowModels[rowId]) {\n    // When creating a new row, we create new MetaRowModels which use observables. If\n    // _createRowModel is called from within the evaluation of a computed(), we do NOT want that\n    // computed to subscribe to observables used by individual MetaRowModels.\n    ko.ignoreDependencies(() => {\n      this.rowModels[rowId] = MetaRowModel.create(this, this._fields, this._rowConstructor, rowId);\n\n      // Whenever a rowModel is created, increment its version number.\n      let inc = this._rowModelVersions[rowId] || (this._rowModelVersions[rowId] = ko.observable(0));\n      inc(inc.peek() + 1);\n    });\n  }\n  return this.rowModels[rowId];\n};\n\n\n/**\n * Returns a MetaRowModel-like object tied to an observable rowId. When the observable changes,\n * the fields of the returned model start reflecting the values for the new rowId. See also\n * MetaRowModel.Floater docs.\n *\n * There should be very few such floating rows. If you ever want a set, you should be using\n * createAllRowsModel() or createRowGroupModel().\n *\n * @param {ko.observable} rowIdObs: observable that evaluates to a rowId.\n */\nMetaTableModel.prototype.createFloatingRowModel = function(rowIdObs) {\n  return MetaRowModel.Floater.create(this, rowIdObs);\n};\n\n/**\n * Override TableModel's _process_RemoveRecord to also remove our reference to this row model.\n */\nMetaTableModel.prototype._process_RemoveRecord = function(action, tableId, rowId) {\n  TableModel.prototype._process_RemoveRecord.apply(this, arguments);\n  this._deleteRowModel(rowId);\n};\n\n/**\n * Clean up the RowModel for a row when it's deleted by an action from the server.\n */\nMetaTableModel.prototype._deleteRowModel = function(rowId) {\n  this.rowModels[rowId]._isDeleted(true);\n  this.rowModels[rowId].dispose();\n  delete this.rowModels[rowId];\n};\n\n/**\n * We have to remember to override Bulk versions too.\n */\nMetaTableModel.prototype._process_BulkRemoveRecord = function(action, tableId, rowIds) {\n  TableModel.prototype._process_BulkRemoveRecord.apply(this, arguments);\n  rowIds.forEach(rowId => this._deleteRowModel(rowId));\n};\n\n/**\n * Override TableModel's _process_AddRecord to also add a row model for the given rowId.\n */\nMetaTableModel.prototype._process_AddRecord = function(action, tableId, rowId, columnValues) {\n  this._createRowModel(rowId);\n  TableModel.prototype._process_AddRecord.apply(this, arguments);\n};\n\n/**\n * We have to remember to override Bulk versions too.\n */\nMetaTableModel.prototype._process_BulkAddRecord = function(action, tableId, rowIds, columns) {\n  rowIds.forEach(rowId => this._createRowModel(rowId));\n  TableModel.prototype._process_BulkAddRecord.apply(this, arguments);\n};\n\n/**\n * Override TableModel's applySchemaAction to assert that there are NO metadata schema changes.\n */\nMetaTableModel.prototype.applySchemaAction = function(action) {\n  throw new Error(\"No schema actions should apply to metadata\");\n};\n\n/**\n * Returns a new observable array (koArray) of MetaRowModels for all the rows in this table,\n * sorted by the given column. It is the caller's responsibility to dispose this array.\n * @param {string} sortColId: Column ID by which to sort.\n */\nMetaTableModel.prototype.createAllRowsModel = function(sortColId) {\n  return this._createRowSetModel(this, sortColId);\n};\n\n/**\n * Returns a new observable array (koArray) of MetaRowModels matching the given `groupValue`.\n * It is the caller's responsibility to dispose this array.\n * @param {String|Number} groupValue - The group value to match.\n * @param {String} options.groupBy  - RowModel field by which to group.\n * @param {String} options.sortBy   - RowModel field by which to sort.\n */\nMetaTableModel.prototype.createRowGroupModel = function(groupValue, options) {\n  var grouping = this.getRowGrouping(options.groupBy);\n  return this._createRowSetModel(grouping.getGroup(groupValue), options.sortBy);\n};\n\n/**\n * Helper that returns a new observable koArray of MetaRowModels subscribed to the given\n * rowSource, and sorted by the given column. It is the caller's responsibility to dispose it.\n */\nMetaTableModel.prototype._createRowSetModel = function(rowSource, sortColId) {\n  var getter = this.tableData.getRowPropFunc(sortColId);\n  var sortedRowSet = rowset.SortedRowSet.create(null, function(r1, r2) {\n    return gutil.nativeCompare(getter(r1), getter(r2));\n  }, undefined);\n  sortedRowSet.subscribeTo(rowSource);\n\n  // When the returned value is disposed, dispose the underlying SortedRowSet too.\n  var ret = this._createRowModelArray(sortedRowSet.getKoArray());\n  ret.autoDispose(sortedRowSet);\n  return ret;\n};\n\n/**\n * Helper which takes an observable array (koArray) of rowIds, and returns a new koArray of\n * objects having those RowModels as prototypes, and with an additional `_index` observable to\n * contain their index in the array. The index is kept correct as the array changes.\n *\n * TODO: this needs a unittest.\n */\nMetaTableModel.prototype._createRowModelArray = function(rowIdArray) {\n  var ret = rowIdArray.map(this._createRowModelItem, this);\n  ret.subscribe(function(splice) {\n    var arr = splice.array, i;\n    for (i = 0; i < splice.deleted.length; i++) {\n      splice.deleted[i]._index(null);\n    }\n    var delta = splice.added - splice.deleted.length;\n    if (delta !== 0) {\n      for (i = splice.start + splice.added; i < arr.length; i++) {\n        arr[i]._index(i);\n      }\n    }\n  }, null, \"spliceChange\");\n  return ret;\n};\n\n/**\n * Creates and returns a RowModel with its own `_index` observable.\n */\nMetaTableModel.prototype._createRowModelItem = function(rowId, index) {\n  var rowModel = this._createRowModel(rowId);\n  assert.ok(rowModel, \"MetaTableModel._createRowModelItem called for invalid rowId \" + rowId);\n  var ret = Object.create(rowModel);    // New object, with rowModel as its prototype.\n  ret._index = ko.observable(index);    // New _index observable overrides the existing one.\n  return ret;\n};\n\nmodule.exports = MetaTableModel;\n"
  },
  {
    "path": "app/client/models/NotifyModel.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport * as log from \"app/client/lib/log\";\nimport { ConnectState, ConnectStateManager } from \"app/client/models/ConnectState\";\nimport { isNarrowScreenObs, testId } from \"app/client/ui2018/cssVars\";\nimport { delay } from \"app/common/delay\";\nimport { isLongerThan } from \"app/common/gutil\";\nimport { InactivityTimer } from \"app/common/InactivityTimer\";\nimport { timeFormat } from \"app/common/timeFormat\";\n\nimport {\n  bundleChanges,\n  Computed,\n  Disposable,\n  dom,\n  DomElementArg,\n  Holder,\n  IDisposable,\n  IDisposableOwner,\n  MutableObsArray,\n  obsArray,\n  Observable,\n} from \"grainjs\";\nimport clamp from \"lodash/clamp\";\nimport defaults from \"lodash/defaults\";\n\nconst t = makeT(\"NotifyModel\");\n// When rendering app errors, we'll only show the last few.\nconst maxAppErrors = 5;\n\ninterface INotifier {\n  createUserMessage(message: string, options?: INotifyOptions): INotification;\n  // If you are looking to report errors, please do that via reportError rather\n  // than these methods so that we have a chance to send the error to our logs.\n  createAppError(error: Error): void;\n\n  createProgressIndicator(name: string, size: string, expireOnComplete: boolean): IProgress;\n  createNotification(options: INotifyOptions): INotification;\n  setConnectState(isConnected: boolean): void;\n  slowNotification<T>(promise: Promise<T>, optTimeout?: number): Promise<T>;\n  getFullAppErrors(): IAppError[];\n}\n\nexport interface INotification extends Expirable {\n  expire(): Promise<void>;\n}\n\nexport interface IProgress extends Expirable {\n  setProgress(percent: number): void;\n}\n\n/**\n * Custom action to be shown as a notification with a handler.\n */\nexport interface CustomAction { label: string, action: () => void }\n/**\n * A string, or a function that builds dom.\n */\nexport type MessageType = string | (() => DomElementArg);\n// Identifies supported actions. These are implemented in NotifyUI.\nexport type NotifyAction = \"upgrade\" | \"renew\" | \"personal\" | \"report-problem\" |\n  \"ask-for-help\" | \"manage\" | CustomAction;\nexport type NotificationLevel = \"message\" | \"info\" | \"success\" | \"warning\" | \"error\";\n\nexport interface INotifyOptions {\n  message: MessageType;     // A string, or a function that builds dom.\n  timestamp?: number;\n  title?: string;\n  canUserClose?: boolean;\n  inToast?: boolean;\n  inDropdown?: boolean;\n  expireSec?: number;\n  badgeCounter?: boolean;\n  level: NotificationLevel;\n\n  memos?: string[];  // A list of relevant notes.\n\n  // cssToastAction class from NotifyUI will be applied automatically to action elements.\n  actions?: NotifyAction[];\n\n  // When set, the notification will replace any previous notification with the same key.\n  // This way, we can avoid accumulating many of substantially identical notifications.\n  key?: string | null;\n}\n\ntype Status = \"active\" | \"expiring\";\n\nexport class Expirable extends Disposable {\n  public static readonly fadeDelay = 250;\n  public readonly status = Observable.create<Status>(this, \"active\");\n\n  constructor() {\n    super();\n  }\n\n  /**\n   * Sets status to 'expiring', then calls dispose after a short delay.\n   */\n  public async expire(withoutDelay: boolean = false): Promise<void> {\n    this.status.set(\"expiring\");\n    if (!withoutDelay) {\n      await delay(Expirable.fadeDelay);\n    }\n    if (!this.isDisposed()) {\n      this.dispose();\n    }\n  }\n}\n\nexport class Notification extends Expirable implements INotification {\n  public options: Required<INotifyOptions> = {\n    title: \"\",\n    message: \"\",\n    timestamp: Date.now(),\n    inDropdown: false,\n    badgeCounter: false,\n    inToast: true,\n    expireSec: 0,\n    canUserClose: false,\n    actions: [],\n    memos: [],\n    key: null,\n    level: \"message\",\n  };\n\n  constructor(_opts: INotifyOptions) {\n    super();\n    this.options = defaults({}, _opts, this.options);\n\n    if (this.options.expireSec > 0) {\n      const expireTimer = setTimeout(() => this.expire(), 1000 * this.options.expireSec);\n      this.onDispose(() => clearTimeout(expireTimer));\n    }\n  }\n}\n\ninterface IProgressOptions {\n  name: string;\n  size: string;\n  expireOnComplete?: boolean;\n}\n\nexport class Progress extends Expirable implements IProgress {\n  public readonly progress = Observable.create(this, 0);\n\n  constructor(public options: IProgressOptions) {\n    super();\n\n    if (options.expireOnComplete) {\n      this.autoDispose(this.progress.addListener(async (progress) => {\n        if (progress >= 100) {\n          await this.expire();\n        }\n      }));\n    }\n  }\n\n  /**\n   * progress should be between 0 and 100.\n   */\n  public setProgress(progress: number) {\n    this.progress.set(clamp(progress, 0, 100));\n  }\n}\n\n/**\n * Similar to grainjs MultiHolder, but knows when items are disposed externally and releases them\n * (avoiding the \"already disposed\" warnings in that case). This is probably how grainjs's\n * MultiHolder should actually work, and maybe how `Disposable.autoDispose` should generally work.\n */\nexport class BetterMultiHolder implements IDisposableOwner {\n  private _items = new Set<IDisposable>();\n\n  public autoDispose<T extends IDisposable>(obj: T): T {\n    this._items.add(obj);\n    if (obj instanceof Disposable) {\n      obj.onDispose(() => this._items.delete(obj));\n    }\n    return obj;\n  }\n\n  public dispose() {\n    for (const item of this._items) {\n      item.dispose();\n    }\n    this._items.clear();\n  }\n}\n\nexport interface IAppError {\n  error: Error;\n  timestamp: number;\n  seen?: boolean;       // If seen, this will be hidden from the \"app errors\" toast\n}\n\nexport class Notifier extends Disposable implements INotifier {\n  private _itemsHolder = this.autoDispose(new BetterMultiHolder());\n\n  private _toasts = this.autoDispose(obsArray<Notification>());\n  private _dropdownItems = this.autoDispose(obsArray<Notification>());\n  private _progressItems = this.autoDispose(obsArray<Progress>([]));\n  private _keyedItems = new Map<string, Notification>();\n\n  private _connectStateManager = ConnectStateManager.create(this);\n  private _connectState = this._connectStateManager.connectState;\n  private _disconnectMsg = Computed.create(this, use => getDisconnectMessage(use(this._connectState)));\n\n  // Holds recent application errors, which the user may report to us.\n  private _appErrorList = this.autoDispose(obsArray<IAppError>());\n\n  // The dropdown will show all recent errors; the toast only the \"new\" ones, i.e. those since the\n  // last toast was closed.\n  private _appErrorDropdownItem = Holder.create<INotification>(this);\n  private _appErrorToast = Holder.create<INotification>(this);\n  private _slowNotificationToast = Holder.create<INotification>(this);\n  private _slowNotificationInactivityTimer = new InactivityTimer(() => this._slowNotificationToast.clear(), 0);\n\n  constructor() {\n    super();\n    Computed.create(this, this._disconnectMsg, (use, msg) =>\n      msg ? use.owner.autoDispose(this.createNotification({\n        message: msg.message,\n        title: msg.title,\n        canUserClose: true,\n        inToast: true,\n        level: \"message\",\n      })) : null);\n  }\n\n  /**\n   * Exposes all the state needed for building UI. This is simply to clarify the intended usage:\n   * these members aren't intended to be exposed, except to the UI-building code.\n   */\n  public getStateForUI() {\n    return {\n      toasts: this._toasts,\n      dropdownItems: this._dropdownItems,\n      progressItems: this._progressItems,\n      connectState: this._connectState,\n      disconnectMsg: this._disconnectMsg,\n    };\n  }\n\n  /**\n   * Creates a basic toast notification. By default, expires in 10 seconds.\n   * Takes an options objects to configure `expireSec` and `canUserClose`.\n   * Set `expireSec` to 0 to prevent expiration.\n   *\n   * Additional option level, can be used to style the notification to like a success, warning,\n   * info or error message.\n   */\n  public createUserMessage(message: MessageType, options: Partial<INotifyOptions> = {}): INotification {\n    const timestamp = Date.now();\n    if (options.actions?.includes(\"ask-for-help\")) {\n      // If user should be able to ask for help, add this error to the notifier dropdown too for a\n      // good while, so the user can find it after the toast disappears.\n      this.createNotification({\n        timestamp,\n        message,\n        inToast: false,\n        expireSec: 300,\n        canUserClose: true,\n        level: \"message\",\n        inDropdown: true,\n        ...options,\n        key: options.key && (\"dropdown:\" + options.key),\n      });\n    }\n    return this.createNotification({\n      timestamp,\n      message,\n      inToast: true,\n      expireSec: 10,\n      canUserClose: true,\n      inDropdown: false,\n      level: \"message\",\n      ...options,\n    });\n  }\n\n  /**\n   * If you are looking to report errors, please do that via reportError so\n   * that we have a chance to send the error to our logs.\n   */\n  public createAppError(error: Error): void {\n    bundleChanges(() => {\n      // Remove old messages, to keep a max of maxAppErrors.\n      if (this._appErrorList.get().length >= maxAppErrors) {\n        this._appErrorList.splice(0, this._appErrorList.get().length - maxAppErrors + 1);\n      }\n      this._appErrorList.push({ error, timestamp: Date.now() });\n    });\n\n    // Create a dropdown item for errors if we don't have one yet.\n    if (this._appErrorDropdownItem.isEmpty()) {\n      this._appErrorDropdownItem.autoDispose(this._createAppErrorItem(\"dropdown\"));\n    }\n\n    // Create a toast for errors if we don't have one yet. When it's closed, mark the items as\n    // \"seen\" (i.e. not to be shown when the toast pops up again).\n    if (this._appErrorToast.isEmpty()) {\n      const n = this._appErrorToast.autoDispose(this._createAppErrorItem(\"toast\"));\n      n.onDispose(() => this._appErrorList.get().forEach((appErr) => { appErr.seen = true; }));\n    }\n  }\n\n  public createNotification(opts: INotifyOptions): INotification {\n    const n = Notification.create(this._itemsHolder, opts);\n    this._addNotification(n).catch((e) => { log.warn(\"_addNotification failed\", e); });\n    return n;\n  }\n\n  public createProgressIndicator(name: string, size: string, expireOnComplete = false): IProgress {\n    // Progress objects normally dispose themselves; constructor disposes any leftover items.\n    const p = Progress.create(this._itemsHolder, { name, size, expireOnComplete });\n    this._progressItems.push(p);\n    p.onDispose(() => this.isDisposed() || arrayRemove(this._progressItems, p));\n    return p;\n  }\n\n  public setConnectState(isConnected: boolean): void {\n    this._connectStateManager.setConnected(isConnected);\n  }\n\n  public getFullAppErrors() {\n    return this._appErrorList.get();\n  }\n\n  // This is exposed primarily for tests.\n  public clearAppErrors() {\n    this._appErrorList.splice(0);\n    this._appErrorToast.clear();\n  }\n\n  /**\n   * Show a notification when promise takes longer than optTimeout to resolve. Returns the passed in\n   * promise.\n   */\n  public async slowNotification<T>(promise: Promise<T>, optTimeout: number = 1000): Promise<T> {\n    if (await isLongerThan(promise, optTimeout)) {\n      if (this._slowNotificationToast.isEmpty()) {\n        this._slowNotificationToast.autoDispose(this.createNotification({\n          message: t(\"Still working...\"),\n          canUserClose: false,\n          inToast: true,\n          level: \"message\",\n        }));\n      }\n      await this._slowNotificationInactivityTimer.disableUntilFinish(promise);\n    }\n    return promise;\n  }\n\n  private async _addNotification(n: Notification): Promise<void> {\n    const key = n.options.key;\n    if (key) {\n      const prev = this._keyedItems.get(key);\n      if (prev) {\n        await prev.expire(true);\n      }\n      this._keyedItems.set(key, n);\n      n.onDispose(() => this.isDisposed() || this._keyedItems.delete(key));\n    }\n    if (n.options.inToast) {\n      this._toasts.push(n);\n      n.onDispose(() => this.isDisposed() || arrayRemove(this._toasts, n));\n    }\n    if (n.options.inDropdown) {\n      this._dropdownItems.push(n);\n      n.onDispose(() => this.isDisposed() || arrayRemove(this._dropdownItems, n));\n    }\n  }\n\n  private _createAppErrorItem(where: \"toast\" | \"dropdown\") {\n    return this.createNotification({\n      // Building DOM here in NotifyModel seems wrong, but I haven't come up with a better way.\n      message: () => dom.domComputed((use) => {\n        let appErrors = use(this._appErrorList);\n\n        // On narrow screens, only show the most recent error in toasts to conserve space.\n        if (where === \"toast\" && use(isNarrowScreenObs())) {\n          appErrors = appErrors.length > 0 ? [appErrors[appErrors.length - 1]] : [];\n        }\n\n        return dom(\"div\",\n          dom.forEach(appErrors, (appErr: IAppError) =>\n            (where === \"toast\" && appErr.seen ? null :\n              dom(\"div\", { tabIndex: \"-1\" }, timeFormat(\"T\", new Date(appErr.timestamp)), \" \",\n                appErr.error.message, testId(\"notification-app-error\"))\n            ),\n          ),\n          testId(\"notification-app-errors\"),\n        );\n      }),\n      title: \"Unexpected error\",\n      canUserClose: true,\n      inToast: where === \"toast\",\n      expireSec: where === \"toast\" ? 10 : 0,\n      inDropdown: where === \"dropdown\",\n      actions: [\"report-problem\"],\n      level: \"error\",\n    });\n  }\n}\n\nfunction arrayRemove<T>(arr: MutableObsArray<T>, elem: T) {\n  const removeIdx = arr.get().findIndex(e => e === elem);\n  if (removeIdx !== -1) {\n    arr.splice(removeIdx, 1);\n  }\n}\n\nfunction getDisconnectMessage(state: ConnectState): { title: string, message: string } | undefined {\n  switch (state) {\n    case ConnectState.RecentlyDisconnected:\n      return { title: \"Connection is lost\", message: \"Attempting to reconnect...\" };\n    case ConnectState.ReallyDisconnected:\n      return { title: \"Not connected\", message: \"The document is in read-only mode until you are back online.\" };\n  }\n}\n"
  },
  {
    "path": "app/client/models/QuerySet.ts",
    "content": "/**\n * A QuerySet represents a data query to the server, which returns matching data and includes a\n * subscription. The subscription tells the server to send us docActions that affect this query.\n *\n * This file combines several classes related to it:\n *\n * - QuerySetManager is maintained by GristDoc, and keeps all active QuerySets for this doc.\n *   A new one is created using QuerySetManager.useQuerySet(owner, query)\n *\n *      This creates a subscription to the server, and sets up owner.autoDispose() to clean up\n *      that subscription. If a subscription already exists, it only returns a reference to it,\n *      and disposal will remove the reference, only unsubscribing from the server when no\n *      referernces remain.\n *\n * - DynamicQuerySet is used by BaseView (in place of FilteredRowSource used previously). It is a\n *   single RowSource which mirrors a QuerySet, and allows the QuerySet to be changed.\n *   You set it to a new query using DynamicQuerySet.makeQuery(...)\n *\n * - QuerySet represents the actual query, makes the calls to the server to populate the data in\n *   the relevant TableData. It is also a FilteredRowSource for the rows matching the query.\n *\n * - TableQuerySets is a simple set of queries maintained for a single table (by DataTableModel).\n *   It's needed to know which rows are still relevant after a QuerySet is disposed.\n *\n * TODO: need to have a fetch limit (e.g. 1000 by default, or an option for user)\n * TODO: client-side should show \"...\" or \"50000 more rows not shown\" in that case.\n * TODO: Reference columns don't work properly because always use a displayCol which relies on formulas\n */\nimport { ClientColumnGettersByColId } from \"app/client/models/ClientColumnGetters\";\nimport DataTableModel from \"app/client/models/DataTableModel\";\nimport { DocModel } from \"app/client/models/DocModel\";\nimport { BaseFilteredRowSource, RowList, RowSource } from \"app/client/models/rowset\";\nimport { TableData } from \"app/client/models/TableData\";\nimport { ActiveDocAPI, ClientQuery, QueryOperation } from \"app/common/ActiveDocAPI\";\nimport { TableDataAction } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { nativeCompare } from \"app/common/gutil\";\nimport { IRefCountSub, RefCountMap } from \"app/common/RefCountMap\";\nimport { getLinkingFilterFunc, RowFilterFunc } from \"app/common/RowFilterFunc\";\nimport { TableData as BaseTableData } from \"app/common/TableData\";\nimport { tbind } from \"app/common/tbind\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\n\nimport { Disposable, Holder, IDisposableOwnerT } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport debounce from \"lodash/debounce\";\n\n// Limit on the how many rows to request for OnDemand tables.\nconst ON_DEMAND_ROW_LIMIT = 10000;\n\n// Copied from app/server/lib/DocStorage.js. Actually could be 999, we are just playing it safe.\nconst MAX_SQL_PARAMS = 500;\n\n/**\n * A representation of a Query that uses tableRef/colRefs (i.e. metadata rowIds) to remain stable\n * across table/column renames.\n */\nexport interface QueryRefs {\n  tableRef: number;\n  filterTuples: FilterTuple[];\n}\n\ntype ColRef = number | \"id\";\ntype FilterTuple = [ColRef, QueryOperation, any[]];\n\n/**\n * QuerySetManager keeps track of all queries for a GristDoc instance. It is also responsible for\n * disposing all state associated with queries when a GristDoc is disposed.\n *\n * Note that queries are made using tableId + colIds, which is a more suitable interface for a\n * (future) public API, and easier to interact with DocData/TableData. However, it creates\n * problems when tables or columns are renamed or deleted.\n *\n * To handle renames, we keep track of queries using their QueryRef representation, using\n * tableRef/colRefs, i.e. metadata rowIds that aren't affected by renames.\n *\n * To handle deletes, we subscribe to isDeleted() observables of the needed tables and columns,\n * and purge the query from QuerySetManager if any isDeleted() flag becomes true.\n */\nexport class QuerySetManager extends Disposable {\n  private _queryMap: RefCountMap<string, QuerySet>;\n\n  constructor(private _docModel: DocModel, docComm: ActiveDocAPI) {\n    super();\n    this._queryMap = this.autoDispose(new RefCountMap<string, QuerySet>({\n      create: (query: string) => QuerySet.create(null, _docModel, docComm, query, this),\n      dispose: (query: string, querySet: QuerySet) => querySet.dispose(),\n      gracePeriodMs: 60000,   // Dispose after a minute of disuse.\n    }));\n  }\n\n  public useQuerySet(owner: IDisposableOwnerT<IRefCountSub<QuerySet>>, query: ClientQuery): QuerySet {\n    // Convert the query to a string key which identifies it.\n    const queryKey: string = encodeQuery(convertQueryToRefs(this._docModel, query));\n\n    // Look up or create the query in the RefCountMap. The returned object is a RefCountSub\n    // subscription, which decrements reference count when disposed.\n    const querySetRefCount = this._queryMap.use(queryKey);\n\n    // The passed-in owner is what will dispose this subscription (decrement reference count).\n    owner.autoDispose(querySetRefCount);\n    return querySetRefCount.get();\n  }\n\n  public purgeKey(queryKey: string) {\n    this._queryMap.purgeKey(queryKey);\n  }\n\n  // For testing: set gracePeriodMs, returning the previous value.\n  public testSetGracePeriodMs(ms: number): number {\n    return this._queryMap.testSetGracePeriodMs(ms);\n  }\n}\n\n/**\n * DynamicQuerySet wraps one QuerySet, and allows changing it on the fly. It serves as a\n * RowSource.\n */\nexport class DynamicQuerySet extends RowSource {\n  // Holds a reference to the currently active QuerySet.\n  private _holder = Holder.create<IRefCountSub<QuerySet>>(this);\n\n  // Shortcut to _holder.get().get().\n  private _querySet?: QuerySet;\n\n  // A ticket number for the latest makeQuery() call. We use it to avoid calling cb() for\n  // superseded queries.\n  private _lastTicket = 0;\n\n  // We could switch between several different queries quickly. If several queries are done\n  // fetching at the same time (e.g. were already ready), debounce lets us only update the\n  // query-set once to the last query.\n  private _updateQuerySetDebounced = debounce(tbind(this._updateQuerySet, this), 0);\n\n  constructor(private _querySetManager: QuerySetManager, private _tableModel: DataTableModel) {\n    super();\n  }\n\n  public getAllRows(): RowList {\n    return this._querySet ? this._querySet.getAllRows() : [];\n  }\n\n  public getNumRows(): number {\n    return this._querySet ? this._querySet.getNumRows() : 0;\n  }\n\n  /**\n   * Tells whether the query's result got truncated, i.e. not all rows are included.\n   */\n  public get isTruncated(): boolean {\n    return this._querySet ? this._querySet.isTruncated : false;\n  }\n\n  /**\n   * Replace the query represented by this DynamicQuerySet. If multiple makeQuery() calls are made\n   * quickly (while waiting for the server), cb() may only be called for the latest one.\n   *\n   * If there is an error fetching data, cb(err) will be called with that error. The second\n   * argument to cb() is true if any data was changed, and false if not. Note that for a series of\n   * makeQuery() calls, cb() is always called at least once, and always asynchronously.\n   */\n  public makeQuery(filters: { [colId: string]: any[] },\n    operations: { [colId: string]: QueryOperation },\n    cb: (err: Error | null, changed: boolean) => void): void {\n    const query: ClientQuery = { tableId: this._tableModel.tableData.tableId, filters, operations };\n    const newQuerySet = this._querySetManager.useQuerySet(this._holder, query);\n    const ticket = this._getTicket();\n\n    // CB should be called asynchronously, since surprising hard-to-debug interactions can happen\n    // if it's sometimes synchronous and sometimes not.\n    newQuerySet.fetchPromise.then(() => {\n      // Only if we weren't superseded by another query.\n      if (!ticket.isValid()) { return; }\n      this._updateQuerySetDebounced(newQuerySet, cb);\n    })\n      .catch((err) => { cb(err, false); });\n  }\n\n  private _updateQuerySet(nextQuerySet: QuerySet, cb: (err: Error | null, changed: boolean) => void): void {\n    try {\n      if (nextQuerySet !== this._querySet) {\n        const oldQuerySet = this._querySet;\n        this._querySet = nextQuerySet;\n\n        if (oldQuerySet) {\n          this.stopListening(oldQuerySet, \"rowChange\");\n          this.stopListening(oldQuerySet, \"rowNotify\");\n          this.trigger(\"rowChange\", \"remove\", oldQuerySet.getAllRows());\n        }\n        this.trigger(\"rowChange\", \"add\", this._querySet.getAllRows());\n        this.listenTo(this._querySet, \"rowNotify\", tbind(this.trigger, this, \"rowNotify\"));\n        this.listenTo(this._querySet, \"rowChange\", tbind(this.trigger, this, \"rowChange\"));\n      }\n      cb(null, true);\n    } catch (err) {\n      cb(err, true);\n    }\n  }\n\n  private _getTicket() {\n    const myTicket = ++this._lastTicket;\n    return {\n      isValid: () => this._lastTicket === myTicket,\n    };\n  }\n}\n\n/**\n * Class representing a query, which knows how to fetch the data, an presents a RowSource with\n * matching rows. It uses new Comm calls for onDemand tables, but for regular tables, fetching\n * data uses the good old tableModel.fetch(). In in most cases the data is already available, so\n * this class is little more than a FilteredRowSource.\n */\nexport class QuerySet extends BaseFilteredRowSource {\n  // A publicly exposed promise, which may be waited on in order to know that the data has\n  // arrived. Until then, the RowSource underlying this QuerySet is empty.\n  public readonly fetchPromise: Promise<void>;\n\n  // Whether the fetched result is considered incomplete, i.e. not all rows were fetched.\n  public isTruncated: boolean;\n\n  constructor(docModel: DocModel, docComm: ActiveDocAPI, queryKey: string, qsm: QuerySetManager) {\n    const queryRefs: QueryRefs = decodeQuery(queryKey);\n    const query: ClientQuery = convertQueryFromRefs(docModel, queryRefs);\n\n    super(getFilterFunc(docModel.docData, query));\n    this.isTruncated = false;\n\n    // When table or any needed columns are deleted, purge this QuerySet from the map.\n    const isInvalid = this.autoDispose(makeQueryInvalidComputed(docModel, queryRefs));\n    this.autoDispose(isInvalid.subscribe((invalid) => {\n      if (invalid) { qsm.purgeKey(queryKey); }\n    }));\n\n    // Find the relevant DataTableModel.\n    const tableModel = docModel.dataTables[query.tableId];\n\n    // The number of values across all filters is limited to MAX_SQL_PARAMS. Normally a query has\n    // a single filter column, but in case there are multiple we divide the limit across all\n    // columns. It's OK to modify the query in place, since this modified version is not used\n    // elsewhere.\n\n    // (It might be better to limit this in DocStorage.js, but by limiting here, it's easier to\n    // know when to set isTruncated flag, to inform the user that data is incomplete.)\n    const colIds = Object.keys(query.filters);\n    if (colIds.length > 0) {\n      const maxParams = Math.floor(MAX_SQL_PARAMS / colIds.length);\n      for (const c of colIds) {\n        const values = query.filters[c];\n        if (values.length > maxParams) {\n          query.filters[c] = values.slice(0, maxParams);\n          this.isTruncated = true;\n        }\n      }\n    }\n\n    let fetchPromise: Promise<void>;\n    if (tableModel.tableMetaRow.onDemand()) {\n      const tableQS = tableModel.tableQuerySets;\n      fetchPromise = docComm.useQuerySet({ limit: ON_DEMAND_ROW_LIMIT, ...query }).then((data) => {\n        // We assume that if we fetched the max number of rows, that there are likely more and the\n        // result should be reported as truncated.\n        // TODO: Better to fetch ON_DEMAND_ROW_LIMIT + 1 and omit one of them, so that isTruncated\n        // is only set if the row limit really was exceeded.\n        const rowIds = data.tableData[2];\n        if (rowIds.length >= ON_DEMAND_ROW_LIMIT) {\n          this.isTruncated = true;\n        }\n\n        this.onDispose(() => {\n          docComm.disposeQuerySet(data.querySubId).catch((err) => {\n            console.log(`Promise rejected for disposeQuerySet: ${err.message}`);\n          });\n          tableQS.removeQuerySet(this);\n        });\n        tableQS.addQuerySet(this, data.tableData);\n      });\n    } else {\n      // For regular (small), we fetch in bulk (and do nothing if already fetched).\n      fetchPromise = tableModel.fetch(false);\n    }\n\n    // This is a FilteredRowSource; subscribe it to the underlying data once the fetch resolves.\n    this.fetchPromise = fetchPromise.then(() => this.subscribeTo(tableModel));\n  }\n}\n\n/**\n * Helper for use in a DataTableModel to maintain all QuerySets.\n */\nexport class TableQuerySets {\n  private _querySets = new Set<QuerySet>();\n\n  constructor(private _tableData: TableData) {}\n\n  public addQuerySet(querySet: QuerySet, data: TableDataAction): void {\n    this._querySets.add(querySet);\n    this._tableData.loadPartial(data);\n  }\n\n  // Returns a Set of unused RowIds from querySet.\n  public removeQuerySet(querySet: QuerySet): void {\n    this._querySets.delete(querySet);\n\n    // Figure out which rows are not used by any other QuerySet in this DataTableModel.\n    const unusedRowIds = new Set(querySet.getAllRows());\n    for (const qs of this._querySets) {\n      for (const rowId of qs.getAllRows()) {\n        unusedRowIds.delete(rowId);\n      }\n    }\n    this._tableData.unloadPartial(Array.from(unusedRowIds) as number[]);\n  }\n}\n\n/**\n * Returns a filtering function which tells whether a row matches the given query.\n */\nexport function getFilterFunc(docData: DocData, query: ClientQuery): RowFilterFunc<UIRowId> {\n  // NOTE we rely without checking on tableId and colIds being valid.\n  const tableData: BaseTableData = docData.getTable(query.tableId)!;\n  const colGetters = new ClientColumnGettersByColId(tableData);\n  const rowFilterFunc = getLinkingFilterFunc(colGetters, query);\n  return (rowId: UIRowId) => rowId !== \"new\" && rowFilterFunc(rowId);\n}\n\n/**\n * Helper that converts a Query (with tableId/colIds) to an object with tableRef/colRefs (i.e.\n * rowIds), and consistently sorted. We use that to identify a Query across table/column renames.\n */\nfunction convertQueryToRefs(docModel: DocModel, query: ClientQuery): QueryRefs {\n  // During table rename, we can be referencing old name of a table.\n  const tableRec = Object.values(docModel.dataTables).find(t => t.tableData.tableId === query.tableId)?.tableMetaRow;\n  if (!tableRec) {\n    throw new Error(`Table ${query.tableId} not found`);\n  }\n\n  const colRefsByColId: { [colId: string]: ColRef } = { id: \"id\" };\n  for (const col of tableRec.columns.peek().peek()) {\n    colRefsByColId[col.colId.peek()] = col.getRowId();\n  }\n\n  const filterTuples = Object.keys(query.filters).map((colId) => {\n    const values = query.filters[colId];\n    // Keep filter values sorted by value, for consistency.\n    values.sort(nativeCompare);\n    return [colRefsByColId[colId], query.operations[colId], values] as FilterTuple;\n  });\n  // Keep filters sorted by colRef, for consistency.\n  filterTuples.sort((a, b) =>\n    nativeCompare(a[0], b[0]) || nativeCompare(a[1], b[1]));\n  return { tableRef: tableRec.getRowId(), filterTuples };\n}\n\n/**\n * Helper to convert a QueryRefs (using tableRef/colRefs) object back to a Query (using\n * tableId/colIds).\n */\nfunction convertQueryFromRefs(docModel: DocModel, queryRefs: QueryRefs): ClientQuery {\n  const tableRec = docModel.dataTablesByRef.get(queryRefs.tableRef)!.tableMetaRow;\n  const filters: { [colId: string]: any[] } = {};\n  const operations: { [colId: string]: QueryOperation } = {};\n  for (const [colRef, operation, values] of queryRefs.filterTuples) {\n    const colId = colRef === \"id\" ? \"id\" : docModel.columns.getRowModel(colRef).colId.peek();\n    filters[colId] = values;\n    operations[colId] = operation;\n  }\n  return { tableId: tableRec.tableId.peek(), filters, operations };\n}\n\n/**\n * Encodes a query (converted to QueryRefs using convertQueryToRefs()) as a string, to be usable\n * as a key into a map.\n *\n * It uses JSON.stringify, but avoids objects since their order of keys in serialization is not\n * guaranteed. This is important to produce consistent results (same query => same encoding).\n */\nfunction encodeQuery(queryRefs: QueryRefs): string {\n  return JSON.stringify([queryRefs.tableRef, queryRefs.filterTuples]);\n}\n\n// Decode an encoded QueryRefs.\nfunction decodeQuery(queryKey: string): QueryRefs {\n  const [tableRef, filterTuples] = JSON.parse(queryKey);\n  return { tableRef, filterTuples };\n}\n\n/**\n * Returns a ko.computed() which turns to true when the table or any of the columns needed by the\n * given query are deleted.\n */\nfunction makeQueryInvalidComputed(docModel: DocModel, queryRefs: QueryRefs): ko.Computed<boolean> {\n  const tableFlag: ko.Observable<boolean> = docModel.tables.getRowModel(queryRefs.tableRef)._isDeleted;\n  const colFlags: (ko.Observable<boolean> | null)[] = queryRefs.filterTuples.map(\n    ([colRef, ,]) => colRef === \"id\" ? null : docModel.columns.getRowModel(colRef)._isDeleted);\n  return ko.computed(() => Boolean(tableFlag() || colFlags.some(c => c?.())));\n}\n"
  },
  {
    "path": "app/client/models/RuleOwner.ts",
    "content": "import { ColumnRec, DocModel } from \"app/client/models/DocModel\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\nimport { Style } from \"app/client/models/Styles\";\nimport { GristObjCode } from \"app/plugin/GristData\";\n\nexport interface RuleOwner {\n  // Field or Section can have a list of conditional styling rules. Each style is a combination of a formula and options\n  // that must by applied. Style is persisted as a new hidden formula column and the list of such\n  // columns is stored as Reference List property ('rules') in a field or column.\n  tableId: ko.Computed<string>;\n  // If this field (or column) has a list of conditional styling rules.\n  hasRules: ko.Computed<boolean>;\n  // List of rules.\n  rulesList: modelUtil.KoSaveableObservable<[GristObjCode.List, ...number[]] | null>;\n  // List of columns that are used as rules for conditional styles.\n  rulesCols: ko.Computed<ColumnRec[]>;\n  // List of columns ids that are used as rules for conditional styles.\n  rulesColsIds: ko.Computed<string[]>;\n  // List of styles used by conditional rules.\n  rulesStyles: modelUtil.KoSaveableObservable<Style[]>;\n  // Adds empty conditional style rule. Sets before sending to the server.\n  addEmptyRule(): Promise<void>;\n  // Removes one rule from the collection. Removes before sending update to the server.\n  removeRule(index: number): Promise<void>;\n}\n\nexport async function removeRule(docModel: DocModel, owner: RuleOwner, index: number) {\n  const col = owner.rulesCols.peek()[index];\n  if (!col) {\n    throw new Error(`There is no rule at index ${index}`);\n  }\n  const newStyles = owner.rulesStyles.peek()?.slice() ?? [];\n  if (newStyles.length >= index) {\n    newStyles.splice(index, 1);\n  } else {\n    console.debug(`There are not style options at index ${index}`);\n  }\n  await docModel.docData.bundleActions(\"Remove conditional rule\", () =>\n    Promise.all([\n      owner.rulesStyles.setAndSave(newStyles),\n      docModel.docData.sendAction([\"RemoveColumn\", owner.tableId.peek(), col.colId.peek()]),\n    ]),\n  );\n}\n"
  },
  {
    "path": "app/client/models/SearchModel.ts",
    "content": "// TODO: Add documentation and clean up log statements.\n\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { PageRec, ViewFieldRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { reportError } from \"app/client/models/errors\";\nimport { delay } from \"app/common/delay\";\nimport { IDocPage } from \"app/common/gristUrls\";\nimport { nativeCompare, waitObs } from \"app/common/gutil\";\nimport { TableData } from \"app/common/TableData\";\nimport { BaseFormatter } from \"app/common/ValueFormatter\";\nimport { CursorPos } from \"app/plugin/GristAPI\";\n\nimport { Computed, Disposable, Observable } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\n\nconst t = makeT(\"SearchModel\");\n\n/**\n * SearchModel used to maintain the state of the search UI.\n */\nexport interface SearchModel {\n  value: Observable<string>;       // string in the search input\n  isOpen: Observable<boolean>;     // indicates whether the search bar is expanded to show the input\n  noMatch: Observable<boolean>;    // indicates if there are no search matches\n  isEmpty: Observable<boolean>;     // indicates whether the value is empty\n  isRunning: Observable<boolean>;  // indicates that matching is in progress\n  multiPage: Observable<boolean>;   // if true will search across all pages\n  allLabel: Observable<string>;   // label to show instead of default 'Search all pages'\n\n  findNext(): Promise<void>;       // find next match\n  findPrev(): Promise<void>;       // find previous match\n  onPageChange(callback: () => void): void; // exposes page changes triggered by search through a callback\n}\n\ninterface SearchPosition {\n  pageIndex: number;\n  sectionIndex: number;\n  rowIndex: number;\n  fieldIndex: number;\n}\n\n/**\n * Stepper is an helper class that is used to implement stepping through all the cells of a\n * document. Fields belongs to rows, rows belongs to section and sections to pages. So this is four\n * steppers that must be used together, one for each level (field, rows, section and pages). When a\n * stepper reaches the end of its array, this is the `nextArrayFunc` callback, passed to the\n * `next()`, that is responsible for both taking a step at the higher level and updating the\n * stepper's array.\n */\nclass Stepper<T> {\n  public array: readonly T[] = [];\n  public index: number = 0;\n\n  public inRange() {\n    return this.index >= 0 && this.index < this.array.length;\n  }\n\n  // Doing await at every step adds a ton of overhead; we can optimize by returning and waiting on\n  // Promises only when needed.\n  public next(step: number, nextArrayFunc: () => Promise<void> | void): Promise<void> | void {\n    this.index += step;\n    if (!this.inRange()) {\n      // If index reached the end of the array, take a step at a higher level to get a new array.\n      // For efficiency, only wait asynchronously if the callback returned a promise.\n      const p = nextArrayFunc();\n      if (p) {\n        return p.then(() => this.setStart(step));\n      } else {\n        this.setStart(step);\n      }\n    }\n  }\n\n  public setStart(step: number) {\n    this.index = step > 0 ? 0 : this.array.length - 1;\n  }\n\n  public get value(): T { return this.array[this.index]; }\n}\n\n/**\n * Interface that represents an ongoing search job which stops on the first match found.\n */\ninterface IFinder {\n  matchFound: boolean;             // true if a match was found\n  startPosition: SearchPosition;   // position at which to stop searching for a new match\n  abort(): void;                   // abort current search\n  matchNext(step: number): Promise<void>;      // next match\n  nextField(step: number): Promise<void> | void; // move the current position\n  getCurrentPosition(): SearchPosition;        // get the current position\n}\n\n// A callback to opening a page: useful to switch to next page during an ongoing search.\ntype DocPageOpener = (viewId: IDocPage) => Promise<void>;\n\n// To support Raw Data Views we will introduce a 'wrapped' page abstraction. Raw data\n// page is not a true page (it doesn't have a record), this will allow as to treat a raw view section\n// as if it were a PageRec.\ninterface ISearchablePageRec {\n  viewSections(): ViewSectionRec[];\n  activeSectionId(): number;\n  getViewId(): IDocPage;\n  openPage(): Promise<void>;\n}\n\nclass RawSectionWrapper implements ISearchablePageRec {\n  constructor(private _section: ViewSectionRec) {\n\n  }\n\n  public viewSections(): ViewSectionRec[] {\n    return [this._section];\n  }\n\n  public activeSectionId() {\n    return this._section.id.peek();\n  }\n\n  public getViewId(): IDocPage {\n    return \"data\";\n  }\n\n  public async openPage() {\n    this._section.view.peek().activeSectionId(this._section.getRowId());\n    await waitObs(this._section.viewInstance);\n    await this._section.viewInstance.peek()?.getLoadingDonePromise();\n  }\n}\n\nclass PageRecWrapper implements ISearchablePageRec {\n  constructor(private _page: PageRec, private _opener: DocPageOpener) {\n\n  }\n\n  public viewSections(): ViewSectionRec[] {\n    const sections = this._page.view.peek().viewSections.peek().peek();\n    const collapsed = new Set(this._page.view.peek().activeCollapsedSections.peek());\n    const activeSectionId = this._page.view.peek().activeSectionId.peek();\n    // If active section is collapsed, it means it is rendered in the popup, so narrow\n    // down the search to only it.\n    const inPopup = collapsed.has(activeSectionId);\n    if (inPopup) {\n      return sections.filter(s => s.getRowId() === activeSectionId);\n    }\n    return sections.filter(s => !collapsed.has(s.getRowId()));\n  }\n\n  public activeSectionId() {\n    return this._page.view.peek().activeSectionId.peek();\n  }\n\n  public getViewId() {\n    return this._page.view.peek().getRowId();\n  }\n\n  public openPage() {\n    return this._opener(this.getViewId());\n  }\n}\n\n// activeSectionId\n\n/**\n * An implementation of an IFinder.\n */\nclass FinderImpl implements IFinder {\n  public matchFound = false;\n  public startPosition: SearchPosition;\n\n  private _searchRegexp: RegExp;\n  private _pageStepper = new Stepper<ISearchablePageRec>();\n  private _sectionStepper = new Stepper<ViewSectionRec>();\n  private _sectionTableData: TableData;\n  private _rowStepper = new Stepper<number>();\n  private _fieldStepper = new Stepper<ViewFieldRec>();\n  private _fieldFormatters: [ViewFieldRec, BaseFormatter][];\n  private _pagesSwitched: number = 0;\n  private _aborted = false;\n  private _clearCursorHighlight: (() => void) | undefined;\n\n  constructor(private _gristDoc: GristDoc, value: string, private _openDocPageCB: DocPageOpener,\n    public multiPage: Observable<boolean>, private _onPageChange?: () => void) {\n    this._searchRegexp = makeRegexp(value);\n  }\n\n  public abort() {\n    this._aborted = true;\n    if (this._clearCursorHighlight) { this._clearCursorHighlight(); }\n  }\n\n  public getCurrentPosition(): SearchPosition {\n    return {\n      pageIndex: this._pageStepper.index,\n      sectionIndex: this._sectionStepper.index,\n      rowIndex: this._rowStepper.index,\n      fieldIndex: this._fieldStepper.index,\n    };\n  }\n\n  // Initialize the steppers. Returns false if anything goes wrong.\n  public async init(): Promise<boolean> {\n    // If we are on a raw view page, pretend that we are looking at true pages.\n    if (\"data\" === this._gristDoc.activeViewId.get()) {\n      // Get all raw sections.\n      const rawSections = this._gristDoc.docModel.visibleTables.peek()\n      // sort in order that is the same as on the raw data list page,\n        .sort((a, b) => nativeCompare(a.tableNameDef.peek(), b.tableNameDef.peek()))\n      // get rawViewSection,\n        .map(table => table.rawViewSection.peek())\n      // and test if it isn't an empty record.\n        .filter(s => Boolean(s.id.peek()));\n      // Pretend that those are pages.\n      this._pageStepper.array = rawSections.map(r => new RawSectionWrapper(r));\n      // Find currently selected one (by comparing to active section id)\n      this._pageStepper.index = rawSections.findIndex(s =>\n        s.getRowId() === this._gristDoc.viewModel.activeSectionId.peek());\n      // If we are at listing, where no section is active open the first page. Otherwise, search will fail.\n      if (this._pageStepper.index < 0) {\n        this._pageStepper.index = 0;\n        await this._pageStepper.value.openPage();\n      }\n    } else {\n      // Else read all visible pages.\n      const pages = this._gristDoc.docModel.visibleDocPages.peek();\n      this._pageStepper.array = pages.map(p => new PageRecWrapper(p, this._openDocPageCB));\n      this._pageStepper.index = pages.findIndex(page => page.viewRef.peek() === this._gristDoc.activeViewId.get());\n      if (this._pageStepper.index < 0) { return false; }\n    }\n\n    const sections = this._pageStepper.value.viewSections();\n    this._sectionStepper.array = sections;\n    this._sectionStepper.index = sections.findIndex(s => s.getRowId() === this._pageStepper.value.activeSectionId());\n    if (this._sectionStepper.index < 0) { return false; }\n\n    this._initNewSectionShown();\n\n    // Find the current cursor position in the current section.\n    const viewInstance = this._sectionStepper.value.viewInstance.peek()!;\n    const pos = viewInstance.cursor.getCursorPos();\n    this._rowStepper.index = pos.rowIndex!;\n    this._fieldStepper.index = pos.fieldIndex!;\n    return true;\n  }\n\n  public async matchNext(step: number): Promise<void> {\n    let count = 0;\n    let lastBreak = Date.now();\n\n    this._pagesSwitched = 0;\n\n    while (!this._matches() || ((await this._loadSection(step)) && !this._matches())) {\n      // If search was aborted, simply returns.\n      if (this._aborted) { return; }\n\n      // To avoid hogging the CPU for too long, check time periodically, and if we've been running\n      // for long enough, take a brief break. We choose a 5ms break every 20ms; and only check\n      // time every 100 iterations, to avoid excessive overhead purely due to time checks.\n      if ((++count) % 100 === 0 && Date.now() >= lastBreak + 20) {\n        await delay(5);\n        lastBreak = Date.now();\n      }\n\n      const p = this.nextField(step);\n      if (p) { await p; }\n\n      // Detect when we get back to the start position; this is where we break on no match.\n      if (this._isCurrentPosition(this.startPosition) && !this._matches()) {\n        console.log(\"SearchBar: reached start position without finding anything\");\n        this.matchFound = false;\n        return;\n      }\n\n      // A fail-safe to prevent certain bugs from causing infinite loops; break also if we scan\n      // through pages too many times.\n      // TODO: test it by disabling the check above.\n      if (this._pagesSwitched > this._pageStepper.array.length) {\n        console.log(\"SearchBar: aborting search due to too many page switches\");\n        this.matchFound = false;\n        return;\n      }\n    }\n    console.log(\"SearchBar: found a match at %s\", JSON.stringify(this.getCurrentPosition()));\n    this.matchFound = true;\n    await this._highlight();\n  }\n\n  public nextField(step: number): Promise<void> | void {\n    return this._fieldStepper.next(step, () => this._nextRow(step));\n  }\n\n  private _nextRow(step: number) {\n    return this._rowStepper.next(step, () => this._nextSection(step));\n  }\n\n  private async _nextSection(step: number) {\n    // Switching sections is rare enough that we don't worry about optimizing away `await` calls.\n    await this._sectionStepper.next(step, () => this._nextPage(step));\n    await this._initNewSectionAny();\n  }\n\n  // TODO There are issues with filtering. A section may have filters applied, and it may be\n  // auto-filtered (linked sections). If a tab is shown, we have the filtered list of rowIds; if\n  // the tab is not shown, it takes work to apply explicit filters. For linked sections, the\n  // sensible behavior seems to scan through ALL values, then once a match is found, set the\n  // cursor that determines the linking to include the matched row. And even that may not always\n  // be possible. So this is an open question.\n\n  private _initNewSectionCommon() {\n    const section = this._sectionStepper.value;\n    const tableModel = this._gristDoc.getTableModel(section.table.peek().tableId.peek());\n    this._sectionTableData = tableModel.tableData;\n\n    this._fieldStepper.array = section.viewFields().peek();\n    this._initFormatters();\n    return tableModel;\n  }\n\n  private _initNewSectionShown() {\n    this._initNewSectionCommon();\n    const viewInstance = this._sectionStepper.value.viewInstance.peek()!;\n    const skip = [\"chart\"].includes(this._sectionStepper.value.parentKey.peek());\n    this._rowStepper.array = skip ? [] : viewInstance.sortedRows.getKoArray().peek() as number[];\n  }\n\n  private async _initNewSectionAny() {\n    const tableModel = this._initNewSectionCommon();\n\n    const viewInstance = this._sectionStepper.value.viewInstance.peek();\n    const skip = [\"chart\"].includes(this._sectionStepper.value.parentKey.peek());\n    if (skip) {\n      this._rowStepper.array = [];\n    } else if (viewInstance) {\n      this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek() as number[];\n    } else {\n      // If we are searching through another page (not currently loaded), we will NOT have a\n      // viewInstance, but we use the unsorted unfiltered row list, and if we find a match, the\n      // _loadSection() method will load the page and we'll repeat the search with a viewInstance.\n      await tableModel.fetch();\n      this._rowStepper.array = this._sectionTableData.getRowIds();\n    }\n  }\n\n  private async _nextPage(step: number) {\n    if (!this.multiPage.get()) { return; }\n    await this._pageStepper.next(step, () => undefined);\n    this._pagesSwitched++;\n\n    const view = this._pageStepper.value;\n    this._sectionStepper.array = view.viewSections();\n  }\n\n  private _initFormatters() {\n    this._fieldFormatters = this._fieldStepper.array.map(f => [f, f.formatter.peek()]);\n  }\n\n  private _matches(): boolean {\n    if (this._pageStepper.index < 0 || this._sectionStepper.index < 0 ||\n      this._rowStepper.index < 0 || this._fieldStepper.index < 0) {\n      console.warn(\"match outside\");\n      return false;\n    }\n    const field = this._fieldStepper.value;\n    let formatter = this._fieldFormatters[this._fieldStepper.index];\n    // When fields are removed during search (or reordered) we need to update\n    // formatters we retrieved on init.\n    if (formatter?.[0] !== field) {\n      this._initFormatters();\n      formatter = this._fieldFormatters[this._fieldStepper.index];\n    }\n    const rowId = this._rowStepper.value;\n    const displayCol = field.displayColModel.peek();\n\n    const value = this._sectionTableData.getValue(rowId, displayCol.colId.peek());\n\n    // TODO: Note that formatting dates is now the bulk of the performance cost.\n    const text = formatter[1  /* formatter */].formatAny(value);\n    return this._searchRegexp.test(text);\n  }\n\n  private async _loadSection(step: number): Promise<boolean> {\n    // If we found a match in a section for which we don't have a valid BaseView instance, we need\n    // to load the BaseView and start searching the section again, since the match we found does\n    // not take into account sort or filters. So we switch to the right page, wait for the\n    // viewInstance to be created, reset the section info, and return true to continue searching.\n    const section = this._sectionStepper.value;\n    if (!section.viewInstance.peek()) {\n      const view = this._pageStepper.value;\n      if (this._aborted) { return false; }\n      await view.openPage();\n      console.log(\"SearchBar: loading view %s section %s\", view.getViewId(), section.getRowId());\n      const viewInstance: any = await waitObs(section.viewInstance);\n      await viewInstance.getLoadingDonePromise();\n      this._initNewSectionShown();\n      this._rowStepper.setStart(step);\n      this._fieldStepper.setStart(step);\n      console.log(\"SearchBar: loaded view %s section %s\", view.getViewId(), section.getRowId());\n      if (this._onPageChange) {\n        this._onPageChange();\n      }\n\n      return true;\n    }\n    return false;\n  }\n\n  // Highlights the cell at the current position.\n  private async _highlight() {\n    if (this._aborted) { return; }\n\n    const section = this._sectionStepper.value;\n    const sectionId = section.getRowId();\n    const cursorPos: CursorPos = {\n      sectionId,\n      rowId: this._rowStepper.value,\n      fieldIndex: this._fieldStepper.index,\n    };\n    await this._gristDoc.recursiveMoveToCursorPos(cursorPos, true).catch(reportError);\n    if (this._aborted) { return; }\n\n    // Highlight the selected cursor, after giving it a chance to update. We find the cursor in\n    // this ad-hoc way rather than use observables, to avoid the overhead of *every* cell\n    // depending on an additional observable.\n    await delay(0);\n    const viewInstance = (await waitObs(section.viewInstance))!;\n    await viewInstance.getLoadingDonePromise();\n    if (this._aborted) { return; }\n    // Make sure we are at good place. This is important when the cursor\n    // was already in a matched record, but the record was scrolled away.\n    viewInstance.scrollToCursor(true).catch(reportError);\n\n    const cursor = viewInstance.viewPane.querySelector(\".selected_cursor\");\n    if (cursor) {\n      cursor.classList.add(\"search-match\");\n      this._clearCursorHighlight = () => {\n        cursor.classList.remove(\"search-match\");\n        clearTimeout(timeout);\n        this._clearCursorHighlight = undefined;\n      };\n      const timeout = setTimeout(this._clearCursorHighlight, 20);\n    }\n  }\n\n  private _isCurrentPosition(pos: SearchPosition): boolean {\n    return (\n      this._pageStepper.index === pos.pageIndex &&\n      this._sectionStepper.index === pos.sectionIndex &&\n      this._rowStepper.index === pos.rowIndex &&\n      this._fieldStepper.index === pos.fieldIndex\n    );\n  }\n}\n\n/**\n * Implementation of SearchModel used to construct the search UI.\n */\nexport class SearchModelImpl extends Disposable implements SearchModel {\n  public readonly value = Observable.create(this, \"\");\n  public readonly isOpen = Observable.create(this, false);\n  public readonly isRunning = Observable.create(this, false);\n  public readonly noMatch = Observable.create(this, true);\n  public readonly isEmpty = Observable.create(this, true);\n  public readonly multiPage = Observable.create(this, false);\n  public readonly allLabel: Computed<string>;\n\n  private _isRestartNeeded = false;\n  private _finder: IFinder | null = null;\n  private _onPageChange: (() => void) | undefined;\n  constructor(private _gristDoc: GristDoc) {\n    super();\n\n    // Listen to input value changes (debounced) to activate searching.\n    const findFirst = debounce((_value: string) => this._findFirst(_value), 100);\n    this.autoDispose(this.value.addListener((v) => { this.isRunning.set(true); void findFirst(v); }));\n\n    // Set this.noMatch to false when multiPage gets turned ON.\n    this.autoDispose(this.multiPage.addListener((v) => { if (v) { this.noMatch.set(false); } }));\n\n    this.allLabel = Computed.create(this, use => use(this._gristDoc.activeViewId) === \"data\" ?\n      t(\"Search all tables\") : t(\"Search all pages\"));\n\n    // Schedule a search restart when user changes pages (otherwise search would resume from the\n    // previous page that is not shown anymore). Also revert noMatch flag when in single page mode.\n    this.autoDispose(this._gristDoc.activeViewId.addListener(() => {\n      if (!this.multiPage.get()) { this.noMatch.set(false); }\n      this._isRestartNeeded = true;\n    }));\n\n    // On Raw data view, whenever table is closed (so activeSectionId = 0), restart search.\n    this.autoDispose(this._gristDoc.viewModel.activeSectionId.subscribe((sectionId) => {\n      if (this._gristDoc.activeViewId.get() === \"data\" && sectionId === 0) {\n        this._isRestartNeeded = true;\n        this.noMatch.set(false);\n      }\n    }));\n  }\n\n  public async findNext() {\n    if (this.isRunning.get() || this.noMatch.get()) { return; }\n    if (this._isRestartNeeded) { return this._findFirst(this.value.get()); }\n    await this._run(async (finder) => {\n      await finder.nextField(1);\n      await finder.matchNext(1);\n    });\n  }\n\n  public async findPrev() {\n    if (this.isRunning.get() || this.noMatch.get()) { return; }\n    if (this._isRestartNeeded) { return this._findFirst(this.value.get()); }\n    await this._run(async (finder) => {\n      await finder.nextField(-1);\n      await finder.matchNext(-1);\n    });\n  }\n\n  public onPageChange(callback: () => void) {\n    this._onPageChange = callback;\n  }\n\n  private async _findFirst(value: string) {\n    this._isRestartNeeded = false;\n    this.isEmpty.set(!value);\n    await this._updateFinder(value);\n    if (!value || !this._finder) { this.noMatch.set(true); return; }\n    await this._run(async (finder) => {\n      await finder.matchNext(1);\n    });\n  }\n\n  private async _updateFinder(value: string) {\n    if (this._finder) { this._finder.abort(); }\n    const impl = new FinderImpl(this._gristDoc,\n      value,\n      this._openDocPage.bind(this),\n      this.multiPage,\n      this._onPageChange,\n    );\n    const isValid = await impl.init();\n    this._finder = isValid ? impl : null;\n  }\n\n  // Internal helper that runs cb, passing it the current `this._finder` as first argument and sets\n  // this.isRunning to true until the call resolves. It also takes care of updating this.noMatch.\n  private async _run(cb: (finder: IFinder) => Promise<void>) {\n    const finder = this._finder;\n    if (!finder) { throw new Error(\"SearchModel: finder is not defined\"); }\n\n    try {\n      this.isRunning.set(true);\n      finder.startPosition = finder.getCurrentPosition();\n      await cb(finder);\n    } finally {\n      this.isRunning.set(false);\n      this.noMatch.set(!finder.matchFound);\n    }\n  }\n\n  // Opens doc page without triggering a restart.\n  private async _openDocPage(viewId: IDocPage) {\n    await this._gristDoc.openDocPage(viewId);\n    this._isRestartNeeded = false;\n  }\n}\n\nfunction makeRegexp(value: string) {\n  // From https://stackoverflow.com/a/3561711/328565\n  const escaped = value.replace(/[-/\\\\^$*+?.()|[\\]{}]/g, \"\\\\$&\");\n  return new RegExp(escaped, \"i\");\n}\n"
  },
  {
    "path": "app/client/models/SectionFilter.ts",
    "content": "import { ColumnFilter } from \"app/client/models/ColumnFilter\";\nimport { ColumnRec, ViewFieldRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { TableData } from \"app/client/models/TableData\";\nimport { buildColFilter, ColumnFilterFunc } from \"app/common/ColumnFilterFunc\";\nimport { buildRowFilter, RowFilterFunc, RowValueFunc } from \"app/common/RowFilterFunc\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\n\nimport { Computed, Disposable, Observable, UseCB } from \"grainjs\";\n\nexport type { ColumnFilterFunc };\n\ninterface OpenColumnFilter {\n  colRef: number;\n  colFilter: ColumnFilter;\n}\n\ntype ColFilterCB = (\n  fieldOrColumn: ViewFieldRec | ColumnRec, colFilter: ColumnFilterFunc | null,\n) => ColumnFilterFunc | null;\n\n/**\n * SectionFilter represents a collection of column filters in place for a view section. It is created\n * out of `filters` (in `viewSection`) and `tableData`, and provides a Computed `sectionFilterFunc` that users can\n * subscribe to in order to update their FilteredRowSource.\n *\n * Additionally, `setFilterOverride()` provides a way to override the current filter for a given colRef,\n * to reflect the changes in an open filter dialog.\n */\nexport class SectionFilter extends Disposable {\n  public readonly sectionFilterFunc: Observable<RowFilterFunc<UIRowId>>;\n\n  private _openFilterOverride: Observable<OpenColumnFilter | null> = Observable.create(this, null);\n\n  constructor(public viewSection: ViewSectionRec, private _tableData: TableData) {\n    super();\n\n    this.sectionFilterFunc = Computed.create(this, this._openFilterOverride, (use, openFilter) => {\n      const openFilterFilterFunc = openFilter && use(openFilter.colFilter.filterFunc);\n      function getFilterFunc(fieldOrColumn: ViewFieldRec | ColumnRec, colFilter: ColumnFilterFunc | null) {\n        if (openFilter?.colRef === fieldOrColumn.origCol().getRowId()) {\n          return openFilterFilterFunc;\n        }\n        return colFilter;\n      }\n      return this.buildFilterFunc(getFilterFunc, use);\n    });\n  }\n\n  /**\n   * Allows to override a single filter for a given colRef. Multiple calls to `setFilterOverride` will overwrite\n   * previously set values.\n   */\n  public setFilterOverride(colRef: number, colFilter: ColumnFilter) {\n    this._openFilterOverride.set(({ colRef, colFilter }));\n    colFilter.onDispose(() => {\n      const override = this._openFilterOverride.get();\n      if (override?.colFilter === colFilter) {\n        this._openFilterOverride.set(null);\n      }\n    });\n  }\n\n  /**\n   * Builds a filter function that combines the filter function of all the columns. You can use\n   * `getFilterFunc(column, colFilter)` to customize the filter func for each column. It calls\n   * `getFilterFunc` right away.\n   */\n  public buildFilterFunc(getFilterFunc: ColFilterCB, use: UseCB) {\n    const filters = use(this.viewSection.filters);\n    const funcs: (RowFilterFunc<UIRowId> | null)[] = filters.map(({ filter, fieldOrColumn }) => {\n      const colFilter = buildColFilter(use(filter), use(use(fieldOrColumn.origCol).type));\n      const filterFunc = getFilterFunc(fieldOrColumn, colFilter);\n\n      const getter = this._tableData.getRowPropFunc(fieldOrColumn.colId.peek());\n\n      if (!filterFunc || !getter) { return null; }\n\n      return buildRowFilter(getter as RowValueFunc<UIRowId>, filterFunc);\n    }).filter(f => f !== null); // Filter out columns that don't have a filter\n\n    return (rowId: UIRowId) => rowId === \"new\" || funcs.every(f => Boolean(f?.(rowId)));\n  }\n}\n"
  },
  {
    "path": "app/client/models/Styles.ts",
    "content": "export interface Style {\n  textColor?: string | undefined; // this can be string, undefined or an absent key.\n  fillColor?: string | undefined;\n  fontBold?: boolean | undefined;\n  fontUnderline?: boolean | undefined;\n  fontItalic?: boolean | undefined;\n  fontStrikethrough?: boolean | undefined;\n}\n\nexport interface HeaderStyle {\n  headerTextColor?: string | undefined; // this can be string, undefined or an absent key.\n  headerFillColor?: string | undefined;\n  headerFontBold?: boolean | undefined;\n  headerFontUnderline?: boolean | undefined;\n  headerFontItalic?: boolean | undefined;\n  headerFontStrikethrough?: boolean | undefined;\n}\n\nexport class CombinedStyle implements Style {\n  public readonly textColor?: string;\n  public readonly fillColor?: string;\n  public readonly fontBold?: boolean;\n  public readonly fontUnderline?: boolean;\n  public readonly fontItalic?: boolean;\n  public readonly fontStrikethrough?: boolean;\n  constructor(rules: (Style | undefined | null)[], flags: any[]) {\n    for (let i = 0; i < rules.length; i++) {\n      if (flags[i]) {\n        const textColor = rules[i]?.textColor;\n        const fillColor = rules[i]?.fillColor;\n        const fontBold = rules[i]?.fontBold;\n        const fontUnderline = rules[i]?.fontUnderline;\n        const fontItalic = rules[i]?.fontItalic;\n        const fontStrikethrough = rules[i]?.fontStrikethrough;\n        this.textColor = textColor || this.textColor;\n        this.fillColor = fillColor || this.fillColor;\n        this.fontBold = fontBold || this.fontBold;\n        this.fontUnderline = fontUnderline || this.fontUnderline;\n        this.fontItalic = fontItalic || this.fontItalic;\n        this.fontStrikethrough = fontStrikethrough || this.fontStrikethrough;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/models/TableData.ts",
    "content": "/**\n * TableData maintains a single table's data.\n */\nimport { ColumnACIndexes } from \"app/client/models/ColumnACIndexes\";\nimport { ColumnCache } from \"app/client/models/ColumnCache\";\nimport { DocData } from \"app/client/models/DocData\";\nimport { DocAction, ReplaceTableData, TableDataAction, UserAction } from \"app/common/DocActions\";\nimport { isRaisedException } from \"app/common/gristTypes\";\nimport { countIf } from \"app/common/gutil\";\nimport { SchemaTypes } from \"app/common/schema\";\nimport { ColTypeMap, MetaTableData as MetaTableDataBase, TableData as TableDataBase } from \"app/common/TableData\";\n\nimport { Emitter } from \"grainjs\";\n\n/**\n * TableData class to maintain a single table's data.\n */\nexport class TableData extends TableDataBase {\n  public readonly tableActionEmitter = new Emitter();\n  public readonly preTableActionEmitter = new Emitter();\n  public readonly dataLoadedEmitter = new Emitter();\n\n  public readonly columnACIndexes = new ColumnACIndexes(this);\n\n  private _columnErrorCounts = new ColumnCache<number | undefined>(this);\n\n  /**\n   * Constructor for TableData.\n   * @param {DocData} docData: The root DocData object for this document.\n   * @param {String} tableId: The name of this table.\n   * @param {Object} tableData: An object equivalent to BulkAddRecord, i.e.\n   *        [\"TableData\", tableId, rowIds, columnValues].\n   * @param {Object} columnTypes: A map of colId to colType.\n   */\n  constructor(public readonly docData: DocData,\n    tableId: string, tableData: TableDataAction | null, columnTypes: ColTypeMap) {\n    super(tableId, tableData, columnTypes);\n  }\n\n  public loadData(tableData: TableDataAction | ReplaceTableData): number[] {\n    const oldRowIds = super.loadData(tableData);\n    // If called from base constructor, this.dataLoadedEmitter may be unset; in that case there\n    // are no subscribers anyway.\n    if (this.dataLoadedEmitter) {\n      this.dataLoadedEmitter.emit(oldRowIds, this.getRowIds());\n    }\n    return oldRowIds;\n  }\n\n  // Used by QuerySet to load new rows for onDemand tables.\n  public loadPartial(data: TableDataAction): void {\n    super.loadPartial(data);\n    // Emit dataLoaded event, to trigger ('rowChange', 'add') on the TableModel RowSource.\n    this.dataLoadedEmitter.emit([], data[2]);\n  }\n\n  // Used by QuerySet to remove unused rows for onDemand tables when a QuerySet is disposed.\n  public unloadPartial(rowIds: number[]): void {\n    super.unloadPartial(rowIds);\n    // Emit dataLoaded event, to trigger ('rowChange', 'rm') on the TableModel RowSource.\n    this.dataLoadedEmitter.emit(rowIds, []);\n  }\n\n  /**\n   * Counts and returns the number of error values in the given column. The count is cached to\n   * keep it faster for large tables, and the cache is cleared as needed on changes to the table.\n   */\n  public countErrors(colId: string): number | undefined {\n    return this._columnErrorCounts.getValue(colId, () => {\n      const values = this.getColValues(colId);\n      return values && countIf(values, isRaisedException);\n    });\n  }\n\n  /**\n   * Sends an array of table-specific action to the server to be applied. The tableId should be\n   * omitted from each `action` parameter and will be inserted automatically.\n   *\n   * @param {Array} actions: Array of user actions of the form [actionType, rowId, etc], which is sent\n   * to the server as [actionType, **tableId**, rowId, etc]\n   * @param {String} optDesc: Optional description of the actions to be shown in the log.\n   * @returns {Array} Array of return values for all the UserActions as produced by the data engine.\n   */\n  public sendTableActions(actions: UserAction[], optDesc?: string) {\n    actions.forEach(action => action.splice(1, 0, this.tableId));\n    return this.docData.sendActions(actions as DocAction[], optDesc);\n  }\n\n  /**\n   * Sends a table-specific action to the server. The tableId should be omitted from the action parameter\n   * and will be inserted automatically.\n   *\n   * @param {Array} action: [actionType, rowId...], sent as [actionType, **tableId**, rowId...]\n   * @param {String} optDesc: Optional description of the actions to be shown in the log.\n   * @returns {Object} Return value for the UserAction as produced by the data engine.\n   */\n  public sendTableAction(action: UserAction, optDesc?: string) {\n    if (!action) { return; }\n    action.splice(1, 0, this.tableId);\n    return this.docData.sendAction(action as DocAction, optDesc);\n  }\n\n  /**\n   * Emits a table-specific action received from the server as a 'tableAction' event.\n   */\n  public receiveAction(action: DocAction): boolean {\n    this.preTableActionEmitter.emit(action);\n    const applied = super.receiveAction(action);\n    if (applied) {\n      this.tableActionEmitter.emit(action);\n    }\n    return applied;\n  }\n}\n\nexport type MetaTableData<TableId extends keyof SchemaTypes> = MetaTableDataBase<TableId> & TableData;\n"
  },
  {
    "path": "app/client/models/TableModel.js",
    "content": "/**\n * TableModel maintains the model for an arbitrary data table of a Grist document.\n */\n\n\nvar _ = require(\"underscore\");\nvar ko = require(\"knockout\");\nvar dispose = require(\"../lib/dispose\");\nvar rowset = require(\"./rowset\");\nvar modelUtil = require(\"./modelUtil\");\n\nfunction TableModel(docModel, tableData) {\n  this.docModel = docModel;\n  this.tableData = tableData;\n\n  // Maps groupBy fields to RowGrouping objects.\n  this.rowGroupings = {};\n\n  this.isLoaded = ko.observable(tableData.isLoaded);\n  this.autoDispose(tableData.dataLoadedEmitter.addListener(this.onDataLoaded, this));\n  this.autoDispose(tableData.tableActionEmitter.addListener(this.dispatchAction, this));\n}\n\ndispose.makeDisposable(TableModel);\n_.extend(TableModel.prototype, rowset.RowSource.prototype, modelUtil.ActionDispatcher);\n\nTableModel.prototype.fetch = function(force) {\n  if (this.isLoaded.peek() && force) {\n    this.isLoaded(false);\n  }\n  return this.tableData.docData.fetchTable(this.tableData.tableId, force);\n};\n\nTableModel.prototype.getAllRows = function() {\n  return this.tableData.getRowIds();\n};\n\nTableModel.prototype.getNumRows = function() {\n  return this.tableData.numRecords();\n};\n\nTableModel.prototype.getExtraRows = function() {\n  return undefined;\n};\n\nTableModel.prototype.getRowGrouping = function(groupByCol) {\n  var grouping = this.rowGroupings[groupByCol];\n  if (!grouping) {\n    grouping = rowset.RowGrouping.create(null, this.tableData.getRowPropFunc(groupByCol));\n    grouping.subscribeTo(this);\n    this.rowGroupings[groupByCol] = grouping;\n  }\n  return grouping;\n};\n\nTableModel.prototype.onDataLoaded = function(oldRowIds, newRowIds) {\n  this.trigger(\"rowChange\", \"remove\", oldRowIds);\n  this.trigger(\"rowChange\", \"add\", newRowIds);\n  this.isLoaded(true);\n};\n\n/**\n * Shortcut for `.tableData.sendTableActions`. See documentation in TableData.js.\n */\nTableModel.prototype.sendTableActions = function(actions, optDesc) {\n  return this.tableData.sendTableActions(actions, optDesc);\n};\n\n/**\n * Shortcut for `.tableData.sendTableAction`. See documentation in TableData.js.\n */\nTableModel.prototype.sendTableAction = function(action, optDesc) {\n  return this.tableData.sendTableAction(action, optDesc);\n};\n\n//----------------------------------------------------------------------\n/**\n * Called via `this.dispatchAction`.\n */\n\nTableModel.prototype._process_AddRecord = function(action, tableId, rowId, columnValues) {\n  this.trigger(\"rowChange\", \"add\", [rowId]);\n};\nTableModel.prototype._process_RemoveRecord = function(action, tableId, rowId) {\n  this.trigger(\"rowChange\", \"remove\", [rowId]);\n};\nTableModel.prototype._process_UpdateRecord = function(action, tableId, rowId, columnValues) {\n  this.trigger(\"rowChange\", \"update\", [rowId]);\n  this.trigger(\"rowNotify\", [rowId], action);\n};\n\nTableModel.prototype._process_ReplaceTableData = function() {\n  // No-op because TableData.js already translates ReplaceTableData to a 'dataLoaded' event.\n};\n\nTableModel.prototype._process_BulkAddRecord = function(action, tableId, rowIds, columns) {\n  this.trigger(\"rowChange\", \"add\", rowIds);\n};\nTableModel.prototype._process_BulkRemoveRecord = function(action, tableId, rowIds) {\n  this.trigger(\"rowChange\", \"remove\", rowIds);\n};\nTableModel.prototype._process_BulkUpdateRecord = function(action, tableId, rowIds, columns) {\n  this.trigger(\"rowChange\", \"update\", rowIds);\n  this.trigger(\"rowNotify\", rowIds, action);\n};\n\n// All schema changes to this table should be forwarded to each row.\n// TODO: we may need to worry about groupings (e.g. recreate the grouping function) once we do row\n// groupings of user data. Metadata isn't subject to schema changes, so that doesn't matter.\nTableModel.prototype.applySchemaAction = function(action) {\n  this.trigger(\"rowNotify\", rowset.ALL, action);\n};\n\nTableModel.prototype._process_AddColumn = function(action) { this.applySchemaAction(action); };\nTableModel.prototype._process_RemoveColumn = function(action) { this.applySchemaAction(action); };\nTableModel.prototype._process_RenameColumn = function(action) { this.applySchemaAction(action); };\nTableModel.prototype._process_ModifyColumn = function(action) { this.applySchemaAction(action); };\n\nTableModel.prototype._process_RenameTable = _.noop;\nTableModel.prototype._process_RemoveTable = _.noop;\n\nmodule.exports = TableModel;\n"
  },
  {
    "path": "app/client/models/TelemetryModel.ts",
    "content": "import { AppModel, getHomeUrl } from \"app/client/models/AppModel\";\nimport { TelemetryPrefs } from \"app/common/Install\";\nimport { InstallAPI, InstallAPIImpl, TelemetryPrefsWithSources } from \"app/common/InstallAPI\";\n\nimport { bundleChanges, Disposable, Observable } from \"grainjs\";\n\nexport interface TelemetryModel {\n  /** Telemetry preferences (e.g. the current telemetry level). */\n  readonly prefs: Observable<TelemetryPrefsWithSources | null>;\n  fetchTelemetryPrefs(): Promise<void>;\n  updateTelemetryPrefs(prefs: Partial<TelemetryPrefs>): Promise<void>;\n}\n\nexport class TelemetryModelImpl extends Disposable implements TelemetryModel {\n  public readonly prefs: Observable<TelemetryPrefsWithSources | null> = Observable.create(this, null);\n  private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());\n\n  constructor(_appModel: AppModel) {\n    super();\n  }\n\n  public async fetchTelemetryPrefs(): Promise<void> {\n    const prefs = await this._installAPI.getInstallPrefs();\n    bundleChanges(() => {\n      this.prefs.set(prefs.telemetry);\n    });\n  }\n\n  public async updateTelemetryPrefs(prefs: Partial<TelemetryPrefs>): Promise<void> {\n    await this._installAPI.updateInstallPrefs({ telemetry: prefs });\n    await this.fetchTelemetryPrefs();\n  }\n}\n"
  },
  {
    "path": "app/client/models/TimeQuery.ts",
    "content": "import { DocData } from \"app/client/models/DocData\";\nimport { getActionColValues, getRowIdsFromDocAction } from \"app/common/DocActions\";\nimport { ITimeData, ResultRow } from \"app/common/TimeQuery\";\n\n/**\n * A client-side implementation of ITimeData, so we can do a\n * TimeQuery to get context for actions in the action log.\n */\nexport class ClientTimeData implements ITimeData {\n  public constructor(public db: DocData) {\n  }\n\n  public async getColIds(tableId: string): Promise<string[]> {\n    const table = this.db.getTable(tableId);\n    return table?.getColIds() || [];\n  }\n\n  public async fetch(tableId: string, colIds: string[], rowIds?: number[]): Promise<ResultRow[]> {\n    await this.db.fetchTable(tableId);\n    const table = this.db.getTable(tableId);\n    const data = table?.getTableDataAction(rowIds, colIds);\n    if (!data) { return []; }\n    const records = getRowIdsFromDocAction(data).map((rowId, i) => {\n      const rec: Record<string, any> = { id: rowId };\n      for (const [colId, values] of Object.entries(getActionColValues(data))) {\n        if (colId !== \"id\") {\n          rec[colId] = values[i];\n        }\n      }\n      return rec;\n    });\n    return records;\n  }\n}\n"
  },
  {
    "path": "app/client/models/ToggleEnterpriseModel.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { getHomeUrl } from \"app/client/models/AppModel\";\nimport { Notifier } from \"app/client/models/NotifyModel\";\nimport { ActivationAPIImpl, ActivationStatus } from \"app/common/ActivationAPI\";\nimport { ConfigAPI } from \"app/common/ConfigAPI\";\nimport { delay } from \"app/common/delay\";\nimport { GristDeploymentType } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Disposable, Observable } from \"grainjs\";\n\nconst t = makeT(\"ToggleEnterprise\");\n\nexport class ToggleEnterpriseModel extends Disposable {\n  public readonly edition: Observable<GristDeploymentType | null> = Observable.create(this, null);\n  public readonly status: Observable<ActivationStatus | null> = Observable.create(this, null);\n  public readonly installationId: Observable<string | null> = Observable.create(this, null);\n  public readonly busy: Observable<boolean> = Observable.create(this, false);\n  private readonly _configAPI: ConfigAPI = new ConfigAPI(getHomeUrl());\n  private readonly _activationAPI: ActivationAPIImpl = new ActivationAPIImpl(getHomeUrl());\n\n  constructor(private _notifier: Notifier) {\n    super();\n  }\n\n  public async fetchEnterpriseToggle() {\n    const { deploymentType } = getGristConfig();\n    this.edition.set(deploymentType || null);\n    if (deploymentType === \"enterprise\") {\n      const status = await this._activationAPI.getActivationStatus();\n      if (this.isDisposed()) {\n        return;\n      }\n      this.status.set(status);\n      this.installationId.set(status.installationId);\n    }\n  }\n\n  public async updateEnterpriseToggle(edition: GristDeploymentType): Promise<void> {\n    // We may be restarting the server, so these requests may well\n    // fail if done in quick succession.\n    const task = async () => {\n      await retryOnNetworkError(() => this._configAPI.setValue({ edition }));\n      this.edition.set(edition);\n      await retryOnNetworkError(() => this._configAPI.restartServer());\n    };\n    await this._doWork(task);\n  }\n\n  public async activateEnterprise(key: string) {\n    const task = async () => {\n      await this._activationAPI.activateEnterprise(key);\n      await retryOnNetworkError(() => this._configAPI.restartServer());\n    };\n    await this._doWork(task);\n  }\n\n  private async _doWork(func: () => Promise<void>) {\n    if (this.busy.get()) {\n      throw new Error(t(\"Please wait for the previous operation to complete.\"));\n    }\n    this.busy.set(true);\n    try {\n      await this._notifier.slowNotification(func());\n      await this._reloadWhenReady();\n    } catch (err) {\n      this.busy.set(false);\n      throw err;\n    }\n  }\n\n  private async _reloadWhenReady() {\n    // Now wait about 30 seconds for the server to come back up, and\n    // refresh the page.\n    let maxTries = 30;\n    while (maxTries-- > 0) {\n      try {\n        await this._configAPI.healthcheck();\n        // We're done, last step is to reload the page.\n        this.busy.set(false);\n        window.location.reload();\n        return;\n      } catch (err) {\n        console.warn(\"Server not ready yet, will retry\", err);\n        await delay(1000);\n      }\n    }\n    throw new Error(t(\"Timed out on waiting for the Grist backend to restart\"));\n  }\n}\n\n// Copied from DocPageModel.ts\nconst reconnectIntervals = [1000, 1000, 2000, 5000, 10000];\nasync function retryOnNetworkError<R>(func: () => Promise<R>): Promise<R> {\n  for (let attempt = 0; ; attempt++) {\n    try {\n      return await func();\n    } catch (err) {\n      // fetch() promises that network errors are reported as TypeError. We'll accept NetworkError too.\n      if (err.name !== \"TypeError\" && err.name !== \"NetworkError\") {\n        throw err;\n      }\n      // We really can't reach the server. Make it known.\n      if (attempt >= reconnectIntervals.length) {\n        throw err;\n      }\n      const reconnectTimeout = reconnectIntervals[attempt];\n      console.warn(`Call to ${func.name} failed, will retry in ${reconnectTimeout} ms`, err);\n      await delay(reconnectTimeout);\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/models/TreeModel.ts",
    "content": "/**\n * This module exposes the various interface that describes the model to generate a tree view. It\n * provides also a way to create a TreeModel from a grist table that implements the tree view\n * interface (ie: a table with both an .indentation and .pagePos fields).\n *\n * To use with tableData;\n *  > fromTableData(tableData, (rec) => dom('div', rec.label))\n *\n * Optionally you can build a model by reusing items from an old model with matching records\n * ids. The is useful to benefit from dom reuse of the TreeViewComponent which allow to persist\n * state when the model updates.\n *\n */\n\nimport { BulkColValues, UserAction } from \"app/common/DocActions\";\nimport { nativeCompare } from \"app/common/gutil\";\n\nimport { obsArray, ObsArray, Observable } from \"grainjs\";\nimport forEach from \"lodash/forEach\";\nimport forEachRight from \"lodash/forEachRight\";\nimport reverse from \"lodash/reverse\";\n\n/**\n * A generic definition of a tree to use with the `TreeViewComponent`. The tree implements\n * `TreeModel` and any item in it implements `TreeItem`.\n */\nexport interface TreeNode {\n  hidden?: boolean;\n  collapsed?: Observable<boolean>;\n  // Returns an observable array of children. Or null if the node does not accept children.\n  children(): ObsArray<TreeItem> | null;\n\n  // Inserts newChild as a child, before nextChild, or at the end if nextChild is null. If\n  // newChild is already in the tree, it is the implementer's responsibility to remove it from the\n  // children() list of its old parent.\n  insertBefore(newChild: TreeItem, nextChild: TreeItem | null): void;\n\n  // Removes child from the list of children().\n  removeChild(child: TreeItem): void;\n}\n\nexport interface TreeItem extends TreeNode {\n  // Returns the DOM element to render for this tree node.\n  buildDom(): HTMLElement;\n}\n\nexport interface TreeModel extends TreeNode {\n  children(): ObsArray<TreeItem>;\n}\n\n// A tree record has an id and an indentation field.\nexport interface TreeRecord {\n  id: number;\n  indentation: number;\n  pagePos: number;\n  [key: string]: any;\n}\n\n// This is compatible with TableData from app/client/models/TableData.\nexport interface TreeTableData {\n  getRecords(): TreeRecord[];\n  sendTableActions(actions: UserAction[]): Promise<unknown>;\n}\n\n// describes a function that builds dom for a particular record\ntype DomBuilder = (id: number, item: TreeItemRecord) => HTMLElement;\n\n// Returns a list of the records from table that is suitable to build the tree model, ie: records\n// are sorted by .posKey, and .indentation starts at 0 for the first records and can only increase\n// one step at a time (but can decrease as much as you want).\nfunction getRecords(table: TreeTableData) {\n  const records = table.getRecords()\n    .sort((a, b) => nativeCompare(a.pagePos, b.pagePos));\n  return fixIndents(records);\n}\n\n// The fixIndents function returns a copy of records with the guarantee the .indentation starts at 0\n// and can only increase one step at a time (note that it is however permitted to decrease several\n// level at a time). This is useful to build a model for the tree view.\nexport function fixIndents(records: TreeRecord[]) {\n  let maxNextIndent = 0;\n  return records.map((rec, index) => {\n    const indentation = Math.min(maxNextIndent, rec.indentation);\n    maxNextIndent = indentation + 1;\n    return { ...rec, indentation };\n  }) as TreeRecord[];\n}\n\n// build a tree model from a grist table storing tree view data\nexport function fromTableData(table: TreeTableData, buildDom: DomBuilder, oldModel?: TreeModelRecord) {\n  const records = getRecords(table);\n  const storage = { table, records };\n\n  // an object to collect items at all level of indentations\n  const indentations = {} as { [ind: number]: TreeItemRecord[] };\n\n  // a object that map record ids to old items\n  const oldItems = {} as { [id: number]: TreeItemRecord };\n  if (oldModel) {\n    walkTree(oldModel, (item: TreeItemRecord) => oldItems[item.record.id] = item);\n  }\n\n  // Let's iterate from bottom to top so that when we visit an item we've already built all of its\n  // children. For each record reuses an old item if there is one with same record id.\n  forEachRight(records, (rec, index) => {\n    const siblings = indentations[rec.indentation] = indentations[rec.indentation] || [];\n    const children = indentations[rec.indentation + 1] || [];\n    delete indentations[rec.indentation + 1];\n    const item = oldItems[rec.id] || new TreeItemRecord();\n    item.hidden = rec.hidden;\n    item.collapsed = rec.collapsed;\n    item.init(storage, index, reverse(children));\n    item.buildDom = () => buildDom(rec.id, item);\n    siblings.push(item);\n  });\n  return new TreeModelRecord(storage, reverse(indentations[0] || []));\n}\n\n// a table data with all of its records as returned by getRecords(tableData)\ninterface Storage {\n  table: TreeTableData;\n  records: TreeRecord[];\n}\n\n// TreeNode implementation that uses a grist table.\nexport class TreeNodeRecord implements TreeNode {\n  public hidden: boolean = false;\n  public collapsed?: Observable<boolean>;\n  public storage: Storage;\n  public index: number | \"root\";\n  public children: () => ObsArray<TreeItemRecord>;\n  private _children: TreeItemRecord[];\n\n  constructor() {\n    // nothing here\n  }\n\n  public init(storage: Storage, index: number | \"root\", children: TreeItemRecord[]) {\n    this.storage = storage;\n    this.index = index;\n    this._children = children;\n    const obsChildren = obsArray(this._children);\n    this.children = () => obsChildren;\n  }\n\n  // Moves 'item' along with all its descendant to just before 'nextChild' by updating the\n  // .indentation and .position fields of all of their corresponding records in the table.\n  public async insertBefore(item: TreeItemRecord, nextChild: TreeItemRecord | null) {\n    // get records for newItem and its descendants\n    const records = item.getRecords();\n\n    if (records.length) {\n      // adjust indentation for the records\n      const indent = this.index === \"root\" ? 0 : this._records[this.index].indentation + 1;\n      const indentations = records.map((rec, i) => rec.indentation + indent - records[0].indentation);\n\n      // adjust positions\n      let upperPos: number | null;\n      if (nextChild) {\n        const index = nextChild.index;\n        upperPos = this._records[index].pagePos;\n      } else {\n        const lastIndex = this.findLastIndex();\n        if (lastIndex !== \"root\") {\n          upperPos = (this._records[lastIndex + 1] || { pagePos: null }).pagePos;\n        } else {\n          upperPos = null;\n        }\n      }\n\n      // do update\n      const update = records.map((rec, i) => ({ ...rec, indentation: indentations[i], pagePos: upperPos! }));\n      await this.sendActions({ update });\n    }\n  }\n\n  // Sends user actions to update [A, B, ...] and remove [C, D, ...] when called with\n  // `{update: [A, B ...], remove: [C, D, ...]}`.\n  public async sendActions(actions: { update?: TreeRecord[], remove?: TreeRecord[] }) {\n    const update = actions.update || [];\n    const remove = actions.remove || [];\n\n    const userActions = [];\n    if (update.length) {\n      const values = {} as BulkColValues;\n      // let's transpose [{key1: \"val1\", ...}, ...] to {key1: [\"val1\", ...], ...}\n      forEach(update[0], (val, key) => values[key] = update.map(rec => rec[key]));\n      const rowIds = values.id;\n      for (const key of [\"id\", \"hidden\", \"collapsed\"]) {\n        delete values[key];\n      }\n      userActions.push([\"BulkUpdateRecord\", rowIds, values]);\n    }\n\n    if (remove.length) {\n      userActions.push([\"BulkRemove\", remove.map(rec => rec.id)]);\n    }\n\n    if (userActions.length) {\n      await this.storage.table.sendTableActions(userActions);\n    }\n  }\n\n  // Removes child.\n  public async removeChild(child: TreeItemRecord) {\n    await this.sendActions({ remove: child.getRecords() });\n  }\n\n  // Get all the records included in this item.\n  public getRecords(): TreeRecord[] {\n    const records = [] as TreeRecord[];\n    if (this.index !== \"root\") { records.push(this._records[this.index]); }\n    walkTree(this, (item: TreeItemRecord) => records.push(this._records[item.index]));\n    return records;\n  }\n\n  public findLastIndex(): number | \"root\" {\n    return this._children.length ? this._children[this._children.length - 1].findLastIndex() : this.index;\n  }\n\n  private get _records() {\n    return this.storage.records;\n  }\n}\n\nexport class TreeItemRecord extends TreeNodeRecord implements TreeItem {\n  public index: number;\n  public buildDom: () => HTMLElement;\n  constructor() {\n    super();\n  }\n\n  public get record() { return this.storage.records[this.index]; }\n}\n\nexport class TreeModelRecord extends TreeNodeRecord implements TreeModel {\n  constructor(storage: Storage, children: TreeItemRecord[]) {\n    super();\n    this.init(storage, \"root\", children);\n  }\n}\n\nexport function walkTree<T extends TreeItem>(model: TreeNode, func: (item: T) => void): void;\nexport function walkTree(model: TreeNode, func: (item: TreeItem) => void) {\n  const children = model.children();\n  if (children) {\n    for (const child of children.get()) {\n      func(child);\n      walkTree(child, func);\n    }\n  }\n}\n\nexport function find<T extends TreeItem>(model: TreeNode, func: (item: T) => boolean): T | undefined;\nexport function find(model: TreeNode, func: (item: TreeItem) => boolean): TreeItem | undefined {\n  const children = model.children();\n  if (children) {\n    for (const child of children.get()) {\n      const found = func(child) && child || find(child, func);\n      if (found) { return found; }\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/models/UnionRowSource.ts",
    "content": "import { RowList, RowListener, RowSource } from \"app/client/models/rowset\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\n\nexport class UnionRowSource extends RowListener implements RowSource {\n  protected _allRows = new Map<UIRowId, Set<RowSource>>();\n\n  constructor(parentRowSources: RowSource[]) {\n    super();\n    for (const p of parentRowSources) {\n      this.subscribeTo(p);\n    }\n  }\n\n  public getAllRows(): RowList {\n    return this._allRows.keys();\n  }\n\n  public getNumRows(): number {\n    return this._allRows.size;\n  }\n\n  public onAddRows(rows: RowList, rowSource: RowSource) {\n    const outputRows = [];\n    for (const r of rows) {\n      let sources = this._allRows.get(r);\n      if (!sources) {\n        sources = new Set();\n        this._allRows.set(r, sources);\n        outputRows.push(r);\n      }\n      sources.add(rowSource);\n    }\n    if (outputRows.length > 0) {\n      this.trigger(\"rowChange\", \"add\", outputRows);\n    }\n  }\n\n  public onRemoveRows(rows: RowList, rowSource: RowSource) {\n    const outputRows = [];\n    for (const r of rows) {\n      const sources = this._allRows.get(r);\n      if (!sources) {\n        continue;\n      }\n      sources.delete(rowSource);\n      if (sources.size === 0) {\n        outputRows.push(r);\n        this._allRows.delete(r);\n      }\n    }\n    if (outputRows.length > 0) {\n      this.trigger(\"rowChange\", \"remove\", outputRows);\n    }\n  }\n\n  public onUpdateRows(rows: RowList) {\n    this.trigger(\"rowChange\", \"update\", rows);\n  }\n}\n"
  },
  {
    "path": "app/client/models/UserManagerModel.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { normalizeEmail } from \"app/common/emails\";\nimport { GristLoadConfig } from \"app/common/gristUrls\";\nimport * as roles from \"app/common/roles\";\nimport { ShareAnnotations, ShareAnnotator } from \"app/common/ShareAnnotator\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { ANONYMOUS_USER_EMAIL, Document, EVERYONE_EMAIL, FullUser, getRealAccess, Organization,\n  PermissionData, PermissionDelta, UserAPI, Workspace } from \"app/common/UserAPI\";\n\nimport { computed, Computed, Disposable, obsArray, ObsArray, observable, Observable } from \"grainjs\";\nimport some from \"lodash/some\";\n\nconst t = makeT(\"UserManagerModel\");\n\nexport interface UserManagerModel {\n  initData: PermissionData;                    // PermissionData used to initialize the UserManager\n  resource: Resource | null;                     // The access resource.\n  resourceType: ResourceType;                  // String representing the access resource type.\n  userSelectOptions: IMemberSelectOption[];    // Select options for each user's role dropdown\n  orgUserSelectOptions: IOrgMemberSelectOption[];  // Select options for each user's role dropdown on the org\n  inheritSelectOptions: IMemberSelectOption[]; // Select options for the maxInheritedRole dropdown\n  publicUserSelectOptions: IMemberSelectOption[]; // Select options for the public member's role dropdown\n  maxInheritedRole: Observable<roles.BasicRole | null>;  // Current unsaved maxInheritedRole setting\n  membersEdited: ObsArray<IEditableMember>;    // Current unsaved editable array of members\n  publicMember: IEditableMember | null;          // Member whose access (VIEWER or null) represents that of\n  // anon@ or everyone@ (depending on the settings and resource).\n  isAnythingChanged: Computed<boolean>;        // Indicates whether there are unsaved changes\n  isSelfRemoved: Computed<boolean>;            // Indicates whether current user is removed\n  isOrg: boolean;                              // Indicates if the UserManager is for an org\n  annotations: Observable<ShareAnnotations>;   // More information about shares, keyed by email.\n  isPersonal: boolean;                         // If user access info/control is restricted to self.\n  hasOverview: boolean;                         // If user access contains readonly info about other users.\n  isPublicMember: boolean;                     // Indicates if current user is a public member.\n\n  activeUser: FullUser | null;                   // Populated if current user is logged in.\n  gristDoc: GristDoc | null;                     // Populated if there is an open document.\n\n  // Analyze the relation that users have to the resource or site.\n  annotate(): void;\n  // Resets all unsaved changes\n  reset(): void;\n  // Recreate annotations, factoring in any changes on the back-end.\n  reloadAnnotations(): Promise<void>;\n  // Writes all unsaved changes to the server.\n  save(userApi: UserAPI, resourceId: number | string): Promise<void>;\n  // Adds a member to membersEdited\n  add(email: string, role: roles.Role | null): void;\n  // Removes a member from membersEdited\n  remove(member: IEditableMember): void;\n  // Returns a boolean indicating if the member is the currently active user.\n  isActiveUser(member: IEditableMember): boolean;\n  // Returns the PermissionDelta reflecting the current unsaved changes in the model.\n  getDelta(): PermissionDelta;\n  // Returns whether we are enabling public sharing.\n  goingToSharePublicly(): boolean;\n}\n\nexport type ResourceType = \"organization\" | \"workspace\" | \"document\";\n\nexport type Resource = Organization | Workspace | Document;\n\nexport interface IEditableMember {\n  id: number;    // Newly invited members do not have ids and are represented by -1\n  name: string;\n  email: string;\n  picture?: string | null;\n  locale?: string | null;\n  access: Observable<roles.Role | null>;\n  parentAccess: roles.BasicRole | null;\n  inheritedAccess: Computed<roles.BasicRole | null>;\n  effectiveAccess: Computed<roles.Role | null>;\n  origAccess: roles.Role | null;\n  isNew: boolean;\n  isRemoved: boolean;\n  isTeamMember?: boolean;\n}\n\n// An option for the select elements used in the UserManager.\nexport interface IMemberSelectOption {\n  value: roles.BasicRole | null;\n  label: string;\n}\n\n// An option for the organization select elements used in the UserManager.\nexport interface IOrgMemberSelectOption {\n  value: roles.NonGuestRole | null;\n  label: string;\n}\n\ninterface IBuildMemberOptions {\n  id: number;\n  name: string;\n  email: string;\n  picture?: string | null;\n  access: roles.Role | null;\n  parentAccess: roles.BasicRole | null;\n  isTeamMember?: boolean;\n}\n\n/**\n *\n */\nexport class UserManagerModelImpl extends Disposable implements UserManagerModel {\n  // Select options for each individual user's role dropdown.\n  public readonly userSelectOptions: IMemberSelectOption[] = [\n    { value: roles.OWNER,  label: t(\"Owner\")  },\n    { value: roles.EDITOR, label: t(\"Editor\") },\n    { value: roles.VIEWER, label: t(\"Viewer\") },\n  ];\n\n  // Select options for each individual user's role dropdown in the org.\n  public readonly orgUserSelectOptions: IOrgMemberSelectOption[] = [\n    { value: roles.OWNER,  label: t(\"Owner\")  },\n    { value: roles.EDITOR, label: t(\"Editor\") },\n    { value: roles.VIEWER, label: t(\"Viewer\") },\n    { value: roles.MEMBER, label: t(\"No Default Access\") },\n  ];\n\n  // Select options for the resource's maxInheritedRole dropdown.\n  public readonly inheritSelectOptions: IMemberSelectOption[] = [\n    { value: roles.OWNER,  label: t(\"In full\")     },\n    { value: roles.EDITOR, label: t(\"View & edit\") },\n    { value: roles.VIEWER, label: t(\"View only\")   },\n    { value: null,         label: t(\"None\")        },\n  ];\n\n  // Select options for the public member's role dropdown.\n  public readonly publicUserSelectOptions: IMemberSelectOption[] = [\n    { value: roles.EDITOR, label: t(\"Editor\") },\n    { value: roles.VIEWER, label: t(\"Viewer\") },\n  ];\n\n  public activeUser: FullUser | null = this._options.activeUser ?? null;\n\n  public resource: Resource | null = this._options.resource ?? null;\n\n  public maxInheritedRole: Observable<roles.BasicRole | null> =\n    observable(this.initData.maxInheritedRole || null);\n\n  // The public member's access settings reflect either those of anonymous users (when\n  // shouldSupportAnon() is true) or those of everyone@ (i.e. access granted to all users,\n  // supported for docs only). The member is null when public access is not supported.\n  public publicMember: IEditableMember | null = this._buildPublicMember();\n\n  public membersEdited = this.autoDispose(obsArray<IEditableMember>(this._buildAllMembers()));\n\n  public annotations = this.autoDispose(observable({ users: new Map() }));\n\n  public isPersonal = Boolean(this.initData.personal);\n\n  // If current user is owner of some parent resource, the access data will contain not only the user\n  // but also other members (and guest) of that resource. In that case we still show the personal dialog, but\n  // with bottom section that will list those users.\n  public hasOverview = Boolean(this.initData.personal && this.initData.users.length > 1);\n\n  public isPublicMember = this.initData.public ?? false;\n\n  public isOrg: boolean = this.resourceType === \"organization\";\n\n  public gristDoc: GristDoc | null = this._options.docPageModel?.gristDoc.get() ?? null;\n\n  // Checks if any members were added/removed/changed, if the max inherited role changed or if the\n  // anonymous access setting changed to enable the confirm button to write changes to the server.\n  public readonly isAnythingChanged: Computed<boolean> = this.autoDispose(computed<boolean>((use) => {\n    const isMemberChangedFn = (m: IEditableMember) => m.isNew || m.isRemoved ||\n      use(m.access) !== m.origAccess;\n    const isInheritanceChanged = !this.isOrg && use(this.maxInheritedRole) !== this.initData.maxInheritedRole;\n    return some(use(this.membersEdited), isMemberChangedFn) || isInheritanceChanged ||\n      (this.publicMember ? isMemberChangedFn(this.publicMember) : false);\n  }));\n\n  // Check if the current user is being removed.\n  public readonly isSelfRemoved: Computed<boolean> = this.autoDispose(computed<boolean>((use) => {\n    return some(use(this.membersEdited), m => m.isRemoved && m.email === this.activeUser?.email);\n  }));\n\n  private _shareAnnotator?: ShareAnnotator;\n\n  constructor(\n    public initData: PermissionData,\n    public resourceType: ResourceType,\n    private _options: {\n      activeUser?: FullUser | null,\n      reload?: () => Promise<PermissionData>,\n      docPageModel?: DocPageModel,\n      appModel?: AppModel,\n      resource?: Resource,\n    },\n  ) {\n    super();\n    if (this._options.appModel) {\n      const features = this._options.appModel.currentFeatures;\n      const { supportEmail } = getGristConfig();\n      this._shareAnnotator = new ShareAnnotator(features, initData, { supportEmail });\n    }\n    this.annotate();\n  }\n\n  public reset(): void {\n    this.membersEdited.set(this._buildAllMembers());\n    this.annotate();\n  }\n\n  public async reloadAnnotations(): Promise<void> {\n    if (!this._options.reload || !this._shareAnnotator) { return; }\n    const data = await this._options.reload();\n    // Update the permission data backing the annotations. We don't update the full model\n    // itself - that would be nice, but tricky since the user may have made changes to it.\n    // But at least we can easily update annotations. This is good for the potentially\n    // common flow of opening a doc, starting to add a user, following the suggestion of\n    // adding that user as a member of the site, then returning to finish off adding\n    // them to the doc.\n    this._shareAnnotator.updateState(data);\n    this.annotate();\n  }\n\n  public async save(userApi: UserAPI, resourceId: number | string): Promise<void> {\n    if (this.resourceType === \"organization\") {\n      await userApi.updateOrgPermissions(resourceId as number, this.getDelta());\n    } else if (this.resourceType === \"workspace\") {\n      await userApi.updateWorkspacePermissions(resourceId as number, this.getDelta());\n    } else if (this.resourceType === \"document\") {\n      await userApi.updateDocPermissions(resourceId as string, this.getDelta());\n    }\n  }\n\n  public add(email: string, role: roles.Role | null): void {\n    email = normalizeEmail(email);\n    const members = this.membersEdited.get();\n    const index = members.findIndex(m => normalizeEmail(m.email) === email);\n    const existing = index > -1 ? members[index] : null;\n    if (existing?.isRemoved) {\n      // The member is replaced with the isRemoved set to false to trigger an\n      // update to the membersEdited observable array.\n      this.membersEdited.splice(index, 1, { ...existing, isRemoved: false });\n    } else if (existing) {\n      const effective = existing.effectiveAccess.get();\n      if (effective && effective !== roles.GUEST) {\n        // If the member is visible, throw to inform the user.\n        throw new Error(\"This user is already in the list\");\n      }\n      // If the member exists but is not visible, update their access to make them visible.\n      // They should be treated as a new user - removing them should make them invisible again.\n      existing.access.set(role);\n      existing.isNew = true;\n    } else {\n      const newMember = this._buildEditableMember({\n        id: -1, // Use a placeholder for the unknown userId\n        email,\n        name: \"\",\n        access: role,\n        parentAccess: null,\n      });\n      newMember.isNew = true;\n      this.membersEdited.push(newMember);\n    }\n    this.annotate();\n  }\n\n  public remove(member: IEditableMember): void {\n    const index = this.membersEdited.get().indexOf(member);\n    if (member.isNew) {\n      this.membersEdited.splice(index, 1);\n    } else {\n      // Keep it in the array with a flag, to simplify comparing \"before\" and \"after\" arrays.\n      this.membersEdited.splice(index, 1, { ...member, isRemoved: true });\n    }\n    this.annotate();\n  }\n\n  public isActiveUser(member: IEditableMember): boolean {\n    return member.email === this.activeUser?.email;\n  }\n\n  public annotate() {\n    // Only attempt for documents for now.\n    // TODO: extend to workspaces.\n    if (!this._shareAnnotator) { return; }\n    this.annotations.set(this._shareAnnotator.annotateChanges(this.getDelta({ silent: true })));\n  }\n\n  // Construct the permission delta from the changed users/maxInheritedRole.\n  // Give warnings or errors as appropriate (these are suppressed if silent is set).\n  public getDelta(options?: { silent: boolean }): PermissionDelta {\n    const delta: PermissionDelta = { users: {} };\n    if (this.resourceType !== \"organization\") {\n      const maxInheritedRole = this.maxInheritedRole.get();\n      if (this.initData.maxInheritedRole !== maxInheritedRole) {\n        delta.maxInheritedRole = maxInheritedRole;\n      }\n    }\n    const members = [...this.membersEdited.get()];\n    if (this.publicMember) {\n      members.push(this.publicMember);\n    }\n    // Loop through the members and update the delta.\n    for (const m of members) {\n      const access = m.access.get();\n      if (!roles.isValidRole(access)) {\n        if (!options?.silent) {\n          throw new Error(`Cannot update user to invalid role ${access}`);\n        }\n        continue;\n      }\n      if (m.isNew || m.isRemoved || m.origAccess !== access) {\n        // Add users whose access changed.\n        delta.users![m.email] = m.isRemoved ? null : access as roles.NonGuestRole;\n      }\n    }\n    return delta;\n  }\n\n  public goingToSharePublicly(): boolean {\n    // We want to detect when the original access for the public member did not exist\n    // and has just been defined.\n    return Boolean(this.publicMember?.access.get() && !this.publicMember?.origAccess);\n  }\n\n  private _buildAllMembers(): IEditableMember[] {\n    // If the UI supports some public access, strip the supported public user (anon@ or\n    // everyone@). Otherwise, keep it, to allow the non-fancy way of adding/removing public access.\n    let users = this.initData.users;\n    const publicMember = this.publicMember;\n    if (publicMember) {\n      users = users.filter(m => m.email !== publicMember.email);\n    }\n    return users.map(m =>\n      this._buildEditableMember({\n        id: m.id,\n        email: m.email,\n        name: m.name,\n        picture: m.picture,\n        access: m.access,\n        parentAccess: m.parentAccess || null,\n        isTeamMember: m.isMember,\n      }),\n    );\n  }\n\n  private _buildPublicMember(): IEditableMember | null {\n    // shouldSupportAnon() changes \"public\" access to \"anonymous\" access, and enables it for\n    // workspaces and org level. It's currently used for on-premise installs only.\n    // TODO Think through proper public sharing or workspaces/orgs, and get rid of\n    // shouldSupportAnon() exceptions.\n    const email =\n      shouldSupportAnon() ? ANONYMOUS_USER_EMAIL :\n        (this.resourceType === \"document\") ? EVERYONE_EMAIL : null;\n    if (!email) { return null; }\n    const user = this.initData.users.find(m => m.email === email);\n    return this._buildEditableMember({\n      id: user ? user.id : -1,\n      email,\n      name: \"\",\n      access: user ? user.access : null,\n      parentAccess: user ? (user.parentAccess || null) : null,\n    });\n  }\n\n  private _buildEditableMember(member: IBuildMemberOptions): IEditableMember {\n    // Represents the member's access set specifically on the resource of interest.\n    const access = Observable.create(this, member.access);\n    let inheritedAccess: Computed<roles.BasicRole | null>;\n\n    if (member.email === this.activeUser?.email) {\n      // Note that we currently prevent the active user's role from changing to prevent users from\n      // locking themselves out of resources. We ensure that by setting inheritedAccess to the\n      // active user's initial access level, which is OWNER normally. (It's sometimes possible to\n      // open UserManager by a less-privileged user, e.g. if access was just lowered, in which\n      // case any attempted changes will fail on saving.)\n      const initialAccessBasicRole = roles.getEffectiveRole(getRealAccess(member, this.initData));\n      // This pretends to be a computed to match the other case, but is really a constant.\n      inheritedAccess = Computed.create(this, use => initialAccessBasicRole);\n    } else {\n      // Gives the role inherited from parent taking the maxInheritedRole into account.\n      inheritedAccess = Computed.create(this, this.maxInheritedRole, (use, maxInherited) =>\n        roles.getWeakestRole(member.parentAccess, maxInherited));\n    }\n    // Gives the effective role of the member on the resource, taking everything into account.\n    const effectiveAccess = Computed.create(this, use =>\n      roles.getStrongestRole(use(access), use(inheritedAccess)));\n    effectiveAccess.onWrite((value) => {\n      // For UI simplicity, we use a single dropdown to represent the effective access level of\n      // the user AND to allow changing it. As a result, we do NOT allow using the dropdown to\n      // write/show values that provide less direct access than what the user already inherits.\n      // It is confusing to show and results in no change in the effective access.\n      const inherited = inheritedAccess.get();\n      const isAboveInherit = roles.getStrongestRole(value, inherited) !== inherited;\n      access.set(isAboveInherit ? value : null);\n    });\n    return {\n      id: member.id,\n      email: member.email,\n      name: member.name,\n      picture: member.picture,\n      access,\n      parentAccess: member.parentAccess || null,\n      inheritedAccess,\n      effectiveAccess,\n      origAccess: member.access,\n      isNew: false,\n      isRemoved: false,\n      isTeamMember: member.isTeamMember,\n    };\n  }\n}\n\nexport function getResourceParent(resource: ResourceType): ResourceType | null {\n  if (resource === \"workspace\") {\n    return \"organization\";\n  } else if (resource === \"document\") {\n    return \"workspace\";\n  } else {\n    return null;\n  }\n}\n\n// Check whether anon should be supported in the UI\nexport function shouldSupportAnon(): boolean {\n  const gristConfig: Partial<GristLoadConfig> = window.gristConfig || {};\n  return gristConfig.supportAnon || false;\n}\n"
  },
  {
    "path": "app/client/models/UserPrefs.ts",
    "content": "import { localStorageObs } from \"app/client/lib/localStorageObs\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { UserOrgPrefs, UserPrefs } from \"app/common/Prefs\";\n\nimport { Computed, Observable } from \"grainjs\";\nimport { CheckerT } from \"ts-interface-checker\";\n\ninterface PrefsTypes {\n  userOrgPrefs: UserOrgPrefs;\n  userPrefs: UserPrefs;\n}\n\nfunction makePrefFunctions<P extends keyof PrefsTypes>(prefsTypeName: P) {\n  type PrefsType = PrefsTypes[P];\n\n  /**\n   * Creates an observable that returns a PrefsType, and which stores changes when set.\n   *\n   * For anon user, the prefs live in localStorage. Note that the observable isn't actually watching\n   * for changes on the server, it will only change when set.\n   */\n  function getPrefsObs(appModel: AppModel): Observable<PrefsType> {\n    if (appModel.currentValidUser && appModel.currentOrg) {\n      let prefs: PrefsType | undefined;\n      let saveBack: (newPrefs: PrefsType) => void = () => {};\n      if (prefsTypeName === \"userPrefs\") {\n        prefs = appModel.currentValidUser.prefs;\n        saveBack = newPrefs => appModel.currentValidUser && (appModel.currentValidUser.prefs = newPrefs);\n      } else {\n        prefs = appModel.currentOrg?.[prefsTypeName];\n        saveBack = newPrefs => appModel.currentOrg && (appModel.currentOrg[prefsTypeName] = newPrefs);\n      }\n      const prefsObs = Observable.create<PrefsType>(null, prefs ?? {});\n      return Computed.create(null, use => use(prefsObs))\n        .onWrite((newPrefs) => {\n          const previousPrefs = prefsObs.get();\n          prefsObs.set(newPrefs);\n          saveBack(newPrefs);\n          appModel.api.updateOrg(\"current\", { [prefsTypeName]: newPrefs })\n            .catch((err) => {\n              prefsObs.set(previousPrefs);\n              saveBack(previousPrefs);\n              throw err;\n            });\n        });\n    } else {\n      const userId = appModel.currentUser?.id || 0;\n      const jsonPrefsObs = localStorageObs(`${prefsTypeName}:u=${userId}`);\n      return Computed.create(null, jsonPrefsObs, (use, p) => (p && JSON.parse(p) || {}) as PrefsType)\n        .onWrite((newPrefs) => {\n          jsonPrefsObs.set(JSON.stringify(newPrefs));\n        });\n    }\n  }\n\n  /**\n   * Creates an observable that returns a particular preference value from `prefsObs`, and which\n   * stores it when set.\n   */\n  function getPrefObs<Name extends keyof PrefsType>(\n    prefsObs: Observable<PrefsType>,\n    prefName: Name,\n    options: {\n      defaultValue?: Exclude<PrefsType[Name], undefined>;\n      checker?: CheckerT<PrefsType[Name]>;\n    } = {},\n  ): Observable<PrefsType[Name] | undefined> {\n    const { defaultValue, checker } = options;\n    return Computed.create(null, (use) => {\n      const prefs = use(prefsObs);\n      if (!(prefName in prefs)) { return defaultValue; }\n\n      const value = prefs[prefName];\n      if (checker) {\n        try {\n          checker.check(value);\n        } catch (e) {\n          console.error(`getPrefObs: preference ${prefName.toString()} has value of invalid type`, e);\n          return defaultValue;\n        }\n      }\n\n      return value;\n    }).onWrite(value => prefsObs.set({ ...prefsObs.get(), [prefName]: value }));\n  }\n\n  return { getPrefsObs, getPrefObs };\n}\n\n// Functions actually exported are:\n// - getUserOrgPrefsObs(appModel): Observable<UserOrgPrefs>\n// - getUserOrgPrefObs(userOrgPrefsObs, prefName): Observable<PrefType[prefName]>\n// - getUserPrefsObs(appModel): Observable<UserPrefs>\n// - getUserPrefObs(userPrefsObs, prefName): Observable<PrefType[prefName]>\n\nexport const { getPrefsObs: getUserOrgPrefsObs, getPrefObs: getUserOrgPrefObs } = makePrefFunctions(\"userOrgPrefs\");\nexport const { getPrefsObs: getUserPrefsObs, getPrefObs: getUserPrefObs } = makePrefFunctions(\"userPrefs\");\n\n// For preferences that store a list of items (such as seen docTours), this helper updates the\n// preference to add itemId to it (e.g. to avoid auto-starting the docTour again in the future).\n// prefKey is used only to log a more informative warning on error.\nexport function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T, isSeen = true) {\n  const seenIds = seenIdsObs.get() || [];\n  try {\n    if (!seenIds.includes(itemId)) {\n      const seen = new Set(seenIds);\n      if (isSeen) {\n        seen.add(itemId);\n      } else {\n        seen.delete(itemId);\n      }\n      seenIdsObs.set([...seen].sort());\n    }\n  } catch (e) {\n    // If we fail to save this preference, it's probably not worth alerting the user about,\n    // so just log to console.\n    console.warn(\"Failed to save preference in markAsSeen\", e);\n  }\n}\n"
  },
  {
    "path": "app/client/models/UserPresenceModel.ts",
    "content": "import { Comm } from \"app/client/components/Comm\";\nimport { DocComm } from \"app/client/components/DocComm\";\nimport { VisibleUserProfile } from \"app/common/ActiveDocAPI\";\nimport { CommDocUserPresenceUpdate } from \"app/common/CommTypes\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\n\nimport { Disposable, Observable } from \"grainjs\";\n\nexport interface UserPresenceModel extends Disposable {\n  userProfiles: Observable<VisibleUserProfile[]>;\n\n  initialize(): Promise<void>;\n}\n\nexport class UserPresenceModelImpl extends DisposableWithEvents implements UserPresenceModel {\n  public userProfiles: Observable<VisibleUserProfile[]>;\n\n  constructor(private _docComm: DocComm, private _comm: Comm) {\n    super();\n    this.userProfiles = Observable.create<VisibleUserProfile[]>(this, []);\n    this.listenTo(this._comm, \"docUserPresenceUpdate\", this._onUserPresenceUpdateMessage);\n  }\n\n  public async initialize(): Promise<void> {\n    let userProfiles: VisibleUserProfile[] = [];\n    try {\n      userProfiles = await this._docComm.listActiveUserProfiles();\n    } catch (e) {\n      reportError(e);\n      reportError(`Unable to fetch current active user list`);\n    }\n\n    if (this.isDisposed()) {\n      return;\n    }\n    this.userProfiles.set(userProfiles);\n  }\n\n  private _onUserPresenceUpdateMessage(message: CommDocUserPresenceUpdate) {\n    const { data } = message;\n    const newProfiles = this.userProfiles.get().slice();\n    const index = newProfiles.findIndex(profileToCheck => profileToCheck.id === data.id);\n    if (!data.profile) {\n      newProfiles.splice(index, 1);\n    } else if (index < 0) {\n      newProfiles.push(data.profile);\n    } else {\n      newProfiles[index] = data.profile;\n    }\n    this.userProfiles.set(newProfiles);\n  }\n}\n\nexport class UserPresenceModelStub extends Disposable implements UserPresenceModel {\n  public userProfiles: Observable<VisibleUserProfile[]>;\n\n  constructor() {\n    super();\n    this.userProfiles = Observable.create<VisibleUserProfile[]>(this, []);\n  }\n\n  public async initialize(): Promise<void> {}\n}\n"
  },
  {
    "path": "app/client/models/ViewFieldConfig.ts",
    "content": "import * as modelUtil from \"app/client/models/modelUtil\";\n// This is circular import, but only for types so it's fine.\nimport * as UserType from \"app/client/widgets/UserType\";\nimport { ifNotSet } from \"app/common/gutil\";\n\nimport * as ko from \"knockout\";\nimport intersection from \"lodash/intersection\";\nimport isEqual from \"lodash/isEqual\";\nimport zip from \"lodash/zip\";\n\nimport type { DocModel, ViewFieldRec } from \"app/client/models/DocModel\";\n\nexport class ViewFieldConfig {\n  /** If there are multiple columns selected in the viewSection */\n  public multiselect: ko.Computed<boolean>;\n  /** If all selected columns have the same widget list. */\n  public sameWidgets: ko.Computed<boolean>;\n  /** Widget options for a field or multiple fields. Doesn't contain style options */\n  public options: CommonOptions;\n  /** Style options for a field or multiple fields  */\n  public style: ko.Computed<StyleOptions>;\n  /** Header style options for a field or multiple fields  */\n  public headerStyle: ko.Computed<StyleOptions>;\n\n  // Rest of the options mimic the same options from ViewFieldRec.\n  public wrap: modelUtil.KoSaveableObservable<boolean | undefined>;\n  public widget: ko.Computed<string | undefined>;\n  public alignment: modelUtil.KoSaveableObservable<string | undefined>;\n  public fields: ko.PureComputed<ViewFieldRec[]>;\n  constructor(private _field: ViewFieldRec, private _docModel: DocModel) {\n    // Everything here will belong to a _field, this class is just a builder.\n    const owner = _field;\n\n    // Get all selected fields from the viewSection, if there is only one field\n    // selected (or the selection is empty) return it in an array.\n    this.fields = owner.autoDispose(ko.pureComputed(() => {\n      const list = this._field.viewSection().selectedFields();\n      if (!list?.length) {\n        return [_field];\n      }\n      // Make extra sure that field and column is not disposed, most of the knockout\n      // based entities, don't dispose their computed observables. As we keep references\n      // for them, it can happen that some of them are disposed while we are still\n      // computing something (mainly when columns are removed or restored using undo).\n      return list.filter(f => !f.isDisposed() && !f.column().isDisposed());\n    }));\n\n    // Helper that lists all not disposed widgets. Many methods below gets all fields\n    // list which still can contain disposed fields, this helper will filter them out.\n    const listFields = () => this.fields().filter(f => !f.isDisposed());\n\n    // Just a helper field to see if we have multiple selected columns or not.\n    this.multiselect = owner.autoDispose(ko.pureComputed(() => this.fields().length > 1));\n\n    // Calculate if all columns share the same allowed widget list (like for Numeric type\n    // we have normal TextBox and Spinner). This will be used to allow the user to change\n    // this type if such columns are selected.\n    this.sameWidgets = owner.autoDispose(ko.pureComputed(() => {\n      const list = listFields();\n      // If we have only one field selected, list is always the same.\n      if (list.length <= 1) { return true; }\n      // Now get all widget list and calculate intersection of the Sets.\n      // Widget types are just strings defined in UserType.\n      const widgets = list.map(c =>\n        Object.keys(UserType.typeDefs[c.column().pureType()]?.widgets ?? {}),\n      );\n      return intersection(...widgets).length === widgets[0]?.length;\n    }));\n\n    // Changing widget type is not trivial, as we need to carefully reset all\n    // widget options to their default values, and there is a nuance there.\n    this.widget = owner.autoDispose(ko.pureComputed({\n      read: () => {\n        // For single column, just return its widget type.\n        if (!this.multiselect()) {\n          return this._field.widget();\n        }\n        // If all have the same value, return it, otherwise\n        // return a default value for this option \"undefined\"\n        const values = listFields().map(f => f.widget());\n        if (allSame(values)) {\n          return values[0];\n        } else {\n          return undefined;\n        }\n      },\n      write: (widget) => {\n        // Go through all the fields, and reset them all.\n        for (const field of listFields()) {\n          // Reset the entire JSON, so that all options revert to their defaults.\n          const previous = field.widgetOptionsJson.peek();\n          // We don't need to bundle anything (actions send in the same tick, are bundled\n          // by default).\n          field.widgetOptionsJson.setAndSave({\n            widget,\n            // Persists color settings across widgets (note: we cannot use `field.fillColor` to get the\n            // current value because it returns a default value for `undefined`. Same for `field.textColor`.\n            fillColor: previous.fillColor,\n            textColor: previous.textColor,\n          }).catch(reportError);\n        }\n      },\n    }));\n\n    // Calculate common options for all column types (and their widgets).\n    // We will use this, to know which options are allowed to be changed\n    // when multiple columns are selected.\n    const commonOptions = owner.autoDispose(ko.pureComputed(() => {\n      const fields = listFields();\n      // Put all options of first widget in the Set, and then remove\n      // them one by one, if they are not present in other fields.\n      let options: Set<string> | null = null;\n      for (const field of fields) {\n        // First get the data, and prepare initial set.\n        const widget = field.widget() || \"\";\n        const widgetOptions = UserType.typeDefs[field.column().pureType()]?.widgets[widget]?.options;\n        if (!widgetOptions) { continue; }\n        if (!options) { options = new Set(Object.keys(widgetOptions)); } else {\n          // And now remove options that are not common.\n          const newOptions = new Set(Object.keys(widgetOptions));\n          for (const key of options) {\n            if (!newOptions.has(key)) {\n              options.delete(key);\n            }\n          }\n        }\n      }\n      return options ?? new Set();\n    }));\n\n    // Prepare our \"multi\" widgetOptionsJson, that can read and save\n    // options for multiple columns.\n    const options = modelUtil.savingComputed({\n      read: () => {\n        // For one column, just proxy this to the field.\n        if (!this.multiselect()) {\n          return this._field.widgetOptionsJson();\n        }\n        // Assemble final json object.\n        const result: any = {};\n        // First get all widgetOption jsons from all columns/fields.\n        const optionList = listFields().map(f => f.widgetOptionsJson());\n        // And fill only those that are common\n        const common = commonOptions();\n        for (const key of common) {\n          // Setting null means that this options is there, but has no value.\n          result[key] = null;\n          // If all columns have the same value, use it.\n          if (allSame(optionList.map(v => v[key]))) {\n            result[key] = optionList[0][key] ?? null;\n          }\n        }\n        return result;\n      },\n      write: (setter, value) => {\n        if (!this.multiselect.peek()) {\n          return setter(this._field.widgetOptionsJson, value);\n        }\n        // When the creator panel is saving widgetOptions, it will pass\n        // our virtual widgetObject, which has nulls for mixed values.\n        // If this option wasn't changed (set), we don't want to save it.\n        value = { ...value };\n        for (const key of Object.keys(value)) {\n          if (value[key] === null) {\n            delete value[key];\n          }\n        }\n        // Now update all options, for all fields, by amending the options\n        // object from the field/column.\n        for (const item of listFields()) {\n          const previous = item.widgetOptionsJson.peek();\n          setter(item.widgetOptionsJson, {\n            ...previous,\n            ...value,\n          });\n        }\n      },\n    });\n\n    // We need some additional information about each property.\n    this.options = owner.autoDispose(extendObservable(modelUtil.objObservable(options), {\n      // Property is not supported by set of columns if it is not a common option.\n      disabled: prop => ko.pureComputed(() => !commonOptions().has(prop)),\n      // Property has mixed value, if not all options are the same.\n      mixed: prop => ko.pureComputed(() => !allSame(listFields().map(f => f.widgetOptionsJson.prop(prop)()))),\n      // Property has empty value, if all options are empty (are null, undefined, empty Array or empty Object).\n      empty: prop => ko.pureComputed(() => allEmpty(listFields().map(f => f.widgetOptionsJson.prop(prop)()))),\n    }));\n\n    // This is repeated logic for wrap property in viewFieldRec,\n    // every field has wrapping implicitly set to true on a card view.\n    this.wrap = modelUtil.fieldWithDefault(\n      this.options.prop(\"wrap\"),\n      () => this._field.viewSection().parentKey() !== \"record\",\n    );\n\n    this.alignment = this.options.prop(\"alignment\");\n\n    // Style options are a bit different, as they are saved when style picker is disposed.\n    // By the time it happens, fields may have changed (since user might have clicked some other column).\n    // To support this use case we need to compute a snapshot of fields, and use it to save style. Style\n    // picker will be rebuild every time fields change, and it will have access to last selected fields\n    // when it will be disposed.\n    this.style = owner.autoDispose(ko.pureComputed(() => {\n      const fields = listFields();\n      const multiSelect = fields.length > 1;\n      const savableOptions = modelUtil.savingComputed({\n        read: () => {\n          // For one column, just proxy this to the field.\n          if (!multiSelect) {\n            return this._field.widgetOptionsJson();\n          }\n          // Assemble final json object.\n          const result: any = {};\n          // First get all widgetOption jsons from all columns/fields.\n          const optionList = fields.map(f => f.widgetOptionsJson());\n          // And fill only those that are common\n          for (const key of [\"textColor\", \"fillColor\", \"fontBold\",\n            \"fontItalic\", \"fontUnderline\", \"fontStrikethrough\"]) {\n            // Setting null means that this options is there, but has no value.\n            result[key] = null;\n            // If all columns have the same value, use it.\n            if (allSame(optionList.map(v => v[key]))) {\n              result[key] = optionList[0][key] ?? null;\n            }\n          }\n          return result;\n        },\n        write: (setter, value) => {\n          if (!multiSelect) {\n            return setter(this._field.widgetOptionsJson, value);\n          }\n          // When the creator panel is saving widgetOptions, it will pass\n          // our virtual widgetObject, which has nulls for mixed values.\n          // If this option wasn't changed (set), we don't want to save it.\n          value = { ...value };\n          for (const key of Object.keys(value)) {\n            if (value[key] === null) {\n              delete value[key];\n            }\n          }\n          // Now update all options, for all fields, by amending the options\n          // object from the field/column.\n          for (const item of fields) {\n            const previous = item.widgetOptionsJson.peek();\n            setter(item.widgetOptionsJson, {\n              ...previous,\n              ...value,\n            });\n          }\n        },\n      });\n      // Style picker needs to be able revert to previous value, if user cancels.\n      const state = fields.map(f => f.style.peek());\n      // We need some additional information about each property.\n      const result: StyleOptions = extendObservable(modelUtil.objObservable(savableOptions), {\n        // Property has mixed value, if not all options are the same.\n        mixed: prop => ko.pureComputed(() => !allSame(fields.map(f => f.widgetOptionsJson.prop(prop)()))),\n        // Property has empty value, if all options are empty (are null, undefined, empty Array or empty Object).\n        empty: prop => ko.pureComputed(() => allEmpty(fields.map(f => f.widgetOptionsJson.prop(prop)()))),\n      });\n      result.revert = () => { zip(fields, state).forEach(([f, s]) => f!.style(s!)); };\n      return result;\n    }));\n\n    this.headerStyle = owner.autoDispose(ko.pureComputed(() => {\n      const fields = listFields();\n      const multiSelect = fields.length > 1;\n      const savableOptions = modelUtil.savingComputed({\n        read: () => {\n          // For one column, just proxy this to the field.\n          if (!multiSelect) {\n            return this._field.widgetOptionsJson();\n          }\n          // Assemble final json object.\n          const result: any = {};\n          // First get all widgetOption jsons from all columns/fields.\n          const optionList = fields.map(f => f.widgetOptionsJson());\n          // And fill only those that are common\n          for (const key of [\"headerTextColor\", \"headerFillColor\", \"headerFontBold\",\n            \"headerFontItalic\", \"headerFontUnderline\", \"headerFontStrikethrough\"]) {\n            // Setting null means that this options is there, but has no value.\n            result[key] = null;\n            // If all columns have the same value, use it.\n            if (allSame(optionList.map(v => v[key]))) {\n              result[key] = optionList[0][key] ?? null;\n            }\n          }\n          return result;\n        },\n        write: (setter, value) => {\n          if (!multiSelect) {\n            return setter(this._field.widgetOptionsJson, value);\n          }\n          // When the creator panel is saving widgetOptions, it will pass\n          // our virtual widgetObject, which has nulls for mixed values.\n          // If this option wasn't changed (set), we don't want to save it.\n          value = { ...value };\n          for (const key of Object.keys(value)) {\n            if (value[key] === null) {\n              delete value[key];\n            }\n          }\n          // Now update all options, for all fields, by amending the options\n          // object from the field/column.\n          for (const item of fields) {\n            const previous = item.widgetOptionsJson.peek();\n            setter(item.widgetOptionsJson, {\n              ...previous,\n              ...value,\n            });\n          }\n        },\n      });\n      // Style picker needs to be able revert to previous value, if user cancels.\n      const state = fields.map(f => f.headerStyle.peek());\n      // We need some additional information about each property.\n      const result: StyleOptions = extendObservable(modelUtil.objObservable(savableOptions), {\n        // Property has mixed value, if not all options are the same.\n        mixed: prop => ko.pureComputed(() => !allSame(fields.map(f => f.widgetOptionsJson.prop(prop)()))),\n        // Property has empty value, if all options are empty (are null, undefined, empty Array or empty Object).\n        empty: prop => ko.pureComputed(() => allEmpty(fields.map(f => f.widgetOptionsJson.prop(prop)()))),\n      });\n      result.revert = () => { zip(fields, state).forEach(([f, s]) => f!.headerStyle(s!)); };\n      return result;\n    }));\n  }\n\n  // Helper for Choice/ChoiceList columns, that saves widget options and renames values in a document\n  // in one bundle\n  public async updateChoices(renames: Record<string, string>, options: any) {\n    const hasRenames = !!Object.entries(renames).length;\n    const tableId = this._field.column.peek().table.peek().tableId.peek();\n    if (this.multiselect.peek()) {\n      this._field.config.options.update(options);\n      const colIds = this.fields.peek().filter(f => !f.isDisposed()).map(f => f.colId.peek());\n      return this._docModel.docData.bundleActions(\"Update choices configuration\", () => Promise.all([\n        this._field.config.options.save(),\n        !hasRenames ? null : this._docModel.docData.sendActions(\n          colIds.map(colId => [\"RenameChoices\", tableId, colId, renames]),\n        ),\n      ]));\n    } else {\n      const column = this._field.column.peek();\n      // In case this column is being transformed - using Apply Formula to Data, bundle the action\n      // together with the transformation.\n      const actionOptions = { nestInActiveBundle: column.isTransforming.peek() };\n      this._field.widgetOptionsJson.update(options);\n      return this._docModel.docData.bundleActions(\"Update choices configuration\", () => Promise.all([\n        this._field.widgetOptionsJson.save(),\n        !hasRenames ? null :\n          this._docModel.docData.sendAction([\"RenameChoices\", tableId, column.colId.peek(), renames]),\n      ]), actionOptions);\n    }\n  }\n}\n\n/**\n * Deeply checks that all elements in a list are equal. Equality is checked by first\n * converting \"empty like\" elements to null and then deeply comparing the elements.\n */\nfunction allSame(arr: any[]) {\n  if (arr.length <= 1) { return true; }\n  const first = ifNotSet(arr[0], null);\n  const same = arr.every((next) => {\n    return isEqual(ifNotSet(next, null), first);\n  });\n  return same;\n}\n\n/**\n * Checks if every item in a list is empty (empty like in empty string, null, undefined, empty Array or Object)\n */\nfunction allEmpty(arr: any[]) {\n  if (arr.length === 0) { return true; }\n  return arr.every(item => ifNotSet(item, null) === null);\n}\n\n/**\n * Extended version of widget options observable that contains information about mixed and empty values.\n */\ntype CommonOptions = modelUtil.SaveableObjObservable<any> & {\n  disabled(prop: string): ko.Computed<boolean>,\n  mixed(prop: string): ko.Computed<boolean>,\n  empty(prop: string): ko.Computed<boolean>,\n};\n\n/**\n * Extended version of widget options observable that contains information about mixed and empty styles, and supports\n * reverting to a previous value.\n */\ntype StyleOptions = modelUtil.SaveableObjObservable<any> & {\n  mixed(prop: string): ko.Computed<boolean>,\n  empty(prop: string): ko.Computed<boolean>,\n  revert(): void;\n};\n\n// This is helper that adds disabled computed to an ObjObservable, it follows\n// the same pattern as `prop` helper.\nfunction extendObservable(\n  obs: modelUtil.SaveableObjObservable<any>,\n  options: { [key: string]: (prop: string) => ko.PureComputed<boolean> },\n) {\n  const result = obs as any;\n  for (const key of Object.keys(options)) {\n    const cacheKey = `__${key}`;\n    result[cacheKey] = new Map();\n    result[key] = (prop: string) => {\n      if (!result[cacheKey].has(prop)) {\n        result[cacheKey].set(prop, options[key](prop));\n      }\n      return result[cacheKey].get(prop);\n    };\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "app/client/models/VirtualTable.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { DocModel } from \"app/client/models/DocModel\";\nimport { reportError } from \"app/client/models/errors\";\nimport { TableData } from \"app/client/models/TableData\";\nimport { concatenateSummaries, summarizeStoredAndUndo } from \"app/common/ActionSummarizer\";\nimport { TableDelta } from \"app/common/ActionSummary\";\nimport { ProcessedAction } from \"app/common/AlternateActions\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { DocAction, TableDataAction, UserAction } from \"app/common/DocActions\";\nimport { DocDataCache } from \"app/common/DocDataCache\";\nimport { RowRecord } from \"app/plugin/GristData\";\n\nimport { bundleChanges } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\n\n/**\n * An interface for use while editing a virtual table.\n * This is the interface passed to beforeEdit and afterEdit callbacks.\n * The getRecord method gives access to the record prior to the edit;\n * the getRecordNew method gives access to (an internal copy of)\n * the record after the edit.\n * The same interface is passed in other places, in which case\n * actions and delta are trivial.\n */\nexport interface IEdit {\n  docModel: DocModel,\n  actions: ProcessedAction[],  // UserActions plus corresponding DocActions (forward and undo).\n  delta: TableDelta,           // A summary of the effect actions would have (or had).\n\n  /**\n   * Apply a set of actions. The result is from the store backing the\n   * virtual table. Will not trigger beforeEdit or afterEdit callbacks.\n   */\n  patch(actions: UserAction[]): Promise<ProcessedAction[]>;\n\n  getRecord(rowId: number): RowRecord | undefined;     // A record in the table.\n  getRecordNew(rowId: number): RowRecord | undefined;  // A record in the table, after the edit.\n  getRowIds(): readonly number[];  // All rowIds in the table.\n}\n\n/**\n * Interface with a back-end for a specific virtual table.\n */\nexport interface IExternalTable {\n  name: string;  // the tableId of the virtual table (e.g. GristHidden_WebhookTable)\n  initialActions(): DocAction[];  // actions to create the table.\n  destroyActions?(): DocAction[];  // actions to destroy the table (auto generated if not defined), pass [] to disable.\n  fetchAll(): Promise<TableDataAction>;  // get initial state of the table.\n  sync?(editor: IEdit): Promise<void>;    // incorporate external changes.\n  beforeEdit?(editor: IEdit): Promise<void>;  // called prior to committing a change.\n  afterEdit?(editor: IEdit): Promise<void>;   // called after committing a change.\n  afterAnySchemaChange?(editor: IEdit): Promise<void>;  // called after any schema change in the document.\n}\n\n// A counter to generate unique actionNums for undo actions.\nlet _counterForUndoActions: number = 1;\n\n/**\n * A flavor of TableData that is backed by external operations and local cache.\n * This lets virtual tables \"fit in\" to a DocData instance.\n */\nexport class VirtualTableData extends TableData {\n  public docModel: DocModel;\n  public ext: IExternalTable;\n  public cache: DocDataCache;\n\n  public override fetchData() {\n    return super.fetchData(async () => {\n      const data = await this.ext.fetchAll();\n      this.cache.docData.getTable(this.getName())?.loadData(data);\n      return data;\n    });\n  }\n\n  public override async sendTableActions(userActions: UserAction[]): Promise<any[]> {\n    const actions = await this._sendTableActionsCore(userActions,\n      { isUser: true });\n    await this.ext.afterEdit?.(this._editor(actions));\n    return actions.map(action => action.retValues);\n  }\n\n  public override async sendTableAction(action: UserAction): Promise<any> {\n    const retValues = await this.sendTableActions([action]);\n    return retValues[0];\n  }\n\n  public setExt(_ext: IExternalTable) {\n    this.ext = _ext;\n    this.cache = new DocDataCache(this.ext.initialActions());\n  }\n\n  public getName() {\n    return this.ext.name;\n  }\n\n  public sync() {\n    return this.ext.sync?.(this._editor());\n  }\n\n  public async schemaChange() {\n    await this.ext.afterAnySchemaChange?.(this._editor());\n  }\n\n  private _editor(actions: ProcessedAction[] = []): IEdit {\n    const summary = concatenateSummaries(\n      actions\n        .map(action => summarizeStoredAndUndo(action.stored, action.undo)));\n    const delta = summary.tableDeltas[this.getName()];\n    return {\n      actions,\n      delta,\n      docModel: this.docModel,\n      getRecord: rowId => this.getRecord(rowId),\n      getRecordNew: rowId => this.getRecord(rowId),\n      getRowIds: () => this.getRowIds(),\n      patch: userActions => this._sendTableActionsCore(userActions, {\n        hasTableIds: true,\n        isUser: false,\n      }),\n    };\n  }\n\n  private async _sendTableActionsCore(userActions: UserAction[], options: {\n    isUser: boolean,\n    isUndo?: boolean,\n    hasTableIds?: boolean,\n    actionNum?: any,\n  }): Promise<ProcessedAction[]> {\n    const { isUndo, isUser, hasTableIds } = options;\n    if (!hasTableIds) {\n      userActions.forEach(action => action.splice(1, 0, this.tableId));\n    }\n    const actions = await this.cache.sendTableActions(userActions);\n    if (isUser) {\n      const newTable = await this.cache.docData.requireTable(this.getName());\n      try {\n        await this.ext.beforeEdit?.({\n          ...this._editor(actions),\n          getRecordNew: rowId => newTable.getRecord(rowId),\n        });\n      } catch (e) {\n        actions.reverse();\n        for (const action of actions) {\n          await this.cache.sendTableActions(action.undo);\n        }\n        throw e;\n      }\n    }\n    for (const action of actions) {\n      for (const docAction of action.stored) {\n        this.docData.receiveAction(docAction);\n        this.cache.docData.receiveAction(docAction);\n        if (isUser) {\n          const code = `ext-${this.getName()}-${_counterForUndoActions}`;\n          _counterForUndoActions++;\n          commands.allCommands.pushUndoAction.run({\n            actionNum: code,\n            actionHash: \"hash\",\n            fromSelf: true,\n            otherId: options.actionNum || 0,\n            linkId: 0,\n            rowIdHint: 0,\n            isUndo,\n            action,\n            op: this._doUndo.bind(this),\n          } as any);\n        }\n      }\n    }\n    return actions;\n  }\n\n  private async _doUndo(actionGroup: {\n    action: ProcessedAction,\n    actionNum: number | string,\n  }, isUndo: boolean) {\n    await this._sendTableActionsCore(\n      isUndo ? actionGroup.action.undo : actionGroup.action.stored,\n      {\n        isUndo,\n        isUser: true,\n        actionNum: actionGroup.actionNum,\n        hasTableIds: true,\n      });\n  }\n}\n\n/**\n * Everything needed to run a virtual table. Contains a tableData instance.\n * Subscribes to schema changes. Offers a debouncing lazySync method that\n * will attempt to synchronize the virtual table with the external source\n * one second after last call (or at most 2 seconds after the first\n * call).\n */\nexport class VirtualTableRegistration extends DisposableWithEvents {\n  public lazySync = debounce(this._sync, 1000, {\n    maxWait: 2000,\n    trailing: true,\n  });\n\n  private _tableData: VirtualTableData;\n\n  constructor(docModel: DocModel, ext: IExternalTable) {\n    super();\n    const docData = docModel.docData;\n    if (docData.getTable(ext.name)) {\n      throw new Error(`Virtual table ${ext.name} already exists`);\n    }\n    // Register the virtual table\n    docData.registerVirtualTableFactory(ext.name, VirtualTableData);\n\n    const initialActions = ext.initialActions();\n    // then process initial actions\n    docData.receiveActions(initialActions);\n    // pass in gristDoc and external interface\n    this._tableData = docData.getTable(ext.name)! as VirtualTableData;\n    // this.tableData.docApi = this.docApi;\n    this._tableData.docModel = docModel;\n    this._tableData.setExt(ext);\n    // subscribe to schema changes\n    this._tableData.schemaChange().catch(e => reportError(e));\n    // debounce is typed as returning a promise, but doesn't appear to actually //do so?\n    Promise.resolve(this.lazySync()).catch(e => reportError(e));\n\n    this.onDispose(() => {\n      bundleChanges(() => {\n        const reverse = ext.destroyActions ? ext.destroyActions() : generateDestroyActions(initialActions);\n        reverse.forEach(action => docData.receiveAction(action));\n        docData.unregisterVirtualTableFactory(ext.name);\n      });\n    });\n  }\n\n  public listenToEvents(source: DisposableWithEvents) {\n    const listener = () => this._tableData.schemaChange().catch(e => reportError(e));\n    this.listenTo(source, \"schemaUpdateAction\", listener);\n    this.onDispose(() => this.stopListening(source, \"schemaUpdateAction\", listener));\n  }\n\n  public updateSchema() {\n    return this._tableData.schemaChange();\n  }\n\n  private async _sync() {\n    if (this.isDisposed()) {\n      return;\n    }\n    await this._tableData.sync();\n  }\n}\n\n/**\n * This is a helper method that generates undo actions for actions that create a virtual\n * table. It just removes everything using the ids in the initial actions. It tries to fail\n * if actions are more complex than simple create table/columns actions.\n */\nfunction generateDestroyActions(initialActions: DocAction[]): DocAction[] {\n  return initialActions.map((action) => {\n    switch (action[0]) {\n      case \"AddTable\": return [\"RemoveTable\", action[1]];\n      case \"AddColumn\": return [\"RemoveColumn\", action[1]];\n      case \"AddRecord\": return [\"RemoveRecord\", action[1], action[2]];\n      case \"BulkAddRecord\": return [\"BulkRemoveRecord\", action[1], action[2]];\n      default: throw new Error(`Cannot generate destroy action for ${action[0]}`);\n    }\n  }).reverse() as unknown as DocAction[];\n}\n"
  },
  {
    "path": "app/client/models/VirtualTableMeta.ts",
    "content": "import { TableDataAction } from \"app/common/DocActions\";\nimport { schema } from \"app/common/schema\";\n\nexport const META_TABLES: { [tableId: string]: TableDataAction } = Object.fromEntries(\n  Object.keys(schema).map(tableId => [tableId, [\"TableData\", tableId, [], {}]]),\n);\n"
  },
  {
    "path": "app/client/models/WorkspaceInfo.ts",
    "content": "/**\n * Helpers needed for showing the title of a workspace.\n */\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport { Workspace } from \"app/common/UserAPI\";\n\n// Render the name of a workspace.  There is a similar method in HomeLeftPane.\n// Not merging since the styling of parts of the name may need to diverge.\nexport function workspaceName(app: AppModel, ws: Workspace) {\n  const { owner, name } = getWorkspaceInfo(app, ws);\n  return [name, owner ? `@${owner.name}` : \"\"].join(\" \").trim();\n}\n\n// Get the name of the personal owner of a workspace, if it is set\n// and distinct from the current user.  If the personal owner is not\n// set, or is the same as the current user, the empty string is\n// returned.  The personal owner will only be set for workspaces in\n// the \"docs\" pseudo-organization, which is assembled from all the\n// personal organizations the current user has access to.\nexport function ownerName(app: AppModel, ws: Workspace): string {\n  const { owner, self } = getWorkspaceInfo(app, ws);\n  return self ? \"\" : (owner ? owner.name : \"\");\n}\n\n// Information needed for showing the title of a workspace.\nexport interface WorkspaceInfo {\n  name: string;      // user-specified workspace name (empty if should not be shown)\n  owner?: FullUser;  // personal owner of workspace (if known and should be shown)\n  self?: boolean;    // set if owner is current user\n  isDefault?: boolean;  // set if workspace is current user's 'Home' workspace\n}\n\n// Get information needed for showing the title of a workspace.\nexport function getWorkspaceInfo(app: AppModel, ws: Workspace): WorkspaceInfo {\n  const user = app.currentUser;\n  const { name, owner } = ws;\n  const isHome = name === \"Home\";\n  if (!user || !owner) { return { owner, name }; }\n  const self = user.id === owner.id;\n  const isDefault = self && isHome;\n  if (ws.isSupportWorkspace) {\n    // Keep workspace name for support workspaces; drop owner name.\n    return { name, self, isDefault };\n  }\n  if (isHome && !isDefault) {\n    // \"Home\" workspaces of other users have their names omitted, but we retain\n    // the name \"Home\" for the current user's \"Home\" workspace.\n    return { name: \"\", owner, self, isDefault };  // omit name in this case\n  }\n  if (self) {\n    return { name, self, isDefault };\n  }\n  return { name, owner, self, isDefault };\n}\n"
  },
  {
    "path": "app/client/models/entities/ACLRuleRec.ts",
    "content": "import { DocModel, IRowModel } from \"app/client/models/DocModel\";\n\nexport type ACLRuleRec = IRowModel<\"_grist_ACLRules\">;\n\nexport function createACLRuleRec(this: ACLRuleRec, docModel: DocModel): void {\n  // currently don't care much about content.\n}\n"
  },
  {
    "path": "app/client/models/entities/CellRec.ts",
    "content": "import { KoArray } from \"app/client/lib/koArray\";\nimport { ColumnRec, DocModel, IRowModel, recordSet, refRecord, TableRec } from \"app/client/models/DocModel\";\nimport { jsonObservable } from \"app/client/models/modelUtil\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\nimport { isCensored } from \"app/common/gristTypes\";\n\nimport * as ko from \"knockout\";\n\nexport interface CellRec extends IRowModel<\"_grist_Cells\"> {\n  column: ko.Computed<ColumnRec>;\n  table: ko.Computed<TableRec>;\n  children: ko.Computed<KoArray<CellRec>>;\n  hidden: ko.Computed<boolean>;\n  parent: ko.Computed<CellRec>;\n\n  text: modelUtil.KoSaveableObservable<string | undefined>;\n  userName: modelUtil.KoSaveableObservable<string | undefined>;\n  mentions: modelUtil.KoSaveableObservable<string[] | undefined>;\n  anchorLink: modelUtil.KoSaveableObservable<string | undefined>;\n  sectionId: modelUtil.KoSaveableObservable<number | undefined>;\n}\n\nexport function createCellRec(this: CellRec, docModel: DocModel): void {\n  this.hidden = ko.pureComputed(() => isCensored(this.content()));\n  this.column = refRecord(docModel.columns, this.colRef);\n  this.table = refRecord(docModel.tables, this.tableRef);\n  this.parent = refRecord(docModel.cells, this.parentId);\n  this.children = recordSet(this, docModel.cells, \"parentId\");\n  const properContent = modelUtil.savingComputed({\n    read: () => this.hidden() ? \"{}\" : this.content(),\n    write: (setter, val) => setter(this.content, val),\n  });\n  const optionJson = jsonObservable(properContent);\n\n  // Comments:\n  this.text = optionJson.prop(\"text\");\n  this.userName = optionJson.prop(\"userName\");\n  this.mentions = optionJson.prop(\"mentions\");\n  this.anchorLink = optionJson.prop(\"anchorLink\");\n  this.sectionId = optionJson.prop(\"sectionId\");\n}\n"
  },
  {
    "path": "app/client/models/entities/ColumnRec.ts",
    "content": "import { KoArray } from \"app/client/lib/koArray\";\nimport { localStorageJsonObs } from \"app/client/lib/localStorageObs\";\nimport { ChatHistory } from \"app/client/models/ChatHistory\";\nimport { CellRec, DocModel, IRowModel, recordSet,\n  refRecord, TableRec, ViewFieldRec } from \"app/client/models/DocModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { jsonObservable, ObjObservable } from \"app/client/models/modelUtil\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { getReferencedTableId } from \"app/common/gristTypes\";\nimport {\n  BaseFormatter,\n  createFullFormatterRaw,\n  createVisibleColFormatterRaw,\n  FullFormatterArgs,\n} from \"app/common/ValueFormatter\";\nimport { createParser } from \"app/common/ValueParser\";\n\nimport { Observable } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport { v4 as uuidv4 } from \"uuid\";\n\n// Column behavior type, used primarily in the UI.\nexport type BEHAVIOR = \"empty\" | \"formula\" | \"data\";\n\n// Represents a column in a user-defined table.\nexport interface ColumnRec extends IRowModel<\"_grist_Tables_column\"> {\n  table: ko.Computed<TableRec>;\n  widgetOptionsJson: ObjObservable<any>;\n  widget: ko.Observable<string>;\n  /** Widget options that are save to copy over (for now, without rules) */\n  cleanWidgetOptionsJson: ko.Computed<string>;\n  viewFields: ko.Computed<KoArray<ViewFieldRec>>;\n  summarySource: ko.Computed<ColumnRec>;\n\n  // Is an empty column (undecided if formula or data); denoted by an empty formula.\n  isEmpty: ko.Computed<boolean>;\n\n  // Is a real formula column (not an empty column; i.e. contains a non-empty formula).\n  isRealFormula: ko.Computed<boolean>;\n\n  // Is a trigger formula column (not formula, but contains non-empty formula)\n  hasTriggerFormula: ko.Computed<boolean>;\n\n  // Used for transforming a column.\n  // Reference to the original column for a transform column, or to itself for a non-transforming column.\n  origColRef: ko.Observable<number>;\n  origCol: ko.Computed<ColumnRec>;\n  // Indicates whether a column is transforming. Manually set, but should be true in both the original\n  // column being transformed and that column's transform column.\n  isTransforming: ko.Observable<boolean>;\n\n  // Convenience observable to obtain and set the type with no suffix\n  pureType: ko.Computed<string>;\n\n  // Column behavior as seen by the user.\n  behavior: ko.Computed<BEHAVIOR>;\n\n  // The column's display column\n  _displayColModel: ko.Computed<ColumnRec>;\n\n  // Display col ref to use for the column, defaulting to the plain column itself.\n  displayColRef: ko.Computed<number>;\n\n  // The display column to use for the column, or the column itself when no displayCol is set.\n  displayColModel: ko.Computed<ColumnRec>;\n  visibleColModel: ko.Computed<ColumnRec>;\n\n  // Reverse Ref/RefList column for this column. Only for Ref/RefList columns in two-way relations.\n  reverseColModel: ko.Computed<ColumnRec>;\n  // If this column has a relation.\n  hasReverse: ko.Computed<boolean>;\n\n  disableModifyBase: ko.Computed<boolean>;    // True if column config can't be modified (name, type, etc.)\n  disableModify: ko.Computed<boolean>;        // True if column can't be modified (is summary) or is being transformed.\n  disableEditData: ko.Computed<boolean>;      // True to disable editing of the data in this column.\n\n  isHiddenCol: ko.Computed<boolean>;\n  isFormCol: ko.Computed<boolean>;\n\n  // Returns the rowModel for the referenced table, or null, if is not a reference column.\n  refTable: ko.Computed<TableRec | null>;\n\n  // Helper for Reference/ReferenceList columns, which returns a formatter according\n  // to the visibleCol associated with column.\n  visibleColFormatter: ko.Computed<BaseFormatter>;\n\n  // A formatter for values of this column.\n  // The difference between visibleColFormatter and formatter is especially important for ReferenceLists:\n  // `visibleColFormatter` is for individual elements of a list, sometimes hypothetical\n  // (i.e. they aren't actually referenced but they exist in the visible column and are relevant to e.g. autocomplete)\n  // `formatter` formats actual cell values, e.g. a whole list from the display column.\n  formatter: ko.Computed<BaseFormatter>;\n  cells: ko.Computed<KoArray<CellRec>>;\n\n  /**\n   * Current history of chat. This is a temporary array used only in the ui.\n   */\n  chatHistory: ko.PureComputed<Observable<ChatHistory>>;\n\n  // Helper which adds/removes/updates column's displayCol to match the formula.\n  saveDisplayFormula(formula: string): Promise<void> | undefined;\n\n  createValueParser(): (value: string) => any;\n\n  /** Helper method to add a reverse column (only for Ref/RefList) */\n  addReverseColumn(): Promise<void>;\n\n  /** Helper method to remove a reverse column (only for Ref/RefList) */\n  removeReverseColumn(): Promise<void>;\n}\n\nexport function createColumnRec(this: ColumnRec, docModel: DocModel): void {\n  this.table = refRecord(docModel.tables, this.parentId);\n  this.widgetOptionsJson = jsonObservable(this.widgetOptions);\n  this.widget = this.widgetOptionsJson.prop(\"widget\");\n  this.viewFields = recordSet(this, docModel.viewFields, \"colRef\");\n  this.summarySource = refRecord(docModel.columns, this.summarySourceCol);\n  this.cells = recordSet(this, docModel.cells, \"colRef\");\n\n  // Is this an empty column (undecided if formula or data); denoted by an empty formula.\n  this.isEmpty = ko.pureComputed(() => this.isFormula() && this.formula() === \"\");\n\n  // Is this a real formula column (not an empty column; i.e. contains a non-empty formula).\n  this.isRealFormula = ko.pureComputed(() => this.isFormula() && this.formula() !== \"\");\n  // If this column has a trigger formula defined\n  this.hasTriggerFormula = ko.pureComputed(() => !this.isFormula() && this.formula() !== \"\");\n\n  // Used for transforming a column.\n  // Reference to the original column for a transform column, or to itself for a non-transforming column.\n  this.origColRef = ko.observable(this.getRowId());\n  this.origCol = refRecord(docModel.columns, this.origColRef);\n  // Indicates whether a column is transforming. Manually set, but should be true in both the original\n  // column being transformed and that column's transform column.\n  this.isTransforming = ko.observable(false);\n\n  // Convenience observable to obtain and set the type with no suffix\n  this.pureType = ko.pureComputed(() => gristTypes.extractTypeFromColType(this.type()));\n\n  // The column's display column\n  this._displayColModel = refRecord(docModel.columns, this.displayCol);\n\n  // Helper which adds/removes/updates this column's displayCol to match the formula.\n  this.saveDisplayFormula = function(formula) {\n    if (formula !== (this._displayColModel().formula() || \"\")) {\n      return docModel.docData.sendAction([\"SetDisplayFormula\", this.table().tableId(),\n        null, this.getRowId(), formula]);\n    }\n  };\n\n  // Display col ref to use for the column, defaulting to the plain column itself.\n  this.displayColRef = ko.pureComputed(() => this.displayCol() || this.origColRef());\n\n  // The display column to use for the column, or the column itself when no displayCol is set.\n  this.displayColModel = refRecord(docModel.columns, this.displayColRef);\n  this.reverseColModel = refRecord(docModel.columns, this.reverseCol);\n  this.hasReverse = this.autoDispose(ko.pureComputed(() => Boolean(this.reverseColModel().id())));\n  this.visibleColModel = refRecord(docModel.columns, this.visibleCol);\n\n  this.disableModifyBase = ko.pureComputed(() => Boolean(this.summarySourceCol()));\n  this.disableModify = ko.pureComputed(() => this.disableModifyBase() || this.isTransforming());\n  this.disableEditData = ko.pureComputed(() => Boolean(this.summarySourceCol()));\n\n  this.isHiddenCol = ko.pureComputed(() => gristTypes.isHiddenCol(this.colId()));\n  this.isFormCol = ko.pureComputed(() => (\n    !this.isHiddenCol() &&\n    !this.isRealFormula()\n  ));\n\n  // Returns the rowModel for the referenced table, or null, if this is not a reference column.\n  this.refTable = ko.pureComputed(() => {\n    const refTableId = getReferencedTableId(this.type() || \"\");\n    return refTableId ? docModel.visibleTables.all().find(t => t.tableId() === refTableId) || null : null;\n  });\n\n  // Helper for Reference/ReferenceList columns, which returns a formatter according to the visibleCol\n  // associated with this column. If no visible column available, return formatting for the column itself.\n  this.visibleColFormatter = ko.pureComputed(() => formatterForRec(this, this, docModel, \"vcol\"));\n\n  this.formatter = ko.pureComputed(() => formatterForRec(this, this, docModel, \"full\"));\n\n  this.createValueParser = function() {\n    const parser = createParser(docModel.docData, this.id.peek());\n    return parser.cleanParse.bind(parser);\n  };\n\n  this.behavior = ko.pureComputed(() => this.isEmpty() ? \"empty\" : this.isFormula() ? \"formula\" : \"data\");\n\n  this.chatHistory = this.autoDispose(ko.computed(() => {\n    const docId = urlState().state.get().doc ?? \"\";\n    // Changed key name from history to history-v2 when ChatHistory changed in incompatible way.\n    const key = `formula-assistant-history-v2-${docId}-${this.table().tableId()}-${this.colId()}`;\n    return localStorageJsonObs(key, { messages: [], conversationId: uuidv4() } as ChatHistory);\n  }));\n\n  this.cleanWidgetOptionsJson = ko.pureComputed(() => {\n    const options = this.widgetOptionsJson();\n    if (options?.rules) {\n      delete options.rules;\n    }\n    return JSON.stringify(options);\n  });\n\n  this.addReverseColumn = () => {\n    return docModel.docData.sendAction([\"AddReverseColumn\", this.table.peek().tableId.peek(), this.colId.peek()]);\n  };\n\n  this.removeReverseColumn = async () => {\n    if (!this.hasReverse.peek()) {\n      throw new Error(\"Column does not have a reverse column\");\n    }\n    // Remove the other column. Data engine will take care of removing the relation.\n    const column = this.reverseColModel.peek();\n    const tableId = column.table.peek().tableId.peek();\n    const colId = column.colId.peek();\n    return await docModel.docData.sendAction([\"RemoveColumn\", tableId, colId]);\n  };\n}\n\nexport function formatterForRec(\n  rec: ColumnRec | ViewFieldRec, colRec: ColumnRec, docModel: DocModel, kind: \"full\" | \"vcol\",\n): BaseFormatter {\n  const vcol = rec.visibleColModel();\n  const func = kind === \"full\" ? createFullFormatterRaw : createVisibleColFormatterRaw;\n  const args: FullFormatterArgs = {\n    docData: docModel.docData,\n    type: colRec.type(),\n    widgetOpts: rec.widgetOptionsJson(),\n    visibleColType: vcol?.type(),\n    visibleColWidgetOpts: vcol?.widgetOptionsJson(),\n    docSettings: docModel.docInfoRow.documentSettingsJson(),\n  };\n  return func(args);\n}\n"
  },
  {
    "path": "app/client/models/entities/DocInfoRec.ts",
    "content": "import { DocModel, IRowModel } from \"app/client/models/DocModel\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\nimport { jsonObservable } from \"app/client/models/modelUtil\";\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\n\nimport * as ko from \"knockout\";\n\n// The document-wide metadata. It's all contained in a single record with id=1.\nexport interface DocInfoRec extends IRowModel<\"_grist_DocInfo\"> {\n  documentSettingsJson: modelUtil.SaveableObjObservable<DocumentSettings>\n  defaultViewId: ko.Computed<number>;\n  newDefaultViewId: ko.Computed<number>;\n  /**\n   * Id of an attachment store if undefined it means that attachments are stored internally (default).\n   * Note: You shouldn't change it directly. There is a docAPI endpoint to modify it (which notifies other\n   * client about transfer job status also).\n   */\n  attachmentStoreId: modelUtil.KoSaveableObservable<string | undefined>;\n}\n\nexport function createDocInfoRec(this: DocInfoRec, docModel: DocModel): void {\n  this.documentSettingsJson = jsonObservable(this.documentSettings);\n  this.attachmentStoreId = this.documentSettingsJson.prop(\"attachmentStoreId\");\n  this.defaultViewId = this.autoDispose(ko.pureComputed(() => {\n    const tab = docModel.allTabs.at(0);\n    return tab ? tab.viewRef() : 0;\n  }));\n  this.newDefaultViewId = this.autoDispose(ko.pureComputed(() => {\n    const page = docModel.visibleDocPages()[0];\n    return page ? page.viewRef() : 0;\n  }));\n}\n"
  },
  {
    "path": "app/client/models/entities/FilterRec.ts",
    "content": "import { ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec } from \"app/client/models/DocModel\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\n\nimport * as ko from \"knockout\";\n\n// Represents a column filter for a view section.\nexport interface FilterRec extends IRowModel<\"_grist_Filters\"> {\n  viewSection: ko.Computed<ViewSectionRec>;\n  column: ko.Computed<ColumnRec>;\n\n  // Observable for the parsed filter object.\n  activeFilter: modelUtil.CustomComputed<string>;\n}\n\nexport function createFilterRec(this: FilterRec, docModel: DocModel): void {\n  this.viewSection = refRecord(docModel.viewSections, this.viewSectionRef);\n  this.column = refRecord(docModel.columns, this.colRef);\n\n  // Observable for the active filter that's initialized from the value saved to the server.\n  this.activeFilter = modelUtil.customComputed({\n    read: () => { const f = this.filter(); return f === \"null\" ? \"\" : f; }, // To handle old empty filters.\n  });\n}\n"
  },
  {
    "path": "app/client/models/entities/PageRec.ts",
    "content": "import { DocModel, IRowModel, refRecord, ViewRec } from \"app/client/models/DocModel\";\nimport { ShareRec } from \"app/client/models/entities/ShareRec\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\n\nimport { Computed, Observable } from \"grainjs\";\nimport * as ko from \"knockout\";\n\n// Represents a page entry in the tree of pages.\nexport interface PageRec extends IRowModel<\"_grist_Pages\"> {\n  view: ko.Computed<ViewRec>;\n  isHidden: ko.Computed<boolean>;\n  isCensored: ko.Computed<boolean>;\n  isSpecial: ko.Computed<boolean>;\n  share: ko.Computed<ShareRec>;\n  isCollapsedByDefault: Computed<boolean>;\n  isCollapsed: Observable<boolean>;\n  setAndSaveCollapsed(value: boolean): Promise<void>;\n}\n\nexport function createPageRec(this: PageRec, docModel: DocModel): void {\n  this.view = refRecord(docModel.views, this.viewRef);\n  // Page is hidden when any of this is true:\n  // - It has an empty name (or no name at all)\n  // - It is GristDocTour (unless user wants to see it)\n  // - It is GristDocTutorial (unless user should see it)\n  // - It is a page generated for a hidden table TODO: Follow up - don't create\n  //   pages for hidden tables.\n  // This is used currently only the left panel, to hide pages from the user.\n  this.isCensored = ko.pureComputed(() => !this.view().name());\n  this.isSpecial = ko.pureComputed(() => {\n    const name = this.view().name();\n    const isTableHidden = () => {\n      const viewId = this.view().id();\n      const tables = docModel.rawDataTables.all();\n      const primaryTable = tables.find(t => t.primaryViewId() === viewId);\n      return !!primaryTable && primaryTable.tableId()?.startsWith(\"GristHidden_\");\n    };\n    return (\n      (name === \"GristDocTour\" && !docModel.showDocTourTable) ||\n      (name === \"GristDocTutorial\" && !docModel.showDocTutorialTable) ||\n      isTableHidden()\n    );\n  });\n  this.isHidden = ko.pureComputed(() => {\n    return this.isCensored() || this.isSpecial();\n  });\n  this.share = refRecord(docModel.shares, this.shareRef);\n  const options = modelUtil.jsonObservable(\n    this.options,\n    (obj: any) => obj || {},\n  );\n  this.isCollapsedByDefault = Computed.create(this, use =>\n    Boolean(use(options).collapsed),\n  );\n  this.isCollapsed = Observable.create(this, this.isCollapsedByDefault.get());\n  this.setAndSaveCollapsed = async (value: boolean) => {\n    this.isCollapsed.set(value);\n    await options.setAndSave({ ...options.peek(), collapsed: value });\n  };\n}\n"
  },
  {
    "path": "app/client/models/entities/ShareRec.ts",
    "content": "import { IRowModel } from \"app/client/models/DocModel\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\n\nexport interface ShareRec extends IRowModel<\"_grist_Shares\"> {\n  optionsObj: modelUtil.SaveableObjObservable<any>;\n}\n\nexport function createShareRec(this: ShareRec): void {\n  this.optionsObj = modelUtil.jsonObservable(this.options);\n}\n"
  },
  {
    "path": "app/client/models/entities/TabBarRec.ts",
    "content": "import { DocModel, IRowModel, refRecord, ViewRec } from \"app/client/models/DocModel\";\n\nimport * as ko from \"knockout\";\n\n// Represents a page entry in the tree of pages.\nexport interface TabBarRec extends IRowModel<\"_grist_TabBar\"> {\n  view: ko.Computed<ViewRec>;\n}\n\nexport function createTabBarRec(this: TabBarRec, docModel: DocModel): void {\n  this.view = refRecord(docModel.views, this.viewRef);\n}\n"
  },
  {
    "path": "app/client/models/entities/TableRec.ts",
    "content": "import { KoArray } from \"app/client/lib/koArray\";\nimport { DocModel, IRowModel, recordSet, refRecord, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { ColumnRec, ValidationRec, ViewRec } from \"app/client/models/DocModel\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\nimport { summaryGroupByDescription } from \"app/common/ActiveDocAPI\";\nimport { MANUALSORT } from \"app/common/gristTypes\";\n\nimport * as ko from \"knockout\";\nimport randomcolor from \"randomcolor\";\n\n// Represents a user-defined table.\nexport interface TableRec extends IRowModel<\"_grist_Tables\"> {\n  columns: ko.Computed<KoArray<ColumnRec>>;\n  visibleColumns: ko.Computed<ColumnRec[]>;\n  validations: ko.Computed<KoArray<ValidationRec>>;\n\n  primaryView: ko.Computed<ViewRec>;\n  rawViewSection: ko.Computed<ViewSectionRec>;\n  recordCardViewSection: ko.Computed<ViewSectionRec>;\n  summarySource: ko.Computed<TableRec>;\n\n  // A Set object of colRefs for all summarySourceCols of table.\n  summarySourceColRefs: ko.Computed<Set<number>>;\n\n  // tableId for normal tables, or tableId of the source table for summary tables.\n  primaryTableId: ko.Computed<string>;\n\n  // The list of grouped by columns.\n  groupByColumns: ko.Computed<ColumnRec[]>;\n  // Grouping description.\n  groupDesc: ko.PureComputed<string>;\n  // Name of the data table - title of the rawViewSection\n  // for summary table it is name of primary table.\n  tableName: modelUtil.KoSaveableObservable<string>;\n  // Table name with a default value (which is tableId).\n  tableNameDef: modelUtil.KoSaveableObservable<string>;\n  // Like tableNameDef, but formatted to be more suitable for displaying to\n  // users (e.g. including group columns for summary tables).\n  formattedTableName: ko.PureComputed<string>;\n  // If user can select this table in various places.\n  // Note: Some hidden tables can still be visible on RawData view.\n  isHidden: ko.Computed<boolean>;\n  isSummary: ko.Computed<boolean>;\n\n  tableColor: string;\n  disableAddRemoveRows: ko.Computed<boolean>;\n  supportsManualSort: ko.Computed<boolean>;\n}\n\nexport function createTableRec(this: TableRec, docModel: DocModel): void {\n  this.columns = recordSet(this, docModel.columns, \"parentId\", { sortBy: \"parentPos\" });\n  this.visibleColumns = this.autoDispose(ko.pureComputed(() =>\n    this.columns().all().filter(c => !c.isHiddenCol())));\n  this.validations = recordSet(this, docModel.validations, \"tableRef\");\n\n  this.primaryView = refRecord(docModel.views, this.primaryViewId);\n  this.rawViewSection = refRecord(docModel.viewSections, this.rawViewSectionRef);\n  this.recordCardViewSection = refRecord(docModel.viewSections, this.recordCardViewSectionRef);\n  this.summarySource = refRecord(docModel.tables, this.summarySourceTable);\n  this.isHidden = this.autoDispose(\n    // This is repeated logic from isHiddenTable.\n    ko.pureComputed(() => !this.tableId() || !!this.summarySourceTable() || this.tableId().startsWith(\"GristHidden_\")),\n  );\n\n  // A Set object of colRefs for all summarySourceCols of this table.\n  this.summarySourceColRefs = this.autoDispose(ko.pureComputed(() => new Set(\n    this.columns().all().map(c => c.summarySourceCol()).filter(colRef => colRef))));\n\n  // tableId for normal tables, or tableId of the source table for summary tables.\n  this.primaryTableId = ko.pureComputed(() =>\n    this.summarySourceTable() ? this.summarySource().tableId() : this.tableId());\n\n  this.isSummary = this.autoDispose(ko.pureComputed(() => Boolean(this.summarySourceTable())));\n\n  this.groupByColumns = ko.pureComputed(() => this.columns().all().filter(c => c.summarySourceCol()));\n\n  this.groupDesc = ko.pureComputed(() => {\n    if (!this.summarySourceTable()) {\n      return \"\";\n    }\n    return summaryGroupByDescription(this.groupByColumns().map(c => c.label()));\n  });\n\n  // TODO: We should save this value and let users change it.\n  this.tableColor = randomcolor({\n    luminosity: \"light\",\n    seed: typeof this.id() === \"number\" ? 5 * this.id() : this.id(),\n  });\n\n  this.disableAddRemoveRows = ko.pureComputed(() => Boolean(this.summarySourceTable()));\n\n  this.supportsManualSort = ko.pureComputed(() => this.columns().all().some(c => c.colId() === MANUALSORT));\n\n  this.tableName = modelUtil.savingComputed({\n    read: () => {\n      if (this.isDisposed()) {\n        return \"\";\n      }\n      if (this.summarySourceTable()) {\n        return this.summarySource().rawViewSection().title();\n      } else {\n        // Need to be extra careful here, rawViewSection might be disposed.\n        if (this.rawViewSection().isDisposed()) {\n          return \"\";\n        }\n        return this.rawViewSection().title();\n      }\n    },\n    write: (setter, val) => {\n      if (this.summarySourceTable()) {\n        setter(this.summarySource().rawViewSection().title, val);\n      } else {\n        setter(this.rawViewSection().title, val);\n      }\n    },\n  });\n  this.tableNameDef = modelUtil.fieldWithDefault(\n    this.tableName,\n    // TableId will be null/undefined when ACL will restrict access to it.\n    ko.computed(() => {\n      // During table removal, we could be disposed.\n      if (this.isDisposed()) {\n        return \"\";\n      }\n      const table = this.summarySourceTable() ? this.summarySource() : this;\n      return table.tableId() || \"\";\n    }),\n  );\n  this.formattedTableName = ko.pureComputed(() => {\n    return this.summarySourceTable() ?\n      `${this.tableNameDef()} ${this.groupDesc()}` :\n      this.tableNameDef();\n  });\n}\n"
  },
  {
    "path": "app/client/models/entities/ValidationRec.ts",
    "content": "import { DocModel, IRowModel } from \"app/client/models/DocModel\";\n\n// Represents a validation rule.\nexport type ValidationRec = IRowModel<\"_grist_Validations\">;\n\nexport function createValidationRec(this: ValidationRec, docModel: DocModel): void {\n  // no extra fields\n}\n"
  },
  {
    "path": "app/client/models/entities/ViewFieldRec.ts",
    "content": "import { ColumnRec, DocModel, IRowModel, refListRecords, refRecord, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { formatterForRec } from \"app/client/models/entities/ColumnRec\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\nimport { removeRule, RuleOwner } from \"app/client/models/RuleOwner\";\nimport { HeaderStyle, Style } from \"app/client/models/Styles\";\nimport { ViewFieldConfig } from \"app/client/models/ViewFieldConfig\";\nimport * as UserType from \"app/client/widgets/UserType\";\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\nimport { DropdownCondition, DropdownConditionCompilationResult } from \"app/common/DropdownCondition\";\nimport { compilePredicateFormula } from \"app/common/PredicateFormula\";\nimport { BaseFormatter } from \"app/common/ValueFormatter\";\nimport { createParser } from \"app/common/ValueParser\";\n\nimport { Computed } from \"grainjs\";\nimport * as ko from \"knockout\";\n\n// Represents a page entry in the tree of pages.\nexport interface ViewFieldRec extends IRowModel<\"_grist_Views_section_field\">, RuleOwner {\n  viewSection: ko.Computed<ViewSectionRec>;\n  widthDef: modelUtil.KoSaveableObservable<number>;\n\n  widthPx: ko.Computed<string>;\n  column: ko.Computed<ColumnRec>;\n  origLabel: ko.Computed<string>;\n  origCol: ko.Computed<ColumnRec>;\n  pureType: ko.Computed<string>;\n  colId: ko.Computed<string>;\n  label: ko.Computed<string>;\n  description: modelUtil.KoSaveableObservable<string>;\n\n  // displayLabel displays label by default but switches to the more helpful colId whenever a\n  // formula field in the view is being edited.\n  displayLabel: modelUtil.KoSaveableObservable<string>;\n\n  // The field knows when we are editing a formula, so that all rows can reflect that.\n  editingFormula: ko.Computed<boolean>;\n\n  // CSS class to add to formula cells, incl. to show that we are editing field's formula.\n  formulaCssClass: ko.Computed<string | null>;\n\n  // The fields's display column\n  _displayColModel: ko.Computed<ColumnRec>;\n\n  // Whether field uses column's widgetOptions (true) or its own (false).\n  // During transform, use the transform column's options (which should be initialized to match\n  // field or column when the transform starts TODO).\n  useColOptions: ko.Computed<boolean>;\n\n  // Helper that returns the RowModel for either field or its column, depending on\n  // useColOptions. Field and Column have a few identical fields:\n  //    .widgetOptions()        // JSON string of options\n  //    .saveDisplayFormula()   // Method to save the display formula\n  //    .displayCol()           // Reference to an optional associated display column.\n  _fieldOrColumn: ko.Computed<ColumnRec | ViewFieldRec>;\n\n  // Display col ref to use for the field, defaulting to the plain column itself.\n  displayColRef: ko.Computed<number>;\n\n  visibleColRef: modelUtil.KoSaveableObservable<number>;\n\n  // The display column to use for the field, or the column itself when no displayCol is set.\n  displayColModel: ko.Computed<ColumnRec>;\n  visibleColModel: ko.Computed<ColumnRec>;\n\n  // The widgetOptions to read and write: either the column's or the field's own.\n  _widgetOptionsStr: modelUtil.KoSaveableObservable<string>;\n\n  // Observable for the object with the current options, either for the field or for the column,\n  // which takes into account the default options for column's type.\n  widgetOptionsJson: modelUtil.SaveableObjObservable<any>;\n\n  disableModify: ko.Computed<boolean>;\n  disableEditData: ko.Computed<boolean>;\n\n  // Whether lines should wrap in a cell.\n  wrap: modelUtil.KoSaveableObservable<boolean>;\n  widget: modelUtil.KoSaveableObservable<string | undefined>;\n  textColor: modelUtil.KoSaveableObservable<string | undefined>;\n  fillColor: modelUtil.KoSaveableObservable<string | undefined>;\n  fontBold: modelUtil.KoSaveableObservable<boolean | undefined>;\n  fontUnderline: modelUtil.KoSaveableObservable<boolean | undefined>;\n  fontItalic: modelUtil.KoSaveableObservable<boolean | undefined>;\n  fontStrikethrough: modelUtil.KoSaveableObservable<boolean | undefined>;\n  headerTextColor: modelUtil.KoSaveableObservable<string | undefined>;\n  headerFillColor: modelUtil.KoSaveableObservable<string | undefined>;\n  headerFontBold: modelUtil.KoSaveableObservable<boolean | undefined>;\n  headerFontUnderline: modelUtil.KoSaveableObservable<boolean | undefined>;\n  headerFontItalic: modelUtil.KoSaveableObservable<boolean | undefined>;\n  headerFontStrikethrough: modelUtil.KoSaveableObservable<boolean | undefined>;\n  // Helper computed to change style of a cell and headerStyle without saving it.\n  style: ko.PureComputed<Style>;\n  headerStyle: ko.PureComputed<HeaderStyle>;\n\n  config: ViewFieldConfig;\n\n  documentSettings: ko.PureComputed<DocumentSettings>;\n\n  // Helper for Reference/ReferenceList columns, which returns a formatter according\n  // to the visibleCol associated with field.\n  visibleColFormatter: ko.Computed<BaseFormatter>;\n\n  // A formatter for values of this column.\n  // The difference between visibleColFormatter and formatter is especially important for ReferenceLists:\n  // `visibleColFormatter` is for individual elements of a list, sometimes hypothetical\n  // (i.e. they aren't actually referenced but they exist in the visible column and are relevant to e.g. autocomplete)\n  // `formatter` formats actual cell values, e.g. a whole list from the display column.\n  formatter: ko.Computed<BaseFormatter>;\n\n  /** Label in FormView. By default FormView uses label, use this to override it. */\n  question: modelUtil.KoSaveableObservable<string | undefined>;\n\n  dropdownCondition: modelUtil.KoSaveableObservable<DropdownCondition | undefined>;\n  dropdownConditionCompiled: Computed<DropdownConditionCompilationResult | null>;\n\n  createValueParser(): (value: string) => any;\n\n  // Helper which adds/removes/updates field's displayCol to match the formula.\n  saveDisplayFormula(formula: string): Promise<void> | undefined;\n}\n\nexport function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void {\n  this.viewSection = refRecord(docModel.viewSections, this.parentId);\n  this.widthDef = modelUtil.fieldWithDefault(this.width, () => this.viewSection().defaultWidth());\n\n  this.widthPx = this.autoDispose(ko.pureComputed(() => this.widthDef() + \"px\"));\n  this.column = this.autoDispose(refRecord(docModel.columns, this.colRef));\n  this.origCol = this.autoDispose(ko.pureComputed(() => this.column().origCol()));\n  this.pureType = this.autoDispose(ko.pureComputed(() => this.column().pureType()));\n  this.colId = this.autoDispose(ko.pureComputed(() => this.column().colId()));\n  this.label = this.autoDispose(ko.pureComputed(() => this.column().label()));\n  this.origLabel = this.autoDispose(ko.pureComputed(() => this.origCol().label()));\n  this.description = this.autoDispose(modelUtil.savingComputed({\n    read: () => this.column().description(),\n    write: (setter, val) => setter(this.column().description, val),\n  }));\n  // displayLabel displays label by default but switches to the more helpful colId whenever a\n  // formula field in the view is being edited.\n  this.displayLabel = modelUtil.savingComputed({\n    read: () => docModel.editingFormula() ? \"$\" + this.origCol().colId() : this.origCol().label(),\n    write: (setter, val) => setter(this.column().label, val),\n  });\n\n  // The field knows when we are editing a formula, so that all rows can reflect that.\n  const _editingFormula = ko.observable(false);\n  this.editingFormula = this.autoDispose(ko.pureComputed({\n    read: () => _editingFormula(),\n    write: (val) => {\n      // Whenever any view field changes its editingFormula status, let the docModel know.\n      docModel.editingFormula(val);\n      _editingFormula(val);\n    },\n  }));\n\n  // CSS class to add to formula cells, incl. to show that we are editing this field's formula.\n  this.formulaCssClass = this.autoDispose(ko.pureComputed<string | null>(() => {\n    const col = this.column();\n\n    // If the current column is transforming, assign the CSS class \"transform_field\"\n    if (col.isTransforming()) {\n      if (col.origCol().isFormula() && col.origCol().formula() !== \"\") {\n        return \"transform_field formula_field\";\n      }\n      return \"transform_field\";\n    } else if (this.editingFormula()) { // If the column is not transforming but a formula is being edited\n      return \"formula_field_edit\";\n    } else if (col.isFormula() && col.formula() !== \"\") { // If a formula exists and it is not empty\n      return \"formula_field\";\n    } else { // If none of the above conditions are met, assign null\n      return null;\n    }\n  }));\n\n  // The fields's display column\n  this._displayColModel = refRecord(docModel.columns, this.displayCol);\n\n  // Helper which adds/removes/updates this field's displayCol to match the formula.\n  this.saveDisplayFormula = function(formula) {\n    if (formula !== (this._displayColModel().formula() || \"\")) {\n      return docModel.docData.sendAction([\"SetDisplayFormula\", this.column().table().tableId(),\n        this.getRowId(), null, formula]);\n    }\n  };\n\n  // Whether this field uses column's widgetOptions (true) or its own (false).\n  // During transform, use the transform column's options (which should be initialized to match\n  // field or column when the transform starts TODO).\n  this.useColOptions = this.autoDispose(ko.pureComputed(() => !this.widgetOptions() || this.column().isTransforming()));\n\n  // Helper that returns the RowModel for either this field or its column, depending on\n  // useColOptions. Field and Column have a few identical fields:\n  //    .widgetOptions()        // JSON string of options\n  //    .saveDisplayFormula()   // Method to save the display formula\n  //    .displayCol()           // Reference to an optional associated display column.\n  this._fieldOrColumn = this.autoDispose(ko.pureComputed(() => this.useColOptions() ? this.column() : this));\n\n  // Display col ref to use for the field, defaulting to the plain column itself.\n  this.displayColRef = this.autoDispose(ko.pureComputed(() => this._fieldOrColumn().displayCol() || this.colRef()));\n\n  this.visibleColRef = modelUtil.addSaveInterface(this.autoDispose(ko.pureComputed({\n    read: () => this._fieldOrColumn().visibleCol(),\n    write: colRef => this._fieldOrColumn().visibleCol(colRef),\n  })),\n  colRef => docModel.docData.bundleActions(null, async () => {\n    const col = docModel.columns.getRowModel(colRef);\n    await Promise.all([\n      this._fieldOrColumn().visibleCol.saveOnly(colRef),\n      this._fieldOrColumn().saveDisplayFormula(colRef ? `$${this.colId()}.${col.colId()}` : \"\"),\n    ]);\n  }, { nestInActiveBundle: this.column.peek().isTransforming.peek() }),\n  );\n\n  // The display column to use for the field, or the column itself when no displayCol is set.\n  this.displayColModel = refRecord(docModel.columns, this.displayColRef);\n  this.visibleColModel = refRecord(docModel.columns, this.visibleColRef);\n\n  // Helper for Reference/ReferenceList columns, which returns a formatter according to the visibleCol\n  // associated with this field. If no visible column available, return formatting for the field itself.\n  this.visibleColFormatter = this.autoDispose(\n    ko.pureComputed(() => formatterForRec(this, this.column(), docModel, \"vcol\")),\n  );\n\n  this.formatter = this.autoDispose(\n    ko.pureComputed(() => formatterForRec(this, this.column(), docModel, \"full\")),\n  );\n\n  this.createValueParser = function() {\n    const fieldRef = this.useColOptions.peek() ? undefined : this.id.peek();\n    const parser = createParser(docModel.docData, this.colRef.peek(), fieldRef);\n    return parser.cleanParse.bind(parser);\n  };\n\n  // The widgetOptions to read and write: either the column's or the field's own.\n  this._widgetOptionsStr = this.autoDispose(modelUtil.savingComputed({\n    read: () => this._fieldOrColumn().widgetOptions(),\n    write: (setter, val) => setter(this._fieldOrColumn().widgetOptions, val),\n  }));\n\n  // Observable for the object with the current options, either for the field or for the column,\n  // which takes into account the default options for this column's type.\n  this.widgetOptionsJson = this.autoDispose(modelUtil.jsonObservable(this._widgetOptionsStr,\n    (opts: any) => UserType.mergeOptions(opts || {}, this.column().pureType())));\n\n  // When user has yet to specify a desired wrapping state, we use different defaults for\n  // GridView (no wrap) and DetailView (wrap).\n  this.wrap = this.autoDispose(modelUtil.fieldWithDefault(\n    this.widgetOptionsJson.prop(\"wrap\"),\n    () => this.viewSection().parentKey() !== \"record\",\n  ));\n  this.widget = this.widgetOptionsJson.prop(\"widget\");\n  this.textColor = this.widgetOptionsJson.prop(\"textColor\");\n  this.fillColor = this.widgetOptionsJson.prop(\"fillColor\");\n  this.fontBold = this.widgetOptionsJson.prop(\"fontBold\");\n  this.fontUnderline = this.widgetOptionsJson.prop(\"fontUnderline\");\n  this.fontItalic = this.widgetOptionsJson.prop(\"fontItalic\");\n  this.fontStrikethrough = this.widgetOptionsJson.prop(\"fontStrikethrough\");\n  this.headerTextColor = this.widgetOptionsJson.prop(\"headerTextColor\");\n  this.headerFillColor = this.widgetOptionsJson.prop(\"headerFillColor\");\n  this.headerFontBold = this.widgetOptionsJson.prop(\"headerFontBold\");\n  this.headerFontUnderline = this.widgetOptionsJson.prop(\"headerFontUnderline\");\n  this.headerFontItalic = this.widgetOptionsJson.prop(\"headerFontItalic\");\n  this.headerFontStrikethrough = this.widgetOptionsJson.prop(\"headerFontStrikethrough\");\n  this.question = this.widgetOptionsJson.prop(\"question\");\n\n  this.documentSettings = this.autoDispose(ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson()));\n  this.style = this.autoDispose(ko.pureComputed({\n    read: () => ({\n      textColor: this.textColor(),\n      fillColor: this.fillColor(),\n      fontBold: this.fontBold(),\n      fontUnderline: this.fontUnderline(),\n      fontItalic: this.fontItalic(),\n      fontStrikethrough: this.fontStrikethrough(),\n    }) as Style,\n    write: (style: Style) => {\n      this.widgetOptionsJson.update(style);\n    },\n  }));\n  this.headerStyle = this.autoDispose(ko.pureComputed({\n    read: () => ({\n      headerTextColor: this.headerTextColor(),\n      headerFillColor: this.headerFillColor(),\n      headerFontBold: this.headerFontBold(),\n      headerFontUnderline: this.headerFontUnderline(),\n      headerFontItalic: this.headerFontItalic(),\n      headerFontStrikethrough: this.headerFontStrikethrough(),\n    }) as HeaderStyle,\n    write: (headerStyle: HeaderStyle) => {\n      this.widgetOptionsJson.update(headerStyle);\n    },\n  }));\n\n  this.tableId = this.autoDispose(ko.pureComputed(() => this.column().table().tableId()));\n  this.rulesList = modelUtil.savingComputed({\n    read: () => this._fieldOrColumn().rules(),\n    write: (setter, val) => setter(this._fieldOrColumn().rules, val),\n  });\n  this.rulesCols = this.autoDispose(\n    refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules())),\n  );\n  this.rulesColsIds = this.autoDispose(ko.pureComputed(() => this.rulesCols().map(c => c.colId())));\n  this.rulesStyles = modelUtil.fieldWithDefault(\n    this.widgetOptionsJson.prop(\"rulesOptions\") as modelUtil.KoSaveableObservable<Style[]>,\n    []);\n  this.hasRules = this.autoDispose(ko.pureComputed(() => this.rulesCols().length > 0));\n\n  // Helper method to add an empty rule (either initial or additional one).\n  // Style options are added to widget options directly and can be briefly out of sync,\n  // which is taken into account during rendering.\n  this.addEmptyRule = async () => {\n    const useCol = this.useColOptions.peek();\n    const action = [\n      \"AddEmptyRule\",\n      this.column.peek().table.peek().tableId.peek(),\n      useCol ? 0 : this.id.peek(), // field_ref\n      useCol ? this.column.peek().id.peek() : 0, // col_ref\n    ];\n    await docModel.docData.sendAction(action, `Update rules for ${this.colId.peek()}`);\n  };\n\n  this.removeRule = (index: number) => removeRule(docModel, this, index);\n  // Externalize widgetOptions configuration, to support changing those options\n  // for multiple fields at once.\n  this.config = new ViewFieldConfig(this, docModel);\n\n  this.disableModify = this.autoDispose(ko.pureComputed(() => this.column().disableModify()));\n  this.disableEditData = this.autoDispose(ko.pureComputed(() => this.column().disableEditData()));\n\n  this.dropdownCondition = this.widgetOptionsJson.prop(\"dropdownCondition\");\n  this.dropdownConditionCompiled = Computed.create(this, (use) => {\n    const dropdownCondition = use(this.dropdownCondition);\n    if (!dropdownCondition?.parsed) { return null; }\n\n    try {\n      return {\n        kind: \"success\",\n        result: compilePredicateFormula(JSON.parse(dropdownCondition.parsed), {\n          variant: \"dropdown-condition\",\n        }),\n      };\n    } catch (e) {\n      return { kind: \"failure\", error: e.message };\n    }\n  });\n}\n"
  },
  {
    "path": "app/client/models/entities/ViewRec.ts",
    "content": "import { BoxSpec } from \"app/client/lib/BoxSpec\";\nimport { KoArray } from \"app/client/lib/koArray\";\nimport * as koUtil from \"app/client/lib/koUtil\";\nimport { DocModel, IRowModel, PageRec, recordSet, refRecord } from \"app/client/models/DocModel\";\nimport { TabBarRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\n\nimport * as ko from \"knockout\";\n\n// Represents a view (now also referred to as a \"page\") containing one or more view sections.\nexport interface ViewRec extends IRowModel<\"_grist_Views\"> {\n  viewSections: ko.Computed<KoArray<ViewSectionRec>>;\n  tabBarItem: ko.Computed<KoArray<TabBarRec>>;\n\n  layoutSpecObj: modelUtil.SaveableObjObservable<BoxSpec>;\n\n  // An observable for the ref of the section last selected by the user.\n  activeSectionId: ko.Computed<number>;\n\n  // This is active collapsed section id. Set when the widget is clicked.\n  activeCollapsedSectionId: ko.Observable<number>;\n\n  // RegionFocusSwitcher updates this so that the view knows the current state of focused regions:\n  // - 'out' means the view region is not focused\n  // - 'in' means the view region is focused\n  // - 'related' means the currently focused region is not the view but something related to it (e.g. the creator panel)\n  focusedRegionState: ko.Observable<\"out\" | \"in\" | \"related\">;\n\n  // Saved collapsed sections.\n  collapsedSections: ko.Computed<number[]>;\n\n  // Active collapsed sections, changed by the user, can be different from the\n  // saved collapsed sections, for a brief moment (editor is buffering changes).\n  activeCollapsedSections: ko.Observable<number[]>;\n\n  activeSection: ko.Computed<ViewSectionRec>;\n\n  // If the active section is removed, set the next active section to be the default.\n  _isActiveSectionGone: ko.Computed<boolean>;\n\n  page: ko.Computed<PageRec | null>;\n}\n\nexport function createViewRec(this: ViewRec, docModel: DocModel): void {\n  this.viewSections = recordSet(this, docModel.viewSections, \"parentId\");\n  this.tabBarItem = recordSet(this, docModel.tabBar, \"viewRef\");\n\n  this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec);\n\n  this.activeCollapsedSectionId = ko.observable(0);\n  this.focusedRegionState = ko.observable<\"out\" | \"in\" | \"related\">(\"in\");\n\n  this.collapsedSections = this.autoDispose(ko.pureComputed(() => {\n    const allSections = new Set(this.viewSections().all().map(x => x.id()));\n    const collapsed: number[] = (this.layoutSpecObj().collapsed || []).map(x => x.leaf as number);\n    return collapsed.filter(x => allSections.has(x));\n  }));\n  this.activeCollapsedSections = ko.observable(this.collapsedSections.peek());\n\n  // An observable for the ref of the section last selected by the user.\n  this.activeSectionId = koUtil.observableWithDefault(ko.observable(), () => {\n    // The default function which is used when the conditional case is true.\n    // Read may occur for recently disposed sections, must check condition first.\n    // `!this.getRowId()` implies that this is an empty (non-existent) view record\n    // which happens when viewing the raw data tables, in which case the default is no active view section.\n\n    if (this.isDisposed() || !this.getRowId()) { return 0; }\n    const all = this.viewSections().all();\n    const collapsed = new Set(this.activeCollapsedSections());\n    const visible = all.filter(x => !collapsed.has(x.id()));\n\n    // Default to the first leaf from layoutSpec (which corresponds to the top-left section), or\n    // fall back to the first item in the list if anything goes wrong (previous behavior).\n    const firstLeaf = getFirstLeaf(this.layoutSpecObj.peek());\n    const result = visible.find(s => s.id() === firstLeaf) ? firstLeaf as number :\n      (visible[0]?.id() || 0);\n    return result;\n  });\n\n  this.activeSection = refRecord(docModel.viewSections, this.activeSectionId);\n\n  // If the active section is removed, set the next active section to be the default.\n  this._isActiveSectionGone = this.autoDispose(ko.computed(() => this.activeSection()._isDeleted()));\n  this.autoDispose(this._isActiveSectionGone.subscribe((gone) => {\n    if (gone) {\n      this.activeSectionId(0);\n    }\n  }));\n\n  this.page = this.autoDispose(ko.pureComputed(() => {\n    const viewRef = this.id();\n    return docModel.allPages().find(p => p.viewRef() === viewRef) ?? null;\n  }));\n}\n\nfunction getFirstLeaf(layoutSpec: BoxSpec | undefined): BoxSpec[\"leaf\"] {\n  while (layoutSpec?.children?.length) {\n    layoutSpec = layoutSpec.children[0];\n  }\n  return layoutSpec?.leaf;\n}\n"
  },
  {
    "path": "app/client/models/entities/ViewSectionRec.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport { SequenceNEVER, SequenceNum } from \"app/client/components/Cursor\";\nimport { EmptyFilterColValues, LinkingState } from \"app/client/components/LinkingState\";\nimport { KoArray } from \"app/client/lib/koArray\";\nimport { fieldInsertPositions } from \"app/client/lib/tableUtil\";\nimport { ColumnToMapImpl } from \"app/client/models/ColumnToMap\";\nimport {\n  ColumnRec,\n  DocModel,\n  FilterRec,\n  IRowModel,\n  recordSet,\n  refListRecords,\n  refRecord,\n  TableRec,\n  ViewFieldRec,\n  ViewRec,\n} from \"app/client/models/DocModel\";\nimport { BEHAVIOR } from \"app/client/models/entities/ColumnRec\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\nimport { removeRule, RuleOwner } from \"app/client/models/RuleOwner\";\nimport { LinkConfig } from \"app/client/ui/LinkConfig\";\nimport { getWidgetTypes } from \"app/client/ui/widgetTypesMap\";\nimport { FilterColValues } from \"app/common/ActiveDocAPI\";\nimport { AccessLevel, ICustomWidget } from \"app/common/CustomWidget\";\nimport { UserAction } from \"app/common/DocActions\";\nimport { RecalcWhen } from \"app/common/gristTypes\";\nimport { arrayRepeat, safeJsonParse } from \"app/common/gutil\";\nimport { Sort } from \"app/common/SortSpec\";\nimport { WidgetType } from \"app/common/widgetTypes\";\nimport { ColumnsToMap, WidgetColumnMap } from \"app/plugin/CustomSectionAPI\";\nimport { CursorPos, UIRowId } from \"app/plugin/GristAPI\";\nimport { GristObjCode } from \"app/plugin/GristData\";\n\nimport { Computed, Holder, Observable, subscribe } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport defaults from \"lodash/defaults\";\n\nimport type { Style } from \"app/client/models/Styles\";\n\nexport interface InsertColOptions {\n  colInfo?: ColInfo;\n  index?: number;\n  nestInActiveBundle?: boolean;\n}\n\nexport interface ColInfo {\n  label?: string;\n  type?: string;\n  isFormula?: boolean;\n  formula?: string;\n  recalcWhen?: RecalcWhen;\n  recalcDeps?: [GristObjCode.List, ...number[]] | null;\n  widgetOptions?: string;\n}\n\nexport interface NewColInfo {\n  colId: string;\n  colRef: number;\n}\n\nexport interface NewFieldInfo extends NewColInfo {\n  fieldRef: number;\n}\n\nexport interface ChartOptions {\n  // Options for ChartView.\n  multiseries?: boolean;\n  lineConnectGaps?: boolean;\n  lineMarkers?: boolean;\n  stacked?: boolean;\n  invertYAxis?: boolean;\n  logYAxis?: boolean;\n  // If \"symmetric\", one series after each Y series gives the length of the error bars around it. If\n  // \"separate\", two series after each Y series give the length of the error bars above and below it.\n  errorBars?: \"symmetric\" | \"separate\";\n  donutHoleSize?: number;\n  showTotal?: boolean;\n  textSize?: number;\n  isXAxisUndefined?: boolean;\n  orientation?: \"v\" | \"h\";\n  aggregate?: boolean;\n}\n\nexport interface ViewSectionOptions extends ChartOptions {\n  // Options for GridView.\n  verticalGridlines?: boolean;\n  horizontalGridlines?: boolean;\n  zebraStripes?: boolean;\n  numFrozen?: number;\n  rowHeight?: number;           // Optional limit on height of rows, in lines.\n  rowHeightUniform?: boolean;   // Whether rowHeight should make rows uniform height, by expanding shorter rows.\n\n  // Other options.\n  customView?: string;    // Configuration for custom widgets in JSON format.\n  disabled?: boolean;     // Applies to the \"default record card\".\n  rulesOptions?: Style[];\n}\n\n// Represents a section of user views, now also known as a \"page widget\" (e.g. a view may contain\n// a grid section and a chart section).\nexport interface ViewSectionRec extends IRowModel<\"_grist_Views_section\">, RuleOwner {\n  // The underlying RowModel provides a KoSaveableObservable for each field in app/common/schema.ts.\n  //   tableRef: number;\n  //   parentId: number;\n  //   parentKey: string;\n  //   title: string;\n  //   description: string;\n  //   defaultWidth: number;\n  //   borderWidth: number;\n  //   theme: string;\n  //   options: string;\n  //   chartType: string;\n  //   layoutSpec: string;\n  //   filterSpec: string;\n  //   sortColRefs: string;\n  //   linkSrcSectionRef: number;\n  //   linkSrcColRef: number;\n  //   linkTargetColRef: number;\n  //   embedId: string;\n  //   rules: [GristObjCode.List, ...number[]]|null;\n  //   shareOptions: string;\n\n  viewFields: ko.Computed<KoArray<ViewFieldRec>>;\n\n  // List of sections linked from this one, i.e. for whom this one is the selector or link source.\n  linkedSections: ko.Computed<KoArray<ViewSectionRec>>;\n\n  // All table columns associated with this view section, excluding hidden helper columns.\n  columns: ko.Computed<ColumnRec[]>;\n\n  optionsObj: modelUtil.SaveableObjObservable<ViewSectionOptions>;\n  shareOptionsObj: modelUtil.SaveableObjObservable<any>;\n\n  customDef: CustomViewSectionDef;\n\n  themeDef: modelUtil.KoSaveableObservable<string>;\n  chartTypeDef: modelUtil.KoSaveableObservable<string>;\n  view: ko.Computed<ViewRec>;\n\n  table: ko.Computed<TableRec>;\n\n  // Widget title with a default value\n  titleDef: modelUtil.KoSaveableObservable<string>;\n  // Default widget title (the one that is used in titleDef).\n  defaultWidgetTitle: ko.PureComputed<string>;\n\n  // true if this record is its table's rawViewSection, i.e. a 'raw data view'\n  // in which case the UI prevents various things like hiding columns or changing the widget type.\n  isRaw: ko.Computed<boolean>;\n\n  /** Is this table card viewsection (the one available after pressing spacebar) */\n  isRecordCard: ko.Computed<boolean>;\n\n  /** Card record viewSection for associated table (might be the same section) */\n  tableRecordCard: ko.Computed<ViewSectionRec>;\n\n  /** True if this section is disabled. Currently only used by Record Card sections. */\n  disabled: modelUtil.KoSaveableObservable<boolean>;\n\n  /** True if the Record Card section of this section's table is disabled. */\n  isTableRecordCardDisabled: ko.Computed<boolean>;\n\n  isVirtual: ko.Computed<boolean>;\n  isCollapsed: ko.Computed<boolean>;\n\n  borderWidthPx: ko.Computed<string>;\n\n  layoutSpecObj: modelUtil.SaveableObjObservable<any>;\n\n  _savedFilters: ko.Computed<KoArray<FilterRec>>;\n\n  /**\n   * Unsaved client-side filters, keyed by original col ref. Currently only wiped when unsaved filters\n   * are applied or reverted.\n   *\n   * If saved filters exist for a col ref, unsaved filters take priority and are applied instead. This\n   * prevents disruption when changes are made to saved filters for the same field/column, but there\n   * may be some cases where we'd want to reset _unsavedFilters on some indirect change to the document.\n   *\n   * NOTE: See `filters`, where `_unsavedFilters` is merged with `savedFilters`.\n   */\n  _unsavedFilters: Map<number, Partial<Filter>>;\n\n  /**\n   * Filter information for all fields/section in the section.\n   *\n   * Re-computed on changes to `savedFilters`, as well as any changes to `viewFields` or `columns`. Any\n   * unsaved filters saved in `_unsavedFilters` are applied on computation, taking priority over saved\n   * filters for the same field/column, if any exist.\n   */\n  filters: ko.Computed<FilterInfo[]>;\n\n  // Subset of `filters` containing non-blank active filters.\n  activeFilters: Computed<FilterInfo[]>;\n\n  // Subset of `activeFilters` that are pinned.\n  pinnedActiveFilters: Computed<FilterInfo[]>;\n\n  // Helper metadata item which indicates whether any of the section's fields/columns have unsaved\n  // changes to their filters. (True indicates unsaved changes)\n  filterSpecChanged: Computed<boolean>;\n\n  // Set to true when a second pinned filter is added, to trigger a behavioral prompt. Note that\n  // the popup is only shown once, even if this observable is set to true again in the future.\n  showNestedFilteringPopup: Observable<boolean>;\n\n  // Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.\n  activeSortJson: modelUtil.CustomComputed<string>;\n\n  // is an array (parsed from JSON) of colRefs (i.e. rowIds into the columns table), with a\n  // twist: a rowId may be positive or negative, for ascending or descending respectively.\n  activeSortSpec: modelUtil.ObjObservable<Sort.SortSpec>;\n\n  // Modified sort spec to take into account any active display columns.\n  activeDisplaySortSpec: ko.Computed<Sort.SortSpec>;\n\n  // Evaluates to an array of column models, which are not referenced by anything in viewFields.\n  hiddenColumns: ko.Computed<ColumnRec[]>;\n\n  // True if the section is the active section.\n  hasFocus: ko.Computed<boolean>;\n  // True if the section is the active section and if the user-focused page panel is the section or the creator panel.\n  hasVisibleFocus: ko.Computed<boolean>;\n  // True if the section is the active section and if the user-focused page panel is the section.\n  hasRegionFocus: ko.Computed<boolean>;\n\n  // Section-linking affects table if linkSrcSection is set. The controller value of the\n  // link is the value of srcCol at activeRowId of linkSrcSection, or activeRowId itself when\n  // srcCol is unset. If targetCol is set, we filter for all rows whose targetCol is equal to\n  // the controller value. Otherwise, the controller value determines the rowId of the cursor.\n\n  /**\n   * Section selected in the `Select By` dropdown. Used for filtering this section.\n   */\n  linkSrcSection: ko.Computed<ViewSectionRec>;\n  /**\n   * Column selected in the `Select By` dropdown in the remote section. It points to a column in remote section\n   * that contains a reference to this table (or common table - because we can be linked by having the same reference\n   * to some other section).\n   * Used for filtering this section. Can be empty as user can just link by section.\n   * Watch out, it is not cleared, so it is only valid when we have linkSrcSection.\n   * In UI it is shown as Target Section (dot) Target Column.\n   */\n  linkSrcCol: ko.Computed<ColumnRec>;\n  /**\n   * In case we have multiple reference columns, that are shown as\n   *   Target Section -> My Column or\n   *   Target Section . Target Column -> My Column\n   * store the reference to the column (my column) to use.\n   */\n  linkTargetCol: ko.Computed<ColumnRec>;\n\n  // Linking state maintains .filterFunc and .cursorPos observables which we use for\n  // auto-scrolling and filtering.\n  linkingState: ko.Computed<LinkingState | null>;\n  _linkingState: Holder<LinkingState>; // Holder for the current value of linkingState\n\n  linkingFilter: ko.Computed<FilterColValues>;\n\n  activeRowId: ko.Observable<UIRowId | null>;     // May be null when there are no rows.\n\n  lastCursorEdit: ko.Observable<SequenceNum>;\n\n  // If the view instance for section is instantiated, it will be accessible here.\n  viewInstance: ko.Observable<BaseView | null>;\n\n  // Describes the most recent cursor position in the section. Only rowId and fieldIndex are used.\n  lastCursorPos: CursorPos;\n\n  // Describes the most recent scroll position.\n  lastScrollPos: {\n    rowIndex: number;   // Used for scrolly sections. Indicates the index of the first visible row.\n    offset: number;     // Pixel distance past the top of row indicated by rowIndex.\n    scrollLeft: number; // Used for grid sections. Indicates the scrollLeft value of the scroll pane.\n  };\n\n  disableAddRemoveRows: ko.Computed<boolean>;\n\n  isSorted: ko.Computed<boolean>;\n  disableDragRows: ko.Computed<boolean>;\n  // Number of frozen columns\n  rawNumFrozen: modelUtil.CustomComputed<number | undefined>;\n  // Number for frozen columns to display.\n  // We won't freeze all the columns on a grid, it will leave at least 1 column unfrozen.\n  numFrozen: ko.Computed<number>;\n  activeCustomOptions: modelUtil.CustomComputed<any>;\n\n  // Observables used for styling rows with limited heights.\n  rowHeight: Computed<number>;\n  rowHeightUniform: Computed<boolean>;\n\n  // Temporary fields used to communicate with the Custom Widget. There are set through the Widget API.\n\n  // Temporary variable holding columns mapping requested by the widget (set by API).\n  columnsToMap: ko.Observable<ColumnsToMap | null>;\n  // Map from widget columns to colIds in document.\n  mappedColumns: ko.Computed<WidgetColumnMap | null>;\n  // Temporary variable holding flag that describes if the widget supports custom options (set by API).\n  hasCustomOptions: ko.Observable<boolean>;\n  // Temporary variable holding widget desired access (changed either from manifest or via API).\n  desiredAccessLevel: ko.Observable<AccessLevel | null>;\n\n  // Show widget as linking source. Used by custom widget.\n  allowSelectBy: ko.Observable<boolean>;\n\n  // List of selected rows from a custom widget, or null if a filter shouldn't be applied.\n  selectedRows: ko.Observable<number[] | null>;\n\n  // If the row filter is active (i.e. if selectedRows is non-null). Separate computed to avoid\n  // re-computing the filter when selectedRows changes.\n  selectedRowsActive: ko.Computed<boolean>;\n\n  editingFormula: ko.Computed<boolean>;\n\n  // Selected fields (columns) for the section.\n  selectedFields: ko.Observable<ViewFieldRec[]>;\n\n  // Some computed observables for multi-select, used in the creator panel, by more than one widgets.\n\n  // Common column behavior or mixed.\n  columnsBehavior: ko.PureComputed<BEHAVIOR | \"mixed\">;\n  // If all selected columns are empty or formula column.\n  columnsAllIsFormula: ko.PureComputed<boolean>;\n  // Common type of selected columns or mixed.\n  columnsType: ko.PureComputed<string>;\n\n  widgetType: modelUtil.KoSaveableObservable<WidgetType>;\n\n  /** Should the layout menu be hidden */\n  hideViewMenu: ko.Observable<boolean>;\n\n  /**\n   * If the section can be expanded (default true), used mostly by virtual tables. If undefined defaults to the caller\n   */\n  canExpand: ko.Observable<boolean>;\n\n  /**\n   * If the section can be renamed, used mostly by virtual tables. If undefined defaults to the caller\n   * of the buildViewSectionDom function (which in turn defaults to true).\n   */\n  canRename: ko.Observable<boolean | undefined>;\n\n  // If set, overrides the value of disableAddRemoveRows().\n  overrideDisableAddRemoveRows: ko.Observable<boolean | undefined>;\n\n  // Save all filters of fields/columns in the section.\n  saveFilters(): Promise<void>;\n\n  // Revert all filters of fields/columns in the section.\n  revertFilters(): void;\n\n  // Set `filter` for the field or column identified by `colRef`.\n  setFilter(colRef: number, filter: Partial<Filter>): void;\n\n  // Revert the filter of the field or column identified by `colRef`.\n  revertFilter(colRef: number): void;\n\n  // Saves custom definition (bundles change)\n  saveCustomDef(): Promise<void>;\n\n  insertColumn(colId?: string | null, options?: InsertColOptions): Promise<NewFieldInfo>;\n\n  /**\n   * Shows column (by adding a view field)\n   * @param col ColId or ColRef\n   * @param index Position to insert the column at\n   * @returns ViewField rowId\n   */\n  showColumn(col: number | string, index?: number): Promise<number>\n\n  /**\n   * Removes one or multiple fields.\n   * @param colRef\n   */\n  removeField(colRef: number | number[]): Promise<void>;\n}\n\nexport type WidgetMappedColumn = number | number[] | null;\nexport type WidgetColumnMapping = Record<string, WidgetMappedColumn>;\n\nexport interface CustomViewSectionDef {\n  /**\n   * The mode.\n   */\n  mode: modelUtil.KoSaveableObservable<\"url\" | \"plugin\">;\n  /**\n   * The url.\n   */\n  url: modelUtil.KoSaveableObservable<string | null>;\n  /**\n   * A widgetId, if available. Preferred to url.\n   * For bundled custom widgets, it is important to refer\n   * to them by something other than url, since url will\n   * vary with deployment, and it should be possible to move\n   * documents between deployments if they have compatible\n   * widgets available.\n   */\n  widgetId: modelUtil.KoSaveableObservable<string | null>;\n  /**\n    * Custom widget information. This is a record of what was\n    * in a custom widget manifest entry when the widget was\n    * configured. Its contents should not be relied on too much.\n    * In particular, any URL contained may come from an entirely\n    * different installation of Grist.\n    */\n  widgetDef: modelUtil.KoSaveableObservable<ICustomWidget | null>;\n  /**\n   * Custom widget options.\n   */\n  widgetOptions: modelUtil.KoSaveableObservable<Record<string, any> | null>;\n  /**\n   * Custom widget interaction options.\n   */\n  columnsMapping: modelUtil.KoSaveableObservable<WidgetColumnMapping | null>;\n  /**\n   * Access granted to url.\n   */\n  access: modelUtil.KoSaveableObservable<string>;\n  /**\n   * The plugin id.\n   */\n  pluginId: modelUtil.KoSaveableObservable<string>;\n  /**\n   * The section id.\n   */\n  sectionId: modelUtil.KoSaveableObservable<string>;\n  /**\n   * If set, render the widget after `grist.ready()`.\n   *\n   * This is used to defer showing a widget on initial load until it has finished\n   * applying the Grist theme.\n   */\n  renderAfterReady: modelUtil.KoSaveableObservable<boolean>;\n}\n\n/** Information about filters for a field or hidden column. */\nexport interface FilterInfo {\n  /** The section that's being filtered. */\n  viewSection: ViewSectionRec;\n  /** The field or column that's being filtered. (Field if column is visible.) */\n  fieldOrColumn: ViewFieldRec | ColumnRec;\n  /** Filter that applies to this field/column, if any. */\n  filter: modelUtil.CustomComputed<string>;\n  /** Whether this filter is pinned to the filter bar. */\n  pinned: modelUtil.CustomComputed<boolean>;\n  /** True if `filter` has a non-blank value. */\n  isFiltered: ko.PureComputed<boolean>;\n  /** True if `pinned` is true. */\n  isPinned: ko.PureComputed<boolean>;\n}\n\nexport interface Filter {\n  filter: string;\n  pinned: boolean;\n}\n\nexport function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void {\n  this.widgetType = this.parentKey as any;\n  this.viewFields = recordSet(this, docModel.viewFields, \"parentId\", { sortBy: \"parentPos\" });\n  this.linkedSections = recordSet(this, docModel.viewSections, \"linkSrcSectionRef\");\n\n  // All table columns associated with this view section, excluding any hidden helper columns.\n  this.columns = this.autoDispose(ko.pureComputed(() => this.table().visibleColumns()));\n  this.editingFormula = ko.pureComputed({\n    read: () => docModel.editingFormula(),\n    write: (val) => {\n      docModel.editingFormula(val);\n    },\n  });\n  const defaultOptions: ViewSectionOptions = {\n    verticalGridlines: true,\n    horizontalGridlines: true,\n    zebraStripes: false,\n    customView: \"\",\n    numFrozen: 0,\n  };\n  this.optionsObj = modelUtil.jsonObservable(this.options,\n    (obj: ViewSectionOptions) => defaults(obj || {}, defaultOptions));\n  this.shareOptionsObj = modelUtil.jsonObservable(this.shareOptions);\n\n  const customViewDefaults = {\n    mode: \"url\",\n    url: null,\n    widgetDef: null,\n    access: \"\",\n    pluginId: \"\",\n    sectionId: \"\",\n    renderAfterReady: false,\n  };\n  const customDefObj = modelUtil.jsonObservable(this.optionsObj.prop(\"customView\"),\n    (obj: any) => defaults(obj || {}, customViewDefaults));\n\n  this.customDef = {\n    mode: customDefObj.prop(\"mode\"),\n    url: customDefObj.prop(\"url\"),\n    widgetId: customDefObj.prop(\"widgetId\"),\n    widgetDef: customDefObj.prop(\"widgetDef\"),\n    widgetOptions: customDefObj.prop(\"widgetOptions\"),\n    columnsMapping: customDefObj.prop(\"columnsMapping\"),\n    access: customDefObj.prop(\"access\"),\n    pluginId: customDefObj.prop(\"pluginId\"),\n    sectionId: customDefObj.prop(\"sectionId\"),\n    renderAfterReady: customDefObj.prop(\"renderAfterReady\"),\n  };\n\n  this.selectedFields = ko.observable<any>([]);\n\n  // During schema change, some columns/fields might be disposed beyond our control.\n  const selectedColumns = this.autoDispose(ko.pureComputed(() => this.selectedFields()\n    .filter(f => !f.isDisposed())\n    .map(f => f.column())\n    .filter(c => !c.isDisposed())));\n  this.columnsBehavior = ko.pureComputed(() => {\n    const list = new Set(selectedColumns().map(c => c.behavior()));\n    return list.size === 1 ? list.values().next().value : \"mixed\";\n  });\n  this.columnsType = ko.pureComputed(() => {\n    const list = new Set(selectedColumns().map(c => c.type()));\n    return list.size === 1 ? list.values().next().value : \"mixed\";\n  });\n  this.columnsAllIsFormula = ko.pureComputed(() => {\n    return selectedColumns().every(c => c.isFormula());\n  });\n\n  this.activeCustomOptions = modelUtil.customValue(this.customDef.widgetOptions);\n\n  this.saveCustomDef = async () => {\n    await customDefObj.save();\n    this.activeCustomOptions.revert();\n  };\n\n  this.themeDef = modelUtil.fieldWithDefault(this.theme, \"form\");\n  this.chartTypeDef = modelUtil.fieldWithDefault(this.chartType, \"bar\");\n  this.view = refRecord(docModel.views, this.parentId);\n\n  this.table = refRecord(docModel.tables, this.tableRef);\n\n  // The user-friendly name of the table, which is the same as tableId for non-summary tables,\n  // and is 'tableId[groupByCols...]' for summary tables.\n  // Consist of 3 parts\n  // - TableId (or primary table id for summary tables) capitalized\n  // - Grouping description (table record contains this for summary tables)\n  // - Widget type description (if not grid)\n  // All concatenated separated by space.\n  this.defaultWidgetTitle = this.autoDispose(ko.pureComputed(() => {\n    const widgetTypeDesc = this.parentKey() !== \"record\" ?\n      `${getWidgetTypes(this.parentKey.peek() as any).getLabel()}` :\n      \"\";\n    const table = this.table();\n    return [\n      table.tableNameDef()?.toUpperCase(), // Due to ACL this can be null.\n      table.groupDesc(),\n      widgetTypeDesc,\n    ].filter(part => Boolean(part?.trim())).join(\" \");\n  }));\n  // Widget title.\n  this.titleDef = modelUtil.fieldWithDefault(this.title, this.defaultWidgetTitle);\n\n  // true if this record is its table's rawViewSection, i.e. a 'raw data view'\n  // in which case the UI prevents various things like hiding columns or changing the widget type.\n  this.isRaw = this.autoDispose(ko.pureComputed(() => this.table().rawViewSectionRef() === this.id()));\n\n  this.tableRecordCard = this.autoDispose(ko.pureComputed(() => this.table().recordCardViewSection()));\n  this.isRecordCard = this.autoDispose(ko.pureComputed(() =>\n    this.table().recordCardViewSectionRef() === this.id()));\n  this.disabled = modelUtil.fieldWithDefault(this.optionsObj.prop(\"disabled\"), false);\n  this.isTableRecordCardDisabled = this.autoDispose(ko.pureComputed(() => this.tableRecordCard().disabled() ||\n    this.table().summarySourceTable() !== 0));\n\n  this.isVirtual = this.autoDispose(ko.pureComputed(() => typeof this.id() === \"string\"));\n\n  this.borderWidthPx = ko.pureComputed(() => this.borderWidth() + \"px\");\n\n  this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec);\n\n  this._savedFilters = recordSet(this, docModel.filters, \"viewSectionRef\");\n\n  /**\n   * Unsaved client-side filters, keyed by original col ref. Currently only wiped when unsaved filters\n   * are applied or reverted.\n   *\n   * If saved filters exist for a col ref, unsaved filters take priority and are applied instead. This\n   * prevents disruption when changes are made to saved filters for the same field/column, but there\n   * may be some cases where we'd want to reset _unsavedFilters on some indirect change to the document.\n   *\n   * NOTE: See `filters`, where `_unsavedFilters` is merged with `savedFilters`.\n   */\n  this._unsavedFilters = new Map();\n\n  /**\n   * Filter information for all fields/columns in the section.\n   *\n   * Re-computed on changes to `savedFilters`, as well as any changes to `viewFields` or `columns`. Any\n   * unsaved filters saved in `_unsavedFilters` are applied on computation, taking priority over saved\n   * filters for the same field/column, if any exist.\n   */\n  this.filters = this.autoDispose(ko.computed(() => {\n    const savedFiltersByColRef = new Map(this._savedFilters().all().map(f => [f.colRef(), f]));\n    const viewFieldsByColRef = new Map(this.viewFields().all().map(f => [f.origCol().getRowId(), f]));\n\n    return this.columns().map((column) => {\n      const savedFilter = savedFiltersByColRef.get(column.origColRef());\n      // Initialize with a saved filter, if one exists. Otherwise, use a blank filter.\n      const filter = modelUtil.customComputed({\n        read: () => { return savedFilter ? savedFilter.activeFilter() : \"\"; },\n      });\n      const pinned = modelUtil.customComputed({\n        read: () => { return savedFilter ? savedFilter.pinned() : false; },\n      });\n\n      // If an unsaved filter exists, overwrite the filter with it.\n      const unsavedFilter = this._unsavedFilters.get(column.origColRef());\n      if (unsavedFilter) {\n        const { filter: f, pinned: p } = unsavedFilter;\n        if (f !== undefined) { filter(f); }\n        if (p !== undefined) { pinned(p); }\n      }\n\n      return {\n        viewSection: this,\n        filter,\n        pinned,\n        fieldOrColumn: viewFieldsByColRef.get(column.origColRef()) ?? column,\n        isFiltered: ko.pureComputed(() => filter() !== \"\"),\n        isPinned: ko.pureComputed(() => pinned()),\n      };\n    });\n  }));\n\n  // List of `filters` that have non-blank active filters.\n  this.activeFilters = Computed.create(this, use => use(this.filters).filter(f => use(f.isFiltered)));\n\n  // List of `activeFilters` that are pinned.\n  this.pinnedActiveFilters = Computed.create(this, use => use(this.activeFilters).filter(f => use(f.isPinned)));\n\n  // Helper metadata item which indicates whether any of the section's fields/columns have unsaved\n  // changes to their filters. (True indicates unsaved changes)\n  this.filterSpecChanged = Computed.create(this, (use) => {\n    return use(this.filters).some(col => !use(col.filter.isSaved) || !use(col.pinned.isSaved));\n  });\n\n  this.showNestedFilteringPopup = Observable.create(this, false);\n\n  // Save all filters of fields/columns in the section.\n  this.saveFilters = () => {\n    return docModel.docData.bundleActions(`Save all filters in ${this.titleDef()}`,\n      async () => {\n        const savedFiltersByColRef = new Map(this._savedFilters().all().map(f => [f.colRef(), f]));\n        const updatedFilters: [number, Filter][] = []; // Pairs of row ids and filters to update.\n        const removedFilterIds: number[] = []; // Row ids of filters to remove.\n        const newFilters: [number, Filter][] = []; // Pairs of column refs and filters to add.\n\n        for (const f of this.filters()) {\n          const { fieldOrColumn, filter, pinned } = f;\n          // Skip saved filters (i.e. filters whose local values are unchanged from server).\n          if (filter.isSaved() && pinned.isSaved()) { continue; }\n\n          const savedFilter = savedFiltersByColRef.get(fieldOrColumn.origCol().origColRef());\n          if (!savedFilter) {\n            // Never save blank filters. (This is primarily a sanity check.)\n            if (filter() === \"\") { continue; }\n\n            // Since no saved filter exists, we must add a new record to the filters table.\n            newFilters.push([fieldOrColumn.origCol().origColRef(), {\n              filter: filter(),\n              pinned: pinned(),\n            }]);\n          } else if (filter() === \"\") {\n            // Mark the saved filter for removal from the filters table.\n            removedFilterIds.push(savedFilter.id());\n          } else {\n            // Mark the saved filter for update in the filters table.\n            updatedFilters.push([savedFilter.id(), {\n              filter: filter(),\n              pinned: pinned(),\n            }]);\n          }\n        }\n\n        const actions: UserAction[] = [];\n\n        // Remove records of any deleted filters.\n        if (removedFilterIds.length > 0) {\n          actions.push([\"BulkRemoveRecord\", removedFilterIds]);\n        }\n\n        // Update existing filter records with new filter values.\n        if (updatedFilters.length > 0) {\n          actions.push([\"BulkUpdateRecord\",\n            updatedFilters.map(([id]) => id),\n            {\n              filter: updatedFilters.map(([, { filter }]) => filter),\n              pinned: updatedFilters.map(([, { pinned }]) => pinned),\n            },\n          ]);\n        }\n\n        // Add new filter records.\n        if (newFilters.length > 0) {\n          actions.push([\"BulkAddRecord\",\n            arrayRepeat(newFilters.length, null),\n            {\n              viewSectionRef: arrayRepeat(newFilters.length, this.id()),\n              colRef: newFilters.map(([colRef]) => colRef),\n              filter: newFilters.map(([, { filter }]) => filter),\n              pinned: newFilters.map(([, { pinned }]) => pinned),\n            },\n          ]);\n        }\n\n        if (actions.length > 0) {\n          await docModel.filters.sendTableActions(actions);\n        }\n\n        // Reset client filter state.\n        this.revertFilters();\n      },\n    );\n  };\n\n  // Revert all filters of fields/columns in the section.\n  this.revertFilters = () => {\n    this._unsavedFilters.clear();\n    this.filters().forEach((c) => {\n      c.filter.revert();\n      c.pinned.revert();\n    });\n  };\n\n  // Set `filter` for the field or column identified by `colRef`.\n  this.setFilter = (colRef: number, filter: Partial<Filter>) => {\n    this._unsavedFilters.set(colRef, { ...this._unsavedFilters.get(colRef), ...filter });\n    const filterInfo = this.filters().find(c => c.fieldOrColumn.origCol().origColRef() === colRef);\n    if (!filterInfo) { return; }\n\n    const { filter: newFilter, pinned: newPinned } = filter;\n    if (newFilter !== undefined) { filterInfo.filter(newFilter); }\n    if (newPinned !== undefined) { filterInfo.pinned(newPinned); }\n  };\n\n  // Revert the filter of the field or column identified by `colRef`.\n  this.revertFilter = (colRef: number) => {\n    this._unsavedFilters.delete(colRef);\n    const filterInfo = this.filters().find(c => c.fieldOrColumn.origCol().origColRef() === colRef);\n    if (!filterInfo) { return; }\n\n    filterInfo.filter.revert();\n    filterInfo.pinned.revert();\n  };\n\n  // Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.\n  this.activeSortJson = modelUtil.customValue(this.sortColRefs);\n\n  // This is an array (parsed from JSON) of colRefs (i.e. rowIds into the columns table), with a\n  // twist: a rowId may be positive or negative, for ascending or descending respectively.\n  // TODO: This method of ignoring columns which are deleted is inefficient and may cause conflicts\n  //  with sharing.\n  this.activeSortSpec = modelUtil.jsonObservable(this.activeSortJson, (obj: Sort.SortSpec | null) => {\n    const tableId = this.tableRef();\n    return (obj || []).filter((sortRef: Sort.ColSpec) => {\n      const colModel = docModel.columns.getRowModel(Sort.getColRef(sortRef) as number /* HACK: for virtual tables */);\n      return !colModel._isDeleted() && colModel.getRowId() && colModel.parentId() === tableId;\n    });\n  });\n\n  // Modified sort spec to take into account any active display columns.\n  this.activeDisplaySortSpec = this.autoDispose(ko.computed(() => {\n    return this.activeSortSpec().map((directionalColRef) => {\n      const colRef = Sort.getColRef(directionalColRef);\n      const field = this.viewFields().all().find(f => f.column().origColRef() === colRef);\n      const effectiveColRef = field ? field.displayColRef() : colRef;\n      return Sort.swapColRef(directionalColRef, effectiveColRef);\n    });\n  }));\n\n  this.autoDispose(this.columns.subscribe((columns) => {\n    if (this.activeSortJson.isSaved.peek() || !columns.length) {\n      return;\n    }\n    const columnRefs = new Set(columns.map(c => c.origColRef()));\n    const parsed: Sort.SortSpec = safeJsonParse(this.activeSortJson.peek(), []);\n    const cleaned = parsed.filter((sortRef: Sort.ColSpec) => {\n      const colRef = Sort.getColRef(sortRef) as number;\n      return columnRefs.has(colRef);\n    });\n    // NOTE: we are modifying the observable here, which is not a good practice, and can lead to\n    // infinite loops in the future.\n    this.activeSortSpec(cleaned);\n  }));\n\n  // Evaluates to an array of column models, which are not referenced by anything in viewFields.\n  this.hiddenColumns = this.autoDispose(ko.pureComputed(() => {\n    const included = new Set(this.viewFields().all().map(f => f.column().origColRef()));\n    return this.columns().filter(c => !included.has(c.getRowId()));\n  }));\n\n  this.hasFocus = ko.pureComputed({\n    // Read may occur for recently disposed sections, must check condition first.\n    read: () => !this.isDisposed() && this.view().activeSectionId() === this.id(),\n    write: (val) => { this.view().activeSectionId(val ? this.id() : 0); },\n  });\n  this.hasVisibleFocus = ko.pureComputed(() => {\n    if (this.isDisposed()) {\n      return false;\n    }\n    const region = this.view().focusedRegionState();\n    return this.hasFocus() && (region === \"in\" || region === \"related\");\n  });\n  this.hasRegionFocus = ko.pureComputed(() => this.hasFocus() && this.view().focusedRegionState() === \"in\");\n\n  // Section-linking affects this table if linkSrcSection is set. The controller value of the\n  // link is the value of srcCol at activeRowId of linkSrcSection, or activeRowId itself when\n  // srcCol is unset. If targetCol is set, we filter for all rows whose targetCol is equal to\n  // the controller value. Otherwise, the controller value determines the rowId of the cursor.\n  this.linkSrcSection = refRecord(docModel.viewSections, this.linkSrcSectionRef);\n  this.linkSrcCol = refRecord(docModel.columns, this.linkSrcColRef);\n  this.linkTargetCol = refRecord(docModel.columns, this.linkTargetColRef);\n\n  this.activeRowId = ko.observable<UIRowId | null>(null);\n  this.lastCursorEdit = ko.observable<SequenceNum>(SequenceNEVER);\n\n  this._linkingState = Holder.create(this);\n  this.linkingState = this.autoDispose(ko.pureComputed(() => {\n    if (!this.linkSrcSectionRef()) {\n      // This view section isn't selected by anything.\n      return null;\n    }\n    try {\n      const config = new LinkConfig(this);\n      return LinkingState.create(this._linkingState, docModel, config);\n    } catch (err) {\n      console.warn(err);\n      // Dispose old LinkingState in case creating the new one failed.\n      this._linkingState.clear();\n      return null;\n    }\n  }));\n\n  this.linkingFilter = this.autoDispose(ko.pureComputed(() => {\n    return this.linkingState()?.filterColValues?.() || EmptyFilterColValues;\n  }));\n\n  // If the view instance for this section is instantiated, it will be accessible here.\n  this.viewInstance = ko.observable<BaseView | null>(null);\n\n  // Describes the most recent cursor position in the section.\n  this.lastCursorPos = {\n    rowIndex: 0,\n    fieldIndex: 0,\n  };\n\n  // Describes the most recent scroll position.\n  this.lastScrollPos = {\n    rowIndex: 0, // Used for scrolly sections. Indicates the index of the first visible row.\n    offset: 0, // Pixel distance past the top of row indicated by rowIndex.\n    scrollLeft: 0,  // Used for grid sections. Indicates the scrollLeft value of the scroll pane.\n  };\n\n  this.disableAddRemoveRows = ko.pureComputed(() =>\n    this.overrideDisableAddRemoveRows() ?? this.table().disableAddRemoveRows());\n\n  this.isSorted = ko.pureComputed(() => this.activeSortSpec().length > 0);\n  this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort());\n\n  // Number of frozen columns\n  this.rawNumFrozen = modelUtil.customValue(this.optionsObj.prop(\"numFrozen\"));\n  // Number for frozen columns to display\n  this.numFrozen = ko.pureComputed(() =>\n    Math.max(\n      0,\n      Math.min(\n        this.rawNumFrozen() || 0,\n        this.viewFields().all().length - 1,\n      ),\n    ),\n  );\n\n  // Observables used for styling rows with limited heights.\n  this.rowHeight = Computed.create<number>(this, use => Number(use(this.optionsObj).rowHeight) || 0);\n  this.rowHeightUniform = Computed.create(this, use =>\n    Boolean(use(this.rowHeight) && use(this.optionsObj).rowHeightUniform));\n\n  // Subscription to trigger row resizing whenever row-height settings change.\n  this.autoDispose(subscribe(this.rowHeight, this.rowHeightUniform, () => this.events.trigger(\"rowHeightChange\")));\n\n  this.hasCustomOptions = ko.observable(false);\n  this.desiredAccessLevel = ko.observable<AccessLevel | null>(null);\n  this.columnsToMap = ko.observable<ColumnsToMap | null>(null);\n  // Calculate mapped columns for Custom Widget.\n  this.mappedColumns = ko.pureComputed(() => {\n    // First check if widget has requested a custom column mapping and\n    // if we have a saved configuration.\n    const request = this.columnsToMap();\n    const mapping = this.customDef.columnsMapping();\n    if (!request || !mapping) {\n      return null;\n    }\n    // Convert simple column expressions (widget can just specify a name of a column) to a rich column definition.\n    const columnsToMap = request.map(r => new ColumnToMapImpl(r));\n    const result: WidgetColumnMap = {};\n    // Prepare map of existing column, will need this for translating colRefs to colIds.\n    const colMap = new Map(this.columns().map(f => [f.id.peek(), f]));\n    for (const widgetCol of columnsToMap) {\n      // Start with marking this column as not mapped.\n      result[widgetCol.name] = widgetCol.allowMultiple ? [] : null;\n      const mappedCol = mapping[widgetCol.name];\n      if (!mappedCol) {\n        continue;\n      }\n      if (widgetCol.allowMultiple) {\n        // We expect a list of colRefs be mapped;\n        if (!Array.isArray(mappedCol)) { continue; }\n        const columns = mappedCol\n          // Remove all colRefs saved but deleted\n          .filter(cId => colMap.has(cId))\n          // And those with wrong type.\n          .filter(cId => widgetCol.canByMapped(colMap.get(cId)!.pureType()))\n          .map(cId => colMap.get(cId)!);\n\n        // Make a subscription to get notified when widget options are changed.\n        columns.forEach(c => c.widgetOptions());\n\n        result[widgetCol.name] = columns.map(c => c.colId());\n      } else {\n        // Widget expects a single value and existing column\n        if (Array.isArray(mappedCol) || !colMap.has(mappedCol)) { continue; }\n        const selectedColumn = colMap.get(mappedCol)!;\n        // Make a subscription to the column to get notified when it changes.\n        void selectedColumn.widgetOptions();\n        result[widgetCol.name] = widgetCol.canByMapped(selectedColumn.pureType()) ? selectedColumn.colId() : null;\n      }\n    }\n    return result;\n  });\n\n  this.allowSelectBy = ko.observable(false);\n  this.selectedRows = ko.observable(null as number[] | null);\n  this.selectedRowsActive = this.autoDispose(ko.pureComputed(() => this.selectedRows() !== null));\n\n  this.tableId = this.autoDispose(ko.pureComputed(() => this.table().tableId()));\n  const rawSection = this.autoDispose(ko.pureComputed(() => this.table().rawViewSection()));\n  this.rulesList = modelUtil.savingComputed({\n    read: () => rawSection().rules(),\n    write: (setter, val) => setter(rawSection().rules, val),\n  });\n  this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => rawSection().rules()));\n  this.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId()));\n  this.rulesStyles = modelUtil.savingComputed({\n    read: () => rawSection().optionsObj.prop(\"rulesOptions\")() ?? [],\n    write: (setter, val) => setter(rawSection().optionsObj.prop(\"rulesOptions\"), val),\n  });\n  this.hasRules = ko.pureComputed(() => this.rulesCols().length > 0);\n  this.addEmptyRule = async () => {\n    const action = [\n      \"AddEmptyRule\",\n      this.tableId.peek(),\n      null,\n      null,\n    ];\n    await docModel.docData.sendAction(action, `Update rules for ${this.table.peek().tableId.peek()}`);\n  };\n\n  this.removeRule = (index: number) => removeRule(docModel, this, index);\n\n  this.isCollapsed = this.autoDispose(ko.pureComputed(() => {\n    const list = this.view().activeCollapsedSections();\n    return list.includes(this.id());\n  }));\n\n  this.insertColumn = async (colId: string | null = null, options: InsertColOptions = {}) => {\n    const { colInfo = {}, index = this.viewFields().peekLength } = options;\n    const parentPos = fieldInsertPositions(this.viewFields(), index)[0];\n    const action = [\"AddColumn\", colId, {\n      ...colInfo,\n      _position: parentPos,\n    }];\n    let newColInfo: NewFieldInfo;\n    await docModel.docData.bundleActions(\"Insert column\", async () => {\n      newColInfo = await docModel.dataTables[this.tableId.peek()].sendTableAction(action);\n      if (!this.isRaw.peek() && !this.isRecordCard.peek()) {\n        const fieldInfo = {\n          colRef: newColInfo.colRef,\n          parentId: this.id.peek(),\n          parentPos,\n        };\n        const fieldRef = await docModel.viewFields.sendTableAction([\"AddRecord\", null, fieldInfo]);\n        newColInfo.fieldRef = fieldRef;\n      }\n    }, { nestInActiveBundle: options.nestInActiveBundle });\n    return newColInfo!;\n  };\n\n  this.showColumn = async (col: string | number, index = this.viewFields().peekLength) => {\n    const parentPos = fieldInsertPositions(this.viewFields(), index, 1)[0];\n    const colRef = typeof col === \"string\" ?\n      this.table().columns().all().find(c => c.colId() === col)?.getRowId() :\n      col;\n    const colInfo = {\n      colRef,\n      parentId: this.id.peek(),\n      parentPos,\n    };\n    return await docModel.viewFields.sendTableAction([\"AddRecord\", null, colInfo]);\n  };\n\n  this.removeField = async (fieldRef: number | number[]) => {\n    if (Array.isArray(fieldRef)) {\n      const action = [\"BulkRemoveRecord\", fieldRef];\n      await docModel.viewFields.sendTableAction(action);\n    } else {\n      const action = [\"RemoveRecord\", fieldRef];\n      await docModel.viewFields.sendTableAction(action);\n    }\n  };\n\n  this.hideViewMenu = ko.observable(false);\n  this.canRename = ko.observable<boolean | undefined>(undefined);\n  this.canExpand = ko.observable<boolean>(true);\n  this.overrideDisableAddRemoveRows = ko.observable<boolean | undefined>(undefined);\n}\n"
  },
  {
    "path": "app/client/models/errors.ts",
    "content": "import { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport * as log from \"app/client/lib/log\";\nimport { INotification, INotifyOptions, MessageType, Notifier } from \"app/client/models/NotifyModel\";\nimport { ErrorTooltips } from \"app/client/ui/GristTooltips\";\nimport { ApiErrorDetails } from \"app/common/ApiError\";\nimport { fetchFromHome, pageHasHome } from \"app/common/urlUtils\";\n\nimport isError from \"lodash/isError\";\nimport pick from \"lodash/pick\";\n\nconst G = getBrowserGlobals(\"document\", \"window\");\n\nlet _notifier: Notifier;\n\n/**\n * Doesn't show or trigger any UI when thrown. Use it when you will handle it yourself, but\n * need to stop any futher actions from the app. Currently only used in the model that tries\n * to react in response of UNIQUE reference constraint validation.\n */\nexport class MutedError extends Error {\n\n}\n\nexport class UserError extends Error {\n  public name: string = \"UserError\";\n  public key?: string;\n  constructor(message: string, options: { key?: string } = {}) {\n    super(message);\n    this.key = options.key;\n  }\n}\n\n/**\n * This error causes Notifier to show the message with an upgrade link.\n */\nexport class NeedUpgradeError extends Error {\n  public name: string = \"NeedUpgradeError\";\n  constructor(message: string = \"This feature is not available in your plan\") {\n    super(message);\n  }\n}\n\n/**\n * Set the global Notifier instance used by subsequent reportError calls.\n */\nexport function setErrorNotifier(notifier: Notifier) {\n  _notifier = notifier;\n}\n\n// Returns application errors collected by NotifyModel. Used in tests.\nexport function getAppErrors(): string[] {\n  return _notifier.getFullAppErrors().map(e => e.error.message);\n}\n\n/**\n * Shows normal notification without any styling or icon.\n */\nexport function reportMessage(msg: MessageType, options?: Partial<INotifyOptions>): INotification | undefined {\n  if (_notifier && !_notifier.isDisposed()) {\n    return _notifier.createUserMessage(msg, {\n      ...options,\n    });\n  }\n}\n\n/**\n * Shows warning toast notification (with yellow styling), and log to server and to console. Pass\n * {level: 'error'} for same behavior with adjusted styling.\n */\nexport function reportWarning(msg: string, options?: Partial<INotifyOptions>) {\n  options = { level: \"warning\", ...options };\n  log.warn(`${options.level}: `, msg);\n  logError(msg);\n  return reportMessage(msg, options);\n}\n\n/**\n * Shows success toast notification (with green styling).\n */\nexport function reportSuccess(msg: MessageType, options?: Partial<INotifyOptions>) {\n  return reportMessage(msg, { level: \"success\", ...options });\n}\n\nfunction isUnhelpful(err: Error | string, ev: ErrorEvent) {\n  if (ev.message === \"ResizeObserver loop completed with undelivered notifications.\") {\n    // Sometimes on Chrome, changing the browser zoom level causes this benign error to\n    // be thrown. It seems to only appear on the Access Rules page, and may have something\n    // to do with Ace. In any case, the error seems harmless and it isn't particularly helpful,\n    // so we don't report it more than once. A quick Google search for the error message\n    // produces many reports, although at the time of this comment, none seem to be related\n    // to Ace, so there's a chance something else is amiss.\n    return true;\n  }\n\n  if (!ev.filename && !ev.lineno && ev.message?.toLowerCase().includes(\"script error\")) {\n    // Errors from cross-origin scripts, and some add-ons, show up as unhelpful sanitized \"Script\n    // error.\" messages. We want to know if they occur, but they are useless to the user, and useless\n    // to report multiple times. We report them just once to the server.\n    //\n    // In particular, this addresses a bug on iOS version of Firefox, which produces uncaught\n    // sanitized errors on load AND on attempts to report them, leading to a loop that hangs the\n    // browser. Reporting just once is a sufficient workaround.\n    return true;\n  }\n\n  if (typeof err === \"object\" && typeof err?.stack === \"string\" && err.stack.includes(\"chrome-extension://\")) {\n    // Sometimes we can tell when the error is really from a browser extension rather than Grist.\n    // These usually don't interfere, and the report of the error is more disruptive than the\n    // error itself.\n    return true;\n  }\n\n  return false;\n}\n\nconst unhelpfulErrors = new Set<string>();\n\n/**\n * Report an error to the user using the global Notifier instance. If the argument is a UserError\n * or an error with a status in the 400 range, it indicates a user error. Otherwise, it's an\n * application error, which the user can report to us as a bug.\n *\n * Not all errors will be shown as an error toast, depending on the content of the error\n * this function might show a simple toast message.\n */\nexport function reportError(err: Error | string, ev?: ErrorEvent): void {\n  if (err instanceof MutedError) {\n    return;\n  }\n  log.error(`ERROR:`, err);\n  if (String(err).match(/GristWSConnection disposed/)) {\n    // This error can be emitted while a page is reloaded, and isn't worth reporting.\n    return;\n  }\n  if (ev && isUnhelpful(err, ev)) {\n    // Report just once to the server. There is little point reporting subsequent such errors once\n    // we know they happen, since each individual error has no useful information.\n    if (!unhelpfulErrors.has(ev.message)) {\n      logError(err);\n      unhelpfulErrors.add(ev.message);\n    }\n    return;\n  }\n\n  logError(err);\n  if (_notifier && !_notifier.isDisposed()) {\n    if (!isError(err)) {\n      err = new Error(String(err));\n    }\n\n    const details: ApiErrorDetails | undefined = (err as any).details;\n    const code: unknown = (err as any).code;\n    const status: unknown = (err as any).status;\n    const message = (details?.userError) || err.message;\n    if (details?.limit) {\n      // This is a notification about reaching a plan limit. Key prevents showing multiple\n      // notifications for the same type of limit.\n      const options: Partial<INotifyOptions> = {\n        title: \"Reached plan limit\",\n        key: `limit:${details.limit.quantity || message}`,\n        actions: details.tips?.some(t => t.action === \"manage\") ? [\"manage\"] : [\"upgrade\"],\n      };\n      if (details.tips?.some(tip => tip.action === \"add-members\")) {\n        // When adding members would fix a problem, give more specific advice.\n        options.title = \"Add users as team members first\";\n        options.actions = [];\n      }\n      // Show the error as a message\n      _notifier.createUserMessage(message, options);\n    } else if (err.name === \"UserError\" || (typeof status === \"number\" && status >= 400 && status < 500)) {\n      // This is explicitly a user error, or one in the \"Client Error\" range, so treat it as user\n      // error rather than a bug. Using message as the key causes same-message notifications to\n      // replace previous ones rather than accumulate.\n      const options: Partial<INotifyOptions> = { key: (err as UserError).key || message };\n      options.memos = details?.memos;\n      if (details?.tips?.some(tip => tip.action === \"ask-for-help\")) {\n        options.actions = [\"ask-for-help\"];\n      }\n      _notifier.createUserMessage(message, options);\n    } else if (err.name === \"NeedUpgradeError\") {\n      // Show the error as a message\n      _notifier.createUserMessage(err.message, { actions: [\"upgrade\"], key: \"NEED_UPGRADE\" });\n    } else if (code === \"AUTH_NO_EDIT\" || code === \"ACL_DENY\") {\n      // Show the error as a message\n      _notifier.createUserMessage(err.message, { key: code, memos: details?.memos });\n    } else if (message.match(/\\[Sandbox\\].*between formula and data/)) {\n      // Show nicer error message for summary tables.\n      _notifier.createUserMessage(ErrorTooltips.summaryFormulas, { key: \"summary\" });\n    } else {\n      // If we don't recognize it, consider it an application error (bug) that the user should be\n      // able to report.\n      if (details?.userError) {\n        // If we have user friendly error, show it instead.\n        _notifier.createAppError(Error(details.userError));\n      } else {\n        _notifier.createAppError(err);\n      }\n    }\n  }\n}\n\n/**\n * Set up error handlers, to report uncaught errors and rejections. These are logged to the\n * console and displayed as notifications, when the notifications UI is set up.\n *\n * koUtil, if passed, will enable reporting errors from the evaluation of knockout computeds. It\n * is passed-in as an argument to avoid creating a dependency when knockout isn't used otherwise.\n */\nexport function setUpErrorHandling(doReportError = reportError, koUtil?: any) {\n  if (koUtil) {\n    koUtil.setComputedErrorHandler((err: any) => doReportError(err));\n  }\n\n  // Report also uncaught JS errors and unhandled Promise rejections.\n  G.window.addEventListener(\"error\", (ev: ErrorEvent) => doReportError(ev.error || ev.message, ev));\n\n  G.window.addEventListener(\"unhandledrejection\", (ev: any) => {\n    const reason = ev.reason || (ev.detail?.reason);\n    doReportError(reason || ev);\n  });\n\n  // Expose globally a function to report a notification. This is for compatibility with old UI;\n  // in new UI, it renders messages as user errors. New code should use `reportError()` instead.\n  G.window.gristNotify = (message: string) => doReportError(new UserError(message));\n\n  // Expose the function used in tests to get a list of errors in the notifier.\n  G.window.getAppErrors = getAppErrors;\n}\n\n/**\n * Send information about a problem to the backend.  This is crude; there is some\n * over-logging (regular errors such as access rights or account limits) and\n * under-logging (javascript errors during startup might never get reported).\n */\nexport function logError(error: Error | string) {\n  if (!pageHasHome()) { return; }\n  const docId = G.window.gristDocPageModel?.currentDocId?.get();\n  fetchFromHome(\"/api/log\", {\n    method: \"POST\",\n    body: JSON.stringify({\n      // Errors don't stringify, so pick out properties explicitly for errors.\n      event: (error instanceof Error) ? pick(error, Object.getOwnPropertyNames(error)) : error,\n      docId,\n      page: G.window.location.href,\n      browser: pick(G.window.navigator, [\"language\", \"platform\", \"userAgent\"]),\n    }),\n    credentials: \"include\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"X-Requested-With\": \"XMLHttpRequest\",\n    },\n  }).catch((e) => {\n    // There ... isn't much we can do about this.\n    console.warn(\"Failed to log event\", e);\n  });\n}\n"
  },
  {
    "path": "app/client/models/features.ts",
    "content": "import { localStorageJsonObs } from \"app/client/lib/localStorageObs\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Observable } from \"grainjs\";\n\nlet _PERMITTED_CUSTOM_WIDGETS: Observable<string[]> | undefined;\n\nexport function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {\n  if (!_PERMITTED_CUSTOM_WIDGETS) {\n    _PERMITTED_CUSTOM_WIDGETS =\n      localStorageJsonObs(\"PERMITTED_CUSTOM_WIDGETS\", getGristConfig().permittedCustomWidgets || []);\n  }\n  return _PERMITTED_CUSTOM_WIDGETS;\n}\n"
  },
  {
    "path": "app/client/models/gristConfigCache.ts",
    "content": "/**\n * When app.html is fetched, the results for the API calls for getDoc() and getWorker() are\n * embedded into the page using window.gristConfig object. When making these calls on the client,\n * we check gristConfig to see if we can use these cached values.\n *\n * Usage is simply:\n *  getDoc(api, docId)\n *  getWorker(api, assignmentId)\n *\n * The cached value is used once only (and reset in gristConfig) and only if marked with a recent\n * timestamp. This optimizes the case of loading the page. On subsequent use, these calls will\n * translate to the usual api.getDoc(), api.getWorker() calls.\n */\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { getWeakestRole } from \"app/common/roles\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { Document, UserAPI } from \"app/common/UserAPI\";\n\nconst MaxGristConfigAgeMs = 5000;\n\nexport async function getDoc(api: UserAPI, docId: string): Promise<Document> {\n  const value = findAndResetInGristConfig(\"getDoc\", docId);\n  const result = await (value || api.getDoc(docId));\n  const mode = urlState().state.get().mode;\n  if (mode === \"view\") {\n    // This mode will be honored by the websocket; here we make sure the rest of the\n    // client knows about it too.\n    result.access = getWeakestRole(result.access, \"viewers\");\n  }\n  return result;\n}\n\nexport async function getWorker(api: UserAPI, assignmentId: string): Promise<string> {\n  const value = findAndResetInGristConfig(\"getWorker\", assignmentId);\n  return value || api.getWorker(assignmentId);\n}\n\ntype CallType = \"getDoc\" | \"getWorker\";\n\nfunction findAndResetInGristConfig(method: \"getDoc\", id: string): Document | null;\nfunction findAndResetInGristConfig(method: \"getWorker\", id: string): string | null;\nfunction findAndResetInGristConfig(method: CallType, id: string): any {\n  const gristConfig = getGristConfig();\n  const methodCache = gristConfig[method];\n  if (!methodCache?.[id]) {\n    console.log(`gristConfigCache ${method}[${id}]: not found`);\n    return null;\n  }\n  // Ignores difference between client and server timestamps, but doing better seems difficult.\n  const timeSinceServer = Date.now() - gristConfig.timestampMs;\n  if (timeSinceServer >= MaxGristConfigAgeMs) {\n    console.log(`gristConfigCache ${method}[${id}]: ${gristConfig.timestampMs} is stale (${timeSinceServer})`);\n    return null;\n  }\n  const value = methodCache[id];\n  delete methodCache[id];         // To be used only once.\n  console.log(`gristConfigCache ${method}[${id}]: found and deleted value`, value);\n  return value;\n}\n"
  },
  {
    "path": "app/client/models/gristUrlState.ts",
    "content": "/**\n * This module provides a urlState() function returning a singleton UrlState, which represents\n * Grist application state as encoded into a URL, and navigation functions.\n *\n * For example, the current org is available as a value or as an observable:\n *\n *    urlState().state.get().org\n *    computed((use) => use(urlState().state).org);\n *\n * Creating a link which has an href but changes state without reloading page is possible with:\n *\n *    dom('a', urlState().setLinkUrl({ws: 10}), \"...\")\n *\n * Grist URLs have the form:\n *    <org-base>/\n *    <org-base>/ws/<ws>/\n *    <org-base>/doc/<doc>[/p/<docPage>]\n *\n * where <org-base> depends on whether subdomains are in use, i.e. one of:\n *    <org>.getgrist.com\n *    localhost:8080/o/<org>\n *\n * Note that the form of URLs depends on the settings in window.gristConfig object.\n */\nimport { unsavedChanges } from \"app/client/components/UnsavedChanges\";\nimport { hooks } from \"app/client/Hooks\";\nimport { UrlState } from \"app/client/lib/UrlState\";\nimport { decodeUrl, encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState } from \"app/common/gristUrls\";\nimport { Document } from \"app/common/UserAPI\";\nimport { CellValue } from \"app/plugin/GristData\";\n\nimport isEmpty from \"lodash/isEmpty\";\nimport isEqual from \"lodash/isEqual\";\n\n/**\n * Returns a singleton UrlState object, initializing it on first use.\n */\nexport function urlState(): UrlState<IGristUrlState> {\n  return _urlState || (_urlState = new UrlState(window, new UrlStateImpl(window as any)));\n}\nlet _urlState: UrlState<IGristUrlState> | undefined;\n\n/**\n * Returns url parameters appropriate for the specified document.\n *\n * In addition to setting `doc` and `slug`, it sets additional parameters\n * from `params` if any are supplied.\n */\nexport function docUrl(doc: Document): IGristUrlState {\n  const state: IGristUrlState = {\n    doc: doc.urlId || doc.id,\n    slug: getSlugIfNeeded(doc),\n  };\n\n  return state;\n}\n\n// Returns the home page for the current org.\nexport function getMainOrgUrl(): string { return urlState().makeUrl({}); }\n\n// When on a document URL, returns the URL with just the doc ID, omitting other bits (like page).\nexport function getCurrentDocUrl(): string { return urlState().makeUrl({ docPage: undefined }); }\n\n/**\n * Implements the interface expected by UrlState. It is only exported for the sake of tests; the\n * only public interface is the urlState() accessor.\n */\nexport class UrlStateImpl {\n  constructor(private _window: { gristConfig?: Partial<GristLoadConfig> }) {}\n\n  /**\n   * The actual serialization of a url state into a URL. The URL has the form\n   *    <org-base>/\n   *    <org-base>/ws/<ws>/\n   *    <org-base>/doc/<doc>[/p/<docPage>]\n   *    <org-base>/doc/<doc>[/m/fork][/p/<docPage>]\n   *\n   * where <org-base> depends on whether subdomains are in use, e.g.\n   *    <org>.getgrist.com\n   *    localhost:8080/o/<org>\n   */\n  public encodeUrl(state: IGristUrlState, baseLocation: Location | URL): string {\n    const gristConfig = this._window.gristConfig || {};\n    return encodeUrl(gristConfig, state, baseLocation, {\n      tweaks: hooks.urlTweaks,\n    });\n  }\n\n  /**\n   * Parse a URL location into an IGristUrlState object. See encodeUrl() documentation.\n   */\n  public decodeUrl(location: Location | URL): IGristUrlState {\n    const gristConfig = this._window.gristConfig || {};\n    return decodeUrl(gristConfig, location, {\n      tweaks: hooks.urlTweaks,\n    });\n  }\n\n  /**\n   * Updates existing state with new state, with attention to Grist-specific meanings.\n   * E.g. setting 'docPage' will reuse previous 'doc', but setting 'org' or 'ws' will ignore it.\n   */\n  public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState {\n    const keepState =\n      newState.org ||\n      newState.ws ||\n      newState.homePage ||\n      newState.doc ||\n      isEmpty(newState) ||\n      newState.account ||\n      newState.billing ||\n      newState.activation ||\n      newState.auditLogs ||\n      newState.welcome ||\n      newState.adminPanel ?\n        prevState.org ?\n          { org: prevState.org } :\n          {} :\n        prevState;\n    return { ...keepState, ...newState };\n  }\n\n  /**\n   * The account page, billing pages, and doc-specific pages for now require a page load.\n   * TODO: Make it so doc pages do NOT require a page load, since we are actually serving the same\n   * single-page app for home and for docs, and should only need a reload triggered if it's\n   * a matter of DocWorker requiring a different version (e.g. /v/OTHER/doc/...).\n   */\n  public needPageLoad(prevState: IGristUrlState, newState: IGristUrlState): boolean {\n    // If we have an API URL we can't use it to switch the state, so we need a page load.\n    if (newState.api || prevState.api) { return true; }\n\n    const gristConfig = this._window.gristConfig || {};\n    const orgReload = prevState.org !== newState.org;\n    // Reload when moving to/from a document or between doc and non-doc.\n    const docReload = prevState.doc !== newState.doc;\n    // Reload when moving to/from the account page.\n    const accountReload = Boolean(prevState.account) !== Boolean(newState.account);\n    // Reload when moving to/from a billing page.\n    const billingReload = Boolean(prevState.billing) !== Boolean(newState.billing);\n    // Reload when moving to/from an activation page.\n    const activationReload = Boolean(prevState.activation) !== Boolean(newState.activation);\n    // Reload when moving to/from the audit logs page.\n    const auditLogsReload = Boolean(prevState.auditLogs) !== Boolean(newState.auditLogs);\n    // Reload when moving to/from a welcome page.\n    const welcomeReload = Boolean(prevState.welcome) !== Boolean(newState.welcome);\n    // Reload when link keys change, which changes what the user can access\n    const linkKeysReload = !isEqual(prevState.params?.linkParameters, newState.params?.linkParameters);\n    // Always reload on login pages.\n    const loginReload = prevState.login || newState.login;\n    // Reload when moving to/from the support Grist page.\n    const adminPanelReload = Boolean(prevState.adminPanel) !== Boolean(newState.adminPanel);\n    return Boolean(\n      orgReload ||\n      accountReload ||\n      billingReload ||\n      activationReload ||\n      auditLogsReload ||\n      gristConfig.errPage ||\n      docReload ||\n      welcomeReload ||\n      linkKeysReload ||\n      loginReload ||\n      adminPanelReload,\n    );\n  }\n\n  /**\n   * Complete outstanding work before changes that would destroy page state, e.g. if there are\n   * edits to be saved.\n   */\n  public async delayPushUrl(prevState: IGristUrlState, newState: IGristUrlState): Promise<void> {\n    if (newState.docPage !== prevState.docPage) {\n      return unsavedChanges.saveChanges();\n    }\n  }\n}\n\n/**\n * Given value like `foo bar baz`, constructs URL by checking if `baz` is a valid URL and,\n * if not, prepending `https://`.\n */\nexport function constructUrl(value: CellValue): string {\n  if (typeof value !== \"string\") {\n    return \"\";\n  }\n  const url = value.slice(value.lastIndexOf(\" \") + 1);\n  try {\n    // Try to construct a valid URL\n    return (new URL(url)).toString();\n  } catch (e) {\n    // Not a valid URL, so try to prefix it with https\n    return \"https://\" + url;\n  }\n}\n\n/**\n * If urlValue contains a URL to the current document that can be navigated to without a page reload,\n * returns a parsed IGristUrlState that can be passed to urlState().pushState() to do that navigation.\n * Otherwise, returns null.\n */\nexport function sameDocumentUrlState(urlValue: CellValue): IGristUrlState | null {\n  const urlString = constructUrl(urlValue);\n  let url: URL;\n  try {\n    url = new URL(urlString);\n  } catch {\n    return null;\n  }\n  const oldOrigin = window.location.origin;\n  const newOrigin = url.origin;\n  if (oldOrigin !== newOrigin) {\n    return null;\n  }\n\n  const urlStateImpl = new UrlStateImpl(window as any);\n  const result = urlStateImpl.decodeUrl(url);\n  if (urlStateImpl.needPageLoad(urlState().state.get(), result)) {\n    return null;\n  } else {\n    return result;\n  }\n}\n"
  },
  {
    "path": "app/client/models/homeUrl.ts",
    "content": "import { urlState } from \"app/client/models/gristUrlState\";\nimport { GristLoadConfig } from \"app/common/gristUrls\";\n\n/**\n * If we don't know what the home URL is, the top level of the site\n * we are on may work. This should always work for single-server installs\n * that don't encode organization information in domains. Even for other\n * cases, this should be a good enough home URL for many purposes, it\n * just may still have some organization information encoded in it from\n * the domain that could influence results that might be supposed to be\n * organization-neutral.\n */\nexport function getFallbackHomeUrl(): string {\n  const { host, protocol } = window.location;\n  return `${protocol}//${host}`;\n}\n\n/**\n * Get the official home URL sent to us from the back end.\n */\nexport function getConfiguredHomeUrl(): string {\n  const gristConfig: GristLoadConfig | undefined = window.gristConfig;\n  return gristConfig?.homeUrl || getFallbackHomeUrl();\n}\n\n/**\n * Get the home URL, using fallback on the admin case and in the\n * single-domain case case.\n */\nexport function getPreferredHomeUrl(): string | undefined {\n  const gristUrl = urlState().state.get();\n  const gristConfig: GristLoadConfig | undefined = window.gristConfig;\n  if (gristUrl.adminPanel || gristConfig?.serveSameOrigin) {\n    // On the admin panel, we should not trust configuration much,\n    // since we want the user to be able to access it to diagnose\n    // problems with configuration. So we access the API via the\n    // site we happen to be on rather than anything configured on\n    // the back end.\n    //\n    // We can also do this in the common self-hosted case of a single\n    // domain, no orgs encoded in subdomains.\n    //\n    // Couldn't we just always do this? Maybe! It could require\n    // adjustments for calls that are meant to be site-neutral if the\n    // domain has an org encoded in it. But that's a small price to\n    // pay. Grist Labs uses a setup where api calls go to a dedicated\n    // domain distinct from all other sites, but there's no particular\n    // advantage to it.\n    return getFallbackHomeUrl();\n  }\n  return getConfiguredHomeUrl();\n}\n\nexport function getHomeUrl(): string {\n  return getPreferredHomeUrl() || getConfiguredHomeUrl();\n}\n"
  },
  {
    "path": "app/client/models/modelUtil.js",
    "content": "var _ = require(\"underscore\");\nvar Promise = require(\"bluebird\");\nvar assert = require(\"assert\");\nvar gutil = require(\"app/common/gutil\");\nvar ko = require(\"knockout\");\nvar koUtil = require(\"../lib/koUtil\");\n\n\n/**\n * Adds a family of 'save' methods to an observable. It accepts a callback for saving a value\n * (presumably to the server), and adds the following methods:\n * @method save()           Saves the current value of the observable to the server.\n * @method saveOnly(obj)    Saves the given value, without changing the observable's value.\n * @method setAndSave(obj)  Sets a new value for the observable and saves it.\n * @method setAndSaveOrRevert(obj)  Sets a new value for the observable and saves it, or reverts\n *    on error.\n * @returns {Observable} Returns the passed-on observable.\n */\nfunction addSaveInterface(observable, saveFunc) {\n  observable.saveOnly = function(value) {\n    // Calls saveFunc and notifies subscribers of 'save' events.\n    return Promise.try(() => saveFunc.call(this, value))\n      .tap(() => observable.notifySubscribers(value, \"save\"));\n  };\n  observable.save = function() {\n    return this.saveOnly(this.peek());\n  };\n  observable.setAndSave = function(value) {\n    this(value);\n    return this.saveOnly(value);\n  };\n  observable.setAndSaveOrRevert = function(value) {\n    const previousValue = this.peek();\n    return this.setAndSave(value).catch((e) => {\n      if (this.peek() === value) {\n        this(previousValue);\n      }\n      throw e;\n    });\n  };\n  return observable;\n}\nexports.addSaveInterface = addSaveInterface;\n\n\n/**\n * Creates a pureComputed with a read/write/save interface. The argument is an object with two\n * properties: `read` is the same as for a computed or a pureComputed. `write` is different: it is\n * a callback called as write(setter, value), where `setter(obs, value)` can be used with another\n * observable to write or save to it. E.g. if `foo` is an observable:\n *\n *  let bar = savingComputed({\n *    read: () => foo(),\n *    write: (setter, val) => setter(foo, val.toUpperCase())\n *  })\n *\n * Now `bar()` has the value of foo, calling `bar(\"hello\")` will call `foo(\"HELLO\")`, and\n * `bar.saveOnly(\"hello\")` will call `foo.saveOnly(\"HELLO\")`.\n */\nfunction savingComputed(options) {\n  return addSaveInterface(ko.pureComputed({\n    read: options.read,\n    write: val => options.write(_writeSetter, val)\n  }), val => options.write(_saveSetter, val));\n}\nexports.savingComputed = savingComputed;\n\nfunction _writeSetter(obs, val) { return obs(val); }\nfunction _saveSetter(obs, val) { return obs.saveOnly(val); }\n\n\n/**\n * Set and save the observable to the given value if it would change the value of the observable.\n * If the observable has no .save() interface, then the saving is skipped. If the save() call\n * fails, then the observable gets reset to its previous value.\n * @param {Observable} observable: Observable which may support the 'save' interface.\n * @param {Object} value: An arbitrary value. If identical to the current value of the observable,\n *    then the call is a no-op.\n * @param {Object} optOrigValue: If given, will use it as the original value of the observable: if\n *    it matches value, will skip saving; if save fails, will revert to this original.\n * @returns {undefined|Promise} If saving, a promise for when save() completes, else undefined.\n */\nfunction setSaveValue(observable, value, optOrigValue) {\n  let orig = (optOrigValue === undefined) ? observable.peek() : optOrigValue;\n  if (value !== orig) {\n    observable(value);\n    if (observable.save) {\n      return Promise.try(() => observable.save())\n        .catch(err => {\n          console.warn(\"setSaveValue %s -> %s failed: %s\", orig, value, err);\n          observable(orig);\n          throw err;\n        });\n    }\n  }\n}\nexports.setSaveValue = setSaveValue;\n\n\n/**\n * Creates an observable for a field value. It accepts a callback for saving its value to the\n * server, and adds a family of 'save' methods to the returned observable (see docs for\n * addSaveInterface() above).\n */\nfunction createField(saveFunc) {\n  return addSaveInterface(ko.observable(), saveFunc);\n}\nexports.createField = createField;\n\n/**\n * Returns an observable that mirrors another one but returns a default value if the underlying\n * field is falsy. Supports writing and saving, which translates directly to writing to the\n * underlying field. If the default value is a function, it's evaluated as in `computed()`, with\n * the given context.\n */\nfunction fieldWithDefault(fieldObs, defaultOrFunc, optContext) {\n  var obsWithDef = koUtil.observableWithDefault(fieldObs, defaultOrFunc, optContext);\n  if (fieldObs.saveOnly) {\n    addSaveInterface(obsWithDef, fieldObs.saveOnly);\n  }\n  return obsWithDef;\n}\nexports.fieldWithDefault = fieldWithDefault;\n\n\n/**\n * Helper to create an observable for a single property of a jsonObservable. It updates whenever\n * the jsonObservable is updated, and it allows setting the property, which sets the entire object\n * of the jsonObservable. Also supports 'save' methods.\n */\nfunction _createJsonProp(jsonObservable, propName) {\n  var jsonProp = ko.pureComputed({\n    read: function() { return jsonObservable()[propName]; },\n    write: function(value) {\n      var obj = jsonObservable.peek();\n      obj[propName] = value;\n      jsonObservable(obj);\n    }\n  });\n\n  // Add save methods (if underlying jsonObservable supports them)\n  if (jsonObservable.saveOnly) {\n    addSaveInterface(jsonProp, function(value) {\n      var obj = _.clone(jsonObservable.peek());\n      obj[propName] = value;\n      return jsonObservable.saveOnly(obj);\n    });\n  }\n  return jsonProp;\n}\n\n\n/**\n * Creates an observable for an object represented by an observable JSON string. It automatically\n * parses the JSON string when it changes, and stringifies on setting the object. It also supports\n * 'save' methods, forwarding calls to the .saveOnly function of the underlying string observable.\n *\n * @param {observable[String]} stringObservable: observable for a string that should contain JSON.\n * @param [Function] modifierFunc: function called with parsed object, which can modify it\n *    at will, e.g. to set defaults. It's OK to modify in-place; only the return value is used.\n * @param [Object] optContext: Optionally a context to call modifierFunc with.\n *\n * The returned observable supports these methods:\n * @method save()           Saves the current value of the observable to the server.\n * @method saveOnly(obj)    Saves the given value, without changing the observable's value.\n * @method setAndSave(obj)  Sets a new value for the observable and saves it.\n * @method update(obj)      Updates json with new properties (caller can .save() afterwards).\n * @method prop(name)       Returns an observable for the given property of the JSON object,\n *    which also supports saving. Multiple calls to prop('foo') return the same observable.\n */\nfunction jsonObservable(stringObservable, modifierFunc, optContext) {\n  modifierFunc = modifierFunc || function(obj) { return obj || {}; };\n\n  // Create the jsonObservable itself\n  var obs = ko.pureComputed({\n    read: function() { // reads the underlying string, parses, and passes through modFunc\n      var json = stringObservable();\n      return modifierFunc.call(optContext, json ? JSON.parse(json) : null);\n    },\n    write: function(obj) { // stringifies the given obj and sets the underlying string to that\n      stringObservable(JSON.stringify(obj));\n    }\n  });\n\n  // Create save interface if possible\n  if (stringObservable.saveOnly) {\n    addSaveInterface(obs, function(obj) {\n      return stringObservable.saveOnly(JSON.stringify(obj));\n    });\n  }\n\n  return objObservable(obs);\n}\nexports.jsonObservable = jsonObservable;\n\n/**\n * Creates an observable for an object.\n *\n * @param {observable[Object]} objectObservable: observable for an object.\n *\n * The returned observable supports these methods:\n * @method update(obj)      Updates object with new properties.\n * @method prop(name)       Returns an observable for the given property of the object.\n */\nfunction objObservable(objectObservable) {\n  objectObservable.update = function(obj) {\n    this(_.extend(this.peek(), obj)); // read self, _.extend, writeback\n  };\n  objectObservable._props = {};\n  objectObservable.prop = function(propName) {\n    // If created, return cached prop. Else _createJsonProp\n    return this._props[propName] || (this._props[propName] = _createJsonProp(this, propName));\n  };\n  return objectObservable;\n}\nexports.objObservable = objObservable;\n\n// Special value that indicates that a customValueField isn't set and is using the saved value.\nvar _sentinel = {};\n\n/**\n * Creates a observable that reflects savedObservable() but may diverge from it when set, and has\n * a methods to revert to the saved value. Additionally, the saving methods\n * (.save/.saveOnly/.setAndSave) save savedObservable() and synchronize the values.\n */\nfunction customValue(savedObservable) {\n  var options = { read: () => savedObservable() };\n  if (savedObservable.saveOnly) {\n    options.save = (val => savedObservable.saveOnly(val));\n  }\n  return customComputed(options);\n}\nexports.customValue = customValue;\n\n/**\n * Creates an observable whose value defaults to options.read() but may diverge from it when set,\n * and has a method to revert to the default value. If options.save(val) is provided, the saving\n * methods (.save/.saveOnly/.setAndSave) call it and reset the observable to its default value.\n * @param {Function} options.read: Returns the default value for the observable.\n * @param {Function} options.save(val): Saves a new value of the observable. May return a Promise.\n *\n * @returns {Observable} A writable observable value with some extra properties:\n *   @property {Observable} isSaved: Computed for whether customComputed() has its default value.\n *   @method revert(): Revert the customComputed() to its default value.\n *   @method save(val): If val is different from the current value of read(), call\n *      options.save(val), then revert the observable to its (possibly new) default value.\n */\nfunction customComputed(options) {\n  var current = ko.observable(_sentinel);\n  var read = options.read;\n  var save = options.save;\n\n  // This is our main interface: just an observable, which defaults to the one at fieldName.\n  var active = ko.pureComputed({\n    read: () => (current() !== _sentinel ? current() : read()),\n    write: val => current(val !== read() ? val : _sentinel),\n  });\n\n  // .isSaved is an observable that returns whether the saved value has not been overridden.\n  active.isSaved = ko.pureComputed(() => (current() === _sentinel));\n\n  // .revert reverts to the saved value, discarding whatever custom value was set.\n  active.revert = function() { current(_sentinel); };\n\n  // When any of the .save/.saveOnly/.setAndSave functions are called on the customValueField,\n  // they save the underlying value and (when that resolves), discard the current value.\n  if (save) {\n    addSaveInterface(active, val => (\n      Promise.try(() => val !== read() ? save(val) : null).finally(active.revert)\n    ));\n  }\n  return active;\n}\nexports.customComputed = customComputed;\n\n\nfunction bulkActionExpand(bulkAction, callback, context) {\n  assert(gutil.startsWith(bulkAction[0], \"Bulk\"));\n\n  var rowIds = bulkAction[2];\n  var columnValues = bulkAction[3];\n  var indivAction = bulkAction.slice(0);\n  indivAction[0] = indivAction[0].slice(4);\n  var colValues = indivAction[3] = columnValues && _.clone(columnValues);\n  for (var i = 0; i < rowIds.length; i++) {\n    indivAction[2] = rowIds[i];\n    if (colValues) {\n      for (var col in colValues) {\n        colValues[col] = columnValues[col][i];\n      }\n    }\n    callback.call(context, indivAction);\n  }\n}\nexports.bulkActionExpand = bulkActionExpand;\n\n\n/**\n * Helper class which provides a `dispatchAction` method that can be subscribed to listen to\n * actions received from the server. It dispatches each action to `this._process_{ActionType}`\n * method, e.g. `this._process_UpdateRecord`.\n *\n * Implementation methods `_process_*` are called with the action as the first argument, and with\n * the action arguments as additional method arguments, for convenience.\n */\nvar ActionDispatcher = {\n  dispatchAction: function(action) {\n    console.assert(!(typeof this.isDisposed === \"function\" && this.isDisposed()),\n      `Dispatching action ${action[0]} on disposed object`, this);\n\n    var methodName = \"_process_\" + action[0];\n    var func = this[methodName];\n    if (typeof func === \"function\") {\n      var args = action.slice(0);\n      args[0] = action;\n      return func.apply(this, args);\n    } else {\n      console.warn(\"Received unknown action %s\", action[0]);\n    }\n  },\n\n  /**\n   * Generic handler for bulk actions (Bulk{Add,Remove,Update}Record) which forwards the bulk call\n   * to multiple per-record calls. Intended to be used as:\n   *    Foo.prototype._process_BulkUpdateRecord = Foo.prototype.dispatchBulk;\n   */\n  dispatchBulk: function(action, tableId, rowIds, columnValues) {\n    bulkActionExpand(action, this.dispatchAction, this);\n  },\n};\nexports.ActionDispatcher = ActionDispatcher;\n"
  },
  {
    "path": "app/client/models/rowset.ts",
    "content": "/**\n * rowset.js module defines a number of classes to deal with maintaining collections of rows and\n * listening to their changes.\n *\n * RowSource: abstract interface for a source of row changes.\n *  - emits rowChange('add|remove|update', rows) events with rows an iterable.\n *  - offers getAllRows() method that returns all rows currently in the RowSource.\n *\n * RowListener: base class for a listener to row changes.\n *  - offers subscribeTo(rowSource), unsubscribeFrom(rowSource) methods.\n *  - derived classes should implement onAddRows(), onRemoveRows(), onUpdateRows().\n *\n * FilteredRowSource(filterFunc): a RowListener that can be subscribed to any other RowSources and\n *  is itself a RowSource which forwards changes to rows that match filterFunc.\n *\n * RowGrouping(groupFunc): a RowListener that can be subscribed to any RowSources, groups\n *  rows by the result of groupFunc, and exposes a per-group RowSource via its getGroup() method.\n *\n * SortedRowSet(compareFunc): a RowListener that can be subscribed to any RowSources, and exposes\n *  an observable koArray via getKoArray(), which maintains rows from RowSources in sorted order.\n */\n\nimport koArray, { KoArray } from \"app/client/lib/koArray\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { CompareFunc, sortedIndex } from \"app/common/gutil\";\nimport { RowFilterFunc } from \"app/common/RowFilterFunc\";\nimport { SkippableRows } from \"app/common/TableData\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\n\nimport { Observable } from \"grainjs\";\n\n/**\n * Special constant value that can be used for the `rows` array for the 'rowNotify'\n * event to indicate that the event applies to all rows.\n */\nexport const ALL: unique symbol = Symbol(\"ALL\");\n\nexport type ChangeType = \"add\" | \"remove\" | \"update\";\nexport type ChangeMethod = \"onAddRows\" | \"onRemoveRows\" | \"onUpdateRows\";\nexport type RowList = Iterable<UIRowId>;\nexport type RowsChanged = RowList | typeof ALL;\n\n// ----------------------------------------------------------------------\n// RowSource\n// ----------------------------------------------------------------------\n\n/**\n * RowSource is an interface expected by RowListener. It should implement `getAllRows()` method,\n * and should emit `rowChange('add|remove|update', rows)` events on changes,\n * and `rowNotify(rows, value)` event to notify listeners of a value associated with a row.\n * For the `rowNotify` event, rows may be the rowset.ALL constant.\n */\nexport abstract class RowSource extends DisposableWithEvents {\n  /**\n   * Returns an iterable over all rows in this RowSource. Should be implemented by derived classes.\n   */\n  public abstract getAllRows(): RowList;\n\n  /**\n   * Returns the number of rows in this row source.\n   */\n  public abstract getNumRows(): number;\n}\n\n// ----------------------------------------------------------------------\n// RowListener\n// ----------------------------------------------------------------------\n\nconst _changeTypes: { [key: string]: ChangeMethod } = {\n  add: \"onAddRows\",\n  remove: \"onRemoveRows\",\n  update: \"onUpdateRows\",\n};\n\n/**\n * RowListener is the base class for collections that want to subscribe to rowset changes. It\n * offers `subscribeTo(rowSource)` method. The derived class should implement several methods\n * which will be called on row changes.\n */\nexport class RowListener extends DisposableWithEvents {\n  /**\n   * Subscribes to the given rowSource and adds the rows currently in it.\n   */\n  public subscribeTo(rowSource: RowSource): void {\n    this.onAddRows(rowSource.getAllRows(), rowSource);\n    this.listenTo(rowSource, \"rowChange\", (changeType: ChangeType, rows: RowList) => {\n      const method: ChangeMethod = _changeTypes[changeType];\n      this[method](rows, rowSource);\n    });\n    this.listenTo(rowSource, \"rowNotify\", this.onRowNotify);\n  }\n\n  /**\n   * Unsubscribes from the given rowSource removing its rows. This is not needed for disposal;\n   * dispose() on its own is sufficient and faster.\n   */\n  public unsubscribeFrom(rowSource: RowSource): void {\n    this.stopListening(rowSource, \"rowChange\");\n    this.stopListening(rowSource, \"rowNotify\");\n    this.onRemoveRows(rowSource.getAllRows());\n  }\n\n  /**\n   * Process row additions. To be implemented by derived classes.\n   */\n  protected onAddRows(rows: RowList, rowSource?: RowSource) { /* no-op */ }\n\n  /**\n   * Process row removals. To be implemented by derived classes.\n   */\n  protected onRemoveRows(rows: RowList, rowSource?: RowSource) { /* no-op */ }\n\n  /**\n   * Process row updates. To be implemented by derived classes.\n   */\n  protected onUpdateRows(rows: RowList, rowSource?: RowSource) { /* no-op */ }\n\n  /**\n   * Derived classes may override this event to handle row notifications. By default, it re-triggers\n   * rowNotify on the RowListener itself.\n   */\n  protected onRowNotify(rows: RowList, notifyValue: any) {\n    this.trigger(\"rowNotify\", rows, notifyValue);\n  }\n}\n\n// ----------------------------------------------------------------------\n// MappedRowSource\n// ----------------------------------------------------------------------\n\n/**\n * MappedRowSource wraps any other RowSource, and passes through all rows, replacing each row\n * identifier with the result of mapperFunc(row) call.\n *\n * The underlying RowSource is exposed as this.parentRowSource.\n *\n * TODO: This class is not used anywhere at the moment, and is a candidate for removal.\n */\nexport class MappedRowSource extends RowSource {\n  private _mapperFunc: (row: UIRowId) => UIRowId;\n\n  constructor(\n    public parentRowSource: RowSource,\n    mapperFunc: (row: UIRowId) => UIRowId,\n  ) {\n    super();\n\n    // Wrap mapperFunc to ensure arguments after the first one aren't passed on to it.\n    this._mapperFunc = row => mapperFunc(row);\n\n    // Listen to the two event types a rowSource might produce, and map the rows in them.\n    this.listenTo(parentRowSource, \"rowChange\", (changeType: ChangeType, rows: RowList) => {\n      this.trigger(\"rowChange\", changeType, Array.from(rows, this._mapperFunc));\n    });\n    this.listenTo(parentRowSource, \"rowNotify\", (rows: RowsChanged, notifyValue: any) => {\n      this.trigger(\"rowNotify\", rows === ALL ? ALL : Array.from(rows, this._mapperFunc), notifyValue);\n    });\n  }\n\n  public getAllRows(): RowList {\n    return Array.from(this.parentRowSource.getAllRows(), this._mapperFunc);\n  }\n\n  public getNumRows(): number {\n    return this.parentRowSource.getNumRows();\n  }\n}\n\n/**\n * A RowSource with some extra rows added.\n */\nexport class ExtendedRowSource extends RowSource {\n  constructor(\n    public parentRowSource: RowSource,\n    public extras: readonly UIRowId[],\n  ) {\n    super();\n\n    // Listen to the two event types a rowSource might produce, and map the rows in them.\n    this.listenTo(parentRowSource, \"rowChange\", (changeType: ChangeType, rows: RowList) => {\n      this.trigger(\"rowChange\", changeType, rows);\n    });\n    this.listenTo(parentRowSource, \"rowNotify\", (rows: RowsChanged, notifyValue: any) => {\n      this.trigger(\"rowNotify\", rows === ALL ? ALL : rows, notifyValue);\n    });\n  }\n\n  public getAllRows(): RowList {\n    return [...this.parentRowSource.getAllRows()].concat(this.extras);\n  }\n\n  public getNumRows(): number {\n    return this.parentRowSource.getNumRows() + this.extras.length;\n  }\n}\n\n// ----------------------------------------------------------------------\n// FilteredRowSource\n// ----------------------------------------------------------------------\n\ninterface FilterRowChanges {\n  adds?: UIRowId[];\n  updates?: UIRowId[];\n  removes?: UIRowId[];\n}\n\n/**\n * See FilteredRowSource, for which this is the base. BaseFilteredRowSource is simpler, in that it\n * does not maintain excluded rows, and does not allow changes to filterFunc.\n */\nexport class BaseFilteredRowSource extends RowListener implements RowSource {\n  protected _matchingRows = new Set<UIRowId>();   // Set of rows matching the filter.\n\n  constructor(protected _filterFunc: RowFilterFunc<UIRowId>) {\n    super();\n  }\n\n  public getAllRows(): RowList {\n    return this._matchingRows.values();\n  }\n\n  public getNumRows(): number {\n    return this._matchingRows.size;\n  }\n\n  public onAddRows(rows: RowList) {\n    const outputRows = [];\n    for (const r of rows) {\n      if (this._filterFunc(r)) {\n        this._matchingRows.add(r);\n        outputRows.push(r);\n      } else {\n        this._addExcludedRow(r);\n      }\n    }\n    if (outputRows.length > 0) {\n      this.trigger(\"rowChange\", \"add\", outputRows);\n    }\n  }\n\n  public onRemoveRows(rows: RowList) {\n    const outputRows = [];\n    for (const r of rows) {\n      if (this._matchingRows.delete(r)) {\n        outputRows.push(r);\n      }\n      this._deleteExcludedRow(r);\n    }\n    if (outputRows.length > 0) {\n      this.trigger(\"rowChange\", \"remove\", outputRows);\n    }\n  }\n\n  public onUpdateRows(rows: RowList) {\n    const changes = this._updateRowsHelper({}, rows);\n    if (changes.removes) { this.trigger(\"rowChange\", \"remove\", changes.removes); }\n    if (changes.updates) { this.trigger(\"rowChange\", \"update\", changes.updates); }\n    if (changes.adds) { this.trigger(\"rowChange\", \"add\", changes.adds); }\n  }\n\n  public onRowNotify(rows: RowsChanged, notifyValue: any) {\n    if (rows === ALL) {\n      this.trigger(\"rowNotify\", ALL, notifyValue);\n    } else {\n      const outputRows = [];\n      for (const r of rows) {\n        if (this._matchingRows.has(r)) {\n          outputRows.push(r);\n        }\n      }\n      if (outputRows.length > 0) {\n        this.trigger(\"rowNotify\", outputRows, notifyValue);\n      }\n    }\n  }\n\n  /**\n   * Helper which goes through the given rows, applies _filterFunc() to them, and depending on the\n   * result, adds the row to one of the arrays: changes.adds, changes.removes, or changes.updates.\n   * Returns `changes` (the first parameter).\n   */\n  protected _updateRowsHelper(changes: FilterRowChanges, rows: RowList) {\n    for (const r of rows) {\n      if (this._filterFunc(r)) {\n        if (this._matchingRows.has(r)) {\n          (changes.updates || (changes.updates = [])).push(r);\n        } else if (this._deleteExcludedRow(r)) {\n          this._matchingRows.add(r);\n          (changes.adds || (changes.adds = [])).push(r);\n        }\n      } else {\n        if (this._matchingRows.delete(r)) {\n          this._addExcludedRow(r);\n          (changes.removes || (changes.removes = [])).push(r);\n        }\n      }\n    }\n    return changes;\n  }\n\n  // These are implemented by FilteredRowSource, but the base class doesn't need to do anything.\n  protected _addExcludedRow(row: UIRowId): void { /* no-op */ }\n  protected _deleteExcludedRow(row: UIRowId): boolean { return true; }\n}\n\n/**\n * FilteredRowSource can listen to any other RowSource, and passes through only the rows matching\n * the given filter function. In particular, an 'update' event may turn into an 'add' or 'remove'\n * if the row starts or stops matching the function.\n *\n * FilteredRowSource is also a RowListener, so to subscribe to a rowSource, use `subscribeTo()`.\n */\nexport class FilteredRowSource extends BaseFilteredRowSource {\n  private _excludedRows = new Set<UIRowId>();   // Set of rows NOT matching the filter.\n\n  /**\n   * Change the filter function. This may trigger 'remove' and 'add' events as necessary to indicate\n   * that rows stopped or started matching the new filter.\n   */\n  public updateFilter(filterFunc: RowFilterFunc<UIRowId>) {\n    this._filterFunc = filterFunc;\n    const changes: FilterRowChanges = {};\n    // After the first call, _excludedRows may have additional rows, but there is no harm in it,\n    // as we know they don't match, and so will be ignored by _updateRowsHelper.\n    this._updateRowsHelper(changes, this._matchingRows);\n    this._updateRowsHelper(changes, this._excludedRows);\n    if (changes.removes) { this.trigger(\"rowChange\", \"remove\", changes.removes); }\n    if (changes.adds) { this.trigger(\"rowChange\", \"add\", changes.adds); }\n  }\n\n  /**\n   * Re-apply the filter to the given rows, triggering add/remove events as needed. This is also\n   * similar to what happens on an rowChange/update event from a RowSource, except that no 'update'\n   * event is propagated if filter status hasn't changed.\n   */\n  public refilterRows(rows: RowList) {\n    const changes = this._updateRowsHelper({}, rows);\n    if (changes.removes) { this.trigger(\"rowChange\", \"remove\", changes.removes); }\n    if (changes.adds) { this.trigger(\"rowChange\", \"add\", changes.adds); }\n  }\n\n  /**\n   * Returns an iterable over all rows that got filtered out by this FilteredRowSource.\n   */\n  public getHiddenRows() {\n    return this._excludedRows.values();\n  }\n\n  protected _addExcludedRow(row: UIRowId): void { this._excludedRows.add(row); }\n  protected _deleteExcludedRow(row: UIRowId): boolean { return this._excludedRows.delete(row); }\n}\n\n// ----------------------------------------------------------------------\n// RowGrouping\n// ----------------------------------------------------------------------\n\n/**\n * Private helper object that maintains a set of rows for a particular group.\n */\nclass RowGroupHelper<Value> extends RowSource {\n  private _rows = new Set<UIRowId>();\n  constructor(public readonly groupValue: Value) {\n    super();\n  }\n\n  public getAllRows() {\n    return this._rows.values();\n  }\n\n  public getNumRows(): number {\n    return this._rows.size;\n  }\n\n  public _addAll(rows: RowList) {\n    for (const r of rows) { this._rows.add(r); }\n  }\n\n  public _removeAll(rows: RowList) {\n    for (const r of rows) { this._rows.delete(r); }\n  }\n}\n\n// ----------------------------------------------------------------------\n/**\n * Helper function that does map.get(key).push(r), creating an Array for the given key if\n * necessary.\n */\nfunction _addToMapOfArrays<K, V>(map: Map<K, V[]>, key: K, r: V): void {\n  let arr = map.get(key);\n  if (!arr) { map.set(key, arr = []); }\n  arr.push(r);\n}\n\n/**\n * RowGrouping is a RowListener which groups rows by the results of _groupFunc(row) and exposes\n * per-group RowSources via getGroup(val).\n *\n * @param {Function} groupFunc: called with row identifier, should return the value to group by.\n *    The returned value must be a primitive value such as a String or Number.\n */\nexport class RowGrouping<Value> extends RowListener {\n  // Maps row identifiers to groupValues.\n  private _rowsToValues = new Map<UIRowId, Value>();\n\n  // Maps group values to RowGroupHelpers\n  private _valuesToGroups = new Map<Value, RowGroupHelper<Value>>();\n\n  constructor(private _groupFunc: (row: UIRowId) => Value) {\n    super();\n\n    // On disposal, dispose all RowGroupHelpers that we maintain.\n    this.onDispose(() => {\n      for (const rowGroupHelper of this._valuesToGroups.values()) {\n        rowGroupHelper.dispose();\n      }\n    });\n  }\n\n  /**\n   * Returns a RowSource for the group of rows for which groupFunc(row) is equal to groupValue.\n   */\n  public getGroup(groupValue: Value): RowGroupHelper<Value> {\n    let group = this._valuesToGroups.get(groupValue);\n    if (!group) {\n      group = new RowGroupHelper(groupValue);\n      this._valuesToGroups.set(groupValue, group);\n    }\n    return group;\n  }\n\n  // Implementation of the RowListener interface.\n\n  public onAddRows(rows: RowList) {\n    const groupedRows = new Map();\n    for (const r of rows) {\n      const newValue = this._groupFunc(r);\n      _addToMapOfArrays(groupedRows, newValue, r);\n      this._rowsToValues.set(r, newValue);\n    }\n\n    groupedRows.forEach((groupRows, groupValue) => {\n      const group = this.getGroup(groupValue);\n      group._addAll(groupRows);\n      group.trigger(\"rowChange\", \"add\", groupRows);\n    });\n  }\n\n  public onRemoveRows(rows: RowList) {\n    const groupedRows = new Map();\n    for (const r of rows) {\n      _addToMapOfArrays(groupedRows, this._rowsToValues.get(r), r);\n      this._rowsToValues.delete(r);\n    }\n\n    // Note that we don't dispose the RowGroupHelper itself when it becomes empty, because this\n    // group may be in use elsewhere (even if empty at the moment). RowGroupHelpers are only\n    // disposed together with the RowGrouping object itself.\n    groupedRows.forEach((groupRows, groupValue) => {\n      const group = this._valuesToGroups.get(groupValue)!;\n      group._removeAll(groupRows);\n      group.trigger(\"rowChange\", \"remove\", groupRows);\n    });\n  }\n\n  public onUpdateRows(rows: RowList) {\n    let updateGroup, removeGroup, insertGroup;\n    for (const r of rows) {\n      const oldValue = this._rowsToValues.get(r);\n      const newValue = this._groupFunc(r);\n      if (newValue === oldValue) {\n        _addToMapOfArrays(updateGroup || (updateGroup = new Map()), oldValue, r);\n      } else {\n        this._rowsToValues.set(r, newValue);\n        _addToMapOfArrays(removeGroup || (removeGroup = new Map()), oldValue, r);\n        _addToMapOfArrays(insertGroup || (insertGroup = new Map()), newValue, r);\n      }\n    }\n    if (removeGroup) {\n      removeGroup.forEach((groupRows, groupValue) => {\n        const group = this._valuesToGroups.get(groupValue)!;\n        group._removeAll(groupRows);\n        group.trigger(\"rowChange\", \"remove\", groupRows);\n      });\n    }\n    if (updateGroup) {\n      updateGroup.forEach((groupRows, groupValue) => {\n        const group = this._valuesToGroups.get(groupValue)!;\n        group.trigger(\"rowChange\", \"update\", groupRows);\n      });\n    }\n    if (insertGroup) {\n      insertGroup.forEach((groupRows, groupValue) => {\n        const group = this.getGroup(groupValue);\n        group._addAll(groupRows);\n        group.trigger(\"rowChange\", \"add\", groupRows);\n      });\n    }\n  }\n\n  public onRowNotify(rows: RowsChanged, notifyValue: any) {\n    if (rows === ALL) {\n      for (const group of this._valuesToGroups.values()) {\n        group.trigger(\"rowNotify\", ALL, notifyValue);\n      }\n    } else {\n      const groupedRows = new Map();\n      for (const r of rows) {\n        _addToMapOfArrays(groupedRows, this._rowsToValues.get(r), r);\n      }\n\n      groupedRows.forEach((groupRows, groupValue) => {\n        const group = this._valuesToGroups.get(groupValue)!;\n        group.trigger(\"rowNotify\", groupRows, notifyValue);\n      });\n    }\n  }\n}\n\n// ----------------------------------------------------------------------\n// SortedRowSet\n// ----------------------------------------------------------------------\n\n/**\n * SortedRowSet is a RowListener which maintains a set of rows in a sorted order, according to the\n * results of compareFunc. The sorted rows are exposed as an observable koArray.\n *\n * SortedRowSet re-emits 'rowNotify(rows, value)' events from RowSources that it subscribes to.\n */\nexport class SortedRowSet extends RowListener {\n  private _allRows = new Set<UIRowId>();\n  private _isPaused: boolean = false;\n  private _koArray: KoArray<UIRowId>;\n  private _keepFunc?: (rowId: number | \"new\") => boolean;\n\n  constructor(private _compareFunc: CompareFunc<UIRowId>,\n    private _skippableRows?: SkippableRows) {\n    super();\n    this._koArray = this.autoDispose(koArray<UIRowId>());\n    this._keepFunc = _skippableRows?.getKeepFunc();\n  }\n\n  /**\n   * Returns the sorted observable koArray maintained by this SortedRowSet.\n   */\n  public getKoArray() {\n    return this._koArray;\n  }\n\n  /**\n   * Disable the populating of koArray temporarily. When pause(false) is called, the array is\n   * brought back up to date. This is useful if there are multiple changes, e.g. subscriptions and\n   * compareFunc updates, to avoid sorting multiple times.\n   */\n  public pause(doPause: boolean) {\n    if (!doPause && this._isPaused) {\n      this._koArray.assign(Array.from(this._allRows).sort(this._compareFunc));\n    }\n    this._isPaused = Boolean(doPause);\n  }\n\n  /**\n   * Re-sorts the array according to the new compareFunc.\n   */\n  public updateSort(compareFunc: CompareFunc<UIRowId>): void {\n    this._compareFunc = compareFunc;\n    if (!this._isPaused) {\n      this._koArray.assign(Array.from(this._koArray.peek()).sort(this._compareFunc));\n    }\n  }\n\n  public onAddRows(rows: RowList) {\n    for (const r of rows) {\n      this._allRows.add(r);\n    }\n    if (this._isPaused) {\n      return;\n    }\n    if (this._canChangeIncrementally(rows)) {\n      for (const r of rows) {\n        const insertIndex = sortedIndex(this._koArray.peek(), r, this._compareFunc);\n        this._koArray.splice(insertIndex, 0, r);\n      }\n    } else {\n      this._koArray.assign(this._keep(Array.from(this._allRows).sort(this._compareFunc)));\n    }\n  }\n\n  public onRemoveRows(rows: RowList) {\n    for (const r of rows) {\n      this._allRows.delete(r);\n    }\n    if (this._isPaused) {\n      return;\n    }\n    if (this._canChangeIncrementally(rows)) {\n      for (const r of rows) {\n        const index = this._koArray.peek().indexOf(r);\n        if (index !== -1) {\n          this._koArray.splice(index, 1);\n        }\n      }\n    } else {\n      this._koArray.assign(this._keep(Array.from(this._allRows).sort(this._compareFunc)));\n    }\n  }\n\n  public onUpdateRows(rows: RowList) {\n    // If paused, do nothing, since we'll re-sort later anyway.\n    if (this._isPaused) {\n      return;\n    }\n\n    // If all affected rows are in correct place relative to their neighbors, then the array is\n    // still sorted, and there is nothing to do. (It's a common case when the update affects fields\n    // not participating in the sort.)\n    //\n    // Note that the logic is all or none, since we can't assume that a single row is in its right\n    // place by comparing to neighbors because the neighbors might themselves be affected and wrong.\n    const sortedRows = Array.from(rows).sort(this._compareFunc);\n    if (_allRowsSorted(this._koArray.peek(), this._allRows, sortedRows, this._compareFunc)) {\n      return;\n    }\n\n    if (this._canChangeIncrementally(rows)) {\n      // Note that we can't add any rows before we remove all affected rows, because affected rows\n      // may no longer be in the correct sort order, so binary search is broken until they are gone.\n      this.onRemoveRows(rows);\n      this.onAddRows(rows);\n    } else {\n      this._koArray.assign(this._keep(Array.from(this._koArray.peek()).sort(this._compareFunc)));\n    }\n  }\n\n  // Check whether a change in the specified rows can be applied incrementally.\n  private _canChangeIncrementally(rows: RowList) {\n    return !this._keepFunc && isSmallChange(rows);\n  }\n\n  // Filter out any rows that should be skipped. This is a no-op if no _keepFunc was found.\n  // All rows that sort within nContext rows of something meant to be kept are also kept.\n  private _keep(rows: UIRowId[], nContext: number = 2) {\n    // Nothing to be done if there's no _keepFunc.\n    if (!this._keepFunc) { return rows; }\n\n    // Seed a list of rows to be kept (we'll expand it as we go).\n    const keeping = rows.map(this._keepFunc);\n\n    // Within a range of skipped rows, we'll keep one as an interstitial, with its\n    // rowId replaced with a special \"skip\" id that makes it get rendered a special\n    // way (with \"...\" in every cell).\n    // Start with a blank list (we'll fill it out as we go).\n    const edge = rows.map(() => false);\n\n    // Keep the first and last (typically 'new') row.\n    const n = rows.length;\n    if (n >= 1) { keeping[0] = true; }\n    if (n >= 2) { keeping[n - 1] = true; }\n\n    // Sweep forwards through the list of kept rows, keeping an extra nContext rows\n    // after each.\n    let last = -nContext - 1;\n    for (let i = 0; i < n; i++) {\n      if (keeping[i]) { last = i; } else if (i - last <= nContext) { keeping[i] = true; }\n    }\n\n    // Sweep backwards through the list of kept rows, keeping an extra nContext rows\n    // before each.\n    last = n + nContext + 1;\n    for (let i = n - 1; i >= 0; i--) {\n      if (keeping[i]) { last = i; } else if (last - i <= nContext) { keeping[i] = true; }\n    }\n\n    // Keep one extra \"edge\" row from each sequence of rows that are to be skipped.\n    let skipping: boolean = false;\n    for (let i = 0; i < n; i++) {\n      if (keeping[i]) {\n        skipping = false;\n      } else {\n        if (!skipping) {\n          edge[i] = true;\n          skipping = true;\n        }\n      }\n    }\n\n    // Go ahead and filter out the rows to keep, tweaking the row id of the\n    // \"edge\" rows.\n    const skipRowId = this._skippableRows?.getSkipRowId() || 0;\n    return rows\n      .map((v, i) => edge[i] ? skipRowId : v)\n      .filter((v, i) => keeping[i] || edge[i] || v === \"new\");\n  }\n}\n\ntype RowTester = (rowId: UIRowId) => boolean;\n/**\n * RowWatcher is a RowListener that maintains an observable function that checks whether a row\n * is in the connected RowSource.\n */\nexport class RowWatcher extends RowListener {\n  /**\n   * Observable function that returns true if the row is in the connected RowSource.\n   */\n  public rowFilter: Observable<RowTester> = Observable.create(this, () => false);\n  // We count the number of times the row is added or removed from the source.\n  // In most cases row is added and removed only once.\n  private _rowCounter = new Map<UIRowId, number>();\n\n  public clear() {\n    this._rowCounter.clear();\n    this.rowFilter.set(() => false);\n    this.stopListening();\n  }\n\n  protected onAddRows(rows: RowList) {\n    for (const r of rows) {\n      this._rowCounter.set(r, (this._rowCounter.get(r) || 0) + 1);\n    }\n    this.rowFilter.set(row => (this._rowCounter.get(row) ?? 0) > 0);\n  }\n\n  protected onRemoveRows(rows: RowList) {\n    for (const r of rows) {\n      this._rowCounter.set(r, (this._rowCounter.get(r) || 0) - 1);\n    }\n    this.rowFilter.set(row => (this._rowCounter.get(row) ?? 0) > 0);\n  }\n}\n\nfunction isSmallChange(rows: RowList) {\n  return Array.isArray(rows) && rows.length <= 2;\n}\n\n/**\n * Helper function to tell if array[index] is in order relative to its neighbors.\n */\nfunction _isIndexInOrder<T>(array: T[], index: number, compareFunc: CompareFunc<T>): boolean {\n  const r = array[index];\n  return ((index === 0 || compareFunc(array[index - 1], r) <= 0) &&\n    (index === array.length - 1 || compareFunc(r, array[index + 1]) <= 0));\n}\n\n/**\n * Helper function to tell if each of sortedRows, if present in the array, is in order relative to\n * its neighbors. sortedRows should be sorted the same way as the array.\n */\nfunction _allRowsSorted<T>(array: T[], allRows: Set<T>, sortedRows: Iterable<T>, compareFunc: CompareFunc<T>): boolean {\n  let last = 0;\n  for (const r of sortedRows) {\n    if (!allRows.has(r)) {\n      continue;\n    }\n    const index = array.indexOf(r, last);\n    if (index === -1 || !_isIndexInOrder(array, index, compareFunc)) {\n      // rows of sortedRows are not present in the array in the same relative order.\n      return false;\n    }\n    last = index;\n  }\n  return true;\n}\n\n/**\n * Track rows that should temporarily be visible even if they don't match filters.\n * This is so that a newly added row doesn't immediately disappear, which would be confusing.\n * This doesn't have much to do with BaseFilteredRowSource, it's just reusing some implementation.\n */\nexport class ExemptFromFilterRowSource extends BaseFilteredRowSource {\n  public constructor() {\n    super(() => false);\n  }\n\n  /**\n   * Call this when one or more new rows are added to keep them temporarily visible.\n   */\n  public addExemptRows(rows: RowList) {\n    const newRows = [];\n    for (const r of rows) {\n      if (!this._matchingRows.has(r)) {\n        this._matchingRows.add(r);\n        newRows.push(r);\n      }\n    }\n    if (newRows.length > 0) {\n      this.trigger(\"rowChange\", \"add\", newRows);\n    }\n  }\n\n  public addExemptRow(rowId: number) {\n    this.addExemptRows([rowId]);\n  }\n\n  /**\n   * Call this when linking or filters change to clear out the temporary rows.\n   */\n  public reset() {\n    this.onRemoveRows(this.getAllRows());\n  }\n\n  public onUpdateRows() { /* no-op */ }\n}\n"
  },
  {
    "path": "app/client/models/rowuid.js",
    "content": "/**\n * For some purposes, we need to identify rows uniquely across different tables, e.g. when showing\n * data with subtotals. This module implements a simple and reasonably efficient way to combine a\n * tableRef and rowId into a single numeric identifier.\n */\n\n\n\n// A JS Number can represent integers exactly up to 53 bits. We use some of those bits to\n// represent tableRef (the rowId of the table in _grist_Tables meta table), and the rest to\n// represent rowId in the table. Note that we currently never reuse old ids, so these limits apply\n// to the count of all tables or all rows per table that ever existed, including deleted ones.\nconst MAX_TABLES = Math.pow(2, 18);     // Up to ~262k tables.\nconst MAX_ROWS = Math.pow(2, 35);       // Up to ~34 billion rows.\nexports.MAX_TABLES = MAX_TABLES;\nexports.MAX_ROWS = MAX_ROWS;\n\n/**\n * Given tableRef and rowId, returns a Number combining them.\n */\nfunction combine(tableRef, rowId) {\n  return tableRef * MAX_ROWS + rowId;\n}\nexports.combine = combine;\n\n/**\n * Given a combined rowUid, returns the tableRef it represents.\n */\nfunction tableRef(rowUid) {\n  return Math.floor(rowUid / MAX_ROWS);\n}\nexports.tableRef = tableRef;\n\n/**\n * Given a combined rowUid, returns the rowId it represents.\n */\nfunction rowId(rowUid) {\n  return rowUid % MAX_ROWS;\n}\nexports.rowId = rowId;\n\n/**\n * Returns a human-readable string representation of the rowUid, as \"tableRef:rowId\".\n */\nfunction toString(rowUid) {\n  return typeof rowUid === \"number\" ? tableRef(rowUid) + \":\" + rowId(rowUid) : rowUid;\n}\nexports.toString = toString;\n"
  },
  {
    "path": "app/client/tsconfig.json",
    "content": "{\n  \"extends\": \"../../buildtools/tsconfig-base.json\",\n  \"include\": [\n    \"**/*\",\n    \"../../stubs/app/client/**/*\"\n  ],\n  \"references\": [\n    { \"path\": \"../common\" },\n  ]\n}\n"
  },
  {
    "path": "app/client/ui/AccountPage.ts",
    "content": "import { detectCurrentLang, makeT } from \"app/client/lib/localization\";\nimport { checkName } from \"app/client/lib/nameUtils\";\nimport { AppModel, reportError } from \"app/client/models/AppModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport * as css from \"app/client/ui/AccountPageCss\";\nimport { ApiKey } from \"app/client/ui/ApiKey\";\nimport { App } from \"app/client/ui/App\";\nimport { AppHeader } from \"app/client/ui/AppHeader\";\nimport { buildChangePasswordDialog } from \"app/client/ui/ChangePasswordDialog\";\nimport { DeleteAccountDialog } from \"app/client/ui/DeleteAccountDialog\";\nimport { translateLocale } from \"app/client/ui/LanguageMenu\";\nimport { leftPanelBasic } from \"app/client/ui/LeftPanelCommon\";\nimport { MFAConfig } from \"app/client/ui/MFAConfig\";\nimport { pagePanels } from \"app/client/ui/PagePanels\";\nimport { ThemeConfig } from \"app/client/ui/ThemeConfig\";\nimport { createTopBarHome } from \"app/client/ui/TopBar\";\nimport { transientInput } from \"app/client/ui/transientInput\";\nimport { cssBreadcrumbs, separator } from \"app/client/ui2018/breadcrumbs\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { select } from \"app/client/ui2018/menus\";\nimport { getPageTitleSuffix, isFeatureEnabled } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { FullUser } from \"app/common/UserAPI\";\n\nimport { Computed, Disposable, dom, domComputed, makeTestId, Observable, styled, subscribe } from \"grainjs\";\n\nconst testId = makeTestId(\"test-account-page-\");\nconst t = makeT(\"AccountPage\");\n\n/**\n * Creates the account page where a user can manage their profile settings.\n */\nexport class AccountPage extends Disposable {\n  private readonly _currentPage = Computed.create(this, urlState().state, (_use, s) => s.account);\n  private _apiKey = Observable.create<string>(this, \"\");\n  private _userObs = Observable.create<FullUser | null>(this, null);\n  private _isEditingName = Observable.create(this, false);\n  private _nameEdit = Observable.create<string>(this, \"\");\n  private _isNameValid = Computed.create(this, this._nameEdit, (_use, val) => checkName(val));\n  private _allowGoogleLogin = Computed.create(this, use => use(this._userObs)?.allowGoogleLogin ?? false)\n    .onWrite(val => this._updateAllowGooglelogin(val));\n\n  constructor(private _appModel: AppModel, private _appObj: App) {\n    super();\n    this._setPageTitle();\n    this._fetchAll().catch(reportError);\n  }\n\n  public buildDom() {\n    const panelOpen = Observable.create(this, false);\n    return pagePanels({\n      leftPanel: {\n        panelWidth: Observable.create(this, 240),\n        panelOpen,\n        hideOpener: true,\n        header: dom.create(AppHeader, this._appModel),\n        content: leftPanelBasic(this._appModel, panelOpen),\n      },\n      headerMain: this._buildHeaderMain(),\n      contentMain: this._buildContentMain(),\n      testId,\n      app: this._appObj,\n    });\n  }\n\n  private _buildContentMain() {\n    const supportedLngs = getGristConfig().supportedLngs ?? [\"en\"];\n    const languageOptions = supportedLngs\n      .map(lng => ({ value: lng, label: translateLocale(lng)! }))\n      .sort((a, b) => a.value.localeCompare(b.value));\n\n    const userLocale = Computed.create(this, (use) => {\n      const selected = detectCurrentLang();\n      if (!supportedLngs.includes(selected)) { return \"en\"; }\n      return selected;\n    });\n    userLocale.onWrite(async (value) => {\n      await this._appModel.api.updateUserLocale(value || null);\n      // Reload the page to apply the new locale.\n      window.location.reload();\n    });\n\n    return domComputed(this._userObs, user => user && (\n      css.container(css.accountPage(\n        css.header(t(\"Account settings\")),\n        css.dataRow(\n          css.inlineSubHeader(t(\"Email\")),\n          css.email(user.email),\n        ),\n        css.dataRow(\n          css.inlineSubHeader(t(\"Name\")),\n          domComputed(this._isEditingName, isEditing => (\n            isEditing ? [\n              transientInput(\n                {\n                  initialValue: user.name,\n                  save: async (val) => {\n                    if (this._isNameValid.get()) {\n                      await this._updateUserName(val);\n                    }\n                  },\n                  close: () => { this._isEditingName.set(false); this._nameEdit.set(\"\"); },\n                },\n                { size: \"5\" }, // Lower size so that input can shrink below ~152px.\n                dom.on(\"input\", (_ev, el) => this._nameEdit.set(el.value)),\n                css.flexGrow.cls(\"\"),\n              ),\n              css.textBtn(\n                css.icon(\"Settings\"), t(\"Save\"),\n                // No need to save on 'click'. The transient input already does it on close.\n              ),\n            ] : [\n              css.name(user.name),\n              css.textBtn(\n                css.icon(\"Settings\"), t(\"Edit\"),\n                dom.on(\"click\", () => this._isEditingName.set(true)),\n              ),\n            ]\n          )),\n          testId(\"username\"),\n        ),\n        // show warning for invalid name but not for the empty string\n        dom.maybe(use => use(this._nameEdit) && !use(this._isNameValid), this._buildNameWarningsDom.bind(this)),\n        css.header(t(\"Password & security\")),\n        css.dataRow(\n          css.inlineSubHeader(t(\"Login method\")),\n          css.loginMethod(user.loginMethod),\n          user.loginMethod === \"Email + Password\" ? css.textBtn(t(\"Change password\"),\n            dom.on(\"click\", () => this._showChangePasswordDialog()),\n          ) : null,\n          testId(\"login-method\"),\n        ),\n        user.loginMethod !== \"Email + Password\" ? null : dom.frag(\n          css.dataRow(\n            labeledSquareCheckbox(\n              this._allowGoogleLogin,\n              t(\"Allow signing in to this account with Google\"),\n              testId(\"allow-google-login-checkbox\"),\n            ),\n            testId(\"allow-google-login\"),\n          ),\n          css.subHeader(t(\"Two-factor authentication\")),\n          css.description(\n            t(\"Two-factor authentication is an extra layer of security for your Grist account \\\ndesigned to ensure that you're the only person who can access your account, even if someone knows your password.\"),\n          ),\n          dom.create(MFAConfig, user),\n        ),\n        css.header(t(\"Theme\")),\n        isFeatureEnabled(\"themes\") ? dom.create(ThemeConfig, this._appModel) : null,\n        css.subHeader(t(\"Language\")),\n        css.dataRow({ style: \"width: 300px\" },\n          select(userLocale, languageOptions, {\n            renderOptionArgs: () => {\n              return dom.cls(cssFirstUpper.className);\n            },\n          }),\n          testId(\"language\"),\n        ),\n        css.header(t(\"API\")),\n        css.dataRow(css.inlineSubHeader(t(\"API Key\")), css.content(\n          dom.create(ApiKey, {\n            apiKey: this._apiKey,\n            onCreate: () => this._createApiKey(),\n            onDelete: () => this._deleteApiKey(),\n            anonymous: false,\n            inputArgs: [{ size: \"5\" }], // Lower size so that input can shrink below ~152px.\n          }),\n        )),\n        !getGristConfig().canCloseAccount ? null : [\n          dom.create(DeleteAccountDialog, user),\n        ],\n      ),\n      testId(\"body\"),\n      )));\n  }\n\n  private _buildHeaderMain() {\n    return dom.frag(\n      cssBreadcrumbs({ style: \"margin-left: 16px;\" },\n        cssLink(\n          urlState().setLinkUrl({}),\n          \"Home\",\n          testId(\"home\"),\n        ),\n        separator(\" / \"),\n        dom(\"span\", \"Account\"),\n      ),\n      createTopBarHome(this._appModel),\n    );\n  }\n\n  private async _fetchApiKey() {\n    this._apiKey.set(await this._appModel.api.fetchApiKey());\n  }\n\n  private async _createApiKey() {\n    this._apiKey.set(await this._appModel.api.createApiKey());\n  }\n\n  private async _deleteApiKey() {\n    await this._appModel.api.deleteApiKey();\n    this._apiKey.set(\"\");\n  }\n\n  private async _fetchUserProfile() {\n    this._userObs.set(await this._appModel.api.getUserProfile());\n  }\n\n  private async _fetchAll() {\n    await Promise.all([\n      this._fetchApiKey(),\n      this._fetchUserProfile(),\n    ]);\n  }\n\n  private async _updateUserName(val: string) {\n    const user = this._userObs.get();\n    if (user && val && val === user.name) { return; }\n\n    await this._appModel.api.updateUserName(val);\n    await this._fetchAll();\n  }\n\n  private async _updateAllowGooglelogin(allowGoogleLogin: boolean) {\n    await this._appModel.api.updateAllowGoogleLogin(allowGoogleLogin);\n    await this._fetchUserProfile();\n  }\n\n  private _showChangePasswordDialog() {\n    return buildChangePasswordDialog();\n  }\n\n  /**\n  * Builds dom to show marning messages to the user.\n  */\n  private _buildNameWarningsDom() {\n    return cssWarnings(\n      t(\"Names only allow letters, numbers and certain special characters\"),\n      testId(\"username-warning\"),\n    );\n  }\n\n  private _setPageTitle() {\n    this.autoDispose(subscribe(this._currentPage, (_use, page): string => {\n      const suffix = getPageTitleSuffix(getGristConfig());\n      switch (page) {\n        case undefined:\n        case \"account\": {\n          return document.title = `Account${suffix}`;\n        }\n      }\n    }));\n  }\n}\n\nconst cssWarnings = styled(css.warning, `\n  margin: -8px 0 0 110px;\n`);\n\nconst cssFirstUpper = styled(\"div\", `\n  & > div::first-letter {\n    text-transform: capitalize;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/AccountPageCss.ts",
    "content": "import { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon as gristIcon } from \"app/client/ui2018/icons\";\n\nimport { styled } from \"grainjs\";\n\nexport const container = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  overflow: auto;\n`);\n\nexport const accountPage = styled(\"div\", `\n  max-width: 600px;\n  margin-top: auto;\n  margin-bottom: auto;\n  padding: 16px;\n`);\n\nexport const content = styled(\"div\", `\n  flex: 1 1 300px;\n`);\n\nexport const textBtn = styled(\"button\", `\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.controlFg};\n  cursor: pointer;\n  margin-left: 16px;\n  background-color: transparent;\n  border: none;\n  padding: 0;\n  text-align: left;\n  min-width: 110px;\n\n  &:hover {\n    color: ${theme.controlHoverFg};\n  }\n`);\n\nexport const icon = styled(gristIcon, `\n  background-color: ${theme.controlFg};\n  margin: 0 4px 2px 0;\n\n  .${textBtn.className}:hover > & {\n    background-color: ${theme.controlHoverFg};\n  }\n`);\n\nexport const description = styled(\"div\", `\n  color: ${theme.lightText};\n  font-size: 13px;\n`);\n\nexport const flexGrow = styled(\"div\", `\n  flex-grow: 1;\n`);\n\nexport const name = styled(flexGrow, `\n  color: ${theme.text};\n  word-break: break-word;\n`);\n\nexport const email = styled(\"div\", `\n  color: ${theme.text};\n  word-break: break-word;\n`);\n\nexport const loginMethod = styled(flexGrow, `\n  color: ${theme.text};\n  word-break: break-word;\n`);\n\nexport const warning = styled(\"div\", `\n  color: ${theme.errorText};\n`);\n\nexport const header = styled(\"div\", `\n  height: 32px;\n  line-height: 32px;\n  margin: 28px 0 16px 0;\n  color: ${theme.text};\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n`);\n\nexport const subHeader = styled(\"div\", `\n  color: ${theme.text};\n  padding: 8px 0;\n  vertical-align: top;\n  font-weight: bold;\n  display: block;\n`);\n\nexport const inlineSubHeader = styled(subHeader, `\n  display: inline-block;\n  min-width: 110px;\n`);\n\nexport const dataRow = styled(\"div\", `\n  margin: 8px 0px;\n  display: flex;\n  align-items: baseline;\n  gap: 2px;\n`);\n"
  },
  {
    "path": "app/client/ui/AccountWidget.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl } from \"app/client/lib/urlUtils\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { getAdminPanelName } from \"app/client/ui/AdminPanelName\";\nimport { manageTeamUsers } from \"app/client/ui/OpenUserManager\";\nimport { maybeAddSiteSwitcherSection } from \"app/client/ui/SiteSwitcher\";\nimport { createUserImage } from \"app/client/ui/UserImage\";\nimport * as viewport from \"app/client/ui/viewport\";\nimport { bigPrimaryButtonLink, primaryButtonLink } from \"app/client/ui2018/buttons\";\nimport { mediaDeviceNotSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport {\n  menu,\n  menuDivider,\n  menuItem,\n  menuItemLink,\n  menuSubHeader,\n} from \"app/client/ui2018/menus\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { commonUrls, isFeatureEnabled } from \"app/common/gristUrls\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport * as roles from \"app/common/roles\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Disposable, dom, DomElementArg, styled } from \"grainjs\";\nimport { cssMenuItem } from \"popweasel\";\n\nconst t = makeT(\"AccountWidget\");\n\n/**\n * Render the user-icon that opens the account menu.\n *\n * When no user is logged in, render \"Sign In\" and \"Sign Up\" buttons.\n *\n * When no user is logged in and a template document is open, render a \"Use This Template\"\n * button.\n */\nexport class AccountWidget extends Disposable {\n  constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) {\n    super();\n  }\n\n  public buildDom() {\n    return cssAccountWidget(\n      dom.domComputed((use) => {\n        const isTemplate = Boolean(this._docPageModel && use(this._docPageModel.isTemplate));\n        const user = this._appModel.currentValidUser;\n        if (!user && isTemplate) {\n          return this._buildUseThisTemplateButton();\n        } else if (!user) {\n          return this._buildSignInAndSignUpButtons();\n        } else {\n          return this._buildAccountMenuButton(user);\n        }\n      }),\n      testId(\"dm-account\"),\n    );\n  }\n\n  private _buildAccountMenuButton(user: FullUser | null) {\n    return cssUserIcon(\n      createUserImage(user, \"medium\", testId(\"user-icon\")),\n      menu(() => this._makeAccountMenu(user), { placement: \"bottom-end\" }),\n    );\n  }\n\n  private _buildSignInAndSignUpButtons() {\n    return [\n      cssSigninButton(t(\"Sign in\"),\n        cssSigninButton.cls(\"-secondary\"),\n        dom.on(\"click\", () => { this._docPageModel?.clearUnsavedChanges(); }),\n        dom.attr(\"href\", (use) => {\n          // Keep the redirect param of the login URL fresh.\n          use(urlState().state);\n          return getLoginUrl();\n        }),\n        testId(\"user-sign-in\"),\n      ),\n      cssSigninButton(t(\"Sign up\"),\n        dom.on(\"click\", () => { this._docPageModel?.clearUnsavedChanges(); }),\n        dom.attr(\"href\", (use) => {\n          // Keep the redirect param of the signup URL fresh.\n          use(urlState().state);\n          return getSignupUrl();\n        }),\n        testId(\"user-sign-up\"),\n      ),\n    ];\n  }\n\n  private _buildUseThisTemplateButton() {\n    return cssUseThisTemplateButton(t(\"Use This Template\"),\n      dom.attr(\"href\", (use) => {\n        const { doc: srcDocId } = use(urlState().state);\n        return getLoginOrSignupUrl({ srcDocId });\n      }),\n      dom.on(\"click\", () => { this._docPageModel?.clearUnsavedChanges(); }),\n      testId(\"dm-account-use-this-template\"),\n    );\n  }\n\n  /**\n   * Renders the content of the account menu, with a list of available orgs, settings, and sign-out.\n   * Note that `user` should NOT be anonymous (none of the items are really relevant).\n   */\n  private _makeAccountMenu(user: FullUser | null): DomElementArg[] {\n    const currentOrg = this._appModel.currentOrg;\n\n    // The 'Document settings' item, when there is an open document.\n    const documentSettingsItem = this._docPageModel ? menuItemLink(\n      urlState().setLinkUrl({ docPage: \"settings\" }),\n      t(\"Document settings\"),\n      testId(\"dm-doc-settings\"),\n    ) : null;\n\n    // The item to toggle mobile mode (presence of viewport meta tag).\n    const mobileModeToggle = menuItem(viewport.toggleViewport,\n      cssSmallDeviceOnly.cls(\"\"),   // Only show this toggle on small devices.\n      t(\"Toggle Mobile Mode\"),\n      cssCheckmark(\"Tick\", dom.show(viewport.viewportEnabled)),\n      testId(\"usermenu-toggle-mobile\"),\n    );\n\n    if (!user) {\n      return [\n        menuItemLink({ href: getLoginOrSignupUrl() }, t(\"Sign in\")),\n        menuDivider(),\n        documentSettingsItem,\n        menuItemLink({ href: commonUrls.plans }, t(\"Pricing\")),\n        mobileModeToggle,\n      ];\n    }\n\n    const users = this._appModel.topAppModel.users;\n    const isExternal = user?.loginMethod === \"External\";\n    return [\n      cssUserInfo(\n        createUserImage(user, \"large\"),\n        cssUserName(dom(\"span\", user.name, testId(\"usermenu-name\")),\n          cssEmail(user.email, testId(\"usermenu-email\")),\n        ),\n      ),\n      menuItemLink(urlState().setLinkUrl({ account: \"account\" }), t(\"Profile settings\"), testId(\"dm-account-settings\")),\n\n      documentSettingsItem,\n\n      // Show 'Organization Settings' when on a home page of a valid org.\n      (!this._docPageModel && currentOrg && this._appModel.isTeamSite ?\n        menuItem(() => manageTeamUsers({ org: currentOrg, user, api: this._appModel.api }),\n          roles.canEditAccess(currentOrg.access) ? t(\"Manage team\") : t(\"Access Details\"),\n          testId(\"dm-org-access\")) :\n        // Don't show on doc pages, or for personal orgs.\n        null),\n\n      this._maybeBuildBillingPageMenuItem(),\n      this._maybeBuildActivationPageMenuItem(),\n      this._maybeBuildAdminPanelMenuItem(),\n      this._maybeBuildSupportGristButton(),\n\n      // TODO: Uncomment when team audit logs are ready to use.\n      // this._maybeBuildAuditLogsMenuItem(),\n\n      mobileModeToggle,\n\n      // TODO Add section (\"Here right now\") listing icons of other users currently on this doc.\n      // (See Invision \"Panels\" near the bottom.)\n\n      // In case of a single-org setup, skip all the account-switching UI. We'll also skip the\n      // org-listing UI below.\n      this._appModel.topAppModel.isSingleOrg || !isFeatureEnabled(\"multiAccounts\") ? [] : [\n        menuDivider(),\n        menuSubHeader(dom.text(use => use(users).length > 1 ? t(\"Switch Accounts\") : t(\"Accounts\"))),\n        dom.forEach(users, (_user) => {\n          if (_user.id === user.id) { return null; }\n          return menuItem(() => this._switchAccount(_user),\n            cssSmallIconWrap(createUserImage(_user, \"small\")),\n            cssOtherEmail(_user.email, testId(\"usermenu-other-email\")),\n          );\n        }),\n        isExternal ? null : menuItemLink({ href: getLoginUrl() }, t(\"Add account\"), testId(\"dm-add-account\")),\n      ],\n\n      menuItemLink({ href: getLogoutUrl() }, t(\"Sign out\"), testId(\"dm-log-out\")),\n\n      maybeAddSiteSwitcherSection(this._appModel),\n    ];\n  }\n\n  // Switch BrowserSession to use the given user for the currently loaded org.\n  private async _switchAccount(user: FullUser) {\n    await this._appModel.switchUser(user);\n    if (urlState().state.get().doc) {\n      // Document access level may have changed.\n      // If it was not accessible but now is, we currently need to reload the page to get\n      // a complete gristConfig for the document from the server.\n      // If it was accessible but now is not, it would suffice to reconnect the web socket.\n      // For simplicity, just reload from server in either case.\n      // TODO: get fancier here to avoid reload.\n      window.location.reload(true);\n      return;\n    }\n    this._appModel.topAppModel.initialize();\n  }\n\n  private _maybeBuildBillingPageMenuItem() {\n    const { deploymentType } = getGristConfig();\n    if (deploymentType !== \"saas\") { return null; }\n\n    const { currentValidUser, currentOrg, isTeamSite } = this._appModel;\n    const canViewBillingPage = Boolean(\n      currentOrg?.billingAccount && // have access to billing account\n      (currentOrg.billingAccount.isManager || // is billing manager\n        currentValidUser?.isSupport || // or support\n        this._appModel.isInstallAdmin())); // or install admin\n\n    return isTeamSite ?\n      // For links, disabling with just a class is hard; easier to just not make it a link.\n      // TODO weasel menus should support disabling menuItemLink.\n      (canViewBillingPage ?\n        menuItemLink(urlState().setLinkUrl({ billing: \"billing\" }), t(\"Billing account\")) :\n        menuItem(() => null, t(\"Billing account\"), dom.cls(\"disabled\", true))\n      ) :\n      menuItem(() => this._appModel.showUpgradeModal(), t(\"Upgrade Plan\"));\n  }\n\n  private _maybeBuildActivationPageMenuItem() {\n    const { deploymentType } = getGristConfig();\n    if (deploymentType !== \"enterprise\" || !this._appModel.isInstallAdmin()) {\n      return null;\n    }\n\n    return menuItemLink(t(\"Activation\"), urlState().setLinkUrl({ activation: \"activation\" }));\n  }\n\n  private _maybeBuildAdminPanelMenuItem() {\n    // Only show Admin Panel item to the installation admins.\n    if (this._appModel.currentUser?.isInstallAdmin) {\n      return menuItemLink(\n        getAdminPanelName(),\n        urlState().setLinkUrl({ adminPanel: \"admin\" }),\n        testId(\"usermenu-admin-panel\"),\n      );\n    }\n  }\n\n  private _maybeBuildSupportGristButton() {\n    const { deploymentType } = getGristConfig();\n    const isEnabled = (deploymentType === \"core\") && isFeatureEnabled(\"supportGrist\");\n    if (isEnabled) {\n      return menuItemLink(t(\"Support Grist\"), \" 💛\",\n        { href: commonUrls.githubSponsorGristLabs, target: \"_blank\" },\n        testId(\"usermenu-support-grist\"),\n      );\n    }\n  }\n\n  // TODO: Uncomment when team audit logs are ready to use.\n  // private _maybeBuildAuditLogsMenuItem() {\n  //   const { deploymentType } = getGristConfig();\n  //   if (\n  //     !this._appModel.isOwner() ||\n  //     !this._appModel.isTeamSite ||\n  //     !deploymentType ||\n  //     ![\"saas\", \"core\", \"enterprise\"].includes(deploymentType)\n  //   ) {\n  //     return null;\n  //   }\n\n  //   return menuItemLink(\n  //     t(\"Audit Logs\"),\n  //     menuAnnotate(t(\"New\")),\n  //     urlState().setLinkUrl({ auditLogs: \"audit-logs\" })\n  //   );\n  // }\n}\n\nconst cssAccountWidget = styled(\"div\", `\n  display: flex;\n  margin-right: 16px;\n  white-space: nowrap;\n`);\n\nexport const cssUserIcon = styled(unstyledButton, `\n  height: 48px;\n  width: 48px;\n  padding: 8px;\n  cursor: pointer;\n  outline-offset: -3px;\n`);\n\nconst cssUserInfo = styled(\"div\", `\n  padding: 12px 24px 12px 16px;\n  min-width: 200px;\n  display: flex;\n  align-items: center;\n`);\n\nconst cssUserName = styled(\"div\", `\n  margin-left: 8px;\n  font-size: ${vars.mediumFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n  color: ${theme.text};\n`);\n\nconst cssEmail = styled(\"div\", `\n  margin-top: 4px;\n  font-size: ${vars.smallFontSize};\n  font-weight: initial;\n  color: ${theme.lightText};\n`);\n\nconst cssSmallIconWrap = styled(\"div\", `\n  flex: none;\n  margin: -4px 8px -4px 0px;\n`);\n\nconst cssOtherEmail = styled(\"div\", `\n  color: ${theme.lightText};\n  .${cssMenuItem.className}-sel & {\n    color: ${theme.menuItemSelectedFg};\n  }\n`);\n\nconst cssCheckmark = styled(icon, `\n  flex: none;\n  margin-left: 16px;\n  --icon-color: ${theme.accentIcon};\n`);\n\n// Note that this css class hides the item when the device width is small (not based on viewport\n// width, which may be larger). This only appropriate for when to enable the \"mobile mode\" toggle.\nconst cssSmallDeviceOnly = styled(menuItem, `\n  @media ${mediaDeviceNotSmall} {\n    & {\n      display: none;\n    }\n  }\n`);\n\nconst cssSigninButton = styled(bigPrimaryButtonLink, `\n  display: flex;\n  align-items: center;\n  font-weight: 700;\n  min-height: unset;\n  height: 36px;\n  padding: 8px 16px 8px 16px;\n  font-size: ${vars.mediumFontSize};\n\n  &-secondary, &-secondary:hover {\n    background-color: transparent;\n    border-color: transparent;\n    color: ${theme.text};\n  }\n`);\n\nconst cssUseThisTemplateButton = styled(primaryButtonLink, `\n  margin: 8px;\n`);\n"
  },
  {
    "path": "app/client/ui/AccountWidgetCss.ts",
    "content": "import { theme, vars } from \"app/client/ui2018/cssVars\";\n\nimport { styled } from \"grainjs\";\n\nexport const cssUserInfo = styled(\"div\", `\n  padding: 12px 24px 12px 16px;\n  min-width: 200px;\n  display: flex;\n  align-items: center;\n`);\n\nexport const cssUserName = styled(\"div\", `\n  margin-left: 8px;\n  font-size: ${vars.mediumFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n  color: ${theme.text};\n`);\n\nexport const cssEmail = styled(\"div\", `\n  margin-top: 4px;\n  font-size: ${vars.smallFontSize};\n  font-weight: initial;\n  color: ${theme.lightText};\n`);\n"
  },
  {
    "path": "app/client/ui/ActiveUserList.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { UserPresenceModel } from \"app/client/models/UserPresenceModel\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { createUserImage, cssUserImage } from \"app/client/ui/UserImage\";\nimport { cssHideForNarrowScreen } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu } from \"app/client/ui2018/menus\";\nimport { visuallyHidden } from \"app/client/ui2018/visuallyHidden\";\nimport { VisibleUserProfile } from \"app/common/ActiveDocAPI\";\nimport { nativeCompare } from \"app/common/gutil\";\nimport { components, tokens } from \"app/common/ThemePrefs\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport {\n  Computed,\n  dom,\n  domComputed,\n  DomElementArg, IDisposableOwner,\n  makeTestId,\n  Observable,\n  styled,\n} from \"grainjs\";\n\nconst t = makeT(\"ActiveUserList\");\nconst testId = makeTestId(\"test-aul-\");\n\nexport function buildActiveUserList(owner: IDisposableOwner, userPresenceModel: UserPresenceModel) {\n  const totalUserIconSlots = 4;\n\n  const visibleUserProfilesObs = Computed.create(owner, (use) => {\n    const userProfiles = use(userPresenceModel.userProfiles);\n    // Clamps max users between 0 and 99 to prevent display issues and errors\n    const maxUsers = Math.min(Math.max(0, getGristConfig().userPresenceMaxUsers ?? 99), 99);\n    return userProfiles\n      .slice()\n      .sort(compareUserProfiles)\n      // Limits the display to avoid overly long lists on public documents.\n      .slice(0, maxUsers);\n  });\n\n  const userMetadataObs = Computed.create(owner, (use) => {\n    const visibleUsers = use(visibleUserProfilesObs);\n    const totalUsers = visibleUsers.length;\n    const totalVisibleUserIcons =\n      totalUsers <= totalUserIconSlots ? totalUserIconSlots : totalUserIconSlots - 1;\n    const totalHiddenUserIcons = totalUsers - totalVisibleUserIcons;\n    return {\n      totalVisibleUserIcons,\n      totalHiddenUserIcons,\n    };\n  });\n\n  const userIconProfilesObs = Computed.create(owner, (use) => {\n    const totalIcons = use(userMetadataObs).totalVisibleUserIcons;\n    const profiles = use(visibleUserProfilesObs).slice(0, totalIcons);\n    // Reverses the order of user images, so that the z-index is automatically correct without explicitly setting it\n    profiles.reverse();\n    return profiles;\n  });\n\n  const showRemainingUsersIconObs = Computed.create(owner, (use) => {\n    return totalUserIconSlots < use(visibleUserProfilesObs).length;\n  });\n\n  const isRemainingUsersMenuOpen = Observable.create(owner, false);\n\n  const computedUserIcons = dom.forEach(userIconProfilesObs, (user) => {\n    return dom(\"li\", createUserIndicator(user, isRemainingUsersMenuOpen));\n  });\n\n  const remainingUsersIndicator = dom.maybe(showRemainingUsersIconObs, () => {\n    return dom(\"li\",\n      createRemainingUsersIndicator(\n        visibleUserProfilesObs,\n        userMetadataObs,\n        isRemainingUsersMenuOpen,\n      ),\n    );\n  });\n\n  return cssActiveUserList(\n    cssHideForNarrowScreen.cls(\"\"),\n    remainingUsersIndicator,\n    computedUserIcons,\n    { \"aria-label\": t(\"active user list\") },\n    testId(\"container\"),\n  );\n}\n\nfunction createUserIndicator(\n  user: VisibleUserProfile,\n  isRemainingUsersMenuOpen: Observable<boolean>,\n) {\n  return createUserListImage(\n    user,\n    dom.hide(isRemainingUsersMenuOpen),\n    hoverTooltip(createTooltipContent(user), { key: \"topBarBtnTooltip\" }),\n    { \"aria-label\": `${t(\"active user\")}: ${user.name}` },\n    testId(\"user-icon\"),\n  );\n}\n\nfunction createRemainingUsersIndicator(\n  usersObs: Observable<VisibleUserProfile[]>,\n  metadataObs: Observable<UsersMetadata>,\n  isRemainingUsersMenuOpen: Observable<boolean>,\n) {\n  return cssRemainingUsersButton(\n    cssRemainingUsersImage(\n      dom.domComputed((use) => {\n        if (use(isRemainingUsersMenuOpen)) {\n          return icon(\"CrossBig\");\n        } else {\n          return `+${use(metadataObs).totalHiddenUserIcons}`;\n        }\n      }),\n      cssUserImage.cls(\"-medium\"),\n      dom.style(\"font-size\", \"12px\"),\n    ),\n    menu(\n      (ctl) => {\n        isRemainingUsersMenuOpen.set(true);\n        ctl.onDispose(() => isRemainingUsersMenuOpen.set(false));\n\n        return domComputed(usersObs, users => users.map(user => remainingUsersMenuItem(\n          createUserImage(user, \"medium\"),\n          dom(\"div\", createUsername(user.name), createEmail(user.email)),\n          testId(\"user-list-user\"),\n        )));\n      },\n      {\n        // Avoids an issue where the menu code will infinitely loop trying to find the\n        // next selectable option, when using keyboard navigation, due to having none.\n        allowNothingSelected: true,\n      },\n    ),\n    { \"aria-label\": t(\"open full active user list\") },\n    testId(\"all-users-button\"),\n  );\n}\n\nconst createTooltipContent = (user: VisibleUserProfile) => {\n  return [createUsername(user.name), createEmail(user.email)];\n};\n\nfunction createUsername(name: string) {\n  return cssUsername(visuallyHidden(\"Name: \"), dom(\"span\", testId(\"user-name\"), name));\n}\n\nfunction createEmail(email?: string) {\n  if (!email) {\n    return null;\n  }\n  return cssEmail(visuallyHidden(\"Email: \"), dom(\"span\", testId(\"user-email\"), email));\n}\n\nconst cssUsername = styled(\"div\", `\n  font-weight: ${tokens.headerControlTextWeight};\n`);\n\nconst cssEmail = styled(\"div\", `\n  font-size: ${tokens.smallFontSize};\n`);\n\n// Flex-direction is reversed to give us the correct overlaps without messing with z-indexes.\nconst cssActiveUserList = styled(\"ul\", `\n  display: flex;\n  align-items: center;\n  justify-content: end;\n  list-style: none;\n\n  margin: 0;\n  padding: 0;\n  border: 0 solid;\n\n  flex-direction: row-reverse;\n\n  & > * {\n    margin-left: -4px\n  }\n\n  & > :last-child {\n    margin-left: 0px;\n  }\n`);\n\nconst userImageBorderCss = `\n  border: 2px solid ${components.topHeaderBg};\n  box-sizing: content-box;\n`;\n\nconst createStyledUserImage = styled(createUserImage, `\n  ${userImageBorderCss};\n`);\n\nconst createUserListImage = (user: Parameters<typeof createUserImage>[0], ...args: DomElementArg[]) =>\n  createStyledUserImage(\n    user,\n    \"medium\",\n    cssUserImage.cls(\"-reduced\"),\n    ...args,\n  );\n\nconst cssRemainingUsersImage = styled(cssUserImage, `\n  margin-left: -4px;\n  background-color: ${tokens.secondary};\n  ${userImageBorderCss};\n  --icon-color: ${tokens.white};\n\n  &:hover {\n    background-color: ${tokens.secondaryMuted};\n  }\n`);\n\nconst cssRemainingUsersButton = styled(\"button\", `\n  margin: 0;\n  padding: 0;\n  border: 0 solid;\n  font: inherit;\n  letter-spacing: inherit;\n  color: inherit;\n  border-radius: 0;\n  background-color: transparent;\n  opacity: 1;\n  appearance: button;\n`);\n\nexport const remainingUsersMenuItem = styled(`div`, `\n  display: flex;\n  justify-content: flex-start;\n  padding: var(--weaseljs-menu-item-padding, 8px 24px);\n  align-items: center;\n  color: ${components.menuItemFg};\n  --icon-color: ${components.accentIcon};\n  text-transform: none;\n\n  & > :first-child {\n    margin-right: 5px;\n  }\n`);\n\nfunction compareUserProfiles(a: VisibleUserProfile, b: VisibleUserProfile) {\n  return nativeCompare(a.isAnonymous, b.isAnonymous) || nativeCompare(a.name, b.name);\n}\n\ninterface UsersMetadata {\n  totalVisibleUserIcons: number;\n  totalHiddenUserIcons: number;\n}\n"
  },
  {
    "path": "app/client/ui/AddNewButton.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\n\nimport { dom, DomElementArg, Observable, styled } from \"grainjs\";\n\nconst t = makeT(`AddNewButton`);\n\nexport function addNewButton(\n  {\n    isOpen,\n    isDisabled = false,\n  }: {\n    isOpen: Observable<boolean> | boolean,\n    isDisabled?: boolean\n  },\n  ...args: DomElementArg[]\n) {\n  return cssAddNewButton(\n    cssAddNewButton.cls(\"-open\", isOpen),\n    cssAddNewButton.cls(\"-disabled\", isDisabled),\n    // Setting spacing as flex items allows them to shrink faster when there isn't enough space.\n    cssLeftMargin(),\n    cssAddText(t(\"Add new\")),\n    dom(\"div\", { style: \"flex: 1 1 16px\" }),\n    cssPlusButton(\n      cssPlusButton.cls(\"-disabled\", isDisabled),\n      cssPlusIcon(\"Plus\"),\n    ),\n    dom(\"div\", { style: \"flex: 0 1 16px\" }),\n    ...args,\n  );\n}\n\nexport const cssAddNewButton = styled(unstyledButton, `\n  display: flex;\n  align-items: center;\n  margin: 22px 0px 22px 0px;\n  height: 40px;\n  color: ${theme.controlPrimaryFg};\n  border: none;\n  border-radius: 4px;\n\n  cursor: default;\n  text-align: left;\n  font-size: ${vars.bigControlFontSize};\n  font-weight: bold;\n  overflow: hidden;\n\n  /* make sure keyboard highlight is not glued to the button,\n  as it is the same color as the button background */\n  outline-offset: 2px;\n\n  --circle-color: ${theme.addNewCircleSmallBg};\n\n  &:hover, &.weasel-popup-open {\n    --circle-color: ${theme.addNewCircleSmallHoverBg};\n  }\n  &-open {\n    margin: 22px 16px 22px 16px;\n    background-color: ${theme.controlPrimaryBg};\n    --circle-color: ${theme.addNewCircleBg};\n  }\n  &-open:hover, &-open.weasel-popup-open {\n    background-color: ${theme.controlPrimaryHoverBg};\n    --circle-color: ${theme.addNewCircleHoverBg};\n  }\n\n  &-disabled, &-disabled:hover {\n    color: ${theme.controlDisabledFg};\n    background-color: ${theme.controlDisabledBg}\n  }\n`);\nconst cssLeftMargin = styled(\"div\", `\n  flex: 0 1 24px;\n  display: none;\n  .${cssAddNewButton.className}-open & {\n    display: block;\n  }\n`);\nconst cssAddText = styled(\"div\", `\n  color: ${theme.controlPrimaryFg};\n  flex: 0 0.5 content;\n  white-space: nowrap;\n  min-width: 0px;\n  display: none;\n  .${cssAddNewButton.className}-open & {\n    display: block;\n  }\n`);\nconst cssPlusButton = styled(\"div\", `\n  flex: none;\n  height: 28px;\n  width: 28px;\n  border-radius: 14px;\n  background-color: var(--circle-color);\n  text-align: center;\n  &-disabled {\n    background-color: ${theme.controlDisabledBg};\n  }\n`);\nconst cssPlusIcon = styled(icon, `\n  background-color: ${theme.addNewCircleFg};\n  margin-top: 6px;\n`);\n"
  },
  {
    "path": "app/client/ui/AddNewTip.ts",
    "content": "import { HomeModel } from \"app/client/models/HomeModel\";\n\nexport function attachAddNewTip(home: HomeModel): (el: Element) => void {\n  return () => {\n    if (shouldShowAddNewTip(home)) {\n      showAddNewTip(home);\n    }\n  };\n}\n\nfunction shouldShowAddNewTip(home: HomeModel): boolean {\n  return (\n    // Only show if the user is an owner or editor.\n    home.app.isOwnerOrEditor() &&\n    // And the tip hasn't been shown before.\n    home.shouldShowAddNewTip.get() &&\n    // And the site isn't empty.\n    !home.empty.get() &&\n    // And home page cards aren't being shown.\n    !(home.currentPage.get() === \"all\" && !home.onlyShowDocuments.get()) &&\n    // And the workspace loaded correctly.\n    home.available.get() &&\n    // And the current page isn't /p/trash; the Add New button is limited there.\n    home.currentPage.get() !== \"trash\"\n  );\n}\n\nfunction showAddNewTip(home: HomeModel): void {\n  const addNewButton = document.querySelector(\".behavioral-prompt-add-new\");\n  if (!addNewButton) {\n    console.warn(\"AddNewTip failed to find Add New button\");\n    return;\n  }\n  if (!isVisible(addNewButton as HTMLElement)) {\n    return;\n  }\n\n  home.app.behavioralPromptsManager.showPopup(addNewButton, \"addNew\", {\n    popupOptions: {\n      placement: \"right-start\",\n    },\n    onDispose: () => home.shouldShowAddNewTip.set(false),\n  });\n}\n\nfunction isVisible(element: HTMLElement): boolean {\n  // From https://github.com/jquery/jquery/blob/c66d4700dcf98efccb04061d575e242d28741223/src/css/hiddenVisibleSelectors.js.\n  return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);\n}\n"
  },
  {
    "path": "app/client/ui/AdminLeftPanel.ts",
    "content": "import { makeTestId } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { AppHeader } from \"app/client/ui/AppHeader\";\nimport * as css from \"app/client/ui/LeftPanelCommon\";\nimport { PageSidePanel } from \"app/client/ui/PagePanels\";\nimport { infoTooltip } from \"app/client/ui/tooltips\";\nimport { colors, vars } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { AdminPanelPage } from \"app/common/gristUrls\";\nimport { commonUrls } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Computed, dom, DomContents, MultiHolder, Observable, styled } from \"grainjs\";\n\nimport type { AppModel } from \"app/client/models/AppModel\";\n\nconst t = makeT(\"AdminPanel\");\nconst testId = makeTestId(\"test-admin-controls-\");\n\n// Check if the AdminControls feature is available, so that we can show it as such in the UI.\nexport function areAdminControlsAvailable(): boolean {\n  return Boolean(getGristConfig().adminControls);\n}\n\n// Collects and exposes translations, used for buildAdminLeftPanel() below, and for breadcrumbs in\n// AdminPanel.ts.\nexport function getPageNames() {\n  const settings: DomContents = t(\"Settings\");\n  const adminControls: DomContents = t(\"Admin Controls\");\n  return {\n    settings,\n    adminControls,\n    pages: {\n      admin: { section: settings, name: t(\"Installation\") },\n      users: { section: adminControls, name: t(\"Users\") },\n      orgs: { section: adminControls, name: t(\"Orgs\") },\n      workspaces: { section: adminControls, name: t(\"Workspaces\") },\n      docs: { section: adminControls, name: t(\"Docs\") },\n    } as { [key in AdminPanelPage]: { section: DomContents, name: DomContents } },\n  };\n}\n\nexport function buildAdminLeftPanel(owner: MultiHolder, appModel: AppModel): PageSidePanel {\n  const panelOpen = Observable.create(owner, true);\n  const pageObs = Computed.create(owner, use => use(urlState().state).adminPanel);\n  const pageNames = getPageNames();\n\n  function buildPageEntry(page: AdminPanelPage, icon: IconName, available: boolean = true) {\n    return css.cssPageEntry(\n      css.cssPageEntry.cls(\"-selected\", use => use(pageObs) === page),\n      css.cssPageEntry.cls(\"-disabled\", !available),\n      css.cssPageLink(\n        css.cssPageIcon(icon),\n        css.cssLinkText(pageNames.pages[page].name),\n        available ? urlState().setLinkUrl({ adminPanel: page }) : null,    // Disable link if page isn't available.\n      ),\n      testId(\"page-\" + page),\n      testId(\"page\"),\n    );\n  }\n\n  const adminControlsAvailable = areAdminControlsAvailable();\n  const content = css.leftPanelBasic(appModel, panelOpen,\n    dom(\"div\",\n      css.cssTools.cls(\"-collapsed\", use => !use(panelOpen)),\n      css.cssSectionHeader(css.cssSectionHeaderText(pageNames.settings)),\n      buildPageEntry(\"admin\", \"Home\"),\n      css.cssSectionHeader(css.cssSectionHeaderText(pageNames.adminControls),\n        (adminControlsAvailable ?\n          infoTooltip(\"adminControls\", { popupOptions: { placement: \"bottom-start\" } }) :\n          cssEnterprisePill(\"Enterprise\", testId(\"enterprise-tag\"))\n        ),\n      ),\n      buildPageEntry(\"users\", \"AddUser\", adminControlsAvailable),\n      buildPageEntry(\"orgs\", \"Public\", adminControlsAvailable),\n      buildPageEntry(\"workspaces\", \"Board\", adminControlsAvailable),\n      buildPageEntry(\"docs\", \"Page\", adminControlsAvailable),\n      (adminControlsAvailable ? null :\n        cssPanelLink(cssLearnMoreLink(\n          { href: commonUrls.helpAdminControls, target: \"_blank\" },\n          t(\"Learn more\"), css.cssPageIcon(\"FieldLink\"),\n          testId(\"learn-more\"),\n        ))\n      ),\n    ),\n  );\n\n  return {\n    panelWidth: Observable.create(owner, 240),\n    panelOpen: panelOpen,\n    content,\n    header: dom.create(AppHeader, appModel),\n  };\n}\n\nconst cssEnterprisePill = styled(\"div\", `\n  display: inline;\n  padding: 2px 4px;\n  margin: 0 8px;\n  border-radius: 4px;\n  vertical-align: middle;\n  font-size: ${vars.smallFontSize};\n  background-color: ${colors.orange};\n  color: white;\n`);\n\nconst cssPanelLink = styled(\"div\", `\n  margin: 8px 24px;\n  .${css.cssTools.className}-collapsed > & {\n    visibility: hidden;\n  }\n`);\n\nconst cssLearnMoreLink = styled(cssLink, `\n  display: inline-flex;\n  gap: 8px;\n  align-items: center;\n`);\n"
  },
  {
    "path": "app/client/ui/AdminPanel.ts",
    "content": "import { buildHomeBanners } from \"app/client/components/Banners\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { markdown } from \"app/client/lib/markdown\";\nimport { getTimeFromNow } from \"app/client/lib/timeUtils\";\nimport { AdminCheckRequest, AdminChecks, probeDetails, ProbeDetails } from \"app/client/models/AdminChecks\";\nimport { AppModel, getHomeUrl, reportError } from \"app/client/models/AppModel\";\nimport { AuditLogsModel, AuditLogsModelImpl } from \"app/client/models/AuditLogsModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { cssEmail, cssUserInfo, cssUserName } from \"app/client/ui/AccountWidgetCss\";\nimport { showEnterpriseToggle } from \"app/client/ui/ActivationPage\";\nimport { buildAdminData } from \"app/client/ui/AdminControls\";\nimport { buildAdminLeftPanel, getPageNames } from \"app/client/ui/AdminLeftPanel\";\nimport {\n  AdminPanelControls,\n  AdminSection,\n  AdminSectionItem,\n  cssIconWrapper as cssWellIcon,\n  cssSection,\n  cssSectionTitle,\n  cssValueLabel,\n  cssWell,\n  cssWellContent,\n  cssWellTitle,\n  HidableToggle,\n} from \"app/client/ui/AdminPanelCss\";\nimport { getAdminPanelName } from \"app/client/ui/AdminPanelName\";\nimport { App } from \"app/client/ui/App\";\nimport { AuditLogStreamingConfig, getDestinationDisplayName } from \"app/client/ui/AuditLogStreamingConfig\";\nimport { AuthenticationSection } from \"app/client/ui/AuthenticationSection\";\nimport { InstallConfigsAPI } from \"app/client/ui/ConfigsAPI\";\nimport { pagePanels } from \"app/client/ui/PagePanels\";\nimport { SupportGristPage } from \"app/client/ui/SupportGristPage\";\nimport { ToggleEnterpriseWidget } from \"app/client/ui/ToggleEnterpriseWidget\";\nimport { createTopBarHome } from \"app/client/ui/TopBar\";\nimport { createUserImage } from \"app/client/ui/UserImage\";\nimport { cssBreadcrumbs, separator } from \"app/client/ui2018/breadcrumbs\";\nimport { basicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { mediaSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink, makeLinks } from \"app/client/ui2018/links\";\nimport { confirmModal, spinnerModal } from \"app/client/ui2018/modals\";\nimport { toggleSwitch } from \"app/client/ui2018/toggleSwitch\";\nimport { BootProbeInfo, BootProbeResult, SandboxingBootProbeDetails } from \"app/common/BootProbe\";\nimport { ConfigAPI } from \"app/common/ConfigAPI\";\nimport { delay } from \"app/common/delay\";\nimport { AdminPanelPage, commonUrls, getPageTitleSuffix, LatestVersionAvailable } from \"app/common/gristUrls\";\nimport { InstallAPI, InstallAPIImpl } from \"app/common/InstallAPI\";\nimport { MINIMAL_PROVIDER_KEY } from \"app/common/loginProviders\";\nimport { InstallAdminInfo } from \"app/common/LoginSessionAPI\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport * as version from \"app/common/version\";\n\nimport { Computed, Disposable, dom, IDisposable, MultiHolder, Observable, styled, UseCBOwner } from \"grainjs\";\n\nconst t = makeT(\"AdminPanel\");\n\n// A fortnight of milliseconds is the default time after which we\n// consider a version check to be stale. It's a big number, but we're\n// still far away from the max at Number.MAX_SAFE_INTEGER\nconst STALE_VERSION_CHECK_TIME_IN_MS = 14 * 24 * 60 * 60 * 1000;\n\nexport class AdminPanel extends Disposable {\n  private _page = Computed.create<AdminPanelPage>(this, use => use(urlState().state).adminPanel || \"admin\");\n\n  constructor(private _appModel: AppModel, private _appObj: App) {\n    super();\n    document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());\n  }\n\n  public buildDom() {\n    const pageObs = Computed.create(this, use => use(urlState().state).adminPanel || \"admin\");\n    return pagePanels({\n      leftPanel: buildAdminLeftPanel(this, this._appModel),\n      headerMain: this._buildMainHeader(pageObs),\n      contentTop: buildHomeBanners(this._appModel),\n      contentMain: this._buildMainContent(),\n      app: this._appObj,\n    });\n  }\n\n  private _buildMainHeader(pageObs: Computed<AdminPanelPage>) {\n    const pageNames = getPageNames();\n    return [\n      cssBreadcrumbs({ style: \"margin-left: 16px;\" },\n        cssLink(\n          urlState().setLinkUrl({}),\n          t(\"Grist Instance\"),\n        ),\n        separator(\" / \"),\n        dom(\"span\", getAdminPanelName()),\n        separator(\" / \"),\n        dom(\"span\", dom.domComputed(use => pageNames.pages[use(pageObs)].section)),\n        separator(\" / \"),\n        dom(\"span\", dom.domComputed(use => pageNames.pages[use(pageObs)].name)),\n      ),\n      createTopBarHome(this._appModel),\n    ];\n  }\n\n  private _buildMainContent() {\n    return cssPageContainer(\n      // Setting tabIndex allows selecting and copying text. This is helpful on admin pages, e.g.\n      // to copy GRIST_BOOT_KEY or version number. But we don't set it for buidAdminData() pages\n      // because it messes with focus in GridViews, and its unclear how to undo its effect.\n      dom.attr(\"tabindex\", use => use(this._page) === \"admin\" ? \"-1\" : null as any),\n\n      dom.domComputed(use => use(this._page) === \"admin\", (isInstallationAdminPage) => {\n        return isInstallationAdminPage ?\n          dom.create(AdminInstallationPanel, this._appModel) :\n          dom.create(buildAdminData, this._appModel);\n      }),\n\n      cssPageContainer.cls(\"-admin-pages\", use => use(this._page) !== \"admin\"),\n\n      testId(\"admin-panel\"),\n    );\n  }\n}\n\nclass AdminInstallationPanel extends Disposable implements AdminPanelControls {\n  public needsRestart = Observable.create(this, false);\n  private _supportsRestart = !!getGristConfig().runningUnderSupervisor;\n  private _supportGrist = SupportGristPage.create(this, this._appModel);\n  private _toggleEnterprise = ToggleEnterpriseWidget.create(this, this._appModel.notifier);\n  private _checks: AdminChecks;\n  private readonly _installAPI: InstallAPI = new InstallAPIImpl(getHomeUrl());\n  private readonly _configAPI: ConfigAPI = new ConfigAPI(getHomeUrl());\n  private _authCheck: Observable<AdminCheckRequest | undefined>;\n  private _loginProvider: Observable<string | undefined>;\n\n  constructor(private _appModel: AppModel) {\n    super();\n    this._checks = new AdminChecks(this, this._installAPI);\n    this._authCheck = Computed.create(this, (use) => {\n      return this._checks.requestCheckById(use, \"authentication\");\n    });\n    this._loginProvider = Computed.create(this, (use) => {\n      const req = use(this._authCheck);\n      const result = req ? use(req.result) : undefined;\n      if (result?.status === \"success\") {\n        return result.details?.provider;\n      }\n      return undefined;\n    });\n  }\n\n  public buildDom() {\n    this._checks.fetchAvailableChecks().catch((err) => {\n      reportError(err);\n    });\n\n    // If probes are available, show the panel as normal.\n    // Otherwise say it is unavailable, and describe a fallback\n    // mechanism for access.\n    return dom.maybe(use => use(this._checks.probes), probes => [\n      (probes as any[]).length > 0 ?\n        this._buildMainContentForAdmin() :\n        this._buildMainContentForOthers(),\n    ]);\n  }\n\n  public async restartGrist(): Promise<void> {\n    confirmModal(\n      t(\"Restart Grist?\"),\n      t(\"Restart\"),\n      async () => {\n        try {\n          await spinnerModal(\n            t(\"Restarting Grist...\"),\n            this._performRestart(),\n          );\n        } catch (err) {\n          reportError(err as Error);\n        }\n      },\n      {\n        explanation: dom(\"div\",\n          dom(\"p\", t(\"Are you sure you want to restart Grist?\")),\n          dom(\"p\", t(\"This will apply any pending changes and briefly interrupt access for all users.\")),\n        ),\n      },\n    );\n  }\n\n  private async _performRestart() {\n    await this._configAPI.restartServer();\n    await reloadSafe();\n  }\n\n  /**\n   * Show something helpful to those without access to the panel,\n   * which could include a legit administrator if auth is misconfigured.\n   */\n  private _buildMainContentForOthers() {\n    const exampleKey = _longCodeForExample();\n    return dom.create(AdminSection, t(\"Administrator Panel Unavailable\"), [\n      dom(\"p\", t(`You do not have access to the administrator panel.\nPlease log in as an administrator.`)),\n      dom(\n        \"p\",\n        t(`Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}`, {\n          bootKey: dom(\"pre\", `GRIST_BOOT_KEY=${exampleKey}`),\n          url: dom(\"pre\", `/admin?boot-key=${exampleKey}`),\n        }),\n      ),\n      testId(\"admin-panel-error\"),\n    ]);\n  }\n\n  private _buildMainContentForAdmin() {\n    return [\n      dom.maybe(this.needsRestart, () => [\n        cssSection(\n          cssSectionTitle(t(\"Restart Grist\")),\n          dom(\"p\", t(\"Restart Grist to apply pending changes.\")),\n          cssWell(\n            cssWell.cls(\"-warning\"),\n            cssWellIcon(icon(\"Warning\")),\n            dom(\"div\",\n              cssWellTitle(t(\"Restart unavailable\")),\n              cssWellContent(\n                dom(\"p\",\n                  t(`Grist is running in an environment that doesn't support restarting from the admin panel.`),\n                ),\n                dom(\"p\",\n                  t(\"Please restart Grist manually.\"),\n                  testId(\"admin-panel-restart-unsupported-warning\"),\n                ),\n              ),\n            ),\n            dom.hide(this._supportsRestart),\n          ),\n          bigPrimaryButton(\n            t(\"Restart Grist\"),\n            dom.on(\"click\", () => this.restartGrist()),\n            testId(\"admin-panel-restart-button\"),\n            dom.show(this._supportsRestart),\n          ),\n        ),\n      ]),\n      dom.create(AdminSection, t(\"Support Grist\"), [\n        dom.create(AdminSectionItem, {\n          id: \"telemetry\",\n          name: t(\"Telemetry\"),\n          description: t(\"Help us make Grist better\"),\n          value: dom.create(\n            HidableToggle,\n            this._supportGrist.getTelemetryOptInObservable(),\n            { labelId: \"admin-panel-item-description-telemetry\" },\n          ),\n          expandedContent: this._supportGrist.buildTelemetrySection(),\n        }),\n        dom.create(AdminSectionItem, {\n          id: \"sponsor\",\n          name: t(\"Sponsor\"),\n          description: t(\"Support Grist Labs on GitHub\"),\n          value: this._supportGrist.buildSponsorshipSmallButton(),\n          expandedContent: this._supportGrist.buildSponsorshipSection(),\n        }),\n      ]),\n      dom.create(AdminSection, t(\"Security Settings\"), [\n        dom.create(AdminSectionItem, {\n          id: \"admins\",\n          name: t(\"Administrative accounts\"),\n          description: t(\"The users with administrative accounts\"),\n          value: this._buildAdminUsersDisplay(),\n          expandedContent: this._buildAdminUsersDetail(),\n        }),\n        dom.create(AdminSectionItem, {\n          id: \"sandboxing\",\n          name: t(\"Sandboxing\"),\n          description: t(\"Sandbox settings for data engine\"),\n          value: this._buildSandboxingDisplay(),\n          expandedContent: this._buildSandboxingNotice(),\n        }),\n        dom.create(AdminSectionItem, {\n          id: \"authentication\",\n          name: t(\"Authentication\"),\n          description: t(\"Current authentication method\"),\n          value: this._buildAuthenticationDisplay(),\n          expandedContent: this._buildAuthenticationPanelExtraContent(),\n        }),\n        dom.create(AdminSectionItem, {\n          id: \"session\",\n          name: t(\"Session Secret\"),\n          description: t(\"Key to sign sessions with\"),\n          value: this._buildSessionSecretDisplay(),\n          expandedContent: this._buildSessionSecretNotice(),\n        }),\n      ]),\n      this._buildAuditLogsSection(),\n      dom.create(AdminSection, t(\"Version\"), [\n        dom.create(AdminSectionItem, {\n          id: \"version\",\n          name: t(\"Current\"),\n          description: t(\"Current version of Grist\"),\n          value: cssValueLabel(t(\"Version {{versionNumber}}\", { versionNumber: version.version })),\n        }),\n        this._maybeAddEnterpriseToggle(),\n        dom.create(this._buildUpdates.bind(this)),\n      ]),\n      dom.create(AdminSection, t(\"Self Checks\"), [\n        this._buildProbeItems({\n          showRedundant: false,\n          showNovel: true,\n        }),\n        dom.create(AdminSectionItem, {\n          id: \"probe-other\",\n          name: t(\"more...\"),\n          description: \"\",\n          value: \"\",\n          expandedContent: this._buildProbeItems({\n            showRedundant: true,\n            showNovel: false,\n          }),\n        }),\n      ]),\n    ];\n  }\n\n  private _maybeAddEnterpriseToggle() {\n    if (!showEnterpriseToggle()) {\n      return null;\n    }\n\n    let makeToggle = () => dom.create(\n      HidableToggle,\n      this._toggleEnterprise.getEnterpriseToggleObservable(),\n      { labelId: \"admin-panel-item-description-enterprise\" },\n    );\n\n    // If the enterprise edition is forced, we don't show the toggle.\n    if (getGristConfig().forceEnableEnterprise) {\n      makeToggle = () => cssValueLabel(cssHappyText(t(\"On\")));\n    }\n\n    return dom.create(AdminSectionItem, {\n      id: \"enterprise\",\n      name: t(\"Enterprise\"),\n      description: t(\"Enable Grist Enterprise\"),\n      value: makeToggle(),\n      expandedContent: this._toggleEnterprise.buildEnterpriseSection(),\n    });\n  }\n\n  private _buildSandboxingDisplay() {\n    return dom.domComputed(\n      (use) => {\n        const req = this._checks.requestCheckById(use, \"sandboxing\");\n        const result = req ? use(req.result) : undefined;\n        const success = result?.status === \"success\";\n        const details = result?.details as SandboxingBootProbeDetails | undefined;\n        if (!details) {\n          // Sandbox details get filled out relatively slowly if\n          // this is first time on admin panel. So show \"checking\"\n          // if we don't have a reported status yet.\n          return cssValueLabel(result?.status ? t(\"unknown\") : t(\"checking\"));\n        }\n        const flavor = details.flavor;\n        const configured = details.configured;\n        return cssValueLabel(\n          configured ?\n            (success ? cssHappyText(t(\"OK\") + `: ${flavor}`) :\n              cssErrorText(t(\"Error\") + `: ${flavor}`)) :\n            cssErrorText(t(\"unconfigured\")));\n      },\n    );\n  }\n\n  private _buildSandboxingNotice() {\n    return [\n      // Use AdminChecks text for sandboxing, in order not to\n      // duplicate.\n      probeDetails.sandboxing.info,\n      dom(\n        \"div\",\n        { style: \"margin-top: 8px\" },\n        cssLink({ href: commonUrls.helpSandboxing, target: \"_blank\" }, t(\"Learn more.\")),\n      ),\n    ];\n  }\n\n  private _buildAdminUsersComputed(\n    use: UseCBOwner,\n    renderSuccess: (users: InstallAdminInfo[]) => Element,\n  ) {\n    const req = this._checks.requestCheckById(use, \"admins\");\n    const result = req ? use(req.result) : undefined;\n    const success = result?.status === \"success\";\n\n    if (!result) {\n      return t(\"checking\");\n    }\n\n    if (!success) {\n      return cssErrorText(t(\"Error\"));\n    }\n\n    const users: InstallAdminInfo[] = result?.details?.users || [];\n    return renderSuccess(users);\n  }\n\n  private _buildAdminUsersDisplay() {\n    return cssValueLabel(\n      dom.domComputed(\n        use => this._buildAdminUsersComputed(use, (users) => {\n          const actualUsers = users.filter(detail => detail.user !== null);\n          if (actualUsers.length > 0) {\n            return cssHappyText(t(\"{{count}} admin accounts\", { count: actualUsers.length }));\n          }\n          return cssErrorText(t(\"no admin accounts\"));\n        }),\n      ),\n      testId(\"admin-panel-admin-accounts-display\"),\n    );\n  }\n\n  private _buildAdminUsersDetail() {\n    return dom.domComputed(\n      use => this._buildAdminUsersComputed(use, (users) => {\n        return cssAdminAccountList(\n          users.map(({ user, reason }) => {\n            const userDisplay = user ? cssUserInfo(\n              createUserImage(user, \"medium\"),\n              cssUserName(dom(\"span\", user.name, testId(\"admin-panel-admin-account-name\")),\n                cssEmail(user.email, testId(\"admin-panel-admin-account-email\")),\n              ),\n            ) : cssErrorText(t(\"Admin account not found\"));\n            return cssAdminAccountListItem([\n              cssAdminAccountItemPart(userDisplay),\n              cssAdminAccountItemPart(cssAdminAccountReason(markdown(reason, { inline: true }))),\n            ], testId(`admin-panel-admin-accounts-list-item`));\n          }),\n          testId(`admin-panel-admin-accounts-list`),\n        );\n      }),\n    );\n  }\n\n  private _buildAuthenticationDisplay() {\n    return dom.domComputed(\n      (use) => {\n        const req = use(this._authCheck);\n        const result = req ? use(req.result) : undefined;\n        if (!result) {\n          return cssValueLabel(\n            cssErrorText(t(\"unavailable\")),\n            testId(\"admin-panel-value-label-error\"),\n          );\n        }\n\n        const { status, details, verdict } = result;\n        const success = status === \"success\";\n\n        const provider = details?.provider ?? details?.label;\n\n        if (!success && !provider) {\n          return cssValueLabel(\n            cssErrorText(t(\"auth error\")),\n            verdict ? { title: verdict } : undefined,\n            testId(\"admin-panel-value-label-error\"),\n          );\n        }\n\n        if (provider === MINIMAL_PROVIDER_KEY) {\n          return cssValueLabel(\n            cssDangerText(t(\"no authentication\")),\n            verdict ? { title: verdict } : undefined,\n            testId(\"admin-panel-value-label-danger\"),\n          );\n        }\n\n        if (!success) {\n          return cssValueLabel(\n            cssErrorText(t(\"auth error\")),\n            verdict ? { title: t(\"error in {{provider}}: {{verdict}}\", { provider, verdict }) } : undefined,\n            testId(\"admin-panel-value-label-error\"),\n          );\n        }\n\n        return cssValueLabel(\n          cssHappyText(provider),\n          testId(\"admin-panel-value-label-success\"),\n        );\n      },\n    );\n  }\n\n  private _buildAuthenticationPanelExtraContent() {\n    return dom.create(AuthenticationSection, {\n      appModel: this._appModel,\n      loginSystemId: this._loginProvider,\n      controls: this,\n      installAPI: this._installAPI,\n    });\n  }\n\n  private _buildSessionSecretDisplay() {\n    return dom.domComputed(\n      (use) => {\n        const req = this._checks.requestCheckById(use, \"session-secret\");\n        const result = req ? use(req.result) : undefined;\n\n        if (result?.status === \"warning\") {\n          return cssValueLabel(cssDangerText(t(\"default\")));\n        }\n\n        return cssValueLabel(cssHappyText(t(\"configured\")));\n      },\n    );\n  }\n\n  private _buildSessionSecretNotice() {\n    return t(\"Grist signs user session cookies with a secret key. Please set this key via the environment variable \\\nGRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice \\\nin the future as session IDs generated since v1.1.16 are inherently cryptographically secure.\");\n  }\n\n  private _buildUpdates(owner: MultiHolder) {\n    // We can be in those states:\n    enum State {\n      // Never checked before (no last version or last check time).\n      // Shows \"No information available\" [Check now]\n      NEVER,\n      // Did check previously, but it was a while ago, user should press the button to check.\n      // Shows \"Last checked X days ago\" [Check now]\n      STALE,\n      // In the middle of checking for updates.\n      CHECKING,\n      // Transient state, shown after Check now is clicked.\n      // Grist is up to date (state only shown after a successful check), or even upfront.\n      // Won't be shown after page is reloaded.\n      // Shows \"Checking for updates...\"\n      CURRENT,\n      // A newer version is available. Can be shown after reload if last\n      // version that was checked is newer than the current version.\n      // Shows \"Newer version available\" [version]\n      AVAILABLE,\n      // Error occurred during this check. If the error occurred during last check\n      // it is not stored.\n      // Shows \"Error checking for updates\" [Check now]\n      ERROR,\n    }\n\n    const config = getGristConfig();\n    const latestVersionAvailable = Observable.create(owner, config.latestVersionAvailable);\n    const checkForLatestVersion = Observable.create(owner, true);\n    const allowAutomaticVersionChecking = Observable.create(owner, config.automaticVersionCheckingAllowed);\n    this._installAPI.getInstallPrefs()\n      .then((prefs) => {\n        if (this.isDisposed() || checkForLatestVersion.isDisposed()) { return; }\n        checkForLatestVersion.set(prefs.checkForLatestVersion ?? true);\n      })\n      .catch(reportError);\n\n    // Observable state of the updates check.\n    const state: Observable<State> = Observable.create(owner, State.NEVER);\n\n    // The background task that checks for updates, can be disposed (cancelled) when needed.\n    let backgroundTask: IDisposable | null = null;\n\n    // By default we link to the Docker Hub releases page, but the\n    // endpoint might say something different.\n    const releaseURL = \"https://hub.docker.com/r/gristlabs/grist\";\n\n    // All the events that might occur\n    const actions = {\n      checkForUpdates: async () => {\n        state.set(State.CHECKING);\n        latestVersionAvailable.set(undefined);\n        // We can be disabled, while the check is in progress.\n        const controller = new AbortController();\n        backgroundTask = {\n          dispose() {\n            if (controller.signal.aborted) { return; }\n            backgroundTask = null;\n            controller.abort();\n          },\n        };\n        owner.autoDispose(backgroundTask);\n        try {\n          const result = await this._installAPI.checkUpdates();\n          if (controller.signal.aborted) { return; }\n          actions.gotLatestVersion(result);\n        } catch (err) {\n          if (controller.signal.aborted) { return; }\n          state.set(State.ERROR);\n          reportError(err);\n        }\n      },\n      disableAutoCheck: () => {\n        backgroundTask?.dispose();\n        backgroundTask = null;\n        this._installAPI.updateInstallPrefs({ checkForLatestVersion: false }).catch(reportError);\n        checkForLatestVersion.set(false);\n      },\n      enableAutoCheck: () => {\n        if (state.get() !== State.CHECKING) {\n          actions.checkForUpdates().catch(reportError);\n          this._installAPI.updateInstallPrefs({ checkForLatestVersion: true }).catch(reportError);\n          checkForLatestVersion.set(true);\n        }\n      },\n      gotLatestVersion: (data: LatestVersionAvailable) => {\n        latestVersionAvailable.set(data);\n        if (data.isNewer) {\n          state.set(State.AVAILABLE);\n        } else {\n          state.set(State.CURRENT);\n        }\n      },\n    };\n\n    const description = Computed.create(owner, (use) => {\n      switch (use(state)) {\n        case State.NEVER: return t(\"No information available\");\n        case State.CHECKING: return \"⌛ \" + t(\"Checking for updates...\");\n        case State.CURRENT: return \"✅ \" + t(\"Grist is up to date\");\n        case State.AVAILABLE: return t(\"Newer version available\");\n        case State.ERROR: return \"❌ \" + t(\"Error checking for updates\");\n        case State.STALE: {\n          const lastCheck = latestVersionAvailable.get()?.dateChecked;\n          return lastCheck ?\n            t(\"Last checked {{time}}\", { time: getTimeFromNow(lastCheck) }) :\n            t(\"No record of last version check\");\n        }\n      }\n    });\n\n    // Now trigger the initial state\n    const lastCheck = latestVersionAvailable.get()?.dateChecked;\n    if (lastCheck) {\n      if (Date.now() - lastCheck > STALE_VERSION_CHECK_TIME_IN_MS) {\n        // It's been too long since we last checked\n        state.set(State.STALE);\n      } else if (latestVersionAvailable.get()?.isNewer === true) {\n        state.set(State.AVAILABLE);\n      } else if (latestVersionAvailable.get()?.isNewer === false) {\n        state.set(State.CURRENT);\n      }\n    } else {\n      state.set(State.NEVER);\n    }\n\n    // Toggle component operates on a boolean observable, without a way to set the value. So\n    // create a controller for it to intercept the write and call the appropriate action.\n    const enabledController = Computed.create(owner, use => use(checkForLatestVersion));\n    enabledController.onWrite((val) => {\n      if (val) {\n        actions.enableAutoCheck();\n      } else {\n        actions.disableAutoCheck();\n      }\n    });\n\n    const upperCheckNowVisible = Computed.create(owner, (use) => {\n      switch (use(state)) {\n        case State.CHECKING:\n        case State.CURRENT:\n        case State.AVAILABLE:\n          return false;\n        default:\n          return true;\n      }\n    });\n\n    return dom.create(AdminSectionItem, {\n      id: \"updates\",\n      name: t(\"Updates\"),\n      description: dom(\"span\", testId(\"admin-panel-updates-message\"), dom.text(description)),\n      value: cssValueButton(\n        dom.domComputed((use) => {\n          if (use(state) === State.CHECKING) {\n            return null;\n          }\n\n          if (use(upperCheckNowVisible)) {\n            return basicButton(\n              t(\"Check now\"),\n              dom.on(\"click\", actions.checkForUpdates),\n              testId(\"admin-panel-updates-upper-check-now\"),\n            );\n          }\n\n          if (use(latestVersionAvailable)) {\n            return cssValueLabel(\n              `Version ${use(latestVersionAvailable)?.version}`,\n              testId(\"admin-panel-updates-version\"),\n            );\n          }\n\n          throw new Error(\"Invalid state\");\n        }),\n      ),\n      expandedContent: dom(\"div\",\n        cssExpandedContent(\n          dom.domComputed(use => dom(\"div\", t(\"Grist releases are at \"),\n            makeLinks(use(latestVersionAvailable)?.releaseUrl || releaseURL),\n          )),\n        ),\n        dom.maybe(latestVersionAvailable, latest => cssExpandedContent(\n          dom(\"div\",\n            dom(\"span\", t(\"Last checked {{time}}\", {\n              time: getTimeFromNow(latest.dateChecked),\n            })),\n            dom(\"span\", \" \"),\n            // Format date in local format.\n            cssGrayed(`(${new Date(latest.dateChecked).toLocaleString()})`),\n          ),\n          // `Check now` button, only shown when auto checks are enabled and we are not in the\n          // middle of checking. Otherwise the button is shown in the summary row, and there is\n          // no need to duplicate it.\n          dom.maybe(use => !use(upperCheckNowVisible), () => [\n            cssCheckNowButton(\n              t(\"Check now\"),\n              testId(\"admin-panel-updates-lower-check-now\"),\n              dom.on(\"click\", actions.checkForUpdates),\n              dom.prop(\"disabled\", use => use(state) === State.CHECKING),\n            ),\n          ]),\n        )),\n        dom.domComputed(allowAutomaticVersionChecking, allowAutomaticChecks =>\n          allowAutomaticChecks ? cssExpandedContent(\n            dom(\"label\", t(\"Auto-check weekly\"), { for: \"admin-panel-updates-auto-check-switch\" }),\n            dom(\"div\", toggleSwitch(enabledController, {\n              args: [testId(\"admin-panel-updates-auto-check\")],\n              inputArgs: [{ id: \"admin-panel-updates-auto-check-switch\" }],\n            })),\n          ) :\n            cssExpandedContent(\n              dom(\"span\", t('Automatic checks are disabled. \\\nSet the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \"true\" to enable them.'),\n              testId(\"admin-panel-updates-auto-check-disabled\")),\n            ),\n        )),\n    });\n  }\n\n  /**\n   * Show the results of various checks. Of the checks, some are considered\n   * \"redundant\" (already covered elsewhere in the Admin Panel) and the\n   * remainder are \"novel\".\n   */\n  private _buildProbeItems(options: {\n    showRedundant: boolean,\n    showNovel: boolean,\n  }) {\n    return dom.domComputed(\n      use => [\n        ...use(this._checks.probes).map((probe) => {\n          const isRedundant = [\n            \"sandboxing\",\n            \"authentication\",\n            \"session-secret\",\n          ].includes(probe.id);\n          const show = isRedundant ? options.showRedundant : options.showNovel;\n          if (!show) { return null; }\n          const req = this._checks.requestCheck(probe);\n          return this._buildProbeItem(req.probe, use(req.result), req.details);\n        }),\n      ],\n    );\n  }\n\n  /**\n   * Show the result of an individual check.\n   */\n  private _buildProbeItem(info: BootProbeInfo,\n    result: BootProbeResult,\n    details: ProbeDetails | undefined) {\n    const status = this._encodeSuccess(result);\n    return dom.create(AdminSectionItem, {\n      id: `probe-${info.id}`,\n      name: info.id,\n      description: info.name,\n      value: cssStatus(status),\n      expandedContent: [\n        cssCheckHeader(\n          t(\"Results\"),\n          { style: \"margin-top: 0px; padding-top: 0px;\" },\n        ),\n        result.verdict ? dom(\"pre\", result.verdict) : null,\n        (result.status === \"none\") ? null :\n          dom(\"p\",\n            (result.status === \"success\") ? t(\"Check succeeded.\") : t(\"Check failed.\")),\n        (result.status !== \"none\") ? null :\n          dom(\"p\", t(\"No fault detected.\")),\n        (details?.info === undefined) ? null : [\n          cssCheckHeader(t(\"Notes\")),\n          details.info,\n        ],\n        (result.details === undefined) ? null : [\n          cssCheckHeader(t(\"Details\")),\n          ...Object.entries(result.details).map(([key, val]) => {\n            return dom(\n              \"div\",\n              cssLabel(key),\n              dom(\"input\", dom.prop(\n                \"value\",\n                typeof val === \"string\" ? val : JSON.stringify(val))));\n          }),\n        ],\n      ],\n    });\n  }\n\n  /**\n   * Give an icon summarizing success or failure. Factor in the\n   * severity of the result for failures. This is crude, the\n   * visualization of the results can be elaborated in future.\n   */\n  private _encodeSuccess(result: BootProbeResult) {\n    switch (result.status) {\n      case \"success\":\n        return \"✅\";\n      case \"fault\":\n        return \"❌\";\n      case \"warning\":\n        return \"❗\";\n      case \"hmm\":\n        return \"?\";\n      case \"none\":\n        return \"―\";\n      default:\n        // should not arrive here\n        return \"??\";\n    }\n  }\n\n  private _buildAuditLogsSection() {\n    const { deploymentType } = getGristConfig();\n    switch (deploymentType) {\n      // Note: SaaS builds are only included to streamline UI testing.\n      case \"core\":\n      case \"enterprise\":\n      case \"saas\": {\n        return dom.create(\n          AdminSection,\n          [t(\"Audit Logs\"), cssSectionTag(t(\"New, Enterprise\"))],\n          [this._buildLogStreamingSection(deploymentType)],\n        );\n      }\n      default: {\n        return null;\n      }\n    }\n  }\n\n  private _buildLogStreamingSection(\n    deploymentType: \"core\" | \"enterprise\" | \"saas\",\n  ) {\n    if (deploymentType === \"core\") {\n      return dom.create(AdminSectionItem, {\n        id: \"log-streaming\",\n        name: t(\"Log Streaming\"),\n        expandedContent: t(\n          \"You can set up streaming of audit events from Grist to an \\\nexternal security information and event management (SIEM) \\\nsystem if you enable Grist Enterprise. {{contactUsLink}} to \\\nlearn more.\",\n          {\n            contactUsLink: cssLink(\n              { href: commonUrls.contact, target: \"_blank\" },\n              t(\"Contact us\"),\n            ),\n          },\n        ),\n      });\n    } else {\n      const model = new AuditLogsModelImpl({\n        configsAPI: new InstallConfigsAPI(),\n      });\n      model.fetchStreamingDestinations().catch(reportError);\n\n      return dom.create(AdminSectionItem, {\n        id: \"log-streaming\",\n        name: t(\"Log Streaming\"),\n        value: this._buildLogStreamingStatus(model),\n        expandedContent: dom.create(AuditLogStreamingConfig, model),\n      });\n    }\n  }\n\n  private _buildLogStreamingStatus(model: AuditLogsModel) {\n    return dom.domComputed((use) => {\n      const destinations = use(model.streamingDestinations);\n      if (!destinations) {\n        return null;\n      } else if (destinations.length === 0) {\n        return cssValueLabel(cssDangerText(t(\"Off\")));\n      } else {\n        const [first, ...rest] = destinations;\n        let status: string;\n        if (rest.length > 0) {\n          status = t(\n            \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\",\n            {\n              firstDestinationName: getDestinationDisplayName(first.name),\n              remainingDestinationsCount: rest.length,\n            },\n          );\n        } else {\n          status = getDestinationDisplayName(first.name);\n        }\n        return cssValueLabel(cssHappyText(status));\n      }\n    });\n  }\n}\n\n// Ugh I'm not a front end person. h5 small-caps, sure why not.\n// Hopefully someone with taste will edit someday!\nconst cssCheckHeader = styled(\"h5\", `\n  margin-bottom: 5px;\n  font-variant: small-caps;\n`);\n\nconst cssStatus = styled(\"div\", `\n  display: inline-block;\n  text-align: center;\n  width: 40px;\n  padding: 5px;\n`);\n\nconst cssPageContainer = styled(\"div\", `\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  overflow: auto;\n  padding: 40px;\n  font-size: ${vars.introFontSize};\n  color: ${theme.text};\n  outline: none;\n\n  &-admin-pages {\n    padding: 12px;\n    font-size: ${vars.mediumFontSize};\n  }\n\n  @media ${mediaSmall} {\n    & {\n      padding: 0px;\n      font-size: ${vars.mediumFontSize};\n    }\n  }\n`);\n\nconst cssExpandedContent = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n  margin-right: 8px;\n  margin-bottom: 1rem;\n  align-items: center;\n`);\n\nconst cssValueButton = styled(\"div\", `\n  height: 30px;\n`);\n\nconst cssCheckNowButton = styled(basicButton, `\n  &-hidden {\n    visibility: hidden;\n  }\n`);\n\nconst cssGrayed = styled(\"span\", `\n  color: ${theme.lightText};\n`);\n\nconst cssErrorText = styled(\"span\", `\n  color: ${theme.errorText};\n`);\n\nconst cssDangerText = styled(\"div\", `\n  color: ${theme.dangerText};\n`);\n\nconst cssHappyText = styled(\"span\", `\n  color: ${theme.controlFg};\n`);\n\nconst cssLabel = styled(\"div\", `\n  display: inline-block;\n  min-width: 100px;\n  text-align: right;\n  padding-right: 5px;\n`);\n\nconst cssSectionTag = styled(\"span\", `\n  color: ${theme.accentText};\n  text-transform: uppercase;\n  font-size: 8px;\n  vertical-align: super;\n  margin-top: -4px;\n  margin-left: 4px;\n  font-weight: bold;\n`);\n\nconst cssAdminAccountList = styled(\"ul\", `\n  list-style: none;\n  padding: 0;\n  max-width: 700px;\n  margin: 0 auto;\n`);\n\nconst cssAdminAccountListItem = styled(\"li\", `\n  padding: 1rem 0rem;\n  margin: 0rem 1.2rem;\n  display: flex;\n  align-items: center;\n  &:not(:first-child) {\n    border-top: 1px solid ${theme.widgetBorder};\n  }\n`);\n\nconst cssAdminAccountReason = styled(\"span\", `\n  font-size: 0.9rem;\n  font-weight: 500;\n  display: inherit;\n`);\n\nconst cssAdminAccountItemPart = styled(\"span\", `\n  width: 50%;\n  &>:not(div) {\n    padding: 12px 24px 12px 16px;\n  }\n`);\n\n/**\n * Make a long code to use in the example, so that if people copy\n * and paste it lazily, they end up decently secure, or at least a\n * lot more secure than a key like \"REPLACE_WITH_YOUR_SECRET\"\n */\nfunction _longCodeForExample() {\n  // Crypto in insecure contexts doesn't have randomUUID\n  if (window.isSecureContext) {\n    return \"example-a\" + window.crypto.randomUUID();\n  }\n  return \"example-b\" + \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\".replace(/x/g, () => {\n    return Math.floor(Math.random() * 16).toString(16);\n  });\n}\n\nasync function reloadSafe() {\n  // Reload the page.\n  const currentUrl = new URL(window.location.href);\n  // Clear search params to avoid re-triggering the configuration page.\n  currentUrl.search = \"\";\n  await delay(2000); // Allow UI to update before doing the work\n  let counter = 10;\n  while (counter-- > 0) {\n    const res = await fetch(window.location.href, { credentials: \"include\" });\n    if (res.status === 200) {\n      break;\n    }\n    await delay(1000);\n  }\n  window.location.href = currentUrl.href;\n}\n"
  },
  {
    "path": "app/client/ui/AdminPanelCss.ts",
    "content": "import { textarea } from \"app/client/ui/inputs\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { transition } from \"app/client/ui/transitions\";\nimport { mediaSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { toggleSwitch } from \"app/client/ui2018/toggleSwitch\";\nimport { components, tokens } from \"app/common/ThemePrefs\";\n\nimport { dom, DomContents, DomElementArg, IDisposableOwner, Observable, styled } from \"grainjs\";\n\nexport interface AdminPanelControls {\n  needsRestart: Observable<boolean>;\n  restartGrist: () => Promise<void>;\n}\n\nexport function HidableToggle(\n  owner: IDisposableOwner,\n  value: Observable<boolean | null>,\n  options: { labelId?: string } = {},\n) {\n  return toggleSwitch(value, {\n    args: [dom.hide(use => use(value) === null)],\n    inputArgs: [options.labelId ? { \"aria-labelledby\": options.labelId } : undefined],\n  });\n}\n\nexport function AdminSection(owner: IDisposableOwner, title: DomContents, items: DomElementArg[]) {\n  return cssSection(\n    cssSectionTitle(title),\n    ...items,\n  );\n}\n\nexport function AdminSectionItem(owner: IDisposableOwner, options: {\n  id: string,\n  name?: DomContents,\n  description?: DomContents,\n  value?: DomElementArg,\n  expandedContent?: DomContents,\n  disabled?: false | string,\n}) {\n  let item: HTMLDivElement | undefined;\n  const itemContent = (...prefix: DomContents[]) => [\n    item = cssItemName(\n      ...prefix,\n      options.name,\n      testId(`admin-panel-item-name-${options.id}`),\n      dom.attr(\"id\", options.id),  // Add an id for use as an anchor,\n      // although it needs tricks (below)\n      prefix.length ? cssItemName.cls(\"-prefixed\") : null,\n      cssItemName.cls(\"-full\", options.description === undefined),\n      () => {\n        // If there is an anchor, check if it points to us.\n        // If not, do nothing. If yes, focus here once rendered.\n        const hash = window.location.hash;\n        if (hash !== \"#\" + options.id) { return; }\n        // A setTimeout seems to be the \"standard\" for doing focus\n        // after rendering throughout the app. Feels a little hacky,\n        // but appears to work reliably, and consequences of failure\n        // are not extreme - we just don't autoscroll and highlight.\n        setTimeout(() => {\n          if (!item) { return; }\n          item.scrollIntoView();\n          item.focus();\n          item.classList.add(cssItemName.className + \"-flash\");\n        }, 0);\n      },\n    ),\n    cssItemDescription(options.description, { id: `admin-panel-item-description-${options.id}` }),\n    cssItemValue(options.value,\n      testId(`admin-panel-item-value-${options.id}`),\n      dom.on(\"click\", ev => ev.stopPropagation())),\n  ];\n  if (options.expandedContent && !options.disabled) {\n    const isCollapsed = Observable.create(owner, true);\n    return cssItem(\n      cssItemShort(\n        itemContent(dom.domComputed(isCollapsed, c => cssCollapseIcon(c ? \"Expand\" : \"Collapse\"))),\n        cssItemShort.cls(\"-expandable\"),\n        dom.on(\"click\", () => isCollapsed.set(!isCollapsed.get())),\n      ),\n      cssExpandedContentWrap(\n        transition(isCollapsed, {\n          prepare(elem, close) { elem.style.maxHeight = close ? elem.scrollHeight + \"px\" : \"0\"; },\n          run(elem, close) { elem.style.maxHeight = close ? \"0\" : elem.scrollHeight + \"px\"; },\n          finish(elem, close) { elem.style.maxHeight = close ? \"0\" : \"unset\"; },\n        }),\n        cssExpandedContent(\n          options.expandedContent,\n        ),\n      ),\n      testId(`admin-panel-item-${options.id}`),\n    );\n  } else {\n    return cssItem(\n      cssItemShort(itemContent(),\n        cssItemShort.cls(\"-disabled\", Boolean(options.disabled)),\n        options.disabled ? hoverTooltip(options.disabled, {\n          placement: \"bottom-end\",\n          modifiers: { offset: { offset: \"0, -10\" } },\n        }) : null,\n      ),\n      testId(`admin-panel-item-${options.id}`),\n    );\n  }\n}\n\nexport const cssSection = styled(\"div\", `\n  padding: 24px;\n  max-width: 750px;\n  width: 100%;\n  margin: 16px auto;\n  border: 1px solid ${theme.widgetBorder};\n  border-radius: 4px;\n  & > div + div {\n    margin-top: 8px;\n  }\n\n  @media ${mediaSmall} {\n    & {\n      width: auto;\n      padding: 12px;\n      margin: 8px;\n    }\n  }\n`);\n\nexport const cssSectionTitle = styled(\"div\", `\n  height: 32px;\n  line-height: 32px;\n  margin-bottom: 8px;\n  font-size: ${vars.headerControlFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n`);\n\nexport const cssItem = styled(\"div\", `\n  margin-top: 8px;\n  container-type: inline-size;\n  container-name: line;\n`);\n\nconst cssItemShort = styled(\"div\", `\n  display: flex;\n  row-gap: 4px;\n  flex-wrap: nowrap;\n  align-items: center;\n  padding: 8px;\n  margin: 0 -8px;\n  border-radius: 4px;\n  justify-content: space-around;\n  flex-direction: row;\n  &-expandable {\n    cursor: pointer;\n  }\n  &-expandable:hover {\n    background-color: ${theme.lightHover};\n  }\n  &-disabled {\n    opacity: .5;\n  }\n\n  @container line (max-width: 500px) {\n    & {\n      flex-direction: column;\n      align-items: stretch;\n      gap: 8px;\n    }\n  }\n`);\n\nconst cssItemName = styled(\"div\", `\n  width: 230px;\n  font-weight: bold;\n  display: flex;\n  align-items: center;\n  margin-right: 14px;\n  font-size: ${vars.largeFontSize};\n  padding-left: 24px;\n  &-prefixed {\n    padding-left: 0;\n  }\n  &-full {\n    padding-left: 0;\n    width: unset;\n  }\n  &-flash {\n    animation: flashToTransparent 1s ease-in-out forwards;\n  }\n  @keyframes flashToTransparent {\n    0%   { background-color: var(--grist-theme-primary-emphasis, inherit); }\n    100% { background-color: inherit; }\n  }\n  @container line (max-width: 500px) {\n    & {\n      padding-left: 0;\n    }\n  }\n  @media ${mediaSmall} {\n    & {\n      padding-left: 0;\n    }\n    &:first-child {\n      margin-left: 0;\n    }\n  }\n`);\n\nconst cssItemDescription = styled(\"div\", `\n  width: 250px;\n  margin-right: auto;\n  margin-bottom: -1px; /* aligns with the value */\n`);\n\nconst cssItemValue = styled(\"div\", `\n  flex: none;\n  margin: -8px 0;\n  padding: 8px;\n  cursor: auto;\n  max-width: 200px;\n  --admin-select-width: 176px;\n\n  .${cssItemShort.className}-disabled & {\n    pointer-events: none;\n  }\n`);\n\nconst cssCollapseIcon = styled(icon, `\n  width: 24px;\n  height: 24px;\n  margin-right: 4px;\n  margin-left: -4px;\n  --icon-color: ${theme.lightText};\n`);\n\nconst cssExpandedContentWrap = styled(\"div\", `\n  transition: max-height 0.3s ease-in-out;\n  overflow: hidden;\n  max-height: 0;\n`);\n\nconst cssExpandedContent = styled(\"div\", `\n  margin-left: 24px;\n  padding: 18px 0;\n  border-bottom: 1px solid ${theme.widgetBorder};\n  .${cssItem.className}:last-child & {\n    padding-bottom: 0;\n    border-bottom: none;\n  }\n  @container line (max-width: 500px) {\n    & {\n      margin-left: 0px;\n    }\n  }\n`);\n\nexport const cssValueLabel = styled(\"div\", `\n  padding: 4px 8px;\n  color: ${theme.text};\n  border: 1px solid ${theme.inputBorder};\n  border-radius: ${vars.controlBorderRadius};\n`);\n\nexport const cssTextArea = styled(textarea, `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  border: 1px solid ${theme.inputBorder};\n  width: 100%;\n  padding: 8px 12px;\n  outline: none;\n  resize: none;\n  border-radius: 3px;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\nexport const cssWell = styled(\"div\", `\n  color: ${theme.text};\n  display: flex;\n  align-items: flex-start;\n  gap: 12px;\n  padding: 16px;\n  border-radius: 10px;\n  width: 100%;\n\n  &-warning {\n    border: 1px solid ${tokens.warningLight};\n    --icon-color: ${tokens.warningLight};\n  }\n\n  &-error {\n    border: 1px solid ${components.errorText};\n    --icon-color: ${components.errorText};\n  }\n`);\n\nexport const cssIconWrapper = styled(\"div\", `\n  font-size: 13px;\n  flex-shrink: 0;\n  margin-top: 2px;\n`);\n\nexport const cssWellTitle = styled(\"div\", `\n  font-size: 14px;\n  font-weight: 600;\n  line-height: 1.5;\n  margin-bottom: 8px;\n`);\n\nexport const cssWellContent = styled(\"div\", `\n  font-size: ${vars.mediumFontSize};\n  line-height: 1.4;\n  & > p {\n    margin: 0px;\n  }\n  & > p + p {\n    margin-top: 8px;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/AdminPanelName.ts",
    "content": "// Separated out into its own file because this is used in several modules, but we'd like to avoid\n// pulling in the full AdminPanel into their bundle.\n\nimport { makeT } from \"app/client/lib/localization\";\n\nconst t = makeT(\"AdminPanel\");\n\n// Translated \"Admin Panel\" name, made available to other modules.\nexport function getAdminPanelName() {\n  return t(\"Admin Panel\");\n}\n"
  },
  {
    "path": "app/client/ui/AdminTogglesCss.ts",
    "content": "import { bigBasicButton, bigBasicButtonLink, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { theme } from \"app/client/ui2018/cssVars\";\n\nimport { styled } from \"grainjs\";\n\nexport const cssSection = styled(\"div\", ``);\n\nexport const cssParagraph = styled(\"div\", `\n  color: ${theme.text};\n  font-size: 14px;\n  line-height: 20px;\n  margin-bottom: 12px;\n`);\n\nexport const cssOptInOutMessage = styled(cssParagraph, `\n  line-height: 40px;\n  font-weight: 600;\n  margin-top: 24px;\n  margin-bottom: 0px;\n`);\n\nexport const cssOptInButton = styled(bigPrimaryButton, `\n  display: block;\n  margin-top: 24px;\n`);\n\nexport const cssOptOutButton = styled(bigBasicButton, `\n  margin-top: 24px;\n`);\n\nexport const cssSponsorButton = styled(bigBasicButtonLink, `\n  margin-top: 24px;\n`);\n\nexport const cssButtonIconAndText = styled(\"div\", `\n  display: flex;\n  align-items: center;\n`);\n\nexport const cssButtonText = styled(\"span\", `\n  margin-left: 8px;\n`);\n\nexport const cssSpinnerBox = styled(\"div\", `\n  margin-top: 24px;\n  text-align: center;\n`);\n"
  },
  {
    "path": "app/client/ui/ApiKey.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { basicButton, textButton } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\n\nimport { Disposable, dom, IDomArgs, makeTestId, Observable, observable, styled } from \"grainjs\";\n\nconst t = makeT(\"ApiKey\");\n\ninterface IWidgetOptions {\n  apiKey: Observable<string>;\n  onDelete: () => Promise<void>;\n  onCreate: () => Promise<void>;\n  anonymous?: boolean; // Configure appearance and available options for anonymous use.\n  // When anonymous, no modifications are permitted to profile information.\n  // TODO: add browser test for this option.\n  inputArgs?: IDomArgs<HTMLInputElement>;\n}\n\nconst testId = makeTestId(\"test-apikey-\");\n\n/**\n * ApiKey component shows an api key with controls to change it. Expects `options.apiKey` the api\n * key and shows it if value is truthy along with a 'Delete' button that triggers the\n * `options.onDelete` callback. When `options.apiKey` is falsy, hides it and show a 'Create' button\n * that triggers the `options.onCreate` callback. It is the responsibility of the caller to update\n * the `options.apiKey` to its new value.\n */\nexport class ApiKey extends Disposable {\n  private _apiKey: Observable<string>;\n  private _onDeleteCB: () => Promise<void>;\n  private _onCreateCB: () => Promise<void>;\n  private _anonymous: boolean;\n  private _inputArgs: IDomArgs<HTMLInputElement>;\n  private _loading = observable(false);\n  private _isHidden: Observable<boolean> = Observable.create(this, true);\n\n  constructor(options: IWidgetOptions) {\n    super();\n    this._apiKey = options.apiKey;\n    this._onDeleteCB = options.onDelete;\n    this._onCreateCB = options.onCreate;\n    this._anonymous = Boolean(options.anonymous);\n    this._inputArgs = options.inputArgs ?? [];\n  }\n\n  public buildDom() {\n    return dom(\"div\", testId(\"container\"), dom.style(\"position\", \"relative\"),\n      dom.maybe(this._apiKey, apiKey => dom(\"div\",\n        cssRow(\n          cssInput(\n            {\n              readonly: true,\n              value: this._apiKey.get(),\n            },\n            dom.attr(\"type\", use => use(this._isHidden) ? \"password\" : \"text\"),\n            testId(\"key\"),\n            { title: t(\"Click to show\") },\n            dom.on(\"click\", (_ev, el) => {\n              this._isHidden.set(false);\n              setTimeout(() => el.select(), 0);\n            }),\n            dom.on(\"blur\", (ev) => {\n              // Hide the key when it is no longer selected.\n              if (ev.target !== document.activeElement) { this._isHidden.set(true); }\n            }),\n            this._inputArgs,\n          ),\n          cssTextBtn(\n            textButton.cls(\"-hover-bg-padding-none\"),\n            cssTextBtnIcon(\"Remove\"), t(\"Remove\"),\n            dom.on(\"click\", () => this._showRemoveKeyModal()),\n            testId(\"delete\"),\n            dom.boolAttr(\"disabled\", use => use(this._loading) || this._anonymous),\n          ),\n        ),\n        description(this._getDescription(), testId(\"description\")),\n      )),\n      dom.maybe(use => !(use(this._apiKey) || this._anonymous), () => [\n        basicButton(t(\"Create\"), dom.on(\"click\", () => this._onCreate()), testId(\"create\"),\n          dom.boolAttr(\"disabled\", this._loading)),\n        description(t(\"By generating an API key, you will be able to \\\nmake API calls for your own account.\"), testId(\"description\")),\n      ]),\n    );\n  }\n\n  // Switch the `_loading` flag to `true` and later, once promise resolves, switch it back to\n  // `false`.\n  private async _switchLoadingFlag(promise: Promise<any>) {\n    this._loading.set(true);\n    try {\n      await promise;\n    } finally {\n      this._loading.set(false);\n    }\n  }\n\n  private _onDelete(): Promise<void> {\n    return this._switchLoadingFlag(this._onDeleteCB());\n  }\n\n  private _onCreate(): Promise<void> {\n    return this._switchLoadingFlag(this._onCreateCB());\n  }\n\n  private _getDescription(): string {\n    return t(\n      !this._anonymous ?\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\" :\n        \"This API key can be used to access this account anonymously via the API.\",\n    );\n  }\n\n  private _showRemoveKeyModal(): void {\n    confirmModal(\n      t(\"Remove API Key\"), t(\"Remove\"),\n      () => this._onDelete(),\n      {\n        explanation: t(\n          \"You're about to delete an API key. This will cause all future requests \\\nusing this API key to be rejected. Do you still want to delete?\",\n        ),\n      },\n    );\n  }\n}\n\nconst description = styled(\"div\", `\n  margin-top: 8px;\n  color: ${theme.lightText};\n  font-size: ${vars.mediumFontSize};\n`);\n\nconst cssInput = styled(\"input\", `\n  background-color: transparent;\n  color: ${theme.inputFg};\n  border: 1px solid ${theme.inputBorder};\n  padding: 4px;\n  border-radius: 3px;\n  outline: none;\n  flex: 1 0 0;\n`);\n\nconst cssRow = styled(\"div\", `\n  display: flex;\n`);\n\nconst cssTextBtn = styled(textButton, `\n  text-align: left;\n  width: 90px;\n  margin-left: 16px;\n`);\n\nconst cssTextBtnIcon = styled(icon, `\n  margin: 0 4px 2px 0;\n`);\n"
  },
  {
    "path": "app/client/ui/App.css",
    "content": "html {\n  height: 100%;\n  overflow: hidden;\n}\n\nbody {\n  height: 100%;\n  font-family: sans-serif;\n  font-size: 1.2rem;\n  margin: 0;\n  padding: 0;\n  background: var(--grist-theme-bg, url('img/gplaypattern.png'));\n  background-color: var(--grist-theme-bg-color, unset);\n}\n\n.g-help {\n  position: absolute;\n  top: 10%;\n  left: 10%;\n  height: 80%;\n  width: 80%;\n  z-index: var(--grist-modal-z-index);\n\n  padding: 1rem;\n\n  background-color: rgba(0, 0, 0, .8);\n\n  -webkit-border-radius: 1rem;\n     -moz-border-radius: 1rem;\n          border-radius: 1rem;\n\n  color: #fff;\n  font-size: 1.4rem;\n  overflow: auto;\n}\n\n.g-help-table {\n  width: 100%;\n  margin-bottom: 2rem;\n}\n"
  },
  {
    "path": "app/client/ui/App.ts",
    "content": "import { ClientScope } from \"app/client/components/ClientScope\";\nimport { Clipboard } from \"app/client/components/Clipboard\";\nimport { Comm } from \"app/client/components/Comm\";\nimport * as commandList from \"app/client/components/commandList\";\nimport * as commands from \"app/client/components/commands\";\nimport { KeyboardFocusHighlighter } from \"app/client/components/KeyboardFocusHighlighter\";\nimport { RegionFocusSwitcher } from \"app/client/components/RegionFocusSwitcher\";\nimport { unsavedChanges } from \"app/client/components/UnsavedChanges\";\nimport { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { isDesktop } from \"app/client/lib/browserInfo\";\nimport { onClickOutside } from \"app/client/lib/domUtils\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport * as koUtil from \"app/client/lib/koUtil\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError, TopAppModel, TopAppModelImpl } from \"app/client/models/AppModel\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { setUpErrorHandling } from \"app/client/models/errors\";\nimport { createAppUI } from \"app/client/ui/AppUI\";\nimport { openAccessibilityModal } from \"app/client/ui/OpenAccessibilityModal\";\nimport { addViewportTag } from \"app/client/ui/viewport\";\nimport { attachCssRootVars } from \"app/client/ui2018/cssVars\";\nimport { attachTheme } from \"app/client/ui2018/theme\";\nimport { BaseAPI } from \"app/common/BaseAPI\";\nimport { CommDocError } from \"app/common/CommTypes\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { fetchFromHome } from \"app/common/urlUtils\";\nimport { ISupportedFeatures } from \"app/common/UserConfig\";\n\nimport { dom } from \"grainjs\";\nimport * as ko from \"knockout\";\n\nconst t = makeT(\"App\");\n\nconst G = getBrowserGlobals(\"document\", \"window\");\n\nexport interface App extends DisposableWithEvents {\n  allCommands: typeof commands.allCommands;\n  comm: Comm;\n  clientScope: ClientScope;\n  features: ko.Computed<ISupportedFeatures>;\n  topAppModel: TopAppModel;\n  pageModel?: DocPageModel;\n  regionFocusSwitcher?: RegionFocusSwitcher;\n}\n\n/**\n * Main Grist App UI component.\n */\nexport class AppImpl extends DisposableWithEvents implements App {\n  // Used by #newui code to avoid a dependency on commands.js, and by tests to issue commands.\n  public allCommands = commands.allCommands;\n\n  public comm = this.autoDispose(Comm.create(this._checkError.bind(this)));\n  public clientScope: ClientScope;\n  public features: ko.Computed<ISupportedFeatures>;\n  public topAppModel: TopAppModel;    // Exposed because used by test/nbrowser/gristUtils.\n\n  // Track the most recently created DocPageModel, for some error handling.\n  public pageModel?: DocPageModel;\n\n  // Track the RegionFocusSwitcher created by pagePanels, so that the codebase can access it.\n  public regionFocusSwitcher?: RegionFocusSwitcher;\n\n  private _settings: ko.Observable<{ features?: ISupportedFeatures }>;\n\n  // Track the version of the server we are communicating with, so that if it changes\n  // we can choose to refresh the client also.\n  private _serverVersion: string | null = null;\n\n  constructor() {\n    super();\n\n    commands.init(); // Initialize the 'commands' module using the default command list.\n\n    // Create the notifications box, and use it for reporting errors we can catch.\n    setUpErrorHandling(reportError, koUtil);\n\n    this.clientScope = this.autoDispose(ClientScope.create());\n\n    // Settings, initialized by initSettings event triggered by a server message.\n    this._settings = ko.observable({});\n    this.features = ko.computed(() => this._settings().features || {});\n\n    KeyboardFocusHighlighter.create(this);\n\n    if (isDesktop()) {\n      Clipboard.create(this, this);\n    } else {\n      // On mobile, we do not want to keep focus on a special textarea (which would cause unwanted\n      // scrolling and showing of mobile keyboard). But we still rely on 'clipboard_focus' and\n      // 'clipboard_blur' events to know when the \"app\" has a focus (rather than a particular\n      // input), by making document.body focusable and using a FocusLayer with it as the default.\n      document.body.setAttribute(\"tabindex\", \"-1\");\n      FocusLayer.create(this, {\n        defaultFocusElem: document.body,\n        allowFocus: Clipboard.allowFocus,\n        onDefaultFocus: () => this.trigger(\"clipboard_focus\"),\n        onDefaultBlur: () => this.trigger(\"clipboard_blur\"),\n      });\n    }\n\n    this.topAppModel = this.autoDispose(TopAppModelImpl.create(null, G.window));\n\n    const isHelpPaneVisible = ko.observable(false);\n\n    G.document.querySelector(\"#grist-logo-wrapper\")?.remove();\n\n    const helpDiv = document.body.appendChild(\n      dom(\"div.g-help\",\n        onClickOutside(() => isHelpPaneVisible(false)),\n        dom.show(isHelpPaneVisible), // Toggle visibility dynamically\n        dom(\"table.g-help-table\",\n          dom(\"thead\",\n            dom(\"tr\",\n              dom(\"th\", t(\"Key\")),\n              dom(\"th\", t(\"Description\")),\n            ),\n          ),\n          dom.forEach(commandList.groups, (group) => {\n            const cmds = group.commands.filter(cmd => Boolean(cmd.desc && cmd.keys.length && !cmd.deprecated));\n            return cmds.length > 0 ?\n              dom(\"tbody\",\n                dom(\"tr\",\n                  dom(\"td\", { colspan: \"2\" }, group.group),\n                ),\n                dom.forEach(cmds, cmd =>\n                  dom(\"tr\",\n                    dom(\"td\", commands.allCommands[cmd.name].getKeysDom()),\n                    dom(\"td\", cmd.desc?.() ?? \"\"),\n                  ),\n                ),\n              ) : null;\n          }),\n        ),\n      ),\n    );\n    this.onDispose(() => { dom.domDispose(helpDiv); helpDiv.remove(); });\n\n    this.autoDispose(commands.createGroup({\n      shortcuts() { isHelpPaneVisible(true); },\n      accessibility() { openAccessibilityModal(this.topAppModel.appObs); },\n      historyBack() { G.window.history.back(); },\n      historyForward() { G.window.history.forward(); },\n    }, this, true));\n\n    /** Ensure menu closes on cancel */\n    this.autoDispose(commands.createGroup({\n      cancel() { isHelpPaneVisible(false); },   // Close menu when Esc/Cancel is triggered\n      cursorDown() { helpDiv.scrollBy(0, 30); }, // 30 is height of the row in the help screen\n      cursorUp() { helpDiv.scrollBy(0, -30); },\n      pageUp() { helpDiv.scrollBy(0, -helpDiv.clientHeight); },\n      pageDown() { helpDiv.scrollBy(0, helpDiv.clientHeight); },\n      moveToFirstField() { helpDiv.scrollTo(0, 0); }, // home\n      moveToLastField() { helpDiv.scrollTo(0, helpDiv.scrollHeight); }, // end\n      find() { return true; }, // restore browser search\n      shortcuts() { isHelpPaneVisible(false); },  // Close menu\n    }, this, isHelpPaneVisible));\n\n    this.listenTo(this.comm, \"clientConnect\", (message) => {\n      console.log(`App clientConnect event: needReload ${message.needReload} version ${message.serverVersion}`);\n      this._settings(message.settings);\n      if (message.serverVersion === \"dead\" || (this._serverVersion && this._serverVersion !== message.serverVersion)) {\n        console.log(\"Upgrading...\");\n        // Server has upgraded.  Upgrade client.  TODO: be gentle and polite.\n        return this.reload();\n      }\n      this._serverVersion = message.serverVersion;\n      // Reload any open documents if needed (if clientId changed, or client can't get all missed\n      // messages). We'll simply reload the active component of the App regardless of what it is.\n      if (message.needReload) {\n        this._reloadPane();\n      }\n    });\n\n    this.listenTo(this.comm, \"connectState\", (isConnected: boolean) => {\n      this.topAppModel.notifier.setConnectState(isConnected);\n    });\n\n    this.listenTo(this.comm, \"docShutdown\", () => {\n      console.log(\"Received docShutdown\");\n      // Reload on next tick, to let other objects process 'docShutdown' before they get disposed.\n      setTimeout(() => this._reloadPane(), 0);\n    });\n\n    this.listenTo(this.comm, \"docError\", (msg: CommDocError) => {\n      this._checkError(new Error(msg.data.message));\n    });\n\n    // When the document is unloaded, dispose the app, allowing it to do any needed\n    // cleanup (e.g. Document on disposal triggers closeDoc message to the server). It needs to be\n    // in 'beforeunload' rather than 'unload', since websocket is closed by the time of 'unload'.\n    G.window.addEventListener(\"beforeunload\", (ev: BeforeUnloadEvent) => {\n      if (unsavedChanges.haveUnsavedChanges()) {\n        // Following https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event\n        ev.returnValue = true;\n        ev.preventDefault();\n        return true;\n      }\n      this.dispose();\n    });\n\n    this.comm.initialize(null);\n\n    // Add the cssRootVars class to enable the variables in cssVars.\n    attachCssRootVars(this.topAppModel.productFlavor);\n    attachTheme();\n    addViewportTag();\n    this.autoDispose(createAppUI(this.topAppModel, this));\n  }\n\n  // We want to test errors from Selenium, but errors we can trigger using driver.executeScript()\n  // will be impossible for the application to report properly (they seem to be considered not of\n  // \"same-origin\"). So this silly callback is for tests to generate a fake error.\n  public testTriggerError(msg: string) { throw new Error(msg); }\n\n  // Intended to be used by tests to enable specific features.\n  public testEnableFeature(featureName: keyof ISupportedFeatures, onOff: boolean) {\n    const features = this.features();\n    features[featureName] = onOff;\n    this._settings(Object.assign(this._settings(), { features }));\n  }\n\n  public getServerVersion() {\n    return this._serverVersion;\n  }\n\n  public reload() {\n    G.window.location.reload(true);\n    return true;\n  }\n\n  /**\n   * This method is not called anywhere, it is here just to introduce\n   * a special translation key. The purpose of this key is to let translators\n   * control whether a translation is ready to be offered to the user.\n   *\n   * If the key has not been translated for a language, and the language\n   * is not the default language, then the language should not be offered\n   * or used (unless some flag is set). TODO: implement this once key\n   * is available in weblate and good translations have been updated.\n   */\n  public checkSpecialTranslationKey() {\n    return t(\"Translators: please translate this only when your language is ready to be offered to users\");\n  }\n\n  // Get the user profile for testing purposes\n  public async testGetProfile(): Promise<any> {\n    const resp = await fetchFromHome(\"/api/profile/user\", { credentials: \"include\" });\n    return resp.json();\n  }\n\n  public testNumPendingApiRequests(): number {\n    return BaseAPI.numPendingRequests();\n  }\n\n  private _reloadPane() {\n    console.log(\"reloadPane\");\n    this.topAppModel.reload();\n  }\n\n  private _checkError(err: Error) {\n    const message = String(err);\n    // Take special action on any error that suggests a memory problem.\n    if (message.match(/MemoryError|unmarshallable object/)) {\n      if (err.message.length > 30) {\n        // TLDR\n        err.message = t(\"Memory Error\");\n      }\n      this.pageModel?.offerRecovery(err);\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/ui/AppHeader.ts",
    "content": "import { makeTestId } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { getWelcomeHomeUrl } from \"app/client/lib/urlUtils\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { getTheme } from \"app/client/ui/CustomThemes\";\nimport { manageTeamUsersApp } from \"app/client/ui/OpenUserManager\";\nimport { cssLeftPane } from \"app/client/ui/PagePanels\";\nimport { maybeAddSiteSwitcherSection } from \"app/client/ui/SiteSwitcher\";\nimport { createUserImage, cssUserImage } from \"app/client/ui/UserImage\";\nimport { colors, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu, menuItem, menuItemLink, menuSubHeader } from \"app/client/ui2018/menus\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { commonUrls } from \"app/common/gristUrls\";\nimport * as roles from \"app/common/roles\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { getOrgName, isTemplatesOrg, Organization } from \"app/common/UserAPI\";\n\nimport { Computed, Disposable, dom, DomContents, styled } from \"grainjs\";\n\nconst t = makeT(\"AppHeader\");\nconst testId = makeTestId(\"test-dm-\");\n\n// Maps a name of a Product (from app/gen-server/entity/Product.ts) to a tag (pill) to show next\n// to the org name.\nconst productPills: { [name: string]: string | null } = {\n  // TODO We don't label paid team plans with a tag yet, but we should label as \"Pro\" once we\n  // update our pricing pages to refer to paid team plans as Pro plans.\n  professional: null,   // Deprecated but used in development.\n  team: null,           // Used for the paid team plans.\n  teamFree: \"Free\",     // The new free team plan.\n  // Other plans are either personal, or grandfathered, or for testing.\n};\n\ninterface AppLogoOrgNameAndLink {\n  name: string;\n  link: AppLogoLink;\n  org?: string;\n  href?: string;\n}\n\ntype AppLogoLink = AppLogoOrgDomain | AppLogoHref;\n\ninterface AppLogoOrgDomain {\n  type: \"domain\";\n  domain: string;\n}\n\ninterface AppLogoHref {\n  type: \"href\";\n  href: string;\n}\n\nexport class AppHeader extends Disposable {\n  private _currentOrg = this._appModel.currentOrg;\n\n  /**\n   * The name and link of the site shown next to the logo.\n   *\n   * The last visited site is used, if known. Otherwise, the current site is used.\n   */\n  private _appLogoOrg = Computed.create<AppLogoOrgNameAndLink>(this, (use) => {\n    const availableOrgs = use(this._appModel.topAppModel.orgs);\n    const currentOrgName = (this._appModel.currentOrgName ||\n      (this._docPageModel && use(this._docPageModel.currentOrgName))) ?? \"\";\n    const lastVisitedOrgDomain = use(this._appModel.lastVisitedOrgDomain);\n    return this._getAppLogoOrgNameAndLink({ availableOrgs, currentOrgName, lastVisitedOrgDomain });\n  });\n\n  private _appLogoOrgName = Computed.create(this, this._appLogoOrg, (_use, { name }) => name);\n\n  private _appLogoOrgLink = Computed.create(this, this._appLogoOrg, (_use, { link }) => link);\n\n  constructor(\n    private _appModel: AppModel,\n    private _docPageModel?: DocPageModel | null) {\n    super();\n  }\n\n  public buildDom() {\n    // Check if we have a custom image.\n    const customImage = this._appModel.currentOrg?.orgPrefs?.customLogoUrl;\n\n    const variant = () => [cssUserImage.cls(\"-border\"), cssUserImage.cls(\"-square\"), cssUserImage.cls(\"-inAppLogo\")];\n\n    // Personal avatar is shown only for logged in users.\n    const personalAvatar = () => !this._appModel.currentValidUser ?\n      cssAppLogo.cls(\"-grist-logo\") :\n      createUserImage(this._appModel.currentValidUser, \"medium\", variant());\n\n    // Team avatar is shown only for team sites (even for anonymous users).\n    const teamAvatar = () => cssAppLogo.cls(\"-grist-logo\");\n\n    // Depending on site the avatar is either personal or team.\n    const avatar = () => this._appModel.isPersonal ?\n      personalAvatar() :\n      teamAvatar();\n\n    // Show the image if it's set, otherwise show the avatar.\n    const image = () => customImage ?\n      dom.style(\"background-image\", customImage ? `url(${customImage})` : \"\") :\n      avatar();\n\n    // Maybe we should show custom logo and make it wide (without site switcher).\n    const productFlavor = getTheme(this._appModel.topAppModel.productFlavor);\n    const content = () => productFlavor.wideLogo ?\n      null :\n      image();\n\n    const altText = t(\"{{- organizationName }} - Back to home\", { organizationName: this._appLogoOrg.get().name });\n\n    return cssAppHeader(\n      cssAppHeader.cls(\"-widelogo\", productFlavor.wideLogo || false),\n      cssAppHeaderBox(\n        dom.domComputed(this._appLogoOrgLink, orgLink => cssAppLogo(\n          { \"aria-label\": altText },\n          this._setHomePageUrl(orgLink),\n          content(),\n          testId(\"logo\"),\n        )),\n        this._buildOrgLinkOrMenu(),\n      ),\n    );\n  }\n\n  private _buildOrgLinkOrMenu() {\n    const { currentValidUser, isTemplatesSite } = this._appModel;\n    const { deploymentType } = getGristConfig();\n    if (deploymentType === \"saas\" && !currentValidUser && isTemplatesSite) {\n      // When signed out and on the templates site (in SaaS Grist), link to the templates page.\n      return cssOrgLink(\n        cssOrgName(dom.text(this._appLogoOrgName), testId(\"orgname\")),\n        { href: commonUrls.templates },\n        testId(\"org\"),\n      );\n    } else {\n      return cssOrg(\n        dom.cls(\"_cssOrg\"),\n        cssOrgName(dom.text(this._appLogoOrgName), testId(\"orgname\")),\n        productPill(this._currentOrg),\n        dom.maybe(this._appLogoOrgName, () => [\n          cssSpacer(),\n          cssDropdownIcon(\"Dropdown\"),\n        ]),\n        menu(() => [\n          menuSubHeader(\n            this._appModel.isPersonal ?\n              t(\"Personal Site\") + (this._appModel.isLegacySite ? ` (${t(\"Legacy\")})` : \"\") :\n              t(\"Team Site\"),\n            testId(\"orgmenu-title\"),\n          ),\n          menuItemLink(urlState().setLinkUrl({}), t(\"Home page\"), testId(\"orgmenu-home-page\")),\n\n          // Show 'Organization Settings' when on a home page of a valid org.\n          (!this._docPageModel && this._currentOrg && !this._currentOrg.owner ?\n            menuItem(() => manageTeamUsersApp({ app: this._appModel }),\n              t(\"Manage team\"), testId(\"orgmenu-manage-team\"),\n              dom.cls(\"disabled\", !roles.canEditAccess(this._currentOrg.access))) :\n            // Don't show on doc pages, or for personal orgs.\n            null),\n\n          this._maybeBuildBillingPageMenuItem(),\n          this._maybeBuildActivationPageMenuItem(),\n\n          maybeAddSiteSwitcherSection(this._appModel),\n        ], { placement: \"bottom-start\" }),\n        testId(\"org\"),\n      );\n    }\n  }\n\n  private _setHomePageUrl(link: AppLogoLink) {\n    if (link.type === \"href\") {\n      return { href: link.href };\n    } else {\n      return urlState().setLinkUrl({ org: link.domain });\n    }\n  }\n\n  private _maybeBuildBillingPageMenuItem() {\n    const { deploymentType } = getGristConfig();\n    if (deploymentType !== \"saas\") { return null; }\n\n    const { currentOrg } = this._appModel;\n    const isBillingManager = this._appModel.isBillingManager() || this._appModel.isSupport();\n    return currentOrg && !currentOrg.owner ?\n      // For links, disabling with just a class is hard; easier to just not make it a link.\n      // TODO weasel menus should support disabling menuItemLink.\n      (isBillingManager ?\n        menuItemLink(\n          urlState().setLinkUrl({ billing: \"billing\" }),\n          t(\"Billing account\"),\n          testId(\"orgmenu-billing\"),\n        ) :\n        menuItem(\n          () => null,\n          t(\"Billing account\"),\n          dom.cls(\"disabled\", true),\n          testId(\"orgmenu-billing\"),\n        )\n      ) :\n      null;\n  }\n\n  private _maybeBuildActivationPageMenuItem() {\n    const { deploymentType } = getGristConfig();\n    if (deploymentType !== \"enterprise\" || !this._appModel.isInstallAdmin()) {\n      return null;\n    }\n\n    return menuItemLink(\"Activation\", urlState().setLinkUrl({ activation: \"activation\" }));\n  }\n\n  private _getAppLogoOrgNameAndLink(params: {\n    availableOrgs: Organization[],\n    currentOrgName: string,\n    lastVisitedOrgDomain: string | null,\n  }): AppLogoOrgNameAndLink {\n    const {\n      currentValidUser,\n      isTemplatesSite,\n    } = this._appModel;\n    const { deploymentType } = getGristConfig();\n    if (deploymentType === \"saas\" && !currentValidUser && isTemplatesSite) {\n      // When signed out and on the templates site (in SaaS Grist), link to the templates page.\n      return {\n        name: t(\"Grist Templates\"),\n        link: {\n          type: \"href\",\n          href: commonUrls.templates,\n        },\n      };\n    }\n\n    const { availableOrgs, currentOrgName, lastVisitedOrgDomain } = params;\n    if (lastVisitedOrgDomain) {\n      const lastVisitedOrg = availableOrgs.find(({ domain }) => domain === lastVisitedOrgDomain);\n      if (lastVisitedOrg) {\n        return {\n          name: getOrgName(lastVisitedOrg),\n          link: {\n            type: \"domain\",\n            domain: lastVisitedOrgDomain,\n          },\n        };\n      }\n    }\n\n    return {\n      name: currentOrgName ?? \"\",\n      link: {\n        type: \"href\",\n        href: getWelcomeHomeUrl(),\n      },\n    };\n  }\n}\n\nexport function productPill(org: Organization | null, options: { large?: boolean } = {}): DomContents {\n  if (!org || isTemplatesOrg(org)) {\n    return null;\n  }\n  const product = org?.billingAccount?.product.name;\n  const pillTag = product && productPills[product];\n  if (!pillTag) {\n    return null;\n  }\n  return cssProductPill(cssProductPill.cls(\"-\" + pillTag),\n    options.large ? cssProductPill.cls(\"-large\") : null,\n    pillTag,\n    testId(\"product-pill\"));\n}\n\nconst cssAppHeader = styled(\"header._cssAppHeader\", `\n  width: 100%;\n  height: 100%;\n  background-color: ${theme.leftPanelBg};\n  padding: 0px;\n  padding: 8px;\n\n  .${cssLeftPane.className}-open & {\n    padding: 8px 16px;\n  }\n  &-widelogo {\n    padding: 0px !important;\n  }\n  &, &:hover, &:focus {\n    text-decoration: none;\n    outline: none;\n    color: ${theme.text};\n  }\n`);\n\nconst cssAppHeaderBox = styled(\"div._cssAppHeaderBox\", `\n  display: flex;\n  align-items: center;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  background-color: ${theme.appHeaderBg};\n  border-radius: 4px;\n  overflow: hidden;\n  &:hover{\n    --middle-border-color: ${theme.appHeaderBorderHover};\n  }\n  .${cssAppHeader.className}-widelogo & {\n    border: none !important;\n    overflow: visible;\n  }\n`);\n\nconst cssAppLogo = styled(\"a._cssAppLogo\", `\n  flex: none;\n  height: 100%;\n  aspect-ratio: 1 / 1;\n  text-decoration: none;\n  background-repeat: no-repeat;\n  background-position: center;\n  background-color: inherit;\n  background-size: cover;\n\n  border: 1px solid ${theme.appHeaderBorder};\n  border-radius: 4px;\n  overflow: hidden;\n  border-right-color: var(--middle-border-color, ${theme.appHeaderBorder});\n\n  /* make sure keyboard highlight is visible\n  (it wouldn't be without the offset because of the overflow: hidden) */\n  outline-offset: -3px;\n  position: relative;\n  z-index: 1;\n\n  &-grist-logo {\n    background-image: var(--icon-GristLogo);\n    background-color: ${vars.logoBg};\n    background-size: ${vars.logoSize};\n  }\n\n  .${cssAppHeader.className}-widelogo & {\n    width: 100%;\n    background-size: contain;\n    background-origin: content-box;\n    padding: 8px;\n    border-right: none !important;\n    background-size: contain;\n    border: 0px !important;\n  }\n  .${cssLeftPane.className}-open .${cssAppHeader.className}-widelogo & {\n    background-image: var(--icon-GristWideLogo, var(--icon-GristLogo));\n    background-size: contain;\n  }\n  &:hover {\n    border-color: ${theme.appHeaderBorderHover};\n    text-decoration: none;\n  }\n  .${cssLeftPane.className}-open & {\n    border-top-right-radius: 0;\n    border-bottom-right-radius: 0;\n  }\n`);\n\nconst cssOrg = styled(unstyledButton, `\n  display: none;\n  flex-grow: 1;\n  flex-basis: 0px;\n  overflow: hidden;\n  align-items: center;\n  cursor: pointer;\n  height: 100%;\n  font-weight: 500;\n\n  border: 1px solid ${theme.appHeaderBorder};\n  border-radius: 4px;\n  border-top-left-radius: 0;\n  border-bottom-left-radius: 0;\n  border-left: 0px;\n\n  /* make sure keyboard highlight is visible\n  (it wouldn't be without the offset because of the overflow: hidden) */\n  outline-offset: -3px;\n\n  &:hover {\n    border-color: ${theme.appHeaderBorderHover};\n  }\n  .${cssLeftPane.className}-open & {\n    display: flex;\n  }\n`);\n\nconst cssDropdownIcon = styled(icon, `\n  --icon-color: ${theme.text};\n  flex-shrink: 0;\n  margin-right: 8px;\n`);\n\nconst cssSpacer = styled(\"div\", `\n  display: none;\n  flex: 1;\n  display: block;\n`);\n\nconst cssOrgLink = styled(\"a.cssOrgLink\", `\n  display: none;\n  flex-grow: 1;\n  align-items: center;\n  max-width: calc(100% - 32px);\n  cursor: pointer;\n  height: 100%;\n  font-weight: 500;\n  color: ${theme.text};\n  user-select: none;\n\n\n  border: 1px solid ${theme.appHeaderBorder};\n  border-radius: 4px;\n  border-left-width: 0;\n  border-top-left-radius: 0;\n  border-bottom-left-radius: 0;\n\n  &, &:hover, &:focus {\n    text-decoration: none;\n  }\n\n  .${cssLeftPane.className}-open & {\n    border-left: 1px solid ${theme.appHeaderBorder};\n  }\n\n  &:hover {\n    color: ${theme.text};\n    background-color: ${theme.hover};\n  }\n\n  .${cssLeftPane.className}-open & {\n    display: flex;\n  }\n`);\n\nconst cssOrgName = styled(\"div\", `\n  padding-left: 16px;\n  padding-right: 8px;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  .${cssAppHeader.className}-widelogo & {\n    display: none;\n  }\n`);\n\nconst cssProductPill = styled(\"div\", `\n  border-radius: 4px;\n  font-size: ${vars.smallFontSize};\n  padding: 2px 4px;\n  display: inline;\n  vertical-align: middle;\n\n  &-Free {\n    background-color: ${colors.orange};\n    color: white;\n  }\n  &-Pro {\n    background-color: ${colors.lightGreen};\n    color: white;\n  }\n  &-large {\n    padding: 4px 8px;\n    margin-left: 16px;\n    font-size: ${vars.mediumFontSize};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/AppUI.ts",
    "content": "import { buildDocumentBanners, buildHomeBanners } from \"app/client/components/Banners\";\nimport { ViewAsBanner } from \"app/client/components/ViewAsBanner\";\nimport { domAsync } from \"app/client/lib/domAsync\";\nimport {\n  loadAccountPage,\n  loadActivationPage,\n  loadAdminPanel,\n  loadAuditLogsPage,\n  loadBillingPage,\n} from \"app/client/lib/imports\";\nimport { createSessionObs, isBoolean, isNumber } from \"app/client/lib/sessionObs\";\nimport { AppModel, TopAppModel } from \"app/client/models/AppModel\";\nimport { DocPageModelImpl } from \"app/client/models/DocPageModel\";\nimport { HomeModelImpl } from \"app/client/models/HomeModel\";\nimport { App } from \"app/client/ui/App\";\nimport { AppHeader } from \"app/client/ui/AppHeader\";\nimport { createBottomBarDoc } from \"app/client/ui/BottomBar\";\nimport { createDocMenu } from \"app/client/ui/DocMenu\";\nimport { createForbiddenPage, createNotFoundPage, createOtherErrorPage } from \"app/client/ui/errorPages\";\nimport { createHomeLeftPane } from \"app/client/ui/HomeLeftPane\";\nimport { buildSnackbarDom } from \"app/client/ui/NotifyUI\";\nimport { OnboardingPage, shouldShowOnboardingPage } from \"app/client/ui/OnboardingPage\";\nimport { pagePanels } from \"app/client/ui/PagePanels\";\nimport { RightPanel } from \"app/client/ui/RightPanel\";\nimport { createTopBarDoc, createTopBarHome } from \"app/client/ui/TopBar\";\nimport { WelcomePage } from \"app/client/ui/WelcomePage\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { getPageTitleSuffix } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent, subscribe } from \"grainjs\";\n\n// When integrating into the old app, we might in theory switch between new-style and old-style\n// content. This function allows disposing the created content by old-style code.\n// TODO once #newui is gone, we don't need to worry about this being disposable.\n// appObj is the App object from app/client/ui/App.ts\nexport function createAppUI(topAppModel: TopAppModel, appObj: App): IDisposable {\n  const content = dom.maybe(topAppModel.appObs, (appModel) => {\n    return [\n      createMainPage(appModel, appObj),\n      buildSnackbarDom(appModel.notifier, appModel),\n    ];\n  });\n  dom.update(document.body, content, {\n    // Cancel out bootstrap's overrides.\n    style: \"font-family: inherit; font-size: inherit; line-height: inherit;\",\n  });\n\n  function dispose() {\n    // Return value of dom.maybe() / dom.domComputed() is a pair of markers with a function that\n    // replaces content between them when an observable changes. It's uncommon to dispose the set\n    // with the markers, and grainjs doesn't provide a helper, but we can accomplish it by\n    // disposing the markers. They will automatically trigger the disposal of the included\n    // content. This avoids the need to wrap the contents in another layer of a dom element.\n    const [beginMarker, endMarker] = content;\n    replaceContent(beginMarker, endMarker, null);\n    dom.domDispose(beginMarker);\n    dom.domDispose(endMarker);\n    document.body.removeChild(beginMarker);\n    document.body.removeChild(endMarker);\n  }\n  return { dispose };\n}\n\nfunction createMainPage(appModel: AppModel, appObj: App) {\n  if (!appModel.currentOrg && appModel.needsOrg.get()) {\n    const err = appModel.orgError;\n    if (err?.status === 404) {\n      return createNotFoundPage(appModel);\n    } else if (err && (err.status === 401 || err.status === 403)) {\n      // Generally give access denied error.\n      // The exception is for document pages, where we want to allow access to documents\n      // shared publicly without being shared specifically with the current user.\n      if (appModel.pageType.get() !== \"doc\") {\n        return createForbiddenPage(appModel);\n      }\n    } else {\n      return createOtherErrorPage(appModel, err?.error);\n    }\n  }\n  return dom.domComputed(appModel.pageType, (pageType) => {\n    if (pageType === \"home\") {\n      return dom.create(pagePanelsHome, appModel, appObj);\n    } else if (pageType === \"billing\") {\n      return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel)));\n    } else if (pageType === \"welcome\") {\n      return dom.create(WelcomePage, appModel, appObj);\n    } else if (pageType === \"account\") {\n      return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel, appObj)));\n    } else if (pageType === \"admin\") {\n      return domAsync(loadAdminPanel().then(m => dom.create(m.AdminPanel, appModel, appObj)));\n    } else if (pageType === \"activation\") {\n      return domAsync(loadActivationPage().then(ap => dom.create(ap.getActivationPage(), appModel)));\n    } else if (pageType === \"audit-logs\") {\n      return domAsync(loadAuditLogsPage().then(m => dom.create(m.AuditLogsPage, appModel, appObj)));\n    } else {\n      return dom.create(pagePanelsDoc, appModel, appObj);\n    }\n  });\n}\n\nfunction pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) {\n  if (shouldShowOnboardingPage(appModel.userPrefsObs)) {\n    return dom.create(OnboardingPage, appModel);\n  }\n\n  const pageModel = HomeModelImpl.create(owner, appModel, app.clientScope);\n  const leftPanelOpen = Observable.create(owner, true);\n\n  // Set document title to strings like \"Home - Grist\" or \"Org Name - Grist\".\n  owner.autoDispose(subscribe(pageModel.currentPage, pageModel.currentWS, (use, page, ws) => {\n    const name = (\n      page === \"trash\" ? \"Trash\" :\n        page === \"templates\" ? \"Examples & Templates\" :\n          ws ? ws.name : appModel.currentOrgName\n    );\n    document.title = `${name}${getPageTitleSuffix(getGristConfig())}`;\n  }));\n\n  return pagePanels({\n    leftPanel: {\n      panelWidth: Observable.create(owner, 240),\n      panelOpen: leftPanelOpen,\n      hideOpener: true,\n      header: dom.create(AppHeader, appModel),\n      content: createHomeLeftPane(leftPanelOpen, pageModel),\n    },\n    headerMain: createTopBarHome(appModel),\n    contentMain: createDocMenu(pageModel),\n    contentTop: buildHomeBanners(appModel),\n    testId,\n    app,\n  });\n}\n\nfunction pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App) {\n  const pageModel = DocPageModelImpl.create(owner, appObj, appModel);\n  // To simplify manual inspection in the common case, keep the most recently created\n  // DocPageModel available as a global variable.\n  window.gristDocPageModel = pageModel;\n  appObj.pageModel = pageModel;\n\n  const leftPanelOpen = createSessionObs<boolean>(owner, \"leftPanelOpen\", true, isBoolean);\n  const rightPanelOpen = createSessionObs<boolean>(owner, \"rightPanelOpen\", false, isBoolean);\n  const leftPanelWidth = createSessionObs<number>(owner, \"leftPanelWidth\", 240, isNumber);\n  const rightPanelWidth = createSessionObs<number>(owner, \"rightPanelWidth\", 240, isNumber);\n\n  // The RightPanel component gets created only when an instance of GristDoc is set in pageModel.\n  // use.owner is a feature of grainjs to make the new RightPanel owned by the computed itself:\n  // each time the gristDoc observable changes (and triggers the callback), the previously-created\n  // instance of RightPanel will get disposed.\n  const rightPanel = Computed.create(owner, pageModel.gristDoc, (use, gristDoc) =>\n    gristDoc ? RightPanel.create(use.owner, gristDoc, rightPanelOpen) : null);\n\n  // Set document title to strings like \"DocName - Grist\"\n  owner.autoDispose(subscribe(pageModel.currentDocTitle, (use, docName) => {\n    // If the document hasn't loaded yet, don't update the title; since the HTML document already has\n    // a title element with the document's name, there's no need for further action.\n    if (!pageModel.currentDoc.get()) { return; }\n\n    document.title = `${docName}${getPageTitleSuffix(getGristConfig())}`;\n  }));\n\n  // Called after either panel is closed, opened, or resized.\n  function onResize() {\n    const gristDoc = pageModel.gristDoc.get();\n    if (gristDoc) { gristDoc.resizeEmitter.emit(); }\n  }\n\n  return pagePanels({\n    leftPanel: {\n      panelWidth: leftPanelWidth,\n      panelOpen: leftPanelOpen,\n      header: dom.create(AppHeader, appModel, pageModel),\n      content: pageModel.createLeftPane(leftPanelOpen),\n    },\n    rightPanel: {\n      panelWidth: rightPanelWidth,\n      panelOpen: rightPanelOpen,\n      header: dom.maybe(rightPanel, panel => panel.header),\n      content: dom.maybe(rightPanel, panel => panel.content),\n    },\n    headerMain: dom.create(createTopBarDoc, appModel, pageModel),\n    contentMain: dom.maybe(pageModel.gristDoc, gristDoc => gristDoc.buildDom()),\n    onResize,\n    testId,\n    contentTop: buildDocumentBanners(pageModel),\n    contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen),\n    banner: dom.create(ViewAsBanner, pageModel),\n    app: appObj,\n  });\n}\n"
  },
  {
    "path": "app/client/ui/AuditLogStreamingConfig.ts",
    "content": "import { handleFormError, handleSubmit } from \"app/client/lib/formUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { AuditLogsModel } from \"app/client/models/AuditLogsModel\";\nimport { textInput } from \"app/client/ui/inputs\";\nimport { bigBasicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { menu, menuItem } from \"app/client/ui2018/menus\";\nimport { confirmModal, modal } from \"app/client/ui2018/modals\";\nimport {\n  AuditLogStreamingDestination,\n  AuditLogStreamingDestinationName,\n  AuditLogStreamingDestinationNameChecker,\n} from \"app/common/Config\";\nimport { commonUrls } from \"app/common/gristUrls\";\n\nimport { Computed, Disposable, dom, makeTestId, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"AuditLogStreamingConfig\");\n\nconst testId = makeTestId(\"test-audit-logs-\");\n\nexport class AuditLogStreamingConfig extends Disposable {\n  constructor(private _model: AuditLogsModel) {\n    super();\n  }\n\n  public buildDom() {\n    return dom.domComputed(\n      this._model.streamingDestinations,\n      (destinations) => {\n        if (destinations === null) {\n          return cssLoadingSpinner(loadingSpinner());\n        }\n\n        return dom(\"div\",\n          cssParagraph(\n            t(\n              \"Set up streaming of audit events from Grist to an external \\\nsecurity information and event management (SIEM) system like \\\nSplunk. {{learnMoreLink}}.\",\n              {\n                learnMoreLink: cssLink(\n                  { href: commonUrls.helpInstallAuditLogs, target: \"_blank\" },\n                  t(\"Learn more\"),\n                ),\n              },\n            ),\n          ),\n          dom(\"div\",\n            dom.hide(destinations.length === 0),\n            dom(\"div\",\n              cssSectionHeading(t(\"Destinations\")),\n              cssDestinations(\n                dom.forEach(destinations, destination =>\n                  cssDestination(\n                    cssDestinationName(\n                      getDestinationDisplayName(destination.name),\n                      testId(\"streaming-destination-name\"),\n                    ),\n                    cssDestinationUrl(\n                      destination.url,\n                      testId(\"streaming-destination-url\"),\n                    ),\n                    cssDestinationOptions(\n                      icon(\"Dots\"),\n                      menu(\n                        () => [\n                          menuItem(\n                            () =>\n                              this._handleEditDestinationClick(destination),\n                            t(\"Edit\"),\n                          ),\n                          menuItem(\n                            () =>\n                              this._handleDeleteDestinationClick(destination),\n                            t(\"Delete\"),\n                          ),\n                        ],\n                        { placement: \"bottom-start\" },\n                      ),\n                      dom.on(\"click\", (ev) => {\n                        ev.stopPropagation();\n                        ev.preventDefault();\n                      }),\n                      testId(\"streaming-destination-options\"),\n                    ),\n                    testId(\"streaming-destination\"),\n                  ),\n                ),\n              ),\n            ),\n          ),\n          bigPrimaryButton(\n            destinations.length === 0 ?\n              t(\"Start streaming\") :\n              t(\"Add destination\"),\n            dom.on(\"click\", () => this._handleAddDestinationClick()),\n            testId(\"add-streaming-destination\"),\n          ),\n        );\n      },\n    );\n  }\n\n  private _handleAddDestinationClick() {\n    showDestinationForm({\n      title: t(\"Add streaming destination\"),\n      submitButtonLabel: t(\"Add destination\"),\n      onSubmit: destination =>\n        this._model.createStreamingDestination(destination),\n    });\n  }\n\n  private _handleDeleteDestinationClick({ id }: AuditLogStreamingDestination) {\n    confirmModal(\n      t(\"Delete streaming destination?\"),\n      t(\"Delete\"),\n      () => this._model.deleteStreamingDestination(id),\n      {\n        explanation: t(\n          \"Are you sure you want to delete this streaming destination? This action cannot be undone.\",\n        ),\n      },\n    );\n  }\n\n  private _handleEditDestinationClick(\n    destination: AuditLogStreamingDestination,\n  ) {\n    showDestinationForm({\n      title: t(\"Edit streaming destination\"),\n      submitButtonLabel: t(\"Save\"),\n      destination,\n      onSubmit: properties =>\n        this._model.updateStreamingDestination(destination.id, properties),\n    });\n  }\n}\n\nexport function getDestinationDisplayName(name: AuditLogStreamingDestinationName) {\n  switch (name) {\n    case \"splunk\": {\n      return t(\"Splunk\");\n    }\n    case \"other\": {\n      return t(\"Other\");\n    }\n  }\n}\n\ninterface DestinationFormOptions {\n  title: string;\n  submitButtonLabel: string;\n  destination?: AuditLogStreamingDestination;\n  onSubmit(properties: Omit<AuditLogStreamingDestination, \"id\">): Promise<void>;\n}\n\nfunction showDestinationForm(options: DestinationFormOptions) {\n  const { title, submitButtonLabel, onSubmit, destination } = options;\n  return modal((ctl, owner) => {\n    const name = Observable.create<AuditLogStreamingDestinationName | null>(\n      owner,\n      destination?.name ?? null,\n    );\n    const url = Observable.create<string>(owner, destination?.url ?? \"\");\n    const token = Observable.create<string>(owner, destination?.token ?? \"\");\n    const pending = Observable.create(owner, false);\n    const disabled = Computed.create(owner, use => !use(name) || !use(url));\n    const error = Observable.create(owner, \"\");\n\n    const handleDestinationChange = (event: Event) => {\n      const value = (event.target as HTMLInputElement)?.value;\n      assertStreamingDestinationName(value);\n      name.set(value);\n    };\n\n    return [\n      cssModal.cls(\"\"),\n      cssModalTitle(title),\n      dom(\"form\",\n        handleSubmit({\n          pending,\n          disabled,\n          onSubmit: fields => onSubmit(toStreamingDestination(fields)),\n          onSuccess: () => ctl.close(),\n          onError: e => handleFormError(e, error),\n        }),\n        cssLabelAndInput(\n          cssLabel(t(\"Destination\")),\n          cssCards(\n            cssCard(\n              cssCard.cls(\"-selected\", use => use(name) === \"splunk\"),\n              { for: \"splunk\" },\n              cssCardInput(\n                {\n                  id: \"splunk\",\n                  name: \"name\",\n                  type: \"radio\",\n                  value: \"splunk\",\n                },\n                dom.prop(\"checked\", use => use(name) === \"splunk\"),\n                dom.on(\"change\", handleDestinationChange),\n              ),\n              cssCardContent(\n                cssCardImage({ src: \"img/audit-logs-splunk.svg\" }),\n              ),\n            ),\n            cssCard(\n              cssCard.cls(\"-selected\", use => use(name) === \"other\"),\n              { for: \"other\" },\n              cssCardInput(\n                {\n                  id: \"other\",\n                  name: \"name\",\n                  type: \"radio\",\n                  value: \"other\",\n                },\n                dom.prop(\"checked\", use => use(name) === \"other\"),\n                dom.on(\"change\", handleDestinationChange),\n              ),\n              cssCardContent(\n                cssCardImage({ src: \"img/audit-logs-other.svg\" }),\n              ),\n            ),\n          ),\n        ),\n        cssLabelAndInput(\n          cssLabel(t(\"URL\"), { for: \"url\" }),\n          cssTextInput(url, {\n            id: \"url\",\n            name: \"url\",\n            type: \"url\",\n            placeholder: t(\"Enter URL\"),\n          }),\n        ),\n        cssLabelAndInput(\n          cssLabel(t(\"Token\"), { for: \"token\" }),\n          cssTextInput(token, {\n            id: \"token\",\n            name: \"token\",\n            type: \"text\",\n            placeholder: t(\"Enter token\"),\n          }),\n        ),\n        cssMessages(\n          dom.maybe(error, e => cssError(e)),\n        ),\n        cssModalButtons(\n          bigBasicButton(\n            t(\"Cancel\"),\n            { type: \"button\" },\n            dom.on(\"click\", () => ctl.close()),\n            testId(\"streaming-destination-form-cancel\"),\n          ),\n          bigPrimaryButton(\n            t(submitButtonLabel),\n            { type: \"submit\" },\n            dom.boolAttr(\"disabled\", use => use(pending) || use(disabled)),\n            testId(\"streaming-destination-form-apply\"),\n          ),\n        ),\n      ),\n    ];\n  });\n}\n\nfunction toStreamingDestination(formData: Record<string, string>) {\n  const { name, url, token } = formData;\n  assertStreamingDestinationName(name);\n  const destination: Omit<AuditLogStreamingDestination, \"id\"> = {\n    name,\n    url,\n    token,\n  };\n  return destination;\n}\n\nfunction assertStreamingDestinationName(name: string): asserts name is AuditLogStreamingDestinationName {\n  AuditLogStreamingDestinationNameChecker.check(name);\n}\n\nconst cssLoadingSpinner = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n`);\n\nconst cssParagraph = styled(\"div\", `\n  margin-bottom: 16px;\n`);\n\nconst cssSectionHeading = styled(\"div\", `\n  font-size: ${vars.introFontSize};\n  font-weight: 600;\n  margin-bottom: 16px;\n`);\n\nconst cssDestinations = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  row-gap: 16px;\n  margin-bottom: 16px;\n`);\n\nconst cssDestination = styled(\"div\", `\n  display: flex;\n  column-gap: 8px;\n  align-items: center;\n  height: 32px;\n  line-height: 32px;\n  border-radius: 3px;\n`);\n\nconst cssDestinationName = styled(\"div\", `\n  font-weight: 600;\n  flex-shrink: 0;\n  display: flex;\n  align-items: center;\n  height: 100%;\n  padding: 4px;\n`);\n\nconst cssDestinationUrl = styled(\"div\", `\n  flex-grow: 1;\n  color: ${theme.lightText};\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  font-weight: normal;\n`);\n\nconst cssDestinationOptions = styled(\"div\", `\n  flex-shrink: 0;\n  margin: 0 4px 0 auto;\n  height: 24px;\n  width: 24px;\n  padding: 4px;\n  line-height: 0px;\n  border-radius: 3px;\n  cursor: pointer;\n  --icon-color: ${theme.lightText};\n\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssModal = styled(\"div\", `\n  padding: 32px;\n  width: min(100%, 480px);\n`);\n\nconst cssModalTitle = styled(\"div\", `\n  font-size: 24px;\n  font-weight: 600;\n  line-height: 32px;\n  margin-bottom: 16px;\n`);\n\nconst cssLabelAndInput = styled(\"div\", `\n  margin-bottom: 16px;\n`);\n\nconst cssLabel = styled(\"label\", `\n  color: ${theme.text};\n  display: inline-block;\n  line-height: 20px;\n  font-size: 14px;\n  font-weight: 600;\n  margin-bottom: 8px;\n`);\n\nconst cssCards = styled(\"grid\", `\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 16px;\n`);\n\nconst cssCard = styled(\"label\", `\n  display: inline-flex;\n  background: #FFF;\n  border: 2px solid ${theme.cardButtonBorder};\n  box-shadow: 1px 1px 4px 1px ${theme.cardButtonShadow};\n  padding: 8px;\n  border-radius: 4px;\n  position: relative;\n  cursor: pointer;\n\n  &:hover {\n    background-color: #E8E8E8;\n  }\n  &-selected {\n    outline: 2px solid ${theme.cardButtonBorderSelected};\n    outline-offset: -2px;\n  }\n`);\n\nconst cssCardInput = styled(\"input\", `\n  flex-shrink: 0;\n  background-color: #FFF;\n  appearance: none;\n  width: 16px;\n  height: 16px;\n  margin: 0px;\n  border-radius: 50%;\n  background-clip: content-box;\n  border: 1px solid #D9D9D9;\n  outline-color: #009058;\n  cursor: pointer;\n\n  &:hover {\n    border: 1px solid #BFBFBF;\n  }\n  &:focus {\n    outline: unset !important;\n    outline-offset: unset !important;\n  }\n  &:focus-visible {\n    outline: 2px solid #009058 !important;\n    outline-offset: 0px !important;\n  }\n  &:checked {\n    padding: 2px;\n    background-color: ${vars.primaryBg};\n    border: 1px solid ${vars.primaryBg};\n  }\n`);\n\nconst cssCardContent = styled(\"span\", `\n  padding: 8px;\n  flex-grow: 1;\n`);\n\nconst cssCardImage = styled(\"img\", `\n  height: 100%;\n  width: 100%;\n  object-fit: contain;\n  aspect-ratio: 3;\n`);\n\nconst cssTextInput = styled(textInput, `\n  height: unset;\n  padding: 8px;\n`);\n\nconst cssMessages = styled(\"div\", `\n  text-align: center;\n  min-height: 15px;\n`);\n\nconst cssError = styled(\"span\", `\n  color: ${theme.errorText};\n`);\n\nconst cssModalButtons = styled(\"div\", `\n  display: flex;\n  justify-content: flex-end;\n  column-gap: 8px;\n  margin-top: 16px;\n`);\n"
  },
  {
    "path": "app/client/ui/AuditLogsPage.ts",
    "content": "import { buildHomeBanners } from \"app/client/components/Banners\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { AppModel, reportError } from \"app/client/models/AppModel\";\nimport { AuditLogsModel, AuditLogsModelImpl } from \"app/client/models/AuditLogsModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { App } from \"app/client/ui/App\";\nimport { AppHeader } from \"app/client/ui/AppHeader\";\nimport { AuditLogStreamingConfig } from \"app/client/ui/AuditLogStreamingConfig\";\nimport { OrgConfigsAPI } from \"app/client/ui/ConfigsAPI\";\nimport { createForbiddenPage, createNotFoundPage } from \"app/client/ui/errorPages\";\nimport { leftPanelBasic } from \"app/client/ui/LeftPanelCommon\";\nimport { pagePanels } from \"app/client/ui/PagePanels\";\nimport { createTopBarHome } from \"app/client/ui/TopBar\";\nimport { cssBreadcrumbs, separator } from \"app/client/ui2018/breadcrumbs\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { commonUrls, getPageTitleSuffix } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport {\n  Computed,\n  Disposable,\n  dom,\n  makeTestId,\n  Observable,\n  styled,\n  subscribe,\n} from \"grainjs\";\n\nconst t = makeT(\"AuditLogsPage\");\n\nconst testId = makeTestId(\"test-audit-logs-page-\");\n\nexport class AuditLogsPage extends Disposable {\n  private readonly _model: AuditLogsModel = new AuditLogsModelImpl({\n    configsAPI: new OrgConfigsAPI(this._appModel.currentOrg!.id),\n  });\n\n  private readonly _currentPage = Computed.create(\n    this,\n    urlState().state,\n    (_use, s) => s.auditLogs,\n  );\n\n  constructor(private _appModel: AppModel, private _appObj: App) {\n    super();\n    this._setTitle();\n  }\n\n  public buildDom() {\n    const { deploymentType } = getGristConfig();\n    if (\n      !this._appModel.isTeamSite ||\n      !deploymentType ||\n      ![\"saas\", \"core\", \"enterprise\"].includes(deploymentType)\n    ) {\n      return createNotFoundPage(this._appModel);\n    }\n    if (!this._appModel.isOwner()) {\n      return createForbiddenPage(\n        this._appModel,\n        t(\"Only site owners may access audit logs.\"),\n      );\n    }\n\n    const panelOpen = Observable.create(this, false);\n    return pagePanels({\n      leftPanel: {\n        panelWidth: Observable.create(this, 240),\n        panelOpen,\n        hideOpener: true,\n        header: dom.create(AppHeader, this._appModel),\n        content: leftPanelBasic(this._appModel, panelOpen),\n      },\n      headerMain: this._buildHeader(),\n      contentTop: buildHomeBanners(this._appModel),\n      contentMain: this._buildContent(),\n      app: this._appObj,\n    });\n  }\n\n  private _buildHeader() {\n    return dom.frag(\n      cssBreadcrumbs(\n        { style: \"margin-left: 16px;\" },\n        cssLink(urlState().setLinkUrl({}), t(\"Home\"), testId(\"home\")),\n        separator(\" / \"),\n        dom(\"span\", t(\"Audit Logs\")),\n      ),\n      createTopBarHome(this._appModel),\n    );\n  }\n\n  private _buildContent() {\n    return cssScrollablePage(\n      cssPageContent(\n        cssPageTitle(\n          t(\"Audit logs for {{siteName}}\", {\n            siteName: this._appModel.currentOrgName,\n          }),\n        ),\n        cssSection(\n          cssSectionTitle(t(\"Log streaming\")),\n          cssSectionBody(this._buildLogStreamingConfig()),\n        ),\n      ),\n    );\n  }\n\n  private _buildLogStreamingConfig() {\n    const { deploymentType } = getGristConfig();\n    if (deploymentType === \"core\") {\n      return t(\n        \"You can set up streaming of audit events from Grist to an external \\\nSIEM (security information and event management) system if you \\\nenable Grist Enterprise. {{contactUsLink}} to learn more.\",\n        {\n          contactUsLink: cssLink(\n            { href: commonUrls.contact, target: \"_blank\" },\n            t(\"Contact us\"),\n          ),\n        },\n      );\n    } else if (\n      deploymentType === \"saas\" &&\n      !this._appModel.currentFeatures?.teamAuditLogs\n    ) {\n      return t(\n        \"You can set up streaming of audit events from Grist to an external \\\nSIEM (security information and event management) system if you \\\n{{upgradePlanButton}}.\",\n        {\n          upgradePlanButton: textButton(\n            dom.on(\"click\", () => this._appModel.showUpgradeModal()),\n            t(\"upgrade your plan\"),\n          ),\n        },\n      );\n    } else {\n      this._model.fetchStreamingDestinations().catch(reportError);\n      return dom.create(AuditLogStreamingConfig, this._model);\n    }\n  }\n\n  private _setTitle() {\n    this.autoDispose(\n      subscribe(this._currentPage, (_use, page): string => {\n        const suffix = getPageTitleSuffix(getGristConfig());\n        switch (page) {\n          case undefined:\n          case \"audit-logs\": {\n            return (document.title = t(\"Audit Logs\") + suffix);\n          }\n        }\n      }),\n    );\n  }\n}\n\nconst cssScrollablePage = styled(\"div\", `\n  overflow: auto;\n`);\n\nconst cssPageContent = styled(\"div\", `\n  color: ${theme.text};\n  margin: 32px auto;\n  max-width: 600px;\n  padding: 24px;\n`);\n\nconst cssPageTitle = styled(\"div\", `\n  height: 32px;\n  line-height: 32px;\n  margin-bottom: 16px;\n  font-size: ${vars.headerControlFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n`);\n\nconst cssSection = styled(\"div\", `\n  font-size: ${vars.introFontSize};\n`);\n\nconst cssSectionTitle = styled(\"div\", `\n  font-weight: bold;\n  font-size: ${vars.largeFontSize};\n  margin-bottom: 16px;\n`);\n\nconst cssSectionBody = styled(\"div\", ``);\n"
  },
  {
    "path": "app/client/ui/AuthenticationSection.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { cssMarkdownSpan } from \"app/client/lib/markdown\";\nimport { AppModel, getHomeUrl, reportError } from \"app/client/models/AppModel\";\nimport {\n  AdminPanelControls,\n  cssIconWrapper,\n  cssWell,\n  cssWellContent,\n  cssWellTitle,\n} from \"app/client/ui/AdminPanelCss\";\nimport { ChangeAdminModal } from \"app/client/ui/ChangeAdminModal\";\nimport { GetGristComProviderInfoModal } from \"app/client/ui/GetGristComProvider\";\nimport { basicButton, bigBasicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { confirmModal, cssModalWidth, modal, saveModal } from \"app/client/ui2018/modals\";\nimport { AuthProvider, ConfigAPI } from \"app/common/ConfigAPI\";\nimport { PendingChanges } from \"app/common/Install\";\nimport { InstallAPI } from \"app/common/InstallAPI\";\nimport {\n  DEPRECATED_PROVIDERS,\n  FORWARD_AUTH_PROVIDER_KEY,\n  GETGRIST_COM_PROVIDER_KEY,\n  GRIST_CONNECT_PROVIDER_KEY,\n  MINIMAL_PROVIDER_KEY,\n  OIDC_PROVIDER_KEY,\n  SAML_PROVIDER_KEY,\n} from \"app/common/loginProviders\";\n\nimport { Computed, Disposable, dom, makeTestId, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"AdminPanel\");\n\nconst testId = makeTestId(\"test-admin-auth-\");\n\ninterface AuthenticationSectionOptions {\n  appModel: AppModel;\n  loginSystemId: Observable<string | undefined>;\n  controls: AdminPanelControls;\n  installAPI: InstallAPI;\n}\n\nexport class AuthenticationSection extends Disposable {\n  private _appModel = this._options.appModel;\n  private _loginSystemId = this._options.loginSystemId;\n  private _controls = this._options.controls;\n  private _installAPI = this._options.installAPI;\n  private _prefsPendingChanges = Observable.create<PendingChanges | null>(this, null);\n\n  private _providers = Observable.create<AuthProvider[]>(this, []);\n  private _configAPI = new ConfigAPI(getHomeUrl());\n  private _currentUserEmail = this._appModel.currentValidUser!.email;\n\n  private _hasActiveOnRestartProvider = Computed.create(this, this._providers, (_use, providers) => {\n    return providers.some(p => p.willBeActive);\n  });\n\n  private _showNoAuthenticationWarning = Computed.create(this, (use) => {\n    return use(this._loginSystemId) === MINIMAL_PROVIDER_KEY && !use(this._hasActiveOnRestartProvider);\n  });\n\n  private _getgristLoginOwner = Computed.create(this, this._providers, (_use, providers) => {\n    const getgristLogin = providers.find(p => p.key === GETGRIST_COM_PROVIDER_KEY);\n    return getgristLogin?.metadata?.owner ?? null;\n  });\n\n  constructor(private _options: AuthenticationSectionOptions) {\n    super();\n\n    this._fetchProviders().catch(reportError);\n    this._fetchPrefsPendingChanges().catch(reportError);\n  }\n\n  public buildDom() {\n    return [\n      dom.maybe(this._showNoAuthenticationWarning, () => this._buildNoAuthenticationWarning()),\n      dom.maybe(this._hasActiveOnRestartProvider, () => this._buildAuthenticationChangeWarning()),\n      dom.domComputed(this._providers, providers => this._buildListOfProviders(providers)),\n    ];\n  }\n\n  private async _fetchProviders() {\n    const providers = await this._configAPI.getAuthProviders();\n    if (this.isDisposed()) {\n      return;\n    }\n    this._providers.set(providers);\n    this._checkIfRestartNeeded();\n  }\n\n  private async _fetchPrefsPendingChanges() {\n    // TODO: This class, `TelemetryModel`, and`AdminInstallationPanel._buildUpdates`\n    // each call this endpoint when a single call should suffice.\n    const prefs = await this._installAPI.getInstallPrefs();\n    if (this.isDisposed()) { return; }\n\n    const { onRestartSetAdminEmail, onRestartReplaceEmailWithAdmin } = prefs;\n    this._prefsPendingChanges.set({ onRestartSetAdminEmail, onRestartReplaceEmailWithAdmin });\n  }\n\n  private _buildListOfProviders(providers: AuthProvider[]) {\n    // Hide deprecated providers unless already configured or active.\n    const visible = providers.filter(p =>\n      !DEPRECATED_PROVIDERS.includes(p.key) || p.isConfigured || p.isActive,\n    );\n    return cssMethodsContainer(\n      visible.map((provider) => {\n        return cssMethodRow(\n          testId(`provider-row-${provider.key.replace(\".\", \"-\")}`),\n          testId(`provider-row`),\n          cssMethodContent(\n            cssMethodLabel(provider.name),\n            // Render badges based on server-calculated badge list\n            provider.isConfigured ?\n              cssMethodBadge(\n                t(\"Configured\"),\n                testId(\"badge\"),\n                testId(\"badge-configured\"),\n              ) :\n              null,\n            provider.isActive ?\n              cssMethodBadge(t(\"Active\"), cssMethodBadge.cls(\"-primary\"), testId(\"badge\"), testId(\"badge-active\")) :\n              null,\n            provider.willBeActive ?\n              cssMethodBadge(\n                t(\"Active on restart\"),\n                cssMethodBadge.cls(\"-warning\"),\n                testId(\"badge\"),\n                testId(\"badge-active-on-restart\")) :\n              null,\n            provider.willBeDisabled ?\n              cssMethodBadge(\n                t(\"Disabled on restart\"),\n                cssMethodBadge.cls(\"-warning\"),\n                testId(\"badge\"),\n                testId(\"badge-disabled-on-restart\")) :\n              null,\n            (provider.configError || provider.activeError) ?\n              cssMethodBadge(\n                t(\"Error\"),\n                cssMethodBadge.cls(\"-error\"),\n                testId(\"badge\"),\n                testId(\"badge-error\")) :\n              null,\n            cssFlex(),\n            // Show \"Set as active method\" button only if configured but not active\n            // and no provider is configured via environment variable\n            provider.canBeActivated ?\n              basicButton(\n                t(\"Set as active method\"),\n                testId(`set-active-button`),\n                dom.on(\"click\", () => this._setActiveProvider(provider)),\n              ) : null,\n            // Always show Configure button\n            basicButton(\n              t(\"Configure\"),\n              testId(\"configure-button\"),\n              testId(`configure-${provider.name.toLowerCase().replace(/\\s+/g, \"-\")}`),\n              dom.on(\"click\", () => this._configureProvider(provider)),\n            ),\n          ),\n          // Show error message if present\n          (provider.configError || provider.activeError) ?\n            dom(\"div\",\n              cssErrorHeader(t(\"Error details\"), testId(\"error-header\")),\n              provider.activeError ? cssMethodError(provider.activeError, testId(\"error-message\")) : null,\n              provider.configError ? cssMethodError(provider.configError, testId(\"error-message\")) : null,\n            ) : null,\n          // Show info message if configured via environment variable\n          provider.isSelectedByEnv ?\n            cssMethodInfo(\n              t(\"Active method is controlled by an environment variable. Unset variable to change active method.\"),\n            ) : null,\n        );\n      }),\n    );\n  }\n\n  private _buildNoAuthenticationWarning() {\n    return [\n      cssWell(\n        dom.style(\"margin-bottom\", \"24px\"),\n        cssWell.cls(\"-warning\"),\n        cssIconWrapper(icon(\"Warning\")),\n        dom(\"div\",\n          cssWellTitle(t(\"No authentication: unrestricted sign-in as demo user\")),\n          cssWellContent(\n            dom(\"p\", t(\"If Grist is accessible on your network, or is available to multiple people, \\\nconfigure one of the authentication methods below.\")),\n          ),\n        ),\n      ),\n    ];\n  }\n\n  private _buildAuthenticationChangeWarning() {\n    return cssWell(\n      dom.style(\"margin-bottom\", \"24px\"),\n      cssWell.cls(\"-warning\"),\n      cssIconWrapper(icon(\"Warning\")),\n      dom(\"div\",\n        cssWellTitle(t(\"Restart required. Authentication change may affect your access\")),\n        cssWellContent(\n          dom.domComputed((use) => {\n            const prefs = use(this._prefsPendingChanges);\n            if (prefs?.onRestartSetAdminEmail) {\n              return dom(\"p\",\n                t(\"You are signed in as {{email}}. \\\nAfter restart, the new administrative user will be {{newEmail}}.\",\n                {\n                  email: dom(\"strong\", this._currentUserEmail),\n                  newEmail: dom(\"strong\", prefs.onRestartSetAdminEmail),\n                }),\n              );\n            } else {\n              return dom(\"p\",\n                t(\"You are signed in as {{email}}. \\\nYou may lose access to this server if you cannot sign in as this user after switching the \\\nauthentication system.\",\n                { email: dom(\"strong\", this._currentUserEmail) }),\n              );\n            }\n          }),\n          dom(\"p\", t('See \"Restart Grist\" section on top of this page to restart.')),\n        ),\n        dom.domComputed((use) => {\n          const prefs = use(this._prefsPendingChanges);\n          if (prefs?.onRestartSetAdminEmail) {\n            return bigBasicButton(\n              t(\"Revert change of admin user\"),\n              dom.style(\"margin-top\", \"16px\"),\n              dom.on(\"click\", () => this._revertSetInstallAdmin()),\n            );\n          } else {\n            return bigPrimaryButton(\n              t(\"Change admin user\"),\n              dom.style(\"margin-top\", \"16px\"),\n              dom.on(\"click\", () => this._showChangeAdminModal()),\n            );\n          }\n        }),\n      ),\n    );\n  }\n\n  private async _setActiveProvider(provider: AuthProvider) {\n    confirmModal(\n      t(\"Set as active method?\"),\n      t(\"Confirm\"),\n      async () => {\n        await this._configAPI.setActiveAuthProvider(provider.key);\n        await this._fetchProviders();\n      },\n      {\n        explanation: dom(\"div\",\n          cssMarkdownSpan(\n            t(\"Are you sure you want to set **{{name}}** as the active authentication method?\",\n              { name: provider.name }),\n          ),\n          dom(\"p\",\n            t(\"The new method will go into effect after you restart Grist.\"),\n          ),\n        ),\n      },\n    );\n  }\n\n  private _configureProvider(provider: AuthProvider) {\n    const configModal = BaseInformationModal.for(provider);\n    if (configModal) {\n      configModal.show(() => this._fetchProviders().catch(reportError));\n      this.onDispose(() => configModal.isDisposed() ? void 0 : configModal.dispose());\n    }\n  }\n\n  private _showChangeAdminModal() {\n    const currentUserEmail = this._appModel.currentValidUser?.email;\n    if (!currentUserEmail) {\n      throw new Error(\"Current user is not defined\");\n    }\n\n    saveModal((_ctl, owner) => {\n      const changeAdminModal = ChangeAdminModal.create(owner, {\n        currentUserEmail,\n        defaultEmail: this._getgristLoginOwner.get().email,\n        onSave: async ({ email, replace }) => {\n          await this._setInstallAdmin(email, replace);\n        },\n      });\n      return {\n        title: t(\"Change admin user\"),\n        body: changeAdminModal.buildDom(),\n        saveFunc: () => changeAdminModal.save(),\n        saveDisabled: changeAdminModal.saveDisabled,\n        width: \"normal\" as const,\n        saveLabel: t(\"Prepare changes\"),\n      };\n    });\n  }\n\n  private async _setInstallAdmin(email: string, replace: boolean) {\n    const onRestartReplaceEmailWithAdmin = replace ? this._currentUserEmail : undefined;\n    await this._installAPI.updateInstallPrefs({\n      onRestartSetAdminEmail: email,\n      onRestartReplaceEmailWithAdmin,\n    });\n    if (this.isDisposed()) { return; }\n\n    await this._fetchPrefsPendingChanges();\n    this._checkIfRestartNeeded();\n  };\n\n  private async _revertSetInstallAdmin() {\n    await this._installAPI.updateInstallPrefs({\n      onRestartSetAdminEmail: null,\n      onRestartReplaceEmailWithAdmin: null,\n    });\n    if (this.isDisposed()) { return; }\n\n    await this._fetchPrefsPendingChanges();\n  };\n\n  private _checkIfRestartNeeded() {\n    const hasActiveOnRestartProvider = this._hasActiveOnRestartProvider.get();\n\n    const prefsPendingChanges = this._prefsPendingChanges.get();\n    const hasUnappliedRestartPrefs = Boolean(\n      prefsPendingChanges?.onRestartSetAdminEmail ||\n      prefsPendingChanges?.onRestartReplaceEmailWithAdmin,\n    );\n    const needsRestart = hasActiveOnRestartProvider || hasUnappliedRestartPrefs;\n    if (needsRestart) {\n      this._controls.needsRestart.set(true);\n    }\n  }\n}\n\n/**\n * Base class for displaying static information about authentication providers.\n */\nabstract class BaseInformationModal extends Disposable {\n  /**\n   * Factory method to create the appropriate modal for a provider.\n   */\n  public static for(provider: AuthProvider) {\n    switch (provider.key) {\n      case OIDC_PROVIDER_KEY:\n        return new OIDCInformationModal(provider);\n      case SAML_PROVIDER_KEY:\n        return new SAMLInformationModal(provider);\n      case FORWARD_AUTH_PROVIDER_KEY:\n        return new ForwardedHeadersInfoModal(provider);\n      case GRIST_CONNECT_PROVIDER_KEY:\n        return new GristConnectInfoModal(provider);\n      case GETGRIST_COM_PROVIDER_KEY:\n        return new GetGristComProviderInfoModal();\n      default:\n        throw new Error(`No configuration modal available for provider key: ${provider.key}`);\n    }\n  }\n\n  constructor(protected _provider: AuthProvider) {\n    super();\n  }\n\n  public show() {\n    return modal((ctl, owner) => [\n      () => {\n        this.onDispose(() => {\n          if (owner.isDisposed()) {\n            return;\n          }\n          ctl.close();\n        });\n        return null;\n      },\n      cssModalWidth(\"fixed-wide\"),\n      cssModalHeader(\n        dom(\"span\", t(`Configure ${this._provider.name}`)),\n        testId(\"modal-header\"),\n      ),\n      cssModalDescription(\n        ...this.getDescription().map(desc => dom(\"p\", cssMarkdownSpan(desc))),\n      ),\n      cssModalInstructions(\n        dom(\"h3\", t(\"Instructions\")),\n        cssMarkdownSpan(this.getInstruction()),\n      ),\n      cssModalButtons(\n        bigPrimaryButton(\n          t(\"Close\"),\n          dom.on(\"click\", () => this.dispose()),\n          testId(\"modal-cancel\"),\n          testId(\"modal-close\"),\n        ),\n      ),\n    ]);\n  }\n\n  protected abstract getDescription(): string[];\n  protected abstract getInstruction(): string;\n}\n\n/**\n * Modal for configuring OIDC authentication.\n */\nclass OIDCInformationModal extends BaseInformationModal {\n  protected getDescription(): string[] {\n    return [\n      t(\"**OIDC** allows users on your Grist server to sign in using an external identity provider that \\\nsupports the OpenID Connect standard.\"),\n      t(\"When signing in, users will be redirected to your chosen identity provider's login page to \\\nauthenticate. After successful authentication, they'll be redirected back to your Grist server and \\\nsigned in as the user verified by the provider.\"),\n    ];\n  }\n\n  protected getInstruction(): string {\n    return t(\"To set up **OIDC**, follow the instructions in \\\n[the Grist support article for OIDC](https://support.getgrist.com/install/oidc).\");\n  }\n}\n\n/**\n * Modal for configuring SAML authentication.\n */\nclass SAMLInformationModal extends BaseInformationModal {\n  protected getDescription(): string[] {\n    return [\n      t(\"**SAML** allows users on your Grist server to sign in using an external identity provider that \\\nsupports the SAML 2.0 standard.\"),\n      t(\"When signing in, users will be redirected to your chosen identity provider's login page to \\\nauthenticate. After successful authentication, they'll be redirected back to your Grist server and \\\nsigned in as the user verified by the provider.\"),\n    ];\n  }\n\n  protected getInstruction(): string {\n    return t(\"To set up **SAML**, follow the instructions in \\\n[the Grist support article for SAML](https://support.getgrist.com/install/saml/).\");\n  }\n}\n\n/**\n * Modal for configuring forwarded headers authentication.\n */\nclass ForwardedHeadersInfoModal extends BaseInformationModal {\n  protected getDescription(): string[] {\n    return [\n      t(\"**Forwarded headers** allows your Grist server to trust authentication performed by an external \\\nproxy (e.g. Traefik ForwardAuth).\"),\n      t(\"When a user accesses Grist, the proxy handles authentication and forwards verified user information \\\nthrough HTTP headers. Grist uses these headers to identify the user.\"),\n    ];\n  }\n\n  protected getInstruction(): string {\n    return t(\"To set up **forwarded headers**, follow the instructions in \\\n[the Grist support article for forwarded headers](https://support.getgrist.com/install/forwarded-headers/).\");\n  }\n}\n\n/**\n * Modal for configuring Grist Connect authentication.\n */\nclass GristConnectInfoModal extends BaseInformationModal {\n  protected getDescription(): string[] {\n    return [\n      t(\"**Grist Connect** is a login solution built and maintained by Grist Labs that integrates seamlessly \\\nwith your Grist server.\"),\n      t(\"When signing in, users will be redirected to a Grist Connect login page where they can authenticate \\\nusing various identity providers. After authentication, they'll be redirected back to your Grist server \\\nand signed in.\"),\n    ];\n  }\n\n  protected getInstruction(): string {\n    return t(\"To set up **Grist Connect**, follow the instructions in \\\n[the Grist support article for Grist Connect](https://support.getgrist.com/install/grist-connect/).\");\n  }\n}\n\nconst cssMethodsContainer = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  border: 1px solid ${theme.menuBorder};\n  border-radius: 8px;\n  overflow: hidden;\n`);\n\nconst cssMethodRow = styled(\"div\", `\n  display: flex;\n  gap: 16px;\n  flex-direction: column;\n  padding: 16px;\n  background-color: ${theme.mainPanelBg};\n  border-bottom: 1px solid ${theme.menuBorder};\n  &:last-child {\n    border-bottom: none;\n  }\n`);\n\nconst cssMethodContent = styled(\"div\", `\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  flex: 1;\n  gap: 12px;\n`);\n\nconst cssMethodInfo = styled(\"div\", `\n  color: ${theme.lightText};\n`);\n\nconst cssMethodError = styled(\"div\", `\n  color: ${theme.errorText};\n  margin-top: 4px;\n`);\n\nconst cssErrorHeader = styled(\"div\", `\n  color: ${theme.errorText};\n  font-weight: 600;\n  font-size: ${vars.smallFontSize};\n  margin-top: 8px;\n  margin-bottom: 4px;\n`);\n\nconst cssMethodLabel = styled(\"div\", `\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.text};\n`);\n\nconst cssMethodBadge = styled(\"div\", `\n  padding: 2px 8px;\n  color: ${theme.lightText};\n  border: 1px solid ${theme.lightText};\n  font-size: ${vars.xsmallFontSize};\n  font-weight: 600;\n  border-radius: 16px;\n  text-transform: uppercase;\n  white-space: nowrap;\n  &-primary {\n    border-color: ${theme.controlPrimaryBg};\n    color: ${theme.controlPrimaryBg};\n  }\n  &-warning {\n    border-color: #ffb535;\n    color: ${theme.toastWarningBg}\n  }\n  &-error {\n    border-color: ${theme.errorText};\n    color: ${theme.errorText};\n  }\n`);\n\nconst cssFlex = styled(\"div\", `\n  flex: 1;\n`);\n\nconst cssModalHeader = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-bottom: 24px;\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: 500;\n  color: ${theme.text};\n`);\n\nconst cssModalDescription = styled(\"div\", `\n  margin-bottom: 24px;\n  color: ${theme.text};\n  font-size: ${vars.mediumFontSize};\n  line-height: 1.5;\n\n  & > p {\n    margin: 0 0 12px 0;\n  }\n\n  & > p:last-child {\n    margin-bottom: 0;\n  }\n`);\n\nconst cssModalInstructions = styled(\"div\", `\n  margin-bottom: 16px;\n\n  & > h3 {\n    margin: 0 0 12px 0;\n    font-size: ${vars.largeFontSize};\n    font-weight: 600;\n    color: ${theme.text};\n  }\n\n  & > p {\n    margin: 0;\n    color: ${theme.text};\n    font-size: ${vars.mediumFontSize};\n    line-height: 1.5;\n  }\n`);\n\nconst cssModalButtons = styled(\"div\", `\n  display: flex;\n  gap: 8px;\n  justify-content: flex-end;\n  margin-top: 24px;\n`);\n"
  },
  {
    "path": "app/client/ui/BottomBar.ts",
    "content": "import { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { tokens } from \"app/common/ThemePrefs\";\n\nimport { dom, MultiHolder, Observable, styled } from \"grainjs\";\n\nexport function createBottomBarDoc(owner: MultiHolder, pageModel: DocPageModel, leftPanelOpen: Observable<boolean>,\n  rightPanelOpen: Observable<boolean>) {\n  return dom.maybe(pageModel.gristDoc, gristDoc => (\n    cssPageName(\n      dom.text(gristDoc.currentPageName),\n      dom.on(\"click\", () => { rightPanelOpen.set(false); leftPanelOpen.set(true); }),\n      testId(\"page-name\"),\n    )\n  ));\n}\n\nconst cssPageName = styled(\"div\", `\n  color: ${tokens.body};\n  margin: 0 10px;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  cursor: pointer;\n`);\n"
  },
  {
    "path": "app/client/ui/CardContextMenu.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { menuDivider, menuItemCmd } from \"app/client/ui2018/menus\";\n\nimport { dom } from \"grainjs\";\n\nconst t = makeT(\"CardContextMenu\");\n\nexport interface ICardContextMenu {\n  disableInsert: boolean;\n  disableDelete: boolean;\n  isViewSorted: boolean;\n  numRows: number;\n}\n\nexport function CardContextMenu({\n  disableInsert,\n  disableDelete,\n  isViewSorted,\n  numRows,\n}: ICardContextMenu) {\n  const result: Element[] = [];\n  if (isViewSorted) {\n    result.push(\n      menuItemCmd(allCommands.insertRecordAfter, t(\"Insert card\"),\n        dom.cls(\"disabled\", disableInsert)),\n    );\n  } else {\n    result.push(\n      menuItemCmd(allCommands.insertRecordBefore, t(\"Insert card above\"),\n        dom.cls(\"disabled\", disableInsert)),\n      menuItemCmd(allCommands.insertRecordAfter, t(\"Insert card below\"),\n        dom.cls(\"disabled\", disableInsert)),\n    );\n  }\n  result.push(\n    menuItemCmd(allCommands.duplicateRows, t(\"Duplicate card\"),\n      dom.cls(\"disabled\", disableInsert || numRows === 0)),\n  );\n  result.push(\n    menuDivider(),\n    menuItemCmd(allCommands.deleteRecords, t(\"Delete card\"),\n      dom.cls(\"disabled\", disableDelete)),\n  );\n  result.push(\n    menuDivider(),\n    menuItemCmd(allCommands.copyLink, t(\"Copy anchor link\")),\n  );\n  return result;\n}\n"
  },
  {
    "path": "app/client/ui/CellContextMenu.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { IMultiColumnContextMenu } from \"app/client/ui/GridViewMenus\";\nimport { menuDivider, menuItemCmd } from \"app/client/ui2018/menus\";\n\nimport { dom } from \"grainjs\";\n\nconst t = makeT(\"CellContextMenu\");\n\nexport interface ICellContextMenu {\n  disableInsert: boolean;\n  disableDelete: boolean;\n  isViewSorted: boolean;\n  numRows: number;\n  disableAnchorLink?: boolean;\n  onlyAddRowSelected?: boolean;\n}\n\nexport function CellContextMenu(cellOptions: ICellContextMenu, colOptions: IMultiColumnContextMenu) {\n  const { disableInsert, disableDelete, isViewSorted, numRows, onlyAddRowSelected } = cellOptions;\n  const { numColumns, disableModify, isReadonly, isFiltered } = colOptions;\n\n  // disableModify is true if the column is a summary column or is being transformed.\n  // isReadonly is true for readonly mode.\n  const disableForReadonlyColumn = dom.cls(\"disabled\", Boolean(disableModify) || isReadonly);\n  const disableForReadonlyView = dom.cls(\"disabled\", isReadonly);\n\n  const nameClearColumns = isFiltered ?\n    t(\"Reset {{count}} entire columns\", { count: numColumns }) :\n    t(\"Reset {{count}} columns\", { count: numColumns });\n  const nameDeleteColumns = t(\"Delete {{count}} columns\", { count: numColumns });\n\n  const nameDeleteRows = t(\"Delete {{count}} rows\", { count: numRows });\n\n  const nameClearCells = (numRows > 1 || numColumns > 1) ? t(\"Clear values\") : t(\"Clear cell\");\n\n  const result: (Element | null)[] = [];\n\n  result.push(\n    menuItemCmd(allCommands.contextMenuCut, t(\"Cut\"), disableForReadonlyColumn),\n    menuItemCmd(allCommands.contextMenuCopy, t(\"Copy\")),\n    menuItemCmd(allCommands.contextMenuCopyWithHeaders, t(\"Copy with headers\")),\n    menuItemCmd(allCommands.contextMenuPaste, t(\"Paste\"), disableForReadonlyColumn),\n    menuDivider(),\n    colOptions.isFormula ?\n      null :\n      menuItemCmd(allCommands.clearValues, nameClearCells, disableForReadonlyColumn),\n    menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),\n\n    ...(\n      (numColumns > 1 || numRows > 1) ? [] : [\n        menuDivider(),\n        menuItemCmd(allCommands.copyLink,\n          t(\"Copy anchor link\"),\n          dom.cls(\"disabled\", cellOptions.disableAnchorLink ?? false),\n        ),\n        menuDivider(),\n        menuItemCmd(allCommands.filterByThisCellValue, t(\"Filter by this value\")),\n        menuItemCmd(allCommands.openDiscussion, t(\"Comment\"), dom.cls(\"disabled\", (\n          isReadonly || numRows === 0 || numColumns === 0 || onlyAddRowSelected\n        ))),\n      ]\n    ),\n\n    menuDivider(),\n\n    // inserts\n    ...(\n      isViewSorted ?\n        // When the view is sorted, any newly added records get shifts instantly at the top or\n        // bottom. It could be very confusing for users who might expect the record to stay above or\n        // below the active row. Thus in this case we show a single `insert row` command.\n        [menuItemCmd(allCommands.insertRecordAfter, t(\"Insert row\"),\n          dom.cls(\"disabled\", disableInsert))] :\n\n        [menuItemCmd(allCommands.insertRecordBefore, t(\"Insert row above\"),\n          dom.cls(\"disabled\", disableInsert)),\n        menuItemCmd(allCommands.insertRecordAfter, t(\"Insert row below\"),\n          dom.cls(\"disabled\", disableInsert))]\n    ),\n    menuItemCmd(allCommands.duplicateRows, t(\"Duplicate rows\", { count: numRows }),\n      dom.cls(\"disabled\", disableInsert || numRows === 0)),\n    menuItemCmd(allCommands.insertFieldBefore, t(\"Insert column to the left\"),\n      disableForReadonlyView),\n    menuItemCmd(allCommands.insertFieldAfter, t(\"Insert column to the right\"),\n      disableForReadonlyView),\n\n    menuDivider(),\n\n    // deletes\n    menuItemCmd(allCommands.deleteRecords, nameDeleteRows, dom.cls(\"disabled\", disableDelete)),\n\n    menuItemCmd(allCommands.deleteFields, nameDeleteColumns, disableForReadonlyColumn),\n\n    // todo: add \"hide N columns\"\n  );\n\n  return result;\n}\n"
  },
  {
    "path": "app/client/ui/ChangeAdminModal.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { cssInput } from \"app/client/ui/cssInput\";\nimport { cssField, cssLabel } from \"app/client/ui/MakeCopyMenu\";\nimport { cssRadioCheckboxOptions, radioCheckboxOption } from \"app/client/ui2018/checkbox\";\nimport { isEmail } from \"app/common/gutil\";\n\nimport { Computed, Disposable, dom, input, Observable } from \"grainjs\";\n\nconst t = makeT(\"ChangeAdminModal\");\n\nexport interface ChangeAdminModalOptions {\n  currentUserEmail: string;\n  defaultEmail?: string;\n  onSave: (fields: { email: string, replace: boolean }) => Promise<void>;\n}\n\nexport class ChangeAdminModal extends Disposable {\n  private _currentUserEmail = this._options.currentUserEmail;\n  private _email = Observable.create(this, this._options.defaultEmail ?? \"\");\n  private _replace = Observable.create(this, true);\n  private _saveDisabled = Computed.create(this, this._email, (_use, email) => !isEmail(email));\n\n  constructor(private _options: ChangeAdminModalOptions) {\n    super();\n  }\n\n  public get saveDisabled() { return this._saveDisabled; }\n\n  public async save() {\n    await this._options.onSave({ email: this._email.get(), replace: this._replace.get() });\n  }\n\n  public buildDom() {\n    return [\n      cssField(\n        cssLabel(t(\"New admin\")),\n        input(this._email,\n          { onInput: true },\n          { placeholder: t(\"Enter new admin email\") },\n          dom.cls(cssInput.className),\n          (elem) => { setTimeout(() => { elem.focus(); }, 20); },\n        ),\n      ),\n      cssRadioCheckboxOptions(\n        radioCheckboxOption(this._replace, true,\n          t(\"Replace {{email}} with the new email throughout. \\\nThe new email will become the installation admin, as well as \\\nthe owner of all materials previously owned by you@example.com.\",\n          { email: dom(\"strong\", this._currentUserEmail) },\n          ),\n        ),\n        radioCheckboxOption(this._replace, false,\n          t(\"Make the new email the installation admin. \\\nOrgs, workspaces, and documents will remain owned by {{email}}. \\\nThese changes will take effect after you restart this Grist server.\",\n          { email: dom(\"strong\", this._currentUserEmail) },\n          ),\n        ),\n      ),\n    ];\n  }\n}\n"
  },
  {
    "path": "app/client/ui/CodeHighlight.ts",
    "content": "import { Ace, loadAce } from \"app/client/lib/imports\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { gristThemeObs } from \"app/client/ui2018/theme\";\n\nimport {\n  BindableValue,\n  Disposable,\n  DomElementArg,\n  Observable,\n  styled,\n  subscribeElem,\n} from \"grainjs\";\n\ninterface BuildCodeHighlighterOptions {\n  maxLines?: number;\n}\n\nlet _ace: Ace;\nlet _highlighter: any;\nlet _PythonMode: any;\nlet _aceDom: any;\nlet _chrome: any;\nlet _dracula: any;\nlet _mode: any;\n\nasync function fetchAceModules() {\n  return {\n    ace: _ace || (_ace = await loadAce()),\n    highlighter: _highlighter || (_highlighter = _ace.require(\"ace/ext/static_highlight\")),\n    PythonMode: _PythonMode || (_PythonMode = _ace.require(\"ace/mode/python\").Mode),\n    aceDom: _aceDom || (_aceDom = _ace.require(\"ace/lib/dom\")),\n    chrome: _chrome || (_chrome = _ace.require(\"ace/theme/chrome\")),\n    dracula: _dracula || (_dracula = _ace.require(\"ace/theme/dracula\")),\n    mode: _mode || (_mode = new _PythonMode()),\n  };\n}\n\n/**\n * Returns a function that accepts a string of text representing code and returns\n * a highlighted version of it as an HTML string.\n *\n * This is useful for scenarios where highlighted code needs to be displayed outside of\n * grainjs. For example, when using `marked`'s `highlight` option to highlight code\n * blocks in a Markdown string.\n */\nexport async function buildCodeHighlighter(options: BuildCodeHighlighterOptions = {}) {\n  const { maxLines } = options;\n  const { highlighter, aceDom, chrome, dracula, mode } = await fetchAceModules();\n\n  return (code: string) => {\n    if (maxLines) {\n      // If requested, trim to maxLines, and add an ellipsis at the end.\n      // (Long lines are also truncated with an ellpsis via text-overflow style.)\n      const lines = code.split(/\\n/);\n      if (lines.length > maxLines) {\n        code = lines.slice(0, maxLines).join(\"\\n\") + \" \\u2026\";  // Ellipsis\n      }\n    }\n\n    let aceThemeName: \"chrome\" | \"dracula\";\n    let aceTheme: any;\n    if (gristThemeObs().get().appearance === \"dark\") {\n      aceThemeName = \"dracula\";\n      aceTheme = dracula;\n    } else {\n      aceThemeName = \"chrome\";\n      aceTheme = chrome;\n    }\n\n    // Rendering highlighted code gives you back the HTML to insert into the DOM, as well\n    // as the CSS styles needed to apply the theme. The latter typically isn't included in\n    // the document until an Ace editor is opened, so we explicitly import it here to avoid\n    // leaving highlighted code blocks without a theme applied.\n    const { html, css } = highlighter.render(code, mode, aceTheme, 1, true);\n    aceDom.importCssString(css, `${aceThemeName}-highlighted-code`);\n    return html;\n  };\n}\n\ninterface BuildHighlightedCodeOptions extends BuildCodeHighlighterOptions {\n  placeholder?: string;\n}\n\n/**\n * Builds a block of highlighted `code`.\n *\n * Highlighting applies an appropriate Ace theme (Chrome or Dracula) based on\n * the current Grist theme, and automatically re-applies it whenever the Grist\n * theme changes.\n */\nexport function buildHighlightedCode(\n  owner: Disposable,\n  code: BindableValue<string>,\n  options: BuildHighlightedCodeOptions,\n  ...args: DomElementArg[]\n): HTMLElement {\n  const { placeholder, maxLines } = options;\n  const codeText = Observable.create(owner, \"\");\n  const codeTheme = Observable.create(owner, gristThemeObs().get());\n\n  async function updateHighlightedCode(elem: HTMLElement) {\n    let text = codeText.get();\n    if (!text) {\n      elem.textContent = placeholder || \"\";\n      return;\n    }\n\n    const { highlighter, aceDom, chrome, dracula, mode } = await fetchAceModules();\n    if (owner.isDisposed()) { return; }\n\n    if (maxLines) {\n      // If requested, trim to maxLines, and add an ellipsis at the end.\n      // (Long lines are also truncated with an ellpsis via text-overflow style.)\n      const lines = text.split(/\\n/);\n      if (lines.length > maxLines) {\n        text = lines.slice(0, maxLines).join(\"\\n\") + \" \\u2026\";  // Ellipsis\n      }\n    }\n\n    let aceThemeName: \"chrome\" | \"dracula\";\n    let aceTheme: any;\n    if (codeTheme.get().appearance === \"dark\") {\n      aceThemeName = \"dracula\";\n      aceTheme = dracula;\n    } else {\n      aceThemeName = \"chrome\";\n      aceTheme = chrome;\n    }\n\n    // Rendering highlighted code gives you back the HTML to insert into the DOM, as well\n    // as the CSS styles needed to apply the theme. The latter typically isn't included in\n    // the document until an Ace editor is opened, so we explicitly import it here to avoid\n    // leaving highlighted code blocks without a theme applied.\n    const { html, css } = highlighter.render(text, mode, aceTheme, 1, true);\n    elem.innerHTML = html;\n    aceDom.importCssString(css, `${aceThemeName}-highlighted-code`);\n  }\n\n  return cssHighlightedCode(\n    elem => subscribeElem(elem, code, async (newCodeText) => {\n      codeText.set(newCodeText);\n      await updateHighlightedCode(elem);\n    }),\n    elem => subscribeElem(elem, gristThemeObs(), async (newCodeTheme) => {\n      codeTheme.set(newCodeTheme);\n      await updateHighlightedCode(elem);\n    }),\n    ...args,\n  );\n}\n\n// Use a monospace font, a subset of what ACE editor seems to use.\nexport const cssCodeBlock = styled(\"div\", `\n  font-family: 'Monaco', 'Menlo', monospace;\n  font-size: ${vars.smallFontSize};\n  background-color: ${theme.highlightedCodeBlockBg};\n  &[disabled], &.disabled {\n    background-color: ${theme.highlightedCodeBlockBgDisabled};\n  }\n`);\n\nconst cssHighlightedCode = styled(cssCodeBlock, `\n  position: relative;\n  overflow: hidden;\n  border: 1px solid ${theme.highlightedCodeBorder};\n  border-radius: 3px;\n  min-height: 28px;\n  padding: 5px 6px;\n  color: ${theme.highlightedCodeFg};\n\n  &.disabled, &.disabled .ace-chrome, &.disabled .ace-dracula {\n    background-color: ${theme.highlightedCodeBgDisabled};\n  }\n  & .ace_line {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/ColumnFilterCalendarView.ts",
    "content": "import { ColumnFilter } from \"app/client/models/ColumnFilter\";\nimport { IColumnFilterViewType } from \"app/client/ui/ColumnFilterMenu\";\nimport { updateRelativeDate } from \"app/client/ui/RelativeDatesOptions\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { IRelativeDateSpec, isRelativeBound } from \"app/common/FilterState\";\nimport getCurrentTime from \"app/common/getCurrentTime\";\n\nimport { Disposable, dom, Observable, styled } from \"grainjs\";\nimport moment from \"moment-timezone\";\n\nexport class ColumnFilterCalendarView extends Disposable {\n  private _$el: any;\n\n  constructor(private _opts: {\n    viewTypeObs: Observable<IColumnFilterViewType>,\n    // Note the invariant: `selectedBoundObs.get() !== null` until this gets disposed.\n    selectedBoundObs: Observable<\"min\" | \"max\" | null>,\n    columnFilter: ColumnFilter,\n  }) {\n    super();\n    this._moveToSelected = this._moveToSelected.bind(this);\n    this.autoDispose(this.columnFilter.min.addListener(() => this._setRange()));\n    this.autoDispose(this.columnFilter.max.addListener(() => this._setRange()));\n    this.autoDispose(this._opts.selectedBoundObs.addListener(this._moveToSelected));\n  }\n\n  public get columnFilter() { return this._opts.columnFilter; }\n  public get selectedBoundObs() { return this._opts.selectedBoundObs; }\n\n  public buildDom() {\n    setTimeout(() => this._moveToSelected(), 0);\n    return cssContainer(\n      cssLinkRow(\n        cssLink(\n          \"← List view\",\n          dom.on(\"click\", () => this._opts.selectedBoundObs.set(null)),\n        ),\n        cssLink(\n          \"Today\",\n          dom.on(\"click\", () => {\n            this._$el.datepicker(\"update\", this._getCurrentTime());\n            this._cleanup();\n          }),\n        ),\n        testId(\"calendar-links\"),\n      ),\n      cssDatepickerContainer(\n        (el) => {\n          const $el = this._$el = $(el) as any;\n          $el.datepicker({\n            defaultViewDate: this._getCurrentTime(),\n            todayHighlight: true,\n          });\n          $el[0].querySelector(\".datepicker\");\n          this._setRange();\n          $el.on(\"changeDate\", () => this._onChangeDate());\n\n          // Schedules cleanups after users navigations (ie: navigating to next/prev month).\n          $el.on(\"changeMonth\", () => setTimeout(() => this._cleanup(), 0));\n          $el.on(\"changeYear\", () => setTimeout(() => this._cleanup(), 0));\n          $el.on(\"changeDecade\", () => setTimeout(() => this._cleanup(), 0));\n          $el.on(\"changeCentury\", () => setTimeout(() => this._cleanup(), 0));\n        },\n      ),\n    );\n  }\n\n  private _setRange() {\n    this._$el.datepicker(\"setRange\", this._getRange());\n    this._moveToSelected();\n  }\n\n  // Move calendar to the selected bound's current date.\n  private _moveToSelected() {\n    const minMax = this._opts.selectedBoundObs.get();\n    let dateValue = this._getCurrentTime();\n\n    if (minMax !== null) {\n      const value = this.columnFilter.getBoundsValue(minMax);\n      if (isFinite(value)) {\n        dateValue = new Date(value * 1000);\n      }\n    }\n\n    this._$el.datepicker(\"update\", dateValue);\n    this._cleanup();\n  }\n\n  private _getCurrentTime(): Date {\n    return getCurrentTime().toDate();\n  }\n\n  private _onChangeDate() {\n    const d = this._$el.datepicker(\"getUTCDate\").valueOf() / 1000;\n    const { min, max } = this.columnFilter;\n    // Check the the min bounds is before max bounds. If not update the other bounds to the same\n    // value.\n    // TODO: also perform this check when users pick relative dates from popup\n    if (this.selectedBoundObs.get() === \"min\") {\n      min.set(this._updateBoundValue(min.get(), d));\n      if (this.columnFilter.getBoundsValue(\"max\") < d) {\n        max.set(this._updateBoundValue(max.get(), d));\n      }\n    } else {\n      max.set(this._updateBoundValue(max.get(), d));\n      if (this.columnFilter.getBoundsValue(\"min\") > d) {\n        min.set(this._updateBoundValue(min.get(), d));\n      }\n    }\n    this._cleanup();\n  }\n\n  private _getRange() {\n    const min = this.columnFilter.getBoundsValue(\"min\");\n    const max = this.columnFilter.getBoundsValue(\"max\");\n    const toDate = (val: number) => {\n      const m = moment.utc(val * 1000);\n      return new Date(Date.UTC(m.year(), m.month(), m.date()));\n    };\n    if (!isFinite(min) && !isFinite(max)) {\n      return [];\n    }\n    if (!isFinite(min)) {\n      return [{ valueOf: () => -Infinity }, toDate(max)];\n    }\n    if (!isFinite(max)) {\n      return [toDate(min), { valueOf: () => +Infinity }];\n    }\n    return [toDate(min), toDate(max)];\n  }\n\n  // Update val with date. Returns the new updated value. Useful to update bounds' value after users\n  // have picked new value from calendar.\n  private _updateBoundValue(val: IRelativeDateSpec | number | undefined, date: number) {\n    return isRelativeBound(val) ? updateRelativeDate(val, date) : date;\n  }\n\n  // Removes the `.active` class from date elements in the datepicker. The active dates background\n  // takes precedence over other backgrounds which are more important to us, such as range's bounds\n  // and current day.\n  private _cleanup() {\n    const elements = this._$el.get()[0].querySelectorAll(\".active\");\n    for (const el of elements) {\n      el.classList.remove(\"active\");\n    }\n  }\n}\n\nconst cssContainer = styled(\"div\", `\n  padding: 16px 16px;\n`);\n\nconst cssLink = textButton;\n\nconst cssLinkRow = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n`);\n\nconst cssDatepickerContainer = styled(\"div\", `\n  padding-top: 16px;\n`);\n"
  },
  {
    "path": "app/client/ui/ColumnFilterMenu.ts",
    "content": "/**\n * Creates a UI for column filter menu given a columnFilter model, a mapping of cell values to counts, and an onClose\n * callback that's triggered on Apply or on Cancel. Changes to the UI result in changes to the underlying model,\n * but on Cancel the model is reset to its initial state prior to menu closing.\n */\nimport * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { kbFocusHighlighterClass } from \"app/client/components/KeyboardFocusHighlighter\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { stripLinks } from \"app/client/lib/markdown\";\nimport { ColumnFilter, NEW_FILTER_JSON } from \"app/client/models/ColumnFilter\";\nimport { ColumnFilterMenuModel, IFilterCount } from \"app/client/models/ColumnFilterMenuModel\";\nimport { ColumnRec, ViewFieldRec } from \"app/client/models/DocModel\";\nimport { FilterInfo } from \"app/client/models/entities/ViewSectionRec\";\nimport { RowSource } from \"app/client/models/rowset\";\nimport { ColumnFilterFunc, SectionFilter } from \"app/client/models/SectionFilter\";\nimport { TableData } from \"app/client/models/TableData\";\nimport { ColumnFilterCalendarView } from \"app/client/ui/ColumnFilterCalendarView\";\nimport { relativeDatesControl } from \"app/client/ui/ColumnFilterMenuUtils\";\nimport { cssInput } from \"app/client/ui/cssInput\";\nimport { getDateRangeOptions, IDateRangeOption } from \"app/client/ui/DateRangeOptions\";\nimport { cssPinButton } from \"app/client/ui/RightPanelStyles\";\nimport { basicButton, primaryButton, textButton } from \"app/client/ui2018/buttons\";\nimport { cssCheckboxSquare, cssLabel as cssCheckboxLabel,\n  cssLabelText, Indeterminate, labeledTriStateSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssOptionRowIcon, menu, menuCssClass, menuDivider, menuItem } from \"app/client/ui2018/menus\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { cssDeleteButton, cssDeleteIcon, cssToken as cssTokenTokenBase } from \"app/client/widgets/ChoiceListEditor\";\nimport { ChoiceOptions } from \"app/client/widgets/ChoiceTextBox\";\nimport { choiceToken } from \"app/client/widgets/ChoiceToken\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { IRelativeDateSpec, isEquivalentFilter, isRelativeBound } from \"app/common/FilterState\";\nimport { extractTypeFromColType, isDateLikeType, isList, isNumberType, isRefListType } from \"app/common/gristTypes\";\nimport { formatRelBounds } from \"app/common/RelativeDates\";\nimport { createFormatter } from \"app/common/ValueFormatter\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nimport { Computed, dom, DomArg, DomElementArg, DomElementMethod, IDisposableOwner,\n  input, makeTestId, Observable, styled } from \"grainjs\";\nimport concat from \"lodash/concat\";\nimport debounce from \"lodash/debounce\";\nimport identity from \"lodash/identity\";\nimport noop from \"lodash/noop\";\nimport partition from \"lodash/partition\";\nimport some from \"lodash/some\";\nimport tail from \"lodash/tail\";\nimport { IOpenController, IPopupOptions, setPopupToCreateDom } from \"popweasel\";\n\nconst t = makeT(\"ColumnFilterMenu\");\n\nexport interface IFilterMenuOptions {\n  model: ColumnFilterMenuModel;\n  valueCounts: Map<CellValue, IFilterCount>;\n  rangeInputOptions?: IRangeInputOptions;\n  showAllFiltersButton?: boolean;\n  doCancel(): void;\n  doSave(): void;\n  renderValue(key: CellValue, value: IFilterCount): DomElementArg;\n  onClose(): void;\n  valueParser?(val: string): any;\n  valueFormatter?(val: any): string;\n}\n\nconst testId = makeTestId(\"test-filter-menu-\");\n\nexport type IColumnFilterViewType = \"listView\" | \"calendarView\";\n\n/**\n * Returns the DOM content for the column filter menu.\n *\n * For use with setPopupToCreateDom().\n */\nexport function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {\n  const { model, doCancel, doSave, onClose, renderValue, valueParser, showAllFiltersButton } = opts;\n  const { columnFilter, filterInfo, gristDoc } = model;\n  const valueFormatter = opts.valueFormatter || (val => val?.toString() || \"\");\n\n  // Map to keep track of displayed checkboxes\n  const checkboxMap = new Map<CellValue, HTMLInputElement>();\n\n  // Listen for changes to filterFunc, and update checkboxes accordingly. Debounce is needed to\n  // prevent some weirdness when users click on a checkbox while focus was on a range input (causing\n  // sometimes the checkbox to not toggle)\n  const filterListener = columnFilter.filterFunc.addListener(debounce((func) => {\n    for (const [value, elem] of checkboxMap) {\n      elem.checked = func(value);\n    }\n  }));\n\n  const { searchValue: searchValueObs, filteredValues, filteredKeys, isSortedByCount } = model;\n\n  const isAboveLimitObs = Computed.create(owner, use => use(model.valuesBeyondLimit).length > 0);\n  const isSearchingObs = Computed.create(owner, use => Boolean(use(searchValueObs)));\n  const showRangeFilter = isNumberType(columnFilter.columnType) || isDateLikeType(columnFilter.columnType);\n  const isDateFilter = isDateLikeType(columnFilter.columnType);\n  const selectedBoundObs = Observable.create<\"min\" | \"max\" | null>(owner, null);\n  const viewTypeObs = Computed.create<IColumnFilterViewType>(owner,\n    use => isDateFilter && use(selectedBoundObs) ? \"calendarView\" : \"listView\",\n  );\n  const isMinSelected = Computed.create<boolean>(owner, use => use(selectedBoundObs) === \"min\")\n    .onWrite(val => val ? selectedBoundObs.set(\"min\") : selectedBoundObs.set(\"max\"));\n  const isMaxSelected = Computed.create<boolean>(owner, use => use(selectedBoundObs) === \"max\")\n    .onWrite(val => val ? selectedBoundObs.set(\"max\") : selectedBoundObs.set(\"min\"));\n\n  let searchInput: HTMLInputElement;\n  let cancel = false;\n\n  const filterMenu: HTMLElement = cssMenu(\n    { tabindex: \"-1\" }, // Allow menu to be focused\n    testId(\"wrapper\"),\n\n    // Makes sure focus goes back to menu container and disable grist keyboard shortcut while open.\n    (elem) => {\n      FocusLayer.create(owner, { defaultFocusElem: elem, pauseMousetrap: true });\n\n      // Gives focus to the searchInput on open (or to the min input if the range filter is\n      // present). Note that this must happen after the instanciation of FocusLayer in order to\n      // correctly override focus set by the latter also using a 0 delay.\n      setTimeout(() => {\n        const el = searchInput;\n        el.focus();\n        el.select();\n      }, 0);\n    },\n\n    dom.cls(menuCssClass),\n    dom.cls(kbFocusHighlighterClass),\n    dom.autoDispose(filterListener),\n    // Save or cancel on disposal, which should always happen as part of closing.\n    dom.onDispose(() => cancel ? doCancel() : doSave()),\n    dom.onKeyDown({\n      Enter: () => onClose(),\n      Escape: () => {\n        cancel = true;\n        onClose();\n      },\n    }),\n\n    // Filter by range\n    dom.maybe(showRangeFilter, () => [\n      cssRangeContainer(\n        rangeInput(\n          columnFilter.min, {\n            isDateFilter,\n            placeholder: isDateFilter ? t(\"Start\") : t(\"Min\"),\n            valueParser,\n            valueFormatter,\n            isSelected: isMinSelected,\n            viewTypeObs,\n            nextSelected: () => selectedBoundObs.set(\"max\"),\n          },\n          testId(\"min\"),\n          dom.onKeyDown({ Tab: e => e.shiftKey || selectedBoundObs.set(\"max\") }),\n        ),\n        rangeInput(\n          columnFilter.max, {\n            isDateFilter,\n            placeholder: isDateFilter ? t(\"End\") : t(\"Max\"),\n            valueParser,\n            valueFormatter,\n            isSelected: isMaxSelected,\n            viewTypeObs,\n          },\n          testId(\"max\"),\n          dom.onKeyDown({ Tab: e => e.shiftKey ? selectedBoundObs.set(\"min\") : selectedBoundObs.set(\"max\") }),\n        ),\n      ),\n\n      // presets links\n      dom.maybe(isDateFilter, () => {\n        function action(option: IDateRangeOption) {\n          const { min, max } = option;\n          columnFilter.min.set(min);\n          columnFilter.max.set(max);\n          // open the calendar view\n          selectedBoundObs.set(\"min\");\n        }\n        return [\n          cssLinkRow(\n            testId(\"presets-links\"),\n            cssLink(\n              getDateRangeOptions()[0].label,\n              dom.on(\"click\", () => action(getDateRangeOptions()[0])),\n            ),\n            cssLink(\n              getDateRangeOptions()[1].label,\n              dom.on(\"click\", () => action(getDateRangeOptions()[1])),\n            ),\n            cssLink(\n              \"More \", icon(\"Dropdown\"),\n              menu(() => getDateRangeOptions().map(\n                option => menuItem(() => action(option), option.label),\n              ), { attach: \".\" + cssMenu.className }),\n            ),\n          ),\n        ];\n      }),\n      cssMenuDivider(),\n    ]),\n\n    dom.domComputed(viewTypeObs, viewType => viewType === \"listView\" ? ListView() :\n      dom.create(ColumnFilterCalendarView, {\n        viewTypeObs, selectedBoundObs, columnFilter,\n      })),\n    Footer(),\n\n    // Prevents click on presets links submenus (any one of the 'More' submenus) from bubling up and\n    // eventually cause the parent menu to close (which used to happen when opening the column\n    // filter from the section sort&filter menu)\n    dom.on(\"click\", ev => ev.stopPropagation()),\n  );\n\n  function ListView() {\n    return [\n      cssMenuHeader(\n        cssSearchIcon(\"Search\"),\n        searchInput = cssSearch(\n          searchValueObs, { onInput: true },\n          testId(\"search-input\"),\n          {\n            \"type\": \"search\",\n            \"placeholder\": t(\"Search values\"),\n            \"aria-label\": t(\"Search values\"),\n          },\n          dom.onKeyDown({\n            Enter: () => {\n              if (searchValueObs.get()) {\n                columnFilter.setState({ included: filteredKeys.get() });\n              }\n            },\n            Escape$: (ev) => {\n              if (searchValueObs.get()) {\n                searchValueObs.set(\"\");\n                searchInput.focus();\n                ev.stopPropagation();\n              }\n            },\n          }),\n        ),\n        dom.maybe(searchValueObs, () => cssCloseIcon(\n          icon(\"CrossSmall\"),\n          { \"aria-label\": t(\"Clear search\") },\n          testId(\"search-close\"),\n          dom.on(\"click\", () => {\n            searchValueObs.set(\"\");\n            searchInput.focus();\n          }),\n          // We need to register the keydown event here to prevent triggering the one registered\n          // on the parent when pressing Enter on the clear button.\n          dom.onKeyDown({\n            Enter: () => {\n              searchValueObs.set(\"\");\n              searchInput.focus();\n            },\n          }),\n        )),\n      ),\n      cssMenuDivider(),\n      cssMenuItem(\n        dom.domComputed((use) => {\n          const searchValue = use(searchValueObs);\n          // This is necessary to avoid a known bug in grainjs where filteredKeys does not get\n          // recalculated.\n          use(filteredKeys);\n          const allSpec = searchValue ? { included: use(filteredKeys) } : { excluded: [] };\n          const noneSpec = searchValue ? { excluded: use(filteredKeys) } : { included: [] };\n          const state = use(columnFilter.state);\n          return [\n            cssSelectAll(\n              dom.text(searchValue ? t(\"All shown\") : t(\"All\")),\n              dom.attr(\"aria-disabled\", isEquivalentFilter(state, allSpec) ? \"true\" : \"false\"),\n              dom.on(\"click\", () => !isEquivalentFilter(state, allSpec) && columnFilter.setState(allSpec)),\n              testId(\"bulk-action\"),\n            ),\n            cssDotSeparator(\"•\"),\n            cssSelectAll(\n              searchValue ? t(\"All except\") : t(\"None\"),\n              dom.attr(\"aria-disabled\", isEquivalentFilter(state, noneSpec) ? \"true\" : \"false\"),\n              dom.on(\"click\", () => !isEquivalentFilter(state, noneSpec) && columnFilter.setState(noneSpec)),\n              testId(\"bulk-action\"),\n            ),\n          ];\n        }),\n        cssSortIconButton(\n          cssSortIcon(\"Sort\", cssSortIcon.cls(\"-active\", isSortedByCount)),\n          dom.attr(\"aria-label\", use => use(isSortedByCount) ?\n            t(\"Sort alphabetically (current: sorted by number of occurrences)\") :\n            t(\"Sort by number of occurrences (current: sorted alphabetically)\"),\n          ),\n          dom.on(\"click\", () => isSortedByCount.set(!isSortedByCount.get())),\n        ),\n      ),\n      cssItemList(\n        testId(\"list\"),\n        dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults(t(\"No matching values\"))),\n        dom.domComputed(filteredValues, values => values.slice(0, model.limitShown).map(([key, value]) => (\n          cssMenuItem(\n            cssLabel(\n              cssCheckboxSquare(\n                { type: \"checkbox\" },\n                dom.on(\"change\", (_ev, elem) => {\n                  if (elem.checked) {\n                    columnFilter.add(key);\n                  } else {\n                    columnFilter.delete(key);\n                  }\n                }),\n                (elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); },\n                dom.style(\"position\", \"relative\"),\n              ),\n              dom(\"span\", renderValue(key, value), testId(\"value\")),\n              cssItemCount(value.count.toLocaleString(), testId(\"count\")),\n            ),\n          )\n        ))), // Include comma separator\n      ),\n    ];\n  }\n\n  function Footer() {\n    return [\n      cssMenuDivider(),\n      cssMenuFooter(\n        dom.domComputed((use) => {\n          const isAboveLimit = use(isAboveLimitObs);\n          const searchValue = use(isSearchingObs);\n          const otherValues = use(model.otherValues);\n          const anyOtherValues = Boolean(otherValues.length);\n          const valuesBeyondLimit = use(model.valuesBeyondLimit);\n          const isRangeFilter = use(columnFilter.isRange);\n          if (isRangeFilter || use(viewTypeObs) === \"calendarView\") {\n            return [];\n          }\n          if (isAboveLimit) {\n            return searchValue ? [\n              buildSummary(t(\"Other Matching\"), valuesBeyondLimit, false, model),\n              buildSummary(t(\"Other Non-Matching\"), otherValues, true, model),\n            ] : [\n              buildSummary(t(\"Other values\"), concat(otherValues, valuesBeyondLimit), false, model),\n              buildSummary(t(\"Future values\"), [], true, model),\n            ];\n          } else {\n            return anyOtherValues ? [\n              buildSummary(t(\"Others\"), otherValues, true, model),\n            ] : [\n              buildSummary(t(\"Future values\"), [], true, model),\n            ];\n          }\n        }),\n        cssFooterButtons(\n          dom(\"div\",\n            cssPrimaryButton(\"Close\", testId(\"apply-btn\"),\n              dom.on(\"click\", () => {\n                onClose();\n              }),\n            ),\n            basicButton(\"Cancel\", testId(\"cancel-btn\"),\n              dom.on(\"click\", () => {\n                cancel = true;\n                onClose();\n              }),\n            ),\n            !showAllFiltersButton ? null : cssAllFiltersButton(\n              \"All filters\",\n              dom.on(\"click\", () => {\n                onClose();\n                commands.allCommands.sortFilterMenuOpen.run(filterInfo.viewSection.getRowId());\n              }),\n              testId(\"all-filters-btn\"),\n            ),\n          ),\n          dom(\"div\",\n            cssPinButton(\n              icon(\"PinTilted\"),\n              cssPinButton.cls(\"-pinned\", model.filterInfo.isPinned),\n              dom.attr(\"aria-label\", use => use(model.filterInfo.isPinned) ?\n                t(\"Unpin filter\") :\n                t(\"Pin filter\"),\n              ),\n              dom.on(\"click\", () => filterInfo.pinned(!filterInfo.pinned())),\n              gristDoc.behavioralPromptsManager.attachPopup(\"filterButtons\", {\n                popupOptions: {\n                  attach: null,\n                  placement: \"right\",\n                },\n              }),\n              testId(\"pin-btn\"),\n            ),\n          ),\n        ),\n      ),\n    ];\n  }\n  return filterMenu;\n}\n\nexport interface IRangeInputOptions {\n  isDateFilter: boolean;\n  placeholder: string;\n  isSelected: Observable<boolean>;\n  viewTypeObs: Observable<IColumnFilterViewType>;\n  valueParser?(val: string): any;\n  valueFormatter(val: any): string;\n  nextSelected?(): void;\n}\n\n// The range input with the preset links.\nfunction rangeInput(obs: Observable<number | undefined | IRelativeDateSpec>, opts: IRangeInputOptions,\n  ...args: DomArg<HTMLDivElement>[]) {\n  const buildInput = () => [\n    dom.maybe(use => isRelativeBound(use(obs)), () => relativeToken(obs, opts)),\n    numericInput(obs, opts),\n  ];\n\n  return cssRangeInputContainer(\n\n    dom.maybe(opts.isDateFilter, () => [\n      cssRangeInputIcon(\"FieldDate\"),\n      buildInput(),\n      icon(\"Dropdown\"),\n    ]),\n\n    dom.maybe(!opts.isDateFilter, () => [\n      buildInput(),\n    ]),\n\n    cssRangeInputContainer.cls(\"-relative\", use => isRelativeBound(use(obs))),\n    dom.cls(\"selected\", use => use(opts.viewTypeObs) === \"calendarView\" && use(opts.isSelected)),\n    dom.on(\"click\", () => opts.isSelected.set(true)),\n    elem => opts.isDateFilter ? attachRelativeDatesOptions(elem, obs, opts) : null,\n    dom.onKeyDown({\n      Backspace$: () => isRelativeBound(obs.get()) && obs.set(undefined),\n    }),\n    ...args,\n  );\n}\n\n// Attach the date options dropdown to elem.\nfunction attachRelativeDatesOptions(elem: HTMLElement, obs: Observable<number | undefined | IRelativeDateSpec>,\n  opts: IRangeInputOptions) {\n  const popupCtl = relativeDatesControl(elem, obs, {\n    ...opts,\n    placement: \"right-start\",\n    attach: \".\" + cssMenu.className,\n  });\n\n  // makes sure the options are shown any time the value changes.\n  const onValueChange = () => {\n    if (opts.isSelected.get()) {\n      popupCtl.open();\n    } else {\n      popupCtl.close();\n    }\n  };\n\n  // toggle popup on click\n  dom.update(elem, [\n    dom.on(\"click\", () => popupCtl.toggle()),\n    dom.autoDispose(opts.isSelected.addListener(onValueChange)),\n    dom.autoDispose(obs.addListener(onValueChange)),\n    dom.onKeyDown({\n      Enter$: (e) => {\n        if (opts.viewTypeObs.get() === \"listView\") { return; }\n        if (opts.isSelected.get()) {\n          if (popupCtl.isOpen()) {\n            opts.nextSelected?.();\n          } else {\n            popupCtl.open();\n          }\n        }\n        // Prevents Enter to close filter menu\n        e.stopPropagation();\n      },\n    }),\n  ]);\n}\n\nfunction numericInput(obs: Observable<number | undefined | IRelativeDateSpec>,\n  opts: IRangeInputOptions,\n  ...args: DomArg<HTMLDivElement>[]) {\n  const valueParser = opts.valueParser || Number;\n  const formatValue = opts.valueFormatter;\n  const placeholder = opts.placeholder;\n  let editMode = false;\n  let inputEl: HTMLInputElement;\n  // handle change\n  const onBlur = () => {\n    onInput.flush();\n    editMode = false;\n    inputEl.value = formatValue(obs.get());\n\n    setTimeout(() => {\n      // Make sure focus is trapped on input during calendar view, so that uses can still use keyboard\n      // to navigate relative date options just after picking a date on the calendar.\n      if (opts.viewTypeObs.get() === \"calendarView\" && opts.isSelected.get()) {\n        inputEl.focus();\n      }\n    });\n  };\n  const onInput = debounce(() => {\n    if (isRelativeBound(obs.get())) { return; }\n    editMode = true;\n    const val = inputEl.value ? valueParser(inputEl.value) : undefined;\n    if (val === undefined || typeof val === \"number\" && !isNaN(val)) {\n      obs.set(val);\n    }\n  }, 100);\n  // TODO: could be nice to have the cursor positioned at the end of the input\n  return inputEl = cssRangeInput(\n    { inputmode: \"numeric\", placeholder, value: formatValue(obs.get()) },\n    dom.on(\"input\", onInput),\n    dom.on(\"blur\", onBlur),\n    // keep input content in sync only when no edit are going on.\n    dom.autoDispose(obs.addListener(() => editMode ? null : inputEl.value = formatValue(obs.get()))),\n    dom.autoDispose(opts.isSelected.addListener(val => val && inputEl.focus())),\n\n    dom.onKeyDown({\n      Enter$: () => onBlur(),\n      Tab$: () => onBlur(),\n    }),\n    ...args,\n  );\n}\n\nfunction relativeToken(obs: Observable<number | undefined | IRelativeDateSpec>,\n  opts: IRangeInputOptions) {\n  return cssTokenContainer(\n    cssTokenToken(\n      dom.text(use => formatRelBounds(use(obs) as IRelativeDateSpec)),\n      cssDeleteButton(\n        // Ignore mousedown events, so that tokens aren't draggable by the delete button.\n        dom.on(\"mousedown\", ev => ev.stopPropagation()),\n        cssDeleteIcon(\"CrossSmall\"),\n        dom.on(\"click\", () => obs.set(undefined)),\n        testId(\"tokenfield-delete\"),\n      ),\n      testId(\"tokenfield-token\"),\n    ),\n  );\n}\n\n/**\n * Builds a tri-state checkbox that summaries the state of all the `values`. The special value\n * `Future Values` which turns the filter into an inclusion filter or exclusion filter, can be\n * added to the summary using `switchFilterType`. Uses `label` as label and also expects\n * `model` as the column filter menu model.\n *\n * The checkbox appears checked if all values of the summary are included, unchecked if none, and in\n * the indeterminate state if values are in mixed state.\n *\n * On user clicks, if checkbox is checked, it does uncheck all the values, and if the\n * `switchFilterType` is true it also converts the filter into an inclusion filter. But if the\n * checkbox is unchecked, or in the Indeterminate state, it does check all the values, and if the\n * `switchFilterType` is true it also converts the filter into an exclusion filter.\n */\nfunction buildSummary(label: string | Computed<string>, values: [CellValue, IFilterCount][],\n  switchFilterType: boolean, model: ColumnFilterMenuModel) {\n  const columnFilter = model.columnFilter;\n  const checkboxState = Computed.create(\n    null, columnFilter.isInclusionFilter, columnFilter.filterFunc,\n    (_use, isInclusionFilter) => {\n      // let's gather all sub options.\n      const subOptions = values.map(val => ({ getState: () => columnFilter.includes(val[0]) }));\n      if (switchFilterType) {\n        subOptions.push({ getState: () => !isInclusionFilter });\n      }\n\n      // At this point if sub options is still empty let's just return false (unchecked).\n      if (!subOptions.length) { return false; }\n\n      // let's compare the state for first sub options against all the others. If there is one\n      // different, then state should be `Indeterminate`, otherwise, the state will the the same as\n      // the one of the first sub option.\n      const first = subOptions[0].getState();\n      if (some(tail(subOptions), val => val.getState() !== first)) { return Indeterminate; }\n      return first;\n    }).onWrite((val) => {\n    if (switchFilterType) {\n      // Note that if `includeFutureValues` is true, we only needs to toggle the filter type\n      // between exclusive and inclusive. Doing this will automatically excludes/includes all\n      // other values, so no need for extra steps.\n      const state = val ?\n        { excluded: model.filteredKeys.get().filter(key => !columnFilter.includes(key)) } :\n        { included: model.filteredKeys.get().filter(key => columnFilter.includes(key)) };\n      columnFilter.setState(state);\n    } else {\n      const keys = values.map(([key]) => key);\n      if (val) {\n        columnFilter.addMany(keys);\n      } else {\n        columnFilter.deleteMany(keys);\n      }\n    }\n  });\n\n  return cssMenuItem(\n    dom.autoDispose(checkboxState),\n    testId(\"summary\"),\n    labeledTriStateSquareCheckbox(\n      checkboxState,\n      `${label} ${formatUniqueCount(values)}`.trim(),\n    ),\n    cssItemCount(formatCount(values), testId(\"count\")),\n  );\n}\n\nfunction formatCount(values: [CellValue, IFilterCount][]) {\n  const count = getCount(values);\n  return count ? count.toLocaleString() : \"\";\n}\n\nfunction formatUniqueCount(values: [CellValue, IFilterCount][]) {\n  const count = values.length;\n  return count ? \"(\" + count.toLocaleString() + \")\" : \"\";\n}\n\n/**\n * Returns a new `Map` object to holds pairs of `CellValue` and `IFilterCount`. For `Bool`, `Choice`\n * and `ChoiceList` type of column, the map is initialized with all possible values in order to make\n * sure they get shown to the user.\n */\nfunction getEmptyCountMap(fieldOrColumn: ViewFieldRec | ColumnRec): Map<CellValue, IFilterCount> {\n  const columnType = fieldOrColumn.origCol().type();\n  let values: any[] = [];\n  if (columnType === \"Bool\") {\n    values = [true, false];\n  } else if ([\"Choice\", \"ChoiceList\"].includes(columnType)) {\n    const options = fieldOrColumn.origCol().widgetOptionsJson;\n    values = options.prop(\"choices\")() ?? [];\n  }\n  return new Map(values.map(v => [v, { label: String(v), count: 0, displayValue: v }]));\n}\n\nexport interface IColumnFilterMenuOptions {\n  /** If true, shows a button that opens the sort & filter widget menu. */\n  showAllFiltersButton?: boolean;\n  /** Callback for when the filter menu is closed. */\n  onClose?: () => void;\n}\n\nexport interface ICreateFilterMenuParams extends IColumnFilterMenuOptions {\n  openCtl: IOpenController;\n  sectionFilter: SectionFilter;\n  filterInfo: FilterInfo;\n  rowSource: RowSource;\n  tableData: TableData;\n  gristDoc: GristDoc;\n}\n\n/**\n * Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().\n */\nexport function createFilterMenu(params: ICreateFilterMenuParams) {\n  const {\n    openCtl,\n    sectionFilter,\n    filterInfo,\n    rowSource,\n    tableData,\n    gristDoc,\n    showAllFiltersButton,\n    onClose = noop,\n  } = params;\n\n  // Go through all of our shown and hidden rows, and count them up by the values in this column.\n  const { fieldOrColumn, filter, isPinned } = filterInfo;\n  const columnType = fieldOrColumn.origCol.peek().type.peek();\n  const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;\n  const { keyMapFunc, labelMapFunc, valueMapFunc } = getMapFuncs(columnType, tableData, fieldOrColumn);\n\n  // range input options\n  const valueParser = (fieldOrColumn as any).createValueParser?.();\n  let colFormatter = fieldOrColumn.visibleColFormatter();\n\n  // Show only the date part of the datetime format in range picker.\n  if (extractTypeFromColType(colFormatter.type) === \"DateTime\") {\n    const { docSettings } = colFormatter;\n    const widgetOpts = fieldOrColumn.origCol.peek().widgetOptionsJson();\n    colFormatter = createFormatter(\"Date\", widgetOpts, docSettings);\n  }\n\n  // formatting values for Numeric columns entail issues. For instance with '%' when users type\n  // 0.499 and press enter, the input now shows 50% and there's no way to know what is the actual\n  // underlying value. Maybe worse, both 0.499 and 0.495 format to 50% but they can have different\n  // effects depending on data. Hence as of writing better to keep it only for Date.\n  const valueFormatter = isDateLikeType(visibleColumnType) ?\n    (val: any) => colFormatter.formatAny(val) : undefined;\n\n  function getFilterFunc(col: ViewFieldRec | ColumnRec, colFilter: ColumnFilterFunc | null) {\n    return col.getRowId() === fieldOrColumn.getRowId() ? null : colFilter;\n  }\n  const filterFunc = Computed.create(null, use => sectionFilter.buildFilterFunc(getFilterFunc, use));\n  openCtl.autoDispose(filterFunc);\n\n  const [allRows, hiddenRows] = partition(Array.from(rowSource.getAllRows()), filterFunc.get());\n  const valueCounts = getEmptyCountMap(fieldOrColumn);\n  addCountsToMap(valueCounts, allRows, { keyMapFunc, labelMapFunc, columnType,\n    valueMapFunc });\n  addCountsToMap(valueCounts, hiddenRows, { keyMapFunc, labelMapFunc, columnType,\n    areHiddenRows: true, valueMapFunc });\n\n  const valueCountsArr = Array.from(valueCounts);\n  const columnFilter = ColumnFilter.create(openCtl, filter.peek(), columnType, visibleColumnType,\n    valueCountsArr.map(arr => arr[0]));\n  sectionFilter.setFilterOverride(fieldOrColumn.origCol().getRowId(), columnFilter); // Will be removed on menu disposal\n  const model = ColumnFilterMenuModel.create(openCtl, {\n    columnFilter,\n    filterInfo,\n    valueCount: valueCountsArr,\n    gristDoc,\n  });\n\n  return columnFilterMenu(openCtl, {\n    model,\n    valueCounts,\n    onClose: () => { openCtl.close(); onClose(); },\n    doSave: () => {\n      const spec = columnFilter.makeFilterJson();\n      const { viewSection } = sectionFilter;\n      viewSection.setFilter(\n        fieldOrColumn.origCol().origColRef(),\n        { filter: spec },\n      );\n\n      // Check if the save was for a new filter, and if that new filter was pinned. If it was, and\n      // it is the second pinned filter in the section, trigger a tip that explains how multiple\n      // filters in the same section work.\n      const isNewPinnedFilter = columnFilter.initialFilterJson === NEW_FILTER_JSON && isPinned();\n      if (isNewPinnedFilter && viewSection.pinnedActiveFilters.get().length === 2) {\n        viewSection.showNestedFilteringPopup.set(true);\n      }\n    },\n    doCancel: () => {\n      const { viewSection } = sectionFilter;\n      if (columnFilter.initialFilterJson === NEW_FILTER_JSON) {\n        viewSection.revertFilter(fieldOrColumn.origCol().origColRef());\n      } else {\n        const initialFilter = columnFilter.initialFilterJson;\n        columnFilter.setState(initialFilter);\n        viewSection.setFilter(\n          fieldOrColumn.origCol().origColRef(),\n          { filter: initialFilter, pinned: model.initialPinned },\n        );\n      }\n    },\n    renderValue: getRenderFunc(columnType, fieldOrColumn),\n    valueParser,\n    valueFormatter,\n    showAllFiltersButton,\n  });\n}\n\n/**\n * Returns three callback functions, `keyMapFunc`, `labelMapFunc`\n * and `valueMapFunc`, which map row ids to cell values, labels\n * and visible col value respectively.\n *\n * The functions vary based on the `columnType`. For example,\n * Reference Lists have a unique `labelMapFunc` that returns a list\n * of all labels in a given cell, rather than a single label.\n *\n * Used by ColumnFilterMenu to compute counts of unique cell\n * values and display them with an appropriate label.\n */\nfunction getMapFuncs(columnType: string, tableData: TableData, fieldOrColumn: ViewFieldRec | ColumnRec) {\n  const keyMapFunc = tableData.getRowPropFunc(fieldOrColumn.colId());\n  const labelGetter = tableData.getRowPropFunc(fieldOrColumn.displayColModel().colId());\n  const formatter = fieldOrColumn.visibleColFormatter();\n\n  let labelMapFunc: (rowId: number) => string | string[];\n  const valueMapFunc: (rowId: number) => any = (rowId: number) => decodeObject(labelGetter(rowId)!);\n\n  if (isRefListType(columnType)) {\n    labelMapFunc = (rowId: number) => {\n      const maybeLabels = labelGetter(rowId);\n      if (!maybeLabels) { return \"\"; }\n      const labels = isList(maybeLabels) ? maybeLabels.slice(1) : [maybeLabels];\n      return labels.map(l => formatter.formatAny(l));\n    };\n  } else {\n    // If this is Markdown widget, remove all formatting (mostly for links).\n    const widget = fieldOrColumn.widget();\n    const isMarkdown = widget === \"Markdown\";\n    const cleaned = isMarkdown ? stripLinks : identity<string>;\n    labelMapFunc = (rowId: number) => cleaned(formatter.formatAny(labelGetter(rowId)));\n  }\n  return { keyMapFunc, labelMapFunc, valueMapFunc };\n}\n\n/**\n * Returns a callback function for rendering values in a filter menu.\n *\n * For example, Choice and Choice List columns will differ from other\n * column types by rendering their values as colored tokens instead of\n * text.\n */\nfunction getRenderFunc(columnType: string, fieldOrColumn: ViewFieldRec | ColumnRec) {\n  if ([\"Choice\", \"ChoiceList\"].includes(columnType)) {\n    const options = fieldOrColumn.widgetOptionsJson.peek();\n    const choiceSet = new Set<string>(options.choices || []);\n    const choiceOptions: ChoiceOptions = options.choiceOptions || {};\n\n    return (_key: CellValue, value: IFilterCount) => {\n      if (value.label === \"\") {\n        return cssItemValue(value.label);\n      }\n\n      return choiceToken(\n        value.label,\n        {\n          fillColor: choiceOptions[value.label]?.fillColor,\n          textColor: choiceOptions[value.label]?.textColor,\n          fontBold: choiceOptions[value.label]?.fontBold ?? false,\n          fontUnderline: choiceOptions[value.label]?.fontUnderline ?? false,\n          fontItalic: choiceOptions[value.label]?.fontItalic ?? false,\n          fontStrikethrough: choiceOptions[value.label]?.fontStrikethrough ?? false,\n          invalid: !choiceSet.has(value.label),\n        },\n        dom.cls(cssToken.className),\n        testId(\"choice-token\"),\n      );\n    };\n  }\n\n  return (key: CellValue, value: IFilterCount) =>\n    cssItemValue(value.label === undefined ? String(key) : value.label);\n}\n\ninterface ICountOptions {\n  columnType: string;\n  // returns the indexing key for the filter\n  keyMapFunc?: (v: any) => any;\n  // returns the string representation of the value (can involves some formatting).\n  labelMapFunc?: (v: any) => any;\n  // returns the underlying value (useful for comparison)\n  valueMapFunc: (v: any) => any;\n  areHiddenRows?: boolean;\n}\n\n/**\n * For each row id in Iterable, adds a key mapped with `keyMapFunc` and a value object with a\n * `label` mapped with `labelMapFunc` and a `count` representing the total number of times the key\n * has been encountered and a `displayValues` mapped with `valueMapFunc`.\n *\n * The optional column type controls how complex cell values are decomposed into keys (e.g. Choice Lists have\n * the possible choices as keys).\n * Note that this logic is replicated in BaseView.prototype.filterByThisCellValue.\n */\nfunction addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: UIRowId[],\n  { keyMapFunc = identity, labelMapFunc = identity, columnType,\n    areHiddenRows = false, valueMapFunc }: ICountOptions) {\n  for (const rowId of rowIds) {\n    let key = keyMapFunc(rowId);\n\n    // If row contains a list and the column is a Choice List, treat each choice as a separate key\n    if (isList(key) && (columnType === \"ChoiceList\")) {\n      const list = decodeObject(key) as unknown[];\n      if (!list.length) {\n        // If the list is empty, add an item for the whole list, otherwise the row will be missing from filters.\n        addSingleCountToMap(valueMap, \"\", () => \"\", () => \"\", areHiddenRows);\n      }\n      for (const item of list) {\n        addSingleCountToMap(valueMap, item, () => item, () => item, areHiddenRows);\n      }\n      continue;\n    }\n\n    // If row contains a Reference List, treat each reference as a separate key\n    if (isList(key) && isRefListType(columnType)) {\n      const refIds = decodeObject(key) as unknown[];\n      if (!refIds.length) {\n        // If the list is empty, add an item for the whole list, otherwise the row will be missing from filters.\n        addSingleCountToMap(valueMap, null, () => null, () => null, areHiddenRows);\n      }\n      const refLabels = labelMapFunc(rowId);\n      const displayValues = valueMapFunc(rowId);\n      refIds.forEach((id, i) => {\n        addSingleCountToMap(valueMap, id, () => refLabels[i], () => displayValues[i], areHiddenRows);\n      });\n      continue;\n    }\n    // For complex values, serialize the value to allow them to be properly stored\n    if (Array.isArray(key)) { key = JSON.stringify(key); }\n    addSingleCountToMap(valueMap, key, () => labelMapFunc(rowId), () => valueMapFunc(rowId), areHiddenRows);\n  }\n}\n\n/**\n * Adds the `value` to `valueMap` using `labelGetter` to get the label and increments `count` unless\n * isHiddenRow is true.\n */\nfunction addSingleCountToMap(valueMap: Map<CellValue, IFilterCount>, value: any, label: () => any,\n  displayValue: () => any, isHiddenRow: boolean) {\n  if (!valueMap.has(value)) {\n    valueMap.set(value, { label: label(), count: 0, displayValue: displayValue() });\n  }\n  if (!isHiddenRow) {\n    valueMap.get(value)!.count++;\n  }\n}\n\nfunction getCount(values: [CellValue, IFilterCount][]) {\n  return values.reduce((acc, val) => acc + val[1].count, 0);\n}\n\nconst defaultPopupOptions: IPopupOptions = {\n  placement: \"bottom-start\",\n  boundaries: \"viewport\",\n  trigger: [\"click\"],\n};\n\ninterface IColumnFilterPopupOptions {\n  // Options to pass to the popup component.\n  popupOptions?: IPopupOptions;\n}\n\ntype IAttachColumnFilterMenuOptions = IColumnFilterPopupOptions & IColumnFilterMenuOptions;\n\n// Helper to attach the column filter menu.\nexport function attachColumnFilterMenu(\n  filterInfo: FilterInfo,\n  options: IAttachColumnFilterMenuOptions = {},\n): DomElementMethod {\n  const { popupOptions, ...filterMenuOptions } = options;\n  const popupOptionsWithDefaults = { ...defaultPopupOptions, ...popupOptions };\n  return (elem) => {\n    const instance = filterInfo.viewSection.viewInstance();\n    if (instance?.createFilterMenu) { // Should be set if using BaseView\n      setPopupToCreateDom(elem, ctl => instance.createFilterMenu(\n        ctl, filterInfo, filterMenuOptions), popupOptionsWithDefaults);\n    }\n  };\n}\n\nconst cssMenu = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  min-width: 252px;\n  max-width: 400px;\n  max-height: 90vh;\n  outline: none;\n  background-color: ${theme.menuBg};\n  padding-top: 0;\n  padding-bottom: 12px;\n`);\nconst cssMenuHeader = styled(\"div\", `\n  height: 40px;\n  flex-shrink: 0;\n\n  display: flex;\n  align-items: center;\n\n  margin: 0 16px;\n`);\nconst cssSelectAll = styled(textButton, `\n  --icon-color: ${theme.controlFg};\n`);\nconst cssDotSeparator = styled(\"span\", `\n  color: ${theme.controlFg};\n  margin: 0 8px;\n  user-select: none;\n`);\nconst cssMenuDivider = styled(menuDivider, `\n  flex-shrink: 0;\n  margin: 0;\n`);\nconst cssItemList = styled(\"div\", `\n  flex-shrink: 1;\n  overflow: auto;\n  min-height: 80px;\n  margin-top: 4px;\n  padding-bottom: 8px;\n`);\nconst cssMenuItem = styled(\"div\", `\n  display: flex;\n  padding: 8px 16px;\n`);\nconst cssLink = textButton;\nconst cssLinkRow = styled(cssMenuItem, `\n  column-gap: 12px;\n  padding-top: 0;\n  padding-bottom: 16px;\n`);\nexport const cssItemValue = styled(cssLabelText, `\n  margin-right: 12px;\n  white-space: pre;\n`);\nconst cssItemCount = styled(\"div\", `\n  flex-grow: 1;\n  align-self: normal;\n  text-align: right;\n  color: ${theme.lightText};\n`);\nconst cssMenuFooter = styled(\"div\", `\n  display: flex;\n  flex-shrink: 0;\n  flex-direction: column;\n  padding-top: 4px;\n`);\nconst cssFooterButtons = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 8px 16px;\n`);\nconst cssPrimaryButton = styled(primaryButton, `\n  margin-right: 8px;\n`);\nconst cssAllFiltersButton = styled(textButton, `\n  margin-left: 8px;\n`);\nconst cssSearch = styled(input, `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  flex-grow: 1;\n  min-width: 1px;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n\n  font-size: ${vars.mediumFontSize};\n\n  margin: 0px 16px 0px 8px;\n  padding: 0px;\n  border: none;\n  outline: none;\n  outline-offset: 3px;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\nconst cssSearchIcon = styled(icon, `\n  --icon-color: ${theme.lightText};\n  flex-shrink: 0;\n  margin-left: auto;\n  margin-right: 4px;\n`);\nconst cssCloseIcon = styled(unstyledButton, `\n  --icon-color: ${theme.lightText};\n  flex-shrink: 0;\n`);\nconst cssNoResults = styled(cssMenuItem, `\n  font-style: italic;\n  color: ${theme.lightText};\n  justify-content: center;\n`);\nconst cssSortIconBase = `\n  --icon-color: ${theme.controlSecondaryFg};\n  margin-left: auto;\n  &-active {\n    --icon-color: ${theme.controlFg}\n  }\n`;\nconst cssSortIcon = styled(icon, cssSortIconBase);\nconst cssSortIconButton = styled(unstyledButton, cssSortIconBase);\nconst cssLabel = styled(cssCheckboxLabel, `\n  align-items: center;\n  font-weight: initial;   /* negate bootstrap */\n  flex-grow: 1;\n`);\nconst cssToken = styled(\"div\", `\n  margin-left: 8px;\n  margin-right: 12px;\n`);\nconst cssRangeContainer = styled(cssMenuItem, `\n  display: flex;\n  align-items: center;\n  row-gap: 6px;\n  flex-direction: column;\n  padding: 16px 16px;\n`);\nconst cssRangeInputContainer = styled(\"div\", `\n  position: relative;\n  width: 100%;\n  display: flex;\n  background-color: ${theme.inputBg};\n  height: 30px;\n  width: 100%;\n  border-radius: 3px;\n  border: 1px solid ${theme.inputBorder};\n  outline: none;\n  padding: 5px;\n  &.selected {\n    border: 1px solid ${theme.inputValid};\n  }\n  &-relative input {\n    padding: 0;\n    max-width: 0;\n  }\n`);\nconst cssRangeInputIcon = cssOptionRowIcon;\nconst cssRangeInput = styled(cssInput, `\n  height: unset;\n  border: none;\n  padding: 0;\n  width: unset;\n  flex-grow: 1;\n`);\nconst cssTokenToken = styled(cssTokenTokenBase, `\n  height: 18px;\n  line-height: unset;\n  align-self: center;\n  cursor: default;\n`);\nconst cssTokenContainer = styled(\"div\", `\n  width: 100%;\n  display: flex;\n  outline: none;\n`);\n"
  },
  {
    "path": "app/client/ui/ColumnFilterMenuUtils.ts",
    "content": "import { popupControl } from \"app/client/lib/popupControl\";\nimport { IOptionFull, SimpleList } from \"app/client/lib/simpleList\";\nimport { relativeDatesOptions } from \"app/client/ui/RelativeDatesOptions\";\nimport { IRangeBoundType, isEquivalentBound } from \"app/common/FilterState\";\n\nimport { Placement } from \"@popperjs/core\";\nimport { Disposable, dom, Observable } from \"grainjs\";\nimport { IOpenController, IPopupOptions, PopupControl } from \"popweasel\";\n\nexport interface IOptionsDropdownOpt {\n  placement: Placement;\n  valueFormatter(val: any): string\n}\n\n// Create a popup control that show the relative dates options for obs in a popup attached to\n// reference.\nexport function relativeDatesControl(\n  reference: HTMLElement,\n  obs: Observable<IRangeBoundType>,\n  opt: { valueFormatter(val: any): string } & IPopupOptions): PopupControl {\n  const popupCtl = popupControl(\n    reference,\n    ctl => RelativeDatesMenu.create(null, ctl, obs, opt).content,\n    opt,\n  );\n  dom.autoDisposeElem(reference, popupCtl);\n  return popupCtl;\n}\n\n// Builds the list of relatives dates to show in a popup next to the range inputs for date\n// filtering. It does not still focus from the range input and takes care of keyboard navigation\n// using arrow Up/Down, Escape to close the menu and enter to trigger select option.\nclass RelativeDatesMenu extends Disposable {\n  public content: Element;\n  private _dropdownList: SimpleList<IRangeBoundType>;\n  private _items: Observable<IOptionFull<IRangeBoundType>[]> = Observable.create(this, []);\n  constructor(ctl: IOpenController,\n    private _obs: Observable<IRangeBoundType>,\n    private _opt: { valueFormatter(val: any): string }) {\n    super();\n    this._dropdownList = (SimpleList<IRangeBoundType>).create(this, ctl, this._items, this._action.bind(this));\n    this._dropdownList.listenKeys(ctl.getTriggerElem() as HTMLElement);\n    this.content = this._dropdownList.content;\n    this.autoDispose(this._obs.addListener(() => this._update()));\n    this._update();\n  }\n\n  private _getOptions() {\n    const newItems = relativeDatesOptions(this._obs.get(), this._opt.valueFormatter);\n    return newItems.map(item => ({ label: item.label, value: item.spec }));\n  }\n\n  private _update() {\n    this._items.set(this._getOptions());\n    const index = this._items.get().findIndex(o => isEquivalentBound(o.value, this._obs.get()));\n    this._dropdownList.setSelected(index ?? -1);\n  }\n\n  private _action(value: IRangeBoundType) {\n    this._obs.set(value);\n  }\n}\n"
  },
  {
    "path": "app/client/ui/ColumnTitle.ts",
    "content": "import { Clipboard } from \"app/client/components/Clipboard\";\nimport * as commands from \"app/client/components/commands\";\nimport { copyToClipboard } from \"app/client/lib/clipboardUtils\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { setTestState } from \"app/client/lib/testState\";\nimport { ViewFieldRec } from \"app/client/models/DocModel\";\nimport { LIMITED_COLUMN_OPTIONS } from \"app/client/ui/FieldConfig\";\nimport { autoGrow } from \"app/client/ui/forms\";\nimport { cssInput, cssLabel, cssRenamePopup, cssTextArea } from \"app/client/ui/RenamePopupStyles\";\nimport { descriptionInfoTooltip, hoverTooltip, showTransientTooltip } from \"app/client/ui/tooltips\";\nimport { basicButton, primaryButton, textButton } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\n\nimport { Computed, dom, IKnockoutReadObservable, makeTestId, Observable, styled } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport { IOpenController, PopupControl, setPopupToCreateDom } from \"popweasel\";\n\nconst testId = makeTestId(\"test-column-title-\");\nconst t = makeT(\"ColumnTitle\");\n\ninterface IColumnTitleOptions {\n  /**\n   * The field to rename.\n   */\n  field: ViewFieldRec;\n  /**\n   * An observable that triggers the popup to open when set to true.\n   */\n  isEditing: ko.Computed<boolean>;\n  /**\n   * Optional commands to bind when the popup is open.\n   */\n  optCommands?: any;\n  /**\n   * Optional computed or boolean to determine if the column can be renamed. Defaults to true.\n   */\n  canRename?: ko.Computed<boolean> | boolean;\n  /**\n   * Optional computed or boolean to determine if the description can be changed. Defaults to true.\n   */\n  canChangeDesc?: ko.Computed<boolean> | boolean;\n}\n\nexport function buildRenameColumn(options: IColumnTitleOptions) {\n  return (elem: Element) => {\n    // To open the popup we will listen to the isEditing observable, and open the popup when it\n    // it is changed. This can be changed either by us, but also by an external source.\n    const trigger = (triggerElem: Element, ctl: PopupControl) => {\n      ctl.autoDispose(options.isEditing.subscribe((editing) => {\n        if (editing) {\n          ctl.open();\n        } else if (!ctl.isDisposed()) {\n          ctl.close();\n        }\n      }));\n    };\n    setPopupToCreateDom(elem, ctl => buildColumnRenamePopup(ctl, options), {\n      placement: \"bottom-start\",\n      trigger: [trigger],\n      attach: \"body\",\n      boundaries: \"viewport\",\n    });\n  };\n}\n\n/**\n * Renders the 'label' observable as the column title. When tooltipContent is non-empty, also\n * renders a tooltip icon just on the left of the title, taking care to keep the icon + title\n * centered when it fits, with appropriate overflow when it doesn't.\n */\nexport function columnHeaderWithInfo(\n  label: IKnockoutReadObservable<string> | string,\n  tooltipContent: IKnockoutReadObservable<string> | string,\n  tooltipTestPrefix: string,\n) {\n  return [\n    dom.maybe(tooltipContent, content => [\n      descriptionInfoTooltip(content, tooltipTestPrefix),\n      cssColumnHeaderInfoPadding(),\n    ]),\n    cssColumnHeaderLabel(dom.text(label), testId(\"text\")),\n  ];\n}\n\nfunction buildColumnRenamePopup(ctrl: IOpenController, options: IColumnTitleOptions) {\n  const { field, isEditing, optCommands } = options;\n  // Store temporary values for the label and description.\n  const editedLabel = Observable.create(ctrl, field.displayLabel.peek());\n  const editedDesc = Observable.create(ctrl, field.description.peek());\n  // Col id is static, as we can't forsee if it will change and what it will\n  // change to (it may overlap with another column)\n  const colId = \"$\" + field.colId.peek();\n\n  const hasChange = Computed.create(ctrl, (use) => {\n    return use(editedLabel)?.trim() !== field.displayLabel.peek() ||\n      use(editedDesc)?.trim() !== field.description.peek();\n  });\n\n  const cantSave = Computed.create(ctrl, (use) => {\n    const filledLabel = Boolean(use(editedLabel)?.trim());\n    return !filledLabel;\n  });\n\n  // Function to change a column name.\n  const saveColumnLabel = async () => {\n    // Trim new label and make sure it is a string (not null).\n    const newLabel = editedLabel.get()?.trim() ?? \"\";\n    // Save only when it is not empty and different from the current value.\n    if (newLabel && newLabel !== field.displayLabel.peek()) {\n      await field.displayLabel.setAndSaveOrRevert(newLabel);\n    }\n  };\n\n  // Function to change a column description.\n  const saveColumnDesc = async () => {\n    const newDesc = editedDesc.get()?.trim() ?? \"\";\n    if (newDesc !== field.description.peek()) {\n      await field.description.setAndSaveOrRevert(newDesc);\n    }\n  };\n\n  // Function save column name and description and close the popup.\n  const save = () => Promise.all([\n    saveColumnLabel(),\n    saveColumnDesc(),\n  ]);\n\n  // When the popup is closing we will save everything, unless the user has pressed the cancel button.\n  let cancelled = false;\n\n  // Function to close the popup with saving.\n  const close = () => ctrl.close();\n\n  // Function to close the popup without saving.\n  const cancel = () => { cancelled = true; close(); };\n\n  // Function that is called when popup is closed.\n  const onClose = () => {\n    if (!cancelled) {\n      save().catch(reportError);\n    }\n    // Reset the isEditing flag. It will set the editIndex in GridView to -1 if this is active column.\n    // It can happen that we will be open even if the column is not active (as the isEditing flag is asynchronous).\n    isEditing(false);\n  };\n\n  // User interface for the popup.\n  const myCommands = {\n    // Escape key: just close the popup.\n    cancel,\n    // Enter key: save and close the popup, unless the description input is focused.\n    // There is also a variant for Ctrl+Enter which will always save.\n    accept: () => {\n      // Enters are ignored in the description input (unless ctrl is pressed)\n      if (document.activeElement === descInput) { return true; }\n      close();\n    },\n    // Tab: save and close the popup, and move to the next field.\n    nextField: () => {\n      close();\n      optCommands?.nextField?.();\n    },\n    // Shift + Tab: save and close the popup, and move to the previous field.\n    prevField: () => {\n      close();\n      optCommands?.prevField?.();\n    },\n    // ArrowUp: moves focus to the label if it is already at the top\n    cursorUp: () => {\n      if (document.activeElement === descInput && descInput?.selectionStart === 0) {\n        labelInput?.focus();\n        labelInput?.select();\n      } else {\n        return true;\n      }\n    },\n    // ArrowDown: move to the description input, only if the label input is focused.\n    cursorDown: () => {\n      if (document.activeElement === labelInput) {\n        const focus = () => {\n          descInput?.focus();\n          descInput?.select();\n        };\n        showDesc.set(true);\n        focus();\n      } else {\n        return true;\n      }\n    },\n  };\n\n  // Create this group and attach it to the popup and both inputs.\n  const commandGroup = commands.createGroup({ ...optCommands, ...myCommands }, ctrl, true);\n  ctrl.autoDispose(commandGroup);\n\n  // We will still focus from other elements and restore it on either the label or description input.\n  let lastFocus: HTMLElement | undefined;\n  const rememberFocus = (el: HTMLElement) => dom.on(\"focus\", () => lastFocus = el);\n  const restoreFocus = (el: HTMLElement) => dom.on(\"focus\", () => lastFocus?.focus());\n\n  const showDesc = Observable.create(ctrl, Boolean(field.description.peek() !== \"\"));\n\n  const defaultTrue = (val: boolean | ko.Computed<boolean> | undefined) => {\n    return val === undefined ? true : val;\n  };\n  const toComputed = (val: boolean | ko.Computed<boolean>) =>\n    typeof val === \"boolean\" ? Computed.create(ctrl, () => val) : Computed.create(ctrl, use => use(val));\n\n  const not = (val: Observable<boolean>) => Computed.create(ctrl, use => !use(val));\n\n  const canRename = toComputed(defaultTrue(options.canRename));\n  const canChangeDesc = toComputed(defaultTrue(options.canChangeDesc));\n\n  let labelInput: HTMLInputElement | undefined;\n  let descInput: HTMLTextAreaElement | undefined;\n  return cssRenamePopup(\n    dom.onDispose(onClose),\n    testId(\"popup\"),\n    dom.cls(menuCssClass),\n    cssLabel(t(\"Column label\")),\n    cssColLabelBlock(\n      labelInput = cssInput(\n        editedLabel,\n        updateOnKey,\n        { placeholder: t(\"Provide a column label\") },\n        testId(\"label\"),\n        commandGroup.attach(),\n        rememberFocus,\n        hoverTooltip(LIMITED_COLUMN_OPTIONS, { hidden: canRename }),\n        dom.boolAttr(\"disabled\", not(canRename)),\n        dom.style(\"pointer-events\", \"all\"),\n      ),\n      cssColId(\n        t(\"COLUMN ID: \"),\n        colId,\n        dom.on(\"click\", async (e, d) => {\n          e.stopImmediatePropagation();\n          e.preventDefault();\n          showTransientTooltip(d, t(\"Column ID copied to clipboard\"), {\n            key: \"copy-column-id\",\n          });\n          await copyToClipboard(colId);\n          setTestState({ clipboard: colId });\n        }),\n        testId(\"colid\"),\n      ),\n    ),\n    dom.maybe(use => !use(showDesc), () => cssAddDescription(\n      textButton(\n        icon(\"Plus\"),\n        t(\"Add description\"),\n        dom.on(\"click\", () => {\n          showDesc.set(true);\n          setTimeout(() => { descInput?.focus(); descInput?.select(); }, 0);\n        }),\n        testId(\"add-description\"),\n      ),\n    )),\n    dom.maybe(showDesc, () => [\n      cssLabel(t(\"Column description\")),\n      descInput = cssTextArea(editedDesc, updateOnKey,\n        testId(\"description\"),\n        commandGroup.attach(),\n        rememberFocus,\n        autoGrow(editedDesc),\n        dom.boolAttr(\"disabled\", not(canChangeDesc)),\n      ),\n    ]),\n    dom.onKeyDown({\n      Enter$: (e) => {\n        if (e.ctrlKey || e.metaKey) {\n          close();\n          return false;\n        }\n      },\n    }),\n    cssButtons(\n      primaryButton(\n        dom.on(\"click\", cancel),\n        testId(\"close\"),\n        dom.hide(hasChange),\n        t(\"Close\"),\n      ),\n      primaryButton(t(\"Save\"),\n        dom.on(\"click\", close),\n        testId(\"save\"),\n        dom.show(hasChange),\n        dom.boolAttr(\"disabled\", cantSave),\n      ),\n      basicButton(t(\"Cancel\"),\n        testId(\"cancel\"),\n        dom.on(\"click\", cancel),\n        dom.show(hasChange),\n      ),\n    ),\n    // After showing the popup, focus the label input and select it's content.\n    (elem) => {\n      setTimeout(() => {\n        if (ctrl.isDisposed()) { return; }\n        if (canRename.get()) {\n          labelInput?.focus();\n          labelInput?.select();\n        } else if (canChangeDesc.get()) {\n          descInput?.focus();\n          descInput?.select();\n        }\n      }, 0);\n    },\n    // Create a FocusLayer to keep focus in this popup while it's active, by default when focus is stolen\n    // by someone else, we will bring back it to the label element. Clicking anywhere outside the popup\n    // will close it, but not when we click on the header itself (as it will reopen it). So this one\n    // makes sure that the focus is restored in the label.\n    (elem) => {\n      FocusLayer.create(ctrl, {\n        defaultFocusElem: elem,\n        pauseMousetrap: false,\n        allowFocus: Clipboard.allowFocus,\n      });\n    },\n    restoreFocus,\n  );\n}\n\nconst updateOnKey = { onInput: true };\n\nconst cssAddDescription = styled(\"div\", `\n  display: flex;\n  padding-top: 14px;\n  padding-bottom: 4px;\n  & button {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n  }\n`);\n\nconst cssColLabelBlock = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex: auto;\n  min-width: 80px;\n`);\n\nconst cssColId = styled(\"div\", `\n  font-size: ${vars.xsmallFontSize};\n  font-weight: ${vars.bigControlTextWeight};\n  margin-top: 8px;\n  color: ${theme.lightText};\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  cursor: pointer;\n  align-self: start;\n`);\n\nconst cssButtons = styled(\"div\", `\n  display: flex;\n  margin-top: 16px;\n  gap: 8px;\n  & button {\n    min-width: calc(50 / 13 * 1em); /* Min 50px for 13px font size, to make Save and Close buttons equal width */\n  }\n`);\n\nconst cssColumnHeaderLabel = styled(\"div\", `\n  padding-left: 1px;\n  padding-right: 1px;\n  z-index: 1;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`);\n\n// The padding is added when there is an icon, and goes on the right (thanks to the 'order'\n// property) to balance out the icon and keep things centered. But it shrinks very aggressively.\nconst cssColumnHeaderInfoPadding = styled(\"div\", `\n  width: 21px;\n  order: 100;\n  flex-shrink: 1e12;\n`);\n"
  },
  {
    "path": "app/client/ui/ConfigsAPI.ts",
    "content": "import { getHomeUrl } from \"app/client/models/AppModel\";\nimport { BaseAPI, IOptions } from \"app/common/BaseAPI\";\nimport { Config, ConfigKey, ConfigValue } from \"app/common/Config\";\nimport { addCurrentOrgToPath } from \"app/common/urlUtils\";\n\nexport interface ConfigsAPI {\n  getConfig(key: ConfigKey): Promise<Config>;\n  updateConfig(key: ConfigKey, value: ConfigValue): Promise<Config>;\n  deleteConfig(key: ConfigKey): Promise<void>;\n}\n\nexport class InstallConfigsAPI extends BaseAPI implements ConfigsAPI {\n  constructor(private _homeUrl: string = getHomeUrl(), options: IOptions = {}) {\n    super(options);\n  }\n\n  public getConfig(key: ConfigKey): Promise<Config> {\n    return this.requestJson(`${this._url}/api/install/configs/${key}`, {\n      method: \"GET\",\n    });\n  }\n\n  public updateConfig(key: ConfigKey, value: ConfigValue): Promise<Config> {\n    return this.requestJson(`${this._url}/api/install/configs/${key}`, {\n      method: \"PUT\",\n      body: JSON.stringify(value),\n    });\n  }\n\n  public async deleteConfig(key: ConfigKey): Promise<void> {\n    await this.request(`${this._url}/api/install/configs/${key}`, {\n      method: \"DELETE\",\n    });\n  }\n\n  private get _url(): string {\n    return addCurrentOrgToPath(this._homeUrl);\n  }\n}\n\nexport class OrgConfigsAPI extends BaseAPI implements ConfigsAPI {\n  constructor(\n    private _org: number | string,\n    private _homeUrl: string = getHomeUrl(),\n    options: IOptions = {},\n  ) {\n    super(options);\n  }\n\n  public getConfig(key: ConfigKey): Promise<Config> {\n    return this.requestJson(\n      `${this._url}/api/orgs/${this._org}/configs/${key}`,\n      {\n        method: \"GET\",\n      },\n    );\n  }\n\n  public updateConfig(key: ConfigKey, value: ConfigValue): Promise<Config> {\n    return this.requestJson(\n      `${this._url}/api/orgs/${this._org}/configs/${key}`,\n      {\n        method: \"PUT\",\n        body: JSON.stringify(value),\n      },\n    );\n  }\n\n  public async deleteConfig(key: ConfigKey): Promise<void> {\n    await this.request(`${this._url}/api/orgs/${this._org}/configs/${key}`, {\n      method: \"DELETE\",\n    });\n  }\n\n  private get _url(): string {\n    return addCurrentOrgToPath(this._homeUrl);\n  }\n}\n"
  },
  {
    "path": "app/client/ui/CoreHomeImports.ts",
    "content": "import { PluginScreen } from \"app/client/components/PluginScreen\";\nimport { guessTimezone } from \"app/client/lib/guessTimezone\";\nimport { ImportSourceElement } from \"app/client/lib/ImportSourceElement\";\nimport { EXTENSIONS_IMPORTABLE_AS_DOC } from \"app/client/lib/uploads\";\nimport { uploadFiles } from \"app/client/lib/uploads\";\nimport { AppModel, reportError } from \"app/client/models/AppModel\";\nimport { openFilePicker } from \"app/client/ui/FileDialog\";\nimport { ImportProgress } from \"app/client/ui/ImportProgress\";\nimport { byteString } from \"app/common/gutil\";\n\nimport { AxiosProgressEvent } from \"axios\";\n\n/**\n * Imports a document and returns its docId, or null if no files were selected.\n */\nexport async function docImport(app: AppModel, workspaceId: number | \"unsaved\"): Promise<string | null> {\n  // We use openFilePicker() and uploadFiles() separately, rather than the selectFiles() helper,\n  // because we only want to connect to a docWorker if there are in fact any files to upload.\n\n  // Start selecting files.  This needs to start synchronously to be seen as a user-initiated\n  // popup, or it would get blocked by default in a typical browser.\n  const files: File[] = await openFilePicker({\n    multiple: false,\n    accept: EXTENSIONS_IMPORTABLE_AS_DOC.join(\",\"),\n  });\n\n  if (!files.length) { return null; }\n\n  return await fileImport(files, app, workspaceId);\n}\n\n/**\n * Imports a document from a file and returns its docId.\n */\nexport async function fileImport(\n  files: File[], app: AppModel, workspaceId: number | \"unsaved\"): Promise<string | null> {\n  // There is just one file (thanks to {multiple: false} above).\n  const progressUI = app.notifier.createProgressIndicator(files[0].name, byteString(files[0].size));\n  const progress = ImportProgress.create(progressUI, progressUI, files[0]);\n  try {\n    const timezone = await guessTimezone();\n\n    if (workspaceId === \"unsaved\") {\n      function onUploadProgress(ev: AxiosProgressEvent) {\n        if (ev.event.lengthComputable) {\n          progress.setUploadProgress(ev.event.loaded / ev.event.total * 100);   // percentage complete\n        }\n      }\n      return await app.api.importUnsavedDoc(files[0], { timezone, onUploadProgress });\n    } else {\n      // Connect to a docworker.  Imports share some properties of documents but not all. In place of\n      // docId, for the purposes of work allocation, we use the special assigmentId `import`.\n      const docWorker = await app.api.getWorkerAPI(\"import\");\n\n      // This uploads to the docWorkerUrl saved in window.gristConfig\n      const uploadResult = await uploadFiles(files, { docWorkerUrl: docWorker.url, sizeLimit: \"import\" },\n        p => progress.setUploadProgress(p));\n      const importResult = await docWorker.importDocToWorkspace(uploadResult!.uploadId, workspaceId, { timezone });\n      return importResult.id;\n    }\n  } catch (err) {\n    reportError(err);\n    return null;\n  } finally {\n    progress.finish();\n    // Dispose the indicator UI and the progress timer owned by it.\n    progressUI.dispose();\n  }\n}\n/**\n * Imports document through a plugin from a home/welcome screen.\n */\nexport async function importFromPlugin(\n  app: AppModel,\n  workspaceId: number | \"unsaved\",\n  importSourceElem: ImportSourceElement,\n) {\n  const screen = PluginScreen.create(null, importSourceElem.importSource.label);\n  try {\n    const plugin = importSourceElem.plugin;\n    const handle = screen.renderPlugin(plugin);\n    const importSource = await importSourceElem.importSourceStub.getImportSource(handle);\n    plugin.removeRenderTarget(handle);\n\n    if (importSource) {\n      // If data has been picked, upload it.\n      const item = importSource.item;\n      if (item.kind === \"fileList\") {\n        const files = item.files.map(({ content, name }) => new File([content], name));\n        const docId = await fileImport(files, app, workspaceId);\n        screen.close();\n        return docId;\n      } else if (item.kind === \"url\") {\n        // TODO: importing from url is not yet implemented.\n        // uploadResult = await fetchURL(this._docComm, item.url);\n        throw new Error(\"Url is not supported yet\");\n      } else {\n        throw new Error(`Import source of kind ${(item as any).kind} are not yet supported!`);\n      }\n    } else {\n      screen.close();\n      return null;\n    }\n  } catch (err) {\n    screen.renderError(err.message);\n    return null;\n  }\n}\n"
  },
  {
    "path": "app/client/ui/CoreNewDocMethods.ts",
    "content": "import { ImportSourceElement } from \"app/client/lib/ImportSourceElement\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/AppModel\";\nimport { docUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { HomeModel } from \"app/client/models/HomeModel\";\nimport { homeImports } from \"app/client/ui/HomeImports\";\n\nconst t = makeT(\"CoreNewDocMethods\");\n\nexport async function createDocAndOpen(home: HomeModel) {\n  const destWS = home.newDocWorkspace.get();\n  if (!destWS) { return; }\n  try {\n    const docId = await home.createDoc(t(\"Untitled document\"), destWS === \"unsaved\" ? \"unsaved\" : destWS.id);\n    // Fetch doc information including urlId.\n    // TODO: consider changing API to return same response as a GET when creating an\n    // object, which is a semi-standard.\n    const doc = await home.app.api.getDoc(docId);\n    await urlState().pushUrl(docUrl(doc));\n  } catch (err) {\n    reportError(err);\n  }\n}\n\nexport async function importDocAndOpen(home: HomeModel) {\n  const destWS = home.newDocWorkspace.get();\n  if (!destWS) { return; }\n  const docId = await homeImports.docImport(home.app, destWS === \"unsaved\" ? \"unsaved\" : destWS.id);\n  if (docId) {\n    const doc = await home.app.api.getDoc(docId);\n    await urlState().pushUrl(docUrl(doc));\n  }\n}\n\nexport async function importFromPluginAndOpen(home: HomeModel, source: ImportSourceElement) {\n  try {\n    const destWS = home.newDocWorkspace.get();\n    if (!destWS) { return; }\n    const docId = await homeImports.importFromPlugin(\n      home.app,\n      destWS === \"unsaved\" ? \"unsaved\" : destWS.id,\n      source);\n    if (docId) {\n      const doc = await home.app.api.getDoc(docId);\n      await urlState().pushUrl(docUrl(doc));\n    }\n  } catch (err) {\n    reportError(err);\n  }\n}\n"
  },
  {
    "path": "app/client/ui/CreateTeamModal.ts",
    "content": "import { autoFocus } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ValidationGroup, Validator } from \"app/client/lib/Validator\";\nimport { AppModel, getHomeUrl } from \"app/client/models/AppModel\";\nimport { reportError, UserError } from \"app/client/models/errors\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink } from \"app/client/ui2018/buttons\";\nimport { mediaSmall, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { IModalControl, modal } from \"app/client/ui2018/modals\";\nimport { PlanSelection } from \"app/common/BillingAPI\";\nimport { TEAM_PLAN } from \"app/common/Features\";\nimport { checkSubdomainValidity } from \"app/common/orgNameUtils\";\nimport { UserAPIImpl } from \"app/common/UserAPI\";\n\nimport { Disposable, dom, DomContents, DomElementArg, input, makeTestId, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"CreateTeamModal\");\nconst testId = makeTestId(\"test-create-team-\");\n\nexport async function buildNewSiteModal(context: Disposable, options: {\n  appModel: AppModel,\n  plan?: PlanSelection,\n  onCreate?: () => void\n}): Promise<void> {\n  const { onCreate } = options;\n\n  showModal(\n    context,\n    (_owner: Disposable, ctrl: IModalControl) => dom.create(NewSiteModalContent, ctrl, onCreate),\n    dom.cls(cssModalIndex.className),\n  );\n}\n\nclass NewSiteModalContent extends Disposable {\n  private _page = Observable.create(this, \"createTeam\");\n  private _team = Observable.create(this, \"\");\n  private _domain = Observable.create(this, \"\");\n  private _ctrl: IModalControl;\n\n  constructor(\n    ctrl: IModalControl,\n    private _onCreate?: (planName: string) => void) {\n    super();\n    this._ctrl = ctrl;\n  }\n\n  public buildDom() {\n    const team = this._team;\n    const domain = this._domain;\n    const ctrl = this._ctrl;\n    return dom.domComputed(this._page, (pageValue) => {\n      switch (pageValue) {\n        case \"createTeam\": return buildTeamPage({\n          team,\n          domain,\n          create: () => this._createTeam(),\n          ctrl,\n        });\n        case \"teamSuccess\": return buildConfirm({ domain: domain.get() });\n      }\n    });\n  }\n\n  private async _createTeam() {\n    const api = new UserAPIImpl(getHomeUrl());\n    try {\n      await api.newOrg({ name: this._team.get(), domain: this._domain.get() });\n      this._page.set(\"teamSuccess\");\n      if (this._onCreate) {\n        this._onCreate(TEAM_PLAN);\n      }\n    } catch (err) {\n      reportError(err as Error);\n    }\n  }\n}\n\nexport function buildUpgradeModal(owner: Disposable, options: {\n  appModel: AppModel,\n  pickPlan?: PlanSelection,\n  reason?: \"upgrade\" | \"renew\",\n}): Promise<void> {\n  throw new UserError(t(`Billing is not supported in grist-core`));\n}\n\nexport class UpgradeButton extends Disposable {\n  constructor(_appModel: AppModel) {\n    super();\n  }\n\n  public buildDom() { return null; }\n}\n\nexport function buildConfirm({\n  domain,\n}: {\n  domain: string;\n}) {\n  return cssConfirmWrapper(\n    cssSparks(),\n    hspace(\"1.5em\"),\n    cssHeaderLine(t(\"Team site created\"), testId(\"confirmation\")),\n    hspace(\"2em\"),\n    bigPrimaryButtonLink(\n      urlState().setLinkUrl({ org: domain || undefined }), t(\"Go to your site\"), testId(\"confirmation-link\"),\n    ),\n  );\n}\n\nfunction buildTeamPage({\n  team,\n  domain,\n  create,\n  ctrl,\n}: {\n  team: Observable<string>;\n  domain: Observable<string>;\n  create: () => any;\n  ctrl: IModalControl;\n}) {\n  const disabled = Observable.create(null, false);\n  const group = new ValidationGroup();\n  async function click() {\n    disabled.set(true);\n    try {\n      if (!await group.validate()) {\n        return;\n      }\n      await create();\n    } finally {\n      if (!disabled.isDisposed()) {\n        disabled.set(false);\n      }\n    }\n  }\n  const clickOnEnter = dom.onKeyPress({\n    Enter: () => click(),\n  });\n  return cssWide(\n    dom.autoDispose(disabled),\n    cssHeaderLine(t(\"Work as a Team\"), testId(\"creation-title\")),\n    cssSubHeaderLine(t(\"Choose a name and url for your team site\")),\n    hspace(\"1.5em\"),\n    cssColumns(\n      cssSetup(\n        cssLabel(t(\"Team name\")),\n        cssRow(cssField(cssInput(\n          team,\n          { onInput: true },\n          autoFocus(),\n          group.inputReset(),\n          clickOnEnter,\n          testId(\"name\")))),\n        dom.create(Validator, group, t(\"Team name is required\"), () => !!team.get()),\n        hspace(\"2em\"),\n        cssLabel(t(\"Team url\")),\n        cssRow(\n          { style: \"align-items: baseline\" },\n          cssField(\n            { style: \"flex: 0 1 0; min-width: auto; margin-right: 5px\" },\n            dom.text(`${window.location.origin}/o/`)),\n          cssField(cssInput(\n            domain, { onInput: true }, clickOnEnter, group.inputReset(), testId(\"domain\"),\n          )),\n        ),\n        dom.create(Validator, group, t(\"Domain name is required\"), () => !!domain.get()),\n        dom.create(Validator, group, t(\"Domain name is invalid\"), () => checkSubdomainValidity(domain.get())),\n        cssButtonsRow(\n          bigBasicButton(\n            t(\"Cancel\"),\n            dom.on(\"click\", () => ctrl.close()),\n            testId(\"cancel\")),\n          bigPrimaryButton(t(\"Create site\"),\n            dom.on(\"click\", click),\n            dom.prop(\"disabled\", disabled),\n            testId(\"confirm\"),\n          ),\n        ),\n      ),\n    ),\n  );\n}\n\nfunction showModal(\n  context: Disposable,\n  content: (owner: Disposable, ctrl: IModalControl) => DomContents,\n  ...args: DomElementArg[]\n) {\n  let control!: IModalControl;\n  modal((ctrl, modalScope) => {\n    control = ctrl;\n    // When parent is being disposed and we are still visible, close the modal.\n    context.onDispose(() => {\n      // If the modal is already closed (disposed, do nothing)\n      if (modalScope.isDisposed()) {\n        return;\n      }\n      // If not, and parent is going away, close the modal.\n      ctrl.close();\n    });\n    return [\n      cssCreateTeamModal.cls(\"\"),\n      cssCloseButton(testId(\"close-modal\"), cssBigIcon(\"CrossBig\"), dom.on(\"click\", () => ctrl.close())),\n      content(modalScope, ctrl),\n    ];\n  }, { backerDomArgs: args });\n  return control;\n}\n\nfunction hspace(height: string) {\n  return dom(\"div\", { style: `height: ${height}` });\n}\n\nexport const cssCreateTeamModal = styled(\"div\", `\n  position: relative;\n  @media ${mediaSmall} {\n    & {\n      width: 100%;\n      min-width: unset;\n      padding: 24px 16px;\n    }\n  }\n`);\n\nconst cssConfirmWrapper = styled(\"div\", `\n  text-align: center;\n`);\n\nconst cssSparks = styled(\"div\", `\n  height: 48px;\n  width: 48px;\n  background-image: var(--icon-Sparks);\n  display: inline-block;\n  background-repeat: no-repeat;\n  &-small {\n    height: 20px;\n    width: 20px;\n    background-size: cover;\n  }\n`);\n\nconst cssColumns = styled(\"div\", `\n  display: flex;\n  gap: 60px;\n  flex-wrap: wrap;\n`);\n\nconst cssSetup = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex-grow: 1;\n`);\n\nconst cssHeaderLine = styled(\"div\", `\n  text-align: center;\n  font-size: 24px;\n  font-weight: 600;\n  margin-bottom: 16px;\n`);\n\nconst cssSubHeaderLine = styled(\"div\", `\n  text-align: center;\n  margin-bottom: 7px;\n`);\n\nconst cssLabel = styled(\"label\", `\n  font-weight: ${vars.headerControlTextWeight};\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.text};\n  line-height: 1.5em;\n  margin: 0px;\n  margin-bottom: 0.3em;\n`);\n\nconst cssWide = styled(\"div\", `\n  min-width: 760px;\n  @media ${mediaSmall} {\n    & {\n      min-width: unset;\n    }\n  }\n`);\n\nconst cssRow = styled(\"div\", `\n  display: flex;\n`);\n\nconst cssField = styled(\"div\", `\n  display: block;\n  flex: 1 1 0;\n  margin: 4px 0;\n  min-width: 120px;\n`);\n\nconst cssButtonsRow = styled(\"div\", `\n  display: flex;\n  justify-content: flex-end;\n  margin-top: 20px;\n  min-width: 250px;\n  gap: 10px;\n  flex-wrap: wrap;\n  @media ${mediaSmall} {\n    & {\n      margin-top: 60px;\n    }\n  }\n`);\n\nconst cssCloseButton = styled(\"div\", `\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  padding: 4px;\n  border-radius: 4px;\n  cursor: pointer;\n  --icon-color: ${theme.modalCloseButtonFg};\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssBigIcon = styled(icon, `\n  padding: 12px;\n`);\n\nconst cssModalIndex = styled(\"div\", `\n  z-index: ${vars.pricingModalZIndex}\n`);\n\nconst cssInput = styled(input, `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  font-size: ${vars.mediumFontSize};\n  height: 42px;\n  line-height: 16px;\n  width: 100%;\n  padding: 13px;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  outline: none;\n\n  &-invalid {\n    color: ${theme.inputInvalid};\n  }\n\n  &[type=number] {\n    -moz-appearance: textfield;\n  }\n  &[type=number]::-webkit-inner-spin-button,\n  &[type=number]::-webkit-outer-spin-button {\n    -webkit-appearance: none;\n    margin: 0;\n  }\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/CustomSectionConfig.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeTestId } from \"app/client/lib/domUtils\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport * as kf from \"app/client/lib/koForm\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { localStorageBoolObs } from \"app/client/lib/localStorageObs\";\nimport { ColumnToMapImpl } from \"app/client/models/ColumnToMap\";\nimport { ColumnRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport {\n  cssDeveloperLink,\n  cssWidgetMetadata,\n  cssWidgetMetadataName,\n  cssWidgetMetadataRow,\n  cssWidgetMetadataValue,\n  CUSTOM_URL_WIDGET_ID,\n  getWidgetName,\n  showCustomWidgetGallery,\n} from \"app/client/ui/CustomWidgetGallery\";\nimport { cssGroupLabel, cssHelp, cssLabel, cssRow, cssSeparator } from \"app/client/ui/RightPanelStyles\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { userTrustsCustomWidget } from \"app/client/ui/userTrustsCustomWidget\";\nimport { cssDragRow, cssFieldEntry, cssFieldLabel } from \"app/client/ui/VisibleFieldsConfig\";\nimport { basicButton, primaryButton, textButton } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssDragger } from \"app/client/ui2018/draggableList\";\nimport { textInput } from \"app/client/ui2018/editableLabel\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssOptionLabel, IOption, IOptionFull, menu, menuItem, menuText, select } from \"app/client/ui2018/menus\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { visuallyHidden } from \"app/client/ui2018/visuallyHidden\";\nimport { AccessLevel, ICustomWidget, isSatisfied, matchWidget } from \"app/common/CustomWidget\";\nimport { not, unwrap } from \"app/common/gutil\";\n\nimport {\n  bundleChanges,\n  Computed,\n  Disposable,\n  dom,\n  DomContents,\n  fromKo,\n  MultiHolder,\n  Observable,\n  styled,\n  UseCBOwner,\n} from \"grainjs\";\n\nconst t = makeT(\"CustomSectionConfig\");\n\nconst testId = makeTestId(\"test-config-widget-\");\n\nclass ColumnPicker extends Disposable {\n  constructor(\n    private _value: Observable<number | number[] | null>,\n    private _column: ColumnToMapImpl,\n    private _section: ViewSectionRec) {\n    super();\n  }\n\n  public buildDom() {\n    // Rewrite value to ignore old configuration when allowMultiple is switched.\n    const properValue = Computed.create(this, (use) => {\n      const value = use(this._value);\n      return Array.isArray(value) ? null : value;\n    });\n    properValue.onWrite(value => this._value.set(value || null));\n\n    const canBeMapped = Computed.create(this, (use) => {\n      return use(this._section.columns)\n        .filter(col => this._column.canByMapped(use(col.pureType)));\n    });\n\n    // This is a HACK, to refresh options only when the menu is opened (or closed)\n    // and not to track down all the dependencies. Otherwise the select menu won't\n    // be hidden when option is selected - there is a bug that prevents it from closing\n    // when list of options is changed.\n    const refreshTrigger = Observable.create(this, false);\n\n    const options = Computed.create(this, (use) => {\n      void use(refreshTrigger);\n\n      const columnsAsOptions: IOption<number | null>[] = use(canBeMapped)\n        .map(col => ({\n          value: col.getRowId(),\n          label: col.label.peek(),\n          icon: \"FieldColumn\",\n        }));\n\n      // For optional mappings, add 'Blank' option but only if the value is set.\n      // This option will allow to clear the selection.\n      if (this._column.optional && properValue.get()) {\n        columnsAsOptions.push({\n          value: 0,\n          // Another hack. Select doesn't allow to have different label for blank option and the default text.\n          // So we will render this label ourselves later using `renderOptionArgs`.\n          label: \"\",\n        });\n      }\n      return columnsAsOptions;\n    });\n\n    const isDisabled = Computed.create(this, (use) => {\n      return use(canBeMapped).length === 0;\n    });\n\n    const defaultLabel = this._column.typeDesc != \"any\" ?\n      t(\"Pick a {{columnType}} column\", { columnType: this._column.typeDesc }) :\n      t(\"Pick a column\");\n\n    return [\n      cssGroupLabel(\n        this._column.title,\n        this._column.optional ? cssSubLabel(t(\" (optional)\")) : null,\n        testId(\"label-for-\" + this._column.name),\n      ),\n      this._column.description ? cssHelp(\n        this._column.description,\n        testId(\"help-for-\" + this._column.name),\n      ) : null,\n      dom.maybe(not(isDisabled), () => [\n        cssRow(\n          dom.update(\n            select(\n              properValue,\n              options,\n              {\n                defaultLabel,\n                renderOptionArgs: (opt) => {\n                  // If there is a label, render it.\n                  // Otherwise show the 'Clear selection' label as a greyed out text.\n                  // This is the continuation of the hack from above - were we added an option\n                  // without a label.\n                  return (opt.label) ? null : [\n                    cssBlank(t(\"Clear selection\")),\n                    testId(\"clear-selection\"),\n                  ];\n                },\n              },\n            ),\n            dom.on(\"click\", () => {\n              // When the menu is opened or closed, refresh the options.\n              refreshTrigger.set(!refreshTrigger.get());\n            }),\n          ),\n          testId(\"mapping-for-\" + this._column.name),\n          testId(\"enabled\"),\n        ),\n      ]),\n      dom.maybe(isDisabled, () => [\n        cssRow(\n          cssDisabledSelect(\n            Observable.create(this, null),\n            [], {\n              disabled: true,\n              defaultLabel: t(\"No {{columnType}} columns in table.\", { columnType: this._column.typeDesc }),\n            },\n          ),\n          hoverTooltip(t(\"No {{columnType}} columns in table.\", { columnType: this._column.typeDesc })),\n          testId(\"mapping-for-\" + this._column.name),\n          testId(\"disabled\"),\n        ),\n      ]),\n    ];\n  }\n}\n\nclass ColumnListPicker extends Disposable {\n  constructor(\n    private _value: Observable<number | number[] | null>,\n    private _column: ColumnToMapImpl,\n    private _section: ViewSectionRec) {\n    super();\n  }\n\n  public buildDom() {\n    return dom.domComputed((use) => {\n      return [\n        cssLabel(this._column.title,\n          cssLabel.cls(\"-required\", !this._column.optional),\n          testId(\"label-for-\" + this._column.name),\n        ),\n        this._buildDraggableList(use),\n        this._buildAddColumn(),\n      ];\n    });\n  }\n\n  private _buildAddColumn() {\n    const owner = MultiHolder.create(null);\n\n    const notMapped = Computed.create(owner, (use) => {\n      const value = use(this._value) || [];\n      const mapped = !Array.isArray(value) ? [] : value;\n      return this._section.columns().filter(col => !mapped.includes(use(col.id)));\n    });\n\n    const typedColumns = Computed.create(owner, (use) => {\n      return use(notMapped).filter(this._typeFilter(use));\n    });\n\n    return [\n      cssRow(\n        dom.autoDispose(owner),\n        cssAddMapping(\n          cssAddIcon(\"Plus\"), t(\"Add\") + \" \" + this._column.title,\n          dom.cls(\"disabled\", use => use(notMapped).length === 0),\n          testId(\"disabled\", use => use(notMapped).length === 0),\n          menu(() => {\n            const wrongTypeCount = notMapped.get().length - typedColumns.get().length;\n            return [\n              ...typedColumns.get()\n                .map(col => menuItem(\n                  () => this._addColumn(col),\n                  col.label.peek(),\n                )),\n              wrongTypeCount > 0 ? menuText(\n                t(\"{{wrongTypeCount}} non-{{columnType}} columns are not shown\", {\n                  wrongTypeCount,\n                  columnType: this._column.type.toLowerCase(),\n                  count: wrongTypeCount,\n                }),\n                testId(\"map-message-\" + this._column.name),\n              ) : null,\n            ];\n          }),\n          testId(\"add-column-for-\" + this._column.name),\n        ),\n      ),\n    ];\n  }\n\n  // Helper method for filtering columns that can be picked by the widget.\n  private _typeFilter = (use = unwrap) => (col: ColumnRec | null) =>\n    !col ? false : this._column.canByMapped(use(col.pureType));\n\n  private _buildDraggableList(use: UseCBOwner) {\n    return dom.update(kf.draggableList(\n      this._readItems(use),\n      this._renderItem.bind(this, use),\n      {\n        itemClass: cssDragRow.className,\n        reorder: this._reorder.bind(this),\n        receive: this._addColumn.bind(this),\n        drag_indicator: cssDragger,\n      },\n    ), testId(\"map-list-for-\" + this._column.name));\n  }\n\n  private _readItems(use: UseCBOwner): ColumnRec[] {\n    let selectedRefs = (use(this._value) || []) as number[];\n    // Ignore if configuration was changed from what it was saved.\n    if (!Array.isArray(selectedRefs)) {\n      selectedRefs = [];\n    }\n    // Filter columns by type - when column type has changed since mapping.\n    const columns = use(this._section.columns).filter(this._typeFilter(use));\n    const columnMap = new Map(columns.map(c => [c.id.peek(), c]));\n    // Remove any columns that are no longer there.\n    return selectedRefs.map(s => columnMap.get(s)!).filter(c => Boolean(c));\n  }\n\n  private _renderItem(use: UseCBOwner, field: ColumnRec): any {\n    return cssFieldEntry(\n      cssFieldLabel(\n        dom.text(field.label),\n        testId(\"ref-select-label\"),\n      ),\n      cssRemoveIcon(\n        \"Remove\",\n        dom.on(\"click\", () => this._remove(field)),\n        testId(\"ref-select-remove\"),\n      ),\n    );\n  }\n\n  // Helper method that for accessing mapped columns. Can be used to set and retrieve the value.\n  private _list(value: number[]): void;\n  private _list(): number[];\n  private _list(value?: number[]) {\n    if (value) {\n      this._value.set(value);\n    } else {\n      let current = (this._value.get() || []) as number[];\n      // Ignore if the saved value is not a number list.\n      if (!Array.isArray(current)) {\n        current = [];\n      }\n      return current;\n    }\n  }\n\n  private _reorder(column: ColumnRec, nextColumn: ColumnRec | null): any {\n    const id = column.id.peek();\n    const nextId = nextColumn?.id.peek();\n    const currentList = this._list();\n    const indexOfId = currentList.indexOf(id);\n    // Remove element from the list.\n    currentList.splice(indexOfId, 1);\n    const indexOfNext = nextId ? currentList.indexOf(nextId) : currentList.length;\n    // Insert before next element or at the end.\n    currentList.splice(indexOfNext, 0, id);\n    this._list(currentList);\n  }\n\n  private _remove(column: ColumnRec): any {\n    const current = this._list();\n    this._value.set(current.filter(c => c != column.id.peek()));\n  }\n\n  private _addColumn(col: ColumnRec): any {\n    // Helper to find column model.\n    const model = (id: number) => this._section.columns().find(c => c.id.peek() === id) || null;\n    // Get the list of currently mapped columns.\n    let current = this._list();\n    // Add new column.\n    current.push(col.id.peek());\n    // Remove those that don't exists anymore.\n    current = current.filter(c => model(c));\n    // And those with wrong type.\n    current = current.filter(c => this._typeFilter()(model(c)));\n    this._value.set(current);\n  }\n}\n\nclass CustomSectionConfigurationConfig extends Disposable {\n  private readonly _hasConfiguration = Computed.create(this, use =>\n    Boolean(use(this._section.hasCustomOptions) || use(this._section.columnsToMap)));\n\n  constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {\n    super();\n  }\n\n  public buildDom() {\n    return dom.maybe(this._hasConfiguration, () => [\n      cssSeparator(),\n      dom.maybe(this._section.hasCustomOptions, () =>\n        cssSection(\n          textButton(\n            t(\"Open configuration\"),\n            dom.on(\"click\", () => this._openConfiguration()),\n            testId(\"open-configuration\"),\n          ),\n        ),\n      ),\n      dom.maybeOwned(use => use(this._section.columnsToMap), (owner, columns) => {\n        const createObs = (column: ColumnToMapImpl) => {\n          const obs = Computed.create(owner, (use) => {\n            const savedDefinition = use(this._section.customDef.columnsMapping) || {};\n            return savedDefinition[column.name];\n          });\n          obs.onWrite(async (value) => {\n            const savedDefinition = this._section.customDef.columnsMapping.peek() || {};\n            savedDefinition[column.name] = value;\n            await this._section.customDef.columnsMapping.setAndSave(savedDefinition);\n          });\n          return obs;\n        };\n        // Create observables for all columns to pick.\n        const mappings = columns.map(c => new ColumnToMapImpl(c)).map(column => ({\n          value: createObs(column),\n          column,\n        }));\n        return dom(\"div\",\n          this._attachColumnMappingTip(this._section.customDef.url()),\n          ...mappings.map(m => m.column.allowMultiple ?\n            dom.create(ColumnListPicker, m.value, m.column, this._section) :\n            dom.create(ColumnPicker, m.value, m.column, this._section)),\n        );\n      }),\n    ]);\n  }\n\n  private _openConfiguration(): void {\n    allCommands.openWidgetConfiguration.run();\n  }\n\n  private _attachColumnMappingTip(widgetUrl: string | null) {\n    switch (widgetUrl) {\n      // TODO: come up with a way to attach tips without hardcoding widget URLs.\n      case \"https://gristlabs.github.io/grist-widget/calendar/index.html\": {\n        return this._gristDoc.behavioralPromptsManager.attachPopup(\"calendarConfig\", {\n          popupOptions: { placement: \"left-start\" },\n        });\n      }\n      default: {\n        return null;\n      }\n    }\n  }\n}\n\n/**\n * Custom widget configuration.\n *\n * Allows picking a custom widget from a gallery of available widgets\n * (fetched from the `/widgets` endpoint), which includes the Custom URL\n * widget.\n *\n * When a custom widget has a desired `accessLevel` set to a value other\n * than `\"None\"`, a prompt will be shown to grant the requested access level\n * to the widget.\n *\n * When `gristConfig.enableWidgetRepository` is set to false, only the\n * Custom URL widget will be available to select in the gallery.\n */\nexport class CustomSectionConfig extends Disposable {\n  protected _customSectionConfigurationConfig = new CustomSectionConfigurationConfig(\n    this._section, this._gristDoc);\n\n  private readonly _widgetId = Computed.create(this, (use) => {\n    // Stored in one of two places, depending on age of document.\n    const widgetId = use(this._section.customDef.widgetId) ||\n      use(this._section.customDef.widgetDef)?.widgetId;\n    if (widgetId) {\n      const pluginId = use(this._section.customDef.pluginId);\n      return (pluginId || \"\") + \":\" + widgetId;\n    } else {\n      return CUSTOM_URL_WIDGET_ID;\n    }\n  });\n\n  private readonly _isCustomUrlWidget = Computed.create(this, this._widgetId, (_use, widgetId) => {\n    return widgetId === CUSTOM_URL_WIDGET_ID;\n  });\n\n  private readonly _currentAccess = Computed.create(this, use =>\n    (use(this._section.customDef.access) as AccessLevel) || AccessLevel.none)\n    .onWrite(async (newAccess) => {\n      await this._section.customDef.access.setAndSave(newAccess);\n    });\n\n  private readonly _desiredAccess = fromKo(this._section.desiredAccessLevel);\n\n  private readonly _url = Computed.create(this, use => use(this._section.customDef.url) || \"\")\n    .onWrite(async (newUrl) => {\n      bundleChanges(() => {\n        this._section.customDef.renderAfterReady(false);\n        if (newUrl) {\n          this._section.customDef.widgetId(null);\n          this._section.customDef.pluginId(\"\");\n          this._section.customDef.widgetDef(null);\n        }\n        this._section.customDef.url(newUrl);\n      });\n      await this._section.saveCustomDef();\n    });\n\n  private readonly _requiresAccess = Computed.create(this, (use) => {\n    const [currentAccess, desiredAccess] = [use(this._currentAccess), use(this._desiredAccess)];\n    return desiredAccess && !isSatisfied(currentAccess, desiredAccess);\n  });\n\n  private readonly _widgetDetailsExpanded: Observable<boolean>;\n\n  private readonly _widgets: Observable<ICustomWidget[] | null> = Observable.create(this, null);\n\n  private readonly _selectedWidget = Computed.create(this, (use) => {\n    const id = use(this._widgetId);\n    if (id === CUSTOM_URL_WIDGET_ID) { return null; }\n\n    const widgets = use(this._widgets);\n    if (!widgets) { return null; }\n\n    const [pluginId, widgetId] = id.split(\":\");\n    return matchWidget(widgets, { pluginId, widgetId }) ?? null;\n  });\n\n  constructor(protected _section: ViewSectionRec, private _gristDoc: GristDoc) {\n    super();\n\n    const userId = this._gristDoc.appModel.currentUser?.id ?? 0;\n    this._widgetDetailsExpanded = this.autoDispose(localStorageBoolObs(\n      `u:${userId};customWidgetDetailsExpanded`,\n      true,\n    ));\n\n    this._getWidgets()\n      .then((widgets) => {\n        if (this.isDisposed()) { return; }\n\n        this._widgets.set(widgets);\n      })\n      .catch(reportError);\n\n    // Clear intermediate state when section changes.\n    this.autoDispose(_section.id.subscribe(() => this._dismissAccessPrompt()));\n  }\n\n  public buildDom(): DomContents {\n    return dom(\"div\",\n      this._buildWidgetSelector(),\n      this._buildAccessLevelConfig(),\n      this._customSectionConfigurationConfig.buildDom(),\n    );\n  }\n\n  protected shouldRenderWidgetSelector(): boolean {\n    return true;\n  }\n\n  protected async _getWidgets() {\n    return await this._gristDoc.app.topAppModel.getWidgets();\n  }\n\n  private _buildWidgetSelector() {\n    if (!this.shouldRenderWidgetSelector()) { return null; }\n\n    return [\n      cssRow(\n        cssWidgetSelector(\n          this._buildShowWidgetDetailsButton(),\n          this._buildWidgetName(),\n        ),\n      ),\n      this._maybeBuildWidgetDetails(),\n    ];\n  }\n\n  private _buildShowWidgetDetailsButton() {\n    return cssShowWidgetDetails(\n      cssShowWidgetDetailsIcon(\n        \"Dropdown\",\n        cssShowWidgetDetailsIcon.cls(\"-collapsed\", use => !use(this._widgetDetailsExpanded)),\n        testId(\"toggle-custom-widget-details\"),\n        testId(use => !use(this._widgetDetailsExpanded) ?\n          \"show-custom-widget-details\" :\n          \"hide-custom-widget-details\",\n        ),\n      ),\n      cssWidgetLabel(t(\"Widget\")),\n      dom.on(\"click\", () => {\n        this._widgetDetailsExpanded.set(!this._widgetDetailsExpanded.get());\n      }),\n      dom.attr(\"aria-expanded\", use => use(this._widgetDetailsExpanded) ? \"true\" : \"false\"),\n      { \"aria-controls\": \"custom-widget-details\" },\n    );\n  }\n\n  private _buildWidgetName() {\n    // The widget name is a button that opens the widget gallery when clicked:\n    // make sure that screen reader users understand that, with a visually hidden text.\n    return cssWidgetName(\n      dom.domComputed((use) => {\n        let visibleText = null;\n        if (use(this._isCustomUrlWidget)) {\n          visibleText = t(\"Custom URL\");\n        } else {\n          const widget = use(this._selectedWidget) ?? use(this._section.customDef.widgetDef);\n          visibleText = widget ? getWidgetName(widget) : use(this._widgetId);\n        }\n        return [\n          dom(\"span\", visibleText, testId(\"open-custom-widget-gallery\")),\n          visuallyHidden(t(\"Change custom widget\")),\n        ];\n      }),\n      dom.on(\"click\", () => showCustomWidgetGallery(this._gristDoc, {\n        sectionRef: this._section.id(),\n      })),\n    );\n  }\n\n  private _maybeBuildWidgetDetails() {\n    return dom.maybe(this._widgetDetailsExpanded, () =>\n      dom.domComputed(this._selectedWidget, widget =>\n        cssRow(\n          this._buildWidgetDetails(widget),\n          { id: \"custom-widget-details\" },\n        ),\n      ),\n    );\n  }\n\n  private _buildWidgetDetails(widget: ICustomWidget | null) {\n    return dom.domComputed(this._isCustomUrlWidget, (isCustomUrlWidget) => {\n      if (isCustomUrlWidget) {\n        return cssCustomUrlDetails(\n          cssTextInput(\n            this._url,\n            async (value) => {\n              if (!value.length || await userTrustsCustomWidget()) {\n                return this._url.set(value);\n              }\n              return this._url.set(this._url.get());\n            },\n            dom.show(this._isCustomUrlWidget),\n            { placeholder: t(\"Enter Custom URL\"), type: \"url\" },\n          ),\n        );\n      } else if (!widget?.description && !widget?.authors?.[0] && !widget?.lastUpdatedAt) {\n        return cssDetailsMessage(t(\"Missing description and author information.\"));\n      } else {\n        return cssWidgetDetails(\n          !widget?.description ? null : cssWidgetDescription(\n            widget.description,\n            testId(\"custom-widget-description\"),\n          ),\n          cssWidgetMetadata(\n            !widget?.authors?.[0] ? null : cssWidgetMetadataRow(\n              cssWidgetMetadataName(t(\"Developer:\")),\n              cssWidgetMetadataValue(\n                widget.authors[0].url ?\n                  cssDeveloperLink(\n                    widget.authors[0].name,\n                    { href: widget.authors[0].url, target: \"_blank\" },\n                    testId(\"custom-widget-developer\"),\n                  ) :\n                  dom(\"span\",\n                    widget.authors[0].name,\n                    testId(\"custom-widget-developer\"),\n                  ),\n                testId(\"custom-widget-developer\"),\n              ),\n            ),\n            !widget?.lastUpdatedAt ? null : cssWidgetMetadataRow(\n              cssWidgetMetadataName(t(\"Last updated:\")),\n              cssWidgetMetadataValue(\n                new Date(widget.lastUpdatedAt).toLocaleDateString(\"default\", {\n                  month: \"long\",\n                  day: \"numeric\",\n                  year: \"numeric\",\n                }),\n                testId(\"custom-widget-last-updated\"),\n              ),\n            ),\n          ),\n        );\n      }\n    });\n  }\n\n  private _buildAccessLevelConfig() {\n    return [\n      cssSeparator({ style: \"margin-top: 0px\" }),\n      cssGroupLabel(t(\"ACCESS LEVEL\")),\n      cssRow(select(this._currentAccess, getAccessLevels()), testId(\"access\")),\n      dom.maybeOwned(this._requiresAccess, owner => kf.prompt(\n        (elem: HTMLDivElement) => { FocusLayer.create(owner, { defaultFocusElem: elem, pauseMousetrap: true }); },\n        cssColumns(\n          cssWarningWrapper(icon(\"Lock\")),\n          dom(\"div\",\n            cssConfirmRow(\n              dom.domComputed(this._desiredAccess, level => this._buildAccessLevelPrompt(level)),\n            ),\n            cssConfirmRow(\n              primaryButton(\n                t(\"Accept\"),\n                testId(\"access-accept\"),\n                dom.on(\"click\", () => this._grantDesiredAccess()),\n              ),\n              basicButton(\n                t(\"Reject\"),\n                testId(\"access-reject\"),\n                dom.on(\"click\", () => this._dismissAccessPrompt()),\n              ),\n            ),\n          ),\n        ),\n        dom.onKeyDown({\n          Enter: () => this._grantDesiredAccess(),\n          Escape: () => this._dismissAccessPrompt(),\n        }),\n      )),\n    ];\n  }\n\n  private _buildAccessLevelPrompt(level: AccessLevel | null) {\n    if (!level) { return null; }\n\n    switch (level) {\n      case AccessLevel.none: {\n        return cssConfirmLine(t(\"Widget does not require any permissions.\"));\n      }\n      case AccessLevel.read_table: {\n        return cssConfirmLine(t(\"Widget needs to {{read}} the current table.\", { read: dom(\"b\", \"read\") }));\n      }\n      case AccessLevel.full: {\n        return cssConfirmLine(t(\"Widget needs {{fullAccess}} to this document.\", {\n          fullAccess: dom(\"b\", \"full access\"),\n        }));\n      }\n    }\n  }\n\n  private _grantDesiredAccess() {\n    if (this._desiredAccess.get()) {\n      this._currentAccess.set(this._desiredAccess.get()!);\n    }\n    this._dismissAccessPrompt();\n  }\n\n  private _dismissAccessPrompt() {\n    this._desiredAccess.set(null);\n  }\n}\n\nfunction getAccessLevels(): IOptionFull<string>[] {\n  return [\n    { label: t(\"No document access\"), value: AccessLevel.none },\n    { label: t(\"Read selected table\"), value: AccessLevel.read_table },\n    { label: t(\"Full document access\"), value: AccessLevel.full },\n  ];\n}\n\nconst cssWarningWrapper = styled(\"div\", `\n  padding-left: 8px;\n  padding-top: 6px;\n  --icon-color: ${theme.iconError}\n`);\n\nconst cssColumns = styled(\"div\", `\n  display: flex;\n`);\n\nconst cssConfirmRow = styled(\"div\", `\n  display: flex;\n  padding: 8px;\n  gap: 8px;\n`);\n\nconst cssConfirmLine = styled(\"span\", `\n  white-space: pre-wrap;\n`);\n\nconst cssSection = styled(\"div\", `\n  margin: 16px 16px 12px 16px;\n`);\n\nconst cssAddIcon = styled(icon, `\n  margin-right: 4px;\n`);\n\nconst cssRemoveIcon = styled(icon, `\n  display: none;\n  cursor: pointer;\n  flex: none;\n  margin-left: 8px;\n  .${cssFieldEntry.className}:hover & {\n    display: block;\n  }\n`);\n\n// Additional text in label (greyed out)\nconst cssSubLabel = styled(\"span\", `\n  text-transform: none;\n  font-size: ${vars.xsmallFontSize};\n  color: ${theme.lightText};\n`);\n\nconst cssAddMapping = styled(\"div\", `\n  display: flex;\n  cursor: pointer;\n  color: ${theme.controlFg};\n  --icon-color: ${theme.controlFg};\n\n  &:not(:first-child) {\n    margin-top: 8px;\n  }\n  &:hover, &:focus, &:active {\n    color: ${theme.controlHoverFg};\n    --icon-color: ${theme.controlHoverFg};\n  }\n  &.disabled {\n    color: ${theme.lightText};\n    --icon-color: ${theme.lightText};\n    pointer-events: none;\n  }\n`);\n\nconst cssTextInput = styled(textInput, `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\nconst cssDisabledSelect = styled(select, `\n  opacity: unset !important;\n`);\n\nconst cssBlank = styled(cssOptionLabel, `\n  --grist-option-label-color: ${theme.lightText};\n`);\n\nconst cssWidgetSelector = styled(\"div\", `\n  width: 100%;\n  display: flex;\n  justify-content: space-between;\n  column-gap: 16px;\n`);\n\nconst cssShowWidgetDetails = styled(unstyledButton, `\n  display: flex;\n  align-items: center;\n  column-gap: 4px;\n  cursor: pointer;\n`);\n\nconst cssShowWidgetDetailsIcon = styled(icon, `\n  --icon-color: ${theme.lightText};\n  flex-shrink: 0;\n\n  &-collapsed {\n    transform: rotate(-90deg);\n  }\n`);\n\nconst cssWidgetLabel = styled(\"div\", `\n  text-transform: uppercase;\n  font-size: ${vars.xsmallFontSize};\n`);\n\nconst cssWidgetName = styled(unstyledButton, `\n  color: ${theme.rightPanelCustomWidgetButtonFg};\n  background-color: ${theme.rightPanelCustomWidgetButtonBg};\n  height: 24px;\n  padding: 4px 8px;\n  border-radius: 4px;\n  cursor: pointer;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n`);\n\nconst cssWidgetDetails = styled(\"div\", `\n  margin-top: 8px;\n  display: flex;\n  flex-direction: column;\n  margin-bottom: 8px;\n`);\n\nconst cssCustomUrlDetails = styled(cssWidgetDetails, `\n  flex: 1 0 auto;\n`);\n\nconst cssDetailsMessage = styled(\"div\", `\n  color: ${theme.lightText};\n`);\n\nconst cssWidgetDescription = styled(\"div\", `\n  margin-bottom: 16px;\n`);\n"
  },
  {
    "path": "app/client/ui/CustomThemes.ts",
    "content": "// TODO: document this all, no tests are exercising this code.\n\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { styled } from \"grainjs\";\n\n/**\n * Is this grist installation or someone's modified installation. We allow modifying logo\n * at the right corner, and making it wider (removing site switcher in the process).\n *\n * If fieldLink, shows wide logo and hides the switcher, otherwise shows the regular logo.\n *\n * We can convert any org name to a ProductFlavor and any ProductFlavor to a CustomTheme.\n *\n * TODO: explain what is fieldlink, I think this is an user of custom Grist build.\n */\nexport type ProductFlavor = \"grist\" | \"fieldlink\";\n\nexport interface CustomTheme {\n  bodyClassName?: string;\n  wideLogo?: boolean;   // Stretch the logo and hide the org name.\n}\n\nexport function getFlavor(org?: string): ProductFlavor {\n  // Using a URL parameter e.g. __themeOrg=fieldlink allows overriding the org used for custom\n  // theming, for testing.\n  const themeOrg = new URLSearchParams(window.location.search).get(\"__themeOrg\");\n  if (themeOrg) { org = themeOrg; }\n\n  // If still not set, use the org from the config.\n  org ||= getGristConfig()?.org;\n\n  // If the org is 'fieldlink', use the fieldlink flavor.\n  if (org === \"fieldlink\") {\n    return \"fieldlink\";\n  }\n\n  // For any other situation, use the grist flavor.\n  return \"grist\";\n}\n\nexport function getTheme(flavor: ProductFlavor): CustomTheme {\n  switch (flavor) {\n    case \"fieldlink\":\n      return {\n        wideLogo: true,\n        bodyClassName: cssFieldLinkBody.className,\n      };\n    default:\n      return {};\n  }\n}\n\nconst cssFieldLinkBody = styled(\"body\", `\n  --icon-GristLogo: url(\"icons/logo-fieldlink.png\");\n  --icon-GristWideLogo: url(\"icons/logo-fieldlink.png\");\n  --grist-logo-bg: white;\n`);\n"
  },
  {
    "path": "app/client/ui/CustomWidgetGallery.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { textInput } from \"app/client/ui/inputs\";\nimport { shadowScroll } from \"app/client/ui/shadowScroll\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { userTrustsCustomWidget } from \"app/client/ui/userTrustsCustomWidget\";\nimport { bigBasicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { IModalControl, modal } from \"app/client/ui2018/modals\";\nimport { AccessLevel, ICustomWidget, matchWidget, WidgetAuthor } from \"app/common/CustomWidget\";\nimport { commonUrls } from \"app/common/gristUrls\";\n\nimport { bundleChanges, Computed, Disposable, dom, makeTestId, Observable, styled } from \"grainjs\";\nimport escapeRegExp from \"lodash/escapeRegExp\";\n\nconst testId = makeTestId(\"test-custom-widget-gallery-\");\n\nconst t = makeT(\"CustomWidgetGallery\");\n\nexport const CUSTOM_URL_WIDGET_ID = \"custom\";\n\ninterface Options {\n  sectionRef?: number;\n  addWidget?(): Promise<{ viewRef: number, sectionRef: number }>;\n}\n\nexport function showCustomWidgetGallery(gristDoc: GristDoc, options: Options = {}) {\n  modal(ctl => [\n    dom.create(CustomWidgetGallery, ctl, gristDoc, options),\n    cssModal.cls(\"\"),\n  ]);\n}\n\ninterface WidgetInfo {\n  variant: WidgetVariant;\n  id: string;\n  name: string;\n  description?: string;\n  developer?: WidgetAuthor;\n  lastUpdated?: string;\n}\n\ninterface CustomWidgetACItem extends ICustomWidget {\n  cleanText: string;\n}\n\ntype WidgetVariant = \"custom\" | \"grist\" | \"community\";\n\nclass CustomWidgetGallery extends Disposable {\n  private readonly _customUrl: Observable<string>;\n  private _customUrlInput: HTMLInputElement;\n  private readonly _filteredWidgets = Observable.create<ICustomWidget[] | null>(this, null);\n  private readonly _section: ViewSectionRec | null = null;\n  private readonly _searchText = Observable.create(this, \"\");\n  private readonly _saveDisabled: Computed<boolean>;\n  private readonly _savedWidgetId: Computed<string | null>;\n  private readonly _selectedWidgetId = Observable.create<string | null>(this, null);\n  private readonly _widgets = Observable.create<CustomWidgetACItem[] | null>(this, null);\n\n  constructor(\n    private _ctl: IModalControl,\n    private _gristDoc: GristDoc,\n    private _options: Options = {},\n  ) {\n    super();\n\n    const { sectionRef } = _options;\n    if (sectionRef) {\n      const section = this._gristDoc.docModel.viewSections.getRowModel(sectionRef);\n      if (!section.id.peek()) {\n        throw new Error(`Section ${sectionRef} does not exist`);\n      }\n\n      this._section = section;\n      this.autoDispose(section._isDeleted.subscribe((isDeleted) => {\n        if (isDeleted) { this._ctl.close(); }\n      }));\n    }\n\n    let customUrl = \"\";\n    if (this._section) {\n      customUrl = this._section.customDef.url() ?? \"\";\n    }\n    this._customUrl = Observable.create(this, customUrl);\n\n    this._savedWidgetId = Computed.create(this, (use) => {\n      if (!this._section) { return null; }\n\n      const { customDef } = this._section;\n      // May be stored in one of two places, depending on age of document.\n      const widgetId = use(customDef.widgetId) || use(customDef.widgetDef)?.widgetId;\n      if (widgetId) {\n        const pluginId = use(customDef.pluginId);\n        const widget = matchWidget(use(this._widgets) ?? [], {\n          widgetId,\n          pluginId,\n        });\n        return widget ? `${pluginId}:${widgetId}` : null;\n      } else {\n        return CUSTOM_URL_WIDGET_ID;\n      }\n    });\n\n    this._saveDisabled = Computed.create(this, (use) => {\n      const selectedWidgetId = use(this._selectedWidgetId);\n      return selectedWidgetId === null;\n    });\n\n    this._initializeWidgets().catch(reportError);\n\n    this.autoDispose(this._searchText.addListener(() => {\n      this._filterWidgets();\n      this._selectedWidgetId.set(null);\n    }));\n  }\n\n  public buildDom() {\n    return cssCustomWidgetGallery(\n      cssHeader(\n        cssTitle(t(\"Choose custom widget\")),\n        cssSearchInputWrapper(\n          cssSearchIcon(\"Search\"),\n          cssSearchInput(\n            this._searchText,\n            { placeholder: t(\"Search\") },\n            (el) => { setTimeout(() => el.focus(), 10); },\n            testId(\"search\"),\n          ),\n        ),\n      ),\n      shadowScroll(\n        this._buildWidgets(),\n        cssShadowScroll.cls(\"\"),\n      ),\n      cssFooter(\n        dom(\"div\",\n          cssHelpLink(\n            { href: commonUrls.helpCustomWidgets, target: \"_blank\" },\n            cssHelpIcon(\"Question\"),\n            t(\"Learn more about custom widgets\"),\n          ),\n        ),\n        cssFooterButtons(\n          bigBasicButton(\n            t(\"Cancel\"),\n            dom.on(\"click\", () => this._ctl.close()),\n            testId(\"cancel\"),\n          ),\n          bigPrimaryButton(\n            this._options.addWidget ? t(\"Add widget\") : t(\"Change widget\"),\n            dom.on(\"click\", () => this._save()),\n            dom.boolAttr(\"disabled\", this._saveDisabled),\n            testId(\"save\"),\n          ),\n        ),\n      ),\n      dom.onKeyDown({\n        Enter: () => this._save(),\n        Escape: () => this._deselectOrClose(),\n      }),\n      dom.on(\"click\", ev => this._maybeClearSelection(ev)),\n      testId(\"container\"),\n    );\n  }\n\n  private async _initializeWidgets() {\n    const widgets: ICustomWidget[] = [\n      {\n        widgetId: \"custom\",\n        name: t(\"Custom URL\"),\n        description: t(\"Add a widget from outside this gallery.\"),\n        url: \"\",\n      },\n    ];\n    try {\n      const remoteWidgets = await this._gristDoc.appModel.topAppModel.getWidgets();\n      if (this.isDisposed()) { return; }\n\n      widgets.push(...remoteWidgets\n        .filter(({ published }) => published !== false)\n        .sort((a, b) => a.name.localeCompare(b.name)));\n    } catch (e) {\n      reportError(e);\n    }\n\n    this._widgets.set(widgets.map(w => ({ ...w, cleanText: getWidgetCleanText(w) })));\n    this._selectedWidgetId.set(this._savedWidgetId.get());\n    this._filterWidgets();\n  }\n\n  private _filterWidgets() {\n    const widgets = this._widgets.get();\n    if (!widgets) { return; }\n\n    const searchText = this._searchText.get();\n    if (!searchText) {\n      this._filteredWidgets.set(widgets);\n    } else {\n      const searchTerms = searchText.trim().split(/\\s+/);\n      const searchPatterns = searchTerms.map(term =>\n        new RegExp(`\\\\b${escapeRegExp(term)}`, \"i\"));\n      const filteredWidgets = widgets.filter(({ cleanText }) =>\n        searchPatterns.some(pattern => pattern.test(cleanText)),\n      );\n      this._filteredWidgets.set(filteredWidgets);\n    }\n  }\n\n  private _buildWidgets() {\n    return dom.domComputed(this._filteredWidgets, (widgets) => {\n      if (widgets === null) {\n        return cssLoadingSpinner(loadingSpinner());\n      } else if (widgets.length === 0) {\n        return cssNoMatchingWidgets(t(\"No matching widgets\"));\n      } else {\n        return cssWidgets(\n          widgets.map((widget) => {\n            const { description, authors = [], lastUpdatedAt } = widget;\n\n            return this._buildWidget({\n              variant: getWidgetVariant(widget),\n              id: getWidgetId(widget),\n              name: getWidgetName(widget),\n              description,\n              developer: authors[0],\n              lastUpdated: lastUpdatedAt,\n            });\n          }),\n        );\n      }\n    });\n  }\n\n  private _buildWidget(info: WidgetInfo) {\n    const { variant, id, name, description, developer, lastUpdated } = info;\n\n    return cssWidget(\n      dom.cls(\"custom-widget\"),\n      cssWidgetHeader(\n        variant === \"custom\" ? t(\"Add Your Own Widget\") :\n          variant === \"grist\" ? t(\"Grist Widget\") :\n            withInfoTooltip(\n              t(\"Community Widget\"),\n              \"communityWidgets\",\n              {\n                variant: \"hover\",\n                iconDomArgs: [cssTooltipIcon.cls(\"\")],\n              },\n            ),\n        cssWidgetHeader.cls(\"-secondary\", [\"custom\", \"community\"].includes(variant)),\n      ),\n      cssWidgetBody(\n        cssWidgetName(\n          name,\n          testId(\"widget-name\"),\n        ),\n        cssWidgetDescription(\n          description ?? t(\"(Missing info)\"),\n          cssWidgetDescription.cls(\"-missing\", !description),\n          testId(\"widget-description\"),\n        ),\n        variant === \"custom\" ? null : cssWidgetMetadata(\n          variant === \"grist\" ? null : cssWidgetMetadataRow(\n            cssWidgetMetadataName(t(\"Developer:\")),\n            cssWidgetMetadataValue(\n              developer?.url ?\n                cssDeveloperLink(\n                  developer.name,\n                  { href: developer.url, target: \"_blank\" },\n                  dom.on(\"click\", ev => ev.stopPropagation()),\n                  testId(\"widget-developer\"),\n                ) :\n                dom(\"span\",\n                  developer?.name ?? t(\"(Missing info)\"),\n                  testId(\"widget-developer\"),\n                ),\n              cssWidgetMetadataValue.cls(\"-missing\", !developer?.name),\n              testId(\"widget-developer\"),\n            ),\n          ),\n          cssWidgetMetadataRow(\n            cssWidgetMetadataName(t(\"Last updated:\")),\n            cssWidgetMetadataValue(\n              lastUpdated ?\n                new Date(lastUpdated).toLocaleDateString(\"default\", {\n                  month: \"long\",\n                  day: \"numeric\",\n                  year: \"numeric\",\n                }) :\n                t(\"(Missing info)\"),\n              cssWidgetMetadataValue.cls(\"-missing\", !lastUpdated),\n              testId(\"widget-last-updated\"),\n            ),\n          ),\n          testId(\"widget-metadata\"),\n        ),\n        variant !== \"custom\" ? null : cssCustomUrlInput(\n          this._customUrl,\n          (el) => {\n            this._customUrlInput = el as HTMLInputElement;\n          },\n          { placeholder: t(\"Widget URL\"), type: \"url\" },\n          testId(\"custom-url\"),\n        ),\n      ),\n      cssWidget.cls(\"-selected\", use => id === use(this._selectedWidgetId)),\n      dom.on(\"click\", () => this._selectedWidgetId.set(id)),\n      testId(\"widget\"),\n      testId(`widget-${variant}`),\n    );\n  }\n\n  private async _save() {\n    if (this._saveDisabled.get()) { return; }\n\n    if (await this._validateSelectedWidget()) {\n      await this._saveSelectedWidget();\n      this._ctl.close();\n    }\n  }\n\n  /**\n   * Check if the selected widget is valid:\n   * - it is by default for all widgets\n   * - it is for \"custom url widgets\" if the url input follows url format and the user confirmed the security risk modal\n   */\n  private async _validateSelectedWidget() {\n    const isCustomUrlWidget = this._selectedWidgetId.get() === CUSTOM_URL_WIDGET_ID;\n    if (isCustomUrlWidget) {\n      // reportValidity will trigger native browser validation, showing a message to the user if the url is invalid\n      const isValidUrl = this._customUrlInput?.reportValidity();\n      const isEmptyUrl = !this._customUrl.get().length;\n      return isEmptyUrl || (isValidUrl && await userTrustsCustomWidget());\n    }\n    return true;\n  }\n\n  private async _deselectOrClose() {\n    if (this._selectedWidgetId.get()) {\n      this._selectedWidgetId.set(null);\n    } else {\n      this._ctl.close();\n    }\n  }\n\n  private async _saveSelectedWidget() {\n    await this._gristDoc.docData.bundleActions(\n      \"Save selected custom widget\",\n      async () => {\n        let section = this._section;\n        if (!section) {\n          const { addWidget } = this._options;\n          if (!addWidget) {\n            throw new Error(\"Cannot add custom widget: missing `addWidget` implementation\");\n          }\n\n          const { sectionRef } = await addWidget();\n          const newSection = this._gristDoc.docModel.viewSections.getRowModel(sectionRef);\n          if (!newSection.id.peek()) {\n            throw new Error(`Section ${sectionRef} does not exist`);\n          }\n          section = newSection;\n        }\n        const selectedWidgetId = this._selectedWidgetId.get();\n        if (selectedWidgetId === CUSTOM_URL_WIDGET_ID) {\n          return this._saveCustomUrlWidget(section);\n        } else {\n          return this._saveRemoteWidget(section);\n        }\n      },\n    );\n  }\n\n  private async _saveCustomUrlWidget(section: ViewSectionRec) {\n    bundleChanges(() => {\n      section.customDef.renderAfterReady(false);\n      section.customDef.url(this._customUrl.get());\n      section.customDef.widgetId(null);\n      section.customDef.widgetDef(null);\n      section.customDef.pluginId(\"\");\n      section.customDef.access(AccessLevel.none);\n      section.customDef.widgetOptions(null);\n      section.hasCustomOptions(false);\n      section.customDef.columnsMapping(null);\n      section.columnsToMap(null);\n      section.desiredAccessLevel(AccessLevel.none);\n    });\n    await section.saveCustomDef();\n  }\n\n  private async _saveRemoteWidget(section: ViewSectionRec) {\n    const [pluginId, widgetId] = this._selectedWidgetId.get()!.split(\":\");\n    const { customDef } = section;\n    if (customDef.pluginId.peek() === pluginId && customDef.widgetId.peek() === widgetId) {\n      return;\n    }\n\n    const selectedWidget = matchWidget(this._widgets.get() ?? [], { widgetId, pluginId });\n    if (!selectedWidget) {\n      throw new Error(`Widget ${this._selectedWidgetId.get()} not found`);\n    }\n\n    bundleChanges(() => {\n      section.customDef.renderAfterReady(selectedWidget.renderAfterReady ?? false);\n      section.customDef.access(AccessLevel.none);\n      section.desiredAccessLevel(selectedWidget.accessLevel ?? AccessLevel.none);\n      // Keep a record of the original widget definition.\n      // Don't rely on this much, since the document could\n      // have moved installation since, and widgets could be\n      // served from elsewhere.\n      section.customDef.widgetDef(selectedWidget);\n      section.customDef.widgetId(selectedWidget.widgetId);\n      section.customDef.pluginId(selectedWidget.source?.pluginId ?? \"\");\n      section.customDef.url(null);\n      section.customDef.widgetOptions(null);\n      section.hasCustomOptions(false);\n      section.customDef.columnsMapping(null);\n      section.columnsToMap(null);\n    });\n    await section.saveCustomDef();\n  }\n\n  private _maybeClearSelection(event: MouseEvent) {\n    const target = event.target as HTMLElement;\n    if (\n      !target.closest(\".custom-widget\") &&\n      !target.closest(\"button\") &&\n      !target.closest(\"a\") &&\n      !target.closest(\"input\")\n    ) {\n      this._selectedWidgetId.set(null);\n    }\n  }\n}\n\nexport function getWidgetName({ name, source }: ICustomWidget) {\n  return source?.name ? `${name} (${source.name})` : name;\n}\n\nfunction getWidgetVariant({ isGristLabsMaintained = false, widgetId }: ICustomWidget): WidgetVariant {\n  if (widgetId === CUSTOM_URL_WIDGET_ID) {\n    return \"custom\";\n  } else if (isGristLabsMaintained) {\n    return \"grist\";\n  } else {\n    return \"community\";\n  }\n}\n\nfunction getWidgetId({ source, widgetId }: ICustomWidget) {\n  if (widgetId === CUSTOM_URL_WIDGET_ID) {\n    return CUSTOM_URL_WIDGET_ID;\n  } else {\n    return `${source?.pluginId ?? \"\"}:${widgetId}`;\n  }\n}\n\nfunction getWidgetCleanText({ name, description, authors = [] }: ICustomWidget) {\n  let cleanText = name;\n  if (description) { cleanText += ` ${description}`; }\n  if (authors[0]) { cleanText += ` ${authors[0].name}`; }\n  return cleanText;\n}\n\nexport const cssWidgetMetadata = styled(\"div\", `\n  margin-top: auto;\n  display: flex;\n  flex-direction: column;\n  row-gap: 4px;\n`);\n\nexport const cssWidgetMetadataRow = styled(\"div\", `\n  display: flex;\n  column-gap: 4px;\n`);\n\nexport const cssWidgetMetadataName = styled(\"span\", `\n  color: ${theme.lightText};\n  font-weight: 600;\n`);\n\nexport const cssWidgetMetadataValue = styled(\"div\", `\n  &-missing {\n    color: ${theme.lightText};\n  }\n`);\n\nexport const cssDeveloperLink = styled(cssLink, `\n  font-weight: 600;\n`);\n\nconst cssCustomWidgetGallery = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n  height: 100%;\n  outline: none;\n`);\n\nconst WIDGET_WIDTH_PX = 240;\n\nconst WIDGETS_GAP_PX = 16;\n\nconst cssHeader = styled(\"div\", `\n  display: flex;\n  column-gap: 16px;\n  row-gap: 8px;\n  flex-wrap: wrap;\n  justify-content: space-between;\n  margin: 40px 40px 16px 40px;\n\n  /* Don't go beyond the final grid column. */\n  max-width: ${(3 * WIDGET_WIDTH_PX) + (2 * WIDGETS_GAP_PX)}px;\n`);\n\nconst cssTitle = styled(\"div\", `\n  font-size: 24px;\n  font-weight: 500;\n  line-height: 32px;\n`);\n\nconst cssSearchInputWrapper = styled(\"div\", `\n  position: relative;\n  display: flex;\n  align-items: center;\n`);\n\nconst cssSearchIcon = styled(icon, `\n  margin-left: 8px;\n  position: absolute;\n  --icon-color: ${theme.accentIcon};\n`);\n\nconst cssSearchInput = styled(textInput, `\n  height: 28px;\n  padding-left: 32px;\n`);\n\nconst cssShadowScroll = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex: unset;\n  flex-grow: 1;\n  padding: 16px 40px;\n`);\n\nconst cssCenteredFlexGrow = styled(\"div\", `\n  flex-grow: 1;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n`);\n\nconst cssLoadingSpinner = cssCenteredFlexGrow;\n\nconst cssNoMatchingWidgets = styled(cssCenteredFlexGrow, `\n  color: ${theme.lightText};\n`);\n\nconst cssWidgets = styled(\"div\", `\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(0px, ${WIDGET_WIDTH_PX}px));\n  gap: ${WIDGETS_GAP_PX}px;\n`);\n\nconst cssWidget = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  box-shadow: 1px 1px 4px 1px ${theme.widgetGalleryShadow};\n  border-radius: 4px;\n  min-height: 183.5px;\n  cursor: pointer;\n\n  &:hover {\n    background-color: ${theme.widgetGalleryBgHover};\n  }\n  &-selected {\n    outline: 2px solid ${theme.widgetGalleryBorderSelected};\n    outline-offset: -2px;\n  }\n`);\n\nconst cssWidgetHeader = styled(\"div\", `\n  flex-shrink: 0;\n  border: 2px solid ${theme.widgetGalleryBorder};\n  border-bottom: 1px solid ${theme.widgetGalleryBorder};\n  border-radius: 4px 4px 0px 0px;\n  color: ${theme.lightText};\n  font-size: 10px;\n  line-height: 16px;\n  font-weight: 500;\n  padding: 4px 18px;\n  text-transform: uppercase;\n\n  &-secondary {\n    border: 0px;\n    color: ${theme.widgetGallerySecondaryHeaderFg};\n    background-color: ${theme.widgetGallerySecondaryHeaderBg};\n  }\n  .${cssWidget.className}:hover &-secondary {\n    background-color: ${theme.widgetGallerySecondaryHeaderBgHover};\n  }\n`);\n\nconst cssWidgetBody = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex-grow: 1;\n  border: 2px solid ${theme.widgetGalleryBorder};\n  border-top: 0px;\n  border-radius: 0px 0px 4px 4px;\n  padding: 16px;\n`);\n\nconst cssWidgetName = styled(\"div\", `\n  font-size: 15px;\n  font-weight: 600;\n  margin-bottom: 16px;\n`);\n\nconst cssWidgetDescription = styled(\"div\", `\n  margin-bottom: 24px;\n\n  &-missing {\n    color: ${theme.lightText};\n  }\n`);\n\nconst cssCustomUrlInput = styled(textInput, `\n  height: 28px;\n`);\n\nconst cssHelpLink = styled(cssLink, `\n  display: inline-flex;\n  align-items: center;\n  column-gap: 8px;\n`);\n\nconst cssHelpIcon = styled(icon, `\n  flex-shrink: 0;\n`);\n\nconst cssFooter = styled(\"div\", `\n  flex-shrink: 0;\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: space-between;\n  align-items: center;\n  gap: 16px;\n  padding: 16px 40px;\n  border-top: 1px solid ${theme.widgetGalleryBorder};\n`);\n\nconst cssFooterButtons = styled(\"div\", `\n  display: flex;\n  column-gap: 8px;\n`);\n\nconst cssModal = styled(\"div\", `\n  width: 100%;\n  height: 100%;\n  max-width: 930px;\n  max-height: 623px;\n  padding: 0px;\n`);\n\nconst cssTooltipIcon = styled(\"div\", `\n  color: ${theme.widgetGallerySecondaryHeaderFg};\n  border-color: ${theme.widgetGallerySecondaryHeaderFg};\n`);\n"
  },
  {
    "path": "app/client/ui/DateRangeOptions.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { CURRENT_DATE, IRelativeDateSpec } from \"app/common/RelativeDates\";\n\nconst t = makeT(\"DateRangeOptions\");\n\nexport interface IDateRangeOption {\n  label: string;\n  min: IRelativeDateSpec;\n  max: IRelativeDateSpec;\n}\n\nexport function getDateRangeOptions(): IDateRangeOption[] {\n  return [\n    {\n      label: t(\"Today\"),\n      min: CURRENT_DATE,\n      max: CURRENT_DATE,\n    },\n    {\n      label: t(\"Last 7 days\"),\n      min: [{ quantity: -7, unit: \"day\" }],\n      max: [{ quantity: -1, unit: \"day\" }],\n    },\n    {\n      label: t(\"Next 7 days\"),\n      min: [{ quantity: 1, unit: \"day\" }],\n      max: [{ quantity: 7, unit: \"day\" }],\n    },\n    {\n      label: t(\"Last week\"),\n      min: [{ quantity: -1, unit: \"week\" }],\n      max: [{ quantity: -1, unit: \"week\", endOf: true }],\n    },\n    {\n      label: t(\"Last 30 days\"),\n      min: [{ quantity: -30, unit: \"day\" }],\n      max: [{ quantity: -1, unit: \"day\" }],\n    },\n    {\n      label: t(\"This week\"),\n      min: [{ quantity: 0, unit: \"week\" }],\n      max: [{ quantity: 0, unit: \"week\", endOf: true }],\n    },\n    {\n      label: t(\"This month\"),\n      min: [{ quantity: 0, unit: \"month\" }],\n      max: [{ quantity: 0, unit: \"month\", endOf: true }],\n    },\n    {\n      label: t(\"This year\"),\n      min: [{ quantity: 0, unit: \"year\" }],\n      max: [{ quantity: 0, unit: \"year\", endOf: true }],\n    },\n  ];\n}\n"
  },
  {
    "path": "app/client/ui/DefaultActivationPage.ts",
    "content": "import { AppModel } from \"app/client/models/AppModel\";\n\nimport { Disposable, IDomCreator } from \"grainjs\";\n\nexport type IActivationPageCreator = IDomCreator<[AppModel]>;\n\n/**\n * A blank ActivationPage stand-in, as it's possible for the frontend to try and load an \"activation page\",\n * even though there's no activation in core.\n */\nexport class DefaultActivationPage extends Disposable {\n  constructor(_appModel: AppModel) {\n    super();\n  }\n\n  public buildDom() {\n    return null;\n  }\n}\n"
  },
  {
    "path": "app/client/ui/DescriptionConfig.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { KoSaveableObservable } from \"app/client/models/modelUtil\";\nimport { autoGrow } from \"app/client/ui/forms\";\nimport { textarea, textInput } from \"app/client/ui/inputs\";\nimport { cssLabel, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { tokens } from \"app/common/ThemePrefs\";\nimport { CursorPos } from \"app/plugin/GristAPI\";\n\nimport { dom, DomArg, fromKo, MultiHolder, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"DescriptionConfig\");\n\nexport function buildDescriptionConfig(\n  owner: MultiHolder,\n  description: KoSaveableObservable<string>,\n  options: {\n    cursor: ko.Computed<CursorPos>,\n    testPrefix: string,\n  },\n) {\n  // We will listen to cursor position and force a blur event on\n  // the text input, which will trigger save before the column observable\n  // will change its value.\n  // Otherwise, blur will be invoked after column change and save handler will\n  // update a different column.\n  let editor: HTMLTextAreaElement | undefined;\n  owner.autoDispose(\n    options.cursor.subscribe(() => {\n      editor?.blur();\n    }),\n  );\n\n  let preview: HTMLDivElement | undefined;\n  const editing = Observable.create(owner, false);\n\n  async function save(value: string) {\n    value = value.trim();\n    if (value !== description.peek()) {\n      await description.setAndSaveOrRevert(value);\n    }\n    closeEditor();\n  }\n  function closeEditor() {\n    if (editor === document.activeElement) {\n      // If the editor was focused, keep preview focused now, so as to maintain the tab position.\n      setTimeout(() => { preview?.focus(); }, 0);\n    }\n    // Restore editor value, to avoid a \"save\" attempt if this triggers the 'blur' event.\n    if (editor) {\n      editor.value = description.peek();\n    }\n    editing.set(false);\n  }\n  function openEditor() {\n    editing.set(true);\n    setTimeout(() => { editor?.focus(); editor?.select(); }, 0);\n  }\n\n  return dom.domComputed(editing, (isEditing) => {\n    editor = preview = undefined;\n    if (isEditing) {\n      const rows = String(description.peek().split(\"\\n\").length);\n      return [\n        cssLabel(t(\"DESCRIPTION\"), { for: `${options.testPrefix}-description-input` }),\n        cssRow(\n          editor = cssTextArea(fromKo(description), { onInput: false, save },\n            { rows, placeholder: \"Enter description\", id: `${options.testPrefix}-description-input` },\n            dom.onKeyDown({\n              Enter$: (ev, elem) => { if (!ev.shiftKey) { return save(elem.value); } },\n              Escape: closeEditor,\n            }),\n            dom.on(\"blur\", (ev, elem) => save(elem.value)),\n            testId(`${options.testPrefix}-description`),\n            autoGrow(fromKo(description)),\n          ),\n        ),\n      ];\n    } else {\n      return dom.domComputed(use => Boolean(use(description)), (haveDescription) => {\n        preview = undefined;\n        if (haveDescription) {\n          return [\n            cssLabel(t(\"DESCRIPTION\"), { for: `${options.testPrefix}-description-preview` }),\n            cssRow(\n              preview = cssPreview(\n                cssTextInput.cls(\"\"),\n                dom.text(description),\n                { tabIndex: \"0\", id: `${options.testPrefix}-description-preview` },\n                dom.onKeyDown({ Enter: openEditor }),\n                dom.on(\"click\", openEditor),\n                testId(\"description-preview\"),\n              ),\n            ),\n          ];\n        } else {\n          return cssRow(cssTextButton(\n            t(\"Set description\"),\n            dom.on(\"click\", openEditor),\n            testId(\"description-add\"),\n          ),\n          );\n        }\n      });\n    }\n  });\n}\n\n/**\n * A generic version of buildDescriptionConfig that can be used for any text input.\n */\nexport function buildTextInput(\n  owner: MultiHolder,\n  options: {\n    label: string,\n    value: KoSaveableObservable<any>,\n    cursor?: ko.Computed<CursorPos>,\n    placeholder?: ko.Computed<string>,\n  },\n  ...args: DomArg[]\n) {\n  if (options.cursor) {\n    owner.autoDispose(\n      options.cursor.subscribe(() => {\n        options.value.save().catch(reportError);\n      }),\n    );\n  }\n  return [\n    cssLabel(options.label),\n    cssRow(\n      cssTextInput(fromKo(options.value),\n        dom.on(\"blur\", () => {\n          return options.value.save();\n        }),\n        dom.prop(\"placeholder\", options.placeholder || \"\"),\n        ...args,\n      ),\n    ),\n  ];\n}\n\nconst cssTextInput = styled(textInput, `\n  color: ${theme.inputFg};\n  background-color: ${theme.mainPanelBg};\n  border: 1px solid ${theme.inputBorder};\n  width: 100%;\n  outline: none;\n  height: 28px;\n  border-radius: 3px;\n  padding: 0px 6px;\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n\n  &[readonly] {\n    background-color: ${theme.inputDisabledBg};\n    color: ${theme.inputDisabledFg};\n  }\n`);\n\nconst cssTextArea = styled(textarea, `\n  color: ${theme.inputFg};\n  background-color: ${theme.mainPanelBg};\n  border: 1px solid ${theme.inputBorder};\n  width: 100%;\n  outline: none;\n  border-radius: 3px;\n  padding: 3px 7px;\n  min-height: calc(3em * 1.5);\n  resize: none;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n\n  &[readonly] {\n    background-color: ${theme.inputDisabledBg};\n    color: ${theme.inputDisabledFg};\n  }\n`);\n\nconst cssTextButton = styled(textButton, `\n  margin-top: 8px;\n`);\n\nconst cssPreview = styled(\"div\", `\n  background-color: ${tokens.bgTertiary};\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  cursor: pointer;\n  line-height: 2;\n  &:focus {\n    box-shadow: 0px 0px 2px 2px ${theme.inputFocus};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/DocHistory.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { createSessionObs } from \"app/client/lib/sessionObs\";\nimport { getTimeFromNow } from \"app/client/lib/timeUtils\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { reportError } from \"app/client/models/errors\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { buildConfigContainer } from \"app/client/ui/RightPanelUtils\";\nimport { buttonSelect } from \"app/client/ui2018/buttonSelect\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu, menuItemLink } from \"app/client/ui2018/menus\";\nimport { buildUrlId, parseUrlId } from \"app/common/gristUrls\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { DocSnapshot } from \"app/common/UserAPI\";\n\nimport { Disposable, dom, IDomComponent, MultiHolder, Observable, styled } from \"grainjs\";\nimport moment from \"moment\";\n\nconst t = makeT(\"DocHistory\");\n\nconst DocHistorySubTab = StringUnion(\"activity\", \"snapshots\");\n\nexport class DocHistory extends Disposable implements IDomComponent {\n  private _subTab = createSessionObs(this, \"docHistorySubTab\", \"snapshots\", DocHistorySubTab.guard);\n\n  constructor(private _docPageModel: DocPageModel, private _actionLog: IDomComponent) {\n    super();\n  }\n\n  public buildDom() {\n    const tabs = [\n      { value: \"activity\", label: t(\"Activity\") },\n      { value: \"snapshots\", label: t(\"Snapshots\") },\n    ];\n    return [\n      cssSubTabs(\n        buttonSelect(this._subTab, tabs, {}, testId(\"doc-history-tabs\")),\n      ),\n      dom.domComputed(this._subTab, subTab =>\n        buildConfigContainer(\n          subTab === \"activity\" ? this._actionLog.buildDom() :\n            subTab === \"snapshots\" ? dom.create(this._buildSnapshots.bind(this)) :\n              null,\n        ),\n      ),\n    ];\n  }\n\n  private _buildSnapshots(owner: MultiHolder) {\n    // Fetch snapshots, and render.\n    const doc = this._docPageModel.currentDoc.get();\n    if (!doc) { return null; }\n\n    // origUrlId is the snapshot-less URL, which we use to fetch snapshot history, and for\n    // snapshot comparisons.\n    const origUrlId = buildUrlId({ ...doc.idParts, snapshotId: undefined });\n\n    // If comparing one snapshot to another, get the other ID, so that we can highlight it too.\n    const compareUrlId = urlState().state.get().params?.compare;\n    const compareSnapshotId = compareUrlId && parseUrlId(compareUrlId).snapshotId;\n\n    // Helper to set a link to open a snapshot, optionally comparing it with a docId.\n    // We include urlState().state to preserve the currently selected page.\n    function setLink(snapshot: DocSnapshot, compareDocId?: string) {\n      return dom.attr(\"href\", use => urlState().makeUrl({\n        ...use(urlState().state), doc: snapshot.docId,\n        params: (compareDocId ? { compare: compareDocId } : {}),\n      }));\n    }\n\n    const snapshots = Observable.create<DocSnapshot[]>(owner, []);\n    const snapshotsDenied = Observable.create<boolean>(owner, false);\n    const userApi = this._docPageModel.appModel.api;\n    const docApi = userApi.getDocAPI(origUrlId);\n    docApi.getSnapshots().then(result =>\n      snapshots.isDisposed() || snapshots.set(result.snapshots)).catch((err) => {\n      snapshotsDenied.set(true);\n      // \"cannot confirm access\" is what we expect if snapshots\n      // are denied because of access rules.\n      if (!String(err).match(/cannot confirm access/)) {\n        reportError(err);\n      }\n    });\n    return dom(\n      \"div\",\n      { tabIndex: \"-1\" },  // Voodoo needed to allow copying text.\n      dom.maybe(snapshotsDenied, () => cssSnapshotDenied(\n        dom(\n          \"p\",\n          t(\"Snapshots are unavailable.\"),\n        ),\n        dom(\n          \"p\",\n          t(\"Only owners have access to snapshots for documents with access rules.\"),\n        ),\n        testId(\"doc-history-error\"))),\n      // Note that most recent snapshots are first.\n      dom.domComputed(snapshots, snapshotList => snapshotList.map((snapshot, index) => {\n        const modified = moment(snapshot.lastModified);\n        const prevSnapshot = snapshotList[index + 1] || null;\n        return cssSnapshot(\n          cssSnapshotTime(getTimeFromNow(snapshot.lastModified)),\n          cssSnapshotCard(\n            cssSnapshotCard.cls(\"-current\", Boolean(\n              snapshot.snapshotId === doc.idParts.snapshotId ||\n              (compareSnapshotId && snapshot.snapshotId === compareSnapshotId),\n            )),\n            dom(\"div\",\n              cssDatePart(modified.format(\"ddd ll\")), \" \",\n              cssDatePart(modified.format(\"LT\")),\n            ),\n            cssMenuDots(icon(\"Dots\"),\n              menu(() => [\n                menuItemLink(setLink(snapshot), t(\"Open snapshot\")),\n                menuItemLink(setLink(snapshot, origUrlId), t(\"Compare to current\")),\n                prevSnapshot && menuItemLink(setLink(prevSnapshot, snapshot.docId), t(\"Compare to previous\")),\n              ],\n              { placement: \"bottom-end\", parentSelectorToMark: \".\" + cssSnapshotCard.className },\n              ),\n              testId(\"doc-history-snapshot-menu\"),\n            ),\n            testId(\"doc-history-card\"),\n          ),\n          testId(\"doc-history-snapshot\"),\n        );\n      })),\n    );\n  }\n}\n\nconst cssSubTabs = styled(\"div\", `\n  padding: 16px;\n  border-bottom: 1px solid ${theme.pagePanelsBorder};\n`);\n\nconst cssSnapshot = styled(\"div\", `\n  margin: 8px 16px;\n`);\n\nconst cssSnapshotDenied = styled(\"div\", `\n  margin: 8px 16px;\n  text-align: center;\n  color: ${theme.text};\n`);\n\nconst cssSnapshotTime = styled(\"div\", `\n  text-align: right;\n  color: ${theme.lightText};\n  font-size: ${vars.smallFontSize};\n`);\n\nconst cssSnapshotCard = styled(\"div\", `\n  border: 1px solid ${theme.documentHistorySnapshotBorder};\n  padding: 8px;\n  color: ${theme.documentHistorySnapshotFg};\n  background: ${theme.documentHistorySnapshotBg};\n  border-radius: 8px;\n  overflow: hidden;\n  display: flex;\n  align-items: center;\n  --icon-color: ${theme.controlSecondaryFg};\n\n  &-current {\n    background-color: ${theme.documentHistorySnapshotSelectedBg};\n    color: ${theme.documentHistorySnapshotSelectedFg};\n    --icon-color: ${theme.documentHistorySnapshotSelectedFg};\n  }\n`);\n\nconst cssDatePart = styled(\"span\", `\n  display: inline-block;\n`);\n\nconst cssMenuDots = styled(\"div\", `\n  flex: none;\n  margin: 0 4px 0 auto;\n  height: 24px;\n  width: 24px;\n  padding: 4px;\n  line-height: 0px;\n  border-radius: 3px;\n  cursor: default;\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/DocIcon.ts",
    "content": "import { hashCode } from \"app/client/lib/hashUtils\";\nimport { splitPageInitial } from \"app/client/ui2018/pages\";\nimport { isValidHex, useBindable } from \"app/common/gutil\";\n\nimport emojiRegex from \"emoji-regex\";\nimport { BindableValue, dom, DomElementArg, styled } from \"grainjs\";\n\nexport interface DocIconOptions {\n  docId: string;\n  docName: BindableValue<string>;\n  icon?: {\n    backgroundColor?: BindableValue<string>;\n    color?: BindableValue<string>;\n    emoji?: BindableValue<string | null>;\n  } | null;\n}\n\nexport function buildDocIcon(options: DocIconOptions, ...args: DomElementArg[]) {\n  const { docId, docName, icon } = options;\n  const { color: defaultColor, backgroundColor: defaultBackgroundColor } =\n    getDefaultIconColors(docId);\n  return cssDocIcon(\n    dom.domComputed((use) => {\n      const emoji = useBindable(use, icon?.emoji);\n      if (isEmoji(emoji)) {\n        return cssEmoji(emoji);\n      } else {\n        return cssInitials(getIconFromName(useBindable(use, docName)));\n      }\n    }),\n    dom.style(\"color\", (use) => {\n      const color = useBindable(use, icon?.color);\n      return isValidHex(color) ? color : defaultColor;\n    }),\n    dom.style(\"background-color\", (use) => {\n      const backgroundColor = useBindable(use, icon?.backgroundColor);\n      return isValidHex(backgroundColor) ?\n        backgroundColor :\n        defaultBackgroundColor;\n    }),\n    ...args,\n  );\n}\n\nexport function getDefaultIconColors(docId: string) {\n  let index = hashCode(docId) % DEFAULT_DOC_ICON_COLORS.length;\n  if (index < 0) {\n    index += DEFAULT_DOC_ICON_COLORS.length;\n  }\n  return DEFAULT_DOC_ICON_COLORS[index];\n}\n\nfunction isEmoji(value: unknown): value is string {\n  if (typeof value !== \"string\") {\n    return false;\n  }\n\n  return emojiRegex().test(value);\n}\n\nfunction getIconFromName(name: string) {\n  // If name starts with emoji, use this as icon, and name as rest.\n  // Reuse the method for getting page initials. If name starts with an emoji we want\n  // to show what pages are showing.\n  const pageInitials = splitPageInitial(name);\n  if (pageInitials.hasEmoji) {\n    return pageInitials.initial;\n  }\n\n  // Otherwise use first two letters/digits from two first words.\n  const parts = name.trim().split(/\\s+/);\n  return parts\n    .slice(0, 2)\n    .map(w => [...w][0])\n    .join(\"\")\n    // https://www.regular-expressions.info/unicode.html\n    .replace(/[^\\p{L}\\p{Nd}]$/u, \"\")\n    // Circumvent weird behavior (regression?) found in Chromium since r1566276\n    // Normally at this point, the above regex should have removed any emoji.\n    // See this discussion for more information:\n    // https://github.com/gristlabs/grist-core/pull/2170/changes#r2923385260\n    .replace(emojiRegex(), \"\")\n    .toUpperCase();\n}\n\n/**\n * Extract the name part to display from doc name (by removing emoji from the start)\n * and return it. If there is no emoji, return the name as is.\n * If there is an preselected icon, return the name as is.\n */\nexport function stripIconFromName(name: string, hasIcon: boolean) {\n  if (hasIcon) {\n    return name;\n  }\n  // Reuse the page initials logic to get the display name. But if the display name is empty (name contains just the\n  // emoji), we want to show this emoji as a name, not the empty string like pages do.\n  const pageInitials = splitPageInitial(name);\n  return pageInitials.displayName;\n}\n\nconst DEFAULT_DOC_ICON_COLORS = [\n  { color: \"#494949\", backgroundColor: \"#E1FEDE\" },\n  { color: \"#494949\", backgroundColor: \"#FED6FB\" },\n  { color: \"#494949\", backgroundColor: \"#CCFEFE\" },\n  { color: \"#494949\", backgroundColor: \"#FEE7C3\" },\n  { color: \"#494949\", backgroundColor: \"#E8D0FE\" },\n  { color: \"#494949\", backgroundColor: \"#FFFACD\" },\n  { color: \"#494949\", backgroundColor: \"#D3E7FE\" },\n  { color: \"#494949\", backgroundColor: \"#FECBCC\" },\n  { color: \"#494949\", backgroundColor: \"#F3E1D2\" },\n  { color: \"#494949\", backgroundColor: \"#CCCCCC\" },\n];\n\nconst cssDocIcon = styled(\"div\", `\n  flex-shrink: 0;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  width: 48px;\n  height: 48px;\n  border-radius: 4px;\n`);\n\nconst cssEmoji = styled(\"div\", `\n  font-size: 20px;\n`);\n\nconst cssInitials = styled(\"div\", `\n  font-size: 18px;\n  font-weight: 600;\n`);\n"
  },
  {
    "path": "app/client/ui/DocList.ts",
    "content": "import { stopEvent } from \"app/client/lib/domUtils\";\nimport { loadUserManager } from \"app/client/lib/imports\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { getTimeFromNow } from \"app/client/lib/timeUtils\";\nimport { docUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { HomeModel, ViewSettings } from \"app/client/models/HomeModel\";\nimport { workspaceName } from \"app/client/models/WorkspaceInfo\";\nimport { contextMenu } from \"app/client/ui/contextMenu\";\nimport { buildDocIcon, stripIconFromName } from \"app/client/ui/DocIcon\";\nimport { STICKY_HEADER_HEIGHT_PX } from \"app/client/ui/DocMenuCss\";\nimport { downloadDocModal } from \"app/client/ui/MakeCopyMenu\";\nimport { showRenameDocModal } from \"app/client/ui/RenameDocModal\";\nimport { shadowScroll } from \"app/client/ui/shadowScroll\";\nimport { makeShareDocUrl } from \"app/client/ui/ShareMenu\";\nimport {\n  isNarrowScreenObs,\n  mediaMedium,\n  theme,\n  vars,\n} from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon as cssIcon } from \"app/client/ui2018/icons\";\nimport { menu, menuIcon, menuItem, select } from \"app/client/ui2018/menus\";\nimport { confirmModal, saveModal } from \"app/client/ui2018/modals\";\nimport { stretchedLink } from \"app/client/ui2018/stretchedLink\";\nimport { buildTabs, TabProps } from \"app/client/ui2018/tabs\";\nimport { unstyledButton, unstyledH2, unstyledUl } from \"app/client/ui2018/unstyled\";\nimport { visuallyHidden } from \"app/client/ui2018/visuallyHidden\";\nimport { HomePageTab } from \"app/common/gristUrls\";\nimport { SortPref } from \"app/common/Prefs\";\nimport * as roles from \"app/common/roles\";\nimport { Document } from \"app/common/UserAPI\";\n\nimport {\n  Computed,\n  computedArray,\n  Disposable,\n  dom,\n  makeTestId,\n  MaybeObsArray,\n  Observable,\n  styled,\n} from \"grainjs\";\nimport sortBy from \"lodash/sortBy\";\n\nconst t = makeT(\"DocList\");\n\nconst testId = makeTestId(\"test-dm-\");\n\ninterface DocListOptions {\n  home: HomeModel;\n  viewSettings?: ViewSettings;\n}\n\nexport class DocList extends Disposable {\n  private readonly _home = this._options.home;\n  private readonly _tabNames: Computed<HomePageTab[]> = Computed.create(\n    this,\n    this._home.currentPage,\n    (_use, page) => {\n      if (page === \"all\") {\n        return [\"recent\", \"pinned\", \"all\"];\n      } else {\n        return [\"all\", \"pinned\"];\n      }\n    },\n  );\n\n  private readonly _tabIconsAndLabels = getTabIconsAndLabels();\n\n  private readonly _tabs: MaybeObsArray<TabProps> = this.autoDispose(\n    computedArray(this._tabNames, tab => ({\n      id: tab,\n      label: this._tabIconsAndLabels[tab].label,\n      icon: this._tabIconsAndLabels[tab].icon,\n      link: { homePageTab: tab },\n    }),\n    ));\n\n  private readonly _viewSettings =\n    this._options.viewSettings ?? this._options.home;\n\n  private readonly _tab = Computed.create(\n    this,\n    this._tabNames,\n    urlState().state,\n    (_use, tabs, { homePageTab }) => {\n      return homePageTab && tabs.includes(homePageTab) ? homePageTab : tabs[0];\n    },\n  );\n\n  private readonly _showWorkspace = Computed.create(\n    this,\n    this._home.currentPage,\n    (_use, page) => {\n      return page !== \"workspace\";\n    },\n  );\n\n  constructor(private _options: DocListOptions) {\n    super();\n  }\n\n  public buildDom() {\n    return dom(\"div\", this._buildHeader(), this._buildBody());\n  }\n\n  private _buildHeader() {\n    return cssHeader(\n      visuallyHidden(unstyledH2(t(\"Documents list\"))),\n      buildTabs(this._tabs, this._tab),\n      this._buildViewSettings(),\n    );\n  }\n\n  private _buildViewSettings() {\n    return dom.maybe(\n      use => use(this._tab) !== \"recent\",\n      () =>\n        cssViewSettings(\n          dom.update(\n            select<SortPref>(\n              this._viewSettings.currentSort,\n              [\n                { value: \"name\", label: t(\"Sort by name\") },\n                { value: \"date\", label: t(\"Sort by date\") },\n              ],\n              { buttonCssClass: cssSortSelect.className },\n            ),\n            testId(\"sort-mode\"),\n          ),\n        ),\n    );\n  }\n\n  private _buildBody() {\n    const { currentSort } = this._viewSettings;\n    return [\n      dom.domComputed(\n        use => ({\n          docs: use(this._home.currentWSDocs),\n          sort: use(currentSort),\n          tab: use(this._tab),\n        }),\n        ({ docs, sort, tab }) => {\n          docs = sortAndFilterDocs(docs, { sort, tab });\n          if (docs.length === 0) {\n            return cssNoDocsMessage(\n              cssNoDocsImage({ alt: \"\", width: \"150\", height: \"140\", src: \"img/create-document.svg\" }),\n              dom(\"div\", cssParagraph(t(\"No documents to show.\"))),\n              testId(\"no-docs-message\"),\n            );\n          }\n\n          return [\n            // the aria-hidden attributes are there to prevent screen reader annoucements,\n            // as they are not relevant in that case\n            cssDocHeaderRow(\n              dom.hide(isNarrowScreenObs()),\n              cssNameColumn(t(\"Name\"), { \"aria-hidden\": \"true\" }),\n              cssWorkspaceColumn(t(\"Workspace\"), dom.show(this._showWorkspace), { \"aria-hidden\": \"true\" }),\n              cssEditedAtColumn(t(\"Last edited\"), { \"aria-hidden\": \"true\" }),\n              cssOptionsColumn(),\n            ),\n            unstyledUl(\n              dom.forEach(docs, (doc) => {\n                return cssDocRow(\n                  cssDoc(\n                    cssDoc.cls(\"-no-access\", doc.disabledAt !== undefined || !roles.canView(doc.access)),\n                    cssDocIconAndName(\n                      buildDocIcon(\n                        {\n                          docId: doc.id,\n                          docName: doc.name,\n                          icon: doc.options?.appearance?.icon,\n                        },\n                        testId(\"doc-icon\"),\n                        { \"aria-hidden\": \"true\" },\n                      ),\n                      cssDocNameAndBadges(\n                        cssDocName(\n                          urlState().setLinkUrl(docUrl(doc)),\n                          stripIconFromName(doc.name, Boolean(doc.options?.appearance?.icon?.emoji)),\n                          testId(\"doc-name\"),\n                        ),\n                        cssDocBadges(\n                          doc.isPinned ?\n                            cssPinIcon(\"Pin2\", testId(\"doc-pinned\")) :\n                            null,\n                          doc.public ?\n                            cssWorldIcon(\"World\", testId(\"doc-public\")) :\n                            null,\n                        ),\n                      ),\n                    ),\n                    cssDocWorkspace(\n                      dom.show(this._showWorkspace),\n                      dom(\"span\",\n                        visuallyHidden(t(\"Workspace\")),\n                        workspaceName(this._home.app, doc.workspace),\n                      ),\n                      testId(\"doc-workspace\"),\n                    ),\n                    cssDocEditedAt(\n                      getUpdatedAt(doc),\n                      testId(\"doc-edited-at\"),\n                    ),\n                    cssDocDetailsCompact(\n                      cssDocName(\n                        urlState().setLinkUrl(docUrl(doc)),\n                        stripIconFromName(doc.name, Boolean(doc.options?.appearance?.icon?.emoji)),\n                      ),\n                      cssDocEditedAt(getUpdatedAt(doc)),\n                      cssDocBadges(\n                        !doc.isPinned ?\n                          null :\n                          cssPinIcon(\"Pin2\"),\n                        !doc.public ?\n                          null :\n                          cssWorldIcon(\"World\"),\n                      ),\n                    ),\n                    cssDocOptions(\n                      cssDotsIcon(\"Dots\"),\n                      menu(() => makeDocOptionsMenu(this._home, doc), {\n                        placement: \"bottom-start\",\n                        // Keep the document highlighted while the menu is open.\n                        parentSelectorToMark: \".\" + cssDocRow.className,\n                      }),\n                      dom.on(\"click\", ev => stopEvent(ev)),\n                      { \"aria-label\": t(\"context menu - {{- documentName }}\", { documentName: `\"${doc.name}\"` }) },\n                      testId(\"doc-options\"),\n                    ),\n                    contextMenu(() => makeDocOptionsMenu(this._home, doc), {\n                      // Keep the document highlighted while the menu is open.\n                      parentSelectorToMark: \".\" + cssDocRow.className,\n                    }),\n                    testId(\"doc\"),\n                  ),\n                );\n              }),\n            ),\n          ];\n        },\n      ),\n    ];\n  }\n}\n\nexport function makeDocOptionsMenu(home: HomeModel, doc: Document) {\n  const org = home.app.currentOrg;\n  const orgAccess: roles.Role | null = org ? org.access : null;\n  const isElectron = (window as any).isRunningUnderElectron;\n\n  function deleteDoc() {\n    confirmModal(\n      t(\"Delete {{name}}\", { name: doc.name }),\n      t(\"Delete\"),\n      () => home.deleteDoc(doc.id, false).catch(reportError),\n      { explanation: t(\"Document will be moved to Trash.\") },\n    );\n  }\n\n  async function manageUsers() {\n    const api = home.app.api;\n    const user = home.app.currentUser;\n    (await loadUserManager()).showUserManagerModal(api, {\n      permissionData: api.getDocAccess(doc.id),\n      activeUser: user,\n      resourceType: \"document\",\n      resourceId: doc.id,\n      resource: doc,\n      linkToCopy: makeShareDocUrl(doc),\n      reload: () => api.getDocAccess(doc.id),\n      onSave: () => home.updateWorkspaces(),\n      appModel: home.app,\n    });\n  }\n\n  return [\n    menuItem(\n      () => showRenameDocModal({ home, doc }),\n      t(\"Rename and set icon\"),\n      dom.cls(\"disabled\", doc.disabledAt !== undefined || !roles.isOwner(doc)),\n      testId(\"rename-doc\"),\n    ),\n    menuItem(\n      () => showMoveDocModal(home, doc),\n      t(\"Move\"),\n      // Note that moving the doc requires ACL access on the doc. Moving a doc to a workspace\n      // that confers descendant ACL access could otherwise increase the user's access to the doc.\n      // By requiring the user to have ACL edit access on the doc to move it prevents using this\n      // as a tool to gain greater access control over the doc.\n      // Having ACL edit access on the doc means the user is also powerful enough to remove\n      // the doc, so this is the only access check required to move the doc out of this workspace.\n      // The user must also have edit access on the destination, however, for the move to work.\n      dom.cls(\"disabled\", doc.disabledAt !== undefined || !roles.canEditAccess(doc.access)),\n      testId(\"move-doc\"),\n    ),\n    menuItem(\n      deleteDoc,\n      t(\"Delete\"),\n      dom.cls(\"disabled\", !roles.isOwner(doc)),\n      testId(\"delete-doc\"),\n    ),\n    menuItem(\n      () => home.pinUnpinDoc(doc.id, !doc.isPinned).catch(reportError),\n      doc.isPinned ? t(\"Unpin\") : t(\"Pin\"),\n      dom.cls(\"disabled\", !roles.canEdit(orgAccess)),\n      testId(\"pin-doc\"),\n    ),\n    menuItem(\n      manageUsers,\n      roles.canEditAccess(doc.access) ? t(\"Manage users\") : t(\"Access details\"),\n      testId(\"doc-access\"),\n    ),\n    // The electron method for \"downloading\" documents only works\n    // with a websocket currently, so downloads are only easy\n    // to support when the document is open.\n    // TODO: support showItemInFolder with electron in a better way.\n    (isElectron ? null :\n      menuItem(\n        () => downloadDocModal(doc, home.app),\n        menuIcon(\"Download\"), t(\"Download document...\"),\n        dom.cls(\"disabled\", doc.disabledAt !== undefined),\n        testId(\"tb-share-option\"))\n    ),\n  ];\n}\n\nfunction showMoveDocModal(home: HomeModel, doc: Document) {\n  saveModal((_ctl, owner) => {\n    const selected: Observable<number | null> = Observable.create(owner, null);\n    const body = cssMoveDocModalBody(\n      shadowScroll(\n        dom.forEach(home.workspaces, (ws) => {\n          if (ws.isSupportWorkspace) {\n            return null;\n          }\n          const isCurrent = Boolean(ws.docs.find(_doc => _doc.id === doc.id));\n          const isEditable = roles.canEdit(ws.access);\n          const disabled = isCurrent || !isEditable;\n          return cssMoveDocListItem(\n            cssMoveDocListText(workspaceName(home.app, ws)),\n            isCurrent ? cssMoveDocListHintText(t(\"Current workspace\")) : null,\n            !isEditable ?\n              cssMoveDocListHintText(t(\"Requires edit permissions\")) :\n              null,\n            cssMoveDocListItem.cls(\"-disabled\", disabled),\n            cssMoveDocListItem.cls(\n              \"-selected\",\n              use => use(selected) === ws.id,\n            ),\n            dom.on(\"click\", () => disabled || selected.set(ws.id)),\n            testId(\"dest-ws\"),\n          );\n        }),\n      ),\n    );\n    return {\n      title: t(\"Move {{name}} to workspace\", { name: doc.name }),\n      body,\n      saveDisabled: Computed.create(owner, use => !use(selected)),\n      saveFunc: async () =>\n        !selected.get() ||\n        home.moveDoc(doc.id, selected.get()!).catch(reportError),\n      saveLabel: t(\"Move\"),\n    };\n  });\n}\n\ninterface IconAndLabel {\n  icon: IconName;\n  label: string;\n}\n\nfunction getTabIconsAndLabels(): Record<HomePageTab, IconAndLabel> {\n  return {\n    recent: {\n      icon: \"Clock\",\n      label: t(\"Recent\"),\n    },\n    pinned: {\n      icon: \"Pin2\",\n      label: t(\"Pinned\"),\n    },\n    all: {\n      icon: \"Layers\",\n      label: t(\"All\"),\n    },\n  };\n}\n\ninterface SortAndFilterOptions {\n  sort: SortPref;\n  tab: HomePageTab;\n}\n\nfunction sortAndFilterDocs(\n  docs: Document[],\n  { sort, tab }: SortAndFilterOptions,\n) {\n  if (tab === \"pinned\") {\n    docs = docs.filter(({ isPinned }) => isPinned);\n  }\n  if (sort === \"date\" || tab === \"recent\") {\n    docs = sortBy(docs, doc => doc.removedAt || doc.updatedAt).reverse();\n  } else {\n    docs = sortBy(docs, doc => doc.name.toLowerCase());\n  }\n  return docs;\n}\n\nexport async function renameDoc(home: HomeModel, doc: Document, val: string) {\n  if (val !== doc.name) {\n    try {\n      await home.renameDoc(doc.id, val);\n    } catch (err) {\n      reportError(err as Error);\n    }\n  }\n}\n\nexport function getUpdatedAt(doc: Document) {\n  if (doc.removedAt) {\n    return t(\"Deleted {{at}}\", { at: getTimeFromNow(doc.removedAt) });\n  }\n  return t(\"Edited {{at}}\", { at: getTimeFromNow(doc.updatedAt) });\n}\n\nconst cssHeader = styled(\"div\", `\n  position: sticky;\n  top: ${STICKY_HEADER_HEIGHT_PX}px;\n  background-color: ${theme.mainPanelBg};\n  z-index: ${vars.stickyHeaderZIndex};\n  display: flex;\n  column-gap: 24px;\n  margin-bottom: 8px;\n`);\n\nconst cssViewSettings = styled(\"div\", `\n  display: flex;\n  align-items: flex-end;\n  min-width: 0;\n`);\n\nconst cssSortSelect = styled(\"div\", `\n  border: none;\n  display: inline-flex;\n  column-gap: 6px;\n  height: unset;\n  line-height: unset;\n  align-items: center;\n  color: ${theme.controlFg};\n  --icon-color: ${theme.controlFg};\n  background-color: unset;\n  font-size: 14px;\n  font-weight: 500;\n  padding: 0;\n\n  &:hover, &:focus, &.weasel-popup-open {\n    color: ${theme.controlHoverFg};\n    --icon-color: ${theme.controlHoverFg};\n    outline: none;\n    box-shadow: none;\n  }\n`);\n\nconst cssParagraph = styled(\"div\", `\n  color: ${theme.text};\n  font-size: 13px;\n  font-weight: 500;\n  line-height: 1.6;\n  margin-bottom: 12px;\n  text-align: center;\n`);\n\nconst cssNoDocsMessage = styled(\"div\", `\n  margin-top: 70px;\n  display: flex;\n  flex-direction: column;\n  row-gap: 16px;\n  align-items: center;\n  justify-content: center;\n`);\n\nconst cssNoDocsImage = styled(\"img\", `\n  display: flex;\n  align-items: center;\n  justify-content: center;\n`);\n\nconst cssDocHeaderRow = styled(\"div\", `\n  display: flex;\n  padding: 0px 8px;\n  color: ${theme.lightText};\n\n  @media ${mediaMedium} {\n    & {\n      display: none;\n    }\n  }\n`);\n\nconst cssNameColumn = styled(\"div\", `\n  flex: 1 0 50%;\n`);\n\nconst cssWorkspaceColumn = styled(\"div\", `\n  flex: 1 1 20%;\n  margin-right: 40px;\n  max-width: 200px;\n`);\n\nconst cssEditedAtColumn = styled(\"div\", `\n  flex: 1 1 30%;\n  margin-right: 16px;\n  max-width: 250px;\n`);\n\nconst cssOptionsColumn = styled(\"div\", `\n  flex: none;\n  width: 44px;\n  margin: 0 4px 0 auto;\n  padding: 4px;\n  display: flex;\n`);\n\nconst cssDocRow = styled(\"li\", `\n  position: relative;\n  border-radius: 3px;\n  font-size: 14px;\n  color: ${theme.text};\n  --icon-color: ${theme.lightText};\n\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.lightHover};\n  }\n`);\n\nconst cssDoc = styled(\"div\", `\n  display: flex;\n  position: relative;\n  align-items: center;\n  border-radius: 3px;\n  outline: none;\n  padding: 8px;\n\n  &-no-access, &-no-access:hover, &-no-access:focus {\n    color: ${theme.disabledText};\n    cursor: not-allowed;\n  }\n\n  @media ${mediaMedium} {\n    & {\n      align-items: initial;\n      column-gap: 16px;\n    }\n  }\n`);\n\nconst cssDocDetailsCompact = styled(\"div\", `\n  display: none;\n  flex: 1 1 auto;\n  flex-direction: column;\n  row-gap: 8px;\n  column-gap: 40px;\n  min-width: 0px;\n  align-items: flex-start;\n\n  @media ${mediaMedium} {\n    & {\n      display: flex;\n    }\n  }\n`);\n\nconst cssDocIconAndName = styled(cssNameColumn, `\n  display: flex;\n  align-items: center;\n  column-gap: 11px;\n  overflow: hidden;\n\n  @media ${mediaMedium} {\n    & {\n      align-items: initial;\n      flex: none;\n    }\n  }\n`);\n\nconst cssDocNameAndBadges = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  width: 100%;\n  margin-right: 35px;\n  overflow: hidden;\n\n  @media ${mediaMedium} {\n    & {\n      display: none;\n    }\n  }\n`);\n\nconst noAccessStyles = `\n  .${cssDoc.className}-no-access &,\n  .${cssDoc.className}-no-access:hover &,\n  .${cssDoc.className}-no-access:focus & {\n    color: ${theme.disabledText};\n  }\n`;\n\nconst cssDocName = styled(stretchedLink, `\n  font-size: 14px;\n  flex: 0 1 auto;\n  padding: 5px;\n  color: ${theme.text};\n  font-weight: 600;\n\n  &, &:hover, &:focus {\n    text-decoration: none;\n    outline: none;\n    color: inherit;\n  }\n\n  &:focus-visible {\n    outline-offset: -3px;\n  }\n\n  ${noAccessStyles}\n\n  .${cssDocDetailsCompact.className} & {\n    padding: 0;\n  }\n\n  .${cssDocDetailsCompact.className} &:focus-visible {\n    outline-offset: 3px;\n  }\n\n  @media ${mediaMedium} {\n    & {\n      width: 100%;\n    }\n  }\n`);\n\nconst cssDocBadges = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  flex-shrink: 0;\n  column-gap: 8px;\n  margin-left: auto;\n  min-height: 16px;\n\n  @media ${mediaMedium} {\n    & {\n      margin-left: initial;\n    }\n  }\n`);\n\nconst cssPinIcon = styled(cssIcon, `\n  flex: none;\n  --icon-color: ${theme.lightText};\n`);\n\nconst cssWorldIcon = styled(cssIcon, `\n  width: 24px;\n  height: 24px;\n  flex: none;\n  --icon-color: ${theme.accentIcon};\n`);\n\nconst secondaryColumnStyles = `\n  color: ${theme.mediumText};\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  font-size: 13px;\n  font-weight: normal;\n  ${noAccessStyles}\n\n  @media ${mediaMedium} {\n    .${cssDoc.className} > & {\n      display: none;\n    }\n  }\n`;\n\nconst cssDocWorkspace = styled(cssWorkspaceColumn, secondaryColumnStyles);\n\nconst cssDocEditedAt = styled(cssEditedAtColumn, `\n  ${secondaryColumnStyles};\n\n  @media ${mediaMedium} {\n    & {\n      width: 100%;\n    }\n  }\n`);\n\nconst cssDocOptions = styled(unstyledButton, `\n  position: relative;\n  z-index: 2; /* make sure this is above the stretched link row */\n  flex: none;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  margin: 0 4px 0 auto;\n  height: 44px;\n  width: 44px;\n  padding: 4px;\n  line-height: 0px;\n  border-radius: 4px;\n  --icon-color: ${theme.controlFg};\n\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssDotsIcon = styled(cssIcon, `\n  --icon-color: ${theme.controlFg};\n  width: 19.2px;\n  height: 19.2px;\n`);\n\nconst cssMoveDocModalBody = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  border-bottom: 1px solid ${theme.modalBorderDark};\n  margin: 0 -64px;\n  height: 200px;\n`);\n\nconst cssMoveDocListItem = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n  width: 100%;\n  height: 32px;\n  padding: 12px 64px;\n  cursor: pointer;\n  font-size: ${vars.mediumFontSize};\n\n  &-selected {\n    background-color: ${theme.moveDocsSelectedBg};\n    color: ${theme.moveDocsSelectedFg};\n  }\n  &-disabled {\n    color: ${theme.moveDocsDisabledFg};\n    cursor: default;\n  }\n`);\n\nconst cssMoveDocListText = styled(\"div\", `\n  display: flex;\n  flex: 1 1 0;\n  flex-direction: column;\n  justify-content: center;\n`);\n\nconst cssMoveDocListHintText = styled(cssMoveDocListText, `\n  text-align: right;\n`);\n"
  },
  {
    "path": "app/client/ui/DocMenu.ts",
    "content": "/**\n * This module exports a DocMenu component, consisting of an organization dropdown, a sidepane\n * of workspaces, and a doc list. The organization and workspace selectors filter the doc list.\n * Orgs, workspaces and docs are fetched asynchronously on build via the passed in API.\n */\nimport { makeT } from \"app/client/lib/localization\";\nimport { getTimeFromNow } from \"app/client/lib/timeUtils\";\nimport { reportError } from \"app/client/models/AppModel\";\nimport { docUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { HomeModel, makeLocalViewSettings, ViewSettings } from \"app/client/models/HomeModel\";\nimport { getWorkspaceInfo, workspaceName } from \"app/client/models/WorkspaceInfo\";\nimport { attachAddNewTip } from \"app/client/ui/AddNewTip\";\nimport { DocList, getUpdatedAt, makeDocOptionsMenu } from \"app/client/ui/DocList\";\nimport * as css from \"app/client/ui/DocMenuCss\";\nimport { buildHomeIntro } from \"app/client/ui/HomeIntro\";\nimport { buildPinnedDoc, createPinnedDocs } from \"app/client/ui/PinnedDocs\";\nimport { buildTemplateDocs } from \"app/client/ui/TemplateDocs\";\nimport { shouldShowWelcomeCoachingCall, showWelcomeCoachingCall } from \"app/client/ui/WelcomeCoachingCall\";\nimport { buttonSelect, cssButtonSelect } from \"app/client/ui2018/buttonSelect\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { menu, menuItem, menuText, select } from \"app/client/ui2018/menus\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\nimport { IHomePage } from \"app/common/gristUrls\";\nimport { SortPref, ViewPref } from \"app/common/Prefs\";\nimport * as roles from \"app/common/roles\";\nimport { Document, Workspace } from \"app/common/UserAPI\";\n\nimport {\n  dom,\n  DomArg,\n  DomContents,\n  DomElementArg,\n  makeTestId,\n  Observable,\n} from \"grainjs\";\nimport sortBy from \"lodash/sortBy\";\n\nconst t = makeT(`DocMenu`);\n\nconst testId = makeTestId(\"test-dm-\");\n\n/**\n * The DocMenu is the main area of the home page, listing all docs.\n *\n * Usage:\n *    dom('div', createDocMenu(homeModel))\n */\nexport function createDocMenu(home: HomeModel): DomElementArg[] {\n  return [\n    attachWelcomePopups(home),\n    dom.domComputed(home.loading, (loading) => {\n      if (loading) {\n        return loading === \"slow\" ? css.spinner(loadingSpinner()) : null;\n      }\n\n      return css.docList(\n        attachAddNewTip(home),\n        css.docListContent(\n          css.docMenu(\n            dom.domComputed<[IHomePage, Workspace | undefined]>(\n              use => [use(home.currentPage), use(home.currentWS)],\n              ([page, workspace]): Exclude<DomContents, void> => {\n                switch (page) {\n                  case \"all\": {\n                    return buildAllDocumentsPage(home);\n                  }\n                  case \"workspace\": {\n                    return buildWorkspacePage(home, workspace);\n                  }\n                  case \"templates\": {\n                    return buildTemplatesPage(home);\n                  }\n                  case \"trash\": {\n                    return buildTrashPage(home);\n                  }\n                }\n              },\n            ),\n            testId(\"doclist\"),\n          ),\n        ),\n      );\n    }),\n  ];\n}\n\nfunction attachWelcomePopups(home: HomeModel): (el: Element) => void {\n  return (element: Element) => {\n    const { app } = home;\n    if (shouldShowWelcomeCoachingCall(app)) {\n      showWelcomeCoachingCall(element, app);\n    }\n  };\n}\n\nfunction buildAllDocumentsPage(home: HomeModel) {\n  return [\n    buildHomeIntro(home),\n    home.app.isPersonal && !home.app.currentValidUser ?\n      null :\n      dom.maybe(home.available, () => dom.create(DocList, { home })),\n  ];\n}\n\nfunction buildWorkspacePage(home: HomeModel, workspace: Workspace | undefined) {\n  if (!workspace) {\n    return css.docBlock(t(\"Workspace not found\"));\n  }\n\n  const viewSettings = makeLocalViewSettings(home, workspace.id);\n  return [\n    dom.maybe(home.available, () => [\n      css.stickyHeader(\n        css.workspaceHeaderWrap(\n          css.workspaceHeaderIcon(\"Folder2\"),\n          css.workspaceHeader(\n            workspaceName(home.app, workspace),\n          ),\n          testId(\"doc-header\"),\n        ),\n      ),\n      dom.create(DocList, {\n        home,\n        viewSettings,\n      }),\n    ]),\n  ];\n}\n\nfunction buildTemplatesPage(home: HomeModel) {\n  const viewSettings = makeLocalViewSettings(home, \"templates\");\n  return [\n    dom.maybe(\n      use => use(home.featuredTemplates).length > 0,\n      () => [\n        css.featuredTemplatesHeader(\n          css.featuredTemplatesIcon(\"Idea\"),\n          t(\"Featured\"),\n          testId(\"featured-templates-header\"),\n        ),\n        createPinnedDocs(home, home.featuredTemplates, true),\n      ],\n    ),\n    dom.maybe(home.available, () => [\n      css.docListHeaderWrap(\n        css.listHeader(\n          dom.domComputed(\n            use => use(home.featuredTemplates).length > 0,\n            hasFeaturedTemplates =>\n              hasFeaturedTemplates ?\n                t(\"More Examples and Templates\") :\n                t(\"Examples and Templates\"),\n          ),\n          testId(\"doc-header\"),\n        ),\n        buildPrefs(viewSettings),\n      ),\n      dom(\n        \"div\",\n        buildAllTemplates(home, home.templateWorkspaces, viewSettings),\n      ),\n    ]),\n  ];\n}\n\nfunction buildTrashPage(home: HomeModel) {\n  const viewSettings = makeLocalViewSettings(home, \"trash\");\n  return dom.maybe(home.available, () => [\n    css.docListHeaderWrap(\n      css.listHeader(t(\"Trash\"), testId(\"doc-header\")),\n      buildPrefs(viewSettings),\n    ),\n    dom(\n      \"div\",\n      css.docBlock(\n        t(\n          \"Documents stay in Trash for 30 days, after which they get deleted permanently.\",\n        ),\n      ),\n      dom.maybe(\n        use => use(home.trashWorkspaces).length === 0,\n        () => css.docBlock(t(\"Trash is empty.\")),\n      ),\n      buildAllDocsBlock(home, home.trashWorkspaces, viewSettings),\n    ),\n  ]);\n}\n\nfunction buildAllDocsBlock(\n  home: HomeModel,\n  workspaces: Observable<Workspace[]>,\n  viewSettings: ViewSettings,\n) {\n  return dom.forEach(workspaces, (ws) => {\n    // Don't show the support workspace -- examples/templates are now retrieved from a special org.\n    // TODO: Remove once support workspaces are removed from the backend.\n    if (ws.isSupportWorkspace) { return null; }\n\n    return css.docBlock(\n      css.docBlockHeaderLink(\n        css.wsLeft(\n          css.docHeaderIcon(\"Folder\"),\n          workspaceName(home.app, ws),\n        ),\n\n        (ws.removedAt ?\n          [\n            css.docRowUpdatedAt(t(\"Deleted {{at}}\", { at: getTimeFromNow(ws.removedAt) })),\n            css.docMenuTrigger(icon(\"Dots\")),\n            menu(() => makeRemovedWsOptionsMenu(home, ws),\n              { placement: \"bottom-end\", parentSelectorToMark: \".\" + css.docRowWrapper.className }),\n          ] :\n          urlState().setLinkUrl({ ws: ws.id })\n        ),\n\n        dom.hide(use => Boolean(getWorkspaceInfo(home.app, ws).isDefault &&\n          use(home.singleWorkspace))),\n\n        testId(\"ws-header\"),\n      ),\n      buildWorkspaceDocBlock(home, ws, viewSettings),\n      testId(\"doc-block\"),\n    );\n  });\n}\n\n/**\n * Builds all templates.\n *\n * Templates are grouped by workspace, with each workspace representing a category of\n * templates. Categories are rendered as collapsible menus, and the contained templates\n * can be viewed in both icon and list view.\n *\n * Used on the Examples & Templates below the featured templates.\n */\nfunction buildAllTemplates(home: HomeModel, templateWorkspaces: Observable<Workspace[]>, viewSettings: ViewSettings) {\n  return dom.forEach(templateWorkspaces, (workspace) => {\n    return css.templatesDocBlock(\n      css.templateBlockHeader(\n        css.wsLeft(\n          css.docHeaderIcon(\"Folder\"),\n          workspace.name,\n        ),\n        testId(\"templates-header\"),\n      ),\n      buildTemplateDocs(home, workspace.docs, viewSettings),\n      css.docBlock.cls(use => \"-\" + use(viewSettings.currentView)),\n      testId(\"templates\"),\n    );\n  });\n}\n\n/**\n * Build the widget for selecting sort and view mode options.\n */\nfunction buildPrefs(viewSettings: ViewSettings, ...args: DomArg<HTMLElement>[]) {\n  return css.prefSelectors(\n    // The Sort selector.\n    dom.update(\n      select<SortPref>(viewSettings.currentSort, [\n        { value: \"name\", label: t(\"By Name\") },\n        { value: \"date\", label: t(\"By Date Modified\") },\n      ],\n      { buttonCssClass: css.sortSelector.className },\n      ),\n      testId(\"sort-mode\"),\n    ),\n\n    // The View selector.\n    buttonSelect<ViewPref>(viewSettings.currentView, [\n      { value: \"icons\", icon: \"TypeTable\", tooltip: t(\"Grid view\") },\n      { value: \"list\", icon: \"TypeCardList\", tooltip: t(\"List view\") },\n    ],\n    cssButtonSelect.cls(\"-light\"),\n    testId(\"view-mode\"),\n    ),\n    ...args,\n  );\n}\n\nfunction buildWorkspaceDocBlock(\n  home: HomeModel,\n  workspace: Workspace,\n  viewSettings: ViewSettings,\n) {\n  function renderDocs(sort: \"date\" | \"name\", view: \"list\" | \"icons\") {\n    // Docs are sorted by name in HomeModel, we only re-sort if we want a different order.\n    let docs = workspace.docs;\n    if (sort === \"date\") {\n      // Note that timestamps are ISO strings, which can be sorted without conversions.\n      docs = sortBy(docs, doc => doc.removedAt || doc.updatedAt).reverse();\n    }\n    return dom.forEach(docs, (doc) => {\n      if (view === \"icons\") {\n        return dom.update(\n          buildPinnedDoc(home, doc, workspace),\n          testId(\"doc\"),\n        );\n      }\n      // TODO: Introduce a \"SwitchSelector\" pattern to avoid the need for N computeds (and N\n      // recalculations) to select one of N items.\n      return css.docRowWrapper(\n        css.docRowLink(\n          doc.removedAt ? null : urlState().setLinkUrl(docUrl(doc)),\n          css.docRowLink.cls(\"-no-access\", !roles.canView(doc.access)),\n          css.docLeft(\n            css.docName(doc.name, testId(\"doc-name\")),\n            css.docPinIcon(\"PinSmall\", dom.show(doc.isPinned)),\n            doc.public ? css.docPublicIcon(\"Public\", testId(\"public\")) : null,\n          ),\n          css.docRowUpdatedAt(getUpdatedAt(doc), testId(\"doc-time\")),\n          (doc.removedAt || doc.disabledAt ?\n            [\n              // For deleted documents, attach the menu to the entire doc row, and include the\n              // \"Dots\" icon just to clarify that there are options.\n              menu(() => makeRemovedDocOptionsMenu(home, doc, workspace),\n                { placement: \"bottom-end\", parentSelectorToMark: \".\" + css.docRowWrapper.className }),\n              css.docMenuTrigger(icon(\"Dots\"), testId(\"doc-options\")),\n            ] :\n            css.docMenuTrigger(icon(\"Dots\"),\n              menu(() => makeDocOptionsMenu(home, doc),\n                { placement: \"bottom-start\", parentSelectorToMark: \".\" + css.docRowWrapper.className }),\n              // Clicks on the menu trigger shouldn't follow the link that it's contained in.\n              dom.on(\"click\", (ev) => { ev.stopPropagation(); ev.preventDefault(); }),\n              testId(\"doc-options\"),\n            )\n          ),\n        ),\n        testId(\"doc\"),\n      );\n    });\n  }\n\n  const { currentSort, currentView } = viewSettings;\n  return [\n    dom.domComputed(\n      use => ({ sort: use(currentSort), view: use(currentView) }),\n      opts => renderDocs(opts.sort, opts.view)),\n    css.docBlock.cls(use => \"-\" + use(currentView)),\n  ];\n}\n\n//  TODO rebuilds of big page chunks (all workspace) cause screen position to jump, sometimes\n//  losing the doc that was e.g. just renamed.\n\nexport function makeRemovedDocOptionsMenu(home: HomeModel, doc: Document, workspace: Workspace) {\n  function hardDeleteDoc() {\n    confirmModal(t(\"Permanently Delete \\\"{{name}}\\\"?\", { name: doc.name }), t(\"Delete Forever\"),\n      () => home.deleteDoc(doc.id, true).catch(reportError),\n      { explanation: t(\"Document will be permanently deleted.\") },\n    );\n  }\n\n  return [\n    menuItem(() => home.restoreDoc(doc), t(\"Restore\"),\n      dom.cls(\"disabled\", !roles.isOwner(doc) || !!workspace.removedAt),\n      testId(\"doc-restore\"),\n    ),\n    menuItem(hardDeleteDoc, t(\"Delete Forever\"),\n      dom.cls(\"disabled\", !roles.isOwner(doc)),\n      testId(\"doc-delete-forever\"),\n    ),\n    (workspace.removedAt ?\n      menuText(t(\"To restore this document, restore the workspace first.\")) :\n      null\n    ),\n  ];\n}\n\nfunction makeRemovedWsOptionsMenu(home: HomeModel, ws: Workspace) {\n  return [\n    menuItem(() => home.restoreWorkspace(ws), t(\"Restore\"),\n      dom.cls(\"disabled\", !roles.canDelete(ws.access)),\n      testId(\"ws-restore\"),\n    ),\n    menuItem(() => home.deleteWorkspace(ws.id, true), t(\"Delete Forever\"),\n      dom.cls(\"disabled\", !roles.canDelete(ws.access) || ws.docs.length > 0),\n      testId(\"ws-delete-forever\"),\n    ),\n    (ws.docs.length > 0 ?\n      menuText(t(\"You may delete a workspace forever once it has no documents in it.\")) :\n      null\n    ),\n  ];\n}\n"
  },
  {
    "path": "app/client/ui/DocMenuCss.ts",
    "content": "import { cssFocusedPanel } from \"app/client/components/RegionFocusSwitcher\";\nimport { transientInput } from \"app/client/ui/transientInput\";\nimport { bigBasicButton } from \"app/client/ui2018/buttons\";\nimport { mediaSmall, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { components } from \"app/common/ThemePrefs\";\n\nimport { styled } from \"grainjs\";\n\n// Import popweasel to ensure that sortSelector style below comes later in CSS than popweasel\n// styles, which gives it priority.\nimport \"popweasel\";\n\nexport const docList = styled(\"div\", `\n  height: 100%;\n  padding: 0px 40px 64px 40px;\n  overflow-y: auto;\n  position: relative;\n  display: flex;\n  flex-direction: column;\n\n  @media ${mediaSmall} {\n    & {\n      padding: 0px 24px 64px 24px;\n    }\n  }\n  @media print {\n    & {\n      display: none;\n    }\n  }\n`);\n\nexport const docListContent = styled(\"div\", `\n  display: flex;\n  width: 100%;\n  max-width: 1340px;\n  margin: 0 auto;\n`);\n\nexport const docMenu = styled(\"div\", `\n  width: 100%;\n`);\n\nconst headerWrap = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 16px;\n`);\n\nexport const docListHeaderWrap = styled(headerWrap, `\n  margin: 16px 0px 24px 0px;\n`);\n\nexport const listHeader = styled(\"div\", `\n  min-height: 32px;\n  line-height: 32px;\n  color: ${theme.text};\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n`);\n\nexport const listHeaderNoWrap = styled(listHeader, `\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n`);\n\nexport const docListHeader = styled(listHeader, `\n  margin-bottom: 24px;\n`);\n\nexport const workspaceHeaderWrap = styled(\"div\", `\n  display: flex;\n  overflow: hidden;\n`);\n\nexport const workspaceHeader = styled(listHeaderNoWrap, `\n  font-size: 24px;\n  font-weight: 500;\n`);\n\nexport const templatesHeaderWrap = styled(\"div\", `\n  display: flex;\n  align-items: baseline;\n  justify-content: space-between;\n  gap: 16px;\n  margin: 16px 0px 24px 0px;\n\n  @media ${mediaSmall} {\n    & {\n      flex-direction: column;\n      align-items: flex-start;\n    }\n  }\n`);\n\nexport const templatesHeader = styled(listHeader, `\n  cursor: pointer;\n`);\n\nexport const featuredTemplatesHeader = styled(docListHeader, `\n  margin-top: 16px;\n  display: flex;\n  align-items: center;\n`);\n\nexport const otherSitesHeader = templatesHeader;\n\nexport const STICKY_HEADER_HEIGHT_PX = 76;\n\n// The main panel may have a focus ring, but it is hidden by the header that is sticky positioned\n// at `top: 0px`. In theory we'd want to say that the sticky header is at `top: 3px` to compensate for\n// potential focus ring; but in that case other issues arise (like we see 3px of content below the sticky\n// header when scrolling).\n// The trick here is to set a top border that is transparent by default, and colored when the main\n// panel has focus. We basically replicate the top-side of the focus ring on the header.\nexport const stickyHeader = styled(headerWrap, `\n  height: ${STICKY_HEADER_HEIGHT_PX}px;\n  position: sticky;\n  top: 0px;\n  background-color: ${theme.mainPanelBg};\n  z-index: ${vars.stickyHeaderZIndex};\n  margin-bottom: 0px;\n  padding: 13px 0px 24px 0px;\n  border-top: 3px solid transparent;\n  .${cssFocusedPanel.className}-focused:focus & {\n    border-top-color: ${components.kbFocusHighlight};\n  }\n`);\n\nexport const allDocsTemplates = styled(\"div\", `\n  display: flex;\n`);\n\nexport const docBlock = styled(\"div\", `\n  color: ${theme.text};\n  max-width: 550px;\n  min-width: 300px;\n  margin-bottom: 28px;\n\n  &-icons {\n    max-width: max-content;\n    min-width: calc(min(550px, 100%));\n  }\n`);\n\nexport const templatesDocBlock = styled(docBlock, `\n  margin-top: 32px;\n`);\n\nexport const otherSitesBlock = styled(\"div\", `\n  color: ${theme.text};\n  margin-bottom: 32px;\n`);\n\nexport const otherSitesButtons = styled(\"div\", `\n  display: flex;\n  overflow: auto;\n  padding-bottom: 16px;\n  margin-top: 16px;\n  margin-bottom: 28px;\n  gap: 16px;\n`);\n\nexport const siteButton = styled(bigBasicButton, `\n  flex: 0 0 auto;\n`);\n\nexport const docHeaderIcon = styled(icon, `\n  margin-right: 8px;\n  margin-top: -3px;\n  --icon-color: ${theme.lightText};\n`);\n\nexport const workspaceHeaderIcon = styled(icon, `\n  flex-shrink: 0;\n  width: 32px;\n  height: 32px;\n  margin-right: 16px;\n  --icon-color: ${theme.lightText};\n`);\n\nexport const pinnedDocsIcon = styled(docHeaderIcon, `\n  --icon-color: ${theme.text};\n`);\n\nexport const featuredTemplatesIcon = styled(icon, `\n  --icon-color: ${theme.text};\n  margin-right: 8px;\n  width: 20px;\n  height: 20px;\n`);\n\nexport const templatesHeaderIcon = styled(docHeaderIcon, `\n  width: 24px;\n  height: 24px;\n`);\n\nexport const otherSitesHeaderIcon = templatesHeaderIcon;\n\nconst docBlockHeader = `\n  display: flex;\n  align-items: center;\n  height: 40px;\n  line-height: 40px;\n  margin-bottom: 8px;\n  margin-right: -16px;\n  color: ${theme.text};\n  font-size: ${vars.mediumFontSize};\n  font-weight: bold;\n  &, &:hover, &:focus {\n    text-decoration: none;\n    outline: none;\n    color: inherit;\n  }\n`;\n\nexport const docBlockHeaderLink = styled(\"a\", docBlockHeader);\n\nexport const templateBlockHeader = styled(\"div\", docBlockHeader);\n\nexport const wsLeft = styled(\"div\", `\n  color: ${theme.text};\n  flex: 1 0 50%;\n  min-width: 0px;\n  margin-right: 24px;\n`);\n\nexport const docRowWrapper = styled(\"div\", `\n  position: relative;\n  margin: 0px -16px 8px -16px;\n  border-radius: 3px;\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.text};\n  --icon-color: ${theme.lightText};\n\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n`);\n\nexport const docRowLink = styled(\"a\", `\n  display: flex;\n  align-items: center;\n  height: 40px;\n  line-height: 40px;\n  border-radius: 3px;\n  outline: none;\n  transition: background-color 2s;\n  &, &:hover, &:focus {\n    text-decoration: none;\n    outline: none;\n    color: inherit;\n  }\n  &-no-access, &-no-access:hover, &-no-access:focus {\n    color: ${theme.disabledText};\n    cursor: not-allowed;\n  }\n`);\n\nexport const docLeft = styled(\"div\", `\n  flex: 1 0 50%;\n  min-width: 0px;\n  margin: 0 16px;\n  display: flex;\n  align-items: center;\n`);\n\nexport const docName = styled(\"div\", `\n  flex: 0 1 auto;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`);\n\nexport const docPinIcon = styled(icon, `\n  flex: none;\n  margin-left: 4px;\n  --icon-color: ${theme.accentIcon};\n`);\n\nexport const docPublicIcon = styled(icon, `\n  flex: none;\n  margin-left: auto;\n  --icon-color: ${theme.accentIcon};\n`);\n\nexport const docEditorInput = styled(transientInput, `\n  flex: 1 0 50%;\n  min-width: 0px;\n  margin: 0 16px;\n  color: initial;\n  font-size: inherit;\n  line-height: initial;\n`);\n\nexport const docRowUpdatedAt = styled(\"div\", `\n  flex: 1 1 50%;\n  color: ${theme.lightText};\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  font-weight: normal;\n`);\n\nexport const docMenuTrigger = styled(\"div\", `\n  flex: none;\n  margin: 0 4px 0 auto;\n  height: 24px;\n  width: 24px;\n  padding: 4px;\n  line-height: 0px;\n  border-radius: 3px;\n  cursor: default;\n  --icon-color: ${theme.docMenuDocOptionsFg};\n  .${docRowLink.className}:hover > & {\n    --icon-color: ${theme.docMenuDocOptionsHoverFg};\n  }\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.docMenuDocOptionsHoverBg};\n    --icon-color: ${theme.docMenuDocOptionsHoverFg};\n  }\n`);\n\nexport const moveDocModalBody = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  border-bottom: 1px solid ${theme.modalBorderDark};\n  margin: 0 -64px;\n  height: 200px;\n`);\n\nexport const moveDocListItem = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n  width: 100%;\n  height: 32px;\n  padding: 12px 64px;\n  cursor: pointer;\n  font-size: ${vars.mediumFontSize};\n\n  &-selected {\n    background-color: ${theme.moveDocsSelectedBg};\n    color: ${theme.moveDocsSelectedFg};\n  }\n  &-disabled {\n    color: ${theme.moveDocsDisabledFg};\n    cursor: default;\n  }\n`);\n\nexport const moveDocListText = styled(\"div\", `\n  display: flex;\n  flex: 1 1 0;\n  flex-direction: column;\n  justify-content: center;\n`);\n\nexport const moveDocListHintText = styled(moveDocListText, `\n  text-align: right;\n`);\n\nexport const spinner = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  height: 80px;\n  margin: auto;\n  margin-top: 80px;\n`);\n\nexport const prefSelectors = styled(\"div\", `\n  float: right;\n  display: flex;\n  align-items: center;\n`);\n\nexport const sortSelector = styled(\"div\", `\n  margin-right: 24px;\n\n  /* negate the styles of a select that normally looks like a button */\n  border: none;\n  display: inline-flex;\n  height: unset;\n  line-height: unset;\n  align-items: center;\n  border-radius: ${vars.controlBorderRadius};\n  color: ${theme.controlFg};\n  --icon-color: ${theme.controlFg};\n  background-color: unset;\n\n  &:focus, &:hover {\n    outline: none;\n    box-shadow: none;\n    background-color: ${theme.hover};\n  }\n  @media ${mediaSmall} {\n    & {\n      margin-right: 0;\n    }\n  }\n`);\n\nexport const upgradeButton = styled(\"div\", `\n  margin-left: 32px;\n\n  @media ${mediaSmall} {\n    & {\n      margin-left: 8px;\n    }\n  }\n`);\n\nexport const upgradeCard = styled(\"div\", `\n  margin-left: 64px;\n`);\n\nexport const paragraph = styled(docBlock, `\n  color: ${theme.text};\n  line-height: 1.6;\n`);\n\nexport const introLine = styled(paragraph, `\n  font-size: ${vars.introFontSize};\n  margin-bottom: 8px;\n`);\n"
  },
  {
    "path": "app/client/ui/DocTour.ts",
    "content": "import { DocComm } from \"app/client/components/DocComm\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { sameDocumentUrlState } from \"app/client/models/gristUrlState\";\nimport { cssButtons, cssLinkBtn, cssLinkIcon } from \"app/client/ui/ExampleCard\";\nimport { IOnBoardingMsg, startOnBoarding } from \"app/client/ui/OnBoardingPopups\";\nimport { isNarrowScreen } from \"app/client/ui2018/cssVars\";\nimport { IconList, IconName } from \"app/client/ui2018/IconList\";\nimport { DocData } from \"app/common/DocData\";\n\nimport { Placement } from \"@popperjs/core\";\nimport { placements } from \"@popperjs/core/lib/enums\";\nimport { dom } from \"grainjs\";\nimport sortBy from \"lodash/sortBy\";\n\nconst t = makeT(\"DocTour\");\n\nexport async function startDocTour(docData: DocData, docComm: DocComm, onFinishCB: () => void) {\n  const docTour: IOnBoardingMsg[] = await makeDocTour(docData, docComm) || invalidDocTour;\n  exposeDocTour(docTour);\n  startOnBoarding(docTour, onFinishCB);\n}\n\nconst invalidDocTour: IOnBoardingMsg[] = [{\n  title: t(\"No valid document tour\"),\n  body: t(\"Cannot construct a document tour from the data in this document. \\\nEnsure there is a table named GristDocTour with columns Title, Body, Placement, and Location.\"),\n  selector: \"document\",\n  showHasModal: true,\n}];\n\nasync function makeDocTour(docData: DocData, docComm: DocComm): Promise<IOnBoardingMsg[] | null> {\n  const tableId = \"GristDocTour\";\n  if (!docData.getTable(tableId)) {\n    return null;\n  }\n  // Make sure any formulas in GristDocTour table have had time to evaluate. For example, for a\n  // first time open of a new document copy, any use of SELF_HYPERLINK will be stale since the URL\n  // of the document has changed.\n  await docComm.waitForInitialization();\n  await docData.fetchTable(tableId);\n  const tableData = docData.getTable(tableId)!;\n\n  const result = sortBy(tableData.getRowIds(), tableData.getRowPropFunc(\"manualSort\") as any).map((rowId) => {\n    function getValue(colId: string): string {\n      return String(tableData.getValue(rowId, colId) || \"\");\n    }\n    const title = getValue(\"Title\");\n    let body: HTMLElement | string = getValue(\"Body\");\n    const linkText = getValue(\"Link_Text\");\n    const linkUrl = getValue(\"Link_URL\");\n    const linkIcon = getValue(\"Link_Icon\") as IconName;\n    const locationValue = getValue(\"Location\");\n    let placement = getValue(\"Placement\");\n\n    if (!(title || body)) {\n      return null;\n    }\n\n    const urlState = sameDocumentUrlState(locationValue);\n    if (isNarrowScreen() || !placements.includes(placement as Placement)) {\n      placement = \"auto\";\n    }\n\n    let validLinkUrl = true;\n    try {\n      new URL(linkUrl);\n    } catch {\n      validLinkUrl = false;\n    }\n\n    if (validLinkUrl && linkText) {\n      body = dom(\n        \"div\",\n        dom(\"p\", body),\n        dom(\"p\",\n          cssButtons(cssLinkBtn(\n            IconList.includes(linkIcon) ? cssLinkIcon(linkIcon) : null,\n            linkText,\n            { href: linkUrl, target: \"_blank\" },\n          )),\n        ),\n      );\n    }\n\n    return {\n      title,\n      body,\n      placement,\n      urlState,\n      selector: \".active_cursor\",\n      // Center the popup if the user doesn't provide a link to a cell\n      showHasModal: !urlState?.hash,\n    };\n  }).filter(x => x !== null) as IOnBoardingMsg[];\n  if (!result.length) {\n    return null;\n  }\n  return result;\n}\n\n// for easy testing\nfunction exposeDocTour(docTour: IOnBoardingMsg[]) {\n  (window as any)._gristDocTour = () =>\n    docTour.map(msg => ({\n      ...msg,\n      body: typeof msg.body === \"string\" ? msg.body :\n        (msg.body as HTMLElement)?.outerHTML\n          .replace(/_grain\\d+_/g, \"_grainXXX_\"),\n      urlState: msg.urlState?.hash,\n    }));\n}\n"
  },
  {
    "path": "app/client/ui/DocTutorial.css",
    "content": ".doc-tutorial-popup h1,\n.doc-tutorial-popup h2,\n.doc-tutorial-popup h3,\n.doc-tutorial-popup h4,\n.doc-tutorial-popup h5,\n.doc-tutorial-popup h6,\n.doc-tutorial-popup p,\n.doc-tutorial-popup li {\n  color: var(--grist-theme-text, #262633);\n}\n\n.doc-tutorial-popup h2,\n.doc-tutorial-popup h3,\n.doc-tutorial-popup h4,\n.doc-tutorial-popup h5,\n.doc-tutorial-popup h6 {\n  margin: 20px 0px 10px 0px;\n  font-weight: 400;\n}\n\n.doc-tutorial-popup h1 {\n  margin: 0px 0px 24px 0px;\n  font-weight: 500;\n  font-size: 28px;\n  line-height: 42px;\n}\n\n.doc-tutorial-popup h2 {\n  font-size: 24px;\n  line-height: 36px;\n}\n\n.doc-tutorial-popup h3 {\n  font-size: 22px;\n  line-height: 33px;\n}\n\n.doc-tutorial-popup h4 {\n  font-size: 20px;\n  line-height: 30px;\n}\n\n.doc-tutorial-popup h5 {\n  font-size: 18px;\n  line-height: 27px;\n}\n\n.doc-tutorial-popup h6 {\n  font-size: 16px;\n  line-height: 24px;\n}\n\n.doc-tutorial-popup p {\n  margin: 0px 0px 16px 0px;\n  font-weight: 400;\n  font-size: 14px;\n  line-height: 21px;\n}\n\n.doc-tutorial-popup a,\n.doc-tutorial-popup a:hover {\n  color: var(--grist-theme-link, #16B378);\n}\n\n.doc-tutorial-popup li {\n  font-weight: 400;\n  font-size: 14px;\n  line-height: 21px;\n}\n\n.doc-tutorial-popup ol,\n.doc-tutorial-popup ul {\n  margin: 0px 0px 10px 0px;\n}\n\n.doc-tutorial-popup code {\n  padding: 2px 5px;\n  color: var(--grist-theme-tutorials-popup-code-fg, #333333);\n  background: var(--grist-theme-tutorials-popup-code-bg, #FFFFFF);\n  border: 1px solid var(--grist-theme-tutorials-popup-code-border, #E1E4E5);\n  white-space: pre-wrap;\n  word-wrap: break-word;\n}\n\n.doc-tutorial-popup pre {\n  background: var(--grist-theme-tutorials-popup-code-bg, #FFFFFF);\n  border: 1px solid var(--grist-theme-tutorials-popup-code-border, #E1E4E5);\n}\n\n.doc-tutorial-popup pre > code {\n  background: none;\n  border: none;\n}\n\n.doc-tutorial-popup iframe {\n  border: none;\n}\n\n.doc-tutorial-popup-thumbnail {\n  position: relative;\n  margin: 20px 0px 30px 0px;\n  cursor: pointer;\n}\n\n.doc-tutorial-popup-thumbnail-half-screenshot {\n  margin-left: auto;\n  margin-right: auto;\n  width: 50%;\n}\n\n.doc-tutorial-popup-thumbnail img {\n  width: 100%;\n  border: 1px solid var(--grist-theme-tutorials-popup-border, #D9D9D9);\n  border-radius: 4px;\n  padding: 4px;\n  background-color: transparent;\n}\n\n.doc-tutorial-popup-thumbnail-icon-wrapper {\n  pointer-events: none;\n  position: absolute;\n  bottom: 8px;\n  right: 8px;\n  padding: 4px;\n  background-color: #D9D9D9;\n  border-radius: 4px;\n}\n\n.doc-tutorial-popup-thumbnail-icon {\n  mask-image: var(--icon-Maximize);\n  -webkit-mask-image: var(--icon-Maximize);\n  background-color: var(--grist-theme-accent-icon, var(--grist-color-light-green));\n  width: 16px;\n  height: 16px;\n}\n"
  },
  {
    "path": "app/client/ui/DocTutorial.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { sessionStorageJsonObs } from \"app/client/lib/localStorageObs\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { getWelcomeHomeUrl } from \"app/client/lib/urlUtils\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { renderer } from \"app/client/ui/DocTutorialRenderer\";\nimport { cssPopupBody, FLOATING_POPUP_TOOLTIP_KEY, FloatingPopup, PopupPosition } from \"app/client/ui/FloatingPopup\";\nimport { sanitizeTutorialHTML } from \"app/client/ui/sanitizeHTML\";\nimport { hoverTooltip, setHoverTooltip } from \"app/client/ui/tooltips\";\nimport { basicButton, primaryButton, textButton } from \"app/client/ui2018/buttons\";\nimport { mediaXSmall, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { confirmModal, modal } from \"app/client/ui2018/modals\";\nimport { parseUrlId } from \"app/common/gristUrls\";\n\nimport { Disposable, dom, Holder, makeTestId, Observable, styled } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\nimport range from \"lodash/range\";\nimport sortBy from \"lodash/sortBy\";\nimport { marked, Token } from \"marked\";\n\ninterface DocTutorialSlide {\n  slideContent: string;\n  boxContent?: string;\n  slideTitle?: string;\n  imageUrls: string[];\n}\n\nconst t = makeT(\"DocTutorial\");\n\nconst testId = makeTestId(\"test-doc-tutorial-\");\n\nexport class DocTutorial extends Disposable {\n  private _appModel = this._gristDoc.docPageModel.appModel;\n  private _userId = this._appModel.currentUser?.id ?? 0;\n  private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();\n  private _currentFork = this._currentDoc?.forks?.[0];\n  private _docComm = this._gristDoc.docComm;\n  private _docData = this._gristDoc.docData;\n  private _docId = this._gristDoc.docId();\n  private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);\n  private _currentSlideIndex = Observable.create(this, this._currentFork?.options?.tutorial?.lastSlideIndex ?? 0);\n  private _percentComplete = this._currentFork?.options?.tutorial?.percentComplete;\n  private _popupHolder = Holder.create<FloatingPopup>(this);\n  private _width = this.autoDispose(sessionStorageJsonObs(\n    `u:${this._userId};d:${this._docId};docTutorialWidth`,\n    436,\n  ));\n\n  private _height = this.autoDispose(sessionStorageJsonObs(\n    `u:${this._userId};d:${this._docId};docTutorialHeight`,\n    711,\n  ));\n\n  private _position = this.autoDispose(sessionStorageJsonObs<PopupPosition | undefined>(\n    `u:${this._userId};d:${this._docId};docTutorialPosition`,\n    undefined,\n  ));\n\n  private _saveProgressDebounced = debounce(this._saveProgress, 1000, {\n    // Save progress immediately if at least 1 second has passed since the last change.\n    leading: true,\n    // Otherwise, wait 1 second before saving.\n    trailing: true,\n  });\n\n  constructor(private _gristDoc: GristDoc) {\n    super();\n\n    this.autoDispose(this._currentSlideIndex.addListener((slideIndex) => {\n      const numSlides = this._slides.get()?.length ?? 0;\n      if (numSlides > 0) {\n        this._percentComplete = Math.max(\n          Math.floor((slideIndex / numSlides) * 100),\n          this._percentComplete ?? 0,\n        );\n      } else {\n        this._percentComplete = undefined;\n      }\n    }));\n  }\n\n  public async start() {\n    this._showPopup();\n    await this._loadSlides();\n\n    const tableData = this._docData.getTable(\"GristDocTutorial\");\n    if (tableData) {\n      this.autoDispose(tableData.tableActionEmitter.addListener(() => this._reloadSlides()));\n    }\n\n    this._logTelemetryEvent(\"tutorialOpened\");\n  }\n\n  private _showPopup() {\n    const popup = FloatingPopup.create(this._popupHolder, {\n      title: this._buildPopupTitle.bind(this),\n      content: this._buildPopupContent.bind(this),\n      onMoveEnd: position => this._position.set(position),\n      onResizeEnd: ({ width, height, ...position }) => {\n        this._width.set(width);\n        this._height.set(height);\n        this._position.set(position);\n      },\n      width: this._width.get(),\n      height: this._height.get(),\n      minWidth: 328,\n      minHeight: 300,\n      position: this._position.get(),\n      minimizable: true,\n      stopClickPropagationOnMove: true,\n      args: this._buildPopupArgs(),\n      testId,\n    });\n    popup.showPopup();\n  }\n\n  private _buildPopupTitle() {\n    return dom(\"span\", dom.text(this._gristDoc.docPageModel.currentDocTitle), testId(\"popup-header\"));\n  }\n\n  private _buildPopupContent() {\n    return [\n      dom.domComputed((use) => {\n        const slides = use(this._slides);\n        const slideIndex = use(this._currentSlideIndex);\n        const slide = slides?.[slideIndex];\n        return cssPopupBody(\n          !slide ? cssSpinner(loadingSpinner()) : [\n            dom(\"div\", (elem) => {\n              elem.innerHTML = slide.slideContent;\n            }),\n            !slide.boxContent ? null : cssTryItOutBox(\n              dom(\"div\", (elem) => { elem.innerHTML = slide.boxContent!; }),\n            ),\n            dom.on(\"click\", (ev) => {\n              if ((ev.target as HTMLElement).tagName !== \"IMG\") {\n                return;\n              }\n\n              this._openLightbox((ev.target as HTMLImageElement).src);\n            }),\n            this._initializeImages(),\n          ],\n          testId(\"popup-body\"),\n        );\n      }),\n      cssPopupFooter(\n        dom.domComputed((use) => {\n          const slides = use(this._slides);\n          if (!slides) { return null; }\n\n          const slideIndex = use(this._currentSlideIndex);\n          const numSlides = slides.length;\n          const isFirstSlide = slideIndex === 0;\n          const isLastSlide = slideIndex === numSlides - 1;\n          return [\n            cssProgressBar(\n              range(slides.length).map(i => cssProgressBarDot(\n                hoverTooltip(slides[i].slideTitle, {\n                  closeOnClick: false,\n                  key: FLOATING_POPUP_TOOLTIP_KEY,\n                }),\n                cssProgressBarDot.cls(\"-current\", i === slideIndex),\n                i === slideIndex ? null : dom.on(\"click\", () => this._changeSlide(i)),\n                testId(`popup-slide-${i + 1}`),\n              )),\n            ),\n            cssFooterButtons(\n              basicButton(t(\"Previous\"),\n                dom.on(\"click\", async () => {\n                  await this._previousSlide();\n                }),\n                { style: `visibility: ${isFirstSlide ? \"hidden\" : \"visible\"}` },\n                testId(\"popup-previous\"),\n              ),\n              primaryButton(isLastSlide ? t(\"Finish\") : t(\"Next\"),\n                isLastSlide ?\n                  dom.on(\"click\", async () => await this._exitTutorial(true)) :\n                  dom.on(\"click\", async () => await this._nextSlide()),\n                testId(\"popup-next\"),\n              ),\n            ),\n          ];\n        }),\n        testId(\"popup-footer\"),\n      ),\n      cssTutorialControls(\n        cssTextButton(\n          cssRestartIcon(\"Undo\"),\n          t(\"Restart\"),\n          dom.on(\"click\", () => this._restartTutorial()),\n          testId(\"popup-restart\"),\n        ),\n        cssButtonsSeparator(),\n        cssTextButton(\n          cssSkipIcon(\"Skip\"),\n          t(\"End tutorial\"),\n          dom.on(\"click\", () => this._exitTutorial()),\n          testId(\"popup-end-tutorial\"),\n        ),\n      ),\n    ];\n  }\n\n  private _buildPopupArgs() {\n    return [\n      dom.cls(\"doc-tutorial-popup\"),\n      // Pre-fetch images from all slides and store them in a hidden div.\n      dom.maybe(this._slides, slides =>\n        dom(\"div\",\n          { style: \"display: none;\" },\n          dom.forEach(slides, (slide) => {\n            if (slide.imageUrls.length === 0) { return null; }\n            return dom(\"div\", slide.imageUrls.map(src => dom(\"img\", { src })));\n          }),\n        ),\n      ),\n    ];\n  }\n\n  private _logTelemetryEvent(event: \"tutorialOpened\" | \"tutorialProgressChanged\") {\n    logTelemetryEvent(event, {\n      full: {\n        tutorialForkIdDigest: this._currentFork?.id,\n        tutorialTrunkIdDigest: this._currentFork?.trunkId,\n        lastSlideIndex: this._currentSlideIndex.get(),\n        numSlides: this._slides.get()?.length,\n        percentComplete: this._percentComplete,\n      },\n    });\n  }\n\n  private async _loadSlides() {\n    const tableId = \"GristDocTutorial\";\n    if (!this._docData.getTable(tableId)) {\n      throw new Error(\"DocTutorial failed to find table GristDocTutorial\");\n    }\n\n    await this._docComm.waitForInitialization();\n    if (this.isDisposed()) { return; }\n\n    await this._docData.fetchTable(tableId);\n    if (this.isDisposed()) { return; }\n\n    const tableData = this._docData.getTable(tableId)!;\n    const slides = (await Promise.all(\n      sortBy(tableData.getRowIds(), tableData.getRowPropFunc(\"manualSort\") as any)\n        .map(async (rowId) => {\n          let slideTitle: string | undefined;\n          const imageUrls: string[] = [];\n\n          const getValue = (colId: string): string | undefined => {\n            const value = tableData.getValue(rowId, colId);\n            return value ? String(value) : undefined;\n          };\n\n          const walkTokens = (token: Token) => {\n            if (token.type === \"image\") {\n              imageUrls.push(token.href);\n            }\n\n            if (!slideTitle && token.type === \"heading\" && token.depth === 1) {\n              slideTitle = token.text;\n            }\n          };\n\n          let slideContent = getValue(\"slide_content\");\n          if (!slideContent) { return null; }\n          slideContent = sanitizeTutorialHTML(await marked.parse(slideContent, {\n            async: true, renderer, walkTokens,\n          }));\n\n          let boxContent = getValue(\"box_content\");\n          if (boxContent) {\n            boxContent = sanitizeTutorialHTML(await marked.parse(boxContent, {\n              async: true, renderer, walkTokens,\n            }));\n          }\n          return {\n            slideContent,\n            boxContent,\n            slideTitle,\n            imageUrls,\n          };\n        }),\n    )).filter(slide => slide !== null) as DocTutorialSlide[];\n    if (this.isDisposed()) { return; }\n\n    if (slides.length === 0) {\n      throw new Error(\"DocTutorial failed to find slides in table GristDocTutorial\");\n    }\n\n    this._slides.set(slides);\n  }\n\n  private async _reloadSlides() {\n    await this._loadSlides();\n    const slides = this._slides.get();\n    if (!slides) { return; }\n\n    if (this._currentSlideIndex.get() > slides.length - 1) {\n      this._currentSlideIndex.set(slides.length - 1);\n    }\n  }\n\n  private async _saveProgress() {\n    await this._appModel.api.updateDoc(this._docId, {\n      options: {\n        ...this._currentFork?.options,\n        tutorial: {\n          lastSlideIndex: this._currentSlideIndex.get(),\n          percentComplete: this._percentComplete,\n        },\n      },\n    });\n    this._logTelemetryEvent(\"tutorialProgressChanged\");\n  }\n\n  private async _changeSlide(slideIndex: number) {\n    this._currentSlideIndex.set(slideIndex);\n    await this._saveProgressDebounced();\n  }\n\n  private async _previousSlide() {\n    await this._changeSlide(this._currentSlideIndex.get() - 1);\n  }\n\n  private async _nextSlide() {\n    await this._changeSlide(this._currentSlideIndex.get() + 1);\n  }\n\n  private async _exitTutorial(markAsComplete = false) {\n    this._saveProgressDebounced.cancel();\n    if (markAsComplete) { this._percentComplete = 100; }\n    await this._saveProgressDebounced();\n    const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get();\n    if (lastVisitedOrg) {\n      await urlState().pushUrl({ org: lastVisitedOrg });\n    } else {\n      window.location.assign(getWelcomeHomeUrl());\n    }\n  }\n\n  private async _restartTutorial() {\n    const doRestart = async () => {\n      const urlId = this._currentDoc!.id;\n      const { trunkId } = parseUrlId(urlId);\n      const docApi = this._appModel.api.getDocAPI(urlId);\n      await docApi.replace({ sourceDocId: trunkId, resetTutorialMetadata: true });\n    };\n\n    confirmModal(\n      t(\"Do you want to restart the tutorial? All progress will be lost.\"),\n      t(\"Restart\"),\n      doRestart,\n      {\n        modalOptions: {\n          backerDomArgs: [\n            // Stack modal above the tutorial popup.\n            dom.style(\"z-index\", vars.tutorialModalZIndex.toString()),\n          ],\n        },\n      },\n    );\n  }\n\n  private _initializeImages() {\n    return (element: HTMLElement) => {\n      setTimeout(() => {\n        const imgs = element.querySelectorAll(\"img\");\n        for (const img of imgs) {\n          // Re-assigning src to itself is a neat way to restart a GIF.\n          // eslint-disable-next-line no-self-assign\n          img.src = img.src;\n\n          setHoverTooltip(img, t(\"Click to expand\"), {\n            key: FLOATING_POPUP_TOOLTIP_KEY,\n            modifiers: {\n              flip: {\n                boundariesElement: \"scrollParent\",\n              },\n            },\n            placement: \"bottom\",\n          });\n        }\n      }, 0);\n    };\n  }\n\n  private _openLightbox(src: string) {\n    modal((ctl) => {\n      this.onDispose(ctl.close);\n      return [\n        cssFullScreenModal.cls(\"\"),\n        cssModalCloseButton(\"CrossBig\",\n          dom.on(\"click\", () => ctl.close()),\n          testId(\"lightbox-close\"),\n        ),\n        cssModalContent(cssModalImage({ src }, testId(\"lightbox-image\"))),\n        dom.on(\"click\", (ev, elem) => void (ev.target === elem ? ctl.close() : null)),\n        testId(\"lightbox\"),\n      ];\n    }, {\n      backerDomArgs: [\n        // Stack modal above the tutorial popup.\n        dom.style(\"z-index\", vars.tutorialModalZIndex.toString()),\n      ],\n    });\n  }\n}\n\nconst cssPopupFooter = styled(\"div\", `\n  display: flex;\n  column-gap: 24px;\n  align-items: center;\n  justify-content: space-between;\n  flex-shrink: 0;\n  padding: 16px;\n  border-top: 1px solid ${theme.tutorialsPopupBorder};\n`);\n\nconst cssTryItOutBox = styled(\"div\", `\n  margin-top: 16px;\n  padding: 24px;\n  border-radius: 4px;\n  background-color: ${theme.tutorialsPopupBoxBg};\n`);\n\nconst cssProgressBar = styled(\"div\", `\n  display: flex;\n  gap: 8px;\n  flex-grow: 1;\n  flex-wrap: wrap;\n`);\n\nconst cssProgressBarDot = styled(\"div\", `\n  width: 10px;\n  height: 10px;\n  border-radius: 5px;\n  align-self: center;\n  cursor: pointer;\n  background-color: ${theme.progressBarBg};\n\n  &-current {\n    cursor: default;\n    background-color: ${theme.progressBarFg};\n  }\n`);\n\nconst cssFooterButtons = styled(\"div\", `\n  display: flex;\n  justify-content: flex-end;\n  column-gap: 8px;\n  flex-shrink: 0;\n  min-width: 140px;\n\n  @media ${mediaXSmall} {\n    & {\n      flex-direction: column;\n      row-gap: 8px;\n      column-gap: 0px;\n      min-width: 0px;\n    }\n  }\n`);\n\nconst cssFullScreenModal = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  row-gap: 8px;\n  background-color: initial;\n  width: 100%;\n  height: 100%;\n  border: none;\n  border-radius: 0px;\n  box-shadow: none;\n  padding: 0px;\n`);\n\nconst cssModalCloseButton = styled(icon, `\n  align-self: flex-end;\n  flex-shrink: 0;\n  height: 24px;\n  width: 24px;\n  cursor: pointer;\n  --icon-color: ${theme.modalBackdropCloseButtonFg};\n  &:hover {\n    --icon-color: ${theme.modalBackdropCloseButtonHoverFg};\n  }\n`);\n\nconst cssModalContent = styled(\"div\", `\n  align-self: center;\n  min-height: 0;\n  margin-top: auto;\n  margin-bottom: auto;\n`);\n\nconst cssModalImage = styled(\"img\", `\n  height: 100%;\n  max-width: min(100%, 1200px);\n`);\n\nconst cssSpinner = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  height: 100%;\n`);\n\nconst cssTutorialControls = styled(\"div\", `\n  background-color: ${theme.notificationsPanelHeaderBg};\n  display: flex;\n  justify-content: center;\n  padding: 8px;\n`);\n\nconst cssTextButton = styled(textButton, `\n  font-weight: 500;\n  display: flex;\n  align-items: center;\n  column-gap: 4px;\n  margin: 0 16px;\n`);\n\nconst cssRestartIcon = styled(icon, `\n  width: 14px;\n  height: 14px;\n`);\n\nconst cssButtonsSeparator = styled(\"div\", `\n  width: 0;\n  border-right: 1px solid ${theme.controlFg};\n`);\n\nconst cssSkipIcon = styled(icon, `\n  width: 20px;\n  height: 20px;\n  margin: 0px -3px;\n`);\n\n// This is aligned with css in ./DocTutorial.css\nexport const cssCode = styled(\"code\", `\n  padding: 2px 5px;\n  color: ${theme.tutorialsPopupCodeFg};\n  background: ${theme.tutorialsPopupCodeBg};\n  border: 1px solid ${theme.tutorialsPopupCodeBorder};\n  white-space: pre-wrap;\n  word-wrap: break-word;\n  font-size: max(12.6px, 90%); /* 90% of 13px */\n`);\n"
  },
  {
    "path": "app/client/ui/DocTutorialRenderer.ts",
    "content": "import { marked } from \"marked\";\n\nexport const renderer = new marked.Renderer();\n\nrenderer.image = ({ href, title }) => {\n  let classes = \"doc-tutorial-popup-thumbnail\";\n  const hash = href?.split(\"#\")?.[1];\n  if (hash) {\n    const extraClass = `doc-tutorial-popup-thumbnail-${hash}`;\n    classes += ` ${extraClass}`;\n  }\n  return `<div class=\"${classes}\">\n  <img src=\"${href}\" title=\"${title ?? \"\"}\" />\n  <div class=\"doc-tutorial-popup-thumbnail-icon-wrapper\">\n    <div class=\"doc-tutorial-popup-thumbnail-icon\"></div>\n  </div>\n</div>`;\n};\n\nrenderer.link = ({ href, text }) => {\n  return `<a href=\"${href}\" target=\"_blank\">${text}</a>`;\n};\n"
  },
  {
    "path": "app/client/ui/DocumentSettings.ts",
    "content": "/**\n * This module export a component for editing some document settings consisting of the timezone,\n * (new settings to be added here ...).\n */\nimport { cssPrimarySmallLink, cssSmallButton, cssSmallLinkButton } from \"app/client/components/Forms/styles\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { ACIndexImpl } from \"app/client/lib/ACIndex\";\nimport { ACSelectItem, buildACSelect } from \"app/client/lib/ACSelect\";\nimport { copyToClipboard } from \"app/client/lib/clipboardUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { cssMarkdownSpan } from \"app/client/lib/markdown\";\nimport { reportError } from \"app/client/models/AppModel\";\nimport { reportWarning } from \"app/client/models/errors\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { KoSaveableObservable } from \"app/client/models/modelUtil\";\nimport { AdminSection, AdminSectionItem } from \"app/client/ui/AdminPanelCss\";\nimport { openFilePicker } from \"app/client/ui/FileDialog\";\nimport { buildNotificationsConfig } from \"app/client/ui/Notifications\";\nimport { hoverTooltip, showTransientTooltip, withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { bigBasicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { cssRadioCheckboxOptions, labeledSquareCheckbox, radioCheckboxOption } from \"app/client/ui2018/checkbox\";\nimport { colors, mediaSmall, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { select } from \"app/client/ui2018/menus\";\nimport { confirmModal, cssModalButtons, cssModalTitle, cssSpinner, modal } from \"app/client/ui2018/modals\";\nimport { buildCurrencyPicker } from \"app/client/widgets/CurrencyPicker\";\nimport { buildTZAutocomplete } from \"app/client/widgets/TZAutocomplete\";\nimport { EngineCode } from \"app/common/DocumentSettings\";\nimport { commonUrls, GristLoadConfig, PREFERRED_STORAGE_ANCHOR } from \"app/common/gristUrls\";\nimport { not, propertyCompare } from \"app/common/gutil\";\nimport { getCurrency, locales } from \"app/common/Locales\";\nimport { isOwner, isOwnerOrEditor } from \"app/common/roles\";\nimport {\n  DOCTYPE_NORMAL,\n  DOCTYPE_TEMPLATE,\n  DOCTYPE_TUTORIAL,\n  DocumentType,\n} from \"app/common/UserAPI\";\n\nimport {\n  Computed,\n  Disposable,\n  dom,\n  DomElementMethod,\n  fromKo,\n  IDisposableOwner,\n  IDomArgs,\n  makeTestId,\n  Observable,\n  styled,\n} from \"grainjs\";\nimport * as moment from \"moment-timezone\";\n\nimport type { DocPageModel } from \"app/client/models/DocPageModel\";\n\nconst t = makeT(\"DocumentSettings\");\nconst testId = makeTestId(\"test-settings-\");\n\nexport class DocSettingsPage extends Disposable {\n  private _docInfo = this._gristDoc.docInfo;\n\n  private _timezone = this._docInfo.timezone;\n  private _locale: KoSaveableObservable<string> = this._docInfo.documentSettingsJson.prop(\"locale\");\n  private _currency: KoSaveableObservable<string | undefined> = this._docInfo.documentSettingsJson.prop(\"currency\");\n  private _acceptProposals = Observable.create(\n    this,\n    Boolean(this._gristDoc.docPageModel.currentDoc.get()?.options?.proposedChanges?.acceptProposals),\n  );\n\n  private _working: Observable<boolean> = Observable.create(this, false);\n\n  constructor(private _gristDoc: GristDoc) {\n    super();\n  }\n\n  public buildDom() {\n    const docPageModel = this._gristDoc.docPageModel;\n    const isTimingOn = this._gristDoc.isTimingOn;\n    const isDocOwner = isOwner(docPageModel.currentDoc.get());\n    const isDocEditor = isOwnerOrEditor(docPageModel.currentDoc.get());\n    const isFork = docPageModel.currentDoc.get()?.isFork;\n\n    return cssContainer({ tabIndex: \"-1\" },\n      dom.create(AdminSection, t(\"Document settings\"), [\n        dom.create(AdminSectionItem, {\n          id: \"timezone\",\n          name: t(\"Time zone\"),\n          description: t(\"Default for DateTime columns\"),\n          value: dom.create(cssTZAutoComplete, moment, fromKo(this._timezone), val => this._timezone.saveOnly(val)),\n        }),\n        dom.create(AdminSectionItem, {\n          id: \"locale\",\n          name: t(\"Locale\"),\n          description: t(\"For number and date formats\"),\n          value: dom.create(cssLocalePicker, this._locale),\n        }),\n        dom.create(AdminSectionItem, {\n          id: \"currency\",\n          name: t(\"Currency\"),\n          description: t(\"For currency columns\"),\n          value: dom.domComputed(fromKo(this._locale), l =>\n            dom.create(cssCurrencyPicker, fromKo(this._currency), val => this._currency.saveOnly(val),\n              { defaultCurrencyLabel: t(\"Local currency ({{currency}})\", { currency: getCurrency(l) }) }),\n          ),\n        }),\n        dom.create(AdminSectionItem, {\n          id: \"templateMode\",\n          name: t(\"Document type\"),\n          description: t(\"Default, template, or tutorial\"),\n          value: cssDocTypeContainer(\n            dom.create(\n              displayCurrentType,\n              docPageModel.type,\n            ),\n            cssSmallButton(t(\"Edit\"),\n              dom.on(\"click\", this._buildDocumentTypeModal.bind(this)),\n              testId(\"doctype-edit\"),\n            ),\n          ),\n          disabled: isDocOwner ? false : t(\"Only available to document owners\"),\n        }),\n        !isFork ? dom.create(AdminSectionItem, {\n          id: \"acceptProposals\",\n          name: [t(\"Suggestions\"), betaTag(t(\"experiment\"), { style: \"margin-left: 4px;\" })],\n          description: withInfoTooltip(\n            t(\"Allow others to suggest changes\"),\n            \"suggestions\",\n          ),\n          value: labeledSquareCheckbox(\n            this._acceptProposals,\n            t(\"Enable suggestions\"),\n            dom.on(\"click\", async (elem) => {\n              this._working.set(true);\n              try {\n                const docId = docPageModel.currentDocId.get();\n                if (!docId) {\n                  // Should never happen, don't bother translating.\n                  reportError(new Error(\"Document not found\"));\n                  return;\n                }\n                const acceptProposals = !this._acceptProposals.get();\n                await docPageModel.appModel.api.updateDoc(docId, { options: { proposedChanges: { acceptProposals } } });\n                window.location.reload();\n              } catch (e) {\n                reportError(e);\n              } finally {\n                this._working.set(false);\n              }\n            }),\n            dom.prop(\"disabled\", this._working),\n            testId(\"accept-proposals\"),\n          ),\n          disabled: isDocOwner ? false : t(\"Only available to document owners\"),\n        }) : null,\n      ]),\n\n      dom.create(buildNotificationsConfig, this._gristDoc.docApi, docPageModel.currentDoc.get()),\n\n      dom.create(AdminSection, t(\"Data engine\"), [\n        dom.create(AdminSectionItem, {\n          id: \"timings\",\n          name: t(\"Formula timer\"),\n          description: dom(\"div\",\n            dom.maybe(isTimingOn, () => cssRedText(t(\"Timing is on\") + \"...\")),\n            dom.maybe(not(isTimingOn), () => t(\"Find slow formulas\")),\n            testId(\"timing-desc\"),\n          ),\n          value: dom.domComputed(isTimingOn, (timingOn) => {\n            if (timingOn) {\n              return dom(\"div\",\n                cssPrimarySmallLinkSettings(\n                  t(\"Stop timing...\"),\n                  urlState().setHref({ docPage: \"timing\" }),\n                  { target: \"_blank\" },\n                  testId(\"timing-stop\"),\n                ),\n              );\n            } else {\n              return cssSmallButtonSettings(t(\"Start timing\"),\n                dom.on(\"click\", this._startTiming.bind(this)),\n                testId(\"timing-start\"),\n              );\n            }\n          }),\n          expandedContent: dom(\"div\", t(\n            \"Once you start timing, Grist will measure the time it takes to evaluate each formula. \\\nThis allows diagnosing which formulas are responsible for slow performance when a \\\ndocument is first opened, or when a document responds to changes.\",\n          )),\n          disabled: isDocOwner ? false : t(\"Only available to document owners\"),\n        }),\n        dom.create(AdminSectionItem, {\n          id: \"reload\",\n          name: t(\"Reload\"),\n          description: t(\"Hard reset of data engine\"),\n          value: cssSmallButtonSettings(t(\"Reload data engine\"), dom.on(\"click\", this._reloadEngine.bind(this, true))),\n          disabled: isDocEditor ? false : t(\"Only available to document editors\"),\n        }),\n      ]),\n\n      dom.create(AdminSection, t(\"API\"), [\n        dom.create(AdminSectionItem, {\n          id: \"documentId\",\n          name: t(\"Document ID\"),\n          description: t(\"ID for API use\"),\n          value: cssHoverWrapper(\n            cssInput(docPageModel.currentDocId.get(), { tabIndex: \"-1\" }, clickToSelect(), readonly()),\n            cssCopyButton(\n              cssIcon(\"Copy\"),\n              hoverTooltip(t(\"Copy to clipboard\"), {\n                key: TOOLTIP_KEY,\n              }),\n              copyHandler(() => docPageModel.currentDocId.get()!, t(\"Document ID copied to clipboard\")),\n            ),\n          ),\n          expandedContent: dom(\"div\",\n            cssWrap(\n              t(\"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\", {\n                apiURL: cssLink({ href: commonUrls.helpAPI, target: \"_blank\" }, t(\"API documentation.\")),\n                docId: dom(\"code\", \"docId\"),\n              }),\n            ),\n            dom.domComputed(urlState().makeUrl({\n              api: true,\n              docPage: undefined,\n              doc: docPageModel.currentDocId.get(),\n            }), url => [\n              cssWrap(t(\"Base doc URL: {{docApiUrl}}\", {\n                docApiUrl: cssCopyLink(\n                  { href: url },\n                  dom(\"span\", url),\n                  copyHandler(() => url, t(\"API URL copied to clipboard\")),\n                  hoverTooltip(t(\"Copy to clipboard\"), {\n                    key: TOOLTIP_KEY,\n                  }),\n                ),\n              })),\n            ]),\n          ),\n        }),\n        dom.create(AdminSectionItem, {\n          id: \"api-console\",\n          name: t(\"API console\"),\n          description: t(\"Try API calls from the browser\"),\n          value: cssSmallLinkButtonSettings(t(\"API console\"), {\n            target: \"_blank\",\n            href: getApiConsoleLink(docPageModel),\n          }),\n        }),\n        this._gristDoc.docPageModel.isFork.get() ? null : dom.create(AdminSectionItem, {\n          id: \"webhooks\",\n          name: t(\"Webhooks\"),\n          description: t(\"Notify other services on doc changes\"),\n          value: cssSmallLinkButtonSettings(t(\"Manage webhooks\"), urlState().setLinkUrl({ docPage: \"webhook\" })),\n          disabled: isDocOwner ? false : t(\"Only available to document owners\"),\n        }),\n      ]),\n\n      isDocOwner ? this._buildAttachmentStorageSection() : null,\n    );\n  }\n\n  private _buildAttachmentStorageSection() {\n    const INTERNAL = \"internal\", EXTERNAL = \"external\";\n\n    const storageType = Computed.create(this, (use) => {\n      const id = use(this._docInfo.documentSettingsJson).attachmentStoreId;\n      return id ? EXTERNAL : INTERNAL;\n    });\n    storageType.onWrite(async (type) => {\n      // We use this method, instead of updating the observable directly, to ensure that the\n      // active doc has a chance to send us updates about the transfer.\n      await this._gristDoc.docApi.setAttachmentStore(type);\n    });\n    const storageOptions = [{ value: INTERNAL, label: t(\"Internal\") }, { value: EXTERNAL, label: t(\"External\") }];\n\n    const transfer = this._gristDoc.attachmentTransfer;\n    const locationSummary = Computed.create(this, use => use(transfer)?.locationSummary);\n    const inProgress = Computed.create(this, use => !!use(transfer)?.status.isRunning);\n    const allInCurrent = Computed.create(this, (use) => {\n      const summary = use(locationSummary);\n      const current = use(storageType);\n      return summary && summary === current || summary === \"none\";\n    });\n    const stores = Observable.create(this, [] as string[]);\n\n    const stillInternal = Computed.create(this, (use) => {\n      const currentExternal = use(storageType) === EXTERNAL;\n      return currentExternal && (use(inProgress) || !use(allInCurrent));\n    });\n\n    const stillExternal = Computed.create(this, (use) => {\n      const currentInternal = use(storageType) === INTERNAL;\n      return currentInternal && (use(inProgress) || !use(allInCurrent));\n    });\n\n    const loadStatus = async () => {\n      if (transfer.get()) {\n        return;\n      }\n      const status = await this._gristDoc.docApi.getAttachmentTransferStatus();\n      if (transfer.get()) {\n        return;\n      }\n      transfer.set(status);\n    };\n\n    const checkAvailableStores = () => this._gristDoc.docApi.getAttachmentStores().then((r) => {\n      if (r.stores.length === 0) {\n        // There are no external providers (for now there can be at most 1).\n        stores.set([]);\n      } else {\n        stores.set([INTERNAL, EXTERNAL]);\n      }\n    });\n\n    const beginTransfer = async () => {\n      await this._gristDoc.docApi.transferAllAttachments();\n    };\n\n    const attachmentsReady = Observable.create(this, false);\n\n    Promise.all([\n      loadStatus(),\n      checkAvailableStores(),\n    ])\n      .then(() => attachmentsReady.set(true))\n      .catch(reportError);\n\n    return dom.create(AdminSection, t(\"Attachment storage\"), [\n      dom.create(AdminSectionItem, {\n        id: PREFERRED_STORAGE_ANCHOR,\n        name: withInfoTooltip(\n          dom(\"span\", t(\"Preferred storage for this document\"), testId(\"transfer-header\")),\n          \"attachmentStorage\",\n        ),\n        value: cssFlex(\n          dom.maybe(use => !use(allInCurrent) && !use(inProgress), () => [\n            cssButton(\n              t(\"Start transfer\"),\n              dom.on(\"click\", () => beginTransfer()),\n              testId(\"transfer-start-button\"),\n            ),\n          ]),\n          dom.maybe(inProgress, () => [\n            cssButton(\n              cssLoadingSpinner(\n                loadingSpinner.cls(\"-inline\"),\n                cssLoadingSpinner.cls(\"-disabled\"),\n                testId(\"transfer-spinner\"),\n              ),\n              t(\"Transfer in progress\"),\n              dom.prop(\"disabled\", true),\n              testId(\"transfer-button-in-progress\"),\n            ),\n          ]),\n          dom.update(cssSmallSelect(storageType, storageOptions, {\n            disabled: use => use(inProgress) || !use(attachmentsReady) || use(stores).length === 0,\n          }), testId(\"transfer-storage-select\")),\n        ),\n      }),\n      dom(\"div\",\n        dom.maybe(attachmentsReady, () => [\n          dom.maybe(stillInternal, () => stillInternalCopy(\n            inProgress,\n            testId(\"transfer-message\"),\n            testId(\"transfer-still-internal-copy\"),\n          )),\n          dom.maybe(stillExternal, () => stillExternalCopy(\n            inProgress,\n            testId(\"transfer-message\"),\n            testId(\"transfer-still-external-copy\"),\n          )),\n          dom.maybe(use => use(stores).length === 0, () => [\n            dom(\"span\",\n              t(\"No external stores available\"),\n              testId(\"transfer-message\"),\n              testId(\"transfer-no-stores-warning\"),\n            ),\n          ]),\n        ]),\n      ),\n      this._buildAttachmentUploadSection(),\n    ]);\n  }\n\n  private _buildAttachmentUploadSection() {\n    const isUploadingObs = Observable.create(this, false);\n    const buttonText = Computed.create(this, use => use(isUploadingObs) ? t(\"Uploading...\") : t(\"Upload\"));\n\n    const uploadButton = cssSmallButton(\n      dom.text(buttonText),\n      dom.on(\"click\",\n        async () => {\n          // This may never return due to openFilePicker. Anything past this point isn't guaranteed\n          // to execute.\n          const file = await this._pickAttachmentsFile();\n          if (!file) {\n            return;\n          }\n          if (isUploadingObs.isDisposed()) { return; }\n          isUploadingObs.set(true);\n          try {\n            await this._uploadAttachmentsArchive(file);\n          } finally {\n            if (!isUploadingObs.isDisposed()) {\n              isUploadingObs.set(false);\n            }\n          }\n        }),\n      dom.prop(\"disabled\", isUploadingObs),\n      testId(\"upload-attachment-archive\"),\n    );\n\n    return dom.create(AdminSectionItem, {\n      id: \"uploadAttachments\",\n      name: withInfoTooltip(\n        dom(\"span\", t(\"Upload missing attachments\"), testId(\"transfer-header\")),\n        \"uploadAttachments\",\n      ),\n      value: uploadButton,\n    });\n  }\n\n  // May never finish - see `openFilePicker` for more info.\n  private async _pickAttachmentsFile(): Promise<File | undefined> {\n    const files = await openFilePicker({\n      multiple: false,\n      accept: \".tar\",\n    });\n    return files[0];\n  }\n\n  private async _uploadAttachmentsArchive(file: File) {\n    try {\n      const uploadResult = await this._gristDoc.docApi.uploadAttachmentArchive(file);\n      this._gristDoc.app.topAppModel.notifier.createNotification({\n        title: \"Attachments upload complete\",\n        message: `${uploadResult.added} attachment files reconnected`,\n        level: \"info\",\n        canUserClose: true,\n        expireSec: 5,\n      });\n    } catch (err) {\n      reportWarning(err.toString(), {\n        key: \"attachmentArchiveUploadError\",\n        title: \"Attachments upload failed\",\n        level: \"error\",\n      });\n    }\n  }\n\n  private async _reloadEngine(ask = true) {\n    const handler =  async () => {\n      await this._gristDoc.docApi.forceReload();\n      document.location.reload();\n    };\n    if (!ask) {\n      return handler();\n    }\n    confirmModal(t(\"Reload data engine?\"), t(\"Reload\"), handler, {\n      explanation: t(\n        \"This will perform a hard reload of the data engine. This \\\nmay help if the data engine is stuck in an infinite loop, is \\\nindefinitely processing the latest change, or has crashed. \\\nNo data will be lost, except possibly currently pending actions.\",\n      ),\n    });\n  }\n\n  private async _startTiming() {\n    modal((ctl, owner) => {\n      this.onDispose(() => ctl.close());\n      const selected = Observable.create<TimingModalOption>(owner, TimingModalOption.Adhoc);\n      const page = Observable.create<TimingModalPage>(owner, TimingModalPage.Start);\n\n      const startTiming = async () => {\n        if (selected.get() === TimingModalOption.Reload) {\n          page.set(TimingModalPage.Spinner);\n          await this._gristDoc.docApi.startTiming();\n          await this._gristDoc.docApi.forceReload();\n          ctl.close();\n          urlState().pushUrl({ docPage: \"timing\" }).catch(reportError);\n        } else {\n          await this._gristDoc.docApi.startTiming();\n          ctl.close();\n        }\n      };\n\n      const startPage = () => [\n        cssRadioCheckboxOptions(\n          dom.style(\"max-width\", \"400px\"),\n          radioCheckboxOption(selected, TimingModalOption.Adhoc, dom(\"div\",\n            dom(\"div\",\n              dom(\"strong\", t(\"Start timing\")),\n            ),\n            dom(\"div\",\n              dom.style(\"margin-top\", \"8px\"),\n              dom(\"span\", t(\"You can make changes to the document, then stop timing to see the results.\")),\n            ),\n            testId(\"timing-modal-option-adhoc\"),\n          )),\n          radioCheckboxOption(selected, TimingModalOption.Reload, dom(\"div\",\n            dom(\"div\",\n              dom(\"strong\", t(\"Time reload\")),\n            ),\n            dom(\"div\",\n              dom.style(\"margin-top\", \"8px\"),\n              dom(\"span\", t(\"Force reload the document while timing formulas, and show the result.\")),\n            ),\n            testId(\"timing-modal-option-reload\"),\n          )),\n        ),\n        cssModalButtons(\n          bigPrimaryButton(t(`Start timing`),\n            dom.on(\"click\", startTiming),\n            testId(\"timing-modal-confirm\"),\n          ),\n          bigBasicButton(t(\"Cancel\"), dom.on(\"click\", () => ctl.close()), testId(\"timing-modal-cancel\")),\n        ),\n      ];\n\n      const spinnerPage = () => [\n        cssSpinner(\n          loadingSpinner(),\n          testId(\"timing-modal-spinner\"),\n          dom.style(\"width\", \"fit-content\"),\n        ),\n      ];\n\n      return [\n        cssModalTitle(t(`Formula timer`)),\n        dom.domComputed(page, p => p === TimingModalPage.Start ? startPage() : spinnerPage()),\n        testId(\"timing-modal\"),\n      ];\n    });\n  }\n\n  private _buildDocumentTypeModal() {\n    const docPageModel = this._gristDoc.docPageModel;\n    modal((ctl, owner) => {\n      this.onDispose(() => ctl.close());\n      const currentDocType = docPageModel.type.get() as string;\n      let currentDocTypeOption;\n      switch (currentDocType) {\n        case DOCTYPE_TEMPLATE:\n          currentDocTypeOption = DocTypeOption.Template;\n          break;\n        case DOCTYPE_TUTORIAL:\n          currentDocTypeOption = DocTypeOption.Tutorial;\n          break;\n        default:\n          currentDocTypeOption = DocTypeOption.Regular;\n      }\n\n      const selected = Observable.create<DocTypeOption>(owner, currentDocTypeOption);\n\n      const doSetDocumentType = async () => {\n        let docType: DocumentType;\n        if (selected.get() === DocTypeOption.Regular) {\n          docType = DOCTYPE_NORMAL;\n        } else if (selected.get() === DocTypeOption.Template) {\n          docType = DOCTYPE_TEMPLATE;\n        } else {\n          docType = DOCTYPE_TUTORIAL;\n        }\n\n        const { trunkId } = docPageModel.currentDoc.get()!.idParts;\n        await docPageModel.appModel.api.updateDoc(trunkId, { type: docType });\n        window.location.replace(urlState().makeUrl({\n          docPage: \"settings\",\n          fork: undefined, // will be automatically set once the page is reloaded\n          doc: trunkId,\n        }));\n      };\n\n      const docTypeOption = (\n        {\n          type,\n          label,\n          description,\n          itemTestId,\n        }: {\n          type: DocTypeOption,\n          label: string,\n          description: string,\n          itemTestId: DomElementMethod | null\n        }) => {\n        return radioCheckboxOption(selected, type, dom(\"div\",\n          dom(\"div\",\n            dom(\"strong\", label),\n          ),\n          dom(\"div\",\n            dom.style(\"margin-top\", \"8px\"),\n            dom(\"span\", description),\n          ),\n          itemTestId,\n        ));\n      };\n\n      const documentTypeOptions = () => [\n        cssRadioCheckboxOptions(\n          dom.style(\"max-width\", \"400px\"),\n          docTypeOption({\n            type: DocTypeOption.Regular,\n            label: t(\"Default\"),\n            description: t(\"Normal document behavior. All users work on the same copy of the document.\"),\n            itemTestId: testId(\"doctype-modal-option-regular\"),\n          }),\n          docTypeOption({\n            type: DocTypeOption.Template,\n            label: t(\"Template\"),\n            description: t(\"Document automatically opens in {{fiddleModeDocUrl}}. \\\nAnyone may edit, which will create a new unsaved copy.\",\n            {\n              fiddleModeDocUrl: cssLink({ href: commonUrls.helpFiddleMode, target: \"_blank\" }, t(\"fiddle mode\")),\n            },\n            ),\n            itemTestId: testId(\"doctype-modal-option-template\"),\n          }),\n          docTypeOption({\n            type: DocTypeOption.Tutorial,\n            label: t(\"Tutorial\"),\n            description: t(\"Document automatically opens as a user-specific copy.\"),\n            itemTestId: testId(\"doctype-modal-option-tutorial\"),\n          }),\n        ),\n        cssModalButtons(\n          bigBasicButton(t(\"Cancel\"), dom.on(\"click\", () => ctl.close()), testId(\"doctype-modal-cancel\")),\n          bigPrimaryButton(t(`Confirm change`),\n            dom.on(\"click\", doSetDocumentType),\n            testId(\"doctype-modal-confirm\"),\n          ),\n        ),\n      ];\n      return [\n        cssModalTitle(t(`Change document type`)),\n        documentTypeOptions(),\n        testId(\"doctype-modal\"),\n      ];\n    });\n  }\n}\n\nfunction getApiConsoleLink(docPageModel: DocPageModel) {\n  const url = new URL(location.href);\n  url.pathname = \"/apiconsole\";\n  url.searchParams.set(\"docId\", docPageModel.currentDocId.get()!);\n  // Some extra question marks to placate a test fixture at test/fixtures/projects/DocumentSettings.ts\n  url.searchParams.set(\"workspaceId\", String(docPageModel.currentWorkspace?.get()?.id || \"\"));\n  url.searchParams.set(\"orgId\", String(docPageModel.appModel?.topAppModel.currentSubdomain.get()));\n  return url.href;\n}\n\ntype LocaleItem = ACSelectItem & { locale?: string };\n\nfunction buildLocaleSelect(\n  owner: IDisposableOwner,\n  locale: KoSaveableObservable<string>,\n) {\n  const localeList: LocaleItem[] = locales.map(l => ({\n    value: l.name, // Use name as a value, we will translate the name into the locale on save\n    label: l.name,\n    locale: l.code,\n    cleanText: l.name.trim().toLowerCase(),\n  })).sort(propertyCompare(\"label\"));\n  const acIndex = new ACIndexImpl<LocaleItem>(localeList, { maxResults: 200, keepOrder: true });\n  // AC select will show the value (in this case locale) not a label when something is selected.\n  // To show the label - create another observable that will be in sync with the value, but\n  // will contain text.\n  const textObs = Computed.create(owner, (use) => {\n    const localeCode = use(locale);\n    const localeName = locales.find(l => l.code === localeCode)?.name || localeCode;\n    return localeName;\n  });\n  return buildACSelect(owner,\n    {\n      acIndex, valueObs: textObs,\n      save(_value, item: LocaleItem | undefined) {\n        if (!item) { throw new Error(\"Invalid locale\"); }\n        locale.saveOnly(item.locale!).catch(reportError);\n      },\n    },\n    testId(\"locale-autocomplete\"),\n  );\n}\n\ntype DocumentTypeItem = ACSelectItem & { type?: string };\n\nfunction displayCurrentType(\n  owner: IDisposableOwner,\n  type: Observable<DocumentType | null>,\n) {\n  const typeList: DocumentTypeItem[] = [{\n    label: t(\"Regular\"),\n    type: \"\",\n  }, {\n    label: t(\"Template\"),\n    type: \"template\",\n  }, {\n    label: t(\"Tutorial\"),\n    type: \"tutorial\",\n  }].map(el => ({\n    ...el,\n    value: el.label,\n    cleanText: el.label.trim().toLowerCase(),\n  }));\n  const typeObs = Computed.create(owner, (use) => {\n    const typeCode = use(type) ?? \"\";\n    const typeName = typeList.find(ty => ty.type === typeCode)?.label || typeCode;\n    return typeName;\n  });\n  return dom(\n    \"div\",\n    dom.text(typeObs),\n    testId(\"doctype-value\"),\n  );\n}\n\nconst learnMore = () => t(\n  \"[Learn more.]({{learnLink}})\",\n  { learnLink: commonUrls.attachmentStorage },\n);\n\nfunction stillExternalCopy(inProgress: Observable<boolean>, ...args: IDomArgs<HTMLSpanElement>) {\n  const someExternal = () => t(\n    \"**Some existing attachments are still [external]({{externalLink}})**.\",\n    { externalLink: commonUrls.attachmentStorage },\n  );\n\n  const startToInternal = () => t(\n    'Click \"Start transfer\" to transfer those to Internal storage (stored in the document SQLite file).',\n  );\n\n  const newInInternal = () => t(\n    \"Newly uploaded attachments will be placed in Internal storage.\",\n  );\n\n  return dom.domComputed(inProgress, (yes) => {\n    if (yes) {\n      return cssMarkdownSpan(\n        `${someExternal()} ${newInInternal()}\\n\\n${learnMore()}`, ...args, testId(\"transfer-message-in-progress\"));\n    } else {\n      return cssMarkdownSpan(\n        `${someExternal()} ${startToInternal()} ${newInInternal()}\\n\\n${learnMore()}`,\n        ...args,\n        testId(\"transfer-message-static\"));\n    }\n  });\n}\n\nfunction stillInternalCopy(inProgress: Observable<boolean>, ...args: IDomArgs<HTMLSpanElement>) {\n  const someInternal = () => t(\n    \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\",\n    { internalLink: commonUrls.attachmentStorage },\n  );\n\n  const startToExternal = () => t(\n    'Click \"Start transfer\" to transfer those to External storage.',\n  );\n\n  const newInExternal = () => t(\n    \"Newly uploaded attachments will be placed in External storage.\",\n  );\n\n  return dom.domComputed(inProgress, (yes) => {\n    if (yes) {\n      return cssMarkdownSpan(\n        `${someInternal()} ${newInExternal()}\\n\\n${learnMore()}`,\n        testId(\"transfer-message-in-progress\"),\n        ...args,\n      );\n    } else {\n      return cssMarkdownSpan(\n        `${someInternal()} ${startToExternal()} ${newInExternal()}\\n\\n${learnMore()}`,\n        testId(\"transfer-message-static\"),\n        ...args,\n      );\n    }\n  });\n}\n\nconst cssContainer = styled(\"div\", `\n  overflow-y: auto;\n  position: relative;\n  height: 100%;\n  padding: 32px 64px 24px 64px;\n  color: ${theme.text};\n  @media ${mediaSmall} {\n    & {\n      padding: 32px 24px 24px 24px;\n    }\n  }\n`);\n\nconst cssCopyButton = styled(\"div\", `\n  position: absolute;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  height: 100%;\n  width: 24px;\n  right: 0;\n  top: 0;\n  --icon-color: ${theme.lightText};\n  &:hover {\n    --icon-color: ${colors.lightGreen};\n  }\n`);\n\nconst cssIcon = styled(icon, `\n`);\n\nconst cssInput = styled(\"div\", `\n  border: none;\n  outline: none;\n  background: transparent;\n  width: 100%;\n  min-width: 180px;\n  height: 100%;\n  padding: 5px;\n  padding-right: 20px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`);\n\nconst cssHoverWrapper = styled(\"div\", `\n  max-width: var(--admin-select-width);\n  text-overflow: ellipsis;\n  overflow: hidden;\n  text-wrap: nowrap;\n  display: inline-block;\n  cursor: pointer;\n  transition: all 0.05s;\n  border-radius: 4px;\n  border-color: ${theme.inputBorder};\n  border-style: solid;\n  border-width: 1px;\n  height: 30px;\n  align-items: center;\n  position: relative;\n`);\n\nconst cssSmallButtonSettings = styled(cssSmallButton, `\n  display: block;\n  margin-right: 0;\n  margin-left: auto;\n  width: 100%;\n`);\n\nconst cssSmallLinkButtonSettings = styled(cssSmallLinkButton, `\n  display: block;\n  text-align: center;\n`);\n\nconst cssPrimarySmallLinkSettings = styled(cssPrimarySmallLink, `\n  display: block;\n  width: 100%;\n  text-align:center;\n`);\n\n// This matches the style used in showProfileModal in app/client/ui/AccountWidget.\n\n// Check which engines can be selected in the UI, if any.\nexport function getSupportedEngineChoices(): EngineCode[] {\n  const gristConfig: Partial<GristLoadConfig> = window.gristConfig || {};\n  return gristConfig.supportEngines || [];\n}\n\nconst TOOLTIP_KEY = \"copy-on-settings\";\n\nfunction copyHandler(value: () => string, confirmation: string) {\n  return dom.on(\"click\", async (e, d) => {\n    e.stopImmediatePropagation();\n    e.preventDefault();\n    showTransientTooltip(d as Element, confirmation, {\n      key: TOOLTIP_KEY,\n    });\n    await copyToClipboard(value());\n  });\n}\n\nfunction readonly() {\n  return [\n    { contentEditable: \"false\", spellcheck: \"false\" },\n  ];\n}\n\nfunction clickToSelect() {\n  return dom.on(\"click\", (e) => {\n    e.preventDefault();\n    e.stopPropagation();\n    const range = document.createRange();\n    range.selectNodeContents(e.target as Node);\n    const selection = window.getSelection();\n    if (selection) {\n      selection.removeAllRanges();\n      selection.addRange(range);\n    }\n  });\n}\n\n/**\n * Enum for the different pages of the timing modal.\n */\nenum TimingModalPage {\n  Start, // The initial page with options to start timing.\n  Spinner, // The page with a spinner while we are starting timing and reloading the document.\n}\n\n/**\n * Enum for the different options in the timing modal.\n */\nenum TimingModalOption {\n  /**\n   * Start timing and immediately forces a reload of the document and waits for the\n   * document to be loaded, to show the results.\n   */\n  Reload,\n  /**\n   * Just starts the timing, without reloading the document.\n   */\n  Adhoc,\n}\n\n/**\n * Enum for the different options in the document type Modal.\n */\nenum DocTypeOption {\n  Regular,\n  Template,\n  Tutorial,\n}\n\n// A version that is not underlined, and on hover mouse pointer indicates that copy is available\nconst cssCopyLink = styled(cssLink, `\n  word-wrap: break-word;\n  &:hover {\n    border-radius: 4px;\n    text-decoration: none;\n    background: ${theme.lightHover};\n    outline-color: ${theme.linkHover};\n    outline-offset: 1px;\n  }\n`);\n\nconst cssAutoComplete = `\n  width: var(--admin-select-width);\n  cursor: pointer;\n  & input {\n    text-overflow: ellipsis;\n    padding-right: 24px;\n  }\n`;\n\nconst cssTZAutoComplete = styled(buildTZAutocomplete, cssAutoComplete);\nconst cssCurrencyPicker = styled(buildCurrencyPicker, cssAutoComplete);\nconst cssLocalePicker = styled(buildLocaleSelect, cssAutoComplete);\n\nconst cssWrap = styled(\"p\", `\n  overflow-wrap: anywhere;\n  & * {\n    word-break: break-all;\n  }\n`);\n\nconst cssRedText = styled(\"span\", `\n  color: ${theme.errorText};\n`);\n\nconst cssDocTypeContainer = styled(\"div\", `\n  display: flex;\n  width: var(--admin-select-width);\n  align-items: center;\n  justify-content: space-between;\n  & > * {\n    display: inline-block;\n  }\n`);\n\nconst cssFlex = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n`);\n\nconst cssButton = styled(cssSmallButton, `\n  white-space: nowrap;\n`);\n\nconst cssSmallSelect = styled(select, `\n  width: 100%;\n`);\n\nconst cssLoadingSpinner = styled(loadingSpinner, `\n  &-disabled {\n    --loader-bg: ${theme.loaderBg};\n    --loader-fg: white;\n  }\n  @media (prefers-color-scheme: dark) {\n    &-disabled {\n      --loader-bg: #adadad;\n    }\n  }\n`);\n\nexport const betaTag = styled(\"span\", `\n  text-transform: uppercase;\n  vertical-align: super;\n  font-size: ${vars.xsmallFontSize};\n  color: ${theme.accentText};\n`);\n"
  },
  {
    "path": "app/client/ui/DuplicateTable.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { cssInput } from \"app/client/ui/cssInput\";\nimport { cssField } from \"app/client/ui/MakeCopyMenu\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { colors } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { saveModal } from \"app/client/ui2018/modals\";\nimport { commonUrls } from \"app/common/gristUrls\";\n\nimport { Computed, Disposable, dom, input, makeTestId, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"DuplicateTable\");\n\nconst testId = makeTestId(\"test-duplicate-table-\");\n\n/**\n * Response returned by a DuplicateTable user action.\n */\nexport interface DuplicateTableResponse {\n  /** Row id of the new table. */\n  id: number;\n  /** Table id of the new table. */\n  table_id: string;\n  /** Row id of the new raw view section. */\n  raw_section_id: number;\n}\n\nexport interface DuplicateTableOptions {\n  onSuccess?(response: DuplicateTableResponse): void;\n}\n\n/**\n * Shows a modal with options for duplicating the table `tableId`.\n */\nexport function duplicateTable(\n  gristDoc: GristDoc,\n  tableId: string,\n  { onSuccess }: DuplicateTableOptions = {},\n) {\n  saveModal((_ctl, owner) => {\n    const duplicateTableModal = DuplicateTableModal.create(owner, gristDoc, tableId);\n    return {\n      title: \"Duplicate Table\",\n      body: duplicateTableModal.buildDom(),\n      saveFunc: async () =>  {\n        const response = await duplicateTableModal.save();\n        onSuccess?.(response);\n      },\n      saveDisabled: duplicateTableModal.saveDisabled,\n      width: \"normal\",\n    };\n  });\n}\n\nclass DuplicateTableModal extends Disposable {\n  private _newTableName = Observable.create<string>(this, \"\");\n  private _includeData = Observable.create<boolean>(this, false);\n  private _saveDisabled = Computed.create(this, this._newTableName, (_use, name) => !name.trim());\n\n  constructor(private _gristDoc: GristDoc, private _tableId: string) {\n    super();\n  }\n\n  public get saveDisabled() { return this._saveDisabled; }\n\n  public save() {\n    return this._duplicateTable();\n  }\n\n  public buildDom() {\n    return [\n      cssField(\n        input(\n          this._newTableName,\n          { onInput: true },\n          { placeholder: t(\"Name for new table\") },\n          (elem) => { setTimeout(() => { elem.focus(); }, 20); },\n          dom.on(\"focus\", (_ev, elem) => { elem.select(); }),\n          dom.cls(cssInput.className),\n          testId(\"name\"),\n        ),\n      ),\n      cssWarning(\n        cssWarningIcon(\"Warning\"),\n        dom(\"div\", t(\"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\",\n          { link: cssLink({ href: commonUrls.helpLinkingWidgets, target: \"_blank\" }, \"Read More.\") },\n        )),\n      ),\n      cssField(\n        cssCheckbox(\n          this._includeData,\n          t(\"Copy all data in addition to the table structure.\"),\n          testId(\"copy-all-data\"),\n        ),\n      ),\n      dom.maybe(this._includeData, () => cssWarning(\n        cssWarningIcon(\"Warning\"),\n        dom(\"div\", t(\"Only the document default access rules will apply to the copy.\")),\n        testId(\"acl-warning\"),\n      )),\n    ];\n  }\n\n  private _duplicateTable() {\n    const { docData } = this._gristDoc;\n    const [newTableName, includeData] = [this._newTableName.get(), this._includeData.get()];\n    return docData.sendAction([\"DuplicateTable\", this._tableId, newTableName, includeData]);\n  }\n}\n\nconst cssCheckbox = styled(labeledSquareCheckbox, `\n  margin-top: 8px;\n`);\n\nconst cssWarning = styled(\"div\", `\n  display: flex;\n  column-gap: 8px;\n`);\n\nconst cssWarningIcon = styled(icon, `\n  --icon-color: ${colors.orange};\n  flex-shrink: 0;\n`);\n"
  },
  {
    "path": "app/client/ui/EmojiPicker.ts",
    "content": "import data from \"@emoji-mart/data\";\nimport { Picker } from \"emoji-mart\";\nimport { DomContents } from \"grainjs\";\n\ninterface EmojiPickerOptions {\n  onEmojiSelect: (emoji: any) => void;\n  theme?: \"auto\" | \"dark\" | \"light\";\n}\n\nexport async function buildEmojiPicker({\n  onEmojiSelect,\n  theme,\n}: EmojiPickerOptions) {\n  return new Picker({\n    data,\n    onEmojiSelect,\n    theme,\n    categories: [\n      \"suggested\",\n      \"frequent\",\n      \"people\",\n      \"nature\",\n      \"foods\",\n      \"activity\",\n      \"places\",\n      \"objects\",\n      \"symbols\",\n      \"flags\",\n    ],\n    custom: [\n      {\n        id: \"suggested\",\n        name: \"Suggested\",\n        emojis: [\n          {\n            id: \"clipboard\",\n            name: \"Clipboard\",\n            skins: [\n              {\n                native: \"📋\",\n                unified: \"1f4cb\",\n              },\n            ],\n            keywords: [\"stationery\", \"documents\"],\n            shortcodes: \":clipboard:\",\n          },\n          {\n            id: \"busts_in_silhouette\",\n            name: \"Busts in Silhouette\",\n            skins: [\n              {\n                native: \"👥\",\n                unified: \"1f465\",\n              },\n            ],\n            keywords: [\"user\", \"person\", \"human\", \"group\", \"team\"],\n            shortcodes: \":busts_in_silhouette:\",\n          },\n          {\n            id: \"chart_with_upwards_trend\",\n            name: \"Chart Increasing\",\n            skins: [\n              {\n                native: \"📈\",\n                unified: \"1f4c8\",\n              },\n            ],\n            keywords: [\n              \"with\",\n              \"upwards\",\n              \"trend\",\n              \"graph\",\n              \"presentation\",\n              \"stats\",\n              \"recovery\",\n              \"business\",\n              \"economics\",\n              \"money\",\n              \"sales\",\n              \"good\",\n              \"success\",\n            ],\n            shortcodes: \":chart_with_upwards_trend:\",\n          },\n          {\n            id: \"dollar\",\n            name: \"Dollar Banknote\",\n            skins: [\n              {\n                native: \"💵\",\n                unified: \"1f4b5\",\n              },\n            ],\n            keywords: [\"money\", \"sales\", \"bill\", \"currency\"],\n            shortcodes: \":dollar:\",\n          },\n          {\n            id: \"blue_book\",\n            name: \"Blue Book\",\n            skins: [\n              {\n                native: \"📘\",\n                unified: \"1f4d8\",\n              },\n            ],\n            keywords: [\"read\", \"library\", \"knowledge\", \"learn\", \"study\"],\n            shortcodes: \":blue_book:\",\n          },\n          {\n            id: \"school\",\n            name: \"School\",\n            skins: [\n              {\n                native: \"🏫\",\n                unified: \"1f3eb\",\n              },\n            ],\n            keywords: [\"building\", \"student\", \"education\", \"learn\", \"teach\"],\n            shortcodes: \":school:\",\n          },\n          {\n            id: \"spiral_calendar_pad\",\n            name: \"Spiral Calendar\",\n            skins: [\n              {\n                native: \"🗓️\",\n                unified: \"1f5d3-fe0f\",\n              },\n            ],\n            keywords: [\"pad\", \"date\", \"schedule\", \"planning\"],\n            shortcodes: \":spiral_calendar_pad:\",\n          },\n          {\n            id: \"white_check_mark\",\n            name: \"Check Mark Button\",\n            skins: [\n              {\n                native: \"✅\",\n                unified: \"2705\",\n              },\n            ],\n            keywords: [\n              \"white\",\n              \"green\",\n              \"square\",\n              \"ok\",\n              \"agree\",\n              \"vote\",\n              \"election\",\n              \"answer\",\n              \"tick\",\n            ],\n            shortcodes: \":white_check_mark:\",\n          },\n          {\n            id: \"email\",\n            name: \"Envelope\",\n            skins: [\n              {\n                native: \"✉️\",\n                unified: \"2709-fe0f\",\n              },\n            ],\n            keywords: [\"email\", \"letter\", \"postal\", \"inbox\", \"communication\"],\n            shortcodes: \":email:\",\n            aliases: [\"envelope\"],\n          },\n          {\n            id: \"lock\",\n            name: \"Lock\",\n            skins: [\n              {\n                native: \"🔒\",\n                unified: \"1f512\",\n              },\n            ],\n            keywords: [\"locked\", \"security\", \"password\", \"padlock\"],\n            shortcodes: \":lock:\",\n          },\n          {\n            id: \"unlock\",\n            name: \"Unlocked\",\n            skins: [\n              {\n                native: \"🔓\",\n                unified: \"1f513\",\n              },\n            ],\n\n            keywords: [\"unlock\", \"privacy\", \"security\"],\n            shortcodes: \":unlock:\",\n          },\n          {\n            id: \"ring\",\n            name: \"Ring\",\n            skins: [\n              {\n                native: \"💍\",\n                unified: \"1f48d\",\n              },\n            ],\n\n            keywords: [\n              \"wedding\",\n              \"propose\",\n              \"marriage\",\n              \"valentines\",\n              \"diamond\",\n              \"fashion\",\n              \"jewelry\",\n              \"gem\",\n              \"engagement\",\n            ],\n            shortcodes: \":ring:\",\n          },\n          {\n            id: \"key\",\n            name: \"Key\",\n            skins: [\n              {\n                native: \"🔑\",\n                unified: \"1f511\",\n              },\n            ],\n\n            keywords: [\"lock\", \"door\", \"password\"],\n            shortcodes: \":key:\",\n          },\n          {\n            id: \"beach_with_umbrella\",\n            name: \"Beach with Umbrella\",\n            skins: [\n              {\n                native: \"🏖️\",\n                unified: \"1f3d6-fe0f\",\n              },\n            ],\n\n            keywords: [\"weather\", \"summer\", \"sunny\", \"sand\", \"mojito\"],\n            shortcodes: \":beach_with_umbrella:\",\n          },\n          {\n            id: \"hamburger\",\n            name: \"Hamburger\",\n            skins: [\n              {\n                native: \"🍔\",\n                unified: \"1f354\",\n              },\n            ],\n\n            keywords: [\n              \"meat\",\n              \"fast\",\n              \"food\",\n              \"beef\",\n              \"cheeseburger\",\n              \"mcdonalds\",\n              \"burger\",\n              \"king\",\n            ],\n            shortcodes: \":hamburger:\",\n          },\n          {\n            id: \"birthday\",\n            name: \"Birthday Cake\",\n            skins: [\n              {\n                native: \"🎂\",\n                unified: \"1f382\",\n              },\n            ],\n\n            keywords: [\"food\", \"dessert\"],\n            shortcodes: \":birthday:\",\n          },\n          {\n            id: \"football\",\n            name: \"American Football\",\n            skins: [\n              {\n                native: \"🏈\",\n                unified: \"1f3c8\",\n              },\n            ],\n\n            keywords: [\"sports\", \"balls\", \"NFL\"],\n            shortcodes: \":football:\",\n          },\n          {\n            id: \"soccer\",\n            name: \"Soccer Ball\",\n            skins: [\n              {\n                native: \"⚽\",\n                unified: \"26bd\",\n              },\n            ],\n\n            keywords: [\"sports\", \"football\"],\n            shortcodes: \":soccer:\",\n          },\n          {\n            id: \"baseball\",\n            name: \"Baseball\",\n            skins: [\n              {\n                native: \"⚾\",\n                unified: \"26be\",\n              },\n            ],\n\n            keywords: [\"sports\", \"balls\"],\n            shortcodes: \":baseball:\",\n          },\n          {\n            id: \"earth_americas\",\n            name: \"Earth Globe Americas\",\n            skins: [\n              {\n                native: \"🌎\",\n                unified: \"1f30e\",\n              },\n            ],\n\n            keywords: [\"showing\", \"world\", \"USA\", \"international\"],\n            shortcodes: \":earth_americas:\",\n          },\n          {\n            id: \"office\",\n            name: \"Office Building\",\n            skins: [\n              {\n                native: \"🏢\",\n                unified: \"1f3e2\",\n              },\n            ],\n\n            keywords: [\"bureau\", \"work\"],\n            shortcodes: \":office:\",\n          },\n          {\n            id: \"airplane\",\n            name: \"Airplane\",\n            skins: [\n              {\n                native: \"✈️\",\n                unified: \"2708-fe0f\",\n              },\n            ],\n\n            keywords: [\"vehicle\", \"transportation\", \"flight\", \"fly\"],\n            shortcodes: \":airplane:\",\n          },\n          {\n            id: \"blossom\",\n            name: \"Blossom\",\n            skins: [\n              {\n                native: \"🌼\",\n                unified: \"1f33c\",\n              },\n            ],\n\n            keywords: [\"nature\", \"flowers\", \"yellow\"],\n            shortcodes: \":blossom:\",\n          },\n          {\n            id: \"four_leaf_clover\",\n            name: \"Four Leaf Clover\",\n            skins: [\n              {\n                native: \"🍀\",\n                unified: \"1f340\",\n              },\n            ],\n\n            keywords: [\"vegetable\", \"plant\", \"nature\", \"lucky\", \"irish\"],\n            shortcodes: \":four_leaf_clover:\",\n          },\n          {\n            id: \"butterfly\",\n            name: \"Butterfly\",\n            skins: [\n              {\n                native: \"🦋\",\n                unified: \"1f98b\",\n              },\n            ],\n\n            keywords: [\"animal\", \"insect\", \"nature\", \"caterpillar\"],\n            shortcodes: \":butterfly:\",\n          },\n          {\n            id: \"apple\",\n            name: \"Red Apple\",\n            skins: [\n              {\n                native: \"🍎\",\n                unified: \"1f34e\",\n              },\n            ],\n\n            keywords: [\"fruit\", \"mac\", \"school\"],\n            shortcodes: \":apple:\",\n          },\n          {\n            id: \"snowflake\",\n            name: \"Snowflake\",\n            skins: [\n              {\n                native: \"❄️\",\n                unified: \"2744-fe0f\",\n              },\n            ],\n\n            keywords: [\n              \"winter\",\n              \"season\",\n              \"cold\",\n              \"weather\",\n              \"christmas\",\n              \"xmas\",\n            ],\n            shortcodes: \":snowflake:\",\n          },\n          {\n            id: \"medical_symbol\",\n            name: \"Medical Symbol\",\n            skins: [\n              {\n                native: \"⚕️\",\n                unified: \"2695-fe0f\",\n              },\n            ],\n\n            keywords: [\"staff\", \"of\", \"aesculapius\", \"health\", \"hospital\"],\n            shortcodes: \":medical_symbol:\",\n            aliases: [\"staff_of_aesculapius\"],\n          },\n          {\n            id: \"hospital\",\n            name: \"Hospital\",\n            skins: [\n              {\n                native: \"🏥\",\n                unified: \"1f3e5\",\n              },\n            ],\n\n            keywords: [\"building\", \"health\", \"surgery\", \"doctor\"],\n            shortcodes: \":hospital:\",\n          },\n          {\n            id: \"test_tube\",\n            name: \"Test Tube\",\n            skins: [\n              {\n                native: \"🧪\",\n                unified: \"1f9ea\",\n              },\n            ],\n\n            keywords: [\"chemistry\", \"experiment\", \"lab\", \"science\"],\n            shortcodes: \":test_tube:\",\n          },\n          {\n            id: \"microscope\",\n            name: \"Microscope\",\n            skins: [\n              {\n                native: \"🔬\",\n                unified: \"1f52c\",\n              },\n            ],\n\n            keywords: [\n              \"laboratory\",\n              \"experiment\",\n              \"zoomin\",\n              \"science\",\n              \"study\",\n            ],\n            shortcodes: \":microscope:\",\n          },\n          {\n            id: \"construction_worker\",\n            name: \"Construction Worker\",\n            skins: [\n              {\n                native: \"👷\",\n                unified: \"1f477\",\n              },\n            ],\n\n            keywords: [\"labor\", \"build\"],\n            shortcodes: \":construction_worker:\",\n            skin: 1,\n          },\n          {\n            id: \"building_construction\",\n            name: \"Building Construction\",\n            skins: [\n              {\n                native: \"🏗️\",\n                unified: \"1f3d7-fe0f\",\n              },\n            ],\n\n            keywords: [\"wip\", \"working\", \"progress\"],\n            shortcodes: \":building_construction:\",\n          },\n          {\n            id: \"office_worker\",\n            name: \"Office Worker\",\n            skins: [\n              {\n                native: \"🧑‍💼\",\n                unified: \"1f9d1-200d-1f4bc\",\n              },\n            ],\n\n            keywords: [\"business\"],\n            shortcodes: \":office_worker:\",\n            skin: 1,\n          },\n          {\n            id: \"handshake\",\n            name: \"Handshake\",\n            skins: [\n              {\n                native: \"🤝\",\n                unified: \"1f91d\",\n              },\n            ],\n            keywords: [\"agreement\", \"shake\"],\n            shortcodes: \":handshake:\",\n            skin: 1,\n          },\n          {\n            id: \"briefcase\",\n            name: \"Briefcase\",\n            skins: [\n              {\n                native: \"💼\",\n                unified: \"1f4bc\",\n              },\n            ],\n            keywords: [\n              \"business\",\n              \"documents\",\n              \"work\",\n              \"law\",\n              \"legal\",\n              \"job\",\n              \"career\",\n            ],\n            shortcodes: \":briefcase:\",\n          },\n          {\n            id: \"classical_building\",\n            name: \"Classical Building\",\n            skins: [\n              {\n                native: \"🏛️\",\n                unified: \"1f3db-fe0f\",\n              },\n            ],\n            keywords: [\"art\", \"culture\", \"history\"],\n            shortcodes: \":classical_building:\",\n          },\n          {\n            id: \"scales\",\n            name: \"Balance Scale\",\n            skins: [\n              {\n                native: \"⚖️\",\n                unified: \"2696-fe0f\",\n              },\n            ],\n\n            keywords: [\"scales\", \"law\", \"fairness\", \"weight\"],\n            shortcodes: \":scales:\",\n          },\n          {\n            id: \"house\",\n            name: \"House\",\n            skins: [\n              {\n                native: \"🏠\",\n                unified: \"1f3e0\",\n              },\n            ],\n            keywords: [\"building\", \"home\"],\n            shortcodes: \":house:\",\n          },\n          {\n            id: \"mortar_board\",\n            name: \"Graduation Cap\",\n            skins: [\n              {\n                native: \"🎓\",\n                unified: \"1f393\",\n              },\n            ],\n            keywords: [\n              \"mortar\",\n              \"board\",\n              \"school\",\n              \"college\",\n              \"degree\",\n              \"university\",\n              \"hat\",\n              \"legal\",\n              \"learn\",\n              \"education\",\n            ],\n            shortcodes: \":mortar_board:\",\n          },\n          {\n            id: \"books\",\n            name: \"Books\",\n            skins: [\n              {\n                native: \"📚\",\n                unified: \"1f4da\",\n              },\n            ],\n            keywords: [\"literature\", \"library\", \"study\"],\n            shortcodes: \":books:\",\n          },\n        ],\n      },\n    ],\n  }) as unknown as DomContents;\n}\n"
  },
  {
    "path": "app/client/ui/ExampleCard.ts",
    "content": "import { IExampleInfo } from \"app/client/ui/ExampleInfo\";\nimport { AutomaticHelpToolInfo } from \"app/client/ui/Tools\";\nimport { prepareForTransition, TransitionWatcher } from \"app/client/ui/transitions\";\nimport { mediaXSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\n\nimport { dom, styled } from \"grainjs\";\n\nlet prevCardClose: (() => void) | null = null;\n\n// Open a popup with a card introducing this example, if the user hasn't dismissed it in the past.\nexport function showExampleCard(\n  example: IExampleInfo, toolInfo: AutomaticHelpToolInfo,\n) {\n  const { elem: btnElem, markAsSeen, reopen } = toolInfo;\n\n  // Close the example card.\n  function close() {\n    prevCardClose = null;\n    collapseAndRemoveCard(cardElem, btnElem.getBoundingClientRect());\n    markAsSeen();\n  }\n\n  const card = example.welcomeCard;\n  if (!card) { return null; }\n  const cardElem = cssCard(\n    cssImage({ src: example.imgUrl }),\n    cssBody(\n      cssTitle(card.title),\n      cssInfo(card.text),\n      cssButtons(\n        cssLinkBtn(cssLinkIcon(\"Page\"), card.tutorialName,\n          { href: example.tutorialUrl, target: \"_blank\" },\n        ),\n        // TODO: Add a link to the overview video (as popup or to a support page that shows the\n        // video). Also include a 'Video' icon.\n        // cssLinkBtn(cssLinkIcon('Video'), 'Grist Video Tour'),\n      ),\n    ),\n    cssCloseButton(cssBigIcon(\"CrossBig\"),\n      dom.on(\"click\", close),\n      testId(\"example-card-close\"),\n    ),\n    testId(\"example-card\"),\n  );\n  document.body.appendChild(cardElem);\n\n  // When reopening, open the card smoothly, for a nicer-looking effect.\n  if (reopen) {\n    expandCard(cardElem, btnElem.getBoundingClientRect());\n  }\n\n  prevCardClose?.();\n  prevCardClose = () => disposeCard(cardElem);\n}\n\nfunction disposeCard(cardElem: HTMLElement) {\n  dom.domDispose(cardElem);\n  cardElem.remove();\n}\n\n// When closing the card, collapse it visually into the button that can open it again, to hint to\n// the user where to find that button. Remove the card after the animation.\nfunction collapseAndRemoveCard(card: HTMLElement, collapsedRect: DOMRect) {\n  const watcher = new TransitionWatcher(card);\n  watcher.onDispose(() => disposeCard(card));\n  collapseCard(card, collapsedRect);\n}\n\n// Implements the collapsing animation by simply setting a scale transform with a suitable origin.\nfunction collapseCard(card: HTMLElement, collapsedRect: DOMRect) {\n  const rect = card.getBoundingClientRect();\n  const originX = (collapsedRect.left + collapsedRect.width / 2) - rect.left;\n  const originY = (collapsedRect.top + collapsedRect.height / 2) - rect.top;\n  Object.assign(card.style, {\n    transform: `scale(${collapsedRect.width / rect.width}, ${collapsedRect.height / rect.height})`,\n    transformOrigin: `${originX}px ${originY}px`,\n    opacity: \"0\",\n  });\n}\n\n// To expand the card visually, we reverse the process by collapsing it first with transitions\n// disabled, then resetting properties to their defaults with transitions enabled again.\nfunction expandCard(card: HTMLElement, collapsedRect: DOMRect) {\n  prepareForTransition(card, () => collapseCard(card, collapsedRect));\n  Object.assign(card.style, {\n    transform: \"\",\n    opacity: \"\",\n    visibility: \"visible\",\n  });\n}\n\nconst cssCard = styled(\"div\", `\n  position: absolute;\n  left: 24px;\n  bottom: 24px;\n  margin-right: 24px;\n  max-width: 624px;\n  padding: 32px 56px 32px 32px;\n  background-color: ${theme.popupBg};\n  box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};\n  display: flex;\n  overflow: hidden;\n  transition-property: opacity, transform;\n  transition-duration: 0.5s;\n  transition-timing-func: ease-in;\n  --title-font-size: ${vars.headerControlFontSize};\n\n  @media ${mediaXSmall} {\n    & {\n      flex-direction: column;\n      padding: 32px;\n      --title-font-size: 18px;\n    }\n  }\n`);\n\nconst cssImage = styled(\"img\", `\n  flex: none;\n  width: 180px;\n  height: 140px;\n  margin: 0 16px 0 -8px;\n  @media ${mediaXSmall} {\n    & {\n      margin: auto;\n    }\n  }\n`);\n\nconst cssBody = styled(\"div\", `\n  color: ${theme.text};\n  min-width: 0px;\n`);\n\nconst cssTitle = styled(\"div\", `\n  color: ${theme.text};\n  font-size: var(--title-font-size);\n  font-weight: ${vars.headerControlTextWeight};\n  margin-bottom: 16px;\n`);\n\nconst cssInfo = styled(\"div\", `\n  margin: 16px 0 24px 0;\n  line-height: 1.6;\n`);\n\nexport const cssButtons = styled(\"div\", `\n  display: flex;\n`);\n\nexport const cssLinkBtn = styled(cssLink, `\n  &:not(:last-child) {\n    margin-right: 32px;\n  }\n`);\n\nexport const cssLinkIcon = styled(icon, `\n  margin-right: 8px;\n  margin-top: -2px;\n`);\n\nexport const cssCloseButton = styled(\"div\", `\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  padding: 4px;\n  border-radius: 4px;\n  cursor: pointer;\n  --icon-color: ${theme.popupCloseButtonFg};\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n\nexport const cssBigIcon = styled(icon, `\n  padding: 12px;\n`);\n"
  },
  {
    "path": "app/client/ui/ExampleInfo.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\n\nconst t = makeT(\"ExampleInfo\");\n\nexport interface IExampleInfo {\n  id: number;\n  urlId: string;\n  title: string;\n  imgUrl: string;\n  tutorialUrl: string;\n  welcomeCard: WelcomeCard;\n}\n\ninterface WelcomeCard {\n  title: string;\n  text: string;\n  tutorialName: string;\n}\n\nexport const buildExamples = (): IExampleInfo[] => [{\n  id: 1,    // Identifies the example in UserPrefs.seenExamples\n  urlId: \"lightweight-crm\",\n  title: t(\"Lightweight CRM\"),\n  imgUrl: \"https://www.getgrist.com/themes/grist/assets/images/use-cases/lightweight-crm.png\",\n  tutorialUrl: \"https://support.getgrist.com/lightweight-crm/\",\n  welcomeCard: {\n    title: t(\"Welcome to the Lightweight CRM template\"),\n    text: t(\"Check out our related tutorial for how to link data, and create high-productivity layouts.\"),\n    tutorialName: t(\"Tutorial: Create a CRM\"),\n  },\n}, {\n  id: 2,    // Identifies the example in UserPrefs.seenExamples\n  urlId: \"investment-research\",\n  title: t(\"Investment Research\"),\n  imgUrl: \"https://www.getgrist.com/themes/grist/assets/images/use-cases/data-visualization.png\",\n  tutorialUrl: \"https://support.getgrist.com/investment-research/\",\n  welcomeCard: {\n    title: t(\"Welcome to the Investment Research template\"),\n    text: t(\"Check out our related tutorial to learn how to create \\\nsummary tables and charts, and to link charts dynamically.\"),\n    tutorialName: t(\"Tutorial: Analyze & Visualize\"),\n  },\n}, {\n  id: 3,    // Identifies the example in UserPrefs.seenExamples\n  urlId: \"afterschool-program\",\n  title: t(\"Afterschool Program\"),\n  imgUrl: \"https://www.getgrist.com/themes/grist/assets/images/use-cases/business-management.png\",\n  tutorialUrl: \"https://support.getgrist.com/afterschool-program/\",\n  welcomeCard: {\n    title: t(\"Welcome to the Afterschool Program template\"),\n    text: t(\"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\"),\n    tutorialName: t(\"Tutorial: Manage Business Data\"),\n  },\n}];\n"
  },
  {
    "path": "app/client/ui/Experiments.ts",
    "content": "import { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { getStorage } from \"app/client/lib/storage\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Disposable, dom, styled } from \"grainjs\";\n\nconst t = makeT(\"Experiments\");\n\nconst G = getBrowserGlobals(\"document\", \"window\");\n\nconst EXPERIMENTS = {\n  newRecordButton: () => t(\"New record button\"),\n};\n\ntype Experiment = keyof typeof EXPERIMENTS;\n\nconst EXPERIMENT_URL_PARAM = \"experiment\";\n\nexport class Experiments extends Disposable {\n  constructor(private _userId: number) {\n    super();\n  }\n\n  public isEnabled(experiment: Experiment) {\n    const experimentState = this._getExperimentState(experiment);\n    return experimentState.enabled;\n  }\n\n  /**\n   * Returns whether or not the user wants to show the experiments modal.\n   */\n  public isRequested() {\n    const urlExperiment = this.getCurrentRequest();\n    return urlExperiment && this._isSupported(urlExperiment);\n  }\n\n  /**\n   * Returns the experiment that the user wants to show the modal for.\n   */\n  public getCurrentRequest() {\n    const searchParams = new URLSearchParams(G.window.location.search);\n    return searchParams.get(EXPERIMENT_URL_PARAM);\n  }\n\n  /**\n   * Shows the modal for the given experiment, allowing the user to enable or disable it.\n   */\n  public showModal(experiment: string) {\n    if (!this._isSupported(experiment)) {\n      return;\n    }\n\n    const experimentState = this._getExperimentState(experiment);\n    const alreadyEnabled = experimentState.enabled;\n    const experimentLabel = dom(\"strong\", EXPERIMENTS[experiment as keyof typeof EXPERIMENTS]());\n\n    confirmModal(\n      t(\"Experimental feature\"),\n      alreadyEnabled ? t(\"Disable feature\") : t(\"Enable feature\"),\n      () => {\n        this._setExperimentState(experiment, !alreadyEnabled);\n        this._showFeedbackModal(experiment, !alreadyEnabled);\n      },\n      {\n        explanation: cssWrapper(\n          dom(\"p\", dom.cls(cssWrapper.className), alreadyEnabled ?\n            t(\"You are about to disable this experimental feature: {{experiment}}\", {\n              experiment: experimentLabel,\n            }) :\n            t(\"You are about to enable this experimental feature: {{experiment}}\", {\n              experiment: experimentLabel,\n            }),\n          ),\n          !alreadyEnabled ? dom(\"p\", t(\"Don't worry, you can disable it later if needed.\")) : null,\n        ),\n        modalOptions: {\n          noEscapeKey: true,\n          noClickAway: true,\n          onCancel: this._cleanAndReloadUrl,\n        },\n      },\n    );\n  }\n\n  /**\n   * Show a modal for user feedback after toggling an experiment\n   */\n  private _showFeedbackModal(experiment: string, nowEnabled: boolean) {\n    const experimentUrl = new URL(getGristConfig().homeUrl || window.location.href);\n    experimentUrl.searchParams.set(EXPERIMENT_URL_PARAM, experiment);\n    const urlBlock = cssLink(\n      { href: experimentUrl.toString() },\n      experimentUrl.toString(),\n    );\n    const experimentLabel = dom(\"strong\", EXPERIMENTS[experiment as keyof typeof EXPERIMENTS]());\n    confirmModal(\n      t(\"Experimental feature\"),\n      t(\"Reload the page\"),\n      this._cleanAndReloadUrl,\n      {\n        explanation: cssWrapper(\n          dom(\"p\", nowEnabled ?\n            t(\"{{experiment}} enabled.\", { experiment: experimentLabel }) :\n            t(\"{{experiment}} disabled.\", { experiment: experimentLabel }),\n          ),\n          nowEnabled ?\n            dom(\n              \"p\",\n              dom.cls(cssWrapper.className),\n              t(\"Visit this URL at any time to stop using this feature: {{url}}\", { url: urlBlock }),\n            ) :\n            null,\n        ),\n        hideCancel: true,\n        modalOptions: {\n          onCancel: this._cleanAndReloadUrl,\n        },\n      },\n    );\n  }\n\n  private _isSupported(experiment: string) {\n    return EXPERIMENTS.hasOwnProperty(experiment);\n  }\n\n  private _getExperimentState(experiment: string): { enabled: boolean, timestamp: number | null } {\n    return safeJsonParse(\n      getStorage().getItem(this._getStorageKey(experiment)) || \"\",\n      { enabled: false, timestamp: null },\n    );\n  }\n\n  private _setExperimentState(experiment: string, enabled: boolean) {\n    getStorage().setItem(\n      this._getStorageKey(experiment),\n      JSON.stringify({ enabled, timestamp: Date.now() }),\n    );\n  }\n\n  private _getStorageKey(experiment: string) {\n    return `u=${this._userId}:experiment=${experiment}`;\n  }\n\n  /**\n   * Removes the current experiment URL param and reloads the page.\n   */\n  private _cleanAndReloadUrl() {\n    const url = new URL(window.location.href);\n    url.searchParams.delete(EXPERIMENT_URL_PARAM);\n    window.location.href = url.toString();\n  }\n}\n\nconst cssWrapper = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n  margin-bottom: 0;\n  & > p {\n    margin-bottom: 0;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/FieldConfig.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { BEHAVIOR, ColumnRec } from \"app/client/models/entities/ColumnRec\";\nimport { buildHighlightedCode, cssCodeBlock } from \"app/client/ui/CodeHighlight\";\nimport { cssBlockedCursor, cssFieldFormula, cssLabel, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { buildFormulaTriggers } from \"app/client/ui/TriggerFormulas\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { textInput } from \"app/client/ui2018/editableLabel\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { cssIconButton, icon } from \"app/client/ui2018/icons\";\nimport { selectMenu, selectOption, selectTitle } from \"app/client/ui2018/menus\";\nimport { createFormulaErrorObs, cssError } from \"app/client/widgets/FormulaEditor\";\nimport { RecalcWhen } from \"app/common/gristTypes\";\nimport { sanitizeIdent } from \"app/common/gutil\";\nimport { components, tokens } from \"app/common/ThemePrefs\";\nimport { CursorPos } from \"app/plugin/GristAPI\";\n\nimport { bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder,\n  Observable, styled } from \"grainjs\";\nimport * as ko from \"knockout\";\n\nconst t = makeT(\"FieldConfig\");\n\nexport const LIMITED_COLUMN_OPTIONS = t(\"Column options are limited in summary tables.\");\n\nexport function buildNameConfig(\n  owner: MultiHolder,\n  origColumn: ColumnRec,\n  cursor: ko.Computed<CursorPos>,\n  disabled: ko.Computed<boolean>, // Whether the name is editable (it's not editable for multiple selected columns).\n) {\n  const untieColId = origColumn.untieColIdFromLabel;\n\n  const editedLabel = Observable.create(owner, \"\");\n  const editableColId = Computed.create(owner, editedLabel, (use, edited) =>\n    \"$\" + (edited ? sanitizeIdent(edited) : use(origColumn.colId)));\n  const saveColId = (val: string) => origColumn.colId.saveOnly(val.startsWith(\"$\") ? val.slice(1) : val);\n\n  const isSummaryTable = Computed.create(owner, use => Boolean(use(use(origColumn.table).summarySourceTable)));\n  // We will listen to cursor position and force a blur event on both the id and\n  // the label inputs, which will trigger save before the column observable\n  // will change its value.\n  // Otherwise, blur will be invoked after column change and save handler will\n  // update a different column.\n  const editors: HTMLInputElement[] = [];\n  owner.autoDispose(\n    cursor.subscribe(() => {\n      editors.forEach(e => e.blur());\n    }),\n  );\n  const setEditor = (id: number) => (el: HTMLInputElement) => { editors[id] = el; };\n\n  const toggleUntieColId = () => {\n    if (!origColumn.disableModify.peek() && !disabled.peek()) {\n      untieColId.setAndSaveOrRevert(!untieColId.peek()).catch(reportError);\n    }\n  };\n\n  return [\n    cssLabel(t(\"COLUMN LABEL AND ID\")),\n    cssRow(\n      dom.cls(cssBlockedCursor.className, origColumn.disableModify),\n      cssColLabelBlock(\n        cssInput(fromKo(origColumn.label),\n          val => origColumn.label.setAndSaveOrRevert(val)\n            .catch(reportError)\n            .finally(() => editedLabel.set(\"\")),\n          dom.on(\"input\", (ev, elem) => { if (!untieColId.peek()) { editedLabel.set(elem.value); } }),\n          dom.boolAttr(\"readonly\", use => use(origColumn.disableModify) || use(disabled)),\n          testId(\"field-label\"),\n          setEditor(0),\n        ),\n        cssInput(editableColId,\n          saveColId,\n          dom.boolAttr(\"readonly\",\n            use => use(disabled) || use(origColumn.disableModify) || !use(origColumn.untieColIdFromLabel)),\n          cssCodeBlock.cls(\"\"),\n          { style: \"margin-top: 8px\" },\n          testId(\"field-col-id\"),\n          setEditor(1),\n        ),\n      ),\n      cssColTieBlock(\n        cssColTieConnectors(),\n        cssToggleButton(\n          dom.domComputed(untieColId, isUntied =>\n            isUntied ? icon(\"FieldReferenceDisabled\") : icon(\"FieldReference\"),\n          ),\n          cssToggleButton.cls(\"-selected\", use => !use(untieColId)),\n          dom.on(\"click\", toggleUntieColId),\n          cssToggleButton.cls(\"-disabled\", use => use(origColumn.disableModify) || use(disabled)),\n          testId(\"field-derive-id\"),\n        ),\n      ),\n    ),\n    dom.maybe(isSummaryTable,\n      () => cssRow(LIMITED_COLUMN_OPTIONS)),\n  ];\n}\n\nexport interface BuildEditorOptions {\n  // Element to attach to.\n  refElem: Element;\n  // Should the detach button be shown?\n  canDetach: boolean;\n  // Simulate user typing on the cell - open editor with an initial value.\n  editValue?: string;\n  // Custom save handler.\n  onSave?: SaveHandler;\n  // Custom cancel handler.\n  onCancel?: () => void;\n}\n\ntype SaveHandler = (column: ColumnRec, formula: string) => Promise<void>;\n\ntype BuildEditor = (options: BuildEditorOptions) => void;\n\nexport function buildFormulaConfig(\n  owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor,\n) {\n  // If we can't modify anything about the column.\n  const disableModify = Computed.create(owner, use => use(origColumn.disableModify) || use(origColumn.hasReverse));\n\n  // Intermediate state - user wants to specify formula, but haven't done yet\n  const maybeFormula = Observable.create(owner, false);\n\n  // Intermediate state - user wants to specify formula, but haven't done yet\n  const maybeTrigger = Observable.create(owner, false);\n\n  // If this column belongs to a summary table.\n  const isSummaryTable = Computed.create(owner, use => Boolean(use(use(origColumn.table).summarySourceTable)));\n\n  // Column behavior. There are 3 types of behaviors:\n  // - empty: isFormula and formula == ''\n  // - formula: isFormula and formula != ''\n  // - data: not isFormula nd formula == ''\n  const behavior = Computed.create<BEHAVIOR | null>(owner, (use) => {\n    // When no id column is invalid, show nothing.\n    if (!use(origColumn.id)) { return null; }\n    // Column is a formula column, when it is a formula column with valid formula or will be a formula.\n    if (use(origColumn.isRealFormula) || use(maybeFormula)) { return \"formula\"; }\n    // If column is not empty, or empty but wants to be a trigger\n    if (use(maybeTrigger) || !use(origColumn.isEmpty)) { return \"data\"; }\n    return \"empty\";\n  });\n\n  // Reference to current editor, we will open it when user wants to specify a formula or trigger.\n  // And close it dispose it when user opens up behavior menu.\n  let formulaField: HTMLElement | null = null;\n\n  const focusFormulaField = () => setTimeout(() => formulaField?.focus(), 0);\n\n  // Helper function to clear temporary state (will be called when column changes or formula editor closes)\n  const clearState = () => bundleChanges(() => {\n    // For a detached editor, we may have already been disposed when user switched page.\n    if (owner.isDisposed()) { return; }\n    maybeFormula.set(false);\n    maybeTrigger.set(false);\n    formulaField = null;\n  });\n\n  // Clear state when column has changed\n  owner.autoDispose(origColumn.id.subscribe(clearState));\n  owner.autoDispose(origColumn.formula.subscribe(clearState));\n  owner.autoDispose(origColumn.isFormula.subscribe(clearState));\n\n  // User might have selected multiple columns, in that case all elements will be disabled, except the menu.\n  // If user has selected only empty or formula columns, we offer to reset all or to convert to data.\n  // If user has selected any data column, we offer only to reset all.\n  const viewSection = Computed.create(owner, (use) => {\n    return use(gristDoc.currentView)?.viewSection;\n  });\n  const isMultiSelect = Computed.create(owner, (use) => {\n    const vs = use(viewSection);\n    return !!vs && use(vs.selectedFields).length > 1;\n  });\n\n  // If all columns are empty or have formulas.\n  const multiType = Computed.create(owner, (use) => {\n    if (!use(isMultiSelect)) { return false; }\n    const vs = use(viewSection);\n    if (!vs) { return false; }\n    return use(vs.columnsBehavior);\n  });\n\n  // If all columns are empty or have formulas.\n  const isFormulaLike = Computed.create(owner, (use) => {\n    if (!use(isMultiSelect)) { return false; }\n    const vs = use(viewSection);\n    if (!vs) { return false; }\n    return use(vs.columnsAllIsFormula);\n  });\n\n  // Helper to get all selected columns refs.\n  const selectedColumns = () => viewSection.get()?.selectedFields.peek().map(f => f.column.peek()) || [];\n  const selectedColumnIds = () => selectedColumns().map(f => f.id.peek()) || [];\n\n  // Clear and reset all option for multiple selected columns.\n  const clearAndResetAll = () => selectOption(\n    () => Promise.all([\n      gristDoc.docModel.clearColumns(selectedColumnIds()),\n    ]),\n    \"Clear and reset\", \"CrossSmall\",\n  );\n\n  // Convert the given columns to data, saving the calculated values and unsetting the formulas.\n  const convertIsFormula = async (colRefs: number[], opts: { toFormula: boolean, noRecalc?: boolean }) => {\n    return gristDoc.docModel.columns.sendTableAction(\n      [\"BulkUpdateRecord\", colRefs, {\n        isFormula: colRefs.map(f => opts.toFormula),\n        recalcWhen: colRefs.map(f => opts.noRecalc ? RecalcWhen.NEVER : RecalcWhen.DEFAULT),\n        recalcDeps: colRefs.map(f => null),\n      }],\n    );\n  };\n\n  // Convert to data option for multiple selected columns.\n  const convertToDataAll = () => selectOption(\n    () => convertIsFormula(selectedColumnIds(), { toFormula: false, noRecalc: true }),\n    \"Convert columns to data\", \"Database\",\n    dom.cls(\"disabled\", isSummaryTable),\n  );\n\n  // Menu helper that will show normal menu with some default options\n  const menu = (label: DomContents, options: DomElementArg[]) =>\n    cssRow(\n      selectMenu(\n        label,\n        () => !isMultiSelect.get() ? options : [\n          isFormulaLike.get() ? convertToDataAll() : null,\n          clearAndResetAll(),\n        ],\n        testId(\"field-behaviour\"),\n        // HACK: Menu helper will add tabindex to this element, which will make\n        // this element focusable and will steal focus from clipboard. This in turn,\n        // will not dispose the formula editor when menu is clicked.\n        el => el.removeAttribute(\"tabindex\"),\n        dom.cls(cssBlockedCursor.className, disableModify),\n        dom.cls(\"disabled\", disableModify)),\n    );\n\n  // Behavior label\n  const behaviorName = Computed.create(owner, behavior, (use, type) => {\n    if (use(isMultiSelect)) {\n      const commonType = use(multiType);\n      if (commonType === \"formula\") { return t(\"Formula columns\", { count: 2 }); }\n      if (commonType === \"data\") { return t(\"Data columns\", { count: 2 }); }\n      if (commonType === \"mixed\") { return t(\"Mixed Behavior\"); }\n      return t(\"Empty columns\", { count: 2 });\n    } else {\n      if (type === \"formula\") { return t(\"Formula columns\", { count: 1 }); }\n      if (type === \"data\") { return t(\"Data columns\", { count: 1 }); }\n      return t(\"Empty columns\", { count: 1 });\n    }\n  });\n  const behaviorIcon = Computed.create<IconName>(owner, (use) => {\n    return use(behaviorName) === t(\"Data columns\", { count: 2 }) ||\n      use(behaviorName) === t(\"Data columns\", { count: 1 }) ? \"Database\" : \"Script\";\n  });\n  const behaviorLabel = () => selectTitle(behaviorName, behaviorIcon);\n\n  // Actions on select menu:\n\n  // Converts data column to formula column.\n  const convertDataColumnToFormulaOption = () => selectOption(\n    () => (maybeFormula.set(true), focusFormulaField()),\n    t(\"Clear and make into formula\"), \"Script\");\n\n  // Converts to empty column and opens up the editor. (label is the same, but this is used when we have no formula)\n  const convertTriggerToFormulaOption = () => selectOption(\n    () => convertIsFormula([origColumn.id.peek()], { toFormula: true, noRecalc: true }),\n    t(\"Clear and make into formula\"), \"Script\");\n\n  // Convert column to data.\n  // This method is also available through a text button.\n  const convertToData = () => convertIsFormula([origColumn.id.peek()], { toFormula: false, noRecalc: true });\n  const convertToDataOption = () => selectOption(\n    convertToData,\n    t(\"Convert column to data\"), \"Database\",\n    dom.cls(\"disabled\", isSummaryTable),\n  );\n\n  // Clears the column\n  const clearAndResetOption = () => selectOption(\n    () => gristDoc.docModel.clearColumns([origColumn.id.peek()]),\n    t(\"Clear and reset\"), \"CrossSmall\");\n\n  // Actions on text buttons:\n\n  // Tries to convert data column to a trigger column.\n  const convertDataColumnToTriggerColumn = () => {\n    maybeTrigger.set(true);\n    // Open the formula editor.\n    focusFormulaField();\n  };\n\n  // Converts formula column to trigger formula column.\n  const convertFormulaToTrigger = () =>\n    convertIsFormula([origColumn.id.peek()], { toFormula: false, noRecalc: false });\n\n  const setFormula = () => { maybeFormula.set(true); focusFormulaField(); };\n  const setTrigger = () => { maybeTrigger.set(true); focusFormulaField(); };\n\n  // Actions on save formula. Those actions are using column that comes from FormulaEditor.\n  // Formula editor scope is broader then RightPanel, it can be disposed after RightPanel is closed,\n  // and in some cases, when window is in background, it won't be disposed at all when panel is closed.\n\n  // Converts column to formula column.\n  const onSaveConvertToFormula = async (column: ColumnRec, formula: string) => {\n    // For a detached editor, we may have already been disposed when user switched page.\n    if (owner.isDisposed()) { return; }\n    // For non formula column, we will not convert it to formula column when expression is empty,\n    // as it means we were trying to convert data column to formula column, but changed our mind.\n    const notBlank = Boolean(formula);\n    // But when the column is a formula column, empty formula expression is acceptable (it will\n    // convert column to empty column).\n    const trueFormula = column.formula.peek();\n    if (notBlank || trueFormula) { await gristDoc.docModel.convertToFormula(column.id.peek(), formula); }\n    // Clear state only when owner was not disposed\n    if (!owner.isDisposed()) {\n      clearState();\n    }\n  };\n\n  // Updates formula or convert column to trigger formula column if necessary.\n  const onSaveConvertToTrigger = async (column: ColumnRec, formula: string) => {\n    // If formula expression is not empty, and column was plain data column (without a formula)\n    if (formula && !column.hasTriggerFormula.peek()) {\n      // then convert column to a trigger formula column\n      await gristDoc.docModel.convertToTrigger(column.id.peek(), formula);\n    } else if (column.hasTriggerFormula.peek()) {\n      // else, if it was already a trigger formula column, just update formula.\n      await gristDoc.docModel.updateFormula(column.id.peek(), formula);\n    }\n    // Clear state only when owner was not disposed\n    if (!owner.isDisposed()) {\n      clearState();\n    }\n  };\n\n  // Should we disable all other action buttons and formula editor. For now\n  // we will disable them when multiple columns are selected, or any of the column selected\n  // can't be modified or if the column has a reverse column.\n  const disableOtherActions = Computed.create(owner,\n    use => use(disableModify) || use(isMultiSelect) || use(origColumn.hasReverse),\n  );\n\n  const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);\n  // Helper that will create different flavors for formula builder.\n  const formulaBuilder = (onSave: SaveHandler, canDetach?: boolean) => [\n    cssRow(\n      buildFormula(\n        origColumn,\n        buildEditor,\n        {\n          disabled: disableOtherActions,\n          canDetach,\n          onSave,\n          onCancel: clearState,\n        },\n        (el) => { formulaField = el; },\n      ),\n    ),\n    dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId(\"field-error-count\"))),\n  ];\n\n  return dom.maybe(behavior, (type: BEHAVIOR) => [\n    cssLabel(t(\"COLUMN BEHAVIOR\")),\n    ...(type === \"empty\" ? [\n      menu(behaviorLabel(), [\n        convertToDataOption(),\n      ]),\n      cssEmptySeparator(),\n      cssRow(textButton(\n        t(\"Set formula\"),\n        dom.on(\"click\", setFormula),\n        dom.prop(\"disabled\", disableOtherActions),\n        testId(\"field-set-formula\"),\n      )),\n      cssRow(withInfoTooltip(\n        textButton(\n          t(\"Set trigger formula\"),\n          dom.on(\"click\", setTrigger),\n          dom.prop(\"disabled\", use => use(isSummaryTable) || use(disableOtherActions)),\n          testId(\"field-set-trigger\"),\n        ),\n        \"setTriggerFormula\",\n      )),\n      cssRow(textButton(\n        t(\"Make into data column\"),\n        dom.on(\"click\", convertToData),\n        dom.prop(\"disabled\", use => use(isSummaryTable) || use(disableOtherActions)),\n        testId(\"field-set-data\"),\n      )),\n    ] : type === \"formula\" ? [\n      menu(behaviorLabel(), [\n        convertToDataOption(),\n        clearAndResetOption(),\n      ]),\n      formulaBuilder(onSaveConvertToFormula),\n      cssEmptySeparator(),\n      cssRow(textButton(\n        t(\"Convert to trigger formula\"),\n        dom.on(\"click\", convertFormulaToTrigger),\n        dom.hide(maybeFormula),\n        dom.prop(\"disabled\", use => use(isSummaryTable) || use(disableOtherActions)),\n        testId(\"field-set-trigger\"),\n      )),\n    ] : /* type == 'data' */ [\n      menu(behaviorLabel(),\n        [\n          dom.domComputed(origColumn.hasTriggerFormula, hasTrigger => hasTrigger ?\n          // If we have trigger, we will convert it directly to a formula column\n            convertTriggerToFormulaOption() :\n          // else we will convert to empty column and open up the editor\n            convertDataColumnToFormulaOption(),\n          ),\n          clearAndResetOption(),\n        ],\n      ),\n      // If data column is or wants to be a trigger formula:\n      dom.maybe(use => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [\n        cssLabel(t(\"TRIGGER FORMULA\")),\n        formulaBuilder(onSaveConvertToTrigger, false),\n        dom.create(buildFormulaTriggers, origColumn, {\n          disabled: disableOtherActions,\n          notTrigger: maybeTrigger,\n        }),\n      ]),\n      // Else offer a way to convert to trigger formula.\n      dom.maybe(use => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [\n        cssEmptySeparator(),\n        cssRow(withInfoTooltip(\n          textButton(\n            t(\"Set trigger formula\"),\n            dom.on(\"click\", convertDataColumnToTriggerColumn),\n            dom.prop(\"disabled\", disableOtherActions),\n            testId(\"field-set-trigger\"),\n          ),\n          \"setTriggerFormula\",\n        )),\n      ]),\n    ]),\n  ]);\n}\n\ninterface BuildFormulaOptions {\n  disabled: Observable<boolean>;\n  canDetach?: boolean;\n  onSave?: SaveHandler;\n  onCancel?: () => void;\n}\n\nfunction buildFormula(\n  column: ColumnRec,\n  buildEditor: BuildEditor,\n  options: BuildFormulaOptions,\n  ...args: DomElementArg[]\n) {\n  const { disabled, canDetach = true, onSave, onCancel } = options;\n  return dom.create(buildHighlightedCode, column.formula, { maxLines: 2 },\n    dom.cls(cssFieldFormula.className),\n    dom.cls(\"formula_field_sidepane\"),\n    cssFieldFormula.cls(\"-disabled\", disabled),\n    cssFieldFormula.cls(\"-disabled-icon\", use => !use(column.formula)),\n    dom.cls(\"disabled\"),\n    { tabIndex: \"-1\" },\n    // Focus event use used by a user to edit an existing formula.\n    // It can also be triggered manually to open up the editor.\n    dom.on(\"focus\", (_, refElem) => buildEditor({\n      refElem,\n      editValue: undefined,\n      canDetach,\n      onSave,\n      onCancel,\n    })),\n    ...args,\n  );\n}\n\nconst cssToggleButton = styled(cssIconButton, `\n  margin-left: 8px;\n  background-color: ${components.buttonGroupBg};\n  box-shadow: inset 0 0 0 1px ${components.buttonGroupBorder};\n  cursor: pointer;\n\n  &:hover {\n    background-color: ${components.buttonGroupBgHover};\n    box-shadow: inset 0 0 0 1px ${components.buttonGroupBorderHover};\n  }\n\n  &-selected, &-selected:hover {\n    box-shadow: inset 0 0 0 1px ${components.buttonGroupSelectedBorder};\n    background-color: ${components.buttonGroupSelectedBg};\n    --icon-color: ${components.buttonGroupSelectedBorder};\n  }\n  &-disabled, &-disabled:hover {\n    cursor: not-allowed;\n    background-color: ${tokens.bg};\n    --icon-color: ${components.iconDisabled};\n  }\n`);\n\nconst cssColLabelBlock = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex: auto;\n  min-width: 80px;\n`);\n\nconst cssColTieBlock = styled(\"div\", `\n  position: relative;\n`);\n\nconst cssColTieConnectors = styled(\"div\", `\n  position: absolute;\n  border: 2px solid ${theme.inputBorder};\n  top: -9px;\n  bottom: -9px;\n  right: 11px;\n  left: 0px;\n  border-left: none;\n  z-index: -1;\n`);\n\nconst cssEmptySeparator = styled(\"div\", `\n  margin-top: 16px;\n`);\n\nconst cssInput = styled(textInput, `\n  color: ${theme.inputFg};\n  background-color: ${theme.mainPanelBg};\n  border: 1px solid ${theme.inputBorder};\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n\n  &[readonly] {\n    background-color: ${theme.inputDisabledBg};\n    color: ${theme.inputDisabledFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/FieldContextMenu.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { menuDivider, menuItemCmd } from \"app/client/ui2018/menus\";\n\nimport { dom } from \"grainjs\";\n\nconst t = makeT(\"FieldContextMenu\");\n\nexport interface IFieldContextMenu {\n  disableModify: boolean;\n  isReadonly: boolean;\n  field: ViewFieldRec;\n  isAddRow: boolean;\n}\n\nexport function FieldContextMenu(fieldOptions: IFieldContextMenu) {\n  const { disableModify, isReadonly, field, isAddRow } = fieldOptions;\n  const disableForReadonlyColumn = dom.cls(\"disabled\", disableModify || isReadonly);\n\n  const isVirtual = typeof field.colRef.peek() === \"string\";\n  const disabledForVirtual = dom.cls(\"disabled\", isVirtual);\n\n  return [\n    menuItemCmd(allCommands.contextMenuCut, t(\"Cut\"), disableForReadonlyColumn),\n    menuItemCmd(allCommands.contextMenuCopy, t(\"Copy\")),\n    menuItemCmd(allCommands.contextMenuPaste, t(\"Paste\"), disableForReadonlyColumn),\n    menuDivider(),\n    menuItemCmd(allCommands.clearValues, t(\"Clear field\"), disableForReadonlyColumn),\n    menuItemCmd(allCommands.hideCardFields, t(\"Hide field\"), disableForReadonlyColumn),\n    menuDivider(),\n    menuItemCmd(allCommands.openDiscussion, t(\"Comment\"), dom.cls(\"disabled\", isReadonly || isVirtual || isAddRow)),\n    menuItemCmd(allCommands.copyLink, t(\"Copy anchor link\"), disabledForVirtual),\n  ];\n}\n"
  },
  {
    "path": "app/client/ui/FieldMenus.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { menuItem, menuSubHeader } from \"app/client/ui2018/menus\";\n\nimport { dom } from \"grainjs\";\n\ninterface IFieldOptions {\n  useSeparate: () => void;\n  saveAsCommon: () => void;\n  revertToCommon: () => void;\n}\n\nconst t = makeT(\"FieldMenus\");\n\nexport function FieldSettingsMenu(useColOptions: boolean, disableSeparate: boolean, actions: IFieldOptions) {\n  useColOptions = useColOptions || disableSeparate;\n  return [\n    menuSubHeader(useColOptions ? t(\"Using common settings\") : t(\"Using separate settings\")),\n    useColOptions ? menuItem(actions.useSeparate, t(\"Use separate settings\"), dom.cls(\"disabled\", disableSeparate)) : [\n      menuItem(actions.saveAsCommon, t(\"Save as common settings\")),\n      menuItem(actions.revertToCommon, t(\"Revert to common settings\")),\n    ],\n  ];\n}\n"
  },
  {
    "path": "app/client/ui/FileDialog.ts",
    "content": "/**\n * Utility to simplify file uploads via the browser-provided file picker. It takes care of\n * maintaining an invisible <input type=file>, to make usage very simple:\n *\n *    FileDialog.open({ multiple: true }, files => { do stuff with files });\n *\n * Promise interface allows this:\n *\n *    const fileList = await FileDialog.openFilePicker({multiple: true});\n *\n * (Note that in either case, it's possible for the callback to never be called, or for the\n * Promise to never resolve; see comments for openFilePicker.)\n *\n * Note that interacting with a file dialog is difficult with WebDriver, but\n * test/browser/gristUtils.js provides a `gu.fileDialogUpload()` to make it easy.\n */\n\nimport * as browserGlobals from \"app/client/lib/browserGlobals\";\nimport dom from \"app/client/lib/dom\";\nconst G = browserGlobals.get(\"document\", \"window\");\n\nexport interface FileDialogOptions {\n  multiple?: boolean;   // Whether multiple files may be selected.\n  accept?: string;      // Comma-separated list of content-type specifiers,\n  // e.g. \".jpg,.png\", \"text/plain\", \"audio/*\", \"video/*\", \"image/*\".\n}\n\ntype FilesCB = (files: File[]) => void;\n\nfunction noop() { /* no-op */ }\n\nlet _fileForm: HTMLFormElement;\nlet _fileInput: HTMLInputElement;\nlet _currentCB: FilesCB = noop;\n\n/**\n * Opens the file picker dialog, and returns a Promise for the list of selected files.\n * WARNING: The Promise might NEVER resolve. If the user dismisses the dialog without picking a\n *          file, there is no good way to detect that in order to resolve the promise.\n *          Do NOT rely on the promise resolving, e.g. on .finally() getting called.\n *          The implementation MAY resolve with an empty list in this case, when possible.\n *\n * This does not cause indefinite memory leaks. If the dialog is opened again, the reference to\n * the previous callback is cleared, and GC can collect the forgotten promise and related memory.\n *\n * Ideally we'd know when the dialog is dismissed without a selection, but that seems impossible\n * today. See https://stackoverflow.com/questions/4628544/how-to-detect-when-cancel-is-clicked-on-file-input\n * (tricks using click, focus, blur, etc are unreliable even in one browser, much less cross-platform).\n */\nexport function openFilePicker(options: FileDialogOptions): Promise<File[]> {\n  return new Promise(resolve => open(options, resolve));\n}\n\n/**\n * Opens the file picker dialog. If files are selected, calls the provided callback.\n * If no files are selected, will call the callback with an empty list if possible, or more\n * typically not call it at all.\n */\nexport function open(options: FileDialogOptions, callback: FilesCB): void {\n  if (!_fileInput) {\n    // The IDs are only needed for the sake of browser tests.\n    _fileForm = dom(\"form#file_dialog_form\", { style: \"position: absolute; top: 0; display: none\" },\n      _fileInput = dom(\"input#file_dialog_input\", { type: \"file\" }));\n\n    G.document.body.appendChild(_fileForm);\n\n    _fileInput.addEventListener(\"change\", (ev) => {\n      _currentCB(_fileInput.files ? Array.from(_fileInput.files) : []);\n      _currentCB = noop;\n    });\n  }\n\n  // Clear the input, to make sure that selecting the same file as previously still\n  // triggers a 'change' event.\n  _fileForm.reset();\n  _fileInput.multiple = Boolean(options.multiple);\n  _fileInput.accept = options.accept || \"\";\n  _currentCB = callback;\n\n  // .click() is a well-supported shorthand for dispatching a mouseclick event on input elements.\n  // We do it in a separate tick to work around a rare Firefox bug.\n  setTimeout(() => _fileInput.click(), 0);\n}\n"
  },
  {
    "path": "app/client/ui/FilterBar.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { NEW_FILTER_JSON } from \"app/client/models/ColumnFilter\";\nimport { ColumnRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { FilterInfo } from \"app/client/models/entities/ViewSectionRec\";\nimport { attachColumnFilterMenu } from \"app/client/ui/ColumnFilterMenu\";\nimport { dropdownWithSearch } from \"app/client/ui/searchDropdown\";\nimport { cssButton } from \"app/client/ui2018/buttons\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\n\nimport { dom, IDisposableOwner, IDomArgs, styled } from \"grainjs\";\nimport { IPopupOptions, PopupControl } from \"popweasel\";\n\nconst t = makeT(\"FilterBar\");\n\nexport function filterBar(\n  _owner: IDisposableOwner,\n  gristDoc: GristDoc,\n  viewSection: ViewSectionRec,\n) {\n  const popupControls = new WeakMap<ColumnRec, PopupControl>();\n  return cssFilterBar(\n    testId(\"filter-bar\"),\n    dom.forEach(viewSection.activeFilters, filterInfo => makeFilterField(filterInfo, popupControls)),\n    dom.maybe(viewSection.showNestedFilteringPopup, () => {\n      return dom(\"div\",\n        gristDoc.behavioralPromptsManager.attachPopup(\"nestedFiltering\", {\n          onDispose: () => viewSection.showNestedFilteringPopup.set(false),\n        }),\n      );\n    }),\n    makePlusButton(viewSection, popupControls),\n    cssFilterBar.cls(\"-hidden\", use => use(viewSection.pinnedActiveFilters).length === 0),\n  );\n}\n\nfunction makeFilterField(filterInfo: FilterInfo, popupControls: WeakMap<ColumnRec, PopupControl>) {\n  const { fieldOrColumn, filter, pinned, isPinned } = filterInfo;\n  return cssFilterBarItem(\n    testId(\"filter-field\"),\n    cssFilterBarItemButton(\n      testId(\"btn\"),\n      cssFilterBarItemIcon(\"FilterSimple\"),\n      cssMenuTextLabel(dom.text(fieldOrColumn.origCol().label)),\n      cssBtn.cls(\"-grayed\", use => use(filter.isSaved) && use(pinned.isSaved)),\n      attachColumnFilterMenu(filterInfo, {\n        popupOptions: {\n          placement: \"bottom-start\",\n          attach: \"body\",\n          trigger: [\n            \"click\",\n            (_el, popupControl) => popupControls.set(fieldOrColumn.origCol(), popupControl),\n          ],\n        },\n        showAllFiltersButton: true,\n      }),\n    ),\n    cssFilterBarItem.cls(\"-unpinned\", use => !use(isPinned)),\n  );\n}\n\nexport interface AddFilterMenuOptions {\n  /**\n   * If 'only-unfiltered', only columns without active filters will be selectable in\n   * the menu.\n   *\n   * If 'unpinned-or-unfiltered', columns that have active filters but are not pinned\n   * will also be selectable.\n   *\n   * Defaults to `only-unfiltered'.\n   */\n  allowedColumns?: \"only-unfiltered\" | \"unpinned-or-unfiltered\";\n  /**\n   * Options that are passed to the menu component.\n   */\n  menuOptions?: IPopupOptions;\n}\n\nexport function addFilterMenu(\n  filters: FilterInfo[],\n  popupControls: WeakMap<ColumnRec, PopupControl>,\n  options: AddFilterMenuOptions = {},\n) {\n  const { allowedColumns, menuOptions } = options;\n  return (\n    dropdownWithSearch<FilterInfo>({\n      action: filterInfo => openFilter(filterInfo, popupControls),\n      options: () => filters.map(filterInfo => ({\n        label: filterInfo.fieldOrColumn.origCol().label.peek(),\n        value: filterInfo,\n        disabled: allowedColumns === \"unpinned-or-unfiltered\" ?\n          filterInfo.isPinned.peek() && filterInfo.isFiltered.peek() :\n          filterInfo.isFiltered.peek(),\n      })),\n      popupOptions: menuOptions,\n      placeholder: t(\"Search Columns\"),\n    })\n  );\n}\n\nfunction openFilter(\n  { fieldOrColumn, isFiltered, viewSection }: FilterInfo,\n  popupControls: WeakMap<ColumnRec, PopupControl>,\n) {\n  viewSection.setFilter(fieldOrColumn.origCol().origColRef(), {\n    filter: isFiltered.peek() ? undefined : NEW_FILTER_JSON,\n    pinned: true,\n  });\n  popupControls.get(fieldOrColumn.origCol())?.open();\n}\n\nfunction makePlusButton(viewSectionRec: ViewSectionRec, popupControls: WeakMap<ColumnRec, PopupControl>) {\n  return dom.domComputed((use) => {\n    const filters = use(viewSectionRec.filters);\n    return cssPlusButton(\n      cssBtn.cls(\"-grayed\"),\n      cssPlusIcon(\"Plus\"),\n      addFilterMenu(filters, popupControls, {\n        allowedColumns: \"unpinned-or-unfiltered\",\n      }),\n      testId(\"add-filter-btn\"),\n    );\n  });\n}\n\nconst cssFilterBar = styled(\"div.filter_bar\", `\n  display: flex;\n  flex-direction: row;\n  flex-wrap: wrap;\n  row-gap: 8px;\n  margin: 8px 0px 8px -4px;\n  &-hidden {\n    display: none;\n  }\n`);\nconst cssFilterBarItem = styled(\"div\", `\n  flex: 1 1 80px;\n  min-width: 0px;\n  max-width: max-content;\n  border-radius: ${vars.controlBorderRadius};\n  margin: 0 4px;\n  &-unpinned {\n    display: none;\n  }\n`);\nconst cssFilterBarItemIcon = styled(icon, `\n  flex-shrink: 0;\n`);\nconst cssMenuTextLabel = styled(\"span\", `\n  flex-grow: 1;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n`);\nconst cssPlusIcon = styled(icon, `\n  margin-top: -3px;\n`);\nconst cssBtn = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  column-gap: 4px;\n\n  height: 24px;\n  padding: 3px 8px;\n  .${cssFilterBar.className} > & {\n    margin: 0 4px;\n  }\n  &-grayed {\n    color:        ${theme.filterBarButtonSavedFg};\n    --icon-color: ${theme.filterBarButtonSavedFg};\n    background-color: ${theme.filterBarButtonSavedBg};\n    border-color: ${theme.filterBarButtonSavedBg};\n  }\n  &-grayed:hover {\n    background-color: ${theme.filterBarButtonSavedHoverBg};\n    border-color: ${theme.filterBarButtonSavedHoverBg};\n  }\n`);\nconst cssFilterBarItemButton = (...args: IDomArgs<HTMLDivElement>) => (\n  dom(\"div\", cssButton.cls(\"\"), cssButton.cls(\"-primary\"),\n    cssBtn.cls(\"\"), ...args)\n);\nconst cssPlusButton = styled(cssFilterBarItemButton, `\n  padding: 3px 3px\n`);\n"
  },
  {
    "path": "app/client/ui/FilterConfig.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { attachColumnFilterMenu } from \"app/client/ui/ColumnFilterMenu\";\nimport { addFilterMenu } from \"app/client/ui/FilterBar\";\nimport { cssIcon, cssPinButton, cssRow, cssSortFilterColumn } from \"app/client/ui/RightPanelStyles\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\n\nimport { Computed, Disposable, dom, makeTestId, styled } from \"grainjs\";\nimport { IMenuOptions } from \"popweasel\";\n\nconst testId = makeTestId(\"test-filter-config-\");\n\nconst t = makeT(\"SortConfig\");\n\nexport interface FilterConfigOptions {\n  /** Options to pass to the menu and popup components. */\n  menuOptions?: IMenuOptions;\n}\n\n/**\n * Component that renders controls for managing filters for a view section.\n *\n * Active filters (i.e. columns that have non-blank filters set) are displayed in\n * a vertical list of pill-shaped buttons. These buttons can be clicked to open their\n * respective filter menu. Additionally, there are buttons to the right of each filter\n * for removing and pinning them.\n */\nexport class FilterConfig extends Disposable {\n  private _popupControls = new WeakMap();\n\n  private _canAddFilter = Computed.create(this, (use) => {\n    return use(this._section.filters).some(f => !use(f.isFiltered));\n  });\n\n  constructor(private _section: ViewSectionRec, private _options: FilterConfigOptions = {}) {\n    super();\n  }\n\n  public buildDom() {\n    const { menuOptions } = this._options;\n    return dom(\"div\",\n      dom.forEach(this._section.activeFilters, (filterInfo) => {\n        const { fieldOrColumn, filter, pinned, isPinned } = filterInfo;\n        return cssRow(\n          cssSortFilterColumn(\n            cssIconWrapper(\n              cssFilterIcon(\"FilterSimple\",\n                cssFilterIcon.cls(\"-accent\", use => !use(filter.isSaved) || !use(pinned.isSaved)),\n                testId(\"filter-icon\"),\n              ),\n            ),\n            cssLabel(dom.text(fieldOrColumn.label)),\n            dom.attr(\"aria-label\", use =>\n              t(\"{{- columnName }} column filters\", { columnName: use(fieldOrColumn.label) }),\n            ),\n            attachColumnFilterMenu(filterInfo, {\n              popupOptions: {\n                placement: \"bottom-end\",\n                ...menuOptions,\n                trigger: [\n                  \"click\",\n                  (_el, popupControl) => this._popupControls.set(fieldOrColumn.origCol(), popupControl),\n                ],\n              },\n            }),\n            testId(\"column\"),\n          ),\n          cssPinFilterButton(\n            icon(\"PinTilted\"),\n            dom.attr(\"aria-label\", use => use(isPinned) ?\n              t(\"Unpin filter - {{- columnName}} column (current: pinned)\", { columnName: use(fieldOrColumn.label) }) :\n              t(\"Pin filter - {{- columnName}} column (current: unpinned)\", { columnName: use(fieldOrColumn.label) }),\n            ),\n            dom.on(\"click\", () => this._section.setFilter(fieldOrColumn.origCol().origColRef(), {\n              pinned: !isPinned.peek(),\n            })),\n            cssPinButton.cls(\"-pinned\", isPinned),\n            testId(\"pin-filter\"),\n          ),\n          cssIconWrapper(\n            cssRemoveFilterButton(\n              cssIcon(\"Remove\"),\n              dom.attr(\"aria-label\", use =>\n                t(\"remove filter - {{- columnName}} column\", { columnName: use(fieldOrColumn.label) }),\n              ),\n              dom.on(\"click\",\n                () => this._section.setFilter(fieldOrColumn.origCol().origColRef(), {\n                  filter: \"\",\n                  pinned: false,\n                })),\n              testId(\"remove-filter\"),\n            ),\n          ),\n          testId(\"filter\"),\n        );\n      }),\n      cssRow(\n        dom.domComputed((use) => {\n          const filters = use(this._section.filters);\n          return textButton(\n            t(\"Add column\"),\n            addFilterMenu(filters, this._popupControls, {\n              menuOptions: {\n                placement: \"bottom-end\",\n                ...this._options.menuOptions,\n              },\n            }),\n            dom.on(\"click\", ev => ev.stopPropagation()),\n            dom.hide(u => !u(this._canAddFilter)),\n            testId(\"add-filter-btn\"),\n          );\n        }),\n      ),\n      testId(\"container\"),\n    );\n  }\n}\n\nconst cssIconWrapper = styled(\"div\", ``);\n\nconst cssLabel = styled(\"div\", `\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  overflow: hidden;\n  flex-grow: 1;\n`);\n\nconst cssFilterIcon = styled(cssIcon, `\n  flex: none;\n  margin: 0px 6px 0px 0px;\n  background-color: ${theme.controlSecondaryFg};\n\n  &-accent {\n    background-color: ${theme.accentIcon};\n  }\n`);\n\nconst cssRemoveFilterButton = styled(unstyledButton, `\n  flex: none;\n  margin: 0 6px;\n  cursor: pointer;\n  & .${cssIcon.className} {\n    background-color: ${theme.controlSecondaryFg};\n  }\n  &:hover .${cssIcon.className}{\n    background-color: ${theme.controlSecondaryHoverFg};\n  }\n`);\n\nconst cssPinFilterButton = styled(cssPinButton, `\n  margin-left: 6px;\n`);\n"
  },
  {
    "path": "app/client/ui/FloatingPopup.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { documentCursor } from \"app/client/lib/popupUtils\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { isNarrowScreen, isNarrowScreenObs, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { clamp } from \"app/common/gutil\";\n\nimport {\n  Disposable,\n  dom,\n  DomContents,\n  DomElementArg,\n  IDisposable,\n  makeTestId,\n  Observable,\n  styled,\n  subscribeElem,\n  TestId,\n} from \"grainjs\";\nimport $ from \"jquery\";\n\nconst POPUP_GAP_PX = 16;\n\nconst POPUP_HEADER_HEIGHT_PX = 30;\n\nconst t = makeT(\"FloatingPopup\");\n\nconst defaultTestId = makeTestId(\"test-floating-\");\n\nexport const FLOATING_POPUP_TOOLTIP_KEY = \"floatingPopupTooltip\";\n\nexport interface PopupOptions {\n  /** Width in pixels. */\n  width: number;\n  /** Height in pixels. */\n  height: number;\n  title?: () => DomContents;\n  content?: () => DomContents;\n  onClose?: () => void;\n  onMoveEnd?: (position: PopupPosition) => void;\n  onResizeEnd?: (size: PopupSize) => void;\n  closeButton?: boolean;\n  closeButtonIcon?: IconName;\n  closeButtonHover?: () => DomContents;\n  /** Defaults to close. */\n  closeBehavior?: \"close\" | \"hide\";\n  minimizable?: boolean;\n  /** Minimum width in pixels. */\n  minWidth?: number;\n  /** Minimum height in pixels. */\n  minHeight?: number;\n  /** Maximum width in pixels. */\n  maxWidth?: number;\n  /** Maximum height in pixels. */\n  maxHeight?: number;\n  /** Defaults to false. */\n  stopClickPropagationOnMove?: boolean;\n  position?: PopupPosition;\n  args?: DomElementArg[];\n  testId?: TestId;\n}\n\nexport interface PopupPosition {\n  left: number;\n  top: number;\n}\n\ninterface PopupSize extends PopupPosition {\n  width: number;\n  height: number;\n}\n\nexport class FloatingPopup extends Disposable {\n  private _width = Observable.create(this, this._options.width);\n  private _height = Observable.create(this, this._options.height);\n  private _position = Observable.create(this, this._options.position);\n  private _closable = this._options.closeButton ?? false;\n  private _closeBehavior = this._options.closeBehavior ?? \"close\";\n  private _minimizable = this._options.minimizable ?? false;\n  private _isMinimized = Observable.create(this, false);\n  private _isHidden = Observable.create(this, true);\n  private _minWidth = this._options.minWidth ?? 0;\n  private _minHeight = this._options.minHeight ?? 0;\n  private _maxWidth = this._options.maxWidth ?? Infinity;\n  private _maxHeight = this._options.maxHeight ?? Infinity;\n  private _isFinishingMove = false;\n  private _popupElement: HTMLElement | null = null;\n  private _popupMinimizeButtonElement: HTMLElement | null = null;\n\n  private _startX: number;\n  private _startY: number;\n  private _startLeft: number;\n  private _startTop: number;\n  private _cursorGrab: IDisposable | null = null;\n\n  constructor(protected _options: PopupOptions) {\n    super();\n\n    if (_options.stopClickPropagationOnMove) {\n      // weasel.js registers a 'click' listener that closes any open popups that\n      // are outside the click target. We capture the click event here, stopping\n      // propagation in a few scenarios where closing popups is undesirable.\n      window.addEventListener(\"click\", (ev) => {\n        if (this._isFinishingMove) {\n          ev.stopPropagation();\n          this._isFinishingMove = false;\n          return;\n        }\n\n        if (this._popupMinimizeButtonElement?.contains(ev.target as Node)) {\n          ev.stopPropagation();\n          this._minimizeOrMaximize();\n          return;\n        }\n      }, { capture: true });\n    }\n\n    this._handleMouseDown = this._handleMouseDown.bind(this);\n    this._handleMouseMove = this._handleMouseMove.bind(this);\n    this._handleMouseUp = this._handleMouseUp.bind(this);\n    this._handleTouchStart = this._handleTouchStart.bind(this);\n    this._handleTouchMove = this._handleTouchMove.bind(this);\n    this._handleTouchEnd = this._handleTouchEnd.bind(this);\n    this._handleWindowResize = this._handleWindowResize.bind(this);\n\n    this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup()));\n\n    this.onDispose(() => {\n      this._disposePopup();\n      this._cursorGrab?.dispose();\n    });\n\n    this._addPopupToDom();\n  }\n\n  public showPopup() {\n    this._isHidden.set(false);\n    this._repositionPopup();\n  }\n\n  protected _closePopup() {\n    if (!this._closable) { return; }\n\n    if (this._closeBehavior === \"close\") {\n      this._disposePopup();\n    } else {\n      this._hidePopup();\n    }\n  }\n\n  protected _buildTitle(): DomContents {\n    return this._options.title?.() ?? null;\n  }\n\n  protected _buildContent(): DomContents {\n    return this._options.content?.() ?? null;\n  }\n\n  protected _buildArgs(): any {\n    return this._options.args ?? [];\n  }\n\n  private _addPopupToDom() {\n    if (this._popupElement) { return; }\n\n    this._popupElement = this._buildPopup();\n    document.body.appendChild(this._popupElement);\n  }\n\n  private _disposePopup() {\n    if (!this._popupElement) { return; }\n\n    document.body.removeChild(this._popupElement);\n    dom.domDispose(this._popupElement);\n    this._popupElement = null;\n  }\n\n  private _hidePopup() {\n    this._isHidden.set(true);\n  }\n\n  private _getDefaultPosition(): PopupPosition {\n    const top = Math.max(\n      document.body.offsetHeight - this._height.get(),\n      getPopupTopBottomGapPx(),\n    );\n    const left = Math.max(\n      document.body.offsetWidth - this._width.get(),\n      POPUP_GAP_PX,\n    );\n    return {\n      top,\n      left,\n    };\n  }\n\n  private _handleMouseDown(ev: MouseEvent) {\n    if (ev.button !== 0) { return; } // Only handle left-click.\n    this._startX = ev.clientX;\n    this._startY = ev.clientY;\n    this._setStartPosition();\n    document.addEventListener(\"mousemove\", this._handleMouseMove);\n    document.addEventListener(\"mouseup\", this._handleMouseUp);\n    this._forceCursor();\n  }\n\n  private _handleTouchStart(ev: TouchEvent) {\n    this._startX = ev.touches[0].clientX;\n    this._startY = ev.touches[0].clientY;\n    this._setStartPosition();\n    document.addEventListener(\"touchmove\", this._handleTouchMove);\n    document.addEventListener(\"touchend\", this._handleTouchEnd);\n    this._forceCursor();\n  }\n\n  private _setStartPosition() {\n    this._startTop = this._popupElement!.offsetTop;\n    this._startLeft = this._popupElement!.offsetLeft;\n  }\n\n  private _handleTouchMove({ touches }: TouchEvent) {\n    this._handleMouseMove(touches[0]);\n  }\n\n  private _handleMouseMove({ clientX, clientY }: MouseEvent | Touch) {\n    // Last change in position (from last move).\n    const deltaX = clientX - this._startX;\n    const deltaY = clientY - this._startY;\n\n    // Available space where we can put the popup (anchored at top left corner).\n    const viewPort = {\n      right: document.body.offsetWidth,\n      bottom: document.body.offsetHeight,\n      top: getPopupTopBottomGapPx(),\n      left: 0,\n    };\n\n    // Allow some extra space, where we can still move the popup outside the viewport.\n    viewPort.right += this._popupElement!.offsetWidth - (POPUP_HEADER_HEIGHT_PX * 4);\n    viewPort.left -= this._popupElement!.offsetWidth - (POPUP_HEADER_HEIGHT_PX * 4);\n    viewPort.bottom += this._popupElement!.offsetHeight - POPUP_HEADER_HEIGHT_PX;\n\n    let newLeft = this._startLeft + deltaX;\n    let newTop = this._startTop + deltaY;\n    const newRight = (val?: number) => {\n      if (val !== undefined) { newLeft = val - this._popupElement!.offsetWidth; }\n      return newLeft + this._popupElement!.offsetWidth;\n    };\n    const newBottom = (val?: number) => {\n      if (val !== undefined) { newTop = val - this._popupElement!.offsetHeight; }\n      return newTop + this._popupElement!.offsetHeight;\n    };\n\n    // Calculate new position in the padding area.\n    if (newLeft < viewPort.left) { newLeft = viewPort.left; }\n    if (newRight() > viewPort.right) { newRight(viewPort.right); }\n    if (newTop  < viewPort.top) { newTop = viewPort.top; }\n    if (newBottom() > viewPort.bottom) { newBottom(viewPort.bottom); }\n\n    this._popupElement!.style.left = `${newLeft}px`;\n    this._popupElement!.style.top = `${newTop}px`;\n  }\n\n  private _handleMouseUp() {\n    this._isFinishingMove = true;\n    document.removeEventListener(\"mousemove\", this._handleMouseMove);\n    document.removeEventListener(\"mouseup\", this._handleMouseUp);\n    document.body.removeEventListener(\"mouseleave\", this._handleMouseUp);\n    this._handleMoveEnd();\n  }\n\n  private _handleTouchEnd() {\n    document.removeEventListener(\"touchmove\", this._handleTouchMove);\n    document.removeEventListener(\"touchend\", this._handleTouchEnd);\n    document.body.removeEventListener(\"touchcancel\", this._handleTouchEnd);\n    this._handleMoveEnd();\n  }\n\n  private _handleMoveEnd() {\n    this._cursorGrab?.dispose();\n    this._updatePosition();\n    this._options.onMoveEnd?.(this._position.get()!);\n  }\n\n  private _updatePosition() {\n    this._position.set({\n      left: this._popupElement!.offsetLeft,\n      top: this._popupElement!.offsetTop,\n    });\n  }\n\n  private _updateSize() {\n    this._width.set(this._popupElement!.offsetWidth);\n    this._height.set(this._popupElement!.offsetHeight);\n  }\n\n  private _handleWindowResize() {\n    this._repositionPopup();\n  }\n\n  private _repositionPopup() {\n    if (this._isHidden.get()) { return; }\n\n    const newWidth = clamp(\n      this._width.get(),\n      this._minWidth,\n      document.body.offsetWidth - (2 * POPUP_GAP_PX),\n    );\n    const newHeight = clamp(\n      this._height.get(),\n      this._minHeight,\n      document.body.offsetHeight - (2 * getPopupTopBottomGapPx()),\n    );\n    this._popupElement!.style.width = `${newWidth}px`;\n    this._popupElement!.style.height = `${newHeight}px`;\n\n    const topGapPx = getPopupTopBottomGapPx();\n    let { left: newLeft, top: newTop } = this._position.get() ?? this._getDefaultPosition();\n    if (newLeft - POPUP_GAP_PX < 0) { newLeft = POPUP_GAP_PX; }\n    if (newTop - topGapPx < 0) { newTop = topGapPx; }\n    if (newLeft + POPUP_GAP_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) {\n      newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_GAP_PX;\n    }\n    if (newTop + topGapPx > document.body.offsetHeight - this._popupElement!.offsetHeight) {\n      newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topGapPx;\n    }\n    this._popupElement!.style.left = `${newLeft}px`;\n    this._popupElement!.style.top = `${newTop}px`;\n  }\n\n  private _minimizeOrMaximize() {\n    if (!this._minimizable) { return; }\n\n    this._isMinimized.set(!this._isMinimized.get());\n    this._repositionPopup();\n  }\n\n  private _buildPopup() {\n    const popup = cssPopupWrap(\n      { tabIndex: \"-1\" },\n      dom.style(\"min-height\", use => use(this._isMinimized) ? \"unset\" : `${this._minHeight}px`),\n      cssPopup(\n        cssPopupHeader(\n          cssBottomHandle(this._testId(\"popup-move-handle\")),\n          dom.domComputed(this._isMinimized, (isMinimized) => {\n            return [\n              // Copy buttons on the left side of the header, to automatically\n              // center the title.\n              cssPopupButtons(\n                cssPopupHeaderButton(\n                  icon(\"Maximize\"),\n                  dom.show(this._minimizable),\n                ),\n                cssPopupHeaderButton(\n                  icon(\"CrossBig\"),\n                  dom.show(this._closable),\n                ),\n                dom.style(\"visibility\", \"hidden\"),\n              ),\n              cssPopupTitle(\n                cssPopupTitleText(this._buildTitle()),\n                this._testId(\"popup-title\"),\n              ),\n              cssPopupButtons(\n                this._popupMinimizeButtonElement = cssPopupHeaderButton(\n                  isMinimized ? icon(\"Maximize\") : icon(\"Minimize\"),\n                  hoverTooltip(isMinimized ? t(\"Maximize\") : t(\"Minimize\"), {\n                    key: FLOATING_POPUP_TOOLTIP_KEY,\n                  }),\n                  dom.on(\"click\", () => this._minimizeOrMaximize()),\n                  dom.show(this._minimizable),\n                  this._testId(\"popup-minimize-maximize\"),\n                ),\n                cssPopupHeaderButton(\n                  icon(this._options.closeButtonIcon ?? \"CrossBig\"),\n                  this._options.closeButtonHover && hoverTooltip(this._options.closeButtonHover(), {\n                    key: FLOATING_POPUP_TOOLTIP_KEY,\n                  }),\n                  dom.on(\"click\", () => {\n                    this._options.onClose?.();\n                    this._closePopup();\n                  }),\n                  dom.show(this._closable),\n                  this._testId(\"popup-close\"),\n                ),\n                // Disable dragging when a button in the header is clicked.\n                dom.on(\"mousedown\", ev => ev.stopPropagation()),\n                dom.on(\"touchstart\", ev => ev.stopPropagation()),\n              ),\n            ];\n          }),\n          dom.on(\"mousedown\", this._handleMouseDown),\n          dom.on(\"touchstart\", this._handleTouchStart),\n          dom.on(\"dblclick\", () => this._minimizeOrMaximize()),\n          this._testId(\"popup-header\"),\n        ),\n        cssPopupContent(\n          this._buildContent(),\n          cssPopupContent.cls(\"-minimized\", this._isMinimized),\n        ),\n      ),\n      this._resizable.bind(this),\n      () => { window.addEventListener(\"resize\", this._handleWindowResize); },\n      dom.onDispose(() => {\n        document.removeEventListener(\"mousemove\", this._handleMouseMove);\n        document.removeEventListener(\"mouseup\", this._handleMouseUp);\n        document.removeEventListener(\"touchmove\", this._handleTouchMove);\n        document.removeEventListener(\"touchend\", this._handleTouchEnd);\n        window.removeEventListener(\"resize\", this._handleWindowResize);\n      }),\n      cssPopupWrap.cls(\"-minimized\", this._isMinimized),\n      cssPopupWrap.cls(\"-hidden\", this._isHidden),\n      this._testId(\"popup\"),\n      this._buildArgs(),\n    );\n\n    return popup;\n  }\n\n  private _resizable() {\n    return (elem: HTMLElement) =>\n      subscribeElem(elem, this._isMinimized, (minimized) => {\n        if (minimized) {\n          ($(elem)).resizable({\n            disabled: true,\n          });\n        } else {\n          ($(elem)).resizable({\n            disabled: false,\n            handles: \"all\",\n            minWidth: this._minWidth,\n            minHeight: this._minHeight,\n            maxWidth: this._maxWidth,\n            maxHeight: this._maxHeight,\n            resize: this._handleResize.bind(this),\n            stop: this._handleResizeStop.bind(this),\n          });\n        }\n      });\n  }\n\n  private _handleResize(\n    _event: Event,\n    { position, originalPosition, size, originalSize }: JQueryUI,\n  ) {\n    // Constrain resizing to the portion of the viewport that the popup is\n    // allowed to be positioned.\n    //\n    // While jQuery can optionally take a container to constrain resizing to,\n    // it's a bit incompatible with our current model of positioning; if a\n    // popup is partially off-screen and a container is set, jQuery will\n    // reposition the popup on resize start, which isn't what we want. Instead,\n    // we manually clamp the position and dimensions of the popup to match\n    // the constraints we've settled on for re-positioning.\n    if (position.top !== originalPosition.top) {\n      position.top = clamp(\n        position.top,\n        getPopupTopBottomGapPx(),\n        document.body.offsetHeight - POPUP_HEADER_HEIGHT_PX,\n      );\n      size.height = originalPosition.top + originalSize.height - position.top;\n    }\n    if (position.left !== originalPosition.left) {\n      position.left = clamp(\n        position.left,\n        POPUP_GAP_PX,\n        document.body.offsetWidth - (POPUP_HEADER_HEIGHT_PX * 4),\n      );\n      size.width = originalPosition.left + originalSize.width - position.left;\n    }\n    if (\n      position.top === originalPosition.top &&\n      size.height !== originalSize.height\n    ) {\n      size.height = clamp(\n        size.height,\n        this._minHeight,\n        document.body.offsetHeight - position.top - POPUP_GAP_PX,\n      );\n    }\n    if (\n      position.left === originalPosition.left &&\n      size.width !== originalSize.width\n    ) {\n      size.width = clamp(\n        size.width,\n        Math.max(this._minWidth, position.left < 0 ? -position.left + (POPUP_HEADER_HEIGHT_PX * 4) : 0),\n        document.body.offsetWidth - position.left - POPUP_GAP_PX,\n      );\n    }\n  }\n\n  private _handleResizeStop() {\n    this._updatePosition();\n    this._updateSize();\n    this._options.onResizeEnd?.({\n      ...this._position.get()!,\n      width: this._width.get(),\n      height: this._height.get(),\n    });\n  }\n\n  private _forceCursor() {\n    this._cursorGrab?.dispose();\n    this._cursorGrab = documentCursor(\"grabbing\");\n  }\n\n  private _testId(name: string) {\n    return this._options.testId?.(name) ?? defaultTestId(name);\n  }\n}\n\nfunction getPopupTopBottomGapPx(): number {\n  // On mobile, we need additional margin to avoid blocking the top and bottom bars.\n  return POPUP_GAP_PX + (isNarrowScreen() ? 50 : 0);\n}\n\nconst cssPopupWrap = styled(\"div.floating-popup\", `\n  position: fixed;\n  outline: 2px solid ${theme.accentBorder};\n  border-radius: 5px;\n  z-index: ${vars.floatingPopupZIndex};\n  background-color: ${theme.popupBg};\n  box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};\n\n  &-minimized {\n    max-width: 225px;\n    height: unset !important;\n    min-height: unset;\n  }\n\n  &-hidden {\n    display: none;\n  }\n\n  & > .ui-resizable-e,\n  & > .ui-resizable-w {\n    cursor: ew-resize;\n  }\n\n  & > .ui-resizable-n,\n  & > .ui-resizable-s {\n    cursor: ns-resize;\n  }\n\n  & > .ui-resizable-ne,\n  & > .ui-resizable-sw {\n    cursor: nesw-resize;\n  }\n\n  & > .ui-resizable-nw,\n  & > .ui-resizable-se {\n    cursor: nwse-resize;\n  }\n\n  & > .ui-resizable-se {\n    background-image: none;\n    width: 9px;\n    height: 9px;\n    right: -5px;\n    bottom: -5px;\n  }\n`);\n\nconst cssPopup = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n  height: 100%;\n  border-radius: 5px;\n`);\n\nconst cssPopupHeader = styled(\"div\", `\n  color: ${theme.tutorialsPopupHeaderFg};\n  --icon-color: ${theme.tutorialsPopupHeaderFg};\n  background-color: ${theme.accentBorder};\n  align-items: center;\n  flex-shrink: 0;\n  cursor: grab;\n  padding-left: 4px;\n  padding-right: 4px;\n  height: ${POPUP_HEADER_HEIGHT_PX}px;\n  user-select: none;\n  display: flex;\n  justify-content: space-between;\n  position: relative;\n  isolation: isolate;\n  &:active {\n    cursor: grabbing;\n  }\n`);\n\nconst cssPopupButtons = styled(\"div\", `\n  display: flex;\n  column-gap: 8px;\n  align-items: center;\n`);\n\nconst cssPopupTitle = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  font-weight: 600;\n  overflow: hidden;\n`);\n\nconst cssPopupTitleText = styled(\"div\", `\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n`);\n\nexport const cssPopupBody = styled(\"div\", `\n  flex-grow: 1;\n  padding: 24px;\n  overflow: auto;\n`);\n\nconst cssPopupHeaderButton = styled(\"div\", `\n  padding: 4px;\n  border-radius: 4px;\n  cursor: pointer;\n  z-index: 1000;\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssTopHandle = styled(\"div\", `\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 1px;\n  height: 1px;\n  pointer-events: none;\n  user-select: none;\n  visibility: hidden;\n`);\n\nconst cssBottomHandle = styled(cssTopHandle, `\n  top: unset;\n  bottom: 0;\n  left: 8px;\n`);\n\nconst cssPopupContent = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex-grow: 1;\n  overflow: hidden;\n\n  &-minimized {\n    display: none;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/FormAPI.ts",
    "content": "import { BaseAPI, IOptions } from \"app/common/BaseAPI\";\nimport { CellValue, ColValues } from \"app/common/DocActions\";\nimport { addCurrentOrgToPath } from \"app/common/urlUtils\";\n\n/**\n * Form and associated field metadata from a Grist view section.\n *\n * Includes the layout of the form, metadata such as the form title, and\n * a map of data for each field in the form. All of this is used to build a\n * submittable version of the form (see `FormRenderer.ts`, which handles the\n * actual building of forms).\n */\nexport interface Form {\n  formFieldsById: Record<number, FormField>;\n  formLayoutSpec: string;\n  formTitle: string;\n  formTableId: string;\n}\n\n/**\n * Metadata for a field in a form.\n *\n * Form fields are directly related to Grist fields; the former is based on data\n * from the latter, with additional metadata specific to forms, like whether a\n * form field is required. All of this is used to build a field in a submittable\n * version of the form (see `FormRenderer.ts`, which handles the actual building\n * of forms).\n */\nexport interface FormField {\n  /** The field label. Defaults to the Grist column label or id. */\n  question: string;\n  /** The field description. */\n  description: string;\n  /** The Grist column id of the field. */\n  colId: string;\n  /** The Grist column type of the field (e.g. \"Text\"). */\n  type: string;\n  /** Additional field options. */\n  options: FormFieldOptions;\n  /** Populated with data from a referenced table. Only set if `type` is a Reference type. */\n  refValues: [number, CellValue][] | null;\n}\n\nexport interface FormFieldOptions {\n  /** Choices for a Choice or Choice List field. */\n  choices?: string[];\n  /** Text or Any field format. Defaults to `\"singleline\"`. */\n  formTextFormat?: FormTextFormat;\n  /** Number of lines/rows for the `\"multiline\"` option of `formTextFormat`. Defaults to `3`. */\n  formTextLineCount?: number;\n  /** Maximum length for the text response. */\n  formTextMaximumLength?: number;\n  /** Numeric or Int field format. Defaults to `\"text\"`. */\n  formNumberFormat?: FormNumberFormat;\n  /** Toggle field format. Defaults to `\"switch\"`. */\n  formToggleFormat?: FormToggleFormat;\n  /** Choice or Reference field format. Defaults to `\"select\"`. */\n  formSelectFormat?: FormSelectFormat;\n  /**\n   * Field options alignment.\n   *\n   * Only applicable to Choice List and Reference List fields, and Choice and Reference fields\n   * when `formSelectFormat` is `\"radio\"`.\n   *\n   * Defaults to `\"vertical\"`.\n   */\n  formOptionsAlignment?: FormOptionsAlignment;\n  /**\n   * Field options sort order.\n   *\n   * Only applicable to Choice, Choice List, Reference, and Reference List fields.\n   *\n   * Defaults to `\"default\"`.\n   */\n  formOptionsSortOrder?: FormOptionsSortOrder;\n  /**\n   * Maximum number of options to display for Choice List and Reference List fields.\n   *\n   * Defaults to `30`.\n   */\n  formOptionsLimit?: number;\n  /** True if the field is required. Defaults to `false`. */\n  formRequired?: boolean;\n  /** True if the field is marked as hidden, to hide from users. Defaults to false. */\n  formIsHidden?: boolean;\n  /** If set, this field will allow values to be set via URL. */\n  formAcceptFromUrl?: boolean;\n}\n\nexport type FormTextFormat = \"singleline\" | \"multiline\";\n\nexport type FormNumberFormat = \"text\" | \"spinner\";\n\nexport type FormToggleFormat = \"switch\" | \"checkbox\";\n\nexport type FormSelectFormat = \"select\" | \"radio\";\n\nexport type FormOptionsAlignment = \"vertical\" | \"horizontal\";\n\nexport type FormOptionsSortOrder = \"default\" | \"ascending\" | \"descending\";\n\nexport const FORM_OPTIONS_DEFAULT_LIMIT = 30;\n\nexport function getFormOptionsLimit(options: FormFieldOptions): number {\n  return options.formOptionsLimit || FORM_OPTIONS_DEFAULT_LIMIT;\n}\n\nexport interface FormAPI {\n  getForm(options: GetFormOptions): Promise<Form>;\n  createRecord(options: CreateRecordOptions): Promise<void>;\n  createAttachments(options: CreateAttachmentOptions): Promise<number[]>;\n}\n\ninterface FormTargetWithDocId {\n  docId: string;\n}\n\ninterface FormTargetWithShareKey {\n  shareKey: string;\n}\n\ntype FormTarget = FormTargetWithDocId | FormTargetWithShareKey;\n\ninterface GetFormCommonOptions {\n  vsId: number;\n}\n\ntype GetFormOptions = GetFormCommonOptions & FormTarget;\n\ninterface CreateRecordCommonOptions {\n  tableId: string;\n  colValues: ColValues;\n}\n\ntype CreateRecordOptions = CreateRecordCommonOptions & FormTarget;\n\ninterface CreateAttachmentCommonOptions {\n  upload: File[];\n}\n\ntype CreateAttachmentOptions = CreateAttachmentCommonOptions & FormTarget;\n\nexport class FormAPIImpl extends BaseAPI implements FormAPI {\n  constructor(private _homeUrl: string, options: IOptions = {}) {\n    super(options);\n  }\n\n  public async getForm(options: GetFormOptions): Promise<Form> {\n    const { vsId } = options;\n    return this.requestJson(this._docOrShareUrl(`/forms/${vsId}`, options), {\n      method: \"GET\",\n    });\n  }\n\n  public async createRecord(options: CreateRecordOptions): Promise<void> {\n    const { tableId, colValues } = options;\n    return this.requestJson(\n      this._docOrShareUrl(`/tables/${tableId}/records`, options),\n      {\n        method: \"POST\",\n        body: JSON.stringify({ records: [{ fields: colValues }] }),\n      },\n    );\n  }\n\n  public async createAttachments(options: CreateAttachmentOptions): Promise<number[]> {\n    const upload = options.upload.filter(f => f.size > 0);\n    if (upload.length === 0) {\n      return [];\n    }\n\n    const formData = new FormData();\n    for (const file of upload) {\n      formData.append(\"upload\", file);\n    }\n\n    return this.requestJson(this._docOrShareUrl(\"/attachments\", options), {\n      method: \"POST\",\n      headers: { ...this.defaultHeadersWithoutContentType() },\n      body: formData,\n    });\n  }\n\n  private get _baseUrl(): string {\n    return addCurrentOrgToPath(this._homeUrl);\n  }\n\n  private _docOrShareUrl(path: string, target: FormTarget): string {\n    const base =\n      \"docId\" in target ?\n        `${this._baseUrl}/api/docs/${target.docId}` :\n        `${this._baseUrl}/api/s/${target.shareKey}`;\n    const url = new URL(`${base}${path}`);\n    if (\"shareKey\" in target) {\n      url.searchParams.set(\"utm_source\", \"grist-forms\");\n    }\n    return url.href;\n  }\n}\n"
  },
  {
    "path": "app/client/ui/FormContainer.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { colors, mediaSmall } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { commonUrls } from \"app/common/gristUrls\";\n\nimport { DomContents, DomElementArg, styled } from \"grainjs\";\n\nconst t = makeT(\"FormContainer\");\n\nexport function buildFormMessagePage(buildBody: () => DomContents, ...args: DomElementArg[]) {\n  return cssFormMessagePage(\n    cssFormMessage(\n      cssFormMessageBody(\n        buildBody(),\n      ),\n      cssFormMessageFooter(\n        buildFormFooter(),\n      ),\n    ),\n    ...args,\n  );\n}\n\nexport function buildFormFooter() {\n  return [\n    cssPoweredByGrist(\n      cssPoweredByGristLink(\n        {\n          \"href\": commonUrls.forms,\n          \"target\": \"_blank\",\n          \"aria-label\": t(\"Powered by Grist\"),\n        },\n        t(\"Powered by\"),\n        cssGristLogo(),\n      ),\n    ),\n    cssBuildForm(\n      cssBuildFormLink(\n        { href: commonUrls.forms, target: \"_blank\" },\n        t(\"Build your own form\"),\n        icon(\"Expand\"),\n      ),\n    ),\n  ];\n}\n\nexport const cssFormMessageImageContainer = styled(\"div\", `\n  margin-top: 28px;\n  display: flex;\n  justify-content: center;\n`);\n\nexport const cssFormMessageImage = styled(\"img\", `\n  height: 100%;\n  width: 100%;\n`);\n\nexport const cssFormMessageText = styled(\"p\", `\n  color: ${colors.dark};\n  text-align: center;\n  font-weight: 600;\n  font-size: 16px;\n  line-height: 24px;\n  margin-top: 32px;\n  margin-bottom: 24px;\n`);\n\nconst cssFormMessagePage = styled(\"div\", `\n  padding: 16px;\n`);\n\nconst cssFormMessage = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  background-color: white;\n  border: 1px solid ${colors.darkGrey};\n  border-radius: 3px;\n  max-width: 600px;\n  margin: 0px auto;\n`);\n\nconst cssFormMessageBody = styled(\"main\", `\n  width: 100%;\n  padding: 20px 48px 20px 48px;\n\n  @media ${mediaSmall} {\n    & {\n      padding: 20px;\n    }\n  }\n`);\n\nconst cssFormMessageFooter = styled(\"footer\", `\n  border-top: 1px solid ${colors.darkGrey};\n  padding: 8px 16px;\n  width: 100%;\n`);\n\nconst cssPoweredByGrist = styled(\"div\", `\n  color: ${colors.darkText};\n  font-size: 13px;\n  font-style: normal;\n  font-weight: 600;\n  line-height: 16px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0px 10px;\n`);\n\nconst cssPoweredByGristLink = styled(\"a\", `\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  color: ${colors.darkText};\n  text-decoration: none;\n`);\n\nconst cssGristLogo = styled(\"div\", `\n  width: 58px;\n  height: 20.416px;\n  flex-shrink: 0;\n  background: url(img/logo-grist.png);\n  background-position: 0 0;\n  background-size: contain;\n  background-color: transparent;\n  background-repeat: no-repeat;\n  margin-top: 3px;\n`);\n\nconst cssBuildForm = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  margin-top: 8px;\n`);\n\nconst cssBuildFormLink = styled(\"a\", `\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 11px;\n  line-height: 16px;\n  text-decoration-line: underline;\n  color: ${colors.darkGreen};\n  --icon-color: ${colors.darkGreen};\n`);\n"
  },
  {
    "path": "app/client/ui/FormErrorPage.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport {\n  buildFormMessagePage,\n  cssFormMessageImage,\n  cssFormMessageImageContainer,\n  cssFormMessageText,\n} from \"app/client/ui/FormContainer\";\nimport { getPageTitleSuffix } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Disposable, makeTestId, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-form-\");\n\nconst t = makeT(\"FormErrorPage\");\n\nexport class FormErrorPage extends Disposable {\n  constructor(private _message: string) {\n    super();\n    document.title = `${t(\"Error\")}${getPageTitleSuffix(getGristConfig())}`;\n  }\n\n  public buildDom() {\n    return buildFormMessagePage(() => [\n      cssFormErrorMessageImageContainer(\n        cssFormErrorMessageImage({ src: \"img/form-error.svg\" }),\n      ),\n      cssFormMessageText(this._message, testId(\"error-page-text\")),\n    ], testId(\"error-page\"));\n  }\n}\n\nconst cssFormErrorMessageImageContainer = styled(cssFormMessageImageContainer, `\n  height: 281px;\n`);\n\nconst cssFormErrorMessageImage = styled(cssFormMessageImage, `\n  max-height: 281px;\n  max-width: 250px;\n`);\n"
  },
  {
    "path": "app/client/ui/FormPage.ts",
    "content": "import { FormRenderer } from \"app/client/components/FormRenderer\";\nimport { handleSubmit, TypedFormData } from \"app/client/lib/formUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { sanitizeHttpUrl } from \"app/client/lib/sanitizeUrl\";\nimport { FormModel, FormModelImpl } from \"app/client/models/FormModel\";\nimport { buildFormFooter } from \"app/client/ui/FormContainer\";\nimport { FormErrorPage } from \"app/client/ui/FormErrorPage\";\nimport { FormSuccessPage } from \"app/client/ui/FormSuccessPage\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { colors } from \"app/client/ui2018/cssVars\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { getPageTitleSuffix } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Disposable, dom, makeTestId, Observable, styled, subscribe } from \"grainjs\";\n\nconst t = makeT(\"FormPage\");\n\nconst testId = makeTestId(\"test-form-\");\n\nexport class FormPage extends Disposable {\n  private readonly _model: FormModel = new FormModelImpl();\n  private readonly _error = Observable.create<string | null>(this, null);\n\n  constructor() {\n    super();\n    this._model.fetchForm().catch(reportError);\n\n    this.autoDispose(subscribe(this._model.form, (_use, form) => {\n      if (!form) { return; }\n\n      document.title = `${form.formTitle}${getPageTitleSuffix(getGristConfig())}`;\n    }));\n  }\n\n  public buildDom() {\n    return cssPageContainer(\n      dom.domComputed((use) => {\n        const error = use(this._model.error);\n        if (error) { return dom.create(FormErrorPage, error); }\n\n        const submitted = use(this._model.submitted);\n        if (submitted) { return dom.create(FormSuccessPage, this._model); }\n\n        return this._buildFormPageDom();\n      }),\n    );\n  }\n\n  private _buildFormPageDom() {\n    return dom.domComputed((use) => {\n      const form = use(this._model.form);\n      const rootLayoutNode = use(this._model.formLayout);\n      if (!form || !rootLayoutNode) { return null; }\n\n      const formRenderer = FormRenderer.new(rootLayoutNode, {\n        fields: form.formFieldsById,\n        rootLayoutNode,\n        disabled: this._model.submitting,\n        error: this._error,\n      });\n\n      const formFraming = getGristConfig().formFraming;\n\n      return dom(\"div\",\n        cssFormBorder(\n          testId(\"framing\"),\n          cssFormBorder.cls(`-${formFraming}`),\n          formFraming !== \"border\" ? null :\n            cssFormBorderHelp(withInfoTooltip(\n              \"Grist Form\",\n              \"formFraming\",\n              { iconDomArgs: [cssFormBorderHelpButton.cls(\"\")] },\n            )),\n          cssForm(\n            cssFormBody(\n              cssFormContent(\n                dom.autoDispose(formRenderer),\n                formRenderer.render(),\n                handleSubmit({\n                  pending: this._model.submitting,\n                  onSubmit: (_formData, formElement) => this._handleFormSubmit(formElement),\n                  onSuccess: () => this._handleFormSubmitSuccess(),\n                  onError: e => this._handleFormError(e),\n                }),\n              ),\n            ),\n          ),\n        ),\n        cssFormFooter(\n          buildFormFooter(),\n        ),\n        testId(\"page\"),\n      );\n    });\n  }\n\n  private async _handleFormSubmit(formElement: HTMLFormElement) {\n    await this._model.submitForm(new TypedFormData(formElement));\n  }\n\n  private async _handleFormSubmitSuccess() {\n    const formLayout = this._model.formLayout.get();\n    if (!formLayout) { throw new Error(\"formLayout is not defined\"); }\n\n    const { successURL } = formLayout;\n    if (successURL) {\n      const url = sanitizeHttpUrl(successURL);\n      if (url) {\n        window.location.href = url;\n      }\n    }\n\n    this._model.submitted.set(true);\n  }\n\n  private _handleFormError(e: unknown) {\n    this._error.set(t(\"There was an error submitting your form. Please try again.\"));\n    if (!(e instanceof ApiError) || e.status >= 500) {\n      // If it doesn't look like a user error (i.e. a 4XX HTTP response), report it.\n      reportError(e as Error | string);\n    }\n  }\n}\n\nconst cssPageContainer = styled(\"div\", `\n  height: 100%;\n  width: 100%;\n  padding: 20px;\n  overflow: auto;\n`);\n\nconst cssFormBorder = styled(\"div\", `\n  margin: 0px auto;\n  position: relative;\n  &-border {\n    border: 2px solid ${colors.lightGreen};\n    border-radius: 12px;\n    border-top-width: 20px;\n    padding: 12px;\n    max-width: 624px;\n    margin-bottom: 24px;\n  }\n  &-minimal {\n    max-width: 600px;\n  }\n`);\n\nconst cssFormBorderHelp = styled(\"div\", `\n  color: white;\n  position: absolute;\n  top: -18px;\n  right: 18px;\n  font-size: 12px;\n`);\n\nconst cssFormBorderHelpButton = styled(\"div\", `\n  border-color: white;\n  color: white;\n  height: 1rem;\n  width: 1rem;\n  font-size: 0.9rem;\n  &:hover {\n    background-color: rgba(0, 0, 0, 0.1);\n    border-color: white;\n    color: white;\n  }\n`);\n\nconst cssForm = styled(\"div\", `\n  display: flex;\n  position: relative;\n  overflow: hidden;\n  flex-direction: column;\n  align-items: center;\n  background-color: white;\n  border-radius: 3px;\n`);\n\nconst cssFormBody = styled(\"main\", `\n  width: 100%;\n`);\n\n// TODO: break up and move to `FormRendererCss.ts`.\nconst cssFormContent = styled(\"form\", `\n  color: ${colors.dark};\n  font-size: 15px;\n  line-height: 1.42857143;\n\n  & h1,\n  & h2,\n  & h3,\n  & h4,\n  & h5,\n  & h6 {\n    margin: 8px 0px 12px 0px;\n    font-weight: normal;\n  }\n  & h1 {\n    font-size: 24px;\n  }\n  & h2 {\n    font-size: 22px;\n  }\n  & h3 {\n    font-size: 16px;\n  }\n  & h4 {\n    font-size: 13px;\n  }\n  & h5 {\n    font-size: 11px;\n  }\n  & h6 {\n    font-size: 10px;\n  }\n  & p {\n    margin: 0 0 10px 0;\n  }\n  & strong {\n    font-weight: 600;\n  }\n  & hr {\n    border: 0px;\n    border-top: 1px solid ${colors.darkGrey};\n    margin: 4px 0px;\n  }\n`);\n\nconst cssFormFooter = styled(\"footer\", `\n  padding: 8px 16px;\n  width: 100%;\n`);\n"
  },
  {
    "path": "app/client/ui/FormSuccessPage.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { FormModel } from \"app/client/models/FormModel\";\nimport {\n  buildFormMessagePage,\n  cssFormMessageImage,\n  cssFormMessageImageContainer,\n  cssFormMessageText,\n} from \"app/client/ui/FormContainer\";\nimport { vars } from \"app/client/ui2018/cssVars\";\nimport { getPageTitleSuffix } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Computed, Disposable, dom, makeTestId, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-form-\");\n\nconst t = makeT(\"FormSuccessPage\");\n\nexport class FormSuccessPage extends Disposable {\n  private _successText = Computed.create(this, this._model.formLayout, (_use, layout) => {\n    if (!layout) { return null; }\n\n    return layout.successText || t(\"Thank you! Your response has been recorded.\");\n  });\n\n  private _showNewResponseButton = Computed.create(this, this._model.formLayout, (_use, layout) => {\n    return Boolean(layout?.anotherResponse);\n  });\n\n  constructor(private _model: FormModel) {\n    super();\n    document.title = `${t(\"Form Submitted\")}${getPageTitleSuffix(getGristConfig())}`;\n  }\n\n  public buildDom() {\n    return buildFormMessagePage(() => [\n      cssFormSuccessMessageImageContainer(\n        cssFormSuccessMessageHeading(\n          cssFormSuccessMessageImage({ src: \"img/form-success.svg\", alt: t(\"Form Submitted\") }),\n        ),\n      ),\n      cssFormMessageText(dom.text(this._successText), testId(\"success-page-text\")),\n      dom.maybe(this._showNewResponseButton, () =>\n        cssFormButtons(\n          cssFormNewResponseButton(\n            t(\"Submit new response\"),\n            dom.on(\"click\", () => this._handleClickNewResponseButton()),\n          ),\n        ),\n      ),\n    ], testId(\"success-page\"));\n  }\n\n  private async _handleClickNewResponseButton() {\n    await this._model.fetchForm();\n  }\n}\n\nconst cssFormSuccessMessageImageContainer = styled(cssFormMessageImageContainer, `\n  height: 215px;\n`);\n\nconst cssFormSuccessMessageHeading = styled(\"h1\", `\n  line-height: 1;\n  margin: 0;\n`);\n\nconst cssFormSuccessMessageImage = styled(cssFormMessageImage, `\n  max-height: 215px;\n  max-width: 250px;\n`);\n\nconst cssFormButtons = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  margin-top: 24px;\n`);\n\nconst cssFormNewResponseButton = styled(\"button\", `\n  position: relative;\n  outline: none;\n  border-style: none;\n  line-height: normal;\n  user-select: none;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  padding: 12px 24px;\n  min-height: 40px;\n  background: ${vars.primaryBg};\n  border-radius: 3px;\n  color: ${vars.primaryFg};\n\n  &:hover {\n    cursor: pointer;\n    background: ${vars.primaryBgHover};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/GetGristComProvider.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { inlineMarkdown } from \"app/client/lib/markdown\";\nimport { getHomeUrl, reportError } from \"app/client/models/AppModel\";\nimport { cssTextArea } from \"app/client/ui/AdminPanelCss\";\nimport { bigBasicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssLink, cssNestedLinks } from \"app/client/ui2018/links\";\nimport { cssModalWidth, modal } from \"app/client/ui2018/modals\";\nimport { AsyncFlow, CancelledError, FlowRunner } from \"app/common/AsyncFlow\";\nimport { ConfigAPI } from \"app/common/ConfigAPI\";\nimport { commonUrls } from \"app/common/gristUrls\";\nimport { GETGRIST_COM_PROVIDER_KEY } from \"app/common/loginProviders\";\nimport { components } from \"app/common/ThemePrefs\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Disposable, dom, makeTestId, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"AdminPanel\");\n\nconst testId = makeTestId(\"test-admin-auth-\");\n\n/**\n * Modal for configuring \"Sign in with getgrist.com\" login system.\n */\nexport class GetGristComProviderInfoModal extends Disposable {\n  private _onConfigure: (() => void) | undefined;\n  private readonly _configKey: Observable<string> = Observable.create(this, \"\");\n  private readonly _working: Observable<boolean> = Observable.create(this, false);\n  private readonly _error: Observable<boolean> = Observable.create(this, false);\n  private readonly _configAPI: ConfigAPI = new ConfigAPI(getHomeUrl());\n\n  constructor() {\n    super();\n    this.autoDispose(this._configKey.addListener(() => {\n      const timeout = setTimeout(() => {\n        if (this.isDisposed()) {\n          return;\n        }\n        this._error.set(false);\n      });\n      this.onDispose(() => clearTimeout(timeout));\n    }));\n  }\n\n  public show(\n    onConfigure?: () => void,\n  ): void {\n    this._onConfigure = onConfigure;\n    modal((ctl, owner) => {\n      this.onDispose(() => ctl.close());\n      const registerUrlObs: Observable<string> = Observable.create<string>(owner, \"\");\n      const runner = FlowRunner.create(owner, async (flow: AsyncFlow) => {\n        const providerConfig = await this._configAPI.getAuthProviderConfig(GETGRIST_COM_PROVIDER_KEY);\n        flow.checkIfCancelled();\n        const registerUrl = new URL(commonUrls.signInWithGristRegister);\n        const spHost = providerConfig.GRIST_GETGRISTCOM_SP_HOST || getGristConfig().homeUrl;\n        if (spHost) {\n          const callBackUrl = new URL(spHost).origin;\n          registerUrl.searchParams.set(\"uri\", callBackUrl);\n        }\n        registerUrlObs.set(registerUrl.href);\n      });\n      runner.resultPromise.catch((err) => {\n        if (err instanceof CancelledError) {\n          return;\n        }\n        reportError(err);\n      });\n      return [\n        cssModalWidth(\"fixed-wide\"),\n        cssModalHeader(\n          dom(\"span\", t(\"Configure Sign in with getgrist.com\")),\n          testId(\"modal-header\"),\n        ),\n        cssModalDescription(\n          dom(\"p\",\n            cssNestedLinks(inlineMarkdown(t(`**Sign in with getgrist.com** \\\nallows users on your Grist server to sign in using their account on \\\ngetgrist.com, which is the cloud version of Grist managed by Grist Labs. \\\nUser registration and authentication are fully handled by Grist Labs, \\\nwhile your documents and data stay on your server. [Learn more.](${commonUrls.signInWithGristHelp})`)))),\n        ),\n        cssModalInstructions(\n          dom(\"h3\", t(\"Instructions\")),\n          dom(\"p\", t(\n            \"To set up {{provider}}, you need to register your Grist server on \\\ngetgrist.com and paste the configuration key you receive below.\", {\n              provider: dom(\"b\", t(\"Sign in with getgrist.com\")),\n            })),\n        ),\n        cssLink(\n          dom.attr(\"href\", registerUrlObs),\n          dom.on(\"click\", (ev, el) => {\n            // Make sure we have a URL to go to.\n            if (!registerUrlObs.get()) {\n              ev.preventDefault();\n            }\n          }),\n          { target: \"_blank\" },\n          { style: \"margin-bottom: 16px; display: inline-block;\" },\n          t(\"Register your Grist server\"),\n        ),\n        cssLargerTextArea(\n          this._configKey,\n          { onInput: true },\n          { placeholder: t(\"Paste configuration key here\") },\n          cssLargerTextArea.cls(\"-error\", use => use(this._error)),\n          testId(\"config-key-textarea\"),\n        ),\n        cssModalButtons(\n          bigBasicButton(\n            t(\"Cancel\"),\n            dom.on(\"click\", () => this.dispose()),\n            testId(\"modal-cancel\"),\n          ),\n          bigPrimaryButton(\n            t(\"Configure\"),\n            dom.prop(\"disabled\", use => use(this._working) || !use(this._configKey)),\n            dom.on(\"click\", () => this._handleConfigure()),\n            testId(\"modal-configure\"),\n          ),\n        ),\n      ];\n    });\n  }\n\n  private async _handleConfigure() {\n    if (!this._configKey.get()) {\n      this._error.set(true);\n      return;\n    }\n    this._working.set(true);\n    try {\n      await this._configAPI.configureProvider(\n        GETGRIST_COM_PROVIDER_KEY,\n        { GRIST_GETGRISTCOM_SECRET: this._configKey.get() },\n      );\n      this._onConfigure?.();\n      this.dispose();\n    } catch (e) {\n      if (this.isDisposed()) {\n        return;\n      }\n      reportError(e as Error);\n      this._error.set(true);\n    } finally {\n      if (!this.isDisposed()) {\n        this._working.set(false);\n      }\n    }\n  }\n}\n\nconst cssLargerTextArea = styled(cssTextArea, `\n  font-size: ${vars.mediumFontSize};\n  height: calc(1.5em * 4);\n  transition: border-color 0.2s ease;\n  &-error {\n    border-color: ${components.errorText};\n  }\n`);\n\nconst cssModalHeader = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-bottom: 24px;\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: 500;\n  color: ${theme.text};\n`);\n\nconst cssModalDescription = styled(\"div\", `\n  margin-bottom: 24px;\n  color: ${theme.text};\n  font-size: ${vars.mediumFontSize};\n  line-height: 1.5;\n\n  & > p {\n    margin: 0 0 12px 0;\n  }\n\n  & > p:last-child {\n    margin-bottom: 0;\n  }\n`);\n\nconst cssModalInstructions = styled(\"div\", `\n  margin-bottom: 16px;\n\n  & > h3 {\n    margin: 0 0 12px 0;\n    font-size: ${vars.largeFontSize};\n    font-weight: 600;\n    color: ${theme.text};\n  }\n\n  & > p {\n    margin: 0;\n    color: ${theme.text};\n    font-size: ${vars.mediumFontSize};\n    line-height: 1.5;\n  }\n`);\n\nconst cssModalButtons = styled(\"div\", `\n  display: flex;\n  gap: 8px;\n  justify-content: flex-end;\n  margin-top: 24px;\n`);\n"
  },
  {
    "path": "app/client/ui/GridOptions.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { KoSaveableObservable, setSaveValue } from \"app/client/models/modelUtil\";\nimport { cssGroupLabel, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { testId } from \"app/client/ui2018/cssVars\";\n\nimport { Computed, Disposable, dom, IDisposableOwner } from \"grainjs\";\n\nconst t = makeT(\"GridOptions\");\n\n/**\n * Builds the grid options.\n */\nexport class GridOptions extends Disposable {\n  constructor(private _section: ViewSectionRec) {\n    super();\n  }\n\n  public buildDom() {\n    const section = this._section;\n    return dom(\"div\",\n      { \"role\": \"group\", \"aria-labelledby\": \"grid-options-label\" },\n      cssGroupLabel(t(\"Grid Options\"), { id: \"grid-options-label\" }),\n      dom(\"div\", [\n        cssRow(\n          labeledSquareCheckbox(\n            setSaveValueFromKo(this, section.optionsObj.prop(\"verticalGridlines\")),\n            t(\"Vertical gridlines\"),\n          ),\n          testId(\"v-grid-button\"),\n        ),\n\n        cssRow(\n          labeledSquareCheckbox(\n            setSaveValueFromKo(this, section.optionsObj.prop(\"horizontalGridlines\")),\n            t(\"Horizontal gridlines\"),\n          ),\n          testId(\"h-grid-button\"),\n        ),\n\n        cssRow(\n          labeledSquareCheckbox(\n            setSaveValueFromKo(this, section.optionsObj.prop(\"zebraStripes\")),\n            t(\"Zebra stripes\"),\n          ),\n          testId(\"zebra-stripe-button\"),\n        ),\n\n        testId(\"grid-options\"),\n      ]),\n    );\n  }\n}\n\n// Returns a grainjs observable that reflects the value of obs a knockout saveable observable. The\n// returned observable will set and save obs to the given value when written. If the obs.save() call\n// fails, then it gets reset to its previous value.\nfunction setSaveValueFromKo(owner: IDisposableOwner, obs: KoSaveableObservable<boolean | undefined>) {\n  const ret = Computed.create(null, use => use(obs) ?? false);\n  ret.onWrite(async (val) => {\n    await setSaveValue(obs, val);\n  });\n  return ret;\n}\n"
  },
  {
    "path": "app/client/ui/GridViewMenus.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport * as commands from \"app/client/components/commands\";\nimport GridView from \"app/client/components/GridView\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ColumnRec } from \"app/client/models/entities/ColumnRec\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { buildDateHelpersMenuItems } from \"app/client/ui/GridViewMenusDateHelpers\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { isNarrowScreen, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport {\n  menuCssClass,\n  menuDivider,\n  menuIcon,\n  menuItem,\n  menuItemCmd,\n  menuItemSubmenu,\n  menuItemTrimmed,\n  menuSubHeader,\n  menuSubHeaderMenu,\n  menuText,\n  searchableMenu,\n  SearchableMenuItem,\n} from \"app/client/ui2018/menus\";\nimport * as UserType from \"app/client/widgets/UserType\";\nimport { isFullReferencingType, isListType, RecalcWhen } from \"app/common/gristTypes\";\nimport { Sort } from \"app/common/SortSpec\";\n\nimport { dom, DomElementArg, styled } from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\nimport * as weasel from \"popweasel\";\n\nconst t = makeT(\"GridViewMenus\");\n\nexport function buildAddColumnMenu(gridView: GridView, index?: number) {\n  const isSummaryTable = Boolean(gridView.viewSection.table().summarySourceTable());\n  return [\n    buildAddNewColumMenuSection(gridView, index),\n    buildHiddenColumnsMenuItems(gridView, index),\n    isSummaryTable ? null : [\n      buildLookupSection(gridView, index),\n      buildShortcutsMenuItems(gridView, index),\n    ],\n  ];\n}\n\nexport function getColumnTypes(gristDoc: GristDoc, tableId: string, pure = false) {\n  const typeNames = [\n    \"Text\",\n    \"Numeric\",\n    \"Int\",\n    \"Bool\",\n    \"Date\",\n    `DateTime:${gristDoc.docModel.docInfoRow.timezone()}`,\n    \"Choice\",\n    \"ChoiceList\",\n    `Ref:${tableId}`,\n    `RefList:${tableId}`,\n    \"Attachments\"];\n  return typeNames.map(type => ({ type, obj: UserType.typeDefs[type.split(\":\")[0]] }))\n    .map((ct): {\n      displayName: string,\n      colType: string,\n      testIdName: string,\n      icon: IconName | undefined,\n      openCreatorPanel: boolean } => ({\n      displayName: t(ct.obj.label),\n      colType: ct.type,\n      testIdName: ct.obj.label.toLowerCase().replace(\" \", \"-\"),\n      icon: ct.obj.icon,\n      openCreatorPanel: isFullReferencingType(ct.type),\n    })).map((ct) => {\n      if (!pure) { return ct; } else {\n        return {\n          ...ct,\n          colType: ct.colType.split(\":\")[0],\n        };\n      }\n    });\n}\n\nfunction buildAddNewColumMenuSection(gridView: GridView, index?: number): DomElementArg[] {\n  function buildEmptyNewColumMenuItem() {\n    return menuItem(\n      async () => {\n        await gridView.insertColumn(null, { index });\n      },\n      t(\"Add column\"),\n      testId(\"new-columns-menu-add-new\"),\n    );\n  }\n\n  function BuildNewColumnWithTypeSubmenu() {\n    const columnTypes = getColumnTypes(gridView.gristDoc, gridView.tableModel.tableMetaRow.tableId());\n\n    return menuItemSubmenu(\n      ctl => [\n        ...columnTypes.map(colType =>\n          menuItem(\n            async () => {\n              await gridView.insertColumn(null, { index, colInfo: { type: colType.colType }, onPopupClose: () => {\n                if (!colType.openCreatorPanel || isNarrowScreen()) { return; }\n                commands.allCommands.fieldTabOpen.run();\n                commands.allCommands.rightPanelOpen.run();\n                commands.allCommands.showPopup.run({ popup: \"referenceColumnsConfig\" });\n              } });\n            },\n            menuIcon(colType.icon!),\n            colType.displayName === \"Reference\" ?\n              gridView.gristDoc.behavioralPromptsManager.attachPopup(\"referenceColumns\", {\n                popupOptions: {\n                  attach: `.${menuCssClass}`,\n                  placement: \"left-start\",\n                },\n              }) : null,\n            colType.displayName,\n            testId(`new-columns-menu-add-${colType.testIdName}`)),\n        ),\n        testId(\"new-columns-menu-add-with-type-submenu\"),\n      ],\n      { allowNothingSelected: false },\n      t(\"Add column with type\"),\n      testId(\"new-columns-menu-add-with-type\"),\n    );\n  }\n\n  function buildNewFunctionColumnMenuItem() {\n    return menuItem(\n      async () => {\n        await gridView.insertColumn(null, { index, skipPopup: true, colInfo: { isFormula: true } });\n        gridView.activateEditorAtCursor();\n        commands.allCommands.makeFormula.run();\n        commands.allCommands.detachEditor.run();\n      },\n      withInfoTooltip(\n        t(\"Add formula column\"),\n        \"formulaColumn\",\n        { variant: \"hover\" },\n      ),\n      testId(\"new-columns-menu-add-formula\"),\n    );\n  }\n\n  return [\n    buildEmptyNewColumMenuItem(),\n    BuildNewColumnWithTypeSubmenu(),\n    buildNewFunctionColumnMenuItem(),\n  ];\n}\n\nfunction buildHiddenColumnsMenuItems(gridView: GridView, index?: number) {\n  const { viewSection } = gridView;\n  const hiddenColumns = viewSection.hiddenColumns();\n  if (hiddenColumns.length === 0) { return null; }\n\n  if (hiddenColumns.length <= 5) {\n    return [\n      menuDivider(),\n      menuSubHeader(t(\"Hidden Columns\"), testId(\"new-columns-menu-hidden-columns-header\")),\n      hiddenColumns.map((col: ColumnRec) =>\n        menuItem(\n          async () => {\n            await gridView.showColumn(col.id(), index);\n          },\n          col.label(),\n          testId(\"new-columns-menu-hidden-column-inlined\"),\n        ),\n      ),\n    ];\n  } else {\n    return [\n      menuDivider(),\n      menuSubHeaderMenu(\n        () => {\n          return searchableMenu(\n            hiddenColumns.map(col => ({\n              cleanText: col.label().trim().toLowerCase(),\n              builder: () => menuItemTrimmed(\n                () => gridView.showColumn(col.id(), index),\n                col.label(),\n                testId(\"new-columns-menu-hidden-column-collapsed\"),\n              ),\n            })),\n            { searchInputPlaceholder: t(\"Search columns\") },\n          );\n        },\n        { allowNothingSelected: true },\n        t(\"Hidden Columns\"),\n        testId(\"new-columns-menu-hidden-columns-menu\"),\n      ),\n    ];\n  }\n}\n\nfunction buildShortcutsMenuItems(gridView: GridView, index?: number) {\n  return [\n    menuDivider(),\n    menuSubHeader(t(\"Shortcuts\"), testId(\"new-columns-menu-shortcuts\")),\n    buildTimestampMenuItems(gridView, index),\n    buildAuthorshipMenuItems(gridView, index),\n    buildDetectDuplicatesMenuItems(gridView, index),\n    buildDateHelpersMenuItems(gridView, index),\n    buildUUIDMenuItem(gridView, index),\n  ];\n}\n\nfunction buildTimestampMenuItems(gridView: GridView, index?: number) {\n  return menuItemSubmenu(() => [\n    menuItem(\n      async () => {\n        await gridView.insertColumn(t(\"Created at\"), {\n          colInfo: {\n            label: t(\"Created at\"),\n            type: `DateTime:${gridView.gristDoc.docModel.docInfoRow.timezone()}`,\n            isFormula: false,\n            formula: \"NOW()\",\n            recalcWhen: RecalcWhen.DEFAULT,\n            recalcDeps: null,\n          },\n          index,\n          skipPopup: true,\n        });\n      },\n      t(\"Apply to new records\"),\n      testId(\"new-columns-menu-shortcuts-timestamp-new\"),\n    ),\n    menuItem(\n      async () => {\n        await gridView.insertColumn(t(\"Last updated at\"), {\n          colInfo: {\n            label: t(\"Last updated at\"),\n            type: `DateTime:${gridView.gristDoc.docModel.docInfoRow.timezone()}`,\n            isFormula: false,\n            formula: \"NOW()\",\n            recalcWhen: RecalcWhen.MANUAL_UPDATES,\n            recalcDeps: null,\n          },\n          index,\n          skipPopup: true,\n        });\n      },\n      t(\"Apply on record changes\"),\n      testId(\"new-columns-menu-shortcuts-timestamp-change\"),\n    ),\n  ], {},\n  t(\"Timestamp\"),\n  testId(\"new-columns-menu-shortcuts-timestamp\"),\n  );\n}\n\nfunction buildAuthorshipMenuItems(gridView: GridView, index?: number) {\n  return menuItemSubmenu(() => [\n    menuItem(\n      async () => {\n        await gridView.insertColumn(t(\"Created by\"), {\n          colInfo: {\n            label: t(\"Created by\"),\n            type: \"Text\",\n            isFormula: false,\n            formula: \"user.Name\",\n            recalcWhen: RecalcWhen.DEFAULT,\n            recalcDeps: null,\n          },\n          index,\n          skipPopup: true,\n        });\n      },\n      t(\"Apply to new records\"),\n      testId(\"new-columns-menu-shortcuts-author-new\"),\n    ),\n    menuItem(\n      async () => {\n        await gridView.insertColumn(t(\"Last updated by\"), {\n          colInfo: {\n            label: t(\"Last updated by\"),\n            type: \"Text\",\n            isFormula: false,\n            formula: \"user.Name\",\n            recalcWhen: RecalcWhen.MANUAL_UPDATES,\n            recalcDeps: null,\n          },\n          index,\n          skipPopup: true,\n        });\n      },\n      t(\"Apply on record changes\"),\n      testId(\"new-columns-menu-shortcuts-author-change\"),\n    ),\n  ], {}, t(\"Authorship\"), testId(\"new-columns-menu-shortcuts-author\"));\n}\n\nfunction buildDetectDuplicatesMenuItems(gridView: GridView, index?: number) {\n  const { viewSection } = gridView;\n  return menuItemSubmenu(\n    () => searchableMenu(\n      viewSection.columns().map((col) => {\n        function buildFormula() {\n          if (isListType(col.type())) {\n            return `any([len(${col.table().tableId()}.lookupRecords(${col.colId()}` +\n              `=CONTAINS(x))) > 1 for x in $${col.colId()}])`;\n          } else {\n            return `$${col.colId()} != \"\" and $${col.colId()} is not None and ` +\n              `len(${col.table().tableId()}.lookupRecords(` +\n              `${col.colId()}=$${col.colId()})) > 1`;\n          }\n        }\n\n        return {\n          cleanText: col.label().trim().toLowerCase(),\n          label: col.label(),\n          action: async () => {\n            await gridView.gristDoc.docData.bundleActions(t(\"Adding duplicates column\"), async () => {\n              const newColInfo = await gridView.insertColumn(\n                t(\"Duplicate in {{- label}}\", { label: col.label() }),\n                {\n                  colInfo: {\n                    label: t(\"Duplicate in {{- label}}\", { label: col.label() }),\n                    type: \"Bool\",\n                    isFormula: true,\n                    formula: buildFormula(),\n                    recalcWhen: RecalcWhen.DEFAULT,\n                    recalcDeps: null,\n                    widgetOptions: JSON.stringify({\n                      rulesOptions: [{\n                        fillColor: \"#ffc23d\",\n                        textColor: \"#262633\",\n                      }],\n                    }),\n                  },\n                  index,\n                  skipPopup: true,\n                },\n              );\n\n              // TODO: do the steps below as part of the AddColumn action.\n              const newField = viewSection.viewFields().all()\n                .find(field => field.colId() === newColInfo.colId);\n              if (!newField) {\n                throw new Error(`Unable to find field for column ${newColInfo.colId}`);\n              }\n\n              await newField.addEmptyRule();\n              const newRule = newField.rulesCols()[0];\n              if (!newRule) {\n                throw new Error(`Unable to find conditional rule for field ${newField.label()}`);\n              }\n\n              await newRule.formula.setAndSave(`$${newColInfo.colId}`);\n            }, { nestInActiveBundle: true });\n          },\n        };\n      }),\n      { searchInputPlaceholder: t(\"Search columns\") },\n    ),\n    { allowNothingSelected: true },\n    t(\"Detect duplicates in...\"),\n    testId(\"new-columns-menu-shortcuts-duplicates\"),\n  );\n}\n\nfunction buildUUIDMenuItem(gridView: GridView, index?: number) {\n  return menuItem(\n    async () => {\n      await gridView.gristDoc.docData.bundleActions(t(\"Adding UUID column\"), async () => {\n        // First create a formula column so that UUIDs are computed for existing cells.\n        const { colRef } = await gridView.insertColumn(t(\"UUID\"), {\n          colInfo: {\n            label: t(\"UUID\"),\n            type: \"Text\",\n            isFormula: true,\n            formula: \"UUID()\",\n            recalcWhen: RecalcWhen.DEFAULT,\n            recalcDeps: null,\n          },\n          index,\n          skipPopup: true,\n        });\n\n        // Then convert it to a trigger formula, so that UUIDs aren't re-computed.\n        //\n        // TODO: remove this step and do it as part of the AddColumn action.\n        await gridView.gristDoc.docModel.convertToTrigger(colRef, \"UUID()\");\n      }, { nestInActiveBundle: true });\n    },\n    withInfoTooltip(\n      t(\"UUID\"),\n      \"uuid\",\n      { variant: \"hover\" },\n    ),\n    testId(\"new-columns-menu-shortcuts-uuid\"),\n  );\n}\n\nfunction menuLabelWithBadge(label: string, toast: string) {\n  return cssListLabel(\n    cssListCol(label),\n    cssListFun(toast));\n}\n\nfunction buildLookupSection(gridView: GridView, index?: number) {\n  function suggestAggregation(col: ColumnRec) {\n    if (col.pureType() === \"Int\" || col.pureType() === \"Numeric\") {\n      return [\n        \"sum\", \"average\", \"min\", \"max\",\n      ];\n    } else if (col.pureType() === \"Bool\") {\n      return [\n        \"count\", \"percent\",\n      ];\n    } else if (col.pureType() === \"Date\" || col.pureType() === \"DateTime\") {\n      return [\n        \"list\", \"min\", \"max\",\n      ];\n    } else {\n      return [\n        \"list\",\n      ];\n    }\n  }\n  // colTypeOverload allow to change created column type if default is wrong.\n  function buildColumnInfo(\n    fun: string,\n    referenceToSource: string,\n    col: ColumnRec) {\n    function formula() {\n      switch (fun) {\n        case \"list\": return `${referenceToSource}.${col.colId()}`;\n        case \"average\": return `ref = ${referenceToSource}\\n` +\n          `AVERAGE(ref.${col.colId()}) if ref else None`;\n        case \"min\": return `ref = ${referenceToSource}\\n` +\n          `MIN(ref.${col.colId()}) if ref else None`;\n        case \"max\": return `ref = ${referenceToSource}\\n` +\n          `MAX(ref.${col.colId()}) if ref else None`;\n        case \"count\":\n        case \"sum\": return `SUM(${referenceToSource}.${col.colId()})`;\n        case \"percent\":\n          return  `ref = ${referenceToSource}\\n` +\n            `AVERAGE(map(int, ref.${col.colId()})) if ref else None`;\n        default: return `${referenceToSource}`;\n      }\n    }\n\n    function type() {\n      switch (fun) {\n        case \"average\": return \"Numeric\";\n        case \"min\": return col.type();\n        case \"max\": return col.type();\n        case \"count\": return \"Int\";\n        case \"sum\": return col.type();\n        case \"percent\": return \"Numeric\";\n        case \"list\": return \"Any\";\n        default: return \"Any\";\n      }\n    }\n\n    function widgetOptions() {\n      switch (fun) {\n        case \"percent\": return { numMode: \"percent\" };\n        default: return {};\n      }\n    }\n\n    return {\n      formula: formula(),\n      type: type(),\n      widgetOptions: JSON.stringify(widgetOptions()),\n      isFormula: true,\n    };\n  }\n\n  function buildLookupsMenuItems() {\n    // Function that builds a menu for one of our Ref columns, we will show all columns\n    // from the referenced table and offer to create a formula column with aggregation in case\n    // our column is RefList.\n    function buildRefColMenu(ref: ColumnRec, col: ColumnRec): SearchableMenuItem {\n      // Helper for searching for this entry.\n      const cleanText = col.label().trim().toLowerCase();\n\n      // Next the label we will show.\n      let label: string | HTMLElement;\n      // For Ref column we will just show the column name.\n      if (ref.pureType() === \"Ref\") {\n        label = col.label();\n      } else {\n        // For RefList column we will show the column name and the aggregation function which is the first\n        // on of suggested action (and a default action).\n        label = menuLabelWithBadge(col.label(), suggestAggregation(col)[0]);\n      }\n\n      return {\n        cleanText,\n        builder: buildItem,\n      };\n\n      function buildItem() {\n        if (ref.pureType() === \"Ref\") {\n          // Just insert a plain menu item that will insert a formula column with lookup.\n          return menuItemTrimmed(\n            () => insertPlainLookup(), col.label(),\n            testId(`new-columns-menu-lookup-column`),\n            testId(`new-columns-menu-lookup-column-${col.colId()}`),\n          );\n        } else {\n          // Depending on the number of aggregation functions we will either create a plain menu item\n          // or submenu with all the functions.\n          const functions = suggestAggregation(col);\n          if (functions.length === 1) {\n            const action = () => insertAggLookup(functions[0]);\n            return menuItem(action, label,\n              testId(`new-columns-menu-lookup-column`),\n              testId(`new-columns-menu-lookup-column-${col.colId()}`),\n            );\n          } else {\n            return menuItemSubmenu(\n              () => functions.map(fun => menuItem(\n                () => insertAggLookup(fun), fun,\n                testId(`new-columns-menu-lookup-submenu-function`),\n                testId(`new-columns-menu-lookup-submenu-function-${fun}`),\n              )),\n              {\n                action: () => insertAggLookup(suggestAggregation(col)[0]),\n              },\n              label,\n              testId(`new-columns-menu-lookup-submenu`),\n              testId(`new-columns-menu-lookup-submenu-${col.colId()}`),\n            );\n          }\n        }\n      }\n\n      function insertAggLookup(fun: string) {\n        return gridView.insertColumn(`${ref.label()}_${col.label()}`, {\n          colInfo: {\n            label: `${ref.label()}_${col.label()}`,\n            ...buildColumnInfo(\n              fun,\n              `$${ref.colId()}`,\n              col,\n            ),\n            recalcDeps: null,\n          },\n          index,\n          skipPopup: true,\n        });\n      }\n\n      function insertPlainLookup() {\n        return gridView.insertColumn(`${ref.label()}_${col.label()}`, {\n          colInfo: {\n            label: `${ref.label()}_${col.label()}`,\n            isFormula: true,\n            formula: `$${ref.colId()}.${col.colId()}`,\n            recalcDeps: null,\n            type: col.type(),\n            widgetOptions: col.cleanWidgetOptionsJson(),\n          },\n          index,\n          skipPopup: true,\n        });\n      }\n    }\n\n    const { viewSection } = gridView;\n    const columns = viewSection.columns();\n    const onlyRefOrRefList = (c: ColumnRec) => c.pureType() === \"Ref\" || c.pureType() === \"RefList\";\n    const references = columns.filter(onlyRefOrRefList);\n\n    return references.map(ref => menuItemSubmenu(\n      () => searchableMenu(\n        ref.refTable()?.visibleColumns().map(buildRefColMenu.bind(null, ref)) ?? [],\n        {\n          searchInputPlaceholder: t(\"Search columns\"),\n        },\n      ),\n      { allowNothingSelected: true },\n      `${ref.refTable()?.tableNameDef()} [${ref.label()}]`,\n      testId(`new-columns-menu-lookup-${ref.colId()}`),\n      testId(`new-columns-menu-lookup`),\n    ));\n  }\n\n  interface RefTable {\n    tableId: string,\n    tableName: string,\n    columns: ColumnRec[],\n    referenceFields: ColumnRec[]\n  }\n\n  function buildReverseLookupsMenuItems() {\n    const getReferencesToThisTable = (): RefTable[] => {\n      const { viewSection } = gridView;\n      const otherTables = gridView.gristDoc.docModel.allTables.all().filter(tab =>\n        tab.summarySourceTable() === 0 && tab.tableId.peek() !== viewSection.tableId());\n      return otherTables.map((tab) => {\n        return {\n          tableId: tab.tableId(),\n          tableName: tab.tableNameDef(),\n          columns: tab.visibleColumns(),\n          referenceFields:\n            tab.visibleColumns.peek().filter(c => (c.pureType() === \"Ref\" || c.pureType() == \"RefList\") &&\n              c.refTable()?.tableId() === viewSection.tableId()),\n        };\n      })\n        .filter(tab => tab.referenceFields.length > 0);\n    };\n\n    const insertColumn = async (tab: RefTable, col: ColumnRec, refCol: ColumnRec, aggregate: string) => {\n      const formula =\n        `${tab.tableId}.lookupRecords(${refCol.colId()}=${refCol.pureType() == \"RefList\" ? \"CONTAINS($id)\" : \"$id\"})`;\n      await gridView.insertColumn(`${tab.tableId}_${col.label()}`, {\n        colInfo: {\n          label: `${tab.tableId}_${col.label()}`,\n          ...buildColumnInfo(aggregate,\n            formula,\n            col),\n        },\n        index,\n        skipPopup: true,\n      });\n    };\n\n    const tablesWithAnyRefColumn = getReferencesToThisTable();\n    return tablesWithAnyRefColumn.map((tab: RefTable) => tab.referenceFields.map((refCol) => {\n      const buildSubmenuForRevLookupMenuItem = (col: ColumnRec): SearchableMenuItem => {\n        const aggregationList = suggestAggregation(col);\n        const firstAggregation = aggregationList[0];\n        if (!firstAggregation) {\n          throw new Error(`No aggregation suggested for column ${col.label()}`);\n        }\n        return {\n          cleanText: col.label().trim().toLowerCase(),\n          builder: () => {\n            const content = menuLabelWithBadge(col.label(), firstAggregation);\n            // In case we have only one suggested column we will just insert it, and there is no,\n            // need for submenu.\n            if (aggregationList.length === 1) {\n              const action = () => insertColumn(tab, col, refCol, firstAggregation);\n              return menuItem(action, content, testId(\"new-columns-menu-revlookup-column\"));\n            } else {\n              // We have some other suggested columns, we will build submenu for them.\n              const submenu = () => {\n                const items = aggregationList.map((fun) => {\n                  const action = () => insertColumn(tab, col, refCol, fun);\n                  return menuItem(action, fun, testId(\"new-columns-menu-revlookup-column-function\"));\n                });\n                return items;\n              };\n              const options = {};\n              return menuItemSubmenu(\n                submenu,\n                options,\n                content,\n                testId(\"new-columns-menu-revlookup-submenu\"),\n              );\n            }\n          },\n        };\n      };\n      const label = `${tab.tableName} [← ${refCol.label()}]`;\n      const options = { allowNothingSelected: true };\n      const submenu = () => {\n        const subItems = tab.columns.map(buildSubmenuForRevLookupMenuItem);\n        return searchableMenu(subItems, { searchInputPlaceholder: t(\"Search columns\") });\n      };\n      return menuItemSubmenu(submenu, options, label, testId(\"new-columns-menu-revlookup\"));\n    }));\n  }\n\n  const lookupMenu  = buildLookupsMenuItems();\n  const reverseLookupMenu = buildReverseLookupsMenuItems();\n\n  const menuContent = (lookupMenu.length === 0 && reverseLookupMenu.length === 0) ?\n    [menuText(\n      t(\"No reference columns.\"),\n      testId(\"new-columns-menu-lookups-none\"),\n    )] :\n    [lookupMenu, reverseLookupMenu];\n\n  return [\n    menuDivider(),\n    menuSubHeader(\n      withInfoTooltip(\n        t(\"Lookups\"),\n        \"lookups\",\n        { variant: \"hover\" },\n      ),\n      testId(\"new-columns-menu-lookups\"),\n    ),\n    ...menuContent,\n  ];\n}\n\nexport interface IMultiColumnContextMenu {\n  // For multiple selection, true/false means the value applies to all columns, 'mixed' means it's\n  // true for some columns, but not all.\n  numColumns: number;\n  numFrozen: number;\n\n  // If the columns are read-only. Mixed for multiple columns where some are read-only.\n  disableModify: boolean | \"mixed\";\n\n  isReadonly: boolean;\n  isRaw: boolean;\n  isFiltered: boolean; // If this view shows a proper subset of all rows in the table.\n  isFormula: boolean | \"mixed\";\n  columnIndices: number[];\n  totalColumnCount: number;\n  disableFrozenMenu?: boolean;\n}\n\ninterface IColumnContextMenu extends IMultiColumnContextMenu {\n  filterOpenFunc: () => void;\n  sortSpec: Sort.SortSpec;\n  colRowId: number;\n}\n\nexport function calcFieldsCondition(\n  fields: ViewFieldRec[], condition: (f: ViewFieldRec) => boolean,\n): boolean | \"mixed\" {\n  return fields.every(condition) ? true : (fields.some(condition) ? \"mixed\" : false);\n}\n\nexport function buildColumnContextMenu(options: IColumnContextMenu) {\n  const { disableModify, filterOpenFunc, colRowId, sortSpec, isReadonly } = options;\n\n  const disableForReadonlyColumn = dom.cls(\"disabled\", Boolean(disableModify) || isReadonly);\n\n  const addToSortLabel = getAddToSortLabel(sortSpec, colRowId);\n\n  const isVirtual = typeof colRowId === \"string\";\n  const disabledForVirtual = dom.cls(\"disabled\", isVirtual);\n\n  return [\n    !isVirtual ? menuItemCmd(allCommands.fieldTabOpen, t(\"Column Options\")) : null,\n    menuItem(filterOpenFunc, t(\"Filter Data\")),\n    menuDivider({ style: \"margin-bottom: 0;\" }),\n    cssRowMenuItem(\n      customMenuItem(\n        allCommands.sortAsc.run,\n        dom(\"span\", t(\"Sort\"), { style: \"flex: 1  0 auto; margin-right: 8px;\" },\n          testId(\"sort-label\")),\n        icon(\"Sort\", dom.style(\"transform\", \"scaley(-1)\")),\n        \"A-Z\",\n        dom.style(\"flex\", \"\"),\n        cssCustomMenuItem.cls(\"-selected\", Sort.containsOnly(sortSpec, colRowId, Sort.ASC)),\n        testId(\"sort-asc\"),\n      ),\n      customMenuItem(\n        allCommands.sortDesc.run,\n        icon(\"Sort\"),\n        \"Z-A\",\n        cssCustomMenuItem.cls(\"-selected\", Sort.containsOnly(sortSpec, colRowId, Sort.DESC)),\n        testId(\"sort-dsc\"),\n      ),\n      testId(\"sort\"),\n    ),\n    addToSortLabel ? [\n      cssRowMenuItem(\n        customMenuItem(\n          allCommands.addSortAsc.run,\n          cssRowMenuLabel(addToSortLabel, testId(\"add-to-sort-label\")),\n          icon(\"Sort\", dom.style(\"transform\", \"scaley(-1)\")),\n          \"A-Z\",\n          cssCustomMenuItem.cls(\"-selected\", Sort.contains(sortSpec, colRowId, Sort.ASC)),\n          testId(\"add-to-sort-asc\"),\n        ),\n        customMenuItem(\n          allCommands.addSortDesc.run,\n          icon(\"Sort\"),\n          \"Z-A\",\n          cssCustomMenuItem.cls(\"-selected\", Sort.contains(sortSpec, colRowId, Sort.DESC)),\n          testId(\"add-to-sort-dsc\"),\n        ),\n        testId(\"add-to-sort\"),\n      ),\n    ] : null,\n    menuDivider({ style: \"margin-bottom: 0; margin-top: 0;\" }),\n    menuItem(\n      allCommands.sortFilterTabOpen.run,\n      t(\"More sort options ...\"),\n      testId(\"more-sort-options\"),\n      disabledForVirtual,\n    ),\n    menuDivider({ style: \"margin-top: 0;\" }),\n    menuItemCmd(allCommands.renameField, t(\"Rename column\"), disableForReadonlyColumn),\n    freezeMenuItemCmd(options),\n    menuDivider(),\n    buildMultiColumnMenu((options.disableFrozenMenu = true, options)),\n    testId(\"column-menu\"),\n  ];\n}\n\n/**\n * Note about available options. There is a difference between clearing values (writing empty\n * string, which makes cells blank, including Numeric cells) and converting a column to an empty\n * column (i.e. column with empty formula; in this case a Numeric column becomes all 0s today).\n *\n * We offer both options if data columns are selected. If only formulas, only the second option\n * makes sense.\n */\nexport function buildMultiColumnMenu(options: IMultiColumnContextMenu) {\n  const disableForReadonlyColumn = dom.cls(\"disabled\", Boolean(options.disableModify) || options.isReadonly);\n  const disableForReadonlyView = dom.cls(\"disabled\", options.isReadonly);\n  const num: number = options.numColumns;\n  const nameClearColumns = options.isFiltered ?\n    t(\"Reset {{count}} entire columns\", { count: num }) :\n    t(\"Reset {{count}} columns\", { count: num });\n  const nameDeleteColumns = t(\"Delete {{count}} columns\", { count: num });\n  const nameHideColumns = t(\"Hide {{count}} columns\", { count: num });\n  const frozenMenu = options.disableFrozenMenu ? null : freezeMenuItemCmd(options);\n  return [\n    frozenMenu ? [frozenMenu, menuDivider()] : null,\n    // Offered only when selection includes formula columns, and converts only those.\n    (options.isFormula ?\n      menuItemCmd(allCommands.convertFormulasToData, t(\"Convert formula to data\"),\n        disableForReadonlyColumn) : null),\n\n    // With data columns selected, offer an additional option to clear out selected cells.\n    (options.isFormula !== true ?\n      menuItemCmd(allCommands.clearValues, t(\"Clear values\"), disableForReadonlyColumn) : null),\n\n    (!options.isRaw ? menuItemCmd(allCommands.hideFields, nameHideColumns, disableForReadonlyView) : null),\n    menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),\n    menuItemCmd(allCommands.deleteFields, nameDeleteColumns, disableForReadonlyColumn),\n\n    menuDivider(),\n    menuItemCmd(allCommands.insertFieldBefore, t(\"Insert column to the left\"), disableForReadonlyView),\n    menuItemCmd(allCommands.insertFieldAfter, t(\"Insert column to the right\"), disableForReadonlyView),\n  ];\n}\n\nexport function freezeAction(options: IMultiColumnContextMenu): { text: string; numFrozen: number; } | null {\n  /**\n   * When user clicks last column - don't offer freezing\n   * When user clicks on a normal column - offer him to freeze all the columns to the\n   * left (inclusive).\n   * When user clicks on a frozen column - offer him to unfreeze all the columns to the\n   * right (inclusive)\n   * When user clicks on a set of columns then:\n   * - If the set of columns contains the last columns that are frozen - offer unfreezing only those columns\n   * - If the set of columns is right after the frozen columns or spans across - offer freezing only those columns\n   *\n   * All of the above are a single command - toggle freeze\n   */\n\n  const length = options.numColumns;\n\n  // make some assertions - number of columns selected should always be > 0\n  if (length === 0) { return null; }\n\n  const indices = options.columnIndices;\n  const firstColumnIndex = indices[0];\n  const lastColumnIndex = indices[indices.length - 1];\n  const numFrozen = options.numFrozen;\n\n  // if set has last column in it - don't offer freezing\n  if (lastColumnIndex == options.totalColumnCount - 1) {\n    return null;\n  }\n\n  const isNormalColumn = length === 1 && (firstColumnIndex + 1) > numFrozen;\n  const isFrozenColumn = length === 1 && (firstColumnIndex + 1) <= numFrozen;\n  const isSet = length > 1;\n  const isLastFrozenSet = isSet && lastColumnIndex + 1 === numFrozen;\n  const isFirstNormalSet = isSet && firstColumnIndex === numFrozen;\n  const isSpanSet = isSet && firstColumnIndex <= numFrozen && lastColumnIndex >= numFrozen;\n\n  let text = \"\";\n\n  if (!isSet) {\n    if (isNormalColumn) {\n      // text to show depends on what user selected and how far are we from\n      // last frozen column\n\n      // if user clicked the first column or a column just after frozen set\n      if (firstColumnIndex === 0 || firstColumnIndex === numFrozen) {\n        text = t(\"Freeze {{count}} columns\", { count: 1 });\n      } else {\n        // else user clicked any other column that is farther, offer to freeze\n        // proper number of column\n        const properNumber = firstColumnIndex - numFrozen + 1;\n        text = numFrozen ?\n          t(\"Freeze {{count}} more columns\", { count: properNumber }) :\n          t(\"Freeze {{count}} columns\", { count: properNumber });\n      }\n      return {\n        text,\n        numFrozen: firstColumnIndex + 1,\n      };\n    } else if (isFrozenColumn) {\n      // when user clicked last column in frozen set - offer to unfreeze this column\n      if (firstColumnIndex + 1 === numFrozen) {\n        text = t(\"Unfreeze {{count}} columns\", { count: 1 });\n      } else {\n        // else user clicked column that is not the last in a frozen set\n        // offer to unfreeze proper number of columns\n        const properNumber = numFrozen - firstColumnIndex;\n        text = properNumber === numFrozen ?\n          t(\"Unfreeze all columns\") :\n          t(\"Unfreeze {{count}} columns\", { count: properNumber });\n      }\n      return {\n        text,\n        numFrozen: indices[0],\n      };\n    } else {\n      return null;\n    }\n  } else {\n    if (isLastFrozenSet) {\n      text = t(\"Unfreeze {{count}} columns\", { count: length });\n      return {\n        text,\n        numFrozen: numFrozen - length,\n      };\n    } else if (isFirstNormalSet) {\n      text = t(\"Freeze {{count}} columns\", { count: length });\n      return {\n        text,\n        numFrozen: numFrozen + length,\n      };\n    } else if (isSpanSet) {\n      const toFreeze = lastColumnIndex + 1 - numFrozen;\n      text = t(\"Freeze {{count}} more columns\", { count: toFreeze });\n      return {\n        text,\n        numFrozen: numFrozen + toFreeze,\n      };\n    } else {\n      return null;\n    }\n  }\n}\n\nfunction freezeMenuItemCmd(options: IMultiColumnContextMenu) {\n  // calculate action available for this options\n  const toggle = freezeAction(options);\n  // if we can't offer freezing - don't create a menu at all\n  // this shouldn't happen - as current design offers some action on every column\n  if (!toggle) { return null; }\n  // create menu item if we have something to offer\n  return menuItemCmd(allCommands.toggleFreeze, toggle.text);\n}\n\n// Returns 'Add to sort' is there are columns in the sort spec but colId is not part of it. Returns\n// undefined if colId is the only column in the spec. Otherwise returns `Sorted (#N)` where #N is\n// the position (1 based) of colId in the spec.\nfunction getAddToSortLabel(sortSpec: Sort.SortSpec, colId: number): string | undefined {\n  const columnsInSpec = sortSpec.map(n => Sort.getColRef(n));\n  if (sortSpec.length !== 0 && !isEqual(columnsInSpec, [colId])) {\n    const index = columnsInSpec.indexOf(colId);\n    if (index > -1) {\n      return t(\"Sorted (#{{count}})\", { count: index + 1 });\n    } else {\n      return t(\"Add to sort\");\n    }\n  }\n}\n\nconst cssRowMenuItem = styled((...args: DomElementArg[]) => dom(\"li\", { tabindex: \"-1\" }, ...args), `\n  display: flex;\n  outline: none;\n`);\n\nconst cssRowMenuLabel = styled(\"div\", `\n  margin-right: 8px;\n  flex: 1 0 auto;\n`);\n\nconst cssCustomMenuItem = styled(\"div\", `\n  padding: 8px 8px;\n  display: flex;\n  &:not(:hover) {\n    background-color: ${theme.menuBg};\n    color: ${theme.menuItemFg};\n    --icon-color: ${theme.menuItemFg};\n  }\n  &:last-of-type {\n    padding-right: 24px;\n    flex: 0 0 auto;\n  }\n  &:first-of-type {\n    padding-left: 24px;\n    flex: 1 0 auto;\n  }\n  &-selected, &-selected:not(:hover) {\n    background-color: ${theme.menuItemSelectedBg};\n    color: ${theme.menuItemSelectedFg};\n    --icon-color: ${theme.menuItemSelectedFg};\n  }\n`);\n\nfunction customMenuItem(action: () => void, ...args: DomElementArg[]) {\n  const element: HTMLElement = cssCustomMenuItem(\n    ...args,\n    dom.on(\"click\", () => action()),\n  );\n  return element;\n}\n\nconst cssListLabel = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n  align-items: baseline;\n  flex: 1;\n`);\n\nconst cssListCol = styled(\"div\", `\n  flex: 1 0 auto;\n`);\n\nconst cssListFun = styled(\"div\", `\n  flex: 0 0 auto;\n  margin-left: 8px;\n  text-transform: lowercase;\n  padding: 1px 4px;\n  border-radius: 3px;\n  background-color: ${theme.choiceTokenBg};\n  font-size: ${vars.xsmallFontSize};\n  min-width: 28px;\n  text-align: center;\n  .${weasel.cssMenuItem.className}-sel & {\n    color: ${theme.choiceTokenFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/GridViewMenusDateHelpers.ts",
    "content": "import GridView from \"app/client/components/GridView\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ColumnRec } from \"app/client/models/entities/ColumnRec\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport {\n  cssMenuItemCmd,\n  ISubMenuOptions,\n  menuDivider,\n  menuItem,\n  menuItemSubmenu,\n  menuSubHeader,\n} from \"app/client/ui2018/menus\";\nimport { RecalcWhen } from \"app/common/gristTypes\";\nimport { removePrefix } from \"app/common/gutil\";\nimport { tokens } from \"app/common/ThemePrefs\";\n\nimport { dom, styled } from \"grainjs\";\nimport moment from \"moment-timezone\";\nimport * as weasel from \"popweasel\";\n\nconst t = makeT(\"GridViewMenus\");\n\n// Formula constants - uses $Column as placeholder for column ID\nconst FORMULAS = {\n  // Year formulas\n  YEAR: \"YEAR($Column) if $Column else None\",\n\n  // Month formulas\n  MONTH_FULL_WITH_YEAR: '$Column.strftime(\"%B %Y\") if $Column else None',\n  MONTH_SORTABLE: '$Column.strftime(\"%Y-%m\") if $Column else None',\n  MONTH_SHORT_WITH_YEAR: '$Column.strftime(\"%b %Y\") if $Column else None',\n  MONTH_NAME_ONLY: '$Column.strftime(\"%B\") if $Column else None',\n  MONTH_NUMBER_ONLY: \"MONTH($Column) if $Column else None\",\n\n  // Quarter formulas\n\n  // Q1 2024\n  QUARTER_DEFAULT: '\"Q{}\".format((MONTH($Column) - 1) // 3 + 1) + \" \" + str(YEAR($Column)) if $Column else None',\n  // 2024-Q1\n  QUARTER_SORTABLE: '\"{}-Q{}\".format(YEAR($Column), (MONTH($Column) - 1) // 3 + 1) if $Column else None',\n\n  // Week formulas\n\n  // Week 1\n  WEEK_DEFAULT: '\"Week {}\".format(WEEKNUM($Column)) if $Column else None',\n  // 2024-W01\n  WEEK_SORTABLE: '\"{}-W{:02d}\".format(YEAR($Column), WEEKNUM($Column)) if $Column else None',\n\n  // Day formulas\n  DAY_OF_MONTH: \"DAY($Column) if $Column else None\",\n  FULL_DATE: \"DATE(YEAR($Column), MONTH($Column), DAY($Column)) if $Column else None\",\n  DAY_OF_WEEK_FULL: '$Column.strftime(\"%A\") if $Column else None',\n  DAY_OF_WEEK_ABBREVIATED: '$Column.strftime(\"%a\") if $Column else None',\n  DAY_OF_WEEK_NUMERIC: \"WEEKDAY($Column, 2) if $Column else None\",\n\n  // Boundary formulas - Start of\n  START_OF_YEAR: \"DATE(YEAR($Column), 1, 1) if $Column else None\",\n  START_OF_QUARTER: \"DATE(YEAR($Column), ((MONTH($Column)-1)//3)*3 + 1, 1) if $Column else None\",\n  START_OF_MONTH: \"DATE(YEAR($Column), MONTH($Column), 1) if $Column else None\",\n  START_OF_WEEK: \"DATEADD($Column, days=-WEEKDAY($Column, 3)) if $Column else None\",\n  START_OF_DAY: \"DATE(YEAR($Column), MONTH($Column), DAY($Column)) if $Column else None\",\n  START_OF_HOUR: \"$Column.replace(minute=0, second=0, microsecond=0) if $Column else None\",\n\n  // Boundary formulas - End of\n  END_OF_YEAR: \"DATE(YEAR($Column), 12, 31) if $Column else None\",\n  END_OF_QUARTER: \"EOMONTH(DATE(YEAR($Column), ((MONTH($Column)-1)//3)*3 + 3, 1), 0) if $Column else None\",\n  END_OF_MONTH: \"EOMONTH($Column, 0) if $Column else None\",\n  END_OF_WEEK: \"DATEADD($Column, days=7-WEEKDAY($Column, 3)-1) if $Column else None\",\n  END_OF_DAY: \"DATETIME(YEAR($Column), MONTH($Column), DAY($Column), 23, 59, 59) if $Column else None\",\n  END_OF_HOUR: \"$Column.replace(minute=59, second=59, microsecond=999999) if $Column else None\",\n\n  // Time formulas\n  HOUR_24: '$Column.strftime(\"%H\") if $Column else None',\n  // lstrip to remove leading zero (-I doesn't work on windows)\n  HOUR_12: '$Column.strftime(\"%I %p\").lstrip(\"0\") if $Column else None',\n  TIME_BUCKET: \"if not $Column:\\n  return None\\n\" +\n    \"hour = HOUR($Column)\\n\" +\n    'if hour < 12:\\n  return \"Morning\"\\n' +\n    'if hour < 18:\\n  return \"Afternoon\"\\n' +\n    'return \"Evening\"',\n  MINUTE: \"MINUTE($Column) if $Column else None\",\n  AM_PM: '$Column.strftime(\"%p\") if $Column else None',\n\n  // Relative formulas\n  DAYS_UNTIL: 'DATEDIF(TODAY(), $Column, \"D\") if $Column else None',\n  DAYS_SINCE: 'DATEDIF($Column, TODAY(), \"D\") if $Column else None',\n  MONTHS_UNTIL: 'DATEDIF(TODAY(), $Column, \"M\") if $Column else None',\n  MONTHS_SINCE: 'DATEDIF($Column, TODAY(), \"M\") if $Column else None',\n  YEARS_UNTIL: 'DATEDIF(TODAY(), $Column, \"Y\") if $Column else None',\n  YEARS_SINCE: 'DATEDIF($Column, TODAY(), \"Y\") if $Column else None',\n  IS_WEEKEND: \"WEEKDAY($Column, 2) >= 6 if $Column else None\",\n} as const;\n\n// Menu configuration, later on the menu is built out of it.\nconst CONFIGURATION: Record<string, Section> = {\n  quickPicks: {\n    header: () => t(\"Quick Picks\"),\n    items: [\n      {\n        label: () => t(\"Year\"),\n        example: (date: moment.Moment) => date.year().toString(),\n        columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Year\")}`,\n        formula: FORMULAS.YEAR,\n        type: \"Int\",\n      },\n      {\n        label: () => t(\"Month\"),\n        example: (date: moment.Moment) => date.format(\"YYYY-MM\"),\n        columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Month\")}`,\n        formula: FORMULAS.MONTH_SORTABLE,\n        type: \"Text\",\n      },\n      {\n        label: () => t(\"Quarter\"),\n        example: (date: moment.Moment) => `${date.year()}-Q${Math.floor((date.month() - 1) / 3) + 1}`,\n        columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Quarter\")}`,\n        formula: FORMULAS.QUARTER_SORTABLE,\n        type: \"Text\",\n      },\n      {\n        label: () => t(\"Day of week\"),\n        example: (date: moment.Moment) => date.format(\"dddd\"),\n        columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Day of week\")}`,\n        formula: FORMULAS.DAY_OF_WEEK_FULL,\n        type: \"Text\",\n      },\n    ],\n  },\n  calendar: {\n    header: () => t(\"Calendar\"),\n    items: [\n      {\n        label: () => t(\"Year\"),\n        example: (date: moment.Moment) => date.year().toString(),\n        columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Year\")}`,\n        formula: FORMULAS.YEAR,\n        type: \"Int\",\n      } as PlainItem,\n      {\n        label: () => t(\"Quarter\"),\n        items: [\n          {\n            label: () => t(\"Default\"),\n            example: (date: moment.Moment) => `Q${Math.floor((date.month() - 1) / 3) + 1} ${date.year()}`,\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Quarter\")}`,\n            formula: FORMULAS.QUARTER_DEFAULT,\n            type: \"Text\",\n            default: true,\n          },\n          {\n            label: () => t(\"Sortable\"),\n            example: (date: moment.Moment) => `${date.year()}-Q${Math.floor((date.month() - 1) / 3) + 1}`,\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Quarter\")}`,\n            formula: FORMULAS.QUARTER_SORTABLE,\n            type: \"Text\",\n          },\n        ],\n      } as SubmenuItem,\n      {\n        label: () => t(\"Month\"),\n        items: [\n          {\n            label: () => t(\"Full name with year\"),\n            example: (date: moment.Moment) => date.format(\"MMMM YYYY\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Month\")}`,\n            formula: FORMULAS.MONTH_FULL_WITH_YEAR,\n            default: true,\n            type: \"Text\",\n          },\n          {\n            label: () => t(\"Sortable\"),\n            example: (date: moment.Moment) => date.format(\"YYYY-MM\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Month\")}`,\n            formula: FORMULAS.MONTH_SORTABLE,\n            type: \"Text\",\n          },\n          {\n            label: () => t(\"Short with year\"),\n            example: (date: moment.Moment) => date.format(\"MMM YYYY\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Month\")}`,\n            formula: FORMULAS.MONTH_SHORT_WITH_YEAR,\n            type: \"Text\",\n          },\n          {\n            label: () => t(\"Name only\"),\n            example: (date: moment.Moment) => date.format(\"MMMM\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Month\")}`,\n            formula: FORMULAS.MONTH_NAME_ONLY,\n            type: \"Text\",\n          },\n          {\n            label: () => t(\"Number only\"),\n            example: (date: moment.Moment) => date.format(\"MM\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Month\")}`,\n            formula: FORMULAS.MONTH_NUMBER_ONLY,\n            type: \"Int\",\n          },\n        ],\n      },\n      {\n        label: () => t(\"Week of year\"),\n        items: [\n          {\n            label: () => t(\"Default\"),\n            example: (date: moment.Moment) => `Week ${date.isoWeek()}`,\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Week\")}`,\n            formula: FORMULAS.WEEK_DEFAULT,\n            default: true,\n            type: \"Text\",\n          },\n          {\n            label: () => t(\"Sortable\"),\n            example: (date: moment.Moment) => `${date.format(\"YYYY\")}-W${date.format(\"WW\")}`,\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Week\")}`,\n            formula: FORMULAS.WEEK_SORTABLE,\n            type: \"Text\",\n          },\n        ],\n      },\n      {\n        label: () => t(\"Day\"),\n        items: [\n          {\n            label: () => t(\"Day of month\"),\n            example: (date: moment.Moment) => date.format(\"DD\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} Day of month`,\n            formula: FORMULAS.DAY_OF_MONTH,\n            type: \"Int\",\n          },\n          {\n            label: () => t(\"Full date\"),\n            example: (date: moment.Moment) => date.format(\"YYYY-MM-DD\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} Full date`,\n            formula: FORMULAS.FULL_DATE,\n            type: \"Date\",\n          },\n          {\n            label: () => t(\"Day of week (full)\"),\n            example: (date: moment.Moment) => date.format(\"dddd\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Day of week\")}`,\n            formula: FORMULAS.DAY_OF_WEEK_FULL,\n            type: \"Text\",\n            default: true,\n          },\n          {\n            label: () => t(\"Day of week (abbrev)\"),\n            example: (date: moment.Moment) => date.format(\"ddd\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Day of week\")}`,\n            formula: FORMULAS.DAY_OF_WEEK_ABBREVIATED,\n            type: \"Text\",\n          },\n          {\n            label: () => t(\"Day of week (numeric)\"),\n            example: (date: moment.Moment) => date.isoWeekday().toString(),\n            columnLabel: (col: ColumnRec) => `${col.label()} ${t(\"Day of week\")}`,\n            formula: FORMULAS.DAY_OF_WEEK_NUMERIC,\n            type: \"Int\",\n          },\n          {\n            label: () => t(\"Is weekend?\"),\n            example: (date: moment.Moment) => date.day() === 0 || date.day() === 6 ? \"Yes\" : \"No\",\n            columnLabel: (col: ColumnRec) => `${col.label()} Is weekend?`,\n            formula: FORMULAS.IS_WEEKEND,\n            type: \"Bool\",\n          },\n        ],\n      },\n    ],\n  },\n  intervals: {\n    header: () => t(\"Intervals\"),\n    items: [\n      {\n        label: () => t(\"Start of\"),\n        items: [\n          {\n            label: () => t(\"Year\"),\n            example: (date: moment.Moment) => date.clone().startOf(\"year\").format(\"YYYY-MM-DD\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} Start of Year`,\n            formula: FORMULAS.START_OF_YEAR,\n            type: \"Date\",\n          },\n          {\n            label: () => t(\"Quarter\"),\n            example: (date: moment.Moment) => date.clone().startOf(\"quarter\").format(\"YYYY-MM-DD\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} Start of Quarter`,\n            formula: FORMULAS.START_OF_QUARTER,\n            type: \"Date\",\n          },\n          {\n            label: () => t(\"Month\"),\n            example: (date: moment.Moment) => date.clone().startOf(\"month\").format(\"YYYY-MM-DD\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} Start of Month`,\n            formula: FORMULAS.START_OF_MONTH,\n            type: \"Date\",\n          },\n          {\n            label: () => t(\"Week\"),\n            example: (date: moment.Moment) => date.clone().startOf(\"isoWeek\").format(\"YYYY-MM-DD\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} Start of Week`,\n            formula: FORMULAS.START_OF_WEEK,\n            type: \"Date\",\n          },\n          {\n            label: () => t(\"Day\"),\n            example: (date: moment.Moment) => date.clone().startOf(\"day\").format(\"YYYY-MM-DD HH:mm:ss\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} Start of Day`,\n            formula: FORMULAS.START_OF_DAY,\n            type: \"DateTime\",\n          },\n          {\n            label: () => t(\"Hour\"),\n            example: (date: moment.Moment) => date.clone().startOf(\"hour\").format(\"YYYY-MM-DD HH:mm:ss\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} Start of Hour`,\n            formula: FORMULAS.START_OF_HOUR,\n            type: \"DateTime\",\n          },\n        ],\n      },\n      {\n        label: () => t(\"End of\"),\n        items: [\n          {\n            label: () => t(\"Year\"),\n            example: (date: moment.Moment) => date.clone().endOf(\"year\").format(\"YYYY-MM-DD\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} End of Year`,\n            formula: FORMULAS.END_OF_YEAR,\n            type: \"Date\",\n          },\n          {\n            label: () => t(\"Quarter\"),\n            example: (date: moment.Moment) => date.clone().endOf(\"quarter\").format(\"YYYY-MM-DD\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} End of Quarter`,\n            formula: FORMULAS.END_OF_QUARTER,\n            type: \"Date\",\n          },\n          {\n            label: () => t(\"Month\"),\n            example: (date: moment.Moment) => date.clone().endOf(\"month\").format(\"YYYY-MM-DD\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} End of Month`,\n            formula: FORMULAS.END_OF_MONTH,\n            type: \"Date\",\n          },\n          {\n            label: () => t(\"Week\"),\n            example: (date: moment.Moment) => date.clone().endOf(\"isoWeek\").format(\"YYYY-MM-DD\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} End of Week`,\n            formula: FORMULAS.END_OF_WEEK,\n            type: \"Date\",\n          },\n          {\n            label: () => t(\"Day\"),\n            example: (date: moment.Moment) => date.clone().endOf(\"day\").format(\"YYYY-MM-DD HH:mm:ss\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} End of Day`,\n            formula: FORMULAS.END_OF_DAY,\n            type: \"DateTime\",\n          },\n          {\n            label: () => t(\"Hour\"),\n            example: (date: moment.Moment) => date.clone().endOf(\"hour\").format(\"YYYY-MM-DD HH:mm:ss\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} End of Hour`,\n            formula: FORMULAS.END_OF_HOUR,\n            type: \"DateTime\",\n          },\n        ],\n      },\n      {\n        label: () => t(\"Relative\"),\n        items: [\n          {\n            label: () => t(\"Days since\"),\n            example: (date: moment.Moment) => moment().diff(date, \"days\").toString(),\n            columnLabel: (col: ColumnRec) => `${col.label()} Days since`,\n            formula: FORMULAS.DAYS_SINCE,\n            type: \"Int\",\n          },\n          {\n            label: () => t(\"Days until\"),\n            example: (date: moment.Moment) => moment(date).diff(moment(), \"days\").toString(),\n            columnLabel: (col: ColumnRec) => `${col.label()} Days until`,\n            formula: FORMULAS.DAYS_UNTIL,\n            type: \"Int\",\n          },\n          {\n            label: () => t(\"Months since\"),\n            example: (date: moment.Moment) => moment().diff(date, \"months\").toString(),\n            columnLabel: (col: ColumnRec) => `${col.label()} Months since`,\n            formula: FORMULAS.MONTHS_SINCE,\n            type: \"Int\",\n          },\n          {\n            label: () => t(\"Months until\"),\n            example: (date: moment.Moment) => moment(date).diff(moment(), \"months\").toString(),\n            columnLabel: (col: ColumnRec) => `${col.label()} Months until`,\n            formula: FORMULAS.MONTHS_UNTIL,\n            type: \"Int\",\n          },\n          {\n            label: () => t(\"Years since\"),\n            example: (date: moment.Moment) => moment().diff(date, \"years\").toString(),\n            columnLabel: (col: ColumnRec) => `${col.label()} Years since`,\n            formula: FORMULAS.YEARS_SINCE,\n            type: \"Int\",\n          },\n          {\n            label: () => t(\"Years until\"),\n            example: (date: moment.Moment) => moment(date).diff(moment(), \"years\").toString(),\n            columnLabel: (col: ColumnRec) => `${col.label()} Years until`,\n            formula: FORMULAS.YEARS_UNTIL,\n            type: \"Int\",\n          },\n        ],\n      },\n    ],\n  },\n  time: {\n    header: () => t(\"Time\"),\n    items: [\n      {\n        label: () => t(\"Hour\"),\n        items: [\n          {\n            label: () => t(\"24-hour format\"),\n            example: (date: moment.Moment) => date.format(\"HH\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} Hour`,\n            formula: FORMULAS.HOUR_24,\n            type: \"Text\",\n            default: true,\n          },\n          {\n            label: () => t(\"12-hour format\"),\n            example: (date: moment.Moment) => date.format(\"h A\"),\n            columnLabel: (col: ColumnRec) => `${col.label()} Hour`,\n            formula: FORMULAS.HOUR_12,\n            type: \"Text\",\n          },\n          {\n            label: () => t(\"Time bucket\"),\n            example: (date: moment.Moment) =>\n              date.hour() < 12 ? \"Morning\" : date.hour() < 18 ? \"Afternoon\" : \"Evening\",\n            columnLabel: (col: ColumnRec) => `${col.label()} Hour`,\n            formula: FORMULAS.TIME_BUCKET,\n            type: \"Text\",\n          },\n        ],\n      },\n      {\n        label: () => t(\"Minute\"),\n        example: (date: moment.Moment) => date.format(\"mm\"),\n        columnLabel: (col: ColumnRec) => `${col.label()} Minute`,\n        formula: FORMULAS.MINUTE,\n        type: \"Int\",\n      },\n      {\n        label: () => t(\"AM/PM\"),\n        example: (date: moment.Moment) => date.format(\"A\"),\n        columnLabel: (col: ColumnRec) => `${col.label()} AM/PM`,\n        formula: FORMULAS.AM_PM,\n        type: \"Text\",\n      },\n    ],\n  },\n};\n\n// Helper function to get formula with actual column ID\nconst getFormula = (formulaTemplate: string, colId: string): string => {\n  return formulaTemplate.replace(/\\$Column/g, `$${colId}`);\n};\n\n/**\n * Leaf in the menu (item without subitems).\n */\ninterface PlainItem {\n  formula: string;\n  type: string;\n  columnLabel: (col: ColumnRec) => string;\n  label: () => string;\n  default?: boolean; // if true, clicking on the parent item will create this item\n  example?: (date: moment.Moment) => string;\n}\n\n/**\n * Submenu item, it can be clicked (so invoked) if it has a default item.\n */\ninterface SubmenuItem {\n  label: () => string;\n  items: PlainItem[];\n}\n\n/**\n * Section content, either a plain item or a submenu item.\n */\ntype SectionItem = PlainItem | SubmenuItem;\n\n/**\n * Section in the menu, with a header and items.\n */\ninterface Section {\n  header: () => string;\n  items: SectionItem[];\n}\n\n/**\n * Builds a submenu for adding columns with date helpers from existing Date/DateTime columns.\n */\nexport function buildDateHelpersMenuItems(gridView: GridView, index?: number) {\n  const { viewSection } = gridView;\n\n  // We will only show Date and DateTime columns (not fields, so hidden ones are out too).\n  const dateColumns = viewSection.columns().filter((col: ColumnRec) =>\n    // First filter is just for the types.\n    col.pureType() === \"Date\" || col.pureType() === \"DateTime\",\n  );\n\n  // If there are no available date columns, don't show the menu at all.\n  if (dateColumns.length === 0) {\n    return null;\n  }\n\n  // Helper to get the value of a column in the current row, falling back to current time.\n  // This is used to show an example of the formula output.\n  // If there is no current row, or no value in the current row, we use current time.\n  // We also take care of timezone for DateTime columns, but only for the preview, the final\n  // formula might not take this into account (we don't have python support for that).\n  const valueInColumn = (colId: string) => {\n    try {\n      const col = gridView.viewSection.columns().find(c => c.colId() === colId);\n      if (!col) {\n        return moment();\n      }\n      const timezone = (col.pureType() === \"DateTime\" ? removePrefix(col.type(), \"DateTime:\") : null) || \"UTC\";\n      const rowModel = gridView.viewData.at(gridView.cursor.rowIndex.peek() || 0);\n      if (!rowModel || !(colId in rowModel)) {\n        // Always use current time as fallback for consistency\n        return moment.tz(moment(), timezone);\n      }\n      const timestamp = (rowModel as any)[colId].peek();\n      if (typeof timestamp !== \"number\") {\n        return moment.tz(moment(), timezone);\n      }\n      // If no value in selected row, use current time for consistency\n      const date = timestamp ? moment.tz(timestamp * 1000, timezone) : moment.tz(moment(), timezone);\n      return date;\n    } catch (ex) {\n      console.warn(`Can not read current value of column: ${ex}`);\n      // Always use current time as fallback for consistency\n      return moment();\n    }\n  };\n\n  /**\n   * Menu item handler, creates a formula column based on the option and the column.\n   */\n  const handler = (option: PlainItem, col: ColumnRec) => async () => {\n    const columnLabel = option.columnLabel(col);\n    // Copy the column type to preserve timezone if any.\n    let type = col.pureType() === \"DateTime\" && option.type === \"DateTime\" ? col.type() : option.type;\n    if (type === \"DateTime\") {\n      type += `:UTC`; // Default to UTC, user can change it later if needed.\n    }\n    await gridView.insertColumn(columnLabel, {\n      colInfo: {\n        label: columnLabel,\n        type,\n        isFormula: true,\n        formula: getFormula(option.formula, col.colId()),\n        recalcWhen: RecalcWhen.DEFAULT,\n        recalcDeps: null,\n      },\n      index,\n      skipPopup: true,\n    });\n  };\n\n  // Helper to render the label from the configuration (that is either string or function)\n  const renderLabel = (option: SectionItem) => {\n    return dom(\"span\",\n      typeof option.label === \"function\" ? option.label() : option.label,\n      testId(\"date-helpers-item-label\"),\n    );\n  };\n\n  // Helper to render the example from the configuration (if any)\n  const renderExample = (option: SectionItem, col: ColumnRec, current: moment.Moment) => {\n    if (\"example\" in option && option.example) {\n      return [\n        cssExample(\n          option.example(current),\n          testId(\"date-helpers-item-example\"),\n        ),\n        cssMinWidth.cls(\"\"),\n        cssMenuItemCmd.cls(\"\"),\n      ];\n    }\n    return null;\n  };\n\n  // Helper function to create a menu item from an option\n  const createMenuItem = (option: PlainItem, col: ColumnRec, current: moment.Moment) => {\n    return menuItem(\n      handler(option, col),\n      renderLabel(option),\n      renderExample(option, col, current),\n    ) as HTMLElement;\n  };\n\n  // Helper to create test ID out of menu labels. The idea here is simple, each test id looks like:\n  // date-helpers-item-{section}-{first-level}-{second-level} where the last part is optional.\n  // Each part is lowercased, spaces replaced with dashes, and all other non-alphanumeric characters\n  // removed. This should give us stable and readable test IDs. Those ids are defined statically above, so\n  // they are not user/data dependent.\n  const makeTestPart = (s: string) => s.replace(/\\s+/g, \"-\").toLowerCase().replace(/[^0-9a-z-]/g, \"\");\n  const itemTestId = (sectionName: string, firstLevel: string, secondLevel?: string) => {\n    let result = `date-helpers-item-${makeTestPart(sectionName)}-${makeTestPart(firstLevel)}`;\n    if (secondLevel) {\n      result += `-${makeTestPart(secondLevel)}`;\n    }\n    return testId(result);\n  };\n\n  // Main renderer of a section from the configuration we have.\n  const processSection = (section: Section, col: ColumnRec, needsDivider: boolean, current: moment.Moment) => {\n    const items: Element[] = [];\n\n    if (needsDivider) {\n      items.push(menuDivider());\n    }\n\n    items.push(menuSubHeader(section.header));\n\n    const isDateTime = col.pureType() === \"DateTime\";\n\n    // Process items - they can be direct items or submenu items\n    for (const item of section.items) {\n      // Check if this item has sub-items (making it a submenu)\n      if (\"items\" in item) {\n        // This is a submenu item\n        const submenuLabel = item.label();\n        // Filter out DateTime-only items if column is Date\n        const filteredItems = isDateTime ?\n          item.items :\n          item.items.filter(opt => !opt.type.startsWith(\"DateTime\"));\n\n        if (filteredItems.length === 0) {\n          continue; // Skip empty submenus\n        }\n\n        const defaultItem = filteredItems.find(subItem => subItem.default);\n        const options: ISubMenuOptions = {};\n        if (defaultItem) {\n          options.action = handler(defaultItem, col);\n        }\n        items.push(\n          menuItemSubmenu(\n            () => filteredItems.map(option =>\n              dom.update(\n                createMenuItem(option, col, current),\n                itemTestId(section.header(), submenuLabel, option.label()),\n              ),\n            ),\n            options,\n            submenuLabel,\n            itemTestId(section.header(), submenuLabel),\n          ),\n        );\n      } else {\n        // This is a direct menu item\n        // Skip DateTime-only items if column is Date\n        if (!isDateTime && item.type.startsWith(\"DateTime\")) {\n          continue;\n        }\n        items.push(dom.update(\n          createMenuItem(item, col, current),\n          itemTestId(section.header(), item.label()),\n        ));\n      }\n    }\n\n    return items;\n  };\n\n  return menuItemSubmenu(\n    () => dateColumns.map((col: ColumnRec) =>\n      menuItemSubmenu(\n        () => {\n          const menuItems: any[] = [];\n          // Iterate over all sections in the config\n          Object.entries(CONFIGURATION).forEach(([key, section], sectionIndex) => {\n            // Skip time section for Date columns\n            if (key === \"time\" && col.pureType.peek() === \"Date\") {\n              return;\n            }\n            // First section doesn't need a divider\n            const needsDivider = sectionIndex > 0;\n            menuItems.push(...processSection(section, col, needsDivider, valueInColumn(col.colId.peek())));\n          });\n          return menuItems;\n        },\n        {},\n        col.label(),\n        testId(`date-helpers-column-${col.colId()}`),\n      ),\n    ),\n    {},\n    t(\"Date helpers…\"),\n    testId(\"new-columns-menu-date-helpers\"),\n  );\n}\n\nconst cssMinWidth = styled(\"div\", `\n  min-width: 220px; /* picked by hand to make sure examples are not too close to the label */\n`);\n\nconst cssExample = styled(\"div\", `\n  border: 1px solid ${theme.cardButtonBorder};\n  border-radius: 4px;\n  color: ${theme.menuItemIconFg};\n  display: block;\n  font-family: ${tokens.fontFamilyData};\n  margin-left: 16px;\n  margin-right: -12px;\n  padding: 2px 4px;\n\n  .${weasel.cssMenuItem.className}-sel > & {\n    border-color: ${theme.menuItemSelectedFg};\n    color: ${theme.menuItemIconSelectedFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/GristTooltips.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { cssMarkdownSpan } from \"app/client/lib/markdown\";\nimport { buildHighlightedCode } from \"app/client/ui/CodeHighlight\";\nimport { ShortcutKey, ShortcutKeyContent } from \"app/client/ui/ShortcutKey\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { commonUrls, GristDeploymentType } from \"app/common/gristUrls\";\nimport { BehavioralPrompt } from \"app/common/Prefs\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { dom, DomContents, DomElementArg, styled } from \"grainjs\";\n\nconst t = makeT(\"GristTooltips\");\n\nconst cssTooltipContent = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  row-gap: 8px;\n`);\n\nconst cssBoldText = styled(\"span\", `\n  font-weight: 600;\n`);\n\nconst cssItalicizedText = styled(\"span\", `\n  font-style: italic;\n`);\n\nconst cssIcon = styled(icon, `\n  height: 18px;\n  width: 18px;\n`);\n\nexport type Tooltip =\n  | \"dataSize\" |\n  \"setTriggerFormula\" |\n  \"selectBy\" |\n  \"workOnACopy\" |\n  \"openAccessRules\" |\n  \"addRowConditionalStyle\" |\n  \"addColumnConditionalStyle\" |\n  \"uuid\" |\n  \"lookups\" |\n  \"formulaColumn\" |\n  \"accessRulesTableWide\" |\n  \"setChoiceDropdownCondition\" |\n  \"setRefDropdownCondition\" |\n  \"communityWidgets\" |\n  \"twoWayReferences\" |\n  \"twoWayReferencesDisabled\" |\n  \"viewAsBanner\" |\n  \"reassignTwoWayReference\" |\n  \"attachmentStorage\" |\n  \"uploadAttachments\" |\n  \"adminControls\" |\n  \"formFraming\" |\n  \"formUrlValues\" |\n  \"rowHeight\" |\n  \"suggestions\"\n  ;\n\nexport type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;\n\nexport const GristTooltips: Record<Tooltip, TooltipContentFunc> = {\n  dataSize: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\", t(\"The total size of all data in this document, excluding attachments.\")),\n    dom(\"div\", t(\"Updates every 5 minutes.\")),\n    ...args,\n  ),\n  setTriggerFormula: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\",\n      t(\"Formulas that trigger in certain cases, and store the calculated value as data.\"),\n    ),\n    dom(\"div\",\n      t(\"Useful for storing the timestamp or author of a new record, data cleaning, and more.\"),\n    ),\n    dom(\"div\",\n      cssLink({ href: commonUrls.helpTriggerFormulas, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  selectBy: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\", t(\"Link your new widget to an existing widget on this page.\")),\n    dom(\"div\",\n      cssLink({ href: commonUrls.helpLinkingWidgets, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  workOnACopy: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\",\n      t(\"Try out changes in a copy, then decide whether to replace the original with your edits.\"),\n    ),\n    dom(\"div\",\n      cssLink({ href: commonUrls.helpTryingOutChanges, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  openAccessRules: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\",\n      t(\"Access rules give you the power to create nuanced rules to determine who can \\\nsee or edit which parts of your document.\"),\n    ),\n    dom(\"div\",\n      cssLink({ href: commonUrls.helpAccessRules, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  addRowConditionalStyle: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\", t(\"Apply conditional formatting to rows based on formulas.\")),\n    dom(\"div\",\n      cssLink({ href: commonUrls.helpConditionalFormatting, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  addColumnConditionalStyle: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\", t(\"Apply conditional formatting to cells in this column when formula conditions are met.\")),\n    dom(\"div\", t(\"Click on “Open row styles” to apply conditional formatting to rows.\")),\n    dom(\"div\",\n      cssLink({ href: commonUrls.helpConditionalFormatting, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  uuid: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\", t(\"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\")),\n    dom(\"div\",\n      cssLink({ href: commonUrls.helpLinkKeys, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  lookups: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\", t(\"Lookups return data from related tables.\")),\n    dom(\"div\", t(\"Use reference columns to relate data in different tables.\")),\n    dom(\"div\",\n      cssLink({ href: commonUrls.helpColRefs, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  formulaColumn: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\", t(\"Formulas support many Excel functions and full Python syntax.\")),\n    dom(\"div\",\n      cssLink({ href: commonUrls.formulas, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  accessRulesTableWide: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\", t(\"These rules are applied after all column rules have been processed, if applicable.\")),\n    ...args,\n  ),\n  setChoiceDropdownCondition: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\",\n      t(\"Filter displayed dropdown values with a condition.\"),\n    ),\n    dom(\"div\", { style: \"margin-top: 8px;\" }, t(\"Example: {{example}}\", {\n      example: dom.create(buildHighlightedCode, \"choice not in $Categories\", {}, { style: \"margin-top: 8px;\" }),\n    })),\n    ...args,\n  ),\n  setRefDropdownCondition: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\",\n      t(\"Filter displayed dropdown values with a condition.\"),\n    ),\n    dom(\"div\", { style: \"margin-top: 8px;\" }, t(\"Example: {{example}}\", {\n      example: dom.create(buildHighlightedCode, 'choice.Role == \"Manager\"', {}, { style: \"margin-top: 8px;\" }),\n    })),\n    dom(\"div\",\n      cssLink({ href: commonUrls.helpFilteringReferenceChoices, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  communityWidgets: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\",\n      t(\"Community widgets are created and maintained by Grist community members.\"),\n    ),\n    dom(\"div\",\n      cssLink({ href: commonUrls.helpCustomWidgets, target: \"_blank\" }, t(\"Learn more.\")),\n    ),\n    ...args,\n  ),\n  twoWayReferences: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\",\n      t(\"Creates a new Reference List column in the target table, with both this \\\nand the target columns editable and synchronized.\"),\n    ),\n    ...args,\n  ),\n  twoWayReferencesDisabled: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\",\n      t(\"Two-way references are not currently supported for Formula or Trigger Formula columns\"),\n    ),\n    ...args,\n  ),\n  reassignTwoWayReference: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\",\n      t(\"This limitation occurs when one column in a two-way reference has the Reference type.\"),\n    ),\n    dom(\"div\",\n      t(`To allow multiple assignments, change the referenced column's type to Reference List.`),\n    ),\n    ...args,\n  ),\n  viewAsBanner: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\", t(\"The preview below this header shows how the selected user will see this document\")),\n    ...args,\n  ),\n  attachmentStorage: (...args: DomElementArg[]) => cssTooltipContent(\n    cssMarkdownSpan(\n      t(\n        \"Internal storage means all attachments are stored in the document SQLite file, \\\nwhile external storage indicates all attachments are stored in the same \\\nexternal storage.\",\n      ) +\n      \"\\n\\n\" +\n      t(\n        \"[Learn more.]({{link}})\", {\n          link: commonUrls.attachmentStorage,\n        },\n      )),\n    ...args,\n  ),\n  adminControls: (...args: DomElementArg[]) => cssTooltipContent(\n    dom(\"div\", t(\"Manage users and resources in a Grist installation.\")),\n    dom(\"div\", cssLink({ href: commonUrls.helpAdminControls, target: \"_blank\" }, t(\"Learn more.\"))),\n    ...args,\n  ),\n  uploadAttachments: (...args: DomElementArg[]) => cssTooltipContent(\n    cssMarkdownSpan(\n      t(\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. \\\nOnly .tar attachment archives downloaded from Grist can be uploaded here.\",\n      ),\n    ),\n    ...args,\n  ),\n  formFraming: (...args: DomElementArg[]) => cssTooltipContent(\n    cssMarkdownSpan(\n      t(\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. \\\nor any party providing this service. For your security, do not submit passwords through this form, \\\nand be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\", {\n          mail: getGristConfig().supportEmail,\n        },\n      )),\n  ),\n  formUrlValues: () => cssTooltipContent(\n    dom(\"div\",\n      t(\"When checked, this field’s default value can be prefilled from the URL using query parameters.\")),\n    dom(\"div\", cssLink({ href: commonUrls.helpFormUrlValues, target: \"_blank\" }, t(\"Learn more.\"))),\n  ),\n  rowHeight: (...args: DomElementArg[]) => cssTooltipContent(\n    t(\"Set the maximum number of lines for multi-line text.\"),\n    ...args,\n  ),\n  suggestions: () => cssTooltipContent(\n    cssMarkdownSpan(\n      t(\n        \"With suggestions, users make changes in a personal copy without \\\nmodifying the original document, then submit these suggestions \\\nto be reviewed by the document owner prior to integration.\",\n      ) +\n      \"\\n\\n\" +\n      t(\n        \"[Learn more.]({{link}})\", {\n          link: commonUrls.helpSuggestions,\n        },\n      )),\n  ),\n};\n\ntype ErrorTooltip = \"summaryFormulas\";\n\nexport const ErrorTooltips: Record<ErrorTooltip, TooltipContentFunc> = {\n  summaryFormulas: () =>\n    cssTooltipContent(\n      dom(\"div\", t(\"Summary tables can only contain formula columns.\")),\n      dom(\n        \"div\",\n        cssLink(\n          { href: commonUrls.helpSummaryFormulas, target: \"_blank\" },\n          t(\"Learn more.\"),\n        ),\n      ),\n    ),\n};\n\nexport interface BehavioralPromptContent {\n  popupType: \"tip\" | \"news\";\n  title: () => DomContents;\n  content: (...domArgs: DomElementArg[]) => DomContents;\n  deploymentTypes: GristDeploymentType[] | \"all\";\n  /** Defaults to `everyone`. */\n  audience?: \"signed-in-users\" | \"anonymous-users\" | \"everyone\";\n  /** Defaults to `desktop`. */\n  deviceType?: \"mobile\" | \"desktop\" | \"all\";\n  /** Defaults to `false`. */\n  hideDontShowTips?: boolean;\n  /** Defaults to `false`. */\n  forceShow?: boolean;\n  /** Defaults to `true`. */\n  markAsSeen?: boolean;\n}\n\nexport const GristBehavioralPrompts: Record<BehavioralPrompt, BehavioralPromptContent> = {\n  referenceColumns: {\n    popupType: \"tip\",\n    title: () => t(\"Reference Columns\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\", t(\"Reference columns are the key to {{relational}} data in Grist.\", {\n        relational: cssBoldText(t(\"relational\")),\n      })),\n      dom(\"div\", t(\"They allow for one record to point (or refer) to another.\")),\n      dom(\"div\",\n        cssLink({ href: commonUrls.helpColRefs, target: \"_blank\" }, t(\"Learn more.\")),\n      ),\n      ...args,\n    ),\n    deploymentTypes: [\"saas\", \"core\", \"enterprise\", \"electron\"],\n  },\n  referenceColumnsConfig: {\n    popupType: \"tip\",\n    title: () => t(\"Reference Columns\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\", t(\"Select the table to link to.\")),\n      dom(\"div\", t(\"Cells in a reference column always identify an {{entire}} \\\nrecord in that table, but you may select which column from that record to show.\", {\n        entire: cssItalicizedText(t(\"entire\")),\n      })),\n      dom(\"div\",\n        cssLink({ href: commonUrls.helpUnderstandingReferenceColumns, target: \"_blank\" }, t(\"Learn more.\")),\n      ),\n      ...args,\n    ),\n    deploymentTypes: [\"saas\", \"core\", \"enterprise\", \"electron\"],\n  },\n  rawDataPage: {\n    popupType: \"tip\",\n    title: () => t(\"Raw Data page\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\", t(\"The Raw Data page lists all data tables in your document, \\\nincluding summary tables and tables not included in page layouts.\")),\n      dom(\"div\", cssLink({ href: commonUrls.helpRawData, target: \"_blank\" }, t(\"Learn more.\"))),\n      ...args,\n    ),\n    deploymentTypes: [\"saas\", \"core\", \"enterprise\", \"electron\"],\n  },\n  filterButtons: {\n    popupType: \"tip\",\n    title: () => t(\"Pinning Filters\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\", t(\"Pinned filters are displayed as buttons above the widget.\")),\n      dom(\"div\", t(\"Unpin to hide the button while keeping the filter.\")),\n      dom(\"div\", cssLink({ href: commonUrls.helpFilterButtons, target: \"_blank\" }, t(\"Learn more.\"))),\n      ...args,\n    ),\n    deploymentTypes: [\"saas\", \"core\", \"enterprise\", \"electron\"],\n  },\n  nestedFiltering: {\n    popupType: \"tip\",\n    title: () => t(\"Nested Filtering\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\", t(\"You can filter by more than one column.\")),\n      dom(\"div\", t(\"Only those rows will appear which match all of the filters.\")),\n      ...args,\n    ),\n    deploymentTypes: [\"saas\", \"core\", \"enterprise\", \"electron\"],\n  },\n  pageWidgetPicker: {\n    popupType: \"tip\",\n    title: () => t(\"Selecting Data\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\", t(\"Select the table containing the data to show.\")),\n      dom(\"div\", t(\"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\")),\n      ...args,\n    ),\n    deploymentTypes: [\"saas\", \"core\", \"enterprise\", \"electron\"],\n  },\n  pageWidgetPickerSelectBy: {\n    popupType: \"tip\",\n    title: () => t(\"Linking Widgets\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\", t(\"Link your new widget to an existing widget on this page.\")),\n      dom(\"div\", t(\"This is the secret to Grist's dynamic and productive layouts.\")),\n      dom(\"div\", cssLink({ href: commonUrls.helpLinkingWidgets, target: \"_blank\" }, t(\"Learn more.\"))),\n      ...args,\n    ),\n    deploymentTypes: [\"saas\", \"core\", \"enterprise\", \"electron\"],\n  },\n  editCardLayout: {\n    popupType: \"tip\",\n    title: () => t(\"Editing Card Layout\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\", t(\"Rearrange the fields in your card by dragging and resizing cells.\")),\n      dom(\"div\", t(\"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\", {\n        EyeHideIcon: cssIcon(\"EyeHide\"),\n      })),\n      ...args,\n    ),\n    deploymentTypes: [\"saas\", \"core\", \"enterprise\", \"electron\"],\n  },\n  addNew: {\n    popupType: \"tip\",\n    title: () => t(\"Add new\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\", t(\"Click the Add new button to create new documents or workspaces, or import data.\")),\n      ...args,\n    ),\n    deploymentTypes: [\"saas\", \"core\", \"enterprise\", \"electron\"],\n  },\n  rickRow: {\n    popupType: \"tip\",\n    title: () => t(\"Anchor Links\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\",\n        t(\"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\",\n          {\n            shortcut: ShortcutKey(ShortcutKeyContent(commands.allCommands.copyLink.humanKeys[0])),\n          },\n        ),\n      ),\n      ...args,\n    ),\n    deploymentTypes: \"all\",\n    deviceType: \"all\",\n    hideDontShowTips: true,\n    forceShow: true,\n    markAsSeen: false,\n  },\n  calendarConfig: {\n    popupType: \"tip\",\n    title: () => t(\"Calendar\"),\n    content: (...args: DomElementArg[]) => cssTooltipContent(\n      dom(\"div\", t(\"To configure your calendar, select columns for start/end dates and event titles. \\\nNote each column's type.\")),\n      dom(\"div\", t(\"Can't find the right columns? Click 'Change Widget' to select the table with events \\\ndata.\")),\n      dom(\"div\", cssLink({ href: commonUrls.helpCalendarWidget, target: \"_blank\" }, t(\"Learn more.\"))),\n      ...args,\n    ),\n    deploymentTypes: [\"saas\", \"core\", \"enterprise\", \"electron\"],\n  },\n};\n"
  },
  {
    "path": "app/client/ui/HomeIntro.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { HomeModel } from \"app/client/models/HomeModel\";\nimport { productPill } from \"app/client/ui/AppHeader\";\nimport * as css from \"app/client/ui/DocMenuCss\";\nimport { buildHomeIntroCards } from \"app/client/ui/HomeIntroCards\";\nimport { isNarrowScreenObs, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu, menuCssClass } from \"app/client/ui2018/menus\";\nimport { toggleSwitch } from \"app/client/ui2018/toggleSwitch\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\n\nimport { dom, DomContents, styled } from \"grainjs\";\nimport { defaultMenuOptions } from \"popweasel\";\n\nconst t = makeT(\"HomeIntro\");\n\nexport function buildHomeIntro(homeModel: HomeModel): DomContents {\n  const user = homeModel.app.currentValidUser;\n  const isAnonym = !user;\n  const isPersonal = !homeModel.app.isTeamSite;\n  if (isAnonym) {\n    return makeAnonIntro(homeModel);\n  } else if (isPersonal) {\n    return makePersonalIntro(homeModel, user);\n  } else {\n    return makeTeamSiteIntro(homeModel);\n  }\n}\n\nfunction makeTeamSiteIntro(homeModel: HomeModel) {\n  return [\n    css.stickyHeader(\n      cssHeaderWithPill(\n        cssHeader(\n          dom.text(use =>\n            use(isNarrowScreenObs()) ?\n              homeModel.app.currentOrgName :\n              t(\"Welcome to {{- orgName}}\", { orgName: homeModel.app.currentOrgName }),\n          ),\n        ),\n        cssPill(productPill(homeModel.app.currentOrg, { large: true })),\n        testId(\"welcome-title\"),\n      ),\n      buildPreferencesMenu(homeModel),\n    ),\n    dom.create(buildHomeIntroCards, { homeModel }),\n  ];\n}\n\nfunction makePersonalIntro(homeModel: HomeModel, user: FullUser) {\n  return [\n    css.stickyHeader(\n      cssHeader(\n        // this is like using a `<h1>` element, but in our case it's easier to use aria attributes than changing\n        // some common `styled` components in order to use a specific h1 here\n        { \"role\": \"heading\", \"aria-level\": \"1\" },\n        dom.text(use =>\n          use(isNarrowScreenObs()) ?\n            t(\"Welcome to Grist!\") :\n            t(\"Welcome to Grist, {{- name}}!\", { name: user.name }),\n        ),\n        testId(\"welcome-title\"),\n      ),\n      buildPreferencesMenu(homeModel),\n    ),\n    dom.create(buildHomeIntroCards, { homeModel }),\n  ];\n}\n\nfunction makeAnonIntro(homeModel: HomeModel) {\n  return [\n    css.stickyHeader(\n      cssHeader(\n        t(\"Welcome to Grist!\"),\n        testId(\"welcome-title\"),\n      ),\n    ),\n    dom.create(buildHomeIntroCards, { homeModel }),\n  ];\n}\n\nfunction buildPreferencesMenu(homeModel: HomeModel) {\n  const { onlyShowDocuments } = homeModel;\n\n  return cssDotsMenu(\n    cssDots(icon(\"Dots\")),\n    menu(\n      () => [\n        toggleSwitch(onlyShowDocuments, {\n          label: t(\"Only show documents\"),\n          args: [\n            testId(\"welcome-menu-only-show-documents\"),\n          ],\n        }),\n      ],\n      {\n        ...defaultMenuOptions,\n        menuCssClass: `${menuCssClass} ${cssPreferencesMenu.className}`,\n        placement: \"bottom-end\",\n      },\n    ),\n    testId(\"welcome-menu\"),\n  );\n}\n\nconst cssHeader = styled(css.listHeaderNoWrap, `\n  font-size: 24px;\n  line-height: 36px;\n`);\n\nconst cssHeaderWithPill = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  overflow: hidden;\n`);\n\nconst cssPill = styled(\"div\", `\n  flex-shrink: 0;\n`);\n\nconst cssPreferencesMenu = styled(\"div\", `\n  padding: 10px 16px;\n`);\n\nconst cssDotsMenu = styled(\"div\", `\n  display: flex;\n  cursor: pointer;\n  border-radius: ${vars.controlBorderRadius};\n\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssDots = styled(\"div\", `\n  --icon-color: ${theme.lightText};\n  padding: 8px;\n`);\n"
  },
  {
    "path": "app/client/ui/HomeIntroCards.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { HomeModel } from \"app/client/models/HomeModel\";\nimport { newDocMethods } from \"app/client/ui/NewDocMethods\";\nimport { openVideoTour } from \"app/client/ui/OpenVideoTour\";\nimport { basicButtonLink, bigPrimaryButton, primaryButtonLink } from \"app/client/ui2018/buttons\";\nimport { colors, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton, unstyledH2 } from \"app/client/ui2018/unstyled\";\nimport { commonUrls, isFeatureEnabled } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Computed, dom, IDisposableOwner, makeTestId, styled, subscribeElem } from \"grainjs\";\n\ninterface BuildHomeIntroCardsOptions {\n  homeModel: HomeModel;\n}\n\nconst t = makeT(\"HomeIntroCards\");\n\nconst testId = makeTestId(\"test-intro-\");\n\nexport function buildHomeIntroCards(\n  owner: IDisposableOwner,\n  { homeModel }: BuildHomeIntroCardsOptions,\n) {\n  const { onboardingTutorialDocId, templateOrg } = getGristConfig();\n\n  const percentComplete = Computed.create(owner, (use) => {\n    if (!homeModel.app.currentValidUser) { return 0; }\n\n    const tutorial = use(homeModel.onboardingTutorial);\n    if (!tutorial) { return undefined; }\n\n    return tutorial.forks?.[0]?.options?.tutorial?.percentComplete ?? 0;\n  });\n\n  let videoPlayButtonElement: HTMLElement;\n\n  return dom.maybe(use => !use(homeModel.onlyShowDocuments), () => cssHomeIntroCards(\n    cssVideoTour(\n      cssVideoTourThumbnail(\n        cssVideoTourThumbnailSpacer(),\n        videoPlayButtonElement = cssVideoTourPlayButton(\n          cssVideoTourPlayIcon(\"VideoPlay2\"),\n        ),\n        cssVideoTourThumbnailText(t(\"3 minute video tour\")),\n      ),\n      dom.on(\"click\", () => openVideoTour(videoPlayButtonElement)),\n      testId(\"video-tour\"),\n    ),\n    cssTutorial(\n      dom.hide(() => !isFeatureEnabled(\"tutorials\") || !templateOrg || !onboardingTutorialDocId),\n      cssTutorialHeader(t(\"Finish our basics tutorial\")),\n      cssTutorialBody(\n        cssTutorialProgress(\n          cssTutorialProgressText(\n            cssTutorialProgressPercentage(\n              dom.domComputed(percentComplete, percent => percent !== undefined ? `${percent}%` : null),\n              testId(\"tutorial-percent-complete\"),\n            ),\n          ),\n          cssTutorialProgressBar(\n            elem => subscribeElem(elem, percentComplete, (val) => {\n              elem.style.setProperty(\"--percent-complete\", String(val ?? 0));\n            }),\n          ),\n        ),\n        dom(\"div\",\n          primaryButtonLink(\n            t(\"Tutorial\"),\n            urlState().setLinkUrl({ org: templateOrg!, doc: onboardingTutorialDocId }),\n          ),\n        ),\n      ),\n      testId(\"tutorial\"),\n    ),\n    cssNewDocument(\n      cssNewDocumentHeader(t(\"Start a new document\")),\n      cssNewDocumentBody(\n        cssNewDocumentButton(\n          cssNewDocumentButtonIcon(\"Page\"),\n          t(\"Blank document\"),\n          dom.on(\"click\", () => newDocMethods.createDocAndOpen(homeModel)),\n          dom.boolAttr(\"disabled\", use => !use(homeModel.newDocWorkspace)),\n          testId(\"create-doc\"),\n        ),\n        cssNewDocumentButton(\n          cssNewDocumentButtonIcon(\"Import\"),\n          t(\"Import file\"),\n          dom.on(\"click\", () => newDocMethods.importDocAndOpen(homeModel)),\n          dom.boolAttr(\"disabled\", use => !use(homeModel.newDocWorkspace)),\n          testId(\"import-doc\"),\n        ),\n        cssNewDocumentButton(\n          dom.show(isFeatureEnabled(\"templates\") && Boolean(templateOrg)),\n          cssNewDocumentButtonIcon(\"FieldTable\"),\n          t(\"Templates\"),\n          urlState().setLinkUrl({ homePage: \"templates\" }),\n          testId(\"templates\"),\n        ),\n      ),\n    ),\n    cssWebinars(\n      dom.show(isFeatureEnabled(\"helpCenter\")),\n      cssWebinarsImage({ src: \"img/webinars.svg\" }),\n      unstyledH2(t(\"Learn more\")),\n      cssWebinarsButton(\n        t(\"Webinars\"),\n        { href: commonUrls.webinars, target: \"_blank\" },\n        testId(\"webinars\"),\n      ),\n    ),\n    cssHelpCenter(\n      dom.show(isFeatureEnabled(\"helpCenter\")),\n      cssHelpCenterImage({ src: \"img/help-center.svg\" }),\n      unstyledH2(t(\"Find solutions and explore more resources\")),\n      cssHelpCenterButton(\n        t(\"Help center\"),\n        { href: commonUrls.help, target: \"_blank\" },\n        testId(\"help-center\"),\n      ),\n    ),\n    testId(\"cards\"),\n  ));\n}\n\n// Cards are hidden at specific breakpoints; we use non-standard ones\n// here, as they work better than the ones defined in `cssVars.ts`.\nconst mediaXLarge = `(max-width: ${1440 - 0.02}px)`;\nconst mediaLarge = `(max-width: ${1280 - 0.02}px)`;\nconst mediaMedium = `(max-width: ${1048 - 0.02}px)`;\nconst mediaSmall = `(max-width: ${828 - 0.02}px)`;\n\nconst cssHomeIntroCards = styled(\"div\", `\n  display: grid;\n  gap: 24px;\n  margin-top: 4px;\n  margin-bottom: 24px;\n  display: grid;\n  grid-template-columns: 239px minmax(0, 437px) minmax(196px, 1fr) minmax(196px, 1fr);\n  grid-template-rows: repeat(2, 1fr);\n\n  @media ${mediaLarge} {\n    & {\n      grid-template-columns: 239px minmax(0, 437px) minmax(196px, 1fr);\n    }\n  }\n  @media ${mediaMedium} {\n    & {\n      grid-template-columns: 239px minmax(0, 437px);\n    }\n  }\n  @media ${mediaSmall} {\n    & {\n      display: flex;\n      flex-direction: column;\n    }\n  }\n`);\n\nconst cssVideoTour = styled(unstyledButton, `\n  grid-area: 1 / 1 / 2 / 2;\n  flex-shrink: 0;\n  width: 239px;\n  overflow: hidden;\n  cursor: pointer;\n  border-radius: 4px;\n  aspect-ratio: 16 / 9;\n\n  outline-offset: 1px;\n\n  @media ${mediaSmall} {\n    & {\n      width: unset;\n      aspect-ratio: unset;\n      min-height: 120px;\n    }\n  }\n`);\n\nconst cssVideoTourThumbnail = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  padding: 36px 32px;\n  background-image: url(\"img/youtube-screenshot.png\");\n  background-color: rgba(0, 0, 0, 0.4);\n  background-blend-mode: multiply;\n  background-size: cover;\n  transform: scale(1.2);\n  width: 100%;\n  height: 100%;\n`);\n\nconst cssVideoTourThumbnailSpacer = styled(\"div\", ``);\n\nconst cssVideoTourPlayButton = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  align-self: center;\n  width: 32px;\n  height: 32px;\n  background-color: ${theme.controlPrimaryBg};\n  border-radius: 50%;\n\n  .${cssVideoTourThumbnail.className}:hover & {\n    background-color: ${theme.controlPrimaryHoverBg};\n  }\n`);\n\nconst cssVideoTourPlayIcon = styled(icon, `\n  --icon-color: ${theme.controlPrimaryFg};\n  width: 24px;\n  height: 24px;\n`);\n\nconst cssVideoTourThumbnailText = styled(\"div\", `\n  color: ${colors.light};\n  font-weight: 700;\n  text-align: center;\n`);\n\nconst cssTutorial = styled(\"div\", `\n  grid-area: 1 / 2 / 2 / 3;\n  position: relative;\n  border-radius: 4px;\n  color: ${theme.announcementPopupFg};\n  background-color: ${theme.announcementPopupBg};\n  padding: 16px;\n`);\n\nconst cssTutorialHeader = styled(\"div\", `\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  font-size: 16px;\n  font-style: normal;\n  font-weight: 500;\n  margin-bottom: 8px;\n`);\n\nconst cssTutorialBody = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n`);\n\nconst cssTutorialProgress = styled(\"div\", `\n  display\n  flex: auto;\n  min-width: 120px;\n`);\n\nconst cssTutorialProgressText = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n`);\n\nconst cssTutorialProgressPercentage = styled(\"div\", `\n  font-size: 18px;\n  font-style: normal;\n  font-weight: 700;\n  min-height: 21.5px;\n`);\n\nconst cssTutorialProgressBar = styled(\"div\", `\n  margin-top: 4px;\n  height: 10px;\n  border-radius: 8px;\n  background: ${theme.mainPanelBg};\n  --percent-complete: 0;\n\n  &::after {\n    content: '';\n    border-radius: 8px;\n    background: ${theme.progressBarFg};\n    display: block;\n    height: 100%;\n    width: calc((var(--percent-complete) / 100) * 100%);\n  }\n`);\n\nconst cssNewDocument = styled(\"div\", `\n  grid-area: 2 / 1 / 3 / 3;\n  grid-column: span 2;\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  position: relative;\n  border-radius: 4px;\n  color: ${theme.announcementPopupFg};\n  background-color: ${theme.announcementPopupBg};\n  padding: 24px;\n  min-height: 140px;\n`);\n\nconst cssNewDocumentHeader = styled(\"h2\", `\n  margin: 0;\n  font-weight: 500;\n  font-size: ${vars.xxlargeFontSize};\n`);\n\nconst cssNewDocumentBody = styled(\"div\", `\n  display: flex;\n  gap: 16px;\n  margin-top: 16px;\n\n  @media ${mediaSmall} {\n    & {\n      flex-direction: column;\n    }\n  }\n`);\n\nconst cssNewDocumentButton = styled(bigPrimaryButton, `\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-grow: 1;\n  padding: 6px;\n`);\n\nconst cssNewDocumentButtonIcon = styled(icon, `\n  flex-shrink: 0;\n  margin-right: 8px;\n\n  @media ${mediaXLarge} {\n    & {\n      display: none;\n    }\n  }\n`);\n\nconst cssSecondaryCard = styled(\"div\", `\n  font-weight: 500;\n  font-size: 14px;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  text-align: center;\n  justify-content: center;\n  min-width: 196px;\n  color: ${theme.text};\n  background-color: ${theme.popupSecondaryBg};\n  position: relative;\n  border-radius: 4px;\n  padding: 16px;\n  min-height: 140px;\n`);\n\nconst cssSecondaryCardImage = styled(\"img\", `\n  display: block;\n  height: auto;\n`);\n\nconst cssSecondaryCardButton = styled(basicButtonLink, `\n  font-weight: 400;\n  font-size: ${vars.mediumFontSize};\n  margin-top: 8px;\n`);\n\nconst cssWebinars = styled(cssSecondaryCard, `\n  grid-area: 2 / 3 / 3 / 4;\n\n  @media ${mediaMedium} {\n    & {\n      display: none;\n    }\n  }\n`);\n\nconst cssWebinarsImage = styled(cssSecondaryCardImage, `\n  width: 105.78px;\n  margin-bottom: 8px;\n`);\n\nconst cssWebinarsButton = cssSecondaryCardButton;\n\nconst cssHelpCenter = styled(cssSecondaryCard, `\n  grid-area: 2 / 4 / 3 / 5;\n\n  @media ${mediaLarge} {\n    & {\n      display: none;\n    }\n  }\n`);\n\nconst cssHelpCenterImage = styled(cssSecondaryCardImage, `\n  width: 67.77px;\n`);\n\nconst cssHelpCenterButton = cssSecondaryCardButton;\n"
  },
  {
    "path": "app/client/ui/HomeLeftPane.ts",
    "content": "import { startHomeAirtableImport } from \"app/client/lib/airtable/startHomeAirtableImport\";\nimport { loadUserManager } from \"app/client/lib/imports\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { getLoginOrSignupUrl } from \"app/client/lib/urlUtils\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { HomeModel } from \"app/client/models/HomeModel\";\nimport { getWorkspaceInfo, workspaceName } from \"app/client/models/WorkspaceInfo\";\nimport { addNewButton, cssAddNewButton } from \"app/client/ui/AddNewButton\";\nimport { getAdminPanelName } from \"app/client/ui/AdminPanelName\";\nimport {\n  createAccessibilityTools,\n  createHelpTools,\n  cssHomeTools,\n  cssLeftPanel,\n  cssLinkText,\n  cssMenuTrigger,\n  cssPageColorIcon,\n  cssPageEntry,\n  cssPageIcon,\n  cssPageLink,\n  cssPageLinkContainer,\n  cssScrollPane,\n  cssSectionHeader,\n  cssSectionHeaderText,\n} from \"app/client/ui/LeftPanelCommon\";\nimport { newDocMethods } from \"app/client/ui/NewDocMethods\";\nimport { createVideoTourToolsButton } from \"app/client/ui/OpenVideoTour\";\nimport { transientInput } from \"app/client/ui/transientInput\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu, menuIcon, menuItem, upgradableMenuItem, upgradeText } from \"app/client/ui2018/menus\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\nimport { stretchedLink } from \"app/client/ui2018/stretchedLink\";\nimport { commonUrls, isFeatureEnabled } from \"app/common/gristUrls\";\nimport * as roles from \"app/common/roles\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { Workspace } from \"app/common/UserAPI\";\nimport * as version from \"app/common/version\";\n\nimport { computed, dom, domComputed, DomElementArg, observable, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"HomeLeftPane\");\n\nexport function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: HomeModel) {\n  const creating = observable<boolean>(false);\n  const renaming = observable<Workspace | null>(null);\n  const isAnonymous = !home.app.currentValidUser;\n  const { enableAnonPlayground, templateOrg, onboardingTutorialDocId } = getGristConfig();\n  const canCreate = !isAnonymous || enableAnonPlayground;\n\n  // Show version when hovering over the application icon.\n  // Include gitcommit when known. Cast version.gitcommit since, depending\n  // on how Grist is compiled, tsc may believe it to be a constant and\n  // believe that testing it is unnecessary.\n  const appVersion = `Version ${version.version}` +\n    ((version.gitcommit as string) !== \"unknown\" ? ` (${version.gitcommit})` : \"\");\n\n  return cssContent(\n    dom.autoDispose(creating),\n    dom.autoDispose(renaming),\n    addNewButton({ isOpen: leftPanelOpen, isDisabled: !canCreate },\n      canCreate ? menu(() => addMenu(home, creating), {\n        placement: \"bottom-start\",\n        // \"Add New\" menu should have the same width as the \"Add New\" button that opens it.\n        stretchToSelector: `.${cssAddNewButton.className}`,\n      }) : null,\n      dom.cls(\"behavioral-prompt-add-new\"),\n      testId(\"dm-add-new\"),\n    ),\n    cssScrollPane(\n      cssPageEntry(\n        cssPageEntry.cls(\"-selected\", use => use(home.currentPage) === \"all\"),\n        cssPageLink(cssPageIcon(\"Home\"),\n          cssLinkText(t(\"All documents\")),\n          urlState().setLinkUrl({ ws: undefined, homePage: undefined }),\n          testId(\"dm-all-docs\"),\n        ),\n      ),\n      dom.maybe(use => !use(home.singleWorkspace), () =>\n        cssSectionHeader(\n          cssSectionHeaderText(t(\"Workspaces\")),\n          // Give it a testId, because it's a good element to simulate \"click-away\" in tests.\n          testId(\"dm-ws-label\"),\n          { id: \"grist-workspaces-heading\" },\n        ),\n      ),\n      dom(\"nav\",\n        { \"aria-labelledby\": \"grist-workspaces-heading\" },\n        dom.forEach(home.workspaces, (ws) => {\n          if (ws.isSupportWorkspace) { return null; }\n          const info = getWorkspaceInfo(home.app, ws);\n          const isTrivial = computed(use => Boolean(getWorkspaceInfo(home.app, ws).isDefault &&\n            use(home.singleWorkspace)));\n          // TODO: Introduce a \"SwitchSelector\" pattern to avoid the need for N computeds (and N\n          // recalculations) to select one of N items.\n          const isRenaming = computed(use => use(renaming) === ws);\n          return cssPageEntry(\n            dom.autoDispose(isRenaming),\n            dom.autoDispose(isTrivial),\n            dom.hide(isTrivial),\n            cssPageEntry.cls(\"-selected\", use => use(home.currentWSId) === ws.id),\n            cssPageLinkContainer(cssPageIcon(\"Folder\"),\n              stretchedLink(\n                cssLinkText(workspaceName(home.app, ws)),\n                urlState().setLinkUrl({ ws: ws.id }),\n              ),\n              dom.hide(isRenaming),\n              // Don't show menu if workspace is personal and shared by another user; we could\n              // be a bit more nuanced here, but as of today the menu isn't particularly useful\n              // as all the menu options are disabled.\n              !info.self && info.owner ? null : cssMenuTrigger(icon(\"Dots\"),\n                menu(() => workspaceMenu(home, ws, renaming),\n                  { placement: \"bottom-start\", parentSelectorToMark: \".\" + cssPageEntry.className }),\n\n                // Clicks on the menu trigger shouldn't follow the link that it's contained in.\n                dom.on(\"click\", (ev) => { ev.stopPropagation(); ev.preventDefault(); }),\n                { \"aria-label\": t(\"context menu - {{- workspaceName }}\", { workspaceName: `\"${ws.name}\"` }) },\n                testId(\"dm-workspace-options\"),\n              ),\n              testId(\"dm-workspace\"),\n              dom.cls(\"test-dm-workspace-selected\", use => use(home.currentWSId) === ws.id),\n            ),\n            cssPageEntry.cls(\"-renaming\", isRenaming),\n            dom.maybe(isRenaming, () =>\n              cssPageLink(cssPageIcon(\"Folder\"),\n                cssEditorInput({\n                  initialValue: ws.name || \"\",\n                  save: async val => (val !== ws.name) ? home.renameWorkspace(ws.id, val) : undefined,\n                  close: () => renaming.set(null),\n                }, testId(\"dm-ws-name-editor\")),\n              ),\n            ),\n          );\n        }),\n      ),\n      dom.maybe(creating, () => cssPageEntry(\n        cssPageLink(cssPageIcon(\"Folder\"),\n          cssEditorInput({\n            initialValue: \"\",\n            save: async val => (val !== \"\") ? home.createWorkspace(val) : undefined,\n            close: () => creating.set(false),\n          }, testId(\"dm-ws-name-editor\")),\n        ),\n      )),\n      cssHomeTools(\n        { \"aria-labelledby\": \"grist-resources-heading\" },\n        cssSectionHeader(\n          cssPageColorIcon(\"GristLogo\", { title: appVersion, id: \"grist-resources-logo\" }),\n          cssSectionHeaderText(t(\"Grist Resources\"), { id: \"grist-resources-heading\" }),\n        ),\n        cssPageEntry(\n          dom.show(isFeatureEnabled(\"templates\") && Boolean(templateOrg)),\n          cssPageEntry.cls(\"-selected\", use => use(home.currentPage) === \"templates\"),\n          cssPageLink(cssPageIcon(\"Board\"), cssLinkText(t(\"Examples & Templates\")),\n            urlState().setLinkUrl({ homePage: \"templates\" }),\n            testId(\"dm-templates-page\"),\n          ),\n        ),\n        isAnonymous ? null : cssPageEntry(\n          cssPageEntry.cls(\"-selected\", use => use(home.currentPage) === \"trash\"),\n          cssPageLink(cssPageIcon(\"RemoveBig\"), cssLinkText(t(\"Trash\")),\n            urlState().setLinkUrl({ homePage: \"trash\" }),\n            testId(\"dm-trash\"),\n          ),\n        ),\n        cssPageEntry(\n          dom.show(isFeatureEnabled(\"tutorials\") && Boolean(templateOrg && onboardingTutorialDocId)),\n          cssPageLink(cssPageIcon(\"Bookmark\"), cssLinkText(t(\"Tutorial\")),\n            urlState().setLinkUrl({ org: templateOrg!, doc: onboardingTutorialDocId }),\n            testId(\"dm-basic-tutorial\"),\n          ),\n        ),\n        createVideoTourToolsButton(),\n        (home.app.isInstallAdmin() ?\n          cssPageEntry(\n            cssPageLink(cssPageIcon(\"Settings\"), cssLinkText(getAdminPanelName()),\n              urlState().setLinkUrl({ adminPanel: \"admin\" }),\n              testId(\"dm-admin-panel\"),\n            ),\n          ) : null\n        ),\n        createHelpTools(home.app),\n        createAccessibilityTools(),\n        (commonUrls.termsOfService ?\n          cssPageEntry(\n            cssPageLink(cssPageIcon(\"Memo\"), cssLinkText(t(\"Terms of service\")),\n              { href: commonUrls.termsOfService, target: \"_blank\" },\n              testId(\"dm-tos\"),\n            ),\n          ) : null\n        ),\n      ),\n    ),\n  );\n}\n\nfunction addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[] {\n  const org = home.app.currentOrg;\n  const orgAccess: roles.Role | null = org ? org.access : null;\n  const needUpgrade = home.app.currentFeatures?.maxWorkspacesPerOrg === 1;\n\n  return [\n    menuItem(() => newDocMethods.createDocAndOpen(home), menuIcon(\"Page\"), t(\"Create empty document\"),\n      dom.cls(\"disabled\", !home.newDocWorkspace.get()),\n      testId(\"dm-new-doc\"),\n    ),\n    menuItem(() => newDocMethods.importDocAndOpen(home), menuIcon(\"Import\"), t(\"Import document\"),\n      dom.cls(\"disabled\", !home.newDocWorkspace.get()),\n      testId(\"dm-import\"),\n    ),\n    domComputed(home.importSources, importSources => ([\n      ...importSources.map((source, i) =>\n        menuItem(() => newDocMethods.importFromPluginAndOpen(home, source),\n          menuIcon(\"Import\"),\n          source.importSource.label,\n          dom.cls(\"disabled\", !home.newDocWorkspace.get()),\n          testId(`dm-import-plugin`),\n        )),\n    ])),\n    menuItem(\n      async () => {\n        if (home.app.currentValidUser) {\n          await startHomeAirtableImport(home);\n        } else {\n          window.location.href = getLoginOrSignupUrl();\n        }\n      },\n      menuIcon(\"Import\"), t(\"Import from Airtable\"),\n      dom.show(isFeatureEnabled(\"importFromAirtable\")),\n      dom.cls(\"disabled\", !home.newDocWorkspace.get()),\n      testId(\"dm-import-from-airtable\"),\n    ),\n    // For workspaces: if ACL says we can create them, but product says we can't,\n    // then offer an upgrade link.\n    upgradableMenuItem(needUpgrade, () => creating.set(true), menuIcon(\"Folder\"), t(\"Create workspace\"),\n      dom.cls(\"disabled\", use => !roles.canEdit(orgAccess) || !use(home.available)),\n      testId(\"dm-new-workspace\"),\n    ),\n    upgradeText(needUpgrade, () => home.app.showUpgradeModal()),\n  ];\n}\n\nfunction workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Workspace | null>) {\n  function deleteWorkspace() {\n    confirmModal(t(\"Delete {{workspace}} and all included documents?\", { workspace: ws.name }), t(\"Delete\"),\n      async () => {\n        let all = home.workspaces.get();\n        const index = all.findIndex(w => w.id === ws.id);\n        const selected = home.currentWSId.get() === ws.id;\n        await home.deleteWorkspace(ws.id, false);\n        // If workspace was not selected, don't do navigation.\n        if (!selected) { return; }\n        all = home.workspaces.get();\n        if (!all.length) {\n          // There was only one workspace, navigate to all docs.\n          await urlState().pushUrl({ homePage: \"all\" });\n        } else {\n          // Maintain the index.\n          const newIndex = Math.max(0, Math.min(index, all.length - 1));\n          await urlState().pushUrl({ ws: all[newIndex].id });\n        }\n      },\n      { explanation: t(\"Workspace will be moved to Trash.\") });\n  }\n\n  async function manageWorkspaceUsers() {\n    const api = home.app.api;\n    const user = home.app.currentUser;\n    (await loadUserManager()).showUserManagerModal(api, {\n      permissionData: api.getWorkspaceAccess(ws.id),\n      activeUser: user,\n      resourceType: \"workspace\",\n      resourceId: ws.id,\n      resource: ws,\n    });\n  }\n\n  const needUpgrade = home.app.currentFeatures?.maxWorkspacesPerOrg === 1;\n\n  return [\n    upgradableMenuItem(needUpgrade, () => renaming.set(ws), t(\"Rename\"),\n      dom.cls(\"disabled\", !roles.canEdit(ws.access)),\n      testId(\"dm-rename-workspace\")),\n    upgradableMenuItem(needUpgrade, deleteWorkspace, t(\"Delete\"),\n      dom.cls(\"disabled\", user => !roles.canEdit(ws.access)),\n      testId(\"dm-delete-workspace\")),\n    // TODO: Personal plans can't currently share workspaces, but that restriction\n    // should formally be documented and defined in `Features`, with this check updated\n    // to look there instead.\n    home.app.isPersonal ? null : upgradableMenuItem(needUpgrade, manageWorkspaceUsers,\n      roles.canEditAccess(ws.access) ? t(\"Manage users\") : t(\"Access Details\"),\n      testId(\"dm-workspace-access\")),\n    upgradeText(needUpgrade, () => home.app.showUpgradeModal()),\n  ];\n}\n\n// Below are all the styled elements.\n\nconst cssContent = styled(cssLeftPanel, `\n  --page-icon-margin: 12px;\n`);\n\nexport const cssEditorInput = styled(transientInput, `\n  height: 24px;\n  flex: 1 1 0px;\n  min-width: 0px;\n  background-color: ${theme.inputBg};\n  margin-right: 16px;\n  font-size: inherit;\n`);\n"
  },
  {
    "path": "app/client/ui/IAssistantPopup.ts",
    "content": "import { AssistantState } from \"app/common/ActiveDocAPI\";\n\nimport { Disposable } from \"grainjs\";\n\nexport interface IAssistantPopup extends Disposable {\n  open: () => void;\n  setState(state: AssistantState): void;\n}\n"
  },
  {
    "path": "app/client/ui/ImportProgress.ts",
    "content": "import { IProgress } from \"app/client/models/NotifyModel\";\n\nimport { Disposable } from \"grainjs\";\n\nexport class ImportProgress extends Disposable {\n  // Import does upload first, then import. We show a single indicator, estimating which fraction\n  // of the time should be given to upload (whose progress we can report well), and which to the\n  // subsequent import (whose progress indicator is mostly faked).\n  private _uploadFraction: number;\n  private _estImportSeconds: number;\n\n  private _importTimer: null | ReturnType<typeof setInterval> = null;\n  private _importStart: number = 0;\n\n  constructor(private _progressUI: IProgress, file: File) {\n    super();\n    // We'll assume that for .grist files, the upload takes 90% of the total time, and for other\n    // files, 40%.\n    this._uploadFraction = file.name.endsWith(\".grist\") ? 0.9 : 0.4;\n\n    // TODO: Import step should include a progress callback, to be combined with upload progress.\n    // Without it, we estimate import to take 2s per MB (non-scientific unreliable estimate), and\n    // use an asymptotic indicator which keeps moving without ever finishing. Not terribly useful,\n    // but does slow down for larger files, and is more comforting than a stuck indicator.\n    this._estImportSeconds = file.size / 1024 / 1024 * 2;\n\n    this._progressUI.setProgress(0);\n    this.onDispose(() => this._importTimer && clearInterval(this._importTimer));\n  }\n\n  // Once this reaches 100, the import stage begins.\n  public setUploadProgress(percentage: number) {\n    this._progressUI.setProgress(percentage * this._uploadFraction);\n    if (percentage >= 100 && !this._importTimer) {\n      this._importStart = Date.now();\n      this._importTimer = setInterval(() => this._onImportTimer(), 100);\n    }\n  }\n\n  public finish() {\n    if (this._importTimer) {\n      clearInterval(this._importTimer);\n    }\n    this._progressUI.setProgress(100);\n  }\n\n  /**\n   * Calls _progressUI.setProgress(percent) with percentage increasing from 0 and asymptotically\n   * approaching 100, reaching 50% after estSeconds. It's intended to look reasonable when the\n   * estimate is good, and to keep showing slowing progress even if it's not.\n   */\n  private _onImportTimer() {\n    const elapsedSeconds = (Date.now() - this._importStart) / 1000;\n    const importProgress = elapsedSeconds / (elapsedSeconds + this._estImportSeconds);\n    const progress = this._uploadFraction + importProgress * (1 - this._uploadFraction);\n    this._progressUI.setProgress(100 * progress);\n  }\n}\n"
  },
  {
    "path": "app/client/ui/LanguageMenu.ts",
    "content": "import { detectCurrentLang, makeT, setAnonymousLocale } from \"app/client/lib/localization\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { cssHoverCircle } from \"app/client/ui/TopBarCss\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu, menuItem } from \"app/client/ui2018/menus\";\nimport { getCountryCode } from \"app/common/Locales\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { dom, makeTestId, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-language-\");\nconst t = makeT(\"LanguageMenu\");\n\nexport function buildLanguageMenu(appModel: AppModel) {\n  // Get the list of languages from the config, or default to English.\n  const languages = getGristConfig().supportedLngs ?? [\"en\"];\n  // Get the current language (from user's preference, cookie or browser)\n  const userLanguage = detectCurrentLang();\n\n  if (appModel.currentValidUser) {\n    // For logged in users, we don't need to show the menu (they have a preference in their profile).\n    // But for tests we will show a hidden indicator.\n    return dom(\"input\", { type: \"hidden\" }, (testId(`current-` + userLanguage)));\n  }\n\n  // When we switch language, we need to reload the page to get the new translations.\n  // This button is only for anonymous users, so we don't need to save the preference or wait for anything.\n  const changeLanguage = (lng: string) => {\n    setAnonymousLocale(lng);\n    window.location.reload();\n  };\n  const flagIcon = buildFlagIcon(userLanguage);\n  return cssFlagButton(\n    // Flag or emoji flag if we have it.\n    cssFlagIconWrapper(flagIcon),\n    // Expose for test the current language use.\n    testId(`current-` + userLanguage),\n    menu(\n      // Convert the list of languages we support to menu items.\n      () => languages.map(lng => menuItem(() => changeLanguage(lng), [\n        // Try to convert the locale to nice name, fallback to locale itself.\n        cssFirstUpper(translateLocale(lng) ?? lng),\n        // If this is current language, mark it with a tick (by default we mark en).\n        userLanguage === lng ? cssWrapper(icon(\"Tick\"), testId(\"selected\")) : null,\n        testId(`lang-` + lng),\n      ])),\n      {\n        placement: \"bottom-end\",\n      },\n    ),\n    hoverTooltip(t(\"Language\"), { key: \"topBarBtnTooltip\" }),\n    testId(\"button\"),\n  );\n}\n\nfunction buildFlagIcon(locale: string) {\n  const countryCode = getCountryCode(locale);\n  return [\n    // Try to show an icon of the country's flag. (The icon may not exist.)\n    !countryCode ? null : cssFlagIcon({\n      // Unfortunately, Windows doesn't support emoji flags, so we need to use SVG icons.\n      style: `background-image: url(\"icons/locales/${countryCode}.svg\");`,\n    }, testId(\"button-icon\")),\n    // Display a placeholder icon behind the one above, to act as a fallback.\n    cssPlaceholderFlagIcon(\"Flag\"),\n  ];\n}\n\nexport function translateLocale(locale: string) {\n  try {\n    locale = locale.replace(\"_\", \"-\");\n    // This API might not be available in all browsers.\n    const languageNames = new Intl.DisplayNames([locale], { type: \"language\" });\n    return languageNames.of(locale) || null;\n  } catch (err) {\n    return null;\n  }\n}\n\nconst cssWrapper = styled(\"div\", `\n  margin-left: auto;\n  display: inline-block;\n`);\n\nconst cssFirstUpper = styled(\"span\", `\n  &::first-letter {\n    text-transform: capitalize;\n  }\n`);\n\nconst cssFlagButton = styled(cssHoverCircle, `\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  margin: 5px;\n  cursor: pointer;\n`);\n\nconst cssFlagIconWrapper = styled(\"div\", `\n  position: relative;\n  width: 16px;\n  height: 16px;\n`);\n\nconst cssFlagIcon = styled(\"div\", `\n  position: absolute;\n  width: 16px;\n  height: 16px;\n  background-repeat: no-repeat;\n  background-position: center;\n  background-color: transparent;\n  background-size: contain;\n  z-index: 1;\n`);\n\nconst cssPlaceholderFlagIcon = styled(icon, `\n  vertical-align: initial;\n  --icon-color: ${theme.topBarButtonPrimaryFg};\n`);\n"
  },
  {
    "path": "app/client/ui/LeftPanelCommon.ts",
    "content": "/**\n * These styles are used in HomeLeftPanel, and in Tools for the document left panel.\n * They work in a structure like this:\n *\n *    import * as css from 'app/client/ui/LeftPanelStyles';\n *    css.cssLeftPanel(\n *      css.cssScrollPane(\n *        css.cssTools(\n *          css.cssSectionHeader(...),\n *          css.cssPageEntry(css.cssPageLink(cssPageIcon(...), css.cssLinkText(...))),\n *          css.cssPageEntry(css.cssPageLink(cssPageIcon(...), css.cssLinkText(...))),\n *        )\n *      )\n *    )\n */\nimport { allCommands } from \"app/client/components/commands\";\nimport { beaconOpenMessage } from \"app/client/lib/helpScout\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { colorIcon, icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { visuallyHidden } from \"app/client/ui2018/visuallyHidden\";\nimport { commonUrls, isFeatureEnabled } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { dom, DomContents, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"LeftPanelCommon\");\n\n/**\n * Creates the \"help tools\", a button/link to open HelpScout beacon, and one to open the\n * HelpCenter in a new tab.\n */\nexport function createHelpTools(appModel: AppModel): DomContents {\n  if (!isFeatureEnabled(\"helpCenter\")) {\n    return [];\n  }\n  const { deploymentType } = getGristConfig();\n  return cssSplitPageEntry(\n    cssPageEntryMain(\n      cssPageLink(cssPageIcon(\"Help\"),\n        cssLinkText(t(\"Help Center\")),\n        dom.cls(\"tour-help-center\"),\n        deploymentType === \"saas\" ?\n          dom.on(\"click\", () => beaconOpenMessage({ appModel })) :\n          { href: commonUrls.help, target: \"_blank\" },\n        testId(\"left-feedback\"),\n      ),\n    ),\n    cssPageEntrySmall(\n      cssPageLink(cssPageIcon(\"FieldLink\"),\n        { \"href\": commonUrls.help, \"aria-label\": t(\"Help Center\"), \"target\": \"_blank\" },\n      ),\n    ),\n  );\n}\n\nexport function createAccessibilityTools(): DomContents {\n  // The accessibility is sometimes not available, make sure to not render the button in that case\n  // (e.g. when rendering error pages)\n  if (!allCommands.accessibility) {\n    return [];\n  }\n  return cssPageEntry(\n    cssPageButton(\n      cssPageIcon(\"Accessibility\"),\n      // always have an accessible label in case we hide the text (collapsed panel)\n      visuallyHidden(t(\"Accessibility\")),\n      // hide the visible text from screen readers to prevent duplicate labels with the visually hidden one\n      cssLinkText(t(\"Accessibility\"), { \"aria-hidden\": \"true\" }),\n      cssKeyboardShortcut(\n        \"F4\",\n        testId(\"accessibility-shortcut-keys\"),\n      ),\n      dom.on(\"click\", () => allCommands.accessibility.run()),\n      testId(\"accessibility-shortcut\"),\n    ),\n  );\n}\n\n/**\n * Creates a basic left panel, used in error and billing pages. It only contains the help tools.\n * You can provide optional content to include above the help tools.\n */\nexport function leftPanelBasic(appModel: AppModel, panelOpen: Observable<boolean>, optContent: DomContents = null) {\n  return cssLeftPanel(\n    cssScrollPane(\n      optContent,\n      cssTools(\n        cssTools.cls(\"-collapsed\", use => !use(panelOpen)),\n        cssSpacer(),\n        createHelpTools(appModel),\n        createAccessibilityTools(),\n      ),\n    ),\n  );\n}\n\nexport const cssLeftPanel = styled(\"div\", `\n  flex: 1 1 0px;\n  font-size: ${vars.mediumFontSize};\n  display: flex;\n  flex-direction: column;\n`);\n\nexport const cssScrollPane = styled(\"div\", `\n  flex: 1 1 0px;\n  overflow: hidden auto;\n  display: flex;\n  flex-direction: column;\n`);\n\nexport const cssTools = styled(\"nav\", `\n  flex: none;\n  margin-top: auto;\n  padding: 16px 0 16px 0;\n  cursor: default;\n`);\n\nexport const cssHomeTools = styled(cssTools, `\n  padding-top: 0px;\n  border-top: 1px solid ${theme.pagePanelsBorder};\n`);\n\nexport const cssSectionHeader = styled(\"div\", `\n  margin: 24px 0 8px 24px;\n  display: flex;\n  gap: 8px;\n  align-items: center;\n  .${cssTools.className}-collapsed > & {\n    visibility: hidden;\n  }\n`);\n\nexport const cssSectionHeaderText = styled(\"span\", `\n  color: ${theme.lightText};\n  text-transform: uppercase;\n  font-weight: 500;\n  font-size: ${vars.xsmallFontSize};\n  letter-spacing: 1px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n`);\n\nexport const cssPageEntry = styled(\"div\", `\n  margin: 0px 16px 0px 0px;\n  border-radius: 0 3px 3px 0;\n  color: ${theme.text};\n  --icon-color: ${theme.lightText};\n  cursor: default;\n\n  &:hover, &.weasel-popup-open, &-renaming {\n    background-color: ${theme.pageHoverBg};\n  }\n  &-selected, &-selected:hover, &-selected.weasel-popup-open {\n    background-color: ${theme.activePageBg};\n    color: ${theme.activePageFg};\n    --icon-color: ${theme.activePageFg};\n  }\n  &-disabled, &-disabled:hover, &-disabled.weasel-popup-open {\n    background-color: initial;\n    color: ${theme.disabledPageFg};\n    --icon-color: ${theme.disabledPageFg};\n  }\n  .${cssTools.className}-collapsed > & {\n    margin-right: 0;\n  }\n`);\n\nconst cssPageAction = `\n  position: relative;\n  z-index: 1;\n  display: flex;\n  align-items: center;\n  height: 32px;\n  line-height: 32px;\n  padding-left: 24px;\n  outline: none;\n  cursor: pointer;\n  outline-offset: -3px;\n  width: 100%;\n  &, &:hover, &:focus, & a, & a:hover, & a:focus {\n    text-decoration: none;\n    outline: none;\n    color: inherit;\n  }\n  .${cssPageEntry.className}-disabled & {\n    cursor: default;\n  }\n  .${cssTools.className}-collapsed & {\n    padding-left: 16px;\n  }\n`;\n\nexport const cssPageLink = styled(\"a\", cssPageAction);\n\nexport const cssPageLinkContainer = styled(\"div\", `\n  ${cssPageAction}\n\n  .${cssPageEntry.className}-disabled & :is(a, button) {\n    cursor: default;\n  }\n`);\n\nexport const cssPageButton = styled(unstyledButton, cssPageAction);\n\nexport const cssLinkText = styled(\"span\", `\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  .${cssTools.className}-collapsed & {\n    display: none;\n  }\n`);\n\nexport const cssPageIcon = styled(icon, `\n  flex: none;\n  margin-right: var(--page-icon-margin, 8px);\n  .${cssTools.className}-collapsed & {\n    margin-right: 0;\n  }\n`);\n\nexport const cssKeyboardShortcut = styled(\"span\", `\n  margin-left: auto;\n  margin-right: 16px;\n  color: ${theme.lightText};\n  text-transform: uppercase;\n\n  .${cssPageButton.className}:hover &,\n  .${cssPageButton.className}:focus & {\n    color: inherit;\n  }\n  .${cssTools.className}-collapsed & {\n    position: absolute;\n    line-height: 1;\n    top: 3px;\n    right: 3px;\n    margin: 0;\n    font-size: 0.8em;\n  }\n`);\n\nexport const cssPageColorIcon = styled(colorIcon, `\n  flex: none;\n  margin-right: var(--page-icon-margin, 8px);\n  .${cssTools.className}-collapsed & {\n    margin-right: 0;\n  }\n`);\n\nexport const cssSpacer = styled(\"div\", `\n  height: 18px;\n`);\n\nexport const cssSplitPageEntry = styled(\"div\", `\n  display: flex;\n  align-items: center;\n`);\n\nexport const cssPageEntryMain = styled(cssPageEntry, `\n  flex: auto;\n  margin: 0;\n  min-width: 0px;\n`);\n\nexport const cssPageEntrySmall = styled(cssPageEntry, `\n  flex: none;\n  border-radius: 3px;\n  --icon-color: ${theme.controlFg};\n  --page-icon-margin: 0;\n  & > .${cssPageLink.className}, & > .${cssPageButton.className} {\n    padding: 0 16px 0 16px;\n  }\n  &:hover {\n    --icon-color: ${theme.controlHoverFg};\n  }\n  .${cssTools.className}-collapsed & {\n    display: none;\n  }\n`);\n\nexport const cssMenuTrigger = styled(unstyledButton, `\n  position: relative;\n  z-index: 2;\n  margin: 0 4px 0 auto;\n  height: 24px;\n  width: 24px;\n  padding: 4px;\n  line-height: 0px;\n  border-radius: 3px;\n  cursor: default;\n  display: none;\n  .${cssPageLinkContainer.className}:hover > &,\n  .${cssPageLinkContainer.className}:focus-within > &,\n  .${cssPageLink.className}:hover > &,\n  .${cssPageLink.className}:focus-within > &,\n  &.weasel-popup-open {\n    display: block;\n  }\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.pageOptionsHoverBg};\n  }\n  .${cssPageEntry.className}-selected &:hover, .${cssPageEntry.className}-selected &.weasel-popup-open {\n    background-color: ${theme.pageOptionsSelectedHoverBg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/LinkConfig.ts",
    "content": "import { ColumnRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { getReferencedTableId } from \"app/common/gristTypes\";\n\nimport assert from \"assert\";\n\nexport class LinkConfig {\n  public readonly srcSection: ViewSectionRec;\n  public readonly tgtSection: ViewSectionRec;\n  // Note that srcCol and tgtCol may be the empty column records if that column is not used.\n  public readonly srcCol: ColumnRec;\n  public readonly tgtCol: ColumnRec;\n  public readonly srcColId: string | undefined;\n  public readonly tgtColId: string | undefined;\n\n  // The constructor throws an exception if settings are invalid. When used from inside a knockout\n  // computed, the constructor subscribes to all parts relevant for linking.\n  constructor(tgtSection: ViewSectionRec) {\n    this.tgtCol = tgtSection.linkTargetCol();\n    this.srcCol = tgtSection.linkSrcCol();\n    this.srcSection = tgtSection.linkSrcSection();\n    this.tgtSection = tgtSection;\n    this.srcColId = this.srcCol.colId();\n    this.tgtColId = this.tgtCol.colId();\n    this._assertValid();\n  }\n\n  // Check if section-linking configuration is valid, and throw exception if not.\n  private _assertValid(): void {\n    // Use null for unset cols (rather than an empty ColumnRec) for easier comparisons below.\n    const srcCol = this.srcCol?.getRowId() ? this.srcCol : null;\n    const tgtCol = this.tgtCol?.getRowId() ? this.tgtCol : null;\n    const srcTableId = (srcCol ? getReferencedTableId(srcCol.type()) :\n      this.srcSection.table().primaryTableId());\n    const tgtTableId = (tgtCol ? getReferencedTableId(tgtCol.type()) :\n      this.tgtSection.table().primaryTableId());\n    const srcTableSummarySourceTable = this.srcSection.table().summarySourceTable();\n    const tgtTableSummarySourceTable = this.tgtSection.table().summarySourceTable();\n    try {\n      assert(Boolean(this.srcSection.getRowId()), \"srcSection was disposed\");\n      assert(!tgtCol || tgtCol.parentId() === this.tgtSection.tableRef(), \"tgtCol belongs to wrong table\");\n      assert(!srcCol || srcCol.parentId() === this.srcSection.tableRef(), \"srcCol belongs to wrong table\");\n      assert(this.srcSection.getRowId() !== this.tgtSection.getRowId(), \"srcSection links to itself\");\n\n      // We usually expect srcTableId and tgtTableId to be non-empty, but there's one exception:\n      // when linking two summary tables that share a source table (which we can check directly)\n      // and the source table is hidden by ACL, so its tableId is empty from our perspective.\n      if (!(srcTableSummarySourceTable !== 0 && srcTableSummarySourceTable === tgtTableSummarySourceTable)) {\n        assert(tgtTableId, \"tgtCol not a valid reference\");\n        assert(srcTableId, \"srcCol not a valid reference\");\n      }\n      assert(srcTableId === tgtTableId, \"mismatched tableIds\");\n\n      // If this section has a custom link filter, it can't create cycles.\n      if (this.tgtSection.selectedRowsActive()) {\n        // Make sure we don't have a cycle.\n        let src = this.tgtSection.linkSrcSection();\n        while (!src.isDisposed() && src.getRowId()) {\n          assert(src.getRowId() !== this.srcSection.getRowId(),\n            \"Sections with filter linking can't be part of a cycle (same record linking)'\");\n          src = src.linkSrcSection();\n        }\n      }\n    } catch (e) {\n      throw new Error(`LinkConfig invalid: ` +\n        `${this.srcSection.getRowId()}:${this.srcCol?.getRowId()}[${srcTableId}] -> ` +\n        `${this.tgtSection.getRowId()}:${this.tgtCol?.getRowId()}[${tgtTableId}]: ${e}`);\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/ui/LoginPagesCss.ts",
    "content": "import { textInput } from \"app/client/ui/inputs\";\nimport {\n  bigBasicButton as gristBigBasicButton,\n  bigBasicButtonLink as gristBigBasicButtonLink,\n  bigPrimaryButton as gristBigPrimaryButton,\n  bigPrimaryButtonLink as gristBigPrimaryButtonLink,\n  textButton as gristTextButton,\n} from \"app/client/ui2018/buttons\";\nimport { mediaXSmall, theme } from \"app/client/ui2018/cssVars\";\n\nimport { styled } from \"grainjs\";\n\nexport const text = styled(\"div\", `\n  color: ${theme.text};\n  font-weight: 400;\n  line-height: 20px;\n  font-size: 14px;\n`);\n\nexport const lightText = styled(text, `\n  color: ${theme.lightText};\n`);\n\nexport const lightColor = styled(\"span\", `\n  color: ${theme.lightText};\n`);\n\nexport const centeredText = styled(text, `\n  text-align: center;\n`);\n\nexport const lightlyBolded = styled(\"span\", `\n  font-weight: 500;\n`);\n\nexport const input = textInput;\n\nexport const codeInput = styled(input, `\n  width: 200px;\n`);\n\nexport const label = styled(\"label\", `\n  color: ${theme.text};\n  display: inline-block;\n  line-height: 20px;\n  font-size: 14px;\n  font-weight: 500;\n`);\n\nexport const formLabel = styled(label, `\n  margin-bottom: 8px;\n`);\n\nexport const googleButton = styled(\"button\", `\n  /* Resets */\n  position: relative;\n  border-style: none;\n\n  /* Vars */\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  height: 48px;\n  gap: 12px;\n  font-size: 15px;\n  font-weight: 500;\n  line-height: 16px;\n  padding: 16px;\n  color: ${theme.loginPageGoogleButtonFg};\n  background-color: ${theme.loginPageGoogleButtonBg};\n  border: 1px solid ${theme.loginPageGoogleButtonBorder};\n  border-radius: 4px;\n  cursor: pointer;\n  width: 100%;\n\n  &:hover {\n    background-color: ${theme.loginPageGoogleButtonBgHover};\n  }\n`);\n\nexport const image = styled(\"div\", `\n  display: inline-block;\n  background-size: contain;\n  background-repeat: no-repeat;\n  background-position: center;\n`);\n\nexport const gristLogo = styled(image, `\n  width: 35px;\n  height: 32px;\n  background-image: var(--icon-GristLogo);\n`);\n\nexport const googleLogo = styled(image, `\n  width: 24px;\n  height: 24px;\n  background-image: var(--icon-GoogleLogo);\n`);\n\nexport const loginMethodsSeparator = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin: 24px 0px 24px 0px;\n`);\n\nexport const horizontalLine = styled(\"hr\", `\n  border: 1px solid ${theme.loginPageLine};\n  flex-grow: 1;\n`);\n\nconst buttonCommonStyles = `\n  /* TODO: Remove once Grist buttons have outlines. */\n  outline: revert;\n\n  font-weight: 500;\n  font-size: 15px;\n  line-height: 16px;\n`;\n\nconst buttonStyles = buttonCommonStyles + `\n  height: 48px;\n`;\n\nconst buttonLinkStyles = buttonCommonStyles + `\n  padding: 16px 32px 16px 32px;\n`;\n\nexport const bigBasicButton = styled(gristBigBasicButton, buttonStyles);\n\nexport const bigBasicButtonLink = styled(gristBigBasicButtonLink, buttonLinkStyles);\n\nexport const bigPrimaryButton = styled(gristBigPrimaryButton, buttonStyles);\n\nexport const bigPrimaryButtonLink = styled(gristBigPrimaryButtonLink, buttonLinkStyles);\n\nexport const textButton = styled(gristTextButton, `\n  outline: revert;\n  font-size: 14px;\n`);\n\nexport const pageContainer = styled(\"div\", `\n  height: 100%;\n  overflow: auto;\n  background-color: ${theme.loginPageBackdrop};\n\n  @media ${mediaXSmall} {\n    & {\n      background-color: ${theme.loginPageBg};\n    }\n  }\n`);\n\nexport const flexJustifyCenter = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n`);\n\nexport const formContainer = styled(\"div\", `\n  background-color: ${theme.loginPageBg};\n  max-width: 576px;\n  width: 100%;\n  margin: 60px 25px 60px 25px;\n  padding: 40px 56px 40px 56px;\n  border-radius: 8px;\n\n  @media ${mediaXSmall} {\n    & {\n      margin: 0px;\n      padding: 25px 20px 25px 20px;\n    }\n  }\n`);\n\nexport const formHeading = styled(\"div\", `\n  font-weight: 500;\n  font-size: 32px;\n  line-height: 40px;\n  margin-bottom: 8px;\n  color: ${theme.text};\n\n  @media ${mediaXSmall} {\n    & {\n      font-size: 24px;\n      line-height: 32px;\n    }\n  }\n`);\n\nexport const centeredFormHeading = styled(formHeading, `\n  margin-top: 16px;\n  text-align: center;\n`);\n\nexport const centeredFormSubheading = styled(centeredText, `\n  margin-bottom: 28px;\n`);\n\nexport const formInstructions = styled(\"div\", `\n  margin-bottom: 32px;\n`);\n\nexport const formError = styled(text, `\n  color: ${theme.errorText};\n  margin-bottom: 16px;\n`);\n\nexport const centeredFormError = styled(formError, `\n  text-align: center;\n`);\n\nexport const formButtons = styled(\"div\", `\n  margin: 32px 0px 0px 0px;\n`);\n\nexport const formFooter = styled(text, `\n  margin-top: 24px;\n`);\n\nexport const formBody = styled(\"div\", ``);\n\nexport const resendCode = styled(text, `\n  margin-top: 16px;\n`);\n\nexport const spinner = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  width: 100%;\n  height: 250px;\n`);\n"
  },
  {
    "path": "app/client/ui/MakeCopyMenu.ts",
    "content": "/**\n * Link or button that opens a menu to make a copy of a document, full or empty. It's used for\n * the sample documents (those in the Support user's Examples & Templates workspace).\n */\n\nimport { hooks } from \"app/client/Hooks\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { AppModel, reportError } from \"app/client/models/AppModel\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { getWorkspaceInfo, ownerName, workspaceName } from \"app/client/models/WorkspaceInfo\";\nimport { cssInput } from \"app/client/ui/cssInput\";\nimport { bigBasicButton, bigPrimaryButtonLink } from \"app/client/ui2018/buttons\";\nimport {\n  cssRadioCheckboxOptions,\n  labeledSquareCheckbox,\n  radioCheckboxOption,\n} from \"app/client/ui2018/checkbox\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { IOptionFull, select } from \"app/client/ui2018/menus\";\nimport {\n  confirmModal,\n  cssModalBody,\n  cssModalButtons,\n  cssModalTitle,\n  modal,\n  saveModal,\n} from \"app/client/ui2018/modals\";\nimport * as roles from \"app/common/roles\";\nimport { components, tokens } from \"app/common/ThemePrefs\";\nimport {\n  CreatableArchiveFormats,\n  DocAttachmentsLocation,\n  Document,\n  isTemplatesOrg,\n  Organization,\n  Workspace,\n} from \"app/common/UserAPI\";\n\nimport {\n  Computed,\n  Disposable,\n  dom,\n  input,\n  Observable,\n  styled,\n  subscribe,\n  subscribeElem,\n} from \"grainjs\";\nimport sortBy from \"lodash/sortBy\";\n\nconst t = makeT(\"MakeCopyMenu\");\n\nexport async function replaceTrunkWithFork(doc: Document, pageModel: DocPageModel, origUrlId: string) {\n  const { appModel } = pageModel;\n  const trunkAccess = (await appModel.api.getDoc(origUrlId)).access;\n  if (!roles.canEdit(trunkAccess)) {\n    modal(ctl => [\n      cssModalBody(t(\"Replacing the original requires editing rights on the original document.\")),\n      cssModalButtons(\n        bigBasicButton(t(\"Cancel\"), dom.on(\"click\", () => ctl.close())),\n      ),\n    ]);\n    return;\n  }\n  const docApi = appModel.api.getDocAPI(origUrlId);\n  const cmp = await docApi.compareDoc(doc.id);\n  let titleText = t(\"Update Original\");\n  let buttonText = t(\"Update\");\n  let warningText = t(\"The original version of this document will be updated.\");\n  if (cmp.summary === \"left\" || cmp.summary === \"both\") {\n    titleText = t(\"Original Has Modifications\");\n    buttonText = t(\"Overwrite\");\n    warningText = `${warningText} ${t(\"Be careful, the original has changes \\\nnot in this document. Those changes will be overwritten.\")}`;\n  } else if (cmp.summary === \"unrelated\") {\n    titleText = t(\"Original Looks Unrelated\");\n    buttonText = t(\"Overwrite\");\n    warningText = `${warningText} ${t(\"It will be overwritten, losing any content not in this document.\")}`;\n  } else if (cmp.summary === \"same\") {\n    titleText = t(\"Original Looks Identical\");\n    warningText = `${warningText} ${t(\"However, it appears to be already identical.\")}`;\n  }\n  confirmModal(titleText, buttonText,\n    async () => {\n      try {\n        await docApi.replace({ sourceDocId: doc.id });\n        pageModel.clearUnsavedChanges();\n        await urlState().pushUrl({ doc: origUrlId });\n      } catch (e) {\n        reportError(e);  // For example: no write access on trunk.\n      }\n    }, { explanation: warningText });\n}\n\n/**\n * Whether we should offer user the option to copy this doc to other orgs.\n * We allow copying out of source org when the source org is a personal org, or user has owner\n * access to the doc, or the doc is public.\n */\nfunction allowOtherOrgs(doc: Document, app: AppModel): boolean {\n  const org = app.currentOrg;\n  const isPersonalOrg = Boolean(org?.owner);\n  // We allow copying out of a personal org.\n  if (isPersonalOrg) { return true; }\n  // Otherwise, it's a proper org. Allow copying out if the doc is public or if the user has\n  // owner access to it. In case of a fork, it's the owner access to trunk that matters.\n  if (doc.public || roles.canEditAccess(doc.trunkAccess || doc.access)) { return true; }\n  // For non-public docs on a team site, non-privileged users are not allowed to copy them out.\n  return false;\n}\n\n/**\n * Ask user for the destination and new name, and make a copy of the doc using those.\n */\nexport async function makeCopy(options: {\n  pageModel: DocPageModel,\n  doc: Document,\n  modalTitle: string,\n}): Promise<void> {\n  const { pageModel, doc, modalTitle } = options;\n  const { appModel } = pageModel;\n  let orgs = allowOtherOrgs(doc, appModel) ? await appModel.api.getOrgs(true) : null;\n  if (orgs) {\n    // Don't show the templates org since it's selected by default, and\n    // is not writable to.\n    orgs = orgs.filter(o => !isTemplatesOrg(o));\n  }\n\n  // Show a dialog with a form to select destination.\n  saveModal((ctl, owner) => {\n    const saveCopyModal = SaveCopyModal.create(owner, { pageModel, doc, orgs });\n    return {\n      title: modalTitle,\n      body: saveCopyModal.buildDom(),\n      saveFunc: () => saveCopyModal.save(),\n      saveDisabled: saveCopyModal.saveDisabled,\n      width: \"normal\",\n    };\n  });\n}\n\ninterface SaveCopyModalParams {\n  pageModel: DocPageModel;\n  doc: Document;\n  orgs: Organization[] | null;\n}\n\nclass SaveCopyModal extends Disposable {\n  private _pageModel = this._params.pageModel;\n  private _app = this._pageModel.appModel;\n  private _doc = this._params.doc;\n  private _orgs = this._params.orgs;\n  private _workspaces = Observable.create<Workspace[] | null>(this, null);\n  private _destName = Observable.create<string>(this, \"\");\n  private _destOrg = Observable.create<Organization | null>(this, this._app.currentOrg);\n  private _destWS = Observable.create<Workspace | null>(this, this._doc.workspace);\n  private _asTemplate = Observable.create<boolean>(this, false);\n  private _saveDisabled = Computed.create(this, this._destWS, this._destName, (use, ws, name) =>\n    (!name.trim() || !ws || !roles.canEdit(ws.access)));\n\n  private _showWorkspaces = Computed.create(this, this._destOrg, (use, org) => {\n    // Workspace are available for personal and team sites now, but there are legacy sites without it.\n    // Make best effort to figure out if they are disabled, but if we don't have the info, show the selector.\n    if (!org) {\n      return false;\n    }\n    // We won't have info about any other org except the one we are at.\n    if (org.id === this._app.currentOrg?.id) {\n      const workspaces = this._app.currentFeatures?.workspaces ?? true;\n      const numberAllowed = this._app.currentFeatures?.maxWorkspacesPerOrg ?? 2;\n      return workspaces && numberAllowed > 1;\n    }\n    return true;\n  });\n\n  // If orgs is non-null, then we show a selector for orgs.\n  constructor(private _params: SaveCopyModalParams) {\n    super();\n    if (this._doc.name !== \"Untitled\") {\n      this._destName.set(this._doc.name + \" (copy)\");\n    }\n    if (this._orgs && this._app.currentOrg) {\n      // Set _destOrg to an Organization object from _orgs array; there should be one equivalent\n      // to currentOrg, but we need the actual object for select() to recognize it as selected.\n      const orgId = this._app.currentOrg.id;\n      const newOrg = this._orgs.find(org => org.id === orgId) || this._orgs[0];\n      this._destOrg.set(newOrg);\n    }\n    this.autoDispose(subscribe(this._destOrg, (use, org) => this._updateWorkspaces(org).catch(reportError)));\n  }\n\n  public get saveDisabled() { return this._saveDisabled; }\n\n  public async save() {\n    const ws = this._destWS.get();\n    if (!ws) { throw new Error(t(\"No destination workspace\")); }\n    const api = this._app.api;\n    const org = this._destOrg.get();\n    const destName = this._destName.get();\n    try {\n      const doc = await api.copyDoc(this._doc.id, ws.id, {\n        documentName: destName,\n        asTemplate: this._asTemplate.get(),\n      });\n      this._pageModel.clearUnsavedChanges();\n      await urlState().pushUrl({ org: org?.domain || undefined, doc, docPage: urlState().state.get().docPage });\n    } catch (err) {\n      // Convert access denied errors to normal Error to make it consistent with other endpoints.\n      // TODO: Should not allow to click this button when user doesn't have permissions.\n      if (err.status === 403) {\n        throw new Error(err.details.userError || err.message);\n      }\n      throw err;\n    }\n  }\n\n  public buildDom() {\n    return [\n      cssField(\n        cssLabel(t(\"Name\")),\n        input(this._destName,\n          { onInput: true },\n          { placeholder: t(\"Enter document name\") },\n          dom.cls(cssInput.className),\n          // modal dialog grabs focus after 10ms delay; so to focus this input, wait a bit longer\n          // (see the TODO in app/client/ui2018/modals.ts about weasel.js and focus).\n          (elem) => { setTimeout(() => { elem.focus(); }, 20); },\n          dom.on(\"focus\", (ev, elem) => { elem.select(); }),\n          testId(\"copy-dest-name\")),\n      ),\n      cssField(\n        cssLabel(t(\"As template\")),\n        cssCheckbox(this._asTemplate, t(\"Include the structure without any of the data.\"),\n          testId(\"save-as-template\")),\n      ),\n      // Show the team picker only when saving to other teams is allowed and there are other teams\n      // accessible.\n      (this._orgs ?\n        cssField(\n          cssLabel(t(\"Organization\")),\n          select(this._destOrg, this._orgs.map(value => ({ value, label: value.name }))),\n          testId(\"copy-dest-org\"),\n        ) : null\n      ),\n      // Don't show the workspace picker when destOrg is a personal site and there is just one\n      // workspace, since workspaces are not a feature of personal orgs.\n      // Show the workspace picker only when destOrg is a team site, because personal orgs do not have workspaces.\n      dom.domComputed(use => use(this._showWorkspaces) && use(this._workspaces), wss =>\n        wss === false ? null :\n          wss?.length === 0 ? cssWarningText(t(\"You do not have write access to this site\"),\n            testId(\"copy-warning\")) :\n            [\n              cssField(\n                cssLabel(t(\"Workspace\")),\n                (wss === null ?\n                  cssSpinner(loadingSpinner()) :\n                  select(this._destWS, wss.map(value => ({\n                    value,\n                    label: workspaceName(this._app, value),\n                    disabled: !roles.canEdit(value.access),\n                  })))\n                ),\n                testId(\"copy-dest-workspace\"),\n              ),\n              wss ? dom.domComputed(this._destWS, destWs =>\n                destWs && !roles.canEdit(destWs.access) ?\n                  cssWarningText(t(\"You do not have write access to the selected workspace\"),\n                    testId(\"copy-warning\"),\n                  ) : null,\n              ) : null,\n            ],\n      ),\n    ];\n  }\n\n  /**\n   * Fetch a list of workspaces for the given org, in the same order in which we list them in\n   * HomeModel, and set this._workspaces to it. While fetching, this._workspaces is set to null.\n   * Once fetched, we also set this._destWS.\n   */\n  private async _updateWorkspaces(org: Organization | null) {\n    this._workspaces.set(null);     // Show that workspaces are loading.\n    this._destWS.set(null);         // Disable saving while waiting to set a new destination workspace.\n    try {\n      let wss = org ? await this._app.api.getOrgWorkspaces(org.id) : [];\n      if (this._destOrg.get() !== org) {\n        // We must have switched the org. Don't update anything; in particularr, keep _workspaces\n        // and _destWS as null, to show loading/save-disabled status. Let the new fetch update things.\n        return;\n      }\n      // Sort the same way that HomeModel sorts workspaces.\n      wss = sortBy(wss,\n        ws => [ws.isSupportWorkspace, ownerName(this._app, ws).toLowerCase(), ws.name.toLowerCase()]);\n      // Filter out isSupportWorkspace, since it's not writable and confusing to include.\n      // (The support user creating a new example can just download and upload.)\n      wss = wss.filter(ws => !ws.isSupportWorkspace);\n\n      let defaultWS: Workspace | undefined;\n      const showWorkspaces = (org && !org.owner);\n      if (showWorkspaces) {\n        // If we show a workspace selector, default to the current document's workspace (when its\n        // org is selected) even if it's not writable. User can switch the workspace manually.\n        defaultWS = wss.find(ws => (ws.id === this._doc.workspace.id));\n      } else {\n        // If the workspace selector is not shown (for personal orgs), prefer the user's default\n        // Home workspace as long as its writable.\n        defaultWS = wss.find(ws => getWorkspaceInfo(this._app, ws).isDefault && roles.canEdit(ws.access));\n      }\n      const firstWritable = wss.find(ws => roles.canEdit(ws.access));\n\n      // If there is at least one destination available, set one as the current selection.\n      // Otherwise, make it clear to the user that there are no options.\n      if (firstWritable) {\n        this._workspaces.set(wss);\n        this._destWS.set(defaultWS || firstWritable);\n      } else {\n        this._workspaces.set([]);\n        this._destWS.set(null);\n      }\n    } catch (e) {\n      this._workspaces.set([]);\n      this._destWS.set(null);\n      throw e;\n    }\n  }\n}\n\ntype DownloadOption = \"full\" | \"nohistory\" | \"template\";\n\nexport function downloadDocModal(doc: Document, appModel: AppModel) {\n  return modal((ctl, owner) => {\n    const docApi = appModel.api.getDocAPI(doc.id);\n    const selected = Observable.create<DownloadOption>(owner, \"full\");\n\n    const attachmentStatusObs = Observable.create<DocAttachmentsLocation | undefined | \"unknown\">(owner, undefined);\n    docApi.getAttachmentTransferStatus()\n      .then((status) => {\n        if (owner.isDisposed()) {\n          return;\n        }\n        attachmentStatusObs.set(status.locationSummary);\n      })\n      .catch((err) => {\n        if (owner.isDisposed()) {\n          return;\n        }\n        reportError(err);\n        attachmentStatusObs.set(\"unknown\");\n      });\n\n    const hasExternalAttachments =\n      Computed.create(owner, attachmentStatusObs, (use, status) => status !== \"internal\" && status !== \"none\");\n\n    const options = dom.domComputed(attachmentStatusObs, (status) => {\n      const isInternal = status === \"internal\" || status === \"none\";\n      const downloadText = isInternal ? t(\"Download full document and history\") : t(\"Download document and history\");\n      return cssRadioCheckboxOptions(\n        radioCheckboxOption(selected, \"full\", downloadText),\n        radioCheckboxOption(selected, \"nohistory\", t(\n          \"Download document without history (can significantly reduce file size)\",\n        )),\n        radioCheckboxOption(selected, \"template\", t(\"Download document structure only (no data, for template use)\")),\n      );\n    });\n\n    return [\n      cssModalTitle(t(`Download document`)),\n      dom.maybe(use => use(attachmentStatusObs) === undefined, () => cssSpinner(loadingSpinner())),\n      dom.maybe(use => use(attachmentStatusObs) !== undefined, () => [\n        options,\n        dom.maybe(hasExternalAttachments, () => cssAttachmentsWarning(\n          t(\n            \"Attachments are external and not included in this download. \\\nIf uploading the document to a separate Grist installation, \\\nyou will also need to {{downloadLink}} separately. \",\n            {\n              downloadLink: cssLink(t(\"download attachments\"), {\n                href: docApi.getDownloadAttachmentsArchiveUrl({ format: \"tar\" }),\n                target: \"_blank\",\n                download: \"\",\n              }),\n            },\n          ),\n          testId(\"external-attachments-info\"),\n        )),\n        cssCopyMenuModalButtons(\n          dom.domComputed((modalButtonUse) => {\n            const href = docApi.getDownloadUrl({\n              template: modalButtonUse(selected) === \"template\",\n              removeHistory: modalButtonUse(selected) === \"nohistory\" || modalButtonUse(selected) === \"template\",\n            });\n            return bigPrimaryButtonLink(t(`Download`), hooks.maybeModifyLinkAttrs({\n              href,\n              target: \"_blank\",\n              download: \"\",\n            }),\n            dom.on(\"click\", () => {\n              ctl.close();\n            }),\n            testId(\"download-button-link\"),\n            );\n          }),\n          bigBasicButton(t(\"Cancel\"), dom.on(\"click\", () => {\n            ctl.close();\n          })),\n        ),\n      ]),\n    ];\n  });\n}\n\nexport function downloadAttachmentsModal(doc: Document, pageModel: DocPageModel) {\n  return modal((ctl, owner) => {\n    const docApi = pageModel.appModel.api.getDocAPI(doc.id);\n\n    const attachmentStatusObs = Observable.create<DocAttachmentsLocation | undefined | \"unknown\">(owner, undefined);\n    docApi.getAttachmentTransferStatus()\n      .then((status) => {\n        if (owner.isDisposed()) {\n          return;\n        }\n        attachmentStatusObs.set(status.locationSummary);\n      })\n      .catch((err) => {\n        if (owner.isDisposed()) {\n          return;\n        }\n        reportError(err);\n        attachmentStatusObs.set(\"unknown\");\n      });\n\n    const isExternal = Computed.create(owner, attachmentStatusObs,\n      (use, status) => status !== \"none\" && status !== \"internal\",\n    );\n\n    const formatObs = Observable.create<CreatableArchiveFormats>(owner, \"tar\");\n    const allFormats: IOptionFull<CreatableArchiveFormats>[] = [\n      { value: \"tar\", label: t(\".tar (recommended)\") },\n      { value: \"zip\", label: t(\".zip\") },\n    ];\n    const attachmentArchiveDownloadHref: Computed<string> = Computed.create(owner, (use) => {\n      const format = use(formatObs);\n      return docApi.getDownloadAttachmentsArchiveUrl({ format });\n    });\n\n    return [\n      cssModalTitle(t(`Download attachments`)),\n      dom.maybe(use => use(attachmentStatusObs) === undefined, () => cssSpinner(loadingSpinner())),\n      dom.maybe(use => use(attachmentStatusObs) !== undefined, () => [\n        cssEagerWrap(dom(\"p\", t(\"Download an archive of all the attachments present in this document.\"))),\n        dom.maybe(isExternal, () => cssEagerWrap(dom(\"p\",\n          t(\n            'If you\\'re planning to upload this document to a Grist installation, \\\nyou will need the archive in the \".tar\" format to restore attachments. ',\n            /* '{{learnMore}}.',\n            {\n              learnMore: cssLink(t(\"Learn more\"), {\n                href: \"https://TODO\",\n              }),\n            },\n            */\n          ),\n          testId(\"attachments-external-message\"),\n        ))),\n        cssAttachmentsDownloadRow(\n          t(\"Format:\"),\n          dom.update(\n            cssArchiveFormatSelect(formatObs, allFormats, { menuCssClass: \"test-attachments-format-options\" }),\n            testId(\"attachments-format-select\"),\n          ),\n        ),\n        cssCopyMenuModalButtons(\n          cssDownloadAttachmentsButton(\n            t(\"Download attachments\"),\n            elem => subscribeElem(elem, attachmentArchiveDownloadHref, (href) => {\n              dom.attrsElem(elem, hooks.maybeModifyLinkAttrs({\n                href: href,\n                target: \"_blank\",\n                download: \"\",\n              }));\n            }),\n            dom.on(\"click\", () => {\n              ctl.close();\n            }),\n            testId(\"download-attachments-button-link\"),\n          ),\n          bigBasicButton(t(\"Cancel\"), dom.on(\"click\", () => {\n            ctl.close();\n          })),\n        ),\n      ]),\n    ];\n  });\n}\n\nexport const cssField = styled(\"div\", `\n  margin: 16px 0;\n  display: flex;\n`);\n\nexport const cssLabel = styled(\"label\", `\n  font-weight: normal;\n  font-size: ${tokens.mediumFontSize};\n  color: ${components.text};\n  margin: 8px 16px 0 0;\n  white-space: nowrap;\n  width: 80px;\n  flex: none;\n`);\n\nconst cssWarningText = styled(\"div\", `\n  color: ${components.errorText};\n  margin-top: 8px;\n`);\n\nconst cssSpinner = styled(\"div\", `\n  text-align: center;\n  flex: 1;\n  height: 30px;\n`);\n\nconst cssCheckbox = styled(labeledSquareCheckbox, `\n  margin-top: 8px;\n`);\n\nconst cssAttachmentsDownloadRow = styled(\"div\", `\n  margin: 16px 0;\n  display: flex;\n  gap: 16px;\n  align-items: center;\n`);\n\nconst cssArchiveFormatSelect = styled(select, ``);\n\nconst cssDownloadAttachmentsButton = styled(bigPrimaryButtonLink, `\n  text-wrap: nowrap;\n`);\n\n// Prevents the div from expanding the parent and makes it only use available space instead.\nconst cssEagerWrap = styled(\"div\", `\n  contain: inline-size;\n`);\n\nconst cssAttachmentsWarning = styled(cssEagerWrap, `\n  margin: 16px 0;\n`);\n\nconst cssCopyMenuModalButtons = styled(cssModalButtons, `\n  display: flex;\n  align-items: center;\n`);\n"
  },
  {
    "path": "app/client/ui/MarkdownCellRenderer.ts",
    "content": "import { domAsync } from \"app/client/lib/domAsync\";\nimport { constructUrl } from \"app/client/models/gristUrlState\";\nimport { buildCodeHighlighter } from \"app/client/ui/CodeHighlight\";\nimport { sanitizeHTMLIntoDOM } from \"app/client/ui/sanitizeHTML\";\nimport { cssLink, gristIconLink } from \"app/client/ui2018/links\";\nimport { AsyncCreate } from \"app/common/AsyncCreate\";\nimport { removePrefix } from \"app/common/gutil\";\n\nimport { dom, DomContents } from \"grainjs\";\nimport escape from \"lodash/escape\";\nimport { marked, Marked } from \"marked\";\nimport { markedHighlight } from \"marked-highlight\";\nimport markedLinkifyIt from \"marked-linkify-it\";\n\nexport const renderer = new marked.Renderer();\n\n/**\n * A custom link renderer that handles user references (for mentions) and normal links. For now mentions\n * are not supported in cells.\n */\nrenderer.link = ({ href, text }) => {\n  const userRef = removePrefix(href, \"user:\");\n  return userRef ? cssLink({ \"data-userref\": userRef }, text, dom.cls(\"grist-mention\")).outerHTML :\n    gristIconLink(constructUrl(href), text).outerHTML;\n};\n\n// Disable Markdown features that we aren't ready to support yet.\nrenderer.hr = ({ raw }) => raw;\nrenderer.html = ({ raw }) => escape(raw);\nrenderer.image = ({ raw }) => raw;\n\n// Creator for a Marked instance that includes some extra features.\nconst markedAsync = new AsyncCreate<Marked>(async () => {\n  const highlight = await buildCodeHighlighter({ maxLines: 60 });\n  return new Marked(\n    markedHighlight({\n      highlight: code => highlight(code),\n    }),\n    markedLinkifyIt(),\n  );\n});\n\ninterface RenderOptions {\n  onMarkedResolved?: () => void;\n}\n\n// Call render() synchronously if possible, or asynchronously otherwise. Aside from the first\n// batch of renders, this will always be synchronous. This matters for printing, where we\n// prepare a view in \"beforeprint\" callback, and async renders take place too late.\nlet markedResolved: Marked | undefined;\nfunction domAsyncOrDirect(\n  render: (markedObj: Marked) => DomContents,\n  options: RenderOptions = {},\n) {\n  return markedResolved ?\n    render(markedResolved) :\n    domAsync(markedAsync.get().then((markedObj) => {\n      options.onMarkedResolved?.();\n      markedResolved = markedObj;\n      return render(markedResolved);\n    }));\n}\n\n/**\n * Parses and renders the given markdownValue, sanitizing the result.\n *\n * This does not support HTML or images in the markdown value, and renders those as text.\n *\n * The actual rendering will happen asynchronously on first use, while the markdown loads some\n * extensions (specifically, the code highlighter).\n */\nexport function renderCellMarkdown(\n  markdownValue: string,\n  options: RenderOptions = {},\n): DomContents {\n  return domAsyncOrDirect((markedObj: Marked) => {\n    const source = markedObj.parse(markdownValue, {\n      async: false,\n      gfm: false,\n      renderer,\n    });\n    return sanitizeHTMLIntoDOM(source);\n  }, options);\n}\n"
  },
  {
    "path": "app/client/ui/MenuToggle.ts",
    "content": "import { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\n\nimport { dom, DomElementArg, IDisposableOwner, styled } from \"grainjs\";\n\n/**\n * Creates a toggle button - little square button with a dropdown icon inside, used\n * by a context menu for a row inside a grid, a card inside a cardlist and column name.\n */\nexport function menuToggle(obs: IDisposableOwner | null, ...args: DomElementArg[]) {\n  const contextMenu = cssMenuToggle(\n    icon(\"Dropdown\", dom.cls(\"menu_toggle_icon\")),\n    ...args,\n  );\n  return contextMenu;\n}\n\nconst cssMenuToggle = styled(\"div.menu_toggle\", `\n  background: ${theme.menuToggleBg};\n  cursor: pointer;\n  --icon-color: ${theme.menuToggleFg};\n  border: 1px solid ${theme.menuToggleBorder};\n  border-radius: 4px;\n  &:hover  {\n    --icon-color: ${theme.menuToggleHoverFg};\n    border-color: ${theme.menuToggleHoverFg};\n  }\n  &:active  {\n    --icon-color: ${theme.menuToggleActiveFg};\n    border-color: ${theme.menuToggleActiveFg};\n  }\n  & > .menu_toggle_icon {\n    display: block; /* don't create a line */\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/MultiSelector.ts",
    "content": "/**\n * Usage in Grist: Multi-selector component serves as the base class for Sorting, Filtering,\n * Group-By, Linking, and possibly other widgets in Grist.\n *\n * The multi-selector component allows the user to create an ordered list of items selected from a\n * unique list. Visually it shows a list of Items, and a link to add a new item. Each item shows a\n * trash-can icon to remove it. Optionally, items may be reordered relative to each other using a\n * dragger. Each Item contains a dropdown which shows a fixed set of options (e.g. column names).\n * Options already selected (present as other Items) are omitted from the list.\n *\n * Using the link to add a new item creates an \"Empty Item\" row, which then gets added to\n * the list and becomes a real item when the user chooses a value. Note that the Empty Item\n * uses the empty string '' as its value, so it is not a valid value in the list of items.\n *\n * The MultiSelect class may be extended to be used with enhanced items, to show additional UI (to\n * control additional properties of items, e.g. ascending/descending for sorting), and to provide\n * custom implementation for changes (e.g. adding an item may involve a request to the server).\n *\n * TODO: Implement optional reordering of items\n * TODO: Optionally omit selected items from list\n */\n\nimport { button1 } from \"app/client/ui/buttons\";\n\nimport { computed, MutableObsArray, ObsArray, observable, Observable } from \"grainjs\";\nimport { Disposable, dom, makeTestId, select, styled } from \"grainjs\";\n\nexport interface BaseItem {\n  value: any;\n  label: string;\n}\n\nconst testId = makeTestId(\"test-ms-\");\n\nexport abstract class MultiItemSelector<Item extends BaseItem> extends Disposable {\n  constructor(private _incItems: MutableObsArray<Item>, private _allItems: ObsArray<Item>,\n    private _options: {\n      addItemLabel: string,\n      addItemText: string\n    }) {\n    super();\n  }\n\n  public buildDom() {\n    return cssMultiSelectorWrapper(\n      cssItemList(testId(\"list\"),\n        dom.forEach(this._incItems, item => this.buildItemDom(item)),\n        this._buildAddItemDom(this._options.addItemLabel, this._options.addItemText),\n      ),\n    );\n  }\n\n  // Must be overridden to return list of available items. Items already present in the items\n  // array (according to value) may be safely included, and will not be shown in the select-box.\n\n  // The default implementations update items array, but may be overridden.\n  protected async add(item: Item): Promise<void> {\n    this._incItems.push(item);\n  }\n\n  // Called with an item from `_allItems`\n  protected async remove(item: Item): Promise<void> {\n    const idx = this._findIncIndex(item);\n    if (idx === -1) { return; }\n    this._incItems.splice(idx, 1);\n  }\n\n  // TODO: Called with an item in the items array\n  protected async reorder(item: Item, nextItem: Item): Promise<void> { return; }\n\n  // Replaces an existing item (if found) with a new one\n  protected async changeItem(item: Item, newItem: Item): Promise<void> {\n    const idx = this._findIncIndex(item);\n    if (idx === -1) { return; }\n    this._incItems.splice(idx, 1, newItem);\n  }\n\n  // Exposed for use by custom buildItemDom().\n  protected buildDragHandle(item: Item): Element { return new Element(); }\n\n  protected buildSelectBox(selectedValue: string,\n    selectCb: (newItem: Item) => void,\n    selectOptions?: { defLabel?: string }): Element {\n    const obs = computed(use => selectedValue).onWrite(async (value) => {\n      const newItem = this._findItemByValue(value);\n      if (newItem) {\n        selectCb(newItem);\n      }\n    });\n\n    const result = select(\n      obs,\n      this._allItems,\n      selectOptions,\n    );\n    dom.autoDisposeElem(result, obs);\n\n    return result;\n  }\n\n  protected buildRemoveButton(removeCb: () => void): Element {\n    return cssItemRemove(testId(\"remove-btn\"),\n      dom.on(\"click\", removeCb),\n      \"✖\",\n    );\n  }\n\n  // May be overridden for custom-looking items.\n  protected buildItemDom(item: Item): Element {\n    return dom(\"li\", testId(\"item\"),\n      // this.buildDragHandle(item), TODO: once dragging is implemented\n      this.buildSelectBox(item.value, async newItem => this.changeItem(item, newItem)),\n      this.buildRemoveButton(() => this.remove(item)),\n    );\n  }\n\n  // Returns the index (order) of the item if it's been included, or -1 otherwise.\n  private _findIncIndex(item: Item): number {\n    return this._incItems.get().findIndex(_item => _item === item);\n  }\n\n  // Returns the item object given it's value, or undefined if not found.\n  private _findItemByValue(value: string): Item | undefined {\n    return this._allItems.get().find(_item => _item.value === value);\n  }\n\n  // Builds the about-to-be-added item\n  private _buildAddItemDom(defLabel: string, defText: string): Element {\n    const addNewItem: Observable<boolean> = observable(false);\n    return dom(\"li\", testId(\"add-item\"),\n      dom.domComputed(addNewItem, isAdding => isAdding ?\n        dom.frag(\n          this.buildSelectBox(\"\", async (newItem) => {\n            await this.add(newItem);\n            addNewItem.set(false);\n          }, { defLabel }),\n          this.buildRemoveButton(() => addNewItem.set(false))) :\n        button1(defText, testId(\"add-btn\"),\n          dom.on(\"click\", () => addNewItem.set(true))),\n      ),\n    );\n  }\n}\n\nconst cssMultiSelectorWrapper = styled(\"div\", `\n  border: 1px solid blue;\n`);\n\nconst cssItemList = styled(\"ul\", `\n  list-style-type: none;\n`);\n\nconst cssItemRemove = styled(\"span\", `\n  padding: 0 .5rem;\n  vertical-align: middle;\n  cursor: pointer;\n`);\n"
  },
  {
    "path": "app/client/ui/NewRecordButton.ts",
    "content": "import BaseView from \"app/client/components/BaseView\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { primaryButton } from \"app/client/ui2018/buttons\";\nimport { testId, zIndexes } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\n\nimport { dom, styled } from \"grainjs\";\n\nconst t = makeT(\"NewRecordButton\");\n\nconst translationStrings = {\n  record: t(\"New record\"),\n  single: t(\"New card\"),\n};\n\n/**\n * Helper to render the \"New Record\" button for the given view.\n *\n * It only renders when the experiment is enabled and the view has focus.\n */\nexport function maybeShowNewRecordExperiment(view: BaseView) {\n  const experimentIsEnabled = view.gristDoc.appModel.experiments?.isEnabled(\"newRecordButton\");\n  return dom.maybe(\n    use => (experimentIsEnabled && use(view.viewSection.hasFocus) && use(view.enableAddRow)),\n    () => newRecordButton(view),\n  );\n}\n\n/**\n * \"New Record\" button for the given view that inserts a new record at the end on click.\n *\n * Note that each view has its own implementation of how to \"create a new record\"\n * via the `onNewRecordRequest` method.\n *\n * Appears in the bottom-left corner of its parent element.\n */\nfunction newRecordButton(view: BaseView) {\n  const viewType = view.viewSection.parentKey.peek();\n\n  const translationString = translationStrings[viewType as keyof typeof translationStrings] ||\n    t(\"New record\");\n  return cssNewRecordButton(\n    icon(\"Plus\"),\n    dom(\"span\", translationString),\n    dom.on(\"click\", () => {\n      view.onNewRecordRequest?.()?.catch(reportError);\n    }),\n    testId(\"new-record-button\"),\n  );\n}\n\nconst cssNewRecordButton = styled(primaryButton, `\n  position: absolute;\n  bottom: -12px;\n  left: -12px;\n  z-index: ${zIndexes.newRecordButtonZIndex};\n  display: flex;\n  align-items: center;\n  gap: 6px;\n`);\n"
  },
  {
    "path": "app/client/ui/NotifyUI.ts",
    "content": "import { beaconOpenMessage, IBeaconOpenOptions } from \"app/client/lib/helpScout\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { ConnectState } from \"app/client/models/ConnectState\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { Expirable, IAppError, Notification, Notifier, NotifyAction, Progress } from \"app/client/models/NotifyModel\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { cssHoverCircle, cssTopBarBtn } from \"app/client/ui/TopBarCss\";\nimport { isNarrowScreenObs, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\nimport { commonUrls, isFeatureEnabled } from \"app/common/gristUrls\";\n\nimport { dom, makeTestId, styled } from \"grainjs\";\nimport { cssMenu, defaultMenuOptions, IOpenController, setPopupToCreateDom } from \"popweasel\";\n\nconst t = makeT(\"NotifyUI\");\n\nconst testId = makeTestId(\"test-notifier-\");\n\nfunction buildAction(action: NotifyAction, item: Notification, options: IBeaconOpenOptions): HTMLElement | null {\n  const appModel = options.appModel;\n  switch (action) {\n    case \"upgrade\":\n      if (appModel) {\n        return cssToastAction(t(\"Upgrade Plan\"), dom.on(\"click\", () =>\n          appModel.showUpgradeModal()));\n      } else {\n        return dom(\"a\", cssToastAction.cls(\"\"), t(\"Upgrade Plan\"), { target: \"_blank\" },\n          { href: commonUrls.plans });\n      }\n    case \"manage\":\n      if (urlState().state.get().billing === \"billing\") { return null; }\n      return dom(\"a\", cssToastAction.cls(\"\"), t(\"Manage billing\"), { target: \"_blank\" },\n        { href: urlState().makeUrl({ billing: \"billing\" }) });\n    case \"renew\":\n      // If already on the billing page, nothing to return.\n      if (urlState().state.get().billing === \"billing\") { return null; }\n      // If not a billing manager, nothing to return.\n      if (appModel?.currentOrg?.billingAccount &&\n        !appModel.currentOrg.billingAccount.isManager) { return null; }\n      // Otherwise return a link to the billing page.\n      return dom(\"a\", cssToastAction.cls(\"\"), t(\"Renew\"), { target: \"_blank\" },\n        { href: urlState().makeUrl({ billing: \"billing\" }) });\n\n    case \"personal\":\n      if (!appModel) { return null; }\n      return cssToastAction(t(\"Go to your free personal site\"), dom.on(\"click\", async () => {\n        const info = await appModel.api.getSessionAll();\n        const orgs = info.orgs.filter(org => org.owner && org.owner.id === appModel.currentUser?.id);\n        if (orgs.length !== 1) {\n          throw new Error(t(\"Cannot find personal site, sorry!\"));\n        }\n        window.location.assign(urlState().makeUrl({ org: orgs[0].domain || undefined }));\n      }));\n\n    case \"report-problem\":\n      return cssToastAction(t(\"Report a problem\"), testId(\"toast-report-problem\"),\n        dom.on(\"click\", () => beaconOpenMessage({ ...options, includeAppErrors: true })));\n\n    case \"ask-for-help\": {\n      const errors: IAppError[] = [{\n        error: new Error(item.options.message as string),\n        timestamp: item.options.timestamp,\n      }];\n      return cssToastAction(t(\"Ask for help\"),\n        dom.on(\"click\", () => beaconOpenMessage({ ...options, includeAppErrors: true, errors })));\n    }\n\n    default:\n      return cssToastAction(action.label, testId(\"toast-custom-action\"),\n        dom.on(\"click\", action.action));\n  }\n}\n\nfunction notificationIcon(item: Notification) {\n  let iconName: IconName | null = null;\n  switch (item.options.level) {\n    case \"error\":   iconName = \"Warning\"; break;\n    case \"warning\": iconName = \"Warning\"; break;\n    case \"success\": iconName = \"TickSolid\"; break;\n    case \"info\": iconName = \"Info\"; break;\n  }\n  return iconName ? icon(iconName, dom.cls(cssToastIcon.className)) : null;\n}\n\nfunction buildNotificationDom(item: Notification, options: IBeaconOpenOptions) {\n  const iconElement = notificationIcon(item);\n  const hasLeftIcon = Boolean(!item.options.title && iconElement);\n  return cssToastWrapper(testId(\"toast-wrapper\"),\n    cssToastWrapper.cls(use => `-${use(item.status)}`),\n    cssToastWrapper.cls(`-${item.options.level}`),\n    cssToastWrapper.cls(\"-memo\", item.options.memos.length > 0),\n    cssToastWrapper.cls(hasLeftIcon ? \"-left-icon\" : \"\"),\n    item.options.title ? null : iconElement,\n    cssToastBody(\n      item.options.title ? cssToastTitle(notificationIcon(item), cssToastTitle(item.options.title)) : null,\n      cssToastText(testId(\"toast-message\"),\n        item.options.message,\n      ),\n      item.options.actions.length ? cssToastActions(\n        item.options.actions.map(action => buildAction(action, item, options)),\n      ) : null,\n      item.options.memos.length ? cssToastMemos(\n        item.options.memos.map(memo => cssToastMemo(\n          cssToastMemoIcon(\"Memo\"),\n          dom(\"div\", memo, testId(\"toast-memo\")),\n        )),\n      ) : null,\n    ),\n    dom.maybe(item.options.canUserClose, () =>\n      cssToastClose(testId(\"toast-close\"),\n        \"✕\",\n        dom.on(\"click\", () => item.dispose()),\n      ),\n    ),\n  );\n}\n\nfunction buildProgressDom(item: Progress) {\n  return cssToastWrapper(testId(\"progress-wrapper\"),\n    cssToastBody(\n      cssToastText(testId(\"progress-message\"),\n        dom.text(item.options.name),\n        dom.maybe(item.options.size, size => cssProgressBarSize(` (${size})`)),\n      ),\n      cssProgressBarWrapper(\n        cssProgressBarStatus(\n          dom.style(\"width\", use => `${use(item.progress)}%`),\n        ),\n      ),\n    ),\n  );\n}\n\nexport function buildNotifyMenuButton(notifier: Notifier, appModel: AppModel | null) {\n  const { connectState } = notifier.getStateForUI();\n  return dom.maybe(use => (!use(isNarrowScreenObs()) && use(connectState) !== ConnectState.Connected), () =>\n    cssHoverCircle({ style: `margin: 5px;` },\n      dom.domComputed(connectState, state => buildConnectStateButton(state)),\n      (elem) => {\n        setPopupToCreateDom(elem, ctl => buildNotifyDropdown(ctl, notifier, appModel),\n          { ...defaultMenuOptions, placement: \"bottom-end\" });\n      },\n      hoverTooltip(\"Notifications\", { key: \"topBarBtnTooltip\" }),\n      testId(\"menu-btn\"),\n    ),\n  );\n}\n\nfunction buildNotifyDropdown(ctl: IOpenController, notifier: Notifier, appModel: AppModel | null): Element {\n  const { connectState, disconnectMsg, dropdownItems } = notifier.getStateForUI();\n\n  return cssDropdownWrapper(\n    // Reuse css classes for menus (combination of popweasel classes and those from Grist menus)\n    dom.cls(cssMenu.className),\n    dom.cls(menuCssClass),\n\n    // Close on Escape.\n    dom.onKeyDown({ Escape: () => ctl.close() }),\n    // Once attached, focus this element, so that it accepts keyboard events.\n    (elem) => { setTimeout(() => elem.focus(), 0); },\n\n    cssDropdownContent(\n      cssDropdownHeader(\n        cssDropdownHeaderTitle(t(\"Notifications\")),\n        !isFeatureEnabled(\"helpCenter\") ? null :\n          cssDropdownFeedbackLink(\n            cssDropdownFeedbackIcon(\"Feedback\"),\n            t(\"Give feedback\"),\n            dom.on(\"click\", () => beaconOpenMessage({ appModel, onOpen: () => ctl.close(), route: \"/ask/message/\" })),\n            testId(\"feedback\"),\n          ),\n      ),\n      dom.maybe(disconnectMsg, msg =>\n        cssDropdownStatus(\n          buildConnectStateButton(connectState.get()),\n          dom(\"div\", cssDropdownStatusText(msg.message), testId(\"disconnect-msg\")),\n        ),\n      ),\n      dom.maybe(use => use(dropdownItems).length === 0 && !use(disconnectMsg), () =>\n        cssDropdownStatus(\n          dom(\"div\", cssDropdownStatusText(t(\"No notifications\"))),\n        ),\n      ),\n      dom.forEach(dropdownItems, item =>\n        buildNotificationDom(item, { appModel, onOpen: () => ctl.close() })),\n    ),\n    testId(\"dropdown\"),\n  );\n}\n\nexport function buildSnackbarDom(notifier: Notifier, appModel: AppModel | null): Element {\n  const { progressItems, toasts } = notifier.getStateForUI();\n  return cssSnackbarWrapper(testId(\"snackbar-wrapper\"),\n    dom.forEach(progressItems, item => buildProgressDom(item)),\n    dom.forEach(toasts, toast => buildNotificationDom(toast, { appModel })),\n  );\n}\n\nfunction buildConnectStateButton(state: ConnectState): Element {\n  switch (state) {\n    case ConnectState.JustDisconnected: return cssTopBarBtn(\"Notification\", cssTopBarBtn.cls(\"-slate\"));\n    case ConnectState.RecentlyDisconnected: return cssTopBarBtn(\"Offline\", cssTopBarBtn.cls(\"-slate\"));\n    case ConnectState.ReallyDisconnected: return cssTopBarBtn(\"Offline\", cssTopBarBtn.cls(\"-error\"));\n    case ConnectState.Connected:\n    default:\n      return cssTopBarBtn(\"Notification\");\n  }\n}\n\nconst cssDropdownWrapper = styled(\"div\", `\n  background-color: ${theme.notificationsPanelBodyBg};\n  border: 1px solid ${theme.notificationsPanelBorder};\n  padding: 0px;\n`);\n\nconst cssDropdownContent = styled(\"div\", `\n  min-width: 320px;\n  max-width: 320px;\n`);\n\nconst cssDropdownHeader = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 24px;\n  background-color: ${theme.notificationsPanelHeaderBg};\n  outline: 1px solid ${theme.notificationsPanelBorder};\n`);\n\nconst cssDropdownHeaderTitle = styled(\"span\", `\n  color: ${theme.text};\n  font-weight: bold;\n`);\n\nconst cssDropdownFeedbackLink = styled(\"div\", `\n  display: flex;\n  color: ${theme.controlFg};\n  cursor: pointer;\n  user-select: none;\n  &:hover {\n    text-decoration: underline;\n  }\n`);\n\nconst cssDropdownFeedbackIcon = styled(icon, `\n  background-color: ${theme.controlFg};\n  margin-right: 4px;\n`);\n\nconst cssDropdownStatus = styled(\"div\", `\n  padding: 16px 48px 24px 48px;\n  text-align: center;\n  border-top: 1px solid ${theme.notificationsPanelBorder};\n`);\n\nconst cssDropdownStatusText = styled(\"div\", `\n  display: inline-block;\n  margin: 8px 0 0 0;\n  text-align: left;\n  color: ${theme.lightText};\n`);\n\n// z-index below is set above other assorted children of <body>, which includes\n// indexes such as 999 for modals.\nconst cssSnackbarWrapper = styled(\"div\", `\n  position: fixed;\n  bottom: 8px;\n  right: 8px;\n  z-index: ${vars.notificationZIndex};\n\n  display: flex;\n  flex-direction: column;\n  align-items: flex-end;\n\n  font-size: ${vars.mediumFontSize};\n\n  pointer-events: none; /* Allow mouse clicks through */\n`);\n\nconst cssToastBody = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex-grow: 1;\n  padding: 0 12px;\n  overflow-wrap: anywhere;\n`);\n\nconst cssToastIcon = styled(\"div\", `\n  flex-shrink: 0;\n  height: 18px;\n  width: 18px;\n`);\n\nconst cssToastActions = styled(\"div\", `\n  display: flex;\n  align-items: flex-end;\n  margin-top: 16px;\n  color: ${theme.toastControlFg};\n`);\n\nconst cssToastWrapper = styled(\"div\", `\n  display: flex;\n  min-width: 240px;\n  max-width: 320px;\n  overflow: hidden;\n\n  margin: 4px;\n  padding: 12px;\n  border-radius: 3px;\n\n  color: ${theme.toastText};\n  background-color: ${theme.toastBg};\n\n  pointer-events: auto;\n\n  opacity: 1;\n  transition: opacity ${Expirable.fadeDelay}ms;\n\n  &-memo {\n    color: ${theme.toastMemoText};\n    background-color: ${theme.toastMemoBg};\n  }\n  &-error {\n    border-left: 6px solid ${theme.toastErrorBg};\n    padding-left: 6px;\n    --icon-color: ${theme.toastErrorIcon};\n  }\n  &-success {\n    border-left: 6px solid ${theme.toastSuccessBg};\n    padding-left: 6px;\n    --icon-color: ${theme.toastSuccessIcon};\n  }\n  &-warning {\n    border-left: 6px solid ${theme.toastWarningBg};\n    padding-left: 6px;\n    --icon-color: ${theme.toastWarningIcon};\n  }\n  &-info {\n    border-left: 6px solid ${theme.toastInfoBg};\n    padding-left: 6px;\n    --icon-color: ${theme.toastInfoIcon};\n  }\n  &-info .${cssToastActions.className} {\n    color: ${theme.toastInfoControlFg};\n  }\n\n  &-left-icon {\n    padding-left: 12px;\n  }\n  &-left-icon > .${cssToastBody.className} {\n    padding-left: 10px;\n  }\n\n  &-expiring, &-expired {\n    opacity: 0;\n  }\n  .${cssDropdownContent.className} > & > .notification-icon {\n    display: none;\n  }\n  .${cssDropdownContent.className} > & {\n    background-color: unset;\n    color: ${theme.text};\n    border-radius: 0px;\n    border-top: 1px solid ${theme.notificationsPanelBorder};\n    margin: 0px;\n    padding: 16px 20px;\n  }\n`);\n\nconst cssToastText = styled(\"div\", `\n  .${cssToastWrapper.className}-memo & {\n    font-weight: 700;\n  }\n`);\n\nconst cssToastTitle = styled(cssToastText, `\n  display: flex;\n  gap: 8px;\n  font-weight: bold;\n  margin-bottom: 8px;\n`);\n\nconst cssToastClose = styled(\"div\", `\n  cursor: pointer;\n  user-select: none;\n  width: 16px;\n  height: 16px;\n  line-height: 16px;\n  text-align: center;\n  margin: -4px -4px -4px 4px;\n`);\n\nconst cssToastAction = styled(\"div\", `\n  cursor: pointer;\n  user-select: none;\n  margin-right: 24px;\n  &, &:hover, &:focus {\n    color: inherit;\n  }\n  &:hover {\n    text-decoration: underline;\n  }\n`);\n\nconst cssToastMemos = styled(\"div\", `\n  margin-top: 8px;\n  display: flex;\n  flex-direction: column;\n`);\n\nconst cssToastMemo = styled(\"div\", `\n  display: flex;\n  column-gap: 8px;\n  align-items: center;\n`);\n\nconst cssToastMemoIcon = styled(icon, `\n  --icon-color: ${theme.toastMemoText};\n  flex-shrink: 0;\n`);\n\nconst cssProgressBarWrapper = styled(\"div\", `\n  margin-top: 18px;\n  margin-bottom: 11px;\n  height: 3px;\n  border-radius: 3px;\n  background-color: ${theme.progressBarBg};\n`);\n\nconst cssProgressBarSize = styled(\"span\", `\n  color: ${theme.toastLightText};\n`);\n\nconst cssProgressBarStatus = styled(\"div\", `\n  height: 3px;\n  min-width: 3px;\n  border-radius: 3px;\n  background-color: ${theme.progressBarFg};\n`);\n"
  },
  {
    "path": "app/client/ui/OnBoardingPopups.ts",
    "content": "/**\n * Utility to generate a series of onboarding popups. It is used to give users a short description\n * of some elements of the UI. The first step is to create the list of messages following the\n * `IOnBoardingMsg` interface. Then you have to attach each message to its corresponding element of\n * the UI using the `attachOnBoardingMsg' dom method:\n *\n *  Usage:\n *\n *    // create the list of message\n *    const messages = [{id: 'add-new-btn', placement: 'right', buildDom: () => ... },\n *                      {id: 'share-btn', buildDom: () => ... ];\n *\n *\n *    // attach each message to the corresponding element\n *    dom('div', 'Add New', ..., dom.cls('tour-add-new-btn'));\n *\n *    // start\n *    startOnBoarding(message, onFinishCB);\n *\n * Note:\n * - this module does UI only, saving which user has already seen the popups has to be handled by\n *   the caller. Pass an `onFinishCB` to handle when a user dimiss the popups.\n */\n\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport * as Mousetrap from \"app/client/lib/Mousetrap\";\nimport { reportError } from \"app/client/models/errors\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { cssBigIcon, cssCloseButton } from \"app/client/ui/ExampleCard\";\nimport { bigBasicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { delay } from \"app/common/delay\";\nimport { IGristUrlState } from \"app/common/gristUrls\";\n\nimport { createPopper, Placement } from \"@popperjs/core\";\nimport { Disposable, dom, DomElementArg, Holder, makeTestId, Observable, styled, svg } from \"grainjs\";\nimport range from \"lodash/range\";\n\nconst t = makeT(\"OnBoardingPopups\");\n\nconst testId = makeTestId(\"test-onboarding-\");\n\n// Describes an onboarding popup. Each popup is uniquely identified by its id.\nexport interface IOnBoardingMsg {\n\n  // A CSS selector pointing to the reference element\n  selector: string,\n\n  // Title\n  title: DomElementArg,\n\n  // Body\n  body?: DomElementArg,\n\n  // If true show the message as a modal centered on the screen.\n  showHasModal?: boolean,\n\n  // The popper placement.\n  placement?: Placement,\n\n  // Adjusts the popup offset so that it is positioned relative to the content of the reference\n  // element. This is useful when the reference element has padding and no border (ie: such as\n  // icons). In which case, and when set to true, it will fill the gap between popups and the UI\n  // part it's pointing at. If `cropPadding` is falsy otherwise, the popup might look a bit distant.\n  cropPadding?: boolean,\n\n  // The popper offset.\n  offset?: [number, number],\n\n  // Skip the message\n  skip?: boolean;\n\n  // If present, will be passed to urlState().pushUrl() to navigate to the location defined by that state\n  urlState?: IGristUrlState;\n}\n\nlet _isTourActiveObs: Observable<boolean> | undefined;\n\n// Returns a singleton observable for whether some tour is currently active.\n//\n// GristDoc subscribes to this observable in order to temporarily disable tips and other\n// in-product popups from being shown while a tour is active.\nexport function isTourActiveObs(): Observable<boolean> {\n  if (!_isTourActiveObs) {\n    const obs = Observable.create<boolean>(null, false);\n    _isTourActiveObs = obs;\n  }\n  return _isTourActiveObs;\n}\n\n// There should only be one tour at a time. Use a holder to dispose the previous tour when\n// starting a new one.\nconst tourSingleton = Holder.create<OnBoardingPopupsCtl>(null);\n\nexport function startOnBoarding(messages: IOnBoardingMsg[], onFinishCB: (lastMessageIndex: number) => void) {\n  const ctl = OnBoardingPopupsCtl.create(tourSingleton, messages, onFinishCB);\n  ctl.onDispose(() => isTourActiveObs().set(false));\n  ctl.start().catch(reportError);\n  isTourActiveObs().set(true);\n}\n\n// Returns whether some tour is currently active.\nexport function isTourActive(): boolean {\n  return isTourActiveObs().get();\n}\n\nclass OnBoardingError extends Error {\n  public name = \"OnBoardingError\";\n  constructor(message: string) {\n    super(message);\n  }\n}\n\n/**\n * Current index in the list of messages.\n * This allows closing the tour and reopening where you left off.\n * Since it's a single global value, mixing unrelated tours\n * (e.g. the generic welcome tour and a specific document tour)\n * in a single page load won't work well.\n */\nlet ctlIndex = 0;\n\nclass OnBoardingPopupsCtl extends Disposable {\n  private _openPopupCtl: { close: () => void } | undefined;\n  private _overlay: HTMLElement;\n  private _arrowEl = buildArrow();\n\n  constructor(private _messages: IOnBoardingMsg[], private _onFinishCB: (lastMessageIndex: number) => void) {\n    super();\n    if (this._messages.length === 0) {\n      throw new OnBoardingError(\"messages should not be an empty list\");\n    }\n\n    // In case we're reopening after deleting some rows of GristDocTour,\n    // ensure ctlIndex is still within bounds\n    ctlIndex = Math.min(ctlIndex, this._messages.length - 1);\n\n    this.onDispose(() => {\n      this._openPopupCtl?.close();\n    });\n  }\n\n  public async start() {\n    this._showOverlay();\n    await this._move(0);\n    if (this.isDisposed()) {\n      return;\n    }\n\n    Mousetrap.setPaused(true);\n    this.onDispose(() => {\n      Mousetrap.setPaused(false);\n    });\n  }\n\n  private _finish(lastMessageIndex: number) {\n    this._onFinishCB(lastMessageIndex);\n    this.dispose();\n  }\n\n  private async _move(movement: number, maybeClose = false) {\n    const newIndex = ctlIndex + movement;\n    const entry = this._messages[newIndex];\n    if (!entry) {\n      if (maybeClose) {\n        this._finish(ctlIndex);\n        // User finished the tour, close and restart from the beginning if they reopen\n        ctlIndex = 0;\n      }\n      return;  // gone out of bounds, probably by keyboard shortcut\n    }\n    ctlIndex = newIndex;\n    if (entry.skip) {\n      // movement = 0 when starting a tour, make sure we don't get stuck in a loop\n      await this._move(movement || +1);\n      return;\n    }\n\n    // close opened popup if any\n    this._openPopupCtl?.close();\n\n    if (entry.urlState) {\n      await urlState().pushUrl(entry.urlState);\n      await delay(100);  // make sure cursor is in correct place\n    }\n\n    if (entry.showHasModal) {\n      this._showHasModal();\n    } else {\n      await this._showHasPopup(movement);\n    }\n  }\n\n  private async _showHasPopup(movement: number) {\n    const content = this._buildPopupContent();\n    const entry = this._messages[ctlIndex];\n    const elem = document.querySelector<HTMLElement>(entry.selector);\n    const { placement } = entry;\n\n    // The element the popup refers to is not present. To the user we show nothing and simply skip\n    // it to the next.\n    if (!elem) {\n      console.warn(`On boarding tour: element ${entry.selector} not found!`);\n      // movement = 0 when starting a tour, make sure we don't get stuck in a loop\n      return this._move(movement || +1);\n    }\n\n    // Cleanup\n    function close() {\n      popper.destroy();\n      dom.domDispose(content);\n      content.remove();\n    }\n\n    this._openPopupCtl = { close };\n    document.body.appendChild(content);\n    this._addFocusLayer(content);\n\n    // Create a popper for positioning the popup content relative to the reference element\n    const adjacentPadding = entry.cropPadding ? this._getAdjacentPadding(elem, placement) : 0;\n    const popper = createPopper(elem, content, {\n      placement,\n      modifiers: [{\n        name: \"arrow\",\n        options: {\n          element: this._arrowEl,\n        },\n      }, {\n        name: \"offset\",\n        options: {\n          offset: [0, 12 - adjacentPadding],\n        },\n      }],\n    });\n  }\n\n  private _addFocusLayer(container: HTMLElement) {\n    dom.autoDisposeElem(container, new FocusLayer({\n      defaultFocusElem: container,\n      allowFocus: elem => (elem !== document.body),\n    }));\n  }\n\n  // Get the padding length for the side that will be next to the popup.\n  private _getAdjacentPadding(elem: HTMLElement, placement?: Placement) {\n    if (placement) {\n      let padding = \"\";\n      if (placement.includes(\"bottom\")) {\n        padding = getComputedStyle(elem).paddingBottom;\n      } else if (placement.includes(\"top\")) {\n        padding = getComputedStyle(elem).paddingTop;\n      } else if (placement.includes(\"left\")) {\n        padding = getComputedStyle(elem).paddingLeft;\n      } else if (placement.includes(\"right\")) {\n        padding = getComputedStyle(elem).paddingRight;\n      }\n      // Note: getComputedStyle return value in pixel, hence no need to handle other unit. See here\n      // for reference:\n      // https://developer.mozilla.org/en-US/docs/Web/API/Window/getComputedStyle#notes.\n      if (padding?.endsWith(\"px\")) {\n        return Number(padding.slice(0, padding.length - 2));\n      }\n    }\n    return 0;\n  }\n\n  private _showHasModal() {\n    const content = this._buildPopupContent();\n    dom.update(this._overlay, content);\n    this._addFocusLayer(content);\n\n    function close() {\n      content.remove();\n      dom.domDispose(content);\n    }\n\n    this._openPopupCtl = { close };\n  }\n\n  private _buildPopupContent() {\n    return Container(\n      { tabindex: \"-1\" },\n      this._arrowEl,\n      ContentWrapper(\n        cssCloseButton(cssBigIcon(\"CrossBig\"),\n          dom.on(\"click\", () => this._finish(ctlIndex)),\n          testId(\"close\"),\n        ),\n        cssTitle(this._messages[ctlIndex].title),\n        cssBody(this._messages[ctlIndex].body),\n        this._buildFooter(),\n        testId(\"popup\"),\n      ),\n      dom.onKeyDown({\n        Escape: () => this._finish(ctlIndex),\n        ArrowLeft: () => this._move(-1),\n        ArrowRight: () => this._move(+1),\n        Enter: () => this._move(+1, true),\n      }),\n    );\n  }\n\n  private _buildFooter() {\n    const nSteps = this._messages.length;\n    const isLastStep = ctlIndex === nSteps - 1;\n    const isFirstStep = ctlIndex === 0;\n    return Footer(\n      ProgressBar(\n        range(nSteps).map(i => Dot(Dot.cls(\"-done\", i > ctlIndex))),\n      ),\n      Buttons(\n        bigBasicButton(\n          t(\"Previous\"), testId(\"previous\"),\n          dom.on(\"click\", () => this._move(-1)),\n          dom.prop(\"disabled\", isFirstStep),\n          { style: `margin-right: 8px; visibility: ${isFirstStep ? \"hidden\" : \"visible\"}` },\n        ),\n        bigPrimaryButton(\n          isLastStep ? t(\"Finish\") : t(\"Next\"), testId(\"next\"),\n          dom.on(\"click\", () => this._move(+1, true)),\n        ),\n      ),\n    );\n  }\n\n  private _showOverlay() {\n    document.body.appendChild(this._overlay = Overlay());\n    this.onDispose(() => {\n      document.body.removeChild(this._overlay);\n      dom.domDispose(this._overlay);\n    });\n  }\n}\n\nfunction buildArrow() {\n  return ArrowContainer(\n    svg(\"svg\", { style: \"width: 13px; height: 34px;\" },\n      svg(\"path\", { d: \"M 2 19 h 13 v 18 Z\" })),\n  );\n}\n\nconst Container = styled(\"div\", `\n  align-self: center;\n  border: 2px solid ${theme.accentBorder};\n  border-radius: 3px;\n  z-index: ${vars.onboardingPopupZIndex};\n  max-width: 490px;\n  position: relative;\n  background-color: ${theme.popupBg};\n  box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};\n  outline: unset;\n`);\n\nfunction sideSelectorChunk(side: \"top\" | \"bottom\" | \"left\" | \"right\") {\n  return `.${Container.className}[data-popper-placement^=${side}]`;\n}\n\nconst ArrowContainer = styled(\"div\", `\n  position: absolute;\n\n  & path {\n    stroke: ${theme.accentBorder};\n    stroke-width: 2px;\n    fill: ${theme.popupBg};\n  }\n\n  ${sideSelectorChunk(\"top\")} > & {\n    bottom: -26px;\n  }\n\n  ${sideSelectorChunk(\"bottom\")} > & {\n    top: -23px;\n  }\n\n  ${sideSelectorChunk(\"right\")} > & {\n    left: -12px;\n  }\n\n  ${sideSelectorChunk(\"left\")} > & {\n    right: -12px;\n  }\n\n  ${sideSelectorChunk(\"top\")} svg {\n    transform: rotate(-90deg);\n  }\n\n  ${sideSelectorChunk(\"bottom\")} svg {\n    transform: rotate(90deg);\n  }\n\n  ${sideSelectorChunk(\"left\")} svg {\n    transform: scalex(-1);\n  }\n`);\n\nconst ContentWrapper = styled(\"div\", `\n  position: relative;\n  padding: 32px;\n  background-color: ${theme.popupBg};\n`);\n\nconst Footer = styled(\"div\", `\n  display: flex;\n  flex-direction: row;\n  margin-top: 32px;\n  justify-content: space-between;\n  align-items: center;\n`);\n\nconst ProgressBar = styled(\"div\", `\n  display: flex;\n  flex-direction: row;\n  flex-wrap: wrap;\n  row-gap: 12px;\n`);\n\nconst Buttons = styled(\"div\", `\n  display: flex;\n  flex-directions: row;\n`);\n\nconst Dot = styled(\"div\", `\n  width: 6px;\n  height: 6px;\n  border-radius: 3px;\n  margin-right: 12px;\n  align-self: center;\n  background-color: ${theme.progressBarFg};\n  &-done {\n    background-color: ${theme.progressBarBg};\n  }\n`);\n\nconst Overlay = styled(\"div\", `\n  position: fixed;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  width: 100%;\n  height: 100%;\n  top: 0;\n  left: 0;\n  z-index: ${vars.onboardingBackdropZIndex};\n  overflow-y: auto;\n`);\n\nconst cssTitle = styled(\"div\", `\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n  color: ${theme.text};\n  margin: 0 0 16px 0;\n  line-height: 32px;\n`);\n\nconst cssBody = styled(\"div\", `\n  color: ${theme.text};\n`);\n"
  },
  {
    "path": "app/client/ui/OnboardingPage.ts",
    "content": "import { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { logError } from \"app/client/models/errors\";\nimport { getMainOrgUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { getUserPrefObs } from \"app/client/models/UserPrefs\";\nimport { textInput } from \"app/client/ui/inputs\";\nimport { PlayerState, YouTubePlayer } from \"app/client/ui/YouTubePlayer\";\nimport { bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink } from \"app/client/ui2018/buttons\";\nimport { colors, mediaMedium, mediaXSmall, theme } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { modal } from \"app/client/ui2018/modals\";\nimport { BaseAPI } from \"app/common/BaseAPI\";\nimport { commonUrls, getPageTitleSuffix } from \"app/common/gristUrls\";\nimport { UserPrefs } from \"app/common/Prefs\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport {\n  Computed,\n  Disposable,\n  dom,\n  DomContents,\n  IDisposableOwner,\n  input,\n  makeTestId,\n  Observable,\n  styled,\n  subscribeElem,\n} from \"grainjs\";\n\nconst t = makeT(\"OnboardingPage\");\n\nconst testId = makeTestId(\"test-onboarding-\");\n\nconst choices: { icon: IconName, color: string, textKey: string }[] = [\n  { icon: \"UseProduct\", color: `${colors.lightGreen}`, textKey: \"Product Development\" },\n  { icon: \"UseFinance\", color: \"#0075A2\",              textKey: \"Finance & Accounting\" },\n  { icon: \"UseMedia\",   color: \"#F7B32B\",              textKey: \"Media Production\"    },\n  { icon: \"UseMonitor\", color: \"#F2545B\",              textKey: \"IT & Technology\"     },\n  { icon: \"UseChart\",   color: \"#7141F9\",              textKey: \"Marketing\"           },\n  { icon: \"UseScience\", color: \"#231942\",              textKey: \"Research\"            },\n  { icon: \"UseSales\",   color: \"#885A5A\",              textKey: \"Sales\"               },\n  { icon: \"UseEducate\", color: \"#4A5899\",              textKey: \"Education\"           },\n  { icon: \"UseHr\",      color: \"#688047\",              textKey: \"HR & Management\"     },\n  { icon: \"UseOther\",   color: \"#929299\",              textKey: \"Other\"               },\n];\n\nexport function shouldShowOnboardingPage(userPrefsObs: Observable<UserPrefs>): boolean {\n  return Boolean(getGristConfig().survey && userPrefsObs.get()?.showNewUserQuestions);\n}\n\ntype IncrementStep = (delta?: 1 | -1) => void;\n\ninterface Step {\n  state?: QuestionsState | VideoState;\n  buildDom(): DomContents;\n  onNavigateAway?(): void;\n}\n\ninterface QuestionsState {\n  organization: Observable<string>;\n  role: Observable<string>;\n  useCases: Observable<boolean>[];\n  useOther: Observable<string>;\n}\n\ninterface VideoState {\n  watched: Observable<boolean>;\n}\n\nexport class OnboardingPage extends Disposable {\n  private _steps: Step[];\n  private _stepIndex: Observable<number> = Observable.create(this, 0);\n\n  constructor(private _appModel: AppModel) {\n    super();\n\n    this.autoDispose(this._stepIndex.addListener((_, prevIndex) => {\n      this._steps[prevIndex].onNavigateAway?.();\n    }));\n\n    const incrementStep: IncrementStep = (delta: -1 | 1 = 1) => {\n      this._stepIndex.set(this._stepIndex.get() + delta);\n    };\n\n    this._steps = [\n      {\n        state: {\n          organization: Observable.create(this, \"\"),\n          role: Observable.create(this, \"\"),\n          useCases: choices.map(() => Observable.create(this, false)),\n          useOther: Observable.create(this, \"\"),\n        },\n        buildDom() { return dom.create(buildQuestions, incrementStep, this.state as QuestionsState); },\n        onNavigateAway() { saveQuestions(this.state as QuestionsState); },\n      },\n      {\n        state: {\n          watched: Observable.create(this, false),\n        },\n        buildDom() { return dom.create(buildVideo, incrementStep, this.state as VideoState); },\n      },\n      {\n        buildDom() { return dom.create(buildTutorial, incrementStep); },\n      },\n    ];\n\n    document.title = `Welcome${getPageTitleSuffix(getGristConfig())}`;\n\n    getUserPrefObs(this._appModel.userPrefsObs, \"showNewUserQuestions\").set(undefined);\n  }\n\n  public buildDom() {\n    return cssPageContainer(\n      cssOnboardingPage(\n        cssSidebar(\n          cssSidebarContent(\n            cssSidebarHeading1(t(\"Welcome\")),\n            cssSidebarHeading2(this._appModel.currentUser!.name + \"!\"),\n            testId(\"sidebar\"),\n          ),\n          cssGetStarted(\n            cssGetStartedImg({ src: \"img/get-started.png\" }),\n          ),\n        ),\n        cssMainPanel(\n          buildStepper(this._steps, this._stepIndex),\n          dom.domComputed(this._stepIndex, (index) => {\n            return this._steps[index].buildDom();\n          }),\n        ),\n        testId(\"page\"),\n      ),\n    );\n  }\n}\n\nfunction buildStepper(steps: Step[], stepIndex: Observable<number>) {\n  return cssStepper(\n    steps.map((_, i) =>\n      cssStep(\n        cssStepCircle(\n          cssStepCircle.cls(\"-done\", use => (i < use(stepIndex))),\n          dom.domComputed(use => i < use(stepIndex), done => done ? icon(\"Tick\") : String(i + 1)),\n          cssStepCircle.cls(\"-current\", use => (i === use(stepIndex))),\n          dom.on(\"click\", () => { stepIndex.set(i); }),\n          testId(`step-${i + 1}`),\n        ),\n      ),\n    ),\n  );\n}\n\nfunction saveQuestions(state: QuestionsState) {\n  const { organization, role, useCases, useOther } = state;\n  if (!organization.get() && !role.get() && !useCases.map(useCase => useCase.get()).includes(true)) {\n    return;\n  }\n\n  const org_name = organization.get();\n  const org_role = role.get();\n  const use_cases = choices.filter((c, i) => useCases[i].get()).map(c => c.textKey);\n  const use_other = use_cases.includes(\"Other\") ? useOther.get() : \"\";\n  const submitUrl = new URL(window.location.href);\n  submitUrl.pathname = \"/welcome/info\";\n  BaseAPI.request(submitUrl.href, {\n    method: \"POST\",\n    body: JSON.stringify({ org_name, org_role, use_cases, use_other }),\n  }).catch(e => logError(e));\n}\n\nfunction buildQuestions(owner: IDisposableOwner, incrementStep: IncrementStep, state: QuestionsState) {\n  const { organization, role, useCases, useOther } = state;\n  const isFilled = Computed.create(owner, (use) => {\n    return Boolean(use(organization) || use(role) || useCases.map(useCase => use(useCase)).includes(true));\n  });\n\n  return cssQuestions(\n    cssHeading(t(\"Tell us who you are\")),\n    cssQuestion(\n      cssFieldHeading(t(\"What organization are you with?\")),\n      cssInput(\n        organization,\n        { type: \"text\", placeholder: t(\"Your organization\") },\n        testId(\"questions-organization\"),\n      ),\n    ),\n    cssQuestion(\n      cssFieldHeading(t(\"What is your role?\")),\n      cssInput(\n        role,\n        { type: \"text\", placeholder: t(\"Your role\") },\n        testId(\"questions-role\"),\n      ),\n    ),\n    cssQuestion(\n      cssFieldHeading(t(\"What brings you to Grist (you can select multiple)?\")),\n      cssUseCases(\n        choices.map((item, i) => cssUseCase(\n          cssUseCaseIcon(icon(item.icon)),\n          cssUseCase.cls(\"-selected\", useCases[i]),\n          dom.on(\"click\", () => useCases[i].set(!useCases[i].get())),\n          (item.icon !== \"UseOther\" ?\n            t(item.textKey) :\n            [\n              cssOtherLabel(t(item.textKey)),\n              cssOtherInput(useOther, {}, { type: \"text\", placeholder: t(\"Type here\") },\n                // The following subscribes to changes to selection observable, and focuses the input when\n                // this item is selected.\n                elem => subscribeElem(elem, useCases[i], val => val && setTimeout(() => elem.focus(), 0)),\n                // It's annoying if clicking into the input toggles selection; better to turn that\n                // off (user can click icon to deselect).\n                dom.on(\"click\", ev => ev.stopPropagation()),\n                // Similarly, ignore Enter/Escape in \"Other\" textbox, so that they don't submit/close the form.\n                dom.onKeyDown({\n                  Enter: (ev, elem) => elem.blur(),\n                  Escape: (ev, elem) => elem.blur(),\n                }),\n              ),\n            ]\n          ),\n          testId(\"questions-use-case\"),\n        )),\n      ),\n    ),\n    cssContinue(\n      bigPrimaryButton(\n        t(\"Next step\"),\n        dom.show(isFilled),\n        dom.on(\"click\", () => incrementStep()),\n        testId(\"next-step\"),\n      ),\n      bigBasicButton(\n        t(\"Skip step\"),\n        dom.hide(isFilled),\n        dom.on(\"click\", () => incrementStep()),\n        testId(\"skip-step\"),\n      ),\n    ),\n    testId(\"questions\"),\n  );\n}\n\nfunction buildVideo(_owner: IDisposableOwner, incrementStep: IncrementStep, state: VideoState) {\n  const { watched } = state;\n\n  function onPlay() {\n    watched.set(true);\n\n    return modal((ctl, modalOwner) => {\n      const youtubePlayer = YouTubePlayer.create(modalOwner,\n        commonUrls.onboardingTutorialVideoId,\n        {\n          onPlayerReady: player => player.playVideo(),\n          onPlayerStateChange(_player, { data }) {\n            if (data !== PlayerState.Ended) { return; }\n\n            ctl.close();\n          },\n          height: \"100%\",\n          width: \"100%\",\n          origin: getMainOrgUrl(),\n        },\n        cssYouTubePlayer.cls(\"\"),\n      );\n\n      return [\n        dom.on(\"click\", () => ctl.close()),\n        (elem) => { FocusLayer.create(modalOwner, { defaultFocusElem: elem, pauseMousetrap: true }); },\n        dom.onKeyDown({\n          \"Escape\": () => ctl.close(),\n          \" \": () => youtubePlayer.playPause(),\n        }),\n        cssModalHeader(\n          cssModalCloseButton(\n            cssCloseIcon(\"CrossBig\"),\n          ),\n        ),\n        cssModalBody(\n          cssVideoPlayer(\n            dom.on(\"click\", ev => ev.stopPropagation()),\n            youtubePlayer.buildDom(),\n            testId(\"video-player\"),\n          ),\n          cssModalButtons(\n            bigPrimaryButton(\n              t(\"Next step\"),\n              dom.on(\"click\", (ev) => {\n                ev.stopPropagation();\n                ctl.close();\n                incrementStep();\n              }),\n            ),\n          ),\n        ),\n        cssVideoPlayerModal.cls(\"\"),\n      ];\n    });\n  }\n\n  return dom(\"div\",\n    cssHeading(t(\"Discover Grist in 3 minutes\")),\n    cssScreenshot(\n      dom.on(\"click\", onPlay),\n      dom(\"div\",\n        cssScreenshotImg({ src: \"img/youtube-screenshot.png\" }),\n        cssActionOverlay(\n          cssAction(\n            cssRoundButton(cssVideoPlayIcon(\"VideoPlay\")),\n          ),\n        ),\n      ),\n      testId(\"video-thumbnail\"),\n    ),\n    cssContinue(\n      cssBackButton(\n        t(\"Back\"),\n        dom.on(\"click\", () => incrementStep(-1)),\n        testId(\"back\"),\n      ),\n      bigPrimaryButton(\n        t(\"Next step\"),\n        dom.show(watched),\n        dom.on(\"click\", () => incrementStep()),\n        testId(\"next-step\"),\n      ),\n      bigBasicButton(\n        t(\"Skip step\"),\n        dom.hide(watched),\n        dom.on(\"click\", () => incrementStep()),\n        testId(\"skip-step\"),\n      ),\n    ),\n    testId(\"video\"),\n  );\n}\n\nfunction buildTutorial(_owner: IDisposableOwner, incrementStep: IncrementStep) {\n  const { templateOrg, onboardingTutorialDocId } = getGristConfig();\n  return dom(\"div\",\n    cssHeading(\n      t(\"Go hands-on with the Grist Basics tutorial\"),\n      cssSubHeading(\n        t(\"Grist may look like a spreadsheet, but it doesn't always \\\nact like one. Discover what makes Grist different.\",\n        ),\n      ),\n    ),\n    cssTutorial(\n      cssScreenshot(\n        dom.on(\"click\", () => urlState().pushUrl({ org: templateOrg!, doc: onboardingTutorialDocId })),\n        cssTutorialScreenshotImg({ src: \"img/tutorial-screenshot.png\" }),\n        cssTutorialOverlay(\n          cssAction(\n            cssTutorialButton(t(\"Go to the tutorial!\")),\n          ),\n        ),\n        testId(\"tutorial-thumbnail\"),\n      ),\n    ),\n    cssContinue(\n      cssBackButton(\n        t(\"Back\"),\n        dom.on(\"click\", () => incrementStep(-1)),\n        testId(\"back\"),\n      ),\n      bigBasicButton(\n        t(\"Skip tutorial\"),\n        dom.on(\"click\", () => window.location.href = urlState().makeUrl(urlState().state.get())),\n        testId(\"skip-tutorial\"),\n      ),\n    ),\n    testId(\"tutorial\"),\n  );\n}\n\nconst cssPageContainer = styled(\"div\", `\n  overflow: auto;\n  height: 100%;\n  background-color: ${theme.mainPanelBg};\n`);\n\nconst cssOnboardingPage = styled(\"div\", `\n  display: flex;\n  min-height: 100%;\n`);\n\nconst cssSidebar = styled(\"div\", `\n  width: 460px;\n  background-color: ${colors.lightGreen};\n  color: ${colors.light};\n  background-image:\n    linear-gradient(to bottom, rgb(41, 185, 131) 32px, transparent 32px),\n    linear-gradient(to right, rgb(41, 185, 131) 32px, transparent 32px);\n  background-size: 240px 120px;\n  background-position: 0 0, 40%;\n  display: flex;\n  flex-direction: column;\n\n  @media ${mediaMedium} {\n    & {\n      display: none;\n    }\n  }\n`);\n\nconst cssGetStarted = styled(\"div\", `\n  width: 500px;\n  height: 350px;\n  margin: auto -77px 0 37px;\n  overflow: hidden;\n`);\n\nconst cssGetStartedImg = styled(\"img\", `\n  display: block;\n  width: 500px;\n  height: auto;\n`);\n\nconst cssSidebarContent = styled(\"div\", `\n  line-height: 32px;\n  margin: 112px 16px 64px 16px;\n  font-size: 24px;\n  line-height: 48px;\n  font-weight: 500;\n`);\n\nconst cssSidebarHeading1 = styled(\"div\", `\n  font-size: 32px;\n  text-align: center;\n`);\n\nconst cssSidebarHeading2 = styled(\"div\", `\n  font-size: 28px;\n  text-align: center;\n`);\n\nconst cssMainPanel = styled(\"div\", `\n  margin: 56px auto;\n  padding: 0px 96px;\n  text-align: center;\n\n  @media ${mediaMedium} {\n    & {\n      padding: 0px 32px;\n    }\n  }\n`);\n\nconst cssHeading = styled(\"div\", `\n  color: ${theme.text};\n  font-size: 24px;\n  font-weight: 500;\n  margin: 32px 0px;\n`);\n\nconst cssSubHeading = styled(cssHeading, `\n  font-size: 15px;\n  font-weight: 400;\n  margin-top: 16px;\n`);\n\nconst cssStep = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  cursor: default;\n\n  &:not(:last-child)::after {\n    content: \"\";\n    width: 50px;\n    height: 2px;\n    background-color: var(--grist-color-light-green);\n  }\n`);\n\nconst cssStepCircle = styled(\"div\", `\n  --icon-color: ${theme.controlPrimaryFg};\n  --step-color: ${theme.controlPrimaryBg};\n  display: inline-block;\n  width: 24px;\n  height: 24px;\n  border-radius: 30px;\n  border: 1px solid var(--step-color);\n  color: var(--step-color);\n  margin: 4px;\n  position: relative;\n  cursor: pointer;\n\n  &:hover {\n    --step-color: ${theme.controlPrimaryHoverBg};\n  }\n  &-current {\n    background-color: var(--step-color);\n    color: ${theme.controlPrimaryFg};\n    outline: 3px solid ${theme.cursorInactive};\n  }\n  &-done {\n    background-color: var(--step-color);\n  }\n`);\n\nconst cssQuestions = styled(\"div\", `\n  max-width: 500px;\n`);\n\nconst cssQuestion = styled(\"div\", `\n  margin: 16px 0 8px 0;\n  text-align: left;\n`);\n\nconst cssFieldHeading = styled(\"div\", `\n  color: ${theme.text};\n  font-size: 13px;\n  font-weight: 700;\n  margin-bottom: 12px;\n`);\n\nconst cssContinue = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  margin-top: 40px;\n  gap: 16px;\n`);\n\nconst cssUseCases = styled(\"div\", `\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  margin: -8px -4px;\n`);\n\nconst cssUseCase = styled(\"div\", `\n  flex: 1 0 40%;\n  min-width: 200px;\n  margin: 8px 4px 0 4px;\n  height: 40px;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  display: flex;\n  align-items: center;\n  text-align: left;\n  cursor: pointer;\n  color: ${theme.text};\n  --icon-color: ${theme.accentIcon};\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n  &-selected {\n    border: 2px solid ${theme.controlFg};\n  }\n  &-selected:hover {\n    border: 2px solid ${theme.controlHoverFg};\n  }\n  &-selected:focus-within {\n    box-shadow: 0 0 2px 0px ${theme.controlFg};\n  }\n`);\n\nconst cssUseCaseIcon = styled(\"div\", `\n  margin: 0 16px;\n  --icon-color: ${theme.accentIcon};\n`);\n\nconst cssOtherLabel = styled(\"div\", `\n  display: block;\n\n  .${cssUseCase.className}-selected & {\n    display: none;\n  }\n`);\n\nconst cssInput = styled(textInput, `\n  height: 40px;\n`);\n\nconst cssOtherInput = styled(input, `\n  color: ${theme.inputFg};\n  display: none;\n  border: none;\n  background: none;\n  outline: none;\n  padding: 0px;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n  .${cssUseCase.className}-selected & {\n    display: block;\n  }\n`);\n\nconst cssTutorial = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n`);\n\nconst cssScreenshot = styled(\"div\", `\n  max-width: 720px;\n  display: flex;\n  position: relative;\n  border-radius: 3px;\n  border: 3px solid ${colors.lightGreen};\n  overflow: hidden;\n  cursor: pointer;\n`);\n\nconst cssActionOverlay = styled(\"div\", `\n  position: absolute;\n  z-index: 1;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  background-color: rgba(0, 0, 0, 0.20);\n`);\n\nconst cssTutorialOverlay = styled(cssActionOverlay, `\n  background-color: transparent;\n`);\n\nconst cssAction = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  margin: auto;\n  align-items: center;\n  justify-content: center;\n  height: 100%;\n`);\n\nconst cssVideoPlayIcon = styled(icon, `\n  --icon-color: ${colors.light};\n  width: 38px;\n  height: 33.25px;\n`);\n\nconst cssCloseIcon = styled(icon, `\n  --icon-color: ${colors.light};\n  width: 22px;\n  height: 22px;\n`);\n\nconst cssYouTubePlayer = styled(\"iframe\", `\n  border-radius: 4px;\n`);\n\nconst cssModalHeader = styled(\"div\", `\n  display: flex;\n  flex-shrink: 0;\n  justify-content: flex-end;\n`);\n\nconst cssModalBody = styled(\"div\", `\n  display: flex;\n  flex-grow: 1;\n  flex-direction: column;\n  justify-content: center;\n  align-items: center;\n`);\n\nconst cssBackButton = styled(bigBasicButton, `\n  border: none;\n`);\n\nconst cssModalButtons = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  margin-top: 24px;\n`);\n\nconst cssVideoPlayer = styled(\"div\", `\n  width: 100%;\n  max-width: 1280px;\n  height: 100%;\n  max-height: 720px;\n\n  @media ${mediaXSmall} {\n    & {\n      max-height: 240px;\n    }\n  }\n`);\n\nconst cssVideoPlayerModal = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n  height: 100%;\n  padding: 8px;\n  background-color: transparent;\n  box-shadow: none;\n`);\n\nconst cssModalCloseButton = styled(\"div\", `\n  margin-bottom: 8px;\n  padding: 4px;\n  border-radius: 4px;\n  cursor: pointer;\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssScreenshotImg = styled(\"img\", `\n  transform: scale(1.2);\n  width: 100%;\n`);\n\nconst cssTutorialScreenshotImg = styled(\"img\", `\n  width: 100%;\n  opacity: 0.4;\n`);\n\nconst cssRoundButton = styled(\"div\", `\n  width: 75px;\n  height: 75px;\n  flex-shrink: 0;\n  border-radius: 100px;\n  background: ${colors.lightGreen};\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  --icon-color: var(--light, #FFF);\n\n  .${cssScreenshot.className}:hover & {\n    background: ${colors.darkGreen};\n  }\n`);\n\nconst cssStepper = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  text-align: center;\n  font-size: 14px;\n  font-style: normal;\n  font-weight: 700;\n  line-height: 20px;\n  text-transform: uppercase;\n`);\n\nconst cssTutorialButton = styled(bigPrimaryButtonLink, `\n  .${cssScreenshot.className}:hover & {\n    background-color: ${theme.controlPrimaryHoverBg};\n    border-color: ${theme.controlPrimaryHoverBg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/OpenAccessibilityModal.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { inlineMarkdown, markdown } from \"app/client/lib/markdown\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { bigPrimaryButton, cssButton } from \"app/client/ui2018/buttons\";\nimport { cssLink, cssNestedLinks } from \"app/client/ui2018/links\";\nimport { cssModalBody,\n  cssModalButtons,\n  cssModalSubheading,\n  cssModalTitle,\n  cssModalWidth,\n  IModalControl,\n  modal,\n} from \"app/client/ui2018/modals\";\nimport { commonUrls, isFeatureEnabled } from \"app/common/gristUrls\";\nimport { tokens } from \"app/common/ThemePrefs\";\n\nimport { dom, makeTestId, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"OpenAccessibilityModal\");\n\nconst testId = makeTestId(\"test-accessibility-modal-\");\n\n/**\n * Opens a modal containing accessibility options.\n */\nexport function openAccessibilityModal(appObs: Observable<AppModel | null>) {\n  const showHighContrastTheme = isFeatureEnabled(\"themes\");\n\n  return modal(\n    (ctl) => {\n      return [\n        cssModalWidth(\"fixed-wide\"),\n        cssModalTitle(\n          t(`Accessibility`),\n          { \"role\": \"heading\", \"aria-level\": 1 },\n          testId(\"title\"),\n        ),\n        cssModalBody(\n          showHighContrastTheme ? highContrastThemeSection(appObs, ctl) : null,\n          keyboardSection(),\n        ),\n        cssModalButtons(\n          bigPrimaryButton(t(`Close`),\n            dom.on(\"click\", () => ctl.close()),\n            testId(\"confirm\"),\n          ),\n        ),\n      ];\n    },\n  );\n}\n\nconst highContrastThemeSection = (appObs: Observable<AppModel | null>, ctl: IModalControl) => {\n  const themePrefs = appObs.get()?.themePrefs;\n  return cssSection(\n    cssModalSubheading(t(\"High contrast theme\"), { \"role\": \"heading\", \"aria-level\": 2 }),\n    dom.domComputed((use) => {\n      const currentTheme = themePrefs ? use(themePrefs).colors.light : undefined;\n      const isHighContrast = currentTheme === \"HighContrastLight\";\n      if (isHighContrast) {\n        return dom(\"p\",\n          t(\"You are currently using the high contrast theme.\"),\n          { id: \"a11y-modal-high-contrast-theme-enabled\", tabindex: -1 },\n        );\n      }\n      return dom(\"div\",\n        markdown(t(\"You are currently **not using** the high contrast theme.\")),\n        dom(\"p\", cssAlwaysReadableButton(\n          // Note: default light theme has contrast issues.\n          // It would be weird to have this button, that is made to easily switch to a contrasted theme,\n          // being itself not readable enough.\n          // So we make sure this button is always correctly readable, whatever the theme.\n          // Note: we use a distinct class name to make css overrides easier. A light theme changed with CSS overrides\n          // might not have the contrast issues. To allow easier changes of this specific behavior in CSS overrides,\n          // the class is not generated by grainjs.\n          dom.cls(\"force-contrasts\"),\n          t(\"Use the high contrast theme (light appearance)\"),\n          dom.on(\"click\", () => {\n            themePrefs?.set({\n              ...themePrefs.get(),\n              colors: { light: \"HighContrastLight\", dark: \"HighContrastLight\" },\n              appearance: \"light\",\n              syncWithOS: false,\n            });\n            // after clicking the button, it disappears. Focus the text element above that confirms the change.\n            setTimeout(() => {\n              const element = document.getElementById(\"a11y-modal-high-contrast-theme-enabled\");\n              if (element) {\n                element.focus();\n              }\n            }, 100);\n          }),\n          testId(\"high-contrast-theme-button\"),\n        )),\n      );\n    }),\n    dom(\"p\", t(\"To see other available themes, go to your {{profileSettingsLink}}.\", {\n      profileSettingsLink: cssLink(\n        // close the modal when clicking the profile settings link, in case we already are on the profile settings page.\n        dom.on(\"click\", () => ctl.close()),\n        urlState().setLinkUrl({ account: \"account\" }),\n        t(\"profile settings\"),\n      ),\n    })),\n  );\n};\n\nconst keyboardSection = () => {\n  const nextRegionShortcut = dom(\"span\", getCssKeys(allCommands.nextRegion.humanKeys));\n  const prevRegionShortcut = dom(\"span\", getCssKeys(allCommands.prevRegion.humanKeys));\n  const creatorPanelShortcut = dom(\"span\", getCssKeys(allCommands.creatorPanel.humanKeys));\n  const shortcutsModal = dom(\"span\", getCssKeys(allCommands.shortcuts.humanKeys));\n  const accessibilityModal = dom(\"span\", getCssKeys(allCommands.accessibility.humanKeys));\n  return cssSection(\n    cssModalSubheading(t(\"Keyboard navigation\"), { \"role\": \"heading\", \"aria-level\": 2 }),\n    dom(\"p\", t(\"On a document page, keyboard navigation is first locked on the current widget.\")),\n    dom(\"p\", t(\"Focus on other parts of the user interface using the following shortcuts:\")),\n    dom(\"ul\",\n      cssShortcutRow(t(\"{{nextRegionShortcut}} Focus on the next region\", { nextRegionShortcut })),\n      cssShortcutRow(t(\"{{prevRegionShortcut}} Focus on the previous region\", { prevRegionShortcut })),\n      cssShortcutRow(t(\"{{creatorPanelShortcut}} Focus to and from the creator panel\", { creatorPanelShortcut })),\n    ),\n    dom(\"p\", t(\"\\\"Regions\\\" are what we call the different parts of the user interface:\")),\n    dom(\"ul\",\n      dom(\"li\", t(\"The left panel, home of the main navigation.\")),\n      dom(\"li\", t(\"The top panel, or the document header.\")),\n      dom(\"li\", inlineMarkdown(t(\n        \"On document pages, each [widget]({{supportPageUrl}}) is a region that can receive focus.\",\n        { supportPageUrl: commonUrls.helpWidgets },\n      ))),\n      dom(\"li\", t(\"On non-document pages, the main content area is a region.\")),\n      dom(\"li\", t(\"Finally, the right panel – or the creator panel – is only available \\\nthrough its own shortcut and is not included in the next and previous region cycle.\")),\n    ),\n    cssModalSubheading(t(\"Other important keyboard shortcuts\"), { \"role\": \"heading\", \"aria-level\": 2 }),\n    dom(\"ul\",\n      cssShortcutRow(t(\"{{shortcutsModal}} Show the complete list of keyboard shortcuts\", { shortcutsModal })),\n      cssShortcutRow(t(\"{{accessibilityModal}} Show the accessibility options (this modal)\", { accessibilityModal })),\n    ),\n  );\n};\n\nconst getCssKeys = (keys: string[]) => {\n  return keys.map((k, i) => i === keys.length - 1 ? cssKey(k) : [cssKey(k), t(\" or \")]);\n};\n\nconst cssSection = styled(cssNestedLinks, `\n  margin-bottom: 32px;\n  &:last-child {\n    margin-bottom: 0;\n  }\n`);\n\nconst cssKey = styled(\"span\", `\n  display: inline-block;\n  font-family: ${tokens.fontFamilyData};\n  color: ${tokens.body};\n  background-color: ${tokens.bgSecondary};\n  border: 1px solid ${tokens.decoration};\n  border-bottom: 3px solid ${tokens.decoration};\n  padding: 0.2em 0.4em;\n  border-radius: 0.2em;\n  line-height: 1.3;\n`);\n\nconst cssShortcutRow = styled(\"li\", `\n  display: flex;\n  align-items: baseline;\n  gap: 0.5rem;\n  margin-bottom: 0.25rem;\n\n  & > span:first-child {\n    text-align: right;\n    min-width: 110px;\n  }\n`);\n\nconst cssAlwaysReadableButton = styled(cssButton, `\n  html[data-grist-theme=\"GristLight\"] &.force-contrasts {\n    color: ${tokens.body};\n    border-color: currentColor;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/OpenUserManager.ts",
    "content": "import { loadUserManager } from \"app/client/lib/imports\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { FullUser, Organization, UserAPI } from \"app/common/UserAPI\";\n\nexport interface ManageTeamUsersOptions {\n  org: Organization;\n  user: FullUser | null;\n  api: UserAPI;\n  onSave?: (personal: boolean) => Promise<unknown>;\n}\n\n// Opens the user-manager for the given org.\nexport async function manageTeamUsers({ org, user, api, onSave }: ManageTeamUsersOptions) {\n  (await loadUserManager()).showUserManagerModal(api, {\n    permissionData: api.getOrgAccess(org.id),\n    activeUser: user,\n    resourceType: \"organization\",\n    resourceId: org.id,\n    resource: org,\n    onSave,\n  });\n}\n\nexport interface ManagePersonalUsersAppOptions {\n  app: AppModel;\n  onSave?: (personal: boolean) => Promise<unknown>;\n}\n\n// Opens the user-manager for the current org in the given AppModel.\nexport async function manageTeamUsersApp({ app, onSave }: ManagePersonalUsersAppOptions) {\n  if (app.currentOrg) {\n    return manageTeamUsers({ org: app.currentOrg, user: app.currentValidUser, api: app.api, onSave });\n  }\n}\n"
  },
  {
    "path": "app/client/ui/OpenVideoTour.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { getMainOrgUrl } from \"app/client/models/gristUrlState\";\nimport { cssLinkText, cssPageButton, cssPageEntry, cssPageIcon } from \"app/client/ui/LeftPanelCommon\";\nimport { YouTubePlayer } from \"app/client/ui/YouTubePlayer\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssModalCloseButton, modal } from \"app/client/ui2018/modals\";\nimport { commonUrls, isFeatureEnabled } from \"app/common/gristUrls\";\n\nimport { dom, keyframes, makeTestId, styled } from \"grainjs\";\n\nconst t = makeT(\"OpenVideoTour\");\n\nconst testId = makeTestId(\"test-video-tour-\");\n\n/**\n * Opens a modal containing a video tour of Grist.\n */\nexport function openVideoTour(refElement: HTMLElement) {\n  return modal(\n    (ctl, owner) => {\n      const youtubePlayer = YouTubePlayer.create(owner,\n        commonUrls.onboardingTutorialVideoId,\n        {\n          onPlayerReady: player => player.playVideo(),\n          height: \"100%\",\n          width: \"100%\",\n          origin: getMainOrgUrl(),\n          playerVars: {\n            rel: 0,\n          },\n        },\n        cssYouTubePlayer.cls(\"\"),\n      );\n\n      owner.onDispose(async () => {\n        if (youtubePlayer.isLoading()) { return; }\n\n        logTelemetryEvent(\"watchedVideoTour\", {\n          limited: { watchTimeSeconds: Math.floor(youtubePlayer.getCurrentTime()) },\n        });\n      });\n\n      return [\n        cssModal.cls(\"\"),\n        cssModalCloseButton(\n          cssCloseIcon(\"CrossBig\"),\n          dom.on(\"click\", () => ctl.close()),\n          testId(\"close\"),\n        ),\n        cssYouTubePlayerContainer(youtubePlayer.buildDom()),\n        testId(\"modal\"),\n      ];\n    },\n    {\n      refElement,\n      variant: \"collapsing\",\n    },\n  );\n}\n\n/**\n * Creates a text button that shows the video tour on click.\n */\nexport function createVideoTourTextButton(): HTMLDivElement {\n  const elem: HTMLDivElement = cssVideoTourTextButton(\n    cssVideoIcon(\"Video\"),\n    t(\"Grist Video Tour\"),\n    dom.on(\"click\", () => openVideoTour(elem)),\n    testId(\"text-button\"),\n  );\n\n  return elem;\n}\n\n/**\n * Creates the \"Video Tour\" button for the \"Tools\" section of the left panel.\n *\n * Shows the video tour on click.\n */\nexport function createVideoTourToolsButton(): HTMLDivElement | null {\n  if (!isFeatureEnabled(\"helpCenter\")) { return null; }\n\n  let iconElement: HTMLElement;\n\n  return cssPageEntry(\n    cssPageButton(\n      iconElement = cssPageIcon(\"Video\"),\n      cssLinkText(t(\"Video Tour\")),\n      dom.cls(\"tour-help-center\"),\n      dom.on(\"click\", () => openVideoTour(iconElement)),\n      testId(\"tools-button\"),\n    ),\n  );\n}\n\nconst cssModal = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n  padding: 16px;\n  width: 100%;\n  max-width: 864px;\n`);\n\nconst delayedVisibility = keyframes(`\n  to {\n    visibility: visible;\n  }\n`);\n\nconst cssYouTubePlayerContainer = styled(\"div\", `\n  position: relative;\n  padding-bottom: 56.25%;\n  height: 0;\n  /* Wait until the modal is finished animating. */\n  visibility: hidden;\n  animation: 0s linear 0.4s forwards ${delayedVisibility};\n`);\n\nconst cssYouTubePlayer = styled(\"div\", `\n  position: absolute;\n  top: 0;\n  left: 0;\n`);\n\nconst cssVideoTourTextButton = styled(\"div\", `\n  color: ${theme.controlFg};\n  cursor: pointer;\n\n  &:hover {\n    color: ${theme.controlHoverFg};\n  }\n`);\n\nconst cssVideoIcon = styled(icon, `\n  background-color: ${theme.controlFg};\n  cursor: pointer;\n  margin: 0px 4px 3px 0;\n\n  .${cssVideoTourTextButton.className}:hover > & {\n    background-color: ${theme.controlHoverFg};\n  }\n`);\n\nconst cssCloseIcon = styled(icon, `\n  padding: 12px;\n`);\n"
  },
  {
    "path": "app/client/ui/PagePanels.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { RegionFocusSwitcher } from \"app/client/components/RegionFocusSwitcher\";\nimport { watchElementForBlur } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { SessionObs } from \"app/client/lib/sessionObs\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { App } from \"app/client/ui/App\";\nimport { resizeFlexVHandle } from \"app/client/ui/resizeHandle\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { transition, TransitionWatcher } from \"app/client/ui/transitions\";\nimport { cssHideForNarrowScreen, isScreenResizing, mediaNotSmall, mediaSmall, theme } from \"app/client/ui2018/cssVars\";\nimport { isNarrowScreenObs } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\n\nimport {\n  dom, DomElementArg, DomElementMethod, MultiHolder, noTestId, Observable, styled, subscribe, TestId,\n} from \"grainjs\";\nimport debounce from \"lodash/debounce\";\nimport noop from \"lodash/noop\";\nimport once from \"lodash/once\";\n\nconst t = makeT(\"PagePanels\");\n\nconst AUTO_EXPAND_TIMEOUT_MS = 400;\n\n// delay must be greater than the time needed for transientInput to update focus (ie: 10ms);\nconst DELAY_BEFORE_TESTING_FOCUS_CHANGE_MS = 12;\n\nexport interface PageSidePanel {\n  // Note that widths need to start out with a correct default in JS (having them in CSS is not\n  // enough), needed for open/close transitions.\n  panelWidth: Observable<number>;\n  panelOpen: Observable<boolean>;\n  hideOpener?: boolean;           // If true, don't show the opener handle.\n  header?: DomElementArg;\n  content: DomElementArg;\n}\n\nexport interface PageContents {\n  leftPanel: PageSidePanel;\n  rightPanel?: PageSidePanel;     // If omitted, the right panel isn't shown at all.\n\n  headerMain: DomElementArg;\n  contentMain: DomElementArg;\n  banner?: DomElementArg;\n\n  onResize?: () => void;          // Callback for when either pane is opened, closed, or resized.\n  testId?: TestId;\n  contentTop?: DomElementArg;\n  contentBottom?: DomElementArg;\n\n  app?: App;\n}\n\nexport function pagePanels(page: PageContents) {\n  const testId = page.testId || noTestId;\n  const left = page.leftPanel;\n  const right = page.rightPanel;\n  const onResize = page.onResize || (() => null);\n  const leftOverlap = Observable.create(null, false);\n  const dragResizer = Observable.create(null, false);\n  const bannerHeight = Observable.create(null, 0);\n  const isScreenResizingObs = isScreenResizing();\n\n  const appObj = page.app;\n\n  let lastLeftOpen = left.panelOpen.get();\n  let lastRightOpen = right?.panelOpen.get() || false;\n  let leftPaneDom: HTMLElement;\n  let rightPaneDom: HTMLElement;\n  let mainHeaderDom: HTMLElement;\n  let contentTopDom: HTMLElement;\n  let onLeftTransitionFinish = noop;\n\n  let regionFocusSwitcher: RegionFocusSwitcher | undefined;\n  // Create a RegionFocusSwitcher when we have a valid appObj (some tests don't include one).\n  if (appObj?.topAppModel) {\n    regionFocusSwitcher = RegionFocusSwitcher.create(null, appObj);\n    appObj.regionFocusSwitcher = regionFocusSwitcher;\n  }\n\n  // When switching to mobile mode, close panels; when switching to desktop, restore the\n  // last desktop state.\n  const sub1 = subscribe(isNarrowScreenObs(), (use, narrow) => {\n    if (narrow) {\n      lastLeftOpen = leftOverlap.get() ? false : left.panelOpen.get();\n      lastRightOpen = right?.panelOpen.get() || false;\n    }\n    left.panelOpen.set(narrow ? false : lastLeftOpen);\n    right?.panelOpen.set(narrow ? false : lastRightOpen);\n\n    // overlap should always be OFF when switching screen mode\n    leftOverlap.set(false);\n  });\n\n  // When url changes, we must have navigated;\n  //   - close the left panel since if it were open, it was the likely cause of the navigation\n  //     (e.g. switch to another page or workspace).\n  //   - reset the focus switcher to behave like a normal browser navigation (lose focus).\n  const sub2 = subscribe(isNarrowScreenObs(), urlState().state, (use, narrow, state) => {\n    if (narrow) {\n      left.panelOpen.set(false);\n    }\n    regionFocusSwitcher?.reset();\n  });\n\n  const pauseSavingLeft = (yesNo: boolean) => {\n    (left.panelOpen as SessionObs<boolean>)?.pauseSaving?.(yesNo);\n  };\n\n  const commandsGroup = commands.createGroup({\n    leftPanelOpen: () => new Promise((resolve) => {\n      const watcher = new TransitionWatcher(leftPaneDom);\n      watcher.onDispose(() => resolve(undefined));\n      left.panelOpen.set(true);\n    }),\n    rightPanelOpen: () => new Promise((resolve, reject) => {\n      if (!right) {\n        reject(new Error(\"PagePanels rightPanelOpen called while right panel is undefined\"));\n        return;\n      }\n\n      const watcher = new TransitionWatcher(rightPaneDom);\n      watcher.onDispose(() => resolve(undefined));\n      right.panelOpen.set(true);\n    }),\n  }, null, true);\n  let contentWrapper: HTMLElement;\n  return cssPageContainer(\n    dom.autoDispose(sub1),\n    dom.autoDispose(sub2),\n    dom.autoDispose(commandsGroup),\n    dom.autoDispose(leftOverlap),\n    regionFocusSwitcher ? dom.autoDispose(regionFocusSwitcher) : null,\n    dom(\"div\", testId(\"top-panel\"), page.contentTop, (elem) => { contentTopDom = elem; }),\n    dom.maybe(page.banner, () => {\n      let elem: HTMLElement;\n      const updateTop = () => {\n        const height = mainHeaderDom.getBoundingClientRect().bottom;\n        elem.style.top = height + \"px\";\n      };\n      setTimeout(() => watchHeightElem(contentTopDom, updateTop));\n      const lis = isScreenResizingObs.addListener(val => val || updateTop());\n      return elem = cssBannerContainer(\n        page.banner,\n        watchHeight(h => bannerHeight.set(h)),\n        dom.autoDispose(lis),\n      );\n    }),\n    cssContentMain(\n      (el) => {\n        regionFocusSwitcher?.onPageDomLoaded(el);\n      },\n      leftPaneDom = cssLeftPane(\n        testId(\"left-panel\"),\n        regionFocusSwitcher?.panelAttrs(\"left\", t(\"Main navigation and document settings (left panel)\")),\n        cssOverflowContainer(\n          contentWrapper = cssLeftPanelContainer(\n            cssLeftPaneHeader(\n              left.header,\n              dom.style(\"margin-bottom\", use => use(bannerHeight) + \"px\"),\n            ),\n            left.content,\n          ),\n        ),\n\n        // Show plain border when the resize handle is hidden.\n        cssResizeDisabledBorder(\n          dom.hide(use => use(left.panelOpen) && !use(leftOverlap)),\n          cssHideForNarrowScreen.cls(\"\"),\n          testId(\"left-disabled-resizer\"),\n        ),\n\n        dom.style(\"width\", use => use(left.panelOpen) ? use(left.panelWidth) + \"px\" : \"\"),\n\n        // Opening/closing the left pane, with transitions.\n        cssLeftPane.cls(\"-open\", left.panelOpen),\n        transition(use => (use(isNarrowScreenObs()) ? false : use(left.panelOpen)), {\n          prepare(elem, open) {\n            elem.style.width = (open ? 48 : left.panelWidth.get()) + \"px\";\n          },\n          run(elem, open) {\n            elem.style.width = contentWrapper.style.width = (open ? left.panelWidth.get() : 48) + \"px\";\n          },\n          finish() {\n            onResize();\n            contentWrapper.style.width = \"\";\n            onLeftTransitionFinish();\n          },\n        }),\n\n        // opening left panel on hover\n        dom.on(\"mouseenter\", (evt1, elem) => {\n          if (left.panelOpen.get() ||\n\n            // when no opener should not auto-expand\n            left.hideOpener ||\n\n            // if user is resizing the window, don't expand.\n            isScreenResizingObs.get()) { return; }\n\n          let isMouseInsideLeftPane = true;\n          let isFocusInsideLeftPane = false;\n          let isMouseDragging = false;\n\n          const owner = new MultiHolder();\n          const startExpansion = () => {\n            leftOverlap.set(true);\n            pauseSavingLeft(true); // prevents from updating state in the window storage\n            left.panelOpen.set(true);\n            onLeftTransitionFinish = noop;\n            watchBlur();\n          };\n          const startCollapse = () => {\n            left.panelOpen.set(false);\n            pauseSavingLeft(false);\n            // turns overlap off only when the transition finishes\n            onLeftTransitionFinish = once(() => leftOverlap.set(false));\n            clear();\n          };\n          const clear = () => {\n            if (owner.isDisposed()) { return; }\n            clearTimeout(timeoutId);\n            owner.dispose();\n          };\n          dom.onDisposeElem(elem, clear);\n\n          // updates isFocusInsideLeftPane and starts watch for blur on activeElement.\n          const watchBlur = debounce(() => {\n            if (owner.isDisposed()) { return; }\n            isFocusInsideLeftPane = Boolean(leftPaneDom.contains(document.activeElement) ||\n              document.activeElement?.closest(\".grist-floating-menu\"));\n            maybeStartCollapse();\n            if (document.activeElement) {\n              maybePatchDomAndChangeFocus(); // This is to support projects test environment\n              watchElementForBlur(document.activeElement, watchBlur);\n            }\n          }, DELAY_BEFORE_TESTING_FOCUS_CHANGE_MS);\n\n          // starts collapsed only if neither mouse nor focus are inside the left pane. Return true\n          // if started collapsed, false otherwise.\n          const maybeStartCollapse = () => {\n            if (!isMouseInsideLeftPane && !isFocusInsideLeftPane && !isMouseDragging) {\n              startCollapse();\n            }\n          };\n\n          // mouse events\n          const onMouseEvt = (evt: MouseEvent) => {\n            const rect = leftPaneDom.getBoundingClientRect();\n            isMouseInsideLeftPane = evt.clientX <= rect.right;\n            isMouseDragging = evt.buttons !== 0;\n            maybeStartCollapse();\n          };\n          owner.autoDispose(dom.onElem(document, \"mousemove\", onMouseEvt));\n          owner.autoDispose(dom.onElem(document, \"mouseup\", onMouseEvt));\n\n          // Enables collapsing when the cursor leaves the window. This comes handy in a split\n          // screen setup, especially when Grist is on the right side: moving the cursor back and\n          // forth between the 2 windows, the cursor is likely to hover the left pane and expand it\n          // inadvertendly. This line collapses it back.\n          const onMouseLeave = () => {\n            isMouseInsideLeftPane = false;\n            maybeStartCollapse();\n          };\n          owner.autoDispose(dom.onElem(document.body, \"mouseleave\", onMouseLeave));\n\n          // schedule start of expansion\n          const timeoutId = setTimeout(startExpansion, AUTO_EXPAND_TIMEOUT_MS);\n        }),\n        cssLeftPane.cls(\"-overlap\", leftOverlap),\n        cssLeftPane.cls(\"-dragging\", dragResizer),\n      ),\n\n      // Resizer for the left pane.\n      // TODO: resizing to small size should collapse. possibly should allow expanding too\n      cssResizeFlexVHandle(\n        { target: \"left\", onSave: (val) => {\n          left.panelWidth.set(val); onResize();\n          leftPaneDom.style.width = val + \"px\";\n          setTimeout(() => dragResizer.set(false), 0);\n        },\n        onDrag: (val) => { dragResizer.set(true); } },\n        testId(\"left-resizer\"),\n        dom.show(use => use(left.panelOpen) && !use(leftOverlap)),\n        cssHideForNarrowScreen.cls(\"\")),\n\n      cssMainPane(\n        mainHeaderDom = cssTopHeader(\n          testId(\"top-header\"),\n          regionFocusSwitcher?.panelAttrs(\"top\", t(\"Document header\")),\n          (left.hideOpener ? null :\n            unstyledButton(\n              { \"aria-label\": left.panelOpen.get() ?\n                t(\"Close navigation panel (left panel)\") :\n                t(\"Open navigation panel (left panel)\") },\n              dom.on(\"click\", () => toggleObs(left.panelOpen)),\n              cssPanelOpener(\n                \"PanelRight\",\n                cssPanelOpener.cls(\"-open\", left.panelOpen),\n                testId(\"left-opener\"),\n                cssHideForNarrowScreen.cls(\"\"),\n              ),\n            )\n          ),\n\n          page.headerMain,\n\n          (!right || right.hideOpener ? null :\n            unstyledButton(\n              { \"aria-label\": right.panelOpen.get() ? t(\"Close Creator Panel\") : t(\"Open creator panel\") },\n              dom.on(\"click\", () => toggleObs(right.panelOpen)),\n              cssPanelOpener(\n                \"PanelLeft\",\n                cssPanelOpener.cls(\"-open\", right.panelOpen),\n                testId(\"right-opener\"),\n                dom.cls(\"tour-creator-panel\"),\n                hoverTooltip(\n                  () => (right.panelOpen.get() ? t(\"Close Creator Panel\") : t(\"Open creator panel\")),\n                  { key: \"topBarBtnTooltip\" },\n                ),\n                cssHideForNarrowScreen.cls(\"\"),\n              ),\n            )\n          ),\n          dom.style(\"margin-bottom\", use => use(bannerHeight) + \"px\"),\n        ),\n\n        cssContentMainPane(\n          testId(\"main-content\"),\n          regionFocusSwitcher?.panelAttrs(\"main\", t(\"Main content\")),\n          page.contentMain,\n        ),\n\n        cssMainPane.cls(\"-left-overlap\", leftOverlap),\n        testId(\"main-pane\"),\n      ),\n      (right ? [\n        // Resizer for the right pane.\n        cssResizeFlexVHandle(\n          { target: \"right\", onSave: (val) => { right.panelWidth.set(val); onResize(); } },\n          testId(\"right-resizer\"),\n          dom.show(right.panelOpen),\n          cssHideForNarrowScreen.cls(\"\")),\n\n        rightPaneDom = cssRightPane(\n          testId(\"right-panel\"),\n          regionFocusSwitcher?.panelAttrs(\"right\", t(\"Creator panel (right panel)\")),\n          cssRightPaneHeader(\n            right.header,\n            dom.style(\"margin-bottom\", use => use(bannerHeight) + \"px\"),\n          ),\n          right.content,\n\n          dom.style(\"width\", use => use(right.panelOpen) ? use(right.panelWidth) + \"px\" : \"\"),\n\n          // Opening/closing the right pane, with transitions.\n          cssRightPane.cls(\"-open\", right.panelOpen),\n          transition(use => (use(isNarrowScreenObs()) ? false : use(right.panelOpen)), {\n            prepare(elem, open) { elem.style.marginLeft = (open ? -1 : 1) * right.panelWidth.get() + \"px\"; },\n            run(elem, open) { elem.style.marginLeft = \"\"; },\n            finish: onResize,\n          }),\n        )] : null\n      ),\n      cssContentOverlay(\n        dom.show(use => use(left.panelOpen) || Boolean(right && use(right.panelOpen))),\n        dom.on(\"click\", () => {\n          left.panelOpen.set(false);\n          if (right) { right.panelOpen.set(false); }\n        }),\n        testId(\"overlay\"),\n      ),\n      dom.maybe(isNarrowScreenObs(), () =>\n        cssBottomFooter(\n          testId(\"bottom-footer\"),\n          cssPanelOpenerNarrowScreenBtn(\n            cssPanelOpenerNarrowScreen(\n              \"FieldTextbox\",\n              dom.on(\"click\", () => {\n                right?.panelOpen.set(false);\n                toggleObs(left.panelOpen);\n              }),\n              testId(\"left-opener-ns\"),\n            ),\n            cssPanelOpenerNarrowScreenBtn.cls(\"-open\", left.panelOpen),\n          ),\n          page.contentBottom,\n          (!right ? null :\n            cssPanelOpenerNarrowScreenBtn(\n              cssPanelOpenerNarrowScreen(\n                \"Settings\",\n                dom.on(\"click\", () => {\n                  left.panelOpen.set(false);\n                  toggleObs(right.panelOpen);\n                }),\n                testId(\"right-opener-ns\"),\n              ),\n              cssPanelOpenerNarrowScreenBtn.cls(\"-open\", right.panelOpen),\n            )\n          ),\n        ),\n      ),\n    ),\n  );\n}\n\nfunction toggleObs(boolObs: Observable<boolean>) {\n  boolObs.set(!boolObs.get());\n}\n\nconst bottomFooterHeightPx = 48;\nconst cssVBox = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n`);\nconst cssHBox = styled(\"div\", `\n  display: flex;\n`);\nconst cssPageContainer = styled(cssVBox, `\n  position: absolute;\n  isolation: isolate; /* Create a new stacking context */\n  z-index: 0; /* As of March 2019, isolation does not have Edge support, so force one with z-index */\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  min-width: 600px;\n  background-color: ${theme.pageBg};\n\n  @media ${mediaSmall} {\n    & {\n      min-width: 240px;\n    }\n    .interface-singlePage & {\n      padding-bottom: 0;\n    }\n  }\n`);\nconst cssContentMain = styled(cssHBox, `\n  flex: 1 1 0px;\n  overflow: hidden;\n  position: relative;\n`);\n\n// div wrapping the contentMain passed to pagePanels\nconst cssContentMainPane = styled(cssVBox, `\n  flex-grow: 1;\n  overflow: auto;\n`);\n\nexport const cssLeftPane = styled(cssVBox, `\n  position: relative;\n  background-color: ${theme.leftPanelBg};\n  width: 48px;\n  margin-right: 0px;\n  transition: width 0.4s;\n  will-change: width;\n  @media ${mediaSmall} {\n    & {\n      width: 240px;\n      position: fixed;\n      z-index: 10;\n      top: 0;\n      bottom: ${bottomFooterHeightPx}px;\n      left: -${240 + 15}px; /* adds an extra 15 pixels to also hide the box shadow */\n      visibility: hidden;\n      box-shadow: 10px 0 5px rgba(0, 0, 0, 0.2);\n      transition: left 0.4s, visibility 0.4s;\n      will-change: left;\n    }\n    &-open {\n      left: 0;\n      visibility: visible;\n    }\n  }\n  &-open {\n    width: 240px;\n  }\n  @media print {\n    & {\n      display: none;\n    }\n  }\n  .interface-singlePage & {\n    display: none;\n  }\n  &-overlap {\n    position: absolute;\n    z-index: 10;\n    top: 0;\n    bottom: 0;\n    left: 0;\n    min-width: unset;\n  }\n  &-dragging {\n    transition: unset;\n    min-width: 160px;\n    max-width: 320px;\n  }\n`);\nconst cssOverflowContainer = styled(cssVBox, `\n  overflow: hidden;\n  flex: 1 1 0px;\n`);\nconst cssMainPane = styled(cssVBox, `\n  position: relative;\n  flex: 1 1 0px;\n  min-width: 0px;\n  background-color: ${theme.mainPanelBg};\n  z-index: 1;\n  &-left-overlap {\n    margin-left: 48px;\n  }\n  @media ${mediaSmall} {\n    & {\n      padding-bottom: ${bottomFooterHeightPx}px;\n    }\n  }\n`);\nconst cssRightPane = styled(cssVBox, `\n  position: relative;\n  background-color: ${theme.rightPanelBg};\n  width: 0px;\n  margin-left: 0px;\n  overflow: hidden;\n  transition: margin-left 0.4s;\n  z-index: 0;\n  @media ${mediaSmall} {\n    & {\n      width: 240px;\n      position: fixed;\n      z-index: 10;\n      top: 0;\n      bottom: ${bottomFooterHeightPx}px;\n      right: -${240 + 15}px; /* adds an extra 15 pixels to also hide the box shadow */\n      box-shadow: -10px 0 5px rgba(0, 0, 0, 0.2);\n      visibility: hidden;\n      transition: right 0.4s, visibility 0.4s;\n      will-change: right;\n    }\n    &-open {\n      right: 0;\n      visibility: visible;\n    }\n  }\n  &-open {\n    width: 240px;\n    min-width: 240px;\n    max-width: 320px;\n  }\n  @media print {\n    & {\n      display: none;\n    }\n  }\n  .interface-singlePage & {\n    display: none;\n  }\n`);\nconst cssHeader = styled(\"div\", `\n  height: 49px;\n  flex: none;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  border-bottom: 1px solid ${theme.pagePanelsBorder};\n\n  @media print {\n    & {\n      display: none;\n    }\n  }\n\n  .interface-singlePage & {\n    display: none;\n  }\n`);\nconst cssTopHeader = styled(cssHeader, `\n  background-color: ${theme.topHeaderBg};\n`);\nconst cssLeftPaneHeader = styled(cssHeader, `\n  background-color: ${theme.leftPanelBg};\n`);\nconst cssRightPaneHeader = styled(cssHeader, `\n  background-color: ${theme.rightPanelBg};\n  border-bottom: 0;\n`);\nconst cssBottomFooter = styled(\"div\", `\n  height: ${bottomFooterHeightPx}px;\n  background-color: ${theme.bottomFooterBg};\n  z-index: 20;\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  justify-content: space-between;\n  padding: 8px 16px;\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  right: 0;\n  border-top: 1px solid ${theme.pagePanelsBorder};\n  @media ${mediaNotSmall} {\n    & {\n      display: none;\n    }\n  }\n  @media print {\n    & {\n      display: none;\n    }\n  }\n  .interface-singlePage & {\n    display: none;\n  }\n`);\nconst cssResizeFlexVHandle = styled(resizeFlexVHandle, `\n  --resize-handle-color: ${theme.pagePanelsBorder};\n  --resize-handle-highlight: ${theme.pagePanelsBorderResizing};\n\n  @media print {\n    & {\n      display: none;\n    }\n  }\n`);\nconst cssResizeDisabledBorder = styled(\"div\", `\n  flex: none;\n  width: 1px;\n  height: 100%;\n  background-color: ${theme.pagePanelsBorder};\n  position: absolute;\n  top: 0;\n  bottom: 0;\n  right: -1px;\n  z-index: 2;\n`);\nconst cssPanelOpener = styled(icon, `\n  flex: none;\n  width: 32px;\n  height: 32px;\n  padding: 8px 8px;\n  cursor: pointer;\n  -webkit-mask-size: 16px 16px;\n  background-color: ${theme.controlFg};\n  transition: transform 0.4s;\n  &:hover { background-color: ${theme.controlHoverFg}; }\n  &-open { transform: rotateY(180deg); }\n`);\nconst cssPanelOpenerNarrowScreenBtn = styled(\"div\", `\n  width: 32px;\n  height: 32px;\n  --icon-color: ${theme.sidePanelOpenerFg};\n  cursor: pointer;\n  border-radius: 4px;\n  &-open {\n    background-color: ${theme.sidePanelOpenerActiveBg};\n    --icon-color: ${theme.sidePanelOpenerActiveFg};\n  }\n`);\nconst cssPanelOpenerNarrowScreen = styled(icon, `\n  width: 24px;\n  height: 24px;\n  margin: 4px;\n`);\nconst cssContentOverlay = styled(\"div\", `\n  position: absolute;\n  top: 0;\n  left: 0;\n  bottom: 0;\n  right: 0;\n  background-color: ${theme.pageBackdrop};\n  opacity: 0.5;\n  display: none;\n  z-index: 9;\n  @media screen and ${mediaSmall} {\n    & {\n      display: unset;\n    }\n  }\n`);\nconst cssLeftPanelContainer = styled(\"div\", `\n  flex: 1 1 0px;\n  display: flex;\n  flex-direction: column;\n`);\nconst cssHiddenInput = styled(\"input\", `\n  position: absolute;\n  top: -100px;\n  left: 0;\n  width: 10px;\n  height: 10px;\n  font-size: 1;\n  z-index: -1;\n`);\nconst cssBannerContainer = styled(\"div\", `\n  position: absolute;\n  z-index: 11;\n  width: 100%;\n`);\n// watchElementForBlur does not work if focus is on body. Which never happens when running in Grist\n// because focus is constantly given to the copypasteField. But it does happen when running inside a\n// projects test. For that latter case we had a hidden <input> field to the dom and give it focus.\nfunction maybePatchDomAndChangeFocus() {\n  if (document.activeElement?.matches(\"body\")) {\n    const hiddenInput = cssHiddenInput();\n    document.body.appendChild(hiddenInput);\n    hiddenInput.focus();\n  }\n}\n// Watch for changes in dom subtree and call callback with element height;\nfunction watchHeight(callback: (height: number) => void): DomElementMethod {\n  return elem => watchHeightElem(elem, callback);\n}\n\nfunction watchHeightElem(elem: HTMLElement, callback: (height: number) => void) {\n  const onChange = () => callback(elem.getBoundingClientRect().height);\n  const observer = new MutationObserver(onChange);\n  observer.observe(elem, { childList: true, subtree: true, attributes: true });\n  dom.onDisposeElem(elem, () => observer.disconnect());\n  onChange();\n}\n"
  },
  {
    "path": "app/client/ui/PageWidgetPicker.ts",
    "content": "import { BehavioralPromptsManager } from \"app/client/components/BehavioralPromptsManager\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/AppModel\";\nimport { ColumnRec, TableRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { PERMITTED_CUSTOM_WIDGETS } from \"app/client/models/features\";\nimport { linkId, NoLink } from \"app/client/ui/selectBy\";\nimport { overflowTooltip, withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { getWidgetTypes } from \"app/client/ui/widgetTypesMap\";\nimport { bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { spinnerModal } from \"app/client/ui2018/modals\";\nimport { isLongerThan, nativeCompare } from \"app/common/gutil\";\nimport { IAttachedCustomWidget, IWidgetType } from \"app/common/widgetTypes\";\n\nimport {\n  computed,\n  Computed,\n  Disposable,\n  dom,\n  domComputed,\n  DomElementArg,\n  fromKo,\n  IOption,\n  makeTestId,\n  Observable,\n  onKeyDown,\n  select,\n  styled,\n} from \"grainjs\";\nimport without from \"lodash/without\";\nimport Popper from \"popper.js\";\nimport { IOpenController, popupOpen, setPopupToCreateDom } from \"popweasel\";\n\nconst t = makeT(\"PageWidgetPicker\");\n\ntype TableRef = number | \"New Table\" | null;\n\n// Describes a widget selection.\nexport interface IPageWidget {\n\n  // The widget type\n  type: IWidgetType;\n\n  // The table (one of the listed tables or 'New Table')\n  table: TableRef;\n\n  // Whether to summarize the table (not available for \"New Table\").\n  summarize: boolean;\n\n  // some of the listed columns to use to summarize the table.\n  columns: number[];\n\n  // link\n  link: string;\n\n  // the page widget section id (should be 0 for a to-be-saved new widget)\n  section: number;\n}\n\nexport const DefaultPageWidget: () => IPageWidget = () => ({\n  type: \"record\",\n  table: null,\n  summarize: false,\n  columns: [],\n  link: NoLink,\n  section: 0,\n});\n\n// Creates a IPageWidget from a ViewSectionRec.\nexport function toPageWidget(section: ViewSectionRec): IPageWidget {\n  const link = linkId({\n    srcSectionRef: section.linkSrcSectionRef.peek(),\n    srcColRef: section.linkSrcColRef.peek(),\n    targetColRef: section.linkTargetColRef.peek(),\n  });\n  return {\n    type: section.parentKey.peek() as IWidgetType,\n    table: section.table.peek().summarySourceTable.peek() || section.tableRef.peek(),\n    summarize: Boolean(section.table.peek().summarySourceTable.peek()),\n    columns: section.table.peek().columns.peek().peek()\n      .filter(col => col.summarySourceCol.peek())\n      .map(col => col.summarySourceCol.peek()),\n    link, section: section.id.peek(),\n  };\n}\n\nexport interface IOptions extends ISelectOptions {\n\n  // the initial selected value, we call the function when the popup get triggered\n  value?: () => IPageWidget;\n\n  // placement, directly passed to the underlying Popper library.\n  placement?: Popper.Placement;\n}\n\nexport interface ICompatibleTypes {\n\n  // true if \"New Page\" is selected in Page Picker\n  isNewPage: boolean | undefined;\n\n  // true if can be summarized\n  summarize: boolean;\n}\n\nconst testId = makeTestId(\"test-wselect-\");\n\n// The picker disables some choices that do not make much sense. This function return the list of\n// compatible types given the tableId and whether user is creating a new page or not.\nfunction getCompatibleTypes(tableId: TableRef,\n  { isNewPage, summarize }: ICompatibleTypes): IWidgetType[] {\n  let compatibleTypes: IWidgetType[] = [];\n  if (tableId !== \"New Table\") {\n    compatibleTypes = [\"record\", \"single\", \"detail\", \"chart\", \"custom\", \"custom.calendar\", \"form\"];\n  } else if (isNewPage) {\n    // New view + new table means we'll be switching to the primary view.\n    compatibleTypes = [\"record\", \"form\"];\n  } else {\n    // The type 'chart' makes little sense when creating a new table.\n    compatibleTypes = [\"record\", \"single\", \"detail\", \"form\"];\n  }\n  return summarize ? compatibleTypes.filter(el => isSummaryCompatible(el)) : compatibleTypes;\n}\n\n// The Picker disables some choices that do not make much sense.\n// This function return a boolean telling if summary can be used with this type.\nfunction isSummaryCompatible(widgetType: IWidgetType): boolean {\n  const incompatibleTypes: IWidgetType[] = [\"form\"];\n  return !incompatibleTypes.includes(widgetType);\n}\n\n// Whether table and type make for a valid selection whether the user is creating a new page or not.\nfunction isValidSelection(table: TableRef,\n  type: IWidgetType,\n  { isNewPage, summarize }: ICompatibleTypes) {\n  return table !== null && getCompatibleTypes(table, { isNewPage, summarize }).includes(type);\n}\n\nexport type ISaveFunc = (val: IPageWidget) => Promise<any>;\n\n// Delay in milliseconds, after a user click on the save btn, before we start showing a modal\n// spinner. If saving completes before this time elapses (which is likely to happen for regular\n// table) we don't show the modal spinner.\nconst DELAY_BEFORE_SPINNER_MS = 500;\n\n// Attaches the page widget picker to elem to open on 'click' on the left.\nexport function attachPageWidgetPicker(elem: HTMLElement, gristDoc: GristDoc, onSave: ISaveFunc,\n  options: IOptions = {}) {\n  // Overrides .placement, this is needed to enable the page widget to update position when user\n  // expand the `Group By` panel.\n  // TODO: remove .placement from the options of this method (note: breaking buildPageWidgetPicker\n  // into two steps, one for model creation and the other for building UI, seems promising. In\n  // particular listening to value.summarize to update popup position could be done directly in\n  // code).\n  options.placement = \"left\";\n  const domCreator = (ctl: IOpenController) => buildPageWidgetPicker(ctl, gristDoc, onSave, options);\n  setPopupToCreateDom(elem, domCreator, {\n    placement: \"left\",\n    trigger: [\"click\"],\n    attach: \"body\",\n    boundaries: \"viewport\",\n  });\n}\n\n// Open page widget widget picker on the right of element.\nexport function openPageWidgetPicker(elem: HTMLElement, gristDoc: GristDoc, onSave: ISaveFunc,\n  options: IOptions = {}) {\n  popupOpen(elem, ctl => buildPageWidgetPicker(\n    ctl, gristDoc, onSave, options,\n  ), { placement: \"right\" });\n}\n\n// Builds a picker to stick into the popup. Takes care of setting up the initial selected value and\n// bind various events to the popup behaviours: close popup on save, gives focus to the picker,\n// binds cancel and save to Escape and Enter keydown events. Also takes care of preventing the popup\n// to overlay the trigger element (which could happen when the 'Group By' panel is expanded for the\n// first time). When saving is taking time, show a modal spinner (see DELAY_BEFORE_SPINNER_MS).\nexport function buildPageWidgetPicker(\n  ctl: IOpenController,\n  gristDoc: GristDoc,\n  onSave: ISaveFunc,\n  options: IOptions = {},\n) {\n  const { behavioralPromptsManager, docModel } = gristDoc;\n  const tables = fromKo(docModel.visibleTables.getObservable());\n  const columns = fromKo(docModel.columns.createAllRowsModel(\"parentPos\").getObservable());\n\n  // default value for when it is omitted\n  const defaultValue: IPageWidget = {\n    type: \"record\",\n    table: null, // when creating a new widget, let's initially have no table selected\n    summarize: false,\n    columns: [],\n    link: NoLink,\n    section: 0,\n  };\n\n  // get initial value and setup state for the picker.\n  const initValue = options.value?.() || defaultValue;\n  const value: IWidgetValueObs = {\n    type: Observable.create(ctl, initValue.type),\n    table: Observable.create(ctl, initValue.table),\n    summarize: Observable.create(ctl, initValue.summarize),\n    columns: Observable.create(ctl, initValue.columns),\n    link: Observable.create(ctl, initValue.link),\n    section: Observable.create(ctl, initValue.section),\n  };\n\n  // calls onSave and closes the popup. Failure must be handled by the caller.\n  async function onSaveCB() {\n    ctl.close();\n    const type = value.type.get();\n    const savePromise = onSave({\n      type,\n      table: value.table.get(),\n      summarize: value.summarize.get(),\n      columns: sortedAs(value.columns.get(), columns.get().map(col => col.id.peek())),\n      link: value.link.get(),\n      section: value.section.get(),\n    });\n    if (value.table.get() === \"New Table\") {\n      // Adding empty table will show a prompt, so we don't want to wait for it.\n      await savePromise;\n    } else {\n      // If savePromise throws an error, before or after timeout, we let the error propagate as it\n      // should be handle by the caller.\n      if (await isLongerThan(savePromise, DELAY_BEFORE_SPINNER_MS)) {\n        const label = getWidgetTypes(type).getLabel();\n        await spinnerModal(t(\"Building {{- label}} widget\", { label }), savePromise);\n      }\n    }\n  }\n\n  // whether the current selection is valid\n  function isValid() {\n    return isValidSelection(\n      value.table.get(),\n      value.type.get(),\n      {\n        isNewPage: options.isNewPage,\n        summarize: value.summarize.get(),\n      });\n  }\n\n  // Summarizing a table causes the 'Group By' panel to expand on the right. To prevent it from\n  // overlaying the trigger, we bind an update of the popup to it when it is on the left of the\n  // trigger.\n  // WARN: This does not work when the picker is triggered from a menu item because the trigger\n  // element does not exist anymore at this time so calling update will misplace the popup. However,\n  // this is not a problem at the time or writing because the picker is never placed at the left of\n  // a menu item (currently picker is only placed at the right of a menu item and at the left of a\n  // basic button).\n  if (options.placement && options.placement === \"left\") {\n    ctl.autoDispose(value.summarize.addListener((val, old) => val && ctl.update()));\n  }\n\n  // dom\n  return cssPopupWrapper(\n    dom.create(PageWidgetSelect,\n      value, tables, columns, onSaveCB, behavioralPromptsManager, options),\n\n    (elem) => { FocusLayer.create(ctl, { defaultFocusElem: elem, pauseMousetrap: true }); },\n    onKeyDown({\n      Escape: () => ctl.close(),\n      Enter: () => isValid() && onSaveCB(),\n    }),\n\n  );\n}\n\n// Same as IWidgetValue but with observable values\nexport type IWidgetValueObs = {\n  [P in keyof IPageWidget]: Observable<IPageWidget[P]>;\n};\n\nexport interface ISelectOptions {\n  // the button's label\n  buttonLabel?: string;\n\n  // Indicates whether the section builder is in a new view\n  isNewPage?: boolean;\n\n  // A callback to provides the links that are available to a page widget. It is called any time the\n  // user changes in the selected page widget (type, table, summary ...) and we update the \"SELECT\n  // BY\" dropdown with the result list of options. The \"SELECT BY\" dropdown is hidden if omitted.\n  selectBy?: (val: IPageWidget) => IOption<string>[];\n}\n\nconst registeredCustomWidgets: IAttachedCustomWidget[] =  [\"custom.calendar\"];\n\nconst permittedCustomWidgets: IAttachedCustomWidget[] = PERMITTED_CUSTOM_WIDGETS().get().map(widget =>\n  widget as IAttachedCustomWidget) ?? [];\n// the list of widget types in the order they should be listed by the widget.\nconst finalListOfCustomWidgetToShow =  permittedCustomWidgets.filter(a =>\n  registeredCustomWidgets.includes(a));\nconst sectionTypes: IWidgetType[] = [\n  \"record\", \"single\", \"detail\", \"form\", \"chart\", ...finalListOfCustomWidgetToShow, \"custom\",\n];\n\n// Returns dom that let a user select a page widget. User can select a widget type (id: 'grid',\n// 'card', ...), one of `tables` and optionally some of the `columns` of the selected table if she\n// wants to generate a summary. Clicking the `Add ...` button trigger `onSave()`. Note: this is an\n// internal method used by widgetPicker, it is only exposed for testing reason.\nexport class PageWidgetSelect extends Disposable {\n  // an observable holding the list of options of the `select by` dropdown\n  private _selectByOptions = this._options.selectBy ?\n    Computed.create(this, (use) => {\n      // TODO: it is unfortunate to have to convert from IWidgetValueObs to IWidgetValue. Maybe\n      // better to change this._value to be Observable<IWidgetValue> instead.\n      const val = {\n        type: use(this._value.type),\n        table: use(this._value.table),\n        summarize: use(this._value.summarize),\n        columns: use(this._value.columns),\n        // should not have a dependency on .link\n        link: this._value.link.get(),\n        section: use(this._value.section),\n      };\n      return this._options.selectBy!(val);\n    }) :\n    null;\n\n  private _isNewTableDisabled = Computed.create(this, this._value.type, (use, type) => !isValidSelection(\n    \"New Table\", type, { isNewPage: this._options.isNewPage, summarize: use(this._value.summarize) }));\n\n  private _isSummaryDisabled = Computed.create(this, this._value.type, (_use, type) => !isSummaryCompatible(type));\n\n  constructor(\n    private _value: IWidgetValueObs,\n    private _tables: Observable<TableRec[]>,\n    private _columns: Observable<ColumnRec[]>,\n    private _onSave: () => Promise<void>,\n    private _behavioralPromptsManager: BehavioralPromptsManager,\n    private _options: ISelectOptions = {},\n  ) { super(); }\n\n  public buildDom() {\n    return cssContainer(\n      testId(\"container\"),\n      cssBody(\n        cssPanel(\n          header(t(\"Select widget\")),\n          sectionTypes.map((value) => {\n            const widgetInfo = getWidgetTypes(value);\n            const disabled = computed(this._value.table,\n              (use, tid) => this._isTypeDisabled(value, tid, use(this._value.summarize)),\n            );\n            return cssEntry(\n              dom.autoDispose(disabled),\n              cssTypeIcon(widgetInfo.icon),\n              widgetInfo.getLabel(),\n              dom.on(\"click\", () => !disabled.get() && this._selectType(value)),\n              cssEntry.cls(\"-selected\", use => use(this._value.type) === value),\n              cssEntry.cls(\"-disabled\", disabled),\n              testId(\"type\"),\n            );\n          }),\n        ),\n        cssPanel(\n          testId(\"data\"),\n          header(t(\"Select data\")),\n          cssEntry(\n            cssIcon(\"TypeTable\"), t(\"New Table\"),\n            // prevent the selection of 'New Table' if it is disabled\n            dom.on(\"click\", ev => !this._isNewTableDisabled.get() && this._selectTable(\"New Table\")),\n            this._behavioralPromptsManager.attachPopup(\"pageWidgetPicker\", {\n              popupOptions: {\n                attach: null,\n                placement: \"right-start\",\n              },\n            }),\n            cssEntry.cls(\"-selected\", use => use(this._value.table) === \"New Table\"),\n            cssEntry.cls(\"-disabled\", this._isNewTableDisabled),\n            testId(\"table\"),\n          ),\n          dom.forEach(this._tables, table => dom(\"div\",\n            cssEntryWrapper(\n              cssEntry(cssIcon(\"TypeTable\"),\n                cssLabel(dom.text(table.tableNameDef), overflowTooltip()),\n                dom.on(\"click\", () => this._selectTable(table.id())),\n                cssEntry.cls(\"-selected\", use => use(this._value.table) === table.id()),\n                testId(\"table-label\"),\n              ),\n              cssPivot(\n                cssBigIcon(\"Pivot\"),\n                cssEntry.cls(\"-selected\", use => use(this._value.summarize) &&\n                  use(this._value.table) === table.id(),\n                ),\n                cssEntry.cls(\"-disabled\", this._isSummaryDisabled),\n                dom.on(\"click\", (_ev, el) =>\n                  !this._isSummaryDisabled.get() && this._selectPivot(table.id(), el as HTMLElement)),\n                testId(\"pivot\"),\n              ),\n              testId(\"table\"),\n            ),\n          )),\n        ),\n        cssPanel(\n          header(t(\"Group by\")),\n          dom.hide(use => !use(this._value.summarize)),\n          domComputed(\n            use => use(this._columns)\n              .filter(col => !col.isHiddenCol() && col.parentId() === use(this._value.table)),\n            cols => cols ?\n              dom.forEach(cols, col =>\n                cssEntry(cssIcon(\"FieldColumn\"), cssFieldLabel(dom.text(col.label)),\n                  dom.on(\"click\", () => this._toggleColumnId(col.id())),\n                  cssEntry.cls(\"-selected\", use => use(this._value.columns).includes(col.id())),\n                  testId(\"column\"),\n                ),\n              ) :\n              null,\n          ),\n        ),\n      ),\n      cssFooter(\n        cssFooterContent(\n          // If _selectByOptions exists and has more than then \"NoLinkOption\", show the selector.\n          dom.maybe(use => this._selectByOptions && use(this._selectByOptions).length > 1, () =>\n            withInfoTooltip(\n              cssSelectBy(\n                cssSmallLabel(t(\"SELECT BY\")),\n                dom.update(cssSelect(this._value.link, this._selectByOptions!),\n                  testId(\"selectby\")),\n              ),\n              \"selectBy\",\n              { popupOptions: { attach: null }, domArgs: [\n                this._behavioralPromptsManager.attachPopup(\"pageWidgetPickerSelectBy\", {\n                  popupOptions: {\n                    attach: null,\n                    placement: \"bottom-start\",\n                  },\n                }),\n              ] },\n            ),\n          ),\n          dom(\"div\", { style: \"flex-grow: 1\" }),\n          bigPrimaryButton(\n            // TODO: The button's label of the page widget picker should read 'Close' instead when\n            // there are no changes.\n            this._options.buttonLabel || t(\"Add to page\"),\n            dom.prop(\"disabled\", use => !isValidSelection(\n              use(this._value.table),\n              use(this._value.type),\n              {\n                isNewPage: this._options.isNewPage,\n                summarize: use(this._value.summarize),\n              }),\n            ),\n            dom.on(\"click\", () => this._onSave().catch(reportError)),\n            testId(\"addBtn\"),\n          ),\n        ),\n      ),\n    );\n  }\n\n  private _closeSummarizePanel() {\n    this._value.summarize.set(false);\n    this._value.columns.set([]);\n  }\n\n  private _openSummarizePanel() {\n    this._value.summarize.set(true);\n  }\n\n  private _selectType(type: IWidgetType) {\n    this._value.type.set(type);\n  }\n\n  private _selectTable(tid: TableRef) {\n    if (tid !== this._value.table.get()) {\n      this._value.link.set(NoLink);\n    }\n    this._value.table.set(tid);\n    this._closeSummarizePanel();\n  }\n\n  private _isSelected(el: HTMLElement) {\n    return el.classList.contains(cssEntry.className + \"-selected\");\n  }\n\n  private _selectPivot(tid: TableRef, pivotEl: HTMLElement) {\n    if (this._isSelected(pivotEl)) {\n      this._closeSummarizePanel();\n    } else {\n      if (tid !== this._value.table.get()) {\n        this._value.columns.set([]);\n        this._value.table.set(tid);\n        this._value.link.set(NoLink);\n      }\n      this._openSummarizePanel();\n    }\n  }\n\n  private _toggleColumnId(cid: number) {\n    const ids = this._value.columns.get();\n    const newIds = ids.includes(cid) ? without(ids, cid) : [...ids, cid];\n    this._value.columns.set(newIds);\n  }\n\n  private _isTypeDisabled(type: IWidgetType, table: TableRef, isSummaryOn: boolean) {\n    if (table === null) {\n      return false;\n    }\n    return !getCompatibleTypes(table, { isNewPage: this._options.isNewPage, summarize: isSummaryOn }).includes(type);\n  }\n}\n\nfunction header(label: string, ...args: DomElementArg[]) {\n  return cssHeader(dom(\"h4\", label), ...args, testId(\"heading\"));\n}\n\nconst cssContainer = styled(\"div\", `\n  --outline: 1px solid ${theme.widgetPickerBorder};\n\n  max-height: 386px;\n  box-shadow: 0 2px 20px 0 ${theme.widgetPickerShadow};\n  border-radius: 2px;\n  display: flex;\n  flex-direction: column;\n  user-select: none;\n  background-color: ${theme.widgetPickerPrimaryBg};\n`);\n\nconst cssPopupWrapper = styled(\"div\", `\n  &:focus {\n    outline: none;\n  }\n`);\n\nconst cssBody = styled(\"div\", `\n  display: flex;\n  min-height: 0;\n`);\n\n// todo: try replace min-width / max-width\nconst cssPanel = styled(\"div\", `\n  width: 224px;\n  font-size: ${vars.mediumFontSize};\n  overflow: auto;\n  padding-bottom: 18px;\n  &:nth-of-type(2n) {\n    background-color: ${theme.widgetPickerSecondaryBg};\n    outline: var(--outline);\n  }\n`);\n\nconst cssHeader = styled(\"div\", `\n  color: ${theme.text};\n  margin: 24px 0 24px 24px;\n  font-size: ${vars.mediumFontSize};\n`);\n\nconst cssEntry = styled(\"div\", `\n  color: ${theme.widgetPickerItemFg};\n  padding: 0 0 0 24px;\n  height: 32px;\n  display: flex;\n  flex-direction: row;\n  flex: 1 1 0px;\n  align-items: center;\n  white-space: nowrap;\n  overflow: hidden;\n  cursor: pointer;\n  &-selected {\n    background-color: ${theme.widgetPickerItemSelectedBg};\n  }\n  &-disabled {\n    color: ${theme.widgetPickerItemDisabledBg};\n    cursor: default;\n  }\n  &-disabled&-selected {\n    background-color: inherit;\n  }\n`);\n\nconst cssIcon = styled(icon, `\n  margin-right: 8px;\n  flex-shrink: 0;\n  --icon-color: ${theme.widgetPickerIcon};\n  .${cssEntry.className}-disabled > & {\n    opacity: 0.25;\n  }\n`);\n\nconst cssTypeIcon = styled(cssIcon, `\n  --icon-color: ${theme.widgetPickerPrimaryIcon};\n`);\n\nconst cssLabel = styled(\"span\", `\n  text-overflow: ellipsis;\n  overflow: hidden;\n`);\n\nconst cssFieldLabel = styled(cssLabel, `\n  padding-right: 8px;\n`);\n\nconst cssEntryWrapper = styled(\"div\", `\n  display: flex;\n  align-items: center;\n`);\n\nconst cssPivot = styled(cssEntry, `\n  width: 48px;\n  padding-left: 8px;\n  flex: 0 0 auto;\n`);\n\nconst cssBigIcon = styled(icon, `\n  width: 24px;\n  height: 24px;\n  background-color: ${theme.widgetPickerSummaryIcon};\n  .${cssEntry.className}-disabled > & {\n    opacity: 0.25;\n    filter: saturate(0);\n  }\n`);\n\nconst cssFooter = styled(\"div\", `\n  display: flex;\n  border-top: var(--outline);\n`);\n\nconst cssFooterContent = styled(\"div\", `\n  flex-grow: 1;\n  height: 65px;\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  padding: 0 24px 0 24px;\n`);\n\nconst cssSmallLabel = styled(\"span\", `\n  color: ${theme.text};\n  font-size: ${vars.xsmallFontSize};\n  margin-right: 8px;\n`);\n\nconst cssSelect = styled(select, `\n  color: ${theme.selectButtonFg};\n  background-color: ${theme.selectButtonBg};\n  flex: 1 0 160px;\n  width: 160px;\n`);\n\nconst cssSelectBy = styled(\"div\", `\n  display: flex;\n  align-items: center;\n`);\n\n// Returns a copy of array with its items sorted in the same order as they appear in other.\nfunction sortedAs(array: number[], other: number[]) {\n  const order: { [id: number]: number } = {};\n  for (const [index, item] of other.entries()) {\n    order[item] = index;\n  }\n  return array.slice().sort((a, b) => nativeCompare(order[a], order[b]));\n}\n"
  },
  {
    "path": "app/client/ui/Pages.ts",
    "content": "import { createGroup } from \"app/client/components/commands\";\nimport { buildDuplicatePageDialog } from \"app/client/components/duplicatePage\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { PageRec } from \"app/client/models/DocModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport MetaTableModel from \"app/client/models/MetaTableModel\";\nimport { find as findInTree, fromTableData, TreeItemRecord, TreeRecord,\n  TreeTableData } from \"app/client/models/TreeModel\";\nimport { TreeViewComponent } from \"app/client/ui/TreeViewComponent\";\nimport { cssRadioCheckboxOptions, radioCheckboxOption } from \"app/client/ui2018/checkbox\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { ISaveModalOptions, saveModal } from \"app/client/ui2018/modals\";\nimport { buildCensoredPage, buildPageDom, PageOptions } from \"app/client/ui2018/pages\";\nimport { mod } from \"app/common/gutil\";\n\nimport { Computed, Disposable, dom, fromKo, makeTestId, observable, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"Pages\");\n\n// build dom for the tree view of pages\nexport function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Observable<boolean>) {\n  const pagesTable = activeDoc.docModel.pages;\n  const buildDom = buildDomFromTable.bind(null, pagesTable, activeDoc);\n\n  const records = Computed.create<TreeRecord[]>(owner, use =>\n    use(activeDoc.docModel.menuPages).map(page => ({\n      id: page.getRowId(),\n      indentation: use(page.indentation),\n      pagePos: use(page.pagePos),\n      viewRef: use(page.viewRef),\n      hidden: use(page.isCensored),\n      collapsed: page.isCollapsed,\n    })),\n  );\n  const getTreeTableData = (): TreeTableData => ({\n    getRecords: () => records.get(),\n    sendTableActions: (...args) => pagesTable.tableData.sendTableActions(...args),\n  });\n\n  // create the model and keep in sync with the table\n  const model = observable(fromTableData(getTreeTableData(), buildDom));\n  owner.autoDispose(records.addListener(() => {\n    model.set(fromTableData(getTreeTableData(), buildDom, model.get()));\n  }));\n\n  // create a computed that reads the selected page from the url and return the corresponding item\n  const selected = Computed.create(owner, activeDoc.activeViewId, (use, viewId) =>\n    findInTree(model.get(), (i: TreeItemRecord) => i.record.viewRef === viewId) || null,\n  );\n\n  owner.autoDispose(createGroup({\n    nextPage: () => selected.get() && otherPage(selected.get()!, +1),\n    prevPage: () => selected.get() && otherPage(selected.get()!, -1),\n  }, null, true));\n\n  // dom\n  return dom(\"nav\",\n    { \"aria-label\": t(\"Document pages\") },\n    dom.create(TreeViewComponent, model, { isOpen, selected, isReadonly: activeDoc.isReadonly }),\n  );\n}\n\nconst testId = makeTestId(\"test-removepage-\");\n\nfunction buildDomFromTable(\n  pagesTable: MetaTableModel<PageRec>,\n  activeDoc: GristDoc,\n  pageId: number,\n  item: TreeItemRecord,\n) {\n  if (item.hidden) {\n    return buildCensoredPage();\n  }\n\n  const { isReadonly } = activeDoc;\n  const pageRec = pagesTable.rowModels[pageId];\n  const viewRec = pageRec.view.peek();\n  const pageName = viewRec.name;\n  const viewId = viewRec.id.peek();\n  const { docModel } = activeDoc;\n\n  const options: PageOptions = {\n    onRename: async (newName: string) => {\n      if (newName.length) {\n        await pageName.saveOnly(newName);\n      }\n    },\n    onRemove: () => removeView(activeDoc, viewId, pageName.peek()),\n    onDuplicate: () => buildDuplicatePageDialog(activeDoc, pageId),\n    // Can't remove last visible page\n    isRemoveDisabled: () => docModel.visibleDocPages.peek().length <= 1,\n    isReadonly,\n    isCollapsed: pageRec.isCollapsed,\n    onCollapse: value => pageRec.isCollapsed.set(value),\n    isCollapsedByDefault: pageRec.isCollapsedByDefault,\n    onCollapseByDefault: value => pageRec.setAndSaveCollapsed(value),\n    hasSubPages: () => item.children().get().length > 0,\n    href: urlState().setLinkUrl({ docPage: viewId }),\n  };\n\n  return buildPageDom(fromKo(pageName), options);\n}\n\nfunction removeView(activeDoc: GristDoc, viewId: number, pageName: string) {\n  logTelemetryEvent(\"deletedPage\", { full: { docIdDigest: activeDoc.docId() } });\n\n  const docData = activeDoc.docData;\n  // Create a set with tables on other pages (but not on this one).\n  const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels\n    .filter(vs => !vs.isRaw.peek() && !vs.isRecordCard.peek() && vs.parentId.peek() !== viewId)\n    .map(vs => vs.tableRef.peek()));\n\n  // Check if this page is a last page for some tables.\n  const notVisibleTables = [...new Set(activeDoc.docModel.viewSections.rowModels\n    .filter(vs => vs.parentId.peek() === viewId) // Get all sections on this view\n    .filter(vs => !vs.table.peek().summarySourceTable.peek()) // Sections that have normal tables\n    .filter(vs => !tablesOnOtherViews.has(vs.tableRef.peek())) // That aren't on other views\n    .filter(vs => vs.table.peek().tableId.peek()) // Which we can access (has tableId)\n    .map(vs => vs.table.peek()))]; // Return tableRec object, and remove duplicates.\n\n  const removePage = () => [[\"RemoveRecord\", \"_grist_Views\", viewId]];\n  const removeAll = () => [\n    ...removePage(),\n    ...notVisibleTables.map(tb => [\"RemoveTable\", tb.tableId.peek()]),\n  ];\n\n  if (notVisibleTables.length) {\n    const tableNames = notVisibleTables.map(tb => tb.tableNameDef.peek());\n    buildPrompt(tableNames, async (option) => {\n      // Errors are handled in the dialog.\n      if (option === \"data\") {\n        await docData.sendActions(removeAll(), `Remove page ${pageName} with tables ${tableNames}`);\n      } else if (option === \"page\") {\n        await docData.sendActions(removePage(), `Remove only page ${pageName}`);\n      } else {\n        // This should not happen, as save should be disabled when no option is selected.\n      }\n    });\n  } else {\n    return docData.sendActions(removePage(), `Remove only page ${pageName}`);\n  }\n}\n\ntype RemoveOption = \"\" | \"data\" | \"page\";\n\n// Select another page in cyclic ordering of pages. Order is downard if given a positive `delta`,\n// upward otherwise.\nfunction otherPage(currentPage: TreeItemRecord, delta: number) {\n  const records = currentPage.storage.records;\n  const index = mod(currentPage.index + delta, records.length);\n  const docPage = records[index].viewRef;\n  return urlState().pushUrl({ docPage });\n}\n\nfunction buildPrompt(tableNames: string[], onSave: (option: RemoveOption) => Promise<any>) {\n  saveModal((ctl, owner): ISaveModalOptions => {\n    const selected = Observable.create<RemoveOption>(owner, \"\");\n    const saveDisabled = Computed.create(owner, use => use(selected) === \"\");\n    const saveFunc = () => onSave(selected.get());\n    return {\n      title: t(\"The following tables will no longer be visible\", { count: tableNames.length }),\n      body: dom(\"div\",\n        testId(\"popup\"),\n        buildWarning(tableNames),\n        cssRadioCheckboxOptions(\n          radioCheckboxOption(selected, \"data\", t(\"Delete data and this page.\")),\n          radioCheckboxOption(selected, \"page\",\n            t(\"Keep data and delete page. Table will remain available in {{rawDataLink}}\",\n              {\n                rawDataLink: cssLink(\n                  t(\"raw data page\"),\n                  urlState().setHref({ docPage: \"data\" }),\n                  { target: \"_blank\" },\n                ),\n              },\n            ),\n          ),\n        ),\n      ),\n      saveDisabled,\n      saveLabel: t(\"Delete\"),\n      saveFunc,\n      width: \"fixed-wide\",\n      extraButtons: [],\n    };\n  });\n}\n\nfunction buildWarning(tables: string[]) {\n  return cssWarning(\n    dom.forEach(tables, tb => cssTableName(tb, testId(\"table\"))),\n  );\n}\n\nconst cssWarning = styled(\"div\", `\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n  margin-bottom: 16px;\n`);\n\nconst cssTableName = styled(\"div\", `\n  color: ${theme.choiceTokenFg};\n  background-color: ${theme.choiceTokenBg};\n  padding: 3px 6px;\n  border-radius: 4px;\n`);\n"
  },
  {
    "path": "app/client/ui/PinnedDocs.ts",
    "content": "import { getTimeFromNow } from \"app/client/lib/timeUtils\";\nimport { docUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { HomeModel } from \"app/client/models/HomeModel\";\nimport { makeDocOptionsMenu } from \"app/client/ui/DocList\";\nimport { makeRemovedDocOptionsMenu } from \"app/client/ui/DocMenu\";\nimport { colors, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu } from \"app/client/ui2018/menus\";\nimport * as roles from \"app/common/roles\";\nimport { Document, Workspace } from \"app/common/UserAPI\";\n\nimport { dom, makeTestId, Observable, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-dm-\");\n\n/**\n * Builds a list of pinned documents, or nothing if there are no pinned docs.\n *\n * A misnomer because it's currently only used on the templates page to show\n * featured templates, with pinned documents now being shown in DocList.\n */\nexport function createPinnedDocs(home: HomeModel, docs: Observable<Document[]>, isExample = false) {\n  return pinnedDocList(\n    dom.forEach(docs, doc => buildPinnedDoc(home, doc, doc.workspace, isExample)),\n    testId(\"pinned-doc-list\"),\n  );\n}\n\n/**\n * Build a single pinned document, with an optional icon and description.\n *\n * A misnomer because it's currently only used on the templates page to show\n * featured templates, and on the trash page for the thumbnail (aka \"icon\")\n * view.\n */\nexport function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Workspace, isExample = false): HTMLElement {\n  return pinnedDocWrapper(\n    pinnedDoc(\n      doc.removedAt ?\n        null :\n        urlState().setLinkUrl({ ...docUrl(doc), ...(isExample ? { org: workspace.orgDomain } : {}) }),\n      pinnedDoc.cls(\"-no-access\", !roles.canView(doc.access)),\n      pinnedDocPreview(\n        (doc.options?.icon ?\n          cssImage({ src: doc.options.icon }) :\n          [docInitials(doc.name), pinnedDocThumbnail()]\n        ),\n        (doc.public && !isExample ? cssPublicIcon(\"PublicFilled\", testId(\"public\")) : null),\n        pinnedDocPreview.cls(\"-with-icon\", Boolean(doc.options?.icon)),\n      ),\n      pinnedDocFooter(\n        pinnedDocTitle(\n          dom.text(doc.name),\n          testId(\"pinned-doc-name\"),\n          // Mostly for the sake of tests, allow .test-dm-pinned-doc-name to find documents in\n          // either 'list' or 'icons' views.\n          testId(\"doc-name\"),\n        ),\n        doc.options?.description ?\n          cssPinnedDocDesc(doc.options.description, testId(\"pinned-doc-desc\")) :\n          cssPinnedDocTimestamp(\n            capitalizeFirst(getTimeFromNow(doc.removedAt || doc.updatedAt)),\n            testId(\"pinned-doc-desc\"),\n          ),\n      ),\n    ),\n    isExample ? null : (doc.removedAt ?\n      [\n        // For deleted documents, attach the menu to the entire doc icon, and include the\n        // \"Dots\" icon just to clarify that there are options.\n        menu(() => makeRemovedDocOptionsMenu(home, doc, workspace),\n          { placement: \"right-start\" }),\n        pinnedDocOptions(icon(\"Dots\"), testId(\"pinned-doc-options\")),\n      ] :\n      pinnedDocOptions(icon(\"Dots\"),\n        menu(() => makeDocOptionsMenu(home, doc),\n          { placement: \"bottom-start\" }),\n        // Clicks on the menu trigger shouldn't follow the link that it's contained in.\n        dom.on(\"click\", (ev) => { ev.stopPropagation(); ev.preventDefault(); }),\n        testId(\"pinned-doc-options\"),\n      )\n    ),\n    testId(\"pinned-doc\"),\n  );\n}\n\nfunction docInitials(docTitle: string) {\n  return cssDocInitials(docTitle.slice(0, 2), testId(\"pinned-initials\"));\n}\n\n// Capitalizes the first letter in the given string.\nfunction capitalizeFirst(str: string): string {\n  return str.replace(/^[a-z]/gi, c => c.toUpperCase());\n}\n\nconst pinnedDocList = styled(\"div\", `\n  display: flex;\n  overflow-x: auto;\n  overflow-y: hidden;\n  padding-bottom: 16px;\n  margin: 0 0 28px 0;\n`);\n\nconst pinnedDocWrapper = styled(\"div\", `\n  display: inline-block;\n  flex: 0 0 auto;\n  position: relative;\n  width: 210px;\n  margin: 16px 24px 16px 0;\n  border: 1px solid ${theme.pinnedDocBorder};\n  border-radius: 1px;\n  vertical-align: top;\n  &:hover {\n    border: 1px solid ${theme.pinnedDocBorderHover};\n  }\n\n  /* TODO: Specify a gap on flexbox parents of pinnedDocWrapper instead. */\n  &:last-child {\n    margin-right: 0px;\n  }\n`);\n\nconst pinnedDoc = styled(\"a\", `\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n  color: ${theme.text};\n  text-decoration: none;\n  cursor: pointer;\n\n  &:hover {\n    color: ${theme.text};\n    text-decoration: none;\n  }\n  &-no-access, &-no-access:hover {\n    color: ${theme.disabledText};\n    cursor: not-allowed;\n  }\n`);\n\nconst pinnedDocPreview = styled(\"div\", `\n  position: relative;\n  flex: none;\n  width: 100%;\n  height: 131px;\n  background-color: ${colors.dark};\n  min-height: 0;\n\n  padding: 10px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n\n  .${pinnedDoc.className}-no-access > & {\n    opacity: 0.8;\n  }\n\n  &-with-icon {\n    padding: 0;\n  }\n`);\n\nconst pinnedDocThumbnail = styled(\"div\", `\n  position: absolute;\n  right: 20px;\n  bottom: 20px;\n  height: 48px;\n  width: 48px;\n  background-image: var(--icon-ThumbPreview);\n  background-size: 48px 48px;\n  background-repeat: no-repeat;\n  background-position: center;\n`);\n\nconst cssDocInitials = styled(\"div\", `\n  position: absolute;\n  left: 20px;\n  bottom: 20px;\n  font-size: 32px;\n  border: 1px solid ${colors.lightGreen};\n  color: ${colors.mediumGreyOpaque};\n  border-radius: 3px;\n  padding: 4px 0;\n  width: 48px;\n  height: 48px;\n  text-align: center;\n`);\n\nconst pinnedDocOptions = styled(\"div\", `\n  position: absolute;\n  top: 12px;\n  right: 12px;\n  height: 24px;\n  width: 24px;\n  padding: 4px;\n  line-height: 0px;\n  border-radius: 3px;\n  cursor: default;\n  visibility: hidden;\n  background-color: ${colors.mediumGrey};\n  --icon-color: ${colors.light};\n\n  .${pinnedDocWrapper.className}:hover &, &.weasel-popup-open {\n    visibility: visible;\n  }\n`);\n\nconst pinnedDocFooter = styled(\"div\", `\n  width: 100%;\n  font-size: ${vars.mediumFontSize};\n  background-color: ${theme.pinnedDocFooterBg};\n`);\n\nconst pinnedDocTitle = styled(\"div\", `\n  margin: 16px 16px 0px 16px;\n  font-weight: bold;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`);\n\nconst cssPinnedDocTimestamp = styled(\"div\", `\n  margin: 8px 16px 16px 16px;\n  color: ${theme.lightText};\n`);\n\nconst cssPinnedDocDesc = styled(cssPinnedDocTimestamp, `\n  margin: 8px 16px 16px 16px;\n  color: ${theme.lightText};\n  height: 48px;\n  line-height: 16px;\n  -webkit-box-orient: vertical;\n  display: -webkit-box;\n  -webkit-line-clamp: 3;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  word-break: break-word;\n`);\n\nconst cssImage = styled(\"img\", `\n  position: relative;\n  background-color: ${colors.dark};\n  height: 100%;\n  width: 100%;\n  object-fit: scale-down;\n`);\n\nconst cssPublicIcon = styled(icon, `\n  position: absolute;\n  top: 16px;\n  left: 16px;\n  --icon-color: ${theme.accentIcon};\n`);\n"
  },
  {
    "path": "app/client/ui/PredefinedCustomSectionConfig.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport { CustomSectionConfig } from \"app/client/ui/CustomSectionConfig\";\nimport { ICustomWidget } from \"app/common/CustomWidget\";\n\nexport class PredefinedCustomSectionConfig extends CustomSectionConfig {\n  constructor(section: ViewSectionRec, gristDoc: GristDoc) {\n    super(section, gristDoc);\n  }\n\n  public buildDom() {\n    return this._customSectionConfigurationConfig.buildDom();\n  }\n\n  protected shouldRenderWidgetSelector(): boolean {\n    return false;\n  }\n\n  protected async _getWidgets(): Promise<ICustomWidget[]> {\n    return [];\n  }\n}\n"
  },
  {
    "path": "app/client/ui/ProposedChangesPage.ts",
    "content": "import { ActionLogPart, computeContext, showCell } from \"app/client/components/ActionLog\";\nimport { cssBannerLink } from \"app/client/components/Banner\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { ApiData, VirtualDoc, VirtualSection } from \"app/client/components/VirtualDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { getTimeFromNow } from \"app/client/lib/timeUtils\";\nimport { ColumnRec } from \"app/client/models/DocModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { docListHeader } from \"app/client/ui/DocMenuCss\";\nimport { buildOriginalUrlId } from \"app/client/ui/ShareMenu\";\nimport { basicButton, bigBasicButton, bigPrimaryButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { colors, mediaSmall, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { rebaseSummary } from \"app/common/ActionSummarizer\";\nimport { getActionColValues, TableDataAction } from \"app/common/DocActions\";\nimport {\n  DocStateComparison,\n  DocStateComparisonDetails,\n  removeMetadataChangesFromDetails,\n} from \"app/common/DocState\";\nimport { isHiddenCol } from \"app/common/gristTypes\";\nimport { buildUrlId, commonUrls, parseUrlId } from \"app/common/gristUrls\";\nimport { isLongerThan } from \"app/common/gutil\";\nimport { TabularDiff, TabularDiffs } from \"app/common/TabularDiff\";\nimport { Proposal } from \"app/common/UserAPI\";\n\nimport {\n  Computed, Disposable, dom, makeTestId, MultiHolder, MutableObsArray,\n  obsArray, Observable, styled,\n} from \"grainjs\";\nimport * as ko from \"knockout\";\n\nconst t = makeT(\"ProposedChangesPage\");\n\nconst testId = makeTestId(\"test-proposals-\");\n\n/**\n * This is a page to show the differences between the current document\n * (the \"fork\") and an original document (the \"trunk\"). The differences\n * are shown in ActionLog format, which at the time of writing is very\n * weak, so this page inherits that weakness.\n * TODO: improve the rendering of differences.\n *\n */\nexport class ProposedChangesPage extends Disposable {\n  public body: ProposedChangesTrunkPage | ProposedChangesForkPage;\n  public readonly isInitialized: Observable<boolean | \"slow\"> = Observable.create(this, false);\n\n  constructor(public gristDoc: GristDoc) {\n    super();\n    const urlId = this.gristDoc.docPageModel.currentDocId.get();\n    const parts = parseUrlId(urlId || \"\");\n    const isFork = Boolean(urlId && parts.trunkId && parts.forkId);\n\n    this.body = this.autoDispose(\n      isFork ?\n        ProposedChangesForkPage.create(null, gristDoc) :\n        ProposedChangesTrunkPage.create(null, gristDoc));\n\n    const loader = this.body.load();\n    loader.then((result) => {\n      if (result) { this.isInitialized.set(true); }\n    }).catch(reportError);\n    isLongerThan(loader, 100).then(slow => slow && this.isInitialized.set(\"slow\")).catch(() => {});\n  }\n\n  public buildDom() {\n    const content = cssContainer(\n      dom.cls(\"diff\"),\n      cssHeader(this.body.title(), betaTag(t(\"experiment\"))),\n      dom.maybe(this.isInitialized, (init) => {\n        if (init === \"slow\") {\n          return loadingSpinner();\n        } else {\n          return this.body.buildDom();\n        }\n      }),\n    );\n    // Common pattern on other pages to avoid clipboard deactivation.\n    return dom(\"div.clipboard\",\n      { tabIndex: \"-1\" },\n      content);\n  }\n}\n\nexport class ProposedChangesTrunkPage extends Disposable {\n  private _proposalsObs: MutableObsArray<Proposal> = this.autoDispose(obsArray());\n  private _proposals?: Proposal[];\n\n  private _proposalCount = Computed.create(\n    this, this._proposalsObs,\n    (_owner, ps) => ps.length,\n  );\n\n  private _showDismissed = Observable.create(this, false);\n  private _userProposalsObs = Computed.create(\n    this, this.gristDoc.currentUser, this._proposalsObs, this._showDismissed,\n    (_owner, user, ps, showDismissed) => {\n      const proposals = ps\n        .filter(p => p.srcDoc.creator.id === user?.id && !user?.anonymous)\n        .filter(p => p.status.status !== \"dismissed\" || showDismissed);\n      return (proposals.length > 0) ? proposals : null;\n    },\n  );\n\n  constructor(public gristDoc: GristDoc) {\n    super();\n  }\n\n  public async load() {\n    const urlId = this.gristDoc.docPageModel.currentDocId.get();\n    if (!urlId) { return; }\n    const proposals = await this.gristDoc.appModel.api.getDocAPI(urlId).getProposals();\n    if (this.isDisposed()) { return; }\n    this._proposals = proposals.proposals.filter(p => p.status.status !== \"retracted\");\n    this._proposalsObs.splice(0);\n    this._proposalsObs.push(...this._proposals);\n\n    // Fetch all tables that have changes in any proposal\n    const tablesToFetch = new Set<string>();\n    for (const proposal of this._proposals) {\n      const details = proposal.comparison.comparison?.details;\n      if (details?.leftChanges.tableDeltas) {\n        Object.keys(details.leftChanges.tableDeltas).forEach(tableId => tablesToFetch.add(tableId));\n      }\n    }\n    await Promise.all([...tablesToFetch].map(tableId =>\n      this.gristDoc.docData.fetchTable(tableId).catch((err) => {\n        console.warn(`Failed to fetch table ${tableId}:`, err);\n      }),\n    ));\n\n    return true;\n  }\n\n  public title() {\n    return t(\"Suggestions\");\n  }\n\n  public buildDom() {\n    if (!this._proposals) { return null; }\n    const isReadOnly = this.gristDoc.docPageModel.currentDoc.get()?.isReadonly;\n    return [\n      dom.domComputed(this._showDismissed, (showDismissed) => {\n        return [\n          dom.maybe(this._userProposalsObs, (userProposals) => {\n            return dom(\n              \"p\",\n              cssSuggestionLabel(\n                t(\"Your suggestions\"),\n              ),\n              ...userProposals.map(p => [\n                cssSuggestionLink(this._linkProposal(p)),\n              ]),\n            );\n          }),\n          dom.maybe(use => use(this._proposalCount) === 0, () => {\n            return [\n              cssWarningMessage(\n                cssWarningIcon(\"Warning\"),\n                dom(\"div\",\n                  `This is an experimental feature, with many limitations,\nand is subject to change and withdrawal.`,\n                  \" \",\n                  cssLink(t(\"Learn more\"), {\n                    href: commonUrls.helpSuggestions,\n                    target: \"_blank\",\n                  }),\n                ),\n              ),\n              dom(\"p\", \"There are currently no suggestions.\"),\n            ];\n          }),\n          isReadOnly ? [\n            dom(\"p\", \"Would you like to suggest some changes?\"),\n            bigPrimaryButton(\n              t(\"Work on a copy\"),\n              dom.on(\"click\", async () => {\n                const { urlId } = await this.gristDoc.docComm.fork();\n                await urlState().pushUrl({ doc: urlId });\n              }),\n              testId(\"fork\"),\n            ),\n          ] : null,\n          dom.maybe(this._proposalsObs, (proposals) => {\n            if (proposals.some(p => p.status.status === \"dismissed\")) {\n              if (isReadOnly) { return null; }\n              return labeledSquareCheckbox(this._showDismissed, \"Show dismissed suggestions.\");\n            }\n          }),\n          dom(\n            \"div\",\n            dom.forEach(this._proposalsObs, (proposal) => {\n              const details = proposal.comparison.comparison?.details;\n              if (!details) { return null; }\n              const applied = proposal.status.status === \"applied\";\n              const dismissed = proposal.status.status === \"dismissed\";\n              if (dismissed && !showDismissed) { return null; }\n              return dom(\"div\", [\n                cssProposalHeader(\n                  this._linkProposal(proposal),\n                  \" | \",\n                  proposal.srcDoc.creator.name || proposal.srcDoc.creator.email, \" | \",\n                  getProposalActionSummary(proposal),\n                  testId(\"header\"),\n                ),\n                buildComparisonDetails(this, this.gristDoc, details, proposal.comparison.comparison),\n                proposal.status.status === \"dismissed\" ? \"DISMISSED\" : null,\n                isReadOnly ? null : cssButtonRow(\n                  applied ? null : primaryButton(\n                    t(\"Accept\"),\n                    dom.on(\"click\", async () => {\n                      const outcome = await this.gristDoc.docComm.applyProposal(proposal.shortId);\n                      this._updateProposal(proposal, outcome.proposal);\n                      // For the moment, send debug information to console\n                      for (const change of outcome.log.changes) {\n                        if (change.fail) {\n                          reportError(new Error(change.msg));\n                        }\n                      }\n                    }),\n                    testId(\"apply\"),\n                  ),\n                  \" \",\n                  isReadOnly ? null : basicButton(\n                    dismissed ? t(\"Undo dismissal\") : t(\"Dismiss\"),\n                    dom.on(\"click\", async () => {\n                      const result = await this.gristDoc.docComm.applyProposal(proposal.shortId, {\n                        dismiss: !dismissed,\n                      });\n                      this._updateProposal(proposal, result.proposal);\n                    }),\n                    testId(\"dismiss\"),\n                  ),\n                ),\n              ], testId(\"patch\"));\n            }),\n            testId(\"patches\"),\n          ),\n        ];\n      }),\n    ];\n  }\n\n  private _updateProposal(oldProposal: Proposal, newProposal: Proposal) {\n    const proposals = this._proposalsObs.get();\n    const idx = proposals.findIndex(p => p === oldProposal);\n    this._proposalsObs.splice(idx, 1, newProposal);\n  }\n\n  private _linkProposal(proposal: Proposal) {\n    const name = `#${proposal.shortId}`;\n    return proposal.srcDoc.id !== \"hidden\" ?\n      cssBannerLink(\n        name,\n        urlState().setLinkUrl({\n          doc: buildUrlId({\n            trunkId: this.gristDoc.docId(),\n            forkId: proposal.srcDoc.id,\n            ...(proposal.srcDoc.creator.anonymous ? {} : {\n              forkUserId: proposal.srcDoc.creator.id,\n            }),\n          }),\n          docPage: \"suggestions\",\n        }),\n      ) : name;\n  }\n}\n\nexport class ProposedChangesForkPage extends Disposable {\n  // This will hold a comparison between this document and another version.\n  private _comparison?: DocStateComparison;\n\n  private _proposalObs: Observable<Proposal | null> = Observable.create(this, null);\n  private _outOfDateObs: Computed<boolean>;\n\n  constructor(public gristDoc: GristDoc) {\n    super();\n    this._outOfDateObs = Computed.create(\n      this, gristDoc.latestActionState, this._proposalObs,\n      (_owner, actionState, proposal) => {\n        const proposed = proposal?.comparison.comparison?.left;\n        return Boolean(proposed && actionState && proposed.h !== actionState.h);\n      },\n    );\n  }\n\n  public title() {\n    return t(\"Suggest changes\");\n  }\n\n  /**\n   * Use the API to compare this doc with the original.\n   * TODO: make sure the comparison remains live either by recomputing\n   * or by concatenating incoming changes to the computed difference.\n   */\n  public async load() {\n    const urlId = this.gristDoc.docPageModel.currentDocId.get();\n    if (!urlId) { return; }\n    const parts = parseUrlId(urlId || \"\");\n    const comparisonUrlId = parts.trunkId;\n    const comparison = await this.gristDoc.appModel.api.getDocAPI(urlId).compareDoc(\n      comparisonUrlId, { detail: true },\n    );\n    if (this.isDisposed()) { return; }\n    this._comparison = comparison;\n    const proposals = await this.gristDoc.appModel.api.getDocAPI(urlId).getProposals({\n      outgoing: true,\n    });\n    if (this.isDisposed()) { return; }\n    this._proposalObs.set(proposals.proposals[0] || null);\n\n    // Fetch all tables that have changes\n    const details = comparison.details;\n    if (details?.leftChanges.tableDeltas) {\n      const tablesToFetch = Object.keys(details.leftChanges.tableDeltas);\n      await Promise.all(tablesToFetch.map(tableId =>\n        this.gristDoc.docData.fetchTable(tableId).catch((err) => {\n          console.warn(`Failed to fetch table ${tableId}:`, err);\n        }),\n      ));\n    }\n\n    return true;\n  }\n\n  public buildDom() {\n    const details = this._comparison?.details;\n    const isSnapshot = this.gristDoc.docPageModel.isSnapshot.get();\n    const docId = this.gristDoc.docId();\n    const origUrlId = buildOriginalUrlId(docId, isSnapshot);\n    const isReadOnly = this.gristDoc.docPageModel.currentDoc.get()?.isReadonly;\n    const maybeHasChanges =\n      Object.keys(details?.leftChanges.tableDeltas || {}).length !== 0 ||\n      details?.leftChanges.tableRenames.length !== 0;\n    const trunkAcceptsProposals =\n      this.gristDoc.docPageModel.currentDoc?.get()?.options?.proposedChanges?.acceptProposals;\n    return dom.domComputed(\n      use => [use(this._proposalObs), use(this._outOfDateObs)] as const, ([proposal, outOfDate]) => {\n        const hasProposal = Boolean(proposal?.updatedAt && proposal?.status?.status === undefined);\n        return [\n          dom.maybe(!trunkAcceptsProposals, () => {\n            return cssWarningMessage(\n              cssWarningIcon(\"Warning\"),\n              t(`The original document isn't asking for proposed changes.`),\n            );\n          }),\n          dom(\"p\",\n            t(\"This is a list of changes relative to the {{originalDocument}}.\", {\n              originalDocument: cssBannerLink(\n                t(\"original document\"),\n                urlState().setLinkUrl({\n                  doc: origUrlId,\n                  docPage: \"suggestions\",\n                }, {\n                  beforeChange: () => {\n                    const user = this.gristDoc.currentUser.get();\n                    // If anonymous, be careful, proposal list won't\n                    // give a link back to this URL since that would\n                    // let anyone edit it.\n                    if (user?.anonymous) { return; }\n                    // If a proposal hasn't been saved, or is retracted,\n                    // also be careful, since there won't be a back-link.\n                    if (!proposal?.updatedAt ||\n                      proposal.status.status === \"retracted\") { return; }\n                    // Otherwise, don't worry about losing the link\n                    // to this page, you can get it from the original\n                    // document.\n                    this.gristDoc.docPageModel.clearUnsavedChanges();\n                  },\n                }),\n              ),\n            }),\n          ),\n          dom.maybe(!maybeHasChanges, () => {\n            return dom(\"p\", t(\"No changes found to suggest. Please make some edits.\"));\n          }),\n          cssDataRow(\n            details ? buildComparisonDetails(this, this.gristDoc, details, this._comparison) : null,\n          ),\n          [\n            dom(\"p\",\n              getProposalActionSummary(proposal),\n              testId(\"status\"),\n            ),\n            this._getProposalRelativeToCurrent(),\n            (isReadOnly || !maybeHasChanges) ? null : cssControlRow(\n              (hasProposal && !outOfDate) ? null : bigPrimaryButton(\n                hasProposal ? t(\"Update suggestion\") : t(\"Suggest change\"),\n                dom.on(\"click\", async () => {\n                  const urlId = this.gristDoc.docPageModel.currentDocId.get();\n                  await this.gristDoc.appModel.api.getDocAPI(urlId!).makeProposal();\n                  await this.update();\n                }),\n                testId(\"propose\"),\n              ),\n              (proposal?.updatedAt && (proposal?.status.status !== \"retracted\")) ? bigBasicButton(\n                t(\"Retract suggestion\"),\n                dom.on(\"click\", async () => {\n                  const urlId = this.gristDoc.docPageModel.currentDocId.get();\n                  await this.gristDoc.appModel.api.getDocAPI(urlId!).makeProposal({ retracted: true });\n                  await this.update();\n                }),\n                testId(\"retract\"),\n              ) : null,\n            ),\n          ],\n        ];\n      });\n  }\n\n  public async update() {\n    const proposal = await this.gristDoc.docPageModel.refreshProposal();\n    if (this.isDisposed()) { return; }\n    if (proposal === \"empty\" || !proposal) {\n      this._proposalObs.set(null);\n      return;\n    }\n    this._proposalObs.set(proposal);\n  }\n\n  private _getProposalRelativeToCurrent() {\n    return dom.maybe(this._outOfDateObs, (outOfDate) => {\n      if (outOfDate) {\n        return dom(\n          \"p\",\n          t(`There are fresh changes that haven't been added to the suggestion yet.`),\n        );\n      }\n    });\n  }\n}\n\nclass ActionLogPartInProposal extends ActionLogPart {\n  public constructor(\n    private _gristDoc: GristDoc,\n    private _details: DocStateComparisonDetails,\n    private _comparison: DocStateComparison | undefined,\n  ) {\n    super(_gristDoc);\n  }\n\n  public showForTable(): boolean {\n    return true;\n  }\n\n  public async selectCell(rowId: number, colId: string, tableId: string): Promise<void> {\n    await showCell(this._gristDoc, { tableId, colId, rowId });\n  }\n\n  public async getContext() {\n    const parentActionNum = this._comparison?.parent?.n;\n    const summary = this._details.leftChanges;\n    if (parentActionNum) {\n      const actionLog = this._gristDoc.getActionLog();\n      const ref = await actionLog.getChangesSince(parentActionNum);\n      rebaseSummary(ref, summary);\n    }\n    return computeContext(this._gristDoc, summary);\n  }\n\n  public buildDom() {\n    return this.renderTabularDiffs(this._details.leftChanges, {\n      txt: \"\",\n      // This holds any extra context known about the comparison. Computed on\n      // request. It is managed by ActionLogPart.\n      contextObs: ko.observable({}),\n      customRender: (diffs, ctx, selectCell) => {\n        return this._makeTable({\n          diffs,\n          origComparison: this._comparison,\n          toggleInfo: (table: any) => this.toggleContext(ctx, table),\n          selectCell: selectCell,\n        });\n      },\n    });\n  }\n\n  protected _makeTable(props: {\n    diffs?: TabularDiffs;\n    origComparison?: DocStateComparison;\n    toggleInfo?: (table: string) => void;\n    selectCell?: (rowId: number, colId: string, tableId: string) => Promise<void>;\n  }) {\n    const { diffs, toggleInfo } = props;\n    const doc = VirtualDoc.create(this, this._gristDoc.appModel);\n    if (!diffs) {\n      return null;\n    }\n    const lst = Object.entries(diffs).map(([table, tdiff]: [string, TabularDiff]) => {\n      const data = convertTabularDiffToTableData(table, tdiff);\n      // Careful, there can be blank rowModels if tables were removed.\n      const tableRow = this._gristDoc.docModel.tables.rowModels.find(tr => tr?.tableId() === table);\n      const columnRows = tableRow ? this._gristDoc.docModel.columns.rowModels.filter(\n        cr => cr.parentId() === tableRow.id(),\n      ) : null;\n      const colIdToColRecs = columnRows ? Object.fromEntries(\n        columnRows.map(cr => [cr.colId(), cr]),\n      ) : {};\n      const haveId = tdiff.header.includes(\"id\");\n\n      // For references, we may need to handle our columns having\n      // distinct \"display columns\". If a column has a display column,\n      // we use its type, formatting, and content, but retain the\n      // colId of the original.\n      const displayedColRecs: Record<string, ColumnRec> = {};\n      const colRefToColRecs: Map<number, ColumnRec> = columnRows ? new Map(\n        columnRows.map(cr => [cr.id(), cr]),\n      ) : new Map();\n      for (const [colId, colRec] of Object.entries(colIdToColRecs)) {\n        const displayCol = colRec.displayCol();\n        if (!displayCol) { continue; }\n        const displayColRec = colRefToColRecs.get(displayCol);\n        if (!displayColRec) { continue; }\n        displayedColRecs[colId] = displayColRec;\n        const displayColId = displayColRec.colId();\n        const values = getActionColValues(data);\n        const src = values[displayColId];\n        values[colId] = src;\n        delete values[displayColId];\n        tdiff.header = tdiff.header.filter(colId => colId !== displayColId);\n      }\n\n      doc.addTable({\n        name: table,\n        tableId: table,\n        data: new ApiData(() => data),\n        columns: [\n          // Add the special row change type column\n          {\n            colId: \"_gristChangeType\",\n            label: \"_gristChangeType\",\n            type: \"Text\",\n          },\n          // Add regular columns from the diff\n          ...tdiff.header.map((colId) => {\n            const colRec = displayedColRecs[colId] ?? colIdToColRecs[colId];\n            return {\n              colId,\n              label: colId,\n              type: (colRec?.pureType.peek() as any) || \"Any\",\n              widgetOptions: colRec?.widgetOptionsJson.peek(),\n            };\n          }).filter(col => !isHiddenCol(col.colId)),\n        ],\n      });\n      doc.refreshTableData(table).catch(reportError);\n      return dom.create(VirtualSection, doc, {\n        tableId: table,\n        hiddenColumns: [\"_gristChangeType\"],\n        hideViewButtons: true,\n        gridOptions: {\n          inline: true,\n          maxInlineHeight: 400,\n          rowMenu: false,\n          colMenu: false,\n          onCellDblClick: (pos) => {\n            if (Number.isInteger(pos.rowId)) {\n              const colId = tdiff.header[pos.fieldIndex ?? 0 - 1];\n              props.selectCell?.(pos.rowId as number, colId || \"\", table).catch(reportError);\n            }\n          },\n          cornerRenderer: toggleInfo ? el => [\n            cssExpander(\n              haveId ? icon(\"PanelLeft\") : icon(\"PanelRight\"),\n              haveId ? testId(\"collapse\") : testId(\"expand\"),\n              dom.on(\"click\", () => toggleInfo(table)),\n            ),\n          ] : () => [\"\"],\n          rowIndexRenderer: (row) => {\n            const changeType = row.cells._gristChangeType.peek();\n            return dom(\"div\", String(changeType || \"?\"),\n              dom.style(\"font-weight\", \"bold\"),\n              dom.style(\"font-size\", \"12px\"));\n          },\n        },\n      });\n    });\n    return lst;\n  }\n}\n\nfunction getProposalActionSummary(proposal: Proposal | null) {\n  return proposal?.updatedAt ? dom.text(\n    proposal?.status.status === \"retracted\" ?\n      t(\"Retracted {{at}}.\", { at: getTimeFromNow(proposal.updatedAt) }) :\n      proposal?.status.status === \"dismissed\" ?\n        t(\"Dismissed {{at}}.\", { at: getTimeFromNow(proposal.updatedAt) }) :\n        proposal?.status.status === \"applied\" && proposal.appliedAt ?\n          t(\"Accepted {{at}}.\", { at: getTimeFromNow(proposal.appliedAt) }) :\n          t(\"Suggestion made {{at}}.\", { at: getTimeFromNow(proposal.updatedAt) }),\n  ) : null;\n}\n\n/**\n * Converts a TabularDiff to TableDataAction format.\n * Transforms cell deltas into the appropriate format for display.\n * Also adds a special column '_gristChangeType' to track the type of change.\n */\nfunction convertTabularDiffToTableData(table: string, tdiff: TabularDiff): TableDataAction {\n  const data: TableDataAction = [\"TableData\", table, [], {}];\n\n  for (const row of tdiff.cells) {\n    data[2].push(row.rowId);\n\n    // Add special column to track row change type\n    data[3]._gristChangeType ??= [];\n    data[3]._gristChangeType.push(row.type);\n\n    for (const [idx, cell] of row.cellDeltas.entries()) {\n      let item;\n      if (cell === null) {\n        item = \"...\";\n      } else if (!Array.isArray(cell)) {\n        item = cell;\n      } else {\n        const [pre, post] = cell;\n        if (!pre && !post) {\n          item = \"\";\n        } else {\n          item = [\"V\", {\n            parent: pre?.[0],\n            remote: post?.[0],\n          }];\n        }\n      }\n      const colId = tdiff.header[idx];\n      data[3][colId] ??= [];\n      data[3][colId].push(item as any);\n    }\n  }\n\n  return data;\n}\n\nfunction buildComparisonDetails(\n  owner: MultiHolder,\n  gristDoc: GristDoc,\n  origDetails: DocStateComparisonDetails,\n  origComparison: DocStateComparison | undefined,\n) {\n  // The change we want to render is based on a calculation\n  // done on the fork document. The calculation treated the\n  // fork as the local/left document, and the trunk as the\n  // remote/right document.\n  const { details, leftHadMetadata } = removeMetadataChangesFromDetails(origDetails);\n  // We want to look at the changes from their most recent\n  // common ancestor and the current doc.\n  const part = new ActionLogPartInProposal(gristDoc, details, origComparison);\n  owner.autoDispose(part);\n\n  return [\n    leftHadMetadata ? dom(\"p\", \"(some changes we can't deal with yet were ignored)\") : null,\n    part.buildDom(),\n  ];\n}\n\nconst cssExpander = styled(\"div\", `\n  cursor: pointer;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  height: 100%;\n  color: ${theme.controlPrimaryBg};\n  --icon-color: ${theme.controlPrimaryBg};\n  &:hover {\n    text-decoration: underline;\n  }\n`);\n\nconst cssHeader = styled(docListHeader, `\n  margin-bottom: 0;\n  &:not(:first-of-type) {\n    margin-top: 40px;\n  }\n`);\n\nconst cssDataRow = styled(\"div\", `\n  margin: 16px 0px;\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.text};\n  & .view_data_pane_container {\n    width: max-content;\n  }\n`);\n\nconst cssButtonRow = styled(cssDataRow, `\n  width: 360px;\n`);\n\nconst cssContainer = styled(\"div\", `\n  overflow-y: auto;\n  position: relative;\n  height: 100%;\n  padding: 32px 64px 24px 64px;\n  @media ${mediaSmall} {\n    & {\n      padding: 32px 24px 24px 24px;\n    }\n  }\n`);\n\nconst cssControlRow = styled(\"div\", `\n  flex: none;\n  margin-bottom: 16px;\n  margin-top: 16px;\n  display: flex;\n  gap: 16px;\n`);\n\nconst betaTag = styled(\"span\", `\n  text-transform: uppercase;\n  vertical-align: super;\n  font-size: ${vars.xsmallFontSize};\n  color: ${theme.accentText};\n`);\n\nconst cssSuggestionLabel = styled(\"span\", `\n  display: inline-block;\n  padding-top: 5px;\n  padding-bottom: 5px;\n  padding-right: 5px;\n`);\n\nconst cssSuggestionLink = styled(\"span\", `\n  display: inline-block;\n  margin-left: 10px;\n  padding: 5px;\n  border-top-left-radius: 20px;\n  border-bottom-right-radius: 20px;\n  background-color: ${theme.accessRulesTableHeaderBg};\n  color: ${theme.accessRulesTableHeaderFg};\n  min-width: 30px;\n`);\n\nconst cssProposalHeader = styled(\"h3\", `\n  padding: 5px;\n  padding-top: 20px;\n  padding-bottom: 5px;\n  border-top-left-radius: 20px;\n  border-bottom-right-radius: 20px;\n  background-color: ${theme.accessRulesTableHeaderBg};\n  color: ${theme.accessRulesTableHeaderFg};\n`);\n\nconst cssWarningMessage = styled(\"div\", `\n  margin-top: 8px;\n  margin-bottom: 8px;\n  padding: 8px;\n  display: flex;\n  align-items: center;\n  column-gap: 8px;\n  max-width: 400px;\n  border: 1px solid ${theme.accessRulesTableHeaderFg};\n`);\n\nconst cssWarningIcon = styled(icon, `\n  --icon-color: ${colors.warning};\n  flex-shrink: 0;\n`);\n"
  },
  {
    "path": "app/client/ui/RelativeDatesOptions.ts",
    "content": "import { IRangeBoundType, isRelativeBound } from \"app/common/FilterState\";\nimport getCurrentTime from \"app/common/getCurrentTime\";\nimport {\n  CURRENT_DATE,\n  diffUnit,\n  formatRelBounds,\n  IPeriod,\n  IRelativeDateSpec,\n  isEquivalentRelativeDate,\n  relativeDateToUnixTimestamp,\n} from \"app/common/RelativeDates\";\n\nimport moment from \"moment-timezone\";\n\nexport const DEPS = { getCurrentTime };\n\nexport interface IRelativeDateOption {\n  label: string;\n  value: number | IRelativeDateSpec;\n}\n\nconst DEFAULT_OPTION_LIST: IRelativeDateSpec[] = [\n  CURRENT_DATE, [{\n    quantity: -3,\n    unit: \"day\",\n  }], [{\n    quantity: -7,\n    unit: \"day\",\n  }], [{\n    quantity: -30,\n    unit: \"day\",\n  }], [{\n    quantity: 0,\n    unit: \"year\",\n  }], [{\n    quantity: 3,\n    unit: \"day\",\n  }], [{\n    quantity: 7,\n    unit: \"day\",\n  }], [{\n    quantity: 30,\n    unit: \"day\",\n  }], [{\n    quantity: 0,\n    unit: \"year\",\n    endOf: true,\n  }]];\n\nexport function relativeDatesOptions(value: IRangeBoundType, valueFormatter: (val: any) => string,\n): { label: string, spec: IRangeBoundType }[] {\n  return relativeDateOptionsSpec(value)\n    .map(spec => ({ spec, label: formatBoundOption(spec, valueFormatter) }));\n}\n\n// Returns a list of different relative date spec that all match passed in date value. If value is\n// undefined it returns a default list of spec meant to showcase user the different flavors of\n// relative date.\nfunction relativeDateOptionsSpec(value: IRangeBoundType): IRangeBoundType[] {\n  if (value === undefined) {\n    return DEFAULT_OPTION_LIST;\n  } else if (isRelativeBound(value)) {\n    value = relativeDateToUnixTimestamp(value);\n  }\n\n  const date = moment.utc(value * 1000);\n  const res: IRangeBoundType[] = [value];\n\n  let relDate = getMatchingDoubleRelativeDate(value, { unit: \"day\" });\n  if (Math.abs(relDate[0].quantity) <= 90) {\n    res.push(relDate);\n  }\n\n  relDate = getMatchingDoubleRelativeDate(value, { unit: \"week\" });\n  if (Math.abs(relDate[0].quantity) <= 4) {\n    res.push(relDate);\n  }\n\n  // any day of the month (with longer limit for 1st day of the month)\n  relDate = getMatchingDoubleRelativeDate(value, { unit: \"month\" });\n  if (Math.abs(relDate[0].quantity) <= (date.date() === 1 ? 12  : 3)) {\n    res.push(relDate);\n  }\n\n  // If date is 1st of Jan show 1st day of year options\n  if (date.date() === 1 && date.month() === 0) {\n    res.push(getMatchingDoubleRelativeDate(value, { unit: \"year\" }));\n  }\n\n  // 31st of Dec\n  if (date.date() === 31 && date.month() === 11) {\n    res.push(getMatchingDoubleRelativeDate(value, { unit: \"year\", endOf: true }));\n  }\n\n  // Last day of any month\n  if (date.clone().endOf(\"month\").date() === date.date()) {\n    relDate = getMatchingDoubleRelativeDate(value, { unit: \"month\", endOf: true });\n    if (Math.abs(relDate[0].quantity) < 12) {\n      res.push(relDate);\n    }\n  }\n\n  return res;\n}\n\nfunction now(): moment.Moment {\n  const m = DEPS.getCurrentTime();\n  return moment.utc([m.year(), m.month(), m.date()]);\n}\n\n// Returns a relative date spec as a sequence of one or two IPeriod that allows to match dateValue\n// starting from the current date. The first period has .unit, .startOf and .endOf set according to\n// passed in option.\nexport function getMatchingDoubleRelativeDate(\n  dateValue: number,\n  option: { unit: \"day\" | \"week\" | \"month\" | \"year\", endOf?: boolean },\n): IPeriod[] {\n  const { unit } = option;\n  const date = moment.utc(dateValue * 1000);\n  const dateNow = now();\n  const quantity = diffUnit(date, dateNow.clone(), unit);\n  const m = dateNow.clone().add(quantity, unit);\n  if (option.endOf) { m.endOf(unit); m.startOf(\"day\"); } else { m.startOf(unit); }\n  const dayQuantity = diffUnit(date, m, \"day\");\n  const res = [{ quantity, ...option }];\n  // Only add a 2nd period when it is not moot.\n  if (dayQuantity) { res.push({ quantity: dayQuantity, unit: \"day\" }); }\n  return res;\n}\n\nexport function formatBoundOption(bound: IRangeBoundType, valueFormatter: (val: any) => string): string {\n  return isRelativeBound(bound) ? formatRelBounds(bound) : valueFormatter(bound);\n}\n\n// Update relativeDate to match the new date picked by user.\nexport function updateRelativeDate(relativeDate: IRelativeDateSpec, date: number): IRelativeDateSpec | number {\n  const periods = Array.isArray(relativeDate) ? relativeDate : [relativeDate];\n\n  if ([1, 2].includes(periods.length)) {\n    const { unit, endOf } = periods[0];\n    const relDate = getMatchingDoubleRelativeDate(date, { unit, endOf });\n\n    // Returns the relative date only if it is one of the suggested relative dates, otherwise\n    // returns the absolute date.\n    const options = relativeDateOptionsSpec(date);\n    if (options.find(opt => isRelativeBound(opt) && isEquivalentRelativeDate(opt, relDate))) {\n      return relDate;\n    }\n    return date;\n  }\n\n  throw new Error(\n    `Relative date spec does only support 1 or 2 periods, got ${periods.length}!`,\n  );\n}\n"
  },
  {
    "path": "app/client/ui/RenameDocModal.ts",
    "content": "import { domAsync } from \"app/client/lib/domAsync\";\nimport { loadEmojiPicker } from \"app/client/lib/imports\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { HomeModel } from \"app/client/models/HomeModel\";\nimport { buildDocIcon, getDefaultIconColors } from \"app/client/ui/DocIcon\";\nimport { textarea } from \"app/client/ui/inputs\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { buildColorPicker, ColorOption } from \"app/client/ui2018/ColorSelect\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { saveModal } from \"app/client/ui2018/modals\";\nimport { gristThemeObs, gristThemePrefs } from \"app/client/ui2018/theme\";\nimport { Document } from \"app/common/UserAPI\";\n\nimport {\n  Computed,\n  Disposable,\n  dom,\n  makeTestId,\n  Observable,\n  styled,\n} from \"grainjs\";\nimport { defaultMenuOptions, setPopupToCreateDom } from \"popweasel\";\n\nconst t = makeT(\"RenameDocModal\");\n\nconst testId = makeTestId(\"test-dm-\");\n\ninterface RenameDocModalOptions {\n  home: HomeModel;\n  doc: Document;\n}\n\nexport function showRenameDocModal({ home, doc }: RenameDocModalOptions) {\n  saveModal((_ctl, owner) => {\n    const modal = RenameDocModal.create(owner, { home, doc });\n    return {\n      title: t(\"Rename and set icon\"),\n      body: modal.buildDom(),\n      saveFunc: () => modal.save(),\n      saveDisabled: modal.saveDisabled,\n      modalArgs: [cssModal.cls(\"\")],\n    };\n  });\n}\n\nclass RenameDocModal extends Disposable {\n  public readonly saveDisabled;\n\n  private readonly _home = this._options.home;\n  private readonly _doc = this._options.doc;\n  private readonly _name = Observable.create(this, this._doc.name);\n  private readonly _defaultIconColors = getDefaultIconColors(this._doc.id);\n  private readonly _icon = {\n    backgroundColor: Observable.create(\n      this,\n      this._doc.options?.appearance?.icon?.backgroundColor ??\n      this._defaultIconColors.backgroundColor,\n    ),\n    color: Observable.create(\n      this,\n      this._doc.options?.appearance?.icon?.color ??\n      this._defaultIconColors.color,\n    ),\n    emoji: Observable.create(\n      this,\n      this._doc.options?.appearance?.icon?.emoji ?? null,\n    ),\n  };\n\n  constructor(private _options: RenameDocModalOptions) {\n    super();\n\n    this.saveDisabled = Computed.create(\n      this,\n      this._name,\n      (_use, name) => name.trim().length === 0,\n    );\n  }\n\n  public buildDom() {\n    return [\n      cssField(\n        cssLabel(t(\"Name\"), { for: \"name\" }),\n        cssTextArea(\n          this._name,\n          { onInput: true },\n          (el) => {\n            setTimeout(() => {\n              el.select();\n            }, 10);\n          },\n          { id: \"name\", placeholder: t(\"Enter document name\") },\n        ),\n      ),\n      cssField(\n        cssLabel(t(\"Icon\")),\n        cssIconAndButtons(\n          buildDocIcon(\n            {\n              docId: this._doc.id,\n              docName: this._name,\n              icon: this._icon,\n            },\n            testId(\"doc-icon-preview\"),\n          ),\n          cssButtons(\n            textButton(\n              cssIconAndLabel(icon(\"Pencil\"), t(\"Choose color\")),\n              (el) => {\n                setPopupToCreateDom(\n                  el,\n                  ctl =>\n                    buildColorPicker(ctl, {\n                      styleOptions: {\n                        textColor: new ColorOption({\n                          color: this._icon.color,\n                          allowsNone: false,\n                          defaultColor: this._defaultIconColors.color,\n                          noneText: this._defaultIconColors.color,\n                        }),\n                        fillColor: new ColorOption({\n                          color: this._icon.backgroundColor,\n                          allowsNone: false,\n                          defaultColor: this._defaultIconColors.backgroundColor,\n                          noneText: this._defaultIconColors.backgroundColor,\n                        }),\n                      },\n                    }),\n                  { ...defaultMenuOptions, attach: null },\n                );\n              },\n            ),\n            textButton(\n              cssIconAndLabel(icon(\"Smiley\"), t(\"Choose icon\"), testId(\"choose-icon\")),\n              (el) => {\n                setPopupToCreateDom(\n                  el,\n                  (ctl) => {\n                    return cssEmojiPicker(\n                      domAsync(\n                        loadEmojiPicker().then((module) => {\n                          if (ctl.isDisposed()) {\n                            return;\n                          }\n\n                          ctl.update();\n\n                          return module.buildEmojiPicker({\n                            onEmojiSelect: (emoji) => {\n                              this._icon.emoji.set(emoji.native);\n                              ctl.close();\n                            },\n                            theme: gristThemePrefs.get()?.syncWithOS ?\n                              \"auto\" :\n                              gristThemeObs().get().appearance,\n                          });\n                        }),\n                      ),\n                    );\n                  },\n                  { ...defaultMenuOptions, attach: null },\n                );\n              },\n            ),\n            textButton(\n              t(\"Reset icon\"),\n              testId(\"reset-icon\"),\n              dom.on(\"click\", () => {\n                this._icon.emoji.set(null);\n              }),\n              dom.prop(\"disabled\", use => !use(this._icon.emoji)),\n            ),\n          ),\n        ),\n      ),\n    ];\n  }\n\n  public async save() {\n    await this._home.renameDoc(this._doc.id, this._name.get().trimEnd(), {\n      icon: {\n        backgroundColor: this._icon.backgroundColor.get(),\n        color: this._icon.color.get(),\n        emoji: this._icon.emoji.get(),\n      },\n    });\n  }\n}\n\nconst cssModal = styled(\"div\", `\n  position: relative;\n  width: 100%;\n  max-width: 488px;\n  min-width: 0px;\n`);\n\nconst cssField = styled(\"div\", `\n  margin: 16px 0;\n`);\n\nconst cssLabel = styled(\"label\", `\n  display: inline-block;\n  font-weight: 700;\n  line-height: 16px;\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.text};\n  margin-bottom: 8px;\n`);\n\nconst cssTextArea = styled(textarea, `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  border: 1px solid ${theme.inputBorder};\n  width: 100%;\n  padding: 8px 12px;\n  outline: none;\n  resize: none;\n  border-radius: 3px;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\nconst cssIconAndButtons = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 16px;\n`);\n\nconst cssButtons = styled(\"div\", `\n  display: flex;\n  flex-wrap: wrap;\n  gap: 12px;\n`);\n\nconst cssIconAndLabel = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  column-gap: 2px;\n`);\n\nconst cssEmojiPicker = styled(\"div\", `\n  z-index: ${vars.emojiPickerZIndex};\n`);\n"
  },
  {
    "path": "app/client/ui/RenamePopupStyles.ts",
    "content": "import { textarea } from \"app/client/ui/inputs\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssTextInput } from \"app/client/ui2018/editableLabel\";\n\nimport { IInputOptions, input, Observable, styled } from \"grainjs\";\n\nexport const cssRenamePopup = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  min-width: 280px;\n  padding: 16px;\n  background-color: ${theme.popupBg};\n  border-radius: 2px;\n  outline: none;\n`);\n\nexport const cssLabel = styled(\"label\", `\n  color: ${theme.text};\n  font-size: ${vars.xsmallFontSize};\n  font-weight: ${vars.bigControlTextWeight};\n  text-transform: uppercase;\n  margin: 0 0 8px 0;\n  &:not(:first-child) {\n    margin-top: 16px;\n  }\n`);\n\nconst cssInputWithIcon = styled(\"div\", `\n  position: relative;\n  display: flex;\n  flex-direction: column;\n`);\n\nexport const cssInput = styled((\n  obs: Observable<string>,\n  opts: IInputOptions,\n  ...args) => input(obs, opts, cssTextInput.cls(\"\"), ...args), `\n  text-overflow: ellipsis;\n  color: ${theme.inputFg};\n  background-color: transparent;\n  &:disabled {\n    color: ${theme.inputDisabledFg};\n    background-color: ${theme.inputDisabledBg};\n    pointer-events: none;\n  }\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n  .${cssInputWithIcon.className} > &:disabled {\n    padding-right: 28px;\n  }\n`);\n\nexport const cssTextArea = styled(textarea, `\n  color: ${theme.inputFg};\n  background-color: ${theme.mainPanelBg};\n  border: 1px solid ${theme.inputBorder};\n  width: 100%;\n  padding: 3px 6px;\n  outline: none;\n  max-width: 100%;\n  min-width: calc(280px - 16px*2);\n  max-height: 500px;\n  min-height: calc(3em * 1.5);\n  resize: none;\n  border-radius: 3px;\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n\n  &[readonly] {\n    background-color: ${theme.inputDisabledBg};\n    color: ${theme.inputDisabledFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/RightPanel.ts",
    "content": "/**\n * Builds the structure of the right-side panel containing configuration and assorted tools.\n * It includes the regular tabs, to configure the Page (including several sub-tabs), and Field;\n * and allows other tools, such as Activity Feed, to be rendered temporarily in its place.\n *\n * A single RightPanel object is created in AppUI for a document page, and attached to PagePanels.\n * GristDoc registers callbacks with it to create various standard tabs. These are created as\n * needed, and destroyed when hidden.\n *\n * In addition, tools such as \"Activity Feed\" may use openTool() to replace the panel header and\n * content. The user may dismiss this panel.\n *\n * All methods above return an object which may  be disposed to close and dispose that specific\n * tab from the outside (e.g. when GristDoc is disposed).\n */\nimport * as commands from \"app/client/components/commands\";\nimport { FieldModel } from \"app/client/components/Forms/Field\";\nimport { FormView } from \"app/client/components/Forms/FormView\";\nimport { MappedFieldsConfig } from \"app/client/components/Forms/MappedFieldsConfig\";\nimport { GristDoc, IExtraTool, TabContent } from \"app/client/components/GristDoc\";\nimport { EmptyFilterState } from \"app/client/components/LinkingState\";\nimport { RefSelect } from \"app/client/components/RefSelect\";\nimport ViewConfigTab from \"app/client/components/ViewConfigTab\";\nimport { domAsync } from \"app/client/lib/domAsync\";\nimport * as imports from \"app/client/lib/imports\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { createSessionObs, isBoolean, SessionObs } from \"app/client/lib/sessionObs\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { reportError } from \"app/client/models/AppModel\";\nimport { ColumnRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { CustomSectionConfig } from \"app/client/ui/CustomSectionConfig\";\nimport { showCustomWidgetGallery } from \"app/client/ui/CustomWidgetGallery\";\nimport { buildDescriptionConfig } from \"app/client/ui/DescriptionConfig\";\nimport { BuildEditorOptions } from \"app/client/ui/FieldConfig\";\nimport { GridOptions } from \"app/client/ui/GridOptions\";\nimport { textarea } from \"app/client/ui/inputs\";\nimport { attachPageWidgetPicker, IPageWidget, toPageWidget } from \"app/client/ui/PageWidgetPicker\";\nimport { PredefinedCustomSectionConfig } from \"app/client/ui/PredefinedCustomSectionConfig\";\nimport { cssConfigContainer, cssGroupLabel, cssLabel, cssSeparator } from \"app/client/ui/RightPanelStyles\";\nimport { buildConfigContainer, getFieldType } from \"app/client/ui/RightPanelUtils\";\nimport { rowHeightConfigTable } from \"app/client/ui/RowHeightConfig\";\nimport { linkId, NoLink, selectBy } from \"app/client/ui/selectBy\";\nimport { VisibleFieldsConfig } from \"app/client/ui/VisibleFieldsConfig\";\nimport { getTelemetryWidgetTypeFromVS, getWidgetTypes } from \"app/client/ui/widgetTypesMap\";\nimport { ariaTabs } from \"app/client/ui2018/ariaTabs\";\nimport { basicButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { buttonSelect } from \"app/client/ui2018/buttonSelect\";\nimport { cssLabel as cssCheckboxLabel, labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { textInput } from \"app/client/ui2018/editableLabel\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { select } from \"app/client/ui2018/menus\";\nimport { unstyledButton, unstyledUl } from \"app/client/ui2018/unstyled\";\nimport { FieldBuilder } from \"app/client/widgets/FieldBuilder\";\nimport { isFullReferencingType } from \"app/common/gristTypes\";\nimport { not } from \"app/common/gutil\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { components } from \"app/common/ThemePrefs\";\nimport { IWidgetType } from \"app/common/widgetTypes\";\n\nimport {\n  bundleChanges,\n  Computed,\n  Disposable,\n  dom,\n  domComputed,\n  DomContents,\n  DomElementArg,\n  DomElementMethod,\n  fromKo,\n  IDomComponent,\n  MultiHolder,\n  Observable,\n  styled,\n  subscribe,\n  toKo,\n} from \"grainjs\";\nimport * as ko from \"knockout\";\n\n// some unicode characters\nconst BLACK_CIRCLE = \"\\u2022\";\nconst ELEMENTOF = \"\\u2208\"; // 220A for small elementof\n\nconst t = makeT(\"RightPanel\");\n\n// Represents a top tab of the right side-pane.\nconst TopTab = StringUnion(\"pageWidget\", \"field\");\n\n// Represents a subtab of pageWidget in the right side-pane.\nconst PageSubTab = StringUnion(\"widget\", \"sortAndFilter\", \"data\", \"submission\");\n\nexport class RightPanel extends Disposable {\n  public readonly header: DomContents;\n  public readonly content: DomContents;\n\n  // If the panel is showing a tool, such as Action Log, instead of the usual section/field\n  // configuration, this will be set to the tool's header and content.\n  private _extraTool: Observable<IExtraTool | null>;\n\n  // Which of the two standard top tabs (page widget or field) is selected, or was last selected.\n  private _topTab = createSessionObs(this, \"rightTopTab\", \"pageWidget\", TopTab.guard);\n  private _topTabComponents: ReturnType<typeof ariaTabs>;\n\n  // Which subtab is open for configuring page widget.\n  private _subTab = createSessionObs(this, \"rightPageSubTab\", \"widget\", PageSubTab.guard);\n  private _subTabComponents: ReturnType<typeof ariaTabs>;\n\n  // Which type of page widget is active, e.g. \"record\" or \"chart\". This affects the names and\n  // icons in the top tab.\n  private _pageWidgetType = Computed.create<IWidgetType | null>(this, (use) => {\n    const section: ViewSectionRec = use(this._gristDoc.viewModel.activeSection);\n    return (use(section.parentKey) || null) as IWidgetType;\n  });\n\n  private _isForm = Computed.create(this, (use) => {\n    return use(this._pageWidgetType) === \"form\";\n  });\n\n  private _hasActiveWidget = Computed.create(this, use => Boolean(use(this._pageWidgetType)));\n\n  // Returns the active section if it's valid, null otherwise.\n  private _validSection = Computed.create(this, (use) => {\n    const sec = use(this._gristDoc.viewModel.activeSection);\n    return sec.getRowId() ? sec : null;\n  });\n\n  // Which subtab is open for configuring page widget.\n  private _advLinkInfoCollapsed = createSessionObs(this, \"rightPageAdvancedLinkInfoCollapsed\",\n    true, isBoolean);\n\n  constructor(private _gristDoc: GristDoc, private _isOpen: Observable<boolean>) {\n    super();\n    this._extraTool = _gristDoc.rightPanelTool;\n    this.autoDispose(subscribe(this._extraTool, (_use, tool) => tool && _isOpen.set(true)));\n    this.header = this._buildHeaderDom();\n    this.content = this._buildContentDom();\n\n    this.autoDispose(commands.createGroup({\n      fieldTabOpen: () => this._openFieldTab(),\n      viewTabOpen: () => this._openViewTab(),\n      viewTabFocus: () => this._viewTabFocus(),\n      sortFilterTabOpen: () => this._openSortFilter(),\n      dataSelectionTabOpen: () => this._openDataSelection(),\n    }, this, true));\n\n    // When a page widget is changed, subType might not be valid anymore, so reset it.\n    // TODO: refactor sub tabs and navigation using order of the tab.\n    this.autoDispose(subscribe((use) => {\n      if (!use(this._isForm) && use(this._subTab) === \"submission\") {\n        setImmediate(() => !this._subTab.isDisposed() && this._subTab.set(\"sortAndFilter\"));\n      } else if (use(this._isForm) && use(this._subTab) === \"sortAndFilter\") {\n        setImmediate(() => !this._subTab.isDisposed() && this._subTab.set(\"submission\"));\n      }\n    }));\n\n    this._topTabComponents = ariaTabs(\"rightTopbar\", this._topTab);\n    this._subTabComponents = ariaTabs(\"rightSubbar\", this._subTab);\n  }\n\n  private _openFieldTab() {\n    this._open(\"field\");\n  }\n\n  private _openViewTab() {\n    this._open(\"pageWidget\", \"widget\");\n  }\n\n  private _viewTabFocus() {\n    // If the view tab is already open, focus on the first input.\n    this._focus(\"pageWidget\");\n  }\n\n  private _openSortFilter() {\n    this._open(\"pageWidget\", \"sortAndFilter\");\n  }\n\n  private _openDataSelection() {\n    this._open(\"pageWidget\", \"data\");\n  }\n\n  private _open(topTab: typeof TopTab.type, subTab?: typeof PageSubTab.type) {\n    bundleChanges(() => {\n      this._isOpen.set(true);\n      this._topTab.set(topTab);\n      if (subTab) {\n        this._subTab.set(subTab);\n      }\n    });\n  }\n\n  private _focus(topTab: typeof TopTab.type) {\n    bundleChanges(() => {\n      if (!this._isOpen.get()) { return; }\n      this._isOpen.set(true);\n      this._topTab.set(topTab);\n    });\n  }\n\n  private _buildHeaderDom() {\n    return dom.domComputed((use) => {\n      if (!use(this._isOpen)) { return null; }\n      const tool = use(this._extraTool);\n      return tool ? this._buildToolHeader(tool) : this._buildStandardHeader();\n    });\n  }\n\n  private _buildToolHeader(tool: IExtraTool) {\n    return cssTopBarItem(cssTopBarIcon(tool.icon), tool.label,\n      cssHoverCircle(cssHoverIcon(\"CrossBig\"),\n        dom.on(\"click\", () => this._gristDoc.showTool(\"none\")),\n        testId(\"right-tool-close\"),\n      ),\n      cssTopBarItem.cls(\"-selected\", true),\n    );\n  }\n\n  private _buildStandardHeader() {\n    return dom.maybe(this._pageWidgetType, (type) => {\n      const widgetInfo = getWidgetTypes(type);\n      const fieldInfo = getFieldType(type);\n      return [\n        cssTopBarTabList(\n          this._topTabComponents.tabList(),\n          cssTopBarItem(\n            this._topTabComponents.tab(\"pageWidget\"),\n            cssTopBarIcon(widgetInfo.icon),\n            widgetInfo.getLabel(),\n            testId(\"right-tab-pagewidget\"),\n          ),\n          cssTopBarItem(\n            this._topTabComponents.tab(\"field\"),\n            cssTopBarIcon(fieldInfo.icon),\n            fieldInfo.label,\n            testId(\"right-tab-field\"),\n          ),\n        ),\n      ];\n    });\n  }\n\n  private _buildContentDom() {\n    return dom.domComputed((use) => {\n      if (!use(this._isOpen)) { return null; }\n      const tool = use(this._extraTool);\n      if (tool) { return tabContentToDom(tool.content); }\n      const isForm = use(this._isForm);\n\n      return [\n        cssTabPanel(\n          this._topTabComponents.tabPanel(\"pageWidget\",\n            isForm ?\n              [\n                dom.create(this._buildPageFormHeader.bind(this)),\n                dom.create(() => this._buildPageWidgetContent(isForm)),\n              ] :\n              use(this._hasActiveWidget) ?\n                [\n                  dom.create(this._buildPageWidgetHeader.bind(this)),\n                  dom.create(() => this._buildPageWidgetContent(isForm)),\n                ] :\n                null,\n          ),\n          testId(\"right-tabpanel-pagewidget\"),\n        ),\n        cssTabPanel(\n          this._topTabComponents.tabPanel(\"field\",\n            isForm ?\n              dom.create(this._buildQuestionContent.bind(this)) :\n              dom.create(this._buildFieldContent.bind(this)),\n          ),\n          testId(\"right-tabpanel-field\"),\n        ),\n      ];\n    });\n  }\n\n  private _buildFieldContent(owner: MultiHolder) {\n    const fieldBuilder = owner.autoDispose(ko.computed(() => {\n      const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();\n      return vsi?.activeFieldBuilder() ?? null;\n    }));\n\n    const selectedColumns = owner.autoDispose(ko.computed(() => {\n      const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();\n      if (vsi?.selectedColumns) {\n        return vsi.selectedColumns();\n      }\n      const field = fieldBuilder()?.field;\n      return field ? [field] : [];\n    }));\n\n    const isMultiSelect = owner.autoDispose(ko.pureComputed(() => {\n      const list = selectedColumns();\n      return Boolean(list && list.length > 1);\n    }));\n\n    owner.autoDispose(selectedColumns.subscribe((cols) => {\n      if (owner.isDisposed() || this._gristDoc.isDisposed() || this._gristDoc.viewModel.isDisposed()) { return; }\n      const section = this._gristDoc.viewModel.activeSection();\n      if (!section || section.isDisposed()) { return; }\n      section.selectedFields(cols || []);\n    }));\n    this._gristDoc.viewModel.activeSection()?.selectedFields(selectedColumns.peek() || []);\n\n    const docModel = this._gristDoc.docModel;\n    const origColRef = owner.autoDispose(ko.computed(() => fieldBuilder()?.origColumn.origColRef() || 0));\n    const origColumn = owner.autoDispose(docModel.columns.createFloatingRowModel(origColRef));\n    const isColumnValid = owner.autoDispose(ko.computed(() => Boolean(origColRef())));\n\n    // Builder for the reference display column multiselect.\n    const refSelect = RefSelect.create(owner, { docModel, origColumn, fieldBuilder });\n\n    // build cursor position observable\n    const cursor = owner.autoDispose(ko.computed(() => {\n      const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();\n      return vsi?.cursor.currentPosition() ?? {};\n    }));\n\n    return domAsync(imports.loadViewPane().then((ViewPane) => {\n      const { buildNameConfig, buildFormulaConfig } = ViewPane.FieldConfig;\n      return dom.maybe(isColumnValid, () =>\n        buildConfigContainer(\n          cssSection(\n            dom.create(buildNameConfig, origColumn, cursor, isMultiSelect),\n          ),\n          cssSection(\n            dom.create(buildDescriptionConfig, origColumn.description, { cursor, testPrefix: \"column\" }),\n          ),\n          cssSeparator(),\n          cssSection(\n            dom.create(buildFormulaConfig,\n              origColumn, this._gristDoc, this._activateFormulaEditor.bind(this)),\n          ),\n          cssSeparator(),\n          dom.maybe<FieldBuilder | null>(fieldBuilder, builder => [\n            cssLabel(t(\"COLUMN TYPE\")),\n            cssSection(\n              builder.buildSelectTypeDom(),\n            ),\n            cssSection(\n              builder.buildSelectWidgetDom(),\n            ),\n            cssSection(\n              builder.buildConfigDom(),\n            ),\n            builder.buildColorConfigDom(),\n            cssSection(\n              builder.buildSettingOptions(),\n              dom.maybe(isMultiSelect, () => disabledSection()),\n            ),\n          ]),\n          cssSeparator(),\n          cssSection(\n            dom.maybe(refSelect.isForeignRefCol, () => [\n              cssLabel(t(\"Add referenced columns\")),\n              cssRow(refSelect.buildDom()),\n              cssSeparator(),\n            ]),\n            cssLabel(t(\"TRANSFORM\")),\n            dom.maybe<FieldBuilder | null>(fieldBuilder, builder => builder.buildTransformDom()),\n            testId(\"panel-transform\"),\n          ),\n          this._disableIfReadonly(),\n        ),\n      );\n    }));\n  }\n\n  // Helper to activate the side-pane formula editor over the given HTML element.\n  private _activateFormulaEditor(options: BuildEditorOptions) {\n    const vsi = this._gristDoc.viewModel.activeSection().viewInstance();\n    if (!vsi) { return; }\n\n    const { refElem, editValue, canDetach, onSave, onCancel } = options;\n    const editRow = vsi.moveEditRowToCursor();\n    return vsi.activeFieldBuilder.peek().openSideFormulaEditor({\n      editRow,\n      refElem,\n      canDetach,\n      editValue,\n      onSave,\n      onCancel,\n    });\n  }\n\n  private _buildPageWidgetContent(isForm: boolean) {\n    const content = (activeSection: ViewSectionRec) => {\n      return [\n        dom(\"div\",\n          this._subTabComponents.tabPanel(\"widget\",\n            dom.create(this._buildPageWidgetConfig.bind(this), activeSection),\n          ),\n          testId(\"right-subtabpanel-widget\"),\n        ),\n        isForm ?\n          dom(\"div\",\n            this._subTabComponents.tabPanel(\"submission\",\n              dom.create(this._buildPageSubmissionConfig.bind(this), activeSection),\n            ),\n            testId(\"right-subtabpanel-submission\"),\n          ) :\n          dom(\"div\",\n            this._subTabComponents.tabPanel(\"sortAndFilter\",\n              dom.create(this._buildPageSortFilterConfig.bind(this)),\n            ),\n            cssConfigContainer.cls(\"-disabled\", activeSection.isRecordCard),\n            testId(\"right-subtabpanel-sortAndFilter\"),\n          ),\n        dom(\"div\",\n          this._subTabComponents.tabPanel(\"data\",\n            dom.create(this._buildPageDataConfig.bind(this), activeSection),\n          ),\n          testId(\"right-subtabpanel-data\"),\n        ),\n      ];\n    };\n    return dom.maybe(this._validSection, activeSection =>\n      buildConfigContainer(content(activeSection)),\n    );\n  }\n\n  private _buildPageFormHeader(_owner: MultiHolder) {\n    return [\n      cssSubTabContainer(\n        this._subTabComponents.tabList(),\n        cssSubTab(t(\"Configuration\"),\n          this._subTabComponents.tab(\"widget\"),\n          // the data-text attribute is necessary for a css trick to work (see cssSubTab)\n          dom.attr(\"data-text\", t(\"Configuration\")),\n          testId(\"config-widget\")),\n        cssSubTab(t(\"Submission\"),\n          this._subTabComponents.tab(\"submission\"),\n          dom.attr(\"data-text\", t(\"Submission\")),\n          testId(\"config-submission\")),\n        cssSubTab(t(\"Data\"),\n          this._subTabComponents.tab(\"data\"),\n          dom.attr(\"data-text\", t(\"Data\")),\n          testId(\"config-data\")),\n      ),\n    ];\n  }\n\n  private _buildPageWidgetHeader(_owner: MultiHolder) {\n    return [\n      cssSubTabContainer(\n        this._subTabComponents.tabList(),\n        cssSubTab(t(\"Widget\"),\n          this._subTabComponents.tab(\"widget\"),\n          // the data-text attribute is necessary for a css trick to work (see cssSubTab)\n          dom.attr(\"data-text\", t(\"Widget\")),\n          testId(\"config-widget\")),\n        cssSubTab(t(\"Sort & filter\"),\n          this._subTabComponents.tab(\"sortAndFilter\"),\n          dom.attr(\"data-text\", t(\"Sort & filter\")),\n          testId(\"config-sortAndFilter\")),\n        cssSubTab(t(\"Data\"),\n          this._subTabComponents.tab(\"data\"),\n          dom.attr(\"data-text\", t(\"Data\")),\n          testId(\"config-data\")),\n      ),\n    ];\n  }\n\n  private _createViewConfigTab(owner: MultiHolder): Observable<null | ViewConfigTab> {\n    const viewConfigTab = Observable.create<null | ViewConfigTab>(owner, null);\n    const gristDoc = this._gristDoc;\n    imports.loadViewPane()\n      .then((ViewPane) => {\n        if (owner.isDisposed()) { return; }\n        viewConfigTab.set(owner.autoDispose(\n          ViewPane.ViewConfigTab.create({ gristDoc, viewModel: gristDoc.viewModel })));\n      })\n      .catch(reportError);\n    return viewConfigTab;\n  }\n\n  private _buildPageWidgetConfig(owner: MultiHolder, activeSection: ViewSectionRec) {\n    // TODO: This uses private methods from ViewConfigTab. These methods are likely to get\n    // refactored, but if not, should be made public.\n    const viewConfigTab = this._createViewConfigTab(owner);\n    const hasCustomMapping = Computed.create(owner, (use) => {\n      // We shouldn't get here if activeSection is disposed but some errors reported in the wild\n      // point to this being sometimes possible.\n      if (activeSection.isDisposed()) { return false; }\n      const widgetType = use(this._pageWidgetType);\n      const isCustom = widgetType === \"custom\" || widgetType?.startsWith(\"custom.\");\n      return Boolean(isCustom && use(activeSection.columnsToMap));\n    });\n\n    // build cursor position observable\n    const cursor = owner.autoDispose(ko.computed(() => {\n      const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();\n      return vsi?.cursor.currentPosition() ?? {};\n    }));\n\n    return dom.maybe(viewConfigTab, vct => [\n      this._disableIfReadonly(),\n      dom.maybe(use => !use(activeSection.isRecordCard), () => [\n        cssLabel(dom.text(use => use(activeSection.isRaw) ? t(\"DATA TABLE NAME\") : t(\"WIDGET TITLE\")),\n          { for: \"right-widget-title-input\" },\n        ),\n        cssRow(cssTextInput(\n          Computed.create(owner, use => use(activeSection.titleDef)),\n          val => activeSection.titleDef.saveOnly(val),\n          dom.boolAttr(\"disabled\", (use) => {\n            const isRawTable = use(activeSection.isRaw);\n            const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0;\n            return isRawTable && isSummaryTable;\n          }),\n          { id: \"right-widget-title-input\" },\n          testId(\"right-widget-title\"),\n        )),\n\n        cssSection(\n          dom.create(buildDescriptionConfig, activeSection.description, { cursor, testPrefix: \"right-widget\" }),\n        ),\n      ]),\n\n      dom.maybe(\n        use => !use(activeSection.isRaw) && !use(activeSection.isRecordCard),\n        () => cssRow(\n          primaryButton(t(\"Change widget\"), this._createPageWidgetPicker()),\n          cssRow.cls(\"-top-space\"),\n        ),\n      ),\n\n      dom.maybe(use => [\"detail\", \"single\"].includes(use(this._pageWidgetType)!), () => [\n        cssGroupLabel(t(\"Theme\")),\n        dom(\"div\",\n          vct._buildThemeDom(),\n          vct._buildLayoutDom()),\n      ]),\n\n      domComputed((use) => {\n        if (use(this._pageWidgetType) !== \"record\") { return null; }\n        return dom.create(GridOptions, activeSection);\n      }),\n\n      domComputed((use) => {\n        if (use(this._pageWidgetType) !== \"record\") { return null; }\n        return dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"row-style-label\" },\n          cssSeparator(),\n          cssGroupLabel(t(\"Row style\"), { id: \"row-style-label\" }),\n          dom.create(rowHeightConfigTable, activeSection.optionsObj),\n          domAsync(imports.loadViewPane().then(ViewPane =>\n            dom.create(ViewPane.ConditionalStyle, t(\"Row style\"), activeSection, this._gristDoc),\n          )),\n        );\n      }),\n\n      dom.maybe(use => use(this._pageWidgetType) === \"chart\", () =>\n        dom(\"div\", { \"role\": \"group\", \"aria-label\": t(\"Chart options\") },\n          cssGroupLabel(t(\"CHART TYPE\")),\n          vct._buildChartConfigDom(),\n        ),\n      ),\n\n      dom.maybe(use => use(this._pageWidgetType) === \"custom\", () => {\n        const parts = vct._buildCustomTypeItems() as any[];\n        return [\n          cssSeparator(),\n          // If 'customViewPlugin' feature is on, show the toggle that allows switching to\n          // plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's\n          // the only one that will be shown without the feature flag.\n          dom.maybe(use => use(this._gristDoc.app.features).customViewPlugin,\n            () => dom(\"div\", parts[0].buildDom())),\n          dom.maybe(use => use(activeSection.customDef.mode) === \"plugin\",\n            () => dom(\"div\", parts[2].buildDom())),\n          // In the default url mode, allow picking a url and granting/forbidding\n          // access to data.\n          dom.maybe(use => use(activeSection.customDef.mode) === \"url\" && use(this._pageWidgetType) === \"custom\",\n            () => dom.create(CustomSectionConfig, activeSection, this._gristDoc)),\n        ];\n      }),\n      dom.maybe(use =>  use(this._pageWidgetType)?.startsWith(\"custom.\"), () => {\n        return [\n          dom.create(PredefinedCustomSectionConfig, activeSection, this._gristDoc),\n        ];\n      }),\n\n      dom.maybe(\n        use => !(\n          use(hasCustomMapping) ||\n          use(this._pageWidgetType) === \"chart\" ||\n          use(activeSection.isRaw)\n        ) && use(activeSection.parentKey) !== \"form\",\n        () => [\n          cssSeparator(),\n          dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),\n        ]),\n\n      dom.maybe(this._isForm, () => [\n        cssSeparator(),\n        dom.create(MappedFieldsConfig, activeSection),\n      ]),\n    ]);\n  }\n\n  private _buildPageSortFilterConfig(owner: MultiHolder) {\n    const viewConfigTab = this._createViewConfigTab(owner);\n    return dom.maybe(viewConfigTab, vct => vct.buildSortFilterDom());\n  }\n\n  private _buildLinkInfo(activeSection: ViewSectionRec, ...domArgs: DomElementArg[]) {\n    // NOTE!: linkingState.filterState might transiently be EmptyFilterState while things load\n    // Each case (filters-table, id cols, etc) needs to be able to handle having lfilter.filterLabels = {}\n    const tgtSec = activeSection;\n    return dom.domComputed((use) => {\n      const srcSec = use(tgtSec.linkSrcSection); // might be the empty section\n      const srcCol = use(tgtSec.linkSrcCol);\n      const srcColId = use(use(tgtSec.linkSrcCol).colId); // if srcCol is the empty col, colId will be undefined\n\n      if (srcSec.isDisposed()) { // can happen when deleting srcSection with rightpanel open\n        return cssLinkInfoPanel(\"\");\n      }\n\n      // const tgtColId = use(use(tgtSec.linkTargetCol).colId);\n      const srcTable = use(srcSec.table);\n      const tgtTable = use(tgtSec.table);\n\n      const lstate = use(tgtSec.linkingState);\n      if (lstate == null) { return null; }\n\n      // if not filter-linking, this will be incorrect, but we don't use it then\n      const lfilter = lstate.filterState ? use(lstate.filterState) : EmptyFilterState;\n\n      // If it's null then no cursor-link is set, but in that case we won't show the string anyway.\n      const cursorPos = lstate.cursorPos ? use(lstate.cursorPos) : 0;\n      const linkedCursorStr =  cursorPos ? `${use(tgtTable.tableId)}[${cursorPos}]` : \"\";\n\n      // Make descriptor for the link's source like: \"TableName . ColName\" or \"${SIGMA} TableName\", etc\n      const fromTableDom = [\n        dom.maybe(use2 => use2(srcTable.summarySourceTable), () => cssLinkInfoIcon(\"Pivot\")),\n        use(srcSec.titleDef) + (srcColId ? ` ${BLACK_CIRCLE} ${use(srcCol.label)}` : \"\"),\n        dom.style(\"white-space\", \"normal\"), // Allow table name to wrap, reduces how often scrollbar needed\n      ];\n\n      // Count filters for proper pluralization\n      const hasId = lfilter.filterLabels?.hasOwnProperty(\"id\");\n      const numFilters = Object.keys(lfilter.filterLabels).length - (hasId ? 1 : 0);\n\n      // ================== Link-info Helpers\n\n      // For each col-filter in lfilters, makes a row showing \"${icon} colName = [filterVals]\"\n      // FilterVals is in a box to look like a grid cell\n      const makeFiltersTable = (): DomContents => {\n        return cssLinkInfoBody(\n          dom.style(\"width\", \"100%\"), // width 100 keeps table from growing outside bounds of flex parent if overfull\n          dom(\"table\",\n            dom.style(\"margin-left\", \"8px\"),\n            Object.keys(lfilter.filterLabels).map((colId) => {\n              const vals = lfilter.filterLabels[colId];\n              let operationSymbol = \"=\";\n              // if [filter (reflist) <- ref], op=\"intersects\", need to convey \"list has value\". symbol =\":\"\n              // if [filter (ref) <- reflist], op=\"in\", vals.length>1, need to convey \"ref in list\"\n              // Sometimes operation will be 'empty', but in that case \"=\" still works fine, i.e. \"list = []\"\n              if (lfilter.operations[colId] == \"intersects\") {\n                operationSymbol = \":\";\n              } else if (vals.length > 1) {\n                operationSymbol = ELEMENTOF;\n              }\n\n              if (colId == \"id\") {\n                return dom(\"div\", `ERROR: ID FILTER: ${colId}[${vals}]`);\n              } else {\n                return dom(\"tr\",\n                  dom(\"td\", cssLinkInfoIcon(\"Filter\"),\n                    `${colId}`),\n                  dom(\"td\", operationSymbol, dom.style(\"padding\", \"0 2px 0 2px\")),\n                  dom(\"td\", cssLinkInfoValuesBox(\n                    isFullReferencingType(lfilter.colTypes[colId]) ?\n                      cssLinkInfoIcon(\"FieldReference\") : null,\n                    `${vals.join(\", \")}`)),\n                );\n              }\n            }), // end of keys(filterLabels).map\n          ));\n      };\n\n      // Given a list of filterLabels, show them all in a box, as if a grid cell\n      // Shows a \"Reference\" icon in the left side, since this should only be used for reflinks and cursor links\n      const makeValuesBox = (valueLabels: string[]): DomContents => {\n        return cssLinkInfoBody((\n          cssLinkInfoValuesBox(\n            cssLinkInfoIcon(\"FieldReference\"),\n            valueLabels.join(\", \")) // TODO: join labels like \"Entries[1], Entries[2]\" to \"Entries[[1,2]]\"\n        ));\n      };\n\n      const linkType = lstate.linkTypeDescription();\n\n      return cssLinkInfoPanel(() => {\n        switch (linkType) {\n          case \"Filter:Summary-Group\":\n          case \"Filter:Col->Col\":\n          case \"Filter:Row->Col\":\n          case \"Summary\":\n            return [\n              dom(\"div\", `Link applies filter${numFilters > 1 ? \"s\" : \"\"}:`),\n              makeFiltersTable(),\n              dom(\"div\", `Linked from `, fromTableDom),\n            ];\n          case \"Show-Referenced-Records\": {\n            // filterLabels might be {} if EmptyFilterState, so filterLabels[\"id\"] might be undefined\n            const displayValues = lfilter.filterLabels.id ?? [];\n            return [\n              dom(\"div\", `Link shows record${displayValues.length > 1 ? \"s\" : \"\"}:`),\n              makeValuesBox(displayValues),\n              dom(\"div\", `from `, fromTableDom),\n            ];\n          }\n          case \"Cursor:Same-Table\":\n          case \"Cursor:Reference\":\n            return [\n              dom(\"div\", `Link sets cursor to:`),\n              makeValuesBox([linkedCursorStr]),\n              dom(\"div\", `from `, fromTableDom),\n            ];\n          case \"Error:Invalid\":\n          default:\n            return dom(\"div\", `Error: Couldn't identify link state`);\n        }\n      },\n      ...domArgs,\n      ); // End of cssLinkInfoPanel\n    });\n  }\n\n  private _buildLinkInfoAdvanced(activeSection: ViewSectionRec) {\n    return  dom.domComputed((use): DomContents => {\n      // TODO: if this just outputs a string, this could really be in LinkingState as a toDebugStr function\n      //      but the fact that it's all observables makes that trickier to do correctly, so let's leave it here\n      const srcSec = use(activeSection.linkSrcSection); // might be the empty section\n      const tgtSec = activeSection;\n\n      if (srcSec.isDisposed()) { // can happen when deleting srcSection with rightpanel open\n        return cssRow(\"\");\n      }\n\n      const srcCol = use(activeSection.linkSrcCol); // might be the empty column\n      const tgtCol = use(activeSection.linkTargetCol);\n      // columns might be the empty column\n      // to check nullness, use `.getRowId() == 0` or `use(srcCol.colId) == undefined`\n\n      const secToStr = (sec: ViewSectionRec) => (!sec?.getRowId()) ?\n        \"null\" :\n        `#${use(sec.id)} \"${use(sec.titleDef)}\", (table \"${use(use(sec.table).tableId)}\")`;\n      const colToStr = (col: ColumnRec) => (!col?.getRowId()) ?\n        \"null\" :\n        `#${use(col.id)} \"${use(col.colId)}\", type \"${use(col.type)}\")`;\n\n      // linkingState can be null if the constructor throws, so for debugging we want to show link info\n      // if either the viewSection or the linkingState claim there's a link\n      const hasLink = use(srcSec.id) != undefined || use(tgtSec.linkingState) != null;\n      const lstate = use(tgtSec.linkingState);\n      const lfilter = lstate?.filterState ? use(lstate.filterState) : undefined;\n\n      // Debug info for cursor linking\n      const inPos = lstate?.incomingCursorPos ? use(lstate.incomingCursorPos) : null;\n      const cursorPosStr = (lstate?.cursorPos ? `${use(tgtSec.tableId)}[${use(lstate.cursorPos)}]` : \"N/A\") +\n      // TODO: the lastEdited and incomingCursorPos is kinda technical, to do with how bidirectional linking determines\n      //       priority for cyclical cursor links. Might be too technical even for the \"advanced info\" box\n        `\\n srclastEdited: T+${use(srcSec.lastCursorEdit)} \\n tgtLastEdited: T+${use(tgtSec.lastCursorEdit)}` +\n        `\\n incomingCursorPos: ${inPos ? `${inPos[0]}@T+${inPos[1]}` : \"N/A\"}`;\n\n      // Main link info as a big string, will be in a <pre></pre> block\n      let preString = \"No Incoming Link\";\n      if (hasLink) {\n        preString = [\n          `From Sec: ${secToStr(srcSec)}`,\n          `To   Sec: ${secToStr(tgtSec)}`,\n          \"\",\n          `From Col: ${colToStr(srcCol)}`,\n          `To   Col: ${colToStr(tgtCol)}`,\n          \"===========================\",\n          // Show linkstate\n          lstate == null ? \"LinkState: null\" : [\n            `Link Type: ${use(lstate.linkTypeDescription)}`,\n            ``,\n\n            \"Cursor Pos: \" + cursorPosStr,\n            !lfilter ? \"Filter State: null\" :\n              [\"Filter State:\", ...(Object.keys(lfilter).map(key =>\n                `- ${key}: ${JSON.stringify((lfilter as any)[key])}`))].join(\"\\n\"),\n          ].join(\"\\n\"),\n        ].join(\"\\n\");\n      }\n\n      const collapsed: SessionObs<boolean> = this._advLinkInfoCollapsed;\n      return hasLink ? [\n        cssRow(\n          icon(\"Dropdown\", dom.style(\"transform\", use2 => use2(collapsed) ? \"rotate(-90deg)\" : \"\")),\n          \"Advanced Link info\",\n          dom.style(\"font-size\", `${vars.smallFontSize}`),\n          dom.style(\"text-transform\", \"uppercase\"),\n          dom.style(\"cursor\", \"pointer\"),\n          dom.on(\"click\", () => collapsed.set(!collapsed.get())),\n        ),\n        dom.maybe(not(collapsed), () => cssRow(cssLinkInfoPre(preString))),\n      ] : null;\n    });\n  }\n\n  private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) {\n    const viewConfigTab = this._createViewConfigTab(owner);\n    const viewModel = this._gristDoc.viewModel;\n    const table = activeSection.table;\n    const groupedBy = Computed.create(owner, use => use(use(table).groupByColumns));\n    const link = Computed.create(owner, (use) => {\n      return linkId({\n        srcSectionRef: use(activeSection.linkSrcSectionRef),\n        srcColRef: use(activeSection.linkSrcColRef),\n        targetColRef: use(activeSection.linkTargetColRef),\n      });\n    });\n\n    // This computed is not enough to make sure that the linkOptions are up to date. Indeed\n    // the selectBy function depends on a much greater number of observables. Creating that many\n    // dependencies does not seem a better approach. Instead, we refresh the list of\n    // linkOptions only when the user clicks on the dropdown. Such behavior is not supported by the\n    // weasel select function as of writing and would require a custom implementation, so we will simulate\n    // this behavior by using temporary observable that will be changed when the user clicks on the dropdown.\n    const refreshTrigger = Observable.create(owner, false);\n    const linkOptions = Computed.create(owner, (use) => {\n      void use(refreshTrigger);\n      return selectBy(\n        this._gristDoc.docModel,\n        viewModel.viewSections().all(),\n        activeSection,\n      );\n    });\n\n    link.onWrite(async (val) => {\n      const widgetType = getTelemetryWidgetTypeFromVS(activeSection);\n      if (val !== NoLink) {\n        logTelemetryEvent(\"linkedWidget\", { full: { docIdDigest: this._gristDoc.docId(), widgetType } });\n      } else {\n        logTelemetryEvent(\"unlinkedWidget\", { full: { docIdDigest: this._gristDoc.docId(), widgetType } });\n      }\n\n      await this._gristDoc.saveLink(val);\n    });\n    return [\n      this._disableIfReadonly(),\n      dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"data-table-label\" },\n        cssGroupLabel(t(\"DATA TABLE\"), { id: \"data-table-label\" }),\n        cssRow(\n          cssIcon(\"TypeTable\"), cssDataLabel(t(\"SOURCE DATA\")),\n          cssContent(dom.text(use => use(use(table).primaryTableId)),\n            testId(\"pwc-table\")),\n        ),\n        dom(\n          \"div\",\n          cssRow(cssIcon(\"Pivot\"), cssDataLabel(t(\"GROUPED BY\"), { id: \"data-grouped-by-label\" })),\n          cssRow(domComputed(groupedBy, cols => cssList(\n            cols.map(c => cssListItem(dom.text(c.label), testId(\"pwc-groupedBy-col\"))),\n            { \"aria-labelledby\": \"data-grouped-by-label\" },\n          ))),\n\n          testId(\"pwc-groupedBy\"),\n          // hide if not a summary table\n          dom.hide(use => !use(use(table).summarySourceTable)),\n        ),\n\n        dom.maybe(use => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () =>\n          cssButtonRow(primaryButton(t(\"Edit data selection\"), this._createPageWidgetPicker(),\n            testId(\"pwc-editDataSelection\")),\n          dom.maybe(\n            use => Boolean(use(use(activeSection.table).summarySourceTable)),\n            () => basicButton(\n              t(\"Detach\"),\n              dom.on(\"click\", () => this._gristDoc.docData.sendAction(\n                [\"DetachSummaryViewSection\", activeSection.getRowId()])),\n              testId(\"detach-button\"),\n            )),\n          cssRow.cls(\"-top-space\"),\n          )),\n      ),\n\n      // TODO: \"Advanced settings\" is for \"on-demand\" marking of tables. This is now a deprecated feature. UIRowId\n      // is only shown for tables that are marked as \"on-demand\"\"\n      dom.domComputed(use => use(use(table).onDemand) && use(viewConfigTab), vct => vct ? cssRow(\n        dom(\"div\", vct._buildAdvancedSettingsDom()),\n      ) : null),\n\n      dom.maybe(use => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () => [\n        cssSeparator(),\n        cssLabel(t(\"SELECT BY\")),\n        cssRow(\n          dom.update(\n            select(link, linkOptions, { defaultLabel: t(\"Select widget\") }),\n            dom.on(\"click\", () => {\n              refreshTrigger.set(!refreshTrigger.get());\n            }),\n          ),\n          testId(\"right-select-by\"),\n        ),\n      ]),\n\n      dom.maybe(activeSection.linkingState, () => cssRow(this._buildLinkInfo(activeSection))),\n\n      domComputed((use) => {\n        const selectorFor = use(use(activeSection.linkedSections).getObservable());\n        // TODO: sections should be listed following the order of appearance in the view layout (ie:\n        // left/right - top/bottom);\n        return selectorFor.length ? [\n          cssGroupLabel(t(\"SELECTOR FOR\"), { id: \"data-selector-for-label\" }, testId(\"selector-for\")),\n          cssRow(cssList(\n            { \"aria-labelledby\": \"data-selector-for-label\" },\n            selectorFor.map(sec => this._buildSectionItem(sec)),\n          )),\n        ] : null;\n      }),\n\n      // Advanced link info is a little too JSON-ish for general use. But it's very useful for debugging\n      this._buildLinkInfoAdvanced(activeSection),\n    ];\n  }\n\n  private _createPageWidgetPicker(): DomElementMethod {\n    const gristDoc = this._gristDoc;\n    const { activeSection } = gristDoc.viewModel;\n    const onSave = async (val: IPageWidget) => {\n      const { id } = await gristDoc.saveViewSection(activeSection.peek(), val);\n      if (val.type === \"custom\") {\n        showCustomWidgetGallery(gristDoc, { sectionRef: id() });\n      }\n    };\n    return (elem) => {\n      attachPageWidgetPicker(elem, gristDoc, onSave, {\n        buttonLabel: t(\"Save\"),\n        value: () => toPageWidget(activeSection.peek()),\n        selectBy: val => gristDoc.selectBy(val),\n      });\n    };\n  }\n\n  // Returns dom for a section item.\n  private _buildSectionItem(sec: ViewSectionRec) {\n    return cssListItem(\n      dom.text(sec.titleDef),\n      this._buildLinkInfo(sec, dom.style(\"border\", \"none\")),\n      testId(\"selector-for-entry\"),\n    );\n  }\n\n  // Returns a DomArg that disables the content of the panel by adding a transparent overlay on top\n  // of it.\n  private _disableIfReadonly() {\n    if (this._gristDoc.docPageModel) {\n      return dom.maybe(this._gristDoc.docPageModel.isReadonly,  () => (\n        cssOverlay(\n          testId(\"disable-overlay\"),\n          cssBottomText(t(\"You do not have edit access to this document\")),\n        )\n      ));\n    }\n  }\n\n  private _buildPageSubmissionConfig(owner: MultiHolder, activeSection: ViewSectionRec) {\n    // All of those observables are backed by the layout config.\n    const submitButtonKo = activeSection.layoutSpecObj.prop(\"submitText\");\n    const toComputed = (obs: typeof submitButtonKo) => {\n      const result = Computed.create(owner, use => use(obs));\n      result.onWrite(val => obs.setAndSave(val));\n      return result;\n    };\n    const submitButton = toComputed(submitButtonKo);\n    const successText = toComputed(activeSection.layoutSpecObj.prop(\"successText\"));\n    const successURL = toComputed(activeSection.layoutSpecObj.prop(\"successURL\"));\n    const anotherResponse = toComputed(activeSection.layoutSpecObj.prop(\"anotherResponse\"));\n    const redirection = Observable.create(owner, Boolean(successURL.get()));\n    owner.autoDispose(redirection.addListener((val) => {\n      if (!val) {\n        successURL.set(null);\n      }\n    }));\n    owner.autoDispose(successURL.addListener((val) => {\n      if (val) {\n        redirection.set(true);\n      }\n    }));\n    return [\n      cssLabel(t(\"Submit button label\")),\n      cssRow(\n        cssTextInput(submitButton, val => submitButton.set(val), { placeholder: t(\"Submit\") }),\n      ),\n      cssLabel(t(\"Success text\")),\n      cssRow(\n        cssTextArea(\n          successText,\n          { autoGrow: true, save: val => successText.set(val) },\n          { placeholder: t(\"Thank you! Your response has been recorded.\") },\n        ),\n      ),\n      cssLabel(t(\"Submit another response\")),\n      cssRow(\n        labeledSquareCheckbox(anotherResponse, [\n          t(\"Display button\"),\n        ]),\n      ),\n      cssLabel(t(\"Redirection\")),\n      cssRow(\n        labeledSquareCheckbox(\n          redirection,\n          t(\"Redirect automatically after submission\"),\n          testId(\"form-redirect\"),\n        ),\n      ),\n      cssRow(\n        cssTextInput(\n          successURL,\n          val => successURL.set(val),\n          { placeholder: t(\"Enter redirect URL\") },\n          testId(\"form-redirect-url\"),\n        ),\n        dom.show(redirection),\n      ),\n    ];\n  }\n\n  private _buildQuestionContent(owner: MultiHolder) {\n    const fieldBuilder = owner.autoDispose(ko.computed(() => {\n      const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();\n      return vsi?.activeFieldBuilder() ?? null;\n    }));\n\n    // Sorry for the acrobatics below, but grainjs are not reentred when the active section changes.\n    const viewInstance = owner.autoDispose(ko.computed(() => {\n      const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();\n      if (!vsi || vsi.isDisposed() || !toKo(ko, this._isForm)) { return null; }\n      return vsi;\n    }));\n\n    const formView = owner.autoDispose(ko.computed(() => {\n      const view = viewInstance() as unknown as FormView;\n      if (!view?.selectedBox) { return null; }\n      return view;\n    }));\n\n    const selectedBox = owner.autoDispose(ko.pureComputed(() => {\n      const view = formView();\n      if (!view) { return null; }\n      const box = toKo(ko, view.selectedBox)();\n      return box;\n    }));\n    const selectedField = Computed.create(owner, (use) => {\n      const box = use(selectedBox);\n      if (!box) { return null; }\n      if (box.type !== \"Field\") { return null; }\n      const fieldBox = box as FieldModel;\n      return use(fieldBox.field);\n    });\n    const selectedBoxWithOptions = Computed.create(owner, (use) => {\n      const box = use(selectedBox);\n      if (!box || ![\"Paragraph\", \"Label\"].includes(box.type)) { return null; }\n\n      return box;\n    });\n\n    return domAsync(imports.loadViewPane().then(() => buildConfigContainer(cssSection(\n      // Field config.\n      dom.maybe(selectedField, (field) => {\n        const fieldTitle = field.widgetOptionsJson.prop(\"question\");\n\n        return [\n          cssLabel(t(\"Field title\")),\n          cssRow(\n            cssTextInput(\n              fromKo(fieldTitle),\n              val => fieldTitle.saveOnly(val).catch(reportError),\n              dom.prop(\"readonly\", use => use(field.disableModify)),\n              dom.prop(\"placeholder\", use => use(field.displayLabel) || use(field.colId)),\n              testId(\"field-title\"),\n            ),\n          ),\n          cssLabel(t(\"Table column name\")),\n          cssRow(\n            cssTextInput(\n              fromKo(field.displayLabel),\n              val => field.displayLabel.saveOnly(val).catch(reportError),\n              dom.prop(\"readonly\", use => use(field.disableModify)),\n              testId(\"field-label\"),\n            ),\n          ),\n          dom.maybe<FieldBuilder | null>(fieldBuilder, builder => [\n            cssSeparator(),\n            cssLabel(t(\"COLUMN TYPE\")),\n            cssSection(\n              builder.buildSelectTypeDom(),\n            ),\n            cssSection(\n              builder.buildFormConfigDom(),\n            ),\n          ]),\n        ];\n      }),\n\n      // Box config\n      dom.maybe(selectedBoxWithOptions, box => [\n        cssLabel(dom.text(box.type)),\n        cssRow(\n          cssTextArea(\n            box.prop(\"text\"),\n            { onInput: true, autoGrow: true },\n            dom.on(\"blur\", () => box.save().catch(reportError)),\n            { placeholder: t(\"Enter text\") },\n          ),\n        ),\n        cssRow(\n          buttonSelect(box.prop(\"alignment\"), [\n            { value: \"left\",   icon: \"LeftAlign\" },\n            { value: \"center\", icon: \"CenterAlign\" },\n            { value: \"right\",  icon: \"RightAlign\" },\n          ]),\n          dom.autoDispose(box.prop(\"alignment\").addListener(() => box.save().catch(reportError))),\n        ),\n      ]),\n\n      // Default.\n      dom.maybe(u => !u(selectedField) && !u(selectedBoxWithOptions), () => [\n        buildFormConfigPlaceholder(),\n      ]),\n    ))));\n  }\n}\n\nfunction buildFormConfigPlaceholder() {\n  return cssFormConfigPlaceholder(\n    cssFormConfigImg(),\n    cssFormConfigMessage(\n      cssFormConfigMessageTitle(t(\"No field selected\")),\n      dom(\"div\", t(\"Select a field in the form widget to configure.\")),\n    ),\n  );\n}\n\nfunction disabledSection() {\n  return cssOverlay(\n    testId(\"panel-disabled-section\"),\n  );\n}\n\n// This logic is copied from SidePane.js for building DOM from TabContent.\n// TODO It may not be needed after new-ui refactoring of the side-pane content.\nfunction tabContentToDom(content: Observable<TabContent[]> | TabContent[] | IDomComponent) {\n  function buildItemDom(item: any) {\n    return dom(\"div.config_item\",\n      dom.show(item.showObs || true),\n      item.buildDom(),\n    );\n  }\n\n  if (\"buildDom\" in content) {\n    return content.buildDom();\n  }\n\n  return cssTabContents(\n    dom.forEach(content, (itemOrHeader) => {\n      if (itemOrHeader.header) {\n        return dom(\"div.config_group\",\n          dom.show(itemOrHeader.showObs || true),\n          itemOrHeader.label ? dom(\"div.config_header\", itemOrHeader.label) : null,\n          dom.forEach(itemOrHeader.items, item => buildItemDom(item)),\n        );\n      } else {\n        return buildItemDom(itemOrHeader);\n      }\n    }),\n  );\n}\n\nconst cssOverlay = styled(\"div\", `\n  background-color: ${theme.rightPanelDisabledOverlay};\n  opacity: 0.8;\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  z-index: 100;\n`);\n\nconst cssBottomText = styled(\"span\", `\n  color: ${theme.text};\n  position: absolute;\n  bottom: -40px;\n  padding: 4px 16px;\n`);\n\nconst cssRow = styled(\"div\", `\n  color: ${theme.text};\n  display: flex;\n  margin: 8px 16px;\n  align-items: center;\n  &-top-space {\n    margin-top: 24px;\n  }\n  &-disabled {\n    color: ${theme.disabledText};\n  }\n  & .${cssCheckboxLabel.className} {\n    flex-shrink: revert;  /* allow checkbox labels to wrap in right-panel rows */\n  }\n`);\n\nconst cssButtonRow = styled(cssRow, `\n  margin-left: 0;\n  margin-right: 0;\n  & > button {\n    margin-left: 16px;\n  }\n`);\n\nconst cssIcon = styled(icon, `\n  flex: 0 0 auto;\n  --icon-color: ${theme.lightText};\n`);\n\nconst cssTopBarTabList = styled(\"div\", `\n  display: flex;\n  width: 100%;\n`);\n\nconst cssTopBarItem = styled(unstyledButton, `\n  flex: 1 1 0px;\n  height: 100%;\n  background-color: ${theme.rightPanelTabBg};\n  border-right: 1px solid ${theme.rightPanelTabBg};\n  border-left: 1px solid ${theme.rightPanelTabBg};\n  border-bottom: 1px solid ${theme.rightPanelTabBorder};\n  font-weight: initial;\n  color: ${theme.rightPanelTabFg};\n  --icon-color: ${theme.rightPanelTabIcon};\n  display: flex;\n  align-items: center;\n  cursor: default;\n  outline-offset: -6px;\n  &:first-child {\n    border-left: 0;\n  }\n  &:last-child {\n    border-right: 0;\n  }\n  /* the -selected class is used when the topbar item is not a tab */\n  &-selected, &[aria-selected=\"true\"] {\n    background-color: ${theme.rightPanelTabSelectedBg};\n    font-weight: ${vars.headerControlTextWeight};\n    color: ${theme.rightPanelTabSelectedFg};\n    --icon-color: ${theme.rightPanelTabSelectedIcon};\n    border-bottom-color: ${theme.rightPanelTabSelectedBg};\n    border-left-color: ${theme.rightPanelTabBorder};\n    border-right-color: ${theme.rightPanelTabBorder};\n  }\n  &:not(&-selected, &[aria-selected=\"true\"]):hover {\n    background-color: ${theme.rightPanelTabHoverBg};\n    border-left-color: ${theme.rightPanelTabHoverBg};\n    border-right-color: ${theme.rightPanelTabHoverBg};\n    color: ${theme.rightPanelTabHoverFg};\n    --icon-color: ${theme.rightPanelTabIconHover};\n  }\n`);\n\nconst cssTopBarIcon = styled(icon, `\n  flex: none;\n  margin: 16px;\n  height: 16px;\n  width: 16px;\n  background-color: var(--icon-color);\n`);\n\nconst cssHoverCircle = styled(\"div\", `\n  margin-left: auto;\n  margin-right: 8px;\n  width: 32px;\n  height: 32px;\n  background: none;\n  border-radius: 16px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  cursor: pointer;\n  &:hover {\n    background-color: ${theme.rightPanelTabButtonHoverBg};\n    --icon-color: ${theme.iconButtonFg};\n  }\n`);\n\nconst cssHoverIcon = styled(icon, `\n  height: 16px;\n  width: 16px;\n  background-color: var(--icon-color);\n`);\n\nconst cssSubTabContainer = styled(\"div\", `\n  height: 48px;\n  flex: none;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  border-bottom: 1px solid ${components.pagePanelsBorder};\n`);\n\nconst cssSubTab = styled(unstyledButton, `\n  color: ${components.rightPanelSubtabFg};\n  flex: auto;\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  justify-content: flex-end;\n  align-items: center;\n  text-align: center;\n  padding-bottom: 8px;\n  cursor: default;\n  border-bottom: 2px solid transparent;\n  outline-offset: -3px;\n\n  &[aria-selected=\"true\"] {\n    font-weight: 600;\n    color: ${components.rightPanelSubtabSelectedFg};\n    border-bottom-color: ${components.rightPanelSubtabSelectedUnderline};\n  }\n  &:not(&[aria-selected=\"true\"]):hover {\n    color: ${components.rightPanelSubtabHoverFg};\n  }\n  &:hover {\n    font-weight: 600;\n  }\n\n  /* Trick to prevent text moving on hover because of font-weight change */\n  &::after {\n    content: attr(data-text);\n    content: attr(data-text) / \"\";\n    font-weight: 600;\n    opacity: 0;\n    pointer-events: none;\n    height: 0;\n    overflow: hidden;\n  }\n`);\n\nconst cssTabPanel = styled(\"div\", `\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n`);\n\nconst cssTabContents = styled(\"div\", `\n  padding: 16px 8px;\n  overflow: auto;\n`);\n\nconst cssDataLabel = styled(\"div\", `\n  flex: 0 0 81px;\n  color: ${theme.lightText};\n  font-size: ${vars.xsmallFontSize};\n  margin-left: 4px;\n  margin-top: 2px;\n`);\n\nconst cssContent = styled(\"div\", `\n  flex: 0 1 auto;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  min-width: 1em;\n`);\n\nconst cssList = styled(unstyledUl, `\n  width: 100%;\n`);\n\nconst cssListItem = styled(\"li\", `\n  background-color: ${theme.hover};\n  border-radius: 2px;\n  margin-bottom: 4px;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  width: 100%;\n  padding: 4px 8px;\n`);\n\nconst cssTextArea = styled(textarea, `\n  flex: 1 0 auto;\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n\n  outline: none;\n  padding: 3px 7px;\n  /* Make space at least for two lines: size of line * 2 * line height + 2 * padding + border * 2 */\n  min-height: calc(2em * 1.5 + 2 * 3px + 2px);\n  line-height: 1.5;\n  resize: none;\n\n  &:disabled {\n    color: ${theme.inputDisabledFg};\n    background-color: ${theme.inputDisabledBg};\n    pointer-events: none;\n  }\n`);\n\nconst cssTextInput = styled(textInput, `\n  flex: 1 0 auto;\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n\n  &:disabled {\n    color: ${theme.inputDisabledFg};\n    background-color: ${theme.inputDisabledBg};\n    pointer-events: none;\n  }\n`);\n\nconst cssSection = styled(\"div\", `\n  position: relative;\n`);\n\n// ============ LinkInfo CSS ============\n\n// LinkInfoPanel is a flex-column\n// `LinkInfoPanel > table` is the table where we show linked filters, if there are any\nconst cssLinkInfoPanel = styled(\"div\", `\n  width: 100%;\n\n  display: flex;\n  flex-flow: column;\n  align-items: start;\n  text-align: left;\n\n  font-family: ${vars.fontFamily};\n\n  border: 1px solid ${theme.pagePanelsBorder};\n  border-radius: 4px;\n\n  padding: 6px;\n\n  white-space: nowrap;\n  overflow-x: auto;\n\n  & table {\n      border-spacing: 2px;\n      border-collapse: separate;\n  }\n`);\n\n// Center table / values box inside LinkInfoPanel\nconst cssLinkInfoBody = styled(\"div\", `\n  margin: 2px 0 2px 0;\n  align-self: center;\n`);\n\n// Intended to imitate style of a grid cell\n// white-space: normal allows multiple values to wrap\n// min-height: 22px matches real field size, +2 for the borders\nconst cssLinkInfoValuesBox = styled(\"div\", `\n  border: 1px solid ${\"#CCC\"};\n  padding: 3px 3px 0px 3px;\n  min-width: 60px;\n  min-height: 24px;\n\n  white-space: normal;\n`);\n\n// If inline with text, icons look better shifted up slightly\n// since icons are position:relative, bottom:1 should shift it without affecting layout\nconst cssLinkInfoIcon = styled(icon, `\n  bottom: 1px;\n  margin-right: 3px;\n  background-color: ${theme.controlSecondaryFg};\n`);\n\n// ============== styles for _buildLinkInfoAdvanced\nconst cssLinkInfoPre = styled(\"pre\", `\n  padding: 6px;\n  font-size: ${vars.smallFontSize};\n  line-height: 1.2;\n`);\n\nconst cssFormConfigPlaceholder = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  row-gap: 16px;\n  margin-top: 32px;\n  padding: 8px;\n`);\n\nconst cssFormConfigImg = styled(\"div\", `\n  height: 140px;\n  width: 100%;\n  background-size: contain;\n  background-repeat: no-repeat;\n  background-position: center;\n  background-image: var(--icon-FormConfig);\n`);\n\nconst cssFormConfigMessage = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  row-gap: 8px;\n  color: ${theme.text};\n  text-align: center;\n`);\n\nconst cssFormConfigMessageTitle = styled(\"div\", `\n  font-size: ${vars.largeFontSize};\n  font-weight: 600;\n`);\n"
  },
  {
    "path": "app/client/ui/RightPanelStyles.ts",
    "content": "import { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { numericSpinner } from \"app/client/widgets/NumericSpinner\";\n\nimport { styled } from \"grainjs\";\n\nexport const cssIcon = styled(icon, `\n  flex: 0 0 auto;\n  --icon-color: ${theme.lightText};\n`);\n\nconst cssLabelBase = `\n  display: block;\n  color: ${theme.text};\n  text-transform: uppercase;\n  margin: 16px 16px 8px 16px;\n  font-size: ${vars.xsmallFontSize};\n`;\n\nexport const cssLabel = styled(\"label\", cssLabelBase);\n\nexport const cssGroupLabel = styled(\"div\", cssLabelBase);\n\nexport const cssLabelText = styled(\"span\", `\n  color: ${theme.text};\n  text-transform: uppercase;\n  font-size: ${vars.xsmallFontSize};\n`);\n\nexport const cssHelp = styled(\"div\", `\n  color: ${theme.lightText};\n  margin: -8px 16px 12px 16px;\n  font-style: italic;\n  font-size: ${vars.xsmallFontSize};\n`);\n\nexport const cssRow = styled(\"div\", `\n  display: flex;\n  margin: 8px 16px;\n  align-items: center;\n  color: ${theme.text};\n  &-top-space {\n    margin-top: 24px;\n  }\n  &-disabled {\n    color: ${theme.disabledText};\n  }\n`);\n\nexport const cssRowWrapped = styled(cssRow, `\n  flex-wrap: wrap;\n  row-gap: 5px;\n`);\n\nexport const cssSortFilterColumn = styled(unstyledButton, `\n  cursor: pointer;\n  display: flex;\n  flex-grow: 1;\n  text-align: left;\n  align-items: center;\n  color: ${theme.text};\n  background-color: ${theme.hover};\n  overflow: hidden;\n  border-radius: 4px;\n  padding: 4px 8px;\n`);\n\nexport const cssBlockedCursor = styled(\"span\", `\n  &, & * {\n    cursor: not-allowed !important;\n  }\n`);\n\nexport const cssButtonRow = styled(cssRowWrapped, `\n  margin-left: 0;\n  margin-right: 0;\n  & > button {\n    margin-left: 16px;\n  }\n`);\n\nexport const cssSeparator = styled(\"div\", `\n  border-bottom: 1px solid ${theme.pagePanelsBorder};\n  margin-top: 16px;\n`);\n\nexport const cssSaveButtonsRow = styled(\"div\", `\n  margin: 16px 16px 12px 16px;\n`);\n\nexport const cssPinButton = styled(unstyledButton, `\n  cursor: pointer;\n  --icon-color: ${theme.controlSecondaryFg};\n  border-radius: ${vars.controlBorderRadius};\n  padding: 3px;\n  outline-offset: 2px;\n\n  &-pinned {\n    background-color: ${theme.controlPrimaryBg};\n    --icon-color: ${theme.controlPrimaryFg};\n  }\n\n  &:not(&-pinned):hover {\n    background-color: ${theme.hover};\n  }\n`);\n\nexport const cssNumericSpinner = styled(numericSpinner, `\n  height: 28px;\n`);\n\nexport const cssFieldFormula = styled(\"div\", `\n  flex: auto;\n  cursor: pointer;\n  padding-left: 24px;\n  --icon-color: ${theme.accentIcon};\n\n  &-disabled-icon.formula_field_sidepane::before {\n    --icon-color: ${theme.iconDisabled};\n  }\n  &-disabled {\n    pointer-events: none;\n  }\n`);\n\nexport const cssConfigContainer = styled(\"div.test-config-container\", `\n  overflow: auto;\n  --color-list-item: none;\n  --color-list-item-hover: none;\n\n  &:after {\n    content: \"\";\n    display: block;\n    height: 40px;\n  }\n  & .fieldbuilder_settings {\n    margin: 16px 0 0 0;\n  }\n  &-disabled {\n    opacity: 0.4;\n    pointer-events: none;\n  }\n`);\n\nexport const cssWarningBox = styled(\"div\", `\n  text-align: left;\n  margin-top: 1.5rem;\n  border: 1px solid #fbeed5;\n  border-radius: 4px;\n  background: #fcf8e3;\n  padding: 8px;\n  color: #845d1a;\n  display: flex;\n  flex-direction: column;\n`);\n\nexport const cssWarningHeader = styled(\"div\", `\n  font-weight: bold;\n  margin-bottom: 1rem;\n  align-self: flex-start;\n`);\n"
  },
  {
    "path": "app/client/ui/RightPanelUtils.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { cssConfigContainer } from \"app/client/ui/RightPanelStyles\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { IWidgetType } from \"app/common/widgetTypes\";\n\nimport { dom, DomElementArg } from \"grainjs\";\n\nconst t = makeT(\"RightPanel\");\n\n// Returns the icon and label of a type, default to those associate to 'record' type.\nexport function getFieldType(widgetType: IWidgetType | null) {\n  // A map of widget type to the icon and label to use for a field of that widget.\n  const fieldTypes = new Map<IWidgetType, { label: string; icon: IconName; pluralLabel: string; }>([\n    [\"record\", { label: t(\"columns\", { count: 1 }), icon: \"TypeCell\", pluralLabel: t(\"columns\", { count: 2 }) }],\n    [\"detail\", { label: t(\"fields\", { count: 1 }), icon: \"TypeCell\", pluralLabel: t(\"fields\", { count: 2 }) }],\n    [\"single\", { label: t(\"fields\", { count: 1 }), icon: \"TypeCell\", pluralLabel: t(\"fields\", { count: 2 }) }],\n    [\"chart\", { label: t(\"series\", { count: 1 }), icon: \"ChartLine\", pluralLabel: t(\"series\", { count: 2 }) }],\n    [\"custom\", { label: t(\"columns\", { count: 1 }), icon: \"TypeCell\", pluralLabel: t(\"columns\", { count: 2 }) }],\n    [\"form\", { label: t(\"fields\", { count: 1 }), icon: \"TypeCell\", pluralLabel: t(\"fields\", { count: 2 }) }],\n  ]);\n\n  return fieldTypes.get(widgetType || \"record\") || fieldTypes.get(\"record\")!;\n}\n\nexport function buildConfigContainer(...args: DomElementArg[]): HTMLElement {\n  return cssConfigContainer(\n    // The `position: relative;` style is needed for the overlay for the readonly mode. Note that\n    // we cannot set it on the cssConfigContainer directly because it conflicts with how overflow\n    // works. `padding-top: 1px;` prevents collapsing the top margins for the container and the\n    // first child.\n    dom(\"div\", { style: \"position: relative; padding-top: 1px;\" }, ...args),\n  );\n}\n"
  },
  {
    "path": "app/client/ui/RowContextMenu.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { menuDivider, menuIcon, menuItemCmd, menuItemCmdLabel } from \"app/client/ui2018/menus\";\n\nimport { dom } from \"grainjs\";\n\nconst t = makeT(\"RowContextMenu\");\n\nexport interface IRowContextMenu {\n  disableInsert: boolean;\n  disableDelete: boolean;\n  disableMakeHeadersFromRow: boolean;\n  disableShowRecordCard: boolean;\n  isViewSorted: boolean;\n  numRows: number;\n  disableAnchorLink?: boolean;\n}\n\nexport function RowContextMenu({\n  disableInsert,\n  disableDelete,\n  disableMakeHeadersFromRow,\n  disableShowRecordCard,\n  disableAnchorLink,\n  isViewSorted,\n  numRows,\n}: IRowContextMenu) {\n  const result: Element[] = [];\n  if (numRows === 1) {\n    result.push(\n      menuItemCmd(\n        allCommands.viewAsCard,\n        () => menuItemCmdLabel(menuIcon(\"TypeCard\"), t(\"View as card\")),\n        dom.cls(\"disabled\", disableShowRecordCard),\n      ),\n      menuDivider(),\n    );\n  }\n  if (isViewSorted) {\n    // When the view is sorted, any newly added records get shifts instantly at the top or\n    // bottom. It could be very confusing for users who might expect the record to stay above or\n    // below the active row. Thus in this case we show a single `insert row` command.\n    result.push(\n      menuItemCmd(allCommands.insertRecordAfter, t(\"Insert row\"),\n        dom.cls(\"disabled\", disableInsert)),\n    );\n  } else {\n    result.push(\n      menuItemCmd(allCommands.insertRecordBefore, t(\"Insert row above\"),\n        dom.cls(\"disabled\", disableInsert)),\n      menuItemCmd(allCommands.insertRecordAfter, t(\"Insert row below\"),\n        dom.cls(\"disabled\", disableInsert)),\n    );\n  }\n  result.push(\n    menuItemCmd(allCommands.duplicateRows, t(\"Duplicate rows\", { count: numRows }),\n      dom.cls(\"disabled\", disableInsert || numRows === 0)),\n  );\n  result.push(\n    menuDivider(),\n    menuItemCmd(allCommands.makeHeadersFromRow, t(\"Use as table headers\"),\n      dom.cls(\"disabled\", disableMakeHeadersFromRow)),\n  );\n  result.push(\n    menuDivider(),\n    // TODO: should show `Delete ${num} rows` when multiple are selected\n    menuItemCmd(allCommands.deleteRecords, t(\"Delete\"),\n      dom.cls(\"disabled\", disableDelete)),\n  );\n  result.push(\n    menuDivider(),\n    menuItemCmd(allCommands.copyLink, t(\"Copy anchor link\"),\n      dom.cls(\"disabled\", disableAnchorLink ?? false)),\n  );\n  return result;\n}\n"
  },
  {
    "path": "app/client/ui/RowHeightConfig.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewSectionOptions, ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport { SaveableObjObservable } from \"app/client/models/modelUtil\";\nimport { cssNumericSpinner, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { infoTooltip } from \"app/client/ui/tooltips\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { testId } from \"app/client/ui2018/cssVars\";\n\nimport { Computed, dom, DomContents, DomElementArg, IDisposableOwner, styled, subscribeElem } from \"grainjs\";\n\nconst t = makeT(\"RowHeightConfig\");\n\n/**\n * Builds the configuration UI to show in columns. It's only non-empty for grid sections\n * (widgetType 'record'). It shows minimal information and has a link to the actual configuration\n * UI in the table's creator panel.\n */\nexport function rowHeightConfigColumn(viewSection: ViewSectionRec): DomContents {\n  const optionsObs: SaveableObjObservable<ViewSectionOptions> = viewSection.optionsObj;\n  return dom.maybe(use => use(viewSection.widgetType) === \"record\", () => [\n    cssRowHeightText(t(\"Max row height\"), \":\",\n      dom(\"b\", dom.text(use => String(use(optionsObs).rowHeight || \"auto\")),\n        testId(\"row-height-label\"),\n      ),\n    ),\n    textButton(t(\"Change\"), dom.on(\"click\", allCommands.viewTabOpen.run),\n      testId(\"row-height-change-link\"),\n    ),\n  ]);\n}\n\n/**\n * Builds the configuration UI to how for the table, to show for GridView widgets.\n */\nexport function rowHeightConfigTable(\n  owner: IDisposableOwner,\n  optionsObs: SaveableObjObservable<ViewSectionOptions>,\n): DomContents {\n  const rowHeightObs = Computed.create<number | \"\">(owner, use => use(optionsObs).rowHeight || \"\");\n  const setRowHeight = (rowHeight: number | undefined) => optionsObs.setAndSave({ ...optionsObs.peek(), rowHeight });\n\n  const uniformRows = Computed.create<boolean>(owner, use => use(optionsObs).rowHeightUniform || false);\n  uniformRows.onWrite((val: boolean) => optionsObs.setAndSave({ ...optionsObs.peek(), rowHeightUniform: val }));\n\n  return [\n    cssRow(\n      cssRowHeightLabel(t(\"Max height\"), infoTooltip(\"rowHeight\"), { for: \"row-height-max-input\" }),\n      cssNumericSpinner(rowHeightObs,\n        {\n          minValue: 0,\n          maxValue: 100,\n          save: setRowHeight,\n          inputArgs: [{ placeholder: \"auto\", id: \"row-height-max-input\" }, dom.style(\"width\", \"5em\")],\n        },\n        testId(\"row-height-max\"),\n      ),\n    ),\n    cssRowExpandable(\n      cssRowExpandable.cls(\"-expand\", use => Boolean(use(rowHeightObs))),\n      labeledSquareCheckbox(\n        uniformRows,\n        t(\"Expand all rows to this height\"),\n        testId(\"row-height-expand\"),\n        dom.boolAttr(\"disabled\", use => !use(rowHeightObs)),\n      ),\n    ),\n  ];\n}\n\n/**\n * Given row-heights configuration, applies it to a GridView section.\n */\nexport function applyRowHeightLimit(section: ViewSectionRec): DomElementArg {\n  return [\n    // We want dom.style('--row-height-lines', section.rowHeight), but it doesn't work for \"custom\n    // variable\" properties, so we do it manually. TODO: fix grainjs to support this.\n    elem => subscribeElem(elem, section.rowHeight,\n      val => elem.style.setProperty(\"--row-height-lines\", String(val))),\n    dom.cls(\"row_height_set\", use => Boolean(use(section.rowHeight) > 0)),\n    dom.cls(\"row_height_uniform\", section.rowHeightUniform),\n  ];\n}\n\nconst cssRowHeightTextBase = `\n  flex: 1 0 auto;\n  display: inline-flex;\n  gap: 8px;\n  margin-right: 16px;\n`;\nconst cssRowHeightText = styled(\"span\", cssRowHeightTextBase);\n\nconst cssRowHeightLabel = styled(\"label\", cssRowHeightTextBase);\n\nconst cssRowExpandable = styled(cssRow, `\n  transition: max-height 0.2s;\n  max-height: 0;\n  overflow: hidden;\n  &-expand {\n    /*\n     * fit-content doesn't work with transitions on height.\n     * In practice, height shouldn't exceed 32px, even when text wraps to a\n     * second line.\n     */\n    max-height: 32px;\n    overflow: visible;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/ShareMenu.ts",
    "content": "import { hooks } from \"app/client/Hooks\";\nimport { loadUserManager } from \"app/client/lib/imports\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { getLoginOrSignupUrl } from \"app/client/lib/urlUtils\";\nimport { AppModel, reportError } from \"app/client/models/AppModel\";\nimport { DocInfo, DocPageModel } from \"app/client/models/DocPageModel\";\nimport { reportWarning } from \"app/client/models/errors\";\nimport { docUrl, urlState } from \"app/client/models/gristUrlState\";\nimport {\n  downloadAttachmentsModal,\n  downloadDocModal,\n  makeCopy,\n  replaceTrunkWithFork,\n} from \"app/client/ui/MakeCopyMenu\";\nimport { sendToDrive } from \"app/client/ui/sendToDrive\";\nimport { hoverTooltip, withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { cssHoverCircle, cssTopBarBtn } from \"app/client/ui/TopBarCss\";\nimport { primaryButton } from \"app/client/ui2018/buttons\";\nimport { mediaXSmall, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport {\n  menu,\n  menuDivider,\n  menuIcon,\n  menuItem,\n  menuItemLink,\n  menuItemSubmenu,\n  menuText,\n} from \"app/client/ui2018/menus\";\nimport { buildUrlId, isFeatureEnabled, parseUrlId } from \"app/common/gristUrls\";\nimport * as roles from \"app/common/roles\";\nimport { Document } from \"app/common/UserAPI\";\n\nimport { dom, DomContents, styled } from \"grainjs\";\nimport { cssMenuItem, MenuCreateFunc } from \"popweasel\";\n\nconst t = makeT(\"ShareMenu\");\n\nexport function buildOriginalUrlId(urlId: string, isSnapshot: boolean): string {\n  const parts = parseUrlId(urlId);\n  return isSnapshot ? buildUrlId({ ...parts, snapshotId: undefined }) : parts.trunkId;\n}\n\n/**\n * Builds the content of the export menu. The menu button and contents render differently for\n * different modes (normal, pre-fork, fork, snapshot).\n */\nexport function buildShareMenuButton(pageModel: DocPageModel): DomContents {\n  // The menu needs pageModel.currentDoc to render the button. It further needs pageModel.gristDoc\n  // to render its contents, but we handle by merely skipping such content if gristDoc is not yet\n  // available (a user quick enough to open the menu in this state would have to re-open it).\n  return dom.maybe(pageModel.currentDoc, (doc) => {\n    const isProposable = Boolean(doc.options?.proposedChanges?.acceptProposals);\n    const saveCopy = () => handleSaveCopy({ pageModel, doc, modalTitle: t(\"Save Document\") });\n    if (doc.isSnapshot) {\n      const backToCurrent = () => urlState().pushUrl({ doc: buildOriginalUrlId(doc.id, true) });\n      return shareButton(t(\"Back to current\"), () => [\n        menuManageUsers(doc, pageModel),\n        menuSaveCopy({ pageModel, doc, saveActionTitle: t(\"Save copy\") }),\n        menuOriginal(doc, pageModel, { isSnapshot: true }),\n        menuExports(doc, pageModel),\n      ], { buttonAction: backToCurrent });\n    } else if (doc.isTutorialFork) {\n      return shareButton(t(\"Save copy\"), () => [\n        menuSaveCopy({ pageModel, doc, saveActionTitle: t(\"Save copy\") }),\n        menuOriginal(doc, pageModel, { isTutorialFork: true }),\n        menuExports(doc, pageModel),\n      ], { buttonAction: saveCopy });\n    } else if ((doc.isPreFork || doc.isBareFork) && !isProposable) {\n      // A new unsaved document, or a fiddle, or a public example.\n      const saveActionTitle =\n        doc.isBareFork ? t(\"Save Document\") :\n          isProposable ? t(\"Suggest changes\") : t(\"Save copy\");\n      return shareButton(saveActionTitle, () => [\n        menuManageUsers(doc, pageModel),\n        menuSaveCopy({ pageModel, doc, saveActionTitle }),\n        menuExports(doc, pageModel),\n      ], { buttonAction: saveCopy });\n    } else if (doc.isFork) {\n      if (isProposable) {\n        return shareButton([\n          t(\"Suggest changes\"),\n          dom.maybe(pageModel.proposalNewChangesCount,\n            changes => cssChangeCount(` (${changes})`)),\n        ], () => [\n          menuManageUsers(doc, pageModel),\n          menuSaveCopy({ pageModel, doc, saveActionTitle: t(\"Save copy\") }),\n          menuOriginal(doc, pageModel),\n          menuExports(doc, pageModel),\n        ], { buttonAction: async () => {\n          await urlState().pushUrl({\n            docPage: \"suggestions\",\n          });\n        } });\n      }\n      // For forks, the main actions are \"Replace Original\" and \"Save copy\". When \"Replace\n      // Original\" is unavailable (for samples, forks of public docs, etc), we'll consider \"Save\n      // Copy\" primary and keep it as an action button on top. Otherwise, show a tag without a\n      // default action; click opens the menu where the user can choose.\n      if (!roles.canEdit(doc.trunkAccess || null)) {\n        return shareButton(t(\"Save copy\"), () => [\n          menuManageUsers(doc, pageModel),\n          menuSaveCopy({ pageModel, doc, saveActionTitle: t(\"Save copy\") }),\n          menuOriginal(doc, pageModel),\n          menuExports(doc, pageModel),\n        ], { buttonAction: saveCopy });\n      } else {\n        return shareButton(t(\"Unsaved\"), () => [\n          menuManageUsers(doc, pageModel),\n          menuSaveCopy({ pageModel, doc, saveActionTitle: t(\"Save copy\") }),\n          menuOriginal(doc, pageModel),\n          menuExports(doc, pageModel),\n        ]);\n      }\n    } else {\n      return shareButton(null, () => [\n        menuManageUsers(doc, pageModel),\n        menuSaveCopy({ pageModel, doc, saveActionTitle: t(\"Duplicate document\") }),\n        menuWorkOnCopy(pageModel, { suggestChanges: isProposable }),\n        menuExports(doc, pageModel),\n      ]);\n    }\n  });\n}\n\n/**\n * Render the share button, possibly as a text+icon pair when buttonText is not null. The text\n * portion can be an independent action button (when buttonAction is given), or simply a more\n * visible extension of the icon that opens the menu.\n */\nfunction shareButton(buttonText: DomContents | null, menuCreateFunc: MenuCreateFunc,\n  options: { buttonAction?: () => void } = {},\n) {\n  if (!buttonText) {\n    // Regular circular button that opens a menu.\n    return cssHoverCircle({ style: `margin: 5px;` },\n      cssTopBarBtn(\"Share\", dom.cls(\"tour-share-icon\")),\n      menu(menuCreateFunc, { placement: \"bottom-end\" }),\n      hoverTooltip(t(\"Share\"), { key: \"topBarBtnTooltip\" }),\n      testId(\"tb-share\"),\n    );\n  } else if (options.buttonAction) {\n    // Split button: the left text part calls `buttonAction`, and the circular icon opens menu.\n    return cssShareButton(\n      cssShareAction(buttonText,\n        dom.on(\"click\", options.buttonAction),\n        testId(\"tb-share-action\"),\n      ),\n      cssShareCircle(\n        cssShareIcon(\"Share\"),\n        menu(menuCreateFunc, { placement: \"bottom-end\" }),\n        hoverTooltip(t(\"Share\"), { key: \"topBarBtnTooltip\" }),\n        testId(\"tb-share\"),\n      ),\n    );\n  } else {\n    // Combined button: the left text part and circular icon open the menu as a single button.\n    return cssShareButton(\n      cssShareButton.cls(\"-combined\"),\n      cssShareAction(buttonText),\n      cssShareCircle(\n        cssShareIcon(\"Share\"),\n      ),\n      menu(menuCreateFunc, { placement: \"bottom-end\" }),\n      hoverTooltip(t(\"Share\"), { key: \"topBarBtnTooltip\" }),\n      testId(\"tb-share\"),\n    );\n  }\n}\n\nasync function handleSaveCopy(options: {\n  pageModel: DocPageModel,\n  doc: Document,\n  modalTitle: string,\n}) {\n  const { pageModel } = options;\n  const { appModel } = pageModel;\n  if (!appModel.currentValidUser) {\n    pageModel.clearUnsavedChanges();\n    window.location.href = getLoginOrSignupUrl({ srcDocId: urlState().state.get().doc });\n    return;\n  }\n\n  return makeCopy(options);\n}\n\n// Renders \"Manage Users\" menu item.\nfunction menuManageUsers(doc: DocInfo, pageModel: DocPageModel) {\n  return [\n    menuItem(() => manageUsers(doc, pageModel),\n      roles.canEditAccess(doc.access) ? t(\"Manage users\") : t(\"Access Details\"),\n      dom.cls(\"disabled\", doc.isFork),\n      testId(\"tb-share-option\"),\n    ),\n    menuDivider(),\n  ];\n}\n\ninterface MenuOriginalOptions {\n  /** Defaults to false. */\n  isSnapshot?: boolean;\n  /** Defaults to false. */\n  isTutorialFork?: boolean;\n}\n\n/**\n * Renders \"Return to Original\" and \"Replace Original\" menu items.\n *\n * When used with snapshots, we say \"Current Version\" in place of the word \"Original\".\n *\n * When used with tutorial forks, the \"Return to Original\" and \"Compare to Original\" menu\n * items are excluded. Note that it's still possible to return to the original by manually\n * setting the open mode in the URL to \"/m/default\" - if the menu item were to ever be included\n * again, it should likely be a shortcut to setting the open mode back to default.\n */\nfunction menuOriginal(doc: Document, pageModel: DocPageModel, options: MenuOriginalOptions = {}) {\n  const { isSnapshot = false, isTutorialFork = false } = options;\n  const termToUse = isSnapshot ? t(\"current version\") : t(\"original\");\n  const origUrlId = buildOriginalUrlId(doc.id, isSnapshot);\n  const originalUrl = urlState().makeUrl({ doc: origUrlId });\n\n  // When comparing forks, show changes from the original to the fork. When comparing a snapshot,\n  // show changes from the snapshot to the original, which seems more natural. The per-snapshot\n  // comparison links in DocHistory use the same order.\n  const [leftDocId, rightDocId] = isSnapshot ? [doc.id, origUrlId] : [origUrlId, doc.id];\n\n  // Preserve the current state in order to stay on the selected page. TODO: Should auto-switch to\n  // first page when the requested page is not in the document.\n  const compareHref = dom.attr(\"href\", use => urlState().makeUrl({\n    ...use(urlState().state), doc: rightDocId, params: { compare: leftDocId, compareEmphasis: \"local\" } }));\n\n  const compareUrlId = urlState().state.get().params?.compare;\n  const comparingSnapshots: boolean = isSnapshot && Boolean(compareUrlId && parseUrlId(compareUrlId).snapshotId);\n\n  function replaceOriginal() {\n    replaceTrunkWithFork(doc, pageModel, origUrlId).catch(reportError);\n  }\n  return [\n    isTutorialFork ? null : cssMenuSplitLink({ href: originalUrl },\n      cssMenuSplitLinkText(t(\"Return to {{termToUse}}\", { termToUse })),\n      cssMenuIconLink({ href: originalUrl, target: \"_blank\" },\n        cssMenuIcon(\"FieldLink\"),\n        testId(\"open-original\"),\n      ),\n      dom.on(\"click\", () => { pageModel.clearUnsavedChanges(); }),\n      testId(\"return-to-original\"),\n    ),\n    menuItem(replaceOriginal, t(\"Replace {{termToUse}}...\", { termToUse }),\n      // Disable if original is not writable, and also when comparing snapshots (since it's\n      // unclear which of the versions to use).\n      dom.cls(\"disabled\", !roles.canEdit(doc.trunkAccess || null) || comparingSnapshots),\n      testId(\"replace-original\"),\n    ),\n    isTutorialFork ?\n      null :\n      menuItemLink(compareHref, { target: \"_blank\" }, t(\"Compare to {{termToUse}}\", { termToUse }),\n        dom.on(\"click\", () => { pageModel.clearUnsavedChanges(); }),\n        testId(\"compare-original\"),\n      ),\n  ];\n}\n\n// Renders \"Save Copy...\" and \"Copy as Template...\" menu items. The name of the first action is\n// specified in saveActionTitle.\nfunction menuSaveCopy(options: {\n  pageModel: DocPageModel,\n  doc: Document,\n  saveActionTitle: string,\n}) {\n  const { pageModel, doc, saveActionTitle } = options;\n  const saveCopy = () => handleSaveCopy({ pageModel, doc, modalTitle: saveActionTitle });\n  return [\n    // TODO Disable these when user has no accessible destinations.\n    menuItem(saveCopy, `${saveActionTitle}...`, testId(\"save-copy\")),\n  ];\n}\n\n// Renders \"Work on a Copy\" menu item.\nfunction menuWorkOnCopy(pageModel: DocPageModel, options?: {\n  suggestChanges?: boolean\n}) {\n  const gristDoc = pageModel.gristDoc.get();\n  if (!gristDoc) { return null; }\n\n  const makeUnsavedCopy = async function() {\n    const { urlId } = await gristDoc.docComm.fork();\n    await urlState().pushUrl({ doc: urlId });\n  };\n\n  const label = options?.suggestChanges ? t(\"Suggest changes\") : t(\"Work on a copy\");\n  return [\n    menuItem(makeUnsavedCopy, label, testId(\"work-on-copy\")),\n    menuText(\n      withInfoTooltip(\n        t(\"Edit without affecting the original\"),\n        \"workOnACopy\",\n        { popupOptions: { attach: null } },\n      ),\n    ),\n  ];\n}\n\n/**\n * The part of the menu with \"Download\" and \"Export as...\" items.\n */\nfunction menuExports(doc: Document, pageModel: DocPageModel) {\n  const isElectron = window.isRunningUnderElectron;\n  const gristDoc = pageModel.gristDoc.get();\n  if (!gristDoc) { return null; }\n\n  const onClick = dom.on(\"click\", (e) => {\n    const currentPage = pageModel.gristDoc.get()?.activeViewId.get();\n    const notDataPage = typeof currentPage !== \"number\";\n    if (notDataPage) {\n      // Disable navigation.\n      e.preventDefault();\n      // Show warning.\n      setTimeout(() => reportWarning(\n        t(\"Exporting is only available from document pages. Please select a document page and try again.\"),\n        {\n          key: \"exporting-not-available\",\n        },\n      ));\n    }\n  });\n\n  // Note: This line adds the 'show in folder' option for electron and a download option for hosted.\n  return [\n    menuDivider(),\n    (isElectron ?\n      menuItem(() => gristDoc.app.comm.showItemInFolder(doc.name),\n        t(\"Show in folder\"), testId(\"tb-share-option\")) :\n      menuItem(() => downloadDocModal(doc, pageModel.appModel),\n        menuIcon(\"Download\"), t(\"Download document...\"), testId(\"tb-share-option\"))\n    ),\n    menuItem(\n      () => downloadAttachmentsModal(doc, pageModel),\n      menuIcon(\"Download\"),\n      t(\"Download attachments...\"),\n      testId(\"tb-share-option\"),\n    ),\n    menuItemSubmenu(\n      () => [\n        menuItemLink(\n          onClick,\n          hooks.maybeModifyLinkAttrs({ href: gristDoc.getCsvLink(), target: \"_blank\", download: \"\" }),\n          t(\"Comma Separated Values (.csv)\"),\n          testId(\"tb-share-option\"),\n        ),\n        menuItemLink(\n          onClick,\n          hooks.maybeModifyLinkAttrs({ href: gristDoc.getTsvLink(), target: \"_blank\", download: \"\" }),\n          t(\"Tab Separated Values (.tsv)\"), testId(\"tb-share-option\")),\n        menuItemLink(\n          onClick,\n          hooks.maybeModifyLinkAttrs({ href: gristDoc.getDsvLink(), target: \"_blank\", download: \"\" }),\n          t(\"DOO Separated Values (.dsv)\"), testId(\"tb-share-option\")),\n        menuItemLink(\n          onClick,\n          hooks.maybeModifyLinkAttrs({\n            href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(),\n            target: \"_blank\", download: \"\",\n          }), t(\"Microsoft Excel (.xlsx)\"), testId(\"tb-share-option\")),\n      ],\n      {},\n      menuIcon(\"Download\"),\n      t(\"Export as...\"),\n      testId(\"tb-share-option\"),\n    ),\n    (!isFeatureEnabled(\"sendToDrive\") ? null : menuItem(() => sendToDrive(doc, pageModel),\n      menuIcon(\"Download\"), t(\"Send to Google Drive\"), testId(\"tb-share-option\"))),\n  ];\n}\n\n/**\n * Opens the user-manager for the doc.\n */\nasync function manageUsers(doc: DocInfo, docPageModel: DocPageModel) {\n  const appModel: AppModel = docPageModel.appModel;\n  const api = appModel.api;\n  const user = appModel.currentValidUser;\n  (await loadUserManager()).showUserManagerModal(api, {\n    permissionData: api.getDocAccess(doc.id),\n    activeUser: user,\n    resourceType: \"document\",\n    resourceId: doc.id,\n    resource: doc,\n    docPageModel,\n    appModel: docPageModel.appModel,\n    linkToCopy: makeShareDocUrl(doc),\n    // On save, re-fetch the document info, to toggle the \"Public Access\" icon if it changed.\n    // Skip if personal, since personal cannot affect \"Public Access\", and the only\n    // change possible is to remove the user (which would make refreshCurrentDoc fail)\n    onSave: async personal => !personal && docPageModel.refreshCurrentDoc(doc),\n    reload: () => api.getDocAccess(doc.id),\n  });\n}\n\nexport function makeShareDocUrl(doc: Document) {\n  const url = new URL(urlState().makeUrl(docUrl(doc)));\n  url.searchParams.set(\"utm_id\", \"share-doc\");\n  return url.href;\n}\n\nconst cssShareButton = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  position: relative;\n  z-index: 0;\n  margin: 5px;\n  white-space: nowrap;\n\n  --share-btn-bg: ${theme.controlPrimaryBg};\n  &-combined:hover, &-combined.weasel-popup-open {\n    --share-btn-bg: ${theme.controlPrimaryHoverBg};\n  }\n`);\n\n// Hide this on very small screens, since it takes up a lot of space and its action is also\n// available in the associated menu.\nconst cssShareAction = styled(primaryButton, `\n  margin-right: -16px;\n  padding-right: 24px;\n  background-color: var(--share-btn-bg);\n  border-color:     var(--share-btn-bg);\n\n  @media ${mediaXSmall} {\n    & {\n      display: none !important;\n    }\n  }\n`);\n\nconst cssShareCircle = styled(cssHoverCircle, `\n  z-index: 1;\n  background-color: var(--share-btn-bg);\n  border: 1px solid ${theme.topHeaderBg};\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.controlPrimaryHoverBg};\n  }\n`);\n\nconst cssShareIcon = styled(cssTopBarBtn, `\n  background-color: ${theme.controlPrimaryFg};\n  height: 30px;\n  width: 30px;\n`);\n\nconst cssMenuSplitLink = styled(menuItemLink, `\n  padding: 0;\n  align-items: stretch;\n`);\n\nconst cssMenuSplitLinkText = styled(\"div\", `\n  flex: auto;\n  padding: var(--weaseljs-menu-item-padding, 8px 24px);\n  &:not(:hover) {\n    background-color: ${theme.menuBg};\n    color: ${theme.menuItemFg};\n  }\n`);\n\nconst cssMenuIconLink = styled(\"a\", `\n  display: block;\n  flex: none;\n  padding: 8px 24px;\n  --icon-color: ${theme.controlFg};\n\n  .${cssMenuItem.className}-sel > & {\n    --icon-color: ${theme.menuItemIconSelectedFg};\n  }\n\n  .${cssMenuItem.className}.disabled & {\n    --icon-color: ${theme.menuItemDisabledFg};\n  }\n`);\n\nconst cssMenuIcon = styled(icon, `\n  display: block;\n`);\n\nconst cssChangeCount = styled(\"span\", `\n  font-weight: bold;\n`);\n"
  },
  {
    "path": "app/client/ui/ShortcutKey.ts",
    "content": "import { theme } from \"app/client/ui2018/cssVars\";\n\nimport { styled } from \"grainjs\";\n\nexport const ShortcutKeyContent = styled(\"span\", `\n  font-style: normal;\n  font-family: inherit;\n  color: ${theme.shortcutKeyPrimaryFg};\n`);\n\nexport const ShortcutKeyContentStrong = styled(ShortcutKeyContent, `\n  font-weight: 700;\n`);\n\nexport const ShortcutKey = styled(\"div\", `\n  display: inline-block;\n  padding: 2px 5px;\n  border-radius: 4px;\n  margin: 0px 2px;\n  border: 1px solid ${theme.shortcutKeyBorder};\n  color: ${theme.shortcutKeyFg};\n  background-color: ${theme.shortcutKeyBg};\n  font-family: inherit;\n  font-style: normal;\n  white-space: nowrap;\n`);\n"
  },
  {
    "path": "app/client/ui/SiteSwitcher.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menuDivider, menuIcon, menuItem, menuItemLink, menuSubHeader } from \"app/client/ui2018/menus\";\nimport { getSingleOrg, isFeatureEnabled } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { getOrgName } from \"app/common/UserAPI\";\n\nimport { dom, makeTestId, styled } from \"grainjs\";\n\nconst t = makeT(\"SiteSwitcher\");\n\nconst testId = makeTestId(\"test-site-switcher-\");\n\n/**\n * Adds a menu divider and a site switcher, if there is need for one.\n */\nexport function maybeAddSiteSwitcherSection(appModel: AppModel) {\n  const orgs = appModel.topAppModel.orgs;\n  return dom.maybe(use => use(orgs).length > 0 && !getSingleOrg() && isFeatureEnabled(\"multiSite\"), () => [\n    menuDivider(),\n    buildSiteSwitcher(appModel),\n  ]);\n}\n\n/**\n * Builds a menu sub-section that displays a list of orgs/sites that the current\n * valid user has access to, with buttons to navigate to them.\n *\n * Used by AppHeader and AccountWidget.\n */\nexport function buildSiteSwitcher(appModel: AppModel) {\n  const orgs = appModel.topAppModel.orgs;\n\n  return [\n    menuSubHeader(t(\"Switch Sites\")),\n    dom.forEach(orgs, org =>\n      menuItemLink(urlState().setLinkUrl({ org: org.domain || undefined }),\n        cssOrgSelected.cls(\"\", appModel.currentOrg ? org.id === appModel.currentOrg.id : false),\n        getOrgName(org),\n        cssOrgCheckmark(\"Tick\", testId(\"org-tick\")),\n        testId(\"org\"),\n      ),\n    ),\n    dom.maybe(\n      () => isFeatureEnabled(\"createSite\") && (appModel.isInstallAdmin() || getGristConfig().canAnyoneCreateOrgs),\n      () => [\n        menuItem(\n          () => appModel.showNewSiteModal(),\n          menuIcon(\"Plus\"),\n          t(\"Create new team site\"),\n          testId(\"create-new-site\"),\n        ),\n      ],\n    ),\n  ];\n}\n\nconst cssOrgSelected = styled(\"div\", `\n  background-color: ${theme.siteSwitcherActiveBg};\n  color: ${theme.siteSwitcherActiveFg};\n`);\n\nconst cssOrgCheckmark = styled(icon, `\n  flex: none;\n  margin-left: 16px;\n  --icon-color: ${theme.siteSwitcherActiveFg};\n  display: none;\n  .${cssOrgSelected.className} > & {\n    display: block;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/SortConfig.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport koArray from \"app/client/lib/koArray\";\nimport * as kf from \"app/client/lib/koForm\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { addToSort, updatePositions } from \"app/client/lib/sortUtil\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { ObjObservable } from \"app/client/models/modelUtil\";\nimport { cssIcon, cssRow, cssSortFilterColumn } from \"app/client/ui/RightPanelStyles\";\nimport { dropdownWithSearch } from \"app/client/ui/searchDropdown\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { labeledLeftSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { cssDragger } from \"app/client/ui2018/draggableList\";\nimport { menu } from \"app/client/ui2018/menus\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { Sort } from \"app/common/SortSpec\";\n\nimport { Computed, Disposable, dom, makeTestId, MultiHolder, styled } from \"grainjs\";\nimport difference from \"lodash/difference\";\nimport isEqual from \"lodash/isEqual\";\nimport { cssMenuItem, IMenuOptions } from \"popweasel\";\n\ninterface SortableColumn {\n  label: string;\n  value: number;\n  icon: \"FieldColumn\";\n  type: string;\n}\n\nexport interface SortConfigOptions {\n  /** Options to pass to all menus created by `SortConfig`. */\n  menuOptions?: IMenuOptions;\n}\n\nconst testId = makeTestId(\"test-sort-config-\");\n\nconst t = makeT(\"SortConfig\");\n\n/**\n * Component that renders controls for managing sorting for a view section.\n *\n * Sorted columns are displayed in a vertical list of pill-shaped buttons. These\n * buttons can be clicked to toggle their sort direction, and can be clicked and\n * dragged to re-arrange their order. Additionally, there are buttons to the right\n * of each sorted column for removing them, and opening a menu with advanced sort\n * options.\n */\nexport class SortConfig extends Disposable {\n  // Computed array of sortable columns.\n  private _columns: Computed<SortableColumn[]> = Computed.create(this, (use) => {\n    // Columns is an observable holding an observable array - must call 'use' on it 2x.\n    const cols = use(use(use(this._section.table).columns).getObservable());\n    return cols.filter(col => !use(col.isHiddenCol)).map(col => ({\n      label: use(col.label),\n      value: col.getRowId(),\n      icon: \"FieldColumn\",\n      type: col.type(),\n    }));\n  });\n\n  // We only want to recreate rows, when the actual columns change.\n  private _colRefs = Computed.create(this, (use) => {\n    return use(this._section.activeSortSpec).map(col => Sort.getColRef(col));\n  });\n\n  private _sortRows = this.autoDispose(koArray(this._colRefs.get()));\n\n  private _changedColRefs = Computed.create(this, (use) => {\n    const changedSpecs = difference(\n      use(this._section.activeSortSpec),\n      Sort.parseSortColRefs(use(this._section.sortColRefs)),\n    );\n    return new Set(changedSpecs.map(spec => Sort.getColRef(spec)));\n  });\n\n  constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc, private _options: SortConfigOptions = {}) {\n    super();\n\n    this.autoDispose(this._colRefs.addListener((curr, prev) => {\n      if (!isEqual(curr, prev)) {\n        this._sortRows.assign(curr);\n      }\n    }));\n  }\n\n  public buildDom() {\n    return dom(\"div\",\n      // Sort rows.\n      kf.draggableList(this._sortRows, (colRef: number) => this._createRow(colRef), {\n        reorder: (colRef: number, nextColRef: number | null) => this._reorder(colRef, nextColRef),\n        removeButton: false,\n        drag_indicator: cssDragger,\n        itemClass: cssDragRow.className,\n      }),\n      // Add to sort btn & menu.\n      this._buildAddToSortButton(this._columns),\n      this._buildUpdateDataButton(),\n      testId(\"container\"),\n    );\n  }\n\n  private _createRow(colRef: number) {\n    return this._buildSortRow(colRef, this._section.activeSortSpec, this._columns);\n  }\n\n  /**\n   * Builds a single row of the sort dom.\n   * Takes the colRef, current sortSpec and array of column select options to show\n   * in the column select dropdown.\n   */\n  private _buildSortRow(\n    colRef: number,\n    sortSpec: ObjObservable<Sort.SortSpec>,\n    columns: Computed<SortableColumn[]>,\n  ) {\n    const holder = new MultiHolder();\n    const { menuOptions } = this._options;\n\n    const col           = Computed.create(holder, () => colRef);\n    const details       = Computed.create(holder, use => Sort.specToDetails(Sort.findCol(use(sortSpec), colRef)!));\n    const hasSpecs      = Computed.create(holder, details, (_, specDetails) => Sort.hasOptions(specDetails));\n    const isAscending   = Computed.create(holder, details, (_, specDetails) => specDetails.direction === Sort.ASC);\n\n    col.onWrite((newRef) => {\n      let specs = sortSpec.peek();\n      const colSpec = Sort.findCol(specs, colRef);\n      const newSpec = Sort.findCol(specs, newRef);\n      if (newSpec) {\n        // this column is already there so only swap order\n        specs = Sort.swap(specs, colRef, newRef);\n        // but keep the directions\n        specs = Sort.setSortDirection(specs, colRef, Sort.direction(newSpec));\n        specs = Sort.setSortDirection(specs, newRef, Sort.direction(colSpec!));\n      } else {\n        specs = Sort.replace(specs, colRef, Sort.createColSpec(newRef, Sort.direction(colSpec!)));\n      }\n      this._saveSort(specs);\n    });\n\n    const computedFlag = (\n      flag: keyof Sort.ColSpecDetails,\n      allowedTypes: string[] | null,\n      label: string,\n    ) => {\n      const computed = Computed.create(holder, details, (_, d) => d[flag] || false);\n      computed.onWrite((value) => {\n        const specs = sortSpec.peek();\n        // Get existing details\n        const specDetails = Sort.specToDetails(Sort.findCol(specs, colRef)!) as any;\n        // Update flags\n        specDetails[flag] = value;\n        // Replace the colSpec at the index\n        this._saveSort(Sort.replace(specs, Sort.getColRef(colRef), specDetails));\n      });\n      return { computed, allowedTypes, flag, label };\n    };\n    const orderByChoice = computedFlag(\"orderByChoice\", [\"Choice\"], t(\"Use choice position\"));\n    const naturalSort   = computedFlag(\"naturalSort\", [\"Text\"], t(\"Natural sort\"));\n    const emptyLast     = computedFlag(\"emptyLast\", null, t(\"Empty values last\"));\n    const flags = [orderByChoice, emptyLast, naturalSort];\n\n    const column = columns.get().find(c => c.value === Sort.getColRef(colRef));\n\n    return cssSortRow(\n      dom.autoDispose(holder),\n      cssSortFilterColumn(\n        dom.attr(\"aria-label\", (use) => {\n          const ascending = use(isAscending);\n          return [\n            t(\"{{- columnName }} column\", { columnName: column!.label }),\n            ascending ?\n              t(\"Sort in descending order (current: ascending)\") :\n              t(\"Sort in ascending order (current: descending)\"),\n          ].join(\" - \");\n        }),\n        dom.domComputed(isAscending, ascending =>\n          cssSortIcon(\n            \"Sort\",\n            cssSortIcon.cls(\"-accent\", use => use(this._changedColRefs).has(column!.value)),\n            dom.style(\"transform\", ascending ? \"scaleY(-1)\" : \"none\"),\n            testId(\"order\"),\n            testId(ascending ? \"sort-order-asc\" : \"sort-order-desc\"),\n          ),\n        ),\n        cssLabel(column!.label),\n        dom.on(\"click\", () => {\n          this._saveSort(Sort.flipSort(sortSpec.peek(), colRef));\n        }),\n        testId(\"column\"),\n      ),\n      cssMenu(\n        { \"aria-label\": t(\"Sort options - {{- columnName }} column\", { columnName: column!.label }) },\n        cssBigIconWrapper(\n          cssIcon(\"Dots\", dom.cls(cssBgAccent.className, hasSpecs)),\n          testId(\"options-icon\"),\n        ),\n        menu(_ctl => flags.map(({ computed, allowedTypes, flag, label }) => {\n          // when allowedTypes is null, flag can be used for every column\n          const enabled = !allowedTypes || allowedTypes.includes(column!.type);\n          return cssMenuItem(\n            labeledLeftSquareCheckbox(\n              computed as any,\n              label,\n              dom.prop(\"disabled\", !enabled),\n            ),\n            dom.cls(cssOptionMenuItem.className),\n            dom.cls(\"disabled\", !enabled),\n            testId(\"option\"),\n            testId(`option-${flag}`),\n          );\n        },\n        ), menuOptions),\n      ),\n      cssSortIconBtn(\n        { \"aria-label\": t(\"Remove sort setting - {{- columnName }} column\", { columnName: column!.label }) },\n        cssIcon(\"Remove\"),\n        dom.on(\"click\", () => {\n          const specs = sortSpec.peek();\n          if (Sort.findCol(specs, colRef)) {\n            this._saveSort(Sort.removeCol(specs, colRef));\n          }\n        }),\n        testId(\"remove\"),\n      ),\n      testId(\"row\"),\n    );\n  }\n\n  private _buildAddToSortButton(columns: Computed<SortableColumn[]>) {\n    const available = Computed.create(null, (use) => {\n      const currentSection = this._section;\n      const currentSortSpec = use(currentSection.activeSortSpec);\n      const specRowIds = new Set(currentSortSpec.map(_sortRef => Sort.getColRef(_sortRef)));\n      return use(columns).filter(_col => !specRowIds.has(_col.value));\n    });\n    const { menuOptions } = this._options;\n    return cssButtonRow(\n      dom.autoDispose(available),\n      dom.domComputed((use) => {\n        const cols = use(available);\n        return textButton(\n          t(\"Add column\"),\n          dropdownWithSearch({\n            popupOptions: menuOptions,\n            options: () => cols.map(col => ({ label: col.label, value: col })),\n            action: col => addToSort(this._section.activeSortSpec, col.value, 1),\n            placeholder: t(\"Search Columns\"),\n          }),\n          dom.on(\"click\", (ev) => { ev.stopPropagation(); }),\n          testId(\"add\"),\n        );\n      }),\n      dom.hide(use => !use(available).length),\n    );\n  }\n\n  private _buildUpdateDataButton() {\n    return dom.maybe(this._section.isSorted, () =>\n      cssButtonRow(\n        textButton(t(\"Update data\"),\n          dom.on(\"click\", () => updatePositions(this._gristDoc, this._section)),\n          testId(\"update\"),\n          dom.show(use => (\n            use(use(this._section.table).supportsManualSort) &&\n            !use(this._gristDoc.isReadonly)\n          )),\n        ),\n      ),\n    );\n  }\n\n  private _reorder(colRef: number, nextColRef: number | null) {\n    const activeSortSpec = this._section.activeSortSpec.peek();\n    const colSpec = Sort.findCol(activeSortSpec, colRef);\n    if (colSpec === undefined) {\n      throw new Error(`Col ${colRef} not found in active sort spec`);\n    }\n\n    const newSpec = Sort.reorderSortRefs(this._section.activeSortSpec.peek(), colSpec, nextColRef);\n    this._saveSort(newSpec);\n  }\n\n  private _saveSort(sortSpec: Sort.SortSpec) {\n    this._section.activeSortSpec(sortSpec);\n  }\n}\n\nconst cssDragRow = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  margin: 0 16px 0px 0px;\n  & > .kf_draggable_content {\n    margin: 4px 0;\n    flex: 1 1 0px;\n    min-width: 0px;\n  }\n`);\n\nconst cssLabel = styled(\"div\", `\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  overflow: hidden;\n  flex-grow: 1;\n`);\n\nconst cssSortRow = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  width: 100%;\n`);\n\nconst cssSortIconBtn = styled(unstyledButton, `\n  flex: none;\n  margin: 0 6px;\n  cursor: pointer;\n  & .${cssIcon.className} {\n    background-color: ${theme.controlSecondaryFg};\n  }\n  &:hover .${cssIcon.className}{\n    background-color: ${theme.controlSecondaryHoverFg};\n  }\n`);\n\nconst cssSortIcon = styled(cssIcon, `\n  flex: none;\n  margin: 0px 6px 0px 0px;\n  background-color: ${theme.controlSecondaryFg};\n\n  &-accent {\n    background-color: ${theme.accentIcon};\n  }\n`);\n\nconst cssBigIconWrapper = styled(\"div\", `\n  padding: 3px;\n  border-radius: 3px;\n  cursor: pointer;\n  user-select: none;\n`);\n\nconst cssBgAccent = styled(`div`, `\n  background: ${theme.accentIcon}\n`);\n\nconst cssMenu = styled(unstyledButton, `\n  display: inline-flex;\n  cursor: pointer;\n  border-radius: 3px;\n  border: 1px solid transparent;\n  margin-left: 6px;\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssOptionMenuItem = styled(\"div\", `\n  &:hover {\n    background-color: ${theme.hover};\n  }\n  & label {\n    flex: 1;\n    cursor: pointer;\n  }\n  &.disabled * {\n    color: ${theme.menuItemDisabledFg} important;\n    cursor: not-allowed;\n  }\n`);\n\nconst cssButtonRow = styled(cssRow, `\n  margin-top: 4px;\n`);\n"
  },
  {
    "path": "app/client/ui/SortFilterConfig.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { FilterConfig } from \"app/client/ui/FilterConfig\";\nimport { cssLabel, cssSaveButtonsRow } from \"app/client/ui/RightPanelStyles\";\nimport { SortConfig } from \"app/client/ui/SortConfig\";\nimport { basicButton, primaryButton } from \"app/client/ui2018/buttons\";\n\nimport { Computed, Disposable, dom, makeTestId, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-sort-filter-config-\");\n\nconst t = makeT(\"SortFilterConfig\");\n\nexport class SortFilterConfig extends Disposable {\n  private _docModel = this._gristDoc.docModel;\n  private _isReadonly = this._gristDoc.isReadonly;\n\n  private _hasChanges: Computed<boolean> = Computed.create(this, use => (\n    use(this._section.filterSpecChanged) || !use(this._section.activeSortJson.isSaved)\n  ));\n\n  constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {\n    super();\n  }\n\n  public buildDom() {\n    return [\n      dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"sortfilterconfig-sort-label\" },\n        cssLabel(t(\"Sort\"), { id: \"sortfilterconfig-sort-label\" }),\n        dom.create(SortConfig, this._section, this._gristDoc, {\n          menuOptions: { attach: \"body\", allowNothingSelected: true },\n        }),\n      ),\n      dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"sortfilterconfig-filter-label\" },\n        cssLabel(t(\"Filter\"), { id: \"sortfilterconfig-filter-label\" }),\n        dom.create(FilterConfig, this._section, {\n          menuOptions: { attach: \"body\" },\n        }),\n      ),\n      dom.maybe(this._hasChanges, () => [\n        cssSaveButtonsRow(\n          cssSaveButton(t(\"Save\"),\n            dom.on(\"click\", () => this._save()),\n            dom.boolAttr(\"disabled\", this._isReadonly),\n            testId(\"save\"),\n          ),\n          basicButton(t(\"Revert\"),\n            dom.on(\"click\", () => this._revert()),\n            testId(\"revert\"),\n          ),\n          testId(\"save-btns\"),\n        ),\n      ]),\n    ];\n  }\n\n  private async _save() {\n    await this._docModel.docData.bundleActions(t(\"Update Sort & Filter settings\"), () => Promise.all([\n      this._section.activeSortJson.save(),\n      this._section.saveFilters(),\n    ]));\n  }\n\n  private _revert() {\n    this._section.activeSortJson.revert();\n    this._section.revertFilters();\n  }\n}\n\nconst cssSaveButton = styled(primaryButton, `\n  margin-right: 8px;\n`);\n"
  },
  {
    "path": "app/client/ui/SupportGristButton.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { tokenFieldStyles } from \"app/client/lib/TokenField\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { TelemetryModel, TelemetryModelImpl } from \"app/client/models/TelemetryModel\";\nimport { basicButton, basicButtonLink, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { colors, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { modal } from \"app/client/ui2018/modals\";\nimport { commonUrls, isFeatureEnabled } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Computed, Disposable, dom, DomContents, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"SupportGristNudge\");\n\n/**\n * Button that nudges users to support Grist by opting in to telemetry or sponsoring on Github.\n *\n * For installation admins, this includes a modal with a nudge which collapses into a \"Support\n * Grist\" button in the top bar. When that's not applicable, it is only a \"Support Grist\" button\n * that links to the Github sponsorship page.\n *\n * Users can dismiss this button.\n */\nexport class SupportGristButton extends Disposable {\n  private readonly _showButton: Computed<null | \"link\" | \"expand\">;\n  private readonly _telemetryModel: TelemetryModel = TelemetryModelImpl.create(this, this._appModel);\n\n  constructor(private _appModel: AppModel) {\n    super();\n    const { deploymentType, telemetry } = getGristConfig();\n    const isEnabled = (deploymentType === \"core\") && isFeatureEnabled(\"supportGrist\");\n    const isAdmin = _appModel.isInstallAdmin();\n    const isTelemetryOn = (telemetry && telemetry.telemetryLevel !== \"off\");\n    const isAdminNudgeApplicable = isAdmin && !isTelemetryOn;\n\n    this._showButton = Computed.create(this, (use) => {\n      if (!isEnabled || use(_appModel.dismissedPopups).includes(\"supportGrist\")) {\n        return null;\n      }\n\n      return isAdminNudgeApplicable ? \"expand\" : \"link\";\n    });\n  }\n\n  public buildDom(): DomContents {\n    return dom.domComputed(this._showButton, (which) => {\n      if (!which) { return null; }\n      const elemType = (which === \"link\") ? basicButtonLink : basicButton;\n      return cssContributeButton(\n        elemType(cssHeartIcon(\"💛 \"), t(\"Support Grist\"),\n          (which === \"link\" ?\n            { href: commonUrls.githubSponsorGristLabs, target: \"_blank\" } :\n            dom.on(\"click\", () => this._buildNudgeModal())\n          ),\n\n          cssContributeButtonCloseButton(\n            icon(\"CrossSmall\"),\n            dom.on(\"click\", (ev) => {\n              ev.stopPropagation();\n              ev.preventDefault();\n              this._markDismissed();\n            }),\n            testId(\"support-grist-button-dismiss\"),\n          ),\n          testId(\"support-grist-button\"),\n        ),\n      );\n    });\n  }\n\n  private _buildNudgeModal() {\n    return modal((ctl, owner) => {\n      const currentStep = Observable.create<\"opt-in\" | \"opted-in\">(owner, \"opt-in\");\n\n      return [\n        cssModal.cls(\"\"),\n        cssCloseButton(\n          icon(\"CrossBig\"),\n          dom.on(\"click\", () => ctl.close()),\n          testId(\"support-nudge-close\"),\n        ),\n        dom.domComputed(currentStep, (step) => {\n          return step === \"opt-in\" ?\n            this._buildOptInScreen(async () => {\n              await this._optInToTelemetry();\n              currentStep.set(\"opted-in\");\n            }) :\n            this._buildOptedInScreen(() => ctl.close());\n        }),\n      ];\n    }, {});\n  }\n\n  private _buildOptInScreen(onOptIn: () => Promise<void>) {\n    return [\n      cssLeftAlignedHeader(t(\"Support Grist\")),\n      cssParagraph(t(\n        \"Opt in to telemetry to help us understand how the product \\\nis used, so that we can prioritize future improvements.\",\n      )),\n      cssParagraph(\n        t(\n          \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never \\\ndocument contents. Opt out any time from the {{supportGristLink}} in the user menu.\",\n          {\n            helpCenterLink: helpCenterLink(),\n            supportGristLink: adminPanelLink(),\n          },\n        ),\n      ),\n      cssFullWidthButton(\n        t(\"Opt in to Telemetry\"),\n        dom.on(\"click\", () => onOptIn()),\n        testId(\"support-nudge-opt-in\"),\n      ),\n    ];\n  }\n\n  private _buildOptedInScreen(onClose: () => void) {\n    return [\n      cssCenteredFlex(cssSparks()),\n      cssCenterAlignedHeader(t(\"Opted In\")),\n      cssParagraph(\n        t(\n          \"Thank you! Your trust and support is greatly appreciated.\\\n Opt out any time from the {{link}} in the user menu.\",\n          { link: adminPanelLink() },\n        ),\n      ),\n      cssCenteredFlex(\n        cssPrimaryButton(\n          t(\"Close\"),\n          dom.on(\"click\", () => onClose()),\n          testId(\"support-nudge-close-button\"),\n        ),\n      ),\n    ];\n  }\n\n  private _markDismissed() {\n    this._appModel.dismissPopup(\"supportGrist\", true);\n  }\n\n  private async _optInToTelemetry() {\n    await this._telemetryModel.updateTelemetryPrefs({ telemetryLevel: \"limited\" });\n    this._markDismissed();\n  }\n}\n\nfunction helpCenterLink() {\n  return cssLink(\n    t(\"Help Center\"),\n    { href: commonUrls.helpTelemetryLimited, target: \"_blank\" },\n  );\n}\n\nfunction adminPanelLink() {\n  return cssLink(\n    t(\"Admin Panel\"),\n    { href: urlState().makeUrl({ adminPanel: \"admin\" }), target: \"_blank\" },\n  );\n}\n\nconst cssCenteredFlex = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n`);\n\nconst cssContributeButton = styled(\"div\", ``);\n\nconst cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, `\n  margin-left: 4px;\n  vertical-align: bottom;\n  line-height: 1;\n  position: absolute;\n  top: -4px;\n  right: -8px;\n  border-radius: 16px;\n  background-color: ${colors.dark};\n  width: 18px;\n  height: 18px;\n  cursor: pointer;\n  z-index: 1;\n  display: none;\n  align-items: center;\n  justify-content: center;\n  --icon-color: ${colors.light};\n\n  .${cssContributeButton.className}:hover & {\n    display: flex;\n  }\n  &:hover {\n    --icon-color: ${colors.lightGreen};\n  }\n`);\n\nconst cssHeader = styled(\"div\", `\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: 600;\n  margin-bottom: 16px;\n`);\n\nconst cssLeftAlignedHeader = styled(cssHeader, `\n  text-align: left;\n`);\n\nconst cssCenterAlignedHeader = styled(cssHeader, `\n  text-align: center;\n`);\n\nconst cssParagraph = styled(\"div\", `\n  font-size: 13px;\n  line-height: 18px;\n  margin-bottom: 12px;\n`);\n\nconst cssPrimaryButton = styled(bigPrimaryButton, `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  margin-top: 32px;\n  text-align: center;\n`);\n\nconst cssFullWidthButton = styled(cssPrimaryButton, `\n  width: 100%;\n`);\n\nconst cssCloseButton = styled(\"div\", `\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  padding: 4px;\n  border-radius: 4px;\n  cursor: pointer;\n  --icon-color: ${theme.popupCloseButtonFg};\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssSparks = styled(\"div\", `\n  height: 48px;\n  width: 48px;\n  background-image: var(--icon-Sparks);\n  display: inline-block;\n  background-repeat: no-repeat;\n`);\n\n// This is just to avoid the emoji pushing the button to be taller.\nconst cssHeartIcon = styled(\"span\", `\n  line-height: 1;\n`);\n\nconst cssModal = styled(\"div\", `\n  position: relative;\n  width: 100%;\n  max-width: 400px;\n  min-width: 0px;\n`);\n"
  },
  {
    "path": "app/client/ui/SupportGristPage.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { TelemetryModel, TelemetryModelImpl } from \"app/client/models/TelemetryModel\";\nimport {\n  cssButtonIconAndText,\n  cssButtonText,\n  cssOptInButton,\n  cssOptInOutMessage,\n  cssOptOutButton,\n  cssParagraph,\n  cssSection,\n  cssSpinnerBox,\n  cssSponsorButton,\n} from \"app/client/ui/AdminTogglesCss\";\nimport { basicButtonLink } from \"app/client/ui2018/buttons\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { commonUrls } from \"app/common/gristUrls\";\nimport { TelemetryPrefsWithSources } from \"app/common/InstallAPI\";\n\nimport { Computed, Disposable, dom, makeTestId } from \"grainjs\";\n\nconst testId = makeTestId(\"test-support-grist-page-\");\n\nconst t = makeT(\"SupportGristPage\");\n\nexport class SupportGristPage extends Disposable {\n  private readonly _model: TelemetryModel = new TelemetryModelImpl(this._appModel);\n  private readonly _optInToTelemetry = Computed.create(this, this._model.prefs,\n    (_use, prefs) => {\n      if (!prefs) { return null; }\n\n      return prefs.telemetryLevel.value !== \"off\";\n    })\n    .onWrite(async (optIn) => {\n      const telemetryLevel = optIn ? \"limited\" : \"off\";\n      await this._model.updateTelemetryPrefs({ telemetryLevel });\n    });\n\n  constructor(private _appModel: AppModel) {\n    super();\n    this._model.fetchTelemetryPrefs().catch(reportError);\n  }\n\n  public buildTelemetrySection() {\n    return cssSection(\n      dom.domComputed(this._model.prefs, (prefs) => {\n        if (prefs === null) {\n          return cssSpinnerBox(loadingSpinner());\n        }\n\n        if (!this._appModel.isInstallAdmin()) {\n          // TODO: We are no longer serving this page to non-admin users, so this branch should no\n          // longer match, and this version perhaps should be removed.\n          if (prefs.telemetryLevel.value === \"limited\") {\n            return [\n              cssParagraph(t(\n                \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\",\n              )),\n            ];\n          } else {\n            return [\n              cssParagraph(t(\n                \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\",\n              )),\n            ];\n          }\n        } else {\n          return [\n            cssParagraph(t(\n              \"Support Grist by opting in to telemetry, which helps us understand how the product \\\nis used, so that we can prioritize future improvements.\",\n            )),\n            cssParagraph(\n              t(\"We only collect usage statistics, as detailed in our {{link}}, never document contents.\", {\n                link: telemetryHelpCenterLink(),\n              }),\n            ),\n            cssParagraph(t(\"You can opt out of telemetry at any time from this page.\")),\n            this._buildTelemetrySectionButtons(prefs),\n          ];\n        }\n      }),\n      testId(\"telemetry-section\"),\n    );\n  }\n\n  public getTelemetryOptInObservable() { return this._optInToTelemetry; }\n\n  public _buildTelemetrySectionButtons(prefs: TelemetryPrefsWithSources) {\n    const { telemetryLevel: { value, source } } = prefs;\n    if (source === \"preferences\") {\n      return dom.domComputed(this._optInToTelemetry, (optedIn) => {\n        if (optedIn) {\n          return [\n            cssOptInOutMessage(\n              t(\"You have opted in to telemetry. Thank you!\"), \" 🙏\",\n              testId(\"telemetry-section-message\"),\n            ),\n            cssOptOutButton(t(\"Opt out of Telemetry\"),\n              dom.on(\"click\", () => this._optInToTelemetry.set(false)),\n            ),\n          ];\n        } else {\n          return [\n            cssOptInButton(t(\"Opt in to Telemetry\"),\n              dom.on(\"click\", () => this._optInToTelemetry.set(true)),\n            ),\n          ];\n        }\n      });\n    } else {\n      return cssOptInOutMessage(\n        value !== \"off\" ?\n          [t(\"You have opted in to telemetry. Thank you!\"), \" 🙏\"] :\n          t(\"You have opted out of telemetry.\"),\n        testId(\"telemetry-section-message\"),\n      );\n    }\n  }\n\n  public buildSponsorshipSection() {\n    return cssSection(\n      cssParagraph(\n        t(\n          \"Grist software is developed by Grist Labs, which offers free and paid \\\nhosted plans. We also make Grist code available under a standard free \\\nand open OSS license (Apache 2.0) on {{link}}.\",\n          { link: gristCoreLink() },\n        ),\n      ),\n      cssParagraph(\n        t(\n          \"You can support Grist open-source development by sponsoring \\\nus on our {{link}}.\",\n          { link: sponsorGristLink() },\n        ),\n      ),\n      cssParagraph(t(\n        \"We are a small and determined team. Your support matters a lot to us. \\\nIt also shows to others that there is a determined community behind this product.\",\n      )),\n      cssSponsorButton(\n        cssButtonIconAndText(icon(\"Heart\"), cssButtonText(t(\"Manage Sponsorship\"))),\n        { href: commonUrls.githubSponsorGristLabs, target: \"_blank\" },\n      ),\n      testId(\"sponsorship-section\"),\n    );\n  }\n\n  public buildSponsorshipSmallButton() {\n    return basicButtonLink(\"💛 \", t(\"Sponsor\"),\n      { href: commonUrls.githubSponsorGristLabs, target: \"_blank\" });\n  }\n}\n\nfunction telemetryHelpCenterLink() {\n  return cssLink(\n    t(\"Help Center\"),\n    { href: commonUrls.helpTelemetryLimited, target: \"_blank\" },\n  );\n}\n\nfunction sponsorGristLink() {\n  return cssLink(\n    t(\"GitHub Sponsors page\"),\n    { href: commonUrls.githubSponsorGristLabs, target: \"_blank\" },\n  );\n}\n\nfunction gristCoreLink() {\n  return cssLink(\n    t(\"GitHub\"),\n    { href: commonUrls.githubGristCore, target: \"_blank\" },\n  );\n}\n"
  },
  {
    "path": "app/client/ui/TemplateDocs.ts",
    "content": "import { docUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { HomeModel, ViewSettings } from \"app/client/models/HomeModel\";\nimport * as css from \"app/client/ui/DocMenuCss\";\nimport { buildPinnedDoc } from \"app/client/ui/PinnedDocs\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { Document, Workspace } from \"app/common/UserAPI\";\n\nimport { dom, makeTestId, styled } from \"grainjs\";\nimport sortBy from \"lodash/sortBy\";\n\nconst testId = makeTestId(\"test-dm-\");\n\n/**\n * Builds all `templateDocs` according to the specified `viewSettings`.\n */\nexport function buildTemplateDocs(home: HomeModel, templateDocs: Document[], viewSettings: ViewSettings) {\n  const { currentView, currentSort } = viewSettings;\n  return dom.domComputed(use => [use(currentView), use(currentSort)] as const, (opts) => {\n    const [view, sort] = opts;\n    // Template docs are sorted by name in HomeModel. We only re-sort if we want a different order.\n    let sortedDocs = templateDocs;\n    if (sort === \"date\") {\n      sortedDocs = sortBy(templateDocs, d => d.removedAt || d.updatedAt).reverse();\n    }\n    return cssTemplateDocs(dom.forEach(sortedDocs, d => buildTemplateDoc(home, d, d.workspace, view)));\n  });\n}\n\n/**\n * Build a single template doc according to `view`.\n *\n * If `view` is set to 'list', the template will be rendered\n * as a clickable row that includes a title and description.\n *\n * If `view` is set to 'icons', the template will be rendered\n * as a clickable tile that includes a title, image and description.\n */\nfunction buildTemplateDoc(home: HomeModel, doc: Document, workspace: Workspace, view: \"list\" | \"icons\") {\n  if (view === \"icons\") {\n    return buildPinnedDoc(home, doc, workspace, true);\n  } else {\n    return css.docRowWrapper(\n      cssDocRowLink(\n        urlState().setLinkUrl({ ...docUrl(doc), org: workspace.orgDomain }),\n        cssDocName(doc.name, testId(\"template-doc-title\")),\n        doc.options?.description ? cssDocRowDetails(doc.options.description, testId(\"template-doc-description\")) : null,\n      ),\n      testId(\"template-doc\"),\n    );\n  }\n}\n\nconst cssDocRowLink = styled(css.docRowLink, `\n  display: block;\n  height: unset;\n  line-height: 1.6;\n  padding: 8px 0;\n`);\n\nconst cssDocName = styled(css.docName, `\n  margin: 0 16px;\n`);\n\nconst cssDocRowDetails = styled(\"div\", `\n  margin: 0 16px;\n  line-height: 1.6;\n  color: ${theme.lightText};\n`);\n\nconst cssTemplateDocs = styled(\"div\", `\n  margin-bottom: 16px;\n`);\n"
  },
  {
    "path": "app/client/ui/ThemeConfig.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport * as css from \"app/client/ui/AccountPageCss\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { select } from \"app/client/ui2018/menus\";\nimport { prefersColorSchemeDarkObs } from \"app/client/ui2018/theme\";\nimport { ThemeName, themeNameAppearances } from \"app/common/ThemePrefs\";\n\nimport { Computed, Disposable, dom, makeTestId, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-theme-config-\");\nconst t = makeT(\"ThemeConfig\");\n\nexport class ThemeConfig extends Disposable {\n  private _themePrefs = this._appModel.themePrefs;\n\n  private _syncWithOS = Computed.create(this, this._themePrefs, (_use, prefs) => {\n    return prefs.syncWithOS;\n  }).onWrite(value => this._updateSyncWithOS(value));\n\n  private _themeName = Computed.create(this,\n    this._themePrefs,\n    this._syncWithOS,\n    prefersColorSchemeDarkObs(),\n    (_use, prefs, syncWithOS, prefersColorSchemeDark) => {\n      if (syncWithOS) {\n        return prefersColorSchemeDark ? \"GristDark\" : \"GristLight\";\n      } else {\n        // The user theme name is stored in both colors.light and colors.dark, just take one of them\n        // This is a bit weird but this rather contained weirdness is preferred to changing the user prefs schema.\n        return prefs.colors.light;\n      }\n    })\n    .onWrite((themeName) => {\n      this._updateTheme(themeName);\n    });\n\n  constructor(private _appModel: AppModel) {\n    super();\n  }\n\n  public buildDom() {\n    return dom(\"div\",\n      css.subHeader(t(\"Appearance \")),\n      css.dataRow(\n        cssAppearanceSelect(\n          select(\n            this._themeName,\n            [\n              { value: \"GristLight\", label: \"Light\" },\n              { value: \"GristDark\", label: \"Dark\" },\n              { value: \"HighContrastLight\", label: \"Light (High Contrast)\" },\n            ],\n            {\n              disabled: this._syncWithOS,\n              translateOptionLabels: true,\n            },\n          ),\n          testId(\"appearance\"),\n        ),\n      ),\n      css.dataRow(\n        labeledSquareCheckbox(\n          this._syncWithOS,\n          t(\"Switch appearance automatically to match system\"),\n          testId(\"sync-with-os\"),\n        ),\n      ),\n      testId(\"container\"),\n    );\n  }\n\n  private _updateTheme(themeName: ThemeName) {\n    this._themePrefs.set({\n      ...this._themePrefs.get(),\n      appearance: themeNameAppearances[themeName],\n      // Important note: the `colors` property is not actually used for its original purpose.\n      // It's currently our way to store the theme name in user prefs (without having to change the user prefs schema).\n      // This is why we just repeat the name in both `light` and `dark` properties.\n      colors: { light: themeName, dark: themeName },\n    });\n  }\n\n  private _updateSyncWithOS(syncWithOS: boolean) {\n    this._themePrefs.set({ ...this._themePrefs.get(), syncWithOS });\n  }\n}\n\nconst cssAppearanceSelect = styled(\"div\", `\n  width: 180px;\n`);\n"
  },
  {
    "path": "app/client/ui/TimingPage.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { ApiData, RawFormat, VirtualDoc } from \"app/client/components/VirtualDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { docListHeader } from \"app/client/ui/DocMenuCss\";\nimport { mediaSmall } from \"app/client/ui2018/cssVars\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { FormulaTimingInfo } from \"app/common/ActiveDocAPI\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport { not } from \"app/common/gutil\";\n\nimport { dom, makeTestId, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"TimingPage\");\nconst testId = makeTestId(\"test-timing-page-\");\n\nexport class TimingPage extends DisposableWithEvents {\n  private _data: Observable<FormulaTimingInfo[] | null> = Observable.create(this, null);\n  private _doc = VirtualDoc.create(this, this._gristDoc.appModel);\n\n  constructor(private _gristDoc: GristDoc) {\n    super();\n\n    this._doc.addTable({\n      name: \"Timing\",\n      columns: [\n        { label: t(\"Table ID\"), type: \"Text\", colId: \"tableId\" },\n        { label: t(\"Column ID\"), type: \"Text\", colId: \"colId\" },\n        { label: t(\"Total Time (s)\"), type: \"Numeric\", colId: \"sum\" },\n        { label: t(\"Number of Calls\"), type: \"Numeric\", colId: \"calls\" },\n        { label: t(\"Average Time (s)\"), type: \"Numeric\", colId: \"average\" },\n        { label: t(\"Max Time (s)\"), type: \"Numeric\", colId: \"max\" },\n      ],\n      data: new ApiData(() => this._data.get() || []),\n      format: new RawFormat(),\n      initialFocus: true,\n    });\n\n    if (this._gristDoc.isTimingOn.get() === false) {\n      // Just redirect back to the settings page.\n      this._openSettings();\n    } else {\n      this._start().catch((ex) => {\n        this._openSettings();\n        reportError(ex);\n      });\n    }\n  }\n\n  public buildDom() {\n    return cssContainer(\n      dom.maybe(this._data, () =>\n        dom(\"div\", { style: \"display: flex; justify-content: space-between; align-items: baseline\" },\n          cssHeader(t(\"Formula timer\")),\n        ),\n      ),\n      dom.maybe(this._data, () => {\n        return this._doc.buildDom();\n      }),\n      dom.maybe(not(this._data), () => cssLoaderScreen(\n        loadingSpinner(),\n        dom(\"div\", t(\"Loading timing data. Don't close this tab.\")),\n        testId(\"spinner\"),\n      )),\n    );\n  }\n\n  private _openSettings() {\n    urlState().pushUrl({ docPage: \"settings\" }).catch(reportError);\n  }\n\n  private async _start() {\n    const docApi = this._gristDoc.docPageModel.appModel.api.getDocAPI(this._gristDoc.docId());\n    // Get the data from the server (and wait for the engine to calculate everything if it hasn't already).\n    const data = await docApi.stopTiming();\n    if (this.isDisposed()) { return; }\n    this._data.set(data);\n  }\n}\n\nconst cssHeader = styled(docListHeader, `\n  margin-bottom: 12px;\n`);\n\nconst cssContainer = styled(\"div\", `\n  overflow-y: auto;\n  position: relative;\n  height: 100%;\n  padding: 32px 64px 24px 64px;\n\n  display: flex;\n  flex-direction: column;\n  @media ${mediaSmall} {\n    & {\n      padding: 32px 24px 24px 24px;\n    }\n  }\n  & .viewsection_content {\n    margin: 0px;\n    margin-left: 4px;\n  }\n`);\n\nconst cssLoaderScreen = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  flex: 1;\n`);\n"
  },
  {
    "path": "app/client/ui/ToggleEnterpriseWidget.ts",
    "content": "import { cssSmallLinkButton } from \"app/client/components/Forms/styles\";\nimport { copyToClipboard } from \"app/client/lib/clipboardUtils\";\nimport { makeTestId } from \"app/client/lib/domUtils\";\nimport { dateFmtFull } from \"app/client/lib/formatUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { markdown } from \"app/client/lib/markdown\";\nimport { Notifier } from \"app/client/models/NotifyModel\";\nimport { ToggleEnterpriseModel } from \"app/client/models/ToggleEnterpriseModel\";\nimport { cssOptInButton, cssParagraph, cssSection } from \"app/client/ui/AdminTogglesCss\";\nimport { hoverTooltip, showTransientTooltip } from \"app/client/ui/tooltips\";\nimport { bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { colors, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { ActivationState, commonUrls } from \"app/common/gristUrls\";\nimport { not } from \"app/common/gutil\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { BindableValue, Computed, Disposable, dom, input, MultiHolder, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"ToggleEnterpriseWidget\");\nconst testId = makeTestId(\"test-toggle-enterprise-\");\nconst TOOLTIP_KEY = \"copy-on-settings\";\n\ntype State = \"core\" | \"activated\" | \"trial\" | \"no-key\" | \"error\";\n\nexport class ToggleEnterpriseWidget extends Disposable {\n  private readonly _model = this.autoDispose(new ToggleEnterpriseModel(this._notifier));\n  /** If we are running Enterprise edition (even if not activated or valid) */\n  private readonly _isEnterpriseEdition = Computed.create(this, this._model.edition, (_use, edition) => {\n    return edition === \"enterprise\";\n  }).onWrite(async (enabled) => {\n    await this._model.updateEnterpriseToggle(enabled ? \"enterprise\" : \"core\");\n  });\n\n  private _activationKey = Observable.create(this, \"\");\n  private _activation = Observable.create<ActivationState | null>(this, null);\n\n  private _state = Computed.create<State | null>(this, (use) => {\n    const status = use(this._model.status);\n    if (!use(this._isEnterpriseEdition) || !status) {\n      return \"core\";\n    } else if (status.key) {\n      return \"activated\";\n    } else if (status.trial && status.trial.daysLeft > 0) {\n      return \"trial\";\n    } else if (use(this._activation)?.error) {\n      return \"error\";\n    } else {\n      return \"no-key\";\n    }\n  });\n\n  constructor(private _notifier: Notifier) {\n    super();\n    this._model.fetchEnterpriseToggle().catch(reportError);\n    const { activation } = getGristConfig();\n    this._activation.set(activation ?? null);\n  }\n\n  public getEnterpriseToggleObservable() {\n    return this._isEnterpriseEdition;\n  }\n\n  public buildEnterpriseSection() {\n    return cssSection(\n      testId(\"enterprise-content\", this._isEnterpriseEdition),\n      dom.domComputed(this._state, (state) => {\n        switch (state) {\n          case \"trial\":\n            return this._trialCopy();\n          case \"activated\":\n            return this._activatedCopy();\n          case \"no-key\":\n            return this._noKeyCopy();\n          case \"error\":\n            return this._errorCopy();\n          default:\n            return this._coreCopy();\n        }\n      }),\n      testId(\"enterprise-opt-in-section\"),\n    );\n  }\n\n  private _buildPasteYourKey(show: BindableValue<boolean> = Observable.create(this, true)) {\n    const redactedInstallationId = Computed.create(this, this._model.installationId, (use, id) => {\n      // Leave only 6 first letter, rest convert to *.\n      return id ? id.slice(0, 6) + \"*\".repeat(id.length - 6) : \"\";\n    });\n    return cssParagraph(\n      dom.autoDispose(redactedInstallationId),\n      cssTextLine(\n        dom(\"b\", t(`Activation key`)),\n        cssInstallationId(\n          dom(\"span\", t(\"Installation ID:\")),\n          dom.text(redactedInstallationId),\n          copyHandler(() => this._model.installationId.get()!, t(\"Installation ID copied to clipboard\")),\n          testId(\"installation-id\"),\n          cssCopyButton(\n            icon(\"Copy\"),\n          ),\n          hoverTooltip(t(\"Copy to clipboard\"), {\n            key: TOOLTIP_KEY,\n          }),\n        ),\n      ),\n      cssInput(\n        this._activationKey, { onInput: true }, { placeholder: t(\"Paste your activation key\") },\n        dom.onKeyPress({ Enter: this._activateButtonClicked.bind(this) }),\n        testId(\"key-input\"),\n        dom.boolAttr(\"disabled\", this._model.busy),\n      ),\n      bigPrimaryButton(\n        t(\"Activate\"),\n        dom.on(\"click\", this._activateButtonClicked.bind(this)),\n        dom.style(\"margin-top\", \"12px\"),\n        testId(\"activate-button\"),\n        dom.prop(\"disabled\", use => use(this._activationKey)?.trim().length === 0 || use(this._model.busy)),\n      ),\n      dom.style(\"display\", \"none\"),\n      dom.show(show),\n    );\n  }\n\n  private async _activateButtonClicked() {\n    await this._model.activateEnterprise(this._activationKey.get().trim());\n  }\n\n  private _trialCopy() {\n    return [\n      cssParagraph(\n        dom(\"b\", t(\"You are currently trialing Grist Enterprise.\")),\n      ),\n      cssParagraph(\n        markdown(t(`An activation key is used to run Grist Enterprise after a trial period\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\nnot need an activation key to run Grist Core.\n\nLearn more in our [Help Center]({{helpCenter}}).`, {\n          contactLink: commonUrls.contact,\n          helpCenter: commonUrls.helpEnterpriseOptIn,\n        })),\n      ),\n      this._buildPasteYourKey(),\n    ];\n  }\n\n  private _activatedCopy() {\n    const owner = new MultiHolder();\n\n    const expireAt = Computed.create(owner, (use) => {\n      const state = use(this._model.status);\n      if (!state?.key?.expirationDate) {\n        return null;\n      }\n      return dateFmtFull(state.key.expirationDate);\n    });\n\n    const expired = Computed.create(owner, (use) => {\n      const state = use(this._model.status);\n      return state?.key?.daysLeft !== undefined && state.key.daysLeft <= 0;\n    });\n\n    const graceDays = Computed.create(owner, (use) => {\n      const state = use(this._model.status);\n      return state?.grace?.daysLeft ?? 0;\n    });\n\n    const grace = Computed.create(owner, (use) => {\n      return Boolean(use(graceDays));\n    });\n\n    const graceText = Computed.create(owner, (use) => {\n      if (use(grace)) {\n        return t(\"Your instance will be in **read-only** mode in **{{days}}** day(s).\", { days: use(graceDays) });\n      }\n      return \"\";\n    });\n    const inputVisible = Observable.create(owner, expired.get());\n\n    const maxSeats = Computed.create(owner, (use) => {\n      const state = use(this._model.status);\n      return state?.features?.installationSeats ?? 0;\n    });\n\n    const currentSeats = Computed.create(owner, (use) => {\n      const state = use(this._model.status);\n      return state?.current?.installationSeats ?? 0;\n    });\n\n    const isLimited = Computed.create(owner, use => use(this._model.status)?.features?.installationSeats !== undefined);\n\n    const exceeded = Computed.create(owner, (use) => {\n      return use(isLimited) ? use(currentSeats) > use(maxSeats) : false;\n    });\n\n    return [\n      cssParagraph(\n        testId(\"key-info\"),\n        dom.autoDispose(owner),\n        cssRow(\n          cssLabel(t(\"Plan name\") + \":\"),\n          dom(\"div\", dom.text(\"Grist Enterprise\")),\n          testId(\"plan-name\"),\n        ),\n        dom.maybe(expireAt, date => [\n          cssRow(\n            cssLabel(t(\"Expiration date\") + \":\"),\n            dom(\"div\", dom.text(date), testId(\"expiration-date\")),\n          ),\n        ]),\n        dom.maybe(isLimited, () => [\n          cssRow(\n            cssLabel(t(\"Installation seats\") + \":\"),\n            cssFlexLine(testId(\"installation-seats\"), dom.domComputed(use => [\n              cssInline(markdown(`Limit: **${use(maxSeats)}**, Current: **${use(currentSeats)}**`)),\n              dom.domComputed(exceeded, valid => [\n                planStatusIcon(!valid ? \"Tick\" : \"CrossSmall\", planStatusIcon.cls(!valid ? \"-valid\" : \"-invalid\")),\n              ]),\n            ])),\n          ),\n        ]),\n        cssRow(\n          cssLabel(t(\"Activation key\") + \":\"),\n          dom.show(use => !use(inputVisible) || use(expired)),\n          cssRowWithEdit(\n            dom(\"span\",\n              dom(\"span\",\n                dom.text(use => use(this._model.status)?.keyPrefix ?? \"\"),\n                testId(\"key-prefix\"),\n              ),\n              \"**********************\",\n            ),\n            cssSmallLinkButton(\n              \"Update\", dom.on(\"click\", () => inputVisible.set(true)),\n              testId(\"update-key-button\"),\n              dom.show(use => !use(expired) && !use(grace)),\n            ),\n          ),\n        ),\n        dom.maybe(use => use(inputVisible) && !use(expired), () => [\n          this._buildPasteYourKey(),\n        ]),\n      ),\n      dom.maybe(expired, () => [\n        cssSpacer(),\n        dom.domComputed(use => [\n          cssParagraph(\n            dom(\"b\",\n              use(exceeded) ? t(\"Your activation key has expired due to exceeding limits.\") :\n                t(\"Your subscription expired on {{date}}.\", { date: use(expireAt) }),\n            ),\n            testId(\"expired-reason\"),\n          ),\n        ]),\n      ]),\n      dom.maybe(use => use(grace) || use(expired), () => [\n        cssSpacer(),\n        dom(\"div\",\n          testId(\"expired-info\"),\n          dom.domComputed(graceText, txt => cssParagraph(\n            markdown((txt ? txt + \" \" : \"\") + t(\n              `To continue using Grist Enterprise, you need to\n                  [contact us]({{signupLink}}) to get your activation key.`, {\n                signupLink: commonUrls.contact,\n              })),\n          )),\n        ),\n        cssSpacer(),\n        this._buildPasteYourKey(),\n      ]),\n    ];\n  }\n\n  private _coreCopy() {\n    return [\n      cssParagraph(\n        enterpriseNotEnabledCopy(),\n      ),\n      cssOptInButton(t(\"Enable Grist Enterprise\"),\n        dom.on(\"click\", () => this._isEnterpriseEdition.set(true)),\n      ),\n    ];\n  }\n\n  private _noKeyCopy() {\n    const trialExpired = Computed.create(this, (use) => {\n      const state = use(this._model.status);\n      return state?.trial?.expirationDate ? new Date(state.trial.expirationDate) : null;\n    });\n\n    const trialExpiredIso = Computed.create(this, (use) => {\n      const date = use(trialExpired);\n      return date ? date.toISOString() : \"\";\n    });\n\n    const trialExpiredLocal = Computed.create(this, (use) => {\n      const date = use(trialExpired);\n      return date ? dateFmtFull(date) : \"\";\n    });\n\n    return [\n      cssParagraph(\n        testId(\"not-active-key\"),\n        dom(\"b\", t(\"You do not have an active subscription.\")),\n      ),\n      dom.maybe(trialExpiredLocal, expireAt => [\n        cssParagraph(\n          markdown(t(\n            `Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.`, {\n              signupLink: commonUrls.plans,\n              expireAt,\n            })),\n        ),\n        dom(\"span\", dom.text(trialExpiredIso), { style: \"display: none;\" }, testId(\"trial-expiration-date\")),\n      ]),\n      dom.maybe(not(trialExpired), () => [\n        cssParagraph(\n          markdown(t(`An active subscription is required to continue using Grist Enterprise. You can\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\nactivation key below.`, {\n            signupLink: commonUrls.plans,\n          })),\n        ),\n      ]),\n      learnMoreLink(),\n      this._buildPasteYourKey(),\n    ];\n  }\n\n  private _errorCopy() {\n    return [\n      cssParagraph(\n        testId(\"error-message\"),\n        cssErrorText(dom.text(use => use(this._activation)?.error ?? \"\")),\n      ),\n      learnMoreLink(),\n      this._buildPasteYourKey(),\n    ];\n  }\n}\n\nfunction enterpriseNotEnabledCopy() {\n  return [\n    cssParagraph(\n      markdown(t(`An activation key is used to run Grist Enterprise after a trial period\n        of 30 days has expired. Get an activation key by [signing up for Grist\n        Enterprise]({{signupLink}}). You do not need an activation key to run\n        Grist Core.`, { signupLink: commonUrls.plans })),\n    ),\n    learnMoreLink(),\n  ];\n}\n\nfunction learnMoreLink() {\n  return cssParagraph(\n    markdown(t(`Learn more in our [Help Center]({{helpCenter}}).`, {\n      signupLink: commonUrls.plans,\n      helpCenter: commonUrls.helpEnterpriseOptIn,\n    })));\n}\n\nfunction copyHandler(value: () => string, confirmation: string) {\n  return dom.on(\"click\", async (e, d) => {\n    e.stopImmediatePropagation();\n    e.preventDefault();\n    showTransientTooltip(d as Element, confirmation, {\n      key: TOOLTIP_KEY,\n    });\n    await copyToClipboard(value());\n  });\n}\n\nexport const cssInput = styled(input, `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  font-size: ${vars.mediumFontSize};\n  height: 42px;\n  line-height: 16px;\n  width: 100%;\n  padding: 13px;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  outline: none;\n\n  &-invalid {\n    color: ${theme.inputInvalid};\n  }\n\n  &[type=number] {\n    -moz-appearance: textfield;\n  }\n  &[type=number]::-webkit-inner-spin-button,\n  &[type=number]::-webkit-outer-spin-button {\n    -webkit-appearance: none;\n    margin: 0;\n  }\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\nconst cssRow = styled(\"div\", `\n  display: flex;\n  margin-bottom: 8px;\n  align-items: baseline;\n  flex-wrap: wrap;\n  & label {\n    margin-right: 8px;\n  }\n  & div {\n    flex-grow: 1;\n  }\n`);\n\nconst cssLabel = styled(\"label\", `\n  font-weight: bold;\n  margin-right: 8px;\n`);\n\nconst cssRowWithEdit = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n`);\n\nconst cssTextLine = styled(\"div\", `\n  display: flex;\n  align-items: baseline;\n  margin-bottom: 8px;\n`);\n\nconst cssInstallationId = styled(\"div\", `\n  margin-left: 16px;\n  color: ${theme.inputDisabledFg};\n  cursor: pointer;\n  display: flex;\n  align-items: baseline;\n  gap: 4px;\n  font-size: ${vars.smallFontSize};\n  --icon-color: ${theme.lightText};\n  &:hover {\n    --icon-color: ${colors.lightGreen};\n    background-color: ${theme.lightHover};\n  }\n`);\n\nconst cssSpacer = styled(\"div\", `\n  height: 12px;\n`);\n\nconst cssInline = styled(\"span\", `\n  display: inline;\n  & p {\n    display: inline;\n  }\n`);\n\nconst planStatusIcon = styled(icon, `\n  width: 24px;\n  height: 24px;\n\n  &-valid {\n    --icon-color: ${theme.inputValid};\n  }\n  &-invalid {\n    --icon-color: ${theme.inputInvalid};\n  }\n`);\n\nconst cssFlexLine = styled(\"span\", `\n  display: flex;\n  align-items: center;\n  gap: 4px;\n`);\n\nconst cssErrorText = styled(\"div\", `\n  color: ${theme.errorText};\n`);\n\nconst cssCopyButton = styled(\"div\", `\n  width: 24px;\n`);\n"
  },
  {
    "path": "app/client/ui/Tools.ts",
    "content": "import { ACLUsersPopup } from \"app/client/aclui/ACLUsers\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { getUserOrgPrefObs, markAsSeen } from \"app/client/models/UserPrefs\";\nimport { showExampleCard } from \"app/client/ui/ExampleCard\";\nimport { buildExamples } from \"app/client/ui/ExampleInfo\";\nimport {\n  createAccessibilityTools,\n  createHelpTools,\n  cssLinkText,\n  cssMenuTrigger,\n  cssPageButton,\n  cssPageEntry,\n  cssPageEntryMain,\n  cssPageEntrySmall,\n  cssPageIcon,\n  cssPageLink,\n  cssPageLinkContainer,\n  cssSectionHeader,\n  cssSectionHeaderText,\n  cssSpacer,\n  cssSplitPageEntry,\n  cssTools,\n} from \"app/client/ui/LeftPanelCommon\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\nimport { stretchedLink } from \"app/client/ui2018/stretchedLink\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { buildOpenAssistantButton } from \"app/client/widgets/AssistantPopup\";\nimport { isOwner } from \"app/common/roles\";\n\nimport { Computed, computed, Disposable, dom, makeTestId,\n  Observable, observable, styled } from \"grainjs\";\nimport noop from \"lodash/noop\";\n\nconst testId = makeTestId(\"test-tools-\");\nconst t = makeT(\"Tools\");\n\nexport function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {\n  const docPageModel = gristDoc.docPageModel;\n  const isDocOwner = isOwner(docPageModel.currentDoc.get());\n  const isOverridden = Boolean(docPageModel.userOverride.get());\n  const canMakeProposal = Computed.create(owner, (use) => {\n    return use(docPageModel.isFork) && !use(docPageModel.isBareFork) && !use(docPageModel.isPrefork) &&\n      !use(docPageModel.isSnapshot);\n  });\n  // If we are on a fork, currentDoc options are actually for the trunk.\n  const trunkAcceptsProposals = computed((use) => {\n    return use(docPageModel.currentDoc)?.options?.proposedChanges?.acceptProposals;\n  });\n  const canViewAccessRules = observable(false);\n  function updateCanViewAccessRules() {\n    canViewAccessRules.set((isDocOwner && !isOverridden) ||\n      gristDoc.docModel.rules.getNumRows() > 0);\n  }\n  owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules));\n  updateCanViewAccessRules();\n  return cssTools(\n    { \"aria-labelledby\": \"grist-tools-heading\" },\n    cssTools.cls(\"-collapsed\", use => !use(leftPanelOpen)),\n    cssSectionHeader(cssSectionHeaderText(t(\"TOOLS\"), { id: \"grist-tools-heading\" })),\n    buildOpenAssistantButton(gristDoc, testId(\"assistant\")),\n    cssPageEntry(\n      cssPageEntry.cls(\"-selected\", use => use(gristDoc.activeViewId) === \"acl\"),\n      cssPageEntry.cls(\"-disabled\", use => !use(canViewAccessRules)),\n      dom.domComputedOwned(canViewAccessRules, (computedOwner, _canViewAccessRules) => {\n        const aclUsers = ACLUsersPopup.create(computedOwner, docPageModel);\n        if (_canViewAccessRules) {\n          aclUsers.load()\n          // getUsersForViewAs() could fail for couple good reasons (access deny to anon user,\n          // `document not found` when anon creates a new empty document, ...), users can have more\n          // info by opening acl page, so let's silently fail here.\n            .catch(noop);\n        }\n        return cssPageLinkContainer(\n          cssPageIcon(\"EyeShow\"),\n          stretchedLink(\n            cssLinkText(t(\"Access Rules\")),\n            _canViewAccessRules ? urlState().setLinkUrl({ docPage: \"acl\" }) : null,\n          ),\n          cssMenuTrigger(\n            icon(\"Dots\"),\n            aclUsers.menu({\n              placement: \"bottom-start\",\n              parentSelectorToMark: \".\" + cssPageEntry.className,\n            }),\n            { \"aria-label\": t(\"context menu - Access Rules\") },\n            testId(\"access-rules-trigger\"),\n            dom.show(use => use(aclUsers.isInitialized) && _canViewAccessRules),\n          ),\n        );\n      }),\n      testId(\"access-rules\"),\n    ),\n    cssPageEntry(\n      cssPageEntry.cls(\"-selected\", use => use(gristDoc.activeViewId) === \"data\"),\n      cssPageLink(\n        cssPageIcon(\"Database\"),\n        cssLinkText(t(\"Raw data\")),\n        testId(\"raw\"),\n        urlState().setLinkUrl({ docPage: \"data\" }),\n      ),\n    ),\n    cssPageEntry(\n      cssPageButton(cssPageIcon(\"Log\"), cssLinkText(t(\"Document history\")), testId(\"log\"),\n        dom.on(\"click\", () => gristDoc.showTool(\"docHistory\"))),\n    ),\n    dom.maybe(\n      trunkAcceptsProposals, () => {\n        return cssPageEntry(\n          cssPageEntry.cls(\"-selected\", use => use(gristDoc.activeViewId) === \"suggestions\"),\n          cssPageLink(\n            cssPageIcon(\"MobileChat\"),\n            dom.domComputed((use) => {\n              const proposable = use(canMakeProposal);\n              const changes = proposable ? use(docPageModel.proposalNewChangesCount) : 0;\n              const text = proposable ? t(\"Suggest changes\") : t(\"Suggestions\");\n              return cssLinkText(changes ? [text, cssChangeCount(` (${changes})`)] : text);\n            }),\n            testId(\"proposals\"),\n            urlState().setLinkUrl({ docPage: \"suggestions\" }),\n          ),\n        );\n      }),\n    cssPageEntry(\n      cssPageEntry.cls(\"-selected\", use => use(gristDoc.activeViewId) === \"code\"),\n      cssPageLink(cssPageIcon(\"Code\"),\n        cssLinkText(t(\"Code view\")),\n        urlState().setLinkUrl({ docPage: \"code\" }),\n      ),\n      testId(\"code\"),\n    ),\n    cssPageEntry(\n      cssPageEntry.cls(\"-selected\", use => use(gristDoc.activeViewId) === \"settings\"),\n      cssPageLink(cssPageIcon(\"Settings\"),\n        cssLinkText(t(\"Settings\")),\n        urlState().setLinkUrl({ docPage: \"settings\" }),\n      ),\n      testId(\"settings\"),\n    ),\n    cssSpacer(),\n    dom.maybe(docPageModel.currentDoc, (doc) => {\n      const ex = buildExamples().find(e => e.urlId === doc.urlId);\n      if (!ex?.tutorialUrl) { return null; }\n      return cssPageEntry(\n        cssPageLinkContainer(\n          cssPageIcon(\"Page\"),\n          dom(\"a\",\n            cssLinkText(t(\"How-to Tutorial\")),\n            testId(\"tutorial\"),\n            { href: ex.tutorialUrl, target: \"_blank\" },\n          ),\n          cssExampleCardOpener(\n            { \"aria-label\": t(\"Preview the tutorial\") },\n            icon(\"TypeDetails\"),\n            testId(\"welcome-opener\"),\n            automaticHelpTool(\n              info => showExampleCard(ex, info),\n              gristDoc,\n              \"seenExamples\",\n              ex.id,\n            ),\n          ),\n        ),\n      );\n    }),\n    // Show the 'Tour of this Document' button if a GristDocTour table exists.\n    dom.maybe(use => use(gristDoc.docModel.hasDocTour) && !use(gristDoc.docModel.isTutorial), () =>\n      cssSplitPageEntry(\n        cssPageEntryMain(\n          cssPageLink(cssPageIcon(\"Page\"),\n            cssLinkText(t(\"Tour of this Document\")),\n            urlState().setLinkUrl({ docTour: true }),\n            testId(\"doctour\"),\n          ),\n        ),\n        !isDocOwner ? null : cssPageEntrySmall(\n          cssPageButton(cssPageIcon(\"Remove\"),\n            { \"aria-label\": t(\"Delete document tour\") },\n            dom.on(\"click\", () => confirmModal(t(\"Delete document tour?\"), t(\"Delete\"), () =>\n              gristDoc.docData.sendAction([\"RemoveTable\", \"GristDocTour\"])),\n            ),\n            testId(\"remove-doctour\"),\n          ),\n        ),\n      ),\n    ),\n    createHelpTools(docPageModel.appModel),\n    createAccessibilityTools(),\n  );\n}\n\n/**\n * Helper for showing users some kind of help (example cards or document tours)\n * automatically if they haven't seen it before, or if they click\n * on some element to explicitly show it again. Put this in said dom element,\n * and it will provide the onclick handler and a handler which automatically\n * shows when the dom element is attached, both by calling showFunc.\n *\n * prefKey is a key for a list of identifiers saved in user preferences.\n * itemId should be a single identifier that fits in that list.\n * If itemId is already present then the help will not be shown automatically,\n * otherwise it will be added to the list and saved under prefKey\n * when info.markAsSeen() is called.\n */\nfunction automaticHelpTool(\n  showFunc: (info: AutomaticHelpToolInfo) => void,\n  gristDoc: GristDoc,\n  prefKey: \"seenExamples\" | \"seenDocTours\",\n  itemId: number | string,\n) {\n  function show(elem: HTMLElement, reopen: boolean) {\n    const prefObs: Observable<typeof itemId[] | undefined> = getUserOrgPrefObs(gristDoc.userOrgPrefs, prefKey);\n    const seenIds = prefObs.get() || [];\n\n    // If this help was previously dismissed, don't show it again, unless the user is reopening it.\n    if (!reopen && seenIds.includes(itemId)) {\n      return;\n    }\n\n    showFunc({ elem, reopen, markAsSeen: () => markAsSeen(prefObs, itemId) });\n  }\n\n  return [\n    dom.on(\"click\", (ev, elem) => {\n      ev.preventDefault();\n      show(elem as HTMLElement, true);\n    }),\n    (elem: HTMLElement) => {\n      // Once the trigger element is attached to DOM, show the help\n      setTimeout(() => show(elem, false), 0);\n    },\n  ];\n}\n\n/** Values which may be useful when showing an automatic help tool */\nexport interface AutomaticHelpToolInfo {\n  // Element where automaticHelpTool is attached, typically a button,\n  // which shows the help when clicked\n  elem: HTMLElement;\n\n  // true if the help was shown explicitly by clicking elem,\n  // false if it's being shown automatically to new users\n  reopen: boolean;\n\n  // Call this when the user explicitly dismisses the help to\n  // remember this in user preferences and not show it automatically on next load\n  markAsSeen: () => void;\n}\n\nconst cssExampleCardOpener = styled(unstyledButton, `\n  cursor: pointer;\n  margin-right: 4px;\n  margin-left: auto;\n  border-radius: 16px;\n  border-radius: 3px;\n  height: 24px;\n  width: 24px;\n  padding: 4px;\n  line-height: 0px;\n  --icon-color: ${theme.iconButtonFg};\n  background-color: ${theme.iconButtonPrimaryBg};\n  outline-offset: 2px;\n  &:hover {\n    background-color: ${theme.iconButtonPrimaryHoverBg};\n  }\n  .${cssTools.className}-collapsed & {\n    display: none;\n  }\n`);\n\nconst cssChangeCount = styled(\"span\", `\n  font-weight: bold;\n`);\n"
  },
  {
    "path": "app/client/ui/TopBar.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { loadSearch } from \"app/client/lib/imports\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { AppModel, reportError } from \"app/client/models/AppModel\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { workspaceName } from \"app/client/models/WorkspaceInfo\";\nimport { AccountWidget } from \"app/client/ui/AccountWidget\";\nimport { buildActiveUserList } from \"app/client/ui/ActiveUserList\";\nimport { buildLanguageMenu } from \"app/client/ui/LanguageMenu\";\nimport { buildNotifyMenuButton } from \"app/client/ui/NotifyUI\";\nimport { manageTeamUsersApp } from \"app/client/ui/OpenUserManager\";\nimport { UpgradeButton } from \"app/client/ui/ProductUpgrades\";\nimport { buildShareMenuButton } from \"app/client/ui/ShareMenu\";\nimport { SupportGristButton } from \"app/client/ui/SupportGristButton\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { cssHoverCircle, cssTopBarBtn } from \"app/client/ui/TopBarCss\";\nimport { docBreadcrumbs } from \"app/client/ui2018/breadcrumbs\";\nimport { basicButton } from \"app/client/ui2018/buttons\";\nimport { isNarrowScreenObs, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport * as roles from \"app/common/roles\";\n\nimport { Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, styled } from \"grainjs\";\n\nimport type * as searchModule from \"app/client/ui2018/search\";\n\nconst t = makeT(\"TopBar\");\n\nexport function createTopBarHome(appModel: AppModel, onSave?: (personal: boolean) => Promise<unknown>) {\n  const isAnonymous = !appModel.currentValidUser;\n\n  return [\n    cssFlexSpace(),\n    cssButtons(\n      dom.create(UpgradeButton, appModel),\n      dom.create(SupportGristButton, appModel),\n      (appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?\n        [\n          basicButton(\n            t(\"Manage team\"),\n            dom.on(\"click\", () => manageTeamUsersApp({ app: appModel, onSave })),\n            testId(\"topbar-manage-team\"),\n          ),\n        ] :\n        null\n      ),\n    ),\n    buildLanguageMenu(appModel),\n    isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel),\n    dom(\"div\", dom.create(AccountWidget, appModel)),\n  ];\n}\n\nexport function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageModel: DocPageModel) {\n  const doc = pageModel.currentDoc;\n  const renameDoc = (val: string) => pageModel.renameDoc(val);\n  const displayNameWs = Computed.create(owner, pageModel.currentWorkspace,\n    (use, ws) => ws ? { ...ws, name: workspaceName(appModel, ws) } : ws);\n\n  const moduleObs = Observable.create<typeof searchModule | null>(owner, null);\n  loadSearch().then(module => moduleObs.set(module)).catch(reportError);\n\n  // Observable to decide whether to include the searchBar into this page. It doesn't work on\n  // 'code' and 'acl' pages, so it's better to omit it, and let the browser's native search work.\n  const enabledObs = Computed.create(owner, pageModel.gristDoc, (use, gristDoc) => {\n    const viewId = gristDoc ? use(gristDoc.activeViewId) : null;\n    return viewId !== null && viewId !== \"code\" && viewId !== \"acl\";\n  });\n\n  const searchModelObs = Computed.create(owner,\n    moduleObs, pageModel.gristDoc, enabledObs,\n    (use, module, gristDoc, enabled) => {\n      if (!module || !gristDoc || !enabled) {\n        return null;\n      }\n      return module.SearchModelImpl.create(use.owner, gristDoc);\n    });\n\n  const isSearchOpen = Computed.create(owner, searchModelObs, (use, searchModel) => {\n    return Boolean(searchModel && use(searchModel.isOpen));\n  });\n\n  const isUndoRedoAvailable = Computed.create(owner, (use) => {\n    const gristDoc = use(pageModel.gristDoc);\n    if (!gristDoc) { return false; }\n\n    const undoStack = gristDoc.getUndoStack();\n    return !use(undoStack.isDisabled);\n  });\n\n  const isAnonymous = !pageModel.appModel.currentValidUser;\n\n  return [\n    // TODO Before gristDoc is loaded, we could show doc-name without the page. For now, we delay\n    // showing of breadcrumbs until gristDoc is loaded.\n    dom.maybe(pageModel.gristDoc, gristDoc =>\n      cssBreadcrumbContainer(\n        docBreadcrumbs(displayNameWs, pageModel.currentDocTitle, gristDoc.currentPageName, {\n          docNameSave: renameDoc,\n          pageNameSave: getRenamePageFn(gristDoc),\n          cancelRecoveryMode: getCancelRecoveryModeFn(gristDoc),\n          isPageNameReadOnly: use => use(gristDoc.isReadonly) || typeof use(gristDoc.activeViewId) !== \"number\",\n          isDocNameReadOnly: use => use(gristDoc.isReadonly) || use(pageModel.isFork),\n          isFork: pageModel.isFork,\n          isBareFork: pageModel.isBareFork,\n          isRecoveryMode: pageModel.isRecoveryMode,\n          isTutorialFork: pageModel.isTutorialFork,\n          isFiddle: Computed.create(owner, use => use(pageModel.isPrefork)),\n          isSnapshot: pageModel.isSnapshot,\n          isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc?.public)),\n          isTemplate: pageModel.isTemplate,\n          isAnonymous,\n          isProposable: Computed.create(\n            owner, gristDoc.docPageModel.currentDoc,\n            (_use, currentDoc) => Boolean(currentDoc?.options?.proposedChanges?.acceptProposals),\n          ),\n          isReadonly: pageModel.isReadonly,\n          proposeChanges: async () => {\n            const { urlId } = await gristDoc.docComm.fork();\n            await urlState().pushUrl({ doc: urlId });\n          },\n        }),\n        dom.hide(use => use(isSearchOpen) && use(isNarrowScreenObs())),\n      ),\n    ),\n    cssFlexSpace(),\n    dom.maybe(pageModel.gristDoc, gristDoc => buildActiveUserList(owner, gristDoc.userPresenceModel)),\n    // Don't show useless undo/redo buttons for sample docs, to leave more space for \"Make copy\".\n    dom.maybe(pageModel.undoState, state => [\n      topBarUndoBtn(\"Undo\",\n        dom.on(\"click\", () => state.isUndoDisabled.get() || allCommands.undo.run()),\n        dom.hide(use => use(isSearchOpen)),\n        hoverTooltip(t(\"Undo\"), { key: \"topBarBtnTooltip\" }),\n        cssHoverCircle.cls(\"-disabled\", use => use(state.isUndoDisabled) || !use(isUndoRedoAvailable)),\n        dom.attr(\"aria-disabled\", use => use(state.isUndoDisabled) ? \"true\" : \"false\"),\n        dom.attr(\"aria-label\", t(\"Undo\")),\n        testId(\"undo\"),\n      ),\n      topBarUndoBtn(\"Redo\",\n        dom.on(\"click\", () => state.isRedoDisabled.get() || allCommands.redo.run()),\n        dom.hide(use => use(isSearchOpen)),\n        hoverTooltip(t(\"Redo\"), { key: \"topBarBtnTooltip\" }),\n        cssHoverCircle.cls(\"-disabled\", use => use(state.isRedoDisabled) || !use(isUndoRedoAvailable)),\n        dom.attr(\"aria-disabled\", use => use(state.isRedoDisabled) ? \"true\" : \"false\"),\n        dom.attr(\"aria-label\", t(\"Redo\")),\n        testId(\"redo\"),\n      ),\n      cssSpacer(),\n    ]),\n    dom.domComputed((use) => {\n      const model = use(searchModelObs);\n      return model && use(moduleObs)?.searchBar(\n        model, makeTestId(\"test-tb-search-\"),\n        pageModel.gristDoc.get()?.regionFocusSwitcher,\n      );\n    }),\n    dom.maybe(use => !(use(pageModel.isTemplate) && isAnonymous), () => [\n      buildShareMenuButton(pageModel),\n      dom.maybe(pageModel.gristDoc,\n        gristDoc => buildShowDiscussionButton(gristDoc)),\n      buildNotifyMenuButton(appModel.notifier, appModel),\n    ]),\n    dom(\"div\", dom.create(AccountWidget, appModel, pageModel)),\n  ];\n}\n\nfunction buildShowDiscussionButton(gristDoc: GristDoc) {\n  return cssHoverCircle({ style: `margin: 5px; position: relative;` },\n    cssTopBarBtn(\"Chat\", dom.cls(\"tour-share-icon\")),\n    hoverTooltip(\"Comments\", { key: \"topBarBtnTooltip\" }),\n    testId(\"open-discussion\"),\n    dom.on(\"click\", () => {\n      gristDoc.showTool(\"discussion\");\n      allCommands.rightPanelOpen.run();\n    }),\n  );\n}\n\n// Given the GristDoc instance, returns a rename function for the current active page.\n// If the current page is not able to be renamed or the new name is invalid, the function is a noop.\nfunction getRenamePageFn(gristDoc: GristDoc): (val: string) => Promise<void> {\n  return async (val: string) => {\n    const views = gristDoc.docModel.views;\n    const viewId = gristDoc.activeViewId.get();\n    if (typeof viewId === \"number\" && val.length > 0) {\n      const name = views.rowModels[viewId].name;\n      await name.saveOnly(val);\n    }\n  };\n}\n\nfunction getCancelRecoveryModeFn(gristDoc: GristDoc): () => Promise<void> {\n  return async () => {\n    await gristDoc.app.topAppModel.api.getDocAPI(gristDoc.docPageModel.currentDocId.get()!)\n      .recover(false);\n  };\n}\n\nfunction topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element {\n  return cssHoverCircle(\n    cssTopBarUndoBtn(iconName),\n    ...domArgs,\n  );\n}\n\nconst cssButtons = styled(\"div\", `\n  display: flex;\n  gap: 8px;\n  margin-right: 8px;\n`);\n\nconst cssTopBarUndoBtn = styled(cssTopBarBtn, `\n  background-color: ${theme.topBarButtonSecondaryFg};\n\n  .${cssHoverCircle.className}:hover & {\n    background-color: ${theme.topBarButtonPrimaryFg};\n  }\n\n  .${cssHoverCircle.className}-disabled:hover & {\n    background-color: ${theme.topBarButtonDisabledFg};\n    cursor: default;\n  }\n`);\n\nconst cssBreadcrumbContainer = styled(\"div\", `\n  padding: 7px;\n  flex: 1 1 auto;\n  min-width: 24px;\n  overflow: hidden;\n`);\n\nconst cssFlexSpace = styled(\"div\", `\n  flex: 1 1 0px;\n`);\n\nconst cssSpacer = styled(\"div\", `\n  max-width: 10px;\n  flex: auto;\n`);\n"
  },
  {
    "path": "app/client/ui/TopBarCss.ts",
    "content": "import { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\n\nimport { styled } from \"grainjs\";\n\nexport const cssHoverCircle = styled(unstyledButton, `\n  width: 32px;\n  height: 32px;\n  background: none;\n  border-radius: 16px;\n\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n\n  &-disabled:hover {\n    background: none;\n  }\n`);\n\nexport const cssTopBarBtn = styled(icon, `\n  width: 32px;\n  height: 32px;\n  padding: 8px 8px;\n  cursor: pointer;\n  -webkit-mask-size: 16px 16px;\n  background-color: ${theme.topBarButtonPrimaryFg};\n\n  .${cssHoverCircle.className}-disabled & {\n    background-color: ${theme.topBarButtonDisabledFg};\n    cursor: default;\n  }\n  &-slate { background-color: ${theme.topBarButtonSecondaryFg}; }\n  &-error { background-color: ${theme.topBarButtonErrorFg}; }\n`);\n"
  },
  {
    "path": "app/client/ui/TreeViewComponent.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { TreeItem, TreeModel, TreeNode, walkTree } from \"app/client/models/TreeModel\";\nimport { mouseDrag, MouseDragHandler, MouseDragStart } from \"app/client/ui/mouseDrag\";\nimport * as css from \"app/client/ui/TreeViewComponentCss\";\n\nimport { Computed, dom, DomArg, Holder } from \"grainjs\";\nimport { Disposable, IDisposable, makeTestId, ObsArray, Observable, observable } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\nimport defaults from \"lodash/defaults\";\nimport noop from \"lodash/noop\";\n\nconst t = makeT(\"TreeViewComponent\");\n\n// DropZone identifies a location where an item can be inserted\ninterface DropZone {\n  zone: \"above\" | \"below\" | \"within\";\n  item: ItemModel;\n\n  // `locked` allows to lock the dropzone until the cursor leaves the current item. This is useful\n  // when the dropzone is not computed from the cursor position but rather is set to 'within' an\n  // item when the auto-expander's timeout expires (defined in `this._updateExpander(...)`). In such\n  // a case it would be nearly impossible for the user to properly drop the item without the\n  // lock. Because when she releases the mouse she is very likely to move cursor unintentionally\n  // which would update the dropZone to either 'above' or 'below' and would insert item in the\n  // desired position.\n  locked?: boolean;\n}\n\n// The view model for a TreeItem\ninterface ItemModel {\n  highlight: Observable<boolean>;\n  collapsed: Observable<boolean>;\n  dragged: Observable<boolean>;\n  // vertical distance in px the user is dragging the item\n  deltaY: Observable<number>;\n  // the px distance from the left side of the container to the label\n  offsetLeft: () => number;\n  treeItem: TreeItem;\n  headerElement: HTMLElement;\n  containerElement: HTMLElement;\n  handleElement: HTMLElement;\n  labelElement: HTMLElement;\n  offsetElement: HTMLElement;\n  arrowElement: HTMLElement;\n}\n\n// Whether item1 and item2 are models of the same item.\nfunction eq(item1: ItemModel | \"root\", item2: ItemModel | \"root\") {\n  if (item1 === \"root\" && item2 === \"root\") {\n    return true;\n  }\n  if (item1 === \"root\" || item2 === \"root\") {\n    return false;\n  }\n  return item1.treeItem === item2.treeItem;\n}\n\n// Set when a drag starts.\ninterface Drag extends IDisposable {\n  startY: number;\n  item: ItemModel;\n  // a holder used to update the highlight surrounding the target's parent\n  highlightedBox: Holder<IDisposable>;\n  autoExpander: Holder<{ item: ItemModel } & IDisposable>;\n}\n\n// The geometry of the target which is a visual artifact showing where the user can drop an item.\ninterface Target {\n  width: number;\n  top: number;\n  left: number;\n}\n\nexport interface TreeViewOptions {\n  // the number of pixels used for indentation.\n  offset?: number;\n  expanderDelay?: number;\n  // the delay a user has to keep the mouse down on a item before dragging starts\n  dragStartDelay?: number;\n  isOpen?: Observable<boolean>;\n  selected: Observable<TreeItem | null>;\n  // When true turns readonly mode on, defaults to false.\n  isReadonly?: Observable<boolean>;\n}\n\nconst testId = makeTestId(\"test-treeview-\");\n\n/**\n * The TreeViewComponent is a component that can show hierarchical data. It supports collapsing\n * children and dragging and dropping to move items in the tree.\n *\n * Hovering an item reveals a handle. User must then grab that handle to drag an item. During drag\n * the handle slides vertically to follow cursor's motion. During drag, the component highlights\n * visually where the user can drop the item by displaying a target (above or below) the targeted\n * item and by highlighting its parent. In order to ensure data consistency, the component prevents\n * dropping an item within its own children. If the cursor leaves the component during a drag, all\n * such visual artifact (handle, target and target's parent) are hidden, but if the cursor re-enter\n * the component without releasing the mouse, they will show again allowing user to resume dragging.\n */\n// note to self: in the future the model will be updated by the server, which could cause conflicts\n// if the user is dragging at the same time. It could be simpler to freeze the model and to differ\n// their resolution until after the drag terminates.\nexport class TreeViewComponent extends Disposable {\n  private readonly _options: Required<TreeViewOptions>;\n  private readonly _containerElement: Element;\n\n  private _drag: Holder<Drag> = Holder.create(this);\n  private _hoveredItem: ItemModel | \"root\" = \"root\";\n  private _dropZone: DropZone | null = null;\n\n  private readonly _hideTarget = observable(true);\n  private readonly _target = observable<Target>({ width: 0, top: 0, left: 0 });\n  private readonly _dragging = observable(false);\n  private readonly _isClosed: Computed<boolean>;\n\n  private _treeItemMap = new Map<TreeItem, Element>();\n  private _childrenDom: Observable<Node>;\n\n  constructor(private _model: Observable<TreeModel>, options: TreeViewOptions) {\n    super();\n    this._options = defaults(options, {\n      offset: 10,\n      expanderDelay: 1000,\n      dragStartDelay: 1000,\n      isOpen: Observable.create(this, true),\n      isReadonly: Observable.create(this, false),\n    });\n\n    // While building dom we add listeners to the children of all tree nodes to watch for changes\n    // and call this._update. Hence, repeated calls to this._update is likely to add or remove\n    // listeners to the observable that triggered the update which is not supported by grainjs and\n    // could fail (possibly infinite loop). Debounce allows for several change to resolve to a\n    // single update.\n    this._update = debounce(this._update.bind(this), 0, { leading: false });\n\n    // build dom for the tree of children\n    this._childrenDom = observable(this._buildChildren(this._model.get().children()));\n    this.autoDispose(this._model.addListener(this._update, this));\n\n    this._isClosed = Computed.create(this, use => !use(this._options.isOpen));\n\n    this._containerElement = css.treeViewContainer(\n\n      // hides the drop zone and target when the cursor leaves the component\n      dom.on(\"mouseleave\", () => {\n        this._setDropZone(null);\n        const drag = this._drag.get();\n        if (drag) {\n          drag.autoExpander.clear();\n          drag.item.handleElement.style.display = \"none\";\n        }\n      }),\n\n      dom.on(\"mouseenter\", () => {\n        const drag = this._drag.get();\n        if (drag) {\n          drag.item.handleElement.style.display = \"\";\n        }\n      }),\n\n      // it's important to insert drop zone indicator before children, otherwise it could prevent\n      // some mouse events to hit children's dom\n      this._buildTarget(),\n\n      // insert children\n      dom.domComputed(this._childrenDom),\n\n      css.treeViewContainer.cls(\"-close\", this._isClosed),\n      css.treeViewContainer.cls(\"-dragging\", this._dragging),\n      testId(\"container\"),\n    );\n  }\n\n  public buildDom() { return this._containerElement; }\n\n  // Starts a drag.\n  private _startDrag(ev: MouseEvent) {\n    if (this._options.isReadonly.get()) { return null; }\n    if (this._isClosed.get()) { return null; }\n    this._hoveredItem = this._closestItem(ev.target as HTMLElement | null);\n    if (this._hoveredItem === \"root\") {\n      return null;\n    }\n    const drag = {\n      startY: ev.clientY - this._hoveredItem.headerElement.getBoundingClientRect().top,\n      item: this._hoveredItem,\n      highlightedBox: Holder.create(this),\n      autoExpander: Holder.create<IDisposable & { item: ItemModel }>(this),\n      dispose: () => {\n        drag.autoExpander.dispose();\n        drag.highlightedBox.dispose();\n        drag.item.dragged.set(false);\n        drag.item.handleElement.style.display = \"\";\n        drag.item.deltaY.set(0);\n      },\n    };\n\n    this._drag.autoDispose(drag);\n    this._hoveredItem.dragged.set(true);\n    this._dragging.set(true);\n    return {\n      onMove: (mouseEvent: MouseEvent) => this._onMouseMove(mouseEvent),\n      onStop: () => this._terminateDragging(),\n    };\n  }\n\n  // Terminates a drag.\n  private _terminateDragging() {\n    const drag = this._drag.get();\n    // Clearing the `drag` instance before moving the item allow to revert the style of the item\n    // being dragged before it gets removed from the model.\n    this._drag.clear();\n    if (drag && this._dropZone) {\n      this._moveTreeNode(drag.item, this._dropZone);\n    }\n    this._setDropZone(null);\n    this._hideTarget.set(true);\n    this._dragging.set(false);\n  }\n\n  // The target is an horizontal bar indicating where user can drop an item. It typically shows\n  // above or below any particular item to indicate where the dragged item would be inserted.\n  private _buildTarget() {\n    return css.target(\n      testId(\"target\"),\n      // show only if a drop zone is set\n      dom.hide(this._hideTarget),\n      dom.style(\"width\", use => use(this._target).width + \"px\"),\n      dom.style(\"top\", use => use(this._target).top + \"px\"),\n      dom.style(\"left\", use => use(this._target).left + \"px\"),\n    );\n  }\n\n  // Update this._childrenDom with the content of the new tree. Its rebuilds entirely the tree of\n  // items and reuses dom from the old content for each item that were already part of the old\n  // tree. Then takes care of disposing dom for those items that were removed from the old tree.\n  private _update() {\n    this._childrenDom.set(this._buildChildren(this._model.get().children(), 0));\n\n    // Dispose all the items from this._treeItemMap that are not in the new tree. Note an item\n    // already takes care of removing itself from the this._treeItemMap on dispose (thanks to the\n    // dom.onDispose(() => this._treeItemMap.delete(treeItem)) in this._getOrCreateItem). First\n    // create a map with all the items from _treeItemMap (they may or may not be included in the new\n    // tree), then walk the new tree and remove all of its items from the map. Eventually, what\n    // remains in the map are the elements that need disposal.\n    const map = new Map(this._treeItemMap);\n    walkTree(this._model.get(), treeItem => map.delete(treeItem));\n    map.forEach((elem, key) => dom.domDispose(elem));\n  }\n\n  // Build list of children. For each child reuses item's dom if already exist and update the offset\n  // and the list of children. Also add a listener that calls this._update to children.\n  private _buildChildren(children: ObsArray<TreeItem>, level: number = 0) {\n    return css.itemChildren(\n      children.get().map((treeItem) => {\n        const elem = this._getOrCreateItem(treeItem);\n        this._setOffset(elem, level);\n        const itemHeaderElem = elem.children[0];\n        const itemChildren = treeItem.children();\n        const arrowElement = dom.getData(elem, \"item\").arrowElement;\n        if (itemChildren) {\n          const itemChildrenElem = this._buildChildren(treeItem.children()!, level + 1);\n          replaceChildren(elem, itemHeaderElem, itemChildrenElem);\n          dom.styleElem(arrowElement, \"visibility\", itemChildren.get().length ? \"visible\" : \"hidden\");\n        } else {\n          replaceChildren(elem, itemHeaderElem);\n          dom.styleElem(arrowElement, \"visibility\", \"hidden\");\n        }\n        return elem;\n      }),\n      dom.autoDispose(children.addListener(this._update, this)),\n    );\n  }\n\n  // Get or create dom for treeItem.\n  private _getOrCreateItem(treeItem: TreeItem): Element {\n    let item = this._treeItemMap.get(treeItem);\n    if (!item) {\n      item = this._buildTreeItemDom(treeItem,\n        dom.onDispose(() => this._treeItemMap.delete(treeItem)),\n      );\n      this._treeItemMap.set(treeItem, item);\n    }\n    return item;\n  }\n\n  private _setOffset(el: Element, level: number) {\n    const item = dom.getData(el, \"item\") as ItemModel;\n    item.offsetElement.style.width = level * this._options.offset + \"px\";\n  }\n\n  private _buildTreeItemDom(treeItem: TreeItem, ...args: DomArg[]): Element {\n    const collapsed = treeItem.collapsed ?? observable(false);\n    const dragged = observable(false);\n    // vertical distance in px the user is dragging the item\n    const deltaY = observable(0);\n    const children = treeItem.children();\n    const offsetLeft = () =>\n      labelElement.getBoundingClientRect().left - this._containerElement.getBoundingClientRect().left;\n    const highlight = observable(false);\n\n    let headerElement: HTMLElement;\n    let labelElement: HTMLElement;\n    let handleElement: HTMLElement | null = null;\n    let offsetElement: HTMLElement;\n    let arrowElement: HTMLElement;\n\n    const containerElement = dom(\"div.itemContainer\",\n      testId(\"itemContainer\"),\n      dom.cls(\"collapsed\", collapsed),\n      css.itemHeaderWrapper(\n        testId(\"itemHeaderWrapper\"),\n        dom.cls(\"dragged\", dragged),\n        css.itemHeaderWrapper.cls(\"-not-dragging\", use => !use(this._dragging)),\n        headerElement = css.itemHeader(\n          testId(\"itemHeader\"),\n          dom.cls(\"highlight\", highlight),\n          dom.cls(\"selected\", use => use(this._options.selected) === treeItem),\n          offsetElement = css.offset(testId(\"offset\")),\n          // The label is first in the DOM but visibly shown after the arrow thanks to flexbox re-ordering.\n          // This is done mostly so that screen reader users better understand the context of the arrow button.\n          labelElement = css.itemLabel(\n            testId(\"label\"),\n            treeItem.buildDom(),\n            dom.style(\"top\", use => use(deltaY) + \"px\"),\n          ),\n          arrowElement = css.arrow(\n            dom.attr(\"aria-label\", use => use(collapsed) ? t(\"Expand\") : t(\"Collapse\")),\n            css.dropdown(\"Dropdown\"),\n            testId(\"itemArrow\"),\n            dom.style(\"transform\", use => use(collapsed) ? \"rotate(-90deg)\" : \"\"),\n            dom.on(\"click\", ev => toggle(collapsed)),\n            // Let's prevent dragging to start when un-intentionally holding the mouse down on an arrow.\n            dom.on(\"mousedown\", ev => ev.stopPropagation()),\n          ),\n          delayedMouseDrag(this._startDrag.bind(this), this._options.dragStartDelay),\n        ),\n        treeItem.hidden ? null : css.itemLabelRight(\n          handleElement = css.centeredIcon(\"DragDrop\",\n            dom.style(\"top\", use => use(deltaY) + \"px\"),\n            testId(\"handle\"),\n            dom.hide(this._options.isReadonly),\n          ),\n          mouseDrag((startEvent, elem) => this._startDrag(startEvent)),\n        ),\n      ),\n      ...args,\n    );\n\n    // Associates some of this item internals to the dom element. This is what makes possible to\n    // find which item user is currently pointing at using `const item =\n    // this._closestItem(ev.target);` where ev is a mouse event.\n    const itemModel = {\n      collapsed, dragged, children, treeItem, offsetLeft, highlight, deltaY,\n      headerElement,\n      containerElement,\n      handleElement,\n      labelElement,\n      offsetElement,\n      arrowElement,\n    } as ItemModel;\n    dom.dataElem(containerElement, \"item\", itemModel);\n\n    return containerElement;\n  }\n\n  private _updateHandle(y: number) {\n    const drag = this._drag.get();\n    if (drag) {\n      drag.item.deltaY.set(y - drag.startY - drag.item.headerElement.getBoundingClientRect().top);\n    }\n  }\n\n  private _onMouseMove(ev: MouseEvent) {\n    if (!(ev.target instanceof HTMLElement)) {\n      return null;\n    }\n\n    const item = this._closestItem(ev.target);\n    if (item === \"root\") {\n      return;\n    }\n\n    // updates the expander when cursor is entering a new item while dragging\n    const drag = this._drag.get();\n    if (drag && !eq(this._hoveredItem, item)) {\n      this._updateExpander(drag, item);\n    }\n\n    this._hoveredItem = item;\n\n    this._updateHandle(ev.clientY);\n\n    // update the target, update the target's parent\n    const dropZone = this._getDropZone(ev.clientY);\n    this._setDropZone(dropZone);\n  }\n\n  // Set the drop zone and update the target and target's parent\n  private _setDropZone(dropZone: DropZone | null) {\n    // if there is a locked dropzone on the hovered item already set, do nothing (see\n    // `DropZone#locked` documentation at the begin of this file for more detail)\n    if (this._dropZone && this._dropZone.locked && eq(this._dropZone.item, this._hoveredItem)) {\n      return;\n    }\n    this._dropZone = dropZone;\n    this._updateTarget();\n    this._updateTargetParent();\n  }\n\n  // Update the target based on this._dropZone.\n  private _updateTarget() {\n    const dropZone = this._dropZone;\n    if (dropZone) {\n      const left = this._getDropZoneOffsetLeft(dropZone);\n      const width = this._getDropZoneRight(dropZone) - left;\n      const top = this._getDropZoneTop(dropZone);\n      this._target.set({ width, left, top });\n      this._hideTarget.set(false);\n    } else {\n      this._hideTarget.set(true);\n    }\n  }\n\n  // compute the px distance between the left side of the container and the right side of the header\n  private _getDropZoneRight(dropZone: DropZone): number {\n    const headerRight = dropZone.item.headerElement.getBoundingClientRect().right;\n    const containerRight = this._containerElement.getBoundingClientRect().left;\n    return headerRight - containerRight;\n  }\n\n  // compute the px distance between the left side of the container and the drop zone\n  private _getDropZoneOffsetLeft(dropZone: DropZone): number {\n    // when target is 'within' the item we must add one level of indentation to the items left offset\n    return dropZone.item.offsetLeft() + (dropZone.zone === \"within\" ? this._options.offset : 0);\n  }\n\n  // compute the px distance between the top of the container and the drop zone\n  private _getDropZoneTop(dropZone: DropZone): number {\n    const el = dropZone.item.headerElement;\n    // when crossing the border between 2 consecutive items A and B while dragging another item, in\n    // order to allow the target to remain steady between A and B we need to remove 2 px when\n    // dropzone is 'above', otherwise it causes the target to flicker.\n    return dropZone.zone === \"above\" ? el.offsetTop - 2 : el.offsetTop + el.clientHeight;\n  }\n\n  // Turns off the highlight on the former parent, and turns it on the new parent.\n  private _updateTargetParent() {\n    const drag = this._drag.get();\n    if (!drag) {\n      return;\n    }\n    const newParent = this._dropZone ? this._getDropZoneParent(this._dropZone) : null;\n    if (newParent && newParent !== \"root\") {\n      drag.highlightedBox.autoDispose({ dispose: () => newParent.highlight.set(false) });\n      newParent.highlight.set(true);\n    } else {\n      // setting holder to a dump value allows to dispose the previous value\n      drag.highlightedBox.autoDispose({ dispose: noop });\n    }\n  }\n\n  private _getDropZone(mouseY: number): DropZone | null {\n    const item = this._hoveredItem;\n    const drag = this._drag.get();\n\n    if (!drag || item === \"root\") {\n      return null;\n    }\n\n    // let's not permit dropping above or below the dragged item\n    if (eq(drag.item, item)) {\n      return null;\n    }\n\n    // prevents dropping items into their own children\n    if (this._isInChildOf(item.containerElement, drag.item.containerElement)) {\n      return null;\n    }\n\n    const children = item.treeItem.children();\n    const rect = item.headerElement.getBoundingClientRect();\n\n    // if cursor is over the top half of the header set the drop zone to above this item\n    if ((mouseY - rect.top) <= rect.height / 2) {\n      return { zone: \"above\", item };\n    }\n\n    // if cursor is over the bottom half of the header set the drop zone to below this item, unless\n    // the children are expanded in which case set the drop zone to 'within' this item.\n    if ((mouseY - rect.top) > rect.height / 2) {\n      if (!item.collapsed.get() && children?.get().length) {\n        // set drop zone to above the first child only if the dragged item is not this item, because\n        // it is not allowed to drop item into their own children.\n        if (eq(item, drag.item)) {\n          return null;\n        }\n        return { zone: \"within\", item };\n      } else {\n        return { zone: \"below\", item };\n      }\n    }\n    return null;\n  }\n\n  // Returns whether `element` is nested in a child of `parent`. Both `el` and `parent` must be\n  // a child of this._containerElement.\n  private _isInChildOf(el: Element, parent: Element) {\n    while (el.parentElement &&\n      el.parentElement !== parent &&\n      el.parentElement !== this._containerElement // let's stop at the top element\n    ) {\n      el = el.parentElement;\n    }\n    return el.parentElement === parent;\n  }\n\n  // Finds the closest ancestor with '.itemContainer' and returns the attached ItemModel. Returns\n  // \"root\" if none are found.\n  private _closestItem(element: HTMLElement | null): ItemModel | \"root\" {\n    if (element) {\n      let el: HTMLElement | null = element;\n      while (el && el !== this._containerElement) {\n        if (el.classList.contains(\"itemContainer\")) {\n          return dom.getData(el, \"item\");\n        }\n        el = el.parentElement;\n      }\n    }\n    return \"root\";\n  }\n\n  // Return the ItemModel of the item's parent or 'root' if parent is the root.\n  private _getParent(item: ItemModel): ItemModel | \"root\" {\n    return this._closestItem(item.containerElement.parentElement);\n  }\n\n  // Return the ItemModel of the dropZone's parent or 'root' if parent is the root.\n  private _getDropZoneParent(zone: DropZone): ItemModel | \"root\" {\n    return zone.zone === \"within\" ? zone.item : this._getParent(zone.item);\n  }\n\n  // Returns the TreeNode associated with the item or the TreeModel if item is the root.\n  private _getTreeNode(item: ItemModel | \"root\"): TreeNode {\n    return item === \"root\" ? this._model.get() : item.treeItem;\n  }\n\n  // returns the item that is just after where zone is pointing to\n  private _getNextChild(zone: DropZone): TreeItem | undefined {\n    const children = this._getTreeNode(this._getDropZoneParent(zone)).children();\n    if (!children) {\n      return undefined;\n    }\n    switch (zone.zone) {\n      case \"within\": return children.get()[0];\n      case \"above\": return zone.item.treeItem;\n      case \"below\": return findNext(children.get(), zone.item.treeItem);\n    }\n  }\n\n  // trigger calls to TreeNode#insertBefore(...) and TreeNode#removeChild(...) to move draggedItem\n  // to where zone is pointing to.\n  private _moveTreeNode(draggedItem: ItemModel, zone: DropZone) {\n    const parentTo = this._getTreeNode(this._getDropZoneParent(zone));\n    const childrenTo = parentTo.children();\n    const nextChild = this._getNextChild(zone);\n    const parentFrom = this._getTreeNode(this._getParent(draggedItem));\n\n    if (!childrenTo) {\n      throw new Error(\"Should not be possible to drop into an item with `null` children\");\n    }\n\n    if (parentTo === parentFrom) {\n      // if dropping an item below the above item, do nothing.\n      if (nextChild === draggedItem.treeItem) {\n        return;\n      }\n      // if dropping and item above the below item, do nothing.\n      if (findNext(childrenTo.get(), draggedItem.treeItem) === nextChild) {\n        return;\n      }\n    }\n    // call callbacks\n    parentTo.insertBefore(draggedItem.treeItem, nextChild || null);\n  }\n\n  // Shuts down the previous expander, and sets a new one for item if it is not the dragged item and\n  // it can be expanded (ie: it has collapsed children or it has empty list of children).\n  private _updateExpander(drag: Drag, item: ItemModel) {\n    const children = item.treeItem.children();\n    if (eq(drag.item, item) || !children || children.get().length && !item.collapsed.get()) {\n      drag.autoExpander.clear();\n    } else {\n      const callback = () => {\n        // Expanding the item needs some extra care. Because we could push the dragged item\n        // downwards in the view (if the dragged item is below the item to be expanded). In which\n        // case we must update `item.deltaY` to reflect the offset in order to prevent an offset\n        // between the handle (and the drag image) and the cursor. So let's first save the old pos of the item.\n        const oldItemTop = drag.item.headerElement.getBoundingClientRect().top;\n\n        // let's expand the item\n        item.collapsed.set(false);\n\n        // let's get the new pos for the dragged item, and get the diff\n        const newItemTop = drag.item.headerElement.getBoundingClientRect().top;\n        const offset = newItemTop - oldItemTop;\n\n        // let's reflect the offset on `item.deltaY`\n        drag.item.deltaY.set(drag.item.deltaY.get() - offset);\n\n        // then set the dropzone.\n        this._setDropZone({ zone: \"within\", item, locked: true });\n      };\n      const timeoutId = window.setTimeout(callback, this._options.expanderDelay);\n      const dispose = () => window.clearTimeout(timeoutId);\n      drag.autoExpander.autoDispose({ item, dispose });\n    }\n  }\n}\n\n// returns the item next to item in children, or null.\nfunction findNext(children: TreeItem[], item: TreeItem) {\n  return children.find((val, i, array) => Boolean(i) && array[i - 1] === item);\n}\n\nfunction toggle(obs: Observable<boolean>) {\n  obs.set(!obs.get());\n}\n\nexport function addTreeView(model: Observable<TreeModel>, options: TreeViewOptions) {\n  return dom.create(TreeViewComponent, model, options);\n}\n\n// Starts dragging only when the user keeps the mouse down for a while. Also the cursor must not\n// move until the timer expires. Implementation relies on `./mouseDrag` and a timer that will call\n// `startDrag` only after a timer expires.\nfunction delayedMouseDrag(startDrag: MouseDragStart, delay: number) {\n  return mouseDrag((startEvent, el) => {\n    // the drag handler is assigned when the timer expires\n    let handler: MouseDragHandler | null;\n    const timeoutId = setTimeout(() => handler = startDrag(startEvent, el), delay);\n    dom.onDisposeElem(el, () => clearTimeout(timeoutId));\n    function onMove(ev: MouseEvent) {\n      // Clears timeout if cursor moves before timer expires, ie: the startDrag won't be called.\n      if (handler) {\n        handler.onMove(ev);\n      } else {\n        clearTimeout(timeoutId);\n      }\n    }\n    function onStop(ev: MouseEvent) {\n      if (handler) {\n        handler.onStop(ev);\n      } else {\n        clearTimeout(timeoutId);\n      }\n    }\n    return { onMove, onStop };\n  });\n}\n\n// Replaces the children of elem with children.\nfunction replaceChildren(elem: Element, ...children: Element[]) {\n  while (elem.firstChild) {\n    elem.removeChild(elem.firstChild);\n  }\n  for (const child of children) {\n    elem.appendChild(child);\n  }\n}\n"
  },
  {
    "path": "app/client/ui/TreeViewComponentCss.ts",
    "content": "import { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\n\nimport { styled } from \"grainjs\";\n\nexport const treeViewContainer = styled(\"div\", `\n  user-select: none;\n  -moz-user-select: none;\n  position: relative;\n  display: flex;\n  flex-direction: row;\n  /* adds 2px to make room for the target when shown above the first item */\n  margin-top: 2px;\n`);\n\nexport const itemChildren = styled(\"div\", `\n  flex: 1 1 auto;\n  min-width: 0;\n  .itemContainer.collapsed > & {\n    display: none;\n  }\n`);\n\nexport const dragDropContainer = styled(\"div\", `\n  position: relative;\n`);\n\n// pointer-events: none is set while dragging, to ensure that mouse events are sent to the element\n// over which we are dragging, rather than the one being dragged (which is also under the cursor).\nexport const itemHeaderWrapper = styled(\"div\", `\n  display: flex;\n  flex-direction: row;\n\n  &.dragged {\n    pointer-events: none;\n  }\n`);\n\nexport const itemHeader = styled(\"div\", `\n  display: flex;\n  flex-direction: row;\n  flex-grow: 1;\n  min-width: 0;\n  border-radius: 0 2px 2px 0;\n  border: solid 1px transparent;\n  color: ${theme.text};\n  .${itemHeaderWrapper.className}-not-dragging:hover > & {\n    background-color: ${theme.pageHoverBg};\n  }\n  .${itemHeaderWrapper.className}-not-dragging > &.selected {\n    background-color: ${theme.activePageBg};\n    color: ${theme.activePageFg};\n  }\n  &.highlight {\n    border-color: ${theme.controlFg};\n  }\n`);\n\nexport const dropdown = styled(icon, `\n  background-color: ${theme.controlSecondaryFg};\n  .${itemHeaderWrapper.className}-not-dragging > .${itemHeader.className}.selected & {\n    background-color: ${theme.activePageFg};\n  }\n`);\n\nexport const itemLabelRight = styled(\"div\", `\n  --icon-color: ${theme.controlSecondaryFg};\n  width: 16px;\n  .${treeViewContainer.className}-close & {\n    display: none;\n  }\n`);\n\nexport const centeredIcon = styled(icon, `\n  height: 100%;\n  visibility: hidden;\n  .${itemHeaderWrapper.className}-not-dragging:hover &, .${itemHeaderWrapper.className}.dragged & {\n    visibility: visible;\n    cursor: grab;\n  }\n`);\n\nexport const itemLabel = styled(\"div\", `\n  order: 99;\n  flex-grow: 1;\n  min-width: 0;\n  cursor: pointer;\n  .${itemHeaderWrapper.className}.dragged & {\n    opacity: 0.5;\n    transform: rotate(3deg);\n    position: relative;\n  }\n`);\n\nexport const arrow = styled(unstyledButton, `\n  display: flex;\n  flex-shrink: 0;\n  align-items: center;\n  width: 24px;\n  justify-content: center;\n  .${treeViewContainer.className}-close & {\n    display: none;\n  }\n`);\n\nexport const offset = styled(\"div\", `\n  flex-shrink: 0;\n  .${treeViewContainer.className}-close & {\n    display: none;\n  }\n`);\n\n// I gave target a 2px height in order to make it dstinguishable from the header's highlight which\n// is a 1px border of same color. Setting pointer-events to prevent target from grabbing mouse\n// events none and causes some intermittent interruptions to the processing of mouse events when\n// hovering while dragging.\nexport const target = styled(\"div\", `\n  position: absolute;\n  height: 2px;\n  background: ${theme.controlFg};\n  pointer-events: none;\n`);\n"
  },
  {
    "path": "app/client/ui/TriggerFormulas.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/errors\";\nimport { cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { shadowScroll } from \"app/client/ui/shadowScroll\";\nimport { basicButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menuCssClass, menuDivider } from \"app/client/ui2018/menus\";\nimport { cssSelectBtn } from \"app/client/ui2018/select\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { isEmptyList, RecalcWhen } from \"app/common/gristTypes\";\nimport { nativeCompare } from \"app/common/gutil\";\nimport { decodeObject, encodeObject } from \"app/plugin/objtypes\";\n\nimport { Computed, dom, IDisposableOwner, MultiHolder, Observable, styled } from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\nimport { cssMenu, cssMenuItem, defaultMenuOptions, IOpenController, setPopupToCreateDom } from \"popweasel\";\n\nimport type { ColumnRec } from \"app/client/models/entities/ColumnRec\";\nimport type { TableRec } from \"app/client/models/entities/TableRec\";\n\nconst t = makeT(\"TriggerFormulas\");\n\n/**\n * Build UI to select triggers for formulas in data columns (such for default values).\n */\nexport function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, options: {\n  notTrigger?: Observable<boolean> | null // if column is not yet a trigger,\n  disabled?: Observable<boolean>\n}) {\n  // Set up observables to translate between the UI representation of triggers, and what we\n  // actually store.\n  // - We store the pair (recalcWhen, recalcDeps). When recalcWhen is DEFAULT, recalcDeps lists\n  //   the fields to depend on; in other cases, recalcDeps is not used.\n  // - We show two checkboxes:\n  //   [] Apply to new records -- toggles between recalcWhen of NEVER and DEFAULT.\n  //   [] Apply on record changes -- when turned on, allows selecting fields to depend on. When\n  //      \"Any field\" is selected, it toggles between recalcWhen of MANUAL_UPDATES and DEFAULT.\n\n  function isApplyOnChangesChecked(recalcWhen: RecalcWhen, recalcDeps: CellValue): boolean {\n    return recalcWhen === RecalcWhen.MANUAL_UPDATES ||\n      (recalcWhen === RecalcWhen.DEFAULT && recalcDeps != null && !isEmptyList(recalcDeps));\n  }\n\n  async function toggleApplyOnChanges(value: boolean) {\n    // Whether turning on or off, we reset to the default state.\n    await setRecalc(RecalcWhen.DEFAULT, null);\n    forceApplyOnChanges.set(value);\n  }\n\n  // The state of \"Apply to new records\" checkbox. Only writable when applyOnChanges is false, so\n  // only controls if recalcWhen should be DEFAULT or NEVER.\n  const applyToNew = Computed.create(owner, use => use(column.recalcWhen) !== RecalcWhen.NEVER)\n    .onWrite(value => setRecalc(value ? RecalcWhen.DEFAULT : RecalcWhen.NEVER, null));\n\n  // If true, mark 'Apply on record changes' checkbox, overriding stored state.\n  const forceApplyOnChanges = Observable.create(owner, false);\n\n  // The actual state of the checkbox. Clicking it toggles forceApplyOnChanges, and also resets\n  // recalcWhen/recalcDeps to its default state.\n  const applyOnChanges = Computed.create(owner,\n    use => (use(forceApplyOnChanges) || isApplyOnChangesChecked(use(column.recalcWhen), use(column.recalcDeps))))\n    .onWrite(toggleApplyOnChanges);\n\n  // Helper to update column's recalcWhen and recalcDeps properties.\n  async function setRecalc(when: RecalcWhen, deps: number[] | null) {\n    if (when !== column.recalcWhen.peek() || deps !== column.recalcDeps.peek()) {\n      return column._table.sendTableAction(\n        [\"UpdateRecord\", column.id.peek(), { recalcWhen: when, recalcDeps: encodeObject(deps) }],\n      );\n    }\n  }\n\n  const docModel = column._table.docModel;\n  const summaryText = Computed.create(owner, (use) => {\n    if (use(column.recalcWhen) === RecalcWhen.MANUAL_UPDATES) {\n      return t(\"Any field\");\n    }\n    const deps = decodeObject(use(column.recalcDeps)) as number[] | null;\n    if (!deps || deps.length === 0) { return \"\"; }\n    return deps.map(dep => use(docModel.columns.getRowModel(dep)?.label)).join(\", \");\n  });\n\n  const changesDisabled = Computed.create(owner, (use) => {\n    return Boolean(\n      (options.disabled && use(options.disabled)) ||\n      (options.notTrigger && use(options.notTrigger)),\n    );\n  });\n\n  const newRowsDisabled = Computed.create(owner, (use) => {\n    return Boolean(\n      use(applyOnChanges) || use(changesDisabled),\n    );\n  });\n\n  return [\n    cssRow(\n      labeledSquareCheckbox(\n        applyToNew,\n        t(\"Apply to new records\"),\n        dom.boolAttr(\"disabled\", newRowsDisabled),\n        testId(\"field-formula-apply-to-new\"),\n      ),\n    ),\n    cssRow(\n      labeledSquareCheckbox(\n        applyOnChanges,\n        dom.text(use => use(applyOnChanges) ?\n          t(\"Apply on changes to:\") :\n          t(\"Apply on record changes\"),\n        ),\n        dom.boolAttr(\"disabled\", changesDisabled),\n        testId(\"field-formula-apply-on-changes\"),\n      ),\n    ),\n    dom.maybe(applyOnChanges, () =>\n      cssIndentedRow(\n        cssSelectBtn(\n          cssSelectSummary(dom.text(summaryText)),\n          icon(\"Dropdown\"),\n          testId(\"field-triggers-select\"),\n          dom.cls(\"disabled\", use => !!options.disabled && use(options.disabled)),\n          (elem) => {\n            setPopupToCreateDom(elem, ctl => buildTriggerSelectors(ctl, column.table.peek(), column, setRecalc),\n              { ...defaultMenuOptions, placement: \"bottom-end\" });\n          },\n        ),\n      ),\n    ),\n  ];\n}\n\nfunction buildTriggerSelectors(ctl: IOpenController, tableRec: TableRec, column: ColumnRec,\n  setRecalc: (when: RecalcWhen, deps: number[] | null) => Promise<void>,\n) {\n  // ctl may be used as an owner for disposable object. Just give is a clearer name for this.\n  const owner: IDisposableOwner = ctl;\n\n  // The initial set of selected columns (as a set of rowIds).\n  const initialDeps = new Set(decodeObject(column.recalcDeps.peek()) as number[] | null);\n\n  // State of the \"Any field\" checkbox.\n  const allUpdates = Observable.create(owner, column.recalcWhen.peek() === RecalcWhen.MANUAL_UPDATES);\n\n  // Collect all the ColumnRec objects for available columns in this table.\n  const showColumns = tableRec.columns.peek().peek().filter(col => !col.isHiddenCol.peek());\n  showColumns.sort((a, b) => nativeCompare(a.label.peek(), b.label.peek()));\n\n  // Array of observables for the checkbox for each column. There should never be so many\n  // columns as to make this a performance problem.\n  const columnsState = showColumns.map(col => Observable.create(owner, initialDeps.has(col.id.peek())));\n\n  // The \"Current field\" checkbox is merely one of the column checkboxes.\n  const current = columnsState.find((col, index) => showColumns[index].id.peek() === column.id.peek())!;\n\n  // If user checks the \"Any field\" checkbox, all the others should get unchecked.\n  owner.autoDispose(allUpdates.addListener((value) => {\n    if (value) {\n      columnsState.forEach(obs => obs.set(false));\n    }\n  }));\n\n  // Computed results based on current selections.\n  const when = Computed.create(owner, use => use(allUpdates) ? RecalcWhen.MANUAL_UPDATES : RecalcWhen.DEFAULT);\n  const deps = Computed.create(owner, (use) => {\n    return use(allUpdates) ? null :\n      showColumns.filter((col, index) => use(columnsState[index])).map(col => col.id.peek());\n  });\n\n  // Whether the selections changed, i.e. warrant saving.\n  const isChanged = Computed.create(owner, (use) => {\n    return use(when) !== use(column.recalcWhen) || !isEqual(new Set(use(deps)), initialDeps);\n  });\n\n  let shouldSave = true;\n  function close(_shouldSave: boolean) {\n    shouldSave = _shouldSave;\n    ctl.close();\n  }\n\n  function onClose() {\n    if (shouldSave && isChanged.get()) {\n      setRecalc(when.get(), deps.get()).catch(reportError);\n    }\n  }\n\n  return cssSelectorMenu(\n    { tabindex: \"-1\" }, // Allow menu to be focused\n    testId(\"field-triggers-dropdown\"),\n    dom.cls(menuCssClass),\n    dom.onDispose(onClose),\n    dom.onKeyDown({\n      Enter: () => close(true),\n      Escape: () => close(false),\n    }),\n    // Set focus on open, so that keyboard events work.\n    (elem) => { setTimeout(() => elem.focus(), 0); },\n\n    cssItemsFixed(\n      cssSelectorItem(\n        labeledSquareCheckbox(current,\n          [t(\"Current field \"), cssSelectorNote(\"(data cleaning)\")],\n          dom.boolAttr(\"disabled\", allUpdates),\n        ),\n      ),\n      menuDivider(),\n      cssSelectorItem(\n        labeledSquareCheckbox(allUpdates,\n          [`${t(\"Any field\")} `, cssSelectorNote(\"(except formulas)\")],\n        ),\n      ),\n    ),\n    cssItemsList(\n      showColumns.map((col, index) =>\n        cssSelectorItem(\n          labeledSquareCheckbox(columnsState[index],\n            col.label.peek(),\n            dom.boolAttr(\"disabled\", allUpdates),\n          ),\n        ),\n      ),\n    ),\n    cssItemsFixed(\n      cssSelectorFooter(\n        dom.maybe(isChanged, () =>\n          primaryButton(t(\"OK\"),\n            dom.on(\"click\", () => close(true)),\n            testId(\"trigger-deps-apply\"),\n          ),\n        ),\n        basicButton(dom.text(use => use(isChanged) ? t(\"Cancel\") : t(\"Close\")),\n          dom.on(\"click\", () => close(false)),\n          testId(\"trigger-deps-cancel\"),\n        ),\n      ),\n    ),\n  );\n}\n\nconst cssIndentedRow = styled(cssRow, `\n  margin-left: 40px;\n`);\n\nconst cssSelectSummary = styled(\"div\", `\n  flex: 1 1 0px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n\n  &:empty::before {\n    content: \"Select fields\";\n    color: ${theme.selectButtonPlaceholderFg};\n  }\n`);\n\nconst cssSelectorMenu = styled(cssMenu, `\n  display: flex;\n  flex-direction: column;\n  max-height: calc(max(300px, 95vh - 300px));\n  max-width: 400px;\n  padding-bottom: 0px;\n`);\n\nconst cssItemsList = styled(shadowScroll, `\n  flex: auto;\n  min-height: 80px;\n  border-top: 1px solid ${theme.menuBorder};\n  border-bottom: 1px solid ${theme.menuBorder};\n  margin-top: 8px;\n  padding: 8px 0;\n`);\n\nconst cssItemsFixed = styled(\"div\", `\n  flex: none;\n`);\n\nconst cssSelectorItem = styled(cssMenuItem, `\n  justify-content: flex-start;\n  align-items: center;\n  display: flex;\n  padding: 8px 16px;\n  white-space: nowrap;\n`);\n\nconst cssSelectorNote = styled(\"span\", `\n  color: ${theme.lightText};\n`);\n\nconst cssSelectorFooter = styled(cssSelectorItem, `\n  justify-content: space-between;\n  margin: 3px 0;\n`);\n"
  },
  {
    "path": "app/client/ui/UserImage.ts",
    "content": "import { hashCode } from \"app/client/lib/hashUtils\";\nimport { colors } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { UserProfile } from \"app/common/LoginSessionAPI\";\nimport { components } from \"app/common/ThemePrefs\";\n\nimport { dom, DomElementArg, styled } from \"grainjs\";\n\nexport type User = Partial<UserProfile> | \"exampleUser\" | \"addUser\" | null;\n\nexport type Size = \"small\" | \"medium\" | \"large\";\n\n/**\n * Returns a DOM element showing a circular icon with a user's picture, or the user's initials if\n * picture is missing. Also varies the color of the circle when using initials.\n */\nexport function createUserImage(user: User, size: Size, ...args: DomElementArg[]): HTMLElement {\n  return cssUserImage(\n    cssUserImage.cls(\"-\" + size),\n    ...(function* () {\n      if (user === \"exampleUser\") {\n        yield [cssUserImage.cls(\"-example\"), cssExampleUserIcon(\"EyeShow\")];\n      } else if (user === \"addUser\") {\n        yield [cssUserImage.cls(\"-add\"), cssUserIcon(\"AddUser\")];\n      } else if (!user || user.anonymous) {\n        yield cssUserImage.cls(\"-anon\");\n      } else {\n        if (user.picture) {\n          yield cssUserPicture({ src: user.picture }, dom.on(\"error\", (ev, el) => dom.hideElem(el, true)));\n        }\n        yield dom.style(\"background-color\", pickColor(user));\n        const initials = getInitials(user);\n        if (initials.length > 1) {\n          yield cssUserImage.cls(\"-reduced\");\n        }\n        yield initials;\n      }\n    })(),\n    ...args,\n  );\n}\n\n/**\n * Extracts initials from a user, e.g. a FullUser. E.g. \"Foo Bar\" is turned into \"FB\", and\n * \"foo@example.com\" into just \"f\".\n *\n * Exported for testing.\n */\nexport function getInitials(user: Partial<UserProfile>) {\n  const source = (user.name?.trim()) || (user.email?.trim()) || \"\";\n  return source.split(/\\s+/, 2).map(p => p.slice(0, 1)).join(\"\");\n}\n\n/**\n * Hashes the username to return a color.\n */\nfunction pickColor(user: Partial<UserProfile>): string {\n  let c = hashCode(user.name + \":\" + user.email) % someColors.length;\n  if (c < 0) { c += someColors.length; }\n  return someColors[c];\n}\n\n// These mostly come from https://clrs.cc/\nconst someColors = [\n  \"#0B437D\",\n  \"#0074D9\",\n  \"#7FDBFF\",\n  \"#39CCCC\",\n  \"#16DD6D\",\n  \"#2ECC40\",\n  \"#16B378\",\n  \"#EFCC00\",\n  \"#FF851B\",\n  \"#FF4136\",\n  \"#85144b\",\n  \"#F012BE\",\n  \"#B10DC9\",\n];\n\nexport const cssUserImage = styled(\"div\", `\n  --text-color: white;\n  position: relative;\n  text-align: center;\n  text-transform: uppercase;\n  user-select: none;\n  -moz-user-select: none;\n  color: var(--text-color);\n  border-radius: 100px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  --border-size: 0px;\n  width: calc(var(--icon-size, 24px) - var(--border-size));\n  height: calc(var(--icon-size, 24px) - var(--border-size));\n  flex-shrink: 0;\n  flex-grow: 0;\n  line-height: 1em;\n\n  background-color: ${components.topHeaderBg};\n\n  &-small {\n    --icon-size: 24px;\n    font-size: 13.5px;\n    --reduced-font-size: 12px;\n  }\n  &-medium {\n    --icon-size: 32px;\n    font-size: 18px;\n    --reduced-font-size: 16px;\n  }\n  &-border {\n    --border-size: 2px;\n  }\n  &-large {\n    --icon-size: 40px;\n    font-size: 22.5px;\n    --reduced-font-size: 20px;\n  }\n  &-anon {\n    border: 1px solid ${colors.slate};\n    color: ${colors.slate};\n  }\n  &-anon::before {\n    content: \"?\"\n  }\n  &-reduced {\n    font-size: var(--reduced-font-size);\n  }\n  &-square {\n    border-radius: 0px;\n  }\n  &-example, &-add {\n    background-color: ${colors.slate};\n    border: 1px solid ${colors.slate};\n  }\n  /* make sure the kb highlight is on top of the image when used in app logo */\n  &-inAppLogo {\n    z-index: -1;\n  }\n`);\n\nconst cssUserPicture = styled(\"img\", `\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  background-color: ${components.menuBg};\n  border-radius: inherit;\n  box-sizing: content-box;    /* keep the border outside of the size of the image */\n`);\n\nconst cssUserIcon = styled(icon, `\n  background-color: white;\n`);\n\nconst cssExampleUserIcon = styled(cssUserIcon, `\n  width: 45px;\n  height: 45px;\n  transform: scaleY(0.75);\n`);\n"
  },
  {
    "path": "app/client/ui/UserItem.ts",
    "content": "import { colors, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\n\nimport { input, styled } from \"grainjs\";\nimport { cssMenuItem } from \"popweasel\";\n\n// Styled elements used for rendering a user, e.g. in the UserManager, Billing, etc.\n// There is a general structure, but enough small variation that there is no helper at this point.\n//\n//   cssMemberListItem(\n//     cssMemberImage(\n//       createUserImage(getFullUser(member), 'large')\n//     ),\n//     cssMemberText(\n//       cssMemberPrimary(NAME),\n//       cssMemberSecondary(EMAIL),\n//       cssMemberType(DESCRIPTION),\n//     )\n//   )\n\nexport const cssMemberListItem = styled(\"div\", `\n  display: flex;\n  width: 460px;\n  min-height: 64px;\n  margin: 0 auto;\n  padding: 12px 0;\n`);\n\nexport const cssMemberImage = styled(\"div\", `\n  width: 40px;\n  height: 40px;\n  margin: 0 4px;\n  border-radius: 20px;\n  background-color: ${colors.lightGreen};\n  background-size: cover;\n\n  .${cssMemberListItem.className}-removed & {\n    opacity: 0.4;\n  }\n`);\n\nexport const cssMemberText = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  margin: 2px 12px;\n  flex: 1 1 0;\n  min-width: 0px;\n  font-size: ${vars.mediumFontSize};\n\n  .${cssMemberListItem.className}-removed & {\n    opacity: 0.4;\n  }\n`);\n\nexport const cssMemberPrimary = styled(\"span\", `\n  font-weight: bold;\n  color: ${theme.text};\n  padding: 2px 0;\n\n  .${cssMenuItem.className}-sel & {\n    color: ${theme.menuItemSelectedFg};\n  }\n`);\n\nexport const cssMemberSecondary = styled(\"span\", `\n  color: ${theme.lightText};\n  /* the following just undo annoying bootstrap styles that apply to all labels */\n  margin: 0px;\n  font-weight: normal;\n  padding: 2px 0;\n  white-space: nowrap;\n\n  .${cssMenuItem.className}-sel & {\n    color: ${theme.menuItemSelectedFg};\n  }\n`);\n\nexport const cssMemberType = styled(\"span\", `\n  color: ${theme.lightText};\n  /* the following just undo annoying bootstrap styles that apply to all labels */\n  margin: 0px;\n  font-weight: normal;\n  padding: 2px 0;\n  white-space: nowrap;\n\n  .${cssMenuItem.className}-sel & {\n    color: ${theme.menuItemSelectedFg};\n  }\n`);\n\nexport const cssMemberTypeProblem = styled(\"span\", `\n  color: ${theme.errorText};\n  /* the following just undo annoying bootstrap styles that apply to all labels */\n  margin: 0px;\n  font-weight: normal;\n  padding: 2px 0;\n  white-space: nowrap;\n\n  .${cssMenuItem.className}-sel & {\n    color: ${theme.menuItemSelectedFg};\n  }\n`);\n\nexport const cssMemberBtn = styled(\"div\", `\n  width: 16px;\n  height: 16px;\n  cursor: pointer;\n\n  &-disabled {\n    opacity: 0.3;\n    cursor: default;\n  }\n`);\n\nexport const cssRemoveIcon = styled(icon, `\n  background-color: ${theme.lightText};\n  margin: 12px 0;\n`);\n\nexport const cssEmailInputContainer = styled(\"div\", `\n  position: relative;\n  display: flex;\n  height: 42px;\n  padding: 0 3px;\n  margin: 16px 63px;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  font-size: ${vars.mediumFontSize};\n  outline: none;\n\n  &-green {\n    border: 1px solid ${theme.inputValid};\n  }\n`);\n\nexport const cssEmailInput = styled(input, `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  flex: 1 1 0;\n  font-size: ${vars.mediumFontSize};\n  font-family: ${vars.fontFamily};\n  outline: none;\n  border: none;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\nexport const cssMailIcon = styled(icon, `\n  margin: 12px 8px 12px 13px;\n  background-color: ${theme.lightText};\n`);\n"
  },
  {
    "path": "app/client/ui/UserManager.ts",
    "content": "/**\n * This module exports a UserManager component, consisting of a list of emails, each with an\n * associated role (See app/common/roles), and a way to change roles, and add or remove new users.\n * The component is instantiated as a modal with a confirm button to pass changes to the server.\n *\n * It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions.\n */\n\nimport { ACIndexImpl, normalizeText } from \"app/client/lib/ACIndex\";\nimport { ACUserItem, buildACMemberEmail } from \"app/client/lib/ACUserManager\";\nimport { copyToClipboard } from \"app/client/lib/clipboardUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { markdown } from \"app/client/lib/markdown\";\nimport { buildMultiUserManagerModal } from \"app/client/lib/MultiUserManager\";\nimport { setTestState } from \"app/client/lib/testState\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { DocPageModel } from \"app/client/models/DocPageModel\";\nimport { reportError } from \"app/client/models/errors\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { IEditableMember, IMemberSelectOption, IOrgMemberSelectOption,\n  Resource } from \"app/client/models/UserManagerModel\";\nimport { UserManagerModel, UserManagerModelImpl } from \"app/client/models/UserManagerModel\";\nimport { getResourceParent, ResourceType } from \"app/client/models/UserManagerModel\";\nimport { shadowScroll } from \"app/client/ui/shadowScroll\";\nimport { hoverTooltip, ITooltipControl, showTransientTooltip, withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { createUserImage } from \"app/client/ui/UserImage\";\nimport { cssMemberBtn, cssMemberImage, cssMemberListItem,\n  cssMemberPrimary, cssMemberSecondary, cssMemberText, cssMemberType, cssMemberTypeProblem,\n  cssRemoveIcon } from \"app/client/ui/UserItem\";\nimport { basicButton, bigBasicButton, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { mediaXSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { menu, menuItem, menuText } from \"app/client/ui2018/menus\";\nimport { confirmModal, cssAnimatedModal, cssModalBody, cssModalButtons, cssModalTitle,\n  IModalControl, modal } from \"app/client/ui2018/modals\";\nimport { normalizeEmail } from \"app/common/emails\";\nimport { commonUrls, isOrgInPathOnly } from \"app/common/gristUrls\";\nimport { capitalizeFirstWord, isAffirmative, isLongerThan } from \"app/common/gutil\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport * as roles from \"app/common/roles\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { Organization, PermissionData, UserAPI } from \"app/common/UserAPI\";\n\nimport { Computed, Disposable, dom, DomElementArg, IDomArgs, Observable, observable, styled } from \"grainjs\";\nimport pick from \"lodash/pick\";\n\nconst t = makeT(\"UserManager\");\n\ntype ACCEPTED_WARNINGS = \"self-removal\" | \"public-sharing\";\n\nexport interface IUserManagerOptions {\n  permissionData: Promise<PermissionData>;\n  activeUser: FullUser | null;\n  resourceType: ResourceType;\n  resourceId: string | number;\n  resource?: Resource;\n  docPageModel?: DocPageModel;\n  appModel?: AppModel;  // If present, we offer access to a nested team-level dialog.\n  linkToCopy?: string;\n  reload?: () => Promise<PermissionData>;\n  onSave?: (personal: boolean) => Promise<unknown>;\n  prompt?: {  // If set, user manager should open with this email filled in and ready to go.\n    email: string;\n  };\n  showAnimation?: boolean; // If true, animates opening of the modal. Defaults to false.\n  isReadonly?: boolean;    // If true, show a view-only sharing UI. Used for Admin Controls.\n}\n\n// Returns an instance of UserManagerModel given IUserManagerOptions. Makes the async call for the\n// required properties of the options.\nasync function getModel(options: IUserManagerOptions): Promise<UserManagerModelImpl> {\n  const permissionData = await options.permissionData;\n  return new UserManagerModelImpl(\n    permissionData, options.resourceType,\n    pick(options, [\"activeUser\", \"reload\", \"appModel\", \"docPageModel\", \"resource\"]),\n  );\n}\n\n/**\n * Public interface for creating the UserManager in the app. Creates a modal that includes\n * the UserManager menu with save and cancel buttons.\n */\nexport function showUserManagerModal(userApi: UserAPI, options: IUserManagerOptions) {\n  const modelObs: Observable<UserManagerModel | null | \"slow\"> = observable(null);\n\n  async function onConfirm(ctl: IModalControl, acceptedWarnings = new Set<ACCEPTED_WARNINGS>()) {\n    const model = modelObs.get();\n    const config = getGristConfig();\n    if (!model || model === \"slow\") {\n      ctl.close();\n      return;\n    }\n    const tryToSaveChanges = async () => {\n      // Save changes to the server, reporting any errors to the app.\n      try {\n        const isAnythingChanged = model.isAnythingChanged.get();\n        if (isAnythingChanged) {\n          await model.save(userApi, options.resourceId);\n        }\n        await options.onSave?.(model.isPersonal);\n        ctl.close();\n        if (model.isPersonal && isAnythingChanged) {\n          // the only thing an individual without ACL_EDIT rights can do is\n          // remove themselves - so reload.\n          window.location.reload();\n        }\n      } catch (err) {\n        reportError(err);\n      }\n    };\n    const resourceType = resourceName(model.resourceType);\n    if (model.isSelfRemoved.get() && !acceptedWarnings.has(\"self-removal\")) {\n      const resourceType = resourceName(model.resourceType);\n      confirmModal(\n        t(`You are about to remove your own access to this {{resourceType}}`, { resourceType }),\n        t(\"Remove my access\"),\n        () => onConfirm(ctl, new Set([...acceptedWarnings, \"self-removal\"])),\n        {\n          explanation: (\n            t(\"Once you have removed your own access, \\\nyou will not be able to get it back without assistance \\\nfrom someone else with sufficient access to the {{resourceType}}.\", { resourceType })\n          ),\n        },\n      );\n      return;\n    }\n    if (isAffirmative(config.warnBeforeSharingPublicly) && model.goingToSharePublicly() &&\n      !acceptedWarnings.has(\"public-sharing\")) {\n      confirmModal(\n        t(\"Verify your sensitive data before sharing publicly\"),\n        t(\"Share it publicly\"),\n        () => onConfirm(ctl, new Set([...acceptedWarnings, \"public-sharing\"])),\n        {\n          explanation: (\n            markdown(t(\"Your {{resourceType}} will be accessible to anyone with the link, \\\nwhether shared directly or found through a search engine. \\n \\\nEnsure that your {{resourceType}} does not contain sensitive data before sharing.\",\n            { resourceType }))),\n        },\n      );\n      return;\n    }\n    tryToSaveChanges().catch(reportError);\n  }\n\n  // Get the model and assign it to the observable. Report errors to the app.\n  const waitPromise = getModel(options)\n    .then(model => modelObs.set(model))\n    .catch(reportError);\n\n  isLongerThan(waitPromise, 400).then(slow => slow && modelObs.set(\"slow\")).catch(() => {});\n\n  return buildUserManagerModal(modelObs, onConfirm, options);\n}\n\nfunction buildUserManagerModal(\n  modelObs: Observable<UserManagerModel | null | \"slow\">,\n  onConfirm: (ctl: IModalControl) => Promise<void>,\n  options: IUserManagerOptions,\n) {\n  return modal(ctl => [\n    // We set the padding to 0 since the body scroll shadows extend to the edge of the modal.\n    { style: \"padding: 0;\" },\n    options.showAnimation ? dom.cls(cssAnimatedModal.className) : null,\n    dom.domComputed(modelObs, (model) => {\n      if (!model) { return null; }\n      if (model === \"slow\") { return cssSpinner(loadingSpinner()); }\n\n      const cssBody = model.isPersonal ? cssAccessDetailsBody : cssUserManagerBody;\n      return [\n        cssTitle(\n          renderTitle(options.resourceType, options.resource, model.isPersonal),\n          (options.resourceType === \"document\" && (!model.isPersonal || model.isPublicMember) ?\n            makeCopyBtn(options.linkToCopy, cssCopyBtn.cls(\"-header\")) :\n            null\n          ),\n          testId(\"um-header\"),\n        ),\n        cssModalBody(\n          cssBody(\n            new UserManager(\n              model,\n              pick(options, \"linkToCopy\", \"docPageModel\", \"appModel\", \"prompt\", \"resource\", \"isReadonly\"),\n            ).buildDom(),\n          ),\n        ),\n        cssModalButtons(\n          { style: \"margin: 32px 64px; display: flex;\" },\n          (model.isPublicMember || options.isReadonly ? null :\n            bigPrimaryButton(t(\"Confirm\"),\n              dom.boolAttr(\"disabled\", use => !use(model.isAnythingChanged)),\n              dom.on(\"click\", () => onConfirm(ctl)),\n              testId(\"um-confirm\"),\n            )\n          ),\n          bigBasicButton(\n            model.isPublicMember || options.isReadonly ? t(\"Close\") : t(\"Cancel\"),\n            dom.on(\"click\", () => ctl.close()),\n            testId(\"um-cancel\"),\n          ),\n          (model.resourceType === \"document\" && model.gristDoc && !model.isPersonal ?\n            withInfoTooltip(\n              cssLink({ href: urlState().makeUrl({ docPage: \"acl\" }) },\n                dom.text(use => use(model.isAnythingChanged) ? t(\"Save & \") : \"\"),\n                t(\"Open Access Rules\"),\n                dom.on(\"click\", (ev) => {\n                  ev.preventDefault();\n                  return onConfirm(ctl).then(() => urlState().pushUrl({ docPage: \"acl\" }));\n                }),\n                testId(\"um-open-access-rules\"),\n              ),\n              \"openAccessRules\",\n              { domArgs: [cssAccessLink.cls(\"\")] },\n            ) :\n            null\n          ),\n          testId(\"um-buttons\"),\n        ),\n      ];\n    }),\n  ]);\n}\n\n/**\n * See module documentation for overview.\n *\n * Usage:\n *    const um = new UserManager(model);\n *    um.buildDom();\n *\n * Exported for tests.\n */\nexport class UserManager extends Disposable {\n  private _dom: HTMLDivElement;\n\n  constructor(\n    private _model: UserManagerModel,\n    private _options: {\n      linkToCopy?: string,\n      docPageModel?: DocPageModel,\n      appModel?: AppModel,\n      prompt?: { email: string },\n      resource?: Resource,\n      isReadonly?: boolean,\n    }) {\n    super();\n  }\n\n  public buildDom() {\n    if (this._model.isPublicMember) {\n      return this._buildSelfPublicAccessDom();\n    }\n\n    if (this._model.hasOverview) {\n      return this._buildOverviewAccessDom();\n    }\n\n    if (this._model.isPersonal) {\n      return this._buildSelfAccessDom();\n    }\n\n    const acMemberEmail = this.autoDispose(new ACMemberEmail(\n      this._onAdd.bind(this),\n      this._model.membersEdited.get(),\n      this._options.prompt,\n    ));\n\n    return [\n      ...(this._options.isReadonly ? [cssOptionRow()] : [\n        acMemberEmail.buildDom(),\n        this._buildOptionsDom(),\n      ]),\n      this._dom = shadowScroll(\n        testId(\"um-members\"),\n        this._buildPublicAccessMember(),\n        dom.forEach(this._model.membersEdited, member => this._buildMemberDom({ member })),\n      ),\n    ];\n  }\n\n  private _onAddOrEdit(email: string, role: roles.NonGuestRole) {\n    const members = this._model.membersEdited.get();\n    const maybeMember = members.find(m => normalizeEmail(m.email) === email);\n    if (maybeMember) {\n      maybeMember.access.set(role);\n    } else {\n      this._onAdd(email, role);\n    }\n  }\n\n  private _onAdd(email: string, role: roles.NonGuestRole) {\n    this._model.add(email, role);\n    // Make sure the entry we have just added is actually visible - confusing if not.\n    Array.from(this._dom.querySelectorAll(\".member-email\"))\n      .find(el => el.textContent === email)\n      ?.scrollIntoView();\n  }\n\n  private _buildOptionsDom(): Element {\n    const publicMember = this._model.publicMember;\n    let tooltipControl: ITooltipControl | undefined;\n    return dom(\"div\",\n      cssOptionRowMultiple(\n        icon(\"AddUser\"),\n        cssLabel(t(\"Invite multiple\")),\n        dom.on(\"click\", _ev => buildMultiUserManagerModal(\n          this,\n          this._model,\n          (email, role) => {\n            this._onAddOrEdit(email, role);\n          },\n        )),\n      ),\n      cssOptionRow(\n        // TODO: Consider adding a tooltip explaining inheritance. A brief text caption may\n        // be used to fill whitespace in org UserManager.\n        this._model.isOrg ? null : dom(\"span\", { style: `float: left;` },\n          dom(\"span\", t(\"Inherit access: \")),\n          this._inheritRoleSelector(),\n        ),\n        publicMember ? dom(\"span\", { style: `float: right;` },\n          cssSmallPublicMemberIcon(\"PublicFilled\"),\n          dom(\"span\", t(\"Public access: \")),\n          cssOptionBtn(\n            menu(() => {\n              tooltipControl?.close();\n              return [\n                menuItem(() => publicMember.access.set(roles.VIEWER), t(\"On\"), testId(`um-public-option`)),\n                menuItem(() => publicMember.access.set(null), t(\"Off\"),\n                  // Disable null access if anonymous access is inherited.\n                  dom.cls(\"disabled\", use => use(publicMember.inheritedAccess) !== null),\n                  testId(`um-public-option`),\n                ),\n                // If the 'Off' setting is disabled, show an explanation.\n                dom.maybe(use => use(publicMember.inheritedAccess) !== null, () => menuText(\n                  t(`Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.`,\n                    { parent: getResourceParent(this._model.resourceType) },\n                  ))),\n              ];\n            }),\n            dom.text(use => use(publicMember.effectiveAccess) ? t(\"On\") : t(\"Off\")),\n            cssCollapseIcon(\"Collapse\"),\n            testId(\"um-public-access\"),\n          ),\n          hoverTooltip((ctl) => {\n            tooltipControl = ctl;\n            return t(\"Allow anyone with the link to open.\");\n          }),\n        ) : null,\n      ),\n    );\n  }\n\n  // Build a single member row.\n  private _buildMemberDom({ member, readonly}: { member: IEditableMember; readonly?: boolean; }) {\n    const disableRemove = Computed.create(null, (use) => {\n      if (this._options.isReadonly || readonly) {\n        return true;\n      }\n      if (this._model.isPersonal) {\n        return !member.origAccess;\n      }\n      return Boolean(this._model.isActiveUser(member) || use(member.inheritedAccess));\n    });\n    return dom(\"div\",\n      dom.autoDispose(disableRemove),\n      dom.maybe(use => use(member.effectiveAccess) && use(member.effectiveAccess) !== roles.GUEST, () =>\n        cssMemberListItem(\n          cssMemberListItem.cls(\"-removed\", member.isRemoved),\n          cssMemberImage(\n            createUserImage(getFullUser(member), \"large\"),\n          ),\n          cssMemberText(\n            cssMemberPrimary(\n              member.name || member.email,\n              member.email ? dom.cls(\"member-email\") : null,\n              testId(\"um-member-name\"),\n            ),\n            !member.name ? null : cssMemberSecondary(\n              member.email, dom.cls(\"member-email\"), testId(\"um-member-email\"),\n            ),\n            (this._model.isPersonal ?\n              this._buildSelfAnnotationDom(member) :\n              this._buildAnnotationDom(member)\n            ),\n          ),\n          member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess,\n            member.inheritedAccess, this._model.isActiveUser(member)),\n          // Only show delete buttons when editing the org users or when a user is being newly\n          // added to any resource. In workspace/doc UserManager instances we want to see all the\n          // users in the org, whether or not they have access to the resource of interest. They may\n          // be denied access via the role dropdown.\n          // Show the undo icon when an item has been removed but its removal has not been saved to\n          // the server.\n          cssMemberBtn(\n            // Button icon.\n            member.isRemoved ? cssUndoIcon(\"Undo\", testId(\"um-member-undo\")) :\n              cssRemoveIcon(\"Remove\", testId(\"um-member-delete\")),\n            cssMemberBtn.cls(\"-disabled\", disableRemove),\n            // Click handler.\n            dom.on(\"click\", () => disableRemove.get() ||\n              (member.isRemoved ? this._model.add(member.email, member.access.get()) :\n                this._model.remove(member))),\n          ),\n          testId(\"um-member\"),\n        ),\n      ),\n    );\n  }\n\n  // Build an annotation for a single member in the Manage Users dialog.\n  private _buildAnnotationDom(member: IEditableMember) {\n    return dom.domComputed(this._model.annotations, (annotations) => {\n      const annotation = annotations.users.get(member.email);\n      if (!annotation) { return null; }\n      if (annotation.isSupport) {\n        return cssMemberType(t(\"Grist support\"));\n      }\n      if (annotation.isMember && annotations.hasTeam) {\n        return cssMemberType(t(\"Team member\"));\n      }\n      const collaborator = annotations.hasTeam ? t(\"guest\") : t(\"free collaborator\");\n      const limit = annotation.collaboratorLimit;\n      if (!limit?.top) { return null; }\n      const elements: HTMLSpanElement[] = [];\n      if (limit.at <= limit.top) {\n        elements.push(cssMemberType(\n          t(`{{limitAt}} of {{limitTop}} {{collaborator}}s`, { limitAt: limit.at, limitTop: limit.top, collaborator })),\n        );\n      } else {\n        elements.push(cssMemberTypeProblem(\n          t(`{{collaborator}} limit exceeded`, { collaborator: capitalizeFirstWord(collaborator) })),\n        );\n      }\n      if (annotations.hasTeam) {\n        // Add a link for adding a member. For a doc, streamline this so user can make\n        // the change and continue seamlessly.\n        // TODO: streamline for workspaces.\n        elements.push(cssLink(\n          { href: urlState().makeUrl({ manageUsers: true }) },\n          dom.on(\"click\", (e) => {\n            if (this._options.appModel) {\n              e.preventDefault();\n              manageTeam(this._options.appModel,\n                () => this._model.reloadAnnotations(),\n                { email: member.email }).catch(reportError);\n            }\n          }),\n          t(`Add {{member}} to your team`, { member: member.name || t(\"member\") })));\n      } else if (limit.at >= limit.top) {\n        elements.push(cssLink({ href: commonUrls.plans, target: \"_blank\" },\n          t(\"Create a team to share with more people\")));\n      }\n      return elements;\n    });\n  }\n\n  // Build an annotation for the current user in the Access Details dialog.\n  private _buildSelfAnnotationDom(user: IEditableMember) {\n    return dom.domComputed(this._model.annotations, (annotations) => {\n      const annotation = annotations.users.get(user.email);\n      if (!annotation) { return null; }\n\n      let memberType: string;\n      if (annotation.isSupport) {\n        memberType = t(\"Grist support\");\n      } else if (annotation.isMember && annotations.hasTeam) {\n        memberType = t(\"Team member\");\n      } else if (annotations.hasTeam) {\n        memberType = t(\"Outside collaborator\");\n      } else {\n        memberType = t(\"Collaborator\");\n      }\n\n      return cssMemberType(memberType, testId(\"um-member-annotation\"));\n    });\n  }\n\n  private _buildPublicAccessMember() {\n    const publicMember = this._model.publicMember;\n    if (!publicMember) { return null; }\n    return dom(\"div\",\n      dom.maybe(use => Boolean(use(publicMember.effectiveAccess)), () =>\n        cssMemberListItem(\n          cssPublicMemberIcon(\"PublicFilled\"),\n          cssMemberText(\n            cssMemberPrimary(t(\"Public access\")),\n            cssMemberSecondary(t(\"Anyone with link \"), makeCopyBtn(this._options.linkToCopy)),\n          ),\n          this._memberRoleSelector(publicMember.effectiveAccess, publicMember.inheritedAccess, false,\n            this._model.publicUserSelectOptions,\n          ),\n          cssMemberBtn(\n            cssMemberBtn.cls(\"-disabled\", this._options.isReadonly),\n            cssRemoveIcon(\"Remove\", testId(\"um-member-delete\")),\n            dom.on(\"click\", () => publicMember.access.set(null)),\n          ),\n          testId(\"um-public-member\"),\n        ),\n      ),\n    );\n  }\n\n  private _buildSelfPublicAccessDom() {\n    const accessValue = this._options.resource?.access;\n    const accessLabel = this._model.publicUserSelectOptions\n      .find(opt => opt.value === accessValue)?.label;\n    const activeUser = this._model.activeUser;\n    const name = activeUser?.name ?? \"Anonymous\";\n\n    return dom(\"div\",\n      cssMemberListItem(\n        (!activeUser ?\n          cssPublicMemberIcon(\"PublicFilled\") :\n          cssMemberImage(createUserImage(activeUser, \"large\"))\n        ),\n        cssMemberText(\n          cssMemberPrimary(name, testId(\"um-member-name\")),\n          activeUser?.email ? cssMemberSecondary(activeUser.email) : null,\n          cssMemberPublicAccess(\n            dom(\"span\", t(\"Public access\"), testId(\"um-member-annotation\")),\n            cssPublicAccessIcon(\"PublicFilled\"),\n          ),\n        ),\n        cssRoleBtn(\n          accessLabel ?? t(\"Guest\"),\n          cssCollapseIcon(\"Collapse\"),\n          dom.cls(\"disabled\"),\n          testId(\"um-member-role\"),\n        ),\n        testId(\"um-member\"),\n      ),\n      testId(\"um-members\"),\n    );\n  }\n\n  private _buildSelfAccessDom(...args: IDomArgs<HTMLDivElement>) {\n    const meComputed = Computed.create(this,\n      use => use(this._model.membersEdited).find(m => m.id === this._model.activeUser?.id));\n    return dom(\"div\",\n      dom.autoDispose(meComputed),\n      dom.domComputed(meComputed, me => me ? this._buildMemberDom({ member: me }) : null),\n      testId(\"um-members\"),\n      ...args,\n    );\n  }\n\n  private _buildOverviewAccessDom() {\n    const othersComputed = Computed.create(this,\n      use => use(this._model.membersEdited).filter(m => m.id !== this._model.activeUser?.id));\n    return this._buildSelfAccessDom(\n      dom.autoDispose(othersComputed),\n      dom.domComputed(othersComputed, others => dom(\"div\",\n        !others.length ? null : [\n          cssAccessOverview(t(\"Access overview\")),\n          cssAccessOverviewList(\n            dom.forEach(others, member => this._buildMemberDom({ member, readonly: true })),\n          ),\n          testId(\"um-access-overview\"),\n        ],\n      )),\n    );\n  }\n\n  // Returns a div containing a button that opens a menu to choose between roles.\n  private _memberRoleSelector(\n    role: Observable<string | null>,\n    inherited: Observable<roles.Role | null>,\n    isActiveUser: boolean,\n    allRolesOverride?: IOrgMemberSelectOption[],\n  ) {\n    const allRoles = allRolesOverride ||\n      (this._model.isOrg ? this._model.orgUserSelectOptions : this._model.userSelectOptions);\n    return cssRoleBtn(\n      // Don't include the menu if we're only showing access details for the current user.\n      this._model.isPersonal ? null : menu(() => [\n        dom.forEach(allRoles, _role =>\n          // The active user should be prevented from changing their own role.\n          menuItem(() => isActiveUser || role.set(_role.value), _role.label,\n            // Indicate which option is inherited, if any.\n            dom.text(use => use(inherited) && (use(inherited) === _role.value) &&\n              !isActiveUser ? \" (inherited)\" : \"\"),\n            // Disable everything providing less access than the inherited access\n            dom.cls(\"disabled\", use =>\n              roles.getStrongestRole(_role.value, use(inherited)) !== _role.value),\n            testId(`um-role-option`),\n          ),\n        ),\n        // If the user's access is inherited, give an explanation on how to change it.\n        isActiveUser ? menuText(t(`User may not modify their own access.`)) : null,\n        // If the user's access is inherited, give an explanation on how to change it.\n        dom.maybe(use => use(inherited) && !isActiveUser, () => menuText(\n          t(`User inherits permissions from {{parent}}. To remove, \\\nset 'Inherit access' option to 'None'.`, { parent: getResourceParent(this._model.resourceType) }))),\n        // If the user is a guest, give a description of the guest permission.\n        dom.maybe(use => !this._model.isOrg && use(role) === roles.GUEST, () => menuText(\n          t(`User has view access to {{resource}} resulting from manually-set access \\\nto resources inside. If removed here, this user will lose access to resources inside.`,\n          { resource: this._model.resourceType }))),\n        this._model.isOrg ? menuText(t(`No default access allows access to be \\\ngranted to individual documents or workspaces, rather than the full team site.`)) : null,\n      ]),\n      dom.text((use) => {\n        // Get the label of the active role. Note that the 'Guest' role is assigned when the role\n        // is not found because it is not included as a selection.\n        const activeRole = allRoles.find((_role: IOrgMemberSelectOption) => use(role) === _role.value);\n        return activeRole ? activeRole.label : t(\"Guest\");\n      }),\n      cssCollapseIcon(\"Collapse\"),\n      this._options.isReadonly || this._model.isPersonal ? dom.cls(\"disabled\") : null,\n      testId(\"um-member-role\"),\n    );\n  }\n\n  // Builds the max inherited role selection button and menu.\n  private _inheritRoleSelector() {\n    const role = this._model.maxInheritedRole;\n    const allRoles = this._model.inheritSelectOptions;\n    return cssOptionBtn(\n      menu(() => [\n        dom.forEach(allRoles, _role =>\n          menuItem(() => role.set(_role.value), _role.label,\n            testId(`um-role-option`),\n          ),\n        ),\n      ]),\n      dom.text((use) => {\n        // Get the label of the active role.\n        const activeRole = allRoles.find((_role: IMemberSelectOption) => use(role) === _role.value);\n        return activeRole ? activeRole.label : \"\";\n      }),\n      cssCollapseIcon(\"Collapse\"),\n      testId(\"um-max-inherited-role\"),\n    );\n  }\n}\n\nfunction getUserItem(member: IEditableMember): ACUserItem {\n  return {\n    value: member.email,\n    label: member.email,\n    cleanText: normalizeText(member.email),\n    email: member.email,\n    name: member.name,\n    picture: member?.picture,\n    id: member.id,\n  };\n}\n\n/**\n * Represents the widget that allows typing in an email and adding it.\n */\nexport class ACMemberEmail extends Disposable {\n  private _email = this.autoDispose(observable<string>(\"\"));\n\n  constructor(\n    private _onAdd: (email: string, role: roles.NonGuestRole) => void,\n    private _members: IEditableMember[],\n    private _prompt?: { email: string },\n  ) {\n    super();\n    if (_prompt) {\n      this._email.set(_prompt.email);\n    }\n  }\n\n  public buildDom() {\n    const acUserItem = this._members\n      // Only suggest team members in autocomplete.\n      .filter((member: IEditableMember) => member.isTeamMember)\n      .map((member: IEditableMember) => getUserItem(member));\n    const acIndex = new ACIndexImpl<ACUserItem>(acUserItem);\n\n    return buildACMemberEmail(this,\n      {\n        acIndex,\n        emailObs: this._email,\n        save: this._handleSave.bind(this),\n        prompt: this._prompt,\n      },\n      testId(\"um-member-new\"),\n    );\n  }\n\n  private _handleSave(selectedEmail: string) {\n    this._onAdd(selectedEmail, roles.VIEWER);\n  }\n}\n\n// Returns a new FullUser object from an IEditableMember.\nfunction getFullUser(member: IEditableMember): FullUser {\n  return {\n    id: member.id,\n    name: member.name,\n    email: member.email,\n    picture: member.picture,\n    locale: member.locale,\n  };\n}\n\n// Create a \"Copy Link\" button.\nfunction makeCopyBtn(linkToCopy: string | undefined, ...domArgs: DomElementArg[]) {\n  return linkToCopy && cssCopyBtn(cssCopyIcon(\"Copy\"), t(\"Copy link\"),\n    dom.on(\"click\", (ev, elem) => copyLink(elem, linkToCopy)),\n    testId(\"um-copy-link\"),\n    ...domArgs,\n  );\n}\n\n// Copy the current document link to clipboard, and notify the user with a transient popup near\n// the given element.\nasync function copyLink(elem: HTMLElement, link: string) {\n  await copyToClipboard(link);\n  setTestState({ clipboard: link });\n  showTransientTooltip(elem, t(\"Link copied to clipboard\"), { key: \"copy-doc-link\" });\n}\n\nasync function manageTeam(appModel: AppModel,\n  onSave?: () => Promise<void>,\n  prompt?: { email: string }) {\n  await urlState().pushUrl({ manageUsers: false });\n  const user = appModel.currentValidUser;\n  const currentOrg = appModel.currentOrg;\n  if (currentOrg) {\n    const api = appModel.api;\n    showUserManagerModal(api, {\n      permissionData: api.getOrgAccess(currentOrg.id),\n      activeUser: user,\n      resourceType: \"organization\",\n      resourceId: currentOrg.id,\n      resource: currentOrg,\n      onSave,\n      prompt,\n      showAnimation: true,\n    });\n  }\n}\n\nconst cssAccessDetailsBody = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  width: 600px;\n  font-size: ${vars.mediumFontSize};\n`);\n\nconst cssUserManagerBody = styled(cssAccessDetailsBody, `\n  height: 374px;\n  border-bottom: 1px solid ${theme.modalBorderDark};\n`);\n\nconst cssSpinner = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  margin: 32px;\n`);\n\nconst cssCopyBtn = styled(basicButton, `\n  border: none;\n  font-weight: normal;\n  padding: 0 8px;\n  &-header {\n    float: right;\n    margin-top: 8px;\n  }\n`);\n\nconst cssCopyIcon = styled(icon, `\n  margin-right: 4px;\n  margin-top: -2px;\n`);\n\nconst cssOptionRow = styled(\"div\", `\n  font-size: ${vars.mediumFontSize};\n  margin: 0 63px 23px 63px;\n`);\n\nconst cssOptionRowMultiple = styled(\"div\", `\n  margin: 0 63px 12px 63px;\n  font-size: ${vars.mediumFontSize};\n  display: flex;\n  cursor: pointer;\n  color: ${theme.controlFg};\n  --icon-color: ${theme.controlFg};\n\n  &:hover {\n    color: ${theme.controlHoverFg};\n    --icon-color: ${theme.controlHoverFg};\n  }\n`);\n\nconst cssLabel = styled(\"span\", `\n  margin-left: 4px;\n`);\n\nconst cssOptionBtn = styled(\"span\", `\n  display: inline-flex;\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.controlFg};\n  cursor: pointer;\n`);\n\nconst cssPublicMemberIcon = styled(icon, `\n  width: 40px;\n  height: 40px;\n  margin: 0 4px;\n  --icon-color: ${theme.accentIcon};\n`);\n\nconst cssSmallPublicMemberIcon = styled(cssPublicMemberIcon, `\n  width: 16px;\n  height: 16px;\n  top: -2px;\n`);\n\nconst cssPublicAccessIcon = styled(icon, `\n  --icon-color: ${theme.accentIcon};\n`);\n\nconst cssUndoIcon = styled(icon, `\n  --icon-color: ${theme.controlSecondaryFg};\n  margin: 12px 0;\n`);\n\nconst cssRoleBtn = styled(\"div\", `\n  display: flex;\n  justify-content: flex-end;\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.controlFg};\n  margin: 12px 24px;\n  cursor: pointer;\n\n  &.disabled {\n    opacity: 0.5;\n    cursor: default;\n  }\n`);\n\nconst cssCollapseIcon = styled(icon, `\n  margin-top: 1px;\n  background-color: ${theme.controlFg};\n`);\n\nconst cssAccessLink = styled(cssLink, `\n  align-self: center;\n  margin-left: auto;\n`);\n\nconst cssOrgName = styled(\"div\", `\n  font-size: ${vars.largeFontSize};\n`);\n\nconst cssOrgDomain = styled(\"span\", `\n  color: ${theme.accentText};\n`);\n\nconst cssAccessOverviewList = styled(\"div\", `\n  max-height: min(374px, calc(100vh - 374px));\n  overflow-y: auto;\n`);\n\nconst cssAccessOverview = styled(\"div\", `\n  font-size: 16px;\n  margin-left: 64px;\n  margin-bottom: 14px;\n  margin-top: 16px;\n  font-weight: 600;\n`);\n\nconst cssTitle = styled(cssModalTitle, `\n  margin: 40px 64px 0 64px;\n\n  @media ${mediaXSmall} {\n    & {\n      margin: 16px;\n    }\n  }\n`);\n\nconst cssMemberPublicAccess = styled(cssMemberSecondary, `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n`);\n\n// Render the UserManager title for `resourceType` (e.g. org as \"team site\").\nfunction renderTitle(resourceType: ResourceType, resource?: Resource, personal?: boolean) {\n  switch (resourceType) {\n    case \"organization\": {\n      if (personal) {\n        return t(\"Your role for this team site\");\n      }\n\n      function getOrgDisplay() {\n        if (!resource) {\n          return null;\n        }\n\n        const org = resource as Organization;\n        const gristConfig = getGristConfig();\n        const gristHomeHost = gristConfig.homeUrl ? new URL(gristConfig.homeUrl).host : \"\";\n        const baseDomain = gristConfig.baseDomain || gristHomeHost;\n        const orgDisplay = isOrgInPathOnly() ? `${baseDomain}/o/${org.domain}` : `${org.domain}${baseDomain}`;\n\n        return cssOrgName(`${org.name} (`, cssOrgDomain(orgDisplay), \")\");\n      }\n\n      return [t(\"Manage members of team site\"), getOrgDisplay()];\n    }\n    default: {\n      return personal ?\n        t(`Your role for this {{resourceType}}`, { resourceType }) :\n        t(`Invite people to {{resourceType}}`, { resourceType });\n    }\n  }\n}\n\n// Rename organization to team site.\nfunction resourceName(resourceType: ResourceType): string {\n  return resourceType === \"organization\" ? t(\"team site\") : resourceType;\n}\n"
  },
  {
    "path": "app/client/ui/ViewLayoutMenu.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { hooks } from \"app/client/Hooks\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { menuDivider, menuItemCmd, menuItemLink } from \"app/client/ui2018/menus\";\nimport { WidgetType } from \"app/common/widgetTypes\";\n\nimport { dom, UseCB } from \"grainjs\";\n\nconst t = makeT(\"ViewLayoutMenu\");\n\n/**\n * Returns a list of menu items for a view section.\n */\nexport function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: boolean) {\n  const viewInstance = viewSection.viewInstance.peek()!;\n  const gristDoc = viewInstance.gristDoc;\n\n  // get current row index from cursor\n  const cursorRow = viewInstance.cursor.rowIndex.peek();\n  // get row id from current data\n  // rowId can be string - it is wrongly typed in cursor and in viewData\n  const rowId = (cursorRow !== null ? viewInstance.viewData.getRowId(cursorRow) : null) as string | null | number;\n  const isAddRow = rowId === \"new\";\n\n  const contextMenu = [\n    menuItemCmd(allCommands.deleteRecords,\n      t(\"Delete record\"),\n      testId(\"section-delete-card\"),\n      dom.cls(\"disabled\", isReadonly || isAddRow)),\n    menuItemCmd(allCommands.copyLink,\n      t(\"Copy anchor link\"),\n      testId(\"section-card-link\"),\n    ),\n    menuDivider(),\n  ];\n\n  const viewRec = viewSection.view();\n  const isSinglePage = urlState().state.get().params?.style === \"singlePage\";\n\n  const sectionId = viewSection.table.peek().rawViewSectionRef.peek();\n  const anchorUrlState = viewInstance.getAnchorLinkForSection(sectionId);\n  anchorUrlState.hash!.popup = true;\n  const rawUrl = urlState().makeUrl(anchorUrlState);\n\n  // Count number of rendered sections on the viewLayout. Note that the layout might be detached or cleaned\n  // when we have an external section in the popup.\n  const expandedSectionCount = () => gristDoc.viewLayout?.layout.getAllLeafIds().length ?? 0 > 1;\n\n  const dontRemoveSection = () =>\n    !viewRec.getRowId() || viewRec.viewSections().peekLength <= 1 || isReadonly || expandedSectionCount() === 1;\n\n  const dontCollapseSection = () =>\n    dontRemoveSection() ||\n    (gristDoc.externalSectionId.get() === viewSection.getRowId()) ||\n    (gristDoc.maximizedSectionId.get() === viewSection.getRowId());\n\n  const dontDuplicateSection = (use: UseCB) =>\n    use(viewSection.isRaw) || use(viewSection.isVirtual) || isReadonly;\n\n  const showRawData = (use: UseCB) => {\n    return !use(viewSection.isRaw) &&// Don't show raw data if we're already in raw data.\n      !use(viewSection.isRecordCard) &&\n      !isSinglePage // Don't show raw data in single page mode.\n    ;\n  };\n\n  const isCard = (use: UseCB) => use(viewSection.widgetType) === WidgetType.Card;\n  const showCreateForm = (use: UseCB) => use(viewSection.widgetType) === WidgetType.Table &&\n    Boolean(viewRec.getRowId()) && !isReadonly;\n\n  return [\n    dom.maybe(isCard, () => contextMenu),\n    dom.maybe(showRawData,\n      () => menuItemLink(\n        { href: rawUrl }, t(\"Show raw data\"), testId(\"show-raw-data\"),\n        dom.on(\"click\", () => {\n          // Replace the current URL so that the back button works as expected (it navigates back from\n          // the current page).\n          urlState().pushUrl(anchorUrlState, { replace: true }).catch(reportError);\n        }),\n      ),\n    ),\n    menuItemCmd(allCommands.printSection, t(\"Print widget\"), testId(\"print-section\")),\n    menuItemLink(hooks.maybeModifyLinkAttrs({ href: gristDoc.getCsvLink(), target: \"_blank\", download: \"\" }),\n      t(\"Download as CSV\"), testId(\"download-section\")),\n    menuItemLink(hooks.maybeModifyLinkAttrs({ href: gristDoc.getXlsxActiveViewLink(), target: \"_blank\", download: \"\" }),\n      t(\"Download as XLSX\"), testId(\"download-section\")),\n    dom.maybe(use => [\"detail\", \"single\"].includes(use(viewSection.parentKey)), () =>\n      menuItemCmd(allCommands.editLayout, t(\"Edit card layout\"),\n        dom.cls(\"disabled\", isReadonly))),\n\n    dom.maybe(!isSinglePage, () => [\n      menuDivider(),\n      menuItemCmd(allCommands.viewTabOpen, t(\"Widget options\"), testId(\"widget-options\")),\n      menuItemCmd(allCommands.sortFilterTabOpen, t(\"Advanced sort & filter\"), dom.hide(viewSection.isRecordCard)),\n      menuItemCmd(allCommands.dataSelectionTabOpen, t(\"Data selection\"), dom.hide(viewSection.isRecordCard)),\n      menuItemCmd(allCommands.createForm, t(\"Create a form\"), dom.show(showCreateForm)),\n    ]),\n\n    menuDivider(dom.hide(viewSection.isRecordCard)),\n    dom.maybe(use => use(viewSection.parentKey) === \"custom\" && use(viewSection.hasCustomOptions), () =>\n      menuItemCmd(allCommands.openWidgetConfiguration, t(\"Open configuration\"),\n        testId(\"section-open-configuration\")),\n    ),\n    menuItemCmd(allCommands.collapseSection, t(\"Collapse widget\"),\n      dom.cls(\"disabled\", dontCollapseSection()),\n      dom.hide(viewSection.isRecordCard),\n      testId(\"section-collapse\")),\n    menuItemCmd(allCommands.duplicateSection, t(\"Duplicate widget\"),\n      dom.cls(\"disabled\", dontDuplicateSection),\n      testId(\"duplicate-section\")),\n    menuItemCmd(allCommands.deleteSection, t(\"Delete widget\"),\n      dom.cls(\"disabled\", dontRemoveSection()),\n      dom.hide(viewSection.isRecordCard),\n      testId(\"section-delete\")),\n  ];\n}\n\n/**\n * Returns a list of menu items for a view section.\n */\nexport function makeCollapsedLayoutMenu(viewSection: ViewSectionRec, gristDoc: GristDoc) {\n  const isReadonly = gristDoc.isReadonly.get();\n  const sectionId = viewSection.table.peek().rawViewSectionRef.peek();\n  const anchorUrlState = { hash: { sectionId, popup: true } };\n  const rawUrl = urlState().makeUrl(anchorUrlState);\n  return [\n    dom.maybe(use => !use(viewSection.isRaw) && use(gristDoc.canShowRawData),\n      () => menuItemLink(\n        { href: rawUrl }, t(\"Show raw data\"), testId(\"show-raw-data\"),\n        dom.on(\"click\", () => {\n          // Replace the current URL so that the back button works as expected (it navigates back from\n          // the current page).\n          urlState().pushUrl(anchorUrlState, { replace: true }).catch(reportError);\n        }),\n      ),\n    ),\n    menuDivider(),\n    menuItemCmd(allCommands.restoreSection, t(\"Add to page\"),\n      dom.cls(\"disabled\", isReadonly),\n      testId(\"section-expand\")),\n    menuItemCmd(allCommands.deleteCollapsedSection, t(\"Delete widget\"),\n      dom.cls(\"disabled\", isReadonly),\n      testId(\"section-delete\")),\n  ];\n}\n"
  },
  {
    "path": "app/client/ui/ViewSectionMenu.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/AppModel\";\nimport { DocModel, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { FilterConfig } from \"app/client/ui/FilterConfig\";\nimport { cssLabel, cssSaveButtonsRow } from \"app/client/ui/RightPanelStyles\";\nimport { SortConfig } from \"app/client/ui/SortConfig\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { makeViewLayoutMenu } from \"app/client/ui/ViewLayoutMenu\";\nimport { basicButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { isNarrowScreenObs, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu } from \"app/client/ui2018/menus\";\n\nimport { Computed, dom, IDisposableOwner, makeTestId, styled } from \"grainjs\";\nimport { defaultMenuOptions } from \"popweasel\";\n\nconst testId = makeTestId(\"test-section-menu-\");\nconst t = makeT(\"ViewSectionMenu\");\n\n// Handler for [Save] button.\nasync function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise<void> {\n  await docModel.docData.bundleActions(t(\"Update Sort&Filter settings\"), () => Promise.all([\n    viewSection.activeSortJson.save(),      // Save sort\n    viewSection.saveFilters(),              // Save filter\n    viewSection.activeCustomOptions.save(), // Save widget options\n  ]));\n}\n\n// Handler for [Revert] button.\nfunction doRevert(viewSection: ViewSectionRec) {\n  viewSection.activeSortJson.revert();      // Revert sort\n  viewSection.revertFilters();              // Revert filter\n  viewSection.activeCustomOptions.revert(); // Revert widget options\n}\n\n// [Filter Icon] - Filter toggle and all the components in the menu.\nexport function viewSectionMenu(\n  owner: IDisposableOwner,\n  gristDoc: GristDoc,\n  viewSection: ViewSectionRec,\n) {\n  const { docModel, isReadonly } = gristDoc;\n\n  // If there is any filter (should [Filter Icon] background be filled).\n  const anyFilter = Computed.create(owner, use =>  Boolean(use(viewSection.activeFilters).length));\n\n  // Should we show [Save] [Revert] buttons.\n  const displaySaveObs: Computed<boolean> = Computed.create(owner, use => (\n    use(viewSection.filterSpecChanged) ||\n    !use(viewSection.activeSortJson.isSaved) ||\n    !use(viewSection.activeCustomOptions.isSaved)\n  ));\n\n  const save = () => { doSave(docModel, viewSection).catch(reportError); };\n  const revert = () => doRevert(viewSection);\n\n  // If this section is the only one in the view (or view temporary has no sections at all).\n  const singleVisible = Computed.create(owner, (use) => {\n    const view = use(viewSection.view);\n    const sections = use(use(view.viewSections).getObservable());\n    const expanded = sections.filter(s => use(s.isCollapsed) === false).length;\n    return expanded === 1 || !expanded; // single, or no sections at all (temporary).\n  });\n\n  // Should we show expand icon.\n  const showExpandIcon = Computed.create(owner, (use) => {\n    return !use(isNarrowScreenObs()) && // not on narrow screens\n      use(gristDoc.maximizedSectionId) !== use(viewSection.id) && // not in when we are maximized\n      use(gristDoc.externalSectionId) !== use(viewSection.id) && // not in when we are external\n      !use(viewSection.isRaw) && // not in raw mode\n      !use(viewSection.isRecordCard) &&\n      !use(singleVisible) && // not in single section\n      use(viewSection.canExpand)\n    ;\n  });\n\n  return [\n    cssFilterMenuWrapper(\n      cssFilterMenuWrapper.cls(\"-unsaved\", displaySaveObs),\n      testId(\"wrapper\"),\n      cssMenu(\n        testId(\"sortAndFilter\"),\n        // [Filter icon]\n        cssFilterIconWrapper(\n          testId(\"filter-icon\"),\n          // Fill background when there are some filters. Ignore sort options.\n          cssFilterIconWrapper.cls(\"-any\", anyFilter),\n          cssFilterIcon(\"Filter\"),\n          hoverTooltip(t(\"Sort and filter\"), { key: \"sortFilterBtnTooltip\" }),\n        ),\n      ),\n      // [Save] [Revert] buttons when there are unsaved options.\n      dom.maybe(displaySaveObs, () => cssSectionSaveButtonsWrapper(\n        cssSaveTextButton(\n          t(\"Save\"),\n          cssSaveTextButton.cls(\"-accent\"),\n          dom.on(\"click\", save),\n          hoverTooltip(\"Save sort & filter settings\", { key: \"sortFilterBtnTooltip\" }),\n          testId(\"small-btn-save\"),\n          dom.hide(isReadonly),\n        ),\n        cssRevertIconButton(\n          cssRevertIcon(\"Revert\", cssRevertIcon.cls(\"-normal\")),\n          dom.on(\"click\", revert),\n          hoverTooltip(\"Revert sort & filter settings\", { key: \"sortFilterBtnTooltip\" }),\n          testId(\"small-btn-revert\"),\n        ),\n      )),\n      menu(ctl => [\n        // Sort section.\n        makeSortPanel(viewSection, gristDoc),\n        // Filter section.\n        makeFilterPanel(viewSection),\n        // Widget options\n        dom.maybe(use => use(viewSection.parentKey) === \"custom\", () =>\n          makeCustomOptions(viewSection),\n        ),\n        // [Save] [Revert] buttons\n        dom.domComputed(displaySaveObs, displaySave => [\n          displaySave ? cssSaveButtonsRow(\n            cssSaveButton(t(\"Save\"), testId(\"btn-save\"),\n              dom.on(\"click\", () => { ctl.close(); save(); }),\n              dom.boolAttr(\"disabled\", isReadonly)),\n            basicButton(t(\"Revert\"), testId(\"btn-revert\"),\n              dom.on(\"click\", () => { ctl.close(); revert(); })),\n          ) : null,\n        ]),\n        // Updates to active sort or filters can cause menu contents to grow, while\n        // leaving the position of the popup unchanged. This can sometimes lead to\n        // the menu growing beyond the boundaries of the viewport. To mitigate this,\n        // we subscribe to changes to the sort/filters and manually update the popup's\n        // position, which will re-position the popup if necessary so that it's fully\n        // visible.\n        dom.autoDispose(viewSection.activeFilters.addListener(() => ctl.update())),\n        dom.autoDispose(viewSection.activeSortJson.subscribe(() => ctl.update())),\n        (elem) => { FocusLayer.create(ctl, { defaultFocusElem: elem, pauseMousetrap: true }); },\n      ], { ...defaultMenuOptions, placement: \"bottom-end\", trigger: [\n        // Toggle the menu whenever the filter icon button is clicked.\n        (el, ctl) => dom.onMatchElem(el, \".test-section-menu-sortAndFilter\", \"click\", () => {\n          ctl.toggle();\n        }),\n        // Close the menu whenever the save or revert button is clicked.\n        (el, ctl) => dom.onMatchElem(el, \".test-section-menu-small-btn-save\", \"click\", () => {\n          ctl.close();\n        }),\n        (el, ctl) => dom.onMatchElem(el, \".test-section-menu-small-btn-revert\", \"click\", () => {\n          ctl.close();\n        }),\n      ] }),\n      dom.hide(viewSection.isRecordCard),\n    ),\n    cssMenu(\n      dom.hide(viewSection.hideViewMenu),\n      testId(\"viewLayout\"),\n      cssDotsIconWrapper(cssIcon(\"Dots\")),\n      menu(_ctl => makeViewLayoutMenu(viewSection, isReadonly.get()), {\n        ...defaultMenuOptions,\n        placement: \"bottom-end\",\n      }),\n    ),\n    dom.maybe(showExpandIcon, () =>\n      cssExpandIconWrapper(\n        cssSmallIcon(\"Grow\"),\n        testId(\"expandSection\"),\n        dom.on(\"click\", () =>  allCommands.expandSection.run()),\n        hoverTooltip(\"Expand section\", { key: \"expandSection\" }),\n      ),\n    ),\n  ];\n}\n\nfunction makeSortPanel(section: ViewSectionRec, gristDoc: GristDoc) {\n  return [\n    cssLabel(t(\"SORT\"), testId(\"heading-sort\")),\n    dom.create(SortConfig, section, gristDoc, {\n      // Attach content to triggerElem's parent, which is needed to prevent view\n      // section menu to close when clicking an item in the advanced sort menu.\n      menuOptions: { attach: null },\n    }),\n  ];\n}\n\nfunction makeFilterPanel(section: ViewSectionRec) {\n  return [\n    cssLabel(t(\"FILTER\"), testId(\"heading-filter\")),\n    dom.create(FilterConfig, section, {\n      // Attach content to triggerElem's parent, which is needed to prevent view\n      // section menu to close when clicking an item of the add filter menu.\n      menuOptions: { attach: null },\n    }),\n  ];\n}\n\n// Custom Options\n// (empty)|(customized)|(modified) [Remove Icon]\nfunction makeCustomOptions(section: ViewSectionRec) {\n  const color = Computed.create(null, use => use(section.activeCustomOptions.isSaved) ? \"-normal\" : \"-accent\");\n  const text = Computed.create(null, (use) => {\n    if (use(section.activeCustomOptions)) {\n      return use(section.activeCustomOptions.isSaved) ? t(\"(customized)\") : t(\"(modified)\");\n    } else {\n      return t(\"(empty)\");\n    }\n  });\n  return [\n    cssMenuInfoHeader(t(\"Custom options\"), testId(\"heading-widget-options\")),\n    cssMenuText(\n      dom.autoDispose(text),\n      dom.autoDispose(color),\n      dom.text(text),\n      cssMenuText.cls(color),\n      cssSpacer(),\n      dom.maybe(use => Boolean(use(section.activeCustomOptions)), () =>\n        cssMenuIconWrapper(\n          cssIcon(\"Remove\", testId(\"btn-remove-options\"), dom.on(\"click\", () =>\n            section.activeCustomOptions(null),\n          )),\n        ),\n      ),\n      testId(\"custom-options\"),\n    ),\n  ];\n}\n\nconst clsOldUI = styled(\"div\", ``);\n\nexport const cssMenu = styled(\"div\", `\n  display: flex;\n  cursor: pointer;\n  border-radius: 3px;\n  &.${clsOldUI.className} {\n    margin-top: 0px;\n    border-radius: 0px;\n  }\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssIconWrapper = styled(\"div\", `\n  padding: 3px;\n  border-radius: 3px;\n  cursor: pointer;\n  user-select: none;\n`);\n\nconst cssMenuIconWrapper = styled(cssIconWrapper, `\n  display: flex;\n  margin: -3px 0;\n  width: 22px;\n  height: 22px;\n\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n  &-changed {\n    background-color: ${theme.accentIcon};\n  }\n  &-changed:hover, &-changed:hover.weasel-popup-open {\n    background-color: ${theme.controlHoverFg};\n  }\n`);\n\nconst cssFilterMenuWrapper = styled(\"div\", `\n  display: flex;\n  border-radius: 3px;\n  align-items: center;\n  &-unsaved {\n    border: ${theme.controlBorder};\n  }\n  & .${cssMenu.className} {\n    border: none;\n  }\n`);\n\nconst cssIcon = styled(icon, `\n  flex: none;\n  cursor: pointer;\n  background-color: ${theme.lightText};\n\n  .${cssMenuIconWrapper.className}-changed & {\n    background-color: ${theme.controlPrimaryFg};\n  }\n\n  .${clsOldUI.className} & {\n    background-color: ${theme.controlPrimaryFg};\n  }\n\n  &-accent {\n    background-color: ${theme.accentIcon};\n  }\n`);\n\nexport const cssDotsIconWrapper = styled(cssIconWrapper, `\n  border-radius: 0px 2px 2px 0px;\n  display: flex;\n  .${clsOldUI.className} & {\n    border-radius: 0px;\n  }\n`);\n\nconst cssExpandIconWrapper = styled(\"div\", `\n  display: flex;\n  border-radius: 3px;\n  align-items: center;\n  padding: 4px;\n  cursor: pointer;\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssSmallIcon = styled(cssIcon, `\n  height: 13px;\n  width: 13px;\n`);\n\nconst cssFilterIconWrapper = styled(cssIconWrapper, `\n  border-radius: 2px 0px 0px 2px;\n  display: flex;\n  &-any {\n    border-radius: 2px;\n    background-color: ${theme.controlSecondaryFg};\n  }\n  .${cssFilterMenuWrapper.className}-unsaved & {\n    background-color: ${theme.controlPrimaryBg};\n  }\n`);\n\nconst cssFilterIcon = styled(cssIcon, `\n  .${cssFilterIconWrapper.className}-any & {\n    background-color: ${theme.controlPrimaryFg};\n  }\n  .${cssFilterMenuWrapper.className}-unsaved & {\n    background-color: ${theme.controlPrimaryFg};\n  }\n`);\n\nconst cssMenuInfoHeader = styled(\"div\", `\n  color: ${theme.menuSubheaderFg};\n  font-weight: ${vars.bigControlTextWeight};\n  padding: 8px 24px 8px 24px;\n  cursor: default;\n`);\n\nconst cssMenuText = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  padding: 0px 24px 8px 24px;\n  cursor: default;\n  white-space: nowrap;\n  &-accent {\n    color: ${theme.accentText};\n  }\n  &-normal {\n    color: ${theme.lightText};\n  }\n`);\n\nconst cssSaveButton = styled(primaryButton, `\n  margin-right: 8px;\n`);\n\nconst cssSaveTextButton = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  cursor: pointer;\n  font-size: ${vars.mediumFontSize};\n  padding: 0px 5px;\n  color: ${theme.controlFg};\n  border-right: ${theme.controlBorder};\n`);\n\nconst cssRevertIconButton = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  cursor: pointer;\n`);\n\nconst cssRevertIcon = styled(icon, `\n  --icon-color: ${theme.accentIcon};\n  margin: 0 5px 0 5px;\n`);\n\nconst cssSectionSaveButtonsWrapper = styled(\"div\", `\n  padding: 0 1px 0 1px;\n  display: flex;\n  justify-content: space-between;\n  align-self: normal;\n`);\n\nconst cssSpacer = styled(\"div\", `\n  margin: 0 auto;\n`);\n"
  },
  {
    "path": "app/client/ui/VisibleFieldsConfig.ts",
    "content": "import DetailView from \"app/client/components/DetailView\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { KoArray, syncedKoArray } from \"app/client/lib/koArray\";\nimport * as kf from \"app/client/lib/koForm\";\nimport { makeT } from \"app/client/lib/localization\";\nimport * as tableUtil from \"app/client/lib/tableUtil\";\nimport { ColumnRec, ViewFieldRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { getFieldType } from \"app/client/ui/RightPanelUtils\";\nimport { basicButton, primaryButton, textButton } from \"app/client/ui2018/buttons\";\nimport * as checkbox from \"app/client/ui2018/checkbox\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssDragger } from \"app/client/ui2018/draggableList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { visuallyHiddenStyles } from \"app/client/ui2018/visuallyHidden\";\nimport * as gutil from \"app/common/gutil\";\nimport { IWidgetType } from \"app/common/widgetTypes\";\n\nimport { Computed, Disposable, dom, IDomArgs, makeTestId, Observable, styled, subscribe } from \"grainjs\";\nimport ko from \"knockout\";\nimport difference from \"lodash/difference\";\nimport isEqual from \"lodash/isEqual\";\n\nconst testId = makeTestId(\"test-vfc-\");\nconst t = makeT(\"VisibleFieldsConfig\");\n\nexport type IField = ViewFieldRec | ColumnRec;\n\ninterface DraggableFieldsOption {\n  // an object holding options for the draggable list, see koForm.js for more detail on the accepted\n  // options.\n  draggableOptions: any;\n\n  // Allows to skip first n items. This feature is useful to separate the series from the x-axis and\n  // the group-by-column in the chart type widget.\n  skipFirst?: Observable<number>;\n\n  // Allows to filter view fields.\n  filterFunc?: (field: ViewFieldRec, index: number) => boolean;\n\n  // Allows to prevent updates of the list. This option is to be used when skipFirst option is used\n  // and it is useful to prevent the list to update during changes that only affect the skipped\n  // fields.\n  freeze?: Observable<boolean>;\n\n  // the itemCreateFunc callback passed to kf.draggableList for the visible fields.\n  itemCreateFunc(field: IField): Element | undefined;\n}\n\n/**\n * VisibleFieldsConfig builds dom for the visible/hidden fields configuration component. Usage is:\n *\n *   dom.create(VisibleFieldsConfig, gristDoc, section);\n *\n * Can also be used to build the two draggable list only:\n *\n *   const config = VisibleFieldsConfig.create(null, gristDoc, section);\n *   const [visibleFieldsDraggable, hiddenFieldsDraggable] =\n *     config.buildSectionFieldsConfigHelper({visibleFields: {itemCreateFunc: getLabelFunc},\n *                                            hiddenFields: {itemCreateFunc: getLabelFunc}});\n *\n * The later for is useful to support old ui, refer to function's doc for more detail on the\n * available options.\n */\nexport class VisibleFieldsConfig extends Disposable {\n  private _hiddenFields: KoArray<ColumnRec> = this.autoDispose(syncedKoArray(this._section.hiddenColumns));\n\n  private _fieldLabel = Computed.create(this, (use) => {\n    const widgetType = use(this._section.parentKey) as IWidgetType;\n    return getFieldType(widgetType).pluralLabel;\n  });\n\n  private _collapseHiddenFields = Observable.create(this, false);\n\n  /**\n   * Set if and only if the corresponding selection is empty, ie: respectively\n   * visibleFieldsSelection and hiddenFieldsSelection.\n   */\n  private _showVisibleBatchButtons = Observable.create(this, false);\n  private _showHiddenBatchButtons = Observable.create(this, false);\n\n  private _visibleFieldsSelection = new Set<number>();\n  private _hiddenFieldsSelection = new Set<number>();\n\n  private _disabled = this.autoDispose(ko.computed(() => {\n    const view = this._section.viewInstance();\n    if (!view || !(\"recordLayout\" in view)) {\n      return false;\n    }\n\n    return Boolean((view as DetailView).recordLayout.layoutEditor());\n  }));\n\n  constructor(private _gristDoc: GristDoc,\n    private _section: ViewSectionRec) {\n    super();\n\n    // Unselects visible fields that are hidden.\n    this.autoDispose(this._section.viewFields.peek().subscribe((ev) => {\n      unselectDeletedFields(this._visibleFieldsSelection, ev);\n      this._showVisibleBatchButtons.set(Boolean(this._visibleFieldsSelection.size));\n    }, null, \"spliceChange\"));\n\n    // Unselectes hidden fields that are shown.\n    this.autoDispose(this._hiddenFields.subscribe((ev) => {\n      unselectDeletedFields(this._hiddenFieldsSelection, ev);\n      this._showHiddenBatchButtons.set(Boolean(this._hiddenFieldsSelection.size));\n    }, null, \"spliceChange\"));\n  }\n\n  /**\n   * Build the draggable list components to show the visible fields of a section.\n   */\n  public buildVisibleFieldsConfigHelper(options: DraggableFieldsOption) {\n    let fields = this._section.viewFields.peek();\n\n    if (options.skipFirst || options.filterFunc) {\n      const skipFirst = options.skipFirst || Observable.create(this, -1);\n      const filterFunc = options.filterFunc || (() => true);\n\n      const freeze = options.freeze;\n      const allFields = this._section.viewFields.peek();\n      const newArray = new KoArray<ViewFieldRec>();\n\n      function update() {\n        if (freeze?.get()) { return; }\n        const newValues = allFields.peek()\n          .filter((_v, i) => i + 1 > skipFirst.get())\n          .filter(filterFunc)\n        ;\n        if (isEqual(newArray.all(), newValues)) { return; }\n        newArray.assign(newValues);\n      }\n      update();\n      this.autoDispose(allFields.subscribe(update));\n      this.autoDispose(subscribe(skipFirst, update));\n      if (options.freeze) {\n        this.autoDispose(subscribe(options.freeze, update));\n      }\n      fields = newArray;\n    }\n\n    return kf.draggableList(\n      fields,\n      options.itemCreateFunc,\n      {\n        itemClass: cssDragRow.className,\n        reorder: this.changeFieldPosition.bind(this),\n        remove: this.removeField.bind(this),\n        receive: this.addField.bind(this),\n        ...options.draggableOptions,\n      },\n    );\n  }\n\n  /**\n   * Build the two draggable list components to show both the visible and the hidden fields of a\n   * section. Each draggable list can be parametrized using both `options.visibleFields` and\n   * `options.hiddenFields` options.\n   *\n   * @param {DraggableFieldsOption} options.hiddenFields options for the list of hidden fields.\n   * @param {DraggableFieldsOption} options.visibleFields options for the list of visible fields.\n   * @return {[Element, Element]} the two draggable elements (ie: koForm.draggableList) showing\n   *                              respectivelly the list of visible fields and the list of hidden\n   *                              fields of section.\n   */\n  public buildSectionFieldsConfigHelper(\n    options: {\n      visibleFields: DraggableFieldsOption,\n      hiddenFields: DraggableFieldsOption,\n    }): [HTMLElement, HTMLElement] {\n    const fieldsDraggable = this.buildVisibleFieldsConfigHelper(options.visibleFields);\n    const hiddenFieldsDraggable = kf.draggableList(\n      this._hiddenFields,\n      options.hiddenFields.itemCreateFunc,\n      {\n        itemClass: cssDragRow.className,\n        reorder() { throw new Error(t(\"Hidden Fields cannot be reordered\")); },\n        receive() { throw new Error(t(\"Cannot drop items into Hidden Fields\")); },\n        remove(item: ColumnRec) {\n          // Return the column object. This value is passed to the viewFields\n          // receive function as its respective item parameter\n          return item;\n        },\n        removeButton: false,\n        ...options.hiddenFields.draggableOptions,\n      },\n    );\n    kf.connectDraggableOneWay(hiddenFieldsDraggable, fieldsDraggable);\n\n    this.autoDispose(this._disabled.subscribe(() => {\n      this._setVisibleCheckboxes(fieldsDraggable, false);\n      this._setHiddenCheckboxes(hiddenFieldsDraggable, false);\n    }));\n\n    return [fieldsDraggable, hiddenFieldsDraggable];\n  }\n\n  public buildDom() {\n    const [fieldsDraggable, hiddenFieldsDraggable] = this.buildSectionFieldsConfigHelper({\n      visibleFields: {\n        itemCreateFunc: field => this._buildVisibleFieldItem(field as ViewFieldRec),\n        draggableOptions: {\n          removeButton: false,\n          drag_indicator: cssDragger,\n        },\n      },\n      hiddenFields: {\n        itemCreateFunc: field => this._buildHiddenFieldItem(field as ColumnRec),\n        draggableOptions: {\n          removeButton: false,\n          drag_indicator: cssDragger,\n        },\n      },\n    });\n    return [\n      dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"visible-fields-label\" },\n        cssHeader(\n          cssFieldListHeader(\n            dom.text(use => t(\"Visible {{label}}\", { label: use(this._fieldLabel) })),\n            { id: \"visible-fields-label\" },\n          ),\n          dom.maybe(\n            use => Boolean(use(use(this._section.viewFields).getObservable()).length),\n            () => (\n              cssIconButton(\n                icon(\"Tick\"),\n                t(\"Select all\"),\n                { \"aria-describedby\": \"visible-fields-label\" },\n                dom.on(\"click\", () => this._setVisibleCheckboxes(fieldsDraggable, true)),\n                dom.prop(\"disabled\", this._disabled),\n                testId(\"visible-fields-select-all\"),\n              )\n            ),\n          ),\n        ),\n        cssFieldsDraggable(\n          cssFieldsDraggable.cls(\"-disabled\", this._disabled),\n          dom.update(fieldsDraggable, testId(\"visible-fields\")),\n        ),\n        dom.maybe(this._showVisibleBatchButtons, () =>\n          cssRow(\n            primaryButton(\n              dom.text(use => t(\"Hide {{label}}\", { label: use(this._fieldLabel) })),\n              dom.on(\"click\", () => this._removeSelectedFields()),\n              testId(\"visible-hide\"),\n            ),\n            basicButton(\n              t(\"Clear\"),\n              dom.on(\"click\", () => this._setVisibleCheckboxes(fieldsDraggable, false)),\n              testId(\"visible-clear\"),\n            ),\n            testId(\"visible-batch-buttons\"),\n          ),\n        ),\n      ),\n      dom(\"div\", { \"role\": \"group\", \"aria-labelledby\": \"hidden-fields-label\" },\n        cssHeader(\n          cssHeaderButton(\n            icon(\n              \"Dropdown\",\n              dom.style(\"transform\", use => use(this._collapseHiddenFields) ? \"rotate(-90deg)\" : \"\"),\n            ),\n            dom.boolAttr(\"disabled\", this._disabled),\n            dom.on(\"click\", () => this._collapseHiddenFields.set(!this._collapseHiddenFields.get())),\n            testId(\"collapse-hidden\"),\n            // TODO: show `hidden column` only when some fields are hidden\n            cssFieldListHeader(dom.text(use => t(\"Hidden {{label}}\", { label: use(this._fieldLabel) }))),\n            {\n              \"id\": \"hidden-fields-label\",\n              \"aria-controls\": \"hidden-fields-list\",\n            },\n            dom.attr(\"aria-expanded\", use => use(this._collapseHiddenFields) ? \"false\" : \"true\"),\n          ),\n          dom.maybe(\n            use => Boolean(use(this._hiddenFields.getObservable()).length && !use(this._collapseHiddenFields)),\n            () => (\n              cssIconButton(\n                icon(\"Tick\"),\n                t(\"Select all\"),\n                { \"aria-describedby\": \"hidden-fields-label\" },\n                dom.on(\"click\", () => this._setHiddenCheckboxes(hiddenFieldsDraggable, true)),\n                dom.prop(\"disabled\", this._disabled),\n                testId(\"hidden-fields-select-all\"),\n              )\n            ),\n          ),\n        ),\n        dom(\n          \"div\",\n          dom.hide(this._collapseHiddenFields),\n          { id: \"hidden-fields-list\" },\n          cssFieldsDraggable(\n            cssFieldsDraggable.cls(\"-disabled\", this._disabled),\n            dom.update(\n              hiddenFieldsDraggable,\n              testId(\"hidden-fields\"),\n            ),\n          ),\n          dom.maybe(this._showHiddenBatchButtons, () =>\n            cssRow(\n              primaryButton(\n                dom.text(use => t(\"Show {{label}}\", { label: use(this._fieldLabel) })),\n                dom.on(\"click\", () => this._addSelectedFields()),\n                testId(\"hidden-show\"),\n              ),\n              basicButton(\n                t(\"Clear\"),\n                dom.on(\"click\", () => this._setHiddenCheckboxes(hiddenFieldsDraggable, false)),\n                testId(\"hidden-clear\"),\n              ),\n              testId(\"hidden-batch-buttons\"),\n            ),\n          ),\n        ),\n      ),\n    ];\n  }\n\n  public async removeField(field: IField) {\n    await this._section.removeField(field.getRowId());\n  }\n\n  public async addField(column: IField, nextField: ViewFieldRec | null = null) {\n    const exists = this._section.viewFields.peek().peek()\n      .findIndex(f => f.column.peek().getRowId() === column.id.peek());\n    if (exists !== -1) {\n      return;\n    }\n    const parentPos = getFieldNewPosition(this._section.viewFields.peek(), column, nextField);\n    const colInfo = {\n      parentId: this._section.id.peek(),\n      colRef: column.id.peek(),\n      parentPos,\n    };\n    const action = [\"AddRecord\", null, colInfo];\n    await this._gristDoc.docModel.viewFields.sendTableAction(action);\n  }\n\n  public changeFieldPosition(field: ViewFieldRec, nextField: ViewFieldRec | null) {\n    const parentPos = getFieldNewPosition(this._section.viewFields.peek(), field, nextField);\n    const vsfAction = [\"UpdateRecord\", field.id.peek(), { parentPos }];\n    return this._gristDoc.docModel.viewFields.sendTableAction(vsfAction);\n  }\n\n  // Set all checkboxes for the visible fields.\n  private _setVisibleCheckboxes(visibleFieldsDraggable: Element, checked: boolean) {\n    this._setCheckboxesHelper(\n      visibleFieldsDraggable,\n      this._section.viewFields.peek().peek(),\n      this._visibleFieldsSelection,\n      checked,\n    );\n    this._showVisibleBatchButtons.set(checked);\n  }\n\n  // Set all checkboxes for the hidden fields.\n  private _setHiddenCheckboxes(hiddenFieldsDraggable: Element, checked: boolean) {\n    this._setCheckboxesHelper(\n      hiddenFieldsDraggable,\n      this._hiddenFields.peek(),\n      this._hiddenFieldsSelection,\n      checked,\n    );\n    this._showHiddenBatchButtons.set(checked);\n  }\n\n  // A helper to set all checkboxes. Takes care of setting all checkboxes in the dom and updating\n  // the selection.\n  private _setCheckboxesHelper(draggable: Element, fields: IField[], selection: Set<number>,\n    checked: boolean) {\n    findCheckboxes(draggable).forEach(el => el.checked = checked);\n\n    selection.clear();\n\n    if (checked) {\n      // add all ids to the selection\n      fields.forEach(field => selection.add(field.id.peek()));\n    }\n  }\n\n  private _buildHiddenFieldItem(column: IField) {\n    const id = column.id.peek();\n    const selection = this._hiddenFieldsSelection;\n\n    return cssFieldEntry(\n      testId(\"hidden-field\"),\n      cssFieldLabel(dom.text(column.label)),\n      cssHideIconButton(\n        icon(\"EyeShow\"),\n        dom.on(\"click\", () => this.addField(column)),\n        testId(\"hide\"),\n        dom.boolAttr(\"disabled\", this._disabled),\n        dom.attr(\"aria-label\", use => t(\"Show {{label}}\", { label: use(column.label) })),\n      ),\n      buildCheckbox(\n        dom.prop(\"checked\", selection.has(id)),\n        dom.boolAttr(\"disabled\", this._disabled),\n        dom.attr(\"aria-label\", use => t(\"Show {{label}} (batch mode)\", { label: use(column.label) })),\n        dom.on(\"change\", (ev, el) => {\n          if (el.checked) {\n            selection.add(id);\n          } else {\n            selection.delete(id);\n          }\n          this._showHiddenBatchButtons.set(Boolean(selection.size));\n        }),\n      ),\n    );\n  }\n\n  private _buildVisibleFieldItem(field: IField) {\n    const id = field.id.peek();\n    const selection = this._visibleFieldsSelection;\n\n    return cssFieldEntry(\n      testId(\"visible-field\"),\n      cssFieldLabel(dom.text(field.label)),\n      cssHideIconButton(\n        icon(\"EyeHide\"),\n        dom.on(\"click\", () => this.removeField(field)),\n        testId(\"hide\"),\n        dom.boolAttr(\"disabled\", this._disabled),\n        dom.attr(\"aria-label\", use => t(\"Hide {{label}}\", { label: use(field.label) })),\n      ),\n      buildCheckbox(\n        dom.prop(\"checked\", selection.has(id)),\n        dom.boolAttr(\"disabled\", this._disabled),\n        dom.attr(\"aria-label\", use => t(\"Hide {{label}} (batch mode)\", { label: use(field.label) })),\n        dom.on(\"change\", (ev, el) => {\n          if (el.checked) {\n            selection.add(id);\n          } else {\n            selection.delete(id);\n          }\n          this._showVisibleBatchButtons.set(Boolean(selection.size));\n        }),\n      ),\n    );\n  }\n\n  private async _removeSelectedFields() {\n    const toRemove = Array.from(this._visibleFieldsSelection).sort(gutil.nativeCompare);\n    const action = [\"BulkRemoveRecord\", toRemove];\n    await this._gristDoc.docModel.viewFields.sendTableAction(action);\n  }\n\n  private async _addSelectedFields() {\n    const toAdd = Array.from(this._hiddenFieldsSelection);\n    const rowIds = gutil.arrayRepeat(toAdd.length, null);\n    const colInfo = {\n      parentId: gutil.arrayRepeat(toAdd.length, this._section.id.peek()),\n      colRef: toAdd,\n    };\n    const action = [\"BulkAddRecord\", rowIds, colInfo];\n    await this._gristDoc.docModel.viewFields.sendTableAction(action);\n  }\n}\n\nfunction getFieldNewPosition(fields: KoArray<ViewFieldRec>, item: IField,\n  nextField: ViewFieldRec | null): number | null {\n  const index = getItemIndex(fields, nextField);\n  return tableUtil.fieldInsertPositions(fields, index, 1)[0];\n}\n\nfunction getItemIndex(collection: KoArray<ViewFieldRec>, item: ViewFieldRec | null): number {\n  if (item !== null) {\n    return collection.peek().indexOf(item);\n  }\n  return collection.peek().length;\n}\n\nfunction buildCheckbox(...args: IDomArgs<HTMLInputElement>) {\n  return checkbox.cssLabel(\n    { style: \"flex-shrink: 0;\" },\n    checkbox.cssCheckboxSquare(\n      { type: \"checkbox\" },\n      ...args,\n    ),\n  );\n}\n\n// helper to find checkboxes within a draggable list. This assumes that checkboxes are the only\n// <input> element in draggableElement.\nfunction findCheckboxes(draggableElement: Element): NodeListOf<HTMLInputElement> {\n  return draggableElement.querySelectorAll<HTMLInputElement>(\"input\");\n}\n\n// Removes from selection the ids of the fields that appear as deleted in the splice event. Note\n// that it can happen that a field appears as deleted and yet belongs to the new array (as a result\n// of an `assign` call for instance). In which case the field is to be considered as not deleted.\nfunction unselectDeletedFields(selection: Set<number>, event: { deleted: IField[], array: IField[] }) {\n  // go though the difference between deleted fields and the new array.\n  const removed: IField[] = difference(event.deleted, event.array);\n  for (const field of removed) {\n    selection.delete(field.id.peek());\n  }\n}\n\nexport const cssDragRow = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  margin: 0 16px 0px 0px;\n  & > .kf_draggable_content {\n    margin: 2px 0;\n    flex: 1 1 0px;\n    min-width: 0px;\n  }\n`);\n\nexport const cssFieldEntry = styled(\"div\", `\n  display: flex;\n  background-color: ${theme.hover};\n  width: 100%;\n  border-radius: 2px;\n  margin: 0 8px 0 0;\n  padding: 4px 8px;\n  cursor: default;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n\n  --icon-color: ${theme.lightText};\n`);\n\n// Note: the show/hide icons are actually always displayed,\n// just visually hidden when not focused/hovered.\n// This is done like that instead of `display: none` to easily support kb nav.\nconst cssHideIconButton = styled(unstyledButton, `\n  flex: none;\n  margin-right: 8px;\n  line-height: 1;\n  --icon-color: ${theme.lightText};\n  &:not(:focus, :focus-within, .kf_draggable:hover &) {\n    ${visuallyHiddenStyles}\n  }\n`);\n\nexport const cssFieldLabel = styled(\"span\", `\n  color: ${theme.text};\n  flex: 1 1 auto;\n  text-overflow: ellipsis;\n  overflow: hidden;\n`);\n\nconst cssFieldListHeader = styled(\"span\", `\n  color: ${theme.text};\n  flex: 1 1 0px;\n  font-size: ${vars.xsmallFontSize};\n  text-transform: uppercase;\n`);\n\nconst cssRow = styled(\"div\", `\n  display: flex;\n  margin: 16px;\n  gap: 8px;\n  --icon-color: ${theme.lightText};\n`);\n\nconst cssHeader = styled(cssRow, `\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 12px;\n`);\n\nconst cssIconButton = styled(textButton, `\n  &:disabled {\n    --icon-color: ${theme.controlPrimaryBg};\n  };\n`);\n\nconst cssFieldsDraggable = styled(\"div\", `\n  &-disabled {\n    pointer-events: none;\n    opacity: 0.5;\n  }\n`);\n\nconst cssHeaderButton = styled(unstyledButton, `\n  display: flex;\n  align-items: center;\n`);\n"
  },
  {
    "path": "app/client/ui/WebhookPage.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { ViewSectionHelper } from \"app/client/components/ViewLayout\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportMessage, reportSuccess } from \"app/client/models/errors\";\nimport { IEdit, IExternalTable, VirtualTableRegistration } from \"app/client/models/VirtualTable\";\nimport { docListHeader } from \"app/client/ui/DocMenuCss\";\nimport { bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { mediaSmall, testId } from \"app/client/ui2018/cssVars\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { DisposableWithEvents } from \"app/common/DisposableWithEvents\";\nimport {\n  DocAction,\n  getColIdsFromDocAction,\n  getColValues,\n  isDataAction,\n  TableDataAction,\n  UserAction,\n} from \"app/common/DocActions\";\nimport { VirtualId } from \"app/common/SortSpec\";\nimport { WebhookSummary } from \"app/common/Triggers\";\nimport { DocAPI } from \"app/common/UserAPI\";\nimport { GristObjCode, RowRecord } from \"app/plugin/GristData\";\n\nimport { dom, styled } from \"grainjs\";\nimport { observableArray, ObservableArray } from \"knockout\";\nimport omit from \"lodash/omit\";\nimport pick from \"lodash/pick\";\nimport range from \"lodash/range\";\nimport without from \"lodash/without\";\n\nconst t = makeT(\"WebhookPage\");\n\nconst TABLE_COLUMN_ROW_ID = VirtualId();\n\n/**\n * A list of columns for a virtual table about webhooks.\n * The ids need to be strings.\n */\nconst WEBHOOK_COLUMNS = [\n  {\n    id: TABLE_COLUMN_ROW_ID,\n    colId: \"tableId\",\n    type: \"Choice\",\n    label: t(\"Table\"),\n    // widgetOptions are configured later, since the choices depend\n    // on the user tables in the document.\n  },\n  {\n    id: VirtualId(),\n    colId: \"url\",\n    type: \"Text\",\n    label: t(\"URL\"),\n  },\n  {\n    id: VirtualId(),\n    colId: \"eventTypes\",\n    type: \"ChoiceList\",\n    label: t(\"Event Types\"),\n    widgetOptions: JSON.stringify({\n      widget: \"TextBox\",\n      alignment: \"left\",\n      choices: [\"add\", \"update\"],\n      choiceOptions: {},\n    }),\n  },\n  {\n    id: VirtualId(),\n    colId: \"watchedColIdsText\",\n    type: \"Text\",\n    label: t(\"Filter for changes in these columns (semicolon-separated ids)\"),\n  },\n  {\n    id: VirtualId(),\n    colId: \"enabled\",\n    type: \"Bool\",\n    label: t(\"Enabled\"),\n    widgetOptions: JSON.stringify({\n      widget: \"Switch\",\n    }),\n  },\n  {\n    id: VirtualId(),\n    colId: \"isReadyColumn\",\n    type: \"Text\",\n    label: t(\"Ready Column\"),\n  },\n  {\n    id: VirtualId(),\n    colId: \"webhookId\",\n    type: \"Text\",\n    label: t(\"Webhook Id\"),\n  },\n  {\n    id: VirtualId(),\n    colId: \"name\",\n    type: \"Text\",\n    label: t(\"Name\"),\n  },\n  {\n    id: VirtualId(),\n    colId: \"memo\",\n    type: \"Text\",\n    label: t(\"Memo\"),\n  },\n  {\n    id: VirtualId(),\n    colId: \"status\",\n    type: \"Text\",\n    label: t(\"Status\"),\n  },\n  {\n    id: VirtualId(),\n    colId: \"authorization\",\n    type: \"Text\",\n    label: t(\"Header Authorization\"),\n  },\n] as const;\n\n/**\n * Layout of fields in a view, with a specific ordering.\n */\nconst WEBHOOK_VIEW_FIELDS: (typeof WEBHOOK_COLUMNS)[number][\"colId\"][] = [\n  \"name\", \"memo\",\n  \"eventTypes\", \"tableId\",\n  \"watchedColIdsText\", \"isReadyColumn\",\n  \"url\", \"authorization\",\n  \"webhookId\", \"enabled\",\n  \"status\",\n];\n\n/**\n *\n * Change webhooks based on a virtual table.\n *\n * TODO: error handling is not rock-solid. If a set of actions are\n * applied all together, and one fails, then state between UI and\n * back-end may end up being inconsistent. One option would be just to\n * resync in the case of an error. In practice, the way the virtual\n * table is used in a card list, it would be hard to tickle this case\n * right now, so I'm not going to worry about it.\n *\n */\nclass WebhookExternalTable implements IExternalTable {\n  public name = \"GristHidden_WebhookTable\";\n  public saveableFields = [\n    \"tableId\", \"watchedColIdsText\", \"url\", \"authorization\", \"eventTypes\", \"enabled\", \"name\", \"memo\", \"isReadyColumn\",\n  ];\n\n  public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]);\n\n  public constructor(private _docApi: DocAPI) {\n  }\n\n  public initialActions(): DocAction[] {\n    return _prepareWebhookInitialActions(this.name);\n  }\n\n  public async fetchAll(): Promise<TableDataAction> {\n    const webhooks = (await this._docApi.getWebhooks()).webhooks;\n    this._initalizeWebhookList(webhooks);\n    const indices = range(webhooks.length);\n    return [\"TableData\", this.name, indices.map(i => i + 1),\n      getColValues(indices.map(rowId => _mapWebhookValues(webhooks[rowId])))];\n  }\n\n  public async beforeEdit(editor: IEdit) {\n    const results = editor.actions;\n    for (const r of results) {\n      for (const d of r.stored) {\n        if (!isDataAction(d)) {\n          continue;\n        }\n        const colIds = new Set(getColIdsFromDocAction(d) || []);\n        if (colIds.has(\"webhookId\") || colIds.has(\"status\")) {\n          throw new Error(t(`Sorry, not all fields can be edited.`));\n        }\n      }\n    }\n    const delta = editor.delta;\n    for (const recId of delta.removeRows) {\n      const rec = editor.getRecord(recId);\n      if (!rec) {\n        continue;\n      }\n      await this._removeWebhook(rec);\n      reportMessage(t(`Removed webhook.`));\n    }\n    const updates = new Set(delta.updateRows);\n    const t2 = editor;\n    for (const recId of updates) {\n      const rec = t2.getRecordNew(recId);\n      if (rec?.webhookId) {\n        await this._updateWebhook(String(rec?.webhookId), rec);\n      }\n    }\n  }\n\n  public async afterEdit(editor: IEdit) {\n    const { delta } = editor;\n    const updates = new Set(delta.updateRows);\n    const addsAndUpdates = new Set([...delta.addRows, ...delta.updateRows]);\n    for (const recId of addsAndUpdates) {\n      const rec = editor.getRecord(recId);\n      if (!rec) {\n        continue;\n      }\n      const notes: string[] = [];\n      const values: Record<string, any> = {};\n      if (!rec.webhookId) {\n        try {\n          const webhookId = await this._addWebhook(rec);\n          values.webhookId = webhookId;\n          notes.push(\"Added\");\n        } catch (e) {\n          notes.push(\"Incomplete\" + \" | \" + this._getErrorString(e).replace(/^Error: /, \"\").replace(\"\\n\", \" | \"));\n        }\n      } else {\n        notes.push(\"Updated\");\n      }\n      if (!values.status) {\n        values.status = notes.join(\"\\n\");\n      }\n      if (!updates.has(recId)) {\n        // 'enabled' needs an initial value, otherwise it is unsettable\n        values.enabled = false;\n      }\n      await editor.patch([\n        [\"UpdateRecord\", this.name, recId, values],\n      ]);\n    }\n  }\n\n  public async sync(editor: IEdit): Promise<void> {\n    // Map from external webhookId to local arbitrary rowId.\n    const rowMap = new Map(editor.getRowIds().map(rowId => [editor.getRecord(rowId)!.webhookId, rowId]));\n    // Provisional list of rows to remove (we'll be trimming this down\n    // as we go).\n    const toRemove = new Set(editor.getRowIds());\n    // Synchronization is done by applying a collected list of actions.\n    const actions: UserAction[] = [];\n\n    // Prepare to add or update webhook listings stored locally. Uses\n    // brute force, on the assumption that there won't be many\n    // webhooks, or that \"updating\" something that hasn't actually\n    // changed is not disruptive.\n    const webhooks = (await this._docApi.getWebhooks()).webhooks;\n    this._initalizeWebhookList(webhooks);\n    for (const webhook of webhooks) {\n      const values = _mapWebhookValues(webhook);\n      const rowId = rowMap.get(webhook.id);\n\n      if (rowId) {\n        toRemove.delete(rowId);\n        actions.push(\n          [\"UpdateRecord\", this.name, rowId, values],\n        );\n      } else {\n        actions.push(\n          [\"AddRecord\", this.name, null, values],\n        );\n      }\n    }\n\n    // Prepare to remove webhook rows that no longer correspond to something that\n    // exists externally.\n    for (const rowId of toRemove) {\n      if (editor.getRecord(rowId)?.webhookId) {\n        actions.push([\"RemoveRecord\", this.name, rowId]);\n      }\n    }\n\n    // Apply the changes.\n    await editor.patch(actions);\n  }\n\n  public async afterAnySchemaChange(editor: IEdit) {\n    // Configure the table picker, since the set of tables may have changed.\n    // TODO: should do something about the ready column picker. Right now,\n    // Grist doesn't have a good way to handle contingent choices.\n    const choices = editor.docModel.visibleTables.all().map(tableRec => tableRec.tableId());\n    editor.docModel.docData.receiveAction([\n      \"UpdateRecord\", \"_grist_Tables_column\", TABLE_COLUMN_ROW_ID as any, {\n        widgetOptions: JSON.stringify({\n          widget: \"TextBox\",\n          alignment: \"left\",\n          choices,\n        }),\n      }]);\n  }\n\n  private _initalizeWebhookList(webhooks: WebhookSummary[]) {\n    this.webhooks.removeAll();\n    this.webhooks.push(\n      ...webhooks.map((webhook) => {\n        const uiWebhook: UIWebhookSummary = { ...webhook };\n        uiWebhook.fields.watchedColIdsText = webhook.fields.watchedColIds ? webhook.fields.watchedColIds.join(\";\") : \"\";\n        return uiWebhook;\n      }));\n  }\n\n  private _getErrorString(e: ApiError): string {\n    return e.details?.userError || e.message;\n  }\n\n  private async _addWebhook(rec: RowRecord) {\n    const fields = this._prepareFields(rec);\n    // Leave enabled at default, meaning it will enable on successful\n    // creation. It seems likely we'd get support requests asking why\n    // webhooks are not working otherwise.\n    const { webhookId } = await this._docApi.addWebhook(omit(fields, \"enabled\"));\n    return webhookId;\n  }\n\n  private async _updateWebhook(id: string, rec: RowRecord) {\n    const fields = this._prepareFields(rec);\n    if (Object.keys(fields).length) {\n      await this._docApi.updateWebhook({ id, fields });\n    }\n  }\n\n  private async _removeWebhook(rec: RowRecord) {\n    if (rec.webhookId) {\n      await this._docApi.removeWebhook(String(rec.webhookId), String(rec.tableId));\n    }\n  }\n\n  /**\n   * Perform some transformations for sending fields to api:\n   *   - (1) removes all non saveble props and\n   *   - (2) removes the leading 'L' from eventTypes.\n   */\n  private _prepareFields(fields: any) {\n    fields = pick(fields, ...this.saveableFields);\n    if (fields.eventTypes) {\n      fields.eventTypes = without(fields.eventTypes, \"L\");\n    }\n    fields.watchedColIds = fields.watchedColIdsText ?\n      fields.watchedColIdsText.split(\";\").filter((colId: string) => colId.trim() !== \"\") :\n      [];\n    return fields;\n  }\n}\n\n/**\n * Visualize webhooks. There's a button to clear the queue, and\n * a card list of webhooks.\n */\nexport class WebhookPage extends DisposableWithEvents {\n  public docApi = this.gristDoc.docPageModel.appModel.api.getDocAPI(this.gristDoc.docId());\n  public sharedTable: VirtualTableRegistration;\n  private _webhookExternalTable: WebhookExternalTable;\n\n  constructor(public gristDoc: GristDoc) {\n    super();\n    // this._webhooks = observableArray<WebhookSummary>();\n    this._webhookExternalTable = new WebhookExternalTable(this.docApi);\n    const table = this.autoDispose(new VirtualTableRegistration(gristDoc.docModel, this._webhookExternalTable));\n    this.listenTo(gristDoc, \"webhooks\", async () => {\n      await table.lazySync();\n    });\n  }\n\n  public buildDom() {\n    const viewSectionModel = this.gristDoc.docModel.viewSections.getRowModel(\"vt_webhook_fs1\" as any);\n    ViewSectionHelper.create(this, this.gristDoc, viewSectionModel);\n    if (this.gristDoc.docPageModel.isFork.get()) {\n      return cssContainer(\n        cssHeader(t(\"Webhooks Unavailable In Unsaved Document Copies\")),\n      );\n    }\n    return cssContainer(\n      cssHeader(t(\"Webhook settings\")),\n      cssControlRow(\n        bigPrimaryButton(t(\"Clear queue\"),\n          dom.on(\"click\", () => this.reset()),\n          testId(\"webhook-reset\"),\n        ),\n      ),\n      // active_section here is a bit of a hack, to allow tests to run\n      // more easily.\n      dom(\"div.active_section.view_data_pane_container.flexvbox\", viewSectionModel.viewInstance()!.viewPane),\n    );\n  }\n\n  public async reset() {\n    await this.docApi.flushWebhooks();\n    reportSuccess(t(\"Cleared webhook queue.\"));\n  }\n\n  public async resetSelected(id: string) {\n    await this.docApi.flushWebhook(id);\n    reportSuccess(t(`Cleared webhook ${id} queue.`));\n  }\n}\n\nconst cssHeader = styled(docListHeader, `\n  margin-bottom: 0;\n  &:not(:first-of-type) {\n    margin-top: 40px;\n  }\n`);\n\nconst cssControlRow = styled(\"div\", `\n  flex: none;\n  margin-bottom: 16px;\n  margin-top: 16px;\n  display: flex;\n  gap: 16px;\n`);\n\nconst cssContainer = styled(\"div\", `\n  overflow-y: auto;\n  position: relative;\n  height: 100%;\n  padding: 32px 64px 24px 64px;\n\n  display: flex;\n  flex-direction: column;\n  @media ${mediaSmall} {\n    & {\n      padding: 32px 24px 24px 24px;\n    }\n  }\n`);\n\n/**\n * Actions needed to create the virtual table about webhooks, and a\n * view for it. There are some \"any\" casts to place string ids where\n * numbers are expected.\n */\nfunction _prepareWebhookInitialActions(tableId: string): DocAction[] {\n  return [[\n    // Add the virtual table.\n    \"AddTable\", tableId,\n    WEBHOOK_COLUMNS.map(col => ({\n      isFormula: true,\n      type: \"Any\",\n      formula: \"\",\n      id: col.colId,\n    })),\n  ], [\n    // Add an entry for the virtual table.\n    \"AddRecord\", \"_grist_Tables\", \"vt_webhook_ft1\" as any, { tableId, primaryViewId: 0 },\n  ], [\n    // Add entries for the columns of the virtual table.\n    \"BulkAddRecord\", \"_grist_Tables_column\",\n    WEBHOOK_COLUMNS.map(col => col.id) as any, getColValues(WEBHOOK_COLUMNS.map(rec =>\n      Object.assign({\n        isFormula: false,\n        formula: \"\",\n        widgetOptions: \"\",\n        parentId: \"vt_webhook_ft1\" as any,\n      }, omit(rec, [\"id\"]) as any))),\n  ], [\n    // Add a view section.\n    \"AddRecord\", \"_grist_Views_section\", \"vt_webhook_fs1\" as any,\n    { tableRef: \"vt_webhook_ft1\", parentKey: \"detail\", title: \"\", borderWidth: 1, defaultWidth: 100, theme: \"blocks\" },\n  ], [\n    // List the fields shown in the view section.\n    \"BulkAddRecord\", \"_grist_Views_section_field\", WEBHOOK_VIEW_FIELDS.map((_, i) => `vt_webhook_ff${i + 1}`) as any, {\n      colRef: WEBHOOK_VIEW_FIELDS.map(colId => WEBHOOK_COLUMNS.find(r => r.colId === colId)!.id),\n      parentId: WEBHOOK_VIEW_FIELDS.map(() => \"vt_webhook_fs1\"),\n      parentPos: WEBHOOK_VIEW_FIELDS.map((_, i) => i),\n    },\n  ]];\n}\n\n/**\n * Map a webhook summary to a webhook table raw record.  The main\n * difference is that `eventTypes` is tweaked to be in a cell format,\n * `status` is converted to a string,\n * and `watchedColIdsText` is converted to list in a cell format.\n */\nfunction _mapWebhookValues(webhookSummary: UIWebhookSummary): Partial<WebhookSchemaType> {\n  const fields = webhookSummary.fields;\n  const { eventTypes, watchedColIdsText } = fields;\n  const watchedColIds = watchedColIdsText ?\n    watchedColIdsText.split(\";\").filter(colId => colId.trim() !== \"\") :\n    [];\n  return {\n    ...fields,\n    webhookId: webhookSummary.id,\n    status: JSON.stringify(webhookSummary.usage),\n    eventTypes: [GristObjCode.List, ...eventTypes],\n    watchedColIds: [GristObjCode.List, ...watchedColIds],\n  };\n}\n\ntype WebhookSchemaType = {\n  [prop in keyof WebhookSummary[\"fields\"]]: WebhookSummary[\"fields\"][prop]\n} & {\n  eventTypes: [GristObjCode, ...unknown[]];\n  watchedColIds: [GristObjCode, ...unknown[]];\n  status: string;\n  webhookId: string;\n};\n\ntype UIWebhookSummary = WebhookSummary & {\n  fields: { watchedColIdsText?: string; }\n};\n"
  },
  {
    "path": "app/client/ui/WelcomeCoachingCall.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { bigBasicButton, bigPrimaryButtonLink } from \"app/client/ui2018/buttons\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { cardPopup, cssPopupBody, cssPopupButtons, cssPopupCloseButton,\n  cssPopupTitle } from \"app/client/ui2018/popups\";\nimport { commonUrls } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { dom, styled } from \"grainjs\";\n\nconst t = makeT(\"WelcomeCoachingCall\");\n\nexport function shouldShowWelcomeCoachingCall(appModel: AppModel) {\n  const { deploymentType } = getGristConfig();\n  if (deploymentType !== \"saas\") { return false; }\n\n  // Defer showing coaching call until Add New tip is dismissed.\n  const { behavioralPromptsManager, dismissedWelcomePopups } = appModel;\n  if (behavioralPromptsManager.shouldShowPopup(\"addNew\")) { return false; }\n\n  const popup = dismissedWelcomePopups.get().find(p => p.id === \"coachingCall\");\n  return (\n    // Only show if the user is an owner.\n    appModel.isOwner() && (\n      // And preferences for the popup haven't been saved before.\n      popup === undefined ||\n      // Or the popup has been shown before, and it's time to shown it again.\n      popup.nextAppearanceAt !== null && popup.nextAppearanceAt <= Date.now()\n    )\n  );\n}\n\n/**\n * Shows a popup with an offer for a free coaching call.\n */\nexport function showWelcomeCoachingCall(triggerElement: Element, appModel: AppModel) {\n  const { dismissedWelcomePopups } = appModel;\n\n  cardPopup(triggerElement, (ctl) => {\n    const dismissPopup = (scheduleNextAppearance?: boolean) => {\n      const dismissedPopups = dismissedWelcomePopups.get();\n      const newDismissedPopups = [...dismissedPopups];\n      const coachingPopup = newDismissedPopups.find(p => p.id === \"coachingCall\");\n      if (!coachingPopup) {\n        newDismissedPopups.push({\n          id: \"coachingCall\",\n          lastDismissedAt: Date.now(),\n          timesDismissed: 1,\n          nextAppearanceAt: scheduleNextAppearance ?\n            new Date().setDate(new Date().getDate() + 7) :\n            null,\n        });\n      } else {\n        Object.assign(coachingPopup, {\n          lastDismissedAt: Date.now(),\n          timesDismissed: coachingPopup.timesDismissed + 1,\n          nextAppearanceAt: scheduleNextAppearance && coachingPopup.timesDismissed + 1 <= 1 ?\n            new Date().setDate(new Date().getDate() + 7) :\n            null,\n        });\n      }\n      dismissedWelcomePopups.set(newDismissedPopups);\n      ctl.close();\n    };\n\n    return [\n      cssPopup.cls(\"\"),\n      cssPopupHeader(\n        cssLogoAndName(\n          cssLogo(),\n          cssName(\"Grist\"),\n        ),\n        cssPopupCloseButton(\n          cssCloseIcon(\"CrossBig\"),\n          dom.on(\"click\", () => dismissPopup(true)),\n          testId(\"popup-close-button\"),\n        ),\n      ),\n      cssPopupTitle(t(\"Free coaching call\"),\n        testId(\"popup-title\"),\n      ),\n      cssPopupBody(\n        cssBody(\n          dom(\"div\",\n            t(\"Schedule your {{freeCoachingCall}} with a member of our team.\",\n              { freeCoachingCall: cssBoldText(t(\"free coaching call\")) },\n            ),\n          ),\n          dom(\"div\",\n            t(\"On the call, we'll take the time to understand your needs and tailor the call to you. \\\nWe can show you the Grist basics, or start working with your data right away to build the dashboards you need.\"),\n          ),\n          dom(\"div\",\n            t(\"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users \\\nnavigate the fundamentals of Grist.\",\n            {\n              ourWeeklyWebinars: cssLink(\n                { href: commonUrls.webinars, target: \"_blank\" },\n                t(\"Grist 101\"),\n              ),\n            },\n            ),\n            testId(\"popup-body-webinar\"),\n          ),\n        ),\n        testId(\"popup-body\"),\n      ),\n      cssPopupButtons(\n        bigPrimaryButtonLink(\n          t(\"Schedule call\"),\n          dom.on(\"click\", () => {\n            dismissPopup(false);\n            logTelemetryEvent(\"clickedScheduleCoachingCall\");\n          }),\n          {\n            href: commonUrls.freeCoachingCall,\n            target: \"_blank\",\n          },\n          testId(\"popup-primary-button\"),\n        ),\n        bigBasicButton(\n          t(\"Maybe later\"),\n          dom.on(\"click\", () => dismissPopup(true)),\n          testId(\"popup-basic-button\"),\n        ),\n      ),\n      testId(\"coaching-call\"),\n    ];\n  });\n}\n\nconst cssBody = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  row-gap: 16px;\n`);\n\nconst cssBoldText = styled(\"span\", `\n  font-weight: 600;\n`);\n\nconst cssCloseIcon = styled(icon, `\n  padding: 12px;\n`);\n\nconst cssName = styled(\"div\", `\n  color: ${theme.popupCloseButtonFg};\n  font-size: ${vars.largeFontSize};\n  font-weight: 600;\n`);\n\nconst cssLogo = styled(\"div\", `\n  flex: none;\n  height: 32px;\n  width: 32px;\n  background-image: var(--icon-GristLogo);\n  background-size: ${vars.logoSize};\n  background-repeat: no-repeat;\n  background-position: center;\n`);\n\nconst cssLogoAndName = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 4px;\n`);\n\nconst cssPopup = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n`);\n\nconst cssPopupHeader = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 16px;\n`);\n"
  },
  {
    "path": "app/client/ui/WelcomePage.ts",
    "content": "import { handleSubmit } from \"app/client/lib/formUtils\";\nimport { getLoginUrl, getSignupUrl } from \"app/client/lib/urlUtils\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { AccountWidget } from \"app/client/ui/AccountWidget\";\nimport { App } from \"app/client/ui/App\";\nimport { AppHeader } from \"app/client/ui/AppHeader\";\nimport { textInput } from \"app/client/ui/inputs\";\nimport { pagePanels } from \"app/client/ui/PagePanels\";\nimport { createUserImage } from \"app/client/ui/UserImage\";\nimport { cssMemberImage, cssMemberListItem, cssMemberPrimary,\n  cssMemberSecondary, cssMemberText } from \"app/client/ui/UserItem\";\nimport { buildWelcomeSitePicker } from \"app/client/ui/WelcomeSitePicker\";\nimport { basicButtonLink, bigBasicButtonLink, bigPrimaryButton } from \"app/client/ui2018/buttons\";\nimport { mediaSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { WelcomePage as WelcomePageEnum } from \"app/common/gristUrls\";\n\nimport { Disposable, dom, domComputed, DomContents, MultiHolder, Observable, styled } from \"grainjs\";\n\n// Redirect from ..../welcome/thing to .../welcome/${name}\nfunction _redirectToSiblingPage(name: string) {\n  const url = new URL(location.href);\n  const parts = url.pathname.split(\"/\");\n  parts.pop();\n  parts.push(name);\n  url.pathname = parts.join(\"/\");\n  window.location.assign(url.href);\n}\n\nfunction handleSubmitForm(\n  pending: Observable<boolean>,\n  onSuccess: (v: any) => void,\n  onError?: (e: unknown) => void,\n): (elem: HTMLFormElement) => void {\n  return handleSubmit({ pending, onSuccess, onError });\n}\n\nexport class WelcomePage extends Disposable {\n  constructor(private _appModel: AppModel, private _appObj: App) {\n    super();\n  }\n\n  public buildDom() {\n    return domComputed(urlState().state, state => this._buildDomInPagePanels(state.welcome));\n  }\n\n  private _buildDomInPagePanels(page?: WelcomePageEnum) {\n    return pagePanels({\n      leftPanel: {\n        panelWidth: Observable.create(this, 240),\n        panelOpen: Observable.create(this, false),\n        hideOpener: true,\n        header: dom.create(AppHeader, this._appModel),\n        content: null,\n      },\n      headerMain: [cssFlexSpace(), dom.create(AccountWidget, this._appModel)],\n      contentMain: (\n        page === \"teams\" ? dom.create(buildWelcomeSitePicker, this._appModel) :\n          this._buildPageContent(page)\n      ),\n      app: this._appObj,\n    });\n  }\n\n  private _buildPageContent(page?: WelcomePageEnum): Element {\n    return cssScrollContainer(cssContainer(\n      cssTitle(\"Welcome to Grist\"),\n      testId(\"welcome-page\"),\n      page === \"signup\" ? dom.create(this._buildSignupForm.bind(this)) :\n        page === \"verify\" ? dom.create(this._buildVerifyForm.bind(this)) :\n          page === \"select-account\" ? dom.create(this._buildAccountPicker.bind(this)) :\n            null,\n    ));\n  }\n\n  private _buildSignupForm(owner: MultiHolder) {\n    let inputEl: HTMLInputElement;\n    const pending = Observable.create(owner, false);\n\n    // delayed focus\n    setTimeout(() => inputEl.focus(), 10);\n\n    // We expect to have an email query parameter on welcome/signup.\n    // TODO: make form work without email parameter - except the real todo is:\n    // TODO: replace this form with Amplify.\n    const url = new URL(location.href);\n    const email = Observable.create(owner, url.searchParams.get(\"email\") || \"\");\n    const password = Observable.create(owner, \"\");\n\n    const action = new URL(window.location.href);\n    action.pathname = \"/signup/register\";\n\n    return dom(\n      \"form\",\n      { method: \"post\", action: action.href },\n      handleSubmitForm(pending, () => _redirectToSiblingPage(\"verify\")),\n      cssParagraph(\n        `Welcome Sumo-ling! ` +  // This flow currently only used with AppSumo.\n        `Your Grist site is almost ready. Let's get your account set up and verified. ` +\n        `If you already have a Grist account as `,\n        dom(\"b\", email.get()),\n        ` you can just `,\n        cssLink({ href: getLoginUrl({ nextUrl: null }) }, \"log in\"),\n        ` now. Otherwise, please pick a password.`,\n      ),\n      cssSeparatedLabel(\"The email address you activated Grist with:\"),\n      cssInput(\n        email, { onInput: true },\n        { name: \"emailShow\" },\n        dom.boolAttr(\"disabled\", true),\n        dom.attr(\"type\", \"email\"),\n      ),\n      // Duplicate email as a hidden form since disabled input won't get submitted\n      // for some reason.\n      cssInput(\n        email, { onInput: true },\n        { name: \"email\", style: \"visibility: hidden;\" },\n        dom.boolAttr(\"hidden\", true),\n        dom.attr(\"type\", \"email\"),\n      ),\n      cssSeparatedLabel(\"A password to use with Grist:\"),\n      inputEl = cssInput(\n        password, { onInput: true },\n        { name: \"password\" },\n        dom.attr(\"type\", \"password\"),\n      ),\n      cssButtonGroup(\n        bigPrimaryButton(\n          \"Continue\",\n          testId(\"continue-button\"),\n        ),\n        bigBasicButtonLink(\"Did this already\", dom.on(\"click\", () => {\n          _redirectToSiblingPage(\"verify\");\n        })),\n      ),\n    );\n  }\n\n  private _buildVerifyForm(owner: MultiHolder) {\n    let inputEl: HTMLInputElement;\n    const pending = Observable.create(owner, false);\n\n    // delayed focus\n    setTimeout(() => inputEl.focus(), 10);\n\n    const action = new URL(window.location.href);\n    action.pathname = \"/signup/verify\";\n\n    const url = new URL(location.href);\n    const email = Observable.create(owner, url.searchParams.get(\"email\") || \"\");\n    const code = Observable.create(owner, url.searchParams.get(\"code\") || \"\");\n    return dom(\n      \"form\",\n      { method: \"post\", action: action.href },\n      handleSubmitForm(pending, (result) => {\n        if (result.status === \"confirmed\") {\n          const verified = new URL(window.location.href);\n          verified.pathname = \"/verified\";\n          window.location.assign(verified.href);\n        } else if (result.status === \"resent\") {\n          // just to give a sense that something happened...\n          window.location.reload();\n        }\n      }),\n      cssParagraph(\n        `Please check your email for a 6-digit verification code, and enter it here.`),\n      cssParagraph(\n        `If you've any trouble, try our full set of sign-up options. Do take care to use ` +\n        `the email address you activated with: `,\n        dom(\"b\", email.get())),\n      cssSeparatedLabel(\"Confirmation code\"),\n      inputEl = cssInput(\n        code, { onInput: true },\n        { name: \"code\" },\n        dom.attr(\"type\", \"number\"),\n      ),\n      cssInput(\n        email, { onInput: true },\n        { name: \"email\" },\n        dom.boolAttr(\"hidden\", true),\n      ),\n      cssButtonGroup(\n        bigPrimaryButton(\n          dom.domComputed(code, c => c ?\n            \"Apply verification code\" : \"Resend verification email\"),\n        ),\n        bigBasicButtonLink(\"More sign-up options\",\n          { href: getSignupUrl({ nextUrl: null }) }),\n      ),\n    );\n  }\n\n  private _buildAccountPicker(): DomContents {\n    function addUserToLink(email: string): string {\n      const next = new URLSearchParams(location.search).get(\"next\") || \"\";\n      const url = new URL(next, location.href);\n      url.searchParams.set(\"user\", email);\n      return url.toString();\n    }\n\n    return [\n      cssParagraph(\n        \"Select an account to continue with.\",\n      ),\n      dom.maybe(this._appModel.topAppModel.users, users =>\n        users.map(user => basicButtonLink(\n          cssUserItem.cls(\"\"),\n          cssMemberListItem(\n            cssMemberImage(\n              createUserImage(user, \"large\"),\n            ),\n            cssMemberText(\n              cssMemberPrimary(user.name || dom(\"span\", user.email, testId(\"select-email\"))),\n              user.name ? cssMemberSecondary(user.email, testId(\"select-email\")) : null,\n            ),\n          ),\n          { href: addUserToLink(user.email) },\n          testId(\"select-user\"),\n        )),\n      ),\n    ];\n  }\n}\n\nconst cssUserItem = styled(\"div\", `\n  margin: 0 0 8px;\n  align-items: center;\n  &:first-of-type {\n    margin-top: 16px;\n  }\n  &:hover {\n    background-color: ${theme.lightHover};\n  }\n`);\n\nconst cssScrollContainer = styled(\"div\", `\n  display: flex;\n  overflow-y: auto;\n  flex-direction: column;\n`);\n\nconst cssContainer = styled(\"div\", `\n  max-width: 450px;\n  align-self: center;\n  margin: 60px;\n  display: flex;\n  flex-direction: column;\n  &:after {\n    content: \"\";\n    height: 8px;\n  }\n  @media ${mediaSmall} {\n    & {\n      margin: 24px;\n    }\n  }\n`);\n\nconst cssFlexSpace = styled(\"div\", `\n  flex: 1 1 0px;\n`);\n\nconst cssTitle = styled(\"div\", `\n  height: 32px;\n  line-height: 32px;\n  margin: 0 0 28px 0;\n  color: ${theme.text};\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n`);\n\nconst textStyle = `\n  font-weight: normal;\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.text};\n`;\n\n// TODO: there's probably a much better way to style labels with a bit of\n// space between them and things they are not the label for?\nconst cssSeparatedLabel = styled(\"label\", textStyle + \" margin-top: 20px;\");\nconst cssParagraph = styled(\"p\", textStyle);\n\nconst cssButtonGroup = styled(\"div\", `\n  margin-top: 24px;\n  display: flex;\n  justify-content: space-evenly;\n  &-right {\n    justify-content: flex-end;\n  }\n`);\n\nconst cssInput = styled(textInput, `\n  display: inline;\n  height: 42px;\n  line-height: 16px;\n  padding: 13px;\n  border-radius: 3px;\n`);\n"
  },
  {
    "path": "app/client/ui/WelcomeSitePicker.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport * as css from \"app/client/ui/LoginPagesCss\";\nimport { createUserImage } from \"app/client/ui/UserImage\";\nimport { bigBasicButtonLink } from \"app/client/ui2018/buttons\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { getOrgName } from \"app/common/UserAPI\";\n\nimport { Computed, dom, DomContents, IDisposableOwner, styled } from \"grainjs\";\n\nconst t = makeT(\"WelcomeSitePicker\");\n\nexport function buildWelcomeSitePicker(owner: IDisposableOwner, appModel: AppModel): DomContents {\n  // We assume that there is a single domain for personal orgs, and will show a button to open\n  // that domain with each of the currently signed-in users.\n  const personalOrg = Computed.create(owner, use =>\n    use(appModel.topAppModel.orgs).find(o => Boolean(o.owner))?.domain || undefined);\n\n  return cssPageContainer(\n    testId(\"welcome-page\"),\n    css.flexJustifyCenter(\n      css.formContainer(\n        css.flexJustifyCenter(css.gristLogo()),\n        cssHeading(t(\"Welcome back\")),\n        cssMessage(t(\"You have access to the following Grist sites.\")),\n        cssColumns(\n          dom.maybe(\n            () => getGristConfig().enablePersonalOrgs,\n            () => cssColumn(\n              cssColumnLabel(css.horizontalLine(), css.lightText(\"Personal\"), css.horizontalLine()),\n              dom.forEach(appModel.topAppModel.users, user => (\n                cssOrgButton(\n                  cssPersonalOrg(\n                    createUserImage(user, \"small\"),\n                    dom(\"div\", user.email, testId(\"personal-org-email\")),\n                  ),\n                  dom.attr(\"href\", use => urlState().makeUrl({ org: use(personalOrg) })),\n                  dom.on(\"click\", (ev) => { void (switchToPersonalUrl(ev, appModel, personalOrg.get(), user)); }),\n                  testId(\"personal-org\"),\n                )\n              )),\n            ),\n          ),\n          cssColumn(\n            cssColumnLabel(css.horizontalLine(), css.lightText(\"Team\"), css.horizontalLine()),\n            dom.forEach(appModel.topAppModel.orgs, org => (\n              org.owner || !org.domain ? null : cssOrgButton(\n                getOrgName(org),\n                urlState().setLinkUrl({ org: org.domain }),\n                testId(\"org\"),\n              )\n            )),\n          ),\n        ),\n        cssMessage(t(\"You can always switch sites using the account menu.\")),\n      ),\n    ),\n  );\n}\n\n// TODO This works but not for opening a link in a new tab. We currently lack and endpoint that\n// would enable opening a link as a particular user, or to switch user and open as them.\nasync function switchToPersonalUrl(ev: MouseEvent, appModel: AppModel, org: string | undefined, user: FullUser) {\n  // Only handle plain-vanilla clicks.\n  if (ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) { return; }\n  ev.preventDefault();\n  // Set the active session for the given org, then load its home page.\n  await appModel.switchUser(user, org);\n  window.location.assign(urlState().makeUrl({ org }));\n}\n\nconst cssPageContainer = styled(css.pageContainer, `\n  padding-bottom: 40px;\n`);\n\nconst cssHeading = styled(css.formHeading, `\n  margin-top: 16px;\n  text-align: center;\n`);\n\nconst cssMessage = styled(css.centeredText, `\n  margin: 24px 0;\n`);\n\nconst cssColumns = styled(\"div\", `\n  display: flex;\n  flex-wrap: wrap;\n  gap: 32px;\n`);\n\nconst cssColumn = styled(\"div\", `\n  flex: 1 0 0px;\n  min-width: 200px;\n  position: relative;\n`);\n\nconst cssColumnLabel = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n`);\n\nconst cssOrgButton = styled(bigBasicButtonLink, `\n  display: block;\n  margin: 8px 0;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  overflow: hidden;\n`);\n\nconst cssPersonalOrg = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  margin-left: -8px;\n  gap: 8px;\n  color: ${theme.lightText};\n`);\n"
  },
  {
    "path": "app/client/ui/WelcomeTour.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { IOnBoardingMsg, startOnBoarding } from \"app/client/ui/OnBoardingPopups\";\nimport { ShortcutKey, ShortcutKeyContent } from \"app/client/ui/ShortcutKey\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { dom, styled } from \"grainjs\";\n\nconst t = makeT(\"WelcomeTour\");\n\nexport function getOnBoardingMessages(): IOnBoardingMsg[] {\n  const { assistant, features } = getGristConfig();\n  return [\n    {\n      title: t(\"Editing Data\"),\n      body: () => [\n        dom(\"p\",\n          t(\"Double-click or hit {{enter}} on a cell to edit it. \", {\n            enter: ShortcutKey(ShortcutKeyContent(t(\"Enter\"))),\n          }),\n          t(\"Start with {{equal}} to enter a formula.\", {\n            equal: ShortcutKey(ShortcutKeyContent(\"=\")),\n          })),\n      ],\n      selector: \".field_clip\",\n      placement: \"bottom\",\n    },\n    {\n      selector: \".tour-creator-panel\",\n      title: t(\"Configuring your document\"),\n      body: () => [\n        dom(\"p\",\n          t(\"Toggle the {{creatorPanel}} to format columns, \", { creatorPanel: dom(\"em\", t(\"creator panel\")) }),\n          t(\"convert to card view, select data, and more.\"),\n        ),\n      ],\n      placement: \"left\",\n      cropPadding: true,\n    },\n    {\n      selector: \".tour-type-selector\",\n      title: t(\"Customizing columns\"),\n      body: () => [\n        dom(\"p\",\n          t(\"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \")),\n        dom(\"p\",\n          t(\"Make it relational! Use the {{ref}} type to link tables. \", {\n            ref: ShortcutKey(t(\"Reference\")),\n          })),\n      ],\n      placement: \"right\",\n    },\n    {\n      selector: \".tour-add-new\",\n      title: t(\"Building up\"),\n      body: () => [\n        dom(\"p\", t(\"Use {{addNew}} to add widgets, pages, or import more data. \", {\n          addNew: ShortcutKey(t(\"Add new\")),\n        })),\n      ],\n      placement: \"right\",\n    },\n    {\n      selector: \".tour-share-icon\",\n      title: t(\"Sharing\"),\n      body: () => [\n        dom(\"p\", t(\"Use the Share button ({{share}}) to share the document or export data.\",\n          { share: TopBarButtonIcon(t(\"Share\")) })),\n      ],\n      placement: \"bottom\",\n      cropPadding: true,\n    },\n    ...(assistant?.version === 2 ? [{\n      selector: \".tour-assistant\",\n      title: t(\"AI Assistant\"),\n      body: () => [\n        dom(\"p\",\n          t(\n            \"Understand, modify and work with your data and formulas \" +\n            \"with the help of Grist's AI Assistant!\",\n          ),\n        ),\n      ],\n      placement: \"right\" as const,\n    }] : []),\n    {\n      selector: \".tour-help-center\",\n      title: t(\"Flying higher\"),\n      body: () => [\n        dom(\"p\", t(\"Use {{helpCenter}} for documentation or questions.\",\n          { helpCenter: ShortcutKey(GreyIcon(\"Help\"), t(\"Help Center\")) })),\n      ],\n      placement: \"right\",\n    },\n    ...(features?.includes(\"templates\") && Boolean(getGristConfig().templateOrg) ? [{\n      selector: \".tour-welcome\",\n      title: t(\"Welcome to Grist!\"),\n      body: () => [\n        dom(\"p\", t(\"Browse our {{templateLibrary}} to discover what's possible and get inspired.\",\n          {\n            templateLibrary: cssLink({ target: \"_blank\", href: urlState().makeUrl({ homePage: \"templates\" }) },\n              t(\"template library\"), cssInlineIcon(\"FieldLink\")),\n          },\n        )),\n      ],\n      showHasModal: true,\n    }] : []),\n  ];\n}\n\nexport function startWelcomeTour(onFinishCB: () => void) {\n  commands.allCommands.fieldTabOpen.run();\n  const messages = getOnBoardingMessages();\n  startOnBoarding(messages, (lastMessageIndex) => {\n    logTelemetryEvent(\"viewedWelcomeTour\", {\n      full: {\n        percentComplete: Math.floor(((lastMessageIndex + 1) / messages.length) * 100),\n      },\n    });\n    onFinishCB();\n  });\n}\n\nconst TopBarButtonIcon = styled(icon, `\n  --icon-color: ${theme.topBarButtonPrimaryFg};\n`);\n\nconst GreyIcon = styled(icon, `\n  --icon-color: ${theme.shortcutKeySecondaryFg};\n  margin-right: 8px;\n`);\n\nconst cssInlineIcon = styled(icon, `\n  margin: -3px 8px 0 4px;\n`);\n"
  },
  {
    "path": "app/client/ui/WidgetTitle.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport { autoGrow } from \"app/client/ui/forms\";\nimport { cssInput, cssLabel, cssRenamePopup, cssTextArea } from \"app/client/ui/RenamePopupStyles\";\nimport { descriptionInfoTooltip } from \"app/client/ui/tooltips\";\nimport { basicButton, cssButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\nimport { ModalControl } from \"app/client/ui2018/modals\";\n\nimport { Computed, dom, DomElementArg, makeTestId, Observable, styled } from \"grainjs\";\nimport { IOpenController, IPopupOptions, PopupControl, setPopupToCreateDom } from \"popweasel\";\n\nconst testId = makeTestId(\"test-widget-title-\");\nconst t = makeT(\"WidgetTitle\");\n\ninterface WidgetTitleOptions {\n  tableNameHidden?: boolean,\n  widgetNameHidden?: boolean,\n  disabled?: boolean,\n}\n\nexport function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) {\n  const title = Computed.create(null, use => use(vs.titleDef));\n  const description = Computed.create(null, use => use(vs.description));\n  return buildRenamableTitle(vs, title, description, options, dom.autoDispose(title), ...args);\n}\n\ninterface TableNameOptions {\n  isEditing: Observable<boolean>,\n  disabled?: boolean,\n}\n\nexport function buildTableName(vs: ViewSectionRec, options: TableNameOptions, ...args: DomElementArg[]) {\n  const title = Computed.create(null, use => use(use(vs.table).tableNameDef));\n  const description = Computed.create(null, use => use(vs.description));\n  return buildRenamableTitle(\n    vs,\n    title,\n    description,\n    {\n      openOnClick: false,\n      widgetNameHidden: true,\n      ...options,\n    },\n    dom.autoDispose(title),\n    ...args,\n  );\n}\n\ninterface RenamableTitleOptions {\n  tableNameHidden?: boolean,\n  widgetNameHidden?: boolean,\n  /** Defaults to true. */\n  openOnClick?: boolean,\n  isEditing?: Observable<boolean>,\n  disabled?: boolean,\n}\n\nfunction buildRenamableTitle(\n  vs: ViewSectionRec,\n  title: Observable<string>,\n  description: Observable<string>,\n  options: RenamableTitleOptions,\n  ...args: DomElementArg[]\n) {\n  const { openOnClick = true, disabled = false, isEditing, ...renameTitleOptions } = options;\n  let popupControl: PopupControl | undefined;\n  return cssTitleContainer(\n    cssTitle(\n      testId(\"text\"),\n      dom.text(title),\n      dom.on(\"click\", () => {\n        // The popup doesn't close if `openOnClick` is false and the title is\n        // clicked. Make sure that it does.\n        if (!openOnClick) { popupControl?.close(); }\n      }),\n      // In case titleDef is all blank space, make it visible on hover.\n      cssTitle.cls(\"-empty\", use => !use(title)?.trim()),\n      cssTitle.cls(\"-open-on-click\", openOnClick),\n      cssTitle.cls(\"-disabled\", disabled),\n      (elem) => {\n        if (disabled) { return; }\n\n        // The widget title popup can be configured to open in up to two ways:\n        //   1. When the title is clicked - done by setting `openOnClick` to `true`.\n        //   2. When `isEditing` is set to true - done by setting `isEditing` to `true`.\n        //\n        // Typically, the former should be set. The latter is useful for triggering the\n        // popup from a different part of the UI, like a menu item.\n        const trigger: IPopupOptions[\"trigger\"] = [];\n        if (openOnClick) { trigger.push(\"click\"); }\n        if (isEditing) {\n          trigger.push((_: Element, ctl: PopupControl) => {\n            popupControl = ctl;\n            ctl.autoDispose(isEditing.addListener((editing) => {\n              if (editing) {\n                ctl.open();\n              } else if (!ctl.isDisposed()) {\n                ctl.close();\n              }\n            }));\n          });\n        }\n        setPopupToCreateDom(elem, (ctl) => {\n          if (isEditing) {\n            ctl.onDispose(() => isEditing.set(false));\n          }\n\n          return buildRenameTitlePopup(ctl, vs, renameTitleOptions);\n        }, {\n          placement: \"bottom-start\",\n          trigger,\n          attach: \"body\",\n          boundaries: \"viewport\",\n        });\n      },\n      openOnClick ? dom.on(\"click\", (ev) => { ev.stopPropagation(); ev.preventDefault(); }) : null,\n    ),\n    dom.maybe(description, () => [\n      descriptionInfoTooltip(description.get(), \"widget\"),\n    ]),\n    ...args,\n  );\n}\n\nfunction buildRenameTitlePopup(ctrl: IOpenController, vs: ViewSectionRec, options: RenamableTitleOptions) {\n  const tableRec = vs.table.peek();\n  // If the table is a summary table.\n  const isSummary = Boolean(tableRec.summarySourceTable.peek());\n  // Table name, for summary table it contains also a grouping description, but it is not editable.\n  // Example: Table1 or Table1 [by B, C]\n  const tableName = [tableRec.tableNameDef.peek(), tableRec.groupDesc.peek()]\n    .filter(p => Boolean(p?.trim())).join(\" \");\n  // User input for table name.\n  const inputTableName = Observable.create(ctrl, tableName);\n  // User input for widget title.\n  const inputWidgetTitle = Observable.create(ctrl, vs.title.peek() ?? \"\");\n  // Placeholder for widget title:\n  // - when widget title is empty shows a default widget title (what would be shown when title is empty)\n  // - when widget title is set, shows just a text to override it.\n  const inputWidgetPlaceholder = !vs.title.peek() ? t(\"Override widget title\") : vs.defaultWidgetTitle.peek();\n\n  // User input for widget description\n  const inputWidgetDesc = Observable.create(ctrl, vs.description.peek() ?? \"\");\n\n  const disableSave = Computed.create(ctrl, (use) => {\n    const newTableName = use(inputTableName)?.trim() ?? \"\";\n    const newWidgetTitle = use(inputWidgetTitle)?.trim() ?? \"\";\n    const newWidgetDesc = use(inputWidgetDesc)?.trim() ?? \"\";\n    // Can't save when table name is empty or there wasn't any change.\n    return !newTableName || (\n      newTableName === tableName &&\n      newWidgetTitle === use(vs.title) &&\n      newWidgetDesc === use(vs.description)\n    );\n  });\n\n  const modalCtl = ModalControl.create(ctrl, () => ctrl.close());\n\n  const saveTableName = async () => {\n    // For summary table ignore - though we could rename primary table.\n    if (isSummary) { return; }\n    // Can't save an empty name - there are actually no good reasons why we can't have empty table name,\n    // unfortunately there are some use cases that really on the empty name:\n    // - For ACL we sometimes may check if tableId is empty (and sometimes if table name).\n    // - Pages with empty name are not visible by default (and pages are renamed with a table - if their name match).\n    if (!inputTableName.get().trim()) { return; }\n    // If value was changed.\n    if (inputTableName.get() !== tableRec.tableNameDef.peek()) {\n      await tableRec.tableNameDef.saveOnly(inputTableName.get());\n    }\n  };\n\n  const saveWidgetTitle = async () => {\n    const newTitle = inputWidgetTitle.get()?.trim() ?? \"\";\n    // If value was changed.\n    if (newTitle !== vs.title.peek()) {\n      await vs.title.saveOnly(newTitle);\n    }\n  };\n\n  const saveWidgetDesc = async () => {\n    const newWidgetDesc = inputWidgetDesc.get().trim() ?? \"\";\n    // If value was changed.\n    if (newWidgetDesc !== vs.description.peek()) {\n      await vs.description.saveOnly(newWidgetDesc);\n    }\n  };\n\n  const save = () => Promise.all([\n    saveTableName(),\n    saveWidgetTitle(),\n    saveWidgetDesc(),\n  ]);\n\n  function initialFocus() {\n    const isRawView = !widgetInput;\n    const isWidgetTitleEmpty = !vs.title.peek();\n    function focus(inputEl?: HTMLInputElement) {\n      inputEl?.focus();\n      inputEl?.select();\n    }\n    if (isSummary) {\n      focus(widgetInput);\n    } else if (isRawView) {\n      focus(tableInput);\n    } else if (isWidgetTitleEmpty) {\n      focus(tableInput);\n    } else {\n      focus(widgetInput);\n    }\n  }\n\n  // When the popup is closing we will save everything, unless the user has pressed the cancel button.\n  let cancelled = false;\n\n  // Function to close the popup with saving.\n  const close = () => ctrl.close();\n\n  // Function to close the popup without saving.\n  const cancel = () => { cancelled = true; close(); };\n\n  // Function that is called when popup is closed.\n  const onClose = () => {\n    if (!cancelled) {\n      save().catch(reportError);\n    }\n  };\n\n  // User interface for the popup.\n  const myCommands = {\n    // Escape key: just close the popup.\n    cancel,\n    // Enter key: save and close the popup, unless the description input is focused.\n    // There is also a variant for Ctrl+Enter which will always save.\n    accept: () => {\n      // Enters are ignored in the description input (unless ctrl is pressed)\n      if (document.activeElement === descInput) { return true; }\n      close();\n    },\n    // ArrowUp\n    cursorUp: () => {\n      // moves focus to the widget title input if it is already at the top of widget description\n      if (document.activeElement === descInput && descInput?.selectionStart === 0) {\n        widgetInput?.focus();\n        widgetInput?.select();\n      } else if (document.activeElement === widgetInput) {\n        tableInput?.focus();\n        tableInput?.select();\n      } else {\n        return true;\n      }\n    },\n    // ArrowDown\n    cursorDown: () => {\n      if (document.activeElement === tableInput) {\n        widgetInput?.focus();\n        widgetInput?.select();\n      } else if (document.activeElement === widgetInput) {\n        descInput?.focus();\n        descInput?.select();\n      } else {\n        return true;\n      }\n    },\n  };\n\n  // Create this group and attach it to the popup and all inputs.\n  const commandGroup = commands.createGroup({ ...myCommands }, ctrl, true);\n\n  let tableInput: HTMLInputElement | undefined;\n  let widgetInput: HTMLInputElement | undefined;\n  let descInput: HTMLTextAreaElement | undefined;\n  return cssRenamePopup(\n    // Create a FocusLayer to keep focus in this popup while it's active, and prevent keyboard\n    // shortcuts from being seen by the view underneath.\n    (elem) => { FocusLayer.create(ctrl, { defaultFocusElem: elem, pauseMousetrap: false }); },\n    dom.onDispose(onClose),\n    dom.autoDispose(commandGroup),\n    testId(\"popup\"),\n    dom.cls(menuCssClass),\n    dom.maybe(!options.tableNameHidden, () => [\n      cssLabel(t(\"DATA TABLE NAME\")),\n      // Update tableName on key stroke - this will show the default widget name as we type.\n      // above this modal.\n      tableInput = cssInput(\n        inputTableName,\n        updateOnKey,\n        { disabled: isSummary, placeholder: t(\"Provide a table name\") },\n        testId(\"table-name-input\"),\n        commandGroup.attach(),\n      ),\n    ]),\n    dom.maybe(!options.widgetNameHidden, () => [\n      cssLabel(t(\"WIDGET TITLE\")),\n      widgetInput = cssInput(inputWidgetTitle, updateOnKey, { placeholder: inputWidgetPlaceholder },\n        testId(\"section-name-input\"),\n        commandGroup.attach(),\n      ),\n    ]),\n    cssLabel(t(\"WIDGET DESCRIPTION\")),\n    descInput = cssTextArea(inputWidgetDesc, updateOnKey,\n      testId(\"section-description-input\"),\n      commandGroup.attach(),\n      autoGrow(inputWidgetDesc),\n    ),\n    cssButtons(\n      primaryButton(t(\"Save\"),\n        dom.on(\"click\", close),\n        dom.boolAttr(\"disabled\", use => use(disableSave) || use(modalCtl.workInProgress)),\n        testId(\"save\"),\n      ),\n      basicButton(t(\"Cancel\"),\n        testId(\"cancel\"),\n        dom.on(\"click\", cancel),\n      ),\n    ),\n    dom.onKeyDown({\n      Enter$: (e) => {\n        if (e.ctrlKey || e.metaKey) {\n          close();\n          return false;\n        }\n      },\n    }),\n    (elem) => { setTimeout(initialFocus, 0); },\n  );\n}\n\nconst updateOnKey = { onInput: true };\n\n// Leave class for tests.\nconst cssTitleContainer = styled(\"div\", `\n  flex: 1 1 0px;\n  min-width: 0px;\n  display: flex;\n  & .info_toggle_icon {\n    width: 13px;\n    height: 13px;\n  }\n`);\n\nconst cssTitle = styled(\"div\", `\n  overflow: hidden;\n  border-radius: 3px;\n  margin: -4px;\n  padding: 4px;\n  text-overflow: ellipsis;\n  align-self: start;\n  &-open-on-click:not(&-disabled) {\n    cursor: pointer;\n  }\n  &-open-on-click:not(&-disabled):hover {\n    background-color: ${theme.hover};\n  }\n  &-empty {\n    min-width: 48px;\n    min-height: 23px;\n  }\n`);\n\nconst cssButtons = styled(\"div\", `\n  display: flex;\n  margin-top: 16px;\n  & > .${cssButton.className}:not(:first-child) {\n    margin-left: 8px;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/YouTubePlayer.ts",
    "content": "import { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { waitObs } from \"app/common/gutil\";\n\nimport { Disposable, dom, DomElementArg } from \"grainjs\";\nimport ko from \"knockout\";\n\nexport interface Player {\n  playVideo(): void;\n  pauseVideo(): void;\n  stopVideo(): void;\n  mute(): void;\n  unMute(): void;\n  setVolume(volume: number): void;\n  getCurrentTime(): number;\n  getPlayerState(): PlayerState;\n}\n\nexport interface PlayerOptions {\n  height?: string;\n  width?: string;\n  origin?: string;\n  playerVars?: PlayerVars;\n  onPlayerReady?(player: Player): void\n  onPlayerStateChange?(player: Player, event: PlayerStateChangeEvent): void;\n}\n\nexport interface PlayerVars {\n  controls?: 0 | 1;\n  disablekb?: 0 | 1;\n  fs?: 0 | 1;\n  iv_load_policy?: 1 | 3;\n  modestbranding?: 0 | 1;\n  rel?: 0 | 1;\n}\n\nexport interface PlayerStateChangeEvent {\n  data: PlayerState;\n}\n\nexport enum PlayerState {\n  Unstarted = -1,\n  Ended = 0,\n  Playing = 1,\n  Paused = 2,\n  Buffering = 3,\n  VideoCued = 5,\n}\n\nconst G = getBrowserGlobals(\"document\", \"window\");\n\n/**\n * Wrapper component for the YouTube IFrame Player API.\n *\n * Fetches the JavaScript code for the API if needed, and creates an iframe that\n * points to a YouTube video with the specified id.\n *\n * For more documentation, see https://developers.google.com/youtube/iframe_api_reference.\n */\nexport class YouTubePlayer extends Disposable {\n  private _domArgs: DomElementArg[];\n  private _isLoading: ko.Observable<boolean> = ko.observable(true);\n  private _playerId = `youtube-player-${this._videoId}`;\n  private _player: Player;\n\n  constructor(\n    private _videoId: string,\n    private _options: PlayerOptions,\n    ...domArgs: DomElementArg[]\n  ) {\n    super();\n\n    this._domArgs = domArgs;\n\n    if (!(G.window as any).YT) {\n      const tag = document.createElement(\"script\");\n\n      tag.src = \"https://www.youtube.com/iframe_api\";\n      const firstScriptTag = document.getElementsByTagName(\"script\")[0];\n      firstScriptTag?.parentNode?.insertBefore(tag, firstScriptTag);\n\n      (G.window as any).onYouTubeIframeAPIReady = () => this._handleYouTubeIframeAPIReady();\n    } else {\n      setTimeout(() => this._handleYouTubeIframeAPIReady(), 0);\n    }\n  }\n\n  public isLoading() {\n    return this._isLoading();\n  }\n\n  public isLoaded() {\n    return waitObs(this._isLoading, val => !val);\n  }\n\n  public play() {\n    this._player.playVideo();\n  }\n\n  public pause() {\n    this._player.pauseVideo();\n  }\n\n  public playPause() {\n    if (this._player.getPlayerState() === PlayerState.Playing) {\n      this._player.pauseVideo();\n    } else {\n      this._player.playVideo();\n    }\n  }\n\n  public setVolume(volume: number) {\n    this._player.setVolume(volume);\n  }\n\n  public getCurrentTime(): number {\n    return this._player.getCurrentTime();\n  }\n\n  public buildDom() {\n    return dom(\"div\", { id: this._playerId }, ...this._domArgs);\n  }\n\n  private _handleYouTubeIframeAPIReady() {\n    const { onPlayerReady, onPlayerStateChange, playerVars, ...otherOptions } = this._options;\n    this._player = new (G.window as any).YT.Player(this._playerId, {\n      videoId: this._videoId,\n      playerVars,\n      events: {\n        onReady: () => {\n          this._isLoading(false);\n          onPlayerReady?.(this._player);\n        },\n        onStateChange: (event: PlayerStateChangeEvent) =>\n          onPlayerStateChange?.(this._player, event),\n      },\n      ...otherOptions,\n    });\n  }\n}\n"
  },
  {
    "path": "app/client/ui/buildReassignModal.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ColumnRec, DocModel } from \"app/client/models/DocModel\";\nimport { cssCode } from \"app/client/ui/DocTutorial\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { bigBasicButton, bigPrimaryButton, textButton } from \"app/client/ui2018/buttons\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { cssModalBody, cssModalButtons, cssModalTitle, cssModalWidth, modal } from \"app/client/ui2018/modals\";\nimport { DocAction } from \"app/common/DocActions\";\nimport { cached } from \"app/common/gutil\";\nimport { decodeObject, encodeObject } from \"app/plugin/objtypes\";\n\nimport { dom, Observable, styled } from \"grainjs\";\nimport mapValues from \"lodash/mapValues\";\n\nconst t = makeT(\"ReassignModal\");\n\n/**\n * Builds a modal that shows the user that they can't reassign records because of uniqueness\n * constraints on the Ref/RefList column. It shows the user the conflicts and provides option\n * to resolve the confilic and retry the change.\n *\n * Currently we support uniquness only on 2-way referenced columns. While it is techincally\n * possible to support it on plain Ref/RefList columns, the implementation assumes that we\n * have the reverse column somewhere and can use it to find the conflicts without building\n * a dedicated index.\n *\n * Mental model of data structure:\n * Left table: Owners\n * Columns: [Name, Pets: RefList(Pets)]\n *\n * Right table: Pets\n * Columns: [Name, Owner: Ref(Owners)]\n *\n * Actions that were send to the server were updating the Owners table.\n *\n * Note: They could affect multiple columns, not only the Pets column.\n */\nexport async function buildReassignModal(options: {\n  docModel: DocModel,\n  actions: DocAction[],\n}) {\n  const { docModel, actions } = options;\n\n  const tableRec = cached((tableId: string) => {\n    return docModel.getTableModel(tableId).tableMetaRow;\n  });\n\n  const columnRec = cached((tableId: string, colId: string) => {\n    const result = tableRec(tableId).columns().all().find(c => c.colId() === colId);\n    if (!result) {\n      throw new Error(`Column ${colId} not found in table ${tableId}`);\n    }\n    return result;\n  });\n\n  // Helper that gets records, but caches and copies them, so that we can amend them when needed.\n  const amended = new Map<string, any>();\n  const getRow = (tableId: string, rowId: number) => {\n    const key = `${tableId}:${rowId}`;\n    if (amended.has(key)) {\n      return amended.get(key);\n    }\n    const tableData = docModel.getTableModel(tableId).tableData;\n    const origRow = tableData.getRecord(rowId);\n    if (!origRow) {\n      return null;\n    }\n    const row = structuredClone(origRow);\n    amended.set(key, row);\n    return row;\n  };\n\n  // Helper that returns name of the row (as seen in Ref editor).\n  const rowDisplay = cached((tableId: string, rowId: number, colId: string) => {\n    const col = columnRec(tableId, colId);\n    // Name of the row (for 2-way reference) is the value of visible column in reverse table.\n    const visibleCol = col.reverseColModel().visibleColModel().colId();\n    const record = getRow(tableId, rowId);\n    return record?.[visibleCol] ?? String(rowId);\n  });\n\n  // We will generate set of problems, and then explain it.\n  class Problem {\n    constructor(public data: {\n      tableId: string,\n      colRec: ColumnRec,\n      revRec: ColumnRec,\n      pointer: number,\n      newRowId: number,\n      oldRowId: number,\n    }) {}\n\n    public buildReason() {\n      // Pets record Azor is already assigned to Owners record Bob.\n      const { colRec, revRec, pointer, oldRowId } = this.data;\n      const Pets = revRec.table().tableNameDef();\n      const Owners = colRec.table().tableNameDef();\n      const Azor = rowDisplay(revRec.table().tableId(), pointer, revRec.colId()) as string;\n      const Bob = rowDisplay(colRec.table().tableId(), oldRowId, colRec.colId()) as string;\n      const text = t(\n        `{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record \\\n         {{oldSourceName}}.`,\n        {\n          targetTable: cssCode(Pets),\n          sourceTable: cssCode(Owners),\n          targetName: cssName(Azor),\n          oldSourceName: cssName(Bob),\n        });\n\n      return cssBulletLine(text);\n    }\n\n    public buildHeader() {\n      // Generally we try to show a text like this:\n      // Each Pets record may only be assigned to a single Owners record.\n      const { colRec, revRec } = this.data;\n      // Task is the name of the revRec table\n      const Pets = revRec.table().tableNameDef();\n      const Owners = colRec.table().tableNameDef();\n      return cssHigherLine([\n        t(`Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.`,\n          {\n            targetTable: cssCode(Pets),\n            sourceTable: cssCode(Owners),\n          }),\n      ]);\n    }\n\n    public fixUserAction() {\n      // Fix action is the action that removes Task 17 from Bob.\n      const tableId = this.data.tableId;\n      const colId = this.data.colRec.colId();\n      const oldRowId = this.data.oldRowId;\n      const oldRecord = getRow(tableId, oldRowId);\n      const oldValue = decodeObject(oldRecord[colId]);\n      let newValue: any = Array.isArray(oldValue) ?\n        oldValue.filter(v => v !== this.data.pointer) :\n        0;\n      if (Array.isArray(newValue) && newValue.length === 0) {\n        newValue = null;\n      }\n      oldRecord[colId] = encodeObject(newValue);\n      return [\"UpdateRecord\", tableId, oldRowId, { [colId]: oldRecord[colId] }];\n    }\n\n    public buildAction(checked: Observable<boolean>, multiple: boolean = false) {\n      // Shows a checkbox and explanation what can be done, checkbox has a text\n      // Reassing to People record Ann\n      // Reasing to new Poeple records.\n      const { colRec, newRowId } = this.data;\n      const Ann = rowDisplay(colRec.table().tableId(), newRowId, colRec.colId()) as string;\n      const singleText = () => t(`Reassign to {{sourceTable}} record {{sourceName}}.`,\n        {\n          sourceTable: cssCode(colRec.table().tableNameDef()),\n          sourceName: cssName(Ann),\n        });\n      const multiText = () => t(`Reassign to new {{sourceTable}} records.`,\n        {\n          sourceTable: cssCode(colRec.table().tableNameDef()),\n        });\n      return cssCheckbox(checked, multiple ? multiText() : singleText());\n    }\n  }\n\n  // List of problems we found in actions.\n  const problems: Problem[] = [];\n  const uniqueColumns: ColumnRec[] = [];\n  const newOwners = new Set<number | null>();\n\n  // We will hold changes in references, so that we can clear the action itself.\n  const newValues = new Map<string, Map<number, number>>();\n  const assignPet = (colId: string, petId: number, ownerId: number) => {\n    if (!newValues.has(colId)) {\n      newValues.set(colId, new Map());\n    }\n    newValues.get(colId)!.set(petId, ownerId);\n  };\n  const wasPetJustAssigned = (colId: string, petId: number) => {\n    return newValues.has(colId) && newValues.get(colId)!.get(petId);\n  };\n\n  const properActions = [] as DocAction[];\n  // Helper that unassigns a pet from the owner, by amanding the value stored in Ref/RefList column.\n  function unassign(value: any, pet: number) {\n    const newValue = decodeObject(value);\n    const newValueArray = Array.isArray(newValue) ? newValue : [newValue] as any;\n    const filteredOut = newValueArray.filter((v: any) => v !== pet);\n    const wasArray = Array.isArray(newValue);\n    if (wasArray) {\n      if (newValueArray.length === 0) {\n        return null;\n      }\n      return encodeObject(filteredOut);\n    } else {\n      return filteredOut[0] ?? null;\n    }\n  }\n\n  // We will go one by one for each action (either update or add), we will flat bulk actions\n  // and simulate applying them to the data, to test if the following actions won't produce\n  // conflicts.\n  for (const origAction of bulkToSingle(actions)) {\n    const action = structuredClone(origAction);\n    if (action[0] === \"UpdateRecord\" || action[0] === \"AddRecord\") {\n      const ownersTable = action[1]; // this is same for each action.\n      const newOwnerId = action[2];\n      newOwners.add(newOwnerId);\n      const valuesInAction = action[3];\n      for (const colId of Object.keys(valuesInAction)) {\n        // We are only interested in uqniue ref columns with reverse column.\n        const petsCol = columnRec(ownersTable, colId);\n        const ownerRevCol = petsCol.reverseColModel();\n        if (!ownerRevCol?.id()) {\n          continue;\n        }\n        if (petsCol.reverseColModel().pureType() !== \"Ref\") {\n          continue;\n        }\n        const petsTable = ownerRevCol.table().tableId();\n        uniqueColumns.push(petsCol); // TODO: what it does\n\n        // Prepare the data for testing, we will treat Ref as RefList to simplify the code.\n        const newValue = decodeObject(valuesInAction[colId]);\n        let petsAfter: number[] = Array.isArray(newValue) ? newValue : [newValue] as any;\n        const prevValue = decodeObject(getRow(ownersTable, newOwnerId)?.[colId]) ?? [];\n        const petsBefore: number[] = Array.isArray(prevValue) ? prevValue : [prevValue] as any;\n\n        // The new owner will have new pets. We are only interested in a situation\n        // where owner is assigned with a new pet, if pet was removed, we don't care as this\n        // won't cause a conflict.\n        petsAfter = petsAfter.filter(p => !petsBefore.includes(p));\n        if (petsAfter.length === 0) {\n          continue;\n        }\n        // Now find current owners of the pets that will be assigned to the new owner.\n        for (const pet of petsAfter) {\n          // We will use data available in that other table (Pets). Notice that we assume, that\n          // the reverse column (Owner in Pets) is Ref column.\n          const oldOwner = getRow(petsTable, pet)?.[ownerRevCol.colId()] as number;\n          // If the pet didn't have an owner previously, we don't care, we are fine reasigning it.\n          if (!oldOwner || (typeof oldOwner !== \"number\")) {\n            // We ignore it, but there might be other actions that will try to move this pet\n            // to other owner, so remember that one.\n\n            // But before remembering, check if that hasn't happend already.\n            const assignedTo = wasPetJustAssigned(petsCol.colId(), pet);\n            if (assignedTo) {\n              // We have two actions that will assign the same pet to two different owners.\n              // We can't allow that, so we will remove this update from the action.\n              valuesInAction[colId] = unassign(valuesInAction[colId], pet);\n            } else {\n              assignPet(colId, pet, newOwnerId);\n            }\n          } else {\n            // If we will assign it to someone else in previous action, ignore this update.\n            if (wasPetJustAssigned(petsCol.colId(), pet)) {\n              valuesInAction[colId] = unassign(valuesInAction[colId], pet);\n              continue;\n            } else {\n              assignPet(colId, pet, newOwnerId);\n              problems.push(new Problem({\n                tableId: ownersTable,\n                pointer: pet,\n                colRec: petsCol,\n                revRec: ownerRevCol,\n                newRowId: newOwnerId,\n                oldRowId: oldOwner,\n              }));\n            }\n          }\n        }\n      }\n\n      properActions.push(action);\n    } else {\n      throw new Error(`Unsupported action ${action[0]}`);\n    }\n  }\n\n  if (!problems.length) {\n    throw new Error(\"No problems found\");\n  }\n\n  const checked = Observable.create(null, false);\n\n  const multipleOrNew = newOwners.size > 1 || newOwners.has(null);\n\n  modal((ctl) => {\n    const reassign = async () => {\n      await docModel.docData.sendActions([\n        ...problems.map(p => p.fixUserAction()).filter(Boolean),\n        ...properActions,\n      ]);\n      ctl.close();\n    };\n    const configureReference = async () => {\n      ctl.close();\n      if (!uniqueColumns.length) { return; }\n      const revCol = uniqueColumns[0].reverseColModel();\n      const rawViewSection = revCol.table().rawViewSection();\n      if (!rawViewSection) { return; }\n      await commands.allCommands.showRawData.run(rawViewSection.id());\n      const reverseColId = revCol.colId.peek();\n      if (!reverseColId) { return; } // might happen if it is censored.\n      const targetField = rawViewSection.viewFields.peek().all()\n        .find(f => f.colId.peek() === reverseColId);\n      if (!targetField) { return; }\n      await commands.allCommands.setCursor.run(null, targetField);\n      await commands.allCommands.rightPanelOpen.run();\n      await commands.allCommands.fieldTabOpen.run();\n    };\n    return [\n      cssModalWidth(\"normal\"),\n      cssModalTitle(t(\"Record already assigned\", { count: problems.length })),\n      cssModalBody(() => {\n        // Show single problem in a simple way.\n        return dom(\"div\",\n          problems[0].buildHeader(),\n          dom(\"div\",\n            dom.style(\"margin-top\", \"18px\"),\n            dom(\"div\", problems.slice(0, 4).map(p => p.buildReason())),\n            problems.length <= 4 ? null : dom(\"div\", `... and ${problems.length - 4} more`),\n            dom(\"div\",\n              problems[0].buildAction(checked, multipleOrNew),\n              dom.style(\"margin-top\", \"18px\"),\n            ),\n          ),\n        );\n      }),\n      cssModalButtons(\n        dom.style(\"display\", \"flex\"),\n        dom.style(\"justify-content\", \"space-between\"),\n        dom.style(\"align-items\", \"baseline\"),\n        dom.domComputed(checked, v => [\n          v ? bigPrimaryButton(t(\"Reassign\"), dom.on(\"click\", reassign)) :\n            bigBasicButton(t(\"Cancel\"), dom.on(\"click\", () => ctl.close())),\n        ]),\n        dom(\"div\",\n          withInfoTooltip(\n            textButton(\"Configure reference\", dom.on(\"click\", configureReference)),\n            \"reassignTwoWayReference\",\n          ),\n        ),\n      ),\n    ];\n  });\n}\n\n/**\n * This function is used to traverse through the actions, and if there are bulk actions, it will\n * flatten them to equivalent single actions.\n */\nfunction* bulkToSingle(actions: DocAction[]): Iterable<DocAction> {\n  for (const a of actions) {\n    if (a[0].startsWith(\"Bulk\")) {\n      const name = a[0].replace(\"Bulk\", \"\") as \"AddRecord\" | \"UpdateRecord\";\n      const rowIds = a[2] as number[];\n      const tableId = a[1];\n      const colValues = a[3] as any;\n      for (let i = 0; i < rowIds.length; i++) {\n        yield [name, tableId, rowIds[i], mapValues(colValues, values => values[i])];\n      }\n    } else {\n      yield a;\n    }\n  }\n}\n\nconst cssBulletLine = styled(\"div\", `\n  margin-bottom: 8px;\n  line-height: 22px;\n  &::before {\n    content: '•';\n    margin-right: 4px;\n    color: ${theme.lightText};\n  }\n`);\n\nconst cssHigherLine = styled(\"div\", `\n  line-height: 22px;\n`);\n\nconst cssName = (text: string) => dom(\"span\", `\"${text}\"`);\n\nconst cssCheckbox = styled(labeledSquareCheckbox, `\n  line-height: 22px;\n  & > span {\n    overflow: unset; /* make some room for cssCode */\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/buttons.ts",
    "content": "import { styled } from \"grainjs\";\n\n/**\n * Plain clickable button, light grey with dark text, slightly raised.\n */\nexport const button1 = styled(\"button\", `\n  background: linear-gradient(to bottom, #fafafa 0%,#eaeaea 100%);\n  border-radius: 0.5em;\n  border: 1px solid #c9c9c9;\n  box-shadow: 0px 2px 2px -2px rgba(0,0,0,0.2);\n  color: #444;\n  font-weight: 500;\n  overflow: hidden;\n  padding: 0.4em 1em;\n  margin: 0.5em;\n\n  &:disabled {\n    color: #A0A0A0;\n  }\n  &:active:not(:disabled) {\n    background: linear-gradient(to bottom, #eaeaea 0%, #fafafa 100%);\n    box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2), 0px 0px 2px 1px #0078ff;\n  }\n`);\n\n/**\n * Similar to button1 but smaller to match other grist buttons.\n */\nexport const button1Small = styled(button1, `\n  height: 2.5rem;\n  line-height: 1.1rem;\n  font-size: 1rem;\n  font-weight: bold;\n  color: #444;\n  border-radius: 4px;\n  box-shadow: none;\n  outline: none;\n  &:active:not(:disabled) {\n    box-shadow: none;\n  }\n`);\n\nconst buttonBrightStyle = `\n  &:not(:disabled) {\n    background: linear-gradient(to bottom, #ffb646 0%,#f68400 100%);\n    border-color: #f68400;\n    color: #ffffff;\n    text-shadow: 1px 1px 0px rgb(0,0,0,0.2);\n  }\n  &:active:not(:disabled) {\n    background: linear-gradient(to bottom, #f68400 0%,#ffb646 100%);\n  }\n`;\n\n/**\n * Just like button1 but orange with white text.\n */\nexport const button1Bright = styled(button1, buttonBrightStyle);\n\n/**\n * Just like button1Small but orange with white text.\n */\nexport const button1SmallBright = styled(button1Small, buttonBrightStyle);\n\n/**\n * A button that looks like a flat circle with a unicode symbol inside, e.g.\n * \"\\u2713\" for checkmark, or \"\\u00D7\" for an \"x\".\n *\n * Modifier class circleSymbolButton.cls(\"-light\") makes it look disabled.\n * Modifier class circleSymbolButton.cls(\"-green\") makes it green.\n */\nexport const circleSymbolButton = styled(\"button\", `\n  border: none;\n  padding: 0px;\n  width: 1em;\n  height: 1em;\n  margin: 0.3em;\n  background: grey;\n  color: #fff;\n  border-radius: 1em;\n  font-family: sans-serif;\n  font-weight: bold;\n  text-align: center;\n  font-size: 1.2em;\n  line-height: 0em;\n  text-decoration: none;\n  cursor:pointer;\n\n  &-light {\n    background-color: lightgrey;\n  }\n  &-green {\n    background-color: #00c209;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/contextMenu.ts",
    "content": "/**\n * This module implements context menu to be shown on contextmenu event (most commonly associated\n * with right+click, but could varies slightly depending on platform, ie: mac support ctrl+click as\n * well).\n *\n * To prevent the default context menu to show everywhere else (including on the top of your custom\n * context menu) dont forget to prevent it by including below line at the root of the dom:\n *   `dom.on('contextmenu', ev => ev.preventDefault())`\n */\nimport { cssMenuElem, registerMenuOpen } from \"app/client/ui2018/menus\";\n\nimport { Disposable, dom, DomArg, DomContents, Holder } from \"grainjs\";\nimport { IMenuOptions, IOpenController, Menu } from \"popweasel\";\n\nexport type IContextMenuContentFunc = (ctx: ContextMenuController) => DomContents;\n\ninterface ContextMenuControllerOptions {\n  triggerElem: Element;\n  menuOptions?: IMenuOptions;\n}\n\nclass ContextMenuController extends Disposable implements IOpenController {\n  private _content: HTMLElement;\n  private _triggerElem = this._options.triggerElem;\n\n  constructor(\n    private _event: MouseEvent,\n    contentFunc: IContextMenuContentFunc,\n    private _options: ContextMenuControllerOptions,\n  ) {\n    super();\n\n    setTimeout(() => this._updatePosition(), 0);\n\n    // Create content and add to the dom but keep hidden until menu gets positioned\n    const menu = Menu.create(null, this, [contentFunc(this)], {\n      menuCssClass: cssMenuElem.className + \" grist-floating-menu\",\n      ...this._options.menuOptions,\n    });\n    const content = this._content = menu.content;\n    content.style.visibility = \"hidden\";\n    document.body.appendChild(content);\n\n    // Prevents arrow to move the cursor while menu is open.\n    dom.onKeyElem(content, \"keydown\", {\n      ArrowLeft: ev => ev.stopPropagation(),\n      ArrowRight: ev => ev.stopPropagation(),\n      // UP and DOWN are already handle by the menu to navigate the menu)\n    });\n\n    // On click anywhere on the page (outside popup content), close it.\n    const onClick = (evt: MouseEvent) => {\n      const target: Node | null = evt.target as Node;\n      if (target && !content.contains(target)) {\n        this.close();\n      }\n    };\n    this.autoDispose(dom.onElem(document, \"contextmenu\", onClick, { useCapture: true }));\n    this.autoDispose(dom.onElem(document, \"click\", onClick, { useCapture: true }));\n\n    // Cleanup involves removing the element.\n    this.onDispose(() => {\n      dom.domDispose(content);\n      content.remove();\n    });\n\n    registerMenuOpen(this);\n  }\n\n  public close() {\n    this.dispose();\n  }\n\n  public setOpenClass(elem: Element, cls: string = \"weasel-popup-open\") {\n    elem.classList.add(cls);\n    this.onDispose(() => elem.classList.remove(cls));\n  }\n\n  public getTriggerElem() {\n    return this._triggerElem;\n  }\n\n  public update() {}\n\n  private _updatePosition() {\n    const content = this._content;\n    const ev = this._event;\n    const rect = content.getBoundingClientRect();\n    // position menu on the right of the cursor if it can fit, on the left otherwise\n    content.style.left = ((ev.pageX + rect.width < window.innerWidth) ?\n      ev.pageX :\n      Math.max(ev.pageX - rect.width, 0)) + \"px\";\n    // position menu below the cursor if it can fit, otherwise fit at the bottom of the screen\n    content.style.bottom = Math.max(window.innerHeight - (ev.pageY + rect.height), 0) + \"px\";\n    // show content\n    content.style.visibility = \"\";\n  }\n}\n\n/**\n * Show a context menu on contextmenu.\n */\nexport function contextMenu(\n  contentFunc: IContextMenuContentFunc,\n  options: IMenuOptions = {},\n): DomArg {\n  return (elem) => {\n    const holder = Holder.create(null);\n    dom.autoDisposeElem(elem, holder);\n    dom.onElem(elem, \"contextmenu\", (ev) => {\n      ev.preventDefault();\n      ev.stopPropagation();\n      ContextMenuController.create(holder, ev, contentFunc, {\n        triggerElem: elem as Element,\n        menuOptions: options,\n      });\n    });\n  };\n}\n"
  },
  {
    "path": "app/client/ui/createAppPage.ts",
    "content": "import { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { setupLocale } from \"app/client/lib/localization\";\nimport { AppModel, TopAppModelImpl, TopAppModelOptions } from \"app/client/models/AppModel\";\nimport { reportError, setUpErrorHandling } from \"app/client/models/errors\";\nimport { buildSnackbarDom } from \"app/client/ui/NotifyUI\";\nimport { addViewportTag } from \"app/client/ui/viewport\";\nimport { attachCssRootVars } from \"app/client/ui2018/cssVars\";\nimport { attachTheme } from \"app/client/ui2018/theme\";\nimport { BaseAPI } from \"app/common/BaseAPI\";\n\nimport { dom, DomContents } from \"grainjs\";\n\nconst G = getBrowserGlobals(\"document\", \"window\");\n\n/**\n * Sets up the application model, error handling, and global styles, and replaces\n * the DOM body with the result of calling `buildAppPage`.\n */\nexport function createAppPage(\n  buildAppPage: (appModel: AppModel) => DomContents,\n  modelOptions: TopAppModelOptions = {},\n) {\n  setUpErrorHandling();\n\n  const topAppModel = TopAppModelImpl.create(null, {}, undefined, modelOptions);\n\n  addViewportTag();\n  attachCssRootVars(topAppModel.productFlavor);\n  attachTheme();\n  setupLocale().catch(reportError);\n\n  // Add globals needed by test utils.\n  G.window.gristApp = {\n    topAppModel,\n    testNumPendingApiRequests: () => BaseAPI.numPendingRequests(),\n  };\n  dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => {\n    return [\n      buildAppPage(appModel),\n      buildSnackbarDom(appModel.notifier, appModel),\n    ];\n  }));\n}\n"
  },
  {
    "path": "app/client/ui/createPage.ts",
    "content": "import { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { setupLocale } from \"app/client/lib/localization\";\nimport { reportError, setErrorNotifier, setUpErrorHandling } from \"app/client/models/errors\";\nimport { Notifier } from \"app/client/models/NotifyModel\";\nimport { buildSnackbarDom } from \"app/client/ui/NotifyUI\";\nimport { addViewportTag } from \"app/client/ui/viewport\";\nimport { attachCssRootVars } from \"app/client/ui2018/cssVars\";\nimport { attachDefaultLightTheme, attachTheme } from \"app/client/ui2018/theme\";\nimport { BaseAPI } from \"app/common/BaseAPI\";\n\nimport { dom, DomContents } from \"grainjs\";\n\nconst G = getBrowserGlobals(\"document\", \"window\");\n\n/**\n * Sets up error handling and global styles, and replaces the DOM body with the\n * result of calling `buildPage`.\n */\nexport function createPage(buildPage: () => DomContents, options: { disableTheme?: boolean } = {}) {\n  const { disableTheme } = options;\n\n  setUpErrorHandling();\n\n  addViewportTag();\n  attachCssRootVars(\"grist\");\n  if (disableTheme) {\n    attachDefaultLightTheme();\n  } else {\n    attachTheme();\n  }\n  setupLocale().catch(reportError);\n\n  // Add globals needed by test utils.\n  G.window.gristApp = {\n    testNumPendingApiRequests: () => BaseAPI.numPendingRequests(),\n  };\n\n  const notifier = Notifier.create(null);\n  setErrorNotifier(notifier);\n\n  dom.update(document.body, () => [\n    buildPage(),\n    buildSnackbarDom(notifier, null),\n  ]);\n}\n"
  },
  {
    "path": "app/client/ui/cssInput.ts",
    "content": "import { theme, vars } from \"app/client/ui2018/cssVars\";\n\nimport { styled } from \"grainjs\";\n\nexport const cssInput = styled(\"input\", `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  height: 30px;\n  width: 100%;\n  font-size: ${vars.mediumFontSize};\n  border-radius: 3px;\n  padding: 5px;\n  border: 1px solid ${theme.inputBorder};\n  outline: none;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/errorPages.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { getLoginUrl, getSignupUrl } from \"app/client/lib/urlUtils\";\nimport { AppModel } from \"app/client/models/AppModel\";\nimport { getMainOrgUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { AppHeader } from \"app/client/ui/AppHeader\";\nimport { leftPanelBasic } from \"app/client/ui/LeftPanelCommon\";\nimport { pagePanels } from \"app/client/ui/PagePanels\";\nimport { createTopBarHome } from \"app/client/ui/TopBar\";\nimport { bigBasicButtonLink, bigPrimaryButtonLink } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { commonUrls, getPageTitleSuffix } from \"app/common/gristUrls\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { dom, DomContents, DomElementArg, makeTestId, observable, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-\");\n\nconst t = makeT(\"errorPages\");\n\nfunction signInAgainButton() {\n  return cssButtonWrap(bigPrimaryButtonLink(\n    t(\"Sign in again\"), { href: getLoginUrl() }, testId(\"error-signin\"),\n  ));\n}\n\nexport function createErrPage(appModel: AppModel) {\n  const { errMessage, errDetails, errPage, errTargetUrl } = getGristConfig();\n  if (errTargetUrl) {\n    // In case the error page was reached via a redirect (typically during sign-in),\n    // replace the current URL with the target URL, so that the user can retry their\n    // action by simply refreshing the page.\n    history.replaceState(null, \"\", errTargetUrl);\n  }\n  return errPage === \"signed-out\" ? createSignedOutPage(appModel) :\n    errPage === \"not-found\" ? createNotFoundPage(appModel, errMessage) :\n      errPage === \"access-denied\" ? createForbiddenPage(appModel, errMessage) :\n        errPage === \"account-deleted\" ? createAccountDeletedPage(appModel) :\n          errPage === \"signin-failed\" ? createSigninFailedPage(appModel, errMessage) :\n            errPage === \"unsubscribed\" ? createUnsubscribedPage(appModel, errMessage, errDetails) :\n              createOtherErrorPage(appModel, errMessage);\n}\n\n/**\n * Creates a page to show that the user has no access to this org.\n */\nexport function createForbiddenPage(appModel: AppModel, message?: string) {\n  document.title = t(\"Access denied{{suffix}}\", { suffix: getPageTitleSuffix(getGristConfig()) });\n\n  const isAnonym = () => !appModel.currentValidUser;\n  const isExternal = () => appModel.currentValidUser?.loginMethod === \"External\";\n  return pagePanelsError(appModel, t(\"Access denied{{suffix}}\", { suffix: \"\" }), [\n    dom.domComputed(appModel.currentValidUser, user => user ? [\n      cssErrorText(message || t(\"You do not have access to this organization's documents.\")),\n      cssErrorText(t(\"You are signed in as {{email}}. You can sign in with a different \\\naccount, or ask an administrator for access.\", { email: dom(\"b\", user.email) })),\n    ] : [\n      // This page is not normally shown because a logged out user with no access will get\n      // redirected to log in. But it may be seen if a user logs out and returns to a cached\n      // version of this page or is an external user (connected through GristConnect).\n      cssErrorText(t(\"Sign in to access this organization's documents.\")),\n    ]),\n    cssButtonWrap(bigPrimaryButtonLink(\n      isExternal() ? t(\"Go to main page\") :\n        isAnonym() ? t(\"Sign in\") :\n          t(\"Add account\"),\n      { href: isExternal() ? getMainOrgUrl() : getLoginUrl() },\n      testId(\"error-signin\"),\n    )),\n  ]);\n}\n\n/**\n * Creates a page that shows the user is logged out.\n */\nexport function createSignedOutPage(appModel: AppModel) {\n  document.title = t(\"Signed out{{suffix}}\", { suffix: getPageTitleSuffix(getGristConfig()) });\n\n  return pagePanelsError(appModel, t(\"Signed out{{suffix}}\", { suffix: \"\" }), [\n    cssErrorText(t(\"You are now signed out.\")),\n    signInAgainButton(),\n  ]);\n}\n\n/**\n * Creates a page that shows the user is logged out.\n */\nexport function createAccountDeletedPage(appModel: AppModel) {\n  document.title = t(\"Account deleted{{suffix}}\", { suffix: getPageTitleSuffix(getGristConfig()) });\n\n  return pagePanelsError(appModel, t(\"Account deleted{{suffix}}\", { suffix: \"\" }), [\n    cssErrorText(t(\"Your account has been deleted.\")),\n    cssButtonWrap(bigPrimaryButtonLink(\n      t(\"Sign up\"), { href: getSignupUrl() }, testId(\"error-signin\"),\n    )),\n  ]);\n}\n\nexport function createUnsubscribedPage(\n  appModel: AppModel,\n  errMessage: string | undefined,\n  errDetails: Record<string, string | undefined> | undefined,\n) {\n  document.title = t(\"Unsubscribed{{suffix}}\", { suffix: getPageTitleSuffix(getGristConfig()) });\n  const docUrl = errDetails?.docUrl;\n\n  if (errMessage) {\n    return pagePanelsError(appModel, t(\"We could not unsubscribe you\"), [\n      cssErrorText(\n        cssErrorText.cls(\"-narrow\"),\n        t(\"There was an error\"), \": \", addPeriod(errMessage),\n      ),\n      docUrl && cssErrorText(\n        cssErrorText.cls(\"-narrow\"),\n        addPeriod(\n          t(\"You can still unsubscribe from this document by updating your preferences in the document settings\"),\n        ),\n      ),\n      docUrl && cssButtonWrap(bigBasicButtonLink(t(\"Manage settings\"), { href: `${docUrl}/p/settings` })),\n      cssContactSupportDiv(\n        t(\"Need Help?\"), \" \", cssLink(\n          t(\"Contact support\"), { href: commonUrls.contactSupport },\n        ),\n      ),\n    ]);\n  }\n\n  // Extract details from errDetails\n  const docName = errDetails?.docName || t(\"this document\");\n  const notification = errDetails?.notification;\n  const mode = errDetails?.mode;\n  const email = errDetails?.email;\n\n  let message: DomContents;\n  let description: DomContents;\n  if (notification === \"docChanges\") {\n    message = t(\n      \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\",\n      {\n        changes: dom(\"b\", t(\"changes\")),\n        docName: dom(\"b\", docName),\n        email: dom(\"b\", email || t(\"your email\")),\n      },\n    );\n\n    description = t(\n      \"You have been unsubscribed from notifications about changes to {{docName}}. You can update \" +\n      \"your preferences anytime in the document settings.\",\n      {\n        docName: dom(\"b\", docName),\n      },\n    );\n  } else if (notification === \"suggestions\") {\n    message = t(\n      \"You will no longer receive email notifications about {{suggestions}} in {{docName}} at {{email}}.\",\n      {\n        suggestions: dom(\"b\", t(\"suggestions\")),\n        docName: dom(\"b\", docName),\n        email: dom(\"b\", email || t(\"your email\")),\n      },\n    );\n\n    description = t(\n      \"You have been unsubscribed from notifications about suggestions to {{docName}}. You can update \" +\n      \"your preferences anytime in the document settings.\",\n      {\n        docName: dom(\"b\", docName),\n      },\n    );\n  } else if (mode === \"full\") {\n    message = t(\n      \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\",\n      {\n        comments: dom(\"b\", t(\"comments\")),\n        docName: dom(\"b\", docName),\n        email: dom(\"b\", email || t(\"your email\")),\n      },\n    );\n\n    description = t(\n      \"You have been unsubscribed from notifications about any comments in {{docName}}, including mentions \" +\n      \"of you and replies to your comments. You can update your preferences anytime in the document settings.\",\n      {\n        docName: dom(\"b\", docName),\n      },\n    );\n  } else {\n    message = t(\n      \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\",\n      {\n        comments: dom(\"b\", t(\"comments\")),\n        docName: dom(\"b\", docName),\n        email: dom(\"b\", email || t(\"your email\")),\n      },\n    );\n\n    description = t(\n      \"You have been unsubscribed from notifications about comments in {{docName}}, \" +\n      \"except for mentions of you and replies to your comments. You can update your \" +\n      \"preferences anytime in the document settings.\",\n      {\n        docName: dom(\"b\", docName),\n      },\n    );\n  }\n\n  return pagePanelsError(appModel, t(\"You are unsubscribed\"), [\n    cssErrorText(\n      cssErrorText.cls(\"-narrow\"),\n      dom(\"p\", message),\n      description && dom(\"p\", description),\n    ),\n    cssButtonWrap(bigBasicButtonLink(t(\"Manage settings\"), { href: `${docUrl}/p/settings` })),\n    cssContactSupportDiv(\n      t(\"Need Help?\"), \" \", cssLink(\n        t(\"Contact support\"), { href: commonUrls.contactSupport },\n      ),\n    ),\n  ]);\n}\n\n/**\n * Creates a \"Page not found\" page.\n */\nexport function createNotFoundPage(appModel: AppModel, message?: string) {\n  document.title = t(\"Page not found{{suffix}}\", { suffix: getPageTitleSuffix(getGristConfig()) });\n\n  return pagePanelsError(appModel, t(\"Page not found{{suffix}}\", { suffix: \"\" }), [\n    cssErrorText(message ||\n      t(\"The requested page could not be found.{{separator}}Please check the URL and try again.\", {\n        separator: dom(\"br\"),\n      })),\n    cssButtonWrap(bigPrimaryButtonLink(t(\"Go to main page\"), testId(\"error-primary-btn\"),\n      urlState().setLinkUrl({}))),\n    cssButtonWrap(bigBasicButtonLink(t(\"Contact support\"), { href: commonUrls.contactSupport })),\n  ]);\n}\n\nexport function createSigninFailedPage(appModel: AppModel, message?: string) {\n  document.title = t(\"Sign-in failed{{suffix}}\", { suffix: getPageTitleSuffix(getGristConfig()) });\n  return pagePanelsError(appModel, t(\"Sign-in failed{{suffix}}\", { suffix: \"\" }), [\n    cssErrorText(message ??\n      t(\"Failed to log in.{{separator}}Please try again or contact support.\", {\n        separator: dom(\"br\"),\n      })),\n    signInAgainButton(),\n    cssButtonWrap(bigBasicButtonLink(t(\"Contact support\"), { href: commonUrls.contactSupport })),\n  ]);\n}\n\n/**\n * Creates a generic error page with the given message.\n */\nexport function createOtherErrorPage(appModel: AppModel, message?: string) {\n  document.title = t(\"Error{{suffix}}\", { suffix: getPageTitleSuffix(getGristConfig()) });\n\n  return pagePanelsError(appModel, t(\"Something went wrong\"), [\n    cssErrorText(message ? t(\"There was an error: {{message}}\", { message: addPeriod(message) }) :\n      t(\"There was an unknown error.\")),\n    cssButtonWrap(bigPrimaryButtonLink(t(\"Go to main page\"), testId(\"error-primary-btn\"),\n      urlState().setLinkUrl({}))),\n    cssButtonWrap(bigBasicButtonLink(t(\"Contact support\"), { href: commonUrls.contactSupport })),\n  ]);\n}\n\nfunction addPeriod(msg: string): string {\n  return msg.endsWith(\".\") ? msg : msg + \".\";\n}\n\nfunction pagePanelsError(appModel: AppModel, header: string, content: DomElementArg) {\n  const panelOpen = observable(false);\n  return pagePanels({\n    leftPanel: {\n      panelWidth: observable(240),\n      panelOpen,\n      hideOpener: true,\n      header: dom.create(AppHeader, appModel),\n      content: leftPanelBasic(appModel, panelOpen),\n    },\n    headerMain: createTopBarHome(appModel),\n    contentMain: cssCenteredContent(cssErrorContent(\n      cssBigIcon(),\n      cssErrorHeader(header, testId(\"error-header\")),\n      content,\n      testId(\"error-content\"),\n    )),\n  });\n}\n\nconst cssCenteredContent = styled(\"div\", `\n  width: 100%;\n  height: 100%;\n  overflow-y: auto;\n`);\n\nconst cssErrorContent = styled(\"div\", `\n  text-align: center;\n  margin: 64px 0 64px;\n`);\n\nconst cssBigIcon = styled(\"div\", `\n  display: inline-block;\n  width: 100%;\n  height: 64px;\n  background-image: var(--icon-GristLogo);\n  background-size: contain;\n  background-repeat: no-repeat;\n  background-position: center;\n`);\n\nconst cssErrorHeader = styled(\"div\", `\n  font-weight: ${vars.headerControlTextWeight};\n  font-size: ${vars.xxxlargeFontSize};\n  margin: 24px;\n  text-align: center;\n  color: ${theme.text};\n`);\n\nconst cssErrorText = styled(\"div\", `\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.text};\n  margin: 0 auto 24px auto;\n  max-width: 400px;\n  text-align: center;\n`);\n\nconst cssButtonWrap = styled(\"div\", `\n  margin-bottom: 8px;\n`);\n\nconst cssContactSupportDiv = styled(\"div\", `\n  margin-top: 24px;\n`);\n"
  },
  {
    "path": "app/client/ui/forms.ts",
    "content": "/**\n * Collection of styled elements to put together basic forms. Intended usage is:\n *\n *   return forms.form({method: 'POST',\n *     forms.question(\n *       forms.text('What color is the sky right now?'),\n *       forms.checkboxItem([{name: 'sky-blue'}], 'Blue'),\n *       forms.checkboxItem([{name: 'sky-orange'}], 'Orange'),\n *       forms.checkboxOther([], {name: 'sky-other', placeholder: 'Other...'}),\n *     ),\n *     forms.question(\n *       forms.text('What is the meaning of life, universe, and everything?'),\n *       forms.textBox({name: 'meaning', placeholder: 'Your answer'}),\n *     ),\n *   );\n */\nimport { cssCheckboxSquare, cssLabel } from \"app/client/ui2018/checkbox\";\n\nimport { dom, DomArg, DomElementArg, Observable, styled } from \"grainjs\";\n\nexport {\n  form,\n  cssQuestion as question,\n  cssText as text,\n  textBox,\n};\n\n/**\n * Create a checkbox accompanied by a label. The first argument should be the (possibly empty)\n * array of arguments to the checkbox; the rest goes into the label. E.g.\n *    checkboxItem([{name: 'ok'}], 'Check to approve');\n */\nexport function checkboxItem(\n  checkboxArgs: DomArg<HTMLInputElement>[], ...labelArgs: DomElementArg[]\n): HTMLElement {\n  return cssCheckboxLabel(\n    cssCheckbox({ type: \"checkbox\" }, ...checkboxArgs),\n    ...labelArgs);\n}\n\n/**\n * Create a checkbox accompanied by a textbox, for a choice of \"Other\". The checkbox gets checked\n * automatically when something is typed into the textbox.\n *    checkboxOther([{name: 'choice-other'}], {name: 'other-text', placeholder: '...'});\n */\nexport function checkboxOther(checkboxArgs: DomElementArg[], ...textboxArgs: DomElementArg[]): HTMLElement {\n  let checkbox: HTMLInputElement;\n  return cssCheckboxLabel(\n    checkbox = cssCheckbox({ type: \"checkbox\" }, ...checkboxArgs),\n    cssTextBox(...textboxArgs,\n      dom.on(\"input\", (e, elem) => { checkbox.checked = Boolean(elem.value); }),\n    ),\n  );\n}\n\n/**\n * Returns whether the form is fully filled, i.e. has a value for each of the provided names of\n * form elements. If a name ends with \"*\", it is treated as a prefix, and any element matching it\n * would satisfy this key (e.g. use \"foo_*\" to accept any checkbox named \"foo_<something>\").\n */\nexport function isFormFilled(formElem: HTMLFormElement, names: string[]): boolean {\n  const formData = new FormData(formElem);\n  return names.every(name => hasValue(formData, name));\n}\n\n/**\n * Returns true of the form includes a non-empty value for the given name. If the second argument\n * ends with \"-\", it is treated as a prefix, and the function returns true if the form includes\n * any value for a key that starts with that prefix.\n */\nexport function hasValue(formData: FormData, nameOrPrefix: string): boolean {\n  if (nameOrPrefix.endsWith(\"*\")) {\n    const prefix = nameOrPrefix.slice(0, -1);\n    return [...formData.keys()].filter(k => k.startsWith(prefix)).some(k => formData.get(k));\n  } else {\n    return Boolean(formData.get(nameOrPrefix));\n  }\n}\n\nfunction resize(el: HTMLElement) {\n  el.style.height = \"5px\"; // hack for triggering style update.\n  const border = getComputedStyle(el, null).borderTopWidth || \"0\";\n  el.style.height = `calc(${el.scrollHeight}px + 2 * ${border})`;\n}\n\nexport function autoGrow(text: Observable<unknown>) {\n  // If this should autogrow we need to monitor width of this element.\n  return (el: HTMLElement) => {\n    let width = 0;\n    const resizeObserver = new ResizeObserver((entries) => {\n      const elem = entries[0].target as HTMLElement;\n      if (elem.offsetWidth !== width && width) {\n        resize(elem);\n      }\n      width = elem.offsetWidth;\n    });\n    resizeObserver.observe(el);\n    dom.onDisposeElem(el, () => resizeObserver.disconnect());\n    el.addEventListener(\"input\", () => resize(el));\n    dom.autoDisposeElem(el, text.addListener(() => setTimeout(() => resize(el), 0)));\n    setTimeout(() => resize(el), 10);\n    dom.autoDisposeElem(el, text.addListener((val) => {\n      // Changes to the text are not reflected by the input event (witch is used by the autoGrow)\n      // So we need to manually update the textarea when the text is cleared.\n      if (!val) {\n        el.style.height = \"5px\"; // there is a min-height css attribute, so this is only to trigger a style update.\n      }\n    }));\n  };\n}\n\nconst cssForm = styled(\"form\", `\n  margin-bottom: 32px;\n  font-size: 14px;\n  &:focus {\n    outline: none;\n  }\n  & input:focus, & button:focus {\n    outline: none;\n    box-shadow: 0 0 1px 2px lightblue;\n  }\n`);\n\nconst cssQuestion = styled(\"div\", `\n  margin: 32px 0;\n  padding-left: 24px;\n  & > :first-child {\n    margin-left: -24px;\n  }\n`);\n\nconst cssText = styled(\"div\", `\n  margin: 16px 0;\n  font-size: 15px;\n`);\n\nconst cssCheckboxLabel = styled(cssLabel, `\n  font-size: 14px;\n  font-weight: normal;\n  display: flex;\n  align-items: center;\n  margin: 12px 0;\n  user-select: unset;\n`);\n\nconst cssCheckbox = styled(cssCheckboxSquare, `\n  position: relative;\n  margin-right: 12px !important;\n  border-radius: var(--radius);\n`);\n\nconst cssTextBox = styled(\"input\", `\n  flex: auto;\n  width: 100%;\n  font-size: inherit;\n  padding: 4px 8px;\n  border: 1px solid #D9D9D9;\n  border-radius: 3px;\n\n  &-invalid {\n    color: red;\n  }\n`);\n\nconst form = cssForm.bind(null, { tabIndex: \"-1\" });\nconst textBox = cssTextBox.bind(null, { type: \"text\" });\n"
  },
  {
    "path": "app/client/ui/googleAuth.ts",
    "content": "import { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { GristLoadConfig } from \"app/common/gristUrls\";\n\nimport type { Disposable } from \"grainjs\";\n\n/**\n * Functions to perform server side authentication with Google.\n *\n * The authentication flow is performed by server side (app/server/lib/GoogleAuth.ts). Here we will\n * open up a popup with a stub html file (served by the server), that will redirect user to Google\n * Auth Service. In return, we will get authorization_code (which will be delivered by a postMessage\n * from the iframe), that when converted to authorization_token, can be used to access Google Drive\n * API. Accessing Google Drive files is done by the server, here we only ask for the permissions.\n *\n * Exposed methods are:\n * - getGoogleCodeForSending: asks google for a permission to create files on the drive (and read\n *                            them)\n * - getGoogleCodeForReading: asks google for a permission to read all files\n * - canReadPrivateFiles:     Grist by default won't ask for permission to read all files, but can be\n *                            configured this way by an environmental variable.\n */\n\nconst G = getBrowserGlobals(\"window\");\n\nexport const ACCESS_DENIED = \"access_denied\";\nexport const AUTH_INTERRUPTED = \"auth_interrupted\";\n\n// https://developers.google.com/identity/protocols/oauth2/scopes#drive\n// \"View and manage Google Drive files and folders that you have opened or created with this app\"\nconst APP_SCOPE = \"https://www.googleapis.com/auth/drive.file\";\n// \"See and download all your Google Drive files\"\nconst READ_SCOPE = \"https://www.googleapis.com/auth/drive.readonly\";\n\nexport function getGoogleCodeForSending(owner: Disposable) {\n  return getGoogleAuthCode(owner, APP_SCOPE);\n}\n\nexport function getGoogleCodeForReading(owner: Disposable) {\n  const gristConfig: Partial<GristLoadConfig> = window.gristConfig || {};\n  // Default scope allows as to manage files we created.\n  return getGoogleAuthCode(owner, gristConfig.googleDriveScope || APP_SCOPE);\n}\n\n/**\n * Checks if default scope for Google Drive integration will allow to access all personal files\n */\nexport function canReadPrivateFiles() {\n  const gristConfig: Partial<GristLoadConfig> = window.gristConfig || {};\n  return gristConfig.googleDriveScope === READ_SCOPE;\n}\n\n/**\n * Opens up a popup with server side Google Authentication. Returns a code that can be used\n * by server side to retrieve access_token required in Google Api.\n */\nfunction getGoogleAuthCode(owner: Disposable, scope: string) {\n  // Compute google auth server endpoint (grist endpoint for server side google authentication).\n  // This endpoint renders a page that redirects user to Google Consent screen and after Google\n  // sends a response, it will post this response back to us.\n  // Message will be an object { code, error }.\n  const authLink = getGoogleAuthEndpoint(scope);\n  const authWindow = openPopup(authLink);\n  return new Promise<string>((resolve, reject) => {\n    attachListener(owner, authWindow, async (event: MessageEvent | null) => {\n      // If the no message, or window was closed (user closed it intentionally).\n      if (!event || authWindow.closed) {\n        reject(new Error(AUTH_INTERRUPTED));\n        return;\n      }\n      // For the first message (we expect only a single message) close the window.\n      authWindow.close();\n      if (owner.isDisposed()) {\n        reject(new Error(AUTH_INTERRUPTED));\n        return;\n      }\n      // Check response from the popup\n      const response = (event.data || {}) as { code?: string, error?: string };\n      // - when user declined, report back, caller should stop current flow,\n      if (response.error === \"access_denied\") {\n        reject(new Error(ACCESS_DENIED));\n        return;\n      }\n      // - when there is no authorization, or error is different from what we expected - report to user.\n      if (!response.code) {\n        reject(new Error(response.error || \"Missing authorization code\"));\n        return;\n      }\n      resolve(response.code);\n    });\n  });\n}\n\n// Helper function that attaches a handler to message event from a popup window.\nfunction attachListener(owner: Disposable, popup: Window, listener: (e: MessageEvent | null) => void) {\n  const wrapped = (e: MessageEvent) => {\n    // Listen to events only from our window.\n    if (e.source !== popup) { return; }\n    // In case when Grist was reloaded or user navigated away - do nothing.\n    if (owner.isDisposed()) { return; }\n    listener(e);\n    // Clear the listener, to avoid orphaned calls from closed event.\n    listener = () => {};\n  };\n  // Unfortunately there is no ease way to detect if user has closed the popup.\n  const closeHandler = onClose(popup, () => {\n    listener(null);\n    // Clear the listener, to avoid orphaned messages from window.\n    listener = () => {};\n  });\n  owner.onDispose(closeHandler);\n  G.window.addEventListener(\"message\", wrapped);\n  owner.onDispose(() => {\n    G.window.removeEventListener(\"message\", wrapped);\n  });\n}\n\n// Periodically checks if the window is closed.\n// Returns a function that can be used to cancel the event.\nfunction onClose(window: Window, clb: () => void) {\n  const interval = setInterval(() => {\n    if (window.closed) {\n      clearInterval(interval);\n      clb();\n    }\n  }, 1000);\n  return () => clearInterval(interval);\n}\n\nfunction openPopup(url: string): Window {\n  // Center window on desktop\n  // https://stackoverflow.com/questions/16363474/window-open-on-a-multi-monitor-dual-monitor-system-where-does-window-pop-up\n  const width = 600;\n  const height = 650;\n  const left = window.screenX + (screen.width - width) / 2;\n  const top = (screen.height - height) / 4;\n  let windowFeatures = `top=${top},left=${left},menubar=no,location=no,` +\n    `resizable=yes,scrollbars=yes,status=yes,height=${height},width=${width}`;\n\n  // If window will be too large (for example on mobile) - open as a new tab\n  if (screen.width <= width || screen.height <= height) {\n    windowFeatures = \"\";\n  }\n\n  const authWindow = G.window.open(url, \"GoogleAuthPopup\", windowFeatures);\n  if (!authWindow) {\n    // This method should be invoked by an user action.\n    throw new Error(\"This method should be invoked synchronously\");\n  }\n  return authWindow;\n}\n\n/**\n * Generates Google Auth endpoint (exposed by Grist) url. For example:\n * https://docs.getgrist.com/auth/google\n * @param scope Requested access scope for Google Services:\n * https://developers.google.com/identity/protocols/oauth2/scopes\n */\nfunction getGoogleAuthEndpoint(scope: string) {\n  return new URL(`auth/google?scope=${scope}`, window.location.origin).href;\n}\n"
  },
  {
    "path": "app/client/ui/inputs.ts",
    "content": "import { autoGrow } from \"app/client/ui/forms\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\n\nimport { dom, DomElementArg, IDomArgs, IInputOptions, Observable, styled, subscribe } from \"grainjs\";\n\nexport const cssInput = styled(\"input\", `\n  font-size: ${vars.mediumFontSize};\n  height: 48px;\n  line-height: 20px;\n  width: 100%;\n  padding: 14px;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 4px;\n  outline: none;\n  display: block;\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n\n  &[type=number] {\n    -moz-appearance: textfield;\n  }\n  &[type=number]::-webkit-inner-spin-button,\n  &[type=number]::-webkit-outer-spin-button {\n    -webkit-appearance: none;\n    margin: 0;\n  }\n\n  &-invalid {\n    border: 1px solid ${theme.inputInvalid};\n  }\n\n  &-valid {\n    border: 1px solid ${theme.inputValid};\n  }\n`);\n\n/**\n * Builds a text input that updates `obs` as you type.\n */\nexport function textInput(obs: Observable<string | undefined>, ...args: DomElementArg[]): HTMLInputElement {\n  return cssInput(\n    dom.prop(\"value\", u => u(obs) || \"\"),\n    dom.on(\"input\", (_e, elem) => obs.set(elem.value)),\n    ...args,\n  );\n}\n\nexport interface ITextAreaOptions extends IInputOptions {\n  autoGrow?: boolean;\n  save?: (value: string) => void;\n}\n\nexport function textarea(\n  obs: Observable<string>, options?: ITextAreaOptions | null, ...args: IDomArgs<HTMLTextAreaElement>\n): HTMLTextAreaElement {\n  const isValid = options?.isValid;\n\n  function setValue(elem: HTMLTextAreaElement) {\n    if (options?.save) { options.save(elem.value); } else { obs.set(elem.value); }\n    if (isValid) { isValid.set(elem.validity.valid); }\n  }\n\n  const value = options?.autoGrow ? Observable.create(null, obs.get()) : null;\n  const trackInput = Boolean(options?.onInput || options?.autoGrow);\n  const onInput = trackInput ? dom.on(\"input\", (e, elem: HTMLTextAreaElement) => {\n    if (options?.onInput) {\n      setValue(elem);\n    }\n    if (options?.autoGrow) {\n      value?.set(elem.value);\n    }\n  }) : null;\n\n  return dom(\"textarea\", ...args,\n    value ? [\n      dom.autoDispose(value),\n      dom.autoDispose(obs.addListener(v => value.set(v))),\n    ] : null,\n    dom.prop(\"value\", use => use(obs) ?? \"\"),\n    (isValid ?\n      elem => dom.autoDisposeElem(elem,\n        subscribe(obs, use => isValid.set(elem.checkValidity()))) :\n      null),\n    onInput,\n    options?.autoGrow ? [\n      autoGrow(value!),\n      dom.style(\"resize\", \"none\"),\n    ] : null,\n    dom.on(\"change\", (e, elem) => setValue(elem)),\n  );\n}\n"
  },
  {
    "path": "app/client/ui/mouseDrag.ts",
    "content": "/**\n * Small utility to help with processing mouse-drag events. Usage is:\n *    dom('div', mouseDrag((startEvent, elem) => ({\n *      onMove(moveEvent) { ... },\n *      onStop(stopEvent) { ... },\n *    })));\n *\n * The passed-in callback is called on 'mousedown' events. It may return null to ignore the event.\n * Otherwise, it should return a handler for mousemove/mouseup: we will then subscribe to these\n * events, and clean up on mouseup.\n */\nimport { dom, DomElementMethod, IDisposable } from \"grainjs\";\n\nexport interface MouseDragHandler {\n  onMove(moveEv: MouseEvent): void;\n  onStop(endEv: MouseEvent): void;\n}\n\nexport type MouseDragStart = (startEv: MouseEvent, elem: HTMLElement) => MouseDragHandler | null;\n\nexport function mouseDragElem(elem: HTMLElement, onStart: MouseDragStart): IDisposable {\n  // This prevents the default text-drag behavior when elem is part of a text selection.\n  elem.style.userSelect = \"none\";\n\n  return dom.onElem(elem, \"mousedown\", (ev, el) => _startDragging(ev, el, onStart));\n}\nexport function mouseDrag(onStart: MouseDragStart): DomElementMethod {\n  return (elem) => { mouseDragElem(elem, onStart); };\n}\n\n// Same as mouseDragElem, but listens for mousedown on descendants of elem that match selector.\nexport function mouseDragMatchElem(elem: HTMLElement, selector: string, onStart: MouseDragStart): IDisposable {\n  return dom.onMatchElem(elem, selector, \"mousedown\",\n    (ev, el) => _startDragging(ev as MouseEvent, el as HTMLElement, onStart));\n}\n\nfunction _startDragging(startEv: MouseEvent, elem: HTMLElement, onStart: MouseDragStart) {\n  const dragHandler = onStart(startEv, elem);\n  if (dragHandler) {\n    const { onMove, onStop } = dragHandler;\n    const upLis = dom.onElem(document, \"mouseup\", stop, { useCapture: true });\n    const moveLis = dom.onElem(document, \"mousemove\", onMove, { useCapture: true });\n\n    function stop(stopEv: MouseEvent) {\n      moveLis.dispose();\n      upLis.dispose();\n      onStop(stopEv);\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/ui/resizeHandle.ts",
    "content": "/**\n * Exports resizeFlexVHandle() for resizing flex items. The returned handle should be attached to\n * a flexbox container next to the item that needs resizing. Usage:\n *\n *    dom('div.flex',\n *      dom('div.child-to-be-resized', ...),\n *      resizeFlexVHandle({target: 'left', onSave: (width) => { ... })\n *      dom('div.other-children', ...),\n *    )\n *\n * The .target parameter determines whether to resize the left or right sibling of the handle. The\n * handle shows up as a 1px line of color --resize-handle-color. On hover and while resizing, it\n * changes to --resize-handle-highlight.\n *\n * The handle may be dragged to change the width of the target. It sets style.width while\n * dragging, and calls .onSave(width) at the end, and optionally .onDrag(width) while dragging.\n *\n * You may limit the width of the target with min-width, max-width properties as usual.\n *\n * At the moment, flexbox width resizing is the only need, but the same approach is intended to be\n * easily extended to non-flexbox situation, and to height-resizing.\n */\nimport { mouseDrag } from \"app/client/ui/mouseDrag\";\n\nimport { DomElementArg, styled } from \"grainjs\";\n\nexport type ChangeFunc = (value: number) => void;\nexport type Edge = \"left\" | \"right\";\n\nexport interface IResizeFlexOptions {\n  // Whether to change the width of the flex item to the left or to the right of this handle.\n  target: \"left\" | \"right\";\n  onDrag?(value: number): void;\n  onSave?(value: number): void;\n}\n\nexport interface IResizeOptions {\n  prop: \"width\" | \"height\";\n  sign: 1 | -1;\n  getTarget(handle: Element): Element | null;\n  onDrag?(value: number): void;\n  onSave?(value: number): void;\n}\n\n// See module documentation for usage.\nexport function resizeFlexVHandle(options: IResizeFlexOptions, ...args: DomElementArg[]): Element {\n  const resizeOptions: IResizeOptions = {\n    prop: \"width\",\n    sign: options.target === \"left\" ? 1 : -1,\n    getTarget(handle: Element) {\n      return options.target === \"left\" ? handle.previousElementSibling : handle.nextElementSibling;\n    },\n    onDrag: options.onDrag,\n    onSave: options.onSave,\n  };\n  return cssResizeFlexVHandle(\n    mouseDrag((ev, handle) => onResizeStart(ev, handle, resizeOptions)),\n    ...args);\n}\n\n// The core of the implementation. This is intended to be very general, so as to be adaptable to\n// other kinds of resizing (edge of parent, resizing height) by only attaching this to the\n// mouseDrag() event with suitable options. See resizeFlexVHandle().\nfunction onResizeStart(startEv: MouseEvent, handle: Element, options: IResizeOptions) {\n  const target = options.getTarget(handle) as HTMLElement | null;\n  if (!target) { return null; }\n\n  const { sign, prop, onDrag, onSave } = options;\n  const startSize = getComputedSize(target, prop);\n\n  // Set the body cursor to that on the handle, so that it doesn't jump to different shapes as\n  // the mouse moves temporarily outside of the handle. (Approach from jqueryui-resizable.)\n  const startBodyCursor = document.body.style.cursor;\n  document.body.style.cursor = window.getComputedStyle(handle).cursor;\n\n  handle.classList.add(cssResizeDragging.className);\n\n  return {\n    // While moving, just adjust the size of the target, relying on min-width/max-width for\n    // constraints.\n    onMove(ev: MouseEvent) {\n      target.style[prop] = (startSize + sign * (ev.pageX - startEv.pageX)) + \"px\";\n      if (onDrag) { onDrag(getComputedSize(target, prop)); }\n    },\n\n    // At the end, undo temporary changes, and call onSave() with the actual size.\n    onStop(ev: MouseEvent) {\n      handle.classList.remove(cssResizeDragging.className);\n\n      // Restore the body cursor to what it was.\n      document.body.style.cursor = startBodyCursor;\n\n      target.style[prop] = (startSize + sign * (ev.pageX - startEv.pageX)) + \"px\";\n      onSave?.(getComputedSize(target, prop));\n    },\n  };\n}\n\n// Compute the CSS width or height of the element. If element.style[prop] is set to it, it should\n// be unchanged. (Note that when an element has borders or padding, the size from\n// getBoundingClientRect() would be different, and isn't suitable for style[prop].)\nfunction getComputedSize(elem: Element, prop: \"width\" | \"height\"): number {\n  const sizePx = window.getComputedStyle(elem)[prop];\n  const sizeNum = sizePx && parseFloat(sizePx);\n  // If we can't get the size, fall back to getBoundingClientRect().\n  return Number.isFinite(sizeNum as number) ? sizeNum as number : elem.getBoundingClientRect()[prop];\n}\n\nconst cssResizeFlexVHandle = styled(\"div\", `\n  position: relative;\n  flex: none;\n  top: 0;\n  height: 100%;\n  cursor: ew-resize;\n  z-index: 10;\n\n  /* width with negative margins leaves exactly 1px line in the middle */\n  width: 7px;\n  margin: auto -3px;\n\n  /* two highlighted 1px lines are placed in the middle, normal and highlighted one */\n  &::before, &::after {\n    content: \"\";\n    position: absolute;\n    height: 100%;\n    width: 1px;\n    left: 3px;\n  }\n  &::before {\n    background-color: var(--resize-handle-color, lightgrey);\n  }\n  /* the highlighted line is shown on hover with opacity transition */\n  &::after {\n    background-color: var(--resize-handle-highlight, black);\n    opacity: 0;\n    transition: opacity linear 0.2s;\n  }\n  &:hover::after {\n    opacity: 1;\n    transition: opacity linear 0.2s 0.2s;\n  }\n  /* the highlighted line is also always shown while dragging */\n  &-dragging::after {\n    opacity: 1;\n    transition: none !important;\n  }\n`);\n\n// May be applied to Handle class to show the highlighted line while dragging.\nconst cssResizeDragging = styled(\"div\", `\n  &::after {\n    opacity: 1;\n    transition: none !important;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/sanitizeHTML.ts",
    "content": "import createDOMPurifier from \"dompurify\";\n\nexport function sanitizeHTML(source: string | Node): string {\n  return defaultPurifier.sanitize(source);\n}\n\nexport function sanitizeHTMLIntoDOM(source: string | Node): DocumentFragment {\n  try {\n    return defaultPurifier.sanitize(source, { RETURN_DOM_FRAGMENT: true });\n  } catch (err) {\n    // There seems to be a regression in Chrome during printing related to TrustedTypes (see\n    // https://issues.chromium.org/issues/40138301). We attempt a workaround by forcing\n    // DOMPurify to avoid using TrustedTypes. Keep workaround narrowly limited to printing.\n    if ((window as any).isCurrentlyPrinting) {\n      console.warn(\"Working around error from dompurify during printing\", err);\n      return defaultPurifier.sanitize(source, {\n        RETURN_DOM_FRAGMENT: true,\n        TRUSTED_TYPES_POLICY: {\n          createHTML: (html: string) => html,\n          createScriptURL: (scriptUrl: string) => scriptUrl,\n        } as any,    // We need a cast because it's an incomplete stub of TrustedTypePolicy,\n        // just the bits that dompurify actually calls.\n      });\n    }\n    throw err;\n  }\n}\n\nexport function sanitizeTutorialHTML(source: string | Node): string {\n  return tutorialPurifier.sanitize(source, {\n    ADD_TAGS: [\"iframe\"],\n    ADD_ATTR: [\"allowFullscreen\"],\n  });\n}\n\nconst defaultPurifier = createDOMPurifier();\nconst tutorialPurifier = createDOMPurifier();\n\n// If we are executed in a browser, we can add hooks to the purifiers to customize their behavior.\n// But sometimes this code is included in tests, where `window` is not defined.\nif (typeof window !== \"undefined\") {\n  defaultPurifier.addHook(\"afterSanitizeAttributes\", handleAfterSanitizeAttributes);\n\n  tutorialPurifier.addHook(\"afterSanitizeAttributes\", handleAfterSanitizeAttributes);\n  tutorialPurifier.addHook(\"uponSanitizeElement\", handleSanitizeTutorialElement);\n}\n\nfunction handleAfterSanitizeAttributes(node: Element) {\n  // Code copied from:\n  // https://github.com/cure53/DOMPurify/blob/main/demos/hooks-target-blank-demo.html\n  if (\"target\" in node) {\n    node.setAttribute(\"target\", \"_blank\");\n    node.setAttribute(\"rel\", \"noopener noreferrer\");\n  }\n}\n\nfunction handleSanitizeTutorialElement(node: Node, data: createDOMPurifier.UponSanitizeElementHookEvent) {\n  if (data.tagName !== \"iframe\") { return; }\n\n  const src = (node as Element).getAttribute(\"src\");\n  if (src?.startsWith(\"https://www.youtube.com/embed/\")) {\n    return;\n  }\n\n  node.parentNode?.removeChild(node);\n}\n"
  },
  {
    "path": "app/client/ui/searchDropdown.ts",
    "content": "// A dropdown with a search input to better navigate long list with\n// keyboard. Dropdown features a search input and reoders the list of\n// items to bring best matches at the top.\n\nimport { ACIndexImpl, ACIndexOptions, ACItem, buildHighlightedDom, HighlightFunc,\n  normalizeText } from \"app/client/lib/ACIndex\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { getOptionFull, SimpleList } from \"app/client/lib/simpleList\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menuDivider } from \"app/client/ui2018/menus\";\n\nimport { Disposable, dom, DomElementMethod, IOptionFull, makeTestId, Observable, styled } from \"grainjs\";\nimport mergeWith from \"lodash/mergeWith\";\nimport { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from \"popweasel\";\n\nconst t = makeT(\"searchDropdown\");\n\nconst testId = makeTestId(\"test-sd-\");\n\nexport type { HighlightFunc } from \"app/client/lib/ACIndex\";\n\nexport type IOption<T> = (T & string) | IOptionFull<T>;\n\nexport interface IDropdownWithSearchOptions<T> {\n\n  // the callback to trigger on selection\n  action: (value: T) => void;\n\n  // list of options\n  options: () => IOption<T>[],\n\n  /** Called when the dropdown menu is disposed. */\n  onClose?: () => void;\n\n  // place holder for the search input. Default to 'Search'\n  placeholder?: string;\n\n  // popup options\n  popupOptions?: IPopupOptions;\n\n  /** ACIndexOptions to use for indexing and searching items. */\n  acOptions?: ACIndexOptions;\n\n  /**\n   * If set, the width of the dropdown menu will be equal to that of\n   * the trigger element.\n   */\n  matchTriggerElemWidth?: boolean;\n}\n\nexport class OptionItem<T> implements ACItem, IOptionFull<T> {\n  public label = this._params.label;\n  public value = this._params.value;\n  public disabled = this._params.disabled;\n  public cleanText = normalizeText(this.label);\n\n  constructor(private _params: IOptionFull<T>) {\n\n  }\n}\n\nclass TruncatedListItem<T> extends OptionItem<T> {\n  constructor(label: string) {\n    super({\n      label,\n      value: \"\" as unknown as T,\n      disabled: true,\n    });\n  }\n}\n\nexport function dropdownWithSearch<T>(options: IDropdownWithSearchOptions<T>): DomElementMethod {\n  return (elem) => {\n    const popupOptions = mergeWith(\n      {}, defaultMenuOptions, options.popupOptions,\n      (_objValue: any, srcValue: any) => Array.isArray(srcValue) ? srcValue : undefined,\n    );\n    setPopupToFunc(\n      elem,\n      ctl => (DropdownWithSearch<T>).create(null, ctl, options),\n      popupOptions,\n    );\n  };\n}\n\nclass DropdownWithSearch<T> extends Disposable {\n  private _items: Observable<OptionItem<T>[]>;\n  private _acIndex: ACIndexImpl<OptionItem<T>>;\n  private _inputElem: HTMLInputElement;\n  private _simpleList: SimpleList<T>;\n  private _highlightFunc: HighlightFunc;\n\n  constructor(private _ctl: IOpenController, private _options: IDropdownWithSearchOptions<T>) {\n    super();\n    const acItems = _options.options().map(getOptionFull).map(params => new OptionItem(params));\n    this._acIndex = new ACIndexImpl<OptionItem<T>>(acItems, this._options.acOptions);\n    this._items = Observable.create<OptionItem<T>[]>(this, acItems);\n    this._highlightFunc = () => [];\n    this._simpleList = this._buildSimpleList();\n    this._simpleList.listenKeys(this._inputElem);\n    this._update();\n    // auto-focus the search input\n    setTimeout(() => this._inputElem.focus(), 1);\n    this._ctl.onDispose(() => _options.onClose?.());\n  }\n\n  public get content(): HTMLElement {\n    return this._simpleList.content;\n  }\n\n  private _buildSimpleList() {\n    const action = this._action.bind(this);\n    const headerDom = this._buildHeader.bind(this);\n    const renderItem = this._buildItem.bind(this);\n    return (SimpleList<T>).create(this, this._ctl, this._items, action, {\n      matchTriggerElemWidth: this._options.matchTriggerElemWidth,\n      headerDom,\n      renderItem,\n    });\n  }\n\n  private _buildHeader() {\n    return [\n      cssMenuHeader(\n        cssSearchIcon(\"Search\"),\n        this._inputElem = cssSearch(\n          { placeholder: this._options.placeholder || t(\"Search\") },\n          dom.on(\"input\", () => { this._update(); }),\n          dom.on(\"blur\", () => setTimeout(() => this._inputElem.focus(), 0)),\n        ),\n\n        // Prevents click on header to close menu\n        dom.on(\"click\", ev => ev.stopPropagation()),\n        testId(\"search\"),\n      ),\n      cssMenuDivider(),\n    ];\n  }\n\n  private _buildItem(item: OptionItem<T>) {\n    return item instanceof TruncatedListItem ?\n      [this._buildTruncatedMsgItem(item), testId(\"truncated-message\")] :\n      [buildHighlightedDom(item.label, this._highlightFunc, cssMatchText), testId(\"searchable-list-item\")];\n  }\n\n  private _buildTruncatedMsgItem(item: TruncatedListItem<T>) {\n    return cssTruncatedMessageItem(\n      item.label,\n      // Prevents click to close menu\n      dom.on(\"click\", ev => ev.stopPropagation()),\n    );\n  }\n\n  private _update() {\n    const acResults = this._acIndex.search(this._inputElem?.value || \"\");\n    this._highlightFunc = acResults.highlightFunc;\n    let items = acResults.items;\n    if (items.length < this._acIndex.totalItems) {\n      items = items.concat(new TruncatedListItem(\n        t(\"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\", {\n          displayedCount: items.length,\n          totalCount: this._acIndex.totalItems,\n        }),\n      ) as OptionItem<T>);\n    }\n    this._items.set(items);\n    this._simpleList.setSelected(acResults.selectIndex);\n  }\n\n  private _action(value: T | null) {\n    // If value is null, simply close the menu. This happens when pressing enter with no element\n    // selected.\n    if (value !== null) {\n      this._options.action(value);\n    }\n    this._ctl.close();\n  }\n}\n\nconst cssMatchText = styled(\"span\", `\n  color: ${theme.autocompleteMatchText};\n  .${cssMenuItem.className}-sel > & {\n    color: ${theme.autocompleteSelectedMatchText};\n  }\n`);\nconst cssMenuHeader = styled(\"div\", `\n  display: flex;\n  padding: 13px 17px 15px 17px;\n`);\nconst cssSearchIcon = styled(icon, `\n  --icon-color: ${theme.lightText};\n  flex-shrink: 0;\n  margin-left: auto;\n  margin-right: 4px;\n`);\nconst cssSearch = styled(\"input\", `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  flex-grow: 1;\n  min-width: 1px;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n\n  font-size: ${vars.mediumFontSize};\n\n  margin: 0px 16px 0px 8px;\n  padding: 0px;\n  border: none;\n  outline: none;\n  outline-offset: 3px;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\nconst cssMenuDivider = styled(menuDivider, `\n  flex-shrink: 0;\n  margin: 0;\n`);\nconst cssTruncatedMessageItem = styled(\"div\", `\n  color: ${theme.inputPlaceholderFg};\n`);\n"
  },
  {
    "path": "app/client/ui/selectBy.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { DocModel, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { IPageWidget } from \"app/client/ui/PageWidgetPicker\";\nimport {\n  buildLinkNodes,\n  buildRefColLinkNodes,\n  isSummaryGroup,\n  isValidLink,\n  LinkNode,\n  LinkNodeColumn,\n  LinkNodeOperations,\n  LinkNodeSection,\n  LinkNodeTable,\n} from \"app/common/LinkNode\";\n\nimport { IOptionFull } from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\n\nconst t = makeT(\"selectBy\");\n\n// some unicode characters\nconst BLACK_CIRCLE = \"\\u2022\";\nconst RIGHT_ARROW = \"\\u2192\";\n\n// Describes a link\nexport interface IPageWidgetLink {\n\n  // The source section id\n  srcSectionRef: number;\n\n  // The source column id\n  srcColRef: number;\n\n  // The target col id\n  targetColRef: number;\n}\n\nexport const NoLink = linkId({\n  srcSectionRef: 0,\n  srcColRef: 0,\n  targetColRef: 0,\n});\n\n// Represents the differents way to reference to a section for linking\ntype MaybeSection = ViewSectionRec | IPageWidget;\n\n// Returns a list of options with all links that link one of the `source` section to the `target`\n// section. Each `opt.value` is a unique identifier (see: linkId() and linkFromId() for more\n// detail), and `opt.label` is a human readable representation of the form\n// `<section_name>[.<source-col-name>][ -> <target-col-name>]` where the <source-col-name> appears\n// only when linking from a reference column, as opposed to linking from the table directly. And the\n// <target-col-name> shows only when both <section_name>[.<source-col-name>] is ambiguous.\nexport function selectBy(docModel: DocModel, sources: ViewSectionRec[],\n  target: MaybeSection): IOptionFull<string>[] {\n  const sourceNodes = createNodesFromViewSections(docModel, sources);\n  const targetNodes = isViewSectionRec(target) ?\n    createNodesFromViewSections(docModel, [target]) :\n    createNodesFromPageWidget(docModel, target);\n\n  const NoLinkOption: IOptionFull<string> = {\n    label: t(\"Select widget\"),\n    value: NoLink,\n  };\n  const options = [NoLinkOption];\n  for (const srcNode of sourceNodes) {\n    const validTargets = targetNodes.filter(tgt => isValidLink(srcNode, tgt));\n    const hasMany = validTargets.length > 1;\n    for (const tgtNode of validTargets) {\n      // a unique identifier for this link\n      const value = linkId({\n        srcSectionRef: srcNode.section.id,\n        srcColRef: srcNode.column ? srcNode.column.id : 0,\n        targetColRef: tgtNode.column ? tgtNode.column.id : 0,\n      });\n\n      // a human readable description\n      let label = srcNode.section.title;\n\n      // add the source node col name (except for 'group') or nothing for table node\n      if (srcNode.column && !isSummaryGroup(srcNode)) {\n        label += ` ${BLACK_CIRCLE} ${srcNode.column.label}`;\n      }\n\n      // add the target column name (except for 'group') when clarification is needed, i.e. if either:\n      // - target has multiple valid nodes, or\n      // - source col is 'group' and is thus hidden.\n      //     Need at least one column name to distinguish from simply selecting by summary table.\n      //     This is relevant when a table has a column referencing itself.\n      if (tgtNode.column && !isSummaryGroup(tgtNode) && (hasMany || isSummaryGroup(srcNode))) {\n        label += ` ${RIGHT_ARROW} ${tgtNode.column.label}`;\n      }\n\n      // add the new option\n      options.push({ label, value });\n    }\n  }\n  return options;\n}\n\nfunction isViewSectionRec(section: MaybeSection): section is ViewSectionRec {\n  return Boolean((section as ViewSectionRec).getRowId);\n}\n\nfunction createNodesFromViewSections(\n  docModel: DocModel,\n  viewSections: ViewSectionRec[],\n): LinkNode[] {\n  const operations: LinkNodeOperations = {\n    getTableById: id => getLinkNodeTableById(docModel, id),\n    getSectionById: id => getLinkNodeSectionById(docModel, id),\n  };\n  const sections = viewSections\n    .filter(s => !s.isDisposed())\n    .map(s => getLinkNodeSectionById(docModel, s.getRowId()));\n  return buildLinkNodes(sections, operations);\n}\n\nfunction getLinkNodeTableById(docModel: DocModel, id: number): LinkNodeTable {\n  const table = docModel.tables.getRowModel(id);\n  return {\n    id: table.getRowId(),\n    tableId: table.primaryTableId.peek(),\n    isSummaryTable: table.primaryTableId.peek() !== table.tableId.peek(),\n    columns: table.columns\n      .peek()\n      .all()\n      .map(c => ({\n        id: c.getRowId(),\n        colId: c.colId.peek(),\n        label: c.label.peek(),\n        type: c.type.peek(),\n        summarySourceCol: c.summarySourceCol.peek(),\n      })),\n  };\n}\n\nfunction getLinkNodeSectionById(\n  docModel: DocModel,\n  id: number,\n): LinkNodeSection {\n  const section = docModel.viewSections.getRowModel(id);\n  return {\n    id: section.getRowId(),\n    tableRef: section.table.peek().getRowId(),\n    parentId: section.parentId.peek(),\n    tableId: section.table.peek().primaryTableId.peek(),\n    parentKey: section.parentKey.peek(),\n    title: section.titleDef.peek(),\n    allowSelectBy: section.allowSelectBy.peek(),\n    selectedRowsActive: section.selectedRowsActive.peek(),\n    linkSrcSectionRef: section.linkSrcSection.peek().getRowId(),\n    linkSrcColRef: section.linkSrcCol.peek().getRowId(),\n    linkTargetColRef: section.linkTargetCol.peek().getRowId(),\n  };\n}\n\n// Creates an array of LinkNode from a page widget.\nfunction createNodesFromPageWidget(docModel: DocModel, pageWidget: IPageWidget): LinkNode[] {\n  if (typeof pageWidget.table !== \"number\") { return []; }\n\n  const nodes: LinkNode[] = [];\n  let table = docModel.tables.getRowModel(pageWidget.table);\n  const isSummary = pageWidget.summarize;\n  const groupbyColumns = isSummary ? new Set(pageWidget.columns) : undefined;\n  let tableExists = true;\n  if (isSummary) {\n    const summaryTable = docModel.tables.rowModels.find(\n      tr  => tr?.summarySourceTable.peek() && isEqual(tr.summarySourceColRefs.peek(), groupbyColumns));\n    if (summaryTable) {\n      // The selected source table and groupby columns correspond to this existing summary table.\n      table = summaryTable;\n    } else {\n      // This summary table doesn't exist yet. `fromColumns` will be using columns from the source table.\n      // Make sure it only uses columns that are in the selected groupby columns.\n      // The resulting targetColRef will incorrectly be from the source table,\n      // but will be corrected in GristDoc.saveLink after the summary table is created.\n      tableExists = false;\n    }\n  }\n\n  const section = docModel.viewSections.getRowModel(pageWidget.section);\n  const mainNode: LinkNode = {\n    tableId: table.primaryTableId.peek(),\n    isSummary,\n    isAttachments: false, // hmm, we should need a check here in case attachments col is on the main-node link\n    // (e.g.: link from summary table with Attachments in group-by) but it seems to work fine as is\n    groupbyColumns,\n    widgetType: pageWidget.type,\n    ancestors: [],\n    isAncestorSameTableCursorLink: [],\n    section: {\n      id: section.getRowId(),\n      tableRef: section.tableRef.peek(),\n      parentId: section.parentId.peek(),\n      tableId: section.table.peek().primaryTableId.peek(),\n      parentKey: section.parentKey.peek(),\n      title: section.titleDef.peek(),\n      linkSrcSectionRef: section.linkSrcSectionRef.peek(),\n      linkSrcColRef: section.linkSrcColRef.peek(),\n      linkTargetColRef: section.linkTargetColRef.peek(),\n      allowSelectBy: section.allowSelectBy.peek(),\n      selectedRowsActive: section.selectedRowsActive.peek(),\n    },\n  };\n  nodes.push(mainNode);\n\n  let columns: LinkNodeColumn[] = table.columns.peek().peek().map(c => ({\n    id: c.getRowId(),\n    colId: c.colId.peek(),\n    label: c.label.peek(),\n    type: c.type.peek(),\n    summarySourceCol: c.summarySourceCol.peek(),\n  }));\n  if (!tableExists) {\n    columns = columns.filter(c => mainNode.groupbyColumns!.has(c.id));\n  }\n  nodes.push(...buildRefColLinkNodes(columns, mainNode));\n  return nodes;\n}\n\n// Returns an identifier to uniquely identify a link. Here we adopt a simple approach where\n// {srcSectionRef: 2, srcColRef: 3, targetColRef: 3} is turned into \"[2, 3, 3]\".\nexport function linkId(link: IPageWidgetLink) {\n  return JSON.stringify([link.srcSectionRef, link.srcColRef, link.targetColRef]);\n}\n\n// Returns link's properties from its identifier.\nexport function linkFromId(linkid: string): IPageWidgetLink {\n  const [srcSectionRef, srcColRef, targetColRef] = JSON.parse(linkid);\n  return { srcSectionRef, srcColRef, targetColRef };\n}\n"
  },
  {
    "path": "app/client/ui/sendToDrive.ts",
    "content": "import { get as getBrowserGlobals } from \"app/client/lib/browserGlobals\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/errors\";\nimport { getGoogleCodeForSending } from \"app/client/ui/googleAuth\";\nimport { spinnerModal } from \"app/client/ui2018/modals\";\n\nimport type { DocPageModel } from \"app/client/models/DocPageModel\";\nimport type { Document } from \"app/common/UserAPI\";\n\nconst G = getBrowserGlobals(\"window\");\n\nconst t = makeT(\"sendToDrive\");\n\n/**\n * Sends xlsx file to Google Drive. It first authenticates with Google to get encrypted access\n * token, then it calls \"send-to-drive\" api endpoint to upload xlsx file to drive and finally it\n * redirects to the created spreadsheet. Code that is received from Google contains encrypted access\n * token, server is able to decrypt it using GOOGLE_CLIENT_SECRET key.\n */\nexport async function sendToDrive(doc: Document, pageModel: DocPageModel) {\n  // Get current document - it will be used to remove popup listener.\n  const gristDoc = pageModel.gristDoc.get();\n  // Sanity check - gristDoc should be always present\n  if (!gristDoc) { throw new Error(\"Grist document is not present in Page Model\"); }\n\n  // Create send to google drive handler (it will return a spreadsheet url).\n  const send = (code: string) =>\n    // Decorate it with a spinner\n    spinnerModal(t(\"Sending file to Google Drive\"),\n      pageModel.appModel.api.getDocAPI(doc.id)\n        .sendToDrive(code, pageModel.currentDocTitle.get()),\n    );\n\n  try {\n    const token = await getGoogleCodeForSending(gristDoc);\n    const { url } = await send(token);\n    G.window.location.assign(url);\n  } catch (err) {\n    reportError(err);\n  }\n}\n"
  },
  {
    "path": "app/client/ui/shadowScroll.ts",
    "content": "import { dom, IDomArgs, Observable, styled } from \"grainjs\";\n\n// Shadow css settings for member scroll top and bottom.\nconst SHADOW_TOP = \"inset 0 4px 6px 0 var(--grist-theme-scroll-shadow, rgba(217,217,217,0.4))\";\nconst SHADOW_BTM = \"inset 0 -4px 6px 0 var(--grist-theme-scroll-shadow, rgba(217,217,217,0.4))\";\n\n/**\n * Creates a scroll div used in the UserManager and moveDoc menus to display\n * shadows at the top and bottom of a list of scrollable items.\n */\nexport function shadowScroll(...args: IDomArgs<HTMLDivElement>) {\n  // Observables to indicate the scroll position.\n  const scrollTop = Observable.create(null, true);\n  const scrollBtm = Observable.create(null, true);\n  return cssScrollMenu(\n    dom.autoDispose(scrollTop),\n    dom.autoDispose(scrollBtm),\n    // Update scroll positions on init and on scroll.\n    (elem) => { setTimeout(() => scrollBtm.set(isAtScrollBtm(elem)), 0); },\n    dom.on(\"scroll\", (_, elem) => {\n      scrollTop.set(isAtScrollTop(elem));\n      scrollBtm.set(isAtScrollBtm(elem));\n    }),\n    // Add shadows on the top/bottom if the list is scrolled away from either.\n    dom.style(\"box-shadow\", (use) => {\n      const shadows = [use(scrollTop) ? null : SHADOW_TOP, use(scrollBtm) ? null : SHADOW_BTM];\n      return shadows.filter(css => css).join(\", \");\n    }),\n    ...args,\n  );\n}\n\n// Indicates that an element is currently scrolled such that the top of the element is visible.\nfunction isAtScrollTop(elem: Element): boolean {\n  return elem.scrollTop === 0;\n}\n\n// Indicates that an element is currently scrolled such that the bottom of the element is visible.\n// It is expected that the elem arg has the offsetHeight property set.\nfunction isAtScrollBtm(elem: HTMLElement): boolean {\n  // Check we're within a threshold of 1 pixel, to account for possible rounding.\n  return (elem.scrollHeight - elem.offsetHeight - elem.scrollTop) < 1;\n}\n\nconst cssScrollMenu = styled(\"div\", `\n  flex: 1 1 0;\n  width: 100%;\n  overflow-y: auto;\n`);\n"
  },
  {
    "path": "app/client/ui/tooltips.ts",
    "content": "/**\n * This module implements tooltips of two kinds:\n * - to be shown on hover, similar to the native \"title\" attribute (popweasel meant to provide\n *   that, but its machinery isn't really needed). TODO these aren't yet implemented.\n * - to be shown briefly, as a transient notification next to some action element.\n */\n\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { GristTooltips, Tooltip } from \"app/client/ui/GristTooltips\";\nimport { prepareForTransition } from \"app/client/ui/transitions\";\nimport { colors, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { makeLinks } from \"app/client/ui2018/links\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\nimport { tokens } from \"app/common/ThemePrefs\";\n\nimport { BindableValue, dom, DomContents, DomElementArg, DomElementMethod, Observable, styled } from \"grainjs\";\nimport merge from \"lodash/merge\";\nimport Popper from \"popper.js\";\nimport { cssMenu, cssMenuItem, defaultMenuOptions, IPopupOptions, setPopupToCreateDom } from \"popweasel\";\n\nexport interface ITipOptions {\n  /**\n   * Where to place the tooltip relative to the reference element.\n   *\n   * Defaults to 'top'.\n   *\n   * See https://popper.js.org/docs/v1/#popperplacements--codeenumcode.\n   */\n  placement?: Popper.Placement;\n\n  /** When set, a tooltip will replace any previous tooltip with the same key. */\n  key?: string;\n\n  /**\n   * Optionally, popper modifiers (e.g. {offset: {offset: 8}}),\n   * See https://popper.js.org/docs/v1/#modifiers.\n   */\n  modifiers?: Popper.Modifiers;\n}\n\nexport interface ITransientTipOptions extends ITipOptions {\n  /** When to remove the transient tooltip. Defaults to 2000ms. */\n  timeoutMs?: number;\n}\n\nexport interface IHoverTipOptions extends ITransientTipOptions {\n  /** How soon after pointerenter to show it. Defaults to 200 ms. */\n  openDelay?: number;\n\n  /** If set and non-zero, remove the tip automatically after this time. */\n  timeoutMs?: number;\n\n  /**\n   * How soon after mouseleave to hide it.\n   *\n   * Defaults to 100 ms.\n   *\n   * A non-zero delay gives the pointer some time to be outside of the trigger\n   * and the tooltip content if the user moves the pointer from one to the other.\n   */\n  closeDelay?: number;\n\n  /**\n   * Also show the tip on clicking the element it's attached to.\n   *\n   * Defaults to false.\n   *\n   * Should only be set to true if `closeOnClick` is false.\n   */\n  openOnClick?: boolean;\n\n  /**\n   * Hide the tip on clicking the element it's attached to.\n   *\n   * Defaults to true.\n   *\n   * Should only be set to true if `openOnClick` is false.\n   */\n  closeOnClick?: boolean;\n\n  /** Whether to show the tooltip only when the ref element overflows horizontally. */\n  overflowOnly?: boolean;\n\n  /**\n   * If set tooltip won't be shown on hover. Default to false.\n   */\n  hidden?: Observable<boolean>;\n}\n\nexport type ITooltipContent = ITooltipContentFunc | DomContents;\n\nexport type ITooltipContentFunc = (ctl: ITooltipControl) => DomContents;\n\nexport interface ITooltipControl {\n  close(): void;\n  getDom(): HTMLElement;       // The tooltip DOM.\n}\n\n/**\n * Map of open tooltips, mapping the key (from ITipOptions) to ITooltipControl that allows removing\n * the tooltip.\n */\nconst openTooltips = new Map<string, ITooltipControl>();\n\n/**\n * Show tipContent briefly (2s by default), in a tooltip next to refElem (on top of it, by default).\n * See also ITipOptions.\n */\nexport function showTransientTooltip(\n  refElem: Element,\n  tipContent: ITooltipContent,\n  options: ITransientTipOptions = {}) {\n  const ctl = showTooltip(refElem, typeof tipContent == \"function\" ? tipContent : () => tipContent, options);\n  const origClose = ctl.close;\n  ctl.close = () => { clearTimeout(timer); origClose(); };\n\n  const timer = setTimeout(ctl.close, options.timeoutMs || 2000);\n  return ctl;\n}\n\n/**\n * Show the return value of tipContent(ctl) in a tooltip next to refElem (on top of it, by default).\n * Returns ctl. In both places, ctl is an object with a close() method, which closes the tooltip.\n * See also ITipOptions.\n */\nexport function showTooltip(\n  refElem: Element, tipContent: ITooltipContentFunc, options: ITipOptions = {},\n): ITooltipControl {\n  const placement: Popper.Placement = options.placement ?? \"top\";\n  const key = options.key;\n  const hasKey = key && openTooltips.has(key);\n  let closed = false;\n\n  // If we had a previous tooltip with the same key, clean it up.\n  if (key) { openTooltips.get(key)?.close(); }\n\n  // Cleanup involves destroying the Popper instance, removing the element, etc.\n  function close() {\n    if (closed) { return; }\n    closed = true;\n    popper.destroy();\n    dom.domDispose(content);\n    content.remove();\n    if (key) { openTooltips.delete(key); }\n  }\n  const ctl: ITooltipControl = { close, getDom: () => content };\n\n  // Add the content element.\n  const content = cssTooltip({ role: \"tooltip\" }, tipContent(ctl), testId(`tooltip`));\n  // Prepending instead of appending allows better text selection, as this element is on top.\n  document.body.prepend(content);\n\n  // Create a popper for positioning the tooltip content relative to refElem.\n  const popperOptions: Popper.PopperOptions = {\n    modifiers: merge(\n      { preventOverflow: { boundariesElement: \"viewport\" } },\n      options.modifiers,\n    ),\n    placement,\n  };\n\n  const popper = new Popper(refElem, content, popperOptions);\n\n  // If refElem is disposed we close the tooltip.\n  dom.onDisposeElem(refElem, close);\n\n  // If we're not replacing the tooltip, fade in the content using transitions.\n  if (!hasKey) {\n    prepareForTransition(content, () => { content.style.opacity = \"0\"; });\n    content.style.opacity = \"\";\n  }\n\n  if (key) { openTooltips.set(key, ctl); }\n  return ctl;\n}\n\n/**\n * Render a tooltip on hover. Suitable for use during dom construction, e.g.\n *    dom('div', 'Trigger', hoverTooltip('Hello!')\n */\nexport function hoverTooltip(tipContent: ITooltipContent, options?: IHoverTipOptions): DomElementMethod {\n  const defaultOptions: IHoverTipOptions = { placement: \"bottom\" };\n  return elem => setHoverTooltip(elem, tipContent, { ...defaultOptions, ...options });\n}\n\n/**\n * On hover, show the full text of this element when it overflows horizontally. It is intended\n * mainly for styled with \"text-overflow: ellipsis\".\n * E.g. dom('label', 'Long text...', overflowTooltip()).\n */\nexport function overflowTooltip(options?: IHoverTipOptions): DomElementMethod {\n  const defaultOptions: IHoverTipOptions = {\n    placement: \"bottom-start\",\n    overflowOnly: true,\n    modifiers: { offset: { offset: \"40, 0\" } },\n  };\n  return elem => setHoverTooltip(elem, () => elem.textContent,  { ...defaultOptions, ...options });\n}\n\n/**\n * Attach a tooltip to the given element, to be rendered on hover.\n */\nexport function setHoverTooltip(\n  refElem: Element,\n  tipContent: ITooltipContent,\n  options: IHoverTipOptions = {},\n) {\n  const { key, openDelay = 200, timeoutMs, closeDelay = 100, openOnClick, closeOnClick = true,\n    overflowOnly = false } = options;\n\n  const tipContentFunc = typeof tipContent === \"function\" ? tipContent : () => tipContent;\n\n  // Controller for closing the tooltip, if one is open.\n  let tipControl: ITooltipControl | undefined;\n\n  // A marker, that the tooltip should be closed, but we are waiting for the mouseup event.\n  const POSTPONED = Symbol();\n\n  // Timer to open or close the tooltip, depending on whether tipControl is set.\n  let timer: ReturnType<typeof setTimeout> | undefined | typeof POSTPONED;\n\n  // To allow user select text, we will monitor if the selection has started in the tooltip (by listening\n  // to the mousedown event). If it has and mouse goes outside, we will mark that the tooltip should be closed.\n  // When the selection is over (by listening to mouseup on window), a new close is scheduled with 1.4s, to allow\n  // user to press Ctrl+C (but only if the marker - POSTPONED - is still set).\n  let mouseGrabbed = false;\n  function grabMouse(tip: Element) {\n    mouseGrabbed = true;\n    const listener = dom.onElem(window, \"mouseup\", () => {\n      mouseGrabbed = false;\n      if (timer === POSTPONED) {\n        scheduleCloseIfOpen(1400);\n      }\n    });\n    dom.autoDisposeElem(tip, listener);\n\n    // Disable text selection in any other element except this one. This class sets user-select: none to all\n    // elements except the tooltip. This helps to avoid accidental selection of text in other elements, once\n    // the mouse leaves the tooltip.\n    document.body.classList.add(cssDisableSelectOnAll.className);\n    dom.onDisposeElem(tip, () => document.body.classList.remove(cssDisableSelectOnAll.className));\n  }\n\n  function clearTimer() {\n    if (timer !== POSTPONED) { clearTimeout(timer); }\n    timer = undefined;\n  }\n  function resetTimer(func: () => void, delay: number | typeof POSTPONED) {\n    clearTimer();\n    timer = delay === POSTPONED ? POSTPONED : setTimeout(func, delay);\n  }\n  function scheduleCloseIfOpen(timeout = closeDelay) {\n    clearTimer();\n    if (tipControl) {\n      resetTimer(close, mouseGrabbed ? POSTPONED : timeout);\n    }\n  }\n  function open() {\n    if (options.hidden?.get()) { return; }\n    clearTimer();\n    tipControl = showTooltip(refElem, ctl => tipContentFunc({ ...ctl, close }), options);\n    const tipDom = tipControl.getDom();\n    dom.onElem(tipDom, \"pointerenter\", clearTimer);\n    dom.onElem(tipDom, \"pointerleave\", () => scheduleCloseIfOpen());\n    dom.onElem(tipDom, \"mousedown\", grabMouse.bind(null, tipDom));\n    dom.onDisposeElem(tipDom, () => close());\n    if (timeoutMs) { resetTimer(close, timeoutMs); }\n  }\n  function close() {\n    if (options.hidden?.get()) { return; }\n    clearTimer();\n    tipControl?.close();\n    tipControl = undefined;\n  }\n\n  // We simulate hover effect by handling pointerenter/pointerleave.\n  dom.onElem(refElem, \"pointerenter\", () => {\n    if (options.hidden?.get()) { return; }\n\n    if (overflowOnly && (refElem as HTMLElement).offsetWidth >= refElem.scrollWidth) {\n      return;\n    }\n    if (!tipControl && !timer) {\n      // If we're replacing a tooltip, open without delay.\n      const delay = key && openTooltips.has(key) ? 0 : openDelay;\n      resetTimer(open, delay);\n    } else if (tipControl) {\n      // Already shown, reset to newly-shown state.\n      clearTimer();\n      if (timeoutMs) { resetTimer(close, timeoutMs); }\n    }\n  });\n\n  dom.onElem(refElem, \"pointerleave\", () => scheduleCloseIfOpen());\n\n  if (openOnClick) {\n    // If requested, re-open on click.\n    dom.onElem(refElem, \"click\", () => { close(); open(); });\n  } else if (closeOnClick) {\n    // If requested, close on click.\n    dom.onElem(refElem, \"click\", () => { close(); });\n  }\n\n  // Close tooltip if refElem is disposed.\n  dom.onDisposeElem(refElem, close);\n}\n\n/**\n * Build a handy button for closing a tooltip.\n */\nexport function tooltipCloseButton(ctl: ITooltipControl): HTMLElement {\n  return cssTooltipCloseButton(icon(\"CrossSmall\"),\n    dom.on(\"mousedown\", (ev) => {\n      ev.stopPropagation();\n      ev.preventDefault();\n      ctl.close();\n    }),\n    testId(\"tooltip-close\"),\n  );\n}\n\nexport interface InfoTooltipOptions {\n  /** Defaults to `click`. */\n  variant?: InfoTooltipVariant;\n  /** Only applicable to the `click` variant. */\n  popupOptions?: IPopupOptions;\n  /** Only applicable to the `click` variant. */\n  onOpen?: () => void;\n}\n\nexport type InfoTooltipVariant = \"click\" | \"hover\";\n\n/**\n * Renders an info icon that shows a tooltip with the specified `content`.\n */\nexport function infoTooltip(\n  tooltip: BindableValue<Tooltip>,\n  options: InfoTooltipOptions = {},\n  ...domArgs: DomElementArg[]\n) {\n  const { variant = \"click\" } = options;\n  switch (variant) {\n    case \"click\": {\n      const { popupOptions } = options;\n      return buildClickableInfoTooltip(tooltip, { popupOptions }, domArgs);\n    }\n    case \"hover\": {\n      return buildHoverableInfoTooltip(tooltip, domArgs);\n    }\n  }\n}\n\nexport interface ClickableInfoTooltipOptions {\n  popupOptions?: IPopupOptions;\n  onOpen?: () => void;\n}\n\nfunction buildClickableInfoTooltip(\n  tooltip: BindableValue<Tooltip>,\n  options: ClickableInfoTooltipOptions = {},\n  ...domArgs: DomElementArg[]\n) {\n  const { popupOptions } = options;\n  return dom.domComputed(tooltip, tip =>\n    cssInfoTooltipButton(\"?\",\n      (elem) => {\n        setPopupToCreateDom(\n          elem,\n          (ctl) => {\n            logTelemetryEvent(\"viewedTip\", { full: { tipName: tip } });\n\n            return cssInfoTooltipPopup(\n              cssInfoTooltipPopupCloseButton(\n                icon(\"CrossSmall\"),\n                dom.on(\"click\", () => ctl.close()),\n                testId(\"info-tooltip-close\"),\n              ),\n              cssInfoTooltipPopupBody(\n                GristTooltips[tip](),\n                testId(\"info-tooltip-popup-body\"),\n              ),\n              dom.cls(menuCssClass),\n              dom.cls(cssMenu.className),\n              dom.onKeyDown({\n                Enter: () => ctl.close(),\n                Escape: () => ctl.close(),\n              }),\n              (popup) => { setTimeout(() => popup.focus(), 0); },\n              testId(\"info-tooltip-popup\"),\n            );\n          },\n          { ...defaultMenuOptions, ...{ placement: \"bottom-end\" }, ...popupOptions },\n        );\n      },\n      testId(\"info-tooltip\"),\n      ...domArgs,\n    ),\n  );\n}\n\nfunction buildHoverableInfoTooltip(\n  tooltip: BindableValue<Tooltip>,\n  ...domArgs: DomElementArg[]\n) {\n  return cssInfoTooltipButton(\"?\",\n    hoverTooltip(() => cssInfoTooltipTransientPopup(\n      dom.domComputed(tooltip, tip => GristTooltips[tip]()),\n      cssTooltipCorner(testId(\"tooltip-origin\")),\n      { tabIndex: \"-1\" },\n      testId(\"info-tooltip-popup\"),\n    ), { closeOnClick: false }),\n    testId(\"info-tooltip\"),\n    ...domArgs,\n  );\n}\n\nexport interface WithInfoTooltipOptions {\n  /** Defaults to `click`. */\n  variant?: InfoTooltipVariant;\n  domArgs?: DomElementArg[];\n  iconDomArgs?: DomElementArg[];\n  /** Only applicable to the `click` variant. */\n  popupOptions?: IPopupOptions;\n  onOpen?: () => void;\n}\n\n/**\n * Wraps `domContent` with a info tooltip icon that displays the specified\n * `tooltip` and returns the wrapped element. Tooltips are defined in\n * `app/client/ui/GristTooltips.ts`.\n *\n * The tooltip button is displayed to the right of `domContents`, and displays\n * a popup on click by default. The popup can be dismissed by clicking away from\n * it; clicking the close button in the top-right corner; or pressing Enter or Escape.\n *\n * You may optionally specify `options.variant`, which controls whether the tooltip\n * is shown on hover or on click.\n *\n * Arguments can be passed to both the top-level wrapped DOM element and the\n * tooltip icon element with `options.domArgs` and `options.tooltipIconDomArgs`\n * respectively.\n *\n * Usage:\n *\n *   withInfoTooltip(dom('div', 'Hello World!'), 'selectBy')\n */\nexport function withInfoTooltip(\n  domContents: DomContents,\n  tooltip: BindableValue<Tooltip>,\n  options: WithInfoTooltipOptions = {},\n) {\n  const { variant = \"click\", domArgs, iconDomArgs, popupOptions } = options;\n  return cssInfoTooltip(\n    domContents,\n    infoTooltip(tooltip, { variant, popupOptions }, iconDomArgs),\n    ...(domArgs ?? []),\n  );\n}\n\n/**\n * Renders an description info icon that shows a tooltip with the specified `content` on click.\n */\nexport function descriptionInfoTooltip(\n  content: string,\n  testPrefix: string,\n  ...domArgs: DomElementArg[]) {\n  const body = makeLinks(content);\n  const options = {\n    closeDelay: 200,\n    key: \"columnDescription\",\n    openOnClick: true,\n  };\n  const builder = () => cssInfoTooltipTransientPopup(\n    body,\n    // Used id test to find the origin of the tooltip regardless webdriver implementation (some of them start)\n    cssTooltipCorner(testId(\"tooltip-origin\")),\n    testId(`${testPrefix}-info-tooltip-popup`),\n    { tabIndex: \"-1\" },\n  );\n  return cssDescriptionInfoTooltipButton(\n    icon(\"Info\", dom.cls(\"info_toggle_icon\")),\n    testId(`${testPrefix}-info-tooltip`),\n    dom.on(\"mousedown\", e => e.stopPropagation()),\n    dom.on(\"click\", e => e.stopPropagation()),\n    hoverTooltip(builder, options),\n    dom.cls(\"info_toggle_icon_wrapper\"),\n    ...domArgs,\n  );\n}\n\nconst cssInfoTooltip = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  column-gap: 8px;\n  font-weight: unset; /* Don't want to inherit font settings in a tooltip */\n`);\n\nconst cssTooltipCorner = styled(\"div\", `\n  position: absolute;\n  width: 0;\n  height: 0;\n  top: 0;\n  left: 0;\n  visibility: hidden;\n`);\n\nconst cssInfoTooltipTransientPopup = styled(\"div\", `\n  position: relative;\n  white-space: pre-wrap;\n  text-align: left;\n  text-overflow: ellipsis;\n  overflow: hidden;\n  line-height: 1.4;\n  max-width: min(500px, calc(100vw - 80px)); /* can't use 100%, 500px and 80px are picked by hand */\n`);\n\nconst cssDescriptionInfoTooltipButton = styled(\"div\", `\n  cursor: pointer;\n  --icon-color: ${theme.infoButtonFg};\n  border-radius: 50%;\n  display: inline-block;\n  padding-left: 5px;\n  line-height: 0px;\n\n  &:hover  {\n    --icon-color: ${theme.infoButtonHoverFg};\n  }\n  &:active  {\n    --icon-color: ${theme.infoButtonActiveFg};\n  }\n`);\n\nconst cssTooltip = styled(\"div\", `\n  position: absolute;\n  z-index: ${vars.tooltipZIndex};      /* should be higher than a modal */\n  background-color: ${theme.tooltipBg};\n  border-radius: 3px;\n  box-shadow: 0 0 2px rgba(0,0,0,0.5);\n  text-align: center;\n  color: ${theme.tooltipFg};\n  width: auto;\n  font-family: sans-serif;\n  font-size: 10pt;\n  padding: 8px 16px;\n  margin: 4px;\n  transition: opacity 0.2s;\n  user-select: auto;\n`);\n\nconst cssDisableSelectOnAll = styled(\"div\", `\n  & *:not(.${cssTooltip.className}, .${cssTooltip.className} *) {\n    user-select: none;\n  }\n`);\n\nconst cssTooltipCloseButton = styled(\"div\", `\n  cursor: pointer;\n  user-select: none;\n  width: 16px;\n  height: 16px;\n  line-height: 16px;\n  text-align: center;\n  margin: -4px -4px -4px 8px;\n  --icon-color: ${theme.tooltipCloseButtonFg};\n  border-radius: 16px;\n\n  &:hover {\n    background-color: ${theme.tooltipCloseButtonHoverBg};\n    --icon-color: ${theme.tooltipCloseButtonHoverFg};\n  }\n`);\n\nconst cssInfoTooltipIcon = styled(\"div\", `\n  flex-shrink: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  height: ${vars.largeFontSize};\n  width: ${vars.largeFontSize};\n  border: 1px solid ${theme.controlSecondaryFg};\n  color: ${theme.controlSecondaryFg};\n  border-radius: 50%;\n  user-select: none;\n  font-weight: initial;\n\n  .${cssMenuItem.className}-sel & {\n    color: ${theme.menuItemSelectedFg};\n    border-color: ${theme.menuItemSelectedFg};\n  }\n`);\n\nexport const cssInfoTooltipButton = styled(cssInfoTooltipIcon, `\n  cursor: pointer;\n\n  &:hover {\n    border: 1px solid ${theme.controlSecondaryHoverFg};\n    color: ${theme.controlSecondaryHoverFg};\n  }\n\n  &-in-banner {\n    color: ${tokens.black};\n    border-color: ${tokens.black};\n  }\n\n  &-in-banner:hover {\n    border-color: ${colors.lightGreen};\n    color: ${colors.lightGreen};\n  }\n`);\n\nconst cssInfoTooltipPopup = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  background-color: ${theme.popupBg};\n  max-width: 240px;\n  margin: 4px;\n  padding: 0px;\n`);\n\nconst cssInfoTooltipPopupBody = styled(\"div\", `\n  color: ${theme.text};\n  text-align: left;\n  padding: 0px 16px 16px 16px;\n`);\n\nconst cssInfoTooltipPopupCloseButton = styled(\"div\", `\n  flex-shrink: 0;\n  align-self: flex-end;\n  cursor: pointer;\n  --icon-color: ${theme.controlSecondaryFg};\n  margin: 8px 8px 4px 0px;\n  padding: 2px;\n  border-radius: 4px;\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/transientInput.ts",
    "content": "/**\n * This is a temporary <input> element. The intended usage is to create is when needed (e.g. when\n * some \"rename\" option is chosen), and provide methods to save and to close.\n *\n * It calls save() on Enter and on blur, which should return a Promise. On successful save, and on\n * Escape, it calls close(), which should destroy the <input>.\n */\n\nimport { reportError } from \"app/client/models/AppModel\";\nimport { theme } from \"app/client/ui2018/cssVars\";\n\nimport { dom, DomArg, styled } from \"grainjs\";\n\nexport interface ITransientInputOptions {\n  initialValue: string;\n  save(value: string): Promise<void>;\n  close(): void;\n}\n\nexport function transientInput({ initialValue, save, close }: ITransientInputOptions,\n  ...args: DomArg<HTMLInputElement>[]) {\n  let lastSave: string = initialValue;\n\n  async function onSave(explicitSave: boolean) {\n    try {\n      if (explicitSave || input.value !== lastSave) {\n        lastSave = input.value;\n        await save(input.value);\n      }\n      close();\n    } catch (err) {\n      reportError(err);\n      delayedFocus();\n    }\n  }\n\n  function delayedFocus() {\n    setTimeout(() => { input.focus(); input.select(); }, 10);\n  }\n\n  const input = cssInput({ type: \"text\", placeholder: \"Enter name\" },\n    dom.prop(\"value\", initialValue),\n    dom.on(\"blur\", () => onSave(false)),\n    dom.onKeyDown({\n      Enter: () => onSave(true),\n      Escape: () => close(),\n    }),\n    ...args,\n  );\n  delayedFocus();\n  return input;\n}\n\nconst cssInput = styled(\"input\", `\n  background-color: transparent;\n  color: ${theme.inputFg};\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui/transitions.ts",
    "content": "/**\n * A helper for CSS transitions. Usage:\n *\n *    dom(...,\n *      transition(obs, {\n *        prepare(elem, val) { SET STYLE WITH TRANSITIONS OFF },\n *        run(elem, val) { SET STYLE WITH TRANSITIONS ON },\n *        // finish(elem, val) { console.log(\"transition finished\"); }\n *      )\n *    )\n *\n * Allows modifiying styles in response to changes in an observable. Any time the observable\n * changes, the prepare() callback allows preparing the styles, with transitions off. Then\n * the run() callback can set the styles that will be subject to transitions.\n *\n * The actual transition styles (e.g. 'transition: width 0.2s') should be set on elem elsewhere.\n *\n * The optional finish() callback is called when the transition ends. If CSS transitions are set\n * on multiple properties, only the first one is used to determine when the transition ends.\n *\n * All callbacks are called with the element this is attached to, and the value of the observable.\n *\n * The recommendation is to avoid setting styles at transition end, since it's not entirely\n * reliable; it's better to arrange CSS so that the desired final styles can be set in run(). The\n * finish() callback is intended to tell other code that the element is in its transitioned state.\n *\n * When the observable changes during a transition, the prepare() callback is skipped, the run()\n * callback is called, and the finish() callback delayed until the new transition ends.\n *\n * If other styles are changed (or css classes applied) when the observable changes, subscriptions\n * triggered BEFORE the transition() subscription are applied with transitions OFF (like\n * prepare()); those triggered AFTER are subject to transitions (like run()).\n */\n\nimport { BindableValue, Disposable, dom, DomElementMethod, subscribeElem } from \"grainjs\";\n\nexport interface ITransitionLogic<T = void> {\n  prepare(elem: HTMLElement, value: T): void;\n  run(elem: HTMLElement, value: T): void;\n  finish?(elem: HTMLElement, value: T): void;\n}\n\nexport function transition<T>(obs: BindableValue<T>, trans: ITransitionLogic<T>): DomElementMethod {\n  const { prepare, run, finish } = trans;\n  let watcher: TransitionWatcher | null = null;\n  let firstCall = true;\n  return elem => subscribeElem<T>(elem, obs, (val) => {\n    // First call is initialization, don't treat it as a transition\n    if (firstCall) { firstCall = false; return; }\n    if (watcher) {\n      watcher.reschedule();\n    } else {\n      watcher = new TransitionWatcher(elem);\n      watcher.onDispose(() => {\n        watcher = null;\n        if (finish) { finish(elem, val); }\n      });\n\n      // Call prepare() with transitions turned off.\n      prepareForTransition(elem, () => prepare(elem, val));\n    }\n    run(elem, val);\n  });\n}\n\n/**\n * Call prepare() with transitions turned off. This allows preparing an element before another\n * change to properties actually gets animated using the element's transition settings.\n */\nexport function prepareForTransition(elem: HTMLElement, prepare: () => void) {\n  const prior = elem.style.transitionProperty;\n  elem.style.transitionProperty = \"none\";\n  prepare();\n\n  // Recompute styles while transitions are off. See https://stackoverflow.com/a/16575811/328565\n  // for explanation and https://stackoverflow.com/a/31862081/328565 for the recommendation used\n  // here to trigger a style computation without a reflow.\n  //\n  // eslint-disable-next-line @typescript-eslint/no-unused-expressions, no-unused-expressions\n  window.getComputedStyle(elem).opacity;\n\n  // Restore transitions.\n  elem.style.transitionProperty = prior;\n}\n\n/**\n * Helper for waiting for an active transition to end. Beyond listening to 'transitionend', it\n * does a few things:\n *\n * (1) if the transition lists multiple properties, only the first property and duration are used\n *     ('transitionend' on additional properties is inconsistent across browsers).\n * (2) if 'transitionend' fails to fire, the transition is considered ended when duration elapses,\n *     plus 10ms grace period (to let 'transitionend' fire first normally).\n * (3) reschedule() allows resetting the timer if a new transition is known to have started.\n *\n * When the transition ends, TransitionWatcher disposes itself. Its onDispose() method allows\n * registering callbacks.\n */\nexport class TransitionWatcher extends Disposable {\n  private _propertyName: string;\n  private _durationMs: number;\n  private _timer: ReturnType<typeof setTimeout>;\n\n  constructor(elem: Element) {\n    super();\n    const style = window.getComputedStyle(elem);\n    this._propertyName = style.transitionProperty.split(\",\")[0].trim();\n\n    // Gets the duration of the transition from the styles of the given element, in ms.\n    // FF and Chrome both return transitionDuration in seconds (e.g. \"0.150s\") In case of multiple\n    // values, e.g. \"0.150s, 2s\"; parseFloat will just parse the first one.\n    const duration = style.transitionDuration;\n    this._durationMs = ((duration && parseFloat(duration)) || 0) * 1000;\n\n    this.autoDispose(dom.onElem(elem, \"transitionend\", e =>\n      (e.propertyName === this._propertyName) && this.dispose()));\n\n    this._timer = setTimeout(() => this.dispose(), this._durationMs + 10);\n    this.onDispose(() => clearTimeout(this._timer));\n  }\n\n  public reschedule() {\n    clearTimeout(this._timer);\n    this._timer = setTimeout(() => this.dispose(), this._durationMs + 10);\n  }\n}\n"
  },
  {
    "path": "app/client/ui/userTrustsCustomWidget.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { inlineMarkdown } from \"app/client/lib/markdown\";\nimport { alert } from \"app/client/ui2018/alerts\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { getConfirmText, saveModal } from \"app/client/ui2018/modals\";\nimport { gristThemeObs } from \"app/client/ui2018/theme\";\n\nimport { Computed, dom, makeTestId, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"userTrustsCustomWidget\");\nconst testId = makeTestId(\"test-custom-widget-warning-modal-\");\n\n/**\n * Show a modal to the user asking him to confirm he trusts the custom widget he's about to install.\n *\n * @returns Promise<boolean> Promise that resolves to true if the user confirms he trusts the widget, false otherwise.\n */\nexport function userTrustsCustomWidget() {\n  let setUserChoice: (choice: boolean) => void;\n  const waitForUserChoice = new Promise<boolean>((resolve) => {\n    setUserChoice = resolve;\n  });\n\n  saveModal((ctl, owner) => {\n    const src = Computed.create(owner, use => use(gristThemeObs()).appearance === \"light\" ?\n      \"img/security-alert.png\" :\n      \"img/security-alert-dark-theme.png\");\n    const confirmIsChecked = Observable.create(owner, false);\n    const saveDisabled = Computed.create(owner, use => !use(confirmIsChecked));\n    return {\n      title: t(\"Be careful with unknown custom widgets\"),\n      body: dom(\"div\",\n        cssImageContainer(cssImage(dom.attr(\"src\", src))),\n        alert(t(\"Please review the following before adding a new custom widget.\")),\n        dom(\"p\", inlineMarkdown(\n          t(\"Custom widgets are **powerful**! \\\nThey may be able to read and write your document data, and send it elsewhere.\"),\n        )),\n        dom(\"ul\",\n          cssListItem(inlineMarkdown(t(\"Are you sure you **trust the resource** at this URL?\"))),\n          cssListItem(inlineMarkdown(t(\"Do you **trust the person** who shared this link?\"))),\n          cssListItem(inlineMarkdown(t(\"Have you **reviewed the code** at this URL?\"))),\n        ),\n        dom(\"p\", inlineMarkdown(\n          t(\"If in doubt, do not install this widget, or ask an administrator \\\nof your organization to review it for safety.\"),\n        )),\n        cssConfirmCheckbox(\n          labeledSquareCheckbox(confirmIsChecked,\n            dom(\"span\",\n              inlineMarkdown(t(\"I confirm that I understand these warnings and accept the risks\")),\n            ),\n            testId(\"confirm-checkbox\"),\n          ),\n        ),\n      ),\n      saveLabel: getConfirmText(),\n      saveFunc: () => Promise.resolve(setUserChoice(true)),\n      saveDisabled,\n      width: \"fixed-wide\",\n    };\n  }, {\n    onCancel: () => setUserChoice(false),\n  });\n\n  return waitForUserChoice;\n}\n\nconst cssImageContainer = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n  margin-bottom: 20px;\n`);\n\nconst cssImage = styled(\"img\", `\n  width: 100px;\n  height: 100px;\n`);\n\nconst cssConfirmCheckbox = styled(\"div\", `\n  margin: 2rem 0 1rem;\n`);\n\nconst cssListItem = styled(\"li\", `\n  margin: 0.5rem 0;\n`);\n"
  },
  {
    "path": "app/client/ui/viewport.ts",
    "content": "import { isIOS } from \"app/client/lib/browserInfo\";\nimport { localStorageBoolObs } from \"app/client/lib/localStorageObs\";\n\nimport { dom } from \"grainjs\";\n\nexport const viewportEnabled = localStorageBoolObs(\"viewportEnabled\", true);\n\nexport function toggleViewport() {\n  viewportEnabled.set(!viewportEnabled.get());\n  if (!viewportEnabled.get()) {\n    // Removing the meta tag doesn't cause mobile browsers to reload automatically.\n    location.reload();\n  }\n}\n\nexport function addViewportTag() {\n  dom.update(document.head,\n    dom.maybe(viewportEnabled, () => {\n      // For the maximum-scale=1 advice, see https://stackoverflow.com/a/46254706/328565. On iOS,\n      // it prevents the auto-zoom when an input is focused, but does not prevent manual\n      // pinch-to-zoom. On Android, it's not needed, and would prevent manual zoom.\n      const viewportContent = \"width=device-width,initial-scale=1.0\" + (isIOS() ? \",maximum-scale=1\" : \"\");\n      return dom(\"meta\", { name: \"viewport\", content: viewportContent });\n    }),\n  );\n}\n"
  },
  {
    "path": "app/client/ui/widgetTypesMap.ts",
    "content": "// the list of widget types with their labels and icons\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewSectionRec } from \"app/client/models/entities/ViewSectionRec\";\nimport { IPageWidget } from \"app/client/ui/PageWidgetPicker\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { IWidgetType } from \"app/common/widgetTypes\";\n\nconst t = makeT(\"widgetTypesMap\");\n\nexport const widgetTypesMap = new Map<IWidgetType, IWidgetTypeInfo>([\n  [\"record\", { name: \"Table\", icon: \"TypeTable\", getLabel: () => t(\"Table\") }],\n  [\"single\", { name: \"Card\", icon: \"TypeCard\", getLabel: () => t(\"Card\") }],\n  [\"detail\", { name: \"Card List\", icon: \"TypeCardList\", getLabel: () => t(\"Card List\") }],\n  [\"chart\", { name: \"Chart\", icon: \"TypeChart\", getLabel: () => t(\"Chart\") }],\n  [\"form\", { name: \"Form\", icon: \"Board\", getLabel: () => t(\"Form\") }],\n  [\"custom\", { name: \"Custom\", icon: \"TypeCustom\", getLabel: () => t(\"Custom\") }],\n  [\"custom.calendar\", { name: \"Calendar\", icon: \"TypeCalendar\", getLabel: () => t(\"Calendar\") }],\n]);\n\n// Widget type info.\nexport interface IWidgetTypeInfo {\n  name: string;\n  icon: IconName;\n  getLabel: () => string;\n}\n\n// Returns the widget type info for sectionType, or the one for 'record' if sectionType is null.\nexport function getWidgetTypes(sectionType: IWidgetType | null): IWidgetTypeInfo {\n  return widgetTypesMap.get(sectionType || \"record\") || widgetTypesMap.get(\"record\")!;\n}\n\nexport interface GetTelemetryWidgetTypeOptions {\n  /** Defaults to `false`. */\n  isSummary?: boolean;\n  /** Defaults to `false`. */\n  isNewTable?: boolean;\n}\n\nexport function getTelemetryWidgetTypeFromVS(vs: ViewSectionRec) {\n  return getTelemetryWidgetType(vs.widgetType.peek(), {\n    isSummary: vs.table.peek().summarySourceTable.peek() !== 0,\n  });\n}\n\nexport function getTelemetryWidgetTypeFromPageWidget(widget: IPageWidget) {\n  return getTelemetryWidgetType(widget.type, {\n    isNewTable: widget.table === \"New Table\",\n    isSummary: widget.summarize,\n  });\n}\n\nfunction getTelemetryWidgetType(type: IWidgetType, options: GetTelemetryWidgetTypeOptions = {}) {\n  let telemetryWidgetType: string | undefined = widgetTypesMap.get(type)?.name;\n  if (!telemetryWidgetType) { return undefined; }\n\n  if (options.isNewTable) {\n    telemetryWidgetType = \"New \" + telemetryWidgetType;\n  }\n  if (options.isSummary) {\n    telemetryWidgetType += \" (Summary)\";\n  }\n\n  return telemetryWidgetType;\n}\n"
  },
  {
    "path": "app/client/ui2018/ColorPalette.ts",
    "content": "/*\n * The palettes were inspired by comparisons of a handful of popular services.\n */\nexport const swatches = [\n  // white-black\n  \"#FFFFFF\",\n  \"#DCDCDC\",\n  \"#888888\",\n  \"#000000\",\n\n  // red\n  \"#FECBCC\",\n  \"#FD8182\",\n  \"#E00A17\",\n  \"#740206\",\n\n  // brown\n  \"#F3E1D2\",\n  \"#D6A77F\",\n  \"#AA632B\",\n  \"#653008\",\n\n  // orange\n  \"#FEE7C3\",\n  \"#FECC81\",\n  \"#FD9D28\",\n  \"#B36F19\",\n\n  // yellow\n  \"#FFFACD\",\n  \"#FEF47A\",\n  \"#E8D62F\",\n  \"#928619\",\n\n  // green\n  \"#E1FEDE\",\n  \"#98FD90\",\n  \"#2AE028\",\n  \"#126E0E\",\n\n  // light blue\n  \"#CCFEFE\",\n  \"#8AFCFE\",\n  \"#24D6DB\",\n  \"#0C686A\",\n\n  // dark blue\n  \"#D3E7FE\",\n  \"#75B5FC\",\n  \"#157AFB\",\n  \"#084794\",\n\n  // violet\n  \"#E8D0FE\",\n  \"#BC77FC\",\n  \"#8725FB\",\n  \"#460D81\",\n\n  // pink\n  \"#FED6FB\",\n  \"#FD79F4\",\n  \"#E621D7\",\n  \"#760C6E\",\n];\n\n/**\n * Tells if swatch is a light color or dark (2 first are light 2 last are dark)\n */\nexport function isLight(index: number) {\n  return index % 4 <= 1;\n}\n"
  },
  {
    "path": "app/client/ui2018/ColorSelect.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { basicButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { isLight, swatches } from \"app/client/ui2018/ColorPalette\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { textInput } from \"app/client/ui2018/editableLabel\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { gristFloatingMenuClass } from \"app/client/ui2018/menus\";\nimport { cssSelectBtn } from \"app/client/ui2018/select\";\nimport { isValidHex } from \"app/common/gutil\";\n\nimport { BindableValue, Computed, Disposable, dom, DomElementArg, Observable, onKeyDown, styled } from \"grainjs\";\nimport { defaultMenuOptions, IOpenController, setPopupToCreateDom } from \"popweasel\";\n\nconst t = makeT(\"ColorSelect\");\n\nexport interface StyleOptions {\n  textColor: ColorOption,\n  fillColor: ColorOption,\n  fontBold?: Observable<boolean | undefined>,\n  fontUnderline?: Observable<boolean | undefined>,\n  fontItalic?: Observable<boolean | undefined>,\n  fontStrikethrough?: Observable<boolean | undefined>,\n}\n\nexport class ColorOption {\n  public color: Observable<string | undefined>;\n  // If the color accepts undefined/empty as a value. Controls empty selector in the picker.\n  public allowsNone: boolean = false;\n  // Default color to show when value is empty or undefined (itself can be empty).\n  public defaultColor: string = \"\";\n  // Text to be shown in the picker when color is not set.\n  public noneText: string = \"\";\n  constructor(options: {\n    color: Observable<string | undefined>,\n    allowsNone?: boolean,\n    defaultColor?: string,\n    noneText?: string\n  }) {\n    Object.assign(this, options);\n  }\n}\n\n/**\n * colorSelect allows to select color for both fill and text cell color. It allows for fast\n * selection thanks to two color palette, `lighter` and `darker`, and also to pick custom color with\n * native color picker. Pressing Escape reverts to the saved value. Caller is expected to handle\n * logging of onSave() callback rejection. In case of rejection, values are reverted to their saved one.\n */\nexport function colorSelect(\n  styleOptions: StyleOptions,\n  options: {\n    // Handler to save the style.\n    onSave: () => Promise<void>,\n    // Invoked when user opens the color picker.\n    onOpen?: () => void,\n    // Invoked when user closes the color picker without saving.\n    onRevert?: () => void,\n    placeholder?: BindableValue<string>\n  }): Element {\n  const {\n    textColor,\n    fillColor,\n    fontBold,\n    fontUnderline,\n    fontItalic,\n    fontStrikethrough,\n  } = styleOptions;\n  const {\n    onSave,\n    onOpen,\n    onRevert,\n    placeholder = t(\"Default cell style\"),\n  } = options;\n  const selectBtn = cssSelectBtn(\n    cssContent(\n      cssButtonIcon(\n        \"T\",\n        dom.style(\"color\", use => use(textColor.color) || textColor.defaultColor),\n        dom.style(\"background-color\", use => use(fillColor.color)?.slice(0, 7) || fillColor.defaultColor),\n        fontBold ? dom.cls(\"font-bold\", use => use(fontBold) ?? false) : null,\n        fontItalic ? dom.cls(\"font-italic\", use => use(fontItalic) ?? false) : null,\n        fontUnderline ? dom.cls(\"font-underline\", use => use(fontUnderline) ?? false) : null,\n        fontStrikethrough ? dom.cls(\"font-strikethrough\", use => use(fontStrikethrough) ?? false) : null,\n        cssLightBorder.cls(\"\"),\n        testId(\"btn-icon\"),\n      ),\n      dom.text(placeholder),\n    ),\n    icon(\"Dropdown\"),\n    testId(\"color-select\"),\n  );\n\n  const domCreator = (ctl: IOpenController) => {\n    onOpen?.();\n    return buildColorPicker(ctl, { styleOptions, onSave, onRevert });\n  };\n  setPopupToCreateDom(selectBtn, domCreator, { ...defaultMenuOptions, placement: \"bottom-end\" });\n\n  return selectBtn;\n}\n\nexport interface ColorButtonOptions {\n  styleOptions: StyleOptions;\n  colorPickerDomArgs?: DomElementArg[];\n  onSave(): Promise<void>;\n  onRevert?(): void;\n  onClose?(): void;\n}\n\nexport function colorButton(options: ColorButtonOptions): Element {\n  const { colorPickerDomArgs, ...colorPickerOptions } = options;\n  const { styleOptions } = colorPickerOptions;\n  const {\n    textColor,\n    fillColor,\n    fontBold,\n    fontItalic,\n    fontUnderline,\n    fontStrikethrough,\n  } = styleOptions;\n  const iconBtn = cssIconBtn(\n    \"T\",\n    dom.style(\"color\", use => use(textColor.color) || textColor.defaultColor),\n    dom.style(\"background-color\", use => use(fillColor.color)?.slice(0, 7) || fillColor.defaultColor),\n    fontBold ? dom.cls(\"font-bold\", use => use(fontBold) ?? false) : null,\n    fontItalic ? dom.cls(\"font-italic\", use => use(fontItalic) ?? false) : null,\n    fontUnderline ? dom.cls(\"font-underline\", use => use(fontUnderline) ?? false) : null,\n    fontStrikethrough ? dom.cls(\"font-strikethrough\", use => use(fontStrikethrough) ?? false) : null,\n    testId(\"color-button\"),\n  );\n\n  const domCreator = (ctl: IOpenController) =>\n    buildColorPicker(ctl, colorPickerOptions, colorPickerDomArgs);\n  setPopupToCreateDom(iconBtn, domCreator, { ...defaultMenuOptions, placement: \"bottom-end\" });\n\n  return iconBtn;\n}\n\ninterface ColorPickerOptions {\n  styleOptions: StyleOptions;\n  onSave?(): Promise<void>;\n  onRevert?(): void;\n  onClose?(): void;\n}\n\nexport function buildColorPicker(\n  ctl: IOpenController,\n  options: ColorPickerOptions,\n  ...domArgs: DomElementArg[]\n): Element {\n  const { styleOptions, onSave, onRevert, onClose } = options;\n  const {\n    textColor,\n    fillColor,\n    fontBold,\n    fontUnderline,\n    fontItalic,\n    fontStrikethrough,\n  } = styleOptions;\n  const textColorModel = ColorModel.create(null, textColor.color);\n  const fillColorModel = ColorModel.create(null, fillColor.color);\n  const models: (BooleanModel | ColorModel)[] = [textColorModel, fillColorModel];\n\n  let fontBoldModel: BooleanModel | undefined;\n  let fontUnderlineModel: BooleanModel | undefined;\n  let fontItalicModel: BooleanModel | undefined;\n  let fontStrikethroughModel: BooleanModel | undefined;\n  if (fontBold) {\n    fontBoldModel = BooleanModel.create(null, fontBold);\n    models.push(fontBoldModel);\n  }\n  if (fontUnderline) {\n    fontUnderlineModel = BooleanModel.create(null, fontUnderline);\n    models.push(fontUnderlineModel);\n  }\n  if (fontItalic) {\n    fontItalicModel = BooleanModel.create(null, fontItalic);\n    models.push(fontItalicModel);\n  }\n  if (fontStrikethrough) {\n    fontStrikethroughModel = BooleanModel.create(null, fontStrikethrough);\n    models.push(fontStrikethroughModel);\n  }\n\n  const notChanged = Computed.create(null, use => models.every(m => use(m.needsSaving) === false));\n\n  function revert() {\n    onRevert?.();\n    if (!onRevert) {\n      models.forEach(m => m.revert());\n    }\n    ctl.close();\n  }\n\n  ctl.onDispose(async () => {\n    if (!notChanged.get()) {\n      try {\n        // TODO: disable the trigger btn while saving\n        await onSave?.();\n      } catch (e) {\n        onRevert?.();\n        if (!onRevert) {\n          models.forEach(m => m.revert());\n        }\n      }\n    }\n    models.forEach(m => m.dispose());\n    notChanged.dispose();\n    onClose?.();\n  });\n\n  return cssContainer(\n    dom.cls(gristFloatingMenuClass),\n    cssComponents(\n      dom.create(FontComponent, {\n        fontBoldModel,\n        fontUnderlineModel,\n        fontItalicModel,\n        fontStrikethroughModel,\n      }),\n      dom.create(PickerComponent, textColorModel, {\n        title: \"text\",\n        ...textColor,\n      }),\n      dom.create(PickerComponent, fillColorModel, {\n        title: \"fill\",\n        ...fillColor,\n      }),\n    ),\n    // gives focus and binds keydown events\n    (elem: any) => { setTimeout(() => elem.focus(), 0); },\n    onKeyDown({\n      Escape: () => { revert(); },\n      Enter: () => { ctl.close(); },\n    }),\n\n    cssButtonRow(\n      primaryButton(t(\"Apply\"),\n        dom.on(\"click\", () => ctl.close()),\n        dom.boolAttr(\"disabled\", notChanged),\n        testId(\"colors-save\"),\n      ),\n      basicButton(t(\"Cancel\"),\n        dom.on(\"click\", () => revert()),\n        testId(\"colors-cancel\"),\n      ),\n    ),\n\n    // Set focus when `focusout` is bubbling from a children element. This is to allow to receive\n    // keyboard event again after user interacted with the hex box text input.\n    dom.on(\"focusout\", (ev, elem) => (ev.target !== elem) && elem.focus()),\n\n    ...domArgs,\n  );\n}\n\n// PickerModel is a helper model that helps keep track of the server value for an observable that\n// needs to be changed locally without saving. To use, you must call `model.setValue(...)` instead\n// of `obs.set(...)`. Then it offers `model.needsSaving()` that tells you whether current value\n// needs saving, and `model.revert()` that reverts obs to the its server value.\nclass PickerModel<T extends boolean | string | undefined> extends Disposable {\n  // Is current value different from the server value?\n  public needsSaving: Observable<boolean>;\n  private _serverValue: Observable<T>;\n  private _localChange: boolean = false;\n  constructor(public obs: Observable<T>) {\n    super();\n    this._serverValue = Observable.create(this, this.obs.get());\n    this.needsSaving =  Computed.create(this, (use) => {\n      const current = use(this.obs);\n      const server = use(this._serverValue);\n      // We support booleans and strings only for now, so if current is false and server\n      // is undefined, we assume they are the same.\n      // TODO: this probably should be a strategy method.\n      return current !== (typeof current === \"boolean\" ? (server ?? false) : server);\n    });\n    this.autoDispose(this.obs.addListener((val) => {\n      if (this._localChange) { return; }\n      this._serverValue.set(val);\n    }));\n  }\n\n  // Set the value picked by the user\n  public setValue(val: T) {\n    this._localChange = true;\n    this.obs.set(val);\n    this._localChange = false;\n  }\n\n  // Revert obs to its server value\n  public revert() {\n    this.obs.set(this._serverValue.get());\n  }\n}\n\nclass ColorModel extends PickerModel<string | undefined> {}\nclass BooleanModel extends PickerModel<boolean | undefined> {}\n\ninterface PickerComponentOptions {\n  title: string;\n  allowsNone: boolean;\n  // Default color to show when value is empty or undefined (itself can be empty).\n  defaultColor: string;\n  // Text to be shown in the picker when color is not set.\n  noneText: string;\n}\nclass PickerComponent extends Disposable {\n  private _colorHex = Computed.create(this, this._model.obs, (_use, val) =>\n    val?.toUpperCase().slice(0, 7));\n\n  private _colorCss = Computed.create(this, this._colorHex, (_use, color) =>\n    color || this._options.defaultColor);\n\n  constructor(\n    private _model: PickerModel<string | undefined>,\n    private _options: PickerComponentOptions) {\n    super();\n  }\n\n  public buildDom() {\n    const title = this._options.title;\n    const colorText = Computed.create(null, use => use(this._colorHex) || this._options.noneText);\n    return dom(\"div\",\n      cssHeaderRow(title),\n      cssControlRow(\n        cssColorPreview(\n          dom.update(\n            cssColorSquare(\n              cssLightBorder.cls(\"\"),\n              dom.style(\"background-color\", this._colorCss),\n              cssNoneIcon(\"Empty\",\n                dom.hide(use => Boolean(use(this._colorCss))),\n              ),\n              testId(`${title}-color-square`),\n            ),\n            cssColorInput(\n              { type: \"color\" },\n              dom.attr(\"value\", use => use(this._model.obs) ?? \"\"),\n              dom.on(\"input\", (ev, elem) => this._setValue(elem.value || undefined)),\n              testId(`${title}-input`),\n            ),\n          ),\n          cssHexBox(\n            colorText,\n            async (val) => {\n              if (!val || isValidHex(val)) {\n                this._model.setValue(val || undefined);\n              }\n            },\n            dom.autoDispose(colorText),\n            testId(`${title}-hex`),\n            // select the hex value on click. Doing it using settimeout allows to avoid some\n            // sporadically losing the selection just after the click.\n            dom.on(\"click\", (ev, elem) => setTimeout(() => elem.select(), 0)),\n          ),\n        ),\n        cssEmptyBox(\n          cssEmptyBox.cls(\"-selected\", use => !use(this._colorHex)),\n          dom.on(\"click\", () => this._setValue(undefined)),\n          dom.hide(!this._options.allowsNone),\n          cssNoneIcon(\"Empty\"),\n          testId(`${title}-empty`),\n        ),\n      ),\n      cssPalette(\n        swatches.map((color, index) => (\n          cssColorSquare(\n            dom.style(\"background-color\", color),\n            cssLightBorder.cls(\"\", isLight(index)),\n            cssColorSquare.cls(\"-selected\", use => use(this._colorHex) === color),\n            dom.style(\"outline-color\", isLight(index) ? \"\" : color),\n            dom.on(\"click\", () => this._setValue(color)),\n            testId(`color-${color}`),\n          )\n        )),\n        testId(`${title}-palette`),\n      ),\n    );\n  }\n\n  private _setValue(val: string | undefined) {\n    this._model.setValue(val);\n  }\n}\n\nclass FontComponent extends Disposable {\n  private _bold = this._options.fontBoldModel;\n  private _underline = this._options.fontUnderlineModel;\n  private _italic = this._options.fontItalicModel;\n  private _strikethrough = this._options.fontStrikethroughModel;\n\n  constructor(\n    private _options: {\n      fontBoldModel?: BooleanModel,\n      fontUnderlineModel?: BooleanModel,\n      fontItalicModel?: BooleanModel,\n      fontStrikethroughModel?: BooleanModel,\n    },\n  ) {\n    super();\n  }\n\n  public buildDom() {\n    function option(iconName: IconName, model: BooleanModel) {\n      return cssFontOption(\n        cssFontIcon(iconName),\n        dom.on(\"click\", () => model.setValue(!model.obs.get())),\n        cssFontOption.cls(\"-selected\", use => use(model.obs) ?? false),\n        testId(`font-option-${iconName}`),\n      );\n    }\n    return cssFontOptions(\n      this._bold ? option(\"FontBold\", this._bold) : null,\n      this._underline ? option(\"FontUnderline\", this._underline) : null,\n      this._italic ? option(\"FontItalic\", this._italic) : null,\n      this._strikethrough ? option(\"FontStrikethrough\", this._strikethrough) : null,\n    );\n  }\n}\n\nconst cssFontOptions = styled(\"div\", `\n  display: flex;\n  border: 1px solid ${theme.colorSelectFontOptionsBorder};\n\n  &:empty {\n    display: none;\n  }\n`);\n\nconst cssFontOption = styled(\"div\", `\n  display: grid;\n  place-items: center;\n  flex-grow: 1;\n  --icon-color: ${theme.colorSelectFontOptionFg};\n  height: 24px;\n  cursor: pointer;\n\n  &:not(:last-child) {\n    border-right: 1px solid ${theme.colorSelectFontOptionsBorder};\n  }\n  &:hover:not(&-selected) {\n    background: ${theme.colorSelectFontOptionBgHover};\n  }\n  &-selected {\n    background: ${theme.colorSelectFontOptionBgSelected};\n    --icon-color: ${theme.colorSelectFontOptionFgSelected}\n  }\n`);\n\nconst cssColorInput = styled(\"input\", `\n  opacity: 0;\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  padding: 0;\n  border: none;\n`);\n\nconst cssColorPreview = styled(\"div\", `\n  display: flex;\n`);\n\nconst cssControlRow = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 8px;\n`);\n\nconst cssHeaderRow = styled(\"div\", `\n  color: ${theme.colorSelectFg};\n  text-transform: uppercase;\n  font-size: ${vars.smallFontSize};\n  margin-bottom: 12px;\n`);\n\nconst cssPalette = styled(\"div\", `\n  width: 236px;\n  height: calc(4 * 20px + 3 * 4px);\n  display: flex;\n  flex-direction: column;\n  flex-wrap: wrap;\n  justify-content: space-between;\n  align-content: space-between;\n`);\n\nconst cssContainer = styled(\"div\", `\n  padding: 18px 16px;\n  background-color: ${theme.colorSelectBg};\n  box-shadow: 0 2px 16px 0 ${theme.colorSelectShadow};\n  z-index: 20;\n  margin: 2px 0;\n  &:focus {\n    outline: none;\n  }\n`);\n\nconst cssComponents = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 24px;\n`);\n\nconst cssContent = styled(\"div\", `\n  display: flex;\n  align-items: center;\n`);\n\nconst cssHexBox = styled(textInput, `\n  border: 1px solid ${theme.colorSelectInputBorder};\n  border-left: none;\n  font-size: ${vars.smallFontSize};\n  display: flex;\n  align-items: center;\n  color: ${theme.colorSelectInputFg};\n  background-color: ${theme.colorSelectInputBg};\n  width: 56px;\n  outline: none;\n  padding: 0 3px;\n  height: unset;\n  border-radius: unset;\n`);\n\nconst cssLightBorder = styled(\"div\", `\n  border: 1px solid ${theme.colorSelectColorSquareBorder};\n`);\n\nconst cssColorSquare = styled(\"div\", `\n  width: 20px;\n  height: 20px;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  position: relative;\n  &-selected {\n    outline: 1px solid ${theme.colorSelectColorSquareBorder};\n    outline-offset: 1px;\n  }\n`);\n\nconst cssEmptyBox = styled(cssColorSquare, `\n  --icon-color: ${theme.iconError};\n  border: 1px solid #D9D9D9;\n  &-selected {\n    outline: 1px solid ${theme.colorSelectColorSquareBorderEmpty};\n    outline-offset: 1px;\n  }\n`);\n\nconst cssFontIcon = styled(icon, `\n  height: 12px;\n  width: 12px;\n`);\n\nconst cssNoneIcon = styled(icon, `\n  height: 100%;\n  width: 100%;\n  --icon-color: ${theme.iconError}\n`);\n\nconst cssButtonIcon = styled(cssColorSquare, `\n  margin-right: 6px;\n  margin-left: 4px;\n`);\n\nconst cssIconBtn = styled(cssColorSquare, `\n  min-width: 18px;\n  width: 18px;\n  height: 18px;\n  cursor: pointer;\n  display: grid;\n  place-items: center;\n`);\n\nconst cssButtonRow = styled(\"div\", `\n  gap: 8px;\n  display: flex;\n  margin-top: 24px;\n`);\n"
  },
  {
    "path": "app/client/ui2018/IconList.ts",
    "content": "// This file is auto-generated by buildtools/genIconCSS.ts\n// Do not edit it manually.\n\nexport const IconList = [\n  \"ChartArea\",\n  \"ChartBar\",\n  \"ChartDonut\",\n  \"ChartKaplan\",\n  \"ChartLine\",\n  \"ChartPie\",\n  \"TypeCalendar\",\n  \"TypeCard\",\n  \"TypeCardList\",\n  \"TypeCell\",\n  \"TypeChart\",\n  \"TypeCustom\",\n  \"TypeDetails\",\n  \"TypeTable\",\n  \"FieldAny\",\n  \"FieldAttachment\",\n  \"FieldCheckbox\",\n  \"FieldChoice\",\n  \"FieldColumn\",\n  \"FieldDate\",\n  \"FieldDateTime\",\n  \"FieldFunction\",\n  \"FieldFunctionEqual\",\n  \"FieldInteger\",\n  \"FieldLink\",\n  \"FieldMarkdown\",\n  \"FieldNumeric\",\n  \"FieldReference\",\n  \"FieldReferenceDisabled\",\n  \"FieldSpinner\",\n  \"FieldSwitcher\",\n  \"FieldTable\",\n  \"FieldText\",\n  \"FieldTextbox\",\n  \"FieldToggle\",\n  \"LoginStreamline\",\n  \"LoginUnify\",\n  \"LoginVisualize\",\n  \"GoogleLogo\",\n  \"GristLogo\",\n  \"ThumbPreview\",\n  \"Accessibility\",\n  \"AddUser\",\n  \"ArrowLeft\",\n  \"ArrowRight\",\n  \"ArrowRightOutlined\",\n  \"BarcodeQR\",\n  \"BarcodeQR2\",\n  \"Board\",\n  \"Bookmark\",\n  \"CenterAlign\",\n  \"Chat\",\n  \"Clock\",\n  \"Code\",\n  \"Collapse\",\n  \"Columns\",\n  \"Convert\",\n  \"Copy\",\n  \"CrossBig\",\n  \"CrossSmall\",\n  \"Database\",\n  \"Desktop\",\n  \"Dots\",\n  \"Download\",\n  \"DragDrop\",\n  \"Dropdown\",\n  \"DropdownUp\",\n  \"Empty\",\n  \"Exclamation\",\n  \"Expand\",\n  \"EyeHide\",\n  \"EyeShow\",\n  \"Feedback\",\n  \"Filter\",\n  \"FilterSimple\",\n  \"Fireworks\",\n  \"Flag\",\n  \"Folder\",\n  \"Folder2\",\n  \"FontBold\",\n  \"FontItalic\",\n  \"FontStrikethrough\",\n  \"FontUnderline\",\n  \"FormConfig\",\n  \"FunctionResult\",\n  \"GreenArrow\",\n  \"Grow\",\n  \"Headband\",\n  \"Heart\",\n  \"Help\",\n  \"Home\",\n  \"Idea\",\n  \"Import\",\n  \"ImportArrow\",\n  \"Info\",\n  \"Layers\",\n  \"LeftAlign\",\n  \"Lighting\",\n  \"Lock\",\n  \"Log\",\n  \"Mail\",\n  \"Maximize\",\n  \"Memo\",\n  \"Message\",\n  \"Minimize\",\n  \"Minus\",\n  \"Mobile\",\n  \"MobileChat\",\n  \"MobileChat2\",\n  \"NewNotification\",\n  \"Notification\",\n  \"Offline\",\n  \"Page\",\n  \"PanelLeft\",\n  \"PanelRight\",\n  \"Paragraph\",\n  \"Pencil\",\n  \"Pin2\",\n  \"PinBig\",\n  \"PinSmall\",\n  \"PinTilted\",\n  \"Pivot\",\n  \"PivotLight\",\n  \"Plus\",\n  \"Popup\",\n  \"Public\",\n  \"PublicColor\",\n  \"PublicFilled\",\n  \"Question\",\n  \"Redo\",\n  \"Remove\",\n  \"RemoveBig\",\n  \"Repl\",\n  \"ResizePanel\",\n  \"Revert\",\n  \"RightAlign\",\n  \"Robot\",\n  \"Script\",\n  \"Search\",\n  \"Section\",\n  \"Separator\",\n  \"Settings\",\n  \"Share\",\n  \"Skip\",\n  \"Smiley\",\n  \"Sort\",\n  \"Sparks\",\n  \"Star\",\n  \"Stop\",\n  \"Tick\",\n  \"TickSolid\",\n  \"TickSwitch\",\n  \"Undo\",\n  \"Validation\",\n  \"Video\",\n  \"VideoPlay\",\n  \"VideoPlay2\",\n  \"Warning\",\n  \"Warning2\",\n  \"Widget\",\n  \"World\",\n  \"Wrap\",\n  \"Zoom\",\n  \"UseChart\",\n  \"UseEducate\",\n  \"UseFinance\",\n  \"UseHr\",\n  \"UseMedia\",\n  \"UseMonitor\",\n  \"UseOther\",\n  \"UseProduct\",\n  \"UseSales\",\n  \"UseScience\",\n] as const;\n\nexport type IconName = (typeof IconList)[number];\n"
  },
  {
    "path": "app/client/ui2018/alerts.ts",
    "content": "/**\n * A simple component that displays a highlighted-styled div with an info icon and a message.\n *\n * Example:\n *\n * `alert('This is some important information')`\n */\nimport { vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\n\nimport { dom, DomElementArg, styled } from \"grainjs\";\n\n/**\n * Creates an alert element with an icon and message.\n */\nexport function alert(content: DomElementArg, ...args: DomElementArg[]) {\n  return cssAlert(\n    cssAlertIcon(\n      icon(\"Info\",\n        dom.style(\"width\", \"13px\"),\n        dom.style(\"height\", \"13px\"),\n        dom.style(\"background-color\", \"currentColor\"),\n      ),\n    ),\n    dom(\"p\", content),\n    ...args,\n  );\n}\n\nconst cssAlert = styled(\"div\", `\n  position: relative;\n  display: flex;\n  align-items: baseline;\n  gap: 8px;\n  padding: 8px 12px;\n  background-color: #ffe4b6;\n  color: #000;\n  border: 1px solid #cb7b2b;\n  border-radius: ${vars.controlBorderRadius};\n  margin-bottom: 16px;\n  & p {\n    margin: 0;\n  }\n`);\n\nconst cssAlertIcon = styled(\"span\", `\n  display: inline-flex;\n  align-items: center;\n  position: relative;\n  top: 2px;\n`);\n"
  },
  {
    "path": "app/client/ui2018/ariaTabs.ts",
    "content": "import { dom, DomContents, Observable } from \"grainjs\";\n\n/**\n * Helper to simplify building tabs following the ARIA Tabs pattern.\n *\n * This uses `ariaTabList`, `ariaTab` and `ariaTabPanel` internally,\n * exposing functions with the correct `tabListId` and `state` arguments already set.\n *\n * @param tabListId - The id of the tablist. Unique name that is used to generate various `id` dom attributes.\n * @param state - The observable that contains the current active tab id. It gets updated when tab changes.\n */\nexport const ariaTabs = (tabListId: string, state: Observable<string>) => {\n  return {\n    tabList: ariaTabList,\n    tab: (tabId: string) => ariaTab(tabListId, tabId, state),\n    tabPanel: (tabId: string, children: DomContents) => ariaTabPanel(tabListId, tabId, state, children),\n  };\n};\n\n/**\n * Returns a list of DOM args to attach to an element we want to expose as a \"tab list\",\n * following the ARIA Tabs pattern. https://www.w3.org/WAI/ARIA/apg/patterns/tabs/\n *\n * A \"tab list\" is a dom element containing multiple \"tabs\" (see `ariaTab`).\n */\nexport const ariaTabList = () => ({ role: \"tablist\" });\n\n/**\n * Returns a list of DOM args to attach to an element we want to expose as a \"tab\",\n * following the ARIA Tabs pattern. https://www.w3.org/WAI/ARIA/apg/patterns/tabs/\n *\n * A \"tab\" is a button that is a child of a \"tablist\". It is used to switch the current active tab\n * of the tablist.\n *\n * For tab content, see `ariaTabPanel`.\n *\n * @param tabListId - The id of the tablist. It helps building the final element `id` attribute of the tab.\n * @param tabId - The id for this tab. It matches the value representing the tab in the state.\n *                It helps building the final element `id` attribute of the tab.\n * @param state - The observable that contains the current active tab id. It gets updated when tab changes.\n */\nexport const ariaTab = (tabListId: string, tabId: string, state: Observable<string>) => {\n  return [\n    {\n      \"id\": `aria-tab-${tabListId}-${tabId}`,\n      \"role\": \"tab\",\n      \"data-tab-id\": tabId,\n      \"aria-controls\": `aria-tabpanel-${tabListId}-${tabId}`,\n    },\n    dom.attr(\"aria-selected\", use => use(state) === tabId ? \"true\" : \"false\"),\n    dom.attr(\"tabindex\", use => use(state) === tabId ? \"0\" : \"-1\"),\n    // this is important to bypass default handling of tabindex in the RegionFocusSwitcher and Clipboard\n    dom.cls(\"ignore_tabindex\"),\n    dom.on(\"click\", () => state.set(tabId)),\n    dom.onKeyDown({\n      // Only horizontal tabs are currently implemented.\n      ArrowLeft: event => cycle(event.target, state, -1),\n      ArrowRight: event => cycle(event.target, state, 1),\n    }),\n  ];\n};\n\n/**\n * Returns a list of DOM args to attach to an element we want to expose as a \"tab panel\",\n * and automatically renders its content only when the tab is active.\n *\n * This follows the ARIA Tabs pattern: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/\n *\n * A \"tab panel\" is a content area containing children elements that are displayed only\n * when the tied `ariaTab` is the active one.\n *\n * Note that the tab panel itself must always be in the DOM, whether or not it is the active one.\n * This helper takes care of rendering the tab panel content or not based on the current active tab state.\n *\n * For tab buttons, see `ariaTab`.\n *\n * @param tabListId - The id of the tablist. It helps building the final element `id` attribute of the tabpanel.\n * @param tabId - The id for this tabpanel. It matches the value representing the tab in the state of the tied\n *                `ariaTab`. It helps building the final element `id` attribute of the tabpanel.\n * @param state - The observable that contains the current active tab id. It gets updated when tab changes.\n * @param children - The tab content, automatically appended in the DOM only when the tab is active.\n */\nexport const ariaTabPanel = (tabListId: string, tabId: string, state: Observable<string>, children: DomContents) => {\n  return [\n    {\n      \"id\": `aria-tabpanel-${tabListId}-${tabId}`,\n      \"role\": \"tabpanel\",\n      \"aria-labelledby\": `aria-tab-${tabListId}-${tabId}`,\n    },\n    dom.attr(\"aria-hidden\", use => use(state) !== tabId ? \"true\" : \"false\"),\n    dom.domComputed(state, currentTabId => currentTabId === tabId ? children : null),\n  ];\n};\n\nconst cycle = (fromElement: EventTarget | null, state: Observable<string>, direction: number) => {\n  if (!fromElement) {\n    return;\n  }\n  const tabList = (fromElement as HTMLElement)?.closest('[role=\"tablist\"]');\n  if (!tabList) {\n    return;\n  }\n  const tabs = tabList.querySelectorAll('[role=\"tab\"]');\n  const currentIndex = Array.from(tabs).indexOf(fromElement as HTMLElement);\n  const newIndex = (currentIndex + direction + tabs.length) % tabs.length;\n  const newTabId = tabs[newIndex].getAttribute(\"data-tab-id\");\n  if (newTabId) {\n    state.set(newTabId);\n    const newTab = tabs[newIndex];\n    (newTab as HTMLElement).focus();\n  }\n};\n"
  },
  {
    "path": "app/client/ui2018/breadcrumbs.ts",
    "content": "/**\n * Exports `docBreadcrumbs()` which returns a styled breadcrumb for the current page:\n *\n *  [icon] Workspace (link) / Document name (editable) / Page name (editable)\n *\n * Workspace is a clickable link and document and page names are editable labels.\n */\nimport { makeT } from \"app/client/lib/localization\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { cssHideForNarrowScreen, mediaNotSmall, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { editableLabel } from \"app/client/ui2018/editableLabel\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\n\nimport { BindableValue, dom, Observable, styled } from \"grainjs\";\nimport { tooltip } from \"popweasel\";\n\nconst t = makeT(\"breadcrumbs\");\n\nexport const cssBreadcrumbs = styled(\"div\", `\n  color: ${theme.lightText};\n  white-space: nowrap;\n  cursor: default;\n`);\n\nexport const separator = styled(\"span\", `\n  padding: 0 2px;\n`);\n\nconst cssIcon = styled(icon, `\n  background-color: ${theme.accentIcon};\n  margin-top: -2px;\n`);\n\nconst cssPublicIcon = styled(cssIcon, `\n  margin-left: 8px;\n  margin-top: -4px;\n`);\n\nconst cssWorkspaceName = styled(cssLink, `\n  margin-left: 8px;\n`);\n\nconst cssWorkspaceNarrowScreen = styled(icon, `\n  transform: rotateY(180deg);\n  width: 32px;\n  height: 32px;\n  margin-bottom: 4px;\n  margin-left: -7px;\n  margin-right: 8px;\n  background-color: ${theme.lightText};\n  cursor: pointer;\n  @media ${mediaNotSmall} {\n    & {\n      display: none;\n    }\n  }\n`);\n\nconst cssEditableName = styled(\"input\", `\n  &:hover, &:focus {\n    color: ${theme.text};\n  }\n`);\n\nconst cssTag = styled(\"span\", `\n  background-color: ${theme.breadcrumbsTagBg};\n  color: ${theme.breadcrumbsTagFg};\n  border-radius: 3px;\n  padding: 0 4px;\n  margin-left: 4px;\n`);\n\nconst cssAlertTag = styled(cssTag, `\n  background-color: ${theme.breadcrumbsTagAlertBg};\n  --icon-color: ${theme.breadcrumbsTagFg};\n  a {\n    cursor: pointer;\n  }\n`);\n\ninterface PartialWorkspace {\n  id: number;\n  name: string;\n}\n\nexport function docBreadcrumbs(\n  workspace: Observable<PartialWorkspace | null>,\n  docName: Observable<string>,\n  pageName: Observable<string>,\n  options: {\n    docNameSave: (val: string) => Promise<void>,\n    pageNameSave: (val: string) => Promise<void>,\n    cancelRecoveryMode: () => Promise<void>,\n    proposeChanges?: () => Promise<void>,\n    isDocNameReadOnly?: BindableValue<boolean>,\n    isPageNameReadOnly?: BindableValue<boolean>,\n    isFork: Observable<boolean>,\n    isTutorialFork: Observable<boolean>,\n    isBareFork: Observable<boolean>,\n    isFiddle: Observable<boolean>,\n    isRecoveryMode: Observable<boolean>,\n    isSnapshot?: Observable<boolean>,\n    isPublic?: Observable<boolean>,\n    isTemplate?: Observable<boolean>,\n    isAnonymous?: boolean,\n    isProposable?: Observable<boolean>,\n    isReadonly?: Observable<boolean>,\n  },\n): Element {\n  const shouldShowWorkspace = !(options.isTemplate && options.isAnonymous);\n  return cssBreadcrumbs(\n    !shouldShowWorkspace ? null : dom.domComputed<[boolean, PartialWorkspace | null]>(\n      use => [use(options.isBareFork), use(workspace)],\n      ([isBareFork, ws]) => {\n        if (isBareFork || !ws) { return null; }\n        return [\n          cssIcon(\"Home\",\n            testId(\"bc-home\"),\n            cssHideForNarrowScreen.cls(\"\")),\n          cssWorkspaceName(\n            urlState().setLinkUrl({ ws: ws.id }),\n            dom.text(ws.name),\n            testId(\"bc-workspace\"),\n            cssHideForNarrowScreen.cls(\"\"),\n          ),\n          cssWorkspaceNarrowScreen(\n            \"Expand\",\n            urlState().setLinkUrl({ ws: ws.id }),\n            testId(\"bc-workspace-ns\"),\n          ),\n          separator(\" / \",\n            testId(\"bc-separator\"),\n            cssHideForNarrowScreen.cls(\"\")),\n        ];\n      },\n    ),\n    editableLabel(docName, {\n      save: options.docNameSave,\n      inputArgs: [\n        testId(\"bc-doc\"),\n        cssEditableName.cls(\"\"),\n        dom.boolAttr(\"disabled\", options.isDocNameReadOnly || false),\n      ],\n    }),\n    dom.maybe(options.isPublic, () => cssPublicIcon(\"PublicFilled\", testId(\"bc-is-public\"))),\n    dom.domComputed((use) => {\n      if (options.isSnapshot && use(options.isSnapshot)) {\n        return cssTag(t(\"snapshot\"), testId(\"snapshot-tag\"));\n      }\n      if (use(options.isFork) && !use(options.isTutorialFork)) {\n        if (options.isProposable && use(options.isProposable)) {\n          return cssTag(t(\"suggesting\"), testId(\"proposing-changes-tag\"));\n        } else {\n          return cssTag(t(\"unsaved\"), testId(\"unsaved-tag\"));\n        }\n      }\n      if (use(options.isRecoveryMode)) {\n        return cssAlertTag(t(\"recovery mode\"),\n          dom(\"a\", dom.on(\"click\", () => options.cancelRecoveryMode()),\n            icon(\"CrossSmall\")),\n          testId(\"recovery-mode-tag\"));\n      }\n      if (use(options.isFiddle)) {\n        if (options.isProposable && use(options.isProposable)) {\n          return cssTag(t(\"suggesting\"), tooltip({ title: t(`You may make edits,\nbut they will not affect the original document.\nYou can propose them as suggestions.`) }), testId(\"fiddle-tag\"));\n        } else {\n          return cssTag(t(\"fiddle\"), tooltip({ title: t(`You may make edits, but they will create a new copy and will\nnot affect the original document.`) }), testId(\"fiddle-tag\"));\n        }\n      }\n      if (options.isProposable && use(options.isProposable)) {\n        if (options.isReadonly && use(options.isReadonly)) {\n          return cssAlertTag(\"\",\n            dom(\"a\", dom.on(\"click\", () => options.proposeChanges?.()),\n              \"suggesting \", icon(\"Pencil\")),\n            testId(\"propose-changes-tag\"));\n        } else {\n          return cssTag(t(\"editing\"), tooltip({\n            title: \"Editing directly. Work on a copy if you want to propose changes.\",\n          }), testId(\"direct-tag\"));\n        }\n      }\n    }),\n    separator(\" / \",\n      testId(\"bc-separator\"),\n      cssHideForNarrowScreen.cls(\"\")),\n    editableLabel(pageName, {\n      save: options.pageNameSave,\n      inputArgs: [\n        testId(\"bc-page\"),\n        cssEditableName.cls(\"\"),\n        dom.boolAttr(\"disabled\", options.isPageNameReadOnly || false),\n        dom.cls(cssHideForNarrowScreen.className),\n      ],\n    }),\n  );\n}\n"
  },
  {
    "path": "app/client/ui2018/buttonSelect.ts",
    "content": "import { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { colors, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\nimport { isColorDark } from \"app/common/gutil\";\nimport { components } from \"app/common/ThemePrefs\";\n\nimport { dom, DomElementArg, Observable, styled } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\n\nexport interface ISelectorOptionFull<T> {\n  value: T;\n  label?: string;\n  tooltip?: string;\n  icon?: IconName;\n}\n\n// For string options, we can use a string for label and value without wrapping into an object.\nexport type ISelectorOption<T> = (T & string) | ISelectorOptionFull<T>;\n\n/**\n * Creates a button select, which is a row of buttons of which only one may be selected.\n * The observable `obs` reflects the value of the selected option, and `optionArray` is an array\n * of option values and labels. These may be either strings, or {label, value, icon} objects.\n * Icons and labels are optional (but one should be included or the buttons will be blank).\n *\n * The type of value may be any type at all; it is opaque to this widget.\n *\n * A \"light\" style is supported in CSS by passing cssButtonSelect.cls('-light') as an additional\n * argument.\n *\n * A disabled state is supported by passing cssButtonSelect.cls('-disabled').\n *\n * Usage:\n *    const fruit = observable(\"apple\");\n *    buttonSelect(fruit, [\"apple\", \"banana\", \"mango\"]);\n *\n *    const alignments: ISelectorOption<string>[] = [\n *      {value: 'left',   icon: 'LeftAlign'},\n *      {value: 'center', icon: 'CenterAlign'},\n *      {value: 'right',  icon: 'RightAlign'}\n *    ];\n *    buttonSelect(obs, alignments);\n *\n */\nexport function buttonSelect<T>(\n  obs: Observable<T>,\n  optionArray: ISelectorOption<T>[],\n  ...domArgs: DomElementArg[]\n) {\n  return makeButtonSelect(obs, optionArray, (val: T) => { obs.set(val); }, ...domArgs);\n}\n\n/**\n * Identical to a buttonSelect, but allows the possibility of none of the items being selected.\n * Sets the observable `obs` to null when no items are selected.\n */\nexport function buttonToggleSelect<T>(\n  obs: Observable<T | null>,\n  optionArray: ISelectorOption<T>[],\n  ...domArgs: DomElementArg[]\n) {\n  const onClick = (val: T) => { obs.set(obs.get() === val ? null : val); };\n  return makeButtonSelect(obs, optionArray, onClick, ...domArgs);\n}\n\n/**\n * Pre-made text alignment selector.\n */\nexport function alignmentSelect(obs: Observable<string>, ...domArgs: DomElementArg[]) {\n  const alignments: ISelectorOption<string>[] = [\n    { value: \"left\",   icon: \"LeftAlign\" },\n    { value: \"center\", icon: \"CenterAlign\" },\n    { value: \"right\",  icon: \"RightAlign\" },\n  ];\n  return buttonSelect(obs, alignments, {}, testId(\"alignment-select\"), ...domArgs);\n}\n\n/**\n * Color selector button. Observable should contain a hex color value, e.g. #a4ba23.\n */\nexport function colorSelect(value: Observable<string>, save: (val: string) => Promise<void>,\n  ...domArgs: DomElementArg[]) {\n  // On some machines (seen on chrome running on a Mac) the `change` event fires as many times as\n  // the `input` event, hence the debounce. Also note that when user picks a first color and then a\n  // second before closing the picker, it will create two user actions on Chrome, and only one in FF\n  // (which should be the expected behaviour).\n  const setValue = debounce(e => value.set(e.target.value), 300);\n  const onSave = debounce(e => save(e.target.value), 300);\n\n  return cssColorBtn(\n    // TODO: When re-opening the color picker after a new color was saved on server, the picker will\n    // reset the value to what it was when the picker was last closed. To allow picker to show the\n    // latest saved value we should rebind the <input .../> element each time the value is changed\n    // by the server.\n    cssColorPicker(\n      { type: \"color\" },\n      dom.attr(\"value\", use => use(value).slice(0, 7)),\n      dom.on(\"input\", setValue),\n      dom.on(\"change\", onSave),\n    ),\n    dom.style(\"background-color\", use => use(value) || \"#000000\"),\n    cssColorBtn.cls(\"-dark\", use => isColorDark(use(value) || \"#000000\")),\n    cssColorIcon(\"Dots\"),\n    ...domArgs,\n  );\n}\n\nexport function makeButtonSelect<T>(\n  obs: Observable<T | null>,\n  optionArray: ISelectorOption<T>[],\n  onClick: (value: T) => any,\n  ...domArgs: DomElementArg[]\n) {\n  return cssButtonSelect(\n    dom.forEach(optionArray, (option: ISelectorOption<T>) => {\n      const value = getOptionValue(option);\n      const label = getOptionLabel(option);\n      const tooltip = isFullOption(option) && option.tooltip;\n      const screenReaderLabel = !label && tooltip;\n      return cssSelectorBtn(\n        tooltip ? hoverTooltip(tooltip) : null,\n        screenReaderLabel ? { \"aria-label\": screenReaderLabel } : null,\n        cssSelectorBtn.cls(\"-selected\", use => use(obs) === value),\n        dom.on(\"click\", () => onClick(value)),\n        isFullOption(option) && option.icon ? icon(option.icon) : null,\n        label ? cssSelectorLabel(label) : null,\n        testId(\"select-button\"),\n      );\n    }),\n    ...domArgs,\n  );\n}\n\nfunction isFullOption<T>(option: ISelectorOption<T>): option is ISelectorOptionFull<T> {\n  return typeof option !== \"string\";\n}\n\nfunction getOptionLabel<T>(option: ISelectorOption<T>): string | undefined {\n  return isFullOption(option) ? option.label : option;\n}\n\nfunction getOptionValue<T>(option: ISelectorOption<T>): T {\n  return isFullOption(option) ? option.value : option;\n}\n\nexport const cssButtonSelect = styled(\"div\", `\n  /* Resets */\n  position: relative;\n  outline: none;\n  border-style: none;\n  display: flex;\n\n  /* Vars */\n  color: ${theme.text};\n  flex: 1 1 0;\n\n  &-disabled {\n    opacity: 0.4;\n    pointer-events: none;\n  }\n\n  &:not(&-light) {\n    background-color: ${theme.inputBg};\n    padding: 3px;\n    gap: 4px;\n    border: 1px solid ${theme.buttonGroupBorder};\n    border-radius: ${vars.controlBorderRadius};\n  }\n`);\n\nconst cssSelectorBtn = styled(unstyledButton, `\n  display: flex;\n  align-items: center;\n  justify-content: center;\n\n  /* Vars */\n  flex: 1 1 0;\n  font-size: ${vars.mediumFontSize};\n  letter-spacing: -0.08px;\n  text-align: center;\n  line-height: normal;\n  min-width: 32px;\n  white-space: nowrap;\n  padding: 4px 10px;\n  border: 1px solid transparent;\n  border-radius: ${vars.controlBorderRadius};\n\n  background-color: ${theme.buttonGroupBg};\n  --icon-color: ${theme.buttonGroupIcon};\n\n  margin-left: -1px;\n\n  cursor: pointer;\n  outline-offset: -2px;\n\n  &:first-child {\n    margin-left: 0;\n  }\n\n  &:hover:not(&-selected) {\n    background-color: ${theme.buttonGroupBgHover};\n    border: 1px solid ${theme.buttonGroupBorderHover};\n    z-index: 5;  /* Update z-index so selected borders take precedent */\n  }\n\n  &-selected {\n    color: ${theme.buttonGroupSelectedFg};\n    --icon-color: ${theme.buttonGroupSelectedBorder};\n    border: 1px solid ${theme.buttonGroupSelectedBorder};\n    background-color: ${theme.buttonGroupSelectedBg};\n    z-index: 10;  /* Update z-index so selected borders take precedent */\n  }\n\n  /* Styles when container includes cssButtonSelect.cls('-light') */\n  .${cssButtonSelect.className}-light {\n    gap: 4px;\n  }\n\n  .${cssButtonSelect.className}-light > &:hover:not(&-selected) {\n    background: ${components.buttonGroupLightBg};\n  }\n\n  .${cssButtonSelect.className}-light > & {\n    border: 1px solid transparent;\n    border-radius: ${vars.controlBorderRadius};\n    margin-left: 0px;\n    padding: 5px;\n    min-width: 28px;\n    color: ${theme.buttonGroupLightFg};\n    --icon-color: ${theme.buttonGroupLightFg};\n  }\n\n  .${cssButtonSelect.className}-light > &-selected {\n    border-color: ${theme.buttonGroupLightSelectedFg};\n    color: ${theme.buttonGroupLightSelectedFg};\n    --icon-color: ${theme.buttonGroupLightSelectedFg};\n    background-color: ${components.buttonGroupLightSelectedBg};\n  }\n`);\n\nconst cssSelectorLabel = styled(\"span\", `\n  margin: 0 2px;\n  vertical-align: middle;\n`);\n\nconst cssColorBtn = styled(\"div\", `\n  position: relative;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  width: 100%;\n  min-width: 32px;\n  max-width: 56px;\n  height: 32px;\n  border-radius: 4px;\n  border: 1px solid ${colors.darkGrey};\n\n  &:hover {\n    border: 1px solid ${colors.hover};\n  }\n\n  &-dark {\n    border: none !important;\n  }\n`);\n\nconst cssColorPicker = styled(\"input\", `\n  position: absolute;\n  cursor: pointer;\n  opacity: 0;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n`);\n\nconst cssColorIcon = styled(icon, `\n  margin: 0 2px;\n  background-color: ${colors.slate};\n  pointer-events: none;\n\n  .${cssColorBtn.className}-dark & {\n    background-color: ${colors.light};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/buttons.ts",
    "content": "/**\n * UI 2018 Buttons\n *\n * Four styles are include: basicButton, primaryButton, bigBasicButton, bigPrimaryButton.\n *\n * Buttons support passing in DomElementArgs, which can be used to register click handlers, set\n * the disabled property, and other HTML <button> element behaviors.\n *\n * Examples:\n *\n * `basicButton('Basic button', dom.on('click', () => alert('Basic button')))`\n * `primaryButton('Primary button', dom.prop('disabled', true))`\n */\n\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { tbind } from \"app/common/tbind\";\nimport { components, tokens } from \"app/common/ThemePrefs\";\n\nimport { BindableValue, dom, DomElementArg, styled } from \"grainjs\";\n\nexport const cssButton = styled(\"button\", `\n  /* Resets */\n  position: relative;\n  outline: none;\n  border-style: none;\n  line-height: normal;\n  user-select: none;\n\n  /* Vars */\n  font-size:     ${vars.mediumFontSize};\n  letter-spacing: -0.08px;\n  padding: 4px 8px;\n\n  background-color: transparent;\n  color:            ${theme.controlFg};\n  --icon-color:     ${theme.controlFg};\n\n  border:        ${theme.controlBorder};\n  border-radius: ${vars.controlBorderRadius};\n\n  cursor: pointer;\n\n  outline-offset: 2px;\n\n  &-large {\n    font-weight: 500;\n    padding: 10px 24px;\n    min-height: 40px;\n  }\n\n  &-primary {\n    background-color: ${theme.controlPrimaryBg};\n    color:            ${theme.controlPrimaryFg};\n    --icon-color:     ${theme.controlPrimaryFg};\n    border-color:     ${theme.controlPrimaryBg};\n  }\n\n  &:hover {\n    color:        ${theme.controlHoverFg};\n    --icon-color: ${theme.controlHoverFg};\n    border-color: ${theme.controlHoverFg};\n  }\n  &-primary:hover {\n    color:            ${theme.controlPrimaryFg};\n    --icon-color:     ${theme.controlPrimaryFg};\n    background-color: ${theme.controlPrimaryHoverBg};\n    border-color: ${theme.controlPrimaryHoverBg};\n  }\n  &:disabled {\n    cursor: not-allowed;\n    color:        ${theme.controlDisabledFg};\n    --icon-color: ${theme.controlDisabledFg};\n    background-color: ${theme.controlDisabledBg};\n    border-color: ${theme.controlDisabledBg};\n  }\n`);\n\ninterface IButtonProps {\n  large?: BindableValue<boolean>;\n  primary?: BindableValue<boolean>;\n  link?: boolean;\n}\n\n/**\n * Helper to create a button or button-like link with requested properties.\n */\nexport function button(props: IButtonProps, ...domArgs: DomElementArg[]) {\n  const elem = props.link ? cssButtonLink(dom.cls(cssButton.className)) : cssButton();\n  return dom.update(elem,\n    cssButton.cls(\"-large\", props.large ?? false),\n    cssButton.cls(\"-primary\", props.primary ?? false),\n    ...domArgs,\n  );\n}\n\n// Button-creating functions, each taking ...DomElementArg arguments.\nexport const basicButton = tbind(button, null, {});\nexport const bigBasicButton = tbind(button, null, { large: true });\nexport const primaryButton = tbind(button, null, { primary: true });\nexport const bigPrimaryButton = tbind(button, null, { large: true, primary: true });\n\n// Functions that create button-like <a> links, each taking ...DomElementArg arguments.\nexport const basicButtonLink = tbind(button, null, { link: true });\nexport const bigBasicButtonLink = tbind(button, null, { link: true, large: true });\nexport const primaryButtonLink = tbind(button, null, { link: true, primary: true });\nexport const bigPrimaryButtonLink = tbind(button, null, { link: true, large: true, primary: true });\n\n// Button that looks like a link (have no background and no border).\n// On text button hover, allow theme to show a background and/or border.\n// It's done with a pseudo-element to add some \"padding\" to the background without moving the content.\nexport const textButton = styled(cssButton, `\n  position: relative;\n  z-index: 1;\n  border: none;\n  padding: 0px;\n  text-align: left;\n  background-color: inherit !important;\n  &:disabled, &[aria-disabled=\"true\"] {\n    color: ${theme.controlPrimaryBg};\n    opacity: 0.4;\n  }\n  &:hover::after {\n    z-index: -1;\n    content: '';\n    position: absolute;\n    inset: -3px;\n    left: -6px;\n    right: -6px;\n    border-radius: ${tokens.controlBorderRadius};\n    background-color: ${components.textButtonHoverBg};\n    border: 1px solid ${components.textButtonHoverBorder};\n  }\n  &-hover-bg-padding-none:hover::after {\n    top: 0px;\n    bottom: 0px;\n  }\n  &-hover-bg-padding-sm:hover::after {\n    inset: -1px;\n    left: -3px;\n    right: -3px;\n  }\n  &:active::after {\n    border-color: transparent;\n    background-color: transparent;\n  }\n`);\n\nconst cssButtonLink = styled(\"a\", `\n  display: inline-block;\n  &, &:hover, &:focus {\n    text-decoration: none;\n  }\n`);\n\nexport const cssButtonGroup = styled(\"div\", `\n  display: flex;\n  flex-direction: row;\n\n  & > .${cssButton.className} {\n    border-radius: 0;\n  }\n\n  & > .${cssButton.className}:first-child {\n    border-top-left-radius: ${vars.controlBorderRadius};\n    border-bottom-left-radius: ${vars.controlBorderRadius};\n  }\n\n  & > .${cssButton.className}:last-child {\n    border-top-right-radius: ${vars.controlBorderRadius};\n    border-bottom-right-radius: ${vars.controlBorderRadius};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/checkbox.ts",
    "content": "/**\n * UI 2018 Checkboxes\n *\n * Includes:\n *  - squareCheckbox\n *  - circleCheckbox\n *  - labeledSquareCheckbox\n *  - labeledCircleCheckbox\n *\n * Checkboxes support passing in DomElementArgs, which can be used to register click handlers, set\n * the disabled property, and other HTML <input> element behaviors.\n *\n * Examples:\n *  squareCheckbox(observable(true)),\n *  labeledSquareCheckbox(observable(false), 'Include other values', dom.prop('disabled', true)),\n */\n\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\n\nimport { Computed, dom, DomArg, DomContents, Observable, styled } from \"grainjs\";\n\nexport const cssLabel = styled(\"label\", `\n  position: relative;\n  display: inline-flex;\n  min-width: 0px;\n  margin-bottom: 0px;\n  flex-shrink: 0;\n\n  outline: none;\n  user-select: none;\n\n  --color: ${theme.checkboxBorder};\n  &:hover {\n    --color: ${theme.checkboxBorderHover};\n  }\n`);\n\n// TODO: the !important markings are to trump bootstrap, and should be removed when it's gone.\nexport const cssCheckboxSquare = styled(\"input\", `\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  margin: 0 !important;\n  padding: 0;\n\n  flex-shrink: 0;\n\n  display: inline-block;\n  width: 16px;\n  height: 16px;\n  outline: none !important;\n  outline-offset: 2px;\n\n  --radius: 3px;\n\n  &:checked:enabled, &:indeterminate:enabled {\n    --color: ${theme.controlPrimaryBg};\n  }\n\n  &:disabled {\n    --color: ${theme.checkboxDisabledBg};\n    cursor: not-allowed;\n  }\n\n  &::before, &::after {\n    content: '';\n\n    position: absolute;\n    top: 0;\n    left: 0;\n\n    height: 16px;\n    width: 16px;\n\n    box-sizing: border-box;\n    border: 1px solid var(--color);\n    border-radius: var(--radius);\n  }\n\n  &:checked::before, &:disabled::before, &:indeterminate::before {\n    background-color: var(--color);\n  }\n\n  &:not(:checked):indeterminate::after {\n    -webkit-mask-image: var(--icon-Minus);\n  }\n\n  &:not(:disabled)::after {\n    background-color: ${theme.checkboxBg};\n  }\n\n  &:checked::after, &:indeterminate::after {\n    content: '';\n    position: absolute;\n    height: 16px;\n    width: 16px;\n    -webkit-mask-image: var(--icon-Tick);\n    -webkit-mask-size: contain;\n    -webkit-mask-position: center;\n    -webkit-mask-repeat: no-repeat;\n    background-color: ${theme.controlPrimaryFg};\n  }\n`);\n\nexport const cssCheckboxCircle = styled(cssCheckboxSquare, `\n  --radius: 100%;\n`);\n\nexport const cssLabelText = styled(\"span\", `\n  margin-left: 8px;\n  color: ${theme.text};\n  font-weight: initial;   /* negate bootstrap */\n  overflow: hidden;\n  text-overflow: ellipsis;\n  line-height: 16px;\n`);\n\ntype CheckboxArg = DomArg<HTMLInputElement>;\n\nfunction checkbox(\n  obs: Observable<boolean>, cssCheckbox: typeof cssCheckboxSquare,\n  label: DomArg, right: boolean,  ...domArgs: CheckboxArg[]\n) {\n  const field = cssCheckbox(\n    { type: \"checkbox\" },\n    dom.prop(\"checked\", obs),\n    dom.on(\"change\", (ev, el) => obs.set(el.checked)),\n    ...domArgs,\n  );\n  const text = label ? cssLabelText(label) : null;\n  if (right) {\n    return cssReversedLabel([text, cssInlineRelative(field)]);\n  }\n  return cssLabel(field, text);\n}\n\nexport function squareCheckbox(obs: Observable<boolean>, ...domArgs: CheckboxArg[]) {\n  return checkbox(obs, cssCheckboxSquare, \"\", false, ...domArgs);\n}\n\nexport function circleCheckbox(obs: Observable<boolean>, ...domArgs: CheckboxArg[]) {\n  return checkbox(obs, cssCheckboxCircle, \"\", false, ...domArgs);\n}\n\nexport function labeledSquareCheckbox(obs: Observable<boolean>, label: DomArg, ...domArgs: CheckboxArg[]) {\n  return checkbox(obs, cssCheckboxSquare, label, false, ...domArgs);\n}\n\nexport function labeledLeftSquareCheckbox(obs: Observable<boolean>, label: DomArg, ...domArgs: CheckboxArg[]) {\n  return checkbox(obs, cssCheckboxSquare, label, true, ...domArgs);\n}\n\nexport function labeledCircleCheckbox(obs: Observable<boolean>, label: DomArg, ...domArgs: CheckboxArg[]) {\n  return checkbox(obs, cssCheckboxCircle, label, false, ...domArgs);\n}\n\nexport const Indeterminate = \"indeterminate\";\nexport type TriState = boolean | \"indeterminate\";\n\nfunction triStateCheckbox(\n  obs: Observable<TriState>, cssCheckbox: typeof cssCheckboxSquare, label: string = \"\",  ...domArgs: CheckboxArg[]\n) {\n  const checkboxObs = Computed.create(null, obs, (_use, state) => state === true)\n    .onWrite(checked => obs.set(checked));\n  return checkbox(\n    checkboxObs, cssCheckbox, label, false,\n    dom.prop(\"indeterminate\", use => use(obs) === \"indeterminate\"),\n    dom.autoDispose(checkboxObs),\n    ...domArgs,\n  );\n}\n\nexport function triStateSquareCheckbox(obs: Observable<TriState>, ...domArgs: CheckboxArg[]) {\n  return triStateCheckbox(obs, cssCheckboxSquare, \"\", ...domArgs);\n}\n\nexport function labeledTriStateSquareCheckbox(obs: Observable<TriState>, label: string, ...domArgs: CheckboxArg[]) {\n  return triStateCheckbox(obs, cssCheckboxSquare, label, ...domArgs);\n}\n\nexport function radioCheckboxOption<T>(selectedObservable: Observable<T>, optionId: T, content: DomContents) {\n  const selected = Computed.create(null, use => use(selectedObservable) === optionId)\n    .onWrite(val => val ? selectedObservable.set(optionId) : void 0);\n  return dom.update(\n    labeledCircleCheckbox(selected, content, dom.autoDispose(selected)),\n    testId(`option-${optionId}`),\n    cssBlockCheckbox.cls(\"\"),\n    cssBlockCheckbox.cls(\"-block\", selected),\n  );\n}\n\nexport const cssRadioCheckboxOptions = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n`);\n\n// We need to reset top and left of ::before element, as it is wrongly set\n// on the inline checkbox.\n// To simulate radio button behavior, we will block user input after option is selected, because\n// checkbox doesn't support two-way binding.\nconst cssBlockCheckbox = styled(\"div\", `\n  display: flex;\n  padding: 10px 8px;\n  border: 1px solid ${theme.controlSecondaryDisabledFg};\n  border-radius: 3px;\n  cursor: pointer;\n  & input::before, & input::after  {\n    top: unset;\n    left: unset;\n  }\n  &:hover {\n    border-color: ${theme.controlFg};\n  }\n  &-block {\n    pointer-events: none;\n  }\n  &-block a {\n    pointer-events: all;\n  }\n`);\n\nconst cssInlineRelative = styled(\"div\", `\n  display: inline-block;\n  position: relative;\n  height: 16px;\n`);\n\nconst cssReversedLabel = styled(cssLabel, `\n  justify-content: space-between;\n  gap: 8px;\n  & .${cssLabelText.className} {\n    margin: 0px;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/cssVars.ts",
    "content": "/**\n * CSS Variables.\n *\n * This file has two roles:\n *  - appending theme-agnostic CSS variables and global CSS rules to the DOM with `attachCssRootVars`.\n *  - (deprecated) exposing the design tokens to the rest of the codebase.\n *    Prefer using the {@link tokens} object directly.\n *\n * Note that theme-related CSS variables (like colors and font families) are added to the DOM\n * with `theme.ts#attachCssThemeVars` and are required to be attached, as most theme-agnostic\n * CSS variables described below consume those variables.\n */\nimport { getOrCreateStyleElement } from \"app/client/lib/getOrCreateStyleElement\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { getTheme, ProductFlavor } from \"app/client/ui/CustomThemes\";\nimport { CssCustomProp as CustomProp } from \"app/common/CssCustomProp\";\nimport { components, tokens } from \"app/common/ThemePrefs\";\n\nimport { DomElementMethod, makeTestId, Observable, styled, TestId } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\nimport values from \"lodash/values\";\n\n/**\n * @deprecated Consume the {@link tokens} object directly\n *\n * Before having most cssVars theme-related (listed in `tokens`), we had a list of colors\n * defined here, consumed by the whole codebase.\n *\n * This is kept for now:\n * - to prevent having to update the whole codebase to target the new `tokens` structure for now,\n * - to make old css variables still work in case people used them in plugins or custom css.\n */\nexport const colors = {\n  lightGrey: new CustomProp(\"color-light-grey\", tokens.bgSecondary),\n  mediumGrey: new CustomProp(\"color-medium-grey\", tokens.bgTertiary),\n  mediumGreyOpaque: new CustomProp(\"color-medium-grey-opaque\", tokens.decorationSecondary),\n  darkGrey: new CustomProp(\"color-dark-grey\", tokens.decoration),\n\n  light: new CustomProp(\"color-light\", tokens.white),\n  dark: new CustomProp(\"color-dark\", tokens.body),\n  darkText: new CustomProp(\"color-dark-text\", \"#494949\"),\n  darkBg: new CustomProp(\"color-dark-bg\", tokens.bgEmphasis),\n  slate: new CustomProp(\"color-slate\", tokens.secondary),\n\n  lighterGreen: new CustomProp(\"color-lighter-green\", tokens.primaryEmphasis),\n  lightGreen: new CustomProp(\"color-light-green\", tokens.primary),\n  darkGreen: new CustomProp(\"color-dark-green\", tokens.primaryMuted),\n  darkerGreen: new CustomProp(\"color-darker-green\", tokens.primaryDim),\n\n  lighterBlue: new CustomProp(\"color-lighter-blue\", tokens.infoLight),\n  lightBlue: new CustomProp(\"color-light-blue\", tokens.info),\n  orange: new CustomProp(\"color-orange\", tokens.warningLight),\n\n  cursor: new CustomProp(\"color-cursor\", tokens.cursor),\n  selection: new CustomProp(\"color-selection\", tokens.selection),\n  selectionOpaque: new CustomProp(\"color-selection-opaque\", tokens.selectionOpaque),\n  selectionDarkerOpaque: new CustomProp(\"color-selection-darker-opaque\", tokens.selectionDarkerOpaque),\n\n  inactiveCursor: new CustomProp(\"color-inactive-cursor\", tokens.cursorInactive),\n\n  hover: new CustomProp(\"color-hover\", tokens.hover),\n  error: new CustomProp(\"color-error\", tokens.error),\n  warning: new CustomProp(\"color-warning\", tokens.warningLight),\n  warningBg: new CustomProp(\"color-warning-bg\", tokens.warning),\n  backdrop: new CustomProp(\"color-backdrop\", tokens.backdrop),\n};\n\n/**\n * zIndexes are listed on their own and can't be modified by themes.\n */\nexport const zIndexes = {\n  stickyHeaderZIndex: new CustomProp(\"sticky-header-z-index\", \"20\"),\n  insertColumnLineZIndex: new CustomProp(\"insert-column-line-z-index\", \"20\"),\n  emojiPickerZIndex: new CustomProp(\"modal-z-index\", \"20\"),\n  newRecordButtonZIndex: new CustomProp(\"new-record-button-z-index\", \"30\"),\n  popupSectionBackdropZIndex: new CustomProp(\"popup-section-backdrop-z-index\", \"100\"),\n  menuZIndex: new CustomProp(\"menu-z-index\", \"999\"),\n  modalZIndex: new CustomProp(\"modal-z-index\", \"999\"),\n  onboardingBackdropZIndex: new CustomProp(\"onboarding-backdrop-z-index\", \"999\"),\n  onboardingPopupZIndex: new CustomProp(\"onboarding-popup-z-index\", \"1000\"),\n  floatingPopupZIndex: new CustomProp(\"floating-popup-z-index\", \"1002\"),\n  tutorialModalZIndex: new CustomProp(\"tutorial-modal-z-index\", \"1003\"),\n  pricingModalZIndex: new CustomProp(\"pricing-modal-z-index\", \"1004\"),\n  floatingPopupMenuZIndex: new CustomProp(\"floating-popup-menu-z-index\", \"1004\"),\n  notificationZIndex: new CustomProp(\"notification-z-index\", \"1100\"),\n  browserCheckZIndex: new CustomProp(\"browser-check-z-index\", \"5000\"),\n  tooltipZIndex: new CustomProp(\"tooltip-z-index\", \"5000\"),\n  // TODO: Add properties for remaining hard-coded z-indexes.\n};\n\n/**\n * @deprecated Consume the {@link tokens} and {@link zIndexes} object directly.\n *\n * Before having most cssVars theme-related (listed in `tokens`), we had a list of various\n * design tokens here, consumed by the whole codebase.\n *\n * Some tokens were not migrated to `tokens` as they are not used in this codebase, but were\n * kept in case they are used by plugins or custom css.\n *\n * This is kept for now:\n * - to prevent having to update the whole codebase to target the new `tokens` structure for now,\n * - to make old css variables still work in case people used them in plugins or custom css.\n */\nexport const vars = {\n  fontFamily: new CustomProp(\"font-family\", tokens.fontFamily),\n  fontFamilyData: new CustomProp(\"font-family-data\", tokens.fontFamilyData),\n\n  xxsmallFontSize: new CustomProp(\"xx-font-size\", tokens.xxsmallFontSize),\n  xsmallFontSize: new CustomProp(\"x-small-font-size\", tokens.xsmallFontSize),\n  smallFontSize: new CustomProp(\"small-font-size\", tokens.smallFontSize),\n  mediumFontSize: new CustomProp(\"medium-font-size\", tokens.mediumFontSize),\n  introFontSize: new CustomProp(\"intro-font-size\", tokens.introFontSize),\n  largeFontSize: new CustomProp(\"large-font-size\", tokens.largeFontSize),\n  xlargeFontSize: new CustomProp(\"x-large-font-size\", tokens.xlargeFontSize),\n  xxlargeFontSize: new CustomProp(\"xx-large-font-size\", tokens.xxlargeFontSize),\n  xxxlargeFontSize: new CustomProp(\"xxx-large-font-size\", tokens.xxxlargeFontSize),\n\n  controlFontSize: new CustomProp(\"control-font-size\", \"12px\"),\n  smallControlFontSize: new CustomProp(\"small-control-font-size\", \"10px\"),\n  bigControlFontSize: new CustomProp(\"big-control-font-size\", tokens.bigControlFontSize),\n  headerControlFontSize: new CustomProp(\"header-control-font-size\", tokens.headerControlFontSize),\n  bigControlTextWeight: new CustomProp(\"big-text-weight\", tokens.bigControlTextWeight),\n  headerControlTextWeight: new CustomProp(\"header-text-weight\", tokens.headerControlTextWeight),\n\n  labelTextSize: new CustomProp(\"label-text-size\", \"medium\"),\n  labelTextBg: new CustomProp(\"label-text-bg\", \"#ffffff\"),\n  labelActiveBg: new CustomProp(\"label-active-bg\", \"#f0f0f0\"),\n\n  controlMargin: new CustomProp(\"normal-margin\", \"2px\"),\n  controlPadding: new CustomProp(\"normal-padding\", \"3px 5px\"),\n  tightPadding: new CustomProp(\"tight-padding\", \"1px 2px\"),\n  loosePadding: new CustomProp(\"loose-padding\", \"5px 15px\"),\n\n  primaryBg: new CustomProp(\"primary-fg\", tokens.primary),\n  primaryBgHover: new CustomProp(\"primary-fg-hover\", tokens.primaryMuted),\n  primaryFg: new CustomProp(\"primary-bg\", tokens.white),\n\n  controlBg: new CustomProp(\"control-bg\", tokens.white),\n  controlFg: new CustomProp(\"control-fg\", tokens.primary),\n  controlFgHover: new CustomProp(\"primary-fg-hover\", tokens.primaryMuted),\n\n  controlBorder: new CustomProp(\"control-border\", components.controlBorder),\n  controlBorderRadius: new CustomProp(\"border-radius\", tokens.controlBorderRadius),\n\n  logoBg: new CustomProp(\"logo-bg\", tokens.logoBg),\n  logoSize: new CustomProp(\"logo-size\", tokens.logoSize),\n  toastBg: new CustomProp(\"toast-bg\", \"#040404\"),\n\n  ...zIndexes,\n};\n\n/**\n * @deprecated Consume and update the {@link components} object directly\n * when handling component-specific theme variables.\n *\n * This is here only to keep the old export and prevent having to update the whole codebase\n * to target the new `components` structure.\n */\nexport const theme = {\n  ...components,\n};\n\nconst cssColors = values(colors).map(v => v.decl()).join(\"\\n\");\nconst cssVars = values(vars).map(v => v.decl()).join(\"\\n\");\n\n// We set box-sizing globally to match bootstrap's setting of border-box, since we are integrating\n// into an app which already has it set, and it's impossible to make things look consistently with\n// AND without it. This duplicates bootstrap's setting.\nconst cssBorderBox = `\n  *, *:before, *:after {\n  -webkit-box-sizing: border-box;\n     -moz-box-sizing: border-box;\n          box-sizing: border-box;\n  }\n`;\n\n// These styles duplicate bootstrap's global settings, which we rely on even on pages that don't\n// have bootstrap.\nconst cssInputFonts = `\n  button, input, select, textarea {\n    font-family: inherit;\n    font-size: inherit;\n    line-height: inherit;\n  }\n`;\n\n// Font style classes used by style selector.\nconst cssFontStyles = `\n  .font-italic {\n    font-style: italic;\n  }\n  .font-bold {\n    font-weight: 800;\n  }\n  .font-underline {\n    text-decoration: underline;\n  }\n  .font-strikethrough {\n    text-decoration: line-through;\n  }\n  .font-strikethrough.font-underline {\n    text-decoration: line-through underline;\n  }\n`;\n\nconst cssRootVars = cssColors + cssVars;\nconst cssReset = cssBorderBox + cssInputFonts + cssFontStyles;\n\nconst cssBody = styled(\"body\", `\n  margin: 0;\n  height: 100%;\n`);\n\nconst cssRoot = styled(\"html\", `\n  height: 100%;\n  overflow: hidden;\n  font-family: ${vars.fontFamily};\n  font-size: ${vars.mediumFontSize};\n  -moz-osx-font-smoothing: grayscale;\n  -webkit-font-smoothing: antialiased;\n`);\n\n// Also make a globally available testId, with a simple \"test-\" prefix (i.e. in tests, query css\n// class \".test-{name}\". Ideally, we'd use noTestId() instead in production.\nexport const testId: TestId = makeTestId(\"test-\");\n\n// Min width for normal screen layout (in px). Note: <768px is bootstrap's definition of small\n// screen (covers phones, including landscape, but not tablets).\nconst largeScreenWidth = 992;\nconst mediumScreenWidth = 768;\nconst smallScreenWidth = 576;   // Anything below this is extra-small (e.g. portrait phones).\n\n// Fractional width for max-query follows https://getbootstrap.com/docs/4.0/layout/overview/#responsive-breakpoints\nexport const mediaMedium = `(max-width: ${largeScreenWidth - 0.02}px)`;\nexport const mediaSmall = `(max-width: ${mediumScreenWidth - 0.02}px)`;\nexport const mediaNotSmall = `(min-width: ${mediumScreenWidth}px)`;\nexport const mediaXSmall = `(max-width: ${smallScreenWidth - 0.02}px)`;\n\nexport const mediaDeviceNotSmall = `(min-device-width: ${mediumScreenWidth}px)`;\n\nexport function isNarrowScreen() {\n  return window.innerWidth < mediumScreenWidth;\n}\n\nlet _isNarrowScreenObs: Observable<boolean> | undefined;\n\n// Returns a singleton observable for whether the screen is a small one.\nexport function isNarrowScreenObs(): Observable<boolean> {\n  if (!_isNarrowScreenObs) {\n    const obs = Observable.create<boolean>(null, isNarrowScreen());\n    window.addEventListener(\"resize\", () => obs.set(isNarrowScreen()));\n    _isNarrowScreenObs = obs;\n  }\n  return _isNarrowScreenObs;\n}\n\nexport function isXSmallScreen() {\n  return window.innerWidth < smallScreenWidth;\n}\n\nlet _isXSmallScreenObs: Observable<boolean> | undefined;\n\n// Returns a singleton observable for whether the screen is an extra small one.\nexport function isXSmallScreenObs(): Observable<boolean> {\n  if (!_isXSmallScreenObs) {\n    const obs = Observable.create<boolean>(null, isXSmallScreen());\n    window.addEventListener(\"resize\", () => obs.set(isXSmallScreen()));\n    _isXSmallScreenObs = obs;\n  }\n  return _isXSmallScreenObs;\n}\n\nexport const cssHideForNarrowScreen = styled(\"div\", `\n  @media ${mediaSmall} {\n    & {\n      display: none !important;\n    }\n  }\n`);\n\nlet _isScreenResizingObs: Observable<boolean> | undefined;\n\n// Returns a singleton observable for whether user is currently resizing the window. (listen to\n// `resize` events and uses a timer of 1000ms).\nexport function isScreenResizing(): Observable<boolean> {\n  if (!_isScreenResizingObs) {\n    const obs = Observable.create<boolean>(null, false);\n    const ping = debounce(() => obs.set(false), 1000);\n    window.addEventListener(\"resize\", () => { obs.set(true); ping(); });\n    _isScreenResizingObs = obs;\n  }\n  return _isScreenResizingObs;\n}\n\n/**\n * Attaches the global css properties to the document's root to make them available in the page.\n */\nexport function attachCssRootVars(productFlavor: ProductFlavor, varsOnly: boolean = false) {\n  /* Initiate the grist-layers before any other css to make sure it's understood by all styles,\n   * and apply all base grist rules and styles in the grist-base layer. */\n  getOrCreateStyleElement(\"grist-root-css\", {\n    position: \"beforebegin\",\n    element: document.head.querySelector('style, link[rel=\"stylesheet\"]'),\n  }).textContent = `\n@layer grist-base, grist-theme, grist-custom;\n@layer grist-base {\n  :root {\n    ${cssRootVars}\n  }\n  ${!varsOnly && cssReset}\n}`;\n\n  document.documentElement.classList.add(cssRoot.className);\n  document.body.classList.add(cssBody.className);\n  const customTheme = getTheme(productFlavor);\n  if (customTheme.bodyClassName) {\n    document.body.classList.add(customTheme.bodyClassName);\n  }\n  const interfaceStyle = urlState().state.get().params?.style || \"full\";\n  document.body.classList.add(`interface-${interfaceStyle}`);\n}\n\n// A dom method to hide element in print view\nexport function hideInPrintView(): DomElementMethod {\n  return cssHideInPrint.cls(\"\");\n}\n\nconst cssHideInPrint = styled(\"div\", `\n  @media print {\n    & {\n      display: none !important;\n    }\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/draggableList.ts",
    "content": "import { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\n\nimport { styled } from \"grainjs\";\n\n// TODO: Update and move koForm draggableList here.\n\n// Drag icon for use in koForm draggableList.\nexport const cssDragger = styled((...args: any[]) => icon(\"DragDrop\", testId(\"dragger\"), ...args), `\n  --icon-color: ${theme.controlSecondaryFg};\n  visibility: hidden;\n  align-self: center;\n  flex-shrink: 0;\n  .kf_draggable:hover & {\n    visibility: visible;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/editableLabel.ts",
    "content": "/**\n * editableLabel uses grainjs's input widget and adds UI and behavioral extensions:\n *   - Label width grows/shrinks with content (using a hidden sizer element)\n *   - On Escape, cancel editing and revert to original value\n *   - Clicking away or hitting Enter on empty value cancels editing too\n *\n * The structure is a wrapper diver with an input child: div > input. Supports passing in\n * DomElementArgs, which get passed to the underlying <input> element.\n *\n * TODO: Consider merging this into grainjs's input widget.\n */\nimport { theme } from \"app/client/ui2018/cssVars\";\n\nimport { dom, DomArg, styled } from \"grainjs\";\nimport { Observable } from \"grainjs\";\nimport noop from \"lodash/noop\";\n\nconst cssWrapper = styled(\"div\", `\n  position: relative;\n  display: inline-block;\n`);\n\nexport const cssLabelText = styled(rawTextInput, `\n  /* Reset appearance */\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  padding: 0;\n  margin: 0;\n  border: none;\n  outline: none;\n\n  /* Size is determined by the hidden sizer, so take up 100% of width */\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n\n  line-height: inherit;\n  font-size: inherit;\n  font-family: inherit;\n  font-weight: inherit;\n  background-color: inherit;\n  color: inherit;\n`);\n\nexport const cssTextInput = styled(\"input\", `\n  outline: none;\n  height: 28px;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  padding: 0 6px;\n`);\n\nconst cssSizer = styled(\"div\", `\n  visibility: hidden;\n  overflow: visible;\n  white-space: pre;\n\n  &:empty:before {\n    content: ' ';  /* Don't collapse */\n  }\n`);\n\nenum Status { NORMAL, EDITING, SAVING }\n\ntype SaveFunc = (value: string) => void | PromiseLike<void>;\n\nexport interface EditableLabelOptions {\n  save: SaveFunc;\n  args?: DomArg<HTMLDivElement>[];\n  inputArgs?: DomArg<HTMLInputElement>[];\n}\n\n/**\n * Provides a label that takes in an observable that is set on Enter or loss of focus. Escape\n * cancels editing. Label grows in size with typed input. Validation logic (if any) should happen in\n * the save function, to reject a value simply throw an error, this will revert to the saved one .\n */\nexport function editableLabel(label: Observable<string>, options: EditableLabelOptions) {\n  const { save, args, inputArgs } = options;\n\n  let input: HTMLInputElement;\n  let sizer: HTMLSpanElement;\n\n  function updateSizer() {\n    sizer.textContent = input.value;\n  }\n\n  return cssWrapper(\n    sizer = cssSizer(label.get()),\n    input = rawTextInput(label, save, updateSizer, dom.cls(cssLabelText.className),\n      dom.on(\"focus\", () => input.select()),\n      ...inputArgs ?? [],\n    ),\n    ...args ?? [],\n  );\n}\n\n/**\n * Provides a text input element that pretty much behaves like the editableLabel only it shows as a\n * regular input within a rigid static frame. It takes in an observable that is set on Enter or loss\n * of focus. Escape cancels editing. Validation logic (if any) should happen in the save function,\n * to reject a value simply throw an error, this will revert to the the saved one.\n */\nexport function textInput(label: Observable<string>, save: SaveFunc, ...args: DomArg<HTMLInputElement>[]) {\n  return rawTextInput(label, save, noop, dom.cls(cssTextInput.className), ...args);\n}\n\n/**\n * A helper that implements all the saving logic for both editableLabel and textInput.\n */\nexport function rawTextInput(value: Observable<string>, save: SaveFunc, onChange: () => void,\n  ...args: DomArg<HTMLInputElement>[]) {\n  let status: Status = Status.NORMAL;\n  let inputEl: HTMLInputElement;\n\n  // When label changes updates the input, unless in the middle of editing.\n  const lis = value.addListener((val) => { if (status !== Status.EDITING) { setValue(val); } });\n\n  function setValue(val: string) {\n    inputEl.value = val;\n    onChange();\n  }\n\n  function revertToSaved() {\n    setValue(value.get());\n    status = Status.NORMAL;\n    inputEl.blur();\n  }\n\n  async function saveEdit() {\n    if (status === Status.EDITING) {\n      status = Status.SAVING;\n      inputEl.disabled = true;\n      // Ignore errors; save() callback is expected to handle their reporting.\n      try { await save(inputEl.value); } catch (e) { /* ignore */ }\n      inputEl.disabled = false;\n      revertToSaved();\n    } else if (status === Status.NORMAL) {\n      // If we are not editing, nothing to save, but lets end in the expected blurred state.\n      inputEl.blur();\n    }\n  }\n\n  return inputEl = dom(\"input\",\n    dom.autoDispose(lis),\n    { type: \"text\" },\n    dom.on(\"input\", () => { status = Status.EDITING; onChange(); }),\n    dom.on(\"blur\", saveEdit),\n    // we set the attribute to the initial value and keep it updated for the convenience of usage\n    // with selenium webdriver\n    dom.attr(\"value\", value),\n    dom.onKeyDown({\n      Escape: revertToSaved,\n      Enter: saveEdit,\n    }),\n    ...args,\n  );\n}\n"
  },
  {
    "path": "app/client/ui2018/icons.ts",
    "content": "/**\n * Exports a single `icon` function which returns a DOM Element for the given icon name.\n * Names are of type `IconName` imported from `IconList.ts`, which is auto-generated during the\n * build process.\n *\n * In order to use the icons, you must first include the generated CSS file:\n *\n *   <link rel=\"stylesheet\" href=\"icons.css\">\n *\n * The CSS file encodes each icon as a `url()` with base64 DataURI of the icon SVG and saves it\n * as a CSS :root var of the form --icon-${name}. It also includes a class for each icon that\n * uses the variable to set the mask-image (vs background-image, allowing you to change colors\n * using background-color):\n *\n *   .icon.Search_icon { -webkit-mask-image: var(--icon-Search); }\n *\n * This approach is more performant than inlining SVGs or using <symbol> with <use>.\n *\n * Examples:\n *\n *   // Display icon with default color and size\n *   dom('div',\n *     icon('Search')\n *   );\n *\n *   // Display bigger icon in blue\n *   const bigBlueIcon = styled(icon, `\n *     background-color: blue;\n *     width: 32px;\n *     height: 32px;\n *   `);\n *   dom('div',\n *     bigBlueIcon('Search')\n *   )\n *\n *   // Use icon image directly in css to style a checkbox\n *   // Refer to https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Advanced_styling_for_HTML_forms\n *   const checkbox = styled('input#checkbox', `\n *     -webkit-appearance: none;\n *     -moz-appearance: none;\n *      width: 1rem;\n *      height: 1rem;\n *      border: 1px solid blue;\n *\n *      &:checked::before {\n *        position: absolute;\n *        content: var(--icon-Select);\n *      }\n *   `);\n */\n\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\n\nimport { dom, DomElementArg, styled } from \"grainjs\";\n\n/**\n * Defaults for all icons.\n */\nconst iconStyles = `\n  position: relative;\n  display: inline-block;\n  vertical-align: middle;\n  -webkit-mask-repeat: no-repeat;\n  -webkit-mask-position: center;\n  -webkit-mask-size: contain;\n  width: 16px;\n  height: 16px;\n  background-color: var(--icon-color, var(--grist-theme-text, black));\n`;\n\nconst iconColorStyles = `\n  position: relative;\n  display: inline-block;\n  vertical-align: middle;\n  width: 16px;\n  height: 16px;\n  background-repeat: no-repeat;\n  background-position: center;\n  background-size: contain;\n`;\n\nconst cssIconDiv = styled(\"div\", iconStyles);\n\nconst cssIconSpan = styled(\"span\", iconStyles);\n\nconst cssColorIcon = styled(\"div\", iconColorStyles);\n\nexport function icon(name: IconName, ...domArgs: DomElementArg[]): HTMLElement {\n  return cssIconDiv(\n    dom.style(\"-webkit-mask-image\", `var(--icon-${name})`),\n    ...domArgs,\n  );\n}\n\nexport function iconSpan(name: IconName, ...domArgs: DomElementArg[]): HTMLElement {\n  return cssIconSpan(\n    dom.style(\"-webkit-mask-image\", `var(--icon-${name})`),\n    ...domArgs,\n  );\n}\n\nexport function colorIcon(name: IconName, ...domArgs: DomElementArg[]): HTMLElement {\n  return cssColorIcon(\n    dom.style(\"background-image\", `var(--icon-${name})`),\n    ...domArgs,\n  );\n}\n\nexport const cssIconSpanBackground = styled(cssIconSpan, `\n  background-color: var(--icon-background, inherit);\n  -webkit-mask: none;\n  & .${cssIconSpan.className} {\n    transition: inherit;\n    display: block;\n  }\n`);\n\n/**\n * Container box for an icon to serve as a button..\n */\nexport const cssIconButton = styled(unstyledButton, `\n  flex: none;\n  height: 24px;\n  width: 24px;\n  padding: 4px;\n  border-radius: 3px;\n  line-height: 0px;\n  cursor: default;\n  outline-offset: -2px;\n  --icon-color: ${theme.controlSecondaryFg};\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.controlSecondaryHoverBg};\n    --icon-color: ${theme.controlSecondaryFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/links.ts",
    "content": "import { findLinks } from \"app/client/lib/textUtils\";\nimport { sameDocumentUrlState, urlState } from \"app/client/models/gristUrlState\";\nimport { hideInPrintView, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { cssIconSpanBackground, iconSpan } from \"app/client/ui2018/icons\";\nimport { useBindable } from \"app/common/gutil\";\n\nimport { BindableValue, dom, DomArg, IDomArgs, styled } from \"grainjs\";\n\n/**\n * Styling for a simple <A HREF> link.\n */\n\nconst linkStyles = `\n  color: ${theme.link};\n  --icon-color: ${theme.link};\n  text-decoration: none;\n`;\n\nconst linkHoverStyles = `\n  color: ${theme.linkHover};\n  --icon-color: ${theme.linkHover};\n  text-decoration: underline;\n`;\n\nexport const cssLink = styled(\"a\", `\n  ${linkStyles}\n  &:hover, &:focus {\n    ${linkHoverStyles}\n  }\n`);\n\n/**\n * This helps us apply link styles when we can't directly use `cssLink`,\n * for example when styling generated markdown.\n */\nexport const cssNestedLinks = styled(\"div\", `\n  & a {\n    ${linkStyles}\n  }\n  & a:hover, & a:focus {\n    ${linkHoverStyles}\n  }\n`);\n\nexport function gristLink(href: BindableValue<string>, ...args: IDomArgs<HTMLElement>) {\n  return dom(\"a\",\n    dom.attr(\"href\", use => withAclAsUserParam(useBindable(use, href))),\n    dom.attr(\"target\", \"_blank\"),\n    dom.on(\"click\", handleGristLinkClick),\n    // stop propagation to prevent the grist custom context menu to show up and let the default one\n    // to show up instead.\n    dom.on(\"contextmenu\", ev => ev.stopPropagation()),\n    // As per Google and Mozilla recommendations to prevent opened links\n    // from running on the same process as Grist:\n    // https://developers.google.com/web/tools/lighthouse/audits/noopener\n    dom.attr(\"rel\", \"noopener noreferrer\"),\n    hideInPrintView(),\n    args,\n  );\n}\n\nexport function gristIconLink(href: string, label = href) {\n  return cssMaybeWrap(\n    gristLink(href,\n      cssIconSpanBackground(\n        iconSpan(\"FieldLink\", testId(\"tb-link-icon\")),\n        dom.cls(cssHoverInText.className),\n      ),\n    ),\n    linkColor(label),\n    testId(\"text-link\"),\n  );\n}\n\n/**\n * If possible (i.e. if `url` points to somewhere in the current document)\n * use pushUrl to navigate without reloading or opening a new tab\n */\nexport function handleGristLinkClick(ev: MouseEvent, elem: HTMLAnchorElement) {\n  // Only override plain-vanilla clicks.\n  if (ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) { return; }\n\n  const newUrlState = sameDocumentUrlState(elem.href);\n  if (!newUrlState) { return; }\n\n  ev.preventDefault();\n  urlState().pushUrl(newUrlState).catch(reportError);\n}\n\n/**\n * Generates dom contents out of a text with clickable links.\n */\nexport function makeLinks(text: string) {\n  try {\n    const domElements: DomArg[] = [];\n    for (const { value, isLink } of findLinks(text)) {\n      if (isLink) {\n        domElements.push(gristIconLink(value));\n      } else {\n        domElements.push(value);\n      }\n    }\n    return domElements;\n  } catch (ex) {\n    // In case when something went wrong, simply log and return original text, as showing\n    // links is not that important.\n    console.warn(\"makeLinks failed\", ex);\n    return text;\n  }\n}\n\n/**\n * Returns a modified version of `href` with the value of `aclAsUser_` from\n * the current URL, if present.\n *\n * Only works for same document URLs (i.e. URLs that can be navigated to\n * without reloading the page).\n */\nfunction withAclAsUserParam(href: string) {\n  const state = urlState().state.get();\n  const aclAsUser = state.params?.linkParameters?.aclAsUser;\n  if (!aclAsUser) {\n    return href;\n  }\n\n  let hrefWithParams: string;\n  try {\n    const url = new URL(href);\n    if (!url.searchParams.has(\"aclAsUser_\")) {\n      url.searchParams.set(\"aclAsUser_\", aclAsUser);\n    }\n    hrefWithParams = url.href;\n  } catch {\n    return href;\n  }\n\n  return sameDocumentUrlState(hrefWithParams) ? hrefWithParams : href;\n}\n\n// For links we want to break all the parts, not only words.\nconst cssMaybeWrap = styled(\"span\", `\n  white-space: inherit;\n  .text_wrapping & {\n    word-break: break-all;\n    white-space: pre-wrap;\n  }\n`);\n\n// A gentle transition effect on hover in, and the same effect on hover out with a little delay.\nexport const cssHoverIn = (parentClass: string) => styled(\"span\", `\n  --icon-color: var(--grist-actual-cell-color, ${theme.link});\n  margin: -1px 2px 2px 0;\n  border-radius: 3px;\n  transition-property: background-color;\n  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\n  transition-duration: 150ms;\n  transition-delay: 90ms;\n  .${parentClass}:hover & {\n    --icon-background: ${theme.link};\n    --icon-color: white;\n    transition-duration: 80ms;\n    transition-delay: 0ms;\n  }\n`);\n\nconst cssHoverInText = cssHoverIn(cssMaybeWrap.className);\n\nconst linkColor = styled(\"span\", `\n  color: var(--grist-actual-cell-color, ${theme.link});\n`);\n"
  },
  {
    "path": "app/client/ui2018/loaders.ts",
    "content": "import { theme } from \"app/client/ui2018/cssVars\";\n\nimport { DomArg, keyframes, Observable, observable, styled } from \"grainjs\";\n\nconst rotate360 = keyframes(`\n  from { transform: rotate(45deg); }\n  75% { transform: rotate(405deg); }\n  to { transform: rotate(405deg); }\n`);\n\nconst flash = keyframes(`\n  0% {\n    background-color: ${theme.loaderFg};\n  }\n  50%, 100% {\n    background-color: ${theme.loaderBg};\n  }\n`);\n\n/**\n * Creates a 32x32 pixel loading spinner. Use by calling `loadingSpinner()`.\n */\nexport const loadingSpinner = styled(\"div\", `\n  --loader-fg: ${theme.loaderFg};\n  --loader-bg: ${theme.loaderBg};\n  display: inline-block;\n  box-sizing: border-box;\n  width: 32px;\n  height: 32px;\n  border-radius: 32px;\n  border: 4px solid var(--loader-bg);\n  border-top-color: var(--loader-fg);\n  animation: ${rotate360} 1s ease-out infinite;\n  &-inline {\n    width: 1em;\n    height: 1em;\n    line-height: inherit;\n    border-radius: 50%;\n    border-width: 1px;\n  }\n`);\n\n/**\n * Creates a three-dots loading animation. Use by calling `loadingDots()`.\n */\nexport function loadingDots(...args: DomArg<HTMLDivElement>[]) {\n  return cssLoadingDotsContainer(\n    cssLoadingDot(cssLoadingDot.cls(\"-left\")),\n    cssLoadingDot(cssLoadingDot.cls(\"-middle\")),\n    cssLoadingDot(cssLoadingDot.cls(\"-right\")),\n    ...args,\n  );\n}\n\nexport function watchPromise<T extends (...args: any[]) => any>(fun: T): T & { busy: Observable<boolean> } {\n  const loading = observable(false);\n  const result = async (...args: any) => {\n    loading.set(true);\n    try {\n      return await fun(...args);\n    } finally {\n      if (!loading.isDisposed()) {\n        loading.set(false);\n      }\n    }\n  };\n  return Object.assign(result, { busy: loading }) as any;\n}\n\nconst cssLoadingDotsContainer = styled(\"div\", `\n  --dot-size: 10px;\n  display: inline-flex;\n  column-gap: calc(var(--dot-size) / 2);\n`);\n\nconst cssLoadingDot = styled(\"div\", `\n  border-radius: 50%;\n  width: var(--dot-size);\n  height: var(--dot-size);\n  background-color: ${theme.loaderFg};\n  color: ${theme.loaderFg};\n  animation: ${flash} 1s alternate infinite;\n\n  &-left {\n    animation-delay: 0s;\n  }\n  &-middle {\n    animation-delay: 0.25s;\n  }\n  &-right {\n    animation-delay: 0.5s;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/menus.ts",
    "content": "import { MenuCommand } from \"app/client/components/commandList\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { NeedUpgradeError, reportError } from \"app/client/models/errors\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { cssCheckboxSquare, cssLabel, cssLabelText } from \"app/client/ui2018/checkbox\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssSelectBtn } from \"app/client/ui2018/select\";\n\nimport {\n  BindableValue, Computed, dom, DomContents, DomElementArg, DomElementMethod, IDomArgs,\n  MaybeObsArray, MutableObsArray, Observable, styled,\n} from \"grainjs\";\nimport debounce from \"lodash/debounce\";\nimport * as weasel from \"popweasel\";\n\nconst t = makeT(\"menus\");\n\nexport interface IOptionFull<T> {\n  value: T;\n  label: string;\n  disabled?: boolean;\n  icon?: IconName;\n}\n\nlet _lastOpenedController: weasel.IOpenController | null = null;\n\n// Close opened menu if any, otherwise do nothing.\n// WARN: current implementation does not handle submenus correctly. Does not seem a problem as of\n// today though, as there is no submenu in UI.\nexport function closeRegisteredMenu() {\n  if (_lastOpenedController) { _lastOpenedController.close(); }\n}\n\n// Register `ctl` to make sure it is closed when `closeMenu()` is called.\nexport function registerMenuOpen(ctl: weasel.IOpenController) {\n  _lastOpenedController = ctl;\n  ctl.onDispose(() => _lastOpenedController = null);\n}\n\n// For string options, we can use a string for label and value without wrapping into an object.\nexport type IOption<T> = (T & string) | IOptionFull<T>;\n\nexport function menu(createFunc: weasel.MenuCreateFunc, options?: weasel.IMenuOptions): DomElementMethod {\n  const wrappedCreateFunc = (ctl: weasel.IOpenController) => {\n    registerMenuOpen(ctl);\n    return createFunc(ctl);\n  };\n  return weasel.menu(wrappedCreateFunc, { ...defaults, ...options });\n}\n\nexport interface SearchableMenuOptions {\n  searchInputPlaceholder?: string;\n}\n\nexport interface SearchableMenuItem {\n  cleanText: string;\n  builder?: () => Element;\n\n  label?: string;\n  action?: (item: HTMLElement) => void;\n  args?: DomElementArg[];\n}\n\nexport function searchableMenu(\n  menuItems: MaybeObsArray<SearchableMenuItem>,\n  options: SearchableMenuOptions = {},\n): DomElementArg[] {\n  const { searchInputPlaceholder } = options;\n\n  const searchValue = Observable.create(null, \"\");\n  const setSearchValue = debounce((value) => { searchValue.set(value); }, 100);\n\n  return [\n    menuItemStatic(\n      cssMenuSearch(\n        cssMenuSearchIcon(\"Search\"),\n        cssMenuSearchInput(\n          dom.autoDispose(searchValue),\n          dom.on(\"input\", (_ev, elem) => { setSearchValue(elem.value); }),\n          { placeholder: searchInputPlaceholder },\n          testId(\"searchable-menu-input\"),\n        ),\n      ),\n    ),\n    menuDivider(),\n    dom.domComputed(searchValue, (value) => {\n      const cleanSearchValue = value.trim().toLowerCase();\n      return dom.forEach(menuItems, (item) => {\n        if (!item.cleanText.includes(cleanSearchValue)) { return null; }\n        if (item.label && item.action) {\n          return menuItem(item.action, item.label, ...(item.args || []));\n        } else if (item.builder) {\n          return item.builder();\n        } else {\n          throw new Error(\"Invalid menu item\");\n        }\n      });\n    }),\n    testId(\"searchable-menu\"),\n  ];\n}\n\n// TODO Weasel doesn't allow other options for submenus, but probably should.\nexport type ISubMenuOptions =\n  weasel.ISubMenuOptions &\n  weasel.IPopupOptions &\n  { allowNothingSelected?: boolean };\n\n/**\n * Menu item with submenu\n */\nexport function menuItemSubmenu(\n  submenu: weasel.MenuCreateFunc,\n  options: ISubMenuOptions,\n  ...args: DomElementArg[]\n): Element {\n  return weasel.menuItemSubmenu(\n    submenu,\n    {\n      ...defaults,\n      expandIcon: () => cssExpandIcon(\"Expand\"),\n      menuCssClass: `${cssSubMenuElem.className} ${defaults.menuCssClass}`,\n      ...options,\n    },\n    dom.cls(cssMenuItemSubmenu.className),\n    ...args,\n  );\n}\n\n/**\n * Header with a submenu (used in collapsed menus scenarios).\n */\nexport function menuSubHeaderMenu(\n  submenu: weasel.MenuCreateFunc,\n  options: ISubMenuOptions,\n  ...args: DomElementArg[]\n): Element {\n  return menuItemSubmenu(\n    submenu,\n    {\n      ...options,\n    },\n    menuSubHeader.cls(\"\"),\n    cssPointer.cls(\"\"),\n    ...args,\n  );\n}\n\nexport const cssEllipsisLabel = styled(\"div\", `\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n`);\n\nexport const cssExpandIcon = styled(icon, `\n  position: absolute;\n  right: 4px;\n`);\n\nconst cssSubMenuElem = styled(\"div\", `\n  white-space: nowrap;\n  min-width: 200px;\n`);\n\nexport const cssMenuElem = styled(\"div\", `\n  font-family: ${vars.fontFamily};\n  font-size: ${vars.mediumFontSize};\n  line-height: initial;\n  max-width: 400px;\n  padding: 8px 0px 16px 0px;\n  box-shadow: 0 2px 20px 0 ${theme.menuShadow};\n  min-width: 160px;\n  z-index: ${vars.menuZIndex};\n  --weaseljs-selected-background-color: ${theme.menuItemSelectedBg};\n  --weaseljs-menu-item-padding: 8px 24px;\n  background-color: ${theme.menuBg};\n\n  @media print {\n    & {\n      display: none;\n    }\n  }\n`);\n\n// The original purpose of z-index is lost to time, but probably matters in the presence of other\n// popups (modals, tutorials, etc). For the case of menus (which can have submenus), it's moved\n// from cssMenuElem to cssMenuWrapElem to work aroung a bug of submenus getting cut off in Safari.\n// More on Safari issue: https://ecomgraduates.com/blogs/news/fixing-z-index-issue-on-safari-browser.\nconst cssMenuWrapElem = styled(\"div\", `\n  z-index: ${vars.menuZIndex};\n  & > .${cssMenuElem.className} {\n    z-index: auto;\n  }\n`);\n\nexport const menuItemStyle = `\n  justify-content: flex-start;\n  align-items: center;\n  color: ${theme.menuItemFg};\n  --icon-color: ${theme.accentIcon};\n  .${weasel.cssMenuItem.className}-sel {\n    color: ${theme.menuItemSelectedFg};\n    --icon-color: ${theme.menuItemSelectedFg};\n  }\n  &.disabled {\n    cursor: default;\n    color: ${theme.menuItemDisabledFg};\n    --icon-color: ${theme.menuItemDisabledFg};\n  }\n`;\n\nexport const menuItemStatic = styled(\"div\", menuItemStyle);\n\nexport const menuCssClass = cssMenuElem.className;\n\nexport const gristFloatingMenuClass = \"grist-floating-menu\";\n\n// Add grist-floating-menu class to support existing browser tests\nconst defaults = {\n  menuCssClass: menuCssClass + \" \" + gristFloatingMenuClass,\n  menuWrapCssClass: cssMenuWrapElem.className,\n};\n\nexport interface SelectOptions<T> extends weasel.ISelectUserOptions {\n  /** Additional DOM element args to pass to each select option. */\n  renderOptionArgs?: (option: IOptionFull<T | null>) => DomElementArg;\n\n  /** Whether to translate the labels of the provided options. */\n  translateOptionLabels?: boolean;\n}\n\n/**\n * Creates a select dropdown widget. The observable `obs` reflects the value of the selected\n * option, and `optionArray` is an array (regular or observable) of option values and labels.\n * These may be either strings, or {label, value, icon, disabled} objects. Icons are optional\n * and must be IconName strings from 'app/client/ui2018/IconList'.\n *\n * The type of value may be any type at all; it is opaque to this widget.\n *\n * If obs is set to an invalid or disabled value, then defLabel option is used to determine the\n * label that the select box will show, blank by default.\n *\n * Usage:\n *    const fruit = observable(\"apple\");\n *    select(fruit, [\"apple\", \"banana\", \"mango\"]);\n *\n *    const employee = observable(17);\n *    const allEmployees = Observable.create(owner, [\n *      {value: 12, label: \"Bob\", disabled: true},\n *      {value: 17, label: \"Alice\"},\n *      {value: 21, label: \"Eve\"},\n *    ]);\n *    select(employee, allEmployees, {defLabel: \"Select employee:\"});\n *\n *    const name = observable(\"alice\");\n *    const names = [\"alice\", \"bob\", \"carol\"];\n *    select(name, names, {renderOptionArgs: (op) => console.log(`Rendered option ${op.value}`)});\n *\n * Note that this select element is not compatible with browser address autofill for usage in\n * forms, and that formSelect should be used for this purpose.\n */\nexport function select<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,\n  options: SelectOptions<T> = {}) {\n  const { renderOptionArgs, ...weaselOptions } = options;\n  const _menu = cssSelectMenuElem(testId(\"select-menu\"));\n  const _btn = cssSelectBtn(testId(\"select-open\"));\n\n  const { menuCssClass: menuClass, ...otherOptions } = weaselOptions;\n  const selectOptions = {\n    buttonArrow: cssInlineCollapseIcon(\"Collapse\"),\n    menuCssClass: [_menu.className,  (menuClass || \"\"), gristFloatingMenuClass].join(\" \"),\n    menuWrapCssClass: cssMenuWrapElem.className,\n    buttonCssClass: _btn.className,\n    ...otherOptions,\n  };\n\n  return weasel.select(obs, optionArray, selectOptions, op =>\n    cssOptionRow(\n      op.icon ? cssOptionRowIcon(op.icon) : null,\n      cssOptionLabel(options.translateOptionLabels ? t(op.label) : op.label),\n      renderOptionArgs ? renderOptionArgs(op) : null,\n      testId(\"select-row\"),\n    ),\n  );\n}\n\n/**\n * Same as select(), but the main element looks like a link rather than a button.\n */\nexport function linkSelect<T>(obs: Observable<T>, optionArray: MaybeObsArray<IOption<T>>,\n  options: weasel.ISelectUserOptions = {}) {\n  const _btn = cssSelectBtnLink(testId(\"select-open\"));\n  return select(obs, optionArray, { buttonCssClass: _btn.className, ...options });\n}\n\nexport interface IMultiSelectUserOptions {\n  placeholder?: string;\n  error?: Observable<boolean>;\n}\n\n/**\n * Creates a select dropdown widget that supports selecting multiple options.\n *\n * The observable array `selectedOptions` reflects the selected options, and\n * `availableOptions` is an array (normal or observable) of selectable options.\n * These may either be strings, or {label, value} objects.\n */\nexport function multiSelect<T>(selectedOptions: MutableObsArray<T>,\n  availableOptions: MaybeObsArray<IOption<T>>,\n  options: IMultiSelectUserOptions = {},\n  ...domArgs: DomElementArg[]) {\n  const selectedOptionsSet = Computed.create(null, selectedOptions, (_use, opts) => new Set(opts));\n\n  const selectedOptionsText = Computed.create(null, selectedOptionsSet, (use, selectedOpts) => {\n    if (selectedOpts.size === 0) {\n      return options.placeholder ?? t(\"Select fields\");\n    }\n\n    const optionArray = Array.isArray(availableOptions) ? availableOptions : use(availableOptions);\n    return optionArray\n      .filter(opt => selectedOpts.has(weasel.getOptionFull(opt).value))\n      .map(opt => weasel.getOptionFull(opt).label)\n      .join(\", \");\n  });\n\n  function buildMultiSelectMenu(ctl: weasel.IOpenController) {\n    return cssMultiSelectMenu(\n      { tabindex: \"-1\" }, // Allow menu to be focused.\n      dom.cls(menuCssClass),\n      FocusLayer.attach({ pauseMousetrap: true }),\n      dom.onKeyDown({\n        Enter: () => ctl.close(),\n        Escape: () => ctl.close(),\n      }),\n      (elem) => {\n        // Set focus on open, so that keyboard events work.\n        setTimeout(() => elem.focus(), 0);\n\n        // Sets menu width to match parent container (button) width.\n        const style = elem.style;\n        style.minWidth = ctl.getTriggerElem().getBoundingClientRect().width + \"px\";\n        style.marginLeft = style.marginRight = \"0\";\n      },\n      dom.domComputed(selectedOptionsSet, (selectedOpts) => {\n        return dom.forEach(availableOptions, (option) => {\n          const fullOption = weasel.getOptionFull(option);\n          return cssCheckboxLabel(\n            cssCheckboxSquare(\n              { type: \"checkbox\" },\n              dom.prop(\"checked\", selectedOpts.has(fullOption.value)),\n              dom.on(\"change\", (_ev, elem) => {\n                if (elem.checked) {\n                  selectedOptions.push(fullOption.value);\n                } else {\n                  selectedOpts.delete(fullOption.value);\n                  selectedOptions.set([...selectedOpts]);\n                }\n              }),\n              dom.style(\"position\", \"relative\"),\n              testId(\"multi-select-menu-option-checkbox\"),\n            ),\n            cssCheckboxText(fullOption.label, testId(\"multi-select-menu-option-text\")),\n            testId(\"multi-select-menu-option\"),\n          );\n        });\n      }),\n      testId(\"multi-select-menu\"),\n    );\n  }\n\n  return cssSelectBtn(\n    dom.autoDispose(selectedOptionsSet),\n    dom.autoDispose(selectedOptionsText),\n    cssMultiSelectSummary(\n      dom.text(selectedOptionsText),\n      cssMultiSelectSummary.cls(\"-placeholder\", use => use(selectedOptionsSet).size === 0),\n    ),\n    icon(\"Dropdown\"),\n    (elem) => {\n      weasel.setPopupToCreateDom(elem, ctl => buildMultiSelectMenu(ctl), weasel.defaultMenuOptions);\n    },\n    dom.style(\"border\", (use) => {\n      return options.error && use(options.error) ?\n        `1px solid ${theme.selectButtonBorderInvalid}` :\n        `1px solid ${theme.selectButtonBorder}`;\n    }),\n    ...domArgs,\n  );\n}\n\n/**\n * Creates a select dropdown widget that is more ideal for forms. Implemented using the <select>\n * element to work with browser form autofill and typing in the desired value to quickly set it.\n * The appearance of the opened menu is OS dependent.\n *\n * The observable `obs` reflects the value of the selected option, and `optionArray` is an\n * array (regular or observable) of option values and labels. These may be either strings,\n * or {label, value} objects.\n *\n * If obs is set to an empty string value, then defLabel option is used to determine the\n * label that the select box will show, blank by default.\n *\n * Usage:\n *    const fruit = observable(\"\");\n *    formSelect(fruit, [\"apple\", \"banana\", \"mango\"], {defLabel: \"Select fruit:\"});\n */\nexport function formSelect(obs: Observable<string>, optionArray: MaybeObsArray<IOption<string>>,\n  options: { defaultLabel?: string } = {}) {\n  const { defaultLabel = \"\" } = options;\n  const container: Element = cssSelectBtnContainer(\n    dom(\"select\", { class: cssSelectBtn.className, style: \"height: 42px; padding: 12px 30px 12px 12px;\" },\n      dom.prop(\"value\", obs),\n      dom.on(\"change\", (_, elem) => { obs.set(elem.value); }),\n      dom(\"option\", { value: \"\", hidden: \"hidden\" }, defaultLabel),\n      dom.forEach(optionArray, (option) => {\n        const obj: weasel.IOptionFull<string> = weasel.getOptionFull(option);\n        return dom(\"option\", { value: obj.value }, obj.label);\n      }),\n    ),\n    cssCollapseIcon(\"Collapse\"),\n  );\n  return container;\n}\n\nexport function inputMenu(createFunc: weasel.MenuCreateFunc, options?: weasel.IMenuOptions): DomElementMethod {\n  // Triggers the input menu on 'input' events, if the input has text inside.\n  function inputTrigger(triggerElem: Element, ctl: weasel.PopupControl): void {\n    dom.onElem(triggerElem, \"input\", () => {\n      if ((triggerElem as HTMLInputElement).value.length > 0) {\n        ctl.open();\n      } else {\n        ctl.close();\n      }\n    });\n  }\n  return weasel.inputMenu(createFunc, {\n    trigger: [inputTrigger],\n    menuCssClass: `${cssMenuElem.className} ${cssInputButtonMenuElem.className}`,\n    ...options,\n  });\n}\n\n// A menu item that leads to the billing page if the desired operation requires an upgrade.\n// Such menu items are marked with a little sparkle unicode.\nexport function upgradableMenuItem(needUpgrade: boolean, action: () => void, ...rem: any[]) {\n  if (needUpgrade) {\n    return menuItem(() => reportError(new NeedUpgradeError()), ...rem, \" *\");\n  } else {\n    return menuItem(action, ...rem);\n  }\n}\n\nexport function upgradeText(needUpgrade: boolean, onClick: () => void) {\n  if (!needUpgrade) { return null; }\n  return menuText(dom(\"span\", t(\"* Workspaces are available on team plans. \"),\n    cssUpgradeTextButton(\n      t(\"Upgrade now\"),\n      textButton.cls(\"-hover-bg-padding-sm\"),\n      dom.on(\"click\", () => onClick()),\n    )));\n}\n\n/**\n * Create an autocomplete element and tie it to an input or textarea element.\n *\n * Usage:\n *      const employees = ['Thomas', 'June', 'Bethany', 'Mark', 'Marjorey', 'Zachary'];\n *      const inputElem = input(...);\n *      autocomplete(inputElem, employees);\n */\nexport function autocomplete(\n  inputElem: HTMLInputElement,\n  choices: MaybeObsArray<string>,\n  options: weasel.IAutocompleteOptions = {},\n) {\n  return weasel.autocomplete(inputElem, choices, {\n    ...defaults, ...options,\n    menuCssClass: defaults.menuCssClass + \" \" + cssSelectMenuElem.className + \" \" + (options.menuCssClass || \"\"),\n  });\n}\n\n/**\n * Creates simple (not reactive) static menu that looks like a select-box.\n * Primary usage is for menus, where you want to control how the options and a default\n * label will look. Label is not updated or changed when one of the option is clicked, for those\n * use cases use a select component.\n * Icons are optional, can use custom elements instead of labels and options.\n *\n * Usage:\n *\n *  selectMenu(selectTitle(\"Title\", \"Script\"), () => [\n *    selectOption(() => ..., \"Option1\", \"Database\"),\n *    selectOption(() => ..., \"Option2\", \"Script\"),\n *  ]);\n *\n *  // Control disabled state (if the menu will be opened or not)\n *\n *  const disabled = observable(false);\n *  selectMenu(selectTitle(\"Title\", \"Script\"), () => [\n *    selectOption(() => ..., \"Option1\", \"Database\"),\n *    selectOption(() => ..., \"Option2\", \"Script\"),\n *  ], disabled);\n *\n */\nexport function selectMenu(\n  label: DomElementArg,\n  items: () => DomElementArg[],\n  ...args: IDomArgs<HTMLDivElement>\n) {\n  return cssSelectBtn(\n    label,\n    icon(\"Dropdown\"),\n    listOfMenuItems(items),\n    ...args,\n  );\n}\n\nexport function listOfMenuItems(items: () => DomElementArg[]) {\n  const _menu = cssSelectMenuElem(testId(\"select-menu\"));\n  return menu(\n    items,\n    {\n      ...weasel.defaultMenuOptions,\n      menuCssClass: _menu.className + \" \" + gristFloatingMenuClass,\n      menuWrapCssClass: cssMenuWrapElem.className,\n      stretchToSelector: `.${cssSelectBtn.className}`,\n      trigger: [(triggerElem, ctl) => {\n        const isDisabled = () => triggerElem.classList.contains(\"disabled\");\n        dom.onElem(triggerElem, \"click\", () => isDisabled() || ctl.toggle());\n        dom.onKeyElem(triggerElem as HTMLElement, \"keydown\", {\n          ArrowDown: () => isDisabled() || ctl.open(),\n          ArrowUp: () => isDisabled() || ctl.open(),\n        });\n      }],\n    },\n  );\n}\n\nexport function selectTitle(label: BindableValue<string>, iconName?: BindableValue<IconName>) {\n  return cssOptionRow(\n    iconName ? dom.domComputed(iconName, name => cssOptionRowIcon(name)) : null,\n    dom.text(label),\n  );\n}\n\nexport function selectOption(\n  action: (item: HTMLElement) => void,\n  label: BindableValue<string>,\n  iconName?: BindableValue<IconName>,\n  ...args: IDomArgs<HTMLElement>) {\n  return menuItem(action, selectTitle(label, iconName), ...args);\n}\n\nexport const menuSubHeader = styled(\"div\", `\n  color: ${theme.menuSubheaderFg};\n  font-size: ${vars.xsmallFontSize};\n  text-transform: uppercase;\n  font-weight: ${vars.headerControlTextWeight};\n  padding: 8px 24px 8px 24px;\n  cursor: default;\n`);\n\nexport const cssPointer = styled(\"div\", `\n  cursor: pointer;\n`);\n\nexport const menuText = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  font-size: ${vars.smallFontSize};\n  color: ${theme.menuText};\n  padding: 8px 24px 4px 24px;\n  max-width: 250px;\n  cursor: default;\n`);\n\nexport const menuItem = styled(weasel.menuItem, menuItemStyle);\n\nexport const menuItemLink = styled(weasel.menuItemLink, menuItemStyle);\n\n// when element name is too long, it will be trimmed with ellipsis (\"...\")\nexport function menuItemTrimmed(\n  action: (item: HTMLElement, ev: Event) => void, label: string, ...args: DomElementArg[]) {\n  return menuItem(action, cssEllipsisLabel(label), ...args);\n}\n\n/**\n * A version of menuItem which runs the action on next tick, allowing the menu to close even when\n * the action causes the disabling of the element being clicked.\n * TODO disabling the element should not prevent the menu from closing; once fixed in weasel, this\n * can be removed.\n */\nexport const menuItemAsync: typeof weasel.menuItem = function(action, ...args) {\n  return menuItem(() => setTimeout(action, 0), ...args);\n};\n\nexport function menuItemCmd(\n  cmd: MenuCommand,\n  label: string | (() => DomContents),\n  ...args: DomElementArg[]\n) {\n  return menuItem(\n    () => cmd.run(),\n    typeof label === \"string\" ?\n      dom(\"span\", label, testId(\"cmd-name\")) :\n      dom(\"div\", label(), testId(\"cmd-name\")),\n    cmd.humanKeys?.length ? cssCmdKey(cmd.humanKeys[0]) : null,\n    cssMenuItemCmd.cls(\"\"), // overrides some menu item styles\n    ...args,\n  );\n}\n\nexport const menuItemCmdLabel = styled(\"div\", `\n  display: flex;\n  align-items: center;\n`);\n\nexport function menuAnnotate(text: string, ...args: DomElementArg[]) {\n  return cssAnnotateMenuItem(text, ...args);\n}\n\nexport const menuDivider = styled(weasel.cssMenuDivider, `\n  background-color: ${theme.menuBorder};\n  margin: 8px 0;\n`);\n\nexport const menuIcon = styled(icon, `\n  flex: none;\n  margin-right: 8px;\n`);\n\nconst cssSelectMenuElem = styled(cssMenuElem, `\n  max-height: 400px;\n  overflow-y: auto;\n\n  --weaseljs-menu-item-padding: 8px 16px;\n`);\n\nconst cssSelectBtnContainer = styled(\"div\", `\n  position: relative;\n  width: 100%;\n`);\n\nconst cssSelectBtnLink = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  font-size: ${vars.mediumFontSize};\n  color: ${theme.controlFg};\n  --icon-color: ${theme.controlFg};\n  width: initial;\n  height: initial;\n  line-height: inherit;\n  background-color: initial;\n  padding: initial;\n  border: initial;\n  border-radius: initial;\n  box-shadow: initial;\n  cursor: pointer;\n  outline: none;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n\n  &:hover, &:focus, &:active {\n    color: ${theme.controlHoverFg};\n    --icon-color: ${theme.controlHoverFg};\n    box-shadow: initial;\n  }\n`);\n\nexport const cssOptionRow = styled(\"span\", `\n  display: flex;\n  align-items: center;\n  width: 100%;\n`);\n\nexport const cssOptionRowIcon = styled(icon, `\n  height: 16px;\n  width: 16px;\n  background-color: var(--icon-color, ${theme.menuItemIconFg});\n  margin: -3px 8px 0 2px;\n  margin: 0 8px 0 0;\n  flex: none;\n\n  .${weasel.cssMenuItem.className}-sel & {\n    background-color: ${theme.menuItemSelectedFg};\n  }\n\n  .${weasel.cssMenuItem.className}.disabled & {\n    background-color: ${theme.menuItemDisabledFg};\n  }\n`);\n\nexport const cssOptionLabel = styled(\"div\", `\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  --grist-option-label-color: ${theme.menuItemFg};\n  --grist-option-label-color-sel: ${theme.menuItemSelectedFg};\n  --grist-option-label-color-disabled: ${theme.menuItemDisabledFg};\n\n  .${weasel.cssMenuItem.className} & {\n    color: var(--grist-option-label-color);\n  }\n\n  .${weasel.cssMenuItem.className}-sel & {\n    color: var(--grist-option-label-color-sel);\n    background-color: ${theme.menuItemSelectedBg};\n  }\n\n  .${weasel.cssMenuItem.className}.disabled & {\n    color: var(--grist-option-label-color-disabled);\n  }\n`);\n\nconst cssInlineCollapseIcon = styled(icon, `\n  margin: 0 2px;\n  pointer-events: none;\n`);\n\nconst cssCollapseIcon = styled(icon, `\n  position: absolute;\n  right: 12px;\n  top: calc(50% - 8px);\n  pointer-events: none;\n  background-color: ${theme.selectButtonFg};\n`);\n\nconst cssInputButtonMenuElem = styled(cssMenuElem, `\n  padding: 4px 0px;\n`);\n\nexport const cssMenuItemCmd = styled(\"div\", `\n  justify-content: space-between;\n  --icon-color: ${theme.menuItemFg};\n\n  .${weasel.cssMenuItem.className}-sel & {\n    --icon-color: ${theme.menuItemSelectedFg};\n  }\n\n  .${weasel.cssMenuItem.className}.disabled & {\n    --icon-color: ${theme.menuItemDisabledFg};\n  }\n`);\n\nexport const cssCmdKey = styled(\"span\", `\n  margin-left: 16px;\n  color: ${theme.menuItemIconFg};\n  margin-right: -12px;\n\n  .${weasel.cssMenuItem.className}-sel > & {\n    color: ${theme.menuItemIconSelectedFg};\n  }\n\n  .${weasel.cssMenuItem.className}.disabled & {\n    color: ${theme.menuItemDisabledFg};\n  }\n`);\n\nconst cssAnnotateMenuItem = styled(\"span\", `\n  color: ${theme.accentText};\n  text-transform: uppercase;\n  font-size: 8px;\n  vertical-align: super;\n  margin-top: -4px;\n  margin-left: 4px;\n  font-weight: bold;\n\n  .${weasel.cssMenuItem.className}-sel > & {\n    color: ${theme.menuItemIconSelectedFg};\n  }\n`);\n\nconst cssMultiSelectSummary = styled(\"div\", `\n  flex: 1 1 0px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  color: ${theme.selectButtonFg};\n\n  &-placeholder {\n    color: ${theme.selectButtonPlaceholderFg};\n  }\n`);\n\nconst cssMultiSelectMenu = styled(weasel.cssMenu, `\n  display: flex;\n  flex-direction: column;\n  max-height: calc(max(300px, 95vh - 300px));\n  max-width: 400px;\n  padding-bottom: 0px;\n  background-color: ${theme.menuBg};\n  z-index: ${vars.menuZIndex};\n`);\n\nconst cssCheckboxLabel = styled(cssLabel, `\n  padding: 8px 16px;\n`);\n\nconst cssCheckboxText = styled(cssLabelText, `\n  margin-right: 12px;\n  color: ${theme.text};\n  white-space: pre;\n`);\n\nconst cssUpgradeTextButton = styled(textButton, `\n  font-size: ${vars.smallFontSize};\n`);\n\nconst cssMenuItemSubmenu = styled(\"div\", `\n  position: relative;\n  justify-content: flex-start;\n  color: ${theme.menuItemFg};\n  --icon-color: ${theme.accentIcon};\n  .${weasel.cssMenuItem.className}-sel {\n    color: ${theme.menuItemSelectedFg};\n    --icon-color: ${theme.menuItemSelectedFg};\n  }\n  &.disabled {\n    cursor: default;\n    color: ${theme.menuItemDisabledFg};\n    --icon-color: ${theme.menuItemDisabledFg};\n  }\n`);\n\nconst cssMenuSearch = styled(\"div\", `\n  display: flex;\n  column-gap: 8px;\n  align-items: center;\n  padding: 8px 16px;\n`);\n\nconst cssMenuSearchIcon = styled(icon, `\n  flex-shrink: 0;\n  --icon-color: ${theme.menuItemIconFg};\n`);\n\nconst cssMenuSearchInput = styled(\"input\", `\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  flex-grow: 1;\n  font-size: ${vars.mediumFontSize};\n  padding: 0px;\n  border: none;\n  outline: none;\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\ntype MenuDefinition = MenuItem[];\n\ninterface MenuItem {\n  label?: string;\n  header?: string;\n  action?: string | (() => void);\n  disabled?: boolean;\n  icon?: IconName;\n  shortcut?: string;\n  submenu?: MenuDefinition;\n  maxSubmenu?: number;\n  type?: \"header\" | \"separator\" | \"item\"; // default to item.\n}\n\n/**\n * A helper method that can generate a menu (like context menu in GridView) out of a plain definition.\n * Currently only used in Virtual Tables.\n */\nexport function menuBuilder(definition: MenuItem[]) {\n  return menu(ctl => [...buildMenuItems(definition)], {});\n}\n\nexport function* buildMenuItems(current: MenuItem[]): IterableIterator<Element> {\n  for (const item of current) {\n    const isHeader = item.type === \"header\" || item.header;\n    // If this is header with submenu.\n    if (isHeader && item.submenu) {\n      yield menuSubHeaderMenu(() => [...buildMenuItems(item.submenu!)], {}, item.header ?? item.label);\n      continue;\n    } else if (isHeader) {\n      yield menuSubHeader(item.header ?? item.label);\n      continue;\n    }\n\n    // Not a header, so it's an item or a separator.\n    if (item.type === \"separator\") {\n      yield menuDivider();\n      continue;\n    }\n\n    // If this is an item with submenu.\n    if (item.submenu) {\n      yield menuItemSubmenu(() => [...buildMenuItems(item.submenu!)], {}, item.label);\n      continue;\n    }\n\n    // Not a submenu, so it's a regular item.\n    const action = typeof item.action === \"function\" ? item.action : () => {};\n    yield menuItem(\n      action,\n      item.icon && menuIcon(item.icon),\n      item.label,\n      item.shortcut && cssCmdKey(item.shortcut),\n      item.disabled ? dom.cls(\"disabled\", item.disabled) : null,\n    );\n  }\n}\n"
  },
  {
    "path": "app/client/ui2018/modals.ts",
    "content": "import { kbFocusHighlighterClass } from \"app/client/components/KeyboardFocusHighlighter\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/errors\";\nimport { cssInput } from \"app/client/ui/cssInput\";\nimport { prepareForTransition, TransitionWatcher } from \"app/client/ui/transitions\";\nimport { bigBasicButton, bigPrimaryButton, cssButton } from \"app/client/ui2018/buttons\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { mediaSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { cssMenuElem } from \"app/client/ui2018/menus\";\nimport { waitGrainObs } from \"app/common/gutil\";\nimport { MaybePromise } from \"app/plugin/gutil\";\n\nimport { Computed, Disposable, dom, DomContents, DomElementArg, input, keyframes,\n  MultiHolder, Observable, styled } from \"grainjs\";\nimport { IOpenController, IPopupOptions, PopupControl, popupOpen } from \"popweasel\";\n\nconst t = makeT(\"modals\");\n\nexport const getConfirmText = () => t(\"Confirm\");\n\n// IModalControl is passed into the function creating the body of the modal.\nexport interface IModalControl {\n  // Observable for whether there is work in progress that's delaying the closing of the modal. It\n  // is useful for disabling a Save or Close button.\n  workInProgress: Computed<boolean>;\n\n  // Focus the modal dialog.\n  focus(): void;\n\n  // Request to close, without waiting. It's possible for closing to get prevented.\n  close(): void;\n\n  // Returns true if closed, false if closing was prevented.\n  closeAndWait(): Promise<boolean>;\n\n  // Prevents closing, if close has been called and is pending. No-op otherwise.\n  preventClose(): void;\n\n  // Wraps the passed-in function, so that closing is delayed while the function is running. If\n  // {close: true} is passed in, then requests closing of the modal when the function is done.\n  //\n  // With catchErrors set, errors are caught and reported, and prevent the dialog from closing.\n  // Otherwise, only StayOpen exception prevents closing; other errors will be propagated.\n  doWork<Args extends any[]>(\n    func: (...args: Args) => Promise<unknown>,\n    options?: { close?: boolean, catchErrors?: boolean },\n  ): (...args: Args) => Promise<void>;\n}\n\nexport class ModalControl extends Disposable implements IModalControl {\n  private _inProgress = Observable.create<number>(this, 0);\n  private _workInProgress = Computed.create(this, this._inProgress, (use, n) => (n > 0));\n  private _closePromise: Promise<boolean> | undefined;\n  private _shouldClose = false;\n\n  constructor(\n    private _doClose: () => void,\n    private _doFocus?: () => void,\n  ) {\n    super();\n  }\n\n  public focus() {\n    this._doFocus?.();\n  }\n\n  public close(): void {\n    this.closeAndWait().catch(() => {});\n  }\n\n  public async closeAndWait(): Promise<boolean> {\n    return this._closePromise || (this._closePromise = this._doCloseAndWait());\n  }\n\n  public preventClose(): void {\n    this._shouldClose = false;\n  }\n\n  public get workInProgress() {\n    return this._workInProgress;\n  }\n\n  public doWork<Args extends any[]>(\n    func: (...args: Args) => Promise<unknown>,\n    options: { close?: boolean, catchErrors?: boolean } = {},\n  ): (...args: Args) => Promise<void> {\n    return async (...args) => {\n      this._inProgress.set(this._inProgress.get() + 1);\n      const closePromise = options.close ? this.closeAndWait() : null;\n      try {\n        await func(...args);\n      } catch (err) {\n        if (err instanceof StayOpen) {\n          this.preventClose();\n        } else if (options.catchErrors) {\n          reportError(err);\n          this.preventClose();\n        } else {\n          throw err;\n        }\n      } finally {\n        this._inProgress.set(this._inProgress.get() - 1);\n        if (closePromise) {\n          await closePromise;\n        }\n      }\n    };\n  }\n\n  private async _doCloseAndWait(): Promise<boolean> {\n    this._shouldClose = true;\n    try {\n      // Since some modals expect an immediate close; avoid an await when no work is pending.\n      if (this.workInProgress.get()) {\n        await waitGrainObs(this.workInProgress, wip => !wip);\n      }\n      if (this._shouldClose) { this._doClose(); }\n      return this._shouldClose;\n    } finally {\n      this._closePromise = undefined;\n    }\n  }\n}\n\n/**\n * The modal variant.\n *\n * Fade-in modals open with a fade-in background animation, and close immediately.\n *\n * Collapsing modals open with a expanding animation from a referenced DOM element, and\n * close with a collapsing animation into the referenced element.\n */\nexport type IModalVariant = \"fade-in\" | \"collapsing\";\n\nexport interface IModalOptions {\n  // The modal variant. Defaults to \"fade-in\".\n  variant?: IModalVariant;\n  // Required for \"collapsing\" variant modals. This is the anchor element for animations.\n  refElement?: HTMLElement;\n  // If set, escape key does not close the dialog.\n  noEscapeKey?: boolean;\n  // If set, clicking into background does not close dialog.\n  noClickAway?: boolean;\n  // DOM arguments to pass to the modal backer.\n  backerDomArgs?: DomElementArg[];\n  // Function called when the user cancels the modal (esc key or clicking outside)\n  onCancel?: () => void;\n  // If given, call and wait for this before closing the dialog. If it returns false, don't close.\n  // Error also prevents closing, and is reported as an unexpected error.\n  beforeClose?: () => Promise<boolean>;\n}\n\n// A custom error type to signal to the modal that it should stay open, but not report any error\n// (presumably because the error was already reported).\nexport class StayOpen extends Error {\n}\n\nexport type ModalWidth =\n  \"normal\" |          // Normal dialog, from 428px to 480px in width.\n  \"fixed-wide\";       // Fixed 600px width.\n\n/**\n * A simple modal. Shows up in the middle of the screen with a tinted backdrop.\n * Created with the given body content and width.\n *\n * Closed via clicking anywhere outside the modal. May also be closed by\n * calling ctl.close().\n *\n * The createFn callback may tie the disposal of temporary objects to its `owner` argument.\n *\n * Example usage:\n *  modal((ctl, owner) => [\n *    cssModalTitle(`Pin doc`),\n *    cssModalBody('Are you sure you want to pin doc?')\n *    cssModalButtons(\n *      primary('Yes', dom.on('click', () => { onClick(true); ctl.close(); })),\n *      secondary('Cancel', dom.on('click', () => { onClick(false); ctl.close(); }))\n *    )\n *  ])\n */\nexport function modal(\n  createFn: (ctl: IModalControl, owner: MultiHolder) => DomElementArg,\n  options: IModalOptions = {},\n): void {\n  const {\n    noEscapeKey,\n    noClickAway,\n    refElement = document.body,\n    variant = \"fade-in\",\n    backerDomArgs = [],\n  } = options;\n\n  function doClose() {\n    if (!modalDom.isConnected) { return; }\n\n    if (variant === \"collapsing\") {\n      collapseAndCloseModal();\n    } else {\n      closeModal();\n    }\n  }\n\n  function closeModal() {\n    document.body.removeChild(modalDom);\n    // Ensure we run the disposers for the DOM contained in the modal.\n    dom.domDispose(modalDom);\n  }\n\n  function collapseAndCloseModal() {\n    const watcher = new TransitionWatcher(dialogDom);\n    watcher.onDispose(() => closeModal());\n    modalDom.classList.add(cssModalBacker.className + \"-collapsing\");\n    collapseModal();\n  }\n\n  function expandModal() {\n    prepareForTransition(dialogDom, () => collapseModal());\n    Object.assign(dialogDom.style, {\n      transform: \"\",\n      opacity: \"\",\n      visibility: \"visible\",\n    });\n  }\n\n  function collapseModal() {\n    const rect = dialogDom.getBoundingClientRect();\n    const collapsedRect = refElement.getBoundingClientRect();\n    const originX = (collapsedRect.left + collapsedRect.width / 2) - rect.left;\n    const originY = (collapsedRect.top + collapsedRect.height / 2) - rect.top;\n    Object.assign(dialogDom.style, {\n      transform: `scale(${collapsedRect.width / rect.width}, ${collapsedRect.height / rect.height})`,\n      transformOrigin: `${originX}px ${originY}px`,\n      opacity: \"0\",\n    });\n  }\n\n  let close = doClose;\n  let dialogDom: HTMLElement;\n\n  const modalDom = cssModalBacker(\n    dom.create((owner) => {\n      const focus = () => dialogDom.focus();\n      const ctl = ModalControl.create(owner, doClose, focus);\n      close = () => {\n        ctl.close();\n        options.onCancel?.();\n      };\n\n      dialogDom = cssModalDialog(\n        createFn(ctl, owner),\n        dom.cls(kbFocusHighlighterClass),\n        cssModalDialog.cls(\"-collapsing\", variant === \"collapsing\"),\n        dom.on(\"click\", ev => ev.stopPropagation()),\n        noEscapeKey ? null : dom.onKeyDown({ Escape: close }),\n        testId(\"modal-dialog\"),\n      );\n      FocusLayer.create(owner, {\n        defaultFocusElem: dialogDom,\n        allowFocus: elem => (elem !== document.body),\n        // Pause mousetrap keyboard shortcuts while the modal is shown. Without this, arrow keys\n        // will navigate in a grid underneath the modal, and Enter may open a cell there.\n        pauseMousetrap: true,\n      });\n      return dialogDom;\n    }),\n    noClickAway ? null : dom.on(\"click\", () => close()),\n    ...backerDomArgs,\n  );\n\n  document.body.appendChild(modalDom);\n  if (variant === \"collapsing\") { expandModal(); }\n}\n\nexport interface ISaveModalOptions {\n  title: DomElementArg;           // Normally just a string.\n  body: DomElementArg;            // Content of the dialog.\n  saveLabel?: DomElementArg;      // Normally just a string; defaults to \"Save\".\n  saveDisabled?: Observable<boolean>;   // Optional observable for when to disable Save button.\n  saveFunc: () => Promise<unknown>;     // Called on Save; dialog closes when promise is fulfilled.\n  hideCancel?: boolean;           // If set, hide the Cancel button\n  width?: ModalWidth;             // Set a width style for the dialog.\n  modalArgs?: DomElementArg;      // Extra args to apply to the outer cssModalDialog element.\n  extraButtons?: DomContents;     // More buttons!\n}\n\n/**\n * Creates a modal dialog with a title, body, and Save/Cancel buttons. The provided createFunc()\n * is called immediately to get the dialog's contents and options (see ISaveModalOptions for\n * details). For example:\n *\n *    saveModal((ctl, owner) => {\n *      const myObs = Computed.create(owner, ...);\n *      return {\n *        title: 'My Dialog',\n *        body: dom('div', 'Hello', dom.text(myObs)),\n *        saveDisabled: Computed.create(owner, (use) => !use(myObs)),\n *        saveFunc: () => server.ping(),\n *        modalArgs: {style: 'background-color: blue'},\n *      };\n *    });\n *\n * On Save, the dialog calls saveFunc(), disables the Save button, and stays open until saveFunc()\n * is resolved. It then closes on success, or reports the error and stays open on rejection. To\n * stay open without reporting an error (if one is already reported), throw StayOpen exception.\n *\n * The dialog interprets Enter/Escape keys as if the Save/Cancel buttons were clicked.\n *\n * Note that it's possible to close the dialog via Cancel while saveFunc() is pending. That's\n * probably desirable, but keep in mind that the dialog may be disposed before saveFunc() returns.\n *\n * Error handling examples:\n *  1.  saveFunc: doSomething\n *      (Most common) If doSomething fails, the error is reported and the dialog stays open.\n *  2.  saveFunc: () => doSomething().catch(reportError)\n *      If doSomething fails, the error is reported but the dialog closes anyway.\n *  3.  saveFunc: () => doSomething().catch((e) => { alert(\"BOOM\"); throw new StayOpen(); })\n *      If doSomething fails, an alert is shown, and the dialog stays open.\n */\nexport function saveModal(\n  createFunc: (ctl: IModalControl, owner: MultiHolder) => ISaveModalOptions,\n  modalOptions?: IModalOptions,\n) {\n  return modal((ctl, owner) => {\n    const options = createFunc(ctl, owner);\n\n    const isSaveDisabled = Computed.create(owner, use =>\n      use(ctl.workInProgress) || (options.saveDisabled ? use(options.saveDisabled) : false));\n\n    const save = ctl.doWork(options.saveFunc, { close: true, catchErrors: true });\n\n    return [\n      cssModalTitle(options.title, testId(\"modal-title\")),\n      cssModalBody(options.body),\n      cssModalButtons(\n        bigPrimaryButton(options.saveLabel || t(\"Save\"),\n          dom.boolAttr(\"disabled\", isSaveDisabled),\n          dom.on(\"click\", save),\n          testId(\"modal-confirm\"),\n        ),\n        options.extraButtons,\n        options.hideCancel ? null : bigBasicButton(t(\"Cancel\"),\n          dom.on(\"click\", () => {\n            ctl.close();\n            modalOptions?.onCancel?.();\n          }),\n          testId(\"modal-cancel\"),\n        ),\n      ),\n      dom.onKeyDown({ Enter: () => isSaveDisabled.get() || save() }),\n      options.width && cssModalWidth(options.width),\n      options.modalArgs,\n    ];\n  }, modalOptions);\n}\n\nexport interface ConfirmModalOptions {\n  explanation?: DomElementArg,\n  hideCancel?: boolean;\n  /** Defaults to true. */\n  hideDontShowAgain?: boolean;\n  extraButtons?: DomContents;\n  modalOptions?: IModalOptions;\n  saveDisabled?: Observable<boolean>;\n  width?: ModalWidth;\n}\n\n/**\n * Builds a simple confirm modal with 'Enter' bound to the confirm action.\n *\n * See saveModal() for error handling notes that here apply to the onConfirm callback.\n */\nexport function confirmModal(\n  title: DomElementArg,\n  btnText: DomElementArg,\n  onConfirm: (dontShowAgain?: boolean) => MaybePromise<void>,\n  options: ConfirmModalOptions = {},\n): void {\n  const {\n    explanation,\n    hideCancel,\n    hideDontShowAgain = true,\n    extraButtons,\n    modalOptions,\n    saveDisabled,\n    width,\n  } = options;\n  return saveModal((_ctl, owner): ISaveModalOptions => {\n    const dontShowAgain = Observable.create(owner, false);\n    return {\n      title,\n      body: [\n        explanation || null,\n        hideDontShowAgain ? null : dom(\"div\",\n          cssDontShowAgainCheckbox(\n            dontShowAgain,\n            cssDontShowAgainCheckboxLabel(t(\"Don't show again\")),\n            testId(\"modal-dont-show-again\"),\n          ),\n        ),\n      ],\n      saveLabel: btnText,\n      saveFunc: async () => onConfirm(hideDontShowAgain ? undefined : dontShowAgain.get()),\n      hideCancel,\n      width: width ?? \"normal\",\n      extraButtons,\n      saveDisabled,\n    };\n  }, modalOptions);\n}\n\n/**\n * Creates a simple prompt modal (replacement for the native one).\n * Closed via clicking anywhere outside the modal or Cancel button.\n *\n * Example usage:\n *  promptModal(\n *    \"Enter your name\",\n *    (name: string) => alert(`Hello ${name}`),\n *    \"Ok\" // Confirm button name,\n *    \"John doe\", // Initial text (can be empty or undefined)\n *    \"Enter your name\", // input placeholder\n *    () => console.log('User cancelled') // Called when user cancels, or clicks outside.\n *  )\n *\n * @param title: Prompt text.\n * @param onConfirm: Handler for Confirm button.\n * @param btnText: Text of the confirm button.\n * @param initial: Initial value in the input element.\n * @param placeholder: Placeholder for the input element.\n * @param onCancel: Optional cancel handler.\n */\nexport function promptModal(\n  title: string,\n  onConfirm: (text: string) => Promise<void>,\n  btnText?: string,\n  initial?: string,\n  placeholder?: string,\n  onCancel?: () => void,\n  body?: DomElementArg,\n): void {\n  saveModal((ctl, owner): ISaveModalOptions => {\n    let confirmed = false;\n    const text = Observable.create(owner, initial ?? \"\");\n    const txtInput = input(text, { onInput: true }, { placeholder }, cssInput.cls(\"\"), testId(\"modal-prompt\"));\n    const options: ISaveModalOptions = {\n      title,\n      body: [body, txtInput],\n      saveLabel: btnText || t(\"Save\"),\n      saveFunc: () => {\n        // Mark that confirm was invoked.\n        confirmed = true;\n        return onConfirm(text.get() || \"\");\n      },\n      width: \"normal\",\n    };\n    owner.onDispose(() => {\n      if (confirmed) { return; }\n      onCancel?.();\n    });\n    setTimeout(() => txtInput.focus(), 10);\n    return options;\n  });\n}\n\n/**\n * Wraps prompt modal in a promise that is resolved either when user confirms or cancels.\n * When user cancels the returned value is always undefined.\n *\n * Example usage:\n *  async handler() {\n *    const name = await invokePrompt(\"Please enter your name\");\n *    if (name !== undefined) alert(`Hello ${name}`);\n *  }\n *\n * @param title: Prompt text.\n * @param options.btnText: Text of the confirm button, default is \"Ok\".\n * @param options.initial: Initial value in the input element.\n * @param options.placeholder: Placeholder for the input element.\n * @param options.body: More material before the input element.\n */\nexport function invokePrompt(\n  title: string,\n  options: {\n    btnText?: string,\n    initial?: string,\n    placeholder?: string,\n    body?: DomElementArg,\n  },\n): Promise<string | undefined> {\n  let onResolve: (text: string | undefined) => any;\n  const prom = new Promise<string | undefined>((resolve) => {\n    onResolve = resolve;\n  });\n  promptModal(title, onResolve!, options?.btnText ?? t(\"Ok\"), options?.initial, options?.placeholder, () => {\n    if (onResolve) {\n      onResolve(undefined);\n    }\n  }, options?.body);\n  return prom;\n}\n\n/**\n * Builds a simple spinner modal. The modal gets removed when `promise` resolves.\n */\nexport async function spinnerModal<T>(\n  title: string,\n  promise: Promise<T>): Promise<T> {\n  modal((ctl, owner) => {\n    // `finally` is missing from es2016, below is a work-around.\n    const close = () => ctl.close();\n    promise.then(close, close);\n\n    return [\n      cssModalSpinner.cls(\"\"),\n      cssModalTitle(\n        title,\n        testId(\"modal-spinner-title\"),\n      ),\n      cssSpinner(loadingSpinner()),\n      testId(\"modal-spinner\"),\n    ];\n  }, {\n    noClickAway: true,\n    noEscapeKey: true,\n  });\n  return await promise;\n}\n\n/**\n * Apply this to a modal as\n *    modal(() => [cssModalBody(...), cssModalWidth('normal')])\n * or\n *    saveModal(() => {..., width: 'normal'})\n */\nexport function cssModalWidth(style: ModalWidth) {\n  return cssModalDialog.cls(\"-\" + style);\n}\n\n/**\n * Shows a little modal as a tooltip.\n *\n * Example:\n * dom.on('click', (_, element) => modalTooltip(element, (ctl) => {\n *  return dom('div', 'Hello world', dom.on('click', () => ctl.close()));\n * }))\n */\nexport function modalTooltip(\n  reference: Element,\n  domCreator: (ctl: IOpenController) => DomElementArg,\n  options: IPopupOptions = {},\n): PopupControl {\n  return popupOpen(reference, (ctl: IOpenController) => {\n    const element = cssModalTooltip(\n      domCreator(ctl),\n    );\n    return element;\n  }, options);\n}\n\n/* CSS styled components */\n\nexport const cssModalTooltip = styled(cssMenuElem, `\n  padding: 16px 24px;\n  background: ${theme.modalBg};\n  border-radius: 3px;\n  outline: none;\n  & > div {\n    outline: none;\n  }\n`);\n\nexport const cssModalTopPadding = styled(\"div\", `\n  padding-top: var(--css-modal-dialog-padding-vertical);\n`);\n\nexport const cssModalBottomPadding = styled(\"div\", `\n  padding-bottom: var(--css-modal-dialog-padding-vertical);\n`);\n\nexport const cssModalHorizontalPadding = styled(\"div\", `\n  padding-left: var(--css-modal-dialog-padding-horizontal);\n  padding-right: var(--css-modal-dialog-padding-horizontal);\n`);\n\n// For centering, we use 'margin: auto' on the flex item instead of 'justify-content: center' on\n// the flex container, to ensure the full item can be scrolled in case of overflow.\n// See https://stackoverflow.com/a/33455342/328565\n//\n// If you want to control the padding yourself, use the cssModalTopPadding and other classes above and add -full-body\n// variant to the modal.\nexport const cssModalDialog = styled(\"div\", `\n  --css-modal-dialog-padding-horizontal: 64px;\n  --css-modal-dialog-padding-vertical: 40px;\n  background-color: ${theme.modalBg};\n  min-width: 428px;\n  color: ${theme.darkText};\n  margin: auto;\n  border-radius: 3px;\n  box-shadow: 0 2px 18px 0 ${theme.modalInnerShadow}, 0 0 1px 0 ${theme.modalOuterShadow};\n  padding: var(--css-modal-dialog-padding-vertical) var(--css-modal-dialog-padding-horizontal);\n  outline: none;\n\n  &-normal {\n    max-width: 480px;\n  }\n  &-fixed-wide {\n    width: 600px;\n  }\n  &-collapsing {\n    transition-property: opacity, transform;\n    transition-duration: 0.4s;\n    transition-timing-function: ease-in-out;\n  }\n  @media ${mediaSmall} {\n    & {\n      width: unset;\n      min-width: unset;\n      --css-modal-dialog-padding-horizontal: 16px;\n      --css-modal-dialog-padding-vertical: 24px;\n    }\n  }\n  &-full-body {\n    padding: 0;\n  }\n`);\n\nexport const cssModalTitle = styled(\"div\", `\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n  color: ${theme.text};\n  margin: 0 0 16px 0;\n  line-height: 32px;\n  overflow-wrap: break-word;\n`);\n\nexport const cssModalSubheading = styled(\"div\", `\n  font-size: ${vars.xlargeFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n  color: ${theme.text};\n  margin: 0 0 16px 0;\n  line-height: 32px;\n  overflow-wrap: break-word;\n`);\n\nexport const cssModalBody = styled(\"div\", `\n  color: ${theme.text};\n  margin: 16px 0;\n  overflow-wrap: break-word;\n`);\n\nexport const cssModalButtons = styled(\"div\", `\n  margin: 40px 0 0 0;\n\n  & > button,\n  & > .${cssButton.className} {\n    margin: 0 8px 0 0;\n  }\n`);\n\nexport const cssModalCloseButton = styled(\"div\", `\n  align-self: flex-end;\n  margin: -8px;\n  padding: 4px;\n  border-radius: 4px;\n  cursor: pointer;\n  --icon-color: ${theme.modalCloseButtonFg};\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssFadeIn = keyframes(`\n  from {background-color: transparent}\n`);\n\nconst cssFadeOut = keyframes(`\n  from {background-color: ${theme.modalBackdrop}}\n`);\n\nconst cssModalBacker = styled(\"div\", `\n  position: fixed;\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n  height: 100%;\n  top: 0;\n  left: 0;\n  padding: 16px;\n  z-index: ${vars.modalZIndex};\n  background-color: ${theme.modalBackdrop};\n  overflow-y: auto;\n  animation-name: ${cssFadeIn};\n  animation-duration: 0.4s;\n\n  &-collapsing {\n    animation-name: ${cssFadeOut};\n    background-color: transparent;\n  }\n`);\n\nexport const cssSpinner = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  height: 80px;\n  margin: auto;\n`);\n\nconst cssModalSpinner = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n`);\n\nconst cssFadeInFromTop = keyframes(`\n  from {top: -250px; opacity: 0}\n  to {top: 0; opacity: 1}\n`);\n\nexport const cssAnimatedModal = styled(\"div\", `\n  animation-name: ${cssFadeInFromTop};\n  animation-duration: 0.4s;\n  position: relative;\n`);\n\nconst cssDontShowAgainCheckbox = styled(labeledSquareCheckbox, `\n  line-height: normal;\n`);\n\nconst cssDontShowAgainCheckboxLabel = styled(\"span\", `\n  color: ${theme.lightText};\n`);\n"
  },
  {
    "path": "app/client/ui2018/pages.ts",
    "content": "import { isDesktop } from \"app/client/lib/browserInfo\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { cssEditorInput } from \"app/client/ui/HomeLeftPane\";\nimport { hoverTooltip, overflowTooltip } from \"app/client/ui/tooltips\";\nimport { itemHeader, itemHeaderWrapper, treeViewContainer } from \"app/client/ui/TreeViewComponentCss\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu, menuDivider, menuItem, menuItemAsync, menuText } from \"app/client/ui2018/menus\";\nimport { unstyledButton, unstyledLink } from \"app/client/ui2018/unstyled\";\n\nimport { Computed, dom, domComputed, DomElementArg, makeTestId, observable, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"pages\");\n\nconst testId = makeTestId(\"test-docpage-\");\n\nexport interface PageOptions {\n  onRename: (name: string) => Promise<void>;\n  onRemove: () => void;\n  onDuplicate: () => void;\n  isRemoveDisabled: () => boolean;\n  isReadonly: Observable<boolean>;\n  isCollapsed: Observable<boolean>;\n  onCollapse: (value: boolean) => void;\n  isCollapsedByDefault: Computed<boolean>;\n  onCollapseByDefault: (value: boolean) => Promise<void>;\n  hasSubPages: () => boolean;\n  href: DomElementArg;\n}\n\nfunction isTargetSelected(target: HTMLElement) {\n  const parentItemHeader = target.closest(\".\" + itemHeader.className);\n  return parentItemHeader ? parentItemHeader.classList.contains(\"selected\") : false;\n}\n\n// build the dom for a document page entry. It shows an icon (for now the first letter of the name,\n// but later we'll support user selected icon), the name and a dots menu containing a \"Rename\" and\n// \"Remove\" entries. Clicking \"Rename\" turns the page name into an editable input, which then call\n// the options.onRename callback with the new name. Setting options.onRemove to undefined disables\n// the item in the menu.\nexport function buildPageDom(name: Observable<string>, options: PageOptions, ...args: DomElementArg[]) {\n  const {\n    onRename,\n    onRemove,\n    onDuplicate,\n    isRemoveDisabled,\n    isReadonly,\n    isCollapsed,\n    onCollapse,\n    isCollapsedByDefault,\n    onCollapseByDefault,\n    hasSubPages,\n    href,\n  } = options;\n  const isRenaming = observable(false);\n  const pageMenu = () => [\n    menuItem(\n      () => isRenaming.set(true),\n      t(\"Rename\"),\n      dom.cls(\"disabled\", isReadonly),\n      testId(\"rename\"),\n    ),\n    menuItem(\n      onRemove,\n      t(\"Remove\"),\n      dom.cls(\"disabled\", use => use(isReadonly) || isRemoveDisabled()),\n      testId(\"remove\"),\n    ),\n    menuItem(\n      onDuplicate,\n      t(\"Duplicate page\"),\n      dom.cls(\"disabled\", isReadonly),\n      testId(\"duplicate\"),\n    ),\n    dom.maybe(hasSubPages(), () => [\n      menuDivider(),\n      menuItemAsync(\n        () => onCollapse(false),\n        t(\"Expand {{maybeDefault}}\", {\n          maybeDefault: dom.maybe(\n            use => !use(isCollapsedByDefault),\n            () => t(\"(default)\"),\n          ),\n        }),\n        dom.cls(\"disabled\", use => !use(isCollapsed)),\n        testId(\"expand\"),\n      ),\n      menuItemAsync(\n        () => onCollapse(true),\n        t(\"Collapse {{maybeDefault}}\", {\n          maybeDefault: dom.maybe(isCollapsedByDefault, () => t(\"(default)\")),\n        }),\n        dom.cls(\"disabled\", isCollapsed),\n        testId(\"collapse\"),\n      ),\n      menuItemAsync(\n        async () => { await onCollapseByDefault(true); },\n        t(\"Set default: Collapse\"),\n        dom.show(use => !use(isCollapsedByDefault)),\n        testId(\"collapse-by-default\"),\n      ),\n      menuItemAsync(\n        async () => { await onCollapseByDefault(false); },\n        t(\"Set default: Expand\"),\n        dom.show(isCollapsedByDefault),\n        testId(\"expand-by-default\"),\n      ),\n    ]),\n    dom.maybe(options.isReadonly, () =>\n      menuText(t(\"You do not have edit access to this document\")),\n    ),\n  ];\n  let pageElem: HTMLElement;\n\n  // toggle '-renaming' class on the item's header. This is useful to make the background remain the\n  // same while opening dots menu\n  const lis = isRenaming.addListener(() => {\n    const parent = pageElem.closest(\".\" + itemHeader.className);\n    if (parent) {\n      dom.clsElem(parent, itemHeader.className + \"-renaming\", isRenaming.get());\n    }\n  });\n\n  const splitName = Computed.create(null, name, (use, _name) => splitPageInitial(_name));\n\n  return pageElem = dom(\n    \"div\",\n    dom.autoDispose(lis),\n    dom.autoDispose(splitName),\n    domComputed(use => use(name) === \"\", blank => blank ? dom(\"div\", \"-\") :\n      domComputed(isRenaming, isrenaming => (\n        isrenaming ?\n          cssPageItem(\n            cssPageInitial(\n              testId(\"initial\"),\n              dom.text(use => use(splitName).initial),\n              cssPageInitial.cls(\"-emoji\", use => use(splitName).hasEmoji),\n            ),\n            cssEditorInput(\n              {\n                initialValue: name.get() || \"\",\n                save: async val => onRename(val),\n                close: () => isRenaming.set(false),\n              },\n              testId(\"editor\"),\n              dom.on(\"mousedown\", ev => ev.stopPropagation()),\n              dom.on(\"click\", (ev) => { ev.stopPropagation(); ev.preventDefault(); }),\n            ),\n            // Note that we don't pass extra args when renaming is on, because they usually includes\n            // mouse event handlers interfering with input editor and yields wrong behavior on\n            // firefox.\n          ) :\n          cssPageItem(\n            cssPageLink(\n              testId(\"link\"),\n              href,\n              cssPageInitial(\n                testId(\"initial\"),\n                dom.text(use => use(splitName).initial),\n                cssPageInitial.cls(\"-emoji\", use => use(splitName).hasEmoji),\n              ),\n              cssPageName(\n                dom.text(use => use(splitName).displayName),\n                testId(\"label\"),\n                dom.on(\"click\", ev => isTargetSelected(ev.target as HTMLElement) && isRenaming.set(true)),\n                overflowTooltip(),\n              ),\n            ),\n            cssPageMenuTrigger(\n              dom.attr(\"aria-label\", use => t(\"context menu - {{- pageName }}\", { pageName: use(name) })),\n              cssPageMenuIcon(\"Dots\"),\n              menu(pageMenu, { placement: \"bottom-start\", parentSelectorToMark: \".\" + itemHeader.className }),\n              dom.on(\"click\", (ev) => { ev.stopPropagation(); ev.preventDefault(); }),\n\n              // Let's prevent dragging to start when un-intentionally holding the mouse down on '...' menu.\n              dom.on(\"mousedown\", ev => ev.stopPropagation()),\n              testId(\"dots\"),\n            ),\n            // Prevents the default dragging behaviour that Firefox support for links which conflicts\n            // with our own dragging pages.\n            dom.on(\"dragstart\", ev => ev.preventDefault()),\n            args,\n          )\n      )),\n    ));\n}\n\nexport function buildCensoredPage() {\n  return cssPageItem(\n    cssPageInitial(\n      testId(\"initial\"),\n      dom.text(\"C\"),\n    ),\n    cssCensoredPageName(\n      dom.text(\"CENSORED\"),\n      testId(\"label\"),\n    ),\n    hoverTooltip(\"This page is censored due to access rules.\"),\n  );\n}\n\n// This crazy expression matches all \"possible emoji\" and comes from a very official source:\n// https://unicode.org/reports/tr51/#EBNF_and_Regex (linked from\n// https://stackoverflow.com/a/68146409/328565). It is processed from the original by replacing \\x\n// with \\u, removing whitespace, and factoring out a long subexpression.\nconst emojiPart = /(?:\\p{RI}\\p{RI}|\\p{Emoji}(?:\\p{EMod}|\\u{FE0F}\\u{20E3}?|[\\u{E0020}-\\u{E007E}]+\\u{E007F})?)/u;\nconst pageInitialRegex = new RegExp(`^${emojiPart.source}(?:\\\\u{200D}${emojiPart.source})*`, \"u\");\n\n// Divide up the page name into an \"initial\" and \"displayName\", where an emoji initial, if\n// present, is omitted from the displayName, but a regular character used as the initial is kept.\nexport function splitPageInitial(name: string): { initial: string, displayName: string, hasEmoji: boolean } {\n  const m = name.match(pageInitialRegex);\n  // A common false positive is digits; those match \\p{Emoji} but should not be considered emojis.\n  // (Other matching non-emojis include characters like '*', but those are nicer to show as emojis.)\n  if (m && !/^\\d$/.test(m[0])) {\n    return { initial: m[0], displayName: name.slice(m[0].length).trim(), hasEmoji: true };\n  } else {\n    return { initial: Array.from(name)[0], displayName: name.trim(), hasEmoji: false };\n  }\n}\n\nconst cssPageItem = styled(\"div\", `\n  position: relative;\n  display: flex;\n  flex-direction: row;\n  height: 28px;\n  align-items: center;\n  flex-grow: 1;\n`);\n\nconst notClosedTreeViewContainer = `.${treeViewContainer.className}:not(.${treeViewContainer.className}-close)`;\n\nconst cssPageLink = styled(unstyledLink, `\n  display: flex;\n  align-items: center;\n  height: 100%;\n  flex-grow: 1;\n  max-width: 100%;\n  ${notClosedTreeViewContainer} .${cssPageItem.className}:focus-within &,\n  ${notClosedTreeViewContainer} .${cssPageItem.className}:has(.weasel-popup-open) & {\n    max-width: calc(100% - 28px);\n  }\n  @media ${onHoverSupport(true)} {\n    ${notClosedTreeViewContainer} .${itemHeaderWrapper.className}-not-dragging:hover & {\n      max-width: calc(100% - 28px);\n    }\n  }\n  @media ${onHoverSupport(false)} {\n    ${notClosedTreeViewContainer} .${itemHeaderWrapper.className}-not-dragging > .${itemHeader.className}.selected & {\n      max-width: calc(100% - 28px);\n    }\n  }\n  .${treeViewContainer.className}-close & {\n    display: flex;\n    justify-content: center;\n  }\n`);\n\nconst cssPageInitial = styled(\"div\", `\n  flex-shrink: 0;\n  color: ${theme.pageInitialsFg};\n  border-radius: 3px;\n  background-color: ${theme.pageInitialsBg};\n  width: 20px;\n  height: 20px;\n  margin-right: 8px;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n\n  &-emoji {\n    background-color: ${theme.pageInitialsEmojiBg};\n    box-shadow: 0 0 0 1px ${theme.pageInitialsEmojiOutline};\n    font-size: 15px;\n    overflow: hidden;\n    color: ${theme.text};\n  }\n  .${treeViewContainer.className}-close & {\n    margin-right: 0;\n  }\n  .${itemHeader.className}.selected &-emoji {\n    box-shadow: none;\n  }\n`);\n\nconst cssPageName = styled(\"div\", `\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  flex-grow: 1;\n  .${treeViewContainer.className}-close & {\n    display: none;\n  }\n`);\n\nconst cssCensoredPageName = styled(cssPageName, `\n  color: ${theme.disabledPageFg};\n`);\n\nfunction onHoverSupport(yesNo: boolean) {\n  // On desktop, we show page menu button on hover over page link. This isn't usable on mobile,\n  // and interferes with clicks on iOS; so instead we show the button when the page is selected.\n  //\n  // We can achieve the distinction in CSS with\n  //    @media (hover: hover) { ... }\n  //    @media (hover: none) { ... }\n  //\n  // Except that it interferes with tests, because headless Chrome test on Linux incorrectly\n  // matches (hover: none). To work around it, we assume desktop browsers can always hover,\n  // and use trivial match-all/match-none media queries on desktop browsers.\n  if (isDesktop()) {\n    return yesNo ? \"all\" : \"not all\";\n  } else {\n    return yesNo ? \"(hover: hover)\" : \"(hover: none)\";\n  }\n}\n\nconst cssPageMenuTrigger = styled(unstyledButton, `\n  position: relative;\n  z-index: 2;\n  cursor: default;\n  display: none;\n  margin-right: 4px;\n  margin-left: auto;\n  line-height: 0px;\n  border-radius: 3px;\n  height: 24px;\n  width: 24px;\n  padding: 4px;\n\n  .${treeViewContainer.className}-close & {\n    display: none !important;\n  }\n  .${cssPageItem.className}:focus-within &, &.weasel-popup-open {\n    display: block;\n  }\n  @media ${onHoverSupport(true)} {\n    .${itemHeaderWrapper.className}-not-dragging:hover & {\n      display: block;\n    }\n  }\n  @media ${onHoverSupport(false)} {\n    .${itemHeaderWrapper.className}-not-dragging > .${itemHeader.className}.selected & {\n      display: block;\n    }\n  }\n  .${itemHeaderWrapper.className}-not-dragging &:hover, &.weasel-popup-open {\n    background-color: ${theme.pageOptionsHoverBg};\n  }\n  .${itemHeaderWrapper.className}-not-dragging > .${itemHeader.className}.selected &:hover,\n  .${itemHeaderWrapper.className}-not-dragging > .${itemHeader.className}.selected &.weasel-popup-open {\n    background-color: ${theme.pageOptionsSelectedHoverBg};\n  }\n\n  .${itemHeader.className}.weasel-popup-open, .${itemHeader.className}-renaming {\n    background-color: ${theme.pageHoverBg};\n  }\n`);\n\nconst cssPageMenuIcon = styled(icon, `\n  background-color: ${theme.pageOptionsFg};\n  .${itemHeader.className}.selected & {\n    background-color: ${theme.pageOptionsHoverFg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/popups.ts",
    "content": "import { cssButton } from \"app/client/ui2018/buttons\";\nimport { mediaSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\n\nimport { Disposable, dom, DomElementArg, styled } from \"grainjs\";\n\ninterface IPopupController extends Disposable {\n  /** Close the popup. */\n  close(): void;\n}\n\n/**\n * A controller for an open popup.\n *\n * Callers are responsible for providing a suitable close callback (`_doClose`).\n * Typically, this callback should remove the popup from the DOM and run any of\n * its disposers.\n *\n * Used by popup DOM creator functions to close popups on certain interactions,\n * like clicking a dismiss button from the body of the popup.\n */\nclass PopupController extends Disposable implements IPopupController {\n  constructor(\n    private _doClose: () => void,\n  ) {\n    super();\n  }\n\n  public close(): void {\n    this._doClose();\n  }\n}\n\n/**\n * A simple card popup that's shown in the bottom-right corner of the screen.\n *\n * Disposed whenever the `trigger` element is disposed.\n */\nexport function cardPopup(\n  triggerElement: Element,\n  createFn: (ctl: PopupController) => DomElementArg,\n): void {\n  // Closes this popup, removing it from the DOM.\n  const closePopup = () => {\n    document.body.removeChild(popupDom);\n    // Ensure we run the disposers for the DOM contained in the popup.\n    dom.domDispose(popupDom);\n  };\n\n  const popupDom = cssPopupCard(\n    dom.create((owner) => {\n      // Create a controller for this popup. We'll pass it into `createFn` so that\n      // the body of the popup can close this popup, if needed.\n      const ctl = PopupController.create(owner, closePopup);\n      return dom(\"div\",\n        createFn(ctl),\n        testId(\"popup-card-content\"),\n      );\n    }),\n    testId(\"popup-card\"),\n  );\n\n  // Show the popup by appending it to the DOM.\n  document.body.appendChild(popupDom);\n\n  // If the trigger element is disposed, close this popup.\n  dom.onDisposeElem(triggerElement, closePopup);\n}\n\nconst cssPopupCard = styled(\"div\", `\n  position: absolute;\n  right: 16px;\n  bottom: 16px;\n  margin-left: 16px;\n  max-width: 428px;\n  padding: 32px;\n  background-color: ${theme.popupBg};\n  box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};\n  outline: none;\n\n  @media ${mediaSmall} {\n    & {\n      padding: 24px;\n    }\n  }\n`);\n\nexport const cssPopupTitle = styled(\"div\", `\n  font-size: ${vars.xxxlargeFontSize};\n  font-weight: ${vars.headerControlTextWeight};\n  color: ${theme.text};\n  margin: 0 0 16px 0;\n  line-height: 32px;\n  overflow-wrap: break-word;\n`);\n\nexport const cssPopupBody = styled(\"div\", `\n  color: ${theme.text};\n`);\n\nexport const cssPopupButtons = styled(\"div\", `\n  margin: 24px 0 0 0;\n\n  & > button,\n  & > .${cssButton.className} {\n    margin: 0 8px 0 0;\n  }\n`);\n\nexport const cssPopupCloseButton = styled(\"div\", `\n  align-self: flex-end;\n  border-radius: 4px;\n  cursor: pointer;\n  padding: 4px;\n  --icon-color: ${theme.popupCloseButtonFg};\n\n  &:hover {\n    background-color: ${theme.hover};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/radio.ts",
    "content": "import { theme } from \"app/client/ui2018/cssVars\";\n\nimport { styled } from \"grainjs\";\n\nexport const cssRadioInput = styled(\"input\", `\n  appearance: none;\n  width: 16px;\n  height: 16px;\n  margin: 0px !important;\n  border-radius: 50%;\n  background-clip: content-box;\n  border: 1px solid ${theme.checkboxBorder};\n  background-color: ${theme.checkboxBg};\n  flex-shrink: 0;\n  &:hover {\n    border: 1px solid ${theme.checkboxBorderHover};\n  }\n  &:disabled {\n    background-color: 1px solid ${theme.checkboxDisabledBg};\n  }\n  &:checked {\n    padding: 2px;\n    background-color: ${theme.controlPrimaryBg};\n    border: 1px solid ${theme.controlPrimaryBg};\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/search.ts",
    "content": "/**\n * Search icon that expands to a search bar and collapse on 'x' or blur.\n * Takes a `SearchModel` that controls the search behavior.\n */\nimport { allCommands, createGroup } from \"app/client/components/commands\";\nimport { Panel, RegionFocusSwitcher } from \"app/client/components/RegionFocusSwitcher\";\nimport { modKeyProp } from \"app/client/lib/browserInfo\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/AppModel\";\nimport { SearchModel } from \"app/client/models/SearchModel\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { cssHoverCircle, cssTopBarBtn } from \"app/client/ui/TopBarCss\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { mediaSmall, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { unstyledButton } from \"app/client/ui2018/unstyled\";\n\nimport { dom, input, styled } from \"grainjs\";\nimport { noTestId, TestId } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\n\nexport * from \"app/client/models/SearchModel\";\n\nconst t = makeT(\"search\");\n\nconst EXPAND_TIME = 0.5;\n\nconst searchWrapper = styled(\"div\", `\n  display: flex;\n  flex: initial;\n  align-items: center;\n  box-sizing: border-box;\n  border: 1px solid transparent;\n  padding: 0px 16px;\n  width: 50px;\n  height: 100%;\n  max-height: 50px;\n  transition: width 0.4s;\n  position: relative;\n  &-expand {\n    width: 100% !important;\n    border: 1px solid ${theme.searchBorder};\n  }\n  @media ${mediaSmall} {\n    & {\n      width: 32px;\n      padding: 0px;\n    }\n    &-expand {\n      margin-left: 12px;\n    }\n  }\n`);\n\nconst expandedSearch = styled(\"div\", `\n  display: none;\n  flex-grow: 0;\n  align-items: center;\n  width: 0;\n  opacity: 0;\n  align-self: stretch;\n  transition: width ${EXPAND_TIME}s, opacity ${EXPAND_TIME / 2}s ${EXPAND_TIME / 2}s;\n  .${searchWrapper.className}-expand > & {\n    display: flex;\n    width: auto;\n    flex-grow: 1;\n    opacity: 1;\n  }\n`);\n\nconst searchInput = styled(input, `\n  background-color: ${theme.topHeaderBg};\n  color: ${theme.inputFg};\n  outline: none;\n  border: none;\n  margin: 0;\n  padding: 0;\n  padding-left: 4px;\n  box-sizing: border-box;\n  align-self: stretch;\n  width: 0;\n  transition: width ${EXPAND_TIME}s;\n  .${searchWrapper.className}-expand & {\n    width: 100%;\n  }\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\nconst cssArrowBtn = styled(unstyledButton, `\n  font-size: 14px;\n  padding: 3px;\n  cursor: pointer;\n  margin: 2px 4px;\n  visibility: hidden;\n  width: 24px;\n  height: 24px;\n  background-color: ${theme.searchPrevNextButtonBg};\n  --icon-color: ${theme.searchPrevNextButtonFg};\n  border-radius: 3px;\n  text-align: center;\n  display: flex;\n  align-items: center;\n\n  .${searchWrapper.className}-expand & {\n    visibility: visible;\n  }\n`);\n\nconst cssCloseBtnContainer = styled(unstyledButton, `\n  margin-left: 4px;\n  flex-shrink: 0;\n`);\n\nconst cssCloseBtn = styled(icon, `\n  cursor: pointer;\n  background-color: ${theme.controlFg};\n`);\n\nconst cssLabel = styled(\"span\", `\n  font-size: ${vars.smallFontSize};\n  color: ${theme.lightText};\n  white-space: nowrap;\n  margin-right: 12px;\n`);\n\nconst cssOptions = styled(\"div\", `\n  background: ${theme.topHeaderBg};\n  position: absolute;\n  right: 0;\n  top: 48px;\n  z-index: ${vars.menuZIndex};\n  padding: 2px 4px;\n  overflow: hidden;\n  white-space: nowrap;\n`);\n\nconst cssShortcut = styled(\"span\", `\n  color: ${theme.lightText};\n`);\n\nconst wrapperClass = \"grist-doc-search-bar\";\n\nexport function searchBar(model: SearchModel, testId: TestId = noTestId, regionFocusSwitcher?: RegionFocusSwitcher) {\n  let regionIdOnOpen: Panel | undefined;\n\n  // when the search model triggers a page change to show the current match, the RFS region changes back to \"main\",\n  // while we want to stay focused on the searchbar if its open: deal with that here.\n  let focusedSearchElement: HTMLElement | undefined;\n  model.onPageChange(() => {\n    if (model.isOpen.get()) {\n      regionFocusSwitcher?.focusRegion(\"top\");\n      if (focusedSearchElement) {\n        focusedSearchElement.focus();\n      } else {\n        inputElem.focus();\n      }\n    }\n  });\n\n  let hasOutsideClicksListener = false;\n  const onOutsideClicks = (event: MouseEvent) => {\n    if (event.target instanceof HTMLElement && !event.target.closest(`.${wrapperClass}`)) {\n      toggleMenu(false);\n    }\n  };\n\n  const cleanupOutsideClicksListener = () => {\n    if (hasOutsideClicksListener) {\n      document.body.removeEventListener(\"click\", onOutsideClicks);\n      hasOutsideClicksListener = false;\n    }\n  };\n\n  const toggleMenu = debounce((_value?: boolean) => {\n    model.isOpen.set(_value === undefined ? !model.isOpen.get() : _value);\n\n    // when we open the searchbar: focus the input, and make sure outside clicks will close the searchbar\n    if (model.isOpen.get()) {\n      regionIdOnOpen = regionFocusSwitcher?.getRegionId();\n      regionFocusSwitcher?.focusRegion(\"top\");\n      inputElem.focus();\n      inputElem.select();\n      if (!hasOutsideClicksListener) {\n        document.body.addEventListener(\"click\", onOutsideClicks);\n        hasOutsideClicksListener = true;\n      }\n    // when we close the searchbar: focus back where we were and cleanup\n    } else {\n      if (regionIdOnOpen === \"main\") {\n        regionFocusSwitcher?.focusRegion(\"main\");\n      } else {\n        buttonElem.focus();\n      }\n      regionIdOnOpen = undefined;\n      focusedSearchElement = undefined;\n      cleanupOutsideClicksListener();\n    }\n  }, 100);\n\n  const buttonElem = cssHoverCircle(\n    dom.on(\"click\", () => toggleMenu(true)),\n    cssTopBarBtn(\"Search\",\n      testId(\"icon\"),\n      hoverTooltip(t(\"Search\"), { key: \"topBarBtnTooltip\" }),\n    ),\n  );\n\n  const commandGroup = createGroup({\n    find: () => toggleMenu(true),\n    // On Mac, Firefox has a default behaviour witch causes to close the search bar on Cmd+g and\n    // Cmd+shirt+G. Returning false is a Mousetrap convenience which prevents that.\n    findNext: () => { model.findNext().catch(reportError); return false; },\n    findPrev: () => { model.findPrev().catch(reportError); return false; },\n  }, null, true);\n\n  const inputElem: HTMLInputElement = searchInput(model.value, { onInput: true },\n    {\n      \"type\": \"text\",\n      \"placeholder\": t(\"Search in document\"),\n      \"aria-label\": t(\"Search in document\"),\n    },\n    dom.on(\"focus\", () => {\n      focusedSearchElement = inputElem;\n    }),\n    dom.onKeyDown({\n      Enter: async (ev) => {\n        // If the user is pressing the mod key, act like we trigger the \"closeSearchBar\" command described in\n        // commandList.\n        // We don't actually register this as the closeSearchBar command,\n        // as it's a bit troublesome to want to both have findNext and findPrev be active all the time,\n        // while at the same time have closeSearchBar be active only when model.isOpen\n        if (ev[modKeyProp()] && !ev.shiftKey) {\n          toggleMenu(false);\n          return;\n        }\n        return ev.shiftKey ? model.findPrev() : model.findNext();\n      },\n    }),\n    commandGroup.attach(),\n  );\n\n  return searchWrapper(\n    testId(\"wrapper\"),\n    searchWrapper.cls(\"-expand\", model.isOpen),\n    dom.cls(wrapperClass),\n    dom.autoDispose(commandGroup),\n    dom.onKeyDown({\n      // The $ indicates to grainjs we don't want to stop propagation of the event here.\n      // This handles the case where we are kb-focused on the search icon and press Esc:\n      // we want the RegionFocusSwitcher to trigger its Escape handler correctly.\n      Escape$: () => {\n        if (model.isOpen.get()) {\n          toggleMenu(false);\n        }\n      },\n    }),\n    dom.onDispose(() => {\n      toggleMenu.cancel(); // Make sure we don't attempt to call delayed callback after disposal.\n      cleanupOutsideClicksListener();\n    }),\n    buttonElem,\n    expandedSearch(\n      testId(\"input\"),\n      inputElem,\n      cssOptions(\n        labeledSquareCheckbox(\n          model.multiPage,\n          dom.text(model.allLabel),\n          // Prevent focus from being stolen from the input when clicking the checkbox itself\n          dom.on(\"mousedown\", event => event.preventDefault()),\n        ),\n        // Keep focus on the input when clicking the checkbox text label\n        dom.onMatch(\"label\", \"mouseup\", () => setTimeout(() => inputElem.focus(), 0)),\n        testId(\"option-all-pages\"),\n      ),\n      dom.domComputed((use) => {\n        const noMatch = use(model.noMatch);\n        const isEmpty = use(model.isEmpty);\n        if (isEmpty) { return null; }\n        if (noMatch) { return cssLabel(t(\"No results\")); }\n        return [\n          cssArrowBtn(\n            icon(\"Dropdown\"),\n            testId(\"next\"),\n            // Prevent focus from being stolen from the input\n            dom.on(\"mousedown\", event => event.preventDefault()),\n            dom.on(\"focus\", (event) => {\n              focusedSearchElement = event.target as HTMLElement;\n            }),\n            dom.on(\"click\", () => model.findNext()),\n            hoverTooltip(\n              [\n                t(\"Find Next \"),\n                cssShortcut(`(${[\"Enter\", allCommands.findNext.humanKeys].join(\", \")})`),\n              ],\n              { key: \"searchArrowBtnTooltip\" },\n            ),\n          ),\n          cssArrowBtn(\n            icon(\"DropdownUp\"),\n            testId(\"prev\"),\n            // Prevent focus from being stolen from the input\n            dom.on(\"mousedown\", event => event.preventDefault()),\n            dom.on(\"focus\", (event) => {\n              focusedSearchElement = event.target as HTMLElement;\n            }),\n            dom.on(\"click\", () => model.findPrev()),\n            hoverTooltip(\n              [\n                t(\"Find Previous \"),\n                cssShortcut(`(${[\"Shift + Enter\", allCommands.findPrev.humanKeys].join(\", \")})`),\n              ],\n              { key: \"searchArrowBtnTooltip\" },\n            ),\n          ),\n        ];\n      }),\n      cssCloseBtnContainer(\n        testId(\"close\"),\n        { \"aria-label\": t(\"Close search bar\") },\n        dom.on(\"click\", () => toggleMenu(false)),\n        cssCloseBtn(\"CrossSmall\"),\n      ),\n    ),\n  );\n}\n"
  },
  {
    "path": "app/client/ui2018/select.ts",
    "content": "import { theme, vars } from \"app/client/ui2018/cssVars\";\n\nimport { styled } from \"grainjs\";\n\n// Import popweasel so that the styles we define here are included later in CSS, and take priority\n// over popweasel styles, when used together.\nimport \"popweasel\";\n\n/**\n * Style for a select dropdown button.\n *\n * This incorporates styling from popweasel's select, so that it can be used to style buttons that\n * don't use it.\n */\nexport const cssSelectBtn = styled(\"div\", `\n  position: relative;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  width: 100%;\n  height: 30px;\n  line-height: 16px;\n  background-color: ${theme.selectButtonBg};\n  color: ${theme.selectButtonFg};\n  --icon-color: ${theme.selectButtonFg};\n  font-size: ${vars.mediumFontSize};\n  padding: 5px;\n  border: 1px solid ${theme.selectButtonBorder};\n  border-radius: 3px;\n  cursor: pointer;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  user-select: none;\n  -moz-user-select: none;\n  outline: none;\n\n  &:focus {\n    outline: none;\n    box-shadow: 0px 0px 2px 2px #5E9ED6;\n  }\n\n  &.disabled, &-disabled {\n    opacity: 0.4;\n    pointer-events: none;\n    cursor: default;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/stretchedLink.ts",
    "content": "/**\n * A link that has a clickable area that spans the entire containing block.\n *\n * Don't forget to apply the `position: relative` CSS property to the parent block you want the link to cover.\n *\n * @see https://getbootstrap.com/docs/5.3/helpers/stretched-link\n */\nimport { styled } from \"grainjs\";\n\nexport const stretchedLink = styled(\"a\", `\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n\n  &::after {\n    position: absolute;\n    inset: 0;\n    content: '';\n    z-index: 1;\n  }\n`);\n"
  },
  {
    "path": "app/client/ui2018/tabs.ts",
    "content": "import { makeTestId } from \"app/client/lib/domUtils\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { isNarrowScreenObs, theme } from \"app/client/ui2018/cssVars\";\nimport { IconName } from \"app/client/ui2018/IconList\";\nimport { icon as cssIcon } from \"app/client/ui2018/icons\";\nimport { IGristUrlState } from \"app/common/gristUrls\";\nimport { useBindable } from \"app/common/gutil\";\n\nimport { BindableValue, dom, IDomArgs, MaybeObsArray, styled, UseCBOwner } from \"grainjs\";\n\nconst testId = makeTestId(\"test-component-tabs-\");\n\nexport interface TabProps {\n  /** Label to show */\n  label: string;\n  /* Optional id to use for selected comparison. If not provided label will be compared */\n  id?: string;\n  /** Icon to show */\n  icon?: IconName;\n  /** Function to call when tab is clicked */\n  onClick?: () => void;\n  /** Grist Link state to switch to when tab is clicked. */\n  link?: IGristUrlState;\n}\n\nexport function buildTabs(\n  tabs: MaybeObsArray<TabProps>,\n  selected: BindableValue<string | null | undefined>,\n  ...args: IDomArgs<HTMLDivElement>\n) {\n  const isSelected = (tab: TabProps) => (use: UseCBOwner) => useBindable(use, selected) === (tab.id ?? tab.label);\n  return cssTabs(\n    dom.forEach(tabs, tab => cssTab(\n      cssIconAndLabel(!tab.icon ? null : cssTabIcon(tab.icon, dom.hide(isNarrowScreenObs())),\n\n        // The combination with space makes the label as wide as its bold version,\n        // to avoid slight shifts of other labels when switching tabs.\n        dom(\"div\", tab.label, cssBoldLabelSpacer(tab.label))),\n\n      cssTab.cls(\"-selected\", isSelected(tab)),\n\n      tab.onClick && dom.on(\"click\", tab.onClick.bind(tab)),\n\n      tab.link && urlState().setLinkUrl(tab.link, { replace: true }),\n\n      testId(\"tab\"),\n      testId(\"tab-selected\", isSelected(tab)),\n    )),\n    testId(\"list\"),\n    ...args,\n  );\n}\n\nexport const cssTabs = styled(\"div\", `\n  flex-grow: 1;\n  display: flex;\n  border-bottom: 1px solid ${theme.tableBodyBorder};\n  user-select: none;\n`);\n\nexport const cssTab = styled(\"a\", `\n  display: block;\n  padding: 8px 16px;\n  color: ${theme.mediumText};\n  --icon-color: ${theme.lightText};\n  font-size: 14px;\n  font-weight: 500;\n  text-decoration: none;\n  cursor: pointer;\n\n  &:hover, &:focus {\n    color: ${theme.mediumText};\n    text-decoration: none;\n  }\n\n  &-selected {\n    --icon-color: ${theme.controlFg};\n    font-weight: 700;\n    border-bottom: 2px solid ${theme.controlFg};\n    margin-bottom: -1px;\n  }\n`);\n\nconst cssIconAndLabel = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  column-gap: 8px;\n`);\n\nconst cssBoldLabelSpacer = styled(\"div\", `\n  font-weight: bold;\n  height: 1px;\n  color: transparent;\n  overflow: hidden;\n  visibility: hidden;\n`);\n\nconst cssTabIcon = styled(cssIcon, `\n  width: 20px;\n  height: 20px;\n`);\n"
  },
  {
    "path": "app/client/ui2018/theme.ts",
    "content": "import { getOrCreateStyleElement } from \"app/client/lib/getOrCreateStyleElement\";\nimport { createPausableObs, PausableObservable } from \"app/client/lib/pausableObs\";\nimport { getStorage } from \"app/client/lib/storage\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { isFeatureEnabled } from \"app/common/gristUrls\";\nimport {\n  components,\n  componentsCssMapping,\n  convertThemeKeysToCssVars,\n  legacyVarsMapping,\n  Theme,\n  ThemeAppearance,\n  ThemeName,\n  themeNameAppearances,\n  ThemePrefs,\n  tokens,\n  tokensCssMapping,\n} from \"app/common/ThemePrefs\";\nimport { getThemeTokens } from \"app/common/Themes\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Computed, Observable } from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\n\nconst DEFAULT_LIGHT_THEME: Theme = getThemeObject(\"GristLight\");\nconst DEFAULT_DARK_THEME: Theme = getThemeObject(\"GristDark\");\n\n/**\n * A singleton observable for the current user's Grist theme preferences.\n *\n * Set by `AppModel`, which populates it from `UserPrefs`.\n */\nexport const gristThemePrefs = Observable.create<ThemePrefs | null>(null, null);\n\n/**\n * Returns `true` if the user agent prefers a dark color scheme.\n */\nexport function prefersColorSchemeDark(): boolean {\n  return window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n}\n\nlet _prefersColorSchemeDarkObs: PausableObservable<boolean> | undefined;\n\n/**\n * Returns a singleton observable for whether the user agent prefers a\n * dark color scheme.\n */\nexport function prefersColorSchemeDarkObs(): PausableObservable<boolean> {\n  if (!_prefersColorSchemeDarkObs) {\n    const query = window.matchMedia(\"(prefers-color-scheme: dark)\");\n    const obs = createPausableObs<boolean>(null, query.matches);\n    query.addEventListener(\"change\", event => obs.set(event.matches));\n    _prefersColorSchemeDarkObs = obs;\n  }\n  return _prefersColorSchemeDarkObs;\n}\n\nlet _gristThemeObs: Computed<Theme> | undefined;\n\n/**\n * A singleton observable for the current Grist theme.\n */\nexport function gristThemeObs() {\n  if (!_gristThemeObs) {\n    _gristThemeObs = Computed.create(null, (use) => {\n      // Default to light theme if themes are disabled in config.\n      if (!isFeatureEnabled(\"themes\")) { return DEFAULT_LIGHT_THEME; }\n\n      // If a user's preference is known, return it.\n      const themePrefs = use(gristThemePrefs);\n      const userAgentPrefersDarkTheme = use(prefersColorSchemeDarkObs());\n      if (themePrefs) { return getThemeFromPrefs(themePrefs, userAgentPrefersDarkTheme); }\n\n      // If user pref is not set or not yet loaded, first check the previously known theme from local storage\n      // (this prevents the appearance being wrongly set for a few milliseconds while loading the page)\n      const storageTheme = getStorage().getItem(\"grist-theme\");\n      if (storageTheme) {\n        return getThemeObject(storageTheme as ThemeName);\n      }\n\n      // Otherwise, fall back to the user agent's preference.\n      return userAgentPrefersDarkTheme ? DEFAULT_DARK_THEME : DEFAULT_LIGHT_THEME;\n    });\n  }\n  return _gristThemeObs;\n}\n\n/**\n * Attaches the current theme's CSS variables to the document, and\n * re-attaches them whenever the theme changes.\n *\n * When custom CSS is enabled (and theme selection is then unavailable in the UI),\n * default light theme variables are attached.\n */\nexport function attachTheme() {\n  // Attach the current theme's variables to the DOM.\n  attachCssThemeVars(gristThemeObs().get());\n  if (getGristConfig().enableCustomCss) {\n    fixOldCustomCss();\n  }\n\n  // Whenever the theme changes, re-attach its variables to the DOM.\n  gristThemeObs().addListener((newTheme, oldTheme) => {\n    if (isEqual(newTheme, oldTheme)) { return; }\n\n    attachCssThemeVars(newTheme);\n  });\n}\n\n/**\n * Attaches the default light theme to the DOM.\n *\n * In some cases, theme choice is disabled (for example, in forms), but we still need to\n * append the theme vars to the DOM so that the UI works.\n */\nexport function attachDefaultLightTheme() {\n  attachCssThemeVars(DEFAULT_LIGHT_THEME);\n}\n\n/**\n * Returns the `Theme` from the given `themePrefs`.\n *\n * If theme query parameters are present (`themeName`, `themeAppearance`, `themeSyncWithOs`),\n * they will take precedence over their respective values in `themePrefs`.\n */\nfunction getThemeFromPrefs(themePrefs: ThemePrefs, userAgentPrefersDarkTheme: boolean): Theme {\n  let { appearance, syncWithOS } = themePrefs;\n\n  const urlParams = urlState().state.get().params;\n  if (urlParams?.themeAppearance) {\n    appearance = urlParams?.themeAppearance;\n  }\n  if (urlParams?.themeSyncWithOs !== undefined) {\n    syncWithOS = urlParams?.themeSyncWithOs;\n  }\n\n  // The themeName is stored both in themePrefs.colors.light and themePrefs.colors.dark\n  // (see app/common/ThemePrefs.ts#getDefaultThemePrefs for more info).\n  // So, it doesn't matter if we take the theme name from colors.light or colors.dark\n  let themeName = themePrefs.colors.light;\n  if (urlParams?.themeName) {\n    themeName = urlParams?.themeName;\n  }\n  // User might set up the theme appearance in the url params, but not the theme name:\n  // make sure the theme is correctly set in that case\n  if (urlParams?.themeAppearance && !urlParams?.themeName) {\n    themeName = appearance === \"dark\" ? \"GristDark\" : \"GristLight\";\n  }\n\n  if (syncWithOS) {\n    appearance = userAgentPrefersDarkTheme ? \"dark\" : \"light\";\n    themeName = userAgentPrefersDarkTheme ? \"GristDark\" : \"GristLight\";\n  }\n\n  return { appearance, colors: getThemeTokens(themeName), name: themeName };\n}\n\nfunction getThemeObject(themeName: ThemeName): Theme {\n  return {\n    appearance: themeNameAppearances[themeName],\n    colors: getThemeTokens(themeName),\n    name: themeName,\n  };\n}\n\nfunction attachCssThemeVars(theme: Theme) {\n  const themeWithCssVars = convertThemeKeysToCssVars(theme);\n  const { appearance, colors: cssVars } = themeWithCssVars;\n\n  // This way of attaching css vars to the DOM is the same in grist-plugin-api and\n  // should be kept in sync in any case it changes.\n  // Ideally, this should stay as is, to prevent breaking changes in the grist-plugin-api file.\n  const properties = Object.entries(cssVars)\n    .map(([name, value]) => `--grist-theme-${name}: ${value};`);\n\n  // Update tokens and components empty CssCustomProps with actual theme values for when we want\n  // to fetch actual values at runtime instead of relying on css vars.\n  Object.entries(tokens).forEach(([token, cssProp]) => {\n    cssProp.value = cssVars[tokensCssMapping[token as keyof typeof tokensCssMapping]];\n  });\n  Object.entries(components).forEach(([component, cssProp]) => {\n    cssProp.value = cssVars[componentsCssMapping[component as keyof typeof componentsCssMapping]];\n  });\n\n  // Include properties for styling the scrollbar.\n  properties.push(...getCssThemeScrollbarProperties(appearance));\n\n  // Include properties for picking an appropriate background image.\n  properties.push(...getCssThemeBackgroundProperties(appearance));\n\n  // Apply the properties to the theme style element.\n  // The 'grist-theme' layer takes precedence over the 'grist-base' layer where\n  // default CSS variables are defined.\n  getOrCreateStyleElement(\"grist-theme\", {\n    element: document.getElementById(\"grist-root-css\"),\n    position: \"afterend\",\n  }).textContent = `@layer grist-theme {\n  :root {\n${properties.join(\"\\n\")}\n  }\n}`;\n\n  // Make the browser aware of the color scheme.\n  document.documentElement.style.setProperty(`color-scheme`, appearance);\n\n  // Add data-attributes to ease up custom css overrides.\n  document.documentElement.setAttribute(\"data-grist-theme\", theme.name);\n  document.documentElement.setAttribute(\"data-grist-appearance\", theme.appearance);\n\n  // Cache the appearance in local storage; this is currently used to apply a suitable\n  // background image that's shown while the application is loading.\n  getStorage().setItem(\"appearance\", appearance);\n  getStorage().setItem(\"grist-theme\", theme.name);\n}\n\n/**\n * Gets scrollbar-related css properties that are appropriate for the given `appearance`.\n *\n * Note: Browser support for customizing scrollbars is still a mixed bag; the bulk of customization\n * is non-standard and unsupported by Firefox. If support matures, we could expose some of these in\n * custom themes, but for now we'll just go with reasonable presets.\n */\nfunction getCssThemeScrollbarProperties(appearance: ThemeAppearance) {\n  return [\n    \"--scroll-bar-fg: \" +\n    (appearance === \"dark\" ? \"#6B6B6B;\" : \"#A8A8A8;\"),\n    \"--scroll-bar-hover-fg: \" +\n    (appearance === \"dark\" ? \"#7B7B7B;\" : \"#8F8F8F;\"),\n    \"--scroll-bar-active-fg: \" +\n    (appearance === \"dark\" ? \"#8B8B8B;\" : \"#7C7C7C;\"),\n    \"--scroll-bar-bg: \" +\n    (appearance === \"dark\" ? \"#2B2B2B;\" : \"#F0F0F0;\"),\n  ];\n}\n\n/**\n * Gets background-related css properties that are appropriate for the given `appearance`.\n *\n * Currently, this sets a property for showing a background image that's visible while a page\n * is loading.\n */\nfunction getCssThemeBackgroundProperties(appearance: ThemeAppearance) {\n  const value = appearance === \"dark\" ?\n    'url(\"img/prismpattern.png\")' :\n    'url(\"img/gplaypattern.png\")';\n  return [`--grist-theme-bg: ${value};`];\n}\n\n/**\n * In case custom css is enabled and it only overrides \"old\" \"--grist-color-*\" variables and the likes,\n * make sure to add missing matching variables ourselves.\n *\n * A warning is logged to the console to help the user migrate to the new theme variables.\n */\nfunction fixOldCustomCss() {\n  // Find the custom css stylesheet\n  const customCss = Array.from(document.styleSheets)\n    .find(sheet => (sheet.ownerNode as Element)?.id === \"grist-custom-css\");\n  if (!customCss) {\n    return;\n  }\n\n  const cssRulesArray = Array.from(customCss.cssRules);\n\n  // Find existing `grist-custom` layers\n  const gristCustomLayers = cssRulesArray\n    // current TS version doesn't know about CSSLayerBlockRule type\n    .filter(rule => rule.constructor.name === \"CSSLayerBlockRule\" && (rule as any).name === \"grist-custom\");\n\n  // Find all `:root` rules at the root of the custom css file or in the `grist-custom` layers\n  const rootCssRules = [\n    ...cssRulesArray,\n    ...gristCustomLayers.map(layer => Array.from((layer as any).cssRules)).flat(),\n  ].filter((rule) => {\n    return (rule as CSSRule).constructor.name === \"CSSStyleRule\" && (rule as CSSStyleRule).selectorText === \":root\";\n  }) as CSSStyleRule[];\n  if (!rootCssRules.length) {\n    return;\n  }\n\n  // Find all the --grist-* variables declared in the `:root` rules\n  const overridenVars: Record<string, string> = {};\n  rootCssRules.forEach((rootBlock) => {\n    for (const key in rootBlock.style) { // eslint-disable-line @typescript-eslint/no-for-in-array\n      const value = rootBlock.style[key];\n      if (rootBlock.style.hasOwnProperty(key) && value.startsWith(\"--grist-\")) {\n        overridenVars[value] = rootBlock.style.getPropertyValue(value).trim();\n      }\n    }\n  });\n\n  // Create missing variables to match old custom css vars with new theme vars\n  const missingVars: any[] = [];\n  legacyVarsMapping.forEach(({ old, new: newVariable }) => {\n    const found = !!overridenVars[old];\n    if (found &&\n      !overridenVars[old].startsWith(\"var(--grist-\") &&\n      !missingVars.find(v => v.name === newVariable)\n    ) {\n      missingVars.push({\n        name: newVariable,\n        value: `var(${old}) !important`,\n      });\n    }\n  });\n\n  if (!missingVars.length) {\n    return;\n  }\n\n  // Add the missing variables to the dom\n  getOrCreateStyleElement(\"grist-custom-css-fixes\", {\n    element: document.getElementById(\"grist-custom-css\"),\n    position: \"afterend\",\n  }).textContent = `@layer grist-custom {\n  :root {\n${missingVars.map(({ name, value }) => `${name}: ${value};`).join(\"\\n\")}\n  }\n}`;\n  console.warn(\n    \"The custom.css file uses deprecated variables that will be removed in the future. \" +\n    \"\\nPlease follow the example custom.css file to update the variables: https://support.getgrist.com/self-managed/#how-do-i-customize-styling.\",\n  );\n}\n"
  },
  {
    "path": "app/client/ui2018/toggleSwitch.ts",
    "content": "import { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { components } from \"app/common/ThemePrefs\";\n\nimport { dom, DomElementArg, Observable, styled } from \"grainjs\";\n\ninterface ToggleSwitchOptions {\n  label?: string;\n  /**\n   * By default, the toggle switch internally uses a hidden input checkbox element,\n   * that is checked/unchecked when the toggle switch is clicked and that updates\n   * the passed value observable accordingly.\n   *\n   * If `false`, the toggle switch doesn't generate an input element and doesn't respond\n   * to clicks or keyboard focuses. If you specifically need an interactive switch that doesn't\n   * generate a hidden input, you can pass additional dom `args` to do what you want.\n   */\n  useHiddenInput?: boolean;\n  enableTransitions?: Observable<boolean>;\n  /**\n   * grainjs dom args to apply on the wrapping element.\n   */\n  args?: DomElementArg[];\n  /**\n   * grainjs dom args to apply on the hidden input element.\n   */\n  inputArgs?: DomElementArg[];\n  /**\n   * grainjs dom args to apply on the label element.\n   */\n  labelArgs?: DomElementArg[];\n}\n\n/**\n * Renders a toggle switch with an optional label.\n *\n * @param value - An observable that can contain a boolean or null value that is linked to the toggle switch.\n *                When the observed value changes, the toggle switch UI updates.\n *                If not provided, the toggle renders in \"toggled off\" state and\n *                internally sets options.useHiddenInput to false.\n * @param options - see ToggleSwitchOptions\n */\nexport function toggleSwitch(value?: Observable<boolean | null>, options: ToggleSwitchOptions = {}) {\n  const { label, useHiddenInput = true, args = [], inputArgs = [], labelArgs = [] } = options;\n\n  const useInput = useHiddenInput && value;\n  return cssToggleSwitch(\n    useInput ?\n      cssInput(\n        { type: \"checkbox\" },\n        dom.prop(\"checked\", value),\n        dom.prop(\"value\", use => use(value) ? \"1\" : \"0\"),\n        dom.on(\"change\", (_e, elem) => value.set(elem.checked)),\n        ...inputArgs,\n      ) :\n      undefined,\n    value ? dom.cls(`${cssToggleSwitch.className}--checked`, use => !!use(value)) : undefined,\n    cssSwitch(\n      cssSwitchSlider(testId(\"toggle-switch-slider\")),\n      cssSwitchCircle(testId(\"toggle-switch-circle\")),\n    ),\n    label ? cssLabel(label, ...labelArgs) : null,\n    dom.cls(`${cssToggleSwitch.className}--transitions`, options.enableTransitions ?? true),\n    testId(\"toggle-switch\"),\n    ...args,\n  );\n}\n\nconst cssToggleSwitch = styled(\"label\", `\n  position: relative;\n  display: flex;\n  width: fit-content;\n  cursor: pointer;\n`);\n\nconst cssInput = styled(\"input\", `\n  position: absolute;\n  height: 1px;\n  width: 1px;\n  top: 1px;\n  left: 4px;\n  margin: 0;\n  opacity: 0;\n\n  &::before, &::after {\n    height: 1px;\n    width: 1px;\n  }\n  &:focus {\n    outline: none;\n  }\n`);\n\nconst cssSwitch = styled(\"div\", `\n  position: relative;\n  width: 30px;\n  height: 17px;\n  flex: none;\n  margin: 0 auto;\n  border-radius: 13px;\n\n  &:hover {\n    box-shadow: 0 0 2px ${components.switchHoverShadow};\n  }\n`);\n\nconst cssSwitchSlider = styled(\"div\", `\n  position: absolute;\n  cursor: pointer;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background-color: ${components.switchInactiveSlider};\n  border-radius: 13px;\n\n  .${cssToggleSwitch.className}--transitions & {\n    transition: background-color .4s;\n  }\n\n  /* border for inactive sliders, implemented like this to mitigate\n  pixel-perfect positioning issues between slider and circle */\n  &::after {\n    content: \"\";\n    position: absolute;\n    inset: 0;\n    box-shadow: inset 0 0 0 1px ${components.switchInactivePill};\n    border-radius: 13px;\n  }\n\n  .${cssInput.className}:focus-visible + .${cssSwitch.className} > & {\n    outline: 2px solid ${components.kbFocusHighlight};\n    outline-offset: 1px;\n  }\n\n  .${cssToggleSwitch.className}--checked .${cssSwitch.className} > & {\n    background-color: ${components.switchActiveSlider};\n  }\n\n  .${cssToggleSwitch.className}--checked .${cssSwitch.className} > &::after {\n    box-shadow: none;\n  }\n`);\n\nconst cssSwitchCircle = styled(\"div\", `\n  position: absolute;\n  z-index: 1;\n  cursor: pointer;\n  content: \"\";\n  height: 11px;\n  width: 11px;\n  left: 3.5px;\n  top: 0;\n  bottom: 0;\n  margin-top: auto;\n  margin-bottom: auto;\n  background-color: ${components.switchInactivePill};\n  border-radius: 100%;\n\n  .${cssToggleSwitch.className}--transitions & {\n    transition: transform .4s;\n  }\n\n  .${cssToggleSwitch.className}--checked .${cssSwitch.className} > & {\n    transform: translateX(12px);\n    background-color: ${components.switchActivePill};\n    height: 13px;\n    width: 13px;\n    box-shadow: none;\n  }\n\n  .${cssToggleSwitch.className}--checked .${cssSwitch.className} > &::after {\n    position: absolute;\n    z-index: 2;\n    content: \"\";\n    background-color: ${components.switchActiveSlider};\n    mask-image: var(--icon-TickSwitch);\n    mask-size: 10px;\n    mask-repeat: no-repeat;\n    mask-position: 1px center;\n    inset: 0;\n  }\n`);\n\nconst cssLabel = styled(\"span\", `\n  color: ${theme.text};\n  margin-left: 8px;\n  font-size: 13px;\n  font-weight: 400;\n  line-height: 16px;\n  overflow-wrap: anywhere;\n`);\n"
  },
  {
    "path": "app/client/ui2018/unstyled.ts",
    "content": "/**\n * Helpers to create unstyled variants of various HTML elements that have default styles.\n *\n * Useful to easily build semantic content without having to deal with removing styles all the time.\n */\nimport { styled } from \"grainjs\";\n\nconst base = `\n  margin: 0;\n  padding: 0;\n  border: 0 solid;\n`;\n\nexport const unstyledButton = styled(\"button\", `\n  ${base}\n  font: inherit;\n  letter-spacing: inherit;\n  color: inherit;\n  border-radius: 0;\n  background-color: transparent;\n  opacity: 1;\n  appearance: button;\n`);\n\nexport const unstyledLink = styled(\"a\", `\n  ${base}\n  color: inherit;\n  -webkit-text-decoration: inherit;\n  text-decoration: inherit;\n\n  &:hover, &:focus {\n    color: inherit;\n    -webkit-text-decoration: inherit;\n    text-decoration: inherit;\n  }\n`);\n\nexport const unstyledUl = styled(\"ul\", `\n  ${base}\n  list-style: none;\n`);\n\nconst unstyledHeadings = `\n  ${base}\n  font-size: inherit;\n  font-weight: inherit;\n`;\n\nexport const unstyledH2 = styled(\"h2\", unstyledHeadings);\n"
  },
  {
    "path": "app/client/ui2018/visuallyHidden.ts",
    "content": "/**\n * \"Visually hidden\" helpers.\n *\n * Allows to add things in the DOM that are not shown on screen but are still announced by screen readers.\n *\n * The code is taken from Bootstrap which has a pretty battle-tested implementation (thanks to them!)\n * @see https://github.com/twbs/bootstrap/blob/c5bec4ea7bd74b679fc2ecc53c141bff3750915b/scss/mixins/_visually-hidden.scss\n * @see https://getbootstrap.com/docs/5.3/helpers/visually-hidden/\n * @see https://www.ffoodd.fr/masquage-accessible-de-pointe/index.html\n */\nimport { styled } from \"grainjs\";\n\nconst commonStyles = `\n  border: 0 !important;\n  clip-path: inset(50%) !important;\n  height: 1px !important;\n  margin: -1px !important;\n  overflow: hidden !important;\n  padding: 0 !important;\n  width: 1px !important;\n  white-space: nowrap !important;\n`;\n\n/**\n * Base styles to help build custom \"visually hidden\" elements.\n */\nexport const visuallyHiddenStyles = `\n  position: absolute !important;\n  ${commonStyles};\n`;\n\n/**\n * Visually hides an element.\n *\n * You should use this with div, span, p, headings. Certainly not much else.\n */\nexport const visuallyHidden = styled(\"div\", `\n  ${commonStyles}\n\n  &:not(caption) {\n    position: absolute !important;\n  }\n\n  & * {\n    overflow: hidden !important;\n  }\n`);\n\n/**\n * Visually hides an element but show it when it gets keyboard focus.\n * Useful for things like skip links.\n *\n * You should use this on interactive html elements like <a> or <button>.\n *\n * Note: you can also use this on a div containing interactive elements, that you want\n * to show as a whole only when one of its interactive elements is focused.\n *\n * See bootstrap docs linked above for more details.\n */\nexport const visuallyHiddenFocusable = styled(visuallyHidden, `\n  &:not(:focus, :focus-within) {\n    ${commonStyles}\n  }\n\n  &:not(caption):not(:focus, :focus-within){\n    position: absolute !important;\n  }\n\n  &:not(:focus, :focus-within) * {\n    overflow: hidden !important;\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/AbstractWidget.js",
    "content": "var dispose = require(\"../lib/dispose\");\nconst {theme} = require(\"app/client/ui2018/cssVars\");\nconst {CellStyle} = require(\"app/client/widgets/CellStyle\");\nconst {dom} = require(\"grainjs\");\n\n/**\n * AbstractWidget - The base of the inheritance tree for widgets.\n * @param {Function} field - The RowModel for this view field.\n * @param {string|undefined} options.defaultTextColor - CSS value of the default\n * text color for the widget. Defaults to the current theme's cell fg color.\n *\n */\nfunction AbstractWidget(field, opts = {}) {\n  this.field = field;\n  this.options = field.widgetOptionsJson;\n  this.valueFormatter = this.field.visibleColFormatter;\n  this.defaultTextColor = opts.defaultTextColor ?? theme.cellFg.toString();\n}\ndispose.makeDisposable(AbstractWidget);\n\n/**\n * Builds the DOM showing configuration buttons and fields in the sidebar.\n */\nAbstractWidget.prototype.buildConfigDom = function(_gristDoc) {\n  throw new Error(\"Not Implemented\");\n};\n\n/**\n * Builds the transform prompt config DOM in the few cases where it is necessary.\n * Child classes need not override this function if they do not require transform config options.\n */\nAbstractWidget.prototype.buildTransformConfigDom = function(_gristDoc) {\n  return null;\n};\n\n/**\n * Builds the data cell DOM.\n * @param {DataRowModel} row - The rowModel object.\n */\nAbstractWidget.prototype.buildDom = function(row) {\n  throw new Error(\"Not Implemented\");\n};\n\nAbstractWidget.prototype.buildColorConfigDom = function(gristDoc) {\n  return dom.create(CellStyle, this.field, gristDoc, this.defaultTextColor);\n};\n\nAbstractWidget.prototype.buildFormConfigDom = function() {\n  return null;\n};\n\nAbstractWidget.prototype.buildFormTransformConfigDom = function() {\n  return null;\n};\n\nmodule.exports = AbstractWidget;\n"
  },
  {
    "path": "app/client/widgets/Assistant.ts",
    "content": "import {\n  Banner,\n  buildBannerMessage,\n  cssBannerLink,\n} from \"app/client/components/Banner\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { domAsync } from \"app/client/lib/domAsync\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { sessionStorageBoolObs } from \"app/client/lib/localStorageObs\";\nimport { getLoginOrSignupUrl } from \"app/client/lib/urlUtils\";\nimport { ChatHistory, ChatMessage } from \"app/client/models/ChatHistory\";\nimport { constructUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { showEnterpriseToggle } from \"app/client/ui/ActivationPage\";\nimport { buildCodeHighlighter } from \"app/client/ui/CodeHighlight\";\nimport { autoGrow } from \"app/client/ui/forms\";\nimport { sanitizeHTML } from \"app/client/ui/sanitizeHTML\";\nimport { createUserImage } from \"app/client/ui/UserImage\";\nimport { bigPrimaryButtonLink, primaryButton, textButton } from \"app/client/ui2018/buttons\";\nimport { colors, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink, gristIconLink } from \"app/client/ui2018/links\";\nimport { loadingDots } from \"app/client/ui2018/loaders\";\nimport { gristThemeObs } from \"app/client/ui2018/theme\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { AssistanceResponse } from \"app/common/Assistance\";\nimport { AsyncCreate } from \"app/common/AsyncCreate\";\nimport { DocAction } from \"app/common/DocActions\";\nimport { isFreePlan } from \"app/common/Features\";\nimport { commonUrls } from \"app/common/gristUrls\";\nimport { TelemetryEvent, TelemetryMetadata } from \"app/common/Telemetry\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport {\n  Computed,\n  Disposable,\n  dom,\n  DomContents,\n  DomElementArg,\n  makeTestId,\n  MutableObsArray,\n  obsArray,\n  Observable,\n  styled,\n  subscribeElem,\n} from \"grainjs\";\nimport { marked, Marked } from \"marked\";\nimport { markedHighlight } from \"marked-highlight\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nconst t = makeT(\"Assistant\");\n\nconst testId = makeTestId(\"test-assistant-\");\n\nconst LOW_CREDITS_WARNING_BANNER_THRESHOLD = 10;\n\ninterface AssistantOptions {\n  history: Observable<ChatHistory>;\n  gristDoc: GristDoc;\n  parentHeightPx?: Observable<number>;\n  onSend(message: string): Promise<AssistanceResponse>;\n  buildIntroMessage(...args: DomElementArg[]): DomContents;\n  /**\n   * Only used by version 1 of the AI assistant (`FormulaAssistant`).\n   */\n  onApplyFormula?(formula: string): Promise<void>;\n  onEscape?(): void;\n  logTelemetryEvent?(event: TelemetryEvent, includeContext?: boolean, metadata?: TelemetryMetadata): void;\n}\n\n/**\n * A widget that lets you talk to an AI-powered assistant.\n *\n * Displays a list of sent and received messages (`AssistantConversation`)\n * and inputs to send a new message.\n *\n * Used by both `FormulaAssistant` and `AssistantPopup`:\n *  - The former is a panel shown in `FormulaEditor` when version 1\n *    of the AI assistant is configured in the backend. Available in all\n *    flavors of Grist.\n *  - The latter is a popup opened from the left panel when version 2\n *    of the AI assistant in configured in the backend. Available in SaaS\n *    and Enterprise flavors of Grist.\n */\nexport class Assistant extends Disposable {\n  private _history = this._options.history;\n  private _gristDoc = this._options.gristDoc;\n  private _appModel = this._gristDoc.appModel;\n  private _parentHeightPx = this._options.parentHeightPx;\n  private _conversation: AssistantConversation;\n  /** State of the user input */\n  private _userInput = Observable.create(this, \"\");\n  /** Dom element that holds the user input */\n  private _input: HTMLTextAreaElement;\n  // Input wrapper element (used for resizing).\n  private _inputWrapper: HTMLElement;\n  /** Whether the low credit limit banner should be shown. */\n  private _showApproachingLimitBanner = this.autoDispose(\n    sessionStorageBoolObs(\n      `org:${\n        this._appModel.currentOrg?.id ?? 0\n      };assistantShowApproachingLimitBanner`,\n      true,\n    ),\n  );\n\n  private _showUpgradeBanner = Computed.create(this, (use) => {\n    const { assistant, deploymentType } = getGristConfig();\n    return (\n      assistant?.version === 1 &&\n      deploymentType === \"core\" &&\n      showEnterpriseToggle() &&\n      !use(this._appModel.dismissedPopups).includes(\"upgradeNewAssistant\")\n    );\n  });\n\n  /** Number of remaining credits. If null, assistant usage is unlimited. */\n  private _numRemainingCredits = Observable.create<number | null>(this, null);\n  private _lastSendPromise: Promise<AssistanceResponse> | null = null;\n\n  constructor(private _options: AssistantOptions) {\n    super();\n\n    this._conversation = AssistantConversation.create(this, {\n      history: this._history,\n      gristDoc: this._gristDoc,\n      buildIntroMessage: this._options.buildIntroMessage,\n      onClearConversation: this.clear.bind(this),\n      onApplyFormula: this._options.onApplyFormula,\n    });\n  }\n\n  public buildDom() {\n    return [\n      this._buildBanner(),\n      this._conversation.buildDom(),\n      this._appModel.currentValidUser ?\n        this._buildChatInput() :\n        this._buildSignupNudge(),\n    ];\n  }\n\n  public async send(message: string): Promise<void> {\n    this._conversation.addQuestion(message);\n    this._userInput.set(\"\");\n    await this._doAsk(message);\n  }\n\n  public clear() {\n    this._lastSendPromise = null;\n    this._conversation.clear();\n    this._userInput.set(\"\");\n    this._options.logTelemetryEvent?.(\"assistantClearConversation\", true);\n  }\n\n  public focus() {\n    if (!this._input) {\n      return;\n    }\n\n    this._input.focus();\n    if (this._input.value.length > 0) {\n      // Make sure focus moves to the last character.\n      this._input.selectionStart = this._input.value.length;\n      this._input.scrollTop = this._input.scrollHeight;\n    }\n  }\n\n  public scrollToBottom(options: { smooth?: boolean; sync?: boolean } = {}) {\n    this._conversation.scrollDown(options);\n  }\n\n  public get conversationId() {\n    return this._conversation.id.get();\n  }\n\n  public get conversationLength() {\n    return this._conversation.length.get();\n  }\n\n  public get conversationHistoryLength() {\n    return this._conversation.historyLength.get();\n  }\n\n  public get conversationSuggestedFormulas() {\n    return this._conversation.suggestedFormulas.get();\n  }\n\n  private _buildBanner() {\n    return dom.domComputed((use) => {\n      const numCredits = use(this._numRemainingCredits);\n      if (numCredits === 0) {\n        return dom.create(Banner, {\n          content: buildBannerMessage(\n            t(\"You have used all available credits.\"),\n            \" \",\n            this._buildBannerUpgradeMessage(),\n            testId(\"banner-message\"),\n          ),\n          style: \"error\",\n          bannerCssClass: cssBanner.className,\n        });\n      } else if (\n        numCredits !== null &&\n        numCredits <= LOW_CREDITS_WARNING_BANNER_THRESHOLD &&\n        use(this._showApproachingLimitBanner)\n      ) {\n        return dom.create(Banner, {\n          content: buildBannerMessage(\n            t(\"You have {{numCredits}} remaining credits.\", { numCredits }),\n            \" \",\n            this._buildBannerUpgradeMessage(),\n            testId(\"banner-message\"),\n          ),\n          style: \"warning\",\n          showCloseButton: true,\n          onClose: () => {\n            this._showApproachingLimitBanner.set(false);\n          },\n          bannerCssClass: cssBanner.className,\n        });\n      } else if (use(this._showUpgradeBanner)) {\n        return dom.create(Banner, {\n          content: buildBannerMessage(\n            t(\"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\", {\n              learnMoreLink: cssBannerAnchorLink(\n                { href: commonUrls.helpAssistant, target: \"_blank\" },\n                t(\"Learn more.\"),\n              ),\n            }),\n            testId(\"banner-message\"),\n          ),\n          style: \"custom\",\n          background: \"linear-gradient(to right, #29A3A3, #16A772)\",\n          showCloseButton: true,\n          onClose: () => {\n            this._appModel.dismissPopup(\"upgradeNewAssistant\", true);\n          },\n          bannerCssClass: cssBanner.className,\n        });\n      }\n    });\n  }\n\n  private _buildBannerUpgradeMessage() {\n    const canUpgradeSite =\n      this._appModel.isOwner() &&\n      Boolean(this._appModel.planName && isFreePlan(this._appModel.planName));\n    const isBillingManager =\n      this._appModel.isBillingManager() || this._appModel.isSupport();\n    if (!canUpgradeSite && !isBillingManager) {\n      return t(\"For higher limits, contact the site owner.\");\n    }\n\n    return t(\"For higher limits, {{upgradeNudge}}.\", {\n      upgradeNudge: cssBannerLink(\n        canUpgradeSite ?\n          t(\"upgrade to the Pro Team plan\") :\n          t(\"upgrade your plan\"),\n        dom.on(\"click\", async () => {\n          if (canUpgradeSite) {\n            this._gristDoc.appModel.showUpgradeModal().catch(reportError);\n          } else {\n            await urlState().pushUrl({ billing: \"billing\" });\n          }\n        }),\n      ),\n    });\n  }\n\n  /**\n   * Builds the chat input at the bottom of the chat.\n   */\n  private _buildChatInput() {\n    // Make sure we dispose the previous input.\n    if (this._input) {\n      dom.domDispose(this._input);\n    }\n\n    // Input is created by hand, as we need a finer control of the user input than what is available\n    // in generic textInput control.\n    this._input = cssInput(\n      dom.on(\"input\", (ev: Event) => {\n        this._userInput.set((ev.target as HTMLInputElement).value);\n      }),\n      autoGrow(this._userInput),\n      dom.style(\"max-height\", (use) => {\n        // Set an upper bound on the height the input can grow to, so that when the parent container\n        // is resized, the input is automatically resized to fit and doesn't overflow.\n        const panelHeight = this._parentHeightPx ?\n          use(this._parentHeightPx) :\n          0;\n        // The available input height is computed by taking the parent height, and subtracting\n        // the heights of all the other elements (except for the input).\n        const availableInputHeight =\n          panelHeight -\n          ((this._inputWrapper?.clientHeight ?? 0) -\n            (this._input?.clientHeight ?? 0)) -\n            MIN_CHAT_HISTORY_HEIGHT_PX;\n        return `${Math.max(availableInputHeight, MIN_CHAT_INPUT_HEIGHT_PX)}px`;\n      }),\n      dom.onKeyDown({\n        Enter$: ev => this._handleChatEnterKeyDown(ev),\n        Escape: () => this._options.onEscape?.(),\n      }),\n      dom.autoDispose(\n        this._userInput.addListener(value => (this._input.value = value)),\n      ),\n      dom.prop(\"disabled\", this._conversation.thinking),\n      dom.prop(\"placeholder\", (use) => {\n        const lastFormula = use(this._conversation.lastSuggestedFormula);\n        if (lastFormula && this._options.onApplyFormula) {\n          return t(\"Press Enter to apply suggested formula.\");\n        } else {\n          return t(\"What do you need help with?\");\n        }\n      }),\n      dom.autoDispose(\n        this._conversation.thinking.addListener((value) => {\n          if (!value) {\n            setTimeout(() => this.focus(), 0);\n          }\n        }),\n      ),\n    );\n\n    return (this._inputWrapper = cssHContainer(\n      testId(\"input\"),\n      dom.cls(cssTopBorder.className),\n      dom.cls(cssVSpace.className),\n      cssInputWrapper(\n        dom.cls(cssTypography.className),\n        this._input,\n        cssInputButtonsRow(\n          cssSendButton(\n            icon(\"FieldAny\"),\n            dom.on(\"click\", this._ask.bind(this)),\n            dom.show(use => !use(this._conversation.thinking)),\n            cssButton.cls(\n              \"-disabled\",\n              use => use(this._conversation.thinking) || use(this._userInput).length === 0,\n            ),\n            testId(\"send\"),\n          ),\n          cssCancelButton(\n            cssCancelIcon(\"Stop\"),\n            dom.on(\"click\", this._cancel.bind(this)),\n            dom.show(this._conversation.thinking),\n            testId(\"cancel\"),\n          ),\n          dom.on(\"click\", (ev) => {\n            ev.stopPropagation();\n            this.focus();\n          }),\n          cssInputButtonsRow.cls(\"-disabled\", this._conversation.thinking),\n        ),\n        cssInputWrapper.cls(\"-disabled\", this._conversation.thinking),\n      ),\n    ));\n  }\n\n  /**\n   * Builds the signup nudge shown to anonymous users at the bottom of the chat.\n   */\n  private _buildSignupNudge() {\n    const { deploymentType } = getGristConfig();\n    return deploymentType === \"saas\" ? buildSignupNudge() : buildAnonNudge();\n  }\n\n  private _handleChatEnterKeyDown(ev: KeyboardEvent) {\n    // If shift is pressed, we want to insert a new line.\n    if (ev.shiftKey) {\n      return;\n    }\n\n    ev.preventDefault();\n    const lastFormula = this._conversation.lastSuggestedFormula.get();\n    if (\n      this._input.value === \"\" &&\n      lastFormula &&\n      this._options.onApplyFormula\n    ) {\n      this._options.onApplyFormula(lastFormula).catch(reportError);\n    } else {\n      this._ask().catch(reportError);\n    }\n  }\n\n  private _addResponse(response: AssistanceResponse) {\n    console.debug(\"received assistant response: \", response);\n    const { reply, state, limit } = response;\n    let suggestedActions: DocAction[] | undefined;\n    let suggestedFormula: string | undefined;\n    if (limit && limit.limit >= 0) {\n      this._numRemainingCredits.set(Math.max(limit.limit - limit.usage, 0));\n    } else {\n      this._numRemainingCredits.set(null);\n    }\n    if (\"suggestedFormula\" in response) {\n      suggestedFormula = response.suggestedFormula;\n      suggestedActions = response.suggestedActions;\n    }\n    // If back-end is capable of conversation, keep its state.\n    this._history.set({ ...this._history.get(), state });\n    // If model has a conversational skills (and maintains a history), we might get actually\n    // some markdown text back, so we need to parse it.\n    const prettyMessage = state ?\n      reply || suggestedFormula || \"\" :\n      suggestedFormula || reply || \"\";\n    // Add it to the chat.\n    this._conversation.addResponse({\n      message: prettyMessage,\n      formula: suggestedFormula,\n      action: suggestedActions?.[0],\n      sender: \"ai\",\n    });\n  }\n\n  private async _ask() {\n    if (this._conversation.thinking.get()) {\n      return;\n    }\n    const message = this._userInput.get();\n    if (!message) {\n      return;\n    }\n    this._conversation.addQuestion(message);\n    this._userInput.set(\"\");\n    await this._doAsk(message);\n  }\n\n  private async _doAsk(message: string) {\n    this._conversation.thinking.set(true);\n    const sendPromise = this._options.onSend(message);\n    this._lastSendPromise = sendPromise;\n    try {\n      const response = await sendPromise;\n      if (this.isDisposed() || this._lastSendPromise !== sendPromise) {\n        return;\n      }\n\n      this._addResponse(response);\n    } catch (err: unknown) {\n      if (this.isDisposed() || this._lastSendPromise !== sendPromise) {\n        return;\n      }\n\n      if (err instanceof ApiError && err.status === 429 && err.details?.limit) {\n        const { projectedValue, maximum } = err.details.limit;\n        if (projectedValue >= maximum) {\n          this._numRemainingCredits.set(0);\n          return;\n        }\n      }\n      if (err instanceof ApiError) {\n        this._conversation.addError(err);\n        return;\n      }\n\n      throw err;\n    } finally {\n      if (!this.isDisposed() && this._lastSendPromise === sendPromise) {\n        this._conversation.thinking.set(false);\n      }\n    }\n  }\n\n  private _cancel() {\n    this._lastSendPromise = null;\n    this._conversation.thinking.set(false);\n  }\n}\n\nconst renderer = new marked.Renderer();\n\nrenderer.link = ({ href, text }) => gristIconLink(constructUrl(href), text).outerHTML;\n\n/**\n * A chat conversation. It is responsible for keeping the history of the chat and\n * sending messages to the AI.\n */\nclass AssistantConversation extends Disposable {\n  private static _marked?: AsyncCreate<Marked>;\n\n  public id: Observable<string>;\n  public newMessages: MutableObsArray<ChatMessage>;\n  public allMessages: MutableObsArray<ChatMessage>;\n  public length: Computed<number>;\n  public historyLength: Computed<number>;\n  public suggestedFormulas: Computed<string[]>;\n  public lastSuggestedFormula: Computed<string | null>;\n  public thinking: Observable<boolean>;\n\n  private _history = this._options.history;\n  private _gristDoc = this._options.gristDoc;\n  private _element: HTMLElement;\n\n  constructor(\n    private _options: {\n      history: Observable<ChatHistory>;\n      gristDoc: GristDoc;\n      buildIntroMessage: (...args: DomElementArg[]) => DomContents;\n      onClearConversation: () => void;\n      onApplyFormula?: (formula: string) => void;\n    },\n  ) {\n    super();\n\n    if (!AssistantConversation._marked) {\n      AssistantConversation._marked = new AsyncCreate(async () => {\n        const highlight = await buildCodeHighlighter({ maxLines: 60 });\n        return new Marked(\n          markedHighlight({\n            highlight: code => highlight(code),\n          }),\n        );\n      });\n    }\n\n    let conversationId = this._history.get().conversationId;\n    if (!conversationId) {\n      conversationId = uuidv4();\n      this._history.set({ ...this._history.get(), conversationId });\n    }\n    this.id = Observable.create(this, conversationId);\n    this.autoDispose(\n      this.id.addListener((newId) => {\n        // If a new conversation id was generated (e.g. on Clear Conversation), save it.\n        this._history.set({\n          conversationId: newId,\n          messages: [],\n          state: undefined,\n          developerPromptVersion: \"default\",\n        });\n        this.allMessages.set([]);\n        this.newMessages.set([]);\n      }),\n    );\n\n    // Create observable array of messages that is connected to the ChatHistory.\n    this.allMessages = this.autoDispose(obsArray(this._history.get().messages));\n    this.autoDispose(\n      this.allMessages.addListener((messages) => {\n        this._history.set({ ...this._history.get(), messages: [...messages] });\n      }),\n    );\n    this.newMessages = this.autoDispose(obsArray());\n\n    this.historyLength = Computed.create(\n      this,\n      use => use(this.allMessages).length,\n    );\n    this.length = Computed.create(this, use => use(this.newMessages).length);\n\n    this.suggestedFormulas = Computed.create(this, (use) => {\n      return use(this.allMessages)\n        .map(({ formula }) => formula)\n        .filter((formula): formula is string => Boolean(formula));\n    });\n    this.lastSuggestedFormula = Computed.create(this, (use) => {\n      return (\n        [...use(this.allMessages)].reverse().find(({ formula }) => formula)\n          ?.formula ?? null\n      );\n    });\n\n    this.thinking = Observable.create(this, false);\n    this.autoDispose(this.thinking.addListener((thinking) => {\n      if (thinking) {\n        this.scrollDown();\n      }\n    }));\n  }\n\n  public addResponse(message: ChatMessage) {\n    // Clear any thinking from messages.\n    this.thinking.set(false);\n    const entry: ChatMessage = { ...message, sender: \"ai\" };\n    this.allMessages.push(entry);\n    this.newMessages.push(entry);\n    this.scrollDown();\n  }\n\n  public addQuestion(message: string) {\n    this.thinking.set(false);\n    const entry: ChatMessage = { message, sender: \"user\" };\n    this.allMessages.push(entry);\n    this.newMessages.push(entry);\n    this.scrollDown();\n  }\n\n  public addError(error: ApiError) {\n    this.thinking.set(false);\n    const entry: ChatMessage = { message: \"\", error, sender: \"ai\" };\n    this.allMessages.push(entry);\n    this.newMessages.push(entry);\n    this.scrollDown();\n  }\n\n  public clear() {\n    this.thinking.set(false);\n    this.id.set(uuidv4());\n  }\n\n  public scrollDown(options: { smooth?: boolean; sync?: boolean } = {}) {\n    if (!this._element) { return; }\n\n    const { smooth = true, sync = false } = options;\n    const scrollToOptions: ScrollToOptions = {\n      top: 99999,\n      behavior: smooth ? \"smooth\" : \"auto\",\n    };\n    if (sync) {\n      this._element.scroll(scrollToOptions);\n    } else {\n      setTimeout(() => this._element.scroll(scrollToOptions), 0);\n    }\n  }\n\n  public buildDom() {\n    return domAsync(\n      AssistantConversation._marked!.get().then(() => {\n        return (this._element = cssHistory(\n          this._buildIntroMessage(),\n          dom.forEach(this.allMessages, (entry) => {\n            if (entry.sender === \"user\") {\n              return cssMessage(\n                dom(\"span\",\n                  dom.text(entry.message),\n                  testId(\"message-user\"),\n                  testId(\"message\"),\n                ),\n                cssAvatar(buildAvatar(this._gristDoc)),\n              );\n            } else if (entry.error) {\n              return cssAiMessage(\n                cssAvatar(cssAiImage()),\n                this._buildErrorMessage(entry.error),\n              );\n            } else {\n              return dom(\"div\",\n                cssAiMessage(\n                  cssAvatar(cssAiImage()),\n                  this._render(\n                    entry.message,\n                    testId(\"message-ai\"),\n                    testId(\"message\"),\n                  ),\n                ),\n                !this._options.onApplyFormula ?\n                  null :\n                  cssAiMessageButtonsRow(\n                    cssAiMessageButtons(\n                      primaryButton(\n                        t(\"Apply\"),\n                        dom.on(\"click\", () => {\n                          this._options.onApplyFormula?.(entry.formula!);\n                        }),\n                      ),\n                    ),\n                    dom.show(Boolean(entry.formula)),\n                  ),\n              );\n            }\n          }),\n          dom.maybe(this.thinking, () =>\n            dom(\"div\",\n              cssAiMessage(\n                cssAvatar(cssAiImage()),\n                cssLoadingDots(),\n              ),\n            ),\n          ),\n          () => { this.scrollDown({ smooth: false }); },\n        ));\n      }),\n    );\n  }\n\n  private _buildErrorMessage(error: ApiError) {\n    const code = error.details?.code;\n    switch (code) {\n      case \"ContextLimitExceeded\": {\n        return dom(\"div\",\n          t(\n            \"The conversation has become too long and I can no longer \\\nrespond effectively. Please {{startANewChatButton}} to continue \\\nreceiving assistance.\",\n            {\n              startANewChatButton: textButton(\n                textButton.cls(\"-hover-bg-padding-sm\"),\n                t(\"start a new chat\"),\n                dom.on(\"click\", () => this._options.onClearConversation()),\n              ),\n            },\n          ),\n        );\n      }\n      default: {\n        return this._render(error.details?.userError ?? error.message);\n      }\n    }\n  }\n\n  private _buildIntroMessage() {\n    return this._options.buildIntroMessage(\n      testId(\"message-intro\"),\n    );\n  }\n\n  /**\n   * Renders the message as markdown.\n   */\n  private _render(message: string, ...args: DomElementArg[]) {\n    return domAsync(\n      AssistantConversation._marked!.get().then(({ parse }) => {\n        return dom(\"div\",\n          el =>\n            subscribeElem(el, gristThemeObs(), () => {\n              el.innerHTML = sanitizeHTML(\n                parse(message, { async: false, renderer }),\n              );\n            }),\n          ...args,\n        );\n      }),\n    );\n  }\n}\n\nfunction buildSignupNudge() {\n  return cssSignupNudgeWrapper(\n    cssSignupNudgeParagraph(\n      t(\"Sign up for a free Grist account to start using the AI Assistant.\"),\n    ),\n    cssSignupNudgeButtonsRow(\n      bigPrimaryButtonLink(\n        t(\"Sign Up for Free\"),\n        { href: getLoginOrSignupUrl() },\n        testId(\"sign-up\"),\n      ),\n    ),\n  );\n}\n\nfunction buildAnonNudge() {\n  return cssSignupNudgeWrapper(\n    cssSignupNudgeWrapper.cls(\"-center\"),\n    cssSignupNudgeParagraph(\n      t(\"AI Assistant is only available for logged in users.\"),\n    ),\n  );\n}\n\n/** Builds avatar image for user or assistant. */\nfunction buildAvatar(grist: GristDoc) {\n  const user = grist.app.topAppModel.appObs.get()?.currentUser || null;\n  if (user) {\n    return createUserImage(user, \"medium\");\n  } else {\n    // TODO: this will not happen, as this should be only for logged in users.\n    return dom(\"div\", \"\");\n  }\n}\n\nconst MIN_CHAT_HISTORY_HEIGHT_PX = 160;\n\nconst MIN_CHAT_INPUT_HEIGHT_PX = 42;\n\nconst cssHistory = styled(\"div\", `\n  overflow: auto;\n  display: flex;\n  flex-direction: column;\n  color: ${theme.inputFg};\n`);\n\nconst cssHContainer = styled(\"div\", `\n  margin-top: auto;\n  padding-left: 18px;\n  padding-right: 18px;\n  display: flex;\n  flex-shrink: 0;\n  flex-direction: column;\n`);\n\nconst cssTopBorder = styled(\"div\", `\n  border-top: 1px solid ${theme.formulaAssistantBorder};\n`);\n\nconst cssVSpace = styled(\"div\", `\n  padding-top: 18px;\n  padding-bottom: 18px;\n`);\n\nconst cssInputButtonsRow = styled(\"div\", `\n  padding-top: 8px;\n  width: 100%;\n  justify-content: flex-end;\n  cursor: text;\n  display: flex;\n\n  &-disabled {\n    cursor: default;\n  }\n`);\n\nconst cssTypography = styled(\"div\", `\n  color: ${theme.inputFg};\n`);\n\nconst cssButton = styled(\"div\", `\n  border-radius: 4px;\n  display: flex;\n  align-self: flex-end;\n  margin-bottom: 6px;\n  margin-right: 6px;\n\n  &-disabled {\n    --icon-color: ${theme.controlSecondaryFg};\n  }\n\n  &:not(&-disabled) {\n    cursor: pointer;\n    --icon-color: ${theme.controlPrimaryFg};\n    color: ${theme.controlPrimaryFg};\n    background-color: ${theme.controlPrimaryBg};\n  }\n\n  &:hover:not(&-disabled) {\n    background-color: ${theme.controlPrimaryHoverBg};\n  }\n`);\n\nconst cssSendButton = styled(cssButton, `\n  padding: 3px;\n`);\n\nconst cssCancelButton = styled(cssButton, `\n  padding: 6px;\n`);\n\nconst cssCancelIcon = styled(icon, `\n  width: 10px;\n  height: 10px;\n`);\n\nconst cssInputWrapper = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  border: 1px solid ${theme.inputBorder};\n  border-radius: 3px;\n  align-items: center;\n  --icon-color: ${theme.controlSecondaryFg};\n  &-disabled {\n    background-color: ${theme.inputDisabledBg};\n  }\n  & > input {\n    outline: none;\n    padding: 0px;\n    align-self: stretch;\n    flex: 1;\n    border: none;\n    background-color: inherit;\n  }\n`);\n\nconst cssMessage = styled(\"div\", `\n  display: grid;\n  grid-template-columns: 1fr 60px;\n  border-top: 1px solid ${theme.formulaAssistantBorder};\n  padding: 20px 0px 20px 20px;\n`);\n\nexport const cssAiMessage = styled(\"div\", `\n  position: relative;\n  display: grid;\n  grid-template-columns: 60px 1fr;\n  border-top: 1px solid ${theme.formulaAssistantBorder};\n  padding: 20px 20px 20px 0px;\n  white-space: normal;\n  word-break: break-word;\n\n  & h1, & h2, & h3, & h4, & h5, & h6 {\n    margin-top: 24px;\n    margin-bottom: 16px;\n    font-weight: 600;\n    line-height: 1.25;\n  }\n  & h1 {\n    padding-bottom: .3em;\n    font-size: 2em;\n  }\n  & h2 {\n    padding-bottom: .3em;\n    font-size: 1.5em;\n  }\n  & h3 {\n    font-size: 1.25em;\n  }\n  & h4 {\n    font-size: 1em;\n  }\n  & h5 {\n    font-size: .875em;\n  }\n  & h6 {\n    color: ${theme.lightText};\n    font-size: .85em;\n  }\n  & p, & blockquote, & ul, & ol, & dl, & pre {\n    margin-top: 0px;\n    margin-bottom: 10px;\n  }\n  & hr {\n    border-color: ${theme.lightText};\n  }\n  & img {\n    max-width: 100%;\n  }\n  & code, & pre {\n    color: ${theme.text};\n    font-size: 85%;\n    background-color: ${theme.formulaAssistantPreformattedTextBg};\n    border: 0;\n    border-radius: 6px;\n  }\n  & code {\n    padding: .2em .4em;\n    margin: 0;\n    white-space: pre-wrap;\n  }\n  & pre {\n    padding: 16px;\n    overflow: auto;\n    line-height: 1.45;\n  }\n  & pre code {\n    font-size: 100%;\n    display: inline;\n    max-width: auto;\n    margin: 0;\n    padding: 0;\n    overflow: visible;\n    line-height: inherit;\n    word-wrap: normal;\n    background: transparent;\n  }\n  & pre > code {\n    background: transparent;\n    margin: 0;\n    padding: 0;\n  }\n  & pre .ace-chrome, & pre .ace-dracula {\n    background: ${theme.formulaAssistantPreformattedTextBg} !important;\n  }\n  & .ace_indent-guide {\n    background: none;\n  }\n  & .ace_static_highlight {\n    white-space: pre-wrap;\n  }\n  & ul, & ol {\n    padding-left: 2em;\n  }\n  & li > ol, & li > ul {\n    margin: 0;\n  }\n  & li + li,\n  & li > ol > li:first-child,\n  & li > ul > li:first-child {\n    margin-top: .25em;\n  }\n  & blockquote {\n    font-size: ${vars.mediumFontSize};\n    border-left: .25em solid ${theme.markdownCellMediumBorder};\n    padding: 0 1em;\n  }\n  & table {\n    margin: 0 0 10px;\n    border: 1px solid ${theme.tableBodyBorder};\n  }\n  & table > thead > tr {\n    background-color: ${theme.tableHeaderBg};\n  }\n  & table > tbody > tr {\n    background-color: ${theme.tableBodyBg};\n  }\n  & table th,\n  & table td {\n    border: 1px solid ${theme.tableBodyBorder};\n    padding: 6px 13px;\n  }\n`);\n\nexport const cssAvatar = styled(\"div\", `\n  display: flex;\n  align-items: flex-start;\n  justify-content: center;\n`);\n\nexport const cssAiImage = styled(\"div\", `\n  flex: none;\n  height: 32px;\n  width: 32px;\n  border-radius: 50%;\n  background-color: white;\n  background-image: var(--icon-GristLogo);\n  background-size: 22px 22px;\n  background-repeat: no-repeat;\n  background-position: center;\n`);\n\nconst cssAiMessageButtonsRow = styled(\"div\", `\n  display: flex;\n  justify-content: flex-end;\n  padding: 8px;\n`);\n\nconst cssAiMessageButtons = styled(\"div\", `\n  display: flex;\n  column-gap: 8px;\n`);\n\nconst cssInput = styled(\"textarea\", `\n  border: 0px;\n  flex-grow: 1;\n  outline: none;\n  width: 100%;\n  padding: 4px 6px;\n  padding-top: 6px;\n  resize: none;\n  min-height: ${MIN_CHAT_INPUT_HEIGHT_PX}px;\n  background: transparent;\n\n  &:disabled {\n    background-color: ${theme.inputDisabledBg};\n    color: ${theme.inputDisabledFg};\n  }\n\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\nconst cssLoadingDots = styled(loadingDots, `\n  --dot-size: 5px;\n  align-items: center;\n`);\n\nconst cssSignupNudgeWrapper = styled(\"div\", `\n  border-top: 1px solid ${theme.formulaAssistantBorder};\n  padding: 16px;\n  margin-top: auto;\n  display: flex;\n  flex-shrink: 0;\n  flex-direction: column;\n\n  &-center {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n  }\n`);\n\nconst cssSignupNudgeParagraph = styled(\"div\", `\n  color: ${theme.text};\n  font-size: ${vars.mediumFontSize};\n  font-weight: 500;\n  margin-bottom: 12px;\n  text-align: center;\n`);\n\nconst cssSignupNudgeButtonsRow = styled(\"div\", `\n  display: flex;\n  justify-content: center;\n`);\n\nconst cssBanner = styled(\"div\", `\n  padding: 6px 8px 6px 8px;\n  text-align: center;\n`);\n\nconst cssBannerAnchorLink = styled(cssLink, `\n  color: ${colors.light};\n\n  &:hover {\n    color: ${colors.light};\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/AttachmentsEditor.ts",
    "content": "// External dependencies\n\n// Grist client libs\nimport { DocComm } from \"app/client/components/DocComm\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { selectFiles, uploadFiles } from \"app/client/lib/uploads\";\nimport { DocData } from \"app/client/models/DocData\";\nimport { MetaTableData } from \"app/client/models/TableData\";\nimport { basicButton, basicButtonLink, cssButtonGroup } from \"app/client/ui2018/buttons\";\nimport { mediaSmall, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { editableLabel } from \"app/client/ui2018/editableLabel\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { IModalControl, modal } from \"app/client/ui2018/modals\";\nimport { renderFileType } from \"app/client/widgets/AttachmentsWidget\";\nimport { FieldOptions, NewBaseEditor } from \"app/client/widgets/NewBaseEditor\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { clamp, encodeQueryParams } from \"app/common/gutil\";\nimport { SingleCell } from \"app/common/TableData\";\nimport { UploadResult } from \"app/common/uploads\";\n\nimport { dom, LiveIndex, makeLiveIndex, styled } from \"grainjs\";\nimport { MutableObsArray, obsArray, ObsArray, observable, Observable } from \"grainjs\";\nimport { computed, Computed, computedArray } from \"grainjs\";\nimport * as mimeTypes from \"mime-types\";\n\nconst t = makeT(\"AttachmentsEditor\");\n\ninterface Attachment {\n  rowId: number;\n\n  // Checksum of the file content, with extension, which identifies the file data in the _gristsys_Files table.\n  fileIdent: string;\n\n  // MIME type of the data (looked up from fileIdent's extension).\n  fileType: string;\n\n  // User-defined filename of the attachment. An observable to support renaming.\n  filename: Observable<string>;\n\n  // Whether the attachment is an image, indicated by the presence of imageHeight in the record.\n  hasPreview: boolean;\n\n  // The download URL of the attachment; served with Content-Disposition of \"attachment\".\n  url: Observable<string>;\n\n  // The inline URL of the attachment; served with Content-Disposition of \"inline\".\n  inlineUrl: Observable<string>;\n}\n\n/**\n * An AttachmentsEditor shows a full-screen modal with attachment previews, and options to rename,\n * download, add or remove attachments in the edited cell.\n */\nexport class AttachmentsEditor extends NewBaseEditor {\n  private _attachmentsTable: MetaTableData<\"_grist_Attachments\">;\n  private _docComm: DocComm;\n\n  private _rowIds: MutableObsArray<number>;\n  private _attachments: ObsArray<Attachment>;\n  private _isUploading: Observable<boolean>;\n  private _index: LiveIndex;\n  private _selected: Computed<Attachment | null>;\n\n  constructor(options: FieldOptions) {\n    super(options);\n\n    const docData: DocData = options.gristDoc.docData;\n    const cellValue: CellValue = options.cellValue;\n    const cell: SingleCell | null = options.rowId ? {\n      rowId: options.rowId,\n      colId: options.field.colId(),\n      tableId: options.field.column().table().tableId(),\n    } : null;\n\n    // editValue is abused slightly to indicate a 1-based index of the attachment.\n    const initRowIndex: number | undefined = (options.editValue && parseInt(options.editValue, 0) - 1) || 0;\n\n    this._attachmentsTable = docData.getMetaTable(\"_grist_Attachments\");\n    this._docComm = docData.docComm;\n\n    this._rowIds = obsArray(Array.isArray(cellValue) ? cellValue.slice(1) as number[] : []);\n    this._attachments = computedArray(this._rowIds, (val: number): Attachment => {\n      const fileIdent: string = this._attachmentsTable.getValue(val, \"fileIdent\")!;\n      const fileType = mimeTypes.lookup(fileIdent) || \"application/octet-stream\";\n      const filename: Observable<string> =\n        observable(this._attachmentsTable.getValue(val, \"fileName\")!);\n      return {\n        rowId: val,\n        fileIdent,\n        fileType,\n        filename,\n        hasPreview: Boolean(this._attachmentsTable.getValue(val, \"imageHeight\")),\n        url: computed(use => this._getUrl(cell, val, use(filename))),\n        inlineUrl: computed(use => this._getUrl(cell, val, use(filename), true)),\n      };\n    });\n    this._index = makeLiveIndex(this, this._attachments, initRowIndex);\n    this._selected = this.autoDispose(computed((use) => {\n      const index = use(this._index);\n      return index === null ? null : use(this._attachments)[index];\n    }));\n    this._isUploading = Observable.create(this, false);\n  }\n\n  // This \"attach\" is not about \"attachments\", but about attaching this widget to the page DOM.\n  public attach(cellElem: Element) {\n    modal((ctl, owner) => {\n      // If FieldEditor is disposed externally (e.g. on navigation), be sure to close the modal.\n      this.onDispose(ctl.close);\n      return [\n        cssFullScreenModal.cls(\"\"),\n        dom.onKeyDown({\n          Enter: (ev) => { ctl.close(); this.options.commands.fieldEditSaveHere(); },\n          Escape: (ev) => { ctl.close(); this.options.commands.fieldEditCancel(); },\n          ArrowLeft$: ev => !isInEditor(ev) && this._moveIndex(-1),\n          ArrowRight$: ev => !isInEditor(ev) && this._moveIndex(1),\n        }),\n        // Close if clicking into the background. (The default modal's behavior for this isn't\n        // triggered because our content covers the whole screen.)\n        dom.on(\"click\", (ev, elem) => { if (ev.target === elem) { ctl.close(); } }),\n        ...this._buildDom(ctl),\n      ];\n    }, { noEscapeKey: true });\n  }\n\n  public getCellValue() {\n    return [\"L\", ...this._rowIds.get()] as CellValue;\n  }\n\n  public getCursorPos(): number {\n    return 0;\n  }\n\n  public getTextValue(): string {\n    return \"\";\n  }\n\n  // Builds the attachment preview modal.\n  private _buildDom(ctl: IModalControl) {\n    return [\n      cssHeader(\n        cssFlexExpand(\n          dom.text((use) => {\n            const len = use(this._attachments).length;\n            return len ? t(\"{{index}} of {{total}}\", { index: (use(this._index) || 0) + 1, total: len }) : \"\";\n          }),\n          testId(\"pw-counter\"),\n          dom.maybe(this._isUploading, () =>\n            cssLoading(\n              loadingSpinner(),\n              t(\"Uploading…\"),\n              testId(\"pw-spinner\"),\n            ),\n          ),\n        ),\n        dom.maybe(this._selected, selected =>\n          cssTitle(\n            cssEditableLabel(selected.filename, {\n              save: val => this._renameAttachment(selected, val),\n              inputArgs: [testId(\"pw-name\")],\n            }),\n          ),\n        ),\n        cssFlexExpand(\n          cssFileButtons(\n            dom.maybe(this._selected, selected =>\n              basicButtonLink(cssButton.cls(\"\"), cssButtonIcon(\"Download\"), t(\"Download\"),\n                dom.attr(\"href\", selected.url),\n                dom.attr(\"target\", \"_blank\"),\n                dom.attr(\"download\", selected.filename),\n                testId(\"pw-download\"),\n              ),\n            ),\n            this.options.readonly ? null : [\n              cssButton(cssButtonIcon(\"FieldAttachment\"), t(\"Add\"),\n                dom.on(\"click\", () => this._select()),\n                testId(\"pw-add\"),\n              ),\n              dom.maybe(this._selected, () =>\n                cssButton(cssButtonIcon(\"Remove\"), t(\"Delete\"),\n                  dom.on(\"click\", () => this._remove()),\n                  testId(\"pw-remove\"),\n                ),\n              ),\n            ],\n          ),\n          cssCloseButton(cssBigIcon(\"CrossBig\"), dom.on(\"click\", () => ctl.close()),\n            testId(\"pw-close\")),\n        ),\n      ),\n      cssNextArrow(cssNextArrow.cls(\"-left\"), cssBigIcon(\"Expand\"), testId(\"pw-left\"),\n        dom.hide(use => !use(this._attachments).length || use(this._index) === 0),\n        dom.on(\"click\", () => this._moveIndex(-1)),\n      ),\n      cssNextArrow(cssNextArrow.cls(\"-right\"), cssBigIcon(\"Expand\"), testId(\"pw-right\"),\n        dom.hide(use => !use(this._attachments).length || use(this._index) === use(this._attachments).length - 1),\n        dom.on(\"click\", () => this._moveIndex(1)),\n      ),\n      dom.domComputed(this._selected, selected => renderContent(selected, this.options.readonly)),\n\n      // Drag-over logic\n      (elem: HTMLElement) => dragOverClass(elem, cssDropping.className),\n      cssDragArea(this.options.readonly ? null : cssWarning(t(\"Drop files here to attach\"))),\n      this.options.readonly ? null : dom.on(\"drop\", ev => this._upload(ev.dataTransfer!.files)),\n      testId(\"pw-modal\"),\n    ];\n  }\n\n  private async _renameAttachment(att: Attachment, fileName: string): Promise<void> {\n    await this._attachmentsTable.sendTableAction([\"UpdateRecord\", att.rowId, { fileName }]);\n    // Update the observable, since it's not on its own observing changes.\n    att.filename.set(this._attachmentsTable.getValue(att.rowId, \"fileName\")!);\n  }\n\n  private _getUrl(\n    cell: SingleCell | null,\n    attId: number,\n    filename: string,\n    inline?: boolean,\n  ): string {\n    return this._docComm.docUrl(\"attachment\") + \"?\" + encodeQueryParams({\n      ...this._docComm.getUrlParams(),\n      name: filename,\n      ...cell,\n      maybeNew: 1,  // The attachment may be uploaded by the user but not stored in the cell yet.\n      attId,\n      ...(inline ? { inline: 1 } : {}),\n    });\n  }\n\n  private _moveIndex(dir: -1 | 1): void {\n    const next = this._index.get()! + dir;\n    this._index.set(clamp(next, 0, this._attachments.get().length));\n  }\n\n  // Removes the attachment being previewed from the cell (but not the document).\n  private _remove(): void {\n    this._rowIds.splice(this._index.get()!, 1);\n  }\n\n  private async _select(): Promise<void> {\n    try {\n      const uploadResult = await selectFiles({\n        docWorkerUrl: this._docComm.docWorkerUrl,\n        multiple: true,\n        sizeLimit: \"attachment\",\n      }, (progress) => {\n        if (progress === 0) {\n          this._isUploading.set(true);\n        }\n      },\n      );\n      this._isUploading.set(false);\n      return this._add(uploadResult);\n    } catch (error) {\n      this._isUploading.set(false);\n      throw error;\n    }\n  }\n\n  private async _upload(files: FileList): Promise<void> {\n    try {\n      const uploadResult = await uploadFiles(\n        Array.from(files),\n        { docWorkerUrl: this._docComm.docWorkerUrl, sizeLimit: \"attachment\" },\n        (progress) => {\n          if (progress === 0) {\n            this._isUploading.set(true);\n          }\n        },\n      );\n      this._isUploading.set(false);\n      return this._add(uploadResult);\n    } catch (error) {\n      this._isUploading.set(false);\n      throw error;\n    }\n  }\n\n  private async _add(uploadResult: UploadResult | null): Promise<void> {\n    if (!uploadResult) { return; }\n    const rowIds = await this._docComm.addAttachments(uploadResult.uploadId);\n    const len = this._rowIds.get().length;\n    if (rowIds.length > 0) {\n      this._rowIds.push(...rowIds);\n      this._index.set(len);\n    }\n  }\n}\n\nfunction isInEditor(ev: KeyboardEvent): boolean {\n  return (ev.target as HTMLElement).tagName === \"INPUT\";\n}\n\nfunction renderContent(att: Attachment | null, readonly: boolean): HTMLElement {\n  const commonArgs = [cssContent.cls(\"\"), testId(\"pw-attachment-content\")];\n  if (!att) {\n    return cssWarning(\n      t(\"No attachments\"),\n      readonly ? null : cssDetails(t(\"Drop files here to attach.\")),\n      ...commonArgs,\n    );\n  } else if (att.hasPreview) {\n    return dom(\"img\", dom.attr(\"src\", att.url), ...commonArgs);\n  } else if (att.fileType.startsWith(\"video/\")) {\n    return dom(\"video\", dom.attr(\"src\", att.inlineUrl), { autoplay: false, controls: true }, ...commonArgs);\n  } else if (att.fileType.startsWith(\"audio/\")) {\n    return dom(\"audio\", dom.attr(\"src\", att.inlineUrl), { autoplay: false, controls: true }, ...commonArgs);\n  } else if (att.fileType.startsWith(\"text/\") || att.fileType === \"application/json\") {\n    // Rendering text/html is risky. Things like text/plain and text/csv we could render though,\n    // but probably not using object tag (which needs work to look acceptable).\n    return dom(\"div\", ...commonArgs,\n      cssWarning(cssContent.cls(\"\"), renderFileType(att.filename.get(), att.fileIdent),\n        cssDetails(t(\"Preview not available.\"))));\n  } else {\n    // Setting 'type' attribute is important to avoid a download prompt from Chrome.\n    return dom(\"object\", { type: att.fileType }, dom.attr(\"data\", att.inlineUrl), ...commonArgs,\n      cssWarning(cssContent.cls(\"\"), renderFileType(att.filename.get(), att.fileIdent),\n        cssDetails(t(\"Preview not available.\"))),\n    );\n  }\n}\n\nfunction dragOverClass(target: HTMLElement, className: string): void {\n  let enterTarget: EventTarget | null = null;\n  function toggle(ev: DragEvent, onOff: boolean) {\n    enterTarget = onOff ? ev.target : null;\n    ev.stopPropagation();\n    ev.preventDefault();\n    target.classList.toggle(className, onOff);\n  }\n  dom.onElem(target, \"dragenter\", ev => toggle(ev, true));\n  dom.onElem(target, \"dragleave\", ev => (ev.target === enterTarget) && toggle(ev, false));\n  dom.onElem(target, \"drop\", ev => toggle(ev, false));\n}\n\nconst cssFullScreenModal = styled(\"div\", `\n  background-color: initial;\n  width: 100%;\n  height: 100%;\n  border: none;\n  border-radius: 0px;\n  box-shadow: none;\n  padding: 0px;\n`);\n\nconst cssHeader = styled(\"div\", `\n  padding: 16px 24px;\n  position: fixed;\n  left: 0;\n  right: 0;\n  margin: 0 auto;\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: white;\n  @media ${mediaSmall} {\n    & {\n      align-items: flex-start;\n      flex-direction: column-reverse;\n      gap: 8px;\n      padding: 0 16px;\n    }\n  }\n`);\n\nconst cssCloseButton = styled(\"div\", `\n  padding: 6px;\n  border-radius: 32px;\n  cursor: pointer;\n  background-color: ${theme.attachmentsEditorButtonBg};\n  --icon-color: ${theme.attachmentsEditorButtonFg};\n\n  &:hover {\n    background-color: ${theme.attachmentsEditorButtonHoverBg};\n    --icon-color: ${theme.attachmentsEditorButtonHoverFg};\n  }\n\n  @media ${mediaSmall} {\n    & {\n      margin-left: auto;\n    }\n  }\n`);\n\nconst cssBigIcon = styled(icon, `\n  padding: 10px;\n`);\n\nconst cssTitle = styled(\"div\", `\n  display: inline-block;\n  padding: 8px 16px;\n  margin-right: 8px;\n  min-width: 0px;\n  overflow: hidden;\n\n  &:hover {\n    outline: 1px solid ${theme.lightText};\n  }\n  &:focus-within {\n    outline: 1px solid ${theme.controlFg};\n  }\n\n  @media ${mediaSmall} {\n    & {\n      outline: 1px solid ${theme.lightText};\n    }\n  }\n`);\n\nconst cssEditableLabel = styled(editableLabel, `\n  font-size: ${vars.mediumFontSize};\n  font-weight: bold;\n  color: white;\n`);\n\nconst cssFlexExpand = styled(\"div\", `\n  flex: 1;\n  display: flex;\n  align-items: center;\n  @media ${mediaSmall} {\n    & {\n      width: 100%;\n    }\n  }\n`);\n\nconst cssLoading = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-left: 16px;\n  & .${loadingSpinner.className} {\n    --loader-fg: currentColor;\n  }\n  @media ${mediaSmall} {\n    & .${loadingSpinner.className} {\n      width: 16px;\n      height: 16px;\n      border-width: 1px;\n    }\n  }\n`);\n\nconst cssFileButtons = styled(cssButtonGroup, `\n  margin-left: auto;\n  margin-right: 16px;\n  height: 32px;\n  flex: none;\n\n  @media ${mediaSmall} {\n    & {\n      margin-left: 0px;\n      margin-right: 0px;\n    }\n  }\n`);\n\nconst cssButton = styled(basicButton, `\n  color: ${theme.attachmentsEditorButtonFg};\n  background-color: ${theme.attachmentsEditorButtonBg};\n  font-weight: normal;\n  padding: 0 16px;\n  border-top: none;\n  border-right: none;\n  border-bottom: none;\n  border-left: 1px solid ${theme.attachmentsEditorButtonBorder};\n  display: flex;\n  align-items: center;\n\n  &:first-child {\n    border: none;\n  }\n  &:hover {\n    color: ${theme.attachmentsEditorButtonHoverFg};\n    background-color: ${theme.attachmentsEditorButtonHoverBg};\n    border-color: ${theme.attachmentsEditorButtonBorder};\n  }\n`);\n\nconst cssButtonIcon = styled(icon, `\n  --icon-color: ${theme.attachmentsEditorButtonIcon};\n  margin-right: 4px;\n`);\n\nconst cssNextArrow = styled(\"div\", `\n  position: fixed;\n  height: 32px;\n  margin: auto 24px;\n  top: 0px;\n  bottom: 0px;\n  z-index: 1;\n\n  padding: 6px;\n  border-radius: 32px;\n  cursor: pointer;\n  background-color: ${theme.controlPrimaryBg};\n  --icon-color: ${theme.controlPrimaryFg};\n\n  &:hover {\n    background-color: ${theme.controlPrimaryHoverBg};\n  }\n  &-left {\n    transform: rotateY(180deg);\n    left: 0px;\n  }\n  &-right {\n    right: 0px;\n  }\n`);\n\nconst cssDropping = styled(\"div\", \"\");\n\nconst cssContent = styled(\"div\", `\n  display: block;\n  height: calc(100% - 72px);\n  width: calc(100% - 64px);\n  max-width: 800px;\n  margin-left: auto;\n  margin-right: auto;\n  margin-top: 64px;\n  margin-bottom: 8px;\n  outline: none;\n  img& {\n    width: max-content;\n    height: unset;\n  }\n  audio& {\n    padding-bottom: 64px;\n  }\n  .${cssDropping.className} > & {\n    display: none;\n  }\n  @media ${mediaSmall} {\n    & {\n      margin-top: 102px;\n      height: calc(100% - 102px);\n    }\n  }\n`);\n\nconst cssWarning = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  align-items: center;\n  font-size: ${vars.mediumFontSize};\n  font-weight: bold;\n  color: white;\n  padding: 0px;\n`);\n\nconst cssDetails = styled(\"div\", `\n  font-weight: normal;\n  margin-top: 24px;\n`);\n\nconst cssDragArea = styled(cssContent, `\n  border: 2px dashed ${theme.attachmentsEditorBorder};\n  height: calc(100% - 96px);\n  margin-top: 64px;\n  padding: 0px;\n  justify-content: center;\n  display: none;\n  .${cssDropping.className} > & {\n    display: flex;\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/AttachmentsWidget.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { FormFieldRulesConfig } from \"app/client/components/Forms/FormConfig\";\nimport { dragOverClass } from \"app/client/lib/dom\";\nimport { stopEvent } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { selectFiles, uploadFiles } from \"app/client/lib/uploads\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { KoSaveableObservable } from \"app/client/models/modelUtil\";\nimport { MetaTableData } from \"app/client/models/TableData\";\nimport { cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { colors, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { NewAbstractWidget } from \"app/client/widgets/NewAbstractWidget\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { encodeQueryParams } from \"app/common/gutil\";\nimport { SingleCell } from \"app/common/TableData\";\nimport { UploadResult } from \"app/common/uploads\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\nimport { GristObjCode } from \"app/plugin/GristData\";\n\nimport { extname } from \"path\";\n\nimport { Computed, dom, DomContents, fromKo, input, Observable, onElem, styled } from \"grainjs\";\n\nconst t = makeT(\"AttachmentsWidget\");\n\n/**\n * AttachmentsWidget - A widget for displaying attachments as image previews.\n */\nexport class AttachmentsWidget extends NewAbstractWidget {\n  private _attachmentsTable: MetaTableData<\"_grist_Attachments\">;\n  private _height: KoSaveableObservable<string>;\n  private _uploadingTimeouts: Partial<Record<UIRowId, number>> = {};\n  private _uploadingStatesObs: Observable<Partial<Record<UIRowId, boolean>>>;\n\n  constructor(field: ViewFieldRec) {\n    super(field);\n\n    // TODO: the Attachments table currently treated as metadata, and loaded on open,\n    // but should probably be loaded on demand as it contains user data, which may be large.\n    this._attachmentsTable = this._getDocData().getMetaTable(\"_grist_Attachments\");\n\n    this._height = this.options.prop(\"height\");\n    this._uploadingStatesObs = Observable.create(this, {});\n\n    this.autoDispose(this._height.subscribe(() => {\n      this.field.viewSection().events.trigger(\"rowHeightChange\");\n    }));\n\n    this.onDispose(() => {\n      Object.values(this._uploadingTimeouts).forEach(timeout => clearTimeout(timeout));\n    });\n  }\n\n  public buildDom(row: DataRowModel) {\n    // NOTE: A cellValue of the correct type includes the list encoding designator 'L' as the\n    // first element.\n    const cellValue = row.cells[this.field.colId()];\n    const values = Computed.create(null, fromKo(cellValue), (use, _cellValue) =>\n      Array.isArray(_cellValue) ? _cellValue.slice(1) as number[] : []);\n\n    const colId = this.field.colId();\n    const tableId = this.field.column().table().tableId();\n\n    const isUploadingObs = Computed.create(null, this._uploadingStatesObs, (use, states) =>\n      states[row.getRowId()] || false,\n    );\n\n    return cssAttachmentWidget(\n      dom.autoDispose(values),\n      dom.autoDispose(isUploadingObs),\n\n      dom.cls(\"field_clip\"),\n      dragOverClass(\"attachment_drag_over\"),\n      dom.maybe(use => !use(use(this.field.column).isRealFormula), () => [\n        cssAttachmentIcon(\n          cssAttachmentIcon.cls(\"-hover\", use => use(values).length > 0),\n          dom.on(\"click\", async (ev) => {\n            stopEvent(ev);\n            await this._selectAndSave(row, cellValue);\n          }),\n          testId(\"attachment-icon\"),\n        ),\n      ]),\n      dom.maybe<number>(row.id, (rowId) => {\n        return dom.forEach(values, (value: number) =>\n          isNaN(value) ? null : this._buildAttachment(value, values, {\n            rowId, colId, tableId,\n          }));\n      }),\n      dom.maybe(isUploadingObs, () =>\n        cssSpinner(\n          cssSpinner.cls(\"-has-attachments\", use => use(values).length > 0),\n          testId(\"attachment-spinner\"),\n          { title: t(\"Uploading, please wait…\") },\n        ),\n      ),\n      dom.on(\"drop\", ev => this._uploadAndSave(row, cellValue, ev.dataTransfer!.files)),\n      testId(\"attachment-widget\"),\n    );\n  }\n\n  public buildConfigDom(): DomContents {\n    const options = this.field.config.options;\n    const height = options.prop(\"height\");\n    const inputRange = input(\n      fromKo(height),\n      { onInput: true }, {\n        style: \"margin: 0 5px;\",\n        type: \"range\",\n        min: \"16\",\n        max: \"96\",\n        value: \"36\",\n      },\n      testId(\"pw-thumbnail-size\"),\n      // When multiple columns are selected, we can only edit height when all\n      // columns support it.\n      dom.prop(\"disabled\", use => use(options.disabled(\"height\"))),\n    );\n    // Save the height on change event (when the user releases the drag button)\n    onElem(inputRange, \"change\", (ev: Event) => {\n      height.setAndSave(inputRange.value).catch(reportError);\n    });\n    return cssRow(\n      cssSizeLabel(\"Size\"),\n      inputRange,\n    );\n  }\n\n  public buildFormConfigDom(): DomContents {\n    return [\n      dom.create(FormFieldRulesConfig, this.field),\n    ];\n  }\n\n  protected _buildAttachment(value: number, allValues: Computed<number[]>, cell: SingleCell): Element {\n    const filename = this._attachmentsTable.getValue(value, \"fileName\")!;\n    const fileIdent = this._attachmentsTable.getValue(value, \"fileIdent\")!;\n    const height = this._attachmentsTable.getValue(value, \"imageHeight\")!;\n    const width = this._attachmentsTable.getValue(value, \"imageWidth\")!;\n    const hasPreview = Boolean(height);\n    const ratio = hasPreview ? (width / height) : 1;\n\n    return cssAttachmentPreview({ title: filename }, // Add a filename tooltip to the previews.\n      dom.style(\"height\", use => `${use(this._height)}px`),\n      dom.style(\"width\", use => `${parseInt(use(this._height), 10) * ratio}px`),\n      // TODO: Update to legitimately determine whether a file preview exists.\n      hasPreview ? dom(\"img\", { style: \"height: 100%; min-width: 100%; vertical-align: top;\" },\n        dom.attr(\"src\", this._getUrl(value, cell)),\n      ) : renderFileType(filename, fileIdent, this._height),\n      // Open editor as if with input, using it to tell it which of the attachments to show. We\n      // pass in a 1-based index. Hitting a key opens the cell, and this approach allows an\n      // accidental feature of opening e.g. second attachment by hitting \"2\".\n      dom.on(\"dblclick\", ev => commands.allCommands.input.run(String(allValues.get().indexOf(value) + 1), ev)),\n      testId(\"pw-thumbnail\"),\n    );\n  }\n\n  // Returns the attachment download url.\n  private _getUrl(attId: number, cell: SingleCell): string {\n    const docComm = this._getDocComm();\n    return docComm.docUrl(\"attachment\") + \"?\" + encodeQueryParams({\n      ...docComm.getUrlParams(),\n      ...cell,\n      attId,\n      name: this._attachmentsTable.getValue(attId, \"fileName\"),\n    });\n  }\n\n  private _setUploadingState(rowId: UIRowId, uploading: boolean): void {\n    const timeouts = this._uploadingTimeouts;\n    const states = this._uploadingStatesObs.get();\n    if (timeouts[rowId]) {\n      clearTimeout(timeouts[rowId]);\n    }\n    if (uploading) {\n      timeouts[rowId] = window.setTimeout(() => {\n        if (this.isDisposed()) {\n          return;\n        }\n        states[rowId] = true;\n        this._uploadingStatesObs.set({ ...states });\n        // let the view know about the spinner so that it can expands the row height for it if needed\n        const viewInstance = this.field.viewSection().viewInstance();\n        const rowModel = viewInstance?.viewData.getRowModel(rowId);\n        if (rowModel) {\n          viewInstance?.onRowResize([rowModel]);\n        }\n      }, 750);\n    } else {\n      states[rowId] = false;\n      this._uploadingStatesObs.set({ ...states });\n    }\n  }\n\n  private async _selectAndSave(row: DataRowModel, value: KoSaveableObservable<CellValue>): Promise<void> {\n    // keep a copy of the row id at the beginning ; because the given row may change while uploading\n    // (example: user starts uploading in card 2/10, then switches to card 3/10, the upload must save to card 2/10)\n    const rowId = row.getRowId();\n    try {\n      const uploadResult = await selectFiles({\n        docWorkerUrl: this._getDocComm().docWorkerUrl,\n        multiple: true,\n        sizeLimit: \"attachment\",\n      }, (progress) => {\n        if (progress === 0) {\n          this._setUploadingState(rowId, true);\n        }\n      });\n      this._setUploadingState(rowId, false);\n      return this._save(rowId, value, uploadResult);\n    } catch (error) {\n      this._setUploadingState(rowId, false);\n      throw error;\n    }\n  }\n\n  private async _uploadAndSave(row: DataRowModel, value: KoSaveableObservable<CellValue>,\n    files: FileList): Promise<void> {\n    // keep a copy of the row id at the beginning ; because the given row may change while uploading\n    // (example: user starts uploading in card 2/10, then switches to card 3/10, the upload must save to card 2/10)\n    const rowId = row.getRowId();\n    // Move the cursor here (note that this may involve switching active section when dragging\n    // into a cell of an inactive section).\n    commands.allCommands.setCursor.run(row, this.field);\n    try {\n      const uploadResult = await uploadFiles(\n        Array.from(files),\n        { docWorkerUrl: this._getDocComm().docWorkerUrl, sizeLimit: \"attachment\" },\n        (progress) => {\n          if (progress === 0) {\n            this._setUploadingState(rowId, true);\n          }\n        },\n      );\n      this._setUploadingState(rowId, false);\n      return this._save(rowId, value, uploadResult);\n    } catch (error) {\n      this._setUploadingState(rowId, false);\n      throw error;\n    }\n  }\n\n  private async _save(rowId: UIRowId, value: KoSaveableObservable<CellValue>,\n    uploadResult: UploadResult | null,\n  ): Promise<void> {\n    if (!uploadResult) { return; }\n\n    // Add a row if one doesn't already exist.\n    if (rowId === \"new\") {\n      const viewSection = this.field.viewSection();\n      const view = viewSection.viewInstance();\n      if (!view) {\n        throw new Error(`Widget ${viewSection.getRowId()} not found`);\n      }\n\n      rowId = await view.insertRow()!;\n    }\n\n    // Upload the attachments.\n    const rowIds = await this._getDocComm().addAttachments(uploadResult.uploadId);\n\n    const tableId = this.field.tableId();\n    const tableData = this._getDocData().getTable(tableId);\n    if (!tableData) {\n      throw new Error(`Table ${tableId} not found`);\n    }\n\n    // Save the attachment IDs to the cell.\n    // Values should be saved with a leading \"L\" to fit Grist's list value encoding.\n    const formatted: CellValue = value() ? value() : [GristObjCode.List];\n    const newValue = (formatted as number[]).concat(rowIds) as CellValue;\n    await tableData.sendTableAction([\"UpdateRecord\", rowId, {\n      [this.field.colId()]: newValue,\n    }]);\n  }\n}\n\nexport function renderFileType(fileName: string, fileIdent: string, height?: ko.Observable<string>): HTMLElement {\n  // Prepend 'x' to ensure we return the extension even if the basename is empty (e.g. \".xls\").\n  // Take slice(1) to strip off the leading period.\n  const extension = extname(\"x\" + fileName).slice(1) || extname(\"x\" + fileIdent).slice(1) || \"?\";\n  return cssFileType(extension.toUpperCase(),\n    height && cssFileType.cls((use) => {\n      const size = parseFloat(use(height));\n      return size < 28 ? \"-small\" : size < 60 ? \"-medium\" : \"-large\";\n    }),\n  );\n}\n\nconst cssAttachmentWidget = styled(\"div\", `\n  display: flex;\n  flex-wrap: wrap;\n  white-space: pre-wrap;\n\n  &.attachment_drag_over {\n    outline: 2px dashed #ff9a00;\n    outline-offset: -2px;\n  }\n`);\n\nconst cssAttachmentIcon = styled(\"div\", `\n  position: absolute;\n  top: 2px;\n  left: 5px;\n  padding: 2px;\n  background-color: ${theme.attachmentsCellIconBg};\n  color: ${theme.attachmentsCellIconFg};\n  border-radius: 2px;\n  border: none;\n  cursor: pointer;\n  box-shadow: 0 0 0 1px ${theme.cellEditorBg};\n  z-index: 1;\n\n  &:hover {\n    background-color: ${theme.attachmentsCellIconHoverBg};\n  }\n\n  &-hover {\n    display: none;\n  }\n  .${cssAttachmentWidget.className}:hover &-hover {\n    display: inline;\n  }\n\n  &::before {\n    display: block;\n    background-color: var(--grist-control-bg, --grist-theme-text, black);\n    content: ' ';\n    mask-image: var(--icon-FieldAttachment);\n    width: 14px;\n    height: 14px;\n    mask-size: contain;\n    mask-repeat: no-repeat;\n  }\n`);\n\nconst cssAttachmentPreview = styled(\"div\", `\n  color: black;\n  background-color: white;\n  border: 1px solid #bbb;\n  margin: 0 2px 2px 0;\n  position: relative;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  z-index: 0;\n  &:hover {\n    border-color: ${theme.cursor};\n  }\n`);\n\nconst cssSizeLabel = styled(\"div\", `\n  color: ${theme.lightText};\n  margin-right: 9px;\n`);\n\nconst cssFileType = styled(\"div\", `\n  height: 100%;\n  width: 100%;\n  max-height: 80px;\n  max-width: 80px;\n  background-color: ${colors.slate};\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: ${vars.mediumFontSize};\n  font-weight: bold;\n  color: white;\n  overflow: hidden;\n\n  &-small { font-size: ${vars.xxsmallFontSize}; }\n  &-medium { font-size: ${vars.smallFontSize}; }\n  &-large { font-size: ${vars.mediumFontSize}; }\n`);\n\nconst cssSpinner = styled(loadingSpinner, `\n  width: 16px;\n  height: 16px;\n  border-width: 2px;\n  margin-left: 23px;\n\n  &-has-attachments {\n    margin-left: 2px;\n    margin-top: 2px;\n    margin-bottom: 6px;\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/BaseEditor.js",
    "content": "/**\n * Required parameters:\n * @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.\n * @param {Object} options.cellValue: The value in the underlying cell being edited.\n * @param {String} options.editValue: String to be edited, or undefined to use cellValue.\n * @param {Number} options.cursorPos: The initial position where to place the cursor.\n * @param {Object} options.commands: Object mapping command names to functions, to enable as part\n *  of the command group that should be activated while the editor exists.\n */\nfunction BaseEditor(options) {\n}\n\n/**\n * Called after the editor is instantiated to attach its DOM to the page.\n * - cellElem: The element representing the cell that this editor should match\n *   in size and position. Used by derived classes, e.g. to construct an EditorPlacement object.\n */\nBaseEditor.prototype.attach = function(cellElem) {\n  // No-op by default.\n};\n\n/**\n * Returns DOM container with the editor, typically present and attached after attach() has been\n * called.\n */\nBaseEditor.prototype.getDom = function() {\n  return null;\n};\n\n/**\n * Called to get the value to save back to the cell.\n */\nBaseEditor.prototype.getCellValue = function() {\n  throw new Error(\"Not Implemented\");\n};\n\n/**\n * Used if an editor needs perform any actions before a save\n */\nBaseEditor.prototype.prepForSave = function() {\n  // No-op by default.\n};\n\n/**\n * Called to get the text in the editor, used when switching between editing data and formula.\n */\nBaseEditor.prototype.getTextValue = function() {\n  throw new Error(\"Not Implemented\");\n};\n\n/**\n * Called to get the position of the cursor in the editor. Used when switching between editing\n * data and formula.\n */\nBaseEditor.prototype.getCursorPos = function() {\n  throw new Error(\"Not Implemented\");\n};\n\nmodule.exports = BaseEditor;\n"
  },
  {
    "path": "app/client/widgets/CellStyle.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { ColorOption, colorSelect } from \"app/client/ui2018/ColorSelect\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { ConditionalStyle } from \"app/client/widgets/ConditionalStyle\";\n\nimport { Computed, Disposable, dom, DomContents, fromKo, styled } from \"grainjs\";\n\nconst t = makeT(\"CellStyle\");\n\nexport class CellStyle extends Disposable {\n  constructor(\n    private _field: ViewFieldRec,\n    private _gristDoc: GristDoc,\n    private _defaultTextColor: string | undefined,\n  ) {\n    super();\n  }\n\n  public buildDom(): DomContents {\n    const isTableWidget = this._field.viewSection().parentKey() === \"record\";\n    return [\n      dom.maybe(use => isTableWidget, () => {\n        return [\n          cssLine(\n            cssLabel(t(\"HEADER STYLE\")),\n          ),\n          cssRow(\n            testId(\"header-color-select\"),\n            dom.domComputedOwned(fromKo(this._field.config.headerStyle), (holder, options) => {\n              const headerTextColor = fromKo(options.prop(\"headerTextColor\"));\n              const headerFillColor = fromKo(options.prop(\"headerFillColor\"));\n              const headerFontBold = fromKo(options.prop(\"headerFontBold\"));\n              const headerFontUnderline = fromKo(options.prop(\"headerFontUnderline\"));\n              const headerFontItalic = fromKo(options.prop(\"headerFontItalic\"));\n              const headerFontStrikethrough = fromKo(options.prop(\"headerFontStrikethrough\"));\n              const hasMixedStyle = Computed.create(holder, (use) => {\n                if (!use(this._field.config.multiselect)) { return false; }\n                const commonStyle = [\n                  use(options.mixed(\"headerTextColor\")),\n                  use(options.mixed(\"headerFillColor\")),\n                  use(options.mixed(\"headerFontBold\")),\n                  use(options.mixed(\"headerFontUnderline\")),\n                  use(options.mixed(\"headerFontItalic\")),\n                  use(options.mixed(\"headerFontStrikethrough\")),\n                ];\n                return commonStyle.some(Boolean);\n              });\n              return colorSelect(\n                {\n                  textColor: new ColorOption({\n                    color: headerTextColor,\n                    defaultColor: theme.tableHeaderFg.toString(),\n                    allowsNone: true,\n                    noneText: \"default\",\n                  }),\n                  fillColor: new ColorOption({\n                    color: headerFillColor,\n                    allowsNone: true,\n                    noneText: \"none\",\n                  }),\n                  fontBold: headerFontBold,\n                  fontItalic: headerFontItalic,\n                  fontUnderline: headerFontUnderline,\n                  fontStrikethrough: headerFontStrikethrough,\n                },\n                {\n                  onSave: () => options.save(),\n                  onRevert: () => options.revert(),\n                  placeholder: use => use(hasMixedStyle) ? t(\"Mixed style\") : t(\"Default header style\"),\n                },\n              );\n            }),\n          )];\n      }),\n      cssLine(\n        cssLabel(t(\"CELL STYLE\")),\n        cssButton(\n          t(\"Open row styles\"),\n          dom.on(\"click\", allCommands.viewTabOpen.run),\n          dom.hide(!isTableWidget),\n        ),\n      ),\n      cssRow(\n        testId(\"cell-color-select\"),\n        dom.domComputedOwned(fromKo(this._field.config.style), (holder, options) => {\n          const textColor = fromKo(options.prop(\"textColor\"));\n          const fillColor = fromKo(options.prop(\"fillColor\"));\n          const fontBold = fromKo(options.prop(\"fontBold\"));\n          const fontUnderline = fromKo(options.prop(\"fontUnderline\"));\n          const fontItalic = fromKo(options.prop(\"fontItalic\"));\n          const fontStrikethrough = fromKo(options.prop(\"fontStrikethrough\"));\n          const hasMixedStyle = Computed.create(holder, (use) => {\n            if (!use(this._field.config.multiselect)) { return false; }\n            const commonStyle = [\n              use(options.mixed(\"textColor\")),\n              use(options.mixed(\"fillColor\")),\n              use(options.mixed(\"fontBold\")),\n              use(options.mixed(\"fontUnderline\")),\n              use(options.mixed(\"fontItalic\")),\n              use(options.mixed(\"fontStrikethrough\")),\n            ];\n            return commonStyle.some(Boolean);\n          });\n          return colorSelect(\n            {\n              textColor: new ColorOption({\n                color: textColor,\n                defaultColor: this._defaultTextColor,\n                allowsNone: true,\n                noneText: \"default\",\n              }),\n              fillColor: new ColorOption({\n                color: fillColor,\n                allowsNone: true,\n                noneText: \"none\",\n              }),\n              fontBold: fontBold,\n              fontItalic: fontItalic,\n              fontUnderline: fontUnderline,\n              fontStrikethrough: fontStrikethrough,\n            }, {\n              onSave: () => options.save(),\n              onRevert: () => options.revert(),\n              placeholder: use => use(hasMixedStyle) ? t(\"Mixed style\") : t(\"Default cell style\"),\n            },\n          );\n        }),\n      ),\n      dom.create(\n        ConditionalStyle,\n        t(\"Cell style\"),\n        this._field,\n        this._gristDoc,\n        fromKo(this._field.config.multiselect),\n      ),\n    ];\n  }\n}\n\nconst cssLine = styled(\"div\", `\n  display: flex;\n  margin: 16px 16px 8px 16px;\n  justify-content: space-between;\n  align-items: baseline;\n`);\n\nconst cssLabel = styled(\"div\", `\n  color: ${theme.text};\n  text-transform: uppercase;\n  font-size: ${vars.xsmallFontSize};\n`);\n\nconst cssButton = styled(textButton, `\n  font-size: ${vars.mediumFontSize};\n`);\n\nconst cssRow = styled(\"div\", `\n  display: flex;\n  margin: 8px 16px;\n  align-items: center;\n  &-top-space {\n    margin-top: 24px;\n  }\n  &-disabled {\n    color: ${theme.disabledText};\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/CheckBox.css",
    "content": "\n.widget_checkbox {\n  position: relative;\n  margin: -1px auto;\n  width: 16px;\n  height: 16px;\n}\n\n:not(.formula_field)>.field_clip.has_cursor>.widget_checkbox {\n  cursor: pointer;\n  box-shadow: inset 0 0 0 1px #606060;\n  border-radius: 3px;\n  background: linear-gradient(to bottom, rgba(255,255,255,1) 0%, rgba(252,252,252,1) 29%, rgba(239,239,239,1) 50%, rgba(232,232,232,1) 50%, rgba(242,242,242,1) 100%);\n}\n:not(.formula_field)>.field_clip>.widget_checkbox:hover:not(.disabled) {\n  cursor: pointer;\n  box-shadow: inset 0 0 0 1px #606060;\n  border-radius: 3px;\n  background: linear-gradient(to bottom, rgba(255,255,255,1) 0%, rgba(252,252,252,1) 29%, rgba(239,239,239,1) 50%, rgba(232,232,232,1) 50%, rgba(242,242,242,1) 100%);\n}\n\n:not(.formula_field)>.field_clip>.widget_checkbox:active:not(.disabled) {\n  background: linear-gradient(to bottom, rgba(147,180,242,1) 0%, rgba(135,168,233,1) 10%, rgba(115,149,218,1) 25%, rgba(115,150,224,1) 37%, rgba(115,153,230,1) 50%, rgba(86,134,219,1) 51%, rgba(130,174,235,1) 83%, rgba(151,194,243,1) 100%);\n}\n\n.widget_checkbox:focus {\n  outline: none !important;\n}\n\n.widget_checkmark {\n  position: relative;\n  width: 6px;\n  height: 12px;\n  -ms-transform: rotate(40deg); /* IE 9 */\n  -webkit-transform: rotate(40deg); /* Chrome, Safari, Opera */\n  transform: rotate(40deg);\n  left: 4px;\n  top: 2px;\n}\n\n.checkmark_stem {\n  position: relative;\n  width: 3px;\n  height: 12px;\n  background-color: var(--grist-actual-cell-color, var(--grist-theme-toggle-checkbox-fg, #606060));\n  border: 1px solid var(--grist-actual-cell-color, var(--grist-theme-toggle-checkbox-fg, #606060));\n  left: 3px;\n  top: -5px;\n}\n\n.checkmark_kick {\n  position: relative;\n  width: 3px;\n  height: 3px;\n  background-color: var(--grist-actual-cell-color, var(--grist-theme-toggle-checkbox-fg, #606060));\n  border: 1px solid var(--grist-actual-cell-color, var(--grist-theme-toggle-checkbox-fg, #606060));\n  top: 7px;\n}\n"
  },
  {
    "path": "app/client/widgets/CheckBoxEditor.js",
    "content": "var dispose = require(\"../lib/dispose\");\nvar _ = require(\"underscore\");\nvar TextEditor = require(\"./TextEditor\");\n\n\nfunction CheckBoxEditor(options) {\n  TextEditor.call(this, options);\n}\ndispose.makeDisposable(CheckBoxEditor);\n_.extend(CheckBoxEditor.prototype, TextEditor.prototype);\n\n// For documentation, see NewBaseEditor.ts\nCheckBoxEditor.skipEditor = function(typedVal, cellVal, {event}) {\n  // eslint-disable-next-line no-undef\n  if (typedVal === \"<enter>\" || (event && event instanceof KeyboardEvent)) {\n    // This is a special case when user hits <enter>. We return the toggled value to save, and by\n    // this indicate that the editor should not open.\n    return !cellVal;\n  }\n};\n\n// For documentation, see NewBaseEditor.ts\nCheckBoxEditor.supportsReadonly = function() { return false; };\n\nmodule.exports = CheckBoxEditor;\n"
  },
  {
    "path": "app/client/widgets/ChoiceEditor.js",
    "content": "var _ = require(\"underscore\");\nvar dispose = require(\"app/client/lib/dispose\");\nvar TextEditor = require(\"app/client/widgets/TextEditor\");\n\nconst {Autocomplete} = require(\"app/client/lib/autocomplete\");\nconst {ACIndexImpl, buildHighlightedDom} = require(\"app/client/lib/ACIndex\");\nconst {makeT} = require(\"app/client/lib/localization\");\nconst {\n  buildDropdownConditionFilter,\n  ChoiceItem,\n  cssChoiceList,\n  cssMatchText,\n  cssPlusButton,\n  cssPlusIcon,\n} = require(\"app/client/widgets/ChoiceListEditor\");\nconst {icon} = require(\"app/client/ui2018/icons\");\nconst {menuCssClass} = require(\"app/client/ui2018/menus\");\nconst {testId, theme} = require(\"app/client/ui2018/cssVars\");\nconst {choiceToken, cssChoiceACItem} = require(\"app/client/widgets/ChoiceToken\");\nconst {dom, styled} = require(\"grainjs\");\n\nconst t = makeT(\"ChoiceEditor\");\n\n/**\n * ChoiceEditor - TextEditor with a dropdown for possible choices.\n */\nfunction ChoiceEditor(options) {\n  TextEditor.call(this, options);\n\n  this.widgetOptionsJson = options.field.widgetOptionsJson;\n  this.choices = this.widgetOptionsJson.peek().choices || [];\n  this.choicesSet = new Set(this.choices);\n  this.choiceOptions = this.widgetOptionsJson.peek().choiceOptions || {};\n\n  this.hasDropdownCondition = Boolean(options.field.dropdownCondition.peek()?.text);\n  this.dropdownConditionError;\n\n  let acItems = this.choices.map(c => new ChoiceItem(c, false, false));\n  if (this.hasDropdownCondition) {\n    try {\n      const dropdownConditionFilter = this.buildDropdownConditionFilter();\n      acItems = acItems.filter((item) => dropdownConditionFilter(item));\n    } catch (e) {\n      acItems = [];\n      this.dropdownConditionError = e.message;\n    }\n  }\n\n  const acIndex = new ACIndexImpl(acItems);\n  this._acOptions = {\n    popperOptions: {\n      placement: \"bottom\"\n    },\n    menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,\n    buildNoItemsMessage: this.buildNoItemsMessage.bind(this),\n    search: (term) => this.maybeShowAddNew(acIndex.search(term), term),\n    renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc),\n    getItemText: (item) => item.label,\n    onClick: () => this.options.commands.fieldEditSave(),\n  };\n\n  if (!options.readonly && options.field.viewSection().parentKey() === \"single\") {\n    this.cellEditorDiv.classList.add(cssChoiceEditor.className);\n    this.cellEditorDiv.appendChild(cssChoiceEditIcon(\"Dropdown\"));\n  }\n\n  // Whether to include a button to show a new choice.\n  // TODO: Disable when the user cannot change column configuration.\n  this.enableAddNew = !this.hasDropdownCondition;\n}\n\ndispose.makeDisposable(ChoiceEditor);\n_.extend(ChoiceEditor.prototype, TextEditor.prototype);\n\nChoiceEditor.prototype.getCellValue = function() {\n  const selectedItem = this.autocomplete && this.autocomplete.getSelectedItem();\n  if (selectedItem) {\n    return selectedItem.label;\n  } else if (this.textInput.value.trim() === \"\") {\n    return \"\";\n  } else {\n    return TextEditor.prototype.getCellValue.call(this);\n  }\n};\n\nChoiceEditor.prototype.renderACItem = function(item, highlightFunc) {\n  const options = this.choiceOptions[item.label];\n\n  return cssChoiceACItem(\n    (item.isNew ?\n      [cssChoiceACItem.cls(\"-new\"), cssPlusButton(cssPlusIcon(\"Plus\")), testId(\"choice-editor-new-item\")] :\n      [cssChoiceACItem.cls(\"-with-new\", this.showAddNew)]\n    ),\n    choiceToken(\n      buildHighlightedDom(item.label, highlightFunc, cssMatchText),\n      options || {},\n      dom.style(\"max-width\", \"100%\"),\n      testId(\"choice-editor-item-label\")\n    ),\n    testId(\"choice-editor-item\"),\n  );\n};\n\nChoiceEditor.prototype.attach = function(cellElem) {\n  TextEditor.prototype.attach.call(this, cellElem);\n  // Don't create autocomplete if readonly.\n  if (this.options.readonly) { return; }\n\n  this.autocomplete = Autocomplete.create(this, this.textInput, this._acOptions);\n};\n\n/**\n * Updates list of valid choices with any new ones that may have been\n * added from directly inside the editor (via the \"add new\" item in autocomplete).\n */\nChoiceEditor.prototype.prepForSave = async function() {\n  const selectedItem = this.autocomplete && this.autocomplete.getSelectedItem();\n  if (selectedItem && selectedItem.isNew) {\n    const choices = this.widgetOptionsJson.prop(\"choices\");\n    await choices.saveOnly([...(choices.peek() || []), selectedItem.label]);\n  }\n};\n\nChoiceEditor.prototype.buildDropdownConditionFilter = function() {\n  const dropdownConditionCompiled = this.options.field.dropdownConditionCompiled.get();\n  if (dropdownConditionCompiled?.kind !== \"success\") {\n    throw new Error(\"Dropdown condition is not compiled\");\n  }\n\n  return buildDropdownConditionFilter({\n    dropdownConditionCompiled: dropdownConditionCompiled.result,\n    gristDoc: this.options.gristDoc,\n    tableId: this.options.field.tableId(),\n    rowId: this.options.rowId,\n  });\n};\n\nChoiceEditor.prototype.buildNoItemsMessage = function() {\n  if (this.dropdownConditionError) {\n    return t(\"Error in dropdown condition\");\n  } else if (this.hasDropdownCondition) {\n    return t(\"No choices matching condition\");\n  } else {\n    return t(\"No choices to select\");\n  }\n};\n\n/**\n * If the search text does not match anything exactly, adds 'new' item to it.\n *\n * Also see: prepForSave.\n */\nChoiceEditor.prototype.maybeShowAddNew = function(result, text) {\n  // TODO: This logic is also mostly duplicated in ChoiceListEditor and ReferenceEditor.\n  // See if there's anything common we can factor out and re-use.\n  this.showAddNew = false;\n  if (!this.enableAddNew) {\n    return result;\n  }\n\n  const trimmedText = text.trim();\n  if (!trimmedText || this.choicesSet.has(trimmedText)) {\n    return result;\n  }\n\n  const addNewItem = new ChoiceItem(trimmedText, false, false, true);\n  if (result.items.find((item) => item.cleanText === addNewItem.cleanText)) {\n    return result;\n  }\n\n  result.extraItems.push(addNewItem);\n  this.showAddNew = true;\n\n  return result;\n};\n\nconst cssChoiceEditIcon = styled(icon, `\n  background-color: ${theme.lightText};\n  position: absolute;\n  top: 0;\n  left: 0;\n  margin: 3px 3px 0 3px;\n`);\n\nconst cssChoiceEditor = styled(\"div\", `\n  & > .celleditor_text_editor, & > .celleditor_content_measure {\n    padding-left: 18px;\n  }\n`);\n\nmodule.exports = ChoiceEditor;\n"
  },
  {
    "path": "app/client/widgets/ChoiceListCell.ts",
    "content": "import {\n  FormFieldRulesConfig,\n  FormOptionsAlignmentConfig,\n  FormOptionsLimitConfig,\n  FormOptionsSortConfig,\n} from \"app/client/components/Forms/FormConfig\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport {\n  ChoiceOptionsByName,\n  ChoiceTextBox,\n} from \"app/client/widgets/ChoiceTextBox\";\nimport { choiceToken } from \"app/client/widgets/ChoiceToken\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nimport { dom, styled } from \"grainjs\";\n\n/**\n * ChoiceListCell - A cell that renders a list of choice tokens.\n */\nexport class ChoiceListCell extends ChoiceTextBox {\n  public buildDom(row: DataRowModel) {\n    const value = row.cells[this.field.colId.peek()];\n\n    return cssChoiceList(\n      dom.cls(\"field_clip\"),\n      cssChoiceList.cls(\"-wrap\", this.wrapping),\n      dom.style(\"justify-content\", use => use(this.alignment) === \"right\" ? \"flex-end\" : use(this.alignment)),\n      dom.domComputed((use) => {\n        return use(row._isAddRow) ? null :\n          [\n            use(value), use(this.getChoiceValuesSet()),\n            use(this.getChoiceOptions()),\n          ] as [CellValue, Set<string>, ChoiceOptionsByName];\n      }, (input) => {\n        if (!input) { return null; }\n        const [rawValue, choiceSet, choiceOptionsByName] = input;\n        const val = decodeObject(rawValue);\n        if (!val) { return null; }\n        // Handle any unexpected values we might get (non-array, or array with non-strings).\n        const tokens: unknown[] = Array.isArray(val) ? val : [val];\n        return tokens.map((token) => {\n          const isBlank = String(token).trim() === \"\";\n          return choiceToken(\n            isBlank ? \"[Blank]\" : String(token),\n            {\n              ...(choiceOptionsByName.get(String(token)) || {}),\n              invalid: !choiceSet.has(String(token)),\n              blank: String(token).trim() === \"\",\n            },\n            dom.cls(cssToken.className),\n            testId(\"choice-list-cell-token\"),\n          );\n        });\n      }),\n    );\n  }\n\n  public buildFormConfigDom() {\n    return [\n      this.buildChoicesConfigDom(),\n      dom.create(FormOptionsAlignmentConfig, this.field),\n      dom.create(FormOptionsSortConfig, this.field),\n      dom.create(FormOptionsLimitConfig, this.field),\n      dom.create(FormFieldRulesConfig, this.field),\n    ];\n  }\n}\n\n// When \"max row height\" is set, we try hard to keep lines to the same height as lines of normal\n// text, since the overflow ellipsis is based on lines of text rather than pixel height, and a\n// discrepancy will cause it to show below the visible area or in the middle of text.\n// We also treat it as if line-wrapping is on, as there seems little advantage to keep the cell to\n// a single line when an explicit max number of lines is set.\nexport const cssChoiceList = styled(\"div\", `\n  display: flex;\n  align-content: start;\n  align-items: start;\n  padding: 0 3px;\n\n  position: relative;\n  min-height: 22px;\n\n  &-wrap {\n    flex-wrap: wrap;\n  }\n\n  .row_height_set & {\n    flex-wrap: wrap;\n    word-break: break-word;\n    white-space: pre-wrap;\n    padding: 2px 3px 0 3px;\n    line-height: 0px;\n  }\n`);\n\nexport const cssToken = styled(\"div\", `\n  flex: 0 1 auto;\n  min-width: 0px;\n  margin: 2px;\n  line-height: 16px;\n\n  .row_height_set & {\n    margin: 1px 2px;\n    padding-top: 0px;\n    padding-bottom: 0px;\n    vertical-align: text-bottom;\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/ChoiceListEditor.ts",
    "content": "import { createGroup } from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { ACIndexImpl, ACItem, ACResults,\n  buildHighlightedDom, HighlightFunc, normalizeText } from \"app/client/lib/ACIndex\";\nimport { IAutocompleteOptions } from \"app/client/lib/autocomplete\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { IToken, TokenField, tokenFieldStyles } from \"app/client/lib/TokenField\";\nimport { colors, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\nimport { ChoiceOptions } from \"app/client/widgets/ChoiceTextBox\";\nimport { choiceToken, choiceTokenDomArgs, cssChoiceACItem } from \"app/client/widgets/ChoiceToken\";\nimport { createMobileButtons, getButtonMargins } from \"app/client/widgets/EditorButtons\";\nimport { EditorPlacement } from \"app/client/widgets/EditorPlacement\";\nimport { FieldOptions, NewBaseEditor } from \"app/client/widgets/NewBaseEditor\";\nimport { csvEncodeRow } from \"app/common/csvFormat\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { CompiledPredicateFormula } from \"app/common/PredicateFormula\";\nimport { EmptyRecordView } from \"app/common/RecordView\";\nimport { decodeObject, encodeObject } from \"app/plugin/objtypes\";\n\nimport { dom, styled } from \"grainjs\";\n\nconst t = makeT(\"ChoiceListEditor\");\n\nexport class ChoiceItem implements ACItem, IToken {\n  public cleanText: string = normalizeText(this.label);\n  constructor(\n    public label: string,\n    public isInvalid: boolean,  // If set, this token is not one of the valid choices.\n    public isBlank: boolean,    // If set, this token is blank.\n    public isNew?: boolean,     // If set, this is a choice to be added to the config.\n  ) {}\n}\n\nexport class ChoiceListEditor extends NewBaseEditor {\n  protected cellEditorDiv: HTMLElement;\n  protected commandGroup: any;\n\n  private _tokenField: TokenField<ChoiceItem>;\n  private _textInput: HTMLInputElement;\n  private _dom: HTMLElement;\n  private _editorPlacement!: EditorPlacement;\n  private _contentSizer: HTMLElement;   // Invisible element to size the editor with all the tokens\n  private _inputSizer!: HTMLElement;     // Part of _contentSizer to size the text input\n  private _alignment: string;\n\n  private _widgetOptionsJson = this.options.field.widgetOptionsJson.peek();\n  private _choices: string[] = this._widgetOptionsJson.choices || [];\n  private _choicesSet = new Set<string>(this._choices);\n  private _choiceOptionsByName: ChoiceOptions = this._widgetOptionsJson.choiceOptions || {};\n\n  // Whether to include a button to show a new choice.\n  // TODO: Disable when the user cannot change column configuration.\n  private _enableAddNew: boolean;\n  private _showAddNew: boolean = false;\n\n  private _hasDropdownCondition = Boolean(this.options.field.dropdownCondition.peek()?.text);\n  private _dropdownConditionError: string | undefined;\n\n  constructor(protected options: FieldOptions) {\n    super(options);\n\n    let acItems = this._choices.map(c => new ChoiceItem(c, false, false));\n    if (this._hasDropdownCondition) {\n      try {\n        const dropdownConditionFilter = this._buildDropdownConditionFilter();\n        acItems = acItems.filter(item => dropdownConditionFilter(item));\n      } catch (e) {\n        acItems = [];\n        this._dropdownConditionError = e.message;\n      }\n    }\n\n    const acIndex = new ACIndexImpl<ChoiceItem>(acItems);\n    const acOptions: IAutocompleteOptions<ChoiceItem> = {\n      menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,\n      buildNoItemsMessage: this._buildNoItemsMessage.bind(this),\n      search: async (term: string) => this._maybeShowAddNew(acIndex.search(term), term),\n      renderItem: (item, highlightFunc) => this._renderACItem(item, highlightFunc),\n      getItemText: item => item.label,\n    };\n\n    this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));\n    this._alignment = options.field.widgetOptionsJson.peek().alignment || \"left\";\n\n    // If starting to edit by typing in a string, ignore previous tokens.\n    const cellValue = decodeObject(options.cellValue);\n    const startLabels: unknown[] = options.editValue !== undefined || !Array.isArray(cellValue) ? [] : cellValue;\n    const startTokens = startLabels.map(label => new ChoiceItem(\n      String(label),\n      !this._choicesSet.has(String(label)),\n      String(label).trim() === \"\",\n    ));\n\n    this._tokenField = TokenField.ctor<ChoiceItem>().create(this, {\n      initialValue: startTokens,\n      renderToken: item => choiceTokenDomArgs(\n        item.isBlank ? \"[Blank]\" : item.label,\n        {\n          ...(this._choiceOptionsByName[item.label] || {}),\n          invalid: item.isInvalid,\n          blank: item.isBlank,\n        },\n      ),\n      createToken: label => new ChoiceItem(label, !this._choicesSet.has(label), label.trim() === \"\"),\n      acOptions,\n      openAutocompleteOnFocus: true,\n      readonly: options.readonly,\n      trimLabels: true,\n      styles: { cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon },\n    });\n\n    this._dom = dom(\"div.default_editor\",\n      dom.cls(\"readonly_editor\", options.readonly),\n      dom.cls(cssReadonlyStyle.className, options.readonly),\n      this.cellEditorDiv = cssCellEditor(testId(\"widget-text-editor\"),\n        this._contentSizer = cssContentSizer(),\n        elem => this._tokenField.attach(elem),\n      ),\n      createMobileButtons(options.commands),\n    );\n\n    this._textInput = this._tokenField.getTextInput();\n    dom.update(this._tokenField.getRootElem(),\n      dom.style(\"justify-content\", this._alignment),\n    );\n    dom.update(this._tokenField.getHiddenInput(),\n      this.commandGroup.attach(),\n    );\n    dom.update(this._textInput,\n      // Resize the editor whenever user types into the textbox.\n      dom.on(\"input\", () => this.resizeInput(true)),\n      dom.prop(\"value\", options.editValue || \"\"),\n      this.commandGroup.attach(),\n    );\n\n    dom.update(this._dom,\n      dom.on(\"click\", () => this._textInput.focus()),\n    );\n\n    this._enableAddNew = !this._hasDropdownCondition;\n  }\n\n  public attach(cellElem: Element): void {\n    // Attach the editor dom to page DOM.\n    this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, { margins: getButtonMargins() });\n\n    // Reposition the editor if needed for external reasons (in practice, window resize).\n    this.autoDispose(this._editorPlacement.onReposition.addListener(() => this.resizeInput()));\n\n    // Update the sizing whenever the tokens change. Delay it till next tick to give a chance for\n    // DOM updates that happen around tokenObs changes, to complete.\n    this.autoDispose(this._tokenField.tokensObs.addListener(() =>\n      Promise.resolve().then(() => this.resizeInput())));\n\n    this.setSizerLimits();\n\n    // Once the editor is attached to DOM, resize it to content, focus, and set cursor.\n    this.resizeInput();\n    this._textInput.focus();\n    const pos = Math.min(this.options.cursorPos, this._textInput.value.length);\n    this._textInput.setSelectionRange(pos, pos);\n  }\n\n  public getDom(): HTMLElement {\n    return this._dom;\n  }\n\n  public getCellValue(): CellValue {\n    return encodeObject(this._tokenField.tokensObs.get().map(item => item.label));\n  }\n\n  public getTextValue() {\n    const values = this._tokenField.tokensObs.get().map(token => token.label);\n    return csvEncodeRow(values, { prettier: true });\n  }\n\n  public getCursorPos(): number {\n    return this._textInput.selectionStart || 0;\n  }\n\n  /**\n   * Updates list of valid choices with any new ones that may have been\n   * added from directly inside the editor (via the \"add new\" item in autocomplete).\n   */\n  public async prepForSave() {\n    const tokens = this._tokenField.tokensObs.get();\n    const newChoices = tokens.filter(({ isNew }) => isNew).map(({ label }) => label);\n    if (newChoices.length > 0) {\n      const choices = this.options.field.widgetOptionsJson.prop(\"choices\");\n      await choices.saveOnly([...(choices.peek() || []), ...new Set(newChoices)]);\n    }\n  }\n\n  public setSizerLimits() {\n    // Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap\n    // once we reach it.\n    const rootElem = this._tokenField.getRootElem();\n    const maxSize = this._editorPlacement.calcSizeWithPadding(rootElem,\n      { width: Infinity, height: Infinity }, { calcOnly: true });\n    this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + \"px\";\n  }\n\n  /**\n   * Helper which resizes the token-field to match its content.\n   */\n  protected resizeInput(onlyTextInput: boolean = false) {\n    if (this.isDisposed()) { return; }\n\n    const rootElem = this._tokenField.getRootElem();\n\n    // To size the content, we need both the tokens and the text typed into _textInput. We\n    // re-create the tokens using cloneNode(true) copies all styles and properties, but not event\n    // handlers. We can skip this step when we know that only _textInput changed.\n    if (!onlyTextInput || !this._inputSizer) {\n      this._contentSizer.innerHTML = \"\";\n\n      dom.update(this._contentSizer,\n        dom.update(rootElem.cloneNode(true) as HTMLElement,\n          dom.style(\"width\", \"\"),\n          dom.style(\"height\", \"\"),\n          this._inputSizer = cssInputSizer(),\n\n          // Remove the testId('tokenfield') from the cloned element, to simplify tests (so that\n          // selecting .test-tokenfield only returns the actual visible tokenfield container).\n          dom.cls(\"test-tokenfield\", false),\n        ),\n      );\n    }\n\n    // Use a separate sizer to size _textInput to the text inside it.\n    // \\u200B is a zero-width space; so the sizer will have height even when empty.\n    this._inputSizer.textContent = this._textInput.value + \"\\u200B\";\n    const rect = this._contentSizer.getBoundingClientRect();\n\n    const size = this._editorPlacement.calcSizeWithPadding(rootElem, rect);\n    rootElem.style.width = size.width + \"px\";\n    rootElem.style.height = size.height + \"px\";\n    this._textInput.style.width = this._inputSizer.getBoundingClientRect().width + \"px\";\n  }\n\n  private _buildDropdownConditionFilter() {\n    const dropdownConditionCompiled = this.options.field.dropdownConditionCompiled.get();\n    if (dropdownConditionCompiled?.kind !== \"success\") {\n      throw new Error(\"Dropdown condition is not compiled\");\n    }\n\n    return buildDropdownConditionFilter({\n      dropdownConditionCompiled: dropdownConditionCompiled.result,\n      gristDoc: this.options.gristDoc,\n      tableId: this.options.field.tableId(),\n      rowId: this.options.rowId,\n    });\n  }\n\n  private _buildNoItemsMessage(): string {\n    if (this._dropdownConditionError) {\n      return t(\"Error in dropdown condition\");\n    } else if (this._hasDropdownCondition) {\n      return t(\"No choices matching condition\");\n    } else {\n      return t(\"No choices to select\");\n    }\n  }\n\n  /**\n   * If the search text does not match anything exactly, adds 'new' item to it.\n   *\n   * Also see: prepForSave.\n   */\n  private _maybeShowAddNew(result: ACResults<ChoiceItem>, text: string): ACResults<ChoiceItem> {\n    this._showAddNew = false;\n    if (!this._enableAddNew) {\n      return result;\n    }\n\n    const trimmedText = text.trim();\n    if (!trimmedText || this._choicesSet.has(trimmedText)) {\n      return result;\n    }\n\n    const addNewItem = new ChoiceItem(trimmedText, false, false, true);\n    if (result.items.find(item => item.cleanText === addNewItem.cleanText)) {\n      return result;\n    }\n\n    result.extraItems.push(addNewItem);\n    this._showAddNew = true;\n\n    return result;\n  }\n\n  private _renderACItem(item: ChoiceItem, highlightFunc: HighlightFunc) {\n    const options = this._choiceOptionsByName[item.label];\n\n    return cssChoiceACItem(\n      (item.isNew ?\n        [cssChoiceACItem.cls(\"-new\"), cssPlusButton(cssPlusIcon(\"Plus\"))] :\n        [cssChoiceACItem.cls(\"-with-new\", this._showAddNew)]\n      ),\n      choiceToken(\n        buildHighlightedDom(item.label, highlightFunc, cssMatchText),\n        options || {},\n        dom.style(\"max-width\", \"100%\"),\n        testId(\"choice-list-editor-item-label\"),\n      ),\n      testId(\"choice-list-editor-item\"),\n      item.isNew ? testId(\"choice-list-editor-new-item\") : null,\n    );\n  }\n}\n\nexport interface GetACFilterFuncParams {\n  dropdownConditionCompiled: CompiledPredicateFormula;\n  gristDoc: GristDoc;\n  tableId: string;\n  rowId: number;\n}\n\nexport function buildDropdownConditionFilter(\n  params: GetACFilterFuncParams,\n): (item: ChoiceItem) => boolean {\n  const { dropdownConditionCompiled, gristDoc, tableId, rowId } = params;\n  const table = gristDoc.docData.getTable(tableId);\n  if (!table) { throw new Error(`Table ${tableId} not found`); }\n\n  const user = gristDoc.docPageModel.user.get() ?? undefined;\n  const rec = table.getRecord(rowId) || new EmptyRecordView();\n  return (item: ChoiceItem) => dropdownConditionCompiled({ user, rec, choice: item.label });\n}\n\nconst cssCellEditor = styled(\"div\", `\n  background-color: ${theme.cellEditorBg};\n  cursor: text;\n  font-family: var(--grist-font-family-data);\n  font-size: var(--grist-medium-font-size);\n`);\n\nconst cssTokenField = styled(tokenFieldStyles.cssTokenField, `\n  border: none;\n  align-items: start;\n  align-content: start;\n  padding: 0 3px;\n  height: min-content;\n  min-height: 22px;\n  color: black;\n  flex-wrap: wrap;\n`);\n\nexport const cssToken = styled(tokenFieldStyles.cssToken, `\n  padding: 1px 4px;\n  margin: 2px;\n  line-height: 16px;\n  white-space: pre;\n\n  &.selected {\n    box-shadow: inset 0 0 0 1px ${theme.choiceTokenSelectedBorder};\n  }\n`);\n\nexport const cssDeleteButton = styled(tokenFieldStyles.cssDeleteButton, `\n  position: absolute;\n  top: -8px;\n  right: -6px;\n  border-radius: 16px;\n  background-color: ${colors.dark};\n  width: 14px;\n  height: 14px;\n  cursor: pointer;\n  z-index: 1;\n  display: none;\n  align-items: center;\n  justify-content: center;\n\n  .${cssToken.className}:hover & {\n    display: flex;\n  }\n  .${cssTokenField.className}.token-dragactive & {\n    cursor: unset;\n  }\n`);\n\nexport const cssDeleteIcon = styled(tokenFieldStyles.cssDeleteIcon, `\n  --icon-color: ${colors.light};\n  &:hover {\n    --icon-color: ${colors.darkGrey};\n  }\n`);\n\nconst cssContentSizer = styled(\"div\", `\n  position: absolute;\n  left: 0;\n  top: -100px;\n  border: none;\n  visibility: hidden;\n  overflow: visible;\n  width: max-content;\n\n  & .${tokenFieldStyles.cssInputWrapper.className} {\n    display: none;\n  }\n`);\n\nconst cssInputSizer = styled(\"div\", `\n  flex: auto;\n  min-width: 24px;\n  margin: 3px 2px;\n`);\n\n// Set z-index to be higher than the 1000 set for .cell_editor.\nexport const cssChoiceList = styled(\"div\", `\n  z-index: 1001;\n  box-shadow: 0 0px 8px 0 ${theme.menuShadow};\n  overflow-y: auto;\n  padding: 8px 0 0 0;\n  --weaseljs-menu-item-padding: 8px 16px;\n`);\n\nconst cssReadonlyStyle = styled(\"div\", `\n  padding-left: 16px;\n  background: ${theme.cellEditorBg};\n`);\n\nexport const cssMatchText = styled(\"span\", `\n  text-decoration: underline;\n`);\n\nexport const cssPlusButton = styled(\"div\", `\n  display: flex;\n  width: 20px;\n  height: 20px;\n  border-radius: 20px;\n  margin-right: 8px;\n  align-items: center;\n  justify-content: center;\n  background-color: ${theme.autocompleteAddNewCircleBg};\n  color: ${theme.autocompleteAddNewCircleFg};\n\n  .selected > & {\n    background-color: ${theme.autocompleteAddNewCircleSelectedBg};\n  }\n`);\n\nexport const cssPlusIcon = styled(icon, `\n  background-color: ${theme.autocompleteAddNewCircleFg};\n`);\n"
  },
  {
    "path": "app/client/widgets/ChoiceListEntry.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { IToken, TokenField } from \"app/client/lib/TokenField\";\nimport { cssBlockedCursor } from \"app/client/ui/RightPanelStyles\";\nimport { basicButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { colorButton, ColorOption } from \"app/client/ui2018/ColorSelect\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { editableLabel } from \"app/client/ui2018/editableLabel\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { ChoiceOptionsByName, IChoiceOptions } from \"app/client/widgets/ChoiceTextBox\";\n\nimport {\n  Computed, Disposable, dom, DomContents, DomElementArg, Holder, MultiHolder, Observable, styled,\n} from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\nimport uniqBy from \"lodash/uniqBy\";\nimport { createCheckers, iface, ITypeSuite, opt, union } from \"ts-interface-checker\";\n\nconst t = makeT(\"ChoiceListEntry\");\n\nclass RenameMap implements Record<string, string> {\n  constructor(tokens: ChoiceItem[]) {\n    for (const { label, previousLabel: id } of tokens.filter(x => x.previousLabel)) {\n      if (label === id) {\n        continue;\n      }\n      this[id!] = label;\n    }\n  }\n\n  [key: string]: string;\n}\n\nclass ChoiceItem implements IToken {\n  public static from(item: ChoiceItem) {\n    return new ChoiceItem(item.label, item.previousLabel, item.options);\n  }\n\n  constructor(\n    public label: string,\n    // We will keep the previous label value for a token, to tell us which token\n    // was renamed. For new tokens this should be null.\n    public previousLabel: string | null,\n    public options?: IChoiceOptions,\n  ) {}\n\n  public rename(label: string) {\n    return new ChoiceItem(label, this.previousLabel, this.options);\n  }\n\n  public changeStyle(options: IChoiceOptions) {\n    return new ChoiceItem(this.label, this.previousLabel, { ...this.options, ...options });\n  }\n}\n\nconst ChoiceItemType = iface([], {\n  label: \"string\",\n  previousLabel: union(\"string\", \"null\"),\n  options: opt(\"ChoiceOptionsType\"),\n});\n\nconst ChoiceOptionsType = iface([], {\n  textColor: opt(\"string\"),\n  fillColor: opt(\"string\"),\n  fontBold: opt(\"boolean\"),\n  fontUnderline: opt(\"boolean\"),\n  fontItalic: opt(\"boolean\"),\n  fontStrikethrough: opt(\"boolean\"),\n});\n\nconst choiceTypes: ITypeSuite = {\n  ChoiceItemType,\n  ChoiceOptionsType,\n};\n\nconst { ChoiceItemType: ChoiceItemChecker } = createCheckers(choiceTypes);\n\n/**\n * ChoiceListEntry - Editor for choices and choice colors.\n *\n * The ChoiceListEntry can be in one of two modes: edit or view (default).\n *\n * When in edit mode, it displays a custom, vertical TokenField that allows for entry\n * of new choice values. Once changes are saved, the new values become valid choices,\n * and can be used in Choice and Choice List columns. Each choice in the TokenField\n * also includes a color picker button to customize the fill/text color of the choice.\n * The same capabilities of TokenField, such as undo/redo and rich copy/paste support,\n * are present in ChoiceListEntry as well.\n *\n * When in view mode, it looks similar to edit mode, but hides the bottom input and the\n * color picker dropdown buttons. Past 6 choices, it stops rendering individual choices\n * and only shows the total number of additional choices that are hidden, and can be\n * seen when edit mode is activated.\n *\n * Usage:\n * > dom.create(ChoiceListEntry, values, options, (vals, options) => {});\n */\nexport class ChoiceListEntry extends Disposable {\n  private _isEditing: Observable<boolean> = Observable.create(this, false);\n  private _tokenFieldHolder: Holder<TokenField<ChoiceItem>> = Holder.create(this);\n\n  private _editorContainer: HTMLElement | null = null;\n  private _editorSaveButtons: HTMLElement | null = null;\n\n  constructor(\n    private _values: Observable<string[]>,\n    private _choiceOptionsByName: Observable<ChoiceOptionsByName>,\n    private _onSave: (values: string[], choiceOptions: ChoiceOptionsByName, renames: Record<string, string>) => void,\n    private _disabled: Observable<boolean>,\n    private _mixed: Observable<boolean>,\n  ) {\n    super();\n\n    // Since the saved values can be modified outside the ChoiceListEntry (via undo/redo),\n    // add a listener to update edit status on changes.\n    this.autoDispose(this._values.addListener(() => {\n      this._cancel();\n    }));\n\n    this.onDispose(() => {\n      if (!this._isEditing.get()) { return; }\n\n      this._save();\n    });\n  }\n\n  // Arg maxRows indicates the number of rows to display when the editor is inactive.\n  public buildDom(maxRows: number = 6): DomContents {\n    return dom.domComputed(this._isEditing, (editMode) => {\n      if (editMode) {\n        // If we have mixed values, we can't show any options on the editor.\n        const initialValue = this._mixed.get() ? [] : this._values.get().map((label) => {\n          return new ChoiceItem(label, label, this._choiceOptionsByName.get().get(label));\n        });\n        const tokenField = TokenField.ctor<ChoiceItem>().create(this._tokenFieldHolder, {\n          initialValue,\n          renderToken: token => this._renderToken(token),\n          createToken: label => new ChoiceItem(label, null),\n          clipboardToTokens: clipboardToChoices,\n          tokensToClipboard: (tokens, clipboard) => {\n            // Save tokens as JSON for parts of the UI that support deserializing it properly (e.g. ChoiceListEntry).\n            clipboard.setData(\"application/json\", JSON.stringify(tokens));\n            // Save token labels as newline-separated text, for general use (e.g. pasting into cells).\n            clipboard.setData(\"text/plain\", tokens.map(tok => tok.label).join(\"\\n\"));\n          },\n          openAutocompleteOnFocus: false,\n          trimLabels: true,\n          styles: { cssTokenField, cssToken, cssTokenInput, cssInputWrapper, cssDeleteButton, cssDeleteIcon },\n          keyBindings: {\n            previous: \"ArrowUp\",\n            next: \"ArrowDown\",\n          },\n          variant: \"vertical\",\n        });\n\n        return cssVerticalFlex(\n          this._editorContainer = cssListBox(\n            { tabIndex: \"-1\" },\n            (elem) => {\n              tokenField.attach(elem);\n              this._focusOnOpen(tokenField.getTextInput());\n            },\n            dom.on(\"focusout\", (ev) => {\n              const hasActiveElement = (\n                element: Element | null,\n                activeElement = document.activeElement,\n              ) => {\n                return element?.contains(activeElement);\n              };\n\n              // Save and close the editor when it loses focus.\n              setTimeout(() => {\n                // The editor may have already been closed via keyboard shortcut.\n                if (!this._isEditing.get()) { return; }\n\n                if (\n                  // Don't close if focus hasn't left the editor.\n                  hasActiveElement(this._editorContainer) ||\n                  // Or if the token color picker has focus.\n                  hasActiveElement(document.querySelector(\".token-color-picker\")) ||\n                  // Or if Save or Cancel was clicked.\n                  hasActiveElement(this._editorSaveButtons, ev.relatedTarget as Element | null)\n                ) {\n                  return;\n                }\n\n                this._save();\n              }, 0);\n            }),\n            testId(\"choice-list-entry\"),\n          ),\n          this._editorSaveButtons = cssButtonRow(\n            primaryButton(t(\"Save\"),\n              dom.on(\"click\", () => this._save()),\n              testId(\"choice-list-entry-save\"),\n            ),\n            basicButton(t(\"Cancel\"),\n              dom.on(\"click\", () => this._cancel()),\n              testId(\"choice-list-entry-cancel\"),\n            ),\n          ),\n          dom.onKeyDown({ Escape: () => this._cancel() }),\n          dom.onKeyDown({ Enter: () => this._save() }),\n        );\n      } else {\n        const holder = new MultiHolder();\n        const someValues = Computed.create(holder, this._values, (_use, values) =>\n          values.length <= maxRows ? values : values.slice(0, maxRows - 1));\n        const noChoices = Computed.create(holder, someValues, (_use, values) => values.length === 0);\n\n        return cssVerticalFlex(\n          dom.autoDispose(holder),\n          dom.maybe(this._mixed, () => [\n            cssListBoxInactive(\n              dom.cls(cssBlockedCursor.className, this._disabled),\n              row(\"Mixed configuration\"),\n            ),\n          ]),\n          dom.maybe(use => !use(this._mixed), () => [\n            cssListBoxInactive(\n              dom.cls(cssBlockedCursor.className, this._disabled),\n              dom.maybe(noChoices, () => row(t(\"No choices configured\"))),\n              dom.domComputed(this._choiceOptionsByName, choiceOptions =>\n                dom.forEach(someValues, (val) => {\n                  return row(\n                    cssTokenColorInactive(\n                      dom.style(\"background-color\", getFillColor(choiceOptions.get(val)) || \"#FFFFFF\"),\n                      dom.style(\"color\", getTextColor(choiceOptions.get(val)) || \"#000000\"),\n                      dom.cls(\"font-bold\", choiceOptions.get(val)?.fontBold ?? false),\n                      dom.cls(\"font-underline\", choiceOptions.get(val)?.fontUnderline ?? false),\n                      dom.cls(\"font-italic\", choiceOptions.get(val)?.fontItalic ?? false),\n                      dom.cls(\"font-strikethrough\", choiceOptions.get(val)?.fontStrikethrough ?? false),\n                      \"T\",\n                      testId(\"choice-list-entry-color\"),\n                    ),\n                    cssTokenLabel(\n                      val,\n                      testId(\"choice-list-entry-label\"),\n                    ),\n                  );\n                }),\n              ),\n              // Show description row for any remaining rows\n              dom.maybe(use => use(this._values).length > maxRows, () =>\n                row(\n                  dom(\"span\",\n                    testId(\"choice-list-entry-label\"),\n                    dom.text(use => t(\"+{{count}} more\", { count: use(this._values).length - (maxRows - 1) })),\n                  ),\n                ),\n              ),\n              dom.on(\"click\", () => this._startEditing()),\n              cssListBoxInactive.cls(\"-disabled\", this._disabled),\n              testId(\"choice-list-entry\"),\n            ),\n          ]),\n          dom.maybe(use => !use(this._disabled), () => [\n            cssButtonRow(\n              primaryButton(\n                dom.text(use => use(this._mixed) ? t(\"Reset\") : t(\"Edit\")),\n                dom.on(\"click\", () => this._startEditing()),\n                testId(\"choice-list-entry-edit\"),\n              ),\n            ),\n          ]),\n        );\n      }\n    });\n  }\n\n  private _startEditing(): void {\n    if (!this._disabled.get()) {\n      this._isEditing.set(true);\n    }\n  }\n\n  private _save(): void {\n    const tokenField = this._tokenFieldHolder.get();\n    if (!tokenField) { return; }\n\n    const tokens = tokenField.tokensObs.get();\n    const tokenInputVal = tokenField.getTextInputValue();\n    if (tokenInputVal !== \"\") {\n      tokens.push(new ChoiceItem(tokenInputVal, null));\n    }\n\n    const newTokens = uniqBy(tokens, tok => tok.label);\n    const newValues = newTokens.map(tok => tok.label);\n    const newOptions: ChoiceOptionsByName = new Map();\n    const keys: (keyof IChoiceOptions)[] = [\n      \"fillColor\", \"textColor\", \"fontBold\", \"fontItalic\", \"fontStrikethrough\", \"fontUnderline\",\n    ];\n    for (const tok of newTokens) {\n      if (tok.options) {\n        const options: IChoiceOptions = {};\n        keys.filter(k => tok.options![k] !== undefined)\n          .forEach(k => options[k] = tok.options![k] as any);\n        newOptions.set(tok.label, options);\n      }\n    }\n\n    // Call user save function if the values and/or options have changed.\n    if (!isEqual(this._values.get(), newValues) ||\n      !isEqual(this._choiceOptionsByName.get(), newOptions)) {\n      // Because of the listener on this._values, editing will stop if values are updated.\n      this._onSave(newValues, newOptions, new RenameMap(newTokens));\n    } else {\n      this._cancel();\n    }\n  }\n\n  private _cancel(): void {\n    this._isEditing.set(false);\n  }\n\n  private _focusOnOpen(elem: HTMLInputElement): void {\n    setTimeout(() => focus(elem), 0);\n  }\n\n  private _renderToken(token: ChoiceItem) {\n    const fillColorObs = Observable.create(null, getFillColor(token.options));\n    const textColorObs = Observable.create(null, getTextColor(token.options));\n    const fontBoldObs = Observable.create(null, token.options?.fontBold);\n    const fontItalicObs = Observable.create(null, token.options?.fontItalic);\n    const fontUnderlineObs = Observable.create(null, token.options?.fontUnderline);\n    const fontStrikethroughObs = Observable.create(null, token.options?.fontStrikethrough);\n    const choiceText = Observable.create(null, token.label);\n\n    const rename = async (to: string) => {\n      const tokenField = this._tokenFieldHolder.get();\n      if (!tokenField) { return; }\n\n      to = to.trim();\n      // If user removed the label, revert back to original one.\n      if (!to) {\n        choiceText.set(token.label);\n      } else {\n        tokenField.replaceToken(token.label, ChoiceItem.from(token).rename(to));\n        // We don't need to update choiceText, since it will be replaced (rerendered).\n      }\n    };\n\n    function stopPropagation(ev: Event) {\n      ev.stopPropagation();\n    }\n\n    const focusOnNew = () => {\n      const tokenField = this._tokenFieldHolder.get();\n      if (!tokenField) { return; }\n      focus(tokenField.getTextInput());\n    };\n\n    const tokenColorAndLabel: HTMLDivElement = cssColorAndLabel(\n      dom.autoDispose(fillColorObs),\n      dom.autoDispose(textColorObs),\n      dom.autoDispose(choiceText),\n      colorButton(\n        {\n          styleOptions: {\n            textColor: new ColorOption({ color: textColorObs, defaultColor: \"#000000\" }),\n            fillColor: new ColorOption(\n              { color: fillColorObs, allowsNone: true, noneText: \"none\", defaultColor: \"#FFFFFF\" }),\n            fontBold: fontBoldObs,\n            fontItalic: fontItalicObs,\n            fontUnderline: fontUnderlineObs,\n            fontStrikethrough: fontStrikethroughObs,\n          },\n          onSave: async () => {\n            const tokenField = this._tokenFieldHolder.get();\n            if (!tokenField) { return; }\n\n            const fillColor = fillColorObs.get();\n            const textColor = textColorObs.get();\n            const fontBold = fontBoldObs.get();\n            const fontItalic = fontItalicObs.get();\n            const fontUnderline = fontUnderlineObs.get();\n            const fontStrikethrough = fontStrikethroughObs.get();\n            tokenField.replaceToken(token.label, ChoiceItem.from(token).changeStyle({\n              fillColor,\n              textColor,\n              fontBold,\n              fontItalic,\n              fontUnderline,\n              fontStrikethrough,\n            }));\n          },\n          onClose: () => this._editorContainer?.focus(),\n          colorPickerDomArgs: [\n            dom.cls(\"token-color-picker\"),\n          ],\n        },\n      ),\n      editableLabel(choiceText, {\n        save: rename,\n        inputArgs: [\n          testId(\"token-label\"),\n          // Don't bubble up keyboard events, use them for editing the text.\n          // Without this keys like Backspace, or Mod+a will propagate and modify all tokens.\n          dom.on(\"keydown\", stopPropagation),\n          dom.on(\"copy\", stopPropagation),\n          dom.on(\"cut\", stopPropagation),\n          dom.on(\"paste\", stopPropagation),\n          dom.onKeyDown({\n            // On enter, focus on the input element.\n            Enter: focusOnNew,\n            // On escape, focus on the token (i.e. the parent node of this element). That way\n            // the browser will scroll the view if needed, and a subsequent escape will close\n            // the editor.\n            Escape: () => tokenColorAndLabel.parentElement?.focus(),\n          }),\n          // Don't bubble up click, as it would change focus.\n          dom.on(\"click\", stopPropagation),\n          dom.cls(cssEditableLabelInput.className),\n        ],\n        args: [dom.cls(cssEditableLabel.className)],\n      }),\n    );\n\n    return [\n      tokenColorAndLabel,\n      dom.onKeyDown({ Escape$: () => this._cancel() }),\n    ];\n  }\n}\n\n// Helper to focus on the token input and select/scroll to the bottom\nfunction focus(elem: HTMLInputElement) {\n  elem.focus();\n  elem.setSelectionRange(elem.value.length, elem.value.length);\n  elem.scrollTo(0, elem.scrollHeight);\n}\n\n// Build a display row with the given DOM arguments\nfunction row(...domArgs: DomElementArg[]): Element {\n  return cssListRow(\n    ...domArgs,\n    testId(\"choice-list-entry-row\"),\n  );\n}\n\nfunction getTextColor(choiceOptions?: IChoiceOptions) {\n  return choiceOptions?.textColor;\n}\n\nfunction getFillColor(choiceOptions?: IChoiceOptions) {\n  return choiceOptions?.fillColor;\n}\n\n/**\n * Converts clipboard contents (if any) to choices.\n *\n * Attempts to convert from JSON first, if clipboard contains valid JSON.\n * If conversion is not possible, falls back to converting from newline-separated plaintext.\n */\nfunction clipboardToChoices(clipboard: DataTransfer): ChoiceItem[] {\n  const maybeTokens = clipboard.getData(\"application/json\");\n  if (maybeTokens && isJSON(maybeTokens)) {\n    const tokens: ChoiceItem[] = JSON.parse(maybeTokens);\n    if (Array.isArray(tokens) && tokens.every((tok): tok is ChoiceItem => ChoiceItemChecker.test(tok))) {\n      tokens.forEach(tok => tok.previousLabel = null);\n      return tokens;\n    }\n  }\n\n  const maybeText = clipboard.getData(\"text/plain\");\n  if (maybeText) {\n    return maybeText.split(\"\\n\").map(label => new ChoiceItem(label, null));\n  }\n\n  return [];\n}\n\nfunction isJSON(string: string) {\n  try {\n    JSON.parse(string);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nconst cssListBox = styled(\"div\", `\n  width: 100%;\n  padding: 1px;\n  line-height: 1.5;\n  padding-left: 4px;\n  padding-right: 4px;\n  border: 1px solid ${theme.choiceEntryBorderHover};\n  border-radius: 4px;\n  background-color: ${theme.choiceEntryBg};\n`);\n\nconst cssListBoxInactive = styled(cssListBox, `\n  cursor: pointer;\n  border: 1px solid ${theme.choiceEntryBorder};\n\n  &:hover:not(&-disabled) {\n    border: 1px solid ${theme.choiceEntryBorderHover};\n  }\n  &-disabled {\n    opacity: 0.4;\n  }\n`);\n\nconst cssListRow = styled(\"div\", `\n  display: flex;\n  margin-top: 4px;\n  margin-bottom: 4px;\n  padding: 4px 8px;\n  color: ${theme.choiceTokenFg};\n  background-color: ${theme.choiceTokenBg};\n  border-radius: 3px;\n  text-overflow: ellipsis;\n`);\n\nconst cssTokenField = styled(\"div\", `\n  &.token-dragactive {\n    cursor: grabbing;\n  }\n`);\n\nconst cssToken = styled(cssListRow, `\n  position: relative;\n  display: flex;\n  justify-content: space-between;\n  user-select: none;\n  cursor: grab;\n\n  &.selected {\n    background-color: ${theme.choiceTokenSelectedBg};\n  }\n  &.token-dragging {\n    pointer-events: none;\n    z-index: 1;\n    opacity: 0.7;\n  }\n  .${cssTokenField.className}.token-dragactive & {\n    cursor: unset;\n  }\n  &:focus {\n    outline: none;\n  }\n`);\n\nconst cssTokenColorInactive = styled(\"div\", `\n  flex-shrink: 0;\n  width: 18px;\n  height: 18px;\n  display: grid;\n  place-items: center;\n`);\n\nconst cssTokenLabel = styled(\"span\", `\n  margin-left: 6px;\n  display: inline-block;\n  text-overflow: ellipsis;\n  white-space: pre;\n  overflow: hidden;\n`);\n\nconst cssEditableLabelInput = styled(\"input\", `\n  display: inline-block;\n  text-overflow: ellipsis;\n  white-space: pre;\n  overflow: hidden;\n`);\n\nconst cssEditableLabel = styled(\"div\", `\n  margin-left: 6px;\n  text-overflow: ellipsis;\n  white-space: pre;\n  overflow: hidden;\n`);\n\nconst cssTokenInput = styled(\"input\", `\n  background-color: ${theme.choiceEntryBg};\n  padding-top: 4px;\n  padding-bottom: 4px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  flex: auto;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  border: none;\n  outline: none;\n`);\n\nconst cssInputWrapper = styled(\"div\", `\n  margin-top: 4px;\n  margin-bottom: 4px;\n  position: relative;\n  flex: auto;\n  display: flex;\n`);\n\nconst cssFlex = styled(\"div\", `\n  display: flex;\n`);\n\nconst cssColorAndLabel = styled(cssFlex, `\n  max-width: calc(100% - 20px);\n`);\n\nconst cssVerticalFlex = styled(\"div\", `\n  width: 100%;\n  display: flex;\n  flex-direction: column;\n`);\n\nconst cssButtonRow = styled(\"div\", `\n  gap: 8px;\n  display: flex;\n  margin-top: 8px;\n`);\n\nconst cssDeleteButton = styled(\"div\", `\n  display: inline;\n  float: right;\n  margin-left: 4px;\n  cursor: pointer;\n  .${cssTokenField.className}.token-dragactive & {\n    cursor: unset;\n  }\n`);\n\nconst cssDeleteIcon = styled(icon, `\n   --icon-color: ${theme.text};\n   opacity: 0.6;\n   &:hover {\n     opacity: 1.0;\n   }\n `);\n"
  },
  {
    "path": "app/client/widgets/ChoiceTextBox.ts",
    "content": "import { DropdownConditionConfig } from \"app/client/components/DropdownConditionConfig\";\nimport {\n  FormFieldRulesConfig,\n  FormOptionsSortConfig,\n  FormSelectConfig,\n} from \"app/client/components/Forms/FormConfig\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { KoSaveableObservable } from \"app/client/models/modelUtil\";\nimport { Style } from \"app/client/models/Styles\";\nimport { cssLabel, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { ChoiceListEntry } from \"app/client/widgets/ChoiceListEntry\";\nimport { choiceToken } from \"app/client/widgets/ChoiceToken\";\nimport { NTextBox } from \"app/client/widgets/NTextBox\";\n\nimport { Computed, dom, styled } from \"grainjs\";\n\nexport type IChoiceOptions = Style;\nexport type ChoiceOptions = Record<string, IChoiceOptions | undefined>;\nexport type ChoiceOptionsByName = Map<string, IChoiceOptions | undefined>;\n\nconst t = makeT(\"ChoiceTextBox\");\n\n/**\n * ChoiceTextBox - A textbox for choice values.\n */\nexport class ChoiceTextBox extends NTextBox {\n  private _choices: KoSaveableObservable<string[]>;\n  private _choiceValues: Computed<string[]>;\n  private _choiceValuesSet: Computed<Set<string>>;\n  private _choiceOptions: KoSaveableObservable<ChoiceOptions | null | undefined>;\n  private _choiceOptionsByName: Computed<ChoiceOptionsByName>;\n\n  constructor(field: ViewFieldRec) {\n    super(field);\n    this._choices = this.options.prop(\"choices\");\n    this._choiceOptions = this.options.prop(\"choiceOptions\");\n    this._choiceValues = Computed.create(this, use => use(this._choices) || []);\n    this._choiceValuesSet = Computed.create(this, this._choiceValues, (_use, values) => new Set(values));\n    this._choiceOptionsByName = Computed.create(this, use => toMap(use(this._choiceOptions)));\n  }\n\n  public buildDom(row: DataRowModel) {\n    const value = row.cells[this.field.colId()];\n    const isSingle = this.field.viewSection().parentKey() === \"single\";\n    const maybeDropDownCssChoiceEditIcon = isSingle ? cssChoiceEditIcon(\"Dropdown\") : null;\n\n    return cssChoiceField(\n      cssChoiceTextWrapper(\n        dom.style(\"justify-content\", use => use(this.alignment) === \"right\" ? \"flex-end\" : use(this.alignment)),\n        maybeDropDownCssChoiceEditIcon,\n        dom.domComputed((use) => {\n          if (this.isDisposed() || use(row._isAddRow)) { return null; }\n\n          const formattedValue = use(this.valueFormatter).formatAny(use(value));\n          if (formattedValue === \"\") { return null; }\n\n          return choiceToken(\n            formattedValue,\n            {\n              ...(use(this._choiceOptionsByName).get(formattedValue) || {}),\n              invalid: !use(this._choiceValuesSet).has(formattedValue),\n            },\n            dom.cls(cssChoiceText.className),\n            testId(\"choice-token\"),\n          );\n        }),\n      ),\n    );\n  }\n\n  public buildConfigDom(gristDoc: GristDoc) {\n    return [\n      super.buildConfigDom(gristDoc),\n      this.buildChoicesConfigDom(),\n      dom.create(DropdownConditionConfig, this.field, gristDoc),\n    ];\n  }\n\n  public buildTransformConfigDom() {\n    return [\n      this.buildChoicesConfigDom(),\n    ];\n  }\n\n  public buildFormConfigDom() {\n    return [\n      this.buildChoicesConfigDom(),\n      dom.create(FormSelectConfig, this.field),\n      dom.create(FormOptionsSortConfig, this.field),\n      dom.create(FormFieldRulesConfig, this.field),\n    ];\n  }\n\n  public buildFormTransformConfigDom() {\n    return [\n      this.buildChoicesConfigDom(),\n    ];\n  }\n\n  protected getChoiceValuesSet(): Computed<Set<string>> {\n    return this._choiceValuesSet;\n  }\n\n  protected getChoiceOptions(): Computed<ChoiceOptionsByName> {\n    return this._choiceOptionsByName;\n  }\n\n  protected save(choices: string[], choiceOptions: ChoiceOptionsByName, renames: Record<string, string>) {\n    const options = {\n      choices,\n      choiceOptions: toObject(choiceOptions),\n    };\n    return this.field.config.updateChoices(renames, options);\n  }\n\n  protected buildChoicesConfigDom() {\n    const disabled = Computed.create(null,\n      use => use(this.field.disableModify) ||\n        use(use(this.field.column).disableEditData) ||\n        use(this.field.config.options.disabled(\"choices\")),\n    );\n\n    const mixed = Computed.create(null,\n      use => !use(disabled) &&\n        (use(this.field.config.options.mixed(\"choices\")) || use(this.field.config.options.mixed(\"choiceOptions\"))),\n    );\n\n    return [\n      cssLabel(t(\"CHOICES\")),\n      cssRow(\n        dom.autoDispose(disabled),\n        dom.autoDispose(mixed),\n        dom.create(\n          ChoiceListEntry,\n          this._choiceValues,\n          this._choiceOptionsByName,\n          this.save.bind(this),\n          disabled,\n          mixed,\n        ),\n      ),\n    ];\n  }\n}\n\n// Converts a POJO containing choice options to an ES6 Map\nfunction toMap(choiceOptions?: ChoiceOptions | null): ChoiceOptionsByName {\n  if (!choiceOptions) { return new Map(); }\n\n  return new Map(Object.entries(choiceOptions));\n}\n\n// Converts an ES6 Map containing choice options to a POJO\nfunction toObject(choiceOptions: ChoiceOptionsByName): ChoiceOptions {\n  const object: ChoiceOptions = {};\n  for (const [choice, options] of choiceOptions.entries()) {\n    object[choice] = options;\n  }\n  return object;\n}\n\nconst cssChoiceField = styled(\"div.field_clip\", `\n  padding: 0 3px;\n`);\n\nconst cssChoiceTextWrapper = styled(\"div\", `\n  display: flex;\n  width: 100%;\n  min-width: 0px;\n  overflow: hidden;\n`);\n\nconst cssChoiceText = styled(\"div\", `\n  margin: 2px;\n  height: min-content;\n  line-height: 16px;\n`);\n\nconst cssChoiceEditIcon = styled(icon, `\n  background-color: ${theme.lightText};\n  display: block;\n  height: inherit;\n`);\n"
  },
  {
    "path": "app/client/widgets/ChoiceToken.ts",
    "content": "import { Style } from \"app/client/models/Styles\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\n\nimport { colord, extend } from \"colord\";\nimport a11yPlugin from \"colord/plugins/a11y\";\nimport { dom, DomContents, DomElementArg, styled } from \"grainjs\";\n\nextend([a11yPlugin]);\n\nexport const DEFAULT_BACKGROUND_COLOR = theme.choiceTokenBg.toString();\nexport const DEFAULT_COLOR = theme.choiceTokenFg.toString();\n\nexport interface IChoiceTokenOptions extends Style {\n  invalid?: boolean;\n  blank?: boolean;\n}\n\n/**\n * Creates a colored token representing a choice (e.g. Choice and Choice List values).\n *\n * Tokens are pill-shaped boxes that contain text, with custom fill and text\n * colors. If colors are not specified, a gray fill with black text will be used.\n *\n * Additional styles and other DOM arguments can be passed in to customize the\n * appearance and behavior of the token.\n *\n * @param {DomElementArg} label The text that will appear inside the token.\n * @param {IChoiceTokenOptions} options Options for customizing the token appearance.\n * @param {DOMElementArg[]} args Additional arguments to pass to the token.\n * @returns {DomContents} A colored choice token.\n */\nexport function choiceToken(\n  label: DomElementArg,\n  options: IChoiceTokenOptions,\n  ...args: DomElementArg[]\n): DomContents {\n  return cssChoiceToken(choiceTokenDomArgs(label, options), ...args);\n}\n\n/**\n * Exposes the choiceToken dom args outside of cssChoiceToken to allow\n * easy usage of them with TokenField#renderToken, that has its own wrapper dom el.\n */\nexport function choiceTokenDomArgs(\n  label: DomElementArg,\n  options: IChoiceTokenOptions,\n): DomElementArg {\n  const { fillColor, textColor, fontBold, fontItalic, fontUnderline,\n    fontStrikethrough, invalid, blank } = options;\n  const { bg, fg } = getReadableColorsCombo({ fillColor, textColor });\n  return [\n    label,\n    dom.style(\"background-color\", bg),\n    dom.style(\"color\", fg),\n    dom.cls(\"font-bold\", fontBold ?? false),\n    dom.cls(\"font-underline\", fontUnderline ?? false),\n    dom.cls(\"font-italic\", fontItalic ?? false),\n    dom.cls(\"font-strikethrough\", fontStrikethrough ?? false),\n    invalid ? cssChoiceToken.cls(\"-invalid\") : null,\n    blank ? cssChoiceToken.cls(\"-blank\") : null,\n  ];\n}\n\nexport const cssChoiceToken = styled(\"div\", `\n  display: inline-block;\n  padding: 1px 4px;\n  border-radius: 3px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: pre;\n\n  &-invalid {\n    color: ${theme.choiceTokenInvalidFg} !important;\n    background-color: ${theme.choiceTokenInvalidBg} !important;\n    box-shadow: inset 0 0 0 1px ${theme.choiceTokenInvalidBorder};\n  }\n  &-blank {\n    color: ${theme.lightText} !important;\n  }\n`);\n\nconst contrastCalculationsCache: Record<string, string> = {};\n\n// shades to pick from for automatic text color, ordered from lightest to darkest\nconst grayShades = [\n  \"#e8e8e8\",\n  \"#bfbfbf\",\n  \"#959595\",\n  \"#70707d\",\n  \"#44444c\",\n  \"#242428\",\n  \"#000000\",\n];\n\nfunction findBestShade(color: string, shades: string[]) {\n  const cache = contrastCalculationsCache;\n  if (cache[color] !== undefined) {\n    return cache[color];\n  }\n  const c = colord(color);\n  // Find the best text gray shade for the given bg color.\n  // Logic is: we take the highest contrast ratio we can get, but stop searching\n  // when we find a contrast ratio > 7 (WCAG AAA level).\n  const matchingShade = shades.reduce((prev, current) => {\n    if (prev.foundBest) {\n      return prev;\n    }\n    const currentContrast = c.contrast(current);\n    if (currentContrast > 7 || currentContrast > prev.contrast) {\n      return { shade: current, contrast: currentContrast, foundBest: currentContrast > 7 };\n    }\n    return prev;\n  }, {\n    shade: shades[0],\n    contrast: c.contrast(shades[0]),\n    foundBest: false,\n  });\n  cache[color] = matchingShade.shade;\n  return cache[color];\n}\n\nexport function getReadableColorsCombo(\n  token: IChoiceTokenOptions,\n  defaultColors: { bg: string, fg: string } = { bg: DEFAULT_BACKGROUND_COLOR, fg: DEFAULT_COLOR },\n) {\n  const { fillColor, textColor } = token;\n  const hasCustomBg = fillColor !== undefined;\n  const hasCustomText = textColor !== undefined;\n  const bg = fillColor || defaultColors.bg;\n  let fg = textColor || defaultColors.fg;\n  if (hasCustomBg && !hasCustomText) {\n    fg = findBestShade(fillColor, grayShades);\n  }\n  return { bg, fg };\n}\n\nconst ADD_NEW_HEIGHT = \"37px\";\n\nexport const cssChoiceACItem = styled(\"li\", `\n  display: block;\n  font-family: ${vars.fontFamily};\n  white-space: pre;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  outline: none;\n  padding: var(--weaseljs-menu-item-padding, 8px 24px);\n  cursor: pointer;\n\n  &.selected {\n    background-color: ${theme.autocompleteItemSelectedBg};\n  }\n  &-with-new {\n    scroll-margin-bottom: ${ADD_NEW_HEIGHT};\n  }\n  &-new {\n    display: flex;\n    align-items: center;\n    position: sticky;\n    bottom: 0px;\n    height: ${ADD_NEW_HEIGHT};\n    background-color: ${theme.menuBg};\n    border-top: 1px solid ${theme.menuBorder};\n    scroll-margin-bottom: initial;\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/ConditionalStyle.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport * as kf from \"app/client/lib/koForm\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ColumnRec } from \"app/client/models/DocModel\";\nimport { KoSaveableObservable } from \"app/client/models/modelUtil\";\nimport { RuleOwner } from \"app/client/models/RuleOwner\";\nimport { Style } from \"app/client/models/Styles\";\nimport { buildHighlightedCode } from \"app/client/ui/CodeHighlight\";\nimport { cssFieldFormula } from \"app/client/ui/RightPanelStyles\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { ColorOption, colorSelect } from \"app/client/ui2018/ColorSelect\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssDragger } from \"app/client/ui2018/draggableList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { setupEditorCleanup } from \"app/client/widgets/FieldEditor\";\nimport { cssError, openFormulaEditor } from \"app/client/widgets/FormulaEditor\";\nimport { isRaisedException, isValidRuleValue } from \"app/common/gristTypes\";\nimport { GristObjCode, RowRecord } from \"app/plugin/GristData\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nimport { Computed, Disposable, dom, DomContents, makeTestId, Observable, styled } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\n\nconst testId = makeTestId(\"test-widget-style-\");\nconst t = makeT(\"ConditionalStyle\");\n\ntype ColumnRecAndIndex = [ColumnRec, number];\n\nexport class ConditionalStyle extends Disposable {\n  // Holds data from currently selected record (holds data only when this field has conditional styles).\n  private _currentRecord: Computed<RowRecord | undefined>;\n  // Helper field for refreshing current record data.\n  private _dataChangeTrigger = Observable.create(this, 0);\n  // Rules columns with their respective rule index.\n  private _rulesColsWithIndex: Computed<ColumnRecAndIndex[]> = Computed.create(this, (use) => {\n    const rulesCols = use(this._ruleOwner.rulesCols);\n    return rulesCols.map((col, i) => [col, i]);\n  });\n\n  constructor(\n    private _label: string,\n    private _ruleOwner: RuleOwner,\n    private _gristDoc: GristDoc,\n    private _disabled?: Observable<boolean>,\n  ) {\n    super();\n    this._currentRecord = Computed.create(this, (use) => {\n      if (!use(this._ruleOwner.hasRules)) {\n        return;\n      }\n      // As we are not subscribing to data change, we will monitor actions\n      // that are sent from the server to refresh this computed observable.\n      void use(this._dataChangeTrigger);\n      const tableId = use(_ruleOwner.tableId);\n      const tableData = _gristDoc.docData.getTable(tableId)!;\n      const cursor = use(_gristDoc.cursorPosition);\n      // Make sure we are not on the new row.\n      if (!cursor || typeof cursor.rowId !== \"number\") {\n        return undefined;\n      }\n      return tableData.getRecord(cursor.rowId);\n    });\n\n    // Here we will subscribe to tableActionEmitter, and update currentRecord observable.\n    // We have 'dataChangeTrigger' that is just a number that will be updated every time\n    // we received some table actions.\n    const debouncedUpdate = debounce(() => {\n      if (this._dataChangeTrigger.isDisposed()) {\n        return;\n      }\n      this._dataChangeTrigger.set(this._dataChangeTrigger.get() + 1);\n    }, 0);\n    Computed.create(this, (use) => {\n      const tableId = use(_ruleOwner.tableId);\n      const tableData = _gristDoc.docData.getTable(tableId);\n      return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedUpdate)) : null;\n    });\n  }\n\n  public buildDom(): DomContents {\n    return [\n      cssRow(\n        { style: \"margin-top: 16px\" },\n        withInfoTooltip(\n          textButton(\n            t(\"Add conditional style\"),\n            testId(\"add-conditional-style\"),\n            dom.on(\"click\", () => this._ruleOwner.addEmptyRule()),\n            dom.prop(\"disabled\", this._disabled),\n          ),\n          this._label === t(\"Row Style\") ? \"addRowConditionalStyle\" : \"addColumnConditionalStyle\",\n        ),\n        dom.hide(use => use(this._ruleOwner.hasRules)),\n      ),\n      dom.domComputedOwned(\n        use => use(this._rulesColsWithIndex),\n        (owner, rules) =>\n          cssRuleList(\n            dom.show(use => rules.length > 0 && (!this._disabled || !use(this._disabled))),\n            kf.draggableList(rules, (rule: ColumnRecAndIndex) => this._buildRule(owner, rule), {\n              reorder: this._reorderRule.bind(this),\n              removeButton: false,\n              drag_indicator: cssDragger,\n              itemClass: cssDragRow.className,\n              handle: `.${cssDragger.className}`,\n            }),\n          ),\n      ),\n      cssRow(\n        textButton(t(\"Add another rule\"),\n          dom.on(\"click\", () => this._ruleOwner.addEmptyRule()),\n          testId(\"add-another-rule\"),\n          dom.prop(\"disabled\", use => this._disabled && use(this._disabled)),\n        ),\n        dom.show(use => use(this._ruleOwner.hasRules)),\n      ),\n    ];\n  }\n\n  private _buildRule(owner: Disposable, rule: ColumnRecAndIndex) {\n    const [column, index] = rule;\n    const textColor = this._buildStyleOption(owner, index, \"textColor\");\n    const fillColor = this._buildStyleOption(owner, index, \"fillColor\");\n    const fontBold = this._buildStyleOption(owner, index, \"fontBold\");\n    const fontItalic = this._buildStyleOption(owner, index, \"fontItalic\");\n    const fontUnderline = this._buildStyleOption(owner, index, \"fontUnderline\");\n    const fontStrikethrough = this._buildStyleOption(owner, index, \"fontStrikethrough\");\n    const save = async () => {\n      // This will save both options.\n      await this._ruleOwner.rulesStyles.save();\n    };\n    const currentValue = Computed.create(owner, (use) => {\n      const record = use(this._currentRecord);\n      if (!record) {\n        return null;\n      }\n      const value = record[use(column.colId)];\n      return value ?? null;\n    });\n    const hasError = Computed.create(owner, (use) => {\n      return !isValidRuleValue(use(currentValue));\n    });\n    const errorMessage = Computed.create(owner, (use) => {\n      const value = use(currentValue);\n      return (!use(hasError) ? \"\" :\n        isRaisedException(value) ? t(\"Error in style rule\") :\n          t(\"Rule must return True or False\"));\n    });\n    return dom(\"div\",\n      testId(`conditional-rule-${index}`),\n      testId(`conditional-rule`), // for testing\n      cssLineLabel(t(\"IF...\")),\n      cssColumnsRow(\n        cssLeftColumn(\n          this._buildRuleFormula(column.formula, column, hasError),\n          cssRuleError(\n            dom.text(errorMessage),\n            dom.show(hasError),\n            testId(`rule-error-${index}`),\n          ),\n          colorSelect(\n            {\n              textColor: new ColorOption({ color: textColor, allowsNone: true, noneText: \"default\" }),\n              fillColor: new ColorOption({ color: fillColor, allowsNone: true, noneText: \"none\" }),\n              fontBold,\n              fontItalic,\n              fontUnderline,\n              fontStrikethrough,\n            }, {\n              onSave: save,\n              placeholder: this._label || t(\"Conditional Style\"),\n            },\n          ),\n        ),\n        cssRemoveButton(\n          \"Remove\",\n          testId(`remove-rule-${index}`),\n          dom.on(\"click\", () => this._ruleOwner.removeRule(index)),\n        ),\n      ),\n    );\n  }\n\n  private async _reorderRule(rule: ColumnRecAndIndex, nextRule: ColumnRecAndIndex | null) {\n    const rulesList = decodeObject(this._ruleOwner.rulesList.peek());\n    if (!Array.isArray(rulesList) || rulesList.length === 0) {\n      throw new Error(\"No conditional style rules\");\n    }\n\n    const ruleColRef = rule[0].id.peek();\n    const nextRuleColRef = nextRule?.[0].id.peek();\n    const rulesStyles = [...this._ruleOwner.rulesStyles.peek()];\n    const ruleColRefIndex = rulesList.indexOf(ruleColRef);\n\n    // Remove the rule.\n    rulesList.splice(ruleColRefIndex, 1);\n    const [ruleStyle] = rulesStyles.splice(ruleColRefIndex, 1);\n\n    // Insert the removed rule before the next rule.\n    const nextRuleColRefIndex = nextRuleColRef ? rulesList.indexOf(nextRuleColRef) : rulesList.length;\n    rulesList.splice(nextRuleColRefIndex, 0, ruleColRef);\n    rulesStyles.splice(nextRuleColRefIndex, 0, ruleStyle);\n\n    await this._gristDoc.docModel.docData.bundleActions(\"Reorder conditional rules\", () =>\n      Promise.all([\n        this._ruleOwner.rulesList.setAndSave([GristObjCode.List, ...rulesList]),\n        this._ruleOwner.rulesStyles.setAndSave(rulesStyles),\n      ]),\n    );\n  }\n\n  private _buildStyleOption<T extends keyof Style>(owner: Disposable, index: number, option: T) {\n    const obs = Computed.create(owner, (use) => {\n      const styles = use(this._ruleOwner.rulesStyles);\n      return styles?.[index]?.[option];\n    });\n    obs.onWrite((value) => {\n      const list = Array.from(this._ruleOwner.rulesStyles.peek() ?? []);\n      list[index] = list[index] ?? {};\n      list[index][option] = value;\n      this._ruleOwner.rulesStyles(list);\n    });\n    return obs;\n  }\n\n  private _buildRuleFormula(\n    formula: KoSaveableObservable<string>,\n    column: ColumnRec,\n    hasError: Observable<boolean>,\n  ) {\n    return dom.create(buildHighlightedCode,\n      formula,\n      { maxLines: 1 },\n      dom.cls(\"formula_field_sidepane\"),\n      dom.cls(cssFieldFormula.className),\n      dom.cls(cssErrorBorder.className, hasError),\n      { tabIndex: \"-1\" },\n      dom.on(\"focus\", (_, refElem) => {\n        const section = this._gristDoc.viewModel.activeSection();\n        const vsi = section.viewInstance();\n        const editorHolder = openFormulaEditor({\n          gristDoc: this._gristDoc,\n          editingFormula: section.editingFormula,\n          column,\n          editRow: vsi?.moveEditRowToCursor(),\n          refElem,\n          setupCleanup: setupEditorCleanup,\n          canDetach: false,\n        });\n        // Add editor to document holder - this will prevent multiple formula editor instances.\n        this._gristDoc.fieldEditorHolder.autoDispose(editorHolder);\n      }),\n    );\n  }\n}\n\nconst cssIcon = styled(icon, `\n  flex: 0 0 auto;\n`);\n\nconst cssLabel = styled(\"div\", `\n  text-transform: uppercase;\n  margin: 16px 16px 12px 0px;\n  color: ${theme.text};\n  font-size: ${vars.xsmallFontSize};\n`);\n\nconst cssRow = styled(\"div\", `\n  display: flex;\n  margin: 8px 16px;\n  align-items: center;\n  &-top-space {\n    margin-top: 24px;\n  }\n  &-disabled {\n    color: ${theme.disabledText};\n  }\n`);\n\nconst cssRemoveButton = styled(cssIcon, `\n  flex: none;\n  margin: 6px;\n  margin-right: 0px;\n  transform: translateY(4px);\n  cursor: pointer;\n  --icon-color: ${theme.controlSecondaryFg};\n  &:hover {\n    --icon-color: ${theme.controlFg};\n  }\n`);\n\nconst cssLineLabel = styled(cssLabel, `\n  margin-top: 0px;\n  margin-bottom: 4px;\n`);\n\nconst cssRuleList = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  margin-top: 16px;\n  margin-bottom: 12px;\n`);\n\nconst cssErrorBorder = styled(\"div\", `\n  border-color: ${theme.inputInvalid};\n`);\n\nconst cssRuleError = styled(cssError, `\n  margin: 2px 0px 10px 0px;\n`);\n\nconst cssColumnsRow = styled(cssRow, `\n  align-items: flex-start;\n  margin: 0px 16px 0px 0px;\n`);\n\nconst cssLeftColumn = styled(\"div\", `\n  overflow: hidden;\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n`);\n\nconst cssDragRow = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  & > .kf_draggable_content {\n    margin: 4px 0;\n    flex: 1 1 0px;\n    min-width: 0px;\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/CurrencyPicker.ts",
    "content": "import { ACIndexImpl } from \"app/client/lib/ACIndex\";\nimport { ACSelectItem, buildACSelect } from \"app/client/lib/ACSelect\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { currencies } from \"app/common/Locales\";\n\nimport { Computed, IDisposableOwner, Observable } from \"grainjs\";\n\nconst t = makeT(\"CurrencyPicker\");\n\ninterface CurrencyPickerOptions {\n  // The label to use in the select menu for the default option.\n  defaultCurrencyLabel: string;\n  disabled?: Observable<boolean>;\n}\n\nexport function buildCurrencyPicker(\n  owner: IDisposableOwner,\n  currency: Observable<string | undefined>,\n  onSave: (value: string | undefined) => void,\n  { defaultCurrencyLabel, disabled }: CurrencyPickerOptions,\n) {\n  const currencyItems: ACSelectItem[] = currencies\n    .map(item => ({\n      value: item.code,\n      label: `${item.code} ${item.name}`,\n      cleanText: `${item.code} ${item.name}`.trim().toLowerCase(),\n    }));\n\n  // Add default currency label option to the very front.\n  currencyItems.unshift({\n    label: defaultCurrencyLabel,\n    value: defaultCurrencyLabel,\n    cleanText: defaultCurrencyLabel.toLowerCase(),\n  });\n  // Create a computed that will display 'Local currency' as a value and label\n  // when `currency` is undefined.\n  const valueObs = Computed.create(owner, use => use(currency) || defaultCurrencyLabel);\n  const acIndex = new ACIndexImpl<ACSelectItem>(currencyItems, { maxResults: 200, keepOrder: true });\n  return buildACSelect(owner,\n    {\n      acIndex, valueObs,\n      disabled,\n      save(_, item: ACSelectItem | undefined) {\n        // Save only if we have found a match\n        if (!item) {\n          throw new Error(t(\"Invalid currency\"));\n        }\n        // For default value, return undefined to use default currency for document.\n        onSave(item.value === defaultCurrencyLabel ? undefined : item.value);\n      },\n    },\n    testId(\"currency-autocomplete\"),\n  );\n}\n"
  },
  {
    "path": "app/client/widgets/DateEditor.ts",
    "content": "import { CommandGroup, createGroup } from \"app/client/components/commands\";\nimport { loadScript } from \"app/client/lib/loadScript\";\nimport { detectCurrentLang } from \"app/client/lib/localization\";\nimport { FieldOptions } from \"app/client/widgets/NewBaseEditor\";\nimport { NTextEditor } from \"app/client/widgets/NTextEditor\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { parseDate, TWO_DIGIT_YEAR_THRESHOLD } from \"app/common/parseDate\";\n\nimport { dom } from \"grainjs\";\nimport moment from \"moment-timezone\";\n\n// These are all the locales available for the datepicker. Having a prepared list lets us find a\n// suitable one without trying combinations that don't exist. This list can be rebuilt using:\n//    ls bower_components/bootstrap-datepicker/dist/locales/bootstrap-datepicker.* | cut -d. -f2 | xargs echo\n//\n// eslint-disable-next-line @stylistic/max-len\nconst availableLocales = \"ar-tn ar az bg bm bn br bs ca cs cy da de el en-AU en-CA en-GB en-IE en-NZ en-ZA eo es et eu fa fi fo fr-CH fr gl he hi hr hu hy id is it-CH it ja ka kh kk km ko kr lt lv me mk mn ms nl-BE nl no oc pl pt-BR pt ro rs-latin rs ru si sk sl sq sr-latin sr sv sw ta tg th tk tr uk uz-cyrl uz-latn vi zh-CN zh-TW\";\n\nmonkeyPatchDatepicker();\n\n/**\n * DateEditor - Editor for Date type. Includes a dropdown datepicker.\n *  See reference: http://bootstrap-datepicker.readthedocs.org/en/latest/index.html\n */\nexport class DateEditor extends NTextEditor {\n  protected safeFormat: string;     // Format that specifies a complete date.\n\n  private _dateFormat: string | undefined = this.options.field.widgetOptionsJson.peek().dateFormat;\n  private _locale = detectCurrentLang();\n  private _keyboardNav = false;     // Whether keyboard navigation is active for the datepicker.\n\n  constructor(\n    options: FieldOptions,\n    protected timezone: string = \"UTC\",     // For use by the derived DateTimeEditor.\n  ) {\n    super(options);\n\n    // Update moment format string to represent a date unambiguously.\n    this.safeFormat = makeFullMomentFormat(this._dateFormat || \"\");\n\n    // Set placeholder to current date(time), unless in read-only mode.\n    if (!options.readonly) {\n      // Use the default local timezone to format the placeholder date.\n      // TODO: this.timezone is better for DateTime; gristDoc.docInfo.timezone.peek() is better for Date.\n      const defaultTimezone = moment.tz.guess();\n      const placeholder = moment.tz(defaultTimezone).format(this.safeFormat);\n      this.textInput.setAttribute(\"placeholder\", placeholder);\n    }\n\n    const cellValue = this.formatValue(options.cellValue, this.safeFormat, true);\n\n    // Set the edited value, if not explicitly given, to the formatted version of cellValue.\n    this.textInput.value = options.state ?? options.editValue ?? cellValue;\n\n    if (!options.readonly) {\n      // When the up/down arrow is pressed, modify the datepicker options to take control of\n      // the arrow keys for date selection.\n      const datepickerCommands = {\n        ...options.commands,\n        datepickerFocus: () => { this._allowKeyboardNav(true); },\n      };\n      const datepickerCommandGroup = this.autoDispose(createGroup(datepickerCommands, this, true));\n      this._attachDatePicker(datepickerCommandGroup)\n        .catch(e => console.error(\"Error attaching datepicker\", e));\n    }\n  }\n\n  public getCellValue() {\n    const timestamp = parseDate(this.textInput.value, {\n      dateFormat: this.safeFormat,\n      timezone: this.timezone,\n    });\n    return timestamp !== null ? timestamp : this.textInput.value;\n  }\n\n  // Moment value formatting helper.\n  protected formatValue(value: CellValue, formatString: string | undefined, shouldFallBackToValue: boolean) {\n    if (typeof value === \"number\" && formatString) {\n      return moment.tz(value * 1000, this.timezone).format(formatString);\n    } else {\n      // If value is AltText, return it unchanged. This way we can see it and edit in the editor.\n      return (shouldFallBackToValue && typeof value === \"string\") ? value : \"\";\n    }\n  }\n\n  // Helper to allow/disallow keyboard navigation within the datepicker.\n  private _allowKeyboardNav(bool: boolean) {\n    if (this._keyboardNav !== bool) {\n      this._keyboardNav = bool;\n      $(this.textInput).data().datepicker.o.keyboardNavigation = bool;\n      // Force parse must be turned on with keyboard navigation, since it forces the highlighted date\n      // to be used when enter is pressed. Otherwise, keyboard date selection will have no effect.\n      $(this.textInput).data().datepicker.o.forceParse = bool;\n    }\n  }\n\n  // Attach the datepicker.\n  private async _attachDatePicker(datepickerCommands: CommandGroup) {\n    const localeToUse = await loadLocale(this._locale);\n    if (this.isDisposed()) { return; }    // Good idea to check after 'await'.\n    const datePickerWidget = $(this.textInput).datepicker({\n      keyboardNavigation: false,\n      forceParse: false,\n      todayHighlight: true,\n      todayBtn: \"linked\",\n      assumeNearbyYear: TWO_DIGIT_YEAR_THRESHOLD,\n      language: localeToUse,\n      // Use the stripped format converted to one suitable for the datepicker.\n      format: {\n        toDisplay: (date: string, format: unknown, lang: unknown) => moment.utc(date).format(this.safeFormat),\n        toValue: (date: string, format: unknown, lang: unknown) => {\n          const timestampSec = parseDate(date, {\n            dateFormat: this.safeFormat,\n            // datepicker reads date in utc (ie: using date.getUTCDate()).\n            timezone: \"UTC\",\n          });\n          return (timestampSec === null) ? null : new Date(timestampSec * 1000);\n        },\n      },\n    });\n    this.onDispose(() => datePickerWidget.datepicker(\"destroy\"));\n\n    // NOTE: Datepicker interferes with normal enter and escape functionality. Add an event handler\n    // to the DatePicker to prevent interference with normal behavior.\n    datePickerWidget.on(\"keydown\", (e) => {\n      // If enter or escape is pressed, destroy the datepicker and re-dispatch the event.\n      if (e.keyCode === 13 || e.keyCode === 27) {\n        datePickerWidget.datepicker(\"destroy\");\n        // The current target of the event will be the textarea.\n        setTimeout(() => e.currentTarget?.dispatchEvent(e.originalEvent!), 0);\n      }\n    });\n\n    datePickerWidget.on(\"show\", () => {\n      // A workaround to allow clicking in the datepicker without losing focus.\n      const datepickerElem: HTMLElement | null = document.querySelector(\".datepicker\");\n      if (datepickerElem) {\n        dom.update(datepickerElem,\n          dom.attr(\"tabIndex\", \"0\"),      // allows datepicker to gain focus\n          dom.cls(\"clipboard_allow_focus\"),      // tells clipboard to not steal focus from us\n        );\n      }\n\n      // Attach command group to the input to allow switching keyboard focus to the datepicker.\n      dom.update(this.textInput,\n        // If the user inputs text into the textbox, take keyboard focus from the datepicker.\n        dom.on(\"input\", () => { this._allowKeyboardNav(false); }),\n        datepickerCommands.attach(),\n      );\n    });\n    datePickerWidget.datepicker(\"show\");\n  }\n}\n\n// Updates the given Moment format to specify a complete date, so that the datepicker sees an\n// unambiguous date in the textbox input. If the format is incomplete, fall back to YYYY-MM-DD.\nfunction makeFullMomentFormat(mFormat: string): string {\n  let safeFormat = mFormat;\n  if (!safeFormat.includes(\"Y\")) {\n    safeFormat += \" YYYY\";\n  }\n  if (!safeFormat.includes(\"D\") || !safeFormat.includes(\"M\")) {\n    safeFormat = \"YYYY-MM-DD\";\n  }\n  return safeFormat;\n}\n\nlet availableLocaleSet: Set<string> | undefined;\nconst loadedLocaleMap = new Map<string, string>();    // Maps requested locale to the one to use.\n\n// Datepicker supports many languages. They just need to be loaded. Here we load the language we\n// need on-demand, taking care not to load any language more than once (we don't need to assume\n// there is only one language being used on the page, though in practice that may well be true).\nasync function loadLocale(locale: string): Promise<string> {\n  return loadedLocaleMap.get(locale) ||\n    loadedLocaleMap.set(locale, await doLoadLocale(locale)).get(locale)!;\n}\n\nasync function doLoadLocale(locale: string): Promise<string> {\n  if (!availableLocaleSet) {\n    availableLocaleSet = new Set(availableLocales.split(/\\s+/));\n  }\n  if (!availableLocaleSet.has(locale)) {\n    const shortLocale = locale.split(\"-\")[0];            // If \"xx-YY\" is not available, try \"xx\"\n    if (!availableLocaleSet.has(shortLocale)) {\n      // No special locale available. (This is even true for \"en\", which is fine since that's\n      // loaded by default.)\n      return locale;\n    }\n    locale = shortLocale;\n  }\n\n  console.debug(`DateEditor: loading locale ${locale}`);\n  try {\n    await loadScript(`bootstrap-datepicker/dist/locales/bootstrap-datepicker.${locale}.min.js`);\n  } catch (e) {\n    console.warn(`DateEditor: failed to load ${locale}`);\n  }\n  return locale;\n}\n\n// DatePicker unfortunately requires an <input> (not <textarea>). But textarea is better for us,\n// because sometimes it's taller than a line, and an <input> looks worse. The following\n// unconsionable hack tricks Datepicker into thinking anything it's attached to is an input.\n// It's more reasonable to just modify boostrap-datepicker, but that has its own downside (with\n// upgrading and minification). This hack, however, is simpler than other workarounds.\nfunction monkeyPatchDatepicker() {\n  const Datepicker = ($.fn as any).datepicker?.Constructor;\n  if (Datepicker?.prototype) {\n    // datepicker.isInput can now be set to anything, but when read, always returns true. Tricksy.\n    Object.defineProperty(Datepicker.prototype, \"isInput\", {\n      get: function() { return true; },\n      set: function(v) {},\n    });\n  }\n}\n"
  },
  {
    "path": "app/client/widgets/DateTextBox.js",
    "content": "var _ = require(\"underscore\");\nvar ko = require(\"knockout\");\nvar dom = require(\"../lib/dom\");\nvar dispose = require(\"../lib/dispose\");\nvar kd = require(\"../lib/koDom\");\nvar kf = require(\"../lib/koForm\");\nvar AbstractWidget = require(\"./AbstractWidget\");\n\nconst {FormFieldRulesConfig} = require(\"app/client/components/Forms/FormConfig\");\nconst {fromKoSave} = require(\"app/client/lib/fromKoSave\");\nconst {alignmentSelect, cssButtonSelect} = require(\"app/client/ui2018/buttonSelect\");\nconst {cssLabel, cssRow} = require(\"app/client/ui/RightPanelStyles\");\nconst {cssTextInput} = require(\"app/client/ui2018/editableLabel\");\nconst {dom: gdom, styled, fromKo} = require(\"grainjs\");\nconst {select} = require(\"app/client/ui2018/menus\");\nconst {dateFormatOptions} = require(\"app/common/parseDate\");\n\n/**\n * DateTextBox - The most basic widget for displaying simple date information.\n */\nfunction DateTextBox(field) {\n  AbstractWidget.call(this, field);\n\n  this.alignment = this.options.prop(\"alignment\");\n\n  // These properties are only used in configuration.\n  this.dateFormat = this.field.config.options.prop(\"dateFormat\");\n  this.isCustomDateFormat = this.field.config.options.prop(\"isCustomDateFormat\");\n  this.mixedDateFormat = ko.pureComputed(() => this.dateFormat() === null || this.isCustomDateFormat() === null);\n\n  // Helper to set 'dateFormat' and 'isCustomDateFormat' from the set of default date format strings.\n  this.standardDateFormat = this.autoDispose(ko.computed({\n    owner: this,\n    read: function() { return this.mixedDateFormat() ? null : this.isCustomDateFormat() ? \"Custom\" : this.dateFormat(); },\n    write: function(val) {\n      if (val === \"Custom\") { this.isCustomDateFormat.setAndSave(true); } else {\n        this.field.config.options.update({isCustomDateFormat: false, dateFormat: val});\n        this.field.config.options.save();\n      }\n    }\n  }));\n\n  // An observable that always returns `UTC`, eases DateTimeEditor inheritance.\n  this.timezone = ko.observable(\"UTC\");\n}\ndispose.makeDisposable(DateTextBox);\n_.extend(DateTextBox.prototype, AbstractWidget.prototype);\n\nDateTextBox.prototype.buildDateConfigDom = function() {\n  const disabled = this.field.config.options.disabled(\"dateFormat\");\n  return dom(\"div\",\n    cssLabel(\"Date Format\"),\n    cssRow(dom(select(\n      fromKo(this.standardDateFormat),\n      [...dateFormatOptions, \"Custom\"],\n      { disabled, defaultLabel: \"Mixed format\" },\n    ), dom.testId(\"Widget_dateFormat\"))),\n    kd.maybe(() => !this.mixedDateFormat() && this.isCustomDateFormat(), () => {\n      return cssRow(dom(\n        textbox(this.dateFormat, { disabled }),\n        dom.testId(\"Widget_dateCustomFormat\")));\n    })\n  );\n};\n\nDateTextBox.prototype.buildConfigDom = function() {\n  return dom(\"div\",\n    this.buildDateConfigDom(),\n    cssRow(\n      alignmentSelect(\n        fromKoSave(this.field.config.options.prop(\"alignment\")),\n        cssButtonSelect.cls(\"-disabled\", this.field.config.options.disabled(\"alignment\")),\n      ),\n    )\n  );\n};\n\nDateTextBox.prototype.buildTransformConfigDom = function() {\n  return this.buildDateConfigDom();\n};\n\nDateTextBox.prototype.buildFormConfigDom = function() {\n  return [\n    gdom.create(FormFieldRulesConfig, this.field),\n  ];\n};\n\nDateTextBox.prototype.buildDom = function(row) {\n  let value = row[this.field.colId()];\n  return dom(\"div.field_clip\",\n    kd.style(\"text-align\", this.alignment),\n    kd.text(() => row._isAddRow() || this.isDisposed() ? \"\" : this.valueFormatter().format(value()))\n  );\n};\n\n// clean up old koform styles\nconst cssClean = styled(\"div\", `\n  flex: 1;\n  margin: 0px;\n`);\n\n// override focus - to look like modern ui\nconst cssFocus = styled(\"div\", `\n  &:focus {\n    outline: none;\n    box-shadow: 0 0 3px 2px var(--grist-color-cursor);\n    border: 1px solid transparent;\n  }\n`);\n\n// helper method to create old style textbox that looks like a new one\nfunction textbox(value, options) {\n  const textDom = kf.text(value, options ?? {});\n  const tzInput = textDom.querySelector(\"input\");\n  dom(tzInput,\n    kd.cssClass(cssTextInput.className),\n    kd.cssClass(cssFocus.className)\n  );\n  dom(textDom,\n    kd.cssClass(cssClean.className)\n  );\n  return textDom;\n}\n\nmodule.exports = DateTextBox;\n"
  },
  {
    "path": "app/client/widgets/DateTimeEditor.css",
    "content": ".default_editor.celleditor_datetime {\n  box-shadow: none;\n  display: flex;\n}\n\n.celleditor_datetime_editor.celleditor_cursor_editor {\n  flex: auto;\n  min-width: 0;\n  overflow: hidden;\n  box-shadow: none;\n  z-index: 9;\n  outline: 1px solid var(--grist-color-cursor);\n  position: relative;\n}\n\n.celleditor_datetime_editor:focus-within {\n  box-shadow: 0 0 3px 2px var(--grist-color-cursor);\n  z-index: 10;\n  outline: none;\n}\n\n.celleditor_datetime_editor > .celleditor_text_editor {\n  width: 100%;\n}\n\n.datepicker {\n  color: var(--grist-theme-text, #333) !important;\n  background-color: var(--grist-theme-menu-bg, #fff) !important;\n  outline: none;\n}\n\n.datepicker-dropdown {\n  box-shadow: 0 2px 20px 0 var(--grist-theme-menu-shadow, rgba(38, 38, 51, 0.6));\n}\n\n.datepicker-dropdown.datepicker-orient-top:after {\n  border-top: 6px solid var(--grist-theme-menu-bg, #fff);\n}\n\n.datepicker-dropdown.datepicker-orient-bottom:after {\n  border-bottom: 6px solid var(--grist-theme-menu-bg, #fff);\n}\n\n.datepicker .prev:hover,\n.datepicker .next:hover,\n.datepicker .datepicker-switch:hover,\n.datepicker .day:hover,\n.datepicker .month:hover,\n.datepicker .year:hover,\n.datepicker .decade:hover,\n.datepicker .century:hover,\n.datepicker th.today:hover,\n.datepicker .focused\n{\n  background: var(--grist-theme-hover, #eee) !important;\n}\n\n.datepicker .active {\n  color: var(--grist-theme-date-picker-selected-fg, #fff) !important;\n  background-color: var(--grist-theme-date-picker-selected-bg, #286090) !important;\n  border-color: var(--grist-theme-date-picker-selected-bg, #204d74) !important;\n  text-shadow: none !important;\n}\n\n.datepicker .active:hover {\n  background-color: var(--grist-theme-date-picker-selected-bg-hover, #204d74) !important;\n  border-color: var(--grist-theme-date-picker-selected-bg-hover, #122b40) !important;\n}\n\n.datepicker .old,\n.datepicker .new\n{\n  color: var(--grist-theme-text-light, #777) !important;\n}\n\n.datepicker .range-start,\n.datepicker .range-end\n{\n  color: var(--grist-theme-text, #fff) !important;\n  background-color: var(--grist-theme-date-picker-range-start-end-bg, #777) !important;\n  border-color: var(--grist-theme-date-picker-range-start-end-bg, #555) !important;\n  text-shadow: none !important;\n}\n\n.datepicker .range-start:hover,\n.datepicker .range-end:hover\n{\n  background-color: var(--grist-theme-date-picker-range-start-end-bg-hover, #5e5e5e) !important;\n  border-color: var(--grist-theme-date-picker-range-start-end-bg-hover, #373737) !important;\n}\n\n.datepicker .range\n{\n  color: var(--grist-theme-text, #000) !important;\n  background-color: var(--grist-theme-date-picker-range-bg, #eee) !important;\n  border-color: var(--grist-theme-date-picker-range-bg, #bbb) !important;\n}\n\n.datepicker .range:hover {\n  background-color: var(--grist-theme-date-picker-range-bg-hover, #d5d5d5) !important;\n  border-color: var(--grist-theme-date-picker-range-bg-hover, #9d9d9d) !important;\n}\n\n.datepicker td.today {\n  color: var(--grist-theme-date-picker-today-fg, #000) !important;\n  background-color: var(--grist-theme-date-picker-today-bg, #f7ca77) !important;\n  border-color: var(--grist-theme-date-picker-today-bg, #f1a417) !important;\n  text-shadow: none !important;\n}\n.datepicker td.today:hover {\n  background-color: var(--grist-theme-date-picker-today-bg-hover, #f4b747) !important;\n  border-color: var(--grist-theme-date-picker-today-bg-hover, #bf800c) !important;\n}\n"
  },
  {
    "path": "app/client/widgets/DateTimeEditor.ts",
    "content": "import { DateEditor } from \"app/client/widgets/DateEditor\";\nimport { FieldOptions } from \"app/client/widgets/NewBaseEditor\";\nimport { removePrefix } from \"app/common/gutil\";\nimport { parseDate } from \"app/common/parseDate\";\n\nimport { dom } from \"grainjs\";\nimport moment from \"moment-timezone\";\n\n/**\n * DateTimeEditor - Editor for DateTime type. Includes a dropdown datepicker.\n *  See reference: http://bootstrap-datepicker.readthedocs.org/en/latest/index.html\n */\nexport class DateTimeEditor extends DateEditor {\n  private _timeFormat: string | undefined;\n  private _dateSizer: HTMLElement;\n  private _timeSizer: HTMLElement;\n  private _dateInput: HTMLTextAreaElement;\n  private _timeInput: HTMLTextAreaElement;\n\n  constructor(options: FieldOptions) {\n    // Get the timezone from the end of the type string.\n    const timezone = removePrefix(options.field.column().type(), \"DateTime:\");\n\n    // Adjust the command group, but not for readonly mode.\n    if (!options.readonly) {\n      const origCommands = options.commands;\n      options.commands = {\n        ...origCommands,\n        prevField: () => this._focusIndex() === 1 ? this._setFocus(0) : origCommands.prevField(),\n        nextField: () => this._focusIndex() === 0 ? this._setFocus(1) : origCommands.nextField(),\n      };\n    }\n\n    // Call the superclass.\n    super(options, timezone || \"UTC\");\n    this._timeFormat = this.options.field.widgetOptionsJson.peek().timeFormat;\n\n    // To reuse code, this knows all about the DOM that DateEditor builds (using TextEditor), and\n    // modifies that to be two side-by-side textareas.\n    this._dateSizer = this.contentSizer;    // For consistency with _timeSizer.\n    this._dateInput = this.textInput;       // For consistency with _timeInput.\n\n    const isValid = (typeof options.cellValue === \"number\");\n    const formatted = this.formatValue(options.cellValue, this._timeFormat, false);\n    // Use a placeholder of 12:00am, since that is the autofill time value.\n    const placeholder = moment.tz(\"0\", \"H\", this.timezone).format(this._timeFormat);\n\n    // for readonly\n    if (options.readonly) {\n      if (!isValid) {\n        // do nothing - DateEditor will show correct error\n      } else {\n        // append time format or a placeholder\n        const time = (formatted || placeholder);\n        const sep = time ? \" \" : \"\";\n        this.textInput.value = this.textInput.value + sep + time;\n      }\n    } else {\n      const widgetElem = this.getDom();\n      dom.update(widgetElem, dom.cls(\"celleditor_datetime\"));\n      dom.update(this.cellEditorDiv, dom.cls(\"celleditor_datetime_editor\"));\n      widgetElem.appendChild(\n        dom(\"div\",\n          dom.cls(\"celleditor_cursor_editor\"),\n          dom.cls(\"celleditor_datetime_editor\"),\n          this._timeSizer = dom(\"div\", dom.cls(\"celleditor_content_measure\")),\n          this._timeInput = dom(\"textarea\", dom.cls(\"celleditor_text_editor\"),\n            dom.attr(\"placeholder\", placeholder),\n            dom.prop(\"value\", formatted),\n            this.commandGroup.attach(),\n            dom.on(\"input\", () => this._onChange()),\n          ),\n        ),\n      );\n    }\n\n    // If the edit value is encoded json, use those values as a starting point\n    if (typeof options.state == \"string\") {\n      try {\n        const { date, time } = JSON.parse(options.state);\n        this._dateInput.value = date;\n        this._timeInput.value = time;\n        this._onChange();\n      } catch (e) {\n        console.error(\"DateTimeEditor can't restore its previous state\");\n      }\n    }\n  }\n\n  public getCellValue() {\n    const date = this._dateInput.value;\n    const time = this._timeInput.value;\n    const timestamp = parseDate(date, {\n      dateFormat: this.safeFormat,\n      time: time,\n      timeFormat: this._timeFormat,\n      timezone: this.timezone,\n    });\n    return timestamp !== null ? timestamp :\n      (date && time ? `${date} ${time}` : date || time);\n  }\n\n  public setSizerLimits() {\n    const maxSize = this.editorPlacement.calcSize({ width: Infinity, height: Infinity }, { calcOnly: true });\n    if (this.options.readonly) {\n      return;\n    }\n    this._dateSizer.style.maxWidth =\n      this._timeSizer.style.maxWidth = Math.ceil(maxSize.width / 2 - 6) + \"px\";\n  }\n\n  /**\n   * Overrides the resizing function in TextEditor.\n   */\n  protected resizeInput() {\n    // for readonly field, we will use logic from a super class\n    if (this.options.readonly) {\n      return super.resizeInput();\n    }\n    // Use the size calculation provided in options.calcSize (that takes into account cell size and\n    // screen size), with both date and time parts as the input. The resulting size is applied to\n    // the parent (containing date + time), with date and time each expanding or shrinking from the\n    // measured sizes using flexbox logic.\n    this._dateSizer.textContent = this._dateInput.value;\n    this._timeSizer.textContent = this._timeInput.value;\n    const dateRect = this._dateSizer.getBoundingClientRect();\n    const timeRect = this._timeSizer.getBoundingClientRect();\n    // Textboxes get 3px of padding on left/right/top (see TextEditor.css); we specify it manually\n    // since editorPlacement can't do a good job figuring it out with the flexbox arrangement.\n    const size = this.editorPlacement.calcSize({\n      width: dateRect.width + timeRect.width + 12,\n      height: Math.max(dateRect.height, timeRect.height) + 3,\n    });\n    this.getDom().style.width = size.width + \"px\";\n    this._dateInput.parentElement!.style.flexBasis = (dateRect.width + 6) + \"px\";\n    this._timeInput.parentElement!.style.flexBasis = (timeRect.width + 6) + \"px\";\n    this._dateInput.style.height = Math.ceil(size.height - 3) + \"px\";\n    this._timeInput.style.height = Math.ceil(size.height - 3) + \"px\";\n  }\n\n  /**\n   * Returns which element has focus: 0 if date, 1 if time, null if neither.\n   */\n  private _focusIndex() {\n    return document.activeElement === this._dateInput ? 0 :\n      (document.activeElement === this._timeInput ? 1 : null);\n  }\n\n  /**\n   * Sets focus to date if index is 0, or time if index is 1.\n   */\n  private _setFocus(index: 0 | 1) {\n    const elem = (index === 0 ? this._dateInput : (index === 1 ? this._timeInput : null));\n    if (elem) {\n      elem.focus();\n      elem.selectionStart = 0;\n      elem.selectionEnd = elem.value.length;\n    }\n  }\n\n  /**\n   * Occurs when user types something into the editor\n   */\n  private _onChange() {\n    this.resizeInput();\n\n    // store editor state as an encoded JSON string\n    const date = this._dateInput.value;\n    const time = this._timeInput.value;\n    this.editorState.set(JSON.stringify({ date, time }));\n  }\n}\n"
  },
  {
    "path": "app/client/widgets/DateTimeTextBox.js",
    "content": "var _ = require(\"underscore\");\nvar ko = require(\"knockout\");\nvar moment = require(\"moment-timezone\");\nvar dom = require(\"../lib/dom\");\nvar dispose = require(\"../lib/dispose\");\nvar kd = require(\"../lib/koDom\");\nvar kf = require(\"../lib/koForm\");\nvar DateTextBox = require(\"./DateTextBox\");\nvar gutil = require(\"app/common/gutil\");\n\nconst {fromKoSave} = require(\"app/client/lib/fromKoSave\");\nconst {alignmentSelect, cssButtonSelect} = require(\"app/client/ui2018/buttonSelect\");\nconst {cssRow, cssLabel} = require(\"app/client/ui/RightPanelStyles\");\nconst {cssTextInput} = require(\"app/client/ui2018/editableLabel\");\nconst {dom: gdom, styled, fromKo} = require(\"grainjs\");\nconst {select} = require(\"app/client/ui2018/menus\");\nconst {buildTZAutocomplete} = require(\"app/client/widgets/TZAutocomplete\");\nconst {timeFormatOptions} = require(\"app/common/parseDate\");\n\n\n/**\n * DateTimeTextBox - The most basic widget for displaying date and time information.\n */\nfunction DateTimeTextBox(field) {\n  DateTextBox.call(this, field);\n\n  // Returns the timezone from the end of the type string\n  this._timezone = this.autoDispose(ko.computed(() =>\n    gutil.removePrefix(field.column().type(), \"DateTime:\")));\n\n  this._setTimezone = (val) => field.column().type.setAndSave(\"DateTime:\" + val);\n\n  this.timeFormat = this.field.config.options.prop(\"timeFormat\");\n  this.isCustomTimeFormat = this.field.config.options.prop(\"isCustomTimeFormat\");\n  this.mixedTimeFormat = ko.pureComputed(() => this.timeFormat() === null || this.isCustomTimeFormat() === null);\n\n  // Helper to set 'timeFormat' and 'isCustomTimeFormat' from the set of default time format strings.\n  this.standardTimeFormat = this.autoDispose(ko.computed({\n    owner: this,\n    read: function() { return this.isCustomTimeFormat() ? \"Custom\" : this.timeFormat(); },\n    write: function(val) {\n      if (val === \"Custom\") { this.isCustomTimeFormat.setAndSave(true); } else {\n        this.isCustomTimeFormat.setAndSave(false);\n        this.timeFormat.setAndSave(val);\n      }\n    }\n  }));\n}\ndispose.makeDisposable(DateTimeTextBox);\n_.extend(DateTimeTextBox.prototype, DateTextBox.prototype);\n\n/**\n * Builds the config dom for the DateTime TextBox. If isTransformConfig is true,\n * builds only the necessary dom for the transform config menu.\n */\nDateTimeTextBox.prototype.buildConfigDom = function(_gristDoc, isTransformConfig) {\n  const disabled = ko.pureComputed(() => {\n    return this.field.config.options.disabled(\"timeFormat\")() || this.field.column().disableEditData();\n  });\n  const alignment = fromKoSave(this.field.config.options.prop(\"alignment\"));\n  return dom(\"div\",\n    cssLabel(\"Timezone\"),\n    cssRow(\n      gdom.create(buildTZAutocomplete, moment, fromKo(this._timezone), this._setTimezone,\n        { disabled : fromKo(disabled)}),\n    ),\n    this.buildDateConfigDom(),\n    cssLabel(\"Time Format\"),\n    cssRow(dom(\n      select(\n        fromKo(this.standardTimeFormat),\n        [...timeFormatOptions, \"Custom\"],\n        { disabled : fromKo(disabled), defaultLabel: \"Mixed format\" }\n      ),\n      dom.testId(\"Widget_timeFormat\")\n    )),\n    kd.maybe(() => !this.mixedTimeFormat() && this.isCustomTimeFormat(), () => {\n      return cssRow(\n        dom(\n          textbox(this.timeFormat, { disabled: this.field.config.options.disabled(\"timeFormat\")}),\n          dom.testId(\"Widget_timeCustomFormat\")\n        )\n      );\n    }),\n    isTransformConfig ? null : cssRow(\n      alignmentSelect(\n        alignment,\n        cssButtonSelect.cls(\"-disabled\", this.field.config.options.disabled(\"alignment\")),\n      )\n    )\n  );\n};\n\nDateTimeTextBox.prototype.buildTransformConfigDom = function(gristDoc) {\n  return this.buildConfigDom(gristDoc, true);\n};\n\n// clean up old koform styles\nconst cssClean = styled(\"div\", `\n  flex: 1;\n  margin: 0px;\n`);\n\n// override focus - to look like modern ui\nconst cssFocus = styled(\"div\", `\n  &:focus {\n    outline: none;\n    box-shadow: 0 0 3px 2px #5e9ed6;\n    border: 1px solid transparent;\n  }\n`);\n\n\n// helper method to create old style textbox that looks like a new one\nfunction textbox(value, options) {\n  const textDom = kf.text(value, options || {});\n  const tzInput = textDom.querySelector(\"input\");\n  dom(tzInput,\n    kd.cssClass(cssTextInput.className),\n    kd.cssClass(cssFocus.className)\n  );\n  dom(textDom,\n    kd.cssClass(cssClean.className)\n  );\n  return textDom;\n}\n\nmodule.exports = DateTimeTextBox;\n"
  },
  {
    "path": "app/client/widgets/DiffBox.ts",
    "content": "import { CellDiffTool, DIFF_LOCAL } from \"app/client/lib/CellDiffTool\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { NewAbstractWidget } from \"app/client/widgets/NewAbstractWidget\";\nimport { inlineStyle } from \"app/common/gutil\";\n\nimport { Diff, DIFF_DELETE, DIFF_INSERT } from \"diff-match-patch\";\nimport { Computed, dom } from \"grainjs\";\n\n/**\n *\n * A special widget used for rendering cell-level comparisons and conflicts.\n *\n */\nexport class DiffBox extends NewAbstractWidget {\n  private _diffTool = new CellDiffTool();\n\n  public buildConfigDom() {\n    return dom(\"div\");\n  }\n\n  /**\n   * Render a cell-level diff as a series of styled spans.\n   */\n  public buildDom(row: DataRowModel) {\n    const formattedValue = Computed.create(null, (use) => {\n      if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {\n        // Work around JS errors during certain changes, following code in Reference.js\n        return [] as Diff[];\n      }\n      const value = use(row.cells[use(use(this.field.displayColModel).colId)]);\n      const formatter = use(this.valueFormatter);\n      return this._diffTool.prepareCellDiff(value, formatter);\n    });\n    return dom(\n      \"div.field_clip\",\n      dom.autoDispose(formattedValue),\n      dom.style(\"text-align\", this.options.prop(\"alignment\")),\n      dom.cls(\"text_wrapping\", use => Boolean(use(this.options.prop(\"wrap\")))),\n      inlineStyle(\"--grist-diff-color\", \"#000000\"),\n      inlineStyle(\"--grist-diff-background-color\", \"#00000000\"),\n      dom.forEach(formattedValue, ([code, txt]) => {\n        if (code === DIFF_DELETE) {\n          return dom(\"span.diff-parent\", txt);\n        } else if (code === DIFF_INSERT) {\n          return dom(\"span.diff-remote\", txt);\n        } else if (code === DIFF_LOCAL) {\n          return dom(\"span.diff-local\", txt);\n        } else {\n          return dom(\"span.diff-common\", txt);\n        }\n      }),\n    );\n  }\n}\n"
  },
  {
    "path": "app/client/widgets/DiscussionEditor.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { showUndoDiscardNotification } from \"app/client/components/Drafts\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { domDispatch, domOnCustom, makeTestId } from \"app/client/lib/domUtils\";\nimport { createObsArray } from \"app/client/lib/koArrayWrap\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { localStorageBoolObs } from \"app/client/lib/localStorageObs\";\nimport { CellRec, ViewSectionRec } from \"app/client/models/DocModel\";\nimport { reportError } from \"app/client/models/errors\";\nimport { getCurrentDocUrl, urlState } from \"app/client/models/gristUrlState\";\nimport { INotification } from \"app/client/models/NotifyModel\";\nimport { RowSource, RowWatcher } from \"app/client/models/rowset\";\nimport { renderCellMarkdown } from \"app/client/ui/MarkdownCellRenderer\";\nimport { createUserImage } from \"app/client/ui/UserImage\";\nimport { basicButton, primaryButton, textButton } from \"app/client/ui2018/buttons\";\nimport { labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menu, menuItem } from \"app/client/ui2018/menus\";\nimport { cssMarkdown } from \"app/client/widgets/MarkdownTextBox\";\nimport { buildMentionTextBox, CommentWithMentions } from \"app/client/widgets/MentionTextBox\";\nimport { CommentContent } from \"app/common/DocComments\";\nimport { CellInfoType } from \"app/common/gristTypes\";\nimport { FullUser, PermissionData } from \"app/common/UserAPI\";\nimport { CursorPos } from \"app/plugin/GristAPI\";\n\nimport {\n  bundleChanges,\n  Computed,\n  Disposable,\n  dom,\n  DomArg,\n  DomContents,\n  DomElementArg,\n  Holder,\n  IDomComponent,\n  MultiHolder,\n  ObsArray,\n  Observable,\n  styled,\n} from \"grainjs\";\nimport * as ko from \"knockout\";\nimport flatMap from \"lodash/flatMap\";\nimport moment from \"moment\";\nimport { PopupControl, popupOpen } from \"popweasel\";\n\nconst testId = makeTestId(\"test-discussion-\");\nconst t = makeT(\"DiscussionEditor\");\nconst COMMENTS_LIMIT = 200;\n\nexport interface DiscardedComment extends CursorPos {\n  text: CommentWithMentions;\n}\n\ninterface DiscussionModel {\n  /**\n   * List of comments to show. In popup comments are only shown for the current cell. In panel comments are\n   * filtered manually by user.\n   */\n  comments: Observable<CellRec[]>,\n  /**\n   * If there are no active comments in the list.\n   */\n  isEmpty: Computed<boolean>,\n  /**\n   * Saves a comment that creates a new discussion in the given cell.\n   * Assumes that the cell has no comments yet.\n   */\n  startIn(pos: CursorPos, text: CommentWithMentions): Promise<void>;\n  /**\n   * Replies to a comment with the provided text.\n   */\n  reply(discussion: CellRec, text: CommentWithMentions): Promise<void>;\n  /**\n   * Resolves one discussion thread. There should be only one unresolved discussion per cell.\n   */\n  resolve(discussion: CellRec): Promise<void>;\n  /**\n   * Updates a comment with the provided text.\n   */\n  update(comment: CellRec, text: CommentWithMentions): Promise<void>;\n  /**\n   * Opens a resolved discussion thread. There should be only one unresolved discussion thread at a time. Popup will\n   * show only the last unresolved discussion thread, but all threads are available in the discussion panel.\n   */\n  open(discussion: CellRec): Promise<void>;\n  /**\n   * Removes a comment or a whole discussion thread depending on the comment itself (if it is a root of discussion or\n   * just a reply).\n   */\n  remove(comment: CellRec): Promise<void>;\n}\n\nexport class DiscussionModelImpl extends Disposable implements DiscussionModel {\n  public static fromCursor(owner: MultiHolder, gristDoc: GristDoc, cursorPos: CursorPos): DiscussionModelImpl {\n    if (!cursorPos.sectionId || cursorPos.fieldIndex === undefined || typeof cursorPos.rowId !== \"number\") {\n      throw new Error(\"Cannot create CellImpl without sectionId, fieldIndex and rowId in cursor position\");\n    }\n    const section = gristDoc.docModel.viewSections.getRowModel(cursorPos.sectionId);\n    const column = section.viewFields.peek().peek()[cursorPos.fieldIndex].column.peek();\n    const rowId = Number(cursorPos.rowId);\n    const comments = Computed.create(null, (use) => {\n      const fromColumn = use(use(column.cells).getObservable());\n      const forRow = fromColumn.filter(d => use(d.rowId) === rowId && use(d.root) && !use(d.hidden));\n      return forRow;\n    });\n    const model = DiscussionModelImpl.create(owner, gristDoc, comments);\n    model.autoDispose(comments);\n    return model;\n  }\n\n  public isEmpty: Computed<boolean>;\n\n  constructor(\n    protected gristDoc: GristDoc,\n    public comments: Observable<CellRec[]>,\n  ) {\n    super();\n\n    this.isEmpty = Computed.create(this, (use) => {\n      const discussions = use(comments);\n      const notResolved = discussions.filter(d => !use(d.resolved));\n      const visible = notResolved.filter(d => !use(d.hidden));\n      return visible.length === 0;\n    });\n  }\n\n  public async startIn(pos: CursorPos, commentText: CommentWithMentions): Promise<void> {\n    this.gristDoc.commentMonitor?.clear();\n    if (!pos.sectionId || pos.fieldIndex === undefined || typeof pos.rowId !== \"number\") {\n      throw new Error(\"Cannot start discussion without sectionId, fieldIndex and rowId in cursor position\");\n    }\n    const section = this.gristDoc.docModel.viewSections.getRowModel(pos.sectionId);\n    const column = section.viewFields.peek().peek()[pos.fieldIndex].column.peek();\n    const colRef = column.id.peek();\n    const rowId = Number(pos.rowId);\n    const tableRef = section.table.peek().id.peek();\n    const author = commentAuthor(this.gristDoc);\n    const hashForSection = this.gristDoc.currentView.get()?.getAnchorLinkForSection(pos.sectionId);\n    const commentsHash = { hash: { ...hashForSection, comments: true } };\n    const fullUrl = hashForSection ? urlState().makeUrl(commentsHash) : undefined;\n    // Anchor link has a format: /p/1#anchor-link, so it also includes the page we are expected to land.\n    const anchorLink = fullUrl ? fullUrl.replace(getCurrentDocUrl(), \"\") : undefined;\n    await this.gristDoc.docData.sendActions([[\n      \"AddRecord\",\n      \"_grist_Cells\",\n      null,\n      {\n        tableRef,\n        colRef,\n        rowId,\n        type: CellInfoType.COMMENT,\n        root: true,\n        content: JSON.stringify({\n          userName: author?.name ?? \"\",\n          anchorLink,\n          ...commentText,\n        } as CommentContent),\n      },\n    ]], t(\"Started discussion\"));\n  }\n\n  public async reply(comment: CellRec, commentText: CommentWithMentions): Promise<void> {\n    this.gristDoc.commentMonitor?.clear();\n    const author = commentAuthor(this.gristDoc);\n    await this.gristDoc.docData.bundleActions(t(\"Reply to a comment\"), () =>\n      this.gristDoc.docModel.cells.sendTableAction([\n        \"AddRecord\",\n        null,\n        {\n          parentId: comment.id.peek(),\n          root: false,\n          type: CellInfoType.COMMENT,\n          content: JSON.stringify({\n            userName: author?.name ?? \"\",\n            sectionId: comment.sectionId.peek(),\n            anchorLink: comment.anchorLink.peek(),\n            ...commentText,\n          } as CommentContent),\n          tableRef: comment.tableRef.peek(),\n          colRef: comment.colRef.peek(),\n          rowId: comment.rowId.peek(),\n        },\n      ]),\n    );\n  }\n\n  public resolve(comment: CellRec): Promise<void> {\n    return comment.resolved.setAndSave(true);\n  }\n\n  public async update(comment: CellRec, commentText: CommentWithMentions): Promise<void> {\n    this.gristDoc.commentMonitor?.clear();\n    comment.mentions(commentText.mentions);\n    return comment.text.setAndSave(commentText.text);\n  }\n\n  public async open(comment: CellRec): Promise<void> {\n    return comment.resolved.setAndSave(false);\n  }\n\n  public async remove(comment: CellRec): Promise<void> {\n    await comment._table.sendTableAction([\"RemoveRecord\", comment.id.peek()]);\n  }\n}\n\n/**\n * Discussion popup that is attached to a cell.\n */\nexport class CommentPopup extends Disposable {\n  private _newText: Observable<CommentWithMentions> = Observable.create(this,\n    this._props.initialText ?? new CommentWithMentions());\n\n  private _menuInstance: PopupControl;\n\n  constructor(private _props: {\n    domEl: Element,\n    cell: DiscussionModel,\n    gristDoc: GristDoc,\n    cursorPos: CursorPos,\n    initialText?: CommentWithMentions | null,\n    closeClicked: () => void;\n  }) {\n    super();\n    this._props.gristDoc.docPageModel.refreshDocumentAccess().catch(reportError);\n    const access = this._props.gristDoc.docPageModel.docUsers;\n\n    this._menuInstance = popupOpen(this._props.domEl, (ctl) => {\n      // When the popup is being disposed (after closing).\n      ctl.onDispose(() => {\n        // Make sure we are not disposed already. TODO: popupMenu should have some hooks exposed like beforeClose.\n        if (this.isDisposed() || this._newText.isDisposed()) { return; }\n        // If there is some text, store it in the comment monitor to allow restoring it.\n        const text = this._newText.get();\n        if (text.shouldBeRestored()) {\n          this._props.gristDoc.commentMonitor?.setDiscardedComment({\n            text,\n            ...this._props.cursorPos,\n          });\n        }\n      });\n      return cssCommentPopup(\n        testId(\"popup\"),\n        dom.domComputed(this._props.cell.isEmpty, (empty) => {\n          if (!empty) {\n            return dom.create(SingleThread, {\n              text: this._newText,\n              cell: _props.cell,\n              gristDoc: _props.gristDoc,\n              access,\n              closeClicked: _props.closeClicked,\n              cursorPos: _props.cursorPos,\n            });\n          } else {\n            return dom.create(EmptyThread, {\n              access,\n              text: this._newText,\n              currentUserId: _props.gristDoc.currentUser.get()?.id ?? 0,\n              closeClicked: _props.closeClicked,\n              onSave: text => this._onSave(text).catch(reportError),\n            });\n          }\n        }),\n      );\n    }, {\n      placement: \"bottom\",\n      attach: \"body\",\n      boundaries: \"window\",\n    });\n\n    this.onDispose(() => {\n      if (this._menuInstance && !this._menuInstance.isDisposed()) {\n        this._menuInstance.dispose();\n      }\n    });\n  }\n\n  private async _onSave(text: CommentWithMentions) {\n    if (!this._menuInstance.isDisposed()) {\n      this._menuInstance.update();\n    }\n    await this._props.cell.startIn(this._props.cursorPos, text);\n  }\n}\n\n/**\n * Monitor for discarded comments. It will show a popup with an undo button for 10 seconds to\n * restore the discarded comment and move the cursor to the cell.\n */\nexport class CommentMonitor extends Disposable {\n  private _currentNotification: Holder<INotification> = Holder.create(this);\n\n  constructor(private _doc: GristDoc) {\n    super();\n    // If the cursor is changed in anyway, remove the popup.\n    this.autoDispose(this._doc.cursorPosition.addListener(() => {\n      this.clear();\n    }));\n  }\n\n  public clear() {\n    if (this.isDisposed()) { return; }\n    this._currentNotification.clear();\n  }\n\n  public setDiscardedComment(discarded: DiscardedComment) {\n    this._currentNotification.autoDispose(\n      showUndoDiscardNotification(this._doc, () => {\n        this._doc.moveToCursorPos(discarded)\n          .then(() => this.isDisposed() || allCommands.openDiscussion.run(null, discarded.text))\n          .then(() => this.clear())\n          .catch(reportError);\n      }),\n    );\n  }\n}\n\n/**\n * Component for starting discussion on a cell. Displays simple textbox and a button to start discussion.\n */\nclass EmptyThread extends Disposable {\n  private _entry: CommentEntry;\n\n  constructor(public props: {\n    text: Observable<CommentWithMentions>,\n    access: Observable<PermissionData | null>,\n    currentUserId: number,\n    closeClicked: () => void,\n    onSave: (text: CommentWithMentions) => void\n  }) {\n    super();\n    this._entry = CommentEntry.create(this, {\n      currentUserId: this.props.currentUserId,\n      access: this.props.access,\n      mode: \"start\",\n      text: this.props.text,\n      editorArgs: [{ placeholder: t(\"Write a comment\") }],\n      mainButton: t(\"Comment\"),\n      buttons: [t(\"Cancel\")],\n      args: [testId(\"editor-start\")],\n      onSave: this._onSave.bind(this),\n      onCancel: this._onCancel.bind(this),\n    });\n  }\n\n  public buildDom() {\n    return cssTopic(\n      testId(\"topic-empty\"),\n      testId(\"topic\"),\n      cssCommonPadding(\n        this._entry.buildDom(),\n      ),\n      dom.onKeyDown({\n        Escape: () => this.props.closeClicked?.(),\n      }),\n    );\n  }\n\n  private _onSave(md: CommentWithMentions) {\n    this.props.text.set(new CommentWithMentions());\n    this.props.onSave(md);\n    this._entry.clear();\n  }\n\n  private _onCancel() {\n    this.props.text.set(new CommentWithMentions());\n    this.props.closeClicked?.();\n  }\n}\n\n/**\n * Main component for displaying discussion on a popup.\n * Shows only comments that are not resolved. UI tries best to keep only one unresolved comment at a time.\n * But if there are multiple unresolved comments, it will show all of them in a list.\n */\nclass SingleThread extends Disposable implements IDomComponent {\n  // Holder for a new comment text.\n  private _newText = this.props.text;\n  // CommentList dom - used for scrolling.\n  private _commentList!: HTMLDivElement;\n  // Currently edited comment.\n  private _commentInEdit = Observable.create<Comment | null>(this, null);\n  // Helper variable to mitigate some flickering when closing editor.\n  // We hide the editor before resolving discussion or clearing discussion, as\n  // actions that create discussions and comments are asynchronous, so user can see\n  // that comments elements are removed.\n  private _closing = Observable.create(this, false);\n  private _comments: Observable<CellRec[]>;\n  private _commentsToRender: Observable<CellRec[]>;\n  private _truncated: Observable<boolean>;\n  private _entry: CommentEntry;\n\n  constructor(public props: {\n    text: Observable<CommentWithMentions>,\n    cell: DiscussionModel,\n    access: Observable<PermissionData | null>,\n    gristDoc: GristDoc,\n    cursorPos: CursorPos,\n    closeClicked?: () => void,\n    listChanged?: () => void,\n  }) {\n    super();\n    // On popup we will only show last non resolved comment.\n    this._comments = Computed.create(this,\n      use => use(props.cell.comments)\n        .filter(ds => !use(ds.resolved) && !use(ds.hidden) && use(ds.root))\n        .sort((a, b) => (use(a.timeCreated) ?? 0) - (use(b.timeCreated) ?? 0)));\n    this._commentsToRender = Computed.create(this, (use) => {\n      const sorted = use(this._comments).sort((a, b) => (use(a.timeCreated) ?? 0) - (use(b.timeCreated) ?? 0));\n      const start = Math.max(0, sorted.length - COMMENTS_LIMIT);\n      return sorted.slice(start);\n    });\n\n    if (this.props.listChanged) {\n      const sizeObs = Computed.create(this, use => use(this._commentsToRender).length);\n      this.autoDispose(sizeObs.addListener(() => {\n        this.props.listChanged?.();\n      }));\n    }\n\n    this._truncated = Computed.create(this, use => use(this._comments).length > COMMENTS_LIMIT);\n    this._entry = CommentEntry.create(this, {\n      access: this.props.access,\n      mode: \"comment\",\n      text: this._newText,\n      currentUserId: this.props.gristDoc.currentUser.get()?.id ?? 0,\n      mainButton: \"Reply\",\n      editorArgs: [{ placeholder: t(\"Reply\") }],\n      args: [testId(\"editor-add\")],\n      onSave: () => this._save(),\n      onCancel: () => this.props.closeClicked?.(),\n    });\n  }\n\n  public buildDom() {\n    return cssTopic(\n      dom.maybe(this._truncated, () => cssTruncate(t(\"Showing last {{nb}} comments\", { nb: COMMENTS_LIMIT }))),\n      domOnCustom(Comment.EDIT, (s: Comment) => this._onEditComment(s)),\n      domOnCustom(Comment.CANCEL, () => this._onCancelEdit()),\n      dom.hide(this._closing),\n      testId(\"topic\"),\n      testId(\"topic-filled\"),\n      this._commentList = cssCommentList(\n        testId(\"topic-comments\"),\n        dom.forEach(this._commentsToRender, (comment) => {\n          return cssDiscussion(\n            cssDiscussion.cls(\"-resolved\", use => Boolean(use(comment.resolved))),\n            dom.create(Comment, {\n              ...this.props,\n              comment,\n            }),\n          );\n        }),\n      ),\n      dom.maybe(use => !use(this.props.gristDoc.isReadonly), () => this._createCommentEntry()),\n      dom.onKeyDown({\n        Escape: () => this.props.closeClicked?.(),\n      }),\n    );\n  }\n\n  private _onCancelEdit() {\n    if (this._commentInEdit.get()) {\n      this._commentInEdit.get()?.setEditing(false);\n    }\n    this._commentInEdit.set(null);\n  }\n\n  private _onEditComment(el: Comment) {\n    if (this._commentInEdit.get()) {\n      this._commentInEdit.get()?.setEditing(false);\n    }\n    el.setEditing(true);\n    this._commentInEdit.set(el);\n  }\n\n  private async _save() {\n    try {\n      const list = this._commentsToRender.get();\n      const md = this._newText.get();\n      this._newText.set(new CommentWithMentions());\n      this._entry.clear();\n      if (!list.length) {\n        throw new Error(\"There should be only one comment in edit mode\");\n      }\n      await this.props.cell.reply(list[list.length - 1], md);\n      this._entry.clear();\n    } catch (err) {\n      return reportError(err);\n    } finally {\n      this._commentList.scrollTo(0, 10000);\n    }\n  }\n\n  private _createCommentEntry() {\n    return cssReplyBox(this._entry.buildDom());\n  }\n}\n\n/**\n * List of comments (each can have multiple replies), used in discussion panel.\n */\nclass MultiThreads extends Disposable implements IDomComponent {\n  // Currently edited comment.\n  private _commentInEdit = Observable.create<Comment | null>(this, null);\n  // Helper variable to mitigate some flickering when closing editor.\n  // We hide the editor before resolving discussion or clearing discussion, as\n  // actions that create discussions and comments are asynchronous, so user can see\n  // that comments elements are removed.\n  private _closing = Observable.create(this, false);\n  private _comments: Observable<CellRec[]>;\n  private _commentsToRender: Observable<CellRec[]>;\n  private _truncated: Observable<boolean>;\n  private _access: Observable<PermissionData | null>;\n\n  constructor(private _props: {\n    cell: DiscussionModel,\n    readonly: Observable<boolean>,\n    gristDoc: GristDoc,\n    closeClicked?: () => void\n  }) {\n    super();\n    this._comments = Computed.create(this, use =>\n      use(_props.cell.comments).filter(ds => !use(ds.hidden) && use(ds.root)));\n\n    this._commentsToRender = Computed.create(this, (use) => {\n      const sorted = use(this._comments).sort((a, b) => (use(a.timeCreated) ?? 0) - (use(b.timeCreated) ?? 0));\n      const start = Math.max(0, sorted.length - COMMENTS_LIMIT);\n      return sorted.slice(start);\n    });\n    this._truncated = Computed.create(this, use => use(this._comments).length > COMMENTS_LIMIT);\n    this._props.gristDoc.docPageModel.refreshDocumentAccess().catch(reportError);\n    this._access = this._props.gristDoc.docPageModel.docUsers;\n  }\n\n  public buildDom() {\n    return cssTopic(\n      dom.maybe(this._truncated, () => cssTruncate(t(\"Showing last {{nb}} comments\", { nb: COMMENTS_LIMIT }))),\n      cssTopic.cls(\"-panel\"),\n      domOnCustom(Comment.EDIT, (s: Comment) => this._onEditComment(s)),\n      domOnCustom(Comment.CANCEL, () => this._onCancelEdit()),\n      dom.hide(this._closing),\n      testId(\"topic\"),\n      testId(\"topic-filled\"),\n      cssCommentList(\n        testId(\"topic-comments\"),\n        dom.forEach(this._commentsToRender, (comment) => {\n          return cssDiscussionWrapper(\n            cssDiscussion(\n              cssDiscussion.cls(\"-resolved\", use => Boolean(use(comment.resolved))),\n              dom.create(Comment, {\n                ...this._props,\n                access: this._access,\n                panel: true,\n                comment,\n              }),\n            ),\n          );\n        }),\n      ),\n      dom.onKeyDown({\n        Escape: () => this._props.closeClicked?.(),\n      }),\n    );\n  }\n\n  private _onCancelEdit() {\n    if (this._commentInEdit.get()) {\n      this._commentInEdit.get()?.setEditing(false);\n    }\n    this._commentInEdit.set(null);\n  }\n\n  private _onEditComment(el: Comment) {\n    if (this._commentInEdit.get()) {\n      this._commentInEdit.get()?.setEditing(false);\n    }\n    el.setEditing(true);\n    this._commentInEdit.set(el);\n  }\n}\n\n/**\n * Component for displaying a single comment, either in popup or discussion panel.\n */\nclass Comment extends Disposable {\n  // Public custom events. Those are propagated to the parent component (TopicView) to make\n  // sure only one comment is in edit mode at a time.\n  public static EDIT = \"comment-edit\"; // comment is in edit mode\n  public static CANCEL = \"comment-cancel\"; // edit mode was cancelled or turned off\n  public static SELECT = \"comment-select\"; // comment was clicked\n  // Public modes that are modified by topic view.\n  public replying = Observable.create(this, false);\n  private _isEditing = Observable.create(this, false);\n  private _replies: ObsArray<CellRec>;\n  private _hasReplies: Computed<boolean>;\n  private _expanded = Observable.create(this, false);\n  private _resolved: Computed<boolean>;\n  private _showReplies: Computed<boolean>;\n  private _bodyDom: Element;\n  private get _isReply() {\n    return !!this.props.parent;\n  }\n\n  private get _start() {\n    return this.props.parent ? this.props.parent : this.props.comment;\n  }\n\n  constructor(\n    public props: {\n      comment: CellRec,\n      access: Observable<PermissionData | null>,\n      cell: DiscussionModel,\n      gristDoc: GristDoc,\n      cursorPos?: CursorPos,\n      parent?: CellRec | null,\n      panel?: boolean,\n      args?: DomArg<HTMLDivElement>[]\n    }) {\n    super();\n    this._replies = createObsArray(this, props.comment.children());\n    this._hasReplies = Computed.create(this, use => use(this._replies).length > 0);\n    this._resolved = Computed.create(this, use =>\n      this._isReply && this.props.parent ?\n        Boolean(use(this.props.parent.resolved)) :\n        Boolean(use(this.props.comment.resolved)),\n    );\n    this._showReplies = Computed.create(this, (use) => {\n      // We don't show replies if we are reply.\n      if (this._isReply) {\n        return false;\n      }\n      // Or we are resolved root comment on panel that is collapsed.\n      if (use(this.props.comment.resolved) && this.props.panel && !use(this._expanded)) {\n        return false;\n      }\n      return true;\n    });\n  }\n\n  public buildDom() {\n    const comment = this.props.comment;\n    const topic = this.props.cell;\n\n    const wasUpdated = Computed.create(null, use => use(comment.timeUpdated) !== use(comment.timeCreated));\n\n    const containerClass = () => this.props.panel ? cssDiscussionPanel.className : cssCommentPopup.className;\n\n    const user = (c: CellRec) =>\n      comment.hidden() ? null : commentAuthor(this.props.gristDoc, c.userRef(), c.userName());\n    this._bodyDom = cssComment(\n      ...(this.props.args ?? []),\n      dom.autoDispose(wasUpdated),\n      this._isReply  ? testId(\"reply\") : testId(\"comment\"),\n      dom.on(\"click\", () => {\n        if (this._isReply) { return; }\n        domDispatch(this._bodyDom, Comment.SELECT, comment);\n        if (!this._resolved.get()) { return; }\n        this._expanded.set(!this._expanded.get());\n      }),\n      dom.maybe(use => !use(comment.hidden), () => [\n        cssColumns(\n          // 1. Column with avatar only\n          buildAvatar(user(comment), testId(\"comment-avatar\")),\n          // 2. Column with nickname/date, menu and text\n          cssCommentHeader(\n            // User name date and buttons\n            cssCommentBodyHeader(\n              cssCommentBodyText(\n                buildNick(user(comment), testId(\"comment-nick\")),\n                dom.domComputed(use => cssTime(\n                  formatTime(use(comment.timeUpdated) || use(comment.timeCreated) || 0),\n                  testId(\"comment-time\"),\n                  use(wasUpdated) ? dom(\"span\", \" (\" + t(\"updated\") + \")\") : null,\n                )),\n              ),\n              // if this is reply in a resolved comment, don't show menu\n              dom.maybe(use => !(this._isReply && use(this._resolved)), () => [\n                cssIconButton(\n                  icon(\"Dots\"),\n                  testId(\"comment-menu\"),\n                  dom.style(\"margin-left\", `3px`),\n                  menu(() => this._menuItems(), {\n                    placement: \"bottom-start\",\n                    attach: `.${containerClass()}`,\n                  }),\n                  dom.on(\"click\", stopPropagation),\n                ),\n              ]),\n            ),\n          ),\n        ),\n        // Comment text\n        dom.maybe(use => !use(this._isEditing),\n          () => dom.domComputed(comment.hidden, (hidden) => {\n            if (hidden) {\n              return cssCommentCensored(\n                \"CENSORED\",\n                testId(\"comment-text\"),\n              );\n            }\n            return cssRenderedCommentMarkdown(\n              dom.domComputed(comment.text, (text?: string) => text && renderCellMarkdown(text)),\n              testId(\"comment-text\"),\n            );\n          }),\n        ),\n        // Comment editor\n        dom.maybeOwned(this._isEditing,\n          (owner) => {\n            const text = Observable.create(owner, new CommentWithMentions(comment.text.peek() ?? \"\"));\n            return dom.create(CommentEntry, {\n              text,\n              mainButton: t(\"Save\"),\n              buttons: [t(\"Cancel\")],\n              currentUserId: this.props.gristDoc.currentUser.get()?.id ?? 0,\n              onSave: async () => {\n                const value = text.get();\n                await topic.update(comment, value);\n                this.setEditing(false);\n              },\n              onCancel: () => {\n                this.setEditing(false);\n              },\n              mode: \"start\",\n              args: [testId(\"editor-edit\")],\n              access: this.props.access,\n            });\n          },\n        ),\n        dom.maybe(this._showReplies, () =>\n          cssCommentReplyWrapper(\n            testId(\"replies\"),\n            cssReplyList(\n              dom.forEach(this._replies, (commentReply) => {\n                return dom(\"div\",\n                  dom.create(Comment, {\n                    ...this.props,\n                    access: this.props.access,\n                    comment: commentReply,\n                    parent: this.props.comment,\n                    args: [dom.style(\"padding-left\", \"0px\"), dom.style(\"padding-right\", \"0px\")],\n                  }),\n                );\n              }),\n            ),\n          ),\n        ),\n        // Reply editor or button\n        dom.maybe(use =>\n          !use(this._isEditing) &&\n          !this._isReply &&\n          this.props.panel &&\n          !use(this.props.gristDoc.isReadonly) &&\n          !use(comment.resolved),\n        () => dom.domComputed((use) => {\n          if (!use(this.replying)) {\n            return cssReplyButton(icon(\"Message\"), t(\"Reply\"),\n              testId(\"comment-reply-button\"),\n              dom.on(\"click\", withStop(() => this.replying.set(true))),\n              dom.style(\"margin-left\", use2 => use2(this._hasReplies) ? \"16px\" : \"0px\"),\n            );\n          } else {\n            return dom.create(CommentEntry, {\n              text: Observable.create(null, new CommentWithMentions()),\n              args: [dom.style(\"margin-top\", \"8px\"), testId(\"editor-reply\")],\n              mainButton: t(\"Reply\"),\n              buttons: [t(\"Cancel\")],\n              currentUserId: this.props.gristDoc.currentUser.get()?.id ?? 0,\n              onSave: (value: CommentWithMentions) => {\n                this.replying.set(false);\n                topic.reply(comment, value).catch(reportError);\n              },\n              onCancel: () => this.replying.set(false),\n              onClick: (button) => {\n                if (button === t(\"Cancel\")) {\n                  this.replying.set(false);\n                }\n              },\n              mode: \"reply\",\n              access: this.props.access,\n            });\n          }\n        }),\n        ),\n        // Resolved marker\n        dom.domComputed((use) => {\n          if (!use(comment.resolved) || this._isReply) { return null; }\n          return cssResolvedBlock(\n            testId(\"comment-resolved\"),\n            icon(\"FieldChoice\"),\n            cssResolvedText(dom.text(\n              t(`Marked as resolved`),\n            )));\n        }),\n      ]),\n    );\n\n    return this._bodyDom;\n  }\n\n  public setEditing(editing: boolean) {\n    if (this.props.gristDoc.isReadonly.get()) {\n      return;\n    }\n    if (this._isEditing.get() === editing) {\n      return;\n    }\n    this._isEditing.set(editing);\n    if (editing) {\n      domDispatch(this._bodyDom, Comment.EDIT, this);\n    } else {\n      domDispatch(this._bodyDom, Comment.CANCEL, this);\n    }\n  }\n\n  private _menuItems() {\n    const currentUser = this.props.gristDoc.currentUser.get()?.ref;\n    const currentUserAccess = this.props.gristDoc.docPageModel.currentDoc.get()?.access;\n    const isDocOwner = currentUserAccess === \"owners\";\n    const comment = this.props.comment;\n    const lastComment = comment.column.peek().cells.peek().peek().at(-1);\n\n    const resolveVisible = !this.props.comment.resolved() &&\n      !this._isReply;\n    const openVisible =\n      this._resolved.get() && // if this discussion is resolved\n      !this._isReply && // and this is not a reply\n      lastComment === comment; // and this is the last comment\n    const editVisible = !this._resolved.get();\n    return [\n      // Show option for anchor link, except in the side-panel view where we don't have cursorPos.\n      // Without it, we don't know the section, and anchor links can't work without it.\n      (this.props.cursorPos ?\n        menuItem(\n          () => this.props.gristDoc.copyAnchorLink({ comments: true, ...this.props.cursorPos }).catch(reportError),\n          t(\"Copy link\"),\n        ) :\n        null\n      ),\n      !resolveVisible ? null :\n        menuItem(\n          () => this.props.cell.resolve(comment),\n          t(\"Resolve\"),\n          dom.cls(\"disabled\", (use) => {\n            // Thread author or owner can resolve\n            return use(this.props.gristDoc.isReadonly) || (use(this._start.userRef) !== currentUser && !isDocOwner);\n          }),\n        ),\n      !openVisible ? null :\n        menuItem(\n          () => this.props.cell.open(comment),\n          t(\"Open\"),\n          dom.cls(\"disabled\", (use) => {\n            // Thread author or owner can open\n            return use(this.props.gristDoc.isReadonly) || (use(this._start.userRef) !== currentUser && !isDocOwner);\n          }),\n        ),\n      menuItem(\n        () => {\n          return this.props.cell.remove(comment);\n        },\n        comment.root.peek() ? t(\"Remove thread\") : t(\"Remove\"),\n        dom.cls(\"disabled\", (use) => {\n          // Comment author or owner can delete\n          return (currentUser !== use(comment.userRef) && !isDocOwner) || use(this.props.gristDoc.isReadonly);\n        }),\n      ),\n      // If comment is resolved, we can't edit it.\n      !editVisible ? null : menuItem(\n        () => this.setEditing(true),\n        t(\"Edit\"),\n        dom.cls(\"disabled\", (use) => {\n          // Only comment author can edit\n          return currentUser !== use(comment.userRef) || use(this.props.gristDoc.isReadonly);\n        }),\n      ),\n    ];\n  }\n}\n\n/**\n * Component for displaying input element for a comment (either for replying or starting a new discussion).\n */\nclass CommentEntry extends Disposable {\n  private _editableDiv: HTMLDivElement;\n\n  constructor(public props: {\n    text: Observable<CommentWithMentions>,\n    mode?: \"comment\" | \"start\" | \"reply\", // inline for reply, full for new discussion\n    mainButton?: string, // Text for the main button (defaults to Send)\n    buttons?: string[], // Additional buttons to show.\n    editorArgs?: DomArg<HTMLElement>[]\n    args?: DomArg<HTMLDivElement>[],\n    access: Observable<PermissionData | null>,\n    currentUserId: number,\n    onClick?: (button: string) => void,\n    onSave?: (m: CommentWithMentions) => void,\n    onCancel?: () => void, // On Escape\n  }) {\n    super();\n  }\n\n  public buildDom() {\n    const clickBuilder = (button: string) => dom.on(\"click\", () => {\n      if (button === t(\"Cancel\")) {\n        this.props.onCancel?.();\n      } else {\n        this.props.onClick?.(button);\n      }\n    });\n    const onEnter = () => {\n      const value = this.props.text.get();\n      if (!value.isEmpty()) {\n        this.props.onSave?.(value);\n      }\n    };\n    return cssCommentEntry(\n      ...(this.props.args ?? []),\n      cssCommentEntry.cls(`-${this.props.mode ?? \"comment\"}`),\n      testId(\"comment-input\"),\n      dom.on(\"click\", stopPropagation),\n      this._editableDiv = buildMentionTextBox(\n        this.props.text,\n        this.props.access,\n        cssCommentEntryText.cls(\"\"),\n        cssContentEditable.cls(`-${this.props.mode}`),\n        dom.onKeyDown({\n          Enter$: async (e) => {\n            if (!e.shiftKey && !this.props.text.get().isEmpty()) {\n              e.preventDefault();\n              e.stopPropagation();\n              onEnter();\n              return;\n            }\n          },\n          Escape: (e) => {\n            this.props.onCancel?.();\n            e.preventDefault();\n            e.stopPropagation();\n          },\n        }),\n        ...(this.props.editorArgs || []),\n        testId(\"textarea\"),\n      ),\n      cssCommentEntryButtons(\n        primaryButton(\n          this.props.mainButton ?? \"Send\",\n          dom.prop(\"disabled\", use => use(this.props.text).isEmpty()),\n          dom.on(\"click\", withStop(onEnter)),\n          testId(\"button-send\"),\n        ),\n        dom.forEach(this.props.buttons || [], button => basicButton(\n          button, clickBuilder(button), testId(`button-${button}`),\n        )),\n      ),\n    );\n  }\n\n  public clear() {\n    this._editableDiv.innerHTML = \"\";\n  }\n}\n\n/**\n * Component that is rendered on the right drawer. It shows all discussions in the document or on the\n * current page. By current page, we mean comments in all currently visible rows (that are not filtered out).\n */\nexport class DiscussionPanel extends Disposable implements IDomComponent {\n  // View mode - current page or whole document.\n  private _currentPage: Observable<boolean>;\n  private _currentPageKo: ko.Observable<boolean>;\n  private _onlyMine: Observable<boolean>;\n  // Toggle to switch whether to show active discussions or all discussions (including resolved ones).\n  private _resolved: Observable<boolean>;\n  private _length = Observable.create<number>(this, 0);\n\n  constructor(private _grist: GristDoc) {\n    super();\n    const userId = _grist.currentUser.get()?.id || 0;\n    // We store options in session storage, so that they are preserved across page reloads.\n    this._resolved = this.autoDispose(localStorageBoolObs(`u:${userId};showResolvedDiscussions`, false));\n    this._onlyMine = this.autoDispose(localStorageBoolObs(`u:${userId};showMyDiscussions`, false));\n    this._currentPage = this.autoDispose(localStorageBoolObs(`u:${userId};showCurrentPage`, true));\n    this._currentPageKo = ko.observable(this._currentPage.get());\n    this._currentPage.addListener(val => this._currentPageKo(val));\n  }\n\n  public buildDom(): DomContents {\n    const owner = new MultiHolder();\n\n    // Computed for all sections visible on the page.\n    const viewSections = Computed.create(owner, (use) => {\n      return use(use(this._grist.viewModel.viewSections).getObservable());\n    });\n\n    // Based on the view, we get all tables or only visible ones.\n    const tables = Computed.create(owner, (use) => {\n      // Filter out those tables that are not available by ACL.\n      if (use(this._currentPageKo)) {\n        return [...new Set(use(viewSections).map(vs => use(vs.table)).filter(tb => use(tb.tableId)))];\n      } else {\n        return use(this._grist.docModel.visibleTables.getObservable()).filter(tb => use(tb.tableId));\n      }\n    });\n\n    // Column filter - only show discussions in this column (depending on the mode).\n    const columnFilter = Computed.create(owner, (use) => {\n      if (use(this._currentPageKo)) {\n        const fieldSet = new Set<number>();\n        use(viewSections).forEach((vs) => {\n          use(use(vs.viewFields).getObservable()).forEach(vf => fieldSet.add(use(vf.colRef)));\n        });\n        return (ds: CellRec) => {\n          return fieldSet.has(use(ds.colRef));\n        };\n      } else {\n        return () => true;\n      }\n    });\n\n    // Create a row filter based on user filters (rows that user actually see).\n    const watcher = RowWatcher.create(owner);\n    watcher.rowFilter.set(() => true);\n    // Now watch for viewSections (when they are changed, and then update watcher instance).\n    // Unfortunately, we can't use _viewSections here because GrainJS has a different\n    // behavior than ko when one observable changes during the evaluation. Here viewInstance\n    // will probably be set during computations. To fix this we need a ko.observable here.\n    const sources = owner.autoDispose(ko.computed(() => {\n      if (this._currentPageKo()) {\n        const list: RowSource[] = [];\n        for (const vs of this._grist.viewModel.viewSections().all()) {\n          const viewInstance = vs.viewInstance();\n          if (viewInstance) {\n            list.push(viewInstance.rowSource);\n          }\n        }\n        return list;\n      }\n      return null;\n    }));\n    sources.peek()?.forEach(source => watcher.subscribeTo(source));\n    owner.autoDispose(sources.subscribe((list) => {\n      bundleChanges(() => {\n        watcher.clear();\n        if (list) {\n          list.forEach(source => watcher.subscribeTo(source));\n        } else {\n          // Page\n          watcher.rowFilter.set(() => true);\n        }\n      });\n    }));\n\n    const rowFilter = watcher.rowFilter;\n\n    const discussionFilter = Computed.create(owner, (use) => {\n      const filterRow = use(rowFilter);\n      const filterCol = use(columnFilter);\n      const showAll = use(this._resolved);\n      const showOnlyMine = use(this._onlyMine);\n      const currentUser = use(this._grist.currentUser)?.ref ?? \"\";\n      const userFilter = (d: CellRec) => {\n        const replies = use(use(d.children).getObservable());\n        return use(d.userRef) === currentUser ||\n          (use(d.mentions) ?? []).includes(currentUser) ||\n          replies.some(c => use(c.userRef) === currentUser || (use(c.mentions) ?? []).includes(currentUser));\n      };\n      return (ds: CellRec) =>\n        !use(ds.hidden) && // filter by ACL\n        filterRow(use(ds.rowId)) &&\n        filterCol(ds) &&\n        (showOnlyMine ? userFilter(ds) : true) &&\n        (showAll || !use(ds.resolved))\n      ;\n    });\n    const allDiscussions = Computed.create(owner, (use) => {\n      const list = flatMap(flatMap(use(tables).map((tb) => {\n        const columns = use(use(tb.columns).getObservable());\n        const dList = columns.map(col => use(use(col.cells).getObservable())\n          .filter(c => use(c.root) && use(c.type) === CellInfoType.COMMENT));\n        return dList;\n      })));\n      return list;\n    });\n    const discussions = Computed.create(owner, (use) => {\n      const all = use(allDiscussions);\n      const filter = use(discussionFilter);\n      return all.filter(filter);\n    });\n    const topic = DiscussionModelImpl.create(owner, this._grist, discussions);\n    owner.autoDispose(discussions.addListener(d => this._length.set(d.length)));\n    this._length.set(discussions.get().length);\n    // Selector for page all whole document.\n    return cssDiscussionPanel(\n      dom.autoDispose(owner),\n      testId(\"panel\"),\n      // Discussion list - actually we are showing first comment of each discussion.\n      cssDiscussionPanelList(\n        dom.create(MultiThreads, {\n          readonly: this._grist.isReadonly,\n          gristDoc: this._grist,\n          cell: topic,\n        }),\n      ),\n      domOnCustom(Comment.SELECT, (ds: CellRec) => {\n        this._navigate(ds).catch(() => {});\n      }),\n    );\n  }\n\n  public buildMenu(): DomContents {\n    return cssPanelHeader(\n      dom(\"span\", dom.text(use => t(\"{{count}} comments\", { count: use(this._length) })), testId(\"comment-count\")),\n      cssIconButtonMenu(\n        icon(\"Dots\"),\n        testId(\"panel-menu\"),\n        menu(() => {\n          return [cssDropdownMenu(\n            labeledSquareCheckbox(this._onlyMine, t(\"Only my threads\"), testId(\"my-threads\")),\n            labeledSquareCheckbox(this._currentPage, t(\"Only current page\"), testId(\"only-page\")),\n            labeledSquareCheckbox(this._resolved, t(\"Show resolved comments\"), testId(\"show-resolved\")),\n          )];\n        }, { placement: \"bottom-start\" }),\n        dom.on(\"click\", stopPropagation),\n      ),\n    );\n  }\n\n  /**\n   * Navigates to cell on current page or opens discussion next to the panel.\n   */\n  private async _navigate(discussion: CellRec) {\n    // Try to find the cell on the current page.\n    const rowId = discussion.rowId.peek();\n    function findSection(viewSections: ViewSectionRec[]) {\n      const section = viewSections\n        .filter(s => s.tableRef.peek() === discussion.tableRef.peek())\n        .find(s => s.viewFields.peek().all().find(f => f.colRef.peek() === discussion.colRef.peek()));\n      const sectionId = section?.getRowId();\n      const fieldIndex = section?.viewFields.peek().all()\n        .findIndex(f => f.colRef.peek() === discussion.colRef.peek()) ?? -1;\n      if (fieldIndex !== -1) {\n        return { sectionId, fieldIndex };\n      }\n      return null;\n    }\n    let sectionId = 0;\n    let fieldIndex = -1;\n    const section = findSection(this._grist.viewModel.viewSections.peek().all());\n    // If we haven't found the cell on the current page, try other pages.\n    if (!section) {\n      for (const pageId of this._grist.docModel.pages.getAllRows()) {\n        const page = this._grist.docModel.pages.getRowModel(pageId);\n        const vss = page.view.peek().viewSections.peek().all();\n        const result = findSection(vss);\n        if (result) {\n          sectionId = result.sectionId!;\n          fieldIndex = result.fieldIndex;\n          break;\n        }\n      }\n    } else {\n      sectionId = section.sectionId!;\n      fieldIndex = section.fieldIndex;\n    }\n\n    if (!sectionId) {\n      return;\n    }\n\n    const currentPosition = this._grist.cursorPosition.get();\n\n    if (currentPosition?.sectionId === sectionId &&\n      currentPosition.fieldIndex === fieldIndex &&\n      currentPosition.rowId === rowId) {\n      return;\n    }\n\n    // Navigate cursor to the cell.\n    const ok = await this._grist.recursiveMoveToCursorPos({\n      rowId,\n      sectionId,\n      fieldIndex,\n    }, true);\n    if (!ok) {\n      return;\n    }\n  }\n}\n\nfunction buildAvatar(user: FullUser | null, ...args: DomElementArg[]) {\n  return cssAvatar(user, \"small\", ...args);\n}\n\nfunction buildNick(user: { name: string } | null, ...args: DomArg<HTMLElement>[]) {\n  return cssNick(user?.name ?? \"Anonymous\", ...args);\n}\n\n// // Helper binding function to handle click outside an element. Takes into account floating menus.\n// function onClickOutside(content: HTMLElement, click: () => void) {\n//   const onClick = (evt: MouseEvent) => {\n//     const target: Node | null = evt.target as Node;\n//     if (target && !content.contains(target)) {\n//       // Check if any parent of target has class grist-floating-menu, if so, don't close.\n//       if (target.parentElement?.closest(\".grist-floating-menu\")) {\n//         return;\n//       }\n//       click();\n//     }\n//   };\n//   return dom.onElem(document, 'click', onClick, {useCapture: true});\n// }\n\n// Display timestamp as a relative time ago using moment.js\nfunction formatTime(timeStampSec: number) {\n  const time = moment(Math.floor(timeStampSec * 1000));\n  const now = moment();\n  const diff = now.diff(time, \"days\");\n  if (diff < 1) {\n    return time.fromNow();\n  }\n  return time.format(\"MMM D, YYYY\");\n}\n\nfunction commentAuthor(grist: GristDoc, userRef?: string, userName?: string): FullUser | null {\n  if (!userRef) {\n    const loggedInUser = grist.app.topAppModel.appObs.get()?.currentValidUser;\n    if (!loggedInUser) {\n      return {\n        name: userName || \"\",\n        ref: userRef || \"\",\n        email: \"\",\n        id: 0,\n      };\n    }\n    if (!loggedInUser.ref) {\n      throw new Error(\"User reference is not set\");\n    }\n    return loggedInUser;\n  } else {\n    if (typeof userName !== \"string\") {\n      return null;\n    }\n    return {\n      name: userName,\n      ref: userRef || \"\",\n      email: \"\",\n      id: 0,\n    };\n  }\n}\n\nfunction stopPropagation(ev: Event) {\n  ev.stopPropagation();\n}\n\nfunction withStop(handler: () => any) {\n  return (ev: Event) => {\n    stopPropagation(ev);\n    handler();\n  };\n}\n\nconst cssAvatar = styled(createUserImage, `\n  flex: none;\n  margin-top: 2px;\n`);\n\nconst cssCommentPopup = styled(\"div\", `\n  width: 350px;\n  max-width: min(350px, calc(100vw - 10px));\n  max-height: min(600px, calc(100vh - 10px));\n`);\n\nconst cssDiscussionPanel = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex: 1;\n  overflow: auto;\n  padding: 8px;\n`);\n\nconst cssDiscussionPanelList = styled(\"div\", `\n  margin-bottom: 0px;\n`);\n\nconst cssCommonPadding = styled(\"div\", `\n  padding: 16px;\n`);\n\nconst cssPanelHeader = styled(\"div\", `\n  display: flex;\n  flex: 1;\n  align-items: center;\n  justify-content: space-between;\n`);\n\nconst cssDropdownMenu = styled(\"div\", `\n  display: flex;\n  padding: 12px;\n  padding-left: 16px;\n  padding-right: 16px;\n  gap: 10px;\n  flex-direction: column;\n`);\n\nconst cssReplyBox = styled(cssCommonPadding, `\n  border-top: 1px solid ${theme.commentsPopupBorder};\n`);\n\nconst cssCommentEntry = styled(\"div\", `\n  display: grid;\n  &-comment {\n    grid-template-columns: 1fr auto;\n    grid-template-rows: 1fr;\n    gap: 8px;\n    grid-template-areas: \"text buttons\";\n  }\n  &-start, &-reply {\n    grid-template-rows: 1fr auto;\n    grid-template-columns: 1fr;\n    gap: 8px;\n    grid-template-areas: \"text\" \"buttons\";\n  }\n\n`);\n\nconst cssCommentEntryText = styled(\"div\", `\n  grid-area: text;\n`);\n\nconst cssCommentEntryButtons = styled(\"div\", `\n  grid-area: buttons;\n  display: flex;\n  align-items: flex-start;\n  gap: 8px;\n`);\n\nconst cssContentEditable = styled(\"div\", `\n  min-height: 5em;\n  border-radius: 3px;\n  padding: 4px 6px;\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  border: 1px solid ${theme.inputBorder};\n  outline: none;\n  width: 100%;\n  resize: none;\n  max-height: 10em;\n  overflow: auto;\n  &-comment, &-reply {\n    min-height: 28px;\n    height: 28px;\n  }\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n`);\n\nconst cssTopic = styled(\"div\", `\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  border: 1px solid ${theme.commentsPopupBorder};\n  border-radius: 4px;\n  background-color: ${theme.commentsPopupBodyBg};\n  box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.2);\n  z-index: 100;\n  outline: none;\n  max-height: inherit;\n  &-disabled {\n    background-color: ${theme.commentsPanelResolvedTopicBg}\n  }\n  &-panel {\n    width: unset;\n    box-shadow: none;\n    border-radius: 0px;\n    background: unset;\n    border: 0px;\n  }\n`);\n\nconst cssDiscussionWrapper = styled(\"div\", `\n  border-bottom: 1px solid ${theme.commentsPopupBorder};\n  max-height: inherit;\n  &:last-child {\n    border-bottom: none;\n  }\n  .${cssTopic.className}-panel & {\n    border: 1px solid ${theme.commentsPanelTopicBorder};\n    border-radius: 4px;\n    background-color: ${theme.commentsPanelTopicBg};\n    margin-bottom: 4px;\n    overflow: hidden;\n  }\n`);\n\nconst cssDiscussion = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  padding: 16px;\n  max-height: inherit;\n  &-resolved {\n    background-color: ${theme.commentsPanelResolvedTopicBg};\n    cursor: pointer;\n  }\n  &-resolved * {\n    color: ${theme.lightText} !important;\n  }\n`);\n\nconst cssCommentCensored = styled(\"div\", `\n  color: ${theme.text};\n  margin-top: 4px;\n`);\n\nconst cssRenderedCommentMarkdown = styled(cssMarkdown, `\n  color: ${theme.text};\n  margin-top: 4px;\n  white-space: normal;\n  word-break: break-word;\n`);\n\nconst cssCommentList = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  overflow: auto;\n`);\n\nconst cssColumns = styled(\"div\", `\n  display: flex;\n  align-items: flex-start;\n  gap: 8px;\n`);\n\nconst cssCommentReplyWrapper = styled(\"div\", `\n  margin-top: 16px;\n`);\n\nconst cssComment = styled(\"div\", `\n  border-bottom: 1px solid ${theme.commentsPopupBorder};\n  .${cssCommentList.className} &:last-child {\n    border-bottom: 0px;\n  }\n`);\n\nconst cssReplyList = styled(\"div\", `\n  margin-left: 8px;\n  & > div + div {\n    margin-top: 20px;\n  }\n`);\n\nconst cssCommentHeader = styled(\"div\", `\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n  overflow: hidden;\n`);\n\nconst cssCommentBodyHeader = styled(\"div\", `\n  display: flex;\n  align-items: baseline;\n  overflow: hidden;\n`);\n\nconst cssCommentBodyText = styled(\"div\", `\n  flex: 1;\n  min-width: 0px;\n`);\n\nconst cssIconButton = styled(\"div\", `\n  flex: none;\n  margin: 0 4px 0 auto;\n  height: 24px;\n  width: 24px;\n  padding: 4px;\n  line-height: 0px;\n  border-radius: 3px;\n  cursor: pointer;\n  --icon-color: ${theme.controlSecondaryFg};\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.controlSecondaryHoverBg};\n  }\n`);\n\nconst cssIconButtonMenu = styled(\"div\", `\n  flex: none;\n  margin: 0 4px 0 auto;\n  height: 24px;\n  width: 24px;\n  padding: 4px;\n  line-height: 0px;\n  border-radius: 4px;\n  cursor: pointer;\n  --icon-color: ${theme.controlFg};\n  &.weasel-popup-open {\n    background-color: ${theme.iconButtonPrimaryBg};\n    --icon-color: ${theme.iconButtonFg};\n  }\n  &:hover {\n    background-color: ${theme.iconButtonPrimaryHoverBg};\n    --icon-color: ${theme.iconButtonFg};\n  }\n`);\n\nconst cssReplyButton = styled(textButton, `\n  align-self: flex-start;\n  display: flex;\n  gap: 4px;\n  margin-top: 16px;\n`);\n\nconst cssTime = styled(\"div\", `\n  color: ${theme.lightText};\n  font-size: ${vars.smallFontSize};\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  letter-spacing: 0.02em;\n  line-height: 16px;\n  overflow: hidden;\n`);\n\nconst cssNick = styled(\"div\", `\n  font-weight: 600;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  overflow: hidden;\n  color: ${theme.commentsUserNameFg};\n  &-small {\n    font-size: 12px;\n  }\n`);\n\nconst cssResolvedBlock = styled(\"div\", `\n  margin-top: 5px;\n  --icon-color: ${theme.text};\n`);\n\nconst cssResolvedText = styled(\"span\", `\n  color: ${theme.text};\n  font-size: ${vars.smallFontSize};\n  margin-left: 5px;\n`);\n\nconst cssTruncate = styled(\"div\", `\n  position: absolute;\n  background: white;\n  inset: 0;\n  height: 2rem;\n  opacity: 57%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-weight: 600;\n`);\n"
  },
  {
    "path": "app/client/widgets/EditorButtons.ts",
    "content": "import { colors, isNarrowScreen, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { IEditorCommandGroup } from \"app/client/widgets/NewBaseEditor\";\n\nimport { dom, styled } from \"grainjs\";\n\n/**\n * Detects if the device likely needs mobile editor buttons.\n *\n * Inspired by:\n * https://stackoverflow.com/questions/4817029/whats-an-optimal-or-efficient-way-to-detect-a-touch-screen-device-using-j\n * avas\n */\nfunction needsMobileButtons(): boolean {\n  // Check for touch support\n  const hasTouchSupport = Boolean(\"ontouchstart\" in window ||\n    (navigator.maxTouchPoints && navigator.maxTouchPoints > 0));\n\n  // Check if primary pointer is coarse (finger/stylus) vs fine (mouse)\n  const primaryPointerIsCoarse = window.matchMedia(\"(pointer: coarse)\").matches;\n\n  // Show mobile buttons if device has narrow screen OR (touch AND coarse pointer)\n  return isNarrowScreen() || (hasTouchSupport && primaryPointerIsCoarse);\n}\n\n/**\n * Creates Save/Cancel icon buttons to show next to the cell editor.\n */\nexport function createMobileButtons(commands: IEditorCommandGroup) {\n  return needsMobileButtons() ? [\n    cssCancelBtn(cssIconWrap(cssFinishIcon(\"CrossSmall\")), dom.on(\"mousedown\", commands.fieldEditCancel)),\n    cssSaveBtn(cssIconWrap(cssFinishIcon(\"Tick\")), dom.on(\"mousedown\", commands.fieldEditSaveHere)),\n  ] : null;\n}\n\nexport function getButtonMargins() {\n  return needsMobileButtons() ? { left: 20, right: 20, top: 0, bottom: 0 } : undefined;\n}\n\nconst cssFinishBtn = styled(\"div\", `\n  height: 40px;\n  width: 40px;\n  padding: 8px;\n  position: absolute;\n  top: -8px;\n  --icon-color: ${theme.controlPrimaryFg};\n`);\n\nconst cssCancelBtn = styled(cssFinishBtn, `\n  --icon-background-color: ${colors.error};\n  left: -40px;\n`);\n\nconst cssSaveBtn = styled(cssFinishBtn, `\n  --icon-background-color: ${theme.controlPrimaryBg};\n  right: -40px;\n`);\n\nconst cssIconWrap = styled(\"div\", `\n  border-radius: 20px;\n  background-color: var(--icon-background-color);\n  height: 24px;\n  width: 24px;\n`);\n\nconst cssFinishIcon = styled(icon, `\n  height: 24px;\n  width: 24px;\n`);\n"
  },
  {
    "path": "app/client/widgets/EditorPlacement.ts",
    "content": "import { Disposable, dom, Emitter } from \"grainjs\";\n\nexport interface ISize {\n  width: number;\n  height: number;\n}\n\ninterface ISizeOpts {\n  // Don't reposition the editor as part of the size calculation.\n  calcOnly?: boolean;\n}\n\nexport interface IMargins {\n  top: number;\n  bottom: number;\n  left: number;\n  right: number;\n}\n\nexport type IRect = ISize & IMargins;\n\n// edgeMargin is how many pixels to leave before the edge of the browser window by default.\n// This is added to margins that may be passed into the constructor.\nconst edgeMargin = 12;\n\n// How large the editor can get when it needs to shift to the left or upwards.\nconst maxShiftWidth = 560;\nconst maxShiftHeight = 400;\n\n/**\n * This class implements the placement and sizing of the cell editor, such as TextEditor and\n * FormulaEditor. These try to match the size and position of the cell being edited, expanding\n * when needed.\n *\n * This class also takes care of attaching the editor DOM and destroying it on disposal.\n */\nexport class EditorPlacement extends Disposable {\n  public readonly onReposition = this.autoDispose(new Emitter());\n\n  private _editorRoot: HTMLElement;\n  private _maxRect: IRect;\n  private _cellRect: IRect;\n  private _margins: IMargins;\n\n  // - editorDom is the DOM to attach. It gets destroyed when EditorPlacement is disposed.\n  // - cellElem is the cell being mirrored by the editor; the editor generally expands to match\n  //   the size of the cell.\n  // - margins may be given to add to the default edgeMargin, to increase distance to edges of the window.\n  constructor(editorDom: HTMLElement, private _cellElem: Element, options: { margins?: IMargins } = {}) {\n    super();\n\n    this._margins = {\n      top: (options.margins?.top || 0) + edgeMargin,\n      bottom: (options.margins?.bottom || 0) + edgeMargin,\n      left: (options.margins?.left || 0) + edgeMargin,\n      right: (options.margins?.right || 0) + edgeMargin,\n    };\n\n    // Initialize _maxRect and _cellRect used for sizing the editor. We don't re-measure them\n    // while typing (e.g. OK to scroll the view away from the editor), but we re-measure them on\n    // window resize, which is only a normal occurrence on Android when virtual keyboard is shown.\n    this._maxRect = document.body.getBoundingClientRect();\n    this._cellRect = rectWithoutBorders(this._cellElem);\n\n    this.autoDispose(dom.onElem(window, \"resize\", () => {\n      this._maxRect = document.body.getBoundingClientRect();\n      this._cellRect = rectWithoutBorders(this._cellElem);\n      this.onReposition.emit();\n    }));\n\n    const editorRoot = this._editorRoot = dom(\"div.cell_editor\", editorDom);\n    // To hide from the user the incorrectly-sized element, we set visibility to hidden, and\n    // reset it in _calcEditorSize() as soon as we have the sizes.\n    editorRoot.style.visibility = \"hidden\";\n\n    document.body.appendChild(editorRoot);\n    this.onDispose(() => {\n      // When the editor is destroyed, destroy and remove its DOM.\n      dom.domDispose(editorRoot);\n      editorRoot.remove();\n    });\n  }\n\n  /**\n   * Calculate the size of the full editor and shift the editor if needed to give it more space.\n   * The position and size are applied to the editor unless {calcOnly: true} option is given.\n   */\n  public calcSize(desiredSize: ISize, options: ISizeOpts = {}): ISize {\n    const maxRect = this._maxRect;\n    const margin = this._margins;\n\n    const noShiftMaxWidth = maxRect.right - margin.right - this._cellRect.left;\n    const maxWidth = Math.min(maxRect.width - margin.left - margin.right, Math.max(maxShiftWidth, noShiftMaxWidth));\n    const width = Math.min(maxWidth, Math.max(this._cellRect.width, desiredSize.width));\n    const left = Math.max(margin.left,\n      Math.min(this._cellRect.left - maxRect.left, maxRect.width - margin.right - width));\n\n    const noShiftMaxHeight = maxRect.bottom - margin.bottom - this._cellRect.top;\n    const maxHeight = Math.min(maxRect.height - margin.top - margin.bottom, Math.max(maxShiftHeight, noShiftMaxHeight));\n    const height = Math.min(maxHeight, Math.max(this._cellRect.height, desiredSize.height));\n    const top = Math.max(margin.top,\n      Math.min(this._cellRect.top - maxRect.top, maxRect.height - margin.bottom - height));\n\n    // To hide from the user the split second before things are sized correctly, we set visibility\n    // to hidden until we can get the sizes. As soon as sizes are available, restore visibility.\n    if (!options.calcOnly) {\n      Object.assign(this._editorRoot.style, {\n        \"visibility\": \"visible\",\n        \"left\": left + \"px\",\n        \"top\": top + \"px\",\n        // Set the width (but not the height) of the outer container explicitly to accommodate the\n        // particular setup where a formula may include error details below -- these should\n        // stretch to the calculated width (so need an explicit value), but may be dynamic in\n        // height. (This feels hacky, but solves the problem.)\n        \"width\": width + \"px\",\n        \"max-height\": maxHeight + \"px\",\n      });\n    }\n\n    return { width, height };\n  }\n\n  /**\n   * Calculate the size for the editable part of the editor, given in elem. This assumes that the\n   * size of the full editor differs from the editable part only in constant padding. The full\n   * editor may be shifted as part of this call.\n   */\n  public calcSizeWithPadding(elem: HTMLElement, desiredElemSize: ISize, options: ISizeOpts = {}): ISize {\n    const rootRect = this._editorRoot.getBoundingClientRect();\n    const elemRect = elem.getBoundingClientRect();\n    const heightDelta = rootRect.height - elemRect.height;\n    const widthDelta = rootRect.width - elemRect.width;\n    const { width, height } = this.calcSize({\n      width: desiredElemSize.width + widthDelta,\n      height: desiredElemSize.height + heightDelta,\n    }, options);\n    return {\n      width: width - widthDelta,\n      height: height - heightDelta,\n    };\n  }\n}\n\n// Get the bounding rect of elem excluding borders. This allows the editor to match cellElem more\n// closely which is more visible in case of DetailView.\nfunction rectWithoutBorders(elem: Element): IRect {\n  const rect = elem.getBoundingClientRect();\n  const style = getComputedStyle(elem, null);\n  const bTop = parseFloat(style.getPropertyValue(\"border-top-width\"));\n  const bRight = parseFloat(style.getPropertyValue(\"border-right-width\"));\n  const bBottom = parseFloat(style.getPropertyValue(\"border-bottom-width\"));\n  const bLeft = parseFloat(style.getPropertyValue(\"border-left-width\"));\n  return {\n    width: rect.width - bLeft - bRight,\n    height: rect.height - bTop - bBottom,\n    top: rect.top + bTop,\n    bottom: rect.bottom - bBottom,\n    left: rect.left + bLeft,\n    right: rect.right - bRight,\n  };\n}\n"
  },
  {
    "path": "app/client/widgets/EditorTooltip.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { ITooltipControl, showTooltip, tooltipCloseButton } from \"app/client/ui/tooltips\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\n\nimport { dom, styled } from \"grainjs\";\n\nconst t = makeT(\"EditorTooltip\");\n\nexport function showTooltipToCreateFormula(editorDom: HTMLElement, convert: () => void) {\n  function buildTooltip(ctl: ITooltipControl) {\n    return cssConvertTooltip(icon(\"Convert\"),\n      cssLink(t(\"Convert column to formula\"),\n        dom.on(\"mousedown\", (ev) => { ev.preventDefault(); convert(); }),\n        testId(\"editor-tooltip-convert\"),\n      ),\n      tooltipCloseButton(ctl),\n    );\n  }\n  const offerCtl = showTooltip(editorDom, buildTooltip, { key: \"col-to-formula\" });\n\n  dom.onDisposeElem(editorDom, offerCtl.close);\n  const lis = dom.onElem(editorDom, \"keydown\", () => {\n    lis.dispose();\n    offerCtl.close();\n  });\n}\n\nconst cssConvertTooltip = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  --icon-color: ${theme.controlFg};\n\n  & > .${cssLink.className} {\n    margin-left: 8px;\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/ErrorDom.ts",
    "content": "import { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { getObjCode } from \"app/common/gristTypes\";\nimport { formatUnknown } from \"app/common/ValueFormatter\";\n\nimport { dom } from \"grainjs\";\n\nexport function buildErrorDom(row: DataRowModel, field: ViewFieldRec) {\n  const value = row.cells[field.colId.peek()];\n  if (value === undefined) { return null; }   // Work around JS errors during field removal.\n  const options = field.widgetOptionsJson;\n  // The \"invalid\" class sets the pink background, as long as the error text is non-empty.\n  return dom(\"div.field_clip.invalid\",\n    // Sets CSS class field-error-P, field-error-U, etc.\n    dom.clsPrefix(\"field-error-\", use => getObjCode(use(value)) || \"\"),\n    dom.style(\"text-align\", options.prop(\"alignment\")),\n    dom.cls(\"text_wrapping\", use => Boolean(use(options.prop(\"wrap\")))),\n    dom.text(use => formatUnknown(value ? use(value) : \"???\")),\n  );\n}\n"
  },
  {
    "path": "app/client/widgets/FieldBuilder.css",
    "content": ".transform_editor {\n  min-height: 28px;\n  margin: 8px 16px;\n  padding: 5px 6px;\n  background-color: var(--grist-theme-ace-editor-bg, white);\n  border: 1px solid var(--grist-theme-input-border, #D9D9D9);\n  border-radius: 3px;\n}\n\n.transform_menu {\n  padding-bottom: 8px;\n}\n\n.fieldbuilder_settings {\n  background-color: var(--grist-theme-right-panel-field-settings-bg, #e8e8e8);\n  margin: 1rem -1px -4px -1px;\n  padding-bottom: 1px;\n}\n\n.fieldbuilder_settings_header {\n  height: 2rem;\n  margin-top: 0px;\n  margin-bottom: 0px;\n}\n\n.fieldbuilder_settings_button {\n  display: inline-block;\n  float: right;\n  padding: 0 1rem;\n  border-radius: 5px;\n  background-color: var(--grist-theme-right-panel-field-settings-button-bg, lightgrey);\n}\n\n.field-comment-indicator {\n  display: none;\n}\n\n.field-with-comments .field-comment-indicator {\n  display: block;\n  position: absolute;\n  top: 0;\n  right: 0;\n  width: 0;\n  height: 0;\n  border-top: 11px solid var(--grist-color-orange);\n  border-left: 11px solid transparent;\n  cursor: pointer;\n}\n"
  },
  {
    "path": "app/client/widgets/FieldBuilder.ts",
    "content": "import { ColumnTransform } from \"app/client/components/ColumnTransform\";\nimport * as commands from \"app/client/components/commands\";\nimport { Cursor } from \"app/client/components/Cursor\";\nimport { FormulaTransform } from \"app/client/components/FormulaTransform\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { addColTypeSuffix, guessWidgetOptionsSync, inferColTypeSuffix } from \"app/client/components/TypeConversion\";\nimport { TypeTransform } from \"app/client/components/TypeTransform\";\nimport { UnsavedChange } from \"app/client/components/UnsavedChanges\";\nimport dom from \"app/client/lib/dom\";\nimport { KoArray } from \"app/client/lib/koArray\";\nimport * as kd from \"app/client/lib/koDom\";\nimport * as kf from \"app/client/lib/koForm\";\nimport * as koUtil from \"app/client/lib/koUtil\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { reportError } from \"app/client/models/AppModel\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ColumnRec, DocModel, ViewFieldRec } from \"app/client/models/DocModel\";\nimport { SaveableObjObservable, setSaveValue } from \"app/client/models/modelUtil\";\nimport { CombinedStyle, Style } from \"app/client/models/Styles\";\nimport { FieldSettingsMenu } from \"app/client/ui/FieldMenus\";\nimport { cssBlockedCursor, cssLabel, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { buttonSelect, cssButtonSelect } from \"app/client/ui2018/buttonSelect\";\nimport { IOptionFull, menu, select } from \"app/client/ui2018/menus\";\nimport { DiffBox } from \"app/client/widgets/DiffBox\";\nimport { CommentPopup, DiscussionModelImpl } from \"app/client/widgets/DiscussionEditor\";\nimport { buildErrorDom } from \"app/client/widgets/ErrorDom\";\nimport { FieldEditor, saveWithoutEditor } from \"app/client/widgets/FieldEditor\";\nimport { FloatingEditor } from \"app/client/widgets/FloatingEditor\";\nimport { openFormulaEditor } from \"app/client/widgets/FormulaEditor\";\nimport { CommentWithMentions } from \"app/client/widgets/MentionTextBox\";\nimport { NewAbstractWidget } from \"app/client/widgets/NewAbstractWidget\";\nimport { IEditorConstructor } from \"app/client/widgets/NewBaseEditor\";\nimport * as UserType from \"app/client/widgets/UserType\";\nimport * as UserTypeImpl from \"app/client/widgets/UserTypeImpl\";\nimport { getReferencedTableId, isFullReferencingType } from \"app/common/gristTypes\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { WidgetType } from \"app/common/widgetTypes\";\nimport { CellValue } from \"app/plugin/GristData\";\n\nimport {\n  bundleChanges,\n  Computed,\n  Disposable,\n  dom as grainjsDom,\n  fromKo,\n  makeTestId,\n  MultiHolder,\n  Observable,\n  styled,\n  toKo,\n} from \"grainjs\";\nimport * as ko from \"knockout\";\nimport isEqual from \"lodash/isEqual\";\nimport * as _ from \"underscore\";\n\nconst testId = makeTestId(\"test-fbuilder-\");\nconst t = makeT(\"FieldBuilder\");\n\n// Creates a FieldBuilder object for each field in viewFields\nexport function createAllFieldWidgets(gristDoc: GristDoc, viewFields: ko.Computed<KoArray<ViewFieldRec>>,\n  cursor: Cursor, options: { isPreview?: boolean } = {}) {\n  // TODO: Handle disposal from the map when fields are removed.\n  return viewFields().map(function(field) {\n    return new FieldBuilder(gristDoc, field, cursor, options);\n  }).setAutoDisposeValues();\n}\n\n/**\n * Returns the appropriate object from UserType.typeDefs, defaulting to Text for unknown types.\n */\nfunction getTypeDefinition(type: string | false) {\n  if (!type) { return UserType.typeDefs.Text; }\n  return UserType.typeDefs[type] || UserType.typeDefs.Text;\n}\n\ntype ComputedStyle = { style?: Style; error?: true } | null | undefined;\n\n/**\n * Builds a font option computed property.\n */\nfunction buildFontOptions(\n  builder: FieldBuilder,\n  computedRule: ko.Computed<ComputedStyle>,\n  optionName: keyof Style) {\n  return koUtil.withKoUtils(ko.computed(() => {\n    if (builder.isDisposed()) { return false; }\n    const style = computedRule()?.style;\n    const styleFlag = style?.[optionName] || builder.field[optionName]();\n    return styleFlag;\n  })).onlyNotifyUnequal();\n}\n\nexport interface BuildEditorOptions {\n  init?: string;\n  state?: unknown;\n  event?: Event\n}\n\n/**\n * Creates an instance of FieldBuilder.  Used to create all column configuration DOMs, cell DOMs,\n * and cell editor DOMs for all Grist Types.\n * @param {Object} field - The field for which the DOMs are to be created.\n * @param {Object} cursor - The cursor object, used to get the cursor position while saving values.\n */\nexport class FieldBuilder extends Disposable {\n  public columnTransform: ColumnTransform | null;\n  public readonly origColumn: ColumnRec;\n  public readonly options: SaveableObjObservable<any>;\n  public readonly widget: ko.PureComputed<any>;\n  public readonly isCallPending: ko.Observable<boolean>;\n  public readonly widgetImpl: ko.Computed<NewAbstractWidget>;\n  public readonly diffImpl: NewAbstractWidget;\n\n  private readonly _availableTypes: Computed<IOptionFull<string>[]>;\n  private readonly _readOnlyPureType: ko.PureComputed<string>;\n  private readonly _isRightType: ko.PureComputed<(value: CellValue, options?: any) => boolean>;\n  private readonly _refTableId: ko.Computed<string | null>;\n  private readonly _isRef: ko.Computed<boolean>;\n  private readonly _rowMap: Map<DataRowModel, Element>;\n  private readonly _isTransformingFormula: ko.Computed<boolean>;\n  private readonly _isTransformingType: ko.Computed<boolean>;\n  private readonly _widgetCons: ko.Computed<{ create: (...args: any[]) => NewAbstractWidget }>;\n  private readonly _docModel: DocModel;\n  private readonly _readonly: Computed<boolean>;\n  private readonly _isForm: ko.Computed<boolean>;\n  private readonly _showRefConfigPopup: ko.Observable<boolean>;\n  private readonly _isEditorActive = Observable.create(this, false);\n\n  public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,\n    private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {\n    super();\n\n    this._docModel = gristDoc.docModel;\n    this.origColumn = field.origCol();\n    this.options = field.widgetOptionsJson;\n\n    this._readOnlyPureType = ko.pureComputed(() => this.field.column().pureType());\n\n    this._readonly = Computed.create(this, use =>\n      use(gristDoc.isReadonly) || use(field.disableEditData) || Boolean(this._options.isPreview));\n\n    this._isForm = this.autoDispose(ko.computed(() => {\n      return this.field.viewSection().widgetType() === WidgetType.Form;\n    }));\n\n    // Observable with a list of available types.\n    this._availableTypes = Computed.create(this, (use) => {\n      const isFormula = use(this.origColumn.isFormula);\n      const types: IOptionFull<string>[] = [];\n      _.each(UserType.typeDefs, (def: any, key: string | number) => {\n        const o: IOptionFull<string> = {\n          value: key as string,\n          label: def.label,\n          icon: def.icon,\n        };\n        if (key === \"Any\") {\n          // User is unable to select the Any type in non-formula columns.\n          o.disabled = !isFormula;\n        }\n        types.push(o);\n      });\n      return types;\n    });\n\n    // Observable which evaluates to a *function* that decides if a value is valid.\n    this._isRightType = ko.pureComputed<(value: CellValue, options?: any) => boolean>(() => {\n      return gristTypes.isRightType(this._readOnlyPureType()) || _.constant(false);\n    });\n\n    // Returns a boolean indicating whether the column is type Reference or ReferenceList.\n    this._isRef = this.autoDispose(ko.computed(() => {\n      const type = this.field.column().type();\n      return type !== \"Attachments\" && isFullReferencingType(type);\n    }));\n\n    // Gives the table ID to which the reference points.\n    this._refTableId = this.autoDispose(ko.computed({\n      read: () => getReferencedTableId(this.field.column().type()),\n      write: (val) => {\n        const type = this.field.column().type();\n        if (type.startsWith(\"Ref:\")) {\n          this._setType(`Ref:${val}`);\n        } else {\n          this._setType(`RefList:${val}`);\n        }\n      },\n    }));\n\n    this.widget = ko.pureComputed(() => this.field.widget());\n\n    // Whether there is a pending call that transforms column.\n    this.isCallPending = ko.observable(false);\n\n    // Maintains an instance of the transform object if the field is currently being transformed,\n    // and null if not. Gets disposed along with the transform menu dom.\n    this.columnTransform = null;\n\n    // Returns a boolean indicating whether a formula transform is in progress.\n    this._isTransformingFormula = this.autoDispose(ko.computed(() => {\n      return this.field.column().isTransforming() && this.columnTransform instanceof FormulaTransform;\n    }));\n    // Returns a boolean indicating whether a type transform is in progress.\n    this._isTransformingType = this.autoDispose(ko.computed(() => {\n      return (this.field.column().isTransforming() || this.isCallPending()) &&\n        (this.columnTransform instanceof TypeTransform);\n    }));\n\n    // Map from rowModel to cell dom for the field to which this fieldBuilder applies.\n    this._rowMap = new Map();\n\n    // Returns the constructor for the widget, and only notifies subscribers on changes.\n    this._widgetCons = this.autoDispose(koUtil.withKoUtils(ko.computed(() => {\n      if (this._isForm()) {\n        return UserTypeImpl.getFormWidgetConstructor(this.options().widget, this._readOnlyPureType());\n      } else {\n        return UserTypeImpl.getWidgetConstructor(this.options().widget, this._readOnlyPureType());\n      }\n    })).onlyNotifyUnequal());\n\n    // Computed builder for the widget.\n    this.widgetImpl = this.autoDispose(koUtil.computedBuilder(() => {\n      const cons = this._widgetCons();\n      // Must subscribe to `colId` so that field.colId is rechecked on transform.\n      return cons.create.bind(cons, this.field, this.field.colId());\n    }, this).extend({ deferred: true }));\n\n    this.diffImpl = this.autoDispose(DiffBox.create(this.field));\n\n    this._showRefConfigPopup = ko.observable(false);\n\n    this.autoDispose(commands.createGroup({\n      showPopup: (args: any) => {\n        if (args.popup === \"referenceColumnsConfig\") {\n          this._showRefConfigPopup(true);\n        }\n      },\n    }, this, true));\n  }\n\n  public buildSelectWidgetDom() {\n    return grainjsDom.maybe(use => !use(this._isTransformingType) && use(this._readOnlyPureType), (type) => {\n      const typeWidgets = getTypeDefinition(type).widgets;\n      const widgetOptions = Object.keys(typeWidgets).map(label => ({\n        label,\n        value: label,\n        icon: typeWidgets[label].icon,\n      }));\n      if (widgetOptions.length <= 1) { return null; }\n      // Here we need to accommodate the fact that the widget can be null, which\n      // won't be visible on a select component when disabled.\n      const defaultWidget = Computed.create(null, (use) => {\n        if (widgetOptions.length <= 2) {\n          return;\n        }\n        const value = use(this.field.config.widget);\n        return value;\n      });\n      defaultWidget.onWrite(value => this.field.config.widget(value));\n      const disabled = Computed.create(null, use => !use(this.field.config.sameWidgets));\n      return [\n        cssLabel(t(\"CELL FORMAT\")),\n        cssRow(\n          grainjsDom.autoDispose(defaultWidget),\n          widgetOptions.length <= 2 ?\n            buttonSelect(\n              fromKo(this.field.config.widget),\n              widgetOptions,\n              cssButtonSelect.cls(\"-disabled\", disabled),\n            ) :\n            select(\n              defaultWidget,\n              widgetOptions,\n              {\n                disabled,\n                defaultLabel: t(\"Mixed format\"),\n                translateOptionLabels: true,\n              },\n            ),\n          testId(\"widget-select\"),\n        ),\n      ];\n    });\n  }\n\n  /**\n   * Build the type change dom.\n   */\n  public buildSelectTypeDom() {\n    const holder = new MultiHolder();\n    const commonType = Computed.create(holder, use => use(use(this.field.viewSection).columnsType));\n    const selectType = Computed.create(holder, (use) => {\n      const myType = use(fromKo(this._readOnlyPureType));\n      return use(commonType) === \"mixed\" ? \"\" : myType;\n    });\n    selectType.onWrite((newType) => {\n      const sameType = newType === this._readOnlyPureType.peek();\n      if (!sameType || commonType.get() === \"mixed\") {\n        if ([\"Ref\", \"RefList\"].includes(newType)) {\n          this._showRefConfigPopup(true);\n        }\n        return this._setType(newType);\n      }\n    });\n    const onDispose = () => (this.isDisposed() || selectType.set(this.field.column().pureType()));\n    const allFormulas = Computed.create(holder, use => use(use(this.field.viewSection).columnsAllIsFormula));\n    return [\n      cssRow(\n        grainjsDom.autoDispose(holder),\n        select(selectType, this._availableTypes, {\n          disabled: use =>\n            // If we are transforming column at this moment (applying a formula to change data),\n            use(this._isTransformingFormula) ||\n            // If this is a summary column\n            use(this.origColumn.disableModifyBase) ||\n            // If there are multiple column selected, but all have different type than Any.\n            (use(this.field.config.multiselect) && !use(allFormulas)) ||\n            // If we are waiting for a server response\n            use(this.isCallPending),\n          menuCssClass: cssTypeSelectMenu.className,\n          defaultLabel: t(\"Mixed types\"),\n          translateOptionLabels: true,\n          renderOptionArgs: (op) => {\n            if ([\"Ref\", \"RefList\"].includes(selectType.get())) {\n              // Don't show tip if a reference column type is already selected.\n              return;\n            }\n\n            if (op.label === \"Reference\") {\n              return this.gristDoc.behavioralPromptsManager.attachPopup(\"referenceColumns\", {\n                popupOptions: {\n                  attach: `.${cssTypeSelectMenu.className}`,\n                  placement: \"left-start\",\n                },\n              });\n            } else {\n              return null;\n            }\n          },\n        }),\n        testId(\"type-select\"),\n        grainjsDom.cls(\"tour-type-selector\"),\n        grainjsDom.cls(cssBlockedCursor.className, use =>\n          use(this.origColumn.disableModifyBase) ||\n          use(this._isTransformingFormula) ||\n          (use(this.field.config.multiselect) && !use(allFormulas)),\n        ),\n      ),\n      grainjsDom.maybe(use => use(this._isRef) && !use(this._isTransformingType), () => this._buildRefTableSelect()),\n      grainjsDom.maybe(this._isTransformingType, () => {\n        // Editor dom must be built before preparing transform.\n        return dom(\"div.type_transform_prompt\",\n          kf.prompt(\n            dom(\"div\",\n              grainjsDom.maybe(this._isRef, () => this._buildRefTableSelect()),\n              grainjsDom.maybe(use => use(this.field.column().isTransforming),\n                () => this.columnTransform!.buildDom()),\n            ),\n          ),\n          grainjsDom.onDispose(onDispose),\n        );\n      }),\n    ];\n  }\n\n  // Helper function to set the column type to newType.\n  public _setType(newType: string): void {\n    // If the original column is a formula, we won't be showing any transform UI, so we can\n    // just set the type directly. We test original column as this field might be in the middle\n    // of transformation and temporary be connected to a helper column (but formula columns are\n    // never transformed using UI).\n    if (this.origColumn.isFormula()) {\n      // Do not type transform a new/empty column or a formula column. Just make a best guess for\n      // the full type, and set it. If multiple columns are selected (and all are formulas/empty),\n      // then we will set the type for all of them using full type guessed from the first column.\n      const column = this.field.column(); // same as this.origColumn.\n      const calculatedType = inferColTypeSuffix(newType, column) ?? addColTypeSuffix(newType, column, this._docModel);\n      const fields = this.field.viewSection.peek().selectedFields.peek();\n      // If we selected multiple empty/formula columns, make the change for all of them.\n      if (\n        fields.length > 1 &&\n        fields.every(f => f.column.peek().isFormula() || f.column.peek().isEmpty())\n      ) {\n        this.gristDoc.docData.bundleActions(t(\"Changing multiple column types\"), () =>\n          Promise.all(this.field.viewSection.peek().selectedFields.peek().map(f =>\n            f.column.peek().type.setAndSave(calculatedType),\n          ))).catch(reportError);\n      } else if (column.pureType() === \"Any\") {\n        // If this is Any column, guess the final options.\n        const guessedOptions = guessWidgetOptionsSync({\n          docModel: this._docModel,\n          origCol: this.origColumn,\n          toTypeMaybeFull: newType,\n        });\n        const existingOptions = column.widgetOptionsJson.peek();\n        const widgetOptions = JSON.stringify({ ...existingOptions, ...guessedOptions });\n        bundleChanges(() => {\n          this.gristDoc.docData.bundleActions(t(\"Changing column type\"), () =>\n            Promise.all([\n              // This order is better for any other UI modifications, as first we are updating options\n              // and then saving type.\n              !isEqual(existingOptions, guessedOptions) ?\n                column.widgetOptions.setAndSave(widgetOptions) :\n                Promise.resolve(),\n              column.type.setAndSave(calculatedType),\n            ]),\n          ).catch(reportError);\n        });\n      } else {\n        column.type.setAndSave(calculatedType).catch(reportError);\n      }\n    } else if (!this.columnTransform) {\n      this.columnTransform = TypeTransform.create(null, this.gristDoc, this);\n      this.columnTransform.prepare(newType).catch(reportError);\n    } else {\n      if (this.columnTransform instanceof TypeTransform) {\n        this.columnTransform.setType(newType).catch(reportError);\n      }\n    }\n  }\n\n  // Builds the reference type table selector. Built when the column is type reference.\n  public _buildRefTableSelect() {\n    const allTables = Computed.create(null, use =>\n      use(this._docModel.visibleTables.getObservable()).map(tableRec => ({\n        value: use(tableRec.tableId),\n        label: use(tableRec.tableNameDef),\n        icon: \"FieldTable\" as const,\n      })),\n    );\n    const isDisabled = Computed.create(null, (use) => {\n      return use(this.origColumn.disableModifyBase) || use(this.field.config.multiselect);\n    });\n    return [\n      cssLabel(t(\"DATA FROM TABLE\"),\n        kd.maybe(this._showRefConfigPopup, () => {\n          return dom(\"div\", this.gristDoc.behavioralPromptsManager.attachPopup(\n            \"referenceColumnsConfig\",\n            {\n              onDispose: () => this._showRefConfigPopup(false),\n              popupOptions: {\n                placement: \"left-start\",\n              },\n            },\n          ));\n        },\n        ),\n      ),\n      cssRow(\n        dom.autoDispose(allTables),\n        dom.autoDispose(isDisabled),\n        select(fromKo(this._refTableId), allTables, {\n          // Disallow changing the destination table when the column should not be modified\n          // (specifically when it's a group-by column of a summary table).\n          disabled: isDisabled,\n        }),\n        testId(\"ref-table-select\"),\n      ),\n    ];\n  }\n\n  /**\n   * Build the formula transform dom\n   */\n  public buildTransformDom() {\n    const transformButton = ko.computed({\n      read: () => this.field.column().isTransforming(),\n      write: (val) => {\n        if (val) {\n          this.columnTransform = FormulaTransform.create(null, this.gristDoc, this);\n          return this.columnTransform.prepare();\n        } else {\n          return this.columnTransform?.cancel();\n        }\n      },\n    });\n    return dom(\"div\",\n      dom.autoDispose(transformButton),\n      dom.onDispose(() => {\n        // When losing focus, if there's an active column transform, finalize it.\n        if (this.columnTransform) {\n          this.columnTransform.finalize().catch(reportError);\n        }\n      }),\n      cssRow(\n        textButton(t(\"Apply formula to data\"),\n          dom.on(\"click\", () => transformButton(true)),\n          kd.hide(this._isTransformingFormula),\n          kd.boolAttr(\"disabled\", () =>\n            this._isTransformingType() ||\n            this.origColumn.isFormula() ||\n            this.origColumn.disableModifyBase() ||\n            this.field.config.multiselect()),\n          dom.testId(\"FieldBuilder_editTransform\"),\n          testId(\"edit-transform\"),\n        )),\n      kd.maybe(this._isTransformingFormula, () => {\n        return this.columnTransform!.buildDom();\n      }),\n    );\n  }\n\n  /**\n   * Builds the FieldBuilder Options Config DOM. Calls the buildConfigDom function of its widgetImpl.\n   */\n  public buildConfigDom() {\n    // NOTE: adding a grainjsDom .maybe here causes the disposable order of the widgetImpl and\n    // the dom created by the widgetImpl to get out of sync.\n    return dom(\"div\",\n      kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>\n        dom(\"div\", widget.buildConfigDom(this.gristDoc)),\n      ),\n    );\n  }\n\n  public buildColorConfigDom() {\n    // NOTE: adding a grainjsDom .maybe here causes the disposable order of the widgetImpl and\n    // the dom created by the widgetImpl to get out of sync.\n    return dom(\"div\",\n      kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>\n        dom(\"div\", widget.buildColorConfigDom(this.gristDoc)),\n      ),\n    );\n  }\n\n  public buildFormConfigDom() {\n    return dom(\"div\",\n      kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>\n        dom(\"div\", widget.buildFormConfigDom()),\n      ),\n    );\n  }\n\n  /**\n   * Builds the FieldBuilder Options Config DOM. Calls the buildConfigDom function of its widgetImpl.\n   */\n  public buildSettingOptions() {\n    // NOTE: adding a grainjsDom .maybe here causes the disposable order of the widgetImpl and\n    // the dom created by the widgetImpl to get out of sync.\n    return dom(\"div\",\n      kd.maybe(() => !this._isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>\n        dom(\"div\",\n          // If there is more than one field for this column (i.e. present in multiple views).\n          kd.maybe(() => this.origColumn.viewFields().all().length > 1, () =>\n            dom(\"div.fieldbuilder_settings\",\n              kf.row(\n                kd.toggleClass(\"fieldbuilder_settings_header\", true),\n                kf.label(\n                  dom(\"div.fieldbuilder_settings_button\",\n                    dom.testId(\"FieldBuilder_settings\"),\n                    kd.text(() => this.field.useColOptions() ? t(\"Common\") : t(\"Separate\")), \" ▾\",\n                    menu(() => FieldSettingsMenu(\n                      this.field.useColOptions(),\n                      this.field.viewSection().isRaw(),\n                      {\n                        useSeparate: () => this.fieldSettingsUseSeparate(),\n                        saveAsCommon: () => this.fieldSettingsSaveAsCommon(),\n                        revertToCommon: () => this.fieldSettingsRevertToCommon(),\n                      },\n                    )),\n                  ),\n                  t(\"Field in {{count}} views\", {\n                    count: kd.text(() => this.origColumn.viewFields().all().length),\n                  }),\n                ),\n              ),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n\n  public fieldSettingsUseSeparate() {\n    return this.gristDoc.docData.bundleActions(\n      t(\"Use separate field settings for {{colId}}\", { colId: this.origColumn.colId() }), () => {\n        return Promise.all([\n          setSaveValue(this.field.widgetOptions, this.field.column().widgetOptions() || \"{}\"),\n          setSaveValue(this.field.visibleCol, this.field.column().visibleCol()),\n          this.field.saveDisplayFormula(this.field.column()._displayColModel().formula() || \"\"),\n        ]);\n      },\n    );\n  }\n\n  public fieldSettingsSaveAsCommon() {\n    return this.gristDoc.docData.bundleActions(\n      t(\"Save field settings for {{colId}} as common\", { colId: this.origColumn.colId() }), () => {\n        return Promise.all([\n          setSaveValue(this.field.column().widgetOptions, this.field.widgetOptions()),\n          setSaveValue(this.field.column().visibleCol, this.field.visibleCol()),\n          this.field.column().saveDisplayFormula(this.field._displayColModel().formula() || \"\"),\n          setSaveValue(this.field.widgetOptions, \"\"),\n          setSaveValue(this.field.visibleCol, 0),\n          this.field.saveDisplayFormula(\"\"),\n        ]);\n      },\n    );\n  }\n\n  public fieldSettingsRevertToCommon() {\n    return this.gristDoc.docData.bundleActions(\n      t(\"Revert field settings for {{colId}} to common\", { colId: this.origColumn.colId() }), () => {\n        return Promise.all([\n          setSaveValue(this.field.widgetOptions, \"\"),\n          setSaveValue(this.field.visibleCol, 0),\n          this.field.saveDisplayFormula(\"\"),\n        ]);\n      },\n    );\n  }\n\n  /**\n   * Builds the cell and editor DOM for the chosen UserType. Calls the buildDom and\n   *  buildEditorDom functions of its widgetImpl.\n   */\n  public buildDomWithCursor(row: DataRowModel, isActive: ko.Computed<boolean>, isSelected: ko.Computed<boolean>) {\n    const computedFlags = koUtil.withKoUtils(ko.pureComputed(() => {\n      return this.field.rulesColsIds().map(colRef => row.cells[colRef]?.() ?? false);\n    }, this).extend({ deferred: true }));\n    // Here we are using computedWithPrevious helper, to return\n    // the previous value of computed rule. When user adds or deletes\n    // rules there is a brief moment that rule is still not evaluated\n    // (rules.length != value.length), in this case return last value\n    // and wait for the update.\n    const computedRule = koUtil.withKoUtils(ko.pureComputed<ComputedStyle>(() => {\n      if (this.isDisposed()) { return null; }\n      // If this is add row or a blank row (not loaded yet with all fields = '')\n      // don't use rules.\n      if (row._isAddRow() || !row.id()) { return null; }\n      const styles: Style[] = this.field.rulesStyles();\n      // Make sure that rules where computed.\n      if (!Array.isArray(styles) || styles.length === 0) { return null; }\n      const flags = computedFlags();\n      // Make extra sure that all rules are up to date.\n      // If not, fallback to the previous value.\n      // We need to make sure that all rules columns are created,\n      // sometimes there are more styles for a brief moment.\n      if (styles.length < flags.length) { return/* undefined */; }\n      // We will combine error information in the same computed value.\n      // If there is an error in rules - return it instead of the style.\n      const error = flags.some(f => !gristTypes.isValidRuleValue(f));\n      if (error) {\n        return { error };\n      }\n      // Combine them into a single style option.\n      return { style: new CombinedStyle(styles, flags) };\n    }, this).extend({ deferred: true })).previousOnUndefined();\n\n    const widgetObs = koUtil.withKoUtils(ko.computed(() => {\n      // TODO: Accessing row values like this doesn't always work (row and field might not be updated\n      // simultaneously).\n      if (this.isDisposed()) { return null; }   // Work around JS errors during field removal.\n      const value = row.cells[this.field.colId()];\n      const cell = value && value();\n      if ((value as any) && this._isRightType()(cell, this.options) || row._isAddRow.peek()) {\n        return this.widgetImpl();\n      } else if (gristTypes.isVersions(cell)) {\n        return this.diffImpl;\n      } else {\n        return null;\n      }\n    }).extend({ deferred: true })).onlyNotifyUnequal();\n\n    const ruleText = koUtil.withKoUtils(ko.computed(() => {\n      if (this.isDisposed()) { return null; }\n      return computedRule()?.style?.textColor || \"\";\n    })).onlyNotifyUnequal();\n\n    const ruleFill = koUtil.withKoUtils(ko.computed(() => {\n      if (this.isDisposed()) { return null; }\n      return notTransparent(computedRule()?.style?.fillColor || \"\");\n    })).onlyNotifyUnequal();\n\n    const fontBold = buildFontOptions(this, computedRule, \"fontBold\");\n    const fontItalic = buildFontOptions(this, computedRule, \"fontItalic\");\n    const fontUnderline = buildFontOptions(this, computedRule, \"fontUnderline\");\n    const fontStrikethrough = buildFontOptions(this, computedRule, \"fontStrikethrough\");\n\n    const errorInStyle = ko.pureComputed(() => Boolean(computedRule()?.error));\n\n    const cellText = ko.pureComputed(() => this.field.textColor() || \"\");\n    const cellFill = ko.pureComputed(() => notTransparent(this.field.fillColor() || \"\"));\n\n    const hasComment = koUtil.withKoUtils(ko.computed(() => {\n      if (this.isDisposed()) { return false; }   // Work around JS errors during field removal.\n      const rowId = row.id();\n      const discussion = this.field.column().cells().all()\n        .find(d =>\n          d.rowId() === rowId &&\n          !d.resolved() &&\n          d.type() === gristTypes.CellInfoType.COMMENT &&\n          !d.hidden() &&\n          d.root());\n      return Boolean(discussion);\n    }).extend({ deferred: true })).onlyNotifyUnequal();\n\n    const domHolder = new MultiHolder();\n    domHolder.autoDispose(hasComment);\n    domHolder.autoDispose(widgetObs);\n    domHolder.autoDispose(computedFlags);\n    domHolder.autoDispose(errorInStyle);\n    domHolder.autoDispose(cellText);\n    domHolder.autoDispose(cellFill);\n    domHolder.autoDispose(computedRule);\n    domHolder.autoDispose(fontBold);\n    domHolder.autoDispose(fontItalic);\n    domHolder.autoDispose(fontUnderline);\n    domHolder.autoDispose(fontStrikethrough);\n\n    return (elem: Element) => {\n      this._rowMap.set(row, elem);\n      dom(elem,\n        dom.autoDispose(domHolder),\n        kd.style(\"--grist-cell-color\", cellText),\n        kd.style(\"--grist-cell-background-color\", cellFill),\n        kd.style(\"--grist-rule-color\", ruleText),\n        kd.style(\"--grist-column-rule-background-color\", ruleFill),\n        this._options.isPreview ? null : kd.cssClass(this.field.formulaCssClass),\n        kd.toggleClass(\"field-with-comments\", hasComment),\n        kd.maybe(hasComment, () => dom(\"div.field-comment-indicator\",\n          dom.on(\"click\", (e: MouseEvent) => {\n            commands.allCommands.openDiscussion.run();\n            e.stopPropagation();\n          }),\n        )),\n        kd.toggleClass(\"readonly\", toKo(ko, this._readonly)),\n        kd.maybe(isSelected, () => dom(\"div.selected_cursor\",\n          kd.toggleClass(\"active_cursor\", isActive),\n        )),\n        kd.scope(widgetObs, (widget: NewAbstractWidget) => {\n          if (this.isDisposed()) { return null; }   // Work around JS errors during field removal.\n          const cellDom = widget ? widget.buildDom(row) : buildErrorDom(row, this.field);\n          if (cellDom === null) { return null; }\n          return dom(cellDom, kd.toggleClass(\"has_cursor\", isActive),\n            kd.toggleClass(\"field-error-from-style\", errorInStyle),\n            kd.toggleClass(\"font-bold\", fontBold),\n            kd.toggleClass(\"font-underline\", fontUnderline),\n            kd.toggleClass(\"font-italic\", fontItalic),\n            kd.toggleClass(\"font-strikethrough\", fontStrikethrough));\n        }),\n      );\n    };\n  }\n\n  public buildEditorDom(editRow: DataRowModel, mainRowModel: DataRowModel, options: BuildEditorOptions) {\n    // If the user attempts to edit a value during transform, finalize (i.e. cancel or execute)\n    // the transform.\n    if (this.columnTransform) {\n      this.columnTransform.finalize().catch(reportError);\n      return;\n    }\n\n    // Clear previous editor. Some caveats:\n    // - The floating editor has an async cleanup routine, but it promises that it won't affect as.\n    // - All other editors should be synchronous, so this line will remove all opened editors.\n    const holder = this.gristDoc.fieldEditorHolder;\n    // If the global editor is from our own field, we will dispose it immediately, otherwise we will\n    // rely on the clipboard to dispose it by grabbing focus.\n    const clearOwn = () => this.isEditorActive() && holder.clear();\n\n    // If this is censored value, don't open up the editor, unless it is a formula field.\n    const cell = editRow.cells[this.field.colId()];\n    const value = cell && cell();\n    if (gristTypes.isCensored(value) && !this.origColumn.isFormula.peek()) {\n      return clearOwn();\n    }\n\n    const editorCtor: IEditorConstructor =\n      UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType());\n    // constructor may be null for a read-only non-formula field, though not today.\n    if (!editorCtor) {\n      return clearOwn();\n    }\n\n    if (this._readonly.get() && editorCtor.supportsReadonly && !editorCtor.supportsReadonly()) {\n      return clearOwn();\n    }\n\n    if (\n      !this._readonly.get() &&\n      saveWithoutEditor(editorCtor, editRow, this.field, {\n        typedVal: options.init,\n        event: options.event,\n      })\n    ) {\n      return clearOwn();\n    }\n\n    const cellElem = this._rowMap.get(mainRowModel)!;\n\n    // The editor may dispose itself; the Holder will know to clear itself in this case.\n    const fieldEditor = FieldEditor.create(holder, {\n      gristDoc: this.gristDoc,\n      field: this.field,\n      cursor: this._cursor,\n      editRow,\n      cellElem,\n      editorCtor,\n      state: options.state,\n      startVal: this._readonly.get() ? undefined : options.init, // don't start with initial value\n      readonly: this._readonly.get(), // readonly for editor will not be observable\n      event: options.event,\n    });\n    this._isEditorActive.set(true);\n\n    // expose the active editor in a grist doc as an observable\n    fieldEditor.onDispose(() => {\n      this._isEditorActive.set(false);\n      this.gristDoc.activeEditor.set(null);\n    });\n    this.gristDoc.activeEditor.set(fieldEditor);\n  }\n\n  public buildDiscussionPopup(\n    editRow: DataRowModel,\n    mainRowModel: DataRowModel,\n    text: CommentWithMentions | null,\n  ) {\n    const holder = this.gristDoc.fieldEditorHolder;\n    const cellElem: Element = this._rowMap.get(mainRowModel)!;\n    if (this.columnTransform) {\n      this.columnTransform.finalize().catch(reportError);\n      return;\n    }\n    if (editRow._isAddRow.peek()) {\n      return;\n    }\n\n    const cell = editRow.cells[this.field.colId()];\n    const value = cell && cell();\n    if (gristTypes.isCensored(value)) {\n      holder.clear();\n      return;\n    }\n\n    // Reuse fieldEditor holder to make sure only one popup/editor is attached to the cell.\n    const discussionHolder = MultiHolder.create(holder);\n    // When the cell element is disposed, we will dispose the discussion holder.\n    grainjsDom.autoDisposeElem(cellElem, discussionHolder);\n    const model = DiscussionModelImpl.fromCursor(discussionHolder, this.gristDoc, this._cursor.getCursorPos());\n    // Don't show the popup if there are no active discussions.\n    if (model.isEmpty.get() && this.gristDoc.isReadonly.get()) {\n      holder.clear();\n      return;\n    }\n    CommentPopup.create(discussionHolder, {\n      domEl: cellElem,\n      cell: model,\n      gristDoc: this.gristDoc,\n      initialText: text,\n      closeClicked: () => holder.clear(),\n      cursorPos: this._cursor.getCursorPos(),\n    });\n  }\n\n  public isEditorActive() {\n    const holder = this.gristDoc.fieldEditorHolder;\n    return !holder.isEmpty() && this._isEditorActive.get();\n  }\n\n  /**\n   * Open the formula editor in the side pane. It will be positioned over refElem.\n   */\n  public openSideFormulaEditor(options: {\n    editRow: DataRowModel,\n    refElem: Element,\n    canDetach: boolean,\n    editValue?: string,\n    onSave?: (column: ColumnRec, formula: string) => Promise<void>,\n    onCancel?: () => void\n  }) {\n    const { editRow, refElem, canDetach, editValue, onSave, onCancel } = options;\n\n    // Remember position when the popup was opened.\n    const position = this.gristDoc.cursorPosition.get();\n\n    // Create a controller for the floating editor. It is primarily responsible for moving the editor\n    // dom from the place where it was rendered to the popup (and moving it back).\n    const floatController = {\n      attach: async (content: HTMLElement) => {\n        // If we haven't change page and the element is still in the DOM, move the editor to the\n        // back to where it was rendered. It still has it's content, so no need to dispose it.\n        if (refElem.isConnected) {\n          formulaEditor.attach(refElem);\n        } else {\n          // Else, we will navigate to the position we left off, dispose the editor and the content.\n          formulaEditor.dispose();\n          grainjsDom.domDispose(content);\n          await this.gristDoc.recursiveMoveToCursorPos(position!, true);\n        }\n      },\n      detach() {\n        return formulaEditor.detach();\n      },\n      autoDispose(el: Disposable) {\n        return formulaEditor.autoDispose(el);\n      },\n      dispose() {\n        formulaEditor.dispose();\n      },\n    };\n\n    // Create a custom cleanup method, that won't destroy us when we loose focus while being detached.\n    function setupEditorCleanup(\n      owner: MultiHolder, gristDoc: GristDoc,\n      editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>,\n    ) {\n      // Just override the behavior on focus lost.\n      const saveOnFocus = () => floatingExtension.active.get() ? void 0 : _saveEdit().catch(reportError);\n      UnsavedChange.create(owner, async () => { await saveOnFocus(); });\n      gristDoc.app.on(\"clipboard_focus\", saveOnFocus);\n      owner.onDispose(() => {\n        gristDoc.app.off(\"clipboard_focus\", saveOnFocus);\n        editingFormula(false);\n      });\n    }\n\n    // Get the field model from metatables, as the one provided by the caller might be some floating one, that\n    // will change when user navigates around.\n    const field = this.gristDoc.docModel.viewFields.getRowModel(this.field.getRowId());\n\n    // Finally create the editor passing only the field, which will enable detachable flavor of formula editor.\n    const formulaEditor = openFormulaEditor({\n      gristDoc: this.gristDoc,\n      field,\n      editingFormula: this.field.editingFormula,\n      setupCleanup: setupEditorCleanup,\n      editRow,\n      refElem,\n      editValue,\n      canDetach,\n      onSave,\n      onCancel,\n    });\n\n    // And now create the floating editor itself. It is just a floating wrapper that will grab the dom\n    // from the editor and show it in the popup. It also overrides various parts of Grist to make smoother experience.\n    const floatingExtension = FloatingEditor.create(formulaEditor, floatController, {\n      gristDoc: this.gristDoc,\n      refElem,\n      placement: \"overlapping\",\n    });\n\n    // Add editor to document holder - this will prevent multiple formula editor instances.\n    this.gristDoc.fieldEditorHolder.autoDispose(formulaEditor);\n  }\n}\n\nconst cssTypeSelectMenu = styled(\"div\", `\n  max-height: 500px;\n`);\n\n// Simple helper that removes transparency from a HEX or rgba color.\n// User can set a transparent fill color using doc actions, but we don't want to show it well\n// when a column is frozen.\nfunction notTransparent(color: string): string {\n  if (!color) {\n    return color;\n  } else if (color.startsWith(\"#\") && color.length === 9) {\n    return color.substring(0, 7);\n  } else if (color.startsWith(\"rgba\")) {\n    // rgba(255, 255, 255)\n    // rgba(255, 255, 255, 0.5)\n    // rgba(255 255 255 / 0.5)\n    // rgba(255 255 255 / 50%)\n    return color.replace(/^rgba\\((\\d+)[,\\s]+(\\d+)[,\\s]+(\\d+)[/,\\s]+([\\d.%]+)\\)$/i, \"rgb($1, $2, $3)\");\n  }\n  return color;\n}\n"
  },
  {
    "path": "app/client/widgets/FieldEditor.ts",
    "content": "import { CellPosition } from \"app/client/components/CellPosition\";\nimport * as commands from \"app/client/components/commands\";\nimport { Cursor } from \"app/client/components/Cursor\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { UnsavedChange } from \"app/client/components/UnsavedChanges\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { reportError } from \"app/client/models/errors\";\nimport { showTooltipToCreateFormula } from \"app/client/widgets/EditorTooltip\";\nimport { FloatingEditor } from \"app/client/widgets/FloatingEditor\";\nimport { FormulaEditor, getFormulaError } from \"app/client/widgets/FormulaEditor\";\nimport { IEditorCommandGroup, IEditorConstructor, NewBaseEditor } from \"app/client/widgets/NewBaseEditor\";\nimport { asyncOnce } from \"app/common/AsyncCreate\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { isVersions } from \"app/common/gristTypes\";\nimport * as gutil from \"app/common/gutil\";\nimport { CursorPos } from \"app/plugin/GristAPI\";\n\nimport { Disposable, dom, Emitter, Holder, MultiHolder, Observable } from \"grainjs\";\nimport isEqual from \"lodash/isEqual\";\n\nconst t = makeT(\"FieldEditor\");\n\n/**\n * Check if the typed-in value should change the cell without opening the cell editor, and if so,\n * saves and returns true. E.g. on typing enter, CheckBoxEditor toggles the cell without opening.\n */\nexport function saveWithoutEditor(\n  editorCtor: IEditorConstructor,\n  editRow: DataRowModel,\n  field: ViewFieldRec,\n  options: { typedVal?: string, event?: Event },\n): boolean {\n  const { typedVal, event } = options;\n  // Never skip the editor if editing a formula. Also, check that skipEditor static function\n  // exists (we don't bother adding it on old-style JS editors that don't need it).\n  if (!field.column.peek().isRealFormula.peek() && editorCtor.skipEditor) {\n    const origVal = editRow.cells[field.colId()].peek();\n    const skipEditorValue = editorCtor.skipEditor(typedVal, origVal, { event });\n    if (skipEditorValue !== undefined) {\n      setAndSave(editRow, field, skipEditorValue).catch(reportError);\n      return true;\n    }\n  }\n  return false;\n}\n\n// Set the given field of editRow to value, only if different from the current value of the cell.\nexport async function setAndSave(editRow: DataRowModel, field: ViewFieldRec, value: CellValue): Promise<void> {\n  const obs = editRow.cells[field.colId()];\n  if (!isEqual(value, obs.peek())) {\n    return obs.setAndSave(value);\n  }\n}\n\n/**\n * Event that is fired when editor stat has changed\n */\nexport interface FieldEditorStateEvent {\n  position: CellPosition,\n  wasModified: boolean,\n  currentState: any,\n  type: string\n}\n\nexport class FieldEditor extends Disposable {\n  public readonly saveEmitter = this.autoDispose(new Emitter());\n  public readonly cancelEmitter = this.autoDispose(new Emitter());\n  public readonly changeEmitter = this.autoDispose(new Emitter());\n  public floatingEditor: FloatingEditor;\n\n  private _gristDoc: GristDoc;\n  private _field: ViewFieldRec;\n  private _cursor: Cursor;\n  private _editRow: DataRowModel;\n  private _cellElem: Element;\n  private _editCommands: IEditorCommandGroup;\n  private _editorCtor: IEditorConstructor;\n  private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);\n  private _saveEdit = asyncOnce(() => this._doSaveEdit());\n  private _editorHasChanged = false;\n  private _isFormula = false;\n  private _readonly = false;\n  private _detached = Observable.create(this, false);\n  private _detachedAt: CursorPos | null = null;\n  private _event?: Event;\n\n  constructor(options: {\n    gristDoc: GristDoc,\n    field: ViewFieldRec,\n    cursor: Cursor,\n    editRow: DataRowModel,\n    cellElem: Element,\n    editorCtor: IEditorConstructor,\n    startVal?: string,\n    state?: any,\n    readonly: boolean,\n    event?: Event\n  }) {\n    super();\n    this._gristDoc = options.gristDoc;\n    this._field = options.field;\n    this._cursor = options.cursor;\n    this._editRow = options.editRow;\n    this._editorCtor = options.editorCtor;\n    this._cellElem = options.cellElem;\n    this._readonly = options.readonly;\n    this._event = options.event;\n\n    const startVal = options.startVal;\n    let offerToMakeFormula = false;\n\n    const column = this._field.column();\n    this._isFormula = column.isRealFormula.peek();\n    let editValue: string | undefined = startVal;\n    if (!options.readonly && startVal && gutil.startsWith(startVal, \"=\")) {\n      if (this._isFormula  || this._field.column().isEmpty()) {\n        // If we typed '=' on an empty column, convert it to a formula. If on a formula column,\n        // start editing ignoring the initial '='.\n        this._isFormula  = true;\n        editValue = gutil.removePrefix(startVal, \"=\")!;\n      } else {\n        // If we typed '=' on a non-empty column, only suggest to convert it to a formula.\n        offerToMakeFormula = true;\n      }\n    }\n\n    // These are the commands for while the editor is active.\n    this._editCommands = {\n      // _saveEdit disables this command group, so when we run fieldEditSave again, it triggers\n      // another registered group, if any. E.g. GridView listens to it to move the cursor down.\n      fieldEditSave: () => {\n        this._saveEdit().then((jumped: boolean) => {\n          // To avoid confusing cursor movement, do not increment the rowIndex if the row\n          // was re-sorted after editing.\n          if (!jumped) { commands.allCommands.fieldEditSave.run(); }\n        })\n          .catch(reportError);\n      },\n      fieldEditSaveHere: () => { this._saveEdit().catch(reportError); },\n      fieldEditCancel: () => { this._cancelEdit(); },\n      prevField: () => { this._saveEdit().then(commands.allCommands.prevField.run).catch(reportError); },\n      nextField: () => { this._saveEdit().then(commands.allCommands.nextField.run).catch(reportError); },\n      makeFormula: () => this._makeFormula(),\n      unmakeFormula: () => this._unmakeFormula(),\n    };\n\n    // for readonly editor rewire commands, most of this also could be\n    // done by just overriding the saveEdit method, but this is more clearer\n    if (options.readonly) {\n      this._editCommands.fieldEditSave = () => {\n        // those two lines are tightly coupled - without disposing first\n        // it will run itself in a loop. But this is needed for a GridView\n        // which navigates to the next row on save.\n        this._editCommands.fieldEditCancel();\n        commands.allCommands.fieldEditSave.run();\n      };\n      this._editCommands.fieldEditSaveHere = this._editCommands.fieldEditCancel;\n      this._editCommands.prevField = () => { this._cancelEdit(); commands.allCommands.prevField.run(); };\n      this._editCommands.nextField = () => { this._cancelEdit(); commands.allCommands.nextField.run(); };\n      this._editCommands.makeFormula = () => true; /* don't stop propagation */\n      this._editCommands.unmakeFormula = () => true;\n    }\n\n    this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state);\n\n    // Create a floating editor, which will be used to display the editor in a popup.\n    this.floatingEditor = FloatingEditor.create(this, this, {\n      gristDoc: this._gristDoc,\n      refElem: this._cellElem,\n      placement: \"adjacent\",\n    });\n\n    if (offerToMakeFormula) {\n      this._offerToMakeFormula();\n    }\n\n    // connect this editor to editor monitor, it will restore this editor\n    // when user or server refreshes the browser\n    this._gristDoc.editorMonitor?.monitorEditor(this);\n\n    // For detached editor, we don't need to cleanup anything.\n    // It will be cleanuped automatically.\n    const onCleanup = async () => {\n      if (this._detached.get()) { return; }\n      await this._saveEdit();\n    };\n\n    // for readonly field we don't need to do anything special\n    if (!options.readonly) {\n      setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, onCleanup);\n    } else {\n      setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());\n    }\n  }\n\n  // cursorPos refers to the position of the caret within the editor.\n  public rebuildEditor(editValue: string | undefined, cursorPos: number, state?: any) {\n    // Attachment column with a formula is different, it either uses FormulaEditor if user typed something or pressed\n    // enter, or AttachmentEditor if user clicked or dblclicked to edit. In later case we assume user wants to see\n    // attachments, not the formula.\n    if (this._field.column.peek().pureType.peek() === \"Attachments\" && this._field.column().isRealFormula.peek()) {\n      if (this._event && (this._event.type === \"click\" || this._event.type === \"dblclick\")) {\n        this._isFormula = false;\n        this._readonly = true;\n      }\n    }\n\n    const editorCtor: IEditorConstructor = this._isFormula ? FormulaEditor : this._editorCtor;\n    const cellCurrentValue = this._editRow.cells[this._field.colId()].peek();\n    let cellValue: CellValue;\n\n    const column = this._field.column();\n    if (this._isFormula) {\n      cellValue = column.formula();\n    } else if (Array.isArray(cellCurrentValue) && cellCurrentValue[0] === \"C\") {\n      // This cell value is censored by access control rules\n      // Really the rules should also block editing, but in case they don't, show a blank value\n      // rather than a 'C'. However if the user tries to edit the cell and then clicks away\n      // without typing anything the empty string is saved, deleting what was there.\n      // We should probably just automatically block updates where reading is not allowed.\n      cellValue = \"\";\n    } else {\n      cellValue = cellCurrentValue;\n    }\n\n    const errorHolder = new MultiHolder();\n\n    const error = getFormulaError(errorHolder, {\n      gristDoc: this._gristDoc,\n      editRow: this._editRow,\n      field: this._field,\n    });\n\n    // For readonly mode use the default behavior of Formula Editor\n    // TODO: cleanup this flag - it gets modified in too many places\n    if (!this._readonly) {\n      // Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the\n      // editor by typing into it (and overriding previous formula). In other cases (e.g. double-click),\n      // we defer this mode until the user types something.\n      const active = this._isFormula && editValue !== undefined;\n      this._field.editingFormula(active);\n    }\n\n    this._detached.set(false);\n    this._editorHasChanged = false;\n    // Replace the item in the Holder with a new one, disposing the previous one.\n    const editor = this._editorHolder.autoDispose(editorCtor.create({\n      gristDoc: this._gristDoc,\n      field: this._field,\n      column: this._field.column(), // needed for FormulaEditor\n      editingFormula: this._field.editingFormula, // needed for Formula editor\n      cellValue: onlyCurrent(cellValue),\n      rowId: this._editRow.id(),\n      formulaError: error,\n      editValue,\n      cursorPos,\n      state,\n      canDetach: true,\n      commands: this._editCommands,\n      readonly: this._readonly,\n    }));\n\n    editor.autoDispose(errorHolder);\n\n    // if editor supports live changes, connect it to the change emitter\n    if (editor.editorState) {\n      editor.autoDispose(editor.editorState.addListener((currentState) => {\n        this._editorHasChanged = true;\n        const event: FieldEditorStateEvent = {\n          position: this.cellPosition(),\n          wasModified: this._editorHasChanged,\n          currentState,\n          type: this._field.column.peek().pureType.peek(),\n        };\n        this.changeEmitter.emit(event);\n      }));\n    }\n\n    editor.attach(this._cellElem);\n  }\n\n  public detach() {\n    this._detached.set(true);\n    this._detachedAt = this._gristDoc.cursorPosition.get()!;\n    return this._editorHolder.get()!.detach()!;\n  }\n\n  public async attach(content: HTMLElement) {\n    // If we are disconnected from the dom (maybe page was changed or something), we can't\n    // simply attach the editor back, we need to rebuild it.\n    if (!this._cellElem.isConnected) {\n      dom.domDispose(content);\n      if (await this._gristDoc.recursiveMoveToCursorPos(this._detachedAt!, true)) {\n        await this._gristDoc.activateEditorAtCursor();\n      }\n      this.dispose();\n      return;\n    }\n    this._detached.set(false);\n    this._editorHolder.get()?.attach(this._cellElem);\n    this._field.viewSection.peek().hasFocus(true);\n  }\n\n  public getDom() {\n    return this._editorHolder.get()?.getDom();\n  }\n\n  // calculate current cell's absolute position\n  public cellPosition() {\n    const rowId = this._editRow.getRowId();\n    const colRef = this._field.column.peek().origColRef.peek();\n    const sectionId = this._field.viewSection.peek().id.peek();\n    const position = {\n      rowId,\n      colRef,\n      sectionId,\n    };\n    return position;\n  }\n\n  private _makeFormula() {\n    const editor = this._editorHolder.get();\n    // On keyPress of \"=\" on textInput, consider turning the column into a formula.\n    if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {\n      if (this._field.column().isEmpty()) {\n        this._isFormula = true;\n        // If we typed '=' an empty column, convert it to a formula.\n        this.rebuildEditor(editor.getTextValue(), 0);\n        return false;\n      } else {\n        // If we typed '=' on a non-empty column, only suggest to convert it to a formula.\n        this._offerToMakeFormula();\n      }\n    }\n    return true;    // don't stop propagation.\n  }\n\n  private _unmakeFormula() {\n    const editor = this._editorHolder.get();\n    if (editor instanceof FormulaEditor && editor.isDetached.get()) {\n      return true;\n    }\n\n    // Only convert to data if we are undoing a to-formula conversion. To convert formula to\n    // data, use column menu option, or delete the formula first (which makes the column \"empty\").\n    if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 &&\n      !this._field.column().isRealFormula()) {\n      // Restore a plain '=' character. This gives a way to enter \"=\" at the start if line. The\n      // second backspace will delete it.\n      this._isFormula = false;\n      this.rebuildEditor(\"=\" + editor.getTextValue(), 1);\n      return false;\n    }\n    return true;    // don't stop propagation.\n  }\n\n  private _offerToMakeFormula() {\n    const editorDom = this._editorHolder.get()?.getDom();\n    if (!editorDom) { return; }\n    showTooltipToCreateFormula(editorDom, () => this._convertEditorToFormula());\n  }\n\n  private _convertEditorToFormula() {\n    const editor = this._editorHolder.get();\n    if (editor) {\n      const editValue = editor.getTextValue();\n      const formulaValue = editValue.startsWith(\"=\") ? editValue.slice(1) : editValue;\n      this._isFormula = true;\n      this.rebuildEditor(formulaValue, 0);\n    }\n  }\n\n  // Cancels the edit\n  private _cancelEdit() {\n    if (this.isDisposed()) { return; }\n    const event: FieldEditorStateEvent = {\n      position: this.cellPosition(),\n      wasModified: this._editorHasChanged,\n      currentState: this._editorHolder.get()?.editorState?.get(),\n      type: this._field.column.peek().pureType.peek(),\n    };\n    this.cancelEmitter.emit(event);\n    this.dispose();\n  }\n\n  // Returns true if Enter/Shift+Enter should NOT move the cursor, for instance if the current\n  // record got reordered (i.e. the cursor jumped), or when editing a formula.\n  private async _doSaveEdit(): Promise<boolean> {\n    const editor = this._editorHolder.get();\n    if (!editor) { return false; }\n    // Make sure the editor is save ready\n    const saveIndex = this._cursor.rowIndex();\n    return await this._gristDoc.docData.bundleActions(null, async () => {\n      await editor.prepForSave();\n      if (this.isDisposed()) {\n        // We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.\n        console.warn(t(\"Unable to finish saving edited cell\"));         return false;\n      }\n      // Then save the value the appropriate way\n      // TODO: this isFormula value doesn't actually reflect if editing the formula, since\n      // editingFormula() is used for toggling column headers, and this is deferred to start of\n      // typing (a double-click or Enter) does not immediately set it. (This can cause a\n      // console.warn below, although harmless.)\n      const isFormula = this._field.editingFormula();\n      const col = this._field.column();\n      let waitPromise: Promise<unknown> | null = null;\n\n      if (isFormula) {\n        const formula = String(editor.getCellValue() ?? \"\");\n        // Bundle multiple changes so that we can undo them in one step.\n        if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {\n          waitPromise = Promise.all([\n            col.updateColValues({ isFormula, formula }),\n            // If we're saving a non-empty formula, then also add an empty record to the table\n            // so that the formula calculation is visible to the user.\n            (!this._detached.get() && this._editRow._isAddRow.peek() && formula !== \"\" ?\n              this._editRow.updateColValues({}) : undefined),\n          ]);\n        }\n      } else {\n        const value = editor.getCellValue();\n        if (col.isRealFormula()) {\n          console.warn(t(\"It should be impossible to save a plain data value into a formula column\"));\n        } else {\n          // This could still be an isFormula column if it's empty (isEmpty is true), but we don't\n          // need to toggle isFormula in that case, since the data engine takes care of that.\n          waitPromise = setAndSave(this._editRow, this._field, value);\n        }\n      }\n\n      const event: FieldEditorStateEvent = {\n        position: this.cellPosition(),\n        wasModified: this._editorHasChanged,\n        currentState: this._editorHolder.get()?.editorState?.get(),\n        type: this._field.column.peek().pureType.peek(),\n      };\n      this.saveEmitter.emit(event);\n\n      const cursor = this._cursor;\n      // Deactivate the editor. We are careful to avoid using `this` afterwards.\n      this.dispose();\n      await waitPromise;\n      return isFormula || (saveIndex !== cursor.rowIndex());\n    });\n  }\n}\n\n/**\n * For an readonly editor, set up its cleanup:\n * - canceling on click-away (when focus returns to Grist \"clipboard\" element)\n */\nfunction setupReadonlyEditorCleanup(\n  owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, cancelEdit: () => any,\n) {\n  // Whenever focus returns to the Clipboard component, close the editor by saving the value.\n  gristDoc.app.on(\"clipboard_focus\", cancelEdit);\n  owner.onDispose(() => {\n    field.editingFormula(false);\n    gristDoc.app.off(\"clipboard_focus\", cancelEdit);\n  });\n}\n\n/**\n * For an active editor, set up its cleanup:\n * - saving on click-away (when focus returns to Grist \"clipboard\" element)\n * - unset field.editingFormula mode\n * - Arrange for UnsavedChange protection against leaving the page with unsaved changes.\n */\nexport function setupEditorCleanup(\n  owner: MultiHolder, gristDoc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>,\n) {\n  const saveEdit = () => _saveEdit().catch(reportError);\n\n  // Whenever focus returns to the Clipboard component, close the editor by saving the value.\n  gristDoc.app.on(\"clipboard_focus\", saveEdit);\n\n  // TODO: This should ideally include a callback that returns true only when the editor value\n  // has changed. Currently an open editor is considered unsaved even when unchanged.\n  UnsavedChange.create(owner, async () => { await saveEdit(); });\n\n  owner.onDispose(() => {\n    gristDoc.app.off(\"clipboard_focus\", saveEdit);\n    // Unset field.editingFormula flag when the editor closes.\n    editingFormula(false);\n  });\n}\n\nfunction onlyCurrent(cellValue: CellValue): CellValue {\n  if (isVersions(cellValue)) {\n    const versions = cellValue[1];\n    if (versions.local !== undefined) { return versions.local; }\n    if (versions.parent !== undefined) { return versions.parent; }\n  }\n  return cellValue;\n}\n"
  },
  {
    "path": "app/client/widgets/FloatingEditor.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { detachNode } from \"app/client/lib/dom\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { FloatingPopup, PopupPosition } from \"app/client/ui/FloatingPopup\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\n\nimport { Disposable, dom, Holder, IDisposableOwner, IDomArgs,\n  makeTestId, MultiHolder, Observable, styled } from \"grainjs\";\n\nconst t = makeT(\"FloatingEditor\");\n\nconst testId = makeTestId(\"test-floating-editor-\");\n\nconst FLOATING_POPUP_WIDTH_PX = 436;\n\nexport interface IFloatingOwner extends IDisposableOwner {\n  detach(): HTMLElement;\n  attach(content: HTMLElement): Promise<void> | void;\n}\n\nexport interface FloatingEditorOptions {\n  gristDoc: GristDoc;\n  /**\n   * The element that `placement` should be relative to.\n   */\n  refElem?: Element;\n  /**\n   * How to position the editor.\n   *\n   * If \"overlapping\", the editor will be positioned on top of `refElem`, anchored\n   * to its top-left corner.\n   *\n   * If \"adjacent\", the editor will be positioned to the left or right of `refElem`,\n   * depending on available space.\n   *\n   * If \"fixed\", the editor will be positioned in the bottom-right corner of the\n   * viewport.\n   *\n   * Defaults to \"fixed\".\n   */\n  placement?: \"overlapping\" | \"adjacent\" | \"fixed\";\n}\n\nexport class FloatingEditor extends Disposable {\n  public active = Observable.create<boolean>(this, false);\n\n  private _gristDoc = this._options.gristDoc;\n  private _placement = this._options.placement ?? \"fixed\";\n  private _refElem = this._options.refElem;\n\n  constructor(\n    private _fieldEditor: IFloatingOwner,\n    private _options: FloatingEditorOptions,\n  ) {\n    super();\n    this.autoDispose(commands.createGroup({\n      detachEditor: this.createPopup.bind(this),\n    }, this, true));\n  }\n\n  public createPopup() {\n    const editor = this._fieldEditor;\n\n    const popupOwner = Holder.create(editor);\n    const tempOwner = new MultiHolder();\n    try {\n      // Create a layer to grab the focus, when we will move the editor to the popup. Otherwise the focus\n      // will be moved to the clipboard which can destroy us (as it will be treated as a clickaway). So here\n      // we are kind of simulating always focused editor (even if it is not in the dom for a brief moment).\n      FocusLayer.create(tempOwner, { defaultFocusElem: document.activeElement as any });\n\n      // Take some data from gristDoc to create a title.\n      const cursor = this._gristDoc.cursorPosition.get()!;\n      const vs = this._gristDoc.docModel.viewSections.getRowModel(cursor.sectionId!);\n      const table = vs.tableId.peek();\n      const field = vs.viewFields.peek().at(cursor.fieldIndex!)!;\n      const title = `${table}.${field.label.peek()}`;\n\n      let content: HTMLElement;\n      // Now create the popup. It will be owned by the editor itself.\n      const popup = FloatingPopup.create(popupOwner, {\n        width: FLOATING_POPUP_WIDTH_PX,\n        height: 711,\n        content: () => (content = editor.detach()), // this will be called immediately, and will move some dom between\n        // existing editor and the popup. We need to save it, so we can\n        // detach it on close.\n        title: () => title, // We are not reactive yet\n        closeButton: true,  // Show the close button with a hover\n        closeButtonIcon: \"Minimize\",\n        closeButtonHover: () => t(\"Collapse Editor\"),\n        onClose: async () => {\n          const layer = FocusLayer.create(null, { defaultFocusElem: document.activeElement as any });\n          try {\n            detachNode(content);\n            popupOwner.dispose();\n            await editor.attach(content);\n          } finally {\n            layer.dispose();\n          }\n        },\n        minWidth: 328,\n        minHeight: 400,\n        position: this._getPopupPosition(),\n        testId,\n      });\n      // Set a public flag that we are active.\n      this.active.set(true);\n      popup.onDispose(() => {\n        this.active.set(false);\n      });\n\n      // Show the popup with the editor.\n      popup.showPopup();\n    } finally {\n      // Dispose the focus layer, we only needed it for the time when the dom was moved between parents.\n      tempOwner.dispose();\n    }\n  }\n\n  private _getPopupPosition(): PopupPosition | undefined {\n    if (!this._refElem || this._placement === \"fixed\") {\n      return undefined;\n    }\n\n    const refElem = this._refElem as HTMLElement;\n    const rect = refElem.getBoundingClientRect();\n    const { right, top } = rect;\n    let left: number;\n    if (this._placement === \"overlapping\") {\n      // Anchor the floating editor to the top-left corner of the refElement.\n      left = rect.left;\n    } else if (window.innerWidth - right >= FLOATING_POPUP_WIDTH_PX) {\n      // If there's enough space to the right of refElement, position the\n      // floating editor there.\n      left = right;\n    } else {\n      // Otherwise position it to the left of refElement; note that it may still\n      // overlap if there isn't enough space on this side either.\n      left = rect.left - FLOATING_POPUP_WIDTH_PX;\n    }\n    return {\n      left,\n      top,\n    };\n  }\n}\n\nexport function createDetachedIcon(...args: IDomArgs<HTMLDivElement>) {\n  return cssResizeIconWrapper(\n    cssSmallIcon(\"Maximize\"),\n    dom.on(\"click\", (e) => {\n      e.stopPropagation();\n      e.preventDefault();\n      commands.allCommands.detachEditor.run();\n    }),\n    dom.on(\"mousedown\", (e) => {\n      e.preventDefault();\n      e.stopPropagation();\n    }),\n    testId(\"detach-button\"),\n    ...args,\n  );\n}\n\nconst cssSmallIcon = styled(icon, `\n  width: 14px;\n  height: 14px;\n`);\n\nconst cssResizeIconWrapper = styled(\"div\", `\n  position: absolute;\n  right: -2px;\n  top: -20px;\n  line-height: 0px;\n  cursor: pointer;\n  z-index: 10;\n  --icon-color: ${theme.cellBg};\n  background: var(--grist-theme-control-primary-bg, var(--grist-primary-fg));\n  height: 20px;\n  width: 21px;\n  --icon-color: white;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  line-height: 0px;\n  border-top-left-radius: 4px;\n  border-top-right-radius: 4px;\n  &:hover {\n    background: var(--grist-theme-control-primary-hover-bg, var(--grist-primary-fg-hover))\n  }\n  & > div {\n    transition: background .05s ease-in-out;\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/FormulaAssistant.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { localStorageBoolObs } from \"app/client/lib/localStorageObs\";\nimport { movable } from \"app/client/lib/popupUtils\";\nimport { logTelemetryEvent } from \"app/client/lib/telemetry\";\nimport { ChatHistory } from \"app/client/models/ChatHistory\";\nimport { ColumnRec, ViewFieldRec } from \"app/client/models/DocModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { basicButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { menu, menuItem } from \"app/client/ui2018/menus\";\nimport { Assistant, cssAiImage, cssAiMessage, cssAvatar } from \"app/client/widgets/Assistant\";\nimport { FormulaEditor } from \"app/client/widgets/FormulaEditor\";\nimport { AssistanceState } from \"app/common/Assistance\";\nimport { commonUrls } from \"app/common/gristUrls\";\nimport { TelemetryEvent, TelemetryMetadata } from \"app/common/Telemetry\";\nimport { getGristConfig } from \"app/common/urlUtils\";\n\nimport { Disposable, dom, DomElementArg, makeTestId, Observable, styled } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\nimport noop from \"lodash/noop\";\n\nconst t = makeT(\"FormulaAssistant\");\nconst testId = makeTestId(\"test-formula-editor-\");\n\n/**\n * An extension or the FormulaEditor that provides assistance for writing formulas.\n * It renders itself in the detached FormulaEditor and adds some extra UI elements.\n * - Save button: a subscription for the Enter key that saves the formula and closes the assistant.\n * - Preview button: a new functionality that allows to preview the formula in a temporary column.\n * - Cancel button: a subscription for the Escape key that discards all changes and closes the assistant.\n * - A chat component: that allows to communicate with the assistant.\n */\nexport class FormulaAssistant extends Disposable {\n  private _gristDoc = this._options.gristDoc;\n  private _appModel = this._gristDoc.appModel;\n  private _history: Observable<ChatHistory>;\n  /** Chat component */\n  private _chat: Assistant;\n  /** Is the formula assistant expanded */\n  private _assistantExpanded = this.autoDispose(localStorageBoolObs(\n    `u:${this._appModel.currentUser?.id ?? 0};formulaAssistantExpanded`, true));\n\n  /** Is assistant features are enabled */\n  private _assistantEnabled = getGristConfig().assistant?.version === 1;\n  /** Preview column ref */\n  private _transformColRef: string;\n  /** Preview column id */\n  private _transformColId: string;\n  /** Method to invoke when we are closed, it saves or reverts */\n  private _triggerFinalize: (() => void) = noop;\n  /** What action button was clicked, by default close without saving */\n  private _action: \"save\" | \"cancel\" | \"close\" = \"close\";\n  // Our dom element (used for resizing).\n  private _domElement: HTMLElement;\n  /** Chat panel body element. */\n  private _chatPanelBody: HTMLElement;\n  /** Client height of the chat panel body element. */\n  private _chatPanelBodyClientHeight = Observable.create<number>(this, 0);\n  /** Set to true the first time the panel has been expanded (including by default). */\n  private _hasExpandedOnce = false;\n  /**\n   * Last known height of the chat panel.\n   *\n   * This is like `_chatPanelBodyClientHeight`, but updated only for the purposes of\n   * being able to collapse and expand the panel to a known height.\n   */\n  private _lastChatPanelHeight: number | undefined;\n  /** True if the chat panel is being resized via dragging. */\n  private _isResizing = Observable.create(this, false);\n\n  /**\n   * Debounced version of the method that will force parent editor to resize, we call it often\n   * as we have an ability to resize the chat window.\n   */\n  private _resizeEditor = debounce(() => {\n    if (!this.isDisposed()) {\n      this._options.editor.resize();\n    }\n  }, 10);\n\n  constructor(private _options: {\n    column: ColumnRec,\n    field?: ViewFieldRec,\n    gristDoc: GristDoc,\n    editor: FormulaEditor\n  }) {\n    super();\n\n    if (!this._options.field) {\n      // TODO: field is not passed only for rules (as there is no preview there available to the user yet)\n      // this should be implemented but it requires creating a helper column to helper column and we don't\n      // have infrastructure for that yet.\n      throw new Error(\"Formula assistant requires a field to be passed.\");\n    }\n\n    this._history = this._options.column.chatHistory.peek();\n\n    this._chat = Assistant.create(this, {\n      history: this._history,\n      gristDoc: this._gristDoc,\n      parentHeightPx: this._chatPanelBodyClientHeight,\n      onSend: this._sendMessage.bind(this),\n      buildIntroMessage,\n      onApplyFormula: this._applyFormula.bind(this),\n      onEscape: this._cancel.bind(this),\n      logTelemetryEvent: this._logTelemetryEvent.bind(this),\n    });\n\n    this.autoDispose(commands.createGroup({\n      activateAssistant: () => {\n        this._expandChatPanel();\n        setTimeout(() => { this._chat.focus(); }, 0);\n      },\n    }, this, this._assistantEnabled));\n\n    // Unfortunately we need to observe the size of the formula editor dom and resize it accordingly.\n    const observer = new ResizeObserver(this._resizeEditor);\n    observer.observe(this._options.editor.getDom());\n    this.onDispose(() => observer.disconnect());\n\n    // Start bundling all actions from this moment on and close the editor as soon,\n    // as user tries to do something different.\n    const bundleInfo = this._options.gristDoc.docData.startBundlingActions({\n      description: \"Formula Editor\",\n      prepare: () => this._preparePreview(),\n      finalize: () => this._cleanupPreview(),\n      shouldIncludeInBundle: (actions) => {\n        if (actions.length !== 1) { return false; }\n\n        const actionName = actions[0][0];\n        if (actionName === \"ModifyColumn\") {\n          const tableId = this._options.column.table.peek().tableId.peek();\n          return actions[0][1] === tableId &&\n            typeof actions[0][2] === \"string\" &&\n            [this._transformColId, this._options.column.id.peek()].includes(actions[0][2]);\n        } else if (actionName === \"UpdateRecord\") {\n          return actions[0][1] === \"_grist_Tables_column\" && actions[0][2] === this._transformColRef;\n        } else {\n          return false;\n        }\n      },\n    });\n\n    this._triggerFinalize = bundleInfo.triggerFinalize;\n    this.onDispose(() => {\n      if (this._hasExpandedOnce) {\n        const suggestionApplied = this._chat.conversationSuggestedFormulas\n          .includes(this._options.column.formula.peek());\n        if (suggestionApplied) {\n          this._logTelemetryEvent(\"assistantApplySuggestion\", false, {\n            conversationLength: this._chat.conversationLength,\n            conversationHistoryLength: this._chat.conversationHistoryLength,\n          });\n        }\n        this._logTelemetryEvent(\"assistantClose\", false, {\n          suggestionApplied,\n          conversationLength: this._chat.conversationLength,\n          conversationHistoryLength: this._chat.conversationHistoryLength,\n        });\n      }\n\n      // This will be noop if already called.\n      this._triggerFinalize();\n    });\n  }\n\n  // The main dom added to the editor and the bottom (3 buttons and chat window).\n  public buildDom() {\n    // When the tools are resized, resize the editor.\n    const observer = new ResizeObserver(this._resizeEditor);\n    this._domElement = cssTools(\n      el => observer.observe(el),\n      dom.onDispose(() => observer.disconnect()),\n      cssButtons(\n        basicButton(t(\"Cancel\"), dom.on(\"click\", () => {\n          this._cancel();\n        }), testId(\"cancel-button\")),\n        basicButton(t(\"Preview\"), dom.on(\"click\", async () => {\n          await this._preview();\n        }), testId(\"preview-button\")),\n        primaryButton(t(\"Save\"), dom.on(\"click\", () => {\n          this._saveOrClose();\n        }), testId(\"save-button\")),\n      ),\n      this._buildChatPanel(),\n    );\n\n    if (this._assistantEnabled) {\n      if (!this._assistantExpanded.get()) {\n        this._chatPanelBody.style.setProperty(\"height\", \"0px\");\n      } else {\n        // The actual height doesn't matter too much here, so we just pick\n        // a value that guarantees the assistant will fill as much of the\n        // available space as possible.\n        this._chatPanelBody.style.setProperty(\"height\", \"999px\");\n      }\n    }\n\n    if (this._assistantEnabled && this._assistantExpanded.get()) {\n      this._logTelemetryEvent(\"assistantOpen\", true);\n      this._hasExpandedOnce = true;\n    }\n\n    return this._domElement;\n  }\n\n  private _buildChatPanel() {\n    return dom.maybe(this._assistantEnabled, () => {\n      return cssChatPanel(\n        cssChatPanelHeaderResizer(\n          movable({\n            onStart: this._onResizeStart.bind(this),\n            onMove: this._onResizeMove.bind(this),\n            onEnd: this._onResizeEnd.bind(this),\n          }),\n          cssChatPanelHeaderResizer.cls(\"-collapsed\", use => !use(this._assistantExpanded)),\n        ),\n        this._buildChatPanelHeader(),\n        this._buildChatPanelBody(),\n      );\n    });\n  }\n\n  private _logTelemetryEvent(event: TelemetryEvent, includeContext = false, metadata: TelemetryMetadata = {}) {\n    logTelemetryEvent(event, {\n      full: {\n        version: 1,\n        docIdDigest: this._gristDoc.docId(),\n        conversationId: this._chat.conversationId,\n        ...(!includeContext ? {} : { context: {\n          tableId: this._options.column.table.peek().tableId.peek(),\n          colId: this._options.column.colId.peek(),\n        } }),\n        ...metadata,\n      },\n    });\n  }\n\n  private _buildChatPanelHeader() {\n    return cssChatPanelHeader(\n      cssChatPanelHeaderTitle(\n        icon(\"Robot\"),\n        t(\"AI Assistant\"),\n      ),\n      cssChatPanelHeaderButtons(\n        cssChatPanelHeaderButton(\n          dom.domComputed(this._assistantExpanded, isExpanded => isExpanded ?\n            icon(\"Dropdown\") : icon(\"DropdownUp\")),\n          dom.on(\"click\", () => {\n            if (this._assistantExpanded.get()) {\n              this._collapseChatPanel();\n            } else {\n              this._expandChatPanel();\n            }\n          }),\n          testId(\"ai-assistant-expand-collapse\"),\n        ),\n        cssChatPanelHeaderButton(\n          icon(\"Dots\"),\n          menu(() => [\n            menuItem(\n              () => this._chat.clear(),\n              t(\"Clear conversation\"),\n              testId(\"ai-assistant-options-clear-conversation\"),\n            ),\n          ], { menuWrapCssClass: cssChatOptionsMenu.className }),\n          testId(\"ai-assistant-options\"),\n        ),\n      ),\n    );\n  }\n\n  private _buildChatPanelBody() {\n    setTimeout(() => {\n      this._options.editor.resize();\n    }, 0);\n\n    const observer = new ResizeObserver(() => {\n      // Keep track of changes to the chat panel body height; its children need to know it to adjust\n      // their max heights accordingly.\n      this._chatPanelBodyClientHeight.set(this._chatPanelBody.clientHeight);\n    });\n\n    this._chatPanelBody = cssChatPanelBody(\n      dom.onDispose(() => observer.disconnect()),\n      testId(\"ai-assistant-chat-panel\"),\n      this._chat.buildDom(),\n      cssChatPanelBody.cls(\"-resizing\", this._isResizing),\n      // Stop propagation of mousedown events, as the formula editor will still focus.\n      dom.on(\"mousedown\", ev => ev.stopPropagation()),\n    );\n\n    observer.observe(this._chatPanelBody);\n\n    return this._chatPanelBody;\n  }\n\n  /**\n   * Save button handler. We just store the action and wait for the bundler to finalize.\n   */\n  private _saveOrClose() {\n    if (this._hasExpandedOnce) {\n      this._logTelemetryEvent(\"assistantSave\", true, {\n        oldFormula: this._options.column.formula.peek(),\n        newFormula: this._options.editor.getTextValue(),\n      });\n    }\n    this._action = \"save\";\n    this._triggerFinalize();\n  }\n\n  /**\n   * Cancel button handler.\n   */\n  private _cancel() {\n    if (this._hasExpandedOnce) {\n      this._logTelemetryEvent(\"assistantCancel\", true, {\n        conversationLength: this._chat.conversationLength,\n      });\n    }\n    this._action = \"cancel\";\n    this._triggerFinalize();\n  }\n\n  /**\n   * Preview button handler.\n   */\n  private async _preview() {\n    const tableId = this._options.column.table.peek().tableId.peek();\n    const formula = this._options.editor.getCellValue();\n    const isFormula = true;\n    await this._options.gristDoc.docData.sendAction(\n      [\"ModifyColumn\", tableId, this._transformColId, { formula, isFormula },\n      ]);\n    if (!this.isDisposed()) {\n      this._options.editor.focus();\n    }\n  }\n\n  private async _preparePreview() {\n    const docData = this._options.gristDoc.docData;\n    const tableId = this._options.column.table.peek().tableId.peek();\n\n    // Add a new column to the table, and set it as the transform column.\n    const { colRef, colId } = await docData.sendAction([\"AddColumn\", tableId, \"gristHelper_Transform\", {\n      type: this._options.column.type.peek(),\n      label: this._options.column.colId.peek(),\n      isFormula: true,\n      formula: this._options.column.formula.peek(),\n      widgetOptions: JSON.stringify(this._options.field?.widgetOptionsJson()),\n    }]);\n\n    this._transformColRef = colRef;\n    this._transformColId = colId;\n\n    const rules = this._options.field?.rulesList();\n    if (rules) {\n      await docData.sendAction([\"UpdateRecord\", \"_grist_Tables_column\", colRef, {\n        rules: this._options.field?.rulesList(),\n      }]);\n    }\n\n    this._options.field?.colRef(colRef); // Don't save, it is only in browser.\n\n    // Update the transform column so that it points to the original column.\n    const transformColumn = this._options.field?.column.peek();\n    if (transformColumn) {\n      transformColumn.isTransforming(true);\n      this._options.column.isTransforming(true);\n      transformColumn.origColRef(this._options.column.getRowId()); // Don't save\n    }\n  }\n\n  private async _cleanupPreview() {\n    // Mark that we did finalize already.\n    this._triggerFinalize = noop;\n    const docData = this._options.gristDoc.docData;\n    const tableId = this._options.column.table.peek().tableId.peek();\n    const column = this._options.column;\n    try {\n      if (this._action === \"save\") {\n        const formula = this._options.editor.getCellValue();\n        // Modify column right away, so that it looks smoother on the ui, when we\n        // switch the column for the field.\n        await docData.sendActions([\n          [\"ModifyColumn\", tableId, column.colId.peek(), { formula, isFormula: true }],\n        ]);\n      }\n      // Switch the column for the field, this isn't sending any actions, we are just restoring it to what it is\n      // in database. But now the column has already correct data as it was already calculated.\n      this._options.field?.colRef(column.getRowId());\n\n      // Now trigger the action in our owner that should dispose us. The save\n      // method will be no op if we saved anything.\n      if (this._action === \"save\") {\n        commands.allCommands.fieldEditSaveHere.run();\n      } else if (this._action === \"cancel\") {\n        commands.allCommands.fieldEditCancel.run();\n      } else {\n        if (this._action !== \"close\") {\n          throw new Error(\"Unexpected value for _action\");\n        }\n        if (!this.isDisposed()) {\n          commands.allCommands.fieldEditCancel.run();\n        }\n      }\n      await docData.sendActions([\n        [\"RemoveColumn\", tableId, this._transformColId],\n      ]);\n    } finally {\n      // Repeat the change, in case of an error.\n      this._options.field?.colRef(column.getRowId());\n      column.isTransforming(false);\n    }\n  }\n\n  private _collapseChatPanel() {\n    if (!this._assistantExpanded.get()) { return; }\n\n    this._assistantExpanded.set(false);\n    // The panel's height and client height may differ; to ensure the collapse transition\n    // appears linear, temporarily disable the transition and sync the height and client\n    // height.\n    this._chatPanelBody.style.setProperty(\"transition\", \"none\");\n    this._chatPanelBody.style.setProperty(\"height\", `${this._chatPanelBody.clientHeight}px`);\n    // eslint-disable-next-line @typescript-eslint/no-unused-expressions, no-unused-expressions\n    this._chatPanelBody.offsetHeight; // Flush CSS changes.\n    this._chatPanelBody.style.removeProperty(\"transition\");\n    this._chatPanelBody.style.setProperty(\"height\", \"0px\");\n    this._resizeEditor();\n  }\n\n  private _expandChatPanel() {\n    if (!this._hasExpandedOnce) {\n      this._logTelemetryEvent(\"assistantOpen\", true);\n      this._hasExpandedOnce = true;\n    }\n    if (this._assistantExpanded.get()) { return; }\n\n    this._assistantExpanded.set(true);\n    const editor = this._options.editor.getDom();\n    let availableSpace = editor.clientHeight - MIN_FORMULA_EDITOR_HEIGHT_PX -\n      FORMULA_EDITOR_BUTTONS_HEIGHT_PX - CHAT_PANEL_HEADER_HEIGHT_PX;\n    if (editor.querySelector(\".error_msg\")) {\n      availableSpace -= editor.querySelector(\".error_msg\")!.clientHeight;\n    }\n    if (editor.querySelector(\".error_details\")) {\n      availableSpace -= editor.querySelector(\".error_details\")!.clientHeight;\n    }\n    if (this._lastChatPanelHeight) {\n      const height = Math.min(Math.max(this._lastChatPanelHeight, 220), availableSpace);\n      this._chatPanelBody.style.setProperty(\"height\", `${height}px`);\n      this._lastChatPanelHeight = height;\n    } else {\n      this._lastChatPanelHeight = availableSpace;\n      this._chatPanelBody.style.setProperty(\"height\", `${this._lastChatPanelHeight}px`);\n    }\n    this._resizeEditor();\n  }\n\n  private _onResizeStart() {\n    this._isResizing.set(true);\n    const start = this._domElement?.clientHeight;\n    const total = this._options.editor.getDom().clientHeight;\n    return {\n      start, total,\n    };\n  }\n\n  /**\n   * Resize handler for the chat window.\n   */\n  private _onResizeMove(x: number, y: number, { start, total}: { start: number, total: number }): void {\n    // The y axis includes the panel header and formula editor buttons; excluded them from the\n    // new height of the panel body.\n    const newChatPanelBodyHeight = start - y - CHAT_PANEL_HEADER_HEIGHT_PX - FORMULA_EDITOR_BUTTONS_HEIGHT_PX;\n\n    // Toggle `_isResizing` whenever the new panel body height crosses the threshold for the minimum\n    // height. As of now, the sole purpose of this observable is to control when the animation for\n    // expanding and collapsing is shown.\n    if (newChatPanelBodyHeight < MIN_CHAT_PANEL_BODY_HEIGHT_PX && this._isResizing.get()) {\n      this._isResizing.set(false);\n    } else if (newChatPanelBodyHeight >= MIN_CHAT_PANEL_BODY_HEIGHT_PX && !this._isResizing.get()) {\n      this._isResizing.set(true);\n    }\n\n    const collapseThreshold = 78;\n    if (newChatPanelBodyHeight < collapseThreshold) {\n      this._collapseChatPanel();\n    } else {\n      this._expandChatPanel();\n      const calculatedHeight = Math.max(\n        MIN_CHAT_PANEL_BODY_HEIGHT_PX,\n        Math.min(total - MIN_FORMULA_EDITOR_HEIGHT_PX, newChatPanelBodyHeight),\n      );\n      this._chatPanelBody.style.height = `${calculatedHeight}px`;\n    }\n  }\n\n  private _onResizeEnd() {\n    this._isResizing.set(false);\n    if (this._assistantExpanded.get()) {\n      this._lastChatPanelHeight = this._chatPanelBody.clientHeight;\n    }\n  }\n\n  private async _sendMessage(message: string) {\n    return await askAI(this._gristDoc, {\n      column: this._options.column,\n      description: message,\n      conversationId: this._chat.conversationId,\n      state: this._history.get().state,\n    });\n  }\n\n  private async _applyFormula(formula: string) {\n    this._options.editor.setFormula(formula);\n    this._resizeEditor();\n    await this._preview();\n  }\n}\n\n/**\n * Sends the message to the backend and returns the response.\n */\nasync function askAI(grist: GristDoc, options: {\n  column: ColumnRec,\n  description: string,\n  conversationId: string,\n  state?: AssistanceState\n}) {\n  const { column, description, conversationId, state } = options;\n  const tableId = column.table.peek().tableId.peek();\n  const colId = column.colId.peek();\n  return await grist.docComm.getAssistance({\n    conversationId,\n    context: { tableId, colId },\n    text: description,\n    state,\n  });\n}\n\nfunction buildIntroMessage(...args: DomElementArg[]) {\n  return cssAiIntroMessage(\n    cssAvatar(cssAiImage()),\n    dom(\"div\",\n      cssAiMessageParagraph(t(`Hi, I'm the Grist Formula AI Assistant.`)),\n      cssAiMessageParagraph(\n        t(`There are some things you should know when working with me:`),\n      ),\n      cssAiMessageParagraph(\n        cssAiMessageBullet(\n          cssTickIcon(\"Tick\"),\n          t(\n            \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\",\n          ),\n        ),\n        cssAiMessageBullet(\n          cssTickIcon(\"Tick\"),\n          t(\n            'Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\n\"Please calculate the total invoice amount.\"',\n          ),\n        ),\n        getGristConfig().assistant?.provider === \"OpenAI\" ?\n          cssAiMessageBullet(\n            cssTickIcon(\"Tick\"),\n            dom(\"div\",\n              t(\n                \"When you talk to me, your questions and your document structure (visible in {{codeView}}) \\\nare sent to OpenAI. {{learnMore}}.\",\n                {\n                  codeView: cssLink(\n                    t(\"Code view\"),\n                    urlState().setLinkUrl({ docPage: \"code\" }),\n                  ),\n                  learnMore: cssLink(t(\"Learn more\"), {\n                    href: commonUrls.helpFormulaAssistantDataUse,\n                    target: \"_blank\",\n                  }),\n                },\n              ),\n            ),\n          ) :\n          null,\n      ),\n      cssAiMessageParagraph(\n        t(\n          \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, \\\nor visit our {{community}} for more help.\",\n          {\n            functionList: cssLink(t(\"Function List\"), {\n              href: commonUrls.functions,\n              target: \"_blank\",\n            }),\n            formulaCheatSheet: cssLink(t(\"Formula Cheat Sheet\"), {\n              href: commonUrls.formulaSheet,\n              target: \"_blank\",\n            }),\n            community: cssLink(t(\"Community\"), {\n              href: commonUrls.community,\n              target: \"_blank\",\n            }),\n          },\n        ),\n      ),\n    ),\n    ...args,\n  );\n}\n\nconst MIN_FORMULA_EDITOR_HEIGHT_PX = 100;\n\nconst FORMULA_EDITOR_BUTTONS_HEIGHT_PX = 42;\n\nconst MIN_CHAT_PANEL_BODY_HEIGHT_PX = 180;\n\nconst CHAT_PANEL_HEADER_HEIGHT_PX = 30;\n\nexport const cssAiIntroMessage = styled(cssAiMessage, `\n  border-top: unset;\n`);\n\nexport const cssAiMessageParagraph = styled(\"div\", `\n  margin-bottom: 8px;\n`);\n\nconst cssAiMessageBullet = styled(\"div\", `\n  display: flex;\n  align-items: flex-start;\n  margin-bottom: 6px;\n`);\n\nconst cssTickIcon = styled(icon, `\n  --icon-color: ${theme.accentIcon};\n  margin-right: 8px;\n  flex-shrink: 0;\n`);\n\nconst cssChatPanel = styled(\"div\", `\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  overflow:hidden;\n  flex-grow: 1;\n`);\n\nconst cssChatPanelHeader = styled(\"div\", `\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  flex-shrink: 0;\n  padding: 0px 8px 0px 8px;\n  background-color: ${theme.formulaAssistantHeaderBg};\n  height: ${CHAT_PANEL_HEADER_HEIGHT_PX}px;\n  border-top: 1px solid ${theme.formulaAssistantBorder};\n  border-bottom: 1px solid ${theme.formulaAssistantBorder};\n`);\n\nconst cssChatPanelHeaderTitle = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  color: ${theme.lightText};\n  --icon-color: ${theme.accentIcon};\n  column-gap: 8px;\n  user-select: none;\n`);\n\nconst cssChatPanelHeaderButtons = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  column-gap: 8px;\n`);\n\nconst cssChatPanelHeaderButton = styled(\"div\", `\n  --icon-color: ${theme.controlSecondaryFg};\n  border-radius: 3px;\n  padding: 3px;\n  cursor: pointer;\n  user-select: none;\n  &:hover, &.weasel-popup-open {\n    background-color: ${theme.hover};\n  }\n`);\n\nconst cssChatPanelHeaderResizer = styled(\"div\", `\n  position: absolute;\n  top: -3px;\n  height: 7px;\n  width: 100%;\n  cursor: ns-resize;\n`);\n\nconst cssChatPanelBody = styled(\"div\", `\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n  flex-grow: 1;\n  transition: height 0.4s;\n\n  &-resizing {\n    transition: unset;\n  }\n`);\n\nconst cssButtons = styled(\"div\", `\n  display: flex;\n  justify-content: flex-end;\n  gap: 8px;\n  padding: 8px;\n`);\n\nconst cssTools = styled(\"div._tools_container\", `\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n`);\n\nconst cssChatOptionsMenu = styled(\"div\", `\n  z-index: ${vars.floatingPopupMenuZIndex};\n`);\n"
  },
  {
    "path": "app/client/widgets/FormulaEditor.ts",
    "content": "import * as AceEditor from \"app/client/components/AceEditor\";\nimport { CommandName } from \"app/client/components/commandList\";\nimport * as commands from \"app/client/components/commands\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ColumnRec } from \"app/client/models/DocModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { reportError } from \"app/client/models/errors\";\nimport { hoverTooltip } from \"app/client/ui/tooltips\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { colors, testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { createMobileButtons, getButtonMargins } from \"app/client/widgets/EditorButtons\";\nimport { EditorPlacement, ISize } from \"app/client/widgets/EditorPlacement\";\nimport { createDetachedIcon } from \"app/client/widgets/FloatingEditor\";\nimport { FormulaAssistant } from \"app/client/widgets/FormulaAssistant\";\nimport { NewBaseEditor, Options } from \"app/client/widgets/NewBaseEditor\";\nimport { asyncOnce } from \"app/common/AsyncCreate\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { isRaisedException } from \"app/common/gristTypes\";\nimport { undef } from \"app/common/gutil\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { decodeObject, RaisedException } from \"app/plugin/objtypes\";\n\nimport { Computed, Disposable, dom, Holder, MultiHolder, Observable, styled, subscribe } from \"grainjs\";\nimport debounce from \"lodash/debounce\";\n\n// How wide to expand the FormulaEditor when an error is shown in it.\nconst minFormulaErrorWidth = 400;\nconst t = makeT(\"FormulaEditor\");\n\nexport interface IFormulaEditorOptions extends Options {\n  cssClass?: string;\n  editingFormula: ko.Computed<boolean>;\n  column: ColumnRec;\n  field?: ViewFieldRec;\n  canDetach?: boolean;\n}\n\n/**\n * Required parameters:\n * @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.\n * @param {Object} options.cellValue: The value in the underlying cell being edited.\n * @param {String} options.editValue: String to be edited.\n * @param {Number} options.cursorPos: The initial position where to place the cursor.\n * @param {Object} options.commands: Object mapping command names to functions, to enable as part\n *  of the command group that should be activated while the editor exists.\n * @param {Boolean} options.omitBlurEventForObservableMode: Flag to indicate whether ace editor\n *  should save the value on `blur` event.\n */\nexport class FormulaEditor extends NewBaseEditor {\n  public isDetached = Observable.create(this, false);\n  protected options: IFormulaEditorOptions;\n\n  private _aceEditor: any;\n  private _dom: HTMLElement;\n  private _editorPlacement!: EditorPlacement;\n  private _placementHolder = Holder.create(this);\n  private _canDetach: boolean;\n  private _isEmpty: Computed<boolean>;\n\n  constructor(options: IFormulaEditorOptions) {\n    super(options);\n\n    const editingFormula = options.editingFormula;\n\n    const initialValue = undef(options.state as string | undefined, options.editValue, String(options.cellValue));\n    // create editor state observable (used by draft and latest position memory)\n    this.editorState = Observable.create(this, initialValue);\n\n    this._isEmpty = Computed.create(this, this.editorState, (_use, state) => state === \"\");\n\n    this._aceEditor = AceEditor.create({\n      // A bit awkward, but we need to assume calcSize is not used until attach() has been called\n      // and _editorPlacement created.\n      calcSize: this._calcSize.bind(this),\n      saveValueOnBlurEvent: !options.readonly,\n      editorState: this.editorState,\n      readonly: options.readonly,\n      getSuggestions: this._getSuggestions.bind(this),\n    });\n\n    // For editable editor we will grab the cursor when we are in the formula editing mode.\n    const cursorCommands = options.readonly ? {} : { setCursor: this._onSetCursor };\n    const isActive = Computed.create(this, use => Boolean(use(editingFormula)));\n    const commandGroup = this.autoDispose(commands.createGroup(cursorCommands, this, isActive));\n\n    // We will create a group of editor commands right away.\n    const editorGroup = this.autoDispose(commands.createGroup({\n      ...options.commands,\n    }, this, true));\n\n    // Merge those two groups into one.\n    const aceCommands: any = {\n      knownKeys: { ...commandGroup.knownKeys, ...editorGroup.knownKeys },\n      commands: { ...commandGroup.commands, ...editorGroup.commands },\n    };\n\n    // Tab, Shift + Tab, Enter should be handled by the editor itself when we are in the detached mode.\n    // We will create disabled group, but will push those commands to the editor directly.\n    const passThrough = (name: CommandName) => () => {\n      if (this.isDetached.get()) {\n        // For detached editor, just leave the default behavior.\n        return true;\n      }\n      // Else invoke regular command.\n      return commands.allCommands[name]?.run() ?? false;\n    };\n    const detachedCommands = this.autoDispose(commands.createGroup({\n      nextField: passThrough(\"nextField\"),\n      prevField: passThrough(\"prevField\"),\n      fieldEditSave: passThrough(\"fieldEditSave\"),\n    }, this, false /* don't activate, we're just borrowing constructor */));\n\n    Object.assign(aceCommands.knownKeys, detachedCommands.knownKeys);\n    Object.assign(aceCommands.commands, detachedCommands.commands);\n\n    const hideErrDetails = Observable.create(this, true);\n    const raisedException = Computed.create(this, (use) => {\n      const formulaError = options.formulaError && use(options.formulaError);\n      if (!formulaError) {\n        return null;\n      }\n      const error = isRaisedException(formulaError) ?\n        decodeObject(formulaError) as RaisedException :\n        new RaisedException([\"Unknown error\"]);\n      return error;\n    });\n    const errorText = Computed.create(this, raisedException, (_, error) => {\n      if (!error) {\n        return \"\";\n      }\n      return error.message ? `${error.name} : ${error.message}` : error.name;\n    });\n    const errorDetails = Computed.create(this, raisedException, (_, error) => {\n      if (!error) {\n        return \"\";\n      }\n      return error.details ?? \"\";\n    });\n\n    // Once the exception details are available, update the sizing. The extra delay is to allow\n    // the DOM to update before resizing.\n    this.autoDispose(errorDetails.addListener(() => setTimeout(this.resize.bind(this), 0)));\n\n    this._canDetach = Boolean(options.canDetach && !options.readonly);\n\n    this.autoDispose(this._aceEditor);\n\n    // Show placeholder text when the formula is blank.\n    this._isEmpty.addListener(() => this._updateEditorPlaceholder());\n\n    // Disable undo/redo while the editor is detached.\n    this.isDetached.addListener((isDetached) => {\n      // TODO: look into whether we can support undo/redo while the editor is detached.\n      if (isDetached) {\n        options.gristDoc.getUndoStack().disable();\n      } else {\n        options.gristDoc.getUndoStack().enable();\n      }\n    });\n\n    this.onDispose(() => {\n      options.gristDoc.getUndoStack().enable();\n    });\n\n    this._dom = cssFormulaEditor(\n      // switch border shadow\n      dom.cls(\"readonly_editor\", options.readonly),\n      createMobileButtons(options.commands),\n      options.cssClass ? dom.cls(options.cssClass) : null,\n\n      // This shouldn't be needed, but needed for tests.\n      dom.on(\"mousedown\", (ev) => {\n        // If we are detached, allow user to click and select error text.\n        if (this.isDetached.get()) {\n          // If we clicked on input element in our dom, don't do anything. We probably clicked on chat input, in AI\n          // tools box.\n          const clickedOnInput = ev.target instanceof HTMLInputElement || ev.target instanceof HTMLTextAreaElement;\n          if (clickedOnInput && this._dom.contains(ev.target)) {\n            // By not doing anything special here we assume that the input element will take the focus.\n            return;\n          }\n        }\n        // Allow clicking the error message.\n        if (ev.target instanceof HTMLElement && (\n          ev.target.classList.contains(\"error_msg\") ||\n          ev.target.classList.contains(\"error_details_inner\")\n        )) {\n          return;\n        }\n        ev.preventDefault();\n        this.focus();\n      }),\n      !this._canDetach ? null : createDetachedIcon(\n        hoverTooltip(t(\"Expand Editor\")),\n        dom.hide(this.isDetached),\n      ),\n      cssFormulaEditor.cls(\"-detached\", this.isDetached),\n      dom(\"div.formula_editor.formula_field_edit\", testId(\"formula-editor\"),\n        this._aceEditor.buildDom((aceObj: any) => {\n          initializeAceOptions(aceObj);\n          const val = initialValue;\n          const pos = Math.min(options.cursorPos, val.length);\n          this._aceEditor.setValue(val, pos);\n          this._aceEditor.attachCommandGroup(aceCommands);\n\n          // enable formula editing if state was passed\n          if (options.state || options.readonly) {\n            editingFormula(true);\n          }\n          if (options.readonly) {\n            this._aceEditor.enable(false);\n            aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything\n          }\n          // This catches any change to the value including e.g. via backspace or paste.\n          aceObj.once(\"change\", () => {\n            editingFormula?.(true);\n          });\n\n          if (val === \"\") {\n            // Show placeholder text if the formula is blank.\n            this._updateEditorPlaceholder();\n          }\n        }),\n      ),\n      dom.maybe(options.formulaError, () => [\n        dom(\"div.error_msg\", testId(\"formula-error-msg\"),\n          dom.attr(\"tabindex\", \"-1\"),\n          dom.maybe(errorDetails, () =>\n            dom.domComputed(hideErrDetails, hide => cssCollapseIcon(\n              hide ? \"Expand\" : \"Collapse\",\n              testId(\"formula-error-expand\"),\n              dom.on(\"click\", () => {\n                if (errorDetails.get()) {\n                  hideErrDetails.set(!hideErrDetails.get());\n                  this._aceEditor.resize();\n                }\n              }),\n            )),\n          ),\n          dom.text(errorText),\n        ),\n        dom.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () =>\n          dom(\"div.error_details\",\n            dom.attr(\"tabindex\", \"-1\"),\n            dom(\"div.error_details_inner\",\n              dom.text(errorDetails),\n            ),\n            testId(\"formula-error-details\"),\n          ),\n        ),\n      ]),\n      dom.maybe(this.isDetached, () => {\n        return dom.create(FormulaAssistant, {\n          column: this.options.column,\n          field: this.options.field,\n          gristDoc: this.options.gristDoc,\n          editor: this,\n        });\n      }),\n    );\n  }\n\n  public attach(cellElem: Element): void {\n    this.isDetached.set(false);\n    this._editorPlacement = EditorPlacement.create(\n      this._placementHolder, this._dom, cellElem, { margins: getButtonMargins() });\n    // Reposition the editor if needed for external reasons (in practice, window resize).\n    this.autoDispose(this._editorPlacement.onReposition.addListener(this._aceEditor.resize, this._aceEditor));\n    this._aceEditor.onAttach();\n    this._updateEditorPlaceholder();\n    this._aceEditor.resize();\n    this.focus();\n  }\n\n  public getDom(): HTMLElement {\n    return this._dom;\n  }\n\n  public setFormula(formula: string) {\n    this._aceEditor.setValue(formula);\n  }\n\n  public getCellValue() {\n    const value = this._aceEditor.getValue();\n    // Strip the leading \"=\" sign, if any, in case users think it should start the formula body (as\n    // it does in Excel, and because the equal sign is also used for formulas in Grist UI).\n    return (value[0] === \"=\") ? value.slice(1) : value;\n  }\n\n  public getTextValue() {\n    return this._aceEditor.getValue();\n  }\n\n  public getCursorPos() {\n    const aceObj = this._aceEditor.getEditor();\n    return aceObj.getSession().getDocument().positionToIndex(aceObj.getCursorPosition());\n  }\n\n  public focus() {\n    if (this.isDisposed()) { return; }\n    this._aceEditor.getEditor().focus();\n  }\n\n  public resize() {\n    if (this.isDisposed()) { return; }\n    this._aceEditor.resize();\n  }\n\n  public detach() {\n    // Remove the element from the dom (to prevent any autodispose) from happening.\n    this._dom.parentNode?.removeChild(this._dom);\n    // First mark that we are detached, to show the buttons,\n    // and halt the autosizing mechanism.\n    this.isDetached.set(true);\n    // Finally, destroy the normal inline placement helper.\n    this._placementHolder.clear();\n    // We are going in the full formula edit mode right away.\n    this.options.editingFormula(true);\n    this._updateEditorPlaceholder();\n    // Set the focus in timeout, as the dom is added after this function.\n    setTimeout(() => !this.isDisposed() && this._aceEditor.resize(), 0);\n    // Return the dom, it will be moved to the floating editor.\n    return this._dom;\n  }\n\n  private _updateEditorPlaceholder() {\n    const editor = this._aceEditor.getEditor();\n    const shouldShowPlaceholder = editor.session.getValue().length === 0;\n    if (editor.renderer.emptyMessageNode) {\n      // Remove the current placeholder if one is present.\n      editor.renderer.scroller.removeChild(editor.renderer.emptyMessageNode);\n    }\n    if (!shouldShowPlaceholder) {\n      editor.renderer.emptyMessageNode = null;\n    } else {\n      const withAiButton =\n        this._canDetach &&\n        !this.isDetached.get() &&\n        getGristConfig().assistant?.version === 1;\n      editor.renderer.emptyMessageNode = cssFormulaPlaceholder(\n        !withAiButton ?\n          t(\"Enter formula.\") :\n          t(\"Enter formula or {{button}}.\", {\n            button: cssUseAssistantButton(\n              t(\"use AI Assistant\"),\n              dom.on(\"click\", ev => this._handleUseAssistantButtonClick(ev)),\n              testId(\"formula-editor-use-ai-assistant\"),\n            ),\n          }),\n      );\n      editor.renderer.scroller.appendChild(editor.renderer.emptyMessageNode);\n    }\n  }\n\n  private _handleUseAssistantButtonClick(ev: MouseEvent) {\n    ev.stopPropagation();\n    ev.preventDefault();\n    commands.allCommands.detachEditor.run();\n    commands.allCommands.activateAssistant.run();\n  }\n\n  private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {\n    if (this.isDetached.get()) {\n      // If we are detached, we will stop autosizing.\n      return {\n        height: 0,\n        width: 0,\n      };\n    }\n\n    const placeholder: HTMLElement | undefined = this._aceEditor.getEditor().renderer.emptyMessageNode;\n    if (placeholder) {\n      // If we are showing the placeholder, fit it all on the same line.\n      return this._editorPlacement.calcSizeWithPadding(elem, {\n        width: placeholder.scrollWidth,\n        height: placeholder.scrollHeight,\n      });\n    }\n\n    const errorBox: HTMLElement | null = this._dom.querySelector(\".error_details\");\n    const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0;\n    const errorBoxDesiredHeight = errorBox?.scrollHeight || 0;\n\n    // If we have an error to show, ask for a larger size for formulaEditor.\n    const desiredSize = {\n      width: Math.max(desiredElemSize.width, (this.options.formulaError?.get() ? minFormulaErrorWidth : 0)),\n      // Ask for extra space for the error; we'll decide how to allocate it below.\n      height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),\n    };\n    const result = this._editorPlacement.calcSizeWithPadding(elem, desiredSize);\n    if (errorBox) {\n      // Note that result.height does not include errorBoxStartHeight, but includes any available\n      // extra space that we requested.\n      const availableForError = errorBoxStartHeight + (result.height - desiredElemSize.height);\n      // This is the key calculation: if space is available, use it; if not, give 64px to error\n      // (it'll scroll within that), but don't use more than desired.\n      const errorBoxEndHeight = Math.min(errorBoxDesiredHeight, Math.max(availableForError, 64));\n      errorBox.style.height = `${errorBoxEndHeight}px`;\n      result.height -= (errorBoxEndHeight - errorBoxStartHeight);\n    }\n    return result;\n  }\n\n  private _getSuggestions(prefix: string) {\n    const section = this.options.gristDoc.viewModel.activeSection();\n    // If section is disposed or is pointing to an empty row, don't try to autocomplete.\n    if (!section?.getRowId()) { return []; }\n\n    const tableId = section.table().tableId();\n    const columnId = this.options.column.colId();\n    const rowId = section.activeRowId();\n    return this.options.gristDoc.docComm.autocomplete(prefix, tableId, columnId, rowId);\n  }\n\n  // TODO: update regexes to unicode?\n  private _onSetCursor(row?: DataRowModel, col?: ViewFieldRec) {\n    // Don't do anything when we are readonly.\n    if (this.options.readonly) { return; }\n    // If we don't have column information, we can't insert anything.\n    if (!col) { return; }\n\n    const colId = col.origCol.peek().colId.peek();\n\n    if (col.tableId.peek() !== this.options.column.table.peek().tableId.peek()) {\n      // Fall back to default behavior if cursor didn't move to a column in the same table.\n      this.options.gristDoc.onSetCursorPos(row, col).catch(reportError);\n      return;\n    }\n\n    const aceObj = this._aceEditor.getEditor();\n    if (!aceObj.selection.isEmpty()) {\n      // If text selected, replace whole selection\n      aceObj.session.replace(aceObj.selection.getRange(), \"$\" + colId);\n    } else {\n      // Not a selection, gotta figure out what to replace\n      const pos = aceObj.getCursorPosition();\n      const line = aceObj.session.getLine(pos.row);\n      const result = _isInIdentifier(line, pos.column); // returns {start, end, id} | null\n      if (!result) {\n        // Not touching an identifier, insert colId as normal\n        aceObj.insert(\"$\" + colId);\n        // We are touching an identifier\n      } else if (result.ident.startsWith(\"$\")) {\n        // If ident is a colId, replace it\n        const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end);\n        aceObj.session.replace(idRange, \"$\" + colId);\n      }\n      // Else touching a normal identifier, don't mangle it\n    }\n    // Resize editor in case it is needed.\n    this._aceEditor.resize();\n\n    // This focus method will try to focus a textarea immediately and again on setTimeout. But\n    // other things may happen by the setTimeout time, messing up focus. The reason the immediate\n    // call doesn't usually help is that this is called on 'mousedown' before its corresponding\n    // focus/blur occur. We can do a bit better by restoring focus immediately after blur occurs.\n    aceObj.focus();\n    const lis = dom.onElem(aceObj.textInput.getElement(), \"blur\", (e) => { lis.dispose(); aceObj.focus(); });\n    // If no blur right away, clear the listener, to avoid unexpected interference.\n    setTimeout(() => lis.dispose(), 0);\n  }\n}\n\n// returns whether the column in that line is inside or adjacent to an identifier\n// if yes, returns {start, end, ident}, else null\nfunction _isInIdentifier(line: string, column: number) {\n  // If cursor is in or after an identifier, scoot back to the start of it\n  const prefix = line.slice(0, column);\n  let startOfIdent = prefix.search(/[$A-Za-z0-9_]+$/);\n  if (startOfIdent < 0) { startOfIdent = column; } // if no match, maybe we're right before it\n\n  // We're either before an ident or nowhere near one. Try to match to its end\n  const match = line.slice(startOfIdent).match(/^[$a-zA-Z0-9_]+/);\n  if (match) {\n    const ident = match[0];\n    return { ident, start: startOfIdent, end: startOfIdent + ident.length };\n  } else {\n    return null;\n  }\n}\n\n/**\n * Open a formula editor. Returns a Disposable that owns the editor.\n * This is used for the editor in the side panel.\n */\nexport function openFormulaEditor(options: {\n  gristDoc: GristDoc,\n  // Associated formula from a different column (for example style rule).\n  column?: ColumnRec,\n  // Associated formula from a view field. If provided together with column, this field is used\n  field?: ViewFieldRec,\n  editingFormula: ko.Computed<boolean>,\n  // Needed to get exception value, if any.\n  editRow?: DataRowModel,\n  // Element over which to position the editor.\n  refElem: Element,\n  editValue?: string,\n  onSave?: (column: ColumnRec, formula: string) => Promise<void>,\n  onCancel?: () => void,\n  canDetach?: boolean,\n  // Called after editor is created to set up editor cleanup (e.g. saving on click-away).\n  setupCleanup: (\n    owner: Disposable,\n    doc: GristDoc,\n    editingFormula: ko.Computed<boolean>,\n    save: () => Promise<void>,\n  ) => void,\n}): FormulaEditor {\n  const { gristDoc, editRow, refElem, setupCleanup } = options;\n  const attachedHolder = new MultiHolder();\n\n  if (options.field) {\n    options.column = options.field.origCol();\n  } else if (options.canDetach) {\n    throw new Error(\"Field is required for detached editor\");\n  }\n\n  // We can't rely on the field passed in, we need to create our own.\n  const column = options.column ?? options.field?.column();\n\n  if (!column) {\n    throw new Error(\"Column or field is required\");\n  }\n\n  // AsyncOnce ensures it's called once even if triggered multiple times.\n  const saveEdit = asyncOnce(async () => {\n    const detached = editor.isDetached.get();\n    if (detached) {\n      editor.dispose();\n      return;\n    }\n    const formula = String(editor.getCellValue());\n    if (formula !== column.formula.peek()) {\n      if (options.onSave) {\n        await options.onSave(column, formula);\n      } else {\n        await column.updateColValues({ formula });\n      }\n      editor.dispose();\n    } else {\n      editor.dispose();\n      options.onCancel?.();\n    }\n  });\n\n  // These are the commands for while the editor is active.\n  const editCommands = {\n    fieldEditSave: () => { saveEdit().catch(reportError); },\n    fieldEditSaveHere: () => { saveEdit().catch(reportError); },\n    fieldEditCancel: () => { editor.dispose(); options.onCancel?.(); },\n  };\n\n  const formulaError = editRow ? getFormulaError(attachedHolder, {\n    gristDoc,\n    editRow,\n    column,\n    field: options.field,\n  }) : undefined;\n  const editorOptions: IFormulaEditorOptions = {\n    gristDoc,\n    column,\n    field: options.field,\n    editingFormula: options.editingFormula,\n    rowId: editRow ? editRow.id() : 0,\n    cellValue: column.formula(),\n    formulaError,\n    editValue: options.editValue,\n    cursorPos: Number.POSITIVE_INFINITY,    // Position of the caret within the editor.\n    commands: editCommands,\n    cssClass: \"formula_editor_sidepane\",\n    readonly: false,\n    canDetach: options.canDetach,\n  };\n  const editor = FormulaEditor.create(null, editorOptions);\n  editor.autoDispose(attachedHolder);\n  editor.attach(refElem);\n\n  const editingFormula = options.editingFormula ?? options?.field?.editingFormula;\n\n  if (!editingFormula) {\n    throw new Error(t(\"editingFormula is required\"));\n  }\n\n  // When formula is empty enter formula-editing mode (highlight formula icons; click on a column inserts its ID).\n  // This function is used for primarily for switching between different column behaviors, so we want to enter full\n  // edit mode right away.\n  // TODO: consider converting it to parameter, when this will be used in different scenarios.\n  if (!column.formula()) {\n    editingFormula(true);\n  }\n  setupCleanup(editor, gristDoc, editingFormula, saveEdit);\n  return editor;\n}\n\n/**\n * If the cell at the given row and column is a formula value containing an exception, return an\n * observable with this exception, and fetch more details to add to the observable.\n */\nexport function getFormulaError(owner: Disposable, options: {\n  gristDoc: GristDoc,\n  editRow: DataRowModel,\n  column?: ColumnRec,\n  field?: ViewFieldRec,\n}): Observable<CellValue | undefined> {\n  const { gristDoc, editRow } = options;\n  const formulaError = Observable.create(owner, undefined as any);\n  // When we don't have a field information we don't need to be reactive at all.\n  if (!options.field) {\n    const column = options.column!;\n    const colId = column.colId.peek();\n    const onValueChange = errorMonitor(gristDoc, column, editRow, owner, formulaError);\n    const subscription = editRow.cells[colId].subscribe(onValueChange);\n    owner.autoDispose(subscription);\n    onValueChange(editRow.cells[colId].peek());\n    return formulaError;\n  } else {\n    // We can't rely on the editRow we got, as this is owned by the view. When we will be detached the view will be\n    // gone. So, we will create our own observable that will be updated when the row is updated.\n    const errorRow: DataRowModel = gristDoc.getTableModel(options.field.tableId.peek()).createFloatingRowModel() as any;\n    errorRow.assign(editRow.getRowId());\n    owner.autoDispose(errorRow);\n\n    // When we have a field information we will grab the error from the column that is currently connected to the field.\n    // This will change when user is using the preview feature in detached editor, where a new column is created, and\n    // field starts showing it instead of the original column.\n    Computed.create(owner, (use) => {\n      // This pattern creates a subscription using compute observable.\n\n      // Create an holder for everything that is created during recomputation. It will be returned as the value\n      // of the computed observable, and will be disposed when the value changes.\n      const holder = MultiHolder.create(use.owner);\n\n      // Now subscribe to the column in the field, this is the part that will be changed when user creates a preview.\n      const column = use(options.field!.column);\n      const colId = use(column.colId);\n      const onValueChange = errorMonitor(gristDoc, column, errorRow, holder, formulaError);\n      // Unsubscribe when computed is recomputed.\n      holder.autoDispose(errorRow.cells[colId].subscribe(onValueChange));\n      // Trigger the subscription to get the initial value.\n      onValueChange(errorRow.cells[colId].peek());\n\n      // Return the holder, it will be disposed when the value changes.\n      return holder;\n    });\n  }\n  return formulaError;\n}\n\nfunction errorMonitor(\n  gristDoc: GristDoc,\n  column: ColumnRec,\n  editRow: DataRowModel,\n  holder: Disposable,\n  formulaError: Observable<CellValue | undefined>) {\n  return  function onValueChange(cellCurrentValue: CellValue) {\n    const isFormula = column.isFormula() || column.hasTriggerFormula();\n    if (isFormula && isRaisedException(cellCurrentValue)) {\n      if (!formulaError.get()) {\n        // Don't update it when there is already an error (to avoid flickering).\n        formulaError.set(cellCurrentValue);\n      }\n      gristDoc.docData.getFormulaError(column.table().tableId(), column.colId(), editRow.getRowId())\n        .then((value) => {\n          if (holder.isDisposed()) { return; }\n          formulaError.set(value);\n        })\n        .catch((er) => {\n          if (!holder.isDisposed()) {\n            reportError(er);\n          }\n        });\n    } else {\n      formulaError.set(undefined);\n    }\n  };\n}\n\n/**\n * Create and return an observable for the count of errors in a column, which gets updated in\n * response to changes in origColumn and in user data.\n */\nexport function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, origColumn: ColumnRec) {\n  const errorMessage = Observable.create(owner, \"\");\n\n  // Count errors in origColumn when it's a formula column. Counts get cached by the\n  // tableData.countErrors() method, and invalidated on relevant data changes.\n  function countErrors() {\n    if (owner.isDisposed()) { return; }\n    const tableData = gristDoc.docData.getTable(origColumn.table.peek().tableId.peek());\n    const isFormula = origColumn.isRealFormula.peek() || origColumn.hasTriggerFormula.peek();\n    if (tableData && isFormula) {\n      const colId = origColumn.colId.peek();\n      const numCells = tableData.getColValues(colId)?.length || 0;\n      const numErrors = tableData.countErrors(colId) || 0;\n      errorMessage.set(\n        (numErrors === 0) ? \"\" :\n          (numCells === 1) ? t(`Error in the cell`) :\n            (numErrors === numCells) ? t(`Errors in all {{numErrors}} cells`, { numErrors }) :\n              t(`Errors in {{numErrors}} of {{numCells}} cells`, { numErrors, numCells }),\n      );\n    } else {\n      errorMessage.set(\"\");\n    }\n  }\n\n  // Debounce the count calculation to defer it to the end of a bundle of actions.\n  const debouncedCountErrors = debounce(countErrors, 0);\n\n  // If there is an update to the data in the table, count errors again. Since the same UI is\n  // reused when different page widgets are selected, we need to re-create this subscription\n  // whenever the selected table changes. We use a Computed to both react to changes and dispose\n  // the previous subscription when it changes.\n  Computed.create(owner, (use) => {\n    const tableData = gristDoc.docData.getTable(use(use(origColumn.table).tableId));\n    return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedCountErrors)) : null;\n  });\n\n  // The counts depend on the origColumn and its isRealFormula status, but with the debounced\n  // callback and subscription to data, subscribe to relevant changes manually (rather than using\n  // a Computed).\n  owner.autoDispose(subscribe((use) => { use(origColumn.id); use(origColumn.isRealFormula); debouncedCountErrors(); }));\n  return errorMessage;\n}\n\nexport function initializeAceOptions(aceObj: any) {\n  aceObj.setFontSize(11);\n  aceObj.setHighlightActiveLine(false);\n  aceObj.getSession().setUseWrapMode(false);\n  aceObj.renderer.setPadding(0);\n}\n\nconst cssCollapseIcon = styled(icon, `\n  margin: -3px 4px 0 4px;\n  --icon-color: ${colors.slate};\n  cursor: pointer;\n  position: sticky;\n  top: 0px;\n  flex-shrink: 0;\n`);\n\nexport const cssError = styled(\"div\", `\n  color: ${theme.errorText};\n`);\n\nconst cssFormulaEditor = styled(\"div.default_editor.formula_editor_wrapper\", `\n  &-detached {\n    height: 100%;\n    position: relative;\n    box-shadow: none;\n  }\n  &-detached .formula_editor {\n    flex-grow: 1;\n    min-height: 100px;\n  }\n\n  &-detached .error_msg, &-detached .error_details {\n    max-height: 100px;\n    flex-shrink: 0;\n  }\n\n  &-detached .code_editor_container {\n    height: 100%;\n    width: 100%;\n  }\n\n  &-detached .ace_editor {\n    height: 100% !important;\n    width: 100% !important;\n  }\n`);\n\nconst cssFormulaPlaceholder = styled(\"div\", `\n  color: ${theme.lightText};\n  font-style: italic;\n  white-space: nowrap;\n`);\n\nconst cssUseAssistantButton = styled(textButton, `\n  font-size: ${vars.smallFontSize};\n`);\n"
  },
  {
    "path": "app/client/widgets/HyperLinkEditor.ts",
    "content": "import { makeT } from \"app/client/lib/localization\";\nimport { FieldOptions } from \"app/client/widgets/NewBaseEditor\";\nimport { NTextEditor } from \"app/client/widgets/NTextEditor\";\n\nconst t = makeT(\"HyperLinkEditor\");\n\n/**\n * HyperLinkEditor - Is the same NTextEditor but with some placeholder text to help explain\n * to the user how links should be formatted.\n */\nexport class HyperLinkEditor extends NTextEditor {\n  constructor(options: FieldOptions) {\n    super(options);\n    this.textInput.setAttribute(\"placeholder\", t(\"[link label] url\"));\n  }\n}\n"
  },
  {
    "path": "app/client/widgets/HyperLinkTextBox.ts",
    "content": "import { sanitizeLinkUrl } from \"app/client/lib/sanitizeUrl\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { constructUrl } from \"app/client/models/gristUrlState\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { cssIconSpanBackground, iconSpan } from \"app/client/ui2018/icons\";\nimport { cssHoverIn, gristLink } from \"app/client/ui2018/links\";\nimport { NTextBox } from \"app/client/widgets/NTextBox\";\nimport { CellValue } from \"app/common/DocActions\";\n\nimport { Computed, dom, styled } from \"grainjs\";\n\n/**\n * Creates a widget for displaying links.  Links can entered directly or following a title.\n * The last entry following a space is used as the url.\n * ie 'google https://www.google.com' would apears as 'google' to the user but link to the url.\n */\nexport class HyperLinkTextBox extends NTextBox {\n  constructor(field: ViewFieldRec) {\n    super(field, { defaultTextColor: theme.link.toString() });\n  }\n\n  public buildDom(row: DataRowModel) {\n    const value = row.cells[this.field.colId()];\n    const url = Computed.create(\n      null,\n      use => sanitizeLinkUrl(constructUrl(use(value))) ?? \"about:blank\",\n    );\n    return cssFieldClip(\n      dom.autoDispose(url),\n      dom.style(\"text-align\", this.alignment),\n      dom.cls(\"text_wrapping\", this.wrapping),\n      dom.maybe(use => Boolean(use(value)), () =>\n        gristLink(url,\n          cssIconSpanBackground(\n            iconSpan(\"FieldLink\", testId(\"tb-link-icon\")),\n            dom.cls(cssHoverOnField.className),\n          ),\n          testId(\"tb-link\"),\n        ),\n      ),\n      dom.text(use => _formatValue(use(value))),\n    );\n  }\n}\n\n/**\n * Formats value like `foo bar baz` by discarding `baz` and returning `foo bar`.\n */\nfunction _formatValue(value: CellValue): string {\n  if (typeof value !== \"string\") { return \"\"; }\n  const index = value.lastIndexOf(\" \");\n  return index >= 0 ? value.slice(0, index) : value;\n}\n\nconst cssFieldClip = styled(\"div.field_clip\", `\n  color: var(--grist-actual-cell-color, ${theme.link});\n`);\n\nconst cssHoverOnField = cssHoverIn(cssFieldClip.className);\n"
  },
  {
    "path": "app/client/widgets/MarkdownTextBox.ts",
    "content": "import { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { renderCellMarkdown } from \"app/client/ui/MarkdownCellRenderer\";\nimport { theme, vars } from \"app/client/ui2018/cssVars\";\nimport { handleGristLinkClick } from \"app/client/ui2018/links\";\nimport { NTextBox } from \"app/client/widgets/NTextBox\";\n\nimport { dom, styled } from \"grainjs\";\n\n/**\n * Creates a widget for displaying Markdown-formatted text.\n */\nexport class MarkdownTextBox extends NTextBox {\n  constructor(field: ViewFieldRec) {\n    super(field);\n  }\n\n  public buildDom(row: DataRowModel) {\n    const valueObs = row.cells[this.field.colId()];\n\n    return dom(\n      \"div.field_clip\",\n      cssMarkdown(\n        cssMarkdown.cls(\"-text-wrap\", this.wrapping),\n        dom.style(\"text-align\", this.alignment),\n        dom.on(\"contextmenu\", (ev) => {\n          // Disable Grist cell context menu on links.\n          if ((ev.target as HTMLElement).closest(\"a\")) {\n            ev.stopPropagation();\n          }\n        }),\n        dom.onMatch(\"a\", \"click\", (ev, el) => handleGristLinkClick(ev as MouseEvent, el as HTMLAnchorElement)),\n        dom.domComputed(valueObs, value => renderCellMarkdown(String(value), {\n          onMarkedResolved: () => {\n            this.field.viewSection().events.trigger(\"rowHeightChange\");\n          },\n        })),\n      ),\n    );\n  }\n}\n\nexport const cssMarkdown = styled(\"div\", `\n  white-space: nowrap;\n\n  &-text-wrap {\n    white-space: normal;\n    word-break: break-word;\n  }\n  &:not(&-text-wrap) p,\n  &:not(&-text-wrap) h1,\n  &:not(&-text-wrap) h2,\n  &:not(&-text-wrap) h3,\n  &:not(&-text-wrap) h4,\n  &:not(&-text-wrap) h5,\n  &:not(&-text-wrap) h6\n  {\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n  & > *:first-child {\n     margin-top: 0px !important;\n  }\n  & > :not(blockquote, ol, pre, ul):last-child {\n     margin-bottom: 0px !important;\n  }\n  & h1, & h2, & h3, & h4, & h5, & h6 {\n    margin-top: 24px;\n    margin-bottom: 16px;\n    font-weight: 600;\n    line-height: 1.25;\n  }\n  & h1 {\n    padding-bottom: .3em;\n    font-size: 2em;\n  }\n  & h2 {\n    padding-bottom: .3em;\n    font-size: 1.5em;\n  }\n  & h3 {\n    font-size: 1.25em;\n  }\n  & h4 {\n    font-size: 1em;\n  }\n  & h5 {\n    font-size: .875em;\n  }\n  & h6 {\n    color: ${theme.lightText};\n    font-size: .85em;\n  }\n  & p, & blockquote, & ul, & ol, & dl, & pre {\n    margin-top: 0px;\n    margin-bottom: 10px;\n  }\n  & code, & pre {\n    color: ${theme.text};\n    font-size: 85%;\n    background-color: ${theme.markdownCellLightBg};\n    border: 0;\n    border-radius: 6px;\n  }\n  & code {\n    padding: .2em .4em;\n    margin: 0;\n    white-space: pre;\n  }\n  &-text-wrap code {\n    white-space: pre-wrap;\n  }\n  & pre {\n    padding: 16px;\n    overflow: auto;\n    line-height: 1.45;\n  }\n  & pre code {\n    font-size: 100%;\n    display: inline;\n    max-width: auto;\n    margin: 0;\n    padding: 0;\n    overflow: visible;\n    line-height: inherit;\n    word-wrap: normal;\n    background: transparent;\n  }\n  & pre > code {\n    background: transparent;\n    white-space: nowrap;\n    word-break: normal;\n    margin: 0;\n    padding: 0;\n  }\n  & pre .ace-chrome, & pre .ace-dracula {\n    background: ${theme.markdownCellLightBg} !important;\n  }\n  & .ace_indent-guide {\n    background: none;\n  }\n  & .ace_static_highlight {\n    white-space: pre;\n  }\n  &-text-wrap .ace_static_highlight {\n    white-space: pre-wrap;\n  }\n  & ul, & ol {\n    padding-left: 2em;\n  }\n  & li > ol, & li > ul {\n    margin: 0;\n  }\n  & li + li,\n  & li > ol > li:first-child,\n  & li > ul > li:first-child {\n    margin-top: .25em;\n  }\n  & blockquote {\n    font-size: ${vars.mediumFontSize};\n    border-left: .25em solid ${theme.markdownCellMediumBorder};\n    padding: 0 1em;\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/MentionTextBox.ts",
    "content": "import { ACIndexImpl, ACItem, ACResults, buildHighlightedDom, HighlightFunc } from \"app/client/lib/ACIndex\";\nimport { Autocomplete } from \"app/client/lib/autocomplete\";\nimport { makeTestId, onClickOutsideElem } from \"app/client/lib/domUtils\";\nimport { FocusLayer } from \"app/client/lib/FocusLayer\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { autoGrow } from \"app/client/ui/forms\";\nimport { createUserImage } from \"app/client/ui/UserImage\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { cssLink } from \"app/client/ui2018/links\";\nimport { gristFloatingMenuClass, menuCssClass } from \"app/client/ui2018/menus\";\nimport { splitTextWithMentions } from \"app/common/DocComments\";\nimport { canView } from \"app/common/roles\";\nimport { orderBy } from \"app/common/SortFunc\";\nimport { tokens } from \"app/common/ThemePrefs\";\nimport { getRealAccess, PermissionData, UserAccessData, UserProfile } from \"app/common/UserAPI\";\n\nimport {\n  Computed,\n  Disposable,\n  dom,\n  IDomArgs,\n  MultiHolder,\n  Observable,\n  styled,\n} from \"grainjs\";\n\nconst testId = makeTestId(\"test-mention-textbox-\");\nconst t = makeT(\"MentionTextBox\");\n\n/**\n * Some not-so-old Firefox ESR versions (<= 128) don't support \"plaintext-only\" as contenteditable value.\n *\n * We make sure to check for support before using it.\n * If it's not supported, we'll handle ourselves stripping pasted text formatting.\n * @see https://stackoverflow.com/a/18316972\n */\nconst supportsPlaintextEditables = () => {\n  const div = document.createElement(\"div\");\n  div.setAttribute(\"contenteditable\", \"PLAINTEXT-ONLY\");\n  return div.contentEditable === \"plaintext-only\";\n};\nlet _contentEditableValue: string | undefined;\nconst contentEditableValue = () => {\n  if (!_contentEditableValue) {\n    _contentEditableValue = supportsPlaintextEditables() ? \"plaintext-only\" : \"true\";\n  }\n  return _contentEditableValue;\n};\n\n/**\n * A tuple of text and mentions parsed from a comment text or HTML.\n */\nexport class CommentWithMentions {\n  /**\n   * Extracts comment text and mentions from an HTML element that was used as a markdown editor (not a rendered\n   * comment from markdown).\n   */\n  public static fromMarkdownEditor(elem: HTMLElement): CommentWithMentions {\n    const cloned = elem.cloneNode(true) as HTMLElement;\n    // This element can contain some div elements with menus, we don't need those, as we\n    // only expect spans here. Remove them.\n    const divs = cloned.querySelectorAll(\"div\");\n    for (const div of divs) {\n      div.remove();\n    }\n    const mentions = new Set<string>();\n    const mentionElements = cloned.querySelectorAll(\".grist-mention\");\n\n    for (const mention of mentionElements) {\n      const userRef = mention.getAttribute(\"data-userref\");\n      if (userRef) {\n        mentions.add(userRef);\n        const name = mention.textContent?.trim() || \"\";\n        // Replace the mention with markdown syntax.\n        const markdown = `[${name}](user:${userRef})`;\n        mention.replaceWith(markdown);\n      }\n    }\n\n    const text = (cloned.textContent || \"\").trim();\n    return new CommentWithMentions(text, Array.from(mentions));\n  }\n\n  public text: string;\n  public mentions: string[];\n\n  constructor(text: string = \"\", mentions: string[] = []) {\n    this.text = text;\n    this.mentions = mentions;\n  }\n\n  public isEmpty(): boolean {\n    return !this.text.trim();\n  }\n\n  /**\n   * Checks if the comment text is long enough to be preserved locally and restored later by the user.\n   */\n  public shouldBeRestored(): boolean {\n    return this.text.length >= 20;\n  }\n}\n\nexport function buildMentionTextBox(\n  content: Observable<CommentWithMentions>,\n  access: Observable<PermissionData | null>,\n  ...args: IDomArgs<HTMLSpanElement>\n) {\n  const owner = new MultiHolder();\n\n  const setHtml = (html: HTMLElement) => content.set(CommentWithMentions.fromMarkdownEditor(html));\n\n  function getTextBeforeCaret(node: Node, offset: number) {\n    if (node.nodeType === Node.TEXT_NODE) {\n      return node.textContent?.slice(0, offset) || \"\";\n    }\n    return \"\";\n  }\n\n  let mentionPicker: MentionPicker | undefined;\n\n  // Detects when user types '@' and builds a mention span with a picker.\n  function buildMentions() {\n    return (div: HTMLElement) => {\n      dom.update(div,\n        // For tests, inform when the access is ready.\n        testId(\"ready\", use => use(access) !== null),\n        dom.on(\"keydown\", (e: KeyboardEvent) => {\n          if (e.key === \"@\" && (!mentionPicker || mentionPicker.isDisposed())) {\n            const selection = window.getSelection();\n            if (!selection?.rangeCount) { return; }\n            const range = selection.getRangeAt(0);\n            const caretNode = range.startContainer;\n            const caretOffset = range.startOffset;\n            const textBeforeCaret = getTextBeforeCaret(caretNode, caretOffset);\n\n            const match = /(?:^|\\s)$/.exec(textBeforeCaret);\n            if (match) {\n              div.contentEditable = \"false\";\n\n              // Build the mention element and insert it at the caret position.\n              const mentionEl = buildMentionElement();\n              range.deleteContents();\n              range.insertNode(mentionEl);\n              range.setStartAfter(mentionEl);\n              selection.removeAllRanges();\n              selection.addRange(range);\n              e.preventDefault();\n\n              // Show the picker for mentions.\n              mentionPicker = new MentionPicker({\n                parent: div,\n                access,\n                mentionEl: mentionEl,\n                setHtml,\n              });\n              dom.autoDisposeElem(mentionEl, mentionPicker);\n\n              mentionEl.focus();\n\n              setHtml(div);\n            }\n          }\n        }),\n      );\n    };\n  }\n\n  const element = cssContentEditable(\n    dom.autoDispose(owner),\n    dom.on(\"input\", (_: Event, el: HTMLElement) => setHtml(el)),\n    autoGrow(content),\n    dom.attr(\"contentEditable\", contentEditableValue()),\n    /*\n     * In case contenteditable=\"plaintext-only\" is not supported,\n     * we handle ourselves stripping pasted text formatting.\n     *\n     * @see https://stackoverflow.com/a/58980415\n     */\n    dom.on(\"paste\", (e: ClipboardEvent) => {\n      if (contentEditableValue() === \"plaintext-only\") {\n        return;\n      }\n      e.preventDefault();\n      const text = e.clipboardData?.getData(\"text/plain\");\n      document.execCommand(\"insertText\", false, text);\n    }),\n    buildMentions(),\n    renderMarkdownForEditing(content.get().text || \"\"),\n    // Since markdown is rendered asynchronously, we need to ensure that the mentions render by it, have\n    // contentEditable set to false, so that they don't get edited.\n    enforceNotEditableChildren,\n    (el) => {\n      FocusLayer.create(owner, {\n        defaultFocusElem: el,\n        allowFocus: e => (e !== document.body),\n        pauseMousetrap: true,\n      });\n      setTimeout(() => {\n        el.focus();\n      });\n    },\n    ...args,\n  );\n\n  return element;\n}\n\nfunction renderMarkdownForEditing(text: string) {\n  return splitTextWithMentions(text).map(chunk =>\n    typeof chunk === \"string\" ?\n      chunk :\n      cssLink({ \"data-userref\": chunk.ref }, chunk.name, dom.cls(\"grist-mention\")),\n  );\n}\n\nfunction enforceNotEditableChildren(element: HTMLElement) {\n  const fillAttribute = () => {\n    const mentions = element.querySelectorAll(`.${MENTION_CLASS}`);\n    for (const mention of mentions) {\n      if (!mention.getAttribute(\"contentEditable\")) {\n        mention.setAttribute(\"contentEditable\", \"false\");\n      }\n    }\n  };\n  const observer = new MutationObserver(fillAttribute);\n  observer.observe(element, {\n    childList: true,\n    subtree: true,\n    characterData: true,\n  });\n  dom.onDisposeElem(element, () => {\n    observer.disconnect();\n  });\n  fillAttribute();\n}\n\ninterface MentionPickerProps {\n  parent: HTMLElement;\n  access: Observable<PermissionData | null>;\n  mentionEl: HTMLElement;\n  setHtml: (html: HTMLElement) => void;\n}\n\nconst MENTION_CLASS = \"grist-mention\";\nfunction buildMentionElement() {\n  return cssLink(dom.cls(MENTION_CLASS), \"@\", dom.attr(\"contentEditable\", contentEditableValue()));\n}\n\n/**\n * Component with autocomplete popup for mentioning users.\n */\nclass MentionPicker extends Disposable {\n  private _acIndex: Computed<ACIndexImpl<UserItem> | null>;\n  private _ac: Autocomplete<UserItem>;\n  private _hasData: Computed<boolean>;\n  private _mentionEl: HTMLElement;\n\n  constructor(private _props: MentionPickerProps) {\n    super();\n    this._mentionEl = _props.mentionEl;\n    this._hasData = Computed.create(this, use => use(_props.access) !== null);\n    this._acIndex = Computed.create(this, (use) => {\n      const access = use(_props.access);\n      if (!access) { return null; }\n      return new ACIndexImpl<UserItem>(access.users\n        .sort(orderBy(n => n.name || n.email))\n        .map(x => new UserItemImpl(x, access)));\n    });\n\n    // Focus layer.\n    FocusLayer.create(this, { defaultFocusElem: this._mentionEl, pauseMousetrap: true });\n\n    // Outside click handler.\n    this.autoDispose(onClickOutsideElem(this._mentionEl, () => this._convertToPlainText()));\n\n    // Focus on the mention element, and set the cursor after the '@' character.\n    const focusRange = document.createRange();\n    this._mentionEl.focus();\n    focusRange.setStart(this._mentionEl, 1);\n    focusRange.collapse();\n    const selection = window.getSelection()!;\n    selection.removeAllRanges();\n    selection.addRange(focusRange);\n\n    // Autocomplete popup.\n    this.autoDispose(this._ac = new Autocomplete<UserItem>(this._mentionEl, {\n      search: term => this._acIndex.get()?.search(term) ?? new NotReadyResult(),\n      renderItem: this._renderItem.bind(this),\n      getItemText: item => `@${item.name}`,\n      menuCssClass: `${gristFloatingMenuClass} ${menuCssClass}`,\n      onClick: () => this._acceptSelected(),\n      liveUpdate: false,\n      attach: this._parent,\n    }));\n\n    // If we don't have data yet, we will wait for it to be loaded, and trigger search after that.\n    if (!this._hasData.get()) {\n      this.autoDispose(this._acIndex.addListener((idx) => {\n        if (idx) {\n          this._ac.search();\n        }\n      }));\n    }\n\n    // Keyboard handlers.\n    this.autoDispose(dom.onKeyElem(this._mentionEl, \"keydown\", {\n      // Backspace will remove the mention element if it only contains '@'.\n      Backspace$: (ev) => {\n        if (this._mentionEl.textContent !== \"@\") {\n          return;\n        }\n        this._removeMention();\n        ev.preventDefault();\n        ev.stopPropagation();\n      },\n      // Escape converts the mention to plain text.\n      Escape: () => {\n        this._convertToPlainText();\n      },\n      // Enter without selected item converts to plain text.\n      // Enter with selected item accepts the mention.\n      Enter: () => {\n        if (!this._ac.getSelectedItem()) {\n          this._convertToPlainText();\n          return;\n        }\n        this._acceptSelected();\n      },\n      // Same as tab.\n      Tab: () => {\n        if (!this._ac.getSelectedItem()) {\n          this._convertToPlainText();\n          return;\n        }\n        this._acceptSelected();\n      },\n    }));\n  }\n\n  public dispose() {\n    // We expect multiple dispose calls based on various elements (like div, popup, row, view, etc).\n    if (this.isDisposed()) { return; }\n    super.dispose();\n  }\n\n  private get _parent() {\n    return this._props.parent;\n  }\n\n  private _updateTarget() {\n    this._parent.contentEditable = contentEditableValue();\n    this._parent.focus();\n    this._props.setHtml(this._parent);\n  }\n\n  private _removeMention() {\n    this._parent?.removeChild(this._mentionEl);\n    this._updateTarget();\n    this.dispose();\n  }\n\n  private _convertToPlainText() {\n    const mentionText = this._mentionEl.textContent || \"\";\n    const textNode = document.createTextNode(mentionText);\n    this._parent?.insertBefore(textNode, this._mentionEl.nextSibling);\n    this._parent?.removeChild(this._mentionEl);\n    this._cursorAfter(textNode);\n    this._updateTarget();\n    this.dispose();\n  }\n\n  private _acceptSelected() {\n    const selected = this._ac.getSelectedItem();\n    if (!selected || selected instanceof LoadingItem) { return; }\n\n    this._mentionEl.textContent = `@${selected.name}`;\n    this._mentionEl.contentEditable = \"false\";\n    this._mentionEl.setAttribute(\"data-userref\", selected.ref);\n\n    const blankText = document.createTextNode(\" \");\n    this._mentionEl.after(blankText);\n\n    this._cursorAfter(blankText);\n    this._updateTarget();\n    this.dispose();\n  }\n\n  private _renderItem(item: UserItem, highlightFunc: HighlightFunc) {\n    if (item instanceof LoadingItem) {\n      return cssAcItem(\n        dom(\"i\", t(\"...loading\")),\n        testId(\"loading\"),\n        testId(\"acitem\"),\n      );\n    }\n    return cssAcItem(\n      cssMentionAvatar(item.profile, \"small\"),\n      cssAcItem.cls(\"-disabled\", !item.hasAccess),\n      testId(\"disabled\", !item.hasAccess),\n      testId(\"acitem\"),\n      dom(\"span\",\n        buildHighlightedDom(item.name, highlightFunc, cssMatchText),\n        testId(\"acitem-text\"),\n      ),\n      dom.maybe(!item.hasAccess, () => dom(\"span\",\n        \"(\", t(\"no access\"), \")\"),\n      ),\n    );\n  }\n\n  private _cursorAfter(node: Node) {\n    const range = document.createRange();\n    range.setStartAfter(node);\n    range.collapse();\n    const selection = window.getSelection();\n    if (!selection) { return; }\n    selection.removeAllRanges();\n    selection.addRange(range);\n  }\n}\n\ninterface UserItem extends ACItem {\n  hasAccess: boolean;\n  ref: string;\n  profile: Partial<UserProfile>;\n  name: string;\n}\n\nclass UserItemImpl implements UserItem {\n  public readonly cleanText: string;\n  public get hasAccess() {\n    return canView(getRealAccess(this._user, this._access));\n  }\n\n  public get ref() {\n    return this._user.ref || \"\";\n  }\n\n  public get profile(): Partial<UserProfile> {\n    return this._user;\n  }\n\n  public get name() {\n    return this._user.name || this._user.email;\n  }\n\n  constructor(\n    private _user: UserAccessData,\n    private _access: PermissionData,\n  ) {\n    this.cleanText = (_user.name || _user.email).toLowerCase().trim();\n  }\n}\n\nclass LoadingItem implements UserItem {\n  public cleanText: string = \"loading\";\n  public hasAccess = true;\n  public ref = \"\";\n  public profile: Partial<UserProfile> = {};\n  public name = \"Loading...\";\n}\n\nclass NotReadyResult implements ACResults<UserItem> {\n  public items: UserItem[] = [new LoadingItem()];\n  public extraItems: UserItem[] = [];\n  public selectIndex = 0;\n  public highlightFunc: HighlightFunc = () => [];\n}\n\nconst cssAcItem = styled(\"li\", `\n  padding: 4px;\n  white-space: pre;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  cursor: pointer;\n  min-width: 120px;\n  padding: var(--weaseljs-menu-item-padding, 8px 24px);\n  display: flex;\n  gap: 4px;\n  align-items: center;\n  padding-left: 12px;\n  color: ${theme.menuItemFg};\n  background-color: ${theme.menuBg};\n  &.selected {\n    background-color: ${theme.menuItemSelectedBg};\n    color:            ${theme.menuItemSelectedFg};\n  }\n  &-disabled {\n    color: ${theme.disabledText};\n    background: ${tokens.bgSecondary};\n    font-style: italic;\n  }\n`);\n\nconst cssMatchText = styled(\"span\", `\n  color: ${theme.autocompleteMatchText};\n  .${cssAcItem.className}.selected & {\n    color: ${theme.autocompleteSelectedMatchText};\n  }\n`);\n\nconst cssMentionAvatar = styled(createUserImage, `\n  margin-top: 0px;\n  margin-right: 4px;\n`);\n\n// Note: \"white-space: pre-wrap\" is better than \"white-space: pre-line\" in that on Firefox, after\n// inserting a mention, the trailing space that gets inserted automatically is only respected with\n// \"pre-wrap\". With \"pre-line\", it's considered collapsible, and gets replaced on next keystroke.\nconst cssContentEditable = styled(\"div\", `\n  min-height: 5em;\n  border-radius: 3px;\n  padding: 4px 6px;\n  color: ${theme.inputFg};\n  background-color: ${theme.inputBg};\n  border: 1px solid ${theme.inputBorder};\n  outline: none;\n  width: 100%;\n  overflow: auto;\n  white-space: pre-wrap;\n  &-comment, &-reply {\n    min-height: 28px;\n    height: 28px;\n  }\n  &::placeholder {\n    color: ${theme.inputPlaceholderFg};\n  }\n  & a {\n    outline: none !important;\n  }\n  & a[contenteditable=\"true\"],\n  & a[contenteditable=\"plaintext-only\"] {\n    text-decoration: none !important;\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/NTextBox.ts",
    "content": "import { FormFieldRulesConfig } from \"app/client/components/Forms/FormConfig\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { fromKoSave } from \"app/client/lib/fromKoSave\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { fieldWithDefault } from \"app/client/models/modelUtil\";\nimport { FormTextFormat } from \"app/client/ui/FormAPI\";\nimport { cssLabel, cssNumericSpinner, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { rowHeightConfigColumn } from \"app/client/ui/RowHeightConfig\";\nimport { alignmentSelect, buttonSelect, cssButtonSelect, makeButtonSelect } from \"app/client/ui2018/buttonSelect\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { makeLinks } from \"app/client/ui2018/links\";\nimport { NewAbstractWidget, Options } from \"app/client/widgets/NewAbstractWidget\";\n\nimport { Computed, dom, DomContents, fromKo, Observable } from \"grainjs\";\n\nconst t = makeT(\"NTextBox\");\n\n/**\n * TextBox - The most basic widget for displaying text information.\n */\nexport class NTextBox extends NewAbstractWidget {\n  protected alignment: Observable<string>;\n  protected wrapping: Observable<boolean>;\n\n  constructor(field: ViewFieldRec, options: Options = {}) {\n    super(field, options);\n\n    this.alignment = fromKo(this.options.prop(\"alignment\"));\n    this.wrapping = fromKo(this.field.wrap);\n\n    this._addRowHeightListeners();\n  }\n\n  public buildConfigDom(_gristDoc: GristDoc): DomContents {\n    const toggle = () => {\n      const newValue = !this.field.config.wrap.peek();\n      this.field.config.wrap.setAndSave(newValue).catch(reportError);\n    };\n    const options = this.field.config.options;\n    // Some options might be disabled, as more than one column is selected.\n    // Prop observable is owned by the options object.\n    const alignmentDisabled = Computed.create(this, use => use(options.disabled(\"alignment\")));\n    const wrapDisabled = Computed.create(this, use => use(options.disabled(\"wrap\")));\n    return [\n      cssRow(\n        alignmentSelect(\n          fromKoSave(this.field.config.alignment),\n          cssButtonSelect.cls(\"-disabled\", alignmentDisabled),\n        ),\n        dom(\"div\", { style: \"margin-left: 8px;\" },\n          makeButtonSelect(\n            fromKo(this.field.config.wrap),\n            [{ value: true, icon: \"Wrap\" }],\n            toggle,\n            cssButtonSelect.cls(\"-disabled\", wrapDisabled),\n          ),\n          testId(\"tb-wrap-text\"),\n        ),\n      ),\n      cssRow(rowHeightConfigColumn(this.field.viewSection.peek())),\n    ];\n  }\n\n  public buildFormConfigDom(): DomContents {\n    const format = fieldWithDefault<FormTextFormat>(\n      this.field.widgetOptionsJson.prop(\"formTextFormat\"),\n      \"singleline\",\n    );\n    const lineCount = fieldWithDefault<number | \"\">(\n      this.field.widgetOptionsJson.prop(\"formTextLineCount\"),\n      \"\",\n    );\n    const maximumLength = fieldWithDefault<number | \"\">(\n      this.field.widgetOptionsJson.prop(\"formTextMaximumLength\"),\n      \"\",\n    );\n\n    return [\n      cssLabel(t(\"Field Format\")),\n      cssRow(\n        buttonSelect(\n          fromKoSave(format),\n          [\n            { value: \"singleline\", label: t(\"Single line\") },\n            { value: \"multiline\", label: t(\"Multi line\") },\n          ],\n          testId(\"tb-form-field-format\"),\n        ),\n      ),\n      dom.maybe(use => use(format) === \"multiline\", () =>\n        cssRow(\n          cssNumericSpinner(\n            fromKo(lineCount),\n            {\n              label: t(\"Lines\"),\n              defaultValue: 3,\n              minValue: 1,\n              maxValue: 99,\n              save: async val => lineCount.setAndSave((val && Math.floor(val)) ?? \"\"),\n            },\n          ),\n        ),\n      ),\n      cssRow(\n        cssNumericSpinner(\n          fromKo(maximumLength),\n          {\n            label: t(\"Maximum characters\"),\n            defaultValue: undefined,\n            minValue: 0,\n            save: async val => maximumLength.setAndSave((val === undefined ? 0 : Math.floor(val))),\n          },\n          testId(\"tb-form-field-constraint\"),\n        ),\n      ),\n      dom.create(FormFieldRulesConfig, this.field),\n    ];\n  }\n\n  public buildDom(row: DataRowModel) {\n    const value = row.cells[this.field.colId.peek()];\n    return dom(\"div.field_clip\",\n      dom.style(\"text-align\", this.alignment),\n      dom.cls(\"text_wrapping\", this.wrapping),\n      dom.domComputed(use => use(row._isAddRow) || this.isDisposed() ?\n        null :\n        makeLinks(use(this.valueFormatter).formatAny(use(value), t))),\n    );\n  }\n\n  private _addRowHeightListeners() {\n    for (const obs of [this.wrapping, fromKo(this.field.config.widget)]) {\n      this.autoDispose(obs.addListener(() => {\n        this.field.viewSection().events.trigger(\"rowHeightChange\");\n      }));\n    }\n  }\n}\n"
  },
  {
    "path": "app/client/widgets/NTextEditor.ts",
    "content": "/**\n * This is a copy of TextEditor.js, converted to typescript.\n */\nimport { createGroup } from \"app/client/components/commands\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { createMobileButtons, getButtonMargins } from \"app/client/widgets/EditorButtons\";\nimport { EditorPlacement, ISize } from \"app/client/widgets/EditorPlacement\";\nimport { FieldOptions, NewBaseEditor } from \"app/client/widgets/NewBaseEditor\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { undef } from \"app/common/gutil\";\n\nimport { dom, Observable } from \"grainjs\";\n\nexport class NTextEditor extends NewBaseEditor {\n  // Observable with current editor state (used by drafts or latest edit/position component)\n  public readonly editorState: Observable<string>;\n\n  protected cellEditorDiv: HTMLElement;\n  protected textInput: HTMLTextAreaElement;\n  protected commandGroup: any;\n\n  private _dom: HTMLElement;\n  private _editorPlacement!: EditorPlacement;\n  private _contentSizer: HTMLElement;\n  private _alignment: string;\n\n  // Note: TextEditor supports also options.placeholder for use by derived classes, but this is\n  // easy to apply to this.textInput without needing a separate option.\n  constructor(protected options: FieldOptions) {\n    super(options);\n\n    const initialValue: string = undef(\n      options.state as string | undefined,\n      options.editValue, String(options.cellValue ?? \"\"));\n    this.editorState = Observable.create<string>(this, initialValue);\n\n    this.commandGroup = this.autoDispose(createGroup(options.commands, this, true));\n    this._alignment = options.field.widgetOptionsJson.peek().alignment || \"left\";\n    this._dom =\n      dom(\"div.default_editor\",\n      // add readonly class\n        dom.cls(\"readonly_editor\", options.readonly),\n        this.cellEditorDiv = dom(\"div.celleditor_cursor_editor\",\n          testId(\"widget-text-editor\"),\n          this._contentSizer = dom(\"div.celleditor_content_measure\"),\n          this.textInput = dom(\"textarea\",\n            dom.cls(\"celleditor_text_editor\"),\n            dom.style(\"text-align\", this._alignment),\n            dom.prop(\"value\", initialValue),\n            dom.boolAttr(\"readonly\", options.readonly),\n            this.commandGroup.attach(),\n            dom.on(\"input\", () => this.onInput()),\n          ),\n        ),\n        createMobileButtons(options.commands),\n      );\n  }\n\n  public attach(cellElem: Element): void {\n    // Attach the editor dom to page DOM.\n    this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, { margins: getButtonMargins() });\n\n    // Reposition the editor if needed for external reasons (in practice, window resize).\n    this.autoDispose(this._editorPlacement.onReposition.addListener(this.resizeInput, this));\n\n    this.setSizerLimits();\n\n    // Once the editor is attached to DOM, resize it to content, focus, and set cursor.\n    this.resizeInput();\n    this.textInput.focus();\n    const pos = Math.min(this.options.cursorPos, this.textInput.value.length);\n    this.textInput.setSelectionRange(pos, pos);\n  }\n\n  public getDom(): HTMLElement {\n    return this._dom;\n  }\n\n  public getCellValue(): CellValue {\n    const valueParser = this.options.field.createValueParser();\n    return valueParser(this.getTextValue());\n  }\n\n  public getTextValue() {\n    return this.textInput.value;\n  }\n\n  public getCursorPos() {\n    return this.textInput.selectionStart;\n  }\n\n  public setSizerLimits() {\n    // Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap\n    // once we reach it.\n    const maxSize = this._editorPlacement.calcSizeWithPadding(this.textInput,\n      { width: Infinity, height: Infinity }, { calcOnly: true });\n    this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + \"px\";\n  }\n\n  public get contentSizer(): HTMLElement {\n    return this._contentSizer;\n  }\n\n  public get editorPlacement(): EditorPlacement {\n    return this._editorPlacement;\n  }\n\n  /**\n   * Occurs when user types text in the textarea\n   *\n   */\n  protected onInput() {\n    // Resize the textbox whenever user types in it.\n    this.resizeInput();\n\n    // notify about current state\n    this.editorState.set(String(this.getTextValue()));\n  }\n\n  /**\n   * Helper which resizes textInput to match its content. It relies on having a contentSizer element\n   * with the same font/size settings as the textInput, and on having `calcSize` helper,\n   * which is provided by the EditorPlacement class.\n   */\n  protected resizeInput() {\n    const textInput = this.textInput;\n    // \\u200B is a zero-width space; it is used so the textbox will expand vertically\n    // on newlines, but it does not add any width.\n    this._contentSizer.textContent = textInput.value + \"\\u200B\";\n    const rect = this._contentSizer.getBoundingClientRect();\n\n    // Allow for a bit of extra space after the cursor (only desirable when text is left-aligned).\n    if (this._alignment === \"left\") {\n      // Modifiable in modern browsers: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect\n      rect.width += 16;\n    }\n\n    const size = this._editorPlacement.calcSizeWithPadding(textInput, rect);\n    textInput.style.width = size.width + \"px\";\n    textInput.style.height = size.height + \"px\";\n\n    // Scrollbars are first visible (as the content get larger), but resizing should hide them (if there is enough\n    // space), but this doesn't work in Chrome on Windows or Ubuntu (but works on Mac). Here if scrollbars are visible,\n    // but we got same enough spaces, we will force browser to check the available space once more time.\n    if (enoughSpace(rect, size) && hasScroll(textInput)) {\n      textInput.style.overflow = \"hidden\";\n      // eslint-disable-next-line @typescript-eslint/no-unused-expressions, no-unused-expressions\n      textInput.clientHeight; // just access metrics is enough to repaint\n      textInput.style.overflow = \"auto\";\n    }\n  }\n}\n\nfunction enoughSpace(requested: ISize, received: ISize) {\n  return requested.width <= received.width && requested.height <= received.height;\n}\n\nfunction hasScroll(el: HTMLTextAreaElement) {\n  // This is simple check for dimensions, scrollbar will appear when scrollHeight > clientHeight\n  return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;\n}\n"
  },
  {
    "path": "app/client/widgets/NewAbstractWidget.ts",
    "content": "/**\n * NewAbstractWidget is equivalent to AbstractWidget for outside code, but is in typescript, and\n * so is friendlier and clearer to derive TypeScript classes from.\n */\nimport { DocComm } from \"app/client/components/DocComm\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { DocData } from \"app/client/models/DocData\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { SaveableObjObservable } from \"app/client/models/modelUtil\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { CellStyle } from \"app/client/widgets/CellStyle\";\nimport { BaseFormatter } from \"app/common/ValueFormatter\";\n\nimport {\n  Disposable,\n  dom,\n  DomContents,\n  fromKo,\n  IDisposableOwnerT,\n  Observable,\n} from \"grainjs\";\n\nexport interface Options {\n  /**\n   * CSS value of the default widget text color. Defaults to the current theme's\n   * cell fg color.\n   */\n  defaultTextColor?: string;\n}\n\n/**\n * NewAbstractWidget - The base of the inheritance tree for widgets.\n * @param {Function} field - The RowModel for this view field.\n */\nexport abstract class NewAbstractWidget extends Disposable {\n  /**\n   * Override the create() method to match the parameters of create() expected by FieldBuilder.\n   */\n  // We copy Disposable.create() signature (the second one) to pacify typescript, but code must\n  // use the first signature, which is compatible with old-style constructors.\n  public static create<T extends new (...args: any[]) => any>(field: ViewFieldRec): InstanceType<T>;\n  public static create<T extends new (...args: any[]) => any>(\n    this: T, owner: IDisposableOwnerT<InstanceType<T>> | null, ...args: ConstructorParameters<T>): InstanceType<T>;\n  public static create(...args: any[]) {\n    return Disposable.create.call(this as any, null, ...args);\n  }\n\n  protected options: SaveableObjObservable<any>;\n  protected valueFormatter: Observable<BaseFormatter>;\n  protected textColor: Observable<string>;\n  protected fillColor: Observable<string>;\n  protected readonly defaultTextColor: string | undefined = this._opts.defaultTextColor ??\n    theme.cellFg.toString();\n\n  constructor(protected field: ViewFieldRec, private _opts: Options = {}) {\n    super();\n    this.options = field.widgetOptionsJson;\n    this.valueFormatter = fromKo(field.formatter);\n  }\n\n  /**\n   * Builds the DOM showing configuration buttons and fields in the sidebar.\n   */\n  public buildConfigDom(_gristDoc: GristDoc): DomContents {\n    return null;\n  }\n\n  /**\n   * Builds the transform prompt config DOM in the few cases where it is necessary.\n   * Child classes need not override this function if they do not require transform config options.\n   */\n  public buildTransformConfigDom(_gristDoc: GristDoc): DomContents {\n    return null;\n  }\n\n  public buildColorConfigDom(gristDoc: GristDoc): DomContents {\n    return dom.create(CellStyle, this.field, gristDoc, this.defaultTextColor);\n  }\n\n  public buildFormConfigDom(): DomContents {\n    return null;\n  }\n\n  public buildFormTransformConfigDom(): DomContents {\n    return null;\n  }\n\n  /**\n   * Builds the data cell DOM.\n   * @param {DataRowModel} row - The rowModel object.\n   */\n  public abstract buildDom(row: any): Element;\n\n  /**\n   * Returns the DocData object to which this field belongs.\n   */\n  protected _getDocData(): DocData {\n    // TODO: There should be a better way to access docData and docComm, or better yet GristDoc.\n    return this.field._table.tableData.docData;\n  }\n\n  /**\n   * Returns the docComm object for communicating with the server.\n   */\n  protected _getDocComm(): DocComm {\n    return this._getDocData().docComm;\n  }\n}\n"
  },
  {
    "path": "app/client/widgets/NewBaseEditor.ts",
    "content": "/**\n * NewBaseEditor is equivalent to BaseEditor for outside code, but is in typescript, and\n * so is friendlier and clearer to derive TypeScript classes from.\n */\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { CellValue } from \"app/common/DocActions\";\n\nimport { Disposable, IDisposableOwner, Observable } from \"grainjs\";\n\nexport interface IEditorCommandGroup {\n  fieldEditCancel: () => void;\n  fieldEditSaveHere: () => void;\n  [cmd: string]: () => void;\n}\n\n// Usually an editor is created for a field and provided FieldOptions, but it's possible to have\n// no field object, e.g. for a FormulaEditor for a conditional style rule.\nexport interface Options {\n  gristDoc: GristDoc;\n  cellValue: CellValue;\n  rowId: number;\n  formulaError?: Observable<CellValue | undefined>;\n  editValue?: string;\n  cursorPos: number;\n  commands: IEditorCommandGroup;\n  state?: any;\n  readonly: boolean;\n}\n\nexport interface FieldOptions extends Options {\n  field: ViewFieldRec;\n}\n\n// This represents any of the derived editor classes; the part after \"&\" restricts to non-abstract ones.\nexport type IEditorConstructor = typeof NewBaseEditor & (new (...args: any[]) => NewBaseEditor);\n\n/**\n * Required parameters:\n * @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.\n * @param {String} options.cellValue: The value in the underlying cell being edited.\n * @param {String} options.editValue: String to be edited, or undefined to use cellValue.\n * @param {Number} options.cursorPos: The initial position where to place the cursor.\n * @param {Object} options.commands: Object mapping command names to functions, to enable as part\n *  of the command group that should be activated while the editor exists.\n */\nexport abstract class NewBaseEditor extends Disposable {\n  /**\n   * Override the create() method to allow the parameters of create() expected by old-style\n   * Editors and provided by FieldBuilder. TODO: remove this method once all editors have been\n   * updated to new-style Disposables.\n   */\n  public static create<T extends new (...args: any[]) => any, Opt extends Options>(\n    this: T, owner: IDisposableOwner | null, options: Opt): InstanceType<T>;\n  public static create<T extends new (...args: any[]) => any, Opt extends Options>(\n    this: T, options: Opt): InstanceType<T>;\n  public static create(ownerOrOptions: any, options?: any): NewBaseEditor {\n    return options ?\n      Disposable.create.call(this as any, ownerOrOptions, options) :\n      Disposable.create.call(this as any, null, ownerOrOptions);\n  }\n\n  /**\n   * Check if the typed-in value should change the cell without opening the editor, and if so,\n   * returns the value to save. E.g. on typing <enter>, CheckBoxEditor toggles value without opening.\n   */\n  public static skipEditor(\n    typedVal: string | undefined,\n    origVal: CellValue,\n    options?: { event?: Event },\n  ): CellValue | undefined {\n    return undefined;\n  }\n\n  /**\n   * Check if editor supports readonly mode (default: true)\n   */\n  public static supportsReadonly(): boolean {\n    return true;\n  }\n\n  /**\n   * Current state of the editor. Optional, not all editors will report theirs current state.\n   */\n  public editorState?: Observable<any>;\n\n  constructor(protected options: Options) {\n    super();\n  }\n\n  /**\n   * Called after the editor is instantiated to attach its DOM to the page.\n   * - cellElem: The element representing the cell that this editor should match\n   *   in size and position. Used by derived classes, e.g. to construct an EditorPlacement object.\n   */\n  public abstract attach(cellElem: Element): void;\n\n  /**\n   * Called to detach the editor and show it in the floating popup.\n   */\n  public detach(): HTMLElement | null { return null; }\n\n  /**\n   * Returns DOM container with the editor, typically present and attached after attach() has been\n   * called.\n   */\n  public getDom(): HTMLElement | null { return null; }\n\n  /**\n   * Called to get the value to save back to the cell.\n   */\n  public abstract getCellValue(): CellValue;\n\n  /**\n   * Used if an editor needs perform any actions before a save\n   */\n  public prepForSave(): void | Promise<void> {\n    // No-op by default.\n  }\n\n  /**\n   * Called to get the text in the editor, used when switching between editing data and formula.\n   */\n  public abstract getTextValue(): string;\n\n  /**\n   * Called to get the position of the cursor in the editor. Used when switching between editing\n   * data and formula.\n   */\n  public abstract getCursorPos(): number;\n}\n"
  },
  {
    "path": "app/client/widgets/NumericEditor.ts",
    "content": "import { FieldOptions } from \"app/client/widgets/NewBaseEditor\";\nimport { NTextEditor } from \"app/client/widgets/NTextEditor\";\n\nexport class NumericEditor extends NTextEditor {\n  constructor(protected options: FieldOptions) {\n    if (!options.editValue && typeof options.cellValue === \"number\") {\n      // If opening a number for editing, we render it using the basic string representation (e.g.\n      // no currency symbols or groupings), but it's important to use the right locale so that the\n      // number can be parsed back (e.g. correct decimal separator).\n      const locale = options.field.documentSettings.peek().locale;\n      const fmt = new Intl.NumberFormat(locale, { useGrouping: false, maximumFractionDigits: 20 });\n      const editValue = fmt.format(options.cellValue);\n      options = { ...options, editValue };\n    }\n    super(options);\n  }\n}\n"
  },
  {
    "path": "app/client/widgets/NumericSpinner.ts",
    "content": "import { theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { clamp, numberOrDefault } from \"app/common/gutil\";\nimport { MaybePromise } from \"app/plugin/gutil\";\n\nimport { BindableValue, dom, DomElementArg, IDomArgs, makeTestId, Observable, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-numeric-spinner-\");\n\nexport interface NumericSpinnerOptions {\n  /** Defaults to `false`. */\n  setValueOnInput?: boolean;\n  label?: string;\n  defaultValue?: number | Observable<number>;\n  /** No minimum if unset. */\n  minValue?: number;\n  /** No maximum if unset. */\n  maxValue?: number;\n  disabled?: BindableValue<boolean>;\n  inputArgs?: IDomArgs<HTMLInputElement>;\n  /** Called on blur and spinner button click. */\n  save?: (val?: number) => MaybePromise<void>,\n}\n\nexport function numericSpinner(\n  value: Observable<number | \"\">,\n  options: NumericSpinnerOptions = {},\n  ...args: DomElementArg[]\n) {\n  const {\n    setValueOnInput = false,\n    label,\n    defaultValue,\n    minValue = Number.NEGATIVE_INFINITY,\n    maxValue = Number.POSITIVE_INFINITY,\n    disabled,\n    inputArgs = [],\n    save,\n  } = options;\n\n  const getDefaultValue = () => {\n    if (defaultValue === undefined) {\n      return 0;\n    } else if (typeof defaultValue === \"number\") {\n      return defaultValue;\n    } else {\n      return defaultValue.get();\n    }\n  };\n\n  let inputElement: HTMLInputElement;\n\n  const shiftValue = async (delta: 1 | -1, opts: { saveValue?: boolean } = {}) => {\n    const { saveValue } = opts;\n    const currentValue = numberOrDefault(inputElement.value, getDefaultValue());\n    const newValue = clamp(Math.floor(currentValue + delta), minValue, maxValue);\n    if (setValueOnInput) { value.set(newValue); }\n    if (saveValue) { await save?.(newValue); }\n    return newValue;\n  };\n  const incrementValue = (opts: { saveValue?: boolean } = {}) => shiftValue(1, opts);\n  const decrementValue = (opts: { saveValue?: boolean } = {}) => shiftValue(-1, opts);\n\n  return cssNumericSpinner(\n    disabled ? cssNumericSpinner.cls(\"-disabled\", disabled) : null,\n    label ? cssNumLabel(label) : null,\n    inputElement = cssNumInput(\n      { type: \"number\" },\n      dom.prop(\"value\", value),\n      defaultValue !== undefined ? dom.prop(\"placeholder\", defaultValue) : null,\n      dom.onKeyDown({\n        ArrowUp: async (_ev, elem) => { elem.value = String(await incrementValue()); },\n        ArrowDown: async (_ev, elem) => { elem.value = String(await decrementValue()); },\n        Enter$: async (_ev, elem) => save && elem.blur(),\n      }),\n      !setValueOnInput ? null : dom.on(\"input\", (_ev, elem) => {\n        value.set(Number.parseFloat(elem.value));\n      }),\n      !save ? null : dom.on(\"blur\", async () => {\n        let newValue = numberOrDefault(inputElement.value, undefined);\n        if (newValue !== undefined) { newValue = clamp(newValue, minValue, maxValue); }\n        await save(newValue);\n      }),\n      dom.on(\"focus\", (_ev, elem) => elem.select()),\n      ...inputArgs,\n    ),\n    cssSpinner(\n      cssSpinnerBtn(\n        cssSpinnerTop(\"DropdownUp\"),\n        dom.on(\"click\", async () => incrementValue({ saveValue: true })),\n        testId(\"increment\"),\n      ),\n      cssSpinnerBtn(\n        cssSpinnerBottom(\"Dropdown\"),\n        dom.on(\"click\", async () => decrementValue({ saveValue: true })),\n        testId(\"decrement\"),\n      ),\n    ),\n    ...args,\n  );\n}\n\nconst cssNumericSpinner = styled(\"div\", `\n  position: relative;\n  flex: auto;\n  font-weight: normal;\n  display: flex;\n  align-items: center;\n  outline: 1px solid ${theme.inputBorder};\n  background-color: ${theme.inputBg};\n  border-radius: 3px;\n  &-disabled {\n    opacity: 0.4;\n    pointer-events: none;\n  }\n`);\n\nconst cssNumLabel = styled(\"div\", `\n  color: ${theme.lightText};\n  flex-shrink: 0;\n  padding-left: 8px;\n  pointer-events: none;\n`);\n\nconst cssNumInput = styled(\"input\", `\n  flex-grow: 1;\n  padding: 4px 32px 4px 8px;\n  width: 100%;\n  text-align: right;\n  appearance: none;\n  color: ${theme.inputFg};\n  background-color: transparent;\n  border: none;\n  outline: none;\n  -moz-appearance: textfield;\n\n  &::-webkit-outer-spin-button,\n  &::-webkit-inner-spin-button {\n    -webkit-appearance: none;\n    margin: 0;\n  }\n`);\n\nconst cssSpinner = styled(\"div\", `\n  position: absolute;\n  right: 8px;\n  width: 16px;\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n`);\n\nconst cssSpinnerBtn = styled(\"div\", `\n  --icon-color: ${theme.controlSecondaryFg};\n  flex: 1 1 0px;\n  min-height: 0px;\n  position: relative;\n  cursor: pointer;\n  overflow: hidden;\n  &:hover {\n    --icon-color: ${theme.controlSecondaryHoverFg};\n  }\n`);\n\nconst cssSpinnerTop = styled(icon, `\n  position: absolute;\n  top: 0px;\n`);\n\nconst cssSpinnerBottom = styled(icon, `\n  position: absolute;\n  bottom: 0px;\n`);\n"
  },
  {
    "path": "app/client/widgets/NumericTextBox.ts",
    "content": "/**\n * See app/common/NumberFormat for description of options we support.\n */\nimport { FormFieldRulesConfig } from \"app/client/components/Forms/FormConfig\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { fromKoSave } from \"app/client/lib/fromKoSave\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { reportError } from \"app/client/models/errors\";\nimport { fieldWithDefault } from \"app/client/models/modelUtil\";\nimport { FormNumberFormat } from \"app/client/ui/FormAPI\";\nimport { cssLabel, cssNumericSpinner, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { buttonSelect, cssButtonSelect, ISelectorOption, makeButtonSelect } from \"app/client/ui2018/buttonSelect\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { buildCurrencyPicker } from \"app/client/widgets/CurrencyPicker\";\nimport { NTextBox } from \"app/client/widgets/NTextBox\";\nimport { numberOrDefault } from \"app/common/gutil\";\nimport { buildNumberFormat, NumberFormatOptions, NumMode, NumSign } from \"app/common/NumberFormat\";\n\nimport { Computed, dom, DomContents, fromKo, MultiHolder, styled } from \"grainjs\";\nimport * as LocaleCurrency from \"locale-currency\";\n\nconst t = makeT(\"NumericTextBox\");\n\nconst modeOptions: ISelectorOption<NumMode>[] = [\n  { value: \"currency\", label: \"$\" },\n  { value: \"decimal\", label: \",\" },\n  { value: \"percent\", label: \"%\" },\n  { value: \"scientific\", label: \"Exp\" },\n];\n\nconst signOptions: ISelectorOption<NumSign>[] = [\n  { value: \"parens\", label: \"(-)\" },\n];\n\n/**\n * NumericTextBox - The most basic widget for displaying numeric information.\n */\nexport class NumericTextBox extends NTextBox {\n  constructor(field: ViewFieldRec) {\n    super(field);\n  }\n\n  public buildConfigDom(gristDoc: GristDoc): DomContents {\n    // Holder for all computeds created here. It gets disposed with the returned DOM element.\n    const holder = new MultiHolder();\n\n    // Resolved options, to show default min/max decimals, which change depending on numMode.\n    const resolved = Computed.create<Intl.ResolvedNumberFormatOptions>(holder, (use) => {\n      const { numMode } = use(this.field.config.options);\n      const docSettings = use(this.field.documentSettings);\n      return buildNumberFormat({ numMode }, docSettings).resolvedOptions();\n    });\n\n    // Prepare various observables that reflect the options in the UI.\n    const fieldOptions = this.field.config.options;\n    const options = fromKo(fieldOptions);\n    const docSettings = fromKo(this.field.documentSettings);\n    const numMode = Computed.create(holder, options, (use, opts) => (opts.numMode as NumMode) || null);\n    const numSign = Computed.create(holder, options, (use, opts) => opts.numSign || null);\n    const currency = Computed.create(holder, options, (use, opts) => opts.currency);\n    const disabled = Computed.create(holder, use => use(this.field.config.options.disabled(\"currency\")));\n    const minDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.decimals, \"\"));\n    const maxDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.maxDecimals, \"\"));\n    const defaultMin = Computed.create(holder, resolved, (use, res) => res.minimumFractionDigits);\n    const defaultMax = Computed.create(holder, resolved, (use, res) => res.maximumFractionDigits);\n    const docCurrency = Computed.create(holder, docSettings, (use, settings) =>\n      settings.currency ?? LocaleCurrency.getCurrency(settings.locale ?? \"en-US\"),\n    );\n\n    // Save a value as the given property in fieldOptions observable. Set it, save, and revert\n    // on save error. This is similar to what modelUtil.setSaveValue() does.\n    const setSave = (prop: keyof NumberFormatOptions, value: unknown) => {\n      const orig = { ...fieldOptions.peek() };\n      if (value !== orig[prop]) {\n        fieldOptions({ ...orig, [prop]: value, ...updateOptions(prop, value) });\n        fieldOptions.save().catch((err) => { reportError(err); fieldOptions(orig); });\n      }\n    };\n\n    // Prepare setters for the UI elements.\n    // If defined, `val` will be a floating point number between 0 and 20; make sure it's\n    // saved as an integer.\n    const setMinDecimals = (val?: number) => setSave(\"decimals\", val && Math.floor(val));\n    const setMaxDecimals = (val?: number) => setSave(\"maxDecimals\", val && Math.floor(val));\n    // Mode and Sign behave as toggles: clicking a selected on deselects it.\n    const setMode = (val: NumMode) => setSave(\"numMode\", val !== numMode.get() ? val : undefined);\n    const setSign = (val: NumSign) => setSave(\"numSign\", val !== numSign.get() ? val : undefined);\n    const setCurrency = (val: string | undefined) => setSave(\"currency\", val);\n\n    const disabledStyle = cssButtonSelect.cls(\"-disabled\", disabled);\n\n    return [\n      super.buildConfigDom(gristDoc),\n      cssLabel(t(\"Number Format\")),\n      cssRow(\n        dom.autoDispose(holder),\n        makeButtonSelect(numMode, modeOptions, setMode, disabledStyle, cssModeSelect.cls(\"\"), testId(\"numeric-mode\")),\n        makeButtonSelect(numSign, signOptions, setSign, disabledStyle, cssSignSelect.cls(\"\"), testId(\"numeric-sign\")),\n      ),\n      dom.maybe(use => use(numMode) === \"currency\", () => [\n        cssLabel(t(\"Currency\")),\n        cssRow(\n          dom.domComputed(docCurrency, defaultCurrency =>\n            buildCurrencyPicker(holder, currency, setCurrency,\n              { defaultCurrencyLabel: t(`Default currency ({{defaultCurrency}})`, { defaultCurrency }), disabled }),\n          ),\n          testId(\"numeric-currency\"),\n        ),\n      ]),\n      cssLabel(t(\"Decimals\")),\n      cssRow(\n        cssNumericSpinner(\n          minDecimals,\n          {\n            label: t(\"min\"),\n            minValue: 0,\n            maxValue: 20,\n            defaultValue: defaultMin,\n            disabled,\n            save: setMinDecimals,\n          },\n          testId(\"numeric-min-decimals\"),\n        ),\n        cssNumericSpinner(\n          maxDecimals,\n          {\n            label: t(\"max\"),\n            minValue: 0,\n            maxValue: 20,\n            defaultValue: defaultMax,\n            disabled,\n            save: setMaxDecimals,\n          },\n          testId(\"numeric-max-decimals\"),\n        ),\n      ),\n    ];\n  }\n\n  public buildFormConfigDom(): DomContents {\n    const format = fieldWithDefault<FormNumberFormat>(\n      this.field.widgetOptionsJson.prop(\"formNumberFormat\"),\n      \"text\",\n    );\n\n    return [\n      cssLabel(t(\"Field Format\")),\n      cssRow(\n        buttonSelect(\n          fromKoSave(format),\n          [\n            { value: \"text\", label: t(\"Text\") },\n            { value: \"spinner\", label: t(\"Spinner\") },\n          ],\n          testId(\"numeric-form-field-format\"),\n        ),\n      ),\n      dom.create(FormFieldRulesConfig, this.field),\n    ];\n  }\n}\n\n// Helper used by setSave() above to reset some properties when switching modes.\nfunction updateOptions(prop: keyof NumberFormatOptions, value: unknown): Partial<NumberFormatOptions> {\n  // Reset the numSign to default when toggling mode to percent or scientific.\n  if (prop === \"numMode\" && (!value || value === \"scientific\" || value === \"percent\")) {\n    return { numSign: undefined };\n  }\n  return {};\n}\n\nconst cssModeSelect = styled(makeButtonSelect, `\n  flex: 4 4 0px;\n  background-color: ${theme.inputBg};\n`);\n\nconst cssSignSelect = styled(makeButtonSelect, `\n  flex: 1 1 0px;\n  background-color: ${theme.inputBg};\n  margin-left: 16px;\n`);\n"
  },
  {
    "path": "app/client/widgets/Reference.css",
    "content": ".cell_icon {\n  margin-right: 3px;\n  color: #808080;\n}\n"
  },
  {
    "path": "app/client/widgets/Reference.ts",
    "content": "import { DropdownConditionConfig } from \"app/client/components/DropdownConditionConfig\";\nimport {\n  FormFieldRulesConfig,\n  FormOptionsSortConfig,\n  FormSelectConfig,\n} from \"app/client/components/Forms/FormConfig\";\nimport { GristDoc } from \"app/client/components/GristDoc\";\nimport { stopEvent } from \"app/client/lib/domUtils\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { TableRec } from \"app/client/models/DocModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { cssLabel, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { hideInPrintView, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { IOptionFull, select } from \"app/client/ui2018/menus\";\nimport { NTextBox } from \"app/client/widgets/NTextBox\";\nimport { ReverseReferenceConfig } from \"app/client/widgets/ReverseReferenceConfig\";\nimport { isFullReferencingType, isVersions } from \"app/common/gristTypes\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\n\nimport { Computed, dom, styled } from \"grainjs\";\n\nconst t = makeT(\"Reference\");\n\n/**\n * Reference - The widget for displaying references to another table's records.\n */\nexport class Reference extends NTextBox {\n  protected _refTable: Computed<TableRec | null>;\n  private _visibleColRef: Computed<number>;\n  private _validCols: Computed<IOptionFull<number>[]>;\n\n  constructor(field: ViewFieldRec) {\n    super(field);\n\n    this._visibleColRef = Computed.create(this, use => use(this.field.visibleColRef));\n    // Note that saveOnly is used here to prevent display value flickering on visible col change.\n    this._visibleColRef.onWrite(val => this.field.visibleColRef.saveOnly(val));\n\n    this._refTable = Computed.create(this, use => use(use(this.field.column).refTable));\n\n    this._validCols = Computed.create(this, (use) => {\n      const refTable = use(this._refTable);\n      if (!refTable) { return []; }\n      return use(use(refTable.columns).getObservable())\n        .filter(col => !use(col.isHiddenCol))\n        .map<IOptionFull<number>>(col => ({\n          label: use(col.label),\n          value: col.getRowId(),\n          icon: \"FieldColumn\",\n          disabled: isFullReferencingType(use(col.type)) || use(col.isTransforming),\n        }))\n        .concat([{ label: t(\"Row ID\"), value: 0, icon: \"FieldColumn\" }]);\n    });\n  }\n\n  public buildConfigDom(gristDoc: GristDoc) {\n    return [\n      this.buildTransformConfigDom(),\n      dom.create(DropdownConditionConfig, this.field, gristDoc),\n      dom.create(ReverseReferenceConfig, this.field),\n      cssLabel(t(\"CELL FORMAT\")),\n      super.buildConfigDom(gristDoc),\n    ];\n  }\n\n  public buildTransformConfigDom() {\n    const disabled = Computed.create(null, use => use(this.field.config.multiselect));\n    return [\n      cssLabel(t(\"SHOW COLUMN\")),\n      cssRow(\n        dom.autoDispose(disabled),\n        select(this._visibleColRef, this._validCols, {\n          disabled,\n        }),\n        testId(\"fbuilder-ref-col-select\"),\n      ),\n    ];\n  }\n\n  public buildFormConfigDom() {\n    return [\n      this.buildTransformConfigDom(),\n      dom.create(FormSelectConfig, this.field),\n      dom.create(FormOptionsSortConfig, this.field),\n      dom.create(FormFieldRulesConfig, this.field),\n    ];\n  }\n\n  public buildFormTransformConfigDom() {\n    return this.buildTransformConfigDom();\n  }\n\n  public buildDom(row: DataRowModel) {\n    // Note: we require 2 observables here because changes to the cell value (reference id)\n    // and the display value (display column) are not bundled. This can cause `formattedValue`\n    // to briefly display incorrect values (e.g. [Blank] when adding a reference to an empty cell)\n    // because the cell value changes before the display column has a chance to update.\n    //\n    // TODO: Look into a better solution (perhaps updating the display formula to return [Blank]).\n    const referenceId = Computed.create(null, (use) => {\n      const id = row.cells[use(this.field.colId)];\n      return id && use(id);\n    });\n    const formattedValue = Computed.create(null, (use) => {\n      let [value, hasBlankReference, hasRecordCard] = [\"\", false, false];\n      if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {\n        // Work around JS errors during certain changes (noticed when visibleCol field gets removed\n        // for a column using per-field settings).\n        return { value, hasBlankReference, hasRecordCard };\n      }\n\n      const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)];\n      if (!displayValueObs) {\n        return { value, hasBlankReference, hasRecordCard };\n      }\n\n      const displayValue = use(displayValueObs);\n      value = isVersions(displayValue) ?\n        // We can arrive here if the reference value is unchanged (viewed as a foreign key)\n        // but the content of its displayCol has changed.  Postponing doing anything about\n        // this until we have three-way information for computed columns.  For now,\n        // just showing one version of the cell.  TODO: elaborate.\n        use(this.field.formatter).formatAny(displayValue[1].local || displayValue[1].parent) :\n        use(this.field.formatter).formatAny(displayValue);\n\n      hasBlankReference = referenceId.get() !== 0 && value.trim() === \"\";\n      const refTable = use(this._refTable);\n      if (refTable) {\n        hasRecordCard = !use(use(refTable.recordCardViewSection).disabled);\n      }\n\n      return { value, hasBlankReference, hasRecordCard };\n    });\n\n    return cssRef(\n      dom.autoDispose(formattedValue),\n      dom.autoDispose(referenceId),\n      cssRef.cls(\"-blank\", use => use(formattedValue).hasBlankReference),\n      dom.style(\"text-align\", this.alignment),\n      dom.cls(\"text_wrapping\", this.wrapping),\n      cssRefIcon(\"FieldReference\",\n        cssRefIcon.cls(\"-view-as-card\", use =>\n          use(referenceId) !== 0 && use(formattedValue).hasRecordCard,\n        ),\n        dom.on(\"click\", async (ev) => {\n          stopEvent(ev);\n\n          if (referenceId.get() === 0 || !formattedValue.get().hasRecordCard) { return; }\n\n          const rowId = referenceId.get() as UIRowId;\n          const sectionId = this._refTable.get()?.recordCardViewSectionRef();\n          if (sectionId === undefined) {\n            throw new Error(\"Unable to open Record Card: undefined section id\");\n          }\n\n          const anchorUrlState = { hash: { rowId, sectionId, recordCard: true } };\n          await urlState().pushUrl(anchorUrlState, { replace: true });\n        }),\n        dom.on(\"mousedown\", ev => stopEvent(ev)),\n        hideInPrintView(),\n        testId(\"ref-link-icon\"),\n      ),\n      dom(\"span\",\n        dom.text((use) => {\n          if (use(referenceId) === 0) { return \"\"; }\n          if (use(formattedValue).hasBlankReference) { return \"[Blank]\"; }\n          return use(formattedValue).value;\n        }),\n        testId(\"ref-text\"),\n      ),\n    );\n  }\n}\n\nconst cssRefIcon = styled(icon, `\n  float: left;\n  --icon-color: ${theme.lightText};\n  margin: -1px 2px 2px 0;\n\n  &-view-as-card {\n    cursor: pointer;\n  }\n  &-view-as-card:hover {\n    --icon-color: ${theme.controlFg};\n  }\n`);\n\nconst cssRef = styled(\"div.field_clip\", `\n  &-blank {\n    color: ${theme.lightText}\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/ReferenceEditor.ts",
    "content": "import { ACResults, buildHighlightedDom, HighlightFunc, normalizeText } from \"app/client/lib/ACIndex\";\nimport { Autocomplete } from \"app/client/lib/autocomplete\";\nimport { nocaseEqual, ReferenceUtils } from \"app/client/lib/ReferenceUtils\";\nimport { ICellItem } from \"app/client/models/ColumnACIndexes\";\nimport { reportError } from \"app/client/models/errors\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\nimport { FieldOptions } from \"app/client/widgets/NewBaseEditor\";\nimport { NTextEditor } from \"app/client/widgets/NTextEditor\";\nimport { undef } from \"app/common/gutil\";\n\nimport { styled } from \"grainjs\";\n\n/**\n * A ReferenceEditor offers an autocomplete of choices from the referenced table.\n */\nexport class ReferenceEditor extends NTextEditor {\n  private _enableAddNew: boolean;\n  private _showAddNew: boolean = false;\n  private _autocomplete?: Autocomplete<ICellItem>;\n  private _utils: ReferenceUtils;\n\n  constructor(options: FieldOptions) {\n    super(options);\n\n    const gristDoc = options.gristDoc;\n    this._utils = new ReferenceUtils(options.field, gristDoc);\n\n    const vcol = this._utils.visibleColModel;\n    this._enableAddNew = (\n      vcol &&\n      !vcol.isRealFormula() &&\n      !!vcol.colId() &&\n      !this._utils.hasDropdownCondition\n    );\n\n    // Decorate the editor to look like a reference column value (with a \"link\" icon).\n    // But not on readonly mode - here we will reuse default decoration\n    if (!options.readonly) {\n      this.cellEditorDiv.classList.add(cssRefEditor.className);\n      this.cellEditorDiv.appendChild(cssRefEditIcon(\"FieldReference\"));\n    }\n\n    this.textInput.value = undef(options.state, options.editValue, this._idToText());\n\n    const needReload = (options.editValue === undefined && !this._utils.tableData.isLoaded);\n\n    // The referenced table has probably already been fetched (because there must already be a\n    // Reference widget instantiated), but it's better to avoid this assumption.\n    gristDoc.docData.fetchTable(this._utils.refTableId).then(() => {\n      if (this.isDisposed()) { return; }\n      if (needReload && this.textInput.value === \"\") {\n        this.textInput.value = undef(options.state, options.editValue, this._idToText());\n        this.resizeInput();\n      }\n      if (this._autocomplete) {\n        if (options.editValue === undefined) {\n          this._autocomplete.search(items => items.findIndex(item => item.rowId === options.cellValue));\n        } else {\n          this._autocomplete.search();\n        }\n      }\n    })\n      .catch(reportError);\n  }\n\n  public attach(cellElem: Element): void {\n    super.attach(cellElem);\n    // don't create autocomplete for readonly mode\n    if (this.options.readonly) { return; }\n    this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {\n      menuCssClass: `${menuCssClass} ${cssRefList.className} test-autocomplete`,\n      buildNoItemsMessage: () => this._utils.buildNoItemsMessage(),\n      search: this._doSearch.bind(this),\n      renderItem: this._renderItem.bind(this),\n      getItemText: item => item.text,\n      onClick: () => this.options.commands.fieldEditSaveHere(),\n    }));\n  }\n\n  /**\n   * If the 'new' item is saved, add it to the referenced table first. See _buildSourceList\n   */\n  public async prepForSave() {\n    const selectedItem = this._autocomplete?.getSelectedItem();\n    if (selectedItem?.rowId === \"new\" &&\n      selectedItem.text === this.textInput.value) {\n      const colInfo = { [this._utils.visibleColId]: this.textInput.value };\n      selectedItem.rowId = await this._utils.tableData.sendTableAction([\"AddRecord\", null, colInfo]);\n    }\n  }\n\n  public getCellValue() {\n    const selectedItem = this._autocomplete?.getSelectedItem();\n\n    if (selectedItem) {\n      // Selected from the autocomplete dropdown; so we know the *value* (i.e. rowId).\n      return selectedItem.rowId;\n    } else if (nocaseEqual(this.textInput.value, this._idToText())) {\n      // Unchanged from what's already in the cell.\n      return this.options.cellValue;\n    }\n\n    return super.getCellValue();\n  }\n\n  private _idToText() {\n    return this._utils.idToText(this.options.cellValue);\n  }\n\n  /**\n   * If the search text does not match anything exactly, adds 'new' item to it.\n   *\n   * Also see: prepForSave.\n   */\n  private async _doSearch(text: string): Promise<ACResults<ICellItem>> {\n    const result = this._utils.autocompleteSearch(text, this.options.rowId);\n\n    this._showAddNew = false;\n    if (!this._enableAddNew || !text) { return result; }\n\n    const cleanText = normalizeText(text);\n    if (result.items.find(item => item.cleanText === cleanText)) {\n      return result;\n    }\n\n    result.extraItems.push({ rowId: \"new\", text, cleanText });\n    this._showAddNew = true;\n\n    return result;\n  }\n\n  private _renderItem(item: ICellItem, highlightFunc: HighlightFunc) {\n    return renderACItem(item.text, highlightFunc, item.rowId === \"new\", this._showAddNew);\n  }\n}\n\nexport function renderACItem(text: string, highlightFunc: HighlightFunc, isAddNew: boolean, withSpaceForNew: boolean) {\n  if (isAddNew) {\n    return cssRefItem(cssRefItem.cls(\"-new\"),\n      cssPlusButton(cssPlusIcon(\"Plus\")), text,\n      testId(\"ref-editor-item\"), testId(\"ref-editor-new-item\"),\n    );\n  }\n  return cssRefItem(cssRefItem.cls(\"-with-new\", withSpaceForNew),\n    buildHighlightedDom(text, highlightFunc, cssMatchText),\n    testId(\"ref-editor-item\"),\n  );\n}\n\nconst cssRefEditor = styled(\"div\", `\n  & > .celleditor_text_editor, & > .celleditor_content_measure {\n    padding-left: 18px;\n  }\n`);\n\n// Set z-index to be higher than the 1000 set for .cell_editor.\nexport const cssRefList = styled(\"div\", `\n  z-index: 1001;\n  overflow-y: auto;\n  padding: 8px 0 0 0;\n  --weaseljs-menu-item-padding: 8px 16px;\n`);\n\n// We need to now the height of the sticky \"+\" element.\nconst addNewHeight = \"37px\";\n\nconst cssRefItem = styled(\"li\", `\n  display: block;\n  font-family: ${vars.fontFamily};\n  white-space: pre;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  outline: none;\n  padding: var(--weaseljs-menu-item-padding, 8px 24px);\n  cursor: pointer;\n  color: ${theme.menuItemFg};\n\n  &.selected {\n    background-color: ${theme.menuItemSelectedBg};\n    color:            ${theme.menuItemSelectedFg};\n  }\n  &-with-new {\n    scroll-margin-bottom: ${addNewHeight};\n  }\n  &-new {\n    display: flex;\n    align-items: center;\n    color: ${theme.lightText};\n    position: sticky;\n    bottom: 0px;\n    height: ${addNewHeight};\n    background-color: ${theme.menuBg};\n    border-top: 1px solid ${theme.menuBorder};\n    scroll-margin-bottom: initial;\n  }\n  &-new.selected {\n    color: ${theme.menuItemSelectedFg};\n  }\n`);\n\nexport const cssPlusButton = styled(\"div\", `\n  display: flex;\n  width: 20px;\n  height: 20px;\n  border-radius: 20px;\n  margin-right: 8px;\n  align-items: center;\n  justify-content: center;\n  background-color: ${theme.autocompleteAddNewCircleBg};\n  color: ${theme.autocompleteAddNewCircleFg};\n\n  .selected > & {\n    background-color: ${theme.autocompleteAddNewCircleSelectedBg};\n  }\n`);\n\nexport const cssPlusIcon = styled(icon, `\n  background-color: ${theme.autocompleteAddNewCircleFg};\n`);\n\nconst cssRefEditIcon = styled(icon, `\n  background-color: ${theme.lightText};\n  position: absolute;\n  top: 0;\n  left: 0;\n  margin: 3px 3px 0 3px;\n`);\n\nconst cssMatchText = styled(\"span\", `\n  color: ${theme.autocompleteMatchText};\n  .selected > & {\n    color: ${theme.autocompleteSelectedMatchText};\n  }\n`);\n"
  },
  {
    "path": "app/client/widgets/ReferenceList.ts",
    "content": "import {\n  FormFieldRulesConfig,\n  FormOptionsAlignmentConfig,\n  FormOptionsLimitConfig,\n  FormOptionsSortConfig,\n} from \"app/client/components/Forms/FormConfig\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { testId, theme } from \"app/client/ui2018/cssVars\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { cssChoiceList, cssToken } from \"app/client/widgets/ChoiceListCell\";\nimport { choiceToken } from \"app/client/widgets/ChoiceToken\";\nimport { Reference } from \"app/client/widgets/Reference\";\nimport { isList } from \"app/common/gristTypes\";\n\nimport { Computed, dom, styled } from \"grainjs\";\n\n/**\n * ReferenceList - The widget for displaying lists of references to another table's records.\n */\nexport class ReferenceList extends Reference {\n  private _hasRecordCard = Computed.create(this, (use) => {\n    const table = use(this._refTable);\n    if (!table) { return false; }\n\n    return !use(use(table.recordCardViewSection).disabled);\n  });\n\n  public buildDom(row: DataRowModel) {\n    return cssChoiceList(\n      dom.cls(\"field_clip\"),\n      cssChoiceList.cls(\"-wrap\", this.wrapping),\n      dom.style(\"justify-content\", use => use(this.alignment) === \"right\" ? \"flex-end\" : use(this.alignment)),\n      dom.domComputed((use) => {\n        if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {\n          // Work around JS errors during certain changes (noticed when visibleCol field gets removed\n          // for a column using per-field settings).\n          return null;\n        }\n\n        const valueObs = row.cells[use(this.field.colId)];\n        const value = valueObs && use(valueObs);\n        if (!value) { return null; }\n\n        const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)];\n        const displayValue = displayValueObs && use(displayValueObs);\n        if (!displayValue) { return null; }\n\n        // TODO: Figure out what the implications of this block are for ReferenceList.\n        // if (isVersions(content)) {\n        //   // We can arrive here if the reference value is unchanged (viewed as a foreign key)\n        //   // but the content of its displayCol has changed.  Postponing doing anything about\n        //   // this until we have three-way information for computed columns.  For now,\n        //   // just showing one version of the cell.  TODO: elaborate.\n        //   return use(this._formatValue)(content[1].local || content[1].parent);\n        // }\n        const values = isList(value) ? value.slice(1) : [value];\n        const displayValues = isList(displayValue) ? displayValue.slice(1) : [displayValue];\n        // Use field.visibleColFormatter instead of field.formatter\n        // because we're formatting each list element to render tokens, not the whole list.\n        const formatter = use(this.field.visibleColFormatter);\n        return values.map((referenceId, i) => {\n          return {\n            referenceId,\n            formattedValue: formatter.formatAny(displayValues[i]),\n          };\n        });\n      },\n      (values) => {\n        if (!values) {\n          return null;\n        }\n        return values.map(({ referenceId, formattedValue }) => {\n          const isBlankReference = formattedValue.trim() === \"\";\n          return choiceToken(\n            [\n              cssRefIcon(\"FieldReference\",\n                cssRefIcon.cls(\"-view-as-card\", use =>\n                  referenceId !== 0 && use(this._hasRecordCard)),\n                dom.on(\"click\", async () => {\n                  if (referenceId === 0 || !this._hasRecordCard.get()) { return; }\n\n                  const rowId = referenceId as number;\n                  const sectionId = this._refTable.get()?.recordCardViewSectionRef();\n                  if (sectionId === undefined) {\n                    throw new Error(\"Unable to open Record Card: undefined section id\");\n                  }\n\n                  const anchorUrlState = { hash: { rowId, sectionId, recordCard: true } };\n                  await urlState().pushUrl(anchorUrlState, { replace: true });\n                }),\n                dom.on(\"mousedown\", (ev) => {\n                  ev.stopPropagation();\n                  ev.preventDefault();\n                }),\n                testId(\"ref-list-link-icon\"),\n              ),\n              cssLabel(isBlankReference ? \"[Blank]\" : formattedValue,\n                testId(\"ref-list-cell-token-label\"),\n              ),\n              dom.cls(cssRefIconAndLabel.className),\n            ],\n            {\n              blank: isBlankReference,\n            },\n            dom.cls(cssToken.className),\n            testId(\"ref-list-cell-token\"),\n          );\n        });\n      }),\n    );\n  }\n\n  public buildFormConfigDom() {\n    return [\n      this.buildTransformConfigDom(),\n      dom.create(FormOptionsAlignmentConfig, this.field),\n      dom.create(FormOptionsSortConfig, this.field),\n      dom.create(FormOptionsLimitConfig, this.field),\n      dom.create(FormFieldRulesConfig, this.field),\n    ];\n  }\n}\n\nconst cssRefIcon = styled(icon, `\n  --icon-color: ${theme.lightText};\n  flex-shrink: 0;\n  position: absolute;\n  left: 4px;\n\n  &-view-as-card {\n    cursor: pointer;\n  }\n  &-view-as-card:hover {\n    --icon-color: ${theme.controlFg};\n  }\n`);\n\nconst cssRefIconAndLabel = styled(\"div\", `\n  position: relative;\n  padding-left: 20px;\n`);\n\nconst cssLabel = styled(\"div\", `\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n`);\n"
  },
  {
    "path": "app/client/widgets/ReferenceListEditor.ts",
    "content": "import { createGroup } from \"app/client/components/commands\";\nimport { ACItem, ACResults, HighlightFunc, normalizeText } from \"app/client/lib/ACIndex\";\nimport { IAutocompleteOptions } from \"app/client/lib/autocomplete\";\nimport { ReferenceUtils } from \"app/client/lib/ReferenceUtils\";\nimport { IToken, TokenField, tokenFieldStyles } from \"app/client/lib/TokenField\";\nimport { reportError } from \"app/client/models/errors\";\nimport { colors, testId, theme } from \"app/client/ui2018/cssVars\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\nimport { cssChoiceToken } from \"app/client/widgets/ChoiceToken\";\nimport { createMobileButtons, getButtonMargins } from \"app/client/widgets/EditorButtons\";\nimport { EditorPlacement } from \"app/client/widgets/EditorPlacement\";\nimport { FieldOptions, NewBaseEditor } from \"app/client/widgets/NewBaseEditor\";\nimport { cssRefList, renderACItem } from \"app/client/widgets/ReferenceEditor\";\nimport { csvEncodeRow } from \"app/common/csvFormat\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { decodeObject, encodeObject } from \"app/plugin/objtypes\";\n\nimport { dom, styled } from \"grainjs\";\n\nclass ReferenceItem implements IToken, ACItem {\n  /**\n   * A slight misnomer: what actually gets shown inside the TokenField\n   * is the `text`. Instead, `label` identifies a Token in the TokenField by either\n   * its row id (if it has one) or its display text.\n   *\n   * TODO: Look into removing `label` from IToken altogether, replacing it with a solution\n   * similar to getItemText() from IAutocompleteOptions.\n   */\n  public label: string = typeof this.rowId === \"number\" ? String(this.rowId) : this.text;\n  public cleanText: string = normalizeText(this.text);\n\n  constructor(\n    public text: string,\n    public rowId: number | \"new\" | \"invalid\",\n  ) {}\n}\n\n/**\n * A ReferenceListEditor offers an autocomplete of choices from the referenced table.\n */\nexport class ReferenceListEditor extends NewBaseEditor {\n  protected cellEditorDiv: HTMLElement;\n  protected commandGroup: any;\n\n  private _enableAddNew: boolean;\n  private _showAddNew: boolean = false;\n  private _tokenField: TokenField<ReferenceItem>;\n  private _textInput: HTMLInputElement;\n  private _dom: HTMLElement;\n  private _editorPlacement!: EditorPlacement;\n  private _contentSizer: HTMLElement;   // Invisible element to size the editor with all the tokens\n  private _inputSizer!: HTMLElement;     // Part of _contentSizer to size the text input\n  private _alignment: string;\n  private _utils: ReferenceUtils;\n\n  constructor(protected options: FieldOptions) {\n    super(options);\n\n    const gristDoc = options.gristDoc;\n    this._utils = new ReferenceUtils(options.field, gristDoc);\n\n    const vcol = this._utils.visibleColModel;\n    this._enableAddNew = (\n      vcol &&\n      !vcol.isRealFormula() &&\n      !!vcol.colId() &&\n      !this._utils.hasDropdownCondition\n    );\n\n    const acOptions: IAutocompleteOptions<ReferenceItem> = {\n      menuCssClass: `${menuCssClass} ${cssRefList.className} test-autocomplete`,\n      buildNoItemsMessage: () => this._utils.buildNoItemsMessage(),\n      search: this._doSearch.bind(this),\n      renderItem: this._renderItem.bind(this),\n      getItemText: item => item.text,\n    };\n\n    this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));\n    this._alignment = options.field.widgetOptionsJson.peek().alignment || \"left\";\n\n    // If starting to edit by typing in a string, ignore previous tokens.\n    const cellValue = decodeObject(options.cellValue);\n    const startRowIds: unknown[] = options.editValue !== undefined || !Array.isArray(cellValue) ? [] : cellValue;\n\n    // If referenced table hasn't loaded yet, hold off on initializing tokens.\n    const needReload = (options.editValue === undefined && !this._utils.tableData.isLoaded);\n    const startTokens = needReload ?\n      [] : startRowIds.map(id => new ReferenceItem(this._utils.idToText(id), typeof id === \"number\" ? id : \"invalid\"));\n\n    this._tokenField = TokenField.ctor<ReferenceItem>().create(this, {\n      initialValue: startTokens,\n      renderToken: (item) => {\n        const isBlankReference = item.cleanText === \"\";\n        return [\n          isBlankReference ? \"[Blank]\" : item.text,\n          cssToken.cls(\"-blank\", isBlankReference),\n          cssChoiceToken.cls(\"-invalid\", item.rowId === \"invalid\"),\n        ];\n      },\n      createToken: text => new ReferenceItem(text, \"invalid\"),\n      acOptions,\n      openAutocompleteOnFocus: true,\n      readonly: options.readonly,\n      trimLabels: true,\n      styles: { cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon },\n    });\n\n    this._dom = dom(\"div.default_editor\",\n      dom.cls(\"readonly_editor\", options.readonly),\n      dom.cls(cssReadonlyStyle.className, options.readonly),\n      this.cellEditorDiv = cssCellEditor(testId(\"widget-text-editor\"),\n        this._contentSizer = cssContentSizer(),\n        elem => this._tokenField.attach(elem),\n      ),\n      createMobileButtons(options.commands),\n    );\n\n    this._textInput = this._tokenField.getTextInput();\n    dom.update(this._tokenField.getRootElem(),\n      dom.style(\"justify-content\", this._alignment),\n    );\n    dom.update(this._tokenField.getHiddenInput(),\n      this.commandGroup.attach(),\n    );\n    dom.update(this._textInput,\n      // Resize the editor whenever user types into the textbox.\n      dom.on(\"input\", () => this.resizeInput(true)),\n      dom.prop(\"value\", options.editValue || \"\"),\n      this.commandGroup.attach(),\n    );\n\n    dom.update(this._dom,\n      dom.on(\"click\", () => this._textInput.focus()),\n    );\n\n    // The referenced table has probably already been fetched (because there must already be a\n    // Reference widget instantiated), but it's better to avoid this assumption.\n    gristDoc.docData.fetchTable(this._utils.refTableId).then(() => {\n      if (this.isDisposed()) { return; }\n      if (needReload) {\n        this._tokenField.setTokens(\n          startRowIds.map(id => new ReferenceItem(this._utils.idToText(id), typeof id === \"number\" ? id : \"invalid\")),\n        );\n        this.resizeInput();\n      }\n      const autocomplete = this._tokenField.getAutocomplete();\n      if (autocomplete) {\n        autocomplete.search();\n      }\n    })\n      .catch(reportError);\n  }\n\n  public attach(cellElem: Element): void {\n    // Attach the editor dom to page DOM.\n    this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, { margins: getButtonMargins() });\n\n    // Reposition the editor if needed for external reasons (in practice, window resize).\n    this.autoDispose(this._editorPlacement.onReposition.addListener(() => this.resizeInput()));\n\n    // Update the sizing whenever the tokens change. Delay it till next tick to give a chance for\n    // DOM updates that happen around tokenObs changes, to complete.\n    this.autoDispose(this._tokenField.tokensObs.addListener(() =>\n      Promise.resolve().then(() => this.resizeInput())));\n\n    this.setSizerLimits();\n\n    // Once the editor is attached to DOM, resize it to content, focus, and set cursor.\n    this.resizeInput();\n    this._textInput.focus();\n    const pos = Math.min(this.options.cursorPos, this._textInput.value.length);\n    this._textInput.setSelectionRange(pos, pos);\n  }\n\n  public getDom(): HTMLElement {\n    return this._dom;\n  }\n\n  public getCellValue(): CellValue {\n    const rowIds = this._tokenField.tokensObs.get()\n      .map(token => typeof token.rowId === \"number\" ? token.rowId : token.text);\n    return encodeObject(rowIds);\n  }\n\n  public getTextValue(): string {\n    const rowIds = this._tokenField.tokensObs.get()\n      .map(token => typeof token.rowId === \"number\" ? String(token.rowId) : token.text);\n    return csvEncodeRow(rowIds, { prettier: true });\n  }\n\n  public getCursorPos(): number {\n    return this._textInput.selectionStart || 0;\n  }\n\n  /**\n   * If any 'new' item are saved, add them to the referenced table first.\n   */\n  public async prepForSave() {\n    const tokens = this._tokenField.tokensObs.get();\n    const newValues = tokens.filter(({ rowId }) => rowId === \"new\");\n    if (newValues.length === 0) { return; }\n\n    // Add the new items to the referenced table.\n    const colInfo = { [this._utils.visibleColId]: newValues.map(({ text }) => text) };\n    const rowIds = await this._utils.tableData.sendTableAction(\n      [\"BulkAddRecord\", new Array(newValues.length).fill(null), colInfo],\n    );\n\n    // Update the TokenField tokens with the returned row ids.\n    let i = 0;\n    const newTokens = tokens.map((token) => {\n      return token.rowId === \"new\" ? new ReferenceItem(token.text, rowIds[i++]) : token;\n    });\n    this._tokenField.setTokens(newTokens);\n  }\n\n  public setSizerLimits() {\n    // Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap\n    // once we reach it.\n    const rootElem = this._tokenField.getRootElem();\n    const maxSize = this._editorPlacement.calcSizeWithPadding(rootElem,\n      { width: Infinity, height: Infinity }, { calcOnly: true });\n    this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + \"px\";\n  }\n\n  /**\n   * Helper which resizes the token-field to match its content.\n   */\n  protected resizeInput(onlyTextInput: boolean = false) {\n    if (this.isDisposed()) { return; }\n\n    const rootElem = this._tokenField.getRootElem();\n\n    // To size the content, we need both the tokens and the text typed into _textInput. We\n    // re-create the tokens using cloneNode(true) copies all styles and properties, but not event\n    // handlers. We can skip this step when we know that only _textInput changed.\n    if (!onlyTextInput || !this._inputSizer) {\n      this._contentSizer.innerHTML = \"\";\n\n      dom.update(this._contentSizer,\n        dom.update(rootElem.cloneNode(true) as HTMLElement,\n          dom.style(\"width\", \"\"),\n          dom.style(\"height\", \"\"),\n          this._inputSizer = cssInputSizer(),\n\n          // Remove the testId('tokenfield') from the cloned element, to simplify tests (so that\n          // selecting .test-tokenfield only returns the actual visible tokenfield container).\n          dom.cls(\"test-tokenfield\", false),\n        ),\n      );\n    }\n\n    // Use a separate sizer to size _textInput to the text inside it.\n    // \\u200B is a zero-width space; so the sizer will have height even when empty.\n    this._inputSizer.textContent = this._textInput.value + \"\\u200B\";\n    const rect = this._contentSizer.getBoundingClientRect();\n\n    const size = this._editorPlacement.calcSizeWithPadding(rootElem, rect);\n    rootElem.style.width = size.width + \"px\";\n    rootElem.style.height = size.height + \"px\";\n    this._textInput.style.width = this._inputSizer.getBoundingClientRect().width + \"px\";\n  }\n\n  /**\n   * If the search text does not match anything exactly, adds 'new' item to it.\n   *\n   * Also see: prepForSave.\n   */\n  private async _doSearch(text: string): Promise<ACResults<ReferenceItem>> {\n    const { items, selectIndex, highlightFunc } = this._utils.autocompleteSearch(text, this.options.rowId);\n    const result: ACResults<ReferenceItem> = {\n      selectIndex,\n      highlightFunc,\n      items: items.map(i => new ReferenceItem(i.text, i.rowId)),\n      extraItems: [],\n    };\n\n    this._showAddNew = false;\n    if (!this._enableAddNew || !text) { return result; }\n\n    const cleanText = normalizeText(text);\n    if (result.items.find(item => item.cleanText === cleanText)) {\n      return result;\n    }\n\n    result.extraItems.push(new ReferenceItem(text, \"new\"));\n    this._showAddNew = true;\n\n    return result;\n  }\n\n  private _renderItem(item: ReferenceItem, highlightFunc: HighlightFunc) {\n    return renderACItem(\n      item.text,\n      highlightFunc,\n      item.rowId === \"new\",\n      this._showAddNew,\n    );\n  }\n}\n\nconst cssCellEditor = styled(\"div\", `\n  background-color: ${theme.cellEditorBg};\n  cursor: text;\n  font-family: var(--grist-font-family-data);\n  font-size: var(--grist-medium-font-size);\n`);\n\nconst cssTokenField = styled(tokenFieldStyles.cssTokenField, `\n  border: none;\n  align-items: start;\n  align-content: start;\n  padding: 0 3px;\n  height: min-content;\n  min-height: 22px;\n  flex-wrap: wrap;\n`);\n\nconst cssToken = styled(tokenFieldStyles.cssToken, `\n  padding: 1px 4px;\n  margin: 2px;\n  line-height: 16px;\n  white-space: pre;\n  color: ${theme.choiceTokenFg};\n\n  &.selected {\n    box-shadow: inset 0 0 0 1px ${theme.choiceTokenSelectedBorder};\n  }\n\n  &-blank {\n    color: ${theme.lightText};\n  }\n`);\n\nconst cssDeleteButton = styled(tokenFieldStyles.cssDeleteButton, `\n  position: absolute;\n  top: -8px;\n  right: -6px;\n  border-radius: 16px;\n  background-color: ${colors.dark};\n  width: 14px;\n  height: 14px;\n  cursor: pointer;\n  z-index: 1;\n  display: none;\n  align-items: center;\n  justify-content: center;\n\n  .${cssToken.className}:hover & {\n    display: flex;\n  }\n  .${cssTokenField.className}.token-dragactive & {\n    cursor: unset;\n  }\n`);\n\nconst cssDeleteIcon = styled(tokenFieldStyles.cssDeleteIcon, `\n  --icon-color: ${colors.light};\n  &:hover {\n    --icon-color: ${colors.darkGrey};\n  }\n`);\n\nconst cssContentSizer = styled(\"div\", `\n  position: absolute;\n  left: 0;\n  top: -100px;\n  border: none;\n  visibility: hidden;\n  overflow: visible;\n  width: max-content;\n\n  & .${tokenFieldStyles.cssInputWrapper.className} {\n    display: none;\n  }\n`);\n\nconst cssInputSizer = styled(\"div\", `\n  flex: auto;\n  min-width: 24px;\n  margin: 3px 2px;\n`);\n\nconst cssReadonlyStyle = styled(\"div\", `\n  padding-left: 16px;\n  background: white;\n`);\n"
  },
  {
    "path": "app/client/widgets/ReverseReferenceConfig.ts",
    "content": "import { allCommands } from \"app/client/components/commands\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { TableRec } from \"app/client/models/DocModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { cssCode } from \"app/client/ui/DocTutorial\";\nimport { Tooltip } from \"app/client/ui/GristTooltips\";\nimport {\n  cssLabelText,\n  cssRow,\n  cssSeparator,\n} from \"app/client/ui/RightPanelStyles\";\nimport { withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { textButton } from \"app/client/ui2018/buttons\";\nimport { testId, theme, vars } from \"app/client/ui2018/cssVars\";\nimport { cssIconButton, icon } from \"app/client/ui2018/icons\";\nimport { confirmModal } from \"app/client/ui2018/modals\";\nimport { not } from \"app/common/gutil\";\n\nimport { Computed, Disposable, dom, styled } from \"grainjs\";\n\nconst t = makeT(\"ReverseReferenceConfig\");\n\n/**\n * Configuratino for two-way reference column shown in the right panel.\n */\nexport class ReverseReferenceConfig extends Disposable {\n  private _refTable: Computed<TableRec | null>;\n  private _isConfigured: Computed<boolean>;\n  private _reverseTable: Computed<string>;\n  private _reverseColumn: Computed<string>;\n  private _reverseType: Computed<string>;\n  private _disabled: Computed<boolean>;\n  private _tooltip: Computed<Tooltip>;\n\n  constructor(private _field: ViewFieldRec) {\n    super();\n\n    this._refTable = Computed.create(this, use => use(use(this._field.column).refTable));\n    this._isConfigured = Computed.create(this, (use) => {\n      const column = use(this._field.column);\n      return use(column.hasReverse);\n    });\n    this._reverseTable = Computed.create(this, this._refTable, (use, refTable) => {\n      return refTable ? use(refTable.tableNameDef) : \"\";\n    });\n    this._reverseColumn = Computed.create(this, (use) => {\n      const column = use(this._field.column);\n      const reverseCol = use(column.reverseColModel);\n      return reverseCol ? use(reverseCol.label) ?? use(reverseCol.colId) : \"\";\n    });\n    this._reverseType = Computed.create(this, (use) => {\n      const column = use(this._field.column);\n      const reverseCol = use(column.reverseColModel);\n      return reverseCol ? use(reverseCol.pureType) : \"\";\n    });\n    this._disabled = Computed.create(this, (use) => {\n      // If is formula or is trigger formula.\n      const column = use(this._field.column);\n      return Boolean(use(column.formula));\n    });\n    this._tooltip = Computed.create(this, (use) => {\n      return use(this._disabled) ?\n        \"twoWayReferencesDisabled\" :\n        \"twoWayReferences\";\n    });\n  }\n\n  public buildDom() {\n    return dom(\"div\",\n      dom.maybe(not(this._isConfigured), () => [\n        cssRow(\n          dom.style(\"margin-top\", \"16px\"),\n          cssRow.cls(\"-disabled\", this._disabled),\n          withInfoTooltip(\n            textButton(\n              t(\"Add two-way reference\"),\n              dom.on(\"click\", e => this._toggle(e)),\n              testId(\"add-reverse-columm\"),\n              dom.prop(\"disabled\", this._disabled),\n            ),\n            this._tooltip,\n          ),\n        ),\n      ]),\n      dom.maybe(this._isConfigured, () => cssTwoWayConfig(\n        // TWO-WAY REFERENCE  (?)  [Remove]\n        cssRow(\n          dom.style(\"justify-content\", \"space-between\"),\n          withInfoTooltip(\n            cssLabelText(\n              t(\"Two-way Reference\"),\n            ),\n            \"twoWayReferences\",\n          ),\n          cssIconButton(\n            icon(\"Remove\"),\n            dom.on(\"click\", e => this._toggle(e)),\n            dom.style(\"cursor\", \"pointer\"),\n            testId(\"remove-reverse-column\"),\n          ),\n        ),\n        cssRow(\n          cssContent(\n            cssClipLine(\n              cssClipItem(\n                cssCapitalize(t(\"Target table\"), dom.style(\"margin-right\", \"8px\")),\n                dom(\"span\", dom.text(this._reverseTable)),\n              ),\n            ),\n            cssFlexBetween(\n              cssClipItem(\n                cssCapitalize(t(\"Column\"), dom.style(\"margin-right\", \"8px\")),\n                dom(\"span\", dom.text(this._reverseColumn)),\n                cssGrayText(\"(\", dom.text(this._reverseType), \")\"),\n              ),\n              cssIconButton(\n                cssShowOnHover.cls(\"\"),\n                cssNoClip.cls(\"\"),\n                cssIconAccent(\"Pencil\"),\n                dom.on(\"click\", () => this._editConfigClick()),\n                dom.style(\"cursor\", \"pointer\"),\n                testId(\"edit-reverse-column\"),\n              ),\n            ),\n          ),\n          testId(\"reverse-column-label\"),\n        ),\n        cssSeparator(\n          dom.style(\"margin-top\", \"16px\"),\n        ),\n      )),\n    );\n  }\n\n  private async _toggle(e: Event) {\n    e.stopPropagation();\n    e.preventDefault();\n    const column = this._field.column.peek();\n    if (!this._isConfigured.get()) {\n      await column.addReverseColumn();\n      return;\n    }\n    const onConfirm = async () => {\n      await column.removeReverseColumn();\n    };\n\n    const refCol = column.reverseColModel.peek().label.peek() || column.reverseColModel.peek().colId.peek();\n    const refTable = column.reverseColModel.peek().table.peek().tableNameDef.peek();\n\n    const promptTitle = t(\"Delete two-way reference?\");\n\n    const myTable = column.table.peek().tableNameDef.peek();\n    const myName = column.label.peek() || column.colId.peek();\n\n    const explanation = t(\n      \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column \\\n{{myName}} will remain in the current table {{myTable}}.\", {\n        refCol: dom(\"b\", refCol),\n        refTable: cssCode(refTable),\n        myName: dom(\"b\", myName),\n        myTable: cssCode(myTable),\n      });\n\n    confirmModal(\n      promptTitle,\n      t(\"Delete\"),\n      onConfirm,\n      {\n        explanation: cssHigherLine(explanation),\n        width: \"fixed-wide\",\n      },\n    );\n  }\n\n  private async _editConfigClick() {\n    const rawViewSection = this._refTable.get()?.rawViewSection.peek();\n    if (!rawViewSection) { return; }\n    await allCommands.showRawData.run(this._refTable.get()?.rawViewSectionRef.peek());\n    const reverseColId = this._field.column.peek().reverseColModel.peek().colId.peek();\n    if (!reverseColId) { return; } // might happen if it is censored.\n    const targetField = rawViewSection.viewFields.peek().all()\n      .find(f => f.colId.peek() === reverseColId);\n    if (!targetField) { return; }\n    await allCommands.setCursor.run(null, targetField);\n  }\n}\n\nconst cssTwoWayConfig = styled(\"div\", ``);\nconst cssShowOnHover = styled(\"div\", `\n  visibility: hidden;\n  .${cssTwoWayConfig.className}:hover & {\n    visibility: visible;\n  }\n`);\n\nconst cssContent = styled(\"div\", `\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  flex: 1;\n`);\n\nconst cssFlexRow = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  overflow: hidden;\n`);\n\nconst cssFlexBetween = styled(cssFlexRow, `\n  justify-content: space-between;\n  overflow: hidden;\n`);\n\nconst cssCapitalize = styled(\"span\", `\n  text-transform: uppercase;\n  font-size: ${vars.xsmallFontSize};\n  color: ${theme.lightText};\n`);\n\nconst cssClipLine = styled(\"div\", `\n  display: flex;\n  align-items: baseline;\n  gap: 3px;\n  overflow: hidden;\n  flex: 1;\n`);\n\nconst cssClipItem = styled(\"div\", `\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`);\n\nconst cssNoClip = styled(\"div\", `\n  flex: none;\n`);\n\nconst cssGrayText = styled(\"span\", `\n  color: ${theme.lightText};\n  margin-left: 4px;\n`);\n\nconst cssIconAccent = styled(icon, `\n  --icon-color: ${theme.accentIcon};\n`);\n\nconst cssHigherLine = styled(\"div\", `\n  line-height: 1.5;\n`);\n"
  },
  {
    "path": "app/client/widgets/Spinner.css",
    "content": ".widget_spinner {\n  padding-right: 15px;\n}\n"
  },
  {
    "path": "app/client/widgets/Spinner.ts",
    "content": "import * as kf from \"app/client/lib/koForm\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { NumericTextBox } from \"app/client/widgets/NumericTextBox\";\nimport { buildNumberFormat } from \"app/common/NumberFormat\";\n\nimport { dom } from \"grainjs\";\nimport * as ko from \"knockout\";\n\n/**\n * Spinner - A widget with a text field and spinner.\n */\nexport class Spinner extends NumericTextBox {\n  private _stepSize: ko.Computed<number>;\n\n  constructor(field: ViewFieldRec) {\n    super(field);\n    const resolved = this.autoDispose(ko.computed(() => {\n      const { numMode } = this.options();\n      const docSettings = this.field.documentSettings();\n      return buildNumberFormat({ numMode }, docSettings).resolvedOptions();\n    }));\n    this._stepSize = this.autoDispose(ko.computed(() => {\n      const extraScaling = (this.options().numMode === \"percent\") ? 2 : 0;\n      return Math.pow(10, -(this.options().decimals || resolved().minimumFractionDigits) - extraScaling);\n    }));\n  }\n\n  public buildDom(row: DataRowModel) {\n    const value = row.cells[this.field.colId.peek()];\n    return dom.update(super.buildDom(row),\n      dom.cls(\"widget_spinner\"),\n      kf.spinner(value, this._stepSize),\n    );\n  }\n}\n"
  },
  {
    "path": "app/client/widgets/TZAutocomplete.ts",
    "content": "import { ACIndexImpl } from \"app/client/lib/ACIndex\";\nimport { ACSelectItem, buildACSelect } from \"app/client/lib/ACSelect\";\nimport { MomentTimezone } from \"app/client/lib/imports\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { nativeCompare } from \"app/common/gutil\";\n\nimport { IDisposableOwner, Observable } from \"grainjs\";\n\n/**\n * Returns the ordered list of offsets for names at time timestamp. See timezoneOptions for details\n * on the sorting order.\n */\n// exported for testing\nexport function timezoneOptionsImpl(\n  timestamp: number, names: string[], moment: MomentTimezone,\n): ACSelectItem[] {\n  // What we want is moment(timestamp) but the dynamic import with our compiling settings produces\n  // \"moment is not a function\". The following is equivalent, and easier than fixing import setup.\n  const m = moment.unix(timestamp / 1000);\n\n  const options = names.map(value => ({\n    cleanText: value.toLowerCase().trim(),\n    value,\n    label: `(GMT${m.tz(value).format(\"Z\")}) ${value}`,\n    // A quick test reveal that it is a bit more efficient (~0.02ms) to get the offset using\n    // `moment.tz.Zone#parse` than creating a Moment instance for each zone and then getting the\n    // offset with `moment#utcOffset`.\n    offset: -moment.tz.zone(value)!.parse(timestamp),\n  }));\n  options.sort((a, b) => nativeCompare(a.offset, b.offset) || nativeCompare(a.value, b.value));\n  return options;\n}\n\n/**\n * Returns the array of IOptionFull<string> expected by `select` to create the list of timezones\n * options. The returned list is sorted based on the current offset (GMT-11:00 before GMT-10:00),\n * and then on alphabetical order of the name.\n */\nfunction timezoneOptions(moment: MomentTimezone): ACSelectItem[] {\n  return timezoneOptionsImpl(Date.now(), moment.tz.names(), moment);\n}\n\n/**\n * Creates a textbox with an autocomplete dropdown to select a time zone.\n * Usage: dom.create(buildTZAutocomplete, momentModule, valueObs, saveCallback)\n */\nexport function buildTZAutocomplete(\n  owner: IDisposableOwner,\n  moment: MomentTimezone,\n  valueObs: Observable<string>,\n  save: (value: string) => Promise<void> | void,\n  options?: { disabled?: Observable<boolean> },\n) {\n  // Set a large maxResults, since it's sometimes nice to see all supported timezones (there are\n  // fewer than 1000 in practice).\n  const acIndex = new ACIndexImpl<ACSelectItem>(timezoneOptions(moment), {\n    maxResults: 1000,\n    keepOrder: true,\n  });\n\n  // Only save valid time zones. If there is no selected item, we'll auto-select and save only\n  // when there is a good match.\n  const saveTZ = (value: string, item: ACSelectItem | undefined) => {\n    if (!item) {\n      const results = acIndex.search(value);\n      if (results.selectIndex >= 0 && results.items.length > 0) {\n        item = results.items[results.selectIndex];\n        value = item.value;\n      }\n    }\n    if (!item) { throw new Error(\"Invalid time zone\"); }\n    if (value !== valueObs.get()) {\n      return save(value);\n    }\n  };\n  return buildACSelect(owner,\n    { ...options, acIndex, valueObs, save: saveTZ },\n    testId(\"tz-autocomplete\"),\n  );\n}\n"
  },
  {
    "path": "app/client/widgets/TextBox.css",
    "content": ".record-add .field_clip {\n  background-color: var(--grist-theme-table-add-new-bg, inherit);\n}\n\n@media not print {\n  .formula_field, .formula_field_edit {\n    padding-left: 18px;\n  }\n  .formula_field_edit {\n    color: #D0D0D0;\n  }\n\n  .formula_field .field-icon,\n  .formula_field_edit::before,\n  .formula_field_sidepane::before {\n    /* based on standard icon styles */\n    content: \"\";\n    position: absolute;\n    top: 4px;\n    left: 2px;\n    display: inline-block;\n    vertical-align: middle;\n    -webkit-mask-repeat: no-repeat;\n    -webkit-mask-position: center;\n    -webkit-mask-size: contain;\n    -webkit-mask-image: var(--icon-FunctionResult);\n    width: 16px;\n    height: 16px;\n    background-color: var(--icon-color, black);\n    cursor: pointer;\n  }\n\n  .formula_field .field-icon, .formula_field_edit::before {\n    background-color: var(--grist-theme-formula-icon, #D0D0D0);\n  }\n  .formula_field_edit:not(.readonly)::before {\n    background-color: var(--grist-color-cursor);\n  }\n  .formula_field.invalid .field-icon {\n    background-color: white;\n    color: #ffb6c1;\n  }\n  .formula_field_edit.invalid::before {\n    background-color: var(--grist-color-cursor);\n    color: #ffb6c1;\n  }\n\n  .readonly_editor .formula_field_edit::before {\n    display: none;\n  }\n}\n\n.invalid-text input {\n  background-color: #ffb6c1;\n}\n"
  },
  {
    "path": "app/client/widgets/TextEditor.css",
    "content": ".cell_editor {\n  position: absolute;\n  z-index: 1000;        /* make it higher than popper's 999 */\n  display: flex;\n}\n\n.default_editor {\n  box-shadow: 0 0 3px 2px var(--grist-theme-cursor, var(--grist-color-cursor));\n}\n\n.readonly_editor {\n  box-shadow: 0 0 3px 2px var(--grist-theme-cursor-readonly, var(--grist-color-slate));\n}\n\n/* make room for lock icon */\n.readonly_editor .celleditor_cursor_editor .celleditor_text_editor,\n.readonly_editor .celleditor_cursor_editor .celleditor_content_measure {\n  padding-left: 18px;\n}\n\n.readonly_editor::before {\n  content: \"\";\n  position: absolute;\n  top: 0;\n  left: 0;\n  margin: 4px 3px 0 3px;\n  width: 13px;\n  height: 13px;\n  background-color: #D0D0D0;\n  -webkit-mask-repeat: no-repeat;\n  -webkit-mask-position: center;\n  -webkit-mask-size: contain;\n  -webkit-mask-image: var(--icon-Lock);\n}\n\n.formula_editor_wrapper {\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n}\n\n/* Make overflow hidden, since editor might be 1 pixel bigger due to fix for devices\n * with different pixel ratio */\n.formula_editor {\n  background-color: var(--grist-theme-ace-editor-bg, white);\n  padding: 4px 4px 2px 21px;\n  z-index: 10;\n  overflow: hidden;\n  flex: none;\n  min-height: 22px;   /* this is the usual height, but helps slightly when font is shorter than expected */\n}\n\n/* styles specific to the formula editor in the side panel */\n.default_editor.formula_editor_sidepane {\n  border-radius: 3px;\n}\n.formula_editor_sidepane > .formula_editor {\n  padding: 5px 0 5px 24px;\n  border-radius: 3px;\n}\n.formula_editor_sidepane > .formula_field_edit::before, .formula_field_sidepane::before {\n  left: 4px;\n}\n\n.celleditor_cursor_editor {\n  background-color: var(--grist-theme-cell-editor-bg, white);\n\n  /* the following are copied from .field_clip */\n  padding: 3px 3px 0px 3px;\n  font-family: var(--grist-font-family-data);\n  font-size: var(--grist-medium-font-size);\n  line-height: 18px;\n  min-height: 21px;\n  white-space: pre-wrap;\n  overflow-wrap: break-word;\n}\n\n.celleditor_text_editor {\n  display: block;\n  outline: none;\n  padding: 0px;\n  margin: 0px;\n  border: none;\n  resize: none;\n  z-index: 10;\n  background-color: var(--grist-theme-cell-editor-bg, unset);\n  color: var(--grist-theme-cell-editor-fg, black);\n\n  /* Inherit styles, same as for .celleditor_content_measure, to ensure that sizes correspond. */\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n}\n\n.celleditor_text_editor::placeholder {\n  color: var(--grist-theme-cell-editor-placeholder-fg, unset);\n}\n\n.celleditor_content_measure {\n  position: absolute;\n  left: 0;\n  top: 0;\n  border: none;\n  visibility: hidden;\n  overflow: visible;\n  /* with 'pre-wrap', this lets the editor gets as wide as needed before wrapping; */\n  /* width is limited only by max-width (which is set in JS code). */\n  width: max-content;\n\n  /* Inherit styles, same as for .celleditor_text_editor, to ensure that sizes correspond. */\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n}\n\n.error_msg {\n  display: flex;\n  background-color: #ffb6c1;\n  padding: 4px;\n  color: black;\n  white-space: pre-wrap;\n  flex: none;\n  overflow: auto;\n}\n\n.error_details {\n  background-color: #F8EAD5;  /* 20% of color-warning-bg */\n  font-family: 'Monaco', 'Menlo', monospace;\n  font-size: 12px;\n  white-space: pre-wrap;\n  flex: auto;\n  overflow: auto;\n  word-break: break-all;\n}\n\n.error_details_inner {\n  padding: 2px 2px 2px 28px;\n}\n\n.kf_collapser {\n  height: 1.2rem;\n}\n"
  },
  {
    "path": "app/client/widgets/TextEditor.js",
    "content": "var _ = require(\"underscore\");\nvar gutil = require(\"app/common/gutil\");\nvar dom = require(\"../lib/dom\");\nvar kd = require(\"../lib/koDom\");\nvar dispose = require(\"../lib/dispose\");\nvar BaseEditor = require(\"./BaseEditor\");\nvar commands = require(\"../components/commands\");\nconst {testId} = require(\"app/client/ui2018/cssVars\");\nconst {createMobileButtons, getButtonMargins} = require(\"app/client/widgets/EditorButtons\");\nconst {EditorPlacement} = require(\"app/client/widgets/EditorPlacement\");\nconst {observable} = require(\"grainjs\");\n\n/**\n * Required parameters:\n * @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.\n * @param {Object} options.cellValue: The value in the underlying cell being edited.\n * @param {String} options.editValue: String to be edited, or undefined to use cellValue.\n * @param {Number} options.cursorPos: The initial position where to place the cursor.\n * @param {Object} options.commands: Object mapping command names to functions, to enable as part\n *  of the command group that should be activated while the editor exists.\n *\n * Optional parameters:\n * @param {String} options.placeholder: Optional placeholder for the textarea.\n *\n * TextEditor exposes the following members, which derived classes may use:\n * @member {Object} this.options: Options as passed into the constructor.\n * @member {Node} this.dom: The DOM element for the editor.\n * @member {Node} this.textInput: The textarea element of the editor (contained within this.dom).\n * @member {Object} this.commandGroup: The CommandGroup created from options.commands.\n */\nfunction TextEditor(options) {\n  this.options = options;\n  this.commandGroup = this.autoDispose(commands.createGroup(options.commands, null, true));\n  this._alignment = options.field.widgetOptionsJson.peek().alignment || \"left\";\n  // calculate initial value (state, requested edited value or a current cell value)\n  const initialValue = gutil.undef(options.state, options.editValue, String(options.cellValue == null ? \"\" : options.cellValue));\n  // create observable with current state\n  this.editorState = this.autoDispose(observable(initialValue));\n\n  this.dom = dom(\"div.default_editor\",\n    kd.toggleClass(\"readonly_editor\", options.readonly),\n    this.cellEditorDiv = dom(\"div.celleditor_cursor_editor\", dom.testId(\"TextEditor_editor\"),\n      testId(\"widget-text-editor\"),   // new-style testId matches NTextEditor, for more uniform tests.\n      this.contentSizer = dom(\"div.celleditor_content_measure\"),\n      this.textInput = dom(\"textarea.celleditor_text_editor\",\n        kd.attr(\"placeholder\", options.placeholder || \"\"),\n        kd.style(\"text-align\", this._alignment),\n        kd.boolAttr(\"readonly\", options.readonly),\n        kd.value(initialValue),\n        this.commandGroup.attach(),\n\n        // Resize the textbox whenever user types in it.\n        dom.on(\"input\", () => this.onChange())\n      )\n    ),\n    createMobileButtons(options.commands),\n  );\n}\n\ndispose.makeDisposable(TextEditor);\n_.extend(TextEditor.prototype, BaseEditor.prototype);\n\nTextEditor.prototype.attach = function(cellElem) {\n  // Attach the editor dom to page DOM.\n  this.editorPlacement = EditorPlacement.create(this, this.dom, cellElem, {margins: getButtonMargins()});\n\n  // Reposition the editor if needed for external reasons (in practice, window resize).\n  this.autoDispose(this.editorPlacement.onReposition.addListener(this._resizeInput, this));\n\n  this.setSizerLimits();\n\n  // Once the editor is attached to DOM, resize it to content, focus, and set cursor.\n  this._resizeInput();\n  this.textInput.focus();\n  var pos = Math.min(this.options.cursorPos, this.textInput.value.length);\n  this.textInput.setSelectionRange(pos, pos);\n};\n\nTextEditor.prototype.getDom = function() {\n  return this.dom;\n};\n\nTextEditor.prototype.setSizerLimits = function() {\n  // Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap\n  // once we reach it.\n  const maxSize = this.editorPlacement.calcSizeWithPadding(this.textInput,\n    {width: Infinity, height: Infinity}, {calcOnly: true});\n  this.contentSizer.style.maxWidth = Math.ceil(maxSize.width) + \"px\";\n};\n\nTextEditor.prototype.getCellValue = function() {\n  return this.textInput.value;\n};\n\nTextEditor.prototype.onChange = function() {\n  if (this.editorState)\n    this.editorState.set(this.getTextValue());\n  this._resizeInput();\n};\n\nTextEditor.prototype.getTextValue = function() {\n  return this.textInput.value;\n};\n\nTextEditor.prototype.getCursorPos = function() {\n  return this.textInput.selectionStart;\n};\n\n/**\n * Helper which resizes textInput to match its content. It relies on having a contentSizer element\n * with the same font/size settings as the textInput, and on having `calcSize` helper,\n * which is provided by the EditorPlacement class.\n */\nTextEditor.prototype._resizeInput = function() {\n  var textInput = this.textInput;\n  // \\u200B is a zero-width space; it is used so the textbox will expand vertically\n  // on newlines, but it does not add any width.\n  this.contentSizer.textContent = textInput.value + \"\\u200B\";\n  var rect = this.contentSizer.getBoundingClientRect();\n\n  // Allow for a bit of extra space after the cursor (only desirable when text is left-aligned).\n  if (this._alignment === \"left\") {\n    rect.width += 16;\n  }\n\n  var size = this.editorPlacement.calcSizeWithPadding(textInput, rect);\n  textInput.style.width = size.width + \"px\";\n  textInput.style.height = size.height + \"px\";\n};\n\nmodule.exports = TextEditor;\n"
  },
  {
    "path": "app/client/widgets/Toggle.ts",
    "content": "import * as commands from \"app/client/components/commands\";\nimport { FormFieldRulesConfig } from \"app/client/components/Forms/FormConfig\";\nimport { fromKoSave } from \"app/client/lib/fromKoSave\";\nimport { makeT } from \"app/client/lib/localization\";\nimport { DataRowModel } from \"app/client/models/DataRowModel\";\nimport { ViewFieldRec } from \"app/client/models/entities/ViewFieldRec\";\nimport { fieldWithDefault, KoSaveableObservable } from \"app/client/models/modelUtil\";\nimport { FormToggleFormat } from \"app/client/ui/FormAPI\";\nimport { cssLabel, cssRow } from \"app/client/ui/RightPanelStyles\";\nimport { buttonSelect } from \"app/client/ui2018/buttonSelect\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { toggleSwitch } from \"app/client/ui2018/toggleSwitch\";\nimport { NewAbstractWidget, Options } from \"app/client/widgets/NewAbstractWidget\";\nimport { components } from \"app/common/ThemePrefs\";\n\nimport { dom, DomContents, DomElementArg, fromKo, makeTestId, styled } from \"grainjs\";\n\nconst t = makeT(\"Toggle\");\n\nconst testId = makeTestId(\"test-toggle-\");\n\n/**\n * ToggleBase - The base class for toggle widgets, such as a checkbox or a switch.\n */\nabstract class ToggleBase extends NewAbstractWidget {\n  public buildFormConfigDom(): DomContents {\n    const format = fieldWithDefault<FormToggleFormat>(\n      this.field.widgetOptionsJson.prop(\"formToggleFormat\"),\n      \"switch\",\n    );\n\n    return [\n      cssLabel(t(\"Field Format\")),\n      cssRow(\n        buttonSelect(\n          fromKoSave(format),\n          [\n            { value: \"switch\", label: t(\"Switch\") },\n            { value: \"checkbox\", label: t(\"Checkbox\") },\n          ],\n          testId(\"form-field-format\"),\n        ),\n      ),\n      dom.create(FormFieldRulesConfig, this.field),\n    ];\n  }\n\n  protected _addClickEventHandlers(row: DataRowModel) {\n    return [\n      dom.on(\"click\", (event) => {\n        if (event.shiftKey) {\n          // Shift-click is for selection, don't also toggle the checkbox during it.\n          return;\n        }\n        if (!this.field.column().isRealFormula()) {\n          // Move the cursor here, and pretend that enter was pressed. This triggers an editing\n          // flow which is handled by CheckBoxEditor.skipEditor(). This way the edit applies to\n          // editRow, which handles setting default values based on widget linking.\n          commands.allCommands.setCursor.run(row, this.field);\n          commands.allCommands.input.run(\"<enter>\");\n        }\n      }),\n      dom.on(\"dblclick\", (event) => {\n        // Don't start editing the field when a toggle is double-clicked.\n        event.stopPropagation();\n        event.preventDefault();\n      }),\n    ];\n  }\n}\n\nexport class ToggleCheckBox extends ToggleBase {\n  constructor(field: ViewFieldRec, _options: Options = {}) {\n    super(field, { defaultTextColor: theme.toggleCheckboxFg.toString() });\n  }\n\n  public buildDom(row: DataRowModel) {\n    const value = row.cells[this.field.colId.peek()] as KoSaveableObservable<boolean>;\n    return dom(\"div.field_clip\",\n      buildCheckbox(value, this._addClickEventHandlers(row)),\n    );\n  }\n}\n\nexport class ToggleSwitch extends ToggleBase {\n  constructor(field: ViewFieldRec, _options: Options = {}) {\n    super(field, {\n      defaultTextColor: components.switchActiveSlider.getRawValue(),\n    });\n  }\n\n  public override buildDom(row: DataRowModel) {\n    const value = row.cells[this.field.colId.peek()] as KoSaveableObservable<boolean>;\n    return dom(\"div.field_clip\",\n      // For printing, we will show this as a checkbox (without handlers).\n      buildCheckbox(value, dom.cls(\"screen-force-hide\")),\n      // For screen, we will show this as a switch (with handlers).\n      buildSwitch(\n        value,\n        row._isRealChange,\n        this._addClickEventHandlers(row),\n        dom.cls(\"print-force-hide\"),\n      ),\n    );\n  }\n}\n\nfunction buildCheckbox(value: KoSaveableObservable<boolean>, ...args: DomElementArg[]) {\n  return dom(\"div.widget_checkbox\",\n    dom(\"div.widget_checkmark\",\n      dom.show(value),\n      dom(\"div.checkmark_kick\"),\n      dom(\"div.checkmark_stem\"),\n    ),\n    ...args,\n  );\n}\n\nfunction buildSwitch(\n  value: KoSaveableObservable<boolean>,\n  isTransitionEnabled: ko.Observable<boolean>,\n  ...args: DomElementArg[]) {\n  return toggleSwitch(fromKo(value), {\n    args: [dom.cls(cssToggleSwitch.className), ...args],\n    useHiddenInput: false,\n    enableTransitions: fromKo(isTransitionEnabled),\n  });\n}\n\n/* user can define a color in the column config: expose it to the switch component */\nconst cssToggleSwitch = styled(\"div\", `\n  margin: -1px auto;\n  --grist-theme-switch-active-slider: var(--grist-actual-cell-color, ${components.switchActiveSlider.getRawValue()});\n`);\n"
  },
  {
    "path": "app/client/widgets/UserType.ts",
    "content": "import _ from \"underscore\";\n\n/**\n * Given a widget name and a type, return the name of the widget that would\n * actually be used for that type (hopefully the same, unless falling back\n * on a default if widget name is unlisted), and all default configuration\n * information for that widget/type combination.\n * Returns something of form:\n * {\n *   name:\"WidgetName\",\n *   config: {\n *     cons: \"NameOfWidgetClass\",\n *     editCons: \"NameOfEditorClass\",\n *     options: { ... default options for widget ... }\n *   }\n * }\n */\nexport function getWidgetConfiguration(widgetName: string, type: string) {\n  const oneTypeDef = typeDefs[type] || typeDefs.Text;\n  if (!(widgetName in oneTypeDef.widgets)) {\n    widgetName = oneTypeDef.default;\n  }\n  return {\n    name: widgetName,\n    config: oneTypeDef.widgets[widgetName],\n  };\n}\n\nexport function mergeOptions(options: any, type: string) {\n  const { name, config } = getWidgetConfiguration(options.widget, type);\n  return _.defaults({ widget: name }, options, config.options);\n}\n\n// Contains the list of types with their storage types, possible widgets, default widgets,\n// and defaults for all widget settings\n// The names of widgets are used, instead of the actual classes needed, in order to limit\n// the spread of dependencies.  See ./UserTypeImpl for actual classes.\nexport const typeDefs: any = {\n  // TODO : translate labels (can not use classic makeT function)\n  Any: {\n    label: \"Any\",\n    icon: \"FieldAny\",\n    widgets: {\n      TextBox: {\n        cons: \"TextBox\",\n        editCons: \"TextEditor\",\n        icon: \"FieldTextbox\",\n        options: {\n          alignment: \"left\",\n          wrap: undefined,\n        },\n      },\n    },\n    default: \"TextBox\",\n  },\n  Text: {\n    label: \"Text\",\n    icon: \"FieldText\",\n    widgets: {\n      TextBox: {\n        cons: \"TextBox\",\n        editCons: \"TextEditor\",\n        icon: \"FieldTextbox\",\n        options: {\n          alignment: \"left\",\n          wrap: undefined,\n        },\n      },\n      Markdown: {\n        cons: \"MarkdownTextBox\",\n        editCons: \"TextEditor\",\n        icon: \"FieldMarkdown\",\n        options: {\n          alignment: \"left\",\n          wrap: undefined,\n        },\n      },\n      HyperLink: {\n        cons: \"HyperLinkTextBox\",\n        editCons: \"HyperLinkEditor\",\n        icon: \"FieldLink\",\n        options: {\n          alignment: \"left\",\n          wrap: undefined,\n        },\n      },\n    },\n    default: \"TextBox\",\n  },\n  Numeric: {\n    label: \"Numeric\",\n    icon: \"FieldNumeric\",\n    widgets: {\n      TextBox: {\n        cons: \"NumericTextBox\",\n        editCons: \"NumericEditor\",\n        icon: \"FieldTextbox\",\n        options: {\n          alignment: \"right\",\n          wrap: undefined,\n          decimals: undefined,\n          maxDecimals: undefined,\n          numMode: undefined,\n          numSign: undefined,\n          currency: undefined,\n        },\n      },\n      Spinner: {\n        cons: \"Spinner\",\n        editCons: \"NumericEditor\",\n        icon: \"FieldSpinner\",\n        options: {\n          alignment: \"right\",\n          wrap: undefined,\n          decimals: undefined,\n          maxDecimals: undefined,\n          numMode: undefined,\n          numSign: undefined,\n          currency: undefined,\n        },\n      },\n    },\n    default: \"TextBox\",\n  },\n  Int: {\n    label: \"Integer\",\n    icon: \"FieldInteger\",\n    widgets: {\n      TextBox: {\n        cons: \"NumericTextBox\",\n        editCons: \"TextEditor\",\n        icon: \"FieldTextbox\",\n        options: {\n          decimals: 0,\n          alignment: \"right\",\n          wrap: undefined,\n          maxDecimals: undefined,\n          numMode: undefined,\n          numSign: undefined,\n          currency: undefined,\n        },\n      },\n      Spinner: {\n        cons: \"Spinner\",\n        editCons: \"TextEditor\",\n        icon: \"FieldSpinner\",\n        options: {\n          decimals: 0,\n          alignment: \"right\",\n          wrap: undefined,\n          maxDecimals: undefined,\n          numMode: undefined,\n          numSign: undefined,\n          currency: undefined,\n        },\n      },\n    },\n    default: \"TextBox\",\n  },\n  Bool: {\n    label: \"Toggle\",\n    icon: \"FieldToggle\",\n    widgets: {\n      TextBox: {\n        cons: \"TextBox\",\n        formCons: \"Switch\",\n        editCons: \"TextEditor\",\n        icon: \"FieldTextbox\",\n        options: {\n          alignment: \"center\",\n          wrap: undefined,\n        },\n      },\n      CheckBox: {\n        cons: \"CheckBox\",\n        editCons: \"CheckBoxEditor\",\n        icon: \"FieldCheckbox\",\n        options: {},\n      },\n      Switch: {\n        cons: \"Switch\",\n        editCons: \"CheckBoxEditor\",\n        icon: \"FieldSwitcher\",\n        options: {},\n      },\n    },\n    default: \"CheckBox\",\n  },\n  Date: {\n    label: \"Date\",\n    icon: \"FieldDate\",\n    widgets: {\n      TextBox: {\n        cons: \"DateTextBox\",\n        editCons: \"DateEditor\",\n        icon: \"FieldTextbox\",\n        options: {\n          dateFormat: \"YYYY-MM-DD\",\n          isCustomDateFormat: false,\n          alignment: \"left\",\n        },\n      },\n    },\n    default: \"TextBox\",\n  },\n  DateTime: {\n    label: \"DateTime\",\n    icon: \"FieldDateTime\",\n    widgets: {\n      TextBox: {\n        cons: \"DateTimeTextBox\",\n        editCons: \"DateTimeEditor\",\n        icon: \"FieldTextbox\",\n        options: {\n          dateFormat: \"YYYY-MM-DD\",   // Default to ISO standard: https://xkcd.com/1179/\n          timeFormat: \"h:mma\",\n          isCustomDateFormat: false,\n          isCustomTimeFormat: false,\n          alignment: \"left\",\n        },\n      },\n    },\n    default: \"TextBox\",\n  },\n  Choice: {\n    label: \"Choice\",\n    icon: \"FieldChoice\",\n    widgets: {\n      TextBox: {\n        cons: \"ChoiceTextBox\",\n        editCons: \"ChoiceEditor\",\n        icon: \"FieldTextbox\",\n        options: {\n          alignment: \"left\",\n          wrap: undefined,\n          choices: undefined,\n          choiceOptions: undefined,\n        },\n      },\n    },\n    default: \"TextBox\",\n  },\n  ChoiceList: {\n    label: \"Choice List\",\n    icon: \"FieldChoice\",\n    widgets: {\n      TextBox: {\n        cons: \"ChoiceListCell\",\n        editCons: \"ChoiceListEditor\",\n        icon: \"FieldTextbox\",\n        options: {\n          alignment: \"left\",\n          wrap: undefined,\n          choices: undefined,\n          choiceOptions: undefined,\n        },\n      },\n    },\n    default: \"TextBox\",\n  },\n  Ref: {\n    label: \"Reference\",\n    icon: \"FieldReference\",\n    widgets: {\n      Reference: {\n        cons: \"Reference\",\n        editCons: \"ReferenceEditor\",\n        icon: \"FieldReference\",\n        options: {\n          alignment: \"left\",\n          wrap: undefined,\n        },\n      },\n    },\n    default: \"Reference\",\n  },\n  RefList: {\n    label: \"Reference List\",\n    icon: \"FieldReference\",\n    widgets: {\n      Reference: {\n        cons: \"ReferenceList\",\n        editCons: \"ReferenceListEditor\",\n        icon: \"FieldReference\",\n        options: {\n          alignment: \"left\",\n          wrap: undefined,\n        },\n      },\n    },\n    default: \"Reference\",\n  },\n  Attachments: {\n    label: \"Attachment\",\n    icon: \"FieldAttachment\",\n    widgets: {\n      Attachments: {\n        cons: \"AttachmentsWidget\",\n        editCons: \"AttachmentsEditor\",\n        icon: \"FieldAttachment\",\n        options: {\n          height: \"36\",\n        },\n      },\n    },\n    default: \"Attachments\",\n  },\n};\n\n// Extract widgets type to a type from the json above\nexport type WidgetType = \"TextBox\" | \"Markdown\" | \"HyperLink\" | \"Spinner\" |\n  \"CheckBox\" | \"Switch\" | \"Reference\" | \"Attachments\";\n"
  },
  {
    "path": "app/client/widgets/UserTypeImpl.ts",
    "content": "import { AttachmentsEditor } from \"app/client/widgets/AttachmentsEditor\";\nimport { AttachmentsWidget } from \"app/client/widgets/AttachmentsWidget\";\nimport CheckBoxEditor from \"app/client/widgets/CheckBoxEditor\";\nimport ChoiceEditor from \"app/client/widgets/ChoiceEditor\";\nimport { ChoiceListCell } from \"app/client/widgets/ChoiceListCell\";\nimport { ChoiceListEditor } from \"app/client/widgets/ChoiceListEditor\";\nimport { ChoiceTextBox } from \"app/client/widgets/ChoiceTextBox\";\nimport { DateEditor } from \"app/client/widgets/DateEditor\";\nimport DateTextBox from \"app/client/widgets/DateTextBox\";\nimport { DateTimeEditor } from \"app/client/widgets/DateTimeEditor\";\nimport DateTimeTextBox from \"app/client/widgets/DateTimeTextBox\";\nimport { HyperLinkEditor } from \"app/client/widgets/HyperLinkEditor\";\nimport { HyperLinkTextBox } from \"app/client/widgets/HyperLinkTextBox\";\nimport { MarkdownTextBox } from \"app/client/widgets/MarkdownTextBox\";\nimport { NewAbstractWidget } from \"app/client/widgets/NewAbstractWidget\";\nimport { IEditorConstructor } from \"app/client/widgets/NewBaseEditor\";\nimport { NTextBox } from \"app/client/widgets/NTextBox\";\nimport { NTextEditor } from \"app/client/widgets/NTextEditor\";\nimport { NumericEditor } from \"app/client/widgets/NumericEditor\";\nimport { NumericTextBox } from \"app/client/widgets/NumericTextBox\";\nimport { Reference } from \"app/client/widgets/Reference\";\nimport { ReferenceEditor } from \"app/client/widgets/ReferenceEditor\";\nimport { ReferenceList } from \"app/client/widgets/ReferenceList\";\nimport { ReferenceListEditor } from \"app/client/widgets/ReferenceListEditor\";\nimport { Spinner } from \"app/client/widgets/Spinner\";\nimport { ToggleCheckBox, ToggleSwitch } from \"app/client/widgets/Toggle\";\nimport { getWidgetConfiguration } from \"app/client/widgets/UserType\";\nimport { GristType } from \"app/plugin/GristData\";\n\n/**\n * Convert the name of a widget to its implementation.\n */\nexport const nameToWidget = {\n  TextBox: NTextBox,\n  TextEditor: NTextEditor,\n  NumericTextBox: NumericTextBox,\n  NumericEditor: NumericEditor,\n  HyperLinkTextBox: HyperLinkTextBox,\n  HyperLinkEditor: HyperLinkEditor,\n  MarkdownTextBox: MarkdownTextBox,\n  Spinner: Spinner,\n  CheckBox: ToggleCheckBox,\n  CheckBoxEditor: CheckBoxEditor,\n  Reference: Reference,\n  Switch: ToggleSwitch,\n  ReferenceEditor: ReferenceEditor,\n  ReferenceList: ReferenceList,\n  ReferenceListEditor: ReferenceListEditor,\n  ChoiceTextBox: ChoiceTextBox,\n  ChoiceEditor: ChoiceEditor,\n  ChoiceListCell: ChoiceListCell,\n  ChoiceListEditor: ChoiceListEditor,\n  DateTimeTextBox: DateTimeTextBox,\n  DateTextBox: DateTextBox,\n  DateEditor: DateEditor,\n  AttachmentsWidget: AttachmentsWidget,\n  AttachmentsEditor: AttachmentsEditor,\n  DateTimeEditor: DateTimeEditor,\n};\n\nexport interface WidgetConstructor { create: (...args: any[]) => NewAbstractWidget }\n\n/** return a good class to instantiate for viewing a widget/type combination */\nexport function getWidgetConstructor(widget: string, type: string): WidgetConstructor {\n  const { config } = getWidgetConfiguration(widget, type as GristType);\n  return nameToWidget[config.cons as keyof typeof nameToWidget] as any;\n}\n\n/** return a good class to instantiate for viewing a form widget/type combination */\nexport function getFormWidgetConstructor(widget: string, type: string): WidgetConstructor {\n  const { config } = getWidgetConfiguration(widget, type as GristType);\n  return nameToWidget[(config.formCons || config.cons) as keyof typeof nameToWidget] as any;\n}\n\n/** return a good class to instantiate for editing a widget/type combination */\nexport function getEditorConstructor(widget: string, type: string): IEditorConstructor {\n  const { config } = getWidgetConfiguration(widget, type as GristType);\n  return nameToWidget[config.editCons as keyof typeof nameToWidget] as any;\n}\n"
  },
  {
    "path": "app/common/ACLPermissions.ts",
    "content": "/**\n * Internal and DB representation of permission bits. These may be set to on, off, or omitted.\n *\n * In DB, permission sets are represented as strings of the form '[+<bits>][-<bits>]' where <bits>\n * is a string of C,R,U,D,S characters, each appearing at most once; or the special values 'all'\n * or 'none'. Note that empty string is also valid, and corresponds to the PermissionSet {}.\n */\n\nimport fromPairs from \"lodash/fromPairs\";\nimport mapValues from \"lodash/mapValues\";\n\n// A PermissionValue is the result of evaluating rules. It provides a definitive answer.\nexport type PermissionValue = \"allow\" | \"deny\";\n\n// A MixedPermissionValue is the result of evaluating rules without a record. If some rules\n// require a record, and some records may be allowed and some denied, the result is \"mixed\".\nexport type MixedPermissionValue = PermissionValue | \"mixed\";\n\n// Similar to MixedPermissionValue, but if permission for a table depend on columns and NOT on\n// rows, the result is \"mixedColumns\" rather than \"mixed\", which allows some optimizations.\nexport type TablePermissionValue = MixedPermissionValue | \"mixedColumns\";\n\n// PartialPermissionValue is only used transiently while evaluating rules without a record.\nexport type PartialPermissionValue = PermissionValue | \"allowSome\" | \"denySome\" | \"mixed\" | \"\";\n\n/**\n * Internal representation of a set of permission bits.\n */\nexport interface PermissionSet<T = PermissionValue> {\n  read: T;\n  create: T;\n  update: T;\n  delete: T;\n  schemaEdit: T;\n}\n\n// Some shorter type aliases.\nexport type PartialPermissionSet = PermissionSet<PartialPermissionValue>;\nexport type MixedPermissionSet = PermissionSet<MixedPermissionValue>;\nexport type TablePermissionSet = PermissionSet<TablePermissionValue>;\n\n// One of the strings 'read', 'update', etc.\nexport type PermissionKey = keyof PermissionSet;\n\nconst PERMISSION_BITS: { [letter: string]: PermissionKey } = {\n  R: \"read\",\n  C: \"create\",\n  U: \"update\",\n  D: \"delete\",\n  S: \"schemaEdit\",\n};\n\nconst ALL_PERMISSION_BITS = \"CRUDS\";\n\nexport const ALL_PERMISSION_PROPS: (keyof PermissionSet)[] =\n  Array.from(ALL_PERMISSION_BITS, ch => PERMISSION_BITS[ch]);\n\nconst ALIASES: { [key: string]: string } = {\n  all: \"+CRUDS\",\n  none: \"-CRUDS\",\n};\nconst REVERSE_ALIASES = fromPairs(Object.entries(ALIASES).map(([alias, value]) => [value, alias]));\n\nexport const AVAILABLE_BITS_TABLES: PermissionKey[] = [\"read\", \"update\", \"create\", \"delete\"];\nexport const AVAILABLE_BITS_COLUMNS: PermissionKey[] = [\"read\", \"update\"];\n\n// Comes in useful for initializing unset PermissionSets.\nexport function emptyPermissionSet(): PartialPermissionSet {\n  return { read: \"\", create: \"\", update: \"\", delete: \"\", schemaEdit: \"\" };\n}\n\n/**\n * Convert a short string representation to internal.\n */\nexport function parsePermissions(permissionsText: string): PartialPermissionSet {\n  if (ALIASES.hasOwnProperty(permissionsText)) {\n    permissionsText = ALIASES[permissionsText];\n  }\n  const pset: PartialPermissionSet = emptyPermissionSet();\n  let value: PartialPermissionValue = \"\";\n  for (const ch of permissionsText) {\n    if (ch === \"+\") {\n      value = \"allow\";\n    } else if (ch === \"-\") {\n      value = \"deny\";\n    } else if (!PERMISSION_BITS.hasOwnProperty(ch) || value === \"\") {\n      throw new Error(`Invalid permissions specification ${JSON.stringify(permissionsText)}`);\n    } else {\n      const prop = PERMISSION_BITS[ch];\n      pset[prop] = value;\n    }\n  }\n  return pset;\n}\n\n/**\n * Convert an internal representation of permission bits to a short string. Note that there should\n * be no values other then \"allow\" and \"deny\", since anything else will NOT be included.\n */\nexport function permissionSetToText(permissionSet: Partial<PartialPermissionSet>): string {\n  let add = \"\";\n  let remove = \"\";\n  for (const ch of ALL_PERMISSION_BITS) {\n    const prop: keyof PermissionSet = PERMISSION_BITS[ch];\n    const value = permissionSet[prop];\n    if (value === \"allow\") {\n      add += ch;\n    } else if (value === \"deny\") {\n      remove += ch;\n    }\n  }\n  const perm = (add ? \"+\" + add : \"\") + (remove ? \"-\" + remove : \"\");\n  return REVERSE_ALIASES[perm] || perm;\n}\n\n/**\n * Replace allow/deny with allowSome/denySome to indicate dependence on rows.\n */\nexport function makePartialPermissions(pset: PartialPermissionSet): PartialPermissionSet {\n  return mapValues(pset, val => (val === \"allow\" ? \"allowSome\" : (val === \"deny\" ? \"denySome\" : val)));\n}\n\n/**\n * Combine PartialPermissions. Earlier rules win. Note that allowAll|denyAll|mixed are final\n * results (further permissions can't change them), but allowSome|denySome may be changed by\n * further rules into either allowAll|denyAll or mixed.\n *\n * Note that this logic satisfies associative property: (a + b) + c == a + (b + c).\n */\nfunction combinePartialPermission(a: PartialPermissionValue, b: PartialPermissionValue): PartialPermissionValue {\n  if (!a) { return b; }\n  if (!b) { return a; }\n  // If the first is uncertain, the second may keep it unchanged, or make certain, or finalize as mixed.\n  if (a === \"allowSome\") { return (b === \"allowSome\" || b === \"allow\") ? b : \"mixed\"; }\n  if (a === \"denySome\") { return (b === \"denySome\" || b === \"deny\") ? b : \"mixed\"; }\n  // If the first is certain, it's not affected by the second.\n  return a;\n}\n\n/**\n * Combine PartialPermissionSets.\n */\nexport function mergePartialPermissions(a: PartialPermissionSet, b: PartialPermissionSet): PartialPermissionSet {\n  return mergePermissions([a, b], ([_a, _b]) => combinePartialPermission(_a, _b));\n}\n\n/**\n * Returns permissions trimmed to include only the available bits, and empty for any other bits.\n */\nexport function trimPermissions(\n  permissions: PartialPermissionSet, availableBits: PermissionKey[],\n): PartialPermissionSet {\n  const trimmed = emptyPermissionSet();\n  for (const bit of availableBits) {\n    trimmed[bit] = permissions[bit];\n  }\n  return trimmed;\n}\n\n/**\n * Merge a list of PermissionSets by combining individual bits.\n */\nexport function mergePermissions<T, U>(psets: PermissionSet<T>[], combine: (bits: T[]) => U,\n): PermissionSet<U> {\n  const result: Partial<PermissionSet<U>> = {};\n  for (const prop of ALL_PERMISSION_PROPS) {\n    result[prop] = combine(psets.map(p => p[prop]));\n  }\n  return result as PermissionSet<U>;\n}\n\n/**\n * Convert a PartialPermissionSet to MixedPermissionSet by replacing any remaining uncertain bits\n * with 'denyAll'. When rules are properly combined it should never be needed because the\n * hard-coded fallback rules should finalize all bits.\n */\nexport function toMixed(pset: PartialPermissionSet): MixedPermissionSet {\n  return mergePermissions([pset], ([bit]) => (bit === \"allow\" || bit === \"mixed\" ? bit : \"deny\"));\n}\n\n/**\n * Check if PermissionSet may only add permissions, only remove permissions, or may do either.\n * A rule that neither adds nor removes permissions is treated as mixed.\n */\nexport function summarizePermissionSet(pset: PartialPermissionSet): MixedPermissionValue {\n  let sign = \"\";\n  for (const key of Object.keys(pset) as (keyof PartialPermissionSet)[]) {\n    const pWithSome = pset[key];\n    // \"Some\" postfix is not significant for summarization.\n    const p = pWithSome === \"allowSome\" ? \"allow\" : (pWithSome === \"denySome\" ? \"deny\" : pWithSome);\n    if (!p || p === sign) { continue; }\n    if (!sign) {\n      sign = p;\n      continue;\n    }\n    sign = \"mixed\";\n  }\n  return (sign === \"allow\" || sign === \"deny\") ? sign : \"mixed\";\n}\n\n/**\n * Summarize whether a set of permissions are all 'allow', all 'deny', or other ('mixed').\n */\nexport function summarizePermissions(perms: MixedPermissionValue[]): MixedPermissionValue {\n  if (perms.length === 0) { return \"mixed\"; }\n  const perm = perms[0];\n  return perms.some(p => p !== perm) ? \"mixed\" : perm;\n}\n\nfunction isEmpty(permissions: PartialPermissionSet): boolean {\n  return Object.values(permissions).every(v => v === \"\");\n}\n\n/**\n * Divide up a PartialPermissionSet into two: one containing only the 'schemaEdit' permission bit,\n * and the other containing everything else. Empty parts will be returned as undefined, except\n * when both are empty, in which case nonSchemaEdit will be returned as an empty permission set.\n */\nexport function splitSchemaEditPermissionSet(permissions: PartialPermissionSet):\n{ schemaEdit?: PartialPermissionSet, nonSchemaEdit?: PartialPermissionSet } {\n  const schemaEdit = { ...emptyPermissionSet(), schemaEdit: permissions.schemaEdit };\n  const nonSchemaEdit: PartialPermissionSet = { ...permissions, schemaEdit: \"\" };\n  return {\n    schemaEdit: !isEmpty(schemaEdit) ? schemaEdit : undefined,\n    nonSchemaEdit: !isEmpty(nonSchemaEdit) || isEmpty(schemaEdit) ? nonSchemaEdit : undefined,\n  };\n}\n"
  },
  {
    "path": "app/common/ACLRuleCollection.ts",
    "content": "import { parsePermissions, permissionSetToText, splitSchemaEditPermissionSet } from \"app/common/ACLPermissions\";\nimport { AVAILABLE_BITS_COLUMNS, AVAILABLE_BITS_TABLES, trimPermissions } from \"app/common/ACLPermissions\";\nimport { ACLRulesReader } from \"app/common/ACLRulesReader\";\nimport { AclRuleProblem } from \"app/common/ActiveDocAPI\";\nimport { DocData } from \"app/common/DocData\";\nimport { RulePart, RuleSet, UserAttributeRule } from \"app/common/GranularAccessClause\";\nimport { getSetMapValue, isNonNullish } from \"app/common/gutil\";\nimport { CompiledPredicateFormula, ParsedPredicateFormula } from \"app/common/PredicateFormula\";\nimport { MetaRowRecord } from \"app/common/TableData\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nexport type ILogger = Pick<Console, \"log\" | \"debug\" | \"info\" | \"warn\" | \"error\">;\n\nconst defaultMatchFunc: CompiledPredicateFormula = () => true;\n\nexport const SPECIAL_RULES_TABLE_ID = \"*SPECIAL\";\n\n// This is the hard-coded default RuleSet that's added to any user-created default rule.\nconst DEFAULT_RULE_SET: RuleSet = {\n  tableId: \"*\",\n  colIds: \"*\",\n  body: [{\n    aclFormula: \"user.Access in [EDITOR, OWNER]\",\n    matchFunc: input => [\"editors\", \"owners\"].includes(String(input.user!.Access)),\n    permissions: parsePermissions(\"all\"),\n    permissionsText: \"all\",\n  }, {\n    aclFormula: \"user.Access in [VIEWER]\",\n    matchFunc: input => [\"viewers\"].includes(String(input.user!.Access)),\n    permissions: parsePermissions(\"+R-CUDS\"),\n    permissionsText: \"+R\",\n  }, {\n    aclFormula: \"\",\n    matchFunc: defaultMatchFunc,\n    permissions: parsePermissions(\"none\"),\n    permissionsText: \"none\",\n  }],\n};\n\n// Check if the given resource is the special \"SchemaEdit\" resource, which only exists as a\n// frontend representation.\nexport function isSchemaEditResource(resource: { tableId: string, colIds: string }): boolean {\n  return resource.tableId === SPECIAL_RULES_TABLE_ID && resource.colIds === \"SchemaEdit\";\n}\n\nexport type SpecialRuleName = \"AccessRules\" | \"DocCopies\" | \"FullCopies\" | \"SeedRule\" | \"SchemaEdit\";\n\nconst SPECIAL_RULE_SETS: Record<SpecialRuleName, RuleSet> = {\n  SchemaEdit: {\n    tableId: SPECIAL_RULES_TABLE_ID,\n    colIds: [\"SchemaEdit\"],\n    body: [{\n      aclFormula: \"user.Access in [EDITOR, OWNER]\",\n      matchFunc: input => [\"editors\", \"owners\"].includes(String(input.user!.Access)),\n      permissions: parsePermissions(\"+S\"),\n      permissionsText: \"+S\",\n    }, {\n      aclFormula: \"\",\n      matchFunc: defaultMatchFunc,\n      permissions: parsePermissions(\"-S\"),\n      permissionsText: \"-S\",\n    }],\n  },\n  AccessRules: {\n    tableId: SPECIAL_RULES_TABLE_ID,\n    colIds: [\"AccessRules\"],\n    body: [{\n      aclFormula: \"user.Access in [OWNER]\",\n      matchFunc: input => [\"owners\"].includes(String(input.user!.Access)),\n      permissions: parsePermissions(\"+R\"),\n      permissionsText: \"+R\",\n    }, {\n      aclFormula: \"\",\n      matchFunc: defaultMatchFunc,\n      permissions: parsePermissions(\"-R\"),\n      permissionsText: \"-R\",\n    }],\n  },\n  DocCopies: {\n    // Absense of +R on DocCopies means that the user is NOT allowed to copy the document in full\n    // or download it, even if they can see all data and can view access rules.\n    tableId: SPECIAL_RULES_TABLE_ID,\n    colIds: [\"DocCopies\"],\n    body: [{\n      aclFormula: \"\",\n      matchFunc: defaultMatchFunc,\n      permissions: parsePermissions(\"+R\"),\n      permissionsText: \"+R\",\n    }],\n  },\n  FullCopies: {\n    tableId: SPECIAL_RULES_TABLE_ID,\n    colIds: [\"FullCopies\"],\n    body: [{\n      aclFormula: \"user.Access in [OWNER]\",\n      matchFunc: input => [\"owners\"].includes(String(input.user!.Access)),\n      permissions: parsePermissions(\"+R\"),\n      permissionsText: \"+R\",\n    }, {\n      aclFormula: \"\",\n      matchFunc: defaultMatchFunc,\n      permissions: parsePermissions(\"-R\"),\n      permissionsText: \"-R\",\n    }],\n  },\n  SeedRule: {\n    tableId: SPECIAL_RULES_TABLE_ID,\n    colIds: [\"SeedRule\"],\n    body: [],\n  },\n};\n\n// If the user-created rules become dysfunctional, we can swap in this emergency set.\n// It grants full access to owners, and no access to anyone else.\nconst EMERGENCY_RULE_SET: RuleSet = {\n  tableId: \"*\",\n  colIds: \"*\",\n  body: [{\n    aclFormula: \"user.Access in [OWNER]\",\n    matchFunc: input => [\"owners\"].includes(String(input.user!.Access)),\n    permissions: parsePermissions(\"all\"),\n    permissionsText: \"all\",\n  }, {\n    aclFormula: \"\",\n    matchFunc: defaultMatchFunc,\n    permissions: parsePermissions(\"none\"),\n    permissionsText: \"none\",\n  }],\n};\n\nexport class ACLRuleCollection {\n  // Store error if one occurs while reading rules.  Rules are replaced with emergency rules\n  // in this case.\n  public ruleError: Error | undefined;\n\n  // In the absence of rules, some checks are skipped. For now this is important to maintain all\n  // existing behavior. TODO should make sure checking access against default rules is equivalent\n  // and efficient.\n  private _haveRules = false;\n\n  // Map of tableId to list of column RuleSets (those with colIds other than '*')\n  // Includes also SPECIAL_RULES_TABLE_ID.\n  private _columnRuleSets = new Map<string, RuleSet[]>();\n\n  // Maps 'tableId:colId' to one of the RuleSets in the list _columnRuleSets.get(tableId).\n  private _tableColumnMap = new Map<string, RuleSet>();\n\n  // Rules for SPECIAL_RULES_TABLE_ID \"columns\".\n  private _specialRuleSets = new Map<string, RuleSet>();\n\n  // Map of tableId to the single default RuleSet for the table (colIds of '*')\n  private _tableRuleSets = new Map<string, RuleSet>();\n\n  // The default RuleSet (tableId '*', colIds '*')\n  private _defaultRuleSet: RuleSet = DEFAULT_RULE_SET;\n\n  // List of all tableIds mentioned in rules.\n  private _tableIds: string[] = [];\n\n  // Maps name to the corresponding UserAttributeRule.\n  private _userAttributeRules = new Map<string, UserAttributeRule>();\n\n  // Whether there are ANY user-defined rules.\n  public haveRules(): boolean {\n    return this._haveRules;\n  }\n\n  // Return the RuleSet for \"tableId:colId\", or undefined if there isn't one for this column.\n  public getColumnRuleSet(tableId: string, colId: string): RuleSet | undefined {\n    if (tableId === SPECIAL_RULES_TABLE_ID) { return this._specialRuleSets.get(colId); }\n    return this._tableColumnMap.get(`${tableId}:${colId}`);\n  }\n\n  // Return all RuleSets for \"tableId:<any colId>\", not including \"tableId:*\".\n  public getAllColumnRuleSets(tableId: string): RuleSet[] {\n    return this._columnRuleSets.get(tableId) || [];\n  }\n\n  // Return the RuleSet for \"tableId:*\".\n  public getTableDefaultRuleSet(tableId: string): RuleSet | undefined {\n    return this._tableRuleSets.get(tableId);\n  }\n\n  // Return the RuleSet for \"*:*\".\n  public getDocDefaultRuleSet(): RuleSet {\n    return this._defaultRuleSet;\n  }\n\n  // Return the list of all tableId mentions in ACL rules.\n  public getAllTableIds(): string[] {\n    return this._tableIds;\n  }\n\n  // Returns a Map of user attribute name to the corresponding UserAttributeRule.\n  public getUserAttributeRules(): Map<string, UserAttributeRule> {\n    return this._userAttributeRules;\n  }\n\n  /**\n   * Update granular access from DocData. Try hard not to throw\n   * exceptions, reporting problems via this.ruleError. If we\n   * throw an exception, document recovery mode won't work.\n   */\n  public async update(docData: DocData, options: ReadAclOptions) {\n    this.ruleError = undefined;\n    try {\n      await this.updateWithExceptions(docData, options);\n    } catch (e) {\n      this.ruleError = e;  // Report the error indirectly.\n      await this.updateWithExceptions(docData, {\n        ...options,\n        emergency: true,\n      });\n    }\n  }\n\n  /**\n   * Update granular access from DocData. Throw exceptions on\n   * some failures.\n   */\n  public async updateWithExceptions(docData: DocData, options: ReadAclOptions) {\n    const { ruleSets, userAttributes } = this._readAclRules(docData, options);\n\n    // Build a map of user characteristics rules.\n    const userAttributeMap = new Map<string, UserAttributeRule>();\n    for (const userAttr of userAttributes) {\n      userAttributeMap.set(userAttr.name, userAttr);\n    }\n\n    // Build maps of ACL rules.\n    const colRuleSets = new Map<string, RuleSet[]>();\n    const tableColMap = new Map<string, RuleSet>();\n    const tableRuleSets = new Map<string, RuleSet>();\n    const tableIds = new Set<string>();\n    let defaultRuleSet: RuleSet = DEFAULT_RULE_SET;\n\n    // Collect special rules, combining them with corresponding defaults.\n    const specialRuleSets = new Map<string, RuleSet>(Object.entries(SPECIAL_RULE_SETS));\n    for (const ruleSet of ruleSets) {\n      if (ruleSet.tableId === SPECIAL_RULES_TABLE_ID) {\n        const specialType = String(ruleSet.colIds);\n        const specialDefault = specialRuleSets.get(specialType);\n        if (!specialDefault) {\n          // Log that we are seeing an invalid rule, but don't fail.\n          // (Historically, older versions of the Grist app will attempt to\n          // open newer documents).\n          options.log.error(`Invalid rule for ${ruleSet.tableId}:${ruleSet.colIds}`);\n        } else {\n          specialRuleSets.set(specialType, { ...ruleSet, body: [...ruleSet.body, ...specialDefault.body] });\n        }\n      } else if (options.pullOutSchemaEdit && ruleSet.tableId === \"*\" && ruleSet.colIds === \"*\") {\n        // If pullOutSchemaEdit is requested, we move out rules with SchemaEdit permissions from\n        // the default resource into the ficticious \"*SPECIAL:SchemaEdit\" resource. This is used\n        // in the frontend only, to present those rules in a separate section.\n        const schemaParts = ruleSet.body.map(part => splitSchemaEditRulePart(part).schemaEdit).filter(isNonNullish);\n\n        if (schemaParts.length > 0) {\n          const specialType = \"SchemaEdit\";\n          const specialDefault = specialRuleSets.get(specialType)!;\n          specialRuleSets.set(specialType, {\n            tableId: SPECIAL_RULES_TABLE_ID,\n            colIds: [\"SchemaEdit\"],\n            body: [...schemaParts, ...specialDefault.body],\n          });\n        }\n      }\n    }\n\n    // Insert the special rule sets into colRuleSets.\n    for (const ruleSet of specialRuleSets.values()) {\n      getSetMapValue(colRuleSets, SPECIAL_RULES_TABLE_ID, () => []).push(ruleSet);\n    }\n\n    this._haveRules = (ruleSets.length > 0);\n    for (const ruleSet of ruleSets) {\n      if (ruleSet.tableId === \"*\") {\n        if (ruleSet.colIds === \"*\") {\n          // If pullOutSchemaEdit is requested, skip the SchemaEdit rules for the default resource;\n          // those got pulled out earlier into the fictitious \"*SPECIAL:SchemaEdit\" resource.\n          const body = options.pullOutSchemaEdit ?\n            ruleSet.body.map(part => splitSchemaEditRulePart(part).nonSchemaEdit).filter(isNonNullish) :\n            ruleSet.body;\n\n          defaultRuleSet = {\n            ...ruleSet,\n            body: [...body, ...DEFAULT_RULE_SET.body],\n          };\n        } else {\n          // tableId of '*' cannot list particular columns.\n          throw new Error(`Invalid rule for tableId ${ruleSet.tableId}, colIds ${ruleSet.colIds}`);\n        }\n      } else if (ruleSet.tableId === SPECIAL_RULES_TABLE_ID) {\n        // Skip, since we handled these separately earlier.\n      } else if (ruleSet.colIds === \"*\") {\n        tableIds.add(ruleSet.tableId);\n        if (tableRuleSets.has(ruleSet.tableId)) {\n          throw new Error(`Invalid duplicate default rule for ${ruleSet.tableId}`);\n        }\n        tableRuleSets.set(ruleSet.tableId, ruleSet);\n      } else {\n        tableIds.add(ruleSet.tableId);\n        getSetMapValue(colRuleSets, ruleSet.tableId, () => []).push(ruleSet);\n        for (const colId of ruleSet.colIds) {\n          tableColMap.set(`${ruleSet.tableId}:${colId}`, ruleSet);\n        }\n      }\n    }\n\n    // Update GranularAccess state.\n    this._columnRuleSets = colRuleSets;\n    this._tableColumnMap = tableColMap;\n    this._tableRuleSets = tableRuleSets;\n    this._defaultRuleSet = defaultRuleSet;\n    this._tableIds = [...tableIds];\n    this._userAttributeRules = userAttributeMap;\n    this._specialRuleSets = specialRuleSets;\n  }\n\n  /**\n   * Check that all references to table and column IDs in ACL rules are valid.\n   */\n  public checkDocEntities(docData: DocData) {\n    const problems = this.findRuleProblems(docData);\n    if (problems.length === 0) { return; }\n    throw new Error(problems[0].comment);\n  }\n\n  /**\n   * Enumerate rule problems caused by table and column IDs that are not valid.\n   * Problems include:\n   *   - Rules for a table that does not exist\n   *   - Rules for columns that include a column that does not exist\n   *   - User attributes links to a column that does not exist\n   */\n  public findRuleProblems(docData: DocData): AclRuleProblem[] {\n    const problems: AclRuleProblem[] = [];\n    const tablesTable = docData.getMetaTable(\"_grist_Tables\");\n    const columnsTable = docData.getMetaTable(\"_grist_Tables_column\");\n\n    // Collect valid tableIds and check rules against those.\n    const validTableIds = new Set(tablesTable.getColValues(\"tableId\"));\n    const invalidTables = this.getAllTableIds().filter(t => !validTableIds.has(t));\n    if (invalidTables.length > 0) {\n      problems.push({\n        tables: {\n          tableIds: invalidTables,\n        },\n        comment: `Invalid tables in rules: ${invalidTables.join(\", \")}`,\n      });\n    }\n\n    // Collect valid columns, grouped by tableRef (rowId of table record).\n    const validColumns = new Map<number, Set<string>>();   // Map from tableRef to set of colIds.\n    const colTableRefs = columnsTable.getColValues(\"parentId\");\n    for (const [i, colId] of columnsTable.getColValues(\"colId\").entries()) {\n      getSetMapValue(validColumns, colTableRefs[i], () => new Set()).add(colId);\n    }\n\n    // For each valid table, check that any explicitly mentioned columns are valid.\n    for (const tableId of this.getAllTableIds()) {\n      if (!validTableIds.has(tableId)) { continue; }\n      const tableRef = tablesTable.findRow(\"tableId\", tableId);\n      const validTableCols = validColumns.get(tableRef);\n      for (const ruleSet of this.getAllColumnRuleSets(tableId)) {\n        if (Array.isArray(ruleSet.colIds)) {\n          const invalidColIds = ruleSet.colIds.filter(c => !validTableCols?.has(c));\n          if (invalidColIds.length > 0) {\n            problems.push({\n              columns: {\n                tableId,\n                colIds: invalidColIds,\n              },\n              comment: `Invalid columns in rules for table ${tableId}: ${invalidColIds.join(\", \")}`,\n            });\n          }\n        }\n      }\n    }\n\n    // Check for valid tableId/lookupColId combinations in UserAttribute rules.\n    const invalidUAColumns: string[] = [];\n    const names: string[] = [];\n    for (const rule of this.getUserAttributeRules().values()) {\n      const tableRef = tablesTable.findRow(\"tableId\", rule.tableId);\n      const colRef = columnsTable.findMatchingRowId({\n        parentId: tableRef, colId: rule.lookupColId,\n      });\n      if (!colRef) {\n        invalidUAColumns.push(`${rule.tableId}.${rule.lookupColId}`);\n        names.push(rule.name);\n      }\n    }\n    if (invalidUAColumns.length > 0) {\n      problems.push({\n        userAttributes: {\n          invalidUAColumns,\n          names,\n        },\n        comment: `Invalid columns in User Attribute rules: ${invalidUAColumns.join(\", \")}`,\n      });\n    }\n    return problems;\n  }\n\n  private _readAclRules(docData: DocData, options: ReadAclOptions): ReadAclResults {\n    const emergencyResult: ReadAclResults = {\n      ruleSets: [EMERGENCY_RULE_SET],\n      userAttributes: [],\n    };\n    if (options.emergency) { return emergencyResult; }\n    return readAclRules(docData, options);\n  }\n}\n\nexport interface ReadAclOptions {\n  log: ILogger;     // For logging warnings during rule processing.\n  compile?: (parsed: ParsedPredicateFormula) => CompiledPredicateFormula;\n  // If true, add and modify access rules in some special ways.\n  // Specifically, call addHelperCols to add helper columns of restricted columns to rule sets,\n  // and use ACLShareRules to implement any special shares as access rules.\n  // Used in the server, but not in the client, because of at least the following:\n  // 1. Rules would show in the UI\n  // 2. Rules would be saved back after editing, causing them to accumulate\n  enrichRulesForImplementation?: boolean;\n\n  // If true, rules with 'schemaEdit' permission are moved out of the '*:*' resource into a\n  // fictitious '*SPECIAL:SchemaEdit' resource. This is used only on the client, to present\n  // schemaEdit as a separate checkbox. Such rules are saved back to the '*:*' resource.\n  pullOutSchemaEdit?: boolean;\n\n  // If true, replace rules with the emergency rule set.\n  emergency?: boolean;\n}\n\nexport interface ReadAclResults {\n  ruleSets: RuleSet[];\n  userAttributes: UserAttributeRule[];\n}\n\n/**\n * For each column in colIds, return the colIds of any hidden helper columns it has,\n * i.e. display columns of references, and conditional formatting rule columns.\n */\nfunction getHelperCols(docData: DocData, tableId: string, colIds: string[], log: ILogger): string[] {\n  const tablesTable = docData.getMetaTable(\"_grist_Tables\");\n  const columnsTable = docData.getMetaTable(\"_grist_Tables_column\");\n  const fieldsTable = docData.getMetaTable(\"_grist_Views_section_field\");\n\n  const tableRef = tablesTable.findRow(\"tableId\", tableId);\n  if (!tableRef) {\n    return [];\n  }\n\n  const result: string[] = [];\n  for (const colId of colIds) {\n    const [column] = columnsTable.filterRecords({ parentId: tableRef, colId });\n    if (!column) {\n      continue;\n    }\n\n    function addColsFromRefs(colRefs: unknown) {\n      if (!Array.isArray(colRefs)) {\n        return;\n      }\n      for (const colRef of colRefs) {\n        if (typeof colRef !== \"number\") {\n          continue;\n        }\n        const extraCol = columnsTable.getRecord(colRef);\n        if (!extraCol) {\n          continue;\n        }\n        if (extraCol.colId.startsWith(\"gristHelper_\") && extraCol.parentId === tableRef) {\n          result.push(extraCol.colId);\n        } else {\n          log.error(`Invalid helper column ${extraCol.colId} of ${tableId}:${colId}`);\n        }\n      }\n    }\n\n    function addColsFromMetaRecord(rec: MetaRowRecord<\"_grist_Tables_column\" | \"_grist_Views_section_field\">) {\n      addColsFromRefs([rec.displayCol]);\n      addColsFromRefs(decodeObject(rec.rules));\n    }\n\n    addColsFromMetaRecord(column);\n    for (const field of fieldsTable.filterRecords({ colRef: column.id })) {\n      addColsFromMetaRecord(field);\n    }\n  }\n  return result;\n}\n\n/**\n * Parse all ACL rules in the document from DocData into a list of RuleSets and of\n * UserAttributeRules. This is used by both client-side code and server-side.\n */\nfunction readAclRules(\n  docData: DocData, { log, compile, enrichRulesForImplementation }: ReadAclOptions,\n): ReadAclResults {\n  const ruleSets: RuleSet[] = [];\n  const userAttributes: UserAttributeRule[] = [];\n\n  const aclRulesReader = new ACLRulesReader(docData, {\n    addShareRules: enrichRulesForImplementation,\n  });\n\n  // Group rules by resource first, ordering by rulePos. Each group will become a RuleSet.\n  for (const [resourceId, rules] of aclRulesReader.entries()) {\n    const resourceRec = aclRulesReader.getResourceById(resourceId);\n    if (!resourceRec) {\n      throw new Error(`ACLRule ${rules[0].id} refers to an invalid ACLResource ${resourceId}`);\n    }\n    if (!resourceRec.tableId || !resourceRec.colIds) {\n      // This should only be the case for the old-style default rule/resource, which we\n      // intentionally ignore and skip.\n      continue;\n    }\n    const tableId = resourceRec.tableId;\n    const colIds = resourceRec.colIds === \"*\" ? \"*\" : resourceRec.colIds.split(\",\");\n\n    if (enrichRulesForImplementation && Array.isArray(colIds)) {\n      colIds.push(...getHelperCols(docData, tableId, colIds, log));\n    }\n\n    const body: RulePart[] = [];\n    for (const rule of rules) {\n      if (rule.userAttributes) {\n        if (tableId !== \"*\" || colIds !== \"*\") {\n          throw new Error(`ACLRule ${rule.id} invalid; user attributes must be on the default resource`);\n        }\n        const parsed = JSON.parse(String(rule.userAttributes));\n        // TODO: could perhaps use ts-interface-checker here.\n        if (!(parsed && typeof parsed === \"object\" &&\n          [parsed.name, parsed.tableId, parsed.lookupColId, parsed.charId]\n            .every(p => p && typeof p === \"string\"))) {\n          throw new Error(`User attribute rule ${rule.id} is invalid`);\n        }\n        parsed.origRecord = rule;\n        userAttributes.push(parsed as UserAttributeRule);\n      } else if (body.length > 0 && !body[body.length - 1].aclFormula) {\n        throw new Error(`ACLRule ${rule.id} invalid because listed after default rule`);\n      } else if (rule.aclFormula && !rule.aclFormulaParsed) {\n        throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`);\n      } else {\n        const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));\n        let permissions = parsePermissions(String(rule.permissionsText));\n        if (tableId !== \"*\" && tableId !== SPECIAL_RULES_TABLE_ID) {\n          const availableBits = (colIds === \"*\") ? AVAILABLE_BITS_TABLES : AVAILABLE_BITS_COLUMNS;\n          permissions = trimPermissions(permissions, availableBits);\n        }\n        body.push({\n          origRecord: rule,\n          aclFormula: String(rule.aclFormula),\n          matchFunc: rule.aclFormula ? compile?.(aclFormulaParsed) : defaultMatchFunc,\n          memo: rule.memo,\n          permissions,\n          permissionsText: permissionSetToText(permissions),\n        });\n      }\n    }\n    const ruleSet: RuleSet = { tableId, colIds, body };\n    ruleSets.push(ruleSet);\n  }\n  return { ruleSets, userAttributes };\n}\n\n/**\n * In the UI, we present SchemaEdit rules in a separate section, even though in reality they live\n * as schemaEdit permission bits among the rules for the default resource. This function splits a\n * RulePart into two: one containing the schemaEdit permission bit, and the other containing the\n * other bits. If either part is empty, it will be returned as undefined, but if both are empty,\n * nonSchemaEdit will be included as a rule with empty permission bits.\n *\n * It's possible for both parts to be non-empty (for rules created before the updated UI), in\n * which case the schemaEdit one will have a fake origRecord, to cause it to be saved as a new\n * record when saving.\n */\nfunction splitSchemaEditRulePart(rulePart: RulePart): { schemaEdit?: RulePart, nonSchemaEdit?: RulePart } {\n  const p = splitSchemaEditPermissionSet(rulePart.permissions);\n  let schemaEdit: RulePart | undefined;\n  let nonSchemaEdit: RulePart | undefined;\n  if (p.schemaEdit) {\n    schemaEdit = { ...rulePart,\n      permissions: p.schemaEdit,\n      permissionsText: permissionSetToText(p.schemaEdit),\n    };\n  }\n  if (p.nonSchemaEdit) {\n    nonSchemaEdit = { ...rulePart,\n      permissions: p.nonSchemaEdit,\n      permissionsText: permissionSetToText(p.nonSchemaEdit),\n    };\n  }\n  if (schemaEdit && nonSchemaEdit) {\n    schemaEdit.origRecord = { id: -1 } as MetaRowRecord<\"_grist_ACLRules\">;\n  }\n  return { schemaEdit, nonSchemaEdit };\n}\n"
  },
  {
    "path": "app/common/ACLRulesReader.ts",
    "content": "import { DocData } from \"app/common/DocData\";\nimport { extractInfoFromColType } from \"app/common/gristTypes\";\nimport { getSetMapValue } from \"app/common/gutil\";\nimport { SchemaTypes } from \"app/common/schema\";\nimport { ShareOptions } from \"app/common/ShareOptions\";\nimport { MetaRowRecord, MetaTableData } from \"app/common/TableData\";\n\nimport isEqual from \"lodash/isEqual\";\nimport sortBy from \"lodash/sortBy\";\n\n/**\n * For special shares, we need to refer to resources that may not\n * be listed in the _grist_ACLResources table, and have rules that\n * aren't backed by storage in _grist_ACLRules. So we implement\n * a small helper to add an overlay of extra resources and rules.\n * They are distinguishable from real, stored resources and rules\n * by having negative IDs.\n */\nexport class TableWithOverlay<T extends keyof SchemaTypes> {\n  private _extraRecords = new Array<MetaRowRecord<T>>();\n  private _extraRecordsById = new Map<number, MetaRowRecord<T>>();\n  private _excludedRecordIds = new Set<number>();\n  private _nextFreeVirtualId: number = -1;\n\n  public constructor(private _originalTable: MetaTableData<T>) {}\n\n  // Add a record to the table, but only as an overlay - no\n  // persistent changes are made. Uses negative row IDs.\n  // Returns the ID assigned to the record. The passed in\n  // record is expected to have an ID of zero.\n  public addRecord(rec: MetaRowRecord<T>): number {\n    if (rec.id !== 0) { throw new Error(\"Expected a zero ID\"); }\n    const id = this._nextFreeVirtualId;\n    const recWithCorrectId: MetaRowRecord<T> = { ...rec, id };\n    this._extraRecords.push({ ...rec, id });\n    this._extraRecordsById.set(id, recWithCorrectId);\n    this._nextFreeVirtualId--;\n    return id;\n  }\n\n  public excludeRecord(id: number) {\n    this._excludedRecordIds.add(id);\n  }\n\n  // Support the few MetaTableData methods we actually use\n  // in ACLRulesReader.\n\n  public getRecord(id: number) {\n    if (this._excludedRecordIds.has(id)) { return undefined; }\n\n    if (id < 0) {\n      // Reroute negative IDs to our local stash of records.\n      return this._extraRecordsById.get(id);\n    } else {\n      // Everything else, we just pass along.\n      return this._originalTable.getRecord(id);\n    }\n  }\n\n  public getRecords() {\n    return this._filterExcludedRecords([\n      ...this._originalTable.getRecords(),\n      ...this._extraRecords,\n    ]);\n  }\n\n  public filterRecords(properties: Partial<MetaRowRecord<T>>): MetaRowRecord<T>[] {\n    const originalRecords = this._originalTable.filterRecords(properties);\n    const extraRecords = this._extraRecords.filter(rec => Object.keys(properties)\n      .every(p => isEqual((rec as any)[p], (properties as any)[p])));\n    return this._filterExcludedRecords([...originalRecords, ...extraRecords]);\n  }\n\n  public findMatchingRowId(properties: Partial<MetaRowRecord<T>>): number {\n    const rowId = (\n      this._originalTable.findMatchingRowId(properties) ||\n      this._extraRecords.find(rec => Object.keys(properties).every(p =>\n        isEqual((rec as any)[p], (properties as any)[p])),\n      )?.id\n    );\n    return rowId && !this._excludedRecordIds.has(rowId) ? rowId : 0;\n  }\n\n  private _filterExcludedRecords(records: MetaRowRecord<T>[]) {\n    return records.filter(({ id }) => !this._excludedRecordIds.has(id));\n  }\n}\n\nexport interface ACLRulesReaderOptions {\n  /**\n   * Adds virtual rules for all shares in the document.\n   *\n   * If set to `true` and there are shares in the document, regular rules are\n   * modified so that they don't apply when a document is being accessed through\n   * a share, and new rules are added to grant access to the resources specified by\n   * the shares.\n   *\n   * This will also \"split\" any resources (and their rules) if they apply to multiple\n   * resources. Splitting produces copies of the original resource and rules\n   * rules, but with modifications in place so that each copy applies to a single\n   * resource. Normalizing the original rules in this way allows for a simpler mechanism\n   * to override the original rules/resources with share rules, for situations where a\n   * share needs to grant access to a resource that is protected by access rules (shares\n   * and access rules are mutually exclusive at this time).\n   *\n   * Note: a value of `true` will *not* cause any persistent modifications to be made to\n   * rules; all changes are \"virtual\" in the sense that they are applied on top of the\n   * persisted rules to enable shares.\n   *\n   * Defaults to `false`.\n   */\n  addShareRules?: boolean;\n}\n\ninterface ShareContext {\n  shareRef: number;\n  sections: MetaRowRecord<\"_grist_Views_section\">[];\n  columns: MetaRowRecord<\"_grist_Tables_column\">[];\n}\n\n/**\n * Helper class for reading ACL rules from DocData.\n */\nexport class ACLRulesReader {\n  private _resourcesTable = new TableWithOverlay(this.docData.getMetaTable(\"_grist_ACLResources\"));\n  private _rulesTable = new TableWithOverlay(this.docData.getMetaTable(\"_grist_ACLRules\"));\n  private _sharesTable = this.docData.getMetaTable(\"_grist_Shares\");\n  private _hasShares = this._options.addShareRules && this._sharesTable.numRecords() > 0;\n  /** Maps 'tableId:colId' to the comma-separated list of column IDs from the associated resource. */\n  private _resourceColIdsByTableAndColId = new Map<string, string>();\n\n  public constructor(public docData: DocData, private _options: ACLRulesReaderOptions = {}) {\n    this._checkResources();\n    this._addOriginalRules();\n    this._maybeAddShareRules();\n  }\n\n  public entries() {\n    const rulesByResourceId = new Map<number, MetaRowRecord<\"_grist_ACLRules\">[]>();\n    for (const rule of sortBy(this._rulesTable.getRecords(), \"rulePos\")) {\n      // If we have \"virtual\" rules to implement shares, then regular\n      // rules need to be tweaked so that they don't apply when the\n      // share is active.\n      if (this._hasShares && rule.id >= 0) {\n        disableRuleInShare(rule);\n      }\n\n      getSetMapValue(rulesByResourceId, rule.resource, () => []).push(rule);\n    }\n    return rulesByResourceId.entries();\n  }\n\n  public getResourceById(id: number) {\n    return this._resourcesTable.getRecord(id);\n  }\n\n  private _checkResources() {\n    const allTableAndColIds = new Set<string>();\n    for (const resource of this._resourcesTable.getRecords()) {\n      const { tableId, colIds } = resource;\n      const tableAndColIds = `${tableId}:${colIds}`;\n      if (allTableAndColIds.has(tableAndColIds)) {\n        throw new Error(\n          `Duplicate ACLResource ${resource.id}: an ACLResource with the same tableId and colIds already exists`,\n        );\n      }\n\n      allTableAndColIds.add(tableAndColIds);\n    }\n  }\n\n  private _addOriginalRules() {\n    for (const rule of sortBy(this._rulesTable.getRecords(), \"rulePos\")) {\n      const resource = this.getResourceById(rule.resource);\n      if (!resource) {\n        throw new Error(`ACLRule ${rule.id} refers to an invalid ACLResource ${rule.resource}`);\n      }\n\n      if (resource.tableId !== \"*\" && resource.colIds !== \"*\") {\n        const colIds = resource.colIds.split(\",\");\n        if (colIds.length === 1) { continue; }\n\n        for (const colId of colIds) {\n          this._resourceColIdsByTableAndColId.set(`${resource.tableId}:${colId}`, resource.colIds);\n        }\n      }\n    }\n  }\n\n  private _maybeAddShareRules() {\n    if (!this._hasShares) { return; }\n\n    for (const share of this._sharesTable.getRecords()) {\n      this._addRulesForShare(share);\n    }\n    this._addDefaultShareRules();\n  }\n\n  /**\n   * Add any rules needed for the specified share.\n   *\n   * The only kind of share we support for now is form endpoint\n   * sharing.\n   */\n  private _addRulesForShare(share: MetaRowRecord<\"_grist_Shares\">) {\n    // TODO: Unpublished shares could and should be blocked earlier,\n    // by home server\n    const { publish }: ShareOptions = JSON.parse(share.options || \"{}\");\n    if (!publish) {\n      this._blockShare(share.id);\n      return;\n    }\n\n    // Let's go looking for sections related to the share.\n    // It was decided that the relationship between sections and\n    // shares is via pages. Every section on a given page can belong\n    // to at most one share.\n    // Ignore sections which do not have `publish` set to `true` in\n    // `shareOptions`.\n    const pages = this.docData.getMetaTable(\"_grist_Pages\").filterRecords({\n      shareRef: share.id,\n    });\n    const parentViews = new Set(pages.map(page => page.viewRef));\n    const sections = this.docData.getMetaTable(\"_grist_Views_section\").getRecords().filter(\n      (section) => {\n        if (!parentViews.has(section.parentId)) { return false; }\n        const options = JSON.parse(section.shareOptions || \"{}\");\n        return Boolean(options.publish) && Boolean(options.form);\n      },\n    );\n\n    const sectionIds = new Set(sections.map(section => section.id));\n    const fields = this.docData.getMetaTable(\"_grist_Views_section_field\").getRecords().filter(\n      (field) => {\n        return sectionIds.has(field.parentId);\n      },\n    );\n    const columnIds = new Set(fields.map(field => field.colRef));\n    const columns = this.docData.getMetaTable(\"_grist_Tables_column\").getRecords().filter(\n      (column) => {\n        return columnIds.has(column.id);\n      },\n    );\n\n    const tableRefs = new Set(sections.map(section => section.tableRef));\n    const tables = this.docData.getMetaTable(\"_grist_Tables\").getRecords().filter(\n      table => tableRefs.has(table.id),\n    );\n\n    // For tables associated with forms, allow creation of records,\n    // and reading of referenced columns.\n    // TODO: tighten access control on creation since it may be broader\n    // than users expect - hidden columns could be written.\n    for (const table of tables) {\n      this._shareTableForForm(table, {\n        shareRef: share.id, sections, columns,\n      });\n    }\n  }\n\n  /**\n   * When accessing a document via a share, by default no user tables are\n   * accessible. Everything added to the share gives additional\n   * access, and never reduces access, making it easy to grant\n   * access to multiple parts of the document.\n   *\n   * We do leave access unchanged for metadata tables, since they are\n   * censored via an alternative mechanism.\n   */\n  private _addDefaultShareRules() {\n    // Block access to each table.\n    const tableIds = this.docData.getMetaTable(\"_grist_Tables\").getRecords()\n      .map(table => table.tableId)\n      .filter(tableId => !tableId.startsWith(\"_grist_\"))\n      .sort();\n    for (const tableId of tableIds) {\n      this._addShareRule(this._findOrAddResource({ tableId, colIds: \"*\" }), \"-CRUDS\");\n    }\n\n    // Block schema access at the default level.\n    this._addShareRule(this._findOrAddResource({ tableId: \"*\", colIds: \"*\" }), \"-S\");\n  }\n\n  /**\n   * Allow creating records in a table.\n   */\n  private _shareTableForForm(table: MetaRowRecord<\"_grist_Tables\">,\n    shareContext: ShareContext) {\n    const { shareRef } = shareContext;\n    const resource = this._findOrAddResource({\n      tableId: table.tableId,\n      colIds: \"*\",  // At creation, allow all columns to be\n      // initialized.\n    });\n    let aclFormula = `user.ShareRef == ${shareRef}`;\n    let aclFormulaParsed = JSON.stringify([\n      \"Eq\",\n      [\"Attr\", [\"Name\", \"user\"], \"ShareRef\"],\n      [\"Const\", shareRef]]);\n    this._rulesTable.addRecord(this._makeRule({\n      resource, aclFormula, aclFormulaParsed, permissionsText: \"+C\",\n    }));\n\n    // This is a hack to grant read schema access, needed for forms -\n    // Should not be needed once forms are actually available, but\n    // until them is very handy to allow using the web client to\n    // submit records.\n    aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`;\n    aclFormulaParsed = JSON.stringify(\n      [\"And\",\n        [\"Eq\",\n          [\"Attr\", [\"Name\", \"user\"], \"ShareRef\"],\n          [\"Const\", shareRef]],\n        [\"Eq\", [\"Attr\", [\"Name\", \"rec\"], \"id\"], [\"Const\", 0]]]);\n    this._rulesTable.addRecord(this._makeRule({\n      resource, aclFormula, aclFormulaParsed, permissionsText: \"+R\",\n    }));\n\n    this._shareTableReferencesForForm(table, shareContext);\n  }\n\n  /**\n   * Give read access to referenced columns.\n   */\n  private _shareTableReferencesForForm(table: MetaRowRecord<\"_grist_Tables\">,\n    shareContext: ShareContext) {\n    const { shareRef } = shareContext;\n\n    const tables = this.docData.getMetaTable(\"_grist_Tables\");\n    const columns = this.docData.getMetaTable(\"_grist_Tables_column\");\n    const tableColumns = shareContext.columns.filter(c =>\n      c.parentId === table.id &&\n      (c.type.startsWith(\"Ref:\") || c.type.startsWith(\"RefList:\")));\n    for (const column of tableColumns) {\n      let tableId: string;\n      let colId: string;\n\n      const visibleColRef = column.visibleCol;\n      if (visibleColRef) {\n        const visibleCol = columns.getRecord(visibleColRef);\n        if (!visibleCol) { continue; }\n        const referencedTable = tables.getRecord(visibleCol.parentId);\n        if (!referencedTable) { continue; }\n\n        tableId = referencedTable.tableId;\n        colId = visibleCol.colId;\n      } else {\n        const info = extractInfoFromColType(column.type);\n        if (info.type !== \"Ref\" && info.type !== \"RefList\") {\n          // should never happen\n          throw new Error(\"Unexpected column type in _shareTableReferencesForForm\");\n        }\n        tableId = info.tableId;\n        colId = \"id\";\n      }\n\n      const resourceColIds = this._resourceColIdsByTableAndColId.get(`${tableId}:${colId}`) ?? colId;\n      const maybeResourceId = this._resourcesTable.findMatchingRowId({ tableId, colIds: resourceColIds });\n      if (maybeResourceId !== 0) {\n        this._maybeSplitResourceForShares(maybeResourceId);\n      }\n      const resource = this._findOrAddResource({ tableId, colIds: colId });\n      const aclFormula = `user.ShareRef == ${shareRef}`;\n      const aclFormulaParsed = JSON.stringify(\n        [\"Eq\",\n          [\"Attr\", [\"Name\", \"user\"], \"ShareRef\"],\n          [\"Const\", shareRef]]);\n      this._rulesTable.addRecord(this._makeRule({\n        resource, aclFormula, aclFormulaParsed, permissionsText: \"+R\",\n      }));\n    }\n  }\n\n  /**\n   * Splits a resource into multiple resources that are suitable for being\n   * overridden by shares. Rules are copied to each resource, with modifications\n   * that disable them in shares.\n   *\n   * Ignores resources for single columns, and resources created for shares\n   * (i.e. those with a negative ID); the former can already be overridden\n   * by shares without any additional work, and the latter are guaranteed to\n   * only be for single columns.\n   *\n   * The motivation for this method is to normalize document access rules so\n   * that rule sets apply to at most a single column. Document shares may\n   * automatically grant limited access to parts of a document, such as columns\n   * that are referenced from a form field. But for this to happen, extra rules\n   * first need to be added to the original or new resource, which requires looking\n   * up the resource by column ID to see if it exists. This lookup only works if\n   * the rule set of the resource is for a single column; otherwise, the lookup\n   * will fail and cause a new resource to be created, which consequently causes\n   * 2 resources to exist that both contain the same column. Since this is an\n   * unsupported scenario with ambiguous evaluation semantics, we pre-emptively call\n   * this method to avoid such scenarios altogether.\n   */\n  private _maybeSplitResourceForShares(resourceId: number) {\n    if (resourceId < 0) { return; }\n\n    const resource = this.getResourceById(resourceId);\n    if (!resource) {\n      throw new Error(`Unable to find ACLResource with ID ${resourceId}`);\n    }\n\n    const { tableId } = resource;\n    const colIds = resource.colIds.split(\",\");\n    if (colIds.length === 1) { return; }\n\n    const rules = sortBy(this._rulesTable.filterRecords({ resource: resourceId }), \"rulePos\")\n      .map(r => disableRuleInShare(r));\n    // Prepare a new resource for each column, with copies of the original resource's rules.\n    for (const colId of colIds) {\n      const newResourceId = this._resourcesTable.addRecord({ id: 0, tableId, colIds: colId });\n      for (const rule of rules) {\n        this._rulesTable.addRecord({ ...rule, id: 0, resource: newResourceId });\n      }\n    }\n    // Exclude the original resource and rules.\n    this._resourcesTable.excludeRecord(resourceId);\n    for (const rule of rules) {\n      this._rulesTable.excludeRecord(rule.id);\n    }\n  }\n\n  /**\n   * Find a resource we need, and return its rowId. The resource is\n   * added if it is not already present.\n   */\n  private _findOrAddResource(properties: {\n    tableId: string,\n    colIds: string,\n  }): number {\n    const resource = this._resourcesTable.findMatchingRowId(properties);\n    if (resource !== 0) { return resource; }\n    return this._resourcesTable.addRecord({\n      id: 0,\n      ...properties,\n    });\n  }\n\n  private _addShareRule(resourceRef: number, permissionsText: string) {\n    const aclFormula = \"user.ShareRef is not None\";\n    const aclFormulaParsed = JSON.stringify([\n      \"NotEq\",\n      [\"Attr\", [\"Name\", \"user\"], \"ShareRef\"],\n      [\"Const\", null],\n    ]);\n    this._rulesTable.addRecord(this._makeRule({\n      resource: resourceRef, aclFormula, aclFormulaParsed, permissionsText,\n    }));\n  }\n\n  private _blockShare(shareRef: number) {\n    const resource = this._findOrAddResource({\n      tableId: \"*\", colIds: \"*\",\n    });\n    const aclFormula = `user.ShareRef == ${shareRef}`;\n    const aclFormulaParsed = JSON.stringify(\n      [\"Eq\",\n        [\"Attr\", [\"Name\", \"user\"], \"ShareRef\"],\n        [\"Const\", shareRef]]);\n    this._rulesTable.addRecord(this._makeRule({\n      resource, aclFormula, aclFormulaParsed, permissionsText: \"-CRUDS\",\n    }));\n  }\n\n  private _makeRule(options: {\n    resource: number,\n    aclFormula: string,\n    aclFormulaParsed: string,\n    permissionsText: string,\n  }): MetaRowRecord<\"_grist_ACLRules\"> {\n    const { resource, aclFormula, aclFormulaParsed, permissionsText } = options;\n    return {\n      id: 0,\n      resource,\n      aclFormula,\n      aclFormulaParsed,\n      memo: \"\",\n      permissionsText,\n      userAttributes: \"\",\n      rulePos: 0,\n\n      // The following fields are unused and deprecated.\n      aclColumn: 0,\n      permissions: 0,\n      principals: \"\",\n    };\n  }\n}\n\n/**\n * Updates the ACL formula of `rule` such that it's disabled if a document is being\n * accessed via a share.\n *\n * Modifies `rule` in place.\n */\nfunction disableRuleInShare(rule: MetaRowRecord<\"_grist_ACLRules\">) {\n  const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));\n  const newAclFormulaParsed = [\n    \"And\",\n    [\"Eq\", [\"Attr\", [\"Name\", \"user\"], \"ShareRef\"], [\"Const\", null]],\n    aclFormulaParsed || [\"Const\", true],\n  ];\n  rule.aclFormula = \"user.ShareRef is None and (\" + String(rule.aclFormula || \"True\") + \")\";\n  rule.aclFormulaParsed = JSON.stringify(newAclFormulaParsed);\n  return rule;\n}\n"
  },
  {
    "path": "app/common/ActionBundle.ts",
    "content": "/**\n * Basic definitions of types needed for ActionBundles.\n * See also EncActionBundle for how these are packaged for encryption.\n */\n\nimport { ApplyUAOptions } from \"app/common/ActiveDocAPI\";\nimport { DocAction, UserAction } from \"app/common/DocActions\";\nimport { RowCounts } from \"app/common/DocUsage\";\n\n// Metadata about the action.\nexport interface ActionInfo {\n  time: number;       // Milliseconds since epoch.\n  user: string;\n  inst: string;\n  desc?: string;\n  otherId: number;\n  linkId: number;\n}\n\n// Envelope contains information about recipients. In EncActionBundle, it's augmented with\n// information about the symmetric key that encrypts this envelope's contents.\nexport interface Envelope {\n  recipients: string[];       // sorted array of recipient instanceIds\n}\n\n// EnvContent packages arbitrary content with the index of the envelope to which it belongs.\nexport type EnvContent<Content> = [number, Content];\n\n// ActionBundle contains actions arranged into envelopes, i.e. split up by sets of recipients.\n// Note that different Envelopes contain different sets of recipients (which may overlap however).\n// ActionBundle is what gets encrypted/decrypted and then sent between hub and instance.\nexport interface ActionBundle {\n  actionNum: number;\n  actionHash: string | null;        // a checksum of bundle, (not including actionHash and other parts).\n  parentActionHash: string | null;  // a checksum of the parent action bundle, if there is one.\n  envelopes: Envelope[];\n  info: EnvContent<ActionInfo>;           // Should be in the envelope addressed to all peers.\n  stored: EnvContent<DocAction>[];\n  calc: EnvContent<DocAction>[];\n}\n\nexport function getEnvContent<Content>(items: EnvContent<Content>[]): Content[] {\n  return items.map(item => item[1]);\n}\n\n// ======================================================================\n// Types for ActionBundles used locally inside an instance.\n\n// Local action received from the browser, that is not yet applied. It is usually one UserAction,\n// but when multiple actions are sent by the browser in one call, they will form one bundle.\nexport interface UserActionBundle {\n  info: ActionInfo;\n  userActions: UserAction[];\n  options?: ApplyUAOptions;\n}\n\n// ActionBundle as received from the sandbox. It does not have some action metadata, but does have\n// undo information and a retValue for each input UserAction. Note that it is satisfied by the\n// ActionBundle structure defined in sandbox/grist/action_obj.py.\nexport interface SandboxActionBundle {\n  envelopes: Envelope[];\n  stored: EnvContent<DocAction>[];\n  direct: EnvContent<boolean>[];\n  calc: EnvContent<DocAction>[];\n  undo: EnvContent<DocAction>[];   // Inverse actions for all 'stored' actions.\n  retValues: any[];                     // Contains retValue for each of userActions.\n  rowCount: RowCounts;\n  // Mapping of keys (hashes of request args) to all unique requests made in a round of calculation\n  requests?: Record<string, SandboxRequest>;\n\n  // Optionally we can measure and include how many bytes it took to represent this bundle.\n  numBytes?: number;\n}\n\n// Represents a unique call to the Python REQUEST function\nexport interface SandboxRequest {\n  url: string;\n  method: string;\n  body?: string;\n  params: Record<string, string> | null;\n  headers: Record<string, string> | null;\n  deps: unknown;  // pass back to the sandbox unchanged in the response\n}\n\n// Local action that's been applied. It now has an actionNum, and includes doc actions packaged\n// into envelopes, as well as undo, and userActions, which allow rebasing.\nexport interface LocalActionBundle extends ActionBundle {\n  userActions: UserAction[];\n\n  // Inverse actions for all 'stored' actions. These aren't shared and not split by envelope.\n  // Applying 'undo' is governed by EDIT rather than READ permissions, so we always apply all undo\n  // actions. (It is the result of applying 'undo' that may be addressed to different recipients).\n  undo: DocAction[];\n}\n"
  },
  {
    "path": "app/common/ActionDispatcher.ts",
    "content": "import { BulkColValues, ColInfo, ColInfoWithId, ColValues, DocAction } from \"app/common/DocActions\";\n\nimport mapValues from \"lodash/mapValues\";\n\n// TODO this replaces modelUtil's ActionDispatcher and bulkActionExpand. Those should be removed.\n\n/**\n * Helper class which provides a `dispatchAction` method that dispatches DocActions received from\n * the server to methods `this.on{ActionType}`, e.g. `this.onUpdateRecord`.\n *\n * Implementation methods `on*` are called with the action as the first argument, and with\n * the action arguments as additional method arguments, for convenience.\n *\n * Methods for bulk actions may be implemented directly, or will iterate through each record in\n * the action, and call the single-record methods for each one.\n */\nexport abstract class ActionDispatcher {\n  public dispatchAction(action: DocAction): void {\n    // In node 6 testing, this switch is 5+ times faster than looking up \"on\"+action[0].\n    const a: any[] = action;\n    switch (action[0]) {\n      case \"AddRecord\":        return this.onAddRecord(action, a[1], a[2], a[3]);\n      case \"UpdateRecord\":     return this.onUpdateRecord(action, a[1], a[2], a[3]);\n      case \"RemoveRecord\":     return this.onRemoveRecord(action, a[1], a[2]);\n      case \"BulkAddRecord\":    return this.onBulkAddRecord(action, a[1], a[2], a[3]);\n      case \"BulkUpdateRecord\": return this.onBulkUpdateRecord(action, a[1], a[2], a[3]);\n      case \"BulkRemoveRecord\": return this.onBulkRemoveRecord(action, a[1], a[2]);\n      case \"ReplaceTableData\": return this.onReplaceTableData(action, a[1], a[2], a[3]);\n      case \"AddColumn\":        return this.onAddColumn(action, a[1], a[2], a[3]);\n      case \"RemoveColumn\":     return this.onRemoveColumn(action, a[1], a[2]);\n      case \"RenameColumn\":     return this.onRenameColumn(action, a[1], a[2], a[3]);\n      case \"ModifyColumn\":     return this.onModifyColumn(action, a[1], a[2], a[3]);\n      case \"AddTable\":         return this.onAddTable(action, a[1], a[2]);\n      case \"RemoveTable\":      return this.onRemoveTable(action, a[1]);\n      case \"RenameTable\":      return this.onRenameTable(action, a[1], a[2]);\n      default: throw new Error(`Received unknown action ${action[0]}`);\n    }\n  }\n\n  protected abstract onAddRecord(action: DocAction, tableId: string, rowId: number, colValues: ColValues): void;\n  protected abstract onUpdateRecord(action: DocAction, tableId: string, rowId: number, colValues: ColValues): void;\n  protected abstract onRemoveRecord(action: DocAction, tableId: string, rowId: number): void;\n\n  // If not overridden, these will make multiple calls to single-record action methods.\n  protected onBulkAddRecord(action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void {\n    for (let i = 0; i < rowIds.length; i++) {\n      this.onAddRecord(action, tableId, rowIds[i], mapValues(colValues, values => values[i]));\n    }\n  }\n\n  protected onBulkUpdateRecord(action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void {\n    for (let i = 0; i < rowIds.length; i++) {\n      this.onUpdateRecord(action, tableId, rowIds[i], mapValues(colValues, values => values[i]));\n    }\n  }\n\n  protected onBulkRemoveRecord(action: DocAction, tableId: string, rowIds: number[]) {\n    for (const r of rowIds) {\n      this.onRemoveRecord(action, tableId, r);\n    }\n  }\n\n  protected abstract onReplaceTableData(\n    action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void;\n\n  protected abstract onAddColumn(action: DocAction, tableId: string, colId: string, colInfo: ColInfo): void;\n  protected abstract onRemoveColumn(action: DocAction, tableId: string, colId: string): void;\n  protected abstract onRenameColumn(action: DocAction, tableId: string, oldColId: string, newColId: string): void;\n  protected abstract onModifyColumn(action: DocAction, tableId: string, colId: string, colInfo: ColInfo): void;\n\n  protected abstract onAddTable(action: DocAction, tableId: string, columns: ColInfoWithId[]): void;\n  protected abstract onRemoveTable(action: DocAction, tableId: string): void;\n  protected abstract onRenameTable(action: DocAction, oldTableId: string, newTableId: string): void;\n}\n"
  },
  {
    "path": "app/common/ActionGroup.ts",
    "content": "import { ActionSummary } from \"app/common/ActionSummary\";\n\n/**\n * This is the action representation the client works with, for the purposes of undos/redos.\n */\nexport interface MinimalActionGroup {\n  actionNum: number;\n  actionHash: string;\n  fromSelf: boolean;\n  linkId: number;\n  otherId: number;\n  rowIdHint: number;      // If non-zero, this is a rowId that would be a good place to put\n  // the cursor after an undo.\n  isUndo: boolean;        // True if the first user action is ApplyUndoActions.\n}\n\n/**\n * This is the action representation the client works with, for the purposes of document\n * history and undos/redos.\n */\nexport interface ActionGroup extends MinimalActionGroup {\n  desc?: string;\n  actionSummary: ActionSummary;\n  time: number;\n  user: string;\n  primaryAction: string;  // The name of the first user action in the ActionGroup.\n  internal: boolean;      // True if it is inappropriate to log/undo the action.\n}\n"
  },
  {
    "path": "app/common/ActionRouter.ts",
    "content": "import { Rpc } from \"grain-rpc\";\n\n/**\n * ActionRouter allows to choose what actions to send over rpc. Action are posted as message `{type:\n * \"docAction\", action }` over rpc.\n */\nexport class ActionRouter {\n  private _subscribedTables = new Set<string>();\n\n  constructor(private _rpc: Rpc) {}\n\n  /**\n   * Subscribe to send all actions related to a table. Keeps sending actions if table is renamed.\n   */\n  public subscribeTable(tableId: string): Promise<void> {\n    this._subscribedTables.add(tableId);\n    return Promise.resolve();\n  }\n\n  /**\n   * Stop sending all message related to a table.\n   */\n  public unsubscribeTable(tableId: string): Promise<void> {\n    this._subscribedTables.delete(tableId);\n    return Promise.resolve();\n  }\n\n  /**\n   * Process a action updates subscription set in case of table rename and table remove, and post\n   * action if it matches a subscriptions.\n   */\n  public process(action: any[]): Promise<void> {\n    const tableId = action[1];\n    if (!this._subscribedTables.has(tableId)) {\n      return Promise.resolve();\n    }\n    switch (action[0]) {\n      case \"RemoveTable\":\n        this._subscribedTables.delete(tableId);\n        break;\n      case \"RenameTable\":\n        this._subscribedTables.delete(tableId);\n        this._subscribedTables.add(action[2]);\n        break;\n    }\n    return this._rpc.postMessage({ type: \"docAction\", action });\n  }\n}\n"
  },
  {
    "path": "app/common/ActionSummarizer.ts",
    "content": "import { getEnvContent, LocalActionBundle } from \"app/common/ActionBundle\";\nimport { ActionSummary, ColumnDelta, createEmptyActionSummary,\n  createEmptyTableDelta, defunctTableName, LabelDelta, TableDelta } from \"app/common/ActionSummary\";\nimport { DocAction } from \"app/common/DocActions\";\nimport * as Action from \"app/common/DocActions\";\nimport { arrayExtend } from \"app/common/gutil\";\nimport { TableData } from \"app/common/TableData\";\nimport { CellDelta } from \"app/common/TabularDiff\";\n\nimport clone from \"lodash/clone\";\nimport fromPairs from \"lodash/fromPairs\";\nimport keyBy from \"lodash/keyBy\";\nimport sortBy from \"lodash/sortBy\";\nimport toPairs from \"lodash/toPairs\";\nimport values from \"lodash/values\";\n\n/**\n * The default maximum number of rows in a single bulk change that will be recorded\n * individually.  Bulk changes that touch more than this number of rows\n * will be summarized only by the number of rows touched.\n */\nconst MAXIMUM_INLINE_ROWS = 10;\n\n/**\n * Options when producing an action summary.\n */\nexport interface ActionSummaryOptions {\n  /**\n   * Overrides the maximum number of rows in a single bulk change that will be\n   * recorded individually. Set to `null` to specify no limit. Defaults to `10`.\n   */\n  maximumInlineRows?: number | null;\n  /**\n   * If set, all cells in these columns are preserved regardless of the value of\n   * `maximumInlineRows`.\n   */\n  alwaysPreserveColIds?: string[];\n}\n\nexport class ActionSummarizer {\n  private readonly _maxRows = this._getMaxRows();\n\n  constructor(private _options?: ActionSummaryOptions) {}\n\n  /**\n   * Add information about an action based on the forward direction.\n   * The `act` DocAction is examined for everything we can glean,\n   * updating the ActionSummary. On its own, this isn't enough for\n   * the summary to be complete, since we know neither the current\n   * state the action is working on, nor the undo action for `act`.\n   */\n  public addForwardAction(summary: ActionSummary, act: DocAction) {\n    const tableId = act[1];\n    if (Action.isAddTable(act)) {\n      summary.tableRenames.push([null, tableId]);\n      for (const info of act[2]) {\n        this._forTable(summary, tableId).columnRenames.push([null, info.id]);\n      }\n    } else if (Action.isRenameTable(act)) {\n      this._addRename(summary.tableRenames, [tableId, act[2]]);\n    } else if (Action.isRenameColumn(act)) {\n      this._addRename(this._forTable(summary, tableId).columnRenames, [act[2], act[3]]);\n    } else if (Action.isAddColumn(act)) {\n      this._forTable(summary, tableId).columnRenames.push([null, act[2]]);\n    } else if (Action.isRemoveColumn(act)) {\n      this._forTable(summary, tableId).columnRenames.push([act[2], null]);\n    } else if (Action.isAddRecord(act)) {\n      const td = this._forTable(summary, tableId);\n      td.addRows.push(act[2]);\n      this._addRow(td, act[2], act[3], 1);\n    } else if (Action.isUpdateRecord(act)) {\n      const td = this._forTable(summary, tableId);\n      td.updateRows.push(act[2]);\n      this._addRow(td, act[2], act[3], 1);\n    } else if (Action.isBulkAddRecord(act)) {\n      const td = this._forTable(summary, tableId);\n      arrayExtend(td.addRows, act[2]);\n      this._addRows(tableId, td, act[2], act[3], 1);\n    } else if (Action.isBulkUpdateRecord(act)) {\n      const td = this._forTable(summary, tableId);\n      arrayExtend(td.updateRows, act[2]);\n      this._addRows(tableId, td, act[2], act[3], 1);\n    } else if (Action.isReplaceTableData(act)) {\n      const td = this._forTable(summary, tableId);\n      arrayExtend(td.addRows, act[2]);\n      this._addRows(tableId, td, act[2], act[3], 1);\n    }\n  }\n\n  /**\n   * Add information about an action to a summary based on\n   * undo information. `act` is assumed to be an undo action.\n   * So, for example, if it is an AddTable, the summary will\n   * contain a table deletion.\n   */\n  public addReverseAction(summary: ActionSummary, act: DocAction) {\n    const tableId = act[1];\n    if (Action.isAddTable(act)) { // undoing, so this is a table removal\n      summary.tableRenames.push([tableId, null]);\n      for (const info of act[2]) {\n        this._forTable(summary, tableId).columnRenames.push([info.id, null]);\n      }\n    } else if (Action.isAddRecord(act)) { // undoing, so this is a record removal\n      const td = this._forTable(summary, tableId);\n      td.removeRows.push(act[2]);\n      this._addRow(td, act[2], act[3], 0);\n    } else if (Action.isUpdateRecord(act)) { // undoing, so this is reversal of a record update\n      const td = this._forTable(summary, tableId);\n      this._addRow(td, act[2], act[3], 0);\n    } else if (Action.isBulkAddRecord(act)) { // undoing, this may be reversing a table delete\n      const td = this._forTable(summary, tableId);\n      arrayExtend(td.removeRows, act[2]);\n      this._addRows(tableId, td, act[2], act[3], 0);\n    } else if (Action.isBulkUpdateRecord(act)) { // undoing, so this is reversal of a bulk record update\n      const td = this._forTable(summary, tableId);\n      arrayExtend(td.updateRows, act[2]);\n      this._addRows(tableId, td, act[2], act[3], 0);\n    } else if (Action.isRenameTable(act)) { // undoing - sometimes renames only in undo info\n      this._addRename(summary.tableRenames, [act[2], tableId]);\n    } else if (Action.isRenameColumn(act)) { // undoing - sometimes renames only in undo info\n      this._addRename(this._forTable(summary, tableId).columnRenames, [act[3], act[2]]);\n    } else if (Action.isReplaceTableData(act)) { // undoing\n      const td = this._forTable(summary, tableId);\n      arrayExtend(td.removeRows, act[2]);\n      this._addRows(tableId, td, act[2], act[3], 0);\n    }\n  }\n\n  /**\n   * Build a summary from a forward action plus the current table state (pre-action).\n   * This is an alternative to addForwardAction + addReverseAction, for when undo\n   * actions aren't available but the live table data is.\n   *\n   * addForwardAction() populates new values (CellDelta index 1). Removals need\n   * special handling since forward remove actions don't carry cell contents — we\n   * look those up from tableData. The final loop backfills old values (index 0) for\n   * updated/added rows from the current table state, which works because this is\n   * called before the action is applied.\n   */\n  public addAction(summary: ActionSummary, act: DocAction,\n    tableData: TableData) {\n    const tableId = act[1];\n    if (!summary.tableDeltas[tableId]) {\n      summary.tableDeltas[tableId] = createEmptyTableDelta();\n    }\n    this.addForwardAction(summary, act);\n    // removal of records doesn't register in forward action.\n    if (Action.isRemoveRecord(act)) {\n      const td = this._forTable(summary, tableId);\n      td.removeRows.push(act[2]);\n      const rec = tableData.getRecord(act[2]);\n      if (rec) {\n        this._addRow(td, act[2], rec, 0);\n      }\n    } else if (Action.isBulkRemoveRecord(act)) {\n      const td = this._forTable(summary, tableId);\n      arrayExtend(td.removeRows, act[2]);\n      for (const id of act[2]) {\n        const rec = tableData.getRecord(id);\n        if (rec) {\n          this._addRow(td, id, rec, 0);\n        }\n      }\n    }\n\n    // Backfill old values (CellDelta index 0) from the pre-action table state.\n    // For updates, this captures what the cell held before the edit. For adds,\n    // getRecord returns undefined (row doesn't exist yet), so the old value is\n    // correctly set to null (non-existent).\n    const tableDelta = summary.tableDeltas[tableId];\n    for (const r of new Set([...tableDelta.updateRows, ...tableDelta.addRows])) {\n      const row = tableData.getRecord(r);\n      for (const colId of Object.keys(tableDelta.columnDeltas)) {\n        if (!(r in tableDelta.columnDeltas[colId])) {\n          continue;\n        }\n        const cell = row?.[colId];\n        const nestedCell = cell === undefined ? null : [cell] as [any];\n        tableDelta.columnDeltas[colId][r][0] = nestedCell;\n      }\n    }\n  }\n\n  /** helper function to access summary changes for a specific table by name */\n  private _forTable(summary: ActionSummary, tableId: string): TableDelta {\n    return summary.tableDeltas[tableId] || (summary.tableDeltas[tableId] = createEmptyTableDelta());\n  }\n\n  /** helper function to access summary changes for a specific cell by rowId and colId */\n  private _forCell(td: TableDelta, rowId: number, colId: string): CellDelta {\n    const cd = td.columnDeltas[colId] || (td.columnDeltas[colId] = {});\n    return cd[rowId] || (cd[rowId] = [null, null]);\n  }\n\n  /**\n   * helper function to store detailed cell changes for a single row.\n   * Direction parameter is 0 if values are prior values of cells, 1 if values are new values.\n   */\n  private _addRow(td: TableDelta, rowId: number, colValues: Action.ColValues,\n    direction: 0 | 1) {\n    for (const [colId, colChanges] of toPairs(colValues)) {\n      const cell = this._forCell(td, rowId, colId);\n      cell[direction] = [colChanges];\n    }\n  }\n\n  /** helper function to store detailed cell changes for a set of rows */\n  private _addRows(tableId: string, td: TableDelta, rowIds: number[],\n    colValues: Action.BulkColValues, direction: 0 | 1) {\n    const limitRows: boolean = rowIds.length > this._maxRows && !tableId.startsWith(\"_grist_\");\n    let selectedRows: [number, number][] = [];\n    if (limitRows) {\n      // if many rows, just take some from start and one from end as examples\n      selectedRows = [...rowIds.slice(0, this._maxRows - 1).entries()];\n      selectedRows.push([rowIds.length - 1, rowIds[rowIds.length - 1]]);\n    }\n\n    const alwaysPreserveColIds = new Set(this._options?.alwaysPreserveColIds || []);\n    for (const [colId, colChanges] of toPairs(colValues)) {\n      const addCellToSummary = (rowId: number, idx: number) => {\n        const cell = this._forCell(td, rowId, colId);\n        cell[direction] = [colChanges[idx]];\n      };\n      if (!limitRows || alwaysPreserveColIds.has(colId)) {\n        rowIds.forEach(addCellToSummary);\n      } else {\n        selectedRows.forEach(([idx, rowId]) => addCellToSummary(rowId, idx));\n      }\n    }\n  }\n\n  /** add a rename to a list, avoiding duplicates */\n  private _addRename(renames: LabelDelta[], rename: LabelDelta) {\n    if (renames.find(r => r[0] === rename[0] && r[1] === rename[1])) { return; }\n    renames.push(rename);\n  }\n\n  private _getMaxRows() {\n    const maxRows = this._options?.maximumInlineRows;\n    if (maxRows === undefined) {\n      return MAXIMUM_INLINE_ROWS;\n    } else if (maxRows === null) {\n      return Infinity;\n    } else {\n      return maxRows;\n    }\n  }\n}\n\n/**\n * Summarize the tabular changes that a LocalActionBundle results in, in a form\n * that will be suitable for composition.\n */\nexport function summarizeAction(body: LocalActionBundle, options?: ActionSummaryOptions): ActionSummary {\n  return summarizeStoredAndUndo(getEnvContent(body.stored), body.undo, options);\n}\n\nexport function summarizeStoredAndUndo(stored: DocAction[], undo: DocAction[],\n  options?: ActionSummaryOptions): ActionSummary {\n  const summarizer = new ActionSummarizer(options);\n  const summary = createEmptyActionSummary();\n  for (const act of stored) {\n    summarizer.addForwardAction(summary, act);\n  }\n  for (const act of Array.from(undo).reverse()) {\n    summarizer.addReverseAction(summary, act);\n  }\n  // Name tables consistently, by their ultimate name, now we know it.\n  for (const renames of summary.tableRenames) {\n    const pre = renames[0];\n    let post = renames[1];\n    if (pre === null) { continue; }\n    if (post === null) { post = defunctTableName(pre); }\n    if (summary.tableDeltas[pre]) {\n      summary.tableDeltas[post] = summary.tableDeltas[pre];\n      delete summary.tableDeltas[pre];\n    }\n  }\n  for (const td of values(summary.tableDeltas)) {\n    // Name columns consistently, by their ultimate name, now we know it.\n    for (const renames of td.columnRenames) {\n      const pre = renames[0];\n      let post = renames[1];\n      if (pre === null) { continue; }\n      if (post === null) { post = defunctTableName(pre); }\n      if (td.columnDeltas[pre]) {\n        td.columnDeltas[post] = td.columnDeltas[pre];\n        delete td.columnDeltas[pre];\n      }\n    }\n    // remove any duplicates that crept in\n    td.addRows = Array.from(new Set(td.addRows));\n    td.updateRows = Array.from(new Set(td.updateRows));\n    td.removeRows = Array.from(new Set(td.removeRows));\n  }\n  return summary;\n}\n\n/**\n * Once we can produce an ActionSummary for each LocalActionBundle, it is useful to be able\n * to compose them.  Take the case of an ActionSummary pair, part 1 and part 2.  NameMerge\n * is an internal structure to help merging table/column name changes across two parts.\n */\ninterface NameMerge {\n  dead1: Set<string>;  /** anything of this name in part 1 should be removed from merge */\n  dead2: Set<string>;  /** anything of this name in part 2 should be removed from merge */\n  rename1: Map<string, string>;  /** replace these names in part 1 */\n  rename2: Map<string, string>;  /** replace these names in part 2 */\n  merge: LabelDelta[]; /** a merged list of adds/removes/renames for the result */\n}\n\n/**\n * Looks at a pair of name change lists (could be tables or columns) and figures out what\n * changes would need to be made to a data structure keyed on those names in order to key\n * it consistently on final names.\n */\nfunction planNameMerge(names1: LabelDelta[], names2: LabelDelta[]): NameMerge {\n  const result: NameMerge = {\n    dead1: new Set(),\n    dead2: new Set(),\n    rename1: new Map<string, string>(),\n    rename2: new Map<string, string>(),\n    merge: new Array<LabelDelta>(),\n  };\n  const names1ByFinalName: { [name: string]: LabelDelta } = keyBy(names1, p => p[1]!);\n  const names2ByInitialName: { [name: string]: LabelDelta } = keyBy(names2, p => p[0]!);\n  for (const [before1, after1] of names1) {\n    if (!after1) {\n      if (!before1) { throw new Error(\"invalid name change found\"); }\n      // Table/column was deleted in part 1.\n      result.dead1.add(before1);\n      result.merge.push([before1, null]);\n      continue;\n    }\n    // At this point, we know the table/column existed at end of part 1.\n    const pair2 = names2ByInitialName[after1];\n    if (!pair2) {\n      // Table/column's name was stable in part 2, so only change was in part 1.\n      result.merge.push([before1, after1]);\n      continue;\n    }\n    const after2 = pair2[1];\n    if (!after2) {\n      // Table/column was deleted in part 2.\n      result.dead2.add(after1);\n      if (before1) {\n        // Table/column existed prior to part 1, so we need to expose its history.\n        result.dead1.add(before1);\n        result.merge.push([before1, null]);\n      } else {\n        // Table/column did not exist prior to part 1, so we erase it from history.\n        result.dead1.add(after1);\n        result.dead2.add(defunctTableName(after1));\n      }\n      continue;\n    }\n    // It we made it this far, our table/column exists after part 2.  Any information\n    // keyed to its name in part 1 will need to be rekeyed to its final name.\n    result.rename1.set(after1, after2);\n    result.merge.push([before1, after2]);\n  }\n  // Look through part 2 for any changes not already covered.\n  for (const [before2, after2] of names2) {\n    if (!before2 && !after2) { throw new Error(\"invalid name change found\"); }\n    if (before2 && names1ByFinalName[before2]) { continue; }  // Already handled\n    result.merge.push([before2, after2]);\n    // If table/column is renamed in part 2, and name was stable in part 1,\n    // rekey any information about it in part 1.\n    if (before2 && after2) { result.rename1.set(before2, after2); }\n  }\n  // For neatness, sort the merge order. Not essential.\n  result.merge = sortBy(result.merge, ([a, b]) => [a || \"\", b || \"\"]);\n  return result;\n}\n\n/**\n * Re-key nested data to match name changes / removals.  Needs to be done a little carefully\n * since it is perfectly possible for names to be swapped or shuffled.\n *\n * Entries may be TableDeltas in the case of table renames or ColumnDeltas for column renames.\n *\n * @param entries: a dictionary of nested data - TableDeltas for tables, ColumnDeltas for columns.\n * @param dead: a set of keys to remove from the dictionary.\n * @param rename: changes of names to apply to the dictionary.\n *\n * entries may be modified, and if so will be shallow-copied.\n */\nfunction renameAndDelete<T>(entries: CopyOnWrite<{ [name: string]: T }>, dead: Set<string>,\n  rename: Map<string, string>) {\n  if (!(dead.size || rename.size)) {\n    return;\n  }\n  entries.write();\n  const entriesCopy = entries.read();\n  // Remove all entries marked as dead.\n  for (const key of dead) { delete entriesCopy[key]; }\n  // Move all entries that are going to be renamed out to a cache temporarily.\n  const cache: { [name: string]: any } = {};\n  for (const key of rename.keys()) {\n    if (entriesCopy[key]) {\n      cache[key] = entriesCopy[key];\n      delete entriesCopy[key];\n    }\n  }\n  // Move all renamed entries back in with their new names.\n  for (const [key, val] of rename.entries()) {\n    if (cache[key]) { entriesCopy[val] = cache[key]; }\n  }\n}\n\n/**\n * Apply planned name changes to a pair of entries, and return a merged entry incorporating\n * their composition.\n *\n * @param names: the planned name changes as calculated by planNameMerge()\n * @param entries1: the first dictionary of nested data keyed on the names\n * @param entries2: test second dictionary of nested data keyed on the names\n * @param mergeEntry: a function to apply any further corrections needed to the entries\n *\n * entries2 may be modified, and if so it will be copied.\n */\nfunction mergeNames<T>(names: NameMerge,\n  entries1: { [name: string]: T },\n  entries2: CopyOnWrite<{ [name: string]: T }>,\n  mergeEntry: (e1: T, e2: CopyOnWrite<T>) => T): { [name: string]: T } {\n  const entries1Wrapper = copyOnWrite(entries1);\n  // Update the keys of the entries1 and entries2 dictionaries to be consistent.\n  renameAndDelete(entries1Wrapper, names.dead1, names.rename1);\n  renameAndDelete(entries2, names.dead2, names.rename2);\n\n  // Prepare the composition of the two dictionaries.\n  const entries = entries2;                // Start with the second dictionary.\n  for (const key of Object.keys(entries1Wrapper.read())) {  // Add material from the first.\n    const e1 = entries1Wrapper.read()[key];\n    if (!entries.read()[key]) { entries.write()[key] = e1;  continue; }  // No overlap - just add and move on.\n    const e2cow = copyOnWrite(entries.read()[key]);\n    const result = mergeEntry(e1, e2cow);\n    if (e2cow.hasWrite()) {\n      entries.write()[key] = result;          // Recursive merge if overlap.\n    }\n  }\n  return entries.read();\n}\n\n/**\n * Track whether a specific row was added, removed or updated.\n */\ninterface RowChange {\n  added: boolean;\n  removed: boolean;\n  updated: boolean;\n}\n\n/** RowChange for each row in a table */\nexport interface RowChanges {\n  [rowId: number]: RowChange;\n}\n\n/**\n * This is used when we hit a cell that we know has changed but don't know its\n * value due to it being part of a bulk input.  This produces a cell that\n * represents the unknowns.\n */\nfunction bulkCellFor(rc: RowChange | undefined): CellDelta | undefined {\n  if (!rc) { return undefined; }\n  const result: CellDelta = [null, null];\n  if (rc.removed || rc.updated) { result[0] = \"?\"; }\n  if (rc.added || rc.updated) { result[1] = \"?\"; }\n  return result;\n}\n\n/**\n * Merge changes that apply to a particular column.\n *\n * @param present1: affected rows in part 1\n * @param present2: affected rows in part 2\n * @param e1: cached cell values for the column in part 1\n * @param e2: cached cell values for the column in part 2\n *\n * e2 may be modified, and will be copied if so.\n */\nfunction mergeColumn(present1: RowChanges, present2: RowChanges,\n  e1: ColumnDelta, e2: CopyOnWrite<ColumnDelta>): ColumnDelta {\n  for (const key of (Object.keys(present1) as unknown as number[])) {\n    let v1 = e1[key];\n    let v2 = e2.read()[key];\n    if (!v1 && !v2) { continue; }\n    if (present1[key].added && present2[key]?.removed) {\n      delete e2.write()[key];\n      continue;\n    }\n    v1 = v1 || bulkCellFor(present1[key]);\n    v2 = v2 || bulkCellFor(present2[key]);\n    if (!v2)    { e2.write()[key] = e1[key]; continue; }\n    if (!v1[1]) {\n      continue;\n    }  // Deleted row.\n    e2.write()[key] = [v1[0], v2[1]];  // Change is from initial value in e1 to final value in e2.\n  }\n  return e2.read();\n}\n\n/** Put list of numbers in ascending order, with duplicates removed. */\nfunction uniqueAndSorted(lst: number[]) {\n  return [...new Set(lst)].sort((a, b) => a - b);\n}\n\n/** For each row changed, figure out whether it was added/removed/updated */\n/** TODO: need for this method suggests maybe a better core representation for this info */\nfunction getRowChanges(e: TableDelta): RowChanges {\n  const all = new Set([...e.addRows, ...e.removeRows, ...e.updateRows]);\n  const added = new Set(e.addRows);\n  const removed = new Set(e.removeRows);\n  const updated = new Set(e.updateRows);\n  return fromPairs([...all].map((x) => {\n    return [x, { added: added.has(x),\n      removed: removed.has(x),\n      updated: updated.has(x) }] as [number, RowChange];\n  }));\n}\n\n/**\n * Merge changes that apply to a particular table.  For updating addRows and removeRows, care is\n * needed, since it is fine to remove and add the same rowId within a single summary -- this is just\n * rowId reuse.  It needs to be tracked so we know lifetime of rows though.\n *\n * e2 may be modified, and is copied if so.\n */\nfunction mergeTable(e1: TableDelta,  e2: CopyOnWrite<TableDelta>): TableDelta {\n  // First, sort out any changes to names of columns.\n  const names = planNameMerge(e1.columnRenames, e2.read().columnRenames);\n  const columnDeltasCow = copyOnWrite(e2.read().columnDeltas);\n  mergeNames(names, e1.columnDeltas, columnDeltasCow,\n    mergeColumn.bind(null,\n      getRowChanges(e1),\n      getRowChanges(e2.read())));\n  if (columnDeltasCow.hasWrite()) {\n    e2.write();\n    e2.read().columnDeltas = columnDeltasCow.read();\n  }\n  const columnRenames = names.merge;\n  // All the columnar data is now merged.  What remains is to merge the summary lists of rowIds\n  // that we maintain.\n  const addRows1 = new Set(e1.addRows);       // Non-transient rows we have clearly added.\n  const removeRows2 = new Set(e2.read().removeRows); // Non-transient rows we have clearly removed.\n  const transients = e1.addRows.filter(x => removeRows2.has(x));\n  const addRows = uniqueAndSorted([...e2.read().addRows, ...e1.addRows.filter(x => !removeRows2.has(x))]);\n  const removeRows = uniqueAndSorted([...e2.read().removeRows.filter(x => !addRows1.has(x)), ...e1.removeRows]);\n  const updateRows = uniqueAndSorted([...e1.updateRows.filter(x => !removeRows2.has(x)),\n    ...e2.read().updateRows.filter(x => !addRows1.has(x))]);\n  // Remove all traces of transients (rows that were created and destroyed) from history.\n  if (transients.length) {\n    for (const [colId, columnDelta] of Object.entries(e2.read().columnDeltas)) {\n      const updatedColumnDelta = copyOnWrite(columnDelta);\n      for (const rowId of transients) {\n        delete updatedColumnDelta.write()[rowId];\n      }\n      if (updatedColumnDelta.hasWrite()) {\n        e2.write().columnDeltas[colId] = updatedColumnDelta.read();\n      }\n    }\n  }\n  // We unconditionally write at this level.\n  Object.assign(e2.write(), {\n    columnRenames,\n    addRows,\n    removeRows,\n    updateRows,\n  });\n  return e2.read();\n}\n\n/** Finally, merge a pair of summaries. */\nexport function concatenateSummaryPair(sum1: ActionSummary, sum2: ActionSummary): ActionSummary {\n  const names = planNameMerge(sum1.tableRenames, sum2.tableRenames);\n  const rowChanges = mergeNames(names, sum1.tableDeltas, copyOnWrite(sum2.tableDeltas), mergeTable);\n  const sum: ActionSummary = {\n    tableRenames: names.merge,\n    tableDeltas: rowChanges,\n  };\n  return sum;\n}\n\n/** Generalize to merging a list of summaries. */\nexport function concatenateSummaries(sums: ActionSummary[]): ActionSummary {\n  if (sums.length === 0) { return createEmptyActionSummary(); }\n  let result = sums[0];\n  for (let i = 1; i < sums.length; i++) {\n    result = concatenateSummaryPair(result, sums[i]);\n  }\n  return result;\n}\n\nexport function getRenames(ref: ActionSummary | TableDelta) {\n  if (\"tableRenames\" in ref) {\n    return ref.tableRenames;\n  } else {\n    return ref.columnRenames;\n  }\n}\n\nexport function getDeltas<T extends ActionSummary | TableDelta>(ref: T) {\n  if (\"tableRenames\" in ref) {\n    return ref.tableDeltas;\n  } else {\n    return ref.columnDeltas;\n  }\n}\n\ninterface RebasePlan {\n  dead: Set<string>;\n  rename: Map<string, string>;\n  refBack: Map<string, string | null>;\n  targetBack: Map<string, string | null>;\n  targetForward: Map<string, string | null>;\n  refForward: Map<string | null, string | null>;\n  updatedRenames: LabelDelta[];\n}\n\n/**\n * For the ref and target, assumed to start from the same ancestor,\n * figure out the following changes in the ref that can be applied\n * to the target:\n *   - Any renaming of items.\n *   - Any deletion of items.\n * Return items to delete and rename, in the naming scheme of the\n * target.\n */\nfunction planRebase(ref: ActionSummary | TableDelta,\n  target: ActionSummary | TableDelta): RebasePlan {\n  const dead = new Set<string>();\n  const rename = new Map<string, string>();\n  const targetNames = new Map<string, string | null>();\n  const refBack = new Map<string, string | null>();\n  const targetBack = new Map<string, string | null>();\n  const refForward = new Map<string | null, string | null>();\n  for (const [oldId, newId] of getRenames(target)) {\n    if (oldId) {\n      targetNames.set(oldId, newId);\n    }\n    if (newId) {\n      targetBack.set(newId, oldId);\n    }\n  }\n  for (const [oldId, newId] of getRenames(ref)) {\n    if (newId) {\n      refBack.set(newId, oldId);\n    }\n    if (oldId) {\n      refForward.set(oldId, newId);\n    }\n  }\n\n  const targetDeltas = getDeltas(target);\n\n  for (const [oldId, newId] of getRenames(ref)) {\n    if (oldId && !newId) {\n      dead.add(targetNames.get(oldId) || oldId);\n    }\n    if (!oldId && newId && targetDeltas[newId]) {\n      dead.add(newId);\n    }\n    if (oldId && newId && !targetNames.get(oldId)) {\n      rename.set(oldId, newId);\n    }\n  }\n  const updatedRenames: LabelDelta[] = getRenames(target)\n    .filter(([oldId, _]) => !oldId || refForward.get(oldId) !== null)\n    .filter(([oldId, newId]) => oldId || !newId || !refBack.get(newId))\n    .map(([oldId, newId]) => [refForward.get(oldId) || oldId, newId]);\n\n  return {\n    dead,\n    rename,\n    targetBack,\n    refBack,\n    targetForward: targetNames,\n    refForward,\n    updatedRenames,\n  };\n}\n\n/**\n * Applies table and column renames that are present in the `ref`\n * summary to the target summary.\n */\nexport function rebaseSummary(ref: ActionSummary, target: ActionSummary) {\n  const plan = planRebase(ref, target);\n  const empty = createEmptyTableDelta();\n  for (const key of Object.keys(target.tableDeltas)) {\n    const ancestorName = plan.targetBack.get(key) || key;\n    const afterTargetName = plan.targetForward.get(ancestorName) || ancestorName;\n    const afterRefName = plan.refForward.get(ancestorName) || ancestorName;\n    const afterTarget = target.tableDeltas[afterTargetName] ?? empty;\n    const afterRef = ref.tableDeltas[afterRefName] ?? empty;\n    rebaseTable(afterRef, afterTarget);\n  }\n  const deltas = copyOnWrite(target.tableDeltas);\n  renameAndDelete(deltas, plan.dead, plan.rename);\n  target.tableDeltas = deltas.read();\n  target.tableRenames = plan.updatedRenames;\n}\n\nfunction rebaseTable(ref: TableDelta, target: TableDelta) {\n  const plan = planRebase(ref, target);\n  const deltas = copyOnWrite(target.columnDeltas);\n  renameAndDelete(deltas, plan.dead, plan.rename);\n  target.columnDeltas = deltas.read();\n  target.columnRenames = plan.updatedRenames;\n}\n\n/**\n * Wrapper to facilitate making a shallow copy of an\n * object if we find we need to edit it.\n */\nfunction copyOnWrite<T>(item: T): CopyOnWrite<T> {\n  let maybeCopiedItem: T | undefined;\n  return {\n    read() { return maybeCopiedItem || item; },\n    write() {\n      if (!maybeCopiedItem) {\n        // make a shallow clone\n        maybeCopiedItem = clone(item);\n      }\n      return maybeCopiedItem;\n    },\n    hasWrite() {\n      return maybeCopiedItem !== undefined;\n    },\n  };\n}\n\ninterface CopyOnWrite<T> {\n  read(): T;\n  write(): T;\n  hasWrite(): boolean;\n}\n"
  },
  {
    "path": "app/common/ActionSummary.ts",
    "content": "import { CellDelta, TabularDiff, TabularDiffs } from \"app/common/TabularDiff\";\nimport { ResultRow } from \"app/common/TimeQuery\";\n\nimport toPairs from \"lodash/toPairs\";\n\n/**\n * An ActionSummary represents the overall effect of changes that took place\n * during a period of history.\n *   - Only net changes are represented.  Intermediate changes within the period are\n *     not represented.  Changes that are done and undone within the period are not\n *     represented.\n *   - Net addition, removal, and renaming of tables is represented.  The names\n *     of tables, for ActionSummary purposes are their tableIds, the database-safe\n *     version of their names.\n *   - Net addition, removal, and renaming of columns is represented.  As for tables,\n *     the names of columns for ActionSummary purposes are their colIds.\n *   - Net additions and removals of rows are partially represented.  The rowIds of added\n *     and removed rows are represented fully.  The *values* of cells in the rows that\n *     were added or removed are stored in some cases.  There is a threshold on the\n *     number of rows whose values will be cached for each DocAction scanned.\n *   - Net updates of rows are partially represented.  The rowIds of updated rows are\n *     represented fully, but the *values* of updated cells partially, as for additions/\n *     removals.\n *   - Cell value changes affecting _grist_* tables are always represented in full,\n *     even if they are bulk changes.\n *\n * The representation of table name changes and column name changes is the same,\n * simply a list of name pairs [before, after].  We represent the addition of a\n * a table (or column) as the special name pair [null, initialName], and the\n * removal of a table (or column) as the special name pair [finalName, null].\n *\n * An ActionSummary contains two fields:\n *   - tableRenames: a list of table name changes (including addition/removal).\n *   - tableDeltas: a dictionary of changes within a table.\n *\n * The key of the tableDeltas dictionary is the name of a table at the end of the\n * period of history covered by the ActionSummary.\n *   - For example, if we add a table called N, we use the key N for it.\n *   - If we rename a table from N1 to N2, we use the key N2 for it.\n *   - If we add a table called N1, then rename it to N2, we use the key N2 for it.\n * If the table was removed during that period, we use its name at the beginning\n * of the period, preceded by \"-\".\n *   - If we remove a table called N, we use the key -N for it.\n *   - If we add a table called N, then remove it, there is no net change to represent.\n *   - If we remove a table called N, then add a new table called N, we use the key -N\n *     for the first, and the key N for the second.\n *\n * The changes within a table are represented as a TableDelta, which has the following\n * fields:\n *   - columnRenames: a list of column name changes (including addition/removal).\n *   - columnDeltas: a dictionary of changes within a column.\n *   - updateRows, removeRows, addRows: lists of affected rows.\n *\n * The columnRenames/columnDeltas pair work just like tableRenames/tableDeltas, just\n * on the scope of columns within a table rather than tables within a document.\n *\n * The changes within a column are represented as a ColumnDelta, which is a dictionary\n * keyed by rowIds.  It contains CellDelta values.  CellDelta values represent before\n * and after values of a particular cell.\n *   - a CellDelta of [null, [value]] represents a cell that was non-existent coming into\n *     existence with the given value.\n *   - a CellDelta of [[value], null] represents an existing cell with the given value that\n *     is removed.\n *   - a CellDelta of [[value1], [value2]] represents a change in value of a cell between\n *     two known values.\n *   - a CellDelta of ['?', [value2]] represents a change in value of a cell from an\n *     unknown value to a known value.  Unknown values happen when we know a cell was\n *     implicated in a bulk change but its value didn't happen to be stored.\n *   - a CellDelta of [[value1], '?'] represents a change in value of a cell from an\n *     known value to an unknown value.\n * The CellDelta itself does not tell you whether the rowId has the same identity before\n * and after -- for example it may have been removed and then added.  That information\n * is available by consulting the removeRows and addRows fields.\n *\n */\n\n/**\n * A collection of changes related to a set of tables.\n */\nexport interface ActionSummary {\n  tableRenames: LabelDelta[];  /** a list of table renames/additions/removals */\n  tableDeltas: { [tableId: string]: TableDelta };  /** changes within an individual table */\n}\n\n/**\n * A collection of changes related to rows and columns of a single table.\n */\nexport interface TableDelta {\n  updateRows: number[];  /** rowIds of rows that exist before+after and were changed during */\n  removeRows: number[];  /** rowIds of rows that existed before but were removed during */\n  addRows: number[];     /** rowIds of rows that were added during, and exist after */\n  /** Partial record of cell-level changes - large bulk changes not included. */\n  columnDeltas: { [colId: string]: ColumnDelta };\n  columnRenames: LabelDelta[];  /** a list of column renames/additions/removals */\n}\n\n/**\n * Pairs of before/after names of tables and columns.  Null represents non-existence,\n * so the addition and removal of tables/columns can be represented.\n */\nexport type LabelDelta = [string | null, string | null];\n\n/**\n * A collection of changes related to cells in a specific column.\n */\nexport interface ColumnDelta {\n  [rowId: number]: CellDelta;\n}\n\n/** Create an ActionSummary for a period with no action */\nexport function createEmptyActionSummary(): ActionSummary {\n  return { tableRenames: [], tableDeltas: {} };\n}\n\n/** Create a TableDelta for a period with no action */\nexport function createEmptyTableDelta(): TableDelta {\n  return {\n    updateRows: [],\n    removeRows: [],\n    addRows: [],\n    columnDeltas: {},\n    columnRenames: [],\n  };\n}\n\n/**\n * Distill a summary further, into tabular form, for ease of rendering.\n *\n * If context is supplied, in the form of row data for tables, then\n * the difference information is overlaid on it.\n */\nexport function asTabularDiffs(summary: ActionSummary, options: {\n  context?: Record<string, ResultRow[]>,\n  order?: (tableId: string, colIds: string[]) => string[],\n}): TabularDiffs {\n  const allChanges: TabularDiffs = {};\n  for (const [tableId, td] of toPairs(summary.tableDeltas)) {\n    const singleTableContext = options.context?.[tableId];\n    const tableChanges: TabularDiff = allChanges[tableId] = {\n      header: [],\n      cells: [],\n    };\n    // need order to be row-dominant for visualization purposes.\n    const perRow: { [row: number]: { [name: string]: any } } = {};\n    const activeCols = new Set<string>();\n    // First add any background context we have been handed.\n    for (const row of singleTableContext || []) {\n      if (!(typeof row.id === \"number\")) {\n        // Should not happen.\n        throw new Error(`asTabularDiffs saw a non-numeric id: ${row.id}`);\n      }\n      perRow[row.id] = row;\n      for (const col of Object.keys(row)) {\n        if (!activeCols.has(col)) {\n          activeCols.add(col);\n        }\n      }\n    }\n    // Now iterate through the differences provided, and overlay them.\n    for (const [col, perCol] of toPairs(td.columnDeltas)) {\n      activeCols.add(col);\n      for (const row of Object.keys(perCol)) {\n        if (!perRow[row as any]) { perRow[row as any] = {}; }\n        perRow[row as any][col] = perCol[row as any];\n      }\n    }\n    // TODO: recover row numbers (as opposed to rowIds)\n    const reorder = options.order ?? ((_, colIds) => colIds);\n    const activeColsWithoutManualSort = [\n      ...reorder(tableId, [...activeCols]),\n    ].filter(c => c !== \"manualSort\");\n    tableChanges.header = activeColsWithoutManualSort;\n    const addedRows = new Set(td.addRows);\n    const removedRows = new Set(td.removeRows);\n    const updatedRows = new Set(td.updateRows);\n    const rowIds = Object.keys(perRow).map(row => parseInt(row, 10));\n    const presentRows = new Set(rowIds);\n    const droppedRows = [...addedRows, ...removedRows, ...updatedRows]\n      .filter(x => !presentRows.has(x))\n      .sort((a, b) => a - b);\n\n    // Now that we have pulled together rows of changes, we will add a summary cell\n    // to each row to show whether they were caused by row updates, additions or removals.\n    // We also at this point make sure the cells of the row are output in a consistent\n    // order with a header.\n    for (const rowId of rowIds) {\n      if (droppedRows.length > 0) {\n        // Bulk additions/removals/updates may result in just some rows being saved.\n        // We signal this visually with a \"...\" row.  The order of where this should\n        // go isn't well defined at this point (there's a row number TODO above).\n        if (rowId > droppedRows[0]) {\n          tableChanges.cells.push({\n            type: \"...\",\n            rowId: droppedRows[0],\n            cellDeltas: activeColsWithoutManualSort.map(x => [null, null] as [null, null]),\n          });\n          while (rowId > droppedRows[0]) {\n            droppedRows.shift();\n          }\n        }\n      }\n      // For each rowId, we need to issue either 1 or 2 rows.  We issue 2 rows\n      // if the rowId is both added and removed - in this scenario, the rows\n      // before and after are unrelated.  In all other cases, the before and\n      // after values refer to the same row.\n      const versions: [string, (diff: CellDelta) => CellDelta][] = [];\n      if (addedRows.has(rowId) && removedRows.has(rowId)) {\n        versions.push([\"-\", diff => [diff[0], null]]);\n        versions.push([\"+\", diff => [null, diff[1]]]);\n      } else {\n        let code: string = \"...\";\n        if (updatedRows.has(rowId)) {\n          code = \"→\";\n        }\n        if (addedRows.has(rowId)) {\n          code = \"+\";\n        }\n        if (removedRows.has(rowId)) {\n          code = \"-\";\n        }\n        versions.push([code, diff => diff]);\n      }\n      for (const [code, transform] of versions) {\n        const acc: CellDelta[] = [];\n        const perCol = perRow[rowId];\n        activeColsWithoutManualSort.forEach((col) => {\n          const diff = perCol ? perCol[col] : null;\n          if (!diff) {\n            acc.push([null, null]);\n          } else {\n            acc.push(transform(diff));\n          }\n        });\n        tableChanges.cells.push({\n          type: code,\n          rowId,\n          cellDeltas: acc,\n        });\n      }\n    }\n  }\n  return allChanges;\n}\n\n/**\n * Return a suitable key for a removed table/column.  We cannot use their id directly\n * since it could clash with an added table/column of the same name.\n */\nexport function defunctTableName(id: string): string {\n  return `-${id}`;\n}\n\nexport function rootTableName(id: string): string {\n  return id.replace(\"-\", \"\");\n}\n\n/**\n * Returns a list of all tables changed by the summarized action.  Changes include\n * schema or data changes.  Tables are identified by their post-action name.\n * Deleted tables are identified by their pre-action name, with \"-\" prepended.\n */\nexport function getAffectedTables(summary: ActionSummary): string[] {\n  return [\n    // Tables added, renamed, or removed in this action.\n    ...summary.tableRenames.map(pair => pair[1] || defunctTableName(pair[0] || \"\")),\n    // Tables modified in this action.\n    ...Object.keys(summary.tableDeltas),\n  ];\n}\n\n/**\n * Given a tableId from after the specified renames, figure out what the tableId was before\n * the renames.  Returns null if table didn't exist.\n */\nexport function getTableIdBefore(renames: LabelDelta[], tableIdAfter: string | null): string | null {\n  if (tableIdAfter === null) { return tableIdAfter; }\n  const rename = renames.find(_rename => _rename[1] === tableIdAfter);\n  return rename ? rename[0] : tableIdAfter;\n}\n\n/**\n * Given a tableId from before the specified renames, figure out what the tableId is after\n * the renames.  Returns null if there is no valid tableId to return.\n */\nexport function getTableIdAfter(renames: LabelDelta[], tableIdBefore: string | null): string | null {\n  if (tableIdBefore === null) { return tableIdBefore; }\n  const rename = renames.find(_rename => _rename[0] === tableIdBefore);\n  const tableIdAfter = rename ? rename[1] : tableIdBefore;\n  if (tableIdAfter?.startsWith(\"-\")) { return null; }\n  return tableIdAfter;\n}\n"
  },
  {
    "path": "app/common/ActivationAPI.ts",
    "content": "import { BaseAPI, IOptions } from \"app/common/BaseAPI\";\nimport { ActivationState } from \"app/common/gristUrls\";\nimport { addCurrentOrgToPath } from \"app/common/urlUtils\";\n\nexport interface ActivationStatus extends ActivationState {\n  // Name of the plan.\n  planName: string | null;\n  // Activation key (only 4 first characters are shown).\n  keyPrefix: string | null;\n}\n\nexport interface ActivationAPI {\n  getActivationStatus(): Promise<ActivationStatus>;\n  activateEnterprise(key: string): Promise<void>;\n}\n\nexport class ActivationAPIImpl extends BaseAPI implements ActivationAPI {\n  constructor(private _homeUrl: string, options: IOptions = {}) {\n    super(options);\n  }\n\n  public async getActivationStatus(): Promise<ActivationStatus> {\n    return this.requestJson(`${this._url}/api/activation/status`, { method: \"GET\" });\n  }\n\n  public async activateEnterprise(key: string): Promise<void> {\n    await this.request(`${this._url}/api/activation/activate`, { method: \"POST\", body: JSON.stringify({ key }) });\n  }\n\n  private get _url(): string {\n    return addCurrentOrgToPath(this._homeUrl);\n  }\n}\n"
  },
  {
    "path": "app/common/ActiveDocAPI.ts",
    "content": "import { ActionGroup } from \"app/common/ActionGroup\";\nimport { AssistanceRequest, AssistanceResponse } from \"app/common/Assistance\";\nimport { BulkAddRecord, CellValue, TableDataAction, UserAction } from \"app/common/DocActions\";\nimport { DocStateComparison } from \"app/common/DocState\";\nimport { PredicateFormulaProperties } from \"app/common/PredicateFormula\";\nimport { FetchUrlOptions, UploadResult } from \"app/common/uploads\";\nimport { PermissionData, Proposal, UserAccessData } from \"app/common/UserAPI\";\nimport { ParseOptions } from \"app/plugin/FileParserAPI\";\nimport { AccessTokenOptions, AccessTokenResult, UIRowId } from \"app/plugin/GristAPI\";\n\nimport { IMessage } from \"grain-rpc\";\n\nexport interface ApplyUAOptions {\n  desc?: string;      // Overrides the description of the action.\n  otherId?: number;   // For undo/redo; the actionNum of the original action to which it applies.\n  linkId?: number;    // For bundled actions, actionNum of the previous action in the bundle.\n  parseStrings?: boolean;  // If true, parses string values in some actions based on the column\n}\n\nexport interface ApplyUAExtendedOptions extends ApplyUAOptions {\n  bestEffort?: boolean; // If set, action may be applied in part if it cannot be applied completely.\n  fromOwnHistory?: boolean; // If set, action is confirmed to be a redo/undo taken from history, from\n  // an action marked as being by the current user.\n  oldestSource?: number;  // If set, gives the timestamp of the oldest source the undo/redo\n  // action was built from, expressed as number of milliseconds\n  // elapsed since January 1, 1970 00:00:00 UTC\n  attachment?: boolean;   // If set, allow actions on attachments.\n}\n\nexport interface ApplyUAResult {\n  actionNum: number;         // number of the action that got recorded.\n  actionHash: string | null; // hash of the action that got recorded.\n  retValues: any[];          // array of return values, one for each of the passed-in user actions.\n  isModification: boolean;   // true if document was modified.\n}\n\nexport interface DataSourceTransformed {\n  // Identifies the upload, which may include multiple files.\n  uploadId: number;\n\n  // For each file in the upload, the transform rules for that file.\n  transforms: TransformRuleMap[];\n}\n\nexport interface TransformRuleMap {\n  [origTableName: string]: TransformRule;\n}\n\n// Special values for import destinations; null means \"new table\", \"\" means skip table.\n// Both special options exposed as consts.\nexport const NEW_TABLE = null;\nexport const SKIP_TABLE = \"\";\n// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents\nexport type DestId = string | typeof NEW_TABLE | typeof SKIP_TABLE;\n\n/**\n * How to import data into an existing table or a new one.\n */\nexport interface TransformRule {\n  /**\n   * The destination table for the transformed data. If null, the data is imported into a new table.\n   */\n  destTableId: DestId;\n  /**\n   * The list of columns to update (existing or new columns).\n   */\n  destCols: TransformColumn[];\n  /**\n   * The list of columns to read from the source table (just the headers name).\n   */\n  sourceCols: string[];\n}\n\n/**\n * Existing or new column to update. It is created based on the temporary table that was imported.\n */\nexport interface TransformColumn {\n  /**\n   * Label of the column to update. For new table it is the same name as the source column.\n   */\n  label: string;\n  /**\n   * Column id to update (null for a new table).\n   */\n  colId: string | null;\n  /**\n   * Type of the column (important for new columns).\n   */\n  type: string;\n  /**\n   * Formula to apply to the target column.\n   */\n  formula: string;\n  /**\n   * Widget options when we need to create a column (copied from the source).\n   */\n  widgetOptions: string;\n}\n\nexport interface ImportParseOptions extends ParseOptions {\n  delimiter?: string;\n  encoding?: string;\n}\n\nexport interface ImportResult {\n  options: ImportParseOptions;\n  tables: ImportTableResult[];\n}\n\nexport interface ImportTableResult {\n  hiddenTableId: string;\n  uploadFileIndex: number;      // Index into upload.files array, for the file responsible for this table.\n  origTableName: string;\n  transformSectionRef: number;\n  destTableId: string | null;\n}\n\nexport interface ImportOptions {\n  parseOptions?: ImportParseOptions;   // Options for parsing the source file.\n  mergeOptionMaps?: MergeOptionsMap[]; // Options for merging fields, indexed by uploadFileIndex.\n}\n\nexport interface MergeOptionsMap {\n  // Map of original GristTable name of imported table to its merge options, if any.\n  [origTableName: string]: MergeOptions | undefined;\n}\n\nexport interface MergeOptions {\n  mergeCols: string[];          // Columns to use as merge keys for incremental imports.\n  mergeStrategy: MergeStrategy; // Determines how matched records should be merged between 2 tables.\n}\n\nexport interface MergeStrategy {\n  type: \"replace-with-nonblank-source\" | \"replace-all-fields\" | \"replace-blank-fields-only\";\n}\n\n/**\n * Represents a query for Grist data. The tableId is required. An empty set of filters indicates\n * the full table. Examples:\n *    {tableId: \"Projects\", filters: {}}\n *    {tableId: \"Employees\", filters: {Status: [\"Active\"], Dept: [\"Sales\", \"HR\"]}}\n */\ninterface BaseQuery {\n  tableId: string;\n  filters: QueryFilters;\n}\n\n/**\n * Query that can only be used on the client side.\n * Allows filtering with more complex operations.\n */\nexport interface ClientQuery extends BaseQuery {\n  operations: {\n    [colId: string]: QueryOperation;\n  };\n}\n\nexport type FilterColValues = Pick<ClientQuery, \"filters\" | \"operations\">;\n\n/**\n * Query intended to be sent to a server.\n */\nexport interface ServerQuery extends BaseQuery {\n  // Queries to server for onDemand tables will set a limit to avoid bringing down the browser.\n  limit?: number;\n\n  // A SQL where clause, for advanced filters. Combines with 'filters' using AND. It is only used\n  // when the query is fetched from the SQLite database, and ignored by the Python data engine,\n  // and is only constructed within server code. It is not safe to let users specify their own\n  // where clause.\n  where?: {\n    clause: string;\n    params: unknown[];      // There should be one parameter for each '?' placeholder in clause.\n  }\n}\n\n/**\n * Type of the filters option to queries.\n */\nexport interface QueryFilters {\n  // TODO: check if \"any\" can be replaced with \"CellValue\".\n  [colId: string]: any[];\n}\n\n// - in: value should be contained in filters array\n// - intersects: value should be a list with some overlap with filters array\n// - empty: value should be falsy (e.g. null) or an empty list, filters is ignored\nexport type QueryOperation = \"in\" | \"intersects\" | \"empty\";\n\n/**\n * Results of fetching a table. Includes the table data you would\n * expect. May now also include attachment metadata referred to in the table\n * data. Attachment data is expressed as a BulkAddRecord, since it is\n * not a complete table, just selected rows. Attachment data is\n * currently included in fetches when (1) granular access control is\n * in effect, and (2) the user is neither an owner nor someone with\n * read access to the entire document, and (3) there is an attachment\n * column in the fetched table. This is exactly what the standard\n * Grist client needs, but in future it might be desirable to give\n * more control over this behavior.\n */\nexport interface TableFetchResult {\n  tableData: TableDataAction;\n  attachments?: BulkAddRecord;\n}\n\n/**\n * Response from useQuerySet(). A query returns data AND creates a subscription to receive\n * DocActions that affect this data. The querySubId field identifies this subscription, and must\n * be used in a disposeQuerySet() call to unsubscribe.\n */\nexport interface QueryResult extends TableFetchResult {\n  querySubId: number;     // ID of the subscription, to use with disposeQuerySet.\n}\n\n/**\n * Result of a fork operation, with newly minted ids.\n * For a document with docId XXXXX and urlId UUUUU, the fork will have a\n * docId of XXXXX~FORKID[~USERID] and a urlId of UUUUU~FORKID[~USERID].\n */\nexport interface ForkResult {\n  forkId: string;\n  docId: string;\n  urlId: string;\n}\n\n/**\n * An extension of PermissionData to cover not just users with whom a document is shared,\n * but also users mentioned in the document (in user attribute tables), and suggested\n * example users. This is for use in the \"View As\" feature of the access rules page.\n */\nexport interface PermissionDataWithExtraUsers extends PermissionData {\n  attributeTableUsers: UserAccessData[];\n  exampleUsers: UserAccessData[];\n}\n\n/**\n * Basic metadata about a table returned by `getAclResources()`.\n */\nexport interface AclTableDescription {\n  title: string;  // Raw data widget title\n  colIds: string[];  // IDs of all columns in table\n  groupByColLabels: string[] | null;  // Labels of groupby columns for summary tables, or null.\n}\n\nexport interface AclResources {\n  tables: { [tableId: string]: AclTableDescription };\n  problems: AclRuleProblem[];\n}\n\nexport interface AclRuleProblem {\n  tables?: {\n    tableIds: string[],\n  };\n  columns?: {\n    tableId: string,\n    colIds: string[],\n  };\n  userAttributes?: {\n    invalidUAColumns: string[],\n    names: string[],\n  }\n  comment: string;\n}\n\nexport function getTableTitle(table: AclTableDescription): string {\n  let { title } = table;\n  if (table.groupByColLabels) {\n    title += \" \" + summaryGroupByDescription(table.groupByColLabels);\n  }\n  return title;\n}\n\nexport function summaryGroupByDescription(groupByColumnLabels: string[]): string {\n  return `[${groupByColumnLabels.length ? \"by \" + groupByColumnLabels.join(\", \") : \"Totals\"}]`;\n}\n\n/// / Types for autocomplete suggestions\n\n// Suggestion may be a string, or a tuple [funcname, argSpec, isGrist], where:\n//  - funcname (e.g. \"DATEADD\") will be auto-completed with \"(\", AND linked to Grist\n//    documentation.\n//  - argSpec (e.g. \"(start_date, days=0, ...)\") is to be shown as autocomplete caption.\n//  - isGrist is no longer used\ntype ISuggestion = string | [string, string, boolean];\n\n// Suggestion paired with an optional example value to show on the right\nexport type ISuggestionWithValue = [ISuggestion, string | null];\n\n/**\n * Share information from a Grist document.\n */\nexport interface ShareInfo {\n  linkId: string;\n  options: string;\n}\n\n/**\n * Share information from the Grist home database.\n */\nexport interface RemoteShareInfo {\n  key: string;\n}\n\n/**\n * Metrics gathered during formula calculations.\n */\nexport interface TimingInfo {\n  /**\n   * Total time spend evaluating a formula.\n   */\n  sum: number;\n  /**\n   * Number of times the formula was evaluated (for all rows).\n   */\n  count: number;\n  average: number;\n  max: number;\n}\n\n/**\n * Metrics attached to a particular column in a table. Contains also marks if they were gathered.\n * Currently we only mark the `OrderError` exception (so when formula calculation was restarted due to\n * order dependency).\n */\nexport interface FormulaTimingInfo extends TimingInfo {\n  tableId: string;\n  colId: string;\n  marks?: (TimingInfo & { name: string })[];\n}\n\n/*\n * Status of timing info collection. Contains intermediate results if engine is not busy at the moment.\n */\nexport interface TimingStatus {\n  /**\n   * If disabled then 'disabled', else 'active' or 'pending'. Pending means that the engine is busy\n   * and can't respond to confirm the status (but it used to be active before that).\n   */\n  status: \"active\" | \"pending\" | \"disabled\";\n  /**\n   * Will be undefined if we can't get the timing info (e.g. if the document is locked by other call).\n   * Otherwise, contains the intermediate results gathered so far.\n   */\n  timing?: FormulaTimingInfo[];\n}\n\n/**\n * Assistant state associated with the document.\n */\nexport interface AssistantState {\n  prompt: string;\n}\n\n/**\n * Details of a user that has the current document open.\n * The exact details shared depend on the user requesting it.\n */\nexport interface VisibleUserProfile {\n  id: string; // An identifier that uniquely identifies this profile / the other user's session.\n  name: string; // Name associated with the user. May be different from their user name, e.g. due to permissions.\n  email?: string;\n  picture?: string | null; // URL of the user's picture with unspecified dimensions.\n  isAnonymous: boolean; // True if the user isn't logged into an account.\n}\n\nexport interface ActiveDocAPI {\n  /**\n   * Closes a document, and unsubscribes from its userAction events.\n   */\n  closeDoc(): Promise<void>;\n\n  /**\n   * Fetches a particular table from the data engine to return to the client.\n   */\n  fetchTable(tableId: string): Promise<TableFetchResult>;\n\n  /**\n   * Fetches the generated Python code for this document.\n   */\n  fetchPythonCode(): Promise<string>;\n\n  /**\n   * Makes a query (documented elsewhere) and subscribes to it, so that the client receives\n   * docActions that affect this query's results. The subscription remains functional even when\n   * tables or columns get renamed.\n   */\n  useQuerySet(query: ServerQuery): Promise<QueryResult>;\n\n  /**\n   * Removes the subscription to a Query, identified by QueryResult.querySubId, so that the\n   * client stops receiving docActions relevant only to that query.\n   */\n  disposeQuerySet(querySubId: number): Promise<void>;\n\n  /**\n   * Applies an array of user actions to the document.\n   */\n  applyUserActions(actions: UserAction[], options?: ApplyUAOptions): Promise<ApplyUAResult>;\n\n  /**\n   * A variant of applyUserActions where actions are passed in by ids (actionNum, actionHash)\n   * rather than by value.\n   */\n  applyUserActionsById(actionNums: number[], actionHashes: string[],\n    undo: boolean, options?: ApplyUAOptions): Promise<ApplyUAResult>;\n\n  /**\n   * Imports files, removes previously created temporary hidden tables and creates the new ones.\n   */\n  importFiles(dataSource: DataSourceTransformed,\n    parseOptions: ImportParseOptions, prevTableIds: string[]): Promise<ImportResult>;\n\n  /**\n   * Finishes import files, creates the new tables, and cleans up temporary hidden tables and uploads.\n   */\n  finishImportFiles(dataSource: DataSourceTransformed, prevTableIds: string[],\n    options: ImportOptions): Promise<ImportResult>;\n\n  /**\n   * Cancels import files, cleans up temporary hidden tables and uploads.\n   */\n  cancelImportFiles(uploadId: number, prevTableIds: string[]): Promise<void>;\n\n  /**\n   * Returns a diff of changes that will be applied to the destination table from `transformRule`\n   * if the data from `hiddenTableId` is imported with the specified `mergeOptions`.\n   */\n  generateImportDiff(hiddenTableId: string, transformRule: TransformRule,\n    mergeOptions: MergeOptions): Promise<DocStateComparison>;\n\n  /**\n   * Saves attachments from a given upload and creates an entry for them in the database. It\n   * returns the list of rowIds for the rows created in the _grist_Attachments table.\n   */\n  addAttachments(uploadId: number): Promise<number[]>;\n\n  /**\n   * Returns up to n columns in the document, or a specific table, which contain the given values.\n   * Columns are returned ordered from best to worst based on an estimate for number of matches.\n   */\n  findColFromValues(values: any[], n: number, optTableId?: string): Promise<number[]>;\n\n  /**\n   * Returns cell value with an error message (traceback) for one invalid formula cell.\n   */\n  getFormulaError(tableId: string, colId: string, rowId: number): Promise<CellValue>;\n\n  /**\n   * Fetch content at a url.\n   */\n  fetchURL(url: string, options?: FetchUrlOptions): Promise<UploadResult>;\n\n  /**\n   * Find and return a list of auto-complete suggestions that start with `txt`, when editing a\n   * formula in table `tableId` and column `columnId`.\n   */\n  autocomplete(txt: string, tableId: string, columnId: string, rowId: UIRowId | null): Promise<ISuggestionWithValue[]>;\n\n  /**\n   * Get recent actions in ActionGroup format with summaries included.\n   */\n  getActionSummaries(): Promise<GetActionSummariesResult>;\n\n  /**\n   *  Initiates user actions bandling for undo.\n   */\n  startBundleUserActions(): Promise<void>;\n\n  /**\n   *  Stopes user actions bandling for undo.\n   */\n  stopBundleUserActions(): Promise<void>;\n\n  /**\n   * Forward a grain-rpc message to a given plugin.\n   */\n  forwardPluginRpc(pluginId: string, msg: IMessage): Promise<any>;\n\n  /**\n   * Reload documents plugins.\n   */\n  reloadPlugins(): Promise<void>;\n\n  /**\n   * Immediately close the document and data engine, to be reloaded from scratch, and cause all\n   * browser clients to reopen it.\n   */\n  reloadDoc(): Promise<void>;\n\n  /**\n   * Prepare a fork of the document, and return the id(s) of the fork.\n   */\n  fork(): Promise<ForkResult>;\n\n  /**\n   * Check if an ACL formula is valid. If not, will throw an error with an explanation.\n   */\n  checkAclFormula(text: string): Promise<PredicateFormulaProperties>;\n\n  /**\n   * Get a token for out-of-band access to the document.\n   */\n  getAccessToken(options: AccessTokenOptions): Promise<AccessTokenResult>;\n\n  /**\n   * Returns the full set of tableIds, with the list of colIds for each table. This is intended\n   * for editing ACLs. It is only available to users who can edit ACLs, and lists all resources\n   * regardless of rules that may block access to them.\n   */\n  getAclResources(): Promise<AclResources>;\n\n  /**\n   * Wait for document to finish initializing.\n   */\n  waitForInitialization(): Promise<void>;\n\n  /**\n   * Get users that are worth proposing to \"View As\" for access control purposes.\n   */\n  getUsersForViewAs(): Promise<PermissionDataWithExtraUsers>;\n\n  /**\n   * Get a share info associated with the document.\n   */\n  getShare(linkId: string): Promise<RemoteShareInfo | null>;\n\n  /**\n   * Starts collecting timing information from formula evaluations.\n   */\n  startTiming(): Promise<void>;\n\n  /**\n   * Stops collecting timing information and returns the collected data.\n   */\n  stopTiming(): Promise<TimingInfo[]>;\n\n  /**\n   * Get assistant state associated with the document.\n   */\n  getAssistantState(id: string): Promise<AssistantState | null>;\n\n  /**\n   * Lists users that currently have the doc open.\n   * This list varies based on the requesting user's permissions.\n   */\n  listActiveUserProfiles(): Promise<VisibleUserProfile[]>;\n\n  applyProposal(proposalId: number, option?: {\n    dismiss?: boolean,\n  }): Promise<ApplyProposalResult>;\n\n  getAssistance(params: AssistanceRequest): Promise<AssistanceResponse>;\n}\n\nexport interface ApplyProposalResult {\n  proposal: Proposal;\n  log: PatchLog;\n}\n\nexport interface PatchLog {\n  changes: PatchItem[];\n  applied: boolean;\n}\n\nexport interface PatchItem {\n  msg: string;\n  fail?: boolean;\n}\n\nexport interface GetActionSummariesResult {\n  actions: ActionGroup[];\n  censored: boolean;\n}\n"
  },
  {
    "path": "app/common/AlternateActions.ts",
    "content": "import { BulkColValues, ColValues, DocAction, isSchemaAction,\n  TableDataAction, UserAction } from \"app/common/DocActions\";\n\nconst ACTION_TYPES = new Set([\n  \"AddRecord\", \"BulkAddRecord\", \"UpdateRecord\", \"BulkUpdateRecord\",\n  \"RemoveRecord\", \"BulkRemoveRecord\",\n]);\n\n/**\n * The result of processing a UserAction.\n */\nexport interface ProcessedAction {\n  stored: DocAction[];\n  undo: DocAction[];\n  retValues: any;\n}\n\n/**\n * A minimal interface for interpreting UserActions in the context of\n * some current state. We need to know the next free rowId for each\n * table, and also the current state of cells. This interface was\n * abstracted from the initial implementation of on-demand tables.\n */\nexport interface AlternateStorage {\n  getNextRowId(tableId: string): Promise<number>;\n  fetchActionData(tableId: string, rowIds: number[],\n    colIds?: string[]): Promise<TableDataAction>;\n}\n\n/**\n * Handle converting UserActions to DocActions for tables stored\n * in some way that is not handled by the regular data engine.\n */\nexport class AlternateActions {\n  constructor(private _storage: AlternateStorage) {}\n\n  /**\n   * This may be overridden to allow mixing two different storage mechanisms.\n   * The implementation of on-demand tables does this.\n   */\n  public usesAlternateStorage(tableId: string): boolean {\n    return true;\n  }\n\n  /**\n   * Convert a UserAction into stored and undo DocActions as well as return values.\n   */\n  public processUserAction(action: UserAction): Promise<ProcessedAction> {\n    const a = action.map(item => item as any);\n    switch (a[0]) {\n      case \"ApplyUndoActions\": return this._doApplyUndoActions(a[1]);\n      case \"AddRecord\":        return this._doAddRecord(a[1], a[2], a[3]);\n      case \"BulkAddRecord\":    return this._doBulkAddRecord(a[1], a[2], a[3]);\n      case \"UpdateRecord\":     return this._doUpdateRecord(a[1], a[2], a[3]);\n      case \"BulkUpdateRecord\": return this._doBulkUpdateRecord(a[1], a[2], a[3]);\n      case \"RemoveRecord\":     return this._doRemoveRecord(a[1], a[2]);\n      case \"BulkRemoveRecord\": return this._doBulkRemoveRecord(a[1], a[2]);\n      default: throw new Error(`Received unknown action ${action[0]}`);\n    }\n  }\n\n  /**\n   * Splits an array of UserActions into two separate arrays of normal and onDemand actions.\n   */\n  public splitByStorage(actions: UserAction[]): [UserAction[], UserAction[]] {\n    const normal: UserAction[] = [];\n    const onDemand: UserAction[] = [];\n    actions.forEach((a) => {\n      // Check that the actionType can be applied without the sandbox and also that the action\n      // is on a data table.\n      const isOnDemandAction = ACTION_TYPES.has(a[0] as string);\n      const isDataTableAction = typeof a[1] === \"string\" && !a[1].startsWith(\"_grist_\");\n      if (a[0] === \"ApplyUndoActions\") {\n        // Split actions inside the undo action array.\n        const [undoNormal, undoOnDemand] = this.splitByStorage(a[1] as UserAction[]);\n        if (undoNormal.length > 0) {\n          normal.push([\"ApplyUndoActions\", undoNormal]);\n        }\n        if (undoOnDemand.length > 0) {\n          onDemand.push([\"ApplyUndoActions\", undoOnDemand]);\n        }\n      } else if (isDataTableAction && isOnDemandAction && this.usesAlternateStorage(a[1] as string)) {\n        // Check whether the tableId belongs to an onDemand table.\n        onDemand.push(a);\n      } else {\n        normal.push(a);\n      }\n    });\n    return [normal, onDemand];\n  }\n\n  /**\n   * Check if an action represents a schema change on an onDemand table.\n   */\n  public isSchemaAction(docAction: DocAction): boolean {\n    return isSchemaAction(docAction) && this.usesAlternateStorage(docAction[1]);\n  }\n\n  private async _doApplyUndoActions(actions: DocAction[]) {\n    const undo: DocAction[] = [];\n    for (const a of actions) {\n      const converted = await this.processUserAction(a);\n      undo.concat(converted.undo);\n    }\n    return {\n      stored: actions,\n      undo,\n      retValues: null,\n    };\n  }\n\n  private async _doAddRecord(\n    tableId: string,\n    rowId: number | null,\n    colValues: ColValues,\n  ): Promise<ProcessedAction> {\n    if (rowId === null) {\n      rowId = await this._storage.getNextRowId(tableId);\n    }\n    // Set the manualSort to be the same as the rowId. This forces new rows to always be added\n    // at the end of the table.\n    colValues.manualSort = rowId;\n    return {\n      stored: [[\"AddRecord\", tableId, rowId, colValues]],\n      undo: [[\"RemoveRecord\", tableId, rowId]],\n      retValues: rowId,\n    };\n  }\n\n  private async _doBulkAddRecord(\n    tableId: string,\n    rowIds: (number | null)[],\n    colValues: BulkColValues,\n  ): Promise<ProcessedAction> {\n    // When unset, we will set the rowId values to count up from the greatest\n    // values already in the table.\n    if (rowIds[0] === null) {\n      const nextRowId = await this._storage.getNextRowId(tableId);\n      for (let i = 0; i < rowIds.length; i++) {\n        rowIds[i] = nextRowId + i;\n      }\n    }\n    // Set the manualSort values to be the same as the rowIds. This forces new rows to always be\n    // added at the end of the table.\n    colValues.manualSort = rowIds;\n    return {\n      stored: [[\"BulkAddRecord\", tableId, rowIds as number[], colValues]],\n      undo: [[\"BulkRemoveRecord\", tableId, rowIds as number[]]],\n      retValues: rowIds,\n    };\n  }\n\n  private async _doUpdateRecord(\n    tableId: string,\n    rowId: number,\n    colValues: ColValues,\n  ): Promise<ProcessedAction> {\n    const [, , oldRowIds, oldColValues] =\n      await this._storage.fetchActionData(tableId, [rowId], Object.keys(colValues));\n    return {\n      stored: [[\"UpdateRecord\", tableId, rowId, colValues]],\n      undo: [[\"BulkUpdateRecord\", tableId, oldRowIds, oldColValues]],\n      retValues: null,\n    };\n  }\n\n  private async _doBulkUpdateRecord(\n    tableId: string,\n    rowIds: number[],\n    colValues: BulkColValues,\n  ): Promise<ProcessedAction> {\n    const [, , oldRowIds, oldColValues] =\n      await this._storage.fetchActionData(tableId, rowIds, Object.keys(colValues));\n    return {\n      stored: [[\"BulkUpdateRecord\", tableId, rowIds, colValues]],\n      undo: [[\"BulkUpdateRecord\", tableId, oldRowIds, oldColValues]],\n      retValues: null,\n    };\n  }\n\n  private async _doRemoveRecord(tableId: string, rowId: number): Promise<ProcessedAction> {\n    const [, , oldRowIds, oldColValues] = await this._storage.fetchActionData(tableId, [rowId]);\n    return {\n      stored: [[\"RemoveRecord\", tableId, rowId]],\n      undo: [[\"BulkAddRecord\", tableId, oldRowIds, oldColValues]],\n      retValues: null,\n    };\n  }\n\n  private async _doBulkRemoveRecord(tableId: string, rowIds: number[]): Promise<ProcessedAction> {\n    const [, , oldRowIds, oldColValues] = await this._storage.fetchActionData(tableId, rowIds);\n    return {\n      stored: [[\"BulkRemoveRecord\", tableId, rowIds]],\n      undo: [[\"BulkAddRecord\", tableId, oldRowIds, oldColValues]],\n      retValues: null,\n    };\n  }\n}\n"
  },
  {
    "path": "app/common/ApiError.ts",
    "content": "/**\n * A tip for fixing an error.\n */\nexport interface ApiTip {\n  action: \"add-members\" | \"upgrade\" | \"ask-for-help\" | \"manage\";\n  message: string;\n}\n\nexport type LimitType = \"collaborators\" | \"docs\" | \"workspaces\" | \"assistant\";\n\n/**\n * Documentation of a limit relevant to an API error.\n */\nexport interface ApiLimit {\n  quantity: LimitType;  // what are we counting\n  subquantity?: string;    // a nuance to what we are counting\n  maximum: number;         // maximum allowed\n  value: number;           // current value of quantity for user\n  projectedValue: number;  // value of quantity expected if request had been allowed\n}\n\n/**\n * Structured details about an API error.\n */\nexport interface ApiErrorDetails {\n  code?: ApiErrorCode;\n\n  limit?: ApiLimit;\n\n  // If set, this is the more user-friendly message to show to the user than error.message.\n  userError?: string;\n\n  // If set, contains suggestions for fixing a problem.\n  tips?: ApiTip[];\n\n  memos?: string[];\n}\n\nexport type ApiErrorCode =\n  | \"UserNotConfirmed\" |\n  \"FormNotFound\" |\n  \"FormNotPublished\" |\n  \"ContextLimitExceeded\";\n\n/**\n * An error with an http status code.\n */\nexport class ApiError extends Error {\n  constructor(message: string, public status: number, public details?: ApiErrorDetails) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "app/common/Assistance.ts",
    "content": "import { ApplyUAResult } from \"app/common/ActiveDocAPI\";\nimport { DocAction } from \"app/common/DocActions\";\n\n/**\n * State related to a request for assistance.\n *\n * If an AssistanceResponse contains state, that state can be\n * echoed back in an AssistanceRequest to continue a \"conversation.\"\n *\n * Ideally, the state should not be modified or relied upon\n * by the client, so as not to commit too hard to a particular\n * model at this time (it is a bit early for that).\n */\nexport interface AssistanceState {\n  messages?: AssistanceMessage[];\n}\n\nexport interface AssistanceMessage {\n  role: \"system\" | \"user\" | \"assistant\" | \"tool\";\n  content?: string | null;\n  tool_call_id?: string;\n}\n\nexport type AssistanceRequest = AssistanceRequestV1 | AssistanceRequestV2;\n\n/**\n * A request for formula assistance.\n */\nexport interface AssistanceRequestV1 extends BaseAssistanceRequest {\n  context: AssistanceContextV1;\n}\n\n/**\n * A request for document assistance.\n */\nexport interface AssistanceRequestV2 extends BaseAssistanceRequest {\n  context: AssistanceContextV2;\n  developerPromptVersion?: DeveloperPromptVersion;\n}\n\nexport function isAssistanceRequestV2(req: AssistanceRequest): req is AssistanceRequestV2 {\n  return !(\"tableId\" in req.context);\n}\n\nexport type DeveloperPromptVersion = \"default\" | \"new-document\";\n\ninterface BaseAssistanceRequest {\n  conversationId: string;\n  text?: string;\n  state?: AssistanceState;\n}\n\n/**\n * Currently, requests for formula assistance always happen in the context\n * of the column of a particular table.\n */\nexport interface AssistanceContextV1 {\n  tableId: string;\n  colId: string;\n  evaluateCurrentFormula?: boolean;\n  rowId?: number;\n}\n\nexport interface AssistanceContextV2 {\n  viewId?: number;\n}\n\nexport type AssistanceResponse = AssistanceResponseV1 | AssistanceResponseV2;\n\nexport interface AssistanceResponseV1 extends BaseAssistanceResponse {\n  suggestedActions: DocAction[];\n  suggestedFormula?: string;\n}\n\nexport interface AssistanceResponseV2 extends BaseAssistanceResponse {\n  appliedActions?: ApplyUAResult[];\n  confirmationRequired?: boolean;\n}\n\n/**\n * A response to a request for assistance.\n * The client should preserve the state and include it in\n * any follow-up requests.\n */\ninterface BaseAssistanceResponse {\n  /**\n   * If the model can be trusted to issue a self-contained\n   * markdown-friendly string, it can be included here.\n   */\n  reply?: string;\n  state?: AssistanceState;\n  limit?: AssistanceLimit;\n}\n\ninterface AssistanceLimit {\n  usage: number;\n  limit: number;\n}\n"
  },
  {
    "path": "app/common/Assistant.ts",
    "content": "export interface AssistantConfig {\n  provider: AssistantProvider;\n  version: AssistantVersion;\n}\n\nexport type AssistantProvider = \"OpenAI\" | \"Unknown\" | null;\n\nexport type AssistantVersion = 1 | 2;\n"
  },
  {
    "path": "app/common/AsyncCreate.ts",
    "content": "/**\n * Implements a pattern for creating objects requiring asynchronous construction. The given\n * asynchronous createFunc() is called on the .get() call, and the result is cached on success.\n * On failure, the result is cleared, so that subsequent calls attempt the creation again.\n *\n * Usage:\n *  this._obj = new AsyncCreate<MyObject>(asyncCreateFunc);\n *  obj = await this._obj.get();    // calls asyncCreateFunc\n *  obj = await this._obj.get();    // uses cached object if asyncCreateFunc succeeded, else calls it again.\n *\n * Note that multiple calls while createFunc() is running will return the same promise, and will\n * succeed or fail together.\n */\nexport class AsyncCreate<T> {\n  private _value?: Promise<T> = undefined;\n\n  constructor(private _createFunc: () => Promise<T>) {}\n\n  /**\n   * Returns createFunc() result, returning the cached promise if createFunc() succeeded, or if\n   * another call to it is currently pending.\n   */\n  public get(): Promise<T> {\n    return this._value || (this._value = this._clearOnError(this._createFunc.call(null)));\n  }\n\n  /** Clears the cached promise, forcing createFunc to be called again on next get(). */\n  public clear(): void {\n    this._value = undefined;\n  }\n\n  /** Returns a boolean indicating whether the object is created. */\n  public isSet(): boolean {\n    return Boolean(this._value);\n  }\n\n  /** Returns the value if it's set and successful, or undefined otherwise. */\n  public async getIfValid(): Promise<T | undefined> {\n    return this._value ? this._value.catch(() => undefined) : undefined;\n  }\n\n  // Helper which clears this AsyncCreate if the given promise is rejected.\n  private _clearOnError(p: Promise<T>): Promise<T> {\n    p.catch(() => this.clear());\n    return p;\n  }\n}\n\n/**\n * A simpler version of AsyncCreate: given an async function f, returns another function that will\n * call f once, and cache and return its value. On failure the result is cleared, so that\n * subsequent calls will attempt calling f again.\n */\nexport function asyncOnce<T>(createFunc: () => Promise<T>): () => Promise<T> {\n  let value: Promise<T> | undefined;\n  function clearOnError(p: Promise<T>): Promise<T> {\n    p.catch(() => { value = undefined; });\n    return p;\n  }\n  return () => (value || (value = clearOnError(createFunc.call(null))));\n}\n\n/**\n * Supports a usage similar to AsyncCreate in a Map. Returns map.get(key) if it is set to a\n * resolved or pending promise. Otherwise, calls creator(key) to create and return a new promise,\n * and sets the key to it. If the new promise is rejected, the key will be removed from the map,\n * so that subsequent calls would call creator() again.\n *\n * As with AsyncCreate, while the promise for a key is pending, multiple calls to that key will\n * return the same promise, and will succeed or fail together.\n */\nexport function mapGetOrSet<K, V>(map: Map<K, Promise<V>>, key: K, creator: (key: K) => Promise<V>): Promise<V> {\n  return map.get(key) || mapSetOrClear(map, key, creator(key));\n}\n\n/**\n * Supports a usage similar to AsyncCreate in a Map. Sets the given key in a map to the given\n * promise, and removes it later if the promise is rejected. Returns the same promise.\n */\nexport function mapSetOrClear<K, V>(map: Map<K, Promise<V>>, key: K, pvalue: Promise<V>): Promise<V> {\n  pvalue.catch(() => map.delete(key));\n  map.set(key, pvalue);\n  return pvalue;\n}\n\n/**\n * A Map implementation that allows for expiration of old values.\n */\nexport class MapWithTTL<K, V> extends Map<K, V> {\n  private _timeouts = new Map<K, NodeJS.Timeout>();\n\n  /**\n   * Create a map with keys that will be automatically deleted _ttlMs\n   * milliseconds after they have been last set.  Precision of timing\n   * may vary.\n   */\n  constructor(private _ttlMs: number) {\n    super();\n  }\n\n  /**\n   * Set a key, with expiration.\n   */\n  public set(key: K, value: V): this {\n    return this.setWithCustomTTL(key, value, this._ttlMs);\n  }\n\n  /**\n   * Set a key, with custom expiration.\n   */\n  public setWithCustomTTL(key: K, value: V, ttlMs: number): this {\n    const curr = this._timeouts.get(key);\n    if (curr) { clearTimeout(curr); }\n    super.set(key, value);\n    this._timeouts.set(key, setTimeout(this.expire.bind(this, key), ttlMs));\n    return this;\n  }\n\n  /**\n   * By default it simply deletes the key from the map, returning true if the element existed.\n   * It's a separate method to allow overriding it.\n   */\n  public expire(key: K): boolean {\n    return this.delete(key);\n  }\n\n  /**\n   * Remove a key.\n   */\n  public delete(key: K): boolean {\n    const result = super.delete(key);\n    const timeout = this._timeouts.get(key);\n    if (timeout) {\n      clearTimeout(timeout);\n      this._timeouts.delete(key);\n    }\n    return result;\n  }\n\n  /**\n   * Forcibly expire everything.\n   */\n  public clear(): void {\n    for (const timeout of this._timeouts.values()) {\n      clearTimeout(timeout);\n    }\n    this._timeouts.clear();\n    super.clear();\n  }\n}\n\n/**\n * Just like MapWithTTL, but supports an extra callback to call when a value expires.\n */\nexport class MapWithCustomExpire<K, V> extends MapWithTTL<K, V> {\n  constructor(ttlMs: number, private _onExpire: (key: K) => void) {\n    super(ttlMs);\n  }\n\n  public override expire(key: K): boolean {\n    this._onExpire(key);\n    return super.expire(key);\n  }\n}\n\n/**\n * Sometimes it is desirable to cache either fulfilled or rejected\n * outcomes.  This method wraps a promise so that it never throws.\n * The result has an unfreeze method which, when called, is either\n * fulfilled or rejected.\n */\nexport async function freezeError<T>(promise: Promise<T>): Promise<ErrorOrValue<T>> {\n  try {\n    const value = await promise;\n    return { unfreeze: async () => value };\n  } catch (error) {\n    return { unfreeze: async () => { throw error; } };\n  }\n}\n\nexport interface ErrorOrValue<T> {\n  unfreeze(): Promise<T>;\n}\n"
  },
  {
    "path": "app/common/AsyncFlow.ts",
    "content": "/**\n * This module is a helper for asynchronous work. It allows resources acquired asynchronously to\n * be conveniently and reliably released.\n *\n * Usage:\n * (1) Implement a function `myFunc(flow: AsyncFlow)`. The `flow` argument provides some helpers:\n *\n *      // Create a disposable, making it owned by the flow. It will be disposed when the flow\n *      // ends, whether successfully, on error, or by being cancelled.\n *      const foo = Foo.create(flow, ...);\n *\n *      // As with Disposables in general, schedule a callback to be called when the flow ends.\n *      flow.onDispose(...);\n *\n *      // Release foo from the flow's ownership, and give its ownership to another object. This way\n *      // `other` will be responsible for disposing foo, and not flow.\n *      other.autoDispose(flow.release(foo))\n *\n *      // Abort the flow (by throwing CancelledError) if cancellation is requested. This should\n *      // be called after async work, in case the flow shouldn't be continued.\n *      checkIfCancelled();\n *\n * (2) Call `runner = FlowRunner.create(owner, myFunc)`. The flow will start. Once myFunc's\n *     promise resolves (including on failure), the objects owned by the flow will be disposed.\n *\n *     The runner exposes the promise for when the flow ends as `runner.resultPromise`.\n *\n *     If the runner itself is disposed, the flow will be cancelled, and disposed once it notices\n *     the cancellation.\n *\n * To replace one FlowRunner with another, put it in a grainjs Holder.\n */\nimport { Disposable, IDisposable } from \"grainjs\";\n\ntype DisposeListener = ReturnType<Disposable[\"onDispose\"]>;\n\nexport class CancelledError extends Error {}\n\nexport class FlowRunner extends Disposable {\n  public resultPromise: Promise<void>;\n\n  constructor(func: (flow: AsyncFlow) => Promise<void>) {\n    super();\n    const flow = AsyncFlow.create(null);\n    async function runFlow() {\n      try {\n        return await func(flow);\n      } finally {\n        flow.dispose();\n      }\n    }\n    this.resultPromise = runFlow();\n    this.onDispose(flow.cancel, flow);\n  }\n}\n\nexport class AsyncFlow extends Disposable {\n  private _handles = new Map<IDisposable, DisposeListener>();\n  private _isCancelled = false;\n\n  public autoDispose<T extends IDisposable>(obj: T): T {\n    const lis = this.onDispose(obj.dispose, obj);\n    this._handles.set(obj, lis);\n    return obj;\n  }\n\n  public release<T extends IDisposable>(obj: T): T {\n    const h = this._handles.get(obj);\n    if (h) { h.dispose(); }\n    this._handles.delete(obj);\n    return obj;\n  }\n\n  public checkIfCancelled() {\n    if (this._isCancelled) {\n      throw new CancelledError(\"cancelled\");\n    }\n  }\n\n  public cancel() {\n    this._isCancelled = true;\n  }\n}\n"
  },
  {
    "path": "app/common/AttachmentColumns.ts",
    "content": "import { AddRecord, BulkAddRecord, BulkRemoveRecord, BulkUpdateRecord,\n  getColIdsFromDocAction, getColValuesFromDocAction,\n  getTableId, RemoveRecord, ReplaceTableData, TableDataAction,\n  UpdateRecord } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { isNumber } from \"app/common/gutil\";\n\n/**\n * Represent current attachment columns as a map from tableId to a set of\n * colIds.\n */\nexport type AttachmentColumns = Map<string, Set<string>>;\n\n/**\n * Enumerate attachment columns, represented as a map from tableId to\n * a set of colIds.\n */\nexport function getAttachmentColumns(metaDocData: DocData): AttachmentColumns {\n  const tablesTable = metaDocData.getMetaTable(\"_grist_Tables\");\n  const columnsTable = metaDocData.getMetaTable(\"_grist_Tables_column\");\n  const attachmentColumns = new Map<string, Set<string>>();\n  for (const column of columnsTable.filterRecords({ type: \"Attachments\" })) {\n    const table = tablesTable.getRecord(column.parentId);\n    const tableId = table?.tableId;\n    if (!tableId) {\n      /* should never happen */\n      throw new Error(\"table not found: \" + column.parentId);\n    }\n    if (!attachmentColumns.has(tableId)) {\n      attachmentColumns.set(tableId, new Set());\n    }\n    attachmentColumns.get(tableId)!.add(column.colId);\n  }\n  return attachmentColumns;\n}\n\n/**\n * Get IDs of attachments that are present in attachment columns in an action.\n */\nexport function gatherAttachmentIds(\n  attachmentColumns: AttachmentColumns,\n  action: AddRecord | BulkAddRecord | UpdateRecord | BulkUpdateRecord |\n    RemoveRecord | BulkRemoveRecord | ReplaceTableData | TableDataAction,\n): Set<number> {\n  const tableId = getTableId(action);\n  const attColumns = attachmentColumns.get(tableId);\n  const colIds = getColIdsFromDocAction(action) || [];\n  const attIds = new Set<number>();\n  if (!attColumns || !colIds.some(colId => attColumns.has(colId))) {\n    return attIds;\n  }\n  for (const colId of colIds) {\n    if (!attColumns.has(colId)) { continue; }\n    const values = getColValuesFromDocAction(action, colId);\n    if (!values) { continue; }\n    for (const v of values) {\n      // We expect an array. What should we do with other types?\n      // If we were confident no part of Grist would interpret non-array\n      // values as attachment ids, then we should let them be added, as\n      // part of Grist's spreadsheet-style willingness to allow invalid\n      // data. I decided to go ahead and require that numbers or number-like\n      // strings should be checked as if they were attachment ids, just in\n      // case. But if this proves awkward for someone, it could be reasonable\n      // to only check ids in an array after confirming Grist is strict in\n      // how it interprets material in attachment cells.\n      if (typeof v === \"number\") {\n        attIds.add(v);\n      } else if (Array.isArray(v)) {\n        for (const p of v) {\n          if (typeof p === \"number\") {\n            attIds.add(p);\n          }\n        }\n      } else if (typeof v === \"boolean\" || v === null) {\n        // Nothing obvious to do here.\n      } else if (isNumber(v)) {\n        attIds.add(Math.round(parseFloat(v)));\n      }\n    }\n  }\n  return attIds;\n}\n"
  },
  {
    "path": "app/common/BaseAPI.ts",
    "content": "import { ApiError, ApiErrorDetails } from \"app/common/ApiError\";\nimport { tbind } from \"app/common/tbind\";\n\nimport axios, { AxiosRequestConfig, AxiosResponse } from \"axios\";\n\nexport interface IOptions {\n  headers?: Record<string, string>;\n  fetch?: typeof fetch;\n  newFormData?: () => FormData;  // constructor for FormData depends on platform.\n  extraParameters?: Map<string, string>;  // if set, add query parameters to requests.\n}\n\n/**\n * Base setup class for creating a REST API client interface.\n */\nexport class BaseAPI {\n  // Count of pending requests. It is relied on by tests.\n  public static numPendingRequests(): number { return this._numPendingRequests; }\n\n  // Wrap a promise to add to the count of pending requests until the promise is resolved.\n  public static async countPendingRequest<T>(promise: Promise<T>): Promise<T> {\n    try {\n      BaseAPI._numPendingRequests++;\n      return await promise;\n    } finally {\n      BaseAPI._numPendingRequests--;\n    }\n  }\n\n  // Define a decorator for methods in BaseAPI or derived classes.\n  public static countRequest(target: unknown, propertyKey: string, descriptor: PropertyDescriptor) {\n    const originalMethod = descriptor.value;\n    descriptor.value = async function(...args: any[]) {\n      return BaseAPI.countPendingRequest(originalMethod.apply(this, args));\n    };\n  }\n\n  // Make a JSON request to the given URL, and read the response as JSON. Handles errors, and\n  // counts pending requests in the same way as BaseAPI methods do.\n  public static requestJson(url: string, init: RequestInit = {}): Promise<unknown> {\n    return new BaseAPI().requestJson(url, init);\n  }\n\n  // Make a request to the given URL, and read the response. Handles errors, and\n  // counts pending requests in the same way as BaseAPI methods do.\n  public static request(url: string, init: RequestInit = {}): Promise<Response> {\n    return new BaseAPI().request(url, init);\n  }\n\n  private static _numPendingRequests: number = 0;\n\n  protected fetch: typeof fetch;\n  protected newFormData: () => FormData;\n  private _headers: Record<string, string>;\n  private _extraParameters?: Map<string, string>;\n\n  constructor(public readonly options: IOptions = {}) {\n    this.fetch = options.fetch || tbind(window.fetch, window);\n    this.newFormData = options.newFormData || (() => new FormData());\n    this._headers = {\n      \"Content-Type\": \"application/json\",\n      \"X-Requested-With\": \"XMLHttpRequest\",\n      ...options.headers,\n    };\n    // If we are in the client, and have a boot key query parameter,\n    // pass it on as a header to make it available for authentication.\n    // This is a fallback mechanism if auth is broken to access the\n    // admin panel.\n    // TODO: should this be more selective?\n    if (typeof window !== \"undefined\" &&\n      window.location?.pathname.endsWith(\"/admin\")) {\n      const bootKey = new URLSearchParams(window.location.search).get(\"boot-key\");\n      if (bootKey) {\n        this._headers[\"X-Boot-Key\"] = bootKey;\n      }\n    }\n    this._extraParameters = options.extraParameters;\n  }\n\n  // Make a modified request, exposed for test convenience.\n  public async testRequest(url: string, init: RequestInit = {}): Promise<Response> {\n    return this.request(url, init);\n  }\n\n  public defaultHeaders() {\n    return this._headers;\n  }\n\n  public defaultHeadersWithoutContentType() {\n    const headers = { ...this.defaultHeaders() };\n    delete headers[\"Content-Type\"];\n    return headers;\n  }\n\n  // Similar to request, but uses the axios library, and supports progress indicator.\n  @BaseAPI.countRequest\n  protected async requestAxios(url: string, config: AxiosRequestConfig): Promise<AxiosResponse> {\n    // If using with FormData in node, axios needs the headers prepared by FormData.\n    let headers = config.headers;\n    if (config.data && typeof config.data.getHeaders === \"function\") {\n      headers = { ...config.data.getHeaders(), ...headers };\n    }\n    const resp = await axios.request({\n      url,\n      withCredentials: true,\n      validateStatus: status => true,     // This is more like fetch\n      ...config,\n      headers,\n    });\n    if (resp.status !== 200) {\n      throwApiError(url, resp, resp.data);\n    }\n    return resp;\n  }\n\n  @BaseAPI.countRequest\n  protected async request(input: string, init: RequestInit = {}): Promise<Response> {\n    init = Object.assign({ headers: this._headers, credentials: \"include\" }, init);\n    if (this._extraParameters) {\n      const url = new URL(input);\n      for (const [key, val] of this._extraParameters.entries()) {\n        url.searchParams.set(key, val);\n        input = url.href;\n      }\n    }\n    const resp = await this.fetch(input, init);\n    if (!resp.ok) {\n      const body = await resp.json().catch(() => ({}));\n      throwApiError(input, resp, body);\n    }\n    return resp;\n  }\n\n  /**\n   * Make a request, and read the response as JSON. This allows counting the request as pending\n   * until it has been read, which is relied on by tests.\n   */\n  @BaseAPI.countRequest\n  protected async requestJson(input: string, init: RequestInit = {}): Promise<any> {\n    return (await this.request(input, init)).json();\n  }\n}\n\nfunction throwApiError(url: string, resp: Response | AxiosResponse, body: any) {\n  // If the response includes details, include them into the ApiError we construct. Include\n  // also the error message from the server as details.userError. It's used by the Notifier.\n  if (!body) { body = {}; }\n  const details: ApiErrorDetails = body.details && typeof body.details === \"object\" ? body.details :\n    { errorDetails: body.details };\n  // If a userError is already specified, do not overwrite it.\n  // (The error handling here is quite confusing, would it not be better\n  // to just unserialize an ApiError into the form it would have had on\n  // the server?)\n  if (body.error && !details.userError) {\n    details.userError = body.error;\n  }\n  if (body.memos) {\n    details.memos = body.memos;\n  }\n  throw new ApiError(`Request to ${url} failed with status ${resp.status}: ` +\n    `${resp.statusText} (${body.error || \"unknown cause\"})`, resp.status, details);\n}\n"
  },
  {
    "path": "app/common/BasketClientAPI.ts",
    "content": "export interface BasketClientAPI {\n  /**\n   * Returns an array of all tableIds in this basket.\n   */\n  getBasketTables(): Promise<string[]>;\n\n  /**\n   * Adds, updates or deletes a table's data to/from Grist Basket.\n   */\n  embedTable(tableId: string, action: \"add\" | \"update\" | \"delete\"): Promise<void>;\n}\n"
  },
  {
    "path": "app/common/BigInt.ts",
    "content": "/**\n * A minimal library to represent arbitrarily large integers. Unlike the many third party\n * libraries, which are big, this only implements a representation and conversion to string (such\n * as base 10 or base 16), so it's tiny in comparison.\n *\n * Big integers\n *    base: number - the base for the digits\n *    digits: number[] - digits, from least significant to most significant, in [0, base) range.\n *    sign: number - 1 or -1\n */\nexport class BigInt {\n  constructor(\n    private _base: number,      // Base for the digits\n    private _digits: number[],  // Digits from least to most significant, in [0, base) range.\n    private _sign: number,      // +1 or -1\n  ) {}\n\n  public copy() { return new BigInt(this._base, this._digits, this._sign); }\n\n  /** Convert to Number if there is no loss of precision, or string (base 10) otherwise. */\n  public toNative(): number | string {\n    const num = this.toNumber();\n    return Number.isSafeInteger(num) ? num : this.toString(10);\n  }\n\n  /** Convert to Number as best we can. This will lose precision beying 53 bits. */\n  public toNumber(): number {\n    let res = 0;\n    let baseFactor = 1;\n    for (const digit of this._digits) {\n      res += digit * baseFactor;\n      baseFactor *= this._base;\n    }\n    return res * (this._sign < 0 ? -1 : 1);\n  }\n\n  /** Like Number.toString(). Radix (or base) is an integer between 2 and 36, defaulting to 10. */\n  public toString(radix: number = 10): string {\n    const copy = this.copy();\n    const decimals = [];\n    while (copy._digits.length > 0) {\n      decimals.push(copy._mod(radix).toString(radix));\n      copy._divide(radix);\n    }\n    if (decimals.length === 0) { return \"0\"; }\n    return (this._sign < 0 ? \"-\" : \"\") + decimals.reverse().join(\"\");\n  }\n\n  /** Returns the remainder when this number is divided by divisor. */\n  private _mod(divisor: number): number {\n    let res = 0;\n    let baseFactor = 1;\n    for (const digit of this._digits) {\n      res = (res + (digit % divisor) * baseFactor) % divisor;\n      baseFactor = (baseFactor * this._base) % divisor;\n    }\n    return res;\n  }\n\n  /** Divides this number in-place. */\n  private _divide(divisor: number): void {\n    if (this._digits.length === 0) { return; }\n    for (let i = this._digits.length - 1; i > 0; i--) {\n      this._digits[i - 1] += (this._digits[i] % divisor) * this._base;\n      this._digits[i] = Math.floor(this._digits[i] / divisor);\n    }\n    this._digits[0] = Math.floor(this._digits[0] / divisor);\n    while (this._digits.length > 0 && this._digits[this._digits.length - 1] === 0) {\n      this._digits.pop();\n    }\n  }\n}\n"
  },
  {
    "path": "app/common/BillingAPI.ts",
    "content": "import { BaseAPI, IOptions } from \"app/common/BaseAPI\";\nimport { TEAM_FREE_PLAN } from \"app/common/Features\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { addCurrentOrgToPath } from \"app/common/urlUtils\";\nimport { BillingAccount, ManagerDelta, OrganizationWithoutAccessInfo } from \"app/common/UserAPI\";\n\nexport const BillingSubPage = StringUnion(\"payment\", \"scheduled\");\nexport type BillingSubPage = typeof BillingSubPage.type;\n\nexport const BillingPage = StringUnion(...BillingSubPage.values, \"billing\");\nexport type BillingPage = typeof BillingPage.type;\n\n// updateDomain - it is a subpage for billing page, to update domain name.\n// The rest are for payment page:\n// signUpLite - it is a subpage for payment, to finalize (complete) signup process\n// and set domain and team name when they are not set yet (currently only from landing pages).\n// signUp - it is landing page for new team sites (it doesn't ask for the name of the team)\nexport const BillingTask = StringUnion(\"signUpLite\", \"updateDomain\", \"signUp\", \"cancelPlan\", \"upgraded\");\nexport type BillingTask = typeof BillingTask.type;\n\n// Note that IBillingPlan includes selected fields from the Stripe plan object along with\n// custom metadata fields that are present on plans we store in Stripe.\n// For reference: https://stripe.com/docs/api/plans/object\nexport interface IBillingPlan {\n  id: string;                 // the Stripe plan id\n  nickname: string;\n  interval: \"day\" | \"week\" | \"month\" | \"year\";           // billing frequency - one of day, week, month or year\n  // Merged metadata from price and product.\n  metadata: {\n    family?: string;          // groups plans for filtering by GRIST_STRIPE_FAMILY env variable\n    isStandard: boolean;      // indicates that the plan should be returned by the API to be offered.\n    gristProduct: string;     // name of grist product that should be used with this plan.\n    type: string;             // type of the plan (either plan or limit for now)\n    minimumUnits?: number;    // minimum number of units for the plan\n    gristLimit?: string;      // type of the limit (for limit type plans)\n  };\n  amount: number;             // amount in cents charged at each interval\n  trialPeriodDays: number | null;  // Number of days in the trial period, or null if there is none.\n  product: string;         // the Stripe product id.\n  features: string[];       // list of features that are available with this plan\n  active: boolean;\n  name: string;                    // the name of the product\n}\n\nexport interface ILimitTier {\n  name?: string;\n  volume: number;\n  price: number;\n  flatFee: number;\n  type: string;\n  planId: string;\n  interval: string; // probably 'month'|'year';\n}\n\n// Utility type that requires all properties to be non-nullish.\n// type NonNullableProperties<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };\n\n// Stripe promotion code and coupon information. Used by client to apply signup discounts.\n// For reference: https://stripe.com/docs/api/promotion_codes/object#promotion_code_object-coupon\nexport interface IBillingCoupon {\n  id: string;\n  promotion_code: string;\n  name: string | null;\n  percent_off: number | null;\n  amount_off: number | null;\n}\n\n// Stripe subscription discount information.\n// For reference: https://stripe.com/docs/api/discounts/object\nexport interface IBillingDiscount {\n  name: string | null;\n  percent_off: number | null;\n  amount_off: number | null;\n  end_timestamp_ms: number | null;\n}\n\nexport interface IBillingSubscription {\n  // All standard plan options.\n  plans: IBillingPlan[];\n  tiers: ILimitTier[];\n  // Index in the plans array of the plan currently in effect.\n  planIndex: number;\n  // Index in the plans array of the plan to be in effect after the current period end.\n  // Equal to the planIndex when the plan has not been downgraded or cancelled.\n  upcomingPlanIndex: number;\n  // Timestamp in milliseconds indicating when the current plan period ends.\n  // Null if the account is not signed up with Stripe.\n  periodEnd: number | null;\n  // Whether the subscription is in the trial period.\n  isInTrial: boolean;\n  // Value in cents remaining for the current subscription. This indicates the amount that\n  // will be discounted from a subscription upgrade.\n  valueRemaining: number;\n  // The effective tax rate of the customer for the given address.\n  taxRate: number;\n  // The current number of seats paid for current billing period.\n  seatCount: number;\n  // The current number of users with whom the paid org is shared.\n  userCount: number;\n  // The next total in cents that Stripe is going to charge (includes tax and discount).\n  nextTotal: number;\n  // The next due date in milliseconds.\n  nextDueDate: number | null; // in milliseconds\n  // Discount information, if any.\n  discount: IBillingDiscount | null;\n  // Last plan we had a subscription for, if any.\n  lastPlanId: string | null;\n  // Whether there is a valid plan in effect.\n  isValidPlan: boolean;\n  // The time when the plan will be cancelled. (Not set when we are switching to a free plan)\n  cancelAt: number | null;\n  // A flag for when all is well with the user's subscription.\n  inGoodStanding: boolean;\n  // Whether there is a paying valid account (even on free plan). It this is set\n  // user needs to upgrade the plan using Stripe Customer portal. In not, we need to\n  // go though checkout process.\n  activeSubscription: boolean;\n  // Whether the plan is billable. Billable plans must be in Stripe.\n  billable: boolean;\n  // Whether we are waiting for upgrade to complete.\n  upgradingPlanIndex: number;\n\n  // Stripe status, documented at https://stripe.com/docs/api/subscriptions/object#subscription_object-status\n  // such as \"active\", \"trialing\" (reflected in isInTrial), \"incomplete\", etc.\n  status?: string;\n  lastInvoiceUrl?: string;     // URL of the Stripe-hosted page with the last invoice.\n  lastInvoiceOpen?: boolean;   // Whether the last invoice is not paid but it can be.\n  lastChargeError?: string;    // The last charge error, if any, to show in case of a bad status.\n  lastChargeTime?: number;     // The time of the last charge attempt.\n  limit?: ILimit | null;\n  balance?: number;            // The balance of the account.\n\n  // Current product name. Even if not paid or not in good standing.\n  currentProductName?: string;\n\n  paymentLink?: string;       // A link to the payment page for the current plan.\n  paymentOffer?: string;      // Optional text to show for the offer.\n  paymentProduct?: string;    // The product to show for the offer.\n}\n\nexport interface ILimit {\n  limitValue: number;\n  currentUsage: number;\n  type: string; // Limit type, for now only assistant is supported.\n  price: number; // If this is 0, it means it is a free plan.\n}\n\nexport interface IBillingOrgSettings {\n  name: string;\n  domain: string | null;\n  customLogoUrl?: string | null;\n}\n\n// Full description of billing account, including nested list of orgs and managers.\nexport interface FullBillingAccount extends BillingAccount {\n  orgs: OrganizationWithoutAccessInfo[];\n  managers: FullUser[];\n}\n\nexport interface SummaryLine {\n  description: string;\n  quantity?: number | null;\n  amount: number;\n}\n\n// Info to show to the user when he changes the plan.\nexport interface ChangeSummary {\n  productName: string,\n  priceId: string,\n  interval: string,\n  quantity: number,\n  type: \"upgrade\" | \"downgrade\",\n  regular: {\n    lines: SummaryLine[];\n    subTotal: number;\n    tax?: number;\n    total: number;\n    periodStart: number;\n  },\n  invoice?: {\n    lines: SummaryLine[];\n    subTotal: number;\n    tax?: number;\n    total: number;\n    appliedBalance: number;\n    amountDue: number;\n    dueDate: number;\n  }\n}\n\nexport type UpgradeConfirmation = ChangeSummary | { checkoutUrl: string };\n\nexport interface PlanSelection {\n  product?: string; // grist product name\n  priceId?: string; // stripe id of the price\n  offerId?: string; // stripe id of the offer\n  count?: number;   // number of units for the plan (suggested as it might be different).\n}\n\nexport interface BillingAPI {\n  isDomainAvailable(domain: string): Promise<boolean>;\n  getPlans(plan?: PlanSelection): Promise<IBillingPlan[]>;\n  getSubscription(): Promise<IBillingSubscription>;\n  getBillingAccount(): Promise<FullBillingAccount>;\n  updateBillingManagers(delta: ManagerDelta): Promise<void>;\n  updateSettings(settings: Partial<IBillingOrgSettings>): Promise<void>;\n  subscriptionStatus(planId: string): Promise<boolean>;\n  createFreeTeam(name: string, domain: string): Promise<void>;\n  createTeam(name: string, domain: string, plan: PlanSelection, next?: string): Promise<{\n    checkoutUrl?: string,\n    orgUrl?: string,\n  }>;\n  confirmChange(plan: PlanSelection): Promise<UpgradeConfirmation>;\n  changePlan(plan: PlanSelection): Promise<void>;\n  renewPlan(plan: PlanSelection): Promise<{ checkoutUrl: string }>;\n  cancelCurrentPlan(): Promise<void>;\n  customerPortal(): string;\n  updateAssistantPlan(tier: number): Promise<void>;\n\n  changeProduct(product: string): Promise<void>;\n  attachSubscription(subscription: string): Promise<void>;\n  attachPayment(paymentLink: string): Promise<void>;\n  getPaymentLink(): Promise<UpgradeConfirmation>;\n  cancelPlanChange(): Promise<void>;\n  dontCancelPlan(): Promise<void>;\n}\n\nexport class BillingAPIImpl extends BaseAPI implements BillingAPI {\n  constructor(private _homeUrl: string, options: IOptions = {}) {\n    super(options);\n  }\n\n  public async isDomainAvailable(domain: string): Promise<boolean> {\n    return this.requestJson(`${this._url}/api/billing/domain`, {\n      method: \"POST\",\n      body: JSON.stringify({ domain }),\n    });\n  }\n\n  public async getPlans(plan?: PlanSelection): Promise<IBillingPlan[]> {\n    const url = new URL(`${this._url}/api/billing/plans`);\n    url.searchParams.set(\"product\", plan?.product || \"\");\n    url.searchParams.set(\"priceId\", plan?.priceId || \"\");\n    return this.requestJson(url.href, {\n      method: \"GET\",\n    });\n  }\n\n  // Returns an IBillingSubscription\n  public async getSubscription(): Promise<IBillingSubscription> {\n    return this.requestJson(`${this._url}/api/billing/subscription`, { method: \"GET\" });\n  }\n\n  public async getBillingAccount(): Promise<FullBillingAccount> {\n    return this.requestJson(`${this._url}/api/billing`, { method: \"GET\" });\n  }\n\n  public async cancelCurrentPlan() {\n    await this.request(`${this._url}/api/billing/cancel-plan`, {\n      method: \"POST\",\n    });\n  }\n\n  public async updateSettings(settings?: Partial<IBillingOrgSettings>): Promise<void> {\n    await this.request(`${this._url}/api/billing/settings`, {\n      method: \"POST\",\n      body: JSON.stringify({ settings }),\n    });\n  }\n\n  public async updateBillingManagers(delta: ManagerDelta): Promise<void> {\n    await this.request(`${this._url}/api/billing/managers`, {\n      method: \"PATCH\",\n      body: JSON.stringify({ delta }),\n    });\n  }\n\n  public async createTeam(name: string, domain: string, plan: {\n    product?: string, priceId?: string, count?: number\n  }, next?: string): Promise<{\n    checkoutUrl?: string,\n    orgUrl?: string,\n  }> {\n    const data = await this.requestJson(`${this._url}/api/billing/team`, {\n      method: \"POST\",\n      body: JSON.stringify({\n        domain,\n        name,\n        ...plan,\n        next,\n      }),\n    });\n    return data;\n  }\n\n  public async createFreeTeam(name: string, domain: string): Promise<void> {\n    await this.createTeam(name, domain, {\n      product: TEAM_FREE_PLAN,\n    });\n  }\n\n  public async changePlan(plan: PlanSelection): Promise<void> {\n    await this.requestJson(`${this._url}/api/billing/change-plan`, {\n      method: \"POST\",\n      body: JSON.stringify(plan),\n    });\n  }\n\n  public async confirmChange(plan: PlanSelection): Promise<ChangeSummary | { checkoutUrl: string }> {\n    return this.requestJson(`${this._url}/api/billing/confirm-change`, {\n      method: \"POST\",\n      body: JSON.stringify(plan),\n    });\n  }\n\n  public customerPortal(): string {\n    return `${this._url}/api/billing/customer-portal`;\n  }\n\n  public renewPlan(plan: PlanSelection): Promise<{ checkoutUrl: string }> {\n    return this.requestJson(`${this._url}/api/billing/renew`, {\n      method: \"POST\",\n      body: JSON.stringify(plan),\n    });\n  }\n\n  public async updateAssistantPlan(tier: number): Promise<void> {\n    await this.request(`${this._url}/api/billing/upgrade-assistant`, {\n      method: \"POST\",\n      body: JSON.stringify({ tier }),\n    });\n  }\n\n  /**\n   * Checks if current org has active subscription for a Stripe plan.\n   */\n  public async subscriptionStatus(planId: string): Promise<boolean> {\n    const data = await this.requestJson(`${this._url}/api/billing/status`, {\n      method: \"POST\",\n      body: JSON.stringify({ planId }),\n    });\n    return data.active;\n  }\n\n  public async changeProduct(product: string): Promise<void> {\n    await this.request(`${this._url}/api/billing/change-product`, {\n      method: \"POST\",\n      body: JSON.stringify({ product }),\n    });\n  }\n\n  public async attachSubscription(subscriptionId: string): Promise<void> {\n    await this.request(`${this._url}/api/billing/attach-subscription`, {\n      method: \"POST\",\n      body: JSON.stringify({ subscriptionId }),\n    });\n  }\n\n  public async attachPayment(paymentLink: string): Promise<void> {\n    await this.request(`${this._url}/api/billing/attach-payment`, {\n      method: \"POST\",\n      body: JSON.stringify({ paymentLink }),\n    });\n  }\n\n  public async getPaymentLink(): Promise<{ checkoutUrl: string }> {\n    return await this.requestJson(`${this._url}/api/billing/payment-link`, { method: \"GET\" });\n  }\n\n  public async cancelPlanChange(): Promise<void> {\n    await this.request(`${this._url}/api/billing/cancel-plan-change`, { method: \"POST\" });\n  }\n\n  public async dontCancelPlan(): Promise<void> {\n    await this.request(`${this._url}/api/billing/dont-cancel-plan`, { method: \"POST\" });\n  }\n\n  private get _url(): string {\n    return addCurrentOrgToPath(this._homeUrl);\n  }\n}\n"
  },
  {
    "path": "app/common/BinaryIndexedTree.js",
    "content": "/**\n * Implements a binary indexed tree, aka Fenwick tree. See\n * http://en.wikipedia.org/wiki/Fenwick_tree\n */\nfunction BinaryIndexedTree(optSize) {\n  this.tree = [];\n  if (optSize > 0) {\n    this.tree.length = optSize;\n    for (var i = 0; i < optSize; i++) {\n      this.tree[i] = 0;\n    }\n    // The last valid index rounded down to the nearest power of 2.\n    this.mask = mostSignificantOne(this.tree.length - 1);\n  }\n}\n\n/**\n * Returns a number that contains only the least significant one in `num`.\n * @param {Number} num - Positive integer.\n * @returns {Number} The least significant one in `num`, e.g. for 10110, returns 00010.\n */\nfunction leastSignificantOne(num) {\n  return num & (-num);\n}\nBinaryIndexedTree.leastSignificantOne = leastSignificantOne;\n\n\n/**\n * Strips the least significant one from `num`.\n * @param {Number} num - Positive integer.\n * @returns {Number} `num` with the least significant one removed, e.g. for 10110, returns 10100.\n */\nfunction stripLeastSignificantOne(num) {\n  return num & (num - 1);\n}\nBinaryIndexedTree.stripLeastSignificantOne = stripLeastSignificantOne;\n\n\nfunction mostSignificantOne(num) {\n  if (num === 0) {\n    return 0;\n  }\n  var msb = 1;\n  while ((num >>>= 1)) {\n    msb <<= 1;\n  }\n  return msb;\n}\nBinaryIndexedTree.mostSignificantOne = mostSignificantOne;\n\n/**\n * Converts in-place an array of cumulative values to the original values.\n * @param {Array<number>} values - Array of cumulative values, or partial sums.\n * @returns {Array<number>} - same `values` array, with elements replaced by deltas.\n *      E.g. [1,3,6,10] is converted to [1,2,3,4].\n */\nfunction cumulToValues(values) {\n  for (var i = values.length - 1; i >= 1; i--) {\n    values[i] -= values[i - 1];\n  }\n  return values;\n}\nBinaryIndexedTree.cumulToValues = cumulToValues;\n\n\n/**\n * Converts in-place an array of values to cumulative values, or partial sums.\n * @param {Array<number>} values - Array of numerical values.\n * @returns {Array<number>} - same `values` array, with elements replaced by partial sums.\n *      E.g. [1,2,3,4] is converted to [1,3,6,10].\n */\nfunction valuesToCumul(values) {\n  for (var i = 1; i < values.length; i++) {\n    values[i] += values[i - 1];\n  }\n  return values;\n}\nBinaryIndexedTree.valuesToCumul = valuesToCumul;\n\n\n/**\n * @returns {Number} length of the tree.\n */\nBinaryIndexedTree.prototype.size = function() {\n  return this.tree.length;\n};\n\n\n/**\n * Converts the BinaryIndexedTree to a cumulative array.\n * Takes time linear in the size of the array.\n * @returns {Array<number>} - array with each element a partial sum.\n */\nBinaryIndexedTree.prototype.toCumulativeArray = function() {\n  var cumulValues = [this.tree[0]];\n  var len = cumulValues.length = this.tree.length;\n  for (var i = 1; i < len; i++) {\n    cumulValues[i] = this.tree[i] + cumulValues[stripLeastSignificantOne(i)];\n  }\n  return cumulValues;\n};\n\n\n/**\n * Converts the BinaryIndexedTree to an array of individual values.\n * Takes time linear in the size of the array.\n * @returns {Array<number>} - array with each element containing the value that was inserted.\n */\nBinaryIndexedTree.prototype.toValueArray = function() {\n  return cumulToValues(this.toCumulativeArray());\n};\n\n\n/**\n * Creates a tree from an array of cumulative values.\n * Takes time linear in the size of the array.\n * @param {Array<number>} - array with each element a partial sum.\n */\nBinaryIndexedTree.prototype.fillFromCumulative = function(cumulValues) {\n  var len = this.tree.length = cumulValues.length;\n  if (len > 0) {\n    this.tree[0] = cumulValues[0];\n    for (var i = 1; i < len; i++) {\n      this.tree[i] = cumulValues[i] - cumulValues[stripLeastSignificantOne(i)];\n    }\n    // The last valid index rounded down to the nearest power of 2.\n    this.mask = mostSignificantOne(this.tree.length - 1);\n  } else {\n    this.mask = 0;\n  }\n};\n\n\n/**\n * Creates a tree from an array of individual values.\n * Takes time linear in the size of the array.\n * @param {Array<number>} - array with each element containing the value to insert.\n */\nBinaryIndexedTree.prototype.fillFromValues = function(values) {\n  this.fillFromCumulative(valuesToCumul(values.slice()));\n};\n\n\n/**\n * Reads the cumulative value at the given index. Takes time O(log(index)).\n * @param {Number} index - index in the array.\n * @returns {Number} - cumulative values up to and including `index`.\n */\nBinaryIndexedTree.prototype.getCumulativeValue = function(index) {\n  var sum = this.tree[0];\n  while (index > 0) {\n    sum += this.tree[index];\n    index = stripLeastSignificantOne(index);\n  }\n  return sum;\n};\n\n/**\n * Reads the cumulative value from start(inclusive) to end(exclusive). Takes time O(log(end)).\n * @param {Number} start - start index\n * @param {Number} end - end index\n * @returns {Number} - cumulative values between start(inclusive) and end(exclusive)\n */\nBinaryIndexedTree.prototype.getCumulativeValueRange = function(start, end) {\n  return this.getSumTo(end) - this.getSumTo(start);\n};\n\n/**\n * Returns the sum of values up to the given index. Takes time O(log(index)).\n * @param {Number} index - index in the array.\n * @returns {Number} - cumulative values up to but not including `index`.\n */\nBinaryIndexedTree.prototype.getSumTo = function(index) {\n  return (index > 0 ? this.getCumulativeValue(index - 1) : 0);\n};\n\n\n/**\n * Returns the total of all values in the tree. Takes time O(log(N)).\n * @returns {Number} - sum of all values.\n */\nBinaryIndexedTree.prototype.getTotal = function() {\n  return this.getCumulativeValue(this.tree.length - 1);\n};\n\n\n/**\n * Reads a single value at the given index. Takes time O(log(index)).\n * @param {Number} index - index in the array.\n * @returns {Number} - the value that was inserted at `index`.\n */\nBinaryIndexedTree.prototype.getValue = function(index) {\n  var value = this.tree[index];\n  if (index > 0) {\n    var parent = stripLeastSignificantOne(index);\n    index--;\n    while (index !== parent) {\n      value -= this.tree[index];\n      index = stripLeastSignificantOne(index);\n    }\n  }\n  return value;\n};\n\n\n/**\n * Updates a value at an index. Takes time O(log(table size)).\n * @param {Number} index - index in the array.\n * @param {Number} delta - value to add to the previous value at `index`.\n */\nBinaryIndexedTree.prototype.addValue = function(index, delta) {\n  if (index === 0) {\n    this.tree[0] += delta;\n  } else {\n    while (index < this.tree.length) {\n      this.tree[index] += delta;\n      index += leastSignificantOne(index);\n    }\n  }\n};\n\n\n/**\n * Sets a value at an index. Takes time O(log(table size)).\n * @param {Number} index - index in the array.\n * @param {Number} value - new value to set at `index`.\n */\nBinaryIndexedTree.prototype.setValue = function(index, value) {\n  this.addValue(index, value - this.getValue(index));\n};\n\n\n/**\n * Given a cumulative value, finds the first element whose inclusion reaches the value.\n * E.g. for values [1,2,3,4] (cumulative [1,3,6,10]), getIndex(3) = 1, getIndex(3.1) = 2.\n * @param {Number} cumulValue - cumulative value to exceed.\n * @returns {Number} index - the first index such that getCumulativeValue(index) >= cumulValue.\n *    If cumulValue is too large, return one more than the highest valid index.\n */\nBinaryIndexedTree.prototype.getIndex = function(cumulValue) {\n  if (this.tree.length === 0 || this.tree[0] >= cumulValue) {\n    return 0;\n  }\n  var index = 0;\n  var mask = this.mask;\n  var sum = this.tree[0];\n  while (mask !== 0) {\n    var testIndex = index + mask;\n    if (testIndex < this.tree.length && sum + this.tree[testIndex] < cumulValue) {\n      index = testIndex;\n      sum += this.tree[index];\n    }\n    mask >>>= 1;\n  }\n  return index + 1;\n};\n\nmodule.exports = BinaryIndexedTree;\n"
  },
  {
    "path": "app/common/BootProbe.ts",
    "content": "import { SandboxInfo } from \"app/common/SandboxInfo\";\n\nexport type BootProbeIds =\n  \"admins\" |\n  \"boot-page\" |\n  \"health-check\" |\n  \"reachable\" |\n  \"host-header\" |\n  \"sandboxing\" |\n  \"system-user\" |\n  \"authentication\" |\n  \"websockets\" |\n  \"session-secret\"\n;\n\nexport interface BootProbeResult {\n  verdict?: string;\n  // Result of check.\n  // \"success\" is a positive outcome.\n  // \"none\" means no fault detected (but that the test is not exhaustive\n  // enough to claim \"success\").\n  // \"fault\" is a bad error, \"warning\" a ... warning, \"hmm\" almost a debug message.\n  status: \"success\" | \"fault\" | \"warning\" | \"hmm\" | \"none\";\n  details?: Record<string, any>;\n}\n\nexport interface BootProbeInfo {\n  id: BootProbeIds;\n  name: string;\n}\n\nexport type SandboxingBootProbeDetails = SandboxInfo;\n"
  },
  {
    "path": "app/common/BrowserSettings.ts",
    "content": "/**\n * Describes the settings that a browser sends to the server.\n */\nexport interface BrowserSettings {\n  // The browser's timezone, must be one of `momet.tz.names()`.\n  timezone?: string;\n  // The browser's locale, should be read from Accept-Language header.\n  locale?: string;\n}\n"
  },
  {
    "path": "app/common/CircularArray.js",
    "content": "/**\n * Array-like data structure that lets you push elements to it, but holds only the last N of them.\n */\nfunction CircularArray(maxLength) {\n  this.maxLength = maxLength;\n  this._data = [];\n  this._offset = 0;\n}\n\n/**\n * @property {Number} - the number of items in the CircularArray.\n */\nObject.defineProperty(CircularArray.prototype, \"length\", {\n  get: function() { return this._data.length; }\n});\n\n/**\n * @param {Number} index - An index to fetch, between 0 and length - 1.\n * @returns {Object} The item at the given index.\n */\nCircularArray.prototype.get = function(index) {\n  return this._data[(this._offset + index) % this.maxLength];\n};\n\n/**\n * @param {Object} item - An item to push onto the end of the CircularArray.\n */\nCircularArray.prototype.push = function(item) {\n  if (this._data.length < this.maxLength) {\n    this._data.push(item);\n  } else {\n    this._data[this._offset] = item;\n    this._offset = (this._offset + 1) % this.maxLength;\n  }\n};\n\n/**\n * Returns the entire content of CircularArray as a plain array.\n */\nCircularArray.prototype.getArray = function() {\n  return this._data.slice(this._offset).concat(this._data.slice(0, this._offset));\n};\n\nmodule.exports = CircularArray;\n"
  },
  {
    "path": "app/common/ColumnFilterFunc.ts",
    "content": "import { CellValue } from \"app/common/DocActions\";\nimport { FilterState, IRangeBoundType, isRangeFilter, makeFilterState } from \"app/common/FilterState\";\nimport { extractInfoFromColType, isDateLikeType, isList, isListType, isNumberType } from \"app/common/gristTypes\";\nimport { isRelativeBound, relativeDateToUnixTimestamp } from \"app/common/RelativeDates\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nimport noop from \"lodash/noop\";\nimport moment, { Moment } from \"moment-timezone\";\n\nexport type ColumnFilterFunc = (value: CellValue) => boolean;\n\n// Returns a filter function for a particular column: the function takes a cell value and returns\n// whether it's accepted according to the given FilterState.\nexport function makeFilterFunc(state: FilterState,\n  columnType: string = \"\"): ColumnFilterFunc {\n  if (isRangeFilter(state)) {\n    let { min, max } = state;\n    if (isNumberType(columnType) || isDateLikeType(columnType)) {\n      if (isDateLikeType(columnType)) {\n        const info = extractInfoFromColType(columnType);\n        const timezone = (info.type === \"DateTime\" && info.timezone) || \"utc\";\n        min = changeTimezone(min, timezone, m => m.startOf(\"day\"));\n        max = changeTimezone(max, timezone, m => m.endOf(\"day\"));\n      }\n\n      return (val) => {\n        if (typeof val !== \"number\") { return false; }\n        return (\n          (max === undefined ? true : val <= max) &&\n          (min === undefined ? true : min <= val)\n        );\n      };\n    } else {\n      // Although it is not possible to set a range filter for non numeric columns, this still can\n      // happen as a result of a column type conversion. In this case, let's include all values.\n      return () => true;\n    }\n  }\n\n  const { include, values } = state;\n\n  // NOTE: This logic results in complex values and their stringified JSON representations as equivalent.\n  // For example, a TypeError in the formula column and the string '[\"E\",\"TypeError\"]' would be seen as the same.\n  // TODO: This narrow corner case seems acceptable for now, but may be worth revisiting.\n  return (val: CellValue) => {\n    if (isList(val) && columnType && isListType(columnType)) {\n      const list = decodeObject(val) as unknown[];\n      if (list.length) {\n        return list.some(item => values.has(item as any) === include);\n      }\n      // If the list is empty, filter instead by an empty value for the whole list\n      val = columnType === \"ChoiceList\" ? \"\" : null;\n    }\n    return (values.has(Array.isArray(val) ? JSON.stringify(val) : val) === include);\n  };\n}\n\n// Given a JSON string, returns a ColumnFilterFunc\nexport function buildColFilter(filterJson: string | undefined,\n  columnType?: string): ColumnFilterFunc | null {\n  return filterJson ? makeFilterFunc(makeFilterState(filterJson), columnType) : null;\n}\n\n// Returns the unix timestamp for date in timezone. Function support relative date. Also support\n// optional mod argument that let you modify date as a moment instance.\nfunction changeTimezone(date: IRangeBoundType,\n  timezone: string,\n  mod: (m: Moment) => void = noop): number | undefined {\n  if (date === undefined) { return undefined; }\n  const val = isRelativeBound(date) ? relativeDateToUnixTimestamp(date) : date;\n  const m = moment.tz(val * 1000, timezone);\n  mod(m);\n  return Math.floor(m.valueOf() / 1000);\n}\n"
  },
  {
    "path": "app/common/ColumnGetters.ts",
    "content": "import { Sort } from \"app/common/SortSpec\";\n\n/**\n *\n * An interface for accessing the columns of a table by their\n * ID in _grist_Tables_column, which is the ID used in sort specifications.\n * Implementations of this interface can be supplied to SortFunc to\n * sort the rows of a table according to such a specification.\n *\n */\nexport interface ColumnGetters {\n  /**\n   *\n   * Takes a _grist_Tables_column ID and returns a function that maps\n   * rowIds to values for that column.  Those values should be display\n   * values if available, drawn from a corresponding display column.\n   *\n   */\n  getColGetter(spec: Sort.ColSpec): ColumnGetter | null;\n\n  /**\n   *\n   * Returns a getter for the manual sort column if it is available.\n   *\n   */\n  getManualSortGetter(): ColumnGetter | null;\n}\n\n/**\n * Like ColumnGetters, but takes the string `colId` rather than a `ColSpec`\n * or numeric row ID.\n */\nexport interface ColumnGettersByColId {\n  getColGetterByColId(colId: string): ColumnGetter | null;\n}\n\nexport type ColumnGetter = (rowId: number) => any;\n"
  },
  {
    "path": "app/common/CommTypes.ts",
    "content": "import { ActionGroup } from \"app/common/ActionGroup\";\nimport { VisibleUserProfile } from \"app/common/ActiveDocAPI\";\nimport { DocAction } from \"app/common/DocActions\";\nimport { FilteredDocUsageSummary } from \"app/common/DocUsage\";\nimport { Product } from \"app/common/Features\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { AttachmentTransferStatus } from \"app/common/UserAPI\";\n\nexport const ValidEvent = StringUnion(\n  \"docListAction\", \"docUserAction\", \"docShutdown\", \"docError\",\n  \"docUsage\", \"docChatter\", \"docUserPresenceUpdate\", \"clientConnect\");\nexport type ValidEvent = typeof ValidEvent.type;\n\n/**\n * A request in the appropriate form for sending to the server.\n */\nexport interface CommRequest {\n  reqId: number;\n  method: string;\n  args: any[];\n}\n\n/**\n * A regular, successful response from the server.\n */\nexport interface CommResponse {\n  reqId: number;\n  data: any;\n  error?: null;  // TODO: keep until sure server never sets this on regular responses.\n}\n\n/**\n * An exceptional response from the server when there is an error.\n */\nexport interface CommResponseError {\n  reqId: number;\n  error: string;\n  errorCode?: string;\n  shouldFork?: boolean;  // if set, the server suggests forking the document.\n  details?: any;  // if set, error has extra details available. TODO - the treatment of\n  // details could do with some harmonisation between rest API and ws API,\n  // and between front-end and back-end types.\n  status?: number;  // if set, a REST API style code.\n}\n\n/**\n * A message pushed from the server, not in response to a request.\n */\nexport interface CommMessageBase {\n  type: ValidEvent;\n  docFD?: number;\n  data?: unknown;\n}\n\nexport type CommDocMessage = CommDocUserAction | CommDocUsage | CommDocShutdown |\n  CommDocError | CommDocChatter | CommDocUserPresenceUpdate;\nexport type CommMessage = CommDocMessage | CommDocListAction | CommClientConnect;\n\nexport type CommResponseBase = CommResponse | CommResponseError | CommMessage;\n\nexport type CommDocEventType = CommDocMessage[\"type\"];\n\n/**\n * Event for a change to the document list.\n * These are sent to all connected clients, regardless of which documents they have open.\n * TODO: This is entirely unused at the moment.\n */\nexport interface CommDocListAction extends CommMessageBase {\n  type: \"docListAction\";\n  addDocs?: string[];        //  names of documents to add to the docList.\n  removeDocs?: string[];     //  names of documents that got removed.\n  renameDocs?: string[];     //  [oldName, newName] pairs for renamed docs.\n  addInvites?: string[];     //  document invite names to add.\n  removeInvites?: string[];  //  documents invite names to remove.\n}\n\n/**\n * Event for a user action on a document, or part of one. Sent to all clients that have this\n * document open.\n */\nexport interface CommDocUserAction extends CommMessageBase {\n  type: \"docUserAction\";\n  docFD: number;           // The file descriptor of the open document, specific to each client.\n  fromSelf?: boolean;      // Flag to indicate whether the action originated from this client.\n\n  // ActionGroup object containing user action, and doc actions.\n  data: {\n    docActions: DocAction[];\n    actionGroup: ActionGroup;\n    docUsage: FilteredDocUsageSummary;\n    error?: string;\n  };\n}\n\nexport enum WebhookMessageType {\n  Update = \"webhookUpdate\",\n  Overflow = \"webhookOverflowError\",\n}\nexport interface CommDocChatter extends CommMessageBase {\n  type: \"docChatter\";\n  docFD: number;\n  data: {\n    webhooks?: {\n      type: WebhookMessageType,\n      // If present, something happened related to webhooks.\n      // Currently, we give no details, leaving it to client\n      // to call back for details if it cares.\n    },\n    // This could also be a fine place to send updated info\n    // about other users of the document.\n    timing?: {\n      status: \"active\" | \"disabled\";\n    },\n    attachmentTransfer?: AttachmentTransferStatus;\n  };\n}\n\nexport interface CommDocUserPresenceUpdate extends CommMessageBase {\n  type: \"docUserPresenceUpdate\";\n  docFD: number;\n  data: {\n    id: string;\n    // Null entry indicates the user is no longer active and should be removed.\n    profile: VisibleUserProfile | null;\n  }\n}\n\n/**\n * Event for a change to document usage. Sent to all clients that have this document open.\n */\nexport interface CommDocUsage extends CommMessageBase {\n  type: \"docUsage\";\n  docFD: number;           // The file descriptor of the open document, specific to each client.\n  data: {\n    docUsage: FilteredDocUsageSummary;  // Document usage summary.\n    product?: Product;                  // Product that was used to compute `data.docUsage`\n  };\n}\n\n/**\n * Event for when a document is forcibly shutdown, and requires the client to re-open it.\n */\nexport interface CommDocShutdown extends CommMessageBase {\n  type: \"docShutdown\";\n  docFD: number;\n  data: null;\n}\n\n/**\n * Event that signals an error while opening a doc.\n */\nexport interface CommDocError extends CommMessageBase {\n  type: \"docError\";\n  docFD: number;\n  data: {\n    when: string;\n    message: string;\n  }\n}\n\n/**\n * Event sent by server received when a client first connects.\n */\nexport interface CommClientConnect extends CommMessageBase {\n  type: \"clientConnect\";\n\n  // ID for the client, which may be reused if a client reconnects to reattach to its state on\n  // the server.\n  clientId: string;\n\n  // If set, the reconnecting client cannot be sent all missed messages, and needs to reload.\n  needReload?: boolean;\n\n  // Array of serialized messages missed from the server while disconnected.\n  missedMessages?: string[];\n\n  // Which version the server reports for itself.\n  serverVersion?: string;\n\n  // Object containing server settings and features which should be used to initialize the client.\n  settings?: { [key: string]: unknown };\n\n  dup?: boolean;  // Flag that's set to true when it's a duplicate clientConnect message.\n}\n"
  },
  {
    "path": "app/common/Config-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const Config = t.iface([], {\n  \"id\": \"string\",\n  \"key\": \"ConfigKey\",\n  \"value\": \"ConfigValue\",\n  \"createdAt\": \"string\",\n  \"updatedAt\": \"string\",\n  \"org\": t.opt(\"ConfigOrg\"),\n});\n\nexport const ConfigOrg = t.iface([], {\n  \"id\": \"number\",\n  \"name\": \"string\",\n  \"domain\": t.union(\"string\", \"null\"),\n});\n\nexport const ConfigKey = t.lit(\"audit_log_streaming_destinations\");\n\nexport const ConfigValue = t.name(\"AuditLogStreamingDestinations\");\n\nexport const AuditLogStreamingDestinations = t.array(\"AuditLogStreamingDestination\");\n\nexport const AuditLogStreamingDestination = t.iface([], {\n  \"id\": \"string\",\n  \"name\": \"AuditLogStreamingDestinationName\",\n  \"url\": \"string\",\n  \"token\": t.opt(\"string\"),\n});\n\nexport const AuditLogStreamingDestinationName = t.union(t.lit(\"splunk\"), t.lit(\"other\"));\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  Config,\n  ConfigOrg,\n  ConfigKey,\n  ConfigValue,\n  AuditLogStreamingDestinations,\n  AuditLogStreamingDestination,\n  AuditLogStreamingDestinationName,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/common/Config.ts",
    "content": "import ConfigsTI from \"app/common/Config-ti\";\n\nimport { CheckerT, createCheckers } from \"ts-interface-checker\";\n\nexport interface Config {\n  id: string;\n  key: ConfigKey;\n  value: ConfigValue;\n  createdAt: string;\n  updatedAt: string;\n  org?: ConfigOrg;\n}\n\nexport interface ConfigOrg {\n  id: number;\n  name: string;\n  domain: string | null;\n}\n\nexport type ConfigKey = \"audit_log_streaming_destinations\";\n\nexport type ConfigValue = AuditLogStreamingDestinations;\n\nexport type AuditLogStreamingDestinations = AuditLogStreamingDestination[];\n\nexport interface AuditLogStreamingDestination {\n  id: string;\n  name: AuditLogStreamingDestinationName;\n  url: string;\n  token?: string;\n}\n\nexport type AuditLogStreamingDestinationName = \"splunk\" | \"other\";\n\nconst {\n  AuditLogStreamingDestinations,\n  AuditLogStreamingDestinationName,\n  ConfigKey,\n} = createCheckers(ConfigsTI);\n\nexport const AuditLogStreamingDestinationNameChecker =\n  AuditLogStreamingDestinationName as CheckerT<AuditLogStreamingDestinationName>;\n\nexport const ConfigKeyChecker = ConfigKey as CheckerT<ConfigKey>;\n\nexport const ConfigValueCheckers = {\n  audit_log_streaming_destinations:\n    AuditLogStreamingDestinations as CheckerT<AuditLogStreamingDestinations>,\n};\n"
  },
  {
    "path": "app/common/ConfigAPI.ts",
    "content": "import { BaseAPI, IOptions } from \"app/common/BaseAPI\";\nimport { addCurrentOrgToPath } from \"app/common/urlUtils\";\n\n/**\n * Interface for authentication providers.\n */\nexport interface AuthProvider {\n  name: string;\n  key: string;\n  isConfigured?: boolean;         // Whether the provider is configured properly.\n  isActive?: boolean;             // Whether the provider is currently active.\n  activeError?: string;           // Error reported by the provider (either at startup or runtime).\n  configError?: string;           // Error message if provider is misconfigured.\n  willBeActive?: boolean;         // Whether the provider will be active on restart.\n  willBeDisabled?: boolean;       // Whether the provider will be disabled on restart.\n  canBeActivated?: boolean;       // Whether the provider can be activated (configured and not selected via env).\n  isSelectedByEnv?: boolean;      // Whether the provider is selected via environment variable.\n  metadata?: Record<string, any>; // Additional provide metadata.\n}\n\n/**\n * An API for accessing the internal Grist configuration, stored in\n * config.json.\n */\nexport class ConfigAPI extends BaseAPI {\n  constructor(private _homeUrl: string, options: IOptions = {}) {\n    super(options);\n  }\n\n  public async getValue(key: string): Promise<any> {\n    return (await this.requestJson(`${this._url}/api/config/${key}`, { method: \"GET\" })).value;\n  }\n\n  public async setValue(value: any, restart = false): Promise<void> {\n    await this.request(`${this._url}/api/config`, {\n      method: \"PATCH\",\n      body: JSON.stringify({ config: value, restart }),\n    });\n  }\n\n  public async restartServer(): Promise<void> {\n    await this.request(`${this._url}/api/admin/restart`, { method: \"POST\" });\n  }\n\n  public async healthcheck(): Promise<void> {\n    const resp = await this.request(`${this._homeUrl}/status?ready=1`);\n    if (!resp.ok) {\n      throw new Error(await resp.text());\n    }\n  }\n\n  /**\n   * Changes the active authentication provider (to be active after restart).\n   */\n  public async setActiveAuthProvider(providerKey: string): Promise<void> {\n    await this.request(`${this._url}/api/config/auth-providers/set-active`, {\n      method: \"POST\",\n      body: JSON.stringify({ providerKey }),\n    });\n  }\n\n  /**\n   * Fetches available authentication providers from the server.\n   */\n  public async getAuthProviders(): Promise<AuthProvider[]> {\n    const resp = await this.requestJson(`${this._url}/api/config/auth-providers`, { method: \"GET\" });\n    return resp as AuthProvider[];\n  }\n\n  /**\n   * Configures an authentication provider.\n   */\n  public async configureProvider(provider: string, config: Record<string, string>): Promise<void> {\n    const url = new URL(`${this._url}/api/config/auth-providers`);\n    url.searchParams.append(\"provider\", provider);\n    await this.request(url.toString(), {\n      method: \"PATCH\",\n      body: JSON.stringify(config),\n    });\n  }\n\n  /**\n   * Gets the configuration of an authentication provider.\n   */\n  public async getAuthProviderConfig(provider: string): Promise<Record<string, any>> {\n    const url = new URL(`${this._url}/api/config/auth-providers/config`);\n    url.searchParams.append(\"provider\", provider);\n    return await this.requestJson(url.toString(), { method: \"GET\" });\n  }\n\n  private get _url(): string {\n    return addCurrentOrgToPath(this._homeUrl);\n  }\n}\n"
  },
  {
    "path": "app/common/CssCustomProp.ts",
    "content": "const VAR_PREFIX = \"grist\";\n\nexport class CssCustomProp {\n  private _prefix = VAR_PREFIX;\n\n  constructor(\n    public name: string,\n    public value?: string | CssCustomProp,\n    public fallback?: string | CssCustomProp,\n    public type?: \"theme\") {\n    if (this.type === \"theme\") {\n      this._prefix = `${VAR_PREFIX}-theme`;\n    }\n  }\n\n  public decl(): string | undefined {\n    if (this.value === undefined) { return undefined; }\n\n    return `${this.var()}: ${this.value};`;\n  }\n\n  public toString(): string {\n    let value = this.var();\n    if (this.fallback) {\n      value += `, ${this.fallback}`;\n    }\n    return `var(${value})`;\n  }\n\n  public var(): string {\n    return `--${this._prefix}-${this.name}`;\n  }\n\n  /**\n   * Get the actual string value instead of a potential pointer to another css variable\n   */\n  public getRawValue(): string {\n    if (typeof this.value !== \"string\" && this.value?.value) {\n      return this._getRawValue(this.value);\n    }\n    return this.value as string;\n  }\n\n  private _getRawValue(token?: string | CssCustomProp): string {\n    if (typeof token === \"string\") {\n      return token;\n    }\n    if (token?.value) {\n      return this._getRawValue(token.value);\n    }\n    return \"\";\n  }\n}\n"
  },
  {
    "path": "app/common/CustomWidget.ts",
    "content": "import sortBy from \"lodash/sortBy\";\n\n/**\n * Custom widget manifest definition.\n */\nexport interface ICustomWidget {\n  /**\n   * Widget friendly name, used on the UI.\n   */\n  name: string;\n  /**\n   * Widget unique id, probably in npm package format @gristlabs/custom-widget-name.\n   *\n   * There could be multiple versions of the same widget with the\n   * same id, e.g. a bundled version and an external version.\n   */\n  widgetId: string;\n  /**\n   * Custom widget main page URL.\n   */\n  url: string;\n  /**\n   * Optional desired access level.\n   */\n  accessLevel?: AccessLevel;\n  /**\n   * If set, Grist will render the widget after `grist.ready()`.\n   *\n   * This is used to defer showing a widget on initial load until it has finished\n   * applying the Grist theme.\n   */\n  renderAfterReady?: boolean;\n  /**\n   * If set to false, do not offer to user in UI.\n   */\n  published?: boolean;\n  /**\n   * If the widget came from a plugin, we track that here.\n   */\n  source?: {\n    pluginId: string;\n    name: string;\n  };\n  /**\n   * Widget description.\n   */\n  description?: string;\n  /**\n   * Widget authors.\n   *\n   * The first author is the one shown in the UI.\n   */\n  authors?: WidgetAuthor[];\n  /**\n   * Date the widget was last updated.\n   */\n  lastUpdatedAt?: string;\n  /**\n   * If the widget is maintained by Grist Labs.\n   */\n  isGristLabsMaintained?: boolean;\n}\n\nexport interface WidgetAuthor {\n  name: string;\n  url?: string;\n}\n\n/**\n * Widget access level.\n */\nexport enum AccessLevel {\n  /**\n   * Default, no access to Grist.\n   */\n  none = \"none\",\n  /**\n   * Read only access to table the widget is based on.\n   */\n  read_table = \"read table\",\n  /**\n   * Full access to document on user's behalf.\n   */\n  full = \"full\",\n}\n\nexport function isSatisfied(current: AccessLevel, minimum: AccessLevel) {\n  function ordered(level: AccessLevel) {\n    switch (level) {\n      case AccessLevel.none: return 0;\n      case AccessLevel.read_table: return 1;\n      case AccessLevel.full: return 2;\n      default: throw new Error(`Unrecognized access level ${level}`);\n    }\n  }\n  return ordered(current) >= ordered(minimum);\n}\n\n/**\n * Find the best match for a widgetId/pluginId combination among the\n * given widgets. An exact widgetId match is required. A pluginId match\n * is preferred but not required.\n */\nexport function matchWidget(widgets: ICustomWidget[], options: {\n  widgetId: string,\n  pluginId?: string,\n}): ICustomWidget | undefined {\n  const prefs = sortBy(widgets, (w) => {\n    return [w.widgetId !== options.widgetId,\n      (w.source?.pluginId || \"\") !== options.pluginId];\n  });\n  if (prefs.length === 0) { return; }\n  if (prefs[0].widgetId !== options.widgetId) { return; }\n  return prefs[0];\n}\n"
  },
  {
    "path": "app/common/DisposableWithEvents.ts",
    "content": "/**\n * A base class which combines grainjs Disposable with mixed-in backbone Events. It includes the\n * backbone Events methods, and when disposed, stops backbone listeners started with listenTo().\n */\nimport { Events as BackboneEvents, EventsHash } from \"backbone\";\nimport { Disposable } from \"grainjs\";\n\n// In Typescript, mixins are awkward. This follows the recommendation here\n// https://www.typescriptlang.org/docs/handbook/mixins.html\nexport class DisposableWithEvents extends Disposable implements BackboneEvents {\n  public on: (eventName: string | EventsHash, callback?: (...args: any[]) => void, context?: any) => any;\n  public off: (eventName?: string, callback?: (...args: any[]) => void, context?: any) => any;\n  public trigger: (eventName: string, ...args: any[]) => any;\n  public bind: (eventName: string, callback: (...args: any[]) => void, context?: any) => any;\n  public unbind: (eventName?: string, callback?: (...args: any[]) => void, context?: any) => any;\n\n  public once: (events: string, callback: (...args: any[]) => void, context?: any) => any;\n  public listenTo: (object: any, events: string, callback: (...args: any[]) => void) => any;\n  public listenToOnce: (object: any, events: string, callback: (...args: any[]) => void) => any;\n  public stopListening: (object?: any, events?: string, callback?: (...args: any[]) => void) => any;\n\n  // DisposableWithEvents knows also how to stop any backbone listeners started with listenTo().\n  constructor() {\n    super();\n    this.onDispose(this.stopListening, this);\n  }\n}\nObject.assign(DisposableWithEvents.prototype, BackboneEvents);\n"
  },
  {
    "path": "app/common/DocActions.ts",
    "content": "/**\n * This mirrors action definitions from sandbox/grist/actions.py\n */\n\n// Some definitions have moved to be part of plugin API.\nimport { BulkColValues, CellValue, RowRecord } from \"app/plugin/GristData\";\nexport type { BulkColValues, CellValue, RowRecord };\n\n// Part of a special CellValue used for comparisons, embedding several versions of a CellValue.\nexport interface AllCellVersions {\n  parent: CellValue;\n  remote: CellValue;\n  local: CellValue;\n}\nexport type CellVersions = Partial<AllCellVersions>;\n\nexport type AddRecord = [\"AddRecord\", string, number, ColValues];\nexport type BulkAddRecord = [\"BulkAddRecord\", string, number[], BulkColValues];\nexport type RemoveRecord = [\"RemoveRecord\", string, number];\nexport type BulkRemoveRecord = [\"BulkRemoveRecord\", string, number[]];\nexport type UpdateRecord = [\"UpdateRecord\", string, number, ColValues];\nexport type BulkUpdateRecord = [\"BulkUpdateRecord\", string, number[], BulkColValues];\n\nexport type ReplaceTableData = [\"ReplaceTableData\", string, number[], BulkColValues];\n\n// This is the format in which data comes when we fetch a table from the sandbox.\nexport type TableDataAction = [\"TableData\", string, number[], BulkColValues];\n\nexport type AddColumn = [\"AddColumn\", string, string, ColInfo];\nexport type RemoveColumn = [\"RemoveColumn\", string, string];\nexport type RenameColumn = [\"RenameColumn\", string, string, string];\nexport type ModifyColumn = [\"ModifyColumn\", string, string, Partial<ColInfo>];\n\nexport type AddTable = [\"AddTable\", string, ColInfoWithId[]];\nexport type RemoveTable = [\"RemoveTable\", string];\nexport type RenameTable = [\"RenameTable\", string, string];\n\nexport type DocAction = (\n  AddRecord |\n  BulkAddRecord |\n  RemoveRecord |\n  BulkRemoveRecord |\n  UpdateRecord |\n  BulkUpdateRecord |\n  ReplaceTableData |\n  TableDataAction |\n  AddColumn |\n  RemoveColumn |\n  RenameColumn |\n  ModifyColumn |\n  AddTable |\n  RemoveTable |\n  RenameTable\n);\n\n// type guards for convenience - see:\n//   https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards\nexport function isAddRecord(act: DocAction): act is AddRecord { return act[0] === \"AddRecord\"; }\nexport function isBulkAddRecord(act: DocAction): act is BulkAddRecord { return act[0] === \"BulkAddRecord\"; }\nexport function isRemoveRecord(act: DocAction): act is RemoveRecord { return act[0] === \"RemoveRecord\"; }\nexport function isBulkRemoveRecord(act: DocAction): act is BulkRemoveRecord { return act[0] === \"BulkRemoveRecord\"; }\nexport function isUpdateRecord(act: DocAction): act is UpdateRecord { return act[0] === \"UpdateRecord\"; }\nexport function isBulkUpdateRecord(act: DocAction): act is BulkUpdateRecord { return act[0] === \"BulkUpdateRecord\"; }\n\nexport function isReplaceTableData(act: DocAction): act is ReplaceTableData { return act[0] === \"ReplaceTableData\"; }\n\nexport function isAddColumn(act: DocAction): act is AddColumn { return act[0] === \"AddColumn\"; }\nexport function isRemoveColumn(act: DocAction): act is RemoveColumn { return act[0] === \"RemoveColumn\"; }\nexport function isRenameColumn(act: DocAction): act is RenameColumn { return act[0] === \"RenameColumn\"; }\nexport function isModifyColumn(act: DocAction): act is ModifyColumn { return act[0] === \"ModifyColumn\"; }\n\nexport function isAddTable(act: DocAction): act is AddTable { return act[0] === \"AddTable\"; }\nexport function isRemoveTable(act: DocAction): act is RemoveTable { return act[0] === \"RemoveTable\"; }\nexport function isRenameTable(act: DocAction): act is RenameTable { return act[0] === \"RenameTable\"; }\n\nconst SCHEMA_ACTIONS = new Set([\"AddTable\", \"RemoveTable\", \"RenameTable\", \"AddColumn\",\n  \"RemoveColumn\", \"RenameColumn\", \"ModifyColumn\"]);\n\nconst DATA_ACTIONS = new Set([\"AddRecord\", \"RemoveRecord\", \"UpdateRecord\", \"BulkAddRecord\",\n  \"BulkRemoveRecord\", \"BulkUpdateRecord\", \"ReplaceTableData\", \"TableData\"]);\n\n/**\n * Determines whether a given action is a schema action or not.\n */\nexport function isSchemaAction(action: DocAction):\n    action is AddTable | RemoveTable | RenameTable | AddColumn | RemoveColumn | RenameColumn | ModifyColumn {\n  return SCHEMA_ACTIONS.has(action[0]);\n}\n\nexport type DataAction = AddRecord | RemoveRecord | UpdateRecord |\n  BulkAddRecord | BulkRemoveRecord | BulkUpdateRecord |\n  ReplaceTableData | TableDataAction;\n\nexport type SingleDataAction = AddRecord | UpdateRecord | RemoveRecord;\nexport type BulkDataAction = Exclude<DataAction, SingleDataAction>;\n\n// Check if action adds/updates/removes/replaces rows.\nexport function isDataAction(action: DocAction | UserAction): action is DataAction {\n  return DATA_ACTIONS.has(String(action[0]));\n}\n\nexport function isSingleAction(action: DataAction): action is SingleDataAction {\n  return typeof action[2] === \"number\";\n}\nexport function isBulkAction(action: DataAction): action is BulkDataAction {\n  return !isSingleAction(action);\n}\nexport function isSomeAddRecordAction(a: DataAction): a is AddRecord | BulkAddRecord {\n  return isAddRecord(a) || isBulkAddRecord(a);\n}\nexport function isSomeRemoveRecordAction(a: DataAction): a is RemoveRecord | BulkRemoveRecord {\n  return isRemoveRecord(a) || isBulkRemoveRecord(a);\n}\n\n/**\n * Returns the tableId from the action.\n */\nexport function getTableId(action: DocAction): string {\n  return action[1];   // It happens to always be in the same position in the action tuple.\n}\n\n// Returns the action name from DocAction.\nexport function getActionName<T extends DocAction>(action: T) { return action[0]; }\n\n// Returns rowId or rowIds for a DataAction.\nexport function getRowIds(action: SingleDataAction): number;\nexport function getRowIds(action: BulkDataAction): number[];\nexport function getRowIds(action: DataAction): number | number[];\nexport function getRowIds(action: DataAction): number | number[] { return action[2]; }\n\n// Returns the row ids in a DataAction as a list, even if the action is not a bulk action.\nexport function getRowIdsFromDocAction(action: DataAction): number[] {\n  const ids = action[2];\n  return (typeof ids === \"number\") ? [ids] : ids;\n}\n\n// Returns colValues from a DataAction other than a remove action (which doesn't have colValues).\nexport function getActionColValues(action: Exclude<SingleDataAction, RemoveRecord>): ColValues;\nexport function getActionColValues(action: Exclude<BulkDataAction, BulkRemoveRecord>): BulkColValues;\nexport function getActionColValues(\n  action: Exclude<DataAction, RemoveRecord | BulkRemoveRecord>,\n): ColValues | BulkColValues;\nexport function getActionColValues(action: Exclude<DataAction, RemoveRecord | BulkRemoveRecord>) { return action[3]; }\n\nexport interface TableDataActionSet {\n  [tableId: string]: TableDataAction;\n}\n\n// Helper types used in the definitions above.\n\nexport interface ColValues { [colId: string]: CellValue; }\nexport interface ColInfoMap { [colId: string]: ColInfo; }\n\nexport interface ColInfo {\n  type: string;\n  isFormula: boolean;\n  formula: string;\n}\n\nexport interface ColInfoWithId extends ColInfo {\n  id: string;\n}\n\n// Multiple records in column-oriented format, i.e. same as BulkColValues but with a mandatory\n// 'id' column. This is preferred over TableDataAction in external APIs.\nexport interface TableColValues {\n  id: number[];\n  [colId: string]: CellValue[];\n}\n\n// Multiple records in record-oriented format\nexport interface TableRecordValues {\n  records: TableRecordValue[];\n}\n\nexport interface TableRecordValuesWithoutIds {\n  records: TableRecordValueWithoutId[];\n}\n\nexport interface TableRecordValue extends TableRecordValueWithoutId {\n  id: number | string;\n}\n\nexport interface TableRecordValueWithoutId {\n  fields: {\n    [colId: string]: CellValue\n  };\n}\n\n// Both UserActions and DocActions are represented as [ActionName, ...actionArgs].\n// TODO I think it's better to represent DocAction as a Buffer containing the marshalled action.\n\nexport type UserAction = (string | number | object | boolean | null | undefined)[];\n\n// Actions that are performed automatically by the server\n// for things like regular maintenance or triggering formula calculations in the data engine.\n// Typically applied using `makeExceptionalDocSession(\"system\")`.\n// They're also 'non-essential' in the sense that we don't need to worry about storing them\n// in action/undo history if they don't change anything (which they often won't)\n// and we can dismiss their result if the document is shutting down.\nexport const SYSTEM_ACTIONS = new Set([\n  // Initial dummy action performed when the document laods.\n  \"Calculate\",\n  // Called automatically at regular intervals, again to trigger formula calculations.\n  \"UpdateCurrentTime\",\n  // Part of the formula calculation process for formulas that use the `REQUEST` function.\n  \"RespondToRequests\",\n  // Performed at shutdown to clean up temporary helper columns and tables.\n  \"RemoveStaleObjects\",\n]);\n\nexport function getNumRows(action: DocAction): number {\n  return !isDataAction(action) ? 0 :\n    Array.isArray(action[2]) ? action[2].length :\n      1;\n}\n\n// Convert from TableColValues (used by DocStorage and external APIs) to TableDataAction (used\n// mainly by the sandbox).\nexport function toTableDataAction(tableId: string, colValues: TableColValues): TableDataAction {\n  const colData = { ...colValues };   // Make a copy to avoid changing passed-in arguments.\n  const rowIds: number[] = colData.id;\n  delete (colData as BulkColValues).id;\n  return [\"TableData\", tableId, rowIds, colData];\n}\n\n// Convert from TableDataAction (used mainly by the sandbox) to TableColValues (used by DocStorage\n// and external APIs).\n// Also accepts a TableDataAction nested as a tableData member of a larger structure,\n// for convenience in dealing with the result of fetches.\nexport function fromTableDataAction(tableData: TableDataAction | { tableData: TableDataAction }): TableColValues {\n  const data = (\"tableData\" in tableData) ? tableData.tableData : tableData;\n  const rowIds: number[] = data[2];\n  const colValues: BulkColValues = data[3];\n  return { id: rowIds, ...colValues };\n}\n\n/**\n * Convert a list of rows into an object with columns of values, used for\n * BulkAddRecord/BulkUpdateRecord actions.\n */\nexport function getColValues(records: Partial<RowRecord>[]): BulkColValues {\n  const colIdSet = new Set<string>();\n  for (const r of records) {\n    for (const c of Object.keys(r)) {\n      if (c !== \"id\") {\n        colIdSet.add(c);\n      }\n    }\n  }\n  const result: BulkColValues = {};\n  for (const colId of colIdSet) {\n    result[colId] = records.map(r => r[colId]!);\n  }\n  return result;\n}\n\n/**\n * Extract the col ids mentioned in a record-related DocAction as a list\n * (even if the action is not a bulk action). Returns undefined if no col ids\n * mentioned.\n */\nexport function getColIdsFromDocAction(docActions: RemoveRecord | BulkRemoveRecord | AddRecord |\n  BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData |\n  TableDataAction): string[] | undefined {\n  if (docActions[3]) { return Object.keys(docActions[3]); }\n  return undefined;\n}\n\n/**\n * Extract column values for a particular column as CellValue[] from a\n * record-related DocAction. Undefined if absent.\n */\nexport function getColValuesFromDocAction(docAction: RemoveRecord | BulkRemoveRecord | AddRecord |\n  BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData |\n  TableDataAction, colId: string): CellValue[] | undefined {\n  const colValues = docAction[3];\n  if (!colValues) { return undefined; }\n  const cellValues = colValues[colId];\n  if (!cellValues) { return undefined; }\n  if (Array.isArray(docAction[2])) {\n    return cellValues as CellValue[];\n  } else {\n    return [cellValues as CellValue];\n  }\n}\n\n/**\n * Converts a bulk-like data action to its non-bulk equivalent. For actions like TableData or ReplaceTableData\n * it will return a list of single-row actions, one for each row.\n */\nexport function* getSingleAction(a: DataAction): Iterable<SingleDataAction | ReplaceTableData | TableDataAction> {\n  if (isBulkAddRecord(a)) {\n    const [, tableId, rowIds, colValues] = a;\n    for (let i = 0; i < rowIds.length; i++) {\n      yield [\"AddRecord\", tableId, rowIds[i], getRowFromBulkColValues(colValues, i)];\n    }\n  } else if (isBulkRemoveRecord(a)) {\n    const [, tableId, rowIds] = a;\n    for (const rowId of rowIds) {\n      yield [\"RemoveRecord\", tableId, rowId];\n    }\n  } else if (isBulkUpdateRecord(a)) {\n    const [, tableId, rowIds, colValues] = a;\n    for (let i = 0; i < rowIds.length; i++) {\n      yield [\"UpdateRecord\", tableId, rowIds[i], getRowFromBulkColValues(colValues, i)];\n    }\n  } else if (a[0] === \"TableData\" || a[0] === \"ReplaceTableData\") {\n    const [actionName, tableId, rowIds, colValues] = a;\n    for (let i = 0; i < rowIds.length; i++) {\n      yield [actionName, tableId, [rowIds[i]],\n        Object.fromEntries(Object.entries(colValues).map(([colId, values]) => [colId, [values[i]]])),\n      ];\n    }\n  } else {\n    yield a;\n  }\n}\n\nexport function getRowFromBulkColValues(colValues: BulkColValues, idx: number): ColValues {\n  return Object.fromEntries(Object.entries(colValues).map(([colId, values]) => [colId, values[idx]]));\n}\n"
  },
  {
    "path": "app/common/DocComments.ts",
    "content": "import { makeAnchorLinkValue } from \"app/common/gristUrls\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { SchemaTypes } from \"app/common/schema\";\n\n/**\n * Comment data stored in the `content` field of a cell.\n * Notice: this is JSON data created by user, so all fields are not guaranteed to be present or\n * be trusted.\n */\nexport interface CommentContent {\n  /** Text of a comment, safe markdown string as in markdown columns  */\n  text: string;\n  /**\n   * User name of the person who created the comment.\n   * Notice: this is rather a signature of the user, not a real user name,\n   * it is not guaranteed to be secure, as user might change it through the API.\n   */\n  userName?: string | null;\n  /**\n   * Time when the comment was created. Timestamp in milliseconds since epoch.\n   */\n  timeCreated?: number | null;\n  /**\n   * Time when the comment was last updated.\n   */\n  timeUpdated?: number | null;\n  /**\n   * Whether the comment was marked as resolved.\n   */\n  resolved?: boolean | null;\n  /**\n   * User name of the person who resolved the comment, defaults to the user who created it.\n   */\n  resolvedBy?: string | null;\n  /**\n   * List of user refs mentioned in the comment. The extraction is done by the client code, so\n   * it is not guaranteed to be secure or trusted.\n   */\n  mentions?: string[] | null;\n\n  /**\n   * Id of a section where the comment was created.\n   */\n  sectionId?: number | null;\n\n  /** Relative anchor link for a cell  */\n  anchorLink?: string | null;\n}\n\n/**\n * TODO: This is just a skeleton of what we need to extract comment data for notifications\n * purposes.\n */\n\nexport interface DocComment {\n  id: number;\n  text: string;\n  anchorLink: string;\n  /** Anyone in the comment thread (anyone who replied, commented or was mentioned in a cell) */\n  audience: string[]; // List of user refs who are part of the comment thread\n  mentions: string[]; // List of user refs mentioned in the comment\n}\n\nexport type CellRecord = SchemaTypes[\"_grist_Cells\"];\n\n/**\n * Builds a `DocComment` for notifications by parsing cell content, generating an anchor link,\n * and replacing markdown-style mentions with plain text mentions.\n *\n * @param record - Comment's data (a record from _grist_Cell).\n * @param audience - User refs in the comment thread.\n * @param mentions - User refs mentioned in this comment.\n */\nexport function makeDocComment(\n  record: CellRecord,\n  audience: string[],\n  mentions: string[],\n): DocComment | null {\n  const parsed = safeJsonParse(record.content, {}) as CommentContent;\n  if (!parsed.text) { return null; }\n\n  const anchorLink = parsed.anchorLink ?? makeAnchorLinkValue({\n    rowId: record.rowId,\n    colRef: record.colRef,\n    sectionId: parsed.sectionId ?? undefined,\n    comments: true,\n  });\n\n  return {\n    id: record.rowId,\n    text: replaceMentionsInText(parsed.text),\n    anchorLink,\n    audience,\n    mentions,\n  };\n}\n\ntype MentionChunk =\n  | string |\n  { name: string; ref: string };\n\nconst mentionRegex = /\\[(@[^\\]]+?)\\]\\(user:(\\w+)\\)/;\n\n/**\n * Splits a string into chunks of plain text and mention objects.\n * Each mention is of the form [@name](user:ref).\n *\n * Example:\n *   Input: \"Hello [@Alice](user:123) and [@Bob](user:456)\"\n *   Output:\n *     [\n *       \"Hello \",\n *       { name: \"@Alice\", ref: \"123\" },\n *       \" and \",\n *       { name: \"@Bob\", ref: \"456\" }\n *     ]\n *\n *    Input: \"No mentions here\"\n *    Output: [\"No mentions here\"]\n *\n *    Input: \"[@Alice](user:123)\"\n *    Output: [{ name: \"@Alice\", ref: \"123\" }]\n */\nexport function splitTextWithMentions(text: string): MentionChunk[] {\n  if (!text) { return []; }\n\n  // Use split to divide the string by mentionRegex, the result will look like:\n  // for single mention [text1, name1, ref1, text2]\n  // for no mentions [text1] if there are no mentions\n  // for multiple mentions [text1, name1, ref1, text2, name2, ref2, text3]\n  const parts = text.split(mentionRegex);\n  const chunks: MentionChunk[] = [];\n  for (let i = 0; i < parts.length; i += 3) {\n    // Always push the plain text part\n    if (parts[i]) {\n      chunks.push(parts[i]);\n    }\n\n    // If there is anything after the text part, it should be a mention\n    if (i + 2 < parts.length) {\n      const name = parts[i + 1];\n      const ref = parts[i + 2];\n      chunks.push({ name, ref });\n    }\n  }\n\n  return chunks;\n}\n\nfunction replaceMentionsInText(text: string) {\n  // Very simple replacement of links mentions.\n  // [@user](user:XXXXX) -> @user\n  // Also, replace 'nbsp' characters (non-breaking spaces) with regular spaces in this text\n  // version. (E.g. in Gmail, they seem to cause 'Message clipped' footer.)\n  return splitTextWithMentions(text)\n    .map(chunk => typeof chunk === \"string\" ? chunk : chunk.name)\n    .join(\"\")\n    .replace(/\\u00A0/g, \" \");\n}\n\nexport function getMentions(cellContent: string): string[] {\n  const content = safeJsonParse(cellContent, {}) as CommentContent;\n  return content.mentions || [];\n}\n"
  },
  {
    "path": "app/common/DocData.ts",
    "content": "/**\n * DocData maintains all underlying data for a Grist document, knows how to load it,\n * subscribes to actions which change it, and forwards those actions to individual tables.\n * It also provides the interface to apply actions to data.\n */\nimport { ActionDispatcher } from \"app/common/ActionDispatcher\";\nimport { TableFetchResult } from \"app/common/ActiveDocAPI\";\nimport {\n  BulkColValues, ColInfo, ColInfoWithId, ColValues, DocAction,\n  getColIdsFromDocAction,\n  RowRecord, TableDataAction,\n} from \"app/common/DocActions\";\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { schema, SchemaTypes } from \"app/common/schema\";\nimport { ColTypeMap, MetaRowRecord, MetaTableData, TableData } from \"app/common/TableData\";\n\nimport fromPairs from \"lodash/fromPairs\";\nimport groupBy from \"lodash/groupBy\";\n\ntype FetchTableFunc = (tableId: string) => Promise<TableFetchResult>;\n\nexport class DocData extends ActionDispatcher {\n  private _tables = new Map<string, TableData>();\n\n  private _fetchTableFunc: (tableId: string) => Promise<TableDataAction>;\n\n  /**\n   * If metaTableData is not supplied, then any tables needed should be loaded manually,\n   * using syncTable(). All column types will be set to Any, which will affect default\n   * values.\n   */\n  constructor(fetchTableFunc: FetchTableFunc, metaTableData: { [tableId: string]: TableDataAction } | null) {\n    super();\n    // Wrap fetchTableFunc slightly to handle any extra attachment data that\n    // may come along for the ride.\n    this._fetchTableFunc = async (tableId: string) => {\n      const { tableData, attachments } = await fetchTableFunc(tableId);\n      if (attachments) {\n        // Back-end doesn't keep track of which attachments we already have,\n        // so there may be duplicates of rows we already have - but happily\n        // BulkAddRecord overwrites duplicates now.\n        this.receiveAction(attachments);\n      }\n      return tableData;\n    };\n    if (metaTableData === null) { return; }\n    // Create all meta tables, and populate data we already have.\n    for (const tableId in schema) {\n      if (schema.hasOwnProperty(tableId)) {\n        const colTypes: ColTypeMap = (schema as any)[tableId];\n        this._tables.set(tableId, this.createTableData(tableId, metaTableData[tableId] || null, colTypes));\n      }\n    }\n\n    // Build a map from tableRef to [columnRecords]\n    const colsByTable = groupBy(this._tables.get(\"_grist_Tables_column\")!.getRecords(), \"parentId\");\n    for (const t of this._tables.get(\"_grist_Tables\")!.getRecords()) {\n      const tableId = t.tableId as string;\n      const colRecords: RowRecord[] = colsByTable[t.id] || [];\n      const colTypes = fromPairs(colRecords.map(c => [c.colId, c.type]));\n      this._tables.set(tableId, this.createTableData(tableId, null, colTypes));\n    }\n  }\n\n  /**\n   * Creates a new TableData object. A derived class may override to return an object derived from TableData.\n   */\n  public createTableData(tableId: string, tableData: TableDataAction | null, colTypes: ColTypeMap): TableData {\n    return new (tableId in schema ? MetaTableData : TableData)(tableId, tableData, colTypes);\n  }\n\n  /**\n   * Returns the TableData object for the requested table.\n   */\n  public getTable(tableId: string): TableData | undefined {\n    return this._tables.get(tableId);\n  }\n\n  public async requireTable(tableId: string): Promise<TableData> {\n    await this.fetchTable(tableId);\n    const td = this._tables.get(tableId);\n    if (!td) {\n      throw new Error(`could not fetch table: ${tableId}`);\n    }\n    return td;\n  }\n\n  /**\n   * Like getTable, but the result knows about the types of its records\n   */\n  public getMetaTable<TableId extends keyof SchemaTypes>(tableId: TableId): MetaTableData<TableId> {\n    return this.getTable(tableId) as any;\n  }\n\n  /**\n   * Returns an unsorted list of all tableIds in this doc, including both metadata and user tables.\n   */\n  public getTables(): ReadonlyMap<string, TableData> {\n    return this._tables;\n  }\n\n  /**\n   * Fetches the data for tableId if needed, and returns a promise that is fulfilled when the data\n   * is loaded.\n   */\n  public async fetchTable(tableId: string, force?: boolean): Promise<void> {\n    const table = this._tables.get(tableId);\n    if (!table) { throw new Error(`DocData.fetchTable: unknown table ${tableId}`); }\n    if (!table.isLoaded || force) {\n      await table.fetchData(this._fetchTableFunc);\n    }\n  }\n\n  /**\n   * Fetches the data for tableId unconditionally. If metadata for the table is not known,\n   * columns will be assumed to have type 'Any'.\n   */\n  public async syncTable(tableId: string): Promise<void> {\n    const meta = this._tables.get(tableId);  // Not required, but respected if available.\n    const tableData = await this._fetchTableFunc(tableId);\n    const colTypes = fromPairs((getColIdsFromDocAction(tableData) ?? []).map(c => [c, meta?.getColType(c) ?? \"Any\"]));\n    colTypes.id = \"Any\";\n    this._tables.set(tableId, this.createTableData(tableId, tableData, colTypes));\n  }\n\n  /**\n   * Handles an action received from the server, by forwarding it to the appropriate TableData\n   * object.\n   */\n  public receiveAction(action: DocAction): void {\n    // Look up TableData before processing the action in case we rename or remove it.\n    const tableId: string = action[1];\n    const table = this._tables.get(tableId);\n\n    this.dispatchAction(action);\n\n    // Forward all actions to per-table TableData objects.\n    if (table) {\n      table.receiveAction(action);\n    }\n  }\n\n  public receiveActions(actions: DocAction[]): void {\n    actions.forEach(action => this.receiveAction(action));\n  }\n\n  public docInfo(): MetaRowRecord<\"_grist_DocInfo\"> {\n    const docInfoTable = this.getMetaTable(\"_grist_DocInfo\");\n    return docInfoTable.getRecord(1)!;\n  }\n\n  public docSettings(): DocumentSettings {\n    return safeJsonParse(this.docInfo().documentSettings, {});\n  }\n\n  // ---- The following methods implement ActionDispatcher interface ----\n\n  protected onAddTable(action: DocAction, tableId: string, columns: ColInfoWithId[]): void {\n    const colTypes = fromPairs(columns.map(c => [c.id, c.type]));\n    this._tables.set(tableId, this.createTableData(tableId, null, colTypes));\n  }\n\n  protected onRemoveTable(action: DocAction, tableId: string): void {\n    this._tables.delete(tableId);\n  }\n\n  protected onRenameTable(action: DocAction, oldTableId: string, newTableId: string): void {\n    const table = this._tables.get(oldTableId);\n    if (table) {\n      this._tables.set(newTableId, table);\n      this._tables.delete(oldTableId);\n    }\n  }\n\n  protected onAddRecord(action: DocAction, tableId: string, rowId: number, colValues: ColValues): void {}\n  protected onUpdateRecord(action: DocAction, tableId: string, rowId: number, colValues: ColValues): void {}\n  protected onRemoveRecord(action: DocAction, tableId: string, rowId: number): void {}\n\n  protected onBulkAddRecord(action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void {}\n  protected onBulkUpdateRecord(action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void {}\n  protected onBulkRemoveRecord(action: DocAction, tableId: string, rowIds: number[]) {}\n\n  protected onReplaceTableData(action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void {}\n\n  protected onAddColumn(action: DocAction, tableId: string, colId: string, colInfo: ColInfo): void {}\n  protected onRemoveColumn(action: DocAction, tableId: string, colId: string): void {}\n  protected onRenameColumn(action: DocAction, tableId: string, oldColId: string, newColId: string): void {}\n  protected onModifyColumn(action: DocAction, tableId: string, colId: string, colInfo: ColInfo): void {}\n}\n"
  },
  {
    "path": "app/common/DocDataCache.ts",
    "content": "import { AlternateActions, AlternateStorage, ProcessedAction } from \"app/common/AlternateActions\";\nimport { DocAction, UserAction } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\n\nimport max from \"lodash/max\";\n\n/**\n * An implementation of an in-memory storage that can handle UserActions,\n * generating DocActions and retValues that work as for regular storage.\n * It shares an implementation with on-demand tables.\n */\nexport class DocDataCache implements AlternateStorage {\n  public docData: DocData;\n  private _altActions: AlternateActions;\n  constructor(actions?: DocAction[]) {\n    this.docData = new DocData(\n      async (tableId) => {\n        throw new Error(`no ${tableId}`);\n      },\n      null,\n    );\n    this._altActions = new AlternateActions(this);\n    for (const action of actions || []) {\n      this.docData.receiveAction(action);\n    }\n  }\n\n  public async sendTableActions(actions: UserAction[]): Promise<ProcessedAction[]> {\n    const results: ProcessedAction[] = [];\n    for (const userAction of actions) {\n      const processedAction = await this._altActions.processUserAction(userAction);\n      results.push(processedAction);\n      for (const storedAction of processedAction.stored) {\n        this.docData.receiveAction(storedAction);\n      }\n    }\n    return results;\n  }\n\n  public async fetchActionData(tableId: string, rowIds: number[], colIds?: string[]) {\n    const table = await this.docData.requireTable(tableId);\n    return table.getTableDataAction(\n      rowIds,\n      colIds,\n    );\n  }\n\n  public async getNextRowId(tableId: string): Promise<number> {\n    const table = await this.docData.requireTable(tableId);\n    return (max(table.getRowIds()) || 0) + 1;\n  }\n}\n"
  },
  {
    "path": "app/common/DocLimits.ts",
    "content": "import { DataLimitInfo, DataLimitStatus, DocumentUsage } from \"app/common/DocUsage\";\nimport { Features } from \"app/common/Features\";\nimport { APPROACHING_LIMIT_RATIO, getUsageRatio } from \"app/common/Limits\";\n\nimport moment from \"moment-timezone\";\n\nexport interface GetDataLimitStatusParams {\n  docUsage: DocumentUsage | null;\n  productFeatures: Features | undefined;\n  gracePeriodStart: Date | null;\n}\n\n/**\n * Given a set of params that includes document usage, current product features, and\n * a grace-period start (if any), returns the data limit status of a document.\n */\nexport function getDataLimitInfo(params: GetDataLimitStatusParams): DataLimitInfo {\n  const { docUsage, productFeatures, gracePeriodStart } = params;\n  const ratio = getDataLimitRatio(docUsage, productFeatures);\n  if (ratio > 1) {\n    const start = gracePeriodStart;\n    // In case we forgot to define a grace period, we'll default to two weeks.\n    const days = productFeatures?.gracePeriodDays ?? 14;\n    const daysRemaining = start && days ? days - moment().diff(moment(start), \"days\") : NaN;\n    if (daysRemaining > 0) {\n      return { status: \"gracePeriod\", daysRemaining };\n    } else {\n      return { status: \"deleteOnly\" };\n    }\n  } else if (ratio > APPROACHING_LIMIT_RATIO) {\n    return { status: \"approachingLimit\" };\n  }\n\n  return { status: null };\n}\n\n/**\n * Given `docUsage` and `productFeatures`, returns the highest usage ratio\n * across all data-related limits (currently only row count, data size and attachment size).\n */\nexport function getDataLimitRatio(\n  docUsage: DocumentUsage | null,\n  productFeatures: Features | undefined,\n): number {\n  if (!docUsage) { return 0; }\n\n  const { rowCount, dataSizeBytes, attachmentsSizeBytes } = docUsage;\n  const maxRows = productFeatures?.baseMaxRowsPerDocument;\n  const maxDataSize = productFeatures?.baseMaxDataSizePerDocument;\n  const maxAttachmentsSizeBytes = productFeatures?.baseMaxAttachmentsBytesPerDocument;\n  const rowRatio = getUsageRatio(rowCount?.total, maxRows);\n  const dataSizeRatio = getUsageRatio(dataSizeBytes, maxDataSize);\n  const attachmentSizeRatio = getUsageRatio(attachmentsSizeBytes, maxAttachmentsSizeBytes);\n  return Math.max(rowRatio, dataSizeRatio, attachmentSizeRatio);\n}\n\n/**\n * Maps `dataLimitStatus` status to an integer and returns it; larger integer\n * values indicate a more \"severe\" status.\n *\n * Useful for relatively comparing the severity of two DataLimitStatus values.\n */\nexport function getSeverity(dataLimitStatus: DataLimitStatus): number {\n  switch (dataLimitStatus) {\n    case null: { return 0; }\n    case \"approachingLimit\": { return 1; }\n    case \"gracePeriod\": { return 2; }\n    case \"deleteOnly\": { return 3; }\n  }\n}\n"
  },
  {
    "path": "app/common/DocListAPI.ts",
    "content": "import { MinimalActionGroup } from \"app/common/ActionGroup\";\nimport { TableDataAction } from \"app/common/DocActions\";\nimport { FilteredDocUsageSummary } from \"app/common/DocUsage\";\nimport { Role } from \"app/common/roles\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { UserInfo } from \"app/common/User\";\nimport { FullUser } from \"app/common/UserAPI\";\n\n// Possible flavors of items in a list of documents.\nexport type DocEntryTag = \"\" | \"sample\" | \"invite\" | \"shared\";\n\nexport const OpenDocMode = StringUnion(\n  \"default\",  // open doc with user's maximal access level\n  \"view\",     // open doc limited to view access (if user has at least that level of access)\n  \"fork\",     // as for 'view', but suggest a fork on any attempt to edit - the client will\n  // enable the editing UI experience and trigger a fork on any edit.\n);\nexport type OpenDocMode = typeof OpenDocMode.type;\n\n/**\n * A collection of options for opening documents on behalf of\n * a user in special circumstances we have accumulated. This is\n * specifically for the Grist front end, when setting up a websocket\n * connection to a doc worker willing to serve a document. Remember\n * that the front end is untrusted and any information it passes should\n * be treated as user input.\n */\nexport interface OpenDocOptions {\n  /**\n   * Users may now access a single specific document (with a given docId)\n   * using distinct keys for which different access rules apply. When opening\n   * a document, the ID used in the URL may now be passed along so\n   * that the back-end can grant appropriate access.\n   */\n  originalUrlId?: string;\n\n  /**\n   * Access to a document by a user may be voluntarily limited to\n   * read-only, or to trigger forking on edits.\n   */\n  openMode?: OpenDocMode;\n\n  /**\n   * Access to a document may be modulated by URL parameters.\n   * These parameters become an attribute of the user, for\n   * access control.\n   */\n  linkParameters?: Record<string, string>;\n}\n\n/**\n * Represents an entry in the DocList.\n */\nexport interface DocEntry {\n  docId?: string;       // Set for shared docs and invites\n  name: string;\n  mtime?: Date;\n  size?: number;\n  tag: DocEntryTag;\n  senderName?: string;\n  senderEmail?: string;\n}\n\nexport interface DocCreationInfo {\n  id: string;\n  title: string;\n}\n\n/**\n * This documents the members of the structure returned when a local\n * grist document is opened.\n */\nexport interface OpenLocalDocResult {\n  docFD: number;\n  clientId: string;  // the docFD is meaningful only in the context of this session\n  doc: { [tableId: string]: TableDataAction };\n  log: MinimalActionGroup[];\n  isTimingOn: boolean;\n  user: UserInfo;\n  recoveryMode?: boolean;\n  userOverride?: UserOverride;\n  docUsage?: FilteredDocUsageSummary;\n}\n\nexport interface UserOverride {\n  user: FullUser | null;\n  access: Role | null;\n}\n\nexport interface DocListAPI {\n  /**\n   * Returns a all known Grist documents and document invites to show in the doc list.\n   */\n  getDocList(): Promise<{ docs: DocEntry[], docInvites: DocEntry[] }>;\n\n  /**\n   * Creates a new document, fetches it, and adds a table to it. Returns its name.\n   */\n  createNewDoc(): Promise<string>;\n\n  /**\n   * Makes a copy of the given sample doc. Returns the name of the new document.\n   */\n  importSampleDoc(sampleDocName: string): Promise<string>;\n\n  /**\n   * Processes an upload, containing possibly multiple files, to create a single new document, and\n   * returns the new document's name.\n   */\n  importDoc(uploadId: number): Promise<string>;\n\n  /**\n   * Deletes a Grist document. Returns the name of the deleted document. If `deletePermanently` is\n   * true, the doc is deleted permanently rather than just moved to the trash.\n   */\n  deleteDoc(docName: string, deletePermanently: boolean): Promise<string>;\n\n  /**\n   * Renames a document.\n   */\n  renameDoc(oldName: string, newName: string): Promise<void>;\n\n  /**\n   * Opens a document, loads it, subscribes to its userAction events, and returns its metadata.\n   */\n  openDoc(userDocName: string, options?: OpenDocOptions): Promise<OpenLocalDocResult>;\n}\n"
  },
  {
    "path": "app/common/DocSchemaImport.ts",
    "content": "import { ApplyUAResult } from \"app/common/ActiveDocAPI\";\nimport { UserAction } from \"app/common/DocActions\";\nimport { ExistingDocSchema, ExistingTableSchema } from \"app/common/DocSchemaImportTypes\";\nimport { RecalcWhen } from \"app/common/gristTypes\";\nimport { TableMetadata } from \"app/plugin/DocApiTypes\";\nimport { GristType } from \"app/plugin/GristData\";\n\nimport cloneDeep from \"lodash/cloneDeep\";\n\n/**\n * A self-contained schema for a Grist document, that can be declared, validated and then used\n * to generate Grist tables and columns.\n *\n * Acts as an intermediate target for external import tools, e.g.\n *     Airtable base -> Airtable importer -> Grist import schema -> Grist document\n *\n * Supports internal references, where one element of the import schema references another.\n * These are translated to real Grist tables / columns during Grist doc creation.\n */\nexport interface ImportSchema {\n  tables: TableImportSchema[];\n}\n\nexport interface TableImportSchema {\n  // Original ID of the table in the source (e.g. airtable), or an arbitrary ID\n  // Can be referenced in other parts of the schema, and will be converted to a real Grist id during import.\n  originalId: string;\n  // ID / name the table should have in Grist. This will be transformed during import and won't match exactly.\n  desiredGristId: string;\n  columns: ColumnImportSchema[];\n}\n\nexport interface ColumnImportSchema {\n  // Original ID of the column in the source (e.g. airtable), or an arbitrary ID.\n  // Must be unique within the table, but needs not be globally unique.\n  // Can be referenced in other parts of the schema, and will be converted to a real Grist id during import.\n  originalId: string;\n  // ID the column should have in Grist. This will be transformed during import and won't match exactly.\n  desiredGristId: string;\n  // Grist column type.\n  type: GristType;\n  // Is the column a formula column (and not a data column)? False with `formula` set for trigger formulas.\n  isFormula?: boolean;\n  // Formula for the column. See FormulaTemplate docs for more information on format.\n  formula?: FormulaTemplate;\n  // Label for the column - will be preserved exactly.\n  label?: string;\n  // Description for the column.\n  description?: string;\n  // TODO - Only allow null until ID mapping is implemented\n  recalcDeps?: /* { originalColId: string }[] | */ null;\n  // When the trigger formula (if provided) will be recalculated.\n  recalcWhen?: RecalcWhen;\n  // If this column is a reference column, sets the table that's referenced.\n  // If a column reference is provided (instead of a table reference), shows that columns value.\n  ref?: TableRef | ColRef;\n  // Prevents column id changing when label is changed when True.\n  untieColIdFromLabel?: boolean;\n  // Options for the column's display (e.g. currency formatting). Varies based on column type.\n  widgetOptions?: Record<string, any>;\n}\n\n/**\n * Formula columns are often needed to replicate columns found in other tools,\n * such as Airtable's count column.\n *\n * An import schema doesn't know the IDs of the final Grist columns that will be created, due to\n * Grist transforming column IDs when they're created (e.g. \"My Column \" becomes \"My_Column_\").\n * Pre-calculating the ID isn't guaranteed to match - especially if the code changes over time.\n *\n * The formula template allows a formula to be written with placeholders (e.g. `len([R0])`),\n * which are later replaced by the real Grist table or column ids after they're created.\n *\n * The `replacements` field specifies what will be substituted. These replacements can be:\n * - References to tables or columns within the schema - which are transformed into real Grist\n *   ids before being substituted.\n * - References to existing tables or columns in the document - these are preserved as-is.\n *\n * E.g.\n * - { originalTableId: \"32\", originalColId: \"10\" } with `len($[R0])` will possibly become\n * `len($Col10)\n * - { originalTableId: \"32\" } with `[R0].lookupOne()` will possibly become `MyTable32.lookupOne()`\n * - { existingTableId: \"Table1\" } with `[R0].lookupOne()` will definitely become\n * `Table1.lookupOne()`\n *\n * Square brackets are used to prevent collisions with Javascript/Python template syntax.\n */\nexport interface FormulaTemplate {\n  formula: string,\n  replacements?: (TableRef | ColRef)[],\n}\n\n/**\n * Reference to a table within the import schema (i.e. using the table's originalId);\n */\nexport interface OriginalTableRef {\n  originalTableId: string;\n  // 'never' types below allow convenient type guard usage\n  // e.g. `if (ref.originalTableId && ref.originalColId === undefined)` will narrow any ref to an\n  // OriginalTableRef.\n  originalColId?: never;\n\n  existingTableId?: never;\n  existingColId?: never;\n}\n\n/**\n * Reference to a table that already exists in the Grist doc. Uses the table's Grist id.\n */\ninterface ExistingTableRef {\n  originalTableId?: never;\n  originalColId?: never;\n\n  existingTableId: string;\n  existingColId?: never;\n}\n\n// Any table reference - can be narrowed using `ref.originalTableId !== undefined`\ntype TableRef = OriginalTableRef | ExistingTableRef;\n\n/**\n * Reference to a column within the import schema (i.e. using the column's originalId);\n * A table ID also needs to be provided as column ids may not be unique.\n */\ninterface OriginalColRef {\n  existingTableId?: never;\n  existingColId?: never;\n\n  originalTableId: string;\n  originalColId: string;\n}\n\n/**\n * Reference to a column that already exists in the Grist doc. Uses the table and column's Grist id.\n */\ninterface ExistingColRef {\n  existingTableId: string;\n  existingColId: string;\n\n  originalTableId?: never;\n  originalColId?: never;\n}\n\n// Any column reference - can be narrowed using `ref.originalColumnId !== undefined`\ntype ColRef = OriginalColRef | ExistingColRef;\n\n// References to the import schema (i.e. using original id) can be resolved to an actual Grist\n// id, once tables / columns are created. This maps the types from a schema (original) reference to\n// an existing one.\ntype ResolvedRef<T> =\n  T extends (ExistingColRef | OriginalColRef) ? ExistingColRef :\n    T extends (ExistingTableRef | OriginalTableRef) ? ExistingTableRef :\n      T extends undefined ? undefined : never;\n\nexport type ApplyUserActionsFunc = (userActions: UserAction[]) => Promise<ApplyUAResult>;\n\n/**\n * Imports an ImportSchema, adding tables / columns to a document until it matches the schema's\n * contents.\n *\n * This will not modify existing tables, and should be entirely non-destructive.\n *\n * Generates and applies user actions, meaning this tool works anywhere a user action can be\n * applied\n * to a document.\n */\nexport class DocSchemaImportTool {\n  // Abstracting user action application allows this logic to work on both frontend and backend.\n  constructor(private _applyUserActions: ApplyUserActionsFunc) {\n  }\n\n  public async createTablesFromSchema(schema: ImportSchema) {\n    const warnings: DocSchemaImportWarning[] = [];\n    const tableSchemas = schema.tables;\n    const addTableActions: UserAction[] = [];\n\n    for (const tableSchema of tableSchemas) {\n      addTableActions.push([\n        \"AddTable\",\n        // This will be transformed into a valid id\n        tableSchema.desiredGristId,\n        tableSchema.columns.map(colInfo => ({\n          // This will be transformed into a valid id\n          id: colInfo.desiredGristId,\n          type: \"Any\",\n          isFormula: false,\n        })),\n      ]);\n    }\n\n    const tableCreationResults = (await this._applyUserActions(addTableActions)).retValues;\n\n    const tableIdsMap = new Map<string, TableIdsInfo>();\n\n    // This expects everything to have been created successfully, and therefore\n    // in order in the response - without any gaps.\n    tableSchemas.forEach((tableSchema, tableIndex) => {\n      const tableCreationResult = tableCreationResults[tableIndex];\n      const tableIds = {\n        originalId: tableSchema.originalId,\n        gristId: tableCreationResult.table_id as string,\n        gristRefId: tableCreationResult.id as number,\n        columnIdMap: new Map(),\n      };\n      tableIdsMap.set(tableSchema.originalId, tableIds);\n\n      tableSchema.columns.forEach((colSchema, colIndex) => {\n        tableIds.columnIdMap.set(colSchema.originalId, tableCreationResult.columns[colIndex] as string);\n      });\n    });\n\n    const refResolvers = makeResolveRefFuncs(tableIdsMap);\n    // Errors should only be thrown when:\n    // - An id is needed (e.g. the ids passed as targets for the user action)\n    // - The id will always exist if the import is running correctly (e.g. ids created in the previous step)\n    // Any other unresolved references (e.g. to existing columns that don't actually exist) should be warnings.\n    const { resolveRef, resolveRefOrThrow } = refResolvers;\n\n    const modifyColumnActions: UserAction[] = [];\n    for (const tableSchema of tableSchemas) {\n      for (const columnSchema of tableSchema.columns) {\n        let type: string = columnSchema.type;\n        const resolvedSchemaRef = resolveRef(columnSchema.ref);\n        if ([\"Ref\", \"RefList\"].includes(type)) {\n          if (columnSchema.ref && resolvedSchemaRef === undefined) {\n            warnings.push(new ColumnRefWarning(columnSchema, columnSchema.ref));\n          }\n\n          type = resolvedSchemaRef ?\n            `${columnSchema.type}:${resolvedSchemaRef.existingTableId}` :\n            \"Any\";\n        }\n\n        const existingColRef = resolveRefOrThrow({\n          originalTableId: tableSchema.originalId,\n          originalColId: columnSchema.originalId,\n        });\n\n        const preparedFormula = columnSchema.formula && prepareFormula(columnSchema.formula, refResolvers);\n        if (preparedFormula) {\n          warnings.push(...preparedFormula.warnings);\n        }\n\n        modifyColumnActions.push([\n          \"ModifyColumn\",\n          existingColRef.existingTableId,\n          existingColRef.existingColId,\n          {\n            type,\n            isFormula: columnSchema.isFormula ?? false,\n            formula: preparedFormula?.formula,\n            label: columnSchema.label,\n            // Need to decouple it - otherwise our stored column ids may now be invalid.\n            untieColIdFromLabel: columnSchema.label !== undefined,\n            description: columnSchema.description,\n            widgetOptions: JSON.stringify(columnSchema.widgetOptions),\n            visibleCol: resolvedSchemaRef?.existingColId,\n            recalcDeps: columnSchema.recalcDeps,\n            recalcWhen: columnSchema.recalcWhen,\n          },\n        ]);\n      }\n    }\n\n    await this._applyUserActions(modifyColumnActions);\n\n    return {\n      tableIdsMap,\n      warnings,\n    };\n  }\n\n  public async removeTables(tableIds: string[]): Promise<void> {\n    await this._applyUserActions(tableIds.map(id => [\n      \"RemoveTable\",\n      id,\n    ]));\n  }\n}\n\nexport function tablesToSchema(tables: TableMetadata[]): ExistingDocSchema {\n  const tableSchemas: ExistingTableSchema[] = [];\n  for (const { id: tableId, fields: { tableRef }, columns = [] } of tables) {\n    const tableSchema: ExistingTableSchema = { id: tableId, ref: tableRef, columns: [] };\n    for (const { id: colId, fields: { colRef, label, isFormula } } of columns) {\n      tableSchema.columns.push({ id: colId, ref: colRef, label, isFormula });\n    }\n    tableSchemas.push(tableSchema);\n  }\n  return { tables: tableSchemas };\n}\n\n/**\n * Implemented by all schema-related warnings. Should be used almost exclusively (instead of actual\n * warning types) to allow compatibility between the different places that generate warnings.\n *\n * \"Warnings\" here means anything the code thinks is important to show to the user, and may be\n * purely informational or a significant error.\n */\nexport interface DocSchemaImportWarning {\n  readonly message: string;\n  readonly ref?: TableRef | ColRef;\n}\n\nclass RefWarning implements DocSchemaImportWarning {\n  public readonly message: string;\n\n  constructor(public readonly ref: TableRef | ColRef) {\n    this.message = `Reference does not refer to a valid table or column: ${JSON.stringify(ref)}`;\n  }\n}\n\nclass ColumnRefWarning extends RefWarning {\n  public readonly message: string;\n\n  constructor(public readonly column: ColumnImportSchema, public readonly ref: TableRef | ColRef) {\n    super(ref);\n    this.message = `Reference column ${schemaItemDebugIds(column)} does not refer to a valid table or column: ${JSON.stringify(ref)}`;\n  }\n}\n\nclass FormulaRefWarning implements DocSchemaImportWarning {\n  public readonly message: string;\n\n  constructor(\n    public readonly formula: FormulaTemplate,\n    public readonly ref: TableRef | ColRef,\n  ) {\n    const formulaSnippet = formula.formula.trim().split(\"\\n\")[0].trim().substring(0, 40);\n    this.message = `Formula contains a reference to an invalid table or column: ${JSON.stringify(ref)} in formula \"${formulaSnippet}\"`;\n  }\n}\n\n/**\n * Checks the validity of an ImportSchema, raising warnings for any issues found.\n * The type system covers the majority of possible issues (e.g. missing properties) but not all\n * combinations of fields.\n *\n * This primarily deals with checking referential integrity, ensuring internal schema references\n * (original id references) are valid and that existing references point to a valid part of\n * the existing schema.\n */\nexport function validateImportSchema(schema: ImportSchema, existingSchema?: ExistingDocSchema) {\n  existingSchema = existingSchema ?? { tables: [] };\n  const warnings: DocSchemaImportWarning[] = [];\n\n  const tablesByOriginalId = new Map(schema.tables.map(table => [table.originalId, table]));\n  const existingTablesById = new Map(existingSchema.tables.map(table => [table.id, table]));\n\n  const isTableRefValid = (ref: TableRef) => Boolean(\n    ref.originalTableId && tablesByOriginalId.get(ref.originalTableId) !== undefined ||\n    ref.existingTableId && existingTablesById.get(ref.existingTableId) !== undefined,\n  );\n\n  // Checks that the existing table contains that column, or that the column exists if no table ref is provided.\n  const isExistingColRefValid = (ref: ExistingColRef) =>\n    existingTablesById.get(ref.existingTableId)?.columns.some(column => column.id === ref.existingColId);\n\n  // Checks that the original table contains that column, or that the column exists if no table ref is provided.\n  const isOriginalColRefValid = (ref: OriginalColRef) =>\n    tablesByOriginalId.get(ref.originalTableId)?.columns.some(column => column.originalId === ref.originalColId);\n\n  const isRefValid = (ref: TableRef | ColRef) =>\n    ref.existingColId !== undefined ? isExistingColRefValid(ref) :\n      ref.originalColId !== undefined ? isOriginalColRefValid(ref) :\n        isTableRefValid(ref);\n\n  schema.tables.forEach((tableSchema) => {\n    tableSchema.columns.forEach((columnSchema) => {\n      // Validate formula replacements\n      columnSchema.formula?.replacements?.forEach((replacement) => {\n        if (columnSchema.formula && !isRefValid(replacement)) {\n          warnings.push(new FormulaRefWarning(columnSchema.formula, replacement));\n        }\n      });\n\n      // Validate reference columns\n      if (columnSchema.ref && !isRefValid(columnSchema.ref)) {\n        warnings.push(new ColumnRefWarning(columnSchema, columnSchema.ref));\n      }\n    });\n  });\n\n  return warnings;\n}\n\nclass NoColumnInfoForRefWarning implements DocSchemaImportWarning {\n  public readonly message: string;\n\n  constructor(public readonly ref: TableRef | ColRef) {\n    this.message = `Could not find column information in the schema for this ref: ${JSON.stringify(ref)}`;\n  }\n}\n\nclass NoMatchingColumnWarning implements DocSchemaImportWarning {\n  public readonly message: string;\n\n  constructor(public readonly colSchema: ColumnImportSchema, public readonly ref: TableRef | ColRef) {\n    this.message = `Could not match column schema with an existing column: ${JSON.stringify(colSchema)}`;\n  }\n}\n\n/**\n * All transformations that can be applied to an import schema.\n */\nexport interface ImportSchemaTransformParams {\n  // Remove these tables from the schema. May result in invalid references that don't pass validation.\n  skipTableIds?: string[];\n  // Maps internal schema references to a table that already exists in the document.\n  // Implies that the table will be added to skipTableIds (and not created).\n  // Will attempt to automatically match column references with columns in the existing table.\n  mapExistingTableIds?: Map<string, string>;\n}\n\n/**\n * Applies one or more transformations to an import schema (see {ImportSchemaTransformParams}).\n *\n * @param {ImportSchema} schema Original schema to transform\n * @param {ImportSchemaTransformParams} params Transformations that should be applied.\n * @param {ExistingDocSchema} existingDocSchema Details of tables and columns in existing doc - used to map references\n * @returns {{schema: ImportSchema, warnings: DocSchemaImportWarning[]}} The transformed schema (a\n *  deep copy) and warnings for any issues with the transformed schema.\n */\nexport function transformImportSchema(\n  schema: ImportSchema,\n  params: ImportSchemaTransformParams,\n  existingDocSchema: ExistingDocSchema = { tables: [] },\n): { schema: ImportSchema, warnings: DocSchemaImportWarning[] } {\n  const warnings: DocSchemaImportWarning[] = [];\n  const newSchema = cloneDeep(schema);\n  const { mapExistingTableIds } = params;\n  const skipTableIds = params.skipTableIds ?? [];\n\n  if (mapExistingTableIds) {\n    skipTableIds.push(...mapExistingTableIds.keys());\n  }\n\n  // Skip tables - allow the validation step to pick up on any issues introduced.\n  newSchema.tables = newSchema.tables.filter(table => !skipTableIds.includes(table.originalId));\n\n  const mapRef = (originalRef: TableRef | ColRef) => {\n    const { ref, warning } = transformSchemaMapRef(schema, params, existingDocSchema, originalRef);\n    if (warning) {\n      warnings.push(warning);\n    }\n    return ref;\n  };\n\n  if (mapExistingTableIds) {\n    newSchema.tables.forEach((tableSchema) => {\n      tableSchema.columns.forEach((columnSchema) => {\n        // Manually map column properties to their existing table.\n        // This is slightly error-prone long term (as each new reference needs mapping here), but manually\n        // mapping fields is simple and easy for the moment.\n        columnSchema.ref = columnSchema.ref && mapRef(columnSchema.ref);\n\n        if (columnSchema.formula?.replacements) {\n          columnSchema.formula.replacements = columnSchema.formula.replacements.map(mapRef);\n        }\n      });\n    });\n  }\n  return { schema: newSchema, warnings };\n}\n\n// Maps a single reference in the import schema to a new reference for the transformed schema,\n// based on the requested transformations. May raise a warning if a problem is found.\nfunction transformSchemaMapRef(\n  schema: ImportSchema, params: ImportSchemaTransformParams,\n  existingDocSchema: ExistingDocSchema, ref: TableRef | ColRef,\n): { ref: TableRef | ColRef, warning?: DocSchemaImportWarning } {\n  const { mapExistingTableIds } = params;\n  const existingTableId = ref.originalTableId && mapExistingTableIds?.get(ref.originalTableId);\n  // Preserve the reference as-is if no mapping is found or needed.\n  if (!existingTableId) {\n    return { ref };\n  }\n\n  // No column id - only map the table id.\n  if (!ref.originalColId) {\n    return { ref: { existingTableId } };\n  }\n\n  const colSchema = schema\n    .tables.find(table => table.originalId === ref.originalTableId)\n    ?.columns.find(column => column.originalId === ref.originalColId);\n\n  const existingTableSchema = existingDocSchema?.tables.find(table => table.id === existingTableId);\n\n  if (!colSchema) {\n    return { ref, warning: new NoColumnInfoForRefWarning(ref) };\n  }\n\n  const matchingCol = existingTableSchema && findMatchingExistingColumn(colSchema, existingTableSchema);\n\n  if (!matchingCol) {\n    return { ref, warning: new NoMatchingColumnWarning(colSchema, ref) };\n  }\n\n  return { ref: { existingTableId, existingColId: matchingCol.id } };\n}\n\n// Given a column schema, attempts to find a corresponding column in an existing table.\nfunction findMatchingExistingColumn(colSchema: ColumnImportSchema, existingTable: ExistingTableSchema) {\n  return existingTable.columns.find(existingCol =>\n    colSchema.label !== undefined && colSchema.label === existingCol.label ||\n    colSchema.desiredGristId === existingCol.id,\n  );\n}\n\n// Converts a FormulaTemplate into a formula, resolving any IDs that need mapping and substituting\n// them into the formula.\nfunction prepareFormula(\n  template: FormulaTemplate, mappers: ReturnType<typeof makeResolveRefFuncs>,\n): { formula: string, warnings: DocSchemaImportWarning[] } {\n  const warnings: DocSchemaImportWarning[] = [];\n\n  if (!template.replacements || template.replacements.length === 0) {\n    return { formula: template.formula, warnings: [] };\n  }\n  return template.replacements.reduce(({ formula, warnings }, ref, index) => {\n    const resolvedRef = mappers.resolveRef(ref);\n\n    if (resolvedRef === undefined) {\n      warnings.push(new FormulaRefWarning(template, ref));\n    }\n\n    const replacementText = resolvedRef ?\n      (resolvedRef.existingColId ?? resolvedRef?.existingTableId) :\n      (ref.originalColId ? `unknown_column_${ref.originalColId}` : `unknown_table_${ref.originalTableId}`);\n\n    return {\n      formula: formula.replace(RegExp(`\\\\[R${index}\\\\]`, \"g\"), replacementText),\n      warnings,\n    };\n  }, { formula: template.formula, warnings });\n}\n\nexport class DocSchemaImportError extends Error {\n  constructor(message: string) {\n    super(message);\n  }\n}\n\n// Small helper function throwing an import error when a reference can't be resolved.\nfunction createUnresolvedRefError(ref: TableRef | ColRef) {\n  if (ref.originalColId) {\n    return new DocSchemaImportError(\n      `Couldn't find Grist column id for column '${ref.originalColId}' in table '${ref.originalTableId}'`,\n    );\n  }\n  return new DocSchemaImportError(`Couldn't locate Grist table id for table ${ref.originalTableId}`);\n}\n\n/**\n * Creates helper functions that resolve internal schema references that use `originalId` to\n * references to existing Grist entities (tables or columns).\n */\nfunction makeResolveRefFuncs(tableIdsMap: Map<string, TableIdsInfo>) {\n  /**\n   * Resolves any reference type to an ExistingXRef or undefined, if there's no mapping possible.\n   *\n   * Generic overloads greatly simplify type checking, by always returning the narrowest type\n   * possible. E.g. a table reference input will always result in a table reference returned. E.g.\n   * Avoids introducing undefined if it isn't necessary (existing references are always resolvable)\n   */\n  function resolveRef<T extends (ExistingTableRef | ExistingColRef | undefined)>(ref: T): ResolvedRef<T>;\n  function resolveRef<T extends (TableRef | ColRef | undefined)>(ref: T): ResolvedRef<T> | undefined;\n  function resolveRef(ref?: TableRef | ColRef): ExistingTableRef | ExistingColRef | undefined {\n    if (ref === undefined) { return undefined; }\n    if (ref.existingTableId !== undefined) { return ref; }\n    const tableIds = tableIdsMap.get(ref.originalTableId);\n    if (!tableIds) { return undefined; }\n    if (ref.originalColId === undefined) {\n      return { existingTableId: tableIds.gristId };\n    }\n    const colId = tableIds.columnIdMap.get(ref.originalColId);\n    if (colId === undefined) { return undefined; }\n    return { existingTableId: tableIds.gristId, existingColId: colId };\n  }\n\n  /**\n   * Wrapper for resolveRef that throws if the reference isn't mappable.\n   */\n  function resolveRefOrThrow<T extends (TableRef | ColRef)>(ref: T) {\n    const resolvedRef = resolveRef(ref);\n    if (resolvedRef === undefined) {\n      throw createUnresolvedRefError(ref);\n    }\n    return resolvedRef;\n  }\n\n  return {\n    resolveRef,\n    resolveRefOrThrow,\n  };\n}\n\ninterface TableIdsInfo {\n  originalId: string;\n  gristId: string;\n  gristRefId: number;\n  columnIdMap: Map<string, string>;\n}\n\nfunction schemaItemDebugIds({ originalId, desiredGristId }: { originalId: string, desiredGristId: string }) {\n  return `(Original Id: ${originalId}, Desired Grist Id: ${desiredGristId})`;\n}\n"
  },
  {
    "path": "app/common/DocSchemaImportTypes-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const ExistingDocSchema = t.iface([], {\n  \"tables\": t.array(\"ExistingTableSchema\"),\n});\n\nexport const ExistingTableSchema = t.iface([], {\n  \"id\": \"string\",\n  \"name\": t.opt(\"string\"),\n  \"ref\": t.opt(\"number\"),\n  \"columns\": t.array(\"ExistingColumnSchema\"),\n});\n\nexport const ExistingColumnSchema = t.iface([], {\n  \"id\": \"string\",\n  \"ref\": \"number\",\n  \"label\": t.opt(\"string\"),\n  \"isFormula\": \"boolean\",\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  ExistingDocSchema,\n  ExistingTableSchema,\n  ExistingColumnSchema,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/common/DocSchemaImportTypes.ts",
    "content": "// Minimal information needed from the existing document for the import to work.\nimport typeSuite from \"app/common/DocSchemaImportTypes-ti\";\n\nimport { CheckerT, createCheckers } from \"ts-interface-checker\";\n\nexport interface ExistingDocSchema {\n  tables: ExistingTableSchema[];\n}\n\nexport interface ExistingTableSchema {\n  id: string;\n  name?: string;\n  ref?: number;\n  columns: ExistingColumnSchema[];\n}\n\nexport interface ExistingColumnSchema {\n  id: string;\n  ref: number;\n  // Label is required for column matching to work correctly.\n  label?: string;\n  // Useful to import tools to know if a column is writable.\n  isFormula: boolean;\n}\n\nconst Checkers = createCheckers(typeSuite);\nexport const ExistingDocSchemaChecker = Checkers.ExistingDocSchema as CheckerT<ExistingDocSchema>;\nexport const ExistingTableSchemaChecker = Checkers.ExistingTableSchema as CheckerT<ExistingTableSchema>;\nexport const ExistingColumnSchemaChecker = Checkers.ExistingColumnSchema as CheckerT<ExistingColumnSchema>;\n"
  },
  {
    "path": "app/common/DocSnapshot.ts",
    "content": "/**\n * Core metadata about a single document version.\n */\nexport interface ObjSnapshot {\n  lastModified: string;\n  snapshotId: string;\n}\n\n/**\n * Extended Grist metadata about a single document version.  Names of fields are kept\n * short since there is a tight limit on total metadata size in S3.\n */\nexport interface ObjMetadata {\n  t?: string;     // timestamp\n  tz?: string;    // timezone\n  h?: string;     // actionHash\n  n?: number;     // actionNum\n  label?: string;\n}\n\nexport interface ObjSnapshotWithMetadata extends ObjSnapshot {\n  metadata?: ObjMetadata;\n}\n\n/**\n * Information about a single document snapshot in S3, including a Grist docId.\n */\nexport interface DocSnapshot extends ObjSnapshotWithMetadata {\n  docId: string;\n}\n\n/**\n * A collection of document snapshots.  Most recent snapshots first.\n */\nexport interface DocSnapshots {\n  snapshots: DocSnapshot[];\n}\n\n/**\n * Metadata format for external storage like S3 and Azure.\n * The only difference is that external metadata values must be strings.\n *\n * For S3, there are restrictions on total length of metadata (2 KB).\n * See: https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMetadata.html#UserMetadata\n */\ntype ExternalMetadata = Record<string, string>;\n\n/**\n * Convert metadata from internal Grist format to external storage format (string values).\n */\nexport function toExternalMetadata(metadata: ObjMetadata): ExternalMetadata {\n  const result: ExternalMetadata = {};\n  for (const [key, val] of Object.entries(metadata)) {\n    if (val !== undefined) { result[key] = String(val); }\n  }\n  return result;\n}\n\n/**\n * Select metadata controlled by Grist, and convert to expected formats.\n */\nexport function toGristMetadata(metadata: ExternalMetadata): ObjMetadata {\n  const result: ObjMetadata = {};\n  for (const key of [\"t\", \"tz\", \"h\", \"label\"] as const) {\n    if (metadata[key]) { result[key] = metadata[key]; }\n  }\n  if (metadata.n) { result.n = parseInt(metadata.n, 10); }\n  return result;\n}\n"
  },
  {
    "path": "app/common/DocState.ts",
    "content": "import { ActionSummary, createEmptyActionSummary } from \"app/common/ActionSummary\";\n// Because of -ti files, DocState needs to be in DocumentSettings.\nimport { DocState } from \"app/common/DocumentSettings\";\n\nexport type { DocState };\n\n/**\n * A list of document states.  Most recent is first.\n */\nexport interface DocStates {\n  states: DocState[];\n}\n\n/**\n * A comparison between two documents, called \"left\" and \"right\".\n * The comparison is based on the action histories in the documents.\n * If those histories have been truncated, the comparison may report\n * two documents as being unrelated even if they do in fact have some\n * shared history.\n */\nexport interface DocStateComparison {\n  left: DocState;         // left / local document\n  right: DocState;        // right / remote document\n  parent: DocState | null;  // most recent common ancestor of left and right\n  // summary of the relationship between the two documents.\n  //        same: documents have the same most recent state\n  //        left: the left document has actions not yet in the right\n  //       right: the right document has actions not yet in the left\n  //        both: both documents have changes (possible divergence)\n  //   unrelated: no common history found\n  summary: \"same\" | \"left\" | \"right\" | \"both\" | \"unrelated\";\n  // optionally, details of what changed may be included.\n  details?: DocStateComparisonDetails;\n}\n\n/**\n * Detailed comparison between document versions.  For now, this\n * is provided as a pair of ActionSummary objects, relative to\n * the most recent common ancestor.\n */\nexport interface DocStateComparisonDetails {\n  leftChanges: ActionSummary;\n  rightChanges: ActionSummary;\n}\n\n/** Create a DocStateComparison representing no differences. */\nexport function createEmptyDocStateComparison(): DocStateComparison {\n  return {\n    left: { n: 0, h: \"\" },\n    right: { n: 0, h: \"\" },\n    parent: { n: 0, h: \"\" },\n    summary: \"same\",\n    details: {\n      leftChanges: createEmptyActionSummary(),\n      rightChanges: createEmptyActionSummary(),\n    },\n  };\n}\n\nexport function removeMetadataChangesFromDetails(details: DocStateComparisonDetails) {\n  const { summary: leftChanges, hadMetadata: leftHadMetadata } = removeMetadataChangesFromSummary(details.leftChanges);\n  const { summary: rightChanges, hadMetadata: rightHadMetadata } =\n    removeMetadataChangesFromSummary(details.rightChanges);\n  return {\n    details: {\n      leftChanges,\n      rightChanges,\n    },\n    leftHadMetadata,\n    rightHadMetadata,\n  };\n}\n\nfunction removeMetadataChangesFromSummary(summary: ActionSummary) {\n  const result = createEmptyActionSummary();\n  result.tableRenames = summary.tableRenames;\n  const tables = Object.keys(summary.tableDeltas);\n  const metaTables = new Set();\n  for (const table of tables) {\n    if (table.startsWith(\"_grist_\")) {\n      metaTables.add(table);\n      continue;\n    }\n    result.tableDeltas[table] = summary.tableDeltas[table];\n  }\n  return {\n    summary: result,\n    hadMetadata: metaTables.size > 0,\n  };\n}\n"
  },
  {
    "path": "app/common/DocUsage.ts",
    "content": "export interface DocumentUsage {\n  rowCount?: RowCounts;\n  dataSizeBytes?: number;\n  attachmentsSizeBytes?: number;\n}\n\nexport interface RowCounts {\n  total: number;\n  [tableRef: number]: number;\n}\n\nexport type DataLimitStatus = \"approachingLimit\" | \"gracePeriod\" | \"deleteOnly\" | null;\nexport interface DataLimitInfo {\n  status: DataLimitStatus;\n  daysRemaining?: number;\n}\n\ntype DocUsageOrPending = {\n  [Metric in keyof Required<DocumentUsage>]: Required<DocumentUsage>[Metric] | \"pending\"\n};\n\nexport interface DocUsageSummary extends DocUsageOrPending {\n  dataLimitInfo: DataLimitInfo;\n}\n\n// Aggregate usage stats for an org.\nexport interface OrgUsageSummary {\n  // Count of non-removed documents in an org, grouped by data limit status.\n  countsByDataLimitStatus: Record<NonNullable<DataLimitStatus>, number>;\n  // Stats for aggregate attachment usage.\n  attachments: {\n    totalBytes: number;\n    limitExceeded?: boolean;\n  }\n}\n\nexport interface UsageRecommendations {\n  recommendExternal?: boolean;\n}\n\ntype FilteredDocUsage = {\n  [Metric in keyof DocUsageOrPending]: DocUsageOrPending[Metric] | \"hidden\"\n};\n\nexport interface FilteredDocUsageSummary extends FilteredDocUsage {\n  dataLimitInfo: DataLimitInfo;\n  usageRecommendations: UsageRecommendations;\n}\n\n/**\n * Returns an empty org usage summary with values initialized to 0.\n */\nexport function createEmptyOrgUsageSummary(): OrgUsageSummary {\n  return {\n    countsByDataLimitStatus: {\n      approachingLimit: 0,\n      gracePeriod: 0,\n      deleteOnly: 0,\n    },\n    attachments: {\n      totalBytes: 0,\n    },\n  };\n}\n"
  },
  {
    "path": "app/common/DocumentSettings-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const DocumentSettings = t.iface([], {\n  \"locale\": \"string\",\n  \"currency\": t.opt(\"string\"),\n  \"engine\": t.opt(\"EngineCode\"),\n  \"attachmentStoreId\": t.opt(\"string\"),\n  \"baseAction\": t.opt(\"DocState\"),\n});\n\nexport const EngineCode = t.lit(\"python3\");\n\nexport const DocState = t.iface([], {\n  \"n\": \"number\",\n  \"h\": \"string\",\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  DocumentSettings,\n  EngineCode,\n  DocState,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/common/DocumentSettings.ts",
    "content": "import DocumentSettingsTI from \"app/common/DocumentSettings-ti\";\n\nimport { CheckerT, createCheckers } from \"ts-interface-checker\";\n\nexport interface DocumentSettings {\n  locale: string;\n  currency?: string;\n  engine?: EngineCode;\n  // Grist attachments can be stored within the document (embedded in the SQLite file), or held\n  // externally. The attachmentStoreId expresses a preference for which store should be used for\n  // attachments in this doc. This store will be used for new attachments when they're added, and a\n  // process can be triggered to start transferring all attachments that aren't already in this\n  // store over to it. A full id is stored, rather than something more convenient like a boolean or\n  // the string \"external\", after thinking carefully about how downloads/uploads and transferring\n  // files to other installations could work.\n  attachmentStoreId?: string;\n  // action to treat as a starting point, e.g. when a fork or\n  // copy is made.\n  baseAction?: DocState;\n}\n\n/**\n * The back-end will for now support one engine,\n * a gvisor-backed python3.\n */\nexport type EngineCode = \"python3\";\n\n/**\n * Information about a single document state.\n */\nexport interface DocState {\n  n: number;  // a sequential identifier\n  h: string;  // a hash identifier\n}\n\nconst checkers = createCheckers(DocumentSettingsTI);\nexport const DocumentSettingsChecker = checkers.DocumentSettings as CheckerT<DocumentSettings>;\n"
  },
  {
    "path": "app/common/DropdownCondition.ts",
    "content": "import { CompiledPredicateFormula } from \"app/common/PredicateFormula\";\n\nexport interface DropdownCondition {\n  text: string;\n  parsed: string;\n}\n\nexport type DropdownConditionCompilationResult =\n  | DropdownConditionCompilationSuccess |\n  DropdownConditionCompilationFailure;\n\ninterface DropdownConditionCompilationSuccess {\n  kind: \"success\";\n  result: CompiledPredicateFormula;\n}\n\ninterface DropdownConditionCompilationFailure {\n  kind: \"failure\";\n  error: string;\n}\n"
  },
  {
    "path": "app/common/EncActionBundle.ts",
    "content": "/**\n * Types for encrypted ActionBundles that get sent between instances and hub.\n */\n\nimport { ActionInfo, Envelope } from \"app/common/ActionBundle\";\nimport { DocAction } from \"app/common/DocActions\";\n\n// Type representing a point in time as milliseconds since Epoch.\nexport type Timestamp = number;\n\n// Type representing binary data encoded as a base64 string.\nexport type Base64String = string;\n\n// Metadata about a symmetric encryption key.\nexport interface KeyInfo {\n  firstActionNum: number;     // ActionNum of first action for which this key was used.\n  firstUsedTime: Timestamp;   // Timestamp of first action for which this key was used.\n}\n\n// Encrypted symmetric key with metadata, sent from hub to instance with each envelope.\nexport interface EncKeyInfo extends KeyInfo {\n  encryptedKey: Base64String; // Symmetric key encrypted with the recipient's public key.\n}\n\n// Bundle of encryptions of the symmetric key. Note that the hub will store EncKeyBundles for\n// lookup, indexed by the combination {recipients: string[], firstActionNum: number}.\nexport interface EncKeyBundle extends KeyInfo {\n  encryptedKeys: {\n    // Map of instanceId to the symmetric key encrypted with that instance's public key.\n    // A single symmetric key is used for all, and only present here in encrypted form.\n    [instanceId: string]: Base64String;\n  };\n}\n\n// This allows reorganizing ActionBundle by envelope while preserving order information for\n// actions. E.g. if ActionBundle contains {stored: [(0,A), (1,B), (2,C), (0,D)], then we'll have:\n//    - in envelopes 0: {stored: [[0, A], [3, D]]}\n//    - in envelopes 1: {stored: [[1, B]]}\n//    - in envelopes 2: {stored: [[2, C]]}\n// Then recipients of multiple envelopes can sort actions by index to get their correct order.\nexport interface DecryptedEnvelopeContent {\n  info?: ActionInfo;\n  // number is the index into the bundle-wide array of 'stored' or 'calc' DocActions.\n  stored: [number, DocAction][];\n  calc: [number, DocAction][];\n}\n\nexport type DecryptedEnvelope = Envelope & DecryptedEnvelopeContent;\n\n// Sent from instance to hub.\nexport interface EncEnvelopeToHub extends Envelope {\n  encKeyReused?: number;        // If reusing a key, firstActionNum of the key being reused.\n  encKeyBundle?: EncKeyBundle;  // If created a new key, its encryption for all recipients.\n  content: Base64String;        // Marshalled and encrypted DecryptedEnvelopeContent as a base64 string.\n}\n\n// Sent from hub to instance.\nexport interface EncEnvelopeFromHub extends Envelope {\n  encKeyInfo: EncKeyInfo;\n  content: Base64String;        // Marshalled and encrypted DecryptedEnvelopeContent as a base64 string.\n}\n\n// EncActionBundle is an encrypted version of ActionBundle. It comes in two varieties, one for\n// sending ActionBundle to the hub, and one for receiving from the hub.\nexport interface EncActionBundle<EncEnvelope> {\n  actionNum: number;\n  actionHash: string | null;\n  parentActionHash: string | null;\n  envelopes: EncEnvelope[];\n}\n\nexport type EncActionBundleToHub = EncActionBundle<EncEnvelopeToHub>;\nexport type EncActionBundleFromHub = EncActionBundle<EncEnvelopeFromHub>;\n"
  },
  {
    "path": "app/common/ErrorWithCode.ts",
    "content": "import { OpenDocMode } from \"app/common/DocListAPI\";\n\ninterface ErrorDetails {\n  status?: number;\n  accessMode?: OpenDocMode;\n  memos?: string[];\n}\n\n/**\n *\n * An error with a human-readable message and a machine-readable code.\n * Makes it easier to change the human-readable message without breaking\n * error handlers.\n *\n */\nexport class ErrorWithCode extends Error {\n  constructor(public code: string, message: string, public details: ErrorDetails = {}) {\n    super(message);\n  }\n\n  public get accessMode() { return this.details?.accessMode;  }\n  public get status() { return this.details?.status;  }\n}\n"
  },
  {
    "path": "app/common/Features-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const SnapshotWindow = t.iface([], {\n  \"count\": \"number\",\n  \"unit\": t.union(t.lit(\"days\"), t.lit(\"month\"), t.lit(\"year\")),\n});\n\nexport const Product = t.iface([], {\n  \"name\": \"string\",\n  \"features\": \"Features\",\n});\n\nexport const Features = t.iface([], {\n  \"vanityDomain\": t.opt(\"boolean\"),\n  \"workspaces\": t.opt(\"boolean\"),\n  \"maxSharesPerDoc\": t.opt(\"number\"),\n  \"maxSharesPerDocPerRole\": t.opt(t.iface([], {\n    [t.indexKey]: \"number\",\n  })),\n  \"maxSharesPerWorkspace\": t.opt(\"number\"),\n  \"maxDocsPerOrg\": t.opt(\"number\"),\n  \"maxWorkspacesPerOrg\": t.opt(\"number\"),\n  \"readOnlyDocs\": t.opt(\"boolean\"),\n  \"snapshotWindow\": t.opt(\"SnapshotWindow\"),\n  \"baseMaxRowsPerDocument\": t.opt(\"number\"),\n  \"baseMaxApiUnitsPerDocumentPerDay\": t.opt(\"number\"),\n  \"baseMaxDataSizePerDocument\": t.opt(\"number\"),\n  \"baseMaxAttachmentsBytesPerDocument\": t.opt(\"number\"),\n  \"maxAttachmentsBytesPerOrg\": t.opt(\"number\"),\n  \"gracePeriodDays\": t.opt(\"number\"),\n  \"noGraceBanner\": t.opt(\"boolean\"),\n  \"baseMaxAssistantCalls\": t.opt(\"number\"),\n  \"minimumUnits\": t.opt(\"number\"),\n  \"meteredSeats\": t.opt(\"boolean\"),\n  \"teamAuditLogs\": t.opt(\"boolean\"),\n  \"maxNewUserInvitesPerOrg\": t.opt(\"number\"),\n  \"installationEnabled\": t.opt(\"boolean\"),\n  \"installationSeats\": t.opt(\"number\"),\n  \"installationReadOnly\": t.opt(\"boolean\"),\n  \"installationGracePeriodDays\": t.opt(\"number\"),\n  \"installationNoGraceBanner\": t.opt(\"boolean\"),\n});\n\nexport const StripeMetaValues = t.iface([], {\n  \"isStandard\": t.opt(\"boolean\"),\n  \"gristProduct\": t.opt(\"string\"),\n  \"gristLimit\": t.opt(\"string\"),\n  \"family\": t.opt(\"string\"),\n  \"trialPeriodDays\": t.opt(\"number\"),\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  SnapshotWindow,\n  Product,\n  Features,\n  StripeMetaValues,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/common/Features.ts",
    "content": "import Checkers, { Features as FeaturesTi } from \"app/common/Features-ti\";\n\nimport defaultsDeep from \"lodash/defaultsDeep\";\nimport { CheckerT, createCheckers } from \"ts-interface-checker\";\n\nexport interface SnapshotWindow {\n  count: number;\n  unit: \"days\" | \"month\" | \"year\";\n}\n\n// Information about the product associated with an org or orgs.\nexport interface Product {\n  name: string;\n  features: Features;\n}\n\n/**\n * Used as a placeholder on price level, to replace the actual value with the units from\n * subscription item.\n */\nexport const UNITS = \"{units}\";\n\n/**\n * A product is essentially a list of flags and limits that we may enforce/support.\n *\n * Features are build by merging features that come from customer, product, and plan.\n * - units is used to replace the value with the units from subscription item.\n */\nexport interface Features {\n  vanityDomain?: boolean;   // are user-selected domains allowed (unenforced) (default: true)\n\n  workspaces?: boolean;     // are workspaces shown in web interface (default: true)\n  // (this was intended as something we can turn off to shut down\n  // web access to content while leaving access to billing)\n\n  /**\n   * Some optional limits.  Since orgs can change plans, limits will typically be checked\n   * at the point of creation.  E.g. adding someone new to a document, or creating a\n   * new document.  If, after an operation, the limit would be exceeded, that operation\n   * is denied.  That means it is possible to exceed limits if the limits were not in\n   * place when shares/docs were originally being added.  The action that would need\n   * to be taken when infringement is pre-existing is not so obvious.\n   */\n\n  maxSharesPerDoc?: number; // Maximum number of users that can be granted access to a\n  // particular doc.  Doesn't count users granted access at\n  // workspace or organization level.  Doesn't count billable\n  // users if applicable (default: unlimited)\n\n  maxSharesPerDocPerRole?: { [role: string]: number };  // As maxSharesPerDoc, but\n  // for specific roles.  Roles are named as in app/common/roles.\n  // Applied independently to maxSharesPerDoc.\n  // (default: unlimited)\n  maxSharesPerWorkspace?: number;  // Maximum number of users that can be granted access to\n  // a particular workspace.  Doesn't count users granted access\n  // at organizational level, or billable users (default: unlimited)\n\n  maxDocsPerOrg?: number;   // Maximum number of documents allowed per org.\n  // (default: unlimited)\n  maxWorkspacesPerOrg?: number;   // Maximum number of workspaces allowed per org.\n  // (default: unlimited)\n\n  readOnlyDocs?: boolean;   // if set, docs can only be read, not written.\n\n  snapshotWindow?: SnapshotWindow;  // if set, controls how far back snapshots are kept.\n\n  baseMaxRowsPerDocument?: number;  // If set, establishes a default maximum on the\n  // number of rows (total) in a single document.\n  // Actual max for a document may be higher.\n  baseMaxApiUnitsPerDocumentPerDay?: number;  // Similar for api calls.\n  baseMaxDataSizePerDocument?: number;  // Similar maximum for number of bytes of 'normal' data in a document\n  baseMaxAttachmentsBytesPerDocument?: number;  // Similar maximum for total number of bytes used\n  // for attached files in a document\n  maxAttachmentsBytesPerOrg?: number; // Limit across a site.\n\n  gracePeriodDays?: number;  // Duration of the grace period in days, before entering delete-only mode\n  noGraceBanner?: boolean;   // If set, a banner is hidden, used for enterprise plans.\n\n  baseMaxAssistantCalls?: number; // Maximum number of AI assistant calls. Defaults to 0 if not set, use -1 to indicate\n  // unbound limit. This is total limit, not per month or per day, it is used as a seed\n  // value for the limits table. To create a per-month limit, there must be a separate\n  // task that resets the usage in the limits table.\n  minimumUnits?: number; // Minimum number of units for the plan. Default no minimum.\n\n  meteredSeats?: boolean;       // If set, the number of seats is metered, and Grist should\n  // try to update subscription in Stripe (by increasing the quantity).\n\n  teamAuditLogs?: boolean; // Access to team-level audit logging.\n\n  maxNewUserInvitesPerOrg?: number; // Maximum number of site/workspace/doc invites to new users before\n  // additional requests are blocked (until invited users log in or are\n  // uninvited).\n\n  installationEnabled?: boolean; // Allows self hosted Grist plan. Grist will generate an activation\n  // key for the installation, which will unblock enterprise features.\n\n  // The following features are used for self managed Grist instance (called installation).\n\n  installationSeats?: number;           // Number of seats bought (should be filled in by Stripe). Grist won't allow\n  // more users than this number.\n\n  installationReadOnly?: boolean;       // If set, docs can only be read, not written.\n\n  installationGracePeriodDays?: number; // Duration of the grace period in days, before entering read-only mode\n\n  installationNoGraceBanner?: boolean;  // If set, a banner is hidden.\n\n  // TODO: support attachment limits at the installation level. Planned but not\n  // yet implemented. Keep commented out until implemented.\n  // installationMaxAttachmentsBytes?: number;  // Limit of attachment storage across installation.\n}\n\n/**\n * Returns a merged set of features, combining the features of the given objects.\n * If all objects are null, returns null.\n *\n * Examples:\n * // Use features from the billingAccount, for any missing features use product features.\n * - mergedFeatures(billingAccount, product),\n * // Use features from the document, for any missing features use billingAccount features and then product features.\n * - mergedFeatures(document, billingAccount, product),\n */\nexport function mergedFeatures(resource: Features | null, ...defaults: (Features | null)[]): Features {\n  return [resource, ...defaults].filter(Boolean).reduce((acc: Features, f) => defaultsDeep(acc, f), {});\n}\n\n/**\n * Other meta values stored in Stripe Price or Product metadata.\n */\nexport interface StripeMetaValues {\n  isStandard?: boolean;\n  gristProduct?: string;\n  gristLimit?: string;\n  family?: string;\n  trialPeriodDays?: number;\n}\n\nexport const FeaturesChecker = createCheckers(Checkers).Features as CheckerT<Features>;\nexport const StripeMetaValuesChecker = createCheckers(Checkers).StripeMetaValues as CheckerT<StripeMetaValues>;\n\n/**\n * Recreates the Features object from a Record<string, string> (as it is stored in Stripe metadata).\n * Removes any invalid properties.\n */\nexport function parseStripeFeatures(meta: Record<string, string>): Features {\n  // Stripe metadata can contain many more values that we don't care about, so we just\n  // filter out the ones we do care about.\n  const validProps = new Set(FeaturesTi.props.map(p => p.name));\n  const record = parseMetadata(meta);\n  for (const key in record) {\n    // If this is unknown property, remove it.\n    if (!validProps.has(key)) {\n      delete record[key];\n      continue;\n    }\n\n    const value = record[key];\n    const tester = FeaturesChecker.getProp(key);\n    // If the top level property is invalid, just remove it.\n    if (!tester.strictTest(value)) {\n      // There is an exception for 1 and 0, if the target type is boolean.\n      switch (value) {\n        case 1:\n          record[key] = true;\n          break;\n        case 0:\n          record[key] = false;\n          break;\n      }\n      // Test one more time, if it is still invalid, remove it.\n      if (!tester.strictTest(record[key])) {\n        delete record[key];\n      }\n    }\n  }\n  return record;\n}\n\n/**\n * Method that can convert data stored in Stripe metadata (Record<string, string>)\n * to Record<string, any> with proper types.\n */\nexport function parseMetadata(meta: Record<string, string>): Record<string, any> {\n  const copy = { ...meta } as Record<string, any>;\n  // Values are stored as strings in Stripe, so we need to parse them.\n  // This format is not lossless but it is good enough for our purposes.\n  for (const key in copy) {\n    // We support only booleans, integers, floats, empty strings are nulls.\n    const value = copy[key];\n    if (value === \"\") {\n      copy[key] = null;\n    } else if (value === \"true\" || value === \"false\") {\n      copy[key] = value === \"true\";\n    } else if (!isNaN(parseFloat(value))) {\n      copy[key] = parseFloat(value);\n    } else if (!isNaN(parseInt(value, 10))) {\n      copy[key] = parseInt(value, 10);\n    }\n\n    if (key.includes(\".\")) {\n      const [topProp, ...rest] = key.split(\".\");\n      if (rest.length > 1) {\n        throw new Error(`Only one level of nesting is supported, got ${key}`);\n      }\n      const subProp = rest[0];\n      if (!copy[topProp]) {\n        copy[topProp] = {};\n      }\n      copy[topProp][subProp] = copy[key];\n    }\n  }\n  return copy;\n}\n\n// Check whether it is possible to add members at the org level.  There's no flag\n// for this right now, it isn't enforced at the API level, it is just a bluff.\n// For now, when maxWorkspacesPerOrg is 1, we should assume members can't be added\n// to org (even though this is not enforced).\nexport function canAddOrgMembers(features: Features): boolean {\n  return features.maxWorkspacesPerOrg !== 1;\n}\n\n// Grist is aware only about those plans.\n// Those plans are synchronized with database only if they don't exists currently.\nexport const PERSONAL_FREE_PLAN = \"personalFree\";\nexport const TEAM_FREE_PLAN = \"teamFree\";\n\n// This is a plan for suspended users.\nexport const SUSPENDED_PLAN = \"suspended\";\n\n// This is virtual plan for anonymous users.\nexport const ANONYMOUS_PLAN = \"anonymous\";\n// This is free plan. Grist doesn't offer a way to create it using API, but\n// it can be configured as a substitute for any other plan using environment variables (like DEFAULT_TEAM_PLAN)\nexport const FREE_PLAN = \"Free\";\n\n// This is a plan for temporary org, before assigning a real plan.\nexport const STUB_PLAN = \"stub\";\n\n// Legacy free personal plan, which is not available anymore or created in new instances, but used\n// here for displaying purposes and in tests.\nexport const PERSONAL_LEGACY_PLAN = \"starter\";\n\n// Pro plan for team sites (first tier). It is generally read from Stripe, but we use it in tests, so\n// by default all installation have it. When Stripe updates it, it will be synchronized with Grist.\nexport const TEAM_PLAN = \"team\";\n\nexport const displayPlanName: { [key: string]: string } = {\n  [PERSONAL_FREE_PLAN]: \"Free Personal\",\n  [TEAM_FREE_PLAN]: \"Team Free\",\n  [SUSPENDED_PLAN]: \"Suspended\",\n  [ANONYMOUS_PLAN]: \"Anonymous\",\n  [FREE_PLAN]: \"Free\",\n  [TEAM_PLAN]: \"Pro\",\n  teamPro: \"Pro\", // Plans available at getgrist.com\n  business: \"Business\", // Business plan, available at getgrist.com\n} as const;\n\n// Returns true if `planName` is for a legacy product.\nexport function isLegacyPlan(planName: string): boolean {\n  return planName === PERSONAL_LEGACY_PLAN;\n}\n\n// Returns true if `planName` is for a free personal product.\nexport function isFreePersonalPlan(planName: string): boolean {\n  return [PERSONAL_LEGACY_PLAN, PERSONAL_FREE_PLAN].includes(planName);\n}\n\n/**\n * Actually all known plans don't require billing (which doesn't mean they are free actually, as it can\n * be overridden by Stripe). There are also pro (team) and enterprise plans, which are billable, but they are\n * read from Stripe.\n */\nexport function isFreePlan(planName: string): boolean {\n  switch (planName) {\n    case PERSONAL_LEGACY_PLAN:\n    case PERSONAL_FREE_PLAN:\n    case TEAM_FREE_PLAN:\n    case FREE_PLAN:\n    case ANONYMOUS_PLAN:\n      return true;\n    default:\n      return false;\n  }\n}\n\n/**\n * Are the plan limits managed by Grist.\n */\nexport function isManagedPlan(planName: string): boolean {\n  switch (planName) {\n    case PERSONAL_LEGACY_PLAN:\n    case PERSONAL_FREE_PLAN:\n    case TEAM_FREE_PLAN:\n    case FREE_PLAN:\n    case SUSPENDED_PLAN:\n    case ANONYMOUS_PLAN:\n    case STUB_PLAN:\n      return true;\n    default:\n      return false;\n  }\n}\n"
  },
  {
    "path": "app/common/FilterState.ts",
    "content": "import { CellValue } from \"app/common/DocActions\";\nimport { IRelativeDateSpec, isEquivalentRelativeDate, isRelativeBound } from \"app/common/RelativeDates\";\n\nexport type { IRelativeDateSpec } from \"app/common/RelativeDates\";\nexport { isRelativeBound } from \"app/common/RelativeDates\";\n\n// Filter object as stored in the db\nexport interface FilterSpec {\n  included?: CellValue[];\n  excluded?: CellValue[];\n  min?: number | IRelativeDateSpec;\n  max?: number | IRelativeDateSpec;\n}\n\nexport type IRangeBoundType = undefined | number | IRelativeDateSpec;\n\nexport type FilterState = ByValueFilterState | RangeFilterState;\n\n// A more efficient representation of filter state for a column than FilterSpec.\ninterface ByValueFilterState {\n  include: boolean;\n  values: Set<CellValue>;\n}\n\ninterface RangeFilterState {\n  min?: number | IRelativeDateSpec;\n  max?: number | IRelativeDateSpec;\n}\n\n// Creates a FilterState. Accepts spec as a json string or a FilterSpec.\nexport function makeFilterState(spec: string | FilterSpec): FilterState {\n  if (typeof (spec) === \"string\") {\n    return makeFilterState((spec && JSON.parse(spec)) || {});\n  }\n  if (spec.min !== undefined || spec.max !== undefined) {\n    return { min: spec.min, max: spec.max };\n  }\n  return {\n    include: Boolean(spec.included),\n    values: new Set(spec.included || spec.excluded || []),\n  };\n}\n\n// Returns true if state and spec are equivalent, false otherwise.\nexport function isEquivalentFilter(state: FilterState, spec: FilterSpec): boolean {\n  const other = makeFilterState(spec);\n  if (!isRangeFilter(state) && !isRangeFilter(other)) {\n    if (state.include !== other.include) { return false; }\n    if (state.values.size !== other.values.size) { return false; }\n    if (other.values) {\n      for (const val of other.values) { if (!state.values.has(val)) { return false; } }\n    }\n  } else {\n    if (isRangeFilter(state) && isRangeFilter(other)) {\n      if (state.min !== other.min || state.max !== other.max) { return false; }\n    } else {\n      return false;\n    }\n  }\n  return true;\n}\n\nexport function isRangeFilter(state: FilterState): state is RangeFilterState {\n  const { min, max } = state as any;\n  return min !== undefined || max !== undefined;\n}\n\nexport function isEquivalentBound(a: IRangeBoundType, b: IRangeBoundType) {\n  if (isRelativeBound(a) && isRelativeBound(b)) {\n    return isEquivalentRelativeDate(a, b);\n  }\n  if (isRelativeBound(a) || isRelativeBound(b)) {\n    return false;\n  }\n  return a === b;\n}\n"
  },
  {
    "path": "app/common/Forms.ts",
    "content": "/**\n * Number of fields to show in the form by default.\n */\nexport const INITIAL_FIELDS_COUNT = 9;\n"
  },
  {
    "path": "app/common/Formula.ts",
    "content": "/**\n *\n * This represents a formula supported under SQL for on-demand tables.  This is currently\n * a very small subset of the formulas supported by the data engine for regular tables.\n *\n * The following kinds of formula are supported:\n *   $refColId.colId    [where colId is not itself a formula]\n *   $colId             [where colId is not itself a formula]\n *   NNN                [a non-negative integer]\n * TODO: support a broader range of formula, by adding a parser or reusing Python parser.\n * An argument for reusing Python parser: wwe already do substantial parsing of the formula code.\n * E.g. Python does such amazing things as handle updating the formula when any of the columns\n * referred to in Foo.lookup(bar=$baz).blah get updated.\n *\n */\nexport type Formula = LiteralNumberFormula | ColumnFormula | ForeignColumnFormula | FormulaError;\n\n// A simple copy of another column.  E.g. \"$Person\"\nexport interface ColumnFormula {\n  kind: \"column\";\n  colId: string;\n}\n\n// A copy of a column in another table (via a reference column).  E.g. \"$Person.FirstName\"\nexport interface ForeignColumnFormula {\n  kind: \"foreignColumn\";\n  colId: string;\n  refColId: string;\n}\n\nexport interface LiteralNumberFormula {\n  kind: \"literalNumber\";\n  value: number;\n}\n\n// A formula that couldn't be parsed.\nexport interface FormulaError {\n  kind: \"error\";\n  msg: string;\n}\n\n/**\n * Convert a string to a parsed formula.  Regexes are adequate for the very few\n * supported formulas, but once the syntax is at all flexible a proper parser will\n * be needed.  In principle, it might make sense to support python syntax, for\n * compatibility with the data engine, but compatibility in corner cases will be\n * fiddly given underlying differences between sqlite and python.\n */\nexport function parseFormula(txt: string): Formula {\n  // Formula of form: $x.y\n  let m = txt.match(/^\\$([a-z]\\w*)\\.([a-z]\\w*)$/i);\n  if (m) {\n    return { kind: \"foreignColumn\", refColId: m[1], colId: m[2] };\n  }\n\n  // Formula of form: $x\n  m = txt.match(/^\\$([a-z][a-z_0-9]*)$/i);\n  if (m) {\n    return { kind: \"column\", colId: m[1] };\n  }\n\n  // Formula of form: NNN\n  m = txt.match(/^[0-9]+$/);\n  if (m) {\n    const value = parseInt(txt, 10);\n    if (isNaN(value)) { return { kind: \"error\", msg: \"Cannot parse integer\" }; }\n    return { kind: \"literalNumber\", value };\n  }\n\n  // Everything else is an error.\n  return { kind: \"error\", msg: \"Formula not supported\" };\n}\n"
  },
  {
    "path": "app/common/GranularAccessClause.ts",
    "content": "import { PartialPermissionSet } from \"app/common/ACLPermissions\";\nimport { CellValue, RowRecord } from \"app/common/DocActions\";\nimport { CompiledPredicateFormula } from \"app/common/PredicateFormula\";\nimport { MetaRowRecord } from \"app/common/TableData\";\n\nexport interface RuleSet {\n  tableId: string;\n  colIds: \"*\" | string[];\n  // The default permissions for this resource, if set, are represented by a RulePart with\n  // aclFormula of \"\", which must be the last element of body.\n  body: RulePart[];\n}\n\nexport interface RulePart {\n  origRecord?: MetaRowRecord<\"_grist_ACLRules\">;  // Original record used to create this RulePart.\n  aclFormula: string;\n  permissions: PartialPermissionSet;\n  permissionsText: string;        // The text version of PermissionSet, as stored.\n\n  // Compiled version of aclFormula.\n  matchFunc?: CompiledPredicateFormula;\n\n  // Optional memo, currently extracted from comment in formula.\n  memo?: string;\n}\n\n// As InfoView, but also supporting writing.\nexport interface InfoEditor {\n  get(key: string): CellValue;\n  set(key: string, val: CellValue): this;\n  toJSON(): { [key: string]: any };\n}\n\nexport interface UserAttributeRule {\n  origRecord?: RowRecord;         // Original record used to create this UserAttributeRule.\n  name: string;       // Should be unique among UserAttributeRules.\n  tableId: string;    // Table in which to look up an existing attribute.\n  lookupColId: string;  // Column in tableId in which to do the lookup.\n  charId: string;     // Attribute to look up, possibly a path. E.g. 'Email' or 'office.city'.\n}\n"
  },
  {
    "path": "app/common/GristServerAPI.ts",
    "content": "import { BasketClientAPI } from \"app/common/BasketClientAPI\";\nimport { DocListAPI } from \"app/common/DocListAPI\";\nimport { LoginSessionAPI } from \"app/common/LoginSessionAPI\";\nimport { UserConfig } from \"app/common/UserConfig\";\n\nexport interface GristServerAPI extends\n  DocListAPI,\n  LoginSessionAPI,\n  BasketClientAPI,\n  UserAPI,\n  MiscAPI {}\n\ninterface UserAPI {\n  /**\n   * Gets the Grist configuration from the server.\n   */\n  getConfig(): Promise<UserConfig>;\n\n  /**\n   * Updates the user configuration and saves it to the server.\n   * @param {Object} config - Configuration object to save.\n   * @returns {Promise:Object} Configuration object as persisted by the server. You can use it to\n   * validate the configuration.\n   */\n  updateConfig(config: UserConfig): Promise<UserConfig>;\n\n  /**\n   * Re-load plugins.\n   */\n  reloadPlugins(): Promise<void>;\n}\n\ninterface MiscAPI {\n  showItemInFolder(docName: string): Promise<void>;\n}\n"
  },
  {
    "path": "app/common/ICommonUrls-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const ICommonUrls = t.iface([], {\n  \"help\": \"string\",\n  \"helpAccessRules\": \"string\",\n  \"helpAssistant\": \"string\",\n  \"helpAssistantDataUse\": \"string\",\n  \"helpFormulaAssistantDataUse\": \"string\",\n  \"helpColRefs\": \"string\",\n  \"helpConditionalFormatting\": \"string\",\n  \"helpFilterButtons\": \"string\",\n  \"helpLinkingWidgets\": \"string\",\n  \"helpRawData\": \"string\",\n  \"helpSuggestions\": \"string\",\n  \"helpUnderstandingReferenceColumns\": \"string\",\n  \"helpTriggerFormulas\": \"string\",\n  \"helpTryingOutChanges\": \"string\",\n  \"helpWidgets\": \"string\",\n  \"helpCustomWidgets\": \"string\",\n  \"helpInstallAuditLogs\": \"string\",\n  \"helpTeamAuditLogs\": \"string\",\n  \"helpTelemetryLimited\": \"string\",\n  \"helpEnterpriseOptIn\": \"string\",\n  \"helpCalendarWidget\": \"string\",\n  \"helpLinkKeys\": \"string\",\n  \"helpFilteringReferenceChoices\": \"string\",\n  \"helpSandboxing\": \"string\",\n  \"helpSharing\": \"string\",\n  \"helpStateStore\": \"string\",\n  \"helpAPI\": \"string\",\n  \"helpSummaryFormulas\": \"string\",\n  \"helpAdminControls\": \"string\",\n  \"helpFiddleMode\": \"string\",\n  \"helpFormUrlValues\": \"string\",\n  \"helpAirtableIntegration\": \"string\",\n  \"freeCoachingCall\": \"string\",\n  \"contactSupport\": \"string\",\n  \"termsOfService\": t.union(\"string\", \"undefined\"),\n  \"onboardingTutorialVideoId\": \"string\",\n  \"plans\": \"string\",\n  \"contact\": \"string\",\n  \"templates\": \"string\",\n  \"webinars\": \"string\",\n  \"community\": \"string\",\n  \"functions\": \"string\",\n  \"formulaSheet\": \"string\",\n  \"formulas\": \"string\",\n  \"forms\": \"string\",\n  \"openGraphPreviewImage\": \"string\",\n  \"gristLabsCustomWidgets\": \"string\",\n  \"gristLabsWidgetRepository\": \"string\",\n  \"githubGristCore\": \"string\",\n  \"githubSponsorGristLabs\": \"string\",\n  \"versionCheck\": \"string\",\n  \"attachmentStorage\": \"string\",\n  \"signInWithGristRegister\": \"string\",\n  \"signInWithGristHelp\": \"string\",\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  ICommonUrls,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/common/ICommonUrls.ts",
    "content": "export interface ICommonUrls {\n  // Link to the help center.\n  help: string;\n\n  // Various links to support pages:\n  helpAccessRules: string;\n  helpAssistant: string;\n  helpAssistantDataUse: string;\n  helpFormulaAssistantDataUse: string;\n  helpColRefs: string;\n  helpConditionalFormatting: string;\n  helpFilterButtons: string;\n  helpLinkingWidgets: string;\n  helpRawData: string;\n  helpSuggestions: string;\n  helpUnderstandingReferenceColumns: string;\n  helpTriggerFormulas: string;\n  helpTryingOutChanges: string;\n  helpWidgets: string;\n  helpCustomWidgets: string;\n  helpInstallAuditLogs: string;\n  helpTeamAuditLogs: string;\n  helpTelemetryLimited: string;\n  helpEnterpriseOptIn: string;\n  helpCalendarWidget: string;\n  helpLinkKeys: string;\n  helpFilteringReferenceChoices: string;\n  helpSandboxing: string;\n  helpSharing: string;\n  helpStateStore: string;\n  helpAPI: string;\n  helpSummaryFormulas: string;\n  helpAdminControls: string;\n  helpFiddleMode: string;\n  helpFormUrlValues: string;\n  helpAirtableIntegration: string;\n\n  freeCoachingCall: string; // Link to the human help (example: email adress or meeting scheduling tool)\n  contactSupport: string; // Link to contact support on error pages (example: email adress or online form).\n  termsOfService: string | undefined; // Link to the terms of service (if set, adds a button to the bottom-left corner).\n  onboardingTutorialVideoId: string; // URL to the Youtube video to onboard users.\n  plans: string; // Link to the plans.\n  contact: string; // Link to the contact page.\n  templates: string; // Link to the templates store.\n  webinars: string; // Link to the webinars\n  community: string; // Link to the forum.\n  functions: string; // Support doc for the functions.\n  formulaSheet: string; // URL to the formula cheat sheet.\n  formulas: string; // Support doc for formulas.\n  forms: string; // Footer link to show how to create own's form.\n\n  // URL of the preview image when sharing the link on websites like social medias or chat applications.\n  openGraphPreviewImage: string;\n\n  gristLabsCustomWidgets: string; // Repo of the Grist Labs custom widget\n  gristLabsWidgetRepository: string; // Url pointing to a widget manifest\n  githubGristCore: string; // Link to the grist-core project repository on Github.\n  githubSponsorGristLabs: string; // Link to the Grist Labs sponsor page.\n\n  versionCheck: string; // API to check the instance has the latest version and otherwise show a banner.\n  attachmentStorage: string; // Support doc for attachment storage.\n\n  signInWithGristRegister: string; // Registration for Sign in with getgrist.com.\n  signInWithGristHelp: string; // Help for Sign in with getgrist.com.\n}\n"
  },
  {
    "path": "app/common/InactivityTimer.ts",
    "content": "/**\n * InactivityTimer allows to set a function that executes after a certain time of\n * inactivity. Activities can be of two kinds: synchronous or asynchronous. Asynchronous activities,\n * are handle with the `disableUntiFinish` method that takes in a Promise and makes sure that the\n * timer does not start before the promise resolves. Synchronous activities are monitored with the\n * `ping` method which resets the timer if called during inactivity.\n *\n * Timer won't start before any activity happens, but you may simply call ping() after construction\n * to start it. After cb is called, timer is disabled but enabled again if there is more activity.\n *\n * Example usage: InactivityTimer is used internally for implementing the plugins' component\n * deactivation after a certain time of inactivity.\n *\n */\n\nexport class InactivityTimer {\n  private _timeout?: NodeJS.Timeout | null;\n  private _counter: number = 0;\n  private _enabled: boolean = true;\n\n  constructor(private _callback: () => void, private _delay: number) {}\n\n  // Returns the delay used by InactivityTimer, in ms.\n  public getDelay(): number {\n    return this._delay;\n  }\n\n  // Sets a different delay to use, in ms.\n  public setDelay(delayMs: number): void {\n    this._delay = delayMs;\n    this.ping();\n  }\n\n  /**\n   * Enable the InactivityTimer and schedule the callback.\n   */\n  public enable(): void {\n    this._enabled = true;\n    this.ping();\n  }\n\n  /**\n   * Clears the timeout and prevents the callback from being called until enable() is called.\n   */\n  public disable(): void {\n    this._enabled = false;\n    this._clearTimeout();\n  }\n\n  /**\n   * Returns whether the InactivityTimer is enabled. If not, the callback will not be scheduled.\n   */\n  public isEnabled(): boolean {\n    return this._enabled;\n  }\n\n  /**\n   * Whether the callback is currently scheduled, and would trigger if there is no activity and if\n   * it's not disabled before it triggers.\n   */\n  public isScheduled(): boolean {\n    return Boolean(this._timeout);\n  }\n\n  /**\n   * Resets the timer if called during inactivity.\n   */\n  public ping() {\n    if (!this._counter && this._enabled) {\n      this._setTimeout();\n    }\n  }\n\n  /**\n   * The `disableUntilFinish` method takes in a promise and makes sure the timer won't start before\n   * it resolves. It returns a promise that resolves to the same object.\n   */\n  public async disableUntilFinish<T>(promise: Promise<T>): Promise<T> {\n    this._beginActivity();\n    try {\n      return await promise;\n    } finally {\n      this._endActivity();\n    }\n  }\n\n  private _beginActivity() {\n    this._counter++;\n    this._clearTimeout();\n  }\n\n  private _endActivity() {\n    this._counter = Math.max(this._counter - 1, 0);\n    this.ping();\n  }\n\n  private _clearTimeout() {\n    if (this._timeout) {\n      clearTimeout(this._timeout);\n      this._timeout = null;\n    }\n  }\n\n  private _setTimeout() {\n    this._clearTimeout();\n    this._timeout = setTimeout(() => this._onTimeoutTriggered(), this._delay);\n  }\n\n  private _onTimeoutTriggered() {\n    this._clearTimeout();\n    // _counter is set to 0, even if there's no reason why it should be any thing else.\n    this._counter = 0;\n    this._callback();\n  }\n}\n"
  },
  {
    "path": "app/common/Install.ts",
    "content": "import { TelemetryLevel } from \"app/common/Telemetry\";\n\nexport interface InstallPrefs extends PendingChanges {\n  telemetry?: TelemetryPrefs;\n  envVars?: Record<string, any>;\n  checkForLatestVersion?: boolean;\n}\n\nexport interface PendingChanges {\n  /**\n   * If set, saves this value to `GRIST_ADMIN_EMAIL` in `envVars` on server\n   * restart.\n   *\n   * Applied during server initialization in `/stubs/app/server/server.ts`\n   * and automatically removed after changes are successfully applied.\n   *\n   * Set this to `null` to remove this key and cancel a pending change.\n   */\n  onRestartSetAdminEmail?: string | null;\n  /**\n   * If set, looks up the user whose login email matches this value and updates\n   * their login email to be equal to `GRIST_ADMIN_EMAIL` on server restart.\n   *\n   * This is primarily intended to be used in tandem with `onRestartSetAdminEmail`\n   * to replace the current install admin without changing the user, to preserve\n   * any resources they own or have access to. In contrast, setting only\n   * `onRestartSetAdminEmail` changes the actual admin user. You can still\n   * set `onRestartReplaceEmailWithAdmin` separately after previously setting\n   * `onRestartSetAdminEmail` as a basic form of recovery from choosing the\n   * wrong option initially.\n   *\n   * Applied during server initialization in `/stubs/app/server/server.ts`,\n   * and automatically removed after changes are successfully applied.\n   *\n   * Set this to `null` to remove this key and cancel a pending change.\n   */\n  onRestartReplaceEmailWithAdmin?: string | null;\n  /**\n   * If set, clears all sessions on server restart.\n   */\n  onRestartClearSessions?: boolean;\n}\n\nexport interface TelemetryPrefs {\n  /** Defaults to \"off\". */\n  telemetryLevel?: TelemetryLevel;\n}\n"
  },
  {
    "path": "app/common/InstallAPI.ts",
    "content": "import { BaseAPI, IOptions } from \"app/common/BaseAPI\";\nimport { BootProbeInfo, BootProbeResult } from \"app/common/BootProbe\";\nimport { LatestVersionAvailable } from \"app/common/gristUrls\";\nimport { InstallPrefs, PendingChanges } from \"app/common/Install\";\nimport { TelemetryLevel } from \"app/common/Telemetry\";\nimport { addCurrentOrgToPath } from \"app/common/urlUtils\";\n\nexport const installPropertyKeys = [\"prefs\"];\n\nexport interface InstallProperties {\n  prefs: InstallPrefs;\n}\n\nexport interface InstallPrefsWithSources extends PendingChanges {\n  telemetry: {\n    telemetryLevel: PrefWithSource<TelemetryLevel>;\n  },\n  checkForLatestVersion: boolean;\n}\n\nexport type TelemetryPrefsWithSources = InstallPrefsWithSources[\"telemetry\"];\n\nexport interface PrefWithSource<T> {\n  value: T;\n  source: PrefSource;\n}\n\nexport type PrefSource = \"environment-variable\" | \"preferences\";\n\nexport interface InstallAPI {\n  getInstallPrefs(): Promise<InstallPrefsWithSources>;\n  updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void>;\n  /**\n   * Returns information about latest version of Grist\n   */\n  checkUpdates(): Promise<LatestVersionAvailable>;\n  getChecks(): Promise<{ probes: BootProbeInfo[] }>;\n  runCheck(id: string): Promise<BootProbeResult>;\n}\n\nexport class InstallAPIImpl extends BaseAPI implements InstallAPI {\n  constructor(private _homeUrl: string, options: IOptions = {}) {\n    super(options);\n  }\n\n  public async getInstallPrefs(): Promise<InstallPrefsWithSources> {\n    return this.requestJson(`${this._url}/api/install/prefs`, { method: \"GET\" });\n  }\n\n  public async updateInstallPrefs(prefs: Partial<InstallPrefs>): Promise<void> {\n    await this.request(`${this._url}/api/install/prefs`, {\n      method: \"PATCH\",\n      body: JSON.stringify({ ...prefs }),\n    });\n  }\n\n  public checkUpdates(): Promise<LatestVersionAvailable> {\n    return this.requestJson(`${this._url}/api/install/updates`, { method: \"GET\" });\n  }\n\n  public getChecks(): Promise<{ probes: BootProbeInfo[] }> {\n    return this.requestJson(`${this._url}/api/probes`, { method: \"GET\" });\n  }\n\n  public runCheck(id: string): Promise<BootProbeResult> {\n    return this.requestJson(`${this._url}/api/probes/${id}`, { method: \"GET\" });\n  }\n\n  private get _url(): string {\n    return addCurrentOrgToPath(this._homeUrl);\n  }\n}\n"
  },
  {
    "path": "app/common/Interval.ts",
    "content": "export interface IntervalOptions {\n  /**\n   * Handler for errors that are thrown from the callback.\n   */\n  onError: (e: unknown) => void;\n}\n\nexport interface IntervalDelay {\n  // The base delay in milliseconds.\n  delayMs: number;\n  // If set, randomizes the base delay (per interval) by this amount of milliseconds.\n  varianceMs?: number;\n}\n\n/**\n * Interval takes a function to execute, and calls it on an interval based on\n * the provided delay.\n *\n * Supports both fixed and randomized delays between intervals.\n */\nexport class Interval {\n  private _timeout?: NodeJS.Timeout | null;\n  private _lastPendingCall?: Promise<unknown>;\n  private _timeoutDelay?: number;\n  private _stopped: boolean = true;\n\n  constructor(\n    private _callback: () => Promise<unknown>,\n    private _delay: IntervalDelay,\n    private _options: IntervalOptions,\n  ) {}\n\n  /**\n   * Sets the timeout and schedules the callback to be called on interval.\n   */\n  public enable(): void {\n    this._stopped = false;\n    this._setTimeout();\n  }\n\n  /**\n   * Clears the timeout and prevents the next call from being scheduled.\n   *\n   * This method does not currently cancel any pending calls. See `disableAndFinish`\n   * for an async version of this method that supports waiting for the last pending\n   * call to finish.\n   */\n  public disable(): void {\n    this._stopped = true;\n    this._clearTimeout();\n  }\n\n  /**\n   * Like `disable`, but also waits for the last pending call to finish.\n   */\n  public async disableAndFinish(): Promise<void> {\n    this.disable();\n    await this._lastPendingCall;\n  }\n\n  /**\n   * Gets the delay in milliseconds of the next scheduled call.\n   *\n   * Primarily useful for tests.\n   */\n  public getDelayMs(): number | undefined {\n    return this._timeoutDelay;\n  }\n\n  private _clearTimeout() {\n    if (!this._timeout) { return; }\n\n    clearTimeout(this._timeout);\n    this._timeout = null;\n  }\n\n  private _setTimeout() {\n    this._clearTimeout();\n    this._timeoutDelay = this._computeDelayMs();\n    this._timeout = setTimeout(() => this._onTimeoutTriggered(), this._timeoutDelay);\n  }\n\n  private _computeDelayMs() {\n    const { delayMs, varianceMs } = this._delay;\n    if (varianceMs !== undefined) {\n      // Randomize the delay by the specified amount of variance.\n      const [min, max] = [delayMs - varianceMs, delayMs + varianceMs];\n      return Math.floor(Math.random() * (max - min + 1)) + min;\n    } else {\n      return delayMs;\n    }\n  }\n\n  private async _onTimeoutTriggered() {\n    this._clearTimeout();\n    try {\n      await (this._lastPendingCall = this._callback());\n    } catch (e: unknown) {\n      this._options.onError(e);\n    }\n    if (!this._stopped) {\n      this._setTimeout();\n    }\n  }\n}\n"
  },
  {
    "path": "app/common/KeyedMutex.ts",
    "content": "import { Mutex, MutexInterface } from \"async-mutex\";\n\n/**\n * A per-key mutex.  It has the same interface as Mutex, but with an extra key supplied.\n * Maintains an independent mutex for each key on need.\n */\nexport class KeyedMutex {\n  private _mutexes = new Map<string, Mutex>();\n\n  public async acquire(key: string): Promise<MutexInterface.Releaser> {\n    // Create a new mutex if we need one.\n    if (!this._mutexes.has(key)) {\n      this._mutexes.set(key, new Mutex());\n    }\n    const mutex = this._mutexes.get(key)!;\n    const unlock = await mutex.acquire();\n    return () => {\n      unlock();\n      // After unlocking, clean-up the mutex if it is no longer needed.\n      // unlock() leaves the mutex locked if anyone has been waiting for it.\n      if (!mutex.isLocked()) {\n        this._mutexes.delete(key);\n      }\n    };\n  }\n\n  public async runExclusive<T>(key: string, callback: MutexInterface.Worker<T>): Promise<T> {\n    const unlock = await this.acquire(key);\n    try {\n      return await callback();\n    } finally {\n      unlock();\n    }\n  }\n\n  public isLocked(key: string): boolean {\n    const mutex = this._mutexes.get(key);\n    if (!mutex) { return false; }\n    return mutex.isLocked();\n  }\n\n  // Check how many mutexes are in use.\n  public get size(): number {\n    return this._mutexes.size;\n  }\n}\n"
  },
  {
    "path": "app/common/KeyedOps.ts",
    "content": "/**\n * A class for scheduling a particular operation on resources\n * identified by a key.  For operations which should be applied\n * some time after an event.\n */\nexport class KeyedOps {\n  private _operations = new Map<string, OperationStatus>();  // status of operations\n  private _history = new Map<string, OperationHistory>();    // history of operations\n  // (will accumulate without limit)\n  private _changed = new Set<string>();    // set when key needs an operation\n  private _operating = new Set<string>();  // set when operation is in progress for key\n  private _stopped: boolean = false;       // set to prohibit all new operations or retries\n\n  /**\n   * Provide a function to apply operation, and some optional\n   * parameters.\n   *\n   *   - delayBeforeOperationMs: if set, a call to addOperation(key) will have\n   *     a delayed effect.  It will schedule (or reschedule) the operation to occur\n   *     after this interval.  If the operation is currently in progress, it will\n   *     get rerun after it completes.\n   *\n   *   - minDelaybetweenOperationsMs: is set, scheduling for operations will have\n   *     additional delays inserted as necessary to keep this minimal delay between\n   *     the start of successive operations.\n   *\n   *   - retry: if `retry` is set, the operation will be retried\n   *     indefinitely with a rather primitive retry mechanism -\n   *     otherwise no attempt is made to retry failures.\n   *\n   *   - logError: called when errors occur, with a count of number of failures so\n   *     far.\n   *   - scheduleFromFirstAdd: if set, a call to addOperation won't\n   *     reschedule work that hasn't started yet. Set this if you\n   *     want the operation to start at a fixed delay from the\n   *     first time it is added, rather than the last. Otherwise,\n   *     by default, adding will reset the delay.\n   */\n  constructor(private _op: (key: string) => Promise<void>, private _options: {\n    delayBeforeOperationMs?: number,\n    minDelayBetweenOperationsMs?: number,\n    retry?: boolean,\n    logError?: (key: string, failureCount: number, err: Error) => void,\n    scheduleFromFirstAdd?: boolean,\n  }) {\n  }\n\n  /**\n   * Request an operation be done (eventually) on the specified resourse.\n   */\n  public addOperation(key: string) {\n    this._changed.add(key);\n    this._schedule(key);\n  }\n\n  /**\n   * Check whether any work is scheduled or in progress.\n   */\n  public hasPendingOperations() {\n    return this._changed.size > 0 || this._operating.size > 0;\n  }\n\n  /**\n   * Check whether any work is scheduled or in progress for a specific resource.\n   */\n  public hasPendingOperation(key: string) {\n    return this._changed.has(key) || this._operating.has(key);\n  }\n\n  /**\n   * Take all scheduled operations and re-schedule them for right now.  Useful\n   * when shutting down.  Affects retries.  Cannot be undone.  Returns immediately.\n   */\n  public expediteOperations() {\n    this._options.delayBeforeOperationMs = 0;\n    this._options.minDelayBetweenOperationsMs = 0;\n    for (const op of this._operations.values()) {\n      if (op.timeout) {\n        this._schedule(op.key, true);\n      }\n    }\n  }\n\n  /**\n   * Don't allow any more operations, or retries of existing operations.\n   */\n  public stopOperations() {\n    this._stopped = true;\n    this.expediteOperations();\n  }\n\n  /**\n   * Wait for all operations to complete.  This makes most sense to use during\n   * shutdown - otherwise it might be a very long wait to reach a moment where\n   * there are no operations.\n   */\n  public async wait(logRepeat?: (count: number) => void) {\n    let repeats: number = 0;\n    while (this.hasPendingOperations()) {\n      if (repeats && logRepeat) { logRepeat(repeats); }\n      await Promise.all([...this._operating.keys(), ...this._changed.keys()]\n        .map(key => this.expediteOperationAndWait(key)));\n      repeats++;\n    }\n  }\n\n  /**\n   * Re-schedules any pending operation on a resource for right now.  Returns\n   * when operations on the resource are complete.  Does not affect retries.\n   */\n  public async expediteOperationAndWait(key: string) {\n    const status = this._getOperationStatus(key);\n    if (status.promise) {\n      await status.promise;\n      return;\n    }\n    if (!this._changed.has(key)) { return; }\n    const callback = new Promise((resolve) => {\n      status.callbacks.push(resolve);\n    });\n    this._schedule(key, true);\n    await callback;\n  }\n\n  /**\n   * Schedule an operation for a resource.\n   * If the operation is already in progress, we do nothing.\n   * If the operation has not yet happened, it is rescheduled.\n   * If `immediate` is set, the operation is scheduled with no delay.\n   */\n  private _schedule(key: string, immediate: boolean = false) {\n    const status = this._getOperationStatus(key);\n    if (status.promise) { return; }\n    if (status.timeout) {\n      if (this._options.scheduleFromFirstAdd && !immediate) {\n        return;\n      }\n      clearTimeout(status.timeout);\n      delete status.timeout;\n    }\n    let ticks = this._options.delayBeforeOperationMs || 0;\n    const { lastStart } = this._getOperationHistory(key);\n    if (lastStart && this._options.minDelayBetweenOperationsMs && !immediate) {\n      ticks = Math.max(ticks, lastStart + this._options.minDelayBetweenOperationsMs - Date.now());\n    }\n    // Primitive slow-down on retries.\n    // Will do nothing if neither delayBeforeOperationMs nor minDelayBetweenOperationsMs\n    // are set.\n    ticks *= 1 + Math.min(5, status.failures);\n    status.timeout = setTimeout(() => this._update(key), immediate ? 0 : ticks);\n  }\n\n  private _getOperationStatus(key: string): OperationStatus {\n    let status = this._operations.get(key);\n    if (!status) {\n      status = {\n        key,\n        failures: 0,\n        callbacks: [],\n      };\n      this._operations.set(key, status);\n    }\n    return status;\n  }\n\n  private _getOperationHistory(key: string): OperationHistory {\n    let hist = this._history.get(key);\n    if (!hist) {\n      hist = {};\n      this._history.set(key, hist);\n    }\n    return hist;\n  }\n\n  private async _doOp(key: string) {\n    if (this._stopped) { throw new Error(\"operations forcibly stopped\"); }\n    return this._op(key);\n  }\n\n  // Implement the next scheduled operation for a resource.\n  private _update(key: string) {\n    const status = this._getOperationStatus(key);\n    delete status.timeout;\n\n    // We don't have to do anything if there have been no changes.\n    if (!this._changed.has(key)) { return; }\n    // We don't have to do anything (yet) if an operation is already in progress.\n    if (status.promise) { return; }\n\n    // Switch status from changed to operating.\n    this._changed.delete(key);\n    this._operating.add(key);\n    const history = this._getOperationHistory(key);\n    history.lastStart = Date.now();\n\n    // Store a promise for the operation.\n    status.promise = this._doOp(key).then(() => {\n      // Successful push!  Reset failure count, notify callbacks.\n      status.failures = 0;\n      status.callbacks.forEach(callback => callback());\n      status.callbacks = [];\n    }).catch((err) => {\n      // Operation failed.  Increment failure count, notify callbacks.\n      status.failures++;\n      if (this._options.retry && !this._stopped) {\n        this._changed.add(key);\n      }\n      if (this._options.logError) {\n        this._options.logError(key, status.failures, err);\n      }\n      status.callbacks.forEach(callback => callback(err));\n      status.callbacks = [];\n    }).then(() => {\n      // Clean up and schedule follow-up if necessary.\n      this._operating.delete(key);\n      delete status.promise;\n      if (this._changed.has(key)) {\n        this._schedule(key);\n      } else {\n        // No event information left to track, we can delete our OperationStatus entry.\n        if (status.failures === 0 && !status.timeout) {\n          this._operations.delete(key);\n        }\n      }\n    });\n  }\n}\n\n/**\n * Status of an operation.\n */\ninterface OperationStatus {\n  timeout?: NodeJS.Timeout;  // a timeout for a scheduled future operation\n  promise?: Promise<void>;   // a promise for an operation that is under way\n  key: string;               // the operation key\n  failures: number;          // consecutive number of times the operation has failed\n  callbacks: ((err?: Error) => void)[];  // callbacks for notifications when op is done/fails\n}\n\n/**\n * History of an operation.\n */\ninterface OperationHistory {\n  lastStart?: number;        // last time operation was started, in ms since epoch\n}\n"
  },
  {
    "path": "app/common/Limits.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\n\n/**\n * Error class indicating failure due to limits being exceeded.\n */\nexport class LimitExceededError extends ApiError {\n  constructor(message: string) {\n    super(message, 413);\n  }\n}\n\n// Ratio of usage at which we start telling users that they're approaching limits.\nexport const APPROACHING_LIMIT_RATIO = 0.9;\n\n/**\n * Computes a ratio of `usage` to `limit`, if possible. Returns 0 if `usage` or `limit`\n * is invalid or undefined.\n */\nexport function getUsageRatio(usage: number | undefined, limit: number | undefined): number {\n  if (!isEnforceableLimit(limit) || usage === undefined || usage < 0) {\n    // Treat undefined or invalid values as having 0 usage.\n    return 0;\n  }\n\n  return usage / limit;\n}\n\n/**\n * Returns true if `limit` is defined and is a valid, positive number.\n */\nfunction isEnforceableLimit(limit: number | undefined): limit is number {\n  return limit !== undefined && limit > 0;\n}\n"
  },
  {
    "path": "app/common/LinkNode.ts",
    "content": "/**\n * Utilities for creating and validating LinkNodes.\n *\n * A LinkNode is a representation of a node in a widget linking chain.\n *\n * Used by both the client and server to build a list of valid linking options.\n * See `app/client/ui/selectBy.ts` and `app/server/lib/selectBy.ts`, respectively.\n */\n\nimport { getReferencedTableId } from \"app/common/gristTypes\";\nimport * as gutil from \"app/common/gutil\";\n\nimport pick from \"lodash/pick\";\n\nexport interface LinkNodeSection {\n  id: number;\n  tableRef: number;\n  parentId: number;\n  tableId: string;\n  parentKey: string;\n  title: string;\n  linkSrcSectionRef: number;\n  linkSrcColRef: number;\n  linkTargetColRef: number;\n  allowSelectBy?: boolean;\n  selectedRowsActive?: boolean;\n}\n\nexport interface LinkNodeColumn {\n  id: number;\n  colId: string;\n  label: string;\n  type: string;\n  summarySourceCol: number;\n}\n\nexport interface LinkNodeTable {\n  id: number;\n  tableId: string;\n  isSummaryTable: boolean;\n  columns: LinkNodeColumn[];\n}\n\nexport interface LinkNode {\n  // the tableId\n  tableId: string;\n\n  // is the table a summary table\n  isSummary: boolean;\n\n  // does this node involve an \"Attachments\" column. Can be tricky if Attachments is one of groupby cols\n  isAttachments: boolean;\n\n  // For a summary table, the set of col refs of the groupby columns of the underlying table\n  groupbyColumns?: Set<number>;\n\n  // list of ids of the sections that are ancestors to this section according to the linked section\n  // relationship. ancestors[0] is this.section, ancestors[length-1] is oldest ancestor\n  ancestors: number[];\n\n  // For bidirectional linking, cycles are only allowed if all links on that cycle are same-table cursor-link\n  // this.ancestors only records what the ancestors are, but we need to record info about the edges between them.\n  // isAncCursLink[i]==true  means the link from ancestors[i] to ancestors[i+1] is a same-table cursor-link\n  // NOTE: (Since ancestors is a list of nodes, and this is a list of the edges between those nodes, this list will\n  //        be 1 shorter than ancestors (if there's no cycle), or will be the same length (if there is a cycle))\n  isAncestorSameTableCursorLink: boolean[];\n\n  // the section. Must be the empty sections that are to be created.\n  section: LinkNodeSection;\n\n  // the column or undefined for the main section node (ie: the node that does not connect to\n  // any particular column)\n  column?: LinkNodeColumn;\n\n  // the widget type\n  widgetType: string;\n}\n\nexport interface LinkNodeOperations {\n  getTableById(id: number): LinkNodeTable;\n  getSectionById(id: number): LinkNodeSection;\n}\n\nexport function buildLinkNodes(\n  sections: LinkNodeSection[],\n  operations: LinkNodeOperations,\n): LinkNode[] {\n  const { getTableById, getSectionById } = operations;\n  const nodes: LinkNode[] = [];\n  for (const section of sections) {\n    const ancestors: number[] = [];\n    const isAncestorSameTableCursorLink: boolean[] = [];\n\n    let currentSection: LinkNodeSection | undefined = section;\n    while (currentSection) {\n      if (ancestors.includes(currentSection.id)) {\n        break;\n      }\n\n      ancestors.push(currentSection.id);\n\n      const linkedSectionId: number | undefined =\n        currentSection.linkSrcSectionRef;\n      let linkedSection: LinkNodeSection | undefined;\n      if (linkedSectionId) {\n        linkedSection = getSectionById(linkedSectionId);\n        const sourceColumn = linkedSection.linkSrcColRef;\n        const targetColumn = linkedSection.linkTargetColRef;\n        const sourceTable = getTableById(linkedSection.parentId);\n        isAncestorSameTableCursorLink.push(\n          sourceColumn === 0 &&\n          targetColumn === 0 &&\n          !sourceTable.isSummaryTable,\n        );\n      }\n\n      currentSection = linkedSection;\n    }\n\n    const table = getTableById(section.tableRef);\n    const { columns, isSummaryTable } = table;\n    const groupByCols = columns.filter(c => c.summarySourceCol);\n    const groupByColIds = new Set(groupByCols.map(c => c.summarySourceCol));\n    const mainNode: LinkNode = {\n      tableId: table.tableId,\n      isSummary: isSummaryTable,\n      isAttachments:\n        isSummaryTable && groupByCols.some(col => col.type === \"Attachments\"),\n      groupbyColumns: isSummaryTable ? groupByColIds : undefined,\n      widgetType: section.parentKey,\n      ancestors,\n      isAncestorSameTableCursorLink,\n      section: {\n        ...pick(\n          section,\n          \"id\",\n          \"parentId\",\n          \"parentKey\",\n          \"title\",\n          \"linkSrcSectionRef\",\n          \"linkSrcColRef\",\n          \"linkTargetColRef\",\n          \"allowSelectBy\",\n          \"selectedRowsActive\",\n        ),\n        tableRef: table.id,\n        tableId: table.tableId,\n      },\n    };\n\n    nodes.push(mainNode, ...buildRefColLinkNodes(columns, mainNode));\n  }\n  return nodes;\n}\n\nexport function buildRefColLinkNodes(\n  columns: LinkNodeColumn[],\n  parent: LinkNode,\n): LinkNode[] {\n  const nodes: LinkNode[] = [];\n  for (const column of columns) {\n    const tableId = getReferencedTableId(column.type);\n    if (tableId) {\n      nodes.push({\n        ...parent,\n        tableId,\n        column: pick(\n          column,\n          \"id\",\n          \"colId\",\n          \"label\",\n          \"type\",\n          \"summarySourceCol\",\n        ),\n        isAttachments: column.type == \"Attachments\",\n      });\n    }\n  }\n  return nodes;\n}\n\n// Returns true if this node corresponds to the special 'group' reflist column of a summary table\nexport function isSummaryGroup(node: LinkNode): boolean {\n  return node.isSummary && node.column?.colId === \"group\";\n}\n\n// Returns true is the link from `source` to `target` is valid, false otherwise.\nexport function isValidLink(source: LinkNode, target: LinkNode) {\n  // section must not be the same\n  if (source.section.id === target.section.id) {\n    return false;\n  }\n\n  // table must match\n  if (source.tableId !== target.tableId) {\n    return false;\n  }\n\n  // Can only link to the somewhat special 'group' reflist column of summary tables\n  // with another ref/reflist column that isn't also a group column\n  // because otherwise it's equivalent to the usual summary table linking but potentially slower\n  if (\n    (isSummaryGroup(source) && (!target.column || isSummaryGroup(target))) ||\n    isSummaryGroup(target)\n  ) {\n    return false;\n  }\n\n  // Cannot directly link a summary table to a column referencing the source table.\n  // Instead the ref column must link against the group column of the summary table, which is allowed above.\n  // The 'group' column name will be hidden from the options so it feels like linking using summaryness.\n  if (\n    (source.isSummary && !source.column && target.column) ||\n    (target.isSummary && !target.column && source.column)\n  ) {\n    return false;\n  }\n\n  // If the target is a summary table and we're linking based on 'summaryness' (i.e. there are no ref columns)\n  // then the source must be a less detailed summary table, i.e. having a subset of the groupby columns.\n  // (or they should be the same summary table for same-record linking, which this check allows through)\n  if (\n    !source.column &&\n    !target.column &&\n    target.isSummary &&\n    !(\n      source.isSummary &&\n      gutil.isSubset(source.groupbyColumns!, target.groupbyColumns!)\n    )\n  ) {\n    return false;\n  }\n\n  // cannot select from attachments, even though they're implemented as reflists\n  if (source.isAttachments || target.isAttachments) {\n    return false;\n  }\n\n  // cannot select from chart\n  if (source.widgetType === \"chart\") {\n    return false;\n  }\n\n  if (source.widgetType === \"custom\") {\n    // custom widget do not support linking by columns\n    if (source.tableId !== source.section.tableId) {\n      return false;\n    }\n\n    // custom widget must allow select by\n    if (!source.section.allowSelectBy) {\n      return false;\n    }\n  }\n\n  // The link must not create a cycle, unless it's only same-table cursor-links all the way to target\n  if (source.ancestors.includes(target.section.id)) {\n    // cycles only allowed for cursor links\n    if (source.column || target.column || source.isSummary) {\n      return false;\n    }\n\n    // If one of the section has custom row filter, we can't make cycles.\n    if (target.section.selectedRowsActive) {\n      return false;\n    }\n\n    // We know our ancestors cycle back around to ourselves\n    // - lets walk back along the cyclic portion of the ancestor chain and verify that each link in that chain is\n    //   a cursor-link\n\n    // e.g. if the current link graph is:\n    //                     A->B->TGT->C->D->SRC\n    //    (SRC.ancestors):[5][4] [3] [2][1] [0]\n    // We're verifying the new potential link SRC->TGT, which would turn the graph into:\n    //             [from SRC] -> TGT -> C -> D -> SRC -> [to TGT]\n    // (Note that A and B will be cut away, since we change TGT's link source)\n    //\n    // We need to make sure that each link going backwards from `TGT -> C -> D -> SRC` is a same-table-cursor-link,\n    // since we disallow cycles with other kinds of links.\n    // isAncestorCursorLink[i] will tell us if the link going into ancestors[i] is a same-table-cursor-link\n    // So before we step from i=0 (SRC) to i=1 (D), we check isAncestorCursorLink[0], which tells us about D->SRC\n    let i;\n    for (i = 0; i < source.ancestors.length; i++) {\n      // Walk backwards through the ancestors\n\n      // Once we hit the target section, we've seen all links that will be part of the cycle, and they've all been valid\n      if (source.ancestors[i] == target.section.id) {\n        break; // Success!\n      }\n\n      // Check that the link to the preceding section is valid\n      // NOTE! isAncestorSameTableCursorLink could be 1 shorter than ancestors!\n      // (e.g. if the graph looks like A->B->C, there's 3 ancestors but only two links)\n      // (however, if there's already a cycle, they'll be the same length ( [from C]->A->B->C, 3 ancestors & 3 links)\n      // If the link doesn't exist (shouldn't happen?) OR the link is not same-table-cursor, the cycle is invalid\n      if (\n        i >= source.isAncestorSameTableCursorLink.length ||\n        !source.isAncestorSameTableCursorLink[i]\n      ) {\n        return false;\n      }\n    }\n\n    // If we've hit the last ancestor and haven't found target, error out (shouldn't happen!, we checked for it)\n    if (i == source.ancestors.length) {\n      throw Error(\"Array doesn't include targetSection\");\n    }\n\n    // Yay, this is a valid cycle of same-table cursor-links\n  }\n\n  return true;\n}\n"
  },
  {
    "path": "app/common/LocaleCodes.ts",
    "content": "// This file was generated by core/buildtools/generate_locale_list.js at 2021-09-23T08:32:25.517Z\nexport const localeCodes = [\n  \"af-ZA\", \"ak-GH\", \"am-ET\", \"ar-AE\", \"ar-BH\", \"ar-DZ\", \"ar-EG\",\n  \"ar-IN\", \"ar-IQ\", \"ar-JO\", \"ar-KW\", \"ar-LB\", \"ar-LY\", \"ar-MA\",\n  \"ar-OM\", \"ar-QA\", \"ar-SA\", \"ar-SD\", \"ar-SS\", \"ar-SY\", \"ar-TN\",\n  \"ar-YE\", \"as-IN\", \"ast-ES\", \"az-AZ\", \"az-IR\", \"be-BY\", \"bem-ZM\",\n  \"bg-BG\", \"bn-BD\", \"bn-IN\", \"bo-CN\", \"bo-IN\", \"br-FR\", \"brx-IN\",\n  \"bs-BA\", \"ca-AD\", \"ca-ES\", \"ca-FR\", \"ca-IT\", \"ce-RU\", \"chr-US\",\n  \"ckb-IQ\", \"cs-CZ\", \"cy-GB\", \"da-DK\", \"de-AT\", \"de-BE\", \"de-CH\",\n  \"de-DE\", \"de-IT\", \"de-LI\", \"de-LU\", \"doi-IN\", \"dz-BT\", \"el-CY\",\n  \"el-GR\", \"en-AG\", \"en-AU\", \"en-BW\", \"en-CA\", \"en-DK\", \"en-GB\",\n  \"en-HK\", \"en-IE\", \"en-IL\", \"en-IN\", \"en-NG\", \"en-NZ\", \"en-PH\",\n  \"en-SC\", \"en-SG\", \"en-US\", \"en-ZA\", \"en-ZM\", \"en-ZW\", \"es-AR\",\n  \"es-BO\", \"es-CL\", \"es-CO\", \"es-CR\", \"es-CU\", \"es-DO\", \"es-EC\",\n  \"es-ES\", \"es-GT\", \"es-HN\", \"es-MX\", \"es-NI\", \"es-PA\", \"es-PE\",\n  \"es-PR\", \"es-PY\", \"es-SV\", \"es-US\", \"es-UY\", \"es-VE\", \"et-EE\",\n  \"eu-ES\", \"fa-IR\", \"ff-SN\", \"fi-FI\", \"fil-PH\", \"fo-FO\", \"fr-BE\",\n  \"fr-CA\", \"fr-CH\", \"fr-FR\", \"fr-LU\", \"fur-IT\", \"fy-DE\", \"fy-NL\",\n  \"ga-IE\", \"gd-GB\", \"gl-ES\", \"gu-IN\", \"gv-GB\", \"ha-NG\", \"he-IL\",\n  \"hi-IN\", \"hr-HR\", \"hsb-DE\", \"hu-HU\", \"hy-AM\", \"ia-FR\", \"id-ID\",\n  \"ig-NG\", \"is-IS\", \"it-CH\", \"it-IT\", \"ja-JP\", \"kab-DZ\", \"ka-GE\",\n  \"kk-KZ\", \"kl-GL\", \"km-KH\", \"kn-IN\", \"kok-IN\", \"ko-KR\", \"ks-IN\",\n  \"ku-TR\", \"kw-GB\", \"ky-KG\", \"lb-LU\", \"lg-UG\", \"ln-CD\", \"lo-LA\",\n  \"lt-LT\", \"lv-LV\", \"mai-IN\", \"mai-NP\", \"mfe-MU\", \"mg-MG\", \"mi-NZ\",\n  \"mk-MK\", \"ml-IN\", \"mni-IN\", \"mn-MN\", \"mr-IN\", \"ms-MY\", \"mt-MT\",\n  \"my-MM\", \"ne-NP\", \"nl-AW\", \"nl-BE\", \"nl-NL\", \"nn-NO\", \"om-ET\",\n  \"om-KE\", \"or-IN\", \"os-RU\", \"pa-IN\", \"pa-PK\", \"pl-PL\", \"ps-AF\",\n  \"pt-BR\", \"pt-PT\", \"ro-RO\", \"ru-RU\", \"ru-UA\", \"rw-RW\", \"sa-IN\",\n  \"sat-IN\", \"sd-IN\", \"se-NO\", \"si-LK\", \"sk-SK\", \"sl-SI\", \"so-DJ\",\n  \"so-ET\", \"so-KE\", \"so-SO\", \"sq-AL\", \"sq-MK\", \"sr-ME\", \"sr-RS\",\n  \"sv-FI\", \"sv-SE\", \"sw-KE\", \"sw-TZ\", \"ta-IN\", \"ta-LK\", \"te-IN\",\n  \"tg-TJ\", \"th-TH\", \"ti-ER\", \"ti-ET\", \"tk-TM\", \"to-TO\", \"tr-CY\",\n  \"tr-TR\", \"tt-RU\", \"ug-CN\", \"uk-UA\", \"ur-IN\", \"ur-PK\", \"uz-UZ\",\n  \"vi-VN\", \"wae-CH\", \"wo-SN\", \"xh-ZA\", \"yi-US\", \"yo-NG\", \"yue-HK\",\n  \"zh-CN\", \"zh-HK\", \"zh-SG\", \"zh-TW\", \"zu-ZA\",\n];\n"
  },
  {
    "path": "app/common/Locales.ts",
    "content": "import { nativeCompare } from \"app/common/gutil\";\nimport { localeCodes } from \"app/common/LocaleCodes\";\n\nimport * as LocaleCurrency from \"locale-currency\";\nimport * as LocaleCurrencyMap from \"locale-currency/map\";\n\nconst DEFAULT_CURRENCY = \"USD\";\n\nexport interface Locale {\n  name: string;\n  code: string;\n}\n\nexport let locales: readonly Locale[];\n\n// Intl.DisplayNames is only supported on recent browsers, so proceed with caution.\ntry {\n  const regionDisplay = new Intl.DisplayNames(\"en\", { type: \"region\" });\n  const languageDisplay = new Intl.DisplayNames(\"en\", { type: \"language\" });\n  const display = (code: string) => {\n    try {\n      const locale = new Intl.Locale(code);\n      const regionName = regionDisplay.of(locale.region!);\n      const languageName = languageDisplay.of(locale.language);\n      return `${regionName} (${languageName})`;\n    } catch (ex) {\n      return code;\n    }\n  };\n  // Leave only those that are supported by current system (can be translated to human readable form).\n  // Though, this file is in common, it is safe to filter by current system\n  // as the list should be already filtered by codes that are supported by the backend.\n  locales = Intl.DisplayNames.supportedLocalesOf(localeCodes).map((code) => {\n    return { name: display(code), code };\n  });\n} catch {\n  // Fall back to using the locale code as the display name.\n  locales = localeCodes.map(code => ({ name: code, code }));\n}\n\nexport interface Currency {\n  name: string;\n  code: string;\n}\n\nexport let currencies: readonly Currency[];\n\n// locale-currency package doesn't have South Sudanese pound currency or a default value for Kosovo\nLocaleCurrencyMap.SS = \"SSP\";\nLocaleCurrencyMap.XK = \"EUR\";\nconst currenciesCodes = Object.values(LocaleCurrencyMap);\nexport function getCurrency(code: string) {\n  const currency = LocaleCurrency.getCurrency(code ?? \"en-US\");\n  // Fallback to USD\n  return currency ?? DEFAULT_CURRENCY;\n}\n\n// Intl.DisplayNames is only supported on recent browsers, so proceed with caution.\ntry {\n  const currencyDisplay = new Intl.DisplayNames(\"en\", { type: \"currency\" });\n  currencies = [...new Set(currenciesCodes)].map((code) => {\n    return { name: currencyDisplay.of(code)!, code };\n  });\n} catch {\n  // Fall back to using the currency code as the display name.\n  currencies = [...new Set(currenciesCodes)].map((code) => {\n    return { name: code, code };\n  });\n}\n\ncurrencies = [...currencies].sort((a, b) => nativeCompare(a.code, b.code));\n\nexport function getCountryCode(locale: string) {\n  // We have some defaults defined.\n  if (locale === \"en\") { return \"US\"; }\n  let countryCode = locale.split(/[-_]/)[1];\n  if (countryCode) { return countryCode.toUpperCase(); }\n\n  // Some defaults that we support and can't be read from language code.\n  countryCode = {\n    uk: \"UA\", // Ukraine\n  }[locale] ?? locale.toUpperCase();\n\n  // Test if we can use language as a country code.\n  if (localeCodes.map(code => code.split(/[-_]/)[1]).includes(countryCode)) {\n    return countryCode;\n  }\n  return null;\n}\n"
  },
  {
    "path": "app/common/LoginSessionAPI.ts",
    "content": "import { normalizeEmail } from \"app/common/emails\";\nimport { UserPrefs } from \"app/common/Prefs\";\n\n// User profile info for the user. When using Cognito, it is fetched during login.\nexport interface UserProfile {\n  email: string;          // TODO: Used inconsistently: as lowercase login email or display email.\n  name: string;\n  disabledAt?: Date | null;\n  loginEmail?: string;    // When set, this is consistently normalized (lowercase) login email.\n  picture?: string | null; // when present, a url to a public image of unspecified dimensions.\n  anonymous?: boolean;   // when present, asserts whether user is anonymous (not authorized).\n  connectId?: string | null, // used by GristConnect to identify user in external provider.\n  loginMethod?: \"Google\" | \"Email + Password\" | \"External\";\n  locale?: string | null;\n  type?: string; // user type, e.g. 'login' or 'service'\n  extra?: Record<string, any>; // extra fields from the user profile, e.g. from OIDC.\n}\n\n// For describing install admin users and why they are install admins\nexport interface InstallAdminInfo {\n  user: UserProfile | null;\n  reason: string\n}\n\n/**\n * Tries to compare two user profiles to see if they represent the same user.\n * Note: if you have access to FullUser objects, comparing ids is more reliable.\n */\nexport function sameUser(a: UserProfile | FullUser, b: UserProfile | FullUser): boolean {\n  if (\"id\" in a && \"id\" in b) {\n    return a.id === b.id;\n  }\n  if (a.loginEmail && b.loginEmail) {\n    return a.loginEmail === b.loginEmail;\n  } else {\n    return normalizeEmail(a.email) === normalizeEmail(b.email);\n  }\n}\n\n// User profile including user id and user ref.  All information in it should\n// have been validated against database.\nexport interface FullUser extends UserProfile {\n  id: number;\n  ref?: string | null; // Not filled for anonymous users.\n  allowGoogleLogin?: boolean; // when present, specifies whether logging in via Google is possible.\n  isSupport?: boolean; // set if user is a special support user.\n  firstLoginAt?: Date | null;\n  prefs?: UserPrefs;\n  createdAt?: Date; // Not filled for anonymous users.\n}\n\nexport interface LoginSessionAPI {\n  /**\n   * Logs out by clearing all data in the session store besides the session cookie itself.\n   * Broadcasts the logged out state to all clients.\n   */\n  logout(): Promise<void>;\n\n  /**\n   * Replaces the user profile object in the session and broadcasts the new profile to all clients.\n   */\n  updateProfile(profile: UserProfile): Promise<void>;\n}\n"
  },
  {
    "path": "app/common/MemBuffer.js",
    "content": "const gutil = require(\"./gutil\");\nconst {arrayToString, stringToArray} = require(\"./arrayToString\");\n\n\n/**\n * Class for a dynamic memory buffer. You can optionally pass the number of bytes\n * to reserve initially.\n */\nfunction MemBuffer(optBytesToReserve) {\n  this.buffer = new ArrayBuffer(optBytesToReserve || 64);\n  this.asArray = new Uint8Array(this.buffer);\n  this.asDataView = new DataView(this.buffer);\n  this.startPos = 0;\n  this.endPos = 0;\n}\n\n// These are defined in gutil now because they are used there (and to avoid a circular import),\n// but were originally defined in MemBuffer and various code still uses them as MemBuffer members.\nMemBuffer.arrayToString = arrayToString;\nMemBuffer.stringToArray = stringToArray;\n\n/**\n * Returns the number of bytes in the buffer.\n */\nMemBuffer.prototype.size = function() {\n  return this.endPos - this.startPos;\n};\n\n/**\n * Returns the number of bytes reserved in the buffer for data. This is at least size().\n */\nMemBuffer.prototype.reserved = function() {\n  return this.buffer.byteLength - this.startPos;\n};\n\n/**\n * Reserves enough space in the buffer to hold a nbytes of data, counting the data already in the\n * buffer.\n */\nMemBuffer.prototype.reserve = function(nbytes) {\n  if (this.startPos + nbytes > this.buffer.byteLength) {\n    var origArray = new Uint8Array(this.buffer, this.startPos, this.size());\n    if (nbytes > this.buffer.byteLength) {\n      // At least double the size of the buffer.\n      var newBytes = Math.max(nbytes, this.buffer.byteLength * 2);\n      this.buffer = new ArrayBuffer(newBytes);\n      this.asArray = new Uint8Array(this.buffer);\n      this.asDataView = new DataView(this.buffer);\n    }\n    // If we did not allocate more space, this line will just move data to the beginning.\n    this.asArray.set(origArray);\n    this.endPos = this.size();\n    this.startPos = 0;\n  }\n};\n\n/**\n * Clears the buffer.\n */\nMemBuffer.prototype.clear = function() {\n  this.startPos = this.endPos = 0;\n  // If the buffer has grown somewhat big, use this chance to free the memory.\n  if (this.buffer.byteLength >= 256 * 1024) {\n    this.buffer = new ArrayBuffer(64);\n    this.asArray = new Uint8Array(this.buffer);\n    this.asDataView = new DataView(this.buffer);\n  }\n};\n\n/**\n * Returns a Uint8Array viewing all the data in the buffer. It is the caller's responsibility to\n * make a copy if needed to avoid it being affected by subsequent changes to the buffer.\n */\nMemBuffer.prototype.asByteArray = function() {\n  return new Uint8Array(this.buffer, this.startPos, this.size());\n};\n\n/**\n * Converts all buffer data to string using UTF8 encoding.\n * This is mainly for testing.\n */\nMemBuffer.prototype.toString = function() {\n  return arrayToString(this.asByteArray());\n};\n\n/*\n * (Dmitry 2017/03/20. Some unittests that include timing (e.g. Sandbox.js measuring serializing\n * of data using marshal.js) indicated that gutil.arrayCopyForward gets deoptimized. Narrowing it\n * down, I found it was because it was used with different argument types (Arrays, Buffers,\n * Uint8Arrays). To keep it optimized, we'll use a cloned copy of arrayCopyForward (for copying to\n * a Uint8Array) in this module.\n */\nlet arrayCopyForward = gutil.cloneFunc(gutil.arrayCopyForward);\n\n/**\n * Appends an array of bytes to this MemBuffer.\n * @param {Uint8Array|Buffer} bytes: Array of bytes to append. May be a Node Buffer.\n */\nMemBuffer.prototype.writeByteArray = function(bytes) {\n  // Note that the implementation is identical for Uint8Array and a Node Buffer.\n  this.reserve(this.size() + bytes.length);\n  arrayCopyForward(this.asArray, this.endPos, bytes, 0, bytes.length);\n  this.endPos += bytes.length;\n};\n\n/**\n * Encodes the given string in UTF8 and appends to the buffer.\n */\nif (typeof TextDecoder !== \"undefined\") {\n  MemBuffer.prototype.writeString = function(string) {\n    this.writeByteArray(stringToArray(string));\n  };\n} else {\n  // We can write faster without using stringToArray, to avoid allocating new buffers.\n  // We'll encode data in chunks reusing a single buffer. The buffer is a multiple of chunk size\n  // to have enough space for multi-byte characters.\n  var encodeChunkSize = 1024;\n  var encodeBufferPad = Buffer.alloc(encodeChunkSize * 4);\n\n  MemBuffer.prototype.writeString = function(string) {\n    // Reserve one byte per character initially (common case), but we'll reserve more below as\n    // needed.\n    this.reserve(this.size() + string.length);\n    for (var i = 0; i < string.length; i += encodeChunkSize) {\n      var bytesWritten = encodeBufferPad.write(string.slice(i, i + encodeChunkSize));\n      this.reserve(this.size() + bytesWritten);\n      arrayCopyForward(this.asArray, this.endPos, encodeBufferPad, 0, bytesWritten);\n      this.endPos += bytesWritten;\n    }\n  };\n}\n\n\nfunction makeWriteFunc(typeName, bytes, optLittleEndian) {\n  var setter = DataView.prototype[\"set\" + typeName];\n  return function(value) {\n    this.reserve(this.size() + bytes);\n    setter.call(this.asDataView, this.endPos, value, optLittleEndian);\n    this.endPos += bytes;\n  };\n}\n\n/**\n * The following methods append a value of the given type to the buffer.\n * These are analogous to Node Buffer's write* family of methods.\n */\nMemBuffer.prototype.writeInt8 = makeWriteFunc(\"Int8\", 1);\nMemBuffer.prototype.writeUint8 = makeWriteFunc(\"Uint8\", 1);\nMemBuffer.prototype.writeInt16LE = makeWriteFunc(\"Int16\", 2, true);\nMemBuffer.prototype.writeInt16BE = makeWriteFunc(\"Int16\", 2, false);\nMemBuffer.prototype.writeUint16LE = makeWriteFunc(\"Uint16\", 2, true);\nMemBuffer.prototype.writeUint16BE = makeWriteFunc(\"Uint16\", 2, false);\nMemBuffer.prototype.writeInt32LE = makeWriteFunc(\"Int32\", 4, true);\nMemBuffer.prototype.writeInt32BE = makeWriteFunc(\"Int32\", 4, false);\nMemBuffer.prototype.writeUint32LE = makeWriteFunc(\"Uint32\", 4, true);\nMemBuffer.prototype.writeUint32BE = makeWriteFunc(\"Uint32\", 4, false);\nMemBuffer.prototype.writeFloat32LE = makeWriteFunc(\"Float32\", 4, true);\nMemBuffer.prototype.writeFloat32BE = makeWriteFunc(\"Float32\", 4, false);\nMemBuffer.prototype.writeFloat64LE = makeWriteFunc(\"Float64\", 8, true);\nMemBuffer.prototype.writeFloat64BE = makeWriteFunc(\"Float64\", 8, false);\n\n/**\n * To consume data from an mbuf, the following pattern is recommended:\n *    var consumer = mbuf.makeConsumer();\n *    try {\n *      mbuf.readInt8(consumer);\n *      mbuf.readByteArray(consumer, len);\n *      ...\n *    } catch (e) {\n *      if (e.needMoreData) {\n *        ...\n *      }\n *    }\n *    mbuf.consume(consumer);\n */\nMemBuffer.prototype.makeConsumer = function() {\n  return new Consumer(this);\n};\n\n/**\n * After some data has been read via a consumer, mbuf.consume(consumer) will clear out the\n * consumed data from the buffer.\n */\nMemBuffer.prototype.consume = function(consumer) {\n  this.startPos = consumer.pos;\n  if (this.size() === 0) {\n    this.clear();\n    consumer.pos = this.startPos;\n  }\n};\n\n/**\n * Helper class for reading data from the buffer. It keeps track of an offset into the buffer\n * without changing anything in the MemBuffer itself. To affect the MemBuffer,\n * mbuf.consume(consumer) should be called.\n */\nfunction Consumer(mbuf) {\n  this.mbuf = mbuf;\n  this.pos = mbuf.startPos;\n}\n\n/**\n * Helper for reading data, used by MemBuffer's read* methods.\n */\nConsumer.prototype._consume = function(nbytes) {\n  var offset = this.pos;\n  if (this.pos + nbytes > this.mbuf.endPos) {\n    var err = new RangeError(\"MemBuffer: read past end\");\n    err.needMoreData = true;\n    err.consumedData = this.pos - this.mbuf.startPos;\n    throw err;\n  }\n  this.pos += nbytes;\n  return offset;\n};\n\n/**\n * Reads length bytes from the buffer using the passed-in consumer, as created by\n * mbuf.makeConsumer(). Returns a view on the underlying data.\n * @returns {Uint8Array} array of bytes viewing underlying MemBuffer data.\n */\nMemBuffer.prototype.readByteArraySlice = function(cons, length) {\n  return new Uint8Array(this.buffer, cons._consume(length), length);\n};\n\n/**\n * Reads length bytes from the buffer using the passed-in consumer.\n * @returns {Uint8Array} array of bytes that's a copy of the underlying data.\n */\nMemBuffer.prototype.readByteArray = function(cons, length) {\n  return new Uint8Array(this.readByteArraySlice(cons, length));\n};\n\n/**\n * Reads length bytes from the buffer using the passed-in consumer.\n * @returns {Buffer} copy of data as a Node Buffer.\n */\nMemBuffer.prototype.readBuffer = function(cons, length) {\n  return Buffer.from(this.readByteArraySlice(cons, length));\n};\n\n/**\n * Decodes byteLength bytes from the buffer using UTF8 and returns the resulting string. Uses the\n * passed-in consumer, as created by mbuf.makeConsumer().\n * @returns {string}\n */\nif (typeof TextDecoder !== \"undefined\") {\n  MemBuffer.prototype.readString = function(cons, byteLength) {\n    return arrayToString(this.readByteArraySlice(cons, byteLength));\n  };\n} else {\n  var decodeBuffer = Buffer.alloc(1024);\n  MemBuffer.prototype.readString = function(cons, byteLength) {\n    var offset = cons._consume(byteLength);\n    if (byteLength <= decodeBuffer.length) {\n      gutil.arrayCopyForward(decodeBuffer, 0, this.asArray, offset, byteLength);\n      return decodeBuffer.toString(\"utf8\", 0, byteLength);\n    } else {\n      return Buffer.from(new Uint8Array(this.buffer, offset, byteLength)).toString();\n    }\n  };\n}\n\nfunction makeReadFunc(typeName, bytes, optLittleEndian) {\n  var getter = DataView.prototype[\"get\" + typeName];\n  return function(cons) {\n    return getter.call(this.asDataView, cons._consume(bytes), optLittleEndian);\n  };\n}\n\n/**\n * The following methods read and return a value of the given type from the buffer using the\n * passed-in consumer, as created by mbuf.makeConsumer(). E.g.\n *    var consumer = mbuf.makeConsumer();\n *    mbuf.readInt8(consumer);\n *    mbuf.consume(consumer);\n * These are analogous to Node Buffer's read* family of methods.\n */\nMemBuffer.prototype.readInt8 = makeReadFunc(\"Int8\", 1);\nMemBuffer.prototype.readUint8 = makeReadFunc(\"Uint8\", 1);\nMemBuffer.prototype.readInt16LE = makeReadFunc(\"Int16\", 2, true);\nMemBuffer.prototype.readUint16LE = makeReadFunc(\"Uint16\", 2, true);\nMemBuffer.prototype.readInt16BE = makeReadFunc(\"Int16\", 2, false);\nMemBuffer.prototype.readUint16BE = makeReadFunc(\"Uint16\", 2, false);\nMemBuffer.prototype.readInt32LE = makeReadFunc(\"Int32\", 4, true);\nMemBuffer.prototype.readUint32LE = makeReadFunc(\"Uint32\", 4, true);\nMemBuffer.prototype.readInt32BE = makeReadFunc(\"Int32\", 4, false);\nMemBuffer.prototype.readUint32BE = makeReadFunc(\"Uint32\", 4, false);\nMemBuffer.prototype.readFloat32LE = makeReadFunc(\"Float32\", 4, true);\nMemBuffer.prototype.readFloat32BE = makeReadFunc(\"Float32\", 4, false);\nMemBuffer.prototype.readFloat64LE = makeReadFunc(\"Float64\", 8, true);\nMemBuffer.prototype.readFloat64BE = makeReadFunc(\"Float64\", 8, false);\n\nmodule.exports = MemBuffer;\n"
  },
  {
    "path": "app/common/NumberFormat.ts",
    "content": "/**\n * Here are the most relevant formats we want to support.\n *   -1234.56     Plain\n *   -1,234.56    Number (with separators)\n *   12.34%       Percent\n *   1.23E3       Scientific\n *   $(1,234.56)  Accounting\n *   (1,234.56)   Financial\n *   -$1,234.56   Currency\n *\n * We implement a button-based UI, using one selector button to choose mode:\n *    none  = NumMode undefined (plain number, no thousand separators)\n *    `$`   = NumMode 'currency'\n *    `,`   = NumMode 'decimal' (plain number, with thousand separators)\n *    `%`   = NumMode 'percent'\n *    `Exp` = NumMode 'scientific'\n * A second toggle button is `(-)` for Sign, to use parentheses rather than \"-\" for negative\n * numbers. It is Ignored and disabled when mode is 'scientific'.\n */\n\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\nimport { clamp } from \"app/common/gutil\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { FormatOptions } from \"app/common/ValueFormatter\";\n\nimport * as LocaleCurrency from \"locale-currency\";\n\n// Options for number formatting.\nexport const NumMode = StringUnion(\"currency\", \"decimal\", \"percent\", \"scientific\");\nexport type NumMode = typeof NumMode.type;\nexport type NumSign = \"parens\";\n\nexport interface NumberFormatOptions extends FormatOptions {\n  numMode?: NumMode | null;\n  numSign?: NumSign | null;\n  decimals?: number | null;      // aka minimum fraction digits\n  maxDecimals?: number | null;\n  currency?: string | null;\n}\n\nexport function getCurrency(options: NumberFormatOptions, docSettings: DocumentSettings): string {\n  return options.currency || docSettings.currency || LocaleCurrency.getCurrency(docSettings.locale ?? \"en-US\");\n}\n\nexport function buildNumberFormat(options: NumberFormatOptions, docSettings: DocumentSettings): Intl.NumberFormat {\n  const currency = getCurrency(options, docSettings);\n  const nfOptions: Intl.NumberFormatOptions = parseNumMode(options.numMode, currency);\n  // numSign is implemented outside of Intl.NumberFormat since the latter's similar 'currencySign'\n  // option is not well-supported, and doesn't apply to non-currency formats.\n\n  if (options.decimals !== undefined && options.decimals !== null) {\n    // Should be at least 0\n    nfOptions.minimumFractionDigits = clamp(Number(options.decimals), 0, 20);\n  }\n\n  // maximumFractionDigits must not be less than the minimum, so we need to know the minimum\n  // implied by numMode.\n  const tmp = new Intl.NumberFormat(docSettings.locale, nfOptions).resolvedOptions();\n\n  if (options.maxDecimals !== undefined && options.maxDecimals !== null) {\n    // Should be at least 0 and at least minimumFractionDigits.\n    nfOptions.maximumFractionDigits = clamp(Number(options.maxDecimals), tmp.minimumFractionDigits || 0, 20);\n  } else if (!options.numMode) {\n    // For the default format, keep max digits at 10 as we had before.\n    nfOptions.maximumFractionDigits = clamp(10, tmp.minimumFractionDigits || 0, 20);\n  }\n\n  return new Intl.NumberFormat(docSettings.locale, nfOptions);\n}\n\n// Safari 13 and some other browsers don't support narrowSymbol option:\n// https://github.com/mdn/browser-compat-data/issues/8985\n// https://caniuse.com/?search=currencyDisplay\nconst currencyDisplay = (function() {\n  try {\n    new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\", currencyDisplay: \"narrowSymbol\" });\n    return \"narrowSymbol\";\n  } catch (err) {\n    return \"symbol\";\n  }\n})();\n\nexport function parseNumMode(numMode?: NumMode | null, currency?: string): Intl.NumberFormatOptions {\n  switch (numMode) {\n    case \"currency\": return { style: \"currency\", currency, currencyDisplay };\n    case \"decimal\": return { useGrouping: true };\n    case \"percent\": return { style: \"percent\" };\n    // TODO 'notation' option (and therefore numMode 'scientific') works on recent Firefox and\n    // Chrome, not on Safari or Node 10.\n    case \"scientific\": return { notation: \"scientific\" } as Intl.NumberFormatOptions;\n    default: return { useGrouping: false };\n  }\n}\n"
  },
  {
    "path": "app/common/NumberParse.ts",
    "content": "/**\n * Counterpart of NumberFormat.ts.\n * Generic functionality for parsing numbers formatted by Intl.NumberFormat,\n * not tied to documents or anything.\n */\n\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\nimport { getDistinctValues } from \"app/common/gutil\";\nimport { getCurrency, NumberFormatOptions, NumMode, parseNumMode } from \"app/common/NumberFormat\";\nimport { buildNumberFormat } from \"app/common/NumberFormat\";\n\nimport escapeRegExp from \"lodash/escapeRegExp\";\nimport last from \"lodash/last\";\n\n// Possible values of Intl.NumberFormat.formatToParts[i].type\n// Seems Intl.NumberFormatPartTypes is not quite complete\ntype NumberFormatPartTypes = Intl.NumberFormatPartTypes | \"exponentSeparator\";\n\n/**\n * Returns a map converting the decimal digits used in the given formatter\n * to the digits 0123456789.\n * Excludes digits which don't need conversion, so for many locales this is empty.\n */\nfunction getDigitsMap(locale: string) {\n  const formatter = Intl.NumberFormat(locale);\n  const result = new Map<string, string>();\n  for (let i = 0; i < 10; i++) {\n    const digit = String(i);\n    const localeDigit = formatter.format(i);\n    if (localeDigit !== digit) {\n      result.set(localeDigit, digit);\n    }\n  }\n  return result;\n}\n\ninterface ParsedOptions {\n  isPercent: boolean;\n  isCurrency: boolean;\n  isParenthesised: boolean;\n  hasDigitGroupSeparator: boolean;\n  isScientific: boolean;\n}\n\nexport default class NumberParse {\n  // Regex for whitespace and some control characters we need to remove\n  // 200e = Left-to-right mark\n  // 200f = Right-to-left mark\n  // 061c = Arabic letter mark\n  public static readonly removeCharsRegex = /[\\s\\u200e\\u200f\\u061c]/g;\n\n  public static fromSettings(docSettings: DocumentSettings, options: NumberFormatOptions = {}) {\n    return new NumberParse(docSettings.locale, getCurrency(options, docSettings));\n  }\n\n  // Many attributes are public for easy testing.\n  public readonly currencySymbol: string;\n  public readonly percentageSymbol: string;\n  public readonly digitGroupSeparator: string;\n  public readonly digitGroupSeparatorCurrency: string;\n  public readonly exponentSeparator: string;\n  public readonly decimalSeparator: string;\n  public readonly minusSign: string;\n  public readonly defaultNumDecimalsCurrency: number;\n\n  public readonly digitsMap: Map<string, string>;\n\n  public readonly currencyEndsInMinusSign: boolean;\n\n  private readonly _exponentSeparatorRegex: RegExp;\n  private readonly _digitGroupSeparatorRegex: RegExp;\n\n  // Function which replaces keys of digitsMap (i.e. locale-specific digits)\n  // with corresponding digits from 0123456789.\n  private readonly _replaceDigits: (s: string) => string;\n\n  constructor(public readonly locale: string, public readonly currency: string) {\n    const parts = new Map<NumMode, Intl.NumberFormatPart[]>();\n    for (const numMode of NumMode.values) {\n      const formatter = Intl.NumberFormat(locale, parseNumMode(numMode, currency));\n      const formatParts = formatter.formatToParts(-1234567.5678);\n      parts.set(numMode, formatParts);\n    }\n\n    function getPart(partType: NumberFormatPartTypes, numMode: NumMode = \"decimal\"): string {\n      const part = parts.get(numMode)!.find(p => p.type === partType);\n      // Only time we expect `part` to be undefined is for digitGroupSeparatorCurrency\n      return part?.value || \"\";\n    }\n\n    this.currencySymbol = getPart(\"currency\", \"currency\");\n    this.percentageSymbol = getPart(\"percentSign\", \"percent\");\n    this.exponentSeparator = getPart(\"exponentSeparator\", \"scientific\");\n    this.minusSign = getPart(\"minusSign\");\n    this.decimalSeparator = getPart(\"decimal\");\n\n    // Separators for groups of digits, typically groups of 3, i.e. 'thousands separators'.\n    // A few locales have different separators for currency and non-currency.\n    // We check for both but don't check which one is used, currency or not.\n    this.digitGroupSeparator = getPart(\"group\");\n    this.digitGroupSeparatorCurrency = getPart(\"group\", \"currency\");\n\n    // A few locales format negative currency amounts ending in '-', e.g. '€ 1,00-'\n    this.currencyEndsInMinusSign = last(parts.get(\"currency\"))!.type === \"minusSign\";\n\n    // Default number of fractional digits for currency,\n    // e.g. this is 2 for USD because 1 is formatted as $1.00\n    this.defaultNumDecimalsCurrency = getPart(\"fraction\", \"currency\")?.length || 0;\n\n    // Since JS and Python allow both e and E for scientific notation, it seems fair that other\n    // locales should be case insensitive for this.\n    this._exponentSeparatorRegex = new RegExp(escapeRegExp(this.exponentSeparator), \"i\");\n\n    // Overall the parser is quite lax about digit separators.\n    // We only require that the separator is followed by at least 2 digits,\n    // because India groups digits in pairs after the first 3.\n    // More careful checking is probably more complicated than is worth it.\n    this._digitGroupSeparatorRegex = new RegExp(\n      `[${escapeRegExp(\n        this.digitGroupSeparator +\n        this.digitGroupSeparatorCurrency,\n      )}](\\\\d\\\\d)`,\n      \"g\",\n    );\n\n    const digitsMap = this.digitsMap = getDigitsMap(locale);\n    if (digitsMap.size === 0) {\n      this._replaceDigits = (s: string) => s;\n    } else {\n      const digitsRegex = new RegExp([...digitsMap.keys()].join(\"|\"), \"g\");\n      this._replaceDigits = (s: string) => s.replace(digitsRegex, d => digitsMap.get(d) || d);\n    }\n  }\n\n  /**\n   * If the string looks like a number formatted by Grist using this parser's locale and currency (or at least close)\n   * then returns an object where:\n   *   - `result` is that number, the only thing most callers need\n   *   - `cleaned` is a string derived from `value` which can be parsed directly by Number, although `result`\n   *      is still processed a bit further than that, e.g. dividing by 100 for percentages.\n   *   - `options` describes how the number was apparently formatted.\n   *\n   * Returns null otherwise.\n   */\n  public parse(value: string): { result: number, cleaned: string, options: ParsedOptions } | null {\n    // Remove characters before checking for parentheses on the ends of the string.\n    const [value2, isCurrency] = removeSymbol(value, this.currencySymbol);\n    const [value3, isPercent] = removeSymbol(value2, this.percentageSymbol);\n\n    // Remove whitespace and special characters, after currency because some currencies contain spaces.\n    value = value3.replace(NumberParse.removeCharsRegex, \"\");\n\n    const isParenthesised = value.startsWith(\"(\") && value.endsWith(\")\");\n    if (isParenthesised) {\n      value = value.substring(1, value.length - 1);\n    }\n\n    // Must check for empty string directly because Number('') is 0 :facepalm:\n    // Check early so we can return early for performance.\n    // Nothing after this should potentially produce an empty string.\n    if (value === \"\") {\n      return null;\n    }\n\n    // Replace various symbols with the standard versions recognised by JS Number.\n    // Note that this also allows the 'standard' symbols ('e', '.', '-', and '0123456789')\n    // even if the locale doesn't use them when formatting,\n    // although '.' will still be removed if it's a digit separator.\n\n    // Check for exponent separator before replacing digits\n    // because it can contain locale-specific digits representing '10' as in 'x10^'.\n    const withExponent = value;\n    value = value.replace(this._exponentSeparatorRegex, \"e\");\n    const isScientific = withExponent !== value;\n\n    value = this._replaceDigits(value);\n\n    // Must come after replacing digits because the regex uses \\d\n    // which doesn't work for locale-specific digits.\n    // This simply removes the separators, $1 is a captured group of digits which we keep.\n    const withSeparators = value;\n    value = value.replace(this._digitGroupSeparatorRegex, \"$1\");\n    const hasDigitGroupSeparator = withSeparators !== value;\n\n    // Must come after the digit separator replacement\n    // because the digit separator might be '.'\n    value = value.replace(this.decimalSeparator, \".\");\n\n    // .replace with a string only replaces once,\n    // and a number can contain two minus signs when using scientific notation\n    value = value.replace(this.minusSign, \"-\");\n    value = value.replace(this.minusSign, \"-\");\n\n    // Move '-' from the end to the beginning when appropriate (which is rare)\n    if (isCurrency && this.currencyEndsInMinusSign && value.endsWith(\"-\")) {\n      value = \"-\" + value.substring(0, value.length - 1);\n    }\n\n    // Number is more strict than parseFloat which allows extra trailing characters.\n    let result = Number(value);\n    if (isNaN(result)) {\n      return null;\n    }\n\n    // Parentheses represent a negative number, e.g. (123) -> -123\n    // (-123) is treated as an error\n    if (isParenthesised) {\n      if (result <= 0) {\n        return null;\n      }\n      result = -result;\n    }\n\n    if (isPercent) {\n      result *= 0.01;\n    }\n\n    return {\n      result,\n      cleaned: value,\n      options: { isCurrency, isPercent, isParenthesised, hasDigitGroupSeparator, isScientific },\n    };\n  }\n\n  public guessOptions(values: (string | null)[]): NumberFormatOptions {\n    // null: undecided\n    // true: negative numbers should be parenthesised\n    // false: they should not\n    let parens: boolean | null = null;\n\n    // If any of the numbers have thousands separators, that's enough to guess that option\n    let anyHasDigitGroupSeparator = false;\n\n    // Minimum number of decimal places, guessed by looking for trailing 0s after the decimal point\n    let decimals = 0;\n    const decimalsRegex = /\\.\\d+/;\n    // Maximum number of decimal places. We never actually guess a value for this option,\n    // but for currencies we need to check if there are fewer decimal places than the default.\n    let maxDecimals = 0;\n\n    // Keep track of the number of modes seen to pick the most common\n    const modes = {} as Record<NumMode, number>;\n    for (const mode of NumMode.values) {\n      modes[mode] = 0;\n    }\n\n    for (const value of getDistinctValues(values)) {\n      if (!value) {\n        continue;\n      }\n      const parsed = this.parse(value);\n      if (!parsed) {\n        continue;\n      }\n      const {\n        result,\n        cleaned,\n        options: { isCurrency, isPercent, isParenthesised, hasDigitGroupSeparator, isScientific },\n      } = parsed;\n\n      if (result < 0 && !isParenthesised) {\n        // If we see a negative number not surrounded by parens, assume that any other parens mean something else\n        parens = false;\n      } else if (parens === null && isParenthesised) {\n        // If we're still unsure about parens (i.e. the above case hasn't been encountered)\n        // then one parenthesised number is enough to guess that the parens option should be used.\n        parens = true;\n      }\n\n      // If any of the numbers have thousands separators, that's enough to guess that option\n      anyHasDigitGroupSeparator = anyHasDigitGroupSeparator || hasDigitGroupSeparator;\n\n      let mode: NumMode = \"decimal\";\n      if (isCurrency) {\n        mode = \"currency\";\n      } else if (isPercent) {\n        mode = \"percent\";\n      } else if (isScientific) {\n        mode = \"scientific\";\n      }\n      modes[mode] += 1;\n\n      const decimalsMatch = decimalsRegex.exec(cleaned);\n      if (decimalsMatch) {\n        // Number of digits after the '.' (which is part of the match, hence the -1)\n        const numDecimals = decimalsMatch[0].length - 1;\n        maxDecimals = Math.max(maxDecimals, numDecimals);\n        if (decimalsMatch[0].endsWith(\"0\")) {\n          decimals = Math.max(decimals, numDecimals);\n        }\n      }\n    }\n\n    const maxCount = Math.max(...Object.values(modes));\n    if (maxCount === 0) {\n      // No numbers parsed at all, so don't guess any options\n      return {};\n    }\n\n    const result: NumberFormatOptions = {};\n\n    // Find the most common mode.\n    const maxMode: NumMode = NumMode.values.find(k => modes[k] === maxCount)!;\n\n    // 'decimal' is the default mode above when counting,\n    // but only guess it as an actual option if digit separators were used at least once.\n    if (maxMode !== \"decimal\" || anyHasDigitGroupSeparator) {\n      result.numMode = maxMode;\n    }\n\n    if (parens) {\n      result.numSign = \"parens\";\n    }\n\n    // Specify minimum number of decimal places if we saw any trailing 0s after '.'\n    // Otherwise explicitly set it to 0 if needed to suppress the default for that currency.\n    if (decimals > 0 || maxMode === \"currency\" && maxDecimals < this.defaultNumDecimalsCurrency) {\n      result.decimals = decimals;\n    }\n\n    // We should only set maxDecimals if the default maxDecimals is too low.\n    const tmpNF = buildNumberFormat(result, { locale: this.locale, currency: this.currency }).resolvedOptions();\n    if (maxDecimals > tmpNF.maximumFractionDigits) {\n      result.maxDecimals = maxDecimals;\n    }\n\n    return result;\n  }\n}\n\n/**\n * Returns a tuple [removed, wasPresent]\n * - `removed` is the given string `value` with `symbol` removed at most once.\n * - `wasPresent` is `true` if `symbol` was present in `value` and was thus removed.\n */\nfunction removeSymbol(value: string, symbol: string): [string, boolean] {\n  const removed = value.replace(symbol, \"\");\n  const wasPresent = removed.length < value.length;\n  return [removed, wasPresent];\n}\n"
  },
  {
    "path": "app/common/PluginInstance.ts",
    "content": "import { InactivityTimer } from \"app/common/InactivityTimer\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { BarePlugin } from \"app/plugin/PluginManifest\";\nimport { Implementation } from \"app/plugin/PluginManifest\";\nimport { RenderOptions, RenderTarget } from \"app/plugin/RenderOptions\";\n\nimport { IForwarderDest, IMessage, IMsgCustom, IMsgRpcCall, IRpcLogger, MsgType, Rpc } from \"grain-rpc\";\nimport { Checker } from \"ts-interface-checker\";\n\nexport type ComponentKind = \"safeBrowser\" | \"safePython\" | \"unsafeNode\";\n\n// Describes a function that appends some html content to `containerElement` given some\n// options. Useful for provided by a plugin.\nexport type TargetRenderFunc = (containerElement: HTMLElement, options?: RenderOptions) => void;\n\n/**\n * The `BaseComponent` is the base implementation for a plugins' component. It exposes methods\n * related to its activation. It provides basic features including the inactivity timer, activated\n * state for the component. A custom component must override the `deactivateImplementation`,\n * `activeImplementation` and `useRemoteAPI` methods.\n */\nexport abstract class BaseComponent implements IForwarderDest {\n  public inactivityTimer: InactivityTimer;\n  private _activated: boolean = false;\n\n  constructor(plugin: BarePlugin, private _logger: IRpcLogger) {\n    const deactivate = plugin.components.deactivate;\n    const delay = (deactivate?.inactivitySec) ? deactivate.inactivitySec : 300;\n    this.inactivityTimer = new InactivityTimer(() => this.deactivate(), delay * 1000);\n  }\n\n  /**\n   * Wether the Component component have been activated.\n   */\n  public activated(): boolean {\n    return this._activated;\n  }\n\n  /**\n   * Activates the component.\n   */\n  public async activate(): Promise<void> {\n    if (this._logger.info) { this._logger.info(\"Activating plugin component\"); }\n    await this.activateImplementation();\n    this._activated = true;\n    this.inactivityTimer.enable();\n  }\n\n  /**\n   * Force deactivate the component.\n   */\n  public async deactivate(): Promise<void> {\n    if (this._activated) {\n      if (this._logger.info) { this._logger.info(\"Deactivating plugin component\"); }\n      this._activated = false;\n      // Cancel the timer to ensure we don't have an unnecessary hanging timeout (in tests it will\n      // prevent node from exiting, but also it's just wasteful).\n      this.inactivityTimer.disable();\n      try {\n        await this.deactivateImplementation();\n      } catch (e) {\n        // If it fails, we warn and swallow the exception (or it would be an unhandled rejection).\n        if (this._logger.warn) { this._logger.warn(`Deactivate failed: ${e.message}`); }\n      }\n    }\n  }\n\n  public async forwardCall(c: IMsgRpcCall): Promise<any> {\n    if (!this._activated) { await this.activate(); }\n    return await this.inactivityTimer.disableUntilFinish(this.doForwardCall(c));\n  }\n\n  public async forwardMessage(msg: IMsgCustom): Promise<any> {\n    if (!this._activated) { await this.activate(); }\n    this.inactivityTimer.ping();\n    this.doForwardMessage(msg); // eslint-disable-line @typescript-eslint/no-floating-promises\n  }\n\n  protected abstract doForwardCall(c: IMsgRpcCall): Promise<any>;\n\n  protected abstract doForwardMessage(msg: IMsgCustom): Promise<any>;\n\n  protected abstract deactivateImplementation(): Promise<void>;\n\n  protected abstract activateImplementation(): Promise<void>;\n}\n\n/**\n * Node Implementation for the PluginElement interface. A PluginInstance take care of activation of\n * the the plugins's components (activating, timing and deactivating), and create the api's for each contributions.\n *\n * Do not try to instantiate yourself, PluginManager does it for you. Instead use the\n * PluginManager.getPlugin(id) method that get instances for you.\n *\n */\nexport class PluginInstance {\n  public rpc: Rpc;\n  public safeBrowser?: BaseComponent;\n  public unsafeNode?: BaseComponent;\n  public safePython?: BaseComponent;\n\n  private  _renderTargets = new Map<RenderTarget, TargetRenderFunc>();\n\n  private _nextRenderTargetId = 0;\n\n  constructor(public definition: LocalPlugin, rpcLogger: IRpcLogger) {\n    const rpc = this.rpc = new Rpc({ logger: rpcLogger });\n    rpc.setSendMessage((mssg: any) => rpc.receiveMessage(mssg));\n\n    this._renderTargets.set(\"fullscreen\", renderFullScreen);\n  }\n\n  /**\n   * Create an instance for the implementation, this implementation is specific to node environment.\n   */\n  public getStub<Iface>(implementation: Implementation, checker: Checker): Iface {\n    const components: any = this.definition.manifest.components;\n    // the component forwarder was registered under the same relative path that was used to declare\n    // it in the manifest\n    const forwardName = components[implementation.component];\n    return this.rpc.getStubForward<Iface>(forwardName, implementation.name, checker);\n  }\n\n  /**\n   * Stop and clean up all components of this plugin.\n   */\n  public async shutdown(): Promise<void> {\n    await Promise.all([\n      this.safeBrowser?.deactivate(),\n      this.safePython?.deactivate(),\n      this.unsafeNode?.deactivate(),\n    ]);\n  }\n\n  /**\n   * Create a render target and return its identifier. When a plugin calls `render` with `inline`\n   * mode and this identifier, it will append the safe browser process to `element`.\n   */\n  public addRenderTarget(renderPluginContent: TargetRenderFunc): number {\n    const id = this._nextRenderTargetId++;\n    this._renderTargets.set(id, renderPluginContent);\n    return id;\n  }\n\n  /**\n   * Get the function that render an HTML element based on RenderTarget and RenderOptions.\n   */\n  public getRenderTarget(target: RenderTarget, options?: RenderOptions): TargetRenderFunc {\n    const targetRenderPluginContent =  this._renderTargets.get(target);\n    if (!targetRenderPluginContent) {\n      throw new Error(`Unknown render target ${target}`);\n    }\n    return (containerElement, opts) => targetRenderPluginContent(containerElement, opts || options);\n  }\n\n  /**\n   * Removes the render target.\n   */\n  public removeRenderTarget(target: RenderTarget): boolean {\n    return this._renderTargets.delete(target);\n  }\n}\n\n/**\n * Renders safe browser plugin in fullscreen.\n */\nfunction renderFullScreen(element: Element) {\n  element.classList.add(\"plugin_instance_fullscreen\");\n  document.body.appendChild(element);\n}\n\n// Basically the union of relevant interfaces of console and server log.\nexport interface BaseLogger {\n  log?(message: string, ...args: any[]): void;\n  debug?(message: string, ...args: any[]): void;\n  warn?(message: string, ...args: any[]): void;\n}\n\n/**\n * Create IRpcLogger which logs to console or server log with the given prefix. Specifically will\n * warn using baseLog.warn, and log info using baseLog.debug or baseLog.log, as available.\n */\nexport function createRpcLogger(baseLog: BaseLogger, prefix: string): IRpcLogger {\n  const info = baseLog.debug || baseLog.log;\n  const warn = baseLog.warn;\n  return {\n    warn: warn && ((msg: string) => warn(\"%s %s\", prefix, msg)),\n    info: info && ((msg: string) => info(\"%s %s\", prefix, msg)),\n  };\n}\n\n/**\n * If msec milliseconds pass without receiving a Ready message, print the given message as a\n * warning.\n * TODO: I propose making it a method of rpc itself, as rpc.warnIfNotReady(msec, message). Until\n * we have that, this implements it via an ugly hack.\n */\nexport function warnIfNotReady(rpc: Rpc, msec: number, message: string): void {\n  if (!(rpc as any)._logger.warn) { return; }\n  const timer = setTimeout(() => (rpc as any)._logger.warn(message), msec);\n  const origDispatch = (rpc as any)._dispatch;\n  (rpc as any)._dispatch = (msg: IMessage) => {\n    if (msg.mtype === MsgType.Ready) { clearTimeout(timer); }\n    origDispatch.call(rpc, msg);\n  };\n}\n"
  },
  {
    "path": "app/common/PredicateFormula.ts",
    "content": "/**\n * Representation and compilation of predicate formulas.\n *\n * An example of a predicate formula is: \"rec.office == 'Seattle' and user.email in ['sally@', 'xie@']\".\n * These formulas are parsed in Python into a tree with nodes of the form [NODE_TYPE, ...args].\n * See sandbox/grist/predicate_formula.py for details.\n *\n * This module includes typings for the nodes, and the compilePredicateFormula() function that\n * turns such trees into actual predicate functions.\n */\nimport { CellValue, RowRecord } from \"app/common/DocActions\";\nimport { ErrorWithCode } from \"app/common/ErrorWithCode\";\nimport { InfoView } from \"app/common/RecordView\";\nimport { UserInfo } from \"app/common/User\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nimport constant from \"lodash/constant\";\n\n/**\n * Representation of a parsed predicate formula.\n */\nexport type PrimitiveCellValue = number | string | boolean | null;\nexport type ParsedPredicateFormula = [string, ...(ParsedPredicateFormula | PrimitiveCellValue)[]];\n\n/**\n * Inputs to a predicate formula function.\n */\nexport interface PredicateFormulaInput {\n  user?: UserInfo;\n  /**\n   * Used in:\n   * - ACL formulas, depending on the action user is taking, it may represent\n   *   - new version, if user is creating a row\n   *   - old version, if user is changing the row\n   * - Dropdown conditions, where it represents the current version of the record.\n   * - Trigger conditions, where it represents the new version of the record (might be empty row for deleted rows).\n   */\n  rec?: RowRecord | InfoView;\n  /**\n   * Used only in ACL formulas, represents the new version of the record when user is proposing a change\n   * to an existing row.\n   */\n  newRec?: InfoView;\n  /**\n   * Used only in trigger conditions, always represents the old version of the record (might be empty row\n   * for new rows).\n   */\n  oldRec?: RowRecord | InfoView;\n  docId?: string;\n  choice?: string | RowRecord | InfoView;\n}\n\n/**\n * The result of compiling ParsedPredicateFormula.\n */\nexport type CompiledPredicateFormula = (input: PredicateFormulaInput) => boolean;\n\nconst GRIST_CONSTANTS: Record<string, string> = {\n  EDITOR: \"editors\",\n  OWNER: \"owners\",\n  VIEWER: \"viewers\",\n};\n\n/**\n * An intermediate predicate formula returned during compilation, which may return\n * a non-boolean value.\n */\ntype IntermediatePredicateFormula = (input: PredicateFormulaInput) => any;\n\nexport interface CompilePredicateFormulaOptions {\n  /** Defaults to `'acl'`. */\n  variant?: \"acl\" | \"dropdown-condition\" | \"trigger\";\n}\n\n/**\n * Compiles a parsed predicate formula and returns it.\n */\nexport function compilePredicateFormula(\n  parsedPredicateFormula: ParsedPredicateFormula,\n  options: CompilePredicateFormulaOptions = {},\n): CompiledPredicateFormula {\n  const { variant = \"acl\" } = options;\n\n  function compileNode(node: ParsedPredicateFormula): IntermediatePredicateFormula {\n    const rawArgs = node.slice(1);\n    const args = rawArgs as ParsedPredicateFormula[];\n    switch (node[0]) {\n      case \"And\":   { const parts = args.map(compileNode); return input => parts.every(p => p(input)); }\n      case \"Or\":    { const parts = args.map(compileNode); return input => parts.some(p => p(input)); }\n      case \"Add\":   return compileAndCombine(args, ([a, b]) => a + b);\n      case \"Sub\":   return compileAndCombine(args, ([a, b]) => a - b);\n      case \"Mult\":  return compileAndCombine(args, ([a, b]) => a * b);\n      case \"Div\":   return compileAndCombine(args, ([a, b]) => a / b);\n      case \"Mod\":   return compileAndCombine(args, ([a, b]) => a % b);\n      case \"Not\":   return compileAndCombine(args, ([a]) => !a);\n      case \"Eq\":    return compileAndCombine(args, ([a, b]) => a === b);\n      case \"NotEq\": return compileAndCombine(args, ([a, b]) => a !== b);\n      case \"Lt\":    return compileAndCombine(args, ([a, b]) => a < b);\n      case \"LtE\":   return compileAndCombine(args, ([a, b]) => a <= b);\n      case \"Gt\":    return compileAndCombine(args, ([a, b]) => a > b);\n      case \"GtE\":   return compileAndCombine(args, ([a, b]) => a >= b);\n      case \"Is\":    return compileAndCombine(args, ([a, b]) => a === b);\n      case \"IsNot\": return compileAndCombine(args, ([a, b]) => a !== b);\n      case \"In\":    return compileAndCombine(args, ([a, b]) => includes(b, a));\n      case \"NotIn\": return compileAndCombine(args, ([a, b]) => !includes(b, a));\n      case \"List\":  return compileAndCombine(args, values => values);\n      case \"Const\": return constant(node[1] as CellValue);\n      case \"Name\": {\n        const name = rawArgs[0] as keyof PredicateFormulaInput;\n        if (GRIST_CONSTANTS[name]) { return constant(GRIST_CONSTANTS[name]); }\n\n        let validNames: string[];\n        switch (variant) {\n          case \"acl\": {\n            validNames = [\"newRec\", \"rec\", \"user\"];\n            break;\n          }\n          case \"dropdown-condition\": {\n            validNames = [\"rec\", \"choice\", \"user\"];\n            break;\n          }\n          case \"trigger\": {\n            validNames = [\"rec\", \"oldRec\"];\n            break;\n          }\n        }\n        if (!validNames.includes(name)) { throw new Error(`Unknown variable '${name}'`); }\n\n        return input => input[name];\n      }\n      case \"Attr\": {\n        const attrName = rawArgs[1] as string;\n        return compileAndCombine([args[0]], ([value]) => getAttr(value, attrName, args[0]));\n      }\n      case \"Call\": {\n        return compileAndCombine(args, (values) => {\n          const func = values[0];\n          if (!(func instanceof SupportedCallable)) {\n            throw new Error(`Not a function: '${describeNode(args[0])}'`);\n          }\n          return func.func(...values.slice(1));\n        });\n      }\n      case \"keywords\": {\n        // E.g. foo(a, b=2, c=3) becomes [Call, foo, a, [keywords, [b, 2], [c, 3]]],\n        // which becomes foo(a, {b: 2, c: 3}).\n        const pairs = rawArgs.filter((pair): pair is [string, ParsedPredicateFormula] =>\n          Array.isArray(pair) && pair.length == 2 && typeof pair[0] === \"string\");\n        const keys = pairs.map(p => p[0]);\n        const values = pairs.map(p => p[1]);\n        return compileAndCombine(values, compiledValues =>\n          Object.fromEntries(keys.map((k, i) => [k, compiledValues[i]])));\n      }\n      case \"Comment\": return compileNode(args[0]);\n    }\n    throw new Error(`Unknown node type '${node[0]}'`);\n  }\n\n  /**\n   * Helper for operators: compile a list of nodes, then when evaluating, evaluate them all and\n   * combine the array of results using the given combine() function.\n   */\n  function compileAndCombine(\n    args: ParsedPredicateFormula[],\n    combine: (values: any[]) => any,\n  ): IntermediatePredicateFormula {\n    const compiled = args.map(compileNode);\n    return (input: PredicateFormulaInput) => combine(compiled.map(c => c(input)));\n  }\n\n  const compiledPredicateFormula = compileNode(parsedPredicateFormula);\n  return input => Boolean(compiledPredicateFormula(input));\n}\n\n// Wrapper for callables that we explicitly support. We should be careful not to expose anything\n// that could be used unsafely.\nclass SupportedCallable {\n  constructor(public readonly func: Function) {}\n}\n\nfunction getStringMethod(value: string, attrName: string): SupportedCallable | undefined {\n  switch (attrName) {\n    case \"lower\": return new SupportedCallable(() => value.toLowerCase());\n    case \"upper\": return new SupportedCallable(() => value.toUpperCase());\n  }\n  return undefined;\n}\n\nfunction includes(haystack: unknown, needle: unknown) {\n  if (Array.isArray(haystack)) {\n    return haystack.includes(needle);\n  }\n  // We may not want to support \"in\" for strings because of danger of using e.g. `user.Email in\n  // \"alice@example.com\"` (instead of `[\"alice@example.com\"]`) and not realizing that it also\n  // matches, say, \"ice@example.co\". This happens. But disabling it may interfere with existing\n  // documents, so for now we are keeping this behavior for backward compatibility.\n  if (typeof haystack === \"string\" && typeof needle === \"string\") {\n    return haystack.includes(needle);\n  }\n  return false;\n}\n\nfunction describeNode(node: ParsedPredicateFormula): string {\n  if (node[0] === \"Name\") {\n    return node[1] as string;\n  } else if (node[0] === \"Attr\") {\n    return describeNode(node[1] as ParsedPredicateFormula) + \".\" + (node[2] as string);\n  } else {\n    return \"value\";\n  }\n}\n\nfunction getAttr(value: any, attrName: string, valueNode: ParsedPredicateFormula): any {\n  if (value == null) {\n    if (valueNode[0] === \"Name\" && (valueNode[1] === \"rec\" || valueNode[1] === \"newRec\")) {\n      // This code is recognized by GranularAccess to know when an ACL rule is row-specific.\n      throw new ErrorWithCode(\"NEED_ROW_DATA\", `Missing row data '${valueNode[1]}'`);\n    }\n    throw new Error(`No value for '${describeNode(valueNode)}'`);\n  }\n  if (typeof value.get === \"function\") {\n    return decodeObject(value.get(attrName));  // InfoView\n  } else if (typeof value === \"string\") {\n    return getStringMethod(value, attrName);\n  } else if (value !== null && typeof value === \"object\" &&\n    !Array.isArray(value) &&            // We don't support attribute lookups on arrays.\n    value.hasOwnProperty(attrName)) {\n    // Check value and attrName more carefully to reduce the risk of shenanigans.\n    return value[attrName];\n  }\n  return undefined;\n}\n\n/**\n * Predicate formula properties.\n */\nexport interface PredicateFormulaProperties {\n  // Normally includes the full parsed formula.\n  formulaParsed?: ParsedPredicateFormula;\n\n  /**\n   * List of column ids that are referenced by either `$` or `rec.` notation.\n   */\n  recColIds?: string[];\n  /**\n   * List of column ids that are referenced by `choice.` notation.\n   *\n   * Only applies to the `dropdown-condition` variant of predicate formulas,\n   * and only for Reference and Reference List columns.\n   */\n  choiceColIds?: string[];\n}\n\n/**\n * Returns properties about a predicate `formula`.\n *\n * Properties include the list of column ids referenced in the formula.\n * Currently, this information is used for error validation; specifically, to\n * report when invalid column ids are referenced in ACL formulas and dropdown\n * conditions.\n */\nexport function getPredicateFormulaProperties(\n  formula: ParsedPredicateFormula,\n): PredicateFormulaProperties {\n  return {\n    formulaParsed: formula,\n    recColIds: getRecColIds(formula),\n    choiceColIds: getChoiceColIds(formula),\n  };\n}\n\nfunction isAnyRec(formula: ParsedPredicateFormula | PrimitiveCellValue): boolean {\n  return Array.isArray(formula) &&\n    formula[0] === \"Name\" &&\n    (formula[1] === \"rec\" || formula[1] === \"newRec\" || formula[1] === \"oldRec\");\n}\n\nfunction getRecColIds(formula: ParsedPredicateFormula): string[] {\n  return [...new Set(collectColIds(formula, isAnyRec))];\n}\n\nfunction isChoice(formula: ParsedPredicateFormula | PrimitiveCellValue): boolean {\n  return Array.isArray(formula) && formula[0] === \"Name\" && formula[1] === \"choice\";\n}\n\nfunction getChoiceColIds(formula: ParsedPredicateFormula): string[] {\n  return [...new Set(collectColIds(formula, isChoice))];\n}\n\nfunction collectColIds(\n  formula: ParsedPredicateFormula,\n  isIdentifierWithColIds: (formula: ParsedPredicateFormula | PrimitiveCellValue) => boolean,\n): string[] {\n  if (!Array.isArray(formula)) { throw new Error(\"expected a list\"); }\n  if (formula[0] === \"Attr\" && isIdentifierWithColIds(formula[1])) {\n    const colId = String(formula[2]);\n    return [colId];\n  }\n  return formula.flatMap(el => Array.isArray(el) ? collectColIds(el, isIdentifierWithColIds) : []);\n}\n\n// It would be great if our compilation of our little subset of Python also supported static\n// type-checking. Rather than build that, we'll check the formula by seeing if we get an\n// exception on a sample input that has all default value. E.g. this will catch if we use\n// foo.upper() on a non-string, or other non-existent methods.\n// Returns error message if any, or false if no error. The strange return type is to match a\n// convention for collecting warnings in the AccessRules class.\nexport function typeCheckFormula(\n  formulaParsed: ParsedPredicateFormula,\n  sampleRecord: InfoView,\n  userAttrSamples: { [key: string]: InfoView },\n): string | false {\n  try {\n    const compiledFormula = compilePredicateFormula(formulaParsed);\n    const sampleUser: UserInfo = {\n      ...userAttrSamples,\n      Name: \"\",\n      Email: \"\",\n      Access: \"owners\",\n      Origin: \"\",\n      LinkKey: { key: \"\" },\n      UserID: 0,\n      UserRef: \"\",\n      SessionID: \"\",\n      ShareRef: 0,\n      Type: \"login\",\n    };\n    const sampleInput: PredicateFormulaInput = { user: sampleUser, rec: sampleRecord, newRec: sampleRecord };\n    compiledFormula(sampleInput);\n  } catch (e) {\n    return e.message;\n  }\n  return false;\n}\n"
  },
  {
    "path": "app/common/Prefs.ts",
    "content": "import { StringUnion } from \"app/common/StringUnion\";\nimport { ThemePrefs } from \"app/common/ThemePrefs\";\n\nexport const SortPref = StringUnion(\"name\", \"date\");\nexport type SortPref = typeof SortPref.type;\n\nexport const ViewPref = StringUnion(\"list\", \"icons\");\nexport type ViewPref = typeof ViewPref.type;\n\n// A collection of preferences related to a user or org (or combination).\nexport interface Prefs {\n  // A dummy field used only in tests.\n  placeholder?: string;\n}\n\n// A collection of preferences related to a user.\nexport interface UserPrefs extends Prefs {\n  // Whether to ask the user to fill out a form about their use-case, on opening the DocMenu page.\n  // Set to true on first login, then reset when the form is closed, so that it only shows once.\n  showNewUserQuestions?: boolean;\n  // Whether to record a new sign-up event via Google Tag Manager. Set to true on first login, then\n  // reset on first page load (after the event is sent), so that it's only recorded once.\n  recordSignUpEvent?: boolean;\n  // Theme-related preferences.\n  theme?: ThemePrefs;\n  // List of deprecated warnings user have seen. Kept for historical reasons as some users have them in their prefs.\n  seenDeprecatedWarnings?: DeprecationWarning[];\n  // List of dismissedPopups user have seen.\n  dismissedPopups?: DismissedPopup[];\n  // Behavioral prompt preferences.\n  behavioralPrompts?: BehavioralPromptPrefs;\n  // Welcome popups a user has dismissed.\n  dismissedWelcomePopups?: DismissedReminder[];\n  // Localization support.\n  locale?: string;\n  // If only documents should be shown on the All Documents page.\n  onlyShowDocuments?: boolean;\n}\n\n// A collection of preferences related to a combination of user and org.\nexport interface UserOrgPrefs extends Prefs {\n  docMenuSort?: SortPref;\n  docMenuView?: ViewPref;\n\n  // List of example docs that the user has seen and dismissed the welcome card for.\n  // The numbers are the `id` from IExampleInfo in app/client/ui/ExampleInfo.\n  // By living in UserOrgPrefs, this applies only to the examples-containing org.\n  seenExamples?: number[];\n\n  // Whether the user should see the onboarding tour of Grist. False by default, since existing\n  // users should not see it. New users get this set to true when the user is created. This\n  // applies to the personal org only; the tour is currently only shown there.\n  showGristTour?: boolean;\n\n  // List of document IDs where the user has seen and dismissed the document tour.\n  seenDocTours?: string[];\n}\n\nexport interface OrgPrefs extends Prefs {\n  /* The URL (might be data url) of the custom logo to use for the org. */\n  customLogoUrl?: string | null;\n}\n\nexport interface DocPrefs {\n  // Notification configuration; used in the enterprise version.\n  notifications?: object;\n}\n\n/**\n * Combination of DocPrefs for the document and for the current user. This is mostly intended to\n * be the same type, with docDefaults serving as the defaults, and currentUser override them. For\n * prefs that can't be overridden by a user, DocumentOptions may be more suitable.\n */\nexport interface FullDocPrefs {\n  docDefaults: DocPrefs;\n  currentUser: DocPrefs;\n}\n\n/**\n * List of all deprecated warnings that user can see and dismiss.\n * All of them are marked as seen for new users in FlexServer.ts (welcomeNewUser handler).\n * For now we use then to mark which keyboard shortcuts are deprecated, so those keys\n * are also used in commandList.js.\n *\n * Source code for this feature was deprecated itself :). Here is a link to the latest revision:\n * https://github.com/gristlabs/grist-core/blob/ec20e7fb68786e10979f238c16c432c50a9a7464/app/client/components/DeprecatedCommands.ts\n */\nexport const DeprecationWarning = StringUnion(\n  // Those are not checked anymore. They are kept here for historical reasons (as some users have them marked as seen\n  // so they should not be reused).\n  // 'deprecatedInsertRowBefore',\n  // 'deprecatedInsertRecordAfter',\n  // 'deprecatedDeleteRecords',\n);\nexport type DeprecationWarning = typeof DeprecationWarning.type;\n\nexport const BehavioralPrompt = StringUnion(\n  \"referenceColumns\",\n  \"referenceColumnsConfig\",\n  \"rawDataPage\",\n  \"filterButtons\",\n  \"nestedFiltering\",\n  \"pageWidgetPicker\",\n  \"pageWidgetPickerSelectBy\",\n  \"editCardLayout\",\n  \"addNew\",\n  \"rickRow\",\n  \"calendarConfig\",\n\n  // The following were used in the past and should not be re-used.\n  // 'customURL',\n  // 'formsAreHere',\n  // 'newAssistant',\n  // 'comments',\n  // 'accessRules',\n\n);\nexport type BehavioralPrompt = typeof BehavioralPrompt.type;\n\nexport interface BehavioralPromptPrefs {\n  /** Defaults to false. */\n  dontShowTips: boolean;\n  /** List of tips that have been dismissed. */\n  dismissedTips: BehavioralPrompt[];\n}\n\n/**\n * List of all popups that user can see and dismiss\n */\nexport const DismissedPopup = StringUnion(\n  \"deleteRecords\",        // confirmation for deleting records keyboard shortcut\n  \"deleteFields\",         // confirmation for deleting columns keyboard shortcut\n  \"formulaHelpInfo\",      // formula help info shown in the popup editor\n  \"formulaAssistantInfo\", // formula assistant info shown in the popup editor\n  \"supportGrist\",         // nudge to opt in to telemetry\n  \"publishForm\",          // confirmation for publishing a form\n  \"unpublishForm\",        // confirmation for unpublishing a form\n  \"upgradeNewAssistant\",  // nudge to upgrade to enterprise shown in the formula assistant\n\n  /* Deprecated */\n  \"onboardingCards\",      // onboarding cards shown on the doc menu\n  \"tutorialFirstCard\",    // first card of the tutorial\n);\nexport type DismissedPopup = typeof DismissedPopup.type;\n\nexport const WelcomePopup = StringUnion(\n  \"coachingCall\",\n);\nexport type WelcomePopup = typeof WelcomePopup.type;\n\nexport interface DismissedReminder {\n  /** The name of the popup. */\n  id: WelcomePopup;\n  /** Unix timestamp in ms when the popup was last dismissed. */\n  lastDismissedAt: number;\n  /** If non-null, Unix timestamp in ms when the popup will reappear. */\n  nextAppearanceAt: number | null;\n  /**  The number of times this popup has been dismissed. */\n  timesDismissed: number;\n}\n"
  },
  {
    "path": "app/common/RecentItems.js",
    "content": "/**\n * RecentItems maintains a list of maxCount most recently added items.\n * If an existing item is added, it is moved to the end of the list.\n *\n * @constructor\n * @param {Int} options.maxCount - The maximum number of objects that will be maintained.\n * @param {Function} options.keyFunc -  Function that returns a key identifying an item;\n * If an item is added with an existing key, it replaces the previous item in the list but is\n * moved to the end of the list.  Defaults to the identity function.\n * @param {Array} options.intialItems - A list of items to populate the list on initialization\n */\n\nclass RecentItems {\n  constructor(options) {\n    this._items = new Map();\n    this._maxCount = options.maxCount || 0;\n    this._keyFunc = options.keyFunc || (item => item);\n    if (options.intialItems) this.addItems(options.intialItems);\n  }\n\n  addItem(item) {\n    // Map maintains entries in the order of insertion, so by deleting and reinserting an entry,\n    // we move it to the end of the list.\n    this._items.delete(this._keyFunc(item));\n    this._items.set(this._keyFunc(item), item);\n    // Now that the list is correctly ordered we may need to remove the oldest entry which is\n    // the first item.\n    if (this._items.size > this._maxCount && this._maxCount !== 0) {\n      this._items.delete(this._items.keys().next().value);\n    }\n  }\n\n  addItems(items) {\n    items.forEach(item => {\n      this.addItem(item);\n    });\n  }\n\n  /**\n   * Returns a list of the current items in the map.  The list is starts with oldest\n   * added item and ends with the most recently inserted.\n   *\n   * @returns {Array} A list of items.\n   */\n  listItems() {\n    return Array.from(this._items.values());\n  }\n}\n\nmodule.exports = RecentItems;\n"
  },
  {
    "path": "app/common/RecordView.ts",
    "content": "import { CellValue, TableDataAction } from \"app/common/DocActions\";\n\n/** Light wrapper for reading records or user attributes. */\nexport interface InfoView {\n  get(key: string): CellValue;\n  keys(): string[];\n  toJSON(): { [key: string]: any };\n}\n\n/**\n * A row-like view of TableDataAction, which is columnar in nature.\n *\n * If index value is undefined, acts as an EmptyRecordRow.\n */\nexport class RecordView implements InfoView {\n  public constructor(public data: TableDataAction, public index: number | undefined) {\n  }\n\n  public get(colId: string): CellValue {\n    if (this.index === undefined) { return null; }\n    if (colId === \"id\") {\n      return this.data[2][this.index];\n    }\n    return this.data[3][colId]?.[this.index];\n  }\n\n  public has(colId: string) {\n    return colId === \"id\" || colId in this.data[3];\n  }\n\n  public keys(): string[] {\n    return [\"id\", ...Object.keys(this.data[3])];\n  }\n\n  public toJSON() {\n    if (this.index === undefined) { return {}; }\n    const results: { [key: string]: any } = { id: this.index };\n    for (const key of Object.keys(this.data[3])) {\n      results[key] = this.data[3][key]?.[this.index];\n    }\n    return results;\n  }\n}\n\nexport class EmptyRecordView implements InfoView {\n  public get(_colId: string): CellValue { return null; }\n  public keys(): string[] { return []; }\n  public toJSON() { return {}; }\n}\n"
  },
  {
    "path": "app/common/RefCountMap.ts",
    "content": "/**\n * RefCountMap maintains a reference-counted key-value map. Its sole method is use(key) which\n * increments the counter for the key, and returns a disposable object which exposes the value via\n * the get() method, and decrements the counter back on disposal.\n *\n * The value is constructed on first reference using options.create(key) callback. After the last\n * reference is gone, and an optional gracePeriodMs elapsed, the value is cleaned up using\n * options.dispose(key, value) callback.\n */\nimport { IDisposable } from \"grainjs\";\n\nexport interface IRefCountSub<Value> extends IDisposable {\n  get(): Value;\n  dispose(): void;\n}\n\nexport class RefCountMap<Key, Value> implements IDisposable {\n  private _map = new Map<Key, RefCountValue<Value>>();\n  private _createKey: (key: Key) => Value;\n  private _disposeKey: (key: Key, value: Value) => void;\n  private _gracePeriodMs: number;\n\n  /**\n   * Values are created using options.create(key) on first use. They are disposed after last use,\n   * using options.dispose(key, value). If options.gracePeriodMs is greater than zero, values\n   * stick around for this long after last use.\n   */\n  constructor(options: {\n    create: (key: Key) => Value,\n    dispose: (key: Key, value: Value) => void,\n    gracePeriodMs: number,\n  }) {\n    this._createKey = options.create;\n    this._disposeKey = options.dispose;\n    this._gracePeriodMs = options.gracePeriodMs;\n  }\n\n  /**\n   * Use a value, constructing it if needed, or only incrementing the reference count if this key\n   * is already in the map. The returned subscription object has a get() method which returns the\n   * actual value, and a dispose() method, which must be called to release this subscription (i.e.\n   * decrement back the reference count).\n   */\n  public use(key: Key): IRefCountSub<Value> {\n    const rcValue = this._useKey(key);\n    return {\n      get: () => rcValue.value,\n      dispose: () => this._releaseKey(rcValue, key),\n    };\n  }\n\n  /**\n   * Return the value for the key, if one is set, or undefined otherwise, without touching\n   * reference counts.\n   */\n  public get(key: Key): Value | undefined {\n    return this._map.get(key)?.value;\n  }\n\n  /**\n   * Purge a key by immediately removing it from the map. Disposing the remaining IRefCountSub\n   * values will be no-ops.\n   */\n  public purgeKey(key: Key): void {\n    // Note that we must be careful that disposing stale IRefCountSub values is a no-op even when\n    // the same key gets re-added to the map after purgeKey.\n    this._doDisposeKey(key);\n  }\n\n  /**\n   * Disposing clears the map immediately, and calls options.dispose on all values.\n   */\n  public dispose(): void {\n    // Note that a clear() method like this one would not be OK. If the map were to continue being\n    // used after clear(), subscriptions created before clear() would wreak havoc when disposed.\n    for (const [key, r] of this._map) {\n      r.count = 0;\n      r.unsetTimeout();   // Clear timeouts to avoid holding on to references unnecessarily.\n      this._disposeKey.call(null, key, r.value);\n    }\n    this._map.clear();\n  }\n\n  // For testing: set gracePeriodMs, returning the previous value.\n  public testSetGracePeriodMs(ms: number): number {\n    const prev = this._gracePeriodMs;\n    this._gracePeriodMs = ms;\n    return prev;\n  }\n\n  private _useKey(key: Key): RefCountValue<Value> {\n    const r = this._map.get(key);\n    if (r) {\n      r.count += 1;\n      r.unsetTimeout();\n      return r;\n    }\n    const value = this._createKey.call(null, key);\n    const rcValue = new RefCountValue(value);\n    this._map.set(key, rcValue);\n    return rcValue;\n  }\n\n  private _releaseKey(r: RefCountValue<Value>, key: Key): void {\n    if (r.count > 0) {\n      r.count -= 1;\n      if (r.count === 0) {\n        if (this._gracePeriodMs > 0) {\n          if (!r.disposeTimeout) {\n            r.disposeTimeout = setTimeout(() => this._doDisposeKey(key), this._gracePeriodMs);\n          }\n        } else {\n          this._doDisposeKey(key);\n        }\n      }\n    }\n  }\n\n  private _doDisposeKey(key: Key): void {\n    const r = this._map.get(key);\n    if (r) {\n      this._map.delete(key);\n      r.count = 0;\n      r.unsetTimeout();   // Important, to avoid timeout triggering after the same key is re-added.\n      this._disposeKey.call(null, key, r.value);\n    }\n  }\n}\n\n/**\n * This is an implementation detail of the RefCountMap, which represents a single item.\n */\nclass RefCountValue<Value> {\n  public count: number = 1;\n  public disposeTimeout?: ReturnType<typeof setTimeout> = undefined;\n  constructor(public value: Value) {}\n\n  public unsetTimeout() {\n    if (this.disposeTimeout) {\n      clearTimeout(this.disposeTimeout);\n      this.disposeTimeout = undefined;\n    }\n  }\n}\n"
  },
  {
    "path": "app/common/RelativeDates.ts",
    "content": "// Relative date spec describes a date that is distant to the current date by a series of jumps in\n// time defined as a series of periods. Hence, starting from the current date, each one of the\n// periods gets applied successively which eventually yields to the final date. Typical relative\n\nimport getCurrentTime from \"app/common/getCurrentTime\";\n\nimport isEqual from \"lodash/isEqual\";\nimport isNumber from \"lodash/isNumber\";\nimport isUndefined from \"lodash/isUndefined\";\nimport omitBy from \"lodash/omitBy\";\nimport moment from \"moment-timezone\";\n\n// Relative date uses one or two periods. When relative dates are defined by two periods, they are\n// applied successively to the start date to resolve the target date. In practice in grist, as of\n// the time of writing, relative date never uses more than 2 periods and the second period's unit is\n// always day.\nexport type IRelativeDateSpec = IPeriod[];\n\n// IPeriod describes a period of time: when used along with a start date, it allows to target a new\n// date. It allows to encode simple periods such as `30 days ago` as `{quantity: -30, unit:\n// 'day'}`. Or `The last day of last week` as `{quantity: -1, unit: 'week', endOf: true}`. Not that\n// .endOf flag is only relevant when the unit is one of 'week', 'month' or 'year'. When `endOf` is\n// false or missing then it will target the first day (of the week, month or year).\nexport interface IPeriod {\n  quantity: number;\n  unit: \"day\" | \"week\" | \"month\" | \"year\";\n  endOf?: boolean;\n}\n\nexport const CURRENT_DATE: IRelativeDateSpec = [{ quantity: 0, unit: \"day\" }];\n\nexport function isRelativeBound(bound?: number | IRelativeDateSpec): bound is IRelativeDateSpec {\n  return !isUndefined(bound) && !isNumber(bound);\n}\n\n// Returns the number of seconds between 1 January 1970 00:00:00 UTC and the given bound, may it be\n// a relative date.\nexport function relativeDateToUnixTimestamp(bound: IRelativeDateSpec): number {\n  const localDate = getCurrentTime().startOf(\"day\");\n  const date = moment.utc(localDate.toObject());\n  const periods = Array.isArray(bound) ? bound : [bound];\n\n  for (const period of periods) {\n    const { quantity, unit, endOf } = period;\n\n    date.add(quantity, unit);\n    if (endOf) {\n      date.endOf(unit);\n\n      // date must have \"hh:mm:ss\" set to \"00:00:00\"\n      date.startOf(\"day\");\n    } else {\n      date.startOf(unit);\n    }\n  }\n  return Math.floor(date.valueOf() / 1000);\n}\n\n// Format a relative date.\nexport function formatRelBounds(periods: IPeriod[]): string {\n  // if 2nd period is moot revert to one single period\n  periods = periods[1]?.quantity ? periods : [periods[0]];\n\n  if (periods.length === 1) {\n    const { quantity, unit, endOf } = periods[0];\n    if (unit === \"day\") {\n      if (quantity === 0) { return \"Today\"; }\n      if (quantity === -1) { return \"Yesterday\"; }\n      if (quantity === 1) { return \"Tomorrow\"; }\n      return formatReference(periods[0]);\n    }\n\n    if (endOf) {\n      return `Last day of ${formatReference(periods[0])}`;\n    } else {\n      return `1st day of ${formatReference(periods[0])}`;\n    }\n  }\n\n  if (periods.length === 2) {\n    let dayQuantity = periods[1].quantity;\n\n    // If the 1st period has the endOf flag, we're already 1 day back.\n    if (periods[0].endOf) { dayQuantity -= 1; }\n\n    let startOrEnd = \"\";\n    if (periods[0].unit === \"week\") {\n      if (periods[1].quantity === 0) {\n        startOrEnd = \"start \";\n      } else if (periods[1].quantity === 6) {\n        startOrEnd = \"end \";\n      }\n    }\n\n    return `${formatDay(dayQuantity, periods[0].unit)} ${startOrEnd}of ${formatReference(periods[0])}`;\n  }\n\n  throw new Error(\n    `Relative date spec does not support more that 2 periods: ${periods.length}`,\n  );\n}\n\n/**\n * Returns a new timestamp that is the UTC equivalent of the original local `timestamp`, offset\n * according to the delta between`timezone` and UTC.\n */\nexport function localTimestampToUTC(timestamp: number, timezone: string): number {\n  return moment.unix(timestamp).utc().tz(timezone, true).unix();\n}\n\nfunction formatDay(quantity: number, refUnit: IPeriod[\"unit\"]): string {\n  if (refUnit === \"week\") {\n    const n = (quantity + 7) % 7;\n    return [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"][n];\n  }\n\n  const ord = (n: number) => moment.localeData().ordinal(n);\n  if (quantity < 0) {\n    if (quantity === -1) {\n      return \"Last day\";\n    }\n    return `${ord(-quantity)} to last day`;\n  } else {\n    return `${ord(quantity + 1)} day`;\n  }\n}\n\nfunction formatReference(period: IPeriod): string {\n  const { quantity, unit } = period;\n  if (quantity === 0) {\n    return `this ${unit}`;\n  }\n\n  if (quantity === -1) {\n    return `last ${unit}`;\n  }\n\n  if (quantity === 1) {\n    return `next ${unit}`;\n  }\n\n  const n = Math.abs(quantity);\n  const plurals = n > 1 ? \"s\" : \"\";\n  return `${n} ${unit}${plurals} ${quantity < 1 ? \"ago\" : \"from now\"}`;\n}\n\nexport function isEquivalentRelativeDate(a: IPeriod | IPeriod[], b: IPeriod | IPeriod[]) {\n  a = Array.isArray(a) ? a : [a];\n  b = Array.isArray(b) ? b : [b];\n  if (a.length === 2 && a[1].quantity === 0) { a = [a[0]]; }\n  if (b.length === 2 && b[1].quantity === 0) { b = [b[0]]; }\n\n  const compactA = a.map(period => omitBy(period, isUndefined));\n  const compactB = b.map(period => omitBy(period, isUndefined));\n\n  return isEqual(compactA, compactB);\n}\n\n// Get the difference in unit of measurement. If unit is week, makes sure that two dates that are in\n// two different weeks are always at least 1 number apart. Same for month and year.\nexport function diffUnit(a: moment.Moment, b: moment.Moment, unit: \"day\" | \"week\" | \"month\" | \"year\") {\n  return a.clone().startOf(unit).diff(b.clone().startOf(unit), unit);\n}\n"
  },
  {
    "path": "app/common/RowFilterFunc.ts",
    "content": "import { FilterColValues } from \"app/common/ActiveDocAPI\";\nimport { ColumnFilterFunc } from \"app/common/ColumnFilterFunc\";\nimport { ColumnGettersByColId } from \"app/common/ColumnGetters\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { isList } from \"app/common/gristTypes\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nexport type RowFilterFunc<T> = (row: T) => boolean;\n\n// Builds RowFilter for a single column\nexport function buildRowFilter<T>(\n  getter: RowValueFunc<T> | null,\n  filterFunc: ColumnFilterFunc | null): RowFilterFunc<T> {\n  if (!getter || !filterFunc) {\n    return () => true;\n  }\n  return (rowId: T) => filterFunc(getter(rowId));\n}\n\nexport type RowValueFunc<T> = (rowId: T) => CellValue;\n\n// Filter rows for the purpose of linked widgets\nexport function getLinkingFilterFunc(\n  columnGetters: ColumnGettersByColId, { filters, operations }: FilterColValues,\n): RowFilterFunc<number> {\n  const colFuncs = Object.keys(filters).sort().map(\n    (colId) => {\n      const getter = columnGetters.getColGetterByColId(colId);\n      if (!getter) { return () => true; }\n      const values = new Set(filters[colId]);\n      switch (operations[colId]) {\n        case \"intersects\":\n          return (rowId: number) => {\n            const value = getter(rowId) as CellValue;\n            return isList(value) &&\n              (decodeObject(value) as unknown[]).some(v => values.has(v));\n          };\n        case \"empty\":\n          return (rowId: number) => {\n            const value = getter(rowId);\n            // `isList(value) && value.length === 1` means `value == ['L']` i.e. an empty list\n            return !value || isList(value) && value.length === 1;\n          };\n        case \"in\":\n          return (rowId: number) => values.has(getter(rowId));\n      }\n    });\n  return (rowId: number) => colFuncs.every(f => f(rowId));\n}\n"
  },
  {
    "path": "app/common/SandboxInfo.ts",
    "content": "export interface SandboxInfo {\n  flavor: string;       // the type of sandbox in use (gvisor, unsandboxed, etc)\n  functional: boolean;  // whether the sandbox can run code\n  effective: boolean;   // whether the sandbox is actually giving protection\n  configured: boolean;  // whether a sandbox type has been specified\n  // if sandbox fails to run, this records the last step that worked\n  lastSuccessfulStep: \"none\" | \"create\" | \"use\" | \"all\";\n  error?: string;       // if sandbox fails, this stores an error\n}\n"
  },
  {
    "path": "app/common/ServiceAccountTypes-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const ServiceAccountAllOptional = t.iface([], {\n  \"label\": t.union(\"string\", \"undefined\"),\n  \"description\": t.union(\"string\", \"undefined\"),\n  \"expiresAt\": t.union(\"string\", \"undefined\"),\n});\n\nexport const ServiceAccountApiResponse = t.iface([], {\n  \"id\": \"number\",\n  \"login\": \"string\",\n  \"label\": \"string\",\n  \"description\": \"string\",\n  \"expiresAt\": \"string\",\n  \"hasValidKey\": \"boolean\",\n});\n\nexport const ServiceAccountCreationResponse = t.iface([\"ServiceAccountApiResponse\"], {\n  \"key\": \"string\",\n});\n\nexport const PatchServiceAccount = t.name(\"ServiceAccountAllOptional\");\n\nexport const PostServiceAccount = t.iface([\"ServiceAccountAllOptional\"], {\n  \"expiresAt\": \"string\",\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  ServiceAccountAllOptional,\n  ServiceAccountApiResponse,\n  ServiceAccountCreationResponse,\n  PatchServiceAccount,\n  PostServiceAccount,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/common/ServiceAccountTypes.ts",
    "content": "/**\n * Base interface for ServiceAccounts whose all attributes are optional.\n * A Service Account is non-login user managed by a login user.\n * Their purpose is be able to interact with via api on the small chosen scope\n * given to the service account.\n */\ninterface ServiceAccountAllOptional {\n  label: string | undefined;\n  description: string | undefined;\n  expiresAt: string | undefined; // ISO date string\n}\n\nexport interface ServiceAccountApiResponse {\n  id: number;\n  login: string;\n  label: string;\n  description: string;\n  expiresAt: string;\n  hasValidKey: boolean;\n}\n\nexport interface ServiceAccountCreationResponse extends ServiceAccountApiResponse {\n  key: string;\n}\n\nexport type PatchServiceAccount = ServiceAccountAllOptional;\n\nexport interface PostServiceAccount extends ServiceAccountAllOptional {\n  // expiresAt required for creation\n  expiresAt: string;\n}\n"
  },
  {
    "path": "app/common/ShareAnnotator.ts",
    "content": "import { normalizeEmail } from \"app/common/emails\";\nimport { Features } from \"app/common/Features\";\nimport {\n  ANONYMOUS_USER_EMAIL,\n  EVERYONE_EMAIL,\n  PermissionData,\n  PermissionDelta,\n  PREVIEWER_EMAIL,\n} from \"app/common/UserAPI\";\n\nimport omitBy from \"lodash/omitBy\";\n\n/**\n * Mark that the share is share number #at of a maximum of #top. The #at values\n * start at 1.\n */\nexport interface ShareLimitAnnotation {\n  at: number;\n  top?: number;\n}\n\n/**\n * Some facts about a share.\n */\nexport interface ShareAnnotation {\n  isMember?: boolean;   // Is the share for a team member.\n  isSupport?: boolean;  // Is the share for a support user.\n  collaboratorLimit?: ShareLimitAnnotation;  // Does the share count towards a collaborator limit.\n}\n\n/**\n * Facts about all shares for a resource.\n */\nexport interface ShareAnnotations {\n  hasTeam?: boolean;   // Is the resource in a team site?\n  users: Map<string, ShareAnnotation>;  // Annotations keyed by normalized user email.\n}\n\nexport interface ShareAnnotatorOptions {\n  supportEmail?: string;   // Known email address of the support user (e.g. support@getgrist.com).\n}\n\n/**\n * Emails not counted towards collaborator limits.\n */\nconst EXCLUDED_EMAILS = new Set([\n  PREVIEWER_EMAIL,\n  EVERYONE_EMAIL,\n  ANONYMOUS_USER_EMAIL,\n]);\n\n/**\n * Helper for annotating users mentioned in a proposed change of shares, given the\n * current shares in place.\n */\nexport class ShareAnnotator {\n  private _supportEmail = this._options.supportEmail;\n\n  constructor(\n    private _features: Features | null,\n    private _state: PermissionData,\n    private _options: ShareAnnotatorOptions = {},\n  ) {\n  }\n\n  public updateState(state: PermissionData) {\n    this._state = state;\n  }\n\n  public annotateChanges(change: PermissionDelta): ShareAnnotations {\n    const features = this._features ?? {};\n    const annotations: ShareAnnotations = {\n      hasTeam: !this._features || this._features.vanityDomain,\n      users: new Map(),\n    };\n    if (features.maxSharesPerDocPerRole || features.maxSharesPerWorkspace) {\n      // For simplicity, don't try to annotate if limits not used at the time of writing\n      // are in place.\n      return annotations;\n    }\n    const top = features.maxSharesPerDoc;\n    let at = 0;\n    const makeAnnotation =\n      (user: { email: string, isMember?: boolean, isSupport?: boolean, access: string | null }) => {\n        const annotation: ShareAnnotation = {\n          isMember: user.isMember,\n        };\n        if (user.isSupport) {\n          return { isSupport: true };\n        }\n        if (!annotation.isMember && user.access) {\n          at++;\n          annotation.collaboratorLimit = {\n            at,\n            top,\n          };\n        }\n        return annotation;\n      };\n    const users = Object.entries(\n      omitBy(\n        change?.users || {},\n        (_v, k) => EXCLUDED_EMAILS.has(k),\n      ),\n    );\n    const removed = new Set(\n      users.filter(([, v]) => v === null)\n        .map(([k]) => normalizeEmail(k)));\n    for (const user of this._state.users) {\n      if (EXCLUDED_EMAILS.has(user.email)) { continue; }\n      if (removed.has(user.email)) { continue; }\n      if (!user.isMember && !user.access) { continue; }\n      annotations.users.set(user.email, makeAnnotation(user));\n    }\n    const tweaks = new Set(\n      users.filter(([, v]) => v !== null)\n        .map(([k]) => normalizeEmail(k)));\n    for (const email of tweaks) {\n      const annotation = annotations.users.get(email) || makeAnnotation({\n        email,\n        isMember: false,\n        isSupport: Boolean(email.trim() !== \"\" && email === this._supportEmail),\n        access: \"<set>\",\n      });\n      annotations.users.set(email, annotation);\n    }\n    return annotations;\n  }\n}\n"
  },
  {
    "path": "app/common/ShareOptions.ts",
    "content": "/**\n *\n * Options on a share, or a shared widget. This is mostly\n * a placeholder currently. The same structure is currently\n * used both for shares and for specific shared widgets, but\n * this is just to save a little time right now, and should\n * not be preserved in future work.\n *\n * The only flag that matter today is \"publish\".\n * The \"access\" flag could be stripped for now without consequences.\n *\n */\nexport interface ShareOptions {\n  // A share or widget that does not have publish set to true\n  // will not be available via the share mechanism.\n  publish?: boolean;\n\n  // Can be set to 'viewers' to label the share as readonly.\n  // Half-baked, just here to exercise an aspect of homedb\n  // syncing.\n  access?: \"editors\" | \"viewers\";\n}\n"
  },
  {
    "path": "app/common/SortFunc.ts",
    "content": "/**\n * SortFunc class interprets the sortSpec (as saved in viewSection.sortColRefs), exposing a\n * compare(rowId1, rowId2) function that can be used to actually sort rows in a view.\n *\n * TODO: When an operation (such as a paste) would cause rows to jump in the sort order, this\n * class should support freezing of row positions until the user chooses to re-sort. This is not\n * currently implemented.\n */\nimport { ColumnGetter, ColumnGetters } from \"app/common/ColumnGetters\";\nimport { localeCompare, nativeCompare } from \"app/common/gutil\";\nimport { Sort } from \"app/common/SortSpec\";\n\n// Function that will amend column getter to return entry index instead\n// of entry value. Result will be a string padded with zeros, so the ordering\n// between types is preserved.\nexport function choiceGetter(getter: ColumnGetter, choices: string[]): ColumnGetter {\n  return (rowId) => {\n    const value = getter(rowId);\n    const index = choices.indexOf(value);\n    return index >= 0 ? String(index).padStart(5, \"0\") : value;\n  };\n}\n\ntype Comparator = (val1: any, val2: any) => number;\n\n/**\n * Natural comparator based on built in method.\n * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare\n */\nconst collator = new Intl.Collator(undefined, { numeric: true });\nexport function naturalCompare(val1: any, val2: any) {\n  if (typeof val1 === \"string\" && typeof val2 === \"string\") {\n    return collator.compare(val1, val2);\n  }\n  return typedCompare(val1, val2);\n}\n\n/**\n * Empty comparator will treat empty values as last.\n */\nexport const emptyCompare = (next: Comparator) => (val1: any, val2: any) => {\n  const isEmptyValue1 = !val1 && typeof val1 !== \"number\";\n  const isEmptyValue2 = !val2 && typeof val2 !== \"number\";\n\n  // If both values are empty values, rely on next to compare.\n  if (isEmptyValue1 && !isEmptyValue2) {\n    return 1;\n  }\n  if (isEmptyValue2 && !isEmptyValue1) {\n    return -1;\n  }\n  return next(val1, val2);\n};\n\n/**\n * Compare two cell values, paying attention to types and values. Note that native JS comparison\n * can't be used for sorting because it isn't transitive across types (e.g. both 1 < \"2\" and \"2\" <\n * \"a\" are true, but 1 < \"a\" is false.). In addition, we handle complex values represented in\n * Grist as arrays.\n *\n * Note that we need to handle different types of values regardless of the column type,\n * because e.g. a numerical column may contain text (alttext) or null values.\n */\nexport function typedCompare(val1: any, val2: any): number {\n  let result: number, type1: string, array1: boolean;\n  if ((result = nativeCompare(type1 = typeof val1, typeof val2)) !== 0) {\n    return result;\n  }\n  // We need to worry about Array comparisons because formulas returning Any may return null or\n  // object values represented as arrays (e.g. ['D', ...] for dates). Comparing those without\n  // distinguishing types would break the sort. Also, arrays need a special comparator.\n  if (type1 === \"object\") {\n    if ((result = nativeCompare(array1 = val1 instanceof Array, val2 instanceof Array)) !== 0) {\n      return result;\n    }\n    if (array1) {\n      return _arrayCompare(val1, val2);\n    }\n  }\n  if (type1 === \"string\") {\n    return localeCompare(val1, val2);\n  }\n  return nativeCompare(val1, val2);\n}\n\nfunction _arrayCompare(val1: any[], val2: any[]): number {\n  for (let i = 0; i < val1.length; i++) {\n    if (i >= val2.length) {\n      return 1;\n    }\n    const value = typedCompare(val1[i], val2[i]);\n    if (value) {\n      return value;\n    }\n  }\n  return val1.length === val2.length ? 0 : -1;\n}\n\n/**\n * getters is an implementation of app.common.ColumnGetters\n */\nexport class SortFunc {\n  // updateSpec() or updateGetters() can populate these fields, used by the compare() method.\n  private _colGetters: ColumnGetter[] = [];  // Array of column getters (mapping rowId to column value)\n  private _directions: number[] = [];           // Array of 1 (ascending) or -1 (descending) flags.\n  private _comparators: Comparator[] = [];\n\n  constructor(private _getters: ColumnGetters) {}\n\n  public updateSpec(sortSpec: Sort.SortSpec): void {\n    // Prepare an array of column getters for each column in sortSpec.\n    this._colGetters = sortSpec.map((colSpec) => {\n      return this._getters.getColGetter(colSpec);\n    }).filter(getter => getter) as ColumnGetter[];\n\n    // Collect \"ascending\" flags as an array of 1 or -1, one for each column.\n    this._directions = sortSpec.map(colSpec => Sort.direction(colSpec));\n\n    // Collect comparator functions\n    this._comparators = sortSpec.map((colSpec) => {\n      const details = Sort.specToDetails(colSpec);\n      let comparator = typedCompare;\n      if (details.naturalSort) {\n        comparator = naturalCompare;\n      }\n      // Empty decorator should be added last, as first we want to compare\n      // empty values\n      if (details.emptyLast) {\n        comparator = emptyCompare(comparator);\n      }\n      return comparator;\n    });\n\n    const manualSortGetter = this._getters.getManualSortGetter();\n    if (manualSortGetter) {\n      this._colGetters.push(manualSortGetter);\n      this._directions.push(1);\n      this._comparators.push(typedCompare);\n    }\n  }\n\n  /**\n   * Returns 1 or -1 depending on whether rowId1 should be shown before rowId2.\n   */\n  public compare(rowId1: number, rowId2: number): number {\n    for (let i = 0, len = this._colGetters.length; i < len; i++) {\n      const getter = this._colGetters[i];\n      const val1 = getter(rowId1);\n      const val2 = getter(rowId2);\n      const comparator = this._comparators[i];\n      const result = comparator(val1, val2);\n      if (result !== 0 /* not equal */) {\n        return result * this._directions[i];\n      }\n    }\n    return nativeCompare(rowId1, rowId2);\n  }\n}\n\n/**\n * Wrapper for typedCompare that can be used as a sort function for Array.sort().\n * Example:\n * const sorted = rows.sort(orderBy(row => row.name));\n */\nexport function orderBy<T>(keyFunc: (row: T) => any, options: { desc?: boolean } = { desc: false }) {\n  return function(a: T, b: T) {\n    const val1 = keyFunc(a);\n    const val2 = keyFunc(b);\n    return typedCompare(val1, val2) * (options.desc ? -1 : 1);\n  };\n}\n"
  },
  {
    "path": "app/common/SortSpec.ts",
    "content": "/**\n * Sort namespace provides helper function to work with sort expression.\n *\n * Sort expression is a list of column sort expressions, each describing how to\n * sort particular column. Column expression can be either:\n *\n * - Positive number: column with matching id will be sorted in ascending order\n * - Negative number: column will be sorted in descending order\n * - String containing a positive number: same as above\n * - String containing a negative number: same as above\n * - String containing a number and sorting options:\n *   '1:flag1;flag2;flag3'\n *   '-1:flag1;flag2;flag3'\n *   Sorting options modifies the sorting algorithm, supported options are:\n *   - orderByChoice: For choice column sorting function will use choice item order\n *                    instead of choice label text.\n *   - emptyLast:     Treat empty values as greater than non empty (default is empty values first).\n *   - naturalSort:   For text based columns, sorting function will compare strings with numbers\n *                    taking their numeric value rather then text representation ('a2' before 'a11)\n */\nexport namespace Sort {\n  /**\n   * Object base representation for column expression.\n   */\n  export interface ColSpecDetails {\n    colRef: ColRef;\n    direction: Direction;\n    orderByChoice?: boolean;\n    emptyLast?: boolean;\n    naturalSort?: boolean;\n  }\n  /**\n   * Column expression type. Either number, an object, or virtual id string _vid\\d+\n   */\n  export type ColSpec = number | string;\n  export type ColRef = number | string;\n  /**\n   * Sort expression type, for example [1,-2, '3:emptyLast', '-4:orderByChoice']\n   */\n  export type SortSpec = ColSpec[];\n  export type Direction = 1 | -1;\n  export const ASC: Direction = 1;\n  export const DESC: Direction = -1;\n\n  const NOT_FOUND = -1;\n\n  // Flag separator\n  const FLAG_SEPARATOR = \";\";\n  // Separator between colRef and sorting options.\n  const OPTION_SEPARATOR = \":\";\n\n  /**\n   * Checks if column expression has any sorting options.\n   */\n  export function hasOptions(colSpec: ColSpec | ColSpecDetails): boolean {\n    if (typeof colSpec === \"number\") {\n      return false;\n    }\n    const details = typeof colSpec !== \"object\" ? specToDetails(colSpec) : colSpec;\n    return Boolean(details.emptyLast || details.naturalSort || details.orderByChoice);\n  }\n\n  /**\n   * Converts column sort expression from object representation to encoded form.\n   */\n  export function detailsToSpec(d: ColSpecDetails): ColSpec {\n    const head = `${d.direction === ASC ? \"\" : \"-\"}${d.colRef}`;\n    const tail = [];\n    if (d.emptyLast) {\n      tail.push(\"emptyLast\");\n    }\n    if (d.naturalSort) {\n      tail.push(\"naturalSort\");\n    }\n    if (d.orderByChoice) {\n      tail.push(\"orderByChoice\");\n    }\n    if (!tail.length) {\n      return maybeNumber(head);\n    }\n    return head + (tail.length ? OPTION_SEPARATOR : \"\") + tail.join(FLAG_SEPARATOR);\n  }\n\n  /**\n   * Converts column expression to object representation.\n   */\n  export function specToDetails(colSpec: ColSpec): ColSpecDetails {\n    return typeof colSpec === \"number\" ?\n      {\n        colRef: Math.abs(colSpec),\n        direction: colSpec >= 0 ? ASC : DESC,\n      } :\n      parseColSpec(colSpec);\n  }\n\n  function maybeNumber(colRef: string): ColRef {\n    const num = parseInt(colRef, 10);\n    return isNaN(num) ? colRef : num;\n  }\n\n  function parseColSpec(colString: string): ColSpecDetails {\n    if (!colString) {\n      throw new Error(\"Empty column expression\");\n    }\n    const REGEX = /^(?<sign>-)?(?<colRef>(_vid)?(\\d+))(:(?<flag>[\\w\\d;]+))?$/;\n    const match = colString.match(REGEX);\n    if (!match) {\n      throw new Error(\"Error parsing sort expression \" + colString);\n    }\n    const { sign, colRef, flag } = match.groups || {};\n    const flags = flag?.split(\";\");\n    return onlyDefined({\n      colRef: maybeNumber(colRef),\n      direction: sign === \"-\" ? DESC : ASC,\n      orderByChoice: flags?.includes(\"orderByChoice\"),\n      emptyLast: flags?.includes(\"emptyLast\"),\n      naturalSort: flags?.includes(\"naturalSort\"),\n    });\n  }\n\n  function onlyDefined<T extends Record<string, any>>(obj: T): T {\n    return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as T;\n  }\n\n  /**\n   * Extracts colRef (column row id) from column sorting expression.\n   */\n  export function getColRef(colSpec: ColSpec) {\n    if (typeof colSpec === \"number\") {\n      return Math.abs(colSpec);\n    }\n    return parseColSpec(colSpec).colRef;\n  }\n\n  /**\n   * Swaps column expressions.\n   */\n  export function swap(spec: SortSpec, colA: ColSpec, colB: ColSpec): SortSpec {\n    const aIndex = findColIndex(spec, colA);\n    const bIndex = findColIndex(spec, colB);\n    if (aIndex === NOT_FOUND || bIndex === NOT_FOUND) {\n      throw new Error(`Column expressions can be found (${colA} or ${colB})`);\n    }\n    const clone = spec.slice();\n    clone[aIndex] = spec[bIndex];\n    clone[bIndex] = spec[aIndex];\n    return clone;\n  }\n\n  /**\n   * Converts column expression order.\n   */\n  export function setColDirection(colSpec: ColSpec, dir: Direction): ColSpec {\n    if (typeof colSpec == \"number\") {\n      return Math.abs(colSpec) * dir;\n    } else if (colSpec.startsWith(VirtualId.PREFIX)) {\n      return dir === DESC ? `-${colSpec}` : colSpec;\n    } else if (colSpec.startsWith(`-${VirtualId.PREFIX}`)) {\n      return dir === ASC ? colSpec.slice(1) : colSpec;\n    } else {\n      return detailsToSpec({ ...parseColSpec(colSpec), direction: dir });\n    }\n  }\n\n  /**\n   * Creates simple column expression.\n   */\n  export function createColSpec(colRef: ColRef, dir: Direction): ColSpec {\n    if (typeof colRef === \"number\") {\n      return colRef * dir;\n    } else {\n      return dir === ASC ? colRef : `-${colRef}`;\n    }\n  }\n\n  /**\n   * Checks if a column expression is already included in sorting spec. Doesn't check sorting options.\n   */\n  export function contains(spec: SortSpec, colSpec: ColSpec, dir: Direction) {\n    const existing = findCol(spec, colSpec);\n    return !!existing && getColRef(existing) === getColRef(colSpec) && direction(existing) === dir;\n  }\n\n  export function containsOnly(spec: SortSpec, colSpec: ColSpec, dir: Direction) {\n    return spec.length === 1 && contains(spec, colSpec, dir);\n  }\n\n  /**\n   * Checks if a column is sorted in ascending order.\n   */\n  export function isAscending(colSpec: ColSpec): boolean {\n    if (typeof colSpec === \"number\") {\n      return colSpec >= 0;\n    }\n    return parseColSpec(colSpec).direction === ASC;\n  }\n\n  export function direction(colSpec: ColSpec): Direction {\n    return isAscending(colSpec) ? ASC : DESC;\n  }\n\n  /**\n   * Checks if two column expressions refers to the same column.\n   */\n  export function sameColumn(colSpec: ColSpec, colRef: ColSpec): boolean {\n    return getColRef(colSpec) === getColRef(colRef);\n  }\n\n  /**\n   * Swaps column id in column expression. Primary use for display columns.\n   */\n  export function swapColRef(colSpec: ColSpec, colRef: ColRef): ColSpec {\n    if (typeof colSpec === \"number\") {\n      // FIXME: This eslint rule should probably be reenabled. But we need to understand\n      // how this function is expected to be called.\n      // eslint-disable-next-line @typescript-eslint/no-unsafe-unary-minus\n      return colSpec >= 0 ? colRef : -colRef;\n    }\n    const spec = parseColSpec(colSpec);\n    return detailsToSpec({ ...spec, colRef });\n  }\n\n  /**\n   * Finds an index of column expression in a sorting expression.\n   */\n  export function findColIndex(sortSpec: SortSpec, colRef: ColSpec): number {\n    return sortSpec.findIndex(colSpec => sameColumn(colSpec, colRef));\n  }\n\n  export function removeCol(sortSpec: SortSpec, colRef: ColSpec): SortSpec {\n    return sortSpec.filter(col => getColRef(col) !== getColRef(colRef));\n  }\n\n  /**\n   * Finds a column expression in sorting expression (regardless sorting option).\n   */\n  export function findCol(sortSpec: SortSpec, colRef: ColSpec): ColSpec | undefined {\n    const result = sortSpec.find(colSpec => sameColumn(colSpec, colRef));\n    return result;\n  }\n\n  /**\n   * Inserts new column sort options at the index of an existing column options (and removes the old one).\n   * If the old column can't be found it does nothing.\n   * @param colRef Column id to remove\n   * @param newSpec New column sort options to put in place of the old one.\n   */\n  export function replace(sortSpec: SortSpec, colRef: ColRef, newSpec: ColSpec | ColSpecDetails): SortSpec {\n    const index = findColIndex(sortSpec, colRef);\n    if (index >= 0) {\n      const updated = sortSpec.slice();\n      updated[index] = typeof newSpec === \"object\" ? detailsToSpec(newSpec) : newSpec;\n      return updated;\n    }\n    return sortSpec;\n  }\n\n  /**\n   * Flips direction for a single column, returns a new object.\n   */\n  export function flipCol(colSpec: ColSpec): ColSpec {\n    if (typeof colSpec === \"number\") {\n      return -colSpec;\n    }\n    const spec = parseColSpec(colSpec);\n    return detailsToSpec({ ...spec, direction: spec.direction === ASC ? DESC : ASC });\n  }\n\n  // Takes an activeSortSpec and sortRef to flip and returns a new\n  // activeSortSpec with that sortRef flipped (or original spec if sortRef not found).\n  export function flipSort(spec: SortSpec, colSpec: ColSpec): SortSpec {\n    const idx = findColIndex(spec, getColRef(colSpec));\n    if (idx !== NOT_FOUND) {\n      const newSpec = Array.from(spec);\n      newSpec[idx] = flipCol(newSpec[idx]);\n      return newSpec;\n    }\n    return spec;\n  }\n\n  export function setSortDirection(spec: SortSpec, colSpec: ColSpec, dir: Direction): SortSpec {\n    const idx = findColIndex(spec, getColRef(colSpec));\n    if (idx !== NOT_FOUND) {\n      const newSpec = Array.from(spec);\n      newSpec[idx] = setColDirection(newSpec[idx], dir);\n      return newSpec;\n    }\n    return spec;\n  }\n\n  // Parses the sortColRefs string, defaulting to an empty array on invalid input.\n  export function parseSortColRefs(sortColRefs: string): SortSpec {\n    try {\n      return JSON.parse(sortColRefs);\n    } catch (err) {\n      return [];\n    }\n  }\n\n  // Given the current sort spec, moves colSpec to be immediately before nextColSpec. Moves v\n  // to the end of the sort spec if nextColSpec is null.\n  // If the given colSpec or nextColSpec cannot be found, return sortSpec unchanged.\n  // ColSpec are identified only by colRef (order or options don't matter).\n  export function reorderSortRefs(spec: SortSpec, colSpec: ColSpec, nextColSpec: ColSpec | null): SortSpec {\n    const updatedSpec = spec.slice();\n\n    // Remove sortRef from sortSpec.\n    const _idx = findColIndex(updatedSpec, colSpec);\n    if (_idx === NOT_FOUND) {\n      return spec;\n    }\n    updatedSpec.splice(_idx, 1);\n\n    // Add sortRef to before nextSortRef\n    const _nextIdx = nextColSpec ? findColIndex(updatedSpec, nextColSpec) : updatedSpec.length;\n    if (_nextIdx === NOT_FOUND) {\n      return spec;\n    }\n    updatedSpec.splice(_nextIdx, 0, colSpec);\n\n    return updatedSpec;\n  }\n\n  // Helper function for query based sorting, which uses column names instead of columns ids.\n  // Translates expressions like -Pet, to an colRef expression like -1.\n  // NOTE: For column with zero index, it will return a string.\n  export function parseNames(sort: string[], colIdToRef: Map<string, number>): SortSpec {\n    const COL_SPEC_REG = /^(-)?([\\w]+)(:.+)?/;\n    return sort.map((colSpec) => {\n      const match = colSpec.match(COL_SPEC_REG);\n      if (!match) {\n        throw new Error(`unknown key ${colSpec}`);\n      }\n      const [, sign, key, options] = match;\n      let colRef = Number(key);\n      if (!isNaN(colRef)) {\n        // This might be valid colRef\n        if (![...colIdToRef.values()].includes(colRef)) {\n          throw new Error(`invalid column id ${key}`);\n        }\n      } else if (!colIdToRef.has(key)) {\n        throw new Error(`unknown key ${key}`);\n      } else {\n        colRef = colIdToRef.get(key)!;\n      }\n      return `${sign || \"\"}${colRef}${options ?? \"\"}`;\n    });\n  }\n}\n\nlet _virtualIdCounter = 1;\nconst _virtualSymbols = new Map<string, string>();\n/**\n * Creates a virtual id for virtual tables. Can remember some generated ids if called with a\n * name (this feature used only in tests for now).\n *\n * The resulting id looks like _vid\\d+.\n */\nexport function VirtualId(symbol = \"\") {\n  if (symbol) {\n    if (!_virtualSymbols.has(symbol)) {\n      const generated = `${VirtualId.PREFIX}${_virtualIdCounter++}`;\n      _virtualSymbols.set(symbol, generated);\n      return generated;\n    } else {\n      return _virtualSymbols.get(symbol)!;\n    }\n  } else {\n    return `${VirtualId.PREFIX}${_virtualIdCounter++}`;\n  }\n}\nVirtualId.PREFIX = \"_vid\";\n"
  },
  {
    "path": "app/common/StringUnion.ts",
    "content": "export class StringUnionError extends TypeError {\n  constructor(errMessage: string, public readonly actual: string, public readonly values: string[]) {\n    super(errMessage);\n  }\n}\n\n/**\n * TypeScript will infer a string union type from the literal values passed to\n * this function. Without `extends string`, it would instead generalize them\n * to the common string type.\n *\n * Example definition:\n * const Race = StringUnion(\n *   \"orc\",\n *   \"human\",\n *   \"night elf\",\n *   \"undead\",\n * );\n * type Race = typeof Race.type;\n *\n * For more details, see:\n * https://stackoverflow.com/questions/36836011/checking-validity-of-string\n *   -literal-union-type-at-runtime?answertab=active#tab-top\n */\nexport const StringUnion = <UnionType extends string>(...values: UnionType[]) => {\n  Object.freeze(values);\n  const valueSet = new Set<string>(values);\n\n  const guard = (value: string): value is UnionType => {\n    return valueSet.has(value);\n  };\n\n  const check = (value: string): UnionType => {\n    if (!guard(value)) {\n      const actual = JSON.stringify(value);\n      const expected = values.map(s => JSON.stringify(s)).join(\" | \");\n      throw new StringUnionError(`Value '${actual}' is not assignable to type '${expected}'.`, actual, values);\n    }\n    return value;\n  };\n\n  const checkAll = (arr: string[]): UnionType[] => {\n    return arr.map(check);\n  };\n\n  /**\n   * StringUnion.parse(value) returns value when it's valid, and undefined otherwise.\n   */\n  const parse = (value: string | null | undefined): UnionType | undefined => {\n    return value != null && guard(value) ? value : undefined;\n  };\n\n  const unionNamespace = { guard, check, parse, values, checkAll };\n  return Object.freeze(unionNamespace as typeof unionNamespace & { type: UnionType });\n};\n"
  },
  {
    "path": "app/common/TableData.ts",
    "content": "/**\n * TableData maintains a single table's data.\n */\nimport { ActionDispatcher } from \"app/common/ActionDispatcher\";\nimport { BulkAddRecord, BulkColValues, CellValue, ColInfo, ColInfoWithId, ColValues, DocAction,\n  isSchemaAction, ReplaceTableData, RowRecord, TableDataAction } from \"app/common/DocActions\";\nimport { getDefaultForType } from \"app/common/gristTypes\";\nimport { arrayRemove, arraySplice, getDistinctValues } from \"app/common/gutil\";\nimport { SchemaTypes } from \"app/common/schema\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\n\nimport fromPairs from \"lodash/fromPairs\";\nimport isEqual from \"lodash/isEqual\";\n\nexport interface ColTypeMap { [colId: string]: string; }\n\ntype UIRowFunc<T> = (rowId: UIRowId) => T;\n\ninterface ColData {\n  colId: string;\n  type: string;\n  defl: any;\n  values: CellValue[];\n}\n\nexport interface SingleCell {\n  tableId: string;\n  colId: string;\n  rowId: number;\n}\n\n/**\n * An interface for a table with rows that may be skipped.\n */\nexport interface SkippableRows {\n  // If there may be skippable rows, return a function to test rowIds for keeping.\n  getKeepFunc(): undefined | UIRowFunc<boolean>;\n  // Get a special row id which represents a skipped sequence of rows.\n  getSkipRowId(): number;\n}\n\n/**\n * TableData class to maintain a single table's data.\n *\n * In the browser's memory, table data needs a representation that's reasonably compact. We\n * represent it as column-wise arrays. (An early hope was to allow use of TypedArrays, but since\n * types can be mixed, those are not used.)\n */\nexport class TableData extends ActionDispatcher implements SkippableRows {\n  private _tableId: string;\n  private _isLoaded: boolean = false;\n  private _fetchPromise?: Promise<void>;\n\n  // Storage of the underlying data. Each column is an array, all of the same length. Includes\n  // 'id' column, containing a reference to _rowIdCol.\n  private _columns = new Map<string, ColData>();\n\n  // Array of all ColData objects, omitting 'id'.\n  private _colArray: ColData[] = [];\n\n  // The `id` column is direct reference to the 'id' column, and contains row ids.\n  private _rowIdCol: number[] = [];\n\n  // Maps row id to index in the arrays in _columns. I.e. it's the inverse of _rowIdCol.\n  private _rowMap = new Map<number, number>();\n\n  constructor(tableId: string, tableData: TableDataAction | null, colTypes: ColTypeMap) {\n    super();\n    this._tableId = tableId;\n\n    // Initialize all columns to empty arrays, while nothing is yet loaded.\n    for (const colId in colTypes) {\n      if (colTypes.hasOwnProperty(colId)) {\n        const type = colTypes[colId];\n        const defl = getDefaultForType(type);\n        const colData: ColData = { colId, type, defl, values: [] };\n        this._columns.set(colId, colData);\n        this._colArray.push(colData);\n      }\n    }\n    this._columns.set(\"id\", { colId: \"id\", type: \"Id\", defl: 0, values: this._rowIdCol });\n\n    if (tableData) {\n      this.loadData(tableData);\n    }\n    // TODO: We should probably unload big sets of data when no longer needed. This can be left for\n    // when we support loading only parts of a table.\n  }\n\n  /**\n   * Fetch data (as long as a fetch is not in progress), and load it in memory when done.\n   * Returns a promise that's resolved when data finishes loading, and isLoaded becomes true.\n   */\n  public fetchData(fetchFunc: (tableId: string) => Promise<TableDataAction>): Promise<void> {\n    if (!this._fetchPromise) {\n      this._fetchPromise = fetchFunc(this._tableId).then((data) => {\n        this._fetchPromise = undefined;\n        this.loadData(data);\n      }).catch((err) => {\n        this._fetchPromise = undefined;\n        throw err;\n      });\n    }\n    return this._fetchPromise;\n  }\n\n  /**\n   * Populates the data for this table. Returns the array of old rowIds that were loaded before.\n   */\n  public loadData(tableData: TableDataAction | ReplaceTableData): number[] {\n    const rowIds: number[] = tableData[2];\n    const colValues: BulkColValues = tableData[3];\n    const oldRowIds: number[] = this._rowIdCol.slice(0);\n\n    reassignArray(this._rowIdCol, rowIds);\n    for (const colData of this._colArray) {\n      const values = colData.colId === \"id\" ? rowIds : colValues[colData.colId];\n      // If colId is missing from tableData, use an array of default values. Note that reusing\n      // default value like this is only OK because all default values we use are primitive.\n      reassignArray(colData.values, values || this._rowIdCol.map(() => colData.defl));\n    }\n\n    this._rowMap.clear();\n    for (let i = 0; i < rowIds.length; i++) {\n      this._rowMap.set(rowIds[i], i);\n    }\n\n    this._isLoaded = true;\n    return oldRowIds;\n  }\n\n  // Used by QuerySet to load new rows for onDemand tables.\n  public loadPartial(data: TableDataAction): void {\n    // Add the new rows, reusing BulkAddData code.\n    const rowIds: number[] = data[2];\n    this.onBulkAddRecord(data, data[1], rowIds, data[3]);\n\n    // Mark the table as loaded.\n    this._isLoaded = true;\n  }\n\n  // Used by QuerySet to remove unused rows for onDemand tables when a QuerySet is disposed.\n  public unloadPartial(rowIds: number[]): void {\n    // Remove the unneeded rows, reusing BulkRemoveRecord code.\n    this.onBulkRemoveRecord([\"BulkRemoveRecord\", this.tableId, rowIds], this.tableId, rowIds);\n  }\n\n  /**\n   * Read-only tableId.\n   */\n  public get tableId(): string { return this._tableId; }\n\n  /**\n   * Boolean flag for whether the data for this table is already loaded.\n   */\n  public get isLoaded(): boolean { return this._isLoaded; }\n\n  /**\n   * The number of records loaded in this table.\n   */\n  public numRecords(): number { return this._rowIdCol.length; }\n\n  /**\n   * Returns the specified value from this table.\n   */\n  public getValue(rowId: UIRowId, colId: string): CellValue | undefined {\n    const colData = this._columns.get(colId);\n    const index = this._rowMap.get(rowId as number);    // rowId of 'new' will not be found.\n    return colData && index !== undefined ? colData.values[index] : undefined;\n  }\n\n  public hasRowId(rowId: number): boolean {\n    return this._rowMap.has(rowId);\n  }\n\n  /**\n   * Returns the index of the given rowId, if it exists, in the same unstable order that's\n   * returned by getRowIds() and getColValues().\n   */\n  public getRowIdIndex(rowId: UIRowId): number | undefined {\n    return this._rowMap.get(rowId as number);\n  }\n\n  /**\n   * Given a column name, returns a function that takes a rowId and returns the value for that\n   * column of that row. The returned function is faster than getValue() calls.\n   */\n  public getRowPropFunc(colId: string): UIRowFunc<CellValue | undefined> {\n    const colData = this._columns.get(colId);\n    if (!colData) { return () => undefined; }\n    const values = colData.values;\n    const rowMap = this._rowMap;\n    return (rowId: UIRowId) => values[rowMap.get(rowId as number)!];\n  }\n\n  // By default, no rows are skippable, all are kept.\n  public getKeepFunc(): undefined | UIRowFunc<boolean> {\n    return undefined;\n  }\n\n  // By default, no special row id for skip rows is needed.\n  public getSkipRowId(): number {\n    throw new Error(\"no skip row id defined\");\n  }\n\n  /**\n   * Returns the list of all rowIds in this table, in unspecified and unstable order. Equivalent\n   * to getColValues('id').\n   */\n  public getRowIds(): readonly number[] {\n    return this._rowIdCol;\n  }\n\n  /**\n   * Sort and returns the list of all rowIds in this table.\n   */\n  public getSortedRowIds(): number[] {\n    return this._rowIdCol.slice(0).sort((a, b) => a - b);\n  }\n\n  /**\n   * Returns true if cells may contain multiple versions (e.g. in diffs).\n   */\n  public mayHaveVersions() {\n    return false;\n  }\n\n  /**\n   * Returns the list of colIds in this table, including 'id'.\n   */\n  public getColIds(): string[] {\n    return Array.from(this._columns.keys());\n  }\n\n  /**\n   * Returns an unsorted list of all values in the given column. With no intervening actions,\n   * all arrays returned by getColValues() and getRowIds() are parallel to each other, i.e. the\n   * values at the same index correspond to the same record.\n   */\n  public getColValues(colId: string): readonly CellValue[] | undefined {\n    const colData = this._columns.get(colId);\n    return colData ? colData.values : undefined;\n  }\n\n  /**\n   * Returns a limited-sized set of distinct values from a column. If count is given, limits how many\n   * distinct values are returned.\n   */\n  public getDistinctValues(colId: string, count: number = Infinity): Set<CellValue> | undefined {\n    const valColumn = this.getColValues(colId);\n    if (!valColumn) { return undefined; }\n    return getDistinctValues(valColumn, count);\n  }\n\n  /**\n   * Return data in TableDataAction form ['TableData', tableId, [...rowIds], {...}]\n   * Optionally takes a list of row ids to return data from. If a row id is\n   * not actually present in the table, a row of nulls will be returned for it.\n   */\n  public getTableDataAction(desiredRowIds?: number[],\n    colIds?: string[]): TableDataAction {\n    colIds = colIds || this.getColIds();\n    const colIdSet = new Set<string>(colIds);\n    const rowIds = desiredRowIds || this.getRowIds();\n    let bulkColValues: { [colId: string]: CellValue[] };\n    const colArray = this._colArray.filter(({ colId }) => colIdSet.has(colId));\n    if (desiredRowIds) {\n      const len = rowIds.length;\n      bulkColValues = {};\n      for (const colId of colIds) { bulkColValues[colId] = Array(len); }\n      for (let i = 0; i < len; i++) {\n        const index = this._rowMap.get(rowIds[i]);\n        for (const { colId, values } of colArray) {\n          const value = (index === undefined) ? null : values[index];\n          bulkColValues[colId][i] = value;\n        }\n      }\n    } else {\n      bulkColValues = fromPairs(\n        colIds\n          .filter(colId => colId !== \"id\")\n          .map(colId => [colId, this.getColValues(colId)! as CellValue[]]));\n    }\n    return [\"TableData\",\n      this.tableId,\n      rowIds as number[],\n      bulkColValues];\n  }\n\n  public getBulkAddRecord(desiredRowIds?: number[]): BulkAddRecord {\n    const tableData = this.getTableDataAction(desiredRowIds?.sort((a, b) => a - b));\n    return [\n      \"BulkAddRecord\", tableData[1], tableData[2], tableData[3],\n    ];\n  }\n\n  /**\n   * Returns the given columns type, if the column exists, or undefined otherwise.\n   */\n  public getColType(colId: string): string | undefined {\n    const colData = this._columns.get(colId);\n    return colData ? colData.type : undefined;\n  }\n\n  /**\n   * Builds and returns a record object for the given rowId.\n   */\n  public getRecord(rowId: number): undefined | RowRecord {\n    const index = this._rowMap.get(rowId);\n    if (index === undefined) { return undefined; }\n    const ret: RowRecord = { id: this._rowIdCol[index] };\n    for (const colData of this._colArray) {\n      ret[colData.colId] = colData.values[index];\n    }\n    return ret;\n  }\n\n  /**\n   * Builds and returns the list of all records on this table, in unspecified and unstable order.\n   */\n  public getRecords(): RowRecord[] {\n    const records: RowRecord[] = this._rowIdCol.map(id => ({ id }));\n    for (const { colId, values } of this._colArray) {\n      for (let i = 0; i < records.length; i++) {\n        records[i][colId] = values[i];\n      }\n    }\n    return records;\n  }\n\n  public filterRowIds(properties: { [key: string]: CellValue | undefined }): number[] {\n    return this._filterRowIndices(properties).map(i => this._rowIdCol[i]);\n  }\n\n  /**\n   * Builds and returns the list of records in this table that match the given properties object.\n   * Properties may include 'id' and any table columns. Returned records are not sorted.\n   */\n  public filterRecords(properties: { [key: string]: CellValue | undefined }): RowRecord[] {\n    const rowIndices: number[] = this._filterRowIndices(properties);\n\n    // Convert the array of indices to an array of RowRecords.\n    const records: RowRecord[] = rowIndices.map(i => ({ id: this._rowIdCol[i] }));\n    for (const { colId, values } of this._colArray) {\n      for (let i = 0; i < records.length; i++) {\n        records[i][colId] = values[rowIndices[i]];\n      }\n    }\n    return records;\n  }\n\n  /**\n   * Returns the rowId in the table where colValue is found in the column with the given colId.\n   */\n  public findRow(colId: string, colValue: CellValue): number {\n    const colData = this._columns.get(colId);\n    if (!colData) {\n      return 0;\n    }\n    const index = colData.values.indexOf(colValue);\n    return index < 0 ? 0 : this._rowIdCol[index];\n  }\n\n  /**\n   * Returns a record object for the row where colValue is found in the column with the given\n   * colId. If there are multiple matches, it is unspecified which will be returned.\n   */\n  public findRecord(colId: string, colValue: CellValue): RowRecord | undefined {\n    const searchColData = this._columns.get(colId);\n    if (!searchColData) { return undefined; }\n    const index = searchColData.values.indexOf(colValue);\n    if (index < 0) { return undefined; }\n    const ret: RowRecord = { id: this._rowIdCol[index] };\n    for (const colData of this._colArray) {\n      ret[colData.colId] = colData.values[index];\n    }\n    return ret;\n  }\n\n  /**\n   * Returns the first rowId matching the given filters, or 0 if no match. If there are multiple\n   * matches, it is unspecified which will be returned.\n   */\n  public findMatchingRowId(properties: { [key: string]: CellValue | undefined }): number {\n    const props = Object.keys(properties).map(p => ({ col: this._columns.get(p)!, value: properties[p] }));\n    if (!props.every(p => p.col)) {\n      return 0;\n    }\n    return this._rowIdCol.find((id, i) =>\n      props.every(p => isEqual(p.col.values[i], p.value)),\n    ) || 0;\n  }\n\n  /**\n   * Applies a DocAction received from the server; returns true, or false if it was skipped.\n   */\n  public receiveAction(action: DocAction): boolean {\n    if (this._isLoaded || isSchemaAction(action)) {\n      this.dispatchAction(action);\n      return true;\n    }\n    return false;\n  }\n\n  // ---- The following methods implement ActionDispatcher interface ----\n\n  protected onAddRecord(action: DocAction, tableId: string, rowId: number, colValues: ColValues): void {\n    if (this._rowMap.get(rowId) !== undefined) {\n      // If adding a record that already exists, act like an update.\n      // We rely on this behavior for distributing attachment\n      // metadata.\n      this.onUpdateRecord(action, tableId, rowId, colValues);\n      return;\n    }\n    const index: number = this._rowIdCol.length;\n    this._rowMap.set(rowId, index);\n    this._rowIdCol[index] = rowId;\n    for (const { colId, defl, values } of this._colArray) {\n      values[index] = colValues.hasOwnProperty(colId) ? colValues[colId] : defl;\n    }\n  }\n\n  protected onBulkAddRecord(action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void {\n    let destIndex: number = this._rowIdCol.length;\n    for (let i = 0; i < rowIds.length; i++) {\n      const srcIndex = this._rowMap.get(rowIds[i]);\n      if (srcIndex !== undefined) {\n        // If adding a record that already exists, act like an update.\n        // We rely on this behavior for distributing attachment\n        // metadata.\n        for (const colId in colValues) {\n          if (colValues.hasOwnProperty(colId)) {\n            const colData = this._columns.get(colId);\n            if (colData) {\n              colData.values[srcIndex] = colValues[colId][i];\n            }\n          }\n        }\n      } else {\n        this._rowMap.set(rowIds[i], destIndex);\n        this._rowIdCol[destIndex] = rowIds[i];\n        for (const { colId, defl, values } of this._colArray) {\n          values[destIndex] = colValues.hasOwnProperty(colId) ? colValues[colId][i] : defl;\n        }\n        destIndex++;\n      }\n    }\n  }\n\n  protected onRemoveRecord(action: DocAction, tableId: string, rowId: number): void {\n    // Note that in this implementation, delete + undo will reorder the storage and the ordering\n    // of rows returned getRowIds() and similar methods.\n    const index = this._rowMap.get(rowId);\n    if (index !== undefined) {\n      const last: number = this._rowIdCol.length - 1;\n      // We keep the column-wise arrays dense by moving the last element into the freed-up spot.\n      for (const { values } of this._columns.values()) {    // This adjusts _rowIdCol too.\n        values[index] = values[last];\n        values.pop();\n      }\n      this._rowMap.set(this._rowIdCol[index], index);\n      this._rowMap.delete(rowId);\n    }\n  }\n\n  protected onUpdateRecord(action: DocAction, tableId: string, rowId: number, colValues: ColValues): void {\n    const index = this._rowMap.get(rowId);\n    if (index !== undefined) {\n      for (const colId in colValues) {\n        if (colValues.hasOwnProperty(colId)) {\n          const colData = this._columns.get(colId);\n          if (colData) {\n            colData.values[index] = colValues[colId];\n          }\n        }\n      }\n    }\n  }\n\n  protected onBulkUpdateRecord(action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void {\n    for (let i = 0; i < rowIds.length; i++) {\n      const index = this._rowMap.get(rowIds[i]);\n      if (index !== undefined) {\n        for (const colId in colValues) {\n          if (colValues.hasOwnProperty(colId)) {\n            const colData = this._columns.get(colId);\n            if (colData) {\n              colData.values[index] = colValues[colId][i];\n            }\n          }\n        }\n      }\n    }\n  }\n\n  protected onReplaceTableData(action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void {\n    this.loadData(action as ReplaceTableData);\n  }\n\n  protected onAddColumn(action: DocAction, tableId: string, colId: string, colInfo: ColInfo): void {\n    if (this._columns.has(colId)) { return; }\n    const type = colInfo.type;\n    const defl = getDefaultForType(type);\n    const colData: ColData = { colId, type, defl, values: this._rowIdCol.map(() => defl) };\n    this._columns.set(colId, colData);\n    this._colArray.push(colData);\n  }\n\n  protected onRemoveColumn(action: DocAction, tableId: string, colId: string): void {\n    const colData = this._columns.get(colId);\n    if (!colData) { return; }\n    this._columns.delete(colId);\n    arrayRemove(this._colArray, colData);\n  }\n\n  protected onRenameColumn(action: DocAction, tableId: string, oldColId: string, newColId: string): void {\n    const colData = this._columns.get(oldColId);\n    if (colData) {\n      colData.colId = newColId;\n      this._columns.set(newColId, colData);\n      this._columns.delete(oldColId);\n    }\n  }\n\n  protected onModifyColumn(action: DocAction, tableId: string, oldColId: string, colInfo: ColInfo): void {\n    const colData = this._columns.get(oldColId);\n    if (colData && colInfo.hasOwnProperty(\"type\")) {\n      colData.type = colInfo.type;\n      colData.defl = getDefaultForType(colInfo.type);\n    }\n  }\n\n  protected onRenameTable(action: DocAction, oldTableId: string, newTableId: string): void {\n    this._tableId = newTableId;\n  }\n\n  protected onAddTable(action: DocAction, tableId: string, columns: ColInfoWithId[]): void {\n    // A table processing its own addition is a noop\n  }\n\n  protected onRemoveTable(action: DocAction, tableId: string): void {\n    // Stop dispatching actions if we've been deleted. We might also want to clean up in the future.\n    this._isLoaded = false;\n  }\n\n  private _filterRowIndices(properties: { [key: string]: CellValue | undefined }): number[] {\n    const rowIndices: number[] = [];\n    // Array of {col: arrayOfColValues, value: valueToMatch}\n    const props = Object.keys(properties).map(p => ({ col: this._columns.get(p)!, value: properties[p] }));\n    this._rowIdCol.forEach((id, i) => {\n      // Collect the indices of the matching rows.\n      if (props.every(p => isEqual(p.col.values[i], p.value))) {\n        rowIndices.push(i);\n      }\n    });\n    return rowIndices;\n  }\n}\n\n// A type safe record of a meta table with types as defined in schema.ts\n// '&' is used because declaring the id field and the index signature in one block gives a syntax error.\n// The second part is basically equivalent to SchemaTypes[TableId]\n// but TS sees that as incompatible with RowRecord and doesn't allow simple overrides in MetaTableData.\nexport type MetaRowRecord<TableId extends keyof SchemaTypes> =\n  { id: number } &\n  { [ColId in keyof SchemaTypes[TableId]]: SchemaTypes[TableId][ColId] & CellValue };\n\ntype MetaColId<TableId extends keyof SchemaTypes> = keyof MetaRowRecord<TableId> & string;\n\n/**\n * Behaves the same as TableData, but uses SchemaTypes for type safety of its columns.\n */\nexport class MetaTableData<TableId extends keyof SchemaTypes> extends TableData {\n  constructor(tableId: TableId, tableData: TableDataAction | null, colTypes: ColTypeMap) {\n    super(tableId, tableData, colTypes);\n  }\n\n  public getValue<ColId extends MetaColId<TableId>>(rowId: number, colId: ColId):\n    MetaRowRecord<TableId>[ColId] | undefined {\n    return super.getValue(rowId, colId) as any;\n  }\n\n  public getRecords(): MetaRowRecord<TableId>[] {\n    return super.getRecords() as any;\n  }\n\n  public getRecord(rowId: number): MetaRowRecord<TableId> | undefined {\n    return super.getRecord(rowId) as any;\n  }\n\n  public filterRecords(properties: Partial<MetaRowRecord<TableId>>): MetaRowRecord<TableId>[] {\n    return super.filterRecords(properties) as any;\n  }\n\n  public findMatchingRowId(properties: Partial<MetaRowRecord<TableId>>): number {\n    return super.findMatchingRowId(properties);\n  }\n\n  public getRowPropFunc<ColId extends MetaColId<TableId>>(\n    colId: ColId,\n  ): UIRowFunc<MetaRowRecord<TableId>[ColId]> {\n    return super.getRowPropFunc(colId as any) as any;\n  }\n\n  public getColValues<ColId extends MetaColId<TableId>>(\n    colId: ColId,\n  ): readonly MetaRowRecord<TableId>[ColId][] {\n    return super.getColValues(colId) as any;\n  }\n\n  public findRow<ColId extends MetaColId<TableId>>(\n    colId: ColId, colValue: MetaRowRecord<TableId>[ColId],\n  ): number {\n    return super.findRow(colId, colValue);\n  }\n\n  public findRecord<ColId extends MetaColId<TableId>>(\n    colId: ColId, colValue: MetaRowRecord<TableId>[ColId],\n  ): MetaRowRecord<TableId> | undefined {\n    return super.findRecord(colId, colValue) as any;\n  }\n}\n\nfunction reassignArray<T>(targetArray: T[], sourceArray: T[]): void {\n  targetArray.length = 0;\n  arraySplice(targetArray, 0, sourceArray);\n}\n"
  },
  {
    "path": "app/common/TabularDiff.ts",
    "content": "/**\n *\n * Types for use when summarizing differences between versions of a table, with the\n * diff itself presented in tabular form.\n *\n */\n\n/**\n * Pairs of before/after values of cells. Values, when present, are nested in a trivial\n * list since they can be literally anything - null, undefined, etc.  Otherwise they\n * are either null, meaning non-existent, or \"?\", meaning unknown.  Non-existent values\n * appear prior to a table/column being created, or after it has been destroyed.\n * Unknown values appear when they are omitted from summaries of bulk actions, and those\n * summaries are then merged with others.\n */\nexport type CellDelta = [[any] | \"?\" | null, [any] | \"?\" | null];\n\n/** a special column indicating what changes happened on row (addition, update, removal) */\nexport type RowChangeType = string;\n\ninterface TabularDiffRow {\n  type: RowChangeType;\n  rowId: number;\n  cellDeltas: CellDelta[];\n}\n\n/** differences for an individual table */\nexport interface TabularDiff {\n  header: string[];  /** labels for columns */\n  cells: TabularDiffRow[];\n}\n\n/** differences for a collection of tables */\nexport interface TabularDiffs {\n  [tableId: string]: TabularDiff;\n}\n"
  },
  {
    "path": "app/common/Telemetry.ts",
    "content": "import { StringUnion } from \"app/common/StringUnion\";\n\n/**\n * Telemetry levels, in increasing order of data collected.\n */\nexport enum Level {\n  off = 0,\n  limited = 1,\n  full = 2,\n}\n\n/**\n * A set of contracts that all telemetry events must follow prior to being\n * logged.\n *\n * Currently, this includes meeting minimum telemetry levels for events\n * and their metadata, and passing in the correct data type for the value of\n * each metadata property.\n *\n * The `minimumTelemetryLevel` defined at the event level will also be applied\n * to all metadata properties of an event, and can be overridden at the metadata\n * level.\n */\nexport const TelemetryContracts: TelemetryContracts = {\n  \"apiUsage\": {\n    description: \"Triggered when an HTTP request with an API key is made.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      method: {\n        description: \"The HTTP request method (e.g. GET, POST, PUT).\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      userAgent: {\n        description: \"The User-Agent HTTP request header.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"assistantOpen\": {\n    category: \"AIAssistant\",\n    description: \"Triggered when the AI Assistant is first opened.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"short\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      version: {\n        description: \"The assistant version. May be either `1` or `2`.\",\n        dataType: \"number\",\n      },\n      conversationId: {\n        description: \"A random identifier for the current conversation with the assistant.\",\n        dataType: \"string\",\n      },\n      context: {\n        description: \"The context in which the assistant is open (e.g. column id).\",\n        dataType: \"object\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"assistantSend\": {\n    category: \"AIAssistant\",\n    description: \"Triggered when a message is sent to the AI Assistant.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"short\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      siteId: {\n        description: \"The id of the site.\",\n        dataType: \"number\",\n      },\n      siteType: {\n        description: \"The type of the site.\",\n        dataType: \"string\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      access: {\n        description: \"The document access level of the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      version: {\n        description: \"The assistant version. May be either `1` or `2`.\",\n        dataType: \"number\",\n      },\n      conversationId: {\n        description: \"A random identifier for the current conversation with the assistant.\",\n        dataType: \"string\",\n      },\n      context: {\n        description: \"The context in which the assistant is open (e.g. column id).\",\n        dataType: \"object\",\n      },\n      prompt: {\n        description: 'The role (\"user\" or \"system\"), content, and index of the message sent to the AI Assistant.',\n        dataType: \"object\",\n      },\n      developerPromptVersion: {\n        description: 'The developer prompt version. May be either `\"default\"` or `\"new-document\"`.',\n        dataType: \"string\",\n      },\n    },\n  },\n  \"assistantReceive\": {\n    category: \"AIAssistant\",\n    description: \"Triggered when a message is received from the AI Assistant.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"short\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      siteId: {\n        description: \"The id of the site.\",\n        dataType: \"number\",\n      },\n      siteType: {\n        description: \"The type of the site.\",\n        dataType: \"string\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      access: {\n        description: \"The document access level of the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      version: {\n        description: \"The assistant version. May be either `1` or `2`.\",\n        dataType: \"number\",\n      },\n      conversationId: {\n        description: \"A random identifier for the current conversation with the assistant.\",\n        dataType: \"string\",\n      },\n      context: {\n        description: \"The context in which the assistant is open (e.g. column id).\",\n        dataType: \"object\",\n      },\n      response: {\n        description: \"The content and index of the response received from the AI Assistant.\",\n        dataType: \"object\",\n      },\n      suggestedFormula: {\n        description: \"The formula suggested by the AI Assistant, if present.\",\n        dataType: \"string\",\n      },\n      developerPromptVersion: {\n        description: 'The developer prompt version. May be either `\"default\"` or `\"new-document\"`.',\n        dataType: \"string\",\n      },\n    },\n  },\n  \"assistantSave\": {\n    category: \"AIAssistant\",\n    description: \"Triggered when changes in the expanded formula editor are saved after the AI Assistant \" +\n      \"was opened.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"short\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      version: {\n        description: \"The assistant version. Always set to `1`.\",\n        dataType: \"number\",\n      },\n      conversationId: {\n        description: \"A random identifier for the current conversation with the assistant.\",\n        dataType: \"string\",\n      },\n      context: {\n        description: \"The context in which the assistant is open (e.g. column id).\",\n        dataType: \"object\",\n      },\n      newFormula: {\n        description: \"The formula that was saved.\",\n        dataType: \"string\",\n      },\n      oldFormula: {\n        description: \"The formula that was overwritten.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"assistantCancel\": {\n    category: \"AIAssistant\",\n    description: \"Triggered when changes in the expanded formula editor are discarded after the AI Assistant \" +\n      \"was opened.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"short\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      version: {\n        description: \"The assistant version. Always set to `1`.\",\n        dataType: \"number\",\n      },\n      conversationId: {\n        description: \"A random identifier for the current conversation with the assistant.\",\n        dataType: \"string\",\n      },\n      conversationLength: {\n        description: \"The number of messages sent and received since opening the AI Assistant.\",\n        dataType: \"number\",\n      },\n      context: {\n        description: \"The context in which the assistant is open (e.g. column id).\",\n        dataType: \"object\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"assistantApplySuggestion\": {\n    category: \"AIAssistant\",\n    description: \"Triggered when a suggested formula from one of the received messages was applied and saved.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      version: {\n        description: \"The assistant version. Always set to `1`.\",\n        dataType: \"number\",\n      },\n      conversationId: {\n        description: \"A random identifier for the current conversation with the assistant.\",\n        dataType: \"string\",\n      },\n      conversationLength: {\n        description: \"The number of messages sent and received since opening the AI Assistant.\",\n        dataType: \"number\",\n      },\n      conversationHistoryLength: {\n        description: \"The number of messages in the conversation's history. May be less than conversationLength \" +\n          \"if the conversation history was cleared in the same session.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"assistantClearConversation\": {\n    category: \"AIAssistant\",\n    description: \"Triggered when a conversation in the AI Assistant is cleared.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"short\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      version: {\n        description: \"The assistant version. May be either `1` or `2`.\",\n        dataType: \"number\",\n      },\n      conversationId: {\n        description: \"A random identifier for the current conversation with the assistant.\",\n        dataType: \"string\",\n      },\n      context: {\n        description: \"The context in which the assistant is open (e.g. column id).\",\n        dataType: \"object\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"assistantClose\": {\n    category: \"AIAssistant\",\n    description: \"Triggered when a formula is saved or discarded after the AI Assistant was opened.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      version: {\n        description: \"The assistant version. Always set to `1`.\",\n        dataType: \"number\",\n      },\n      conversationId: {\n        description: \"A random identifier for the current conversation with the assistant.\",\n        dataType: \"string\",\n      },\n      suggestionApplied: {\n        description: \"True if a suggested formula from one of the received messages was applied.\",\n        dataType: \"boolean\",\n      },\n      conversationLength: {\n        description: \"The number of messages sent and received since opening the AI Assistant.\",\n        dataType: \"number\",\n      },\n      conversationHistoryLength: {\n        description: \"The number of messages in the conversation's history. May be less than conversationLength \" +\n          \"if the conversation history was cleared in the same session.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"assistantStartDocument\": {\n    category: \"AIAssistant\",\n    description: \"Triggered when a user begins the process of creating a document using the AI Assistant.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"short\",\n    metadataContracts: {\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      prompt: {\n        description: \"The message sent to the AI Assistant.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"beaconOpen\": {\n    category: \"HelpCenter\",\n    description: \"Triggered when HelpScout Beacon is opened.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"beaconArticleViewed\": {\n    category: \"HelpCenter\",\n    description: \"Triggered when an article is opened in HelpScout Beacon.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      articleId: {\n        description: \"The id of the article.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"beaconEmailSent\": {\n    category: \"HelpCenter\",\n    description: \"Triggered when an email is sent in HelpScout Beacon.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"beaconSearch\": {\n    category: \"HelpCenter\",\n    description: \"Triggered when a search is made in HelpScout Beacon.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      searchQuery: {\n        description: \"The search query.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"ratedHelpCenterArticle\": {\n    category: \"HelpCenter\",\n    description: \"Sent by HelpCenter when user clicks thumbs-up or thumbs-down\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      url: {\n        description: \"The URL of the visited page.\",\n        dataType: \"string\",\n      },\n      rating: {\n        description: 'Feedback from user (\"thumbsUp\" or \"thumbsDown\")',\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"documentCreated\": {\n    description: \"Triggered when a document is created.\",\n    minimumTelemetryLevel: Level.limited,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the id of the created document.\",\n        dataType: \"string\",\n      },\n      sourceDocIdDigest: {\n        description: \"A hash of the id of the source document, if the document was \" +\n          \"duplicated from an existing document.\",\n        dataType: \"string\",\n      },\n      isImport: {\n        description: \"Whether the document was created by import.\",\n        dataType: \"boolean\",\n      },\n      isSaved: {\n        description: \"Whether the document was saved to a workspace.\",\n        dataType: \"boolean\",\n      },\n      fileType: {\n        description: \"If the document was created by import, the file extension \" +\n          \"of the file that was imported.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n        minimumTelemetryLevel: Level.full,\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n    },\n  },\n  \"documentForked\": {\n    description: \"Triggered when a document is forked.\",\n    minimumTelemetryLevel: Level.limited,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      siteId: {\n        description: \"The id of the site containing the forked document.\",\n        dataType: \"number\",\n        minimumTelemetryLevel: Level.full,\n      },\n      siteType: {\n        description: \"The type of the site.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n      access: {\n        description: \"The document access level of the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n        minimumTelemetryLevel: Level.full,\n      },\n      forkIdDigest: {\n        description: \"A hash of the fork id.\",\n        dataType: \"string\",\n      },\n      forkDocIdDigest: {\n        description: \"A hash of the full id of the fork, including the trunk id and fork id.\",\n        dataType: \"string\",\n      },\n      trunkIdDigest: {\n        description: \"A hash of the trunk id.\",\n        dataType: \"string\",\n      },\n      isTemplate: {\n        description: \"Whether the trunk is a template.\",\n        dataType: \"boolean\",\n      },\n      lastActivity: {\n        description: \"Timestamp of the last update to the trunk document.\",\n        dataType: \"date\",\n      },\n    },\n  },\n  \"documentOpened\": {\n    description: \"Triggered when a public document or template is opened.\",\n    minimumTelemetryLevel: Level.limited,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      siteId: {\n        description: \"The site id.\",\n        dataType: \"number\",\n        minimumTelemetryLevel: Level.full,\n      },\n      siteType: {\n        description: \"The site type.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n        minimumTelemetryLevel: Level.full,\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n      access: {\n        description: \"The document access level of the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      isPublic: {\n        description: \"Whether the document is public.\",\n        dataType: \"boolean\",\n      },\n      isSnapshot: {\n        description: \"Whether a snapshot was opened.\",\n        dataType: \"boolean\",\n      },\n      isTemplate: {\n        description: \"Whether the document is a template.\",\n        dataType: \"boolean\",\n      },\n      lastUpdated: {\n        description: \"Timestamp of when the document was last updated.\",\n        dataType: \"date\",\n      },\n    },\n  },\n  \"documentUsage\": {\n    description: \"Triggered on doc open and close, as well as hourly while a document is open.\",\n    minimumTelemetryLevel: Level.limited,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      siteId: {\n        description: \"The site id.\",\n        dataType: \"number\",\n        minimumTelemetryLevel: Level.full,\n      },\n      siteType: {\n        description: \"The site type.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n      access: {\n        description: \"The document access level of the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n        minimumTelemetryLevel: Level.full,\n      },\n      triggeredBy: {\n        description: 'What caused this event to trigger. May be either \"docOpen\", \"interval\", or \"docClose\".',\n        dataType: \"string\",\n      },\n      isPublic: {\n        description: \"Whether the document is public.\",\n        dataType: \"boolean\",\n      },\n      rowCount: {\n        description: \"The number of rows in the document.\",\n        dataType: \"number\",\n      },\n      dataSizeBytes: {\n        description: \"The total size of all data in the document, excluding attachments.\",\n        dataType: \"number\",\n      },\n      attachmentsSize: {\n        description: \"The total size of all attachments in the document.\",\n        dataType: \"number\",\n      },\n      numAccessRules: {\n        description: \"The number of access rules in the document.\",\n        dataType: \"number\",\n      },\n      numUserAttributes: {\n        description: \"The number of user attributes in the document.\",\n        dataType: \"number\",\n      },\n      numAttachments: {\n        description: \"The number of attachments in the document.\",\n        dataType: \"number\",\n      },\n      attachmentTypes: {\n        description: \"A list of unique file extensions compiled from all of the document's attachments.\",\n        dataType: \"string[]\",\n      },\n      numCharts: {\n        description: \"The number of charts in the document.\",\n        dataType: \"number\",\n      },\n      chartTypes: {\n        description: \"A list of chart types of every chart in the document.\",\n        dataType: \"string[]\",\n      },\n      numLinkedCharts: {\n        description: \"The number of linked charts in the document.\",\n        dataType: \"number\",\n      },\n      numLinkedWidgets: {\n        description: \"The number of linked widgets in the document.\",\n        dataType: \"number\",\n      },\n      numColumns: {\n        description: \"The number of columns in the document.\",\n        dataType: \"number\",\n      },\n      numColumnsWithConditionalFormatting: {\n        description: \"The number of columns with conditional formatting in the document.\",\n        dataType: \"number\",\n      },\n      numFormulaColumns: {\n        description: \"The number of formula columns in the document.\",\n        dataType: \"number\",\n      },\n      numTriggerFormulaColumns: {\n        description: \"The number of trigger formula columns in the document.\",\n        dataType: \"number\",\n      },\n      numSummaryFormulaColumns: {\n        description: \"The number of summary formula columns in the document.\",\n        dataType: \"number\",\n      },\n      numFieldsWithConditionalFormatting: {\n        description: \"The number of fields with conditional formatting in the document.\",\n        dataType: \"number\",\n      },\n      numTables: {\n        description: \"The number of tables in the document.\",\n        dataType: \"number\",\n      },\n      numOnDemandTables: {\n        description: \"The number of on-demand tables in the document.\",\n        dataType: \"number\",\n      },\n      numTablesWithConditionalFormatting: {\n        description: \"The number of tables with conditional formatting in the document.\",\n        dataType: \"number\",\n      },\n      numSummaryTables: {\n        description: \"The number of summary tables in the document.\",\n        dataType: \"number\",\n      },\n      numCustomWidgets: {\n        description: \"The number of custom widgets in the document.\",\n        dataType: \"number\",\n      },\n      customWidgetIds: {\n        description: \"A list of plugin ids for every custom widget in the document. \" +\n          'The ids of widgets not created by Grist Labs are replaced with \"externalId\".',\n        dataType: \"string[]\",\n      },\n    },\n  },\n  \"processMonitor\": {\n    description: \"Triggered every 5 seconds.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      heapUsedMB: {\n        description: \"Size of JS heap in use, in MiB.\",\n        dataType: \"number\",\n      },\n      heapTotalMB: {\n        description: \"Total heap size, in MiB, allocated for JS by V8. \",\n        dataType: \"number\",\n      },\n      cpuAverage: {\n        description: \"Fraction (typically between 0 and 1) of CPU usage. Includes all threads, so may exceed 1.\",\n        dataType: \"number\",\n      },\n      intervalMs: {\n        description: \"Interval (in milliseconds) over which `cpuAverage` is reported.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"sendingWebhooks\": {\n    description: \"Triggered when sending webhooks.\",\n    minimumTelemetryLevel: Level.limited,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      numEvents: {\n        description: \"The number of events in the batch of webhooks being sent.\",\n        dataType: \"number\",\n      },\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      siteId: {\n        description: \"The site id.\",\n        dataType: \"number\",\n        minimumTelemetryLevel: Level.full,\n      },\n      siteType: {\n        description: \"The site type.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n      access: {\n        description: \"The document access level of the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n        minimumTelemetryLevel: Level.full,\n      },\n    },\n  },\n  \"signupFirstVisit\": {\n    category: \"ProductVisits\",\n    description: \"Triggered when a new user first opens the Grist app.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      loginMethod: {\n        description: 'The login method on getgrist.com. May be \"Email + Password\" or \"Google\".',\n        dataType: \"string\",\n      },\n      siteId: {\n        description: \"The site id of first visit after signup.\",\n        dataType: \"number\",\n      },\n      siteType: {\n        description: \"The site type of first visit after signup.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that signed up.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"signupVerified\": {\n    description: \"Triggered after a user successfully verifies their account during sign-up. \" +\n      \"Not triggered in grist-core.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      verificationMethod: {\n        description: 'The verification method. May be \"code\" or \"link\".',\n        dataType: \"string\",\n      },\n      isAnonymousTemplateSignup: {\n        description: \"Whether the user viewed any templates before signing up.\",\n        dataType: \"boolean\",\n      },\n      templateId: {\n        description: \"The doc id of the template the user last viewed before signing up, if any.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"siteMembership\": {\n    description: \"Triggered daily.\",\n    minimumTelemetryLevel: Level.limited,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      siteId: {\n        description: \"The site id.\",\n        dataType: \"number\",\n      },\n      siteType: {\n        description: \"The site type.\",\n        dataType: \"string\",\n      },\n      numOwners: {\n        description: \"The number of users with an owner role in this site.\",\n        dataType: \"number\",\n      },\n      numEditors: {\n        description: \"The number of users with an editor role in this site.\",\n        dataType: \"number\",\n      },\n      numViewers: {\n        description: \"The number of users with a viewer role in this site.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"siteUsage\": {\n    description: \"Triggered daily.\",\n    minimumTelemetryLevel: Level.limited,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      siteId: {\n        description: \"The site id.\",\n        dataType: \"number\",\n      },\n      siteType: {\n        description: \"The site type.\",\n        dataType: \"string\",\n      },\n      inGoodStanding: {\n        description: \"Whether the site's subscription is in good standing.\",\n        dataType: \"boolean\",\n      },\n      stripePlanId: {\n        description: \"The Stripe Plan id associated with this site.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n      numDocs: {\n        description: \"The number of docs in this site.\",\n        dataType: \"number\",\n      },\n      numWorkspaces: {\n        description: \"The number of workspaces in this site.\",\n        dataType: \"number\",\n      },\n      numMembers: {\n        description: \"The number of site members.\",\n        dataType: \"number\",\n      },\n      lastActivity: {\n        description: \"A timestamp of the most recent update made to a site document.\",\n        dataType: \"date\",\n      },\n      earliestDocCreatedAt: {\n        description: \"A timestamp of the earliest non-deleted document creation time.\",\n        dataType: \"date\",\n      },\n    },\n  },\n  \"tutorialOpened\": {\n    category: \"Tutorial\",\n    description: \"Triggered when a tutorial is opened.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      tutorialForkIdDigest: {\n        description: \"A hash of the tutorial fork id.\",\n        dataType: \"string\",\n      },\n      tutorialTrunkIdDigest: {\n        description: \"A hash of the tutorial trunk id.\",\n        dataType: \"string\",\n      },\n      lastSlideIndex: {\n        description: \"The 0-based index of the last tutorial slide the user had open.\",\n        dataType: \"number\",\n      },\n      numSlides: {\n        description: \"The total number of slides in the tutorial.\",\n        dataType: \"number\",\n      },\n      percentComplete: {\n        description: \"Percentage of tutorial completion.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"tutorialProgressChanged\": {\n    category: \"Tutorial\",\n    description: \"Triggered on changes to tutorial progress.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      tutorialForkIdDigest: {\n        description: \"A hash of the tutorial fork id.\",\n        dataType: \"string\",\n      },\n      tutorialTrunkIdDigest: {\n        description: \"A hash of the tutorial trunk id.\",\n        dataType: \"string\",\n      },\n      lastSlideIndex: {\n        description: \"The 0-based index of the last tutorial slide the user had open.\",\n        dataType: \"number\",\n      },\n      numSlides: {\n        description: \"The total number of slides in the tutorial.\",\n        dataType: \"number\",\n      },\n      percentComplete: {\n        description: \"Percentage of tutorial completion.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"tutorialRestarted\": {\n    category: \"Tutorial\",\n    description: \"Triggered when a tutorial is restarted.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      tutorialForkIdDigest: {\n        description: \"A hash of the tutorial fork id.\",\n        dataType: \"string\",\n      },\n      tutorialTrunkIdDigest: {\n        description: \"A hash of the tutorial trunk id.\",\n        dataType: \"string\",\n      },\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      siteId: {\n        description: \"The site id.\",\n        dataType: \"number\",\n      },\n      siteType: {\n        description: \"The site type.\",\n        dataType: \"string\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      access: {\n        description: \"The document access level of the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"watchedVideoTour\": {\n    category: \"Welcome\",\n    description: \"Triggered when the video tour is closed.\",\n    minimumTelemetryLevel: Level.limited,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      watchTimeSeconds: {\n        description: \"The number of seconds elapsed in the video player.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n        minimumTelemetryLevel: Level.full,\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n        minimumTelemetryLevel: Level.full,\n      },\n    },\n  },\n  \"answeredUseCaseQuestion\": {\n    category: \"Welcome\",\n    description: \"Triggered for each selected use case in the welcome questionnaire.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      useCase: {\n        description: 'The selected use case. If \"Other\", the response is also included.',\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"clickedScheduleCoachingCall\": {\n    category: \"Welcome\",\n    description: \"Triggered when the link to schedule a coaching call is clicked.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"deletedAccount\": {\n    category: \"SubscriptionPlan\",\n    description: \"Triggered when an account is deleted.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n  },\n  \"createdSite\": {\n    category: \"TeamSite\",\n    description: \"Triggered when a site is created.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      siteId: {\n        description: \"The id of the site.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"deletedSite\": {\n    category: \"TeamSite\",\n    description: \"Triggered when a site is deleted.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      siteId: {\n        description: \"The id of the site.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"invitedMember\": {\n    category: \"TeamSite\",\n    description: \"Triggered when users are added to a team site.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      count: {\n        description: \"The number of users added.\",\n        dataType: \"number\",\n      },\n      siteId: {\n        description: \"The id of the site.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"uninvitedMember\": {\n    category: \"TeamSite\",\n    description: \"Triggered when users are removed from a team site.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      count: {\n        description: \"The number of users removed.\",\n        dataType: \"number\",\n      },\n      siteId: {\n        description: \"The id of the site.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"invitedDocUser\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when users are added to a document.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      access: {\n        description: \"The access level granted to the added users.\",\n        dataType: \"string\",\n      },\n      count: {\n        description: \"The number of users added.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"madeDocPublic\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when public access to a document is enabled.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      access: {\n        description: \"The access level granted to public users.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"madeDocPrivate\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when public access to a document is disabled.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"openedTemplate\": {\n    category: \"TemplateUsage\",\n    description: \"Triggered when a template is opened.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      templateId: {\n        description: \"The document id of the template.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"openedTemplateTour\": {\n    category: \"TemplateUsage\",\n    description: \"Triggered when a document tour for a template is opened.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      templateId: {\n        description: \"The document id of the template.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"copiedTemplate\": {\n    category: \"TemplateUsage\",\n    description: \"Triggered when a copy of a template is saved.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      templateId: {\n        description: \"The document id of the template.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"subscribedToPlan\": {\n    category: \"SubscriptionPlan\",\n    description: \"Triggered on subscription to a plan.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      planName: {\n        description: \"The name of the plan.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"cancelledPlan\": {\n    category: \"SubscriptionPlan\",\n    description: \"Triggered on cancellation of a plan.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      planName: {\n        description: \"The name of the plan.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"createdWorkspace\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when a workspace is created.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      workspaceId: {\n        description: \"The id of the workspace.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"deletedWorkspace\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when a workspace is deleted.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      workspaceId: {\n        description: \"The id of the workspace.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"visitedPage\": {\n    category: \"ProductVisits\",\n    description: \"Triggered when a page is loaded.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id. Only included on visits to doc pages.\",\n        dataType: \"string\",\n      },\n      url: {\n        description: \"The URL of the visited page. Link keys, doc ids, and other identifiers \" +\n          \"are excluded from the URL.\",\n        dataType: \"string\",\n      },\n      path: {\n        description: 'The path of the visited page (e.g. \"app.html\").',\n        dataType: \"string\",\n      },\n      userAgent: {\n        description: \"The User-Agent HTTP request header.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"openedDoc\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when a document is opened.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"createdDoc-Empty\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when a new empty document is created.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"createdDoc-FileImport\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when a document is created via file import.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"createdDoc-CopyTemplate\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when a document is created by saving a copy of a template.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"createdDoc-CopyDoc\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when a document is created by saving a copy of a document.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"viewedWelcomeTour\": {\n    category: \"Tutorial\",\n    description: \"Triggered when the Grist welcome tour is closed.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      percentComplete: {\n        description: \"Percentage of tour completion.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"viewedTip\": {\n    category: \"Tutorial\",\n    description: \"Triggered when a tip is shown.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      tipName: {\n        description: \"The name of the tip.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"deletedDoc\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when a document is deleted.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"addedPage\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when a page is added.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"deletedPage\": {\n    category: \"DocumentUsage\",\n    description: \"Triggered when a page is deleted.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"addedWidget\": {\n    category: \"WidgetUsage\",\n    description: \"Triggered when a widget is added.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      widgetType: {\n        description: 'The widget type (e.g. \"Form\").',\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"deletedWidget\": {\n    category: \"WidgetUsage\",\n    description: \"Triggered when a widget is deleted.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      widgetType: {\n        description: 'The widget type (e.g. \"Form\").',\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"duplicatedWidget\": {\n    category: \"WidgetUsage\",\n    description: \"Triggered when a widget is duplicated.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      destPage: {\n        description: 'The type of page the widget is being duplicated to. One of \"SAME\", \"NEW\", \"OTHER\"',\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"linkedWidget\": {\n    category: \"WidgetUsage\",\n    description: \"Triggered when a widget is linked.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      widgetType: {\n        description: 'The widget type (e.g. \"Form\").',\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"unlinkedWidget\": {\n    category: \"WidgetUsage\",\n    description: \"Triggered when a widget is unlinked.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      widgetType: {\n        description: 'The widget type (e.g. \"Form\").',\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"publishedForm\": {\n    category: \"WidgetUsage\",\n    description: \"Triggered when a form is published.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"unpublishedForm\": {\n    category: \"WidgetUsage\",\n    description: \"Triggered when a form is unpublished.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"visitedForm\": {\n    category: \"WidgetUsage\",\n    description: \"Triggered when a published form is visited.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"submittedForm\": {\n    category: \"WidgetUsage\",\n    description: \"Triggered when a published form is submitted.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      siteId: {\n        description: \"The site id.\",\n        dataType: \"number\",\n      },\n      siteType: {\n        description: \"The site type.\",\n        dataType: \"string\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      access: {\n        description: \"The document access level of the user that triggered this event.\",\n        dataType: \"string\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n    },\n  },\n  \"changedAccessRules\": {\n    category: \"AccessRules\",\n    description: \"Triggered when a change to access rules is saved.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      docIdDigest: {\n        description: \"A hash of the doc id.\",\n        dataType: \"string\",\n      },\n      ruleCount: {\n        description: \"The number of access rules in the document.\",\n        dataType: \"number\",\n      },\n      userId: {\n        description: \"The id of the user that triggered this event.\",\n        dataType: \"number\",\n      },\n      altSessionId: {\n        description: \"A random, session-based identifier for the user that triggered this event.\",\n        dataType: \"string\",\n      },\n    },\n  },\n  \"checkedUpdateAPI\": {\n    category: \"SelfHosted\",\n    description: \"Triggered when the app checks for updates.\",\n    minimumTelemetryLevel: Level.full,\n    retentionPeriod: \"indefinitely\",\n    metadataContracts: {\n      deploymentId: {\n        description: \"The installation id of the client.\",\n        dataType: \"string\",\n      },\n      deploymentType: {\n        description: \"The deployment type of the client.\",\n        dataType: \"string\",\n      },\n      currentVersion: {\n        description: \"The current version of the client.\",\n        dataType: \"string\",\n      },\n    },\n  },\n};\n\ntype TelemetryContracts = Record<TelemetryEvent, TelemetryEventContract>;\n\nexport const TelemetryEvents = StringUnion(\n  \"apiUsage\",\n  \"assistantOpen\",\n  \"assistantSend\",\n  \"assistantReceive\",\n  \"assistantSave\",\n  \"assistantCancel\",\n  \"assistantApplySuggestion\",\n  \"assistantClearConversation\",\n  \"assistantClose\",\n  \"assistantStartDocument\",\n  \"beaconOpen\",\n  \"beaconArticleViewed\",\n  \"beaconEmailSent\",\n  \"beaconSearch\",\n  \"ratedHelpCenterArticle\",\n  \"documentCreated\",\n  \"documentForked\",\n  \"documentOpened\",\n  \"documentUsage\",\n  \"processMonitor\",\n  \"sendingWebhooks\",\n  \"signupFirstVisit\",\n  \"signupVerified\",\n  \"siteMembership\",\n  \"siteUsage\",\n  \"tutorialOpened\",\n  \"tutorialProgressChanged\",\n  \"tutorialRestarted\",\n  \"watchedVideoTour\",\n  \"answeredUseCaseQuestion\",\n  \"clickedScheduleCoachingCall\",\n  \"deletedAccount\",\n  \"createdSite\",\n  \"deletedSite\",\n  \"invitedMember\",\n  \"uninvitedMember\",\n  \"invitedDocUser\",\n  \"madeDocPublic\",\n  \"madeDocPrivate\",\n  \"openedTemplate\",\n  \"openedTemplateTour\",\n  \"copiedTemplate\",\n  \"subscribedToPlan\",\n  \"cancelledPlan\",\n  \"createdWorkspace\",\n  \"deletedWorkspace\",\n  \"visitedPage\",\n  \"openedDoc\",\n  \"createdDoc-Empty\",\n  \"createdDoc-FileImport\",\n  \"createdDoc-CopyTemplate\",\n  \"createdDoc-CopyDoc\",\n  \"viewedWelcomeTour\",\n  \"viewedTip\",\n  \"deletedDoc\",\n  \"addedPage\",\n  \"deletedPage\",\n  \"addedWidget\",\n  \"deletedWidget\",\n  \"duplicatedWidget\",\n  \"linkedWidget\",\n  \"unlinkedWidget\",\n  \"publishedForm\",\n  \"unpublishedForm\",\n  \"visitedForm\",\n  \"submittedForm\",\n  \"changedAccessRules\",\n  \"checkedUpdateAPI\",\n);\nexport type TelemetryEvent = typeof TelemetryEvents.type;\n\ntype TelemetryEventCategory =\n  | \"AIAssistant\" |\n  \"HelpCenter\" |\n  \"TemplateUsage\" |\n  \"Tutorial\" |\n  \"Welcome\" |\n  \"SubscriptionPlan\" |\n  \"DocumentUsage\" |\n  \"TeamSite\" |\n  \"ProductVisits\" |\n  \"AccessRules\" |\n  \"WidgetUsage\" |\n  \"SelfHosted\";\n\ninterface TelemetryEventContract {\n  description: string;\n  minimumTelemetryLevel: Level;\n  retentionPeriod: TelemetryRetentionPeriod;\n  category?: TelemetryEventCategory;\n  metadataContracts?: Record<string, MetadataContract>;\n}\n\nexport type TelemetryRetentionPeriod = \"short\" | \"indefinitely\";\n\ninterface MetadataContract {\n  description: string;\n  dataType: \"boolean\" | \"number\" | \"string\" | \"string[]\" | \"date\" | \"object\";\n  minimumTelemetryLevel?: Level;\n}\n\nexport type TelemetryMetadataByLevel = Partial<Record<EnabledTelemetryLevel, TelemetryMetadata>>;\n\nexport type EnabledTelemetryLevel = Exclude<TelemetryLevel, \"off\">;\n\nexport const TelemetryLevels = StringUnion(\"off\", \"limited\", \"full\");\nexport type TelemetryLevel = typeof TelemetryLevels.type;\n\nexport type TelemetryMetadata = Record<string, any>;\n\n/**\n * The name of a cookie that's set whenever a template is opened.\n *\n * The cookie remembers the last template that was opened, which is then read during\n * sign-up to track which templates were viewed before sign-up.\n */\nexport const TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME = \"gr_template_signup_trk\";\n\n/**\n * Returns a function that accepts a telemetry event and metadata, and performs various\n * checks on it based on a set of contracts and the `telemetryLevel`.\n *\n * The function throws if any checks fail.\n */\nexport function buildTelemetryEventChecker(telemetryLevel: TelemetryLevel) {\n  const currentTelemetryLevel = Level[telemetryLevel];\n\n  return (event: TelemetryEvent, metadata?: TelemetryMetadata) => {\n    const eventContract = TelemetryContracts[event];\n    if (!eventContract) {\n      throw new Error(`Unknown telemetry event: ${event}`);\n    }\n\n    const eventMinimumTelemetryLevel = eventContract.minimumTelemetryLevel;\n    if (currentTelemetryLevel < eventMinimumTelemetryLevel) {\n      throw new Error(\n        `Telemetry event ${event} requires a minimum telemetry level of ${eventMinimumTelemetryLevel} ` +\n        `but the current level is ${currentTelemetryLevel}`,\n      );\n    }\n\n    for (const [key, value] of Object.entries(metadata ?? {})) {\n      const metadataContract = eventContract.metadataContracts?.[key];\n      if (!metadataContract) {\n        throw new Error(`Unknown metadata for telemetry event ${event}: ${key}`);\n      }\n\n      const metadataMinimumTelemetryLevel = metadataContract.minimumTelemetryLevel;\n      if (metadataMinimumTelemetryLevel && currentTelemetryLevel < metadataMinimumTelemetryLevel) {\n        throw new Error(\n          `Telemetry metadata ${key} of event ${event} requires a minimum telemetry level of ` +\n          `${metadataMinimumTelemetryLevel} but the current level is ${currentTelemetryLevel}`,\n        );\n      }\n\n      const { dataType } = metadataContract;\n      if (dataType.endsWith(\"[]\")) {\n        if (!Array.isArray(value)) {\n          throw new Error(\n            `Telemetry metadata ${key} of event ${event} expected a value of type array ` +\n            `but received a value of type ${typeof value}`,\n          );\n        }\n\n        const elementDataType = dataType.slice(0, -2);\n        if (value.some(element => typeof element !== elementDataType)) {\n          throw new Error(\n            `Telemetry metadata ${key} of event ${event} expected a value of type ${elementDataType}[] ` +\n            `but received a value of type ${typeof value}[]`,\n          );\n        }\n      } else if (dataType === \"date\") {\n        if (!(value instanceof Date) && typeof value !== \"string\") {\n          throw new Error(\n            `Telemetry metadata ${key} of event ${event} expected a value of type Date or string ` +\n            `but received a value of type ${typeof value}`,\n          );\n        }\n        if (typeof value === \"string\" && !hasTimezone(value)) {\n          throw new Error(\n            `Telemetry metadata ${key} of event ${event} has an ambiguous date string`,\n          );\n        }\n      } else if (dataType !== typeof value) {\n        throw new Error(\n          `Telemetry metadata ${key} of event ${event} expected a value of type ${dataType} ` +\n          `but received a value of type ${typeof value}`,\n        );\n      }\n    }\n  };\n}\n\n// Check that datetime looks like it has a timezone in it. If not,\n// that could be a problem for whatever ingests the data.\nfunction hasTimezone(isoDateString: string) {\n  // Use a regular expression to check for a timezone offset or 'Z'\n  return /([+-]\\d{2}:\\d{2}|Z)$/.test(isoDateString);\n}\n\nexport type TelemetryEventChecker = (event: TelemetryEvent, metadata?: TelemetryMetadata) => void;\n"
  },
  {
    "path": "app/common/TestState.ts",
    "content": "export interface TestState {\n  clipboard?: string;\n  anchorApplied?: boolean;\n  fakeSlowUploads?: boolean;\n}\n"
  },
  {
    "path": "app/common/ThemePrefs.ts",
    "content": "import { CssCustomProp } from \"app/common/CssCustomProp\";\n\nexport interface ThemePrefs {\n  appearance: ThemeAppearance;\n  syncWithOS: boolean;\n  colors: {\n    light: ThemeName;\n    dark: ThemeName;\n  }\n}\n\nexport const themeAppearances = [\"light\", \"dark\"] as const;\nexport type ThemeAppearance = typeof themeAppearances[number];\n\nexport type ThemeNameOrTokens = ThemeName | ThemeTokens;\n\nexport const themeNames = [\"GristLight\", \"GristDark\", \"HighContrastLight\"] as const;\nexport type ThemeName = typeof themeNames[number];\n\nexport const themeNameAppearances = {\n  GristLight: \"light\",\n  GristDark: \"dark\",\n  HighContrastLight: \"light\",\n} as const;\n\nexport function getDefaultThemePrefs(): ThemePrefs {\n  return {\n    appearance: \"light\",\n    syncWithOS: true,\n    colors: {\n      // Note: the colors object is not used for its original purpose.\n      // It's currently our way to store the theme name in user prefs (without having to change the user prefs schema).\n      // This is why we just repeat the name in both `light` and `dark` properties.\n      light: \"GristLight\",\n      dark: \"GristLight\",\n    },\n  };\n}\n\nexport interface Theme {\n  appearance: ThemeAppearance;\n  name: ThemeName;\n  colors: ThemeTokens;\n}\n\nexport interface ThemeWithCssVars {\n  appearance: ThemeAppearance;\n  name: ThemeName;\n  colors: {\n    [key: string]: string;\n  };\n}\n\ntype Token = string | CssCustomProp;\n\n/*\n * List of all possible theme tokens (except component specific ones, see below)\n *\n * This is used to generate the `tokens` object, that initializes CSS variables for every token.\n * Actual values are defined in a theme.\n *\n * Actual CSS variables appended to the DOM will have a 'grist-theme' prefix.\n * Example: the bgSecondary variable is appended as `--grist-theme-bg-secondary` css variable.\n */\nexport const tokensCssMapping = {\n  body: \"body\",\n  emphasis: \"emphasis\",\n  veryLight: \"very-light\",\n\n  bg: \"bg-default\", // names don't match here to prevent conflicting with internal --grist-theme-bg var\n  bgSecondary: \"bg-secondary\",\n  bgTertiary: \"bg-tertiary\",\n  bgEmphasis: \"bg-emphasis\",\n\n  decoration: \"decoration\",\n  decorationSecondary: \"decoration-secondary\",\n  decorationTertiary: \"decoration-tertiary\",\n\n  primary: \"primary\",\n  primaryMuted: \"primary-muted\",\n  primaryDim: \"primary-dim\",\n  primaryEmphasis: \"primary-emphasis\",\n  primaryTranslucent: \"primary-translucent\",\n\n  secondary: \"secondary\",\n  secondaryMuted: \"secondary-muted\",\n\n  white: \"white\",\n  black: \"black\",\n\n  error: \"error\",\n  errorLight: \"error-light\",\n\n  warning: \"warning\",\n  warningLight: \"warning-light\",\n\n  info: \"info\",\n  infoLight: \"info-light\",\n\n  fontFamily: \"font-family\",\n  fontFamilyData: \"font-family-data\",\n\n  xxsmallFontSize: \"xx-small-font-size\",\n  xsmallFontSize: \"x-small-font-size\",\n  smallFontSize: \"small-font-size\",\n  mediumFontSize: \"medium-font-size\",\n  introFontSize: \"intro-font-size\",\n  largeFontSize: \"large-font-size\",\n  xlargeFontSize: \"x-large-font-size\",\n  xxlargeFontSize: \"xx-large-font-size\",\n  xxxlargeFontSize: \"xxx-large-font-size\",\n\n  bigControlFontSize: \"big-control-font-size\",\n  headerControlFontSize: \"header-control-font-size\",\n\n  bigControlTextWeight: \"big-control-text-weight\",\n  headerControlTextWeight: \"header-control-text-weight\",\n\n  controlBorderRadius: \"control-border-radius\",\n\n  // some css vars below are prefixed with 'token-' to avoid conflicts with component vars\n  cursor: \"token-cursor\",\n  cursorInactive: \"token-cursor-inactive\",\n\n  selection: \"token-selection\",\n  selectionOpaque: \"token-selection-opaque\",\n  selectionDarkerOpaque: \"token-selection-darker-opaque\",\n  selectionDarker: \"token-selection-darker\",\n  selectionDarkest: \"token-selection-darkest\",\n\n  hover: \"token-hover\",\n  backdrop: \"backdrop\",\n\n  logoBg: \"logo-bg\",\n  logoSize: \"logo-size\",\n} as const;\n\n/*\n * List of all components theme tokens\n *\n * This is used to generate the `components` object, that initializes CSS variables for every token.\n * Actual values are defined in a theme.\n *\n * Actual CSS variables appended to the DOM will have a 'grist-theme' prefix.\n * Example: the lightText variable is appended as `--grist-theme-light-text` css variable.\n */\nexport const componentsCssMapping = {\n  text: \"text\",\n  lightText: \"text-light\",\n  mediumText: \"text-medium\",\n  darkText: \"text-dark\",\n  errorText: \"text-error\",\n  errorTextHover: \"text-error-hover\",\n  dangerText: \"text-danger\",\n  disabledText: \"text-disabled\",\n  pageBg: \"page-bg\",\n  pageBackdrop: \"page-backdrop\",\n  mainPanelBg: \"page-panels-main-panel-bg\",\n  leftPanelBg: \"page-panels-left-panel-bg\",\n  rightPanelBg: \"page-panels-right-panel-bg\",\n  topHeaderBg: \"page-panels-top-header-bg\",\n  bottomFooterBg: \"page-panels-bottom-footer-bg\",\n  pagePanelsBorder: \"page-panels-border\",\n  pagePanelsBorderResizing: \"page-panels-border-resizing\",\n  sidePanelOpenerFg: \"page-panels-side-panel-opener-fg\",\n  sidePanelOpenerActiveFg: \"page-panels-side-panel-opener-active-fg\",\n  sidePanelOpenerActiveBg: \"page-panels-side-panel-opener-active-bg\",\n  addNewCircleFg: \"add-new-circle-fg\",\n  addNewCircleBg: \"add-new-circle-bg\",\n  addNewCircleHoverBg: \"add-new-circle-hover-bg\",\n  addNewCircleSmallFg: \"add-new-circle-small-fg\",\n  addNewCircleSmallBg: \"add-new-circle-small-bg\",\n  addNewCircleSmallHoverBg: \"add-new-circle-small-hover-bg\",\n  topBarButtonPrimaryFg: \"top-bar-button-primary-fg\",\n  topBarButtonSecondaryFg: \"top-bar-button-secondary-fg\",\n  topBarButtonDisabledFg: \"top-bar-button-disabled-fg\",\n  topBarButtonErrorFg: \"top-bar-button-error-fg\",\n  notificationsPanelHeaderBg: \"notifications-panel-header-bg\",\n  notificationsPanelBodyBg: \"notifications-panel-body-bg\",\n  notificationsPanelBorder: \"notifications-panel-border\",\n  toastText: \"toast-text\",\n  toastLightText: \"toast-text-light\",\n  toastBg: \"toast-bg\",\n  toastMemoText: \"toast-memo-text\",\n  toastMemoBg: \"toast-memo-bg\",\n  toastErrorIcon: \"toast-error-icon\",\n  toastErrorBg: \"toast-error-bg\",\n  toastSuccessIcon: \"toast-success-icon\",\n  toastSuccessBg: \"toast-success-bg\",\n  toastWarningIcon: \"toast-warning-icon\",\n  toastWarningBg: \"toast-warning-bg\",\n  toastInfoIcon: \"toast-info-icon\",\n  toastInfoBg: \"toast-info-bg\",\n  toastControlFg: \"toast-control-fg\",\n  toastInfoControlFg: \"toast-control-info-fg\",\n  tooltipFg: \"tooltip-fg\",\n  tooltipBg: \"tooltip-bg\",\n  tooltipIcon: \"tooltip-icon\",\n  tooltipCloseButtonFg: \"tooltip-close-button-fg\",\n  tooltipCloseButtonHoverFg: \"tooltip-close-button-hover-fg\",\n  tooltipCloseButtonHoverBg: \"tooltip-close-button-hover-bg\",\n  modalBg: \"modal-bg\",\n  modalBackdrop: \"modal-backdrop\",\n  modalBorder: \"modal-border\",\n  modalBorderDark: \"modal-border-dark\",\n  modalBorderHover: \"modal-border-hover\",\n  modalInnerShadow: \"modal-shadow-inner\",\n  modalOuterShadow: \"modal-shadow-outer\",\n  modalCloseButtonFg: \"modal-close-button-fg\",\n  modalBackdropCloseButtonFg: \"modal-backdrop-close-button-fg\",\n  modalBackdropCloseButtonHoverFg: \"modal-backdrop-close-button-hover-fg\",\n  popupBg: \"popup-bg\",\n  popupSecondaryBg: \"popup-secondary-bg\",\n  popupInnerShadow: \"popup-shadow-inner\",\n  popupOuterShadow: \"popup-shadow-outer\",\n  popupCloseButtonFg: \"popup-close-button-fg\",\n  promptFg: \"prompt-fg\",\n  progressBarFg: \"progress-bar-fg\",\n  progressBarErrorFg: \"progress-bar-error-fg\",\n  progressBarBg: \"progress-bar-bg\",\n  link: \"link\",\n  linkHover: \"link-hover\",\n  hover: \"hover\",\n  lightHover: \"hover-light\",\n  cellEditorFg: \"cell-editor-fg\",\n  cellEditorPlaceholderFg: \"cell-editor-placeholder-fg\",\n  cellEditorBg: \"cell-editor-bg\",\n  cursor: \"cursor\",\n  cursorInactive: \"cursor-inactive\",\n  cursorReadonly: \"cursor-readonly\",\n  tableHeaderFg: \"table-header-fg\",\n  tableHeaderSelectedFg: \"table-header-selected-fg\",\n  tableHeaderBg: \"table-header-bg\",\n  tableHeaderSelectedBg: \"table-header-selected-bg\",\n  tableHeaderBorder: \"table-header-border\",\n  tableBodyBg: \"table-body-bg\",\n  tableBodyBorder: \"table-body-border\",\n  tableAddNewBg: \"table-add-new-bg\",\n  tableScrollShadow: \"table-scroll-shadow\",\n  tableFrozenColumnsBorder: \"table-frozen-columns-border\",\n  tableDragDropIndicator: \"table-drag-drop-indicator\",\n  tableDragDropShadow: \"table-drag-drop-shadow\",\n  tableCellSummaryBg: \"table-cell-summary-bg\",\n  cardCompactWidgetBg: \"card-compact-widget-bg\",\n  cardCompactRecordBg: \"card-compact-record-bg\",\n  cardBlocksBg: \"card-blocks-bg\",\n  cardFormLabel: \"card-form-label\",\n  cardCompactLabel: \"card-compact-label\",\n  cardBlocksLabel: \"card-blocks-label\",\n  cardFormBorder: \"card-form-border\",\n  cardCompactBorder: \"card-compact-border\",\n  cardEditingLayoutBg: \"card-editing-layout-bg\",\n  cardEditingLayoutBorder: \"card-editing-layout-border\",\n  cardListFormBorder: \"card-list-form-border\",\n  cardListBlocksBorder: \"card-list-blocks-border\",\n  selection: \"selection\",\n  selectionDarker: \"selection-darker\",\n  selectionDarkest: \"selection-darkest\",\n  selectionOpaqueFg: \"selection-opaque-fg\",\n  selectionOpaqueBg: \"selection-opaque-bg\",\n  selectionOpaqueDarkBg: \"selection-opaque-dark-bg\",\n  selectionHeader: \"selection-header\",\n  widgetBg: \"widget-bg\",\n  widgetBorder: \"widget-border\",\n  widgetActiveBorder: \"widget-active-border\",\n  widgetActiveNonFocusedBorder: \"widget-active-non-focused-border\",\n  widgetInactiveStripesLight: \"widget-inactive-stripes-light\",\n  widgetInactiveStripesDark: \"widget-inactive-stripes-dark\",\n  pinnedDocFooterBg: \"pinned-doc-footer-bg\",\n  pinnedDocBorder: \"pinned-doc-border\",\n  pinnedDocBorderHover: \"pinned-doc-border-hover\",\n  pinnedDocEditorBg: \"pinned-doc-editor-bg\",\n  rawDataTableBorder: \"raw-data-table-border\",\n  rawDataTableBorderHover: \"raw-data-table-border-hover\",\n  controlFg: \"control-fg\",\n  controlPrimaryFg: \"control-primary-fg\",\n  controlPrimaryBg: \"control-primary-bg\",\n  controlSecondaryFg: \"control-secondary-fg\",\n  controlSecondaryDisabledFg: \"control-secondary-disabled-fg\",\n  controlHoverFg: \"control-hover-fg\",\n  controlPrimaryHoverBg: \"control-primary-hover-bg\",\n  controlSecondaryHoverFg: \"control-secondary-hover-fg\",\n  controlSecondaryHoverBg: \"control-secondary-hover-bg\",\n  controlDisabledFg: \"control-disabled-fg\",\n  controlDisabledBg: \"control-disabled-bg\",\n  controlBorder: \"control-border\",\n  checkboxBg: \"checkbox-bg\",\n  checkboxSelectedFg: \"checkbox-selected-bg\",\n  checkboxDisabledBg: \"checkbox-disabled-bg\",\n  checkboxBorder: \"checkbox-border\",\n  checkboxBorderHover: \"checkbox-border-hover\",\n  moveDocsSelectedFg: \"move-docs-selected-fg\",\n  moveDocsSelectedBg: \"move-docs-selected-bg\",\n  moveDocsDisabledFg: \"move-docs-disabled-bg\",\n  filterBarButtonSavedFg: \"filter-bar-button-saved-fg\",\n  filterBarButtonSavedBg: \"filter-bar-button-saved-bg\",\n  filterBarButtonSavedHoverBg: \"filter-bar-button-saved-hover-bg\",\n  iconDisabled: \"icon-disabled\",\n  iconError: \"icon-error\",\n  iconButtonFg: \"icon-button-fg\",\n  iconButtonPrimaryBg: \"icon-button-primary-bg\",\n  iconButtonPrimaryHoverBg: \"icon-button-primary-hover-bg\",\n  iconButtonSecondaryBg: \"icon-button-secondary-bg\",\n  iconButtonSecondaryHoverBg: \"icon-button-secondary-hover-bg\",\n  pageHoverBg: \"left-panel-page-hover-bg\",\n  activePageFg: \"left-panel-active-page-fg\",\n  activePageBg: \"left-panel-active-page-bg\",\n  disabledPageFg: \"left-panel-disabled-page-fg\",\n  pageOptionsFg: \"left-panel-page-options-bg\",\n  pageOptionsHoverFg: \"left-panel-page-options-hover-fg\",\n  pageOptionsHoverBg: \"left-panel-page-options-hover-bg\",\n  pageOptionsSelectedHoverBg: \"left-panel-page-options-selected-hover-bg\",\n  pageInitialsFg: \"left-panel-page-initials-fg\",\n  pageInitialsBg: \"left-panel-page-initials-bg\",\n  pageInitialsEmojiBg: \"left-panel-page-emoji-fg\",\n  pageInitialsEmojiOutline: \"left-panel-page-emoji-outline\",\n  rightPanelTabFg: \"right-panel-tab-fg\",\n  rightPanelTabBg: \"right-panel-tab-bg\",\n  rightPanelTabIcon: \"right-panel-tab-icon\",\n  rightPanelTabIconHover: \"right-panel-tab-icon-hover\",\n  rightPanelTabBorder: \"right-panel-tab-border\",\n  rightPanelTabHoverBg: \"right-panel-tab-hover-bg\",\n  rightPanelTabHoverFg: \"right-panel-tab-hover-fg\",\n  rightPanelTabSelectedFg: \"right-panel-tab-selected-fg\",\n  rightPanelTabSelectedBg: \"right-panel-tab-selected-bg\",\n  rightPanelTabSelectedIcon: \"right-panel-tab-selected-icon\",\n  rightPanelTabButtonHoverBg: \"right-panel-tab-button-hover-bg\",\n  rightPanelSubtabFg: \"right-panel-subtab-fg\",\n  rightPanelSubtabSelectedFg: \"right-panel-subtab-selected-fg\",\n  rightPanelSubtabSelectedUnderline: \"right-panel-subtab-selected-underline\",\n  rightPanelSubtabHoverFg: \"right-panel-subtab-hover-fg\",\n  rightPanelDisabledOverlay: \"right-panel-disabled-overlay\",\n  rightPanelFieldSettingsBg: \"right-panel-field-settings-bg\",\n  rightPanelFieldSettingsButtonBg: \"right-panel-field-settings-button-bg\",\n  rightPanelCustomWidgetButtonFg: \"right-panel-custom-widget-button-fg\",\n  rightPanelCustomWidgetButtonBg: \"right-panel-custom-widget-button-bg\",\n  documentHistorySnapshotFg: \"document-history-snapshot-fg\",\n  documentHistorySnapshotSelectedFg: \"document-history-snapshot-selected-fg\",\n  documentHistorySnapshotBg: \"document-history-snapshot-bg\",\n  documentHistorySnapshotSelectedBg: \"document-history-snapshot-selected-bg\",\n  documentHistorySnapshotBorder: \"document-history-snapshot-border\",\n  documentHistoryActivityText: \"document-history-activity-text\",\n  documentHistoryActivityLightText: \"document-history-activity-text-light\",\n  documentHistoryTableHeaderFg: \"document-history-table-header-fg\",\n  documentHistoryTableBorder: \"document-history-table-border\",\n  documentHistoryTableBorderLight: \"document-history-table-border-light\",\n  accentIcon: \"accent-icon\",\n  accentBorder: \"accent-border\",\n  accentText: \"accent-text\",\n  inputFg: \"input-fg\",\n  inputBg: \"input-bg\",\n  inputDisabledFg: \"input-disabled-fg\",\n  inputDisabledBg: \"input-disabled-bg\",\n  inputPlaceholderFg: \"input-placeholder-fg\",\n  inputBorder: \"input-border\",\n  inputValid: \"input-valid\",\n  inputInvalid: \"input-invalid\",\n  inputFocus: \"input-focus\",\n  inputReadonlyBg: \"input-readonly-bg\",\n  inputReadonlyBorder: \"input-readonly-border\",\n  choiceTokenFg: \"choice-token-fg\",\n  choiceTokenBlankFg: \"choice-token-blank-fg\",\n  choiceTokenBg: \"choice-token-bg\",\n  choiceTokenSelectedBg: \"choice-token-selected-bg\",\n  choiceTokenSelectedBorder: \"choice-token-selected-border\",\n  choiceTokenInvalidFg: \"choice-token-invalid-fg\",\n  choiceTokenInvalidBg: \"choice-token-invalid-bg\",\n  choiceTokenInvalidBorder: \"choice-token-invalid-border\",\n  choiceEntryBg: \"choice-entry-bg\",\n  choiceEntryBorder: \"choice-entry-border\",\n  choiceEntryBorderHover: \"choice-entry-border-hover\",\n  selectButtonFg: \"select-button-fg\",\n  selectButtonPlaceholderFg: \"select-button-placeholder-fg\",\n  selectButtonBg: \"select-button-bg\",\n  selectButtonBorder: \"select-button-border\",\n  selectButtonBorderInvalid: \"select-button-border-invalid\",\n  menuText: \"menu-text\",\n  menuLightText: \"menu-light-text\",\n  menuBg: \"menu-bg\",\n  menuSubheaderFg: \"menu-subheader-fg\",\n  menuBorder: \"menu-border\",\n  menuShadow: \"menu-shadow\",\n  menuItemFg: \"menu-item-fg\",\n  menuItemSelectedFg: \"menu-item-selected-fg\",\n  menuItemSelectedBg: \"menu-item-selected-bg\",\n  menuItemDisabledFg: \"menu-item-disabled-fg\",\n  menuItemIconFg: \"menu-item-icon-fg\",\n  menuItemIconSelectedFg: \"menu-item-icon-selected-fg\",\n  autocompleteMatchText: \"autocomplete-match-text\",\n  autocompleteSelectedMatchText: \"autocomplete-selected-match-text\",\n  autocompleteItemSelectedBg: \"autocomplete-item-selected-bg\",\n  autocompleteAddNewCircleFg: \"autocomplete-add-new-circle-fg\",\n  autocompleteAddNewCircleBg: \"autocomplete-add-new-circle-bg\",\n  autocompleteAddNewCircleSelectedBg: \"autocomplete-add-new-circle-selected-bg\",\n  searchBorder: \"search-border\",\n  searchPrevNextButtonFg: \"search-prev-next-button-fg\",\n  searchPrevNextButtonBg: \"search-prev-next-button-bg\",\n  loaderFg: \"loader-fg\",\n  loaderBg: \"loader-bg\",\n  siteSwitcherActiveFg: \"site-switcher-active-fg\",\n  siteSwitcherActiveBg: \"site-switcher-active-bg\",\n  docMenuDocOptionsFg: \"doc-menu-doc-options-fg\",\n  docMenuDocOptionsHoverFg: \"doc-menu-doc-options-hover-fg\",\n  docMenuDocOptionsHoverBg: \"doc-menu-doc-options-hover-bg\",\n  shortcutKeyFg: \"shortcut-key-fg\",\n  shortcutKeyPrimaryFg: \"shortcut-key-primary-fg\",\n  shortcutKeySecondaryFg: \"shortcut-key-secondary-fg\",\n  shortcutKeyBg: \"shortcut-key-bg\",\n  shortcutKeyBorder: \"shortcut-key-border\",\n  breadcrumbsTagFg: \"breadcrumbs-tag-fg\",\n  breadcrumbsTagBg: \"breadcrumbs-tag-bg\",\n  breadcrumbsTagAlertBg: \"breadcrumbs-tag-alert-fg\",\n  widgetPickerPrimaryBg: \"widget-picker-primary-bg\",\n  widgetPickerSecondaryBg: \"widget-picker-secondary-bg\",\n  widgetPickerItemFg: \"widget-picker-item-fg\",\n  widgetPickerItemSelectedBg: \"widget-picker-item-selected-bg\",\n  widgetPickerItemDisabledBg: \"widget-picker-item-disabled-bg\",\n  widgetPickerIcon: \"widget-picker-icon\",\n  widgetPickerPrimaryIcon: \"widget-picker-primary-icon\",\n  widgetPickerSummaryIcon: \"widget-picker-summary-icon\",\n  widgetPickerBorder: \"widget-picker-border\",\n  widgetPickerShadow: \"widget-picker-shadow\",\n  codeViewText: \"code-view-text\",\n  codeViewKeyword: \"code-view-keyword\",\n  codeViewComment: \"code-view-comment\",\n  codeViewMeta: \"code-view-meta\",\n  codeViewTitle: \"code-view-title\",\n  codeViewParams: \"code-view-params\",\n  codeViewString: \"code-view-string\",\n  codeViewNumber: \"code-view-number\",\n  codeViewBuiltin: \"code-view-builtin\",\n  codeViewLiteral: \"code-view-literal\",\n  importerTableInfoBorder: \"importer-table-info-border\",\n  importerPreviewBorder: \"importer-preview-border\",\n  importerSkippedTableOverlay: \"importer-skipped-table-overlay\",\n  importerMatchIcon: \"importer-match-icon\",\n  importerOutsideBg: \"importer-outside-bg\",\n  importerMainContentBg: \"importer-main-content-bg\",\n  importerActiveFileBg: \"importer-active-file-bg\",\n  importerActiveFileFg: \"importer-active-file-fg\",\n  importerInactiveFileBg: \"importer-inactive-file-bg\",\n  importerInactiveFileFg: \"importer-inactive-file-fg\",\n  menuToggleFg: \"menu-toggle-fg\",\n  menuToggleHoverFg: \"menu-toggle-hover-fg\",\n  menuToggleActiveFg: \"menu-toggle-active-fg\",\n  menuToggleBg: \"menu-toggle-bg\",\n  menuToggleBorder: \"menu-toggle-border\",\n  infoButtonFg: \"info-button-fg\",\n  infoButtonHoverFg: \"info-button-hover-fg\",\n  infoButtonActiveFg: \"info-button-active-fg\",\n  buttonGroupFg: \"button-group-fg\",\n  buttonGroupLightFg: \"button-group-light-fg\",\n  buttonGroupLightBg: \"button-group-light-bg\",\n  buttonGroupBg: \"button-group-bg\",\n  buttonGroupBgHover: \"button-group-bg-hover\",\n  buttonGroupIcon: \"button-group-icon\",\n  buttonGroupBorder: \"button-group-border\",\n  buttonGroupBorderHover: \"button-group-border-hover\",\n  buttonGroupSelectedFg: \"button-group-selected-fg\",\n  buttonGroupLightSelectedFg: \"button-group-light-selected-fg\",\n  buttonGroupLightSelectedBg: \"button-group-light-selected-bg\",\n  buttonGroupSelectedBg: \"button-group-selected-bg\",\n  buttonGroupSelectedBorder: \"button-group-selected-border\",\n  accessRulesTableHeaderFg: \"access-rules-table-header-fg\",\n  accessRulesTableHeaderBg: \"access-rules-table-header-bg\",\n  accessRulesTableBodyFg: \"access-rules-table-body-fg\",\n  accessRulesTableBodyLightFg: \"access-rules-table-body-light-fg\",\n  accessRulesTableBorder: \"access-rules-table-border\",\n  accessRulesColumnListBorder: \"access-rules-column-list-border\",\n  accessRulesColumnItemFg: \"access-rules-column-item-fg\",\n  accessRulesColumnItemBg: \"access-rules-column-item-bg\",\n  accessRulesColumnItemIconFg: \"access-rules-column-item-icon-fg\",\n  accessRulesColumnItemIconHoverFg: \"access-rules-column-item-icon-hover-fg\",\n  accessRulesColumnItemIconHoverBg: \"access-rules-column-item-icon-hover-bg\",\n  accessRulesFormulaEditorBg: \"access-rules-formula-editor-bg\",\n  accessRulesFormulaEditorBorderHover: \"access-rules-formula-editor-border-hover\",\n  accessRulesFormulaEditorBgDisabled: \"access-rules-formula-editor-bg-disabled\",\n  accessRulesFormulaEditorFocus: \"access-rules-formula-editor-focus\",\n  cellFg: \"cell-fg\",\n  cellBg: \"cell-bg\",\n  cellZebraBg: \"cell-zebra-bg\",\n  chartFg: \"chart-fg\",\n  chartBg: \"chart-bg\",\n  chartLegendBg: \"chart-legend-bg\",\n  chartXAxis: \"chart-x-axis\",\n  chartYAxis: \"chart-y-axis\",\n  commentsPopupHeaderBg: \"comments-popup-header-bg\",\n  commentsPopupBodyBg: \"comments-popup-body-bg\",\n  commentsPopupBorder: \"comments-popup-border\",\n  commentsUserNameFg: \"comments-user-name-fg\",\n  commentsPanelTopicBg: \"comments-panel-topic-bg\",\n  commentsPanelTopicBorder: \"comments-panel-topic-border\",\n  commentsPanelResolvedTopicBg: \"comments-panel-resolved-topic-bg\",\n  datePickerSelectedFg: \"date-picker-selected-fg\",\n  datePickerSelectedBg: \"date-picker-selected-bg\",\n  datePickerSelectedBgHover: \"date-picker-selected-bg-hover\",\n  datePickerTodayFg: \"date-picker-today-fg\",\n  datePickerTodayBg: \"date-picker-today-bg\",\n  datePickerTodayBgHover: \"date-picker-today-bg-hover\",\n  datePickerRangeStartEndBg: \"date-picker-range-start-end-bg\",\n  datePickerRangeStartEndBgHover: \"date-picker-range-start-end-bg-hover\",\n  datePickerRangeBg: \"date-picker-range-bg\",\n  datePickerRangeBgHover: \"date-picker-range-bg-hover\",\n  tutorialsPopupBorder: \"tutorials-popup-border\",\n  tutorialsPopupHeaderFg: \"tutorials-popup-header-fg\",\n  tutorialsPopupBoxBg: \"tutorials-popup-box-bg\",\n  tutorialsPopupCodeFg: \"tutorials-popup-code-fg\",\n  tutorialsPopupCodeBg: \"tutorials-popup-code-bg\",\n  tutorialsPopupCodeBorder: \"tutorials-popup-code-border\",\n  aceEditorBg: \"ace-editor-bg\",\n  aceAutocompletePrimaryFg: \"ace-autocomplete-primary-fg\",\n  aceAutocompleteSecondaryFg: \"ace-autocomplete-secondary-fg\",\n  aceAutocompleteHighlightedFg: \"ace-autocomplete-highlighted-fg\",\n  aceAutocompleteBg: \"ace-autocomplete-bg\",\n  aceAutocompleteBorder: \"ace-autocomplete-border\",\n  aceAutocompleteLink: \"ace-autocomplete-link\",\n  aceAutocompleteLinkHighlighted: \"ace-autocomplete-link-highlighted\",\n  aceAutocompleteActiveLineBg: \"ace-autocomplete-active-line-bg\",\n  aceAutocompleteLineBorderHover: \"ace-autocomplete-line-border-hover\",\n  aceAutocompleteLineBgHover: \"ace-autocomplete-line-bg-hover\",\n  colorSelectFg: \"color-select-fg\",\n  colorSelectBg: \"color-select-bg\",\n  colorSelectShadow: \"color-select-shadow\",\n  colorSelectFontOptionsBorder: \"color-select-font-options-border\",\n  colorSelectFontOptionFg: \"color-select-font-option-fg\",\n  colorSelectFontOptionBgHover: \"color-select-font-option-bg-hover\",\n  colorSelectFontOptionFgSelected: \"color-select-font-option-fg-selected\",\n  colorSelectFontOptionBgSelected: \"color-select-font-option-bg-selected\",\n  colorSelectColorSquareBorder: \"color-select-color-square-border\",\n  colorSelectColorSquareBorderEmpty: \"color-select-color-square-border-empty\",\n  colorSelectInputFg: \"color-select-input-fg\",\n  colorSelectInputBg: \"color-select-input-bg\",\n  colorSelectInputBorder: \"color-select-input-border\",\n  highlightedCodeBlockBg: \"highlighted-code-block-bg\",\n  highlightedCodeBlockBgDisabled: \"highlighted-code-block-bg-disabled\",\n  highlightedCodeFg: \"highlighted-code-fg\",\n  highlightedCodeBorder: \"highlighted-code-border\",\n  highlightedCodeBgDisabled: \"highlighted-code-bg-disabled\",\n  loginPageBg: \"login-page-bg\",\n  loginPageBackdrop: \"login-page-backdrop\",\n  loginPageLine: \"login-page-line\",\n  loginPageGoogleButtonFg: \"login-page-google-button-fg\",\n  loginPageGoogleButtonBg: \"login-page-google-button-bg\",\n  loginPageGoogleButtonBgHover: \"login-page-google-button-bg-hover\",\n  loginPageGoogleButtonBorder: \"login-page-google-button-border\",\n  formulaAssistantHeaderBg: \"formula-assistant-header-bg\",\n  formulaAssistantBorder: \"formula-assistant-border\",\n  formulaAssistantPreformattedTextBg: \"formula-assistant-preformatted-text-bg\",\n  attachmentsEditorButtonFg: \"attachments-editor-button-fg\",\n  attachmentsEditorButtonHoverFg: \"attachments-editor-button-hover-fg\",\n  attachmentsEditorButtonBg: \"attachments-editor-button-bg\",\n  attachmentsEditorButtonHoverBg: \"attachments-editor-button-hover-bg\",\n  attachmentsEditorButtonBorder: \"attachments-editor-button-border\",\n  attachmentsEditorButtonIcon: \"attachments-editor-button-icon\",\n  attachmentsEditorBorder: \"attachments-editor-border\",\n  attachmentsCellIconFg: \"attachments-cell-icon-fg\",\n  attachmentsCellIconBg: \"attachments-cell-icon-bg\",\n  attachmentsCellIconHoverBg: \"attachments-cell-icon-hover-bg\",\n  announcementPopupFg: \"announcement-popup-fg\",\n  announcementPopupBg: \"announcement-popup-bg\",\n  switchInactiveSlider: \"switch-inactive-slider\",\n  switchInactivePill: \"switch-inactive-pill\",\n  switchActiveSlider: \"switch-active-slider\",\n  switchActivePill: \"switch-active-pill\",\n  switchHoverShadow: \"switch-hover-shadow\",\n  scrollShadow: \"scroll-shadow\",\n  toggleCheckboxFg: \"toggle-checkbox-fg\",\n  numericSpinnerFg: \"numeric-spinner-fg\",\n  widgetGalleryBorder: \"widget-gallery-border\",\n  widgetGalleryBorderSelected: \"widget-gallery-border-selected\",\n  widgetGalleryShadow: \"widget-gallery-shadow\",\n  widgetGalleryBgHover: \"widget-gallery-bg-hover\",\n  widgetGallerySecondaryHeaderFg: \"widget-gallery-secondary-header-fg\",\n  widgetGallerySecondaryHeaderBg: \"widget-gallery-secondary-header-bg\",\n  widgetGallerySecondaryHeaderBgHover: \"widget-gallery-secondary-header-bg-hover\",\n  markdownCellLightBg: \"markdown-cell-light-bg\",\n  markdownCellLightBorder: \"markdown-cell-light-border\",\n  markdownCellMediumBorder: \"markdown-cell-medium-border\",\n  appHeaderBg: \"app-header-bg\",\n  appHeaderBorder: \"app-header-border\",\n  appHeaderBorderHover: \"app-header-border-hover\",\n  cardButtonBorder: \"card-button-border\",\n  cardButtonBorderSelected: \"card-button-border-selected\",\n  cardButtonShadow: \"card-button-shadow\",\n  formulaIcon: \"formula-icon\",\n  textButtonHoverBg: \"text-button-hover-bg\",\n  textButtonHoverBorder: \"text-button-hover-border\",\n  kbFocusHighlight: \"kb-focus-highlight\",\n} as const;\n\nexport const tokens = Object.fromEntries(\n  Object.entries(tokensCssMapping).map(([name, value]) => [\n    name,\n    new CssCustomProp(value, undefined, undefined, \"theme\"),\n  ]),\n) as { [K in keyof typeof tokensCssMapping]: CssCustomProp };\n\nexport const components = Object.fromEntries(\n  Object.entries(componentsCssMapping).map(([name, value]) => [\n    name,\n    new CssCustomProp(value, undefined, undefined, \"theme\"),\n  ]),\n) as { [K in keyof typeof componentsCssMapping]: CssCustomProp };\n\n/**\n * Mapping of deprecated variables to the new theme variables.\n *\n * This is an array because we want to keep the order of the variables\n * for declaration priority purposes.\n *\n * Used to fix old custom.css files that use deprecated variables.\n * Any cssVars#colors and cssVars#vars targeting theme tokens should match\n * (see test/client/ui2018/cssVars.ts).\n */\nexport const legacyVarsMapping: { old: string, new: string }[] = [\n  { old: \"--grist-color-light-grey\", new: tokens.bgSecondary.var() },\n  { old: \"--grist-color-medium-grey\", new: tokens.bgTertiary.var() },\n  { old: \"--grist-color-medium-grey-opaque\", new: tokens.decorationSecondary.var() },\n  { old: \"--grist-color-dark-grey\", new: tokens.decoration.var() },\n  { old: \"--grist-color-light\", new: tokens.white.var() },\n  { old: \"--grist-color-dark\", new: tokens.body.var() },\n  { old: \"--grist-color-dark-bg\", new: tokens.bgEmphasis.var() },\n  { old: \"--grist-color-slate\", new: tokens.secondary.var() },\n  { old: \"--grist-color-lighter-green\", new: tokens.primaryEmphasis.var() },\n  { old: \"--grist-color-light-green\", new: tokens.primary.var() },\n  { old: \"--grist-color-dark-green\", new: tokens.primaryMuted.var() },\n  { old: \"--grist-color-darker-green\", new: tokens.primaryDim.var() },\n  { old: \"--grist-color-lighter-blue\", new: tokens.infoLight.var() },\n  { old: \"--grist-color-light-blue\", new: tokens.info.var() },\n  { old: \"--grist-color-orange\", new: tokens.warningLight.var() },\n  { old: \"--grist-color-cursor\", new: tokens.cursor.var() },\n  { old: \"--grist-color-selection\", new: tokens.selection.var() },\n  { old: \"--grist-color-selection-opaque\", new: tokens.selectionOpaque.var() },\n  { old: \"--grist-color-selection-darker-opaque\", new: tokens.selectionDarkerOpaque.var() },\n  { old: \"--grist-color-inactive-cursor\", new: tokens.cursorInactive.var() },\n  { old: \"--grist-color-hover\", new: tokens.hover.var() },\n  { old: \"--grist-color-error\", new: tokens.error.var() },\n  { old: \"--grist-color-warning\", new: tokens.warningLight.var() },\n  { old: \"--grist-color-warning-bg\", new: tokens.warning.var() },\n  { old: \"--grist-color-backdrop\", new: tokens.backdrop.var() },\n  { old: \"--grist-font-family\", new: tokens.fontFamily.var() },\n  { old: \"--grist-font-family-data\", new: tokens.fontFamilyData.var() },\n  { old: \"--grist-xx-font-size\", new: tokens.xxsmallFontSize.var() },\n  { old: \"--grist-x-small-font-size\", new: tokens.xsmallFontSize.var() },\n  { old: \"--grist-small-font-size\", new: tokens.smallFontSize.var() },\n  { old: \"--grist-medium-font-size\", new: tokens.mediumFontSize.var() },\n  { old: \"--grist-intro-font-size\", new: tokens.introFontSize.var() },\n  { old: \"--grist-large-font-size\", new: tokens.largeFontSize.var() },\n  { old: \"--grist-x-large-font-size\", new: tokens.xlargeFontSize.var() },\n  { old: \"--grist-xx-large-font-size\", new: tokens.xxlargeFontSize.var() },\n  { old: \"--grist-xxx-large-font-size\", new: tokens.xxxlargeFontSize.var() },\n  { old: \"--grist-big-control-font-size\", new: tokens.bigControlFontSize.var() },\n  { old: \"--grist-header-control-font-size\", new: tokens.headerControlFontSize.var() },\n  { old: \"--grist-big-text-weight\", new: tokens.bigControlTextWeight.var() },\n  { old: \"--grist-header-text-weight\", new: tokens.headerControlTextWeight.var() },\n  { old: \"--grist-primary-bg\", new: tokens.white.var() },\n  { old: \"--grist-primary-fg-hover\", new: tokens.primaryMuted.var() },\n  { old: \"--grist-primary-fg\", new: tokens.primary.var() },\n  { old: \"--grist-control-border\", new: components.controlBorder.var() },\n  { old: \"--grist-border-radius\", new: tokens.controlBorderRadius.var() },\n  { old: \"--grist-logo-bg\", new: tokens.logoBg.var() },\n  { old: \"--grist-logo-size\", new: tokens.logoSize.var() },\n];\n\n/**\n * Helper that converts a theme file object to a \"theme css vars\" object\n *\n * Used to prepare data before appending css vars in the DOM in both the app and external code like grist-plugin-api.\n *\n * It transforms the \"colors\" object to change camelCase keys to css vars kebab-case keys,\n * puts the components tokens at the root lvl,\n * and transforms any CssCustomProp object to its actual value as a string.\n *\n * ⚠ Note the css var keys returned are not actual css vars (they miss the --grist-theme prefix),\n * because the code attaching the css vars apply the prefix themselves.\n *\n * We use this trick because theme-related code notably changed in beginning of 2025,\n * and the grist-plugin-api code is a bit troublesome to update with breaking changes.\n * This small parser helps in keeping the grist-plugin-api \"old code\" working with the current theme system.\n *\n * Example: {\n *   appearance: 'light',\n *   colors: {\n *     bgEmphasis: { name: 'bg-dark', value: '#000', fallback: undefined },\n *     components: { cardButtonBorder: '#fff' }\n *   }\n * }\n * becomes:\n * {\n *   appearance: 'light',\n *   colors: {\n *     'bg-emphasis': 'var(--theme-bg-dark)',\n *     'card-button-border': '#fff'\n *   }\n * }\n */\nexport const convertThemeKeysToCssVars = (theme: Theme): ThemeWithCssVars => {\n  const { components: componentsTokens, ...rest } = theme.colors;\n\n  const mainCssVars = Object.fromEntries(\n    Object.entries(rest).map(([key, value]) => {\n      return [\n        tokensCssMapping[key as keyof typeof tokensCssMapping],\n        value.toString(),\n      ];\n    }),\n  );\n\n  const componentsCssVars = Object.fromEntries(\n    Object.entries(componentsTokens).map(([key, value]) => {\n      return [\n        componentsCssMapping[key as keyof typeof componentsCssMapping],\n        value.toString(),\n      ];\n    }),\n  );\n\n  return {\n    ...theme,\n    colors: {\n      ...mainCssVars,\n      ...componentsCssVars,\n    },\n  };\n};\n\n/**\n * tokens that a given theme must always define\n */\nexport interface SpecificThemeTokens {\n  /**\n   * main body text\n   */\n  body: Token;\n\n  /**\n   * pronounced text\n   */\n  emphasis: Token;\n\n  /**\n   * text that is always light, whatever the current appearance (light or dark theme)\n   */\n  veryLight: Token;\n\n  /**\n   * default body bg color\n   */\n  bg: Token;\n\n  /**\n   * bg color mostly used on panels\n   */\n  bgSecondary: Token;\n\n  /**\n   * transparent bg, mostly used on hover effects\n   */\n  bgTertiary: Token;\n\n  /**\n   * pronounced bg color, mostly used on selected items\n   */\n  bgEmphasis: Token;\n\n  /**\n   * main decoration color, mostly used on borders\n   */\n  decoration: Token;\n\n  /**\n   * less pronounced decoration color\n   */\n  decorationSecondary: Token;\n\n  /**\n   * even less pronounced decoration color\n   */\n  decorationTertiary: Token;\n\n  /**\n   * main accent color used mostly on interactive elements\n   */\n  primary: Token;\n\n  /**\n   * alternative primary color, mostly used on hover effects\n   */\n  primaryMuted: Token;\n\n  /**\n   * dimmer primary color, rarely used\n   */\n  primaryDim: Token;\n\n  /**\n   * more pronounced primary color variant, rarely used\n   */\n  primaryEmphasis: Token;\n\n  /**\n   * primary color with around 50% opacity\n   */\n  primaryTranslucent: Token;\n\n  /**\n   * secondary color, used on elements like less visually pronounced\n   * text and non-primary or disabled controls\n   */\n  secondary: Token;\n\n  /**\n   * alternative secondary color, mostly used on hover effects\n   */\n  secondaryMuted: Token;\n\n  controlBorderRadius: Token;\n\n  /**\n   * cursor color in widgets\n   */\n  cursor: Token;\n  cursorInactive: Token;\n\n  /**\n   * transparent background of selected cells\n   */\n  selection: Token;\n  selectionOpaque: Token;\n  selectionDarkerOpaque: Token;\n  selectionDarker: Token;\n  selectionDarkest: Token;\n\n  /**\n   * non-transparent hover effect color, rarely used\n   */\n  hover: Token;\n\n  /**\n   * transparent modal backdrop bg color\n   */\n  backdrop: Token;\n\n  components: {\n    mediumText: Token;\n    errorText: Token;\n    errorTextHover: Token;\n    pageBackdrop: Token;\n    topBarButtonErrorFg: Token;\n    toastMemoBg: Token;\n    modalInnerShadow: Token;\n    modalOuterShadow: Token;\n    popupInnerShadow: Token;\n    popupOuterShadow: Token;\n    promptFg: Token;\n    progressBarErrorFg: Token;\n    lightHover: Token;\n    cellEditorFg: Token;\n    tableHeaderSelectedBg: Token;\n    tableHeaderBorder: Token;\n    tableAddNewBg: Token;\n    tableScrollShadow: Token;\n    tableFrozenColumnsBorder: Token;\n    tableDragDropIndicator: Token;\n    tableDragDropShadow: Token;\n    cardCompactWidgetBg: Token;\n    cardBlocksBg: Token;\n    cardFormBorder: Token;\n    cardEditingLayoutBg: Token;\n    selection: Token;\n    selectionDarker: Token;\n    selectionDarkest: Token;\n    selectionOpaqueBg: Token;\n    selectionOpaqueDarkBg: Token;\n    selectionHeader: Token;\n    widgetInactiveStripesDark: Token;\n    controlHoverFg: Token;\n    controlDisabledFg: Token;\n    controlDisabledBg: Token;\n    controlBorder: Token;\n    checkboxBorderHover: Token;\n    filterBarButtonSavedBg: Token;\n    iconError: Token;\n    iconButtonPrimaryHoverBg: Token;\n    pageHoverBg: Token;\n    disabledPageFg: Token;\n    pageInitialsBg: Token;\n    pageInitialsEmojiOutline: Token;\n    pageInitialsEmojiBg: Token;\n    rightPanelTabButtonHoverBg: Token;\n    rightPanelFieldSettingsBg: Token;\n    rightPanelFieldSettingsButtonBg: Token;\n    documentHistorySnapshotBorder: Token;\n    documentHistoryTableBorder: Token;\n    inputInvalid: Token;\n    inputReadonlyBorder: Token;\n    choiceTokenBg: Token;\n    choiceTokenSelectedBg: Token;\n    choiceTokenInvalidBg: Token;\n    choiceEntryBorderHover: Token;\n    selectButtonBorderInvalid: Token;\n    menuBorder: Token;\n    menuShadow: Token;\n    autocompleteItemSelectedBg: Token;\n    searchBorder: Token;\n    searchPrevNextButtonBg: Token;\n    siteSwitcherActiveBg: Token;\n    shortcutKeyPrimaryFg: Token;\n    breadcrumbsTagBg: Token;\n    widgetPickerItemFg: Token;\n    widgetPickerSummaryIcon: Token;\n    widgetPickerShadow: Token;\n    codeViewText: Token;\n    codeViewKeyword: Token;\n    codeViewComment: Token;\n    codeViewMeta: Token;\n    codeViewTitle: Token;\n    codeViewParams: Token;\n    codeViewString: Token;\n    codeViewNumber: Token;\n    codeViewBuiltin: Token;\n    codeViewLiteral: Token;\n    importerOutsideBg: Token;\n    importerMainContentBg: Token;\n    importerInactiveFileBg: Token;\n    menuToggleHoverFg: Token;\n    menuToggleActiveFg: Token;\n    buttonGroupBgHover: Token;\n    buttonGroupBorderHover: Token;\n    cellZebraBg: Token;\n    chartFg: Token;\n    chartLegendBg: Token;\n    chartXAxis: Token;\n    chartYAxis: Token;\n    commentsUserNameFg: Token;\n    commentsPanelTopicBorder: Token;\n    commentsPanelResolvedTopicBg: Token;\n    datePickerSelectedFg: Token;\n    datePickerSelectedBg: Token;\n    datePickerSelectedBgHover: Token;\n    datePickerRangeStartEndBg: Token;\n    datePickerRangeStartEndBgHover: Token;\n    datePickerRangeBg: Token;\n    datePickerRangeBgHover: Token;\n    tutorialsPopupBoxBg: Token;\n    tutorialsPopupCodeFg: Token;\n    tutorialsPopupCodeBg: Token;\n    tutorialsPopupCodeBorder: Token;\n    aceAutocompletePrimaryFg: Token;\n    aceAutocompleteSecondaryFg: Token;\n    aceAutocompleteBg: Token;\n    aceAutocompleteBorder: Token;\n    aceAutocompleteLinkHighlighted: Token;\n    aceAutocompleteActiveLineBg: Token;\n    aceAutocompleteLineBorderHover: Token;\n    aceAutocompleteLineBgHover: Token;\n    colorSelectFg: Token;\n    colorSelectShadow: Token;\n    colorSelectFontOptionsBorder: Token;\n    colorSelectFontOptionBgHover: Token;\n    colorSelectColorSquareBorder: Token;\n    highlightedCodeBlockBg: Token;\n    highlightedCodeBlockBgDisabled: Token;\n    highlightedCodeBgDisabled: Token;\n    loginPageBackdrop: Token;\n    loginPageLine: Token;\n    loginPageGoogleButtonFg: Token;\n    loginPageGoogleButtonBg: Token;\n    loginPageGoogleButtonBgHover: Token;\n    attachmentsEditorButtonFg: Token;\n    attachmentsEditorButtonBg: Token;\n    attachmentsEditorButtonHoverBg: Token;\n    attachmentsEditorBorder: Token;\n    attachmentsCellIconFg: Token;\n    attachmentsCellIconBg: Token;\n    attachmentsCellIconHoverBg: Token;\n    announcementPopupBg: Token;\n    scrollShadow: Token;\n    toggleCheckboxFg: Token;\n    numericSpinnerFg: Token;\n    widgetGalleryBorder: Token;\n    widgetGalleryShadow: Token;\n    widgetGallerySecondaryHeaderBg: Token;\n    widgetGallerySecondaryHeaderBgHover: Token;\n    markdownCellLightBg: Token;\n    markdownCellLightBorder: Token;\n    markdownCellMediumBorder: Token;\n    appHeaderBorder: Token;\n    appHeaderBorderHover: Token;\n    cardButtonBorder: Token;\n  };\n}\n\nexport interface BaseThemeTokens {\n  white: Token;\n  black: Token;\n\n  error: Token;\n  errorLight: Token;\n\n  warning: Token;\n  warningLight: Token;\n\n  info: Token;\n  infoLight: Token;\n\n  fontFamily: Token;\n  fontFamilyData: Token;\n\n  xxsmallFontSize: Token;\n  xsmallFontSize: Token;\n  smallFontSize: Token;\n  mediumFontSize: Token;\n  introFontSize: Token;\n  largeFontSize: Token;\n  xlargeFontSize: Token;\n  xxlargeFontSize: Token;\n  xxxlargeFontSize: Token;\n\n  bigControlFontSize: Token;\n  headerControlFontSize: Token;\n  bigControlTextWeight: Token;\n  headerControlTextWeight: Token;\n\n  logoBg: Token;\n  logoSize: Token;\n\n  components: {\n    text: Token;\n    lightText: Token;\n    darkText: Token;\n    disabledText: Token;\n    dangerText: Token;\n    pageBg: Token;\n    mainPanelBg: Token;\n    leftPanelBg: Token;\n    rightPanelBg: Token;\n    topHeaderBg: Token;\n    bottomFooterBg: Token;\n    pagePanelsBorder: Token;\n    pagePanelsBorderResizing: Token;\n    sidePanelOpenerFg: Token;\n    sidePanelOpenerActiveFg: Token;\n    sidePanelOpenerActiveBg: Token;\n    addNewCircleFg: Token;\n    addNewCircleBg: Token;\n    addNewCircleHoverBg: Token;\n    addNewCircleSmallFg: Token;\n    addNewCircleSmallBg: Token;\n    addNewCircleSmallHoverBg: Token;\n    topBarButtonPrimaryFg: Token;\n    topBarButtonSecondaryFg: Token;\n    topBarButtonDisabledFg: Token;\n    notificationsPanelHeaderBg: Token;\n    notificationsPanelBodyBg: Token;\n    notificationsPanelBorder: Token;\n    toastBg: Token;\n    toastLightText: Token;\n    toastText: Token;\n    toastMemoText: Token;\n    toastErrorIcon: Token;\n    toastErrorBg: Token;\n    toastSuccessIcon: Token;\n    toastSuccessBg: Token;\n    toastWarningIcon: Token;\n    toastWarningBg: Token;\n    toastInfoIcon: Token;\n    toastInfoBg: Token;\n    toastInfoControlFg: Token;\n    toastControlFg: Token;\n    tooltipBg: Token;\n    tooltipCloseButtonHoverFg: Token;\n    tooltipFg: Token;\n    tooltipIcon: Token;\n    tooltipCloseButtonFg: Token;\n    tooltipCloseButtonHoverBg: Token;\n    modalBackdrop: Token;\n    modalBg: Token;\n    modalBorder: Token;\n    modalBorderDark: Token;\n    modalBorderHover: Token;\n    modalCloseButtonFg: Token;\n    modalBackdropCloseButtonFg: Token;\n    modalBackdropCloseButtonHoverFg: Token;\n    popupBg: Token;\n    popupSecondaryBg: Token;\n    popupCloseButtonFg: Token;\n    progressBarFg: Token;\n    progressBarBg: Token;\n    hover: Token;\n    link: Token;\n    linkHover: Token;\n    cellEditorPlaceholderFg: Token;\n    cellEditorBg: Token;\n    cursor: Token;\n    cursorInactive: Token;\n    cursorReadonly: Token;\n    tableHeaderFg: Token;\n    tableHeaderSelectedFg: Token;\n    tableHeaderBg: Token;\n    tableBodyBg: Token;\n    tableBodyBorder: Token;\n    tableCellSummaryBg: Token;\n    cardCompactRecordBg: Token;\n    cardFormLabel: Token;\n    cardCompactLabel: Token;\n    cardBlocksLabel: Token;\n    cardCompactBorder: Token;\n    cardEditingLayoutBorder: Token;\n    cardListFormBorder: Token;\n    cardListBlocksBorder: Token;\n    selectionOpaqueFg: Token;\n    widgetBg: Token;\n    widgetBorder: Token;\n    widgetActiveBorder: Token;\n    widgetActiveNonFocusedBorder: Token;\n    widgetInactiveStripesLight: Token;\n    pinnedDocFooterBg: Token;\n    pinnedDocBorder: Token;\n    pinnedDocBorderHover: Token;\n    pinnedDocEditorBg: Token;\n    rawDataTableBorder: Token;\n    rawDataTableBorderHover: Token;\n    controlFg: Token;\n    controlPrimaryFg: Token;\n    controlPrimaryBg: Token;\n    controlPrimaryHoverBg: Token;\n    controlSecondaryFg: Token;\n    controlSecondaryDisabledFg: Token;\n    controlSecondaryHoverFg: Token;\n    controlSecondaryHoverBg: Token;\n    checkboxBg: Token;\n    checkboxSelectedFg: Token;\n    checkboxDisabledBg: Token;\n    checkboxBorder: Token;\n    moveDocsSelectedFg: Token;\n    moveDocsSelectedBg: Token;\n    moveDocsDisabledFg: Token;\n    filterBarButtonSavedFg: Token;\n    filterBarButtonSavedHoverBg: Token;\n    iconDisabled: Token;\n    iconButtonFg: Token;\n    iconButtonPrimaryBg: Token;\n    iconButtonSecondaryBg: Token;\n    iconButtonSecondaryHoverBg: Token;\n    activePageFg: Token;\n    activePageBg: Token;\n    pageOptionsFg: Token;\n    pageOptionsHoverFg: Token;\n    pageOptionsHoverBg: Token;\n    pageOptionsSelectedHoverBg: Token;\n    pageInitialsFg: Token;\n    rightPanelTabFg: Token;\n    rightPanelTabBg: Token;\n    rightPanelTabIcon: Token;\n    rightPanelTabIconHover: Token;\n    rightPanelTabBorder: Token;\n    rightPanelTabHoverBg: Token;\n    rightPanelTabHoverFg: Token;\n    rightPanelTabSelectedFg: Token;\n    rightPanelTabSelectedBg: Token;\n    rightPanelTabSelectedIcon: Token;\n    rightPanelSubtabFg: Token;\n    rightPanelSubtabHoverFg: Token;\n    rightPanelSubtabSelectedFg: Token;\n    rightPanelSubtabSelectedUnderline: Token;\n    rightPanelDisabledOverlay: Token;\n    rightPanelCustomWidgetButtonFg: Token;\n    rightPanelCustomWidgetButtonBg: Token;\n    documentHistorySnapshotFg: Token;\n    documentHistorySnapshotSelectedFg: Token;\n    documentHistorySnapshotBg: Token;\n    documentHistorySnapshotSelectedBg: Token;\n    documentHistoryActivityText: Token;\n    documentHistoryActivityLightText: Token;\n    documentHistoryTableHeaderFg: Token;\n    documentHistoryTableBorderLight: Token;\n    accentIcon: Token;\n    accentBorder: Token;\n    accentText: Token;\n    inputFg: Token;\n    inputBg: Token;\n    inputDisabledFg: Token;\n    inputDisabledBg: Token;\n    inputPlaceholderFg: Token;\n    inputBorder: Token;\n    inputValid: Token;\n    inputFocus: Token;\n    inputReadonlyBg: Token;\n    choiceTokenFg: Token;\n    choiceTokenBlankFg: Token;\n    choiceTokenSelectedBorder: Token;\n    choiceTokenInvalidFg: Token;\n    choiceTokenInvalidBorder: Token;\n    choiceEntryBg: Token;\n    choiceEntryBorder: Token;\n    selectButtonFg: Token;\n    selectButtonPlaceholderFg: Token;\n    selectButtonBg: Token;\n    selectButtonBorder: Token;\n    menuText: Token;\n    menuLightText: Token;\n    menuBg: Token;\n    menuSubheaderFg: Token;\n    menuItemFg: Token;\n    menuItemSelectedFg: Token;\n    menuItemSelectedBg: Token;\n    menuItemDisabledFg: Token;\n    menuItemIconFg: Token;\n    menuItemIconSelectedFg: Token;\n    autocompleteMatchText: Token;\n    autocompleteAddNewCircleFg: Token;\n    autocompleteAddNewCircleBg: Token;\n    autocompleteAddNewCircleSelectedBg: Token;\n    autocompleteSelectedMatchText: Token;\n    searchPrevNextButtonFg: Token;\n    loaderFg: Token;\n    loaderBg: Token;\n    siteSwitcherActiveFg: Token;\n    docMenuDocOptionsFg: Token;\n    docMenuDocOptionsHoverFg: Token;\n    docMenuDocOptionsHoverBg: Token;\n    shortcutKeyFg: Token;\n    shortcutKeySecondaryFg: Token;\n    shortcutKeyBg: Token;\n    shortcutKeyBorder: Token;\n    breadcrumbsTagFg: Token;\n    breadcrumbsTagAlertBg: Token;\n    widgetPickerItemSelectedBg: Token;\n    widgetPickerItemDisabledBg: Token;\n    widgetPickerPrimaryBg: Token;\n    widgetPickerSecondaryBg: Token;\n    widgetPickerIcon: Token;\n    widgetPickerPrimaryIcon: Token;\n    widgetPickerBorder: Token;\n    importerActiveFileBg: Token;\n    importerTableInfoBorder: Token;\n    importerPreviewBorder: Token;\n    importerMatchIcon: Token;\n    importerSkippedTableOverlay: Token;\n    importerActiveFileFg: Token;\n    importerInactiveFileFg: Token;\n    menuToggleFg: Token;\n    menuToggleBg: Token;\n    menuToggleBorder: Token;\n    infoButtonFg: Token;\n    infoButtonHoverFg: Token;\n    infoButtonActiveFg: Token;\n    buttonGroupBg: Token;\n    buttonGroupFg: Token;\n    buttonGroupLightFg: Token;\n    buttonGroupLightBg: Token;\n    buttonGroupIcon: Token;\n    buttonGroupBorder: Token;\n    buttonGroupSelectedFg: Token;\n    buttonGroupLightSelectedFg: Token;\n    buttonGroupLightSelectedBg: Token;\n    buttonGroupSelectedBg: Token;\n    buttonGroupSelectedBorder: Token;\n    accessRulesTableHeaderFg: Token;\n    accessRulesTableHeaderBg: Token;\n    accessRulesTableBodyFg: Token;\n    accessRulesTableBodyLightFg: Token;\n    accessRulesTableBorder: Token;\n    accessRulesColumnListBorder: Token;\n    accessRulesColumnItemFg: Token;\n    accessRulesColumnItemIconFg: Token;\n    accessRulesColumnItemIconHoverFg: Token;\n    accessRulesColumnItemIconHoverBg: Token;\n    accessRulesColumnItemBg: Token;\n    accessRulesFormulaEditorBg: Token;\n    accessRulesFormulaEditorBgDisabled: Token;\n    accessRulesFormulaEditorBorderHover: Token;\n    accessRulesFormulaEditorFocus: Token;\n    cellFg: Token;\n    cellBg: Token;\n    chartBg: Token;\n    commentsPopupHeaderBg: Token;\n    commentsPopupBodyBg: Token;\n    commentsPopupBorder: Token;\n    commentsPanelTopicBg: Token;\n    datePickerTodayFg: Token;\n    datePickerTodayBg: Token;\n    datePickerTodayBgHover: Token;\n    tutorialsPopupBorder: Token;\n    tutorialsPopupHeaderFg: Token;\n    aceAutocompleteLink: Token;\n    aceEditorBg: Token;\n    aceAutocompleteHighlightedFg: Token;\n    colorSelectBg: Token;\n    colorSelectFontOptionFg: Token;\n    colorSelectFontOptionFgSelected: Token;\n    colorSelectFontOptionBgSelected: Token;\n    colorSelectColorSquareBorderEmpty: Token;\n    colorSelectInputFg: Token;\n    colorSelectInputBg: Token;\n    colorSelectInputBorder: Token;\n    highlightedCodeFg: Token;\n    highlightedCodeBorder: Token;\n    loginPageBg: Token;\n    loginPageGoogleButtonBorder: Token;\n    formulaAssistantHeaderBg: Token;\n    formulaAssistantBorder: Token;\n    formulaAssistantPreformattedTextBg: Token;\n    attachmentsEditorButtonHoverFg: Token;\n    attachmentsEditorButtonBorder: Token;\n    attachmentsEditorButtonIcon: Token;\n    announcementPopupFg: Token;\n    widgetGalleryBorderSelected: Token;\n    widgetGalleryBgHover: Token;\n    widgetGallerySecondaryHeaderFg: Token;\n    appHeaderBg: Token;\n    cardButtonBorderSelected: Token;\n    cardButtonShadow: Token;\n    formulaIcon: Token;\n    textButtonHoverBg: Token;\n    textButtonHoverBorder: Token;\n    kbFocusHighlight: Token;\n    switchInactiveSlider: Token;\n    switchInactivePill: Token;\n    switchActiveSlider: Token;\n    switchActivePill: Token;\n    switchHoverShadow: Token;\n  };\n}\n\nexport interface ThemeTokens extends\n  Omit<BaseThemeTokens, \"components\">,\n  Omit<SpecificThemeTokens, \"components\"> {\n  components: BaseThemeTokens[\"components\"] & SpecificThemeTokens[\"components\"];\n}\n"
  },
  {
    "path": "app/common/Themes.ts",
    "content": "import { ThemeName, ThemeTokens } from \"app/common/ThemePrefs\";\nimport { GristDark } from \"app/common/themes/GristDark\";\nimport { GristLight } from \"app/common/themes/GristLight\";\nimport { HighContrastLight } from \"app/common/themes/HighContrastLight\";\n\nconst THEMES: Readonly<Record<ThemeName, ThemeTokens>> = {\n  GristLight,\n  GristDark,\n  HighContrastLight,\n};\n\nexport function getThemeTokens(themeName: ThemeName): ThemeTokens {\n  return THEMES[themeName];\n}\n\n/**\n * A simple JS script that handles setting an appropriate background image\n * based on the appearance setting most recently set by the client.\n *\n * The motivation for this snippet is to avoid the FOUC-like effect that can\n * occur when Grist is loading a page, typically manifesting as a momentary flash\n * from a light background to a dark background (and vice versa). By predicting what\n * the appearance should be based on what was last stored in local storage, we can set\n * a suitable background image before the application (and consequently, the user's\n * theme preferences) have loaded, which should avoid the flash in most cases.\n *\n * Simpler alternatives exist, like using the user agent's preferred color scheme, but\n * don't account for the fact that Grist allows users to manually set their preferred\n * appearance. While this solution isn't perfect, it's a significant improvement over the\n * default behavior.\n */\nexport function getThemeBackgroundSnippet() {\n  /* Note that we only need to set the property if the appearance is dark; a fallback\n   * to the light background (gplaypattern.png) already exists in App.css. */\n  return `\n<script>\ntry {\n  function getGristThemeBackgroundSnippet() {\n    const useThemes = (window.gristConfig.features || []).includes('themes');\n    if (!useThemes) { return; }\n\n    const appearance = localStorage.getItem('appearance');\n    if (appearance) {\n        document.documentElement.setAttribute('data-grist-appearance', appearance);\n    }\n    if (appearance === 'dark') {\n      const style = document.createElement('style');\n      style.setAttribute('id', 'grist-theme-bg');\n      style.textContent = '@layer grist-theme {\\\\n' +\n        '  :root {\\\\n' +\n        '    --grist-theme-bg: url(\"img/prismpattern.png\");\\\\n' +\n        '    --grist-theme-bg-color: #333333;\\\\n' +\n        '  }\\\\n' +\n        '}';\n      document.head.append(style);\n    }\n\n    const theme = localStorage.getItem('grist-theme');\n    if (theme) {\n        document.documentElement.setAttribute('data-grist-theme', theme);\n    }\n  }\n  getGristThemeBackgroundSnippet();\n} catch {\n  /* Do nothing. */\n}\n</script>\n`;\n}\n"
  },
  {
    "path": "app/common/TimeQuery.ts",
    "content": "import { concatenateSummaries } from \"app/common/ActionSummarizer\";\nimport { ActionSummary, ColumnDelta, createEmptyActionSummary, createEmptyTableDelta } from \"app/common/ActionSummary\";\nimport { CellDelta } from \"app/common/TabularDiff\";\n\nimport keyBy from \"lodash/keyBy\";\nimport matches from \"lodash/matches\";\nimport sortBy from \"lodash/sortBy\";\nimport toPairs from \"lodash/toPairs\";\n\n/**\n * We can combine an ActionSummary with the current state of the database\n * to answer questions about the state of the database in the past.  This\n * is particularly useful for Grist metadata tables, which are needed to\n * interpret the content of user tables fully.\n *   - TimeCursor is a simple container for the db and an ActionSummary\n *   - TimeQuery offers a db-like interface for a given table and set of columns\n *   - TimeLayout answers a couple of concrete questions about table meta-data using a\n *     set of TimeQuery objects hooked up to _grist_* tables.\n */\n\nexport interface ResultRow {\n  [column: string]: any;\n}\n\nexport interface ITimeData {\n  fetch(tableId: string, colIds: string[], rowIds?: number[]): Promise<ResultRow[]>;\n  getColIds(tableId: string): Promise<string[]>;\n}\n\n/** Track the state of the database at a particular time. */\nexport class TimeCursor {\n  public summary: ActionSummary;\n\n  constructor(public db: ITimeData) {\n    this.summary = createEmptyActionSummary();\n  }\n\n  /**\n   * Add a summary of an action just before the last action applied to\n   * the TimeCursor, so we stretch further back in time.\n   */\n  public prepend(prevSummary: ActionSummary) {\n    this.summary = concatenateSummaries([prevSummary, this.summary]);\n  }\n\n  /**\n   * Add a summary of an action just after the last action applied to\n   * the TimeCursor, going one step closer to current time. When the\n   * cursor is used, the summary is assumed to extend right up to\n   * current time.\n   */\n  public append(nextSummary: ActionSummary) {\n    // TODO: concatenation appears to modify its inputs, so we\n    // need to clone to avoid propagating that. Look to see if\n    // a safe version of concatenation could be written to save\n    // cloning.\n    this.summary = concatenateSummaries([this.summary, nextSummary]);\n  }\n}\n\n/** internal class for storing a ResultRow dictionary, keyed by rowId */\ninterface ResultRows {\n  [rowId: number]: ResultRow;\n}\n\n/**\n * Query the state of a particular table in the past, given a TimeCursor holding the\n * current db and a summary of all changes between that past time and now.\n * For the moment, for simplicity, names of tables and columns are assumed not to\n * change, and TimeQuery should only be used for _grist_* tables.\n */\nexport class TimeQuery {\n  private _currentRows: ResultRow[];\n  private _pastRows: ResultRow[];\n\n  constructor(public tc: TimeCursor,\n    public tableId: string,\n    public colIds: string[] | \"*\",\n    public rowIds?: number[]) {\n  }\n\n  public reset(tableId: string, colIds: string[] | \"*\", rowIds?: number[]) {\n    this.tableId = tableId;\n    this.colIds = colIds;\n    this.rowIds = rowIds;\n    this._currentRows = [];\n    this._pastRows = [];\n  }\n\n  /**\n   * Get fresh data from DB and overlay with any past data.\n   * TODO: optimize.\n   */\n  public async update(): Promise<ResultRow[]> {\n    this._currentRows = [];\n    this._pastRows = [];\n\n    const tableRenameDelta = this.tc.summary.tableRenames.find(\n      delta => delta[0] === this.tableId,\n    );\n    const tableRenamed = tableRenameDelta ? tableRenameDelta[1] : this.tableId;\n    // Table no longer exists.\n    if (!tableRenamed) { return []; }\n\n    // Let's see everything the summary has accumulated about the table back then.\n    const td = this.tc.summary.tableDeltas[tableRenamed] || createEmptyTableDelta();\n\n    const columnForwardRenames: Record<string, string | null> =\n      Object.fromEntries(td.columnRenames.filter(delta => delta[0]));\n    const columnBackwardRenames: Record<string, string | null> =\n      Object.fromEntries(td.columnRenames.map(([a, b]) => [b, a]).filter(delta => delta[0]));\n\n    const colIdsExpanded = this.colIds === \"*\" ?\n      (await this.tc.db.getColIds(tableRenamed)).map(colId => columnBackwardRenames[colId] ?? colId) :\n      this.colIds;\n\n    const colIdsRenamed =\n      colIdsExpanded.map(colId => columnForwardRenames[colId] ?? colId).filter(colId => colId);\n    this._currentRows = await this.tc.db.fetch(\n      tableRenamed,\n      [\"id\", ...colIdsRenamed],\n      this.rowIds,\n    );\n\n    // Now rewrite the summary as a ResultRow dictionary, to make it comparable\n    // with database.\n    const summaryRows: ResultRows = {};\n    for (const [colId, columns] of toPairs(td.columnDeltas)) {\n      for (const [rowId, cell] of toPairs(columns) as unknown as [keyof ColumnDelta, CellDelta][]) {\n        if (!summaryRows[rowId]) { summaryRows[rowId] = {}; }\n        const val = cell[0];\n        summaryRows[rowId][colId] = (val !== null && typeof val === \"object\") ? val[0] : null;\n      }\n    }\n\n    // Prepare to access the current database state by rowId.\n    const rowsById = keyBy(this._currentRows, r => (r.id as number));\n\n    // Prepare a list of rowIds at the time of interest.\n    // The past rows are whatever the db has now, omitting rows that were added\n    // since the past time, and adding back any rows that were removed since then.\n    // Careful about the order of this, since rows could be replaced.\n    const additions = new Set(td.addRows);\n    const pastRowIds =\n      new Set([...this._currentRows.map(r => r.id as number).filter(r => !additions.has(r)),\n        ...td.removeRows]);\n\n    // Now prepare a row for every expected rowId, using current db data if available\n    // and relevant, and overlaying past data when available.\n    this._pastRows = new Array<ResultRow>();\n    const colIdsOfInterest = new Set(colIdsExpanded);\n    for (const id of Array.from(pastRowIds).sort()) {\n      const rowCurrent: ResultRow = rowsById[id] || { id };\n      const row: ResultRow = {};\n      for (const colId of [\"id\", ...colIdsExpanded]) {\n        const colIdRenamed = columnForwardRenames[colId] ?? colId;\n        if (!colIdRenamed) { continue; }\n        row[colId] = rowCurrent[colIdRenamed];\n      }\n      if (summaryRows[id] && !additions.has(id)) {\n        for (const [colId, val] of toPairs(summaryRows[id])) {\n          const colIdRenamed = columnBackwardRenames[colId] ?? colId;\n          if (colIdsOfInterest.has(colIdRenamed)) {\n            row[colIdRenamed] = val;\n          }\n        }\n      }\n      this._pastRows.push(row);\n    }\n    return this._pastRows;\n  }\n\n  /**\n   * Do a query with a single result, specifying any desired filters.  Exception thrown\n   * if there is no result.\n   */\n  public one(args: { [name: string]: any }): ResultRow {\n    const result = this._pastRows.find(matches(args));\n    if (!result) {\n      throw new Error(`could not find: ${JSON.stringify(args)} for ${this.tableId}`);\n    }\n    return result;\n  }\n\n  /** Get all results for a query. */\n  public all(args?: { [name: string]: any }): ResultRow[] {\n    if (!args) { return this._pastRows; }\n    return this._pastRows.filter(matches(args));\n  }\n}\n\n/**\n * Put some TimeQuery queries to work answering questions about column order and\n * user-facing name of tables.\n */\nexport class TimeLayout {\n  public tables: TimeQuery;\n  public fields: TimeQuery;\n  public columns: TimeQuery;\n  public views: TimeQuery;\n  public sections: TimeQuery;\n\n  constructor(public tc: TimeCursor) {\n    this.tables = new TimeQuery(tc, \"_grist_Tables\", [\"tableId\", \"primaryViewId\", \"rawViewSectionRef\"]);\n    this.fields = new TimeQuery(tc, \"_grist_Views_section_field\",\n      [\"parentId\", \"parentPos\", \"colRef\"]);\n    this.columns = new TimeQuery(tc, \"_grist_Tables_column\", [\"parentId\", \"colId\"]);\n    this.views = new TimeQuery(tc, \"_grist_Views\", [\"id\", \"name\"]);\n    this.sections = new TimeQuery(tc, \"_grist_Views_section\", [\"id\", \"title\"]);\n  }\n\n  /** update from TimeCursor */\n  public async update() {\n    await this.tables.update();\n    await this.columns.update();\n    await this.fields.update();\n    await this.views.update();\n    await this.sections.update();\n  }\n\n  public getColumnOrder(tableId: string): string[] {\n    const primaryViewId = this.tables.one({ tableId }).primaryViewId;\n    const preorder = this.fields.all({ parentId: primaryViewId });\n    const precol = keyBy(this.columns.all(), \"id\");\n    const ordered = sortBy(preorder, \"parentPos\");\n    const names = ordered.map(r => precol[r.colRef].colId);\n    return names;\n  }\n\n  public getTableName(tableId: string): string {\n    const rawViewSectionRef = this.tables.one({ tableId }).rawViewSectionRef;\n    return this.sections.one({ id: rawViewSectionRef }).title;\n  }\n}\n"
  },
  {
    "path": "app/common/Triggers-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const WebhookSubscribeCollection = t.iface([], {\n  \"webhooks\": t.array(\"Webhook\"),\n});\n\nexport const Webhook = t.iface([], {\n  \"fields\": \"WebhookFields\",\n});\n\nexport const WebhookFields = t.iface([], {\n  \"url\": \"string\",\n  \"authorization\": t.opt(\"string\"),\n  \"eventTypes\": t.array(t.union(t.lit(\"add\"), t.lit(\"update\"))),\n  \"tableId\": \"string\",\n  \"watchedColIds\": t.opt(t.array(\"string\")),\n  \"enabled\": t.opt(\"boolean\"),\n  \"isReadyColumn\": t.opt(t.union(\"string\", \"null\")),\n  \"condition\": t.opt(\"string\"),\n  \"name\": t.opt(\"string\"),\n  \"memo\": t.opt(\"string\"),\n});\n\nexport const WebhookBatchStatus = t.union(t.lit(\"success\"), t.lit(\"failure\"), t.lit(\"rejected\"));\n\nexport const WebhookStatus = t.union(t.lit(\"idle\"), t.lit(\"sending\"), t.lit(\"retrying\"), t.lit(\"postponed\"), t.lit(\"error\"), t.lit(\"invalid\"));\n\nexport const WebHookSecret = t.iface([], {\n  \"url\": \"string\",\n  \"unsubscribeKey\": \"string\",\n  \"authorization\": t.opt(\"string\"),\n});\n\nexport const WebhookSubscribe = t.iface([], {\n  \"url\": \"string\",\n  \"authorization\": t.opt(\"string\"),\n  \"eventTypes\": t.array(t.union(t.lit(\"add\"), t.lit(\"update\"))),\n  \"watchedColIds\": t.opt(t.array(\"string\")),\n  \"enabled\": t.opt(\"boolean\"),\n  \"condition\": t.opt(\"string\"),\n  \"isReadyColumn\": t.opt(t.union(\"string\", \"null\")),\n  \"name\": t.opt(\"string\"),\n  \"memo\": t.opt(\"string\"),\n});\n\nexport const WebhookSummaryCollection = t.iface([], {\n  \"webhooks\": t.array(\"WebhookSummary\"),\n});\n\nexport const WebhookSummary = t.iface([], {\n  \"id\": \"string\",\n  \"fields\": t.iface([], {\n    \"url\": \"string\",\n    \"authorization\": t.opt(\"string\"),\n    \"unsubscribeKey\": \"string\",\n    \"eventTypes\": t.array(\"string\"),\n    \"isReadyColumn\": t.union(\"string\", \"null\"),\n    \"tableId\": \"string\",\n    \"watchedColIds\": t.opt(t.array(\"string\")),\n    \"enabled\": \"boolean\",\n    \"name\": \"string\",\n    \"memo\": \"string\",\n  }),\n  \"usage\": t.union(\"WebhookUsage\", \"null\"),\n});\n\nexport const WebhookUpdate = t.iface([], {\n  \"id\": \"string\",\n  \"fields\": \"WebhookPatch\",\n});\n\nexport const WebhookPatch = t.iface([], {\n  \"url\": t.opt(\"string\"),\n  \"authorization\": t.opt(\"string\"),\n  \"eventTypes\": t.opt(t.array(t.union(t.lit(\"add\"), t.lit(\"update\")))),\n  \"tableId\": t.opt(\"string\"),\n  \"watchedColIds\": t.opt(t.array(\"string\")),\n  \"enabled\": t.opt(\"boolean\"),\n  \"isReadyColumn\": t.opt(t.union(\"string\", \"null\")),\n  \"name\": t.opt(\"string\"),\n  \"memo\": t.opt(\"string\"),\n});\n\nexport const WebhookUsage = t.iface([], {\n  \"numWaiting\": \"number\",\n  \"status\": \"WebhookStatus\",\n  \"updatedTime\": t.opt(t.union(\"number\", \"null\")),\n  \"lastSuccessTime\": t.opt(t.union(\"number\", \"null\")),\n  \"lastFailureTime\": t.opt(t.union(\"number\", \"null\")),\n  \"lastErrorMessage\": t.opt(t.union(\"string\", \"null\")),\n  \"lastHttpStatus\": t.opt(t.union(\"number\", \"null\")),\n  \"lastEventBatch\": t.opt(t.union(\"null\", t.iface([], {\n    \"size\": \"number\",\n    \"errorMessage\": t.union(\"string\", \"null\"),\n    \"httpStatus\": t.union(\"number\", \"null\"),\n    \"status\": \"WebhookBatchStatus\",\n    \"attempts\": \"number\",\n  }))),\n  \"numSuccess\": t.opt(t.iface([], {\n    \"pastHour\": \"number\",\n    \"past24Hours\": \"number\",\n  })),\n});\n\nexport const TriggerAction = t.union(\"WebhookAction\", \"EmailAction\");\n\nexport const WebhookAction = t.iface([], {\n  \"type\": t.lit(\"webhook\"),\n  \"id\": \"string\",\n});\n\nexport const EmailAction = t.iface([], {\n  \"id\": \"string\",\n  \"type\": t.lit(\"email\"),\n  \"to\": \"string\",\n  \"subject\": \"string\",\n  \"body\": \"string\",\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  WebhookSubscribeCollection,\n  Webhook,\n  WebhookFields,\n  WebhookBatchStatus,\n  WebhookStatus,\n  WebHookSecret,\n  WebhookSubscribe,\n  WebhookSummaryCollection,\n  WebhookSummary,\n  WebhookUpdate,\n  WebhookPatch,\n  WebhookUsage,\n  TriggerAction,\n  WebhookAction,\n  EmailAction,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/common/Triggers.ts",
    "content": "export interface WebhookSubscribeCollection {\n  webhooks: Webhook[]\n}\n\nexport interface Webhook {\n  fields: WebhookFields;\n}\n\nexport interface WebhookFields {\n  url: string;\n  authorization?: string;\n  eventTypes: (\"add\" | \"update\")[];\n  tableId: string;\n  watchedColIds?: string[];\n  enabled?: boolean;\n  isReadyColumn?: string | null;\n  condition?: string;\n  name?: string;\n  memo?: string;\n}\n\n// Union discriminated by type\nexport type WebhookBatchStatus = \"success\" | \"failure\" | \"rejected\";\nexport type WebhookStatus = \"idle\" | \"sending\" | \"retrying\" | \"postponed\" | \"error\" | \"invalid\";\n\n/** Secrets for webhook stored outside the document in home db */\nexport interface WebHookSecret {\n  url: string;\n  unsubscribeKey: string;\n  authorization?: string;\n}\n\n// WebhookSubscribe should be `Omit<WebhookFields, 'tableId'>` (because subscribe endpoint read\n// tableId from the url) but generics are not yet supported by ts-interface-builder\nexport interface WebhookSubscribe {\n  url: string;\n  authorization?: string;\n  eventTypes: (\"add\" | \"update\")[];\n  watchedColIds?: string[];\n  enabled?: boolean;\n  condition?: string;\n  isReadyColumn?: string | null;\n  name?: string;\n  memo?: string;\n}\n\nexport interface  WebhookSummaryCollection {\n  webhooks: WebhookSummary[];\n}\nexport interface WebhookSummary {\n  id: string;\n  fields: {\n    url: string;\n    authorization?: string;\n    unsubscribeKey: string;\n    eventTypes: string[];\n    isReadyColumn: string | null;\n    tableId: string;\n    watchedColIds?: string[];\n    enabled: boolean;\n    name: string;\n    memo: string;\n  },\n  usage: WebhookUsage | null,\n}\n\n// Describes fields to update a webhook\nexport interface WebhookUpdate {\n  id: string;\n  fields: WebhookPatch;\n}\n\n// WebhookPatch should be `Partial<WebhookFields>` but generics are not yet supported by\n// ts-interface-builder\nexport interface WebhookPatch {\n  url?: string;\n  authorization?: string;\n  eventTypes?: (\"add\" | \"update\")[];\n  tableId?: string;\n  watchedColIds?: string[];\n  enabled?: boolean;\n  isReadyColumn?: string | null;\n  name?: string;\n  memo?: string;\n}\n\nexport interface WebhookUsage {\n  // As minimum we need number of waiting events and status (by default pending).\n  numWaiting: number,\n  status: WebhookStatus;\n  updatedTime?: number | null;\n  lastSuccessTime?: number | null;\n  lastFailureTime?: number | null;\n  lastErrorMessage?: string | null;\n  lastHttpStatus?: number | null;\n  lastEventBatch?: null | {\n    size: number;\n    errorMessage: string | null;\n    httpStatus: number | null;\n    status: WebhookBatchStatus;\n    attempts: number;\n  },\n  numSuccess?: {\n    pastHour: number;\n    past24Hours: number;\n  },\n}\n\n// Union type for trigger actions. Currently only WebhookAction is supported, but this is\n// designed as a discriminated union to support additional action types in the future (e.g., emails).\nexport type TriggerAction = WebhookAction | EmailAction;\n\nexport interface WebhookAction {\n  // The type field is used to discriminate between different action types.\n  // For now we have only webhook, but next types in the pipeline are emails.\n  type: \"webhook\";\n  id: string; // Unique id of the action, used as a key in homeDB secrets for webhooks\n}\n\nexport interface EmailAction {\n  id: string;\n  type: \"email\";\n  to: string; // Comma-separated list of email addresses, user refs.\n  subject: string;\n  body: string;\n}\n"
  },
  {
    "path": "app/common/User.ts",
    "content": "import { getTableId } from \"app/common/DocActions\";\nimport { EmptyRecordView, RecordView } from \"app/common/RecordView\";\nimport { Role } from \"app/common/roles\";\n\n/**\n * User type to distinguish between Users and service accounts\n */\nexport type UserType = \"login\" | \"service\";\n\n/**\n * Information about a user, including any user attributes.\n */\nexport interface UserInfo {\n  Name: string | null;\n  Email: string | null;\n  Access: Role | null;\n  Origin: string | null;\n  LinkKey: Record<string, string | undefined>;\n  UserID: number | null;\n  UserRef: string | null;\n  SessionID: string | null;\n  /**\n   * This is a rowId in the _grist_Shares table, if the user is accessing a document\n   * via a share. Otherwise null.\n   */\n  ShareRef: number | null;\n  Type: UserType | null;\n  [attributes: string]: unknown;\n}\n\n/**\n * Wrapper class for `UserInfo`.\n *\n * Contains methods for converting itself to different representations.\n */\nexport class User implements UserInfo {\n  public Name: string | null = null;\n  public UserID: number | null = null;\n  public Access: Role | null = null;\n  public Origin: string | null = null;\n  public LinkKey: Record<string, string | undefined> = {};\n  public Email: string | null = null;\n  public SessionID: string | null = null;\n  public UserRef: string | null = null;\n  public ShareRef: number | null = null;\n  public Type: UserType | null = null;\n  [attribute: string]: any;\n\n  constructor(info: Record<string, unknown> = {}) {\n    Object.assign(this, info);\n  }\n\n  /**\n   * Returns a JSON representation of this class that excludes full row data,\n   * only keeping user info and table/row ids for any user attributes.\n   *\n   * Used by the sandbox to support `user` variables in formulas (see `user.py`).\n   */\n  public toJSON() {\n    return this._toObject((value) => {\n      if (value instanceof RecordView) {\n        return [getTableId(value.data), value.get(\"id\")];\n      } else if (value instanceof EmptyRecordView) {\n        return null;\n      } else {\n        return value;\n      }\n    });\n  }\n\n  /**\n   * Returns a record representation of this class, with all user attributes\n   * converted from `RecordView` instances to their JSON representations.\n   *\n   * Used by the client to support `user` variables in dropdown conditions.\n   */\n  public toUserInfo(): UserInfo {\n    return this._toObject((value) => {\n      if (value instanceof RecordView) {\n        return value.toJSON();\n      } else if (value instanceof EmptyRecordView) {\n        return null;\n      } else {\n        return value;\n      }\n    }) as UserInfo;\n  }\n\n  private _toObject(mapValue: (value: unknown) => unknown) {\n    const results: { [key: string]: any } = {};\n    for (const [key, value] of Object.entries(this)) {\n      results[key] = mapValue(value);\n    }\n    return results;\n  }\n}\n"
  },
  {
    "path": "app/common/UserAPI.ts",
    "content": "import { ApplyUAResult, ForkResult, FormulaTimingInfo,\n  PermissionDataWithExtraUsers, QueryFilters, TimingStatus } from \"app/common/ActiveDocAPI\";\nimport { AssistanceRequest, AssistanceResponse } from \"app/common/Assistance\";\nimport { BaseAPI, IOptions } from \"app/common/BaseAPI\";\nimport { BillingAPI, BillingAPIImpl } from \"app/common/BillingAPI\";\nimport { BrowserSettings } from \"app/common/BrowserSettings\";\nimport { ICustomWidget } from \"app/common/CustomWidget\";\nimport { BulkColValues, TableColValues, TableRecordValue, TableRecordValues,\n  TableRecordValuesWithoutIds, UserAction } from \"app/common/DocActions\";\nimport { DocCreationInfo, OpenDocMode } from \"app/common/DocListAPI\";\nimport { DocStateComparison, DocStates } from \"app/common/DocState\";\nimport { OrgUsageSummary } from \"app/common/DocUsage\";\nimport { Features, Product } from \"app/common/Features\";\nimport { isClient } from \"app/common/gristUrls\";\nimport { encodeQueryParams } from \"app/common/gutil\";\nimport { FullUser, UserProfile } from \"app/common/LoginSessionAPI\";\nimport { OrgPrefs, UserOrgPrefs, UserPrefs } from \"app/common/Prefs\";\nimport * as roles from \"app/common/roles\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport {\n  WebhookFields,\n  WebhookSubscribe,\n  WebhookSummaryCollection,\n  WebhookUpdate,\n} from \"app/common/Triggers\";\nimport { addCurrentOrgToPath, getGristConfig } from \"app/common/urlUtils\";\nimport { AttachmentStore, AttachmentStoreDesc, TablesGet } from \"app/plugin/DocApiTypes\";\n\nimport { AxiosProgressEvent } from \"axios\";\nimport omitBy from \"lodash/omitBy\";\n\nexport type { FullUser, UserProfile };\n\n// Nominal email address of the anonymous user.\nexport const ANONYMOUS_USER_EMAIL = \"anon@getgrist.com\";\n\n// Nominal email address of a user who, if you share with them, everyone gets access.\nexport const EVERYONE_EMAIL = \"everyone@getgrist.com\";\n\n// Nominal email address of a user who can view anything (for thumbnails).\nexport const PREVIEWER_EMAIL = \"thumbnail@getgrist.com\";\n\n// A special 'docId' that means to create a new document.\nexport const NEW_DOCUMENT_CODE = \"new\";\n\n// Properties shared by org, workspace, and doc resources.\nexport interface CommonProperties {\n  name: string;\n  createdAt: string;   // ISO date string\n  updatedAt: string;   // ISO date string\n  removedAt?: string;  // ISO date string - only can appear on docs and workspaces currently\n  disabledAt?: string; // ISO date string - only can appear on docs currently\n  public?: boolean;    // If set, resource is available to the public\n}\nexport const commonPropertyKeys = [\"createdAt\", \"name\", \"updatedAt\"];\n\nexport interface OrganizationProperties extends CommonProperties {\n  domain: string | null;\n  // Organization includes preferences relevant to interacting with its content.\n  userOrgPrefs?: UserOrgPrefs;  // Preferences specific to user and org\n  orgPrefs?: OrgPrefs;          // Preferences specific to org (but not a particular user)\n  userPrefs?: UserPrefs;        // Preferences specific to user (but not a particular org)\n}\nexport const organizationPropertyKeys = [...commonPropertyKeys, \"domain\",\n  \"orgPrefs\", \"userOrgPrefs\", \"userPrefs\"];\n\n// Basic information about an organization, excluding the user's access level\nexport interface OrganizationWithoutAccessInfo extends OrganizationProperties {\n  id: number;\n  owner: FullUser | null;\n  billingAccount?: BillingAccount;\n  host: string | null;  // if set, org's preferred domain (e.g. www.thing.com)\n}\n\n// Organization information plus the user's access level\nexport interface Organization extends OrganizationWithoutAccessInfo {\n  access: roles.Role;\n}\n\n// Basic information about a billing account associated with an org or orgs.\nexport interface BillingAccount {\n  id: number;\n  individual: boolean;\n  product: Product;\n  stripePlanId: string; // Stripe price id.\n  isManager: boolean;\n  inGoodStanding: boolean;\n  features?: Features; // Features override, not the final set of features.\n  externalOptions?: {\n    invoiceId?: string;\n  };\n}\n\n// The upload types vary based on which fetch implementation is in use.  This is\n// an incomplete list.  For example, node streaming types are supported by node-fetch.\nexport type UploadType = string | Blob | Buffer;\n\n/**\n * Returns a user-friendly org name, which is either org.name, or \"@User Name\" for personal orgs.\n */\nexport function getOrgName(org: Organization): string {\n  return org.owner ? `@` + org.owner.name : org.name;\n}\n\n/**\n * Returns whether the given org is the templates org, which contains the public\n * templates and tutorials.\n */\nexport function isTemplatesOrg(org: { domain: Organization[\"domain\"] } | null): boolean {\n  if (!org) { return false; }\n\n  const { templateOrg } = getGristConfig();\n  return org.domain === templateOrg;\n}\n\nexport type WorkspaceProperties = CommonProperties;\nexport const workspacePropertyKeys = [\"createdAt\", \"name\", \"updatedAt\"];\n\nexport interface Workspace extends WorkspaceProperties {\n  id: number;\n  docs: Document[];\n  org: Organization;\n  orgDomain?: string;\n  access: roles.Role;\n  owner?: FullUser;  // Set when workspaces are in the \"docs\" pseudo-organization,\n  // assembled from multiple personal organizations.\n  // Not set when workspaces are all from the same organization.\n\n  // Set when the workspace belongs to support@getgrist.com. We expect only one such workspace\n  // (\"Examples & Templates\"), containing sample documents.\n  isSupportWorkspace?: boolean;\n}\n\n// null stands for normal document type, the one set by default at document creation.\nexport const DOCTYPE_NORMAL = null;\nexport const DOCTYPE_TEMPLATE = \"template\";\nexport const DOCTYPE_TUTORIAL = \"tutorial\";\n\nexport type DocumentType = typeof DOCTYPE_NORMAL | typeof DOCTYPE_TEMPLATE | typeof DOCTYPE_TUTORIAL;\n\n// Non-core options for a document.\n// \"Non-core\" means bundled into a single options column in the database.\n// TODO: consider smoothing over this distinction in the API.\nexport interface DocumentOptions {\n  description?: string | null;\n  icon?: string | null;\n  openMode?: OpenDocMode | null;\n  externalId?: string | null;  // A slot for storing an externally maintained id.\n  // Not used in grist-core, but handy for Electron app.\n  tutorial?: TutorialMetadata | null;\n  appearance?: DocumentAppearance | null;\n  // Whether search engines should index this document. Defaults to `false`.\n  allowIndex?: boolean;\n  proposedChanges?: ProposedChanges | null;\n}\n\nexport interface TutorialMetadata {\n  lastSlideIndex?: number;\n  percentComplete?: number;\n}\n\ninterface DocumentAppearance {\n  icon?: DocumentIcon | null;\n}\n\ninterface DocumentIcon {\n  backgroundColor?: string;\n  color?: string;\n  emoji?: string | null;\n}\n\nexport interface DocumentProperties extends CommonProperties {\n  isPinned: boolean;\n  urlId: string | null;\n  trunkId: string | null;\n  type: DocumentType | null;\n  options: DocumentOptions | null;\n}\n\nexport interface ProposedChanges {\n  mayHaveProposals?: boolean;\n  acceptProposals?: boolean;\n}\n\nexport const documentPropertyKeys = [\n  ...commonPropertyKeys,\n  \"isPinned\",\n  \"urlId\",\n  \"options\",\n  \"type\",\n  \"appearance\",\n];\n\nexport interface Document extends DocumentProperties {\n  id: string;\n  workspace: Workspace;\n  access: roles.Role;\n  trunkAccess?: roles.Role | null;\n  forks?: Fork[];\n}\n\nexport interface Fork {\n  id: string;\n  trunkId: string;\n  updatedAt: string;  // ISO date string\n  options: DocumentOptions | null;\n}\n\nexport interface ProposalComparison {\n  comparison?: DocStateComparison;\n}\n\nexport interface ProposalStatus {\n  status?: \"applied\" | \"retracted\" | \"dismissed\";\n}\n\nexport interface Proposal {\n  shortId: number;\n  comparison: ProposalComparison;\n  status: ProposalStatus;\n  createdAt: string;  // ISO date string\n  updatedAt: string;  // ISO date string\n  appliedAt: string | null;  // ISO date string\n  srcDocId: string;\n  srcDoc: Document & {\n    creator: FullUser\n  },\n  destDocId: string;\n  destDoc: Document & {\n    creator: FullUser\n  },\n}\n\n// Non-core options for a user.\nexport interface UserOptions {\n  // Whether signing in with Google is allowed. Defaults to true if unset.\n  allowGoogleLogin?: boolean;\n  // The \"sub\" (subject) from the JWT issued by the password-based authentication provider.\n  authSubject?: string;\n  // Whether user is a consultant. Consultant users can be added to sites\n  // without being counted for billing. Defaults to false if unset.\n  isConsultant?: boolean;\n  // Locale selected by the user. Defaults to 'en' if unset.\n  locale?: string;\n  ssoExtraInfo?: Record<string, any>; // Extra fields from the user profile, e.g. from OIDC.\n  // The first time the user logged in using getgrist.com auth. Only set in Grist SaaS.\n  firstOAuthLoginAt?: Date;\n}\n\nexport interface PermissionDelta {\n  maxInheritedRole?: roles.BasicRole | null;\n  users?: {\n    // Maps from email to group name, or null to inherit.\n    [email: string]: roles.NonGuestRole | null\n  };\n}\n\nexport interface PermissionData {\n  // True if permission data is restricted to current user.\n  personal?: true;\n  // True if current user is a public member.\n  public?: boolean;\n  maxInheritedRole?: roles.BasicRole | null;\n  users: UserAccessData[];\n}\n\n// A structure for modifying managers of a billing account.\nexport interface ManagerDelta {\n  users: {\n    // To add a manager, link their email to 'managers'.\n    // To remove a manager, link their email to null.\n    // This format is used to rhyme with the ACL PermissionDelta format.\n    [email: string]: \"managers\" | null\n  };\n}\n\nexport interface UserAccess {\n  // Represents the user's direct access to the resource of interest. Lack of access to a resource\n  // is represented by a null value.\n  access: roles.Role | null;\n  // A user's parentAccess represent their effective inheritable access to the direct parent of the resource\n  // of interest. The user's effective access to the resource of interest can be determined based\n  // on the user's parentAccess, the maxInheritedRole setting of the resource and the user's direct\n  // access to the resource. Lack of access to the parent resource is represented by a null value.\n  // If parent has non-inheritable access, this should be null.\n  parentAccess?: roles.BasicRole | null;\n}\n\n// Information about a user and their access to an unspecified resource of interest.\nexport interface UserAccessData extends UserAccess {\n  id: number;\n  name: string;\n  email: string;\n  ref?: string | null;\n  picture?: string | null; // When present, a url to a public image of unspecified dimensions.\n  orgAccess?: roles.BasicRole | null;\n  anonymous?: boolean;    // If set to true, the user is the anonymous user.\n  isMember?: boolean;\n  disabledAt?: Date | null; // If not null, the user is disabled\n}\n\n/**\n * Combines access, parentAccess, and maxInheritedRole info into the resulting access role.\n */\nexport function getRealAccess(\n  user: UserAccess, inherited: { maxInheritedRole?: roles.BasicRole | null },\n): roles.Role | null {\n  const inheritedAccess = roles.getWeakestRole(user.parentAccess || null, inherited.maxInheritedRole || null);\n  return roles.getStrongestRole(user.access, inheritedAccess);\n}\n\nconst roleNames: { [role: string]: string } = {\n  [roles.OWNER]: \"Owner\",\n  [roles.EDITOR]: \"Editor\",\n  [roles.VIEWER]: \"Viewer\",\n};\n\nexport function getUserRoleText(user: UserAccessData) {\n  return roleNames[user.access!] || user.access || \"no access\";\n}\n\nexport interface ExtendedUser extends FullUser {\n  helpScoutSignature?: string;\n  isInstallAdmin?: boolean;     // Set if user is allowed to manage this installation.\n}\n\nexport interface ActiveSessionInfo {\n  user: ExtendedUser;\n  org: Organization | null;\n  orgError?: OrgError;\n}\n\nexport interface OrgError {\n  error: string;\n  status: number;\n}\n\n/**\n * Options to control the source of a document being replaced.  For\n * example, a document could be initialized from another document\n * (e.g. a fork) or from a snapshot.\n */\nexport interface DocReplacementOptions {\n  /**\n   * The docId to copy from.\n   */\n  sourceDocId?: string;\n  /**\n   * The s3 version ID.\n   */\n  snapshotId?: string;\n  /**\n   * True if tutorial metadata should be reset.\n   *\n   * Metadata that's reset includes the doc (i.e. tutorial) name, and the\n   * properties under options.tutorial (e.g. lastSlideIndex).\n   */\n  resetTutorialMetadata?: boolean;\n}\n\n/**\n * Information about a single document snapshot/backup.\n */\nexport interface DocSnapshot {\n  lastModified: string;  // when the snapshot was made\n  snapshotId: string;    // the id of the snapshot in the underlying store\n  docId: string;         // an id for accessing the snapshot as a Grist document\n}\n\n/**\n * A list of document snapshots.\n */\nexport interface DocSnapshots {\n  snapshots: DocSnapshot[];  // snapshots, freshest first.\n}\n\nexport interface CopyDocOptions {\n  documentName: string;\n  asTemplate?: boolean;\n}\n\nexport interface RenameDocOptions {\n  icon?: DocumentIcon | null;\n}\n\nexport interface UserAPI {\n  getSessionActive(): Promise<ActiveSessionInfo>;\n  setSessionActive(email: string, org?: string): Promise<void>;\n  getSessionAll(): Promise<{ users: FullUser[], orgs: Organization[] }>;\n  getOrgs(merged?: boolean): Promise<Organization[]>;\n  getWorkspace(workspaceId: number): Promise<Workspace>;\n  getOrg(orgId: number | string): Promise<Organization>;\n  getOrgWorkspaces(orgId: number | string, includeSupport?: boolean): Promise<Workspace[]>;\n  getOrgUsageSummary(orgId: number | string): Promise<OrgUsageSummary>;\n  getTemplates(): Promise<Workspace[]>;\n  getTemplate(docId: string): Promise<Document>;\n  getDoc(docId: string): Promise<Document>;\n  newOrg(props: Partial<OrganizationProperties>): Promise<number>;\n  newWorkspace(props: Partial<WorkspaceProperties>, orgId: number | string): Promise<number>;\n  newDoc(props: Partial<DocumentProperties>, workspaceId: number): Promise<string>;\n  newUnsavedDoc(options?: { timezone?: string }): Promise<string>;\n  copyDoc(sourceDocumentId: string, workspaceId: number, options: CopyDocOptions): Promise<string>;\n  renameOrg(orgId: number | string, name: string): Promise<void>;\n  renameWorkspace(workspaceId: number, name: string): Promise<void>;\n  renameDoc(docId: string, name: string, options?: RenameDocOptions): Promise<void>;\n  updateOrg(orgId: number | string, props: Partial<OrganizationProperties>): Promise<void>;\n  updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void>;\n  deleteOrg(orgId: number | string): Promise<void>;\n  deleteWorkspace(workspaceId: number): Promise<void>;     // delete workspace permanently\n  softDeleteWorkspace(workspaceId: number): Promise<void>; // soft-delete workspace\n  undeleteWorkspace(workspaceId: number): Promise<void>;   // recover soft-deleted workspace\n  deleteDoc(docId: string): Promise<void>;      // delete doc permanently\n  softDeleteDoc(docId: string): Promise<void>;  // soft-delete doc\n  undeleteDoc(docId: string): Promise<void>;    // recover soft-deleted doc\n  disableDoc(docId: string): Promise<void>;     // (admin-only) remove all access to doc except deletion\n  enableDoc(docId: string): Promise<void>;      // (admin-only) recover disabled doc\n  updateOrgPermissions(orgId: number | string, delta: PermissionDelta): Promise<void>;\n  updateWorkspacePermissions(workspaceId: number, delta: PermissionDelta): Promise<void>;\n  updateDocPermissions(docId: string, delta: PermissionDelta): Promise<void>;\n  getOrgAccess(orgId: number | string): Promise<PermissionData>;\n  getWorkspaceAccess(workspaceId: number): Promise<PermissionData>;\n  getDocAccess(docId: string): Promise<PermissionData>;\n  pinDoc(docId: string): Promise<void>;\n  unpinDoc(docId: string): Promise<void>;\n  moveDoc(docId: string, workspaceId: number): Promise<void>;\n  getUserProfile(): Promise<FullUser>;\n  updateUserName(name: string): Promise<void>;\n  updateUserLocale(locale: string | null): Promise<void>;\n  updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;\n  disableUser(userId: number): Promise<void>;\n  enableUser(userId: number): Promise<void>;\n  updateIsConsultant(userId: number, isConsultant: boolean): Promise<void>;\n  getWorker(key: string): Promise<string>;\n  getWorkerFull(key: string): Promise<PublicDocWorkerUrlInfo>;\n  getWorkerAPI(key: string): Promise<DocWorkerAPI>;\n  getBillingAPI(): BillingAPI;\n  getDocAPI(docId: string): DocAPI;\n  fetchApiKey(): Promise<string>;\n  createApiKey(): Promise<string>;\n  deleteApiKey(): Promise<void>;\n  getTable(docId: string, tableName: string): Promise<TableColValues>;\n  applyUserActions(docId: string, actions: UserAction[]): Promise<ApplyUAResult>;\n  importUnsavedDoc(material: UploadType, options?: {\n    filename?: string,\n    timezone?: string,\n    onUploadProgress?: (ev: AxiosProgressEvent) => void,\n  }): Promise<string>;\n  deleteUser(userId: number, name: string): Promise<void>;\n  getBaseUrl(): string;  // Get the prefix for all the endpoints this object wraps.\n  forRemoved(): UserAPI; // Get a version of the API that works on removed resources.\n  getWidgets(): Promise<ICustomWidget[]>;\n  /**\n   * Deletes account and personal org with all documents. Note: deleteUser doesn't clear documents, and this method\n   * is specific to Grist installation, and might not be supported. Pass current user's id so that we can verify\n   * that the user is deleting their own account. This is just to prevent accidental deletion from multiple tabs.\n   *\n   * @returns true if the account was deleted, false if there was a mismatch with the current user's id, and the\n   * account was probably already deleted.\n   */\n  closeAccount(userId: number): Promise<boolean>;\n  /**\n   * Deletes current non personal org with all documents. Note: deleteOrg doesn't clear documents, and this method\n   * is specific to Grist installation, and might not be supported.\n   */\n  closeOrg(): Promise<void>;\n}\n\n/**\n * Parameters for the download CSV and XLSX endpoint (/download/table-schema & /download/csv & /download/csv).\n */\nexport interface DownloadDocParams {\n  tableId: string;\n  viewSection?: number;\n  activeSortSpec?: string;\n  filters?: string;\n}\n\nexport const CreatableArchiveFormats = StringUnion(\"zip\", \"tar\");\nexport type CreatableArchiveFormats = typeof CreatableArchiveFormats.type;\n\nexport interface AttachmentsArchiveParams {\n  format?: CreatableArchiveFormats,\n}\n\nexport interface ArchiveUploadResult {\n  added: number;\n  errored: number;\n  unused: number;\n}\n\ninterface GetRowsParams {\n  filters?: QueryFilters;\n  immediate?: boolean;\n}\n\ninterface SqlResult extends TableRecordValuesWithoutIds {\n  statement: string;\n}\n\nexport const DocAttachmentsLocation = StringUnion(\n  \"none\", \"internal\", \"mixed\", \"external\",\n);\nexport type DocAttachmentsLocation = typeof DocAttachmentsLocation.type;\n\nexport const ExpandTableOption = StringUnion(\"column\");\nexport type ExpandTableOption = typeof ExpandTableOption.type;\n\ninterface GetTablesParams {\n  expand?: ExpandTableOption[];\n}\n\n/**\n * Collect endpoints related to the content of a single document that we've been thinking\n * of as the (restful) \"Doc API\".  A few endpoints that could be here are not, for historical\n * reasons, such as downloads.\n */\nexport interface DocAPI {\n  readonly options: IOptions;\n  getBaseUrl(): string;\n  getTables(options?: GetTablesParams): Promise<TablesGet>;\n  // Immediate flag is a currently not-advertised feature, allowing a query to proceed without\n  // waiting for a document to be initialized. This is useful if the calculations done when\n  // opening a document are irrelevant.\n  getRows(tableId: string, options?: GetRowsParams): Promise<TableColValues>;\n  getRecords(tableId: string, options?: GetRowsParams): Promise<TableRecordValue[]>;\n  sql(sql: string, args?: any[]): Promise<SqlResult>;\n  updateRows(tableId: string, changes: TableColValues): Promise<number[]>;\n  addRows(tableId: string, additions: BulkColValues): Promise<number[]>;\n  removeRows(tableId: string, removals: number[]): Promise<number[]>;\n  fork(): Promise<ForkResult>;\n  replace(source: DocReplacementOptions): Promise<void>;\n  // Get list of document versions (specify raw to bypass caching, which should only make\n  // a difference if snapshots have \"leaked\")\n  getSnapshots(raw?: boolean): Promise<DocSnapshots>;\n  // remove selected snapshots, or all snapshots that have \"leaked\" from inventory (should\n  // be empty), or all but the current snapshot.\n  removeSnapshots(snapshotIds: string[] | \"unlisted\" | \"past\"): Promise<{ snapshotIds: string[] }>;\n  getStates(): Promise<DocStates>;\n  forceReload(): Promise<void>;\n  recover(recoveryMode: boolean): Promise<void>;\n  // Compare two documents, optionally including details of the changes.\n  compareDoc(\n    remoteDocId: string,\n    options?: { detail?: boolean; maxRows?: number | null }\n  ): Promise<DocStateComparison>;\n  // Compare two versions within a document, including details of the changes.\n  // Versions are identified by action hashes, or aliases understood by HashUtil.\n  // Currently, leftHash is expected to be an ancestor of rightHash.  If rightHash\n  // is HEAD, the result will contain a copy of any rows added or updated.\n  compareVersion(leftHash: string, rightHash: string): Promise<DocStateComparison>;\n  getDownloadUrl(options: { template: boolean, removeHistory: boolean }): string;\n  getDownloadXlsxUrl(params?: DownloadDocParams): string;\n  getDownloadCsvUrl(params: DownloadDocParams): string;\n  getDownloadTsvUrl(params: DownloadDocParams): string;\n  getDownloadDsvUrl(params: DownloadDocParams): string;\n  getDownloadTableSchemaUrl(params: DownloadDocParams): string;\n  getDownloadAttachmentsArchiveUrl(params: AttachmentsArchiveParams): string;\n\n  /**\n   * Exports current document to the Google Drive as a spreadsheet file. To invoke this method, first\n   * acquire \"code\" via Google Auth Endpoint (see ShareMenu.ts for an example).\n   * @param code Authorization code returned from Google (requested via Grist's Google Auth Endpoint)\n   * @param title Name of the spreadsheet that will be created (should use a Grist document's title)\n   */\n  sendToDrive(code: string, title: string): Promise<{ url: string }>;\n  // Upload a single attachment and return the resulting metadata row ID.\n  // The arguments are passed to FormData.append.\n  uploadAttachment(value: string | Blob, filename?: string): Promise<number>;\n  uploadAttachmentArchive(archive: string | Blob, filename?: string): Promise<ArchiveUploadResult>;\n\n  // Get users that are worth proposing to \"View As\" for access control purposes.\n  getUsersForViewAs(): Promise<PermissionDataWithExtraUsers>;\n\n  getWebhooks(): Promise<WebhookSummaryCollection>;\n  addWebhook(webhook: WebhookFields): Promise<{ webhookId: string }>;\n  removeWebhook(webhookId: string, tableId: string): Promise<void>;\n  // Update webhook\n  updateWebhook(webhook: WebhookUpdate): Promise<void>;\n  flushWebhooks(): Promise<void>;\n  flushWebhook(webhookId: string): Promise<void>;\n\n  getAssistance(params: AssistanceRequest): Promise<AssistanceResponse>;\n  /**\n   * Check if the document is currently in timing mode.\n   * Status is either\n   * - 'active' if timings are enabled.\n   * - 'pending' if timings are enabled but we can't get the data yet (as engine is blocked)\n   * - 'disabled' if timings are disabled.\n   */\n  timing(): Promise<TimingStatus>;\n  /**\n   * Starts recording timing information for the document. Throws exception if timing is already\n   * in progress or you don't have permission to start timing.\n   */\n  startTiming(): Promise<void>;\n  stopTiming(): Promise<FormulaTimingInfo[]>;\n  /**\n   * Starts the transfer of all attachments from the old attachment storage to the new one.\n   */\n  transferAllAttachments(): Promise<void>;\n  /**\n   * Returns the status of the attachment transfer.\n   */\n  getAttachmentTransferStatus(): Promise<AttachmentTransferStatus>;\n  /**\n   * Retries type of attachment storage used by the document.\n   */\n  getAttachmentStore(): Promise<{ type: AttachmentStore }>;\n  /**\n   * Sets the attachment storage used by the document.\n   */\n  setAttachmentStore(type: AttachmentStore): Promise<void>;\n  /**\n   * Lists available external attachment stores. For now it contains at most one store.\n   * If there is one store available it means that external storage is configured and can be used by this document.\n   */\n  getAttachmentStores(): Promise<{ stores: AttachmentStoreDesc[] }>;\n\n  makeProposal(options?: {\n    retracted?: boolean,\n  }): Promise<Proposal>;\n  getProposals(options?: {\n    outgoing?: boolean\n  }): Promise<{ proposals: Proposal[] }>;\n  applyProposal(proposalId: number): Promise<Proposal>;\n\n  applyUserActions(actions: UserAction[]): Promise<ApplyUAResult>;\n}\n\n// Operations that are supported by a doc worker.\nexport interface DocWorkerAPI {\n  readonly url: string;\n  importDocToWorkspace(uploadId: number, workspaceId: number, settings?: BrowserSettings): Promise<DocCreationInfo>;\n  upload(material: UploadType, filename?: string): Promise<number>;\n  downloadDoc(docId: string, template?: boolean): Promise<Response>;\n  copyDoc(docId: string, template?: boolean, name?: string): Promise<number>;\n}\n\nexport class UserAPIImpl extends BaseAPI implements UserAPI {\n  constructor(private _homeUrl: string, private _options: IOptions = {}) {\n    super(_options);\n  }\n\n  public forRemoved(): UserAPI {\n    const extraParameters = new Map<string, string>([[\"showRemoved\", \"1\"]]);\n    return new UserAPIImpl(this._homeUrl, { ...this._options, extraParameters });\n  }\n\n  public async getSessionActive(): Promise<ActiveSessionInfo> {\n    return this.requestJson(`${this._url}/api/session/access/active`, { method: \"GET\" });\n  }\n\n  public async setSessionActive(email: string, org?: string): Promise<void> {\n    const body = JSON.stringify({ email, org });\n    return this.requestJson(`${this._url}/api/session/access/active`, { method: \"POST\", body });\n  }\n\n  public async getSessionAll(): Promise<{ users: FullUser[], orgs: Organization[] }> {\n    return this.requestJson(`${this._url}/api/session/access/all`, { method: \"GET\" });\n  }\n\n  public async getOrgs(merged: boolean = false): Promise<Organization[]> {\n    return this.requestJson(`${this._url}/api/orgs?merged=${merged ? 1 : 0}`, { method: \"GET\" });\n  }\n\n  public async getWorkspace(workspaceId: number): Promise<Workspace> {\n    return this.requestJson(`${this._url}/api/workspaces/${workspaceId}`, { method: \"GET\" });\n  }\n\n  public async getOrg(orgId: number | string): Promise<Organization> {\n    return this.requestJson(`${this._url}/api/orgs/${orgId}`, { method: \"GET\" });\n  }\n\n  public async getOrgWorkspaces(orgId: number | string, includeSupport = true): Promise<Workspace[]> {\n    return this.requestJson(`${this._url}/api/orgs/${orgId}/workspaces?includeSupport=${includeSupport ? 1 : 0}`,\n      { method: \"GET\" });\n  }\n\n  public async getOrgUsageSummary(orgId: number | string): Promise<OrgUsageSummary> {\n    return this.requestJson(`${this._url}/api/orgs/${orgId}/usage`, { method: \"GET\" });\n  }\n\n  public async getTemplates(): Promise<Workspace[]> {\n    return this.requestJson(`${this._url}/api/templates`, { method: \"GET\" });\n  }\n\n  public async getTemplate(docId: string): Promise<Document> {\n    return this.requestJson(`${this._url}/api/templates/${docId}`, { method: \"GET\" });\n  }\n\n  public async getWidgets(): Promise<ICustomWidget[]> {\n    return await this.requestJson(`${this._url}/api/widgets`, { method: \"GET\" });\n  }\n\n  public async getDoc(docId: string): Promise<Document> {\n    return this.requestJson(`${this._url}/api/docs/${docId}`, { method: \"GET\" });\n  }\n\n  public async newOrg(props: Partial<OrganizationProperties>): Promise<number> {\n    return this.requestJson(`${this._url}/api/orgs`, {\n      method: \"POST\",\n      body: JSON.stringify(props),\n    });\n  }\n\n  public async newWorkspace(props: Partial<WorkspaceProperties>, orgId: number | string): Promise<number> {\n    return this.requestJson(`${this._url}/api/orgs/${orgId}/workspaces`, {\n      method: \"POST\",\n      body: JSON.stringify(props),\n    });\n  }\n\n  public async newDoc(props: Partial<DocumentProperties>, workspaceId: number): Promise<string> {\n    return this.requestJson(`${this._url}/api/workspaces/${workspaceId}/docs`, {\n      method: \"POST\",\n      body: JSON.stringify(props),\n    });\n  }\n\n  public async newUnsavedDoc(options: { timezone?: string } = {}): Promise<string> {\n    return this.requestJson(`${this._url}/api/docs`, {\n      method: \"POST\",\n      body: JSON.stringify(options),\n    });\n  }\n\n  public async copyDoc(\n    sourceDocumentId: string,\n    workspaceId: number,\n    options: CopyDocOptions,\n  ): Promise<string> {\n    return this.requestJson(`${this._url}/api/docs`, {\n      method: \"POST\",\n      body: JSON.stringify({\n        sourceDocumentId,\n        workspaceId,\n        ...options,\n      }),\n    });\n  }\n\n  public async renameOrg(orgId: number | string, name: string): Promise<void> {\n    await this.request(`${this._url}/api/orgs/${orgId}`, {\n      method: \"PATCH\",\n      body: JSON.stringify({ name }),\n    });\n  }\n\n  public async renameWorkspace(workspaceId: number, name: string): Promise<void> {\n    await this.request(`${this._url}/api/workspaces/${workspaceId}`, {\n      method: \"PATCH\",\n      body: JSON.stringify({ name }),\n    });\n  }\n\n  public async renameDoc(docId: string, name: string, { icon }: RenameDocOptions = {}): Promise<void> {\n    return this.updateDoc(docId, {\n      name,\n      ...(icon ? { options: { appearance: { icon } } } : undefined),\n    });\n  }\n\n  public async updateOrg(orgId: number | string, props: Partial<OrganizationProperties>): Promise<void> {\n    await this.request(`${this._url}/api/orgs/${orgId}`, {\n      method: \"PATCH\",\n      body: JSON.stringify(props),\n    });\n  }\n\n  public async updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void> {\n    await this.request(`${this._url}/api/docs/${docId}`, {\n      method: \"PATCH\",\n      body: JSON.stringify(props),\n    });\n  }\n\n  public async deleteOrg(orgId: number | string): Promise<void> {\n    await this.request(`${this._url}/api/orgs/${orgId}/force-delete`, { method: \"DELETE\" });\n  }\n\n  public async deleteWorkspace(workspaceId: number): Promise<void> {\n    await this.request(`${this._url}/api/workspaces/${workspaceId}`, { method: \"DELETE\" });\n  }\n\n  public async softDeleteWorkspace(workspaceId: number): Promise<void> {\n    await this.request(`${this._url}/api/workspaces/${workspaceId}/remove`, { method: \"POST\" });\n  }\n\n  public async undeleteWorkspace(workspaceId: number): Promise<void> {\n    await this.request(`${this._url}/api/workspaces/${workspaceId}/unremove`, { method: \"POST\" });\n  }\n\n  public async deleteDoc(docId: string): Promise<void> {\n    await this.request(`${this._url}/api/docs/${docId}`, { method: \"DELETE\" });\n  }\n\n  public async softDeleteDoc(docId: string): Promise<void> {\n    await this.request(`${this._url}/api/docs/${docId}/remove`, { method: \"POST\" });\n  }\n\n  public async undeleteDoc(docId: string): Promise<void> {\n    await this.request(`${this._url}/api/docs/${docId}/unremove`, { method: \"POST\" });\n  }\n\n  public async disableDoc(docId: string): Promise<void> {\n    await this.request(`${this._url}/api/docs/${docId}/disable`, { method: \"POST\" });\n  }\n\n  public async enableDoc(docId: string): Promise<void> {\n    await this.request(`${this._url}/api/docs/${docId}/enable`, { method: \"POST\" });\n  }\n\n  public async updateOrgPermissions(orgId: number | string, delta: PermissionDelta): Promise<void> {\n    await this.request(`${this._url}/api/orgs/${orgId}/access`, {\n      method: \"PATCH\",\n      body: JSON.stringify({ delta }),\n    });\n  }\n\n  public async updateWorkspacePermissions(workspaceId: number, delta: PermissionDelta): Promise<void> {\n    await this.request(`${this._url}/api/workspaces/${workspaceId}/access`, {\n      method: \"PATCH\",\n      body: JSON.stringify({ delta }),\n    });\n  }\n\n  public async updateDocPermissions(docId: string, delta: PermissionDelta): Promise<void> {\n    await this.request(`${this._url}/api/docs/${docId}/access`, {\n      method: \"PATCH\",\n      body: JSON.stringify({ delta }),\n    });\n  }\n\n  public async getOrgAccess(orgId: number | string): Promise<PermissionData> {\n    return this.requestJson(`${this._url}/api/orgs/${orgId}/access`, { method: \"GET\" });\n  }\n\n  public async getWorkspaceAccess(workspaceId: number): Promise<PermissionData> {\n    return this.requestJson(`${this._url}/api/workspaces/${workspaceId}/access`, { method: \"GET\" });\n  }\n\n  public async getDocAccess(docId: string): Promise<PermissionData> {\n    return this.requestJson(`${this._url}/api/docs/${docId}/access`, { method: \"GET\" });\n  }\n\n  public async pinDoc(docId: string): Promise<void> {\n    await this.request(`${this._url}/api/docs/${docId}/pin`, {\n      method: \"PATCH\",\n    });\n  }\n\n  public async unpinDoc(docId: string): Promise<void> {\n    await this.request(`${this._url}/api/docs/${docId}/unpin`, {\n      method: \"PATCH\",\n    });\n  }\n\n  public async moveDoc(docId: string, workspaceId: number): Promise<void> {\n    await this.request(`${this._url}/api/docs/${docId}/move`, {\n      method: \"PATCH\",\n      body: JSON.stringify({ workspace: workspaceId }),\n    });\n  }\n\n  public async getUserProfile(): Promise<FullUser> {\n    return this.requestJson(`${this._url}/api/profile/user`);\n  }\n\n  public async updateUserName(name: string): Promise<void> {\n    await this.request(`${this._url}/api/profile/user/name`, {\n      method: \"POST\",\n      body: JSON.stringify({ name }),\n    });\n  }\n\n  public async updateUserLocale(locale: string | null): Promise<void> {\n    await this.request(`${this._url}/api/profile/user/locale`, {\n      method: \"POST\",\n      body: JSON.stringify({ locale }),\n    });\n  }\n\n  public async updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void> {\n    await this.request(`${this._url}/api/profile/allowGoogleLogin`, {\n      method: \"POST\",\n      body: JSON.stringify({ allowGoogleLogin }),\n    });\n  }\n\n  public async updateIsConsultant(userId: number, isConsultant: boolean): Promise<void> {\n    await this.request(`${this._url}/api/profile/isConsultant`, {\n      method: \"POST\",\n      body: JSON.stringify({ userId, isConsultant }),\n    });\n  }\n\n  public async disableUser(userId: number): Promise<void> {\n    await this.request(`${this._url}/api/users/${userId}/disable`, {\n      method: \"POST\",\n    });\n  }\n\n  public async enableUser(userId: number): Promise<void> {\n    await this.request(`${this._url}/api/users/${userId}/enable`, {\n      method: \"POST\",\n    });\n  }\n\n  public async getWorker(key: string): Promise<string> {\n    const full = await this.getWorkerFull(key);\n    return getPublicDocWorkerUrl(this._homeUrl, full);\n  }\n\n  public async getWorkerFull(key: string): Promise<PublicDocWorkerUrlInfo> {\n    const json = (await this.requestJson(`${this._url}/api/worker/${key}`, {\n      method: \"GET\",\n      credentials: \"include\",\n    })) as PublicDocWorkerUrlInfo;\n    return json;\n  }\n\n  public async getWorkerAPI(key: string): Promise<DocWorkerAPI> {\n    const docUrl = this._urlWithOrg(await this.getWorker(key));\n    return new DocWorkerAPIImpl(docUrl, this._options);\n  }\n\n  public getBillingAPI(): BillingAPI {\n    return new BillingAPIImpl(this._url, this._options);\n  }\n\n  public getDocAPI(docId: string): DocAPI {\n    return new DocAPIImpl(this._url, docId, this._options);\n  }\n\n  public async fetchApiKey(): Promise<string> {\n    const resp = await this.request(`${this._url}/api/profile/apiKey`);\n    return await resp.text();\n  }\n\n  public async createApiKey(): Promise<string> {\n    const res = await this.request(`${this._url}/api/profile/apiKey`, {\n      method: \"POST\",\n    });\n    return await res.text();\n  }\n\n  public async deleteApiKey(): Promise<void> {\n    await this.request(`${this._url}/api/profile/apiKey`, {\n      method: \"DELETE\",\n    });\n  }\n\n  // This method is not strictly needed anymore, but is widely used by\n  // tests so supporting as a handy shortcut for getDocAPI(docId).getRows(tableName)\n  public async getTable(docId: string, tableName: string): Promise<TableColValues> {\n    return this.getDocAPI(docId).getRows(tableName);\n  }\n\n  public async applyUserActions(docId: string, actions: UserAction[]): Promise<ApplyUAResult> {\n    return this.requestJson(`${this._url}/api/docs/${docId}/apply`, {\n      method: \"POST\",\n      body: JSON.stringify(actions),\n    });\n  }\n\n  public async importUnsavedDoc(material: UploadType, options?: {\n    filename?: string,\n    timezone?: string,\n    onUploadProgress?: (ev: AxiosProgressEvent) => void,\n  }): Promise<string> {\n    options = options || {};\n    const formData = this.newFormData();\n    formData.append(\"upload\", material as any, options.filename);\n    if (options.timezone) { formData.append(\"timezone\", options.timezone); }\n    const resp = await this.requestAxios(`${this._url}/api/docs`, {\n      method: \"POST\",\n      data: formData,\n      onUploadProgress: options.onUploadProgress,\n      // On browser, it is important not to set Content-Type so that the browser takes care\n      // of setting HTTP headers appropriately.  Outside browser, requestAxios has logic\n      // for setting the HTTP headers.\n      headers: { ...this.defaultHeadersWithoutContentType() },\n    });\n    return resp.data;\n  }\n\n  public async deleteUser(userId: number, name: string) {\n    await this.request(`${this._url}/api/users/${userId}`,\n      { method: \"DELETE\",\n        body: JSON.stringify({ name }) });\n  }\n\n  public async closeAccount(userId: number): Promise<boolean> {\n    return await this.requestJson(`${this._url}/api/doom/account?userid=` + userId, { method: \"DELETE\" });\n  }\n\n  public async closeOrg() {\n    await this.request(`${this._url}/api/doom/org`, { method: \"DELETE\" });\n  }\n\n  public getBaseUrl(): string { return this._url; }\n\n  // Recomputes the URL on every call to pick up changes in the URL when switching orgs.\n  // (Feels inefficient, but probably doesn't matter, and it's simpler than the alternatives.)\n  private get _url(): string {\n    return this._urlWithOrg(this._homeUrl);\n  }\n\n  private _urlWithOrg(base: string): string {\n    return isClient() ? addCurrentOrgToPath(base) : base.replace(/\\/$/, \"\");\n  }\n}\n\nexport class DocWorkerAPIImpl extends BaseAPI implements DocWorkerAPI {\n  constructor(public readonly url: string, _options: IOptions = {}) {\n    super(_options);\n  }\n\n  public async importDocToWorkspace(uploadId: number, workspaceId: number, browserSettings?: BrowserSettings):\n  Promise<DocCreationInfo> {\n    return this.requestJson(`${this.url}/api/workspaces/${workspaceId}/import`, {\n      method: \"POST\",\n      body: JSON.stringify({ uploadId, browserSettings }),\n    });\n  }\n\n  public async upload(material: UploadType, filename?: string): Promise<number> {\n    const formData = this.newFormData();\n    formData.append(\"upload\", material as any, filename);\n    const json = await this.requestJson(`${this.url}/uploads`, {\n      // On browser, it is important not to set Content-Type so that the browser takes care\n      // of setting HTTP headers appropriately.  Outside of browser, node-fetch also appears\n      // to take care of this - https://github.github.io/fetch/#request-body\n      headers: { ...this.defaultHeadersWithoutContentType() },\n      method: \"POST\",\n      body: formData,\n    });\n    return json.uploadId;\n  }\n\n  public async downloadDoc(docId: string, template: boolean = false): Promise<Response> {\n    const extra = template ? \"?template=1\" : \"\";\n    const result = await this.request(`${this.url}/api/docs/${docId}/download${extra}`, {\n      method: \"GET\",\n    });\n    if (!result.ok) { throw new Error(await result.text()); }\n    return result;\n  }\n\n  public async copyDoc(docId: string, template: boolean = false, name?: string): Promise<number> {\n    const url = new URL(`${this.url}/copy?doc=${docId}`);\n    if (template) {\n      url.searchParams.append(\"template\", \"1\");\n    }\n    if (name) {\n      url.searchParams.append(\"name\", name);\n    }\n    const json = await this.requestJson(url.href, {\n      method: \"POST\",\n    });\n    return json.uploadId;\n  }\n}\n\nexport class DocAPIImpl extends BaseAPI implements DocAPI {\n  private _url: string;\n\n  constructor(url: string, public readonly docId: string, options: IOptions = {}) {\n    super(options);\n    this._url = `${url}/api/docs/${docId}`;\n  }\n\n  public getBaseUrl(): string { return this._url; }\n\n  public async getTables(options?: GetTablesParams): Promise<TablesGet> {\n    const url = new URL(`${this._url}/tables`);\n    if (options?.expand) {\n      url.searchParams.set(\"expand\", options.expand.join(\",\"));\n    }\n    return this.requestJson(url.href);\n  }\n\n  public async getRows(tableId: string, options?: GetRowsParams): Promise<TableColValues> {\n    return this._getRecords(tableId, \"data\", options);\n  }\n\n  public async getRecords(tableId: string, options?: GetRowsParams): Promise<TableRecordValue[]> {\n    const response: TableRecordValues = await this._getRecords(tableId, \"records\", options);\n    return response.records;\n  }\n\n  public async sql(sql: string, args?: any[]): Promise<SqlResult> {\n    return this.requestJson(`${this._url}/sql`, {\n      body: JSON.stringify({\n        sql,\n        ...(args ? { args } : {}),\n      }),\n      method: \"POST\",\n    });\n  }\n\n  public async updateRows(tableId: string, changes: TableColValues): Promise<number[]> {\n    return this.requestJson(`${this._url}/tables/${tableId}/data`, {\n      body: JSON.stringify(changes),\n      method: \"PATCH\",\n    });\n  }\n\n  /**\n   * Adds rows to a table.\n   *\n   * Example:\n   * ```typescript\n   * const newRowIds = await docApi.addRows(\"tableId\", {\n   *   \"Column1\": [value1, value2],\n   *   \"Column2\": [value3, value4],\n   * });\n   * ```\n   * @param tableId Table ID to add rows to.\n   * @param additions JSON object with column values for the new rows.\n   * @returns Array of new row IDs.\n   */\n  public async addRows(tableId: string, additions: BulkColValues): Promise<number[]> {\n    return this.requestJson(`${this._url}/tables/${tableId}/data`, {\n      body: JSON.stringify(additions),\n      method: \"POST\",\n    });\n  }\n\n  public async removeRows(tableId: string, removals: number[]): Promise<number[]> {\n    return this.requestJson(`${this._url}/tables/${tableId}/records/delete`, {\n      body: JSON.stringify(removals),\n      method: \"POST\",\n    });\n  }\n\n  public async fork(): Promise<ForkResult> {\n    return this.requestJson(`${this._url}/fork`, {\n      method: \"POST\",\n    });\n  }\n\n  public async replace(source: DocReplacementOptions): Promise<void> {\n    return this.requestJson(`${this._url}/replace`, {\n      body: JSON.stringify(source),\n      method: \"POST\",\n    });\n  }\n\n  public async getSnapshots(raw?: boolean): Promise<DocSnapshots> {\n    return this.requestJson(`${this._url}/snapshots?raw=${raw}`);\n  }\n\n  public async removeSnapshots(snapshotIds: string[] | \"unlisted\" | \"past\") {\n    const body = typeof snapshotIds === \"string\" ? { select: snapshotIds } : { snapshotIds };\n    return await this.requestJson(`${this._url}/snapshots/remove`, {\n      method: \"POST\",\n      body: JSON.stringify(body),\n    });\n  }\n\n  public async getStates(): Promise<DocStates> {\n    return this.requestJson(`${this._url}/states`);\n  }\n\n  public async getUsersForViewAs(): Promise<PermissionDataWithExtraUsers> {\n    return this.requestJson(`${this._url}/usersForViewAs`);\n  }\n\n  public async getWebhooks(): Promise<WebhookSummaryCollection> {\n    return this.requestJson(`${this._url}/webhooks`);\n  }\n\n  public async addWebhook(webhook: WebhookSubscribe & { tableId: string }): Promise<{ webhookId: string }> {\n    const { tableId } = webhook;\n    return this.requestJson(`${this._url}/tables/${tableId}/_subscribe`, {\n      method: \"POST\",\n      body: JSON.stringify(\n        omitBy(webhook, (val, key) => key === \"tableId\" || val === null)),\n    });\n  }\n\n  public async updateWebhook(webhook: WebhookUpdate): Promise<void> {\n    return this.requestJson(`${this._url}/webhooks/${webhook.id}`, {\n      method: \"PATCH\",\n      body: JSON.stringify(webhook.fields),\n    });\n  }\n\n  public removeWebhook(webhookId: string, tableId: string) {\n    // unsubscribeKey is not required for owners\n    const unsubscribeKey = \"\";\n    return this.requestJson(`${this._url}/tables/${tableId}/_unsubscribe`, {\n      method: \"POST\",\n      body: JSON.stringify({ webhookId, unsubscribeKey }),\n    });\n  }\n\n  public async flushWebhooks(): Promise<void> {\n    await this.request(`${this._url}/webhooks/queue`, {\n      method: \"DELETE\",\n    });\n  }\n\n  public async flushWebhook(id: string): Promise<void> {\n    await this.request(`${this._url}/webhooks/queue/${id}`, {\n      method: \"DELETE\",\n    });\n  }\n\n  public async forceReload(): Promise<void> {\n    await this.request(`${this._url}/force-reload`, {\n      method: \"POST\",\n    });\n  }\n\n  public async recover(recoveryMode: boolean): Promise<void> {\n    await this.request(`${this._url}/recover`, {\n      body: JSON.stringify({ recoveryMode }),\n      method: \"POST\",\n    });\n  }\n\n  public async compareDoc(\n    remoteDocId: string,\n    options: {\n      detail?: boolean;\n      maxRows?: number | null;\n    } = {},\n  ): Promise<DocStateComparison> {\n    const { detail, maxRows } = options;\n    const url = new URL(`${this._url}/compare/${remoteDocId}`);\n    if (detail) {\n      url.searchParams.set(\"detail\", \"true\");\n    }\n    if (maxRows !== undefined) {\n      url.searchParams.set(\"maxRows\", String(maxRows));\n    }\n    return this.requestJson(url.href);\n  }\n\n  public async copyDoc(workspaceId: number, options: CopyDocOptions): Promise<string> {\n    const { documentName, asTemplate } = options;\n    return this.requestJson(`${this._url}/copy`, {\n      body: JSON.stringify({ workspaceId, documentName, asTemplate }),\n      method: \"POST\",\n    });\n  }\n\n  public async compareVersion(leftHash: string, rightHash: string): Promise<DocStateComparison> {\n    const url = new URL(`${this._url}/compare`);\n    url.searchParams.append(\"left\", leftHash);\n    url.searchParams.append(\"right\", rightHash);\n    return this.requestJson(url.href);\n  }\n\n  public getDownloadUrl({ template, removeHistory}: { template: boolean, removeHistory: boolean }): string {\n    return this._url + `/download?template=${template}&nohistory=${removeHistory}`;\n  }\n\n  public getDownloadXlsxUrl(params: DownloadDocParams) {\n    return this._url + \"/download/xlsx?\" + encodeQueryParams({ ...params });\n  }\n\n  public getDownloadCsvUrl(params: DownloadDocParams) {\n    // We spread `params` to work around TypeScript being overly cautious.\n    return this._url + \"/download/csv?\" + encodeQueryParams({ ...params });\n  }\n\n  public getDownloadTsvUrl(params: DownloadDocParams) {\n    return this._url + \"/download/tsv?\" + encodeQueryParams({ ...params });\n  }\n\n  public getDownloadDsvUrl(params: DownloadDocParams) {\n    return this._url + \"/download/dsv?\" + encodeQueryParams({ ...params });\n  }\n\n  public getDownloadTableSchemaUrl(params: DownloadDocParams) {\n    // We spread `params` to work around TypeScript being overly cautious.\n    return this._url + \"/download/table-schema?\" + encodeQueryParams({ ...params });\n  }\n\n  public getDownloadAttachmentsArchiveUrl(params: AttachmentsArchiveParams): string {\n    return this._url + \"/attachments/archive?\" + encodeQueryParams({ ...params });\n  }\n\n  public async sendToDrive(code: string, title: string): Promise<{ url: string }> {\n    const url = new URL(`${this._url}/send-to-drive`);\n    url.searchParams.append(\"title\", title);\n    url.searchParams.append(\"code\", code);\n    return this.requestJson(url.href);\n  }\n\n  public async uploadAttachment(value: string | Blob, filename?: string): Promise<number> {\n    const formData = this.newFormData();\n    formData.append(\"upload\", value, filename);\n    const response = await this.requestAxios(`${this._url}/attachments`, {\n      method: \"POST\",\n      data: formData,\n      // On browser, it is important not to set Content-Type so that the browser takes care\n      // of setting HTTP headers appropriately.  Outside browser, requestAxios has logic\n      // for setting the HTTP headers.\n      headers: { ...this.defaultHeadersWithoutContentType() },\n    });\n    return response.data[0];\n  }\n\n  public async uploadAttachmentArchive(archive: string | Blob, filename?: string): Promise<ArchiveUploadResult> {\n    const formData = this.newFormData();\n    formData.append(\"upload\", archive, filename);\n    const response = await this.requestAxios(`${this._url}/attachments/archive`, {\n      method: \"POST\",\n      data: formData,\n      // On the browser, Content-Type shouldn't be set as it prevents the browser from setting\n      // Content-Type with the correct boundary expression to delimit form fields.\n      // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects#sending_files_using_a_formdata_object\n      // Therefore we omit Content-Type, and allow Axios to handle it as it sees fit - which works\n      // correctly in the browser and in Node.\n      headers: { ...this.defaultHeadersWithoutContentType() },\n    });\n    return response.data;\n  }\n\n  public async getAssistance(\n    params: AssistanceRequest,\n  ): Promise<AssistanceResponse> {\n    return await this.requestJson(`${this._url}/assistant`, {\n      method: \"POST\",\n      body: JSON.stringify(params),\n    });\n  }\n\n  public async timing(): Promise<TimingStatus> {\n    return this.requestJson(`${this._url}/timing`);\n  }\n\n  public async startTiming(): Promise<void> {\n    await this.request(`${this._url}/timing/start`, { method: \"POST\" });\n  }\n\n  public async stopTiming(): Promise<FormulaTimingInfo[]> {\n    return await this.requestJson(`${this._url}/timing/stop`, { method: \"POST\" });\n  }\n\n  public async transferAllAttachments(): Promise<void> {\n    await this.request(`${this._url}/attachments/transferAll`, { method: \"POST\" });\n  }\n\n  public async getAttachmentTransferStatus(): Promise<AttachmentTransferStatus> {\n    return this.requestJson(`${this._url}/attachments/transferStatus`);\n  }\n\n  public async getAttachmentStore(): Promise<{ type: AttachmentStore }> {\n    return this.requestJson(`${this._url}/attachments/store`);\n  }\n\n  public async getAttachmentStores(): Promise<{ stores: AttachmentStoreDesc[] }> {\n    return this.requestJson(`${this._url}/attachments/stores`);\n  }\n\n  public async setAttachmentStore(type: AttachmentStore): Promise<void> {\n    await this.request(`${this._url}/attachments/store`, {\n      method: \"POST\",\n      body: JSON.stringify({ type }),\n    });\n  }\n\n  public async makeProposal(options?: {\n    retracted?: boolean,\n  }) {\n    return this.requestJson(`${this._url}/propose`, {\n      method: \"POST\",\n      body: JSON.stringify(options || {}),\n    });\n  }\n\n  public async applyProposal(proposalId: number) {\n    return this.requestJson(`${this._url}/proposals/${proposalId}/apply`, {\n      method: \"POST\",\n    });\n  }\n\n  public async applyUserActions(actions: UserAction[]): Promise<ApplyUAResult> {\n    return this.requestJson(`${this._url}/apply`, {\n      method: \"POST\",\n      body: JSON.stringify(actions),\n    });\n  }\n\n  public async getProposals(options?: {\n    outgoing?: boolean,\n  }) {\n    const result = await this.requestJson(`${this._url}/proposals?outgoing=${Boolean(options?.outgoing)}`, {\n      method: \"GET\",\n    });\n    return result;\n  }\n\n  private _getRecords(tableId: string, endpoint: \"data\" | \"records\", options?: GetRowsParams): Promise<any> {\n    const url = new URL(`${this._url}/tables/${tableId}/${endpoint}`);\n    if (options?.filters) {\n      url.searchParams.append(\"filter\", JSON.stringify(options.filters));\n    }\n    if (options?.immediate) {\n      url.searchParams.append(\"immediate\", \"true\");\n    }\n    return this.requestJson(url.href);\n  }\n}\n\nexport interface AttachmentTransferStatus {\n  status: {\n    pendingTransferCount: number;\n    isRunning: boolean;\n\n    // Count of failures and successes since starting a\n    // transfer of all files.\n    failures: number;\n    successes: number;\n  };\n  locationSummary: DocAttachmentsLocation;\n}\n\n/**\n * Represents information to build public doc worker url.\n *\n * Structure that may contain either **exclusively**:\n *  - a selfPrefix when no pool of doc worker exist.\n *  - a public doc worker url otherwise.\n */\nexport type PublicDocWorkerUrlInfo = {\n  selfPrefix: string;\n  docWorkerUrl: null;\n  docWorkerId: null;\n} | {\n  selfPrefix: null;\n  docWorkerUrl: string;\n  docWorkerId: string;\n};\n\nexport function getUrlFromPrefix(homeUrl: string, prefix: string) {\n  const url = new URL(homeUrl);\n  url.pathname = prefix + url.pathname;\n  return url.href;\n}\n\n/**\n * Get a docWorkerUrl from information returned from backend. When the backend\n * is fully configured, and there is a pool of workers, this is straightforward,\n * just return the docWorkerUrl reported by the backend. For single-instance\n * installs, the backend returns a null docWorkerUrl, and a client can simply\n * use the homeUrl of the backend, with extra path prefix information\n * given by selfPrefix. At the time of writing, the selfPrefix contains a\n * doc-worker id, and a tag for the codebase (used in consistency checks).\n *\n * @param {string} homeUrl\n * @param {string} docWorkerInfo The information to build the public doc worker url\n *                               (result of the call to /api/worker/:docId)\n */\nexport function getPublicDocWorkerUrl(homeUrl: string, docWorkerInfo: PublicDocWorkerUrlInfo) {\n  return docWorkerInfo.selfPrefix !== null ?\n    getUrlFromPrefix(homeUrl, docWorkerInfo.selfPrefix) :\n    docWorkerInfo.docWorkerUrl;\n}\n"
  },
  {
    "path": "app/common/UserConfig.ts",
    "content": "/*\n * Interface for the user's config found in config.json.\n */\nexport interface UserConfig {\n  docListSortBy?: string;\n  docListSortDir?: number;\n  features?: ISupportedFeatures;\n\n  /*\n   * The host serving the untrusted content: on dev environment could be\n   * \"http://getgrist.localtest.me\". Port is added at runtime and should not be included.\n   */\n  untrustedContentOrigin?: string;\n}\n\nexport interface ISupportedFeatures {\n  signin?: boolean;\n  sharing?: boolean;\n  proxy?: boolean;  // If true, Grist will accept login information via http headers\n  // X-Forwarded-User and X-Forwarded-Email.  Set to true only if\n  // Grist is behind a reverse proxy that is managing those headers,\n  // otherwise they could be spoofed.\n  formulaBar?: boolean;\n\n  // Plugin views, and REPL all need work, but are exposed here to allow existing\n  // tests to continue running. These only affect client-side code.\n  customViewPlugin?: boolean;\n}\n"
  },
  {
    "path": "app/common/ValueConverter.ts",
    "content": "import { TableDataActionSet } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { isList } from \"app/common/gristTypes\";\nimport { BaseFormatter, createFullFormatterFromDocData } from \"app/common/ValueFormatter\";\nimport {\n  createParserOrFormatterArgumentsRaw,\n  createParserRaw,\n  ReferenceListParser,\n  ReferenceParser,\n  ValueParser,\n} from \"app/common/ValueParser\";\nimport { CellValue, GristObjCode } from \"app/plugin/GristData\";\n\n/**\n * Base class for converting values from one type to another with the convert() method.\n * Has a formatter for the source column\n * and a parser for the destination column.\n *\n * The default convert() is for non-list destination types, so if the source value\n * is a list it only converts nicely if the list contains exactly one element.\n */\nexport class ValueConverter {\n  private _isTargetText: boolean = [\"Text\", \"Choice\"].includes(this.parser.type);\n\n  constructor(public formatter: BaseFormatter, public parser: ValueParser) {\n  }\n\n  public convert(value: any): any {\n    if (isList(value)) {\n      if (value.length === 1) {\n        // Empty list: ['L']\n        return null;\n      } else if (value.length > 2 || this._isTargetText) {\n        // List with multiple values, or the target type is text.\n        // Since we're converting to just one value,\n        // format the whole thing as text, which is an error for most types.\n        return this.formatter.formatAny(value);\n      } else {\n        // Singleton list: ['L', value]\n        // Convert just that one value.\n        value = value[1];\n      }\n    }\n    return this.convertInner(value);\n  }\n\n  protected convertInner(value: any): any {\n    const formatted = this.formatter.formatAny(value);\n    return this.parser.cleanParse(formatted);\n  }\n}\n\n/**\n * Base class for converting to a list type (Reference List or Choice List).\n *\n * Wraps single values in a list, and converts lists elementwise.\n */\nclass ListConverter extends ValueConverter {\n  // Don't parse strings like \"Smith, John\" which may look like lists but represent a single choice.\n  // TODO this works when the source is a Choice column, but not when it's a Reference to a Choice column.\n  //   But the guessed choices are also broken in that case.\n  private _choices = new Set<string>((this.formatter.widgetOpts as any).choices || []);\n\n  public convert(value: any): any {\n    if (typeof value === \"string\" && !this._choices.has(value)) {\n      // Parse CSV/JSON\n      return this.parser.cleanParse(value);\n    }\n    const values = isList(value) ? value.slice(1) : [value];\n    if (!values.length || value == null) {\n      return null;\n    }\n    return this.handleValues(value, values.map(v => this.convertInner(v)));\n  }\n\n  protected handleValues(originalValue: any, values: any[]) {\n    return [\"L\", ...values];\n  }\n}\n\nclass ChoiceListConverter extends ListConverter {\n  /**\n   * Convert each source value to a 'Choice'\n   */\n  protected convertInner(value: any): any {\n    return this.formatter.formatAny(value);\n  }\n}\n\nclass ReferenceListConverter extends ListConverter {\n  private _innerConverter = new ReferenceConverter(\n    this.formatter,\n    new ReferenceParser(\"Ref\", this.parser.widgetOpts, this.parser.docSettings),\n  );\n\n  constructor(public formatter: BaseFormatter, public parser: ReferenceListParser) {\n    super(formatter, parser);\n    // Prevent the parser from looking up reference values in the frontend.\n    // Leave it to the data engine which has a much more efficient algorithm for long lists of values.\n    delete parser.tableData;\n  }\n\n  public handleValues(originalValue: any, values: any[]): any {\n    const result = [];\n    let lookupColumn: string = \"\";\n    const raw = this.formatter.formatAny(originalValue);  // AltText if the reference lookup fails\n    for (const value of values) {\n      if (typeof value === \"string\") {\n        // Failed to parse one of the references, so return a raw string for the whole thing\n        return raw;\n      } else {\n        // value is a lookup tuple: ['l', value, options]\n        result.push(value[1]);\n        lookupColumn = value[2].column;\n      }\n    }\n    return [\"l\", result, { column: lookupColumn, raw }];\n  }\n\n  /**\n   * Convert each source value to a 'Reference'\n   */\n  protected convertInner(value: any): any {\n    return this._innerConverter.convert(value);\n  }\n}\n\nclass ReferenceConverter extends ValueConverter {\n  private _innerConverter: ValueConverter = createConverter(this.formatter, this.parser.visibleColParser);\n\n  constructor(public formatter: BaseFormatter, public parser: ReferenceParser) {\n    super(formatter, parser);\n    // Prevent the parser from looking up reference values in the frontend.\n    // Leave it to the data engine which has a much more efficient algorithm for long lists of values.\n    delete parser.tableData;\n  }\n\n  protected convertInner(value: any): any {\n    // Convert to the type of the visible column.\n    const converted = this._innerConverter.convert(value);\n    return this.parser.lookup(converted, this.formatter.formatAny(value));\n  }\n}\n\nclass NumericConverter extends ValueConverter {\n  protected convertInner(value: any): any {\n    if (typeof value === \"boolean\") {\n      return value ? 1 : 0;\n    }\n    return super.convertInner(value);\n  }\n}\n\nclass DateConverter extends ValueConverter {\n  private _sourceType = gristTypes.extractInfoFromColType(this.formatter.type);\n\n  protected convertInner(value: any): any {\n    // When converting Date->DateTime, DateTime->Date, or between DateTime timezones,\n    // it's important to send an encoded Date/DateTime object rather than just a timestamp number\n    // so that the data engine knows what to do in do_convert, especially regarding timezones.\n    // If the source column is a Reference to a Date/DateTime then `value` is already\n    // an encoded object from the display column which has type Any.\n    value = gristTypes.reencodeAsTypedCellValue(value, this._sourceType);\n    if (Array.isArray(value) && (\n      value[0] === GristObjCode.Date ||\n      value[0] === GristObjCode.DateTime\n    )) {\n      return value;\n    }\n    return super.convertInner(value);\n  }\n}\n\nexport const valueConverterClasses: { [type: string]: typeof ValueConverter } = {\n  Date: DateConverter,\n  DateTime: DateConverter,\n  ChoiceList: ChoiceListConverter,\n  Ref: ReferenceConverter,\n  RefList: ReferenceListConverter,\n  Numeric: NumericConverter,\n  Int: NumericConverter,\n};\n\nexport function createConverter(formatter: BaseFormatter, parser: ValueParser) {\n  const cls = valueConverterClasses[gristTypes.extractTypeFromColType(parser.type)] || ValueConverter;\n  return new cls(formatter, parser);\n}\n\n/**\n * Used by the ConvertFromColumn user action in the data engine.\n * The higher order function separates docData (passed by ActiveDoc)\n * from the arguments passed to call_external in Python.\n */\nexport function convertFromColumn(\n  metaTables: TableDataActionSet,\n  sourceColRef: number,\n  type: string,\n  widgetOpts: string,\n  visibleColRef: number,\n  values: readonly CellValue[],\n  displayColValues?: readonly CellValue[],\n): CellValue[] {\n  const docData = new DocData(\n    (_tableId) => { throw new Error(\"Unexpected DocData fetch\"); },\n    metaTables,\n  );\n\n  const formatter = createFullFormatterFromDocData(docData, sourceColRef);\n  const parser = createParserRaw(\n    ...createParserOrFormatterArgumentsRaw(docData, type, widgetOpts, visibleColRef),\n  );\n  const converter = createConverter(formatter, parser);\n  return convertValues(converter, values, displayColValues || values);\n}\n\nexport function convertValues(\n  converter: ValueConverter,\n  // Raw values from the actual column, e.g. row IDs for reference columns\n  values: readonly CellValue[],\n  // Values from the display column, which is the same as the raw values for non-referencing columns.\n  // In almost all cases these are the values that actually matter and get converted.\n  displayColValues: readonly CellValue[],\n): CellValue[] {\n  // Converting Ref <-> RefList without changing the target table is a special case - see prepTransformColInfo.\n  // In this case we deal with the actual row IDs stored in the real column,\n  // whereas in all other cases we use display column values.\n  const sourceType = gristTypes.extractInfoFromColType(converter.formatter.type);\n  const targetType = gristTypes.extractInfoFromColType(converter.parser.type);\n  const refToRefList = (\n    sourceType.type === \"Ref\" &&\n    targetType.type === \"RefList\" &&\n    sourceType.tableId === targetType.tableId\n  );\n  const refListToRef = (\n    sourceType.type === \"RefList\" &&\n    targetType.type === \"Ref\" &&\n    sourceType.tableId === targetType.tableId\n  );\n\n  return displayColValues.map((displayVal, i) => {\n    const actualValue = values[i];\n\n    if (refToRefList && typeof actualValue === \"number\") {\n      if (actualValue === 0) {\n        return null;\n      } else {\n        return [\"L\", actualValue];\n      }\n    } else if (refListToRef && isList(actualValue)) {\n      if (actualValue.length === 1) {\n        // Empty list: ['L']\n        return 0;\n      } else if (actualValue.length === 2) {\n        // Singleton list: ['L', rowId]\n        return actualValue[1];\n      }\n    }\n\n    return converter.convert(displayVal);\n  });\n}\n"
  },
  {
    "path": "app/common/ValueFormatter.ts",
    "content": "import { csvEncodeRow } from \"app/common/csvFormat\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { getReferencedTableId, isList } from \"app/common/gristTypes\";\nimport * as gutil from \"app/common/gutil\";\nimport { isHiddenTable } from \"app/common/isHiddenTable\";\nimport { buildNumberFormat, NumberFormatOptions } from \"app/common/NumberFormat\";\nimport { createParserOrFormatterArguments, ReferenceParsingOptions } from \"app/common/ValueParser\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { decodeObject, GristDateTime } from \"app/plugin/objtypes\";\n\nimport isPlainObject from \"lodash/isPlainObject\";\nimport moment from \"moment-timezone\";\n\nexport { PENDING_DATA_PLACEHOLDER } from \"app/plugin/objtypes\";\n\nexport interface FormatOptions {\n  [option: string]: any;\n}\n\n/**\n * Formats a value of any type generically (with no type-specific options).\n */\nexport function formatUnknown(value: CellValue): string {\n  return formatDecoded(decodeObject(value));\n}\n\n/**\n * Returns true if the array contains other arrays or structured objects,\n * indicating that the list should be formatted like JSON rather than CSV.\n */\nfunction hasNestedObjects(value: any[]) {\n  return value.some(v => typeof v === \"object\" && v && (Array.isArray(v) || isPlainObject(v)));\n}\n\n/**\n * Formats a decoded Grist value for displaying it. For top-level values, formats them the way we\n * like to see them in a cell or in, say, CSV export.\n * For top-level lists containing only simple values like strings and dates, formats them as a CSV row.\n * Nested lists and objects are formatted slightly differently, with quoted strings and ISO format for dates.\n */\nexport function formatDecoded(value: unknown, isTopLevel: boolean = true): string {\n  if (typeof value === \"object\" && value) {\n    if (Array.isArray(value)) {\n      if (!isTopLevel || hasNestedObjects(value)) {\n        return \"[\" + value.map(v => formatDecoded(v, false)).join(\", \") + \"]\";\n      } else {\n        return csvEncodeRow(value.map(v => formatDecoded(v, true)), { prettier: true });\n      }\n    } else if (isPlainObject(value)) {\n      const obj: any = value;\n      const items = Object.keys(obj).map(k => `${JSON.stringify(k)}: ${formatDecoded(obj[k], false)}`);\n      return \"{\" + items.join(\", \") + \"}\";\n    } else if (isTopLevel && value instanceof GristDateTime) {\n      return moment(value).tz(value.timezone).format(\"YYYY-MM-DD HH:mm:ssZ\");\n    }\n    return String(value);\n  }\n  if (isTopLevel) {\n    return (value == null ? \"\" : String(value));\n  }\n  return JSON.stringify(value);\n}\n\nexport type IsRightTypeFunc = (value: CellValue) => boolean;\n\nexport class BaseFormatter {\n  protected isRightType: IsRightTypeFunc;\n\n  constructor(public type: string, public widgetOpts: FormatOptions, public docSettings: DocumentSettings) {\n    this.isRightType = gristTypes.isRightType(gristTypes.extractTypeFromColType(type)) ||\n      gristTypes.isRightType(\"Any\")!;\n  }\n\n  /**\n   * Formats using this.format() if a value is of the right type for this formatter, or using\n   * AnyFormatter otherwise. This method the recommended API. There is no need to override it.\n   */\n  public formatAny(value: any, translate?: (val: string) => string): string {\n    return this.isRightType(value) ? this.format(value, translate) : formatUnknown(value);\n  }\n\n  /**\n   * Formats a value that matches the type of this formatter. This should be overridden by derived\n   * classes to handle values in formatter-specific ways.\n   */\n  protected format(value: any, _translate?: (val: string) => string): string {\n    return String(value);\n  }\n}\n\nexport class BoolFormatter extends BaseFormatter {\n  public format(value: boolean | 0 | 1, translate?: (val: string) => string): string {\n    if (typeof value === \"boolean\" && translate) {\n      return translate(String(value));\n    }\n    return super.format(value, translate);\n  }\n}\n\nclass AnyFormatter extends BaseFormatter {\n  public format(value: any): string {\n    return formatUnknown(value);\n  }\n}\n\nexport class NumericFormatter extends BaseFormatter {\n  private _numFormat: Intl.NumberFormat;\n  private _formatter: (val: number) => string;\n\n  constructor(type: string, options: NumberFormatOptions, docSettings: DocumentSettings) {\n    super(type, options, docSettings);\n    this._numFormat = buildNumberFormat(options, docSettings);\n    this._formatter = (options.numSign === \"parens\") ? this._formatParens : this._formatPlain;\n  }\n\n  public format(value: any): string {\n    return value === null ? \"\" : this._formatter(value);\n  }\n\n  public _formatPlain(value: number): string {\n    return this._numFormat.format(value);\n  }\n\n  public _formatParens(value: number): string {\n    // Surround positive numbers with spaces to align them visually to parenthesized numbers.\n    return (value >= 0) ?\n      ` ${this._numFormat.format(value)} ` :\n      `(${this._numFormat.format(-value)})`;\n  }\n}\n\nclass IntFormatter extends NumericFormatter {\n  constructor(type: string, opts: FormatOptions, docSettings: DocumentSettings) {\n    super(type, { decimals: 0, ...opts }, docSettings);\n  }\n}\n\nexport interface DateFormatOptions {\n  dateFormat?: string;\n}\n\nclass DateFormatter extends BaseFormatter {\n  protected _dateTimeFormat: string;\n  private _timezone: string;\n\n  constructor(type: string, widgetOpts: DateFormatOptions, docSettings: DocumentSettings, timezone: string = \"UTC\") {\n    super(type, widgetOpts, docSettings);\n    // Allow encoded dates/datetimes ([d, number] or [D, number, timezone])\n    // which are found in formula columns of type Any,\n    // particularly reference display columns which are formatted here according to the visible column\n    // which will have the correct column type and options.\n    // Since these encoded objects are not expected in a Date/Datetime column and require\n    // being handled differently from just a number,\n    // we don't change `gristTypes.isRightType` which is used elsewhere.\n    this.isRightType = (value: any) => (\n      value === null ||\n      typeof value === \"number\" ||\n      Array.isArray(value) && (\n        value[0] === GristObjCode.Date ||\n        value[0] === GristObjCode.DateTime\n      )\n    );\n    this._dateTimeFormat = widgetOpts.dateFormat || \"YYYY-MM-DD\";\n    this._timezone = timezone;\n  }\n\n  public format(value: any): string {\n    if (value === null) {\n      return \"\";\n    }\n\n    // For a DateTime object in an Any column, use the provided timezone (`value[2]`)\n    // Otherwise use the timezone configured for a DateTime column.\n    let timezone = this._timezone;\n    if (Array.isArray(value)) {\n      timezone = value[2] || timezone;\n      value = value[1];\n    }\n    // Now `value` is a number\n\n    const time = moment.tz(value * 1000, timezone);\n    return time.format(this._dateTimeFormat);\n  }\n}\n\nexport interface DateTimeFormatOptions extends DateFormatOptions {\n  timeFormat?: string;\n}\n\nclass DateTimeFormatter extends DateFormatter {\n  constructor(type: string, widgetOpts: DateTimeFormatOptions, docSettings: DocumentSettings) {\n    const timezone = gutil.removePrefix(type, \"DateTime:\") || \"\";\n    // Pass up the original widgetOpts. It's helpful to have them available; e.g. ExcelFormatter\n    // takes options from an initialized ValueFormatter.\n    super(type, widgetOpts, docSettings, timezone);\n    const timeFormat = widgetOpts.timeFormat === undefined ? \"h:mma\" : widgetOpts.timeFormat;\n    this._dateTimeFormat = (widgetOpts.dateFormat || \"YYYY-MM-DD\") + \" \" + timeFormat;\n  }\n}\n\nclass RowIdFormatter extends BaseFormatter {\n  public widgetOpts: { tableId: string };\n\n  public format(value: number): string {\n    return value > 0 ? `${this.widgetOpts.tableId}[${value}]` : \"\";\n  }\n}\n\ninterface ReferenceFormatOptions {\n  visibleColFormatter?: BaseFormatter;\n}\n\nclass ReferenceFormatter extends BaseFormatter {\n  public widgetOpts: ReferenceFormatOptions;\n  protected visibleColFormatter: BaseFormatter;\n\n  constructor(type: string, widgetOpts: ReferenceFormatOptions, docSettings: DocumentSettings) {\n    super(type, widgetOpts, docSettings);\n    // widgetOpts.visibleColFormatter shouldn't be undefined, but it can be if a referencing column\n    // is displaying another referencing column, which is partially prohibited in the UI but still possible.\n    this.visibleColFormatter = widgetOpts.visibleColFormatter ||\n      createFormatter(\"Id\", { tableId: getReferencedTableId(type) }, docSettings);\n  }\n\n  public formatAny(value: any): string {\n    /*\n    An invalid value in a referencing column is saved as a string and becomes AltText in the data engine.\n    Then the display column formula (e.g. $person.first_name) raises an InvalidTypedValue trying to access\n    an attribute of that AltText.\n    This would normally lead to the formatter displaying `#Invalid Ref[List]: ` before the string value.\n    That's inconsistent with how the cell is displayed (just the string value in pink)\n    and with how invalid values in other columns are formatted (just the string).\n    It's just a result of the formatter receiving a value from the display column, not the actual column.\n    It's also likely to inconvenience users trying to import/migrate/convert data.\n    So we suppress the error here and just show the text.\n    It's still technically possible for the column to display an actual InvalidTypedValue exception from a formula\n    and this will suppress that too, but this is unlikely and seems worth it.\n    */\n    if (\n      Array.isArray(value) &&\n      value[0] === GristObjCode.Exception &&\n      value[1] === \"InvalidTypedValue\" &&\n      value[2]?.startsWith?.(\"Ref\")\n    ) {\n      return value[3];\n    }\n    return this.formatNotInvalidRef(value);\n  }\n\n  protected formatNotInvalidRef(value: any) {\n    return this.visibleColFormatter.formatAny(value);\n  }\n}\n\nclass ReferenceListFormatter extends ReferenceFormatter {\n  protected formatNotInvalidRef(value: any): string {\n    // Part of this repeats the logic in BaseFormatter.formatAny which is overridden in ReferenceFormatter\n    // It also ensures that complex lists (e.g. if this RefList is displaying a ChoiceList)\n    // are formatted as JSON instead of CSV.\n    if (!isList(value) || hasNestedObjects(decodeObject(value) as CellValue[])) {\n      return formatUnknown(value);\n    }\n    // In the most common case, lists of simple objects like strings or dates\n    // are formatted like a CSV.\n    // This is similar to formatUnknown except the inner values are\n    // formatted according to the visible column options.\n    const formattedValues = value.slice(1).map(v => super.formatNotInvalidRef(v));\n    return csvEncodeRow(formattedValues, { prettier: true });\n  }\n}\n\nconst formatters: { [name: string]: typeof BaseFormatter } = {\n  Numeric: NumericFormatter,\n  Int: IntFormatter,\n  Bool: BoolFormatter,\n  Date: DateFormatter,\n  DateTime: DateTimeFormatter,\n  Ref: ReferenceFormatter,\n  RefList: ReferenceListFormatter,\n  Id: RowIdFormatter,\n  // We don't list anything that maps to AnyFormatter, since that's the default.\n};\n\n/**\n * Takes column type, widget options and document settings, and returns a constructor\n * with a format function that can properly convert a value passed to it into the\n * right format for that column.\n */\nexport function createFormatter(type: string, widgetOpts: FormatOptions, docSettings: DocumentSettings): BaseFormatter {\n  const ctor = formatters[gristTypes.extractTypeFromColType(type)] || AnyFormatter;\n  return new ctor(type, widgetOpts, docSettings);\n}\n\nexport interface FullFormatterArgs {\n  docData: DocData;\n  type: string;\n  widgetOpts: FormatOptions;\n  visibleColType: string;\n  visibleColWidgetOpts: FormatOptions;\n  docSettings: DocumentSettings;\n}\n\n/**\n * Returns a constructor\n * with a format function that can properly convert a value passed to it into the\n * right format for that column.\n *\n * Pass fieldRef (a row ID of _grist_Views_section_field) to use the settings of that view field\n * instead of the table column.\n */\nexport function createFullFormatterFromDocData(\n  docData: DocData,\n  colRef: number,\n  fieldRef?: number,\n): BaseFormatter {\n  const [type, widgetOpts, docSettings] = createParserOrFormatterArguments(docData, colRef, fieldRef);\n  const { visibleColType, visibleColWidgetOpts } = widgetOpts as ReferenceParsingOptions;\n  return createFullFormatterRaw({\n    docData,\n    type,\n    widgetOpts,\n    visibleColType,\n    visibleColWidgetOpts,\n    docSettings,\n  });\n}\n\nexport function createFullFormatterRaw(args: FullFormatterArgs) {\n  const { type, widgetOpts, docSettings } = args;\n  const visibleColFormatter = createVisibleColFormatterRaw(args);\n  return createFormatter(type, { ...widgetOpts, visibleColFormatter }, docSettings);\n}\n\nexport function createVisibleColFormatterRaw(\n  {\n    docData,\n    docSettings,\n    type,\n    visibleColType,\n    visibleColWidgetOpts,\n    widgetOpts,\n  }: FullFormatterArgs,\n): BaseFormatter {\n  let referencedTableId = gristTypes.getReferencedTableId(type);\n  if (!referencedTableId) {\n    return createFormatter(type, widgetOpts, docSettings);\n  } else if (visibleColType) {\n    return createFormatter(visibleColType, visibleColWidgetOpts, docSettings);\n  } else {\n    // This column displays the Row ID, e.g. Table1[2]\n    // Make referencedTableId empty if the table is hidden\n    const tablesData = docData.getMetaTable(\"_grist_Tables\");\n    const tableRef = tablesData.findRow(\"tableId\", referencedTableId);\n    if (isHiddenTable(tablesData, tableRef)) {\n      referencedTableId = \"\";\n    }\n    return createFormatter(\"Id\", { tableId: referencedTableId }, docSettings);\n  }\n}\n"
  },
  {
    "path": "app/common/ValueGuesser.ts",
    "content": "import { CellValue } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\nimport { isObject } from \"app/common/gristTypes\";\nimport { countIf } from \"app/common/gutil\";\nimport { NumberFormatOptions } from \"app/common/NumberFormat\";\nimport NumberParse from \"app/common/NumberParse\";\nimport { dateTimeWidgetOptions, guessDateFormat } from \"app/common/parseDate\";\nimport { MetaRowRecord } from \"app/common/TableData\";\nimport { createFormatter } from \"app/common/ValueFormatter\";\nimport { createParserRaw, ValueParser } from \"app/common/ValueParser\";\n\nimport * as moment from \"moment-timezone\";\n\ninterface GuessedColInfo {\n  type: string;\n  widgetOptions?: object;\n}\n\nexport interface GuessResult {\n  values?: CellValue[];\n  colInfo: GuessedColInfo;\n}\n\ntype ColMetadata = Partial<MetaRowRecord<\"_grist_Tables_column\">>;\n\nexport interface GuessColMetadata {\n  values: CellValue[];\n  colMetadata?: ColMetadata;    // omitted if no changes are proposed.\n}\n\n/**\n * Class for guessing if an array of values should be interpreted as a specific column type.\n * T is the type of values that strings should be parsed to and is stored in the column.\n */\nabstract class ValueGuesser<T> {\n  /**\n   * Guessed column type and maybe widget options.\n   */\n  public abstract colInfo(): GuessedColInfo;\n\n  /**\n   * Parse a single string to a typed value in such a way that formatting the value returns the original string.\n   * If the string cannot be parsed, return the original string.\n   */\n  public abstract parse(value: string): T | string;\n\n  /**\n   * Attempt to parse at least 90% the string values losslessly according to the guessed colInfo.\n   * Return null if this cannot be done.\n   */\n  public guess(values: (string | null)[], docSettings: DocumentSettings): GuessResult | null {\n    const colInfo = this.colInfo();\n    const { type, widgetOptions } = colInfo;\n    const formatter = createFormatter(type, widgetOptions || {}, docSettings);\n    const result: any[] = [];\n    // max number of non-parsed strings to allow before giving up\n    const maxUnparsed = countIf(values, v => Boolean(v)) * 0.1;\n    let unparsed = 0;\n\n    for (const value of values) {\n      if (!value) {\n        if (this.allowBlank()) {\n          result.push(null);\n          continue;\n        } else {\n          return null;\n        }\n      }\n\n      const parsed = this.parse(value);\n      // Give up if too many strings failed to parse or if the parsed value changes when converted back to text\n      if ((typeof parsed === \"string\" && ++unparsed > maxUnparsed) ||\n        !this.isEqualFormatted(formatter.formatAny(parsed), value)) {\n        return null;\n      }\n      result.push(parsed);\n    }\n    return { values: result, colInfo };\n  }\n\n  /**\n   * Whether this type of column can store nulls directly.\n   */\n  protected allowBlank(): boolean {\n    return true;\n  }\n\n  protected isEqualFormatted(formatted1: string, formatted2: string): boolean {\n    return formatted1 === formatted2;\n  }\n}\n\nclass BoolGuesser extends ValueGuesser<boolean> {\n  public colInfo(): GuessedColInfo {\n    return { type: \"Bool\" };\n  }\n\n  public parse(value: string): boolean | string {\n    if (value === \"true\") {\n      return true;\n    } else if (value === \"false\") {\n      return false;\n    } else {\n      return value;\n    }\n  }\n\n  /**\n   * This is the only type that can't store nulls, it converts them to false.\n   */\n  protected allowBlank(): boolean {\n    return false;\n  }\n}\n\nclass NumericGuesser extends ValueGuesser<number> {\n  private _parser: ValueParser;\n  constructor(docSettings: DocumentSettings, private _options: NumberFormatOptions) {\n    super();\n    this._parser = createParserRaw(\"Numeric\", _options, docSettings);\n  }\n\n  public colInfo(): GuessedColInfo {\n    const result: GuessedColInfo = { type: \"Numeric\" };\n    if (Object.keys(this._options).length) {\n      result.widgetOptions = this._options;\n    }\n    return result;\n  }\n\n  public parse(value: string): number | string {\n    return this._parser.cleanParse(value);\n  }\n\n  protected isEqualFormatted(formatted1: string, formatted2: string): boolean {\n    // Consider format guessing successful if it returns the typed-in numeric value exactly or\n    // differing only in whitespace.\n    formatted1 = formatted1.replace(NumberParse.removeCharsRegex, \"\");\n    formatted2 = formatted2.replace(NumberParse.removeCharsRegex, \"\");\n    return formatted1 === formatted2;\n  }\n}\n\nclass DateGuesser extends ValueGuesser<number> {\n  // _format should be a full moment format string\n  // _tz should be the document's default timezone\n  constructor(private _format: string, private _tz: string) {\n    super();\n  }\n\n  public colInfo(): GuessedColInfo {\n    const widgetOptions = dateTimeWidgetOptions(this._format, false);\n    let type;\n    if (widgetOptions.timeFormat) {\n      type = \"DateTime:\" + this._tz;\n    } else {\n      type = \"Date\";\n      this._tz = \"UTC\";\n    }\n    return { widgetOptions, type };\n  }\n\n  // Note that this parsing is much stricter than parseDate to prevent loss of information.\n  // Dates which can be parsed by parseDate based on the guessed widget options may not be parsed here.\n  public parse(value: string): number | string {\n    const m = moment.tz(value, this._format, true, this._tz);\n    return m.isValid() ? m.valueOf() / 1000 : value;\n  }\n}\n\nexport function guessColInfoWithDocData(values: (string | null)[], docData: DocData) {\n  return guessColInfo(values, docData.docSettings(), docData.docInfo().timezone);\n}\n\nexport function guessColInfo(\n  values: (string | null)[], docSettings: DocumentSettings, timezone: string,\n): GuessResult {\n  // Use short-circuiting of || to only do as much work as needed,\n  // in particular not guessing date formats before trying other types.\n  return (\n    new BoolGuesser()\n      .guess(values, docSettings) ||\n      new NumericGuesser(\n        docSettings,\n        NumberParse.fromSettings(docSettings).guessOptions(values),\n      )\n        .guess(values, docSettings) ||\n        new DateGuesser(guessDateFormat(values, timezone), timezone)\n          .guess(values, docSettings) ||\n    // Don't return the same values back if there's no conversion to be done,\n    // as they have to be serialized and transferred over a pipe to Python.\n          { colInfo: { type: \"Text\" } }\n  );\n}\n\n/**\n * Guess column info for a new column, returning the metadata suitable for using with AddTable or\n * AddColumn user actions. In particular, widgetOptions, if any, are returned as a JSON string.\n * Will suggest turning the column to an empty one if all the values are empty (null or \"\").\n */\nexport function guessColInfoForImports(values: CellValue[], docData: DocData): GuessColMetadata {\n  if (values.every(v => (v === null || v === \"\"))) {\n    // Suggest empty column.\n    return { values, colMetadata: { type: \"Any\", isFormula: true, formula: \"\" } };\n  }\n  if (values.some(isObject)) {\n    // Suggest no changes.\n    return { values };\n  }\n  const strValues = values.map(v => (v === null || typeof v === \"string\" ? v : String(v)));\n  const guessed = guessColInfoWithDocData(strValues, docData);\n  values = guessed.values || values;\n\n  const opts = guessed.colInfo.widgetOptions;\n  const colMetadata: ColMetadata = { ...guessed.colInfo, widgetOptions: opts && JSON.stringify(opts) };\n  if (!colMetadata.widgetOptions) {\n    delete colMetadata.widgetOptions;     // Omit widgetOptions unless it is actually valid JSON.\n  }\n  return { values, colMetadata };\n}\n"
  },
  {
    "path": "app/common/ValueParser.ts",
    "content": "import { csvDecodeRow } from \"app/common/csvFormat\";\nimport { BulkColValues, CellValue, ColValues, UserAction } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { getReferencedTableId, isFullReferencingType } from \"app/common/gristTypes\";\nimport * as gutil from \"app/common/gutil\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { NumberFormatOptions } from \"app/common/NumberFormat\";\nimport NumberParse from \"app/common/NumberParse\";\nimport { parseDateStrict, parseDateTime } from \"app/common/parseDate\";\nimport { MetaRowRecord, TableData } from \"app/common/TableData\";\nimport { DateFormatOptions, DateTimeFormatOptions, formatDecoded, FormatOptions } from \"app/common/ValueFormatter\";\nimport { encodeObject } from \"app/plugin/objtypes\";\n\nimport flatMap from \"lodash/flatMap\";\nimport mapValues from \"lodash/mapValues\";\n\nexport class ValueParser {\n  constructor(public type: string, public widgetOpts: FormatOptions, public docSettings: DocumentSettings) {\n  }\n\n  public cleanParse(value: string): any {\n    if (!value) {\n      return value;\n    }\n    return this.parse(value) ?? value;\n  }\n\n  public parse(value: string): any {\n    return value;\n  }\n}\n\nclass IdentityParser extends ValueParser {\n}\n\nexport class NumericParser extends ValueParser {\n  private _parse: NumberParse;\n\n  constructor(type: string, options: NumberFormatOptions, docSettings: DocumentSettings) {\n    super(type, options, docSettings);\n    this._parse = NumberParse.fromSettings(docSettings, options);\n  }\n\n  public parse(value: string): number | null {\n    return this._parse.parse(value)?.result ?? null;\n  }\n}\n\nclass DateParser extends ValueParser {\n  public parse(value: string): any {\n    return parseDateStrict(value, (this.widgetOpts as DateFormatOptions).dateFormat!);\n  }\n}\n\nclass DateTimeParser extends ValueParser {\n  constructor(type: string, widgetOpts: DateTimeFormatOptions, docSettings: DocumentSettings) {\n    super(type, widgetOpts, docSettings);\n    const timezone = gutil.removePrefix(type, \"DateTime:\") || \"\";\n    this.widgetOpts = { ...widgetOpts, timezone };\n  }\n\n  public parse(value: string): any {\n    return parseDateTime(value, this.widgetOpts);\n  }\n}\n\nclass ChoiceListParser extends ValueParser {\n  public cleanParse(value: string): string[] | null {\n    value = value.trim();\n    const result = (\n      this._parseJson(value) ||\n      this._parseCsv(value)\n    ).map(v => v.trim())\n      .filter(v => v);\n    if (!result.length) {\n      return null;\n    }\n    return [\"L\", ...result];\n  }\n\n  private _parseJson(value: string): string[] | undefined {\n    // Don't parse JSON non-arrays\n    if (value.startsWith(\"[\")) {\n      const arr: unknown[] | null = safeJsonParse(value, null);\n      return arr\n        // Remove nulls and empty strings\n        ?.filter(v => v || v === 0)\n        // Convert values to strings, formatting nested JSON objects/arrays as JSON\n        .map(v => formatDecoded(v));\n    }\n  }\n\n  private _parseCsv(value: string): string[] {\n    // Split everything on newlines which are not allowed by the choice editor.\n    return flatMap(value.split(/[\\n\\r]+/), (row) => {\n      return csvDecodeRow(row)\n        .map(v => v.trim());\n    });\n  }\n}\n\n/**\n * This is different from other widget options which are simple JSON\n * stored on the field. These have to be specially derived\n * for referencing columns. See createParser.\n */\nexport interface ReferenceParsingOptions {\n  visibleColId: string;\n  visibleColType: string;\n  visibleColWidgetOpts: FormatOptions;\n\n  // If this is provided and loaded, the ValueParser will look up values directly.\n  // Otherwise an encoded lookup will be produced for the data engine to handle.\n  tableData?: TableData;\n}\n\nexport class ReferenceParser extends ValueParser {\n  public widgetOpts: ReferenceParsingOptions;\n  public tableData = this.widgetOpts.tableData;\n  public visibleColParser = createParserRaw(\n    this.widgetOpts.visibleColType,\n    this.widgetOpts.visibleColWidgetOpts,\n    this.docSettings,\n  );\n\n  protected _visibleColId = this.widgetOpts.visibleColId;\n\n  public parse(raw: string): any {\n    const value = this.visibleColParser.cleanParse(raw);\n    return this.lookup(value, raw);\n  }\n\n  public lookup(value: any, raw: string): any {\n    if (value == null || value === \"\" || !raw) {\n      return 0;  // default value for a reference column\n    }\n\n    if (this._visibleColId === \"id\") {\n      const n = Number(value);\n      if (Number.isInteger(n)) {\n        value = n;\n        // Don't return yet because we need to check that this row ID exists\n      } else {\n        return raw;\n      }\n    }\n\n    if (!this.tableData?.isLoaded) {\n      const options: { column: string, raw?: string } = { column: this._visibleColId };\n      if (value !== raw) {\n        options.raw = raw;\n      }\n      return [\"l\", value, options];\n    }\n\n    return this.tableData.findMatchingRowId({ [this._visibleColId]: value }) || raw;\n  }\n}\n\nexport class ReferenceListParser extends ReferenceParser {\n  public parse(raw: string): any {\n    let values: any[] | null;\n    try {\n      values = JSON.parse(raw);\n    } catch {\n      values = null;\n    }\n    if (!Array.isArray(values)) {\n      // csvDecodeRow should never raise an exception\n      values = csvDecodeRow(raw);\n    }\n    values = values.map(v => typeof v === \"string\" ? this.visibleColParser.cleanParse(v) : encodeObject(v));\n\n    if (!values.length || !raw) {\n      return null;  // null is the default value for a reference list column\n    }\n\n    if (this._visibleColId === \"id\") {\n      const numbers = values.map(Number);\n      if (numbers.every(Number.isInteger)) {\n        values = numbers;\n        // Don't return yet because we need to check that these row IDs exist\n      } else {\n        return raw;\n      }\n    }\n\n    if (!this.tableData?.isLoaded) {\n      const options: { column: string, raw?: string } = { column: this._visibleColId };\n      if (!(values.length === 1 && values[0] === raw)) {\n        options.raw = raw;\n      }\n      return [\"l\", values, options];\n    }\n\n    const rowIds: number[] = [];\n    for (const value of values) {\n      const rowId = this.tableData.findMatchingRowId({ [this._visibleColId]: value });\n      if (rowId) {\n        rowIds.push(rowId);\n      } else {\n        // There's no matching value in the visible column, i.e. this is not a valid reference.\n        // We need to return a string which will become AltText.\n        return raw;\n      }\n    }\n    return [\"L\", ...rowIds];\n  }\n}\n\nexport const valueParserClasses: { [type: string]: typeof ValueParser } = {\n  Numeric: NumericParser,\n  Int: NumericParser,\n  Date: DateParser,\n  DateTime: DateTimeParser,\n  ChoiceList: ChoiceListParser,\n  Ref: ReferenceParser,\n  RefList: ReferenceListParser,\n  Attachments: ReferenceListParser,\n};\n\n/**\n * Returns a ValueParser which can parse strings into values appropriate for\n * a specific widget field or table column.\n * widgetOpts is usually the field/column's widgetOptions JSON\n * but referencing columns need more than that, see ReferenceParsingOptions above.\n */\nexport function createParserRaw(\n  type: string, widgetOpts: FormatOptions, docSettings: DocumentSettings,\n): ValueParser {\n  const cls = valueParserClasses[gristTypes.extractTypeFromColType(type)] || IdentityParser;\n  return new cls(type, widgetOpts, docSettings);\n}\n\n/**\n * Returns a ValueParser which can parse strings into values appropriate for\n * a specific widget field or table column.\n *\n * Pass fieldRef (a row ID of _grist_Views_section_field) to use the settings of that view field\n * instead of the table column.\n */\nexport function createParser(\n  docData: DocData,\n  colRef: number,\n  fieldRef?: number,\n): ValueParser {\n  return createParserRaw(...createParserOrFormatterArguments(docData, colRef, fieldRef));\n}\n\n/**\n * Returns arguments suitable for createParserRaw or createFormatter. Only for internal use.\n *\n * Pass fieldRef (a row ID of _grist_Views_section_field) to use the settings of that view field\n * instead of the table column.\n */\nexport function createParserOrFormatterArguments(\n  docData: DocData,\n  colRef: number,\n  fieldRef?: number,\n): [string, object, DocumentSettings] {\n  const columnsTable = docData.getMetaTable(\"_grist_Tables_column\");\n  const fieldsTable = docData.getMetaTable(\"_grist_Views_section_field\");\n\n  const col = columnsTable.getRecord(colRef)!;\n  let fieldOrCol: MetaRowRecord<\"_grist_Tables_column\" | \"_grist_Views_section_field\"> = col;\n  if (fieldRef) {\n    const field = fieldsTable.getRecord(fieldRef);\n    fieldOrCol = field?.widgetOptions ? field : col;\n  }\n\n  return createParserOrFormatterArgumentsRaw(docData, col.type, fieldOrCol.widgetOptions, fieldOrCol.visibleCol);\n}\n\nexport function createParserOrFormatterArgumentsRaw(\n  docData: DocData,\n  type: string,\n  widgetOptions: string,\n  visibleColRef: number,\n): [string, object, DocumentSettings] {\n  const columnsTable = docData.getMetaTable(\"_grist_Tables_column\");\n  const widgetOpts = safeJsonParse(widgetOptions, {});\n\n  if (isFullReferencingType(type)) {\n    const vcol = columnsTable.getRecord(visibleColRef);\n    widgetOpts.visibleColId = vcol?.colId || \"id\";\n    widgetOpts.visibleColType = vcol?.type;\n    widgetOpts.visibleColWidgetOpts = safeJsonParse(vcol?.widgetOptions || \"\", {});\n    widgetOpts.tableData = docData.getTable(getReferencedTableId(type)!);\n  }\n\n  return [type, widgetOpts, docData.docSettings()];\n}\n\n/**\n * Returns a copy of `colValues` with string values parsed according to the type and options of each column.\n * `bulk` should be `true` if `colValues` is of type `BulkColValues`.\n */\nfunction parseColValues<T extends ColValues | BulkColValues>(\n  tableId: string, colValues: T, docData: DocData, bulk: boolean,\n): T {\n  const columnsTable = docData.getMetaTable(\"_grist_Tables_column\");\n  const tablesTable = docData.getMetaTable(\"_grist_Tables\");\n  const tableRef = tablesTable.findRow(\"tableId\", tableId);\n  if (!tableRef) {\n    return colValues;\n  }\n\n  return mapValues(colValues, (values, colId) => {\n    const colRef = columnsTable.findMatchingRowId({ colId, parentId: tableRef });\n    if (!colRef) {\n      // Column not found - let something else deal with that\n      return values;\n    }\n\n    const parser = createParser(docData, colRef);\n\n    // Optimisation: If there's no special parser for this column type, do nothing\n    if (parser instanceof IdentityParser) {\n      return values;\n    }\n\n    function parseIfString(val: any) {\n      return typeof val === \"string\" ? parser.cleanParse(val) : val;\n    }\n\n    if (bulk) {\n      if (!Array.isArray(values)) {  // in case of bad input\n        return values;\n      }\n      // `colValues` is of type `BulkColValues`\n      return (values as CellValue[]).map(parseIfString);\n    } else {\n      // `colValues` is of type `ColValues`, `values` is just one value\n      return parseIfString(values);\n    }\n  });\n}\n\nexport function parseUserAction(ua: UserAction, docData: DocData): UserAction {\n  switch (ua[0]) {\n    case \"AddRecord\":\n    case \"UpdateRecord\":\n      return _parseUserActionColValues(ua, docData, false);\n    case \"BulkAddRecord\":\n    case \"BulkUpdateRecord\":\n    case \"ReplaceTableData\":\n      return _parseUserActionColValues(ua, docData, true);\n    case \"AddOrUpdateRecord\":\n      // Parse `require` (2) and `col_values` (3). The action looks like:\n      // ['AddOrUpdateRecord', table_id, require, col_values, options]\n      // (`col_values` is called `fields` in the API)\n      ua = _parseUserActionColValues(ua, docData, false, 2);\n      ua = _parseUserActionColValues(ua, docData, false, 3);\n      return ua;\n    case \"BulkAddOrUpdateRecord\":\n      ua = _parseUserActionColValues(ua, docData, true, 2);\n      ua = _parseUserActionColValues(ua, docData, true, 3);\n      return ua;\n    default:\n      return ua;\n  }\n}\n\n// Returns a copy of the user action with one element parsed, by default the last one\nfunction _parseUserActionColValues(ua: UserAction, docData: DocData, parseBulk: boolean, index?: number,\n): UserAction {\n  ua = ua.slice();\n  const tableId = ua[1] as string;\n  if (index === undefined) {\n    index = ua.length - 1;\n  }\n  const colValues = ua[index] as ColValues | BulkColValues;\n  ua[index] = parseColValues(tableId, colValues, docData, parseBulk);\n  return ua;\n}\n"
  },
  {
    "path": "app/common/WidgetOptions.ts",
    "content": "import { NumberFormatOptions } from \"app/common/NumberFormat\";\n\nexport interface WidgetOptions extends NumberFormatOptions {\n  textColor?: \"string\";\n  fillColor?: \"string\";\n  alignment?: \"left\" | \"center\" | \"right\";\n  dateFormat?: string;\n  timeFormat?: string;\n  widget?: \"HyperLink\";\n  choices?: string[];\n}\n"
  },
  {
    "path": "app/common/airtable/AirtableAPI.ts",
    "content": "import {\n  AirtableBaseSchema,\n  AirtableFieldSchema, AirtableListBasesResponse,\n  AirtableTableSchema,\n} from \"app/common/airtable/AirtableAPITypes\";\nimport AirtableSchemaTypeSuite from \"app/common/airtable/AirtableAPITypes-ti\";\n\nimport Airtable, { Record, SelectOptions as QueryParams } from \"airtable\";\nimport { CheckerT, createCheckers } from \"ts-interface-checker\";\n\nexport interface AirtableAPIOptions {\n  apiKey: string;\n  endpointUrl?: string;\n}\n\n// TODO - Improve error handling. Airtable's API throws if an error response is returned,\n//        but we don't want to show that directly to users.\n\n/**\n * Simplifies access to Airtable's API.\n * - Allows easy access to meta methods (e.g. schema retrieval, listing bases) that aren't exposed\n *   by the \"airtable\" package.\n * - Applies type checking and assertions to the responses\n */\nexport class AirtableAPI {\n  private readonly _airtable: Airtable;\n  private _metaRequester: Airtable.Base;\n\n  constructor(_options: AirtableAPIOptions) {\n    this._airtable = new Airtable(_options);\n    // Airtable's JS library doesn't support fetching schemas, but by passing an empty baseId\n    // we can still force it to request the URL we want, and re-use the library's backoff logic\n    // to help with Airtable's rate limiting.\n    this._metaRequester = this._airtable.base(\"\");\n  }\n\n  public base(baseId: string) {\n    return this._airtable.base(baseId);\n  }\n\n  public async listBases(): Promise<AirtableListBasesResponse[\"bases\"]> {\n    // Technically there's pagination here - but each request returns 1000 bases, so it feels\n    // premature to implement.\n    const response = await this._metaRequester.makeRequest({ path: `meta/bases` });\n    const body = response.body as AirtableListBasesResponse;\n    return body.bases;\n  }\n\n  public async getBaseSchema(baseId: string): Promise<AirtableBaseSchema> {\n    const response = await this._metaRequester.makeRequest({\n      path: `meta/bases/${baseId}/tables`,\n    });\n    const schema = response.body;\n    if (!AirtableSchemaChecker.test(schema)) {\n      throw new AirtableAPIError(\"unexpected response structure when fetching base schema\");\n    }\n    return schema;\n  }\n}\n\nexport class AirtableAPIError extends Error {\n  constructor(message: string) {\n    super(`Airtable API error: ${message}`);\n  }\n}\n\nexport interface ListAirtableRecordsResult {\n  records: Airtable.Records<any>,\n  hasMoreRecords: boolean,\n  fetchNextPage: FetchNextPageFunc\n}\n\ntype FetchNextPageFunc = () => Promise<ListAirtableRecordsResult>;\n\nconst fetchPageWhenNoMoreData: FetchNextPageFunc = () => Promise.resolve({\n  records: [],\n  hasMoreRecords: false,\n  fetchNextPage: fetchPageWhenNoMoreData,\n});\n\n/**\n * Airtable's built-in record querying (base.table(\"MyTable\").select().eachPage()) is prone\n * to hanging indefinitely when an error is thrown from the callback, or if the callback fails to\n * call `nextPage()` correctly.\n *\n * This re-implements the listRecords functionality, while keeping the error handling,\n * rate-limiting and auth logic from the Airtable library.\n */\nexport function listRecords(\n  base: Airtable.Base, tableName: string, params: QueryParams<any>,\n): Promise<ListAirtableRecordsResult> {\n  const table = base.table(tableName);\n\n  const fetchNextPage = async (offset?: number): ReturnType<FetchNextPageFunc> => {\n    const { body } = await base.makeRequest({\n      method: \"GET\",\n      path: `/${encodeURIComponent(tableName)}`,\n      qs: {\n        ...params,\n        offset,\n      },\n    });\n\n    const records = body.records.map((recordJson: string) => new Record(table, \"\", recordJson));\n    const hasMoreRecords = body.offset !== undefined;\n\n    return {\n      records,\n      hasMoreRecords,\n      fetchNextPage: hasMoreRecords ? () => fetchNextPage(body.offset) : fetchPageWhenNoMoreData,\n    };\n  };\n\n  return fetchNextPage();\n}\n\nconst checkers = createCheckers(AirtableSchemaTypeSuite);\nexport const AirtableSchemaChecker = checkers.AirtableBaseSchema as CheckerT<AirtableBaseSchema>;\nexport const AirtableSchemaTableChecker = checkers.AirtableSchemaTable as CheckerT<AirtableTableSchema>;\nexport const AirtableSchemaFieldChecker = checkers.AirtableSchemaField as CheckerT<AirtableFieldSchema>;\n"
  },
  {
    "path": "app/common/airtable/AirtableAPITypes-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const AirtableBaseId = t.name(\"string\");\n\nexport const AirtableTableId = t.name(\"string\");\n\nexport const AirtableFieldId = t.name(\"string\");\n\nexport const AirtableFieldName = t.name(\"string\");\n\nexport const AirtableBaseSchema = t.iface([], {\n  \"tables\": t.array(\"AirtableTableSchema\"),\n});\n\nexport const AirtableTableSchema = t.iface([], {\n  \"id\": \"AirtableTableId\",\n  \"name\": \"string\",\n  \"primaryFieldId\": \"string\",\n  \"fields\": t.array(\"AirtableFieldSchema\"),\n});\n\nexport const AirtableFieldSchema = t.iface([], {\n  \"id\": \"AirtableFieldId\",\n  \"name\": \"AirtableFieldName\",\n  \"type\": \"string\",\n  \"options\": t.opt(t.iface([], {\n    [t.indexKey]: \"any\",\n  })),\n});\n\nexport const AirtableChoiceValue = t.iface([], {\n  \"id\": \"string\",\n  \"name\": \"string\",\n  \"color\": \"string\",\n});\n\nexport const AirtableListBasesResponse = t.iface([], {\n  \"bases\": t.array(t.iface([], {\n    \"id\": \"string\",\n    \"name\": \"string\",\n    \"permissionLevel\": t.array(t.union(t.lit(\"none\"), t.lit(\"read\"), t.lit(\"comment\"), t.lit(\"edit\"), t.lit(\"create\"))),\n  })),\n  \"offset\": t.opt(\"string\"),\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  AirtableBaseId,\n  AirtableTableId,\n  AirtableFieldId,\n  AirtableFieldName,\n  AirtableBaseSchema,\n  AirtableTableSchema,\n  AirtableFieldSchema,\n  AirtableChoiceValue,\n  AirtableListBasesResponse,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/common/airtable/AirtableAPITypes.ts",
    "content": "// Aliases for the various Airtable IDs makes various Map type definitions clearer.\nexport type AirtableBaseId = string;\nexport type AirtableTableId = string;\nexport type AirtableFieldId = string;\nexport type AirtableFieldName = string;\n\n// Airtable schema response. Limit this to only needed fields to minimise chance of breakage.\nexport interface AirtableBaseSchema {\n  tables: AirtableTableSchema[];\n}\n\nexport interface AirtableTableSchema {\n  id: AirtableTableId;\n  name: string;\n  primaryFieldId: string;\n  fields: AirtableFieldSchema[];\n}\n\nexport interface AirtableFieldSchema {\n  id: AirtableFieldId;\n  name: AirtableFieldName;\n  type: string;\n  options?: { [key: string]: any };\n}\n\nexport interface AirtableChoiceValue {\n  id: string;\n  name: string;\n  color: string;\n}\n\nexport interface AirtableListBasesResponse {\n  bases: {\n    id: string,\n    name: string,\n    permissionLevel: (\"none\" | \"read\" | \"comment\" | \"edit\" | \"create\")[],\n  }[],\n  offset?: string,\n}\n"
  },
  {
    "path": "app/common/airtable/AirtableAttachmentTracker.ts",
    "content": "import { AirtableFieldSchema } from \"app/common/airtable/AirtableAPITypes\";\nimport { AirtableFieldMappingInfo, GristTableId } from \"app/common/airtable/AirtableCrosswalk\";\nimport { createEmptyBulkColValues } from \"app/common/airtable/AirtableReferenceTracker\";\nimport { TableColValues } from \"app/common/DocActions\";\nimport { getMaxUploadSizeAttachmentMB } from \"app/common/gristUrls\";\nimport { arrayRepeat, byteString } from \"app/common/gutil\";\nimport { GristObjCode } from \"app/plugin/GristData\";\n\nimport pick from \"lodash/pick\";\nimport pLimit from \"p-limit\";\n\nexport type AttachmentsByColumnId = Record<string, Attachment[] | undefined>;\n\ninterface Attachment {\n  filename: string;\n  size: number;\n  url: string;\n}\n\ninterface AttachmentsForRecord {\n  gristRecordId: number;\n  attachmentsByColumnId: AttachmentsByColumnId;\n}\n\nexport class AttachmentTracker {\n  private _tableAttachmentTrackers = new Map<string, TableAttachmentTracker>();\n\n  public addTable(gristTableId: string, columnIdsToUpdate: string[]) {\n    const tableTracker = new TableAttachmentTracker(gristTableId, columnIdsToUpdate);\n    this._tableAttachmentTrackers.set(gristTableId, tableTracker);\n    return tableTracker;\n  }\n\n  public getTables(): TableAttachmentTracker[] {\n    return Array.from(this._tableAttachmentTrackers.values());\n  }\n\n  public getRemainingAttachmentsCount() {\n    let count = 0;\n    for (const table of this.getTables()) {\n      count += table.getRemainingAttachmentsCount();\n    }\n    return count;\n  }\n}\n\nexport class TableAttachmentTracker {\n  private _attachmentsForRecords: AttachmentsForRecord[] = [];\n\n  public constructor(private _tableId: string, private _columnIds: string[]) {\n  }\n\n  public addRecord(attachmentsForRecord: AttachmentsForRecord) {\n    this._attachmentsForRecords.push(attachmentsForRecord);\n  }\n\n  public async importAttachments(\n    uploadAttachment: (value: string | Blob, filename?: string) => Promise<number>,\n    updateRows: (tableId: GristTableId, rows: TableColValues) => Promise<number[]>,\n    options: {\n      maxConcurrentUploads?: number;\n      updateRowsBatchSize?: number;\n      onBatchComplete?(): void;\n    } = {},\n  ) {\n    const { maxConcurrentUploads = 5, updateRowsBatchSize = 25, onBatchComplete } = options;\n\n    while (this._attachmentsForRecords.length > 0) {\n      const attachmentsForRecords = this._attachmentsForRecords.splice(0, updateRowsBatchSize);\n      const limit = pLimit(maxConcurrentUploads);\n      const tableColValues: TableColValues = { id: [], ...createEmptyBulkColValues(this._columnIds) };\n      const uploads: Promise<void>[] = [];\n\n      for (let rowIdx = 0; rowIdx < attachmentsForRecords.length; rowIdx++) {\n        const { gristRecordId, attachmentsByColumnId } = attachmentsForRecords[rowIdx];\n\n        tableColValues.id.push(gristRecordId);\n\n        for (const colId of this._columnIds) {\n          const attachments = attachmentsByColumnId[colId] ?? [];\n          const cellValue: [GristObjCode.List, ...(number | undefined)[]] = [\n            GristObjCode.List, ...arrayRepeat(attachments.length, undefined)];\n          tableColValues[colId][rowIdx] = cellValue;\n\n          attachments.forEach((attachment, index) => {\n            uploads.push(\n              limit(() => this._uploadAttachment(attachment, uploadAttachment)).then((id) => {\n                cellValue[index + 1] = id;\n              }),\n            );\n          });\n        }\n      }\n\n      // TODO: Use a pipeline instead of batching for uploads. Batches are only as fast as the\n      // slowest upload, and a particularly large attachment could hold up starting a new batch.\n      // Also consider switching to allSettled and reporting any warnings/errors to the client.\n      // Note that all errors are currently handled by _uploadAttachment, so this call shouldn't\n      // throw.\n      await Promise.all(uploads);\n\n      for (const colId of this._columnIds) {\n        for (let rowIdx = 0; rowIdx < attachmentsForRecords.length; rowIdx++) {\n          const cellValue = tableColValues[colId][rowIdx] as [GristObjCode.List, ...(number | undefined)[]];\n          const attachmentIds = cellValue.slice(1) as (number | undefined)[];\n          tableColValues[colId][rowIdx] = [GristObjCode.List, ...attachmentIds.filter(id => id !== undefined)];\n        }\n      }\n\n      await updateRows(this._tableId, tableColValues);\n\n      onBatchComplete?.();\n    }\n  }\n\n  public getRemainingAttachmentsCount() {\n    let count = 0;\n    for (const record of this._attachmentsForRecords) {\n      for (const attachments of Object.values(record.attachmentsByColumnId)) {\n        if (attachments) {\n          count += attachments.length;\n        }\n      }\n    }\n    return count;\n  }\n\n  private async _uploadAttachment(\n    { filename, size, url }: Attachment,\n    uploadAttachment: (value: string | Blob, filename?: string) => Promise<number>,\n  ): Promise<number | undefined> {\n    try {\n      const maxSize = getMaxUploadSizeAttachmentMB() * 1024 * 1024;\n      if (size > maxSize) {\n        throw new Error(`Attachments must not exceed ${byteString(maxSize)}`);\n      }\n\n      const response = await fetch(url);\n      if (!response.ok) {\n        throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n      }\n\n      const blob = await response.blob();\n      return await uploadAttachment(blob, filename);\n    } catch (error) {\n      console.error(`Failed to upload attachment \"${filename}\" (URL: ${url}):`, error);\n      return undefined;\n    }\n  }\n}\n\nexport function isAttachmentField({ type }: AirtableFieldSchema) {\n  return type === \"multipleAttachments\";\n}\n\nexport function extractAttachmentsFromRecordField(\n  fieldValue: any,\n  fieldMapping: AirtableFieldMappingInfo,\n): Attachment[] | undefined {\n  if (fieldMapping.airtableField.type !== \"multipleAttachments\") { return undefined; }\n\n  const attachments = Array.isArray(fieldValue) ? fieldValue : undefined;\n  if (!attachments) { return undefined; }\n\n  return attachments.map(a => pick(a, \"filename\", \"size\", \"url\"));\n}\n"
  },
  {
    "path": "app/common/airtable/AirtableCrosswalk.ts",
    "content": "import {\n  AirtableBaseSchema,\n  AirtableFieldName, AirtableFieldSchema,\n  AirtableTableId,\n  AirtableTableSchema,\n} from \"app/common/airtable/AirtableAPITypes\";\nimport { AirtableIdColumnLabel } from \"app/common/airtable/AirtableSchemaImporter\";\nimport {\n  ExistingColumnSchema,\n  ExistingDocSchema,\n  ExistingTableSchema,\n} from \"app/common/DocSchemaImportTypes\";\n\nexport type GristTableId = string;\n\nexport interface AirtableBaseSchemaCrosswalk {\n  tables: Map<AirtableTableId, AirtableTableCrosswalk>\n}\n\nexport interface AirtableTableCrosswalk {\n  airtableTable: AirtableTableSchema;\n  gristTable: ExistingTableSchema;\n  fields: Map<AirtableFieldName, AirtableFieldMappingInfo>\n  // Special case - ID isn't a field in Airtable, but it's useful to have a mapping if it exists.\n  airtableIdColumn?: ExistingColumnSchema;\n}\n\nexport interface AirtableFieldMappingInfo {\n  airtableField: AirtableFieldSchema;\n  gristColumn: ExistingColumnSchema;\n}\n\n/**\n * Creates a mapping from fields in an Airtable schema to fields in a Grist schema.\n * @param {AirtableBaseSchema} airtableSchema\n * @param {ExistingDocSchema} gristSchema\n * @param {Map<AirtableTableId, GristTableId>} tableMap\n * @returns {{schemaCrosswalk: AirtableBaseSchemaCrosswalk, warnings: DocSchemaImportWarning[]}}\n */\nexport function createAirtableBaseToGristDocCrosswalk(\n  airtableSchema: AirtableBaseSchema, gristSchema: ExistingDocSchema, tableMap: Map<AirtableTableId, GristTableId>,\n): { schemaCrosswalk: AirtableBaseSchemaCrosswalk, warnings: AirtableCrosswalkWarning[] } {\n  const schemaCrosswalk: AirtableBaseSchemaCrosswalk = {\n    tables: new Map(),\n  };\n  const warnings: AirtableCrosswalkWarning[] = [];\n\n  for (const [airtableTableId, gristTableId] of tableMap.entries()) {\n    const airtableTableSchema = airtableSchema.tables.find(table => table.id === airtableTableId);\n    const gristTableSchema = gristSchema.tables.find(table => table.id === gristTableId);\n\n    if (!airtableTableSchema) {\n      // Implementation error - this shouldn't be possible if the parameters are passed correctly.\n      throw new Error(`No airtable table found with id '${airtableTableId}' when building crosswalk`);\n    }\n\n    if (!gristTableSchema) {\n      warnings.push(new MissingGristTableWarning(gristTableId));\n      continue;\n    }\n\n    const { crosswalk: tableCrosswalk, warnings: tableWarnings } =\n      createAirtableTableToGristTableCrosswalk(airtableTableSchema, gristTableSchema);\n\n    warnings.push(...tableWarnings);\n\n    schemaCrosswalk.tables.set(airtableTableId, tableCrosswalk);\n  }\n\n  return {\n    schemaCrosswalk,\n    warnings,\n  };\n}\n\nfunction createAirtableTableToGristTableCrosswalk(\n  airtableTableSchema: AirtableTableSchema, gristTableSchema: ExistingTableSchema,\n) {\n  const warnings: AirtableCrosswalkWarning[] = [];\n  const crosswalk: AirtableTableCrosswalk = {\n    airtableTable: airtableTableSchema,\n    gristTable: gristTableSchema,\n    fields: new Map(),\n    airtableIdColumn: gristTableSchema.columns.find(column => column.label === AirtableIdColumnLabel),\n  };\n\n  for (const field of airtableTableSchema.fields) {\n    // Match columns on label. It's the only reliable value we can automatically match on and the simplest to implement.\n    const matchingColumn = findGristColumnForField(field, gristTableSchema);\n    if (!matchingColumn) {\n      warnings.push(new NoDestinationColumnWarning(gristTableSchema.id, field));\n      continue;\n    }\n    // Airtable record queries list fields by name (not id), which is guaranteed to be unique.\n    crosswalk.fields.set(field.name, {\n      airtableField: field,\n      gristColumn: matchingColumn,\n    });\n  }\n\n  return { crosswalk, warnings };\n}\n\nfunction findGristColumnForField(field: AirtableFieldSchema, gristSchema: ExistingTableSchema) {\n  return gristSchema.columns.find(column => column.label === field.name);\n}\n\nexport interface AirtableCrosswalkWarning {\n  message: string;\n}\n\nclass MissingGristTableWarning implements AirtableCrosswalkWarning {\n  public readonly message;\n\n  constructor(public readonly tableId: AirtableTableId) {\n    this.message = `No Grist table found with id '${tableId}'`;\n  }\n}\n\nclass NoDestinationColumnWarning implements AirtableCrosswalkWarning {\n  public readonly message: string;\n\n  constructor(public readonly gristTableId: string, public readonly field: AirtableFieldSchema) {\n    this.message = `No destination column in the Grist table '${gristTableId}' could be found for field '${field.name}'. A column with a matching label is required.`;\n  }\n}\n"
  },
  {
    "path": "app/common/airtable/AirtableDataImporter.ts",
    "content": "import { AirtableFieldSchema } from \"app/common/airtable/AirtableAPITypes\";\nimport {\n  AttachmentsByColumnId,\n  AttachmentTracker,\n  extractAttachmentsFromRecordField,\n  isAttachmentField,\n  TableAttachmentTracker,\n} from \"app/common/airtable/AirtableAttachmentTracker\";\nimport { AirtableDataImportParams } from \"app/common/airtable/AirtableDataImporterTypes\";\nimport {\n  createEmptyBulkColValues,\n  extractRefFromRecordField,\n  isRefField,\n  ReferenceTracker,\n  RefValuesByColumnId,\n  TableReferenceTracker,\n} from \"app/common/airtable/AirtableReferenceTracker\";\nimport { BulkColValues, CellValue, GristObjCode } from \"app/plugin/GristData\";\n\nexport async function importDataFromAirtableBase(\n  { listRecords, addRows, updateRows, uploadAttachment, schemaCrosswalk, onProgress }: AirtableDataImportParams,\n) {\n  const referenceTracker = new ReferenceTracker();\n  const attachmentTracker = new AttachmentTracker();\n\n  const addRowsPromises: Promise<any>[] = [];\n\n  // TODO: Strings passed to onProgress calls in common code aren't translatable.\n  onProgress?.({ percent: 0, status: \"Importing records from Airtable...\" });\n\n  for (const [tableId, tableCrosswalk] of schemaCrosswalk.tables.entries()) {\n    // Filter out any formula columns early - Grist will error on any write to formula columns.\n    const fieldMappings = Array.from(tableCrosswalk.fields.values()).filter(mapping => !mapping.gristColumn.isFormula);\n    const gristColumnIds = fieldMappings.map(mapping => mapping.gristColumn.id);\n\n    // Airtable ID needs to be handled separately to fields, as it's not stored as a field in Airtable\n    if (tableCrosswalk.airtableIdColumn) {\n      gristColumnIds.push(tableCrosswalk.airtableIdColumn.id);\n    }\n\n    const referenceColumnIds = Array.from(tableCrosswalk.fields.values())\n      .filter(mapping => isRefField(mapping.airtableField))\n      .map(mapping => mapping.gristColumn.id);\n\n    let tableReferenceTracker: TableReferenceTracker | undefined;\n    if (referenceColumnIds.length > 0) {\n      tableReferenceTracker = referenceTracker.addTable(tableCrosswalk.gristTable.id, referenceColumnIds);\n    }\n\n    const attachmentColumnIds = Array.from(tableCrosswalk.fields.values())\n      .filter(mapping => isAttachmentField(mapping.airtableField))\n      .map(mapping => mapping.gristColumn.id);\n\n    let tableAttachmentTracker: TableAttachmentTracker | undefined;\n    if (attachmentColumnIds.length > 0) {\n      tableAttachmentTracker = attachmentTracker.addTable(tableCrosswalk.gristTable.id, attachmentColumnIds);\n    }\n\n    let listRecordsResult = await listRecords(tableId);\n\n    while (listRecordsResult.records.length > 0) {\n      const { records } = listRecordsResult;\n\n      const colValues: BulkColValues = createEmptyBulkColValues(gristColumnIds);\n      const airtableRecordIds: string[] = [];\n      const refsByColumnIdForRecords: RefValuesByColumnId[] = [];\n      const attachmentsByColumnIdForRecords: AttachmentsByColumnId[] = [];\n\n      for (const record of records) {\n        const refsByColumnId: RefValuesByColumnId = {};\n        const attachmentsByColumnId: AttachmentsByColumnId = {};\n\n        airtableRecordIds.push(record.id);\n        for (const fieldMapping of fieldMappings) {\n          const { airtableField, gristColumn } = fieldMapping;\n          const rawFieldValue = record.fields[airtableField.name];\n\n          if (isRefField(airtableField)) {\n            refsByColumnId[gristColumn.id] = extractRefFromRecordField(rawFieldValue, fieldMapping);\n          }\n\n          if (isAttachmentField(airtableField)) {\n            attachmentsByColumnId[gristColumn.id] = extractAttachmentsFromRecordField(rawFieldValue, fieldMapping);\n          }\n\n          if (isRefField(airtableField) || isAttachmentField(airtableField)) {\n            // Column should remain blank until it's filled in by a later step.\n            colValues[gristColumn.id].push(null);\n            continue;\n          }\n\n          const converter =\n            AirtableFieldValueConverters[fieldMapping.airtableField.type] ?? AirtableFieldValueConverters.identity;\n\n          const value = converter(fieldMapping.airtableField, record.fields[fieldMapping.airtableField.name]);\n\n          // Always push, even if the value is undefined, so that row values are always at the right index.\n          colValues[fieldMapping.gristColumn.id].push(value ?? null);\n        }\n\n        if (tableCrosswalk.airtableIdColumn) {\n          colValues[tableCrosswalk.airtableIdColumn.id].push(record.id);\n        }\n\n        refsByColumnIdForRecords.push(refsByColumnId);\n        attachmentsByColumnIdForRecords.push(attachmentsByColumnId);\n      }\n\n      const addRowsPromise = addRows(tableCrosswalk.gristTable.id, colValues)\n        .then((gristRowIds) => {\n          airtableRecordIds.forEach((airtableRecordId, index) => {\n            // Only add entries to the reference and attachment trackers once we know they're added to the table.\n            referenceTracker.addRecordIdMapping(airtableRecordId, gristRowIds[index]);\n            tableReferenceTracker?.addUnresolvedRecord({\n              gristRecordId: gristRowIds[index],\n              refsByColumnId: refsByColumnIdForRecords[index],\n            });\n            tableAttachmentTracker?.addRecord({\n              gristRecordId: gristRowIds[index],\n              attachmentsByColumnId: attachmentsByColumnIdForRecords[index],\n            });\n          });\n        });\n\n      addRowsPromises.push(addRowsPromise);\n\n      listRecordsResult = await listRecordsResult.fetchNextPage();\n    }\n  }\n\n  // Future improvement - report all errors here using Promise.allSettled, or continue even if\n  //                      a few sets of rows throw errors\n  await Promise.all(addRowsPromises);\n\n  for (const tableReferenceTracker of referenceTracker.getTables()) {\n    await tableReferenceTracker.bulkUpdateRowsWithUnresolvedReferences(updateRows);\n  }\n\n  const totalAttachmentsCount = attachmentTracker.getRemainingAttachmentsCount();\n  for (const tableAttachmentTracker of attachmentTracker.getTables()) {\n    await tableAttachmentTracker.importAttachments(\n      uploadAttachment,\n      updateRows,\n      {\n        onBatchComplete: () => {\n          const remainingAttachmentsCount = attachmentTracker.getRemainingAttachmentsCount();\n          const uploadedAttachmentsCount = totalAttachmentsCount - remainingAttachmentsCount;\n          const attachmentsPercent = (uploadedAttachmentsCount / totalAttachmentsCount) * 100;\n          onProgress?.({\n            percent: 50 + (attachmentsPercent * 0.50),\n            status: `Importing attachments from Airtable... (${remainingAttachmentsCount} remaining)`,\n          });\n        },\n      },\n    );\n  }\n\n  onProgress?.({ percent: 100 });\n}\n\ntype AirtableFieldValueConverter = (fieldSchema: AirtableFieldSchema, value: any) => CellValue | undefined;\nconst AirtableFieldValueConverters: Record<string, AirtableFieldValueConverter> = {\n  identity(fieldSchema, value) {\n    return value;\n  },\n  aiText(fieldSchema, aiTextState) {\n    return aiTextState?.value;\n  },\n  createdBy(fieldSchema, collaborator) {\n    return formatCollaborator(collaborator);\n  },\n  count(fieldSchema, collaborator) {\n    throw new Error(\"Count is a formula column, and should not have data conversion run\");\n  },\n  formula(fieldSchema, collaborator) {\n    throw new Error(\"Formula is a formula column, and should not have data conversion run\");\n  },\n  lastModifiedBy(fieldSchema, collaborator) {\n    return formatCollaborator(collaborator);\n  },\n  lookup(fieldSchema, value) {\n    // Lookup fields fetch values from other columns. This should be a formula in Grist, no value needed.\n    throw new Error(\"Lookup is a formula column, and should not have data conversion run\");\n  },\n  multipleCollaborators(fieldSchema, collaborators) {\n    const formattedCollaborators = collaborators?.map(formatCollaborator);\n    if (!formattedCollaborators) { return null; }\n    return formattedCollaborators.join(\", \");\n  },\n  singleCollaborator(fieldSchema, collaborator) {\n    return formatCollaborator(collaborator);\n  },\n  multipleSelects(fieldSchema, choices?: string[]) {\n    if (!choices) { return null; }\n    return [GristObjCode.List, ...choices];\n  },\n  rollup(fieldSchema, collaborator) {\n    throw new Error(\"Rollup is a formula column, and should not have data conversion run\");\n  },\n};\n\nconst formatCollaborator = (collaborator: any) => collaborator?.name;\n"
  },
  {
    "path": "app/common/airtable/AirtableDataImporterTypes.ts",
    "content": "import { ListAirtableRecordsResult } from \"app/common/airtable/AirtableAPI\";\nimport { AirtableTableId } from \"app/common/airtable/AirtableAPITypes\";\nimport { AirtableBaseSchemaCrosswalk, GristTableId } from \"app/common/airtable/AirtableCrosswalk\";\nimport { BulkColValues, TableColValues } from \"app/common/DocActions\";\n\n/**\n * Parameters for importing data from Airtable into Grist.\n */\nexport interface AirtableDataImportParams {\n  // Airtable data API operations. Used to export data from Airtable.\n  listRecords: ListRecordsFunc,\n\n  // Grist data API operations. Used to import data into Grist.\n  addRows: AddRowsFunc,\n  updateRows: UpdateRowsFunc,\n  uploadAttachment: UploadAttachmentFunc,\n\n  // Mapping of Airtable tables to Grist tables.\n  schemaCrosswalk: AirtableBaseSchemaCrosswalk,\n\n  onProgress?(progress: AirtableImportProgress): void,\n}\n\n/**\n * The progress of an Airtable import.\n *\n * Used by the UI to show a progress bar with an optional status message communicating\n * the current operation in progress (e.g. importing attachments).\n */\nexport interface AirtableImportProgress {\n  percent: number;\n  status?: string;\n}\n\n/**\n * Function that fetches records from an Airtable table.\n */\nexport type ListRecordsFunc = (tableId: AirtableTableId) => Promise<ListAirtableRecordsResult>;\n\n/**\n * Function that adds rows to a Grist table.\n */\ntype AddRowsFunc = (tableId: GristTableId, rows: BulkColValues) => Promise<number[]>;\n\n/**\n * Function that updates the column value(s) of a set of rows in a Grist table.\n */\nexport type UpdateRowsFunc = (tableId: GristTableId, rows: TableColValues) => Promise<number[]>;\n\n/**\n * Function that uploads an attachment blob to Grist and returns the attachment ID.\n */\nexport type UploadAttachmentFunc = (value: string | Blob, filename?: string) => Promise<number>;\n"
  },
  {
    "path": "app/common/airtable/AirtableReferenceTracker.ts",
    "content": "import { AirtableFieldSchema } from \"app/common/airtable/AirtableAPITypes\";\nimport { AirtableFieldMappingInfo } from \"app/common/airtable/AirtableCrosswalk\";\nimport { UpdateRowsFunc } from \"app/common/airtable/AirtableDataImporterTypes\";\nimport { TableColValues } from \"app/common/DocActions\";\nimport { isNonNullish } from \"app/common/gutil\";\nimport { BulkColValues, GristObjCode } from \"app/plugin/GristData\";\n\nexport type RefValuesByColumnId = Record<string, string[] | undefined>;\n\ninterface UnresolvedRefsForRecord {\n  gristRecordId: number;\n  refsByColumnId: RefValuesByColumnId;\n}\n\nexport class ReferenceTracker {\n  // Maps known airtable ids to their grist row ids to enable reference resolution.\n  // Airtable row ids are guaranteed unique within a base.\n  private _rowIdLookup = new Map<string, number>();\n  // Group references by table and row to achieve bulk-updates and atomic resolutions for rows.\n  private _tableReferenceTrackers = new Map<string, TableReferenceTracker>();\n\n  public addRecordIdMapping(originalRecordId: string, gristRecordId: number) {\n    this._rowIdLookup.set(originalRecordId, gristRecordId);\n  }\n\n  public resolve(originalRecordId: string): number | undefined {\n    return this._rowIdLookup.get(originalRecordId);\n  }\n\n  public addTable(gristTableId: string, columnIdsToUpdate: string[]) {\n    const tableTracker = new TableReferenceTracker(this, gristTableId, columnIdsToUpdate);\n    this._tableReferenceTrackers.set(gristTableId, tableTracker);\n    return tableTracker;\n  }\n\n  public getTables(): TableReferenceTracker[] {\n    return Array.from(this._tableReferenceTrackers.values());\n  }\n}\n\n// Store and resolve references per-table to enable bulk updates.\nexport class TableReferenceTracker {\n  private _unresolvedRefsForRecords: UnresolvedRefsForRecord[] = [];\n\n  // To perform bulk updates, all reference columns need updating at the same time.\n  // Enforce this by explicitly listing the column ids to use during instantiation.\n  public constructor(private _parent: ReferenceTracker, private _tableId: string, private _columnIds: string[]) {\n  }\n\n  public addUnresolvedRecord(unresolvedRefsForRecord: UnresolvedRefsForRecord) {\n    this._unresolvedRefsForRecords.push(unresolvedRefsForRecord);\n  }\n\n  public async bulkUpdateRowsWithUnresolvedReferences(\n    updateRows: UpdateRowsFunc,\n    options?: { batchSize?: number },\n  ) {\n    const batchSize = options?.batchSize ?? 100;\n\n    let pendingUpdate: TableColValues = { id: [], ...createEmptyBulkColValues(this._columnIds) };\n\n    for (const unresolvedRefsForRecord of this._unresolvedRefsForRecords) {\n      pendingUpdate.id.push(unresolvedRefsForRecord.gristRecordId);\n\n      // Every row needs an entry in its respective column in the bulk update, so always loop through\n      // the same columns for every row.\n      for (const columnId of this._columnIds) {\n        const references = unresolvedRefsForRecord.refsByColumnId[columnId];\n        // TODO - Unresolvable references are currently just skipped silently. Find a way to display\n        //        them in the cell / UI.\n        const resolvedReferences = references ?\n          references.map(originalRecordId => this._parent.resolve(originalRecordId)).filter(isNonNullish) : [];\n        pendingUpdate[columnId].push(\n          [GristObjCode.List, ...resolvedReferences],\n        );\n      }\n\n      if (pendingUpdate.id.length >= batchSize) {\n        await updateRows(this._tableId, pendingUpdate);\n        pendingUpdate = { id: [], ...createEmptyBulkColValues(this._columnIds) };\n      }\n    }\n\n    if (pendingUpdate.id.length > 0) {\n      await updateRows(this._tableId, pendingUpdate);\n    }\n  }\n}\n\nexport function isRefField(field: AirtableFieldSchema) {\n  return field.type === \"multipleRecordLinks\";\n}\n\nexport function extractRefFromRecordField(\n  fieldValue: any,\n  fieldMapping: AirtableFieldMappingInfo,\n): string[] | undefined {\n  if (fieldMapping.airtableField.type === \"multipleRecordLinks\") {\n    return fieldValue;\n  }\n  return undefined;\n}\n\nexport function createEmptyBulkColValues(columnIds: string[]): BulkColValues {\n  return Object.fromEntries(columnIds.map(id => [id, []]));\n}\n"
  },
  {
    "path": "app/common/airtable/AirtableSchemaImporter.ts",
    "content": "import {\n  AirtableBaseSchema, AirtableChoiceValue,\n  AirtableFieldSchema,\n  AirtableTableSchema,\n} from \"app/common/airtable/AirtableAPITypes\";\nimport {\n  ColumnImportSchema,\n  DocSchemaImportWarning,\n  FormulaTemplate,\n  ImportSchema,\n  OriginalTableRef,\n} from \"app/common/DocSchemaImport\";\nimport { RecalcWhen } from \"app/common/gristTypes\";\n\n/**\n * Design note: this needs to be deterministic and based solely on the Airtable base schema,\n * it should not be based on the current state of the Grist doc or any other parameters passed to\n * the import.\n *\n * Other areas of the import code may transform the created schema\n * (e.g. skipping tables, resolving references).\n * If this schema changes based on the destination document state, a user-given parameter or anything\n * not directly derived from the Airtable schema, the remainder of the import code may not adapt the\n * schema properly for the target document.\n */\nexport function gristDocSchemaFromAirtableSchema(\n  baseSchema: AirtableBaseSchema,\n): { schema: ImportSchema; warnings: DocSchemaImportWarning[] } {\n  const warnings: DocSchemaImportWarning[] = [];\n\n  const schema: ImportSchema = {\n    tables: baseSchema.tables.map((baseTable) => {\n      const { columns, warnings: columnWarnings } =\n        convertAirtableTableFieldsToColumnSchemas({ base: baseSchema, table: baseTable });\n\n      warnings.push(...columnWarnings);\n\n      return {\n        originalId: baseTable.id,\n        desiredGristId: baseTable.name,\n        columns: [createAirtableIdColumnSchema(), ...columns],\n      };\n    }),\n  };\n\n  return { schema, warnings };\n}\n\nfunction convertAirtableTableFieldsToColumnSchemas(\n  params: { base: AirtableBaseSchema, table: AirtableTableSchema },\n) {\n  const { table } = params;\n  const warnings: DocSchemaImportWarning[] = [];\n  const columns = table.fields\n    .map((field) => {\n      const result = convertAirtableFieldToColumnSchema({ field, ...params });\n\n      if (result.warning) {\n        warnings.push(result.warning);\n      }\n\n      return result.column;\n    })\n    .filter((column): column is ColumnImportSchema => column !== undefined);\n\n  return { columns, warnings };\n}\n\nfunction convertAirtableFieldToColumnSchema(\n  params: { base: AirtableBaseSchema, table: AirtableTableSchema, field: AirtableFieldSchema  },\n): { column?: ColumnImportSchema, warning?: DocSchemaImportWarning } {\n  const { field, table, base } = params;\n\n  if (!AirtableFieldMappers[field.type]) {\n    return {\n      column: undefined,\n      warning: new UnsupportedFieldTypeWarning(field.type, field.name, { originalTableId: table.id }),\n    };\n  }\n  return AirtableFieldMappers[field.type]({\n    field,\n    table,\n    getTableIdForField: (fieldId: string) => findTableIdForField(base, fieldId),\n  });\n}\n\nfunction findTableIdForField(baseSchema: AirtableBaseSchema, fieldId: string) {\n  const tableId = baseSchema.tables.find(table => table.fields.find(field => field.id === fieldId))?.id;\n  // Generally shouldn't happen - the schema should always have sufficient info to resolve a valid field id.\n  if (tableId === undefined) {\n    throw new Error(`Unable to resolve table id for Airtable field ${fieldId}`);\n  }\n  return tableId;\n}\n\nexport const AirtableIdColumnLabel = \"Airtable Id\";\nfunction createAirtableIdColumnSchema(): ColumnImportSchema {\n  return {\n    originalId: \"airtableId\",\n    desiredGristId: \"Airtable Id\",\n    type: \"Text\",\n    label: AirtableIdColumnLabel,\n    untieColIdFromLabel: true,\n  };\n}\n\ninterface AirtableFieldMapperParams {\n  field: AirtableFieldSchema,\n  table: AirtableTableSchema,\n  getTableIdForField: (fieldId: string) => string,\n}\n\ninterface AirtableFieldMapperResult {\n  column: ColumnImportSchema,\n  warning?: DocSchemaImportWarning,\n}\n\ntype AirtableFieldMapper = (params: AirtableFieldMapperParams) => AirtableFieldMapperResult;\nconst AirtableFieldMappers: { [type: string]: AirtableFieldMapper } = {\n  aiText({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n      },\n    };\n  },\n  autoNumber({ field, table }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Numeric\",\n        formula: {\n          formula: \"MAX(PEEK([R0].all.[R1]))+1\",\n          replacements: [\n            { originalTableId: table.id },\n            { originalTableId: table.id, originalColId: field.id },\n          ],\n        },\n      },\n      warning: new AutoNumberLimitationWarning(field.name, { originalTableId: table.id }),\n    };\n  },\n  checkbox({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Bool\",\n      },\n    };\n  },\n  count({ field, table }) {\n    let formula: FormulaTemplate = { formula: \"\", replacements: [] };\n    const fieldOptions = field.options;\n    if (fieldOptions?.isValid && fieldOptions.recordLinkFieldId) {\n      formula = {\n        formula: \"len($[R0])\",\n        replacements: [{ originalTableId: table.id, originalColId: fieldOptions.recordLinkFieldId }],\n      };\n    }\n\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Numeric\",\n        isFormula: true,\n        formula,\n      },\n      warning: new CountLimitationWarning(field.name, { originalTableId: table.id }),\n    };\n  },\n  createdBy({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n      },\n    };\n  },\n  createdTime({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"DateTime\",\n        formula: { formula: \"NOW()\" },\n        recalcWhen: RecalcWhen.DEFAULT,\n      },\n    };\n  },\n  currency({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Numeric\",\n        widgetOptions: {\n          // Airtable only provides a currency symbol, which is pretty useless for setting this column up.\n          // Instead of showing a wrong currency - omit currency formatting and just use precision.\n          decimals: field.options?.precision ?? 2,\n          maxDecimals: field.options?.precision ?? 2,\n        },\n      },\n    };\n  },\n  date({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Date\",\n        widgetOptions: {\n          isCustomDateFormat: true,\n          // Airtable and Grist seem to share identical format syntax, based on limited testing\n          dateFormat: field.options?.dateFormat?.format ?? \"MM/DD/YYYY\",\n        },\n      },\n    };\n  },\n  dateTime({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"DateTime\",\n        widgetOptions: {\n          isCustomDateFormat: true,\n          // Airtable and Grist seem to share identical format syntax, based on limited testing\n          dateFormat: field.options?.dateFormat?.format ?? \"MM/DD/YYYY\",\n          isCustomTimeFormat: true,\n          // Airtable and Grist seem to share identical format syntax, based on limited testing\n          timeFormat: field.options?.timeFormat?.format ?? \"h:mma\",\n        },\n      },\n    };\n  },\n  duration({ field, table }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Numeric\",\n      },\n      warning: new DurationFormatWarning(field.name, { originalTableId: table.id }),\n    };\n  },\n  email({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n      },\n    };\n  },\n  formula({ field }) {\n    const formula = typeof field.options?.formula === \"string\" ? field.options?.formula : \"No formula set\";\n    // Store the formula as a comment to prevent it showing errors.\n    const formattedFormula = formula.split(\"\\n\").map(line => `#${line.trim()}`).join(\"\\n\");\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        // The field schema from Airtable has more information on what this should be,\n        // such as field type, options and referenced fields.\n        // The logic to implement that however doesn't seem worth the time investment.\n        type: \"Any\",\n        formula: { formula: formattedFormula },\n        isFormula: true,\n      },\n    };\n  },\n  lastModifiedBy({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n        formula: { formula: 'user and f\"{user.Name}\"' },\n        recalcWhen: 2,\n      },\n    };\n  },\n  lastModifiedTime({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"DateTime\",\n        formula: { formula: \"NOW()\" },\n        recalcWhen: 2,\n        widgetOptions: {\n          isCustomDateFormat: true,\n          dateFormat: field.options?.result?.dateFormat?.format ?? \"MM/DD/YYYY\",\n          isCustomTimeFormat: true,\n          timeFormat: field.options?.result?.timeFormat?.format ?? \"h:mma\",\n        },\n      },\n    };\n  },\n  multilineText({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n      },\n    };\n  },\n  multipleAttachments({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Attachments\",\n      },\n    };\n  },\n  multipleCollaborators({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n        // Do we make a collaborators table and make this a reference instead?\n      },\n    };\n  },\n  multipleLookupValues({ field, table, getTableIdForField }) {\n    let formula: FormulaTemplate = { formula: \"\" };\n    const fieldOptions = field.options;\n    if (fieldOptions?.recordLinkFieldId && fieldOptions.fieldIdInLinkedTable) {\n      formula = {\n        formula: \"$[R0].[R1]\",\n        replacements: [\n          { originalTableId: table.id, originalColId: fieldOptions.recordLinkFieldId },\n          {\n            originalTableId: getTableIdForField(fieldOptions.fieldIdInLinkedTable),\n            originalColId: fieldOptions.fieldIdInLinkedTable,\n          },\n        ],\n      };\n    }\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Any\",\n        isFormula: true,\n        formula,\n      },\n    };\n  },\n  multipleRecordLinks({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: field.options?.prefersSingleRecordLink ? \"Ref\" : \"RefList\",\n        ref: {\n          originalTableId: field.options?.linkedTableId,\n        },\n      },\n    };\n  },\n  multipleSelects({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"ChoiceList\",\n        widgetOptions: {\n          choices: field.options?.choices.map((choice: AirtableChoiceValue) => choice.name),\n          // We could import the color by mapping choice.color (e.g. tealLight2) to a hex color\n          choiceOptions: {},\n        },\n      },\n    };\n  },\n  number({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Numeric\",\n        widgetOptions: {\n          decimals: field.options?.precision,\n        },\n      },\n    };\n  },\n  percent({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Numeric\",\n        widgetOptions: {\n          decimals: field.options?.precision,\n          numMode: \"percent\",\n        },\n      },\n    };\n  },\n  phoneNumber({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n      },\n    };\n  },\n  rating({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Int\",\n        // Consider setting up some nice conditional formatting.\n      },\n    };\n  },\n  richText({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n        widgetOptions: {\n          widget: \"Markdown\",\n        },\n      },\n    };\n  },\n  rollup({ field, table, getTableIdForField }) {\n    let formula: FormulaTemplate = { formula: \"\" };\n    const fieldOptions = field.options;\n    if (fieldOptions?.recordLinkFieldId && fieldOptions.fieldIdInLinkedTable) {\n      formula = {\n        formula: \"$[R0].[R1]\",\n        replacements: [\n          { originalTableId: table.id, originalColId: fieldOptions.recordLinkFieldId },\n          {\n            originalTableId: getTableIdForField(fieldOptions.fieldIdInLinkedTable),\n            originalColId: fieldOptions.fieldIdInLinkedTable,\n          },\n        ],\n      };\n    }\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Any\",\n        isFormula: true,\n        formula,\n      },\n      warning: new RollupLimitationWarning(field.name, { originalTableId: table.id }),\n    };\n  },\n  singleCollaborator({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n      },\n    };\n  },\n  singleLineText({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n        // We could potentially limit this to only a single line, but it's a view section option\n        // which isn't (at the time of writing) supported by any of the import tools (which only deal\n        // with structure, e.g. tables and columns).\n      },\n    };\n  },\n  singleSelect({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Choice\",\n        widgetOptions: {\n          choices: field.options?.choices.map((choice: AirtableChoiceValue) => choice.name),\n          // We could import the color by mapping choice.color (e.g. tealLight2) to a hex color\n          choiceOptions: {},\n        },\n      },\n    };\n  },\n  url({ field }) {\n    return {\n      column: {\n        originalId: field.id,\n        desiredGristId: field.name,\n        label: field.name,\n        type: \"Text\",\n        widgetOptions: {\n          widget: \"HyperLink\",\n        },\n      },\n    };\n  },\n};\n\nclass UnsupportedFieldTypeWarning implements DocSchemaImportWarning {\n  public readonly message: string;\n\n  constructor(fieldType: string, fieldName: string, public readonly ref: OriginalTableRef) {\n    this.message = `Field \"${fieldName}\" has unsupported type \"${fieldType}\" and will be skipped`;\n  }\n}\n\nclass AutoNumberLimitationWarning implements DocSchemaImportWarning {\n  public readonly message: string;\n\n  constructor(fieldName: string, public readonly ref: OriginalTableRef) {\n    this.message = `AutoNumber field \"${fieldName}\" behaviour will not be identical to Airtable's. Values may be re-used if rows are edited or deleted.`;\n  }\n}\n\nclass DurationFormatWarning implements DocSchemaImportWarning {\n  public readonly message: string;\n\n  constructor(fieldName: string, public readonly ref: OriginalTableRef) {\n    this.message = `Duration field \"${fieldName}\" will be imported as a numeric duration in seconds. Duration formatting is not yet supported.`;\n  }\n}\n\nclass RollupLimitationWarning implements DocSchemaImportWarning {\n  public readonly message: string;\n\n  constructor(fieldName: string, public readonly ref: OriginalTableRef) {\n    this.message = `Rollup field \"${fieldName}\" may not match Airtable. Summary parameters and filter conditions are not supported.`;\n  }\n}\n\nclass CountLimitationWarning implements DocSchemaImportWarning {\n  public readonly message: string;\n\n  constructor(fieldName: string, public readonly ref: OriginalTableRef) {\n    this.message = `Count field \"${fieldName}\" may not match Airtable. Filter conditions are not supported.`;\n  }\n}\n"
  },
  {
    "path": "app/common/arrayToString.ts",
    "content": "/**\n * Functions to convert between an array of bytes and a string. The implementations are\n * different for Node and for the browser.\n */\n\ndeclare const TextDecoder: any, TextEncoder: any;\n\nexport let arrayToString: (data: Uint8Array) => string;\nexport let stringToArray: (data: string) => Uint8Array;\n\nif (typeof TextDecoder !== \"undefined\") {\n  // Note that constructing a TextEncoder/Decoder takes time, so it's faster to reuse.\n  const dec = new TextDecoder(\"utf8\");\n  const enc = new TextEncoder(\"utf8\");\n  arrayToString = function(uint8Array: Uint8Array): string {\n    return dec.decode(uint8Array);\n  };\n  stringToArray = function(str: string): Uint8Array {\n    return enc.encode(str);\n  };\n} else {\n  arrayToString = function(uint8Array: Uint8Array): string {\n    return Buffer.from(uint8Array).toString(\"utf8\");\n  };\n  stringToArray = function(str: string): Uint8Array {\n    return new Uint8Array(Buffer.from(str, \"utf8\"));\n  };\n}\n"
  },
  {
    "path": "app/common/asyncIterators.ts",
    "content": "/**\n * Just some basic utilities for async generators that should really be part of the language or lodash or something.\n */\n\nexport async function* asyncFilter<T>(it: AsyncIterableIterator<T>, pred: (x: T) => boolean): AsyncIterableIterator<T> {\n  for await (const x of it) {\n    if (pred(x)) {\n      yield x;\n    }\n  }\n}\n\nexport async function* asyncMap<T, R>(it: AsyncIterableIterator<T>, mapper: (x: T) => R): AsyncIterableIterator<R> {\n  for await (const x of it) {\n    yield mapper(x);\n  }\n}\n\nexport async function toArray<T>(it: AsyncIterableIterator<T>): Promise<T[]> {\n  const result = [];\n  for await (const x of it) {\n    result.push(x);\n  }\n  return result;\n}\n"
  },
  {
    "path": "app/common/csvFormat.ts",
    "content": "/**\n * Simple utilities for escaping/quoting/parsing CSV data.\n *\n * This only supports the default Excel-like encoding, in which fields containing any separators\n * or quotes get quoted (using '\"'), and quotes get doubled.\n *\n * Quoting is also applied when values contain leading or trailing whitespace, and on parsing,\n * leading or trailing whitespace in unquoted values is trimmed, so that \",\" or \", \" may be used\n * as a separator.\n *\n * This is intended for copy-pasting multi-choice values, where plain comma-separated text is the\n * most user-friendly, and CSV encoding is used to ensure we can handle arbitrary values.\n */\n\n// Encode a row. If {prettier: true} is set, separate output with \", \". Leading whitespace gets\n// encoded in any case.\nexport function csvEncodeRow(values: string[], options: { prettier?: boolean } = {}): string {\n  return values.map(csvEncodeCell).join(options.prettier ? \", \" : \",\");\n}\n\nexport function csvDecodeRow(text: string): string[] {\n  // Clever regexp from https://github.com/micnews/csv-line\n  const parts = text.split(/((?:(?:\"[^\"]*\")|[^,])*)/);\n  const main = parts.filter((v, idx) => idx % 2).map(csvDecodeCell);\n  // The \"delimiter\" (odd-numbered parts) is our content. If it's not at the start/end, it means\n  // we have commas, and should include empty fields at those ends.\n  if (parts[0]) { main.unshift(\"\"); }\n  if (parts[parts.length - 1]) { main.push(\"\"); }\n  return main;\n}\n\nexport function csvEncodeCell(value: string): string {\n  return /[,\\r\\n\"]|^\\s|\\s$/.test(value) ? '\"' + value.replace(/\"/g, '\"\"') + '\"' : value;\n}\n\nexport function csvDecodeCell(value: string): string {\n  return value.trim().replace(/^\"|\"$/g, \"\").replace(/\"\"/g, '\"');\n}\n"
  },
  {
    "path": "app/common/declarations.d.ts",
    "content": "declare module \"app/common/MemBuffer\" {\n  const MemBuffer: any;\n  type MemBuffer = any;\n  export = MemBuffer;\n}\n\ndeclare module \"locale-currency/map\" {\n  const Map: Record<string, string>;\n  type Map = Record<string, string>;\n  export = Map;\n}\n\ndeclare namespace Intl {\n  class DisplayNames {\n    public static supportedLocalesOf(locales: string | string[]): string[];\n    constructor(locales?: string, options?: object);\n    public of(code: string): string;\n  }\n\n  class Locale {\n    public region: string;\n    public language: string;\n    constructor(locale: string);\n  }\n}\n\ndeclare module \"@gristlabs/moment-guess/dist/bundle.js\";\n"
  },
  {
    "path": "app/common/delay.ts",
    "content": "/**\n * Returns a promise that resolves in the given number of milliseconds.\n * (A replica of bluebird.delay using native promises.)\n */\nexport function delay(msec: number): Promise<void> {\n  return new Promise<void>(resolve => setTimeout(resolve, msec));\n}\n"
  },
  {
    "path": "app/common/emails.ts",
    "content": "/**\n *\n * Utilities related to email normalization.  Currently\n * trivial, but could potentially need special per-domain\n * rules in future.\n *\n * Email addresses are a bit slippery.  Domain names are\n * case insensitive, but user names may or may not be,\n * depending on the mail server handling the domain.\n * Other special treatment of user names may also be in\n * place for particular domains (periods, plus sign, etc).\n *\n * We treat emails as case-insensitive for the purposes\n * of determining equality of emails, and indexing users\n * by email address.\n *\n */\n\n/**\n *\n * Convert the supplied email address to a normalized form\n * that we will use for indexing and equality tests.\n * Many possible email addresses could map to the same\n * normalized result; as far as we are concerned those\n * addresses are equivalent.\n *\n * The normalization we do is a simple lowercase.  This\n * means we won't be able to treat both Jane@x.y and\n * jane@x.y as separate email addresses, even through\n * they may in fact be separate mailboxes on x.y.\n *\n * The normalized email is not something we should show\n * the user in the UI, but is rather for internal purposes.\n *\n * The original non-normalized email is called a\n * \"display email\" to distinguish it from a \"normalized\n * email\"\n *\n */\nexport function normalizeEmail(displayEmail: string): string {\n  // We take the lower case, without use of locale.\n  return displayEmail.toLowerCase();\n}\n"
  },
  {
    "path": "app/common/getCurrentTime.ts",
    "content": "import moment from \"moment-timezone\";\n\n/**\n * Returns the current local time. Allows overriding via a \"currentTime\" URL parameter, for the sake\n * of tests.\n */\nexport default function getCurrentTime(): moment.Moment {\n  const getDefault = () => moment();\n  if (typeof window === \"undefined\" || !window) { return getDefault(); }\n  const searchParams = new URLSearchParams(window.location.search);\n\n  return searchParams.has(\"currentTime\") ? moment(searchParams.get(\"currentTime\") || undefined) : getDefault();\n}\n"
  },
  {
    "path": "app/common/gristTypes.ts",
    "content": "import { CellValue, CellVersions } from \"app/common/DocActions\";\nimport { removePrefix } from \"app/common/gutil\";\nimport { GristObjCode, GristType } from \"app/plugin/GristData\";\n\nimport isString from \"lodash/isString\";\n\nexport type GristTypeInfo =\n  { type: \"DateTime\", timezone: string } |\n  { type: \"Ref\", tableId: string } |\n  { type: \"RefList\", tableId: string } |\n  { type: Exclude<GristType, \"DateTime\" | \"Ref\" | \"RefList\"> };\n\nexport const MANUALSORT = \"manualSort\";\n\n// Whether a column is internal and should be hidden.\nexport function isHiddenCol(colId: string): boolean {\n  return colId.startsWith(\"gristHelper_\") || colId === MANUALSORT;\n}\n\n// This mapping includes both the default value, and its representation for SQLite.\nconst _defaultValues: { [key in GristType]: [CellValue, string] } = {\n  Any: [null,  \"NULL\"],\n  Attachments: [null,  \"NULL\"],\n  Blob: [null,  \"NULL\"],\n  // Bool is only supported by SQLite as 0 and 1 values.\n  Bool: [false, \"0\"],\n  Choice: [\"\",    \"''\"],\n  ChoiceList: [null,  \"NULL\"],\n  Date: [null,  \"NULL\"],\n  DateTime: [null,  \"NULL\"],\n  Id: [0,     \"0\"],\n  Int: [0,     \"0\"],\n  // Note that \"1e999\" is a way to store Infinity into SQLite. This is verified by \"Defaults\"\n  // tests in DocStorage.js. See also http://sqlite.1065341.n5.nabble.com/Infinity-td55327.html.\n  ManualSortPos: [Number.POSITIVE_INFINITY, \"1e999\"],\n  Numeric: [0,     \"0\"],\n  PositionNumber: [Number.POSITIVE_INFINITY, \"1e999\"],\n  Ref: [0,     \"0\"],\n  RefList: [null,  \"NULL\"],\n  Text: [\"\",    \"''\"],\n};\n\n/**\n * Given a grist column type (e.g Text, Numeric, ...) returns the default value for that type.\n * If options.sqlFormatted is true, returns the representation of the value for SQLite.\n */\nexport function getDefaultForType(colType: string, options: { sqlFormatted?: boolean } = {}) {\n  const type = extractTypeFromColType(colType);\n  return (_defaultValues[type as GristType] || _defaultValues.Any)[options.sqlFormatted ? 1 : 0];\n}\n\n/**\n * Convert a type like 'Numeric', 'DateTime:America/New_York', or 'Ref:Table1' to a GristTypeInfo\n * object.\n */\nexport function extractInfoFromColType(colType: string): GristTypeInfo {\n  if (colType === \"Attachments\") {\n    return { type: \"RefList\", tableId: \"_grist_Attachments\" };\n  }\n  const colon = colType.indexOf(\":\");\n  const [type, arg] = (colon === -1) ? [colType] : [colType.slice(0, colon), colType.slice(colon + 1)];\n  return (type === \"Ref\") ? { type, tableId: String(arg) } :\n    (type === \"RefList\")  ? { type, tableId: String(arg) } :\n      (type === \"DateTime\") ? { type, timezone: String(arg) } :\n        { type } as GristTypeInfo;\n}\n\n/**\n * Re-encodes a CellValue of a given Grist type as a value suitable to use in an Any column. E.g.\n *    reencodeAsTypedCellValue(123, {type: \"Numeric\"}) -> 123\n *    reencodeAsTypedCellValue(123, {type: \"Date\"}) -> [\"d\", 123]\n *    reencodeAsTypedCellValue(123, {type: \"Ref\", tableId: \"Table1\"}) -> [\"R\", \"Table1\", 123]\n *    reencodeAsTypedCellValue([\"L\", 123], {type: \"RefList\", tableId: \"Table1\"}) -> [\"r\", \"Table1\", [123]]\n *    reencodeAsTypedCellValue([\"L\", 123], {type: \"Attachments\"}) -> [\"r\", \"_grist_Attachments\", [123]]\n */\nexport function reencodeAsTypedCellValue(value: CellValue, typeInfo: GristTypeInfo): CellValue {\n  if (typeof value === \"number\") {\n    switch (typeInfo.type) {\n      case \"Date\": return [GristObjCode.Date, value];\n      case \"DateTime\": return [GristObjCode.DateTime, value, typeInfo.timezone];\n      case \"Ref\": return [GristObjCode.Reference, typeInfo.tableId, value];\n    }\n  } else if (isList(value) || value === null) {\n    const items = value ? value.slice(1) : [];\n    switch (typeInfo.type) {\n      case \"ChoiceList\": return [GristObjCode.List, ...items];\n      case \"RefList\": return [GristObjCode.ReferenceList, typeInfo.tableId, items];\n      case \"Attachments\": return [GristObjCode.ReferenceList, \"_grist_Attachments\", items];\n    }\n  }\n  return value;\n}\n\n/**\n * Returns whether a value (as received in a DocAction) represents a custom object.\n */\nexport function isObject(value: CellValue): value is [GristObjCode, any?] {\n  return Array.isArray(value);\n}\n\n/**\n * Returns GristObjCode of the value if the value is an object, or null otherwise.\n * The return type includes any string, since we should not assume we can only get valid codes.\n */\nexport function getObjCode(value: CellValue): GristObjCode | string | null {\n  return Array.isArray(value) ? value[0] : null;\n}\n\n/**\n * Returns whether a value (as received in a DocAction) represents a raised exception.\n */\nexport function isRaisedException(value: CellValue): boolean {\n  return getObjCode(value) === GristObjCode.Exception;\n}\n\n/**\n * Returns whether a value (as received in a DocAction) represents a group of versions for\n * a comparison or conflict.\n */\nexport function isVersions(value: CellValue): value is [GristObjCode.Versions, CellVersions] {\n  return getObjCode(value) === GristObjCode.Versions;\n}\n\nexport function isSkip(value: CellValue): value is [GristObjCode.Skip] {\n  return getObjCode(value) === GristObjCode.Skip;\n}\n\nexport function isCensored(value: CellValue): value is [GristObjCode.Censored] {\n  return getObjCode(value) === GristObjCode.Censored;\n}\n\n/**\n * Returns whether a value (as received in a DocAction) represents a list.\n */\nexport function isList(value: CellValue): value is [GristObjCode.List, ...CellValue[]] {\n  return Array.isArray(value) && value[0] === GristObjCode.List;\n}\n\n/**\n * Returns whether a value (as received in a DocAction) represents a reference to a record.\n */\nexport function isReference(value: CellValue): value is [GristObjCode.Reference, string, number] {\n  return Array.isArray(value) && value[0] === GristObjCode.Reference;\n}\n\n/**\n * Returns whether a value (as received in a DocAction) represents a reference list (RecordSet).\n */\nexport function isReferenceList(value: CellValue): value is [GristObjCode.ReferenceList, string, number[]] {\n  return Array.isArray(value) && value[0] === GristObjCode.ReferenceList;\n}\n\n/**\n * Returns whether a value (as received in a DocAction) represents a reference or reference list.\n */\nexport function isReferencing(value: CellValue):\n  value is [GristObjCode.ReferenceList | GristObjCode.Reference, string, number[] | number] {\n  return Array.isArray(value) &&\n    (value[0] === GristObjCode.ReferenceList || value[0] === GristObjCode.Reference);\n}\n\n/**\n * Returns whether a value (as received in a DocAction) represents a list or is null,\n * which is a valid value for list types in grist.\n */\nexport function isListOrNull(value: CellValue): boolean {\n  return value === null || isList(value);\n}\n\n/**\n * Returns whether a value (as received in a DocAction) represents an empty list.\n */\nexport function isEmptyList(value: CellValue): boolean {\n  return Array.isArray(value) && value.length === 1 && value[0] === GristObjCode.List;\n}\n\n/**\n * Returns whether a value (as received in a DocAction) represents an empty reference list.\n */\nexport function isEmptyReferenceList(value: CellValue): boolean {\n  return Array.isArray(value) && value.length === 1 && value[0] === GristObjCode.ReferenceList;\n}\n\nfunction isNumber(v: CellValue) { return typeof v === \"number\" || typeof v === \"boolean\"; }\nfunction isNumberOrNull(v: CellValue) { return isNumber(v) || v === null; }\nfunction isBoolean(v: CellValue) { return typeof v === \"boolean\" || v === 1 || v === 0; }\nfunction isBooleanOrNull(v: CellValue) { return isBoolean(v) || v === null; }\n\n// These values are not regular cell values, even in a column of type Any.\nconst abnormalValueTypes: string[] = [GristObjCode.Exception, GristObjCode.Pending, GristObjCode.Skip,\n  GristObjCode.Unmarshallable, GristObjCode.Versions];\n\nfunction isNormalValue(value: CellValue) {\n  return !abnormalValueTypes.includes(getObjCode(value)!);\n}\n\n/**\n * Map of Grist type to an \"isRightType\" checker function, which determines if a given values type\n * matches the declared type of the column.\n */\nconst rightType: { [key in GristType]: (value: CellValue) => boolean } = {\n  Any: isNormalValue,\n  Attachments: isListOrNull,\n  Text: isString,\n  Blob: isString,\n  Int: isNumberOrNull,\n  Bool: isBooleanOrNull,\n  Date: isNumberOrNull,\n  DateTime: isNumberOrNull,\n  Numeric: isNumberOrNull,\n  Id: isNumber,\n  PositionNumber: isNumber,\n  ManualSortPos: isNumber,\n  Ref: isNumber,\n  RefList: isListOrNull,\n  Choice: isString,\n  ChoiceList: isListOrNull,\n};\n\nexport function isRightType(type: string): undefined | ((value: CellValue, options?: any) => boolean) {\n  return rightType[type as GristType];\n}\n\nexport function extractTypeFromColType(type: string): string {\n  if (!type) { return type; }\n  const colon = type.indexOf(\":\");\n  return (colon === -1 ? type : type.slice(0, colon));\n}\n\n/**\n * Enum for values of columns' recalcWhen property, corresponding to Python definitions in\n * schema.py.\n */\nexport enum RecalcWhen {\n  DEFAULT = 0,         // Calculate on new records or when any field in recalcDeps changes.\n  NEVER = 1,           // Don't calculate automatically (but user can trigger manually)\n  MANUAL_UPDATES = 2,  // Calculate on new records and on manual updates to any data field.\n}\n\n/**\n * Converts SQL type strings produced by the Sequelize library into its corresponding\n * Grist type. The list of types is based on an analysis of SQL type string outputs\n * produced by the Sequelize library (mostly covered in lib/data-types.js). Some\n * additional engine/dialect specific types are detailed in dialect directories.\n *\n * TODO: A handful of exotic SQL types (mostly from PostgreSQL) will currently throw an\n * Error, rather than returning a type. Further testing is required to determine\n * whether Grist can manage those data types.\n *\n * @param  {String} sqlType A string produced by Sequelize's describeTable query\n * @return {String}         The corresponding Grist type string\n * @throws {Error}          If the sqlType is unrecognized or unsupported\n */\nexport function sequelizeToGristType(sqlType: string): GristType {\n  // Sequelize type strings can include parens (e.g., `CHAR(10)`). This function\n  // ignores those additional details when determining the Grist type.\n  let endMarker = sqlType.length;\n  const parensMarker = sqlType.indexOf(\"(\");\n  endMarker = parensMarker > 0 ? parensMarker : endMarker;\n\n  // Type strings might also include a space after the basic type description.\n  // The type `DOUBLE PRECISION` is one such example, but modifiers or attributes\n  // relevant to the type might also appear after the type itself (e.g., UNSIGNED,\n  // NONZERO). These are ignored when determining the Grist type.\n  const spaceMarker = sqlType.indexOf(\" \");\n  endMarker = spaceMarker > 0 && spaceMarker < endMarker ? spaceMarker : endMarker;\n\n  switch (sqlType.substring(0, endMarker)) {\n    case \"INTEGER\":\n    case \"BIGINT\":\n    case \"SMALLINT\":\n    case \"INT\":\n      return \"Int\";\n    case \"NUMBER\":\n    case \"FLOAT\":\n    case \"DECIMAL\":\n    case \"NUMERIC\":\n    case \"REAL\":\n    case \"DOUBLE\":\n    case \"DOUBLE PRECISION\":\n      return \"Numeric\";\n    case \"BOOLEAN\":\n    case \"TINYINT\":\n      return \"Bool\";\n    case \"STRING\":\n    case \"CHAR\":\n    case \"TEXT\":\n    case \"UUID\":\n    case \"UUIDV1\":\n    case \"UUIDV4\":\n    case \"VARCHAR\":\n    case \"NVARCHAR\":\n    case \"TINYTEXT\":\n    case \"MEDIUMTEXT\":\n    case \"LONGTEXT\":\n    case \"ENUM\":\n      return \"Text\";\n    case \"TIME\":\n    case \"DATE\":\n    case \"DATEONLY\":\n    case \"DATETIME\":\n    case \"NOW\":\n      return \"Text\";\n    case \"BLOB\":\n    case \"TINYBLOB\":\n    case \"MEDIUMBLOB\":\n    case \"LONGBLOB\":\n      // TODO: Passing binary data to the Sandbox is throwing Errors. Proper support\n      // for these Blob data types requires some more investigation.\n      throw new Error(\"SQL type: `\" + sqlType + \"` is currently unsupported\");\n    case \"NONE\":\n    case \"HSTORE\":\n    case \"JSON\":\n    case \"JSONB\":\n    case \"VIRTUAL\":\n    case \"ARRAY\":\n    case \"RANGE\":\n    case \"GEOMETRY\":\n      throw new Error(\"SQL type: `\" + sqlType + \"` is currently untested\");\n    default:\n      throw new Error(\"Unrecognized datatype: `\" + sqlType + \"`\");\n  }\n}\n\nexport function getReferencedTableId(type: string) {\n  if (type === \"Attachments\") {\n    return \"_grist_Attachments\";\n  }\n  return removePrefix(type, \"Ref:\") || removePrefix(type, \"RefList:\");\n}\n\nexport function isRefListType(type: string) {\n  return type === \"Attachments\" || type?.startsWith(\"RefList:\");\n}\n\nexport function isListType(type: string) {\n  return type === \"ChoiceList\" || isRefListType(type);\n}\n\nexport function isNumberType(type: string | undefined) {\n  return [\"Numeric\", \"Int\"].includes(type || \"\");\n}\n\nexport function isDateLikeType(type: string) {\n  return type === \"Date\" || type.startsWith(\"DateTime\");\n}\n\nexport function isFullReferencingType(type: string) {\n  return type.startsWith(\"Ref:\") || isRefListType(type);\n}\n\nexport function isValidRuleValue(value: CellValue | undefined) {\n  // We want to strictly test if a value is boolean, when the value is 0 or 1 it might\n  // indicate other number in the future.\n  return value === null || typeof value === \"boolean\";\n}\n\n/**\n * Returns true if `value` is blank.\n *\n * Blank values include `null`, (trimmed) empty string, and 0-length lists and\n * reference lists.\n */\nexport function isBlankValue(value: CellValue) {\n  return (\n    value === null ||\n    (typeof value === \"string\" && value.trim().length === 0) ||\n    isEmptyList(value) ||\n    isEmptyReferenceList(value)\n  );\n}\n\nexport type RefListValue = [GristObjCode.List, ...number[]] | null;\n\n/**\n * Type of cell metadata information.\n */\nexport enum CellInfoType {\n  COMMENT = 1,\n}\n"
  },
  {
    "path": "app/common/gristUrls.ts",
    "content": "import { AssistantConfig } from \"app/common/Assistant\";\nimport { BillingPage, BillingSubPage, BillingTask } from \"app/common/BillingAPI\";\nimport { OpenDocMode } from \"app/common/DocListAPI\";\nimport { EngineCode } from \"app/common/DocumentSettings\";\nimport { Features as PlanFeatures } from \"app/common/Features\";\nimport { encodeQueryParams, isAffirmative, removePrefix } from \"app/common/gutil\";\nimport { ICommonUrls } from \"app/common/ICommonUrls\";\nimport ICommonUrlsTI from \"app/common/ICommonUrls-ti\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { TelemetryLevel } from \"app/common/Telemetry\";\nimport { ThemeAppearance, themeAppearances, ThemeName, themeNames } from \"app/common/ThemePrefs\";\nimport { getGristConfig } from \"app/common/urlUtils\";\nimport { Document } from \"app/common/UserAPI\";\nimport { IAttachedCustomWidget } from \"app/common/widgetTypes\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\n\nimport clone from \"lodash/clone\";\nimport pickBy from \"lodash/pickBy\";\nimport slugify from \"slugify\";\nimport * as t from \"ts-interface-checker\";\n\nconst { ICommonUrls: ICommonUrlsChecker } = t.createCheckers(ICommonUrlsTI);\n\nexport const SpecialDocPage = StringUnion(\n  \"code\", \"acl\", \"data\", \"GristDocTour\",\n  \"settings\", \"suggestions\", \"webhook\", \"timing\",\n);\ntype SpecialDocPage = typeof SpecialDocPage.type;\nexport type IDocPage = number | SpecialDocPage;\n\nexport type ViewDocPage = number | \"data\";\n/**\n * ViewDocPage is a page that shows table data (either normal or raw data view).\n */\nexport function isViewDocPage(docPage: IDocPage): docPage is ViewDocPage {\n  return typeof docPage === \"number\" || docPage === \"data\";\n}\n\n// What page to show in the user's home area. Defaults to 'workspace' if a workspace is set, and\n// to 'all' otherwise.\nexport const HomePage = StringUnion(\"all\", \"workspace\", \"templates\", \"trash\");\nexport type IHomePage = typeof HomePage.type;\n\nexport const HomePageTab = StringUnion(\"recent\", \"pinned\", \"all\");\nexport type HomePageTab = typeof HomePageTab.type;\n\nexport const WelcomePage = StringUnion(\"teams\", \"signup\", \"verify\", \"select-account\");\nexport type WelcomePage = typeof WelcomePage.type;\n\nexport const AccountPage = StringUnion(\"account\");\nexport type AccountPage = typeof AccountPage.type;\n\nexport const ActivationPage = StringUnion(\"activation\");\nexport type ActivationPage = typeof ActivationPage.type;\n\nexport const AuditLogsPage = StringUnion(\"audit-logs\");\nexport type AuditLogsPage = typeof AuditLogsPage.type;\n\nexport const LoginPage = StringUnion(\"signup\", \"login\", \"verified\", \"forgot-password\");\nexport type LoginPage = typeof LoginPage.type;\n\nexport const AdminPanelPage = StringUnion(\"admin\", \"docs\", \"users\", \"workspaces\", \"orgs\");\nexport type AdminPanelPage = typeof AdminPanelPage.type;\n\nexport const AdminPanelTab = StringUnion(\"users\", \"workspaces\", \"docs\", \"orgs\", \"details\");\nexport type AdminPanelTab = typeof AdminPanelTab.type;\n\nexport const PREFERRED_STORAGE_ANCHOR = \"preferredStorage\";\nexport const PersistentAnchor = StringUnion(PREFERRED_STORAGE_ANCHOR);\nexport type PersistentAnchor = typeof PersistentAnchor.type;\n\n// Overall UI style.  \"full\" is normal, \"singlePage\" is a single page focused, panels hidden experience.\nexport const InterfaceStyle = StringUnion(\"singlePage\", \"full\");\nexport type InterfaceStyle = typeof InterfaceStyle.type;\n\nexport const CompareEmphasis = StringUnion(\"local\", \"remote\");\nexport type CompareEmphasis = typeof CompareEmphasis.type;\n\n// Default subdomain for home api service if not otherwise specified.\nexport const DEFAULT_HOME_SUBDOMAIN = \"api\";\n\n// This is the minimum length a urlId may have if it is chosen\n// as a prefix of the docId.\nexport const MIN_URLID_PREFIX_LENGTH = 12;\n\n// Values meeting MIN_URLID_PREFIX_LENGTH that appear in non-document URLs and\n// should not be recognized as urlId prefixes when decoding URLs.\nconst RESERVED_URLID_PREFIXES = new Set([\"forgot-password\"]);\n\n// A prefix that identifies a urlId as a share key.\n// Important that this not be part of a valid docId.\nexport const SHARE_KEY_PREFIX = \"s.\";\n\n/**\n * Form framing is used to control the way forms are rendered in Grist.\n * - 'border' adds a green border around the form, used to indicate that the forms can be created\n *   by untrusted users and it makes phishing attacks harder.\n * - 'minimal' doesn't show the border, used for trusted users.\n *\n * The default value is 'border', and it can be controlled by GRIST_FEATURE_FORM_FRAMING environment\n * variable.\n */\nexport type FormFraming = \"border\" | \"minimal\";\n\n/**\n * Special ways to open a document, based on what the user intends to do.\n *   - view: Open document in read-only mode (even if user has edit rights)\n *   - fork: Open document in fork-ready mode.  This means that while edits are\n *           permitted, those edits should go to a copy of the document rather than\n *           the original.\n */\n\nexport const getCommonUrls = () => withAdminDefinedUrls({\n  help: getHelpCenterUrl(),\n  helpAccessRules: \"https://support.getgrist.com/access-rules\",\n  helpAssistant: \"https://support.getgrist.com/assistant\",\n  helpAssistantDataUse: \"https://support.getgrist.com/assistant/#data-use-policy\",\n  helpFormulaAssistantDataUse: \"https://support.getgrist.com/ai-assistant/#data-use-policy\",\n  helpColRefs: \"https://support.getgrist.com/col-refs\",\n  helpConditionalFormatting: \"https://support.getgrist.com/conditional-formatting\",\n  helpFilterButtons: \"https://support.getgrist.com/search-sort-filter/#pinning-filters\",\n  helpLinkingWidgets: \"https://support.getgrist.com/linking-widgets\",\n  helpRawData: \"https://support.getgrist.com/raw-data\",\n  helpSuggestions: \"https://support.getgrist.com/sharing/#suggestions\",\n  helpUnderstandingReferenceColumns: \"https://support.getgrist.com/col-refs/#understanding-reference-columns\",\n  helpTriggerFormulas: \"https://support.getgrist.com/formulas/#trigger-formulas\",\n  helpTryingOutChanges: \"https://support.getgrist.com/copying-docs/#trying-out-changes\",\n  helpWidgets: \"https://support.getgrist.com/page-widgets/#widgets\",\n  helpCustomWidgets: \"https://support.getgrist.com/widget-custom\",\n  helpInstallAuditLogs: \"https://support.getgrist.com/install/audit-log-overview/\",\n  helpTeamAuditLogs: \"https://support.getgrist.com/install/audit-log-overview/\",\n  helpTelemetryLimited: \"https://support.getgrist.com/telemetry-limited\",\n  helpEnterpriseOptIn: \"https://support.getgrist.com/self-managed/#how-do-i-enable-grist-enterprise\",\n  helpCalendarWidget: \"https://support.getgrist.com/widget-calendar\",\n  helpLinkKeys: \"https://support.getgrist.com/examples/2021-04-link-keys\",\n  helpFilteringReferenceChoices: \"https://support.getgrist.com/col-refs/#filtering-reference-choices-in-dropdown-lists\",\n  helpSandboxing: \"https://support.getgrist.com/self-managed/#how-do-i-sandbox-documents\",\n  helpAPI: \"https://support.getgrist.com/api\",\n  helpStateStore: \"https://support.getgrist.com/self-managed/#what-is-a-state-store\",\n  helpSummaryFormulas: \"https://support.getgrist.com/summary-tables/#summary-formulas\",\n  helpAdminControls: \"https://support.getgrist.com/admin-controls\",\n  helpFiddleMode: \"https://support.getgrist.com/glossary/#fiddle-mode\",\n  helpSharing: \"https://support.getgrist.com/sharing\",\n  helpFormUrlValues: \"https://support.getgrist.com/widget-form/#accept-value-from-url\",\n  helpAirtableIntegration: \"https://support.getgrist.com/install/integrations/airtable\",\n  freeCoachingCall: getFreeCoachingCallUrl(),\n  contactSupport: getContactSupportUrl(),\n  termsOfService: getTermsOfServiceUrl(),\n  onboardingTutorialVideoId: getOnboardingVideoId(),\n  plans: \"https://www.getgrist.com/pricing\",\n  contact: \"https://www.getgrist.com/contact\",\n  templates: \"https://www.getgrist.com/templates\",\n  webinars: getWebinarsUrl(),\n  community: \"https://community.getgrist.com\",\n  functions: \"https://support.getgrist.com/functions\",\n  formulaSheet: \"https://support.getgrist.com/formula-cheat-sheet\",\n  formulas: \"https://support.getgrist.com/formulas\",\n  forms: \"https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer\",\n  openGraphPreviewImage: \"https://grist-static.com/icons/opengraph-preview-image.png\",\n\n  gristLabsCustomWidgets: \"https://gristlabs.github.io/grist-widget/\",\n  gristLabsWidgetRepository: \"https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json\",\n  githubGristCore: \"https://github.com/gristlabs/grist-core\",\n  githubSponsorGristLabs: \"https://github.com/sponsors/gristlabs\",\n\n  versionCheck: \"https://api.getgrist.com/api/version\",\n  attachmentStorage: \"https://support.getgrist.com/document-settings/#external-attachments\",\n\n  signInWithGristRegister: \"https://login.getgrist.com/oauth/register\",\n  signInWithGristHelp: \"https://support.getgrist.com/install/sign-in-with-grist\",\n});\n\nexport const commonUrls = getCommonUrls();\n\n/**\n * Values representable in a URL. The current state is available as urlState().state observable\n * in client. Updates to this state are expected by functions such as makeUrl() and setLinkUrl().\n */\nexport interface IGristUrlState {\n  org?: string;\n  homePage?: IHomePage;\n  homePageTab?: HomePageTab;\n  ws?: number;\n  doc?: string;\n  slug?: string;       // if present, this is based on the document title, and is not a stable id\n  mode?: OpenDocMode;\n  fork?: UrlIdParts;\n  docPage?: IDocPage;\n  account?: AccountPage;\n  billing?: BillingPage;\n  activation?: ActivationPage;\n  auditLogs?: AuditLogsPage;\n  login?: LoginPage;\n  welcome?: WelcomePage;\n  adminPanel?: AdminPanelPage;\n  adminPanelTab?: AdminPanelTab;\n  welcomeTour?: boolean;\n  docTour?: boolean;\n  manageUsers?: boolean;\n  createTeam?: boolean;\n  upgradeTeam?: boolean;\n  params?: {\n    billingPlan?: string; // priceId\n    planType?: string;\n    billingTask?: BillingTask;\n    embed?: boolean;\n    state?: string;\n    srcDocId?: string;\n    style?: InterfaceStyle;\n    compare?: string;\n    compareEmphasis?: CompareEmphasis;  // which of local and remote changes should be emphasized visually.\n    linkParameters?: Record<string, string>;  // Parameters to pass as 'user.Link' in granular ACLs.\n    // Encoded in URL as query params with extra '_' suffix.\n    themeSyncWithOs?: boolean;\n    themeAppearance?: ThemeAppearance;\n    themeName?: ThemeName;\n    details?: boolean; // Used on admin pages to show details tab.\n    assistantPrompt?: string;\n    assistantState?: string;\n  };\n  hash?: HashLink;   // if present, this specifies an individual row within a section of a page.\n  api?: boolean;     // indicates that the URL should be encoded as an API URL, not as a landing page.\n  // But this barely works, and is suitable only for documents. For decoding it\n  // indicates that the URL probably points to an API endpoint.\n  viaShare?: boolean; // Accessing document via a special share.\n  form?: {\n    vsId: number;      // a view section id of a form.\n    shareKey?: string; // only one of shareKey or doc should be set.\n  },\n}\n\n// Subset of GristLoadConfig used by getOrgUrlInfo(), which affects the interpretation of the\n// current URL.\nexport interface OrgUrlOptions {\n  // The org associated with the current URL.\n  org?: string;\n\n  // Base domain for constructing new URLs, should start with \".\" and not include port, e.g.\n  // \".getgrist.com\". It should be unset for localhost operation and in single-org mode.\n  baseDomain?: string;\n\n  // In single-org mode, this is the single well-known org.\n  singleOrg?: string;\n\n  // Base URL used for accessing plugin material.\n  pluginUrl?: string;\n\n  // If set, org is expected to be encoded in the path, not domain.\n  pathOnly?: boolean;\n}\n\n// Result of getOrgUrlInfo().\nexport interface OrgUrlInfo {\n  hostname?: string;      // If hostname should be changed to access the requested org.\n  orgInPath?: string;     // If /o/{orgInPath} should be used to access the requested org.\n}\n\nfunction hostMatchesUrl(host?: string, url?: string) {\n  return host !== undefined && url !== undefined && new URL(url).host === host;\n}\n\n/**\n * Returns true if:\n *  - the server is a home worker and the host matches APP_HOME_INTERNAL_URL;\n *  - or the server is a doc worker and the host matches APP_DOC_INTERNAL_URL;\n *\n * @param {string?} host The host to check\n */\nfunction isOwnInternalUrlHost(host?: string) {\n  // Note: APP_HOME_INTERNAL_URL may also be defined in doc worker as well as in home worker\n  if (process.env.APP_HOME_INTERNAL_URL && hostMatchesUrl(host, process.env.APP_HOME_INTERNAL_URL)) {\n    return true;\n  }\n  return Boolean(process.env.APP_DOC_INTERNAL_URL) && hostMatchesUrl(host, process.env.APP_DOC_INTERNAL_URL);\n}\n\n/**\n * Given host (optionally with port), baseDomain, and pluginUrl, determine whether to interpret host\n * as a custom domain, a native domain, or a plugin domain.\n */\nexport function getHostType(host: string, options: {\n  baseDomain?: string, pluginUrl?: string\n}): \"native\" | \"custom\" | \"plugin\" {\n  if (options.pluginUrl) {\n    const url = new URL(options.pluginUrl);\n    if (url.host.toLowerCase() === host.toLowerCase()) {\n      return \"plugin\";\n    }\n  }\n\n  const hostname = host.split(\":\")[0];\n  if (!options.baseDomain) { return \"native\"; }\n  if (\n    hostname === \"localhost\" ||\n    isOwnInternalUrlHost(host) ||\n    hostname.endsWith(options.baseDomain)\n  ) {\n    return \"native\";\n  }\n  return \"custom\";\n}\n\nexport function getOrgUrlInfo(newOrg: string, currentHost: string, options: OrgUrlOptions): OrgUrlInfo {\n  if (newOrg === options.singleOrg) {\n    return {};\n  }\n  if (options.pathOnly) {\n    return { orgInPath: newOrg };\n  }\n  const hostType = getHostType(currentHost, options);\n  if (hostType !== \"plugin\") {\n    const hostname = currentHost.split(\":\")[0];\n    if (!options.baseDomain || hostname === \"localhost\") {\n      return { orgInPath: newOrg };\n    }\n  }\n  if (newOrg === options.org && hostType !== \"native\") {\n    return {};\n  }\n  return { hostname: newOrg + options.baseDomain };\n}\n\n/**\n * The actual serialization of a url state into a URL. The URL has the form\n *    <org-base>/\n *    <org-base>/ws/<ws>/\n *    <org-base>/doc/<doc>[/p/<docPage>]\n *\n * where <org-base> depends on whether subdomains are in use, e.g.\n *    <org>.getgrist.com\n *    localhost:8080/o/<org>\n */\nexport function encodeUrl(gristConfig: Partial<GristLoadConfig>,\n  state: IGristUrlState, baseLocation: Location | URL,\n  options: {\n    tweaks?: UrlTweaks,\n  } = {}): string {\n  const url = new URL(baseLocation.href);\n  const parts = [\"/\"];\n\n  if (state.org) {\n    // We figure out where to stick the org using the gristConfig and the current host.\n    const { hostname, orgInPath } = getOrgUrlInfo(state.org, baseLocation.host, gristConfig);\n    if (hostname) {\n      url.hostname = hostname;\n    }\n    if (orgInPath) {\n      parts.push(`o/${orgInPath}/`);\n    }\n  }\n\n  if (state.api) {\n    parts.push(`api/`);\n  }\n  if (state.ws) { parts.push(`ws/${state.ws}/`); }\n  if (state.doc) {\n    if (state.api) {\n      parts.push(`docs/${encodeURIComponent(state.doc)}`);\n    } else if (state.viaShare) {\n      // Use a special path, and remove SHARE_KEY_PREFIX from id.\n      let id = state.doc;\n      if (id.startsWith(SHARE_KEY_PREFIX)) {\n        id = id.substring(SHARE_KEY_PREFIX.length);\n      }\n      parts.push(`s/${encodeURIComponent(id)}`);\n    } else if (state.slug) {\n      parts.push(`${encodeURIComponent(state.doc)}/${encodeURIComponent(state.slug)}`);\n    } else {\n      parts.push(`doc/${encodeURIComponent(state.doc)}`);\n    }\n    if (state.mode && OpenDocMode.guard(state.mode)) {\n      parts.push(`/m/${state.mode}`);\n    }\n    if (state.docPage) {\n      parts.push(`/p/${state.docPage}`);\n    }\n    if (state.form) {\n      parts.push(`/f/${state.form.vsId}`);\n    }\n  } else if (state.form?.shareKey) {\n    parts.push(`forms/${encodeURIComponent(state.form.shareKey)}/${encodeURIComponent(state.form.vsId)}`);\n  } else if (state.homePage === \"trash\" || state.homePage === \"templates\") {\n    parts.push(`p/${state.homePage}`);\n  }\n\n  if (state.account) {\n    parts.push(state.account === \"account\" ? \"account\" : `account/${state.account}`);\n  }\n\n  if (state.billing) {\n    parts.push(state.billing === \"billing\" ? \"billing\" : `billing/${state.billing}`);\n  }\n\n  if (state.activation) { parts.push(state.activation); }\n\n  if (state.auditLogs) { parts.push(state.auditLogs); }\n\n  if (state.login) { parts.push(state.login); }\n\n  if (state.welcome) {\n    parts.push(`welcome/${state.welcome}`);\n  }\n\n  if (state.adminPanel) {\n    parts.push(state.adminPanel === \"admin\" ? \"admin\" : `admin/${state.adminPanel}`);\n  }\n\n  const queryParams = pickBy(state.params, (v, k) => k !== \"linkParameters\") as { [key: string]: string };\n  for (const [k, v] of Object.entries(state.params?.linkParameters || {})) {\n    queryParams[`${k}_`] = v;\n  }\n  if (state.params?.details) {\n    queryParams.details = \"true\";\n  }\n  const queryStr = encodeQueryParams(queryParams);\n\n  url.pathname = parts.join(\"\");\n  url.search = queryStr;\n\n  if (state.homePageTab) {\n    url.hash = state.homePageTab;\n  } else if (state.hash?.anchor) {\n    url.hash = state.hash.anchor;\n  } else if (state.hash) {\n    // Project tests use hashes, so only set hash if there is an anchor.\n    url.hash = makeAnchorLinkValue(state.hash);\n  } else if (state.welcomeTour) {\n    url.hash = \"repeat-welcome-tour\";\n  } else if (state.docTour) {\n    url.hash = \"repeat-doc-tour\";\n  } else if (state.manageUsers) {\n    url.hash = \"manage-users\";\n  } else if (state.createTeam) {\n    url.hash = \"create-team\";\n  } else if (state.upgradeTeam) {\n    url.hash = \"upgrade-team\";\n  } else if (state.adminPanelTab) {\n    url.hash = state.adminPanelTab;\n  } else {\n    url.hash = \"\";\n  }\n  options.tweaks?.postEncode?.({\n    url,\n    parts,\n    state,\n    baseLocation,\n  });\n  return url.href;\n}\n\n/**\n * Parse a URL location into an IGristUrlState object. See encodeUrl() documentation.\n */\nexport function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Location | URL, options?: {\n  tweaks?: UrlTweaks,\n}): IGristUrlState {\n  location = new URL(location.href);  // Make sure location is a URL.\n  options?.tweaks?.preDecode?.({ url: location });\n  const parts = location.pathname.slice(1).split(\"/\");\n  const state: IGristUrlState = {};\n\n  // Bare minimum we can do to detect API URLs: if it starts with /api/ or /o/{org}/api/...\n  if (parts[0] === \"api\" || (parts[0] === \"o\" && parts[2] === \"api\")) {\n    state.api = true;\n    parts.splice(parts[0] === \"api\" ? 0 : 2, 1);\n  }\n\n  // Bare minimum we can do to detect form URLs with share keys: if it starts with /forms/ or /o/{org}/forms/...\n  if (parts[0] === \"forms\" || (parts[0] === \"o\" && parts[2] === \"forms\")) {\n    const startIndex = parts[0] === \"forms\" ? 0 : 2;\n    // Form URLs have two parts to extract: the share key and the view section id.\n    state.form = {\n      shareKey: parts[startIndex + 1],\n      vsId: parseInt(parts[startIndex + 2], 10),\n    };\n    parts.splice(startIndex, 3);\n  }\n\n  const map = new Map<string, string>();\n  for (let i = 0; i < parts.length; i += 2) {\n    map.set(parts[i], decodeURIComponent(parts[i + 1]));\n  }\n\n  // For the API case, we need to map \"docs\" to \"doc\" (as this is what we did in encodeUrl and what API expects).\n  if (state.api && map.has(\"docs\")) {\n    map.set(\"doc\", map.get(\"docs\")!);\n  }\n\n  // /s/<key> is accepted as another way to write -> /doc/<share-prefix><key>\n  if (map.has(\"s\")) {\n    const key = map.get(\"s\");\n    map.set(\"doc\", `${SHARE_KEY_PREFIX}${key}`);\n    state.viaShare = true;\n  }\n\n  // When the urlId is a prefix of the docId, documents are identified\n  // as \"<urlId>/slug\" instead of \"doc/<urlId>\".  We can detect that because\n  // the minimum length of a urlId prefix is longer than the maximum length\n  // of any of the valid keys in the url.\n  for (const key of map.keys()) {\n    if (\n      key.length >= MIN_URLID_PREFIX_LENGTH &&\n      !RESERVED_URLID_PREFIXES.has(key)\n    ) {\n      map.set(\"doc\", key);\n      map.set(\"slug\", map.get(key)!);\n      map.delete(key);\n      break;\n    }\n  }\n\n  const subdomain = parseSubdomain(location.host);\n  if (gristConfig.org || gristConfig.singleOrg) {\n    state.org = gristConfig.org || gristConfig.singleOrg;\n  } else if (!gristConfig.pathOnly && subdomain.org) {\n    state.org = subdomain.org;\n  }\n  const sp = new URLSearchParams(location.search);\n  if (location.search) { state.params = {}; }\n  if (map.has(\"o\")) { state.org = map.get(\"o\"); }\n  if (map.has(\"ws\")) { state.ws = parseInt(map.get(\"ws\")!, 10); }\n  if (map.has(\"doc\")) {\n    state.doc = map.get(\"doc\");\n    const fork = parseUrlId(map.get(\"doc\")!);\n    if (fork.forkId) { state.fork = fork; }\n    if (map.has(\"slug\")) { state.slug = map.get(\"slug\"); }\n    if (map.has(\"p\")) { state.docPage = parseDocPage(map.get(\"p\")!); }\n    if (map.has(\"f\")) { state.form = { vsId: parseInt(map.get(\"f\")!, 10) }; }\n  } else {\n    if (map.has(\"p\")) {\n      const p = map.get(\"p\")!;\n      state.homePage = HomePage.parse(p);\n    }\n  }\n  if (map.has(\"m\")) { state.mode = OpenDocMode.parse(map.get(\"m\")); }\n  if (map.has(\"account\")) { state.account = AccountPage.parse(map.get(\"account\")) || \"account\"; }\n  if (map.has(\"billing\")) { state.billing = BillingSubPage.parse(map.get(\"billing\")) || \"billing\"; }\n  if (map.has(\"activation\")) {\n    state.activation = ActivationPage.parse(map.get(\"activation\")) || \"activation\";\n  }\n  if (map.has(\"audit-logs\")) {\n    state.auditLogs = AuditLogsPage.parse(map.get(\"audit-logs\")) || \"audit-logs\";\n  }\n  if (map.has(\"welcome\")) { state.welcome = WelcomePage.parse(map.get(\"welcome\")); }\n  if (map.has(\"admin\")) {\n    state.adminPanel = AdminPanelPage.parse(map.get(\"admin\")) || \"admin\";\n  }\n  if (sp.has(\"planType\")) { state.params!.planType = sp.get(\"planType\")!; }\n  if (sp.has(\"billingPlan\")) { state.params!.billingPlan = sp.get(\"billingPlan\")!; }\n  if (sp.has(\"billingTask\")) {\n    state.params!.billingTask = BillingTask.parse(sp.get(\"billingTask\"));\n  }\n  if (map.has(\"signup\")) {\n    state.login = \"signup\";\n  } else if (map.has(\"login\")) {\n    state.login = \"login\";\n  } else if (map.has(\"verified\")) {\n    state.login = \"verified\";\n  } else if (map.has(\"forgot-password\")) {\n    state.login = \"forgot-password\";\n  }\n  if (sp.has(\"state\")) {\n    state.params!.state = sp.get(\"state\")!;\n  }\n  if (sp.has(\"srcDocId\")) {\n    state.params!.srcDocId = sp.get(\"srcDocId\")!;\n  }\n  if (sp.has(\"style\")) {\n    let style = sp.get(\"style\");\n    if (style === \"light\") {\n      style = \"singlePage\";\n    }\n\n    state.params!.style = InterfaceStyle.parse(style);\n  }\n  if (sp.has(\"embed\")) {\n    const embed = state.params!.embed = isAffirmative(sp.get(\"embed\"));\n    // Turn view mode on if no mode has been specified, and not a fork.\n    if (embed && !state.mode && !state.fork) { state.mode = \"view\"; }\n    // Turn on single page style if no style has been specified.\n    if (embed && !state.params!.style) { state.params!.style = \"singlePage\"; }\n  }\n\n  // Theme overrides\n  if (sp.has(\"themeSyncWithOs\")) {\n    state.params!.themeSyncWithOs = isAffirmative(sp.get(\"themeSyncWithOs\"));\n  }\n\n  if (sp.has(\"themeAppearance\")) {\n    const appearance = sp.get(\"themeAppearance\");\n    if (appearance && themeAppearances.includes(appearance as ThemeAppearance)) {\n      state.params!.themeAppearance = appearance as ThemeAppearance;\n    }\n  }\n\n  if (sp.has(\"themeName\")) {\n    const themeName = sp.get(\"themeName\");\n    if (themeName && themeNames.includes(themeName as ThemeName)) {\n      state.params!.themeName = themeName as ThemeName;\n    }\n  }\n\n  if (sp.has(\"details\")) {\n    state.params!.details = isAffirmative(sp.get(\"details\"));\n  }\n\n  if (sp.has(\"compare\")) {\n    state.params!.compare = sp.get(\"compare\")!;\n  }\n  if (sp.has(\"compareEmphasis\")) {\n    const compareEmphasis = sp.get(\"compareEmphasis\")!;\n    if (compareEmphasis && CompareEmphasis.guard(compareEmphasis)) {\n      state.params!.compareEmphasis = compareEmphasis;\n    }\n  }\n  const linkParameters = decodeLinkParameters(sp);\n  if (linkParameters) {\n    state.params!.linkParameters = linkParameters;\n  }\n\n  if (sp.has(\"assistantPrompt\")) {\n    state.params!.assistantPrompt = sp.get(\"assistantPrompt\")!;\n  }\n\n  if (sp.has(\"assistantState\")) {\n    state.params!.assistantState = sp.get(\"assistantState\")!;\n  }\n\n  if (location.hash) {\n    const hash = location.hash;\n    const hashParts = hash.split(\".\");\n    const hashMap = new Map<string, string>();\n    for (const part of hashParts) {\n      if (part.startsWith(\"rr\")) {\n        hashMap.set(part.slice(0, 2), part.slice(2));\n      } else {\n        hashMap.set(part.slice(0, 1), part.slice(1));\n      }\n    }\n    state.homePageTab = HomePageTab.parse(hashMap.get(\"#\"));\n    state.adminPanelTab = AdminPanelTab.parse(hashMap.get(\"#\"));\n    const anchor = PersistentAnchor.parse(hashMap.get(\"#\"));\n    if (anchor) {\n      state.hash = {\n        anchor,\n      };\n    } else if (hashMap.has(\"#\") && [\"a1\", \"a2\", \"a3\", \"a4\"].includes(hashMap.get(\"#\") || \"\")) {\n      const link: HashLink = {};\n      const keys = [\n        \"sectionId\",\n        \"rowId\",\n        \"colRef\",\n      ] as (\"sectionId\" | \"rowId\" | \"colRef\")[];\n      for (const key of keys) {\n        let ch: string;\n        if (key === \"rowId\" && hashMap.has(\"rr\")) {\n          ch = \"rr\";\n          link.rickRow = true;\n        } else {\n          ch = key.substr(0, 1);\n          if (!hashMap.has(ch)) { continue; }\n        }\n        const value = hashMap.get(ch);\n        if (key === \"rowId\" && value === \"new\") {\n          link[key] = \"new\";\n        } else if (key === \"rowId\" && value?.includes(\"-\")) {\n          const rowIdParts = value.split(\"-\").map(p => (p === \"new\" ? p : parseInt(p, 10)));\n          link[key] = rowIdParts[0];\n          link.linkingRowIds = rowIdParts.slice(1);\n        } else {\n          link[key] = parseInt(value!, 10);\n        }\n      }\n      if (hashMap.get(\"#\") === \"a2\") {\n        link.popup = true;\n      } else if (hashMap.get(\"#\") === \"a3\") {\n        link.recordCard = true;\n      } else if (hashMap.get(\"#\") === \"a4\") {\n        link.comments = true;\n      }\n      state.hash = link;\n    }\n    state.welcomeTour = hashMap.get(\"#\") === \"repeat-welcome-tour\";\n    state.docTour = hashMap.get(\"#\") === \"repeat-doc-tour\";\n    state.manageUsers = hashMap.get(\"#\") === \"manage-users\";\n    state.createTeam = hashMap.get(\"#\") === \"create-team\";\n    state.upgradeTeam = hashMap.get(\"#\") === \"upgrade-team\";\n  }\n  return state;\n}\n\nexport function decodeLinkParameters(sp: URLSearchParams) {\n  let linkParameters: Record<string, string> | undefined = undefined;\n  for (const [k, v] of sp.entries()) {\n    if (k.endsWith(\"_\")) {\n      if (!linkParameters) { linkParameters = {}; }\n      linkParameters[k.slice(0, k.length - 1)] = v;\n    }\n  }\n  return linkParameters;\n}\n\n// Returns a function suitable for user with makeUrl/setHref/etc, which updates aclAsUser*\n// linkParameters in the current state, unsetting them if email is null. Optional extraState\n// allows setting other properties (e.g. 'docPage') at the same time.\nexport function userOverrideParams(email: string | null, extraState?: IGristUrlState) {\n  return function(prevState: IGristUrlState): IGristUrlState {\n    const combined = { ...prevState, ...extraState };\n    const linkParameters = clone(combined.params?.linkParameters) || {};\n    if (email) {\n      linkParameters.aclAsUser = email;\n    } else {\n      delete linkParameters.aclAsUser;\n    }\n    delete linkParameters.aclAsUserId;\n    return { ...combined, params: { ...combined.params, linkParameters } };\n  };\n}\n\n/**\n * parseDocPage is a noop for special pages, otherwise parse to integer\n */\nfunction parseDocPage(p: string): IDocPage {\n  if (SpecialDocPage.guard(p)) {\n    return p;\n  }\n  return parseInt(p, 10);\n}\n\n/**\n * Parses the URL like \"foo.bar.baz\" into the pair {org: \"foo\", base: \".bar.baz\"}.\n * Port is allowed and included into base.\n *\n * The \"base\" part is required to have at least two periods.  The \"org\" part must pass\n * the subdomainRegex test.\n *\n * If there's no way to parse the URL into such a pair, then an empty object is returned.\n */\nexport function parseSubdomain(host: string | undefined): { org?: string, base?: string } {\n  if (!host) { return {}; }\n  const match = /^([^.]+)(\\..+\\..+)$/.exec(host.toLowerCase());\n  if (match) {\n    const org = match[1];\n    const base = match[2];\n    if (subdomainRegex.exec(org)) {\n      return { org, base };\n    }\n  }\n  // Host has nowhere to put a subdomain.\n  return {};\n}\n\n// Allowed localhost addresses.\nconst localhostRegex = /^localhost(?::(\\d+))?$/i;\n\n/**\n * Like parseSubdomain, but throws an error if neither of these cases apply:\n *   - host can be parsed into a valid subdomain and a valid base domain.\n *   - host is localhost:NNNN\n * An empty object is only returned when host is localhost:NNNN.\n */\nexport function parseSubdomainStrictly(host: string | undefined): { org?: string, base?: string } {\n  if (!host) { throw new Error(\"host not known\"); }\n  const result = parseSubdomain(host);\n  if (result.org) { return result; }\n  if (!host.match(localhostRegex)) {\n    throw new Error(`host not understood: ${host}`);\n  }\n  // Host is localhost[:NNNN], no org available.\n  return {};\n}\n\n/**\n * For a packaged version of Grist that requires activation, this\n * summarizes the current state. Not applicable to grist-core.\n * This is the thing that is send via sendAppPage (so this is embedded in HTML).\n */\nexport interface ActivationState {\n  installationId: string;    // Unique identifier for this installation.\n  key?: {                    // Set when Grist is activated.\n    expirationDate?: string; // ISO8601 date that Grist will need reactivation.\n    daysLeft?: number;       // Number of days until Grist will need reactivation.\n  },\n  trial?: {                  // Present when installation has not yet been activated.\n    days: number;            // Max number of days allowed prior to activation.\n    expirationDate: string;  // ISO8601 date that Grist will get cranky.\n    daysLeft: number;        // Number of days left until Grist will get cranky.\n  },\n  needKey?: boolean;         // Set when Grist is cranky and demanding activation.\n  error?: string;            // Present when there is an error reading the key.\n  features?: PlanFeatures;   // Features available in this installation.\n  current?: Partial<PlanFeatures>; // Usage of features in this installation.\n  grace?: {\n    daysLeft: number;       // Number of days left in grace period.\n    graceStarted: string;   // ISO8601 date when grace period started.\n  }\n}\n\nexport interface LatestVersionAvailable {\n  version: string;\n  isNewer: boolean;\n  isCritical: boolean;\n  dateChecked: number;\n  releaseUrl?: string;\n}\n\n/**\n * These settings get sent to the client along with the loaded page. At the minimum, the browser\n * needs to know the URL of the home API server (e.g. api.getgrist.com).\n */\nexport interface GristLoadConfig {\n  // URL of the Home API server for the browser client to use.\n  homeUrl: string | null;\n\n  // When loading /doc/{docId}, we include the id used to assign the document (this is the docId).\n  assignmentId?: string;\n\n  // Org or \"subdomain\". When present, this overrides org information from the hostname. We rely\n  // on this for custom domains, but set it generally for all pages.\n  org?: string;\n\n  // Makes the Grist frontend access the Grist instance using its current URL in the browser, rather than APP_HOME_URL.\n  // Used to simplify setup of single-domain (no subdomain / doc worker) installations.\n  serveSameOrigin?: boolean;\n\n  // Base domain for constructing new URLs, should start with \".\" and not include port, e.g.\n  // \".getgrist.com\". It should be unset for localhost operation and in single-org mode.\n  baseDomain?: string;\n\n  // In single-org mode, this is the single well-known org. Suppress any org selection UI.\n  singleOrg?: string;\n\n  // Url for support for the browser client to use.\n  helpCenterUrl?: string;\n\n  // Url for terms of service for the browser client to use\n  termsOfServiceUrl?: string;\n\n  // Url for free coaching call scheduling for the browser client to use.\n  freeCoachingCallUrl?: string;\n\n  // Url for \"contact support\" button on Grist's \"not found\" error page\n  contactSupportUrl?: string;\n\n  // Url for webinars.\n  webinarsUrl?: string;\n\n  // When set, this directs the client to encode org information in path, not in domain.\n  pathOnly?: boolean;\n\n  // Type of error page to show. This is used for pages such as \"signed-out\" and \"not-found\",\n  // which don't include the full app.\n  errPage?: string;\n\n  // When errPage is a generic \"other-error\", this is the message to show.\n  errMessage?: string;\n\n  // When errPage is a generic page, not an error, this is additional details to show.\n  errDetails?: Record<string, string>;\n\n  // When an error page is shown in response to a request for an URL, this is the URL that was\n  // originally requested — this may not be the URL we're responding to, because of an\n  // intermediate redirect in case of e.g. an OIDC sign-in.\n  // The error page will set the browser's current URL to that, so that the user can\n  // retry by simply refreshing the page.\n  errTargetUrl?: string;\n\n  // URL for client to use for untrusted content.\n  pluginUrl?: string;\n\n  // Stripe API key for use on the client.\n  stripeAPIKey?: string;\n\n  // BeaconID for the support widget from HelpScout.\n  helpScoutBeaconId?: string;\n\n  // If set, enable anonymous sharing UI elements.\n  supportAnon?: boolean;\n\n  // If set, enable anonymous playground.\n  enableAnonPlayground?: boolean;\n\n  // If set, allow non-admins to create new organizations\n  canAnyoneCreateOrgs?: boolean;\n\n  // If set, allow access to each user's personal organization\n  enablePersonalOrgs?: boolean;\n\n  // If set, allow selection of the specified engines.\n  // TODO: move this list to a separate endpoint.\n  supportEngines?: EngineCode[];\n\n  // Max upload allowed for imports (except .grist files), in bytes; 0 or omitted for unlimited.\n  maxUploadSizeImport?: number;\n\n  // Max upload allowed for attachments, in bytes; 0 or omitted for unlimited.\n  maxUploadSizeAttachment?: number;\n\n  // Pre-fetched call to getDoc for the doc being loaded.\n  getDoc?: { [id: string]: Document };\n\n  // Pre-fetched call to getWorker for the doc being loaded.\n  getWorker?: { [id: string]: string | null };\n\n  // The timestamp when this gristConfig was generated.\n  timestampMs: number;\n\n  // Google Client Id, used in Google integration (ex: Google Drive Plugin)\n  googleClientId?: string;\n\n  // Max scope we can request for accessing files from Google Drive.\n  // Default used by Grist is https://www.googleapis.com/auth/drive.file:\n  // View and manage Google Drive files and folders that you have opened or created with this app.\n  // More on scopes: https://developers.google.com/identity/protocols/oauth2/scopes#drive\n  googleDriveScope?: string;\n\n  // List of registered plugins (used by HomePluginManager and DocPluginManager)\n  plugins?: LocalPlugin[];\n\n  // If additional custom widgets (besides the Custom URL widget) should be shown in\n  // the custom widget gallery.\n  enableWidgetRepository?: boolean;\n\n  // Whether there is somewhere for survey data to go.\n  survey?: boolean;\n\n  // Google Tag Manager id. Currently only used to load tag manager for reporting new sign-ups.\n  tagManagerId?: string;\n\n  activation?: ActivationState;\n\n  // Latest Grist release available\n  latestVersionAvailable?: LatestVersionAvailable;\n\n  // Is automatic version checking allowed?\n  automaticVersionCheckingAllowed?: boolean;\n\n  // List of enabled features.\n  features?: IFeature[];\n\n  // String to append to the end of the HTML document.title\n  pageTitleSuffix?: string;\n\n  // If custom CSS should be included in the head of each page.\n  enableCustomCss?: boolean;\n\n  // Supported languages for the UI. By default only english (en) is supported.\n  supportedLngs?: readonly string[];\n\n  // Loaded namespaces for translations.\n  namespaces?: readonly string[];\n\n  assistant?: AssistantConfig;\n\n  permittedCustomWidgets?: IAttachedCustomWidget[];\n\n  // Email address of the support user.\n  supportEmail?: string;\n\n  // Current user locale, read from the user options;\n  userLocale?: string;\n\n  // Telemetry config.\n  telemetry?: TelemetryConfig;\n\n  // The Grist deployment type (e.g. core, enterprise).\n  deploymentType?: GristDeploymentType;\n\n  // Force enterprise deployment? For backwards compatibility with grist-ee Docker image\n  forceEnableEnterprise?: boolean;\n\n  // The org containing public templates and tutorials.\n  templateOrg?: string | null;\n\n  // The doc id of the tutorial shown during onboarding.\n  onboardingTutorialDocId?: string;\n\n  // The id of the Youtube video to show for the onboarding\n  onboardingTutorialVideoId?: string;\n\n  // Whether to show the \"Delete Account\" button in the account page.\n  canCloseAccount?: boolean;\n\n  experimentalPlugins?: boolean;\n\n  // If backend has an email service for sending notifications.\n  notifierEnabled?: boolean;\n\n  // Set on /admin pages only, when AdminControls are available and should be enabled in UI.\n  adminControls?: boolean;\n\n  formFraming?: FormFraming;\n\n  adminDefinedUrls?: string;\n\n  // Maximum users to display for user presence features (e.g. active user list)\n  userPresenceMaxUsers?: number;\n\n  warnBeforeSharingPublicly?: boolean;\n\n  // Whether there is a parent process that can restart Grist.\n  runningUnderSupervisor?: boolean;\n}\n\nexport const Features = StringUnion(\n  \"helpCenter\",\n  \"billing\",\n  \"templates\",\n  \"createSite\",\n  \"multiSite\",\n  \"multiAccounts\",\n  \"importFromAirtable\",\n  \"sendToDrive\",\n  \"tutorials\",\n  \"supportGrist\",\n  \"themes\",\n);\nexport type IFeature = typeof Features.type;\n\n// Features that are enabled, even if not explicitly listed in GRIST_UI_FEATURES.\n// These should be still be disabled if listed in GRIST_HIDE_UI_ELEMENTS.\nexport const ImplicitlyEnabledFeatures: IFeature[] = [\"importFromAirtable\"];\n\nexport function isFeatureEnabled(feature: IFeature): boolean {\n  return (getGristConfig().features || []).includes(feature);\n}\n\nexport function getPageTitleSuffix(config?: GristLoadConfig) {\n  return config?.pageTitleSuffix ?? \" - Grist\";\n}\n\nexport interface TelemetryConfig {\n  telemetryLevel: TelemetryLevel;\n}\n\nexport const GristDeploymentTypes = StringUnion(\"saas\", \"core\", \"enterprise\", \"electron\", \"static\");\nexport type GristDeploymentType = typeof GristDeploymentTypes.type;\n\n// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of\n// non-zero length.\nconst subdomainRegex = /^[-a-z0-9]+$/i;\n\nexport interface OrgParts {\n  subdomain: string | null;\n  orgFromHost: string | null;\n  orgFromPath: string | null;\n  pathRemainder: string;\n  mismatch: boolean;\n}\n\n/**\n * Returns true if code is running in client, false if running in server.\n */\nexport function isClient() {\n  return (typeof window !== \"undefined\") && window && window.location?.hostname;\n}\n\nfunction getCustomizableValue(\n  clientSideConfigKey: keyof GristLoadConfig,\n  serverSideEnvVar: keyof NodeJS.ProcessEnv,\n) {\n  return isClient() ? (window as any).gristConfig?.[clientSideConfigKey] : process.env[serverSideEnvVar];\n}\n\n/**\n * Returns a known org \"subdomain\" if Grist is configured in single-org mode\n * (GRIST_SINGLE_ORG=<org> on the server) or if the page includes an org in gristConfig.\n */\nexport function getSingleOrg(): string | null {\n  return getCustomizableValue(\"singleOrg\", \"GRIST_SINGLE_ORG\") || null;\n}\n\nexport function getHelpCenterUrl(): string {\n  const defaultUrl = \"https://support.getgrist.com\";\n  return getCustomizableValue(\"helpCenterUrl\", \"GRIST_HELP_CENTER\") || defaultUrl;\n}\n\nexport function getOnboardingVideoId(): string {\n  const defaultId = \"56AieR9rpww\";\n  return getCustomizableValue(\"onboardingTutorialVideoId\", \"GRIST_ONBOARDING_VIDEO_ID\") || defaultId;\n}\n\nexport function getTermsOfServiceUrl(): string | undefined {\n  return getCustomizableValue(\"termsOfServiceUrl\", \"GRIST_TERMS_OF_SERVICE_URL\") || undefined;\n}\n\nexport function getFreeCoachingCallUrl(): string {\n  const defaultUrl = \"https://calendly.com/grist-team/grist-free-coaching-call\";\n  return getCustomizableValue(\"freeCoachingCallUrl\", \"FREE_COACHING_CALL_URL\") || defaultUrl;\n}\n\nexport function getContactSupportUrl(): string {\n  const defaultUrl = \"https://www.getgrist.com/contact\";\n  return getCustomizableValue(\"contactSupportUrl\", \"GRIST_CONTACT_SUPPORT_URL\") || defaultUrl;\n}\n\nexport function getWebinarsUrl(): string {\n  const defaultUrl = \"https://www.getgrist.com/webinars/grist-101-new-users-guide\";\n  return getCustomizableValue(\"webinarsUrl\", \"GRIST_WEBINARS_URL\") || defaultUrl;\n}\n\nexport function getMaxUploadSizeAttachmentMB(): number {\n  const value = getCustomizableValue(\"maxUploadSizeAttachment\", \"GRIST_MAX_UPLOAD_ATTACHMENT_MB\");\n  return Number(value) || Infinity;\n}\n\n/**\n * Returns true if org must be encoded in path, not in domain.  Determined from\n * gristConfig on the client.  On the server, returns true if the host is\n * supplied and is 'localhost', or if GRIST_ORG_IN_PATH is set to 'true'.\n */\nexport function isOrgInPathOnly(host?: string): boolean {\n  if (isClient()) {\n    const gristConfig: GristLoadConfig = (window as any).gristConfig;\n    return (gristConfig?.pathOnly) || false;\n  } else {\n    if (host?.match(localhostRegex)) { return true; }\n    return (process.env.GRIST_ORG_IN_PATH === \"true\");\n  }\n}\n\n// Extract an organization name from the host.  Returns null if an organization name\n// could not be recovered.  Organization name may be overridden by server configuration.\nexport function getOrgFromHost(reqHost: string): string | null {\n  const singleOrg = getSingleOrg();\n  if (singleOrg) { return singleOrg; }\n  if (isOrgInPathOnly()) { return null; }\n  return parseSubdomain(reqHost).org || null;\n}\n\n/**\n * Get any information about an organization that is embedded in the host name or the\n * path.\n * For example, on nasa.getgrist.com, orgFromHost and subdomain will be set to \"nasa\".\n * On localhost:8000/o/nasa, orgFromPath and subdomain will be set to \"nasa\".\n * On nasa.getgrist.com/o/nasa, orgFromHost, orgFromPath, and subdomain will all be \"nasa\".\n * On spam.getgrist.com/o/nasa, orgFromHost will be \"spam\", orgFromPath will be \"nasa\",\n * subdomain will be null, and mismatch will be true.\n */\nexport function extractOrgParts(reqHost: string | undefined, reqPath: string): OrgParts {\n  let orgFromHost: string | null = getSingleOrg();\n\n  if (!orgFromHost && reqHost) {\n    orgFromHost = getOrgFromHost(reqHost);\n    if (orgFromHost) {\n      // Some subdomains are shared, and do not reflect the name of an organization.\n      // See /documentation/urls.md for a list.\n      if (/^(api|v1-.*|doc-worker-.*)$/.test(orgFromHost)) {\n        orgFromHost = null;\n      }\n    }\n  }\n\n  const part = parseFirstUrlPart(\"o\", reqPath);\n  if (part.value) {\n    const orgFromPath = part.value.toLowerCase();\n    const mismatch = Boolean(orgFromHost && orgFromPath && (orgFromHost !== orgFromPath));\n    const subdomain = mismatch ? null : orgFromPath;\n    return { orgFromHost, orgFromPath, pathRemainder: part.path, mismatch, subdomain };\n  }\n  return { orgFromHost, orgFromPath: null, pathRemainder: reqPath, mismatch: false, subdomain: orgFromHost };\n}\n\n/**\n * When a prefix is extracted from the path, the remainder of the path may be empty.\n * This method makes sure there is at least a \"/\".\n */\nexport function sanitizePathTail(path: string | undefined) {\n  path = path || \"/\";\n  return (path.startsWith(\"/\") ? \"\" : \"/\") + path;\n}\n\n/*\n * If path starts with /{tag}/{value}{/rest}, returns value and the remaining path (/rest).\n * Otherwise, returns value of undefined and the path unchanged.\n * E.g. parseFirstUrlPart('o', '/o/foo/bar') returns {value: 'foo', path: '/bar'}.\n */\nexport function parseFirstUrlPart(tag: string, path: string): { value?: string, path: string } {\n  const match = path.match(/^\\/([^/?#]+)\\/([^/?#]+)(.*)$/);\n  if (match?.[1] === tag) {\n    return { value: match[2], path: sanitizePathTail(match[3]) };\n  } else {\n    return { path };\n  }\n}\n\n/**\n * The internal structure of a UrlId. There is no internal structure,\n * except in the following cases. The id may be for a fork, in which\n * case the fork has a separate id, and a user id may also be embedded\n * to track ownership. The id may be a share key, in which case it\n * has some special syntax to identify it as so.\n */\nexport interface UrlIdParts {\n  trunkId: string;\n  forkId?: string;\n  forkUserId?: number;\n  snapshotId?: string;\n  shareKey?: string;\n}\n\n// Parse a string of the form trunkId or trunkId~forkId or trunkId~forkId~forkUserId\n// or trunkId[....]~v=snapshotId\n// or <SHARE-KEY-PREFIX>shareKey\nexport function parseUrlId(urlId: string): UrlIdParts {\n  let snapshotId: string | undefined;\n  const parts = urlId.split(\"~\");\n  const bareParts = parts.filter(part => !part.includes(\"v=\"));\n  for (const part of parts) {\n    if (part.startsWith(\"v=\")) {\n      snapshotId = decodeURIComponent(part.substr(2).replace(/_/g, \"%\"));\n    }\n  }\n  const trunkId = bareParts[0];\n  // IDs starting with SHARE_KEY_PREFIX are in fact shares.\n  const shareKey = removePrefix(trunkId, SHARE_KEY_PREFIX) || undefined;\n  return {\n    trunkId: bareParts[0],\n    forkId: bareParts[1],\n    forkUserId: (bareParts[2] !== undefined) ? parseInt(bareParts[2], 10) : undefined,\n    snapshotId,\n    shareKey,\n  };\n}\n\n// Construct a string of the form trunkId or trunkId~forkId or trunkId~forkId~forkUserId\n// or trunkId[....]~v=snapshotId\nexport function buildUrlId(parts: UrlIdParts): string {\n  let token = [parts.trunkId, parts.forkId, parts.forkUserId].filter(x => x !== undefined).join(\"~\");\n  if (parts.snapshotId) {\n    // This could be an S3 VersionId, about which AWS makes few promises.\n    // encodeURIComponent leaves untouched the following:\n    //   alphabetic; decimal; any of: - _ . ! ~ * ' ( )\n    // We further encode _.!~*'() to fit within existing limits on what characters\n    // may be in a docId (leaving just the hyphen, which is permitted).  The limits\n    // could be loosened, but without much benefit.\n    const codedSnapshotId = encodeURIComponent(parts.snapshotId)\n      .replace(/[_.!~*'()-]/g, ch => `_${ch.charCodeAt(0).toString(16).toUpperCase()}`)\n      .replace(/%/g, \"_\");\n    token = `${token}~v=${codedSnapshotId}`;\n  }\n  return token;\n}\n\n/**\n * Values that may be encoded in a hash in a document url.\n */\nexport interface HashLink {\n  sectionId?: number;\n  rowId?: UIRowId;\n  colRef?: number;\n  popup?: boolean;\n  comments?: boolean; // Whether to show comments in the popup.\n  rickRow?: boolean;\n  recordCard?: boolean;\n  linkingRowIds?: UIRowId[];\n  anchor?: string;\n}\n\n/**\n * Encode a HashLink as a string to include as the URL fragment (hash prooperty). For example,\n * in https://templates.getgrist.com/doc/lightweight-crm#a1.s1.r7.c2, the \"a1.s1.r7.c2\" portion is\n * the anchor link. The parts have the following meaning:\n *    a = identifies the type of link (1 is normal, 2 for popup, 3 for record-card)\n *    s = sectionId (rowId of the page-widget)\n *    r = rowId (of the actual row in the user table)\n *    c = colRef (rowId of the column's metadata record)\n */\nexport function makeAnchorLinkValue(hash: HashLink): string {\n  const hashParts: string[] = [];\n  if (hash.rowId || hash.popup || hash.recordCard) {\n    if (hash.comments) {\n      hashParts.push(\"a4\");\n    } else if (hash.recordCard) {\n      hashParts.push(\"a3\");\n    } else if (hash.popup) {\n      hashParts.push(\"a2\");\n    } else if (hash.anchor) {\n      hashParts.push(hash.anchor);\n    } else {\n      hashParts.push(\"a1\");\n    }\n    for (const key of [\"sectionId\", \"rowId\", \"colRef\"] as (keyof HashLink)[]) {\n      let enhancedRowId: string | undefined;\n      if (key === \"rowId\" && hash.linkingRowIds?.length) {\n        enhancedRowId = [hash.rowId, ...hash.linkingRowIds].join(\"-\");\n      }\n      const partValue = enhancedRowId ?? hash[key];\n      if (partValue) {\n        const partKey = key === \"rowId\" && hash.rickRow ? \"rr\" : key[0];\n        hashParts.push(`${partKey}${partValue}`);\n      }\n    }\n  }\n  return hashParts.join(\".\");\n}\n\n// Check whether a urlId is a prefix of the docId, and adequately long to be\n// a candidate for use in prettier urls.\nfunction shouldIncludeSlug(doc: { id: string, urlId: string | null }): boolean {\n  if (!doc.urlId || doc.urlId.length < MIN_URLID_PREFIX_LENGTH) { return false; }\n  return doc.id.startsWith(doc.urlId) || doc.urlId.startsWith(SHARE_KEY_PREFIX);\n}\n\n// Convert the name of a document into a slug. The slugify library normalizes unicode characters,\n// replaces those with a reasonable ascii representation. Only alphanumerics are retained, and\n// spaces are replaced with hyphens.\nfunction nameToSlug(name: string): string {\n  return slugify(name, { strict: true });\n}\n\n// Returns a slug for the given docId/urlId/name, or undefined if a slug should\n// not be used.\nexport function getSlugIfNeeded(doc: { id: string, urlId: string | null, name: string }): string | undefined {\n  if (!shouldIncludeSlug(doc)) { return; }\n  return nameToSlug(doc.name);\n}\n\n/**\n * It is possible we want to remap Grist URLs in some way - specifically,\n * grist-static does this. We allow for a hook that is called after\n * encoding state as a URL, and a hook that is called before decoding\n * state from a URL.\n */\nexport interface UrlTweaks {\n  /**\n   * Tweak an encoded URL. Operates on the URL directly, in place.\n   */\n  postEncode?(options: {\n    url: URL,\n    parts: string[],\n    state: IGristUrlState,\n    baseLocation: Location | URL,\n  }): void;\n\n  /**\n   * Tweak a URL prior to decoding it. Operates on the URL directly, in place.\n   */\n  preDecode?(options: {\n    url: URL,\n  }): void;\n}\n\nfunction withAdminDefinedUrls(defaultUrls: ICommonUrls): ICommonUrls {\n  const adminDefinedUrlsStr = getCustomizableValue(\"adminDefinedUrls\", \"GRIST_CUSTOM_COMMON_URLS\");\n  if (!adminDefinedUrlsStr) {\n    return defaultUrls;\n  }\n\n  let adminDefinedUrls;\n  try {\n    adminDefinedUrls = JSON.parse(adminDefinedUrlsStr);\n  } catch (e) {\n    throw new Error(\"The JSON passed to GRIST_CUSTOM_COMMON_URLS is malformed\");\n  }\n\n  const merged = {\n    ...defaultUrls,\n    ...(adminDefinedUrls),\n  };\n  ICommonUrlsChecker.strictCheck(merged);\n  return merged;\n}\n"
  },
  {
    "path": "app/common/gutil.ts",
    "content": "import {\n  BindableValue,\n  Computed,\n  DomElementMethod,\n  Holder,\n  IDisposableOwner,\n  IKnockoutReadObservable,\n  ISubscribable,\n  Listener,\n  MultiHolder,\n  Observable,\n  subscribeElem,\n  UseCB,\n  UseCBOwner,\n} from \"grainjs\";\nimport { Observable as KoObservable } from \"knockout\";\nimport identity from \"lodash/identity\";\n\n// Some definitions have moved to be used by plugin API.\nexport { arrayRepeat } from \"app/plugin/gutil\";\n\nexport const UP_TRIANGLE = \"\\u25B2\";\nexport const DOWN_TRIANGLE = \"\\u25BC\";\n\nconst EMAIL_RE = new RegExp(\"^\\\\w[\\\\w%+/='-]*(\\\\.[\\\\w%+/='-]+)*@([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z\" +\n  \"0-9])?\\\\.)+[A-Za-z]{2,24}$\", \"u\");\n\n// Returns whether str starts with prefix. (Note that this implementation avoids creating a new\n// string, and only checks a single location.)\nexport function startsWith(str: string, prefix: string): boolean {\n  return str.lastIndexOf(prefix, 0) === 0;\n}\n\n// Returns whether str ends with suffix.\nexport function endsWith(str: string, suffix: string): boolean {\n  return str.includes(suffix, str.length - suffix.length);\n}\n\n// If str starts with prefix, removes it and returns what remains. Otherwise, returns null.\nexport function removePrefix(str: string, prefix: string): string | null {\n  return startsWith(str, prefix) ? str.slice(prefix.length) : null;\n}\n\n// If str ends with suffix, removes it and returns what remains. Otherwise, returns null.\nexport function removeSuffix(str: string, suffix: string): string | null {\n  return endsWith(str, suffix) ? str.slice(0, str.length - suffix.length) : null;\n}\n\nexport function removeTrailingSlash(str: string): string {\n  const result = removeSuffix(str, \"/\");\n  return result === null ? str : result;\n}\n\n// Expose <string>.padStart.  The version of node we use has it, but they typings\n// need the es2017 typescript target.  TODO: replace once typings in place.\nexport function padStart(str: string, targetLength: number, padString: string) {\n  return (str as any).padStart(targetLength, padString);\n}\n\n// Capitalizes every word in a string.\nexport function capitalize(str: string): string {\n  return str.replace(/\\b[a-z]/gi, c => c.toUpperCase());\n}\n\n// Capitalizes the first word in a string.\nexport function capitalizeFirstWord(str: string): string {\n  return str.replace(/\\b[a-z]/i, c => c.toUpperCase());\n}\n\n// Returns whether the string n represents a valid number.\n// http://stackoverflow.com/questions/18082/validate-numbers-in-javascript-isnumeric\nexport function isNumber(n: string): boolean {\n  // This wasn't right for a long time: isFinite() is key to failing on strings like \"5a\".\n  return !isNaN(parseFloat(n)) && isFinite(n as any);\n}\n\n/**\n * Returns a value clamped to the given min-max range.\n * @param {Number} value - some numeric value.\n * @param {Number} min - minimum value allowed.\n * @param {Number} max - maximum value allowed. Must have min <= max.\n * @returns {Number} - value restricted to the given range.\n */\nexport function clamp(value: number, min: number, max: number): number {\n  return Math.max(min, Math.min(max, value));\n}\n\n/**\n * Checks if ele is contained within the given bounds.\n * @param {Number} value\n * @param {Number} bound1 - does not have to be less than/equal to bound2\n * @param {Number} bound2\n * @returns {Boolean} - True/False\n */\nexport function between(value: number, bound1: number, bound2: number): boolean {\n  const lower = Math.min(bound1, bound2);\n  const upper = Math.max(bound1, bound2);\n  return lower <= value && value <= upper;\n}\n\n/**\n * Returns the positive modulo of x by n. (Javascript default allows negatives)\n */\nexport function mod(x: number, n: number): number {\n  return ((x % n) + n) % n;\n}\n\n/**\n * Returns a number that is n rounded down to the next nearest number divisible by m\n */\n\nexport function roundDownToMultiple(n: number, m: number): number {\n  return Math.floor(n / m) * m;\n}\n\n/**\n * Returns the first argument unless it's undefined, in which case returns the second one.\n */\nexport function undefDefault<T>(x: T | undefined, y: T): T {\n  return (x !== void 0) ? x : y;\n}\n\n// for typescript 4\n// type Undef<T> = T extends [infer A, ...infer B] ? undefined extends A ? NonNullable<A> | Undef<B> : A : unknown;\n\ntype Undef1<T> = T extends [infer A] ? A : unknown;\n\ntype Undef2<T> = T extends [infer A, infer B] ?\n  undefined extends A ? NonNullable<A> | Undef1<[B]> : A : Undef1<T>;\n\ntype Undef3<T> = T extends [infer A, infer B, infer C] ?\n  undefined extends A ? NonNullable<A> | Undef2<[B, C]> : A : Undef2<T>;\n\ntype Undef<T> = T extends [infer A, infer B, infer C, infer D] ?\n  undefined extends A ? NonNullable<A> | Undef3<[B, C, D]> : A : Undef3<T>;\n\n/*\n\nUndef<T> can detect correct type that will be returned as a first defined value:\n\nconst t1: number = undef(1, 1 as number | undefined);\nconst t1: number | undefined = undef(2 as number | undefined, 3 as number | undefined);\nconst t3: number = undef(3 as number | undefined, undefined, 4);\nconst t4: number = undef(1, '');\nconst t5: number = undef(1 as number | undefined, 4);\nconst t6: string = undef('1', 2);\nconst t7: string | number = undef(undefined, 2 as number | undefined, '3');\nconst t8: string = undef(undefined, undefined, '3');\nconst t9: string = undef(undefined, '2' as string | undefined, '3');\nconst ta: string | number | undefined = undef(undefined, '2' as string | undefined, 3 as number | undefined);\nconst tb: string | number = undef(undefined, '2' as string | undefined, 3 as number | undefined, 5);\n*/\n\n/**\n * Returns the first defined value from the list or unknown.\n * Use with typed result, so the typescript type checker can provide correct type.\n */\nexport function undef<T extends any[]>(...list: T): Undef<T> {\n  for (const value of list) {\n    if (value !== undefined) { return value; }\n  }\n  return undefined as any;\n}\n\n/**\n * Returns the number representation of `value`, or `defaultVal` if it cannot\n * be represented as a valid number.\n */\nexport function numberOrDefault<T>(value: unknown, defaultVal: T): number | T {\n  if (typeof value === \"number\") {\n    return !Number.isNaN(value) ? value : defaultVal;\n  } else if (typeof value === \"string\") {\n    const maybeNumber = Number.parseFloat(value);\n    return !Number.isNaN(maybeNumber) ? maybeNumber : defaultVal;\n  } else {\n    return defaultVal;\n  }\n}\n\n/**\n * Parses json and returns the result, or returns defaultVal if parsing fails.\n */\nexport function safeJsonParse(json: string, defaultVal: any): any {\n  try {\n    return json !== \"\" && json !== undefined ? JSON.parse(json) : defaultVal;\n  } catch (e) {\n    return defaultVal;\n  }\n}\n\n/**\n * Just like encodeURIComponent, but does not encode slashes. Slashes don't hurt to be included in\n * URL parameters, and look much friendlier not encoded.\n */\nexport function encodeQueryParam(str: string | number | undefined): string {\n  return encodeURIComponent(String(str === undefined ? null : str)).replace(/%2F/g, \"/\");\n}\n\n/**\n * Encode an object into a querystring (\"key=value&key2=value2\").\n * This is similar to JQuery's $.param, but only works on shallow objects.\n */\nexport function encodeQueryParams(obj: { [key: string]: string | number | undefined }): string {\n  return Object.keys(obj).map((k: string) => encodeQueryParam(k) + \"=\" + encodeQueryParam(obj[k])).join(\"&\");\n}\n\n/**\n * Return a list of the words in the string, using the given separator string. At most\n * maxNumSplits splits are done, so the result will have at most maxNumSplits + 1 elements (this\n * is the main difference from how JS built-in string.split() works, and similar to Python split).\n * @param {String} str: String to split.\n * @param {String} sep: Separator to split on.\n * @param {Number} maxNumSplits: Maximum number of splits to do.\n * @return {Array[String]} Array of words, of length at most maxNumSplits + 1.\n */\nexport function maxsplit(str: string, sep: string, maxNumSplits: number): string[] {\n  const result: string[] = [];\n  let start = 0, pos;\n  for (let i = 0; i < maxNumSplits; i++) {\n    pos = str.indexOf(sep, start);\n    if (pos === -1) {\n      break;\n    }\n    result.push(str.slice(start, pos));\n    start = pos + sep.length;\n  }\n  result.push(str.slice(start));\n  return result;\n}\n\n// Compare arrays of scalars for equality.\nexport function arraysEqual(a: any[], b: any[]): boolean {\n  if (a === b) {\n    return true;\n  }\n  if (!a || !b) {\n    return false;\n  }\n  if (a.length !== b.length) {\n    return false;\n  }\n\n  for (let i = 0; i < a.length; i++) {\n    if (a[i] !== b[i]) { return false; }\n  }\n  return true;\n}\n\n// Gives a set representing the set difference a - b.\nexport function setDifference<T>(a: Set<T>, b: Set<T>): Set<T> {\n  const c = new Set<T>();\n  for (const ai of a) {\n    if (!b.has(ai)) { c.add(ai); }\n  }\n  return c;\n}\n\n// Like array.indexOf, but works with array-like objects like HTMLCollection.\nexport function indexOf<T>(arrayLike: ArrayLike<T>, item: T): number {\n  return Array.prototype.indexOf.call(arrayLike, item);\n}\n\n/**\n * Removes a value from the given array. Only the first instance is removed.\n * Returns true on success, false if the value was not found.\n */\nexport function arrayRemove<T>(array: T[], value: T): boolean {\n  const index = array.indexOf(value);\n  if (index === -1) {\n    return false;\n  }\n  array.splice(index, 1);\n  return true;\n}\n\n/**\n * Inserts value into the array before nextValue, or at the end if nextValue is not found.\n */\nexport function arrayInsertBefore<T>(array: T[], value: T, nextValue: T): void {\n  const index = array.indexOf(nextValue);\n  if (index === -1) {\n    array.push(value);\n  } else {\n    array.splice(index, 0, value);\n  }\n}\n\n/**\n * Extends the first array with the second. Like native push, but adds all values in anotherArray.\n */\nexport function arrayExtend<T>(array: T[], anotherArray: T[]): void {\n  for (let i = 0, len = anotherArray.length; i < len; i++) {\n    array.push(anotherArray[i]);\n  }\n}\n\n/**\n * Copies count items from fromArray to toArray, copying in a forward direction (which matters\n * when the arrays are the same and source and destination indices overlap).\n *\n * See test/common/arraySplice.js for alternative implementations with timings, from which this\n * one is chosen as consistently among the faster ones.\n */\nexport function arrayCopyForward<T>(toArray: T[], toStart: number,\n  fromArray: ArrayLike<T>, fromStart: number, count: number): void {\n  const end = toStart + count;\n  for (const xend = end - 7; toStart < xend; fromStart += 8, toStart += 8) {\n    toArray[toStart] = fromArray[fromStart];\n    toArray[toStart + 1] = fromArray[fromStart + 1];\n    toArray[toStart + 2] = fromArray[fromStart + 2];\n    toArray[toStart + 3] = fromArray[fromStart + 3];\n    toArray[toStart + 4] = fromArray[fromStart + 4];\n    toArray[toStart + 5] = fromArray[fromStart + 5];\n    toArray[toStart + 6] = fromArray[fromStart + 6];\n    toArray[toStart + 7] = fromArray[fromStart + 7];\n  }\n  for (; toStart < end; ++fromStart, ++toStart) {\n    toArray[toStart] = fromArray[fromStart];\n  }\n}\n\n/**\n * Copies count items from fromArray to toArray, copying in a backward direction (which matters\n * when the arrays are the same and source and destination indices overlap).\n *\n * See test/common/arraySplice.js for alternative implementations with timings, from which this\n * one is chosen as consistently among the faster ones.\n */\nexport function arrayCopyBackward<T>(toArray: T[], toStart: number,\n  fromArray: ArrayLike<T>, fromStart: number, count: number): void {\n  let i = toStart + count - 1, j = fromStart + count - 1;\n  for (const xStart = toStart + 7; i >= xStart; i -= 8, j -= 8) {\n    toArray[i] = fromArray[j];\n    toArray[i - 1] = fromArray[j - 1];\n    toArray[i - 2] = fromArray[j - 2];\n    toArray[i - 3] = fromArray[j - 3];\n    toArray[i - 4] = fromArray[j - 4];\n    toArray[i - 5] = fromArray[j - 5];\n    toArray[i - 6] = fromArray[j - 6];\n    toArray[i - 7] = fromArray[j - 7];\n  }\n  for (; i >= toStart; --i, --j) {\n    toArray[i] = fromArray[j];\n  }\n}\n\n/**\n * Appends a slice of fromArray to the end of toArray.\n *\n * See test/common/arraySplice.js for alternative implementations with timings, from which this\n * one is chosen as consistently among the faster ones.\n */\nexport function arrayAppend<T>(toArray: T[], fromArray: ArrayLike<T>, fromStart: number, count: number): void {\n  if (count === 1) {\n    toArray.push(fromArray[fromStart]);\n  } else {\n    const len = toArray.length;\n    toArray.length = len + count;\n    arrayCopyForward(toArray, len, fromArray, fromStart, count);\n  }\n}\n\n/**\n * Splices array arrToInsert into target starting at the given start index.\n * This implementation tries to be smart by avoiding allocations, appending to the array\n * contiguously, then filling in the gap.\n *\n * See test/common/arraySplice.js for alternative implementations with timings, from which this\n * one is chosen as consistently among the faster ones.\n */\nexport function arraySplice<T>(target: T[], start: number, arrToInsert: ArrayLike<T>): T[] {\n  const origLen = target.length;\n  const tailLen = origLen - start;\n  const insLen = arrToInsert.length;\n  target.length = origLen + insLen;\n  if (insLen > tailLen) {\n    arrayCopyForward(target, origLen, arrToInsert, tailLen, insLen - tailLen);\n    arrayCopyForward(target, start + insLen, target, start, tailLen);\n    arrayCopyForward(target, start, arrToInsert, 0, tailLen);\n  } else {\n    arrayCopyForward(target, origLen, target, origLen - insLen, insLen);\n    arrayCopyBackward(target, start + insLen, target, start, tailLen - insLen);\n    arrayCopyForward(target, start, arrToInsert, 0, insLen);\n  }\n  return target;\n}\n\n// Type for a compare func that returns a positive, negative, or zero value, as used for sorting.\nexport type CompareFunc<T> = (a: T, b: T) => number;\n\n/**\n * Returns the index at which the given element can be inserted to keep the array sorted.\n * This is equivalent to underscore's sortedIndex and python's bisect_left.\n * @param {Array} array - sorted array of elements based on the given compareFunc\n * @param {object} elem - object to be inserted in the given array\n * @param {function} compareFunc - compares 2 elements. Returns a pos value if the 1st element is\n *                                 larger, 0 if they're equal, a neg value if the 2nd is larger.\n */\nexport function sortedIndex<T>(array: ArrayLike<T>, elem: T, compareFunc: CompareFunc<T>): number {\n  let lo = 0, mid;\n  let hi = array.length;\n\n  if (array.length === 0) { return 0; }\n  while (lo < hi) {\n    mid = Math.floor((lo + hi) / 2);\n    if (compareFunc(array[mid], elem) < 0) { // mid < elem\n      lo = mid + 1;\n    } else {\n      hi = mid;\n    }\n  }\n  return lo;\n}\n\n/**\n * Returns true if an array contains duplicate values.\n * Values are considered equal if their toString() representations are equal.\n */\nexport function hasDuplicates(array: any[]): boolean {\n  const prevVals = Object.create(null);\n  for (const value of array) {\n    if (value in prevVals) {\n      return true;\n    }\n    prevVals[value] = true;\n  }\n  return false;\n}\n\n/**\n * Counts the number of items in array which satisfy the callback.\n */\nexport function countIf<T>(array: readonly T[], callback: (item: T) => boolean): number {\n  let count = 0;\n  array.forEach((item) => {\n    if (callback(item)) { count++; }\n  });\n  return count;\n}\n\n/**\n * For two parallel arrays, calls mapFunc(a[i], b[i]) for each pair of corresponding elements, and\n * returns an array of the results.\n */\nexport function map2<T, U, V>(array1: ArrayLike<T>, array2: ArrayLike<U>, mapFunc: (a: T, b: U) => V): V[] {\n  const len = array1.length;\n  const result: V[] = new Array(len);\n  for (let i = 0; i < len; i++) {\n    result[i] = mapFunc(array1[i], array2[i]);\n  }\n  return result;\n}\n\n/**\n * Takes a 2d array returns a new matrix with r rows and c columns\n * @param [Array] dataMatrix: a 2d array\n * @param [Number] r: final row length\n * @param [Number] c: final column length\n */\nexport function growMatrix<T>(dataMatrix: T[][], r: number, c: number): T[][] {\n  const colArr = dataMatrix.map(colVals =>\n    Array.from({ length: c }, (_v, k) => colVals[k % colVals.length]),\n  );\n  return Array.from({ length: r }, (_v, k) => colArr[k % colArr.length]);\n}\n\n/**\n * Returns a function that compares two elements based on multiple sort keys and the\n * given compare functions.\n * Elements are compared using the sort key functions with index 0 having the greatest priority.\n * Subsequent sort key functions are used as tie breakers.\n * @param {function Array} sortKeyFuncs - a list of sort key functions.\n * @param {function Array} compareKeyFuncs - a list of comparison functions parallel to sortKeyFuncs\n * Each compare function  must satisfy the comparison invariant:\n *   If compare(a, b) > 0 then a > b,\n *   If compare(a, b) < 0 then a < b,\n *   If compare(a, b) == 0 then a == b,\n * @param {Array of 1/-1's} optAscending - Comparison on sortKeyFuncs[i] is inverted if optAscending[i] == -1\n */\nexport function multiCompareFunc<T, U>(sortKeyFuncs: readonly ((a: T) => U)[],\n  compareFuncs: ArrayLike<CompareFunc<U>>,\n  optAscending?: number[]): CompareFunc<T> {\n  if (sortKeyFuncs.length !== compareFuncs.length) {\n    throw new Error(\"Number of sort key funcs must be the same as the number of compare funcs\");\n  }\n  const ascending = optAscending || sortKeyFuncs.map(() => 1);\n  return function(a: T, b: T): number {\n    let compareOutcome, keyA, keyB;\n    for (let i = 0; i < compareFuncs.length; i++) {\n      keyA = sortKeyFuncs[i](a);\n      keyB = sortKeyFuncs[i](b);\n      compareOutcome = compareFuncs[i](keyA, keyB);\n      if (compareOutcome !== 0) { return ascending[i] * compareOutcome; }\n    }\n    return 0;\n  };\n}\n\nexport function nativeCompare<T>(a: T, b: T): number {\n  return (a < b ? -1 : (a > b ? 1 : 0));\n}\n\n/**\n * Creates a function that compares objects by a property value.\n */\nexport function propertyCompare<T>(property: keyof T) {\n  return function(a: T, b: T) {\n    return nativeCompare(a[property], b[property]);\n  };\n}\n\n// TODO: In the future, locale should be a value associated with the document or the user.\nexport const defaultLocale = \"en-US\";\nexport const defaultCollator = new Intl.Collator(defaultLocale);\nexport const localeCompare = defaultCollator.compare;\n\n/**\n * A copy of python`s `setdefault` function.\n * Sets key in mapInst to value, if key is not already set.\n * @param {Map} mapInst: Instance of Map.\n * @param {Object} key: Key into the map.\n * @param {Object} value: Value to insert, possibly.\n */\nexport function setDefault<K, V>(mapInst: Map<K, V>, key: K, val: V): V {\n  if (!mapInst.has(key)) { mapInst.set(key, val); }\n  return mapInst.get(key)!;\n}\n\n/**\n * Similar to Python's `setdefault`: returns the key `key` from `mapInst`, or if it's not there, sets\n * it to the result buildValue().\n */\nexport function getSetMapValue<K, V>(mapInst: Map<K, V>, key: K, buildValue: () => V): V {\n  if (!mapInst.has(key)) { mapInst.set(key, buildValue()); }\n  return mapInst.get(key)!;\n}\n\n/**\n * If key is in mapInst, remove it and return its value, else return `undefined`.\n * @param {Map} mapInst: Instance of Map.\n * @param {Object} key: Key into the map to remove.\n */\nexport function popFromMap<K, V>(mapInst: Map<K, V>, key: K): V | undefined {\n  const value = mapInst.get(key);\n  mapInst.delete(key);\n  return value;\n}\n\n/**\n * For each encountered value in `values`, increment the corresponding counter in `valueCounts`.\n */\nexport function addCountsToMap<T>(valueCounts: Map<T, number>, values: Iterable<T>,\n  mapFunc: (v: any) => any = identity) {\n  for (const v of values) {\n    const mappedValue = mapFunc(v);\n    valueCounts.set(mappedValue, (valueCounts.get(mappedValue) || 0) + 1);\n  }\n}\n\n/**\n * Returns whether one Set is a subset of another.\n */\nexport function isSubset(smaller: Set<any>, larger: Set<any>): boolean {\n  for (const value of smaller) {\n    if (!larger.has(value)) {\n      return false;\n    }\n  }\n  return true;\n}\n\n/**\n * Merges the contents of two or more objects together into the first object, recursing into\n * nested objects and arrays (like jquery.extend(true, ...)).\n * @param {Object} target - The object to modify. Use {} to create a new merged object.\n * @param {Object} ... - Additional objects from which to copy properties into target.\n * @returns {Object} The first argument, target, modified.\n */\nexport function deepExtend(target: any, _varArgObjects: any): any {\n  for (let i = 1; i < arguments.length; i++) {\n    const object = arguments[i];\n    // Extend the base object\n    for (const name in object) {\n      if (!object.hasOwnProperty(name)) { continue; }\n      let src = object[name];\n      if (src === target || src === undefined) {\n        // Prevent one kind of infinite loop, as JQuery's extend does, and skip undefined values.\n        continue;\n      }\n\n      if (src) {\n        // Recurse if we're merging plain objects or arrays\n        const tgt = target[name];\n        if (Array.isArray(src)) {\n          src = deepExtend(tgt && Array.isArray(tgt) ? tgt : [], src);\n        } else if (typeof src === \"object\") {\n          src = deepExtend(tgt && typeof tgt === \"object\" ? tgt : {}, src);\n        }\n      }\n      target[name] = src;\n    }\n  }\n  // Return the modified object\n  return target;\n}\n\n/**\n * Returns a human-readable string containing a number of bytes, KB, or MB.\n * @param {Number} bytes. Number of bytes.\n * @returns {String} A description such as \"4.1KB\".\n */\nexport function byteString(bytes: number): string {\n  if (bytes < 1024) {\n    return bytes + \"B\";\n  } else if (bytes < 1024 * 1024) {\n    return (bytes / 1024).toFixed(1) + \"KB\";\n  } else {\n    return (bytes / 1024 / 1024).toFixed(1) + \"MB\";\n  }\n}\n\n/**\n * Creates a new object mapping each key in keysArray to the value returned by callback.\n * @param {Array} keysArray - Array of strings to use as the properties of the returned object.\n * @param {Function} callback - Function that produces the value for each key. Called in the same\n *    way as array.map() calls its callbacks.\n * @param {Object} optThisArg - Value to use as `this` when executing callback.\n * @returns {Object} - object mapping keys from `keysArray` to values returned by `callback`.\n */\nexport function mapToObject<T>(keysArray: string[], callback: (key: string) => T,\n  optThisArg: any): { [key: string]: T } {\n  const values: T[] = keysArray.map(callback, optThisArg);\n  const map: { [key: string]: T } = {};\n  for (let i = 0; i < keysArray.length; i++) {\n    map[keysArray[i]] = values[i];\n  }\n  return map;\n}\n\n/**\n * Remove the specified elements from the array, with the elements specified by\n * their index.  The array arr is modified in-place.  The indexes must be provided\n * in order, sorted lowest to highest, with no duplicates, or out-of-bound indices,\n * etc (this method does no error checking; it is used in place of lodash-pullAt\n * for performance reasons).\n */\nexport function pruneArray<T>(arr: T[], indexes: number[]) {\n  if (indexes.length === 0) { return; }\n  if (indexes.length === 1) {\n    arr.splice(indexes[0], 1);\n    return;\n  }\n  const len = arr.length;\n  let arrAt = 0;\n  let indexesAt = 0;\n  for (let i = 0; i < len; i++) {\n    if (i === indexes[indexesAt]) {\n      indexesAt++;\n      continue;\n    }\n    if (i !== arrAt) {\n      arr[arrAt] = arr[i];\n    }\n    arrAt++;\n  }\n  arr.length = arrAt;\n}\n\n/**\n * A List of python identifiers; the result of running keywords.kwlist in Python 3.9,\n * plus additional illegal identifiers None, False, True\n * Using [] instead of new Array causes a \"comprehension error\" for some reason\n */\nconst _kwlist = [\"False\", \"None\", \"True\", \"and\", \"as\", \"assert\", \"async\", \"await\", \"break\",\n  \"class\", \"continue\", \"def\", \"del\", \"elif\", \"else\", \"except\", \"finally\",\n  \"for\", \"from\", \"global\", \"if\", \"import\", \"in\", \"is\", \"lambda\", \"nonlocal\",\n  \"not\", \"or\", \"pass\", \"raise\", \"return\", \"try\", \"while\", \"with\", \"yield\"];\n/**\n * Given an arbitrary string, makes substitutions to make it a valid SQL/Python identifier.\n * Corresponds to sandbox/grist/gencode.sanitize_ident\n */\nexport function sanitizeIdent(ident: string, prefix?: string) {\n  prefix = prefix || \"c\";\n  // Remove non-alphanumeric non-_ chars\n  ident = ident.replace(/[^a-zA-Z0-9_]+/g, \"_\");\n  // Remove leading and trailing _\n  ident = ident.replace(/^_+|_+$/g, \"\");\n  // Place prefix at front if the beginning isn't a number\n  ident = ident.replace(/^(?=[0-9])/g, prefix);\n  // Append prefix until it is not  python keyword\n  while (_kwlist.includes(ident)) {\n    ident = prefix + ident;\n  }\n  return ident;\n}\n\n/**\n * Clone a function, returning a function object that represents a brand new function with the\n * same code. If the same function is used with different argument types, it would prevent JS V8\n * engine optimizations (or cause it to deoptimize it). If different clones are called with\n * different argument types, they can be optimized independently.\n *\n * As with all micro-optimizations, only do this when the optimization matters.\n */\nexport function cloneFunc(fn: Function): Function {      /* jshint evil:true */  // suppress eval warning.\n  return eval(\"(\" + fn.toString() + \")\");\n}\n\n/**\n * Generates a random id using a sequence of uppercase alphanumeric characters\n * preceded by an optional prefix.\n */\nexport function genRandomId(len: number, optPrefix?: string): string {\n  const chars = \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\";\n  let ret = optPrefix || \"\";\n  for (let i = 0; i < len; i++) {\n    ret += chars[Math.floor(Math.random() * chars.length)];\n  }\n  return ret;\n}\n\n/**\n * Scans through two sorted arrays, calling a function on each item or pair of items\n * for every present key in order.\n * @param {Array} arrA - First array to scan. NOTE: Should be sorted by the key value.\n * @param {Array} arrB - Second array to scan. NOTE: Should be sorted by the key value.\n * @param {Function} callback - Called with an item from arrA as the first argument and an\n *  item from arrB as the second. Called for every unique key in order, either with one of the\n *  arguments null if the key is present only in one array, or both non-null if the key is\n *  present in both arrays. NOTE: Key values should not be null.\n * @param {Function} optKeyFunc - Optional function to map each array value to a sort key.\n *  Defaults to the identity function.\n */\nexport function sortedScan<T, U>(arrA: ArrayLike<T>, arrB: ArrayLike<U>,\n  callback: (a: T | null, B: U | null) => void,\n  optKeyFunc?: (item: T | U) => any) {\n  const keyFunc = optKeyFunc || identity;\n  let i = 0, j = 0;\n  while (i < arrA.length || j < arrB.length) {\n    const a = arrA[i], b = arrB[j];\n    const keyA = i < arrA.length ? keyFunc(a) : null;\n    const keyB = j < arrB.length ? keyFunc(b) : null;\n    if (keyA !== null && (keyB === null || keyA < keyB)) {\n      callback(a, null);\n      i++;\n    } else if (keyA === null || keyA > keyB) {\n      callback(null, b);\n      j++;\n    } else {\n      callback(a, b);\n      i++;\n      j++;\n    }\n  }\n}\n\n/**\n * Returns the time in ms to wait until attempting another connection.\n * @param {Number} attemptNumber - Reconnect attempt number starting at 0.\n * @param {Array} intervals - Array of reconnect intervals in ms.\n * @returns {Number}\n */\nexport function getReconnectTimeout(attemptNumber: number, intervals: ArrayLike<number>): number {\n  if (attemptNumber >= intervals.length) {\n    // Add an additional wait time if already at max attempts.\n    const timeout = intervals[intervals.length - 1];\n    return timeout + Math.random() * timeout;\n  } else {\n    return intervals[attemptNumber];\n  }\n}\n\n/**\n * Returns whether the given email is a valid formatted email string.\n * @param {String} email - Email to test.\n * @returns {Boolean}\n */\nexport function isEmail(email: string): boolean {\n  return EMAIL_RE.test(email.toLowerCase());\n}\n\n/*\n * Takes an observable and returns a promise for when the observable's value matches the given\n * predicate. It then unsubscribes from the observable, and returns its value.\n * If a predicate is not given, resolves to the observable values as soon as it's truthy.\n */\nexport function waitObs<T>(observable: KoObservable<T>, predicate: (value: T) => boolean = Boolean): Promise<T> {\n  return new Promise((resolve, _reject) => {\n    const value = observable.peek();\n    if (predicate(value)) { return resolve(value); }\n    const sub = observable.subscribe((val: T) => {\n      if (predicate(val)) {\n        sub.dispose();\n        resolve(val);\n      }\n    });\n  });\n}\n\n/**\n * Same as waitObs but for grainjs observables.\n */\nexport async function waitGrainObs<T>(observable: Observable<T>): Promise<NonNullable<T>>;\nexport async function waitGrainObs<T>(observable: Observable<T>, predicate?: (value: T) => boolean): Promise<T>;\nexport async function waitGrainObs<T>(observable: Observable<T>,\n  predicate: (value: T) => boolean = Boolean): Promise<T> {\n  let sub: Listener | undefined;\n  const res: T = await new Promise((resolve, _reject) => {\n    const value = observable.get();\n    if (predicate(value)) { return resolve(value); }\n    sub = observable.addListener((val: T) => {\n      if (predicate(val)) {\n        resolve(val);\n      }\n    });\n  });\n  if (sub) { sub.dispose(); }\n  return res;\n}\n\n// `dom.style` does not work here because custom css property (ie: `--foo`) needs to be set using\n// `style.setProperty` (credit: https://vanseodesign.com/css/custom-properties-and-javascript/).\n// TODO: consider making PR to fix `dom.style` in grainjs.\nexport function inlineStyle(property: string, valueObs: BindableValue<any>): DomElementMethod {\n  return elem => subscribeElem(elem, valueObs, (val) => {\n    elem.style.setProperty(property, String(val ?? \"\"));\n  });\n}\n\n/**\n * Class to maintain a chain of promise-returning callbacks. All scheduled callbacks will be\n * called in order as long as the previous one is successful. If a callback fails is rejected,\n * already-scheduled callbacks will be skipped, but newly-scheduled ones will be run.\n */\nexport class PromiseChain<T> {\n  private _last: Promise<T | void> = Promise.resolve();\n\n  // Adds a callback to the chain. If the callback runs, the return value is the return value of\n  // the callback. If it's skipped due to a failure earlier in the chain, the return value is the\n  // rejection with the message \"Skipped due to an earlier error\".\n  public add(nextCB: () => Promise<T>): Promise<T> {\n    const next = this._last.catch(() => { throw new Error(\"Skipped due to an earlier error\"); }).then(nextCB);\n    // If any callback fails, all queued ones will be skipped. Here we reset the chain, so that\n    // callbacks added later do get run.\n    next.catch(() => { this._last = Promise.resolve(); });\n    this._last = next;\n    return next;\n  }\n}\n\n/**\n * Indicates if a hex color value, e.g. '#000000', is darker than the given value.\n * Darkness is measured from 0..255, where 0 is the darkest and 255 is the lightest.\n *\n * Taken from: https://stackoverflow.com/questions/12043187/how-to-check-if-hex-color-is-too-black\n */\nexport function isColorDark(hexColor: string, isDarkBelow: number = 220): boolean {\n  const c = hexColor.substring(1);  // strip #\n  const rgb = parseInt(c, 16);      // convert rrggbb to decimal\n  // Extract RGB components\n  const r = (rgb >> 16) & 0xff;      const g = (rgb >>  8) & 0xff;      const b = (rgb >>  0) & 0xff;\n  const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;  // per ITU-R BT.709\n  return luma < isDarkBelow;\n}\n\n/**\n * Returns true if val is a valid hex color value. For instance: #aabbaa is valid, #aabba is not. Do\n * not accept neither short notation nor hex with transparency, ie: #aab, #aabb and #aabbaabb are\n * invalid.\n */\nexport function isValidHex(val: unknown): val is string {\n  if (typeof val !== \"string\") {\n    return false;\n  }\n\n  return /^#([0-9A-F]{6})$/i.test(val);\n}\n\n/**\n * Resolves to true if promise is still pending after msec milliseconds have passed. Otherwise\n * returns false, including when promise is rejected.\n */\nexport async function timeoutReached(\n  msec: number, promise: Promise<unknown>, options: { rethrow: boolean } = { rethrow: false },\n): Promise<boolean> {\n  // For test purposes, support negative timeout, by failing\n  // immediately.\n  if (msec < 0) {\n    return true;\n  }\n  const timedOut = {};\n  // Be careful to clean up the timer after ourselves, so it doesn't remain in the event loop.\n  let timer: NodeJS.Timeout;\n  const delayPromise = new Promise<any>((resolve) => { timer = setTimeout(() => resolve(timedOut), msec); });\n  try {\n    const res = await Promise.race([promise, delayPromise]);\n    return res == timedOut;\n  } catch (err) {\n    if (options.rethrow) {\n      throw err;\n    }\n    return false;\n  } finally {\n    clearTimeout(timer!);\n  }\n}\n\n/**\n * Returns a promise that resolves to true if promise takes longer than timeoutMsec to resolve. If not\n * or if promise throws returns false. Same as timeoutReached(), with reversed order of arguments.\n */\nexport async function isLongerThan(promise: Promise<unknown>, timeoutMsec: number): Promise<boolean> {\n  return timeoutReached(timeoutMsec, promise);\n}\n\n/**\n * Returns true if the parameter, when rendered as a string, matches\n * 1, on, or true (case insensitively).  Useful for processing query\n * parameters that may have been manually set.\n */\nexport function isAffirmative(parameter: any): boolean {\n  return [\"1\", \"on\", \"true\", \"yes\"].includes(String(parameter).toLowerCase());\n}\n\n/**\n * Returns whether a value is neither null nor undefined, with a type guard for the return type.\n *\n * This is particularly useful for filtering, e.g. if `array` includes values of type\n * T|null|undefined, then TypeScript can tell that `array.filter(isNonNullish)` has the type T[].\n */\nexport function isNonNullish<T>(value: T | null | undefined): value is T {\n  return value !== null && value !== undefined;\n}\n\n/**\n * Ensures that a value is truthy, with a type guard for the return type.\n */\nexport function truthy<T>(value: T | null | undefined): value is Exclude<T, false | \"\" | 0> {\n  return Boolean(value);\n}\n\n/**\n * Returns the value of both grainjs and knockout observable without creating a dependency.\n */\nexport const unwrap: UseCB = (obs: ISubscribable) => {\n  if (\"_getDepItem\" in obs) {\n    return obs.get();\n  }\n  return (obs as ko.Observable).peek();\n};\n\n/**\n * Subscribes to BindableValue\n */\nexport function useBindable<T>(use: UseCBOwner, obs: BindableValue<T>): T {\n  if (obs === null || obs === undefined) { return obs; }\n\n  const smth = obs as any;\n\n  // If knockout\n  if (typeof smth === \"function\" && \"peek\" in smth) { return use(smth) as T; }\n  // If grainjs Observable or Computed\n  if (typeof smth === \"object\" && \"_getDepItem\" in smth) { return use(smth) as T; }\n  // If use function ComputedCallback\n  if (typeof smth === \"function\") { return smth(use) as T; }\n\n  return obs as T;\n}\n\n/**\n * Useful helper for simple boolean negation.\n */\nexport const not = (\n  obs: Observable<any> | IKnockoutReadObservable<any> | boolean | undefined | null,\n) => (use: UseCBOwner) =>  {\n  if (typeof obs === \"boolean\") { return !obs; }\n  if (obs === null || obs === undefined) { return true; }\n  return !use(obs);\n};\n\n/**\n * Get a set of up to `count` distinct values of `values`.\n */\nexport function getDistinctValues<T>(values: readonly T[], count: number = Infinity): Set<T> {\n  const distinct = new Set<T>();\n  // Add values to the set until it reaches the desired size, or until there are no more values.\n  for (let i = 0; i < values.length && distinct.size < count; i++) {\n    distinct.add(values[i]);\n  }\n  return distinct;\n}\n\n/**\n * Asserts that variable `name` has a non-nullish `value`.\n */\nexport function assertIsDefined<T>(name: string, value: T): asserts value is NonNullable<T> {\n  if (value === undefined || value === null) {\n    throw new Error(`Expected '${name}' to be defined, but received ${value}`);\n  }\n}\n\n/**\n * Calls function `fn`, passes any thrown errors to function `recover`, and finally calls `fn`\n * once more if `recover` doesn't throw.\n */\nexport async function retryOnce<T>(fn: () => Promise<T>, recover: (e: unknown) => Promise<void>): Promise<T> {\n  try {\n    return await fn();\n  } catch (e) {\n    await recover(e);\n    return await fn();\n  }\n}\n\n/**\n * Checks if value is 'empty' (like null, undefined, empty string, empty array/set/map, empty object).\n * Values like 0, true, false are not empty.\n */\nexport function notSet(value: any) {\n  return value === undefined || value === null || value === \"\" ||\n    (Array.isArray(value) && !value.length) ||\n    (typeof value === \"object\" && !Object.keys(value).length) ||\n    ([\"[object Map]\", \"[object Set\"].includes(value.toString()) && !value.size);\n}\n\n/**\n * Checks if value is 'empty', if it is, returns the default value (which is null).\n */\nexport function ifNotSet(value: any, def: any = null) {\n  return notSet(value) ? def : value;\n}\n\n/**\n * Creates a computed observable with a nested owner that can be used to dispose,\n * any disposables created inside the computed. Similar to domComputedOwned method.\n */\nexport function computedOwned<T>(\n  owner: IDisposableOwner,\n  func: (owner: IDisposableOwner, use: UseCBOwner) => T,\n): Computed<T> {\n  const holder = Holder.create(owner);\n  return Computed.create(owner, (use) => {\n    const computedOwner = MultiHolder.create(holder);\n    return func(computedOwner, use);\n  });\n}\n\nexport type Constructor<T> = new (...args: any[]) => T;\n\n/**\n * Simple memoization function that caches the result of a function call based on its arguments.\n * Unlike lodash's memoize, it uses all arguments to generate the key.\n */\nexport function cached<T>(fn: T): T {\n  const dict = new Map();\n  const impl = (...args: any[]) => {\n    const key = JSON.stringify(args);\n    if (!dict.has(key)) {\n      dict.set(key, (fn as any)(...args));\n    }\n    return dict.get(key);\n  };\n  return impl as any as T;\n}\n\n/**\n * Converts a duration string like \"1d\", \"2h\", \"14m\", \"10s\" to seconds.\n */\nexport function inSeconds(text: string): number {\n  const match = text.match(/^(\\d+)([smhd])$/);\n  if (!match) {\n    throw new Error(`Invalid duration: ${text}`);\n  }\n  const [, value, unit] = match;\n  const seconds = parseInt(value, 10);\n  switch (unit) {\n    case \"s\": return seconds;\n    case \"m\": return seconds * 60;\n    case \"h\": return seconds * 60 * 60;\n    case \"d\": return seconds * 60 * 60 * 24;\n    default: throw new Error(`Invalid duration unit: ${unit}`);\n  }\n}\n"
  },
  {
    "path": "app/common/isHiddenTable.ts",
    "content": "import { TableData } from \"app/common/TableData\";\nimport { UIRowId } from \"app/plugin/GristAPI\";\n\n/**\n * Return whether a table (identified by the rowId of its metadata record) should\n * normally be hidden from the user (e.g. as an option in the page-widget picker).\n */\nexport function isHiddenTable(tablesData: TableData, tableRef: UIRowId): boolean {\n  const tableId = tablesData.getValue(tableRef, \"tableId\") as string | undefined;\n  // The `!tableId` check covers the case of censored tables (see isTableCensored() below).\n  return !tableId || isSummaryTable(tablesData, tableRef) || tableId.startsWith(\"GristHidden_\");\n}\n\n/**\n * Return whether a table (identified by the rowId of its metadata record) is a\n * summary table.\n */\nexport function isSummaryTable(tablesData: TableData, tableRef: UIRowId): boolean {\n  return tablesData.getValue(tableRef, \"summarySourceTable\") !== 0;\n}\n\n// Check if a table record (from _grist_Tables) is censored.\n// Metadata records get censored by clearing certain of their fields, so it's expected that a\n// record may exist even though various code should consider it as hidden.\nexport function isTableCensored(tablesData: TableData, tableRef: UIRowId): boolean {\n  const tableId = tablesData.getValue(tableRef, \"tableId\");\n  return !tableId;\n}\n\n/**\n * Returns whether this is a metadata table, as opposed to a user table.\n */\nexport function isMetadataTable(tableId: string): boolean {\n  return tableId.startsWith(\"_grist\");\n}\n"
  },
  {
    "path": "app/common/loginProviders.ts",
    "content": "// Special provider that uses default user, which effectively means no authentication.\nexport const MINIMAL_PROVIDER_KEY = \"minimal\";\n\n// OpenID Connect provider key.\nexport const OIDC_PROVIDER_KEY = \"oidc\";\n\n// ForwardAuth provider key.\nexport const FORWARD_AUTH_PROVIDER_KEY = \"forward-auth\";\n\n// This provider is only available in grist-ee version.\nexport const GRIST_CONNECT_PROVIDER_KEY = \"grist-connect\";\n\n// getgrist.com provider key.\nexport const GETGRIST_COM_PROVIDER_KEY = \"getgrist.com\";\n\n// SAML provider key.\nexport const SAML_PROVIDER_KEY = \"saml\";\n\n// Deprecated/unmaintained providers, hidden unless already configured or active.\nexport const DEPRECATED_PROVIDERS: string[] = [\n  GRIST_CONNECT_PROVIDER_KEY,\n];\n"
  },
  {
    "path": "app/common/marshal.ts",
    "content": "/**\n * Module for serializing data in the format of Python 'marshal' module. It's used for\n * communicating with the Python-based formula engine running in a Pypy sandbox. It supports\n * version 0 of python marshalling format, which is what the Pypy sandbox supports.\n *\n * Usage:\n *    Marshalling:\n *      const marshaller = new Marshaller({version: 2});\n *      marshaller.marshal(value);\n *      marshaller.marshal(value);\n *      const buf = marshaller.dump();    // Leaves the marshaller empty.\n *\n *    Unmarshalling:\n *      const unmarshaller = new Unmarshaller();\n *      unmarshaller.on('value', function(value) { ... });\n *      unmarshaller.push(buffer);\n *      unmarshaller.push(buffer);\n *\n * In Python, and in the marshalled format, there is a distinction between strings and unicode\n * objects. In JS, there is a good correspondence to Uint8Array objects and strings, respectively.\n * Python unicode objects always become JS strings. JS Uint8Arrays always become Python strings.\n *\n * JS strings become Python unicode objects, but can be marshalled to Python strings with\n * 'stringToBuffer' option. Similarly, Python strings become JS Uint8Arrays, but can be\n * unmarshalled to JS strings if 'bufferToString' option is set.\n */\nimport { BigInt } from \"app/common/BigInt\";\nimport MemBuffer from \"app/common/MemBuffer\";\n\nimport { EventEmitter } from \"events\";\nimport * as util from \"util\";\n\nexport interface MarshalOptions {\n  stringToBuffer?: boolean;\n  version?: number;\n\n  // True if we want keys in dicts to be buffers.\n  // It is convenient to have some freedom here to simplify implementation\n  // of marshaling for some SQLite wrappers. This flag was initially\n  // introduced for a fork of Grist using better-sqlite3, and I don't\n  // remember exactly what the issues were.\n  keysAreBuffers?: boolean;\n}\n\nexport interface UnmarshalOptions {\n  bufferToString?: boolean;\n}\n\nfunction ord(str: string): number {\n  return str.charCodeAt(0);\n}\n\n/**\n * Type codes used for python marshalling of values.\n * See pypy: rpython/translator/sandbox/_marshal.py.\n */\nconst marshalCodes = {\n  NULL: ord(\"0\"),\n  NONE: ord(\"N\"),\n  FALSE: ord(\"F\"),\n  TRUE: ord(\"T\"),\n  STOPITER: ord(\"S\"),\n  ELLIPSIS: ord(\".\"),\n  INT: ord(\"i\"),\n  INT64: ord(\"I\"),\n  /*\n    BFLOAT, for 'binary float', is an encoding of float that just encodes the bytes of the\n    double in standard IEEE 754 float64 format. It is used by Version 2+ of Python's marshal\n    module. Previously (in versions 0 and 1), the FLOAT encoding is used, which stores floats\n    through their string representations.\n\n    Version 0 (FLOAT) is mandatory for system calls within the sandbox, while Version 2 (BFLOAT)\n    is recommended for Grist's communication because it is more efficient and faster to\n    encode/decode\n   */\n  BFLOAT: ord(\"g\"),\n  FLOAT: ord(\"f\"),\n  COMPLEX: ord(\"x\"),\n  LONG: ord(\"l\"),\n  STRING: ord(\"s\"),\n  INTERNED: ord(\"t\"),\n  STRINGREF: ord(\"R\"),\n  TUPLE: ord(\"(\"),\n  LIST: ord(\"[\"),\n  DICT: ord(\"{\"),\n  CODE: ord(\"c\"),\n  UNICODE: ord(\"u\"),\n  UNKNOWN: ord(\"?\"),\n  SET: ord(\"<\"),\n  FROZENSET: ord(\">\"),\n};\n\ntype MarshalCode = keyof typeof marshalCodes;\n\n// A little hack to test if the value is a 32-bit integer. Actually, for Python, int might be up\n// to 64 bits (if that's the native size), but this is simpler.\n// See http://stackoverflow.com/questions/3885817/how-to-check-if-a-number-is-float-or-integer.\nfunction isInteger(n: number): boolean {\n  // Float have +0.0 and -0.0. To represent -0.0 precisely, we have to use a float, not an int\n  // (see also https://stackoverflow.com/questions/7223359/are-0-and-0-the-same).\n  return n === +n && n === (n | 0) && !Object.is(n, -0.0);\n}\n\n// ----------------------------------------------------------------------\n\n/**\n * To force a value to be serialized using a particular representation (e.g. a number as INT64),\n * wrap it into marshal.wrap('INT64', value) and serialize that.\n */\nexport function wrap(codeStr: MarshalCode, value: unknown) {\n  return new WrappedObj(marshalCodes[codeStr], value);\n}\n\nexport class WrappedObj {\n  constructor(public code: number, public value: unknown) {}\n\n  public inspect() {\n    return util.inspect(this.value);\n  }\n}\n\n// ----------------------------------------------------------------------\n\n/**\n * @param {Boolean} options.stringToBuffer - If set, JS strings will become Python strings rather\n *      than unicode objects (as if each JS string is wrapped into MemBuffer.stringToArray(str)).\n *      This flag becomes a same-named property of Marshaller, which can be set at any time.\n * @param {Number} options.version - If version >= 2, uses binary representation for floats. The\n *      default version 0 formats floats as strings.\n *\n * TODO: The default should be version 2. (0 was used historically because it was needed for\n * communication with PyPy-based sandbox.)\n */\nexport class Marshaller {\n  private _memBuf: MemBuffer;\n  private readonly _floatCode: number;\n  private readonly _stringCode: number;\n  private readonly _keysAreBuffers: boolean;\n\n  constructor(options?: MarshalOptions) {\n    this._memBuf = new MemBuffer(undefined);\n    this._floatCode = options?.version && options.version >= 2 ? marshalCodes.BFLOAT : marshalCodes.FLOAT;\n    this._stringCode = options?.stringToBuffer ? marshalCodes.STRING : marshalCodes.UNICODE;\n    this._keysAreBuffers = Boolean(options?.keysAreBuffers);\n  }\n\n  public dump(): Uint8Array {\n    // asByteArray returns a view on the underlying data, and the constructor creates a new copy.\n    // For some usages, we may want to avoid making the copy.\n    const bytes = new Uint8Array(this._memBuf.asByteArray());\n    this._memBuf.clear();\n    return bytes;\n  }\n\n  public dumpAsBuffer(): Buffer {\n    const bytes = Buffer.from(this._memBuf.asByteArray());\n    this._memBuf.clear();\n    return bytes;\n  }\n\n  public getCode(value: any) {\n    switch (typeof value) {\n      case \"number\": return isInteger(value) ? marshalCodes.INT : this._floatCode;\n      case \"string\": return this._stringCode;\n      case \"boolean\": return value ? marshalCodes.TRUE : marshalCodes.FALSE;\n      case \"undefined\": return marshalCodes.NONE;\n      case \"object\": {\n        if (value instanceof WrappedObj) {\n          return value.code;\n        } else if (value === null) {\n          return marshalCodes.NONE;\n        } else if (value instanceof Uint8Array) {\n          return marshalCodes.STRING;\n        } else if (Buffer.isBuffer(value)) {\n          return marshalCodes.STRING;\n        } else if (Array.isArray(value)) {\n          return marshalCodes.LIST;\n        }\n        return marshalCodes.DICT;\n      }\n      default: {\n        throw new Error(\"Marshaller: Unsupported value of type \" + (typeof value));\n      }\n    }\n  }\n\n  public marshal(value: any): void {\n    const code = this.getCode(value);\n    if (value instanceof WrappedObj) {\n      value = value.value;\n    }\n    this._memBuf.writeUint8(code);\n    switch (code) {\n      case marshalCodes.NULL:       return;\n      case marshalCodes.NONE:       return;\n      case marshalCodes.FALSE:      return;\n      case marshalCodes.TRUE:       return;\n      case marshalCodes.INT:        return this._memBuf.writeInt32LE(value);\n      case marshalCodes.INT64:      return this._writeInt64(value);\n      case marshalCodes.FLOAT:      return this._writeStringFloat(value);\n      case marshalCodes.BFLOAT:     return this._memBuf.writeFloat64LE(value);\n      case marshalCodes.STRING:\n        return (value instanceof Uint8Array || Buffer.isBuffer(value) ?\n          this._writeByteArray(value) :\n          this._writeUtf8String(value));\n      case marshalCodes.TUPLE:      return this._writeList(value);\n      case marshalCodes.LIST:       return this._writeList(value);\n      case marshalCodes.DICT:       return this._writeDict(value);\n      case marshalCodes.UNICODE:    return this._writeUtf8String(value);\n      // None of the following are supported.\n      case marshalCodes.STOPITER:\n      case marshalCodes.ELLIPSIS:\n      case marshalCodes.COMPLEX:\n      case marshalCodes.LONG:\n      case marshalCodes.INTERNED:\n      case marshalCodes.STRINGREF:\n      case marshalCodes.CODE:\n      case marshalCodes.UNKNOWN:\n      case marshalCodes.SET:\n      case marshalCodes.FROZENSET:  throw new Error(\"Marshaller: Can't serialize code \" + code);\n      default:                      throw new Error(\"Marshaller: Can't serialize code \" + code);\n    }\n  }\n\n  private _writeInt64(value: number) {\n    if (!isInteger(value)) {\n      // TODO We could actually support 53 bits or so.\n      throw new Error(\"Marshaller: int64 still only supports 32-bit ints for now: \" + value);\n    }\n    this._memBuf.writeInt32LE(value);\n    this._memBuf.writeInt32LE(value >= 0 ? 0 : -1);\n  }\n\n  private _writeStringFloat(value: number) {\n    // This could be optimized a bit, but it's only used in V0 marshalling, which is only used in\n    // sandbox system calls, which don't really ever use floats anyway.\n    const bytes = MemBuffer.stringToArray(value.toString());\n    if (bytes.byteLength >= 127) {\n      throw new Error(\"Marshaller: Trying to write a float that takes \" + bytes.byteLength + \" bytes\");\n    }\n    this._memBuf.writeUint8(bytes.byteLength);\n    this._memBuf.writeByteArray(bytes);\n  }\n\n  private _writeByteArray(value: Uint8Array | Buffer) {\n    // This works for both Uint8Arrays and Node Buffers.\n    this._memBuf.writeInt32LE(value.length);\n    this._memBuf.writeByteArray(value);\n  }\n\n  private _writeUtf8String(value: string) {\n    const offset = this._memBuf.size();\n    // We don't know the length until we write the value.\n    this._memBuf.writeInt32LE(0);\n    this._memBuf.writeString(value);\n    const byteLength = this._memBuf.size() - offset - 4;\n    // Overwrite the 0 length we wrote earlier with the correct byte length.\n    this._memBuf.asDataView.setInt32(this._memBuf.startPos + offset, byteLength, true);\n  }\n\n  private _writeList(array: unknown[]) {\n    this._memBuf.writeInt32LE(array.length);\n    for (const item of array) {\n      this.marshal(item);\n    }\n  }\n\n  private _writeDict(obj: { [key: string]: any }) {\n    const keys = Object.keys(obj);\n    keys.sort();\n    for (const key of keys) {\n      this.marshal(this._keysAreBuffers ? Buffer.from(key) : key);\n      this.marshal(obj[key]);\n    }\n    this._memBuf.writeUint8(marshalCodes.NULL);\n  }\n}\n\n// ----------------------------------------------------------------------\n\nconst TwoTo32 = 0x100000000;    // 2**32\nconst TwoTo15 = 0x8000;         // 2**15\n\n/**\n * @param {Boolean} options.bufferToString - If set, Python strings will become JS strings rather\n *      than Buffers (as if each decoded buffer is wrapped into `buf.toString()`).\n *      This flag becomes a same-named property of Unmarshaller, which can be set at any time.\n * Note that options.version isn't needed, since this will decode both formats.\n * TODO: Integers (such as int64 and longs) that are too large for JS are currently represented as\n * decimal strings. They may need a better representation, or a configurable option.\n */\nexport class Unmarshaller extends EventEmitter {\n  public memBuf: MemBuffer;\n  private _consumer: any = null;\n  private _lastCode: number | null = null;\n  private readonly _bufferToString: boolean;\n  private _emitter: (v: any) => boolean;\n  private _stringTable: (string | Uint8Array)[] = [];\n\n  constructor(options?: UnmarshalOptions) {\n    super();\n    this.memBuf = new MemBuffer(undefined);\n    this._bufferToString = Boolean(options?.bufferToString);\n    this._emitter = this.emit.bind(this, \"value\");\n  }\n\n  /**\n   * Adds more data for parsing. Parsed values will be emitted as 'value' events.\n   * @param {Uint8Array|Buffer} byteArray: Uint8Array or Node Buffer with bytes to parse.\n   */\n  public push(byteArray: Uint8Array | Buffer) {\n    this.parse(byteArray, this._emitter);\n  }\n\n  /**\n   * Adds data to parse, and calls valueCB(value) for each value parsed. If valueCB returns the\n   * Boolean false, stops parsing and returns.\n   */\n  public parse(byteArray: Uint8Array | Buffer, valueCB: (val: any) => boolean | void) {\n    this.memBuf.writeByteArray(byteArray);\n    try {\n      while (this.memBuf.size() > 0) {\n        this._consumer = this.memBuf.makeConsumer();\n\n        // Have to reset stringTable for interned strings before each top-level parse call.\n        this._stringTable.length = 0;\n\n        const value = this._parse();\n        this.memBuf.consume(this._consumer);\n        if (valueCB(value) === false) {\n          return;\n        }\n      }\n    } catch (err) {\n      // If the error is `needMoreData`, we silently return. We'll retry by reparsing the message\n      // from scratch after the next push(). If buffers contain complete serialized messages, the\n      // cost should be minor. But this design might get very inefficient if we have big messages\n      // of arrays or dictionaries.\n      if (err.needMoreData) {\n        if (!err.consumedData || err.consumedData > 1024) {\n          console.log(\"Unmarshaller: Need more data; wasted parsing of %d bytes\", err.consumedData);\n        }\n      } else {\n        err.message = \"Unmarshaller: \" + err.message;\n        throw err;\n      }\n    }\n  }\n\n  private _parse(): unknown {\n    const code = this.memBuf.readUint8(this._consumer);\n    this._lastCode = code;\n    switch (code) {\n      case marshalCodes.NULL:       return null;\n      case marshalCodes.NONE:       return null;\n      case marshalCodes.FALSE:      return false;\n      case marshalCodes.TRUE:       return true;\n      case marshalCodes.INT:        return this._parseInt();\n      case marshalCodes.INT64:      return this._parseInt64();\n      case marshalCodes.FLOAT:      return this._parseStringFloat();\n      case marshalCodes.BFLOAT:     return this._parseBinaryFloat();\n      case marshalCodes.STRING:     return this._parseByteString();\n      case marshalCodes.TUPLE:      return this._parseList();\n      case marshalCodes.LIST:       return this._parseList();\n      case marshalCodes.DICT:       return this._parseDict();\n      case marshalCodes.UNICODE:    return this._parseUnicode();\n      case marshalCodes.INTERNED:   return this._parseInterned();\n      case marshalCodes.STRINGREF:  return this._parseStringRef();\n      case marshalCodes.LONG:       return this._parseLong();\n        // None of the following are supported.\n        // case marshalCodes.STOPITER:\n        // case marshalCodes.ELLIPSIS:\n        // case marshalCodes.COMPLEX:\n        // case marshalCodes.CODE:\n        // case marshalCodes.UNKNOWN:\n        // case marshalCodes.SET:\n        // case marshalCodes.FROZENSET:\n      default:\n        throw new Error(`Unmarshaller: unsupported code \"${String.fromCharCode(code)}\" (${code})`);\n    }\n  }\n\n  private _parseInt() {\n    return this.memBuf.readInt32LE(this._consumer);\n  }\n\n  private _parseInt64() {\n    const low = this.memBuf.readInt32LE(this._consumer);\n    const hi = this.memBuf.readInt32LE(this._consumer);\n    if ((hi === 0 && low >= 0) || (hi === -1 && low < 0)) {\n      return low;\n    }\n    const unsignedLow = low < 0 ? TwoTo32 + low : low;\n    if (hi >= 0) {\n      return new BigInt(TwoTo32, [unsignedLow, hi], 1).toNative();\n    } else {\n      // This part is tricky. See unittests for check of correctness.\n      return new BigInt(TwoTo32, [TwoTo32 - unsignedLow, -hi - 1], -1).toNative();\n    }\n  }\n\n  private _parseLong() {\n    // The format is a 32-bit size whose sign is the sign of the result, followed by 16-bit digits\n    // in base 2**15.\n    const size = this.memBuf.readInt32LE(this._consumer);\n    const sign = size < 0 ? -1 : 1;\n    const numDigits = size < 0 ? -size : size;\n    const digits = [];\n    for (let i = 0; i < numDigits; i++) {\n      digits.push(this.memBuf.readInt16LE(this._consumer));\n    }\n    return new BigInt(TwoTo15, digits, sign).toNative();\n  }\n\n  private _parseStringFloat() {\n    const len = this.memBuf.readUint8(this._consumer);\n    const buf = this.memBuf.readString(this._consumer, len);\n    return parseFloat(buf);\n  }\n\n  private _parseBinaryFloat() {\n    return this.memBuf.readFloat64LE(this._consumer);\n  }\n\n  private _parseByteString(): string | Uint8Array {\n    const len = this.memBuf.readInt32LE(this._consumer);\n    return (this._bufferToString ?\n      this.memBuf.readString(this._consumer, len) :\n      this.memBuf.readByteArray(this._consumer, len));\n  }\n\n  private _parseInterned() {\n    const s = this._parseByteString();\n    this._stringTable.push(s);\n    return s;\n  }\n\n  private _parseStringRef() {\n    const index = this._parseInt();\n    return this._stringTable[index];\n  }\n\n  private _parseList() {\n    const len = this.memBuf.readInt32LE(this._consumer);\n    const value = [];\n    for (let i = 0; i < len; i++) {\n      value[i] = this._parse();\n    }\n    return value;\n  }\n\n  private _parseDict() {\n    const dict: { [key: string]: any } = {};\n    while (true) {\n      let key = this._parse() as string | Uint8Array;\n      if (key === null && this._lastCode === marshalCodes.NULL) {\n        break;\n      }\n      const value = this._parse();\n      if (key !== null) {\n        if (key instanceof Uint8Array) {\n          key = MemBuffer.arrayToString(key);\n        }\n        dict[key as string] = value;\n      }\n    }\n    return dict;\n  }\n\n  private _parseUnicode() {\n    const len = this.memBuf.readInt32LE(this._consumer);\n    return this.memBuf.readString(this._consumer, len);\n  }\n}\n\n/**\n * Similar to python's marshal.loads(). Parses the given bytes and returns the parsed value. There\n * must not be any trailing data beyond the single marshalled value.\n */\nexport function loads(byteArray: Uint8Array | Buffer, options?: UnmarshalOptions): any {\n  const unmarshaller = new Unmarshaller(options);\n  let parsedValue;\n  unmarshaller.parse(byteArray, function(value) {\n    parsedValue = value;\n    return false;\n  });\n  if (typeof parsedValue === \"undefined\") {\n    throw new Error(\"loads: input data truncated\");\n  } else if (unmarshaller.memBuf.size() > 0) {\n    throw new Error(\"loads: extra bytes past end of input\");\n  }\n  return parsedValue;\n}\n\n/**\n * Serializes arbitrary data by first marshalling then converting to a base64 string.\n */\nexport function dumpBase64(data: any, options?: MarshalOptions) {\n  const marshaller = new Marshaller(options || { version: 2 });\n  marshaller.marshal(data);\n  return marshaller.dumpAsBuffer().toString(\"base64\");\n}\n\n/**\n * Loads data from a base64 string, as serialized by dumpBase64().\n */\nexport function loadBase64(data: string, options?: UnmarshalOptions) {\n  return loads(Buffer.from(data, \"base64\"), options);\n}\n"
  },
  {
    "path": "app/common/normalizedDateTimeString.ts",
    "content": "import moment from \"moment-timezone\";\n\n/**\n * Output an ISO8601 format datetime string, with timezone.\n * Any string fed in without timezone is expected to be in UTC.\n *\n * When connected to postgres, dates will be extracted as Date objects,\n * with timezone information. The normalization done here is not\n * really needed in this case.\n *\n * Timestamps in SQLite are stored as UTC, and read as strings\n * (without timezone information). The normalization here is\n * pretty important in this case.\n */\nexport function normalizedDateTimeString(dateTime: any): string {\n  if (!dateTime) { return dateTime; }\n  if (dateTime instanceof Date) {\n    return moment(dateTime).toISOString();\n  }\n  if (typeof dateTime === \"string\" || typeof dateTime === \"number\") {\n    // When SQLite returns a string, it will be in UTC.\n    // Need to make sure it actually have timezone info in it\n    // (will not by default).\n    return moment.utc(dateTime).toISOString();\n  }\n  throw new Error(`normalizedDateTimeString cannot handle ${dateTime}`);\n}\n"
  },
  {
    "path": "app/common/orgNameUtils.ts",
    "content": "const BLACKLISTED_SUBDOMAINS = new Set([\n  // from wiki page as of 2018-12-14\n  \"aws\",\n  \"gristlogin\",\n  \"issues\",\n  \"metrics\",\n  \"phab\",\n  \"releases\",\n  \"test\",\n  \"vpn\",\n  \"www\",\n\n  // A few more reserved just in case.  The minimum length requirement would eliminate\n  // some in any case, but specified here also in case that minimum changes.\n  \"w\", \"ww\", \"wwww\", \"wwwww\",\n  \"docs\", \"api\", \"static\",\n  \"ftp\", \"imap\", \"pop\", \"smtp\", \"mail\", \"git\", \"blog\", \"wiki\", \"support\", \"kb\", \"help\",\n  \"admin\", \"store\", \"dev\", \"beta\",\n  \"community\", \"try\", \"wpx\", \"telemetry\",\n\n  // a few random tech brands\n  \"google\", \"apple\", \"microsoft\", \"ms\", \"facebook\", \"fb\", \"twitter\", \"youtube\", \"yt\",\n\n  // updates for new special domains\n  \"current\", \"staging\", \"prod\", \"login\", \"login-dev\", \"login-s\",\n\n  // some domains that look suspicious\n  \"1ogin\", \"1ogin-dev\", \"1ogin-s\",\n]);\n\n/**\n *\n * Checks whether the subdomain is on the list of forbidden subdomains.\n * See /documentation/urls.md#organization-subdomains\n *\n * Also enforces various sanity checks.\n *\n * Throws if the subdomain is invalid.\n *\n */\nexport function checkSubdomainValidity(subdomain: string): void {\n  // stick with limited alphanumeric subdomains.\n  if (!(/^[a-z0-9][-a-z0-9]*$/.test(subdomain))) {\n    throw new Error(\"Domain must include lower-case letters, numbers, and dashes only.\");\n  }\n  // 'docs-*' is reserved for personal orgs.\n  if (subdomain.startsWith(\"docs-\")) { throw new Error('Domain cannot use reserved prefix \"docs-\".'); }\n  // 'o-*' is reserved for automatic org domains.\n  if (subdomain.startsWith(\"o-\")) { throw new Error('Domain cannot use reserved prefix \"o-\".'); }\n  // 'doc-worker-*' is reserved for doc workers.\n  if (subdomain.startsWith(\"doc-worker-\")) { throw new Error('Domain cannot use reserved prefix \"doc-worker-\".'); }\n  // special subdomains like _domainkey.\n  if (subdomain.startsWith(\"_\")) { throw new Error('Domain cannot use reserved prefix \"_\".'); }\n  // some domains are currently in use for testing v1.\n  if (subdomain.startsWith(\"v1-\")) { throw new Error('Domain cannot use reserved prefix \"v1-\".'); }\n  // check limit of 63 characters on dns label.\n  if (subdomain.length > 63) { throw new Error(\"Domain must contain less than 64 characters.\"); }\n  // check the subdomain isn't too short.\n  if (subdomain.length <= 2) { throw new Error(\"Domain must contain more than 2 characters.\"); }\n  // a small blacklist prepared by hand.\n  if (BLACKLISTED_SUBDOMAINS.has(subdomain)) { throw new Error(\"Invalid domain value.\"); }\n}\n"
  },
  {
    "path": "app/common/parseDate.ts",
    "content": "import { getDistinctValues, isNonNullish } from \"app/common/gutil\";\n\nimport guessFormat from \"@gristlabs/moment-guess/dist/bundle.js\";\nimport escapeRegExp from \"lodash/escapeRegExp\";\nimport last from \"lodash/last\";\nimport memoize from \"lodash/memoize\";\n// Simply importing 'moment-guess' inconsistently imports bundle.js or bundle.esm.js depending on environment\nimport moment from \"moment-timezone\";\n\n// When using YY format, use a consistent interpretation in datepicker and in moment parsing: add\n// 2000 if the result is at most 10 years greater than the current year; otherwise add 1900. See\n// https://bootstrap-datepicker.readthedocs.io/en/latest/options.html#assumenearbyyear and\n// \"Parsing two digit years\" in https://momentjs.com/docs/#/parsing/string-format/.\nexport const TWO_DIGIT_YEAR_THRESHOLD = 10;\nconst MAX_TWO_DIGIT_YEAR = new Date().getFullYear() + TWO_DIGIT_YEAR_THRESHOLD - 2000;\n\n// Moment suggests that overriding this is fine, but we need to force TypeScript to allow it.\n(moment as any).parseTwoDigitYear = function(yearString: string): number {\n  const year = parseInt(yearString, 10);\n  return year + (year > MAX_TWO_DIGIT_YEAR ? 1900 : 2000);\n};\n\n// Order of formats to try if the date cannot be parsed as the currently set format.\n// Formats are parsed in momentjs strict mode, but separator matching and the MM/DD\n// two digit requirement are ignored. Also, partial completion is permitted, so formats\n// may match even if only beginning elements are provided.\n// TODO: These should be affected by the user's locale/settings.\n// TODO: We may want to consider adding default time formats as well to support more\n//  time formats.\nconst PARSER_FORMATS: string[] = [\n  \"M D YYYY\",\n  \"M D YY\",\n  \"M D\",\n  \"M\",\n  \"MMMM D YYYY\",\n  \"MMMM D\",\n  \"MMMM Do YYYY\",\n  \"MMMM Do\",\n  \"D MMMM YYYY\",\n  \"D MMMM\",\n  \"Do MMMM YYYY\",\n  \"Do MMMM\",\n  \"MMMM\",\n  \"MMM D YYYY\",\n  \"MMM D\",\n  \"MMM Do YYYY\",\n  \"MMM Do\",\n  \"D MMM YYYY\",\n  \"D MMM\",\n  \"Do MMM YYYY\",\n  \"Do MMM\",\n  \"MMM\",\n  \"YYYY M D\",\n  \"YYYY M\",\n  \"YYYY\",\n  \"D M YYYY\",\n  \"D M YY\",\n  \"D M\",\n  \"D\",\n];\n\nconst UNAMBIGUOUS_FORMATS = [\n  \"YYYY M D\",\n  ...PARSER_FORMATS.filter(f => f.includes(\"MMM\")),\n];\n\nconst TIME_REGEX = /(?:^|\\s+|T)(?:(\\d\\d?)(?::(\\d\\d?)(?::(\\d\\d?))?)?|(\\d\\d?)(\\d\\d))\\s*([ap]m?)?$/i;\n// [^a-zA-Z] because no letters are allowed directly before the abbreviation\nconst UTC_REGEX = /[^a-zA-Z](UTC?|GMT|Z)$/i;\nconst NUMERIC_TZ_REGEX = /([+-]\\d\\d?)(?::?(\\d\\d))?$/i;\n\n// Not picky about separators, so replace them in the date and format strings to be spaces.\nconst SEPARATORS = /[\\W_]+/g;\n\nconst tzAbbreviations = memoize((tzName: string): RegExp => {\n  // Some abbreviations are just e.g. +05\n  // and escaping the + seems better than filtering\n  const abbreviations = new Set(moment.tz.zone(tzName)!.abbrs.map(escapeRegExp));\n\n  const union = [...abbreviations].join(\"|\");\n\n  // [^a-zA-Z] because no letters are allowed directly before the abbreviation\n  // so for example CEST won't match even if EST does\n  return new RegExp(`[^a-zA-Z](${union})$`, \"i\");\n});\n\ninterface ParseOptions {\n  time?: string;\n  dateFormat?: string;\n  timeFormat?: string;\n  timezone?: string;\n}\n\n/**\n * parseDate - Attempts to parse a date string using several common formats. Returns the\n *  timestamp of the parsed date in seconds since epoch, or returns null on failure.\n * @param {String} date - The date string to parse.\n * @param {String} options.dateFormat - The preferred momentjs format to use to parse the\n *  date. This is attempted before the default formats.\n * @param {String} options.time - The time string to parse.\n * @param {String} options.timeFormat - The momentjs format to use to parse the time. This\n *  must be given if options.time is given.\n * @param {String} options.timezone - The timezone string for the date/time, which affects\n *  the resulting timestamp.\n */\nexport function parseDate(date: string, options: ParseOptions = {}): number | null {\n  // If no date, return null.\n  if (!date) {\n    return null;\n  }\n\n  // If this looks like a timestamp (string with 9 or more digits), just return it.\n  const timestamp = parseTimeStamp(date);\n  if (timestamp !== null) {\n    return timestamp;\n  }\n\n  const dateFormat = options.dateFormat || \"YYYY-MM-DD\";\n  const dateFormats = [..._buildVariations(dateFormat, date), ...PARSER_FORMATS];\n  const cleanDate = date.replace(SEPARATORS, \" \");\n  let datetime = cleanDate.trim();\n  let timeformat = \"\";\n  let time = options.time;\n  if (time) {\n    const parsedTimeZone = parseTimeZone(time, options.timezone!);\n    const parsedTime = standardizeTime(parsedTimeZone.remaining);\n    if (!parsedTime || parsedTime.remaining) {\n      return null;\n    }\n    time = parsedTime.time;\n    const { tzOffset } = parsedTimeZone;\n    datetime += \" \" + time + tzOffset;\n    timeformat = \" HH:mm:ss\" + (tzOffset ? \"Z\" : \"\");\n  }\n  for (const format of dateFormats) {\n    const fullFormat = format + timeformat;\n    const m = moment.tz(datetime, fullFormat, true, options.timezone || \"UTC\");\n    if (m.isValid()) {\n      return m.unix();\n    }\n  }\n  return null;\n}\n\n/**\n * Similar to parseDate, with these differences:\n * - Only for a date (no time part)\n * - Only falls back to UNAMBIGUOUS_FORMATS, not the full PARSER_FORMATS\n * - Optionally adds all dates which match some format to `results`, otherwise returns first match.\n * This is safer so it can be used for parsing when pasting a large number of dates\n * and won't silently swap around day and month.\n */\nexport function parseDateStrict(\n  date: string, dateFormat: string | null, results?: Set<number>, timezone: string = \"UTC\",\n): number | undefined {\n  if (!date) {\n    return;\n  }\n  // If this looks like a timestamp (string with 9 or more digits), just return it.\n  const timestamp = parseTimeStamp(date);\n  if (timestamp !== null) {\n    return timestamp;\n  }\n  dateFormat = dateFormat || \"YYYY-MM-DD\";\n  const dateFormats = [..._buildVariations(dateFormat, date), ...UNAMBIGUOUS_FORMATS];\n  const cleanDate = date.replace(SEPARATORS, \" \").trim();\n  for (const format of dateFormats) {\n    const m = moment.tz(cleanDate, format, true, timezone);\n    if (m.isValid()) {\n      const value = m.valueOf() / 1000;\n      if (results) {\n        results.add(value);\n      } else {\n        return value;\n      }\n    }\n  }\n}\n\nexport function parseDateTime(dateTime: string, options: ParseOptions): number | undefined {\n  dateTime = dateTime.trim();\n  if (!dateTime) {\n    return;\n  }\n\n  const dateFormat = options.dateFormat || \"YYYY-MM-DD\";\n  const timezone = options.timezone || \"UTC\";\n\n  const dateOnly = parseDateStrict(dateTime, dateFormat, undefined, timezone);\n  if (dateOnly) {\n    return dateOnly;\n  }\n\n  const parsedTimeZone = parseTimeZone(dateTime, timezone);\n  let tzOffset = \"\";\n  if (parsedTimeZone) {\n    tzOffset = parsedTimeZone.tzOffset;\n    dateTime = parsedTimeZone.remaining;\n  }\n\n  const parsedTime = standardizeTime(dateTime);\n  if (!parsedTime) {\n    return;\n  }\n\n  dateTime = parsedTime.remaining;\n  const date = parseDateStrict(dateTime, dateFormat);\n\n  if (!date) {\n    return;\n  }\n\n  // date is a timestamp of midnight in UTC, so to get a formatted representation (for parsing\n  // together with time), take care to interpret it in UTC.\n  const dateString = moment.unix(date).utc().format(\"YYYY-MM-DD\");\n  dateTime = dateString + \" \" + parsedTime.time + tzOffset;\n  const fullFormat = \"YYYY-MM-DD HH:mm:ss\" + (tzOffset ? \"Z\" : \"\");\n  return moment.tz(dateTime, fullFormat, true, timezone).valueOf() / 1000;\n}\n\n// Helper function to get the partial format string based on the input. Momentjs has a feature\n// which allows defaulting to the current year, month and/or day if not accounted for in the\n// parser. We remove any parts of the parser not given in the input to take advantage of this\n// feature.\nfunction _getPartialFormat(input: string, format: string): string {\n  // Define a regular expression to match contiguous non-separators.\n  const re = /Y+|M+o?|D+o?|[a-zA-Z0-9]+/ig;\n  // Count the number of meaningful parts in the input.\n  const numInputParts = input.match(re)?.length || 0;\n\n  // Count the number of parts in the format string.\n  let numFormatParts = format.match(re)?.length || 0;\n\n  if (numFormatParts > numInputParts) {\n    // Remove year from format first, to default to current year.\n    if (/Y+/.test(format)) {\n      format = format.replace(/Y+/, \" \").trim();\n      numFormatParts -= 1;\n    }\n    if (numFormatParts > numInputParts) {\n      // Remove month from format next.\n      format = format.replace(/M+/, \" \").trim();\n    }\n  }\n  return format;\n}\n\n// Moment non-strict mode is considered bad, as it's far too lax. But moment's strict mode is too\n// strict. We want to allow YY|YYYY for either year specifier, as well as M for MMM or MMMM month\n// specifiers. It's silly that we need to create multiple format variations to support this.\nfunction _buildVariations(dateFormat: string, date: string) {\n  // Momentjs has an undesirable feature in strict mode where MM and DD\n  // matches require two digit numbers. Change MM, DD to M, D.\n  let format = dateFormat.replace(/MM+/g, m => (m === \"MM\" ? \"M\" : m))\n    .replace(/DD+/g, m => (m === \"DD\" ? \"D\" : m))\n    .replace(SEPARATORS, \" \")\n    .trim();\n\n  // Allow the input date to end with a 4-digit year even if the format doesn't mention the year\n  if (\n    format.includes(\"M\") &&\n    format.includes(\"D\") &&\n    !format.includes(\"Y\")\n  ) {\n    format += \" YYYY\";\n  }\n\n  format = _getPartialFormat(date, format);\n\n  // Consider some alternatives to the preferred format.\n  const variations = new Set<string>([format]);\n  const otherYear = format.replace(/Y{2,4}/, m => (m === \"YY\" ? \"YYYY\" : (m === \"YYYY\" ? \"YY\" : m)));\n  variations.add(otherYear);\n  variations.add(format.replace(/MMM+/, \"M\"));\n  if (otherYear !== format) {\n    variations.add(otherYear.replace(/MMM+/, \"M\"));\n  }\n  return variations;\n}\n\n// Based on private calculateOffset in moment source code.\nfunction calculateOffset(tzMatch: string[]): string {\n  const [, hhOffset, mmOffset] = tzMatch;\n  const sign = hhOffset.slice(0, 1);\n  return sign + hhOffset.slice(1).padStart(2, \"0\") + \":\" + (mmOffset || \"0\").padStart(2, \"0\");\n}\n\nfunction parseTimeZone(str: string, timezone: string): { remaining: string, tzOffset: string } {\n  str = str.trim();\n\n  let tzMatch = UTC_REGEX.exec(str);\n  let matchStart = 0;\n  let tzOffset = \"\";\n  if (tzMatch) {\n    tzOffset = \"+00:00\";\n    matchStart = tzMatch.index + 1;  // skip [^a-zA-Z] at regex start\n  } else {\n    tzMatch = NUMERIC_TZ_REGEX.exec(str);\n    if (tzMatch) {\n      tzOffset = calculateOffset(tzMatch);\n      matchStart = tzMatch.index;\n    } else if (timezone) {\n      // Abbreviations are simply stripped and ignored, so tzOffset is not set in this case\n      tzMatch = tzAbbreviations(timezone).exec(str);\n      if (tzMatch) {\n        matchStart = tzMatch.index + 1;  // skip [^a-zA-Z] at regex start\n      }\n    }\n  }\n\n  if (tzMatch) {\n    str = str.slice(0, matchStart).trim();\n  }\n\n  return { remaining: str, tzOffset };\n}\n\n// Parses time of the form, roughly, HH[:MM[:SS]][am|pm]. Returns the time in the\n// standardized HH:mm:ss format.\n// This turns out easier than coaxing moment to parse time sensibly and flexibly.\nfunction standardizeTime(timeString: string): { remaining: string, time: string } | undefined {\n  const match = TIME_REGEX.exec(timeString);\n  if (!match) {\n    return;\n  }\n  let hours = parseInt(match[1] || match[4], 10);\n  const mm = (match[2] || match[5] || \"0\").padStart(2, \"0\");\n  const ss = (match[3] || \"0\").padStart(2, \"0\");\n  const ampm = (match[6] || \"\").toLowerCase();\n  if (hours < 12 && hours > 0 && ampm.startsWith(\"p\")) {\n    hours += 12;\n  } else if (hours === 12 && ampm.startsWith(\"a\")) {\n    hours = 0;\n  }\n  const hh = String(hours).padStart(2, \"0\");\n  return { remaining: timeString.slice(0, match.index).trim(), time: `${hh}:${mm}:${ss}` };\n}\n\n/**\n * Guesses a full date[time] format that best matches the given strings.\n * If several formats match equally well, picks the last one lexicographically to match the old date guessing.\n * This means formats with an early Y and/or M are favoured.\n * If no formats match, returns the default YYYY-MM-DD.\n */\nexport function guessDateFormat(values: (string | null)[], timezone: string = \"UTC\"): string {\n  const formats = guessDateFormats(values, timezone);\n  if (!formats) {\n    return \"YYYY-MM-DD\";\n  }\n  return last(formats)!;\n}\n\n/**\n * Returns all full date[time] formats that best match the given strings.\n * If several formats match equally well, returns them all.\n * May return null if there are no matching formats or choosing one is too expensive.\n */\nexport function guessDateFormats(values: (string | null)[], timezone: string = \"UTC\"): string[] | null {\n  const dateStrings: string[] = values.filter(isNonNullish);\n  const sample = getDistinctValues(dateStrings, 100);\n  const formats: Record<string, number> = {};\n  for (const dateString of sample) {\n    let guessed: string | string[];\n    try {\n      guessed = guessFormat(dateString);\n    } catch {\n      continue;\n    }\n    if (typeof guessed === \"string\") {\n      guessed = [guessed];\n    }\n    for (const guess of guessed) {\n      formats[guess] = 0;\n    }\n  }\n  const formatKeys = Object.keys(formats);\n  if (!formatKeys.length || formatKeys.length > 10) {\n    return null;\n  }\n\n  for (const format of formatKeys) {\n    for (const dateString of dateStrings) {\n      const m = moment.tz(dateString, format, true, timezone);\n      if (m.isValid()) {\n        formats[format] += 1;\n      }\n    }\n  }\n\n  const maxCount = Math.max(...Object.values(formats));\n  // Return all formats that tied for first place.\n  // Sort lexicographically for consistency in tests and with the old dateguess.py.\n  return formatKeys.filter(format => formats[format] === maxCount).sort();\n}\n\nexport const dateFormatOptions = [\n  \"YYYY-MM-DD\",\n  \"MM-DD-YYYY\",\n  \"MM/DD/YYYY\",\n  \"MM-DD-YY\",\n  \"MM/DD/YY\",\n  \"DD MMM YYYY\",\n  \"MMMM Do, YYYY\",\n  \"DD-MM-YYYY\",\n];\n\nexport const timeFormatOptions = [\n  \"h:mma\",\n  \"h:mma z\",\n  \"HH:mm\",\n  \"HH:mm z\",\n  \"HH:mm:ss\",\n  \"HH:mm:ss z\",\n];\n\n/**\n * Construct widget options for a Date or DateTime column based on a single moment string\n * which may or may not contain both date and time parts.\n * If defaultTimeFormat is true, fallback to a non-empty default time format when none is found in fullFormat.\n */\nexport function dateTimeWidgetOptions(fullFormat: string, defaultTimeFormat: boolean) {\n  const index = fullFormat.match(/[hHkaAmsSzZT]|$/)!.index!;\n  const dateFormat = fullFormat.substr(0, index).trim();\n  const timeFormat = fullFormat.substr(index).trim() || (defaultTimeFormat ? timeFormatOptions[0] : \"\");\n  return {\n    dateFormat,\n    timeFormat,\n    isCustomDateFormat: !dateFormatOptions.includes(dateFormat),\n    isCustomTimeFormat: !timeFormatOptions.includes(timeFormat),\n  };\n}\n\n/**\n * Attempts to parse a timestamp string. Returns the timestamp in seconds\n * since epoch, or returns null on failure. Accepts only strings with 9 to 11 digits.\n * Lowest 11 digit timestamp is 2286-11-20, so we don't consider them valid.\n */\nexport function parseTimeStamp(date: string): number | null {\n  // If this looks like a timestamp (number with 9 or more digits), just return it.\n  // This covers most of the cases leaving some time around the unix epoch not covered.\n  // So time before 100 000 000 (1974-04-26) is not covered. Also negative values\n  // are also not supported, as they overlap with the YYYYYY date format.\n  if (date && /^[1-9]\\d{8,9}$/.test(date)) {\n    const parsedDate = moment(date, \"X\");\n    if (parsedDate.isValid()) {\n      return parsedDate.unix();\n    }\n  }\n  return null;\n}\n"
  },
  {
    "path": "app/common/plugin.ts",
    "content": "/**\n * Plugin's utilities common to server and client.\n */\nimport { BarePlugin, Implementation } from \"app/plugin/PluginManifest\";\n\nexport type LocalPluginKind = \"installed\" | \"builtIn\";\n\nexport interface ImplDescription {\n  localPluginId: string;\n  implementation: Implementation;\n}\n\nexport interface FileParser {\n  fileExtensions: string[];\n  parseOptions?: ImplDescription;\n  fileParser: ImplDescription;\n}\n\n// Deprecated, use FileParser or ImportSource instead.\nexport interface FileImporter {\n  id: string;\n  fileExtensions?: string[];\n  script?: string;\n  scriptFullPath?: string;\n  filePicker?: string;\n  filePickerFullPath?: string;\n}\n\n/**\n * Manifest parsing error.\n */\nexport interface ManifestParsingError {\n  yamlError?: any;\n  jsonError?: any;\n  cannotReadError?: any;\n  missingEntryErrors?: string;\n}\n\n/**\n * Whether the importer provides a file picker.\n */\nexport function isPicker(importer: FileImporter): boolean {\n  return importer.filePicker !== undefined;\n}\n\n/**\n * A Plugin that was found in the system, either installed or builtin.\n */\nexport interface LocalPlugin {\n  /**\n   * the plugin's manifest\n   */\n  manifest: BarePlugin;\n  /**\n   * The path to the plugin's folder.\n   */\n  path: string;\n  /**\n   * A name to uniquely identify a LocalPlugin.\n   */\n  readonly id: string;\n}\n\nexport interface DirectoryScanEntry {\n  manifest?: BarePlugin;\n  /**\n   * User-friendly error messages.\n   */\n  errors?: any[];\n  path: string;\n  id: string;\n}\n\n/**\n * The contributions type.\n */\nexport type Contribution = \"importSource\" | \"fileParser\";\n"
  },
  {
    "path": "app/common/resetOrg.ts",
    "content": "import { isOwner } from \"app/common/roles\";\nimport { ManagerDelta, PermissionDelta, UserAPI } from \"app/common/UserAPI\";\n\n/**\n * A utility to reset an organization into the state it would have when first\n * created - no docs, one workspace called \"Home\", a single user.  Should be\n * called by a user who is both an owner of the org and a billing manager.\n */\nexport async function resetOrg(api: UserAPI, org: string | number) {\n  const session = await api.getSessionActive();\n  if (!isOwner(session.org)) {\n    throw new Error(\"user must be an owner of the org to be reset\");\n  }\n  const billing = api.getBillingAPI();\n  // If billing api is not available, don't bother setting billing manager.\n  const account = await billing.getBillingAccount().catch(e => null);\n  if (account && !account.managers.some(manager => (manager.id === session.user.id))) {\n    throw new Error(\"user must be a billing manager\");\n  }\n  const wss = await api.getOrgWorkspaces(org);\n  for (const ws of wss) {\n    if (!ws.isSupportWorkspace) {\n      await api.deleteWorkspace(ws.id);\n    }\n  }\n  await api.newWorkspace({ name: \"Home\" }, org);\n  const permissions: PermissionDelta = { users: {} };\n  for (const user of (await api.getOrgAccess(org)).users) {\n    if (user.id !== session.user.id) {\n      permissions.users![user.email] = null;\n    }\n  }\n  await api.updateOrgPermissions(org, permissions);\n  // For non-individual accounts, update billing managers (individual accounts will\n  // throw an error if we try to do this).\n  if (account && !account.individual) {\n    const managers: ManagerDelta = { users: {} };\n    for (const user of account.managers) {\n      if (user.id !== session.user.id) {\n        managers.users[user.email] = null;\n      }\n    }\n    await billing.updateBillingManagers(managers);\n  }\n  return api;\n}\n"
  },
  {
    "path": "app/common/roles.ts",
    "content": "import { Organization } from \"app/common/UserAPI\";\n\nexport const OWNER  = \"owners\";\nexport const EDITOR = \"editors\";\nexport const VIEWER = \"viewers\";\nexport const GUEST  = \"guests\";\nexport const MEMBER = \"members\";\n\n// Roles ordered from most to least permissive.\nconst roleOrder: (Role | null)[] = [OWNER, EDITOR, VIEWER, MEMBER, GUEST, null];\n\nexport type BasicRole = \"owners\" | \"editors\" | \"viewers\";\nexport type NonMemberRole = BasicRole | \"guests\";\nexport type NonGuestRole = BasicRole | \"members\";\nexport type Role = NonMemberRole | \"members\";\n\n// Returns the BasicRole (or null) with the same effective access as the given role.\nexport function getEffectiveRole(role: Role | null): BasicRole | null {\n  if (role === GUEST || role === MEMBER) {\n    return VIEWER;\n  } else {\n    return role;\n  }\n}\n\nexport function canEditAccess(role: string | null): boolean {\n  return role === OWNER;\n}\n\n// Note that while canEdit has the same return value as canDelete, the functions are\n// kept separate as they may diverge in the future.\nexport function canEdit(role: string | null): boolean {\n  return role === OWNER || role === EDITOR;\n}\n\nexport function canDelete(role: string | null): boolean {\n  return role === OWNER || role === EDITOR;\n}\n\nexport function canView(role: string | null): boolean {\n  return role !== null;\n}\n\nexport function isOwner(resource: { access: Role | null } | null): resource is { access: Role } {\n  return resource?.access === OWNER;\n}\n\nexport function isOwnerOrEditor(resource: { access: Role | null } | null): resource is { access: Role } {\n  return canEdit(resource?.access ?? null);\n}\n\nexport function canUpgradeOrg(org: Organization | null): org is Organization {\n  // TODO: Need to consider billing managers and support user.\n  return isOwner(org);\n}\n\n// Returns true if the role string is a valid role or null.\nexport function isValidRole(role: string | null): role is Role | null {\n  return (roleOrder as (string | null)[]).includes(role);\n}\n\n// Returns true if the role string is a valid non-Guest, non-Member, non-null role.\nexport function isBasicRole(role: string | null): role is BasicRole {\n  return Boolean(role && role !== GUEST && role !== MEMBER && isValidRole(role));\n}\n\n// Returns true if the role string is a valid non-Guest, non-null role.\nexport function isNonGuestRole(role: string | null): role is NonGuestRole {\n  return Boolean(role && role !== GUEST && isValidRole(role));\n}\n\n/**\n * Returns out of any number of group role names the one that offers more permissions. The function\n * is overloaded so that the output type matches the specificity of the input values.\n */\nexport function getStrongestRole<T extends Role | null>(...args: T[]): T {\n  return getFirstMatchingRole(roleOrder, args);\n}\n\n/**\n * Returns out of any number of group role names the one that offers fewer permissions. The function\n * is overloaded so that the output type matches the specificity of the input values.\n */\nexport function getWeakestRole<T extends Role | null>(...args: T[]): T {\n  return getFirstMatchingRole(roleOrder.slice().reverse(), args);\n}\n\n// Returns which of the `anyOf` args comes first in `array`. Helper for getStrongestRole\n// and getWeakestRole.\nfunction getFirstMatchingRole<T extends Role | null>(array: (Role | null)[], anyOf: T[]): T {\n  if (anyOf.length === 0) {\n    throw new Error(`getFirstMatchingRole: No roles given`);\n  }\n  for (const role of anyOf) {\n    if (!isValidRole(role)) {\n      throw new Error(`getFirstMatchingRole: Invalid role ${role}`);\n    }\n  }\n  return array.find(item => anyOf.includes(item as T)) as T;\n}\n"
  },
  {
    "path": "app/common/schema.ts",
    "content": "/* eslint-disable */\n\n/*** THIS FILE IS AUTO-GENERATED BY core/sandbox/gen_js_schema.py ***/\n\nimport { GristObjCode } from \"app/plugin/GristData\";\n\n// tslint:disable:object-literal-key-quotes\n\nexport const SCHEMA_VERSION = 46;\n\nexport const schema = {\n\n  \"_grist_DocInfo\": {\n    docId               : \"Text\",\n    peers               : \"Text\",\n    basketId            : \"Text\",\n    schemaVersion       : \"Int\",\n    timezone            : \"Text\",\n    documentSettings    : \"Text\",\n  },\n\n  \"_grist_Tables\": {\n    tableId             : \"Text\",\n    primaryViewId       : \"Ref:_grist_Views\",\n    summarySourceTable  : \"Ref:_grist_Tables\",\n    onDemand            : \"Bool\",\n    rawViewSectionRef   : \"Ref:_grist_Views_section\",\n    recordCardViewSectionRef: \"Ref:_grist_Views_section\",\n  },\n\n  \"_grist_Tables_column\": {\n    parentId            : \"Ref:_grist_Tables\",\n    parentPos           : \"PositionNumber\",\n    colId               : \"Text\",\n    type                : \"Text\",\n    widgetOptions       : \"Text\",\n    isFormula           : \"Bool\",\n    formula             : \"Text\",\n    label               : \"Text\",\n    description         : \"Text\",\n    untieColIdFromLabel : \"Bool\",\n    summarySourceCol    : \"Ref:_grist_Tables_column\",\n    displayCol          : \"Ref:_grist_Tables_column\",\n    visibleCol          : \"Ref:_grist_Tables_column\",\n    rules               : \"RefList:_grist_Tables_column\",\n    reverseCol          : \"Ref:_grist_Tables_column\",\n    recalcWhen          : \"Int\",\n    recalcDeps          : \"RefList:_grist_Tables_column\",\n  },\n\n  \"_grist_Imports\": {\n    tableRef            : \"Ref:_grist_Tables\",\n    origFileName        : \"Text\",\n    parseFormula        : \"Text\",\n    delimiter           : \"Text\",\n    doublequote         : \"Bool\",\n    escapechar          : \"Text\",\n    quotechar           : \"Text\",\n    skipinitialspace    : \"Bool\",\n    encoding            : \"Text\",\n    hasHeaders          : \"Bool\",\n  },\n\n  \"_grist_External_database\": {\n    host                : \"Text\",\n    port                : \"Int\",\n    username            : \"Text\",\n    dialect             : \"Text\",\n    database            : \"Text\",\n    storage             : \"Text\",\n  },\n\n  \"_grist_External_table\": {\n    tableRef            : \"Ref:_grist_Tables\",\n    databaseRef         : \"Ref:_grist_External_database\",\n    tableName           : \"Text\",\n  },\n\n  \"_grist_TableViews\": {\n    tableRef            : \"Ref:_grist_Tables\",\n    viewRef             : \"Ref:_grist_Views\",\n  },\n\n  \"_grist_TabItems\": {\n    tableRef            : \"Ref:_grist_Tables\",\n    viewRef             : \"Ref:_grist_Views\",\n  },\n\n  \"_grist_TabBar\": {\n    viewRef             : \"Ref:_grist_Views\",\n    tabPos              : \"PositionNumber\",\n  },\n\n  \"_grist_Pages\": {\n    viewRef             : \"Ref:_grist_Views\",\n    indentation         : \"Int\",\n    pagePos             : \"PositionNumber\",\n    shareRef            : \"Ref:_grist_Shares\",\n    options             : \"Text\",\n  },\n\n  \"_grist_Views\": {\n    name                : \"Text\",\n    type                : \"Text\",\n    layoutSpec          : \"Text\",\n  },\n\n  \"_grist_Views_section\": {\n    tableRef            : \"Ref:_grist_Tables\",\n    parentId            : \"Ref:_grist_Views\",\n    parentKey           : \"Text\",\n    title               : \"Text\",\n    description         : \"Text\",\n    defaultWidth        : \"Int\",\n    borderWidth         : \"Int\",\n    theme               : \"Text\",\n    options             : \"Text\",\n    chartType           : \"Text\",\n    layoutSpec          : \"Text\",\n    filterSpec          : \"Text\",\n    sortColRefs         : \"Text\",\n    linkSrcSectionRef   : \"Ref:_grist_Views_section\",\n    linkSrcColRef       : \"Ref:_grist_Tables_column\",\n    linkTargetColRef    : \"Ref:_grist_Tables_column\",\n    embedId             : \"Text\",\n    rules               : \"RefList:_grist_Tables_column\",\n    shareOptions        : \"Text\",\n  },\n\n  \"_grist_Views_section_field\": {\n    parentId            : \"Ref:_grist_Views_section\",\n    parentPos           : \"PositionNumber\",\n    colRef              : \"Ref:_grist_Tables_column\",\n    width               : \"Int\",\n    widgetOptions       : \"Text\",\n    displayCol          : \"Ref:_grist_Tables_column\",\n    visibleCol          : \"Ref:_grist_Tables_column\",\n    filter              : \"Text\",\n    rules               : \"RefList:_grist_Tables_column\",\n  },\n\n  \"_grist_Validations\": {\n    formula             : \"Text\",\n    name                : \"Text\",\n    tableRef            : \"Int\",\n  },\n\n  \"_grist_REPL_Hist\": {\n    code                : \"Text\",\n    outputText          : \"Text\",\n    errorText           : \"Text\",\n  },\n\n  \"_grist_Attachments\": {\n    fileIdent           : \"Text\",\n    fileName            : \"Text\",\n    fileType            : \"Text\",\n    fileSize            : \"Int\",\n    fileExt             : \"Text\",\n    imageHeight         : \"Int\",\n    imageWidth          : \"Int\",\n    timeDeleted         : \"DateTime\",\n    timeUploaded        : \"DateTime\",\n  },\n\n  \"_grist_Triggers\": {\n    tableRef            : \"Ref:_grist_Tables\",\n    eventTypes          : \"ChoiceList\",\n    isReadyColRef       : \"Ref:_grist_Tables_column\",\n    actions             : \"Text\",\n    label               : \"Text\",\n    memo                : \"Text\",\n    enabled             : \"Bool\",\n    watchedColRefList   : \"RefList:_grist_Tables_column\",\n    options             : \"Text\",\n    condition           : \"Text\",\n  },\n\n  \"_grist_ACLRules\": {\n    resource            : \"Ref:_grist_ACLResources\",\n    permissions         : \"Int\",\n    principals          : \"Text\",\n    aclFormula          : \"Text\",\n    aclColumn           : \"Ref:_grist_Tables_column\",\n    aclFormulaParsed    : \"Text\",\n    permissionsText     : \"Text\",\n    rulePos             : \"PositionNumber\",\n    userAttributes      : \"Text\",\n    memo                : \"Text\",\n  },\n\n  \"_grist_ACLResources\": {\n    tableId             : \"Text\",\n    colIds              : \"Text\",\n  },\n\n  \"_grist_ACLPrincipals\": {\n    type                : \"Text\",\n    userEmail           : \"Text\",\n    userName            : \"Text\",\n    groupName           : \"Text\",\n    instanceId          : \"Text\",\n  },\n\n  \"_grist_ACLMemberships\": {\n    parent              : \"Ref:_grist_ACLPrincipals\",\n    child               : \"Ref:_grist_ACLPrincipals\",\n  },\n\n  \"_grist_Filters\": {\n    viewSectionRef      : \"Ref:_grist_Views_section\",\n    colRef              : \"Ref:_grist_Tables_column\",\n    filter              : \"Text\",\n    pinned              : \"Bool\",\n  },\n\n  \"_grist_Cells\": {\n    tableRef            : \"Ref:_grist_Tables\",\n    colRef              : \"Ref:_grist_Tables_column\",\n    rowId               : \"Int\",\n    root                : \"Bool\",\n    parentId            : \"Ref:_grist_Cells\",\n    type                : \"Int\",\n    content             : \"Text\",\n    userRef             : \"Text\",\n    timeCreated         : \"DateTime\",\n    timeUpdated         : \"DateTime\",\n    resolved            : \"Bool\",\n  },\n\n  \"_grist_Shares\": {\n    linkId              : \"Text\",\n    options             : \"Text\",\n    label               : \"Text\",\n    description         : \"Text\",\n  },\n\n};\n\nexport interface SchemaTypes {\n\n  \"_grist_DocInfo\": {\n    docId: string;\n    peers: string;\n    basketId: string;\n    schemaVersion: number;\n    timezone: string;\n    documentSettings: string;\n  };\n\n  \"_grist_Tables\": {\n    tableId: string;\n    primaryViewId: number;\n    summarySourceTable: number;\n    onDemand: boolean;\n    rawViewSectionRef: number;\n    recordCardViewSectionRef: number;\n  };\n\n  \"_grist_Tables_column\": {\n    parentId: number;\n    parentPos: number;\n    colId: string;\n    type: string;\n    widgetOptions: string;\n    isFormula: boolean;\n    formula: string;\n    label: string;\n    description: string;\n    untieColIdFromLabel: boolean;\n    summarySourceCol: number;\n    displayCol: number;\n    visibleCol: number;\n    rules: [GristObjCode.List, ...number[]]|null;\n    reverseCol: number;\n    recalcWhen: number;\n    recalcDeps: [GristObjCode.List, ...number[]]|null;\n  };\n\n  \"_grist_Imports\": {\n    tableRef: number;\n    origFileName: string;\n    parseFormula: string;\n    delimiter: string;\n    doublequote: boolean;\n    escapechar: string;\n    quotechar: string;\n    skipinitialspace: boolean;\n    encoding: string;\n    hasHeaders: boolean;\n  };\n\n  \"_grist_External_database\": {\n    host: string;\n    port: number;\n    username: string;\n    dialect: string;\n    database: string;\n    storage: string;\n  };\n\n  \"_grist_External_table\": {\n    tableRef: number;\n    databaseRef: number;\n    tableName: string;\n  };\n\n  \"_grist_TableViews\": {\n    tableRef: number;\n    viewRef: number;\n  };\n\n  \"_grist_TabItems\": {\n    tableRef: number;\n    viewRef: number;\n  };\n\n  \"_grist_TabBar\": {\n    viewRef: number;\n    tabPos: number;\n  };\n\n  \"_grist_Pages\": {\n    viewRef: number;\n    indentation: number;\n    pagePos: number;\n    shareRef: number;\n    options: string;\n  };\n\n  \"_grist_Views\": {\n    name: string;\n    type: string;\n    layoutSpec: string;\n  };\n\n  \"_grist_Views_section\": {\n    tableRef: number;\n    parentId: number;\n    parentKey: string;\n    title: string;\n    description: string;\n    defaultWidth: number;\n    borderWidth: number;\n    theme: string;\n    options: string;\n    chartType: string;\n    layoutSpec: string;\n    filterSpec: string;\n    sortColRefs: string;\n    linkSrcSectionRef: number;\n    linkSrcColRef: number;\n    linkTargetColRef: number;\n    embedId: string;\n    rules: [GristObjCode.List, ...number[]]|null;\n    shareOptions: string;\n  };\n\n  \"_grist_Views_section_field\": {\n    parentId: number;\n    parentPos: number;\n    colRef: number;\n    width: number;\n    widgetOptions: string;\n    displayCol: number;\n    visibleCol: number;\n    filter: string;\n    rules: [GristObjCode.List, ...number[]]|null;\n  };\n\n  \"_grist_Validations\": {\n    formula: string;\n    name: string;\n    tableRef: number;\n  };\n\n  \"_grist_REPL_Hist\": {\n    code: string;\n    outputText: string;\n    errorText: string;\n  };\n\n  \"_grist_Attachments\": {\n    fileIdent: string;\n    fileName: string;\n    fileType: string;\n    fileSize: number;\n    fileExt: string;\n    imageHeight: number;\n    imageWidth: number;\n    timeDeleted: number;\n    timeUploaded: number;\n  };\n\n  \"_grist_Triggers\": {\n    tableRef: number;\n    eventTypes: [GristObjCode.List, ...string[]]|null;\n    isReadyColRef: number;\n    actions: string;\n    label: string;\n    memo: string;\n    enabled: boolean;\n    watchedColRefList: [GristObjCode.List, ...number[]]|null;\n    options: string;\n    condition: string;\n  };\n\n  \"_grist_ACLRules\": {\n    resource: number;\n    permissions: number;\n    principals: string;\n    aclFormula: string;\n    aclColumn: number;\n    aclFormulaParsed: string;\n    permissionsText: string;\n    rulePos: number;\n    userAttributes: string;\n    memo: string;\n  };\n\n  \"_grist_ACLResources\": {\n    tableId: string;\n    colIds: string;\n  };\n\n  \"_grist_ACLPrincipals\": {\n    type: string;\n    userEmail: string;\n    userName: string;\n    groupName: string;\n    instanceId: string;\n  };\n\n  \"_grist_ACLMemberships\": {\n    parent: number;\n    child: number;\n  };\n\n  \"_grist_Filters\": {\n    viewSectionRef: number;\n    colRef: number;\n    filter: string;\n    pinned: boolean;\n  };\n\n  \"_grist_Cells\": {\n    tableRef: number;\n    colRef: number;\n    rowId: number;\n    root: boolean;\n    parentId: number;\n    type: number;\n    content: string;\n    userRef: string;\n    timeCreated: number;\n    timeUpdated: number;\n    resolved: boolean;\n  };\n\n  \"_grist_Shares\": {\n    linkId: string;\n    options: string;\n    label: string;\n    description: string;\n  };\n\n}\n"
  },
  {
    "path": "app/common/tagManager.ts",
    "content": "/**\n * Returns the Google Tag Manager snippet to insert into <head> of the page, if\n * `tagId` is set to a non-empty value. Otherwise returns an empty string.\n */\nexport function getTagManagerSnippet(tagId?: string) {\n  // Note also that we only insert the snippet for the <head>. The second recommended part (for\n  // <body>) is for <noscript> scenario, which doesn't apply to the Grist app (such visits, if\n  // any, wouldn't work and shouldn't be counted for any metrics we care about).\n  if (!tagId) { return \"\"; }\n\n  return `\n<!-- Google Tag Manager -->\n<script>${getTagManagerScript(tagId)}</script>\n<!-- End Google Tag Manager -->\n`;\n}\n\n/**\n * Returns the body of the Google Tag Manager script. This is suitable for use by the client,\n * since it must dynamically load it by calling `document.createElement('script')` and setting\n * its `innerHTML`.\n */\nexport function getTagManagerScript(tagId: string) {\n  return `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\nnew Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\nj=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);\n})(window,document,'script','dataLayer','${tagId}');`;\n}\n"
  },
  {
    "path": "app/common/tbind.ts",
    "content": "/**\n * A version of Function.bind() that preserves types.\n */\n\n// Bind just the context for a function of up to 4 args.\nexport function tbind<T, R, Args extends any[]>(func: (this: T, ...a: Args) => R, context: T): (...a: Args) => R;\n\n// Bind context and first arg for a function of up to 5 args.\nexport function tbind<T, R, X, Args extends any[]>(\n  func: (this: T, x: X, ...a: Args) => R, context: T, x: X,\n): (...a: Args) => R;\n\nexport function tbind(func: any, context: any, ...boundArgs: any[]): any {\n  return func.bind(context, ...boundArgs);\n}\n"
  },
  {
    "path": "app/common/themes/Base.ts",
    "content": "import { BaseThemeTokens, components, tokens } from \"app/common/ThemePrefs\";\n\n/**\n * Base theme tokens that can be used as a starting point for any theme.\n */\nexport const Base: BaseThemeTokens = {\n  /* Direct colors tokens.\n   *\n   * While these can be fine-tuned in each theme, they are not meant to be _drastically_ changed.\n   * Components that are the same color no matter the theme can directly target these tokens.\n   *\n   * More \"semantic\" colors tokens, like \"primary\" or \"secondary\" colors,\n   * are not listed in the Base theme and are defined in each specific theme.\n   */\n  white: \"#ffffff\",\n  black: \"#000000\",\n\n  error: \"#d0021b\",\n  errorLight: \"#ff6666\",\n\n  warning: \"#dd962c\",\n  warningLight: \"#f9ae41\",\n\n  info: \"#3b82f6\",\n  infoLight: \"#87b2f9\",\n\n  logoBg: \"#040404\",\n  logoSize: \"22px 22px\",\n\n  /**\n   * The fonts used attempt to default to system fonts as described here:\n   *  https://css-tricks.com/snippets/css/system-font-stack/\n   */\n  fontFamily: `-apple-system,BlinkMacSystemFont,Segoe UI,Liberation Sans,\n    Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol`,\n  /**\n   * This is more monospace and looks better for data that should often align (e.g. to have 00000\n   * take similar space to 11111). This is the main font for user data.\n   */\n  fontFamilyData:\n    `Liberation Sans,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol`,\n\n  xxsmallFontSize: \"8px\",\n  xsmallFontSize: \"10px\",\n  smallFontSize: \"11px\",\n  mediumFontSize: \"13px\",\n  introFontSize: \"14px\",\n  largeFontSize: \"16px\",\n  xlargeFontSize: \"18px\",\n  xxlargeFontSize: \"20px\",\n  xxxlargeFontSize: \"22px\",\n\n  bigControlFontSize: \"13px\",\n  headerControlFontSize: \"22px\",\n\n  bigControlTextWeight: \"500\",\n  headerControlTextWeight: \"600\",\n\n  /**\n   * Component-specific tokens.\n   *\n   * Those listed in this base theme should not need any override\n   * in either light or dark theme so that everything renders correctly:\n   * they already target theme-aware tokens.\n   *\n   * Lots of other variables must be defined in each specific theme\n   * though, as work to make them target theme-aware tokens has not been done yet.\n   */\n  components: {\n    /* Text */\n    text: tokens.body,\n    lightText: tokens.secondary,\n    darkText: tokens.emphasis,\n    disabledText: tokens.secondary,\n    dangerText: \"#ffa500\",\n\n    /* Page */\n    pageBg: tokens.bgSecondary,\n\n    /* Page Panels */\n    mainPanelBg: tokens.bg,\n    leftPanelBg: tokens.bgSecondary,\n    rightPanelBg: tokens.bgSecondary,\n    topHeaderBg: tokens.bg,\n    bottomFooterBg: tokens.bg,\n    pagePanelsBorder: tokens.bgTertiary,\n    pagePanelsBorderResizing: tokens.primary,\n    sidePanelOpenerFg: tokens.secondary,\n    sidePanelOpenerActiveFg: tokens.white,\n    sidePanelOpenerActiveBg: tokens.primary,\n\n    /* Add New */\n    addNewCircleFg: tokens.white,\n    addNewCircleBg: tokens.primaryMuted,\n    addNewCircleHoverBg: tokens.primaryDim,\n    addNewCircleSmallFg: tokens.white,\n    addNewCircleSmallBg: tokens.primary,\n    addNewCircleSmallHoverBg: tokens.primaryMuted,\n\n    /* Top Bar */\n    topBarButtonPrimaryFg: tokens.primary,\n    topBarButtonSecondaryFg: tokens.secondary,\n    topBarButtonDisabledFg: tokens.decoration,\n\n    /* Notifications */\n    notificationsPanelHeaderBg: tokens.bgSecondary,\n    notificationsPanelBodyBg: tokens.bg,\n    notificationsPanelBorder: tokens.decoration,\n\n    /* Toasts */\n    toastBg: \"#040404\",\n    toastLightText: tokens.secondary,\n    toastText: tokens.white,\n    toastMemoText: tokens.veryLight,\n    toastErrorIcon: tokens.error,\n    toastErrorBg: tokens.error,\n    toastSuccessIcon: tokens.primaryMuted,\n    toastSuccessBg: tokens.primaryMuted,\n    toastWarningIcon: tokens.warningLight,\n    toastWarningBg: tokens.warning,\n    toastInfoIcon: tokens.info,\n    toastInfoBg: tokens.info,\n    toastInfoControlFg: tokens.infoLight,\n    toastControlFg: tokens.primary,\n\n    /* Tooltips */\n    tooltipBg: \"rgba(0, 0, 0, 0.75)\",\n    tooltipCloseButtonHoverFg: tokens.black,\n    tooltipFg: tokens.white,\n    tooltipIcon: tokens.secondary,\n    tooltipCloseButtonFg: tokens.white,\n    tooltipCloseButtonHoverBg: tokens.white,\n\n    /* Modals */\n    modalBackdrop: tokens.backdrop,\n    modalBg: tokens.bg,\n    modalBorder: tokens.decorationSecondary,\n    modalBorderDark: tokens.decoration,\n    modalBorderHover: tokens.secondary,\n    modalCloseButtonFg: tokens.secondary,\n    modalBackdropCloseButtonFg: tokens.primary,\n    modalBackdropCloseButtonHoverFg: tokens.primaryEmphasis,\n\n    /* Popups */\n    popupBg: tokens.bg,\n    popupSecondaryBg: tokens.bgSecondary,\n    popupCloseButtonFg: tokens.secondary,\n\n    /* Progress Bars */\n    progressBarFg: tokens.primary,\n    progressBarBg: tokens.decoration,\n\n    /* Hover */\n    hover: tokens.bgTertiary,\n\n    /* Links */\n    link: tokens.primary,\n    linkHover: tokens.primary,\n\n    /* Cell Editor */\n    cellEditorPlaceholderFg: tokens.secondary,\n    cellEditorBg: tokens.bg,\n\n    /* Cursor */\n    cursor: tokens.cursor,\n    cursorInactive: tokens.cursorInactive,\n    cursorReadonly: tokens.secondary,\n\n    /* Tables */\n    tableHeaderFg: tokens.emphasis,\n    tableHeaderSelectedFg: tokens.emphasis,\n    tableHeaderBg: tokens.bgSecondary,\n    tableBodyBg: tokens.bg,\n    tableBodyBorder: tokens.decoration,\n    tableCellSummaryBg: tokens.bgTertiary,\n\n    /* Cards */\n    cardCompactRecordBg: tokens.bg,\n    cardFormLabel: tokens.secondary,\n    cardCompactLabel: tokens.secondary,\n    cardBlocksLabel: tokens.secondary,\n    cardCompactBorder: tokens.decoration,\n    cardEditingLayoutBorder: tokens.decoration,\n\n    /* Card Lists */\n    cardListFormBorder: tokens.decoration,\n    cardListBlocksBorder: tokens.decoration,\n\n    /* Selection */\n    selectionOpaqueFg: tokens.emphasis,\n\n    /* Widgets */\n    widgetBg: tokens.bg,\n    widgetBorder: tokens.decoration,\n    widgetActiveBorder: tokens.primary,\n    widgetActiveNonFocusedBorder: tokens.primaryTranslucent,\n    widgetInactiveStripesLight: tokens.bgSecondary,\n\n    /* Pinned Docs */\n    pinnedDocFooterBg: tokens.bg,\n    pinnedDocBorder: tokens.bgTertiary,\n    pinnedDocBorderHover: tokens.secondary,\n    pinnedDocEditorBg: tokens.bgTertiary,\n\n    /* Raw Data */\n    rawDataTableBorder: tokens.bgTertiary,\n    rawDataTableBorderHover: tokens.secondary,\n\n    /* Controls */\n    controlFg: tokens.primary,\n    controlPrimaryFg: tokens.white,\n    controlPrimaryBg: tokens.primary,\n    controlPrimaryHoverBg: tokens.primaryMuted,\n    controlSecondaryFg: tokens.secondary,\n    controlSecondaryDisabledFg: tokens.decoration,\n    controlSecondaryHoverFg: tokens.body,\n    controlSecondaryHoverBg: tokens.decorationSecondary,\n\n    /* Checkboxes */\n    checkboxBg: tokens.bg,\n    checkboxSelectedFg: tokens.primary,\n    checkboxDisabledBg: tokens.decoration,\n    checkboxBorder: tokens.decoration,\n\n    /* Move Docs */\n    moveDocsSelectedFg: tokens.white,\n    moveDocsSelectedBg: tokens.primary,\n    moveDocsDisabledFg: tokens.decoration,\n\n    /* Filter Bar */\n    filterBarButtonSavedFg: tokens.white,\n    filterBarButtonSavedHoverBg: tokens.decoration,\n\n    /* Icons */\n    iconDisabled: tokens.secondary,\n\n    /* Icon Buttons */\n    iconButtonFg: tokens.white,\n    iconButtonPrimaryBg: tokens.primary,\n    iconButtonSecondaryBg: tokens.decoration,\n    iconButtonSecondaryHoverBg: tokens.secondary,\n\n    /* Left Panel */\n    activePageFg: tokens.veryLight,\n    activePageBg: tokens.bgEmphasis,\n    pageOptionsFg: tokens.secondary,\n    pageOptionsHoverFg: tokens.white,\n    pageOptionsHoverBg: tokens.decoration,\n    pageOptionsSelectedHoverBg: tokens.secondary,\n    pageInitialsFg: tokens.white,\n\n    /* Right Panel */\n    rightPanelTabFg: tokens.secondary,\n    rightPanelTabBg: tokens.bg,\n    rightPanelTabIcon: tokens.secondary,\n    rightPanelTabIconHover: tokens.body,\n    rightPanelTabBorder: tokens.bgTertiary,\n    rightPanelTabHoverBg: tokens.bg,\n    rightPanelTabHoverFg: tokens.body,\n    rightPanelTabSelectedFg: tokens.body,\n    rightPanelTabSelectedBg: tokens.bgSecondary,\n    rightPanelTabSelectedIcon: tokens.primary,\n    rightPanelSubtabFg: tokens.secondary,\n    rightPanelSubtabHoverFg: tokens.body,\n    rightPanelSubtabSelectedFg: tokens.body,\n    rightPanelSubtabSelectedUnderline: tokens.primary,\n    rightPanelDisabledOverlay: tokens.bgSecondary,\n    rightPanelCustomWidgetButtonFg: tokens.body,\n    rightPanelCustomWidgetButtonBg: tokens.decoration,\n\n    /* Document History */\n    documentHistorySnapshotFg: tokens.body,\n    documentHistorySnapshotSelectedFg: tokens.veryLight,\n    documentHistorySnapshotBg: tokens.bg,\n    documentHistorySnapshotSelectedBg: tokens.bgEmphasis,\n    documentHistoryActivityText: tokens.body,\n    documentHistoryActivityLightText: tokens.secondary,\n    documentHistoryTableHeaderFg: tokens.emphasis,\n    documentHistoryTableBorderLight: tokens.decoration,\n\n    /* Accents */\n    accentIcon: tokens.primary,\n    accentBorder: tokens.primary,\n    accentText: tokens.primary,\n\n    /* Inputs */\n    inputFg: tokens.emphasis,\n    inputBg: tokens.bg,\n    inputDisabledFg: tokens.secondary,\n    inputDisabledBg: tokens.bgSecondary,\n    inputPlaceholderFg: tokens.secondary,\n    inputBorder: tokens.decoration,\n    inputValid: tokens.primary,\n    inputFocus: \"#5e9ed6\",\n    inputReadonlyBg: tokens.bgSecondary,\n\n    /* Choice Tokens */\n    choiceTokenFg: tokens.emphasis,\n    choiceTokenBlankFg: tokens.secondary,\n    choiceTokenSelectedBorder: tokens.primary,\n    choiceTokenInvalidFg: tokens.emphasis,\n    choiceTokenInvalidBorder: tokens.error,\n\n    /* Choice Entry */\n    choiceEntryBg: tokens.bg,\n    choiceEntryBorder: tokens.decoration,\n\n    /* Select Buttons */\n    selectButtonFg: tokens.body,\n    selectButtonPlaceholderFg: tokens.secondary,\n    selectButtonBg: tokens.bg,\n    selectButtonBorder: tokens.decoration,\n\n    /* Menus */\n    menuText: tokens.secondary,\n    menuLightText: tokens.secondary,\n    menuBg: tokens.bg,\n    menuSubheaderFg: tokens.body,\n\n    /* Menu Items */\n    menuItemFg: tokens.emphasis,\n    menuItemSelectedFg: tokens.white,\n    menuItemSelectedBg: tokens.primary,\n    menuItemDisabledFg: tokens.decoration,\n    menuItemIconFg: tokens.secondary,\n    menuItemIconSelectedFg: tokens.white,\n\n    /* Autocomplete */\n    autocompleteMatchText: tokens.primary,\n    autocompleteAddNewCircleFg: tokens.white,\n    autocompleteAddNewCircleBg: tokens.primary,\n    autocompleteAddNewCircleSelectedBg: tokens.primaryMuted,\n    autocompleteSelectedMatchText: tokens.primaryEmphasis,\n\n    /* Search */\n    searchPrevNextButtonFg: tokens.secondary,\n\n    /* Loaders */\n    loaderFg: tokens.primary,\n    loaderBg: tokens.decoration,\n\n    /* Site Switcher */\n    siteSwitcherActiveFg: tokens.white,\n\n    /* Doc Menu */\n    docMenuDocOptionsFg: tokens.decoration,\n    docMenuDocOptionsHoverFg: tokens.secondary,\n    docMenuDocOptionsHoverBg: tokens.decoration,\n\n    /* Shortcut Keys */\n    shortcutKeyFg: tokens.emphasis,\n    shortcutKeySecondaryFg: tokens.secondary,\n    shortcutKeyBg: tokens.bg,\n    shortcutKeyBorder: tokens.secondary,\n\n    /* Breadcrumbs */\n    breadcrumbsTagFg: tokens.white,\n    breadcrumbsTagAlertBg: tokens.error,\n\n    /* Page Widget Picker */\n    widgetPickerItemSelectedBg: tokens.bgTertiary,\n    widgetPickerItemDisabledBg: tokens.bgTertiary,\n    widgetPickerPrimaryBg: tokens.bg,\n    widgetPickerSecondaryBg: tokens.bgSecondary,\n    widgetPickerIcon: tokens.secondary,\n    widgetPickerPrimaryIcon: tokens.primary,\n    widgetPickerBorder: tokens.bgTertiary,\n\n    /* Importer */\n    importerActiveFileBg: tokens.primary,\n    importerTableInfoBorder: tokens.decoration,\n    importerPreviewBorder: tokens.decoration,\n    importerMatchIcon: tokens.decoration,\n    importerSkippedTableOverlay: tokens.bgTertiary,\n\n    // tabs\n    importerActiveFileFg: tokens.white,\n    importerInactiveFileFg: tokens.white,\n\n    /* Menu Toggles */\n    menuToggleFg: tokens.secondary,\n    menuToggleBg: tokens.bg,\n    menuToggleBorder: tokens.secondary,\n\n    /* Info Button */\n    infoButtonFg: \"#8f8f8f\",\n    infoButtonHoverFg: \"#707070\",\n    infoButtonActiveFg: \"#5c5c5c\",\n\n    /* Button Groups */\n    buttonGroupBg: tokens.bg,\n    buttonGroupFg: tokens.body,\n    buttonGroupLightBg: tokens.bgSecondary,\n    buttonGroupLightFg: tokens.secondary,\n    buttonGroupIcon: tokens.secondary,\n    buttonGroupBorder: tokens.decoration,\n    buttonGroupSelectedFg: tokens.body,\n    buttonGroupLightSelectedFg: tokens.primary,\n    buttonGroupLightSelectedBg: tokens.selectionOpaque,\n    buttonGroupSelectedBg: tokens.selectionOpaque,\n    buttonGroupSelectedBorder: tokens.primary,\n\n    /* Access Rules */\n    accessRulesTableHeaderFg: tokens.body,\n    accessRulesTableHeaderBg: tokens.bgTertiary,\n    accessRulesTableBodyFg: tokens.secondary,\n    accessRulesTableBodyLightFg: tokens.decoration,\n    accessRulesTableBorder: tokens.secondary,\n    accessRulesColumnListBorder: tokens.decoration,\n    accessRulesColumnItemFg: tokens.body,\n    accessRulesColumnItemIconFg: tokens.secondary,\n    accessRulesColumnItemIconHoverFg: tokens.veryLight,\n    accessRulesColumnItemIconHoverBg: tokens.secondary,\n    accessRulesColumnItemBg: tokens.decorationSecondary,\n    accessRulesFormulaEditorBg: tokens.bg,\n    accessRulesFormulaEditorBgDisabled: tokens.decorationSecondary,\n    accessRulesFormulaEditorBorderHover: tokens.decoration,\n    accessRulesFormulaEditorFocus: tokens.primary,\n\n    /* Cells */\n    cellFg: tokens.emphasis,\n    cellBg: tokens.bg,\n\n    /* Charts */\n    chartBg: tokens.bg,\n\n    /* Comments */\n    commentsPopupHeaderBg: tokens.bgSecondary,\n    commentsPopupBodyBg: tokens.bg,\n    commentsPopupBorder: tokens.decoration,\n    commentsPanelTopicBg: tokens.bg,\n\n    /* Date Picker */\n    datePickerTodayFg: tokens.white,\n    datePickerTodayBg: tokens.primary,\n    datePickerTodayBgHover: tokens.primaryMuted,\n\n    /* Tutorials */\n    tutorialsPopupBorder: tokens.decoration,\n    tutorialsPopupHeaderFg: tokens.white,\n\n    /* Ace */\n    aceAutocompleteLink: tokens.primary,\n    aceEditorBg: tokens.bg,\n    aceAutocompleteHighlightedFg: tokens.emphasis,\n\n    /* Color Select */\n    colorSelectBg: tokens.bg,\n    colorSelectFontOptionFg: tokens.body,\n    colorSelectFontOptionFgSelected: tokens.veryLight,\n    colorSelectFontOptionBgSelected: tokens.bgEmphasis,\n    colorSelectColorSquareBorderEmpty: tokens.body,\n    colorSelectInputFg: tokens.secondary,\n    colorSelectInputBg: tokens.bg,\n    colorSelectInputBorder: tokens.decoration,\n\n    /* Highlighted Code */\n    highlightedCodeFg: tokens.secondary,\n    highlightedCodeBorder: tokens.decoration,\n\n    /* Login Page */\n    loginPageBg: tokens.bg,\n    loginPageGoogleButtonBorder: tokens.decoration,\n\n    /* Formula Assistant */\n    formulaAssistantHeaderBg: tokens.bgSecondary,\n    formulaAssistantBorder: tokens.decoration,\n    formulaAssistantPreformattedTextBg: tokens.bgSecondary,\n\n    /* Attachments */\n    attachmentsEditorButtonHoverFg: tokens.primary,\n    attachmentsEditorButtonBorder: tokens.decoration,\n    attachmentsEditorButtonIcon: tokens.secondary,\n\n    /* Announcement Popups */\n    announcementPopupFg: tokens.emphasis,\n\n    /* Switches */\n    switchInactiveSlider: tokens.bgSecondary,\n    switchInactivePill: tokens.secondary,\n    switchActiveSlider: tokens.primaryMuted,\n    switchActivePill: tokens.bg,\n    switchHoverShadow: components.switchActiveSlider,\n\n    /* Custom Widget Gallery */\n    widgetGalleryBorderSelected: tokens.primary,\n    widgetGalleryBgHover: tokens.bgSecondary,\n    widgetGallerySecondaryHeaderFg: tokens.white,\n\n    /* App Header */\n    appHeaderBg: tokens.bg,\n\n    /* Card Button */\n    cardButtonBorderSelected: tokens.primary,\n    cardButtonShadow: \"rgba(0,0,0,0.1)\",\n\n    formulaIcon: \"#D0D0D0\",\n\n    /* Text Button */\n    textButtonHoverBg: \"transparent\",\n    textButtonHoverBorder: \"transparent\",\n\n    /* Keyboard Focus Highlighter */\n    kbFocusHighlight: tokens.primary,\n  },\n};\n"
  },
  {
    "path": "app/common/themes/GristDark.ts",
    "content": "import { ThemeTokens, tokens } from \"app/common/ThemePrefs\";\nimport { Base } from \"app/common/themes/Base\";\n\n/**\n * Dark Grist theme. Uses the BaseTheme and describes the dark-theme colors.\n */\nexport const GristDark: ThemeTokens = {\n  ...Base,\n\n  body: \"#efefef\",\n  emphasis: tokens.white,\n  veryLight: \"#efefef\",\n\n  bg: \"#32323f\",\n  bgSecondary: \"#262633\",\n  bgTertiary: \"rgba(111,111,125,0.6)\",\n  bgEmphasis: \"#646473\",\n\n  decoration: \"#70707d\",\n  decorationSecondary: \"#60606d\",\n  decorationTertiary: \"#555563\",\n\n  primary: \"#17b378\",\n  primaryMuted: \"#1da270\",\n  primaryDim: \"#157a54\",\n  primaryEmphasis: \"#13d78d\",\n  primaryTranslucent: \"rgba(23, 179, 120, 0.5)\",\n\n  secondary: \"#a4a4b1\",\n  secondaryMuted: \"#bebebe\",\n\n  controlBorderRadius: \"4px\",\n\n  cursor: tokens.primaryMuted,\n  cursorInactive: \"rgba(29,162,112,0.5)\",\n\n  selection: \"rgba(22,179,120,0.15)\",\n  selectionOpaque: \"#2f4748\",\n  selectionDarkerOpaque: \"#253e3e\",\n  selectionDarker: \"rgba(22,179,120,0.25)\",\n  selectionDarkest: \"rgba(22,179,120,0.35)\",\n\n  hover: \"#a4a4b1\",\n  backdrop: \"rgba(0,0,0,0.6)\",\n\n  components: {\n    ...Base.components,\n\n    /* Text */\n    mediumText: \"#d5d5d5\",\n    errorText: \"#e63946\",\n    errorTextHover: \"#ff5c5c\",\n\n    /* Page */\n    pageBackdrop: tokens.black,\n\n    /* Page Panels */\n    pagePanelsBorder: tokens.decorationSecondary,\n\n    /* Add New */\n    addNewCircleBg: \"#0a5438\",\n    addNewCircleSmallBg: tokens.primaryDim,\n\n    /* Top Bar */\n    topBarButtonErrorFg: tokens.errorLight,\n\n    /* Toasts */\n    toastMemoBg: tokens.decorationTertiary,\n    toastControlFg: \"#16b378\",\n    toastSuccessIcon: \"#009058\",\n    toastSuccessBg: \"#009058\",\n\n    /* Modals */\n    modalInnerShadow: tokens.black,\n    modalOuterShadow: tokens.black,\n\n    /* Popups */\n    popupInnerShadow: tokens.black,\n    popupOuterShadow: tokens.black,\n\n    /* Prompts */\n    promptFg: tokens.secondary,\n\n    /* Progress Bars */\n    progressBarErrorFg: tokens.errorLight,\n\n    /* Hover */\n    lightHover: \"rgba(111,111,125,0.4)\",\n\n    /* Cell Editor */\n    cellEditorFg: tokens.white,\n\n    /* Tables */\n    tableHeaderFg: tokens.body,\n    tableHeaderSelectedFg: tokens.body,\n    tableHeaderSelectedBg: \"#414358\",\n    tableHeaderBorder: tokens.decoration,\n    tableBodyBorder: tokens.decorationSecondary,\n    tableAddNewBg: \"#4a4a5d\",\n    tableScrollShadow: tokens.black,\n    tableFrozenColumnsBorder: tokens.secondary,\n    tableDragDropIndicator: tokens.secondary,\n    tableDragDropShadow: tokens.bgTertiary,\n\n    /* Cards */\n    cardCompactWidgetBg: tokens.bgSecondary,\n    cardBlocksBg: \"#404150\",\n    cardFormBorder: tokens.decoration,\n    cardEditingLayoutBg: \"rgba(85,85,99,0.2)\",\n\n    /* Card Lists */\n    cardListFormBorder: tokens.decorationSecondary,\n    cardListBlocksBorder: tokens.decorationSecondary,\n\n    /* Selection */\n    selection: tokens.selection,\n    selectionDarker: tokens.selectionDarker,\n    selectionDarkest: tokens.selectionDarkest,\n    selectionOpaqueBg: tokens.selectionOpaque,\n    selectionOpaqueDarkBg: tokens.selectionDarkerOpaque,\n    selectionHeader: \"rgba(107,107,144,0.4)\",\n\n    /* Widgets */\n    widgetActiveBorder: tokens.primaryDim,\n    widgetInactiveStripesDark: tokens.bg,\n\n    /* Pinned Docs */\n    pinnedDocBorder: tokens.decorationSecondary,\n    pinnedDocEditorBg: tokens.decorationSecondary,\n\n    /* Raw Data */\n    rawDataTableBorder: tokens.decorationSecondary,\n\n    /* Controls */\n    controlPrimaryBg: tokens.primaryDim,\n    controlHoverFg: tokens.primaryEmphasis,\n    controlSecondaryDisabledFg: tokens.decorationSecondary,\n    controlSecondaryHoverBg: \"#41414e\",\n    controlDisabledFg: tokens.secondary,\n    controlDisabledBg: tokens.decoration,\n    controlBorder: `1px solid ${tokens.primary}`,\n\n    /* Checkboxes */\n    checkboxBorderHover: tokens.secondary,\n\n    /* Move Docs */\n    moveDocsSelectedBg: tokens.primaryDim,\n\n    /* Filter Bar */\n    filterBarButtonSavedBg: tokens.decorationTertiary,\n\n    /* Icons */\n    iconError: \"#ffa500\",\n\n    /* Icon Buttons */\n    iconButtonPrimaryHoverBg: tokens.primaryEmphasis,\n\n    /* Left Panel */\n    pageHoverBg: \"rgba(111,111,117,0.25)\",\n    disabledPageFg: tokens.decoration,\n    pageInitialsBg: \"#8e8ea0\",\n    pageInitialsEmojiOutline: tokens.decoration,\n    pageInitialsEmojiBg: tokens.black,\n\n    /* Right Panel */\n    rightPanelTabBorder: tokens.decorationSecondary,\n    rightPanelTabSelectedIcon: \"#16b378\",\n    rightPanelTabButtonHoverBg: \"#0a5438\",\n    rightPanelSubtabSelectedUnderline: tokens.primaryMuted,\n    rightPanelFieldSettingsBg: \"#404150\",\n    rightPanelFieldSettingsButtonBg: tokens.bgEmphasis,\n    rightPanelCustomWidgetButtonBg: tokens.decorationSecondary,\n\n    /* Document History */\n    documentHistorySnapshotBorder: tokens.decoration,\n    documentHistoryTableHeaderFg: tokens.body,\n    documentHistoryTableBorder: tokens.decoration,\n    documentHistoryTableBorderLight: tokens.decorationSecondary,\n\n    /* Accents */\n    accentBorder: tokens.primaryDim,\n\n    /* Inputs */\n    inputFg: tokens.body,\n    inputInvalid: tokens.errorLight,\n    inputReadonlyBorder: tokens.decoration,\n\n    /* Choice Tokens */\n    choiceTokenBg: tokens.decoration,\n    choiceTokenSelectedBg: tokens.decorationTertiary,\n    choiceTokenInvalidBg: \"#323240\",\n\n    /* Choice Entry */\n    choiceEntryBorderHover: tokens.secondary,\n\n    /* Select Buttons */\n    selectButtonBorderInvalid: tokens.errorLight,\n\n    /* Menus */\n    menuBorder: tokens.decoration,\n    menuShadow: tokens.black,\n\n    /* Menu Items */\n    menuItemSelectedFg: tokens.white,\n    menuItemSelectedBg: tokens.primaryDim,\n\n    /* Autocomplete */\n    autocompleteItemSelectedBg: tokens.decoration,\n    autocompleteAddNewCircleBg: tokens.primaryDim,\n\n    /* Search */\n    searchBorder: tokens.decoration,\n    searchPrevNextButtonBg: \"#24242f\",\n\n    /* Site Switcher */\n    siteSwitcherActiveBg: tokens.black,\n\n    /* Shortcut Keys */\n    shortcutKeyPrimaryFg: tokens.primary,\n\n    /* Breadcrumbs */\n    breadcrumbsTagBg: tokens.decoration,\n\n    /* Page Widget Picker */\n    widgetPickerItemFg: tokens.white,\n    widgetPickerSummaryIcon: tokens.primary,\n    widgetPickerShadow: tokens.black,\n\n    /* Code View */\n    codeViewText: \"#d2d2d2\",\n    codeViewKeyword: \"#d2d2d2\",\n    codeViewComment: \"#888888\",\n    codeViewMeta: \"#7cd4ff\",\n    codeViewTitle: \"#ed7373\",\n    codeViewParams: \"#d2d2d2\",\n    codeViewString: \"#ed7373\",\n    codeViewNumber: \"#ed7373\",\n    codeViewBuiltin: \"#bfe6d8\",\n    codeViewLiteral: \"#9ed682\",\n\n    /* Importer */\n    importerOutsideBg: tokens.bg,\n    importerMainContentBg: tokens.bgSecondary,\n    importerActiveFileBg: \"#16b378\",\n    importerInactiveFileBg: \"#808080\",\n\n    /* Menu Toggles */\n    menuToggleHoverFg: tokens.primary,\n    menuToggleActiveFg: tokens.primaryEmphasis,\n\n    /* Button Groups */\n    buttonGroupBgHover: \"#41414e\",\n    buttonGroupBorderHover: tokens.bgEmphasis,\n\n    /* Access Rules */\n    accessRulesTableHeaderBg: tokens.decorationSecondary,\n\n    /* Cells */\n    cellZebraBg: tokens.bgSecondary,\n\n    /* Charts */\n    chartFg: tokens.secondary,\n    chartLegendBg: \"rgba(50,50,63,0.5)\",\n    chartXAxis: tokens.secondary,\n    chartYAxis: tokens.secondary,\n\n    /* Comments */\n    commentsUserNameFg: tokens.body,\n    commentsPanelTopicBorder: tokens.decorationTertiary,\n    commentsPanelResolvedTopicBg: tokens.bgSecondary,\n\n    /* Date Picker */\n    datePickerSelectedFg: tokens.white,\n    datePickerSelectedBg: \"#7a7a8d\",\n    datePickerSelectedBgHover: \"#8d8d9c\",\n    datePickerTodayBg: tokens.primaryDim,\n    datePickerRangeStartEndBg: \"#7a7a8d\",\n    datePickerRangeStartEndBgHover: \"#8d8d9c\",\n    datePickerRangeBg: tokens.decorationSecondary,\n    datePickerRangeBgHover: \"#7a7a8d\",\n\n    /* Tutorials */\n    tutorialsPopupBoxBg: tokens.decorationSecondary,\n    tutorialsPopupCodeFg: tokens.white,\n    tutorialsPopupCodeBg: tokens.bgSecondary,\n    tutorialsPopupCodeBorder: \"#929299\",\n\n    /* Ace */\n    aceAutocompletePrimaryFg: tokens.body,\n    aceAutocompleteSecondaryFg: tokens.secondary,\n    aceAutocompleteBg: tokens.bg,\n    aceAutocompleteBorder: tokens.decoration,\n    aceAutocompleteLink: \"#28be86\",\n    aceAutocompleteLinkHighlighted: \"#45d48b\",\n    aceAutocompleteActiveLineBg: tokens.decorationTertiary,\n    aceAutocompleteLineBorderHover: \"rgba(111,111,125,0.3)\",\n    aceAutocompleteLineBgHover: \"rgba(111,111,125,0.3)\",\n\n    /* Color Select */\n    colorSelectFg: tokens.secondary,\n    colorSelectShadow: tokens.black,\n    colorSelectFontOptionsBorder: tokens.decorationTertiary,\n    colorSelectFontOptionBgHover: \"rgba(111,111,125,0.25)\",\n    colorSelectColorSquareBorder: tokens.secondary,\n\n    /* Highlighted Code */\n    highlightedCodeBlockBg: tokens.bgSecondary,\n    highlightedCodeBlockBgDisabled: tokens.decorationTertiary,\n    highlightedCodeBgDisabled: tokens.bg,\n\n    /* Login Page */\n    loginPageBackdrop: \"#404150\",\n    loginPageLine: tokens.decorationSecondary,\n    loginPageGoogleButtonFg: tokens.white,\n    loginPageGoogleButtonBg: \"#404150\",\n    loginPageGoogleButtonBgHover: tokens.decorationTertiary,\n\n    /* Attachments */\n    attachmentsEditorButtonFg: tokens.primary,\n    attachmentsEditorButtonHoverFg: tokens.primaryEmphasis,\n    attachmentsEditorButtonBg: \"#404150\",\n    attachmentsEditorButtonHoverBg: tokens.decorationTertiary,\n    attachmentsEditorBorder: tokens.secondary,\n    attachmentsCellIconFg: tokens.secondary,\n    attachmentsCellIconBg: tokens.decorationTertiary,\n    attachmentsCellIconHoverBg: tokens.decoration,\n\n    /* Announcement Popups */\n    announcementPopupBg: \"#404150\",\n\n    /* Switches */\n    switchActivePill: tokens.bgSecondary,\n\n    /* Scroll Shadow */\n    scrollShadow: \"rgba(0,0,0,0.25)\",\n\n    /* Toggle Checkboxes */\n    toggleCheckboxFg: tokens.secondary,\n\n    /* Numeric Spinners */\n    numericSpinnerFg: tokens.secondary,\n\n    /* Custom Widget Gallery */\n    widgetGalleryBorder: tokens.decorationTertiary,\n    widgetGalleryShadow: \"rgba(0,0,0,0.5)\",\n    widgetGallerySecondaryHeaderBg: tokens.decoration,\n    widgetGallerySecondaryHeaderBgHover: tokens.decorationSecondary,\n\n    /* Markdown Cell */\n    markdownCellLightBg: \"#494958\",\n    markdownCellLightBorder: tokens.bg,\n    markdownCellMediumBorder: tokens.decorationTertiary,\n\n    /* App Header */\n    appHeaderBorder: tokens.bg,\n    appHeaderBorderHover: \"#78788c\",\n\n    /* Card Button */\n    cardButtonBorder: tokens.decorationTertiary,\n  },\n};\n"
  },
  {
    "path": "app/common/themes/GristLight.ts",
    "content": "import { ThemeTokens, tokens } from \"app/common/ThemePrefs\";\nimport { Base } from \"app/common/themes/Base\";\n\n/**\n * Default Grist theme. Uses the BaseTheme and describes the light-theme colors.\n */\nexport const GristLight: ThemeTokens = {\n  ...Base,\n\n  body: \"#262633\",\n  emphasis: tokens.black,\n  veryLight: tokens.white,\n\n  bg: tokens.white,\n  bgSecondary: \"#f7f7f7\",\n  bgTertiary: \"rgba(217,217,217,0.6)\",\n  bgEmphasis: \"#262633\",\n\n  decoration: \"#d9d9d9\",\n  decorationSecondary: \"#e8e8e8\",\n  decorationTertiary: \"#d9d9d9\",\n\n  primary: \"#16b378\",\n  primaryMuted: \"#009058\",\n  primaryDim: \"#007548\",\n  primaryEmphasis: \"#b1ffe2\",\n  primaryTranslucent: \"rgba(22, 179, 120, 0.5)\",\n\n  secondary: \"#929299\",\n  secondaryMuted: \"#777777\",\n\n  controlBorderRadius: \"4px\",\n\n  cursor: tokens.primary,\n  cursorInactive: \"#a2e1c9\",\n\n  selection: \"rgba(22,179,120,0.15)\",\n  selectionOpaque: \"#dcf4eb\",\n  selectionDarkerOpaque: \"#d6eee5\",\n  selectionDarker: \"rgba(22,179,120,0.25)\",\n  selectionDarkest: \"rgba(22,179,120,0.35)\",\n\n  hover: \"#bfbfbf\",\n  backdrop: \"rgba(38,38,51,0.9)\",\n\n  components: {\n    ...Base.components,\n\n    /* Text */\n    mediumText: \"#494949\",\n    errorText: tokens.error,\n    errorTextHover: \"#a10000\",\n\n    /* Page */\n    pageBackdrop: \"#808080\",\n\n    /* Top Bar */\n    topBarButtonErrorFg: tokens.error,\n\n    /* Toasts */\n    toastMemoBg: tokens.bgEmphasis,\n\n    /* Modals */\n    modalInnerShadow: \"rgba(31,37,50,0.31)\",\n    modalOuterShadow: \"rgba(76,86,103,0.24)\",\n\n    /* Popups */\n    popupInnerShadow: \"rgba(31,37,50,0.31)\",\n    popupOuterShadow: \"rgba(76,86,103,0.24)\",\n\n    /* Prompts */\n    promptFg: \"#606060\",\n\n    /* Progress Bars */\n    progressBarErrorFg: tokens.error,\n\n    /* Hover */\n    lightHover: tokens.bgSecondary,\n\n    /* Cell Editor */\n    cellEditorFg: tokens.body,\n\n    /* Tables */\n    tableHeaderSelectedBg: tokens.decorationSecondary,\n    tableHeaderBorder: \"lightgrey\",\n    tableAddNewBg: \"inherit\",\n    tableScrollShadow: \"#444444\",\n    tableFrozenColumnsBorder: \"#999999\",\n    tableDragDropIndicator: \"#808080\",\n    tableDragDropShadow: \"#f0f0f0\",\n\n    /* Cards */\n    cardCompactWidgetBg: tokens.bgTertiary,\n    cardBlocksBg: tokens.bgTertiary,\n    cardFormBorder: \"lightgrey\",\n    cardEditingLayoutBg: \"rgba(192,192,192,0.2)\",\n\n    /* Selection */\n    selection: tokens.selection,\n    selectionDarker: tokens.selectionDarker,\n    selectionDarkest: tokens.selectionDarkest,\n    selectionOpaqueBg: tokens.selectionOpaque,\n    selectionOpaqueDarkBg: tokens.selectionDarkerOpaque,\n    selectionHeader: tokens.bgTertiary,\n\n    /* Widgets */\n    widgetInactiveStripesDark: tokens.decorationSecondary,\n\n    /* Controls */\n    controlHoverFg: tokens.primaryMuted,\n    controlDisabledFg: tokens.white,\n    controlDisabledBg: tokens.secondary,\n    controlBorder: \"1px solid #11B683\",\n\n    /* Checkboxes */\n    checkboxBorderHover: tokens.hover,\n\n    /* Filter Bar */\n    filterBarButtonSavedBg: tokens.secondary,\n\n    /* Icons */\n    iconError: tokens.error,\n\n    /* Icon Buttons */\n    iconButtonPrimaryHoverBg: tokens.primaryMuted,\n\n    /* Left Panel */\n    pageHoverBg: tokens.bgTertiary,\n    disabledPageFg: \"#bdbdbd\",\n    pageInitialsBg: tokens.secondary,\n    pageInitialsEmojiOutline: \"#bdbdbd\",\n    pageInitialsEmojiBg: tokens.white,\n\n    /* Right Panel */\n    rightPanelTabButtonHoverBg: tokens.primaryMuted,\n    rightPanelSubtabFg: \"#707070\",\n    rightPanelFieldSettingsBg: tokens.decorationSecondary,\n    rightPanelFieldSettingsButtonBg: \"lightgrey\",\n\n    /* Document History */\n    documentHistorySnapshotBorder: tokens.bgTertiary,\n    documentHistoryTableBorder: \"lightgrey\",\n\n    /* Inputs */\n    inputInvalid: tokens.error,\n    inputReadonlyBorder: tokens.decorationSecondary,\n\n    /* Choice Tokens */\n    choiceTokenBg: tokens.decorationSecondary,\n    choiceTokenSelectedBg: tokens.decoration,\n    choiceTokenInvalidBg: tokens.white,\n\n    /* Choice Entry */\n    choiceEntryBorderHover: tokens.hover,\n\n    /* Select Buttons */\n    selectButtonBorderInvalid: tokens.error,\n\n    /* Menus */\n    menuBorder: tokens.decorationSecondary,\n    menuShadow: \"rgba(38,38,51,0.6)\",\n\n    /* Autocomplete */\n    autocompleteItemSelectedBg: tokens.decorationSecondary,\n\n    /* Search */\n    searchBorder: \"#808080\",\n    searchPrevNextButtonBg: tokens.bgTertiary,\n\n    /* Site Switcher */\n    siteSwitcherActiveBg: tokens.bgEmphasis,\n\n    /* Shortcut Keys */\n    shortcutKeyPrimaryFg: tokens.primaryMuted,\n\n    /* Breadcrumbs */\n    breadcrumbsTagBg: tokens.secondary,\n\n    /* Page Widget Picker */\n    widgetPickerItemFg: tokens.body,\n    widgetPickerSummaryIcon: tokens.primaryMuted,\n    widgetPickerShadow: \"rgba(38,38,51,0.20)\",\n\n    /* Code View */\n    codeViewText: \"#444444\",\n    codeViewKeyword: \"#444444\",\n    codeViewComment: \"#888888\",\n    codeViewMeta: \"#1f7199\",\n    codeViewTitle: \"#880000\",\n    codeViewParams: \"#444444\",\n    codeViewString: \"#880000\",\n    codeViewNumber: \"#880000\",\n    codeViewBuiltin: \"#397300\",\n    codeViewLiteral: \"#78a960\",\n\n    /* Importer */\n    importerOutsideBg: tokens.bgSecondary,\n    importerMainContentBg: tokens.bg,\n    importerInactiveFileBg: tokens.bgTertiary,\n\n    /* Menu Toggles */\n    menuToggleHoverFg: tokens.primaryMuted,\n    menuToggleActiveFg: tokens.primaryDim,\n\n    /* Button Groups */\n    buttonGroupBgHover: tokens.bgSecondary,\n    buttonGroupBorderHover: tokens.hover,\n\n    /* Cells */\n    cellZebraBg: \"#f8f8f8\",\n\n    /* Charts */\n    chartFg: \"#444444\",\n    chartLegendBg: \"#ffffff80\",\n    chartXAxis: \"#444444\",\n    chartYAxis: \"#444444\",\n\n    /* Comments */\n    commentsUserNameFg: \"#494949\",\n    commentsPanelTopicBorder: \"#cccccc\",\n    commentsPanelResolvedTopicBg: \"#f0f0f0\",\n\n    /* Date Picker */\n    datePickerSelectedFg: tokens.body,\n    datePickerSelectedBg: tokens.decoration,\n    datePickerSelectedBgHover: \"#cfcfcf\",\n    datePickerRangeStartEndBg: tokens.decoration,\n    datePickerRangeStartEndBgHover: \"#cfcfcf\",\n    datePickerRangeBg: \"#eeeeee\",\n    datePickerRangeBgHover: tokens.decoration,\n\n    /* Tutorials */\n    tutorialsPopupBoxBg: \"#f5f5f5\",\n    tutorialsPopupCodeFg: \"#333333\",\n    tutorialsPopupCodeBg: tokens.bg,\n    tutorialsPopupCodeBorder: \"#e1e4e5\",\n\n    /* Ace */\n    aceAutocompletePrimaryFg: \"#444444\",\n    aceAutocompleteSecondaryFg: \"#8f8f8f\",\n    aceAutocompleteBg: \"#fbfbfb\",\n    aceAutocompleteBorder: \"lightgrey\",\n    aceAutocompleteLinkHighlighted: \"#009058\",\n    aceAutocompleteActiveLineBg: \"#cad6fa\",\n    aceAutocompleteLineBorderHover: \"#abbffe\",\n    aceAutocompleteLineBgHover: \"rgba(233,233,253,0.4)\",\n\n    /* Color Select */\n    colorSelectFg: tokens.body,\n    colorSelectShadow: \"rgba(38,38,51,0.6)\",\n    colorSelectFontOptionsBorder: tokens.decoration,\n    colorSelectFontOptionBgHover: tokens.decoration,\n    colorSelectColorSquareBorder: tokens.decoration,\n\n    /* Highlighted Code */\n    highlightedCodeBlockBg: tokens.bg,\n    highlightedCodeBlockBgDisabled: tokens.decorationSecondary,\n    highlightedCodeBgDisabled: tokens.decorationSecondary,\n\n    /* Login Page */\n    loginPageBackdrop: \"#f5f8fa\",\n    loginPageLine: tokens.bgSecondary,\n    loginPageGoogleButtonFg: tokens.body,\n    loginPageGoogleButtonBg: tokens.bgSecondary,\n    loginPageGoogleButtonBgHover: tokens.decorationSecondary,\n\n    /* Attachments */\n    attachmentsEditorButtonFg: tokens.primaryMuted,\n    attachmentsEditorButtonBg: tokens.white,\n    attachmentsEditorButtonHoverBg: tokens.decorationSecondary,\n    attachmentsEditorBorder: tokens.decorationSecondary,\n    attachmentsCellIconFg: tokens.white,\n    attachmentsCellIconBg: tokens.decoration,\n    attachmentsCellIconHoverBg: tokens.secondary,\n\n    /* Announcement Popups */\n    announcementPopupBg: tokens.selectionOpaque,\n\n    /* Scroll Shadow */\n    scrollShadow: tokens.bgTertiary,\n\n    /* Toggle Checkboxes */\n    toggleCheckboxFg: \"#606060\",\n\n    /* Numeric Spinners */\n    numericSpinnerFg: \"#606060\",\n\n    /* Custom Widget Gallery */\n    widgetGalleryBorder: tokens.decoration,\n    widgetGalleryShadow: \"rgba(0,0,0,0.1)\",\n    widgetGallerySecondaryHeaderBg: tokens.secondary,\n    widgetGallerySecondaryHeaderBgHover: \"#7e7e85\",\n\n    /* Markdown Cell */\n    markdownCellLightBg: tokens.bgSecondary,\n    markdownCellLightBorder: tokens.decorationSecondary,\n    markdownCellMediumBorder: tokens.decoration,\n\n    /* App Header */\n    appHeaderBorder: tokens.decorationSecondary,\n    appHeaderBorderHover: \"#b0b0b0\",\n\n    /* Card Button */\n    cardButtonBorder: tokens.decoration,\n  },\n};\n"
  },
  {
    "path": "app/common/themes/HighContrastLight.ts",
    "content": "import { ThemeTokens, tokens } from \"app/common/ThemePrefs\";\nimport { GristLight } from \"app/common/themes/GristLight\";\n\n/**\n * High Contrast Light theme. Uses the default Grist theme as base.\n */\nexport const HighContrastLight: ThemeTokens = {\n  ...GristLight,\n\n  secondary: \"#717178\",\n\n  decoration: \"#8F8F8F\",\n  decorationSecondary: \"#cfcfcf\",\n  decorationTertiary: \"#dfdfdf\",\n\n  primary: \"#0f7b51\",\n  primaryMuted: \"#196C47\",\n  primaryDim: \"#196C47\",\n  primaryTranslucent: \"rgba(15, 123, 81, 0.5)\",\n\n  components: {\n    ...GristLight.components,\n    appHeaderBorder: tokens.decoration,\n    pagePanelsBorder: tokens.decorationSecondary,\n    tooltipBg: \"#000\",\n    controlBorder: \"1px solid #0f7b51\",\n    controlSecondaryHoverBg: tokens.decorationTertiary,\n    buttonGroupBgHover: tokens.bgSecondary,\n    buttonGroupBorderHover: tokens.decoration,\n    tableHeaderBorder: tokens.decoration,\n    tableHeaderSelectedBg: tokens.decorationSecondary,\n    selectionHeader: tokens.decorationSecondary,\n    tableBodyBorder: tokens.decorationSecondary,\n    choiceTokenBg: tokens.decorationTertiary,\n    cardFormBorder: tokens.decoration,\n    accessRulesFormulaEditorBgDisabled: tokens.decorationTertiary,\n    rightPanelTabBorder: tokens.decoration,\n    rightPanelSubtabFg: tokens.secondary,\n    formulaIcon: tokens.decoration,\n    textButtonHoverBorder: \"currentColor\",\n    switchActiveSlider: tokens.primary,\n  },\n};\n"
  },
  {
    "path": "app/common/timeFormat.ts",
    "content": "/**\n * timeFormat(format, date) formats the passed-in Date object using the format string. The format\n * string may contain the following:\n *    'h': hour (00 - 23)\n *    'm': minute (00 - 59)\n *    's': second (00 - 59)\n *    'd': day of the month (01 - 31)\n *    'n': month (01 - 12)\n *    'y': 4-digit year\n *    'M': milliseconds (000 - 999)\n *    'Y': date as 20140212\n *    'D': date as 2014-02-12\n *    'T': time as 00:51:06\n *    'A': full time and date, as 2014-02-12 00:51:06.123\n * @param {String} format The format string.\n * @param {Date} date The date/time object to format.\n * @returns {String} The formatted date and/or time.\n */\n\nfunction pad(num: number, len: number): string {\n  const s = num.toString();\n  return s.length >= len ? s : \"00000000\".slice(0, len - s.length) + s;\n}\n\ntype FormatHelper = (out: string[], date: Date) => void;\nconst timeFormatKeys: { [spec: string]: FormatHelper } = {\n  h: (out, date) => out.push(pad(date.getHours(), 2)),\n  m: (out, date) => out.push(pad(date.getMinutes(), 2)),\n  s: (out, date) => out.push(pad(date.getSeconds(), 2)),\n  d: (out, date) => out.push(pad(date.getDate(), 2)),\n  n: (out, date) => out.push(pad(date.getMonth() + 1, 2)),\n  y: (out, date) => out.push(\"\" + date.getFullYear()),\n  M: (out, date) => out.push(pad(date.getMilliseconds(), 3)),\n  Y: (out, date) => timeFormatHelper(out, \"ynd\", date),\n  D: (out, date) => timeFormatHelper(out, \"y-n-d\", date),\n  T: (out, date) => timeFormatHelper(out, \"h:m:s\", date),\n  A: (out, date) => timeFormatHelper(out, \"D T.M\", date),\n};\n\nfunction timeFormatHelper(out: string[], format: string, date: Date) {\n  for (let i = 0, len = format.length; i < len; i++) {\n    const c = format[i];\n    const helper = timeFormatKeys[c];\n    if (helper) {\n      helper(out, date);\n    } else {\n      out.push(c);\n    }\n  }\n}\n\nexport function timeFormat(format: string, date: Date): string {\n  const out: string[] = [];\n  timeFormatHelper(out, format, date);\n  return out.join(\"\");\n}\n"
  },
  {
    "path": "app/common/tpromisified.ts",
    "content": "// credits: https://stackoverflow.com/questions/49998665/promisified-function-type\n\n// Generic Function definition\ntype AnyFunction = (...args: any[]) => any;\n\n// Extracts the type if wrapped by a Promise\ntype Unpacked<T> = T extends Promise<infer U> ? U : T;\n\ntype PromisifiedFunction<T extends AnyFunction> =\n  T extends () => infer U ? () => Promise<Unpacked<U>> :\n    T extends (a1: infer A1) => infer U ? (a1: A1) => Promise<Unpacked<U>> :\n      T extends (a1: infer A1, a2: infer A2) => infer U ? (a1: A1, a2: A2) => Promise<Unpacked<U>> :\n        T extends (a1: infer A1, a2: infer A2, a3: infer A3) => infer U ? (a1: A1, a2: A2, a3: A3) => Promise<Unpacked<U>> : // eslint-disable-line @stylistic/max-len\n          T extends (a1: infer A1, a2: infer A2, a3: infer A3, a4: infer A4) =>\n          infer U ? (a1: A1, a2: A2, a3: A3, a4: A4) => Promise<Unpacked<U>> :\n          // ...\n            T extends (...args: any[]) => infer U ? (...args: any[]) => Promise<Unpacked<U>> : T;\n\n/**\n * `Promisified<T>` has the same methods as `T` but they all return promises. This is useful when\n * creating a stub with `grain-rpc` for an api which is synchronous.\n */\nexport type Promisified<T> = {\n  [K in keyof T]: T[K] extends AnyFunction ? PromisifiedFunction<T[K]> : never\n};\n"
  },
  {
    "path": "app/common/tsconfig.json",
    "content": "{\n  \"extends\": \"../../buildtools/tsconfig-base.json\",\n  \"include\": [\n    \"**/*\",\n    \"../../stubs/app/common/**/*\",\n    \"../../package.json\"\n  ],\n  \"references\": [\n    { \"path\": \"../plugin\" }\n  ]\n}\n"
  },
  {
    "path": "app/common/tsvFormat.ts",
    "content": "/**\n * Given a 2D array of strings, encodes them in tab-separated format.\n * Certain values are quoted; when quoted, internal quotes get doubled. The behavior attempts to\n * match Excel's tsv encoding and parsing when using copy-paste.\n */\nexport function tsvEncode(data: any[][]): string {\n  return data.map(row => row.map(value => encode(value)).join(\"\\t\")).join(\"\\n\");\n}\n\nfunction encode(rawValue: any): string {\n  // For encoding-decoding symmetry, we should also encode any values that start with '\"',\n  // but neither Excel nor Google Sheets do it. They both decode such values to something\n  // different than what produced them (e.g. `\"foo\"\"bar\"` is encoded into `\"foo\"\"bar\"`, and\n  // that is decoded into `foo\"bar`).\n  const value: string = typeof rawValue === \"string\" ? rawValue :\n    (rawValue == null ? \"\" : String(rawValue));\n  if (value.includes(\"\\t\") || value.includes(\"\\n\")) {\n    return '\"' + value.replace(/\"/g, '\"\"') + '\"';\n  }\n  return value;\n}\n\n/**\n * Given a tab-separated string, decodes it and returns a 2D array of strings.\n * TODO: This does not yet deal with Windows line endings (\\r or \\r\\n).\n */\nexport function tsvDecode(tsvString: string): string[][] {\n  const lines: string[][] = [];\n  let row: string[] = [];\n\n  // This is a complex regexp but it does the job of a lot of parsing code. Here are the parts:\n  //  A: [^\\t\\n]*         Sequence of character that does not require the field to get quoted.\n  //  B: ([^\"]*\"\")*[^\"]*  Sequence of characters containing all double-quotes in pairs (i.e. `\"\"`)\n  //  C: \"B\"(?!\")         Quoted sequence, with all double-quotes inside paired up, and ending in a single quote.\n  //  D: C?A              A value for one field, a relaxation of C|A (to cope with not-quite expected data)\n  //  E: D(\\t|\\n|$)       Field value with field, line, or file terminator.\n  const fieldRegexp = /((\"([^\"]*\"\")*[^\"]*\"(?!\"))?[^\\t\\n]*)(\\t|\\n|$)/g;\n  for (;;) {\n    const m = fieldRegexp.exec(tsvString);\n    if (!m) { break; }\n    const sep = m[4];\n    let value = m[1];\n    if (value.startsWith('\"')) {\n      // It's a quoted value, so doubled-up quotes should became individual quotes, and individual\n      // quotes should be removed.\n      value = value.replace(/\"([^\"]*\"\")*[^\"]*\"(?!\")/, q => q.slice(1, -1).replace(/\"\"/g, '\"'));\n    }\n    row.push(value);\n    if (sep !== \"\\t\") {\n      lines.push(row);\n      row = [];\n      if (sep === \"\") {\n        break;\n      }\n    }\n  }\n  return lines;\n}\n"
  },
  {
    "path": "app/common/uploads.ts",
    "content": "/**\n * Code and declarations shared by browser and server-side code for handling uploads.\n *\n * Browser code has several functions available in app/client/lib/uploads.ts which return an\n * UploadResult that represents an upload. An upload may contain multiple files.\n *\n * An upload is identified by a numeric uploadId which is unique within an UploadSet. An UploadSet\n * is collection of uploads tied to a browser session (as maintained by app/server/lib/Client).\n * When the session ends, all uploads are cleaned up.\n *\n * The uploadId is useful to identify the upload to the server, which can then consume the actual\n * files there. It may also be used to clean up the upload once it is no longer needed.\n *\n * Files within an upload can be identified by their index in UploadResult.files array. The\n * origName available for files is not guaranteed to be unique.\n *\n * Implementation detail: The upload is usually a temporary directory on the server, but may be a\n * collection of non-temporary files when files are selected using Electron's native file picker.\n */\n\n/**\n * Represents a single upload, containing one or more files. Empty uploads are never created.\n */\nexport interface UploadResult {\n  uploadId: number;\n  files: FileUploadResult[];\n}\n\n/**\n * Represents a single file within an upload. This is the only information made available to the\n * browser. (In particular, while the server knows also the actual path of the file on the server,\n * the browser has no need for it and should not know it.)\n */\nexport interface FileUploadResult {\n  origName: string;     // The filename that the user reports for the file (not guaranteed unique).\n  size: number;         // The size of the file in bytes.\n  ext: string;          // The extension of the file, starting with \".\"\n}\n\n/**\n * Path where the server accepts POST requests with uploads.  Don't include a leading / so that\n * the page's <base> will be respected.\n */\nexport const UPLOAD_URL_PATH = \"uploads\";\n\n/**\n * Additional options for fetching external resources.\n */\nexport interface FetchUrlOptions {\n  googleAuthorizationCode?: string;   // The authorization code received from Google Auth Service.\n  fileName?: string;                  // The filename for external resource.\n  headers?: { [key: string]: string };  // Additional headers to use when accessing external resource.\n}\n"
  },
  {
    "path": "app/common/urlUtils.ts",
    "content": "import { extractOrgParts, GristLoadConfig } from \"app/common/gristUrls\";\n\nexport function getGristConfig(): GristLoadConfig {\n  return (window as any).gristConfig || {};\n}\n\n/**\n *\n * Adds /o/ORG to the supplied path, with ORG extracted from current URL if possible.\n * If not, path is returned as is, but with any trailing / removed for consistency.\n *\n */\nexport function addCurrentOrgToPath(path: string, skipIfInDomain: boolean = false) {\n  if (typeof window === \"undefined\" || !window) { return path; }\n  return addOrgToPath(path, window.location.href, skipIfInDomain);\n}\n\n/**\n *\n * Adds /o/ORG to the supplied path, with ORG extracted from the page URL if possible.\n * If not, path is returned as is, but with any trailing / removed for consistency.\n *\n */\nexport function addOrgToPath(path: string, page: string, skipIfInDomain: boolean = false) {\n  if (typeof window === \"undefined\" || !window) { return path; }\n  if (path.includes(\"/o/\")) { return path; }\n  const src = new URL(page);\n  const srcParts = extractOrgParts(src.host, src.pathname);\n  if (srcParts.mismatch) {\n    throw new Error(\"Cannot figure out what organization the URL is for.\");\n  }\n  path = path.replace(/\\/$/, \"\");\n  if (!srcParts.subdomain) {\n    return path;\n  }\n  if (skipIfInDomain && srcParts.orgFromHost) {\n    return path;\n  }\n  // TODO: this is confusing -- orgs are typically expected _before_ the path, not after.\n  // Figure out how this is used and make this function less misleading.\n  return `${path}/o/${srcParts.subdomain}`;\n}\n\n/**\n * Expands an endpoint path to a full url anchored to the given doc worker base url.\n */\nexport function docUrl(docWorkerUrl: string | null | undefined, path?: string) {\n  const base = document.querySelector(\"base\");\n  const baseHref = base?.href;\n  const baseUrl = new URL(docWorkerUrl || baseHref || window.location.origin);\n  return baseUrl.toString().replace(/\\/$/, \"\") + (path ? `/${path}` : \"\");\n}\n\n// Get a url on the same webserver as the current page, adding a prefix to encode\n// the current organization if necessary.\nexport function getOriginUrl(path: string) {\n  return `${window.location.origin}${addCurrentOrgToPath(\"/\", true)}${path}`;\n}\n\n// Return a string docId if server has provided one (as in hosted Grist), otherwise null\n// (as in classic Grist).\nexport function getInitialDocAssignment(): string | null {\n  return getGristConfig().assignmentId || null;\n}\n\n// Return true if we are on a page that can supply a doc list.\n// TODO: the doclist object isn't relevant to hosted grist and should be factored out.\nexport function pageHasDocList(): boolean {\n  // No doc list support on hosted grist.\n  return !getGristConfig().homeUrl;\n}\n\n// Return true if we are on a page that has access to home api.\nexport function pageHasHome(): boolean {\n  return Boolean(getGristConfig().homeUrl);\n}\n\n// Construct a url by adding `path` to the home url (adding in the part to the current\n// org if needed), and fetch from it.\nexport function fetchFromHome(path: string, opts: RequestInit): Promise<Response> {\n  const baseUrl = addCurrentOrgToPath(getGristConfig().homeUrl!);\n  return window.fetch(`${baseUrl}${path}`, opts);\n}\n"
  },
  {
    "path": "app/common/widgetTypes.ts",
    "content": "/**\n * Exposes utilities for getting the types information associated to each of the widget types.\n */\nimport { StringUnion } from \"app/common/StringUnion\";\n\n// Custom widgets that are attached to \"Add New\" menu.\nexport const AttachedCustomWidgets = StringUnion(\"custom.calendar\");\nexport type IAttachedCustomWidget = typeof AttachedCustomWidgets.type;\n\n// all widget types\nexport type IWidgetType = \"record\" | \"detail\" | \"single\" | \"chart\" | \"custom\" | \"form\" | IAttachedCustomWidget;\nexport enum WidgetType {\n  Table = \"record\",\n  Card = \"single\",\n  CardList = \"detail\",\n  Chart = \"chart\",\n  Custom = \"custom\",\n  Form = \"form\",\n  Calendar = \"custom.calendar\",\n}\n"
  },
  {
    "path": "app/gen-server/ApiServer.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { isAffirmative } from \"app/common/gutil\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport { BasicRole } from \"app/common/roles\";\nimport * as SATypes from \"app/common/ServiceAccountTypes\";\nimport ServiceAccountTI from \"app/common/ServiceAccountTypes-ti\";\nimport { DOCTYPE_NORMAL,\n  DOCTYPE_TEMPLATE,\n  DOCTYPE_TUTORIAL,\n  OrganizationProperties,\n  PermissionDelta } from \"app/common/UserAPI\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { BillingOptions, HomeDBManager, Scope } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { DocumentAccessChanges, OrgAccessChanges, PreviousAndCurrent,\n  QueryResult, WorkspaceAccessChanges } from \"app/gen-server/lib/homedb/Interfaces\";\nimport { Permissions } from \"app/gen-server/lib/Permissions\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { getSessionUser, linkOrgWithEmail } from \"app/server/lib/BrowserSession\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { RequestWithOrg } from \"app/server/lib/extractOrg\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { getCookieDomain } from \"app/server/lib/gristSessions\";\nimport { getCanAnyoneCreateOrgs, getTemplateOrg } from \"app/server/lib/gristSettings\";\nimport log from \"app/server/lib/log\";\nimport { clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,\n  isParameterOn, optStringParam, sendOkReply, sendReply, stringParam } from \"app/server/lib/requestUtils\";\n\nimport * as crypto from \"crypto\";\n\nimport * as cookie from \"cookie\";\nimport { Request } from \"express\";\nimport * as express from \"express\";\nimport pick from \"lodash/pick\";\nimport * as t from \"ts-interface-checker\";\n\nconst ALLOW_DEPRECATED_BARE_ORG_DELETE = appSettings.section(\"api\").flag(\"allowBareOrgDelete\").readBool({\n  envVar: \"GRIST_ALLOW_DEPRECATED_BARE_ORG_DELETE\",\n});\n\nconst { PatchServiceAccount, PostServiceAccount } = t.createCheckers(ServiceAccountTI);\n\nfor (const checker of [PatchServiceAccount, PostServiceAccount]) {\n  checker.setReportedPath(\"body\");\n}\n\n/**\n * Middleware for validating request's body with a Checker instance.\n */\nfunction validateStrict(checker: t.Checker): express.RequestHandler {\n  return (req, res, next) => {\n    try {\n      checker.strictCheck(req.body);\n    } catch (err) {\n      log.warn(`Error during api call to ${req.path}: Invalid payload: ${String(err)}`);\n      throw new ApiError(\"Invalid payload\", 400, { userError: String(err) });\n    }\n    next();\n  };\n}\n\n// Fetch the org this request was made for, or null if it isn't tied to a particular org.\n// Early middleware should have put the org in the request object for us.\nexport function getOrgFromRequest(req: Request): string | null {\n  return (req as RequestWithOrg).org || null;\n}\n\n/**\n * Compute the signature of the user's email address using HelpScout's secret key, to prove to\n * HelpScout the user identity for identifying customer information and conversation history.\n */\nfunction helpScoutSign(email: string): string | undefined {\n  const secretKey = process.env.HELP_SCOUT_SECRET_KEY_V2;\n  if (!secretKey) { return undefined; }\n  return crypto.createHmac(\"sha256\", secretKey).update(email).digest(\"hex\");\n}\n\n/**\n * Fetch an identifier for an organization from the \"oid\" parameter of the request.\n *   - Integers are accepted, and will be compared with values in orgs.id column\n *   - Strings are accepted, and will be compared with values in orgs.domain column\n *     (or, if they match the pattern docs-NNNN, will check orgs.owner_id)\n *   - The special string \"current\" is replaced with the current org domain embedded\n *     in the url\n *   - If there is no identifier available, a 400 error is thrown.\n */\nexport function getOrgKey(req: Request): string | number {\n  let orgKey: string | null = stringParam(req.params.oid, \"oid\");\n  if (orgKey === \"current\") {\n    orgKey = getOrgFromRequest(req);\n  }\n  if (!orgKey) {\n    throw new ApiError(\"No organization chosen\", 400);\n  } else if (/^\\d+$/.test(orgKey)) {\n    return parseInt(orgKey, 10);\n  }\n  return orgKey;\n}\n\n// Adds an non-personal org with a new billingAccount, with the given name and domain.\n// Returns a QueryResult with the orgId on success.\nexport function addOrg(\n  dbManager: HomeDBManager,\n  userId: number,\n  props: Partial<OrganizationProperties>,\n  options?: {\n    product?: string,\n    billing?: BillingOptions,\n  },\n): Promise<Organization> {\n  return dbManager.connection.transaction(async (manager) => {\n    const user = await manager.findOne(User, { where: { id: userId } });\n    if (!user) { return handleDeletedUser(); }\n    const query = await dbManager.addOrg(user, props, {\n      ...options,\n      setUserAsOwner: false,\n      useNewPlan: true,\n    }, manager);\n    if (query.status !== 200) { throw new ApiError(query.errMessage!, query.status); }\n    return query.data!;\n  });\n}\n\n/**\n * Provides a REST API for the landing page, which returns user's workspaces, organizations and documents.\n * Temporarily sqlite database is used. Later it will be changed to RDS Aurora or PostgreSQL.\n */\nexport class ApiServer {\n  /**\n   * Add API endpoints to the specified connection. An error handler is added to /api to make sure\n   * all error responses have a body in json format.\n   *\n   * Note that it expects bodyParser, userId, and jsonErrorHandler middleware to be set up outside\n   * to apply to these routes, and trustOrigin too for cross-domain requests.\n   */\n  constructor(\n    private _gristServer: GristServer,\n    private _app: express.Application,\n    private _dbManager: HomeDBManager,\n  ) {\n    this._addEndpoints();\n  }\n\n  private _addEndpoints(): void {\n    const requireInstallAdmin = this._gristServer.getInstallAdmin().getMiddlewareRequireAdmin();\n\n    // GET /api/orgs\n    // Get all organizations user may have some access to.\n    this._app.get(\"/api/orgs\", expressWrap(async (req, res) => {\n      const userId = getUserId(req);\n      const domain = getOrgFromRequest(req);\n      const merged = Boolean(req.query.merged);\n      const query = merged ?\n        await this._dbManager.getMergedOrgs(userId, userId, domain) :\n        await this._dbManager.getOrgs(userId, domain);\n      return sendReply(req, res, query);\n    }));\n\n    // GET /api/workspace/:wid\n    // Get workspace by id, returning nested documents that user has access to.\n    this._app.get(\"/api/workspaces/:wid\", expressWrap(async (req, res) => {\n      const wsId = integerParam(req.params.wid, \"wid\");\n      const query = await this._dbManager.getWorkspace(getScope(req), wsId);\n      return sendReply(req, res, query);\n    }));\n\n    // GET /api/orgs/:oid\n    // Get organization by id\n    this._app.get(\"/api/orgs/:oid\", expressWrap(async (req, res) => {\n      const org = getOrgKey(req);\n      const query = await this._dbManager.getOrg(getScope(req), org);\n      return sendReply(req, res, query);\n    }));\n\n    // GET /api/orgs/:oid/workspaces\n    // Get all workspaces and nested documents of organization that user has access to.\n    this._app.get(\"/api/orgs/:oid/workspaces\", expressWrap(async (req, res) => {\n      const org = getOrgKey(req);\n      const query = await this._dbManager.getOrgWorkspaces(getScope(req), org);\n      return sendReply(req, res, query);\n    }));\n\n    // GET /api/orgs/:oid/usage\n    // Get usage summary of all un-deleted documents in the organization.\n    // Only accessible to org owners.\n    this._app.get(\"/api/orgs/:oid/usage\", expressWrap(async (req, res) => {\n      const org = getOrgKey(req);\n      const usage = await this._dbManager.getOrgUsageSummary(getScope(req), org);\n      return sendOkReply(req, res, usage);\n    }));\n\n    // POST /api/orgs\n    // Body params: name (required), domain\n    // Create a new org.\n    this._app.post(\"/api/orgs\", expressWrap(async (req, res) => {\n      // Don't let anonymous users end up owning organizations, it will be confusing.\n      // Maybe if the user has presented credentials this would be ok - but addOrg\n      // doesn't have access to that information yet, so punting on this.\n\n      if (!getCanAnyoneCreateOrgs()) {\n        const isAdmin = await this._gristServer.getInstallAdmin().isAdminReq(req);\n        if (!isAdmin) {\n          throw new ApiError(\"Only admins can create new teams\", 403);\n        }\n      }\n\n      const userId = getAuthorizedUserId(req);\n      const org = await addOrg(this._dbManager, userId, req.body);\n      this._logCreateSiteEvents(req, org);\n      return sendOkReply(req, res, org.id);\n    }));\n\n    // PATCH /api/orgs/:oid\n    // Body params: name, domain\n    // Update the specified org.\n    this._app.patch(\"/api/orgs/:oid\", expressWrap(async (req, res) => {\n      const org = getOrgKey(req);\n      const { data, ...result } = await this._dbManager.updateOrg(getScope(req), org, req.body);\n      if (data && (req.body.name || req.body.domain)) {\n        this._logRenameSiteEvents(req as RequestWithLogin, data);\n      }\n      return sendReply(req, res, result);\n    }));\n\n    // DELETE /api/orgs/:oid/:name\n    // Delete the specified org and all included workspaces and docs.\n    // The :name should match the orgs.domain or orgs.name, or be the string\n    // \"force-delete\".\n    this._app.delete(\"/api/orgs/:oid/:name\", expressWrap(async (req, res) => {\n      const name = stringParam(req.params.name, \"name\");\n      await this._deleteOrg(req, res, name);\n    }));\n\n    this._app.delete(\"/api/orgs/:oid\", expressWrap(async (req, res) => {\n      if (ALLOW_DEPRECATED_BARE_ORG_DELETE) {\n        await this._deleteOrg(req, res, \"force-delete\");\n      } else {\n        throw new ApiError(\n          \"This endpoint is no longer supported. Use DELETE /api/orgs/:oid/:name instead.\",\n          410,\n        );\n      }\n    }));\n\n    // POST /api/orgs/:oid/workspaces\n    // Body params: name\n    // Create a new workspace owned by the specific organization.\n    this._app.post(\"/api/orgs/:oid/workspaces\", expressWrap(async (req, res) => {\n      const org = getOrgKey(req);\n      const { data, ...result } = await this._dbManager.addWorkspace(getScope(req), org, req.body);\n      if (data) { this._logCreateWorkspaceEvents(req, data); }\n      return sendReply(req, res, { ...result, data: data?.id });\n    }));\n\n    // PATCH /api/workspaces/:wid\n    // Body params: name\n    // Update the specified workspace.\n    this._app.patch(\"/api/workspaces/:wid\", expressWrap(async (req, res) => {\n      const wsId = integerParam(req.params.wid, \"wid\");\n      const { data, ...result } = await this._dbManager.updateWorkspace(getScope(req), wsId, req.body);\n      if (data && \"name\" in req.body) { this._logRenameWorkspaceEvents(req, data); }\n      return sendReply(req, res, { ...result, data: data?.current.id });\n    }));\n\n    // DELETE /api/workspaces/:wid\n    // Delete the specified workspace and all included docs.\n    this._app.delete(\"/api/workspaces/:wid\", expressWrap(async (req, res) => {\n      const wsId = integerParam(req.params.wid, \"wid\");\n      await this._hardDeleteWorkspace(req, wsId);\n      return sendReply(req, res, { status: 200, data: wsId });\n    }));\n\n    // POST /api/workspaces/:wid/remove\n    // Soft-delete the specified workspace.  If query parameter \"permanent\" is set,\n    // delete permanently.\n    this._app.post(\"/api/workspaces/:wid/remove\", expressWrap(async (req, res) => {\n      const wsId = integerParam(req.params.wid, \"wid\");\n      if (isParameterOn(req.query.permanent)) {\n        await this._hardDeleteWorkspace(req, wsId);\n        return sendReply(req, res, { status: 200, data: wsId });\n      } else {\n        const { data } = await this._dbManager.softDeleteWorkspace(getScope(req), wsId);\n        if (data) { this._logRemoveWorkspaceEvents(req, data); }\n        return sendOkReply(req, res);\n      }\n    }));\n\n    // POST /api/workspaces/:wid/unremove\n    // Recover the specified workspace if it was previously soft-deleted and is\n    // still available.\n    this._app.post(\"/api/workspaces/:wid/unremove\", expressWrap(async (req, res) => {\n      const wsId = integerParam(req.params.wid, \"wid\");\n      const { data } = await this._dbManager.undeleteWorkspace(getScope(req), wsId);\n      if (data) { this._logRestoreWorkspaceEvents(req, data); }\n      return sendOkReply(req, res);\n    }));\n\n    // POST /api/workspaces/:wid/docs\n    // Create a new doc owned by the specific workspace.\n    this._app.post(\"/api/workspaces/:wid/docs\", expressWrap(async (req, res) => {\n      const wsId = integerParam(req.params.wid, \"wid\");\n      const { data, ...result } = await this._dbManager.addDocument(getScope(req), wsId, req.body);\n      if (data) { this._logCreateDocumentEvents(req, data); }\n      return sendReply(req, res, { ...result, data: data?.id });\n    }));\n\n    // GET /api/templates/\n    // Get all templates.\n    this._app.get(\"/api/templates/\", expressWrap(async (req, res) => {\n      const templateOrg = getTemplateOrg();\n      if (!templateOrg) {\n        throw new ApiError(\"Template org is not configured\", 501);\n      }\n\n      const query = await this._dbManager.getOrgWorkspaces(getScope(req), templateOrg);\n      return sendReply(req, res, query);\n    }));\n\n    // GET /api/templates/:did\n    // Get information about a template.\n    this._app.get(\"/api/templates/:did\", expressWrap(async (req, res) => {\n      const templateOrg = getTemplateOrg();\n      if (!templateOrg) {\n        throw new ApiError(\"Template org is not configured\", 501);\n      }\n\n      const query = await this._dbManager.getDoc({ ...getScope(req), org: templateOrg });\n      return sendOkReply(req, res, query);\n    }));\n\n    // GET /api/widgets/\n    // Get all widget definitions from external source.\n    this._app.get(\"/api/widgets/\", expressWrap(async (req, res) => {\n      const widgetList = await this._gristServer\n        .getWidgetRepository()\n        .getWidgets();\n      return sendOkReply(req, res, widgetList);\n    }));\n\n    // PATCH /api/docs/:did\n    // Update the specified doc.\n    this._app.patch(\"/api/docs/:did\", expressWrap(async (req, res) => {\n      const validDocTypes = [\n        DOCTYPE_NORMAL,\n        DOCTYPE_TEMPLATE,\n        DOCTYPE_TUTORIAL,\n      ];\n\n      if (\"type\" in req.body && !validDocTypes.includes(req.body.type)) {\n        const errMsg = \"Bad Request. 'type' key authorized values : \" +\n          `'${DOCTYPE_TEMPLATE}', '${DOCTYPE_TUTORIAL}' or ${DOCTYPE_NORMAL}`;\n        return res.status(400).send({ error: errMsg });\n      }\n\n      const { data, ...result } = await this._dbManager.updateDocument(getDocScope(req), req.body);\n\n      if (data && \"name\" in req.body) { this._logRenameDocumentEvents(req, data); }\n      return sendReply(req, res, { ...result, data: data?.current.id });\n    }));\n\n    // POST /api/docs/:did/unremove\n    // Recover the specified doc if it was previously soft-deleted and is\n    // still available.\n    this._app.post(\"/api/docs/:did/unremove\", expressWrap(async (req, res) => {\n      const { data } = await this._dbManager.undeleteDocument(getDocScope(req));\n      if (data) { this._logRestoreDocumentEvents(req, data); }\n      return sendOkReply(req, res);\n    }));\n\n    // PATCH /api/orgs/:oid/access\n    // Update the specified org acl rules.\n    this._app.patch(\"/api/orgs/:oid/access\", expressWrap(async (req, res) => {\n      const org = getOrgKey(req);\n      const delta = req.body.delta;\n      const { data, ...result } = await this._dbManager.updateOrgPermissions(getScope(req), org, delta);\n      if (data) { this._logChangeSiteAccessEvents(req as RequestWithLogin, data); }\n      return sendReply(req, res, result);\n    }));\n\n    // PATCH /api/workspaces/:wid/access\n    // Update the specified workspace acl rules.\n    this._app.patch(\"/api/workspaces/:wid/access\", expressWrap(async (req, res) => {\n      const workspaceId = integerParam(req.params.wid, \"wid\");\n      const delta = req.body.delta;\n      const { data, ...result } = await this._dbManager.updateWorkspacePermissions(getScope(req), workspaceId, delta);\n      if (data) { this._logChangeWorkspaceAccessEvents(req as RequestWithLogin, data); }\n      return sendReply(req, res, result);\n    }));\n\n    // GET /api/docs/:did\n    // Get information about a document.\n    this._app.get(\"/api/docs/:did\", expressWrap(async (req, res) => {\n      const query = await this._dbManager.getDoc(req);\n      return sendOkReply(req, res, query);\n    }));\n\n    // PATCH /api/docs/:did/access\n    // Update the specified doc acl rules.\n    this._app.patch(\"/api/docs/:did/access\", expressWrap(async (req, res) => {\n      const delta = req.body.delta;\n      const { data, ...result } = await this._dbManager.updateDocPermissions(getDocScope(req), delta);\n      if (data) { this._logChangeDocumentAccessEvents(req, data); }\n      this._logInvitedDocUserTelemetryEvents(req, delta);\n      return sendReply(req, res, result);\n    }));\n\n    // PATCH /api/docs/:did/move\n    // Move the doc to the workspace specified in the body.\n    this._app.patch(\"/api/docs/:did/move\", expressWrap(async (req, res) => {\n      const workspaceId = integerParam(req.body.workspace, \"workspace\");\n      const { data, ...result } = await this._dbManager.moveDoc(getDocScope(req), workspaceId);\n      if (data) { this._logMoveDocumentEvents(req, data); }\n      return sendReply(req, res, { ...result, data: data?.current.id });\n    }));\n\n    this._app.patch(\"/api/docs/:did/pin\", expressWrap(async (req, res) => {\n      const { data, ...result } = await this._dbManager.pinDoc(getDocScope(req), true);\n      if (data) { this._logPinDocumentEvents(req, data); }\n      return sendReply(req, res, result);\n    }));\n\n    this._app.patch(\"/api/docs/:did/unpin\", expressWrap(async (req, res) => {\n      const { data, ...result } = await this._dbManager.pinDoc(getDocScope(req), false);\n      if (data) { this._logUnpinDocumentEvents(req, data); }\n      return sendReply(req, res, result);\n    }));\n\n    // GET /api/orgs/:oid/access\n    // Get user access information regarding an org\n    this._app.get(\"/api/orgs/:oid/access\", expressWrap(async (req, res) => {\n      const org = getOrgKey(req);\n      const query = await this._withPrivilegedViewForUser(\n        org, req, scope => this._dbManager.getOrgAccess(scope, org),\n      );\n      return sendReply(req, res, query);\n    }));\n\n    // GET /api/workspaces/:wid/access\n    // Get user access information regarding a workspace\n    this._app.get(\"/api/workspaces/:wid/access\", expressWrap(async (req, res) => {\n      const workspaceId = integerParam(req.params.wid, \"wid\");\n      const query = await this._dbManager.getWorkspaceAccess(getScope(req), workspaceId);\n      return sendReply(req, res, query);\n    }));\n\n    // GET /api/docs/:did/access\n    // Get user access information regarding a doc\n    this._app.get(\"/api/docs/:did/access\", expressWrap(async (req, res) => {\n      const query = await this._dbManager.getDocAccess(getDocScope(req));\n      return sendReply(req, res, query);\n    }));\n\n    // GET /api/profile/user\n    // Get user's profile\n    this._app.get(\"/api/profile/user\", expressWrap(async (req, res) => {\n      const fullUser = await this._getFullUser(req);\n      return sendOkReply(req, res, fullUser, { allowedFields: new Set([\"allowGoogleLogin\"]) });\n    }));\n\n    // POST /api/profile/user/name\n    // Body params: string\n    // Update users profile.\n    this._app.post(\"/api/profile/user/name\", expressWrap(async (req, res) => {\n      const userId = getAuthorizedUserId(req);\n      if (!(req.body?.name)) {\n        throw new ApiError(\"Name expected in the body\", 400);\n      }\n      const name = req.body.name;\n      const { previous, current } = await this._dbManager.updateUser(userId, { name });\n      this._logChangeUserNameEvents(req, { previous, current });\n      res.sendStatus(200);\n    }));\n\n    // POST /api/profile/user/locale\n    // Body params: string\n    // Update users profile.\n    this._app.post(\"/api/profile/user/locale\", expressWrap(async (req, res) => {\n      const userId = getAuthorizedUserId(req);\n      await this._dbManager.updateUserOptions(userId, { locale: req.body.locale || null });\n      res.append(\"Set-Cookie\", cookie.serialize(\"grist_user_locale\", req.body.locale || \"\", {\n        httpOnly: false,    // make available to client-side scripts\n        domain: getCookieDomain(req),\n        path: \"/\",\n        secure: true,\n        maxAge: req.body.locale ? 31536000 : 0,\n        sameSite: \"None\", // there is no security concern to expose this information.\n      }));\n      res.sendStatus(200);\n    }));\n\n    // POST /api/profile/allowGoogleLogin\n    // Update user's preference for allowing Google login.\n    this._app.post(\"/api/profile/allowGoogleLogin\", expressWrap(async (req, res) => {\n      const userId = getAuthorizedUserId(req);\n      const fullUser = await this._getFullUser(req);\n      if (fullUser.loginMethod !== \"Email + Password\") {\n        throw new ApiError(\"Only users signed in via email can enable/disable Google login\", 401);\n      }\n\n      const allowGoogleLogin: boolean | undefined = req.body.allowGoogleLogin;\n      if (allowGoogleLogin === undefined) {\n        throw new ApiError(\"Missing body param: allowGoogleLogin\", 400);\n      }\n\n      await this._dbManager.updateUserOptions(userId, { allowGoogleLogin });\n      res.sendStatus(200);\n    }));\n\n    this._app.post(\"/api/profile/isConsultant\", expressWrap(async (req, res) => {\n      const userId = getAuthorizedUserId(req);\n      if (userId !== this._dbManager.getSupportUserId()) {\n        throw new ApiError(\"Only support user can enable/disable isConsultant\", 401);\n      }\n      const isConsultant: boolean | undefined = req.body.isConsultant;\n      const targetUserId: number | undefined = req.body.userId;\n      if (isConsultant === undefined) {\n        throw new ApiError(\"Missing body param: isConsultant\", 400);\n      }\n      if (targetUserId === undefined) {\n        throw new ApiError(\"Missing body param: targetUserId\", 400);\n      }\n      await this._dbManager.updateUserOptions(targetUserId, {\n        isConsultant,\n      });\n      res.sendStatus(200);\n    }));\n\n    this._app.post(\"/api/users/:userId/disable\", requireInstallAdmin, expressWrap(async (req, res) => {\n      await this._changeUserDisabledDate(req, new Date());\n      await sendOkReply(req, res);\n    }));\n\n    this._app.post(\"/api/users/:userId/enable\", requireInstallAdmin, expressWrap(async (req, res) => {\n      await this._changeUserDisabledDate(req, null);\n      await sendOkReply(req, res);\n    }));\n\n    // GET /api/profile/apikey\n    // Get user's apiKey\n    this._app.get(\"/api/profile/apikey\", expressWrap(async (req, res) => {\n      try {\n        const userId = getUserId(req);\n        const apiKey = await this._dbManager.getApiKey(userId);\n        res.status(200).send(apiKey);\n      } catch (e) {\n        throw new ApiError(e, 400);\n      }\n    }));\n\n    // POST /api/profile/apikey\n    // Update user's apiKey\n    this._app.post(\"/api/profile/apikey\", expressWrap(async (req, res) => {\n      const userId = getAuthorizedUserId(req);\n      const force = req.body ? req.body.force : false;\n      const user = await this._dbManager.createApiKey(userId, force);\n      this._logCreateUserAPIKeyEvents(req, user);\n      res.status(200).send(user.apiKey);\n    }));\n\n    // DELETE /api/profile/apiKey\n    // Delete apiKey\n    this._app.delete(\"/api/profile/apikey\", expressWrap(async (req, res) => {\n      const userId = getAuthorizedUserId(req);\n      try {\n        const user = await this._dbManager.deleteApiKey(userId);\n        this._logDeleteUserAPIKeyEvents(req, user);\n        res.sendStatus(200);\n      } catch (e) {\n        throw new ApiError(e, 400);\n      }\n    }));\n\n    // GET /api/session/access/active\n    // Returns active user and active org (if any)\n    this._app.get(\"/api/session/access/active\", expressWrap(async (req, res) => {\n      const fullUser = await this._getFullUser(req, { includePrefs: true });\n      const domain = getOrgFromRequest(req);\n      const org = domain ? (await this._withPrivilegedViewForUser(\n        domain, req, scope => this._dbManager.getOrg(scope, domain),\n      )) : null;\n      const orgError = (org?.errMessage) ? { error: org.errMessage, status: org.status } : undefined;\n      return sendOkReply(req, res, {\n        user: { ...fullUser,\n          helpScoutSignature: helpScoutSign(fullUser.email),\n          isInstallAdmin: await this._gristServer.getInstallAdmin().isAdminReq(req) || undefined,\n        },\n        org: (org?.data) || null,\n        orgError,\n      });\n    }));\n\n    // POST /api/session/access/active\n    // Body params: email (required)\n    // Body params: org (optional) - string subdomain or 'current', for which org's active user to modify.\n    // Sets active user for active org\n    this._app.post(\"/api/session/access/active\", expressWrap(async (req, res) => {\n      const mreq = req as RequestWithLogin;\n      let domain = optStringParam(req.body.org, \"org\");\n      if (!domain || domain === \"current\") {\n        domain = getOrgFromRequest(mreq) || \"\";\n      }\n      const email = req.body.email;\n      if (!email) { throw new ApiError(\"email required\", 400); }\n      try {\n        // Modify session copy in request. Will be saved to persistent storage before responding\n        // by express-session middleware.\n        linkOrgWithEmail(mreq.session, req.body.email, domain);\n        clearSessionCacheIfNeeded(req, { sessionID: mreq.sessionID });\n        return sendOkReply(req, res, { email });\n      } catch (e) {\n        throw new ApiError(\"email not available\", 403);\n      }\n    }));\n\n    // GET /api/session/access/all\n    // Returns all user profiles (with ids) and all orgs they can access.\n    // Flattens personal orgs into a single org.\n    this._app.get(\"/api/session/access/all\", expressWrap(async (req, res) => {\n      const domain = getOrgFromRequest(req);\n      const users = getUserProfiles(req);\n      const userId = getUserId(req);\n      const orgs = await this._dbManager.getMergedOrgs(userId, users, domain);\n      if (orgs.errMessage) { throw new ApiError(orgs.errMessage, orgs.status); }\n      return sendOkReply(req, res, {\n        users: await this._dbManager.completeProfiles(users),\n        orgs: orgs.data,\n      });\n    }));\n\n    // DELETE /users/:uid\n    // Delete the specified user, their personal organization, removing them from all groups.\n    // Not available to the anonymous user.\n    // TODO: should orphan orgs, inaccessible by anyone else, get deleted when last user\n    // leaves?\n    this._app.delete(\"/api/users/:uid\", expressWrap(async (req, res) => {\n      const userIdToDelete = parseInt(req.params.uid, 10);\n      if (!(req.body?.name !== undefined)) {\n        throw new ApiError(\"to confirm deletion of a user, provide their name\", 400);\n      }\n      const { data, ...result } = await this._dbManager.deleteUser(getScope(req), userIdToDelete, req.body.name);\n      if (data) { this._logDeleteUserEvents(req, data); }\n      return sendReply(req, res, result);\n    }));\n\n    if (isAffirmative(process.env.GRIST_ENABLE_SERVICE_ACCOUNTS)) {\n      // POST /service-accounts/\n      // Creates a new service account attached to the user making the api call.\n      this._app.post(\"/api/service-accounts\", validateStrict(PostServiceAccount), expressWrap(async (req, res) => {\n        const ownerId = getAuthorizedUserId(req);\n        const body = req.body as SATypes.PostServiceAccount;\n        const options = {\n          label: body.label,\n          description: body.description,\n          expiresAt: new Date(body.expiresAt),\n        };\n        const serviceAccount = await this._dbManager.createServiceAccount(\n          ownerId, options,\n        );\n        const resp: SATypes.ServiceAccountCreationResponse = {\n          id: serviceAccount.id,\n          login: serviceAccount.serviceUser.loginEmail!,\n          key: serviceAccount.serviceUser.apiKey!,\n          label: serviceAccount.label,\n          description: serviceAccount.description,\n          expiresAt: serviceAccount.expiresAt.toISOString(),\n          hasValidKey: true,\n        };\n\n        return sendOkReply(req, res, resp);\n      }));\n\n      // GET /service-accounts/\n      // Reads all service accounts attached to the user making the api call.\n      this._app.get(\"/api/service-accounts\", expressWrap(async (req, res) => {\n        const userId = getAuthorizedUserId(req);\n        const data = await this._dbManager.getOwnedServiceAccounts(userId);\n        const resp: Partial<SATypes.ServiceAccountApiResponse>[] = data.map((serviceAccount) => {\n          const hasValidKey = serviceAccount.serviceUser.apiKey !== null;\n          return {\n            id: serviceAccount.id,\n            login: serviceAccount.serviceUser.loginEmail!,\n            label: serviceAccount.label,\n            description: serviceAccount.description,\n            expiresAt: serviceAccount.expiresAt.toISOString(),\n            hasValidKey,\n          };\n        });\n        return sendOkReply(req, res, resp);\n      }));\n\n      // GET /service-accounts/:said\n      // Reads one particular service account of the user making the api call.\n      this._app.get(\"/api/service-accounts/:said\", expressWrap(async (req, res) => {\n        const userId = getAuthorizedUserId(req);\n        const serviceAccountId = parseInt(req.params.said);\n        const serviceAccount = await this._dbManager.getServiceAccount(serviceAccountId);\n        this._dbManager.assertServiceAccountExistingAndOwned(serviceAccount, userId);\n        const hasValidKey = serviceAccount.serviceUser.apiKey !== null;\n        const resp: Partial<SATypes.ServiceAccountApiResponse> = {\n          id: serviceAccount.id,\n          login: serviceAccount.serviceUser.loginEmail!,\n          label: serviceAccount.label,\n          description: serviceAccount.description,\n          expiresAt: serviceAccount.expiresAt.toISOString(),\n          hasValidKey,\n        };\n        return sendOkReply(req, res, resp);\n      }));\n\n      // PATCH /service-accounts/:said\n      // Modifies one particular service account of the user making the api call.\n      this._app.patch(\"/api/service-accounts/:said\", validateStrict(PatchServiceAccount), expressWrap(\n        async (req, res) => {\n          const userId = getAuthorizedUserId(req);\n          const serviceAccountId = parseInt(req.params.said);\n          const payload = req.body as SATypes.PatchServiceAccount;\n          const updateProps = {\n            ...(payload.label ? { label: payload.label } : {}),\n            ...(payload.description ? { description: payload.description } : {}),\n            ...(payload.expiresAt ? { expiresAt: new Date(payload.expiresAt) } : {}),\n            expiresAt: payload.expiresAt !== undefined ? new Date(payload.expiresAt) : undefined,\n          };\n\n          const resp = await this._dbManager.updateServiceAccount(\n            serviceAccountId, updateProps, { expectedOwnerId: userId },\n          );\n          if (!resp) {\n            throw new ApiError(`No such service account as \"${serviceAccountId}\"`, 404);\n          }\n          return sendOkReply(req, res);\n        }),\n      );\n\n      // DELETE /service-accounts/:said\n      // Deletes one particular service account of the user making the api call.\n      this._app.delete(\"/api/service-accounts/:said\", expressWrap(async (req, res) => {\n        const userId = getAuthorizedUserId(req);\n        const serviceAccountId = parseInt(req.params.said);\n        const resp = await this._dbManager.deleteServiceAccount(serviceAccountId, { expectedOwnerId: userId });\n        if (resp === null) {\n          throw new ApiError(`No such service account as \"${serviceAccountId}\"`, 404);\n        }\n        return sendOkReply(req, res);\n      }));\n\n      // POST /service-accounts/:said/apikey\n      // Regenerate and return the apikey of a given Service Account\n      this._app.post(\"/api/service-accounts/:said/apikey\", expressWrap(async (req, res) => {\n        const userId = getAuthorizedUserId(req);\n        const serviceAccountId = parseInt(req.params.said);\n        const serviceAccount = await this._dbManager.createServiceAccountApiKey(\n          serviceAccountId, { expectedOwnerId: userId },\n        );\n        if (serviceAccount === null) {\n          throw new ApiError(\n            `Can't regenerate api key of non existing service account ${serviceAccountId}`,\n            404,\n          );\n        }\n        const resp: SATypes.ServiceAccountCreationResponse = {\n          id: serviceAccount.id,\n          key: serviceAccount.serviceUser.apiKey!,\n          login: serviceAccount.serviceUser.loginEmail!,\n          label: serviceAccount.label,\n          description: serviceAccount.description,\n          expiresAt: serviceAccount.expiresAt.toISOString(),\n          hasValidKey: true,\n        };\n        return sendOkReply(req, res, resp);\n      }));\n\n      // DELETE /service-accounts/:said/apikey\n      // Deletes the apikey of a given Service Account by deleting the key\n      this._app.delete(\"/api/service-accounts/:said/apikey\", expressWrap(async (req, res) => {\n        const userId = getAuthorizedUserId(req);\n        const serviceAccountId = parseInt(req.params.said);\n        const serviceAccount = await this._dbManager.deleteServiceAccountApiKey(\n          serviceAccountId, { expectedOwnerId: userId },\n        );\n        if (serviceAccount == null) {\n          throw new ApiError(\n            `Can't delete api key of non existing service account ${serviceAccountId}`,\n            404,\n          );\n        }\n        return sendOkReply(req, res);\n      }));\n    }\n  }\n\n  private async _deleteOrg(req: Request, res: express.Response, name: string) {\n    const orgKey = getOrgKey(req);\n    const requirePermissions = Permissions.REMOVE | Permissions.SCHEMA_EDIT;\n    const org: Organization = this._dbManager.unwrapQueryResult(\n      await this._dbManager.getOrg(getScope(req), orgKey, undefined,\n        { requirePermissions }),\n    );\n    const okToDelete = ((org.domain && name === org.domain) ||\n      (org.name && name === org.name) ||\n      name === \"force-delete\");\n    if (!okToDelete) {\n      throw new ApiError(\"Name does not match organization\", 400);\n    }\n    const doom = await this._gristServer.getDoomTool();\n    try {\n      await doom.deleteOrg(org.id);\n      this._logDeleteSiteEvents(req, org);\n      return sendReply(req, res, { status: 200, data: org.id });\n    } catch (e) {\n      this._logDeleteSiteEvents(req, org, String(e));\n      throw e;\n    }\n  }\n\n  private async _getFullUser(req: Request, options: { includePrefs?: boolean } = {}): Promise<FullUser> {\n    const mreq = req as RequestWithLogin;\n    const userId = getUserId(mreq);\n    const user = await this._dbManager.getUser(userId, options);\n    if (!user) { throw new ApiError(\"unable to find user\", 400); }\n\n    const fullUser = this._dbManager.makeFullUser(user);\n    const domain = getOrgFromRequest(mreq);\n    const sessionUser = getSessionUser(mreq.session, domain || \"\", fullUser.email);\n    const loginMethod = sessionUser?.profile ? sessionUser.profile.loginMethod : undefined;\n    const allowGoogleLogin = user.options?.allowGoogleLogin ?? true;\n    return { ...fullUser, loginMethod, allowGoogleLogin };\n  }\n\n  /**\n   * Run a query, and, if it is denied and the user is the support or admin\n   * user, rerun the query with permission to view the current\n   * org. This is a bit inefficient, but only affects the support/admin\n   * user. We wait to add the special permission only if needed, since\n   * it will in fact override any other access the special user has\n   * been granted, which could reduce their apparent access if that is\n   * part of what is returned by the query.\n   */\n  private async _withPrivilegedViewForUser<T>(\n    org: string | number, req: express.Request,\n    op: (scope: Scope) => Promise<QueryResult<T>>,\n  ): Promise<QueryResult<T>> {\n    const scope = getScope(req);\n    const userId = getUserId(req);\n    const result = await op(scope);\n\n    if (result.status === 200) {\n      return result;\n    }\n\n    if (userId === this._dbManager.getSupportUserId() ||\n      await this._gristServer.getInstallAdmin()?.isAdminReq(req)) {\n      const extendedScope: Scope = { ...scope, specialPermit: { org } };\n      return await op(extendedScope);\n    }\n    return result;\n  }\n\n  private async _changeUserDisabledDate(req: express.Request, disabledAt: Date | null) {\n    const mreq = req as RequestWithLogin;\n    const userId = mreq.userId;\n    const targetUserId = integerParam(req.params.userId, \"userId\");\n    if (targetUserId === userId) {\n      throw new ApiError(\"you cannot disable yourself\", 400);\n    }\n    await this._dbManager.updateUser(targetUserId, { disabledAt });\n  }\n\n  private async _hardDeleteWorkspace(req: Request, wsId: number) {\n    const ws = this._dbManager.unwrapQueryResult(\n      await this._dbManager.getWorkspace(\n        {\n          ...getScope(req),\n          showAll: true,  // fine to hard-delete a soft-deleted workspace\n        },\n        wsId, undefined,\n        {\n          requirePermissions: Permissions.REMOVE | Permissions.SCHEMA_EDIT,\n        }),\n    );\n    try {\n      const doom = await this._gristServer.getDoomTool();\n      await doom.deleteWorkspace(ws.id);\n      this._logDeleteWorkspaceEvents(req, ws);\n    } catch (error) {\n      this._logDeleteWorkspaceEvents(req, ws, error);\n      throw error;\n    }\n    return ws;\n  }\n\n  private _logCreateDocumentEvents(req: Request, document: Document) {\n    const mreq = req as RequestWithLogin;\n    this._gristServer.getAuditLogger().logEvent(mreq, {\n      action: \"document.create\",\n      context: {\n        site: pick(document.workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        document: {\n          ...pick(document, \"id\", \"name\"),\n          workspace: pick(document.workspace, \"id\", \"name\"),\n        },\n      },\n    });\n    this._gristServer.getTelemetry().logEvent(mreq, \"documentCreated\", {\n      limited: {\n        docIdDigest: document.id,\n        sourceDocIdDigest: undefined,\n        isImport: false,\n        fileType: undefined,\n        isSaved: true,\n      },\n      full: {\n        userId: mreq.userId,\n        altSessionId: mreq.altSessionId,\n      },\n    });\n    this._gristServer.getTelemetry().logEvent(mreq, \"createdDoc-Empty\", {\n      full: {\n        docIdDigest: document.id,\n        userId: mreq.userId,\n        altSessionId: mreq.altSessionId,\n      },\n    });\n  }\n\n  private _logRenameDocumentEvents(\n    req: Request,\n    { previous, current }: PreviousAndCurrent<Document>,\n  ) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"document.rename\",\n      context: {\n        site: pick(current.workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        previous: {\n          document: pick(previous, \"id\", \"name\"),\n        },\n        current: {\n          document: pick(current, \"id\", \"name\"),\n        },\n      },\n    });\n  }\n\n  private _logRestoreDocumentEvents(req: Request, document: Document) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"document.restore_from_trash\",\n      context: {\n        site: pick(document.workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        document: {\n          ...pick(document, \"id\", \"name\"),\n          workspace: pick(document.workspace, \"id\", \"name\"),\n        },\n      },\n    });\n  }\n\n  private _logChangeDocumentAccessEvents(\n    req: Request,\n    {\n      document,\n      accessChanges: { publicAccess, maxInheritedAccess, users },\n    }: DocumentAccessChanges,\n  ) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"document.change_access\",\n      context: {\n        site: pick(document.workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        document: pick(document, \"id\", \"name\"),\n        access_changes: {\n          public_access: publicAccess,\n          max_inherited_access: maxInheritedAccess,\n          users,\n        },\n      },\n    });\n  }\n\n  private _logInvitedDocUserTelemetryEvents(req: Request, delta: PermissionDelta) {\n    if (!delta.users) { return; }\n\n    const mreq = req as RequestWithLogin;\n    const numInvitedUsersByAccess: Record<BasicRole, number> = {\n      viewers: 0,\n      editors: 0,\n      owners: 0,\n    };\n    for (const [email, access] of Object.entries(delta.users)) {\n      if (email === \"everyone@getgrist.com\") { continue; }\n      if (access === null || access === \"members\") { continue; }\n\n      numInvitedUsersByAccess[access] += 1;\n    }\n    for (const [access, count] of Object.entries(numInvitedUsersByAccess)) {\n      if (count === 0) { continue; }\n\n      this._gristServer.getTelemetry().logEvent(mreq, \"invitedDocUser\", {\n        full: {\n          access,\n          count,\n          userId: mreq.userId,\n        },\n      });\n    }\n\n    const publicAccess = delta.users[\"everyone@getgrist.com\"];\n    if (publicAccess !== undefined) {\n      this._gristServer.getTelemetry().logEvent(\n        mreq,\n        publicAccess ? \"madeDocPublic\" : \"madeDocPrivate\",\n        {\n          full: {\n            ...(publicAccess ? { access: publicAccess } : {}),\n            userId: mreq.userId,\n          },\n        },\n      );\n    }\n  }\n\n  private _logMoveDocumentEvents(\n    req: Request,\n    { previous, current }: PreviousAndCurrent<Document>,\n  ) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"document.move\",\n      context: {\n        site: pick(current.workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        previous: {\n          document: {\n            ...pick(previous, \"id\", \"name\"),\n            workspace: pick(previous.workspace, \"id\", \"name\"),\n          },\n        },\n        current: {\n          document: {\n            ...pick(current, \"id\", \"name\"),\n            workspace: pick(current.workspace, \"id\", \"name\"),\n          },\n        },\n      },\n    });\n  }\n\n  private _logPinDocumentEvents(req: Request, document: Document) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"document.pin\",\n      context: {\n        site: pick(document.workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        document: pick(document, \"id\", \"name\"),\n      },\n    });\n  }\n\n  private _logUnpinDocumentEvents(req: Request, document: Document) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"document.unpin\",\n      context: {\n        site: pick(document.workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        document: pick(document, \"id\", \"name\"),\n      },\n    });\n  }\n\n  private _logCreateWorkspaceEvents(req: Request, workspace: Workspace) {\n    const mreq = req as RequestWithLogin;\n    this._gristServer.getAuditLogger().logEvent(mreq, {\n      action: \"workspace.create\",\n      context: {\n        site: pick(workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        workspace: pick(workspace, \"id\", \"name\"),\n      },\n    });\n    this._gristServer.getTelemetry().logEvent(mreq, \"createdWorkspace\", {\n      full: {\n        workspaceId: workspace.id,\n        userId: mreq.userId,\n      },\n    });\n  }\n\n  private _logRenameWorkspaceEvents(\n    req: Request,\n    { previous, current }: PreviousAndCurrent<Workspace>,\n  ) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"workspace.rename\",\n      context: {\n        site: pick(current.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        previous: {\n          workspace: pick(previous, \"id\", \"name\"),\n        },\n        current: {\n          workspace: pick(current, \"id\", \"name\"),\n        },\n      },\n    });\n  }\n\n  private _logRemoveWorkspaceEvents(req: Request, workspace: Workspace) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"workspace.move_to_trash\",\n      context: {\n        site: pick(workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        workspace: pick(workspace, \"id\", \"name\"),\n      },\n    });\n  }\n\n  private _logDeleteWorkspaceEvents(req: Request, workspace: Workspace, error?: string) {\n    const mreq = req as RequestWithLogin;\n    this._gristServer.getAuditLogger().logEvent(mreq, {\n      action: \"workspace.delete\",\n      context: {\n        site: pick(workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        workspace: pick(workspace, \"id\", \"name\"),\n        error,\n      },\n    });\n    this._gristServer.getTelemetry().logEvent(mreq, \"deletedWorkspace\", {\n      full: {\n        workspaceId: workspace.id,\n        userId: mreq.userId,\n      },\n    });\n  }\n\n  private _logRestoreWorkspaceEvents(req: Request, workspace: Workspace) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"workspace.restore_from_trash\",\n      context: {\n        site: pick(workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        workspace: pick(workspace, \"id\", \"name\"),\n      },\n    });\n  }\n\n  private _logChangeWorkspaceAccessEvents(\n    req: RequestWithLogin,\n    {\n      workspace,\n      accessChanges: { maxInheritedAccess, users },\n    }: WorkspaceAccessChanges,\n  ) {\n    this._gristServer.getAuditLogger().logEvent(req, {\n      action: \"workspace.change_access\",\n      context: {\n        site: pick(workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        workspace: pick(workspace, \"id\", \"name\"),\n        access_changes: {\n          max_inherited_access: maxInheritedAccess,\n          users,\n        },\n      },\n    });\n  }\n\n  private _logCreateSiteEvents(req: Request, org: Organization) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"site.create\",\n      details: {\n        site: pick(org, \"id\", \"name\", \"domain\"),\n      },\n    });\n  }\n\n  private _logRenameSiteEvents(\n    req: Request,\n    { previous, current }: PreviousAndCurrent<Organization>,\n  ) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"site.rename\",\n      context: {\n        site: pick(current, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        previous: {\n          site: pick(previous, \"id\", \"name\", \"domain\"),\n        },\n        current: {\n          site: pick(current, \"id\", \"name\", \"domain\"),\n        },\n      },\n    });\n  }\n\n  private _logDeleteSiteEvents(req: Request, org: Organization,\n    error?: string) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"site.delete\",\n      details: {\n        site: pick(org, \"id\", \"name\", \"domain\"),\n        error,\n      },\n    });\n  }\n\n  private _logChangeSiteAccessEvents(\n    req: RequestWithLogin,\n    { org, accessChanges: { users } }: OrgAccessChanges,\n  ) {\n    this._gristServer.getAuditLogger().logEvent(req, {\n      action: \"site.change_access\",\n      context: {\n        site: pick(org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        site: pick(org, \"id\", \"name\", \"domain\"),\n        access_changes: {\n          users,\n        },\n      },\n    });\n  }\n\n  private _logChangeUserNameEvents(\n    req: Request,\n    { previous, current }: PreviousAndCurrent<User>,\n  ) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"user.change_name\",\n      details: {\n        previous: {\n          user: {\n            ...pick(previous, \"id\", \"name\"),\n            email: previous.loginEmail,\n          },\n        },\n        current: {\n          user: {\n            ...pick(current, \"id\", \"name\"),\n            email: current.loginEmail,\n          },\n        },\n      },\n    });\n  }\n\n  private _logCreateUserAPIKeyEvents(req: Request, user: User) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"user.create_api_key\",\n      details: {\n        user: {\n          ...pick(user, \"id\", \"name\"),\n          email: user.loginEmail,\n        },\n      },\n    });\n  }\n\n  private _logDeleteUserAPIKeyEvents(req: Request, user: User) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"user.delete_api_key\",\n      details: {\n        user: {\n          ...pick(user, \"id\", \"name\"),\n          email: user.loginEmail,\n        },\n      },\n    });\n  }\n\n  private _logDeleteUserEvents(req: Request, user: User) {\n    this._gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"user.delete\",\n      details: {\n        user: {\n          ...pick(user, \"id\", \"name\"),\n          email: user.loginEmail,\n        },\n      },\n    });\n  }\n}\n\n/**\n * Throw the error for when a user has been deleted since point of call (very unlikely to happen).\n */\nfunction handleDeletedUser(): never {\n  throw new ApiError(\"user not known\", 401);\n}\n"
  },
  {
    "path": "app/gen-server/entity/AclRule.ts",
    "content": "import { Document } from \"app/gen-server/entity/Document\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\n\nimport { BaseEntity, ChildEntity, Column, Entity, JoinColumn, ManyToOne, OneToOne,\n  PrimaryGeneratedColumn, RelationId, TableInheritance } from \"typeorm\";\n\n@Entity(\"acl_rules\")\n@TableInheritance({ column: { type: \"int\", name: \"type\" } })\nexport class AclRule extends BaseEntity {\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @Column({ type: Number })\n  public permissions: number;\n\n  @OneToOne(type => Group, group => group.aclRule)\n  @JoinColumn({ name: \"group_id\" })\n  public group: Group;\n}\n\n@ChildEntity()\nexport class AclRuleWs extends AclRule {\n  @ManyToOne(type => Workspace, workspace => workspace.aclRules)\n  @JoinColumn({ name: \"workspace_id\" })\n  public workspace: Workspace;\n\n  @RelationId((aclRule: AclRuleWs) => aclRule.workspace)\n  public workspaceId: number;\n}\n\n@ChildEntity()\nexport class AclRuleOrg extends AclRule {\n  @ManyToOne(type => Organization, organization => organization.aclRules)\n  @JoinColumn({ name: \"org_id\" })\n  public organization: Organization;\n\n  @RelationId((aclRule: AclRuleOrg) => aclRule.organization)\n  public orgId: number;\n}\n\n@ChildEntity()\nexport class AclRuleDoc extends AclRule {\n  @ManyToOne(type => Document, document => document.aclRules)\n  @JoinColumn({ name: \"doc_id\" })\n  public document: Document;\n\n  @RelationId((aclRule: AclRuleDoc) => aclRule.document)\n  public docId: string;\n}\n"
  },
  {
    "path": "app/gen-server/entity/Activation.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { isEmail, isNonNullish } from \"app/common/gutil\";\nimport { InstallPrefs } from \"app/common/Install\";\nimport { InstallProperties, installPropertyKeys } from \"app/common/InstallAPI\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { BaseEntity, Column, Entity, PrimaryColumn } from \"typeorm\";\n\n@Entity({ name: \"activations\" })\nexport class Activation extends BaseEntity {\n  @PrimaryColumn()\n  public id: string;\n\n  @Column({ name: \"key\", type: \"text\", nullable: true })\n  public key: string | null;\n\n  @Column({ type: nativeValues.jsonEntityType, nullable: true })\n  public prefs: InstallPrefs | null;\n\n  @Column({ name: \"created_at\", default: () => \"CURRENT_TIMESTAMP\" })\n  public createdAt: Date;\n\n  @Column({ name: \"updated_at\", default: () => \"CURRENT_TIMESTAMP\" })\n  public updatedAt: Date;\n\n  // When the enterprise activation was first enabled, so we know when\n  // to start counting the trial date.\n  //\n  // Activations are created at Grist installation to track other\n  // things such as prefs, but the user might not enable Enterprise\n  // until later.\n  @Column({ name: \"enabled_at\", type: nativeValues.dateTimeType, nullable: true })\n  public enabledAt: Date | null;\n\n  // When this installation entered into grace period, due to key expiration or limits exceeded.\n  @Column({ name: \"grace_period_start\", type: nativeValues.dateTimeType, nullable: true })\n  public gracePeriodStart: Date | null;\n\n  public checkProperties(props: any): props is Partial<InstallProperties> {\n    for (const key of Object.keys(props)) {\n      if (!installPropertyKeys.includes(key)) {\n        throw new ApiError(`Unrecognized property ${key}`, 400);\n      }\n    }\n\n    const assertIsEmailOrNullish = (key: keyof InstallPrefs) => {\n      const value = props.prefs?.[key];\n      if (\n        isNonNullish(value) &&\n        !(typeof value === \"string\" && isEmail(value))\n      ) {\n        throw new ApiError(`Invalid ${key}: \"${value}\"`, 400);\n      }\n    };\n\n    assertIsEmailOrNullish(\"onRestartSetAdminEmail\");\n    assertIsEmailOrNullish(\"onRestartReplaceEmailWithAdmin\");\n\n    return true;\n  }\n\n  public updateFromProperties(props: Partial<InstallProperties>) {\n    if (props.prefs === undefined) { return; }\n\n    if (props.prefs === null) {\n      this.prefs = null;\n    } else {\n      this.prefs = this.prefs || {};\n      if (props.prefs.telemetry !== undefined) {\n        this.prefs.telemetry = this.prefs.telemetry || {};\n        if (props.prefs.telemetry.telemetryLevel !== undefined) {\n          this.prefs.telemetry.telemetryLevel = props.prefs.telemetry.telemetryLevel;\n        }\n      }\n\n      if (props.prefs.checkForLatestVersion !== undefined) {\n        this.prefs.checkForLatestVersion = props.prefs.checkForLatestVersion;\n      }\n\n      if (props.prefs.onRestartSetAdminEmail !== undefined) {\n        this.prefs.onRestartSetAdminEmail = props.prefs.onRestartSetAdminEmail;\n      }\n\n      if (props.prefs.onRestartReplaceEmailWithAdmin !== undefined) {\n        this.prefs.onRestartReplaceEmailWithAdmin = props.prefs.onRestartReplaceEmailWithAdmin;\n      }\n\n      if (props.prefs.onRestartClearSessions !== undefined) {\n        this.prefs.onRestartClearSessions = props.prefs.onRestartClearSessions;\n      }\n\n      for (const key of Object.keys(this.prefs) as (keyof InstallPrefs)[]) {\n        if (this.prefs[key] === null) {\n          delete this.prefs[key];\n        }\n      }\n\n      if (Object.keys(this.prefs).length === 0) {\n        this.prefs = null;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/gen-server/entity/Alias.ts",
    "content": "import { Document } from \"app/gen-server/entity/Document\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\n\nimport { BaseEntity, Column, CreateDateColumn, Entity, JoinColumn, ManyToOne,\n  PrimaryColumn } from \"typeorm\";\n\n@Entity({ name: \"aliases\" })\nexport class Alias extends BaseEntity {\n  @PrimaryColumn({ name: \"org_id\", type: Number })\n  public orgId: number;\n\n  @PrimaryColumn({ name: \"url_id\", type: String })\n  public urlId: string;\n\n  @Column({ name: \"doc_id\", type: String })\n  public docId: string;\n\n  @ManyToOne(type => Document)\n  @JoinColumn({ name: \"doc_id\" })\n  public doc: Document;\n\n  @ManyToOne(type => Organization)\n  @JoinColumn({ name: \"org_id\" })\n  public org: Organization;\n\n  @CreateDateColumn({ name: \"created_at\" })\n  public createdAt: Date;\n}\n"
  },
  {
    "path": "app/gen-server/entity/BillingAccount.ts",
    "content": "import { Features, mergedFeatures } from \"app/common/Features\";\nimport { BillingAccountManager } from \"app/gen-server/entity/BillingAccountManager\";\nimport { Limit } from \"app/gen-server/entity/Limit\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Product } from \"app/gen-server/entity/Product\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { BaseEntity, Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from \"typeorm\";\n\n// This type is for billing account status information.  Intended for stuff\n// like \"free trial running out in N days\".\nexport interface BillingAccountStatus {\n  stripeStatus?: string;\n  currentPeriodStart?: string;\n  currentPeriodEnd?: string;\n  message?: string;\n}\n\n// A structure for billing options relevant to an external authority, for sites\n// created outside of Grist's regular billing flow.\nexport interface ExternalBillingOptions {\n  authority: string;   // The name of the external authority.\n  invoiceId?: string;  // An id of an invoice or other external billing context.\n}\n\n/**\n * This relates organizations to products.  It holds any stripe information\n * needed to be able to update and pay for the product that applies to the\n * organization.  It has a list of managers detailing which users have the\n * right to view and edit these settings.\n */\n@Entity({ name: \"billing_accounts\" })\nexport class BillingAccount extends BaseEntity {\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @ManyToOne(type => Product)\n  @JoinColumn({ name: \"product_id\" })\n  public product: Product;\n\n  @Column({ type: nativeValues.jsonEntityType, nullable: true })\n  public features: Features | null;\n\n  @Column({ type: Boolean })\n  public individual: boolean;\n\n  // A flag for when all is well with the user's subscription.\n  // Probably shouldn't use this to drive whether service is provided or not.\n  // Strip recommends updating an end-of-service datetime every time payment\n  // is received, adding on a grace period of some days.\n  @Column({ name: \"in_good_standing\", type: Boolean, default: nativeValues.trueValue })\n  public inGoodStanding: boolean;\n\n  @Column({ type: nativeValues.jsonEntityType, nullable: true })\n  public status: BillingAccountStatus;\n\n  @Column({ name: \"stripe_customer_id\", type: String, nullable: true })\n  public stripeCustomerId: string | null;\n\n  @Column({ name: \"stripe_subscription_id\", type: String, nullable: true })\n  public stripeSubscriptionId: string | null;\n\n  @Column({ name: \"stripe_plan_id\", type: String, nullable: true })\n  public stripePlanId: string | null;\n\n  @Column({ name: \"payment_link\", type: String, nullable: true })\n  public paymentLink: string | null;\n\n  @Column({ name: \"external_id\", type: String, nullable: true })\n  public externalId: string | null;\n\n  @Column({ name: \"external_options\", type: nativeValues.jsonEntityType, nullable: true })\n  public externalOptions: ExternalBillingOptions | null;\n\n  @OneToMany(type => BillingAccountManager, manager => manager.billingAccount)\n  public managers: BillingAccountManager[];\n\n  // Only one billing account per organization.\n  @OneToMany(type => Organization, org => org.billingAccount)\n  public orgs: Organization[];\n\n  @OneToMany(type => Limit, limit => limit.billingAccount)\n  public limits: Limit[];\n\n  // A calculated column that is true if it looks like there is a paid plan.\n  @Column({ name: \"paid\", type: \"boolean\", insert: false, select: false })\n  public paid?: boolean;\n\n  // A calculated column summarizing whether active user is a manager of the billing account.\n  // (No @Column needed since calculation is done in javascript not sql)\n  public isManager?: boolean;\n\n  public getFeatures(): Features {\n    return mergedFeatures(this.features, this.product?.features) ?? {};\n  }\n}\n"
  },
  {
    "path": "app/gen-server/entity/BillingAccountManager.ts",
    "content": "import { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { User } from \"app/gen-server/entity/User\";\n\nimport { BaseEntity, Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from \"typeorm\";\n\n/**\n * A list of users with the right to modify a giving billing account.\n */\n@Entity({ name: \"billing_account_managers\" })\nexport class BillingAccountManager extends BaseEntity {\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @Column({ name: \"billing_account_id\", type: Number })\n  public billingAccountId: number;\n\n  @ManyToOne(type => BillingAccount, { onDelete: \"CASCADE\" })\n  @JoinColumn({ name: \"billing_account_id\" })\n  public billingAccount: BillingAccount;\n\n  @Column({ name: \"user_id\", type: Number })\n  public userId: number;\n\n  @ManyToOne(type => User, { onDelete: \"CASCADE\" })\n  @JoinColumn({ name: \"user_id\" })\n  public user: User;\n}\n"
  },
  {
    "path": "app/gen-server/entity/Config.ts",
    "content": "import { ConfigKey, ConfigValue } from \"app/common/Config\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport {\n  BaseEntity,\n  Column,\n  CreateDateColumn,\n  Entity,\n  JoinColumn,\n  ManyToOne,\n  PrimaryGeneratedColumn,\n  UpdateDateColumn,\n} from \"typeorm\";\n\n@Entity({ name: \"configs\" })\nexport class Config extends BaseEntity {\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @ManyToOne(() => Organization, { nullable: true })\n  @JoinColumn({ name: \"org_id\" })\n  public org: Organization | null;\n\n  @Column({ type: String })\n  public key: ConfigKey;\n\n  @Column({ type: nativeValues.jsonEntityType })\n  public value: ConfigValue;\n\n  @CreateDateColumn({\n    name: \"created_at\",\n    type: Date,\n    default: () => \"CURRENT_TIMESTAMP\",\n  })\n  public createdAt: Date;\n\n  @UpdateDateColumn({\n    name: \"updated_at\",\n    type: Date,\n    default: () => \"CURRENT_TIMESTAMP\",\n  })\n  public updatedAt: Date;\n}\n"
  },
  {
    "path": "app/gen-server/entity/DocPref.ts",
    "content": "import { DocPrefs } from \"app/common/Prefs\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from \"typeorm\";\n\n@Entity({ name: \"doc_prefs\" })\nexport class DocPref {\n  // This table stores per-document preferences that don't belong outside the document.\n  // A record with userId of null contains the default preferences for the document.\n  // A record with a set userId contains overrides for that user.\n\n  @PrimaryColumn({ name: \"doc_id\", type: String })\n  public docId: string;\n\n  @PrimaryColumn({ name: \"user_id\", type: Number })\n  public userId: number | null;\n\n  @ManyToOne(type => Document)\n  @JoinColumn({ name: \"doc_id\" })\n  public doc: Document;\n\n  @ManyToOne(type => User)\n  @JoinColumn({ name: \"user_id\" })\n  public user?: User;\n\n  // Finally, the actual preferences, in JSON.\n  @Column({ type: nativeValues.jsonEntityType })\n  public prefs: DocPrefs;\n}\n"
  },
  {
    "path": "app/gen-server/entity/Document.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { DocumentUsage } from \"app/common/DocUsage\";\nimport { Role } from \"app/common/roles\";\nimport { DocumentOptions, DocumentProperties, documentPropertyKeys, DocumentType,\n  NEW_DOCUMENT_CODE } from \"app/common/UserAPI\";\nimport { AclRuleDoc } from \"app/gen-server/entity/AclRule\";\nimport { Alias } from \"app/gen-server/entity/Alias\";\nimport { Resource } from \"app/gen-server/entity/Resource\";\nimport { Secret } from \"app/gen-server/entity/Secret\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn } from \"typeorm\";\n\n// Acceptable ids for use in document urls.\nconst urlIdRegex = /^[-a-z0-9]+$/i;\n\nfunction isValidUrlId(urlId: string) {\n  if (urlId === NEW_DOCUMENT_CODE) { return false; }\n  return urlIdRegex.exec(urlId);\n}\n\n@Entity({ name: \"docs\" })\nexport class Document extends Resource {\n  @PrimaryColumn({ type: String })\n  public id: string;\n\n  @ManyToOne(type => Workspace)\n  @JoinColumn({ name: \"workspace_id\" })\n  public workspace: Workspace;\n\n  @OneToMany(type => AclRuleDoc, aclRule => aclRule.document)\n  public aclRules: AclRuleDoc[];\n\n  // Indicates whether the doc is pinned to the org it lives in.\n  @Column({ name: \"is_pinned\", type: Boolean, default: false })\n  public isPinned: boolean;\n\n  // Property that may be returned when the doc is fetched to indicate the access the\n  // fetching user has on the doc, i.e. 'owners', 'editors', 'viewers'\n  public access: Role | null;\n\n  // Property that may be returned when the doc is fetched to indicate the share it\n  // is being accessed with. The identifier used is the linkId, which is the share\n  // identifier that is the same between the home database and the document.\n  // The linkId is not a secret, and need only be unique within a document.\n  public linkId?: string | null;\n\n  // Property set for forks, containing access the fetching user has on the trunk.\n  public trunkAccess?: Role | null;\n\n  // a computed column with permissions.\n  // {insert: false} makes sure typeorm doesn't try to put values into such\n  // a column when creating documents.\n  @Column({ name: \"permissions\", type: \"text\", select: false, insert: false, update: false })\n  public permissions?: any;\n\n  @Column({ name: \"url_id\", type: \"text\", nullable: true })\n  public urlId: string | null;\n\n  @Column({ name: \"removed_at\", type: nativeValues.dateTimeType, nullable: true })\n  public removedAt: Date | null;\n\n  @Column({ name: \"disabled_at\", type: nativeValues.dateTimeType, nullable: true })\n  public disabledAt: Date | null;\n\n  @Column({ name: \"grace_period_start\", type: nativeValues.dateTimeType, nullable: true })\n  public gracePeriodStart: Date | null;\n\n  @OneToMany(type => Alias, alias => alias.doc)\n  public aliases: Alias[];\n\n  @Column({ name: \"options\", type: nativeValues.jsonEntityType, nullable: true })\n  public options: DocumentOptions | null;\n\n  @OneToMany(_type => Secret, secret => secret.doc)\n  public secrets: Secret[];\n\n  @Column({ name: \"usage\", type: nativeValues.jsonEntityType, nullable: true })\n  public usage: DocumentUsage | null;\n\n  @Column({ name: \"created_by\", type: \"integer\", nullable: true })\n  public createdBy: number | null;\n\n  @ManyToOne(_type => User)\n  @JoinColumn({ name: \"created_by\" })\n  public creator: User;\n\n  @Column({ name: \"trunk_id\", type: \"text\", nullable: true })\n  public trunkId: string | null;\n\n  @ManyToOne(_type => Document, document => document.forks)\n  @JoinColumn({ name: \"trunk_id\" })\n  public trunk: Document | null;\n\n  @OneToMany(_type => Document, document => document.trunk)\n  public forks: Document[];\n\n  @Column({ name: \"type\", type: \"text\", nullable: true })\n  public type: DocumentType | null;\n\n  public checkProperties(props: any): props is Partial<DocumentProperties> {\n    return super.checkProperties(props, documentPropertyKeys);\n  }\n\n  // Note that `removedAt` and `disabledAt` are currently set by\n  // HomeDBManager because their modification requires checks and\n  // modifications on other entities such as user limits or guests of\n  // associated workspace or org.\n  //\n  // These two properties are ignored by this method.\n  public updateFromProperties(props: Partial<DocumentProperties>) {\n    super.updateFromProperties(props);\n    if (props.isPinned !== undefined) { this.isPinned = props.isPinned; }\n    if (props.urlId !== undefined) {\n      if (props.urlId !== null && !isValidUrlId(props.urlId)) {\n        throw new ApiError(\"invalid urlId\", 400);\n      }\n      this.urlId = props.urlId;\n    }\n    if (props.type !== undefined) { this.type = props.type; }\n    if (props.options !== undefined) {\n      // Options are merged over the existing state - unless options\n      // object is set to \"null\", in which case the state is wiped\n      // completely.\n      if (props.options === null) {\n        this.options = null;\n      } else {\n        this.options = this.options || {};\n        if (props.options.description !== undefined) {\n          this.options.description = props.options.description;\n        }\n        if (props.options.openMode !== undefined) {\n          this.options.openMode = props.options.openMode;\n        }\n        if (props.options.icon !== undefined) {\n          this.options.icon = sanitizeIcon(props.options.icon);\n        }\n        if (props.options.externalId !== undefined) {\n          this.options.externalId = props.options.externalId;\n        }\n        if (props.options.tutorial !== undefined) {\n          // Tutorial metadata is merged over the existing state - unless\n          // metadata is set to \"null\", in which case the state is wiped\n          // completely.\n          if (props.options.tutorial === null) {\n            this.options.tutorial = null;\n          } else {\n            this.options.tutorial = this.options.tutorial || {};\n            if (props.options.tutorial.lastSlideIndex !== undefined) {\n              this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;\n            }\n            if (props.options.tutorial.percentComplete !== undefined) {\n              this.options.tutorial.percentComplete = props.options.tutorial.percentComplete;\n            }\n          }\n        }\n        if (props.options.appearance !== undefined) {\n          if (props.options.appearance === null) {\n            this.options.appearance = null;\n          } else {\n            this.options.appearance = this.options.appearance || {};\n            if (props.options.appearance.icon !== undefined) {\n              this.options.appearance.icon = props.options.appearance.icon;\n            }\n          }\n        }\n        if (props.options.proposedChanges !== undefined) {\n          if (props.options.proposedChanges === null) {\n            this.options.proposedChanges = null;\n          } else {\n            this.options.proposedChanges = this.options.proposedChanges || {};\n            // Merge individual properties, once there are more than one, following\n            // the example of appearance and tutorial above.\n            // TODO: add a helper? Seems a common pattern now.\n            if (props.options.proposedChanges.mayHaveProposals !== undefined) {\n              this.options.proposedChanges.mayHaveProposals = props.options.proposedChanges.mayHaveProposals;\n            }\n            if (props.options.proposedChanges.acceptProposals !== undefined) {\n              this.options.proposedChanges.acceptProposals = props.options.proposedChanges.acceptProposals;\n            }\n          }\n        }\n        if (props.options.allowIndex !== undefined) {\n          this.options.allowIndex = props.options.allowIndex;\n        }\n        // Normalize so that null equates with absence.\n        for (const key of Object.keys(this.options) as (keyof DocumentOptions)[]) {\n          if (this.options[key] === null) {\n            delete this.options[key];\n          }\n        }\n        // Normalize so that no options set equates with absense.\n        if (Object.keys(this.options).length === 0) {\n          this.options = null;\n        }\n      }\n    }\n  }\n}\n\n/**\n * In a query optimization, we filter docs in a CTE. There's no\n * direct way to tell TypeORM that this filtered version of docs\n * has the same structure as the table. So we create a entity\n * with a distinct name for this purpose. Does not exist in the\n * database.\n */\n@Entity({ name: \"filtered_docs\" })\nexport class FilteredDocument extends Document {\n}\n\n// Check that icon points to an expected location.  This will definitely\n// need changing, it is just a placeholder as the icon feature is developed.\nfunction sanitizeIcon(icon: string | null) {\n  if (icon === null) { return icon; }\n  const url = new URL(icon);\n  if (url.protocol !== \"https:\" || url.host !== \"grist-static.com\" || !url.pathname.startsWith(\"/icons/\")) {\n    throw new ApiError(\"invalid document icon\", 400);\n  }\n  return url.href;\n}\n"
  },
  {
    "path": "app/gen-server/entity/Group.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { AclRule } from \"app/gen-server/entity/AclRule\";\nimport { User } from \"app/gen-server/entity/User\";\n\nimport { BaseEntity, BeforeInsert, BeforeUpdate, Column, Entity, JoinTable, ManyToMany,\n  OneToOne, PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity({ name: \"groups\" })\nexport class Group extends BaseEntity {\n  public static readonly ROLE_TYPE = \"role\";\n  public static readonly TEAM_TYPE = \"team\";\n\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @Column({ type: String })\n  public name: string;\n\n  @ManyToMany(type => User)\n  @JoinTable({\n    name: \"group_users\",\n    joinColumn: { name: \"group_id\" },\n    inverseJoinColumn: { name: \"user_id\" },\n  })\n  public memberUsers: User[];\n\n  @ManyToMany(type => Group)\n  @JoinTable({\n    name: \"group_groups\",\n    joinColumn: { name: \"group_id\" },\n    inverseJoinColumn: { name: \"subgroup_id\" },\n  })\n  public memberGroups: Group[];\n\n  @OneToOne(type => AclRule, aclRule => aclRule.group)\n  public aclRule: AclRule;\n\n  @Column({ type: String, enum: [Group.ROLE_TYPE, Group.TEAM_TYPE], default: Group.ROLE_TYPE,\n    // Disabling nullable and select is necessary for the code to be run with older versions of the database.\n    // Especially it is required for testing the migrations.\n    nullable: true,\n    // We must set select to false because of older migrations (like 1556726945436-Billing.ts)\n    // which does not expect a type column at this moment.\n    select: false })\n  public type: typeof Group.ROLE_TYPE | typeof Group.TEAM_TYPE;\n\n  @BeforeUpdate()\n  @BeforeInsert()\n  public checkGroupMembers() {\n    const memberGroups = this.memberGroups ?? [];\n\n    if (this.type === Group.TEAM_TYPE && memberGroups.length > 0) {\n      throw new ApiError(`Groups of type \"${Group.TEAM_TYPE}\" cannot contain groups.`, 400);\n    }\n    const containItself = memberGroups.some(group => group.id === this.id);\n    if (containItself) {\n      throw new ApiError(\"A group cannot contain itself.\", 400);\n    }\n  }\n}\n"
  },
  {
    "path": "app/gen-server/entity/Limit.ts",
    "content": "import { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { BaseEntity, Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity(\"limits\")\nexport class Limit extends BaseEntity {\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @Column({ type: Number })\n  public limit: number;\n\n  @Column({ type: Number })\n  public usage: number;\n\n  @Column({ type: String })\n  public type: string;\n\n  @Column({ name: \"billing_account_id\", type: Number })\n  public billingAccountId: number;\n\n  @ManyToOne(type => BillingAccount)\n  @JoinColumn({ name: \"billing_account_id\" })\n  public billingAccount: BillingAccount;\n\n  @Column({ name: \"created_at\", type: nativeValues.dateTimeType, default: () => \"CURRENT_TIMESTAMP\" })\n  public createdAt: Date;\n\n  /**\n   * Last time the Limit.limit value was changed, by an upgrade or downgrade. Null if it has never been changed.\n   */\n  @Column({ name: \"changed_at\", type: nativeValues.dateTimeType, nullable: true })\n  public changedAt: Date | null;\n\n  /**\n   * Last time the Limit.usage was used (by sending a request to the model). Null if it has never been used.\n   */\n  @Column({ name: \"used_at\", type: nativeValues.dateTimeType, nullable: true })\n  public usedAt: Date | null;\n\n  /**\n   * Last time the Limit.usage was reset, probably by billing cycle change. Null if it has never been reset.\n   */\n  @Column({ name: \"reset_at\", type: nativeValues.dateTimeType, nullable: true })\n  public resetAt: Date | null;\n}\n"
  },
  {
    "path": "app/gen-server/entity/Login.ts",
    "content": "import { User } from \"app/gen-server/entity/User\";\n\nimport {\n  BaseEntity,\n  BeforeInsert,\n  BeforeUpdate,\n  Column,\n  Entity,\n  Index,\n  JoinColumn,\n  ManyToOne,\n  PrimaryColumn,\n} from \"typeorm\";\n\n@Entity({ name: \"logins\" })\nexport class Login extends BaseEntity {\n  public static readonly SERVICE_ACCOUNTS_TLD = \"serviceaccounts.invalid\";\n\n  @PrimaryColumn({ type: Number })\n  public id: number;\n\n  // This is the normalized email address we use for equality and indexing.\n  @Index()\n  @Column({ type: String })\n  public email: string;\n\n  // This is how the user's email address should be displayed.\n  @Column({ name: \"display_email\", type: String })\n  public displayEmail: string;\n\n  @Column({ name: \"user_id\", type: Number })\n  public userId: number;\n\n  @ManyToOne(type => User)\n  @JoinColumn({ name: \"user_id\" })\n  public user: User;\n\n  @BeforeInsert()\n  @BeforeUpdate()\n  public checkServiceAccountMailAreInvalid() {\n    if (this.user?.type === \"service\" && !this.email.endsWith(Login.SERVICE_ACCOUNTS_TLD)) {\n      throw new Error(`Users of type service must have email like XXXXXX@${Login.SERVICE_ACCOUNTS_TLD}`);\n    }\n  }\n}\n"
  },
  {
    "path": "app/gen-server/entity/OAuthClient.ts",
    "content": "import { Organization } from \"app/gen-server/entity/Organization\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport {\n  BaseEntity,\n  Column,\n  CreateDateColumn,\n  Entity,\n  JoinColumn,\n  ManyToOne,\n  PrimaryColumn,\n  UpdateDateColumn,\n} from \"typeorm\";\n\n/**\n * An OAuth client, e.g. a self-hosted instance using \"Sign in with getgrist.com\".\n *\n * Clients are currently owned by personal orgs. In the future, we will want to expand\n * this to include other orgs, to support management of clients within a team.\n */\n@Entity({ name: \"oauth_clients\" })\nexport class OAuthClient extends BaseEntity {\n  /**\n   * The client ID.\n   */\n  @PrimaryColumn({ type: String })\n  public id: string;\n\n  /**\n   * The client properties.\n   *\n   * Note: jsonb is used instead of json to allow indexing specific properties and updating\n   * fields more efficiently, if the need arises. We don't need to preserve exact formatting\n   * as is the case with json columns, and don't expect high write volumes that would make\n   * jsonb's additional overhead a problem.\n   *\n   * Reference: https://github.com/panva/node-oidc-provider/blob/main/example/my_adapter.js#L93-L94.\n   */\n  @Column({ type: nativeValues.jsonbEntityType })\n  public payload: Record<string, unknown>;\n\n  /**\n   * The ID of the org that owns the client.\n   *\n   * Note: Currently, clients are only owned by personal orgs.\n   */\n  @Column({ name: \"org_id\", type: Number })\n  public orgId: number;\n\n  /**\n   * The org that owns the client.\n   *\n   * Clients are deleted when their associated org is deleted. This means that\n   * if a user registered a client using their personal org and later deleted\n   * their Grist account, any self-managed Grist server that was still using\n   * the credentials of the deleted client will stop authenticating and report\n   * a \"client not found\" error on new authentication attempts.\n   *\n   * Recovery in such cases is still possible if an admin of the Grist\n   * server registers a new OAuth client on getgrist.com, and updates their\n   * server to use the new client's credentials. This can be mitigated further\n   * with support for organization-wide sharing of clients, and self-service\n   * transfer of client ownership (see note below about current restrictions).\n   *\n   * Note: Currently, clients are only owned by personal orgs.\n   */\n  @ManyToOne(() => Organization, { onDelete: \"CASCADE\" })\n  @JoinColumn({ name: \"org_id\" })\n  public org: Organization;\n\n  /**\n   * The client created at timestamp.\n   */\n  @CreateDateColumn({ name: \"created_at\" })\n  public createdAt: Date;\n\n  /**\n   * The client updated at timestamp.\n   */\n  @UpdateDateColumn({ name: \"updated_at\" })\n  public updatedAt: Date;\n}\n"
  },
  {
    "path": "app/gen-server/entity/OAuthGrant.ts",
    "content": "import { OAuthClient } from \"app/gen-server/entity/OAuthClient\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport {\n  BaseEntity,\n  Column,\n  CreateDateColumn,\n  Entity,\n  JoinColumn,\n  ManyToOne,\n  PrimaryColumn,\n  UpdateDateColumn,\n} from \"typeorm\";\n\n/**\n * An OAuth grant, which records a user's consent for which scopes/claims we can send to a particular\n * {@link OAuthClient}.\n *\n * A particular client may only have one grant per user, which encompasses all scopes granted to the\n * client on behalf of the user.\n */\n@Entity({ name: \"oauth_grants\" })\nexport class OAuthGrant extends BaseEntity {\n  /**\n   * The grant ID.\n   */\n  @PrimaryColumn({ type: String })\n  public id: string;\n\n  /**\n   * The grant properties.\n   *\n   * Note: See {@link OAuthClient.payload} for explanation of why jsonb is used instead of json.\n   *\n   * Reference: https://github.com/panva/node-oidc-provider/blob/main/example/my_adapter.js#L96-L116.\n   */\n  @Column({ type: nativeValues.jsonbEntityType })\n  public payload: Record<string, unknown>;\n\n  /**\n   * The ID of the client associated with the grant.\n   */\n  @Column({ name: \"oauth_client_id\", type: String })\n  public clientId: string;\n\n  /**\n   * The client associated with the grant.\n   */\n  @ManyToOne(() => OAuthClient, { onDelete: \"CASCADE\" })\n  @JoinColumn({ name: \"oauth_client_id\" })\n  public client: OAuthClient;\n\n  /**\n   * The ID of the user the grant was issued to.\n   */\n  @Column({ name: \"issued_to_user_id\", type: Number })\n  public issuedToUserId: number;\n\n  /**\n   * The user the grant was issued to.\n   */\n  @ManyToOne(() => User, { onDelete: \"CASCADE\" })\n  @JoinColumn({ name: \"issued_to_user_id\" })\n  public issuedToUser: User;\n\n  /**\n   * The grant created at timestamp.\n   */\n  @CreateDateColumn({ name: \"created_at\" })\n  public createdAt: Date;\n\n  /**\n   * The grant updated at timestamp.\n   */\n  @UpdateDateColumn({ name: \"updated_at\" })\n  public updatedAt: Date;\n}\n"
  },
  {
    "path": "app/gen-server/entity/Organization.ts",
    "content": "import { Role } from \"app/common/roles\";\nimport { OrganizationProperties, organizationPropertyKeys } from \"app/common/UserAPI\";\nimport { AclRuleOrg } from \"app/gen-server/entity/AclRule\";\nimport { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { Pref } from \"app/gen-server/entity/Pref\";\nimport { Resource } from \"app/gen-server/entity/Resource\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\n\nimport { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne,\n  PrimaryGeneratedColumn, RelationId } from \"typeorm\";\n\n// Information about how an organization may be accessed.\nexport interface AccessOption {\n  id: number;     // a user id\n  email: string;  // a user email\n  name: string;   // a user name\n  perms: number;  // permissions the user would have on organization\n}\n\nexport interface AccessOptionWithRole extends AccessOption {\n  access: Role;   // summary of permissions\n}\n\n@Entity({ name: \"orgs\" })\nexport class Organization extends Resource {\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @Column({\n    type: String,\n    nullable: true,\n  })\n  public domain: string;\n\n  @OneToOne(type => User, user => user.personalOrg)\n  @JoinColumn({ name: \"owner_id\" })\n  public owner: User;\n\n  @RelationId((org: Organization) => org.owner)\n  public ownerId: number;\n\n  @OneToMany(type => Workspace, workspace => workspace.org)\n  public workspaces: Workspace[];\n\n  @OneToMany(type => AclRuleOrg, aclRule => aclRule.organization)\n  public aclRules: AclRuleOrg[];\n\n  @Column({ name: \"billing_account_id\", type: Number })\n  public billingAccountId: number;\n\n  @ManyToOne(type => BillingAccount)\n  @JoinColumn({ name: \"billing_account_id\" })\n  public billingAccount: BillingAccount;\n\n  // Property that may be returned when the org is fetched to indicate the access the\n  // fetching user has on the org, i.e. 'owners', 'editors', 'viewers'\n  public access: string;\n\n  // Property that may be used internally to track multiple ways an org can be accessed\n  public accessOptions?: AccessOptionWithRole[];\n\n  // a computed column with permissions.\n  // {insert: false} makes sure typeorm doesn't try to put values into such\n  // a column when creating organizations.\n  @Column({ name: \"permissions\", type: \"text\", select: false, insert: false })\n  public permissions?: any;\n\n  // For custom domains, this is the preferred host associated with this org/team.\n  @Column({ name: \"host\", type: \"text\", nullable: true })\n  public host: string | null;\n\n  // Any prefs relevant to the org and user.  This relation is marked to not result\n  // in saves, since OneToMany saves in TypeORM are not reliable - see e.g. later\n  // parts of this issue:\n  //   https://github.com/typeorm/typeorm/issues/3095\n  @OneToMany(type => Pref, pref => pref.org, { persistence: false })\n  public prefs?: Pref[];\n\n  public checkProperties(props: any): props is Partial<OrganizationProperties> {\n    return super.checkProperties(props, organizationPropertyKeys);\n  }\n\n  public updateFromProperties(props: Partial<OrganizationProperties>) {\n    super.updateFromProperties(props);\n    if (props.domain) { this.domain = props.domain; }\n  }\n}\n"
  },
  {
    "path": "app/gen-server/entity/Pref.ts",
    "content": "import { Prefs } from \"app/common/Prefs\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from \"typeorm\";\n\n@Entity({ name: \"prefs\" })\nexport class Pref {\n  // This table may refer to users and/or orgs.\n  // We pretend userId/orgId are the primary key since TypeORM insists on having\n  // one, but we haven't marked them as so in the DB since the SQL standard frowns\n  // on nullable primary keys (and Postgres doesn't support them).  We could add\n  // another primary key, but we don't actually need one.\n  @PrimaryColumn({ name: \"user_id\", type: Number })\n  public userId: number | null;\n\n  @PrimaryColumn({ name: \"org_id\", type: Number })\n  public orgId: number | null;\n\n  @ManyToOne(type => User)\n  @JoinColumn({ name: \"user_id\" })\n  public user?: User;\n\n  @ManyToOne(type => Organization)\n  @JoinColumn({ name: \"org_id\" })\n  public org?: Organization;\n\n  // Finally, the actual preferences, in JSON.\n  @Column({ type: nativeValues.jsonEntityType })\n  public prefs: Prefs;\n}\n"
  },
  {
    "path": "app/gen-server/entity/Product.ts",
    "content": "import { Features, FREE_PLAN,\n  isManagedPlan,\n  PERSONAL_FREE_PLAN,\n  PERSONAL_LEGACY_PLAN,\n  Product as IProduct,\n  STUB_PLAN,\n  SUSPENDED_PLAN,\n  TEAM_FREE_PLAN,\n  TEAM_PLAN } from \"app/common/Features\";\nimport { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport * as assert from \"assert\";\n\nimport { BaseEntity, Column, Connection, Entity, OneToMany, PrimaryGeneratedColumn } from \"typeorm\";\n\n/**\n * A summary of features available in legacy personal sites.\n */\nexport const personalLegacyFeatures: Features = {\n  workspaces: true,\n  // no vanity domain\n  maxDocsPerOrg: 10,\n  maxSharesPerDoc: 2,\n  maxWorkspacesPerOrg: 1,\n  /**\n   * One time limit of 100 requests.\n   */\n  baseMaxAssistantCalls: 100,\n};\n\n/**\n * A summary of features used in 'team' plans. Grist ensures that this plan exists in the database, but it\n * is treated as an external plan that came from Stripe, and is not modified by Grist.\n */\nexport const teamFeatures: Features = {\n  workspaces: true,\n  vanityDomain: true,\n  maxSharesPerWorkspace: 0,   // all workspace shares need to be org members.\n  maxSharesPerDoc: 2,\n  /**\n   * Limit of 100 requests, but unlike for personal/free orgs the usage for this limit is reset at every billing cycle\n   * through Stripe webhook. For canceled subscription the usage is not reset, as the billing cycle is not changed.\n   */\n  baseMaxAssistantCalls: 100,\n  // Added for documentation purposes, but note the docstring about Stripe.\n  baseMaxAttachmentsBytesPerDocument: 3 * 1024 * 1024 * 1024,  // 3GB\n  maxAttachmentsBytesPerOrg: 100 * 1024 * 1024 * 1024,  // 100GB\n};\n\n/**\n * A summary of features available in free team sites.\n */\nexport const teamFreeFeatures: Features = {\n  workspaces: true,\n  vanityDomain: true,\n  maxSharesPerWorkspace: 0,   // all workspace shares need to be org members.\n  maxSharesPerDoc: 2,\n  snapshotWindow: { count: 30, unit: \"days\" },\n  baseMaxRowsPerDocument: 5000,\n  baseMaxApiUnitsPerDocumentPerDay: 5000,\n  baseMaxDataSizePerDocument: 5000 * 2 * 1024,  // 2KB per row\n  baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024,  // 1GB\n  maxAttachmentsBytesPerOrg: 50 * 1024 * 1024 * 1024,  // 50GB\n  gracePeriodDays: 14,\n  /**\n   * One time limit of 100 requests.\n   */\n  baseMaxAssistantCalls: 100,\n};\n\n/**\n * A summary of features available in free personal sites.\n */\nexport const personalFreeFeatures: Features = {\n  workspaces: true,\n  maxSharesPerWorkspace: 0,   // workspace sharing is disabled.\n  maxSharesPerDoc: 2,\n  snapshotWindow: { count: 30, unit: \"days\" },\n  baseMaxRowsPerDocument: 5000,\n  baseMaxApiUnitsPerDocumentPerDay: 5000,\n  baseMaxDataSizePerDocument: 5000 * 2 * 1024,  // 2KB per row\n  baseMaxAttachmentsBytesPerDocument: 1 * 1024 * 1024 * 1024,  // 1GB\n  maxAttachmentsBytesPerOrg: 50 * 1024 * 1024 * 1024,  // 50GB\n  gracePeriodDays: 14,\n  baseMaxAssistantCalls: 100,\n};\n\n/**\n * A summary of features used in unrestricted grandfathered accounts, and also\n * in some test settings.\n */\nexport const freeAllFeatures: Features = {\n  workspaces: true,\n  vanityDomain: true,\n};\n\nexport const suspendedFeatures: Features = {\n  workspaces: true,\n  vanityDomain: true,\n  readOnlyDocs: true,\n  // clamp down on new docs/workspaces/shares\n  maxDocsPerOrg: 0,\n  maxSharesPerDoc: 0,\n  maxWorkspacesPerOrg: 0,\n  baseMaxAssistantCalls: 0,\n};\n\n/**\n *\n * Products are a bundle of enabled features. Grist knows only\n * about free products and creates them by default. Other products\n * are created by the billing system (Stripe) and synchronized when used\n * or via webhooks.\n */\nexport const PRODUCTS: IProduct[] = [\n  {\n    name: PERSONAL_LEGACY_PLAN,\n    features: personalLegacyFeatures,\n  },\n  {\n    name: PERSONAL_FREE_PLAN,\n    features: personalFreeFeatures, // those features are read from database, here are only as a reference.\n  },\n  {\n    name: TEAM_FREE_PLAN,\n    features: teamFreeFeatures,\n  },\n  // This is a product for a team site (used in tests mostly, as the real team plan is managed by Stripe).\n  {\n    name: TEAM_PLAN,\n    features: teamFeatures,\n  },\n  // This is a product for a team site that is no longer in good standing, but isn't yet\n  // to be removed / deactivated entirely.\n  {\n    name: SUSPENDED_PLAN,\n    features: suspendedFeatures,\n  },\n  {\n    name: FREE_PLAN,\n    features: freeAllFeatures,\n  },\n  // This is a product for newly created accounts/orgs.\n  {\n    name: STUB_PLAN,\n    features: {},\n  },\n];\n\n/**\n * Get names of products for different situations.\n */\nexport function getDefaultProductNames() {\n  const defaultProduct = process.env.GRIST_DEFAULT_PRODUCT;\n  return {\n    // Personal site start off on a functional plan.\n    personal: defaultProduct || PERSONAL_FREE_PLAN,\n    // Team site starts off on a limited plan, requiring subscription.\n    teamInitial: defaultProduct || STUB_PLAN,\n    // Team site that has been 'turned off'.\n    teamCancel: \"suspended\",\n    // Functional team site.\n    team: defaultProduct || TEAM_PLAN,\n    teamFree: defaultProduct || TEAM_FREE_PLAN,\n  };\n}\n\nexport function getAnonymousFeatures(): Features {\n  if (!process.env.GRIST_DEFAULT_PRODUCT) {\n    // If GRIST_DEFAULT_PRODUCT is not set, we assume that anonymous users\n    // should have access to the free personal product.\n    return personalFreeFeatures;\n  } else {\n    // If GRIST_DEFAULT_PRODUCT is set, we assume that anonymous users\n    // should have access to the product specified by it.\n    const product = PRODUCTS.find(p => p.name === process.env.GRIST_DEFAULT_PRODUCT);\n    if (!product) {\n      throw new Error(`Unknown default product: ${process.env.GRIST_DEFAULT_PRODUCT}`);\n    }\n    return product.features;\n  }\n}\n\n/**\n * A Grist product.  Corresponds to a set of enabled features and a choice of limits.\n */\n@Entity({ name: \"products\" })\nexport class Product extends BaseEntity {\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @Column({ type: String })\n  public name: string;\n\n  @Column({ type: nativeValues.jsonEntityType })\n  public features: Features;\n\n  @OneToMany(type => BillingAccount, account => account.product)\n  public accounts: BillingAccount[];\n}\n\n/**\n * Make sure the products defined for the current stripe setup are\n * in the database and up to date.  Other products in the database\n * are untouched.\n *\n * If `apply` is set, the products are changed in the db, otherwise\n * the are left unchanged.  A summary of affected products is returned.\n */\nexport async function synchronizeProducts(\n  connection: Connection, apply: boolean, products = PRODUCTS,\n): Promise<string[]> {\n  try {\n    await connection.query(\"select name, features, stripe_product_id from products limit 1\");\n  } catch (e) {\n    // No usable products table, do not try to synchronize.\n    return [];\n  }\n  const changingProducts: string[] = [];\n  await connection.transaction(async (transaction) => {\n    const desiredProducts = new Map(products.map(p => [p.name, p]));\n    const existingProducts = new Map((await transaction.find(Product))\n      .map(p => [p.name, p]));\n    for (const product of desiredProducts.values()) {\n      if (existingProducts.has(product.name)) {\n        // Synchronize features only of known plans (team plan is not known).\n        if (!isManagedPlan(product.name)) {\n          continue;\n        }\n\n        const p = existingProducts.get(product.name)!;\n        try {\n          assert.deepStrictEqual(p.features, product.features);\n        } catch (e) {\n          if (apply) {\n            p.features = product.features;\n            await transaction.save(p);\n          }\n          changingProducts.push(p.name);\n        }\n      } else {\n        if (apply) {\n          const p = new Product();\n          p.name = product.name;\n          p.features = product.features;\n          await transaction.save(p);\n        }\n        changingProducts.push(product.name);\n      }\n    }\n  });\n  return changingProducts;\n}\n"
  },
  {
    "path": "app/gen-server/entity/Proposal.ts",
    "content": "import { ProposalComparison, ProposalStatus } from \"app/common/UserAPI\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { BaseEntity, Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from \"typeorm\";\n\n/**\n *\n * A table for tracking proposed changes to documents.\n *\n * For a \"githubby\" feal, proposals are identified with a document and a\n * short incrementing integer that is unique for that document, like the PR\n * number associated with repositories.\n *\n * The \"comparison\" column contains changes in the format used by the\n * /compare endpoint. This could be tweaked in future. That format represents\n * an overall diff, but doesn't give details of individual steps.\n *\n * The \"comparison\" column shouldn't be allowed to get too big, ideally.\n * Any large changes should be externalized - either computed on demand,\n * or placed in an S3-like store.\n *\n * Each proposal has a source and destination document. They are assumed to\n * share a common ancestor. The source contains the proposed changes.\n * Users of the destination document will be offered those changes, and there\n * should be a way to merge them into the destination document.\n *\n * Currently, only a single proposal is permitted between a given\n * source/destination pair. This should perhaps be relaxed, to be\n * future proof, although the UI would be likely to still be\n * constrained in this way for now.\n *\n * The 'status' of the proposal is a bit simplistic for now. Here are some\n * states of proposals:\n *   - dismissed\n *   - retracted\n *   - applied\n * If this feature were to grow, probably status would need to be events\n * in a full timeline, but it doesn't make sense to invest in that now, and\n * some kind of summary state would be needed anyway.\n *\n * There's at least one security problem with proposals. The source\n * document id may be a \"secret\" if it was created by an anonymous\n * user, in the sense that anyone who knows the id could edit\n * it. Something to bear in mind. Some proposal endpoints do some\n * censoring but may not be the right way to go.\n *\n */\n\n@Entity({ name: \"proposals\" })\nexport class Proposal extends BaseEntity {\n  @Column({ name: \"short_id\", type: Number })\n  public shortId: number;\n\n  @Column({ name: \"comparison\", type: nativeValues.jsonEntityType, nullable: true })\n  public comparison: ProposalComparison;\n\n  @Column({ name: \"status\", type: nativeValues.jsonEntityType, nullable: true })\n  public status: ProposalStatus;\n\n  @PrimaryColumn({ name: \"src_doc_id\", type: String })\n  public srcDocId: string;\n\n  @ManyToOne(_type => Document, { onDelete: \"CASCADE\" })\n  @JoinColumn({ name: \"src_doc_id\" })\n  public srcDoc: Document;\n\n  @PrimaryColumn({ name: \"dest_doc_id\", type: String })\n  public destDocId: string;\n\n  @ManyToOne(_type => Document, { onDelete: \"CASCADE\" })\n  @JoinColumn({ name: \"dest_doc_id\" })\n  public destDoc: Document;\n\n  @Column({ name: \"created_at\", type: Date, default: () => \"CURRENT_TIMESTAMP\" })\n  public createdAt: Date;\n\n  @Column({ name: \"updated_at\", type: Date, default: () => \"CURRENT_TIMESTAMP\" })\n  public updatedAt: Date;\n\n  @Column({ name: \"applied_at\", type: Date })\n  public appliedAt: Date | null;\n}\n"
  },
  {
    "path": "app/gen-server/entity/Resource.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { CommonProperties } from \"app/common/UserAPI\";\n\nimport { BaseEntity, Column } from \"typeorm\";\n\nexport class Resource extends BaseEntity {\n  @Column({ type: String })\n  public name: string;\n\n  @Column({ name: \"created_at\", type: Date, default: () => \"CURRENT_TIMESTAMP\" })\n  public createdAt: Date;\n\n  @Column({ name: \"updated_at\", type: Date, default: () => \"CURRENT_TIMESTAMP\" })\n  public updatedAt: Date;\n\n  // a computed column which, when present, means the entity should be filtered out\n  // of results.\n  @Column({ name: \"filtered_out\", type: \"boolean\", select: false, insert: false })\n  public filteredOut?: boolean;\n\n  public updateFromProperties(props: Partial<CommonProperties>) {\n    if (props.createdAt) { this.createdAt = _propertyToDate(props.createdAt); }\n    if (props.updatedAt) {\n      this.updatedAt = _propertyToDate(props.updatedAt);\n    } else {\n      this.updatedAt = new Date();\n    }\n    if (props.name) { this.name = props.name; }\n  }\n\n  protected checkProperties(props: any, keys: string[]): props is Partial<CommonProperties> {\n    for (const key of Object.keys(props)) {\n      if (!keys.includes(key)) {\n        throw new ApiError(`unrecognized property ${key}`, 400);\n      }\n    }\n    return true;\n  }\n}\n\n// Ensure iso-string-or-date value is converted to a date.\nfunction _propertyToDate(d: string | Date): Date {\n  if (typeof (d) === \"string\") {\n    return new Date(d);\n  }\n  return d;\n}\n"
  },
  {
    "path": "app/gen-server/entity/Secret.ts",
    "content": "import { Document } from \"app/gen-server/entity/Document\";\n\nimport { BaseEntity, Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from \"typeorm\";\n\n@Entity({ name: \"secrets\" })\nexport class Secret extends BaseEntity {\n  @PrimaryColumn({ type: String })\n  public id: string;  // generally a UUID\n\n  @Column({ name: \"value\", type: String })\n  public value: string;\n\n  @ManyToOne(_type => Document, { onDelete: \"CASCADE\" })\n  @JoinColumn({ name: \"doc_id\" })\n  public doc: Document;\n}\n"
  },
  {
    "path": "app/gen-server/entity/ServiceAccount.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { User } from \"app/gen-server/entity/User\";\n\nimport {\n  BaseEntity, BeforeInsert, BeforeUpdate, Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn,\n} from \"typeorm\";\n\n@Entity({ name: \"service_accounts\" })\nexport class ServiceAccount extends BaseEntity {\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @Column({ type: Number, name: \"owner_id\" })\n  public ownerId: number;\n\n  @ManyToOne(() => User)\n  @JoinColumn({ name: \"owner_id\" })\n  public owner: User;\n\n  @Column({ type: Number, name: \"service_user_id\" })\n  public serviceUserId: number;\n\n  @OneToOne(() => User, user => user.serviceAccount)\n  @JoinColumn({ name: \"service_user_id\" })\n  public serviceUser: User;\n\n  @Column({ type: String, nullable: false, default: \"\" })\n  public label: string;\n\n  @Column({ type: String, nullable: false, default: \"\" })\n  public description: string;\n\n  @Column({ type: Date, nullable: false, name: \"expires_at\" })\n  public expiresAt: Date;\n\n  @BeforeUpdate()\n  @BeforeInsert()\n  public checkExpiresAt() {\n    if (Number.isNaN(this.expiresAt.getTime())) {\n      throw new ApiError(\"Invalid expiresAt\", 400);\n    }\n  }\n\n  public isActive(): boolean {\n    const currentDate = new Date();\n    return this.expiresAt > currentDate;\n  }\n}\n"
  },
  {
    "path": "app/gen-server/entity/Share.ts",
    "content": "import { ShareOptions } from \"app/common/ShareOptions\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { BaseEntity, Column, Entity, JoinColumn, ManyToOne,\n  PrimaryColumn } from \"typeorm\";\n\n@Entity({ name: \"shares\" })\nexport class Share extends BaseEntity {\n  /**\n   * A simple integer auto-incrementing identifier for a share.\n   * Suitable for use in within-database references.\n   */\n  @PrimaryColumn({ name: \"id\", type: Number })\n  public id: number;\n\n  /**\n   * A long string secret to identify the share. Suitable for URLs.\n   * Unique across the database / installation.\n   */\n  @Column({ name: \"key\", type: String })\n  public key: string;\n\n  /**\n   * A string to identify the share. This identifier is common to the home\n   * database and the document specified by docId. It need only be unique\n   * within that document, and is not a secret. These two properties are\n   * important when you imagine handling documents that are transferred\n   * between installations, or copied, etc.\n   */\n  @Column({ name: \"link_id\", type: String })\n  public linkId: string;\n\n  /**\n   * The document to which the share belongs.\n   */\n  @Column({ name: \"doc_id\", type: String })\n  public docId: string;\n\n  /**\n   * Any overall qualifiers on the share.\n   */\n  @Column({ name: \"options\", type: nativeValues.jsonEntityType })\n  public options: ShareOptions;\n\n  @ManyToOne(type => Document)\n  @JoinColumn({ name: \"doc_id\" })\n  public doc: Document;\n}\n"
  },
  {
    "path": "app/gen-server/entity/User.ts",
    "content": "import { UserType } from \"app/common/User\";\nimport { UserOptions, UserProfile } from \"app/common/UserAPI\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { Login } from \"app/gen-server/entity/Login\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Pref } from \"app/gen-server/entity/Pref\";\nimport { ServiceAccount } from \"app/gen-server/entity/ServiceAccount\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\nimport { makeId } from \"app/server/lib/idUtils\";\n\nimport { BaseEntity, BeforeInsert, Column, Entity, JoinTable, ManyToMany, OneToMany, OneToOne,\n  PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity({ name: \"users\" })\nexport class User extends BaseEntity {\n  public static readonly LOGIN_TYPE: UserType = \"login\";\n  public static readonly SERVICE_TYPE: UserType = \"service\";\n\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @Column({ type: String })\n  public name: string;\n\n  @Column({ name: \"api_key\", type: String, nullable: true })\n  // Found how to make a type nullable in this discussion: https://github.com/typeorm/typeorm/issues/2567\n  // todo: adds constraint for api_key not to equal ''\n  public apiKey: string | null;\n\n  @Column({ name: \"picture\", type: String, nullable: true })\n  public picture: string | null;\n\n  @Column({ name: \"first_login_at\", type: nativeValues.dateTimeType, nullable: true })\n  public firstLoginAt: Date | null;\n\n  @Column({ name: \"last_connection_at\", type: nativeValues.dateTimeType, nullable: true })\n  public lastConnectionAt: Date | null;\n\n  @Column({ name: \"disabled_at\", type: nativeValues.dateTimeType, nullable: true })\n  public disabledAt: Date | null;\n\n  @OneToOne(type => Organization, organization => organization.owner)\n  public personalOrg: Organization;\n\n  @OneToMany(type => Login, login => login.user)\n  public logins: Login[];\n\n  @OneToMany(type => Pref, pref => pref.user)\n  public prefs: Pref[];\n\n  @ManyToMany(type => Group)\n  @JoinTable({\n    name: \"group_users\",\n    joinColumn: { name: \"user_id\" },\n    inverseJoinColumn: { name: \"group_id\" },\n  })\n  public groups: Group[];\n\n  @Column({ name: \"is_first_time_user\", type: Boolean, default: false })\n  public isFirstTimeUser: boolean;\n\n  @Column({ name: \"options\", type: nativeValues.jsonEntityType, nullable: true })\n  public options: UserOptions | null;\n\n  @Column({ name: \"connect_id\", type: String, nullable: true })\n  public connectId: string | null;\n\n  @OneToOne(() => ServiceAccount, sa => sa.serviceUser)\n  public serviceAccount?: ServiceAccount;\n\n  /**\n   * Unique reference for this user. Primarily used as an ownership key in a cell metadata (comments).\n   */\n  @Column({ name: \"ref\", type: String, nullable: false })\n  public ref: string;\n\n  @Column({ name: \"created_at\", default: () => \"CURRENT_TIMESTAMP\" })\n  public createdAt: Date;\n\n  // A random public key that can be used to manage document preferences without authentication.\n  @Column({ name: \"unsubscribe_key\", type: String, nullable: true })\n  public unsubscribeKey: string | null;\n\n  @Column({ name: \"type\", type: String, enum: [User.LOGIN_TYPE, User.SERVICE_TYPE], default: User.LOGIN_TYPE,\n    // Must be null for migrations testing purpose\n    nullable: true,\n  })\n  public type: UserType;\n\n  @BeforeInsert()\n  public async beforeInsert() {\n    if (!this.ref) {\n      this.ref = makeId();\n    }\n  }\n\n  /**\n   * Get user's email.  Returns undefined if logins has not been joined, or no login\n   * is available\n   */\n  public get loginEmail(): string | undefined {\n    const login = this.logins?.[0];\n    if (!login) { return undefined; }\n    return login.email;\n  }\n\n  /**\n   * As above, but using the display email.\n   */\n  public get displayEmail(): string | undefined {\n    const login = this.logins?.[0];\n    if (!login) { return undefined; }\n    return login.displayEmail;\n  }\n\n  public toUserProfile(): UserProfile {\n    return {\n      name: this.name,\n      email: this.displayEmail || \"\",\n      loginEmail: this.loginEmail || \"\",\n      picture: this.picture,\n    };\n  }\n}\n"
  },
  {
    "path": "app/gen-server/entity/Workspace.ts",
    "content": "import { FullUser, WorkspaceProperties, workspacePropertyKeys } from \"app/common/UserAPI\";\nimport { AclRuleWs } from \"app/gen-server/entity/AclRule\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Resource } from \"app/gen-server/entity/Resource\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from \"typeorm\";\n\n@Entity({ name: \"workspaces\" })\nexport class Workspace extends Resource {\n  @PrimaryGeneratedColumn()\n  public id: number;\n\n  @ManyToOne(type => Organization)\n  @JoinColumn({ name: \"org_id\" })\n  public org: Organization;\n\n  @OneToMany(type => Document, document => document.workspace)\n  public docs: Document[];\n\n  @OneToMany(type => AclRuleWs, aclRule => aclRule.workspace)\n  public aclRules: AclRuleWs[];\n\n  // Property that may be returned when the workspace is fetched to indicate the access the\n  // fetching user has on the workspace, i.e. 'owners', 'editors', 'viewers'\n  public access: string;\n\n  // A computed column that is true if the workspace is a support workspace.\n  @Column({ name: \"support\", type: \"boolean\", insert: false, select: false })\n  public isSupportWorkspace?: boolean;\n\n  // a computed column with permissions.\n  // {insert: false} makes sure typeorm doesn't try to put values into such\n  // a column when creating workspaces.\n  @Column({ name: \"permissions\", type: \"text\", select: false, insert: false })\n  public permissions?: any;\n\n  @Column({ name: \"removed_at\", type: nativeValues.dateTimeType, nullable: true })\n  public removedAt: Date | null;\n\n  // Property that may be returned when the workspace is fetched to indicate\n  // the owner of the workspace.\n  public owner?: FullUser;\n\n  public checkProperties(props: any): props is Partial<WorkspaceProperties> {\n    return super.checkProperties(props, workspacePropertyKeys);\n  }\n\n  public updateFromProperties(props: Partial<WorkspaceProperties>) {\n    super.updateFromProperties(props);\n  }\n}\n"
  },
  {
    "path": "app/gen-server/lib/ActivationsManager.ts",
    "content": "import { InstallPrefs } from \"app/common/Install\";\nimport { InstallPrefsWithSources } from \"app/common/InstallAPI\";\nimport { Activation } from \"app/gen-server/entity/Activation\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { makeId } from \"app/server/lib/idUtils\";\nimport { getTelemetryPrefs } from \"app/server/lib/Telemetry\";\n\nimport omit from \"lodash/omit\";\nimport pick from \"lodash/pick\";\nimport { EntityManager } from \"typeorm\";\n\n/**\n * Manage activations. Not much to do currently, there is at most one\n * activation. The activation singleton establishes an id and creation\n * time for the installation.\n */\nexport class ActivationsManager {\n  constructor(private _db: HomeDBManager) {\n  }\n\n  public async runInTransaction<T>(fn: (transaction: EntityManager) => Promise<T>): Promise<T> {\n    return this._db.runInTransaction(undefined, fn);\n  }\n\n  // Get the current activation row, creating one if necessary.\n  // It will be created with an empty key column, which will get\n  // filled in once an activation key is presented.\n  public async current(transaction?: EntityManager): Promise<Activation> {\n    return await this._db.runInTransaction(transaction, async (manager) => {\n      let activation = await manager.findOne(Activation, { where: {} });\n      if (!activation) {\n        activation = manager.create(Activation);\n        activation.id = makeId();\n        activation.prefs = { checkForLatestVersion: true };\n        await activation.save();\n      }\n      return activation;\n    });\n  }\n\n  public async setKey(key: string, transaction?: EntityManager): Promise<void> {\n    await this._updateActivation((activation) => {\n      activation.key = key;\n    }, transaction);\n  }\n\n  public async updateGracePeriod(gracePeriodStarted: Date | null, transaction?: EntityManager): Promise<void> {\n    await this._updateActivation((activation) => {\n      activation.gracePeriodStart = gracePeriodStarted;\n    }, transaction);\n  }\n\n  public async memberCount(transaction?: EntityManager): Promise<number> {\n    return await this._db.runInTransaction(transaction, async (manager) => {\n      const userManager = this._db.usersManager();\n      const excludedUsers = userManager.getExcludedUserIds();\n      const { count } = await manager\n        .createQueryBuilder()\n        .select(\"CAST(COUNT(*) AS INTEGER)\", \"count\") // Cast to integer for postgres, which returns strings.\n        .from((qb) => {\n          const sub = qb\n            .select(\"DISTINCT u.id\", \"id\")\n            .from(\"acl_rules\", \"a\")\n            .innerJoin(\"groups\", \"g\", \"a.group_id = g.id\")\n            .innerJoin(\"orgs\", \"o\", \"a.org_id = o.id\")\n            .innerJoin(\"group_users\", \"gu\", \"g.id = gu.group_id\")\n            .innerJoin(\"users\", \"u\", \"gu.user_id = u.id\");\n\n          if (process.env.GRIST_SINGLE_ORG === \"docs\") {\n            // Count only personal orgs.\n            return sub\n              .where(\"o.owner_id = u.id\")\n              .andWhere(\"u.id NOT IN (:...excludedUsers)\", { excludedUsers });\n          } else if (process.env.GRIST_SINGLE_ORG) {\n            // Count users of this single org.\n            return sub\n              .where(\"o.owner_id IS NULL\")\n              .andWhere(\"o.domain = :domain\", { domain: process.env.GRIST_SINGLE_ORG })\n              .andWhere(\"u.id NOT IN (:...excludedUsers)\", { excludedUsers });\n          } else {\n            // Count users of all teams except personal.\n            return sub\n              .where(\"o.owner_id IS NULL\")\n              .andWhere(\"u.id NOT IN (:...excludedUsers)\", { excludedUsers });\n          }\n        }, \"subquery\")\n        .getRawOne();\n      return count;\n    });\n  }\n\n  /**\n   * Updates a key/value pair in the app env file stored in the activation record.\n   * TODO: Notify other servers that the env file has changed and they should refresh their copy of appSettings.\n   */\n  public async updateAppEnvFile(delta: Record<string, string | null>, transaction?: EntityManager) {\n    return await this._db.runInTransaction(transaction, async (manager) => {\n      const activation = await this.current(manager);\n      activation.prefs ??= {};\n      activation.prefs.envVars ??= {};\n      // For now we just support 3 keys here, as these ones are tested.\n      Object.assign(activation.prefs.envVars, pick(delta,\n        \"GRIST_LOGIN_SYSTEM_TYPE\",\n        \"GRIST_GETGRISTCOM_SECRET\",\n        \"GRIST_ADMIN_EMAIL\",\n      ));\n      // If any values are undefined or null, remove them.\n      for (const key of Object.keys(delta)) {\n        if (delta[key] === null || delta[key] === undefined) {\n          delete activation.prefs.envVars[key];\n        }\n      }\n      await manager.save(activation);\n    });\n  }\n\n  /**\n   * Returns all prefs with their sources, if applicable.\n   */\n  public async getPrefsWithSources(): Promise<InstallPrefsWithSources> {\n    const activation = await this.current();\n    const telemetryPrefs = await getTelemetryPrefs(this._db, activation);\n    return {\n      checkForLatestVersion: true,\n      ...activation.prefs,\n      telemetry: telemetryPrefs,\n    };\n  }\n\n  /**\n   * Updates the specified `prefs`.\n   */\n  public async updatePrefs(prefs: Partial<InstallPrefs>): Promise<void> {\n    await this._updateActivation((activation) => {\n      const props = { prefs };\n      activation.checkProperties(props);\n      activation.updateFromProperties(props);\n    });\n  }\n\n  /**\n   * Deletes the specified `prefs`.\n   *\n   * Returns the deleted prefs, excluding any that were not found.\n   */\n  public async deletePrefs(\n    prefs: (keyof InstallPrefs)[],\n    { transaction }: { transaction?: EntityManager } = {},\n  ): Promise<Partial<InstallPrefs>> {\n    return await this._db.runInTransaction(transaction, async (manager) => {\n      const activation = await this.current(manager);\n      activation.prefs ??= {};\n      const deletedPrefs = pick(activation.prefs, prefs);\n      activation.prefs = omit(activation.prefs, prefs);\n      if (Object.keys(deletedPrefs).length > 0) {\n        await manager.save(activation);\n      }\n      return deletedPrefs;\n    });\n  }\n\n  private async _updateActivation(fn: (activation: Activation) => void, transaction?: EntityManager): Promise<void> {\n    await this._db.runInTransaction(transaction, async (manager) => {\n      const activation = await this.current(manager);\n      fn(activation);\n      await manager.save(activation);\n    });\n  }\n}\n"
  },
  {
    "path": "app/gen-server/lib/DocApiForwarder.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { SHARE_KEY_PREFIX } from \"app/common/gristUrls\";\nimport { removeTrailingSlash } from \"app/common/gutil\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { getAssignmentId } from \"app/server/lib/idUtils\";\nimport { addAbortHandler } from \"app/server/lib/requestUtils\";\n\nimport * as express from \"express\";\nimport { AbortController } from \"node-abort-controller\";\nimport fetch, { RequestInit } from \"node-fetch\";\n\n/**\n * Forwards all /api/docs/:docId/tables requests to the doc worker handling the :docId document. Makes\n * sure the user has at least view access to the document otherwise rejects the request. For\n * performance reason we stream the body directly from the request, which requires that no-one reads\n * the req before, in particular you should register DocApiForwarder before bodyParser.\n *\n * Use:\n *   const home = new ApiServer(false);\n *   const docApiForwarder = new DocApiForwarder(getDocWorkerMap(), home);\n *   app.use(docApiForwarder.getMiddleware());\n *\n * Note that it expects userId, and jsonErrorHandler middleware to be set up outside\n * to apply to these routes.\n */\nexport class DocApiForwarder {\n  constructor(private _docWorkerMap: IDocWorkerMap, private _dbManager: HomeDBManager,\n    private _gristServer: GristServer) {\n  }\n\n  public addEndpoints(app: express.Application) {\n    app.use((req, res, next) => {\n      if (req.url.startsWith(\"/api/s/\")) {\n        req.url = req.url.replace(\"/api/s/\", `/api/docs/${SHARE_KEY_PREFIX}`);\n      }\n      next();\n    });\n\n    // Middleware to forward a request about an existing document that user has access to.\n    // We do not check whether the document has been soft-deleted; that will be checked by\n    // the worker if needed.\n    const withDoc = expressWrap(this._forwardToDocWorker.bind(this, true, \"viewers\"));\n    // Middleware to forward a request without a pre-existing document (for imports/uploads).\n    const withoutDoc = expressWrap(this._forwardToDocWorker.bind(this, false, null));\n    const withDocWithoutAuth = expressWrap(this._forwardToDocWorker.bind(this, true, null));\n    app.use(\"/api/docs/:docId/tables\", withDoc);\n    app.use(\"/api/docs/:docId/force-reload\", withDoc);\n    app.use(\"/api/docs/:docId/recover\", withDoc);\n    app.use(\"/api/docs/:docId/remove\", withDoc);\n    app.use(\"/api/docs/:docId/disable\", withDocWithoutAuth);\n    app.use(\"/api/docs/:docId/enable\", withDocWithoutAuth);\n    app.delete(\"/api/docs/:docId\", withDoc);\n    app.use(\"/api/docs/:docId/download\", withDoc);\n    app.use(\"/api/docs/:docId/send-to-drive\", withDoc);\n    app.use(\"/api/docs/:docId/fork\", withDoc);\n    app.use(\"/api/docs/:docId/create-fork\", withDoc);\n    app.use(\"/api/docs/:docId/apply\", withDoc);\n    app.use(\"/api/docs/:docId/attachments\", withDoc);\n    app.use(\"/api/docs/:docId/attachments/archive\", withDoc);\n    app.use(\"/api/docs/:docId/attachments/download\", withDoc);\n    app.use(\"/api/docs/:docId/attachments/transferStatus\", withDoc);\n    app.use(\"/api/docs/:docId/attachments/transferAll\", withDoc);\n    app.use(\"/api/docs/:docId/attachments/store\", withDoc);\n    app.use(\"/api/docs/:docId/attachments/stores\", withDoc);\n    app.use(\"/api/docs/:docId/snapshots\", withDoc);\n    app.use(\"/api/docs/:docId/usersForViewAs\", withDoc);\n    app.use(\"/api/docs/:docId/replace\", withDoc);\n    app.use(\"/api/docs/:docId/flush\", withDoc);\n    app.use(\"/api/docs/:docId/states\", withDoc);\n    app.use(\"/api/docs/:docId/compare\", withDoc);\n    app.use(\"/api/docs/:docId/assign\", withDocWithoutAuth);\n    app.use(\"/api/docs/:docId/webhooks/queue\", withDoc);\n    app.use(\"/api/docs/:docId/webhooks\", withDoc);\n    app.use(\"/api/docs/:docId/assistant\", withDoc);\n    app.use(\"/api/docs/:docId/sql\", withDoc);\n    app.use(\"/api/docs/:docId/timing\", withDoc);\n    app.use(\"/api/docs/:docId/timing/start\", withDoc);\n    app.use(\"/api/docs/:docId/timing/stop\", withDoc);\n    app.use(\"/api/docs/:docId/forms/:vsId\", withDoc);\n    app.use(\"/api/docs/:docId/propose\", withDoc);\n    app.use(\"/api/docs/:docId/proposals\", withDoc);\n\n    app.use(\"/api/docs/:docId/copy\", withoutDoc);\n    app.use(\"^/api/docs$\", withoutDoc);\n    app.use(\"/api/workspaces/:wid/import\", withoutDoc);\n  }\n\n  private async _forwardToDocWorker(\n    withDocId: boolean, role: \"viewers\" | null, req: express.Request, res: express.Response,\n  ): Promise<void> {\n    let docId: string | null = null;\n    if (withDocId) {\n      const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager,\n        this._gristServer, req.params.docId);\n      if (role) {\n        assertAccess(role, docAuth, { allowRemoved: true, allowDisabled: true });\n      }\n      docId = docAuth.docId;\n    }\n    // Use the docId for worker assignment, rather than req.params.docId, which could be a urlId.\n    const assignmentId = getAssignmentId(this._docWorkerMap, docId === null ? \"import\" : docId);\n\n    if (!this._docWorkerMap) {\n      throw new ApiError(\"no worker map\", 404);\n    }\n    const docStatus = await this._docWorkerMap.assignDocWorker(assignmentId);\n\n    // Construct new url by keeping only origin and path prefixes of `docWorker.internalUrl`,\n    // and otherwise reflecting fully the original url (remaining path, and query params).\n    const docWorkerUrl = new URL(docStatus.docWorker.internalUrl);\n    const url = new URL(req.originalUrl, docWorkerUrl.origin);\n    url.pathname = removeTrailingSlash(docWorkerUrl.pathname) + url.pathname;\n\n    const headers: { [key: string]: string } = {\n      // At this point, we have already checked and trusted the origin of the request.\n      // See FlexServer#addApiMiddleware(). So don't include the \"Origin\" header.\n      // Including this header also would break features like form submissions,\n      // as the \"Host\" header is not retrieved when calling getTransitiveHeaders().\n      ...getTransitiveHeaders(req, { includeOrigin: false }),\n      \"Content-Type\": req.get(\"Content-Type\") || \"application/json\",\n    };\n    for (const key of [\"X-Sort\", \"X-Limit\"]) {\n      const hdr = req.get(key);\n      if (hdr) { headers[key] = hdr; }\n    }\n\n    const controller = new AbortController();\n\n    // If the original request is aborted, abort the forwarded request too. (Currently this only\n    // affects some export/download requests which can abort long-running work.)\n    addAbortHandler(req, res, () => controller.abort());\n\n    const options: RequestInit = {\n      method: req.method,\n      headers,\n      signal: controller.signal,\n    };\n    if ([\"POST\", \"PATCH\", \"PUT\"].includes(req.method)) {\n      // uses `req` as a stream\n      options.body = req;\n    }\n\n    const docWorkerRes = await fetch(url.href, options);\n    res.status(docWorkerRes.status);\n    for (const key of [\"content-type\", \"content-disposition\", \"cache-control\"]) {\n      const value = docWorkerRes.headers.get(key);\n      if (value) { res.set(key, value); }\n    }\n    return new Promise<void>((resolve, reject) => {\n      docWorkerRes.body.on(\"error\", reject);\n      res.on(\"error\", reject);\n      res.on(\"finish\", resolve);\n      docWorkerRes.body.pipe(res);\n    });\n  }\n}\n"
  },
  {
    "path": "app/gen-server/lib/DocWorkerMap.ts",
    "content": "import { MapWithTTL } from \"app/common/AsyncCreate\";\nimport { isAffirmative } from \"app/common/gutil\";\nimport * as version from \"app/common/version\";\nimport { DocStatus, DocWorkerInfo, IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport log from \"app/server/lib/log\";\nimport { checkPermitKey, formatPermitKey, IPermitStore, Permit } from \"app/server/lib/Permit\";\n\nimport { promisifyAll } from \"bluebird\";\nimport mapValues from \"lodash/mapValues\";\nimport { createClient, Multi, RedisClient } from \"redis\";\nimport Redlock from \"redlock\";\nimport { v4 as uuidv4 } from \"uuid\";\n\npromisifyAll(RedisClient.prototype);\npromisifyAll(Multi.prototype);\n\n// Max time for which we will hold a lock, by default.  In milliseconds.\nconst LOCK_TIMEOUT = 3000;\n\n// How long do checksums stored in redis last.  In milliseconds.\n// Should be long enough to allow S3 to reach consistency with very high probability.\n// Consistency failures shorter than this interval will be detectable, failures longer\n// than this interval will not be detectable.\nconst CHECKSUM_TTL_MSEC = 24 * 60 * 60 * 1000;  // 24 hours\n\n// How long do permits stored in redis last, in milliseconds.\nconst PERMIT_TTL_MSEC = 1 * 60 * 1000;  // 1 minute\n\n// Default doc worker group.\nconst DEFAULT_GROUP = \"default\";\n\nclass DummyDocWorkerMap implements IDocWorkerMap {\n  private _worker?: DocWorkerInfo;\n  private _available: boolean = false;\n  private _elections = new MapWithTTL<string, string>(1);  // default ttl never used\n  private _permitStores = new Map<string, IPermitStore>();\n\n  public async getDocWorker(docId: string) {\n    if (!this._worker) { throw new Error(\"no workers\"); }\n    return { docMD5: \"unknown\", docWorker: this._worker, isActive: true };\n  }\n\n  public async assignDocWorker(docId: string) {\n    if (!this._worker || !this._available) { throw new Error(\"no workers\"); }\n    return { docMD5: \"unknown\", docWorker: this._worker, isActive: true };\n  }\n\n  public async getDocWorkerOrAssign(docId: string, workerId: string): Promise<DocStatus> {\n    if (!this._worker || !this._available) { throw new Error(\"no workers\"); }\n    if (this._worker.id !== workerId) { throw new Error(\"worker not known\"); }\n    return { docMD5: \"unknown\", docWorker: this._worker, isActive: true };\n  }\n\n  public async updateDocStatus(docId: string, checksum: string) {\n    // nothing to do\n  }\n\n  public async addWorker(info: DocWorkerInfo): Promise<void> {\n    this._worker = info;\n  }\n\n  public async removeWorker(workerId: string): Promise<void> {\n    this._worker = undefined;\n  }\n\n  public async setWorkerAvailability(workerId: string, available: boolean): Promise<void> {\n    this._available = available;\n  }\n\n  public async setWorkerLoad(workerInfo: DocWorkerInfo, load: number): Promise<void> {\n    // nothing to do\n  }\n\n  public async isWorkerRegistered(workerInfo: DocWorkerInfo): Promise<boolean> {\n    return Promise.resolve(true);\n  }\n\n  public async releaseAssignment(workerId: string, docId: string): Promise<void> {\n    // nothing to do\n  }\n\n  public async getAssignments(workerId: string): Promise<string[]> {\n    return [];\n  }\n\n  public getPermitStore(prefix: string, defaultTtlMs?: number): IPermitStore {\n    let store = this._permitStores.get(prefix);\n    if (store) { return store; }\n    const _permits = new MapWithTTL<string, string>(defaultTtlMs || PERMIT_TTL_MSEC);\n    store = {\n      async setPermit(permit: Permit, ttlMs?: number): Promise<string> {\n        const key = formatPermitKey(uuidv4(), prefix);\n        if (ttlMs) {\n          _permits.setWithCustomTTL(key, JSON.stringify(permit), ttlMs);\n        } else {\n          _permits.set(key, JSON.stringify(permit));\n        }\n        return key;\n      },\n      async getPermit(key: string): Promise<Permit> {\n        const result = _permits.get(key);\n        return result ? JSON.parse(result) : null;\n      },\n      async removePermit(key: string): Promise<void> {\n        _permits.delete(key);\n      },\n      async close(): Promise<void> {\n        _permits.clear();\n      },\n      getKeyPrefix() {\n        return formatPermitKey(\"\", prefix);\n      },\n    };\n    this._permitStores.set(prefix, store);\n    return store;\n  }\n\n  public async close(): Promise<void> {\n    await Promise.all([...this._permitStores.values()].map(store => store.close()));\n    this._permitStores.clear();\n    this._elections.clear();\n  }\n\n  public async getElection(name: string, durationInMs: number): Promise<string | null> {\n    if (this._elections.get(name)) { return null; }\n    const key = uuidv4();\n    this._elections.setWithCustomTTL(name, key, durationInMs);\n    return key;\n  }\n\n  public async removeElection(name: string, electionKey: string): Promise<void> {\n    if (this._elections.get(name) === electionKey) {\n      this._elections.delete(name);\n    }\n  }\n\n  public async updateChecksum(family: string, key: string, checksum: string) {\n    // nothing to do\n  }\n\n  public async getChecksum(family: string, key: string) {\n    return null;\n  }\n\n  public async getWorkerGroup(workerId: string): Promise<string | null> {\n    return null;\n  }\n\n  public async getDocGroup(docId: string): Promise<string | null> {\n    return null;\n  }\n\n  public async updateDocGroup(docId: string, docGroup: string): Promise<void> {\n    // nothing to do\n  }\n\n  public async removeDocGroup(docId: string): Promise<void> {\n    // nothing to do\n  }\n\n  public getRedisClient() {\n    return null;\n  }\n}\n\n/**\n * Manage the relationship between document and workers.  Backed by Redis.\n * Can also assign workers to \"groups\" for serving particular documents.\n * Keys used:\n *   workers - the set of known active workers, identified by workerId\n *   workers-available - the set of workers available for assignment (a subset of the workers set)\n *   workers-available-{group} - the set of workers available for a given group\n *   workers-available-by-load-{group} - the set of workers available for a given group, sorted by load\n *   worker-{workerId} - a hash of contact information for a worker\n *   worker-{workerId}-docs - a set of docs assigned to a worker, identified by docId\n *   worker-{workerId}-group - if set, marks the worker as serving a particular group\n *   doc-${docId} - a hash containing (JSON serialized) DocStatus fields, other than docMD5.\n *   doc-${docId}-checksum - the docs docMD5, or 'null' if docMD5 is null\n *   doc-${docId}-group - if set, marks the doc as to be served by workers in a given group\n *   workers-lock - a lock used when working with the list of workers\n *   groups - a hash from groupIds (arbitrary strings) to desired number of workers in group\n *   elections-${deployment} - a hash, from groupId to a (serialized json) list of worker ids\n *\n * Assignments of documents to workers can end abruptly at any time.  Clients\n * should be prepared to retry if a worker is not responding or denies that a document\n * is assigned to it.\n *\n * If the groups key is set, workers assign themselves to groupIds to\n * fill the counts specified in groups (in order of groupIds), and\n * once those are exhausted, get assigned to the special group\n * \"default\".\n */\nexport class DocWorkerMap implements IDocWorkerMap {\n  private _client: RedisClient;\n  private _clients: RedisClient[];\n  private _redlock: Redlock;\n\n  // Optional deploymentKey argument supplies a key unique to the deployment (this is important\n  // for maintaining groups across redeployments only)\n  constructor(_clients?: RedisClient[], private _deploymentKey?: string, private _options?: {\n    permitMsec?: number\n  }) {\n    this._deploymentKey = this._deploymentKey || version.version;\n    this._clients = _clients || [createClient(process.env.REDIS_URL)];\n    this._redlock = new Redlock(this._clients);\n    this._client = this._clients[0]!;\n    this._client.on(\"error\", err => log.warn(`DocWorkerMap: redisClient error`, String(err)));\n    this._client.on(\"end\", () => log.warn(`DocWorkerMap: redisClient connection closed`));\n    this._client.on(\"reconnecting\", () => log.warn(`DocWorkerMap: redisClient reconnecting`));\n  }\n\n  public async addWorker(info: DocWorkerInfo): Promise<void> {\n    log.info(`DocWorkerMap.addWorker ${info.id}`);\n    const lock = await this._redlock.lock(\"workers-lock\", LOCK_TIMEOUT);\n    try {\n      // Make a worker-{workerId} key with contact info.\n      await this._client.hmsetAsync(`worker-${info.id}`, info);\n      // Add this worker to set of workers (but don't make it available for work yet).\n      await this._client.saddAsync(\"workers\", info.id);\n\n      if (info.group) {\n        // Accept work only for a specific group.\n        // Do not accept work not associated with the specified group.\n        await this._client.setAsync(`worker-${info.id}-group`, info.group);\n      } else {\n        // Figure out if worker should belong to a group via elections.\n        // Be careful: elections happen within a single deployment, so are somewhat\n        // unintuitive in behavior. For example, if a document is assigned to a group\n        // but there is no worker available for that group, it may open on any worker.\n        // And if a worker is assigned to a group, it may still end up assigned work\n        // not associated with that group if it is the only worker available.\n        const groups = await this._client.hgetallAsync(\"groups\");\n        if (groups) {\n          const elections = await this._client.hgetallAsync(`elections-${this._deploymentKey}`) || {};\n          for (const group of Object.keys(groups).sort()) {\n            const count = parseInt(groups[group], 10) || 0;\n            if (count < 1) { continue; }\n            const elected: string[] = JSON.parse(elections[group] || \"[]\");\n            if (elected.length >= count) { continue; }\n            elected.push(info.id);\n            await this._client.setAsync(`worker-${info.id}-group`, group);\n            await this._client.hsetAsync(`elections-${this._deploymentKey}`, group, JSON.stringify(elected));\n            break;\n          }\n        }\n      }\n    } finally {\n      await lock.unlock();\n    }\n  }\n\n  public async removeWorker(workerId: string): Promise<void> {\n    log.info(`DocWorkerMap.removeWorker ${workerId}`);\n    const lock = await this._redlock.lock(\"workers-lock\", LOCK_TIMEOUT);\n    try {\n      // Drop out of available set first.\n      await this._client.sremAsync(\"workers-available\", workerId);\n      const group = await this._client.getAsync(`worker-${workerId}-group`) || DEFAULT_GROUP;\n      // TODO: remove `workers-available-${group}`.\n      await this._client.sremAsync(`workers-available-${group}`, workerId);\n      await this._client.zremAsync(`workers-available-by-load-${group}`, workerId);\n      // At this point, this worker should no longer be receiving new doc assignments, though\n      // clients may still be directed to the worker.\n\n      // If we were elected for anything, back out.\n      const elections = await this._client.hgetallAsync(`elections-${this._deploymentKey}`);\n      if (elections) {\n        if (group in elections) {\n          const elected: string[] = JSON.parse(elections[group]);\n          const newElected = elected.filter(worker => worker !== workerId);\n          if (elected.length !== newElected.length) {\n            if (newElected.length > 0) {\n              await this._client.hsetAsync(`elections-${this._deploymentKey}`, group,\n                JSON.stringify(newElected));\n            } else {\n              await this._client.hdelAsync(`elections-${this._deploymentKey}`, group);\n              delete elections[group];\n            }\n          }\n          // We're the last one involved in elections - remove the key entirely.\n          if (Object.keys(elected).length === 0) {\n            await this._client.delAsync(`elections-${this._deploymentKey}`);\n          }\n        }\n      }\n\n      // Now, we start removing the assignments.\n      const assignments = await this._client.smembersAsync(`worker-${workerId}-docs`);\n      if (assignments) {\n        const op = this._client.multi();\n        for (const doc of assignments) { op.del(`doc-${doc}`); }\n        await op.execAsync();\n      }\n\n      // Now remove worker-{workerId}* keys.\n      await this._client.delAsync(`worker-${workerId}-docs`);\n      await this._client.delAsync(`worker-${workerId}-group`);\n      await this._client.delAsync(`worker-${workerId}`);\n\n      // Forget about this worker completely.\n      await this._client.sremAsync(\"workers\", workerId);\n    } finally {\n      await lock.unlock();\n    }\n  }\n\n  public async setWorkerAvailability(workerId: string, available: boolean): Promise<void> {\n    log.info(`DocWorkerMap.setWorkerAvailability ${workerId} ${available}`);\n    const group = await this._client.getAsync(`worker-${workerId}-group`) || DEFAULT_GROUP;\n    if (available) {\n      const docWorker = await this._client.hgetallAsync(`worker-${workerId}`) as DocWorkerInfo | null;\n      if (!docWorker) { throw new Error(\"no doc worker contact info available\"); }\n      // TODO: remove `workers-available-${group}`.\n      await this._client.saddAsync(`workers-available-${group}`, workerId);\n      await this._client.zaddAsync(\n        `workers-available-by-load-${group}`,\n        0.0,\n        workerId,\n      );\n\n      // If we're not assigned exclusively to a group, add this worker also to the general\n      // pool of workers.\n      if (!docWorker.group) {\n        await this._client.saddAsync(\"workers-available\", workerId);\n      }\n    } else {\n      await this._client.sremAsync(\"workers-available\", workerId);\n      // TODO: remove `workers-available-${group}`.\n      await this._client.sremAsync(`workers-available-${group}`, workerId);\n      await this._client.zremAsync(`workers-available-by-load-${group}`, workerId);\n    }\n  }\n\n  /**\n   * Sets the load of the specified worker. Does nothing if the worker is not\n   * in the available set.\n   *\n   * Note: This method should only be called by the worker.\n   */\n  public async setWorkerLoad(workerInfo: DocWorkerInfo, load: number): Promise<void> {\n    log.rawInfo(\"DocWorkerMap.setWorkerLoad\", {\n      workerId: workerInfo.id,\n      load,\n    });\n    const group = workerInfo.group || DEFAULT_GROUP;\n    // The \"XX\" argument means only update the key if it exists.\n    await this._client.zaddAsync(`workers-available-by-load-${group}`, \"XX\", load, workerInfo.id);\n  }\n\n  public async isWorkerRegistered(workerInfo: DocWorkerInfo): Promise<boolean> {\n    const group = workerInfo.group || DEFAULT_GROUP;\n    // TODO: replace with `workers-available-by-load-${group}`.\n    return Boolean(await this._client.sismemberAsync(`workers-available-${group}`, workerInfo.id));\n  }\n\n  public async releaseAssignment(workerId: string, docId: string): Promise<void> {\n    const op = this._client.multi();\n    op.del(`doc-${docId}`);\n    op.srem(`worker-${workerId}-docs`, docId);\n    await op.execAsync();\n  }\n\n  public async getAssignments(workerId: string): Promise<string[]> {\n    return this._client.smembersAsync(`worker-${workerId}-docs`);\n  }\n\n  /**\n   * Defined by IDocWorkerMap.\n   *\n   * Looks up which DocWorker is responsible for this docId.\n   * Responsibility could change at any time after this call, so it\n   * should be treated as a hint, and clients should be prepared to be\n   * refused and need to retry.\n   */\n  public async getDocWorker(docId: string): Promise<DocStatus | null> {\n    const { doc } = await this._getDocAndChecksum(docId);\n    return doc;\n  }\n\n  /**\n   *\n   * Defined by IDocWorkerMap.\n   *\n   * Assigns a DocWorker to this docId if one is not yet assigned.\n   * Note that the assignment could be unmade at any time after this\n   * call if the worker dies, is brought down, or for other potential\n   * reasons in the future such as migration of individual documents\n   * between workers.\n   *\n   * A preferred doc worker can be specified, which will be assigned\n   * if no assignment is already made.\n   *\n   * For the special docId \"import\", return a potential assignment.\n   * It will be up to the doc worker to assign the eventually\n   * created document, if desired.\n   *\n   */\n  public async assignDocWorker(docId: string, workerId?: string): Promise<DocStatus> {\n    log.info(`DocWorkerMap.assignDocWorker ${docId} ${String(workerId)}`);\n    if (docId === \"import\") {\n      const lock = await this._redlock.lock(`workers-lock`, LOCK_TIMEOUT);\n      try {\n        const _workerId = await this._getAvailableWorkerId(DEFAULT_GROUP);\n        if (!_workerId) { throw new Error(\"no doc worker available\"); }\n        const docWorker = await this._client.hgetallAsync(`worker-${_workerId}`) as DocWorkerInfo | null;\n        if (!docWorker) { throw new Error(\"no doc worker contact info available\"); }\n        log.info(`DocWorkerMap.assignDocWorker ${docId} assigned to ${docWorker.id}`);\n        return {\n          docMD5: null,\n          docWorker,\n          isActive: false,\n        };\n      } finally {\n        await lock.unlock();\n      }\n    }\n\n    // Check if a DocWorker is already assigned; if so return result immediately\n    // without locking.\n    let docStatus = await this.getDocWorker(docId);\n    if (docStatus) {\n      log.info(`DocWorkerMap.assignDocWorker ${docId} already assigned to ${docStatus.docWorker.id}`);\n      return docStatus;\n    }\n\n    // No assignment yet, so let's lock and set an assignment up.\n    const lock = await this._redlock.lock(`workers-lock`, LOCK_TIMEOUT);\n\n    try {\n      // Now that we've locked, recheck that the worker hasn't been reassigned\n      // in the meantime.  Return immediately if it has.\n      const docAndChecksum = await this._getDocAndChecksum(docId);\n      docStatus = docAndChecksum.doc;\n      if (docStatus) {\n        log.info(`DocWorkerMap.assignDocWorker ${docId} assigned while acquiring lock ${docStatus.docWorker.id}`);\n        return docStatus;\n      }\n\n      if (!workerId) {\n        // Check if document has a preferred worker group set.\n        const group = await this._client.getAsync(`doc-${docId}-group`) || DEFAULT_GROUP;\n\n        // Let's start off by assigning documents to available workers.\n        workerId = await this._getAvailableWorkerId(group) || undefined;\n        if (!workerId) {\n          // No workers available in the desired worker group.  Rather than refusing to\n          // open the document, we fall back on assigning a worker from any of the workers\n          // available, regardless of grouping.\n          // This limits the impact of operational misconfiguration (bad redis setup,\n          // or not starting enough workers).  It has the downside of potentially disguising\n          // problems, so we log a warning.\n          log.warn(`DocWorkerMap.assignDocWorker ${docId} found no workers for group ${group}`);\n          workerId = await this._client.srandmemberAsync(\"workers-available\") || undefined;\n        }\n        if (!workerId) { throw new Error(\"no doc workers available\"); }\n      } else {\n        if (!await this._client.sismemberAsync(\"workers-available\", workerId)) {\n          throw new Error(`worker ${workerId} not known or not available`);\n        }\n      }\n\n      // Look up how to contact the worker.\n      const docWorker = await this._client.hgetallAsync(`worker-${workerId}`) as DocWorkerInfo | null;\n      if (!docWorker) { throw new Error(\"no doc worker contact info available\"); }\n\n      // We can now construct a DocStatus, preserving any existing checksum.\n      const checksum = docAndChecksum.checksum;\n      const newDocStatus = { docMD5: checksum, docWorker, isActive: true };\n\n      // We add the assignment to worker-{workerId}-docs and save doc-{docId}.\n      const result = await this._client.multi()\n        .sadd(`worker-${workerId}-docs`, docId)\n        .hmset(`doc-${docId}`, {\n          docWorker: JSON.stringify(docWorker),  // redis can't store nested objects, strings only\n          isActive: JSON.stringify(true),         // redis can't store booleans, strings only\n        })\n        .setex(`doc-${docId}-checksum`, CHECKSUM_TTL_MSEC / 1000.0, checksum || \"null\")\n        .execAsync();\n      if (!result) { throw new Error(\"failed to store new assignment\"); }\n      log.info(`DocWorkerMap.assignDocWorker ${docId} assigned to ${newDocStatus.docWorker.id}`);\n      return newDocStatus;\n    } finally {\n      await lock.unlock();\n    }\n  }\n\n  /**\n   *\n   * Defined by IDocWorkerMap.\n   *\n   * Assigns a specific DocWorker to this docId if one is not yet assigned.\n   *\n   */\n  public async getDocWorkerOrAssign(docId: string, workerId: string): Promise<DocStatus> {\n    return this.assignDocWorker(docId, workerId);\n  }\n\n  public async updateDocStatus(docId: string, checksum: string): Promise<void> {\n    return this.updateChecksum(\"doc\", docId, checksum);\n  }\n\n  public async updateChecksum(family: string, key: string, checksum: string) {\n    await this._client.setexAsync(`${family}-${key}-checksum`, CHECKSUM_TTL_MSEC / 1000.0, checksum);\n  }\n\n  public async getChecksum(family: string, key: string) {\n    const checksum = await this._client.getAsync(`${family}-${key}-checksum`);\n    return checksum === \"null\" ? null : checksum;\n  }\n\n  public getPermitStore(prefix: string, defaultTtlMs?: number): IPermitStore {\n    const permitMsec = defaultTtlMs || (this._options?.permitMsec) || PERMIT_TTL_MSEC;\n    const client = this._client;\n    return {\n      async setPermit(permit: Permit, ttlMs?: number): Promise<string> {\n        const key = formatPermitKey(uuidv4(), prefix);\n        // seems like only integer seconds are supported?\n        const duration = ttlMs || permitMsec;\n        await client.setexAsync(key, Math.ceil(duration / 1000.0), JSON.stringify(permit));\n        return key;\n      },\n      async getPermit(key: string): Promise<Permit | null> {\n        if (!checkPermitKey(key, prefix)) { throw new Error(\"permit could not be read\"); }\n        const result = await client.getAsync(key);\n        return result && JSON.parse(result);\n      },\n      async removePermit(key: string): Promise<void> {\n        if (!checkPermitKey(key, prefix)) { throw new Error(\"permit could not be read\"); }\n        await client.delAsync(key);\n      },\n      async close() {\n        // nothing to do\n      },\n      getKeyPrefix() {\n        return formatPermitKey(\"\", prefix);\n      },\n    };\n  }\n\n  public async close(): Promise<void> {\n    for (const cli of this._clients || []) {\n      await cli.quitAsync();\n    }\n  }\n\n  public async getElection(name: string, durationInMs: number): Promise<string | null> {\n    // Could use \"set nx\" for election, but redis docs don't encourage that any more,\n    // favoring redlock:\n    //   https://redis.io/commands/setnx#design-pattern-locking-with-codesetnxcode\n    const redisKey = `nomination-${name}`;\n    const lock = await this._redlock.lock(`${redisKey}-lock`, LOCK_TIMEOUT);\n    try {\n      if (await this._client.getAsync(redisKey) !== null) { return null; }\n      const electionKey = uuidv4();\n      // seems like only integer seconds are supported?\n      await this._client.setexAsync(redisKey, Math.ceil(durationInMs / 1000.0), electionKey);\n      return electionKey;\n    } finally {\n      await lock.unlock();\n    }\n  }\n\n  public async removeElection(name: string, electionKey: string): Promise<void> {\n    const redisKey = `nomination-${name}`;\n    const lock = await this._redlock.lock(`${redisKey}-lock`, LOCK_TIMEOUT);\n    try {\n      const current = await this._client.getAsync(redisKey);\n      if (current === electionKey) {\n        await this._client.delAsync(redisKey);\n      } else if (current !== null) {\n        throw new Error(\"could not remove election\");\n      }\n    } finally {\n      await lock.unlock();\n    }\n  }\n\n  public async getWorkerGroup(workerId: string): Promise<string | null> {\n    return this._client.getAsync(`worker-${workerId}-group`);\n  }\n\n  public async getDocGroup(docId: string): Promise<string | null> {\n    return this._client.getAsync(`doc-${docId}-group`);\n  }\n\n  public async updateDocGroup(docId: string, docGroup: string): Promise<void> {\n    await this._client.setAsync(`doc-${docId}-group`, docGroup);\n  }\n\n  public async removeDocGroup(docId: string): Promise<void> {\n    await this._client.delAsync(`doc-${docId}-group`);\n  }\n\n  public getRedisClient(): RedisClient {\n    return this._client;\n  }\n\n  /**\n   * Fetch the doc-<docId> hash and doc-<docId>-checksum key from redis.\n   * Return as a decoded DocStatus and a checksum.\n   */\n  private async _getDocAndChecksum(docId: string): Promise<{\n    doc: DocStatus | null,\n    checksum: string | null,\n  }> {\n    // Fetch the various elements that go into making a DocStatus\n    const props = await this._client.multi()\n      .hgetall(`doc-${docId}`)\n      .get(`doc-${docId}-checksum`)\n      .execAsync() as [{ [key: string]: any } | null, string | null] | null;\n    // Fields are JSON encoded since redis cannot store them directly.\n    const doc = props?.[0] ? mapValues(props[0], val => JSON.parse(val)) as DocStatus : null;\n    // Redis cannot store a null value, so we encode it as 'null', which does\n    // not match any possible MD5.\n    const checksum = (props?.[1] === \"null\" ? null : props?.[1]) || null;\n    if (doc) { doc.docMD5 = checksum; }  // the checksum goes in the DocStatus too.\n    return { doc, checksum };\n  }\n\n  /**\n   * Returns the ID of an available worker or `null` if no workers are\n   * available.\n   *\n   * If `GRIST_EXPERIMENTAL_WORKER_ASSIGNMENT` is set, selection will be\n   * biased towards workers with lower load. Otherwise, selection will\n   * be random.\n   */\n  private async _getAvailableWorkerId(group: string): Promise<string | null> {\n    // TODO: Make weighted random selection the default and remove feature flag.\n    if (isAffirmative(process.env.GRIST_EXPERIMENTAL_WORKER_ASSIGNMENT)) {\n      return await this._getAvailableWorkerIdByLoad(group);\n    } else {\n      return await this._client.srandmemberAsync(`workers-available-${group}`);\n    }\n  }\n\n  /**\n   * Returns the ID of an available worker or `null` if no workers are\n   * available.\n   *\n   * Workers are chosen using weighted random selection, where weights are\n   * the complement of the load on a worker (`0.0` to `1.0` inclusive).\n   */\n  private async _getAvailableWorkerIdByLoad(group: string): Promise<string | null> {\n    log.debug(`DocWorkerMap._getAvailableWorkerIdByLoad ${group}`);\n    const script = `\n      local workers = redis.call(\"ZRANGE\", KEYS[1], 0, -1, \"WITHSCORES\")\n      if #workers == 0 then\n        return nil\n      end\n\n      -- Convert worker loads to weights\n      local worker_weights = {}\n      local worker_weights_sum = 0\n      for i = 2, #workers, 2 do\n        -- load is stored at even indexes (Lua tables start at index 1)\n        local load = tonumber(workers[i])\n        -- approximate weight by taking its complement\n        local weight = 1 - load\n        if weight < 0 then weight = 0 end\n        table.insert(worker_weights, weight)\n        worker_weights_sum = worker_weights_sum + weight\n      end\n\n      -- We pass in our own random number instead of using math.random(),\n      -- which seems to always use the same seed in Redis\n      local rand = tonumber(ARGV[1])\n\n      -- If all weights are 0, pick a worker randomly\n      if worker_weights_sum == 0 then\n        local count = #workers / 2\n        local idx = math.floor(rand * count) + 1\n        return workers[(idx - 1) * 2 + 1]\n      end\n\n      -- Otherwise, use weighted random selection\n      local target = rand * worker_weights_sum\n      local sum = 0\n      for i = 1, #workers, 2 do\n        local weight = worker_weights[(i + 1) / 2]\n        sum = sum + weight\n        if target <= sum then\n          return workers[i]\n        end\n      end\n\n      return nil\n    `;\n    const keyCountAndValues = [1, `workers-available-by-load-${group}`];\n    const args = [Math.random()];\n    log.debug(`DocWorkerMap._getAvailableWorkerIdByLoad random value is ${args[0]}`);\n    return await this._client.evalAsync(\n      script,\n      ...keyCountAndValues,\n      ...args,\n    );\n  }\n}\n\n// If we don't have redis available and use a DummyDocWorker, it should be a singleton.\nlet dummyDocWorkerMap: DummyDocWorkerMap | null = null;\n\nexport function getDocWorkerMap(): IDocWorkerMap {\n  if (process.env.REDIS_URL) {\n    log.info(\"Creating Redis-based DocWorker\");\n    return new DocWorkerMap();\n  } else {\n    log.info(\"Creating local/dummy DocWorker\");\n    dummyDocWorkerMap = dummyDocWorkerMap || new DummyDocWorkerMap();\n    return dummyDocWorkerMap;\n  }\n}\n"
  },
  {
    "path": "app/gen-server/lib/Doom.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { FullUser } from \"app/common/UserAPI\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { HomeDBManager, Scope } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { scrubUserFromOrg } from \"app/gen-server/lib/scrubUserFromOrg\";\nimport { GristLoginSystem } from \"app/server/lib/GristServer\";\nimport { INotifier } from \"app/server/lib/INotifier\";\nimport { IPermitStore } from \"app/server/lib/Permit\";\n\nimport remove from \"lodash/remove\";\nimport sortBy from \"lodash/sortBy\";\nimport fetch from \"node-fetch\";\n\n/**\n *\n * This is a tool that specializes in deletion of resources.  Deletion needs some\n * coordination between multiple services.\n *\n */\nexport class Doom {\n  constructor(private _dbManager: HomeDBManager, private _permitStore: IPermitStore,\n    private _notifier: Pick<INotifier, \"deleteUser\">, private _loginSystem: GristLoginSystem,\n    private _homeApiUrl: string) {\n  }\n\n  /**\n   * Deletes a team site.\n   *   - Remove billing (fails if there is an outstanding balance).\n   *   - Delete workspaces.\n   *   - Delete org.\n   */\n  public async deleteOrg(orgKey: number) {\n    await this._removeBillingFromOrg(orgKey);\n    const workspaces = await this._getWorkspaces(orgKey);\n    for (const workspace of workspaces) {\n      await this.deleteWorkspace(workspace.id);\n    }\n    const finalWorkspaces = await this._getWorkspaces(orgKey);\n    if (finalWorkspaces.length > 0) {\n      throw new ApiError(`Failed to remove all workspaces from org ${orgKey}`, 500);\n    }\n    // There is a window here in which user could put back docs, would be nice to close it.\n    const scope: Scope = {\n      userId: this._dbManager.getPreviewerUserId(),\n      specialPermit: {\n        org: orgKey,\n      },\n    };\n    await this._dbManager.deleteOrg(scope, orgKey);\n  }\n\n  /**\n   * Deletes a workspace after bloody-mindedly deleting its documents one by one.\n   * Fails if any document is not successfully deleted.\n   */\n  public async deleteWorkspace(workspaceId: number) {\n    const workspace = await this._getWorkspace(workspaceId);\n    for (const doc of workspace.docs) {\n      const permitKey = await this._permitStore.setPermit({ docId: doc.id });\n      try {\n        const docApiUrl = this._homeApiUrl + `/api/docs/${doc.id}`;\n        const result = await fetch(docApiUrl, {\n          method: \"DELETE\",\n          headers: {\n            Permit: permitKey,\n          },\n        });\n        if (result.status !== 200) {\n          const info = await result.json().catch(e => null);\n          throw new ApiError(`failed to delete document ${doc.id}: ${result.status} ${JSON.stringify(info)}`, 500);\n        }\n      } finally {\n        await this._permitStore.removePermit(permitKey);\n      }\n    }\n    const finalWorkspace = await this._getWorkspace(workspaceId);\n    if (finalWorkspace.docs.length > 0) {\n      throw new ApiError(`Failed to remove all documents from workspace ${workspaceId}`, 500);\n    }\n    // There is a window here in which user could put back docs.\n    const scope: Scope = {\n      userId: this._dbManager.getPreviewerUserId(),\n      specialPermit: {\n        workspaceId: workspace.id,\n      },\n    };\n    await this._dbManager.deleteWorkspace(scope, workspaceId);\n  }\n\n  /**\n   * Delete a user.\n   */\n  public async deleteUser(userId: number) {\n    const user = await this._dbManager.getUser(userId);\n    if (!user) { throw new Error(`user not found: ${userId}`); }\n\n    // Don't try scrubbing users from orgs just yet, leave this to be done manually.\n    // Automatic scrubbing could do with a solid test set before being used.\n    /**\n    // Need to scrub the user from any org they are in, except their own personal org.\n    let orgs = await this._getOrgs(userId);\n    for (const org of orgs) {\n      if (org.ownerId !== userId) {\n        await this.deleteUserFromOrg(userId, org);\n      }\n    }\n    */\n    let orgs = await this._getOrgs(userId);\n    if (orgs.length === 1 && orgs[0].ownerId === userId) {\n      await this.deleteOrg(orgs[0].id);\n      orgs = await this._getOrgs(userId);\n    }\n    if (orgs.length > 0) {\n      throw new ApiError(\"Cannot remove user from a site\", 500);\n    }\n\n    // Remove user from sendgrid\n    await this._notifier.deleteUser(userId);\n\n    // Remove user from cognito\n    await this._loginSystem.deleteUser(user);\n\n    // Remove user from our db\n    return await this._dbManager.deleteUser({ userId }, userId);\n  }\n\n  /**\n   * Disentangle a user from a specific site. Everything a user has access to will be\n   * passed to another owner user. If there is no owner available, the call will fail -\n   * you'll need to explicitly delete the site. Owners who are billing managers are\n   * preferred. If there are multiple owners who are billing managers, the choice is\n   * made arbitrarily (alphabetically by email).\n   */\n  public async deleteUserFromOrg(userId: number, org: Organization) {\n    const orgId = org.id;\n    const scope = { userId: this._dbManager.getPreviewerUserId() };\n    const members = this._dbManager.unwrapQueryResult(await this._dbManager.getOrgAccess(scope, orgId));\n    const owners: FullUser[] = members.users\n      .filter(u => u.access === \"owners\" && u.id !== userId);\n    if (owners.length === 0) {\n      throw new ApiError(`No owner available for ${org.id}/${org.domain}/${org.name}`, 401);\n    }\n    if (owners.length > 1) {\n      const billing = await this._dbManager.getBillingAccount(scope, orgId, true);\n      const billingManagers = billing.managers.map(manager => manager.user)\n        .filter(u => u.id !== userId)\n        .map(u => this._dbManager.makeFullUser(u));\n      const billingManagerSet = new Set(billingManagers.map(bm => bm.id));\n      const nonBillingManagers = remove(owners, owner => !billingManagerSet.has(owner.id));\n      if (owners.length === 0) {\n        // Darn, no owners were billing-managers - so put them all back into consideration.\n        owners.push(...nonBillingManagers);\n      }\n    }\n    const candidate = sortBy(owners, [\"email\"])[0];\n    await scrubUserFromOrg(orgId, userId, candidate.id, this._dbManager.connection.manager);\n  }\n\n  // List the sites a user has access to.\n  private async _getOrgs(userId: number) {\n    const orgs = this._dbManager.unwrapQueryResult(await this._dbManager.getOrgs(userId, null,\n      { ignoreEveryoneShares: true }));\n    return orgs;\n  }\n\n  // Get information about a workspace, including the docs in it.\n  private async _getWorkspace(workspaceId: number) {\n    const workspace = this._dbManager.unwrapQueryResult(\n      await this._dbManager.getWorkspace({ userId: this._dbManager.getPreviewerUserId(),\n        showAll: true }, workspaceId));\n    return workspace;\n  }\n\n  // List the workspaces in a site.\n  private async _getWorkspaces(orgKey: number) {\n    const org = this._dbManager.unwrapQueryResult(\n      await this._dbManager.getOrgWorkspaces({ userId: this._dbManager.getPreviewerUserId(),\n        includeSupport: false, showAll: true }, orgKey));\n    return org;\n  }\n\n  // Do whatever it takes to clean up billing information linked with site.\n  private async _removeBillingFromOrg(orgKey: number): Promise<void> {\n    const account = await this._dbManager.getBillingAccount(\n      { userId: this._dbManager.getPreviewerUserId() }, orgKey, false);\n    if (account.stripeCustomerId === null) {\n      // Nothing to do.\n      return;\n    }\n    const url = this._homeApiUrl + `/api/billing/detach?orgId=${orgKey}`;\n    const permitKey = await this._permitStore.setPermit({ org: orgKey });\n    try {\n      const result = await fetch(url, {\n        method: \"POST\",\n        headers: {\n          Permit: permitKey,\n        },\n      });\n      if (result.status !== 200) {\n        // There should be a better way to just pass on the error?\n        const info = await result.json().catch(e => null);\n        throw new ApiError(`failed to delete customer: ${result.status} ${JSON.stringify(info)}`, result.status);\n      }\n    } finally {\n      await this._permitStore.removePermit(permitKey);\n    }\n    await this._dbManager.updateBillingAccount(\n      this._dbManager.getPreviewerUserId(), orgKey, async (billingAccount, transaction) => {\n        billingAccount.stripeCustomerId = null;\n        billingAccount.stripePlanId = null;\n        billingAccount.stripeSubscriptionId = null;\n      });\n  }\n}\n"
  },
  {
    "path": "app/gen-server/lib/Housekeeper.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { delay } from \"app/common/delay\";\nimport { buildUrlId } from \"app/common/gristUrls\";\nimport { isAffirmative } from \"app/common/gutil\";\nimport { normalizedDateTimeString } from \"app/common/normalizedDateTimeString\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { HomeDBManager, Scope } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { fromNow } from \"app/gen-server/sqlUtils\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { getAuthorizedUserId } from \"app/server/lib/Authorizer\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { IElectionStore } from \"app/server/lib/IElectionStore\";\nimport log from \"app/server/lib/log\";\nimport { IPermitStore } from \"app/server/lib/Permit\";\nimport { fetchUntrustedWithAgent } from \"app/server/lib/ProxyAgent\";\nimport { optStringParam, stringParam } from \"app/server/lib/requestUtils\";\nimport { updateGristServerLatestVersion } from \"app/server/lib/updateChecker\";\n\nimport * as express from \"express\";\nimport fetch from \"node-fetch\";\nimport * as Fetch from \"node-fetch\";\nimport { EntityManager } from \"typeorm\";\n\nexport const Timings = {\n  DELETE_TRASH_PERIOD_MS: 1 * 60 * 60 * 1000,  // operate every 1 hour\n  LOG_METRICS_PERIOD_MS: 24 * 60 * 60 * 1000,  // operate every day\n  VERSION_CHECK_PERIOD_MS: 7 * 24 * 60 * 60 * 1000, // operate every week\n  VERSION_CHECK_OFFSET_MS: 20 * 1000, // wait 20 seconds before running the first check\n  TEST_PROXY_URL_PERIOD_MS: 5 * 60 * 1000,     // every five minutes\n  AGE_THRESHOLD_OFFSET: \"-30 days\",            // should be an interval known by postgres + sqlite\n\n  // Don't keep doing synchronous work longer than this.\n  SYNC_WORK_LIMIT_MS: appSettings.section(\"telemetry\").section(\"syncWork\").flag(\"limitMs\").requireInt({\n    envVar: \"GRIST_SYNC_WORK_LIMIT_MS\",\n    defaultValue: 50,\n  }),\n\n  // Once reached SYNC_WORK_LIMIT_MS, take a break of this length.\n  SYNC_WORK_BREAK_MS: appSettings.section(\"telemetry\").section(\"syncWork\").flag(\"breakMs\").requireInt({\n    envVar: \"GRIST_SYNC_WORK_BREAK_MS\",\n    defaultValue: 50,\n  }),\n};\n\nexport const GRIST_TEST_PROXY_URL = appSettings.section(\"proxy\").flag(\"testUrl\").readString({\n  envVar: \"GRIST_TEST_PROXY_URL\",\n});\n\n/**\n * Take care of periodic tasks:\n *\n *  - deleting old soft-deleted documents\n *  - deleting old soft-deleted workspaces\n *  - logging metrics\n *\n * Call start(), keep the object around, and call stop() when shutting down.\n *\n * Some care is taken to elect a single server to do the housekeeping, so if there are\n * multiple home servers, there will be no competition or duplication of effort.\n */\nexport class Housekeeper {\n  private _deleteTrashinterval?: NodeJS.Timeout;\n  private _logMetricsInterval?: NodeJS.Timeout;\n  private _checkVersionUpdatesTimeout?: NodeJS.Timeout;\n  private _checkVersionUpdatesInterval?: NodeJS.Timeout;\n  private _testProxyUrlInterval?: NodeJS.Timeout;\n\n  private _electionKey?: string;\n  private _telemetry = this._server.getTelemetry();\n\n  public constructor(private _dbManager: HomeDBManager, private _server: GristServer,\n    private _permitStore: IPermitStore, private _electionStore: IElectionStore) {\n  }\n\n  /**\n   * Start a ticker to launch housekeeping tasks from time to time.\n   */\n  public async start() {\n    await this.stop();\n    this._deleteTrashinterval = setInterval(() => {\n      this.deleteTrashExclusively().catch(log.warn.bind(log));\n    }, Timings.DELETE_TRASH_PERIOD_MS);\n    this._logMetricsInterval = setInterval(() => {\n      this.logMetricsExclusively().catch(log.warn.bind(log));\n    }, Timings.LOG_METRICS_PERIOD_MS);\n    this._checkVersionUpdatesTimeout = setTimeout(() => {\n      this.checkVersionUpdates().catch(log.warn.bind(log));\n    }, isAffirmative(process.env.GRIST_TEST_IMMEDIATE_VERSION_CHECK) ? 0 : Timings.VERSION_CHECK_OFFSET_MS);\n    this._checkVersionUpdatesInterval = setInterval(() => {\n      this.checkVersionUpdatesExclusively().catch(log.warn.bind(log));\n    }, Timings.VERSION_CHECK_PERIOD_MS);\n    if (GRIST_TEST_PROXY_URL) {\n      this._testProxyUrlInterval = setInterval(() => {\n        this.testProxyUrlExclusively().catch(log.warn.bind(log));\n      }, Timings.TEST_PROXY_URL_PERIOD_MS);\n    }\n  }\n\n  /**\n   * Stop scheduling housekeeping tasks.  Note: doesn't wait for any housekeeping task in progress.\n   */\n  public async stop() {\n    for (const interval of [\n      \"_deleteTrashinterval\",\n      \"_logMetricsInterval\",\n      \"_checkVersionUpdatesInterval\",\n      \"_testProxyUrlInterval\"] as const) {\n      clearInterval(this[interval]);\n      this[interval] = undefined;\n    }\n    clearTimeout(this._checkVersionUpdatesTimeout);\n    this._checkVersionUpdatesTimeout = undefined;\n  }\n\n  /**\n   * Deletes old trash if no other server is working on it or worked on it recently.\n   */\n  public async deleteTrashExclusively(): Promise<boolean> {\n    const electionKey = await this._electionStore.getElection(\"housekeeping\", Timings.DELETE_TRASH_PERIOD_MS / 2.0);\n    if (!electionKey) {\n      log.info(\"Skipping deleteTrash since another server is working on it or worked on it recently\");\n      return false;\n    }\n    this._electionKey = electionKey;\n    await this.deleteTrash();\n    return true;\n  }\n\n  /**\n   * Deletes old trash regardless of what other servers may be doing.\n   */\n  public async deleteTrash() {\n    // Delete old soft-deleted docs\n    const docs = await this._getDocsToDelete();\n    for (const doc of docs) {\n      // Last minute check - is the doc really soft-deleted?\n      if (doc.removedAt === null && doc.workspace.removedAt === null) {\n        throw new Error(`attempted to hard-delete a document that was not soft-deleted: ${doc.id}`);\n      }\n      // In general, documents can only be manipulated with the coordination of the\n      // document worker to which they are assigned.\n      try {\n        await this._server.hardDeleteDoc(doc.id);\n      } catch (err) {\n        if (err instanceof ApiError) {\n          log.error(`failed to delete document ${doc.id}: error status ${err.status} ${err.message}`);\n        } else {\n          log.error(`failed to delete document ${doc.id}: error status ${String(err)}`);\n        }\n      }\n    }\n\n    // Delete old soft-deleted workspaces\n    const workspaces = await this._getWorkspacesToDelete();\n    // Note: there's a small chance a workspace could be undeleted right under the wire,\n    // and a document added, in which case the method we call here would not yet clean\n    // up the docs in s3.  TODO: deal with this.\n    for (const workspace of workspaces) {\n      // Last minute check - is the workspace really soft-deleted?\n      if (workspace.removedAt === null) {\n        throw new Error(`attempted to hard-delete a workspace that was not soft-deleted: ${workspace.id}`);\n      }\n      const scope: Scope = {\n        userId: this._dbManager.getPreviewerUserId(),\n        specialPermit: {\n          workspaceId: workspace.id,\n        },\n      };\n      await this._dbManager.deleteWorkspace(scope, workspace.id);\n    }\n\n    // Delete old forks\n    const forks = await this._getForksToDelete();\n    for (const fork of forks) {\n      const docId = buildUrlId({ trunkId: fork.trunkId!, forkId: fork.id, forkUserId: fork.createdBy! });\n      const permitKey = await this._permitStore.setPermit({ docId });\n      try {\n        const result = await fetch(\n          await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}`),\n          {\n            method: \"DELETE\",\n            headers: {\n              Permit: permitKey,\n            },\n          },\n        );\n        if (result.status !== 200) {\n          log.error(`failed to delete fork ${docId}: error status ${result.status}`);\n        }\n      } finally {\n        await this._permitStore.removePermit(permitKey);\n      }\n    }\n  }\n\n  public async testProxyUrlExclusively(): Promise<boolean> {\n    const electionKey = await this._electionStore.getElection(\"testProxyUrl\", Timings.TEST_PROXY_URL_PERIOD_MS / 2.0);\n    if (!electionKey) {\n      log.info(\"Skipping testProxyUrl since another server is working on it or worked on it recently\");\n      return false;\n    }\n    this._electionKey = electionKey;\n    await this.testProxyUrl();\n    return true;\n  }\n\n  public async testProxyUrl() {\n    const url = GRIST_TEST_PROXY_URL;\n    if (!url) { return; }\n    const response = await fetchUntrustedWithAgent(url, {\n      method: \"GET\",\n      timeout: 5000,\n    }).catch((e) => {\n      return {\n        ok: false,\n        status: \"error\",\n        async text() { return String(e); },\n      };\n    });\n    if (response.ok) {\n      log.rawInfo(\"testProxyUrl passed\", {\n        url,\n        status: response.status,\n      });\n    } else {\n      log.rawError(\"testProxyUrl failed\", {\n        url,\n        status: response.status,\n        body: await response.text().catch(e => String(e)),\n      });\n    }\n  }\n\n  /**\n   * Logs metrics if no other server is working on it or worked on it recently.\n   */\n  public async logMetricsExclusively(): Promise<boolean> {\n    const electionKey = await this._electionStore.getElection(\"logMetrics\", Timings.LOG_METRICS_PERIOD_MS / 2.0);\n    if (!electionKey) {\n      log.info(\"Skipping logMetrics since another server is working on it or worked on it recently\");\n      return false;\n    }\n    this._electionKey = electionKey;\n    await this.logMetrics();\n    return true;\n  }\n\n  /**\n   * Logs metrics regardless of what other servers may be doing.\n   */\n  public async logMetrics() {\n    if (this._telemetry.shouldLogEvent(\"siteUsage\")) {\n      log.warn(\"logMetrics siteUsage starting\");\n      // Avoid using a transaction since it may end up being held up for a while, and for no good\n      // reason (atomicity matters for this reporting).\n      const manager = this._dbManager.connection.manager;\n      const usageSummaries = await this._getOrgUsageSummaries(manager);\n\n      // We sleep occasionally during this logging. We may log many MANY lines, which can hang up a\n      // server for minutes (unclear why; perhaps filling up buffers, and allocating memory very\n      // inefficiently?)\n      await forEachWithBreaks(\"logMetrics siteUsage progress\", usageSummaries, (summary) => {\n        this._telemetry.logEvent(null, \"siteUsage\", {\n          limited: {\n            siteId: summary.site_id,\n            siteType: summary.site_type,\n            inGoodStanding: Boolean(summary.in_good_standing),\n            numDocs: Number(summary.num_docs),\n            numWorkspaces: Number(summary.num_workspaces),\n            numMembers: Number(summary.num_members),\n            lastActivity: normalizedDateTimeString(summary.last_activity),\n            earliestDocCreatedAt: normalizedDateTimeString(summary.earliest_doc_created_at),\n          },\n          full: {\n            stripePlanId: summary.stripe_plan_id,\n          },\n        });\n      });\n    }\n\n    if (this._telemetry.shouldLogEvent(\"siteMembership\")) {\n      log.warn(\"logMetrics siteMembership starting\");\n      const manager = this._dbManager.connection.manager;\n      const membershipSummaries = await this._getOrgMembershipSummaries(manager);\n      await forEachWithBreaks(\"logMetrics siteMembership progress\", membershipSummaries, (summary) => {\n        this._telemetry.logEvent(null, \"siteMembership\", {\n          limited: {\n            siteId: summary.site_id,\n            siteType: summary.site_type,\n            numOwners: Number(summary.num_owners),\n            numEditors: Number(summary.num_editors),\n            numViewers: Number(summary.num_viewers),\n          },\n        });\n      });\n    }\n  }\n\n  public async checkVersionUpdatesExclusively() {\n    const electionKey = await this._electionStore.getElection(\"checkVersionUpdates\",\n      Timings.VERSION_CHECK_PERIOD_MS / 2.0);\n    if (!electionKey) {\n      log.info(\"Skipping checkVersionUpdates since another server is working on it or worked on it recently\");\n      return false;\n    }\n    this._electionKey = electionKey;\n\n    await this.checkVersionUpdates();\n  }\n\n  public async checkVersionUpdates() {\n    await updateGristServerLatestVersion(this._server);\n  }\n\n  public addEndpoints(app: express.Application) {\n    // Allow support user to perform housekeeping tasks for a specific\n    // document.  The tasks necessarily bypass user access controls.\n    // As such, it would be best if these endpoints not offer ways to\n    // read or write the content of a document.\n\n    // Remove unlisted snapshots that are not recorded in inventory.\n    // Once all such snapshots have been removed, there should be no\n    // further need for this endpoint.\n    app.post(\"/api/housekeeping/docs/:docId/snapshots/clean\", this._withSupport(async (_req, docId, headers) => {\n      const url = await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/snapshots/remove`);\n      return fetch(url, {\n        method: \"POST\",\n        body: JSON.stringify({ select: \"unlisted\" }),\n        headers,\n      });\n    }));\n\n    // Remove action history from document.  This may be of occasional\n    // use, for allowing support to help users looking to purge some\n    // information that leaked into document history that they'd\n    // prefer not be there, until there's an alternative.\n    app.post(\"/api/housekeeping/docs/:docId/states/remove\", this._withSupport(async (_req, docId, headers) => {\n      const url = await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/states/remove`);\n      return fetch(url, {\n        method: \"POST\",\n        body: JSON.stringify({ keep: 1 }),\n        headers,\n      });\n    }));\n\n    // Force a document to reload.  Can be useful during administrative\n    // actions.\n    app.post(\"/api/housekeeping/docs/:docId/force-reload\", this._withSupport(async (_req, docId, headers) => {\n      const url = await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/force-reload`);\n      return fetch(url, {\n        method: \"POST\",\n        headers,\n      });\n    }));\n\n    // Move a document to its assigned worker.  Can be useful during administrative\n    // actions.\n    //\n    // Optionally accepts a `group` query param for updating the document's group prior\n    // to moving. A blank string unsets the current group, if any. This is useful for controlling\n    // which worker group the document is assigned a worker from.\n    app.post(\"/api/housekeeping/docs/:docId/assign\", this._withSupport(async (req, docId, headers) => {\n      const url = new URL(await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/assign`));\n      const group = optStringParam(req.query.group, \"group\");\n      if (group !== undefined) { url.searchParams.set(\"group\", group); }\n      return fetch(url.toString(), {\n        method: \"POST\",\n        headers,\n      });\n    }, \"assign-doc\"));\n  }\n\n  /**\n   * For test purposes, removes any exclusive lock on housekeeping.\n   */\n  public async testClearExclusivity(): Promise<void> {\n    if (this._electionKey) {\n      await this._electionStore.removeElection(\"housekeeping\", this._electionKey);\n      this._electionKey = undefined;\n    }\n  }\n\n  private async _getDocsToDelete() {\n    const docs = await this._dbManager.connection.createQueryBuilder()\n      .select(\"docs\")\n      .from(Document, \"docs\")\n      .leftJoinAndSelect(\"docs.workspace\", \"workspaces\")\n      .where(`COALESCE(docs.removed_at, workspaces.removed_at) <= ${this._getThreshold()}`)\n      // the following has no effect (since null <= date is false) but added for clarity\n      .andWhere(\"COALESCE(docs.removed_at, workspaces.removed_at) IS NOT NULL\")\n      .getMany();\n    return docs;\n  }\n\n  private async _getWorkspacesToDelete() {\n    const workspaces = await this._dbManager.connection.createQueryBuilder()\n      .select(\"workspaces\")\n      .from(Workspace, \"workspaces\")\n      .leftJoin(\"workspaces.docs\", \"docs\")\n      .where(`workspaces.removed_at <= ${this._getThreshold()}`)\n      // the following has no effect (since null <= date is false) but added for clarity\n      .andWhere(\"workspaces.removed_at IS NOT NULL\")\n      // wait for workspace to be empty\n      .andWhere(\"docs.id IS NULL\")\n      .getMany();\n    return workspaces;\n  }\n\n  private async _getForksToDelete() {\n    const forks = await this._dbManager.connection.createQueryBuilder()\n      .select(\"forks\")\n      .from(Document, \"forks\")\n      .where(\"forks.trunk_id IS NOT NULL\")\n      .andWhere(`forks.updated_at <= ${this._getThreshold()}`)\n      .getMany();\n    return forks;\n  }\n\n  private async _getOrgUsageSummaries(manager: EntityManager) {\n    const orgs = await manager.createQueryBuilder()\n      .select(\"orgs.id\", \"site_id\")\n      .addSelect(\"products.name\", \"site_type\")\n      .addSelect(\"billing_accounts.in_good_standing\", \"in_good_standing\")\n      .addSelect(\"billing_accounts.stripe_plan_id\", \"stripe_plan_id\")\n      .addSelect(\"COUNT(DISTINCT docs.id)\", \"num_docs\")\n      .addSelect(\"COUNT(DISTINCT workspaces.id)\", \"num_workspaces\")\n      .addSelect(\"COUNT(DISTINCT org_member_users.id)\", \"num_members\")\n      .addSelect(\"MAX(docs.updated_at)\", \"last_activity\")\n      .addSelect(\"MIN(docs.created_at)\", \"earliest_doc_created_at\")\n      .from(Organization, \"orgs\")\n      .leftJoin(\"orgs.workspaces\", \"workspaces\")\n      .leftJoin(\"workspaces.docs\", \"docs\")\n      .leftJoin(\"orgs.billingAccount\", \"billing_accounts\")\n      .leftJoin(\"billing_accounts.product\", \"products\")\n      .leftJoin(\"orgs.aclRules\", \"acl_rules\")\n      .leftJoin(\"acl_rules.group\", \"org_groups\")\n      .leftJoin(\"org_groups.memberUsers\", \"org_member_users\")\n      .where(\"org_member_users.id IS NOT NULL\")\n      .groupBy(\"orgs.id\")\n      .addGroupBy(\"products.id\")\n      .addGroupBy(\"billing_accounts.id\")\n      .getRawMany();\n    return orgs;\n  }\n\n  private async _getOrgMembershipSummaries(manager: EntityManager) {\n    const orgs = await manager.createQueryBuilder()\n      .select(\"orgs.id\", \"site_id\")\n      .addSelect(\"products.name\", \"site_type\")\n      .addSelect(\"SUM(CASE WHEN org_groups.name = 'owners' THEN 1 ELSE 0 END)\", \"num_owners\")\n      .addSelect(\"SUM(CASE WHEN org_groups.name = 'editors' THEN 1 ELSE 0 END)\", \"num_editors\")\n      .addSelect(\"SUM(CASE WHEN org_groups.name = 'viewers' THEN 1 ELSE 0 END)\", \"num_viewers\")\n      .from(Organization, \"orgs\")\n      .leftJoin(\"orgs.billingAccount\", \"billing_accounts\")\n      .leftJoin(\"billing_accounts.product\", \"products\")\n      .leftJoin(\"orgs.aclRules\", \"acl_rules\")\n      .leftJoin(\"acl_rules.group\", \"org_groups\")\n      .leftJoin(\"org_groups.memberUsers\", \"org_member_users\")\n      .where(\"org_member_users.id IS NOT NULL\")\n      .groupBy(\"orgs.id\")\n      .addGroupBy(\"products.id\")\n      .getRawMany();\n    return orgs;\n  }\n\n  /**\n   * TypeORM isn't very adept at handling date representation for\n   * comparisons, so we construct the threshold date in SQL so that we\n   * don't have to deal with its caprices.\n   */\n  private _getThreshold() {\n    return fromNow(this._dbManager.connection.driver.options.type, Timings.AGE_THRESHOLD_OFFSET);\n  }\n\n  // Call a document endpoint with a permit, cleaning up after the call.\n  // Checks that the user is the support user.\n  private _withSupport(\n    callback: (req: express.Request, docId: string, headers: Record<string, string>) => Promise<Fetch.Response>,\n    permitAction?: string,\n  ): express.RequestHandler {\n    return expressWrap(async (req, res) => {\n      const userId = getAuthorizedUserId(req);\n      if (userId !== this._dbManager.getSupportUserId()) {\n        throw new ApiError(\"access denied\", 403);\n      }\n      const docId = stringParam(req.params.docId, \"docId\");\n      const permitKey = await this._permitStore.setPermit({ docId, action: permitAction });\n      try {\n        const result = await callback(req, docId, {\n          \"Permit\": permitKey,\n          \"Content-Type\": \"application/json\",\n        });\n        res.status(result.status);\n        // Return JSON result, or an empty object if no result provided.\n        res.json(await result.json().catch(() => ({})));\n      } finally {\n        await this._permitStore.removePermit(permitKey);\n      }\n    });\n  }\n}\n\n/**\n * Call callback(item) for each item on the list, sleeping periodically to allow other works to\n * happen. Any time work takes more than SYNC_WORK_LIMIT_MS, will sleep for SYNC_WORK_BREAK_MS.\n * At each sleep will log a message with logText and progress info.\n */\nasync function forEachWithBreaks<T>(logText: string, items: T[], callback: (item: T) => void): Promise<void> {\n  const delayMs = Timings.SYNC_WORK_BREAK_MS;\n  const itemsTotal = items.length;\n  let itemsProcesssed = 0;\n  const start = Date.now();\n  let syncWorkStart = start;\n  for (const item of items) {\n    callback(item);\n    itemsProcesssed++;\n    if (Date.now() >= syncWorkStart + Timings.SYNC_WORK_LIMIT_MS) {\n      log.rawInfo(logText, { itemsProcesssed, itemsTotal, delayMs });\n      await delay(delayMs);\n      syncWorkStart = Date.now();\n    }\n  }\n  log.rawInfo(logText, { itemsProcesssed, itemsTotal, timeMs: Date.now() - start });\n}\n"
  },
  {
    "path": "app/gen-server/lib/NotifierTypes.ts",
    "content": "/**\n *\n * Grist email notifications are currently emitted using SendGrid.\n * The types here are compatible with SendGrid, but no longer tied\n * to it.\n *\n * TODO: change \"SendGrid\" name to something more neutral (not\n * done yet only to not introduce a ton of noise hiding real changes).\n *\n */\n\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { INotifier } from \"app/server/lib/INotifier\";\n\n/**\n * Structure of email requests. Each request contains a list of\n * people to send an email to. The \"personalizations\"\n * field (this is SendGrid terminology) contains variables which,\n * when combined with an email template, give a complete message.\n * The email template is not included, but can be looked up using\n * a \"type\" code.\n *\n * There is some cruft related to unsubscription. This is\n * pure SendGrid stuff, only relevant when used with SendGrid\n * for the Grist Labs SaaS.\n */\nexport interface SendGridMail {\n  personalizations: SendGridPersonalization[];\n  from: SendGridAddress;\n  reply_to: SendGridAddress;\n  asm?: {  // unsubscribe settings\n    group_id: number;\n  };\n  mail_settings?: {\n    bypass_list_management?: {\n      enable: boolean;\n    }\n  };\n}\n\nexport interface SendGridAddress {\n  email: string;\n  name: string;\n}\nexport interface DynamicTemplateData {\n  [key: string]: any\n}\nexport interface SendGridPersonalization {\n  to: SendGridAddress[];\n  dynamic_template_data: DynamicTemplateData;\n}\n\n/**\n * Structure of sendgrid invite template.  This is entirely under our control, it\n * is the information we choose to send to an email template for invites.\n */\nexport interface SendGridInviteTemplate {\n  type: \"invite\" | \"billingManagerInvite\";\n  user: FullUser;\n  host: FullUser;\n  resource: SendGridInviteResource;\n  access: SendGridInviteAccess;\n}\n\nexport interface SendGridInviteResource {\n  kind: SendGridInviteResourceKind;\n  kindUpperFirst: string;\n  name: string;\n  url: string;\n}\n\nexport type SendGridInviteResourceKind = \"team site\" | \"workspace\" | \"document\";\n\nexport interface SendGridInviteAccess {\n  role: string;\n  canEditAccess?: boolean;\n  canEdit?: boolean;\n  canView?: boolean;\n  canManageBilling?: boolean;\n}\n\n// Common parameters included in emails to active billing managers.\nexport interface SendGridBillingTemplate {\n  type: \"billing\" | \"memberChange\",\n  org: { id: number, name: string };\n  orgUrl: string;\n  billingUrl: string;\n}\n\nexport interface SendGridMemberChangeTemplate extends SendGridBillingTemplate {\n  type: \"memberChange\";\n  initiatingUser: FullUser;\n  added: FullUser[];\n  removed: FullUser[];\n  org: { id: number, name: string };\n  countBefore: number;\n  countAfter: number;\n  orgUrl: string;\n  billingUrl: string;\n  paidPlan: boolean;\n}\n\nexport interface SendGridConfig {\n  address: {\n    from: SendGridAddress;\n    docNotificationsFrom: SendGridAddress;\n    docNotificationsReplyTo: SendGridAddress;\n  };\n  template: { [templateName in TemplateName]?: string },\n  list: {\n    singleUserOnboarding?: string;\n    appSumoSignUps?: string;\n    trial?: string;\n  },\n  unsubscribeGroup: {\n    invites?: number;\n    billingManagers?: number;\n  },\n  field?: {\n    callScheduled?: string;\n    userRef?: string;\n  },\n}\n\nexport const TwoFactorEvents = StringUnion(\n  \"twoFactorMethodAdded\",\n  \"twoFactorMethodRemoved\",\n  \"twoFactorPhoneNumberChanged\",\n  \"twoFactorEnabled\",\n  \"twoFactorDisabled\",\n);\n\nexport type TwoFactorEvent = typeof TwoFactorEvents.type;\n\nexport const DocNotificationEvents = StringUnion(\n  \"docChanges\",\n  \"comments\",\n  \"suggestions\",\n  \"rowChanges\",\n);\nexport type DocNotificationEvent = typeof DocNotificationEvents.type;\n\nexport interface DocNotificationTemplateBase {\n  // senderAuthorName may be set when there is a single author, to use in the email's \"From\" field.\n  senderAuthorName: string | null;\n}\n\nexport const TemplateName = StringUnion(\n  \"billingManagerInvite\",\n  \"invite\",\n  \"memberChange\",\n  \"trialPeriodEndingSoon\",\n  ...TwoFactorEvents.values,\n  ...DocNotificationEvents.values,\n);\nexport type TemplateName = typeof TemplateName.type;\n\nexport interface SendGridMailWithTemplateId extends SendGridMail {\n  template_id: string;\n}\n\nexport type NotifierEventName = keyof INotifier;\n"
  },
  {
    "path": "app/gen-server/lib/Permissions.ts",
    "content": "export enum Permissions {\n  NONE          = 0x0,\n  // Note that the view permission bit provides view access ONLY to the resource to which\n  // the aclRule belongs - it does not allow listing that resource's children. A resource's\n  // children may only be listed if those children also have the view permission set.\n  VIEW          = 0x1,\n  UPDATE        = 0x2,\n  ADD           = 0x4,\n  // Note that the remove permission bit provides remove access to a resource AND all of\n  // its child resources/ACLs\n  REMOVE        = 0x8,\n  SCHEMA_EDIT   = 0x10,\n  ACL_EDIT      = 0x20,\n  EDITOR        = VIEW | UPDATE | ADD | REMOVE,\n  ADMIN         = EDITOR | SCHEMA_EDIT,\n  OWNER         = ADMIN | ACL_EDIT,\n  // A virtual permission bit signifying that the general public has some access to\n  // the resource via ACLs involving the everyone@ user.\n  PUBLIC        = 0x80,\n}\n"
  },
  {
    "path": "app/gen-server/lib/TypeORMPatches.ts",
    "content": "// This contains two TypeORM patches.\n\n// Patch 1:\n// TypeORM Sqlite driver does not support using transactions in async code, if it is possible\n// for two transactions to get called (one of the whole point of transactions).  This\n// patch adds support for that, based on a monkey patch published in:\n//   https://gist.github.com/aigoncharov/556f8c61d752eff730841170cd2bc3f1\n// Explanation at https://github.com/typeorm/typeorm/issues/1884#issuecomment-380767213\n\n// Patch 2:\n// TypeORM parameters are global, and collisions in setting them are not detected.\n// We add a patch to throw an exception if a parameter value is ever set and then\n// changed during construction of a query.\n\nimport { ApiError } from \"app/common/ApiError\";\nimport { delay } from \"app/common/delay\";\nimport log from \"app/server/lib/log\";\n\nimport * as sqlite3 from \"@gristlabs/sqlite3\";\nimport { Mutex, MutexInterface } from \"async-mutex\";\nimport isEqual from \"lodash/isEqual\";\nimport { EntityManager, ObjectLiteral, QueryRunner, TypeORMError } from \"typeorm\";\nimport { PostgresDriver } from \"typeorm/driver/postgres/PostgresDriver\";\nimport { PostgresQueryRunner } from \"typeorm/driver/postgres/PostgresQueryRunner\";\nimport { SqliteDriver } from \"typeorm/driver/sqlite/SqliteDriver\";\nimport { SqliteQueryRunner } from \"typeorm/driver/sqlite/SqliteQueryRunner\";\nimport { IsolationLevel } from \"typeorm/driver/types/IsolationLevel\";\nimport {\n  QueryRunnerProviderAlreadyReleasedError,\n} from \"typeorm/error/QueryRunnerProviderAlreadyReleasedError\";\nimport { QueryBuilder } from \"typeorm/query-builder/QueryBuilder\";\n\n// Print a warning for transactions that take longer than this.\nconst SLOW_TRANSACTION_MS = 5000;\n\n/*************************************************************\n * Patch 1\n * Make transactions work with SQLite.\n *************************************************************/\n\n// A singleton mutex for all sqlite transactions.\nconst mutex = new Mutex();\n\nclass SqliteQueryRunnerPatched extends SqliteQueryRunner {\n  private _releaseMutex: MutexInterface.Releaser | null;\n\n  public async startTransaction(level?: any): Promise<void> {\n    this._releaseMutex = await mutex.acquire();\n    return super.startTransaction(level);\n  }\n\n  public async commitTransaction(): Promise<void> {\n    if (!this._releaseMutex) {\n      throw new Error(\"SqliteQueryRunnerPatched.commitTransaction -> mutex releaser unknown\");\n    }\n    await super.commitTransaction();\n    this._releaseMutex();\n    this._releaseMutex = null;\n  }\n\n  public async rollbackTransaction(): Promise<void> {\n    if (!this._releaseMutex) {\n      throw new Error(\"SqliteQueryRunnerPatched.rollbackTransaction -> mutex releaser unknown\");\n    }\n    await super.rollbackTransaction();\n    this._releaseMutex();\n    this._releaseMutex = null;\n  }\n\n  public async connect(): Promise<any> {\n    if (!this.isTransactionActive) {\n      const release = await mutex.acquire();\n      release();\n    }\n    return super.connect();\n  }\n}\n\nclass SqliteDriverPatched extends SqliteDriver {\n  public createQueryRunner(): QueryRunner {\n    if (!this.queryRunner) {\n      this.queryRunner = new SqliteQueryRunnerPatched(this);\n    }\n    return this.queryRunner;\n  }\n\n  protected loadDependencies(): void {\n    // Use our own sqlite3 module, which is a fork of the original.\n    this.sqlite = sqlite3;\n  }\n}\n\n// Patch the underlying SqliteDriver, since it's impossible to convince typeorm to use only our\n// patched classes. (Previously we patched DriverFactory and Connection, but those would still\n// create an unpatched SqliteDriver and then overwrite it.)\nSqliteDriver.prototype.createQueryRunner = SqliteDriverPatched.prototype.createQueryRunner;\n(SqliteDriver.prototype as any).loadDependencies = (SqliteDriverPatched.prototype as any).loadDependencies;\n\nexport function applyPatch() {\n  // tslint: disable-next-line\n  EntityManager.prototype.transaction = async function<T>(\n    arg1: IsolationLevel | ((entityManager: EntityManager) => Promise<T>),\n    arg2?: (entityManager: EntityManager) => Promise<T>): Promise<T> {\n    const isolation =\n      typeof arg1 === \"string\" ?\n        arg1 :\n        undefined;\n    const runInTransaction =\n      typeof arg1 === \"function\" ?\n        arg1 :\n        arg2;\n\n    if (!runInTransaction) {\n      throw new TypeORMError(\n        `Transaction method requires callback in second parameter if isolation level is supplied.`,\n      );\n    }\n\n    if (this.queryRunner?.isReleased) {\n      throw new QueryRunnerProviderAlreadyReleasedError();\n    }\n    const queryRunner = this.queryRunner || this.connection.createQueryRunner();\n    const isSqlite = this.connection.driver.options.type === \"sqlite\";\n    try {\n      const runOrRollback = async () => {\n        try {\n          await queryRunner.startTransaction(isolation);\n\n          const start = Date.now();\n\n          const timer = setInterval(() => {\n            const timeMs = Date.now() - start;\n            log.warn(`TypeORM transaction slow: [${arg1} ${arg2}]`, { timeMs });\n          }, SLOW_TRANSACTION_MS);\n\n          try {\n            const result = await runInTransaction(queryRunner.manager);\n            await queryRunner.commitTransaction();\n            return result;\n          } finally {\n            clearInterval(timer);\n          }\n        } catch (err) {\n          if (!(err instanceof ApiError)) {\n            // Log with a stack trace in case of unexpected DB problems. Don't bother logging for\n            // errors (like ApiError) that clearly come from our own code.\n            log.debug(\"TypeORM transaction error\", err);\n          }\n          try {\n            // we throw original error even if rollback thrown an error\n            await queryRunner.rollbackTransaction();\n            // tslint: disable-next-line\n          } catch (rollbackError) {\n            // tslint: disable-next-line\n          }\n          throw err;\n        }\n      };\n      if (isSqlite) {\n        return await callWithRetry(runOrRollback, {\n          // Transactions may fail immediately if there are connections from\n          // multiple processes, regardless of busy_timeout setting. Add a\n          // retry for this kind of failure. This is relevant to tests, which\n          // use connections from multiple processes, but not to single-process\n          // instances of Grist, or instances of Grist that use Postgres for the\n          // home server.\n          worthRetry: e => Boolean(e.message.match(/SQLITE_BUSY/)),\n          firstDelayMsec: 10,\n          factor: 1.25,\n          maxTotalMsec: 3000,\n        });\n      } else {\n        // When not using SQLite, don't do anything special.\n        return await runOrRollback();\n      }\n    } finally {\n      await queryRunner.release();\n    }\n  };\n}\n\n/**\n * Call an operation, and if it fails with an error that is worth retrying\n * (or any error if worthRetry callback is not specified), retry it after\n * a delay of firstDelayMsec. Retries are repeated with delays growing by\n * the specified factor (or 2.0 if not specified). Stop if maxTotalMsec is\n * specified and has passed.\n */\nasync function callWithRetry<T>(op: () => Promise<T>, options: {\n  worthRetry?: (err: Error) => boolean,\n  maxTotalMsec?: number,\n  firstDelayMsec: number,\n  factor?: number,\n}): Promise<T> {\n  const startedAt = Date.now();\n  let dt = options.firstDelayMsec;\n  while (true) {\n    try {\n      return await op();\n    } catch (e) {\n      // throw if not worth retrying\n      if (options.worthRetry && e instanceof Error && !options.worthRetry(e)) {\n        throw e;\n      }\n      // throw if max time has expired\n      if (options.maxTotalMsec && Date.now() - startedAt > options.maxTotalMsec) {\n        throw e;\n      }\n      // otherwise wait a bit and retry\n      await delay(dt);\n      dt *= options.factor ?? 2.0;\n    }\n  }\n}\n\n/*************************************************************\n * Patch 2\n * Watch out for parameter collisions, shout loudly if they\n * happen.\n *************************************************************/\n\n// Augment the interface globally\ndeclare module \"typeorm/query-builder/QueryBuilder\" {\n  interface QueryBuilder<Entity> {\n    chain<Q extends QueryBuilder<Entity>>(this: Q, callback: (qb: Q) => Q): Q\n  }\n}\n\nabstract class QueryBuilderPatched<T extends ObjectLiteral> extends QueryBuilder<T> {\n  private static _origSetParameter = QueryBuilder.prototype.setParameter;\n  public setParameter(key: string, value: any): this {\n    const prev = this.expressionMap.parameters[key];\n    if (prev !== undefined && !isEqual(prev, value)) {\n      throw new Error(`TypeORM parameter collision for key '${key}' ('${prev}' vs '${value}')`);\n    }\n    QueryBuilderPatched._origSetParameter.call(this, key, value);\n    return this;\n  }\n\n  /**\n   * A very simple helper to neater code organization. For instance, instead of\n   *    qb = myFunc(qb.foo().bar());\n   * You can do\n   *    qb = qb.foo().bar().chain(myFunc).baz();\n   * This way the order in which myFunc is applied is clearer.\n   */\n  public chain<Q extends QueryBuilder<T>>(this: Q, callback: (qb: Q) => Q): Q {\n    return callback(this);\n  }\n}\n\n(QueryBuilder.prototype as any).setParameter = (QueryBuilderPatched.prototype as any).setParameter;\n(QueryBuilder.prototype as any).chain = (QueryBuilderPatched.prototype as any).chain;\n\n/*************************************************************\n * Patch 3\n * Allow use of PREPAREd statements with Postgres.\n *************************************************************/\n\nconst preparedSqlToName = new Map<string, string>();\nconst preparedNameToSql = new Map<string, string>();\nconst usedNames = new Set<string>();\n\n/**\n * Return true if a query is worth preparing. This would probably\n * be best judged by hand, but there's not much downside to\n * preparing any longish queries, as long as we don't end up with\n * too many of them. That could happen if queries contain embedded\n * parameters in their text.\n */\nfunction worthPreparing(sql: string) {\n  return sql.length > 120;\n}\n\n/**\n * Give a label to a query. We can't call PREPARE directly, or\n * pass statement names through TypeORM, so we have to do some\n * hijinks.\n */\nexport function setPreparedStatement(name: string, sql: string) {\n  if (preparedNameToSql.has(name)) { return; }\n  preparedSqlToName.set(sql, name);\n  preparedNameToSql.set(name, sql);\n}\n\n/**\n * If a query looks to be worth preparing and we haven't already,\n * plan on doing so.\n */\nexport function maybePrepareStatement(sql: string) {\n  if (worthPreparing(sql) && !preparedSqlToName.has(sql)) {\n    const key = pickStatementName(sql);\n    setPreparedStatement(key, sql);\n  }\n}\n\nconst prefixCounts = new Map<string, number>();\n\n/**\n * Pick a name for a statement.\n */\nexport function pickStatementName(query: string): string {\n  const prefix = query.toLowerCase().replace(/[\"']/g, \"\").replace(/[^a-z0-9]/g, \"_\").slice(0, 16);\n  const count = (prefixCounts.get(prefix) || 0) + 1;\n  prefixCounts.set(prefix, count);\n  return `prep_${prefix}_${count}`;\n}\n\n/**\n * A test function for checking how many statements are getting\n * prepared and if they are properly used.\n */\nexport function testGetPreparedStatementCount() {\n  return {\n    preparedCount: preparedNameToSql.size,\n    usedCount: usedNames.size,\n  };\n}\n\n/**\n * Reset bookwork for tracking prepared statements.\n */\nexport function testResetPreparedStatements() {\n  preparedSqlToName.clear();\n  preparedNameToSql.clear();\n  usedNames.clear();\n}\n\n/**\n * Patch typeorm postgres driver to use pg library \"name\"\n * feature for recognized queries.\n */\nexport class PostgresQueryRunnerPatched extends PostgresQueryRunner {\n  public async connect() {\n    const result = await super.connect();\n    const client = this.databaseConnection;\n    if (!client._preparedWrapped) {\n      const originalQuery = client.query.bind(client);\n      client.query = async (text: any, values?: any[]) => {\n        if (typeof text === \"string\" && worthPreparing(text) && preparedSqlToName.size) {\n          const name = preparedSqlToName.get(text);\n          if (name) {\n            if (!usedNames.has(name)) {\n              usedNames.add(name);\n              log.rawDebug(`used a new prepared statement`, {\n                name,\n                usedCount: usedNames.size,\n                preparedCount: preparedNameToSql.size,\n              });\n            }\n            return originalQuery({\n              name, text, values,\n            });\n          }\n        }\n        return originalQuery(text, values);\n      };\n      client._preparedWrapped = true;\n    }\n    return result;\n  }\n}\n\nexport class PostgresDriverPatched extends PostgresDriver {\n  public createQueryRunner(mode: \"master\" | \"slave\"): PostgresQueryRunner {\n    return new PostgresQueryRunnerPatched(this, mode);\n  }\n}\n\nPostgresDriver.prototype.createQueryRunner = PostgresDriverPatched.prototype.createQueryRunner;\n"
  },
  {
    "path": "app/gen-server/lib/Usage.ts",
    "content": "import { Document } from \"app/gen-server/entity/Document\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport log from \"app/server/lib/log\";\n\n// Frequency of logging usage information.  Not something we need\n// to track with much granularity.\nconst USAGE_PERIOD_MS = 1 * 60 * 60 * 1000;   // log every 1 hour\n\n/**\n * Occasionally log usage information - number of users, orgs,\n * docs, etc.\n */\nexport class Usage {\n  private _interval: NodeJS.Timeout;\n  private _currentOperation?: Promise<void>;\n\n  public constructor(private _dbManager: HomeDBManager) {\n    this._interval = setInterval(() => this.apply(), USAGE_PERIOD_MS);\n    // Log once at beginning, in case we roll over servers faster than\n    // the logging period for an extended length of time,\n    // and to raise the visibility of this logging step so if it gets\n    // slow devs notice.\n    this.apply();\n  }\n\n  /**\n   * Remove any scheduled operation, and wait for the current one to complete\n   * (if one is in progress).\n   */\n  public async close() {\n    clearInterval(this._interval);\n    await this._currentOperation;\n  }\n\n  public apply() {\n    if (!this._currentOperation) {\n      this._currentOperation = this._apply()\n        .finally(() => this._currentOperation = undefined);\n    }\n  }\n\n  private async _apply(): Promise<void> {\n    try {\n      const manager = this._dbManager.connection.manager;\n      // raw count of users\n      const userCount = await manager.count(User);\n      // users who have logged in at least once\n      const userWithLoginCount = await manager.createQueryBuilder()\n        .from(User, \"users\")\n        .where(\"first_login_at is not null\")\n        .getCount();\n      // raw count of organizations (excluding personal orgs)\n      const orgCount = await manager.createQueryBuilder()\n        .from(Organization, \"orgs\")\n        .where(\"owner_id is null\")\n        .getCount();\n      // organizations with subscriptions that are in a non-terminated state\n      const orgInGoodStandingCount = await manager.createQueryBuilder()\n        .from(Organization, \"orgs\")\n        .leftJoin(\"orgs.billingAccount\", \"billing_accounts\")\n        .where(\"owner_id is null\")\n        .andWhere(\"billing_accounts.in_good_standing = true\")\n        .getCount();\n      // raw count of documents\n      const docCount = await manager.count(Document);\n      log.rawInfo(\"activity\", {\n        docCount,\n        orgCount,\n        orgInGoodStandingCount,\n        userCount,\n        userWithLoginCount,\n      });\n    } catch (e) {\n      log.warn(\"Error in Usage._apply\", e);\n    }\n  }\n}\n"
  },
  {
    "path": "app/gen-server/lib/homedb/Caches.ts",
    "content": "import { DocPrefs } from \"app/common/Prefs\";\nimport { PermissionData } from \"app/common/UserAPI\";\nimport { DocScope, HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { QueryResult } from \"app/gen-server/lib/homedb/Interfaces\";\nimport { PubSubCache } from \"app/server/lib/PubSubCache\";\nimport { IPubSubManager } from \"app/server/lib/PubSubManager\";\n\n// Defaults for how long we'll cache the results of these calls for. If invalidations were\n// perfect, we could cache indefinitely, but if we've missed cases, or if the DB is changed\n// outside of the normal app code, this is how long values may remain stale.\nconst DocAccessCacheTTL = 5 * 60_000;\nconst DocPrefsCacheTTL = 5 * 60_000;\n\n// A hook for tests to override the default values.\nexport const Deps = { DocAccessCacheTTL, DocPrefsCacheTTL };\n\nexport class HomeDBCaches {\n  private _docAccessCache: PubSubCache<string, QueryResult<PermissionData>>;\n  private _docPrefsCache: PubSubCache<string, Map<number | null, DocPrefs>>;\n\n  constructor(\n    private readonly _homeDb: HomeDBManager,\n    pubSubManager: IPubSubManager,\n  ) {\n    this._docAccessCache = new PubSubCache<string, QueryResult<PermissionData>>({\n      pubSubManager,\n      fetch: this._getDocAccess.bind(this),\n      getChannel: docId => `docAccessCache:${docId}`,\n      ttlMs: Deps.DocAccessCacheTTL,\n    });\n\n    this._docPrefsCache = new PubSubCache<string, Map<number | null, DocPrefs>>({\n      pubSubManager,\n      fetch: docId => this._homeDb.getDocPrefsForUsers(docId, \"any\"),\n      getChannel: docId => `docPrefsCache:${docId}`,\n      ttlMs: Deps.DocPrefsCacheTTL,\n    });\n  }\n\n  public getDocAccess(docId: string) { return this._docAccessCache.getValue(docId); }\n  public getDocPrefs(docId: string) { return this._docPrefsCache.getValue(docId); }\n\n  public invalidateDocAccess(docIds: string[]) { return this._docAccessCache.invalidateKeys(docIds); }\n  public invalidateDocPrefs(docIds: string[]) { return this._docPrefsCache.invalidateKeys(docIds); }\n\n  // Helper to add an invalidation callback to a list of callbacks. We normally use these to queue\n  // invalidations to run after a transaction is committed (to ensure that any refetches they\n  // trigger in other servers see the effects of the transaction).\n  public addInvalidationDocAccess(callbacks: (() => Promise<void>)[], docIds: string[]) {\n    callbacks.push(this.invalidateDocAccess.bind(this, docIds));\n  }\n\n  public addInvalidationDocPrefs(callbacks: (() => Promise<void>)[], docIds: string[]) {\n    callbacks.push(this.invalidateDocPrefs.bind(this, docIds));\n  }\n\n  // Clear all caches.\n  public clear() {\n    this._docAccessCache.clear();\n    this._docPrefsCache.clear();\n  }\n\n  private _getDocAccess(docId: string): Promise<QueryResult<PermissionData>> {\n    const bypassScope: DocScope = { userId: this._homeDb.getPreviewerUserId(), urlId: docId };\n    return this._homeDb.getDocAccess(bypassScope, { flatten: true, excludeUsersWithoutAccess: true });\n  }\n}\n"
  },
  {
    "path": "app/gen-server/lib/homedb/GroupsManager.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport * as roles from \"app/common/roles\";\nimport { AclRule } from \"app/gen-server/entity/AclRule\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { GroupWithMembersDescriptor, NonGuestGroup,\n  Resource, RoleGroupDescriptor, RunInTransaction } from \"app/gen-server/lib/homedb/Interfaces\";\nimport { UsersManager } from \"app/gen-server/lib/homedb/UsersManager\";\nimport { Permissions } from \"app/gen-server/lib/Permissions\";\n\nimport { EntityManager } from \"typeorm\";\n\nexport type GroupTypes = typeof Group.ROLE_TYPE | typeof Group.TEAM_TYPE;\n\n/**\n * Class responsible for Groups and Roles Management.\n *\n * It's only meant to be used by HomeDBManager. If you want to use one of its (instance or static) methods,\n * please make an indirection which passes through HomeDBManager.\n */\nexport class GroupsManager {\n  // All groups.\n  public get defaultGroups(): RoleGroupDescriptor[] {\n    return this._defaultGroups;\n  }\n\n  // Groups whose permissions are inherited from parent resource to child resources.\n  public get defaultBasicGroups(): RoleGroupDescriptor[] {\n    return this._defaultGroups\n      .filter(_grpDesc => _grpDesc.nestParent);\n  }\n\n  // Groups that are common to all resources.\n  public get defaultCommonGroups(): RoleGroupDescriptor[] {\n    return this._defaultGroups\n      .filter(_grpDesc => !_grpDesc.orgOnly);\n  }\n\n  public get defaultGroupNames(): roles.Role[] {\n    return this._defaultGroups.map(_grpDesc => _grpDesc.name);\n  }\n\n  public get defaultBasicGroupNames(): roles.BasicRole[] {\n    return this.defaultBasicGroups\n      .map(_grpDesc => _grpDesc.name) as roles.BasicRole[];\n  }\n\n  public get defaultNonGuestGroupNames(): roles.NonGuestRole[] {\n    return this._defaultGroups\n      .filter(_grpDesc => _grpDesc.name !== roles.GUEST)\n      .map(_grpDesc => _grpDesc.name) as roles.NonGuestRole[];\n  }\n\n  public get defaultCommonGroupNames(): roles.NonMemberRole[] {\n    return this.defaultCommonGroups\n      .map(_grpDesc => _grpDesc.name) as roles.NonMemberRole[];\n  }\n\n  // Returns a map of userIds to the user's strongest default role on the given resource.\n  // The resource's aclRules, groups, and memberUsers must be populated.\n  public static getMemberUserRoles<T extends roles.Role>(res: Resource, allowRoles: T[]): { [userId: string]: T } {\n    // Map of userId to the strongest role for that user among res.aclRules, filtered by allowRoles.\n    const userMap: { [userId: string]: T } = {};\n    for (const aclRule of res.aclRules) {\n      const role = aclRule.group.name as T;\n      if (allowRoles.includes(role)) {\n        for (const { id } of aclRule.group.memberUsers) {\n          // If the user is already present in another group, use the more powerful role name.\n          userMap[id] = userMap[id] ? roles.getStrongestRole(userMap[id], role) : role;\n        }\n      }\n    }\n    return userMap;\n  }\n\n  /**\n   * Five aclRules, each with one group (with the names 'owners', 'editors', 'viewers',\n   * 'guests', and 'members') are created by default on every new entity (Organization,\n   * Workspace, Document). These special groups are documented in the _defaultGroups\n   * constant below.\n   *\n   * When a child resource is created under a parent (i.e. when a new Workspace is created\n   * under an Organization), special groups with a truthy 'nestParent' property are set up\n   * to include in their memberGroups a single group on initialization - the parent's\n   * corresponding special group. Special groups with a falsy 'nextParent' property are\n   * empty on intialization.\n   *\n   * NOTE: The groups are ordered from most to least permissive, and should remain that way.\n   * TODO: app/common/roles already contains an ordering of the default roles. Usage should\n   * be consolidated.\n   */\n  private readonly _defaultGroups: RoleGroupDescriptor[] = [{\n    name: roles.OWNER,\n    permissions: Permissions.OWNER,\n    nestParent: true,\n  }, {\n    name: roles.EDITOR,\n    permissions: Permissions.EDITOR,\n    nestParent: true,\n  }, {\n    name: roles.VIEWER,\n    permissions: Permissions.VIEW,\n    nestParent: true,\n  }, {\n    name: roles.GUEST,\n    permissions: Permissions.VIEW,\n    nestParent: false,\n  }, {\n    name: roles.MEMBER,\n    permissions: Permissions.VIEW,\n    nestParent: false,\n    orgOnly: true,\n  }];\n\n  public constructor(private _usersManager: UsersManager, private _runInTransaction: RunInTransaction) {}\n\n  /**\n   * Helper for adjusting acl inheritance rules. Given an array of top-level groups from the\n   * resource of interest, and an array of inherited groups belonging to the parent resource,\n   * moves the inherited groups to the group with the destination name or lower, if their\n   * permission level is lower. If the destination group name is omitted, the groups are\n   * moved to their original inheritance locations. If the destination group name is null,\n   * the groups are all removed and there is no access inheritance to this resource.\n   * Returns the updated array of top-level groups. These returned groups should be saved\n   * to update the group inheritance in the database.\n   *\n   * For all passed-in groups, their .memberGroups will be reset. For\n   * the basic roles (owner | editor | viewer), these will get updated\n   * to include inheritedGroups, with roles reduced to dest when dest\n   * is given. All of the basic roles must be present among\n   * groups. Any non-basic roles present among inheritedGroups will be\n   * ignored.\n   *\n   * Does not modify inheritedGroups.\n   */\n  public moveInheritedGroups(\n    groups: NonGuestGroup[], inheritedGroups: Group[], dest?: roles.BasicRole | null,\n  ): void {\n    // Limit scope to those inheritedGroups that have basic roles (viewers, editors, owners).\n    inheritedGroups = inheritedGroups.filter(group => roles.isBasicRole(group.name));\n\n    // NOTE that the special names constant is ordered from least to most permissive.\n    const reverseDefaultNames = this.defaultBasicGroupNames.reverse();\n\n    // The destination must be a reserved inheritance group or null.\n    if (dest && !reverseDefaultNames.includes(dest)) {\n      throw new Error(\"moveInheritedGroups called with invalid destination name\");\n    }\n\n    // Mapping from group names to top-level groups\n    const topGroups: { [groupName: string]: NonGuestGroup } = {};\n    groups.forEach((grp) => {\n      // Note that this has a side effect of initializing the memberGroups arrays.\n      grp.memberGroups = [];\n      topGroups[grp.name] = grp;\n    });\n\n    // The destFunc maps from an inherited group to its required top-level group name.\n    const destFunc = (inherited: Group) =>\n      dest === null ? null : reverseDefaultNames.find(sp => sp === inherited.name || sp === dest);\n\n    // Place inherited groups (this has the side-effect of updating member groups)\n    inheritedGroups.forEach((grp) => {\n      if (!roles.isBasicRole(grp.name)) {\n        // We filtered out such groups at the start of this method, but just in case...\n        throw new Error(`${grp.name} is not an inheritable group`);\n      }\n      const moveTo = destFunc(grp);\n      if (moveTo) {\n        topGroups[moveTo].memberGroups.push(grp);\n      }\n    });\n  }\n\n  /**\n   * Update the set of users in a group.  TypeORM's .save() method appears to be\n   * unreliable for a ManyToMany relation with a table with a multi-column primary\n   * key, so we make the update using explicit deletes and inserts.\n   */\n  public async setGroupUsers(manager: EntityManager, groupId: number, usersBefore: User[],\n    usersAfter: User[]) {\n    const userIdsBefore = new Set(usersBefore.map(u => u.id));\n    const userIdsAfter = new Set(usersAfter.map(u => u.id));\n    const toDelete = [...userIdsBefore].filter(id => !userIdsAfter.has(id));\n    const toAdd = [...userIdsAfter].filter(id => !userIdsBefore.has(id));\n    if (toDelete.length > 0) {\n      await manager.createQueryBuilder()\n        .delete()\n        .from(\"group_users\")\n        .whereInIds(toDelete.map(id => ({ user_id: id, group_id: groupId })))\n        .execute();\n    }\n    if (toAdd.length > 0) {\n      await manager.createQueryBuilder()\n        .insert()\n        // Since we are adding new records in group_users, we may get a duplicate key error if two documents\n        // are added at the same time (even in transaction, since we are not blocking the whole table).\n        .orIgnore()\n        .into(\"group_users\")\n        .values(toAdd.map(id => ({ user_id: id, group_id: groupId })))\n        .execute();\n    }\n  }\n\n  /**\n   * Returns a name to group mapping for the standard groups. Useful when adding a new child\n   * entity. Finds and includes the correct parent groups as member groups.\n   */\n  public createGroups(inherit?: Organization | Workspace, ownerId?: number): { [name: string]: Group } {\n    const groupMap: { [name: string]: Group } = {};\n    this.defaultGroups.forEach((groupProps) => {\n      if (!groupProps.orgOnly || !inherit) {\n        // Skip this group if it's an org only group and the resource inherits from a parent.\n        const group = Group.create({\n          name: groupProps.name,\n          type: Group.ROLE_TYPE,\n        });\n        if (inherit) {\n          this.setInheritance(group, inherit);\n        }\n        groupMap[groupProps.name] = group;\n      }\n    });\n    // Add the owner explicitly to the owner group.\n    if (ownerId) {\n      const ownerGroup = groupMap[roles.OWNER];\n      const user = new User();\n      user.id = ownerId;\n      ownerGroup.memberUsers = [user];\n    }\n    return groupMap;\n  }\n\n  // Sets the given group to inherit the groups in the given parent resource.\n  public setInheritance(group: Group, parent: Organization | Workspace) {\n    // Add the parent groups to the group\n    const groupProps = this.defaultGroups.find(special => special.name === group.name);\n    if (!groupProps) {\n      throw new Error(`Non-standard group passed to _addInheritance: ${group.name}`);\n    }\n    if (groupProps.nestParent) {\n      const parentGroups = (parent.aclRules as AclRule[]).map((_aclRule: AclRule) => _aclRule.group);\n      const inheritGroup = parentGroups.find((_parentGroup: Group) => _parentGroup.name === group.name);\n      if (!inheritGroup) {\n        throw new Error(`Special group ${group.name} not found in ${parent.name} for inheritance`);\n      }\n      group.memberGroups = [inheritGroup];\n    }\n  }\n\n  // Returns the most permissive default role that does not have more permissions than the passed\n  // in argument.\n  public getRoleFromPermissions(permissions: number): roles.Role | null {\n    permissions &= ~Permissions.PUBLIC;    const group = this.defaultBasicGroups.find(grp =>\n      (permissions & grp.permissions) === grp.permissions);    return group ? group.name : null;\n  }\n\n  // Returns the maxInheritedRole group name set on a resource.\n  // The resource's aclRules, groups, and memberGroups must be populated.\n  public getMaxInheritedRole(res: Workspace | Document): roles.BasicRole | null {\n    const groups = (res.aclRules as AclRule[]).map((_aclRule: AclRule) => _aclRule.group);\n    let maxInheritedRole: roles.NonGuestRole | null = null;\n    for (const name of this.defaultBasicGroupNames) {\n      const group = groups.find(_grp => _grp.name === name);\n      if (!group) {\n        throw new Error(`Error in _getMaxInheritedRole: group ${name} not found in ${res.name}`);\n      }\n      if (group.memberGroups.length > 0) {\n        maxInheritedRole = name;\n        break;\n      }\n    }\n    return roles.getEffectiveRole(maxInheritedRole);\n  }\n\n  /**\n   * Create a Group.\n   * @param groupDescriptor - The descriptor for the group to be created.\n   * @param optManager - Optional EntityManager to use for the transaction.\n   * @returns The created Group.\n   */\n  public async createGroup(groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager) {\n    return await this._runInTransaction(optManager, async (manager) => {\n      if (groupDescriptor.type === Group.TEAM_TYPE) {\n        await this._throwIfTeamNameCollision(groupDescriptor.name, manager);\n      }\n      const group = Group.create({\n        type: groupDescriptor.type,\n        name: groupDescriptor.name,\n        memberUsers: await this._usersManager.getUsersByIdsStrict(groupDescriptor.memberUsers ?? [], manager),\n        memberGroups: await this._getGroupsByIdsStrict(groupDescriptor.memberGroups ?? [], manager),\n      });\n      return await manager.save(group);\n    });\n  }\n\n  /**\n   * Overwrite a Role Group.\n   * @param id - The id of the Role Group to be overwritten.\n   * @param groupDescriptor - The descriptor to overwrite the role with.\n   * @param optManager - Optional EntityManager to use for the transaction.\n   *\n   * @returns The overwritten Role Group\n   */\n  public async overwriteRoleGroup(\n    id: number, groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager,\n  ) {\n    return await this._runInTransaction(optManager, async (manager) => {\n      const existingGroup = await this.getGroupWithMembersById(id, {}, manager);\n      if (!existingGroup || (existingGroup.type !== Group.ROLE_TYPE)) {\n        throw new ApiError(`Role with id ${id} not found`, 404);\n      }\n      return await this._overwriteGroup(existingGroup, groupDescriptor, manager);\n    });\n  }\n\n  /**\n   * Overwrite a Team Group.\n   * @param id - The id of the Team Group to be overwritten.\n   * @param groupDescriptor - The descriptor to overwrite the role with.\n   * @param optManager - Optional EntityManager to use for the transaction.\n   *\n   * @returns The overwritten Team Group\n   */\n  public async overwriteTeamGroup(\n    id: number, groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager,\n  ) {\n    return await this._runInTransaction(optManager, async (manager) => {\n      const existingGroup = await this.getGroupWithMembersById(id, {}, manager);\n      if (!existingGroup || (existingGroup.type !== Group.TEAM_TYPE)) {\n        throw new ApiError(`Group with id ${id} not found`, 404);\n      }\n      await this._throwIfTeamNameCollision(groupDescriptor.name, manager, id);\n      return await this._overwriteGroup(existingGroup, groupDescriptor, manager);\n    });\n  }\n\n  /**\n   * Delete a Group.\n   *\n   * @param id - The id of the Group to be deleted.\n   * @param expectedType - The expected type of the Group to be deleted. If the type is specified,\n   *                      the Group will only be deleted if it has the expected type.\n   *                      If the type is not specified, the Group will be deleted regardless of its type.\n   * @param optManager - Optional EntityManager to use for the transaction.\n   */\n  public async deleteGroup(id: number, expectedType?: GroupTypes, optManager?: EntityManager) {\n    return await this._runInTransaction(optManager, async (manager) => {\n      const group = await this.getGroupWithMembersById(id, {}, manager);\n      if (!group || (expectedType && expectedType !== group.type)) {\n        throw new ApiError(`Group with id ${id} not found`, 404);\n      }\n      await manager.createQueryBuilder()\n        .delete()\n        .from(\"group_groups\")\n        .where(\"subgroup_id = :id\", { id })\n        .execute();\n      await manager.remove(group);\n    });\n  }\n\n  /**\n   * Get all the groups with their members.\n   * @param optManager - Optional EntityManager to use for the transaction.\n   */\n  public getGroupsWithMembers(optManager?: EntityManager): Promise<Group[]> {\n    return this._runInTransaction(optManager, async (manager: EntityManager) => {\n      return this._getGroupsQueryBuilder(manager)\n        .getMany();\n    });\n  }\n\n  /**\n   * Get all the groups with their members of the given type.\n   *\n   * @param type - The type of the groups to be fetched.\n   * @param opts - Optional options to be used for the query.\n   * @param opts.aclRule - Whether to include the aclRule in the query.\n   * @param optManager - Optional EntityManager to use for the transaction.\n   *\n   * @returns A Promise for an array of Group entities.\n   */\n  public getGroupsWithMembersByType(\n    type: GroupTypes, opts?: { aclRule?: boolean }, optManager?: EntityManager,\n  ): Promise<Group[]> {\n    return this._runInTransaction(optManager, async (manager: EntityManager) => {\n      return this._getGroupsQueryBuilder(manager, opts)\n        .where(\"groups.type = :type\", { type })\n        .getMany();\n    });\n  }\n\n  /**\n   * Get a Group with its members by id.\n   *\n   * @param id - The id of the Group to be fetched.\n   * @param opts - Optional options to be used for the query.\n   * @param opts.aclRule - Whether to include the aclRule in the query.\n   * @param optManager - Optional EntityManager to use for the transaction.\n   *\n   * @returns A Promise for the Group entity.\n   */\n  public async getGroupWithMembersById(\n    id: number, opts?: { aclRule?: boolean }, optManager?: EntityManager,\n  ): Promise<Group | null> {\n    return await this._runInTransaction(optManager, async (manager) => {\n      return await this._getGroupsQueryBuilder(manager, opts)\n        .andWhere(\"groups.id = :groupId\", { groupId: id })\n        .getOne();\n    });\n  }\n\n  /**\n   * Common method to overwrite groups of any type.\n   * @param existing - The existing group to be overwritten.\n   * @param groupDescriptor - The descriptor to overwrite the group with.\n   * @param optManager - The EntityManager to use for the transaction.\n   * @returns The overwritten Group.\n   */\n  private async _overwriteGroup(\n    existing: Group, groupDescriptor: GroupWithMembersDescriptor, optManager: EntityManager,\n  ) {\n    if (existing.type !== groupDescriptor.type) {\n      throw new ApiError(\"cannot change type of group\", 400);\n    }\n    const updatedGroup = Group.create({\n      id: existing.id,\n      type: groupDescriptor.type,\n      name: groupDescriptor.name,\n      memberUsers: await this._usersManager.getUsersByIdsStrict(groupDescriptor.memberUsers ?? [], optManager),\n      memberGroups: await this._getGroupsByIdsStrict(groupDescriptor.memberGroups ?? [], optManager),\n    });\n    return await optManager.save(updatedGroup);\n  }\n\n  /**\n   * Returns a Promise for an array of Groups for the given groupIds.\n   *\n   * @param groupIds - The ids of the Groups to be fetched.\n   * @param optManager - Optional EntityManager to use for the transaction.\n   * @returns A Promise for an array of Group entities.\n   */\n  private async _getGroupsByIds(groupIds: number[], optManager?: EntityManager): Promise<Group[]> {\n    if (groupIds.length === 0) {\n      return [];\n    }\n    return await this._runInTransaction(optManager, async (manager) => {\n      const queryBuilder = this._getGroupsQueryBuilder(manager)\n        .where(\"groups.id IN (:...groupIds)\", { groupIds });\n      return await queryBuilder.getMany();\n    });\n  }\n\n  /**\n   * Returns a Promise for an array of Groups for the given groupIds.\n   * Throws an ApiError if any of the groups are not found.\n   *\n   * @param groupIds - The ids of the Groups to be fetched.\n   * @param optManager - Optional EntityManager to use for the transaction.\n   */\n  private async _getGroupsByIdsStrict(groupIds: number[], optManager?: EntityManager): Promise<Group[]> {\n    const groups = await this._getGroupsByIds(groupIds, optManager);\n    if (groups.length !== groupIds.length) {\n      const foundGroupIds = new Set(groups.map(group => group.id));\n      const missingGroupIds = groupIds.filter(id => !foundGroupIds.has(id));\n      throw new ApiError(\"Groups not found: \" + missingGroupIds.join(\", \"), 404);\n    }\n    return groups;\n  }\n\n  /**\n   * Returns a QueryBuilder for fetching groups with their members.\n   * @param optManager - The EntityManager to use for the query.\n   * @param opts - Optional options to be used for the query.\n   * @param opts.aclRule - Whether to include the aclRule in the query.\n   * @returns The QueryBuilder for fetching groups with their members.\n   */\n  private _getGroupsQueryBuilder(optManager: EntityManager, opts: { aclRule?: boolean } = {}) {\n    let queryBuilder = optManager.createQueryBuilder()\n      .select(\"groups\")\n      .addSelect(\"groups.type\")\n      .addSelect(\"memberGroups.type\")\n      .from(Group, \"groups\")\n      .leftJoinAndSelect(\"groups.memberUsers\", \"memberUsers\")\n      .leftJoinAndSelect(\"groups.memberGroups\", \"memberGroups\");\n    if (opts.aclRule) {\n      queryBuilder = queryBuilder\n        .leftJoinAndSelect(\"groups.aclRule\", \"aclRule\");\n    }\n    return queryBuilder;\n  }\n\n  private async _throwIfTeamNameCollision(name: string, manager: EntityManager, existingId?: number) {\n    const query = this._getGroupsQueryBuilder(manager)\n      .where(\"groups.name = :name\", { name })\n      .andWhere(\"groups.type = :type\", { type: Group.TEAM_TYPE });\n    if (existingId !== undefined) {\n      query.andWhere(\"groups.id != :id\", { id: existingId });\n    }\n    const group = await query.getOne();\n    if (group) {\n      throw new ApiError(`Group with name \"${name}\" already exists`, 409);\n    }\n  }\n}\n"
  },
  {
    "path": "app/gen-server/lib/homedb/HomeDBManager.ts",
    "content": "import { ShareInfo } from \"app/common/ActiveDocAPI\";\nimport { ApiError, LimitType } from \"app/common/ApiError\";\nimport { mapGetOrSet, mapSetOrClear, MapWithTTL } from \"app/common/AsyncCreate\";\nimport { ConfigKey, ConfigValue } from \"app/common/Config\";\nimport { getDataLimitInfo } from \"app/common/DocLimits\";\nimport { DocStateComparison } from \"app/common/DocState\";\nimport { createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary } from \"app/common/DocUsage\";\nimport { normalizeEmail } from \"app/common/emails\";\nimport {\n  ANONYMOUS_PLAN,\n  canAddOrgMembers,\n  Features,\n  isFreePlan,\n  mergedFeatures,\n  PERSONAL_FREE_PLAN,\n} from \"app/common/Features\";\nimport { buildUrlId, MIN_URLID_PREFIX_LENGTH, parseUrlId } from \"app/common/gristUrls\";\nimport { UserProfile } from \"app/common/LoginSessionAPI\";\nimport { checkSubdomainValidity } from \"app/common/orgNameUtils\";\nimport { DocPrefs, FullDocPrefs } from \"app/common/Prefs\";\nimport * as roles from \"app/common/roles\";\nimport { WebHookSecret } from \"app/common/Triggers\";\nimport { UserType } from \"app/common/User\";\nimport {\n  ANONYMOUS_USER_EMAIL,\n  DocumentProperties,\n  EVERYONE_EMAIL,\n  getRealAccess,\n  ManagerDelta,\n  NEW_DOCUMENT_CODE,\n  Organization as OrgInfo,\n  OrganizationProperties,\n  PermissionData,\n  PermissionDelta,\n  PREVIEWER_EMAIL,\n  Proposal as ApiProposal,\n  ProposalStatus,\n  UserAccessData,\n  UserOptions,\n  WorkspaceProperties,\n} from \"app/common/UserAPI\";\nimport { AclRule, AclRuleDoc, AclRuleOrg, AclRuleWs } from \"app/gen-server/entity/AclRule\";\nimport { Alias } from \"app/gen-server/entity/Alias\";\nimport { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { BillingAccountManager } from \"app/gen-server/entity/BillingAccountManager\";\nimport { Config } from \"app/gen-server/entity/Config\";\nimport { DocPref } from \"app/gen-server/entity/DocPref\";\nimport { Document, FilteredDocument } from \"app/gen-server/entity/Document\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { Limit } from \"app/gen-server/entity/Limit\";\nimport { AccessOption, AccessOptionWithRole, Organization } from \"app/gen-server/entity/Organization\";\nimport { Pref } from \"app/gen-server/entity/Pref\";\nimport {\n  getAnonymousFeatures,\n  getDefaultProductNames,\n  personalFreeFeatures,\n  Product,\n} from \"app/gen-server/entity/Product\";\nimport { Proposal } from \"app/gen-server/entity/Proposal\";\nimport { Secret } from \"app/gen-server/entity/Secret\";\nimport { ServiceAccount } from \"app/gen-server/entity/ServiceAccount\";\nimport { Share } from \"app/gen-server/entity/Share\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { HomeDBCaches } from \"app/gen-server/lib/homedb/Caches\";\nimport { GroupsManager, GroupTypes } from \"app/gen-server/lib/homedb/GroupsManager\";\nimport {\n  AvailableUsers,\n  DocAuthKey,\n  DocAuthResult,\n  DocumentAccessChanges,\n  GetUserOptions,\n  GroupWithMembersDescriptor,\n  HomeDBAuth,\n  NonGuestGroup,\n  OrgAccessChanges,\n  PreviousAndCurrent,\n  QueryResult,\n  Resource,\n  RoleGroupDescriptor,\n  ServiceAccountProperties,\n  UserProfileChange,\n  WorkspaceAccessChanges,\n} from \"app/gen-server/lib/homedb/Interfaces\";\nimport { ServiceAccountsManager } from \"app/gen-server/lib/homedb/ServiceAccountsManager\";\nimport { SUPPORT_EMAIL, UsersManager } from \"app/gen-server/lib/homedb/UsersManager\";\nimport { Permissions } from \"app/gen-server/lib/Permissions\";\nimport { scrubUserFromOrg } from \"app/gen-server/lib/scrubUserFromOrg\";\nimport { applyPatch, maybePrepareStatement } from \"app/gen-server/lib/TypeORMPatches\";\nimport {\n  bitOr,\n  getRawAndEntities,\n  hasAtLeastOneOfTheseIds,\n  hasOnlyTheseIdsOrNull,\n  makeJsonArray,\n  now,\n  readJson,\n} from \"app/gen-server/sqlUtils\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { getOrCreateConnection } from \"app/server/lib/dbUtils\";\nimport { StorageCoordinator } from \"app/server/lib/GristServer\";\nimport { getPersonalOrgsEnabled } from \"app/server/lib/gristSettings\";\nimport { makeId } from \"app/server/lib/idUtils\";\nimport { EmitNotifier, INotifier } from \"app/server/lib/INotifier\";\nimport log from \"app/server/lib/log\";\nimport { Permit } from \"app/server/lib/Permit\";\nimport { IPubSubManager } from \"app/server/lib/PubSubManager\";\nimport { getScope } from \"app/server/lib/requestUtils\";\nimport { expectedResetDate } from \"app/server/lib/serverUtils\";\n\nimport { Request } from \"express\";\nimport { flatten, pick, size } from \"lodash\";\nimport moment from \"moment\";\nimport {\n  Brackets,\n  DatabaseType,\n  DataSource,\n  EntityManager,\n  FindManyOptions,\n  ObjectLiteral,\n  SelectQueryBuilder,\n  WhereExpressionBuilder,\n} from \"typeorm\";\nimport { v4 as uuidv4 } from \"uuid\";\n\n// Support transactions in Sqlite in async code.  This is a monkey patch, affecting\n// the prototypes of various TypeORM classes.\n// TODO: remove this patch if the issue is ever accepted as a problem in TypeORM and\n// fixed.  See https://github.com/typeorm/typeorm/issues/1884#issuecomment-380767213\napplyPatch();\n\nexport { SUPPORT_EMAIL };\n\nexport const Deps = {\n  defaultMaxNewUserInvitesPerOrg: {\n    value: appSettings.section(\"features\")\n      .flag(\"maxNewUserInvitesPerOrg\")\n      .readInt({\n        envVar: \"GRIST_MAX_NEW_USER_INVITES_PER_ORG\",\n        minValue: 1,\n      }),\n    // Check over the last 24 hours.\n    durationMs: 24 * 60 * 60 * 1000,\n  },\n  defaultMaxBillingManagersPerOrg: {\n    value: appSettings.section(\"features\")\n      .flag(\"maxBillingManagersPerOrg\")\n      .readInt({\n        envVar: \"GRIST_MAX_BILLING_MANAGERS_PER_ORG\",\n        minValue: 1,\n      }),\n  },\n  usePreparedStatements: appSettings.section(\"db\").section(\"postgres\").flag(\"usePreparedStatements\")\n    .readBool({\n      envVar: \"GRIST_POSTGRES_USE_PREPARED_STATEMENTS\",\n      defaultValue: false,\n    }),\n};\n\n// Name of a special workspace with examples in it.\nexport const EXAMPLE_WORKSPACE_NAME = \"Examples & Templates\";\n\n// Flag controlling whether sites that are publicly accessible should be listed\n// to the anonymous user. Defaults to not listing such sites.\nconst listPublicSites = appSettings.section(\"access\").flag(\"listPublicSites\").readBool({\n  envVar: \"GRIST_LIST_PUBLIC_SITES\",\n  defaultValue: false,\n});\n\n// A TTL in milliseconds for caching the result of looking up access level for a doc,\n// which is a burden under heavy traffic.\nconst DOC_AUTH_CACHE_TTL = appSettings.section(\"access\").flag(\"docAuthCacheTTL\").requireInt({\n  envVar: \"GRIST_TEST_DOC_AUTH_CACHE_TTL\",\n  defaultValue: 5000,\n});\n\n// Maps from userId to group name, or null to inherit.\nexport interface UserIdDelta {\n  [userId: string]: roles.NonGuestRole | null;\n}\n\n// A collection of fun facts derived from a PermissionDelta (used to describe\n// a change of users) and a user.\nexport interface PermissionDeltaAnalysis {\n  // Deltas for existing Grist users.\n  foundUserDelta: UserIdDelta | null;\n  // Users from foundUserDelta.\n  foundUsers: User[];\n  // Deltas for emails not matching any Grist user.\n  notFoundUserDelta: { [email: string]: roles.NonGuestRole; } | null;\n  // The permissions needed to make the change.\n  // Usually Permissions.ACL_EDIT, but Permissions.ACL_VIEW is enough for\n  // a user to remove themselves.\n  permissionThreshold: Permissions;\n  // Flags if the user making the change would be affected by the change.\n  affectsSelf: boolean;\n}\n\n// Options for certain create query helpers private to this file.\ninterface QueryOptions {\n  manager?: EntityManager;\n  markPermissions?: Permissions;\n  needRealOrg?: boolean;  // Set if pseudo-org should be collapsed to user's personal org\n  allowSpecialPermit?: boolean;  // Set if specialPermit in Scope object should be respected,\n  // potentially overriding markPermissions.\n}\n\ninterface DocQueryOptions extends QueryOptions {\n  // Override AccessStyle (defaults to 'open'). E.g. 'openNoPublic' ignores public access.\n  accessStyle?: AccessStyle;\n}\n\n// Information about a change in billable users.\nexport interface UserChange {\n  userId: number;            // who initiated the change\n  org: Organization;         // organization changed\n  customerId: string | null;   // stripe customer id\n  countBefore: number;       // billable users before change\n  countAfter: number;        // billable users after change\n  membersBefore: Map<roles.NonGuestRole, User[]>;\n  membersAfter: Map<roles.NonGuestRole, User[]>;\n}\n\n// The context in which a query is being made.  Includes what we know\n// about the user, and for requests made from pages, the active organization.\nexport interface Scope {\n  userId: number;                // The ID of the user for authentication purposes.\n  org?: string;                  // Org identified in request.\n  urlId?: string;                // Set when accessing a document.  May be a docId.\n  users?: AvailableUsers;        // Set if available identities.\n  includeSupport?: boolean;      // When set, include sample resources shared by support to scope.\n  showRemoved?: boolean;         // When set, query is scoped to removed workspaces/docs.\n  showAll?: boolean;             // When set, return both removed and regular resources.\n  specialPermit?: Permit;        // When set, extra rights are granted on a specific resource.\n}\n\n// Flag for whether we are listing resources or opening them.  This makes a difference\n// for public resources, which we allow users to open but not necessarily list.\n// 'openNoPublic' is like open, but ignores public shares, i.e. only allows users who are listed\n// as collaborators, either directly or by inheriting access.\ntype AccessStyle = \"list\" | \"open\" | \"openNoPublic\";\n\n// A Scope for documents, with mandatory urlId.\nexport interface DocScope extends Scope {\n  urlId: string;\n}\n\n// Represent a DocAuthKey as a string.  The format is \"<urlId>:<org> <userId>\".\n// flushSingleDocAuthCache() depends on this format.\nfunction stringifyDocAuthKey(key: DocAuthKey): string {\n  return stringifyUrlIdOrg(key.urlId, key.org) + ` ${key.userId}`;\n}\n\nfunction stringifyUrlIdOrg(urlId: string, org?: string): string {\n  return `${urlId}:${org}`;\n}\n\nexport interface DocumentMetadata {\n  // ISO 8601 UTC date (e.g. the output of new Date().toISOString()).\n  updatedAt?: string;\n  usage?: DocumentUsage | null;\n}\n\ninterface CreateWorkspaceOptions {\n  org: Organization,\n  props: Partial<WorkspaceProperties>,\n  ownerId?: number\n}\n\n/**\n * Available options for creating a new org with a new billing account.\n * It serves only as a way to remove all foreign keys from the entity.\n */\nexport type BillingOptions = Partial<Pick<BillingAccount,\n  \"stripeCustomerId\" |\n  \"stripeSubscriptionId\" |\n  \"stripePlanId\" |\n  \"externalId\" |\n  \"externalOptions\" |\n  \"inGoodStanding\" |\n  \"status\" |\n  \"paymentLink\" |\n  \"features\"\n>>;\n\n/**\n * HomeDBManager handles interaction between the ApiServer and the Home database,\n * encapsulating the typeorm logic.\n */\nexport class HomeDBManager implements HomeDBAuth {\n  public caches: HomeDBCaches | null;\n  private _usersManager = new UsersManager(this, this.runInTransaction.bind(this));\n  private _groupsManager = new GroupsManager(this._usersManager, this.runInTransaction.bind(this));\n  private _serviceAccountsManager = new ServiceAccountsManager(\n    this, this.runInTransaction.bind(this),\n  );\n\n  private _connection: DataSource;\n  private _exampleWorkspaceId: number;\n  private _exampleOrgId: number;\n  private _idPrefix: string = \"\";  // Place this before ids in subdomains, used in routing to\n  // deployments on same subdomain.\n\n  private _docAuthCache = new MapWithTTL<string, Promise<DocAuthResult>>(DOC_AUTH_CACHE_TTL);\n  private _readonly: boolean = false;\n\n  private get _dbType(): DatabaseType {\n    return this._connection.driver.options.type;\n  }\n\n  public constructor(\n    public storageCoordinator?: StorageCoordinator,\n    private _notifier: INotifier = new EmitNotifier(),\n    pubSubManager?: IPubSubManager,\n  ) {\n    this.caches = pubSubManager ? new HomeDBCaches(this, pubSubManager) : null;\n  }\n\n  public usersManager() {\n    return this._usersManager;\n  }\n\n  public get defaultGroups(): RoleGroupDescriptor[] {\n    return this._groupsManager.defaultGroups;\n  }\n\n  public get defaultBasicGroups(): RoleGroupDescriptor[] {\n    return this._groupsManager.defaultBasicGroups;\n  }\n\n  public get defaultCommonGroups(): RoleGroupDescriptor[] {\n    return this._groupsManager.defaultCommonGroups;\n  }\n\n  public get defaultGroupNames(): roles.Role[] {\n    return this._groupsManager.defaultGroupNames;\n  }\n\n  public get defaultBasicGroupNames(): roles.BasicRole[] {\n    return this._groupsManager.defaultBasicGroupNames;\n  }\n\n  public get defaultNonGuestGroupNames(): roles.NonGuestRole[] {\n    return this._groupsManager.defaultNonGuestGroupNames;\n  }\n\n  public get defaultCommonGroupNames(): roles.NonMemberRole[] {\n    return this.defaultCommonGroups\n      .map(_grpDesc => _grpDesc.name) as roles.NonMemberRole[];\n  }\n\n  public setPrefix(prefix: string) {\n    this._idPrefix = prefix;\n  }\n\n  public setReadonly(readonly = true) {\n    if (this._readonly !== readonly) {\n      this._readonly = readonly;\n      this.flushDocAuthCache();\n    }\n  }\n\n  public isReadonly() {\n    return this._readonly;\n  }\n\n  public async connect(): Promise<void> {\n    this._connection = await getOrCreateConnection();\n  }\n\n  public connectTo(connection: DataSource) {\n    this._connection = connection;\n  }\n\n  // make sure special users and workspaces are available\n  public async initializeSpecialIds(options?: {\n    skipWorkspaces?: boolean  // if set, skip setting example workspace.\n  }) {\n    await this._usersManager.initializeSpecialIds();\n\n    if (!options?.skipWorkspaces) {\n      // Find the example workspace.  If there isn't one named just right, take the first workspace\n      // belonging to the support user.  This shouldn't happen in deployments but could happen\n      // in tests.\n      // TODO: it should now be possible to remove all this; the only remaining\n      // issue is what workspace to associate with documents created by\n      // anonymous users.\n      const supportWorkspaces = await this._workspaces()\n        .leftJoinAndSelect(\"workspaces.org\", \"orgs\")\n        .where(\"orgs.owner_id = :userId\", { userId: this._usersManager.getSupportUserId() })\n        .orderBy(\"workspaces.created_at\")\n        .getMany();\n      const exampleWorkspace = supportWorkspaces.find(ws => ws.name === EXAMPLE_WORKSPACE_NAME) || supportWorkspaces[0];\n      if (!exampleWorkspace) { throw new Error(\"No example workspace available\"); }\n      if (exampleWorkspace.name !== EXAMPLE_WORKSPACE_NAME) {\n        log.warn(\"did not find an appropriately named example workspace in deployment\");\n      }\n      this._exampleWorkspaceId = exampleWorkspace.id;\n      this._exampleOrgId = exampleWorkspace.org.id;\n    }\n  }\n\n  public get connection() {\n    return this._connection;\n  }\n\n  public async testQuery(sql: string, args: any[]): Promise<any> {\n    return this._connection.query(sql, args);\n  }\n\n  /**\n   * Maps from the name of an entity to its id, for the purposes of\n   * unit tests only.  It relies on test entities being named\n   * distinctly.  It just runs through each model in turn by brute\n   * force, and returns the id of this first match it finds.\n   */\n  public async testGetId(name: string): Promise<number | string> {\n    const org = await Organization.findOne({ where: { name } });\n    if (org) { return org.id; }\n    const ws = await Workspace.findOne({ where: { name } });\n    if (ws) { return ws.id; }\n    const doc = await Document.findOne({ where: { name } });\n    if (doc) { return doc.id; }\n    const user = await User.findOne({ where: { name } });\n    if (user) { return user.id; }\n    const product = await Product.findOne({ where: { name } });\n    if (product) { return product.id; }\n    throw new Error(`Cannot testGetId(${name})`);\n  }\n\n  /**\n   * For tests only. Get user's unique reference by name.\n   */\n  public async testGetRef(name: string): Promise<string> {\n    const user = await User.findOne({ where: { name } });\n    if (user) { return user.ref; }\n    throw new Error(`Cannot testGetRef(${name})`);\n  }\n\n  /**\n   * For use in tests.\n   * @see UsersManager.prototype.testClearUserPrefs\n   */\n  public async testClearUserPrefs(emails: string[]) {\n    return this._usersManager.testClearUserPrefs(emails);\n  }\n\n  public async getUserByKey(apiKey: string): Promise<User | undefined> {\n    return this._usersManager.getUserByKey(apiKey);\n  }\n\n  public async getUserByRef(\n    ref: string,\n    options: { manager?: EntityManager; relations?: string[] } = {},\n  ): Promise<User | undefined> {\n    return this._usersManager.getUserByRef(ref, options);\n  }\n\n  public async getUser(userId: number, options: { includePrefs?: boolean } = {}) {\n    return this._usersManager.getUser(userId, options);\n  }\n\n  public async getUsers(options?: { type?: UserType }) {\n    return this._usersManager.getUsers(options);\n  }\n\n  public async getFullUser(userId: number) {\n    return this._usersManager.getFullUser(userId);\n  }\n\n  public async getUserAndEnsureUnsubscribeKey(userId: number) {\n    return this._usersManager.getUserAndEnsureUnsubscribeKey(userId);\n  }\n\n  /**\n   * @see UsersManager.prototype.makeFullUser\n   */\n  public makeFullUser(user: User) {\n    return this._usersManager.makeFullUser(user);\n  }\n\n  /**\n   * @see UsersManager.prototype.ensureExternalUser\n   */\n  public async ensureExternalUser(profile: UserProfile) {\n    return await this._usersManager.ensureExternalUser(profile);\n  }\n\n  /**\n   * @see UsersManager.prototype.updateUser\n   */\n  public async updateUser(\n    userId: number,\n    props: UserProfileChange,\n  ): Promise<PreviousAndCurrent<User>> {\n    const { previous, current, isWelcomed } = await this._usersManager.updateUser(userId, props);\n    if (current && isWelcomed) {\n      await this._notifier.firstLogin(this.makeFullUser(current));\n    }\n    return { previous, current };\n  }\n\n  public async updateUserOptions(userId: number, props: Partial<UserOptions>) {\n    return this._usersManager.updateUserOptions(userId, props);\n  }\n\n  /**\n   * @see UsersManager.prototype.getUserByLoginWithRetry\n   */\n  public async getUserByLoginWithRetry(email: string, options: GetUserOptions = {}): Promise<User> {\n    return this._usersManager.getUserByLoginWithRetry(email, options);\n  }\n\n  /**\n   * @see UsersManager.prototype.getUserByLogin\n   */\n  public async getUserByLogin(email: string, options: GetUserOptions = {}, type: UserType = \"login\"): Promise<User> {\n    return this._usersManager.getUserByLogin(email, options, type);\n  }\n\n  /**\n   * @see UsersManager.prototype.getExistingUserByLogin\n   * Find a user by email. Don't create the user if it doesn't already exist.\n   */\n  public async getExistingUserByLogin(email: string, manager?: EntityManager): Promise<User | undefined> {\n    return await this._usersManager.getExistingUserByLogin(email, manager);\n  }\n\n  /**\n   * @see UsersManager.prototype.getExistingUsersByLogin\n   * Find users by emails.\n   */\n  public async getExistingUsersByLogin(emails: string[], manager?: EntityManager): Promise<User[]> {\n    return await this._usersManager.getExistingUsersByLogin(emails, manager);\n  }\n\n  public async findUsers(findOpts: FindManyOptions<User>, manager?: EntityManager) {\n    return await this._usersManager.findUsers(findOpts, manager);\n  }\n\n  public async createGroup(groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager) {\n    return this._groupsManager.createGroup(groupDescriptor, optManager);\n  }\n\n  public async overwriteTeamGroup(\n    id: number, groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager,\n  ) {\n    return this._groupsManager.overwriteTeamGroup(id, groupDescriptor, optManager);\n  }\n\n  public async overwriteRoleGroup(\n    id: number, groupDescriptor: GroupWithMembersDescriptor, optManager?: EntityManager,\n  ) {\n    return this._groupsManager.overwriteRoleGroup(id, groupDescriptor, optManager);\n  }\n\n  public async deleteGroup(id: number, expectedType?: GroupTypes, optManager?: EntityManager) {\n    return this._groupsManager.deleteGroup(id, expectedType, optManager);\n  }\n\n  public getGroupsWithMembers(manager?: EntityManager): Promise<Group[]> {\n    return this._groupsManager.getGroupsWithMembers(manager);\n  }\n\n  public getGroupsWithMembersByType(\n    type: GroupTypes, opts?: { aclRule?: boolean }, manager?: EntityManager): Promise<Group[]> {\n    return this._groupsManager.getGroupsWithMembersByType(type, opts, manager);\n  }\n\n  public getGroupWithMembersById(\n    id: number, opts?: { aclRule: boolean }, manager?: EntityManager,\n  ): Promise<Group | null> {\n    return this._groupsManager.getGroupWithMembersById(id, opts, manager);\n  }\n\n  /**\n   * Returns true if the given domain string is available, and false if it is not available.\n   * NOTE that the endpoint only checks if the domain string is taken in the database, it does\n   * not check whether the string contains invalid characters.\n   */\n  public async isDomainAvailable(domain: string): Promise<boolean> {\n    let qb = this._orgs();\n    qb = this._whereOrg(qb, domain);\n    const results = await qb.getRawAndEntities();\n    return results.entities.length === 0;\n  }\n\n  /**\n   * Returns the number of users in any non-guest role in the given org.\n   * Note that this does not require permissions and should not be exposed to the client.\n   *\n   * If an Organization is provided, all of orgs.acl_rules, orgs.acl_rules.group,\n   * and orgs.acl_rules.group.memberUsers should be included.\n   */\n  public async getOrgMemberCount(org: string | number | Organization): Promise<number> {\n    return (await this._getOrgMembers(org)).length;\n  }\n\n  /**\n   * Returns the number of billable users in the given org.\n   */\n  public async getOrgBillableMemberCount(org: string | number | Organization): Promise<number> {\n    return (await this._getOrgMembers(org))\n      .filter(u => !u.options?.isConsultant) // remove consultants.\n      .filter(u => !this._usersManager.getExcludedUserIds().includes(u.id)) // remove support user and other\n      .length;\n  }\n\n  /**\n   * @see UsersManager.prototype.deleteUser\n   */\n  public async deleteUser(scope: Scope, userIdToDelete: number,\n    name?: string): Promise<QueryResult<User>> {\n    return this._usersManager.deleteUser(scope, userIdToDelete, name);\n  }\n\n  public async overwriteUser(userId: number, props: UserProfile) {\n    return this._usersManager.overwriteUser(userId, props);\n  }\n\n  /**\n   * Returns a QueryResult for the given organization.  The orgKey\n   * can be a string (the domain from url) or the id of an org.  If it is\n   * null, the user's personal organization is returned.\n   */\n  public async getOrg(scope: Scope, orgKey: string | number | null,\n    transaction?: EntityManager, options?: {\n      requirePermissions: Permissions,\n    }): Promise<QueryResult<Organization>> {\n    const { userId } = scope;\n    // Anonymous access to the merged org is a special case.  We return an\n    // empty organization, not backed by the database, and which can contain\n    // nothing but the example documents always added to the merged org.\n    if (this.isMergedOrg(orgKey) && userId === this._usersManager.getAnonymousUserId()) {\n      const anonOrg: OrgInfo = {\n        id: 0,\n        createdAt: new Date().toISOString(),\n        updatedAt: new Date().toISOString(),\n        domain: this.mergedOrgDomain(),\n        name: \"Anonymous\",\n        owner: this.makeFullUser(this._usersManager.getAnonymousUser()),\n        access: \"viewers\",\n        billingAccount: {\n          id: 0,\n          individual: true,\n          product: {\n            name: ANONYMOUS_PLAN,\n            features: personalFreeFeatures,\n          },\n          stripePlanId: \"\",\n          isManager: false,\n          inGoodStanding: true,\n          features: {},\n        },\n        host: null,\n      };\n      return { status: 200, data: anonOrg as any };\n    }\n    let qb = this.org(scope, orgKey, {\n      ...(options?.requirePermissions ? {\n        markPermissions: options.requirePermissions,\n      } : undefined),\n      manager: transaction,\n      needRealOrg: true,\n    });\n    qb = this._addBillingAccount(qb, scope.userId);\n    let effectiveUserId = scope.userId;\n    if (scope.specialPermit?.org === orgKey) {\n      effectiveUserId = this._usersManager.getPreviewerUserId();\n    }\n    qb = this._withAccess(qb, effectiveUserId, \"orgs\");\n    qb = qb.leftJoinAndSelect(\"orgs.owner\", \"owner\");\n    // Add preference information that will be relevant for presentation of the org.\n    // That includes preference information specific to the site and the user,\n    // or specific just to the site, or specific just to the user.\n    qb = qb.leftJoinAndMapMany(\"orgs.prefs\", Pref, \"prefs\",\n      \"(prefs.org_id = orgs.id or prefs.org_id IS NULL) AND \" +\n      \"(prefs.user_id = :userId or prefs.user_id IS NULL)\",\n      { userId });\n    // Apply a particular order (user+org first if present, then org, then user).\n    // Slightly round-about syntax because Sqlite and Postgres disagree about NULL\n    // ordering (Sqlite does support NULL LAST syntax now, but not on our fork yet).\n    qb = qb.addOrderBy(\"coalesce(prefs.org_id, 0)\", \"DESC\");\n    qb = qb.addOrderBy(\"coalesce(prefs.user_id, 0)\", \"DESC\");\n    const result: QueryResult<any> = await this._verifyAclPermissions(qb, {\n      markedPermissions: options?.requirePermissions !== undefined,\n    });\n    if (result.status === 200) {\n      // Return the only org.\n      result.data = result.data[0];\n      if (this.isMergedOrg(orgKey)) {\n        // The merged psuedo-organization is almost, but not quite, the user's personal\n        // org.  We give it a distinct domain and id.\n        result.data.id = 0;\n        result.data.domain = this.mergedOrgDomain();\n      }\n    }\n    return result;\n  }\n\n  /**\n   * Gets the billing account for the specified org.  Will throw errors if the org\n   * is not found, or if the user does not have access to its billing account.\n   *\n   * The special previewer user is given access to billing account information.\n   *\n   * The billing account includes fields such as stripeCustomerId.\n   * To include `managers` and `orgs` fields listing all billing account managers\n   * and organizations linked to the account, set `includeOrgsAndManagers`.\n   */\n  public async getBillingAccount(scope: Scope, orgKey: string | number,\n    includeOrgsAndManagers: boolean,\n    transaction?: EntityManager): Promise<BillingAccount> {\n    const org = this.unwrapQueryResult(await this.getOrg(scope, orgKey, transaction));\n\n    if (!org.billingAccount.isManager && scope.userId !== this._usersManager.getPreviewerUserId() &&\n      // The special permit (used for the support user) allows access to the billing account.\n      scope.specialPermit?.org !== orgKey) {\n      throw new ApiError(\"User does not have access to billing account\", 401);\n    }\n    if (!includeOrgsAndManagers) { return org.billingAccount; }\n\n    // For full billing account information including all managers\n    // (for team accounts) and orgs (for individual accounts), we need\n    // to make a different query since what we've got so far is\n    // filtered by org and by user for authorization purposes.\n    // Also, filling out user information linked to orgs and managers\n    // requires a few extra joins.\n    return this.getFullBillingAccount(org.billingAccount.id, transaction);\n  }\n\n  /**\n   * Gets all information about a billing account, without permission check.\n   */\n  public getFullBillingAccount(billingAccountId: number, transaction?: EntityManager): Promise<BillingAccount> {\n    return this.runInTransaction(transaction, async (tr) => {\n      let qb = tr.createQueryBuilder()\n        .select(\"billing_accounts\")\n        .from(BillingAccount, \"billing_accounts\")\n        .leftJoinAndSelect(\"billing_accounts.product\", \"products\")\n        .leftJoinAndSelect(\"billing_accounts.managers\", \"managers\")\n        .leftJoinAndSelect(\"managers.user\", \"manager_users\")\n        .leftJoinAndSelect(\"manager_users.logins\", \"manager_logins\")\n        .leftJoinAndSelect(\"billing_accounts.orgs\", \"orgs\")\n        .leftJoinAndSelect(\"orgs.owner\", \"org_users\")\n        .leftJoinAndSelect(\"org_users.logins\", \"org_logins\")\n        .where(\"billing_accounts.id = :billingAccountId\", { billingAccountId });\n      qb = this._addBillingAccountCalculatedFields(qb);\n      // TODO: should reconcile with isManager field that stripped down results have.\n      const results = await qb.getRawAndEntities();\n      const resources = this._normalizeQueryResults(results.entities);\n      if (!resources[0]) {\n        throw new ApiError(\"Cannot find billing account\", 500);\n      }\n      return resources[0];\n    });\n  }\n\n  /**\n   * Look up an org by an external id.  External IDs are used in integrations, and\n   * simply offer an alternate way to identify an org.\n   */\n  public async getOrgByExternalId(externalId: string): Promise<Organization | undefined> {\n    const query = this._orgs()\n      .leftJoinAndSelect(\"orgs.billingAccount\", \"billing_accounts\")\n      .leftJoinAndSelect(\"billing_accounts.product\", \"products\")\n      .where(\"external_id = :externalId\", { externalId });\n    return await query.getOne() || undefined;\n  }\n\n  /**\n   * Returns a QueryResult for an organization with nested workspaces.\n   */\n  public async getOrgWorkspaces(scope: Scope, orgKey: string | number,\n    options: QueryOptions = {}): Promise<QueryResult<Workspace[]>> {\n    const query = this._orgWorkspaces(scope, orgKey, options);\n    // Allow an empty result for the merged org for the anonymous user.  The anonymous user\n    // has no home org or workspace.  For all other situations, expect at least one workspace.\n    const emptyAllowed = this.isMergedOrg(orgKey) && scope.userId === this._usersManager.getAnonymousUserId();\n    const result: QueryResult<any> = await this._verifyAclPermissions(query, { scope, emptyAllowed });\n    // Return the workspaces, not the org(s).\n    if (result.status === 200) {\n      // Place ownership information in workspaces, available for the merged org.\n      for (const o of result.data) {\n        for (const ws of o.workspaces) {\n          ws.owner = o.owner;\n          // Include the org's domain so that the UI can build doc URLs that include the org.\n          ws.orgDomain = o.domain;\n        }\n      }\n      // For org-specific requests, we still have the org's workspaces, plus the Samples workspace\n      // from the support org.\n      result.data = [].concat(...result.data.map((o: Organization) => o.workspaces));\n    }\n    return result;\n  }\n\n  /**\n   * Returns a QueryResult for the workspace with the given workspace id. The workspace\n   * includes nested Docs.\n   */\n  public async getWorkspace(\n    scope: Scope,\n    wsId: number,\n    transaction?: EntityManager,\n    options?: {\n      requirePermissions: Permissions,\n    },\n  ): Promise<QueryResult<Workspace>> {\n    const { userId } = scope;\n    if (scope.specialPermit?.workspaceId === wsId) {\n      const effectiveUserId = this._usersManager.getPreviewerUserId();\n      scope = { ...scope };\n      scope.userId = effectiveUserId;\n      delete scope.users;\n      options = {\n        ...options,\n        requirePermissions: Permissions.VIEW,\n      };\n    }\n    let queryBuilder = this._workspace(scope, wsId, {\n      manager: transaction,\n      ...(options?.requirePermissions ? {\n        markPermissions: options.requirePermissions,\n        allowSpecialPermit: true,\n      } : undefined),\n    })\n      // Nest the docs within the workspace object\n      .leftJoinAndSelect(\"workspaces.docs\", \"docs\", this._onDoc(scope))\n      .leftJoinAndSelect(\"workspaces.org\", \"orgs\")\n      .leftJoinAndSelect(\"orgs.owner\", \"owner\")\n      // Define some order (spec doesn't promise anything though)\n      .orderBy(\"workspaces.created_at\")\n      .addOrderBy(\"docs.created_at\");\n    queryBuilder = this._addIsSupportWorkspace(userId, queryBuilder, \"orgs\", \"workspaces\");\n    // Add access information and query limits\n    // TODO: allow generic org limit once sample/support workspace is done differently\n    queryBuilder = this._applyLimit(queryBuilder, { ...scope, org: undefined }, [\"workspaces\", \"docs\"], \"list\");\n    const result: QueryResult<any> = await this._verifyAclPermissions(queryBuilder, {\n      scope,\n      markedPermissions: options?.requirePermissions !== undefined,\n    });\n    // Return a single workspace.\n    if (result.status === 200) {\n      result.data = result.data[0];\n    }\n    return result;\n  }\n\n  /**\n   * Returns an organization's usage summary (e.g. count of documents that are approaching or exceeding\n   * limits).\n   */\n  public async getOrgUsageSummary(scope: Scope, orgKey: string | number): Promise<OrgUsageSummary> {\n    // Check that an owner of the org is making the request.\n    const markPermissions = Permissions.OWNER;\n    let orgQuery = this.org(scope, orgKey, {\n      markPermissions,\n      needRealOrg: true,\n    });\n    orgQuery = this._addFeatures(orgQuery);\n    const orgQueryResult = await verifyEntity(orgQuery);\n    const org: Organization = this.unwrapQueryResult(orgQueryResult);\n    const productFeatures = org.billingAccount.getFeatures();\n\n    // Grab all the non-removed documents in the org.\n    let docsQuery = this._docs()\n      .innerJoin(\"docs.workspace\", \"workspaces\")\n      .innerJoin(\"workspaces.org\", \"orgs\")\n      .where(\"docs.workspace_id = workspaces.id\")\n      .andWhere(\"workspaces.removed_at IS NULL AND docs.removed_at IS NULL\");\n    docsQuery = this._whereOrg(docsQuery, orgKey);\n    if (this.isMergedOrg(orgKey)) {\n      docsQuery = docsQuery.andWhere(\"orgs.owner_id = :userId\", { userId: scope.userId });\n    }\n    const docsQueryResult = await this._verifyAclPermissions(docsQuery, { scope, emptyAllowed: true });\n    const docs: Document[] = this.unwrapQueryResult(docsQueryResult);\n\n    // Return an aggregate count of documents, grouped by data limit status.\n    const summary = createEmptyOrgUsageSummary();\n    let totalAttachmentsSizeBytes = 0;\n    for (const { usage: docUsage, gracePeriodStart } of docs) {\n      const dataLimitStatus = getDataLimitInfo({ docUsage, gracePeriodStart, productFeatures }).status;\n      totalAttachmentsSizeBytes += docUsage?.attachmentsSizeBytes ?? 0;\n      if (dataLimitStatus) { summary.countsByDataLimitStatus[dataLimitStatus] += 1; }\n    }\n    const maxAttachmentsBytesPerOrg = productFeatures.maxAttachmentsBytesPerOrg;\n    summary.attachments = {\n      totalBytes: totalAttachmentsSizeBytes,\n    };\n    if (maxAttachmentsBytesPerOrg && totalAttachmentsSizeBytes > maxAttachmentsBytesPerOrg) {\n      summary.attachments.limitExceeded = true;\n    }\n    return summary;\n  }\n\n  /**\n   * Compute the best access option for an organization, from the\n   * users available to the client.  If none of the options can access\n   * the organization, returns null.  If there are equally good\n   * options, an arbitrary one is returned.\n   *\n   * Comparison is made between roles rather than fine-grained\n   * permissions, since otherwise the result would not be well defined\n   * (permissions could in general overlap without one being a\n   * superset of the other).  For the acl rules we've used so far,\n   * this problem does not arise and reasoning at the level of a\n   * hierarchy of roles is adequate.\n   */\n  public async getBestUserForOrg(users: AvailableUsers, org: number | string): Promise<AccessOptionWithRole | null> {\n    if (this.isMergedOrg(org)) {\n      // Don't try to pick a best user for the merged personal org.\n      // If this changes in future, be sure to call this._filterByOrgGroups on the query\n      // below, otherwise it will include every users' personal org which is wasteful\n      // and parsing/mapping the results in TypeORM is slow.\n      return null;\n    }\n    let qb = this._orgs();\n    qb = this._whereOrg(qb, org);\n    qb = this._withAccess(qb, users, \"orgs\");\n    const result = await this._verifyAclPermissions(qb, { emptyAllowed: true });\n    if (!result.data) {\n      throw new ApiError(result.errMessage || \"failed to select user\", result.status);\n    }\n    if (!result.data.length) { return null; }\n    const options: AccessOptionWithRole[] = result.data[0].accessOptions!;\n    if (!options.length) { return null; }\n    const role = roles.getStrongestRole(...options.map(option => option.access));\n    return options.find(option => option.access === role) || null;\n  }\n\n  /**\n   * Returns a SelectQueryBuilder which gives an array of orgs already filtered by\n   * the given user' (or users') access.\n   * If a domain is specified, only an org matching that domain and accessible by\n   * the user or users is returned.\n   * The anonymous user is treated specially, to avoid advertising organizations\n   * with anonymous access.\n   */\n  public async getOrgs(users: AvailableUsers, domain: string | null,\n    options?: { ignoreEveryoneShares?: boolean }): Promise<QueryResult<Organization[]>> {\n    let queryBuilder = this._orgs()\n      .leftJoinAndSelect(\"orgs.owner\", \"users\", \"orgs.owner_id = users.id\");\n    if (UsersManager.isSingleUser(users)) {\n      // When querying with a single user in mind, we keep our api promise\n      // of returning their personal org first in the list.\n      queryBuilder = queryBuilder\n        .orderBy(\"(coalesce(users.id,0) = :userId)\", \"DESC\")\n        .setParameter(\"userId\", users);\n    }\n    queryBuilder = queryBuilder\n      .addOrderBy(\"users.name\")\n      .addOrderBy(\"orgs.name\");\n    queryBuilder = this._withAccess(queryBuilder, users, \"orgs\");\n    // Add a direct, efficient filter to remove irrelevant personal orgs from consideration.\n    queryBuilder = this._filterByOrgGroups(queryBuilder, users, domain, options);\n    if (this._usersManager.isAnonymousUser(users) && !listPublicSites) {\n      // The anonymous user is a special case.  It may have access to potentially\n      // many orgs, but listing them all would be kind of a misfeature.  but reporting\n      // nothing would complicate the client.  We compromise, and report at most\n      // the org of the site the user is on (or nothing when the api is accessed\n      // via a url that is unrelated to any particular org).\n      // This special processing is only needed for the isSingleUser case.  Multiple\n      // users can only be presented when the user has proven login access to each.\n      if (domain && !this.isMergedOrg(domain)) {\n        queryBuilder = this._whereOrg(queryBuilder, domain);\n      } else {\n        return { status: 200, data: [] };\n      }\n    }\n    return this._verifyAclPermissions(queryBuilder, { emptyAllowed: true });\n  }\n\n  // As for getOrgs, but all personal orgs are merged into a single entry.\n  public async getMergedOrgs(userId: number, users: AvailableUsers,\n    domain: string | null): Promise<QueryResult<Organization[]>> {\n    const result = await this.getOrgs(users, domain);\n    if (result.status === 200) {\n      return { status: 200, data: this._mergePersonalOrgs(userId, result.data!) };\n    }\n    return result;\n  }\n\n  // Returns the doc with access information for the calling user only.\n  // TODO: The return type of this function includes the workspace and org with the owner\n  // properties set, as documented in app/common/UserAPI. The return type of this function\n  // should reflect that.\n  public async getDocImpl(key: DocAuthKey, transaction?: EntityManager): Promise<Document> {\n    const { userId } = key;\n    // Doc permissions of forks are based on the \"trunk\" document, so make sure\n    // we look up permissions of trunk if we are on a fork (we'll fix the permissions\n    // up for the fork immediately afterwards).\n    const { trunkId, forkId, forkUserId, snapshotId,\n      shareKey } = parseUrlId(key.urlId);\n    let doc: Document;\n    if (shareKey) {\n      const res = await (transaction || this._connection).createQueryBuilder()\n        .select(\"shares\")\n        .from(Share, \"shares\")\n        .leftJoinAndSelect(\"shares.doc\", \"doc\")\n        .leftJoinAndSelect(\"doc.workspace\", \"workspace\")\n        .leftJoinAndSelect(\"workspace.org\", \"org\")\n        .leftJoinAndSelect(\"org.billingAccount\", \"billing_account\")\n        .leftJoinAndSelect(\"billing_account.product\", \"product\")\n        .where(\"key = :key\", { key: shareKey })\n        .andWhere(\"doc.removed_at IS NULL\")\n        .getOne();\n      if (!res) {\n        throw new ApiError(\"Share not known\", 404);\n      }\n      doc = {\n        name: res.doc?.name,\n        id: res.docId,\n        linkId: res.linkId,\n        createdAt: new Date().toISOString(),\n        updatedAt: new Date().toISOString(),\n        removedAt: res.doc?.removedAt || null,\n        disabledAt: res.doc?.disabledAt || null,\n        isPinned: false,\n        urlId: key.urlId,\n        workspace: res.doc.workspace,\n        aliases: [],\n        access: \"editors\",  // a share may have view/edit access,\n        // need to check at granular level\n      } as any;\n\n      return doc;\n    }\n    const urlId = trunkId;\n    if (forkId || snapshotId) { key = { ...key, urlId }; }\n    if (urlId === NEW_DOCUMENT_CODE) {\n      if (!forkId) { throw new ApiError(\"invalid document identifier\", 400); }\n      // We imagine current user owning trunk if there is no embedded userId, or\n      // the embedded userId matches the current user.\n      const access = (forkUserId === undefined || forkUserId === userId) ? \"owners\" :\n        (userId === this._usersManager.getPreviewerUserId() ? \"viewers\" : null);\n      if (!access) { throw new ApiError(\"access denied\", 403); }\n      doc = {\n        name: \"Untitled\",\n        createdAt: new Date().toISOString(),\n        updatedAt: new Date().toISOString(),\n        id: \"new\",\n        isPinned: false,\n        urlId: null,\n        workspace: this.unwrapQueryResult<Workspace>(\n          await this.getWorkspace({ userId: this._usersManager.getSupportUserId() },\n            this._exampleWorkspaceId, transaction)),\n        aliases: [],\n        access,\n      } as any;\n\n      // Use free personal account features for documents opened this way.\n      doc.workspace.org.billingAccount = patch(new BillingAccount(), {\n        features: getAnonymousFeatures(),\n        product: patch(new Product(), { name: PERSONAL_FREE_PLAN }),\n      });\n    } else {\n      // We can't delegate filtering of removed documents to the db, since we'll be\n      // caching authentication.  But we also don't need to delegate filtering, since\n      // it is very simple at the single-document level.  So we direct the db to include\n      // everything with showAll flag, and let the getDoc() wrapper deal with the remaining\n      // work.\n      let qb = this._doc({ ...key, showAll: true }, { manager: transaction })\n        .leftJoinAndSelect(\"orgs.owner\", \"org_users\");\n      if (userId !== this._usersManager.getAnonymousUserId()) {\n        qb = this._addForks(userId, qb);\n      }\n      qb = this._addIsSupportWorkspace(userId, qb, \"orgs\", \"workspaces\");\n      qb = this._addFeatures(qb);  // add features to determine whether we've gone readonly\n\n      // We need to check if the current user is disabled or not. In\n      // order to avoid another DB round trip, we piggyback with an\n      // unconditional table join here.\n      //\n      // Note that we only run this check here because this method is\n      // used for websocket communication. There's no danger currently\n      // in other HomeDB methods of leaking access to disabled users,\n      // so we keep this unusual join localised here, in order to\n      // minimise the cost of the DB query.\n      qb = qb.leftJoin(User, \"users\", \"users.id = :userId\", { userId });\n      qb = qb.addSelect(\"users.disabled_at\", \"users_disabled_at\");\n\n      const docs = this.unwrapQueryResult<Document[]>(\n        await this._verifyAclPermissions(qb, { checkDisabledUser: true }),\n      );\n      if (docs.length === 0) { throw new ApiError(\"document not found\", 404); }\n      if (docs.length > 1) { throw new ApiError(\"ambiguous document request\", 400); }\n      doc = docs[0];\n      const features = doc.workspace.org.billingAccount?.getFeatures() || {};\n      if (features.readOnlyDocs || this.isReadonly()) {\n        // Don't allow any access to docs that is stronger than \"viewers\".\n        doc.access = roles.getWeakestRole(\"viewers\", doc.access);\n      }\n      // Place ownership information in the doc's workspace.\n      (doc.workspace as any).owner = doc.workspace.org.owner;\n    }\n    if (forkId || snapshotId) {\n      doc.trunkId = doc.id;\n\n      // Fix up our reply to be correct for the fork, rather than the trunk.\n      // The \"id\" and \"urlId\" fields need updating.\n      doc.id = buildUrlId({ trunkId: doc.id, forkId, forkUserId, snapshotId });\n      if (doc.urlId) {\n        doc.urlId = buildUrlId({ trunkId: doc.urlId, forkId, forkUserId, snapshotId });\n      }\n\n      // Set trunkAccess field.\n      doc.trunkAccess = doc.access;\n\n      // Update access for fork.\n      if (forkId) { this._setForkAccess(doc, { userId, forkUserId }, doc); }\n      if (!doc.access) {\n        throw new ApiError(\"access denied\", 403);\n      }\n    }\n    return doc;\n  }\n\n  // Calls getDocImpl() and returns the Document from that, caching a fresh DocAuthResult along\n  // the way. Note that we only cache the access level, not Document itself.\n  public async getDoc(reqOrScope: Request | Scope, transaction?: EntityManager): Promise<Document> {\n    const scope = \"params\" in reqOrScope ? getScope(reqOrScope) : reqOrScope;\n    const key = getDocAuthKeyFromScope(scope);\n    const promise = this.getDocImpl(key, transaction);\n    await mapSetOrClear(this._docAuthCache, stringifyDocAuthKey(key), makeDocAuthResult(promise));\n    const doc = await promise;\n    // Filter the result for removed / non-removed documents.\n    if (!scope.showAll && scope.showRemoved ?\n      (doc.removedAt === null && doc.workspace.removedAt === null) :\n      (doc.removedAt || doc.workspace.removedAt)) {\n      throw new ApiError(\"document not found\", 404);\n    }\n    return doc;\n  }\n\n  public async getAllDocs() {\n    return this.connection.getRepository(Document).find();\n  }\n\n  public async getRawDocById(docId: string, transaction?: EntityManager) {\n    return await this.getDoc({\n      urlId: docId,\n      userId: this._usersManager.getPreviewerUserId(),\n      showAll: true,\n    }, transaction);\n  }\n\n  // Returns access info for the given doc and user, caching the results for DOC_AUTH_CACHE_TTL\n  // ms. This helps reduce database load created by liberal authorization requests.\n  public async getDocAuthCached(key: DocAuthKey): Promise<DocAuthResult> {\n    return mapGetOrSet(this._docAuthCache, stringifyDocAuthKey(key),\n      () => makeDocAuthResult(this.getDocImpl(key)));\n  }\n\n  // Used in tests, and to clear all timeouts when exiting.\n  public flushDocAuthCache() {\n    this._docAuthCache.clear();\n  }\n\n  // Clear all caches. This is used, in particular, on server exit.\n  public clearCaches() {\n    this.flushDocAuthCache();\n    this.caches?.clear();\n  }\n\n  // Flush cached access information about a specific document\n  // (identified specifically by a docId, not a urlId).  Any cached\n  // information under an alias will also be flushed.\n  // TODO: make a more efficient implementation if needed.\n  public async flushSingleDocAuthCache(scope: DocScope, docId: string) {\n    // Get all aliases of this document.\n    const aliases = await this._connection.manager.find(Alias, { where: { docId } });\n    // Construct a set of possible prefixes for cache keys.\n    const names = new Set(aliases.map(a => stringifyUrlIdOrg(a.urlId, scope.org)));\n    names.add(stringifyUrlIdOrg(docId, scope.org));\n    // Remove any cache keys that start with any of the prefixes.\n    for (const key of this._docAuthCache.keys()) {\n      const name = key.split(\" \", 1)[0];\n      if (names.has(name)) { this._docAuthCache.delete(key); }\n    }\n  }\n\n  // Find a document by name.  Limit name search to a specific organization.\n  // It is possible to hit ambiguities, e.g. with the same name of a doc\n  // in multiple workspaces, so this is not a general-purpose method.  It\n  // is here to facilitate V0 -> V1 migration, so existing links to docs continue\n  // to work.\n  public async getDocByName(userId: number, orgId: number, docName: string): Promise<QueryResult<Document>> {\n    let qb = this._docs()\n      .innerJoin(\"docs.workspace\", \"workspace\")\n      .innerJoin(\"workspace.org\", \"org\")\n      .where(\"docs.name = :docName\", { docName })\n      .andWhere(\"org.id = :orgId\", { orgId });\n    qb = this._withAccess(qb, userId, \"docs\");\n    return this._single(await this._verifyAclPermissions(qb));\n  }\n\n  /**\n   * Gets a list of all forks whose trunk is `docId`.\n   *\n   * NOTE: This is not a part of the API. It should only be called by the DocApi when\n   * deleting a document.\n   */\n  public async getDocForks(docId: string): Promise<Document[]> {\n    return this._connection.createQueryBuilder()\n      .select(\"forks\")\n      .from(Document, \"forks\")\n      .where(\"forks.trunk_id = :docId\", { docId })\n      .getMany();\n  }\n\n  /**\n   *\n   * Adds an org with the given name. Returns a query result with the added org.\n   *\n   * @param user: user doing the adding\n   * @param name: desired org name\n   * @param domain: desired org domain, or null not to set a domain\n   * @param setUserAsOwner: if this is the user's personal org (they will be made an\n   *   owner in the ACL sense in any case)\n   * @param useNewPlan: by default, the individual billing account associated with the\n   *   user's personal org will be used for all other orgs they create.  Set useNewPlan\n   *   to force a distinct non-individual billing account to be used for this org.\n   *   NOTE: Currently it is always a true - billing account is one to one with org.\n   * @param product: if set, controls the type of plan used for the org. Only\n   *   meaningful for team sites currently, where it defaults to the plan in GRIST_DEFAULT_PRODUCT\n   *   env variable, or else STUB_PLAN.\n   * @param billing: if set, controls the billing account settings for the org.\n   */\n  public async addOrg(\n    user: User,\n    props: Partial<OrganizationProperties>,\n    options: {\n      setUserAsOwner: boolean,\n      useNewPlan: boolean,\n      product?: string,\n      billing?: BillingOptions\n    },\n    transaction?: EntityManager,\n  ): Promise<QueryResult<Organization>> {\n    const notifications: (() => Promise<void>)[] = [];\n    const name = props.name;\n    const domain = props.domain;\n    if (!name) {\n      return {\n        status: 400,\n        errMessage: \"Bad request: name required\",\n      };\n    }\n    const orgResult = await this.runInTransaction(transaction, async (manager) => {\n      if (domain) {\n        try {\n          checkSubdomainValidity(domain);\n        } catch (e) {\n          return {\n            status: 400,\n            errMessage: `Domain is not permitted: ${e.message}`,\n          };\n        }\n      }\n      // Create or find a billing account to associate with this org.\n      const billingAccountEntities = [];\n      let billingAccount;\n      if (options.useNewPlan) { // use separate billing account (currently yes)\n        const productNames = getDefaultProductNames();\n        const product =\n          // For personal site use personal product always (ignoring options.product)\n          options.setUserAsOwner ? productNames.personal :\n          // For team site use the product from options if given\n            options.product ? options.product :\n            // If we are support user, use team product\n            // A bit fragile: this is called during creation of support@ user, before\n            // getSupportUserId() is available, but with setUserAsOwner of true.\n              user.id === this._usersManager.getSupportUserId() ? productNames.team :\n              // Otherwise use teamInitial product (a stub).\n                productNames.teamInitial;\n\n        billingAccount = new BillingAccount();\n        billingAccount.individual = options.setUserAsOwner;\n        const dbProduct = await manager.findOne(Product, { where: { name: product } });\n        if (!dbProduct) {\n          throw new Error(\"Cannot find product for new organization\");\n        }\n        billingAccount.product = dbProduct;\n        billingAccountEntities.push(billingAccount);\n        const billingAccountManager = new BillingAccountManager();\n        billingAccountManager.user = user;\n        billingAccountManager.billingAccount = billingAccount;\n        billingAccountEntities.push(billingAccountManager);\n        // Apply billing settings if requested, but not all of them.\n        if (options.billing) {\n          const billing = options.billing;\n          // If we have features but it is empty object, just remove it\n          if (billing.features && typeof billing.features === \"object\" && Object.keys(billing.features).length === 0) {\n            delete billing.features;\n          }\n          const allowedKeys: (keyof BillingOptions)[] = [\n            \"stripeCustomerId\",\n            \"stripeSubscriptionId\",\n            \"stripePlanId\",\n            \"features\",\n            // save will fail if externalId is a duplicate.\n            \"externalId\",\n            \"externalOptions\",\n            \"inGoodStanding\",\n            \"status\",\n            \"paymentLink\",\n          ];\n          Object.keys(billing).forEach((key) => {\n            if (!allowedKeys.includes(key as any)) {\n              delete (billing as any)[key];\n            }\n          });\n          Object.assign(billingAccount, billing);\n        }\n      } else {\n        log.warn(\"Creating org with shared billing account\");\n        // Use the billing account from the user's personal org to start with.\n        billingAccount = await manager.createQueryBuilder()\n          .select(\"billing_accounts\")\n          .from(BillingAccount, \"billing_accounts\")\n          .leftJoinAndSelect(\"billing_accounts.orgs\", \"orgs\")\n          .where(\"orgs.owner_id = :userId\", { userId: user.id })\n          .getOne();\n        if (options.billing?.externalId && billingAccount?.externalId !== options.billing?.externalId) {\n          throw new ApiError(\"Conflicting external identifier\", 400);\n        }\n        if (!billingAccount) {\n          throw new ApiError(\"Cannot find an initial plan for organization\", 500);\n        }\n      }\n      // Create a new org.\n      const org = new Organization();\n      org.checkProperties(props);\n      org.updateFromProperties(props);\n      org.billingAccount = billingAccount;\n      if (domain) {\n        org.domain = domain;\n      }\n      if (options.setUserAsOwner) {\n        org.owner = user;\n      }\n      // Create the special initial permission groups for the new org.\n      const groupMap = this._groupsManager.createGroups();\n      org.aclRules = this.defaultGroups.map((_grpDesc) => {\n        // Get the special group with the name needed for this ACL Rule\n        const group = groupMap[_grpDesc.name];\n        // Note that the user is added to the owners group of an org when it is created.\n        if (_grpDesc.name === roles.OWNER) {\n          group.memberUsers = [user];\n        }\n        // Add each of the special groups to the new workspace.\n        const aclRuleOrg = new AclRuleOrg();\n        aclRuleOrg.permissions = _grpDesc.permissions;\n        aclRuleOrg.group = group;\n        aclRuleOrg.organization = org;\n        return aclRuleOrg;\n      });\n      // Saves the workspace as well as its new ACL Rules and Group.\n      const groups = org.aclRules.map(rule => rule.group);\n      let savedOrg: Organization;\n      try {\n        const result = await manager.save([org, ...org.aclRules, ...groups, ...billingAccountEntities]);\n        savedOrg = result[0] as Organization;\n      } catch (e) {\n        if (e.name === \"QueryFailedError\" && e.message?.match(/unique constraint/i)) {\n          throw new ApiError(\"Domain already in use\", 400);\n        }\n        throw e;\n      }\n      // Add a starter workspace to the org.  Any limits on org workspace\n      // count are not checked, this will succeed unconditionally.\n      await this._doAddWorkspace({ org: savedOrg, props: { name: \"Home\" } }, manager);\n\n      if (!options.setUserAsOwner) {\n        // This user just made a team site (once this transaction is applied).\n        // Emit a notification.\n        notifications.push(this._teamCreatorNotification(user.id));\n      }\n      return { status: 200, data: savedOrg };\n    });\n    for (const notification of notifications) { await notification(); }\n    return orgResult;\n  }\n\n  /**\n   * Updates the properties of the specified org.\n   *\n   * - If setting anything more than prefs:\n   *     - Checks that the user has UPDATE permissions to the given org. If\n   *       not, throws an error.\n   * - For setting userPrefs or userOrgPrefs:\n   *     - These are user-specific setting, so are allowed with VIEW access\n   *       (that includes guests). Prefs are replaced in their entirety, not\n   *       merged.\n   * - For setting orgPrefs:\n   *     - These are not user-specific, so require UPDATE permissions.\n   *\n   * Returns a query result with status 200 and the previous and current\n   * versions of the org on success.\n   */\n  public async updateOrg(\n    scope: Scope,\n    orgKey: string | number,\n    props: Partial<OrganizationProperties>,\n    transaction?: EntityManager,\n  ): Promise<QueryResult<PreviousAndCurrent<Organization>>> {\n    // Check the scope of the modifications.\n    let markPermissions: number = Permissions.VIEW;\n    let modifyOrg: boolean = false;\n    let modifyPrefs: boolean = false;\n    for (const key of Object.keys(props)) {\n      if (key === \"orgPrefs\") {\n        // If setting orgPrefs, make sure we have SCHEMA_EDIT rights since this\n        // will affect other users.\n        markPermissions = Permissions.SCHEMA_EDIT;\n        modifyPrefs = true;\n      } else if (key === \"userPrefs\" || key === \"userOrgPrefs\") {\n        // These keys only affect the current user.\n        modifyPrefs = true;\n      } else {\n        markPermissions = Permissions.SCHEMA_EDIT;\n        modifyOrg = true;\n      }\n    }\n\n    // TODO: Unsetting a domain will likely have to be supported; also possibly prefs.\n    return await this.runInTransaction(transaction, async (manager) => {\n      const orgQuery = this.org(scope, orgKey, {\n        manager,\n        markPermissions,\n        needRealOrg: true,\n      });\n      const queryResult = await verifyEntity(orgQuery);\n      if (queryResult.status !== 200) {\n        // If the query for the org failed, return the failure result.\n        return queryResult;\n      }\n      // Update the fields and save.\n      const org: Organization = queryResult.data;\n      const previous = structuredClone(org);\n      org.checkProperties(props);\n      if (modifyOrg) {\n        if (props.domain) {\n          if (org.owner) {\n            throw new ApiError(\"Cannot set a domain for a personal organization\", 400);\n          }\n          try {\n            checkSubdomainValidity(props.domain);\n          } catch (e) {\n            return {\n              status: 400,\n              errMessage: `Domain is not permitted: ${e.message}`,\n            };\n          }\n        }\n        org.updateFromProperties(props);\n        await manager.save(org);\n      }\n      if (modifyPrefs) {\n        for (const flavor of [\"orgPrefs\", \"userOrgPrefs\", \"userPrefs\"] as const) {\n          const prefs = props[flavor];\n          if (prefs === undefined) { continue; }\n          const orgId = [\"orgPrefs\", \"userOrgPrefs\"].includes(flavor) ? org.id : null;\n          const userId = [\"userOrgPrefs\", \"userPrefs\"].includes(flavor) ? scope.userId : null;\n          await manager.createQueryBuilder()\n            .insert()\n          // if pref flavor has been set before, update it\n            .onConflict(\"(COALESCE(org_id,0), COALESCE(user_id,0)) DO UPDATE SET prefs = :prefs\")\n          // TypeORM muddles JSON handling a bit here\n            .setParameters({ prefs: JSON.stringify(prefs) })\n            .into(Pref)\n            .values({ orgId, userId, prefs })\n            .execute();\n        }\n      }\n      return { status: 200, data: { previous, current: org } };\n    });\n  }\n\n  // Checks that the user has REMOVE and SCHEMA_EDIT permissions to the given org. If not, throws an\n  // error. Otherwise deletes the given org. Returns a query result with status 200\n  // on success.\n  //\n  // This method only cleans up the database, and not any documents associated\n  // with the site. So it shouldn't be made available directly to users.\n  // Instead use Doom.deleteOrg which is aware of the world outside the\n  // database.\n  public async deleteOrg(\n    scope: Scope,\n    orgKey: string | number,\n    transaction?: EntityManager,\n  ): Promise<QueryResult<Organization>> {\n    return await this.runInTransaction(transaction, async (manager) => {\n      const orgQuery = this.org(scope, orgKey, {\n        manager,\n        markPermissions: Permissions.SCHEMA_EDIT | Permissions.REMOVE,\n        allowSpecialPermit: true,\n      })\n      // Join the org's workspaces (with ACLs and groups), docs (with ACLs and groups)\n      // and ACLs and groups so we can remove them.\n        .leftJoinAndSelect(\"orgs.aclRules\", \"acl_rules\")\n        .leftJoinAndSelect(\"acl_rules.group\", \"groups\")\n        .leftJoinAndSelect(\"orgs.workspaces\", \"workspaces\")\n        .leftJoinAndSelect(\"workspaces.aclRules\", \"workspace_acl_rules\")\n        .leftJoinAndSelect(\"workspace_acl_rules.group\", \"workspace_group\")\n        .leftJoinAndSelect(\"workspaces.docs\", \"docs\")\n        .leftJoinAndSelect(\"docs.aclRules\", \"doc_acl_rules\")\n        .leftJoinAndSelect(\"doc_acl_rules.group\", \"doc_group\")\n        .leftJoinAndSelect(\"orgs.billingAccount\", \"billing_accounts\");\n      const queryResult = await verifyEntity(orgQuery);\n      if (queryResult.status !== 200) {\n        // If the query for the org failed, return the failure result.\n        return queryResult;\n      }\n      const org: Organization = queryResult.data;\n      const deletedOrg = structuredClone(org);\n      // Delete the org, org ACLs/groups, workspaces, workspace ACLs/groups, workspace docs\n      // and doc ACLs/groups.\n      const orgGroups = org.aclRules.map(orgAcl => orgAcl.group);\n      const wsAcls = ([] as AclRule[]).concat(...org.workspaces.map(ws => ws.aclRules));\n      const wsGroups = wsAcls.map(wsAcl => wsAcl.group);\n      const docs = ([] as Document[]).concat(...org.workspaces.map(ws => ws.docs));\n      const docAcls = ([] as AclRule[]).concat(...docs.map(doc => doc.aclRules));\n      const docGroups = docAcls.map(docAcl => docAcl.group);\n      await manager.remove([org, ...org.aclRules, ...orgGroups, ...org.workspaces,\n        ...wsAcls, ...wsGroups, ...docs, ...docAcls, ...docGroups]);\n\n      // Delete billing account if this was the last org using it.\n      const billingAccount = await manager.findOne(BillingAccount, {\n        where: { id: org.billingAccountId },\n        relations: [\"orgs\"],\n      });\n      if (billingAccount?.orgs.length === 0) {\n        await manager.remove([billingAccount]);\n      }\n      return { status: 200, data: deletedOrg };\n    });\n  }\n\n  // Checks that the user has ADD permissions to the given org. If not, throws an error.\n  // Otherwise adds a workspace with the given name. Returns a query result with the\n  // added workspace.\n  public async addWorkspace(\n    scope: Scope,\n    orgKey: string | number,\n    props: Partial<WorkspaceProperties>,\n  ): Promise<QueryResult<Workspace>> {\n    const name = props.name;\n    if (!name) {\n      return {\n        status: 400,\n        errMessage: \"Bad request: name required\",\n      };\n    }\n    return await this._connection.transaction(async (manager) => {\n      let orgQuery = this.org(scope, orgKey, {\n        manager,\n        markPermissions: Permissions.ADD,\n        needRealOrg: true,\n      })\n      // Join the org's ACL rules (with 1st level groups listed) so we can include them in the\n      // workspace.\n        .leftJoinAndSelect(\"orgs.aclRules\", \"acl_rules\")\n        .leftJoinAndSelect(\"acl_rules.group\", \"org_group\")\n        .leftJoinAndSelect(\"orgs.workspaces\", \"workspaces\");  // we may want to count workspaces.\n      orgQuery = this._addFeatures(orgQuery);  // add features to access optional workspace limit.\n      const queryResult = await verifyEntity(orgQuery);\n      if (queryResult.status !== 200) {\n        // If the query for the organization failed, return the failure result.\n        return queryResult;\n      }\n      const org: Organization = queryResult.data;\n      const features = org.billingAccount.getFeatures();\n      if (features.maxWorkspacesPerOrg !== undefined) {\n        // we need to count how many workspaces are in the current org, and if we\n        // are already at or above the limit, then fail.\n        const count = org.workspaces.length;\n        if (count >= features.maxWorkspacesPerOrg) {\n          throw new ApiError(\"No more workspaces permitted\", 403, {\n            limit: {\n              quantity: \"workspaces\",\n              maximum: features.maxWorkspacesPerOrg,\n              value: count,\n              projectedValue: count + 1,\n            },\n          });\n        }\n      }\n      const workspace = await this._doAddWorkspace({ org, props, ownerId: scope.userId }, manager);\n      return { status: 200, data: workspace };\n    });\n  }\n\n  /**\n   * Checks that the user has SCHEMA_EDIT permissions to the given workspace. If\n   * not, throws an error. Otherwise updates the given workspace with the given\n   * name.\n   *\n   * Returns a query result with status 200 and the previous and current\n   * versions of the workspace, on success.\n   */\n  public async updateWorkspace(\n    scope: Scope,\n    wsId: number,\n    props: Partial<WorkspaceProperties>,\n  ): Promise<QueryResult<PreviousAndCurrent<Workspace>>> {\n    return await this._connection.transaction(async (manager) => {\n      const wsQuery = this._workspace(scope, wsId, {\n        manager,\n        markPermissions: Permissions.SCHEMA_EDIT,\n      })\n        .leftJoinAndSelect(\"workspaces.org\", \"orgs\");\n      const queryResult = await verifyEntity(wsQuery);\n      if (queryResult.status !== 200) {\n        // If the query for the workspace failed, return the failure result.\n        return queryResult;\n      }\n      // Update the name and save.\n      const workspace: Workspace = queryResult.data;\n      const previous = structuredClone(workspace);\n      workspace.checkProperties(props);\n      workspace.updateFromProperties(props);\n      await manager.save(workspace);\n      return { status: 200, data: { previous, current: workspace } };\n    });\n  }\n\n  /**\n   * Checks that the user has REMOVE | SCHEMA_EDIT permissions to the given workspace. If not, throws an\n   * error. Otherwise deletes the given workspace. Returns a query result with status 200\n   * and the deleted workspace on success.\n   */\n  public async deleteWorkspace(scope: Scope, wsId: number): Promise<QueryResult<Workspace>> {\n    return await this._connection.transaction(async (manager) => {\n      const wsQuery = this._workspace(scope, wsId, {\n        manager,\n        markPermissions: Permissions.REMOVE | Permissions.SCHEMA_EDIT,\n        allowSpecialPermit: true,\n      })\n      // Join the workspace's docs (with ACLs and groups) and ACLs and groups so we can\n      // remove them. Also join the org to get the orgId.\n        .leftJoinAndSelect(\"workspaces.aclRules\", \"acl_rules\")\n        .leftJoinAndSelect(\"acl_rules.group\", \"groups\")\n        .leftJoinAndSelect(\"workspaces.docs\", \"docs\")\n        .leftJoinAndSelect(\"docs.aclRules\", \"doc_acl_rules\")\n        .leftJoinAndSelect(\"doc_acl_rules.group\", \"doc_groups\")\n        .leftJoinAndSelect(\"workspaces.org\", \"orgs\");\n      const queryResult = await verifyEntity(wsQuery);\n      if (queryResult.status !== 200) {\n        // If the query for the workspace failed, return the failure result.\n        return queryResult;\n      }\n      const workspace: Workspace = queryResult.data;\n      const deletedWorkspace = structuredClone(workspace);\n      // Delete the workspace, workspace docs, doc ACLs/groups and workspace ACLs/groups.\n      const wsGroups = workspace.aclRules.map(wsAcl => wsAcl.group);\n      const docAcls = ([] as AclRule[]).concat(...workspace.docs.map(doc => doc.aclRules));\n      const docGroups = docAcls.map(docAcl => docAcl.group);\n      await manager.remove([workspace, ...wsGroups, ...docAcls, ...workspace.docs,\n        ...workspace.aclRules, ...docGroups]);\n      // Update the guests in the org after removing this workspace.\n      await this._repairOrgGuests(scope, workspace.org.id, manager);\n      return { status: 200, data: deletedWorkspace };\n    });\n  }\n\n  public softDeleteWorkspace(scope: Scope, wsId: number): Promise<QueryResult<Workspace>> {\n    return this._setWorkspaceRemovedAt(scope, wsId, new Date());\n  }\n\n  public async undeleteWorkspace(scope: Scope, wsId: number): Promise<QueryResult<Workspace>> {\n    return this._setWorkspaceRemovedAt(scope, wsId, null);\n  }\n\n  // Checks that the user has ADD permissions to the given workspace. If not, throws an\n  // error. Otherwise adds a doc with the given name. Returns a query result with the id\n  // of the added doc.\n  // The desired docId may be passed in.  If passed in, it should have been generated\n  // by makeId().  The client should not be given control of the choice of docId.\n  // This option is used during imports, where it is convenient not to add a row to the\n  // document database until the document has actually been imported.\n  public async addDocument(\n    scope: Scope,\n    wsId: number,\n    props: Partial<DocumentProperties>,\n    docId?: string,\n  ): Promise<QueryResult<Document>> {\n    const name = props.name;\n    if (!name) {\n      return {\n        status: 400,\n        errMessage: \"Bad request: name required\",\n      };\n    }\n    return await this._connection.transaction(async (manager) => {\n      let wsQuery = this._workspace(scope, wsId, {\n        manager,\n        markPermissions: Permissions.ADD,\n      })\n        .leftJoinAndSelect(\"workspaces.org\", \"orgs\")\n      // Join the workspaces's ACL rules (with 1st level groups listed) so we can include\n      // them in the doc.\n        .leftJoinAndSelect(\"workspaces.aclRules\", \"acl_rules\")\n        .leftJoinAndSelect(\"acl_rules.group\", \"workspace_group\");\n      wsQuery = this._addFeatures(wsQuery);\n      const queryResult = await verifyEntity(wsQuery);\n      if (queryResult.status !== 200) {\n        // If the query for the organization failed, return the failure result.\n        return queryResult;\n      }\n      const workspace: Workspace = queryResult.data;\n      if (workspace.removedAt) {\n        throw new ApiError(\"Cannot add document to a deleted workspace\", 400);\n      }\n      await this._checkRoomForAnotherDoc(workspace, manager);\n      // Create a new document.\n      const doc = new Document();\n      doc.id = docId || makeId();\n      doc.checkProperties(props);\n      doc.updateFromProperties(props);\n      // For some reason, isPinned defaulting to null, not false,\n      // for some typeorm/postgres combination? That causes a\n      // constraint violation.\n      if (!doc.isPinned) {\n        doc.isPinned = false;\n      }\n      // By default, assign a urlId that is a prefix of the docId.\n      // The urlId should be unique across all existing documents.\n      if (!doc.urlId) {\n        for (let i = MIN_URLID_PREFIX_LENGTH; i <= doc.id.length; i++) {\n          const candidate = doc.id.substr(0, i);\n          if (!await manager.findOne(Alias, { where: { urlId: candidate } })) {\n            doc.urlId = candidate;\n            break;\n          }\n        }\n        if (!doc.urlId) {\n          // This should happen only if UUIDs collide.\n          throw new Error(\"Could not find a free identifier for document\");\n        }\n      }\n      if (doc.urlId) {\n        await this._checkForUrlIdConflict(manager, workspace.org, doc.urlId);\n        const alias = new Alias();\n        doc.aliases = [alias];\n        alias.urlId = doc.urlId;\n        alias.orgId = workspace.org.id;\n      } else {\n        doc.aliases = [];\n      }\n      doc.workspace = workspace;\n      doc.createdBy = scope.userId;\n      // Create the special initial permission groups for the new workspace.\n      const groupMap = this._groupsManager.createGroups(workspace, scope.userId);\n      doc.aclRules = this.defaultCommonGroups.map((_grpDesc) => {\n        // Get the special group with the name needed for this ACL Rule\n        const group = groupMap[_grpDesc.name];\n        // Add each of the special groups to the new doc.\n        const aclRuleDoc = new AclRuleDoc();\n        aclRuleDoc.permissions = _grpDesc.permissions;\n        aclRuleDoc.group = group;\n        aclRuleDoc.document = doc;\n        return aclRuleDoc;\n      });\n      // Saves the document as well as its new ACL Rules and Group.\n      const groups = doc.aclRules.map(rule => rule.group);\n      const [data] = await manager.save<[Document, ...(AclRuleDoc | Alias | Group)[]]>([\n        doc,\n        ...doc.aclRules,\n        ...doc.aliases,\n        ...groups,\n      ]);\n      // Ensure that the creator is in the ws and org's guests group. Creator already has\n      // access to the workspace (he is at least an editor), but we need to be sure that\n      // even if he is removed from the workspace, he will still have access to this doc.\n      // Guest groups are updated after any access is changed, so even if we won't add creator\n      // now, he will be added later. NOTE: those functions would normally fail in transaction\n      // as those groups might by already fixed (when there is another doc created in the same\n      // time), but they are ignoring any unique constraints errors.\n      await this._repairWorkspaceGuests(scope, workspace.id, manager);\n      await this._repairOrgGuests(scope, workspace.org.id, manager);\n      return { status: 200, data };\n    });\n  }\n\n  public addSecret(value: string, docId: string): Promise<Secret> {\n    return this._connection.transaction(async (manager) => {\n      const secret = new Secret();\n      secret.id = uuidv4();\n      secret.value = value;\n      secret.doc = { id: docId } as any;\n      await manager.save([secret]);\n      return secret;\n    });\n  }\n\n  // Updates the secret matching id and docId, to the new value.\n  public async updateSecret(id: string, docId: string, value: string, manager?: EntityManager): Promise<void> {\n    const res = await (manager || this._connection).createQueryBuilder()\n      .update(Secret)\n      .set({ value })\n      .where(\"id = :id AND doc_id = :docId\", { id, docId })\n      .execute();\n    if (res.affected !== 1) {\n      throw new ApiError(\"secret with given id not found or nothing was updated\", 404);\n    }\n  }\n\n  public async getSecret(id: string, docId: string, manager?: EntityManager): Promise<string | undefined> {\n    const secret = await (manager || this._connection).createQueryBuilder()\n      .select(\"secrets\")\n      .from(Secret, \"secrets\")\n      .where(\"id = :id AND doc_id = :docId\", { id, docId })\n      .getOne();\n    return secret?.value;\n  }\n\n  // Update the webhook url in the webhook's corresponding secret (note: the webhook identifier is\n  // its secret identifier).\n  public async updateWebhookUrlAndAuth(\n    props: {\n      id: string,\n      docId: string,\n      url: string | undefined,\n      auth: string | undefined,\n      outerManager?: EntityManager },\n  ) {\n    const { id, docId, url, auth, outerManager } = props;\n    return await this.runInTransaction(outerManager, async (manager) => {\n      if (url === undefined && auth === undefined) {\n        throw new ApiError(\"None of the Webhook url and auth are defined\", 404);\n      }\n      const value = await this.getSecret(id, docId, manager);\n      if (!value) {\n        throw new ApiError(\"Webhook with given id not found\", 404);\n      }\n      const webhookSecret = JSON.parse(value);\n      // As we want to patch the webhookSecret object, only set the url and the authorization when they are defined.\n      // When the user wants to empty the value, we are expected to receive empty strings.\n      if (url !== undefined) {\n        webhookSecret.url = url;\n      }\n      if (auth !== undefined) {\n        webhookSecret.authorization = auth;\n      }\n      await this.updateSecret(id, docId, JSON.stringify(webhookSecret), manager);\n    });\n  }\n\n  public async removeWebhook(id: string, docId: string, unsubscribeKey: string, checkKey: boolean): Promise<void> {\n    if (!id) {\n      throw new ApiError(\"Bad request: id required\", 400);\n    }\n    if (!unsubscribeKey && checkKey) {\n      throw new ApiError(\"Bad request: unsubscribeKey required\", 400);\n    }\n    return await this._connection.transaction(async (manager) => {\n      if (checkKey) {\n        const secret = await this.getSecret(id, docId, manager);\n        if (!secret) {\n          throw new ApiError(\"Webhook with given id not found\", 404);\n        }\n        const webhook = JSON.parse(secret) as WebHookSecret;\n        if (webhook.unsubscribeKey !== unsubscribeKey) {\n          throw new ApiError(\"Wrong unsubscribeKey\", 401);\n        }\n      }\n      await manager.createQueryBuilder()\n        .delete()\n        .from(Secret)\n        .where(\"id = :id AND doc_id = :docId\", { id, docId })\n        .execute();\n    });\n  }\n\n  /**\n   * Checks that the user has SCHEMA_EDIT permissions to the given doc. If not,\n   * throws an error. Otherwise updates the given doc with the given name.\n   *\n   * Returns a query result with status 200 and the previous and current\n   * versions of the doc on success.\n   *\n   * NOTE: This does not update the updateAt date indicating the last modified\n   * time of the doc. We may want to make it do so.\n   */\n  public async updateDocument(\n    scope: DocScope,\n    props: Partial<DocumentProperties>,\n    transaction?: EntityManager,\n    options?: {\n      allowSpecialPermit?: boolean,\n    },\n  ): Promise<QueryResult<PreviousAndCurrent<Document>>> {\n    const notifications: (() => Promise<void>)[] = [];\n    const markPermissions = Permissions.SCHEMA_EDIT;\n    const result = await this.runInTransaction(transaction, async (manager) => {\n      const { forkId } = parseUrlId(scope.urlId);\n      let query: SelectQueryBuilder<Document>;\n      if (forkId) {\n        query = this._fork(scope, {\n          manager,\n          allowSpecialPermit: options?.allowSpecialPermit,\n        });\n      } else {\n        query = this._doc(scope, {\n          manager,\n          markPermissions,\n          allowSpecialPermit: options?.allowSpecialPermit,\n        });\n      }\n      const queryResult = await verifyEntity(query);\n      if (queryResult.status !== 200) {\n        // If the query for the doc or fork failed, return the failure result.\n        return queryResult;\n      }\n      // Update the name and save.\n      const doc = getDocResult(queryResult);\n      // Disabled docs can't be modified.\n      if (doc.disabledAt) {\n        return { status: 403, errMessage: \"Document is disabled\" };\n      }\n      const previous = structuredClone(doc);\n      doc.checkProperties(props);\n      doc.updateFromProperties(props);\n      if (forkId) {\n        await manager.save(doc);\n        return { status: 200 };\n      }\n\n      // Forcibly remove the aliases relation from the document object, so that TypeORM\n      // doesn't try to save it.  It isn't safe to do that because it was filtered by\n      // a where clause.\n      // TODO: refactor to avoid using TypeORM's save method.\n      doc.aliases = undefined as any;\n      // TODO: if pinning does anything special in future, like triggering thumbnail\n      // processing, then we should probably call pinDoc.\n      await manager.save(doc);\n      if (props.urlId) {\n        // We accumulate old urlIds in order to correctly redirect them, so we need\n        // to do some extra bookwork when a doc's urlId is changed.  First, throw\n        // an error if urlId is already in use by this org.\n        await this._checkForUrlIdConflict(manager, doc.workspace.org, props.urlId, doc.id);\n        // Otherwise, add an alias entry for this document.\n        await manager.createQueryBuilder()\n          .insert()\n          // if urlId has been used before, update it\n          .onConflict(`(org_id, url_id) DO UPDATE SET doc_id = :docId, created_at = ${now(this._dbType)}`)\n          .setParameter(\"docId\", doc.id)\n          .into(Alias)\n          .values({ orgId: doc.workspace.org.id, urlId: props.urlId, doc })\n          .execute();\n        // TODO: we could limit the max number of aliases stored per document.\n      }\n      // Slightly strange but doc metadata may affect doc-access results because docs of type\n      // 'tutorial' adjust returned access differently from other docs (which may not be ideal).\n      // The callback approach is to publish the invalidation after the transaction commits.\n      this.caches?.addInvalidationDocAccess(notifications, [doc.id]);\n      return { status: 200, data: { previous, current: doc } };\n    });\n    for (const notification of notifications) { await notification(); }\n    return result;\n  }\n\n  // Checks that the user has REMOVE permissions to the given document. If not, throws an\n  // error. Otherwise deletes the given document. Returns a query result with status 200\n  // and the deleted document on success.\n  public async deleteDocument(scope: DocScope): Promise<QueryResult<Document>> {\n    return await this._connection.transaction(async (manager) => {\n      const { forkId } = parseUrlId(scope.urlId);\n      if (forkId) {\n        const forkQuery = this._fork(scope, {\n          manager,\n          allowSpecialPermit: true,\n        });\n        const queryResult = await verifyEntity(forkQuery);\n        if (queryResult.status !== 200) {\n          // If the query for the fork failed, return the failure result.\n          return queryResult;\n        }\n        const fork = getDocResult(queryResult);\n        const data = structuredClone(fork);\n        await manager.remove(fork);\n        return { status: 200, data };\n      } else {\n        const docQuery = this._doc(scope, {\n          manager,\n          markPermissions: Permissions.REMOVE | Permissions.SCHEMA_EDIT,\n          allowSpecialPermit: true,\n        })\n        // Join the docs's ACLs and groups so we can remove them.\n        // Join the workspace and org to get their ids.\n          .leftJoinAndSelect(\"docs.aclRules\", \"acl_rules\")\n          .leftJoinAndSelect(\"acl_rules.group\", \"groups\");\n        const queryResult = await verifyEntity(docQuery);\n        if (queryResult.status !== 200) {\n          // If the query for the doc failed, return the failure result.\n          return queryResult;\n        }\n        const doc = getDocResult(queryResult);\n        const data = structuredClone(doc);\n        const docGroups = doc.aclRules.map(docAcl => docAcl.group);\n        // Delete the doc and doc ACLs/groups.\n        await manager.remove([doc, ...docGroups, ...doc.aclRules]);\n        // Update guests of the workspace and org after removing this doc.\n        await this._repairWorkspaceGuests(scope, doc.workspace.id, manager);\n        await this._repairOrgGuests(scope, doc.workspace.org.id, manager);\n        return { status: 200, data };\n      }\n    });\n  }\n\n  public softDeleteDocument(scope: DocScope): Promise<QueryResult<Document>> {\n    return this._setDocumentRemovedAt(scope, new Date());\n  }\n\n  public async undeleteDocument(scope: DocScope): Promise<QueryResult<Document>> {\n    return this._setDocumentRemovedAt(scope, null);\n  }\n\n  public toggleDisableDocument(action: \"enable\" | \"disable\", scope: DocScope): Promise<QueryResult<Document>> {\n    return this._setDocumentDisabledAt(scope, action === \"disable\" ? new Date() : null);\n  }\n\n  // Fetches and provides a callback with the billingAccount so it may be updated within\n  // a transaction. The billingAccount is saved after any changes applied in the callback.\n  // Will throw an error if the user does not have access to the org's billingAccount.\n  //\n  // Only certain properties of the billingAccount may be changed:\n  // 'inGoodStanding', 'status', 'stripeCustomerId','stripeSubscriptionId', 'stripePlanId'\n  //\n  // Returns an empty query result with status 200 on success.\n  public async updateBillingAccount(\n    scopeOrUser: number | Scope,\n    orgKey: string | number,\n    callback: (billingAccount: BillingAccount, transaction: EntityManager) => void | Promise<void>,\n  ): Promise<QueryResult<void>>  {\n    return await this._connection.transaction(async (transaction) => {\n      const scope = typeof scopeOrUser === \"number\" ? { userId: scopeOrUser } : scopeOrUser;\n      const billingAccount = await this.getBillingAccount(scope, orgKey, false, transaction);\n      const billingAccountCopy = Object.assign({}, billingAccount);\n      await callback(billingAccountCopy, transaction);\n      // Pick out properties that are allowed to be changed, to prevent accidental updating\n      // of other information.\n      const updated = pick(billingAccountCopy, \"inGoodStanding\", \"status\", \"stripeCustomerId\",\n        \"stripeSubscriptionId\", \"stripePlanId\", \"product\", \"externalId\",\n        \"externalOptions\", \"paymentLink\",\n        \"features\");\n      billingAccount.paid = undefined;  // workaround for a typeorm bug fixed upstream in\n      // https://github.com/typeorm/typeorm/pull/4035\n      await transaction.save(Object.assign(billingAccount, updated));\n      return { status: 200 };\n    });\n  }\n\n  // Updates the managers of a billing account.  Returns an empty query result with\n  // status 200 on success.\n  public async updateBillingAccountManagers(userId: number, orgKey: string | number,\n    delta: ManagerDelta): Promise<QueryResult<void>> {\n    const notifications: (() => Promise<void>)[] = [];\n    // Translate our ManagerDelta to a PermissionDelta so that we can reuse existing\n    // methods for normalizing/merging emails and finding the user ids.\n    const permissionDelta: PermissionDelta = { users: {} };\n    for (const key of Object.keys(delta.users)) {\n      const target = delta.users[key];\n      if (target !== null && target !== \"managers\") {\n        throw new ApiError(\"Only valid settings for billing account managers are 'managers' or null\", 400);\n      }\n      permissionDelta.users![key] = delta.users[key] ? \"owners\" : null;\n    }\n\n    return await this._connection.transaction(async (transaction) => {\n      const billingAccount = await this.getBillingAccount({ userId }, orgKey, true, transaction);\n      // At this point, we'll have thrown an error if userId is not a billing account manager.\n      // Now check if the billing account has mutable managers (individual account does not).\n      if (billingAccount.individual) {\n        throw new ApiError(\"billing account managers cannot be added/removed for individual billing accounts\", 400);\n      }\n      // Get the ids of users to update.\n      const billingAccountId = billingAccount.id;\n      const analysis = await this._usersManager.verifyAndLookupDeltaEmails(userId, permissionDelta, true, transaction);\n      this._failIfPowerfulAndChangingSelf(analysis);\n      this._failIfTooManyBillingManagers({\n        analysis,\n        billingAccount,\n      });\n      const { userIdDelta } = await this._createNotFoundUsers({\n        analysis,\n        transaction,\n      });\n      if (!userIdDelta) { throw new ApiError(\"No userIdDelta\", 500); }\n      // Any duplicated emails have been merged, and userIdDelta is now keyed by user ids.\n      // Now we iterate over users and add/remove them as managers.\n      for (const memberUserIdStr of Object.keys(userIdDelta)) {\n        const memberUserId = parseInt(memberUserIdStr, 10);\n        const add = Boolean(userIdDelta[memberUserIdStr]);\n        const manager = await transaction.findOne(BillingAccountManager, { where: { userId: memberUserId,\n          billingAccountId } });\n        if (add) {\n          // Skip adding user if they are already a manager.\n          if (!manager) {\n            const newManager = new BillingAccountManager();\n            newManager.userId = memberUserId;\n            newManager.billingAccountId = billingAccountId;\n            await transaction.save(newManager);\n            notifications.push(this._billingManagerNotification(userId, memberUserId,\n              billingAccount.orgs));\n          }\n        } else {\n          if (manager) {\n            // Don't allow a user to remove themselves as a manager, to be consistent\n            // with ACL behavior.\n            if (memberUserId === userId) {\n              throw new ApiError(\"Users cannot remove themselves as billing managers\", 400);\n            }\n            await transaction.remove(manager);\n          }\n        }\n      }\n      for (const notification of notifications) { await notification(); }\n      return { status: 200 };\n    });\n  }\n\n  // Updates the permissions of users on the given org according to the PermissionDelta.\n  public async updateOrgPermissions(\n    scope: Scope,\n    orgKey: string | number,\n    delta: PermissionDelta,\n  ): Promise<QueryResult<OrgAccessChanges>> {\n    const { userId } = scope;\n    const notifications: (() => Promise<void>)[] = [];\n    const result = await this._connection.transaction(async (manager) => {\n      const analysis = await this._usersManager.verifyAndLookupDeltaEmails(userId, delta, true, manager);\n      let orgQuery = this.org(scope, orgKey, {\n        manager,\n        markPermissions: analysis.permissionThreshold,\n        needRealOrg: true,\n      })\n      // Join the org's ACL rules (with 1st level groups/users listed) so we can edit them.\n        .leftJoinAndSelect(\"orgs.aclRules\", \"acl_rules\")\n        .leftJoinAndSelect(\"acl_rules.group\", \"org_groups\")\n        .leftJoinAndSelect(\"org_groups.memberUsers\", \"org_member_users\");\n      orgQuery = this._addFeatures(orgQuery);\n      orgQuery = this._withAccess(orgQuery, userId, \"orgs\");\n      const queryResult = await verifyEntity(orgQuery);\n      if (queryResult.status !== 200) {\n        // If the query for the organization failed, return the failure result.\n        return queryResult;\n      }\n      this._failIfPowerfulAndChangingSelf(analysis, queryResult);\n      const org: Organization = queryResult.data;\n      await this._failIfTooManyNewUserInvites({\n        orgKey,\n        analysis,\n        billingAccount: org.billingAccount,\n        manager,\n      });\n      const { userIdDelta, users } = await this._createNotFoundUsers({\n        analysis,\n        transaction: manager,\n      });\n      const groups = getNonGuestGroups(org);\n      if (userIdDelta) {\n        const membersBefore = UsersManager.getUsersWithRole(groups, this._usersManager.getExcludedUserIds());\n        const countBefore = removeRole(membersBefore).length;\n        await this._updateUserPermissions(groups, userIdDelta, manager);\n        this._checkUserChangeAllowed(userId, groups);\n        await manager.save(groups);\n        // Fully remove any users being removed from the org.\n        for (const deltaUser in userIdDelta) {\n          // Any users removed from the org should be removed from everything in the org.\n          if (userIdDelta[deltaUser] === null) {\n            await scrubUserFromOrg(org.id, parseInt(deltaUser, 10), userId, manager);\n          }\n        }\n\n        // Get docIds to invalidate, but publish the invalidation once the transaction commits.\n        this.caches?.addInvalidationDocAccess(notifications,\n          await this._getDocsInheritingFrom(manager, { orgId: org.id }));\n\n        // Emit an event if the number of org users is changing.\n        const membersAfter = UsersManager.getUsersWithRole(groups, this._usersManager.getExcludedUserIds());\n        const countAfter = removeRole(membersAfter).length;\n        notifications.push(this._userChangeNotification(userId, org, countBefore, countAfter,\n          membersBefore, membersAfter));\n        // Notify any added users that they've been added to this resource.\n        notifications.push(this._inviteNotification(userId, org, userIdDelta, membersBefore));\n      }\n      return {\n        status: 200,\n        data: {\n          org,\n          accessChanges: {\n            users: getUserAccessChanges({ users, userIdDelta }),\n          },\n        },\n      };\n    });\n    for (const notification of notifications) { await notification(); }\n    return result;\n  }\n\n  // Updates the permissions of users on the given workspace according to the PermissionDelta.\n  public async updateWorkspacePermissions(\n    scope: Scope,\n    wsId: number,\n    delta: PermissionDelta,\n  ): Promise<QueryResult<WorkspaceAccessChanges>> {\n    const { userId } = scope;\n    const notifications: (() => Promise<void>)[] = [];\n    const result = await this._connection.transaction(async (manager) => {\n      const analysis = await this._usersManager.verifyAndLookupDeltaEmails(userId, delta, false, manager);\n      const options = {\n        manager,\n        markPermissions: analysis.permissionThreshold,\n      };\n      let wsQuery = this._buildWorkspaceWithACLRules(scope, wsId, options);\n      wsQuery = this._withAccess(wsQuery, userId, \"workspaces\");\n      const wsQueryResult = await verifyEntity(wsQuery);\n\n      if (wsQueryResult.status !== 200) {\n        // If the query for the workspace failed, return the failure result.\n        return wsQueryResult;\n      }\n      this._failIfPowerfulAndChangingSelf(analysis, wsQueryResult);\n      const ws: Workspace = wsQueryResult.data;\n      const orgId = ws.org.id;\n      let orgQuery = this._buildOrgWithACLRulesQuery(scope, orgId, options);\n      orgQuery = this._addFeatures(orgQuery);\n      const orgQueryResult = await orgQuery.getRawAndEntities();\n      const org: Organization = orgQueryResult.entities[0];\n      await this._failIfTooManyNewUserInvites({\n        orgKey: org.id,\n        analysis,\n        billingAccount: org.billingAccount,\n        manager,\n      });\n      const deltaAndUsers = await this._createNotFoundUsers({\n        analysis,\n        transaction: manager,\n      });\n      let { userIdDelta } = deltaAndUsers;\n      const { users } = deltaAndUsers;\n      // Get all the non-guest groups on the org.\n      const orgGroups = getNonGuestGroups(org);\n      // Get all the non-guest groups to be updated by the delta.\n      const groups = getNonGuestGroups(ws);\n      if (\"maxInheritedRole\" in delta) {\n        // Honor the maxInheritedGroups delta setting.\n        this._moveInheritedGroups(groups, orgGroups, delta.maxInheritedRole);\n        if (delta.maxInheritedRole !== roles.OWNER) {\n          // If the maxInheritedRole was lowered from 'owners', add the calling user\n          // back as an owner so that their acl edit access is not revoked.\n          userIdDelta = userIdDelta || {};\n          userIdDelta[userId] = roles.OWNER;\n        }\n      }\n      const membersBefore = this._usersManager.withoutExcludedUsers(\n        new Map(groups.map(grp => [grp.name, grp.memberUsers])),\n      );\n      if (userIdDelta) {\n        // To check limits on shares, we track group members before and after call\n        // to _updateUserPermissions.  Careful, that method mutates groups.\n        const nonOrgMembersBefore = this._usersManager.getUserDifference(groups, orgGroups);\n        await this._updateUserPermissions(groups, userIdDelta, manager);\n        this._checkUserChangeAllowed(userId, groups);\n        const nonOrgMembersAfter = this._usersManager.getUserDifference(groups, orgGroups);\n        const features = org.billingAccount.getFeatures();\n        const limit = features.maxSharesPerWorkspace;\n        if (limit !== undefined) {\n          this._restrictShares(null, limit, removeRole(nonOrgMembersBefore),\n            removeRole(nonOrgMembersAfter), true, \"workspace\", features);\n        }\n      }\n      await manager.save(groups);\n      // If the users in workspace were changed, make a call to repair the guests in the org.\n      if (userIdDelta) {\n        await this._repairOrgGuests(scope, orgId, manager);\n        // Get docIds to invalidate, but publish the invalidation once the transaction commits.\n        this.caches?.addInvalidationDocAccess(notifications,\n          await this._getDocsInheritingFrom(manager, { wsId: ws.id }));\n        notifications.push(this._inviteNotification(userId, ws, userIdDelta, membersBefore));\n      }\n      return {\n        status: 200,\n        data: {\n          workspace: ws,\n          accessChanges: {\n            maxInheritedAccess: delta.maxInheritedRole,\n            users: getUserAccessChanges({ users, userIdDelta }),\n          },\n        },\n      };\n    });\n    for (const notification of notifications) { await notification(); }\n    return result;\n  }\n\n  // Updates the permissions of users on the given doc according to the PermissionDelta.\n  public async updateDocPermissions(\n    scope: DocScope,\n    delta: PermissionDelta,\n  ): Promise<QueryResult<DocumentAccessChanges>> {\n    const notifications: (() => Promise<void>)[] = [];\n    const result = await this._connection.transaction(async (manager) => {\n      const { userId } = scope;\n      const analysis = await this._usersManager.verifyAndLookupDeltaEmails(userId, delta, false, manager);\n      const doc = await this._loadDocAccess(scope, analysis.permissionThreshold, manager);\n      this._failIfPowerfulAndChangingSelf(analysis, { data: doc, status: 200 });\n      await this._failIfTooManyNewUserInvites({\n        orgKey: doc.workspace.org.id,\n        analysis,\n        billingAccount: doc.workspace.org.billingAccount,\n        manager,\n      });\n      const deltaAndUsers = await this._createNotFoundUsers({\n        analysis,\n        transaction: manager,\n      });\n      let { userIdDelta } = deltaAndUsers;\n      const { users } = deltaAndUsers;\n      // Get all the non-guest doc groups to be updated by the delta.\n      const groups = getNonGuestGroups(doc);\n      if (\"maxInheritedRole\" in delta) {\n        const wsGroups = getNonGuestGroups(doc.workspace);\n        // Honor the maxInheritedGroups delta setting.\n        this._moveInheritedGroups(groups, wsGroups, delta.maxInheritedRole);\n        if (delta.maxInheritedRole !== roles.OWNER) {\n          // If the maxInheritedRole was lowered from 'owners', add the calling user\n          // back as an owner so that their acl edit access is not revoked.\n          userIdDelta = userIdDelta || {};\n          userIdDelta[userId] = roles.OWNER;\n        }\n      }\n      const membersBefore = new Map(groups.map(grp => [grp.name, grp.memberUsers]));\n      if (userIdDelta) {\n        // To check limits on shares, we track group members before and after call\n        // to _updateUserPermissions.  Careful, that method mutates groups.\n        const org = doc.workspace.org;\n        const orgGroups = getNonGuestGroups(org);\n        const nonOrgMembersBefore = this._usersManager.getUserDifference(groups, orgGroups);\n        await this._updateUserPermissions(groups, userIdDelta, manager);\n        this._checkUserChangeAllowed(userId, groups);\n        const nonOrgMembersAfter = this._usersManager.getUserDifference(groups, orgGroups);\n        const features = org.billingAccount.getFeatures();\n        this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter);\n      }\n      await manager.save(groups);\n      if (userIdDelta) {\n        // If the users in the doc were changed, make calls to repair workspace then org guests.\n        await this._repairWorkspaceGuests(scope, doc.workspace.id, manager);\n        await this._repairOrgGuests(scope, doc.workspace.org.id, manager);\n        // The callback approach is to publish the invalidation after the transaction commits.\n        this.caches?.addInvalidationDocAccess(notifications, [doc.id]);\n        notifications.push(this._inviteNotification(userId, doc, userIdDelta, membersBefore));\n      }\n      return {\n        status: 200,\n        data: {\n          document: doc,\n          accessChanges: {\n            publicAccess: userIdDelta?.[this.getEveryoneUserId()],\n            maxInheritedAccess: delta.maxInheritedRole,\n            users: getUserAccessChanges({ users, userIdDelta }),\n          },\n        },\n      };\n    });\n    for (const notification of notifications) { await notification(); }\n    return result;\n  }\n\n  // Returns UserAccessData for all users with any permissions on the org.\n  public async getOrgAccess(scope: Scope, orgKey: string | number): Promise<QueryResult<PermissionData>> {\n    const queryResult = await this._getOrgWithACLRules(scope, orgKey);\n    if (queryResult.status !== 200) {\n      // If the query for the doc failed, return the failure result.\n      return queryResult;\n    }\n    const org: Organization = queryResult.data;\n    const userRoleMap = GroupsManager.getMemberUserRoles(org, this.defaultGroupNames);\n    const users = UsersManager.getResourceUsers(org).filter(u => userRoleMap[u.id]).map((u) => {\n      const access = userRoleMap[u.id];\n      return {\n        ...this.makeFullUser(u),\n        loginEmail: undefined,    // Not part of PermissionData.\n        access,\n        isMember: access !== \"guests\",\n      };\n    });\n    const personal = this._filterAccessData(scope, users, null);\n    return {\n      status: 200,\n      data: {\n        ...personal,\n        users,\n      },\n    };\n  }\n\n  // Returns UserAccessData for all users with any permissions on the ORG, as well as the\n  // maxInheritedRole set on the workspace. Note that information for all users in the org\n  // is given to indicate which users have access to the org but not to this particular workspace.\n  public async getWorkspaceAccess(scope: Scope, wsId: number): Promise<QueryResult<PermissionData>> {\n    // Run the query for the workspace and org in a transaction. This brings some isolation protection\n    // against changes to the workspace or org while we are querying.\n    const { workspace, org, queryFailure } = await this._connection.transaction(async (manager) => {\n      const wsQueryResult = await this._getWorkspaceWithACLRules(scope, wsId, { manager });\n      if (wsQueryResult.status !== 200) {\n        // If the query for the workspace failed, return the failure result.\n        return { queryFailure: wsQueryResult };\n      }\n\n      const orgQuery = this._buildOrgWithACLRulesQuery(scope, wsQueryResult.data.org.id, { manager });\n      const orgQueryResult = await verifyEntity(orgQuery, { skipPermissionCheck: true });\n      if (orgQueryResult.status !== 200) {\n        // If the query for the org failed, return the failure result.\n        return { queryFailure: orgQueryResult };\n      }\n\n      return {\n        workspace: wsQueryResult.data,\n        org: orgQueryResult.data,\n      };\n    });\n    if (queryFailure) {\n      return queryFailure;\n    }\n\n    const wsMap = GroupsManager.getMemberUserRoles(workspace, this.defaultCommonGroupNames);\n\n    // Also fetch the organization ACLs so we can determine inherited rights.\n\n    // The orgMap gives the org access inherited by each user.\n    const orgMap = GroupsManager.getMemberUserRoles(org, this.defaultBasicGroupNames);\n    const orgMapWithMembership = GroupsManager.getMemberUserRoles(org, this.defaultGroupNames);\n    // Iterate through the org since all users will be in the org.\n\n    const users: UserAccessData[] = UsersManager.getResourceUsers([workspace, org]).map((u) => {\n      const orgAccess = orgMapWithMembership[u.id] || null;\n      return {\n        ...this.makeFullUser(u),\n        loginEmail: undefined,    // Not part of PermissionData.\n        access: wsMap[u.id] || null,\n        parentAccess: roles.getEffectiveRole(orgMap[u.id] || null),\n        isMember: orgAccess && orgAccess !== \"guests\",\n      };\n    });\n    const maxInheritedRole = this._groupsManager.getMaxInheritedRole(workspace);\n    const personal = this._filterAccessData(scope, users, maxInheritedRole);\n    return {\n      status: 200,\n      data: {\n        ...personal,\n        maxInheritedRole,\n        users,\n      },\n    };\n  }\n\n  // Returns UserAccessData for all users with any permissions on the ORG, as well as the\n  // maxInheritedRole set on the doc. Note that information for all users in the org is given\n  // to indicate which users have access to the org but not to this particular doc.\n  // TODO: Consider updating to traverse through the doc groups and their nested groups for\n  // a more straightforward way of determining inheritance. The difficulty here is that all users\n  // in the org and their logins are needed for inclusion in the result, which would require an\n  // extra lookup step when traversing from the doc.\n  //\n  // If the user is not an owner of the document, only that user (at most) will be mentioned\n  // in the result.\n  //\n  // Optionally, the results can be flattened, removing all information about inheritance and\n  // parents, and just giving the effective access level of each user (frankly, the default\n  // output of this method is quite confusing).\n  //\n  // Optionally, users without access to the document can be removed from the results\n  // (I believe they are included in order to one day facilitate auto-completion in the client?).\n  public async getDocAccess(scope: DocScope, options?: {\n    flatten?: boolean,\n    excludeUsersWithoutAccess?: boolean,\n  }): Promise<QueryResult<PermissionData>> {\n    // Doc permissions of forks are based on the \"trunk\" document, so make sure\n    // we look up permissions of trunk if we are on a fork (we'll fix the permissions\n    // up for the fork immediately afterwards).\n    const { trunkId, forkId, forkUserId, snapshotId } = parseUrlId(scope.urlId);\n\n    // Unsaved documents don't live in the database and don't\n    // have access control. Anyone with the URL can access them.\n    // In the absence of anything better, we'll just echo back\n    // the current user to confirm their ownership.\n    if (trunkId === NEW_DOCUMENT_CODE) {\n      const user = await this.getUser(scope.userId);\n      return {\n        status: 200,\n        data: {\n          users: [{\n            ...this.makeFullUser(user || this.getAnonymousUser()),\n            access: \"owners\",\n          }],\n        },\n      };\n    }\n\n    const doc = await this._loadDocAccess({ ...scope, urlId: trunkId }, Permissions.VIEW);\n    // The docMap gives the doc access of the user. It maps user to owners/editors/viewers/guests (member is org only),\n    // but since, doc is a leaf resource, in practice we won't have the guests group here.\n    const docMap = GroupsManager.getMemberUserRoles(doc, this.defaultCommonGroupNames);\n    // The wsMap gives the ws access that can be inherited by each user (owners, editors, viewers)\n    const wsMap = GroupsManager.getMemberUserRoles(doc.workspace, this.defaultBasicGroupNames);\n    // The wsMapWithMembership gives the ws access that users have to the workspace. Includes all groups.\n    const wsMapWithMembership = GroupsManager.getMemberUserRoles(doc.workspace, this.defaultGroupNames);\n    // The orgMap gives the org access that can be inherited by each user (owners, editors, viewers).\n    const orgMap = GroupsManager.getMemberUserRoles(doc.workspace.org, this.defaultBasicGroupNames);\n    // The orgMapWithMembership gives the full access to the org for each user, including\n    // the \"members\" level, which grants no default inheritable access but allows the user\n    // to be added freely to workspaces and documents.\n    const orgMapWithMembership = GroupsManager.getMemberUserRoles(doc.workspace.org, this.defaultGroupNames);\n    const wsMaxInheritedRole = this._groupsManager.getMaxInheritedRole(doc.workspace);\n    // Iterate through the org since all users will be in the org.\n    let users: UserAccessData[] = UsersManager.getResourceUsers([doc, doc.workspace, doc.workspace.org]).map((u) => {\n      // Merge the strongest roles from the resource and parent resources. Note that the parent\n      // resource access levels must be tempered by the maxInheritedRole values of their children.\n      const inheritFromOrg = roles.getWeakestRole(orgMap[u.id] || null, wsMaxInheritedRole);\n      const orgAccess = orgMapWithMembership[u.id] || null;\n      return {\n        ...this.makeFullUser(u),\n        firstLoginAt: undefined, // Not part of PermissionData.\n        loginEmail: undefined,    // Not part of PermissionData.\n        access: docMap[u.id] || null,\n        parentAccess: roles.getEffectiveRole(\n          roles.getStrongestRole(wsMap[u.id] || null, inheritFromOrg),\n        ),\n        isMember: orgAccess && orgAccess !== \"guests\",\n      };\n    });\n    let maxInheritedRole = this._groupsManager.getMaxInheritedRole(doc);\n\n    const thisUser = users.find(user => user.id === scope.userId);\n    const docRealAccess = thisUser ? getRealAccess(thisUser, { maxInheritedRole }) : null;\n    const canViewDoc = (user: UserAccessData) => roles.canView(getRealAccess(user, { maxInheritedRole }));\n    const personalMetadata: Pick<PermissionData, \"public\" | \"personal\"> = {};\n\n    // Unlike other resources, documents rule for seeing other users are a little bit different.\n    // The simple rule is as follows:\n    // - If user is at least editor on the document (but not a public editor), then we return all users\n    //   who can see the document.\n    // - If such user is also an owner of a parent resource (workspace or org), then we include all users on\n    //   that resource, including guest users.\n\n    // Previewer user can see everyone on the list.\n    if (scope.userId === this._usersManager.getPreviewerUserId()) {\n      // No need to filter users, just return all of them.\n    } else {\n      const isPublic = !thisUser || thisUser.anonymous || !docRealAccess;\n      if (!isPublic && roles.canEdit(docRealAccess)) {\n        if (roles.canEditAccess(orgMap[scope.userId] ?? null)) {\n          // If this user is an org owner, return all users unfiltered.\n        } else if (roles.canEditAccess(thisUser?.parentAccess ?? null)) {\n          const canViewWorkspace = (user: UserAccessData) => roles.canView(getRealAccess({\n            // Figure out the access level on workspace (including inherited access from org).\n            access: wsMapWithMembership[user.id] || null,\n            parentAccess: orgMap[user.id] || null,\n          }, { maxInheritedRole: wsMaxInheritedRole }));\n          // If user is owner of the workspace, return all users on the workspace and on the document.\n          users = users.filter(user => canViewDoc(user) || canViewWorkspace(user));\n        } else {\n          // For any other editor/owner non-public user, we return all users who can see the document.\n          users = users.filter(user => canViewDoc(user));\n        }\n\n        // If user can't change access on the document, instruct UI to just show user's role.\n        if (!roles.canEditAccess(getRealAccess(thisUser, { maxInheritedRole }) ?? null)) {\n          personalMetadata.public = false;\n          personalMetadata.personal = true;\n        }\n      } else {\n        users = thisUser ? [thisUser] : [];\n        personalMetadata.public = isPublic;\n        personalMetadata.personal = true;\n      }\n    }\n\n    if (options?.excludeUsersWithoutAccess) {\n      users = users.filter(canViewDoc);\n    }\n\n    if (forkId || snapshotId || options?.flatten) {\n      for (const user of users) {\n        const access = getRealAccess(user, { maxInheritedRole });\n        user.access = access;\n        user.parentAccess = undefined;\n      }\n      maxInheritedRole = null;\n    }\n\n    // If we are on a fork, make any access changes needed. Assumes results\n    // have been flattened.\n    if (forkId) {\n      for (const user of users) {\n        this._setForkAccess(doc, { userId: user.id, forkUserId }, user);\n      }\n    }\n\n    return {\n      status: 200,\n      data: {\n        ...personalMetadata,\n        maxInheritedRole: maxInheritedRole,\n        users,\n      },\n    };\n  }\n\n  /**\n   * Moves the doc to the specified workspace.\n   *\n   * Returns a query result with status 200 and the previous and current\n   * versions of the doc on success.\n   */\n  public async moveDoc(\n    scope: DocScope,\n    wsId: number,\n  ): Promise<QueryResult<PreviousAndCurrent<Document>>> {\n    const notifications: (() => Promise<void>)[] = [];\n    const result = await this._connection.transaction(async (manager) => {\n      // Get the doc\n      const doc = await this._loadDocAccess(scope, Permissions.OWNER, manager);\n      // Disabled docs can't be moved\n      if (doc.disabledAt) {\n        return {\n          status: 403,\n          errMessage: \"Document is disabled\",\n        };\n      }\n      const previous = structuredClone(doc);\n      if (doc.workspace.id === wsId) {\n        return {\n          status: 400,\n          errMessage: `Bad request: doc is already in destination workspace`,\n        };\n      }\n      // Get the destination workspace\n      const workspace = await this._loadWorkspaceAccess(scope, wsId, {\n        markPermissions: Permissions.ADD,\n        transaction: manager,\n      });\n      // Collect all first-level users of the doc being moved.\n      const firstLevelUsers = UsersManager.getResourceUsers(doc);\n      const docGroups = doc.aclRules.map(rule => rule.group);\n      if (doc.workspace.org.id !== workspace.org.id) {\n        // Doc is going to a new org.  Check that there is room for it there.\n        await this._checkRoomForAnotherDoc(workspace, manager);\n        // Check also that doc doesn't have too many shares.\n        if (firstLevelUsers.length > 0) {\n          const sourceOrg = doc.workspace.org;\n          const sourceOrgGroups = getNonGuestGroups(sourceOrg);\n          const destOrg = workspace.org;\n          const destOrgGroups = getNonGuestGroups(destOrg);\n          const nonOrgMembersBefore = this._usersManager.getUserDifference(docGroups, sourceOrgGroups);\n          const nonOrgMembersAfter = this._usersManager.getUserDifference(docGroups, destOrgGroups);\n          const features = destOrg.billingAccount.getFeatures();\n          this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter, false);\n        }\n      }\n      // Update the doc workspace.\n      const oldWs = doc.workspace;\n      doc.workspace = workspace;\n      // The doc should have groups which properly inherit the permissions of the\n      // new workspace after it is moved.\n      // Update the doc groups to inherit the groups in the new workspace/org.\n      // Any previously custom added members remain in the doc groups.\n      doc.aclRules.forEach((aclRule) => {\n        this._groupsManager.setInheritance(aclRule.group, workspace);\n      });\n      // If the org is changing, remove all urlIds for this doc, since there could be\n      // conflicts in the new org.\n      // TODO: could try recreating/keeping the urlIds in the new org if there is in fact\n      // no conflict.  Be careful about the merged personal org.\n      if (oldWs.org.id !== doc.workspace.org.id) {\n        doc.urlId = null;\n        await manager.delete(Alias, { doc: doc.id });\n      }\n      // Forcibly remove the aliases relation from the document object, so that TypeORM\n      // doesn't try to save it.  It isn't safe to do that because it was filtered by\n      // a where clause.\n      doc.aliases = undefined as any;\n      // Saves the document as well as its new ACL Rules and Groups and the\n      // updated guest group in the workspace.\n      const [current] = await manager.save<[Document, ...(AclRuleDoc | Group)[]]>([\n        doc,\n        ...doc.aclRules,\n        ...docGroups,\n      ]);\n      if (firstLevelUsers.length > 0) {\n        // If the doc has first-level users, update the source and destination workspaces.\n        await this._repairWorkspaceGuests(scope, oldWs.id, manager);\n        await this._repairWorkspaceGuests(scope, doc.workspace.id, manager);\n        if (oldWs.org.id !== doc.workspace.org.id) {\n          // Also if the org changed, update the source and destination org guest groups.\n          await this._repairOrgGuests(scope, oldWs.org.id, manager);\n          await this._repairOrgGuests(scope, doc.workspace.org.id, manager);\n        }\n      }\n      // The callback approach is to publish the invalidation after the transaction commits.\n      this.caches?.addInvalidationDocAccess(notifications, [doc.id]);\n      return { status: 200, data: { previous, current } };\n    });\n    for (const notification of notifications) { await notification(); }\n    return result;\n  }\n\n  // Pin or unpin a doc.\n  public async pinDoc(\n    scope: DocScope,\n    setPinned: boolean,\n  ): Promise<QueryResult<Document>> {\n    return await this._connection.transaction(async (manager) => {\n      // Find the doc to assert that it exists. Assert that the user has edit access to the\n      // parent org.\n      const permissions = Permissions.EDITOR;\n      const docQuery = this._doc(scope, {\n        manager,\n      })\n        .addSelect(this._markIsPermitted(\"orgs\", scope.userId, \"open\", permissions), \"is_permitted\");\n      const docQueryResult = await verifyEntity(docQuery);\n      if (docQueryResult.status !== 200) {\n        // If the query for the doc failed, return the failure result.\n        return docQueryResult;\n      }\n      const doc = getDocResult(docQueryResult);\n      if (doc.isPinned !== setPinned) {\n        doc.isPinned = setPinned;\n        // Forcibly remove the aliases relation from the document object, so that TypeORM\n        // doesn't try to save it.  It isn't safe to do that because it was filtered by\n        // a where clause.\n        doc.aliases = undefined as any;\n        // Save and return success status.\n        await manager.save(doc);\n      }\n      return { status: 200, data: doc };\n    });\n  }\n\n  /**\n   * Creates a fork of `doc`, using the specified `forkId`.\n   *\n   * NOTE: This is not a part of the API. It should only be called by the ActiveDoc when\n   * a new fork is initiated.\n   */\n  public async forkDoc(\n    userId: number,\n    doc: Document,\n    forkId: string,\n  ): Promise<QueryResult<string>> {\n    return await this._connection.transaction(async (manager) => {\n      const fork = new Document();\n      fork.id = forkId;\n      fork.name = doc.name;\n      fork.createdBy = userId;\n      fork.trunkId = doc.trunkId || doc.id;\n      const result = await manager.save([fork]);\n      return {\n        status: 200,\n        data: result[0].id,\n      };\n    });\n  }\n\n  /**\n   * Updates the updatedAt and usage values for several docs. Takes a map where each entry maps\n   * a docId to a metadata object containing the updatedAt and/or usage values. This is not a part\n   * of the API, it should be called only by the HostedMetadataManager when a change is made to a\n   * doc.\n   */\n  public async setDocsMetadata(\n    docUpdateMap: { [docId: string]: DocumentMetadata },\n  ): Promise<QueryResult<void>> {\n    if (!docUpdateMap || Object.keys(docUpdateMap).length === 0) {\n      return {\n        status: 400,\n        errMessage: `Bad request: missing argument`,\n      };\n    }\n    const docIds = Object.keys(docUpdateMap);\n    return this._connection.transaction(async (manager) => {\n      const updateTasks = docIds.map((docId) => {\n        return manager.createQueryBuilder()\n          .update(Document)\n          .set(docUpdateMap[docId])\n          .where(\"id = :docId\", { docId })\n          .execute();\n      });\n      await Promise.all(updateTasks);\n      return { status: 200 };\n    });\n  }\n\n  public async setDocGracePeriodStart(docId: string, gracePeriodStart: Date | null) {\n    return await this._connection.createQueryBuilder()\n      .update(Document)\n      .set({ gracePeriodStart })\n      .where({ id: docId })\n      .execute();\n  }\n\n  public async getProduct(name: string): Promise<Product | undefined> {\n    return await this._connection.createQueryBuilder()\n      .select(\"product\")\n      .from(Product, \"product\")\n      .where(\"name = :name\", { name })\n      .getOne() || undefined;\n  }\n\n  public async getDocFeatures(docId: string, transaction?: EntityManager): Promise<Features | undefined> {\n    const billingAccount = await (transaction || this._connection).createQueryBuilder()\n      .select(\"account\")\n      .from(BillingAccount, \"account\")\n      .leftJoinAndSelect(\"account.product\", \"product\")\n      .leftJoinAndSelect(\"account.orgs\", \"org\")\n      .leftJoinAndSelect(\"org.workspaces\", \"workspace\")\n      .leftJoinAndSelect(\"workspace.docs\", \"doc\")\n      .where(\"doc.id = :docId\", { docId })\n      .getOne() || undefined;\n\n    if (!billingAccount) {\n      return undefined;\n    }\n\n    return mergedFeatures(billingAccount.features, billingAccount.product.features);\n  }\n\n  public async getDocProduct(docId: string): Promise<Product | undefined> {\n    return await this._connection.createQueryBuilder()\n      .select(\"product\")\n      .from(Product, \"product\")\n      .leftJoinAndSelect(\"product.accounts\", \"account\")\n      .leftJoinAndSelect(\"account.orgs\", \"org\")\n      .leftJoinAndSelect(\"org.workspaces\", \"workspace\")\n      .leftJoinAndSelect(\"workspace.docs\", \"doc\")\n      .where(\"doc.id = :docId\", { docId })\n      .getOne() || undefined;\n  }\n\n  public getAnonymousUser() {\n    return this._usersManager.getAnonymousUser();\n  }\n\n  public getSpecialUserIds() {\n    return this._usersManager.getSpecialUserIds();\n  }\n\n  public getAnonymousUserId() {\n    return this._usersManager.getAnonymousUserId();\n  }\n\n  public getPreviewerUserId() {\n    return this._usersManager.getPreviewerUserId();\n  }\n\n  public getEveryoneUserId() {\n    return this._usersManager.getEveryoneUserId();\n  }\n\n  public getSupportUserId() {\n    return this._usersManager.getSupportUserId();\n  }\n\n  /**\n   * @see UsersManager.prototype.completeProfiles\n   */\n  public async completeProfiles(profiles: UserProfile[]) {\n    return this._usersManager.completeProfiles(profiles);\n  }\n\n  /**\n   * Calculate the public-facing subdomain for an org.\n   *\n   * If the domain is a personal org, the public-facing subdomain will\n   * be docs/docs-s (if `mergePersonalOrgs` is set), or docs-[s]NNN where NNN\n   * is the user id (if `mergePersonalOrgs` is not set).\n   *\n   * If a domain is set in the database, and `suppressDomain` is not\n   * set, we report that domain verbatim.  The `suppressDomain` may\n   * be set in some key endpoints in order to enforce a `vanityDomain`\n   * feature flag.\n   *\n   * Otherwise, we report o-NNN (or o-sNNN in staging) where NNN is\n   * the org id.\n   */\n  public normalizeOrgDomain(orgId: number, domain: string | null,\n    ownerId: number | undefined, mergePersonalOrgs: boolean = true,\n    suppressDomain: boolean = false): string {\n    if (ownerId) {\n      // An org with an ownerId set is a personal org.  Historically, those orgs\n      // have a subdomain like docs-NN where NN is the user ID.\n      const personalDomain = `docs-${this._idPrefix}${ownerId}`;\n      // In most cases now we pool all personal orgs as a single virtual org.\n      // So when mergePersonalOrgs is on, and the subdomain is either not set\n      // (as it is in the database for personal orgs) or set to something\n      // like docs-NN (as it is in the API), normalization should just return the\n      // single merged org (\"docs\" or \"docs-s\").\n      if (mergePersonalOrgs && (!domain || domain === personalDomain)) {\n        domain = this.mergedOrgDomain();\n      }\n      if (!domain) {\n        domain = personalDomain;\n      }\n    } else if (suppressDomain || !domain) {\n      // If no subdomain is set, or custom subdomains or forbidden, return something\n      // uninspiring but unique, like o-NN where NN is the org ID.\n      domain = `o-${this._idPrefix}${orgId}`;\n    }\n    return domain;\n  }\n\n  // Throw an error for query results that represent errors or have no data; otherwise unwrap\n  // the valid result it contains.\n  public unwrapQueryResult<T>(qr: QueryResult<T>): T {\n    if (qr.data) { return qr.data; }\n    throw new ApiError(qr.errMessage || \"an error occurred\", qr.status);\n  }\n\n  // Throw an error for query results that represent errors\n  public checkQueryResult<T>(qr: QueryResult<T>) {\n    if (qr.status !== 200) {\n      throw new ApiError(qr.errMessage || \"an error occurred\", qr.status);\n    }\n  }\n\n  // Get the domain name for the merged organization.  In production, this is 'docs',\n  // in staging, it is 'docs-s'.\n  public mergedOrgDomain() {\n    if (this._idPrefix) {\n      return `docs-${this._idPrefix}`;\n    }\n    return \"docs\";\n  }\n\n  // The merged organization is a special pseudo-organization\n  // patched together from all the material a given user has access\n  // to.  The result is approximately, but not exactly, an organization,\n  // and so it treated a bit differently.\n  public isMergedOrg(orgKey: string | number | null) {\n    return orgKey === this.mergedOrgDomain() || orgKey === 0;\n  }\n\n  /**\n   * Construct a QueryBuilder for a select query on a specific org given by orgId.\n   * Provides options for running in a transaction and adding permission info.\n   * See QueryOptions documentation above.\n   */\n  public org(scope: Scope, org: string | number | null,\n    options: QueryOptions = {}): SelectQueryBuilder<Organization> {\n    return this._org(scope, scope.includeSupport || false, org, options);\n  }\n\n  public async getLimits(accountId: number): Promise<Limit[]> {\n    const result = this._connection.transaction(async (manager) => {\n      return await manager.createQueryBuilder()\n        .select(\"limit\")\n        .from(Limit, \"limit\")\n        .innerJoin(\"limit.billingAccount\", \"account\")\n        .where(\"account.id = :accountId\", { accountId })\n        .getMany();\n    });\n    return result;\n  }\n\n  public async getLimit(accountId: number, limitType: LimitType): Promise<Limit | null> {\n    return await this._getOrCreateLimitAndReset(accountId, limitType, true);\n  }\n\n  public async peekLimit(accountId: number, limitType: LimitType): Promise<Limit | null> {\n    return await this._getOrCreateLimitAndReset(accountId, limitType, false);\n  }\n\n  public async removeLimit(scope: Scope, limitType: LimitType): Promise<void> {\n    await this._connection.transaction(async (manager) => {\n      const org = await this._org(scope, false, scope.org ?? null, { manager, needRealOrg: true })\n        .innerJoinAndSelect(\"orgs.billingAccount\", \"billing_account\")\n        .innerJoinAndSelect(\"billing_account.product\", \"product\")\n        .leftJoinAndSelect(\"billing_account.limits\", \"limit\", \"limit.type = :limitType\", { limitType })\n        .getOne();\n      const existing = org?.billingAccount?.limits?.[0];\n      if (existing) {\n        await manager.remove(existing);\n      }\n    });\n  }\n\n  /**\n   * Increases the usage of a limit for a given org, and returns it.\n   *\n   * If a limit doesn't exist, but the product associated with the org\n   * has limits for the given `limitType`, one will be created.\n   *\n   * Pass `dryRun: true` to check if a limit can be increased without\n   * actually increasing it.\n   */\n  public async increaseUsage(scope: Scope, limitType: LimitType, options: {\n    delta: number,\n    dryRun?: boolean,\n  }, transaction?: EntityManager): Promise<Limit | null> {\n    const limitOrError: Limit | ApiError | null = await this.runInTransaction(transaction, async (manager) => {\n      const org = await this._org(scope, false, scope.org ?? null, { manager, needRealOrg: true })\n        .innerJoinAndSelect(\"orgs.billingAccount\", \"billing_account\")\n        .innerJoinAndSelect(\"billing_account.product\", \"product\")\n        .getOne();\n      // If the org doesn't exists, or is a fake one (like for anonymous users), don't do anything.\n      if (!org || org.id === 0) {\n        // This API shouldn't be called, it should be checked first if the org is valid.\n        throw new ApiError(`Can't create a limit for non-existing organization`, 500);\n      }\n      const features = org?.billingAccount?.getFeatures();\n      if (!features) {\n        throw new ApiError(`No product found for org ${org.id}`, 500);\n      }\n      if (features.baseMaxAssistantCalls === undefined) {\n        // If the product has no assistantLimit, then it is not billable yet, and we don't need to\n        // track usage as it is basically unlimited.\n        return null;\n      }\n      const existing = await this._getOrCreateLimitAndReset(org.billingAccountId, limitType, true, manager);\n      if (!existing) {\n        throw new ApiError(\n          `Can't create a limit for non-existing organization`,\n          500,\n        );\n      }\n      const limitLess = existing.limit === -1; // -1 means no limit, it is not possible to do in stripe.\n      const projectedValue = existing.usage + options.delta;\n      if (!limitLess && projectedValue > existing.limit) {\n        return new ApiError(\n          `Your ${limitType} limit has been reached. Please upgrade your plan to increase your limit.`,\n          429,\n          {\n            limit: {\n              maximum: existing.limit,\n              projectedValue,\n              quantity: limitType,\n              value: existing.usage,\n            },\n            tips: [{\n              // For non-billable accounts, suggest getting a plan, otherwise suggest visiting the billing page.\n              action: org?.billingAccount?.stripeCustomerId ? \"manage\" : \"upgrade\",\n              message: `Upgrade to a paid plan to increase your ${limitType} limit.`,\n            }],\n          },\n        );\n      }\n      existing.usage += options.delta;\n      existing.usedAt = new Date();\n      if (!options.dryRun) {\n        await manager.save(existing);\n      }\n      return existing;\n    });\n    if (limitOrError instanceof ApiError) {\n      throw limitOrError;\n    }\n\n    return limitOrError;\n  }\n\n  public async syncShares(docId: string, shares: ShareInfo[]) {\n    return this._connection.transaction(async (manager) => {\n      for (const share of shares) {\n        const key = makeId();\n        await manager.createQueryBuilder()\n          .insert()\n        // if urlId has been used before, update it\n          .onConflict(`(doc_id, link_id) DO UPDATE SET options = :options`)\n          .setParameter(\"options\", share.options)\n          .into(Share)\n          .values({\n            linkId: share.linkId,\n            docId,\n            options: JSON.parse(share.options),\n            key,\n          })\n          .execute();\n      }\n      const dbShares = await manager.createQueryBuilder()\n        .select(\"shares\")\n        .from(Share, \"shares\")\n        .where(\"doc_id = :docId\", { docId })\n        .getMany();\n      const activeLinkIds = new Set(shares.map(share => share.linkId));\n      const oldShares = dbShares.filter(share => !activeLinkIds.has(share.linkId));\n      if (oldShares.length > 0) {\n        await manager.createQueryBuilder()\n          .delete()\n          .from(\"shares\")\n          .whereInIds(oldShares.map(share => share.id))\n          .execute();\n      }\n    });\n  }\n\n  public async getShareByKey(key: string) {\n    return this._connection.createQueryBuilder()\n      .select(\"shares\")\n      .from(Share, \"shares\")\n      .where(\"shares.key = :key\", { key })\n      .getOne();\n  }\n\n  public async getShareByLinkId(docId: string, linkId: string) {\n    return this._connection.createQueryBuilder()\n      .select(\"shares\")\n      .from(Share, \"shares\")\n      .where(\"shares.doc_id = :docId and shares.link_id = :linkId\", { docId, linkId })\n      .getOne();\n  }\n\n  /**\n   * Gets the config with the specified `key`.\n   *\n   * Returns a query result with status 200 and the config on success.\n   *\n   * Fails if a config with the specified `key` does not exist.\n   */\n  public async getInstallConfig(\n    key: ConfigKey,\n    { transaction }: { transaction?: EntityManager } = {},\n  ): Promise<QueryResult<Config>> {\n    return this.runInTransaction(transaction, (manager) => {\n      const query = this._installConfig(key, {\n        manager,\n      });\n      return verifyEntity(query, { skipPermissionCheck: true });\n    });\n  }\n\n  /**\n   * Updates the value of the config with the specified `key`.\n   *\n   * If a config with the specified `key` does not exist, returns a query\n   * result with status 201 and a new config on success.\n   *\n   * Otherwise, returns a query result with status 200 and the previous and\n   * current versions of the config on success.\n   */\n  public async updateInstallConfig(\n    key: ConfigKey,\n    value: ConfigValue,\n  ): Promise<QueryResult<Config | PreviousAndCurrent<Config>>> {\n    const events: (() => Promise<void>)[] = [];\n    const result = await this._connection.transaction(async (manager) => {\n      const queryResult = await this.getInstallConfig(key, {\n        transaction: manager,\n      });\n      if (queryResult.status === 404) {\n        const config: Config = new Config();\n        config.key = key;\n        config.value = value;\n        await manager.save(config);\n        events.push(this._streamingDestinationsChange());\n        return {\n          status: 201,\n          data: config,\n        };\n      } else {\n        const config: Config = this.unwrapQueryResult(queryResult);\n        const previous = structuredClone(config);\n        config.value = value;\n        await manager.save(config);\n        events.push(this._streamingDestinationsChange());\n        return {\n          status: 200,\n          data: { previous, current: config },\n        };\n      }\n    });\n    for (const event of events) {\n      await event();\n    }\n    return result;\n  }\n\n  /**\n   * Deletes the config with the specified `key`.\n   *\n   * Returns a query result with status 200 and the deleted config on success.\n   *\n   * Fails if a config with the specified `key` does not exist.\n   */\n  public async deleteInstallConfig(key: ConfigKey): Promise<QueryResult<Config>> {\n    const events: (() => Promise<void>)[] = [];\n    const result = await this._connection.transaction(async (manager) => {\n      const queryResult = await this.getInstallConfig(key, {\n        transaction: manager,\n      });\n      const config: Config = this.unwrapQueryResult(queryResult);\n      const deletedConfig = structuredClone(config);\n      await manager.remove(config);\n      events.push(this._streamingDestinationsChange());\n      return {\n        status: 200,\n        data: deletedConfig,\n      };\n    });\n    for (const event of events) {\n      await event();\n    }\n    return result;\n  }\n\n  /**\n   * Gets the config scoped to a particular `org` with the specified `key`.\n   *\n   * Returns a query result with status 200 and the config on success.\n   *\n   * Fails if the scoped user is not an owner of the org, or a config with\n   * the specified `key` does not exist for the org.\n   */\n  public async getOrgConfig(\n    scope: Scope,\n    org: string | number,\n    key: ConfigKey,\n    options: { manager?: EntityManager } = {},\n  ): Promise<QueryResult<Config>> {\n    return this.runInTransaction(options.manager, (manager) => {\n      const query = this._orgConfig(scope, org, key, {\n        manager,\n      });\n      return verifyEntity(query);\n    });\n  }\n\n  /**\n   * Updates the value of the config scoped to a particular `org` with the\n   * specified `key`.\n   *\n   * If a config with the specified `key` does not exist, returns a query\n   * result with status 201 and a new config on success.\n   *\n   * Otherwise, returns a query result with status 200 and the previous and\n   * current versions of the config on success.\n   *\n   * Fails if the user is not an owner of the org.\n   */\n  public async updateOrgConfig(\n    scope: Scope,\n    orgKey: string | number,\n    key: ConfigKey,\n    value: ConfigValue,\n  ): Promise<QueryResult<Config | PreviousAndCurrent<Config>>> {\n    const eventsWithArgs: (() => Promise<void>)[] = [];\n    const result = await this._connection.transaction(async (manager) => {\n      const orgQuery = this.org(scope, orgKey, {\n        markPermissions: Permissions.OWNER,\n        needRealOrg: true,\n        manager,\n      });\n      const orgQueryResult = await verifyEntity(orgQuery);\n      const org: Organization = this.unwrapQueryResult(orgQueryResult);\n      const configQueryResult = await this.getOrgConfig(scope, orgKey, key, {\n        manager,\n      });\n      if (configQueryResult.status === 404) {\n        const config: Config = new Config();\n        config.key = key;\n        config.value = value;\n        config.org = org;\n        await manager.save(config);\n        eventsWithArgs.push(this._streamingDestinationsChange(org.id));\n        return {\n          status: 201,\n          data: config,\n        };\n      } else {\n        const config: Config = this.unwrapQueryResult(configQueryResult);\n        const previous = structuredClone(config);\n        config.value = value;\n        await manager.save(config);\n        eventsWithArgs.push(this._streamingDestinationsChange(org.id));\n        return {\n          status: 200,\n          data: { previous, current: config },\n        };\n      }\n    });\n    for (const eventWithArgs of eventsWithArgs) {\n      await eventWithArgs();\n    }\n    return result;\n  }\n\n  /**\n   * Deletes the config scoped to a particular `org` with the specified `key`.\n   *\n   * Returns a query result with status 200 and the deleted config on success.\n   *\n   * Fails if the scoped user is not an owner of the org, or a config with\n   * the specified `key` does not exist for the org.\n   */\n  public async deleteOrgConfig(\n    scope: Scope,\n    org: string | number,\n    key: ConfigKey,\n  ): Promise<QueryResult<Config>> {\n    const eventsWithArgs: (() => Promise<void>)[] = [];\n    const result = await this._connection.transaction(async (manager) => {\n      const query = this._orgConfig(scope, org, key, {\n        manager,\n      });\n      const queryResult = await verifyEntity(query);\n      const config: Config = this.unwrapQueryResult(queryResult);\n      const deletedConfig = structuredClone(config);\n      await manager.remove(config);\n      eventsWithArgs.push(this._streamingDestinationsChange(deletedConfig.org!.id));\n      return {\n        status: 200,\n        data: deletedConfig,\n      };\n    });\n    for (const eventWithArgs of eventsWithArgs) {\n      await eventWithArgs();\n    }\n    return result;\n  }\n\n  /**\n   * Gets the config with the specified `key` and `orgId`.\n   *\n   * Returns `null` if no matching config is found.\n   */\n  public async getConfigByKeyAndOrgId(\n    key: ConfigKey,\n    orgId: number | null = null,\n    { manager }: { manager?: EntityManager } = {},\n  ) {\n    let query = this._configs(manager).where(\"configs.key = :key\", { key });\n    if (orgId !== null) {\n      query = query\n        .leftJoinAndSelect(\"configs.org\", \"orgs\")\n        .andWhere(\"configs.org_id = :orgId\", { orgId });\n      query = this._addFeatures(query);\n    } else {\n      query = query.andWhere(\"configs.org_id IS NULL\");\n    }\n    return query.getOne();\n  }\n\n  public async getNewUserInvitesCount(\n    org: string | number,\n    options: {\n      createdSince?: Date;\n      excludedUserIds?: number[];\n      transaction?: EntityManager;\n    } = {},\n  ): Promise<number> {\n    const { createdSince, excludedUserIds = [], transaction } = options;\n    return this.runInTransaction(transaction, async (manager) => {\n      const { count } = await this._orgMembers(org, manager)\n        // Postgres returns a string representation of a bigint unless we cast.\n        .select(\"CAST(COUNT(*) AS INTEGER)\", \"count\")\n        .andWhere(\"org_member_users.is_first_time_user = true\")\n        .andWhere(\"org_member_users.id NOT IN (:...excludedUserIds)\", {\n          excludedUserIds: [\n            ...this._usersManager.getExcludedUserIds(),\n            ...excludedUserIds,\n          ],\n        })\n        .chain(qb =>\n          createdSince ?\n            qb.andWhere(\"org_member_users.created_at >= :createdSince\", {\n              createdSince,\n            }) :\n            qb,\n        )\n        .getRawOne();\n      return count;\n    });\n  }\n\n  public async getDocPrefs(scope: DocScope): Promise<FullDocPrefs> {\n    return await this.runInTransaction<FullDocPrefs>(undefined, async (manager) => {\n      const [, prefs] = await this._doGetDocPrefs(scope, manager);\n      return prefs;\n    });\n  }\n\n  public async setDocPrefs(scope: DocScope, newPrefs: Partial<FullDocPrefs>): Promise<void> {\n    const { urlId: docId, userId } = scope;\n    const notifications: (() => Promise<void>)[] = [];\n    await this.runInTransaction(undefined, async (manager) => {\n      const [doc, origPrefs] = await this._doGetDocPrefs(scope, manager);\n      const updates = [];\n      if (newPrefs.docDefaults) {\n        if (doc.access !== roles.OWNER) {\n          throw new ApiError(\"Only document owners may update document prefs\", 403);\n        }\n        const prefs = { ...origPrefs.docDefaults, ...newPrefs.docDefaults };\n        updates.push({ docId, userId: null, prefs });\n      }\n      if (newPrefs.currentUser) {\n        const prefs = { ...origPrefs.currentUser, ...newPrefs.currentUser };\n        updates.push({ docId, userId, prefs });\n      }\n      await manager.createQueryBuilder()\n        .insert().into(DocPref)\n        .values(updates)\n        .onConflict(`(doc_id, COALESCE(user_id, 0)) DO UPDATE SET prefs = EXCLUDED.prefs`)\n        .execute();\n\n      this.caches?.addInvalidationDocPrefs(notifications, [docId]);\n    });\n    for (const notification of notifications) { await notification(); }\n  }\n\n  /**\n   * Combines default and per-user DocPrefs. Does not check access.\n   */\n  public async getDocPrefsForUsers(docId: string, userIds: number[] | \"any\"): Promise<Map<number | null, DocPrefs>> {\n    const records = await this._connection.createQueryBuilder()\n      .select(\"doc_pref\")\n      .from(DocPref, \"doc_pref\")\n      .where(\"doc_id = :docId\", { docId })\n      .chain(qb => (\n        userIds === \"any\" ? qb :\n          qb.andWhere(\"(user_id IS NULL OR user_id IN (:...userIds))\", { userIds })\n      ))\n      .getMany();\n    return new Map<number | null, DocPrefs>(records.map(r => [r.userId, r.prefs]));\n  }\n\n  public setProposal(options: {\n    srcDocId: string,\n    destDocId: string,\n    comparison: DocStateComparison\n    retracted?: boolean\n  }) {\n    return this._connection.transaction(async (manager) => {\n      const maxRow = await manager.createQueryBuilder()\n        .from(Proposal, \"proposals\")\n        .select(\"MAX(proposals.short_id)\", \"max\")\n        .where(\"proposals.dest_doc_id = :docId\", { docId: options.destDocId })\n        .getRawOne<{ max: number }>();\n      const shortId = (maxRow?.max || 0) + 1;\n      const status: ProposalStatus = options?.retracted ? { status: \"retracted\" } : {};\n      await manager.createQueryBuilder()\n        .insert()\n        .into(Proposal, [\"srcDocId\", \"destDocId\", \"comparison\", \"shortId\", \"status\", \"updatedAt\", \"appliedAt\"])\n        .values({\n          srcDocId: options.srcDocId,\n          destDocId: options.destDocId,\n          comparison: { comparison: options.comparison },\n          status,\n          appliedAt: null,\n          shortId,\n        })\n        .orUpdate([\"comparison\", \"status\", \"updated_at\", \"applied_at\"], [\"src_doc_id\", \"dest_doc_id\"])\n        .execute();\n      this.unwrapQueryResult(await this.updateDocument({\n        urlId: options.destDocId,\n        userId: this.getPreviewerUserId(),\n        specialPermit: {\n          docId: options.destDocId,\n        },\n      }, {\n        options: {\n          proposedChanges: {\n            mayHaveProposals: true,\n          },\n        },\n      }, manager, {\n        allowSpecialPermit: true,\n      }));\n      const proposal = await manager.createQueryBuilder()\n        .from(Proposal, \"proposals\")\n        .select(\"proposals\")\n        .leftJoinAndSelect(\"proposals.srcDoc\", \"src_doc\")\n        .leftJoinAndSelect(\"src_doc.creator\", \"src_creator\")\n        .leftJoinAndSelect(\"src_creator.logins\", \"src_logins\")\n        .where(\"proposals.dest_doc_id = :destDocId\", { destDocId: options.destDocId })\n        .andWhere(\"proposals.src_doc_id = :srcDocId\", { srcDocId: options.srcDocId })\n        .getOneOrFail();\n      return this._normalizeQueryResults(proposal);\n    });\n  }\n\n  public async updateProposalStatus(destDocId: string, shortId: number,\n    status: ProposalStatus) {\n    const timestamp = new Date();\n    const result = await this._connection.createQueryBuilder()\n      .update(Proposal)\n      .set({\n        status,\n        updatedAt: timestamp,\n        ...(status.status === \"applied\") ? {\n          appliedAt: timestamp,\n        } : {},\n      })\n      .where(\"shortId = :shortId\", { shortId })\n      .andWhere(\"destDocId = :destDocId\", { destDocId })\n      .execute();\n    return result;\n  }\n\n  public async getProposals(options: {\n    srcDocId?: string,\n    destDocId?: string,\n    shortId?: number,\n  }): Promise<ApiProposal[]> {\n    const result = await this._connection.createQueryBuilder()\n      .select(\"proposals\")\n      .from(Proposal, \"proposals\")\n      .leftJoinAndSelect(\"proposals.srcDoc\", \"src_doc\")\n      .leftJoinAndSelect(\"src_doc.creator\", \"src_creator\")\n      .leftJoinAndSelect(\"src_creator.logins\", \"src_logins\")\n      .leftJoinAndSelect(\"proposals.destDoc\", \"dest_doc\")\n      .leftJoinAndSelect(\"dest_doc.creator\", \"dest_creator\")\n      .leftJoinAndSelect(\"dest_creator.logins\", \"dest_logins\")\n      .where(options)\n      .orderBy(\"proposals.short_id\", \"DESC\")\n      .getMany();\n    return this._normalizeQueryResults(result);\n  }\n\n  public async getProposal(destDocId: string, shortId: number,\n    transaction?: EntityManager): Promise<ApiProposal> {\n    const result = await (transaction || this._connection).createQueryBuilder()\n      .select(\"proposals\")\n      .from(Proposal, \"proposals\")\n      .leftJoinAndSelect(\"proposals.srcDoc\", \"src_doc\")\n      .leftJoinAndSelect(\"src_doc.creator\", \"src_creator\")\n      .leftJoinAndSelect(\"src_creator.logins\", \"src_logins\")\n      .where(\"proposals.shortId = :shortId\", { shortId })\n      .andWhere(\"proposals.destDocId = :destDocId\", { destDocId })\n      .getOne();\n    return this._normalizeQueryResults(result);\n  }\n\n  /**\n   * Run an operation in an existing transaction if available, otherwise create\n   * a new transaction for it.\n   *\n   * @param transaction: the manager of an existing transaction, or undefined.\n   * @param op: the operation to run in a transaction.\n   */\n  public runInTransaction<T>(\n    transaction: EntityManager | undefined,\n    op: (manager: EntityManager) => Promise<T>,\n  ): Promise<T> {\n    if (transaction) { return op(transaction); }\n    return this._connection.transaction(op);\n  }\n\n  // Convenient helpers for database utilities that depend on _dbType.\n  public makeJsonArray(content: string): string { return makeJsonArray(this._dbType, content); }\n  public readJson(selection: any) { return readJson(this._dbType, selection); }\n\n  // This method is implemented for test purpose only\n  // Using it outside of tests context will lead to partial db\n  // destruction\n  public async testDeleteAllServiceAccounts() {\n    return this._serviceAccountsManager.testDeleteAllServiceAccounts();\n  }\n\n  public async createServiceAccount(\n    ownerId: number,\n    props?: ServiceAccountProperties,\n  ) {\n    return this._serviceAccountsManager.createServiceAccount(ownerId, props);\n  }\n\n  public async getOwnedServiceAccounts(ownerId: number) {\n    return this._serviceAccountsManager.getOwnedServiceAccounts(ownerId);\n  }\n\n  public assertServiceAccountExistingAndOwned(\n    serviceAccount: ServiceAccount | null, expectedOwnerId: number,\n  ): asserts serviceAccount is ServiceAccount {\n    return this._serviceAccountsManager.assertServiceAccountExistingAndOwned(serviceAccount, expectedOwnerId);\n  }\n\n  public async getServiceAccount(serviceId: number) {\n    return this._serviceAccountsManager.getServiceAccount(serviceId);\n  }\n\n  public async getServiceAccountByLoginWithOwner(login: string) {\n    return this._serviceAccountsManager.getServiceAccountByLoginWithOwner(login);\n  }\n\n  public async updateServiceAccount(\n    serviceId: number, partial: Partial<ServiceAccount>, options: { expectedOwnerId?: number } = {},\n  ) {\n    return this._serviceAccountsManager.updateServiceAccount(serviceId, partial, options);\n  }\n\n  public async deleteServiceAccount(serviceId: number, options: { expectedOwnerId?: number } = {}) {\n    return this._serviceAccountsManager.deleteServiceAccount(serviceId, options);\n  }\n\n  public async createServiceAccountApiKey(serviceId: number, options: { expectedOwnerId?: number } = {}) {\n    return this._serviceAccountsManager.createServiceAccountApiKey(serviceId, options);\n  }\n\n  public async deleteServiceAccountApiKey(serviceId: number, options: { expectedOwnerId?: number } = {}) {\n    return this._serviceAccountsManager.deleteServiceAccountApiKey(serviceId, options);\n  }\n\n  public async getApiKey(userId: number) {\n    return this._usersManager.getApiKey(userId);\n  }\n\n  public async createApiKey(userId: number, force: boolean, transaction?: EntityManager) {\n    return this._usersManager.createApiKey(userId, force, transaction);\n  }\n\n  public async deleteApiKey(userId: number, transaction?: EntityManager) {\n    return this._usersManager.deleteApiKey(userId, transaction);\n  }\n\n  private async _doGetDocPrefs(scope: DocScope, manager: EntityManager): Promise<[Document, FullDocPrefs]> {\n    const { urlId: docId, userId } = scope;\n    const docQb = this._doc(scope, { accessStyle: \"openNoPublic\", manager });\n    // The following combination throws ApiError for insufficient access.\n    const doc = this.unwrapQueryResult(await this._verifyAclPermissions(docQb))[0];\n\n    const records = await manager.createQueryBuilder()\n      .select(\"doc_pref\")\n      .from(DocPref, \"doc_pref\")\n      .where(\"doc_id = :docId AND (user_id IS NULL OR user_id = :userId)\", { docId, userId })\n      .getMany();\n\n    return [doc, {\n      docDefaults: records.find(r => r.userId === null)?.prefs || {},\n      currentUser: records.find(r => r.userId === userId)?.prefs || {},\n    }];\n  }\n\n  private async _createNotFoundUsers(options: {\n    analysis: PermissionDeltaAnalysis;\n    transaction?: EntityManager;\n  }) {\n    const { analysis, transaction } = options;\n    const { foundUserDelta, foundUsers } = analysis;\n    const { userDelta: notFoundUserDelta, users: notFoundUsers } =\n      await this._usersManager.translateDeltaEmailsToUserIds(\n        analysis.notFoundUserDelta ?? {},\n        transaction,\n      );\n    return {\n      userIdDelta: { ...foundUserDelta, ...notFoundUserDelta },\n      users: [...foundUsers, ...notFoundUsers],\n    };\n  }\n\n  private _installConfig(\n    key: ConfigKey,\n    { manager }: { manager?: EntityManager },\n  ): SelectQueryBuilder<Config> {\n    return this._configs(manager).where(\n      \"configs.key = :key AND configs.org_id is NULL\",\n      { key },\n    );\n  }\n\n  private _orgConfig(\n    scope: Scope,\n    org: string | number,\n    key: ConfigKey,\n    { manager }: { manager?: EntityManager },\n  ): SelectQueryBuilder<Config> {\n    let query = this._configs(manager)\n      .where(\"configs.key = :key\", { key })\n      .leftJoinAndSelect(\"configs.org\", \"orgs\");\n    if (this.isMergedOrg(org)) {\n      query = query.where(\"orgs.owner_id = :userId\", { userId: scope.userId });\n    } else {\n      query = this._whereOrg(query, org, false);\n    }\n    const effectiveUserId = scope.userId;\n    const threshold = Permissions.OWNER;\n    query = query.addSelect(\n      this._markIsPermitted(\"orgs\", effectiveUserId, \"open\", threshold),\n      \"is_permitted\",\n    );\n    return query;\n  }\n\n  private _configs(manager?: EntityManager) {\n    return (manager || this._connection)\n      .createQueryBuilder()\n      .select(\"configs\")\n      .from(Config, \"configs\");\n  }\n\n  private async _getOrgMembers(org: string | number | Organization) {\n    if (!(org instanceof Organization)) {\n      const result = await this._orgMembers(org).getRawAndEntities();\n      if (result.entities.length === 0) {\n        // If the query for the org failed, return the failure result.\n        throw new ApiError(\"org not found\", 404);\n      }\n      org = result.entities[0];\n    }\n    return UsersManager.getResourceUsers(org, this.defaultNonGuestGroupNames);\n  }\n\n  private _orgMembers(\n    org: string | number,\n    manager?: EntityManager,\n  ) {\n    return (\n      this._org(null, false, org, {\n        needRealOrg: true,\n        manager,\n      })\n        // Join the org's ACL rules (with 1st level groups/users listed).\n        .leftJoinAndSelect(\"orgs.aclRules\", \"acl_rules\")\n        .leftJoinAndSelect(\"acl_rules.group\", \"org_groups\")\n        .leftJoinAndSelect(\"org_groups.memberUsers\", \"org_member_users\")\n    );\n  }\n\n  private async _getOrCreateLimitAndReset(\n    accountId: number,\n    limitType: LimitType,\n    force: boolean,\n    transaction?: EntityManager,\n  ): Promise<Limit | null> {\n    if (accountId === 0) {\n      throw new Error(`getLimit: called for not existing account`);\n    }\n    const result = this.runInTransaction(transaction, async (manager) => {\n      let existing = await manager.createQueryBuilder()\n        .select(\"limit\")\n        .from(Limit, \"limit\")\n        .innerJoinAndSelect(\"limit.billingAccount\", \"account\")\n        .innerJoinAndSelect(\"account.product\", \"product\")\n        .where(\"account.id = :accountId\", { accountId })\n        .andWhere(\"limit.type = :limitType\", { limitType })\n        .getOne();\n\n      // If we don't have a limit, and we can't create one, return null.\n      if (!existing && !force) { return null; }\n\n      // If we have a limit, check if we don't need to reset it.\n      if (existing) {\n        // We reset the limit if current date (in UTC) is greater then last billing period and the limit\n        // wasn't reset yet. We store the last reset date in the limit itself.\n\n        // We can only reset the limit if we know the billing period end date, and this is not a free plan.\n        if (existing.billingAccount.status?.currentPeriodEnd &&\n          existing.billingAccount.status?.currentPeriodStart &&\n          existing.billingAccount.inGoodStanding &&\n          !isFreePlan(existing.billingAccount.product.name)\n        ) {\n          const startDate = new Date(existing.billingAccount.status.currentPeriodStart).getTime();\n          const endDate = new Date(existing.billingAccount.status.currentPeriodEnd).getTime();\n\n          // Calculate the date the limit should be cleared.\n          const timestamp = new Date();\n          const expected = expectedResetDate(startDate, endDate, timestamp.getTime());\n          if (expected) {\n            // If we expect to see a reset date, make sure it was reset at that date or little bit after.\n            const wasResetOk = existing.resetAt && expected < existing.resetAt.getTime();\n            if (!wasResetOk) {\n              // So the limit wasn't reset yet, or before the date we expected.\n              existing.usage = 0;\n              existing.resetAt = timestamp;\n              log.info(\n                `Resetting limit ${limitType} for account ` +\n                `${accountId} (${existing.billingAccount.stripeSubscriptionId}) at ${timestamp}`,\n              );\n              await manager.save(existing);\n            }\n          }\n        }\n\n        return existing;\n      }\n      const ba = await manager.createQueryBuilder()\n        .select(\"billing_accounts\")\n        .from(BillingAccount, \"billing_accounts\")\n        .leftJoinAndSelect(\"billing_accounts.product\", \"products\")\n        .where(\"billing_accounts.id = :accountId\", { accountId })\n        .getOne();\n      if (!ba) {\n        throw new Error(`getLimit: no product for account ${accountId}`);\n      }\n      existing = new Limit();\n      existing.billingAccountId = ba.id;\n      existing.type = limitType;\n      existing.limit = ba.getFeatures().baseMaxAssistantCalls ?? 0;\n      existing.usage = 0;\n      await manager.save(existing);\n      return existing;\n    });\n    return result;\n  }\n\n  private _org(scope: Scope | null, includeSupport: boolean, org: string | number | null,\n    options: QueryOptions = {}): SelectQueryBuilder<Organization> {\n    let query = this._orgs(options.manager);\n    // merged pseudo-org must become personal org.\n    if (org === null || (options.needRealOrg && this.isMergedOrg(org))) {\n      if (!scope?.userId) { throw new Error(\"_org: requires userId\"); }\n      query = query.where(\"orgs.owner_id = :userId\", { userId: scope.userId });\n    } else {\n      query = this._whereOrg(query, org, includeSupport);\n    }\n    if (!getPersonalOrgsEnabled()) {\n      // Filter out any personal orgs - treat them as not existing.\n      query.andWhere(\"orgs.owner_id is NULL\");\n    }\n    if (options.markPermissions) {\n      if (!scope?.userId) {\n        throw new Error(`_orgQuery error: userId must be set to mark permissions`);\n      }\n      let effectiveUserId = scope.userId;\n      let threshold = options.markPermissions;\n      // TODO If the specialPermit is used across the network, requests could refer to orgs in\n      // different ways (number vs string), causing this comparison to fail.\n      if (options.allowSpecialPermit && scope.specialPermit?.org === org) {\n        effectiveUserId = this._usersManager.getPreviewerUserId();\n        threshold = Permissions.VIEW;\n      }\n      // Compute whether we have access to the doc\n      query = query.addSelect(\n        this._markIsPermitted(\"orgs\", effectiveUserId, \"open\", threshold),\n        \"is_permitted\",\n      );\n    }\n    return query;\n  }\n\n  /**\n   * Construct a QueryBuilder for a select query on a specific org's workspaces given by orgId.\n   * Provides options for running in a transaction and adding permission info.\n   * See QueryOptions documentation above.\n   */\n  private _orgWorkspaces(scope: Scope, org: string | number | null,\n    options: QueryOptions = {}): SelectQueryBuilder<Organization> {\n    const { userId } = scope;\n    const supportId = this._usersManager.getSpecialUserId(SUPPORT_EMAIL);\n    let query = this.org(scope, org, options)\n      .leftJoinAndSelect(\"orgs.workspaces\", \"workspaces\")\n      .leftJoinAndSelect(\"workspaces.docs\", \"docs\", this._onDoc(scope))\n      .leftJoin(\"orgs.billingAccount\", \"account\")\n      .leftJoin(\"account.product\", \"product\")\n      .addSelect(\"product.features\")\n      .addSelect(\"product.id\")\n      .addSelect(\"account.id\")\n      // order the support org (aka Samples/Examples) after other ones.\n      .orderBy(\"coalesce(orgs.owner_id = :supportId, false)\")\n      .setParameter(\"supportId\", supportId)\n      .setParameter(\"userId\", userId)\n      .addOrderBy(\"(orgs.owner_id = :userId)\", \"DESC\")\n      // For consistency of results, particularly in tests, order workspaces by name.\n      .addOrderBy(\"workspaces.name\")\n      .addOrderBy(\"docs.created_at\")\n      .leftJoinAndSelect(\"orgs.owner\", \"org_users\");\n\n    if (userId !== this._usersManager.getAnonymousUserId()) {\n      query = this._addForks(userId, query);\n    }\n\n    // If merged org, we need to take some special steps.\n    if (this.isMergedOrg(org)) {\n      // Add information about owners of personal orgs.\n      query = query.leftJoinAndSelect(\"org_users.logins\", \"org_logins\");\n      // Add a direct, efficient filter to remove irrelevant personal orgs from consideration.\n      query = this._filterByOrgGroups(query, userId, null);\n      // The anonymous user is a special case; include only examples from support user.\n      if (userId === this._usersManager.getAnonymousUserId()) {\n        query = query.andWhere(\"orgs.owner_id = :supportId\", { supportId });\n      }\n    }\n    query = this._addIsSupportWorkspace(userId, query, \"orgs\", \"workspaces\");\n    // Add access information and query limits\n    // TODO: allow generic org limit once sample/support workspace is done differently\n    query = this._applyLimit(query, { ...scope, org: undefined }, [\"orgs\", \"workspaces\", \"docs\"], \"list\");\n    return query;\n  }\n\n  /**\n   * Check if urlId is already in use in the given org, and throw an error if so.\n   * If the org is a personal org, we check for use of the urlId in any personal org.\n   * If docId is set, we permit the urlId to be in use by that doc.\n   */\n  private async _checkForUrlIdConflict(manager: EntityManager, org: Organization, urlId: string, docId?: string) {\n    // Prepare a query to see if there is an existing conflicting urlId.\n    let aliasQuery = this._docs(manager)\n      .leftJoinAndSelect(\"docs.aliases\", \"aliases\")\n      .leftJoinAndSelect(\"aliases.org\", \"orgs\")\n      .where(\"docs.urlId = :urlId\", { urlId });  // Place restriction on active urlIds only.\n    // Older urlIds are best-effort, and subject to\n    // reuse (currently).\n    if (org.ownerId === this._usersManager.getSupportUserId()) {\n      // This is the support user.  Some of their documents end up as examples on team sites.\n      // so urlIds need to be checked globally, which corresponds to placing no extra where\n      // clause here.\n    } else if (org.ownerId) {\n      // This is a personal org, so look for conflicts in any personal org\n      // (needed to ensure consistency in merged personal org).\n      // We don't need to do anything special about examples since they are stored in a personal\n      // org.\n      aliasQuery = aliasQuery.andWhere(\"orgs.owner_id is not null\");\n    } else {\n      // For team sites, just check within the team site.\n      // We also need to check within the support@ org for conflict with examples, which\n      // currently have an existence within team sites.\n      aliasQuery = aliasQuery.andWhere(\"(aliases.orgId = :orgId OR aliases.orgId = :exampleOrgId)\",\n        { orgId: org.id, exampleOrgId: this._exampleOrgId });\n    }\n    if (docId) {\n      aliasQuery = aliasQuery.andWhere(\"docs.id <> :docId\", { docId });\n    }\n    if (await aliasQuery.getOne()) {\n      throw new ApiError(\"urlId already in use\", 400);\n    }\n    // Also forbid any urlId that would match an existing docId, that is a recipe for confusion\n    // and mischief.\n    if (await this._docs(manager).where(\"docs.id = :urlId\", { urlId }).getOne()) {\n      throw new ApiError(\"urlId already in use as document id\", 400);\n    }\n  }\n\n  /**\n   * Updates the workspace guests with any first-level users of docs inside the workspace.\n   */\n  private async _repairWorkspaceGuests(scope: Scope, wsId: number, transaction?: EntityManager): Promise<void> {\n    return await this.runInTransaction(transaction, async (manager) => {\n      // Get guest group for workspace.\n      const wsQuery = this._workspace(scope, wsId, { manager })\n        .leftJoinAndSelect(\"workspaces.aclRules\", \"acl_rules\")\n        .leftJoinAndSelect(\"acl_rules.group\", \"groups\")\n        .leftJoinAndSelect(\"groups.memberUsers\", \"users\");\n      const workspace: Workspace = (await wsQuery.getOne())!;\n      const wsGuestGroup = workspace.aclRules.map(aclRule => aclRule.group)\n        .find(_grp => _grp.name === roles.GUEST);\n      if (!wsGuestGroup) {\n        throw new Error(`_repairWorkspaceGuests error: could not find ${roles.GUEST} ACL group`);\n      }\n\n      // Get explicitly added users of docs inside the workspace, as a separate query\n      // to avoid multiplying rows and to allow filtering the result in sql.\n      const wsWithDocsQuery = this._workspace(scope, wsId, { manager })\n        .leftJoinAndSelect(\"workspaces.docs\", \"docs\")\n        .leftJoinAndSelect(\"docs.aclRules\", \"doc_acl_rules\")\n        .leftJoinAndSelect(\"doc_acl_rules.group\", \"doc_groups\")\n        .leftJoinAndSelect(\"doc_groups.memberUsers\", \"doc_users\")\n        .andWhere(\"docs.removed_at IS NULL\")  // Don't grant guest access for soft-deleted docs.\n        .andWhere(\"doc_users.id is not null\");\n      const wsWithDocs = await wsWithDocsQuery.getOne();\n      await this._groupsManager.setGroupUsers(manager, wsGuestGroup.id, wsGuestGroup.memberUsers,\n        this._usersManager.filterEveryone(\n          UsersManager.getResourceUsers(wsWithDocs?.docs || []),\n        ),\n      );\n    });\n  }\n\n  /**\n   * Updates the org guests with any first-level users of workspaces inside the org.\n   * NOTE: If repairing both workspace and org guests, this should always be called AFTER\n   * _repairWorkspaceGuests.\n   */\n  private async _repairOrgGuests(scope: Scope, orgKey: string | number, transaction?: EntityManager): Promise<void> {\n    return await this.runInTransaction(transaction, async (manager) => {\n      const orgQuery = this.org(scope, orgKey, { manager })\n        .leftJoinAndSelect(\"orgs.aclRules\", \"acl_rules\")\n        .leftJoinAndSelect(\"acl_rules.group\", \"groups\")\n        .leftJoinAndSelect(\"groups.memberUsers\", \"users\")\n        .andWhere(\"groups.name = :role\", { role: roles.GUEST });\n      const org = await orgQuery.getOne();\n      if (!org) { throw new Error(\"cannot find org\"); }\n      const workspaceQuery = this._workspaces(manager)\n        .where(\"workspaces.org_id = :orgId\", { orgId: org.id })\n        .andWhere(\"workspaces.removed_at IS NULL\")  // Don't grant guest access for soft-deleted workspaces.\n        .leftJoinAndSelect(\"workspaces.aclRules\", \"workspace_acl_rules\")\n        .leftJoinAndSelect(\"workspace_acl_rules.group\", \"workspace_group\")\n        .leftJoinAndSelect(\"workspace_group.memberUsers\", \"workspace_users\")\n        .leftJoinAndSelect(\"workspaces.org\", \"org\");\n      org.workspaces = await workspaceQuery.getMany();\n      const orgGroups = org.aclRules.map(aclRule => aclRule.group);\n      if (orgGroups.length !== 1) {\n        throw new Error(`_repairOrgGuests error: found ${orgGroups.length} ${roles.GUEST} ACL group(s)`);\n      }\n      const orgGuestGroup = orgGroups[0];\n      await this._groupsManager.setGroupUsers(manager, orgGuestGroup.id, orgGuestGroup.memberUsers,\n        this._usersManager.filterEveryone(UsersManager.getResourceUsers(org.workspaces)));\n    });\n  }\n\n  /**\n   * Creates, initializes and saves a workspace in the given org with the given properties.\n   * Product limits on number of workspaces allowed in org are not checked.\n   */\n  private async _doAddWorkspace(\n    { org, props, ownerId }: CreateWorkspaceOptions,\n    transaction?: EntityManager,\n  ): Promise<Workspace> {\n    if (!props.name) { throw new ApiError(\"Bad request: name required\", 400); }\n    return await this.runInTransaction<Workspace>(transaction, async (manager) => {\n      // Create a new workspace.\n      const workspace = new Workspace();\n      workspace.checkProperties(props);\n      workspace.updateFromProperties(props);\n      workspace.org = org;\n      // Create the special initial permission groups for the new workspace.\n      // Optionally add the owner to the workspace.\n      const groupMap = this._groupsManager.createGroups(org, ownerId);\n      workspace.aclRules = this.defaultCommonGroups.map((_grpDesc) => {\n        // Get the special group with the name needed for this ACL Rule\n        const group = groupMap[_grpDesc.name];\n        // Add each of the special groups to the new workspace.\n        const aclRuleWs = new AclRuleWs();\n        aclRuleWs.permissions = _grpDesc.permissions;\n        aclRuleWs.group = group;\n        aclRuleWs.workspace = workspace;\n        return aclRuleWs;\n      });\n      // Saves the workspace as well as its new ACL Rules and Group.\n      const groups = workspace.aclRules.map(rule => rule.group);\n      const result = await manager.save([workspace, ...workspace.aclRules, ...groups]);\n      if (ownerId) {\n        // If we modified direct access to the workspace, we need to update the\n        // guest group to include the owner.\n        await this._repairOrgGuests({ userId: ownerId }, org.id, manager);\n      }\n      return result[0] as Workspace;\n    });\n  }\n\n  /**\n   * If the user is a manager of the billing account associated with\n   * the domain, an extra `billingAccount` field is returned,\n   * containing a `inGoodStanding` flag, a `status` json field, and a\n   * `product.paid` flag which is true if on a paid plan or false\n   * otherwise.  Other `billingAccount` fields are included (stripe ids in\n   * particular) but these will not be reported across the API.\n   */\n  private _addBillingAccount(qb: SelectQueryBuilder<Organization>, userId: number) {\n    qb = qb.leftJoinAndSelect(\"orgs.billingAccount\", \"billing_accounts\");\n    qb = qb.leftJoinAndSelect(\"billing_accounts.product\", \"products\");\n    qb = qb.leftJoinAndSelect(\"billing_accounts.managers\", \"managers\",\n      \"managers.billing_account_id = billing_accounts.id and \" +\n      \"managers.user_id = :userId\");\n    qb = qb.setParameter(\"userId\", userId);\n    qb = this._addBillingAccountCalculatedFields(qb);\n    return qb;\n  }\n\n  /**\n   * Adds any calculated fields related to billing accounts - currently just\n   * products.paid.\n   */\n  private _addBillingAccountCalculatedFields<T extends ObjectLiteral>(qb: SelectQueryBuilder<T>) {\n    // We need to sum up whether the account is paid or not, so that UI can provide\n    // a \"billing\" vs \"upgrade\" link.  For the moment, we just check if there is\n    // a subscription id.  TODO: make sure this is correct in case of free plans.\n    qb = qb.addSelect(`(billing_accounts.stripe_subscription_id is not null)`, \"billing_accounts_paid\");\n    return qb;\n  }\n\n  /**\n   * Makes sure that product features for orgs are available in query result.\n   */\n  private _addFeatures<T extends ObjectLiteral>(qb: SelectQueryBuilder<T>, orgAlias: string = \"orgs\") {\n    qb = qb.leftJoinAndSelect(`${orgAlias}.billingAccount`, \"billing_accounts\");\n    qb = qb.leftJoinAndSelect(\"billing_accounts.product\", \"products\");\n    // orgAlias.billingAccount.product.features should now be available\n    return qb;\n  }\n\n  private _addIsSupportWorkspace<T extends ObjectLiteral>(users: AvailableUsers, qb: SelectQueryBuilder<T>,\n    orgAlias: string, workspaceAlias: string) {\n    const supportId = this._usersManager.getSpecialUserId(SUPPORT_EMAIL);\n\n    // We'll be selecting a boolean and naming it as *_support.  This matches the\n    // SQL name `support` of a column in the Workspace entity whose javascript\n    // name is `isSupportWorkspace`.\n    const alias = `${workspaceAlias}_support`;\n\n    // If we happen to be the support user, don't treat our workspaces as anything\n    // special, so we can work with them in the ordinary way.\n    if (UsersManager.isSingleUser(users) && users === supportId) { return qb.addSelect(\"false\", alias); }\n\n    // Otherwise, treat workspaces owned by support as special.\n    return qb.addSelect(`coalesce(${orgAlias}.owner_id = ${supportId}, false)`, alias);\n  }\n\n  /**\n   * Makes sure that doc forks are available in query result.\n   */\n  private _addForks<T extends ObjectLiteral>(userId: number, qb: SelectQueryBuilder<T>) {\n    return qb.leftJoin(\"docs.forks\", \"forks\", \"forks.created_by = :forkUserId\")\n      .setParameter(\"forkUserId\", userId)\n      .addSelect([\n        \"forks.id\",\n        \"forks.trunkId\",\n        \"forks.createdBy\",\n        \"forks.updatedAt\",\n        \"forks.options\",\n      ]);\n  }\n\n  /**\n   * Modify an access level when the document is a fork. Here are the rules, as they\n   * have evolved (the main constraint is that currently forks have no access info of\n   * their own in the db).\n   *   - If fork is a tutorial:\n   *     - User ~USERID from the fork id is owner, all others have no access.\n   *   - If fork is not a tutorial:\n   *     - If there is no ~USERID in fork id, then all viewers of trunk are owners of the fork.\n   *     - If there is a ~USERID in fork id, that user is owner, all others are at most viewers.\n   */\n  private _setForkAccess(doc: Document,\n    ids: { userId: number, forkUserId?: number },\n    res: { access: roles.Role | null }) {\n    if (doc.type === \"tutorial\") {\n      if (ids.userId === this._usersManager.getPreviewerUserId()) {\n        res.access = \"viewers\";\n      } else if (ids.forkUserId && ids.forkUserId === ids.userId) {\n        res.access = \"owners\";\n      } else {\n        res.access = null;\n      }\n    } else {\n      // Forks without a user id are editable by anyone with view access to the trunk.\n      if (ids.forkUserId === undefined && roles.canView(res.access)) { res.access = \"owners\"; }\n      if (ids.forkUserId !== undefined) {\n        // A fork user id is known, so only that user should get to edit the fork.\n        if (ids.userId === ids.forkUserId) {\n          if (roles.canView(res.access)) { res.access = \"owners\"; }\n        } else {\n          // reduce to viewer if not already viewer\n          res.access = roles.getWeakestRole(\"viewers\", res.access);\n        }\n      }\n    }\n  }\n\n  /**\n   * A helper to throw an error if a user with ACL_EDIT permission attempts\n   * to change their own access rights. The user permissions are expected to\n   * be in the supplied QueryResult, or if none is supplied are assumed to be\n   * ACL_EDIT.\n   */\n  private _failIfPowerfulAndChangingSelf(analysis: PermissionDeltaAnalysis, result?: QueryResult<any>) {\n    const permissions: Permissions = result ? result.data.permissions : Permissions.ACL_EDIT;\n    if (permissions === undefined) {\n      throw new Error(\"Query malformed\");\n    }\n    if ((permissions & Permissions.ACL_EDIT) && analysis.affectsSelf) {\n      // editors don't get to remove themselves.\n      // TODO: Consider when to allow updating own permissions - allowing updating own\n      // permissions indiscriminately could lead to orphaned resources.\n      throw new ApiError(\"Bad request: cannot update own permissions\", 400);\n    }\n  }\n\n  private _failIfTooManyBillingManagers(options: {\n    analysis: PermissionDeltaAnalysis;\n    billingAccount: BillingAccount;\n  }) {\n    const { analysis, billingAccount } = options;\n    const { foundUserDelta, foundUsers, notFoundUserDelta } = analysis;\n\n    const max = Deps.defaultMaxBillingManagersPerOrg.value;\n    if (max === undefined) { return; }\n\n    const foundUserIds = new Set(foundUsers.map(user => user.id));\n    const addedUsers = foundUsers.filter(user => foundUserDelta?.[user.id]);\n    const delta = size(notFoundUserDelta) + addedUsers.length;\n    if (!delta) {\n      return;\n    }\n\n    const current = billingAccount.managers.filter(manager =>\n      !foundUserIds.has(manager.userId)).length;\n    if (current + delta > max) {\n      throw new ApiError(\"Your site has too many billing managers\", 403);\n    }\n  }\n\n  private async _failIfTooManyNewUserInvites(options: {\n    orgKey: string | number;\n    analysis: PermissionDeltaAnalysis;\n    billingAccount: BillingAccount;\n    manager?: EntityManager;\n  }) {\n    const { orgKey, analysis, billingAccount, manager } = options;\n    const { foundUserDelta, foundUsers, notFoundUserDelta } = analysis;\n\n    const max =\n      billingAccount.getFeatures().maxNewUserInvitesPerOrg ??\n      Deps.defaultMaxNewUserInvitesPerOrg.value;\n    if (max === undefined) { return; }\n\n    const createdSince = moment()\n      .subtract(Deps.defaultMaxNewUserInvitesPerOrg.durationMs, \"milliseconds\")\n      .toDate();\n    const newUsers = foundUsers.filter((user) => {\n      return user.isFirstTimeUser && user.createdAt >= createdSince;\n    });\n    const addedUsers = newUsers.filter(user => foundUserDelta?.[user.id]);\n    const delta = size(notFoundUserDelta) + addedUsers.length;\n    if (!delta) {\n      return;\n    }\n\n    const current = await this.getNewUserInvitesCount(orgKey, {\n      createdSince,\n      excludedUserIds: newUsers.map(user => user.id),\n      transaction: manager,\n    });\n    if (current + delta > max) {\n      throw new ApiError(\"Your site has too many pending invitations\", 403);\n    }\n  }\n\n  /**\n   * Helper for adjusting acl rules. Given an array of top-level groups from the resource\n   * of interest, returns the updated groups. The returned groups should be saved to\n   * update the group inheritance in the database. Updates the passed in groups.\n   *\n   * NOTE that all group memberUsers must be populated.\n   */\n  private async _updateUserPermissions(\n    groups: NonGuestGroup[],\n    userDelta: UserIdDelta,\n    manager: EntityManager,\n  ): Promise<void> {\n    // Get the user objects which map to non-null values in the userDelta.\n    const userIds = Object.keys(userDelta).filter(userId => userDelta[userId])\n      .map(userIdStr => parseInt(userIdStr, 10));\n    const users = await this._usersManager.getUsersByIds(userIds, { manager });\n\n    // Add unaffected users to the delta so that we have a record of where they are.\n    groups.forEach((grp) => {\n      grp.memberUsers.forEach((usr) => {\n        if (!(usr.id in userDelta)) {\n          userDelta[usr.id] = grp.name;\n          users.push(usr);\n        }\n      });\n    });\n\n    // Create mapping from group names to top-level groups (contain the inherited groups)\n    const topGroups: { [groupName: string]: NonGuestGroup } = {};\n    groups.forEach((grp) => {\n      // Note that this has a side effect of resetting the memberUsers arrays.\n      grp.memberUsers = [];\n      topGroups[grp.name] = grp;\n    });\n\n    // Add users to groups (this has a side-effect of updating the group memberUsers)\n    users.forEach((user) => {\n      const groupName = userDelta[user.id]!;\n      // NOTE that the special names constant is ordered from least to most permissive.\n      // The destination must be a reserved inheritance group or null.\n      if (groupName && !this.defaultNonGuestGroupNames.includes(groupName)) {\n        throw new Error(`_updateUserPermissions userDelta contains invalid group`);\n      }\n      topGroups[groupName].memberUsers.push(user);\n    });\n  }\n\n  /**\n   * Aggregate the given columns as a json object.  The keys should be simple\n   * alphanumeric strings, and the values should be the names of sql columns -\n   * this method is not set up to quote concrete values.\n   */\n  private _aggJsonObject(content: { [key: string]: string }): string {\n    const args = [...Object.keys(content).map(key => [`'${key}'`, content[key]])];\n    if (this._dbType === \"postgres\") {\n      return `json_agg(json_build_object(${args.join(\",\")}))`;\n    } else {\n      return `json_group_array(json_object(${args.join(\",\")}))`;\n    }\n  }\n\n  // If cte is provided, assume it is a common table\n  // expression that selects certain rows of docs, and\n  // substitute it in.\n  private _docs(manager?: EntityManager, cte?: string) {\n    const builder = (manager || this._connection).createQueryBuilder();\n    const docs = (cte ? builder.addCommonTableExpression(cte, \"filtered_docs\") : builder)\n      .select(\"docs\")\n      .from(cte ? FilteredDocument : Document, \"docs\");\n    return docs;\n  }\n\n  /**\n   * Construct a QueryBuilder for a select query on a specific doc given by urlId.\n   * Provides options for running in a transaction and adding permission info.\n   * See QueryOptions documentation above.\n   *\n   * In order to accept urlIds, the aliases, workspaces, and orgs tables are joined.\n   */\n  private _doc(scope: DocScope, options: DocQueryOptions = {}): SelectQueryBuilder<Document> {\n    const { urlId, userId } = scope;\n    // Check if doc is being accessed with a merged org url.  If so,\n    // we will only filter urlId matches, and will allow docId matches\n    // for team site documents.  This is for backwards compatibility,\n    // to support https://docs.getgrist.com/api/docs/<docid> for team\n    // site documents.\n    const mergedOrg = this.isMergedOrg(scope.org || null);\n    // OPTIMIZATION: we add a CTE to prefilter docs table for a union\n    // of matches on docs.id or on aliases. We observe the Postgres query\n    // planner having a hard time with the WHERE clause that does this\n    // filtering later with an OR.\n    // QUIRK: the :urlId parameter in the CTE relies on it being introduced\n    // later in the where clause. There's nowhere to add it in TypeORM's CTE\n    // interface.\n    let query = this._docs(options.manager, `\n  SELECT docs.*\n  FROM docs\n  WHERE docs.id = :urlId\n\n  UNION ALL\n\n  SELECT docs.*\n  FROM aliases\n  JOIN docs ON docs.id = aliases.doc_id\n  WHERE aliases.url_id = :urlId\n`)\n      .leftJoinAndSelect(\"docs.workspace\", \"workspaces\")\n      .leftJoinAndSelect(\"workspaces.org\", \"orgs\")\n      .leftJoinAndSelect(\"docs.aliases\", \"aliases\")\n      .where(new Brackets((cond) => {\n        return cond\n          .where(\"docs.id = :urlId\", { urlId })\n          .orWhere(new Brackets((urlIdCond) => {\n            let urlIdQuery = urlIdCond\n              .where(\"aliases.url_id = :urlId\", { urlId })\n              .andWhere(\"aliases.org_id = orgs.id\");\n            if (mergedOrg) {\n              // Filter specifically for merged org documents.\n              urlIdQuery = urlIdQuery.andWhere(\"orgs.owner_id is not null\");\n            }\n            return urlIdQuery;\n          }));\n      }));\n\n    // TODO includeSupport should really be false, and the support for it should be removed.\n    // (For this, example doc URLs should be under docs.getgrist.com rather than team domains.)\n    // Add access information and query limits\n    const accessStyle = options.accessStyle || \"open\";\n    query = this._applyLimit(query, { ...scope, includeSupport: true }, [\"docs\", \"workspaces\", \"orgs\"], accessStyle);\n    if (options.markPermissions) {\n      let effectiveUserId = userId;\n      let threshold = options.markPermissions;\n      if (options.allowSpecialPermit && scope.specialPermit?.docId) {\n        query = query.andWhere(\"docs.id = :docId\", { docId: scope.specialPermit.docId });\n        effectiveUserId = this._usersManager.getPreviewerUserId();\n        threshold = Permissions.VIEW;\n      }\n      // Compute whether we have access to the doc\n      query = query.addSelect(\n        this._markIsPermitted(\"docs\", effectiveUserId, accessStyle, threshold),\n        \"is_permitted\",\n      );\n    }\n    return query;\n  }\n\n  /**\n   * Construct a QueryBuilder for a select query on a specific fork given by urlId.\n   * Provides options for running in a transaction.\n   */\n  private _fork(scope: DocScope, options: QueryOptions = {}): SelectQueryBuilder<Document> {\n    // Extract the forkId from the urlId and use it to find the fork in the db.\n    const { forkId } = parseUrlId(scope.urlId);\n    let query = this._docs(options.manager)\n      .leftJoinAndSelect(\"docs.trunk\", \"trunk\")\n      .leftJoinAndSelect(\"trunk.workspace\", \"trunk_workspace\")\n      .leftJoinAndSelect(\"trunk_workspace.org\", \"trunk_org\")\n      .where(\"docs.id = :forkId\", { forkId });\n\n    // Compute whether we have access to the fork.\n    if (options.allowSpecialPermit && scope.specialPermit?.docId) {\n      const { forkId: permitForkId } = parseUrlId(scope.specialPermit.docId);\n      query = query\n        .setParameter(\"permitForkId\", permitForkId)\n        .addSelect(\n          \"docs.id = :permitForkId\",\n          \"is_permitted\",\n        );\n    } else {\n      query = query\n        .setParameter(\"forkUserId\", scope.userId)\n        .setParameter(\"forkAnonId\", this._usersManager.getAnonymousUserId())\n        .addSelect(\n          // Access to forks is currently limited to the users that created them, with\n          // the exception of anonymous users, who have no access to their forks.\n          \"docs.created_by = :forkUserId AND docs.created_by <> :forkAnonId\",\n          \"is_permitted\",\n        );\n    }\n\n    return query;\n  }\n\n  private _workspaces(manager?: EntityManager) {\n    return (manager || this._connection).createQueryBuilder()\n      .select(\"workspaces\")\n      .from(Workspace, \"workspaces\");\n  }\n\n  /**\n   * Construct \"ON\" clause for joining docs.  This clause takes care of filtering\n   * out any docs that are not to be listed due to soft deletion.  This filtering\n   * is done in the \"ON\" clause rather than in a \"WHERE\" clause since we still\n   * want to list workspaces even if there are no docs within them.  A \"WHERE\" clause\n   * would entirely remove information about a workspace with no docs.  The \"ON\"\n   * clause, in combination with a \"LEFT JOIN\", preserves the workspace information\n   * and just sets doc information to NULL.\n   */\n  private _onDoc(scope: Scope) {\n    const onDefault = \"docs.workspace_id = workspaces.id\";\n    if (scope.showAll) {\n      return onDefault;\n    } else if (scope.showRemoved) {\n      return `${onDefault} AND (workspaces.removed_at IS NOT NULL OR docs.removed_at IS NOT NULL)`;\n    } else {\n      return `${onDefault} AND (workspaces.removed_at IS NULL AND docs.removed_at IS NULL)`;\n    }\n  }\n\n  /**\n   * Construct a QueryBuilder for a select query on a specific workspace given by\n   * wsId. Provides options for running in a transaction and adding permission info.\n   * See QueryOptions documentation above.\n   */\n  private _workspace(scope: Scope, wsId: number, options: QueryOptions = {}): SelectQueryBuilder<Workspace> {\n    let query = this._workspaces(options.manager)\n      .where(\"workspaces.id = :wsId\", { wsId });\n    if (options.markPermissions) {\n      let effectiveUserId = scope.userId;\n      let threshold = options.markPermissions;\n      if (options.allowSpecialPermit && scope.specialPermit?.workspaceId === wsId) {\n        effectiveUserId = this._usersManager.getPreviewerUserId();\n        threshold = Permissions.VIEW;\n      }\n      // Compute whether we have access to the ws\n      query = query.addSelect(\n        this._markIsPermitted(\"workspaces\", effectiveUserId, \"open\", threshold),\n        \"is_permitted\",\n      );\n    }\n    return query;\n  }\n\n  private _orgs(manager?: EntityManager) {\n    let query = (manager || this._connection).createQueryBuilder()\n      .select(\"orgs\")\n      .from(Organization, \"orgs\");\n\n    if (!getPersonalOrgsEnabled()) {\n      query = query.where(\"orgs.owner_id is NULL\");\n    }\n\n    return query;\n  }\n\n  // Adds a where clause to filter orgs by domain or id.\n  // If org is null, filter for user's personal org.\n  // if includeSupport is true, include the org of the support@ user (for the Samples workspace)\n  private _whereOrg<T extends WhereExpressionBuilder>(qb: T, org: string | number, includeSupport = false): T {\n    if (this.isMergedOrg(org)) {\n      // Select from universe of personal orgs.\n      // Don't panic though!  While this means that SQL can't use an organization id\n      // to narrow down queries, it will still be filtering via joins against the user and\n      // groups the user belongs to.\n      qb = qb.andWhere(\"orgs.owner_id is not null\");\n      return qb;\n    }\n    // Always include the org of the support@ user, which contains the Samples workspace,\n    // which we always show. (For isMergedOrg case, it's already included.)\n    if (includeSupport) {\n      const supportId = this._usersManager.getSpecialUserId(SUPPORT_EMAIL);\n      return qb.andWhere(new Brackets(q =>\n        this._wherePlainOrg(q, org).orWhere(\"orgs.owner_id = :supportId\", { supportId })));\n    } else {\n      return this._wherePlainOrg(qb, org);\n    }\n  }\n\n  private _wherePlainOrg<T extends WhereExpressionBuilder>(qb: T, org: string | number): T {\n    if (typeof org === \"number\") {\n      return qb.andWhere(\"orgs.id = :org\", { org });\n    }\n    if (org.startsWith(`docs-${this._idPrefix}`)) {\n      // this is someone's personal org\n      const ownerId = org.split(`docs-${this._idPrefix}`)[1];\n      qb = qb.andWhere(\"orgs.owner_id = :ownerId\", { ownerId });\n    } else if (org.startsWith(`o-${this._idPrefix}`)) {\n      // this is an org identified by org id\n      const orgId = org.split(`o-${this._idPrefix}`)[1];\n      qb = qb.andWhere(\"orgs.id = :orgId\", { orgId });\n    } else {\n      // this is a regular domain\n      qb = qb.andWhere(\"orgs.domain = :org\", { org });\n    }\n    return qb;\n  }\n\n  private _withAccess(qb: SelectQueryBuilder<any>, users: AvailableUsers,\n    table: \"orgs\" | \"workspaces\" | \"docs\",\n    accessStyle: AccessStyle = \"open\",\n    variableNamePrefix?: string) {\n    return qb\n      .addSelect(this._markIsPermitted(table, users, accessStyle, null, variableNamePrefix), `${table}_permissions`);\n  }\n\n  /**\n   * Filter for orgs for which the user is a member of a group (or which are shared\n   * with \"everyone@\").  For access to workspaces and docs, we rely on the fact that\n   * the user will be added to a guest group at the organization level.\n   *\n   * If AvailableUsers is a profile list, we do NOT include orgs accessible\n   * via \"everyone@\" (this affects the \"api/session/access/all\" endpoint).\n   *\n   * Otherwise, orgs shared with \"everyone@\" are candidates for inclusion.\n   * If an orgKey is supplied, it is the only org which will be considered\n   * for inclusion on the basis of sharing with \"everyone@\".  TODO: consider\n   * whether this wrinkle is needed anymore, or can be safely removed.\n   */\n  private _filterByOrgGroups(qb: SelectQueryBuilder<Organization>, users: AvailableUsers,\n    orgKey: string | number | null,\n    options?: { ignoreEveryoneShares?: boolean }) {\n    qb = qb\n      .leftJoin(\"orgs.aclRules\", \"acl_rules\")\n      .leftJoin(\"acl_rules.group\", \"groups\")\n      .leftJoin(\"groups.memberUsers\", \"members\");\n    if (UsersManager.isSingleUser(users)) {\n      // Add an exception for the previewer user, if present.\n      const previewerId = this._usersManager.getSpecialUserId(PREVIEWER_EMAIL);\n      if (users === previewerId) { return qb; }\n      const everyoneId = this._usersManager.getSpecialUserId(EVERYONE_EMAIL);\n      if (options?.ignoreEveryoneShares) {\n        return qb.where(\"members.id = :userId\", { userId: users });\n      }\n      return qb.andWhere(new Brackets((cond) => {\n        // Accept direct membership, or via a share with \"everyone@\".\n        return cond\n          .where(\"members.id = :userId\", { userId: users })\n          .orWhere(new Brackets((everyoneCond) => {\n            const everyoneQuery = everyoneCond.where(\"members.id = :everyoneId\", { everyoneId });\n            return (orgKey !== null) ? this._whereOrg(everyoneQuery, orgKey) : everyoneQuery;\n          }));\n      }));\n    }\n\n    // The user hasn't been narrowed down to one choice, so join against logins and\n    // check normalized email.\n    const emails = new Set(users.map(profile => normalizeEmail(profile.email)));\n    // Empty list needs to be special-cased since \"in ()\" isn't supported in postgres.\n    if (emails.size === 0) { return qb.andWhere(\"1 = 0\"); }\n    return qb\n      .leftJoin(\"members.logins\", \"memberLogins\")\n      .andWhere(\"memberLogins.email in (:...emails)\", { emails: [...emails] });\n  }\n\n  private _single(result: QueryResult<any>) {\n    if (result.status === 200) {\n      // TODO: assert result is really singular.\n      result.data = result.data[0];\n    }\n    return result;\n  }\n\n  /**\n   * Helper for adjusting acl inheritance rules. Given an array of top-level groups from the\n   * resource of interest, and an array of inherited groups belonging to the parent resource,\n   * moves the inherited groups to the group with the destination name or lower, if their\n   * permission level is lower. If the destination group name is omitted, the groups are\n   * moved to their original inheritance locations. If the destination group name is null,\n   * the groups are all removed and there is no access inheritance to this resource.\n   * Returns the updated array of top-level groups. These returned groups should be saved\n   * to update the group inheritance in the database.\n   *\n   * For all passed-in groups, their .memberGroups will be reset. For\n   * the basic roles (owner | editor | viewer), these will get updated\n   * to include inheritedGroups, with roles reduced to dest when dest\n   * is given. All of the basic roles must be present among\n   * groups. Any non-basic roles present among inheritedGroups will be\n   * ignored.\n   *\n   * Does not modify inheritedGroups.\n   */\n  private _moveInheritedGroups(\n    groups: NonGuestGroup[], inheritedGroups: Group[], dest?: roles.BasicRole | null,\n  ): void {\n    // Limit scope to those inheritedGroups that have basic roles (viewers, editors, owners).\n    inheritedGroups = inheritedGroups.filter(group => roles.isBasicRole(group.name));\n\n    // NOTE that the special names constant is ordered from least to most permissive.\n    const reverseDefaultNames = this.defaultBasicGroupNames.reverse();\n\n    // The destination must be a reserved inheritance group or null.\n    if (dest && !reverseDefaultNames.includes(dest)) {\n      throw new Error(\"moveInheritedGroups called with invalid destination name\");\n    }\n\n    // Mapping from group names to top-level groups\n    const topGroups: { [groupName: string]: NonGuestGroup } = {};\n    groups.forEach((grp) => {\n      // Note that this has a side effect of initializing the memberGroups arrays.\n      grp.memberGroups = [];\n      topGroups[grp.name] = grp;\n    });\n\n    // The destFunc maps from an inherited group to its required top-level group name.\n    const destFunc = (inherited: Group) =>\n      dest === null ? null : reverseDefaultNames.find(sp => sp === inherited.name || sp === dest);\n\n    // Place inherited groups (this has the side-effect of updating member groups)\n    inheritedGroups.forEach((grp) => {\n      if (!roles.isBasicRole(grp.name)) {\n        // We filtered out such groups at the start of this method, but just in case...\n        throw new Error(`${grp.name} is not an inheritable group`);\n      }\n      const moveTo = destFunc(grp);\n      if (moveTo) {\n        topGroups[moveTo].memberGroups.push(grp);\n      }\n    });\n  }\n\n  // Return a QueryResult reflecting the output of a query builder.\n  // If a rawQueryBuilder is supplied, it is used to make the query,\n  // but then the original queryBuilder is used to interpret the results\n  // as entities (make sure the two queries give results in the same format!)\n  // Checks on all \"permissions\" fields which select queries set on\n  // resources to indicate whether the user has access.\n  // If the output is empty, and `emptyAllowed` is not set, we signal that the desired\n  // resource does not exist (404).\n  // If the overall permissions do not allow viewing, we signal that the resource is forbidden.\n  // Access fields are added to all entities giving the group name corresponding\n  // with the access level of the user.\n  // Returns the resource fetched by the queryBuilder.\n  private async _verifyAclPermissions<T extends Resource>(\n    queryBuilder: SelectQueryBuilder<T>,\n    options: {\n      rawQueryBuilder?: SelectQueryBuilder<any>,\n      emptyAllowed?: boolean,\n      scope?: Scope,\n      // If permissions have been marked, check them\n      markedPermissions?: boolean,\n      // Requires having `users_disabled_at` in the query result\n      checkDisabledUser?: boolean,\n    } = {},\n  ): Promise<QueryResult<T[]>> {\n    prepareStatementIfPossible(options.rawQueryBuilder ?? queryBuilder);\n    const results = await (options.rawQueryBuilder ?\n      getRawAndEntities(options.rawQueryBuilder, queryBuilder) :\n      queryBuilder.getRawAndEntities());\n\n    if (options.checkDisabledUser) {\n      if (results.raw.some(entry => entry.users_disabled_at === undefined)) {\n        throw new Error(\"checkDisabledUser requested but users_disabled_at is undefined\");\n      }\n\n      // Disabled users shouldn't be able to even log in, but if they\n      // got this far (for example they have an existing websocket\n      // connexion), they shouldn't be able to have any document\n      // access.\n      if (results.raw.some(entry => entry.users_disabled_at !== null)) {\n        return {\n          status: 403,\n          errMessage: \"access denied\",\n        };\n      }\n    }\n    if (options.markedPermissions) {\n      if (!results.raw.every(entry => entry.is_permitted)) {\n        return {\n          status: 403,\n          errMessage: \"access denied\",\n        };\n      }\n    }\n    if (results.entities.length === 0 ||\n      (results.entities.length === 1 && results.entities[0].filteredOut)) {\n      if (options.emptyAllowed) { return { status: 200, data: [] }; }\n      return { errMessage: `${getFrom(queryBuilder)} not found`, status: 404 };\n    }\n    const resources = this._normalizeQueryResults(results.entities, {\n      scope: options.scope,\n    });\n    if (resources.length === 0 && !options.emptyAllowed) {\n      return { errMessage: \"access denied\", status: 403 };\n    } else {\n      return {\n        status: 200,\n        data: resources,\n      };\n    }\n  }\n\n  // Normalize query results in the following ways:\n  //   * Convert `permissions` fields to summary `access` fields.\n  //   * Set appropriate `domain` fields for personal organizations.\n  //   * Include `billingAccount` field only for a billing account manager.\n  //   * Replace `user.logins` objects with user.email and user.anonymous.\n  //   * Collapse fields from nested `manager.user` objects into the surrounding\n  //     `manager` objects.\n  //\n  // Find any nested entities with a \"permissions\" field, and add to them an\n  // \"access\" field (if the permission is a simple number) or an \"accessOptions\"\n  // field (if the permission is json).  Entities in a list that the user doesn't\n  // have the right to access may be removed.\n  //   * They are removed for workspaces in orgs.\n  //   * They are not removed for docs in workspaces, if user has right to delete\n  //     the workspace.\n  //\n  // When returning organizations, set the domain to docs-${userId} for personal orgs.\n  // We could also have simply stored that domain in the database, but have kept\n  // them out for now, for the flexibility to change how we want these kinds of orgs\n  // to be presented without having to do awkward migrations.\n  //\n  // The suppressDomain option ensures that any organization domains are given\n  // in ugly o-NNNN form.\n  private _normalizeQueryResults(value: any,\n    options: {\n      suppressDomain?: boolean,\n      scope?: Scope,\n      parentPermissions?: number,\n    } = {}): any {\n    // We only need to examine objects, excluding null.\n    if (typeof value !== \"object\" || value === null) { return value; }\n    // For arrays, add access information and remove anything user should not see.\n    if (Array.isArray(value)) {\n      const items = value.map(v => this._normalizeQueryResults(v, options));\n      // If the items are not workspaces, and the user can delete their parent, then\n      // ignore the user's access level when deciding whether to filter them out or\n      // to keep them.\n      const ignoreAccess = options.parentPermissions &&\n        (options.parentPermissions & Permissions.REMOVE) &&        items.length > 0 && !items[0].docs;\n      return items.filter(v => !this._isForbidden(v, Boolean(ignoreAccess), options.scope));\n    }\n    // For hashes, iterate through key/values, adding access info if 'permissions' field is found.\n    if (value.billingAccount) {\n      // This is an organization with billing account information available.  Check limits.\n      const org = value as Organization;\n      const features = org.billingAccount.getFeatures();\n      if (!features.vanityDomain) {\n        // Vanity domain not allowed for this org.\n        options = { ...options, suppressDomain: true };\n      }\n    }\n    const permissions = (typeof value.permissions === \"number\") ? value.permissions : undefined;\n    const childOptions = { ...options, parentPermissions: permissions };\n    for (const key of Object.keys(value)) {\n      const subValue = value[key];\n      // When returning organizations, set the domain to docs-${userId} for personal orgs.\n      // We could also have simply stored that domain in the database.  I'd prefer to keep\n      // them out for now, for the flexibility to change how we want these kinds of orgs\n      // to be presented without having to do awkward migrations.\n      if (key === \"domain\") {\n        value[key] = this.normalizeOrgDomain(value.id, subValue, value.owner?.id,\n          false, options.suppressDomain);\n        continue;\n      }\n      if (key === \"billingAccount\") {\n        if (value[key].managers) {\n          value[key].isManager = Boolean(value[key].managers.length);\n          delete value[key].managers;\n        }\n        continue;\n      }\n      if (key === \"logins\") {\n        const logins = subValue;\n        delete value[key];\n        if (logins.length !== 1) {\n          throw new ApiError(\"Cannot find unique login for user\", 500);\n        }\n        value.email = logins[0].displayEmail;\n        value.anonymous = (logins[0].userId === this._usersManager.getAnonymousUserId());\n        continue;\n      }\n      if (key === \"managers\") {\n        const managers = this._normalizeQueryResults(subValue, childOptions);\n        for (const manager of managers) {\n          if (manager.user) {\n            Object.assign(manager, manager.user);\n            delete manager.user;\n          }\n        }\n        value[key] = managers;\n        continue;\n      }\n      if (key === \"prefs\" && Array.isArray(subValue)) {\n        delete value[key];\n        const prefs = this._normalizeQueryResults(subValue, childOptions);\n        for (const pref of prefs) {\n          if (pref.orgId && pref.userId) {\n            value.userOrgPrefs = pref.prefs;\n          } else if (pref.orgId) {\n            value.orgPrefs = pref.prefs;\n          } else if (pref.userId) {\n            value.userPrefs = pref.prefs;\n          }\n        }\n        continue;\n      }\n      if (key !== \"permissions\") {\n        value[key] = this._normalizeQueryResults(subValue, childOptions);\n        continue;\n      }\n      if (typeof subValue === \"number\" || !subValue) {\n        // Find the first special group for which the user has all permissions.\n        value.access = this._groupsManager.getRoleFromPermissions(subValue || 0);\n        if (subValue & Permissions.PUBLIC) {\n          value.public = true;\n        }\n      } else {\n        // Resource may be accessed by multiple users, encoded in JSON.\n        const accessOptions: AccessOption[] = readJson(this._dbType, subValue);\n        value.accessOptions = accessOptions.map(option => ({\n          access: this._groupsManager.getRoleFromPermissions(option.perms), ...option,\n        }));\n      }\n      delete value.permissions;  // permissions is not specified in the api, so we drop it.\n    }\n    return value;\n  }\n\n  // entity is forbidden if it contains an access field set to null, or an accessOptions field\n  // that is the empty list.\n  private _isForbidden(entity: any, ignoreAccess: boolean, scope?: Scope): boolean {\n    if (!entity) { return false; }\n    if (entity.filteredOut) { return true; }\n    // Specifically for workspaces (as determined by having a \"docs\" field):\n    // if showing trash, and the workspace looks empty, and the workspace is itself\n    // not marked as trash, then filter it out.  This situation can arise when there is\n    // a trash doc in a workspace that the user does not have access to, and also a\n    // doc that the user does have access to.\n    if (entity.docs && scope?.showRemoved && entity.docs.length === 0 &&\n      !entity.removedAt)  { return true; }\n    if (ignoreAccess) { return false; }\n    if (entity.access === null) { return true; }\n    if (!entity.accessOptions) { return false; }\n    return entity.accessOptions.length === 0;\n  }\n\n  /**\n   * Return a query builder to check if we have access to the given resource.\n   * Tests the given permission-level access, defaulting to view permission.\n   * @param resType: type of resource (table name)\n   * @param userId: id of user accessing the resource\n   * @param permissions: permission to test for - if null, we return the permissions\n   */\n  private _markIsPermitted(\n    resType: \"orgs\" | \"workspaces\" | \"docs\",\n    users: AvailableUsers,\n    accessStyle: AccessStyle,\n    permissions: Permissions | null = Permissions.VIEW,\n    variableNamePrefix?: string,\n  ): (qb: SelectQueryBuilder<any>) => SelectQueryBuilder<any> {\n    const idColumn = resType.slice(0, -1) + \"_id\";\n    return (qb) => {\n      const getBasicPermissions = (q: SelectQueryBuilder<any>) => {\n        if (permissions !== null) {\n          q = q.select(\"acl_rules.permissions\");\n        } else {\n          const everyoneId = this._usersManager.getSpecialUserId(EVERYONE_EMAIL);\n          const anonId = this._usersManager.getSpecialUserId(ANONYMOUS_USER_EMAIL);\n          // Overall permissions are the bitwise-or of all individual\n          // permissions from ACL rules.  We also include\n          // Permissions.PUBLIC if any of the ACL rules are for the\n          // public (shared with everyone@ or anon@).  This could be\n          // optimized if we eliminate one of those users.  The guN\n          // aliases are joining in _getUsersAcls, and refer to the\n          // group_users table at different levels of nesting.\n\n          // When listing, everyone@ shares do not contribute to access permissions,\n          // only to the public flag.  So resources available to the user only because\n          // they are publically available will not be listed.  Shares with anon@,\n          // on the other hand, *are* listed.\n\n          // At this point, we have user ids available for a group associated with the acl\n          // rule, or a subgroup of that group, of a subgroup of that group, or a subgroup\n          // of that group (this is enough nesting to support docs in workspaces in orgs,\n          // with one level of nesting held for future use).\n          const userIdCols = [\"gu0.user_id\", \"gu1.user_id\", \"gu2.user_id\", \"gu3.user_id\"];\n\n          // If any of the user ids is public (everyone@, anon@), we set the PUBLIC flag.\n          // This is only advisory, for display in the client - it plays no role in access\n          // control.\n          const publicFlagSql = `case when ` +\n            hasAtLeastOneOfTheseIds(this._dbType, [everyoneId, anonId], userIdCols) +\n            ` then ${Permissions.PUBLIC} else 0 end`;\n\n          // The contribution made by the acl rule to overall user permission is contained\n          // in acl_rules.permissions. BUT if we are listing resources, we discount the\n          // permission contribution if it is only made with everyone@, and not anon@\n          // or any of the ids associated with the user. The resource may end up being\n          // accessible but unlisted for this user.\n          const contributionSql = accessStyle !== \"list\" ? \"acl_rules.permissions\" :\n            `case when ` +\n            hasOnlyTheseIdsOrNull(this._dbType, [everyoneId], userIdCols) +\n            ` then 0 else acl_rules.permissions end`;\n\n          // Finally, if all users are null, the resource is being viewed by the special\n          // previewer user.\n          const previewerSql = `case when coalesce(${userIdCols.join(\",\")}) is null` +\n            ` then acl_rules.permissions else 0 end`;\n\n          q = q.select(\n            bitOr(this._dbType, `(${publicFlagSql} | ${contributionSql} | ${previewerSql})`, 8),\n            \"permissions\",\n          );\n        }\n        q = q.from(\"acl_rules\", \"acl_rules\");\n        q = this._getUsersAcls(q, users, accessStyle, variableNamePrefix);\n        q = q.andWhere(`acl_rules.${idColumn} = ${resType}.id`);\n        if (permissions !== null) {\n          q = q.andWhere(`(acl_rules.permissions & :permissions) = :permissions`, { permissions }).limit(1);\n        } else if (!UsersManager.isSingleUser(users)) {\n          q = q.addSelect(\"profiles.id\");\n          q = q.addSelect(\"profiles.display_email\");\n          q = q.addSelect(\"profiles.name\");\n          // anything we select without aggregating, we must also group by (postgres is fussy\n          // about this)\n          q = q.groupBy(\"profiles.id\");\n          q = q.addGroupBy(\"profiles.display_email\");\n          q = q.addGroupBy(\"profiles.name\");\n        }\n        return q;\n      };\n      if (UsersManager.isSingleUser(users)) {\n        return getBasicPermissions(qb.subQuery());\n      } else {\n        return qb.subQuery()\n          .from(subQb => getBasicPermissions(subQb.subQuery()), \"options\")\n          .select(this._aggJsonObject({ id: \"options.id\",\n            email: \"options.display_email\",\n            perms: \"options.permissions\",\n            name: \"options.name\" }));\n      }\n    };\n  }\n\n  // Takes a query that includes acl_rules, and filters for just those acl_rules that apply\n  // to the user, either directly or via up to three layers of nested groups.  Two layers are\n  // sufficient for our current ACL setup.  A third is added as a low-cost preparation\n  // for implementing something like teams in the future.  It has no measurable effect on\n  // speed.\n  private _getUsersAcls(qb: SelectQueryBuilder<any>, users: AvailableUsers,\n    accessStyle: AccessStyle, variableNamePrefix: string = \"acls\") {\n    // Every acl_rule is associated with a single group.  A user may\n    // be a direct member of that group, via the group_users table.\n    // Or they may be a member of a group that is a member of that\n    // group, via group_groups.  Or they may be even more steps\n    // removed.  We unroll to a fixed number of steps, and use joins\n    // rather than a recursive query, since we need this step to be as\n    // fast as possible.\n    const userIdVariable = `${variableNamePrefix}UserId`;\n    const permissionsVariable = `${variableNamePrefix}Permissions`;\n    qb = qb\n      // filter for the specified user being a direct or indirect member of the acl_rule's group\n      .where(new Brackets((cond) => {\n        if (UsersManager.isSingleUser(users)) {\n          // Users is an integer, so ok to insert into sql.  It we\n          // didn't, we'd need to use distinct parameter names, since\n          // we may include this code with different user ids in the\n          // same query\n          cond = cond.where(`:${userIdVariable} IN (gu0.user_id, gu1.user_id, gu2.user_id, gu3.user_id)`,\n            { [userIdVariable]: users });\n          // Support public access via the special \"everyone\" user, except for 'openStrict' mode.\n          if (accessStyle !== \"openNoPublic\") {\n            const everyoneId = this._usersManager.getEveryoneUserId();\n            cond = cond.orWhere(`${everyoneId} IN (gu0.user_id, gu1.user_id, gu2.user_id, gu3.user_id)`);\n          }\n          if (accessStyle === \"list\") {\n            // Support also the special anonymous user.  Currently, by convention, sharing a\n            // resource with anonymous should make it listable.\n            const anonId = this._usersManager.getAnonymousUserId();\n            cond = cond.orWhere(`${anonId} IN (gu0.user_id, gu1.user_id, gu2.user_id, gu3.user_id)`);\n          }\n\n          // Add an exception for the previewer user, if present.\n          const previewerId = this._usersManager.getSpecialUserId(PREVIEWER_EMAIL);\n          if (users === previewerId) {\n            // All acl_rules granting view access are available to previewer user.\n            cond = cond.orWhere(`acl_rules.permissions = :${permissionsVariable}`,\n              { [permissionsVariable]: Permissions.VIEW });\n          }\n        } else {\n          cond = cond.where(`profiles.id IN (gu0.user_id, gu1.user_id, gu2.user_id, gu3.user_id)`);\n        }\n        return cond;\n      }));\n    if (!UsersManager.isSingleUser(users)) {\n      // We need to join against a list of users.\n      const emails = new Set(users.map(profile => normalizeEmail(profile.email)));\n      if (emails.size > 0) {\n        // the 1 = 1 on clause seems the shortest portable way to do a cross join in postgres\n        // and sqlite via typeorm.\n        qb = qb.leftJoin(\"(select users.id, display_email, email, name from users inner join logins \" +\n          \"on users.id = logins.user_id where logins.email in (:...emails))\",\n        \"profiles\", \"1 = 1\");\n        qb = qb.setParameter(\"emails\", [...emails]);\n      } else {\n        // Add a dummy user with id 0, for simplicity.  This user will\n        // not match any group.  The casts are needed for a postgres 9.5 issue\n        // where type inference fails (we use 9.5 on jenkins).\n        qb = qb.leftJoin(`(select 0 as id, cast('none' as text) as display_email, ` +\n          `cast('none' as text) as email, cast('none' as text) as name)`,\n        \"profiles\", \"1 = 1\");\n      }\n    }\n    // join the relevant groups and subgroups\n    return this._joinToAllGroupUsers(qb);\n  }\n\n  private async _getDocsInheritingFrom(manager: EntityManager, options: { orgId: number } | { wsId: number }) {\n    const queryBuilder = manager.createQueryBuilder()\n      .from(Document, \"docs\")\n      .leftJoinAndSelect(\"docs.aclRules\", \"acl_rules\")\n      .leftJoin(\"group_groups\", \"gg1\", \"gg1.group_id = acl_rules.group_id\")\n      .leftJoin(\"group_groups\", \"gg2\", \"gg2.group_id = gg1.subgroup_id\")\n      .leftJoin(\"group_groups\", \"gg3\", \"gg3.group_id = gg2.subgroup_id\")\n      .innerJoin(\"acl_rules\", \"rules\", \"rules.group_id in (gg1.subgroup_id, gg2.subgroup_id, gg3.subgroup_id)\")\n      .chain(qb => (\n        \"orgId\" in options ? qb.where(\"rules.org_id = :orgId\", { orgId: options.orgId }) :\n          \"wsId\" in options ? qb.where(\"rules.workspace_id = :wsId\", { wsId: options.wsId }) :\n            qb\n      ))\n      .select(\"docs.id\", \"docId\")\n      .distinct(true);\n    const result = await queryBuilder.getRawMany();\n    return result.map(r => r.docId);\n  }\n\n  // Takes a query that includes 'acl_rules' and joins it to all group_users records that are\n  // connected to it directly or via subgroups.\n  // Public for limited use by extensions of HomeDBManager in some flavors of Grist.\n  // eslint-disable-next-line @typescript-eslint/member-ordering\n  public _joinToAllGroupUsers<T extends ObjectLiteral>(qb: SelectQueryBuilder<T>): SelectQueryBuilder<T> {\n    return qb\n      .leftJoin(\"group_groups\", \"gg1\", \"gg1.group_id = acl_rules.group_id\")\n      .leftJoin(\"group_groups\", \"gg2\", \"gg2.group_id = gg1.subgroup_id\")\n      .leftJoin(\"group_groups\", \"gg3\", \"gg3.group_id = gg2.subgroup_id\")\n      // join the users in the relevant groups and subgroups.\n      .leftJoin(\"group_users\", \"gu3\", \"gg3.subgroup_id = gu3.group_id\")\n      .leftJoin(\"group_users\", \"gu2\", \"gg2.subgroup_id = gu2.group_id\")\n      .leftJoin(\"group_users\", \"gu1\", \"gg1.subgroup_id = gu1.group_id\")\n      .leftJoin(\"group_users\", \"gu0\", \"acl_rules.group_id = gu0.group_id\");\n  }\n\n  // Apply limits to the query.  Results should be limited to a specific org\n  // if request is from a branded webpage; results should be limited to a\n  // specific user or set of users.\n  private _applyLimit<T extends ObjectLiteral>(qb: SelectQueryBuilder<T>, limit: Scope,\n    resources: (\"docs\" | \"workspaces\" | \"orgs\")[],\n    accessStyle: AccessStyle): SelectQueryBuilder<T> {\n    if (limit.org) {\n      // Filtering on merged org is a special case, see urlIdQuery\n      const mergedOrg = this.isMergedOrg(limit.org || null);\n      if (!mergedOrg) {\n        qb = this._whereOrg(qb, limit.org, limit.includeSupport || false);\n      }\n    }\n    if (limit.users || limit.userId) {\n      for (const res of resources) {\n        qb = this._withAccess(qb, limit.users || limit.userId, res, accessStyle, \"limit\");\n      }\n    }\n    if (resources.includes(\"docs\") && resources.includes(\"workspaces\") && !limit.showAll) {\n      // Add Workspace.filteredOut column that is set for workspaces that should be filtered out.\n      // We don't use a WHERE clause directly since this would leave us unable to distinguish\n      // an empty result from insufficient access; and there's no straightforward way to do\n      // what we want in an ON clause.\n      // Filter out workspaces only if there are no docs in them (The \"ON\" clause from\n      // _onDocs will have taken care of including the right docs).  If there are docs,\n      // then include the workspace regardless of whether it itself has been soft-deleted\n      // or not.\n      // TODO: if getOrgWorkspaces and getWorkspace were restructured to make two queries\n      // rather than a single query, this trickiness could be eliminated.\n      if (limit.showRemoved) {\n        qb = qb.addSelect(\"docs.id IS NULL AND workspaces.removed_at IS NULL\",\n          \"workspaces_filtered_out\");\n      } else {\n        qb = qb.addSelect(\"docs.id IS NULL AND workspaces.removed_at IS NOT NULL\",\n          \"workspaces_filtered_out\");\n      }\n    }\n    return qb;\n  }\n\n  // Filter out all personal orgs, and add back in a single merged org.\n  private _mergePersonalOrgs(userId: number, orgs: Organization[]): Organization[] {\n    const regularOrgs = orgs.filter(org => org.owner === null);\n    const personalOrg = orgs.find(org => org.owner?.id === userId);\n    if (!personalOrg) { return regularOrgs; }\n    personalOrg.id = 0;\n    personalOrg.domain = this.mergedOrgDomain();\n    return [personalOrg].concat(regularOrgs);\n  }\n\n  // Check if shares are about to exceed a limit, and emit a meaningful\n  // ApiError if so.\n  // If checkChange is set, issue an error only if a new share is being\n  // made.\n  private _restrictShares(role: roles.NonGuestRole | null, limit: number,\n    before: User[], after: User[], checkChange: boolean, kind: string,\n    features: Features) {\n    const existingUserIds = new Set(before.map(user => user.id));\n    // Do not emit error if users are not added, even if the number is past the limit.\n    if (after.length > limit &&\n      (!checkChange || after.some(user => !existingUserIds.has(user.id)))) {\n      const more = limit > 0 ? \" more\" : \"\";\n      throw new ApiError(\n        checkChange ? `No${more} external ${kind} ${role || \"shares\"} permitted` :\n          `Too many external ${kind} ${role || \"shares\"}`,\n        403, {\n          limit: {\n            quantity: \"collaborators\",\n            subquantity: role || undefined,\n            maximum: limit,\n            value: before.length,\n            projectedValue: after.length,\n          },\n          tips: canAddOrgMembers(features) ? [{\n            action: \"add-members\",\n            message: \"add users as team members to the site first\",\n          }] : [{\n            action: \"upgrade\",\n            message: \"pay for more team members\",\n          }],\n        });\n    }\n  }\n\n  // Check if document shares exceed any of the share limits, and emit a meaningful\n  // ApiError if so.  If both membersBefore and membersAfter are specified, fail\n  // only if a new share is being added, but otherwise don't complain even if limits\n  // are exceeded.  If only membersBefore is specified, fail strictly if limits are\n  // exceeded.\n  private _restrictAllDocShares(features: Features,\n    nonOrgMembersBefore: Map<roles.NonGuestRole, User[]>,\n    nonOrgMembersAfter: Map<roles.NonGuestRole, User[]>,\n    checkChange: boolean = true) {\n    // Apply a limit to document shares that is not specific to a particular role.\n    if (features.maxSharesPerDoc !== undefined) {\n      this._restrictShares(null, features.maxSharesPerDoc, removeRole(nonOrgMembersBefore),\n        removeRole(nonOrgMembersAfter), checkChange, \"document\", features);\n    }\n    if (features.maxSharesPerDocPerRole) {\n      for (const role of this.defaultBasicGroupNames) {\n        const limit = features.maxSharesPerDocPerRole[role];\n        if (limit === undefined) { continue; }\n        // Apply a per-role limit to document shares.\n        this._restrictShares(role, limit, nonOrgMembersBefore.get(role) || [],\n          nonOrgMembersAfter.get(role) || [], checkChange, \"document\", features);\n      }\n    }\n  }\n\n  // Throw an error if there's no room for adding another document.\n  private async _checkRoomForAnotherDoc(workspace: Workspace, manager: EntityManager) {\n    const features = workspace.org.billingAccount.getFeatures();\n    if (features.maxDocsPerOrg !== undefined) {\n      // we need to count how many docs are in the current org, and if we\n      // are already at or above the limit, then fail.\n      const wss = this.unwrapQueryResult(\n        await this.getOrgWorkspaces({ userId: this._usersManager.getPreviewerUserId() }, workspace.org.id, { manager }),\n      );\n      const count = wss.map(ws => ws.docs.length).reduce((a, b) => a + b, 0);\n      if (count >= features.maxDocsPerOrg) {\n        throw new ApiError(\"No more documents permitted\", 403, {\n          limit: {\n            quantity: \"docs\",\n            maximum: features.maxDocsPerOrg,\n            value: count,\n            projectedValue: count + 1,\n          },\n        });\n      }\n    }\n  }\n\n  // For the moment only the support user can add both everyone@ and anon@ to a\n  // resource, since that allows spam.  TODO: enhance or remove.\n  private _checkUserChangeAllowed(userId: number, groups: Group[]) {\n    return this._usersManager.checkUserChangeAllowed(userId, groups);\n  }\n\n  // Fetch a Document with all access information loaded.  Make sure the user has the\n  // specified permissions on the doc.  The Document's organization will have product\n  // feature information loaded also.\n  private async _loadDocAccess(scope: DocScope, markPermissions: Permissions,\n    transaction?: EntityManager): Promise<Document> {\n    return await this.runInTransaction(transaction, async (manager) => {\n      const docQuery = this._doc(scope, { manager, markPermissions });\n      const queryResult = await verifyEntity(docQuery);\n      this.checkQueryResult(queryResult);\n      const doc = getDocResult(queryResult);\n\n      // Retrieve the doc's ACL rules and groups/users so we can edit them.\n      // We do this as a separate query to avoid repeating the document\n      // row (which can be particulary costly since the main document\n      // query contains some non-trivial subqueries and postgres\n      // will re-execute them for each repeated document row).\n      const aclQuery = this._docs(manager)\n        .where({ id: doc.id })\n        .leftJoinAndSelect(\"docs.aclRules\", \"acl_rules\")\n        .leftJoinAndSelect(\"acl_rules.group\", \"doc_groups\")\n        .leftJoinAndSelect(\"doc_groups.memberUsers\", \"doc_group_users\")\n        .leftJoinAndSelect(\"doc_groups.memberGroups\", \"doc_group_groups\")\n        .leftJoinAndSelect(\"doc_group_users.logins\", \"doc_user_logins\");\n      const aclDoc: Document = (await aclQuery.getOne())!;\n      doc.aclRules = aclDoc.aclRules;\n      doc.workspace = await this._loadWorkspaceAccess(scope, doc.workspace.id, { transaction: manager });\n      return doc;\n    });\n  }\n\n  private async _loadWorkspaceAccess(\n    scope: DocScope, wsId: number, options: { markPermissions?: Permissions, transaction?: EntityManager } = {},\n  ) {\n    const { markPermissions, transaction } = options;\n    return await this.runInTransaction(transaction, async (manager) => {\n      // Load the workspace's member groups/users.\n      const workspaceQuery = this._workspace(scope, wsId, { manager, markPermissions })\n        .leftJoinAndSelect(\"workspaces.aclRules\", \"workspace_acl_rules\")\n        .leftJoinAndSelect(\"workspace_acl_rules.group\", \"workspace_groups\")\n        .leftJoinAndSelect(\"workspace_groups.memberUsers\", \"workspace_group_users\")\n        .leftJoinAndSelect(\"workspace_groups.memberGroups\", \"workspace_group_groups\")\n        .leftJoinAndSelect(\"workspace_group_users.logins\", \"workspace_user_logins\")\n      // We'll need the org as well. We will join its members as a separate query, since\n      // SQL results are flattened, and multiplying the number of rows we have already\n      // by the number of org users could get excessive.\n        .leftJoinAndSelect(\"workspaces.org\", \"org\");\n      const wsQueryResult = await verifyEntity(workspaceQuery, { skipPermissionCheck: !markPermissions });\n      if (wsQueryResult.status !== 200) {\n        throw new ApiError(wsQueryResult.errMessage!, wsQueryResult.status);\n      }\n      const workspace: Workspace = wsQueryResult.data;\n\n      // Load the org's member groups/users.\n      let orgQuery = this.org(scope, workspace.org.id, { manager })\n        .leftJoinAndSelect(\"orgs.aclRules\", \"org_acl_rules\")\n        .leftJoinAndSelect(\"org_acl_rules.group\", \"org_groups\")\n        .leftJoinAndSelect(\"org_groups.memberUsers\", \"org_group_users\")\n        .leftJoinAndSelect(\"org_group_users.logins\", \"org_user_logins\");\n      orgQuery = this._addFeatures(orgQuery);\n      workspace.org = (await orgQuery.getOne())!;\n      return workspace;\n    });\n  }\n\n  // Emit an event indicating that the count of users with access to the org has changed, with\n  // the customerId and the updated number of users.\n  // The org argument must include the billingAccount.\n  private _userChangeNotification(\n    userId: number,\n    org: Organization,       // Must include billingAccount\n    countBefore: number,\n    countAfter: number,\n    membersBefore: Map<roles.NonGuestRole, User[]>,\n    membersAfter: Map<roles.NonGuestRole, User[]>,\n  ) {\n    return async () => {\n      const customerId = org.billingAccount.stripeCustomerId;\n      const change: UserChange = { userId, org, customerId,\n        countBefore, countAfter,\n        membersBefore, membersAfter };\n      await this._notifier.userChange(change);\n    };\n  }\n\n  // Create a notification function that emits an event when users may have been added to a resource.\n  private _inviteNotification(userId: number, resource: Organization | Workspace | Document,\n    userIdDelta: UserIdDelta, membersBefore: Map<roles.NonGuestRole,\n      User[]>): () => Promise<void> {\n    return async () => {\n      await this._notifier.addUser(userId, resource, userIdDelta, membersBefore);\n    };\n  }\n\n  private _billingManagerNotification(userId: number, addUserId: number, orgs: Organization[]) {\n    return async () => {\n      await this._notifier.addBillingManager(userId, addUserId, orgs);\n    };\n  }\n\n  private _teamCreatorNotification(userId: number) {\n    return async () => {\n      await this._notifier.teamCreator(userId);\n    };\n  }\n\n  private _streamingDestinationsChange(orgId?: number) {\n    return async () => {\n      await this._notifier.streamingDestinationsChange(orgId || null);\n    };\n  }\n\n  // Set Workspace.removedAt to null (undeletion) or to a datetime (soft deletion)\n  private _setWorkspaceRemovedAt(scope: Scope, wsId: number, removedAt: Date | null) {\n    return this._connection.transaction(async (manager) => {\n      const wsQuery = this._workspace({ ...scope, showAll: true }, wsId, {\n        manager,\n        markPermissions: Permissions.REMOVE | Permissions.SCHEMA_EDIT,\n      })\n        .leftJoinAndSelect(\"workspaces.org\", \"orgs\");\n      const workspace: Workspace = this.unwrapQueryResult(await verifyEntity(wsQuery));\n      workspace.removedAt = removedAt;\n      await manager.createQueryBuilder()\n        .update(Workspace).set({ removedAt }).where({ id: workspace.id })\n        .execute();\n\n      // Update the guests in the org after soft-deleting/undeleting this workspace.\n      await this._repairOrgGuests(scope, workspace.org.id, manager);\n\n      return { status: 200, data: workspace };\n    });\n  }\n\n  // Set Document.removedAt to null (undeletion) or to a datetime (soft deletion)\n  private _setDocumentRemovedAt(scope: DocScope, removedAt: Date | null) {\n    return this._setDocumentDeletionProperty(scope, \"removedAt\", removedAt);\n  }\n\n  private _setDocumentDisabledAt(scope: DocScope, removedAt: Date | null) {\n    return this._setDocumentDeletionProperty(scope, \"disabledAt\", removedAt);\n  }\n\n  private _setDocumentDeletionProperty(scope: DocScope, property: \"removedAt\" | \"disabledAt\", value: Date | null) {\n    return this._connection.transaction(async (manager) => {\n      let docQuery = this._doc({ ...scope, showAll: true }, {\n        manager,\n        markPermissions: Permissions.SCHEMA_EDIT | Permissions.REMOVE,\n        allowSpecialPermit: true,\n      });\n      if (!value) {\n        docQuery = this._addFeatures(docQuery);  // pull in billing information for doc count limits\n      }\n      const doc: Document = this.unwrapQueryResult(await verifyEntity(docQuery));\n      if (!value) {\n        await this._checkRoomForAnotherDoc(doc.workspace, manager);\n      }\n      doc[property] = value;\n      await manager.createQueryBuilder()\n        .update(Document).set({ [property]: value }).where({ id: doc.id })\n        .execute();\n\n      // Update guests of the workspace and org after soft-deleting/undeleting this doc.\n      await this._repairWorkspaceGuests(scope, doc.workspace.id, manager);\n      await this._repairOrgGuests(scope, doc.workspace.org.id, manager);\n\n      return { status: 200, data: doc };\n    });\n  }\n\n  private _filterAccessData(\n    scope: Scope,\n    users: UserAccessData[],\n    maxInheritedRole: roles.BasicRole | null,\n    docId?: string,\n  ): { personal: true, public: boolean } | undefined {\n    if (scope.userId === this._usersManager.getPreviewerUserId()) { return; }\n\n    // If we have special access to the resource, don't filter user information.\n    if (scope.specialPermit?.docId === docId && docId) { return; }\n\n    const thisUser = this._usersManager.getAnonymousUserId() === scope.userId ?\n      null :\n      users.find(user => user.id === scope.userId);\n    const realAccess = thisUser ? getRealAccess(thisUser, { maxInheritedRole }) : null;\n\n    // If we are an owner, don't filter user information.\n    if (thisUser && realAccess === \"owners\") { return; }\n\n    // Limit user information returned to being about the current user.\n    users.length = 0;\n    if (thisUser) { users.push(thisUser); }\n    return { personal: true, public: !realAccess };\n  }\n\n  private _buildWorkspaceWithACLRules(scope: Scope, wsId: number, options: Partial<QueryOptions> = {}) {\n    return this._workspace(scope, wsId, {\n      ...options,\n    })\n    // Join the workspace's ACL rules (with 1st level groups/users listed).\n      .leftJoinAndSelect(\"workspaces.aclRules\", \"acl_rules\")\n      .leftJoinAndSelect(\"acl_rules.group\", \"workspace_groups\")\n      .leftJoinAndSelect(\"workspace_groups.memberUsers\", \"workspace_group_users\")\n      .leftJoinAndSelect(\"workspace_groups.memberGroups\", \"workspace_group_groups\")\n      .leftJoinAndSelect(\"workspace_group_users.logins\", \"workspace_user_logins\")\n      .leftJoinAndSelect(\"workspaces.org\", \"org\");\n  }\n\n  private _getWorkspaceWithACLRules(scope: Scope, wsId: number, options: Partial<QueryOptions> = {}) {\n    const query = this._buildWorkspaceWithACLRules(scope, wsId, {\n      markPermissions: Permissions.VIEW,\n      ...options,\n    });\n    return verifyEntity(query);\n  }\n\n  private _buildOrgWithACLRulesQuery(scope: Scope, org: number | string, opts: Partial<QueryOptions> = {}) {\n    return this.org(scope, org, {\n      needRealOrg: true,\n      ...opts,\n    })\n      // Join the org's ACL rules (with 1st level groups/users listed).\n      .leftJoinAndSelect(\"orgs.aclRules\", \"acl_rules\")\n      .leftJoinAndSelect(\"acl_rules.group\", \"org_groups\")\n      .leftJoinAndSelect(\"org_groups.memberUsers\", \"org_member_users\")\n      .leftJoinAndSelect(\"org_member_users.logins\", \"user_logins\");\n  }\n\n  private _getOrgWithACLRules(scope: Scope, org: number | string) {\n    const orgQuery = this._buildOrgWithACLRulesQuery(scope, org, {\n      markPermissions: Permissions.VIEW,\n      allowSpecialPermit: true,\n    });\n    return verifyEntity(orgQuery);\n  }\n}\n\n// Return a QueryResult reflecting the output of a query builder.\n// Checks on the \"is_permitted\" field which select queries set on resources to\n// indicate whether the user has access.\n//\n// If the output is empty, we signal that the desired resource does not exist.\n//\n// If we retrieve more than 1 entity, we signal that the request is ambiguous.\n//\n// If the \"is_permitted\" field is falsy, we signal that the resource is forbidden,\n// unless skipPermissionCheck is set.\n//\n// Returns the resource fetched by the queryBuilder.\nasync function verifyEntity(\n  queryBuilder: SelectQueryBuilder<any>,\n  options: { skipPermissionCheck?: boolean } = {},\n): Promise<QueryResult<any>> {\n  prepareStatementIfPossible(queryBuilder);\n  const results = await queryBuilder.getRawAndEntities();\n  if (results.entities.length === 0) {\n    return {\n      status: 404,\n      errMessage: `${getFrom(queryBuilder)} not found`,\n    };\n  } else if (results.entities.length > 1) {\n    return {\n      status: 400,\n      errMessage: `ambiguous ${getFrom(queryBuilder)} request`,\n    };\n  } else if (!options.skipPermissionCheck && !results.raw[0].is_permitted) {\n    return {\n      status: 403,\n      errMessage: \"access denied\",\n    };\n  }\n  return {\n    status: 200,\n    data: results.entities[0],\n  };\n}\n\n// Extract a human-readable name for the type of entity being selected.\nfunction getFrom(queryBuilder: SelectQueryBuilder<any>): string {\n  const alias = queryBuilder.expressionMap.mainAlias;\n  const name = (alias?.metadata?.name.toLowerCase()) || \"resource\";\n  if (name === \"filtereddocument\") { return \"document\"; }\n  return name;\n}\n\n// Flatten a map of users per role into a simple list of users.\nexport function removeRole(usersWithRoles: Map<roles.NonGuestRole, User[]>) {\n  return flatten([...usersWithRoles.values()]);\n}\n\nexport async function makeDocAuthResult(docPromise: Promise<Document>): Promise<DocAuthResult> {\n  try {\n    const doc = await docPromise;\n    const removed = Boolean(doc.removedAt || doc.workspace.removedAt);\n    const disabled = Boolean(doc.disabledAt);\n    return { docId: doc.id, access: doc.access, removed, disabled, cachedDoc: doc };\n  } catch (error) {\n    return { docId: null, access: null, removed: null, disabled: null, error };\n  }\n}\n\n/**\n * Extracts DocAuthKey information from scope.  This includes everything needed to\n * identify the document to access.  Throws if information is not present.\n */\nexport function getDocAuthKeyFromScope(scope: Scope): DocAuthKey {\n  const { urlId, userId, org } = scope;\n  if (!urlId) { throw new Error(\"document required\"); }\n  return { urlId, userId, org };\n}\n\n// Returns whether the given group is a valid non-guest group.\nfunction isNonGuestGroup(group: Group): group is NonGuestGroup {\n  return roles.isNonGuestRole(group.name);\n}\n\nfunction getNonGuestGroups(entity: Organization | Workspace | Document): NonGuestGroup[] {\n  return (entity.aclRules as AclRule[]).map(aclRule => aclRule.group).filter(isNonGuestGroup);\n}\n\nfunction getUserAccessChanges({\n  users,\n  userIdDelta,\n}: {\n  users: User[];\n  userIdDelta: UserIdDelta | null;\n}) {\n  if (\n    !userIdDelta ||\n    Object.keys(userIdDelta).length === 0 ||\n    users.length === 0\n  ) {\n    return undefined;\n  }\n\n  return users.map(user => ({\n    ...pick(user, \"id\", \"name\"),\n    email: user.loginEmail,\n    access: userIdDelta[user.id],\n  }));\n}\n\n/**\n * Extract a Document from a query result that is expected to\n * contain one. If it is a FilteredDocument, reset the prototype\n * to be Document - that class is just a tiny variant of Document\n * with a different alias for use with CTEs as a hack around\n * some TypeORM limitations.\n * CAUTION: this modifies material in the queryResult.\n */\nfunction getDocResult(queryResult: QueryResult<any>) {\n  const doc: Document = queryResult.data;\n  // The result may be a Document or a FilteredDocument,\n  // For our purposes they are the same.\n  if (Object.getPrototypeOf(doc) === FilteredDocument.prototype) {\n    Object.setPrototypeOf(doc, Document.prototype);\n  }\n  return doc;\n}\n\nfunction patch<T extends object>(obj: T, ...patches: Partial<T>[]): T {\n  return Object.assign(obj, ...patches);\n}\n\nexport function prepareStatementIfPossible(builder: SelectQueryBuilder<any>) {\n  if (Deps.usePreparedStatements) {\n    const sql = builder.getSql();\n    maybePrepareStatement(sql);\n  }\n  return builder;\n}\n"
  },
  {
    "path": "app/gen-server/lib/homedb/Interfaces.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { FullUser, UserProfile } from \"app/common/LoginSessionAPI\";\nimport * as roles from \"app/common/roles\";\nimport { UserOptions } from \"app/common/UserAPI\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { AccessOptionWithRole, Organization } from \"app/gen-server/entity/Organization\";\nimport { ServiceAccount } from \"app/gen-server/entity/ServiceAccount\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { GroupTypes } from \"app/gen-server/lib/homedb/GroupsManager\";\n\nimport { EntityManager } from \"typeorm\";\n\nexport interface QueryResult<T> {\n  status: number;\n  data?: T;\n  errMessage?: string;\n}\n\nexport interface PreviousAndCurrent<T> {\n  previous: T;\n  current: T;\n}\n\nexport interface GetUserOptions {\n  manager?: EntityManager;\n  profile?: UserProfile;\n  userOptions?: UserOptions;\n}\n\nexport interface UserProfileChange {\n  name?: string;\n  isFirstTimeUser?: boolean;\n  disabledAt?: Date | null;\n  options?: Partial<UserOptions>;\n}\n\n// A specification of the users available during a request.  This can be a single\n// user, identified by a user id, or a collection of profiles (typically drawn from\n// the session).\nexport type AvailableUsers = number | UserProfile[];\n\nexport type NonGuestGroup = Group & { name: roles.NonGuestRole };\n\nexport type Resource = Organization | Workspace | Document;\n\nexport type RunInTransaction = <T>(\n  transaction: EntityManager | undefined,\n  op: ((manager: EntityManager) => Promise<T>),\n) => Promise<T>;\n\nexport interface DocumentAccessChanges {\n  document: Document;\n  accessChanges: Partial<AccessChanges>;\n}\n\nexport interface WorkspaceAccessChanges {\n  workspace: Workspace;\n  accessChanges: Partial<Omit<AccessChanges, \"publicAccess\">>;\n\n}\n\nexport interface OrgAccessChanges {\n  org: Organization;\n  accessChanges: Omit<AccessChanges, \"publicAccess\" | \"maxInheritedAccess\">;\n}\n\nexport interface RoleGroupDescriptor {\n  readonly name: roles.Role;\n  readonly permissions: number;\n  readonly nestParent: boolean;\n  readonly orgOnly?: boolean;\n}\n\nexport interface GroupWithMembersDescriptor {\n  readonly type: GroupTypes;\n  readonly name: string;\n  readonly memberUsers?: number[];\n  readonly memberGroups?: number[];\n}\n\ninterface AccessChanges {\n  publicAccess: roles.NonGuestRole | null;\n  maxInheritedAccess: roles.BasicRole | null;\n  users: (Pick<User, \"id\" | \"name\"> & { email?: string } & {\n    access: roles.NonGuestRole | null;\n  })[];\n}\n\nexport type ServiceAccountProperties = Partial<Pick<ServiceAccount, \"label\" | \"description\" | \"expiresAt\">>;\n\n// Identifies a request to access a document. This combination of values is also used for caching\n// DocAuthResult for DOC_AUTH_CACHE_TTL.  Other request scope information is passed along.\nexport interface DocAuthKey {\n  urlId: string;              // May be docId. Must be unambiguous in the context of the org.\n  userId: number;             // The user accessing this doc. (Could be the ID of Anonymous.)\n  org?: string;               // Undefined if unknown (e.g. in API calls, but needs unique urlId).\n}\n\n// Document auth info. This is the minimum needed to resolve user access checks. For anything else\n// (e.g. doc title), the uncached getDoc() call should be used.\nexport interface DocAuthResult {\n  docId: string | null;         // The unique identifier of the document. Null on error.\n  access: roles.Role | null;    // The access level for the requesting user. Null on error.\n  removed: boolean | null;      // Set if the doc is soft-deleted. Users may still have access\n  // to removed documents for some purposes. Null on error.\n  disabled: boolean | null;     // Removes most user read access and all\n  // write access. Null on error.\n  error?: ApiError;\n  cachedDoc?: Document;       // For cases where stale info is ok.\n}\n\n// Defines a subset of HomeDBManager used for logins. In practice we still just pass around\n// the full HomeDBManager, but this makes it easier to know which of its methods matter.\nexport interface HomeDBAuth {\n  getAnonymousUserId(): number;\n  getSupportUserId(): number;\n  getAnonymousUser(): User;\n  getUser(userId: number, options?: { includePrefs?: boolean }): Promise<User | undefined>;\n  getUserByKey(apiKey: string): Promise<User | undefined>;\n  getUserByLogin(email: string, options?: GetUserOptions): Promise<User>;\n  getUserByLoginWithRetry(email: string, options?: GetUserOptions): Promise<User>;\n  getBestUserForOrg(users: AvailableUsers, org: number | string): Promise<AccessOptionWithRole | null>;\n  getServiceAccountByLoginWithOwner(login: string): Promise<ServiceAccount | null>;\n  makeFullUser(user: User): FullUser;\n}\n\n// Defines a subset of HomeDBManager needed for doc authorization. In practice we still just pass\n// around the full HomeDBManager, but this makes it easier to know which of its methods matter.\nexport interface HomeDBDocAuth {\n  getDocAuthCached(key: DocAuthKey): Promise<DocAuthResult>;\n  getAnonymousUserId(): number;\n}\n"
  },
  {
    "path": "app/gen-server/lib/homedb/ServiceAccountsManager.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { normalizeEmail } from \"app/common/emails\";\nimport { Login } from \"app/gen-server/entity/Login\";\nimport { ServiceAccount } from \"app/gen-server/entity/ServiceAccount\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { RunInTransaction, ServiceAccountProperties } from \"app/gen-server/lib/homedb/Interfaces\";\n\nimport { EntityManager } from \"typeorm\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nexport class ServiceAccountsManager {\n  private get _connection() {\n    return this._homeDb.connection;\n  }\n\n  public constructor(\n    private readonly _homeDb: HomeDBManager,\n    private _runInTransaction: RunInTransaction,\n  ) {}\n\n  // This method is implemented for test purpose only\n  // Using it outside of tests context will lead to partial db\n  // destruction\n  public async testDeleteAllServiceAccounts(optManager?: EntityManager) {\n    const manager = optManager || new EntityManager(this._connection);\n    const queryBuilder = manager.createQueryBuilder()\n      .delete()\n      .from(ServiceAccount, \"service_accounts\");\n    return await queryBuilder.execute();\n  }\n\n  /**\n   * Creates a service account.\n   *\n   * @param ownerId The user ID of the owner\n   * @param props Optional properties to set to this service account.\n   */\n  public async createServiceAccount(\n    ownerId: number,\n    props?: ServiceAccountProperties,\n  ): Promise<ServiceAccount> {\n    return await this._connection.transaction(async (manager) => {\n      const owner = await this._homeDb.getUser(ownerId);\n      if (!owner) {\n        throw new ApiError(\"owner not found\", 404);\n      }\n      if (owner.type !== \"login\") {\n        throw new ApiError('Only regular users (of type \"login\") are allowed to create service accounts', 403);\n      }\n      const uuid = uuidv4();\n      // We use .invalid as tld following RFC 2606\n      // as we don't ever want service user to be able to receive any email\n      // and then be able to connect via link in email\n      const login = `${uuid}@${Login.SERVICE_ACCOUNTS_TLD}`;\n      // Using getUserByLogin will create the user... Yeah, please don't blame us.\n      const serviceUser = await this._homeDb.getUserByLogin(login, { manager }, \"service\");\n\n      await this._homeDb.createApiKey(serviceUser.id, false, manager);\n\n      const newServiceAccount = ServiceAccount.create({\n        owner,\n        serviceUserId: serviceUser.id,\n        label: props?.label,\n        description: props?.description,\n        expiresAt: props?.expiresAt,\n      });\n      const serviceAccount = await manager.save(newServiceAccount);\n      return (await this.getServiceAccount(serviceAccount.id, manager))!;\n    });\n  }\n\n  /**\n   * Returns information of the service account, including:\n   *  - the information from the users table\n   *  - the login information\n   *\n   * @param serviceAccountId The service account email\n   */\n  public async getServiceAccount(\n    serviceAccountId: number,\n    transaction?: EntityManager,\n  ): Promise<ServiceAccount | null> {\n    return await this._runInTransaction(transaction, async (manager) => {\n      return await this._buildServiceAccountQuery(manager)\n        .where(\"serviceAccount.id = :id\", { id: serviceAccountId })\n        .getOne();\n    });\n  }\n\n  /**\n   * Like getServiceAccount but also returns informations of the owner.\n   */\n  public async getServiceAccountByLoginWithOwner(\n    serviceAccountLogin: string,\n    transaction?: EntityManager,\n  ): Promise<ServiceAccount | null> {\n    return await this._runInTransaction(transaction, async (manager) => {\n      return await this._buildServiceAccountQuery(manager)\n        .innerJoinAndSelect(\"serviceAccount.owner\", \"owner\")\n        .where(\"logins.email = :email\", { email: normalizeEmail(serviceAccountLogin) })\n        .getOne();\n    });\n  }\n\n  /**\n   * Ensures that the service account exists and is owned by the specified owner.\n   *\n   * @param serviceAccount The service account to check for existence and the ownership\n   * @param expectedOwnerId The user ID we expect the service account is owned (must be passed)\n   */\n  public assertServiceAccountExistingAndOwned(\n    serviceAccount: ServiceAccount | null,\n    expectedOwnerId: number,\n  ): asserts serviceAccount is ServiceAccount {\n    return this._assertExistingAndOwned(serviceAccount, expectedOwnerId);\n  }\n\n  public async getOwnedServiceAccounts(\n    ownerId: number,\n    transaction?: EntityManager,\n  ): Promise<ServiceAccount[]> {\n    return await this._runInTransaction(transaction, async (manager) => {\n      return await this._buildServiceAccountQuery(manager)\n        .innerJoinAndSelect(\"serviceAccount.owner\", \"owner\")\n        .where(\"owner.id = :id\", { id: ownerId })\n        .getMany();\n    });\n  }\n\n  /**\n   * Update a service account\n   *\n   * @param serviceAccountId The service account email\n   * @param props Properties to change to the service account.\n   * @param options\n   * @param options.expectedOwnerId If passed, check the ownership of the service account before any change\n   * @param options.transaction If passed, reuse this typeorm transaction\n   */\n  public async updateServiceAccount(\n    serviceAccountId: number,\n    props: ServiceAccountProperties,\n    options: { expectedOwnerId?: number, transaction?: EntityManager } = {},\n  ): Promise<ServiceAccount> {\n    const { expectedOwnerId } = options;\n    return await this._runInTransaction(options.transaction, async (manager) => {\n      const serviceAccount = await this.getServiceAccount(serviceAccountId, manager);\n      this._assertExistingAndOwned(serviceAccount, expectedOwnerId);\n      ServiceAccount.merge(serviceAccount, props as Partial<ServiceAccount>);\n      return await manager.save(serviceAccount);\n    });\n  }\n\n  /**\n   * Delete a service account\n   *\n   * @param serviceAccountId The service account email\n   * @param options\n   * @param options.expectedOwnerId If passed, check the ownership of the service account before any change\n   * @param options.transaction If passed, reuse this typeorm transaction\n   */\n  public async deleteServiceAccount(\n    serviceAccountId: number,\n    options: { expectedOwnerId?: number, transaction?: EntityManager } = {},\n  ): Promise<ServiceAccount> {\n    return await this._runInTransaction(options.transaction, async (manager) => {\n      const serviceAccount = await this.getServiceAccount(serviceAccountId, manager);\n      this._assertExistingAndOwned(serviceAccount, options.expectedOwnerId);\n      const { serviceUser } = serviceAccount;\n      serviceUser.disabledAt = new Date();\n      await manager.save(serviceUser);\n      await manager.remove(serviceAccount);\n      return serviceAccount;\n    });\n  }\n\n  /**\n   * Creates the service account API key.\n   *\n   * @param serviceAccountId The service account email\n   * @param options\n   * @param options.expectedOwnerId If passed, check the ownership of the service account before any change\n   * @param options.transaction If passed, reuse this typeorm transaction\n   */\n  public async createServiceAccountApiKey(\n    serviceAccountId: number,\n    options: { expectedOwnerId?: number, transaction?: EntityManager } = {},\n  ): Promise<ServiceAccount> {\n    return await this._runInTransaction(options.transaction, async (manager) => {\n      const serviceAccount = await this.getServiceAccount(serviceAccountId, manager);\n      this._assertExistingAndOwned(serviceAccount, options.expectedOwnerId);\n      await this._homeDb.createApiKey(serviceAccount.serviceUser.id, true, manager);\n\n      const updatedServiceAccount = await this.getServiceAccount(serviceAccountId, manager);\n      this._assertExistingAndOwned(updatedServiceAccount, options.expectedOwnerId);\n      return updatedServiceAccount;\n    });\n  }\n\n  /**\n   * Deletes the API key of the service account\n   *\n   * @param serviceAccountId The service account email\n   * @param options\n   * @param options.expectedOwnerId If passed, check the ownership of the service account before any change\n   * @param options.transaction If passed, reuse this typeorm transaction\n   */\n  public async deleteServiceAccountApiKey(\n    serviceAccountId: number,\n    options: { expectedOwnerId?: number, transaction?: EntityManager } = {},\n  ): Promise<ServiceAccount> {\n    return await this._runInTransaction(options.transaction, async (manager) => {\n      const serviceAccount = await this.getServiceAccount(serviceAccountId, manager);\n      this._assertExistingAndOwned(serviceAccount, options.expectedOwnerId);\n      serviceAccount.serviceUser = await this._homeDb.deleteApiKey(serviceAccount.serviceUser.id, manager);\n      return serviceAccount;\n    });\n  }\n\n  /**\n   * Check that the serviceAccount exists and *if* an expectedOwnerId is passed, check\n   * its ownership.\n   */\n  private _assertExistingAndOwned(\n    serviceAccount: ServiceAccount | null, expectedOwnerId: number | undefined,\n  ): asserts serviceAccount is ServiceAccount {\n    this._assertExisting(serviceAccount);\n    if (expectedOwnerId !== undefined && serviceAccount.ownerId !== expectedOwnerId) {\n      throw new ApiError(\"Cannot access non-owned service account\", 403);\n    }\n  }\n\n  private _assertExisting(serviceAccount: ServiceAccount | null): asserts serviceAccount is ServiceAccount {\n    if (serviceAccount === null) {\n      throw new ApiError(\"This Service Account does not exist\", 404);\n    }\n  }\n\n  private _buildServiceAccountQuery(manager: EntityManager) {\n    return manager.createQueryBuilder()\n      .select(\"serviceAccount\")\n      .from(ServiceAccount, \"serviceAccount\")\n      .innerJoinAndSelect(\"serviceAccount.serviceUser\", \"serviceUser\")\n      .innerJoinAndSelect(\"serviceUser.logins\", \"logins\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/lib/homedb/UsersManager.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { normalizeEmail } from \"app/common/emails\";\nimport { PERSONAL_FREE_PLAN } from \"app/common/Features\";\nimport { buildUrlId } from \"app/common/gristUrls\";\nimport { isEmail } from \"app/common/gutil\";\nimport { UserOrgPrefs } from \"app/common/Prefs\";\nimport * as roles from \"app/common/roles\";\nimport { UserType } from \"app/common/User\";\nimport {\n  ANONYMOUS_USER_EMAIL,\n  EVERYONE_EMAIL,\n  FullUser,\n  PermissionDelta,\n  PREVIEWER_EMAIL,\n  UserOptions,\n  UserProfile,\n} from \"app/common/UserAPI\";\nimport { AclRule } from \"app/gen-server/entity/AclRule\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { Login } from \"app/gen-server/entity/Login\";\nimport { Pref } from \"app/gen-server/entity/Pref\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { HomeDBManager, PermissionDeltaAnalysis, Scope, UserIdDelta } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport {\n  AvailableUsers, GetUserOptions, NonGuestGroup, QueryResult, Resource, RunInTransaction, UserProfileChange,\n} from \"app/gen-server/lib/homedb/Interfaces\";\nimport { Permissions } from \"app/gen-server/lib/Permissions\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { getPersonalOrgsEnabled } from \"app/server/lib/gristSettings\";\n\nimport * as crypto from \"crypto\";\n\nimport flatten from \"lodash/flatten\";\nimport { EntityManager, FindManyOptions, IsNull, Not } from \"typeorm\";\n\nfunction apiKeyGenerator(): string {\n  return crypto.randomBytes(20).toString(\"hex\");\n}\n\n// A special user allowed to add/remove both the EVERYONE_EMAIL and ANONYMOUS_USER_EMAIL to/from a resource.\nexport const SUPPORT_EMAIL = appSettings.section(\"access\").flag(\"supportEmail\").requireString({\n  envVar: \"GRIST_SUPPORT_EMAIL\",\n  defaultValue: \"support@getgrist.com\",\n});\n\n// A list of emails we don't expect to see logins for.\nconst NON_LOGIN_EMAILS = [PREVIEWER_EMAIL, EVERYONE_EMAIL, ANONYMOUS_USER_EMAIL];\n\n/**\n * Class responsible for Users Management.\n *\n * It's only meant to be used by HomeDBManager. If you want to use one of its (instance or static) methods,\n * please make an indirection which passes through HomeDBManager.\n */\nexport class UsersManager {\n  public static isSingleUser(users: AvailableUsers): users is number {\n    return typeof users === \"number\";\n  }\n\n  // Returns all first-level memberUsers in the resources. Requires all resources' aclRules, groups\n  // and memberUsers to be populated.\n  // If optRoles is provided, only checks membership in resource groups with the given roles.\n  public static getResourceUsers(res: Resource | Resource[], optRoles?: string[]): User[] {\n    res = Array.isArray(res) ? res : [res];\n    const users: { [uid: string]: User } = {};\n    let resAcls: AclRule[] = flatten(res.map(_res => _res.aclRules as AclRule[]));\n    if (optRoles) {\n      resAcls = resAcls.filter(_acl => optRoles.includes(_acl.group.name));\n    }\n    resAcls.forEach((aclRule: AclRule) => {\n      aclRule.group.memberUsers.forEach((u: User) => users[u.id] = u);\n    });\n    const userList = Object.keys(users).map(uid => users[uid]);\n    userList.sort((a, b) => a.id - b.id);\n    return userList;\n  }\n\n  // Returns a map of users indexed by their roles. Optionally excludes users whose ids are in\n  // excludeUsers.\n  public static getUsersWithRole(groups: NonGuestGroup[], excludeUsers?: number[]): Map<roles.NonGuestRole, User[]> {\n    const members = new Map<roles.NonGuestRole, User[]>();\n    for (const group of groups) {\n      let users = group.memberUsers;\n      if (excludeUsers) {\n        users = users.filter(user => !excludeUsers.includes(user.id));\n      }\n      members.set(group.name, users);\n    }\n    return members;\n  }\n\n  private _specialUserIds: { [name: string]: number } = {}; // id for anonymous user, previewer, etc\n\n  private get _connection() {\n    return this._homeDb.connection;\n  }\n\n  public constructor(\n    private readonly _homeDb: HomeDBManager,\n    private _runInTransaction: RunInTransaction,\n  ) {}\n\n  /**\n   * Clear all user preferences associated with the given email addresses.\n   * For use in tests.\n   */\n  public async testClearUserPrefs(emails: string[]) {\n    return await this._connection.transaction(async (manager) => {\n      for (const email of emails) {\n        const user = await this.getExistingUserByLogin(email, manager);\n        if (user) {\n          await manager.delete(Pref, { userId: user.id });\n        }\n      }\n    });\n  }\n\n  public getSpecialUserId(key: string) {\n    return this._specialUserIds[key];\n  }\n\n  /**\n   * Return the special user ids.\n   */\n  public getSpecialUserIds() {\n    return Object.values(this._specialUserIds);\n  }\n\n  /**\n   *\n   * Get the id of the anonymous user.\n   *\n   */\n  public getAnonymousUserId(): number {\n    const id = this._specialUserIds[ANONYMOUS_USER_EMAIL];\n    if (!id) { throw new Error(\"'Anonymous' user not available\"); }\n    return id;\n  }\n\n  /**\n   * Get the id of the thumbnail user.\n   */\n  public getPreviewerUserId(): number {\n    const id = this._specialUserIds[PREVIEWER_EMAIL];\n    if (!id) { throw new Error(\"'Previewer' user not available\"); }\n    return id;\n  }\n\n  /**\n   * Get the id of the 'everyone' user.\n   */\n  public getEveryoneUserId(): number {\n    const id = this._specialUserIds[EVERYONE_EMAIL];\n    if (!id) { throw new Error(\"'Everyone' user not available\"); }\n    return id;\n  }\n\n  /**\n   * Get the id of the 'support' user.\n   */\n  public getSupportUserId(): number {\n    const id = this._specialUserIds[SUPPORT_EMAIL];\n    if (!id) { throw new Error(\"'Support' user not available\"); }\n    return id;\n  }\n\n  public async getUserByKey(apiKey: string): Promise<User | undefined> {\n    // Include logins relation for Authorization convenience.\n    return await User.findOne({ where: { apiKey }, relations: [\"logins\"] }) || undefined;\n  }\n\n  public async getUserByRef(\n    ref: string,\n    options: { manager?: EntityManager; relations?: string[] } = {},\n  ): Promise<User | undefined> {\n    const { manager, relations = [\"logins\"] } = options;\n    const user = await this._runInTransaction(manager, m => m.findOne(User, { where: { ref }, relations }));\n    return user || undefined;\n  }\n\n  public async getUser(\n    userId: number,\n    options: { includePrefs?: boolean } = {},\n  ): Promise<User | undefined> {\n    const { includePrefs } = options;\n    const relations = [\"logins\"];\n    if (includePrefs) { relations.push(\"prefs\"); }\n    return await User.findOne({ where: { id: userId }, relations }) || undefined;\n  }\n\n  public async getFullUser(userId: number): Promise<FullUser> {\n    const user = await User.findOne({ where: { id: userId }, relations: [\"logins\"] });\n    if (!user) { throw new ApiError(\"unable to find user\", 400); }\n    return this.makeFullUser(user);\n  }\n\n  /**\n   * Gets a user and ensures that they have an unsubscribe key.\n   */\n  public async getUserAndEnsureUnsubscribeKey(userId: number): Promise<User> {\n    return await this._runInTransaction(undefined, async (manager) => {\n      const relations = [\"logins\"];\n      const user = await manager.findOne(User, { where: { id: userId }, relations });\n      if (!user) { throw new ApiError(\"unable to find user\", 400); }\n      if (!user.unsubscribeKey) {\n        user.unsubscribeKey = crypto.randomBytes(32).toString(\"base64url\");\n        await manager.save(user);\n      }\n      return user;\n    });\n  }\n\n  /**\n   * Convert a user record into the format specified in api.\n   */\n  public makeFullUser(user: User): FullUser {\n    if (!user.logins?.[0]?.displayEmail) {\n      throw new ApiError(\"unable to find mandatory user email\", 400);\n    }\n    const displayEmail = user.logins[0].displayEmail;\n    const loginEmail = user.loginEmail;\n    const result: FullUser = {\n      id: user.id,\n      email: displayEmail,\n      // Only include loginEmail when it's different, to avoid overhead when FullUser is sent\n      // around, and also to avoid updating too many tests.\n      loginEmail: loginEmail !== displayEmail ? loginEmail : undefined,\n      name: user.name,\n      picture: user.picture,\n      ref: user.ref,\n      locale: user.options?.locale,\n      prefs: user.prefs?.find(p => p.orgId === null)?.prefs,\n      firstLoginAt: user.firstLoginAt || null,\n      disabledAt: user.disabledAt || null,\n    };\n    if (user.firstLoginAt) {\n      result.firstLoginAt = user.firstLoginAt;\n    }\n    if (this.getAnonymousUserId() === user.id) {\n      result.anonymous = true;\n    }\n    if (this.getSupportUserId() === user.id) {\n      result.isSupport = true;\n    }\n    return result;\n  }\n\n  /**\n   * Ensures that user with external id exists and updates its profile and email if necessary.\n   *\n   * @param profile External profile\n   */\n  public async ensureExternalUser(profile: UserProfile) {\n    await this._connection.transaction(async (manager) => {\n      // First find user by the connectId from the profile\n      const existing = await manager.findOne(User, {\n        where: { connectId: profile.connectId || undefined },\n        relations: [\"logins\"],\n      });\n\n      // If a user does not exist, create it with data from the external profile.\n      if (!existing) {\n        const newUser = await this.getUserByLoginWithRetry(profile.email, {\n          profile,\n          manager,\n        });\n        // No need to survey this user.\n        newUser.isFirstTimeUser = false;\n        await manager.save(newUser);\n      } else {\n        // Else update profile and login information from external profile.\n        let updated = false;\n        let login: Login = existing.logins[0];\n        const properEmail = normalizeEmail(profile.email);\n\n        if (properEmail !== existing.loginEmail) {\n          login = login ?? new Login();\n          login.email = properEmail;\n          login.displayEmail = profile.email;\n          existing.logins.splice(0, 1, login);\n          login.user = existing;\n          updated = true;\n        }\n\n        if (profile?.name && profile?.name !== existing.name) {\n          existing.name = profile.name;\n          updated = true;\n        }\n\n        if (profile?.picture && profile?.picture !== existing.picture) {\n          existing.picture = profile.picture;\n          updated = true;\n        }\n\n        if (updated) {\n          await manager.save([existing, login]);\n        }\n      }\n    });\n  }\n\n  public async updateUser(userId: number, props: UserProfileChange) {\n    return await this._connection.transaction(async (manager) => {\n      let isWelcomed = false;\n      let needsSave = false;\n      const user = await manager.findOne(User, {\n        relations: [\"logins\"],\n        where: { id: userId },\n      });\n      if (!user) { throw new ApiError(\"unable to find user\", 400); }\n\n      const previous = structuredClone(user);\n      if (props.name && props.name !== user.name) {\n        user.name = props.name;\n        needsSave = true;\n      }\n      if (props.disabledAt !== undefined && props.disabledAt !== user.disabledAt) {\n        user.disabledAt = props.disabledAt;\n        needsSave = true;\n      }\n      if (props.isFirstTimeUser !== undefined && props.isFirstTimeUser !== user.isFirstTimeUser) {\n        user.isFirstTimeUser = props.isFirstTimeUser;\n        needsSave = true;\n        // If we are turning off the isFirstTimeUser flag, then right\n        // after this transaction commits is a great time to trigger\n        // any automation for first logins\n        if (!props.isFirstTimeUser) { isWelcomed = true; }\n      }\n      if (needsSave) {\n        await manager.save(user);\n      }\n      return { previous, current: user, isWelcomed };\n    });\n  }\n\n  // TODO: rather use the updateUser() method, if that makes sense?\n  public async updateUserOptions(userId: number, props: Partial<UserOptions>) {\n    await this._runInTransaction(undefined, async (manager) => {\n      const user = await manager.findOne(User, { where: { id: userId } });\n      if (!user) { throw new ApiError(\"unable to find user\", 400); }\n      user.options = { ...(user.options ?? {}), ...props };\n      await manager.save(user);\n    });\n  }\n\n  /**\n   * Get the anonymous user, as a constructed object rather than a database lookup.\n   */\n  public getAnonymousUser(): User {\n    const user = new User();\n    user.id = this.getAnonymousUserId();\n    user.name = \"Anonymous\";\n    user.isFirstTimeUser = false;\n    const login = new Login();\n    login.displayEmail = login.email = ANONYMOUS_USER_EMAIL;\n    user.logins = [login];\n    user.ref = \"\";\n    return user;\n  }\n\n  // Fetch user from login, creating the user if previously unseen, allowing one retry\n  // for an email key conflict failure. This is in case our transaction conflicts with a peer\n  // doing the same thing. This is quite likely if the first page visited by a previously\n  // unseen user fires off multiple api calls.\n  public async getUserByLoginWithRetry(email: string, options: GetUserOptions = {}): Promise<User> {\n    try {\n      return await this.getUserByLogin(email, options);\n    } catch (e) {\n      if (e.name === \"QueryFailedError\" && e.detail?.match(/Key \\(email\\)=[^ ]+ already exists/)) {\n        // This is a postgres-specific error message. This problem cannot arise in sqlite,\n        // because we have to serialize sqlite transactions in any case to get around a typeorm\n        // limitation.\n        return await this.getUserByLogin(email, options);\n      }\n      throw e;\n    }\n  }\n\n  /**\n   * Find a user by email. Don't create the user if it doesn't already exist.\n   */\n  public async getExistingUserByLogin(\n    email: string,\n    manager?: EntityManager,\n  ): Promise<User | undefined> {\n    return await this._buildExistingUsersByLoginRequest([email], manager)\n      .getOne() || undefined;\n  }\n\n  /**\n   * Find some users by their emails. Don't create the users if they don't already exist.\n   */\n  public async getExistingUsersByLogin(\n    emails: string[],\n    manager?: EntityManager,\n  ): Promise<User[]> {\n    if (emails.length === 0) {\n      return [];\n    }\n    return await this._buildExistingUsersByLoginRequest(emails, manager)\n      .getMany();\n  }\n\n  /**\n   * Find some users given the passed condition.\n   *\n   * @param where The search condition\n   * @param manager The entity manager\n   *\n   * @return The users found\n   */\n  public async findUsers(findOpts: FindManyOptions<User>, manager?: EntityManager) {\n    return (manager || this._connection).getRepository(User)\n      .find({\n        relations: [\"logins\"],\n        order: { id: \"ASC\" },\n        ...findOpts,\n      });\n  }\n\n  /**\n   *\n   * Fetches a user record based on an email address. If a user record already\n   * exists linked to the email address supplied, that is the record returned.\n   * Otherwise a fresh record is created, linked to the supplied email address.\n   * The supplied `options` are used when creating a fresh record, or updating\n   * unset/outdated fields of an existing record.\n   *\n   */\n  public async getUserByLogin(email: string, options: GetUserOptions = {}, type: UserType = \"login\") {\n    const { manager: transaction, profile, userOptions } = options;\n    const normalizedEmail = normalizeEmail(email);\n    return await this._runInTransaction(transaction, async (manager) => {\n      let needUpdate = false;\n      const userQuery = manager.createQueryBuilder()\n        .select(\"user\")\n        .from(User, \"user\")\n        .leftJoinAndSelect(\"user.logins\", \"logins\")\n        .leftJoinAndSelect(\"user.personalOrg\", \"personalOrg\")\n        .where(\"email = :email\", { email: normalizedEmail });\n      let user = await userQuery.getOne();\n      let login: Login;\n      if (!user) {\n        user = new User();\n        // Special users do not have first time user set so that they don't get redirected to the\n        // welcome page.\n        user.isFirstTimeUser = !NON_LOGIN_EMAILS.includes(normalizedEmail);\n        user.type = type;\n        login = new Login();\n        login.email = normalizedEmail;\n        login.user = user;\n        needUpdate = true;\n      } else {\n        login = user.logins[0];\n      }\n\n      // Check that user and login records are up to date.\n      if (!user.name) {\n        // Set the user's name if our provider knows it. Otherwise use their username\n        // from email, for lack of something better. If we don't have a profile at this\n        // time, then leave the name blank in the hopes of learning it when the user logs in.\n        user.name = (profile && this._getNameOrDeduceFromEmail(profile.name, email)) || \"\";\n        needUpdate = true;\n      }\n      if (!user.picture && profile?.picture) {\n        // Set the user's profile picture if our provider knows it.\n        user.picture = profile.picture;\n        needUpdate = true;\n      }\n      if (profile?.email && profile.email !== login.displayEmail) {\n        // Use provider's version of email address for display.\n        login.displayEmail = profile.email;\n        needUpdate = true;\n      }\n\n      if (profile?.connectId && profile?.connectId !== user.connectId) {\n        user.connectId = profile.connectId;\n        needUpdate = true;\n      }\n\n      if (!login.displayEmail) {\n        // Save some kind of display email if we don't have anything at all for it yet.\n        // This could be coming from how someone wrote it in a UserManager dialog, for\n        // instance. It will get overwritten when the user logs in if the provider's\n        // version is different.\n        login.displayEmail = email;\n        needUpdate = true;\n      }\n      if (!user.options?.authSubject && userOptions?.authSubject) {\n        // Link subject from password-based authentication provider if not previously linked.\n        user.options = { ...(user.options ?? {}), authSubject: userOptions.authSubject };\n        needUpdate = true;\n      }\n      // We might want to store extra information returned by the identity provider\n      if (options.profile?.extra) {\n        // Update already existing user options\n        user.options = { ...user.options, ssoExtraInfo: options.profile.extra };\n        needUpdate = true;\n      }\n\n      // get date of now (remove milliseconds for compatibility with other\n      // timestamps in db set by typeorm, and since second level precision is fine)\n      const nowish = new Date();\n      nowish.setMilliseconds(0);\n      if (profile && !user.firstLoginAt) {\n        // set first login time to now\n        user.firstLoginAt = nowish;\n        needUpdate = true;\n      }\n      const getTimestampStartOfDay = (date: Date) => {\n        const timestamp = Math.floor(date.getTime() / 1000); // unix timestamp seconds from epoc\n        const startOfDay = timestamp - (timestamp % 86400 /* 24h */); // start of a day in seconds since epoc\n        return startOfDay;\n      };\n      if (!user.lastConnectionAt || getTimestampStartOfDay(user.lastConnectionAt) !== getTimestampStartOfDay(nowish)) {\n        user.lastConnectionAt = nowish;\n        needUpdate = true;\n      }\n      if (needUpdate) {\n        login.user = user;\n        await manager.save([user, login]);\n      }\n      if (getPersonalOrgsEnabled() && !user.personalOrg && !NON_LOGIN_EMAILS.includes(login.email)) {\n        // Add a personal organization for this user.\n        // We don't add a personal org for anonymous/everyone/previewer \"users\" as it could\n        // get a bit confusing.\n        const result = await this._homeDb.addOrg(user, { name: \"Personal\" }, {\n          setUserAsOwner: true,\n          useNewPlan: true,\n          product: PERSONAL_FREE_PLAN,\n        }, manager);\n        if (result.status !== 200) {\n          throw new Error(result.errMessage);\n        }\n        needUpdate = true;\n\n        // We just created a personal org; set userOrgPrefs that should apply for new users only.\n        const userOrgPrefs: UserOrgPrefs = { showGristTour: true };\n        const org = result.data;\n        if (org) {\n          await this._homeDb.updateOrg({ userId: user.id }, org.id, { userOrgPrefs }, manager);\n        }\n      }\n      if (needUpdate) {\n        // We changed the db - reload user in order to give consistent results.\n        // In principle this could be optimized, but this is simpler to maintain.\n        user = await userQuery.getOne();\n      }\n      return user!;\n    });\n  }\n\n  /*\n   * Deletes a user from the database. For the moment, the only person with the right\n   * to delete a user is the user themselves.\n   * Users have logins, a personal org, and entries in the group_users table. All are\n   * removed together in a transaction. All material in the personal org will be lost.\n   *\n   * @param scope: request scope, including the id of the user initiating this action\n   * @param userIdToDelete: the id of the user to delete from the database\n   * @param name: optional cross-check, delete only if user name matches this\n   */\n  public async deleteUser(scope: Scope, userIdToDelete: number,\n    name?: string): Promise<QueryResult<User>> {\n    const userIdDeleting = scope.userId;\n    if (userIdDeleting !== userIdToDelete) {\n      throw new ApiError(\"not permitted to delete this user\", 403);\n    }\n\n    // Deleting a user leaves their forks orphaned, inaccessible.\n    // Worse, even Grist loses track of how to access them on\n    // disk and in external storage, since they are identified\n    // using a composite key that includes the user id. So we\n    // delete the forks now. Deleting can be a relatively slow\n    // operation, since in general it needs to work via\n    // communication with doc workers. So we do it outside\n    // the main transaction for deleting the user. Within\n    // the transaction, we simply check that no forks have\n    // since appeared. Staying outside the transaction is\n    // important also for single-process Grist combining\n    // home server and doc worker.\n    const forksToDelete = await this._connection.getRepository(Document).find({\n      where: {\n        createdBy: userIdToDelete,\n        trunkId: Not(IsNull()),\n      } });\n    // Delete external storage for orphaned forks.\n    // This might take some time, if there's a lot of them.\n    for (const doc of forksToDelete) {\n      // In tests the storage coordinator may not be present and\n      // that's usually fine. But if we're deleting forks it had\n      // better be there.\n      if (!this._homeDb.storageCoordinator) {\n        throw new Error(\"no mechanism available to delete forks\");\n      }\n      const fullId = buildUrlId({ trunkId: doc.trunkId!, forkId: doc.id, forkUserId: doc.createdBy! });\n      await this._homeDb.storageCoordinator.hardDeleteDoc(fullId);\n    }\n\n    return await this._connection.transaction(async (manager) => {\n      const user = await manager.findOne(User, { where: { id: userIdToDelete },\n        relations: [\"logins\", \"personalOrg\", \"prefs\"] });\n      if (!user) { throw new ApiError(\"user not found\", 404); }\n      if (name) {\n        if (user.name !== name) {\n          throw new ApiError(`user name did not match ('${name}' vs '${user.name}')`, 400);\n        }\n      }\n      if (user.personalOrg) { await this._homeDb.deleteOrg(scope, user.personalOrg.id, manager); }\n\n      // Unset 'created_by' on any documents created by this user. It's sad to lose this info, but\n      // we can't leave an invalid reference (and violate the foreign-key constraint)\n      const docs = await manager.getRepository(Document).find({ where: { createdBy: userIdToDelete } });\n      docs.forEach((doc) => {\n        if (doc.trunkId) {\n          // We tried cleaning up forks before starting the\n          // transaction but one snuck back in? Just bail.\n          throw new ApiError(\"Untimely document addition? Please retry.\", 503);\n        } else {\n          doc.createdBy = null;\n        }\n      });\n      await manager.save(docs);\n\n      await manager.remove([...user.logins]);\n      // We don't have a GroupUser entity, and adding one tickles lots of TypeOrm quirkiness,\n      // so use a plain query to delete entries in the group_users table.\n      await manager.createQueryBuilder()\n        .delete()\n        .from(\"group_users\")\n        .where(\"user_id = :userId\", { userId: userIdToDelete })\n        .execute();\n\n      await manager.delete(User, userIdToDelete);\n      return {\n        status: 200,\n        data: user,\n      };\n    });\n  }\n\n  public async initializeSpecialIds(): Promise<void> {\n    await this._maybeCreateSpecialUserId({\n      email: ANONYMOUS_USER_EMAIL,\n      name: \"Anonymous\",\n    });\n    await this._maybeCreateSpecialUserId({\n      email: PREVIEWER_EMAIL,\n      name: \"Preview\",\n    });\n    await this._maybeCreateSpecialUserId({\n      email: EVERYONE_EMAIL,\n      name: \"Everyone\",\n    });\n    await this._maybeCreateSpecialUserId({\n      email: SUPPORT_EMAIL,\n      name: \"Support\",\n    });\n  }\n\n  /**\n   *\n   * Take a list of user profiles coming from the client's session, correlate\n   * them with Users and Logins in the database, and construct full profiles\n   * with user ids, standardized display emails, pictures, and anonymous flags.\n   *\n   */\n  public async completeProfiles(profiles: UserProfile[]): Promise<FullUser[]> {\n    if (profiles.length === 0) { return []; }\n    const qb = this._connection.createQueryBuilder()\n      .select(\"logins\")\n      .from(Login, \"logins\")\n      .leftJoinAndSelect(\"logins.user\", \"user\")\n      .where(\"logins.email in (:...emails)\", { emails: profiles.map(profile => normalizeEmail(profile.email)) });\n    const completedProfiles: { [email: string]: FullUser } = {};\n    for (const login of await qb.getMany()) {\n      completedProfiles[login.email] = {\n        id: login.user.id,\n        email: login.displayEmail,\n        name: login.user.name,\n        picture: login.user.picture,\n        anonymous: login.user.id === this.getAnonymousUserId(),\n        locale: login.user.options?.locale,\n      };\n    }\n    return profiles.map(profile => completedProfiles[normalizeEmail(profile.email)])\n      .filter(fullProfile => fullProfile);\n  }\n\n  /**\n   * Update users with passed property. Optional user properties that are missing will be reset to their default value.\n   */\n  public async overwriteUser(userId: number, props: UserProfile): Promise<User> {\n    return await this._connection.transaction(async (manager) => {\n      const user = await this.getUser(userId, { includePrefs: true });\n      if (!user) { throw new ApiError(\"unable to find user to update\", 404); }\n      const login = user.logins[0];\n      user.name = this._getNameOrDeduceFromEmail(props.name, props.email);\n      user.picture = props.picture || \"\";\n      user.options = { ...(user.options || {}), locale: props.locale ?? undefined };\n      if (props.email) {\n        login.email = normalizeEmail(props.email);\n        login.displayEmail = props.email;\n      }\n      await manager.save([user, login]);\n\n      return user;\n    });\n  }\n\n  public async getUsers({ type }: { type?: UserType } = {}) {\n    return await User.find({\n      relations: [\"logins\"],\n      where: { type },\n      order: { id: \"ASC\" },\n    });\n  }\n\n  /**\n   * ==================================\n   *\n   * Below methods are public but not exposed by HomeDBManager\n   *\n   * They are meant to be used internally (i.e. by homedb/ modules)\n   *\n   */\n\n  // Looks up the emails in the permission delta and adds them to the users maps in\n  // the delta object.\n  // Returns a QueryResult based on the validity of the passed in PermissionDelta object.\n  public async verifyAndLookupDeltaEmails(\n    userId: number,\n    delta: PermissionDelta,\n    isOrg: boolean = false,\n    transaction?: EntityManager,\n  ): Promise<PermissionDeltaAnalysis> {\n    if (!delta) {\n      throw new ApiError(\"Bad request: missing permission delta\", 400);\n    }\n    this._mergeIndistinguishableEmails(delta);\n    const hasInherit = \"maxInheritedRole\" in delta;\n    const hasUsers = delta.users; // allow zero actual changes; useful to reduce special\n    // cases in scripts\n    if ((isOrg && (hasInherit || !hasUsers)) || (!isOrg && !hasInherit && !hasUsers)) {\n      throw new ApiError(\"Bad request: invalid permission delta\", 400);\n    }\n    // Lookup the email access changes and move them to the users object.\n    const notFoundUserEmailDelta: { [email: string]: roles.NonGuestRole } = {};\n    const foundUserIdDelta: { [userId: string]: roles.NonGuestRole | null } = {};\n    if (hasInherit) {\n      // Verify maxInheritedRole\n      const role = delta.maxInheritedRole;\n      const validRoles = new Set(this._homeDb.defaultBasicGroupNames);\n      if (role && !validRoles.has(role)) {\n        throw new ApiError(`Invalid maxInheritedRole ${role}`, 400);\n      }\n    }\n    let foundUsers: User[] = [];\n    if (delta.users) {\n      // Verify roles\n      const deltaRoles = Object.keys(delta.users).map(_userId => delta.users![_userId]);\n      // Cannot set role \"members\" on workspace/doc.\n      const validRoles = new Set(isOrg ? this._homeDb.defaultNonGuestGroupNames : this._homeDb.defaultBasicGroupNames);\n      for (const role of deltaRoles) {\n        if (role && !validRoles.has(role)) {\n          throw new ApiError(`Invalid user role ${role}`, 400);\n        }\n      }\n      // Lookup emails\n      const emailMap = delta.users;\n      const emails = Object.keys(emailMap);\n      foundUsers = await this.getExistingUsersByLogin(emails, transaction);\n      const emailUsers = new Map(foundUsers.map(user => [user.loginEmail, user]));\n      for (const email of emails) {\n        const user = emailUsers.get(normalizeEmail(email));\n        const role = emailMap[email];\n        if (!user && role === null) {\n          // Removing access from non-existant users is a no-op.\n          continue;\n        }\n\n        // Validate email. We only skip this check for removing users, to allow correcting invalid\n        // emails if any were added in a version of Grist that predates this check.\n        if (!isEmail(email)) {\n          throw new ApiError(\"Invalid email address included\", 400);\n        }\n\n        if (user) {\n          // Org-level sharing with everyone would allow serious spamming - forbid it.\n          if (\n            role !== null && // allow removing anything\n            userId !== this.getSupportUserId() && // allow support user latitude\n            user.id === this.getEveryoneUserId() &&\n            isOrg\n          ) {\n            throw new ApiError(\n              \"This user cannot share with everyone at top level\",\n              403,\n            );\n          }\n          foundUserIdDelta[user.id] = role;\n        } else {\n          notFoundUserEmailDelta[email] = role!;\n        }\n      }\n    }\n    const userIdsAndEmails = [\n      ...Object.keys(foundUserIdDelta),\n      ...Object.keys(notFoundUserEmailDelta),\n    ];\n    const removingSelf =\n      userIdsAndEmails.length === 1 &&\n      userIdsAndEmails[0] === String(userId) &&\n      delta.maxInheritedRole === undefined &&\n      foundUserIdDelta[userId] === null;\n    const permissionThreshold = removingSelf ?\n      Permissions.VIEW :\n      Permissions.ACL_EDIT;\n    return {\n      foundUserDelta: delta.users ? foundUserIdDelta : null,\n      foundUsers,\n      notFoundUserDelta: delta.users ? notFoundUserEmailDelta : null,\n      permissionThreshold,\n      affectsSelf: userId in foundUserIdDelta,\n    };\n  }\n\n  public async translateDeltaEmailsToUserIds(\n    userDelta: { [email: string]: roles.NonGuestRole | null },\n    transaction?: EntityManager,\n  ): Promise<{ userDelta: UserIdDelta; users: User[] }> {\n    const newDelta: UserIdDelta = {};\n    const users: User[] = [];\n    for (const [email, value] of Object.entries(userDelta)) {\n      const user = await this.getUserByLogin(email, {\n        manager: transaction,\n      });\n      newDelta[user.id] = value;\n      users.push(user);\n    }\n    return {\n      userDelta: newDelta,\n      users,\n    };\n  }\n\n  /**\n   * Check for anonymous user, either encoded directly as an id, or as a singular\n   * profile (this case arises during processing of the session/access/all endpoint\n   * whether we are checking for available orgs without committing yet to a particular\n   * choice of user).\n   */\n  public isAnonymousUser(users: AvailableUsers): boolean {\n    return UsersManager.isSingleUser(users) ? users === this.getAnonymousUserId() :\n      users.length === 1 && normalizeEmail(users[0].email) === ANONYMOUS_USER_EMAIL;\n  }\n\n  /**\n   * Get ids of users to be excluded from member counts and emails.\n   */\n  public getExcludedUserIds(): number[] {\n    return [this.getSupportUserId(), this.getAnonymousUserId(), this.getEveryoneUserId()];\n  }\n\n  /**\n   * Returns a Promise for an array of User entities for the given userIds.\n   */\n  public async getUsersByIds(\n    userIds: number[],\n    options: { manager?: EntityManager, withLogins?: boolean } = {},\n  ): Promise<User[]> {\n    if (userIds.length === 0) {\n      return [];\n    }\n    const manager = options.manager || new EntityManager(this._connection);\n    const queryBuilder = manager.createQueryBuilder()\n      .select(\"users\")\n      .from(User, \"users\")\n      .chain(qb => options.withLogins ? qb.leftJoinAndSelect(\"users.logins\", \"logins\") : qb)\n      .where(\"users.id IN (:...userIds)\", { userIds });\n    return await queryBuilder.getMany();\n  }\n\n  /**\n   * Returns a Promise for an array of User entities for the given userIds.\n   * Throws an error if any of the users are not found.\n   * This is useful when we expect all users to exist, and otherwise throw an error.\n   */\n  public async getUsersByIdsStrict(userIds: number[], optManager?: EntityManager): Promise<User[]> {\n    const users = await this.getUsersByIds(userIds, { manager: optManager });\n    if (users.length !== userIds.length) {\n      const foundUserIds = new Set(users.map(user => user.id));\n      const missingUserIds = userIds.filter(userId => !foundUserIds.has(userId));\n      throw new ApiError(\"Users not found: \" + missingUserIds.join(\", \"), 404);\n    }\n    return users;\n  }\n\n  /**\n   * Don't add everyone@ as a guest, unless also sharing with anon@.\n   * This means that material shared with everyone@ doesn't become\n   * listable/discoverable by default.\n   *\n   * This is a HACK to allow existing example doc setup to continue to\n   * work. It could be removed if we are willing to share the entire\n   * support org with users. E.g. move any material we don't want to\n   * share into a workspace that doesn't inherit ACLs. TODO: remove\n   * this hack, or enhance it up as a way to support discoverability /\n   * listing. It has the advantage of cloning well.\n   */\n  public filterEveryone(users: User[]): User[] {\n    const everyone = this.getEveryoneUserId();\n    const anon = this.getAnonymousUserId();\n    if (users.find(u => u.id === anon)) { return users; }\n    return users.filter(u => u.id !== everyone);\n  }\n\n  // Given two arrays of groups, returns a map of users present in the first array but\n  // not the second, where the map is broken down by user role.\n  // This method is used for checking limits on shares.\n  // Excluded users are removed from the results.\n  public getUserDifference(groupsA: Group[], groupsB: Group[]): Map<roles.NonGuestRole, User[]> {\n    const subtractSet =\n      new Set<number>(flatten(groupsB.map(grp => grp.memberUsers)).map(usr => usr.id));\n    const result = new Map<roles.NonGuestRole, User[]>();\n    for (const group of groupsA) {\n      const name = group.name;\n      if (!roles.isNonGuestRole(name)) { continue; }\n      result.set(name, group.memberUsers.filter(user => !subtractSet.has(user.id)));\n    }\n    return this.withoutExcludedUsers(result);\n  }\n\n  public withoutExcludedUsers(members: Map<roles.NonGuestRole, User[]>): Map<roles.NonGuestRole, User[]> {\n    const excludedUsers = this.getExcludedUserIds();\n    for (const [role, users] of members.entries()) {\n      members.set(role, users.filter(user => !excludedUsers.includes(user.id)));\n    }\n    return members;\n  }\n\n  // For the moment only the support user can add both everyone@ and anon@ to a\n  // resource, since that allows spam. TODO: enhance or remove.\n  public checkUserChangeAllowed(userId: number, groups: Group[]) {\n    if (userId === this.getSupportUserId()) { return; }\n    const ids = new Set(flatten(groups.map(g => g.memberUsers)).map(u => u.id));\n    if (ids.has(this.getEveryoneUserId()) && ids.has(this.getAnonymousUserId())) {\n      throw new Error(\"this user cannot share with everyone and anonymous\");\n    }\n  }\n\n  public async getApiKey(userId: number) {\n    const user = await User.findOne({ where: { id: userId } });\n    if (user) {\n      // The null value is of no interest to the user, let's show empty string instead.\n      return user.apiKey || \"\";\n    }\n    throw new ApiError(\"user not known\", 404);\n  }\n\n  public async createApiKey(userId: number, force: boolean, transaction?: EntityManager): Promise<User> {\n    return await this._runInTransaction(transaction, async (manager) => {\n      const user = await manager.findOne(User, { where: { id: userId } });\n      if (!user) {\n        throw new ApiError(\"user not known\", 404);\n      }\n      if (!user.apiKey || force) {\n        user.apiKey = apiKeyGenerator();\n        return await manager.save(User, user);\n      } else {\n        throw new ApiError(\"An apikey is already set, use `{force: true}` to override it.\", 400);\n      }\n    });\n  }\n\n  public async deleteApiKey(userId: number, transaction?: EntityManager): Promise<User> {\n    return await this._runInTransaction(transaction, async (manager) => {\n      const user = await manager.findOne(User, { where: { id: userId } });\n      if (!user) {\n        throw new Error(\"user not known\");\n      }\n      user.apiKey = null;\n      return await manager.save(User, user);\n    });\n  }\n\n  /**\n   *\n   * Get the id of a special user, creating that user if it is not already present.\n   *\n   */\n  private async _maybeCreateSpecialUserId(profile: UserProfile) {\n    let id = this._specialUserIds[profile.email];\n    if (!id) {\n      // get or create user - with retry, since there'll be a race to create the\n      // user if a bunch of servers start simultaneously and the user doesn't exist\n      // yet.\n      const user = await this.getUserByLoginWithRetry(profile.email, { profile });\n      id = this._specialUserIds[profile.email] = user.id;\n    }\n    if (!id) { throw new Error(`Could not find or create user ${profile.email}`); }\n    return id;\n  }\n\n  private _getNameOrDeduceFromEmail(name: string, email: string) {\n    return name || email.split(\"@\")[0];\n  }\n\n  // This deals with the problem posed by receiving a PermissionDelta specifying a\n  // role for both alice@x and Alice@x. We do not distinguish between such emails.\n  // If there are multiple indistinguishabe emails, we preserve just one of them,\n  // assigning it the most powerful permission specified. The email variant perserved\n  // is the earliest alphabetically.\n  private _mergeIndistinguishableEmails(delta: PermissionDelta) {\n    if (!delta.users) { return; }\n    // We normalize emails for comparison, but track how they were capitalized\n    // in order to preserve it. This is worth doing since for the common case\n    // of a user being added to a resource prior to ever logging in, their\n    // displayEmail will be seeded from this value.\n    const displayEmails: { [email: string]: string } = {};\n    // This will be our output.\n    const users: { [email: string]: roles.NonGuestRole | null } = {};\n    for (const displayEmail of Object.keys(delta.users).sort()) {\n      const email = normalizeEmail(displayEmail);\n      const role = delta.users[displayEmail];\n      const key = displayEmails[email] = displayEmails[email] || displayEmail;\n      users[key] = users[key] ? roles.getStrongestRole(users[key], role) : role;\n    }\n    delta.users = users;\n  }\n\n  private _buildExistingUsersByLoginRequest(\n    emails: string[],\n    manager?: EntityManager,\n  ) {\n    const normalizedEmails = emails.map(email => normalizeEmail(email));\n    return (manager || this._connection).createQueryBuilder()\n      .select(\"user\")\n      .from(User, \"user\")\n      .leftJoinAndSelect(\"user.logins\", \"logins\")\n      .where(\"email IN (:...emails)\", { emails: normalizedEmails });\n  }\n}\n"
  },
  {
    "path": "app/gen-server/lib/scrubUserFromOrg.ts",
    "content": "import * as roles from \"app/common/roles\";\nimport { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { BillingAccountManager } from \"app/gen-server/entity/BillingAccountManager\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\n\nimport pick from \"lodash/pick\";\nimport { EntityManager } from \"typeorm\";\n\n/**\n *\n * Remove the given user from the given org and every resource inside the org.\n * If the user being removed is an owner of any resources in the org, the caller replaces\n * them as the owner. This is to prevent complete loss of access to any resource.\n *\n * This method transforms ownership without regard to permissions.  We all talked this\n * over and decided this is what we wanted, but there's no denying it is funky and could\n * be surprising.\n * TODO: revisit user scrubbing when we can.\n *\n */\nexport async function scrubUserFromOrg(\n  orgId: number,\n  removeUserId: number,\n  callerUserId: number,\n  manager: EntityManager,\n): Promise<void> {\n  await addMissingGuestMemberships(callerUserId, orgId, manager);\n\n  // This will be a list of all mentions of removeUser and callerUser in any resource\n  // within the org.\n  const mentions: Mention[] = [];\n\n  // Base query for all group_users related to these two users and this org.\n  const q = manager.createQueryBuilder()\n    .select(\"group_users.group_id, group_users.user_id\")\n    .from(\"group_users\", \"group_users\")\n    .leftJoin(Group, \"groups\", \"group_users.group_id = groups.id\")\n    .addSelect(\"groups.name as name\")\n    .leftJoin(\"groups.aclRule\", \"acl_rules\")\n    .where(\"(group_users.user_id = :removeUserId or group_users.user_id = :callerUserId)\",\n      { removeUserId, callerUserId })\n    .andWhere(\"orgs.id = :orgId\", { orgId });\n\n  // Pick out group_users related specifically to the org resource, in 'mentions' format\n  // (including resource id, a tag for the kind of resource, the group name, the user\n  // id, and the group id).\n  const orgs = q.clone()\n    .addSelect(`'org' as kind, orgs.id`)\n    .innerJoin(Organization, \"orgs\", \"orgs.id = acl_rules.org_id\");\n  mentions.push(...await orgs.getRawMany());\n\n  // Pick out mentions related to any workspace within the org.\n  const wss = q.clone()\n    .innerJoin(Workspace, \"workspaces\", \"workspaces.id = acl_rules.workspace_id\")\n    .addSelect(`'ws' as kind, workspaces.id`)\n    .innerJoin(\"workspaces.org\", \"orgs\");\n  mentions.push(...await wss.getRawMany());\n\n  // Pick out mentions related to any doc within the org.\n  const docs = q.clone()\n    .innerJoin(Document, \"docs\", \"docs.id = acl_rules.doc_id\")\n    .addSelect(`'doc' as kind, docs.id`)\n    .innerJoin(\"docs.workspace\", \"workspaces\")\n    .innerJoin(\"workspaces.org\", \"orgs\");\n  mentions.push(...await docs.getRawMany());\n\n  // Prepare to add and delete group_users.\n  const toDelete: Mention[] = [];\n  const toAdd: Mention[] = [];\n\n  // Now index the mentions by whether they are for the removeUser or the callerUser,\n  // and the resource they apply to.\n  const removeUserMentions = new Map<MentionKey, Mention>();\n  const callerUserMentions = new Map<MentionKey, Mention>();\n  for (const mention of mentions) {\n    const isGuest = mention.name === roles.GUEST;\n    if (mention.user_id === removeUserId) {\n      // We can safely remove any guest roles for the removeUser without any\n      // further inspection.\n      if (isGuest) { toDelete.push(mention); continue; }\n      removeUserMentions.set(getMentionKey(mention), mention);\n    } else {\n      if (isGuest) { continue; }\n      callerUserMentions.set(getMentionKey(mention), mention);\n    }\n  }\n  // Now iterate across the mentions of removeUser, and see what we need to do\n  // for each of them.\n  for (const [key, removeUserMention] of removeUserMentions) {\n    toDelete.push(removeUserMention);\n    if (removeUserMention.name !== roles.OWNER) {\n      // Nothing fancy needed for cases where the removeUser is not the owner.\n      // Just discard those.\n      continue;\n    }\n    // The removeUser was a direct owner on this resource, but the callerUser was\n    // not.  We set the callerUser as a direct owner on this resource, to preserve\n    // access to it.\n    // TODO: the callerUser might inherit sufficient access, in which case this\n    // step is unnecessary and could be skipped.  I believe it does no harm though.\n    const callerUserMention = callerUserMentions.get(key);\n    if (callerUserMention && callerUserMention.name === roles.OWNER) { continue; }\n    if (callerUserMention) { toDelete.push(callerUserMention); }\n    toAdd.push({ ...removeUserMention, user_id: callerUserId });\n  }\n  if (toDelete.length > 0) {\n    await manager.createQueryBuilder()\n      .delete()\n      .from(\"group_users\")\n      .whereInIds(toDelete.map(m => pick(m, [\"user_id\", \"group_id\"])))\n      .execute();\n  }\n  if (toAdd.length > 0) {\n    await manager.createQueryBuilder()\n      .insert()\n      .into(\"group_users\")\n      .values(toAdd.map(m => pick(m, [\"user_id\", \"group_id\"])))\n      .execute();\n  }\n\n  // TODO: At this point, we've removed removeUserId from every mention in group_users.\n  // The user may still be mentioned in billing_account_managers.  If the billing_account\n  // is linked to just this single organization, perhaps it would make sense to remove\n  // the user there, if the callerUser is themselves a billing account manager?\n\n  await addMissingGuestMemberships(callerUserId, orgId, manager);\n}\n\nexport async function scrubUserFromBillingAccounts(removeUserId: number, newUserId: number, manager: EntityManager) {\n  // Select all billing accounts that contain removeUserId as manager.\n  const billingAccounts = await manager.createQueryBuilder()\n    .select(\"billing_account.id\", \"id\")\n    .from(BillingAccount, \"billing_account\")\n    .leftJoin(\"billing_account.managers\", \"ba_managers\")\n    // Mark results with whether the newUserId is present.\n    .addSelect(\"MAX(CASE WHEN ba_managers.user_id = :newUserId THEN 1 ELSE 0 END) = 1\", \"hasNewUser\")\n    .setParameter(\"newUserId\", newUserId)\n    .groupBy(\"billing_account.id\")\n    // Filter for those results that include removeUserId.\n    .having(\"MAX(CASE WHEN ba_managers.user_id = :removeUserId THEN 1 ELSE 0 END) = 1\")\n    .setParameter(\"removeUserId\", removeUserId)\n    .getRawMany();\n\n  for (const ba of billingAccounts) {\n    // Prepare to remove removeUserId as manager.\n    const goneBAManager = await manager.findOne(BillingAccountManager,\n      { where: { userId: removeUserId, billingAccountId: ba.id } });\n    if (!goneBAManager) { continue; }\n\n    // Add newUserId as manager, if they aren't already one.\n    if (!ba.hasNewUser) {\n      const newBAManager = new BillingAccountManager();\n      newBAManager.userId = newUserId;\n      newBAManager.billingAccountId = ba.id;\n      await manager.save(newBAManager);\n    }\n    await manager.remove(goneBAManager);\n  }\n}\n\n/**\n * Adds specified user to any guest groups for the resources of an org where the\n * user needs to be and is not already.\n */\nexport async function addMissingGuestMemberships(userId: number, orgId: number,\n  manager: EntityManager) {\n  // For workspaces:\n  // User should be in guest group if mentioned in a doc within that workspace.\n  let groupUsers = await manager.createQueryBuilder()\n    .select(\"workspace_groups.id as group_id, cast(:userId as int) as user_id\")\n    .setParameter(\"userId\", userId)\n    .from(Workspace, \"workspaces\")\n    .where(\"workspaces.org_id = :orgId\", { orgId })\n    .innerJoin(\"workspaces.docs\", \"docs\")\n    .innerJoin(\"docs.aclRules\", \"doc_acl_rules\")\n    .innerJoin(\"doc_acl_rules.group\", \"doc_groups\")\n    .innerJoin(\"doc_groups.memberUsers\", \"doc_group_users\")\n    .andWhere(\"doc_group_users.id = :userId\", { userId })\n    .leftJoin(\"workspaces.aclRules\", \"workspace_acl_rules\")\n    .leftJoin(\"workspace_acl_rules.group\", \"workspace_groups\")\n    .leftJoin(\"group_users\", \"workspace_group_users\",\n      \"workspace_group_users.group_id = workspace_groups.id and \" +\n      \"workspace_group_users.user_id = :userId\")\n    .andWhere(\"workspace_groups.name = :guestName\", { guestName: roles.GUEST })\n    .groupBy(\"workspaces.id, workspace_groups.id, workspace_group_users.user_id\")\n    .having(\"workspace_group_users.user_id is null\")\n    .getRawMany();\n  if (groupUsers.length > 0) {\n    await manager.createQueryBuilder()\n      .insert()\n      .into(\"group_users\")\n      .values(groupUsers)\n      .execute();\n  }\n\n  // For org:\n  // User should be in guest group if mentioned in a workspace within that org.\n  groupUsers = await manager.createQueryBuilder()\n    .select(\"org_groups.id as group_id, cast(:userId as int) as user_id\")\n    .setParameter(\"userId\", userId)\n    .from(Organization, \"orgs\")\n    .where(\"orgs.id = :orgId\", { orgId })\n    .innerJoin(\"orgs.workspaces\", \"workspaces\")\n    .innerJoin(\"workspaces.aclRules\", \"workspaces_acl_rules\")\n    .innerJoin(\"workspaces_acl_rules.group\", \"workspace_groups\")\n    .innerJoin(\"workspace_groups.memberUsers\", \"workspace_group_users\")\n    .andWhere(\"workspace_group_users.id = :userId\", { userId })\n    .leftJoin(\"orgs.aclRules\", \"org_acl_rules\")\n    .leftJoin(\"org_acl_rules.group\", \"org_groups\")\n    .leftJoin(\"group_users\", \"org_group_users\",\n      \"org_group_users.group_id = org_groups.id and \" +\n      \"org_group_users.user_id = :userId\")\n    .andWhere(\"org_groups.name = :guestName\", { guestName: roles.GUEST })\n    .groupBy(\"org_groups.id, org_group_users.user_id\")\n    .having(\"org_group_users.user_id is null\")\n    .getRawMany();\n  if (groupUsers.length > 0) {\n    await manager.createQueryBuilder()\n      .insert()\n      .into(\"group_users\")\n      .values(groupUsers)\n      .execute();\n  }\n\n  // For doc:\n  // Guest groups are not used.\n}\n\ninterface Mention {\n  id: string | number;       // id of resource\n  kind: \"org\" | \"ws\" | \"doc\";  // type of resource\n  user_id: number;         // id of user in group\n  group_id: number;        // id of group\n  name: string;            // name of group\n}\n\ntype MentionKey = string;\n\nfunction getMentionKey(mention: Mention): MentionKey {\n  return `${mention.kind} ${mention.id}`;\n}\n"
  },
  {
    "path": "app/gen-server/lib/values.ts",
    "content": "/**\n * This smoothes over some awkward differences between TypeORM treatment of\n * booleans and json in sqlite and postgres.  Booleans and json work fine\n * with each db, but have different levels of driver-level support.\n */\n\nexport interface NativeValues {\n  // Json and jsonb columns are handled natively by the postgres driver, but for\n  // sqlite requires a typeorm wrapper (simple-json).\n  jsonEntityType: \"json\" | \"simple-json\";\n  jsonbEntityType: \"jsonb\" | \"simple-json\";\n  jsonType: \"json\" | \"varchar\";\n  jsonbType: \"jsonb\" | \"varchar\";\n  booleanType: \"boolean\" | \"integer\";\n  dateTimeType: \"timestamp with time zone\" | \"datetime\";\n  trueValue: boolean | number;\n  falseValue: boolean | number;\n}\n\nconst sqliteNativeValues: NativeValues = {\n  jsonEntityType: \"simple-json\",\n  jsonbEntityType: \"simple-json\",\n  jsonType: \"varchar\",\n  jsonbType: \"varchar\",\n  booleanType: \"integer\",\n  dateTimeType: \"datetime\",\n  trueValue: 1,\n  falseValue: 0,\n};\n\nconst postgresNativeValues: NativeValues = {\n  jsonEntityType: \"json\",\n  jsonbEntityType: \"jsonb\",\n  jsonType: \"json\",\n  jsonbType: \"jsonb\",\n  booleanType: \"boolean\",\n  dateTimeType: \"timestamp with time zone\",\n  trueValue: true,\n  falseValue: false,\n};\n\nexport const nativeValues = (process.env.TYPEORM_TYPE === \"postgres\") ? postgresNativeValues : sqliteNativeValues;\n"
  },
  {
    "path": "app/gen-server/migration/1536634251710-Initial.ts",
    "content": "import * as sqlUtils from \"app/gen-server/sqlUtils\";\n\nimport { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class Initial1536634251710 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // TypeORM doesn't currently help with types of created tables:\n    //   https://github.com/typeorm/typeorm/issues/305\n    // so we need to do a little smoothing over postgres and sqlite.\n    const dbType = queryRunner.connection.driver.options.type;\n    const datetime = sqlUtils.datetime(dbType);\n    const now = sqlUtils.now(dbType);\n\n    await queryRunner.createTable(new Table({\n      name: \"users\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"integer\",\n          isGenerated: true,\n          generationStrategy: \"increment\",\n          isPrimary: true,\n        },\n        {\n          name: \"name\",\n          type: \"varchar\",\n        },\n        {\n          name: \"api_key\",\n          type: \"varchar\",\n          isNullable: true,\n          isUnique: true,\n        },\n      ],\n    }), false);\n\n    await queryRunner.createTable(new Table({\n      name: \"orgs\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"integer\",\n          isGenerated: true,\n          generationStrategy: \"increment\",\n          isPrimary: true,\n        },\n        {\n          name: \"name\",\n          type: \"varchar\",\n        },\n        {\n          name: \"domain\",\n          type: \"varchar\",\n          isNullable: true,\n        },\n        {\n          name: \"created_at\",\n          type: datetime,\n          default: now,\n        },\n        {\n          name: \"updated_at\",\n          type: datetime,\n          default: now,\n        },\n        {\n          name: \"owner_id\",\n          type: \"integer\",\n          isNullable: true,\n          isUnique: true,\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"owner_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"users\",\n        },\n      ],\n    }), false);\n\n    await queryRunner.createTable(new Table({\n      name: \"workspaces\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"integer\",\n          isGenerated: true,\n          generationStrategy: \"increment\",\n          isPrimary: true,\n        },\n        {\n          name: \"name\",\n          type: \"varchar\",\n        },\n        {\n          name: \"created_at\",\n          type: datetime,\n          default: now,\n        },\n        {\n          name: \"updated_at\",\n          type: datetime,\n          default: now,\n        },\n        {\n          name: \"org_id\",\n          type: \"integer\",\n          isNullable: true,\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"org_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"orgs\",\n        },\n      ],\n    }), false);\n\n    await queryRunner.createTable(new Table({\n      name: \"docs\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"varchar\",\n          isPrimary: true,\n        },\n        {\n          name: \"name\",\n          type: \"varchar\",\n        },\n        {\n          name: \"created_at\",\n          type: datetime,\n          default: now,\n        },\n        {\n          name: \"updated_at\",\n          type: datetime,\n          default: now,\n        },\n        {\n          name: \"workspace_id\",\n          type: \"integer\",\n          isNullable: true,\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"workspace_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"workspaces\",\n        },\n      ],\n    }), false);\n\n    await queryRunner.createTable(new Table({\n      name: \"groups\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"integer\",\n          isGenerated: true,\n          generationStrategy: \"increment\",\n          isPrimary: true,\n        },\n        {\n          name: \"name\",\n          type: \"varchar\",\n        },\n      ],\n    }), false);\n\n    await queryRunner.createTable(new Table({\n      name: \"acl_rules\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"integer\",\n          isGenerated: true,\n          generationStrategy: \"increment\",\n          isPrimary: true,\n        },\n        {\n          name: \"permissions\",\n          type: \"integer\",\n        },\n        {\n          name: \"type\",\n          type: \"varchar\",\n        },\n        {\n          name: \"workspace_id\",\n          type: \"integer\",\n          isNullable: true,\n        },\n        {\n          name: \"org_id\",\n          type: \"integer\",\n          isNullable: true,\n        },\n        {\n          name: \"doc_id\",\n          type: \"varchar\",\n          isNullable: true,\n        },\n        {\n          name: \"group_id\",\n          type: \"integer\",\n          isNullable: true,\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"workspace_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"workspaces\",\n        },\n        {\n          columnNames: [\"org_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"orgs\",\n        },\n        {\n          columnNames: [\"doc_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"docs\",\n        },\n        {\n          columnNames: [\"group_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"groups\",\n        },\n      ],\n    }), false);\n\n    await queryRunner.createTable(new Table({\n      name: \"group_users\",\n      columns: [\n        {\n          name: \"group_id\",\n          type: \"integer\",\n          isPrimary: true,\n        },\n        {\n          name: \"user_id\",\n          type: \"integer\",\n          isPrimary: true,\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"group_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"groups\",\n        },\n        {\n          columnNames: [\"user_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"users\",\n        },\n      ],\n    }), false);\n\n    await queryRunner.createTable(new Table({\n      name: \"group_groups\",\n      columns: [\n        {\n          name: \"group_id\",\n          type: \"integer\",\n          isPrimary: true,\n        },\n        {\n          name: \"subgroup_id\",\n          type: \"integer\",\n          isPrimary: true,\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"group_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"groups\",\n        },\n        {\n          columnNames: [\"subgroup_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"groups\",\n        },\n      ],\n    }), false);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(`DROP TABLE \"group_groups\"`);\n    await queryRunner.query(`DROP TABLE \"group_users\"`);\n    await queryRunner.query(`DROP TABLE \"acl_rules\"`);\n    await queryRunner.query(`DROP TABLE \"groups\"`);\n    await queryRunner.query(`DROP TABLE \"docs\"`);\n    await queryRunner.query(`DROP TABLE \"workspaces\"`);\n    await queryRunner.query(`DROP TABLE \"orgs\"`);\n    await queryRunner.query(`DROP TABLE \"users\"`);\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1539031763952-Login.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class Login1539031763952 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(new Table({\n      name: \"logins\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"integer\",\n          isGenerated: true,\n          generationStrategy: \"increment\",\n          isPrimary: true,\n        },\n        {\n          name: \"user_id\",\n          type: \"integer\",\n        },\n        {\n          name: \"email\",\n          type: \"varchar\",\n          isUnique: true,\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"user_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"users\",\n        },\n      ],\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(\"DROP TABLE logins\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1549313797109-PinDocs.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class PinDocs1549313797109 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const sqlite = queryRunner.connection.driver.options.type === \"sqlite\";\n    await queryRunner.addColumn(\"docs\", new TableColumn({\n      name: \"is_pinned\",\n      type: \"boolean\",\n      default: sqlite ? 0 : false,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"docs\", \"is_pinned\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1549381727494-UserPicture.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class UserPicture1549381727494 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"users\", new TableColumn({\n      name: \"picture\",\n      type: \"varchar\",\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"users\", \"picture\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1551805156919-LoginDisplayEmail.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class LoginDisplayEmail1551805156919 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"logins\", new TableColumn({\n      name: \"display_email\",\n      type: \"varchar\",\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"logins\", \"display_email\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1552416614755-LoginDisplayEmailNonNull.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class LoginDisplayEmailNonNull1552416614755 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.query(\"update logins set display_email = email where display_email is null\");\n    // if our db will already heavily loaded, it might be better to add a check constraint\n    // rather than modifying the column properties.  But for our case, this will be fast.\n\n    // To work correctly with RDS version of postgres, it is important to clone\n    // and change typeorm's settings for the column, rather than the settings specified\n    // in previous migrations.  Otherwise typeorm will fall back on a brutal method of\n    // drop-and-recreate that doesn't work for non-null in any case.\n    //\n    // The pg command is very simple, just alter table logins alter column display_email set not null\n    // but sqlite migration is tedious since table needs to be rebuilt, so still just\n    // marginally worthwhile letting typeorm deal with it.\n    const logins = (await queryRunner.getTable(\"logins\"))!;\n    const displayEmail = logins.findColumnByName(\"display_email\")!;\n    const displayEmailNonNull = displayEmail.clone();\n    displayEmailNonNull.isNullable = false;\n    await queryRunner.changeColumn(\"logins\", displayEmail, displayEmailNonNull);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    const logins = (await queryRunner.getTable(\"logins\"))!;\n    const displayEmail = logins.findColumnByName(\"display_email\")!;\n    const displayEmailNonNull = displayEmail.clone();\n    displayEmailNonNull.isNullable = true;\n    await queryRunner.changeColumn(\"logins\", displayEmail, displayEmailNonNull);\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1553016106336-Indexes.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class Indexes1553016106336 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createIndex(\"acl_rules\", new TableIndex({\n      name: \"acl_rules__org_id\",\n      columnNames: [\"org_id\"],\n    }));\n    await queryRunner.createIndex(\"acl_rules\", new TableIndex({\n      name: \"acl_rules__workspace_id\",\n      columnNames: [\"workspace_id\"],\n    }));\n    await queryRunner.createIndex(\"acl_rules\", new TableIndex({\n      name: \"acl_rules__doc_id\",\n      columnNames: [\"doc_id\"],\n    }));\n\n    await queryRunner.createIndex(\"group_groups\", new TableIndex({\n      name: \"group_groups__group_id\",\n      columnNames: [\"group_id\"],\n    }));\n    await queryRunner.createIndex(\"group_groups\", new TableIndex({\n      name: \"group_groups__subgroup_id\",\n      columnNames: [\"subgroup_id\"],\n    }));\n\n    await queryRunner.createIndex(\"group_users\", new TableIndex({\n      name: \"group_users__group_id\",\n      columnNames: [\"group_id\"],\n    }));\n    await queryRunner.createIndex(\"group_users\", new TableIndex({\n      name: \"group_users__user_id\",\n      columnNames: [\"user_id\"],\n    }));\n\n    await queryRunner.createIndex(\"workspaces\", new TableIndex({\n      name: \"workspaces__org_id\",\n      columnNames: [\"org_id\"],\n    }));\n\n    await queryRunner.createIndex(\"docs\", new TableIndex({\n      name: \"docs__workspace_id\",\n      columnNames: [\"workspace_id\"],\n    }));\n\n    await queryRunner.createIndex(\"logins\", new TableIndex({\n      name: \"logins__user_id\",\n      columnNames: [\"user_id\"],\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropIndex(\"acl_rules\", \"acl_rules__org_id\");\n    await queryRunner.dropIndex(\"acl_rules\", \"acl_rules__workspace_id\");\n    await queryRunner.dropIndex(\"acl_rules\", \"acl_rules__doc_id\");\n    await queryRunner.dropIndex(\"group_groups\", \"group_groups__group_id\");\n    await queryRunner.dropIndex(\"group_groups\", \"group_groups__subgroup_id\");\n    await queryRunner.dropIndex(\"group_users\", \"group_users__group_id\");\n    await queryRunner.dropIndex(\"group_users\", \"group_users__user_id\");\n    await queryRunner.dropIndex(\"workspaces\", \"workspaces__org_id\");\n    await queryRunner.dropIndex(\"docs\", \"docs__workspace_id\");\n    await queryRunner.dropIndex(\"logins\", \"logins__user_id\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1556726945436-Billing.ts",
    "content": "import { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { BillingAccountManager } from \"app/gen-server/entity/BillingAccountManager\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Product } from \"app/gen-server/entity/Product\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, Table, TableColumn, TableForeignKey } from \"typeorm\";\n\nexport class Billing1556726945436 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // Create table for products.\n    await queryRunner.createTable(new Table({\n      name: \"products\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"integer\",\n          isGenerated: true,\n          generationStrategy: \"increment\",\n          isPrimary: true,\n        },\n        {\n          name: \"name\",\n          type: \"varchar\",\n        },\n        {\n          name: \"stripe_product_id\",\n          type: \"varchar\",\n          isUnique: true,\n          isNullable: true,\n        },\n        {\n          name: \"features\",\n          type: nativeValues.jsonType,\n        },\n      ],\n    }));\n\n    // Create a basic free product that existing orgs can use.\n    const product = new Product();\n    product.name = \"Free\";\n    product.features = {};\n    await queryRunner.manager.save(product);\n\n    // Create billing accounts and billing account managers.\n    await queryRunner.createTable(new Table({\n      name: \"billing_accounts\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"integer\",\n          isGenerated: true,\n          generationStrategy: \"increment\",\n          isPrimary: true,\n        },\n        {\n          name: \"product_id\",\n          type: \"integer\",\n        },\n        {\n          name: \"individual\",\n          type: nativeValues.booleanType,\n        },\n        {\n          name: \"in_good_standing\",\n          type: nativeValues.booleanType,\n          default: nativeValues.trueValue,\n        },\n        {\n          name: \"status\",\n          type: nativeValues.jsonType,\n          isNullable: true,\n        },\n        {\n          name: \"stripe_customer_id\",\n          type: \"varchar\",\n          isUnique: true,\n          isNullable: true,\n        },\n        {\n          name: \"stripe_subscription_id\",\n          type: \"varchar\",\n          isUnique: true,\n          isNullable: true,\n        },\n        {\n          name: \"stripe_plan_id\",\n          type: \"varchar\",\n          isNullable: true,\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"product_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"products\",\n        },\n      ],\n    }));\n\n    await queryRunner.createTable(new Table({\n      name: \"billing_account_managers\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"integer\",\n          isGenerated: true,\n          generationStrategy: \"increment\",\n          isPrimary: true,\n        },\n        {\n          name: \"billing_account_id\",\n          type: \"integer\",\n        },\n        {\n          name: \"user_id\",\n          type: \"integer\",\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"billing_account_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"billing_accounts\",\n          onDelete: \"CASCADE\",  // delete manager if referenced billing_account goes away\n        },\n        {\n          columnNames: [\"user_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"users\",\n          onDelete: \"CASCADE\",  // delete manager if referenced user goes away\n        },\n      ],\n    }));\n\n    // Add a reference to billing accounts from orgs.\n    await queryRunner.addColumn(\"orgs\", new TableColumn({\n      name: \"billing_account_id\",\n      type: \"integer\",\n      isNullable: true,\n    }));\n    await queryRunner.createForeignKey(\"orgs\", new TableForeignKey({\n      columnNames: [\"billing_account_id\"],\n      referencedColumnNames: [\"id\"],\n      referencedTableName: \"billing_accounts\",\n    }));\n\n    // Let's add billing accounts to all existing orgs.\n    // Personal orgs are put on an individual billing account.\n    // Other orgs are put on a team billing account, with the\n    // list of payment managers seeded by owners of that account.\n    const query =\n      queryRunner.manager.createQueryBuilder()\n        .select(\"orgs.id\")\n        .from(Organization, \"orgs\")\n        .leftJoin(\"orgs.owner\", \"owners\")\n        .addSelect(\"orgs.owner.id\")\n        .leftJoinAndSelect(\"orgs.aclRules\", \"acl_rules\")\n        .leftJoinAndSelect(\"acl_rules.group\", \"groups\")\n        .leftJoin(\"groups.memberUsers\", \"users\")\n        .addSelect(\"users.id\")\n        .where(\"permissions & 8 = 8\");  // seed managers with owners+editors, omitting guests+viewers\n    // (permission 8 is \"Remove\")\n    const orgs = await query.getMany();\n    for (const org of orgs) {\n      const individual = Boolean(org.owner);\n      const billingAccountInsert = await queryRunner.manager.createQueryBuilder()\n        .insert()\n        .into(BillingAccount)\n        .values([{ product, individual }])\n        .execute();\n      const billingAccountId = billingAccountInsert.identifiers[0].id;\n      if (individual) {\n        await queryRunner.manager.createQueryBuilder()\n          .insert()\n          .into(BillingAccountManager)\n          .values([{ billingAccountId, userId: org.owner.id }])\n          .execute();\n      } else {\n        for (const rule of org.aclRules) {\n          for (const user of rule.group.memberUsers) {\n            await queryRunner.manager.createQueryBuilder()\n              .insert()\n              .into(BillingAccountManager)\n              .values([{ billingAccountId, userId: user.id }])\n              .execute();\n          }\n        }\n      }\n      await queryRunner.manager.createQueryBuilder()\n        .update(Organization)\n        .set({ billingAccountId })\n        .where(\"id = :id\", { id: org.id })\n        .execute();\n    }\n\n    // TODO: in a future migration, orgs.billing_account_id could be constrained\n    // to be non-null.  All code deployments linked to a database that will be\n    // migrated must have code that sets orgs.billing_account_id by that time,\n    // otherwise they would fail to create orgs (and remember creating a user\n    // involves creating an org).\n    /*\n    // Now that all orgs have a billing account (and this migration is running within\n    // a transaction), we can constrain orgs.billing_account_id to be non-null.\n    const orgTable = (await queryRunner.getTable('orgs'))!;\n    const billingAccountId = orgTable.findColumnByName('billing_account_id')!;\n    const billingAccountIdNonNull = billingAccountId.clone();\n    billingAccountIdNonNull.isNullable = false;\n    await queryRunner.changeColumn('orgs', billingAccountId, billingAccountIdNonNull);\n    */\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    // this is a bit ugly, but is the documented way to remove a foreign key\n    const table = await queryRunner.getTable(\"orgs\");\n    const foreignKey = table!.foreignKeys.find(fk => fk.columnNames.includes(\"billing_account_id\"));\n    await queryRunner.dropForeignKey(\"orgs\", foreignKey!);\n\n    await queryRunner.dropColumn(\"orgs\", \"billing_account_id\");\n    await queryRunner.dropTable(\"billing_account_managers\");\n    await queryRunner.dropTable(\"billing_accounts\");\n    await queryRunner.dropTable(\"products\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1557157922339-OrgDomainUnique.ts",
    "content": "import { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class OrgDomainUnique1557157922339 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const logins = (await queryRunner.getTable(\"orgs\"))!;\n    const domain = logins.findColumnByName(\"domain\")!;\n    const domainUnique = domain.clone();\n    domainUnique.isUnique = true;\n    await queryRunner.changeColumn(\"orgs\", domain, domainUnique);\n\n    // On postgres, all of the above amounts to:\n    //   ALTER TABLE \"orgs\" ADD CONSTRAINT \"...\" UNIQUE (\"domain\")\n    // On sqlite, the table gets regenerated.\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    const logins = (await queryRunner.getTable(\"orgs\"))!;\n    const domain = logins.findColumnByName(\"domain\")!;\n    const domainNonUnique = domain.clone();\n    domainNonUnique.isUnique = false;\n    await queryRunner.changeColumn(\"orgs\", domain, domainNonUnique);\n\n    // On postgres, all of the above amount to:\n    //   ALTER TABLE \"orgs\" DROP CONSTRAINT \"...\"\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1561589211752-Aliases.ts",
    "content": "import { datetime, now } from \"app/gen-server/sqlUtils\";\n\nimport { MigrationInterface, QueryRunner, Table, TableColumn, TableIndex } from \"typeorm\";\n\nexport class Aliases1561589211752 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const dbType = queryRunner.connection.driver.options.type;\n\n    // Make a table for document aliases.\n    await queryRunner.createTable(new Table({\n      name: \"aliases\",\n      columns: [\n        {\n          name: \"url_id\",\n          type: \"varchar\",\n          isPrimary: true,\n        },\n        {\n          name: \"org_id\",\n          type: \"integer\",\n          isPrimary: true,\n        },\n        {\n          name: \"doc_id\",\n          type: \"varchar\",\n          isNullable: true,   // nullable in case in future we make aliases for other resources\n        },\n        {\n          name: \"created_at\",\n          type: datetime(dbType),\n          default: now(dbType),\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"doc_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"docs\",\n          onDelete: \"CASCADE\",  // delete alias if doc goes away\n        },\n        {\n          columnNames: [\"org_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"orgs\",\n          // no CASCADE set - let deletions be triggered via docs\n        },\n      ],\n    }));\n\n    // Add preferred alias to docs.  Not quite a foreign key (we'd need org as well)\n    await queryRunner.addColumn(\"docs\", new TableColumn({\n      name: \"url_id\",\n      type: \"varchar\",\n      isNullable: true,\n    }));\n    await queryRunner.createIndex(\"docs\", new TableIndex({\n      name: \"docs__url_id\",\n      columnNames: [\"url_id\"],\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropIndex(\"docs\", \"docs__url_id\");\n    await queryRunner.dropColumn(\"docs\", \"url_id\");\n    await queryRunner.dropTable(\"aliases\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1568238234987-TeamMembers.ts",
    "content": "import * as roles from \"app/common/roles\";\nimport { AclRuleOrg } from \"app/gen-server/entity/AclRule\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Permissions } from \"app/gen-server/lib/Permissions\";\nimport { getDatabaseType } from \"app/server/lib/dbUtils\";\n\nimport { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class TeamMembers1568238234987 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // Get all orgs and add a team member ACL (with group) to each.\n    const orgs = await queryRunner.manager.createQueryBuilder()\n      .select(\"orgs.id\")\n      .from(Organization, \"orgs\")\n      .getMany();\n    for (const org of orgs) {\n      // Don't use `manager.insert().into()` as the Group Entity contains properties that reference columns\n      // (like `type`) that don't exist yet and `insert()` attempts to set their values as well.\n      const groupInsertRes = await queryRunner.manager\n        .query(\"INSERT into groups(name) values($1) RETURNING id\", [roles.MEMBER]);\n      const groupId = getDatabaseType(queryRunner.connection) === \"postgres\" ?\n        groupInsertRes[0].id :\n        groupInsertRes;\n\n      await queryRunner.manager.createQueryBuilder()\n        .insert()\n        .into(AclRuleOrg)\n        .values([{\n          permissions: Permissions.VIEW,\n          organization: { id: org.id },\n          group: groupId,\n        }])\n        .execute();\n    }\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    // Remove all team member groups and corresponding ACLs.\n    const groups = await queryRunner.manager.createQueryBuilder()\n      .select(\"groups\")\n      .from(Group, \"groups\")\n      .where(\"name = :name\", { name: roles.MEMBER })\n      .getMany();\n    for (const group of groups) {\n      await queryRunner.manager.createQueryBuilder()\n        .delete()\n        .from(AclRuleOrg)\n        .where(\"group_id = :id\", { id: group.id })\n        .execute();\n    }\n    await queryRunner.manager.createQueryBuilder()\n      .delete()\n      .from(Group)\n      .where(\"name = :name\", { name: roles.MEMBER })\n      .execute();\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1569593726320-FirstLogin.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class FirstLogin1569593726320 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const sqlite = queryRunner.connection.driver.options.type === \"sqlite\";\n    const datetime = sqlite ? \"datetime\" : \"timestamp with time zone\";\n    await queryRunner.addColumn(\"users\", new TableColumn({\n      name: \"first_login_at\",\n      type: datetime,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"users\", \"first_login_at\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1569946508569-FirstTimeUser.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class FirstTimeUser1569946508569 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"users\", new TableColumn({\n      name: \"is_first_time_user\",\n      type: nativeValues.booleanType,\n      default: nativeValues.falseValue,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"users\", \"is_first_time_user\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1573569442552-CustomerIndex.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class CustomerIndex1573569442552 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createIndex(\"billing_accounts\", new TableIndex({\n      name: \"billing_accounts__stripe_customer_id\",\n      columnNames: [\"stripe_customer_id\"],\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropIndex(\"billing_accounts\", \"billing_accounts__stripe_customer_id\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1579559983067-ExtraIndexes.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class ExtraIndexes1579559983067 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createIndex(\"acl_rules\", new TableIndex({\n      name: \"acl_rules__group_id\",\n      columnNames: [\"group_id\"],\n    }));\n    await queryRunner.createIndex(\"orgs\", new TableIndex({\n      name: \"orgs__billing_account_id\",\n      columnNames: [\"billing_account_id\"],\n    }));\n    await queryRunner.createIndex(\"billing_account_managers\", new TableIndex({\n      name: \"billing_account_managers__billing_account_id\",\n      columnNames: [\"billing_account_id\"],\n    }));\n    await queryRunner.createIndex(\"billing_account_managers\", new TableIndex({\n      name: \"billing_account_managers__user_id\",\n      columnNames: [\"user_id\"],\n    }));\n    await queryRunner.createIndex(\"billing_accounts\", new TableIndex({\n      name: \"billing_accounts__product_id\",\n      columnNames: [\"product_id\"],\n    }));\n    await queryRunner.createIndex(\"aliases\", new TableIndex({\n      name: \"aliases__org_id\",\n      columnNames: [\"org_id\"],\n    }));\n    await queryRunner.createIndex(\"aliases\", new TableIndex({\n      name: \"aliases__doc_id\",\n      columnNames: [\"doc_id\"],\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropIndex(\"acl_rules\", \"acl_rules__group_id\");\n    await queryRunner.dropIndex(\"orgs\", \"orgs__billing_account_id\");\n    await queryRunner.dropIndex(\"billing_account_managers\", \"billing_account_managers__billing_account_id\");\n    await queryRunner.dropIndex(\"billing_account_managers\", \"billing_account_managers__user_id\");\n    await queryRunner.dropIndex(\"billing_accounts\", \"billing_accounts__product_id\");\n    await queryRunner.dropIndex(\"aliases\", \"aliases__org_id\");\n    await queryRunner.dropIndex(\"aliases\", \"aliases__doc_id\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1591755411755-OrgHost.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class OrgHost1591755411755 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"orgs\", new TableColumn({\n      name: \"host\",\n      type: \"varchar\",\n      isNullable: true,\n      isUnique: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"orgs\", \"host\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1592261300044-DocRemovedAt.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn, TableIndex } from \"typeorm\";\n\nexport class DocRemovedAt1592261300044 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    for (const table of [\"docs\", \"workspaces\"]) {\n      await queryRunner.addColumn(table, new TableColumn({\n        name: \"removed_at\",\n        type: nativeValues.dateTimeType,\n        isNullable: true,\n      }));\n      await queryRunner.createIndex(table, new TableIndex({\n        name: `${table}__removed_at`,\n        columnNames: [\"removed_at\"],\n      }));\n    }\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    for (const table of [\"docs\", \"workspaces\"]) {\n      await queryRunner.dropIndex(table, `${table}__removed_at`);\n      await queryRunner.dropColumn(table, \"removed_at\");\n    }\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1596456522124-Prefs.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class Prefs1596456522124 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(new Table({\n      name: \"prefs\",\n      columns: [\n        {\n          name: \"org_id\",\n          type: \"integer\",\n          isNullable: true,\n        },\n        {\n          name: \"user_id\",\n          type: \"integer\",\n          isNullable: true,\n        },\n        {\n          name: \"prefs\",\n          type: nativeValues.jsonType,\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"org_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"orgs\",\n          onDelete: \"CASCADE\",  // delete pref if linked to org that is deleted\n        },\n        {\n          columnNames: [\"user_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"users\",\n          onDelete: \"CASCADE\",  // delete pref if linked to user that is deleted\n        },\n      ],\n      indices: [\n        { columnNames: [\"org_id\", \"user_id\"] },\n        { columnNames: [\"user_id\"] },\n      ],\n      checks: [\n        // Make sure pref refers to something, either a user or an org or both\n        {\n          columnNames: [\"user_id\", \"org_id\"],\n          expression: \"COALESCE(user_id, org_id) IS NOT NULL\",\n        },\n      ],\n    }));\n    // Having trouble convincing TypeORM to create an index on expressions.\n    // Luckily, the SQL is identical for Sqlite and Postgres:\n    await queryRunner.manager.query(\n      'CREATE UNIQUE INDEX \"prefs__user_id__org_id\" ON \"prefs\" ' +\n      \"(COALESCE(user_id,0), COALESCE(org_id,0))\",\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"prefs\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1623871765992-ExternalBilling.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class ExternalBilling1623871765992 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"billing_accounts\", new TableColumn({\n      name: \"external_id\",\n      type: \"varchar\",\n      isNullable: true,\n      isUnique: true,\n    }));\n    await queryRunner.addColumn(\"billing_accounts\", new TableColumn({\n      name: \"external_options\",\n      type: nativeValues.jsonType,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"billing_accounts\", \"external_id\");\n    await queryRunner.dropColumn(\"billing_accounts\", \"external_options\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1626369037484-DocOptions.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class DocOptions1626369037484 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"docs\", new TableColumn({\n      name: \"options\",\n      type: nativeValues.jsonType,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"docs\", \"options\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1631286208009-Secret.ts",
    "content": "import { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class Secret1631286208009 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createTable(new Table({\n      name: \"secrets\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"varchar\",\n          isPrimary: true,\n        },\n        {\n          name: \"value\",\n          type: \"varchar\",\n        },\n        {\n          name: \"doc_id\",\n          type: \"varchar\",\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"doc_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"docs\",\n          onDelete: \"CASCADE\",  // delete secret if linked to doc that is deleted\n        },\n      ],\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"secrets\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1644363380225-UserOptions.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class UserOptions1644363380225 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"users\", new TableColumn({\n      name: \"options\",\n      type: nativeValues.jsonType,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"users\", \"options\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1647883793388-GracePeriodStart.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class GracePeriodStart1647883793388 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"docs\", new TableColumn({\n      name: \"grace_period_start\",\n      type: nativeValues.dateTimeType,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"docs\", \"grace_period_start\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1651469582887-DocumentUsage.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class DocumentUsage1651469582887 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"docs\", new TableColumn({\n      name: \"usage\",\n      type: nativeValues.jsonType,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"docs\", \"usage\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1652273656610-Activations.ts",
    "content": "import * as sqlUtils from \"app/gen-server/sqlUtils\";\n\nimport { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class Activations1652273656610 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // created_at and updated_at code is based on *-Initial.ts\n    const dbType = queryRunner.connection.driver.options.type;\n    const datetime = sqlUtils.datetime(dbType);\n    const now = sqlUtils.now(dbType);\n    await queryRunner.createTable(new Table({\n      name: \"activations\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"varchar\",\n          isPrimary: true,\n        },\n        {\n          name: \"key\",\n          type: \"varchar\",\n          isNullable: true,\n        },\n        {\n          name: \"created_at\",\n          type: datetime,\n          default: now,\n        },\n        {\n          name: \"updated_at\",\n          type: datetime,\n          default: now,\n        },\n      ] }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"activations\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1652277549983-UserConnectId.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from \"typeorm\";\n\nexport class UserConnectId1652277549983 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"users\", new TableColumn({\n      name: \"connect_id\",\n      type: \"varchar\",\n      isNullable: true,\n      isUnique: true,\n    }));\n    await queryRunner.createIndex(\"users\", new TableIndex({\n      name: \"users_connect_id\",\n      columnNames: [\"connect_id\"],\n      isUnique: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropIndex(\"users\", \"users_connect_id\");\n    await queryRunner.dropColumn(\"users\", \"connect_id\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1663851423064-UserUUID.ts",
    "content": "import { makeId } from \"app/server/lib/idUtils\";\n\nimport { chunk } from \"lodash\";\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class UserUUID1663851423064 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    // Add ref column, for now make it nullable and not unique, we\n    // first need to put a value in it for all existing users.\n    await queryRunner.addColumn(\"users\", new TableColumn({\n      name: \"ref\",\n      type: \"varchar\",\n      isNullable: true,\n      isUnique: false,\n    }));\n\n    // Updating so many rows in a multiple queries is not ideal. We will send updates in chunks.\n    // 300 seems to be a good number, for 24k rows we have 80 queries.\n    const userList = await queryRunner.manager.createQueryBuilder()\n      .select([\"users.id\", \"users.ref\"])\n      .from(\"users\", \"users\")\n      .getMany();\n    userList.forEach(u => u.ref = makeId());\n\n    const userChunks = chunk(userList, 300);\n    for (const users of userChunks) {\n      await queryRunner.connection.transaction(async (manager) => {\n        const queries = users.map((user: any, _index: number, _array: any[]) => {\n          return queryRunner.manager.update(\"users\", user.id, user);\n        });\n        await Promise.all(queries);\n      });\n    }\n\n    // We are not making this column unique yet, because it can fail\n    // if there are some old workers still running, and any new user\n    // is created. We will make it unique in a later migration.\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"users\", \"ref\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1664528376930-UserRefUnique.ts",
    "content": "import { makeId } from \"app/server/lib/idUtils\";\n\nimport { chunk } from \"lodash\";\nimport { MigrationInterface, QueryRunner } from \"typeorm\";\n\nexport class UserRefUnique1664528376930 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    // This is second part of migration 1663851423064-UserUUID, that makes\n    // the ref column unique.\n\n    // Update users that don't have unique ref set.\n    const userList = await queryRunner.manager.createQueryBuilder()\n      .select([\"users.id\", \"users.ref\"])\n      .from(\"users\", \"users\")\n      .where(\"users.ref is null\")\n      .getMany();\n    userList.forEach(u => u.ref = makeId());\n\n    const userChunks = chunk(userList, 300);\n    for (const users of userChunks) {\n      await queryRunner.connection.transaction(async (manager) => {\n        const queries = users.map((user: any, _index: number, _array: any[]) => {\n          return queryRunner.manager.update(\"users\", user.id, user);\n        });\n        await Promise.all(queries);\n      });\n    }\n\n    // Mark column as unique and non-nullable.\n    const users = (await queryRunner.getTable(\"users\"))!;\n    const oldRef = users.findColumnByName(\"ref\")!;\n    const newRef = oldRef.clone();\n    newRef.isUnique = true;\n    newRef.isNullable = false;\n    await queryRunner.changeColumn(\"users\", oldRef, newRef);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    // Mark column as non unique and nullable.\n    const users = (await queryRunner.getTable(\"users\"))!;\n    const oldRef = users.findColumnByName(\"ref\")!;\n    const newRef = oldRef.clone();\n    newRef.isUnique = false;\n    newRef.isNullable = true;\n    await queryRunner.changeColumn(\"users\", oldRef, newRef);\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1673051005072-Forks.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from \"typeorm\";\n\nexport class Forks1673051005072 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.addColumns(\"docs\", [\n      new TableColumn({\n        name: \"created_by\",\n        type: \"integer\",\n        isNullable: true,\n      }),\n      new TableColumn({\n        name: \"trunk_id\",\n        type: \"text\",\n        isNullable: true,\n      }),\n      new TableColumn({\n        name: \"type\",\n        type: \"text\",\n        isNullable: true,\n      }),\n    ]);\n\n    await queryRunner.createForeignKeys(\"docs\", [\n      new TableForeignKey({\n        columnNames: [\"created_by\"],\n        referencedTableName: \"users\",\n        referencedColumnNames: [\"id\"],\n        onDelete: \"CASCADE\",\n      }),\n      new TableForeignKey({\n        columnNames: [\"trunk_id\"],\n        referencedTableName: \"docs\",\n        referencedColumnNames: [\"id\"],\n        onDelete: \"CASCADE\",\n      }),\n    ]);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropColumns(\"docs\", [\"created_by\", \"trunk_id\", \"type\"]);\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1678737195050-ForkIndexes.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class ForkIndexes1678737195050 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    // HomeDBManager._onFork() references created_by in the ON clause.\n    await queryRunner.createIndex(\"docs\", new TableIndex({\n      name: \"docs__created_by\",\n      columnNames: [\"created_by\"],\n    }));\n    // HomeDBManager.getDocForks() references trunk_id in the WHERE clause.\n    await queryRunner.createIndex(\"docs\", new TableIndex({\n      name: \"docs__trunk_id\",\n      columnNames: [\"trunk_id\"],\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropIndex(\"docs\", \"docs__created_by\");\n    await queryRunner.dropIndex(\"docs\", \"docs__trunk_id\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1682636695021-ActivationPrefs.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class ActivationPrefs1682636695021 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"activations\", new TableColumn({\n      name: \"prefs\",\n      type: nativeValues.jsonType,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"activations\", \"prefs\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1685343047786-AssistantLimit.ts",
    "content": "import * as sqlUtils from \"app/gen-server/sqlUtils\";\n\nimport { MigrationInterface, QueryRunner, Table, TableIndex } from \"typeorm\";\n\nexport class AssistantLimit1685343047786 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    const dbType = queryRunner.connection.driver.options.type;\n    const datetime = sqlUtils.datetime(dbType);\n    const now = sqlUtils.now(dbType);\n    await queryRunner.createTable(\n      new Table({\n        name: \"limits\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"integer\",\n            isPrimary: true,\n            isGenerated: true,\n            generationStrategy: \"increment\",\n          },\n          {\n            name: \"type\",\n            type: \"varchar\",\n          },\n          {\n            name: \"billing_account_id\",\n            type: \"integer\",\n          },\n          {\n            name: \"limit\",\n            type: \"integer\",\n            default: 0,\n          },\n          {\n            name: \"usage\",\n            type: \"integer\",\n            default: 0,\n          },\n          {\n            name: \"created_at\",\n            type: datetime,\n            default: now,\n          },\n          {\n            name: \"changed_at\", // When the limit was last changed\n            type: datetime,\n            isNullable: true,\n          },\n          {\n            name: \"used_at\", // When the usage was last increased\n            type: datetime,\n            isNullable: true,\n          },\n          {\n            name: \"reset_at\", // When the usage was last reset.\n            type: datetime,\n            isNullable: true,\n          },\n        ],\n        foreignKeys: [\n          {\n            columnNames: [\"billing_account_id\"],\n            referencedTableName: \"billing_accounts\",\n            referencedColumnNames: [\"id\"],\n            onDelete: \"CASCADE\",\n          },\n        ],\n      }),\n    );\n\n    await queryRunner.createIndex(\n      \"limits\",\n      new TableIndex({\n        name: \"limits_billing_account_id\",\n        columnNames: [\"billing_account_id\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"limits\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1701557445716-Shares.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, Table, TableForeignKey, TableUnique } from \"typeorm\";\n\nexport class Shares1701557445716 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(new Table({\n      name: \"shares\",\n      columns: [\n        {\n          name: \"id\",\n          type: \"integer\",\n          isGenerated: true,\n          generationStrategy: \"increment\",\n          isPrimary: true,\n        },\n        {\n          name: \"key\",\n          type: \"varchar\",\n          isUnique: true,\n        },\n        {\n          name: \"doc_id\",\n          type: \"varchar\",\n        },\n        {\n          name: \"link_id\",\n          type: \"varchar\",\n        },\n        {\n          name: \"options\",\n          type: nativeValues.jsonType,\n        },\n      ],\n    }));\n    await queryRunner.createForeignKeys(\"shares\", [\n      new TableForeignKey({\n        columnNames: [\"doc_id\"],\n        referencedTableName: \"docs\",\n        referencedColumnNames: [\"id\"],\n        onDelete: \"CASCADE\", // delete share if doc goes away\n      }),\n    ]);\n    await queryRunner.createUniqueConstraints(\"shares\", [\n      new TableUnique({\n        columnNames: [\"doc_id\", \"link_id\"],\n      }),\n    ]);\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"shares\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1711557445716-Billing.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class Billing1711557445716 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.addColumn(\"billing_accounts\", new TableColumn({\n      name: \"features\",\n      type: nativeValues.jsonType,\n      isNullable: true,\n    }));\n\n    await queryRunner.addColumn(\"billing_accounts\", new TableColumn({\n      name: \"payment_link\",\n      type: nativeValues.jsonType,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"billing_accounts\", \"features\");\n    await queryRunner.dropColumn(\"billing_accounts\", \"payment_link\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1713186031023-UserLastConnection.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class UserLastConnection1713186031023 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const sqlite = queryRunner.connection.driver.options.type === \"sqlite\";\n    const datetime = sqlite ? \"datetime\" : \"timestamp with time zone\";\n    await queryRunner.addColumn(\"users\", new TableColumn({\n      name: \"last_connection_at\",\n      type: datetime,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"users\", \"last_connection_at\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1722529827161-Activation-Enabled.ts",
    "content": "import * as sqlUtils from \"app/gen-server/sqlUtils\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class ActivationEnabled1722529827161 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    const dbType = queryRunner.connection.driver.options.type;\n    const datetime = sqlUtils.datetime(dbType);\n    await queryRunner.addColumn(\"activations\", new TableColumn({\n      name: \"enabled_at\",\n      type: datetime,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropColumn(\"activations\", \"enabled_at\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1727747249153-Configs.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\nimport * as sqlUtils from \"app/gen-server/sqlUtils\";\n\nimport { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class Configs1727747249153 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const dbType = queryRunner.connection.driver.options.type;\n    const datetime = sqlUtils.datetime(dbType);\n    const now = sqlUtils.now(dbType);\n\n    await queryRunner.createTable(\n      new Table({\n        name: \"configs\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"integer\",\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"org_id\",\n            type: \"integer\",\n            isNullable: true,\n          },\n          {\n            name: \"key\",\n            type: \"varchar\",\n          },\n          {\n            name: \"value\",\n            type: nativeValues.jsonType,\n          },\n          {\n            name: \"created_at\",\n            type: datetime,\n            default: now,\n          },\n          {\n            name: \"updated_at\",\n            type: datetime,\n            default: now,\n          },\n        ],\n        foreignKeys: [\n          {\n            columnNames: [\"org_id\"],\n            referencedColumnNames: [\"id\"],\n            referencedTableName: \"orgs\",\n            onDelete: \"CASCADE\",\n          },\n        ],\n      }),\n    );\n\n    await queryRunner.manager.query(\n      'CREATE UNIQUE INDEX \"configs__key__org_id\" ON \"configs\" ' +\n      \"(key, COALESCE(org_id, 0))\",\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropTable(\"configs\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1729754662550-LoginsEmailIndex.ts",
    "content": "import { MigrationInterface, QueryRunner, TableIndex } from \"typeorm\";\n\nexport class LoginsEmailsIndex1729754662550 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.createIndex(\"logins\", new TableIndex({\n      name: \"logins__email\",\n      columnNames: [\"email\"],\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropIndex(\"logins\", \"logins__email\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1732103776245-GracePeriod.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class GracePeriod1732103776245 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.addColumn(\"activations\", new TableColumn({\n      name: \"grace_period_start\",\n      type: nativeValues.dateTimeType,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropColumn(\"activations\", \"grace_period_start\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1738912357827-UserCreatedAt.ts",
    "content": "import { datetime, now } from \"app/gen-server/sqlUtils\";\n\nimport { MigrationInterface, QueryRunner, TableColumn, TableIndex } from \"typeorm\";\n\nexport class UserCreatedAt1738912357827 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const dbType = queryRunner.connection.driver.options.type;\n\n    await queryRunner.addColumn(\"users\", new TableColumn({\n      name: \"created_at\",\n      type: datetime(dbType),\n      default: now(dbType),\n      isNullable: false,\n    }));\n\n    // Backfill created_at in the following order:\n    //  1. If a user has a personal org, use its created_at\n    //  2. If an activation has been minted, use its created_at\n    //\n    // If neither option works, leave the default value. (This should be exceedingly rare.)\n    const activation = await queryRunner\n      .manager\n      .createQueryBuilder()\n      .select([\"activations.created_at\"])\n      .from(\"activations\", \"activations\")\n      .getRawOne();\n    await queryRunner.query(\n      `UPDATE users\n      SET created_at = COALESCE(orgs.created_at, $1, users.created_at)\n      FROM (\n        SELECT u.id AS owner_id, o.created_at AS created_at\n        FROM users u\n        LEFT JOIN orgs o\n        ON u.id = o.owner_id\n      ) AS orgs\n      WHERE users.id = orgs.owner_id;`,\n      [activation?.created_at ?? null],\n    );\n\n    await queryRunner.createIndex(\"users\", new TableIndex({\n      name: \"users__created_at\",\n      columnNames: [\"created_at\"],\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"users\", \"created_at\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1746246433628-DocPref.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, Table, TableForeignKey } from \"typeorm\";\n\nexport class DocPref1746246433628 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.createTable(new Table({\n      name: \"doc_prefs\",\n      columns: [\n        { name: \"doc_id\", type: \"varchar\" },\n        { name: \"user_id\", type: \"integer\", isNullable: true },\n        { name: \"prefs\", type: nativeValues.jsonType },\n      ],\n      foreignKeys: [\n        new TableForeignKey({\n          columnNames: [\"doc_id\"],\n          referencedTableName: \"docs\",\n          referencedColumnNames: [\"id\"],\n          onDelete: \"CASCADE\",    // delete pref if the linked-to doc is deleted\n        }),\n        new TableForeignKey({\n          columnNames: [\"user_id\"],\n          referencedTableName: \"users\",\n          referencedColumnNames: [\"id\"],\n          onDelete: \"CASCADE\",    // delete pref if the linked-to user is deleted\n        }),\n      ],\n    }));\n\n    // To create an index on expression (used here to ensure a unique (doc_id, NULL) combination),\n    // can't use TypeORM, so use direct SQL (identical for Sqlite and Postgres).\n    await queryRunner.query(\n      'CREATE UNIQUE INDEX \"doc_prefs__doc_id__user_id\" ON \"doc_prefs\" ' +\n      \"(doc_id, COALESCE(user_id, 0))\",\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"doc_prefs\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1749454162428-GroupUsersCreatedAt.ts",
    "content": "import * as sqlUtils from \"app/gen-server/sqlUtils\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class GroupUsersCreatedAt1749454162428 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const dbType = queryRunner.connection.driver.options.type;\n    const datetime = sqlUtils.datetime(dbType);\n    const now = sqlUtils.now(dbType);\n    await queryRunner.addColumn(\"group_users\", new TableColumn({\n      name: \"created_at\",\n      comment: \"When the user has been added to the associated group. This column is not exposed to the ORM.\",\n      type: datetime,\n      default: now,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"group_users\", \"created_at\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1753088213255-GroupTypes.ts",
    "content": "import { Group } from \"app/gen-server/entity/Group\";\n\nimport { MigrationInterface, QueryRunner, TableColumn, TableIndex } from \"typeorm\";\n\nexport class GroupTypes1753088213255 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const newColumn = new TableColumn({\n      name: \"type\",\n      type: \"varchar\",\n      enum: [Group.ROLE_TYPE, Group.TEAM_TYPE],\n      comment: `If the type is ${Group.ROLE_TYPE}, the group is meant to assign a role to ` +\n        \"users for a resource (document, workspace or org).\" +\n        \"\\n\\n\" +\n        `If the type is \"${Group.TEAM_TYPE}\", the group is meant to gather users together ` +\n        \"so they can be granted the same access (through a role) to some resources.\",\n      isNullable: true, // Make it not nullable after setting the roles for existing groups\n    });\n\n    await queryRunner.addColumn(\"groups\", newColumn);\n\n    await queryRunner.manager\n      .query(\"UPDATE groups SET type = $1\", [Group.ROLE_TYPE]);\n\n    const newColumnNonNull = newColumn.clone();\n    newColumnNonNull.isNullable = false;\n\n    await queryRunner.changeColumn(\"groups\", newColumn, newColumnNonNull);\n\n    // Add a unique index on name to ensure that a team name is unique\n    await queryRunner.createIndex(\"groups\", new TableIndex({\n      name: \"team_name_unique\",\n      columnNames: [\"name\"],\n      isUnique: true,\n      where: `groups.type = '${Group.TEAM_TYPE}'`,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropIndex(\"groups\", \"team_name_unique\");\n    await queryRunner.dropColumn(\"groups\", \"type\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1754077317821-UserDisabledAt.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class UserDisabledAt1754077317821 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.addColumn(\"users\", new TableColumn({\n      name: \"disabled_at\",\n      type: nativeValues.dateTimeType,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropColumn(\"users\", \"disabled_at\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1756799894986-UserUnsubscribeKey.ts",
    "content": "import { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\n/**\n * Adds an unsubscribe_key column to the users table. Used to create and verity links' signatures in emails.\n */\nexport class UserUnsubscribeKey1756799894986 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.addColumn(\"users\", new TableColumn({\n      name: \"unsubscribe_key\",\n      type: \"varchar\",\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropColumn(\"users\", \"unsubscribe_key\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1756918816559-ServiceAccounts.ts",
    "content": "import { User } from \"app/gen-server/entity/User\";\nimport { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, Table, TableColumn } from \"typeorm\";\n\nexport class ServiceAccounts1756918816559 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<any> {\n    const userTypeColumnTemp = new TableColumn({\n      name: \"type\",\n      type: \"varchar\",\n      enum: [User.LOGIN_TYPE, User.SERVICE_TYPE],\n      isNullable: true,\n    });\n\n    await queryRunner.addColumn(\"users\", userTypeColumnTemp);\n\n    await queryRunner.manager\n      .query(\"UPDATE users SET type = $1\", [User.LOGIN_TYPE]);\n\n    const userTypeColumnNonNull = userTypeColumnTemp.clone();\n    userTypeColumnNonNull.isNullable = false;\n\n    await queryRunner.changeColumn(\"users\", userTypeColumnTemp, userTypeColumnNonNull);\n\n    await queryRunner.createTable(\n      new Table({\n        name: \"service_accounts\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"integer\",\n            isGenerated: true,\n            generationStrategy: \"increment\",\n            isPrimary: true,\n          },\n          {\n            name: \"owner_id\",\n            type: \"integer\",\n          },\n          {\n            name: \"service_user_id\",\n            type: \"integer\",\n            isNullable: false,\n            isUnique: true,\n          },\n          {\n            name: \"label\",\n            type: \"varchar\",\n            isNullable: true,\n          },\n          {\n            name: \"description\",\n            type: \"varchar\",\n            isNullable: true,\n          },\n          {\n            name: \"expires_at\",\n            type: nativeValues.dateTimeType,\n            isNullable: false,\n          },\n        ],\n        foreignKeys: [\n          {\n            columnNames: [\"service_user_id\"],\n            referencedColumnNames: [\"id\"],\n            referencedTableName: \"users\",\n            onDelete: \"CASCADE\",\n          },\n          {\n            columnNames: [\"owner_id\"],\n            referencedColumnNames: [\"id\"],\n            referencedTableName: \"users\",\n            onDelete: \"CASCADE\",\n          },\n        ],\n        indices: [\n          {\n            name: \"service_account__service_user_id\",\n            columnNames: [\"service_user_id\"],\n          },\n          {\n            name: \"service_account__owner_id\",\n            columnNames: [\"owner_id\"],\n          },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<any> {\n    await queryRunner.dropColumn(\"users\", \"type\");\n    await queryRunner.dropTable(\"service_accounts\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1759256005608-Proposals.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\nimport * as sqlUtils from \"app/gen-server/sqlUtils\";\n\nimport { MigrationInterface, QueryRunner, Table, TableUnique } from \"typeorm\";\n\nexport class Proposals1759256005608 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    const dbType = queryRunner.connection.driver.options.type;\n    const datetime = sqlUtils.datetime(dbType);\n    const now = sqlUtils.now(dbType);\n    // The primary key for this table is a composite of the\n    // dest_doc_id (the document receiving the proposal)\n    // and the short_id (this is an id scoped to the\n    // destination document, starting at 1 for the first\n    // proposal made to that document). Note that short_id\n    // is not globally unique, it is only unique within\n    // the scope of the document. It exists for github\n    // pull request numbering aesthetics.\n    await queryRunner.createTable(new Table({\n      name: \"proposals\",\n      columns: [\n        {\n          name: \"dest_doc_id\",\n          type: \"varchar\",\n          isPrimary: true,\n        },\n        {\n          name: \"short_id\",\n          type: \"integer\",\n          isPrimary: true,\n        },\n        {\n          name: \"src_doc_id\",\n          type: \"varchar\",\n        },\n        {\n          name: \"comparison\",\n          type: nativeValues.jsonType,\n        },\n        {\n          name: \"status\",\n          type: nativeValues.jsonType,\n        },\n        {\n          name: \"created_at\",\n          type: datetime,\n          default: now,\n        },\n        {\n          name: \"updated_at\",\n          type: datetime,\n          default: now,\n        },\n        {\n          name: \"applied_at\",\n          type: datetime,\n          isNullable: true,\n        },\n      ],\n      foreignKeys: [\n        {\n          columnNames: [\"src_doc_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"docs\",\n          onDelete: \"CASCADE\",\n        },\n        {\n          columnNames: [\"dest_doc_id\"],\n          referencedColumnNames: [\"id\"],\n          referencedTableName: \"docs\",\n          onDelete: \"CASCADE\",\n        },\n      ],\n    }));\n    // This constraint is currently true, but perhaps\n    // could be removed in the future.\n    await queryRunner.createUniqueConstraint(\n      \"proposals\",\n      new TableUnique({\n        name: \"proposals__dest_doc_id__src_doc_id\",\n        columnNames: [\"dest_doc_id\", \"src_doc_id\"],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"proposals\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1759434763338-DocDisabledAt.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\n\nimport { MigrationInterface, QueryRunner, TableColumn } from \"typeorm\";\n\nexport class DocDisabledAt1759434763338 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.addColumn(\"docs\", new TableColumn({\n      name: \"disabled_at\",\n      type: nativeValues.dateTimeType,\n      isNullable: true,\n    }));\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropColumn(\"docs\", \"disabled_at\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/1764872085347-OAuthClientsAndGrants.ts",
    "content": "import { nativeValues } from \"app/gen-server/lib/values\";\nimport * as sqlUtils from \"app/gen-server/sqlUtils\";\n\nimport { MigrationInterface, QueryRunner, Table } from \"typeorm\";\n\nexport class OAuthClientsAndGrants1764872085347 implements MigrationInterface {\n  public async up(queryRunner: QueryRunner): Promise<void> {\n    const dbType = queryRunner.connection.driver.options.type;\n    const datetime = sqlUtils.datetime(dbType);\n    const now = sqlUtils.now(dbType);\n\n    await queryRunner.createTable(\n      new Table({\n        name: \"oauth_clients\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"varchar\",\n            isPrimary: true,\n          },\n          // Core client properties.\n          //\n          // Schema is dynamic and dictated by the `oidc-provider` library.\n          // See https://github.com/panva/node-oidc-provider/blob/main/example/my_adapter.js#L41-L42).\n          //\n          // Use jsonb if the TypeORM driver supports it. It trades INSERT performance\n          // and format preservation for better modification performance and stronger\n          // validations. For most use cases, it's a better default, and what other\n          // `oidc-provider` adapter implementations also use.\n          {\n            name: \"payload\",\n            type: nativeValues.jsonbType,\n          },\n          // Clients are currently owned by personal orgs. In the future, we will want to expand\n          // this to include other orgs, to support management of clients within a team. Doing so\n          // shouldn't require a new migration.\n          {\n            name: \"org_id\",\n            type: \"integer\",\n          },\n          {\n            name: \"created_at\",\n            type: datetime,\n            default: now,\n          },\n          {\n            name: \"updated_at\",\n            type: datetime,\n            default: now,\n          },\n        ],\n        foreignKeys: [\n          {\n            columnNames: [\"org_id\"],\n            referencedColumnNames: [\"id\"],\n            referencedTableName: \"orgs\",\n            // Delete clients when their orgs are deleted.\n            onDelete: \"CASCADE\",\n          },\n        ],\n      }),\n    );\n\n    await queryRunner.createTable(\n      new Table({\n        name: \"oauth_grants\",\n        columns: [\n          {\n            name: \"id\",\n            type: \"varchar\",\n            isPrimary: true,\n          },\n          // Core grant properties.\n          //\n          // Schema is dynamic and dictated by the `oidc-provider` library.\n          // See https://github.com/panva/node-oidc-provider/blob/main/example/my_adapter.js#L41-L42).\n          {\n            name: \"payload\",\n            type: nativeValues.jsonbType,\n          },\n          // Grants are issued for a particular client.\n          {\n            name: \"oauth_client_id\",\n            type: \"varchar\",\n          },\n          // Grants are issued to a particular user.\n          {\n            name: \"issued_to_user_id\",\n            type: \"integer\",\n          },\n          {\n            name: \"created_at\",\n            type: datetime,\n            default: now,\n          },\n          {\n            name: \"updated_at\",\n            type: datetime,\n            default: now,\n          },\n        ],\n        foreignKeys: [\n          {\n            columnNames: [\"oauth_client_id\"],\n            referencedColumnNames: [\"id\"],\n            referencedTableName: \"oauth_clients\",\n            // Delete grants when their clients are deleted.\n            onDelete: \"CASCADE\",\n          },\n          {\n            columnNames: [\"issued_to_user_id\"],\n            referencedColumnNames: [\"id\"],\n            referencedTableName: \"users\",\n            // Delete grants when their users are deleted.\n            onDelete: \"CASCADE\",\n          },\n        ],\n        indices: [\n          // A particular client-user may only have one grant, which encompasses all\n          // scopes granted to the client on behalf of the user.\n          { columnNames: [\"oauth_client_id\", \"issued_to_user_id\"], isUnique: true },\n        ],\n      }),\n    );\n  }\n\n  public async down(queryRunner: QueryRunner): Promise<void> {\n    await queryRunner.dropTable(\"oauth_grants\");\n    await queryRunner.dropTable(\"oauth_clients\");\n  }\n}\n"
  },
  {
    "path": "app/gen-server/migration/README.md",
    "content": "# Migrations\n\n## Generate migrations\n\nTo generate new migration using `TypeORM` cli, run the following command in\nthis directory:\n\n```bash\nnpx typeorm migration:create MigrationName\n```\n\nAnd then add this migration to test/gen-server/migrations.ts\n\n## Rename Migrations after a rebase\n\nMigrations files are prefixed by a Timestamp.\nIt is in an `Epoch` Timestamp with milliseconds.\nThe migration you are working on must always be the latest one.\n\nTo obtain current date as Epoch with millisecond,\nYou can run :\n\n```bash\ndate +%s%N | cut -b1-13\n```\n"
  },
  {
    "path": "app/gen-server/sqlUtils.ts",
    "content": "import { DatabaseType, ObjectLiteral, QueryRunner, SelectQueryBuilder } from \"typeorm\";\nimport { RelationCountLoader } from \"typeorm/query-builder/relation-count/RelationCountLoader\";\nimport { RelationIdLoader } from \"typeorm/query-builder/relation-id/RelationIdLoader\";\nimport { RawSqlResultsToEntityTransformer } from \"typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer\";\n\n/**\n *\n * Generates an expression to simulate postgres's bit_or\n * aggregate function in sqlite.  The expression is verbose,\n * and has a term for each bit in the permission bitmap,\n * but this seems ok since sqlite is only used in the dev\n * environment.\n * @param column: the sql column to aggregate\n * @param bits: the maximum number of bits to consider\n *\n */\nexport function sqliteBitOr(column: string, bits: number): string {\n  const parts: string[] = [];\n  let mask: number = 1;\n  for (let b = 0; b < bits; b++) {\n    parts.push(`((sum(${column}&${mask})>0)<<${b})`);\n    mask *= 2;\n  }\n  return `(${parts.join(\"+\")})`;\n}\n\n/**\n * Generates an expression to aggregate the named column\n * by taking the bitwise-or of all the values it takes on.\n * @param dbType: the type of database (sqlite and postgres are supported)\n * @param column: the sql column to aggregate\n * @param bits: the maximum number of bits to consider (used for sqlite variant)\n */\nexport function bitOr(dbType: DatabaseType, column: string, bits: number): string {\n  switch (dbType) {\n    case \"postgres\":\n      return `bit_or(${column})`;\n    case \"sqlite\":\n      return sqliteBitOr(column, bits);\n    default:\n      throw new Error(`bitOr not implemented for ${dbType}`);\n  }\n}\n\n/**\n * Checks if a set of columns contains only the given ids (or null).\n * Uses array containment operator on postgres (with array_remove to deal with nulls),\n * and a clunkier syntax for sqlite.\n */\nexport function hasOnlyTheseIdsOrNull(dbType: DatabaseType, ids: number[], columns: string[]): string {\n  switch (dbType) {\n    case \"postgres\":\n      return `array[${ids.join(\",\")}] @> array_remove(array[${columns.join(\",\")}],null)`;\n    case \"sqlite\":\n      return columns.map(col => `coalesce(${col} in (${ids.join(\",\")}), true)`).join(\" AND \");\n    default:\n      throw new Error(`hasOnlyTheseIdsOrNull not implemented for ${dbType}`);\n  }\n}\n\n/**\n * Checks if at least one of a set of ids is present in a set of columns.\n * There must be at least one id and one column.\n * Uses the intersection operator on postgres, and a clunkier syntax for sqlite.\n */\nexport function hasAtLeastOneOfTheseIds(dbType: DatabaseType, ids: number[], columns: string[]): string {\n  switch (dbType) {\n    case \"postgres\":\n      return `array[${ids.join(\",\")}] OPERATOR(pg_catalog.&&) array[${columns.join(\",\")}]`;\n    case \"sqlite\":\n      return ids.map(id => `${id} in (${columns.join(\",\")})`).join(\" OR \");\n    default:\n      throw new Error(`hasAtLeastOneOfTheseIds not implemented for ${dbType}`);\n  }\n}\n\n/**\n * Return SQL for aggregating supplied SQL content into a JSON array.\n */\nexport function makeJsonArray(dbType: DatabaseType, content: string): string {\n  switch (dbType) {\n    case \"postgres\": return `json_agg(${content})`;\n    case \"sqlite\": return `json_group_array(${content})`;\n    default: throw new Error(`makeJsonArray not implemented for ${dbType}`);\n  }\n}\n\n/**\n * Convert a json value returned by the database into a javascript\n * object.  For postgres, the value is already unpacked, but for sqlite\n * it is a string.\n */\nexport function readJson(dbType: DatabaseType, selection: any) {\n  switch (dbType) {\n    case \"postgres\":\n      return selection;\n    case \"sqlite\":\n      return JSON.parse(selection);\n    default:\n      throw new Error(`readJson not implemented for ${dbType}`);\n  }\n}\n\nexport function now(dbType: DatabaseType) {\n  switch (dbType) {\n    case \"postgres\":\n      return \"now()\";\n    case \"sqlite\":\n      return \"datetime('now')\";\n    default:\n      throw new Error(`now not implemented for ${dbType}`);\n  }\n}\n\n// Understands strings like: \"-30 days\" or \"1 year\"\nexport function fromNow(dbType: DatabaseType, relative: string) {\n  switch (dbType) {\n    case \"postgres\":\n      return `(now() + interval '${relative}')`;\n    case \"sqlite\":\n      return `datetime('now','${relative}')`;\n    default:\n      throw new Error(`fromNow not implemented for ${dbType}`);\n  }\n}\n\nexport function datetime(dbType: DatabaseType) {\n  switch (dbType) {\n    case \"postgres\":\n      return \"timestamp with time zone\";\n    case \"sqlite\":\n      return \"datetime\";\n    default:\n      throw new Error(`now not implemented for ${dbType}`);\n  }\n}\n\n/**\n *\n * Generate SQL code from one QueryBuilder, get the \"raw\" results, and then decode\n * them as entities using a different QueryBuilder.\n *\n * This is useful for example if we have a query Q and we wish to add\n * a where clause referring to one of the query's selected columns by\n * its alias.  This isn't supported by Postgres (since the SQL\n * standard says not to).  A simple solution is to wrap Q in a query\n * like \"SELECT * FROM (Q) WHERE ...\".  But if we do that in TypeORM,\n * it loses track of metadata and isn't able to decode the results,\n * even though nothing has changed structurally.  Hence this method.\n *\n * (An alternate solution to this scenario is to simply duplicate the\n * SQL code for the selected column in the where clause.  But our SQL\n * queries are getting awkwardly long.)\n *\n * The results are returned in the same format as SelectQueryBuilder's\n * getRawAndEntities.\n */\nexport async function getRawAndEntities<T extends ObjectLiteral>(rawQueryBuilder: SelectQueryBuilder<any>,\n  nominalQueryBuilder: SelectQueryBuilder<T>): Promise<{\n  entities: T[],\n  raw: any[],\n}> {\n  const raw = await rawQueryBuilder.getRawMany();\n\n  // The following code is based on SelectQueryBuilder's\n  // executeEntitiesAndRawResults.  To extract and use it here, we\n  // need to access the QueryBuilder's QueryRunner, which is\n  // protected, so we break abstraction a little bit.\n  const runnerSource = nominalQueryBuilder as any as QueryRunnerSource;\n  const queryRunner = runnerSource.obtainQueryRunner();\n  try {\n    const expressionMap = nominalQueryBuilder.expressionMap;\n    const connection = nominalQueryBuilder.connection;\n    const relationIdLoader = new RelationIdLoader(connection, queryRunner, expressionMap.relationIdAttributes);\n    const relationCountLoader = new RelationCountLoader(connection, queryRunner, expressionMap.relationCountAttributes);\n    const rawRelationIdResults = await relationIdLoader.load(raw);\n    const rawRelationCountResults = await relationCountLoader.load(raw);\n    const transformer = new RawSqlResultsToEntityTransformer(expressionMap, connection.driver,\n      rawRelationIdResults, rawRelationCountResults,\n      queryRunner);\n    const entities = transformer.transform(raw, expressionMap.mainAlias!);\n    return {\n      entities,\n      raw,\n    };\n  } finally {\n    // This is how the QueryBuilder <-> QueryRunner relationship is managed in TypeORM code.\n    if (queryRunner !== runnerSource.queryRunner) {\n      await queryRunner.release();\n    }\n  }\n}\n\n/**\n * QueryBuilders keep track of a runner that we need for getRawAndEntities,\n * but access is protected.  This interface declared the fields we expect.\n */\ninterface QueryRunnerSource {\n  queryRunner: QueryRunner;\n  obtainQueryRunner(): QueryRunner;\n}\n"
  },
  {
    "path": "app/plugin/CustomSectionAPI-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const ColumnToMap = t.iface([], {\n  \"name\": \"string\",\n  \"title\": t.opt(t.union(\"string\", \"null\")),\n  \"description\": t.opt(t.union(\"string\", \"null\")),\n  \"type\": t.opt(\"string\"),\n  \"optional\": t.opt(\"boolean\"),\n  \"allowMultiple\": t.opt(\"boolean\"),\n  \"strictType\": t.opt(\"boolean\"),\n});\n\nexport const ColumnsToMap = t.array(t.union(\"string\", \"ColumnToMap\"));\n\nexport const InteractionOptionsRequest = t.iface([], {\n  \"requiredAccess\": t.opt(\"string\"),\n  \"hasCustomOptions\": t.opt(\"boolean\"),\n  \"columns\": t.opt(\"ColumnsToMap\"),\n  \"allowSelectBy\": t.opt(\"boolean\"),\n});\n\nexport const InteractionOptions = t.iface([], {\n  \"accessLevel\": \"string\",\n});\n\nexport const WidgetColumnMap = t.iface([], {\n  [t.indexKey]: t.union(\"string\", t.array(\"string\"), \"null\"),\n});\n\nexport const CustomSectionAPI = t.iface([], {\n  \"configure\": t.func(\"void\", t.param(\"customOptions\", \"InteractionOptionsRequest\")),\n  \"mappings\": t.func(t.union(\"WidgetColumnMap\", \"null\")),\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  ColumnToMap,\n  ColumnsToMap,\n  InteractionOptionsRequest,\n  InteractionOptions,\n  WidgetColumnMap,\n  CustomSectionAPI,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/CustomSectionAPI.ts",
    "content": "/**\n * API definitions for CustomSection plugins.\n */\n\nexport interface ColumnToMap {\n  /**\n   * Column name that Widget expects. Must be a valid JSON property name.\n   */\n  name: string;\n  /**\n   * Title or short description of a column (used as a label in section mapping).\n   */\n  title?: string | null,\n  /**\n   * Optional long description of a column (used as a help text in section mapping).\n   */\n  description?: string | null,\n  /**\n   * Column types (as comma separated list), by default \"Any\", what means that any type is\n   * allowed (unless strictType is true).\n   */\n  type?: string, // GristType, TODO: ts-interface-checker doesn't know how to parse this\n  /**\n   * Mark column as optional all columns are required by default.\n   */\n  optional?: boolean\n  /**\n   * Allow multiple column assignment, the result will be list of mapped table column names.\n   */\n  allowMultiple?: boolean,\n  /**\n   * Match column type strictly, so \"Any\" will require \"Any\" and not any other type.\n   */\n  strictType?: boolean,\n}\n\n/**\n * Tells Grist what columns a Custom Widget expects and allows users to map between existing column names\n * and those requested by the Custom Widget.\n */\nexport type ColumnsToMap = (string | ColumnToMap)[];\n\n/**\n * Initial message sent by the CustomWidget with initial requirements.\n */\nexport interface InteractionOptionsRequest {\n  /**\n   * Required access level. If it wasn't granted already, Grist will prompt user to change the current access\n   * level.\n   */\n  requiredAccess?: string,\n  /**\n   * Instructs Grist to show additional menu options that will trigger onEditOptions callback, that Widget\n   * can use to show custom options screen.\n   */\n  hasCustomOptions?: boolean,\n  /**\n   * Tells Grist what columns Custom Widget expects and allows user to map between existing column names\n   * and those requested by Custom Widget.\n   */\n  columns?: ColumnsToMap,\n  /**\n   * Show widget as linking source.\n   */\n  allowSelectBy?: boolean,\n}\n\n/**\n * Widget configuration set and approved by Grist, sent as part of ready message.\n */\nexport interface InteractionOptions {\n  /**\n   * Granted access level.\n   */\n  accessLevel: string,\n}\n\n/**\n * Current columns mapping between viewFields in section and Custom widget.\n */\nexport interface WidgetColumnMap {\n  [key: string]: string | string[] | null\n}\n\n/**\n * Interface for the mapping of a custom widget.\n */\nexport interface CustomSectionAPI {\n  /**\n   * Initial request from a Custom Widget that wants to declare its requirements.\n   */\n  configure(customOptions: InteractionOptionsRequest): Promise<void>;\n  /**\n   * Returns current widget configuration (if requested through configuration method).\n   */\n  mappings(): Promise<WidgetColumnMap | null>;\n}\n"
  },
  {
    "path": "app/plugin/DocApiTypes-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const NewRecord = t.iface([], {\n  \"fields\": t.opt(t.iface([], {\n    [t.indexKey]: \"CellValue\",\n  })),\n});\n\nexport const NewRecordWithStringId = t.iface([], {\n  \"id\": t.opt(\"string\"),\n  \"fields\": t.opt(t.iface([], {\n    [t.indexKey]: \"CellValue\",\n  })),\n});\n\nexport const Record = t.iface([], {\n  \"id\": \"number\",\n  \"fields\": t.iface([], {\n    [t.indexKey]: \"CellValue\",\n  }),\n});\n\nexport const RecordWithStringId = t.iface([], {\n  \"id\": \"string\",\n  \"fields\": t.iface([], {\n    [t.indexKey]: \"CellValue\",\n  }),\n});\n\nexport const AddOrUpdateRecord = t.iface([], {\n  \"require\": t.intersection(t.iface([], {\n    [t.indexKey]: \"CellValue\",\n  }), t.iface([], {\n    \"id\": t.opt(\"number\"),\n  })),\n  \"fields\": t.opt(t.iface([], {\n    [t.indexKey]: \"CellValue\",\n  })),\n});\n\nexport const RecordsPatch = t.iface([], {\n  \"records\": t.tuple(\"Record\", t.rest(t.array(\"Record\"))),\n});\n\nexport const RecordsPost = t.iface([], {\n  \"records\": t.tuple(\"NewRecord\", t.rest(t.array(\"NewRecord\"))),\n});\n\nexport const RecordsPut = t.iface([], {\n  \"records\": t.tuple(\"AddOrUpdateRecord\", t.rest(t.array(\"AddOrUpdateRecord\"))),\n});\n\nexport const RecordId = t.name(\"number\");\n\nexport const MinimalRecord = t.iface([], {\n  \"id\": \"number\",\n});\n\nexport const ColumnsPost = t.iface([], {\n  \"columns\": t.tuple(\"NewRecordWithStringId\", t.rest(t.array(\"NewRecordWithStringId\"))),\n});\n\nexport const ColumnsPatch = t.iface([], {\n  \"columns\": t.tuple(\"RecordWithStringId\", t.rest(t.array(\"RecordWithStringId\"))),\n});\n\nexport const ColumnsPut = t.iface([], {\n  \"columns\": t.tuple(\"RecordWithStringId\", t.rest(t.array(\"RecordWithStringId\"))),\n});\n\nexport const TablePost = t.iface([\"ColumnsPost\"], {\n  \"id\": t.opt(\"string\"),\n});\n\nexport const ColumnMetadata = t.iface([], {\n  \"id\": \"string\",\n  \"fields\": t.iface([], {\n    \"colRef\": \"number\",\n    \"label\": \"string\",\n    \"isFormula\": \"boolean\",\n    [t.indexKey]: \"CellValue\",\n  }),\n});\n\nexport const TableMetadata = t.iface([], {\n  \"id\": \"string\",\n  \"fields\": t.iface([], {\n    \"tableRef\": \"number\",\n    [t.indexKey]: \"CellValue\",\n  }),\n  \"columns\": t.opt(t.array(\"ColumnMetadata\")),\n});\n\nexport const TablesGet = t.iface([], {\n  \"tables\": t.tuple(\"TableMetadata\", t.rest(t.array(\"TableMetadata\"))),\n});\n\nexport const TablesPost = t.iface([], {\n  \"tables\": t.tuple(\"TablePost\", t.rest(t.array(\"TablePost\"))),\n});\n\nexport const TablesPatch = t.iface([], {\n  \"tables\": t.tuple(\"RecordWithStringId\", t.rest(t.array(\"RecordWithStringId\"))),\n});\n\nexport const SqlPost = t.iface([], {\n  \"sql\": \"string\",\n  \"args\": t.opt(t.union(t.array(\"any\"), \"null\")),\n  \"timeout\": t.opt(\"number\"),\n});\n\nexport const SetAttachmentStorePost = t.iface([], {\n  \"type\": \"AttachmentStore\",\n});\n\nexport const AttachmentStore = t.union(t.lit(\"internal\"), t.lit(\"external\"));\n\nexport const AttachmentStoreDesc = t.iface([], {\n  \"label\": \"string\",\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  NewRecord,\n  NewRecordWithStringId,\n  Record,\n  RecordWithStringId,\n  AddOrUpdateRecord,\n  RecordsPatch,\n  RecordsPost,\n  RecordsPut,\n  RecordId,\n  MinimalRecord,\n  ColumnsPost,\n  ColumnsPatch,\n  ColumnsPut,\n  TablePost,\n  ColumnMetadata,\n  TableMetadata,\n  TablesGet,\n  TablesPost,\n  TablesPatch,\n  SqlPost,\n  SetAttachmentStorePost,\n  AttachmentStore,\n  AttachmentStoreDesc,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/DocApiTypes.ts",
    "content": "import { CellValue } from \"app/plugin/GristData\";\n\n/**\n * JSON schema for api /record endpoint. Used in POST method for adding new records.\n */\nexport interface NewRecord {\n  /**\n   * Initial values of cells in record. Optional, if not set cells are left\n   * blank.\n   */\n  fields?: { [colId: string]: CellValue };\n}\n\nexport interface NewRecordWithStringId {\n  id?: string;  // tableId or colId\n  /**\n   * Initial values of cells in record. Optional, if not set cells are left\n   * blank.\n   */\n  fields?: { [colId: string]: CellValue };\n}\n\n/**\n * JSON schema for api /record endpoint. Used in PATCH method for updating existing records.\n */\nexport interface Record {\n  id: number;\n  fields: { [colId: string]: CellValue };\n}\n\nexport interface RecordWithStringId {\n  id: string;  // tableId or colId\n  fields: { [colId: string]: CellValue };\n}\n\n/**\n * JSON schema for api /record endpoint. Used in PUT method for adding or updating records.\n */\nexport interface AddOrUpdateRecord {\n  /**\n   * The values we expect to have in particular columns, either by matching with\n   * an existing record, or creating a new record.\n   */\n  require: { [colId: string]: CellValue } & { id?: number };\n\n  /**\n   * The values we will place in particular columns, either overwriting values in\n   * an existing record, or setting initial values in a new record.\n   */\n  fields?: { [colId: string]: CellValue };\n}\n\n/**\n * JSON schema for the body of api /record PATCH endpoint\n */\nexport interface RecordsPatch {\n  records: [Record, ...Record[]]; // at least one record is required\n}\n\n/**\n * JSON schema for the body of api /record POST endpoint\n */\nexport interface RecordsPost {\n  records: [NewRecord, ...NewRecord[]]; // at least one record is required\n}\n\n/**\n * JSON schema for the body of api /record PUT endpoint\n */\nexport interface RecordsPut {\n  records: [AddOrUpdateRecord, ...AddOrUpdateRecord[]]; // at least one record is required\n}\n\nexport type RecordId = number;\n\n/**\n * The row id of a record, without any of its content.\n */\nexport interface MinimalRecord {\n  id: number\n}\n\nexport interface ColumnsPost {\n  columns: [NewRecordWithStringId, ...NewRecordWithStringId[]]; // at least one column is required\n}\n\nexport interface ColumnsPatch {\n  columns: [RecordWithStringId, ...RecordWithStringId[]]; // at least one column is required\n}\n\nexport interface ColumnsPut {\n  columns: [RecordWithStringId, ...RecordWithStringId[]]; // at least one column is required\n}\n\n/**\n * Creating tables requires a list of columns.\n * `fields` is not accepted because it's not generally sensible to set the metadata fields on new tables.\n */\nexport interface TablePost extends ColumnsPost {\n  id?: string;\n}\n\nexport interface ColumnMetadata {\n  id: string;\n  fields: {\n    colRef: number;\n    label: string;\n    isFormula: boolean;\n    [colId: string]: CellValue;\n  };\n}\n\nexport interface TableMetadata {\n  id: string;\n  fields: {\n    tableRef: number;\n    [colId: string]: CellValue;\n  };\n  columns?: ColumnMetadata[];\n}\n\nexport interface TablesGet {\n  tables: [TableMetadata, ...TableMetadata[]];\n}\n\nexport interface TablesPost {\n  tables: [TablePost, ...TablePost[]]; // at least one table is required\n}\n\nexport interface TablesPatch {\n  tables: [RecordWithStringId, ...RecordWithStringId[]]; // at least one table is required\n}\n\n/**\n * JSON schema for the body of api /sql POST endpoint\n */\nexport interface SqlPost {\n  sql: string;\n  args?: any[] | null; // (It would be nice to support named parameters, but\n  // that feels tricky currently with node-sqlite3)\n  timeout?: number;    // In msecs. Can only be reduced from server default,\n  // not increased. Note timeout of a query could affect\n  // other queued queries on same document, because of\n  // limitations of API node-sqlite3 exposes.\n}\n\nexport interface SetAttachmentStorePost {\n  type: AttachmentStore\n}\n\nexport type AttachmentStore = \"internal\" | \"external\";\n\nexport interface AttachmentStoreDesc {\n  label: string;\n}\n"
  },
  {
    "path": "app/plugin/FileParserAPI-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const EditOptionsAPI = t.iface([], {\n  \"getParseOptions\": t.func(\"ParseOptions\", t.param(\"parseOptions\", \"ParseOptions\", true)),\n});\n\nexport const ParseFileAPI = t.iface([], {\n  \"parseFile\": t.func(\"ParseFileResult\", t.param(\"file\", \"FileSource\"), t.param(\"parseOptions\", \"ParseOptions\", true)),\n});\n\nexport const ParseOptions = t.iface([], {\n  \"NUM_ROWS\": t.opt(\"number\"),\n  \"SCHEMA\": t.opt(t.array(\"ParseOptionSchema\")),\n  \"WARNING\": t.opt(\"string\"),\n});\n\nexport const ParseOptionSchema = t.iface([], {\n  \"name\": \"string\",\n  \"type\": \"string\",\n  \"visible\": \"boolean\",\n});\n\nexport const FileSource = t.iface([], {\n  \"path\": \"string\",\n  \"pathFlavor\": t.union(t.lit(\"windows\"), t.lit(\"posix\")),\n  \"origName\": \"string\",\n});\n\nexport const ParseFileResult = t.iface([\"GristTables\"], {\n  \"parseOptions\": \"ParseOptions\",\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  EditOptionsAPI,\n  ParseFileAPI,\n  ParseOptions,\n  ParseOptionSchema,\n  FileSource,\n  ParseFileResult,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/FileParserAPI.ts",
    "content": "/**\n * API definitions for FileParser plugins.\n */\n\nimport { GristTables } from \"app/plugin/GristTable\";\n\nexport interface EditOptionsAPI {\n  getParseOptions(parseOptions?: ParseOptions): Promise<ParseOptions>;\n}\n\nexport interface ParseFileAPI {\n  parseFile(file: FileSource, parseOptions?: ParseOptions): Promise<ParseFileResult>;\n}\n\n/**\n * ParseOptions contains parse options depending on plugin,\n * number of rows, which is special option that can be used for any plugin\n * and schema for generating parse options UI\n */\nexport interface ParseOptions {\n  NUM_ROWS?: number;\n  SCHEMA?: ParseOptionSchema[];\n  WARNING?: string;     // Only on response, includes a warning from parsing, if any.\n}\n\n/**\n * ParseOptionSchema contains information for generaing parse options UI\n */\nexport interface ParseOptionSchema {\n  name: string;\n  type: string;\n  visible: boolean;\n}\n\nexport interface FileSource {\n  /**\n   * The path is often a temporary file, so its name is meaningless. Access to the file depends on\n   * the type of plugin. For instance, for `safePython` plugins file is directly available at\n   * `/importDir/path`.\n   */\n  path: string;\n\n  /**\n   * The platform-specific flavor of the path stored in the {path} property.\n   * Certain sandboxed environments may have a different path convention to the local OS.\n   * E.g. Pyodide (WASM Python in Node/Deno) uses POSIX paths, even on a Windows host OS.\n   * This enables the path above to be interpreted correctly / converted.\n   */\n  pathFlavor: \"windows\" | \"posix\";\n\n  /**\n   * Plugins that want to know the original filename should use origName. Depending on the source\n   * of the data, it may or may not be meaningful.\n   */\n  origName: string;\n}\n\nexport interface ParseFileResult extends GristTables {\n  parseOptions: ParseOptions;\n}\n"
  },
  {
    "path": "app/plugin/GristAPI-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const UIRowId = t.union(\"number\", t.lit(\"new\"));\n\nexport const CursorPos = t.iface([], {\n  \"rowId\": t.opt(\"UIRowId\"),\n  \"rowIndex\": t.opt(\"number\"),\n  \"fieldIndex\": t.opt(\"number\"),\n  \"sectionId\": t.opt(\"number\"),\n  \"linkingRowIds\": t.opt(t.array(\"UIRowId\")),\n});\n\nexport const ComponentKind = t.union(t.lit(\"safeBrowser\"), t.lit(\"safePython\"), t.lit(\"unsafeNode\"));\n\nexport const GristAPI = t.iface([], {\n  \"render\": t.func(\"number\", t.param(\"path\", \"string\"), t.param(\"target\", \"RenderTarget\"), t.param(\"options\", \"RenderOptions\", true)),\n  \"dispose\": t.func(\"void\", t.param(\"procId\", \"number\")),\n  \"subscribe\": t.func(\"void\", t.param(\"tableId\", \"string\")),\n  \"unsubscribe\": t.func(\"void\", t.param(\"tableId\", \"string\")),\n});\n\nexport const GristDocAPI = t.iface([], {\n  \"getDocName\": t.func(\"string\"),\n  \"listTables\": t.func(t.array(\"string\")),\n  \"fetchTable\": t.func(\"any\", t.param(\"tableId\", \"string\")),\n  \"applyUserActions\": t.func(\"any\", t.param(\"actions\", t.array(t.array(\"any\"))), t.param(\"options\", \"any\", true)),\n  \"getAccessToken\": t.func(\"AccessTokenResult\", t.param(\"options\", \"AccessTokenOptions\")),\n});\n\nexport const CellFormatType = t.union(t.lit(\"normal\"), t.lit(\"typed\"));\n\nexport const FetchSelectedOptions = t.iface([], {\n  \"keepEncoded\": t.opt(\"boolean\"),\n  \"format\": t.opt(t.union(t.lit(\"rows\"), t.lit(\"columns\"))),\n  \"includeColumns\": t.opt(t.union(t.lit(\"shown\"), t.lit(\"normal\"), t.lit(\"all\"))),\n  \"expandRefs\": t.opt(\"boolean\"),\n  \"cellFormat\": t.opt(\"CellFormatType\"),\n});\n\nexport const GristView = t.iface([], {\n  \"fetchSelectedTable\": t.func(\"any\", t.param(\"options\", \"FetchSelectedOptions\", true)),\n  \"fetchSelectedRecord\": t.func(\"any\", t.param(\"rowId\", \"number\"), t.param(\"options\", \"FetchSelectedOptions\", true)),\n  \"allowSelectBy\": t.func(\"void\"),\n  \"setSelectedRows\": t.func(\"void\", t.param(\"rowIds\", t.union(t.array(\"number\"), \"null\"))),\n  \"setCursorPos\": t.func(\"void\", t.param(\"pos\", \"CursorPos\")),\n});\n\nexport const AccessTokenOptions = t.iface([], {\n  \"readOnly\": t.opt(\"boolean\"),\n});\n\nexport const AccessTokenResult = t.iface([], {\n  \"token\": \"string\",\n  \"baseUrl\": \"string\",\n  \"ttlMsecs\": \"number\",\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  UIRowId,\n  CursorPos,\n  ComponentKind,\n  GristAPI,\n  GristDocAPI,\n  CellFormatType,\n  FetchSelectedOptions,\n  GristView,\n  AccessTokenOptions,\n  AccessTokenResult,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/GristAPI.ts",
    "content": "/**\n * This file defines the interface for the grist api exposed to SafeBrowser plugins. Grist supports\n * various ways to require it to cover various scenarios. If writing the main safeBrowser module\n * (the one referenced by the components.safeBrowser key of the manifest) use\n * `self.importScript('grist');`, if writing a view include the script in the html `<script src=\"grist\"></script>`\n *\n *\n * Example usage (let's assume that Grist let's plugin contributes to a Foo API defined as follow ):\n *\n * interface Foo {\n *   foo(name: string): Promise<string>;\n * }\n *\n * > main.ts:\n * class MyFoo {\n *   public foo(name: string): Promise<string> {\n *     return new Promise<string>( async resolve => {\n *       grist.rpc.onMessage( e => {\n *         resolve(e.data + name);\n *       });\n *       grist.ready();\n *       await grist.api.render('view1.html', 'fullscreen');\n *     });\n *   }\n * }\n * grist.rpc.registerImpl<Foo>('grist', new MyFoo()); // can add 3rd arg with type information\n *\n * > view1.html includes:\n * grist.api.render('static/view2.html', 'fullscreen').then( view => {\n *   grist.rpc.onMessage(e => grist.rpc.postMessageForward(\"main.ts\", e.data));\n * });\n *\n * > view2.html includes:\n * grist.rpc.postMessage('view1.html', 'foo ');\n *\n */\n\nimport { RenderOptions, RenderTarget } from \"app/plugin/RenderOptions\";\n\n// This is the row ID used in the client, but it's helpful to have available in some common code\n// as well, which is why it's declared here. Note that for data actions and stored data,\n// 'new' is not used.\n/**\n * Represents the id of a row in a table. The value of the `id` column. Might be a number or 'new' value for a new row.\n */\nexport type UIRowId = number | \"new\";\n\n/**\n * Represents the position of an active cursor on a page.\n */\nexport interface CursorPos {\n  /**\n   * The rowId (value of the `id` column) of the current cursor position, or 'new' if the cursor is on a new row.\n   */\n  rowId?: UIRowId;\n  /**\n   * The index of the current row in the current view.\n   */\n  rowIndex?: number;\n  /**\n   * The index of the selected field in the current view.\n   */\n  fieldIndex?: number;\n  /**\n   * The id of a section that this cursor is in. Ignored when setting a cursor position for a particular view.\n   */\n  sectionId?: number;\n  /**\n   * When in a linked section, CursorPos may include which rows in the controlling sections are\n   * selected: the rowId in the linking-source section, in _that_ section's linking source, etc.\n   */\n  linkingRowIds?: UIRowId[];\n}\n\nexport type ComponentKind = \"safeBrowser\" | \"safePython\" | \"unsafeNode\";\n\nexport const RPC_GRISTAPI_INTERFACE = \"_grist_api\";\n\nexport interface GristAPI {\n  /**\n   * Render the file at `path` into the `target` location in Grist. `path` must be relative to the\n   * root of the plugin's directory and point to an html that is contained within the plugin's\n   * directory. `target` is a predefined location of the Grist UI, it could be `fullscreen` or\n   * identifier for an inline target. Grist provides inline target identifiers in certain call\n   * plugins. E.g. ImportSourceAPI.getImportSource is given a target identifier to allow rende UI\n   * inline in the import dialog. Returns the procId which can be used to dispose the view.\n   */\n  render(path: string, target: RenderTarget, options?: RenderOptions): Promise<number>;\n\n  /**\n   * Dispose the process with id procId. If the process was embedded into the UI, removes the\n   * corresponding element from the view.\n   */\n  dispose(procId: number): Promise<void>;\n\n  // Subscribes to actions for `tableId`. Actions of all subscribed tables are send as rpc's\n  // message.\n  // TODO: document format of messages that can be listened on `rpc.onMessage(...);`\n  subscribe(tableId: string): Promise<void>;\n\n  // Unsubscribe from actions for `tableId`.\n  unsubscribe(tableId: string): Promise<void>;\n\n}\n\n/**\n * Allows getting information from and interacting with the Grist document to which a plugin or widget is attached.\n */\nexport interface GristDocAPI {\n  /**\n   * Returns an identifier for the document.\n   */\n  getDocName(): Promise<string>;\n\n  /**\n   * Returns a sorted list of table IDs.\n   */\n  listTables(): Promise<string[]>;\n\n  /**\n   * Returns a complete table of data as {@link GristData.RowRecords | GristData.RowRecords}, including the\n   * 'id' column. Do not modify the returned arrays in-place, especially if used\n   * directly (not over RPC).\n   */\n  fetchTable(tableId: string): Promise<any>;\n  // TODO: return type is Promise{[colId: string]: CellValue[]}> but cannot be specified\n  // because ts-interface-builder does not properly support index-signature.\n\n  /**\n   * Applies an array of user actions.\n   */\n  applyUserActions(actions: any[][], options?: any): Promise<any>;\n  // TODO: return type should be Promise<ApplyUAResult>, but this requires importing\n  // modules from `app/common` which is not currently supported by the build.\n\n  /**\n   * Get a token for out-of-band access to the document.\n   */\n  getAccessToken(options: AccessTokenOptions): Promise<AccessTokenResult>;\n}\n\n/**\n * CellFormatType determines how each cell value is represented.\n * - \"normal\" -- the usual encoding used in Grist, which depends on the column type (e.g. a Date\n *   is secondsSinceEpoch, and a Ref is an integer (rowId).\n * - \"typed\" -- represents each value in the same format as in the \"normal\" format for \"Any\"\n *   columns, e.g. a Date is [\"d\", secondsSinceEpoch] and RefList is [\"r\", TableId, [rodIds, ...]].\n *   Exceptions are also returned as [\"E\", TypeName...] values, even for /records endpoints.\n *\n * When omitted, REST API uses \"normal\" format, while Custom Widget API uses an in-between format\n * for backward compatibility: it represents most values as \"typed\", but RefList and Attachments\n * as \"normal\".\n */\nexport type CellFormatType = \"normal\" | \"typed\";\n\n/**\n * Options for functions which fetch data from the selected table or record:\n *\n * - {@link onRecords}\n * - {@link onRecord}\n * - {@link fetchSelectedRecord}\n * - {@link fetchSelectedTable}\n * - {@link GristView.fetchSelectedRecord | GristView.fetchSelectedRecord}\n * - {@link GristView.fetchSelectedTable | GristView.fetchSelectedTable}\n *\n * The different methods have different default values for `keepEncoded` and `format`.\n **/\nexport interface FetchSelectedOptions {\n  /**\n   * - `true`: the returned data will contain raw {@link GristData.CellValue}'s.\n   * - `false`: the values will be decoded, replacing e.g. `['D', timestamp]` with a moment date.\n   */\n  keepEncoded?: boolean;\n\n  /**\n   * - `rows`, the returned data will be an array of objects, one per row, with column names as keys.\n   * - `columns`, the returned data will be an object with column names as keys, and arrays of values.\n   */\n  format?: \"rows\" | \"columns\";\n\n  /**\n   * - `shown` (default): return only columns that are explicitly shown\n   *   in the right panel configuration of the widget. This is the only value that doesn't require full access.\n   * - `normal`: return all 'normal' columns, regardless of whether the user has shown them.\n   * - `all`: also return special invisible columns like `manualSort` and display helper columns.\n   */\n  includeColumns?: \"shown\" | \"normal\" | \"all\";\n\n  /**\n   * - `true` (default): the returned data will show the contents of references, not their rowIds\n   * - `false`: the returned data will only display rowIds for references\n   *\n   * Setting `cellFormat: \"typed\"` changes the default to false.\n   */\n  expandRefs?: boolean\n\n  /**\n   * How each cell's value is represented. See CellFormatType.\n   */\n  cellFormat?: CellFormatType;\n}\n\n/**\n * Interface for the data backing a single widget.\n */\nexport interface GristView {\n  /**\n   * Like {@link GristDocAPI.fetchTable | GristDocAPI.fetchTable},\n   * but gets data for the custom section specifically, if there is any.\n   * By default, `options.keepEncoded` is `true` and `format` is `columns`.\n   */\n  fetchSelectedTable(options?: FetchSelectedOptions): Promise<any>;\n\n  /**\n   * Fetches selected record by its `rowId`. By default, `options.keepEncoded` is `true`.\n   */\n  fetchSelectedRecord(rowId: number, options?: FetchSelectedOptions): Promise<any>;\n  // TODO: return type is Promise{[colId: string]: CellValue}> but cannot be specified\n  // because ts-interface-builder does not properly support index-signature.\n\n  /**\n   * Deprecated now. It was used for filtering selected table by `setSelectedRows` method.\n   * Now the preferred way it to use ready message.\n   */\n  allowSelectBy(): Promise<void>;\n\n  /**\n   * Set the list of selected rows to be used against any linked widget.\n   */\n  setSelectedRows(rowIds: number[] | null): Promise<void>;\n\n  /**\n   * Sets the cursor position to a specific row and field. `sectionId` is ignored. Used for widget linking.\n   */\n  setCursorPos(pos: CursorPos): Promise<void>\n}\n\n/**\n * Options when creating access tokens.\n */\nexport interface AccessTokenOptions {\n  /** Restrict use of token to reading only */\n  readOnly?: boolean;\n}\n\n/**\n * Access token information, including the token string itself, a base URL for\n * API calls for which the access token can be used, and the time-to-live the\n * token was created with.\n */\nexport interface AccessTokenResult {\n  /**\n   * The token string, which can currently be provided in an api call as a\n   * query parameter called \"auth\"\n   */\n  token: string;\n\n  /**\n   * The base url of the API for which the token can be used. Currently tokens\n   * are associated with a single document, so the base url will be something\n   * like `https://..../api/docs/DOCID`\n   *\n   * Access tokens currently only grant access to endpoints dealing with the\n   * internal content of a document (such as tables and cells) and not its\n   * metadata (such as the document name or who it is shared with).\n   */\n  baseUrl: string;\n\n  /**\n   * Number of milliseconds the access token will remain valid for\n   * after creation. This will be several minutes.\n   */\n  ttlMsecs: number;\n}\n"
  },
  {
    "path": "app/plugin/GristData-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const GristObjCode = t.enumtype({\n  \"List\": \"L\",\n  \"LookUp\": \"l\",\n  \"Dict\": \"O\",\n  \"DateTime\": \"D\",\n  \"Date\": \"d\",\n  \"Skip\": \"S\",\n  \"Censored\": \"C\",\n  \"Reference\": \"R\",\n  \"ReferenceList\": \"r\",\n  \"Exception\": \"E\",\n  \"Pending\": \"P\",\n  \"Unmarshallable\": \"U\",\n  \"Versions\": \"V\",\n});\n\nexport const CellValue = t.union(\"number\", \"string\", \"boolean\", \"null\", t.tuple(\"GristObjCode\", t.rest(t.array(\"unknown\"))));\n\nexport const BulkColValues = t.iface([], {\n  [t.indexKey]: t.array(\"CellValue\"),\n});\n\nexport const RowRecord = t.iface([], {\n  \"id\": \"number\",\n  [t.indexKey]: \"CellValue\",\n});\n\nexport const RowRecords = t.iface([], {\n  \"id\": t.array(\"number\"),\n  [t.indexKey]: t.array(\"CellValue\"),\n});\n\nexport const GristType = t.union(t.lit(\"Any\"), t.lit(\"Attachments\"), t.lit(\"Blob\"), t.lit(\"Bool\"), t.lit(\"Choice\"), t.lit(\"ChoiceList\"), t.lit(\"Date\"), t.lit(\"DateTime\"), t.lit(\"Id\"), t.lit(\"Int\"), t.lit(\"ManualSortPos\"), t.lit(\"Numeric\"), t.lit(\"PositionNumber\"), t.lit(\"Ref\"), t.lit(\"RefList\"), t.lit(\"Text\"));\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  GristObjCode,\n  CellValue,\n  BulkColValues,\n  RowRecord,\n  RowRecords,\n  GristType,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/GristData.ts",
    "content": "/**\n * Letter codes for {@link CellValue} types encoded as [code, args...] tuples.\n */\nexport enum GristObjCode {\n  List            = \"L\",\n  LookUp          = \"l\",\n  Dict            = \"O\",\n  DateTime        = \"D\",\n  Date            = \"d\",\n  Skip            = \"S\",\n  Censored        = \"C\",\n  Reference       = \"R\",\n  ReferenceList   = \"r\",\n  Exception       = \"E\",\n  Pending         = \"P\",\n  Unmarshallable  = \"U\",\n  Versions        = \"V\",\n}\n\n/**\n * Possible types of cell content.\n *\n * Each `CellValue` may either be a primitive (e.g. `true`, `123`, `\"hello\"`, `null`)\n * or a tuple (JavaScript Array) representing a Grist object. The first element of the tuple\n * is a string character representing the object code. For example, `[\"L\", \"foo\", \"bar\"]`\n * is a `CellValue` of a Choice List column, where `\"L\"` is the type, and `\"foo\"` and\n * `\"bar\"` are the choices.\n *\n * ### Grist Object Types\n *\n * | Code | Type           |\n * | ---- | -------------- |\n * | `L`  | List, e.g. `[\"L\", \"foo\", \"bar\"]` or `[\"L\", 1, 2]` |\n * | `l`  | LookUp, as `[\"l\", value, options]` |\n * | `O`  | Dict, as `[\"O\", {key: value, ...}]` |\n * | `D`  | DateTimes, as `[\"D\", timestamp, timezone]`, e.g. `[\"D\", 1704945919, \"UTC\"]` |\n * | `d`  | Date, as `[\"d\", timestamp]`, e.g. `[\"d\", 1704844800]` |\n * | `C`  | Censored, as `[\"C\"]` |\n * | `R`  | Reference, as `[\"R\", table_id, row_id]`, e.g. `[\"R\", \"People\", 17]` |\n * | `r`  | ReferenceList, as `[\"r\", table_id, row_id_list]`, e.g. `[\"r\", \"People\", [1,2]]` |\n * | `E`  | Exception, as `[\"E\", name, ...]`, e.g. `[\"E\", \"ValueError\"]` |\n * | `P`  | Pending, as `[\"P\"]` |\n * | `U`  | Unmarshallable, as `[\"U\", text_representation]` |\n * | `V`  | Version, as `[\"V\", version_obj]` |\n */\nexport type CellValue = number | string | boolean | null | [GristObjCode, ...unknown[]];\n\nexport interface BulkColValues { [colId: string]: CellValue[]; }\n\n/**\n * Map of column ids to {@link CellValue}'s.\n */\nexport interface RowRecord {\n  id: number;\n  [colId: string]: CellValue;\n}\n\n/**\n * Map of column ids to {@link CellValue} arrays, where array indexes correspond to\n * rows.\n */\nexport interface RowRecords {\n  id: number[];\n  [colId: string]: CellValue[];\n}\n\nexport type GristType = \"Any\" | \"Attachments\" | \"Blob\" | \"Bool\" | \"Choice\" | \"ChoiceList\" |\n  \"Date\" | \"DateTime\" |\n  \"Id\" | \"Int\" | \"ManualSortPos\" | \"Numeric\" | \"PositionNumber\" | \"Ref\" | \"RefList\" | \"Text\";\n"
  },
  {
    "path": "app/plugin/GristTable-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const GristTable = t.iface([], {\n  \"table_name\": t.union(\"string\", \"null\"),\n  \"column_metadata\": t.array(\"GristColumn\"),\n  \"table_data\": t.array(t.array(\"any\")),\n});\n\nexport const GristTables = t.iface([], {\n  \"tables\": t.array(\"GristTable\"),\n});\n\nexport const GristColumn = t.iface([], {\n  \"id\": \"string\",\n  \"type\": \"string\",\n});\n\nexport const APIType = t.enumtype({\n  \"ImportSourceAPI\": 0,\n  \"ImportProcessorAPI\": 1,\n  \"ParseOptionsAPI\": 2,\n  \"ParseFileAPI\": 3,\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  GristTable,\n  GristTables,\n  GristColumn,\n  APIType,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/GristTable.ts",
    "content": "/**\n * Common definitions for Grist plugin APIs.\n */\n\n/**\n * Metadata and data for a table.\n */\nexport interface GristTable {\n  // This is documenting what is currently returned by the core plugins. Capitalization\n  // is python-style.\n  //\n  // TODO: could be worth reconciling with: /documentation/grist-data-format.md.\n  table_name: string | null;  // currently allow names to be null\n  column_metadata: GristColumn[];\n  table_data: any[][];\n}\n\nexport interface GristTables {\n  tables: GristTable[];\n}\n\n/**\n * Metadata about a single column.\n */\nexport interface GristColumn {\n  id: string;\n  type: string;\n}\n\nexport enum APIType {\n  ImportSourceAPI,\n  ImportProcessorAPI,\n  ParseOptionsAPI,\n  ParseFileAPI,\n}\n"
  },
  {
    "path": "app/plugin/ImportSourceAPI-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const ImportSourceAPI = t.iface([], {\n  \"getImportSource\": t.func(t.union(\"ImportSource\", \"undefined\")),\n});\n\nexport const ImportProcessorAPI = t.iface([], {\n  \"processImport\": t.func(t.array(\"GristTable\"), t.param(\"source\", \"ImportSource\")),\n});\n\nexport const FileContent = t.iface([], {\n  \"content\": \"any\",\n  \"name\": \"string\",\n});\n\nexport const FileListItem = t.iface([], {\n  \"kind\": t.lit(\"fileList\"),\n  \"files\": t.array(\"FileContent\"),\n});\n\nexport const URL = t.iface([], {\n  \"kind\": t.lit(\"url\"),\n  \"url\": \"string\",\n});\n\nexport const ImportSource = t.iface([], {\n  \"item\": t.union(\"FileListItem\", \"URL\"),\n  \"options\": t.opt(t.union(\"string\", \"Buffer\")),\n  \"description\": t.opt(\"string\"),\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  ImportSourceAPI,\n  ImportProcessorAPI,\n  FileContent,\n  FileListItem,\n  URL,\n  ImportSource,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/ImportSourceAPI.ts",
    "content": "/**\n * API definitions for ImportSource plugins.\n */\n\nimport { GristTable } from \"app/plugin/GristTable\";\n\nexport interface ImportSourceAPI {\n  /**\n   * Returns a promise that resolves to an `ImportSource` which is then passed for import to the\n   * import modal dialog. `undefined` interrupts the workflow and prevent the modal from showing up,\n   * but not an empty list of `ImportSourceItem`. Which is a valid import source and is used in\n   * cases where only options are to be sent to an `ImportProcessAPI` implementation.\n   */\n  getImportSource(): Promise<ImportSource | undefined>;\n}\n\nexport interface ImportProcessorAPI {\n  processImport(source: ImportSource): Promise<GristTable[]>;\n}\n\nexport interface FileContent {\n  content: any;\n  name: string;\n}\n\nexport interface FileListItem {\n  kind: \"fileList\";\n  // TODO: there're might be a better way to send file content. In particular for electron where\n  // file will then be send from client to server where it shouldn't be really. An idea could be to\n  // expose something similar to `client/lib/upload.ts` to let plugins create upload entries, and\n  // then send only uploads ids over the rpc.\n  files: FileContent[];\n}\n\nexport interface URL {\n  kind: \"url\";\n  url: string;\n}\n\nexport interface ImportSource {\n  item: FileListItem | URL;\n\n  /**\n   * The options are only passed within this plugin, nothing else needs to know how they are\n   * serialized. Using JSON.stringify/JSON.parse is a simple approach.\n   */\n  options?: string | Buffer;\n\n  /**\n   * The short description that shows in the import dialog after source have been selected.\n   */\n  description?: string;\n}\n"
  },
  {
    "path": "app/plugin/InternalImportSourceAPI-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const InternalImportSourceAPI = t.iface([], {\n  \"getImportSource\": t.func(t.union(\"ImportSource\", \"undefined\"), t.param(\"inlineTarget\", \"RenderTarget\")),\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  InternalImportSourceAPI,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/InternalImportSourceAPI.ts",
    "content": "import { ImportSource } from \"app/plugin/ImportSourceAPI\";\nimport { RenderTarget } from \"app/plugin/RenderOptions\";\n\nexport * from  \"app/plugin/ImportSourceAPI\";\n\n/**\n * This internal interface is implemented by grist-plugin-api.ts to support\n * `grist.addImporter(...)`. This is this interface that grist stubs to calls\n * `ImportSourceAPI`. However, some of the complexity (ie: rendering targets) is hidden from the\n * plugin author which implements directly the simpler `ImportSourceAPI`.\n *\n * Reason for this interface is because we want to have the `inlineTarget` parameter but we don't\n * want plugin author to have it.\n */\nexport interface InternalImportSourceAPI {\n  /**\n   * The `inlineTarget` argument which will be passed to the implementation of this method, can be\n   * used as follow `grist.api.render('index.html', inlineTarget)` to embbed `index.html` in the\n   * import panel. Or it can be ignored and use `'fullscreen'` in-place. It is used in\n   * `grist.addImporter(...)` according to the value of the `mode` argument.\n   */\n  getImportSource(inlineTarget: RenderTarget): Promise<ImportSource | undefined>;\n}\n"
  },
  {
    "path": "app/plugin/PluginManifest-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const PublishedPlugin = t.iface([\"BarePlugin\"], {\n  \"name\": \"string\",\n  \"version\": \"string\",\n});\n\nexport const BarePlugin = t.iface([], {\n  \"name\": t.opt(\"string\"),\n  \"components\": t.iface([], {\n    \"safeBrowser\": t.opt(\"string\"),\n    \"safePython\": t.opt(\"string\"),\n    \"unsafeNode\": t.opt(\"string\"),\n    \"widgets\": t.opt(\"string\"),\n    \"deactivate\": t.opt(t.iface([], {\n      \"inactivitySec\": t.opt(\"number\"),\n    })),\n  }),\n  \"contributions\": t.iface([], {\n    \"importSources\": t.opt(t.array(\"ImportSource\")),\n    \"fileParsers\": t.opt(t.array(\"FileParser\")),\n    \"customSections\": t.opt(t.array(\"CustomSection\")),\n  }),\n  \"experimental\": t.opt(\"boolean\"),\n});\n\nexport const ImportSource = t.iface([], {\n  \"label\": \"string\",\n  \"safeHome\": t.opt(\"boolean\"),\n  \"importSource\": \"Implementation\",\n  \"importProcessor\": t.opt(\"Implementation\"),\n});\n\nexport const FileParser = t.iface([], {\n  \"fileExtensions\": t.array(\"string\"),\n  \"editOptions\": t.opt(\"Implementation\"),\n  \"parseFile\": \"Implementation\",\n});\n\nexport const CustomSection = t.iface([], {\n  \"path\": \"string\",\n  \"name\": \"string\",\n});\n\nexport const Implementation = t.iface([], {\n  \"component\": t.union(t.lit(\"safeBrowser\"), t.lit(\"safePython\"), t.lit(\"unsafeNode\")),\n  \"name\": \"string\",\n  \"path\": t.opt(\"string\"),\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  PublishedPlugin,\n  BarePlugin,\n  ImportSource,\n  FileParser,\n  CustomSection,\n  Implementation,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/PluginManifest.ts",
    "content": "/**\n * This file defines the interface for a plugin manifest.\n *\n * Note that it is possible to validate a manifest against a TypeScript interface as follows:\n * (1) Convert the interface to a JSON schema at build time using\n *     https://www.npmjs.com/package/typescript-json-schema:\n *     bin/typescript-json-schema --required --noExtraProps PluginManifest.ts PublishedPlugin\n * (2) Use a JSON schema validator like https://www.npmjs.com/package/ajv to validate manifests\n *     read at run-time and produce informative errors automatically.\n *\n * TODO [Proposal]: To save an ImportSource for reuse, we would save:\n *  {\n *    pluginId: string;\n *    importSource: ImportSource;\n *    importProcessor?: Implementation;\n *    parseOptions?: ParseOptions;        // If importProcessor is omitted and fileParser is used.\n *  }\n * This should suffice for database re-imports, as well as for re-imports from a URL, or from a\n * saved path in the filesystem (which can be a builtIn plugin available for Electron version).\n */\n\n/**\n * PublishedPlugin is a BarePlugin with additional attributes to identify and describe a plugin\n * for publishing.\n */\nexport interface PublishedPlugin extends BarePlugin {\n  name: string;\n  version: string;\n}\n\n/**\n * BarePlugin defines the functionality of a plugin. It is the only part required for a plugin to\n * function, and is implemented by built-in plugins, published plugins, and private plugins (such\n * as those being developed).\n */\nexport interface BarePlugin {\n  /**\n   * An optional human-readable name.\n   */\n  name?: string;\n\n  /**\n   * Components describe how the plugin runs. A plugin may provide UI and behavior that runs in\n   * the browser, Python code that runs in a secure sandbox, and arbitrary code that runs in Node.\n   */\n  components: {\n    /**\n     * Relative path to the directory whose content will be served to the browser. Required for\n     * those plugins that need to render their own HTML or run in-browser Javascript. This\n     * directory should contain all html files referenced in the manifest.\n     *\n     * It is \"safe\" in that Grist offers protections that allow such plugins to be marked \"safe\".\n     */\n    safeBrowser?: string;\n\n    /**\n     * Relative path to a file with Python code that will be run in a python sandbox. This\n     * file is started on plugin activation, and should register any implemented APIs.\n     * Required for plugins that do Python processing.\n     *\n     * It is \"safe\" in that Grist offers protections that allow such plugins to be marked \"safe\".\n     */\n    safePython?: string;\n\n    /**\n     * Relative path to a file containing Javascript code that will be executed with Node.js.\n     * The code is called on plugin activation, and should register any implemented APIs\n     * once we've figured out how that should happen (TODO).  Required for plugins that need\n     * to do any \"unsafe\" work, such as accessing the local filesystem or starting helper\n     * programs.\n     *\n     * It is \"unsafe\" in that it can do too much, and Grist marks such plugins as \"unsafe\".\n     *\n     * An unsafeNode component opens as separate process to run plugin node code, with the\n     * NODE_PATH set to the plugin directory.  The node code can execute arbitrary actions -\n     * there is no sandboxing.\n     *\n     * The node child may communicate with the server via standard ChildProcess ipc\n     * (`process.send`, `process.on('message', ...)`).  The child is expected to\n     * `process.send` a message to the server once it is listening to the `message`\n     * event.  That message is expected to contain a `ready` field set to `true`.  All\n     * other communication should follow the protocol implemented by the Rpc module.\n     * TODO: provide plugin authors with documentation + library to use that implements\n     * these requirements.\n     *\n     */\n    unsafeNode?: string;\n\n    /**\n     * Relative path to a specialized manifest of custom widgets.\n     * I'm unsure how this fits into components and contributions,\n     * this seemed the least-worst spot for it.\n     */\n    widgets?: string;\n\n    /**\n     * Options for when to deactivate the plugin, i.e. when to stop any plugin processes. (Note\n     * that we may in the future also add options for when to activate the plugin, which is for\n     * now automatic and not configurable.)\n     */\n    deactivate?: {\n      // Deactivate after this many seconds of inactivity. Defaults to 300 (5 minutes) if omitted.\n      inactivitySec?: number;\n    }\n  };\n\n  /**\n   * Contributions describe what new functionality the plugin contributes to the Grist\n   * application. See documentation for individual contribution types for details. Any plugin may\n   * provide multiple contributions. It is common to provide just one, in which case include a\n   * single property with a single-element array.\n   */\n  contributions: {\n    importSources?: ImportSource[];\n    fileParsers?: FileParser[];\n    customSections?: CustomSection[];\n  };\n\n  /**\n   * Experimental plugins run only if the environment variable GRIST_EXPERIMENTAL_PLUGINS is\n   * set. Otherwise they are ignored. This is useful for plugins that needs a bit of experimentation\n   * before being pushed to production (ie: production does not have GRIST_EXPERIMENTAL_PLUGINS set\n   * but staging does). Keep in mind that developers need to set this environment if they want to\n   * run them locally.\n   */\n  experimental?: boolean;\n}\n\n/**\n * An ImportSource plugin creates a new source of imports, such as an external API, a file-sharing\n * service, or a new type of database. It adds a new item for the user to select when importing.\n */\nexport interface ImportSource {\n  /**\n   * Label shows up as a new item for the user to select when starting an import.\n   */\n  label: string;\n\n  /**\n   * Whether this import source can be exposed on a home screen for all users. Home imports\n   * support only a safeBrowser component and have no access to current document. Primarily used as\n   * an external/cloud storage providers.\n   */\n  safeHome?: boolean;\n\n  /**\n   * Implementation of ImportSourceAPI. Supports safeBrowser component, which allows you to create\n   * custom UI to show to the user. Or describe UI using a .json or .yml config file and use\n   * {component: \"builtIn\", name: \"importSourceConfig\", path: \"your-config\"}.\n   */\n  importSource: Implementation;\n\n  /**\n   * Implementation of ImportProcessorAPI. It receives the output of importSource, and produces\n   * Grist data. If omitted, uses the default ImportProcessor, which is equivalent to\n   * {component: \"builtIn\", name: \"fileParser\"}.\n   *\n   * The default ImportProcessor handles received ImportSourceItems as follows:\n   *    (1) items of type \"file\" are saved to temp files.\n   *    (2) items of type \"url\" are downloaded to temp files.\n   *    (3) calls ParseFileAPI.parseFile() with all temp files, to produce Grist tables\n   *    (4) returns those Grist tables along with all items of type \"table\".\n   * Note that the default ImportParser ignores ImportSource items of type \"custom\".\n   */\n  importProcessor?: Implementation;\n\n}\n\n/**\n * A FileParser plugin adds support to parse a new type of file data, such as \"csv\", \"yml\", or\n * \"ods\". It then enables importing the new type of file via upload or from any other ImportSource\n * that produces Files or URLs.\n */\nexport interface FileParser {\n  /**\n   * File extensions for which this FileParser should be considered, e.g. \"csv\", \"yml\". You may\n   * use \"\" for files with no extensions, and \"*\" to match any extension.\n   */\n  fileExtensions: string[];\n\n  /**\n   * Implementation of EditOptionsAPI. Supports safeBrowser component, which allows you to create\n   * custom UI to show to the user. Or describe UI using a .json or .yml config file and use\n   * {component: \"builtIn\", name: \"parseOptionsConfig\", path: \"your-config\"}.\n   *\n   * If omitted, the user will be shown no parse options.\n   */\n  editOptions?: Implementation;\n\n  /**\n   * Implementation of ParseFileAPI, which converts Files to Grist data using parse options.\n   */\n  parseFile: Implementation;\n}\n\n/**\n * A CustomSection plugin adds support to add new types of section to Grist, such as a calendar,\n * maps, data visualizations.\n */\nexport interface CustomSection {\n  /**\n   * Path to an html file.\n   */\n  path: string;\n  /**\n   * The name should uniquely identify the section in the plugin.\n   */\n  name: string;\n}\n\n/**\n * A Plugin supplies one or more Implementation of some APIs. Components register implementation\n * using a call such as:\n *    grist.register(SomeAPI, 'myName', impl).\n * The manifest documentation describes which API must be implemented at any particular point, and\n * it is the plugin's responsibility to register an implementation of the correct API and refer to\n * it by Implementation.name.\n */\nexport interface Implementation  {\n  /**\n   * Which component of the plugin provides this implementation.\n   */\n  component: \"safeBrowser\" | \"safePython\" | \"unsafeNode\";\n\n  /**\n   * The name of the implementation registered by the chosen component. The same component can\n   * register any number of APIs at any names.\n   */\n  name: string;\n\n  /**\n   * Path is used by safeBrowser component for which page to load. Defaults to 'index.html'.\n   * It is also used by certain builtIn implementation, e.g. if name is 'parse-options-config',\n   * path is the path to JSON or YAML file containing the configuration.\n   */\n  path?: string;\n}\n"
  },
  {
    "path": "app/plugin/README.md",
    "content": "Methods here are available for use in Grist custom widgets.\n"
  },
  {
    "path": "app/plugin/RenderOptions-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const RenderTarget = t.union(t.lit(\"fullscreen\"), \"number\");\n\nexport const RenderOptions = t.iface([], {\n  \"height\": t.opt(\"string\"),\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  RenderTarget,\n  RenderOptions,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/RenderOptions.ts",
    "content": "/**\n * Where to append the content that a plugin renders.\n *\n * @internal\n */\nexport type RenderTarget = \"fullscreen\" | number;\n\n/**\n * Options for the `grist.render` function.\n */\nexport interface RenderOptions {\n  height?: string;\n}\n"
  },
  {
    "path": "app/plugin/StorageAPI-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const Storage = t.iface([], {\n  \"getItem\": t.func(\"any\", t.param(\"key\", \"string\")),\n  \"hasItem\": t.func(\"boolean\", t.param(\"key\", \"string\")),\n  \"setItem\": t.func(\"void\", t.param(\"key\", \"string\"), t.param(\"value\", \"any\")),\n  \"removeItem\": t.func(\"void\", t.param(\"key\", \"string\")),\n  \"clear\": t.func(\"void\"),\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  Storage,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/StorageAPI.ts",
    "content": "// subset of WebStorage API\nexport interface Storage {\n  getItem(key: string): any;\n  hasItem(key: string): boolean;\n  setItem(key: string, value: any): void;\n  removeItem(key: string): void;\n  clear(): void;\n}\n"
  },
  {
    "path": "app/plugin/TableOperations.ts",
    "content": "import * as Types from \"app/plugin/DocApiTypes\";\n\n/**\n * Offer CRUD-style operations on a table.\n */\nexport interface TableOperations {\n  /**\n   * Create a record or records.\n   */\n  create(records: Types.NewRecord, options?: OpOptions): Promise<Types.MinimalRecord>;\n  create(records: Types.NewRecord[], options?: OpOptions): Promise<Types.MinimalRecord[]>;\n\n  /**\n   * Update a record or records.\n   */\n  update(records: Types.Record | Types.Record[], options?: OpOptions): Promise<void>;\n\n  /**\n   * Delete a record or records.\n   */\n  destroy(recordIds: Types.RecordId | Types.RecordId[]): Promise<void>;\n\n  /**\n   * Add or update a record or records.\n   */\n  upsert(records: Types.AddOrUpdateRecord | Types.AddOrUpdateRecord[],\n    options?: UpsertOptions): Promise<void>;\n\n  /**\n   * Determine the tableId of the table.\n   */\n  getTableId(): Promise<string>;\n\n  // TODO: offer a way to query the table.\n  // select(): Records;\n}\n\n/**\n * General options for table operations.\n */\nexport interface OpOptions {\n  /** Whether to parse strings based on the column type. Defaults to true. */\n  parseStrings?: boolean;\n}\n\n/**\n * Extra options for upserts.\n */\nexport interface UpsertOptions extends OpOptions {\n  /** Permit inserting a record. Defaults to true. */\n  add?: boolean;\n  /** Permit updating a record. Defaults to true. */\n  update?: boolean;\n  /** Whether to update none, one, or all matching records. Defaults to \"first\". */\n  onMany?: \"none\" | \"first\" | \"all\";\n  /** Allow \"wildcard\" operation. Defaults to false. */\n  allowEmptyRequire?: boolean;\n}\n"
  },
  {
    "path": "app/plugin/TableOperationsImpl.ts",
    "content": "import * as Types from \"app/plugin/DocApiTypes\";\nimport { BulkColValues } from \"app/plugin/GristData\";\nimport { arrayRepeat } from \"app/plugin/gutil\";\nimport { OpOptions, TableOperations, UpsertOptions } from \"app/plugin/TableOperations\";\n\nimport flatMap from \"lodash/flatMap\";\nimport groupBy from \"lodash/groupBy\";\nimport isEqual from \"lodash/isEqual\";\nimport pick from \"lodash/pick\";\n\n/**\n * An implementation of the TableOperations interface, given a platform\n * capable of applying user actions. Used by REST API server, and by the\n * Grist plugin API that is embedded in custom widgets.\n */\nexport class TableOperationsImpl implements TableOperations {\n  public constructor(private _platform: TableOperationsPlatform,\n    private _defaultOptions: OpOptions) {\n  }\n\n  public getTableId() {\n    return this._platform.getTableId();\n  }\n\n  public create(records: Types.NewRecord, options?: OpOptions): Promise<Types.MinimalRecord>;\n  public create(records: Types.NewRecord[], options?: OpOptions): Promise<Types.MinimalRecord[]>;\n  public async create(recordsOrRecord: Types.NewRecord[] | Types.NewRecord,\n    options?: OpOptions): Promise<Types.MinimalRecord[] | Types.MinimalRecord> {\n    return await withRecords(recordsOrRecord, async (records) => {\n      const postRecords = convertToBulkColValues(records);\n      // postRecords can be an empty object, in that case we will create empty records.\n      const ids = await this.addRecords(records.length, postRecords, options);\n      return ids.map(id => ({ id }));\n    });\n  }\n\n  public async update(recordOrRecords: Types.Record | Types.Record[], options?: OpOptions) {\n    await withRecords(recordOrRecords, async (records) => {\n      if (!areSameFields(records)) {\n        this._platform.throwError(\"PATCH\", \"requires all records to have same fields\", 400);\n      }\n      const rowIds = records.map(r => r.id);\n      const columnValues = convertToBulkColValues(records);\n      if (!rowIds.length || !columnValues) {\n        // For patch method, we require at least one valid record.\n        this._platform.throwError(\"PATCH\", \"requires a valid record object\", 400);\n      }\n      await this.updateRecords(columnValues, rowIds, options);\n      return [];\n    });\n  }\n\n  public async upsert(recordOrRecords: Types.AddOrUpdateRecord | Types.AddOrUpdateRecord[],\n    upsertOptions?: UpsertOptions): Promise<void> {\n    await withRecords(recordOrRecords, async (records) => {\n      const tableId = await this._platform.getTableId();\n      const options = {\n        add: upsertOptions?.add,\n        update: upsertOptions?.update,\n        on_many: upsertOptions?.onMany,\n        allow_empty_require: upsertOptions?.allowEmptyRequire,\n      };\n      const recordOptions: OpOptions = pick(upsertOptions, \"parseStrings\");\n\n      // Group records based on having the same keys in `require` and `fields`.\n      // A single bulk action will be applied to each group.\n      // We don't want one bulk action for all records that might have different shapes,\n      // because that would require filling arrays with null values.\n      const recGroups = groupBy(records, (rec) => {\n        const requireKeys = Object.keys(rec.require).sort().join(\",\");\n        const fieldsKeys = Object.keys(rec.fields || {}).sort().join(\",\");\n        return `${requireKeys}:${fieldsKeys}`;\n      });\n      const actions = Object.values(recGroups).map((group) => {\n        const require = convertToBulkColValues(group.map(r => ({ fields: r.require })));\n        const fields = convertToBulkColValues(group.map(r => ({ fields: r.fields || {} })));\n        return [\"BulkAddOrUpdateRecord\", tableId, require, fields, options];\n      });\n      await this._applyUserActions(tableId, [...fieldNames(records)],\n        actions, recordOptions);\n      return [];\n    });\n  }\n\n  public async destroy(recordIdOrRecordIds: Types.RecordId | Types.RecordId[]): Promise<void> {\n    await withRecords(recordIdOrRecordIds, async (recordIds) => {\n      const tableId = await this._platform.getTableId();\n      const actions = [[\"BulkRemoveRecord\", tableId, recordIds]];\n      await this._applyUserActions(tableId, [], actions);\n      return [];\n    });\n  }\n\n  // Update records identified by rowIds. Any invalid id fails\n  // the request and returns a 400 error code.\n  // This is exposed as a public method to support the older /data endpoint.\n  public async updateRecords(columnValues: BulkColValues, rowIds: number[],\n    options?: OpOptions) {\n    await this._addOrUpdateRecords(columnValues, rowIds, \"BulkUpdateRecord\", options);\n  }\n\n  /**\n   * Adds records to a table. If columnValues is an empty object (or not provided) it will create empty records.\n   * This is exposed as a public method to support the older /data endpoint.\n   * @param columnValues Optional values for fields (can be an empty object to add empty records)\n   * @param count Number of records to add\n   */\n  public async addRecords(\n    count: number, columnValues: BulkColValues, options?: OpOptions,\n  ): Promise<number[]> {\n    // user actions expect [null, ...] as row ids\n    const rowIds = arrayRepeat(count, null);\n    return this._addOrUpdateRecords(columnValues, rowIds, \"BulkAddRecord\", options);\n  }\n\n  private async _addOrUpdateRecords(\n    columnValues: BulkColValues, rowIds: (number | null)[],\n    actionType: \"BulkUpdateRecord\" | \"BulkAddRecord\",\n    options?: OpOptions,\n  ) {\n    const tableId = await this._platform.getTableId();\n    const colNames = Object.keys(columnValues);\n    const sandboxRes = await this._applyUserActions(\n      tableId, colNames,\n      [[actionType, tableId, rowIds, columnValues]],\n      options,\n    );\n    return sandboxRes.retValues[0];\n  }\n\n  // Apply the supplied actions with the given options. The tableId and\n  // colNames are just to improve error reporting.\n  private async _applyUserActions(tableId: string, colNames: string[], actions: any[][],\n    options: OpOptions = {}): Promise<any> {\n    return handleSandboxErrorOnPlatform(tableId, colNames, this._platform.applyUserActions(\n      actions, { ...this._defaultOptions, ...options },\n    ), this._platform);\n  }\n}\n\n/**\n * The services needed by TableOperationsImpl.\n */\nexport interface TableOperationsPlatform {\n  // Get the tableId of the table upon which we are supposed to operate.\n  getTableId(): Promise<string>;\n\n  // Throw a platform-specific error.\n  throwError(verb: string, text: string, status: number): never;\n\n  // Apply the supplied actions with the given options.\n  applyUserActions(actions: any[][], opts: any): Promise<any>;\n}\n\nexport function convertToBulkColValues(records: (Types.Record | Types.NewRecord)[]): BulkColValues {\n  // User might want to create empty records, without providing a field name, for example for requests:\n  // { records: [{}] }; { records: [{fields:{}}] }\n  // Retrieve all field names from fields property.\n  const result: BulkColValues = {};\n  for (const fieldName of fieldNames(records)) {\n    result[fieldName] = records.map(record => record.fields?.[fieldName] ?? null);\n  }\n  return result;\n}\n\nexport function fieldNames(records: any[]) {\n  return new Set<string>(flatMap(records, r => Object.keys({ ...r.fields, ...r.require })));\n}\n\nexport function areSameFields(records: (Types.Record | Types.NewRecord)[]) {\n  const recordsFields = records.map(r => new Set(Object.keys(r.fields || {})));\n  return recordsFields.every(s => isEqual(recordsFields[0], s));\n}\n\n/**\n * Adapt an operation that takes a list and returns a list to an input that may\n * be a single object or a list. If input is empty list, return the empty list.\n * If input is a single object, return a single object. Otherwise return a list.\n */\nasync function withRecords<T, T2>(recordsOrRecord: T[] | T, op: (records: T[]) => Promise<T2[]>): Promise<T2 | T2[]> {\n  const records = Array.isArray(recordsOrRecord) ? recordsOrRecord : [recordsOrRecord];\n  const result = records.length == 0 ? [] : await op(records);\n  return Array.isArray(recordsOrRecord) ? result : result[0];\n}\n\n/**\n * Catches the errors thrown by the sandbox, and converts to more descriptive ones (such as for\n * invalid table names, columns, or rowIds) with better status codes. Accepts the table name, a\n * list of column names in that table, and a promise for the result of the sandbox call.\n */\nexport async function handleSandboxErrorOnPlatform<T>(\n  tableId: string, colNames: string[], p: Promise<T>, platform: TableOperationsPlatform,\n): Promise<T> {\n  try {\n    return await p;\n  } catch (err) {\n    const message = ((err instanceof Error) && err.message?.startsWith(\"[Sandbox] \")) ? err.message : undefined;\n    if (message) {\n      let match = message.match(/non-existent record #([0-9]+)/);\n      if (match) {\n        platform.throwError(\"\", `Invalid row id ${match[1]}`, 400);\n      }\n      match = message.match(\n        /\\[Sandbox] (?:KeyError u?'(?:Table \\w+ has no column )?|ValueError No such table: |ValueError No such column: )([\\w.]+)/,\n      );\n      if (match) {\n        if (match[1] === tableId) {\n          platform.throwError(\"\", `Table not found \"${tableId}\"`, 404);\n        } else if (colNames.includes(match[1])) {\n          platform.throwError(\"\", `Invalid column \"${match[1]}\"`, 400);\n        } else if (colNames.includes(match[1].replace(`${tableId}.`, \"\"))) {\n          platform.throwError(\"\", `Table or column not found \"${match[1]}\"`, 404);\n        }\n      }\n      platform.throwError(\"\", `Error manipulating data: ${message}`, 400);\n    }\n    throw err;\n  }\n}\n"
  },
  {
    "path": "app/plugin/TypeCheckers.ts",
    "content": "import CustomSectionAPITI from \"app/plugin/CustomSectionAPI-ti\";\nimport FileParserAPITI from \"app/plugin/FileParserAPI-ti\";\nimport GristAPITI from \"app/plugin/GristAPI-ti\";\nimport GristTableTI from \"app/plugin/GristTable-ti\";\nimport ImportSourceAPITI from \"app/plugin/ImportSourceAPI-ti\";\nimport InternalImportSourceAPITI from \"app/plugin/InternalImportSourceAPI-ti\";\nimport RenderOptionsTI from \"app/plugin/RenderOptions-ti\";\nimport StorageAPITI from \"app/plugin/StorageAPI-ti\";\nimport WidgetAPITI from \"app/plugin/WidgetAPI-ti\";\n\nimport { BasicType, createCheckers, ICheckerSuite } from \"ts-interface-checker\";\n\n/**\n * The ts-interface-checker type suites are all exported with the \"TI\" suffix.\n */\nexport {\n  CustomSectionAPITI, FileParserAPITI, GristAPITI, GristTableTI, ImportSourceAPITI,\n  InternalImportSourceAPITI, RenderOptionsTI, StorageAPITI, WidgetAPITI };\n\nconst allTypes = [\n  CustomSectionAPITI, FileParserAPITI, GristAPITI, GristTableTI, ImportSourceAPITI,\n  InternalImportSourceAPITI, RenderOptionsTI, StorageAPITI, WidgetAPITI,\n];\n\n// Ensure Buffer can be handled if mentioned in the interface descriptions, even if not supported\n// in the current environment (i.e. browser).\nif (typeof Buffer === \"undefined\") {\n  allTypes.push({ Buffer: new BasicType(v => false, \"Buffer is not supported\") });\n}\n\nfunction checkDuplicates(types: { [key: string]: object }[]) {\n  const seen = new Set<string>();\n  for (const t of types) {\n    for (const key of Object.keys(t)) {\n      if (seen.has(key)) { throw new Error(`TypeCheckers: Duplicate type name ${key}`); }\n      seen.add(key);\n      // Uncomment the line below to generate updated list of included types.\n      // console.log(`'${key}' |`);\n    }\n  }\n}\n\ncheckDuplicates(allTypes);\n\n/**\n * We also create and export a global checker object that includes all of the types above.\n */\nexport const checkers = createCheckers(...allTypes) as (\n  // The following Pick typecast ensures that Typescript can only use correct properties of the\n  // checkers object (e.g. checkers.GristAPI will compile, but checkers.GristApi will not).\n  // TODO: The restrictive type of ICheckerSuite should be generated automatically. (Currently\n  // generated by commenting out console.log() in checkDuplicates() above.)\n  Pick<ICheckerSuite,\n  \"CustomSectionAPI\" | \"EditOptionsAPI\" | \"ParseFileAPI\" | \"ParseOptions\" | \"ParseOptionSchema\" |\n  \"FileSource\" | \"ParseFileResult\" | \"ComponentKind\" | \"GristAPI\" | \"GristDocAPI\" | \"GristTable\" |\n  \"GristTables\" | \"GristColumn\" | \"GristView\" | \"ImportSourceAPI\" | \"ImportProcessorAPI\" | \"FileContent\" |\n  \"FileListItem\" | \"URL\" | \"ImportSource\" | \"InternalImportSourceAPI\" | \"RenderTarget\" |\n  \"RenderOptions\" | \"Storage\" | \"WidgetAPI\"\n  >);\n"
  },
  {
    "path": "app/plugin/WidgetAPI-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const WidgetAPI = t.iface([], {\n  \"getOptions\": t.func(t.union(\"object\", \"null\")),\n  \"setOptions\": t.func(\"void\", t.param(\"options\", t.iface([], {\n    [t.indexKey]: \"any\",\n  }))),\n  \"clearOptions\": t.func(\"void\"),\n  \"setOption\": t.func(\"void\", t.param(\"key\", \"string\"), t.param(\"value\", \"any\")),\n  \"getOption\": t.func(\"any\", t.param(\"key\", \"string\")),\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  WidgetAPI,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/plugin/WidgetAPI.ts",
    "content": "/**\n * API to manage Custom Widget state.\n */\nexport interface WidgetAPI {\n  /**\n   * Gets all options stored by the widget. Options are stored as plain JSON object.\n   */\n  getOptions(): Promise<object | null>;\n  /**\n   * Replaces all options stored by the widget.\n   */\n  setOptions(options: { [key: string]: any }): Promise<void>;\n  /**\n   * Clears all the options.\n   */\n  clearOptions(): Promise<void>;\n  /**\n   * Store single value in the Widget options object (and create it if necessary).\n   */\n  setOption(key: string, value: any): Promise<void>;\n  /**\n   * Get single value from Widget options object.\n   */\n  getOption(key: string): Promise<any>;\n}\n"
  },
  {
    "path": "app/plugin/grist-plugin-api.ts",
    "content": "// Provide a way to access grist for iframe, web worker (which runs the main safeBrowser script) and\n// unsafeNode. WebView should work the same way as iframe, grist is exposed just the same way and\n// necessary api is exposed using preload script. Here we bootstrap from channel capabilities to key\n// parts of the grist API.\n\n// For iframe (and webview):\n// user will add '<script src=\"/grist-api.js\"></script>' and get a window.grist\n\n// For web worker:\n// use will add `self.importScripts('/grist-api.js');`\n\n// For node, user will do something like:\n//   const {grist} = require('grist-api');\n//   grist.registerFunction();\n// In TypeScript:\n//   import {grist} from 'grist-api';\n//   grist.registerFunction();\n\nimport { ColumnsToMap, CustomSectionAPI, InteractionOptions, InteractionOptionsRequest,\n  WidgetColumnMap } from \"app/plugin/CustomSectionAPI\";\nimport {\n  AccessTokenOptions, AccessTokenResult, FetchSelectedOptions, GristAPI, GristDocAPI,\n  GristView, RPC_GRISTAPI_INTERFACE,\n} from \"app/plugin/GristAPI\";\nimport { RowRecord } from \"app/plugin/GristData\";\nimport { ImportSource, ImportSourceAPI, InternalImportSourceAPI } from \"app/plugin/InternalImportSourceAPI\";\nimport { decodeObject, mapValues } from \"app/plugin/objtypes\";\nimport { RenderOptions, RenderTarget } from \"app/plugin/RenderOptions\";\nimport { TableOperations } from \"app/plugin/TableOperations\";\nimport { TableOperationsImpl } from \"app/plugin/TableOperationsImpl\";\nimport { checkers } from \"app/plugin/TypeCheckers\";\nimport { WidgetAPI } from \"app/plugin/WidgetAPI\";\n\nexport * from \"app/plugin/TypeCheckers\";\nexport * from \"app/plugin/FileParserAPI\";\nexport * from \"app/plugin/GristAPI\";\nexport * from \"app/plugin/GristData\";\nexport * from \"app/plugin/GristTable\";\nexport * from \"app/plugin/ImportSourceAPI\";\nexport * from \"app/plugin/StorageAPI\";\nexport * from \"app/plugin/RenderOptions\";\nexport * from \"app/plugin/WidgetAPI\";\nexport * from \"app/plugin/CustomSectionAPI\";\nexport { decodeObject, mapValues } from \"app/plugin/objtypes\";\n\nimport { IRpcLogger, Rpc } from \"grain-rpc\";\nimport isEqual from \"lodash/isEqual\";\n\nexport const rpc: Rpc = new Rpc({ logger: createRpcLogger() });\n\nexport const api = rpc.getStub<GristAPI>(RPC_GRISTAPI_INTERFACE, checkers.GristAPI);\nexport const coreDocApi = rpc.getStub<GristDocAPI>(\"GristDocAPI@grist\", checkers.GristDocAPI);\n\nconst viewApiStub = rpc.getStub<GristView>(\"GristView\", checkers.GristView);\n\n/**\n * Interface for the records backing a custom widget.\n */\nexport const viewApi: GristView = {\n  ...viewApiStub,\n  // Decoded objects aren't fully preserved over the RPC channel, so decoding has to happen on this side.\n  async fetchSelectedTable(options: FetchSelectedOptions = {}) {\n    let data = await viewApiStub.fetchSelectedTable(options);\n    if (options.keepEncoded === false) {\n      data = mapValues<any[], any[]>(data, col => col.map(decodeObject));\n    }\n    if (options.format === \"rows\") {\n      const rows: RowRecord[] = [];\n      for (let i = 0; i < data.id.length; i++) {\n        const row: RowRecord = { id: data.id[i] };\n        for (const key of Object.keys(data)) {\n          row[key] = data[key][i];\n        }\n        rows.push(row);\n      }\n      return rows;\n    } else {\n      return data;\n    }\n  },\n  async fetchSelectedRecord(rowId: number, options: FetchSelectedOptions = {}) {\n    const rec = await viewApiStub.fetchSelectedRecord(rowId, options);\n    return options.keepEncoded === false ? mapValues(rec, decodeObject) : rec;\n  },\n};\n\n/**\n * Interface for the state of a custom widget.\n */\nexport const widgetApi = rpc.getStub<WidgetAPI>(\"WidgetAPI\", checkers.WidgetAPI);\n\n/**\n * Interface for the mapping of a custom widget.\n */\nexport const sectionApi = rpc.getStub<CustomSectionAPI>(\"CustomSectionAPI\", checkers.CustomSectionAPI);\n\nexport const commandApi = rpc.getStub<any>(\"CommandAPI\");\n\n/**\n * Shortcut for {@link GristView.allowSelectBy}.\n */\nexport const allowSelectBy = viewApi.allowSelectBy;\n\n/**\n * Shortcut for {@link GristView.setSelectedRows}.\n */\nexport const setSelectedRows = viewApi.setSelectedRows;\n\n/**\n * Sets the cursor position in a linked section.\n */\nexport const setCursorPos = viewApi.setCursorPos;\n\n/**\n * Same as {@link GristView.fetchSelectedTable | GristView.fetchSelectedTable},\n * but the option `keepEncoded` is `false` by default.\n */\nexport async function fetchSelectedTable(options: FetchSelectedOptions = {}) {\n  options = { ...options, keepEncoded: options.keepEncoded || false };\n  return await viewApi.fetchSelectedTable(options);\n}\n\n/**\n * Same as {@link GristView.fetchSelectedRecord | GristView.fetchSelectedRecord},\n * but the option `keepEncoded` is `false` by default.\n */\nexport async function fetchSelectedRecord(rowId: number, options: FetchSelectedOptions = {}) {\n  options = { ...options, keepEncoded: options.keepEncoded || false };\n  return await viewApi.fetchSelectedRecord(rowId, options);\n}\n\n/**\n * A collection of methods for fetching document data. The\n * fetchSelectedTable and fetchSelectedRecord methods are\n * overridden to decode data by default.\n */\nexport const docApi: GristDocAPI & GristView = {\n  ...coreDocApi,\n  ...viewApi,\n  fetchSelectedTable,\n  fetchSelectedRecord,\n};\n\nexport const on = rpc.on.bind(rpc);\n\n// Exposing widgetApi methods in a module scope.\n\n/**\n * Shortcut for {@link WidgetAPI.getOption}\n */\nexport const getOption = widgetApi.getOption.bind(widgetApi);\n\n/**\n * Shortcut for {@link WidgetAPI.setOption}\n */\nexport const setOption = widgetApi.setOption.bind(widgetApi);\n\n/**\n * Shortcut for {@link WidgetAPI.setOptions}\n */\nexport const setOptions = widgetApi.setOptions.bind(widgetApi);\n\n/**\n * Shortcut for {@link WidgetAPI.getOptions}\n */\nexport const getOptions = widgetApi.getOptions.bind(widgetApi);\n\n/**\n * Shortcut for {@link WidgetAPI.clearOptions}\n */\nexport const clearOptions = widgetApi.clearOptions.bind(widgetApi);\n\n/**\n * Get access to a table in the document. If no tableId specified, this\n * will use the current selected table (for custom widgets).\n * If a table does not exist, there will be no error until an operation\n * on the table is attempted.\n */\nexport function getTable(tableId?: string): TableOperations {\n  return new TableOperationsImpl({\n    async getTableId() {\n      return tableId || await getSelectedTableId();\n    },\n    throwError(verb, text, status) {\n      throw new Error(text);\n    },\n    applyUserActions(actions, opts) {\n      return docApi.applyUserActions(actions, opts);\n    },\n  }, {});\n}\n\n/**\n * Get an access token, for making API calls outside of the custom widget\n * API. There is no caching of tokens. The returned token can\n * be used to authorize regular REST API calls that access the content of the\n * document. For example, in a custom widget for a table with a `Photos` column\n * containing attachments, the following code will update the `src` of an\n * image with id `the_image` to show the attachment:\n * ```js\n * grist.onRecord(async (record) => {\n *   const tokenInfo = await grist.docApi.getAccessToken({readOnly: true});\n *   const img = document.getElementById('the_image');\n *   const id = record.Photos[0];  // get an id of an attachment - there could be several\n *                                 // in a cell, for this example we just take the first.\n *   const src = `${tokenInfo.baseUrl}/attachments/${id}/download?auth=${tokenInfo.token}`;\n *   img.setAttribute('src', src);\n * });\n * ```\n */\nexport async function getAccessToken(options?: AccessTokenOptions): Promise<AccessTokenResult> {\n  return docApi.getAccessToken(options || {});\n}\n\n/**\n * Get the current selected table (for custom widgets).\n */\nexport const selectedTable: TableOperations = getTable();\n\n// Get the ID of the current selected table (for custom widgets).\n// Will wait for the table ID to be set.\nexport async function getSelectedTableId(): Promise<string> {\n  await _initialization;\n  return _tableId!;\n}\n\n// Get the ID of the current selected table if set (for custom widgets).\n// The ID may take some time to be set, or may never be set if the widget\n// is not linked to anything.\nexport function getSelectedTableIdSync(): string | undefined {\n  return _tableId;\n}\n\n// For custom widgets that support custom columns mappings store current configuration\n// in a memory.\n\n// Actual cached value. Undefined means that widget hasn't asked for configuration yet.\n// Here we are storing serialized configuration instead of actual one, since widget can\n// mutate returned value.\nlet _mappingsCache: WidgetColumnMap | null | undefined;\n// Since widget needs to ask for mappings during onRecord and onRecords event, we will reuse\n// current request if available;\nlet _activeRefreshReq: Promise<void> | null = null;\n// Remember columns requested during ready call.\nlet _columnsToMap: ColumnsToMap | undefined;\nlet _tableId: string | undefined;\nlet _setInitialized: () => void;\nconst _initialization = new Promise<void>(resolve => _setInitialized = resolve);\nlet _readyCalled: boolean = false;\n\nasync function getMappingsIfChanged(data: any): Promise<WidgetColumnMap | null> {\n  const uninitialized = _mappingsCache === undefined;\n  if (data.mappingsChange || uninitialized) {\n    // If no active request.\n    if (!_activeRefreshReq) {\n      // Request for new mappings.\n      _activeRefreshReq = sectionApi\n        .mappings()\n        // Store it in global variable.\n        .then(mappings => void (_mappingsCache = mappings))\n        // Clear current request variable.\n        .finally(() => _activeRefreshReq = null);\n    }\n    await _activeRefreshReq;\n  }\n  return _mappingsCache ? JSON.parse(JSON.stringify(_mappingsCache)) : null;\n}\n\n/**\n * Used by tests to wait for all pending requests to settle.\n *\n * TODO: currently only waits for requests for mappings.\n *\n * @internal\n */\nexport async function testWaitForPendingRequests() {\n  return await _activeRefreshReq;\n}\n\n/**\n * Renames columns in the result using columns mapping configuration passed in ready method.\n * Returns null if not all required columns were mapped or not widget doesn't support\n * custom column mapping.\n */\nexport function mapColumnNames(data: any, options?: {\n  columns?: ColumnsToMap\n  mappings?: WidgetColumnMap | null,\n  reverse?: boolean,\n}) {\n  options = { columns: _columnsToMap, mappings: _mappingsCache, reverse: false, ...options };\n  // If no column configuration was requested or\n  // table has no rows, return original data.\n  if (!options.columns) {\n    return data;\n  }\n  // If we haven't received columns configuration return null.\n  if (!options.mappings) {\n    return null;\n  }\n  // If we are renaming names for whole table, but it is empty, don't do anything.\n  if (Array.isArray(data) && data.length === 0) {\n    return data;\n  }\n\n  // Prepare convert function - a function that will take record returned from Grist\n  // and convert it to a new record with mapped field names;\n  // Convert function will consists of several transformations:\n  const transformations: ((from: any, to: any) => void)[] = [];\n  // First transformation is for copying id field:\n  transformations.push((from, to) => to.id = from.id);\n  // Helper function to test if a column was configured as optional.\n  function isOptional(col: string) {\n    return Boolean(\n      // Columns passed as strings are required.\n      !options!.columns?.includes(col) &&\n      options!.columns?.find(c => typeof c === \"object\" && c?.name === col && c.optional),\n    );\n  }\n  // For each widget column in mapping.\n  // Keys are ordered for determinism in case of conflicts.\n  for (const widgetCol of Object.keys(options.mappings).sort()) {\n    // Get column from Grist.\n    const gristCol = options.mappings[widgetCol];\n    // Copy column as series (multiple values)\n    if (Array.isArray(gristCol) && gristCol.length) {\n      if (!options.reverse) {\n        transformations.push((from, to) => {\n          to[widgetCol] = gristCol.map(col => from[col]);\n        });\n      } else {\n        transformations.push((from, to) => {\n          for (const [idx, col] of gristCol.entries()) {\n            to[col] = from[widgetCol]?.[idx];\n          }\n        });\n      }\n      // Copy column directly under widget column name.\n    } else if (!Array.isArray(gristCol) && gristCol) {\n      if (!options.reverse) {\n        transformations.push((from, to) => to[widgetCol] = from[gristCol]);\n      } else {\n        transformations.push((from, to) => to[gristCol] = from[widgetCol]);\n      }\n    } else if (!isOptional(widgetCol)) {\n      // Column was not configured but was required.\n      return null;\n    }\n  }\n  // Finally assemble function to convert a single record.\n  const convert = (rec: any) => transformations.reduce((obj, tran) => { tran(rec, obj); return obj; }, {} as any);\n  // Transform all records (or a single one depending on the arguments).\n  return Array.isArray(data) ? data.map(convert) : convert(data);\n}\n\n/**\n * Offer a convenient way to map data with renamed columns back into the\n * form used in the original table. This is useful for making edits to the\n * original table in a widget with column mappings. As for mapColumnNames(),\n * we don't attempt to do these transformations automatically.\n */\nexport function mapColumnNamesBack(data: any, options?: {\n  columns?: ColumnsToMap\n  mappings?: WidgetColumnMap | null,\n}) {\n  return mapColumnNames(data, { ...options, reverse: true });\n}\n\n/**\n * For custom widgets, add a handler that will be called whenever the\n * row with the cursor changes - either by switching to a different row, or\n * by some value within the row potentially changing.  Handler may\n * in the future be called with null if the cursor moves away from\n * any row.\n * By default, `options.keepEncoded` is `false`.\n */\nexport function onRecord(\n  callback: (data: RowRecord | null, mappings: WidgetColumnMap | null) => unknown,\n  options: FetchSelectedOptions = {},\n) {\n  // TODO: currently this will be called even if the content of a different row changes.\n  on(\"message\", async function(msg) {\n    if (!msg.tableId || !msg.rowId || msg.rowId === \"new\") { return; }\n    const rec = await docApi.fetchSelectedRecord(msg.rowId, options);\n    callback(rec, await getMappingsIfChanged(msg));\n  });\n}\n\n/**\n * For custom widgets, add a handler that will be called whenever the\n * new (blank) row is selected.\n */\nexport function onNewRecord(callback: (mappings: WidgetColumnMap | null) => unknown) {\n  on(\"message\", async function(msg) {\n    if (msg.tableId && msg.rowId === \"new\") {\n      callback(await getMappingsIfChanged(msg));\n    }\n  });\n}\n\n/**\n * For custom widgets, add a handler that will be called whenever the\n * selected records change.\n * By default, `options.format` is `'rows'` and `options.keepEncoded` is `false`.\n */\nexport function onRecords(\n  callback: (data: RowRecord[], mappings: WidgetColumnMap | null) => unknown,\n  options: FetchSelectedOptions = {},\n) {\n  options = { ...options, format: options.format || \"rows\" };\n  on(\"message\", async function(msg) {\n    if (!msg.tableId || !msg.dataChange) { return; }\n    const data = await docApi.fetchSelectedTable(options);\n    callback(data, await getMappingsIfChanged(msg));\n  });\n}\n\n/**\n * For custom widgets, add a handler that will be called whenever the\n * widget options change (and on initial ready message). Handler will be\n * called with an object containing saved json options, or null if no options were saved.\n * The second parameter has information about the widgets relationship with\n * the document that contains it.\n */\nexport function onOptions(callback: (options: any, settings: InteractionOptions) => unknown) {\n  on(\"message\", function(msg) {\n    if (msg.settings) {\n      callback(msg.options || null, msg.settings);\n    }\n  });\n}\n\n/**\n * Called whenever the Grist theme changes (and on initial ready message).\n */\nfunction onThemeChange(callback: (theme: any) => unknown) {\n  on(\"message\", function(msg) {\n    if (msg.theme) {\n      callback(msg.theme);\n\n      if (msg.fromReady) {\n        void (async function() {\n          await rpc.postMessage({ message: \"themeInitialized\" });\n        })();\n      }\n    }\n  });\n}\n\n/**\n * Calling `addImporter(...)` adds a safeBrowser importer. It is a short-hand for forwarding calls\n * to an `ImportSourceAPI` implementation registered in the file at `path`. It takes care of\n * creating the stub, registering an implementation that renders the file, forward the call and\n * dispose the view properly. If `mode` is `'inline'` embeds the view in the import modal, otherwise\n * renders fullscreen.\n *\n * Notes: it assumes that file at `path` registers an `ImportSourceAPI` implementation under\n * `name`. Calling `addImporter(...)` from another component than a `safeBrowser` component is not\n * currently supported.\n *\n * @internal\n */\nexport async function addImporter(name: string, path: string, mode: \"fullscreen\" | \"inline\", options?: RenderOptions) {\n  // checker is omitted for implementation because call was already checked by grist.\n  rpc.registerImpl<InternalImportSourceAPI>(name, {\n    async getImportSource(target: RenderTarget): Promise<ImportSource | undefined> {\n      const procId = await api.render(path, mode === \"inline\" ? target : \"fullscreen\", options);\n      try {\n        // stubName for the interface `name` at forward destination `path`\n        const stubName = `${name}@${path}`;\n        // checker is omitted in stub because call will be checked just after in grist.\n        return await rpc.getStub<ImportSourceAPI>(stubName).getImportSource();\n      } finally {\n        await api.dispose(procId);\n      }\n    },\n  });\n}\n\nexport function enableKeyboardShortcuts() {\n  // eslint-disable-next-line @typescript-eslint/no-require-imports\n  const Mousetrap = require(\"mousetrap\");\n  Mousetrap.bind(\"mod+z\", () => commandApi.run(\"undo\"));\n  Mousetrap.bind([\"mod+shift+z\", \"ctrl+y\"], () => commandApi.run(\"redo\"));\n}\n\n/**\n * Options when initializing connection to Grist.\n */\nexport interface ReadyPayload extends Omit<InteractionOptionsRequest, \"hasCustomOptions\"> {\n  /**\n   * Handler that will be called by Grist to open additional configuration panel inside the Custom Widget.\n   */\n  onEditOptions?: () => unknown;\n}\n/**\n * Declare that a component is prepared to receive messages from the outside world.\n * Grist will not attempt to communicate with it until this method is called.\n */\nexport function ready(settings?: ReadyPayload): void {\n  // Make it safe for this method to be called multiple times.\n  if (_readyCalled) { return; }\n  _readyCalled = true;\n\n  if (settings?.onEditOptions) {\n    rpc.registerFunc(\"editOptions\", settings.onEditOptions);\n  }\n  on(\"message\", async function(msg) {\n    if (msg.tableId && msg.tableId !== _tableId) {\n      if (!_tableId) { _setInitialized(); }\n      _tableId = msg.tableId;\n    }\n  });\n  rpc.processIncoming();\n  void (async function() {\n    await rpc.sendReadyMessage();\n    if (settings) {\n      const options = {\n        ...(settings),\n        hasCustomOptions: Boolean(settings.onEditOptions),\n      };\n      delete options.onEditOptions;\n      _columnsToMap = options.columns;\n      await sectionApi.configure(options).catch((err: unknown) => console.error(err));\n    }\n  })();\n}\n\n/** @internal */\nfunction getPluginPath(location: Location) {\n  return location.pathname.replace(/^\\/plugins\\//, \"\");\n}\n\nif (typeof window !== \"undefined\") {\n  // Window or iframe.\n  const preloadWindow: any = window;\n  if (preloadWindow.isRunningUnderElectron) {\n    rpc.setSendMessage(msg => preloadWindow.sendToHost(msg));\n    preloadWindow.onGristMessage((data: any) => rpc.receiveMessage(data));\n  } else {\n    rpc.setSendMessage(msg => window.parent.postMessage(msg, \"*\"));\n    window.onmessage = (e: MessageEvent) => rpc.receiveMessage(e.data);\n  }\n\n  // Allow outer Grist application to trigger printing. This is similar to using\n  // iframe.contentWindow.print(), but that call does not work cross-domain.\n  rpc.registerFunc(\"print\", () => window.print());\n} else if (typeof process === \"undefined\") {\n  // Web worker. We can't really bring in the types for WebWorker (available with --lib flag)\n  // without conflicting with a regular window, so use just use `self as any` here.\n  self.onmessage = (e: MessageEvent) => rpc.receiveMessage(e.data);\n  rpc.setSendMessage((mssg: any) => (self as any).postMessage(mssg));\n} else if (typeof process.send !== \"undefined\") {\n  // Forked ChildProcess of node or electron.\n  // sendMessage callback returns void 0 because rpc process.send returns a boolean and rpc\n  // expecting void|Promise interprets truthy values as Promise which cause failure.\n  rpc.setSendMessage((data) => { process.send!(data); });\n  process.on(\"message\", (data: any) => rpc.receiveMessage(data));\n  process.on(\"disconnect\", () => { process.exit(0); });\n} else {\n  // Not a recognized environment, perhaps plain nodejs run independently of Grist, or tests\n  // running under mocha. For now, we only provide a dysfunctional implementation. It allows\n  // plugins to call methods like registerFunction() without failing, so that plugin code may be\n  // imported, but the methods don't do anything useful.\n  rpc.setSendMessage((data) => { return; });\n}\n\n/** @internal */\nfunction createRpcLogger(): IRpcLogger {\n  let prefix: string;\n  if (typeof window !== \"undefined\") {\n    prefix = `PLUGIN VIEW ${getPluginPath(window.location)}:`;\n  } else if (typeof process === \"undefined\") {\n    prefix = `PLUGIN VIEW ${getPluginPath(self.location)}:`;\n  } else if (typeof process.send !== \"undefined\") {\n    prefix = `PLUGIN NODE ${process.env.GRIST_PLUGIN_PATH || \"<unset-plugin-id>\"}:`;\n  } else {\n    return {};\n  }\n  return {\n    info(msg: string) { console.log(\"%s %s\", prefix, msg); },\n    warn(msg: string) { console.warn(\"%s %s\", prefix, msg); },\n  };\n}\n\nlet _theme: any;\n\nonThemeChange((newTheme) => {\n  if (isEqual(newTheme, _theme)) { return; }\n\n  _theme = newTheme;\n  attachCssThemeVars(_theme);\n});\n\nfunction attachCssThemeVars({ appearance, name, colors: cssVars }: any) {\n  // Prepare the custom properties needed for applying the theme.\n  const properties = Object.entries(cssVars)\n    .map(([propName, value]) => `--grist-theme-${propName}: ${value};`);\n\n  // Include properties for styling the scrollbar.\n  properties.push(...getCssScrollbarProperties(appearance));\n\n  // Apply the properties to the theme style element.\n  // The 'grist-theme' layer takes precedence over the 'grist-base' layer where\n  // default CSS variables are defined.\n  getOrCreateStyleElement(\"grist-theme\").textContent = `@layer grist-theme {\n  :root {\n${properties.join(\"\\n\")}\n  }\n}\n`;\n\n  // Make the browser aware of the color scheme.\n  document.documentElement.style.setProperty(`color-scheme`, appearance);\n\n  // Add data-attributes to let plugins easily identify theme name and appearance with CSS.\n  document.documentElement.setAttribute(\"data-grist-theme\", name);\n  document.documentElement.setAttribute(\"data-grist-appearance\", appearance);\n}\n\nfunction getCssScrollbarProperties(appearance: \"light\" | \"dark\") {\n  return [\n    \"--scroll-bar-fg: \" +\n    (appearance === \"dark\" ? \"#6B6B6B;\" : \"#A8A8A8;\"),\n    \"--scroll-bar-hover-fg: \" +\n    (appearance === \"dark\" ? \"#7B7B7B;\" : \"#8F8F8F;\"),\n    \"--scroll-bar-active-fg: \" +\n    (appearance === \"dark\" ? \"#8B8B8B;\" : \"#7C7C7C;\"),\n    \"--scroll-bar-bg: \" +\n    (appearance === \"dark\" ? \"#2B2B2B;\" : \"#F0F0F0;\"),\n  ];\n}\n\nfunction getOrCreateStyleElement(id: string) {\n  let style = document.head.querySelector(`#${id}`);\n  if (style) { return style; }\n  style = document.createElement(\"style\");\n  style.setAttribute(\"id\", id);\n  document.head.append(style);\n  return style;\n}\n"
  },
  {
    "path": "app/plugin/gutil.ts",
    "content": "import constant from \"lodash/constant\";\nimport times from \"lodash/times\";\n\n/**\n * Returns a new array of length count, filled with the given value.\n */\nexport function arrayRepeat<T>(count: number, value: T): T[] {\n  return times(count, constant(value));\n}\n\nexport type MaybePromise<T> = T | Promise<T>;\n"
  },
  {
    "path": "app/plugin/objtypes.ts",
    "content": "/**\n * Encodes and decodes Grist encoding of values, mirroring similar Python functions in\n * sandbox/grist/objtypes.py.\n */\n\nimport { CellValue, GristObjCode } from \"app/plugin/GristData\";\n\nimport isPlainObject from \"lodash/isPlainObject\";\n\n// The text to show on cells whose values are pending.\nexport const PENDING_DATA_PLACEHOLDER = \"Loading...\";\n\n/**\n * A GristDate is just a JS Date object whose toString() method returns YYYY-MM-DD.\n */\nexport class GristDate extends Date {\n  public static fromGristValue(epochSec: number): GristDate {\n    return new GristDate(epochSec * 1000);\n  }\n\n  public toString() {\n    return this.toISOString().slice(0, 10);\n  }\n}\n\n/**\n * A GristDateTime is a JS Date with an added timezone field. Its toString() returns the date in\n * ISO format. To create a timezone-aware momentjs object, use:\n *\n *    moment(d).tz(d.timezone)\n */\nexport class GristDateTime extends Date {\n  public static fromGristValue(epochSec: number, timezone: string): GristDateTime {\n    return Object.assign(new GristDateTime(epochSec * 1000), { timezone });\n  }\n\n  public timezone: string;\n  public toString() { return this.toISOString(); }\n}\n\n/**\n * A Reference represents a reference to a row in a table. It is simply a pair of a string tableId\n * and a numeric rowId.\n */\nexport class Reference {\n  constructor(public tableId: string, public rowId: number) {}\n\n  public toString(): string {\n    return `${this.tableId}[${this.rowId}]`;\n  }\n}\n\n/**\n * A ReferenceList represents a reference to a number of rows in a table. It is simply a pair of a string tableId\n * and a numeric array rowIds.\n */\nexport class ReferenceList {\n  constructor(public tableId: string, public rowIds: number[]) {}\n\n  public toString(): string {\n    return `${this.tableId}[[${this.rowIds}]]`;\n  }\n}\n\n/**\n * A RaisedException represents a formula error. It includes the exception name, message, and\n * optional details.\n */\nexport class RaisedException {\n  public name: string;\n  public details?: string;\n  public message?: string;\n  public user_input?: CellValue;\n\n  constructor(list: any[]) {\n    if (!list.length) {\n      throw new Error(\"RaisedException requires a name as first element\");\n    }\n    list = [...list];\n    this.name = list.shift();\n    this.message = list.shift();\n    this.details = list.shift();\n    this.user_input = list.shift()?.u;\n  }\n\n  /**\n   * This is designed to look somewhat similar to Excel, e.g. #VALUE or #DIV/0!\"\n   */\n  public toString() {\n    switch (this.name) {\n      case \"ZeroDivisionError\": return \"#DIV/0!\";\n      case \"UnmarshallableError\": return this.details || (\"#\" + this.name);\n      case \"InvalidTypedValue\": return `#Invalid ${this.message}: ${this.details}`;\n    }\n    return \"#\" + this.name;\n  }\n}\n\n/**\n * An UnknownValue is a fallback for values that we don't handle otherwise, e.g. of a Python\n * formula returned a function object, or a value we fail to decode.\n * It is typically the Python repr() string of the value.\n */\nexport class UnknownValue {\n  // When encoding an unknown value, get a best-effort string form of it.\n  public static safeRepr(value: unknown): string {\n    try {\n      return String(value);\n    } catch (e) {\n      return `<${typeof value}>`;\n    }\n  }\n\n  constructor(public value: unknown) {}\n  public toString() {\n    return String(this.value);\n  }\n}\n\n/**\n * A trivial placeholder for a value that's not yet available.\n */\nexport class PendingValue {\n  public toString() {\n    return PENDING_DATA_PLACEHOLDER;\n  }\n}\n\n/**\n * A trivial placeholder for a value that won't be shown.\n */\nexport class SkipValue {\n  public toString() {\n    return \"...\";\n  }\n}\n\n/**\n * A placeholder for a value hidden by access control rules.\n * Depending on the types of the columns involved, copying\n * a censored value and pasting elsewhere will either use\n * CensoredValue.__repr__ (python) or CensoredValue.toString (typescript)\n * so they should match\n */\nexport class CensoredValue {\n  public toString() {\n    return \"CENSORED\";\n  }\n}\n\n/**\n * Produces a Grist-encoded version of the value, e.g. turning a Date into ['d', timestamp].\n * Returns ['U', repr(value)] if it fails to encode otherwise.\n *\n * TODO Add tests. This is not yet used for anything.\n */\nexport function encodeObject(value: unknown): CellValue {\n  try {\n    switch (typeof value) {\n      case \"string\":\n      case \"number\":\n      case \"boolean\":\n        return value;\n    }\n    if (value == null) {\n      return null;\n    } else if (value instanceof Reference) {\n      return [GristObjCode.Reference, value.tableId, value.rowId];\n    } else if (value instanceof ReferenceList) {\n      return [GristObjCode.ReferenceList, value.tableId, value.rowIds];\n    } else if (value instanceof Date) {\n      const timestamp = value.valueOf() / 1000;\n      if (\"timezone\" in value) {\n        return [GristObjCode.DateTime, timestamp, (value as GristDateTime).timezone];\n      } else {\n        // TODO Depending on how it's used, may want to return ['d', timestamp] for UTC midnight.\n        return [GristObjCode.DateTime, timestamp, \"UTC\"];\n      }\n    } else if (value instanceof CensoredValue) {\n      return [GristObjCode.Censored];\n    } else if (value instanceof RaisedException) {\n      return [GristObjCode.Exception, value.name, value.message, value.details];\n    } else if (Array.isArray(value)) {\n      return [GristObjCode.List, ...value.map(encodeObject)];\n    } else if (isPlainObject(value)) {\n      return [GristObjCode.Dict, mapValues(value as any, encodeObject, { sort: true })];\n    }\n  } catch (e) {\n    // Fall through to return a best-effort representation.\n  }\n  // We either don't know how to convert the value, or failed during the conversion. Instead we\n  // return an \"UnmarshallableValue\" object, with repr() of the value to show to the user.\n  return [GristObjCode.Unmarshallable, UnknownValue.safeRepr(value)];\n}\n\n/**\n * Given a Grist-encoded value, returns an object represented by it.\n * If the type code is unknown, or construction fails for any reason, returns an UnknownValue.\n */\nexport function decodeObject(value: CellValue): unknown {\n  if (!Array.isArray(value)) {\n    return value;\n  }\n  const code: string = value[0];\n  const args: any[] = value.slice(1);\n  let err: Error | undefined;\n  try {\n    switch (code) {\n      case \"D\": return GristDateTime.fromGristValue(args[0], String(args[1]));\n      case \"d\": return GristDate.fromGristValue(args[0]);\n      case \"E\": return new RaisedException(args);\n      case \"L\": return (args as CellValue[]).map(decodeObject);\n      case \"O\": return mapValues(args[0] as { [key: string]: CellValue }, decodeObject, { sort: true });\n      case \"P\": return new PendingValue();\n      case \"r\": return new ReferenceList(String(args[0]), args[1]);\n      case \"R\": return new Reference(String(args[0]), args[1]);\n      case \"S\": return new SkipValue();\n      case \"C\": return new CensoredValue();\n      case \"U\": return new UnknownValue(args[0]);\n    }\n  } catch (e) {\n    err = e;\n  }\n  // If we can't decode, return an UnknownValue with some attempt to represent what we couldn't\n  // decode as long as some info about the error if any.\n  return new UnknownValue(`${code}(${JSON.stringify(args).slice(1, -1)})` +\n    (err ? `#${err.name}(${err.message})` : \"\"));\n}\n\n// Like lodash's mapValues, with support for sorting keys, for friendlier output.\nexport function mapValues<A, B>(\n  sourceObj: { [key: string]: A }, mapper: (value: A) => B, options: { sort?: boolean } = {},\n): { [key: string]: B } {\n  const result: { [key: string]: B } = {};\n  const keys = Object.keys(sourceObj);\n  if (options.sort) {\n    keys.sort();\n  }\n  for (const key of keys) {\n    result[key] = mapper(sourceObj[key]);\n  }\n  return result;\n}\n"
  },
  {
    "path": "app/plugin/tsconfig.json",
    "content": "{\n  \"extends\": \"../../buildtools/tsconfig-base.json\",\n}\n"
  },
  {
    "path": "app/server/MergedServer.ts",
    "content": "/**\n *\n * A version of hosted grist that recombines a home server,\n * a doc worker, and a static server on a single port.\n *\n */\n\nimport { FlexServer, FlexServerOptions } from \"app/server/lib/FlexServer\";\nimport { getGlobalConfig } from \"app/server/lib/globalConfig\";\nimport log from \"app/server/lib/log\";\n\n// Allowed server types. We'll start one or a combination based on the value of GRIST_SERVERS\n// environment variable.\nexport type ServerType = \"home\" | \"docs\" | \"static\" | \"app\";\nconst allServerTypes: ServerType[] = [\"home\", \"docs\", \"static\", \"app\"];\n\n// Parse a comma-separate list of server types into an array, with validation.\nexport function parseServerTypes(serverTypes: string | undefined): ServerType[] {\n  // Split and filter out empty strings (including the one we get when splitting \"\").\n  const types = (serverTypes || \"\").trim().split(\",\").filter(part => Boolean(part));\n\n  // Check that parts is non-empty and only contains valid options.\n  if (!types.length) {\n    throw new Error(`No server types; should be a comma-separated list of ${allServerTypes.join(\", \")}`);\n  }\n  for (const t of types) {\n    if (!allServerTypes.includes(t as ServerType)) {\n      throw new Error(`Invalid server type '${t}'; should be in ${allServerTypes.join(\", \")}`);\n    }\n  }\n  return types as ServerType[];\n}\n\nfunction checkUserContentPort(): number | null {\n  // Check whether a port is explicitly set for user content.\n  if (process.env.GRIST_UNTRUSTED_PORT) {\n    return parseInt(process.env.GRIST_UNTRUSTED_PORT, 10);\n  }\n  // Checks whether to serve user content on same domain but on different port\n  if (process.env.APP_UNTRUSTED_URL && process.env.APP_HOME_URL) {\n    const homeUrl = new URL(process.env.APP_HOME_URL);\n    const pluginUrl = new URL(process.env.APP_UNTRUSTED_URL);\n    // If the hostname of both home and plugin url are the same,\n    // but the ports are different\n    if (homeUrl.hostname === pluginUrl.hostname &&\n      homeUrl.port !== pluginUrl.port) {\n      const port = parseInt(pluginUrl.port || \"80\", 10);\n      return port;\n    }\n  }\n  return null;\n}\n\ninterface ServerOptions extends FlexServerOptions {\n  // If set, messages logged to console (default: false)\n  // (but if options are not given at all in call to main, logToConsole is set to true)\n  logToConsole?: boolean;\n\n  // If set, documents saved to external storage such as s3 (default is to check environment variables,\n  // which get set in various ways in dev/test entry points)\n  externalStorage?: boolean;\n\n  // If set, add this many extra dedicated doc workers,\n  // at ports above the main server.\n  extraWorkers?: number;\n}\n\nexport class MergedServer {\n  public static async create(port: number, serverTypes: ServerType[], options: ServerOptions = {}) {\n    options.settings ??= getGlobalConfig();\n    const ms = new MergedServer(port, serverTypes, options);\n    // We need to know early on whether we will be serving plugins or not.\n    if (ms.hasComponent(\"home\")) {\n      const userPort = checkUserContentPort();\n      ms.flexServer.setServesPlugins(userPort !== undefined);\n    } else {\n      ms.flexServer.setServesPlugins(false);\n    }\n\n    ms.flexServer.addCleanup();\n    ms.flexServer.setDirectory();\n\n    if (process.env.GRIST_TEST_ROUTER) {\n      // Add a mock api for adding/removing doc workers from load balancer.\n      ms.flexServer.testAddRouter();\n    }\n\n    if (ms._options.logToConsole !== false) { ms.flexServer.addLogging(); }\n    if (ms._options.externalStorage === false) { ms.flexServer.disableExternalStorage(); }\n    await ms.flexServer.initHomeDBManager();\n    await ms.flexServer.addLoginMiddleware();\n\n    if (ms.hasComponent(\"docs\")) {\n      // It is important that /dw and /v prefixes are accepted (if present) by health check\n      // in ms case, since they are included in the url registered for the doc worker.\n      ms.flexServer.stripDocWorkerIdPathPrefixIfPresent();\n      ms.flexServer.addTagChecker();\n    }\n\n    ms.flexServer.addHealthCheck();\n    if (ms.hasComponent(\"home\") || ms.hasComponent(\"app\")) {\n      ms.flexServer.addBootPage();\n    }\n    ms.flexServer.denyRequestsIfNotReady();\n\n    if (ms.hasComponent(\"home\") || ms.hasComponent(\"static\") || ms.hasComponent(\"app\")) {\n      ms.flexServer.setDirectory();\n    }\n\n    if (ms.hasComponent(\"home\") || ms.hasComponent(\"static\")) {\n      ms.flexServer.addStaticAndBowerDirectories();\n    }\n\n    ms.flexServer.addHosts();\n\n    ms.flexServer.addDocWorkerMap();\n\n    if (ms.hasComponent(\"home\") || ms.hasComponent(\"static\")) {\n      await ms.flexServer.addAssetsForPlugins();\n    }\n\n    if (ms.hasComponent(\"home\")) {\n      ms.flexServer.addEarlyWebhooks();\n    }\n\n    if (ms.hasComponent(\"home\") || ms.hasComponent(\"docs\") || ms.hasComponent(\"app\")) {\n      ms.flexServer.addSessions();\n    }\n\n    ms.flexServer.addAccessMiddleware();\n    ms.flexServer.addApiMiddleware();\n    ms.flexServer.addBillingMiddleware();\n\n    return ms;\n  }\n\n  public readonly flexServer: FlexServer;\n  private readonly _serverTypes: ServerType[];\n  private readonly _options: ServerOptions;\n  // It can be useful to start up a bunch of extra workers within\n  // a single process mostly for testing purposes, but conceivably\n  // for real too.\n  private readonly _extraWorkers: MergedServer[] = [];\n\n  private constructor(port: number, serverTypes: ServerType[], options: ServerOptions = {}) {\n    this._serverTypes = serverTypes;\n    this._options = options;\n    this.flexServer = new FlexServer(port, `server(${serverTypes.join(\",\")})`, options);\n  }\n\n  public hasComponent(serverType: ServerType) {\n    return this._serverTypes.includes(serverType);\n  }\n\n  public async run() {\n    try {\n      await this.flexServer.start();\n\n      if (this.hasComponent(\"home\")) {\n        await this._maybeClearSessions();\n        this.flexServer.addUsage();\n        if (!this.hasComponent(\"docs\")) {\n          this.flexServer.addDocApiForwarder();\n        }\n        await this.flexServer.addLandingPages();\n        // Early endpoints use their own json handlers, so they come before\n        // `addJsonSupport`.\n        this.flexServer.addEarlyApi();\n        this.flexServer.addJsonSupport();\n        this.flexServer.addUpdatesCheck();\n        this.flexServer.addWidgetRepository();\n        // todo: add support for home api to standalone app\n        this.flexServer.addHomeApi();\n        this.flexServer.addScimApi();\n        this.flexServer.addBillingApi();\n        this.flexServer.addNotifier();\n        this.flexServer.addAuditLogger();\n        await this.flexServer.addTelemetry();\n        this.flexServer.addAssistant();\n        await this.flexServer.addHousekeeper();\n        await this.flexServer.addLoginRoutes();\n        this.flexServer.addAccountPage();\n        this.flexServer.addBillingPages();\n        this.flexServer.addWelcomePaths();\n        this.flexServer.addLogEndpoint();\n        this.flexServer.addGoogleAuthEndpoint();\n        this.flexServer.addConfigEndpoints();\n      }\n\n      if (this.hasComponent(\"docs\")) {\n        this.flexServer.addJsonSupport();\n        this.flexServer.addWidgetRepository();\n        this.flexServer.addAuditLogger();\n        await this.flexServer.addTelemetry();\n        this.flexServer.addAssistant();\n        await this.flexServer.addDoc();\n      }\n\n      if (this.hasComponent(\"home\")) {\n        this.flexServer.addClientSecrets();\n      }\n\n      this.flexServer.finalizeEndpoints();\n      await this.flexServer.finalizePlugins(this.hasComponent(\"home\") ? checkUserContentPort() : null);\n      this.flexServer.checkOptionCombinations();\n      this.flexServer.summary();\n      this.flexServer.setReady(true);\n\n      if (this._options.extraWorkers) {\n        if (!process.env.REDIS_URL) {\n          throw new Error(\"Redis needed to support multiple workers\");\n        }\n        for (let i = 1; i <= this._options.extraWorkers; i++) {\n          const server = await MergedServer.create(0, [\"docs\"], {\n            ...this._options,\n            extraWorkers: undefined,\n          });\n          await server.run();\n          this._extraWorkers.push(server);\n        }\n      }\n    } catch (e) {\n      await this.close();\n      throw e;\n    }\n  }\n\n  public async close() {\n    for (const worker of this._extraWorkers) {\n      await worker.flexServer.close();\n    }\n    await this.flexServer.close();\n  }\n\n  /**\n   * Look up a worker from its id in Redis.\n   */\n  public testGetWorkerFromId(docWorkerId: string): MergedServer {\n    if (this.flexServer.worker?.id === docWorkerId) {\n      return this;\n    }\n    for (const worker of this._extraWorkers) {\n      if (worker.flexServer.worker.id === docWorkerId) {\n        return worker;\n      }\n    }\n    throw new Error(\"Worker not found\");\n  }\n\n  private async _maybeClearSessions() {\n    try {\n      const activations = this.flexServer.getActivations();\n      // deletePrefs creates a transaction to remove and return onRestartClearSessions.\n      // This is important when there are multiple home servers, as we only want\n      // one server to get back a truthy value and proceed with clearing sessions.\n      const { onRestartClearSessions } = await activations.deletePrefs([\"onRestartClearSessions\"]);\n      if (!onRestartClearSessions) { return; }\n\n      log.info(\"Clearing sessions...\");\n      await this.flexServer.getSessions().clearAllSessions();\n      log.info(\"Successfully cleared sessions\");\n    } catch (err) {\n      // Don't re-throw so we don't disrupt the rest of the startup process.\n      log.error(\"Failed to clear sessions:\", err);\n    }\n  }\n}\n\nexport async function startMain() {\n  try {\n    const serverTypes = parseServerTypes(process.env.GRIST_SERVERS);\n\n    // No defaults for a port, since this server can serve very different purposes.\n    if (!process.env.GRIST_PORT) {\n      throw new Error(\"GRIST_PORT must be specified\");\n    }\n\n    const port = parseInt(process.env.GRIST_PORT, 10);\n\n    let extraWorkers = 0;\n    if (process.env.DOC_WORKER_COUNT) {\n      extraWorkers = parseInt(process.env.DOC_WORKER_COUNT, 10);\n      // If the main server functions also as a doc worker, then\n      // we need one fewer extra servers.\n      if (serverTypes.includes(\"docs\")) { extraWorkers--; }\n    }\n\n    const server = await MergedServer.create(port, serverTypes, {\n      extraWorkers,\n    });\n    await server.run();\n\n    const opt = process.argv[2];\n    if (opt === \"--testingHooks\") {\n      await server.flexServer.addTestingHooks();\n    }\n\n    return server.flexServer;\n  } catch (e) {\n    log.error(\"mergedServer failed to start\", e);\n    process.exit(1);\n  }\n}\n\nif (require.main === module) {\n  startMain().catch(e => log.error(\"mergedServer failed to start\", e));\n}\n"
  },
  {
    "path": "app/server/companion.ts",
    "content": "import { Level, TelemetryContracts } from \"app/common/Telemetry\";\nimport { gitcommit, version } from \"app/common/version\";\nimport { synchronizeProducts } from \"app/gen-server/entity/Product\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { applyPatch } from \"app/gen-server/lib/TypeORMPatches\";\nimport { getMigrations, getOrCreateConnection, getTypeORMSettings,\n  undoLastMigration, updateDb } from \"app/server/lib/dbUtils\";\nimport { getDatabaseUrl } from \"app/server/lib/serverUtils\";\nimport { getTelemetryPrefs } from \"app/server/lib/Telemetry\";\nimport { Gristifier } from \"app/server/utils/gristify\";\nimport { pruneActionHistory } from \"app/server/utils/pruneActionHistory\";\nimport { showAuditLogEvents } from \"app/server/utils/showAuditLogEvents\";\n\nimport * as commander from \"commander\";\nimport { Connection } from \"typeorm\";\n\n/**\n * Main entrypoint for a cli toolbox for configuring aspects of Grist\n * and Grist documents.\n */\nasync function main() {\n  // Tweak TypeORM support of SQLite a little bit to support transactions.\n  applyPatch();\n  const program = getProgram();\n  await program.parseAsync(process.argv);\n}\n\nif (require.main === module) {\n  main().then(() => process.exit(0)).catch((e) => {\n    console.error(e);\n    process.exit(1);\n  });\n}\n\n/**\n * Get the Grist companion client program as a commander object.\n * To actually run it, call parseAsync(argv), optionally after\n * adding any other commands that may be available.\n */\nexport function getProgram(): commander.Command {\n  const program = commander.program;\n  program\n    .name(\"grist-cli\")\n    .description(\"a toolbox of handy Grist-related utilities\");\n\n  addAuditLogsCommand(program, { nested: true });\n  addDbCommand(program, { nested: true });\n  addHistoryCommand(program, { nested: true });\n  addSettingsCommand(program, { nested: true });\n  addSiteCommand(program, { nested: true });\n  addSqliteCommand(program);\n  addVersionCommand(program);\n  return program;\n}\n\nfunction addAuditLogsCommand(program: commander.Command, options: CommandOptions) {\n  const sub = section(program, {\n    sectionName: \"audit-logs\",\n    sectionDescription: \"show information about audit logs\",\n    ...options,\n  });\n  sub(\"events\")\n    .description(\"show audit log events\")\n    .addOption(\n      new commander.Option(\"--type <type>\")\n        .choices([\"installation\", \"site\"])\n        .makeOptionMandatory(),\n    )\n    .action(showAuditLogEvents);\n}\n\n// Add commands related to document history:\n//   history prune <docId> [N]\nexport function addHistoryCommand(program: commander.Command, options: CommandOptions) {\n  const sub = section(program, {\n    sectionName: \"history\",\n    sectionDescription: \"fiddle with history of a Grist document\",\n    ...options,\n  });\n  sub(\"prune <docId>\")\n    .description(\"remove all but last N actions from doc\")\n    .argument(\"[N]\", \"number of actions to keep\", parseIntForCommander, 1)\n    .action(pruneActionHistory);\n}\n\n// Add commands for general configuration\nexport function addSettingsCommand(program: commander.Command,\n  options: CommandOptions) {\n  const sub = section(program, {\n    sectionName: \"settings\",\n    sectionDescription: \"general configuration\",\n    ...options,\n  });\n  sub(\"telemetry\")\n    .description(\"show telemetry settings\")\n    .option(\"--json\", \"show telemetry levels as json\")\n    .option(\"--all\", \"show all telemetry levels\")\n    .action(showTelemetry);\n}\n\nasync function showTelemetry(options: {\n  json?: boolean,\n  all?: boolean,\n}) {\n  const contracts = TelemetryContracts;\n  const db = await getHomeDBManager();\n  const prefs = await getTelemetryPrefs(db);\n  const levelName = prefs.telemetryLevel.value;\n  const level = Level[levelName];\n  if (options.json) {\n    console.log(JSON.stringify({\n      contracts,\n      currentLevel: level,\n      currentLevelName: levelName,\n    }, null, 2));\n  } else {\n    if (options.all) {\n      console.log(\"# All telemetry levels\");\n      console.log(\"\");\n      for (const iLevel of [Level.off, Level.limited, Level.full]) {\n        describeTelemetryLevel(iLevel, \"#\");\n        console.log(\"\");\n        showTelemetryAtLevel(iLevel, \"##\");\n        console.log(\"\");\n      }\n    } else {\n      describeTelemetryLevel(level, \"\");\n      console.log(\"\");\n      showTelemetryAtLevel(level, \"#\");\n    }\n  }\n}\n\nfunction describeTelemetryLevel(level: Level, nesting: \"\" | \"#\") {\n  switch (level) {\n    case Level.off:\n      console.log(nesting + \"# Telemetry level: off\");\n      console.log(\"No telemetry is recorded or transmitted.\");\n      break;\n    case Level.limited:\n      console.log(nesting + \"# Telemetry level: limited\");\n      console.log(\"This is a telemetry level appropriate for self-hosting instances of Grist.\");\n      console.log(\"Data is transmitted to Grist Labs.\");\n      break;\n    case Level.full:\n      console.log(nesting + \"# Telemetry level: full\");\n      console.log(\"This is a telemetry level appropriate for internal use by a hosted service, with\");\n      console.log(\"`GRIST_TELEMETRY_URL` set to an endpoint controlled by the operator of the service.\");\n      break;\n  }\n}\n\nfunction showTelemetryAtLevel(level: Level, nesting: \"\" | \"#\" | \"##\") {\n  const contracts = TelemetryContracts;\n  for (const [name, contract] of Object.entries(contracts)) {\n    if (contract.minimumTelemetryLevel > level) { continue; }\n    console.log(nesting + \"# \" + name);\n    console.log(contract.description);\n    console.log(\"\");\n    console.log(\"| Field | Type | Description |\");\n    console.log(\"| ----- | ---- | ----------- |\");\n    for (const [fieldName, metadata] of Object.entries(contract.metadataContracts || {})) {\n      if ((metadata.minimumTelemetryLevel || 0) > level) { continue; }\n      console.log(\"| \" + fieldName + \" | \" + metadata.dataType + \" | \" + metadata.description + \" |\");\n    }\n    console.log(\"\");\n  }\n}\n\n// Add commands related to sites:\n//   site create <domain> <owner-email>\nexport function addSiteCommand(program: commander.Command,\n  options: CommandOptions) {\n  const sub = section(program, {\n    sectionName: \"site\",\n    sectionDescription: \"set up sites\",\n    ...options,\n  });\n  sub(\"create <domain> <owner-email>\")\n    .description(\"create a site\")\n    .action(async (domain, email) => {\n      console.log(\"create a site\");\n      const profile = { email, name: email };\n      const db = await getHomeDBManager();\n      const user = await db.getUserByLogin(email, { profile });\n      db.unwrapQueryResult(await db.addOrg(user, {\n        name: domain,\n        domain,\n      }, {\n        setUserAsOwner: false,\n        useNewPlan: true,\n        product: \"teamFree\",\n      }));\n    });\n}\n\n// Add commands related to home/landing database:\n//   db migrate\n//   db revert\n//   db check\n//   db url\nexport function addDbCommand(program: commander.Command,\n  options: CommandOptions,\n  reuseConnection?: Connection) {\n  function withConnection(op: (connection: Connection) => Promise<number>) {\n    return async () => {\n      if (!process.env.TYPEORM_LOGGING) {\n        process.env.TYPEORM_LOGGING = \"true\";\n      }\n      const connection = reuseConnection || await getOrCreateConnection();\n      const exitCode = await op(connection);\n      if (exitCode !== 0) {\n        program.error(\"db command failed\", { exitCode });\n      }\n    };\n  }\n  const sub = section(program, {\n    sectionName: \"db\",\n    sectionDescription: \"maintain the database of users, sites, workspaces, and docs\",\n    ...options,\n  });\n\n  sub(\"migrate\")\n    .description(\"run all pending migrations on database\")\n    .action(withConnection(async (connection) => {\n      await updateDb(connection);\n      return 0;\n    }));\n\n  sub(\"revert\")\n    .description(\"revert last migration on database\")\n    .action(withConnection(async (connection) => {\n      await undoLastMigration(connection);\n      return 0;\n    }));\n\n  sub(\"check\")\n    .description(\"check that there are no pending migrations on database\")\n    .action(withConnection(dbCheck));\n\n  sub(\"url\")\n    .description(\"construct a url for the database (for psql, catsql etc)\")\n    .action(withConnection(async () => {\n      console.log(getDatabaseUrl(getTypeORMSettings(), true));\n      return 0;\n    }));\n}\n\n// Add command related to sqlite:\n//   sqlite gristify <sqlite-file>\n//   sqlite clean <sqlite-file>\n//   sqlite query <sqlite-file> <query-string>\nexport function addSqliteCommand(program: commander.Command) {\n  const sub = program.command(\"sqlite\")\n    .description(\"commands for accessing sqlite files\");\n\n  sub.command(\"gristify <sqlite-file>\")\n    .description(\"add grist metadata to an sqlite file\")\n    .option(\"--add-sort\", \"add a manualSort column, important for adding/removing rows\")\n    .action((filename, options) => new Gristifier(filename).gristify(options));\n\n  sub.command(\"clean <sqlite-file>\")\n    .description(\"remove grist metadata from an sqlite file\")\n    .action(filename => new Gristifier(filename).degristify());\n\n  sub.command(\"query <sqlite-file> <query-string>\")\n    .description(\"read data from a sqlite file that may contain Grist marshaling\")\n    .option(\"--json\", \"output as JSON\")\n    .action((filename, query, options) => new Gristifier(filename).query(query, options));\n}\n\nexport function addVersionCommand(program: commander.Command) {\n  program.command(\"version\")\n    .description(\"show Grist version\")\n    .option(\"--with-commit\", \"include the commit string\")\n    .option(\"--verbose\", \"use your words\")\n    .action((options) => {\n      if (options.verbose) {\n        const displayVersion = options.withCommit ? `${version} (commit ${gitcommit})` : version;\n        console.log(`Grist version is ${displayVersion}`);\n      } else {\n        const displayVersion = options.withCommit ? `${version} ${gitcommit}` : version;\n        console.log(displayVersion);\n      }\n    });\n}\n\n// Report the status of the database. Migrations appied, migrations pending,\n// product information applied, product changes pending.\nexport async function dbCheck(connection: Connection) {\n  const migrations = await getMigrations(connection);\n  const changingProducts = await synchronizeProducts(connection, false);\n  const log = process.env.TYPEORM_LOGGING === \"true\" ? console.log : (...args: any[]) => null;\n  const options = getTypeORMSettings();\n  log(\"database url:\", getDatabaseUrl(options, false));\n  log(\"migration files:\", options.migrations);\n  log(\"migrations applied to db:\", migrations.migrationsInDb);\n  log(\"migrations listed in code:\", migrations.migrationsInCode);\n  let exitCode: number = 0;\n  if (migrations.pendingMigrations.length) {\n    log(`Migration(s) need to be applied: ${migrations.pendingMigrations}`);\n    exitCode = 1;\n  } else {\n    log(\"No migrations need to be applied\");\n  }\n  log(\"\");\n  if (changingProducts.length) {\n    log(\"Products need updating:\", changingProducts);\n    log(`   (to revert a product change, run an older version of the code)`);\n    log(`   (db:revert will not undo product changes)`);\n    exitCode = 1;\n  } else {\n    log(`Products unchanged`);\n  }\n  return exitCode;\n}\n\n// Get an interface to the home db.\nexport async function getHomeDBManager() {\n  const dbManager = new HomeDBManager();\n  await dbManager.connect();\n  await dbManager.initializeSpecialIds();\n  return dbManager;\n}\n\n// Get a function for adding a command to a section of related commands.\n// There is a \"nested\" option that uses commander's nested command feature.\n// Older cli code may use an older unnested style.\nfunction section(program: commander.Command, options: {\n  sectionName: string,\n  sectionDescription: string,\n  nested: boolean\n}) {\n  // If unnested, we'll return a function that adds commands directly to the\n  // program (section description is ignored in this case). If nested, we make\n  // a command to represent the section, and return a function that adds to that.\n  const sub = options.nested ?\n    program.command(options.sectionName).description(options.sectionDescription) :\n    program;\n  return (name: string) => {\n    if (options.nested) {\n      return sub.command(name);\n    } else {\n      return sub.command(`${options.sectionName}:${name}`);\n    }\n  };\n}\n\n// Options for command style.\nexport interface CommandOptions {\n  nested: boolean,\n  sectionName?: string,\n}\n\n// This is based on the recommended way to parse integers for commander.\nexport function parseIntForCommander(value: string, prev: number) {\n  const pvalue = parseInt(value, 10);\n  if (isNaN(pvalue)) {\n    throw new Error(\"Not a number.\");\n  }\n  return pvalue;\n}\n"
  },
  {
    "path": "app/server/declarations.d.ts",
    "content": "declare module \"app/server/lib/ActionLog\";\ndeclare module \"app/server/lib/sandboxUtil\";\ndeclare module \"app/server/lib/User\";\n\ndeclare module \"app/server/lib/shutdown\" {\n  export function addCleanupHandler<T>(context: T, method: (this: T) => void, timeout?: number, name?: string): void;\n  export function removeCleanupHandlers<T>(context: T): void;\n  export function cleanupOnSignals(...signalNames: string[]): void;\n  export function exit(optExitCode?: number): Promise<void>;\n}\n\n// There is a @types/bluebird, but it's not great, and breaks for some of our usages.\ndeclare module \"bluebird\";\n\n// Redlock types refer to bluebird.Disposer.\ndeclare module \"bluebird\" {\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  class Disposer<T> {}\n}\n\n// Used in one place, and the typings are almost entirely unhelpful.\ndeclare module \"multiparty\";\n\n// Used in one place\ndeclare module \"mime-types\";\n\n// Used in one place\ndeclare module \"morgan\";\ndeclare module \"cookie\";\ndeclare module \"cookie-parser\";\ndeclare module \"on-headers\";\ndeclare module \"@gristlabs/express-session\";\n\n// Used for command line path tweaks.\ndeclare module \"app-module-path\" {\n  export function addPath(path: string): void;\n}\n\ndeclare module \"csv\";\n\ndeclare module \"winston/lib/winston/common\" {\n  export function serialize(meta: any): string;\n}\n\n/**\n * Type definitions for Grist environment variables.\n *\n * This extends NodeJS.ProcessEnv to provide type safety and autocompletion\n * for environment variables used throughout the Grist codebase.\n */\ndeclare namespace NodeJS {\n  interface ProcessEnv {\n    // Database\n    TYPEORM_TYPE?: string;\n    TYPEORM_LOGGING?: \"true\" | \"false\";\n    TYPEORM_DATABASE?: string;\n    TYPEORM_HOST?: string;\n    TYPEORM_PORT?: string;\n    TYPEORM_USERNAME?: string;\n    TYPEORM_PASSWORD?: string;\n    TYPEORM_EXTRA?: string;\n    TYPEORM_EXTRA_DRAFT?: string;\n    REDIS_URL?: string;\n    TEST_REDIS_URL?: string;\n\n    // Notifications\n    GRIST_NOTIFIER?: \"sendgrid\" | \"smtp\" | \"test\";\n    SENDGRID_API_KEY?: string;\n    GRIST_NODEMAILER_SENDER?: string;\n    GRIST_NODEMAILER_CONFIG?: string;\n    GRIST_SMTP_TEMPLATES_DIR?: string;\n\n    // Testing and development\n    GRIST_TEST_SERVER_DEPLOYMENT_TYPE?: \"core\" | \"enterprise\" | \"saas\" | \"static\" | \"electron\";\n  }\n}\n"
  },
  {
    "path": "app/server/devServerMain.ts",
    "content": "/**\n *\n * Run a home server, doc worker, and static server as a single process for regular\n * development work.\n *\n *   PORT         -- this sets the main web server port (defaults to 8080)\n *   HOME_PORT    -- this sets the main home server port (defaults to 9000)\n *   STATIC_PORT  -- port for the static resource server (defaults to 9001)\n *   DOC_PORT     -- comma separated ports for doc workers (defaults to 9002)\n *   TEST_CLEAN_DATABASE -- reset the database(s) before starting\n *   GRIST_SINGLE_PORT -- if set, just a single combined server on HOME_PORT\n *   DOC_WORKER_COUNT  -- if set, makes sure there are at least this number of\n *                        doc workers.  Will add ports incrementally after the last\n *                        worker added with DOC_PORT.\n *\n * If you run more than one doc worker, you'll need to have a redis server running\n * and REDIS_URL set (e.g. to redis://localhost).\n *\n */\n\nimport { updateDb } from \"app/server/lib/dbUtils\";\nimport { FlexServer } from \"app/server/lib/FlexServer\";\nimport log from \"app/server/lib/log\";\nimport { MergedServer } from \"app/server/MergedServer\";\n\nimport * as path from \"path\";\n\nimport { promisifyAll } from \"bluebird\";\nimport * as fse from \"fs-extra\";\nimport { createClient, RedisClient } from \"redis\";\n\npromisifyAll(RedisClient.prototype);\n\nfunction getPort(envVarName: string, fallbackPort: number): number {\n  const val = process.env[envVarName];\n  return val ? parseInt(val, 10) : fallbackPort;\n}\n\nexport async function main() {\n  log.info(\"==========================================================================\");\n  log.info(\"== devServer\");\n  log.info(\"devServer starting.  Please do not set any ports in environment :-)\");\n  log.info(\"Server will be available at http://localhost:8080\");\n\n  process.env.GRIST_HOSTED = \"true\";\n  if (!process.env.GRIST_ADAPT_DOMAIN) {\n    process.env.GRIST_ADAPT_DOMAIN = \"true\";\n  }\n\n  // Experimental plugins are enabled by default for devs\n  if (!process.env.GRIST_EXPERIMENTAL_PLUGINS) {\n    process.env.GRIST_EXPERIMENTAL_PLUGINS = \"1\";\n  }\n\n  // Experimental plugins are enabled by default for devs\n  if (!process.env.GRIST_ENABLE_REQUEST_FUNCTION) {\n    process.env.GRIST_ENABLE_REQUEST_FUNCTION = \"1\";\n  }\n\n  // For tests, it is useful to start with the database in a known state.\n  // If TEST_CLEAN_DATABASE is set, we reset the database before starting.\n  if (process.env.TEST_CLEAN_DATABASE) {\n    // eslint-disable-next-line @typescript-eslint/no-require-imports\n    const { createInitialDb } = require(\"test/gen-server/seed\");\n    await createInitialDb();\n    if (process.env.REDIS_URL) {\n      await createClient(process.env.REDIS_URL).flushdbAsync();\n    }\n  } else {\n    await updateDb();\n  }\n\n  // In V1, we no longer create a config.json file automatically if it is missing.\n  // It is convenient to do that in the dev and test environment.\n  const appRoot = path.dirname(path.dirname(__dirname));\n  const instDir = process.env.GRIST_INST_DIR || appRoot;\n  if (process.env.GRIST_INST_DIR) {\n    const fileName = path.join(instDir, \"config.json\");\n    if (!(await fse.pathExists(fileName))) {\n      const config = {\n        untrustedContentOrigin: \"notset\",\n      };\n      await fse.writeFile(fileName, JSON.stringify(config, null, 2));\n    }\n  }\n\n  if (!process.env.GOOGLE_CLIENT_ID) {\n    log.warn(\"GOOGLE_CLIENT_ID is not defined, Google Drive Plugin will not work.\");\n  }\n\n  if (!process.env.GOOGLE_API_KEY) {\n    log.warn(\"GOOGLE_API_KEY is not defined, Url plugin will not be able to access public files.\");\n  }\n\n  if (process.env.GRIST_SINGLE_PORT) {\n    log.info(\"==========================================================================\");\n    log.info(\"== mergedServer\");\n    const port = getPort(\"HOME_PORT\", 8080);\n    if (!process.env.APP_HOME_URL) {\n      process.env.APP_HOME_URL = `http://localhost:${port}`;\n    }\n    const mergedServer = await MergedServer.create(port, [\"home\", \"docs\", \"static\"]);\n    await mergedServer.run();\n    await mergedServer.flexServer.addTestingHooks();\n    return;\n  }\n\n  // The home server and web server(s) are effectively identical in Grist deployments\n  // now, but remain distinct in some test setups.\n  const homeServerPort = getPort(\"HOME_PORT\", 9000);\n  const webServerPort = getPort(\"PORT\", 8080);\n  if (!process.env.APP_HOME_URL) {\n    // All servers need to know a \"main\" URL for Grist.  This is generally\n    // that of the web server.  In some test setups, the web server port is left\n    // at 0 to be auto-allocated, but for those tests it suffices to use the home\n    // server port.\n    process.env.APP_HOME_URL = `http://localhost:${webServerPort || homeServerPort}`;\n  }\n\n  // Bring up the static resource server\n  log.info(\"==========================================================================\");\n  log.info(\"== staticServer\");\n  const staticPort = getPort(\"STATIC_PORT\", 9001);\n  process.env.APP_STATIC_URL = `http://localhost:${staticPort}`;\n  await MergedServer.create(staticPort, [\"static\"]).then(s => s.run());\n\n  // Bring up a home server\n  log.info(\"==========================================================================\");\n  log.info(\"== homeServer\");\n  const homeServer = await MergedServer.create(homeServerPort, [\"home\"]);\n  await homeServer.run();\n\n  // If a distinct webServerPort is specified, we listen also on that port, though serving\n  // exactly the same content.  This is handy for testing CORS issues.\n  if (webServerPort !== 0 && webServerPort !== homeServerPort) {\n    await homeServer.flexServer.startCopy(\"webServer\", webServerPort);\n  }\n\n  // Bring up the docWorker(s)\n  log.info(\"==========================================================================\");\n  log.info(\"== docWorker\");\n  const ports = (process.env.DOC_PORT || \"9002\").split(\",\").map(port => parseInt(port, 10));\n  if (process.env.DOC_WORKER_COUNT) {\n    const n = parseInt(process.env.DOC_WORKER_COUNT, 10);\n    while (ports.length < n) {\n      ports.push(ports[ports.length - 1] + 1);\n    }\n  }\n  log.info(`== ports ${ports.join(\",\")}`);\n  if (ports.length > 1 && !process.env.REDIS_URL) {\n    throw new Error(\"Need REDIS_URL=redis://localhost or similar for multiple doc workers\");\n  }\n  const workers = new Array<FlexServer>();\n  for (const port of ports) {\n    const mergedServer = await MergedServer.create(port, [\"docs\"]);\n    workers.push(mergedServer.flexServer);\n    await mergedServer.run();\n  }\n\n  await homeServer.flexServer.addTestingHooks(workers);\n}\n\nif (require.main === module) {\n  main().catch((e) => {\n    log.error(\"devServer failed to start %s\", e);\n    process.exit(1);\n  });\n}\n"
  },
  {
    "path": "app/server/generateCheckpoint.ts",
    "content": "/**\n *\n * This opens a sandbox in order to capture a checkpoint of the sandbox after Grist\n * python code has been loaded within it. This helps run Grist's 1000s of tests under\n * gvisor on a ptrace platform, for which all the file accesses on sandbox startup\n * are relatively slow.\n *\n */\n\nimport { create } from \"app/server/lib/create\";\n\nexport async function main() {\n  if (!process.env.GRIST_CHECKPOINT) {\n    throw new Error(\"GRIST_CHECKPOINT must be defined\");\n  }\n  if (!process.env.GRIST_CHECKPOINT_MAKE) {\n    throw new Error(\"GRIST_CHECKPOINT_MAKE must be defined\");\n  }\n  create.NSandbox({\n    preferredPythonVersion: \"3\",\n  });\n}\n\nif (require.main === module) {\n  main().catch((e) => {\n    console.error(e);\n  });\n}\n"
  },
  {
    "path": "app/server/generateInitialDocSql.ts",
    "content": "import { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { AttachmentStoreProvider } from \"app/server/lib/AttachmentStoreProvider\";\nimport { create } from \"app/server/lib/create\";\nimport { DocManager } from \"app/server/lib/DocManager\";\nimport { makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport { DocStorageManager } from \"app/server/lib/DocStorageManager\";\nimport { createDummyTelemetry } from \"app/server/lib/GristServer\";\nimport { createNullAuditLogger } from \"app/server/lib/IAuditLogger\";\nimport { PluginManager } from \"app/server/lib/PluginManager\";\n\nimport * as childProcess from \"child_process\";\nimport * as util from \"util\";\n\nimport * as fse from \"fs-extra\";\n\nconst execFile = util.promisify(childProcess.execFile);\n\n/**\n * Output to stdout typescript code containing SQL strings for creating an empty document.\n * The code is of the form:\n *   export const GRIST_DOC_SQL = <sql code to create a completely empty document>;\n *   export const GRIST_DOC_WITH_TABLE1_SQL = <sql code to create a document with Table1>;\n * Only tables managed by the data engine are included. Any _gristsys_ tables are excluded.\n */\nexport async function main(baseName: string) {\n  console.log(\"/* eslint-disable */\");\n  console.log(\"/*** THIS FILE IS AUTO-GENERATED BY app/server/generateInitialDocSql.ts ***/\");\n  console.log(\"\");\n  for (const version of [\"DOC\", \"DOC_WITH_TABLE1\"] as const) {\n    const storageManager = new DocStorageManager(process.cwd());\n    const pluginManager = new PluginManager();\n    const fname = storageManager.getPath(baseName);\n    if (await fse.pathExists(fname)) {\n      await fse.remove(fname);\n    }\n    const docManager = new DocManager(storageManager, pluginManager, null as any,\n      new AttachmentStoreProvider([], \"\"), {\n        create,\n        getAuditLogger() { return createNullAuditLogger(); },\n        getTelemetry() { return createDummyTelemetry(); },\n        getDocNotificationManager() { return undefined; },\n      } as any,\n    );\n    const activeDoc = new ActiveDoc(docManager, baseName);\n    const session = makeExceptionalDocSession(\"nascent\");\n    await activeDoc.createEmptyDocWithDataEngine(session);\n    if (version === \"DOC_WITH_TABLE1\") {\n      await activeDoc.addInitialTable(session);\n    }\n    // Remove all _gristsys_ tables, since creation of these tables is handled by DocStorage,\n    // not data engine.\n    const tables = await activeDoc.docStorage.all(\"SELECT name FROM sqlite_master WHERE\" +\n      \" type = 'table' AND\" +\n      \" name LIKE '_gristsys_%'\");\n    for (const table of tables) {\n      await activeDoc.docStorage.exec(`DROP TABLE ${table.name}`);\n    }\n    console.log(\"\");\n    console.log(\"export const GRIST_\" + version + \"_SQL = `\");\n    console.log((await execFile(\"sqlite3\", [baseName + \".grist\", \".dump\"])).stdout.trim());\n    console.log(\"`;\");\n    await activeDoc.shutdown();\n    await docManager.shutdownAll();\n    await storageManager.closeStorage();\n  }\n}\n\nif (require.main === module) {\n  main(process.argv[2]).catch((e) => {\n    console.error(e);\n  });\n}\n"
  },
  {
    "path": "app/server/lib/AccessTokens.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { MapWithTTL } from \"app/common/AsyncCreate\";\nimport { KeyedMutex } from \"app/common/KeyedMutex\";\nimport { AccessTokenOptions } from \"app/plugin/GristAPI\";\nimport { makeId } from \"app/server/lib/idUtils\";\n\nimport * as jwt from \"jsonwebtoken\";\nimport { RedisClient } from \"redis\";\n\nexport const Deps = {\n  // Signed tokens expire after this length of time.\n  TOKEN_TTL_MSECS: 15 * 60 * 1000,  // 15 minutes.\n  MAX_SECRETS_KEPT: 3,   // Maximum number of secrets stored per doc.\n};\n\n/**\n * Non-optional information embedded in an access token. Currently\n * access tokens are tied to an individual user and document. In\n * future, they could be used outside of the context of a single\n * document.\n *\n * Includes fields from AccessTokenOptions.\n */\nexport interface AccessTokenInfo extends AccessTokenOptions {\n  userId: number;\n  docId: string;\n}\n\n/**\n * Access token services.\n */\nexport interface IAccessTokens {\n  /**\n   * Sign the content of an access token, returning a plain jwt-format\n   * string. A per-document secret will be used for signing.\n   */\n  sign(content: AccessTokenInfo): Promise<string>;\n\n  /**\n   * Read the content of a token, verifying its signature.\n   */\n  verify(token: string): Promise<AccessTokenInfo>;\n\n  /**\n   * Check how long access tokens remain valid, once minted.\n   */\n  getNominalTTLInMsec(): number;\n\n  close(): Promise<void>;\n}\n\n/**\n * Implementation of access token services. Write operations should\n * be done by a doc worker responsible for the document involved.\n * Read operations can occur anywhere, such as home servers.\n * This class has caches for _reads and _writes that are kept\n * separate so that we don't have to reason about interactions\n * between them. The class could be separated into two, one just\n * for reading, and one just for writing.\n *\n * Token lifetime is handled by JWT expiration. Secret lifetime is\n * handled by maintaining a rolling list of secrets (per document)\n * that are replaced over time.\n *\n * Redis is used if available so that tokens issued by a worker will\n * be honored by its replacement (within the token's period of validity).\n *\n * Secrets may last for a while. How long they last may vary with usage.\n * A new secret is added when a local cache of signing secrets expires.\n * Older secrets rotate out. For example, if we sign a token, then don't\n * sign another until 0.9 * factor * TOKEN_TTL_MSECS later, a new token\n * will used but the older one preserved. We could do the same\n * MAX_SECRETS_KEPT-2 more times until the original secret is lost.\n * This gives an overall lifetime of about factor * TOKEN_TTL_MSECS * MAX_SECRETS_KEPT.\n * A secret could have a shorter lifetime of about factor * TOKEN_TTL_MSECS\n * if we didn't sign anything else for a bit longer. So there's quite some\n * variation, but the important thing is that secrets aren't lingering for\n * many orders of magnitude more than the lifetime of the tokens they sign.\n */\nexport class AccessTokens implements IAccessTokens {\n  private _store: IAccessTokenSignerStore;       // a redis or in-memory \"back end\".\n  private _reads: MapWithTTL<string, string[]>;  // a cache of recent reads.\n  private _writes: MapWithTTL<string, string[]>; // a cache of recent writes.\n  private _dtMsec: number;                       // the duration for which tokens must be honored.\n  private _mutex = new KeyedMutex();             // logic is simpler if serialized.\n\n  // Use redis if available. Cache reads or writes for some multiple of the duration for which\n  // tokens must be honored. Cache is of a list of secrets. It is important to allow multiple\n  // secrets so we can change the secret we are signing with and still honor tokens signed with\n  // a previous secret.\n  constructor(cli: RedisClient | null, private _factor: number = 10) {\n    this._store = cli ? new RedisAccessTokenSignerStore(cli) : new InMemoryAccessTokenSignerStore();\n    this._dtMsec = Deps.TOKEN_TTL_MSECS;\n    this._reads = new MapWithTTL<string, string[]>(this._dtMsec * _factor * 0.5);\n    this._writes = new MapWithTTL<string, string[]>(this._dtMsec * _factor * 0.5);\n  }\n\n  // Return the duration we promise to honor a token for (although we may\n  // honor it for longer).\n  public getNominalTTLInMsec() {\n    return this._dtMsec;\n  }\n\n  // Sign a token. We use JWT, and use its built-in expiration time.\n  public async sign(content: AccessTokenInfo): Promise<string> {\n    const encoder = await this._getOrCreateSecret(content.docId);\n    return jwt.sign(content, encoder, { expiresIn: this._dtMsec / 1000.0 });\n  }\n\n  /**\n   * Check a token is valid. Since the secret used to sign it is dependent\n   * on the docId, we decode the token first to see what document it is claiming\n   * to be for. Then we try to verify the token with all the secrets known for\n   * that doc. Upon failure, we make sure the secret list is up to date and try\n   * again. There is room for optimizing here!\n   */\n  public async verify(token: string): Promise<AccessTokenInfo> {\n    const content = jwt.decode(token);\n    if (typeof content !== \"object\") {\n      throw new ApiError(\"Broken token\", 401);\n    }\n    const docId = content?.docId as string;\n    if (typeof docId !== \"string\" || !docId) {\n      throw new ApiError(\"Broken token\", 401);\n    }\n    try {\n      // Try to verify with the secrets we already know about.\n      return await this._verifyWithGivenDoc(docId, token);\n    } catch (e) {\n      // Retry with up-to-date secrets.\n      await this._refreshSecrets(docId);\n      return await this._verifyWithGivenDoc(docId, token);\n    }\n  }\n\n  public async close() {\n    await this._store.close();\n    this._reads.clear();\n    this._writes.clear();\n  }\n\n  private async _verifyWithGivenDoc(docId: string, token: string): Promise<AccessTokenInfo> {\n    const secrets = this._reads.get(docId) || [];\n    for (const secret of secrets) {\n      try {\n        return this._verifyWithGivenSecret(secret, token);\n      } catch (e) {\n        if (String(e).match(/Token has expired/)) {\n          // Give specific error about token expiration.\n          throw e;\n        }\n        // continue, to try another secret.\n      }\n    }\n    throw new ApiError(\"Cannot verify token\", 401);\n  }\n\n  private _verifyWithGivenSecret(secret: string, token: string): AccessTokenInfo {\n    try {\n      const content: any = jwt.verify(token, secret);\n      if (typeof content !== \"object\") {\n        throw new ApiError(\"Token mismatch\", 401);\n      }\n      const userId = content.userId;\n      const docId = content.docId;\n      if (!userId) { throw new ApiError(\"no userId in access token\", 401); }\n      if (!docId) { throw new ApiError(\"no docId in access token\", 401); }\n      return content as AccessTokenInfo;\n    } catch (e) {\n      if (e.name === \"TokenExpiredError\") {\n        throw new ApiError(\"Token has expired\", 401);\n      }\n      throw new ApiError(\"Cannot verify token\", 401);\n    }\n  }\n\n  /**\n   * Get a secret to sign with. The secret needs to be\n   * valid for longer than dtMsec, so it is available\n   * for verifying the signed token throughout its\n   * lifetime.\n   *\n   * We maintain a truncated list of secrets, signing\n   * with the most recent, and verifying against any.\n   *\n   */\n  private async _getOrCreateSecret(docId: string): Promise<string> {\n    return this._mutex.runExclusive(docId, async () => {\n      let secrets = this._writes.get(docId);\n      if (secrets && secrets.length >= 1) {\n        return secrets[0];\n      }\n      // Our local cache of secrets to sign with is empty.\n      secrets = await this._store.getSigners(docId);\n      secrets.unshift(this._mintSecret());\n      secrets.splice(Deps.MAX_SECRETS_KEPT);\n      this._writes.set(docId, secrets);\n      await this._store.setSigners(docId, secrets, this._dtMsec * this._factor);\n      return secrets[0];\n    });\n  }\n\n  private async _refreshSecrets(docId: string): Promise<void> {\n    const inv = await this._store.getSigners(docId);\n    this._reads.set(docId, inv);\n  }\n\n  private _mintSecret(): string {\n    return makeId() + makeId();\n  }\n}\n\n/**\n * Store a list of signing secrets globally. Light wrapper over redis or memory.\n */\nexport interface IAccessTokenSignerStore {\n  getSigners(docId: string): Promise<string[]>;\n  setSigners(docId: string, secret: string[], ttlMsec: number): Promise<void>;\n  close(): Promise<void>;\n}\n\n// In-memory implementation of IAccessTokenSignerStore, usable for single-process Grist.\n// One limitation is that restarted processes won't honor tokens created by predecessor.\nexport class InMemoryAccessTokenSignerStore implements IAccessTokenSignerStore {\n  private static _keys = new MapWithTTL<string, string[]>(Deps.TOKEN_TTL_MSECS);\n  private static _refCount: number = 0;\n\n  public constructor() {\n    InMemoryAccessTokenSignerStore._refCount++;\n  }\n\n  public async getSigners(docId: string): Promise<string[]> {\n    return InMemoryAccessTokenSignerStore._keys.get(docId) || [];\n  }\n\n  public async setSigners(docId: string, secrets: string[], ttlMsec: number): Promise<void> {\n    InMemoryAccessTokenSignerStore._keys.setWithCustomTTL(docId, secrets, ttlMsec);\n  }\n\n  public async close() {\n    InMemoryAccessTokenSignerStore._refCount--;\n    if (InMemoryAccessTokenSignerStore._refCount <= 0) {\n      InMemoryAccessTokenSignerStore._keys.clear();\n    }\n  }\n}\n\n// Redis based implementation of IAccessTokenSignerStore, for multi process/instance\n// Grist.\nexport class RedisAccessTokenSignerStore implements IAccessTokenSignerStore {\n  constructor(private _cli: RedisClient) { }\n\n  public async getSigners(docId: string): Promise<string[]> {\n    const keys = await this._cli.getAsync(this._getKey(docId));\n    return keys?.split(\",\") || [];\n  }\n\n  public async setSigners(docId: string, secrets: string[], ttlMsec: number): Promise<void> {\n    await this._cli.setexAsync(this._getKey(docId), ttlMsec, secrets.join(\",\"));\n  }\n\n  public async close() {\n  }\n\n  private _getKey(docId: string) {\n    return `token-doc-decoder-${docId}`;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/ActionHistory.ts",
    "content": "/**\n * TODO For now, this is just a placeholder for an actual ActionHistory implementation that should\n * replace today's ActionLog. It defines all the methods that are expected from it by Sharing.ts.\n *\n * In addition, it will need to support some methods to show action history to the user, which is\n * the main purpose of ActionLog today. And it will need to allow querying a subset of history (at\n * least by table or record).\n *\n * The main difference with today's ActionLog is that it needs to mark actions either with labels,\n * or more likely with Git-like branches, so that we can distinguish shared, local-sent, and\n * local-unsent actions. And it needs to work on LocalActionBundles, which include more\n * information than what ActionLog stores. On the other hand, it can probably store actions as\n * blobs, which can simplify the database storage.\n */\n\nimport { LocalActionBundle } from \"app/common/ActionBundle\";\nimport { ActionGroup, MinimalActionGroup } from \"app/common/ActionGroup\";\nimport { summarizeAction } from \"app/common/ActionSummarizer\";\nimport { createEmptyActionSummary } from \"app/common/ActionSummary\";\nimport { DocState } from \"app/common/DocState\";\n\nexport interface ActionGroupOptions {\n  // If set, inspect the action in detail in order to include a summary of\n  // changes made within the action.  Otherwise, the actionSummary returned is empty.\n  summarize?: boolean;\n\n  // The client for which the action group is being prepared, if known.\n  clientId?: string;\n\n  // Values returned by the action, if known.\n  retValues?: any[];\n\n  // Set the 'internal' flag on the created actions, as inappropriate to undo.\n  internal?: boolean;\n}\n\n/**\n * Metadata about an action that is needed for undo/redo stack.\n */\nexport interface ActionHistoryUndoInfoWithoutClient {\n  otherId: number;\n  linkId: number;\n  rowIdHint: number;\n  isUndo: boolean;\n}\n\nexport interface ActionHistoryUndoInfo extends ActionHistoryUndoInfoWithoutClient {\n  clientId: string;\n}\n\nexport abstract class ActionHistory {\n  /**\n   * Initialize the ActionLog by reading the database. No other methods may be used until the\n   * initialization completes. If used, their behavior is undefined.\n   */\n  public abstract initialize(): Promise<void>;\n\n  public abstract isInitialized(): boolean;\n\n  /** Returns the actionNum of the next action we expect from the hub. */\n  public abstract getNextHubActionNum(): number;\n\n  /** Returns the actionNum of the next local action should have. */\n  public abstract getNextLocalActionNum(): number;\n\n  /**\n   * Act as if we have already seen actionNum. getNextHubActionNum will return 1 plus this.\n   * Only suitable for use if there are no unshared local actions.\n   */\n  public abstract skipActionNum(actionNum: number): Promise<void>;\n\n  /** Returns whether we have local unsent actions. */\n  public abstract haveLocalUnsent(): boolean;\n\n  /** Returns whether we have any local actions that have been sent to the hub. */\n  public abstract haveLocalSent(): boolean;\n\n  /** Returns whether we have any locally-applied actions. */\n  public abstract haveLocalActions(): boolean;\n\n  /** Fetches and returns an array of all local unsent actions. */\n  public abstract fetchAllLocalUnsent(): Promise<LocalActionBundle[]>;\n\n  /** Fetches and returns an array of all local actions (sent and unsent). */\n  public abstract fetchAllLocal(): Promise<LocalActionBundle[]>;\n\n  /** Deletes all local-only actions, and resets the affected branch pointers. */\n  // TODO Should we actually delete, or be more git-like, only reset local branch pointer, and let\n  // cleanup of unreferenced actions happen in a separate step?\n  public abstract clearLocalActions(): Promise<void>;\n\n  /**\n   * Marks all actions returned from fetchAllLocalUnsent() as sent. Actions must be consecutive\n   * starting with the the first local unsent action.\n   */\n  public abstract markAsSent(actions: LocalActionBundle[]): Promise<void>;\n\n  /**\n   * Matches the action from the hub against the first sent local action. If it's the same action,\n   * marks our action as \"shared\", i.e. accepted by the hub, and returns true. Else returns false.\n   * If actionHash is null, accepts unconditionally.\n   */\n  public abstract acceptNextSharedAction(actionHash: string | null): Promise<boolean>;\n\n  /** Records a new local unsent action, after setting action.actionNum appropriately. */\n  public abstract recordNextLocalUnsent(action: LocalActionBundle): Promise<void>;\n\n  /** Records a new action received from the hub, after setting action.actionNum appropriately. */\n  public abstract recordNextShared(action: LocalActionBundle): Promise<void>;\n\n  /**\n   * Get the most recent actions from the history.  Results are ordered by\n   * earliest actions first, later actions later.  If `maxActions` is supplied,\n   * at most that number of actions are returned.\n   *\n   * This method should be avoid in production, since it may convert and keep in memory many large\n   * actions. (It has in the past led to exhausting memory and crashing node.)\n   */\n  public abstract getRecentActions(maxActions?: number): Promise<LocalActionBundle[]>;\n\n  /**\n   * Same as getRecentActions, but converts each to an ActionGroup using asActionGroup with the\n   * supplied options.\n   */\n  public abstract getRecentActionGroups(maxActions: number, options: ActionGroupOptions): Promise<ActionGroup[]>;\n\n  public abstract getRecentMinimalActionGroups(maxActions: number, clientId?: string): Promise<MinimalActionGroup[]>;\n\n  /**\n   * Get the most recent states from the history.  States are just\n   * actions without any content.  Results are ordered by most recent\n   * states first (careful, this is the opposite to getRecentActions).\n   * If `maxStates` is supplied, at most that number of actions are\n   * returned.\n   */\n  public abstract getRecentStates(maxStates?: number): Promise<DocState[]>;\n\n  /**\n   * Get a list of actions, identified by their actionNum.  Any actions that could not be\n   * found are returned as undefined.\n   */\n  public abstract getActions(actionNums: number[]): Promise<(LocalActionBundle | undefined)[]>;\n\n  /**\n   * Associates an action with a client. This association is expected to be transient, rather\n   * than persistent.  It should survive a client-side reload but not a server-side restart.\n   */\n  public abstract setActionUndoInfo(actionHash: string, undoInfo: ActionHistoryUndoInfo): void;\n\n  /** Check for any client associated with an action, identified by checksum */\n  public abstract getActionUndoInfo(actionHash: string): ActionHistoryUndoInfo | undefined;\n\n  /**\n   * Remove all stored actions except the last keepN and run the VACUUM command\n   * to reduce the size of the SQLite file.\n   *\n   * @param {Int} keepN - The number of most recent actions to keep. The value must be at least 1, and\n   *  will default to 1 if not given.\n   */\n  public abstract deleteActions(keepN: number): Promise<void>;\n}\n\n/**\n * Convert an ActionBundle into an ActionGroup.  ActionGroups are the representation of\n * actions on the client.\n * @param history: interface to action history\n * @param act: action to convert\n * @param options: options to construct the ActionGroup; see its documentation above.\n */\nexport function asActionGroup(history: ActionHistory,\n  act: LocalActionBundle,\n  options: ActionGroupOptions): ActionGroup {\n  const { summarize, clientId } = options;\n  const info = act.info[1];\n\n  const fromSelf = (act.actionHash && clientId) ?\n    (history.getActionUndoInfo(act.actionHash)?.clientId === clientId) : false;\n\n  const { extra: { primaryAction }, minimal: { rowIdHint, isUndo } } =\n    getActionUndoInfoWithoutClient(act, options.retValues);\n\n  return {\n    actionNum: act.actionNum,\n    actionHash: act.actionHash || \"\",\n    // Desc is a human-readable description of the user action set in a few places by client-side\n    // code, but is mostly (or maybe completely) unused.\n    desc: info.desc,\n    actionSummary: summarize ? summarizeAction(act) : createEmptyActionSummary(),\n    fromSelf,\n    linkId: info.linkId,\n    otherId: info.otherId,\n    time: info.time,\n    user: info.user,\n    rowIdHint,\n    primaryAction,\n    isUndo,\n    internal: options.internal || false,\n  };\n}\n\nexport function asMinimalActionGroup(history: ActionHistory,\n  act: { actionHash: string, actionNum: number },\n  clientId?: string): MinimalActionGroup {\n  const undoInfo = act.actionHash ? history.getActionUndoInfo(act.actionHash) : undefined;\n  const fromSelf = clientId ? (undoInfo?.clientId === clientId) : false;\n  return {\n    actionNum: act.actionNum,\n    actionHash: act.actionHash || \"\",\n    fromSelf,\n    linkId: undoInfo?.linkId || 0,\n    otherId: undoInfo?.otherId || 0,\n    rowIdHint: undoInfo?.rowIdHint || 0,\n    isUndo: undoInfo?.isUndo || false,\n  };\n}\n\nexport function getActionUndoInfo(act: LocalActionBundle, clientId: string,\n  retValues: any[]): ActionHistoryUndoInfo {\n  return {\n    ...getActionUndoInfoWithoutClient(act, retValues).minimal,\n    clientId,\n  };\n}\n\n/**\n * Compute undo information from an action bundle and return values if available.\n * Results are returned as {minimal, extra} where core has information needed for minimal\n * action groups, and extra has information only needed for full action groups.\n */\nfunction getActionUndoInfoWithoutClient(act: LocalActionBundle, retValues?: any[]) {\n  let rowIdHint = 0;\n  if (retValues) {\n    // A hint for cursor position.  This logic used to live on the client, but now trying to\n    // limit how much the client looks at the internals of userActions.\n    // In case of AddRecord, the returned value is rowId, which is the best cursorPos for Redo.\n    for (let i = 0; i < act.userActions.length; i++) {\n      const name = act.userActions[i][0];\n      const retValue = retValues[i];\n      if (name === \"AddRecord\") {\n        rowIdHint = retValue;\n        break;\n      } else if (name === \"BulkAddRecord\") {\n        rowIdHint = retValue[0];\n        break;\n      }\n    }\n  }\n\n  const info = act.info[1];\n  const primaryAction: string = String((act.userActions[0] || [\"\"])[0]);\n  const isUndo = primaryAction === \"ApplyUndoActions\";\n  return {\n    minimal: {\n      rowIdHint,\n      otherId: info.otherId,\n      linkId: info.linkId,\n      isUndo,\n    },\n    extra: {\n      primaryAction,\n    },\n  };\n}\n"
  },
  {
    "path": "app/server/lib/ActionHistoryImpl.ts",
    "content": "/**\n * Minimal ActionHistory implementation\n */\n\nimport { LocalActionBundle } from \"app/common/ActionBundle\";\nimport { ActionGroup, MinimalActionGroup } from \"app/common/ActionGroup\";\nimport { DocState } from \"app/common/DocState\";\nimport * as marshaller from \"app/common/marshal\";\nimport { ActionGroupOptions, ActionHistory, ActionHistoryUndoInfo, asActionGroup,\n  asMinimalActionGroup } from \"app/server/lib/ActionHistory\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { reportTimeTaken } from \"app/server/lib/reportTimeTaken\";\nimport { ISQLiteDB, ResultRow } from \"app/server/lib/SQLiteDB\";\n\nimport * as crypto from \"crypto\";\n\nimport keyBy from \"lodash/keyBy\";\nimport mapValues from \"lodash/mapValues\";\n\nconst section = appSettings.section(\"history\").section(\"action\");\n\n// History will from time to time be pruned back to within these limits\n// on rows and the maximum total number of bytes in the \"body\" column.\n// Pruning is done when the history has grown above these limits, to\n// the specified factor.\nconst ACTION_HISTORY_MAX_ROWS = section.flag(\"maxRows\").requireInt({\n  envVar: \"GRIST_ACTION_HISTORY_MAX_ROWS\",\n  defaultValue: 1000,\n\n  minValue: 1,\n});\n\nconst ACTION_HISTORY_MAX_BYTES = section.flag(\"maxBytes\").requireInt({\n  envVar: \"GRIST_ACTION_HISTORY_MAX_BYTES\",\n  defaultValue: 1e9, // 1 GB.\n  minValue: 1,  // 1 B.\n});\n\nconst ACTION_HISTORY_GRACE_FACTOR = 1.25;  // allow growth to 1.25 times the above limits.\nconst ACTION_HISTORY_CHECK_PERIOD = 10;    // number of actions between size checks.\n\n/**\n *\n * Encode an action as a buffer.\n *\n */\nexport function encodeAction(action: LocalActionBundle): Buffer {\n  const encoder = new marshaller.Marshaller({ version: 2 });\n  encoder.marshal(action);\n  return encoder.dumpAsBuffer();\n}\n\n/**\n *\n * Decode an action from a buffer.  Throws an error if buffer doesn't look plausible.\n *\n */\nexport function decodeAction(blob: Buffer | Uint8Array): LocalActionBundle {\n  return marshaller.loads(blob) as LocalActionBundle;\n}\n\n/**\n *\n * Decode an action from an ActionHistory row. Row must include body, actionNum, actionHash fields.\n *\n */\nfunction decodeActionFromRow(row: ResultRow): LocalActionBundle {\n  const body = decodeAction(row.body);\n  // Reset actionNum and actionHash, just to have one fewer thing to worry about.\n  body.actionNum = row.actionNum;\n  body.actionHash = row.actionHash;\n  return body;\n}\n\n/**\n *\n * Generate an action checksum from a LocalActionBundle\n * Needs to be in sync with Hub/Sharing.\n *\n */\nexport function computeActionHash(action: LocalActionBundle): string {\n  const shaSum = crypto.createHash(\"sha256\");\n  const encoder = new marshaller.Marshaller({ version: 2 });\n  encoder.marshal(action.actionNum);\n  encoder.marshal(action.parentActionHash);\n  encoder.marshal(action.info);\n  encoder.marshal(action.stored);\n  const buf = encoder.dumpAsBuffer();\n  shaSum.update(buf);\n  return shaSum.digest(\"hex\");\n}\n\n/** The important identifiers associated with an action */\ninterface ActionIdentifiers {\n  /**\n   *\n   * actionRef is the SQLite-allocated row id in the main ActionHistory table.\n   * See:\n   *   https://www.sqlite.org/rowidtable.html\n   *   https://sqlite.org/autoinc.html\n   * for background on how this works.\n   *\n   */\n  actionRef: number | null;\n\n  /**\n   *\n   * actionHash is a checksum computed from salient parts of an ActionBundle.\n   *\n   */\n  actionHash: string | null;\n\n  /**\n   *\n   * actionNum is the depth in history from the root, starting from 1 for the first\n   * action.\n   *\n   */\n  actionNum: number | null;\n\n  /**\n   *\n   * The name of a branch where we found this action.\n   *\n   */\n  branchName: string;\n}\n\n/** An organized view of the standard branches: shared, local_sent, local_unsent */\ninterface StandardBranches {\n  shared: ActionIdentifiers;\n  local_sent: ActionIdentifiers;\n  local_unsent: ActionIdentifiers;\n}\n\n/** Tweakable parameters for storing the action history */\ninterface ActionHistoryOptions {\n  maxRows: number;   // maximum number of rows to aim for\n  maxBytes: number;  // maximum total \"body\" bytes to aim for\n  graceFactor: number;  // allow this amount of slop in limits\n  checkPeriod: number;  // number of actions between checks\n}\n\nconst defaultOptions: ActionHistoryOptions = {\n  maxRows: ACTION_HISTORY_MAX_ROWS,\n  maxBytes: ACTION_HISTORY_MAX_BYTES,\n  graceFactor: ACTION_HISTORY_GRACE_FACTOR,\n  checkPeriod: ACTION_HISTORY_CHECK_PERIOD,\n};\n\n/**\n *\n * An implementation of the ActionHistory interface, using SQLite tables.\n *\n * The history of Grist actions is essentially linear.  We have a notion of\n * action branches only to track certain \"subhistories\" of those actions,\n * specifically:\n *   - those actions that have been \"shared\"\n *   - those actions that have been \"sent\" (but not yet declared \"shared\")\n * The \"shared\" branch reaches from the beginning of history to the last known\n * shared action.  The \"local_sent\" branch reaches at least to that point, and\n * potentially on to other actions that have been \"sent\" but not \"shared\".\n * All remaining branches -- just one right now, called \"local_unsent\" --\n * continue on from there.  We may in the future permit multiple such\n * branches.  In this case, this part of the action history could actually\n * form a tree and not be linear.\n *\n * For all branches, we track their \"tip\", the most recent action on\n * that branch.\n *\n * TODO: links to parent actions stored in bundles are not currently\n * updated in the database when those parent actions are deleted.  If this\n * is an issue, it might be best to remove such information from the bundles\n * when stored and add it back as it is retrieved, or treat it separately.\n *\n */\nexport class ActionHistoryImpl implements ActionHistory {\n  private _sharedActionNum: number = 1;       // track depth in tree of shared actions\n  private _localActionNum: number = 1;        // track depth in tree of local actions\n  private _haveLocalSent: boolean = false;    // cache for this.haveLocalSent()\n  private _haveLocalUnsent: boolean = false;  // cache for this.haveLocalUnsent()\n  private _initialized: boolean = false;      // true when initialize() has completed\n  private _actionUndoInfo = new Map<string, ActionHistoryUndoInfo>();  // transient cache of undo info\n\n  constructor(private _db: ISQLiteDB, private _options: ActionHistoryOptions = defaultOptions) {\n  }\n\n  /** remove any existing data from ActionHistory - useful during testing. */\n  public async wipe() {\n    await this._db.run(\"UPDATE _gristsys_ActionHistoryBranch SET actionRef = NULL\");\n    await this._db.run(\"DELETE FROM _gristsys_ActionHistory\");\n    this._actionUndoInfo.clear();\n  }\n\n  public async initialize(): Promise<void> {\n    const branches = await this._getBranches();\n    if (branches.shared.actionNum) {\n      this._sharedActionNum = branches.shared.actionNum + 1;\n    }\n    if (branches.local_unsent.actionNum) {\n      this._localActionNum = branches.local_unsent.actionNum + 1;\n    }\n    // Record whether we currently have local actions (sent or unsent).\n    const sharedActionNum = branches.shared.actionNum || -1;\n    const localSentActionNum = branches.local_sent.actionNum || -1;\n    const localUnsentActionNum = branches.local_unsent.actionNum || -1;\n    this._haveLocalUnsent = localUnsentActionNum > localSentActionNum;\n    this._haveLocalSent = localSentActionNum > sharedActionNum;\n    this._initialized = true;\n    // Apply any limits on action history size.\n    await this._pruneLargeHistory(sharedActionNum);\n  }\n\n  public isInitialized(): boolean {\n    return this._initialized;\n  }\n\n  public getNextHubActionNum(): number {\n    return this._sharedActionNum;\n  }\n\n  public getNextLocalActionNum(): number {\n    return this._localActionNum;\n  }\n\n  public async skipActionNum(actionNum: number): Promise<void> {\n    if (this._localActionNum !== this._sharedActionNum) {\n      throw new Error(\"Tried to skip to an actionNum with unshared local actions\");\n    }\n\n    if (actionNum < this._sharedActionNum) {\n      if (actionNum === this._sharedActionNum - 1) {\n        // that was easy\n        return;\n      }\n      throw new Error(\"Tried to skip to an actionNum we've already passed\");\n    }\n\n    // Force the actionNum to the desired value\n    this._localActionNum = this._sharedActionNum = actionNum;\n\n    // We store a row as we would for recordNextShared()\n    const action: LocalActionBundle = {\n      actionHash: null,\n      parentActionHash: null,\n      actionNum: this._sharedActionNum,\n      userActions: [],\n      undo: [],\n      envelopes: [],\n      info: [0, { time: 0, user: \"grist\", inst: \"\", desc: \"root\", otherId: 0, linkId: 0 }],\n      stored: [],\n      calc: [],\n    };\n    await this._db.execTransaction(async () => {\n      const branches = await this._getBranches();\n      if (branches.shared.actionRef !== branches.local_sent.actionRef ||\n        branches.shared.actionRef !== branches.local_unsent.actionRef) {\n        throw new Error(\"skipActionNum not defined when branches not in sync\");\n      }\n      const actionRef = await this._addAction(action, branches.shared);\n      this._noteSharedAction(action.actionNum);\n      await this._db.run(`UPDATE _gristsys_ActionHistoryBranch SET actionRef = ?\n                            WHERE name IN ('local_unsent', 'local_sent')`,\n      actionRef);\n    });\n  }\n\n  public haveLocalUnsent(): boolean {\n    return this._haveLocalUnsent;\n  }\n\n  public haveLocalSent(): boolean {\n    return this._haveLocalSent;\n  }\n\n  public haveLocalActions(): boolean {\n    return this._haveLocalSent || this._haveLocalUnsent;\n  }\n\n  public async fetchAllLocalUnsent(): Promise<LocalActionBundle[]> {\n    const branches = await this._getBranches();\n    return this._fetchActions(branches.local_sent, branches.local_unsent);\n  }\n\n  public async fetchAllLocal(): Promise<LocalActionBundle[]> {\n    const branches = await this._getBranches();\n    return this._fetchActions(branches.shared, branches.local_unsent);\n  }\n\n  public async clearLocalActions(): Promise<void> {\n    await this._db.execTransaction(async () => {\n      const branches = await this._getBranches();\n      const rows = await this._fetchParts(branches.shared, branches.local_unsent,\n        \"_gristsys_ActionHistory.id, actionHash\");\n      await this._deleteRows(rows);\n      await this._db.run(`UPDATE _gristsys_ActionHistoryBranch SET actionRef = ?\n                            WHERE name IN ('local_unsent', 'local_sent')`,\n      branches.shared.actionRef);\n      this._haveLocalSent = false;\n      this._haveLocalUnsent = false;\n      this._localActionNum = this._sharedActionNum;\n    });\n  }\n\n  public async markAsSent(actions: LocalActionBundle[]): Promise<void> {\n    const branches = await this._getBranches();\n    const candidates = await this._fetchParts(branches.local_sent,\n      branches.local_unsent,\n      \"_gristsys_ActionHistory.id, actionHash\");\n    let tip: number | undefined;\n    try {\n      for (const act of actions) {\n        if (candidates.length === 0) {\n          throw new Error(\"markAsSent() called but nothing local and unsent\");\n        }\n        const candidate = candidates[0];\n        // act and act2 must be one and the same\n        if (act.actionHash !== candidate.actionHash) {\n          throw new Error(\"markAsSent() got an unexpected action\");\n        }\n        tip = candidate.id;\n        candidates.shift();\n        if (candidates.length === 0) {\n          this._haveLocalUnsent = false;\n        }\n        this._haveLocalSent = true;\n      }\n    } finally {\n      if (tip) {\n        await this._db.run(`UPDATE _gristsys_ActionHistoryBranch SET actionRef = ?\n                              WHERE name = 'local_sent'`,\n        tip);\n      }\n    }\n  }\n\n  public async acceptNextSharedAction(actionHash: string | null): Promise<boolean> {\n    const branches = await this._getBranches();\n    const candidates = await this._fetchParts(branches.shared,\n      branches.local_sent,\n      \"_gristsys_ActionHistory.id, actionHash, actionNum\",\n      2);\n    if (candidates.length === 0) {\n      return false;\n    }\n    const candidate = candidates[0];\n    if (actionHash != null) {\n      if (candidate.actionHash !== actionHash) {\n        return false;\n      }\n    }\n    await this._db.run(`UPDATE _gristsys_ActionHistoryBranch SET actionRef = ?\n                          WHERE name = 'shared'`,\n    candidate.id);\n    if (candidates.length === 1) {\n      this._haveLocalSent = false;\n    }\n    this._noteSharedAction(candidate.actionNum);\n    await this._pruneLargeHistory(candidate.actionNum);\n    return true;\n  }\n\n  /** This will populate action.actionHash and action.parentActionHash */\n  public async recordNextLocalUnsent(action: LocalActionBundle): Promise<void> {\n    const branches = await this._getBranches();\n    await this._addAction(action, branches.local_unsent);\n    this._noteLocalAction(action.actionNum);\n    this._haveLocalUnsent = true;\n  }\n\n  public async recordNextShared(action: LocalActionBundle): Promise<void> {\n    // I think, reading Sharing.ts, that these actions should be added to all\n    // the system branches - it is just a shortcut for getting to shared\n    await this._db.execTransaction(async () => {\n      const branches = await this._getBranches();\n      if (branches.shared.actionRef !== branches.local_sent.actionRef ||\n        branches.shared.actionRef !== branches.local_unsent.actionRef) {\n        throw new Error(\"recordNextShared not defined when branches not in sync\");\n      }\n      const actionRef = await this._addAction(action, branches.shared);\n      this._noteSharedAction(action.actionNum);\n      await this._db.run(`UPDATE _gristsys_ActionHistoryBranch SET actionRef = ?\n                            WHERE name IN ('local_unsent', 'local_sent')`,\n      actionRef);\n    });\n    await this._pruneLargeHistory(action.actionNum);\n  }\n\n  public async getRecentActions(maxActions?: number): Promise<LocalActionBundle[]> {\n    const actions = await this._getRecentActionRows(maxActions);\n    return reportTimeTaken(\"getRecentActions\", () => actions.map(decodeActionFromRow));\n  }\n\n  public async getRecentActionGroups(maxActions: number, options: ActionGroupOptions): Promise<ActionGroup[]> {\n    const actions = await this._getRecentActionRows(maxActions);\n    return reportTimeTaken(\"getRecentActionGroups\",\n      () => actions.map(row => asActionGroup(this, decodeActionFromRow(row), options)));\n  }\n\n  public async getRecentMinimalActionGroups(maxActions: number, clientId?: string): Promise<MinimalActionGroup[]> {\n    // Don't look at content of actions.\n    const actions = await this._getRecentActionRows(maxActions, false);\n    return reportTimeTaken(\n      \"getRecentMinimalActionGroups\",\n      () => actions.map(row => asMinimalActionGroup(\n        this,\n        { actionHash: row.actionHash, actionNum: row.actionNum },\n        clientId)));\n  }\n\n  public async getRecentStates(maxStates?: number): Promise<DocState[]> {\n    const branches = await this._getBranches();\n    const states = await this._fetchParts(null,\n      branches.local_unsent,\n      \"_gristsys_ActionHistory.id, actionNum, actionHash\",\n      maxStates,\n      true);\n    return states.map(row => ({ n: row.actionNum, h: row.actionHash }));\n  }\n\n  public async getActions(actionNums: number[]): Promise<(LocalActionBundle | undefined)[]> {\n    const actions = await this._db.all(\n      `SELECT actionHash, actionNum, body FROM _gristsys_ActionHistory\n       where actionNum in (${actionNums.map(x => \"?\").join(\",\")})`,\n      ...actionNums);\n    return reportTimeTaken(\"getActions\", () => {\n      const actionsByActionNum = keyBy(actions, \"actionNum\");\n      return actionNums\n        .map(n => actionsByActionNum[n])\n        .map(row => row ? decodeActionFromRow(row) : undefined);\n    });\n  }\n\n  /**\n   * Helper function to remove all stored actions except the last keepN and run the VACUUM command\n   * to reduce the size of the SQLite file.\n   *\n   * @param {Int} keepN - The number of most recent actions to keep. The value must be at least 1, and\n   *  will default to 1 if not given.\n   * @returns {Promise} - A promise for the SQL execution.\n   *\n   * NOTE: Only keeps actions after maxActionNum - keepN, which might be less than keepN actions if\n   *  actions are not sequential in the file.\n   */\n  public async deleteActions(keepN: number): Promise<void> {\n    await this._db.execTransaction(async () => {\n      const branches = await this._getBranches();\n      const rows = await this._fetchParts(null,\n        branches.local_unsent,\n        \"_gristsys_ActionHistory.id, actionHash\",\n        keepN,\n        true);\n      const ids = await this._deleteRows(rows, true);\n      // By construction, we are removing all rows from the start of history to a certain point.\n      // So, if any of the removed actions are mentioned as the tip of a branch, that tip should\n      // now simply become null/empty.\n      await this._db.run(`UPDATE _gristsys_ActionHistoryBranch SET actionRef = NULL WHERE actionRef NOT IN (${ids})`);\n      await this._db.requestVacuum();\n    });\n  }\n\n  public setActionUndoInfo(actionHash: string, undoInfo: ActionHistoryUndoInfo): void {\n    this._actionUndoInfo.set(actionHash, undoInfo);\n  }\n\n  public getActionUndoInfo(actionHash: string): ActionHistoryUndoInfo | undefined {\n    return this._actionUndoInfo.get(actionHash);\n  }\n\n  /**\n   * Fetches the most recent action row from the history, ordered with earlier actions first.\n   * If `maxActions` is supplied, at most that number of actions are returned.\n   */\n  private async _getRecentActionRows(maxActions: number | undefined,\n    withBody: boolean = true): Promise<ResultRow[]> {\n    const branches = await this._getBranches();\n    const columns = \"_gristsys_ActionHistory.id, actionNum, actionHash\" + (withBody ? \", body\" : \"\");\n    const result = await this._fetchParts(null,\n      branches.local_unsent,\n      columns,\n      maxActions,\n      true);\n    result.reverse();  // Implementation note: this could be optimized away when `maxActions`\n    // is not specified, by simply asking _fetchParts for ascending order.\n    return result;\n  }\n\n  /** Check if we need to update the next shared actionNum */\n  private _noteSharedAction(actionNum: number): void {\n    if (actionNum >= this._sharedActionNum) {\n      this._sharedActionNum = actionNum + 1;\n    }\n    this._noteLocalAction(actionNum);\n  }\n\n  /** Check if we need to update the next local actionNum */\n  private _noteLocalAction(actionNum: number): void {\n    if (actionNum >= this._localActionNum) {\n      this._localActionNum = actionNum + 1;\n    }\n  }\n\n  /** Append an action to a branch. */\n  private async _addAction(action: LocalActionBundle,\n    branch: ActionIdentifiers): Promise<number> {\n    action.parentActionHash = branch.actionHash;\n    if (!action.actionHash) {\n      action.actionHash = computeActionHash(action);\n    }\n    const buf = encodeAction(action);\n    return this._db.execTransaction(async () => {\n      // Add the action.  We let SQLite fill in the \"id\" column, which is an alias for\n      // the SQLite rowid in this case: https://www.sqlite.org/rowidtable.html\n      const id = await this._db.runAndGetId(`INSERT INTO _gristsys_ActionHistory\n                                               (actionHash, parentRef, actionNum, body)\n                                               VALUES (?, ?, ?, ?)`,\n      action.actionHash,\n      branch.actionRef,\n      action.actionNum,\n      buf);\n      await this._db.run(`UPDATE _gristsys_ActionHistoryBranch SET actionRef = ?\n                            WHERE name = ?`,\n      id, branch.branchName);\n      return id;\n    });\n  }\n\n  /** Get the current status of the standard branches: shared, local_sent, and local_unsent */\n  private async _getBranches(): Promise<StandardBranches> {\n    const rows = await this._db.all(`SELECT name, actionNum, actionHash, Branch.actionRef\n                                       FROM _gristsys_ActionHistoryBranch as Branch\n                                       LEFT JOIN _gristsys_ActionHistory as History\n                                         ON History.id = Branch.actionRef\n                                       WHERE name in ('shared', 'local_sent', 'local_unsent')`);\n    const bits = mapValues(keyBy(rows, \"name\"), this._asActionIdentifiers);\n    const missing = { actionHash: null, actionRef: null, actionNum: null } as ActionIdentifiers;\n    return {\n      shared: bits.shared || missing,\n      local_sent: bits.local_sent || missing,\n      local_unsent: bits.local_unsent || missing,\n    };\n  }\n\n  /** Cast an sqlite result row into a structure with the IDs we care about */\n  private _asActionIdentifiers(row: ResultRow | null): ActionIdentifiers | null {\n    if (!row) {\n      return null;\n    }\n    return {\n      actionRef: row.actionRef,\n      actionHash: row.actionHash,\n      actionNum: row.actionNum,\n      branchName: row.name,\n    };\n  }\n\n  /**\n   *\n   * Fetch selected parts of a range of actions.  We do a recursive query\n   * working backwards from the action identified by `end`, following a\n   * chain of ancestors via `parentRef` links, until we reach the action\n   * identified by `start` or run out of ancestors.  The action identified\n   * by `start` is NOT included in the results.  Results are returned in\n   * ascending order of `actionNum` - in other words results closer to the\n   * beginning of history are returned first.\n   *\n   * @param start - identifiers of an action not to include in the results.\n   * @param end - identifiers of an action to include in the results\n   * @param selection - SQLite SELECT result-columns to return\n   * @param limit - optional cap on the number of results to return.\n   * @param desc - optional - if true, invert order of results, starting\n   * from highest `actionNum` rather than lowest.\n   *\n   * @return a list of ResultRows, containing whatever was requested in\n   * the `selection` parameter for each action found.\n   *\n   */\n  private async _fetchParts(start: ActionIdentifiers | null,\n    end: ActionIdentifiers | null,\n    selection: string,\n    limit?: number,\n    desc?: boolean): Promise<ResultRow[]> {\n    if (!end) { return []; }\n\n    // Collect all actions, Starting at the branch tip, and working\n    // backwards until we hit a delimiting actionNum.\n    // See https://sqlite.org/lang_with.html for details of recursive CTEs.\n    const rows = await this._db.all(`WITH RECURSIVE\n                                       actions(id) AS (\n                                         VALUES(?)\n                                         UNION ALL\n                                           SELECT parentRef FROM _gristsys_ActionHistory, actions\n                                             WHERE _gristsys_ActionHistory.id = actions.id\n                                               AND parentRef IS NOT NULL\n                                               AND _gristsys_ActionHistory.id IS NOT ?)\n                                     SELECT ${selection} from actions\n                                       JOIN _gristsys_ActionHistory\n                                         ON actions.id = _gristsys_ActionHistory.id\n                                       WHERE _gristsys_ActionHistory.id IS NOT ?\n                                       ORDER BY actionNum ${desc ? \"DESC \" : \"\"}\n                                       ${limit ? (\"LIMIT \" + limit) : \"\"}`,\n    end.actionRef,\n    start ? start.actionRef : null,\n    start ? start.actionRef : null);\n    return rows;\n  }\n\n  /**\n   *\n   * Fetch a range of actions as LocalActionBundles.  We do a recursive query\n   * working backwards from the action identified by `end`, following a\n   * chain of ancestors via `parentRef` links, until we reach the action\n   * identified by `start` or run out of ancestors.  The action identified\n   * by `start` is NOT included in the results.  Results are returned in\n   * ascending order of `actionNum` - in other words results closer to the\n   * beginning of history are returned first.\n   *\n   * @param start - identifiers of an action not to include in the results.\n   * @param end - identifiers of an action to include in the results\n   *\n   * @return a list of LocalActionBundles.\n   *\n   */\n  private async _fetchActions(start: ActionIdentifiers | null,\n    end: ActionIdentifiers | null): Promise<LocalActionBundle[]> {\n    const rows = await this._fetchParts(start, end, \"body, actionNum, actionHash\");\n    return reportTimeTaken(\"_fetchActions\", () => rows.map(decodeActionFromRow));\n  }\n\n  /**\n   * Delete rows in the ActionHistory.  Any client id association is also removed for\n   * the given rows.  Branch information is not updated, it is the responsibility of\n   * the caller to keep that synchronized.\n   *\n   * @param rows: The rows to delete. Should have at least id and actionHash fields.\n   * @param invert: True if all but the listed rows should be deleted.\n   *\n   * Returns the list of ids of the supplied rows.\n   */\n  private async _deleteRows(rows: ResultRow[], invert?: boolean): Promise<number[]> {\n    // There's no great solution for passing a long list of numbers to sqlite for a\n    // single query.  Here, we concatenate them with comma separators and embed them\n    // in the SQL string.\n    // TODO: deal with limit on max length of sql statement https://www.sqlite.org/limits.html\n    const ids = rows.map(row => row.id);\n    const idList = ids.join(\",\");\n    await this._db.run(`DELETE FROM _gristsys_ActionHistory\n                          WHERE id ${invert ? \"NOT\" : \"\"} IN (${idList})`);\n    for (const row of rows) {\n      this._actionUndoInfo.delete(row.actionHash);\n    }\n    return ids;\n  }\n\n  /**\n   * Deletes rows in the ActionHistory if there are too many of them or they hold too\n   * much data.\n   */\n  private async _pruneLargeHistory(actionNum: number): Promise<void> {\n    // We check history size occasionally, not on every single action.  The check\n    // requires summing a blob length over up to roughly ACTION_HISTORY_MAX_ROWS rows.\n    // For a 2GB test db with 3 times this number of rows, the check takes < 10 ms.\n    // But there's no need to add that tax to every action.\n    if (actionNum % this._options.checkPeriod !== 0) {\n      return;\n    }\n    // Do a quick check on the history size.  We work on the \"shared\" branch, to\n    // avoid the possibility of deleting history that has not yet been shared.\n    let branches = await this._getBranches();\n    const checks = (await this._fetchParts(null,\n      branches.shared,\n      \"count(*) as count, sum(length(body)) as bytes\",\n      undefined,\n      true))[0];\n    if (checks.count <= this._options.maxRows * this._options.graceFactor &&\n      checks.bytes <= this._options.maxBytes * this._options.graceFactor) {\n      return; // Nothing to do, size is ok.\n    }\n    // Too big!  Check carefully what needs to be done.\n    await this._db.execTransaction(async () => {\n      // Make sure branches are up to date within this transaction.\n      branches = await this._getBranches();\n      const rows = await this._fetchParts(null,\n        branches.shared,\n        \"_gristsys_ActionHistory.id, actionHash, actionNum, length(body) as bytes\",\n        undefined,\n        true);\n      // Scan to find the first row that pushes us over a limit.\n      let count: number = 0;\n      let bytes: number = 0;\n      let first: number = -1;\n      for (let i = 0; i < rows.length; i++) {\n        const row = rows[i];\n        count++;\n        bytes += row.bytes;\n        if (count > 1 && (bytes > this._options.maxBytes || count > this._options.maxRows)) {\n          first = i;\n          break;\n        }\n      }\n      if (first === -1) { return; }\n      // Delete remaining rows - in batches because _deleteRows has limited capacity.\n      const batchLength: number = 100;\n      for (let i = first; i < rows.length; i += batchLength) {\n        const batch = rows.slice(i, i + batchLength);\n        const ids = await this._deleteRows(batch);\n        // We are removing all rows from the start of history to a certain point.\n        // So, if any of the removed actions are mentioned as the tip of a branch,\n        // that tip should now simply become null/empty.\n        await this._db.run(`UPDATE _gristsys_ActionHistoryBranch SET actionRef = NULL WHERE actionRef IN (${ids})`);\n      }\n      // At this point, to recover the maximum memory, we could VACUUM the document.\n      // But vacuuming is an unacceptably slow operation for large documents (e.g.\n      // 30 secs for a 2GB doc) so it is obnoxious to do that while the user is waiting.\n      // Without vacuuming, the document will grow due to fragmentation, but this should\n      // be at a lower rate than it would grow if we were simply retaining full history.\n      // TODO: occasionally VACUUM large documents while they are not being used.\n    });\n  }\n}\n"
  },
  {
    "path": "app/server/lib/ActiveDoc.ts",
    "content": "/**\n * Module to manage \"active\" Grist documents, i.e. those loaded in-memory, with\n * clients connected to them. It handles the incoming user actions, and outgoing\n * change events.\n */\n\nimport { ACLRuleCollection } from \"app/common/ACLRuleCollection\";\nimport {\n  getEnvContent,\n  LocalActionBundle,\n  SandboxActionBundle,\n  SandboxRequest,\n  UserActionBundle,\n} from \"app/common/ActionBundle\";\nimport { ActionGroup, MinimalActionGroup } from \"app/common/ActionGroup\";\nimport { rebaseSummary } from \"app/common/ActionSummarizer\";\nimport { ActionSummary } from \"app/common/ActionSummary\";\nimport {\n  AclResources,\n  AclTableDescription,\n  ApplyProposalResult,\n  ApplyUAExtendedOptions,\n  ApplyUAOptions,\n  ApplyUAResult,\n  AssistantState,\n  DataSourceTransformed,\n  ForkResult,\n  FormulaTimingInfo,\n  GetActionSummariesResult,\n  ImportOptions,\n  ImportResult,\n  ISuggestionWithValue,\n  MergeOptions,\n  PatchLog,\n  PermissionDataWithExtraUsers,\n  QueryResult,\n  ServerQuery,\n  TableFetchResult,\n  TransformRule,\n  VisibleUserProfile,\n} from \"app/common/ActiveDocAPI\";\nimport { ApiError } from \"app/common/ApiError\";\nimport {\n  AssistanceContextV1, AssistanceRequest, AssistanceResponse, isAssistanceRequestV2,\n} from \"app/common/Assistance\";\nimport { mapGetOrSet, MapWithTTL } from \"app/common/AsyncCreate\";\nimport { AttachmentColumns, gatherAttachmentIds, getAttachmentColumns } from \"app/common/AttachmentColumns\";\nimport { WebhookMessageType } from \"app/common/CommTypes\";\nimport {\n  BulkAddRecord,\n  BulkRemoveRecord,\n  BulkUpdateRecord,\n  CellValue,\n  DocAction,\n  getTableId,\n  isSchemaAction,\n  TableDataAction,\n  toTableDataAction,\n  UserAction,\n} from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { getDataLimitInfo, getDataLimitRatio, getSeverity } from \"app/common/DocLimits\";\nimport { DocSnapshots } from \"app/common/DocSnapshot\";\nimport {\n  DocState,\n  DocStateComparison,\n  removeMetadataChangesFromDetails,\n} from \"app/common/DocState\";\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\nimport {\n  DataLimitInfo,\n  DocumentUsage,\n  DocUsageSummary,\n  FilteredDocUsageSummary,\n  RowCounts,\n} from \"app/common/DocUsage\";\nimport { normalizeEmail } from \"app/common/emails\";\nimport { Features, Product } from \"app/common/Features\";\nimport { isHiddenCol } from \"app/common/gristTypes\";\nimport { commonUrls, parseUrlId } from \"app/common/gristUrls\";\nimport { byteString, countIf, retryOnce, safeJsonParse, timeoutReached } from \"app/common/gutil\";\nimport { InactivityTimer } from \"app/common/InactivityTimer\";\nimport { Interval } from \"app/common/Interval\";\nimport { APPROACHING_LIMIT_RATIO, getUsageRatio, LimitExceededError } from \"app/common/Limits\";\nimport { normalizedDateTimeString } from \"app/common/normalizedDateTimeString\";\nimport {\n  compilePredicateFormula,\n  getPredicateFormulaProperties,\n  PredicateFormulaProperties,\n} from \"app/common/PredicateFormula\";\nimport * as roles from \"app/common/roles\";\nimport { schema, SCHEMA_VERSION } from \"app/common/schema\";\nimport { MetaRowRecord, SingleCell } from \"app/common/TableData\";\nimport { TelemetryEvent, TelemetryMetadataByLevel } from \"app/common/Telemetry\";\nimport { FetchUrlOptions, UploadResult } from \"app/common/uploads\";\nimport {\n  ANONYMOUS_USER_EMAIL,\n  ArchiveUploadResult,\n  AttachmentTransferStatus,\n  CreatableArchiveFormats,\n  DocReplacementOptions,\n  Document as APIDocument,\n  ExpandTableOption,\n  NEW_DOCUMENT_CODE,\n} from \"app/common/UserAPI\";\nimport { convertFromColumn } from \"app/common/ValueConverter\";\nimport { guessColInfo } from \"app/common/ValueGuesser\";\nimport { parseUserAction } from \"app/common/ValueParser\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Share } from \"app/gen-server/entity/Share\";\nimport { Scope } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { ColumnMetadata, TableMetadata } from \"app/plugin/DocApiTypes\";\nimport { ParseFileResult, ParseOptions } from \"app/plugin/FileParserAPI\";\nimport { AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId } from \"app/plugin/GristAPI\";\nimport { ActionHistory } from \"app/server/lib/ActionHistory\";\nimport { ActionHistoryImpl } from \"app/server/lib/ActionHistoryImpl\";\nimport { ActiveDocImport, FileImportOptions } from \"app/server/lib/ActiveDocImport\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport {\n  Archive,\n  ArchiveEntry,\n  create_tar_archive,\n  create_zip_archive, unpackTarArchive,\n} from \"app/server/lib/Archive\";\nimport { getAndRemoveAssistantStatePermit } from \"app/server/lib/AssistantStatePermit\";\nimport { AttachmentFileManager, MismatchedFileHashError } from \"app/server/lib/AttachmentFileManager\";\nimport { IAttachmentStoreProvider } from \"app/server/lib/AttachmentStoreProvider\";\nimport { AuditEventAction } from \"app/server/lib/AuditEvent\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { Client } from \"app/server/lib/Client\";\nimport { getChanges } from \"app/server/lib/DocApi\";\nimport { getMetaTables } from \"app/server/lib/DocApiUtils\";\nimport { DocClients } from \"app/server/lib/DocClients\";\nimport { DEFAULT_CACHE_TTL, DocManager } from \"app/server/lib/DocManager\";\nimport { DocPluginManager } from \"app/server/lib/DocPluginManager\";\nimport { DocSession, DocSessionPrecursor, makeExceptionalDocSession, OptDocSession } from \"app/server/lib/DocSession\";\nimport { createAttachmentsIndex, DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY } from \"app/server/lib/DocStorage\";\nimport { expandQuery, getFormulaErrorForExpandQuery } from \"app/server/lib/ExpandedQuery\";\nimport { GranularAccess, GranularAccessForBundle } from \"app/server/lib/GranularAccess\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport {\n  AssistanceFormulaEvaluationResult,\n  AssistanceSchemaPromptV1Context,\n  isAssistantV2,\n} from \"app/server/lib/IAssistant\";\nimport { AuditEventProperties } from \"app/server/lib/IAuditLogger\";\nimport { makeForkIds } from \"app/server/lib/idUtils\";\nimport { GRIST_DOC_SQL, GRIST_DOC_WITH_TABLE1_SQL } from \"app/server/lib/initialDocSql\";\nimport { insightLogDecorate, insightLogEntry, insightLogWrap } from \"app/server/lib/InsightLog\";\nimport { ISandbox } from \"app/server/lib/ISandbox\";\nimport log from \"app/server/lib/log\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\nimport { ISandboxOptions } from \"app/server/lib/NSandbox\";\nimport { NullSandbox, UnavailableSandboxMethodError } from \"app/server/lib/NullSandbox\";\nimport { OnDemandActions } from \"app/server/lib/OnDemandActions\";\nimport { Patch } from \"app/server/lib/Patch\";\nimport { isUntrustedRequestBehaviorSet } from \"app/server/lib/ProxyAgent\";\nimport { DocRequests } from \"app/server/lib/Requests\";\nimport { SandboxError } from \"app/server/lib/sandboxUtil\";\nimport {\n  getDocSessionAccess,\n  getDocSessionAccessOrNull,\n  getDocSessionShare,\n  getDocSessionUsage,\n  getLogMeta,\n  RequestOrSession,\n} from \"app/server/lib/sessionUtils\";\nimport { findOrAddAllEnvelope, Sharing } from \"app/server/lib/Sharing\";\nimport { shortDesc } from \"app/server/lib/shortDesc\";\nimport { TableMetadataLoader } from \"app/server/lib/TableMetadataLoader\";\nimport { DocTriggers } from \"app/server/lib/Triggers\";\nimport { fetchURL, FileUploadInfo, globalUploadSet, UploadInfo } from \"app/server/lib/uploads\";\nimport { UserPresence } from \"app/server/lib/UserPresence\";\nimport { ComposedActionQueue, WebhookQueue } from \"app/server/lib/WebhookQueue\";\n\nimport assert from \"assert\";\nimport { EventEmitter } from \"events\";\nimport stream from \"node:stream\";\nimport path from \"path\";\n\nimport { Mutex } from \"async-mutex\";\nimport * as bluebird from \"bluebird\";\nimport { readFile } from \"fs-extra\";\nimport { IMessage, MsgType } from \"grain-rpc\";\nimport imageSize from \"image-size\";\nimport { Cancelable } from \"lodash\";\nimport cloneDeep from \"lodash/cloneDeep\";\nimport flatten from \"lodash/flatten\";\nimport merge from \"lodash/merge\";\nimport pick from \"lodash/pick\";\nimport sum from \"lodash/sum\";\nimport throttle from \"lodash/throttle\";\nimport without from \"lodash/without\";\nimport * as moment from \"moment-timezone\";\nimport fetch from \"node-fetch\";\n\nconst MAX_RECENT_ACTIONS = 100;\n\nconst DEFAULT_TIMEZONE = (process.versions as any).electron ? moment.tz.guess() : \"UTC\";\nconst DEFAULT_LOCALE = process.env.GRIST_DEFAULT_LOCALE || \"en-US\";\n\n// Number of seconds an ActiveDoc is retained without any clients.\n// In dev environment, it is convenient to keep this low for quick tests.\n// In production, it is reasonable to stretch it out a bit.\nconst ACTIVEDOC_TIMEOUT = (process.env.NODE_ENV === \"production\") ? 30 : 5;\n\n// We'll wait this long between re-measuring sandbox memory.\nconst MEMORY_MEASUREMENT_THROTTLE_WAIT_MS = 60 * 1000;\n\n// Apply the UpdateCurrentTime user action every hour\nconst UPDATE_CURRENT_TIME_DELAY = { delayMs: 60 * 60 * 1000, varianceMs: 30 * 1000 };\n\n// Measure and broadcast data size every 5 minutes\nconst UPDATE_DATA_SIZE_DELAY = { delayMs: 5 * 60 * 1000, varianceMs: 30 * 1000 };\n\n// Log document metrics every hour\nconst LOG_DOCUMENT_METRICS_DELAY = { delayMs: 60 * 60 * 1000, varianceMs: 30 * 1000 };\n\n// For items of work that need to happen at shutdown, timeout before aborting the wait for them.\nconst SHUTDOWN_ITEM_TIMEOUT_MS = 5000;\n\nconst MAX_INTERNAL_ATTACHMENTS_BYTES =\n  appSettings.section(\"externalStorage\").flag(\"maxInternalBytes\").readInt({\n    envVar: \"GRIST_MAX_INTERNAL_ATTACHMENTS_BYTES\",\n  });\n\n// We keep a doc open while a user action is pending, but not longer than this. If it's pending\n// this long, the ACTIVEDOC_TIMEOUT will still kick in afterwards, and in the absence of other\n// activity, the doc would still get shut down, with the action's effect lost. This is to prevent\n// indefinitely running processes in case of an infinite loop in a formula.\nconst KEEP_DOC_OPEN_TIMEOUT_MS = 5 * 60 * 1000;\n\n// A hook for dependency injection.\nexport const Deps = {\n  ACTIVEDOC_TIMEOUT,\n  ACTIVEDOC_TIMEOUT_ACTION: \"shutdown\" as \"shutdown\" | \"ignore\",\n\n  UPDATE_CURRENT_TIME_DELAY,\n  SHUTDOWN_ITEM_TIMEOUT_MS,\n  KEEP_DOC_OPEN_TIMEOUT_MS,\n  MAX_INTERNAL_ATTACHMENTS_BYTES,\n};\n\ninterface ActiveDocOptions {\n  safeMode?: boolean;\n  docUrl?: string;\n  docApiUrl?: string;\n  doc?: Document;\n}\n\ninterface UpdateUsageOptions {\n  // Whether usage should be synced to the home database. Defaults to true.\n  syncUsageToDatabase?: boolean;\n  // Whether usage should be broadcast to all doc clients. Defaults to true.\n  broadcastUsageToClients?: boolean;\n}\n\n/**\n * Represents an active document with the given name. The document isn't actually open until\n * either .loadDoc() or .createEmptyDoc() is called.\n * @param {String} docName - The document's filename, without the '.grist' extension.\n */\nexport class ActiveDoc extends EventEmitter {\n  /**\n   * Decorator for ActiveDoc methods that prevents shutdown while the method is running, i.e.\n   * until the returned promise is resolved, or KEEP_DOC_OPEN_TIMEOUT_MS passes.\n   */\n  public static keepDocOpen(target: ActiveDoc, propertyKey: string, descriptor: PropertyDescriptor) {\n    const origFunc = descriptor.value;\n    descriptor.value = function(this: ActiveDoc) {\n      const result = origFunc.apply(this, arguments);\n      this._inactivityTimer.disableUntilFinish(timeoutReached(Deps.KEEP_DOC_OPEN_TIMEOUT_MS, result))\n        .catch(() => {});\n      return result;\n    };\n  }\n\n  public readonly doc: Document | undefined = this._options?.doc;\n  public readonly docStorage: DocStorage;\n  public readonly docPluginManager: DocPluginManager | null;\n  public readonly docClients: DocClients;               // Only exposed for Sharing.ts\n  public docData: DocData | null = null;\n  // Used by DocApi to only allow one webhook-related endpoint to run at a time.\n  public readonly triggersLock: Mutex = new Mutex();\n  public isTimingOn = false;\n\n  public isFork: boolean;\n\n  protected _actionHistory: ActionHistory;\n  protected _sharing: Sharing;\n  // This lock is used to avoid reading sandbox state while it is being modified but before\n  // the result has been confirmed to pass granular access rules (which may depend on the\n  // result).\n  protected _modificationLock: Mutex = new Mutex();\n\n  private readonly _server: GristServer = this._docManager.gristServer;\n  private _log = new LogMethods(\"ActiveDoc \", (s: OptDocSession | null) => this.getLogMeta(s));\n  private _triggers: DocTriggers;\n  private _webhookQueue: WebhookQueue;\n  private _requests: DocRequests;\n  private _dataEngine: Promise<ISandbox> | null = null;\n  private _activeDocImport: ActiveDocImport;\n  private _onDemandActions: OnDemandActions;\n  private _granularAccess: GranularAccess;\n  private _tableMetadataLoader: TableMetadataLoader;\n  private _userPresence: UserPresence;\n  /**\n   * If set, changes to this document should not propagate to outside world\n   */\n  private _muted: boolean = false;\n  /**\n   * If positive, a migration is in progress\n   */\n  private _migrating: number = 0;\n  /**\n   * If set, wait on this to be sure the ActiveDoc is fully\n   * initialized.  True on success.\n   */\n  private _initializationPromise: Promise<void> | null = null;\n  /**\n   * Becomes true once all columns are loaded/computed.\n   */\n  private _fullyLoaded: boolean = false;\n  private _memoryUsedMB: number = 0;\n  private _fetchCache = new MapWithTTL<string, Promise<TableDataAction>>(DEFAULT_CACHE_TTL);\n  private _docUsage: DocumentUsage | null = null;\n  private _product?: Product;\n  private _features?: Features;\n  private _gracePeriodStart: Date | null = null;\n  private _isSnapshot: boolean;\n  private _isForkOrSnapshot: boolean;\n  private _onlyAllowMetaDataActionsOnDb: boolean = false;\n  // Cache of which columns are attachment columns.\n  private _attachmentColumns?: AttachmentColumns;\n  private _attachmentFileManager: AttachmentFileManager;\n\n  // Client watching for 'product changed' event published by Billing to update usage\n  private _pubSubUnsubscribe?: () => void;\n\n  // Timer for shutting down the ActiveDoc a bit after all clients are gone.\n  private _inactivityTimer = new InactivityTimer(() => {\n    this._log.debug(null, \"inactivity timeout\");\n    return this._onInactive();\n  }, Deps.ACTIVEDOC_TIMEOUT * 1000);\n\n  private _recoveryMode: boolean = false;\n  private _shuttingDown: boolean = false;\n  private _afterShutdownCallback?: () => Promise<void>;\n  private _doShutdown?: Promise<void>;\n  private _intervals: Interval[] = [];\n  private _isUntrustedRequestBehaviorSet?: boolean;\n\n  // Size of the last _rawPyCall() response in bytes.\n  private _lastPyCallResponseSize: number | undefined;\n\n  private _reportDataEngineMemoryThrottled:\n    | ((() => Promise<void>) & Cancelable) |\n    null = throttle(\n      this._reportDataEngineMemory.bind(this),\n      MEMORY_MEASUREMENT_THROTTLE_WAIT_MS,\n      {\n        leading: true,\n        trailing: true,\n      },\n    );\n\n  constructor(\n    private readonly _docManager: DocManager,\n    private _docName: string,\n    private _attachmentStoreProvider?: IAttachmentStoreProvider,\n    private _options?: ActiveDocOptions,\n  ) {\n    super();\n    const { trunkId, forkId, snapshotId } = parseUrlId(_docName);\n    this.isFork = Boolean(forkId);\n    this._isSnapshot = Boolean(snapshotId);\n\n    if (!this._isSnapshot) {\n      /**\n       * In cases where large numbers of documents are restarted simultaneously\n       * (like during deployments), there's a tendency for scheduled intervals to\n       * execute at roughly the same moment in time, which causes spikes in load.\n       *\n       * To mitigate this, we use randomized intervals that re-compute their delay\n       * in-between calls, with a variance of 30 seconds.\n       */\n      this._intervals.push(\n        // Cleanup expired attachments every hour (also happens when shutting down).\n        new Interval(\n          () => this.removeUnusedAttachments(true),\n          REMOVE_UNUSED_ATTACHMENTS_DELAY,\n          { onError: e => this._log.error(null, \"failed to remove expired attachments\", e) },\n        ),\n        // Update the time in formulas every hour.\n        new Interval(\n          () => this._updateCurrentTime(),\n          Deps.UPDATE_CURRENT_TIME_DELAY,\n          { onError: e => this._log.error(null, \"failed to update current time\", e) },\n        ),\n        // Measure and broadcast data size every 5 minutes.\n        new Interval(\n          () => this._checkDataSizeLimitRatio(makeExceptionalDocSession(\"system\")),\n          UPDATE_DATA_SIZE_DELAY,\n          { onError: e => this._log.error(null, \"failed to update data size\", e) },\n        ),\n        // Log document metrics every hour.\n        new Interval(\n          async () => { this._logDocMetrics(makeExceptionalDocSession(\"system\"), \"interval\"); },\n          LOG_DOCUMENT_METRICS_DELAY,\n          { onError: e => this._log.error(null, \"failed to log document metrics\", e) },\n        ),\n      );\n    }\n    if (_options?.safeMode) { this._recoveryMode = true; }\n    if (_options?.doc) {\n      const { gracePeriodStart, workspace, usage } = _options.doc;\n      const billingAccount = workspace.org.billingAccount;\n      this._product = billingAccount?.product;\n      this._features = billingAccount?.getFeatures();\n      this._gracePeriodStart = gracePeriodStart;\n\n      if (billingAccount) {\n        this._pubSubUnsubscribe = this._server.getPubSubManager()\n          .subscribe(`billingAccount-${billingAccount.id}-product-changed`, async () => {\n            // A product change has just happened in Billing.\n            // Reload the doc (causing connected clients to reload) to ensure everyone sees the effect of the change.\n            this._log.debug(null, \"reload after product change\");\n            await this.reloadDoc();\n          })\n          .unsubscribeCB;\n      }\n\n      if (!(this.isFork || this._isSnapshot)) {\n        /* Note: We don't currently persist usage for forks or snapshots anywhere, so\n         * we need to hold off on setting _docUsage here. Normally, usage is set shortly\n         * after initialization finishes, after data/attachments size has finished\n         * calculating. However, this leaves a narrow window where forks can circumvent\n         * delete-only restrictions and replace the trunk document (even when the trunk\n         * is delete-only). This isn't very concerning today as the window is typically\n         * too narrow to easily exploit, and there are other ways to work around limits,\n         * like resetting gracePeriodStart by momentarily lowering usage. Regardless, it\n         * would be good to fix this eventually (perhaps around the same time we close\n         * up the gracePeriodStart loophole).\n         *\n         * TODO: Revisit this later and patch up the loophole. */\n        this._docUsage = usage;\n      }\n    }\n    this.docStorage = new DocStorage(_docManager.storageManager, _docName);\n    this.docClients = new DocClients(this);\n    this._userPresence = new UserPresence(this.docClients);\n    this._webhookQueue = new WebhookQueue(this);\n\n    // We will create a wrapper around the action queue, and reroute actions to different queues/handlers.\n    // Webhooks are delivered through the webhook queue, tightly coupled with particular doc worker, and it will\n    // - stop the processing of further actions if queue is too long\n    // - send any request from within the doc worker server\n    // Emails are delivered through the DocNotificationManager, which is a service running on the home server\n    // and has its own batching-job queue mechanism.\n    // NOTICE: this is just a 'plugin' mechanism for other flavors of Grist to implement. Emails are currently\n    // only supported in Grist Cloud or in Enterprise installations.\n    const actionRoute = new ComposedActionQueue();\n    actionRoute.use(\"webhook\", this._webhookQueue);\n    actionRoute.use(\"email\",\n      ev => this._server.getDocNotificationManager()?.rowNotification(this._docName, ev) ?? Promise.resolve());\n\n    this._triggers = new DocTriggers(this, actionRoute);\n    this._requests = new DocRequests(this);\n    this._actionHistory = new ActionHistoryImpl(this.docStorage);\n    this.docPluginManager = _docManager.pluginManager ?\n      new DocPluginManager(\n        _docManager.pluginManager.getPlugins(),\n        _docManager.pluginManager.appRoot!,\n        this,\n        this._server,\n      ) :\n      null;\n    this._tableMetadataLoader = new TableMetadataLoader({\n      decodeBuffer: this.docStorage.decodeMarshalledData.bind(this.docStorage),\n      fetchTable: this.docStorage.fetchTable.bind(this.docStorage),\n      loadMetaTables: this._rawPyCall.bind(this, \"load_meta_tables\"),\n      loadTable: this._rawPyCall.bind(this, \"load_table\"),\n    });\n\n    // This will throw errors if _options?.doc or _attachmentStoreProvider aren't provided,\n    // and ActiveDoc tries to use an external attachment store.\n    this._attachmentFileManager = new AttachmentFileManager(\n      this.docStorage,\n      _attachmentStoreProvider,\n      forkId ? { id: forkId, trunkId } : { id: trunkId, trunkId: undefined },\n      (extraBytes: number) => this._assertAttachmentSizeBelowLimit(extraBytes, {\n        checkInternal: true,\n        checkProduct: false,\n      }),\n    );\n\n    // Every time manager starts the transfer we need to notify clients about it.\n    const notifier = this.sendAttachmentTransferStatusNotification.bind(this);\n\n    // Manager emits to events, at the start and at the end, but we don't care here, we\n    // just want to notify about the current status. We also don't need to unregister\n    // as the manager will be shutdown with us.\n    this._attachmentFileManager\n      .on(AttachmentFileManager.events.TRANSFER_STARTED, notifier)\n      .on(AttachmentFileManager.events.TRANSFER_COMPLETED, notifier);\n\n    // Our DataEngine is a separate sandboxed process (one sandbox per open document,\n    // corresponding to many processes for gvisor).\n    // The data engine runs user-defined python code including formula calculations.\n    // It maintains all document data and metadata, and applies translates higher-level UserActions\n    // into lower-level DocActions.\n\n    // Creation of the data engine needs to be deferred since we need to look at the document to\n    // see what kind of engine it needs. This doesn't delay loading the document, but could delay\n    // first calculation and modification.\n    // TODO: consider caching engine requirement for doc in home db\n    // (but would still need to look at doc to know what process to start in sandbox)\n\n    this._activeDocImport = new ActiveDocImport(this);\n\n    // Schedule shutdown immediately. If a client connects soon (normal case), it will get\n    // unscheduled. If not (e.g. abandoned import, network problems after creating a doc), then\n    // the ActiveDoc will get cleaned up.\n    this._inactivityTimer.enable();\n  }\n\n  public get docName(): string { return this._docName; }\n\n  public get features(): Features | undefined { return this._features; }\n\n  public get recoveryMode(): boolean { return this._recoveryMode; }\n\n  public get isShuttingDown(): boolean { return this._shuttingDown; }\n\n  public get triggers(): DocTriggers { return this._triggers; }\n\n  public get webhookQueue(): WebhookQueue { return this._webhookQueue; }\n\n  public get rowLimitRatio(): number {\n    return getUsageRatio(\n      this._docUsage?.rowCount?.total,\n      this._features?.baseMaxRowsPerDocument,\n    );\n  }\n\n  public get dataSizeLimitRatio(): number {\n    return getUsageRatio(\n      this._docUsage?.dataSizeBytes,\n      this._features?.baseMaxDataSizePerDocument,\n    );\n  }\n\n  public get dataLimitRatio(): number {\n    return getDataLimitRatio(this._docUsage, this._features);\n  }\n\n  public get dataLimitInfo(): DataLimitInfo {\n    return getDataLimitInfo({\n      docUsage: this._docUsage,\n      productFeatures: this._features,\n      gracePeriodStart: this._gracePeriodStart,\n    });\n  }\n\n  public getDocUsageSummary(): DocUsageSummary {\n    return {\n      dataLimitInfo: this.dataLimitInfo,\n      rowCount: this._docUsage?.rowCount ?? \"pending\",\n      dataSizeBytes: this._docUsage?.dataSizeBytes ?? \"pending\",\n      attachmentsSizeBytes: this._docUsage?.attachmentsSizeBytes ?? \"pending\",\n    };\n  }\n\n  public async getFilteredDocUsageSummary(\n    docSession: OptDocSession,\n  ): Promise<FilteredDocUsageSummary> {\n    return this._granularAccess.filterDocUsageSummary(docSession, this.getDocUsageSummary());\n  }\n\n  public getUser(docSession: OptDocSession) {\n    return this._granularAccess.getUser(docSession);\n  }\n\n  public async getUserOverride(docSession: OptDocSession) {\n    return this._granularAccess.getUserOverride(docSession);\n  }\n\n  // Constructs metadata for logging, given a Client or an OptDocSession.\n  public getLogMeta(docSession: OptDocSession | null, docMethod?: string): log.ILogMeta {\n    return {\n      ...getLogMeta(docSession),\n      docId: this._docName,\n      ...(docMethod ? { docMethod } : {}),\n    };\n  }\n\n  public setMuted() {\n    this._muted = true;\n  }\n\n  public get muted() {\n    return this._muted;\n  }\n\n  public isMigrating() {\n    return this._migrating;\n  }\n\n  // Note that this method is only used in tests, and should be avoided in production (see note\n  // in ActionHistory about getRecentActions).\n  public getRecentActionsDirect(maxActions?: number): Promise<LocalActionBundle[]> {\n    return this._actionHistory.getRecentActions(maxActions);\n  }\n\n  public async getRecentStates(docSession: OptDocSession, maxStates?: number): Promise<DocState[]> {\n    // Doc states currently don't include user content, so it seems ok to let all\n    // viewers have access to them.\n    return this._actionHistory.getRecentStates(maxStates);\n  }\n\n  /**\n   * Access specific actions identified by actionNum.\n   * TODO: for memory reasons on large docs, would be best not to hold many actions\n   * in memory at a time, so should e.g. fetch them one at a time.\n   */\n  public getActions(actionNums: number[]): Promise<(LocalActionBundle | undefined)[]> {\n    return this._actionHistory.getActions(actionNums);\n  }\n\n  /**\n   * Get the most recent actions from the history.  Results are ordered by\n   * earliest actions first, later actions later.  If `summarize` is set,\n   * action summaries are computed and included.\n   */\n  public async getRecentActions(\n    docSession: OptDocSession, summarize: boolean,\n  ): Promise<GetActionSummariesResult> {\n    const groups = await this._actionHistory.getRecentActionGroups(MAX_RECENT_ACTIONS,\n      { clientId: docSession.client?.clientId, summarize });\n    const permittedGroups: ActionGroup[] = [];\n    let censored: boolean = false;\n    // Process groups serially since the work is synchronous except for some\n    // possible db accesses that will be serialized in any case.\n    for (const group of groups) {\n      if (await this._granularAccess.allowActionGroup(docSession, group)) {\n        permittedGroups.push(group);\n      } else {\n        censored = true;\n      }\n    }\n    return { actions: permittedGroups, censored };\n  }\n\n  public async getRecentMinimalActions(docSession: OptDocSession): Promise<MinimalActionGroup[]> {\n    return this._actionHistory.getRecentMinimalActionGroups(\n      MAX_RECENT_ACTIONS, docSession.client?.clientId);\n  }\n\n  /** expose action history for tests */\n  public getActionHistory(): ActionHistory {\n    return this._actionHistory;\n  }\n\n  public handleTriggers(localActionBundle: LocalActionBundle): Promise<ActionSummary> {\n    return this._triggers.handle(localActionBundle);\n  }\n\n  /**\n   * Adds a client of this doc to the list of connected clients.\n   * @param client: The client object maintaining the websocket connection.\n   * @param docSessionPrecursor: The incomplete docSession, to be filled in and turned into real\n   *   DocSession.\n   * @returns docSession\n   */\n  public addClient(client: Client, docSessionPrecursor: DocSessionPrecursor): DocSession {\n    const docSession: DocSession = this.docClients.addClient(client, docSessionPrecursor);\n\n    // If we had a shutdown scheduled, unschedule it.\n    if (this._inactivityTimer.isEnabled()) {\n      this._log.info(docSession, \"will stay open\");\n      this._inactivityTimer.disable();\n    }\n    return docSession;\n  }\n\n  public async listActiveUserProfiles(docSession: DocSession): Promise<VisibleUserProfile[]> {\n    return this._userPresence.listVisibleUserProfiles(docSession);\n  }\n\n  public async getAssistance(docSession: OptDocSession,\n    params: AssistanceRequest): Promise<AssistanceResponse> {\n    const dbManager = this._getHomeDbManagerOrFail();\n    const userId = docSession.userId || dbManager.getAnonymousUserId();\n    const scope: Scope = {\n      urlId: this.docName,\n      userId,\n      org: docSession.org,\n    };\n    await dbManager.increaseUsage(scope, \"assistant\", { delta: 1, dryRun: true });\n\n    const assistant = this._server.getAssistant();\n    if (!assistant) {\n      throw new Error(\"Please set ASSISTANT_CHAT_COMPLETION_ENDPOINT OPENAI_API_KEY\");\n    }\n\n    let result: AssistanceResponse;\n    if (isAssistantV2(assistant) && isAssistanceRequestV2(params)) {\n      result = await assistant.getAssistance(docSession, this, params);\n    } else if (!isAssistantV2(assistant) && !isAssistanceRequestV2(params)) {\n      // Same code, different types.\n      result = await assistant.getAssistance(docSession, this, params);\n    } else {\n      throw new ApiError(\"Wrong type of assistance request\", 400);\n    }\n    const limit = await dbManager.increaseUsage(scope, \"assistant\", { delta: 1 });\n    return {\n      ...result,\n      limit: !limit ? undefined : {\n        usage: limit.usage,\n        limit: limit.limit,\n      },\n    };\n  }\n\n  /**\n   * Shut down the ActiveDoc, and remove it from the DocManager. An optional\n   * afterShutdown operation can be provided, which will be run once the ActiveDoc\n   * is completely shut down but before it is removed from the DocManager, ensuring\n   * that the operation will not overlap with a new ActiveDoc starting up for the\n   * same document.\n   *\n   * @param [options] Options to customize the shutdown process.\n   * @param [options.beforeShutdown] A function to call before shutdown.\n   * NOTE: If a shutdown is already in progress, this callback will be ignored (it would be too\n   *   late, obviously). In other words, the first call to this function determines the\n   *   `beforeShutdown` callback to use\n   * (or none if not provided).\n   *\n   * @param [options.afterShutdown] A function to call after shutdown.\n   * NOTE: Unlike `beforeShutdown`, providing an `afterShutdown` callback will set or overwrite\n   * the callback to be called after the shutdown, **even if** it is already in progress.\n   * In other words, the last call to this function determines the `afterShutdown` callback to use\n   *   when provided.\n   */\n  public async shutdown(options: {\n    beforeShutdown?: () => Promise<void>,\n    afterShutdown?: () => Promise<void>\n  } = {}): Promise<void> {\n    if (options.afterShutdown) {\n      this._afterShutdownCallback = options.afterShutdown;\n    }\n    this._doShutdown ||= this._doShutdownImpl({ beforeShutdown: options.beforeShutdown });\n    await this._doShutdown;\n  }\n\n  /**\n   * Create a new blank document (no \"Table1\") using the data engine. This is used only\n   * to generate the SQL saved to initialDocSql.ts\n   *\n   * It does not set documentSettings.engine.  When a document is created during normal\n   * operation, documentSettings.engine gets set after the SQL is used to seed it, in\n   * _createDocFile()\n   *\n   */\n  @ActiveDoc.keepDocOpen\n  public async createEmptyDocWithDataEngine(docSession: OptDocSession): Promise<ActiveDoc> {\n    this._log.debug(docSession, \"createEmptyDocWithDataEngine\");\n    await this._docManager.storageManager.prepareToCreateDoc(this.docName);\n    await this.docStorage.createFile();\n    this._registerSQLiteDB();\n    await this._rawPyCall(\"load_empty\");\n    // This init action is special. It creates schema tables, and is used to init the DB, but does\n    // not go through other steps of a regular action (no ActionHistory or broadcasting).\n    const initBundle = await this._rawPyCall(\"apply_user_actions\", [[\"InitNewDoc\"]]);\n    await this.docStorage.execTransaction(() =>\n      this.docStorage.applyStoredActions(getEnvContent(initBundle.stored)));\n    // DocStorage can't create this index in the initial schema\n    // because the table _grist_Attachments doesn't exist at that point - it's created by InitNewDoc.\n    await createAttachmentsIndex(this.docStorage);\n\n    // A DocData object is needed, but for this purpose, it's OK that it has no data loaded.\n    const docData = new DocData(tableId => this.fetchTable(makeExceptionalDocSession(\"system\"), tableId), {});\n    await this._initDoc(docSession, docData);\n    await this._tableMetadataLoader.clean();\n    // Makes sure docPluginManager is ready in case new doc is used to import new data\n    await this.docPluginManager?.ready;\n    this._fullyLoaded = true;\n    return this;\n  }\n\n  /**\n   * Create a new blank document (no \"Table1\"), used as a stub when importing.\n   */\n  @ActiveDoc.keepDocOpen\n  public async createEmptyDoc(docSession: OptDocSession,\n    options?: { useExisting?: boolean }): Promise<ActiveDoc> {\n    await this.loadDoc(docSession, { forceNew: true,\n      skipInitialTable: true,\n      ...options });\n    // Makes sure docPluginManager is ready in case new doc is used to import new data\n    await this.docPluginManager?.ready;\n    this._fullyLoaded = true;\n    return this;\n  }\n\n  /**\n   * Loads an existing document from storage, fetching all data from the database via DocStorage and\n   * loading it into the DataEngine.  User tables are not immediately loaded (see use of\n   * this.waitForInitialization throughout this class to wait for that).\n   * @returns {Promise} Promise for this ActiveDoc itself.\n   */\n  @ActiveDoc.keepDocOpen\n  public async loadDoc(docSession: OptDocSession, options?: {\n    forceNew?: boolean,          // If set, document will be created.\n    skipInitialTable?: boolean,  // If set, and document is new, \"Table1\" will not be added.\n    useExisting?: boolean,       // If set, document can be created as an overlay on\n    // an existing sqlite file.\n  }): Promise<ActiveDoc> {\n    const startTime = Date.now();\n    this._log.debug(docSession, \"loadDoc\");\n    try {\n      const isNew: boolean = options?.forceNew || await this._docManager.storageManager.prepareLocalDoc(this.docName);\n      if (isNew) {\n        await this._createDocFile(docSession, {\n          skipInitialTable: options?.skipInitialTable,\n          useExisting: options?.useExisting,\n        });\n      } else {\n        await this.docStorage.openFile({\n          beforeMigration: async (currentVersion, newVersion) => {\n            return this._beforeMigration(docSession, \"storage\", currentVersion, newVersion);\n          },\n          afterMigration: async (newVersion, success) => {\n            return this._afterMigration(docSession, \"storage\",  newVersion, success);\n          },\n        });\n      }\n      this._registerSQLiteDB();\n\n      await this._loadOpenDoc(docSession);\n      const metaTableData = await this._tableMetadataLoader.fetchTablesAsActions();\n      const docData = new DocData(tableId => this.fetchTable(makeExceptionalDocSession(\"system\"), tableId),\n        metaTableData);\n\n      await this._initDoc(docSession, docData);\n\n      this._initializationPromise = insightLogWrap(\n        \"ActiveDoc finishInitialization\",\n        () => this._finishInitialization(docSession, docData, startTime).catch(async (err) => {\n          await this.docClients.broadcastDocMessage(null, \"docError\", {\n            when: \"initialization\",\n            message: String(err),\n          });\n        }),\n      );\n    } catch (err) {\n      const level = err.status === 404 ? \"warn\" : \"error\";\n      this._log.log(level, docSession, \"Failed to load document\", err);\n      await this.shutdown();\n      throw err;\n    }\n    return this;\n  }\n\n  /**\n   * Replace this document with another, in-place so its id and other metadata does not change.\n   * This operation will leave the ActiveDoc it is called for unusable.  It will mute it,\n   * shut it down, and unlist it via the DocManager.  A fresh ActiveDoc can be acquired via the\n   * DocManager.\n   */\n  public async replace(docSession: OptDocSession, source: DocReplacementOptions) {\n    if (parseUrlId(this._docName).snapshotId) {\n      throw new ApiError(\"Snapshots cannot be replaced.\", 400);\n    }\n    if (!await this._granularAccess.isOwner(docSession)) {\n      throw new ApiError(\"Only owners can replace a document.\", 403);\n    }\n    this._log.debug(docSession, \"ActiveDoc.replace starting shutdown\");\n\n    // During replacement, it is important for all hands to be off the document. So we\n    // ask the shutdown method to do the replacement when the ActiveDoc is shutdown but\n    // before a new one could be opened.\n    return this.shutdown({\n      afterShutdown: () => this._docManager.storageManager.replace(this.docName, source),\n    });\n  }\n\n  /**\n   * Finish initializing ActiveDoc, by initializing ActionHistory, Sharing, and docData.\n   */\n  public async _initDoc(docSession: OptDocSession, docData: DocData): Promise<void> {\n    this.docData = docData;\n    this._onDemandActions = new OnDemandActions(this.docStorage, this.docData,\n      this._recoveryMode);\n\n    await this._actionHistory.initialize();\n    this._granularAccess = new GranularAccess(this.docData, this.docStorage, this.docClients, (query) => {\n      return this._fetchQueryFromDB(query, false);\n    }, this.recoveryMode, this.getHomeDbManager(), this.docName);\n    await this._granularAccess.update();\n    this._sharing = new Sharing(this, this._actionHistory, this._modificationLock);\n    // Make sure there is at least one item in action history. The document will be perfectly\n    // functional without it, but comparing documents would need updating if history can\n    // be empty. For example, comparing an empty document immediately forked with the\n    // original would fail. So far, we have treated documents without a common history\n    // as incomparible, and we'd need to weaken that to allow comparisons with a document\n    // with nothing in action history.\n    if (this._actionHistory.getNextLocalActionNum() === 1) {\n      await this._actionHistory.recordNextShared({\n        userActions: [],\n        undo: [],\n        info: [0, this._makeInfo(makeExceptionalDocSession(\"system\"))],\n        actionNum: 1,\n        actionHash: null,       // set by ActionHistory\n        parentActionHash: null,\n        stored: [],\n        calc: [],\n        envelopes: [],\n      });\n    }\n  }\n\n  public getHomeDbManager() {\n    return this._docManager.getHomeDbManager();\n  }\n\n  /**\n   * Adds a small table to start off a newly-created blank document.\n   */\n  public addInitialTable(docSession: OptDocSession) {\n    // Use a non-client-specific session, so that this action is not part of anyone's undo history.\n    const newDocSession = makeExceptionalDocSession(\"nascent\");\n    return this.applyUserActions(newDocSession, [[\"AddEmptyTable\", null]]);\n  }\n\n  /**\n   * Imports files, removes previously created temporary hidden tables and creates the new ones.\n   * Param `prevTableIds` is an array of hiddenTableIds as received from previous `importFiles`\n   * call, or empty if there was no previous call.\n   */\n  public importFiles(docSession: DocSession, dataSource: DataSourceTransformed,\n    parseOptions: ParseOptions, prevTableIds: string[]): Promise<ImportResult> {\n    return this._activeDocImport.importFiles(docSession, dataSource, parseOptions, prevTableIds);\n  }\n\n  /**\n   * Finishes import files, creates the new tables, and cleans up temporary hidden tables and\n   * uploads. Param `prevTableIds` is an array of hiddenTableIds as received from previous\n   * `importFiles` call, or empty if there was no previous call.\n   */\n  public finishImportFiles(docSession: DocSession, dataSource: DataSourceTransformed,\n    prevTableIds: string[], importOptions: ImportOptions): Promise<ImportResult> {\n    return this._activeDocImport.finishImportFiles(docSession, dataSource, prevTableIds, importOptions);\n  }\n\n  /**\n   * Cancels import files, cleans up temporary hidden tables and uploads.\n   * Param `prevTableIds` is an array of hiddenTableIds as received from previous `importFiles`\n   * call, or empty if there was no previous call.\n   */\n  public cancelImportFiles(docSession: DocSession, uploadId: number,\n    prevTableIds: string[]): Promise<void> {\n    return this._activeDocImport.cancelImportFiles(docSession, uploadId, prevTableIds);\n  }\n\n  /**\n   * Returns a diff of changes that will be applied to the destination table from `transformRule`\n   * if the data from `hiddenTableId` is imported with the specified `mergeOptions`.\n   *\n   * The diff is returned as a `DocStateComparison` of the same doc, with the `rightChanges`\n   * containing the updated cell values. Old values are pulled from the destination record (if\n   * a match was found), and new values are the result of merging in the new cell values with\n   * the merge strategy from `mergeOptions`.\n   *\n   * No distinction is currently made for added records vs. updated existing records; instead,\n   * we treat added records as an updated record in `hiddenTableId` where all the column\n   * values changed from blank to the original column values from `hiddenTableId`.\n   */\n  public generateImportDiff(_docSession: DocSession, hiddenTableId: string, transformRule: TransformRule,\n    mergeOptions: MergeOptions): Promise<DocStateComparison> {\n    return this._activeDocImport.generateImportDiff(hiddenTableId, transformRule, mergeOptions);\n  }\n\n  /**\n   * Apply a proposal to the document. The proposal is applied as a set of linked action groups\n   * for ease of undo, meaning each action group has a linkId refering to the previous one, and\n   * a otherId set to the first (or root) action group.\n   */\n  public async applyProposal(docSession: OptDocSession, proposalId: number, options?: {\n    dismiss?: boolean,\n  }): Promise<ApplyProposalResult> {\n    if (!await this.isOwner(docSession)) {\n      // For now, only owners can use this method.\n      throw new ApiError(\"Only owners can apply proposals\", 400);\n    }\n    const urlId = this.docName;\n    const proposal = await this._getHomeDbManagerOrFail().getProposal(urlId, proposalId);\n    if (!proposal) {\n      throw new ApiError(\"Proposal not found\", 404);\n    }\n    // Proposal diffs are computed and stored some time in the past.\n    const origDetails = proposal.comparison.comparison?.details;\n    if (!origDetails) {\n      // This shouldn't happen.\n      throw new ApiError(\"Proposal details not found\", 500);\n    }\n\n    // The current document may have advanced since then. We should\n    // recompute the changes since the branch point and now.\n    const states = await this.getRecentStates(docSession);\n    const hash = proposal.comparison.comparison?.parent?.h;\n\n    if (hash) {\n      const changes = await getChanges(docSession, this, {\n        states,\n        rightHash: states[0].h,\n        leftHash: hash,\n      });\n      const rightChanges = changes.details?.rightChanges;\n      if (rightChanges) {\n        rebaseSummary(rightChanges, origDetails.leftChanges);\n      }\n    }\n\n    let result: PatchLog = { changes: [], applied: false };\n    if (options?.dismiss === undefined) {\n      const patch = new Patch(this, docSession);\n      const { details } = removeMetadataChangesFromDetails(origDetails);\n      result = await patch.applyChanges(details);\n      if (result.applied) {\n        await this._getHomeDbManagerOrFail().updateProposalStatus(urlId, proposalId, {\n          status: \"applied\",\n        });\n      }\n    } else {\n      await this._getHomeDbManagerOrFail().updateProposalStatus(urlId, proposalId, {\n        status: options?.dismiss ? \"dismissed\" : undefined,\n      });\n    }\n    const proposalUpdated = await this._getHomeDbManagerOrFail().getProposal(urlId, proposalId);\n    return {\n      proposal: proposalUpdated,\n      log: result,\n    };\n  }\n\n  /**\n   * Close the current document.\n   */\n  public async closeDoc(docSession: DocSession): Promise<void> {\n    // Note that it's async only to satisfy the Rpc interface that expects a promise.\n    this.docClients.removeClient(docSession);\n\n    // If no more clients, schedule a shutdown.\n    if (this.docClients.clientCount() === 0) {\n      this._log.info(docSession, \"will self-close in %d ms\", this._inactivityTimer.getDelay());\n      this._inactivityTimer.enable();\n    }\n  }\n\n  /**\n   * Import the given upload as new tables in one step.\n   */\n  @ActiveDoc.keepDocOpen\n  public async oneStepImport(docSession: OptDocSession, uploadInfo: UploadInfo): Promise<void> {\n    await this._activeDocImport.oneStepImport(docSession, uploadInfo);\n  }\n\n  /**\n   * Import data resulting from parsing a file into a new table.\n   * In normal circumstances this is only used internally.\n   * It's exposed publicly for use by grist-static which doesn't use the plugin system.\n   */\n  public async importParsedFileAsNewTable(\n    docSession: OptDocSession, optionsAndData: ParseFileResult, importOptions: FileImportOptions,\n  ): Promise<ImportResult> {\n    return this._activeDocImport.importParsedFileAsNewTable(docSession, optionsAndData, importOptions);\n  }\n\n  /**\n   * This function saves attachments from a given upload and creates an entry for them in the\n   * database. It returns the list of rowIds for the rows created in the _grist_Attachments table.\n   */\n  public async addAttachments(docSession: OptDocSession, uploadId: number): Promise<number[]> {\n    const userId = docSession.userId;\n    const upload: UploadInfo = globalUploadSet.getUploadInfo(uploadId, this.makeAccessId(userId));\n    try {\n      // We'll assert that the upload won't cause limits to be exceeded, retrying once after\n      // soft-deleting any unused attachments.\n      await retryOnce(\n        () => this._assertUploadInfoSizeBelowLimit(upload),\n        async (e) => {\n          if (!(e instanceof LimitExceededError)) { throw e; }\n\n          // Check if any attachments are unused and can be soft-deleted to reduce the existing\n          // total size. We could do this from the beginning, but updateUsedAttachmentsIfNeeded\n          // is potentially expensive, so this optimises for the common case of not exceeding the limit.\n          const hadChanges = await this.updateUsedAttachmentsIfNeeded();\n          if (hadChanges) {\n            await this._updateAttachmentsSize({ syncUsageToDatabase: false });\n          } else {\n            // No point in retrying if nothing changed.\n            throw e;\n          }\n        },\n      );\n      const userActions: UserAction[] = [];\n      // Process uploads sequentially to reduce risk of race conditions.\n      // Minimal performance impact due to the main async operation being serialized SQL queries.\n      for (const file of upload.files) {\n        userActions.push(await this._prepAttachment(docSession, file));\n      }\n      const result = await this._applyUserActionsWithExtendedOptions(docSession, userActions, {\n        attachment: true,\n      });\n      this._updateAttachmentsSize().catch((e) => {\n        this._log.warn(docSession, \"failed to update attachments size\", e);\n      });\n      await this._granularAccess.noteUploads(docSession, result.retValues);\n      return result.retValues;\n    } finally {\n      await globalUploadSet.cleanup(uploadId);\n    }\n  }\n\n  /**\n   * Returns the record from _grist_Attachments table for the given attachment ID,\n   * or throws an error if not found.\n   */\n  public getAttachmentMetadataWithoutAccessControl(attId: number): MetaRowRecord<\"_grist_Attachments\"> {\n    // docData should always be available after loadDoc() or createDoc().\n    if (!this.docData) {\n      throw new Error(\"No doc data\");\n    }\n    const attRecord = this.docData.getMetaTable(\"_grist_Attachments\").getRecord(attId);\n    if (!attRecord) {\n      throw new ApiError(`Attachment not found: ${attId}`, 404);\n    }\n    return attRecord;\n  }\n\n  public async getAttachmentMetadata(docSession: OptDocSession, attId: number, options?: {\n    cell?: SingleCell,\n    maybeNew?: boolean,\n  }): Promise<MetaRowRecord<\"_grist_Attachments\">> {\n    await this._assertCanGetAttachment(docSession, attId, options);\n    return this.getAttachmentMetadataWithoutAccessControl(attId);\n  }\n\n  /**\n   * Given a _gristAttachments record, returns a promise for the attachment data.\n   * Can optionally take a cell in which the attachment is expected to be\n   * referenced, and/or a `maybeNew` flag which, when set, specifies that the\n   * attachment may be a recent upload that is not yet referenced in the document.\n   * @returns {Promise<Buffer>} Promise for the data of this attachment; rejected on error.\n   */\n  public async getAttachmentData(docSession: OptDocSession, attRecord: MetaRowRecord<\"_grist_Attachments\">,\n    options?: {\n      cell?: SingleCell,\n      maybeNew?: boolean,\n    }): Promise<Buffer> {\n    const attId = attRecord.id;\n    const fileIdent = attRecord.fileIdent;\n    await this._assertCanGetAttachment(docSession, attId, options);\n    const data = await this._attachmentFileManager.getFileData(fileIdent);\n    if (!data) { throw new ApiError(\"Invalid attachment identifier\", 404); }\n    this._log.info(docSession, \"getAttachment: %s -> %s bytes\", fileIdent, data.length);\n    return data;\n  }\n\n  public async getAttachmentsArchive(docSession: OptDocSession,\n    format: CreatableArchiveFormats = \"zip\"): Promise<Archive> {\n    if (\n      !await this._granularAccess.canReadEverything(docSession) &&\n      !await this.canDownload(docSession)\n    ) {\n      throw new ApiError(\"Insufficient access to download attachments\", 403);\n    }\n    if (!this.docData) {\n      throw new Error(\"No doc data\");\n    }\n    const attachments = this.docData.getMetaTable(\"_grist_Attachments\").getRecords();\n    const attachmentFileManager = this._attachmentFileManager;\n    const doc = this;\n\n    async function* fileGenerator(): AsyncGenerator<ArchiveEntry> {\n      const filesAdded = new Set<string>();\n      for (const attachment of attachments) {\n        if (doc._shuttingDown) {\n          throw new ApiError(\"Document is shutting down, archiving aborted\", 500);\n        }\n        const file = await attachmentFileManager.getFile(attachment.fileIdent);\n        const name = attachmentToArchiveFilePath(attachment);\n        // This should only happen if a file has identical name *and* identifier.\n        if (filesAdded.has(name)) {\n          continue;\n        }\n        filesAdded.add(name);\n        yield ({\n          name,\n          size: file.metadata.size,\n          data: file.contentStream,\n        });\n      }\n    }\n\n    if (format == \"tar\") {\n      return create_tar_archive(fileGenerator());\n    }\n    if (format == \"zip\") {\n      return create_zip_archive({ store: true }, fileGenerator());\n    }\n    // Generally this won't happen, as long as the above is exhaustive over the type of `format`\n    throw new ApiError(`Unsupported archive format ${format}`, 400);\n  }\n\n  @ActiveDoc.keepDocOpen\n  public async addMissingFilesFromArchive(docSession: OptDocSession,\n    tarFile: stream.Readable): Promise<ArchiveUploadResult> {\n    if (!await this.isOwner(docSession)) {\n      throw new ApiError(\"Insufficient access to upload an attachment archive\", 403);\n    }\n\n    const fallbackStoreId = this._getDocumentSettings().attachmentStoreId;\n    const results: ArchiveUploadResult = {\n      added: 0,\n      errored: 0,\n      unused: 0,\n    };\n\n    const sizesToUpdate = new Map<string, number>();\n\n    await unpackTarArchive(tarFile, async (file) => {\n      try {\n        const fileIdent = archiveFilePathToAttachmentIdent(file.path);\n        const isAdded = await this._attachmentFileManager.addMissingFileData(\n          fileIdent,\n          file.data,\n          fallbackStoreId,\n        );\n        if (isAdded) {\n          sizesToUpdate.set(fileIdent, file.size);\n          results.added += 1;\n        } else {\n          results.unused += 1;\n        }\n      } catch (err) {\n        results.errored += 1;\n        if (err instanceof MismatchedFileHashError) {\n          this._log.warn(docSession, `Failed to upload attachment: ${err.message}`);\n        }\n        this._log.error(docSession, `Failed to upload attachment: ${err}`);\n      }\n    });\n\n    // This updates _grist_Attachments.fileSize with the size of the uploaded files.\n    // This prevents a loophole where a user has altered `fileSize`, imported the altered document,\n    // then restored the originals.\n    await this._updateAttachmentFileSizesUsingFileIdent(docSession, sizesToUpdate);\n\n    return results;\n  }\n\n  @ActiveDoc.keepDocOpen\n  public async startTransferringAllAttachmentsToDefaultStore() {\n    const attachmentStoreId = this._getDocumentSettings().attachmentStoreId;\n    // If no attachment store is set on the doc, it should transfer everything to internal storage\n    await this._attachmentFileManager.startTransferringAllFilesToOtherStore(attachmentStoreId);\n  }\n\n  /**\n   * Returns a summary of pending attachment transfers between attachment stores.\n   */\n  public async attachmentTransferStatus() {\n    return {\n      status: this._attachmentFileManager.transferStatus(),\n      locationSummary: await this._attachmentFileManager.locationSummary(),\n    };\n  }\n\n  /**\n   * Returns a summary of where attachments on this doc are stored.\n   */\n  public async attachmentLocationSummary() {\n    return await this._attachmentFileManager.locationSummary();\n  }\n\n  /*\n   * Wait for all attachment transfers to be finished, keeping the doc open\n   * for as long as possible.\n   */\n  @ActiveDoc.keepDocOpen\n  public async allAttachmentTransfersCompleted() {\n    await this._attachmentFileManager.allTransfersCompleted();\n  }\n\n  public async setAttachmentStore(docSession: OptDocSession, id: string | undefined): Promise<void> {\n    const docSettings = this._getDocumentSettings();\n    docSettings.attachmentStoreId = id;\n    await this._updateDocumentSettings(docSession, docSettings);\n    await this.sendAttachmentTransferStatusNotification(await this.attachmentTransferStatus());\n  }\n\n  /**\n   * Sets the document attachment store using the store's label.\n   * This avoids needing to know the exact store ID, which can be challenging to calculate in all\n   * the places we might want to set the store.\n   */\n  public async setAttachmentStoreFromLabel(docSession: OptDocSession, label: string | undefined): Promise<void> {\n    const id = label === undefined ? undefined : this._attachmentStoreProvider?.getStoreIdFromLabel(label);\n    await this.setAttachmentStore(docSession, id);\n  }\n\n  public async getAttachmentStore(): Promise<string | undefined> {\n    return this._getDocumentSettings().attachmentStoreId;\n  }\n\n  /**\n   * Fetches the meta tables to return to the client when first opening a document.\n   */\n  public async fetchMetaTables(docSession: OptDocSession) {\n    this._log.info(docSession, \"fetchMetaTables\");\n    if (!this.docData) { throw new Error(\"No doc data\"); }\n    // Get metadata from local cache rather than data engine, so that we can\n    // still get it even if data engine is busy calculating.\n    const tables: { [key: string]: TableDataAction } = {};\n    for (const [tableId, tableData] of this.docData.getTables().entries()) {\n      if (!tableId.startsWith(\"_grist_\")) { continue; }\n      tables[tableId] = tableData.getTableDataAction();\n    }\n    return this._granularAccess.filterMetaTables(docSession, tables);\n  }\n\n  /**\n   * Makes sure document is completely initialized.  May throw if doc is broken.\n   */\n  public async waitForInitialization() {\n    await this._initializationPromise;\n    insightLogEntry()?.mark(\"waitForInit\");\n  }\n\n  // Check if user has rights to download this doc.\n  public async canDownload(docSession: OptDocSession) {\n    return this._granularAccess.canCopyEverything(docSession);\n  }\n\n  // Check if user has rights to read everything in this doc.\n  public async canCopyEverything(docSession: OptDocSession) {\n    return this._granularAccess.canCopyEverything(docSession);\n  }\n\n  // Check if it is appropriate for the user to be treated as an owner of\n  // the document for granular access purposes when in \"prefork\" mode\n  // (meaning a document has been opened with the intent to fork it, but\n  // an initial modification has not yet been made).\n  // Currently, we decide it is appropriate if the user has access to all\n  // the data in the document, either directly or via the special\n  // \"FullCopies\" permission.\n  public async canForkAsOwner(docSession: OptDocSession) {\n    return this._granularAccess.canCopyEverything(docSession);\n  }\n\n  // Remove cached access information for a given session.\n  public flushAccess(docSession: OptDocSession) {\n    return this._granularAccess.flushAccess(docSession);\n  }\n\n  /**\n   * Fetches a particular table from the data engine to return to the client.\n   * @param {String} tableId: The string identifier of the table.\n   * @param {Boolean} waitForFormulas: If true, wait for all data to be loaded/calculated.\n   * @returns {Promise} Promise for the TableData object, which is a BulkAddRecord-like array of the\n   *      form of the form [\"TableData\", table_id, row_ids, column_values].\n   */\n  public async fetchTable(docSession: OptDocSession, tableId: string,\n    waitForFormulas: boolean = false): Promise<TableFetchResult> {\n    return this.fetchQuery(docSession, { tableId, filters: {} }, waitForFormulas);\n  }\n\n  /**\n   * Fetches data according to the given query, which includes tableId and filters (see Query in\n   * app/common/ActiveDocAPI.ts). The data is fetched from the data engine for regular tables, or\n   * from the DocStorage directly for onDemand tables.\n   * @param {Boolean} waitForFormulas: If true, wait for all data to be loaded/calculated.  If\n   *   false, special \"pending\" values may be returned.\n   */\n  public async fetchQuery(docSession: OptDocSession, query: ServerQuery,\n    waitForFormulas: boolean = false): Promise<TableFetchResult> {\n    // Sanitize the query to ensure it only has parts we are OK accepting from the user.\n    // (In particular, it is not safe to accept an untrusted \"where\" part.)\n    query = pick(query, [\"tableId\", \"filters\", \"limit\"]);\n\n    this._inactivityTimer.ping();     // The doc is in active use; ping it to stay open longer.\n\n    // If user does not have rights to access what this query is asking for, fail.\n    const tableAccess = await this._granularAccess.getTableAccess(docSession, query.tableId);\n\n    this._granularAccess.assertCanRead(tableAccess);\n\n    if (query.tableId.startsWith(\"_gristsys_\")) {\n      throw new Error(\"Cannot fetch _gristsys tables\");\n    }\n\n    if (query.tableId.startsWith(\"_grist_\") && !await this._granularAccess.canReadEverything(docSession)) {\n      // Metadata tables may need filtering, and this can't be done by looking at a single\n      // table.  So we pick out the table we want from fetchMetaTables (which has applied\n      // filtering).\n      const tables = await this.fetchMetaTables(docSession);\n      const tableData = tables[query.tableId];\n      if (tableData) { return { tableData }; }\n      // If table not found, continue, to give a consistent error for a table not found.\n    }\n\n    // Some tests read _grist_ tables via the api.  The _fetchQueryFromDB method\n    // currently cannot read those tables, so we load them from the data engine\n    // when ready.\n    // Also, if row-level access is being controlled, we wait for formula columns\n    // to be populated.\n    const wantFull = waitForFormulas || query.tableId.startsWith(\"_grist_\") ||\n      this._granularAccess.getReadPermission(tableAccess) === \"mixed\";\n    const onDemand = this._onDemandActions.isOnDemand(query.tableId);\n    this._log.info(docSession, \"fetchQuery %s %s\", JSON.stringify(query),\n      onDemand ? \"(onDemand)\" : \"(regular)\");\n    let data: TableDataAction;\n    if (onDemand || this._isSnapshot) {\n      data = await this._fetchQueryFromDB(query, onDemand);\n    } else if (wantFull) {\n      await this.waitForInitialization();\n      data = await this._fetchQueryFromDataEngine(query);\n    } else {\n      if (!this._fullyLoaded) {\n        data = await this._fetchQueryFromDB(query, false);\n      }\n      if (this._fullyLoaded) {  // Already loaded or finished loading while fetching from DB\n        const key = JSON.stringify(query);\n        // TODO: cache longer if the underlying fetch takes longer to do.\n        data = await mapGetOrSet(this._fetchCache, key, () => this._fetchQueryFromDataEngine(query));\n      }\n    }\n    // If row-level access is being controlled, filter the data appropriately.\n    // Likewise if column-level access is being controlled.\n    if (this._granularAccess.getReadPermission(tableAccess) !== \"allow\") {\n      data = cloneDeep(data!);  // Clone since underlying fetch may be cached and shared.\n      await this._granularAccess.filterData(docSession, data);\n    }\n\n    // Consider whether we need to add attachment metadata.\n    // TODO: it might be desirable to always send attachment data, or allow\n    // this to be an option in api calls related to fetching.\n    let attachments: BulkAddRecord | undefined;\n    const attachmentColumns = this._getCachedAttachmentColumns();\n    if (attachmentColumns?.size && await this._granularAccess.needAttachmentControl(docSession)) {\n      const attIds = gatherAttachmentIds(attachmentColumns, data!);\n      if (attIds.size > 0) {\n        attachments = this.docData!.getMetaTable(\"_grist_Attachments\")\n          .getBulkAddRecord([...attIds]);\n      }\n    }\n\n    this._log.info(docSession, \"fetchQuery -> %d rows, cols: %s\",\n      data![2].length, Object.keys(data![3]).join(\", \"));\n    return { tableData: data!, ...(attachments && { attachments }) };\n  }\n\n  /**\n   * Fetches the generated Python code.\n   * @returns {Promise} Promise for a string representing the generated Python code.\n   */\n  public async fetchPythonCode(docSession: OptDocSession): Promise<string> {\n    this._log.info(docSession, \"fetchPythonCode(%s)\", docSession);\n    // Permit code view if user can read everything, or can download/copy (perhaps\n    // via an exceptional permission for sample documents)\n    if (!(await this._granularAccess.canReadEverything(docSession) ||\n      await this.canDownload(docSession))) {\n      throw new ApiError(\"Cannot view code, it may contain private material\", 403);\n    }\n    await this.waitForInitialization();\n    return this._pyCall(\"fetch_table_schema\");\n  }\n\n  /**\n   * Makes a query (documented elsewhere) and subscribes to it, so that the client receives\n   * docActions that affect this query's results.\n   */\n  public async useQuerySet(docSession: OptDocSession, query: ServerQuery): Promise<QueryResult> {\n    this._log.info(docSession, \"useQuerySet(%s, %s)\", docSession, query);\n    // TODO implement subscribing to the query.\n    // - Convert tableId+colIds to TableData/ColData references\n    // - Return a unique identifier for unsubscribing\n    // - Each call can create its own object, return own identifier.\n    // - Subscription should not be affected by renames (so don't hold on to query/tableId/colIds)\n    // - Table/column deletion should make subscription inactive, and unsubscribing an inactive\n    //   subscription should not produce an error.\n    const tableFetchResult = await this.fetchQuery(docSession, query);\n    return { querySubId: 0, ...tableFetchResult };\n  }\n\n  /**\n   * Removes all subscriptions to the given query from this client, so that it stops receiving\n   * docActions relevant only to this query.\n   */\n  public async disposeQuerySet(docSession: DocSession, querySubId: number): Promise<void> {\n    this._log.info(docSession, \"disposeQuerySet(%s, %s)\", docSession, querySubId);\n    // TODO To-be-implemented\n  }\n\n  /**\n   * Returns the most likely target column in the document for the given column.\n   * @param {Array} values: An array of values to search for in columns in the document.\n   * @param {Number} n: Number of results to return.\n   * @param {String} optTableId: If a valid tableId, search only that table.\n   * @returns {Promise} Promise for an array of colRefs describing matching columns ordered from\n   *  best to worst. Match quality is determined by searching only a sample of column data.\n   *  See engine.py find_col_from_values for implementation.\n   */\n  public async findColFromValues(docSession: DocSession, values: any[], n: number,\n    optTableId?: string): Promise<number[]> {\n    // This could leak information about private tables, so check for permission.\n    if (!await this._granularAccess.canScanData(docSession)) { return []; }\n    this._log.info(docSession, \"findColFromValues(%s, %s, %s)\", docSession, values, n);\n    await this.waitForInitialization();\n    return this._pyCall(\"find_col_from_values\", values, n, optTableId);\n  }\n\n  /**\n   * Returns table metadata for all tables in the document.\n   *\n   * @returns {Promise<TableMetadata[]>} Records containing metadata for all tables.\n   */\n  public async getTables(\n    docSession: OptDocSession,\n    expand: ExpandTableOption[] = []): Promise<TableMetadata[]> {\n    const metaTables = await this.fetchMetaTables(docSession);\n    const [, , tableRefs, tableData] = metaTables._grist_Tables;\n\n    const includeColumns = expand.includes(\"column\");\n\n    // tableId is pulled out of fields and used as the root id\n    const fieldNames = without(Object.keys(tableData), \"tableId\");\n\n    const tables: TableMetadata[] = [];\n    (tableData.tableId as string[]).forEach((id, index) => {\n      if (!id) {\n        return;\n      }\n      const tableRef = tableRefs[index];\n      const table: TableMetadata = { id, fields: { tableRef } };\n      for (const key of fieldNames) {\n        table.fields[key] = tableData[key][index];\n      }\n      if (includeColumns) {\n        table.columns = this._colMetadataRecords(metaTables, tableRef);\n      }\n      tables.push(table);\n    });\n    return tables;\n  }\n\n  /**\n   * Returns column metadata for all visible columns from `tableId`.\n   *\n   * @param {string} tableId Table to retrieve column metadata for.\n   * @returns {Promise<ColumnMetadata[]>} Records containing metadata about the visible columns\n   * from `tableId`.\n   */\n  public async getTableCols(\n    docSession: OptDocSession,\n    tableId: string,\n    includeHidden = false): Promise<ColumnMetadata[]> {\n    const metaTables = await this.fetchMetaTables(docSession);\n    if (tableId.startsWith(\"_grist_\")) {\n      throw new Error(\"getTableCols not available for meta tables\");\n    }\n    const tableRef = tableIdToRef(metaTables, tableId);\n    return this._colMetadataRecords(metaTables, tableRef, includeHidden);\n  }\n\n  /**\n   * Returns error message (traceback) for one invalid formula cell.\n   * @param {String} tableId - Table name\n   * @param {String} colId - Column name\n   * @param {Integer} rowId - Row number\n   * @returns {Promise} Promise for a error message\n   */\n  public async getFormulaError(docSession: OptDocSession, tableId: string, colId: string,\n    rowId: number): Promise<CellValue> {\n    // Throw an error if the user doesn't have access to read this cell.\n    await this._granularAccess.getCellValue(docSession, { tableId, colId, rowId });\n\n    this._log.info(docSession, \"getFormulaError(%s, %s, %s, %s)\",\n      docSession, tableId, colId, rowId);\n    await this.waitForInitialization();\n    const onDemand = this._onDemandActions.isOnDemand(tableId);\n    if (onDemand) {\n      // It's safe to use this.docData after waitForInitialization().\n      return getFormulaErrorForExpandQuery(this.docData!, tableId, colId);\n    }\n    return this._pyCall(\"get_formula_error\", tableId, colId, rowId);\n  }\n\n  /**\n   * Applies an array of user actions received from a browser client.\n   *\n   * @param {Object} docSession: The client session originating this action.\n   * @param {Array} action: The user action to apply, e.g. [\"UpdateRecord\", tableId, rowId, etc].\n   * @param {Object} options: See _applyUserActions for documentation\n   * @returns {Promise:Array[Object]} Promise that's resolved when action is applied successfully.\n   *                                          The array includes the retValue objects for each\n   *                                          actionGroup.\n   */\n  @insightLogDecorate(\"ActiveDoc\")\n  public async applyUserActions(docSession: OptDocSession, actions: UserAction[],\n    unsanitizedOptions?: ApplyUAOptions): Promise<ApplyUAResult> {\n    const options = sanitizeApplyUAOptions(unsanitizedOptions);\n    return this._applyUserActionsWithExtendedOptions(docSession, actions, options);\n  }\n\n  /**\n   * A variant of applyUserActions where actions are passed in by ids (actionNum, actionHash)\n   * rather than by value.\n   *\n   * @param docSession: The client session originating this action.\n   * @param actionNums: The user actions to do/undo, by actionNum.\n   * @param actionHashes: actionHash checksums for each listed actionNum.\n   * @param undo: Whether the actions are to be undone.\n   * @param options: As for applyUserActions.\n   * @returns Promise of retValues, see applyUserActions.\n   */\n  @insightLogDecorate(\"ActiveDoc\")\n  public async applyUserActionsById(docSession: OptDocSession,\n    actionNums: number[],\n    actionHashes: string[],\n    undo: boolean,\n    unsanitizedOptions?: ApplyUAOptions): Promise<ApplyUAResult> {\n    const options = sanitizeApplyUAOptions(unsanitizedOptions);\n    const actionBundles = await this._actionHistory.getActions(actionNums);\n    let fromOwnHistory: boolean = true;\n    let oldestSource: number = Date.now();\n    for (const [index, bundle] of actionBundles.entries()) {\n      const actionNum = actionNums[index];\n      const actionHash = actionHashes[index];\n      if (!bundle) { throw new Error(`Could not find actionNum ${actionNum}`); }\n      const info = bundle.info[1];\n      const bundleEmail = info.user || \"\";\n      const sessionEmail = docSession.normalizedEmail || \"\";\n      if (sessionEmail !== normalizeEmail(bundleEmail)) {\n        fromOwnHistory = false;\n      }\n      if (info.time && info.time < oldestSource) {\n        oldestSource = info.time;\n      }\n      if (actionHash !== bundle.actionHash) {\n        throw new Error(`Hash mismatch for actionNum ${actionNum}: ` +\n          `expected ${actionHash} but got ${bundle.actionHash}`);\n      }\n    }\n    let actions: UserAction[];\n    if (undo) {\n      actions = [[\"ApplyUndoActions\", flatten(actionBundles.map(a => a!.undo))]];\n    } else {\n      actions = flatten(actionBundles.map(a => a!.userActions));\n    }\n    // Granular access control implemented ultimately in _applyUserActions.\n    // It could be that error cases and timing etc leak some info prior to this\n    // point.\n    // Undos are best effort now by default.\n    return this._applyUserActionsWithExtendedOptions(\n      docSession, actions, { bestEffort: undo,\n        oldestSource,\n        fromOwnHistory, ...(options || {}) });\n  }\n\n  /**\n   * Called by Sharing class for every LocalActionBundle (of our own actions) that gets applied.\n   */\n  public async processActionBundle(localActionBundle: LocalActionBundle): Promise<void> {\n    const docData = this.docData;\n    if (!docData) { return; }  // Happens on doc creation while processing InitNewDoc action.\n    localActionBundle.stored.forEach(da => docData.receiveAction(da[1]));\n    localActionBundle.calc.forEach(da => docData.receiveAction(da[1]));\n    const docActions = getEnvContent(localActionBundle.stored);\n    if (docActions.some(docAction => this._onDemandActions.isSchemaAction(docAction))) {\n      const indexes = this._onDemandActions.getDesiredIndexes();\n      await this.docStorage.updateIndexes(indexes);\n      // TODO: should probably add indexes for user attribute tables.\n    }\n    if (docActions.some(docAction => isSchemaAction(docAction))) {\n      this._attachmentColumns = undefined;\n    }\n  }\n\n  /**\n   * Used by tests to force an update indexes.  We don't otherwise update indexes until\n   * there is a schema change.\n   */\n  public async testUpdateIndexes() {\n    const indexes = this._onDemandActions.getDesiredIndexes();\n    await this.docStorage.updateIndexes(indexes);\n  }\n\n  public async renameDocTo(docSession: OptDocSession, newName: string): Promise<void> {\n    this._log.debug(docSession, \"renameDoc\", newName);\n    await this.docStorage.renameDocTo(newName);\n    this._docName = newName;\n  }\n\n  /**\n   *  Initiates user actions bandling for undo.\n   */\n  public startBundleUserActions(docSession: OptDocSession) {\n    if (!docSession.shouldBundleActions) {\n      docSession.shouldBundleActions = true;\n      docSession.linkId = 0;\n    }\n  }\n\n  /**\n   *  Stops user actions bandling for undo.\n   */\n  public stopBundleUserActions(docSession: OptDocSession) {\n    docSession.shouldBundleActions = false;\n    docSession.linkId = 0;\n  }\n\n  public async autocomplete(\n    docSession: DocSession,\n    txt: string,\n    tableId: string,\n    columnId: string,\n    rowId: UIRowId | null,\n  ): Promise<ISuggestionWithValue[]> {\n    // Autocompletion can leak names of tables and columns.\n    if (!await this._granularAccess.canScanData(docSession)) { return []; }\n    await this.waitForInitialization();\n    const user = await this._granularAccess.getCachedUser(docSession);\n    return this._pyCall(\"autocomplete\", txt, tableId, columnId, rowId, user.toJSON());\n  }\n\n  /**\n   * Callback to generate a prompt containing schema info for assistance.\n   */\n  public async assistanceSchemaPromptV1(\n    docSession: OptDocSession,\n    context: AssistanceSchemaPromptV1Context,\n  ): Promise<string> {\n    // Making a prompt leaks names of tables and columns etc.\n    if (!await this._granularAccess.canScanData(docSession)) {\n      throw new Error(\"Permission denied\");\n    }\n\n    return await this._pyCall(\n      \"get_formula_prompt\",\n      context.tableId,\n      context.colId,\n      context.includeAllTables ?? true,\n      context.includeLookups ?? true,\n    );\n  }\n\n  /**\n   * Callback to make a data-engine formula tweak for assistance.\n   *\n   * Only used by version 1 of the AI assistant.\n   */\n  public assistanceFormulaTweak(txt: string): Promise<string> {\n    return this._pyCall(\"convert_formula_completion\", txt);\n  }\n\n  /**\n   * Callback to compute an existing formula and return the result along with recorded values\n   * of (possibly nested) attributes of `rec`.\n   * Used by AI assistance to fix an incorrect formula.\n   *\n   * Only used by version 1 of the AI assistant.\n   */\n  public assistanceEvaluateFormula(\n    options: AssistanceContextV1,\n  ): Promise<AssistanceFormulaEvaluationResult> {\n    if (!options.evaluateCurrentFormula) {\n      throw new Error(\"evaluateCurrentFormula must be true\");\n    }\n    return this._pyCall(\"evaluate_formula\", options.tableId, options.colId, options.rowId);\n  }\n\n  public fetchURL(docSession: DocSession, url: string, options?: FetchUrlOptions): Promise<UploadResult> {\n    if (this._isUntrustedRequestBehaviorSet === undefined) {\n      this._isUntrustedRequestBehaviorSet = isUntrustedRequestBehaviorSet();\n    }\n    if (!this._isUntrustedRequestBehaviorSet) {\n      throw new Error(\"Cannot use fetchURL without explicit proxy configuration\");\n    }\n    return fetchURL(url, this.makeAccessId(docSession.userId), options);\n  }\n\n  public async forwardPluginRpc(docSession: DocSession, pluginId: string, msg: IMessage): Promise<any> {\n    if (await this._granularAccess.hasNuancedAccess(docSession)) {\n      throw new Error(\"cannot confirm access to plugin\");\n    }\n    if (!this.docPluginManager) { throw new Error(\"no plugin manager available\"); }\n    const pluginRpc = this.docPluginManager.plugins[pluginId].rpc;\n    switch (msg.mtype) {\n      case MsgType.RpcCall: return pluginRpc.forwardCall(msg);\n      case MsgType.Custom: return pluginRpc.forwardMessage(msg);\n    }\n    throw new Error(`Invalid message type for forwardPluginRpc: ${msg.mtype}`);\n  }\n\n  /**\n   * Reload documents plugins.\n   */\n  public async reloadPlugins(docSession: DocSession) {\n    // refresh the list plugins found on the system\n    if (!this._docManager.pluginManager || !this.docPluginManager) { return; }\n    await this._docManager.pluginManager.reloadPlugins();\n    const plugins = this._docManager.pluginManager.getPlugins();\n    // reload found plugins\n    await this.docPluginManager.reload(plugins);\n  }\n\n  /**\n   * Immediately close the document and data engine, to be reloaded from scratch, and cause all\n   * browser clients to reopen it.\n   */\n  public async reloadDoc(docSession?: DocSession) {\n    this._log.debug(docSession || null, \"ActiveDoc.reloadDoc starting shutdown\");\n    this._docManager.restoreTimingOn(this.docName, this.isTimingOn);\n    return this.shutdown();\n  }\n\n  public isOwner(docSession: OptDocSession): Promise<boolean> {\n    return this._granularAccess.isOwner(docSession);\n  }\n\n  /**\n   * Fork the current document.\n   *\n   * TODO: reconcile the two ways there are now of preparing a fork.\n   */\n  public async fork(docSession: OptDocSession): Promise<ForkResult> {\n    const dbManager = this._getHomeDbManagerOrFail();\n    const user = docSession.fullUser;\n    // For now, fork only if user can read everything (or is owner).\n    // TODO: allow forks with partial content.\n    if (!user || !await this.canDownload(docSession)) {\n      throw new ApiError(\"Insufficient access to document to copy it entirely\", 403);\n    }\n    const userId = user.id;\n    const isAnonymous = this._docManager.isAnonymous(userId);\n\n    // Get fresh document metadata (the cached metadata doesn't include the urlId).\n    const reqOrScope = docSession.authorizer?.getAuthKey() || docSession.req;\n    if (!reqOrScope) { throw new Error(\"Document not found\"); }\n    const doc = await dbManager.getDoc(reqOrScope);\n\n    // Don't allow creating forks of forks (for now).\n    if (doc.trunkId) { throw new ApiError(\"Cannot fork a document that's already a fork\", 400); }\n\n    const trunkDocId = doc.id;\n    const trunkUrlId = doc.urlId || doc.id;\n    await this.flushDoc();  // Make sure fork won't be too out of date.\n    const forkIds = makeForkIds({ userId, isAnonymous, trunkDocId, trunkUrlId });\n\n    // To actually create the fork, we call an endpoint.  This is so the fork\n    // can be associated with an arbitrary doc worker, rather than tied to the\n    // same worker as the trunk.  We use a Permit for authorization.\n    const permitStore = this._server.getPermitStore();\n    const permitKey = await permitStore.setPermit({ docId: forkIds.docId,\n      otherDocId: this.docName });\n    try {\n      const url = await this._server.getHomeUrlByDocId(\n        forkIds.docId,\n        `/api/docs/${forkIds.docId}/create-fork`,\n      );\n      const resp = await fetch(url, {\n        method: \"POST\",\n        body: JSON.stringify({ srcDocId: this.docName }),\n        headers: {\n          \"Permit\": permitKey,\n          \"Content-Type\": \"application/json\",\n        },\n      });\n      if (resp.status !== 200) {\n        throw new ApiError(resp.statusText, resp.status);\n      }\n\n      await dbManager.forkDoc(userId, doc, forkIds.forkId);\n      this._logForkDocumentEvents(docSession, { document: doc, fork: forkIds });\n    } finally {\n      await permitStore.removePermit(permitKey);\n    }\n\n    return forkIds;\n  }\n\n  public async getAccessToken(docSession: OptDocSession, options: AccessTokenOptions): Promise<AccessTokenResult> {\n    const tokens = this._server.getAccessTokens();\n    const userId = docSession.userId;\n    const docId = this.docName;\n    const access = getDocSessionAccess(docSession);\n    // If we happen to be using a \"readOnly\" connection, max out at \"readOnly\"\n    // even if user could do more.\n    if (roles.getStrongestRole(\"viewers\", access) === \"viewers\") {\n      options.readOnly = true;\n    }\n    // Return a token that can be used to authorize as the given user.\n    if (!userId) { throw new Error(\"creating access token requires a user\"); }\n    const token = await tokens.sign({\n      readOnly: options.readOnly,\n      userId,  // definitely do not want userId overridable by options.\n      docId,   // likewise for docId.\n    });\n    const ttlMsecs = tokens.getNominalTTLInMsec();\n    const baseUrl = this._options?.docApiUrl;\n    if (!baseUrl) { throw new Error(\"cannot create token without URLs\"); }\n    return {\n      token,\n      baseUrl,\n      ttlMsecs,\n    };\n  }\n\n  /**\n   * Check if an ACL formula is valid. If not, will throw an error with an explanation.\n   */\n  public async checkAclFormula(docSession: DocSession, text: string): Promise<PredicateFormulaProperties> {\n    // Checks can leak names of tables and columns.\n    if (await this._granularAccess.hasNuancedAccess(docSession)) { return {}; }\n    await this.waitForInitialization();\n    try {\n      const parsedAclFormula = await this._pyCall(\"parse_predicate_formula\", text);\n      compilePredicateFormula(parsedAclFormula);\n      // Note that the validity of attributes, and of tables and columns mentioned in resources\n      // and userAttribute rules are checked at a different point, in findRuleProblems() called\n      // from getAclResources().\n      return getPredicateFormulaProperties(parsedAclFormula);\n    } catch (e) {\n      e.message = e.message?.replace(\"[Sandbox] \", \"\");\n      throw e;\n    }\n  }\n\n  /**\n   * Returns the full set of tableIds, with basic metadata for each table. This is intended\n   * for editing ACLs. It is only available to users who can edit ACLs, and lists all resources\n   * regardless of rules that may block access to them.\n   *\n   * Also returns information about resources mentioned in rules that no longer\n   * exist.\n   */\n  public async getAclResources(docSession: DocSession): Promise<AclResources> {\n    if (!this.docData || !await this._granularAccess.hasAccessRulesPermission(docSession)) {\n      throw new Error(\"Cannot list ACL resources\");\n    }\n    const result: { [tableId: string]: AclTableDescription } = {};\n    const tables = this.docData.getMetaTable(\"_grist_Tables\");\n    const sections = this.docData.getMetaTable(\"_grist_Views_section\");\n    const columns = this.docData.getMetaTable(\"_grist_Tables_column\");\n    for (const table of tables.getRecords()) {\n      const sourceTable = table.summarySourceTable ? tables.getRecord(table.summarySourceTable)! : table;\n      const rawSection = sections.getRecord(sourceTable.rawViewSectionRef)!;\n      result[table.tableId] = {\n        title: rawSection.title || sourceTable.tableId,\n        colIds: [\"id\"],\n        groupByColLabels: table.summarySourceTable ? [] : null,\n      };\n    }\n    for (const col of columns.getRecords()) {\n      const tableId = tables.getValue(col.parentId, \"tableId\")!;\n      result[tableId].colIds.push(col.colId);\n      if (col.summarySourceCol) {\n        const sourceCol = columns.getRecord(col.summarySourceCol)!;\n        result[tableId].groupByColLabels!.push(sourceCol.label);\n      }\n    }\n    const ruleCollection = new ACLRuleCollection();\n    await ruleCollection.update(this.docData, { log });\n    const problems = ruleCollection.findRuleProblems(this.docData);\n    return { tables: result, problems };\n  }\n\n  /**\n   * Get users that are worth proposing to \"View As\" for access control purposes.\n   * User are drawn from the following sources:\n   *   - Users document is shared with.\n   *   - Users mentioned in user attribute tables keyed by email address.\n   *   - Some predefined example users.\n   *\n   * The users the document is shared with are only available if the\n   * user is an owner of the document (or, in a fork, an owner of the\n   * trunk document). For viewers or editors, only the user calling\n   * the method will be included as users the document is shared with.\n   *\n   * Users mentioned in user attribute tables will be available to any user with\n   * the right to view access rules.\n   *\n   * Example users are always included.\n   */\n  public async getUsersForViewAs(docSession: OptDocSession): Promise<PermissionDataWithExtraUsers> {\n    // Make sure we have rights to view access rules.\n    if (!await this._granularAccess.hasAccessRulesPermission(docSession)) {\n      throw new Error(\"Cannot list ACL users\");\n    }\n\n    // Prepare a stub for the collected results.\n    const result: PermissionDataWithExtraUsers = {\n      users: [],\n      attributeTableUsers: [],\n      exampleUsers: [],\n    };\n    const isShared = new Set<string>();\n\n    const userId = docSession.userId;\n    if (!userId) { throw new Error(\"Cannot determine user\"); }\n\n    const parsed = parseUrlId(this.docName);\n    const db = this.getHomeDbManager();\n\n    // If this is not a temporary document (i.e. created by anonymous user).\n    if (parsed.trunkId !== NEW_DOCUMENT_CODE) {\n      // Collect users the document is shared with.\n      if (db) {\n        const access = db.unwrapQueryResult(\n          await db.getDocAccess({ userId, urlId: this.docName }, {\n            flatten: true, excludeUsersWithoutAccess: true,\n          }));\n        result.users = access.users;\n        result.users.forEach(user => isShared.add(normalizeEmail(user.email)));\n      }\n    }\n\n    // Collect users from user attribute tables. Omit duplicates with users the document is\n    // shared with.\n    const usersFromUserAttributes = await this._granularAccess.collectViewAsUsersFromUserAttributeTables();\n    for (const user of usersFromUserAttributes) {\n      if (!user.email) { continue; }\n      const email = normalizeEmail(user.email);\n      if (!isShared.has(email)) {\n        result.attributeTableUsers.push({ email: user.email, name: user.name || \"\",\n          id: 0, access: user.access === undefined ? \"editors\" : user.access });\n      }\n    }\n\n    // Add some example users.\n    result.exampleUsers = this._granularAccess.getExampleViewAsUsers();\n\n    // If there are example users with no access, use the public access level.\n    const publicUsers = result.exampleUsers.filter(u => !u.access);\n    if (publicUsers.length && db) {\n      const docAuth = await db.getDocAuthCached({\n        urlId: this.docName,\n        userId: db.getAnonymousUserId(),\n      });\n      publicUsers.forEach(u => u.access = docAuth.access);\n    }\n\n    return result;\n  }\n\n  public getGristDocAPI(): GristDocAPI {\n    if (!this.docPluginManager) { throw new Error(\"no plugin manager available\"); }\n    return this.docPluginManager.gristDocAPI;\n  }\n\n  // Get recent actions in ActionGroup format with summaries included.\n  public async getActionSummaries(docSession: OptDocSession): Promise<GetActionSummariesResult> {\n    return this.getRecentActions(docSession, true);\n  }\n\n  /**\n   * Applies normal actions to the data engine while processing onDemand actions separately.\n   * Should only be called by a Sharing object, with this._modificationLock held, since the\n   * actions may need to be rolled back if final access control checks fail.\n   */\n  public async applyActionsToDataEngine(\n    docSession: OptDocSession | null,\n    userActions: UserAction[],\n  ): Promise<SandboxActionBundle> {\n    const [normalActions, onDemandActions] = this._onDemandActions.splitByStorage(userActions);\n\n    let sandboxActionBundle: SandboxActionBundle;\n    if (normalActions.length > 0) {\n      // For all but the special 'Calculate' action, we wait for full initialization.\n      if (normalActions[0][0] !== \"Calculate\") {\n        await this.waitForInitialization();\n      }\n      const user = docSession ? await this._granularAccess.getCachedUser(docSession) : undefined;\n      sandboxActionBundle = await this._rawPyCall(\"apply_user_actions\", normalActions, user?.toJSON());\n      sandboxActionBundle.numBytes = this._lastPyCallResponseSize;\n      const { requests } = sandboxActionBundle;\n      if (requests) {\n        this._requests.handleRequestsBatchFromUserActions(requests).catch(e => console.error(e));\n      }\n      await this._reportDataEngineMemoryThrottled?.();\n    } else {\n      // Create default SandboxActionBundle to use if the data engine is not called.\n      sandboxActionBundle = createEmptySandboxActionBundle();\n    }\n\n    if (onDemandActions.length > 0) {\n      const allIndex = findOrAddAllEnvelope(sandboxActionBundle.envelopes);\n      await this.docStorage.execTransaction(async () => {\n        for (const action of onDemandActions) {\n          const { stored, undo, retValues } = await this._onDemandActions.processUserAction(action);\n          // Note: onDemand stored/undo actions are arbitrarily processed/added after normal actions\n          // and do not support access control.\n          sandboxActionBundle.stored.push(...stored.map(a => [allIndex, a] as [number, DocAction]));\n          sandboxActionBundle.direct.push(...stored.map(a => [allIndex, true] as [number, boolean]));\n          sandboxActionBundle.undo.push(...undo.map(a => [allIndex, a] as [number, DocAction]));\n          sandboxActionBundle.retValues.push(retValues);\n        }\n      });\n    }\n\n    return sandboxActionBundle;\n  }\n\n  /**\n   * Check which attachments in the _grist_Attachments metadata are actually used,\n   * i.e. referenced by some cell in an Attachments type column.\n   * Set timeDeleted to the current time on newly unused attachments,\n   * 'soft deleting' them so that they get cleaned up automatically from _gristsys_Files after\n   * enough time has passed. Set timeDeleted to null on used attachments that were previously soft\n   * deleted, so that undo can 'undelete' attachments. Returns true if any changes were made, i.e.\n   * some row(s) of _grist_Attachments were updated.\n   */\n  public async updateUsedAttachmentsIfNeeded() {\n    const changes = await this.docStorage.scanAttachmentsForUsageChanges();\n    if (!changes.length) {\n      return false;\n    }\n    const rowIds = changes.map(r => r.id);\n    const now = Date.now() / 1000;\n    const timeDeleted = changes.map(r => r.used ? null : now);\n    const action: BulkUpdateRecord = [\"BulkUpdateRecord\", \"_grist_Attachments\", rowIds, { timeDeleted }];\n    // Don't use applyUserActions which may block the update action in delete-only mode\n    await this._applyUserActionsAsSystem([action]);\n    return true;\n  }\n\n  /**\n   * Delete unused attachments from _grist_Attachments and gristsys_Files.\n   * @param expiredOnly: if true, only delete attachments that were soft-deleted sufficiently long\n   *   ago.\n   * @param options.syncUsageToDatabase: if true, schedule an update to the usage column of the\n   *   docs table, if any unused attachments were soft-deleted. defaults to true.\n   * @param options.broadcastUsageToClients: if true, broadcast updated doc usage to all clients,\n   *   if\n   * any unused attachments were soft-deleted. defaults to true.\n   */\n  public async removeUnusedAttachments(expiredOnly: boolean, options?: UpdateUsageOptions) {\n    const hadChanges = await this.updateUsedAttachmentsIfNeeded();\n    if (hadChanges) {\n      await this._updateAttachmentsSize(options);\n    }\n    const rowIds = await this.docStorage.getSoftDeletedAttachmentIds(expiredOnly);\n    if (rowIds.length) {\n      const action: BulkRemoveRecord = [\"BulkRemoveRecord\", \"_grist_Attachments\", rowIds];\n      await this.applyUserActions(makeExceptionalDocSession(\"system\"), [action]);\n    }\n    try {\n      await this.docStorage.removeUnusedAttachments();\n    } catch (e) {\n      // If document doesn't have _gristsys_Files, don't worry about it;\n      // if this is an error it will have already been reported, and the\n      // document can be in this state when updating initial SQL code after\n      // a schema change.\n      if (!String(e).match(/no such table: _gristsys_Files/)) {\n        throw e;\n      }\n    }\n  }\n\n  // Needed for test/server/migrations.js tests\n  public async testGetVersionFromDataEngine() {\n    return this._pyCall(\"get_version\");\n  }\n\n  // Needed for test/server/lib/HostedStorageManager.ts tests\n  public async testKeepOpen() {\n    this._inactivityTimer.ping();\n  }\n\n  public async getSnapshots(docSession: OptDocSession, skipMetadataCache?: boolean): Promise<DocSnapshots> {\n    if (await this._granularAccess.hasNuancedAccess(docSession)) {\n      throw new Error(\"cannot confirm access to snapshots because of access rules\");\n    }\n    return this._docManager.storageManager.getSnapshots(this.docName, skipMetadataCache);\n  }\n\n  public async removeSnapshots(docSession: OptDocSession, snapshotIds: string[]): Promise<void> {\n    if (!await this.isOwner(docSession)) {\n      throw new Error(\"cannot remove snapshots, access denied\");\n    }\n    return this._docManager.storageManager.removeSnapshots(this.docName, snapshotIds);\n  }\n\n  public async deleteActions(docSession: OptDocSession, keepN: number): Promise<void> {\n    if (!await this.isOwner(docSession)) {\n      throw new Error(\"cannot delete actions, access denied\");\n    }\n    await this._actionHistory.deleteActions(keepN);\n  }\n\n  /**\n   * Make sure the current version of the document has been pushed to persistent\n   * storage.\n   */\n  public async flushDoc(): Promise<void> {\n    return this._docManager.storageManager.flushDoc(this.docName);\n  }\n\n  public makeAccessId(userId: number | null): string | null {\n    return this._docManager.makeAccessId(userId);\n  }\n\n  /**\n   * Apply actions that have already occurred in the data engine to the\n   * database also.\n   */\n  public async applyStoredActionsToDocStorage(docActions: DocAction[]): Promise<void> {\n    // When \"gristifying\" an sqlite database, we may take create tables and\n    // columns in the data engine that already exist in the sqlite database.\n    // During that process, _onlyAllowMetaDataActionsOnDb will be turned on,\n    // and we silently swallow any non-metadata actions.\n    if (this._onlyAllowMetaDataActionsOnDb) {\n      docActions = docActions.filter(a => getTableId(a).startsWith(\"_grist\"));\n    }\n    await this.docStorage.applyStoredActions(docActions);\n  }\n\n  // Set a flag that controls whether user data can be changed in the database,\n  // or only grist-managed tables (those whose names start with _grist)\n  public onlyAllowMetaDataActionsOnDb(flag: boolean) {\n    this._onlyAllowMetaDataActionsOnDb = flag;\n  }\n\n  /**\n   * Called by Sharing manager when working on modifying the document.\n   * Called when DocActions have been produced from UserActions, but\n   * before those DocActions have been applied to the DB. GranularAccessBundle\n   * methods can confirm that those DocActions are legal according to any\n   * granular access rules.\n   */\n  public getGranularAccessForBundle(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[],\n    userActions: UserAction[], isDirect: boolean[],\n    options: ApplyUAOptions | null): GranularAccessForBundle {\n    this._granularAccess.getGranularAccessForBundle(docSession, docActions, undo, userActions, isDirect, options);\n    return this._granularAccess;\n  }\n\n  public async updateRowCount(rowCount: RowCounts, docSession: OptDocSession | null) {\n    // Up-to-date row counts are included in every DocUserAction, so we can skip broadcasting here.\n    await this._updateDocUsage({ rowCount }, { broadcastUsageToClients: false });\n    await this._checkDataLimitRatio();\n\n    // Calculating data size is potentially expensive, so skip calculating it unless the\n    // user is currently being warned specifically about approaching or exceeding the data\n    // size limit, but not the row count limit; we don't need to warn about both limits at\n    // the same time.\n    if (\n      this.dataSizeLimitRatio > APPROACHING_LIMIT_RATIO && this.rowLimitRatio <= APPROACHING_LIMIT_RATIO ||\n      this.dataSizeLimitRatio > 1.0 && this.rowLimitRatio <= 1.0\n    ) {\n      await this._checkDataSizeLimitRatio(docSession);\n    }\n  }\n\n  /**\n   * Clears all outgoing webhook requests queued for this document.\n   */\n  public async clearWebhookQueue() {\n    await this._webhookQueue.clearWebhookQueue();\n  }\n\n  public async clearSingleWebhookQueue(webhookId: string) {\n    await this._webhookQueue.clearSingleWebhookQueue(webhookId);\n  }\n\n  /**\n   * Returns the list of outgoing webhook for a table in this document.\n   */\n  public async webhooksSummary() {\n    return this._webhookQueue.summary();\n  }\n\n  /**\n   * Send a message to clients connected to the document that something\n   * webhook-related has happened (a change in configuration, a delivery,\n   * or an error). It passes information about the type of event (currently data being updated in\n   * some way or an OverflowError, i.e., too many events waiting to be sent). More data may be\n   * added when necessary.\n   */\n  public async sendWebhookNotification(type: WebhookMessageType = WebhookMessageType.Update) {\n    await this.docClients.broadcastDocMessage(null, \"docChatter\", {\n      webhooks: { type },\n    });\n  }\n\n  /**\n   * Sends a message to clients connected to the document that the attachments' transfer\n   * job has started or finished. It is also sent when the attachment store is changed\n   * through the API (as it also includes information about attachments' location).\n   */\n  public async sendAttachmentTransferStatusNotification(attachmentTransfer: AttachmentTransferStatus) {\n    await this.docClients.broadcastDocMessage(null, \"docChatter\", {\n      attachmentTransfer,\n    });\n  }\n\n  public async sendTimingsNotification() {\n    await this.docClients.broadcastDocMessage(null, \"docChatter\", {\n      timing: {\n        status: this.isTimingOn ? \"active\" : \"disabled\",\n      },\n    });\n  }\n\n  public logAuditEvent<Action extends AuditEventAction>(\n    requestOrSession: RequestOrSession,\n    properties: AuditEventProperties<Action>,\n  ) {\n    this._server\n      .getAuditLogger()\n      .logEvent(\n        requestOrSession,\n        merge(this._getAuditEventProperties<Action>(), properties),\n      );\n  }\n\n  public logTelemetryEvent(\n    docSession: OptDocSession | null,\n    event: TelemetryEvent,\n    metadata?: TelemetryMetadataByLevel,\n  ) {\n    this._server.getTelemetry().logEvent(docSession, event, merge(\n      this._getTelemetryMeta(docSession),\n      metadata,\n    ));\n  }\n\n  /**\n   * Make sure the shares listed for the doc in the home db and the\n   * shares listed within the doc itself are in sync. If skipIfNoShares\n   * is set, we skip checking the home db if there are no shares listed\n   * within the doc, as a small optimization.\n   */\n  public async syncShares(docSession: OptDocSession, options: {\n    skipIfNoShares?: boolean,\n  } = {}) {\n    const metaTables = await this.fetchMetaTables(docSession);\n    const shares = metaTables._grist_Shares;\n    const ids = shares[2];\n    const vals = shares[3];\n    const goodShares = ids.map((id, idx) => {\n      return {\n        id,\n        linkId: String(vals.linkId[idx]),\n        options: String(vals.options[idx]),\n      };\n    });\n    if (goodShares.length > 0 || !options.skipIfNoShares) {\n      await this._getHomeDbManagerOrFail().syncShares(this.docName, goodShares);\n    }\n    return goodShares;\n  }\n\n  public async getShare(_docSession: OptDocSession, linkId: string): Promise<Share | null> {\n    return await this._getHomeDbManagerOrFail().getShareByLinkId(this.docName, linkId);\n  }\n\n  public async startTiming(): Promise<void> {\n    await this.waitForInitialization();\n\n    // Set the flag to indicate that timing is on.\n    this.isTimingOn = true;\n\n    try {\n      // Call the data engine to start timing.\n      await this._doStartTiming();\n    } catch (e) {\n      this.isTimingOn = false;\n      throw e;\n    }\n\n    // Mark self as in timing mode, in case we get reloaded.\n    this._docManager.restoreTimingOn(this.docName, true);\n    await this.sendTimingsNotification();\n  }\n\n  public async stopTiming(): Promise<FormulaTimingInfo[]> {\n    await this.waitForInitialization();\n\n    // First call the data engine to stop timing, and gather results.\n    const timingResults = await this._pyCall(\"stop_timing\");\n\n    // Toggle the flag and clear the reminder.\n    this.isTimingOn = false;\n    this._docManager.restoreTimingOn(this.docName, false);\n\n    await this.sendTimingsNotification();\n\n    return timingResults;\n  }\n\n  public async getTimings(): Promise<FormulaTimingInfo[] | void>  {\n    await this.waitForInitialization();\n\n    if (this._modificationLock.isLocked()) {\n      return;\n    }\n    return await this._pyCall(\"get_timings\");\n  }\n\n  public async getAssistantState(_docSession: OptDocSession, id: string): Promise<AssistantState | null> {\n    const store = this._server.getPermitStore();\n    const permit = await getAndRemoveAssistantStatePermit(store, id);\n    if (!permit || permit.docId !== this._docName) {\n      return null;\n    }\n\n    return pick(permit, \"prompt\");\n  }\n\n  public getMemoryUsedMB(): number {\n    return this._memoryUsedMB;\n  }\n\n  public async notifySubscribers(docSession: OptDocSession, accessControl: GranularAccessForBundle): Promise<void> {\n    return this._server.getDocNotificationManager()?.notifySubscribers(docSession, this._docName, accessControl);\n  }\n\n  /**\n   * Loads an open document from DocStorage. Applies migrations if needed, and starts loading\n   * metadata.\n   */\n  protected async _loadOpenDoc(docSession: OptDocSession): Promise<void> {\n    // Check the schema version of document and sandbox, and migrate if the sandbox is newer.\n    const schemaVersion = SCHEMA_VERSION;\n\n    // Migrate the document if needed.\n    const docInfo = await this._tableMetadataLoader.fetchBulkColValuesWithoutIds(\"_grist_DocInfo\");\n    const versionCol = docInfo.schemaVersion;\n    const docSchemaVersion = (versionCol?.length === 1 ? versionCol[0] : 0) as number;\n    if (docSchemaVersion < schemaVersion) {\n      this._log.info(docSession, \"Doc needs migration from v%s to v%s\", docSchemaVersion, schemaVersion);\n      await this._beforeMigration(docSession, \"schema\", docSchemaVersion, schemaVersion);\n      let success: boolean = false;\n      try {\n        await this._withDataEngine(() => this._migrate(docSession), {\n          shutdownAfter: this._isSnapshot,\n        });\n        success = true;\n      } finally {\n        await this._afterMigration(docSession, \"schema\", schemaVersion, success);\n        await this._tableMetadataLoader.clean();  // _grist_DocInfo may have changed.\n      }\n    } else if (docSchemaVersion > schemaVersion) {\n      // We do NOT attempt to down-migrate in this case. Migration code cannot down-migrate\n      // directly (since it doesn't know anything about newer documents). We could revert the\n      // migration action, but that requires merging and still may not be safe. For now, doing\n      // nothing seems best, as long as we follow the recommendations in migrations.py (never\n      // remove/modify/rename metadata tables or columns, or change their meaning).\n      this._log.warn(docSession, \"Doc is newer (v%s) than this version of Grist (v%s); \" +\n      \"proceeding with fingers crossed\", docSchemaVersion, schemaVersion);\n    }\n\n    if (!this._isSnapshot) {\n      this._tableMetadataLoader.startStreamingToEngine();\n    }\n\n    // Start loading the meta tables.\n    for (const tableId of Object.keys(schema)) {\n      this._tableMetadataLoader.startFetchingTable(tableId);\n    }\n  }\n\n  /**\n   * Applies an array of user actions initiated by Grist itself, using a DocSession with \"system\"\n   * access rights. These bypass access rules.\n   *\n   * They also do not count as \"user activity\" for the purpose of keeping the document open.\n   */\n  protected async _applyUserActionsAsSystem(actions: UserAction[]): Promise<ApplyUAResult> {\n    return this._applyUserActions(makeExceptionalDocSession(\"system\"), actions, {});\n  }\n\n  /**\n   * Applies an array of user actions to the sandbox and broadcasts the results to doc's clients.\n   *\n   * @private\n   * @param {Object} client - The client originating this action. May be null.\n   * @param {Array} actions - The user actions to apply.\n   * @param {String} options.desc - Description of the action which overrides the default client\n   *  description if provided. Should be used to describe bundled actions.\n   * @param {Int} options.otherId - Action number for the original useraction to which this\n   *   undo/redo action applies.\n   * @param {Boolean} options.linkId - ActionNumber of the previous action in an undo/redo bundle.\n   * @returns {Promise} Promise that's resolved when all actions are applied successfully to {\n   *    actionNum: number of the action that got recorded\n   *    retValues: array of return values, one for each of the passed-in user actions.\n   *    isModification: true if document was changed by one or more actions.\n   * }\n   */\n  @insightLogDecorate(\"ActiveDoc\")\n  protected async _applyUserActions(docSession: OptDocSession, actions: UserAction[],\n    options: ApplyUAExtendedOptions = {},\n  ): Promise<ApplyUAResult> {\n    const insightLog = insightLogEntry();\n    insightLog?.addMeta({ actionDesc: shortDesc(actions) });\n\n    if (options.parseStrings) {\n      actions = actions.map(ua => parseUserAction(ua, this.docData!));\n    }\n\n    if (options?.bestEffort) {\n      actions = await this._granularAccess.prefilterUserActions(docSession, actions, options);\n    }\n    await this._granularAccess.checkUserActions(docSession, actions);\n    insightLog?.mark(\"accessRulesPreCheck\");\n\n    // Create the UserActionBundle.\n    const action: UserActionBundle = {\n      info: this._makeInfo(docSession, options),\n      options,\n      userActions: actions,\n    };\n\n    const result: ApplyUAResult = await this._sharing.addUserAction(docSession, action);\n    insightLog?.addMeta({ resultDesc: shortDesc(result) });\n\n    if (result.isModification) {\n      this._fetchCache.clear();  // This could be more nuanced.\n      this._docManager.markAsChanged(this, \"edit\");\n      this.logAuditEvent(docSession, {\n        action: \"document.modify\",\n        details: {\n          action: {\n            num: result.actionNum,\n            hash: result.actionHash,\n          },\n          document: {\n            id: this.docName,\n          },\n        },\n      });\n    }\n    return result;\n  }\n\n  private async _doShutdownImpl(options: { beforeShutdown?: () => Promise<void> }): Promise<void> {\n    const docSession = makeExceptionalDocSession(\"system\");\n    this._log.debug(docSession, \"shutdown starting\");\n\n    const safeCallAndWait = async (funcDesc: string, func: () => Promise<unknown>) => {\n      try {\n        if (await timeoutReached(Deps.SHUTDOWN_ITEM_TIMEOUT_MS, func())) {\n          this._log.error(docSession, `${funcDesc} timed out`);\n        }\n      } catch (err) {\n        this._log.error(docSession, `${funcDesc} failed`, err);\n      }\n    };\n\n    try {\n      this.setMuted();\n      this._inactivityTimer.disable();\n\n      // No timeout on this callback: if it hangs, it will make the document unusable.\n      await options.beforeShutdown?.();\n\n      if (this.docClients.clientCount() > 0) {\n        this._log.warn(docSession, `Doc being closed with ${this.docClients.clientCount()} clients left`);\n        await this.docClients.broadcastDocMessage(null, \"docShutdown\", null);\n        this.docClients.interruptAllClients();\n        this.docClients.removeAllClients();\n      }\n\n      this._webhookQueue.shutdown();\n\n      // attachmentFileManager needs to shut down before DocStorage, to allow transfers to finish.\n      await safeCallAndWait(\"attachmentFileManager\",\n        this._attachmentFileManager.shutdown.bind(this._attachmentFileManager));\n\n      // Clear the pubsub subscription to billing account changes.\n      this._pubSubUnsubscribe?.();\n\n      // Clear the MapWithTTL to remove all timers from the event loop.\n      this._fetchCache.clear();\n\n      await Promise.all(this._intervals.map(interval =>\n        safeCallAndWait(\"interval.disableAndFinish\", () => interval.disableAndFinish())));\n\n      this._reportDataEngineMemoryThrottled?.cancel();\n      this._reportDataEngineMemoryThrottled = null;\n\n      // We'll defer syncing usage until everything is calculated.\n      const usageOptions = { syncUsageToDatabase: false, broadcastUsageToClients: false };\n\n      // This cleanup requires docStorage, which may have failed to start up.\n      // We don't want to log pointless errors in that case.\n      if (this.docStorage.isInitialized()) {\n        // Remove expired attachments, i.e. attachments that were soft deleted a while ago. This\n        // needs to happen periodically, and doing it here means we can guarantee that it happens\n        // even if the doc is only ever opened briefly, without having to slow down startup.\n        await safeCallAndWait(\"removeUnusedAttachments\", () => this.removeUnusedAttachments(true, usageOptions));\n\n        if (this._dataEngine && this._fullyLoaded) {\n          // Note that this must happen before `this._shuttingDown = true` because of this line in Sharing.ts:\n          //     if (this._activeDoc.isShuttingDown && isCalculate) {\n          await safeCallAndWait(\"RemoveStaleObjects\",\n            () => this.applyUserActions(docSession, [[\"RemoveStaleObjects\"]]),\n          );\n        }\n\n        // Update data size; we'll be syncing both it and attachments size to the database soon.\n        await safeCallAndWait(\"_updateDataSize\", () => this._updateDataSize(usageOptions));\n      }\n\n      this._syncDocUsageToDatabase(true);\n      this._logDocMetrics(docSession, \"docClose\");\n\n      await safeCallAndWait(\"storageManager.closeDocument\",\n        () => this._docManager.storageManager.closeDocument(this.docName));\n\n      try {\n        const dataEngine = this._dataEngine ? await this._getEngine() : null;\n        this._shuttingDown = true;  // Block creation of engine if not yet in existence.\n        if (dataEngine) {\n          // Give a small grace period for finishing initialization if we are being shut\n          // down while initialization is still in progress, and we don't have an easy\n          // way yet to cancel it cleanly. This is mainly for the benefit of automated\n          // tests.\n          await timeoutReached(3000, this.waitForInitialization());\n        }\n        this._docManager.unregisterSQLiteDB(this.docName);\n        await Promise.all([\n          this.docStorage.shutdown(),\n          this.docPluginManager?.shutdown(),\n          this._isSnapshot ? undefined : dataEngine?.shutdown(),\n        ]);\n        // The this.waitForInitialization promise may not yet have resolved, but\n        // should do so quickly now we've killed everything it depends on.\n        await safeCallAndWait(\"waitForInitialization\", () => this.waitForInitialization());\n      } catch (err) {\n        this._log.error(docSession, \"failed to shutdown some resources\", err);\n      }\n      // No timeout on this callback: if it hangs, it will make the document unusable.\n      await this._afterShutdownCallback?.();\n    } finally {\n      this._docManager.removeActiveDoc(this);\n    }\n    await safeCallAndWait(\"_granularAccess.close\", () => this._granularAccess.close());\n    this._log.debug(docSession, \"shutdown complete\");\n  }\n\n  @ActiveDoc.keepDocOpen\n  private async _applyUserActionsWithExtendedOptions(docSession: OptDocSession, actions: UserAction[],\n    options?: ApplyUAExtendedOptions): Promise<ApplyUAResult> {\n    assert(Array.isArray(actions), \"`actions` parameter should be an array.\");\n    // Be careful not to sneak into user action queue before Calculate action, otherwise\n    // there'll be a deadlock.\n    insightLogEntry()?.addMeta(this.getLogMeta(docSession));\n    await this.waitForInitialization();\n\n    if (\n      this.dataLimitInfo.status === \"deleteOnly\" &&\n      !actions.every(action => [\n        \"RemoveTable\", \"RemoveColumn\", \"RemoveRecord\", \"BulkRemoveRecord\",\n        \"RemoveViewSection\", \"RemoveView\", \"ApplyUndoActions\", \"RespondToRequests\",\n      ].includes(action[0] as string))\n    ) {\n      throw new Error(\"Document is in delete-only mode\");\n    }\n\n    // Granular access control implemented in _applyUserActions.\n    return await this._applyUserActions(docSession, actions, options);\n  }\n\n  /**\n   * Create a new document file without using or initializing the data engine.\n   */\n  @ActiveDoc.keepDocOpen\n  private async _createDocFile(docSession: OptDocSession, options?: {\n    skipInitialTable?: boolean,  // If set, \"Table1\" will not be added.\n    useExisting?: boolean,       // If set, an existing sqlite db is permitted.\n    // Useful for \"gristifying\" an existing db.\n  }): Promise<void> {\n    this._log.debug(docSession, \"createDoc\");\n    await this._docManager.storageManager.prepareToCreateDoc(this.docName);\n    await this.docStorage.createFile(options);\n    const sql = options?.skipInitialTable ? GRIST_DOC_SQL : GRIST_DOC_WITH_TABLE1_SQL;\n    await this.docStorage.exec(sql);\n    const timezone = docSession.browserSettings?.timezone ?? DEFAULT_TIMEZONE;\n    const locale = docSession.browserSettings?.locale ?? DEFAULT_LOCALE;\n    const documentSettings: DocumentSettings = { locale };\n    documentSettings.engine = \"python3\";\n    await this.docStorage.run(\"UPDATE _grist_DocInfo SET timezone = ?, documentSettings = ?\",\n      timezone, JSON.stringify(documentSettings));\n  }\n\n  private _makeInfo(docSession: OptDocSession, options: ApplyUAOptions = {}) {\n    const user =\n      docSession.mode === \"system\" ? \"grist\" :\n      // Anonymize user info for form submissions.\n      // Note: This is half-baked and doesn't account for other types of shares besides forms.\n        getDocSessionShare(docSession) ? ANONYMOUS_USER_EMAIL :\n          docSession.displayEmail || \"\";\n    return {\n      time: Date.now(),\n      user,\n      inst: this._sharing.instanceId || \"unset-inst\",\n      desc: options.desc,\n      otherId: options.otherId || 0,\n      linkId: options.linkId || 0,\n    };\n  }\n\n  /**\n   * Update the time in formulas; this is called via Interval every hour.\n   */\n  private async _updateCurrentTime() {\n    const dataEngine = await this._getEngine();\n    if (dataEngine.isProcessDown()) {\n      // Don't attempt to update time if data engine is down, as this can't help, and leads to\n      // spurious errors. Instead, report as a warning, more clearly and concisely.\n      this._log.warn(null, \"failed to update current time: data engine is down\");\n      return;\n    }\n    return this._applyUserActionsAsSystem([[\"UpdateCurrentTime\"]]);\n  }\n\n  /**\n   * Applies all metrics from `usage` to the current document usage state.\n   *\n   * Allows specifying `options` for toggling whether usage is synced to\n   * the home database and/or broadcast to clients.\n   */\n  private async _updateDocUsage(usage: Partial<DocumentUsage>, options: UpdateUsageOptions = {}) {\n    const { syncUsageToDatabase = true, broadcastUsageToClients = true } = options;\n    const oldStatus = this.dataLimitInfo.status;\n    this._docUsage = { ...(this._docUsage || {}), ...usage };\n    if (syncUsageToDatabase) {\n      /* If status decreased, we'll update usage in the database with minimal delay, so site usage\n       * banners show up-to-date statistics. If status increased or stayed the same, we'll schedule\n       * a delayed update, since it's less critical for banners to update immediately. */\n      const didStatusDecrease = getSeverity(this.dataLimitInfo.status) < getSeverity(oldStatus);\n      this._syncDocUsageToDatabase(didStatusDecrease);\n    }\n    if (broadcastUsageToClients) {\n      await this._broadcastDocUsageToClients();\n    }\n  }\n\n  private _syncDocUsageToDatabase(minimizeDelay = false) {\n    this._docManager.storageManager.scheduleUsageUpdate(this._docName, this._docUsage, minimizeDelay);\n  }\n\n  private async _assertCanGetAttachment(docSession: OptDocSession, attId: number,\n    options?: {\n      cell?: SingleCell,\n      maybeNew?: boolean,\n    }): Promise<void> {\n    const cell = options?.cell;\n    const maybeNew = options?.maybeNew;\n    if (\n      await this._granularAccess.canReadEverything(docSession) ||\n      await this.canDownload(docSession)\n    ) {\n      // Do not need to sweat over access to attachments if user can\n      // read everything or download everything.\n    } else {\n      if (maybeNew && await this._granularAccess.isAttachmentUploadedByUser(docSession, attId)) {\n        // Fine, this is an attachment the user uploaded (recently).\n      } else if (cell) {\n        // Only provide the download if the user has access to the cell\n        // they specified, and that cell is in an attachment column,\n        // and the cell contains the specified attachment.\n        await this._granularAccess.assertAttachmentAccess(docSession, cell, attId);\n      } else {\n        if (!await this._granularAccess.findAttachmentCellForUser(docSession, attId)) {\n          // We found no reason to allow this user to access the attachment.\n          throw new ApiError(\"Cannot access attachment\", 403);\n        }\n      }\n    }\n  }\n\n  private async _broadcastDocUsageToClients() {\n    if (this.muted || this.docClients.clientCount() === 0) { return; }\n\n    await this.docClients.broadcastDocMessage(\n      null,\n      \"docUsage\",\n      { docUsage: this.getDocUsageSummary(), product: this._product },\n      async (session, data) => {\n        return { ...data, docUsage: await this.getFilteredDocUsageSummary(session) };\n      },\n    );\n  }\n\n  private async _updateGracePeriodStart(gracePeriodStart: Date | null) {\n    this._gracePeriodStart = gracePeriodStart;\n    if (!this._isForkOrSnapshot) {\n      await this.getHomeDbManager()?.setDocGracePeriodStart(this.docName, gracePeriodStart);\n    }\n  }\n\n  private async _checkDataLimitRatio() {\n    const exceedingDataLimit = this.dataLimitRatio > 1;\n    if (exceedingDataLimit && !this._gracePeriodStart) {\n      await this._updateGracePeriodStart(new Date());\n    } else if (!exceedingDataLimit && this._gracePeriodStart) {\n      await this._updateGracePeriodStart(null);\n    }\n  }\n\n  private async _checkDataSizeLimitRatio(docSession: OptDocSession | null) {\n    const start = Date.now();\n    if (!this.docStorage.isInitialized()) {\n      return;\n    }\n    const dataSizeBytes = await this._updateDataSize();\n    const timeToMeasure = Date.now() - start;\n    log.rawInfo(\"Data size from dbstat...\", {\n      ...this.getLogMeta(docSession),\n      dataSizeBytes,\n      timeToMeasure,\n    });\n    await this._checkDataLimitRatio();\n  }\n\n  /**\n   * Calculates the total data size in bytes, sets it in _docUsage, and returns it.\n   *\n   * Allows specifying `options` for toggling whether usage is synced to\n   * the home database and/or broadcast to clients.\n   */\n  private async _updateDataSize(options?: UpdateUsageOptions): Promise<number> {\n    const dataSizeBytes = await this.docStorage.getDataSize();\n    await this._updateDocUsage({ dataSizeBytes }, options);\n    return dataSizeBytes;\n  }\n\n  /**\n   * Prepares a single attachment by adding it DocStorage and returns a UserAction to apply.\n   */\n  private async _prepAttachment(docSession: OptDocSession, fileData: FileUploadInfo): Promise<UserAction> {\n    // Check that upload size is within the configured limits.\n    const limit = (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || Infinity;\n    if (fileData.size > limit) {\n      throw new ApiError(`Attachments must not exceed ${byteString(limit)}`, 413);\n    }\n\n    let dimensions: { width?: number, height?: number } = {};\n    // imageSize returns an object with a width, height and type property if the file is an image.\n    // The width and height properties are integers representing width and height in pixels.\n    try {\n      dimensions = await bluebird.fromCallback((cb: any) => imageSize(fileData.absPath, cb));\n    } catch (err) {\n      // Non-images will fail in some way, and that's OK.\n      dimensions.height = 0;\n      dimensions.width = 0;\n    }\n    const attachmentStoreId = this._getDocumentSettings().attachmentStoreId;\n    const addFileResult = await this._attachmentFileManager\n      .addFile(attachmentStoreId, fileData.ext, await readFile(fileData.absPath));\n    this._log.info(\n      docSession, \"addAttachment: store: '%s', file: '%s' (image %sx%s) %s\",\n      attachmentStoreId ?? \"local document\", addFileResult.fileIdent, dimensions.width, dimensions.height,\n      addFileResult.isNewFile ? \"attached\" : \"already exists\",\n    );\n    return [\"AddRecord\", \"_grist_Attachments\", null, {\n      fileIdent: addFileResult.fileIdent,\n      fileName: fileData.origName,\n      // We used to set fileType, but it's not easily available for native types. Since it's\n      // also entirely unused, we just skip it until it becomes relevant.\n      fileSize: fileData.size,\n      fileExt: fileData.ext,\n      imageHeight: dimensions.height,\n      imageWidth: dimensions.width,\n      timeUploaded: Date.now(),\n    }];\n  }\n\n  private async _updateAttachmentFileSizesUsingFileIdent(docSession: OptDocSession,\n    newFileSizesByFileIdent: Map<string, number>): Promise<void> {\n    const rowIdsToUpdate: number[] = [];\n    const newFileSizesForRows: CellValue[] = [];\n    const attachments = this.docData?.getMetaTable(\"_grist_Attachments\").getRecords();\n    for (const attachmentRec of attachments ?? []) {\n      const newSize = newFileSizesByFileIdent.get(attachmentRec.fileIdent);\n      if (newSize && newSize !== attachmentRec.fileSize) {\n        rowIdsToUpdate.push(attachmentRec.id);\n        newFileSizesForRows.push(newSize);\n      }\n    }\n\n    if (rowIdsToUpdate.length === 0) {\n      return;\n    }\n\n    const action: BulkUpdateRecord = [\"BulkUpdateRecord\", \"_grist_Attachments\", rowIdsToUpdate, {\n      fileSize: newFileSizesForRows,\n    }];\n\n    try {\n      await this._applyUserActionsWithExtendedOptions(\n        docSession,\n        [action],\n        { attachment: true },\n      );\n    } catch (e) {\n      if (e instanceof SandboxError) {\n        this._log.error(null, \"Attachment sizes could not be updated due to a sandbox error: \", e);\n        throw new Error(\"Attachment sizes could not be updated due to a sandbox error\", { cause: e });\n      }\n      throw e;\n    }\n\n    // Updates doc's overall attachment usage to reflect any changes to file sizes.\n    await this._updateAttachmentsSize();\n  }\n\n  /**\n   * If the software is newer than the document, migrate the document by fetching all tables, and\n   * giving them to the sandbox so that it can produce migration actions.\n   * TODO: We haven't figured out how to do sharing between different Grist versions that\n   * expect different schema versions. The returned actions at the moment aren't even shared with\n   * collaborators.\n   */\n  private async _migrate(docSession: OptDocSession): Promise<void> {\n    const tableNames = await this.docStorage.getAllTableNames();\n\n    // Fetch only metadata tables first, and try to migrate with only those.\n    const tableData: { [key: string]: Buffer | null } = {};\n    for (const tableName of tableNames) {\n      if (tableName.startsWith(\"_grist_\")) {\n        tableData[tableName] = await this.docStorage.fetchTable(tableName);\n      }\n    }\n\n    let docActions: DocAction[];\n    try {\n      // The last argument tells create_migrations() that only metadata is included.\n      docActions = await this._rawPyCall(\"create_migrations\", tableData, true);\n    } catch (e) {\n      if (!/need all tables/.test(e.message)) {\n        throw e;\n      }\n      // If the migration failed because it needs all tables (i.e. involves changes to data), then\n      // fetch them all. TODO: This is used for some older migrations, and is relied on by tests.\n      // If a new migration needs this flag, more work is needed. The current approach creates\n      // more memory pressure than usual since full data is present in memory at once both in node\n      // and in Python; and it doesn't skip onDemand tables. This is liable to cause crashes.\n      this._log.warn(docSession, \"_migrate: retrying with all tables\");\n      for (const tableName of tableNames) {\n        if (!tableData[tableName] && !tableName.startsWith(\"_gristsys_\")) {\n          tableData[tableName] = await this.docStorage.fetchTable(tableName);\n        }\n      }\n      docActions = await this._rawPyCall(\"create_migrations\", tableData);\n    }\n\n    const processedTables = Object.keys(tableData);\n    const numSchema = countIf(processedTables, t => t.startsWith(\"_grist_\"));\n    const numUser = countIf(processedTables, t => !t.startsWith(\"_grist_\"));\n    this._log.info(docSession, \"_migrate: applying %d migration actions (processed %s schema, %s user tables)\",\n      docActions.length, numSchema, numUser);\n\n    docActions.forEach((action, i) => this._log.info(docSession, \"_migrate: docAction %s: %s\", i, shortDesc(action)));\n    await this.docStorage.execTransaction(() => this.docStorage.applyStoredActions(docActions));\n  }\n\n  /**\n   * Load the specified tables into the data engine.\n   */\n  private async _loadTables(docSession: OptDocSession, tableNames: string[]) {\n    this._log.debug(docSession, \"loading %s tables: %s\", tableNames.length,\n      tableNames.join(\", \"));\n    // Pass the resulting array to `map`, which allows parallel processing of the tables. Database\n    // and DataEngine may still do things serially, but it allows them to be busy simultaneously.\n    await bluebird.map(tableNames, async (tableName: string) =>\n      this._pyCall(\"load_table\", tableName, await this._fetchTableIfPresent(tableName)),\n    // How many tables to query for and push to the data engine in parallel.\n    { concurrency: 3 });\n    return this;\n  }\n\n  // Fetches and returns the requested table, or null if it's missing. This allows documents to\n  // load with missing metadata tables (should only matter if migrations are also broken).\n  private async _fetchTableIfPresent(tableName: string): Promise<Buffer | null> {\n    try {\n      return await this.docStorage.fetchTable(tableName);\n    } catch (err) {\n      if (/no such table/.test(err.message)) { return null; }\n      throw err;\n    }\n  }\n\n  // It's a bit risky letting \"Calculate\" (and other formula-dependent calls) to disable\n  // inactivityTimer, since a user formulas with an infinite loop can disable it forever.\n  // TODO find a solution to this issue.\n  @ActiveDoc.keepDocOpen\n  private async _finishInitialization(docSession: OptDocSession, docData: DocData, startTime: number): Promise<void> {\n    try {\n      const insightLog = insightLogEntry();\n      insightLog?.addMeta(this.getLogMeta(docSession));\n\n      await this._tableMetadataLoader.wait();\n      await this._tableMetadataLoader.clean();\n      insightLog?.mark(\"metadata\");\n\n      const tables = docData.getMetaTable(\"_grist_Tables\");\n      const skipLoadingUserTables = this._recoveryMode;\n      const onDemandCount =\n        skipLoadingUserTables ? tables.numRecords() : tables.filterRowIds({ onDemand: true }).length;\n\n      if (this._isSnapshot) {\n        log.rawInfo(\"Loading complete\", {\n          ...this.getLogMeta(docSession),\n          num_on_demand_tables: onDemandCount,\n        });\n      } else {\n        if (!skipLoadingUserTables) {\n          const pendingTableNames = tables.filterRecords({ onDemand: false }).map(r => r.tableId);\n          pendingTableNames.sort();   // Sort for a consistent order (affects DocRegressionTest)\n          await this._loadTables(docSession, pendingTableNames);\n        }\n        insightLog?.mark(\"userdata\");\n        const tableStats = await this._pyCall(\"get_table_stats\");\n        log.rawInfo(\"Loading complete, table statistics retrieved...\", {\n          ...this.getLogMeta(docSession),\n          ...tableStats,\n          num_on_demand_tables: onDemandCount,\n        });\n        await this._pyCall(\"initialize\", this._options?.docUrl);\n\n        // Report preliminary usage. The \"Calculate\" action below also reports usage, but\n        // since it may take a while to complete, it's helpful to report an early measurement.\n        await this._reportDataEngineMemoryThrottled?.();\n\n        if (this.isTimingOn) {\n          await this._doStartTiming();\n        }\n        insightLog?.mark(\"initialize\");\n\n        // Calculations are not associated specifically with the user opening the document.\n        // TODO: be careful with which users can create formulas.\n        await this._applyUserActionsAsSystem([[\"Calculate\"]]);\n      }\n\n      this._fullyLoaded = true;\n      const endTime = Date.now();\n      const loadMs = endTime - startTime;\n      // Adjust the inactivity timer: if the load took under 1 sec, use the regular timeout; if it\n      // took longer, scale it up proportionately.\n      const closeTimeout = Math.max(loadMs, 1000) * Deps.ACTIVEDOC_TIMEOUT;\n      this._inactivityTimer.setDelay(closeTimeout);\n\n      insightLog?.addMeta({ loadMs, closeTimeout });\n\n      const docUsage = getDocSessionUsage(docSession);\n      if (!docUsage) {\n        // This looks be the first time this installation of Grist is touching\n        // the document. If it has any shares, the home db needs to know.\n        // TODO: could offer a UI to control whether shares are activated.\n        await this.syncShares(docSession, { skipIfNoShares: true });\n      }\n      void this._initializeDocUsage(docSession);\n\n      // Start the periodic work, unless this doc has already started shutting down.\n      if (!this.muted) {\n        for (const interval of this._intervals) {\n          interval.enable();\n        }\n      }\n    } catch (err) {\n      this._fullyLoaded = true;\n      if (!this._shuttingDown) {\n        this._log.warn(docSession, \"_finishInitialization stopped with %s\", err);\n        throw new Error(\"ActiveDoc initialization failed: \" + String(err));\n      }\n    }\n  }\n\n  private _logSnapshotProgress(docSession: OptDocSession) {\n    const snapshotProgress = this._docManager.storageManager.getSnapshotProgress(this.docName);\n    const lastWindowTime = (snapshotProgress.lastWindowStartedAt &&\n      snapshotProgress.lastWindowDoneAt &&\n      snapshotProgress.lastWindowDoneAt > snapshotProgress.lastWindowStartedAt) ?\n      snapshotProgress.lastWindowDoneAt : Date.now();\n    const delay = snapshotProgress.lastWindowStartedAt ?\n      lastWindowTime - snapshotProgress.lastWindowStartedAt : null;\n    log.rawInfo(\"snapshot status\", {\n      ...this.getLogMeta(docSession),\n      ...snapshotProgress,\n      lastChangeAt: normalizedDateTimeString(snapshotProgress.lastChangeAt),\n      lastWindowStartedAt: normalizedDateTimeString(snapshotProgress.lastWindowStartedAt),\n      lastWindowDoneAt: normalizedDateTimeString(snapshotProgress.lastWindowDoneAt),\n      delay,\n    });\n  }\n\n  private _logDocMetrics(docSession: OptDocSession, triggeredBy: \"docOpen\" | \"interval\" | \"docClose\") {\n    this.logTelemetryEvent(docSession, \"documentUsage\", {\n      limited: {\n        triggeredBy,\n        isPublic: (this.doc as unknown as APIDocument)?.public ?? false,\n        rowCount: this._docUsage?.rowCount?.total,\n        dataSizeBytes: this._docUsage?.dataSizeBytes,\n        attachmentsSize: this._docUsage?.attachmentsSizeBytes,\n        ...this._getAccessRuleMetrics(),\n        ...this._getAttachmentMetrics(),\n        ...this._getChartMetrics(),\n        ...this._getWidgetMetrics(),\n        ...this._getColumnMetrics(),\n        ...this._getTableMetrics(),\n        ...this._getCustomWidgetMetrics(),\n      },\n    });\n    // Log progress on making snapshots periodically, to catch anything\n    // excessively slow.\n    this._logSnapshotProgress(docSession);\n  }\n\n  private _getAccessRuleMetrics() {\n    const accessRules = this.docData?.getMetaTable(\"_grist_ACLRules\");\n    const numAccessRules = accessRules?.numRecords() ?? 0;\n    const numUserAttributes = accessRules?.getRecords().filter(r => r.userAttributes).length ?? 0;\n\n    return {\n      numAccessRules,\n      numUserAttributes,\n    };\n  }\n\n  private _getAttachmentMetrics() {\n    const attachments = this.docData?.getMetaTable(\"_grist_Attachments\");\n    const numAttachments = attachments?.numRecords() ?? 0;\n    const attachmentTypes = attachments?.getRecords()\n      // Exclude the leading \".\", if any.\n      .map(r => r.fileExt?.trim()?.slice(1))\n      .filter(ext => Boolean(ext));\n    const uniqueAttachmentTypes = [...new Set(attachmentTypes ?? [])];\n    return {\n      numAttachments,\n      attachmentTypes: uniqueAttachmentTypes,\n    };\n  }\n\n  private _getChartMetrics() {\n    const viewSections = this.docData?.getMetaTable(\"_grist_Views_section\");\n    const viewSectionRecords = viewSections?.getRecords() ?? [];\n    const chartRecords = viewSectionRecords?.filter(r => r.parentKey === \"chart\") ?? [];\n    const chartTypes = chartRecords.map(r => r.chartType || \"bar\");\n    const numCharts = chartRecords.length;\n    const numLinkedCharts = chartRecords.filter(r => r.linkSrcSectionRef).length;\n\n    return {\n      numCharts,\n      chartTypes,\n      numLinkedCharts,\n    };\n  }\n\n  private _getWidgetMetrics() {\n    const viewSections = this.docData?.getMetaTable(\"_grist_Views_section\");\n    const viewSectionRecords = viewSections?.getRecords() ?? [];\n    const numLinkedWidgets = viewSectionRecords.filter(r => r.linkSrcSectionRef).length;\n\n    return {\n      numLinkedWidgets,\n    };\n  }\n\n  private _getColumnMetrics() {\n    const columns = this.docData?.getMetaTable(\"_grist_Tables_column\");\n    const columnRecords = columns?.getRecords().filter(r => !isHiddenCol(r.colId)) ?? [];\n    const numColumns = columnRecords.length;\n    const numColumnsWithConditionalFormatting = columnRecords.filter(r => r.rules).length;\n    const numFormulaColumns = columnRecords.filter(r => r.isFormula && r.formula).length;\n    const numTriggerFormulaColumns = columnRecords.filter(r => !r.isFormula && r.formula).length;\n\n    const tables = this.docData?.getMetaTable(\"_grist_Tables\");\n    const tableRecords = tables?.getRecords().filter(r =>\n      r.tableId && !r.tableId.startsWith(\"GristHidden_\")) ?? [];\n    const summaryTables = tableRecords.filter(r => r.summarySourceTable);\n    const summaryTableIds = new Set([...summaryTables.map(t => t.id)]);\n    const numSummaryFormulaColumns = columnRecords.filter(r =>\n      r.isFormula && summaryTableIds.has(r.parentId)).length;\n\n    const viewSectionFields = this.docData?.getMetaTable(\"_grist_Views_section_field\");\n    const viewSectionFieldRecords = viewSectionFields?.getRecords() ?? [];\n    const numFieldsWithConditionalFormatting = viewSectionFieldRecords.filter(r => r.rules).length;\n\n    return {\n      numColumns,\n      numColumnsWithConditionalFormatting,\n      numFormulaColumns,\n      numTriggerFormulaColumns,\n      numSummaryFormulaColumns,\n      numFieldsWithConditionalFormatting,\n    };\n  }\n\n  private _getTableMetrics() {\n    const tables = this.docData?.getMetaTable(\"_grist_Tables\");\n    const tableRecords = tables?.getRecords().filter(r =>\n      r.tableId && !r.tableId.startsWith(\"GristHidden_\")) ?? [];\n    const numTables = tableRecords.length;\n    const numOnDemandTables = tableRecords.filter(r => r.onDemand).length;\n\n    const viewSections = this.docData?.getMetaTable(\"_grist_Views_section\");\n    const viewSectionRecords = viewSections?.getRecords() ?? [];\n    const numTablesWithConditionalFormatting = viewSectionRecords.filter(r => r.rules).length;\n\n    const summaryTables = tableRecords.filter(r => r.summarySourceTable);\n    const numSummaryTables = summaryTables.length;\n\n    return {\n      numTables,\n      numOnDemandTables,\n      numTablesWithConditionalFormatting,\n      numSummaryTables,\n    };\n  }\n\n  private _getCustomWidgetMetrics() {\n    const viewSections = this.docData?.getMetaTable(\"_grist_Views_section\");\n    const viewSectionRecords = viewSections?.getRecords() ?? [];\n    const customWidgetIds: string[] = [];\n    for (const r of viewSectionRecords) {\n      const { customView } = safeJsonParse(r.options, {});\n      if (!customView) { continue; }\n\n      const { pluginId, url } = safeJsonParse(customView, {});\n      if (!url) { continue; }\n\n      const isGristLabsWidget = url.startsWith(commonUrls.gristLabsCustomWidgets);\n      customWidgetIds.push(isGristLabsWidget ? pluginId : \"externalId\");\n    }\n    const numCustomWidgets = customWidgetIds.length;\n\n    return {\n      numCustomWidgets,\n      customWidgetIds,\n    };\n  }\n\n  private async _fetchQueryFromDB(query: ServerQuery, onDemand: boolean): Promise<TableDataAction> {\n    // Expand query to compute formulas (or include placeholders for them).\n    const expandedQuery = expandQuery(query, this.docData!, onDemand);\n    const marshalled = await this.docStorage.fetchQuery(expandedQuery);\n    const table = this.docStorage.decodeMarshalledData(marshalled, query.tableId);\n\n    // Substitute in constant values for errors / placeholders.\n    if (expandedQuery.constants) {\n      for (const colId of Object.keys(expandedQuery.constants)) {\n        const constant = expandedQuery.constants[colId];\n        table[colId] = table[colId].map(() => constant);\n      }\n    }\n    return toTableDataAction(query.tableId, table);\n  }\n\n  private async _fetchQueryFromDataEngine(query: ServerQuery): Promise<TableDataAction> {\n    return this._pyCall(\"fetch_table\", query.tableId, true, query.filters);\n  }\n\n  private async _reportDataEngineMemory() {\n    if (!this._dataEngine || this._shuttingDown) {\n      return;\n    }\n\n    const dataEngine = await this._getEngine();\n    const memoryUsedBytes = await dataEngine.reportMemoryUsage();\n    this._memoryUsedMB = memoryUsedBytes / (1024 * 1024);\n    this._docManager.setMemoryUsedMB(this, this._memoryUsedMB);\n  }\n\n  private async _initializeDocUsage(docSession: OptDocSession) {\n    const promises: Promise<unknown>[] = [];\n    // We'll defer syncing/broadcasting usage until everything is calculated.\n    const options = { syncUsageToDatabase: false, broadcastUsageToClients: false };\n    if (this._docUsage?.dataSizeBytes === undefined) {\n      promises.push(this._updateDataSize(options));\n    }\n    if (this._docUsage?.attachmentsSizeBytes === undefined) {\n      promises.push(this._updateAttachmentsSize(options));\n    }\n    if (promises.length === 0) {\n      this._logDocMetrics(docSession, \"docOpen\");\n      return;\n    }\n\n    try {\n      await Promise.all(promises);\n      this._syncDocUsageToDatabase();\n      await this._broadcastDocUsageToClients();\n    } catch (e) {\n      this._log.warn(docSession, \"failed to initialize doc usage\", e);\n    }\n\n    this._logDocMetrics(docSession, \"docOpen\");\n  }\n\n  private _getAuditEventProperties<Action extends AuditEventAction>(): Partial<AuditEventProperties<Action>> {\n    const org = this.doc?.workspace.org;\n    return {\n      context: {\n        site: org ? pick(org, \"id\") : undefined,\n      },\n    };\n  }\n\n  private _getTelemetryMeta(docSession: OptDocSession | null): TelemetryMetadataByLevel {\n    return merge(\n      getTelemetryMeta(docSession),\n      {\n        limited: {\n          docIdDigest: this._docName,\n        },\n        full: {\n          siteId: this.doc?.workspace.org.id,\n          siteType: this._product?.name,\n        },\n      },\n    );\n  }\n\n  /**\n   * Called before a migration.  Makes sure a back-up is made.\n   */\n  private async _beforeMigration(docSession: OptDocSession, versionType: \"storage\" | \"schema\",\n    currentVersion: number, newVersion: number) {\n    this._migrating++;\n    const label = `migrate-${versionType}-last-v${currentVersion}-before-v${newVersion}`;\n    this._docManager.markAsChanged(this);  // Give backup current time.\n    const location = await this._docManager.makeBackup(this, label);\n    this._log.info(docSession, \"_beforeMigration: backup made with label %s at %s\", label, location);\n    this.emit(\"backupMade\", location);\n  }\n\n  /**\n   * Called after a migration.\n   */\n  private async _afterMigration(docSession: OptDocSession, versionType: \"storage\" | \"schema\",\n    newVersion: number, success: boolean) {\n    this._migrating--;\n    // Mark as changed even if migration is not successful, out of caution.\n    if (!this._migrating) { this._docManager.markAsChanged(this); }\n  }\n\n  /**\n   * Call a method in the sandbox, without checking the _modificationLock.  Calls to\n   * the sandbox are naturally serialized.\n   */\n  private async _rawPyCall(funcName: string, ...varArgs: unknown[]): Promise<any> {\n    const dataEngine = await this._getEngine();\n    try {\n      const data = await dataEngine.pyCall(funcName, ...varArgs);\n      this._lastPyCallResponseSize = dataEngine.getLastResponseNumBytes?.();\n      return data;\n    } catch (e) {\n      if (e instanceof UnavailableSandboxMethodError && this._isSnapshot) {\n        throw new UnavailableSandboxMethodError(\"pyCall is not available in snapshots\");\n      }\n\n      throw e;\n    }\n  }\n\n  /**\n   * Call a method in the sandbox, while checking on the _modificationLock.  If the\n   * lock is held, the call will wait until the lock is released, and then hold\n   * the lock itself while operating.\n   */\n  private _pyCall(funcName: string, ...varArgs: unknown[]): Promise<any> {\n    return this._modificationLock.runExclusive(() => this._rawPyCall(funcName, ...varArgs));\n  }\n\n  private async _getEngine(): Promise<ISandbox> {\n    if (this._shuttingDown) {\n      throw new Error(\"shutting down, data engine unavailable\");\n    }\n    if (this._dataEngine) { return this._dataEngine; }\n\n    this._dataEngine = this._isSnapshot ? this._makeNullEngine() : this._makeEngine();\n    return this._dataEngine;\n  }\n\n  private _getDocumentSettings(): DocumentSettings {\n    const docSettings = this.docData?.docSettings();\n    if (!docSettings) {\n      throw new Error(\"No document settings found\");\n    }\n    return docSettings;\n  }\n\n  private async _updateDocumentSettings(docSessions: OptDocSession, settings: DocumentSettings): Promise<void> {\n    const docInfo = this.docData?.docInfo();\n    if (!docInfo) {\n      throw new Error(\"No document info found\");\n    }\n    await this.applyUserActions(docSessions, [\n      // Use docInfo.id to avoid hard-coding a reference to a specific row id, in case it changes.\n      [\"UpdateRecord\", \"_grist_DocInfo\", docInfo.id, { documentSettings: JSON.stringify(settings) }],\n    ]);\n  }\n\n  private async _makeEngine(): Promise<ISandbox> {\n    // Figure out what kind of engine we need for this document.\n    const preferredPythonVersion = \"3\";\n    return createSandbox({\n      server: this._server,\n      docId: this._docName,\n      preferredPythonVersion,\n      sandboxOptions: {\n        exports: {\n          request: (key: string, args: SandboxRequest) => this._requests.handleSingleRequestWithCache(key, args),\n          guessColInfo,\n          convertFromColumn,\n        },\n      },\n    });\n  }\n\n  private async _makeNullEngine(): Promise<ISandbox> {\n    return new NullSandbox();\n  }\n\n  /**\n   * Throw an error if the provided upload would exceed the total attachment filesize limit for\n   * this document.\n   */\n  private async _assertUploadInfoSizeBelowLimit(upload: UploadInfo) {\n    // Minor flaw: while we don't double-count existing duplicate files in the total size,\n    // we don't check here if any of the uploaded files already exist and could be left out of the calculation.\n    const uploadSizeBytes = sum(upload.files.map(f => f.size));\n    await this._assertAttachmentSizeBelowLimit(uploadSizeBytes, {\n      checkProduct: true,\n      checkInternal: true,\n    });\n  }\n\n  private async _assertAttachmentSizeBelowLimit(uploadSizeBytes: number, options: {\n    checkProduct: boolean,\n    checkInternal: boolean,\n  }) {\n    let currentSize = this._docUsage?.attachmentsSizeBytes;\n    currentSize = currentSize ?? await this._updateAttachmentsSize({ syncUsageToDatabase: false });\n    const futureSize = currentSize + uploadSizeBytes;\n\n    const productMaxSize = this._product?.features?.baseMaxAttachmentsBytesPerDocument;\n\n    if (options.checkProduct && productMaxSize !== undefined && futureSize > productMaxSize) {\n      // TODO probably want a nicer error message here.\n      throw new LimitExceededError(\"Exceeded attachments limit for document\");\n    }\n\n    // If not using external attachments, apply installation limit on size.\n    // Technically the attachmentStoreId being set doesn't mean all attachments\n    // are actually stored externally, and a determined person could\n    // work around this limit at the API level. That's fine, their\n    // reward will be pain.\n    if (options.checkInternal && Deps.MAX_INTERNAL_ATTACHMENTS_BYTES &&\n      futureSize > Deps.MAX_INTERNAL_ATTACHMENTS_BYTES &&\n      !this.docData?.docSettings().attachmentStoreId) {\n      throw new LimitExceededError(\"Exceeded internal attachments limit for document\");\n    }\n  }\n\n  /**\n   * Calculates the total attachments size in bytes, sets it in _docUsage, and returns it.\n   *\n   * Allows specifying `options` for toggling whether usage is synced to\n   * the home database and/or broadcast to clients.\n   */\n  private async _updateAttachmentsSize(options?: UpdateUsageOptions): Promise<number> {\n    const attachmentsSizeBytes = await this.docStorage.getTotalAttachmentFileSizes();\n    await this._updateDocUsage({ attachmentsSizeBytes }, options);\n    return attachmentsSizeBytes;\n  }\n\n  private _getCachedAttachmentColumns(): AttachmentColumns {\n    if (!this.docData) { return new Map(); }\n    if (!this._attachmentColumns) {\n      this._attachmentColumns = getAttachmentColumns(this.docData);\n    }\n    return this._attachmentColumns;\n  }\n\n  /**\n   * Waits for the data engine to be ready before calling `cb`, creating the\n   * engine if needed.\n   *\n   * Optionally shuts down and removes the engine after.\n   *\n   * NOTE: This method should be used with care, particularly when `shutdownAfter`\n   * is set. Currently, it's only used to run migrations on snapshots (which don't\n   * normally start the data engine).\n   */\n  private async _withDataEngine(\n    cb: () => Promise<void>,\n    options: { shutdownAfter?: boolean } = {},\n  ) {\n    const { shutdownAfter } = options;\n    this._dataEngine = this._dataEngine || this._makeEngine();\n    let engine = await this._dataEngine;\n    if (engine instanceof NullSandbox) {\n      // Make sure the current engine isn't a stub, which may be the case when the\n      // document is a snapshot. Normally, `shutdownAfter` will be true in such\n      // scenarios, so we'll revert back to using a stubbed engine after calling `cb`.\n      this._dataEngine = this._makeEngine();\n      engine = await this._dataEngine;\n    }\n\n    try {\n      await cb();\n    } finally {\n      if (shutdownAfter) {\n        await (await this._dataEngine)?.shutdown();\n        this._dataEngine = null;\n      }\n    }\n  }\n\n  private async _onInactive() {\n    if (Deps.ACTIVEDOC_TIMEOUT_ACTION === \"shutdown\") {\n      await this.shutdown({\n        beforeShutdown: async () => {\n          // from sqlite official doc :\n          // The VACUUM command works by copying the contents of the\n          // database into a temporary database file and then overwriting\n          // the original with the contents of the temporary file.\n          // When overwriting the original, a rollback journal or write-ahead\n          // log WAL file is used just as it would be for any other database\n          // transaction. This means that when VACUUMing a database,\n          // as much as twice the size of the original database file is\n          // required in free disk space.\n          // ---\n          // The temporary copy and rollback machanisms must avoid any document\n          // corruption.\n          try {\n            await this.docStorage.vacuum();\n          } catch (err) {\n            if (err.code === \"ENOENT\") {\n              this._log.warn(null, `Vacuum on inactive: Doc ${this.docName} is no longer available`);\n            } else {\n              this._log.warn(null, `Vacuum on inactive: Doc ${this.docName}\\n ${err}`);\n            }\n          }\n        },\n      });\n    }\n  }\n\n  private _getHomeDbManagerOrFail() {\n    const dbManager = this.getHomeDbManager();\n    if (!dbManager) {\n      throw new Error(\"HomeDbManager not available\");\n    }\n\n    return dbManager;\n  }\n\n  private _doStartTiming() {\n    return  this._pyCall(\"start_timing\");\n  }\n\n  private _logForkDocumentEvents(\n    docSession: OptDocSession,\n    { document, fork }: { document: Document; fork: ForkResult },\n  ) {\n    this.logAuditEvent(docSession, {\n      action: \"document.fork\",\n      details: {\n        document: pick(document, \"id\", \"name\"),\n        fork: {\n          id: fork.forkId,\n          document_id: fork.docId,\n          url_id: fork.urlId,\n        },\n      },\n    });\n    this.logTelemetryEvent(docSession, \"documentForked\", {\n      limited: {\n        forkIdDigest: fork.forkId,\n        forkDocIdDigest: fork.docId,\n        trunkIdDigest: document.trunkId,\n        isTemplate: document.type === \"template\",\n        lastActivity: document.updatedAt,\n      },\n    });\n  }\n\n  /**\n   * Register the underlying SQLiteDB we have so that it can\n   * be used for backup operations. It is important to use\n   * the same SQLite connection for all operations.\n   */\n  private _registerSQLiteDB() {\n    this._docManager.registerSQLiteDB(this.docName, this.docStorage.getDB());\n  }\n\n  private _colMetadataRecords(\n    metaTables: { [p: string]: TableDataAction },\n    tableRef: number,\n    includeHidden = false): ColumnMetadata[] {\n    const [, , colRefs, columnData] = metaTables._grist_Tables_column;\n\n    // colId is pulled out of fields and used as the root id\n    const fieldNames = without(Object.keys(columnData), \"colId\");\n\n    const columns: ColumnMetadata[] = [];\n    (columnData.colId as string[]).forEach((id, index) => {\n      const hasNoId = !id;\n      const isHidden = hasNoId || id === \"manualSort\" || id.startsWith(\"gristHelper_\");\n      const fromDifferentTable = columnData.parentId[index] !== tableRef;\n      const skip = (isHidden && !includeHidden) || hasNoId || fromDifferentTable;\n      if (skip) {\n        return;\n      }\n      const column: ColumnMetadata = { id, fields: {\n        colRef: colRefs[index],\n        label: String(columnData.label?.[index] ?? \"\"),\n        isFormula: Boolean(columnData.isFormula?.[index]),\n      } };\n      const otherFieldNames = without(fieldNames, \"label\", \"isFormula\");\n      for (const key of otherFieldNames) {\n        column.fields[key] = columnData[key][index];\n      }\n      columns.push(column);\n    });\n    return columns;\n  }\n}\n\n// Helper to initialize a sandbox action bundle with no values.\nfunction createEmptySandboxActionBundle(): SandboxActionBundle {\n  return {\n    envelopes: [],\n    stored: [],\n    direct: [],\n    calc: [],\n    undo: [],\n    retValues: [],\n    rowCount: { total: 0 },\n  };\n}\n\n// Helper that converts a Grist table id to a ref.\nexport function tableIdToRef(metaTables: { [p: string]: TableDataAction }, tableId: string) {\n  const [, , tableRefs, tableData] = metaTables._grist_Tables;\n  const tableRowIndex = tableData.tableId.indexOf(tableId);\n  if (tableRowIndex === -1) {\n    throw new ApiError(`Table not found \"${tableId}\"`, 404);\n  }\n  return tableRefs[tableRowIndex];\n}\n\n// Helper that converts a Grist column colId to a ref given the corresponding table.\nexport function colIdToRef(metaTables: { [p: string]: TableDataAction }, tableId: string, colId: string) {\n  const tableRef = tableIdToRef(metaTables, tableId);\n\n  const [, , colRefs, columnData] = metaTables._grist_Tables_column;\n  const colRowIndex = columnData.colId.findIndex((_, i) => (\n    columnData.colId[i] === colId && columnData.parentId[i] === tableRef\n  ));\n  if (colRowIndex === -1) {\n    throw new ApiError(`Column not found \"${colId}\"`, 404);\n  }\n  return colRefs[colRowIndex];\n}\n\n// Helper that check if tableRef is used instead of tableId and return real tableId\n// If metaTables is not define, activeDoc and req allow it to be created\ninterface MetaTables {\n  metaTables: { [p: string]: TableDataAction }\n}\ninterface ActiveDocAndReq {\n  activeDoc: ActiveDoc, req: RequestWithLogin\n}\nexport async function getRealTableId(\n  tableId: string,\n  options:  MetaTables | ActiveDocAndReq,\n): Promise<string> {\n  if (parseInt(tableId)) {\n    const metaTables = \"metaTables\" in options ?\n      options.metaTables :\n      await getMetaTables(options.activeDoc, options.req);\n    const [, , tableRefs, tableData] = metaTables._grist_Tables;\n    if (tableRefs.includes(parseInt(tableId))) {\n      const tableRowIndex = tableRefs.indexOf(parseInt(tableId));\n      return tableData.tableId[tableRowIndex]!.toString();\n    }\n  }\n  return tableId;\n}\n\nexport function sanitizeApplyUAOptions(options?: ApplyUAOptions): ApplyUAOptions {\n  return pick(options || {}, [\"desc\", \"otherId\", \"linkId\", \"parseStrings\"]);\n}\n\n/**\n * Create a sandbox in its default initial state and with default logging.\n */\nexport function createSandbox(options: {\n  server: GristServer,\n  docId: string,\n  preferredPythonVersion: \"3\",\n  sandboxOptions?: Partial<ISandboxOptions>,\n}) {\n  const { docId, preferredPythonVersion, sandboxOptions, server } = options;\n  return server.create.NSandbox({\n    comment: docId,\n    logCalls: false,\n    logTimes: true,\n    logMeta: { docId },\n    preferredPythonVersion,\n    sandboxOptions,\n  });\n}\n\n/**\n * Extract telemetry metadata from session.\n */\nfunction getTelemetryMeta(docSession: OptDocSession | null): TelemetryMetadataByLevel {\n  if (!docSession) { return {}; }\n\n  const access = getDocSessionAccessOrNull(docSession);\n  return {\n    limited: {\n      access,\n    },\n    full: {\n      userId: docSession.userId,\n      altSessionId: docSession.altSessionId,\n    },\n  };\n}\n\nexport function attachmentToArchiveFilePath(fileDetails: { fileIdent: string, fileName: string }): string {\n  const fileIdentParts = fileDetails.fileIdent.split(\".\");\n  const fileHash = fileIdentParts[0];\n  const fileIdentExt = path.extname(fileDetails.fileIdent);\n  const fileNameExt = path.extname(fileDetails.fileName);\n  const fileNameNoExt = path.basename(fileDetails.fileName, fileNameExt);\n  // We need to make sure the downloaded attachment's extension matches Grist's internal\n  // file extension, otherwise we can't recreate the file identifier when uploading.\n  // They might not match, as the upload process considers things like mime type when\n  // adding the extension to the file identifier.\n  return `${fileHash}_${fileNameNoExt}${fileIdentExt}`;\n}\n\nexport function archiveFilePathToAttachmentIdent(filePath: string): string {\n  const fileName = path.basename(filePath);\n  const fileHash = fileName.split(\"_\")[0];\n  const fileExt = path.extname(fileName);\n  return `${fileHash}${fileExt}`;\n}\n"
  },
  {
    "path": "app/server/lib/ActiveDocImport.ts",
    "content": "/*  Helper file to separate ActiveDoc import functions and convert them to TypeScript. */\n\nimport { ColumnDelta, createEmptyActionSummary } from \"app/common/ActionSummary\";\nimport { ApplyUAResult, DataSourceTransformed, ImportOptions, ImportResult, ImportTableResult,\n  MergeOptions, MergeOptionsMap, MergeStrategy, SKIP_TABLE,\n  TransformRule,\n  TransformRuleMap } from \"app/common/ActiveDocAPI\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { BulkColValues, CellValue, fromTableDataAction, UserAction } from \"app/common/DocActions\";\nimport { DocStateComparison } from \"app/common/DocState\";\nimport { isBlankValue } from \"app/common/gristTypes\";\nimport * as gutil from \"app/common/gutil\";\nimport { localTimestampToUTC } from \"app/common/RelativeDates\";\nimport { guessColInfoForImports } from \"app/common/ValueGuesser\";\nimport { ParseFileResult, ParseOptions } from \"app/plugin/FileParserAPI\";\nimport { GristColumn, GristTable } from \"app/plugin/GristTable\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { DocSession, OptDocSession } from \"app/server/lib/DocSession\";\nimport { buildComparisonQuery } from \"app/server/lib/ExpandedQuery\";\nimport log from \"app/server/lib/log\";\nimport { globalUploadSet, moveUpload, UploadInfo } from \"app/server/lib/uploads\";\n\nimport * as path from \"path\";\n\nimport flatten from \"lodash/flatten\";\nimport * as _ from \"underscore\";\n\nconst IMPORT_TRANSFORM_COLUMN_PREFIX = \"gristHelper_Import_\";\n\n/*\n * AddTableRetValue contains return value of user actions 'AddTable'\n*/\ninterface AddTableRetValue {\n  table_id: string;\n  id: number;\n  columns: string[];\n  views: object[];\n}\n\ninterface ReferenceDescription {\n  // the table index\n  tableIndex: number;\n  // the column index\n  colIndex: number;\n  // the id of the table which is referenced\n  refTableId: string;\n}\n\nexport interface FileImportOptions {\n  // Suggested name of the import file. It is sometimes used as a suggested table name, e.g. for csv imports.\n  originalFilename: string;\n  // Containing parseOptions as serialized JSON to pass to the import plugin.\n  parseOptions: ParseOptions;\n  // Map of table names to their merge options.\n  mergeOptionsMap: MergeOptionsMap;\n  // Flag to indicate whether table is temporary and hidden or regular.\n  isHidden: boolean;\n  // Index of original dataSource corresponding to current imported file.\n  uploadFileIndex: number;\n  // Map of table names to their transform rules.\n  transformRuleMap: TransformRuleMap;\n}\n\nexport class ActiveDocImport {\n  constructor(private _activeDoc: ActiveDoc) {}\n  /**\n   * Imports files, removes previously created temporary hidden tables and creates the new ones\n   */\n  public async importFiles(docSession: DocSession, dataSource: DataSourceTransformed,\n    parseOptions: ParseOptions, prevTableIds: string[]): Promise<ImportResult> {\n    this._activeDoc.startBundleUserActions(docSession);\n    await this._removeHiddenTables(docSession, prevTableIds);\n    const accessId = this._activeDoc.makeAccessId(docSession.userId);\n    const uploadInfo: UploadInfo = globalUploadSet.getUploadInfo(dataSource.uploadId, accessId);\n    return this._importFiles(docSession, uploadInfo, dataSource.transforms, { parseOptions }, true);\n  }\n\n  /**\n   * Finishes import files, removes temporary hidden tables, temporary uploaded files and creates\n   * the new tables\n   */\n  public async finishImportFiles(docSession: DocSession, dataSource: DataSourceTransformed,\n    prevTableIds: string[], importOptions: ImportOptions): Promise<ImportResult> {\n    this._activeDoc.startBundleUserActions(docSession);\n    try {\n      await this._removeHiddenTables(docSession, prevTableIds);\n      const accessId = this._activeDoc.makeAccessId(docSession.userId);\n      const uploadInfo: UploadInfo = globalUploadSet.getUploadInfo(dataSource.uploadId, accessId);\n      const importResult = await this._importFiles(docSession, uploadInfo, dataSource.transforms,\n        importOptions, false);\n      await globalUploadSet.cleanup(dataSource.uploadId);\n      return importResult;\n    } finally {\n      this._activeDoc.stopBundleUserActions(docSession);\n    }\n  }\n\n  /**\n   * Cancels import files, removes temporary hidden tables and temporary uploaded files\n   *\n   * @param {ActiveDoc} activeDoc: Instance of ActiveDoc.\n   * @param {number} uploadId: Identifier for the temporary uploaded file(s) to clean up.\n   * @param {Array<String>} prevTableIds: Array of tableIds as received from previous `importFiles`\n   *  call when re-importing with changed `parseOptions`.\n   * @returns {Promise} Promise that's resolved when all actions are applied successfully.\n   */\n  public async cancelImportFiles(docSession: DocSession,\n    uploadId: number,\n    prevTableIds: string[]): Promise<void> {\n    await this._removeHiddenTables(docSession, prevTableIds);\n    this._activeDoc.stopBundleUserActions(docSession);\n    await globalUploadSet.cleanup(uploadId);\n  }\n\n  /**\n   * Returns a diff of changes that will be applied to the destination table from `transformRule`\n   * if the data from `hiddenTableId` is imported with the specified `mergeOptions`.\n   *\n   * The diff is returned as a `DocStateComparison` of the same doc, with the `rightChanges`\n   * containing the updated cell values. Old values are pulled from the destination record (if\n   * a match was found), and new values are the result of merging in the new cell values with\n   * the merge strategy from `mergeOptions`.\n   *\n   * No distinction is currently made for added records vs. updated existing records; instead,\n   * we treat added records as an updated record in `hiddenTableId` where all the column\n   * values changed from blank to the original column values from `hiddenTableId`.\n   *\n   * @param {string} hiddenTableId Source table.\n   * @param {TransformRule} transformRule Transform rule for the original source columns.\n   * The destination table id is populated in the rule.\n   * @param {MergeOptions} mergeOptions Merge options for how to match source rows\n   * with destination records, and how to merge their column values.\n   * @returns {Promise<DocStateComparison>} Comparison data for the changes that will occur if\n   * `hiddenTableId` is merged into the destination table from `transformRule`.\n   */\n  public async generateImportDiff(hiddenTableId: string, { destCols, destTableId }: TransformRule,\n    { mergeCols, mergeStrategy }: MergeOptions): Promise<DocStateComparison> {\n    // Merge column ids from client have prefixes that need to be stripped.\n    mergeCols = stripPrefixes(mergeCols);\n\n    // Get column differences between `hiddenTableId` and `destTableId` for rows that exist in both tables.\n    const srcAndDestColIds: [string, string[]][] = destCols.map(c => [c.colId!, stripPrefixes([c.colId!])]);\n    const srcToDestColIds = new Map(srcAndDestColIds);\n    const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId!, srcToDestColIds, mergeCols);\n\n    // Initialize container for updated column values in the expected format (ColumnDelta).\n    const updatedRecords: { [colId: string]: ColumnDelta } = {};\n    const updatedRecordIds: number[] = [];\n    const srcColIds = srcAndDestColIds.map(([srcColId, _destColId]) => srcColId);\n    for (const id of srcColIds) {\n      updatedRecords[id] = {};\n    }\n\n    // Retrieve the function used to reconcile differences between source and destination.\n    const merge = getMergeFunction(mergeStrategy);\n\n    // Destination columns with a blank formula (i.e. skipped columns).\n    const skippedColumnIds = new Set(\n      stripPrefixes(destCols.filter(c => c.formula.trim() === \"\").map(c => c.colId!)),\n    );\n\n    const numResultRows = comparisonResult[hiddenTableId + \".id\"].length;\n    for (let i = 0; i < numResultRows; i++) {\n      const srcRowId = comparisonResult[hiddenTableId + \".id\"][i] as number;\n\n      if (comparisonResult[destTableId + \".id\"][i] === null) {\n        // No match in destination table found for source row, so it must be a new record.\n        for (const srcColId of srcColIds) {\n          updatedRecords[srcColId][srcRowId] = [[\"\"], [(comparisonResult[`${hiddenTableId}.${srcColId}`][i])]];\n        }\n      } else {\n        // Otherwise, a match was found between source and destination tables.\n        for (const srcColId of srcColIds) {\n          const matchingDestColId = srcToDestColIds.get(srcColId)![0];\n          const srcVal = comparisonResult[`${hiddenTableId}.${srcColId}`][i];\n          const destVal = comparisonResult[`${destTableId}.${matchingDestColId}`][i];\n\n          // Exclude unchanged cell values from the comparison.\n          if (srcVal === destVal) { continue; }\n\n          const shouldSkip = skippedColumnIds.has(matchingDestColId);\n          updatedRecords[srcColId][srcRowId] = [\n            [destVal],\n            // For skipped columns, always use the destination value.\n            [shouldSkip ? destVal : merge(srcVal, destVal)],\n          ];\n        }\n      }\n\n      updatedRecordIds.push(srcRowId);\n    }\n\n    return {\n      left: { n: 0, h: \"\" },  // NOTE: left, right, parent, and summary are not used by Importer.\n      right: { n: 0, h: \"\" },\n      parent: null,\n      summary: \"right\",\n      details: {\n        leftChanges: createEmptyActionSummary(),\n        rightChanges: {\n          tableRenames: [],\n          tableDeltas: {\n            [hiddenTableId]: {\n              removeRows: [],\n              updateRows: updatedRecordIds,\n              addRows: [],  // Since deltas are relative to the source table, we can't (yet) use this.\n              columnRenames: [],\n              columnDeltas: updatedRecords,\n            },\n          },\n        },\n      },\n    };\n  }\n\n  /**\n   * Import the given upload as new tables in one step. This does not give the user a chance to\n   * modify parse options or transforms. The caller is responsible for cleaning up the upload.\n   */\n  public async oneStepImport(docSession: OptDocSession, uploadInfo: UploadInfo): Promise<ImportResult> {\n    this._activeDoc.startBundleUserActions(docSession);\n    try {\n      return this._importFiles(docSession, uploadInfo, [], {}, false);\n    } finally {\n      this._activeDoc.stopBundleUserActions(docSession);\n    }\n  }\n\n  /**\n   * Import data resulting from parsing a file into a new table.\n   * In normal circumstances this is only used internally.\n   * It's exposed publicly for use by grist-static which doesn't use the plugin system.\n   */\n  public async importParsedFileAsNewTable(\n    docSession: OptDocSession, optionsAndData: ParseFileResult, importOptions: FileImportOptions,\n  ): Promise<ImportResult> {\n    const { originalFilename, mergeOptionsMap, isHidden, uploadFileIndex, transformRuleMap } = importOptions;\n    const options = optionsAndData.parseOptions;\n\n    const parsedTables = optionsAndData.tables;\n    const references = this._encodeReferenceAsInt(parsedTables);\n\n    const tables: ImportTableResult[] = [];\n    const fixedColumnIdsByTable: { [tableId: string]: string[]; } = {};\n\n    for (const table of parsedTables) {\n      const ext = path.extname(originalFilename);\n      const basename = path.basename(originalFilename, ext).trim();\n      const hiddenTableName = \"GristHidden_import\";\n      const origTableName = table.table_name ? table.table_name : \"\";\n      const transformRule = transformRuleMap?.hasOwnProperty(origTableName) ?\n        transformRuleMap[origTableName] : null;\n      const columnMetadata = cleanColumnMetadata(table.column_metadata, table.table_data, this._activeDoc);\n      const result: ApplyUAResult = await this._activeDoc.applyUserActions(docSession,\n        [[\"AddTable\", hiddenTableName, columnMetadata]]);\n      const retValue: AddTableRetValue = result.retValues[0];\n      const hiddenTableId = retValue.table_id;    // The sanitized version of the table name.\n      const hiddenTableColIds = retValue.columns;      // The sanitized names of the columns.\n\n      // The table_data received from importFile is an array of columns of data, rather than a\n      // dictionary, so that it doesn't depend on column names. We instead construct the\n      // dictionary once we receive the sanitized column names from AddTable.\n      const dataLength = table.table_data[0] ? table.table_data[0].length : 0;\n      log.info(\"Importing table %s, %s rows, from %s\", hiddenTableId, dataLength, table.table_name);\n\n      const rowIdColumn = _.range(1, dataLength + 1);\n      const columnValues = _.object(hiddenTableColIds, table.table_data);\n      const destTableId = transformRule ? transformRule.destTableId : null;\n      const ruleCanBeApplied = (transformRule != null) &&\n        _.difference(transformRule.sourceCols, hiddenTableColIds).length === 0;\n      await this._activeDoc.applyUserActions(docSession,\n        // BulkAddRecord rather than ReplaceTableData so that type guessing is applied to Any columns.\n        // Don't use parseStrings, only use the strict parsing in ValueGuesser to make the import lossless.\n        [[\"BulkAddRecord\", hiddenTableId, rowIdColumn, columnValues]]);\n\n      // data parsed and put into hiddenTableId\n      // For preview_table (isHidden) do GenImporterView to make views and formulas and cols\n      // For final import, call _transformAndFinishImport, which imports file using a transform rule (or blank)\n\n      let createdTableId: string;\n      let transformSectionRef: number = -1; // TODO: we only have this if we genImporterView, is it necessary?\n\n      if (isHidden) {\n        // Generate formula columns, view sections, etc\n        const results: ApplyUAResult = await this._activeDoc.applyUserActions(docSession,\n          [[\"GenImporterView\", hiddenTableId, destTableId, ruleCanBeApplied ? transformRule : null, null]]);\n\n        transformSectionRef = results.retValues[0].viewSectionRef;\n        createdTableId = hiddenTableId;\n      } else {\n        if (destTableId === SKIP_TABLE) {\n          await this._activeDoc.applyUserActions(docSession, [[\"RemoveTable\", hiddenTableId]]);\n          continue;\n        }\n        // Do final import\n        const mergeOptions = mergeOptionsMap[origTableName] ?? null;\n        const intoNewTable: boolean = destTableId ? false : true;\n        const destTable = destTableId || table.table_name || basename;\n        createdTableId = await this._transformAndFinishImport(docSession, hiddenTableId, destTable,\n          intoNewTable, ruleCanBeApplied ? transformRule : null, mergeOptions);\n      }\n\n      fixedColumnIdsByTable[createdTableId] = hiddenTableColIds;\n\n      tables.push({\n        hiddenTableId: createdTableId, // TODO: rename thing?\n        uploadFileIndex,\n        origTableName,\n        transformSectionRef, // TODO: this shouldn't always be needed, and we only get it if genimporttransform\n        destTableId,\n      });\n    }\n\n    await this._fixReferences(docSession, tables, fixedColumnIdsByTable, references, isHidden);\n\n    return ({ options, tables });\n  }\n\n  /**\n   * Imports all files as new tables, using the given transform rules and import options.\n   * The isHidden flag indicates whether to create temporary hidden tables, or final ones.\n   */\n  private async _importFiles(docSession: OptDocSession, upload: UploadInfo, transforms: TransformRuleMap[],\n    { parseOptions = {}, mergeOptionMaps = [] }: ImportOptions,\n    isHidden: boolean): Promise<ImportResult> {\n    // Check that upload size is within the configured limits.\n    const limit = (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || Infinity;\n    const totalSize = upload.files.reduce((acc, f) => acc + f.size, 0);\n    if (totalSize > limit) {\n      throw new ApiError(`Imported files must not exceed ${gutil.byteString(limit)}`, 413);\n    }\n\n    // The upload must be within the plugin-accessible directory. Once moved, subsequent calls to\n    // moveUpload() will return without having to do anything.\n    if (!this._activeDoc.docPluginManager) { throw new Error(\"no plugin manager available\"); }\n    await moveUpload(upload, this._activeDoc.docPluginManager.tmpDir());\n\n    const importResult: ImportResult = { options: parseOptions, tables: [] };\n    for (const [index, file] of upload.files.entries()) {\n      // If we have a better guess for the file's extension, replace it in origName, to ensure\n      // that DocPluginManager has access to it to guess the best parser type.\n      let origName: string = file.origName;\n      if (file.ext) {\n        origName = path.basename(origName, path.extname(origName)) + file.ext;\n      }\n      const fileParseOptions = { ...parseOptions };\n      if (file.ext === \".dsv\") {\n        if (!fileParseOptions.delimiter) {\n          fileParseOptions.delimiter = \"💩\";\n        }\n        if (!fileParseOptions.encoding) {\n          fileParseOptions.encoding = \"utf-8\";\n        }\n      }\n      const res = await this._importFileAsNewTable(docSession, file.absPath, {\n        parseOptions: fileParseOptions,\n        mergeOptionsMap: mergeOptionMaps[index] || {},\n        isHidden,\n        originalFilename: origName,\n        uploadFileIndex: index,\n        transformRuleMap: transforms[index] || {},\n      });\n      if (index === 0) {\n        // Returned parse options from the first file should be used for all files in one upload.\n        importResult.options = parseOptions = res.options;\n      }\n      importResult.tables.push(...res.tables);\n    }\n    return importResult;\n  }\n\n  /**\n   * Imports the data stored at tmpPath.\n   *\n   * Currently it starts a python parser as a child process\n   * outside the sandbox, and supports xlsx, csv, and perhaps some other formats. It may\n   * result in the import of multiple tables, in case of e.g. Excel formats.\n   * @param {OptDocSession} docSession: Session instance to use for importing.\n   * @param {String} tmpPath: The path from of the original file.\n   * @param {FileImportOptions} importOptions: File import options.\n   * @returns {Promise<ImportResult>} with `options` property containing parseOptions as serialized JSON as adjusted\n   * or guessed by the plugin, and `tables`, which is a list of objects with information about\n   * tables, such as `hiddenTableId`, `uploadFileIndex`, `origTableName`, `transformSectionRef`, `destTableId`.\n   */\n  private async _importFileAsNewTable(docSession: OptDocSession, tmpPath: string,\n    importOptions: FileImportOptions): Promise<ImportResult> {\n    const { originalFilename, parseOptions } = importOptions;\n    log.info(\"ActiveDoc._importFileAsNewTable(%s, %s)\", tmpPath, originalFilename);\n    if (!this._activeDoc.docPluginManager) {\n      throw new Error(\"no plugin manager available\");\n    }\n    const optionsAndData: ParseFileResult =\n      await this._activeDoc.docPluginManager.parseFile(tmpPath, originalFilename, parseOptions);\n    return this.importParsedFileAsNewTable(docSession, optionsAndData, importOptions);\n  }\n\n  /**\n   * Imports records from `hiddenTableId` into `destTableId`, transforming the column\n   * values from `hiddenTableId` according to the `transformRule`. Finalizes import when done.\n   *\n   * If `mergeOptions` is present, records from `hiddenTableId` will be \"merged\" into `destTableId`\n   * according to a set of merge columns. Records from both tables that have equal values for all\n   * merge columns are treated as the same record, and will be updated in `destTableId` according\n   * to the strategy specified in `mergeOptions`.\n   *\n   * @param {string} hiddenTableId Source table containing records to be imported.\n   * @param {string} destTableId Destination table that will be updated.\n   * @param {boolean} intoNewTable True if import destination is a new table.\n   * @param {TransformRule|null} transformRule Rules for transforming source columns using formulas\n   * before merging/importing takes place.\n   * @param {MergeOptions|null} mergeOptions Options for how to merge matching records between\n   * the source and destination table.\n   * @returns {string} The table id of the new or updated destination table.\n   */\n  private async _transformAndFinishImport(\n    docSession: OptDocSession,\n    hiddenTableId: string, destTableId: string,\n    intoNewTable: boolean, transformRule: TransformRule | null,\n    mergeOptions: MergeOptions | null,\n  ): Promise<string> {\n    log.info(\"ActiveDocImport._transformAndFinishImport(%s, %s, %s, %s, %s)\",\n      hiddenTableId, destTableId, intoNewTable, transformRule, mergeOptions);\n\n    const transformDestTableId = intoNewTable ? null : destTableId;\n    const result = await this._activeDoc.applyUserActions(docSession, [[\n      \"GenImporterView\", hiddenTableId, transformDestTableId, transformRule,\n      { createViewSection: false, genAll: false, refsAsInts: true },\n    ]]);\n    transformRule = result.retValues[0].transformRule as TransformRule;\n\n    if (!intoNewTable && mergeOptions && mergeOptions.mergeCols.length > 0) {\n      await this._mergeAndFinishImport(docSession, hiddenTableId, destTableId, transformRule, mergeOptions);\n      return destTableId;\n    }\n\n    const hiddenTableData = fromTableDataAction(await this._activeDoc.fetchTable(docSession, hiddenTableId, true));\n    const columnData: BulkColValues = {};\n\n    const srcCols = await this._activeDoc.getTableCols(docSession, hiddenTableId);\n    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion\n    const srcColIds = srcCols.map(c => c.id as string);\n\n    // Only include destination columns that weren't skipped.\n    const destCols = transformRule.destCols.filter(c => c.formula.trim() !== \"\");\n    for (const destCol of destCols) {\n      const formula = destCol.formula.trim();\n      if (!formula) { continue; }\n\n      const srcColId = formula.startsWith(\"$\") && srcColIds.includes(formula.slice(1)) ?\n        formula.slice(1) : IMPORT_TRANSFORM_COLUMN_PREFIX + destCol.colId;\n\n      columnData[destCol.colId!] = hiddenTableData[srcColId];\n    }\n\n    // We no longer need the temporary import table, so remove it.\n    await this._activeDoc.applyUserActions(docSession, [[\"RemoveTable\", hiddenTableId]]);\n\n    // If destination is a new table, we need to create it.\n    if (intoNewTable) {\n      const colSpecs = destCols.map(({ type, colId: id, label, widgetOptions }) =>\n        ({ type, id, label, widgetOptions }));\n      const newTable = await this._activeDoc.applyUserActions(docSession, [[\"AddTable\", destTableId, colSpecs]]);\n      destTableId = newTable.retValues[0].table_id;\n    }\n\n    await this._activeDoc.applyUserActions(docSession,\n      [[\"BulkAddRecord\", destTableId, gutil.arrayRepeat(hiddenTableData.id.length, null), columnData]],\n      // Don't use parseStrings for new tables to make the import lossless.\n      { parseStrings: !intoNewTable });\n\n    return destTableId;\n  }\n\n  /**\n   * Merges matching records from `hiddenTableId` into `destTableId`, and finalizes import.\n   *\n   * @param {string} hiddenTableId Source table containing records to be imported.\n   * @param {string} destTableId Destination table that will be updated.\n   * @param {TransformRule} transformRule Rules for transforming source columns using formulas\n   * before merging/importing takes place.\n   * @param {MergeOptions} mergeOptions Options for how to merge matching records between\n   * the source and destination table.\n   */\n  private async _mergeAndFinishImport(docSession: OptDocSession, hiddenTableId: string, destTableId: string,\n    { destCols, sourceCols }: TransformRule,\n    { mergeCols, mergeStrategy }: MergeOptions): Promise<void> {\n    // Merge column ids from client have prefixes that need to be stripped.\n    mergeCols = stripPrefixes(mergeCols);\n\n    // Get column differences between `hiddenTableId` and `destTableId` for rows that exist in both tables.\n    const srcAndDestColIds: [string, string][] = destCols.map((destCol) => {\n      const formula = destCol.formula.trim();\n      const srcColId = formula.startsWith(\"$\") && sourceCols.includes(formula.slice(1)) ?\n        formula.slice(1) : IMPORT_TRANSFORM_COLUMN_PREFIX + destCol.colId;\n      return [srcColId, destCol.colId!];\n    });\n    const srcToDestColIds = new Map<string, string[]>();\n    srcAndDestColIds.forEach(([srcColId, destColId]) => {\n      if (!srcToDestColIds.has(srcColId)) {\n        srcToDestColIds.set(srcColId, [destColId]);\n      } else {\n        srcToDestColIds.get(srcColId)!.push(destColId);\n      }\n    });\n    const comparisonResult = await this._getTableComparison(hiddenTableId, destTableId, srcToDestColIds, mergeCols);\n\n    // Initialize containers for new and updated records in the expected formats.\n    const newRecords: BulkColValues = {};\n    let numNewRecords = 0;\n    const updatedRecords: BulkColValues = {};\n    const updatedRecordIds: number[] = [];\n\n    // Destination columns with a blank formula (i.e. skipped columns).\n    const skippedColumnIds = new Set(\n      stripPrefixes(destCols.filter(c => c.formula.trim() === \"\").map(c => c.colId!)),\n    );\n\n    // Remove all skipped columns from the map.\n    srcToDestColIds.forEach((destColIds, srcColId) => {\n      srcToDestColIds.set(srcColId, destColIds.filter(id => !skippedColumnIds.has(id)));\n    });\n\n    const destColIds = flatten([...srcToDestColIds.values()]);\n    for (const id of destColIds) {\n      newRecords[id] = [];\n      updatedRecords[id] = [];\n    }\n\n    // Retrieve the function used to reconcile differences between source and destination.\n    const merge = getMergeFunction(mergeStrategy);\n\n    const srcColIds = [...srcToDestColIds.keys()];\n    const numResultRows = comparisonResult[hiddenTableId + \".id\"].length;\n    for (let i = 0; i < numResultRows; i++) {\n      if (comparisonResult[destTableId + \".id\"][i] === null) {\n        // No match in destination table found for source row, so it must be a new record.\n        for (const srcColId of srcColIds) {\n          const matchingDestColIds = srcToDestColIds.get(srcColId);\n          matchingDestColIds!.forEach((id) => {\n            newRecords[id].push(comparisonResult[`${hiddenTableId}.${srcColId}`][i]);\n          });\n        }\n        numNewRecords++;\n      } else {\n        // Otherwise, a match was found between source and destination tables, so we merge their columns.\n        for (const srcColId of srcColIds) {\n          const matchingDestColIds = srcToDestColIds.get(srcColId);\n          const srcVal = comparisonResult[`${hiddenTableId}.${srcColId}`][i];\n          matchingDestColIds!.forEach((id) => {\n            const destVal = comparisonResult[`${destTableId}.${id}`][i];\n            updatedRecords[id].push(merge(srcVal, destVal));\n          });\n        }\n        updatedRecordIds.push(comparisonResult[destTableId + \".id\"][i] as number);\n      }\n    }\n\n    // We no longer need the temporary import table, so remove it.\n    const actions: UserAction[] = [[\"RemoveTable\", hiddenTableId]];\n\n    if (updatedRecordIds.length > 0) {\n      actions.push([\"BulkUpdateRecord\", destTableId, updatedRecordIds, updatedRecords]);\n    }\n\n    if (numNewRecords > 0) {\n      actions.push([\"BulkAddRecord\", destTableId, gutil.arrayRepeat(numNewRecords, null), newRecords]);\n    }\n\n    await this._activeDoc.applyUserActions(docSession, actions, { parseStrings: true });\n  }\n\n  /**\n   * Builds and executes a SQL query that compares common columns from `hiddenTableId`\n   * and `destTableId`, returning matched rows that contain differences between both tables.\n   *\n   * The `mergeCols` parameter defines how rows from both tables are matched; we consider\n   * rows whose columns values for all columns in `mergeCols` to be the same record in both\n   * tables.\n   *\n   * @param {string} hiddenTableId Source table.\n   * @param {string} destTableId Destination table.\n   * @param {Map<string, string[]>} srcToDestColIds Map of source to one or more destination column ids\n   * to include in the comparison results.\n   * @param {string[]} mergeCols List of (destination) column ids to use for matching.\n   * @returns {Promise<BulkColValues} Decoded column values from both tables that were matched, and had differences.\n   */\n  private async _getTableComparison(hiddenTableId: string, destTableId: string, srcToDestColIds: Map<string, string[]>,\n    mergeCols: string[]): Promise<BulkColValues> {\n    const mergeColIds = new Set(mergeCols);\n    const destToSrcMergeColIds = new Map();\n    srcToDestColIds.forEach((destColIds, srcColId) => {\n      const maybeMergeColId = destColIds.find(colId => mergeColIds.has(colId));\n      if (maybeMergeColId !== undefined) {\n        destToSrcMergeColIds.set(maybeMergeColId, srcColId);\n      }\n    });\n\n    const query = buildComparisonQuery(hiddenTableId, destTableId, srcToDestColIds, destToSrcMergeColIds);\n    const result = await this._activeDoc.docStorage.fetchQuery(query);\n    return this._activeDoc.docStorage.decodeMarshalledDataFromTables(result);\n  }\n\n  /**\n   * This function removes temporary hidden tables which were created during the import process\n   *\n   * @param {Array[String]} hiddenTableIds: Array of hidden table ids\n   * @returns {Promise} Promise that's resolved when all actions are applied successfully.\n   */\n  private async _removeHiddenTables(docSession: DocSession, hiddenTableIds: string[]) {\n    if (hiddenTableIds.length !== 0) {\n      await this._activeDoc.applyUserActions(docSession, hiddenTableIds.map(t => [\"RemoveTable\", t]));\n    }\n  }\n\n  /**\n   * Changes every column of references into a column of integers in `parsedTables`. It\n   * returns a list of descriptors of all columns of references.\n   */\n  private _encodeReferenceAsInt(parsedTables: GristTable[]): ReferenceDescription[] {\n    const references = [];\n    for (const [tableIndex, parsedTable] of parsedTables.entries()) {\n      for (const [colIndex, col] of parsedTable.column_metadata.entries()) {\n        const refTableId = gutil.removePrefix(col.type, \"Ref:\");\n        if (refTableId) {\n          references.push({ refTableId, colIndex, tableIndex });\n          col.type = \"Int\";\n        }\n      }\n    }\n    return references;\n  }\n\n  /**\n   * This function fix references that are broken by the change of table id.\n   */\n  private async _fixReferences(docSession: OptDocSession,\n    tables: ImportTableResult[],\n    fixedColumnIds: { [tableId: string]: string[]; },\n    references: ReferenceDescription[],\n    isHidden: boolean) {\n    // collect all new table ids\n    const tablesByOrigName = _.indexBy(tables, \"origTableName\");\n\n    //  gather all of the user actions\n    let userActions: any[] = references.map((ref) => {\n      const fixedTableId = tables[ref.tableIndex].hiddenTableId;\n      return [\n        \"ModifyColumn\",\n        fixedTableId,\n        fixedColumnIds[fixedTableId][ref.colIndex],\n        { type: `Ref:${tablesByOrigName[ref.refTableId].hiddenTableId}` },\n      ];\n    });\n\n    if (isHidden) {\n      userActions = userActions.concat(userActions.map(([, tableId, columnId, colInfo]) => [\n        \"ModifyColumn\", tableId, IMPORT_TRANSFORM_COLUMN_PREFIX + columnId, colInfo]));\n    }\n\n    // apply user actions\n    if (userActions.length) {\n      await this._activeDoc.applyUserActions(docSession, userActions);\n    }\n  }\n}\n\n// Helper function that returns new `colIds` with import prefixes stripped.\nfunction stripPrefixes(colIds: string[]): string[] {\n  return colIds.map(id => id.startsWith(IMPORT_TRANSFORM_COLUMN_PREFIX) ?\n    id.slice(IMPORT_TRANSFORM_COLUMN_PREFIX.length) : id);\n}\n\ntype MergeFunction = (srcVal: CellValue, destVal: CellValue) => CellValue;\n\n/**\n * Returns a function that maps source and destination column values to a single output value.\n *\n * @param {MergeStrategy} mergeStrategy Determines how matching source and destination column values\n * should be reconciled when merging.\n * @returns {MergeFunction} Function that maps column value pairs to a single output value.\n */\nfunction getMergeFunction({ type }: MergeStrategy): MergeFunction {\n  switch (type) {\n    case \"replace-with-nonblank-source\": {\n      return (srcVal, destVal) => isBlankValue(srcVal) ? destVal : srcVal;\n    }\n    case \"replace-all-fields\": {\n      return (srcVal, _destVal) => srcVal;\n    }\n    case \"replace-blank-fields-only\": {\n      return (srcVal, destVal) => isBlankValue(destVal) ? srcVal : destVal;\n    }\n    default: {\n      // Normally, we should never arrive here. If we somehow do, throw an error.\n      const unknownStrategyType: never = type;\n      throw new Error(`Unknown merge strategy: ${unknownStrategyType}`);\n    }\n  }\n}\n\n/**\n * Tweak the column metadata used in the AddTable action.\n * If `columns` is populated with non-blank column ids, adds labels to all\n * columns using the values set for the column ids.\n * For columns of type Any, guess the type and parse data according to it, or mark as empty\n * formula columns when they should be empty.\n * For columns of type DateTime, add the document timezone to the type.\n */\nfunction cleanColumnMetadata(columns: GristColumn[], tableData: unknown[][], activeDoc: ActiveDoc) {\n  return columns.map((c, index) => {\n    const newCol: any = { ...c };\n    if (c.id) {\n      newCol.label = c.id;\n    }\n    if (c.type === \"Any\") {\n      // If import logic left it to us to decide on column type, then use our guessing logic to\n      // pick a suitable type and widgetOptions, and to convert values to it.\n      const origValues = tableData[index] as CellValue[];\n      const { values, colMetadata } = guessColInfoForImports(origValues, activeDoc.docData!);\n      tableData[index] = values;\n      if (colMetadata) {\n        Object.assign(newCol, colMetadata);\n      }\n    }\n    const timezone = activeDoc.docData!.docInfo().timezone;\n    if (c.type === \"DateTime\" && timezone) {\n      newCol.type = `DateTime:${timezone}`;\n      for (const [i, localTimestamp] of tableData[index].entries()) {\n        if (typeof localTimestamp !== \"number\") { continue; }\n\n        tableData[index][i] = localTimestampToUTC(localTimestamp, timezone);\n      }\n    }\n    return newCol;\n  });\n}\n"
  },
  {
    "path": "app/server/lib/ActiveDocUtils.ts",
    "content": "import { SchemaTypes } from \"app/common/schema\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\n\nexport function getTableById(doc: ActiveDoc, id: number) {\n  return getRecordById(doc, \"_grist_Tables\", id);\n}\n\nexport function getTableColumnById(doc: ActiveDoc, id: number) {\n  return getRecordById(doc, \"_grist_Tables_column\", id);\n}\n\nexport function getTableColumnsByTableId(doc: ActiveDoc, tableId: number) {\n  const table = getTableById(doc, tableId);\n  return getDocDataOrThrow(doc)\n    .getMetaTable(\"_grist_Tables_column\")\n    .filterRecords({\n      parentId: table.id,\n    });\n}\n\nexport function getWidgetById(doc: ActiveDoc, id: number) {\n  return getRecordById(doc, \"_grist_Views_section\", id);\n}\n\nexport function getWidgetsByPageId(doc: ActiveDoc, pageId: number) {\n  const page = getRecordById(doc, \"_grist_Views\", pageId);\n  return getDocDataOrThrow(doc)\n    .getMetaTable(\"_grist_Views_section\")\n    .filterRecords({ parentId: page.id });\n}\n\nexport function getDocDataOrThrow(doc: ActiveDoc) {\n  const docData = doc.docData;\n  if (!docData) {\n    throw new Error(\"Document not ready\");\n  }\n\n  return docData;\n}\n\nfunction getRecordById<TableId extends keyof SchemaTypes>(\n  doc: ActiveDoc,\n  tableId: TableId,\n  id: number,\n) {\n  const record = getDocDataOrThrow(doc).getMetaTable(tableId).getRecord(id);\n  if (!record) {\n    throw new Error(`${getRecordName(tableId)} ${id} not found`);\n  }\n\n  return record;\n}\n\nfunction getRecordName(tableId: keyof SchemaTypes) {\n  switch (tableId) {\n    case \"_grist_Tables\": {\n      return \"Table\";\n    }\n    case \"_grist_Tables_column\": {\n      return \"Column\";\n    }\n    case \"_grist_Views_section\": {\n      return \"Widget\";\n    }\n    default: {\n      return \"Record\";\n    }\n  }\n}\n"
  },
  {
    "path": "app/server/lib/AppEndpoint.ts",
    "content": "/**\n * AppServer serves up the main app.html file to the browser. It is the first point of contact of\n * a browser with Grist. It handles sessions, redirect-to-login, and serving up a suitable version\n * of the client-side code.\n */\n\nimport { ApiError } from \"app/common/ApiError\";\nimport { getSlugIfNeeded, parseUrlId, SHARE_KEY_PREFIX } from \"app/common/gristUrls\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME } from \"app/common/Telemetry\";\nimport { Document as APIDocument, PublicDocWorkerUrlInfo } from \"app/common/UserAPI\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser,\n  RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { DocStatus, IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport {\n  customizeDocWorkerUrl, getDocWorkerInfoOrSelfPrefix, getWorker, useWorkerPool,\n} from \"app/server/lib/DocWorkerUtils\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { DocTemplate, GristServer } from \"app/server/lib/GristServer\";\nimport { getCookieDomain } from \"app/server/lib/gristSessions\";\nimport log from \"app/server/lib/log\";\nimport { addOrgToPathIfNeeded, pruneAPIResult, trustOrigin } from \"app/server/lib/requestUtils\";\nimport { ISendAppPageOptions } from \"app/server/lib/sendAppPage\";\n\nimport * as express from \"express\";\nimport pick from \"lodash/pick\";\n\nexport interface AttachOptions {\n  app: express.Application;                 // Express app to which to add endpoints\n  middleware: express.RequestHandler[];     // Middleware to apply for all endpoints except docs and forms\n  docMiddleware: express.RequestHandler[];  // Middleware to apply for doc landing pages\n  formMiddleware: express.RequestHandler[]; // Middleware to apply for form landing pages\n  forceLogin: express.RequestHandler | null;  // Method to force user to login (if logins are possible)\n  docWorkerMap: IDocWorkerMap | null;\n  sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;\n  dbManager: HomeDBManager;\n  plugins: LocalPlugin[];\n  gristServer: GristServer;\n}\n\nexport function attachAppEndpoint(options: AttachOptions): void {\n  const { app, middleware, docMiddleware, formMiddleware, docWorkerMap,\n    forceLogin, sendAppPage, dbManager, plugins, gristServer } = options;\n  // Per-workspace URLs open the same old Home page, and it's up to the client to notice and\n  // render the right workspace.\n  app.get([\"/\", \"/ws/:wsId\", \"/p/:page\"], ...middleware, expressWrap(async (req, res) =>\n    sendAppPage(req, res, { path: \"app.html\", status: 200, config: { plugins }, googleTagManager: \"anon\" })));\n\n  app.get(\"/apiconsole\", expressWrap(async (req, res) =>\n    sendAppPage(req, res, { path: \"apiconsole.html\", status: 200, config: {} })));\n\n  app.get(\"/api/worker/:docId([^/]+)/?*\", expressWrap(async (req, res) => {\n    if (!trustOrigin(req, res)) { throw new Error(\"Unrecognized origin\"); }\n    res.header(\"Access-Control-Allow-Credentials\", \"true\");\n\n    const { selfPrefix, docWorker } = await getDocWorkerInfoOrSelfPrefix(\n      req.params.docId, docWorkerMap, gristServer.getTag(),\n    );\n    const info: PublicDocWorkerUrlInfo = selfPrefix ?\n      { docWorkerUrl: null, docWorkerId: null, selfPrefix } :\n      {\n        docWorkerUrl: customizeDocWorkerUrl(docWorker!.publicUrl, req),\n        docWorkerId: docWorker!.id,\n        selfPrefix: null,\n      };\n    return res.json(info);\n  }));\n\n  // Handler for serving the document landing pages.  Expects the following parameters:\n  //   urlId, slug (optional), remainder\n  // This handler is used for both \"doc/urlId\" and \"urlId/slug\" style endpoints.\n  const docHandler = expressWrap(async (req, res, next) => {\n    if (req.params.slug && req.params.slug === \"app.html\") {\n      // This can happen on a single-port configuration, since \"docId/app.html\" matches\n      // the \"urlId/slug\" pattern.  Luckily the \".\" character is not allowed in slugs.\n      return next();\n    }\n    if (!docWorkerMap) {\n      return await sendAppPage(req, res, { path: \"app.html\", status: 200, config: { plugins },\n        googleTagManager: \"anon\" });\n    }\n    const mreq = req as RequestWithLogin;\n    const urlId = req.params.urlId;\n    let doc: Document | null = null;\n    try {\n      const userId = getUserId(mreq);\n\n      // Query DB for the doc metadata, to include in the page (as a pre-fetch of getDoc() call),\n      // and to get fresh (uncached) access info.\n      doc = await dbManager.getDoc({ userId, org: mreq.org, urlId });\n      if (isAnonymousUser(mreq) && doc.type === \"tutorial\") {\n        // Tutorials require users to be signed in.\n        throw new ApiError(\"You must be signed in to access a tutorial.\", 403);\n      }\n\n      const slug = getSlugIfNeeded(doc);\n      const slugMismatch = (req.params.slug || null) !== (slug || null);\n      const preferredUrlId = doc.urlId || doc.id;\n      if (!req.params.viaShare &&  // Don't bother canonicalizing for shares yet.\n        (urlId !== preferredUrlId || slugMismatch)) {\n        // Prepare to redirect to canonical url for document.\n        // Preserve any query parameters or fragments.\n        const queryOrFragmentCheck = req.originalUrl.match(/([#?].*)/);\n        const queryOrFragment = queryOrFragmentCheck?.[1] || \"\";\n        const target = slug ?\n          `/${preferredUrlId}/${slug}${req.params.remainder}${queryOrFragment}` :\n          `/doc/${preferredUrlId}${req.params.remainder}${queryOrFragment}`;\n        res.redirect(addOrgToPathIfNeeded(req, target));\n        return;\n      }\n\n      // The docAuth value will be cached from the getDoc() above (or could be derived from doc).\n      const docAuth = await dbManager.getDocAuthCached({ userId, org: mreq.org, urlId });\n      assertAccess(\"viewers\", docAuth);\n    } catch (err) {\n      if (err.status === 404) {\n        log.info(\"/:urlId/app.html did not find doc\", mreq.userId, urlId, doc?.access, mreq.org);\n        throw new ApiError(\"Document not found.\", 404);\n      } else if (err.status === 403) {\n        log.info(\"/:urlId/app.html denied access\", mreq.userId, urlId, doc?.access, mreq.org);\n        // If the user does not have access to the document, and is anonymous, and we\n        // have a login system, we may wish to redirect them to login process.\n        if (isAnonymousUser(mreq) && forceLogin) {\n          // First check if anonymous user has access to this org.  If so, we don't propose\n          // that they log in.  This is the same check made in redirectToLogin() middleware.\n          const result = await dbManager.getOrg({ userId: getUserId(mreq) }, mreq.org || null);\n          if (result.status !== 200 || doc?.type === \"tutorial\") {\n            // Anonymous user does not have any access to this org, doc, or tutorial.\n            // Redirect to log in.\n            return forceLogin(req, res, next);\n          }\n        }\n        if (err.code === \"AUTH_DOC_DISABLED\") {\n          throw new ApiError(req.t(\"access.docDisabled\"), 403);\n        }\n\n        throw new ApiError(req.t(\"access.docNoAccess\"), 403);\n      }\n      throw err;\n    }\n\n    let body: DocTemplate;\n    let docStatus: DocStatus | undefined;\n    const docId = doc.id;\n    if (!useWorkerPool()) {\n      body = await gristServer.getDocTemplate();\n    } else {\n      // The reason to pass through app.html fetched from docWorker is in case it is a different\n      // version of Grist (could be newer or older).\n      // TODO: More must be done for correct version tagging of URLs: <base href> assumes all\n      // links and static resources come from the same host, but we'll have Home API, DocWorker,\n      // and static resources all at hostnames different from where this page is served.\n      // TODO docWorkerMain needs to serve app.html, perhaps with correct base-href already set.\n      const headers = {\n        Accept: \"application/json\",\n        ...getTransitiveHeaders(req, { includeOrigin: true }),\n      };\n      const workerInfo = await getWorker(docWorkerMap, docId, `/${docId}/app.html`, { headers });\n      docStatus = workerInfo.docStatus;\n      body = await workerInfo.resp.json();\n    }\n    logOpenDocumentEvents(mreq, { server: gristServer, doc, urlId });\n    if (doc.type === \"template\") {\n      // Keep track of the last template a user visited in the last hour.\n      // If a sign-up occurs within that time period, we'll know which\n      // template, if any, was viewed most recently.\n      const value = {\n        isAnonymous: isAnonymousUser(mreq),\n        templateId: docId,\n      };\n      res.cookie(TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME, JSON.stringify(value), {\n        maxAge: 1000 * 60 * 60,\n        httpOnly: true,\n        path: \"/\",\n        domain: getCookieDomain(req),\n        sameSite: \"lax\",\n      });\n    }\n\n    // Without a public URL, we're in single server mode.\n    // Use a null workerPublicURL, to signify that the URL prefix serving the\n    // current endpoint is the only one available.\n    const publicUrl = docStatus?.docWorker?.publicUrl;\n    const workerPublicUrl = publicUrl !== undefined ? customizeDocWorkerUrl(publicUrl, req) : null;\n\n    await sendAppPage(req, res, { path: \"\", content: body.page, tag: body.tag, status: 200,\n      googleTagManager: \"anon\", config: {\n        assignmentId: docId,\n        getWorker: { [docId]: workerPublicUrl },\n        getDoc: { [docId]: pruneAPIResult(doc as unknown as APIDocument) },\n        plugins,\n      } });\n  });\n  // Handlers for form preview URLs: one with a slug and one without.\n  app.get(\"/doc/:urlId([^/]+)/f/:vsId\", ...docMiddleware, expressWrap(async (req, res) => {\n    return sendAppPage(req, res, { path: \"form.html\", status: 200, config: {}, googleTagManager: \"anon\" });\n  }));\n  app.get(\"/:urlId([^-/]{12,})/:slug([^/]+)/f/:vsId\", ...docMiddleware, expressWrap(async (req, res) => {\n    return sendAppPage(req, res, { path: \"form.html\", status: 200, config: {}, googleTagManager: \"anon\" });\n  }));\n  // Handler for form URLs that include a share key.\n  app.get(\"/forms/:shareKey([^/]+)/:vsId\", ...formMiddleware, expressWrap(async (req, res) => {\n    return sendAppPage(req, res, { path: \"form.html\", status: 200, config: {}, googleTagManager: \"anon\" });\n  }));\n  // The * is a wildcard in express 4, rather than a regex symbol.\n  // See https://expressjs.com/en/guide/routing.html\n  app.get(\"/doc/:urlId([^/]+):remainder(*)\", ...docMiddleware, docHandler);\n  app.get(\"/s/:urlId([^/]+):remainder(*)\",\n    (req, res, next) => {\n      // /s/<key> is another way of writing /doc/<prefix><key> for shares.\n      req.params.urlId = SHARE_KEY_PREFIX + req.params.urlId;\n      req.params.viaShare = \"1\";\n      next();\n    },\n    ...docMiddleware, docHandler);\n  app.get(\"/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?\",\n    ...docMiddleware, docHandler);\n}\n\nfunction logOpenDocumentEvents(req: RequestWithLogin, options: {\n  server: GristServer;\n  doc: Document;\n  urlId: string;\n}) {\n  const { server, doc, urlId } = options;\n  const { forkId, snapshotId } = parseUrlId(urlId);\n  server.getAuditLogger().logEvent(req, {\n    action: \"document.open\",\n    context: {\n      site: pick(doc.workspace.org, \"id\", \"name\", \"domain\"),\n    },\n    details: {\n      document: {\n        ...pick(doc, \"id\", \"name\"),\n        url_id: urlId,\n        fork_id: forkId,\n        snapshot_id: snapshotId,\n      },\n    },\n  });\n\n  const isPublic = ((doc as unknown) as APIDocument).public ?? false;\n  const isTemplate = doc.type === \"template\";\n  if (isPublic || isTemplate) {\n    server.getTelemetry().logEvent(req, \"documentOpened\", {\n      limited: {\n        docIdDigest: doc.id,\n        access: doc.access,\n        isPublic,\n        isSnapshot: Boolean(snapshotId),\n        isTemplate,\n        lastUpdated: doc.updatedAt,\n      },\n      full: {\n        siteId: doc.workspace.org.id,\n        siteType: doc.workspace.org.billingAccount.product.name,\n        userId: req.userId,\n        altSessionId: req.altSessionId,\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "app/server/lib/AppSettings.ts",
    "content": "import { isAffirmative, isNumber } from \"app/common/gutil\";\n\ntype EnvFile = Record<string, string>;\n\n/**\n * A bundle of settings for the application. May contain\n * a value directly, and/or via nested settings. Also\n * may have some information about where we looked for\n * the value, for reporting as a diagnostic.\n */\nexport class AppSettings {\n  private _value?: JSONValue;\n  private _children?: { [key: string]: AppSettings };\n  private _info?: AppSettingQueryResult;\n  private _envFile?: EnvFile;\n  private get _root(): AppSettings {\n    return this._parent?._root ?? this;\n  }\n\n  public constructor(public readonly name: string, private _parent?: AppSettings) {\n\n  }\n\n  /**\n   * Set the env file stack to a specific value, replacing any previous stack.\n   */\n  public setEnvVars(envVars: EnvFile): void {\n    if (this._parent) {\n      throw new Error(\"setEnvFile should be called on root AppSettings object\");\n    }\n    this._envFile = envVars;\n  }\n\n  /* access the setting - undefined if not set */\n  public get(): JSONValue | undefined {\n    return this._value;\n  }\n\n  /* access the setting as a boolean using isAffirmative - undefined if not set */\n  public getAsBool(): boolean | undefined {\n    return (this._value !== undefined) ? isAffirmative(this._value) : undefined;\n  }\n\n  /**\n   * Access the setting as an integer using parseInt. Undefined if not set.\n   * Throws an error if not numberlike.\n   */\n  public getAsInt(): number | undefined {\n    if (this._value === undefined) { return undefined; }\n    const datum = this._value?.valueOf();\n    if (typeof datum === \"number\") {\n      return datum;\n    }\n    if (isNumber(String(datum))) {\n      return parseInt(String(datum), 10);\n    }\n    throw new Error(`${datum} does not look like a number`);\n  }\n\n  /**\n   * Access the setting as an integer using parseFloat. Undefined if not set.\n   * Throws an error if not numberlike.\n   */\n  public getAsFloat(): number | undefined {\n    if (this._value === undefined) { return undefined; }\n    const datum = this._value?.valueOf();\n    if (typeof datum === \"number\") {\n      return datum;\n    }\n    if (isNumber(String(datum))) {\n      return parseFloat(String(datum));\n    }\n    throw new Error(`${datum} does not look like a number`);\n  }\n\n  /**\n   * Try to read the setting from the environment. Even if\n   * we fail, we record information about how we tried to\n   * find the setting, so we can report on that.\n   */\n  public read(query: AppSettingQuery) {\n    this._value = undefined;\n    this._info = undefined;\n    let value = undefined;\n    let found = false;\n    let source: \"env\" | \"db\" | undefined = undefined;\n\n    const envVars = getEnvVarsFromQuery(query);\n    if (!envVars.length) {\n      throw new Error(\"could not find an environment variable to read\");\n    }\n\n    const sources = [{ name: \"env\", vars: process.env }];\n    if (this._root._envFile) {\n      sources.push({ name: \"db\", vars: this._root._envFile });\n    }\n\n    let envVar = envVars[0];\n    for (const { name, vars } of sources) {\n      for (const synonym of envVars) {\n        value = vars[synonym];\n        if (value !== undefined) {\n          envVar = synonym;\n          found = true;\n          source = name as any;\n          break;\n        }\n      }\n      if (found) { break; }\n    }\n\n    this._info = {\n      envVar: found ? envVar : undefined,\n      found,\n      source,\n      query,\n    };\n    if (value !== undefined) {\n      this._value = value;\n    } else if (query.defaultValue !== undefined) {\n      this._value = query.defaultValue;\n    }\n    if (query.acceptedValues && this._value) {\n      if (query.acceptedValues.every(v => v !== this._value)) {\n        throw new Error(`value is not accepted: ${this._value}`);\n      }\n    }\n    return this;\n  }\n\n  /**\n   * As for read() but type the result as a string.\n   */\n  public readString(query: AppSettingQuery): string | undefined {\n    this.read(query);\n    if (this._value === undefined) { return undefined; }\n    this._value = String(this._value);\n    return this._value;\n  }\n\n  /**\n   * As for readString() but fail if nothing was found.\n   */\n  public requireString(query: AppSettingQuery): string {\n    const result = this.readString(query);\n    if (result === undefined) {\n      throw new Error(`missing environment variable: ${query.envVar}`);\n    }\n    return result;\n  }\n\n  /**\n   * As for readInt() but fail if nothing was found.\n   */\n  public requireInt(query: AppSettingQueryNumber): number {\n    const result = this.readInt(query);\n    if (result === undefined) {\n      throw new Error(`missing environment variable: ${query.envVar}`);\n    }\n    return result;\n  }\n\n  /**\n   * As for readFloat() but fail if nothing was found.\n   */\n  public requireFloat(query: AppSettingQueryNumber): number {\n    const result = this.readFloat(query);\n    if (result === undefined) {\n      throw new Error(`missing environment variable: ${query.envVar}`);\n    }\n    return result;\n  }\n\n  /**\n   * As for read() but type (and store, and report) the result as\n   * a boolean.\n   */\n  public readBool(query: AppSettingQuery): boolean | undefined {\n    this.readString(query);\n    const result = this.getAsBool();\n    this._value = result;\n    return result;\n  }\n\n  /**\n   * As for read() but type (and store, and report) the result as\n   * an integer.\n   */\n  public readInt(query: AppSettingQueryNumber): number | undefined {\n    this.readString(query);\n    const result = this.getAsInt();\n\n    if (result !== undefined) {\n      if (query.minValue !== undefined && result < query.minValue) {\n        throw new Error(`value ${result} is less than minimum ${query.minValue}`);\n      }\n      if (query.maxValue !== undefined && result > query.maxValue) {\n        throw new Error(`value ${result} is greater than maximum ${query.maxValue}`);\n      }\n    }\n\n    this._value = result;\n    return result;\n  }\n\n  /**\n   * As for read() but type (and store, and report) the result as\n   * a float.\n   */\n  public readFloat(query: AppSettingQueryNumber): number | undefined {\n    this.readString(query);\n    const result = this.getAsFloat();\n\n    if (result !== undefined) {\n      if (query.minValue !== undefined && result < query.minValue) {\n        throw new Error(`value ${result} is less than minimum ${query.minValue}`);\n      }\n      if (query.maxValue !== undefined && result > query.maxValue) {\n        throw new Error(`value ${result} is greater than maximum ${query.maxValue}`);\n      }\n    }\n\n    this._value = result;\n    return result;\n  }\n\n  /* set this setting 'manually' */\n  public set(value: JSONValue): void {\n    this._value = value;\n    this._info = undefined;\n  }\n\n  /* access any nested settings */\n  public get nested(): { [key: string]: AppSettings } {\n    return this._children || {};\n  }\n\n  /**\n   * Add a named nested setting, returning an AppSettings\n   * object that can be used to access it. This method is\n   * named \"section\" to suggest that the nested setting\n   * will itself contain multiple settings, but doesn't\n   * require that.\n   */\n  public section(fname: string): AppSettings {\n    if (!this._children) { this._children = {}; }\n    let child = this._children[fname];\n    if (!child) {\n      this._children[fname] = child = new AppSettings(fname, this);\n    }\n    return child;\n  }\n\n  /**\n   * Add a named nested setting, returning an AppSettings\n   * object that can be used to access it. This method is\n   * named \"flag\" to suggest that tthe nested setting will\n   * not iself be nested, but doesn't require that - it is\n   * currently just an alias for the section() method.\n   */\n  public flag(fname: string): AppSettings {\n    return this.section(fname);\n  }\n\n  /**\n   * Produce a summary description of the setting and how it was\n   * derived.\n   */\n  public describe(): AppSettingDescription {\n    return {\n      name: this.name,\n      value: (this._info?.query?.censor && this._value !== undefined) ? \"*****\" : this._value,\n      foundInEnvVar: this._info?.envVar,\n      source: this._info?.source,\n      wouldFindInEnvVar: this._info?.query.preferredEnvVar ? getEnvVarsFromQuery(this._info.query)[0] : undefined,\n      usedDefault: this._value !== undefined && this._info !== undefined && !this._info?.found,\n    };\n  }\n\n  /**\n   * As for describe(), but include all nested settings also.\n   * Used dotted notation for setting names. Omit settings that\n   * are undefined and without useful information about how they\n   * might be defined. Sort alphabetically.\n   */\n  public describeAll(): AppSettingDescription[] {\n    const inv: AppSettingDescription[] = [];\n    inv.push(this.describe());\n    if (this._children) {\n      for (const child of Object.values(this._children)) {\n        for (const item of child.describeAll()) {\n          inv.push({ ...item, name: this.name + \".\" + item.name });\n        }\n      }\n    }\n    return inv.filter(item => item.value !== undefined ||\n      item.wouldFindInEnvVar !== undefined ||\n      item.usedDefault).sort((a, b) => a.name.localeCompare(b.name));\n  }\n}\n\n/**\n * A global object for Grist application settings.\n */\nexport const appSettings = new AppSettings(\"grist\");\n\n/**\n * Hints for how to define a setting, including possible\n * environment variables and default values.\n */\nexport interface AppSettingQuery {\n  /**\n   * Environment variable(s) to check.\n   */\n  envVar: string | string[];\n  /**\n   * \"Canonical\" environment variable to suggest. Should be in envVar (though this is not checked).\n   */\n  preferredEnvVar?: string;\n  /**\n   * Value to use if the variable(s) is/are unavailable.\n   */\n  defaultValue?: JSONValue;\n  /**\n   * When set to true, the value is obscured when printed.\n   */\n  censor?: boolean;\n\n  acceptedValues?: JSONValue[];\n}\n\nexport interface AppSettingQueryNumber extends AppSettingQuery {\n  /**\n   * Value to use if variable(s) unavailable.\n   */\n  defaultValue?: number;\n  /**\n   * Minimum value allowed. Raises an error if the value is lower than this.\n   * If the value is undefined, the setting is not checked.\n   */\n  minValue?: number;\n  /**\n   * Maximum value allowed. Raises an error if the value is greater than this.\n   * If the value is undefined, the setting is not checked.\n   */\n  maxValue?: number;\n}\n\n/**\n * Result of a query specifying whether the setting\n * was found, and if so in what environment variable, and using\n * what query.\n */\nexport interface AppSettingQueryResult {\n  found: boolean;\n  query: AppSettingQuery;\n  envVar?: string;\n  source?: \"env\" | \"db\";\n}\n\n/**\n * Output of AppSettings.describe().\n */\ninterface AppSettingDescription {\n  name: string;            // name of the setting.\n  value?: JSONValue;       // value of the setting, if available.\n  source?: \"env\" | \"db\";         // source of the setting, if available, available values: 'env', 'db'.\n  foundInEnvVar?: string;  // environment variable the setting was read from, if available.\n  wouldFindInEnvVar?: string;  // environment variable that would be checked for the setting.\n  usedDefault: boolean;    // whether a default value was used for the setting.\n}\n\n// Helper function to normalize the AppSettingQuery.envVar list.\nfunction getEnvVarsFromQuery(q?: AppSettingQuery): string[] {\n  if (!q) { return []; }\n  return Array.isArray(q.envVar) ? q.envVar : [q.envVar];\n}\n\n// Keep app settings JSON-like, in case later we decide to load them from\n// a JSON source.\ntype JSONValue = string | number | boolean | null | { [member: string]: JSONValue } | JSONValue[];\n"
  },
  {
    "path": "app/server/lib/Archive.ts",
    "content": "import { drainWhenSettled } from \"app/server/utils/streams\";\n\nimport stream from \"node:stream\";\n\nimport { ZipArchiveEntry } from \"compress-commons\";\nimport * as tar from \"tar-stream\";\nimport ZipStream, { ZipStreamOptions } from \"zip-stream\";\n\nexport interface ArchiveEntry {\n  name: string;\n  size: number;\n  data: stream.Readable | Buffer;\n}\n\nexport interface ArchivePackingOptions {\n  // Whether the destination stream should be closed once the archive has been written.\n  endDestStream: boolean;\n}\n\nconst defaultPackingOptions: ArchivePackingOptions = {\n  endDestStream: true,\n};\n\nexport interface Archive {\n  mimeType: string;\n  fileExtension: string;\n  /**\n   * Starts packing files into the archive.\n   * This will block indefinitely if the data stream is never read from.\n   * This resolves when all files are processed, or an error occurs.\n   * @returns {Promise<void>}\n   */\n  packInto: (destination: stream.Writable, options?: ArchivePackingOptions) => Promise<void>;\n}\n\n/**\n *\n * Creates a streamable zip archive, reading files on-demand from the entries iterator.\n * Entries are provided as an async iterable, to ensure the archive is constructed\n * correctly. A generator can be used for convenience.\n * @param {ZipStreamOptions} zipOptions - Settings for the zip archive\n * @param {AsyncIterable<ArchiveEntry>} entries - Entries to add.\n * @returns {Archive}\n */\nexport function create_zip_archive(\n  zipOptions: ZipStreamOptions, entries: AsyncIterable<ArchiveEntry>,\n): Archive {\n  return {\n    mimeType: \"application/zip\",\n    fileExtension: \"zip\",\n    async packInto(destination: stream.Writable, options: ArchivePackingOptions = defaultPackingOptions) {\n      const archive = new ZipStream(zipOptions);\n      const pipeline = stream.promises.pipeline(archive, destination, { end: options.endDestStream });\n\n      // This can hang indefinitely in various error cases (e.g. `destination` stream closes unexpectedly).\n      // `pipeline` should still resolve correctly, but none of the code in this block is guaranteed to execute.\n      addEntriesToZipArchive(archive, entries)\n        .then(() => archive.finish())\n        .catch(err => archive.destroy(err));\n\n      // This ensures any errors in the stream (e.g. from destroying it above) are propagated.\n      await pipeline;\n    },\n  };\n}\n\n// Asynchronously iterating entries - and trying to add them to the archive.\n// Warning: This function may hang indefinitely if the archive stream errors. DO NOT AWAIT IT.\n// This is due to the underlying ZipStream \"pumping\" the entry queue to keep adding entries.\n// In some circumstances the callback doesn't fire (e.g. there's a downstream error).\nasync function addEntriesToZipArchive(archive: ZipStream, entries: AsyncIterable<ArchiveEntry>): Promise<void> {\n  // ZipStream will break if multiple entries try to be added at the same time.\n  for await (const entry of entries) {\n    await addEntryToZipArchive(archive, entry);\n  }\n}\n\nfunction addEntryToZipArchive(archive: ZipStream, file: ArchiveEntry): Promise<ZipArchiveEntry | undefined> {\n  return new Promise((resolve, reject) => {\n    archive.on(\"error\", function(err) {\n      reject(new Error(`Archive error: ${err}`, { cause: err }));\n    });\n    archive.entry(file.data, { name: file.name }, function(err, entry) {\n      if (err) {\n        return reject(err);\n      }\n      return resolve(entry);\n    });\n  });\n}\n\n/**\n *\n * Creates a streamable tar archive, reading files on-demand from the entries iterator.\n * Entries are provided as an async iterable, to ensure the archive is constructed\n * correctly. A generator can be used for convenience.\n * @param {AsyncIterable<ArchiveEntry>} entries - Entries to add.\n * @returns {Archive}\n */\nexport function create_tar_archive(\n  entries: AsyncIterable<ArchiveEntry>,\n): Archive {\n  return {\n    mimeType: \"application/x-tar\",\n    fileExtension: \"tar\",\n    async packInto(destination: stream.Writable, options: ArchivePackingOptions = defaultPackingOptions) {\n      const archive = tar.pack();\n      const passthrough = new stream.PassThrough();\n      // 'end' prevents `destination` being closed when completed, or if an error occurs in archive.\n      // Passthrough stream is needed as the tar-stream library doesn't implement the 'end' parameter,\n      // piping to the passthrough stream fixes this and prevents `destination` being closed.\n      const pipeline = stream.promises.pipeline(archive, passthrough, destination,\n        { end: options.endDestStream });\n\n      // Zip packing had issues where adding archive entries could hang indefinitely in error states.\n      // While that hasn't been observed with .tar archives, this block isn't awaited as a precaution.\n      addEntriesToTarArchive(archive, entries)\n        .then(() => archive.finalize())\n        .catch(err => archive.destroy(err));\n\n      // This ensures any errors in the stream (e.g. from destroying it above) are handled.\n      // Without this, node will see the stream as having an uncaught error, and complain or crash.\n      await pipeline;\n    },\n  };\n}\n\nasync function addEntriesToTarArchive(archive: tar.Pack, entries: AsyncIterable<ArchiveEntry>): Promise<void> {\n  // ZipStream will break if multiple entries try to be added at the same time.\n  for await (const entry of entries) {\n    const entryStream = archive.entry({ name: entry.name, size: entry.size });\n    await stream.promises.pipeline(entry.data, entryStream);\n  }\n}\n\nexport interface UnpackedFile {\n  path: string;\n  data: stream.Readable;\n  size: number;\n}\n\nexport async function unpackTarArchive(\n  tarStream: stream.Readable,\n  onFile: (file: UnpackedFile) => Promise<void>,\n): Promise<void> {\n  let resolveFinished = () => {};\n  let rejectFinished = (err: any) => {};\n  const finished = new Promise<void>((resolve, reject) => {\n    resolveFinished = resolve;\n    rejectFinished = reject;\n  });\n\n  const extractor = tar.extract();\n\n  extractor.on(\"entry\", function(header, contentStream, next) {\n    // Ensures contentStream is drained when onFile is finished.\n    // Failure to drain contentStream will block the whole extraction.\n    drainWhenSettled(contentStream,\n      onFile({\n        path: header.name,\n        data: contentStream,\n        // Realistically this should never be undefined - it's mandatory for files in a .tar archive\n        size: header.size ?? 0,\n      }),\n      // No sensible behaviour when an error is thrown by onFile - it's onFile's responsibility\n      // to handle it.\n    ).catch(() => {})\n      .finally(() => { next(); });\n  });\n\n  extractor.on(\"error\", (err: any) => { rejectFinished(err); });\n  extractor.on(\"finish\", () => { resolveFinished(); });\n\n  tarStream.pipe(extractor);\n\n  return finished;\n}\n"
  },
  {
    "path": "app/server/lib/Assistant.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { AssistantProvider } from \"app/common/Assistant\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { OptDocSession } from \"app/server/lib/DocSession\";\nimport {\n  AssistantV1Options,\n  AssistantV2Options,\n} from \"app/server/lib/IAssistant\";\nimport log from \"app/server/lib/log\";\nimport { getLogMeta } from \"app/server/lib/sessionUtils\";\n\nimport { createHash } from \"crypto\";\n\nexport function getAssistantV1Options(): AssistantV1Options {\n  const apiKey = appSettings\n    .section(\"assistant\")\n    .flag(\"apiKey\")\n    .readString({\n      envVar: [\"ASSISTANT_API_KEY\", \"OPENAI_API_KEY\"],\n      preferredEnvVar: \"ASSISTANT_API_KEY\",\n      censor: true,\n    });\n  const completionEndpoint = appSettings\n    .section(\"assistant\")\n    .flag(\"chatCompletionEndpoint\")\n    .readString({\n      envVar: \"ASSISTANT_CHAT_COMPLETION_ENDPOINT\",\n    });\n  const model = appSettings.section(\"assistant\").flag(\"model\").readString({\n    envVar: \"ASSISTANT_MODEL\",\n  });\n  const longerContextModel = appSettings\n    .section(\"assistant\")\n    .flag(\"longerContextModel\")\n    .readString({\n      envVar: \"ASSISTANT_LONGER_CONTEXT_MODEL\",\n    });\n  const maxTokens = appSettings.section(\"assistant\").flag(\"maxTokens\").readInt({\n    envVar: \"ASSISTANT_MAX_TOKENS\",\n    minValue: 1,\n  });\n  return {\n    apiKey,\n    completionEndpoint,\n    model,\n    longerContextModel,\n    maxTokens,\n  };\n}\n\nexport function getAssistantV2Options(): AssistantV2Options {\n  const maxToolCalls = appSettings\n    .section(\"assistant\")\n    .flag(\"maxToolCalls\")\n    .readInt({\n      envVar: \"ASSISTANT_MAX_TOOL_CALLS\",\n      minValue: 0,\n    });\n  const structuredOutput = appSettings\n    .section(\"assistant\")\n    .flag(\"structuredOutput\")\n    .readBool({\n      envVar: \"ASSISTANT_STRUCTURED_OUTPUT\",\n      defaultValue: false,\n    });\n  return {\n    ...getAssistantV1Options(),\n    maxToolCalls,\n    structuredOutput,\n  };\n}\n\nexport function getProviderFromHostname(url: string): AssistantProvider {\n  let hostname: string;\n  try {\n    hostname = new URL(url).hostname;\n  } catch {\n    return null;\n  }\n\n  switch (hostname) {\n    case \"api.openai.com\": {\n      return \"OpenAI\";\n    }\n    default: {\n      return \"Unknown\";\n    }\n  }\n}\n\nexport function getUserHash(session: OptDocSession): string {\n  const user = session.fullUser;\n  // Make it a bit harder to guess the user ID.\n  const salt = \"7a8sb6987asdb678asd687sad6boas7f8b6aso7fd\";\n  const hashSource = `${user?.id} ${user?.ref} ${salt}`;\n  const hash = createHash(\"sha256\").update(hashSource).digest(\"base64\");\n  // So that if we get feedback about a user ID hash, we can\n  // search for the hash in the logs to find the original user ID.\n  log.rawInfo(\"getUserHash\", {\n    ...getLogMeta(session),\n    userRef: user?.ref,\n    hash,\n  });\n  return hash;\n}\n\nexport class NonRetryableError extends ApiError {}\n\nexport class TokensExceededError extends NonRetryableError {}\n\nexport class TokensExceededFirstMessageError extends TokensExceededError {\n  constructor() {\n    super(\n      \"Sorry, there's too much information for the AI to process. \" +\n      \"You'll need to either shorten your message or delete some columns.\",\n      400,\n      {\n        code: \"ContextLimitExceeded\",\n      },\n    );\n  }\n}\n\nexport class TokensExceededLaterMessageError extends TokensExceededError {\n  constructor() {\n    super(\n      \"Sorry, there's too much information for the AI to process. \" +\n      \"You'll need to either shorten your message, restart the conversation, or delete some columns.\",\n      400,\n      {\n        code: \"ContextLimitExceeded\",\n      },\n    );\n  }\n}\n\nexport class QuotaExceededError extends NonRetryableError {\n  constructor() {\n    super(\n      \"Sorry, the assistant is facing some long term capacity issues. \" +\n      \"Maybe try again tomorrow.\",\n      503,\n    );\n  }\n}\n\nexport class RetryableError extends Error {\n  constructor(message: string) {\n    super(\n      \"Sorry, the assistant is unavailable right now. \" +\n      \"Try again in a few minutes.\\n\\n\" +\n      \"```\\n(\" + message + \")\\n```\",\n    );\n  }\n}\n"
  },
  {
    "path": "app/server/lib/AssistantStatePermit.ts",
    "content": "import { IPermitStore, Permit } from \"app/server/lib/Permit\";\n\n/**\n * A {@link Permit} that contains assistant-related state.\n *\n * This is currently used by the `/api/assistant/start` endpoint, which\n * redirects to the signup page if the user is unauthenticated.\n * As part of this redirect, a signup state cookie is set which\n * includes the ID of a {@link AssistantStatePermit} containing the\n * prompt the user submitted to the `/api/assistant/start` endpoint. This\n * permit is later replaced with one containing the `docId` of a new\n * document created on the user's first visit after signup, as part of\n * welcoming the user. Finally, the permit is retrieved and cleared\n * when the document with matching `docId` is first opened with the\n * `assistantState` URL parameter set to the permit's ID.\n *\n * Unlike browser cookies, which can only store upwards of ~4 KB of data,\n * permits are able to store significantly larger amounts of data, hence\n * why they are used to store assistant state like LLM prompts.\n * Cookies are still used to track permits across signups, but we only\n * store the permit IDs in them.\n */\nexport interface AssistantStatePermit extends Permit {\n  prompt: string;\n  docId?: string;\n}\n\n/**\n * Gets an assistant state permit by ID, removing it in the process.\n *\n * Returns `null` if a permit with the specified ID does not exist.\n *\n * Note: This is a wrapper for {@link IPermitStore.getPermit} that clears\n * the permit from the store and sets the key prefix for you.\n */\nexport async function getAndRemoveAssistantStatePermit(\n  store: IPermitStore,\n  id: string,\n): Promise<AssistantStatePermit | null> {\n  const prefix = store.getKeyPrefix();\n  const key = prefix + id;\n  const permit = (await store.getPermit(key)) as AssistantStatePermit | null;\n  await store.removePermit(key);\n  return permit;\n}\n\n/**\n * Sets a new assistant state permit.\n *\n * Returns an ID that can be passed to {@link getAssistantStatePermit} to\n * retrieve the permit.\n *\n * Note: This is a wrapper for {@link IPermitStore.setPermit} that sets a\n * reasonable TTL and strips the key prefix for you.\n */\nexport async function setAssistantStatePermit(\n  store: IPermitStore,\n  permit: AssistantStatePermit,\n): Promise<string> {\n  const key = await store.setPermit(permit, 1000 * 60 * 60);\n  const prefix = store.getKeyPrefix();\n  const id = key.replace(prefix, \"\");\n  return id;\n}\n"
  },
  {
    "path": "app/server/lib/AttachmentFileManager.ts",
    "content": "import { AttachmentTransferStatus, DocAttachmentsLocation } from \"app/common/UserAPI\";\nimport {\n  AttachmentFile,\n  AttachmentStoreDocInfo,\n  DocPoolId,\n  getDocPoolIdFromDocInfo,\n  IAttachmentStore, loadAttachmentFileIntoMemory,\n} from \"app/server/lib/AttachmentStore\";\nimport { AttachmentStoreId, IAttachmentStoreProvider } from \"app/server/lib/AttachmentStoreProvider\";\nimport { checksumFileStream, HashPassthroughStream } from \"app/server/lib/checksumFile\";\nimport { DocStorage, FileInfo } from \"app/server/lib/DocStorage\";\nimport log from \"app/server/lib/log\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\nimport { MemoryWritableStream } from \"app/server/utils/streams\";\n\nimport { EventEmitter } from \"events\";\nimport * as stream from \"node:stream\";\n\nimport { AbortController } from \"node-abort-controller\";\n\nexport interface AddFileResult {\n  fileIdent: string;\n  isNewFile: boolean;\n}\n\nexport class StoresNotConfiguredError extends Error {\n  constructor() {\n    super(\"Attempted to access a file store, but AttachmentFileManager was initialized without store access\");\n  }\n}\n\nexport class StoreNotAvailableError extends Error {\n  public readonly storeId: AttachmentStoreId;\n\n  constructor(storeId: AttachmentStoreId) {\n    super(`Store '${storeId}' is not a valid and available store`);\n    this.storeId = storeId;\n  }\n}\n\nexport class UnknownDocumentPoolError extends Error {\n  constructor() {\n    super(`Attempted to access external attachments, but the pool id is unknown`);\n  }\n}\n\nexport class MissingAttachmentError extends Error {\n  public readonly fileIdent: string;\n\n  constructor(fileIdent: string) {\n    super(`Attachment file '${fileIdent}' could not be found in this document`);\n    this.fileIdent = fileIdent;\n  }\n}\n\nexport class AttachmentRetrievalError extends Error {\n  public readonly storeId: AttachmentStoreId | null;\n  public readonly fileId: string;\n\n  constructor(storeId: AttachmentStoreId | null, fileId: string, cause?: any) {\n    const causeError = cause instanceof Error ? cause : undefined;\n    const reason = (causeError ? causeError.message : cause) ?? \"\";\n    const storeName = storeId ? `'${storeId}'` : \"internal storage\";\n    super(`Unable to retrieve '${fileId}' from ${storeName} ${reason}`);\n    this.storeId = storeId;\n    this.fileId = fileId;\n    this.cause = causeError;\n  }\n}\n\nexport class MismatchedFileHashError extends Error {\n  constructor(fileIdent: string, hash: string) {\n    super(`Hash ${hash} is not correct for attachment file '${fileIdent}'`);\n  }\n}\n\n/**\n * Instantiated on a per-document basis to provide a document with access to its attachments.\n * Handles attachment uploading / fetching, as well as trying to ensure consistency with the local\n * document database, which tracks attachments and where they're stored.\n *\n * This class should prevent the document code from having to worry about accessing the underlying\n * stores.\n *\n * Before modifying this class, it's suggested to understand document pools (described in\n * AttachmentStore.ts), which are used to perform the (eventual) cleanup of the files in external\n * stores.\n *\n * The general design philosophy for this class is:\n * - Avoid data loss at all costs (missing files in stores, or missing file table entries)\n * - Always be in a valid state if possible (e.g no file entries with missing attachments)\n * - Files in stores with no file record pointing to them is acceptable (but not preferable), as\n * they'll eventually be cleaned up when the document pool is deleted.\n *\n */\nexport class AttachmentFileManager extends EventEmitter {\n  public static events = {\n    TRANSFER_STARTED: \"transfer-started\",\n    TRANSFER_COMPLETED: \"transfer-completed\",\n  };\n\n  // _docPoolId is a critical point for security. Documents with a common pool id can access each others' attachments.\n  private readonly _docPoolId: DocPoolId | null;\n  private readonly _docName: string;\n  private _log = new LogMethods(\n    \"AttachmentFileManager \",\n    (logInfo: AttachmentFileManagerLogInfo) => this._getLogMeta(logInfo),\n  );\n\n  // Maps file identifiers to their desired store. This may be the same as their current store,\n  // in which case nothing will happen. Map ensures new requests override older pending transfers.\n  private _pendingFileTransfers = new Map<string, AttachmentStoreId | undefined>();\n  private _successes: number = 0;\n  private _failures: number = 0;\n  private _transferJob?: TransferJob;\n  private _loopAbortController: AbortController = new AbortController();\n  private _loopAbort = this._loopAbortController?.signal as globalThis.AbortSignal;\n\n  /**\n   * @param _docStorage - Storage of this manager's document.\n   * @param _storeProvider - Allows instantiating of stores. Should be provided except in test\n   *   scenarios.\n   * @param _docInfo - The document this manager is for. Should be provided except in test\n   *   scenarios.\n   */\n  constructor(\n    private _docStorage: DocStorage,\n    private _storeProvider: IAttachmentStoreProvider | undefined,\n    _docInfo: AttachmentStoreDocInfo | undefined,\n    private _assertInternalStorageAvailable?: (extraBytes: number) => Promise<void>,\n  ) {\n    super();\n    this._docName = _docStorage.docName;\n    this._docPoolId = _docInfo ? getDocPoolIdFromDocInfo(_docInfo) : null;\n  }\n\n  public async shutdown(): Promise<void> {\n    if (this._loopAbort.aborted) {\n      throw new Error(\"shutdown already in progress\");\n    }\n    this._pendingFileTransfers.clear();\n    this._successes = 0;\n    this._failures = 0;\n    this._loopAbortController.abort();\n    await this._transferJob?.catch(reason => this._log.error({}, `Error during shutdown: ${reason}`));\n  }\n\n  // This attempts to add the attachment to the given store.\n  // If the file already exists in another store, it doesn't take any action.\n  // Therefore, there isn't a guarantee that the file exists in the given store, even if this method doesn't error.\n  public async addFile(\n    storeId: AttachmentStoreId | undefined,\n    fileExtension: string,\n    fileData: Buffer,\n  ): Promise<AddFileResult> {\n    const fileIdent = await this._getFileIdentifier(fileExtension, stream.Readable.from(fileData));\n    return this._addFile(storeId, fileIdent, fileData);\n  }\n\n  /**\n   * Adds an attachment file to storage, if that attachment is known about but not currently\n   * available (as determined by `AttachmentFileManager.isFileAvailable`).\n   * This most frequently occurs when a document is (re)uploaded, meaning it has a new docPoolId\n   * and no copies of the attachment files.\n   *\n   * The file is restored to the store it was originally stored in, if that store is available.\n   * Otherwise, `defaultStoreId` is used.\n   *\n   * @param {string} fileIdent - Attachment file to attempt to restore.\n   * @param {stream.Readable} fileData - Contents of the file.\n   * @param {AttachmentStoreId | undefined} defaultStoreId - Store to use if the file's original store is unavailable.\n   * @returns {Promise<boolean>} - True if the file was added, false otherwise.\n   */\n  public async addMissingFileData(\n    fileIdent: string,\n    fileData: stream.Readable,\n    defaultStoreId: AttachmentStoreId | undefined,\n  ): Promise<boolean> {\n    const fileMetadata = await this._docStorage.getFileInfoNoData(fileIdent);\n    if (!fileMetadata) { return false; }\n    if (await this._isFileAvailable(fileMetadata)) { return false; }\n\n    // Try to use the store the file was originally uploaded to, if it's available.\n    const originalStoreId = fileMetadata.storageId;\n    const originalStoreStillExists =\n      !!originalStoreId && this._getStoreProvider().listAllStoreIds().includes(originalStoreId);\n    const destinationStoreId = originalStoreStillExists ? originalStoreId : defaultStoreId;\n    const destinationStore = destinationStoreId && await this._getStore(destinationStoreId) || undefined;\n\n    // Error if the file should be added to an external store, but the store isn't available for some reason.\n    if (destinationStoreId && !destinationStore) {\n      throw new StoreNotAvailableError(destinationStoreId);\n    }\n\n    // Internal storage is handled separately, as it needs the file as a Buffer in memory.\n    // External storage can avoid loading it into memory, as it's all stream APIs.\n    if (destinationStore) {\n      const hashStream = new HashPassthroughStream();\n      // To avoid loading this into memory, we hash the file as it's uploaded, and delete it\n      // if the hash isn't correct.\n      fileData.pipe(hashStream);\n      await this._storeFileInAttachmentStore(destinationStore, fileIdent, hashStream);\n      await stream.promises.finished(hashStream);\n      const fileHash = hashStream.getDigest();\n      if (!fileIdent.startsWith(fileHash)) {\n        await destinationStore.delete(this._getDocPoolId(), fileIdent);\n        throw new MismatchedFileHashError(fileIdent, fileHash);\n      }\n    } else {\n      const hashStream = new HashPassthroughStream();\n      const bufferStream = new MemoryWritableStream();\n      await stream.promises.pipeline(fileData, hashStream, bufferStream);\n      const buffer = bufferStream.getBuffer();\n      const fileHash = hashStream.getDigest();\n      if (!fileIdent.startsWith(fileHash)) {\n        throw new MismatchedFileHashError(fileIdent, fileHash);\n      }\n      await this._storeFileInLocalStorage(fileIdent, buffer);\n    }\n\n    return true;\n  }\n\n  public async getFileData(fileIdent: string): Promise<Buffer> {\n    const file = await this.getFile(fileIdent);\n    return (await loadAttachmentFileIntoMemory(file)).contents;\n  }\n\n  public async getFile(fileIdent: string): Promise<AttachmentFile> {\n    return (await this._getFileInfo(fileIdent)).file;\n  }\n\n  /**\n   * Checks if the contents of an attachment are accessible.\n   * Internal attachments are always considered accessible.\n   * External attachments are accessible if the store is configured and contains the file.\n   * @param {string} fileIdent\n   * @returns {Promise<boolean>}\n   */\n  public async isFileAvailable(fileIdent: string): Promise<boolean> {\n    const fileInfo = await this._docStorage.getFileInfoNoData(fileIdent);\n    return this._isFileAvailable(fileInfo);\n  }\n\n  public async locationSummary(): Promise<DocAttachmentsLocation> {\n    const files = await this._docStorage.listAllFiles();\n    if (files.length == 0) {\n      return \"none\";\n    }\n    const hasInternal = files.some(file => !file.storageId);\n    const hasExternal = files.some(file => file.storageId);\n    if (hasInternal && hasExternal) {\n      return \"mixed\";\n    }\n    if (hasExternal) {\n      return \"external\";\n    }\n    return \"internal\";\n  }\n\n  public async startTransferringAllFilesToOtherStore(newStoreId: AttachmentStoreId | undefined): Promise<void> {\n    if (this._loopAbort.aborted) {\n      throw new Error(\"AttachmentFileManager was shut down\");\n    }\n    this._successes = 0;\n    this._failures = 0;\n    // Take a \"snapshot\" of the files we want to transfer, and schedule those files for transfer.\n    // It's possibly that other code will modify the file statuses / list during this process.\n    // As a consequence, after this process completes, some files may still be in their original\n    // store. Simple approaches to solve this (e.g transferring files until everything in the DB\n    // shows as being in the new store) risk livelock issues, such as if two transfers somehow end\n    // up running simultaneously. This \"snapshot\" approach has few guarantees about final state,\n    // but is extremely unlikely to result in any severe problems.\n    const allFiles = await this._docStorage.listAllFiles();\n    const filesToTransfer = allFiles.filter(file => (file.storageId ?? undefined) !== newStoreId);\n\n    if (filesToTransfer.length === 0) {\n      return;\n    }\n\n    const fileIdents = filesToTransfer.map(file => file.ident);\n    for (const fileIdent of fileIdents) {\n      this.startTransferringFileToOtherStore(fileIdent, newStoreId);\n    }\n  }\n\n  // File transfers are handled by an async job that goes through all pending files, and one-by-one\n  // transfers them from their current store to their target store. This ensures that for a given\n  // doc, we never accidentally start several transfers at once and load many files into memory\n  // simultaneously (e.g. a badly written script spamming API calls). It allows any new transfers\n  // to overwrite any scheduled transfers. This provides a well-defined behaviour where the latest\n  // scheduled transfer happens, instead of the last transfer to finish \"winning\".\n  public startTransferringFileToOtherStore(fileIdent: string, newStoreId: AttachmentStoreId | undefined) {\n    this._pendingFileTransfers.set(fileIdent, newStoreId);\n    this._runTransferJob();\n  }\n\n  // Generally avoid calling this directly, instead use other methods to schedule and run the\n  // transfer job. If a file with a matching identifier already exists in the new store, no\n  // transfer will happen, as the default _addFileToX behaviour is to avoid re-uploading files.\n  public async transferFileToOtherStore(fileIdent: string, newStoreId: AttachmentStoreId | undefined): Promise<void> {\n    this._log.info({ fileIdent, storeId: newStoreId }, `transferring file to new store`);\n    const fileMetadata = await this._docStorage.getFileInfoNoData(fileIdent);\n    // This check runs before the file is retrieved as an optimisation to avoid loading files into\n    // memory unnecessarily.\n    if (!fileMetadata || (fileMetadata.storageId ?? undefined) === newStoreId) {\n      return;\n    }\n    // It's possible that the record has changed between the original metadata check and here.\n    // However, the worst case is we transfer a file that's already been transferred, so no need to\n    // re-check.\n    // Streaming isn't an option here, as SQLite only supports buffers (meaning we need to keep at\n    // least 1 full copy of the file in memory during transfers).\n    const fileInfo = await this._getFileInfo(fileIdent);\n    // Cache this to avoid undefined warnings everywhere we use `dataInMemory`.\n    const fileInMemory = await loadAttachmentFileIntoMemory(fileInfo.file);\n\n    if (!await validateFileChecksum(fileIdent, fileInMemory.contents)) {\n      throw new AttachmentRetrievalError(\n        fileInfo.storageId, fileInfo.ident, \"checksum verification failed for retrieved file\",\n      );\n    }\n    if (!newStoreId) {\n      await this._storeFileInLocalStorage(fileIdent, fileInMemory.contents);\n      return;\n    }\n    const newStore = await this._getStore(newStoreId);\n    if (!newStore) {\n      this._log.warn({\n        fileIdent,\n        storeId: newStoreId,\n      }, `unable to transfer file to unavailable store`);\n      throw new StoreNotAvailableError(newStoreId);\n    }\n    // Store should error if the upload fails in any way.\n    await this._storeFileInAttachmentStore(newStore, fileIdent, stream.Readable.from(fileInMemory.contents));\n\n    // Don't remove the file from the previous store, in case we need to roll back to an earlier\n    // snapshot.\n    // Internal storage is the exception (and is automatically erased), as that's included in\n    // snapshots.\n  }\n\n  public async allTransfersCompleted(): Promise<void> {\n    if (this._transferJob) {\n      await this._transferJob;\n    }\n  }\n\n  public transferStatus() {\n    return {\n      pendingTransferCount: this._pendingFileTransfers.size,\n      isRunning: this._transferJob !== undefined,\n      successes: this._successes,\n      failures: this._failures,\n    };\n  }\n\n  private _runTransferJob() {\n    if (this._transferJob) {\n      return;\n    }\n    this._transferJob = this._performPendingTransfers();\n\n    this._transferJob.catch(err => this._log.error({}, `Error during transfer: ${err}`));\n\n    void this._transferJob.finally(() => {\n      this._transferJob = undefined;\n    });\n  }\n\n  private async _performPendingTransfers() {\n    try {\n      await this._notifyAboutStart();\n      while (this._pendingFileTransfers.size > 0 && !this._loopAbort.aborted) {\n        // Map.entries() will always return the most recent key/value from the map, even after a long async delay\n        // Meaning we can safely iterate here and know the transfer is up to date.\n        for (const [fileIdent, targetStoreId] of this._pendingFileTransfers.entries()) {\n          try {\n            await this.transferFileToOtherStore(fileIdent, targetStoreId);\n            // This is exposed just for testing, to allow for a delay between transfers.\n            // One of the test is refreshing the tab to see if the transfer is still running.\n            if (process.env.GRIST_TEST_TRANSFER_DELAY) {\n              await new Promise(resolve => setTimeout(resolve, Number(process.env.GRIST_TEST_TRANSFER_DELAY)));\n            }\n            this._successes++;\n          } catch (e) {\n            this._failures++;\n            this._log.warn({ fileIdent, storeId: targetStoreId }, `transfer failed: ${e.message}`);\n          } finally {\n            // If a transfer request comes in mid-transfer, it will need re-running.\n            if (this._pendingFileTransfers.get(fileIdent) === targetStoreId) {\n              this._pendingFileTransfers.delete(fileIdent);\n            }\n          }\n        }\n      }\n    } finally {\n      if (!this._loopAbort.aborted) {\n        await this._docStorage.requestVacuum();\n        await this._notifyAboutEnd();\n      }\n    }\n  }\n\n  /**\n   * Sends a notification that a transfer has started.\n   * Note: We calculate arguments ourselves as the status object is not yet available, as\n   * it is calculated from the promise (if it is set or not).\n   */\n  private async _notifyAboutStart() {\n    this.emit(AttachmentFileManager.events.TRANSFER_STARTED, {\n      locationSummary: await this.locationSummary(),\n      status: { pendingTransferCount: this._pendingFileTransfers.size, isRunning: true },\n    } as AttachmentTransferStatus);\n  }\n\n  /**\n   * Sends a notification that a transfer has end.\n   * Note: We calculate arguments ourselves as the status object is not yet available, as\n   * it is calculated from the promise (if it is set or not).\n   */\n  private async _notifyAboutEnd() {\n    this.emit(AttachmentFileManager.events.TRANSFER_COMPLETED, {\n      locationSummary: await this.locationSummary(),\n      status: { pendingTransferCount: this._pendingFileTransfers.size, isRunning: false },\n    } as AttachmentTransferStatus);\n  }\n\n  private async _isFileAvailable(fileInfo?: FileInfo | null): Promise<boolean> {\n    if (!fileInfo) { return false; }\n    // Local files are always available\n    if (fileInfo.storageId === null) { return true; }\n    const store = await this._storeProvider?.getStore(fileInfo.storageId);\n    if (!store) { return false; }\n    return await store.exists(this._getDocPoolId(), fileInfo.ident);\n  }\n\n  private async _addFileToLocalStorage(\n    fileIdent: string,\n    fileData: Buffer,\n  ): Promise<AddFileResult> {\n    this._log.info({\n      fileIdent,\n    }, `adding file to document storage`);\n\n    const fileInfoNoData = await this._docStorage.getFileInfoNoData(fileIdent);\n    const fileExists = fileInfoNoData !== null;\n\n    if (fileExists) {\n      const isFileInLocalStorage = fileInfoNoData.storageId === null;\n      // File is already stored in a different store, the 'add file' operation shouldn't change that.\n      // One way this could happen, is if the store has changed and no migration has happened.\n      if (!isFileInLocalStorage) {\n        return {\n          fileIdent,\n          isNewFile: false,\n        };\n      }\n    }\n\n    // A race condition can occur here, if the file's database record is modified between the\n    // `getFileInfoNoData` call earlier in this function and now.\n    // Any changes made after that point will be overwritten below.\n    // However, the database will always end up referencing a valid file, and the pool-based file\n    // deletion guarantees any files in external storage will be cleaned up eventually.\n\n    await this._storeFileInLocalStorage(fileIdent, fileData);\n\n    return {\n      fileIdent,\n      isNewFile: !fileExists,\n    };\n  }\n\n  private async _addFileToExternalStorage(\n    destStoreId: AttachmentStoreId,\n    fileIdent: string,\n    fileData: stream.Readable,\n  ): Promise<AddFileResult> {\n    this._log.info({\n      fileIdent,\n      storeId: destStoreId,\n    }, `adding file to external storage`);\n\n    const destStore = await this._getStore(destStoreId);\n\n    if (!destStore) {\n      this._log.warn({ fileIdent, storeId: destStoreId }, \"tried to fetch attachment from an unavailable store\");\n      throw new StoreNotAvailableError(destStoreId);\n    }\n\n    const fileInfoNoData = await this._docStorage.getFileInfoNoData(fileIdent);\n    const fileExists = fileInfoNoData !== null;\n\n    if (fileExists) {\n      const isFileInTargetStore = destStoreId === fileInfoNoData.storageId;\n      // File is already stored in a different store, the 'add file' operation shouldn't change that.\n      // One way this could happen, is if the store has changed and no migration has happened.\n      if (!isFileInTargetStore) {\n        return {\n          fileIdent,\n          isNewFile: false,\n        };\n      }\n\n      // Only exit early in the file exists in the store, otherwise we should allow users to fix\n      // any missing files by proceeding to the normal upload logic.\n      const fileAlreadyExistsInStore = await destStore.exists(this._getDocPoolId(), fileIdent);\n      if (fileAlreadyExistsInStore) {\n        return {\n          fileIdent,\n          isNewFile: false,\n        };\n      }\n    }\n\n    // A race condition can occur here, if the file's database record is modified between the\n    // `getFileInfoNoData` call earlier in this function and now.\n    // Any changes made after that point will be overwritten below.\n    // However, the database will always end up referencing a valid file, and the pool-based file\n    // deletion guarantees any files in external storage will be cleaned up eventually.\n\n    await this._storeFileInAttachmentStore(destStore, fileIdent, fileData);\n\n    return {\n      fileIdent,\n      isNewFile: !fileExists,\n    };\n  }\n\n  private async _addFile(\n    destStoreId: AttachmentStoreId | undefined,\n    fileIdent: string,\n    fileData: Buffer,\n  ): Promise<AddFileResult> {\n    if (destStoreId === undefined) {\n      return this._addFileToLocalStorage(fileIdent, fileData);\n    }\n    return this._addFileToExternalStorage(destStoreId, fileIdent, stream.Readable.from(fileData));\n  }\n\n  private async _getFileInfo(fileIdent: string): Promise<AttachmentFileInfo> {\n    const fileInfo = await this._docStorage.getFileInfo(fileIdent);\n    if (!fileInfo) {\n      this._log.error({ fileIdent }, \"cannot find file metadata in document\");\n      throw new MissingAttachmentError(fileIdent);\n    }\n    this._log.debug(\n      { fileIdent, storeId: fileInfo.storageId },\n      `fetching attachment from ${fileInfo.storageId ? \"external\" : \"document \"} storage`,\n    );\n    if (!fileInfo.storageId) {\n      return {\n        ident: fileIdent,\n        storageId: null,\n        file: {\n          metadata: {\n            size: fileInfo.data.length,\n          },\n          contentStream: stream.Readable.from(fileInfo.data),\n          contents: fileInfo.data,\n        },\n      };\n    }\n    const store = await this._getStore(fileInfo.storageId);\n    if (!store) {\n      this._log.warn({ fileIdent, storeId: fileInfo.storageId }, `unable to retrieve file, store is unavailable`);\n      throw new StoreNotAvailableError(fileInfo.storageId);\n    }\n    return {\n      ident: fileIdent,\n      storageId: store.id,\n      file: await this._getFileDataFromAttachmentStore(store, fileIdent),\n    };\n  }\n\n  private _getStoreProvider(): IAttachmentStoreProvider {\n    if (!this._storeProvider) {\n      throw new StoresNotConfiguredError();\n    }\n    return this._storeProvider;\n  }\n\n  private async _getStore(storeId: AttachmentStoreId): Promise<IAttachmentStore | null> {\n    return this._getStoreProvider().getStore(storeId);\n  }\n\n  private _getDocPoolId(): string {\n    if (!this._docPoolId) {\n      throw new UnknownDocumentPoolError();\n    }\n    return this._docPoolId;\n  }\n\n  private async _getFileIdentifier(fileExtension: string, fileData: stream.Readable): Promise<string> {\n    const checksum = await checksumFileStream(fileData);\n    return `${checksum}${fileExtension}`;\n  }\n\n  // Uploads the file to local storage, overwriting the current DB record for the file.\n  private async _storeFileInLocalStorage(fileIdent: string, fileData: Buffer): Promise<AddFileResult> {\n    // Insert (or overwrite) the entry for this file in the document database.\n    await this._assertInternalStorageAvailable?.(fileData.byteLength);\n    const isNewFile = await this._docStorage.attachOrUpdateFile(fileIdent, fileData, undefined);\n\n    return {\n      fileIdent,\n      isNewFile,\n    };\n  }\n\n  // Uploads the file to an attachment store, overwriting the current DB record for the file if successful.\n  private async _storeFileInAttachmentStore(\n    store: IAttachmentStore, fileIdent: string, fileData: stream.Readable,\n  ): Promise<void> {\n    // The underlying store should guarantee the file exists if this method doesn't error,\n    // so no extra validation is needed here.\n    await store.upload(this._getDocPoolId(), fileIdent, fileData);\n\n    // Insert (or overwrite) the entry for this file in the document database.\n    await this._docStorage.attachOrUpdateFile(fileIdent, undefined, store.id);\n  }\n\n  private async _getFileDataFromAttachmentStore(store: IAttachmentStore, fileIdent: string): Promise<AttachmentFile> {\n    try {\n      return await store.download(this._getDocPoolId(), fileIdent);\n    } catch (e) {\n      throw new AttachmentRetrievalError(store.id, fileIdent, e);\n    }\n  }\n\n  private _getLogMeta(logInfo?: AttachmentFileManagerLogInfo): log.ILogMeta {\n    return {\n      docName: this._docName,\n      docPoolId: this._docPoolId,\n      ...logInfo,\n    };\n  }\n}\n\nasync function validateFileChecksum(fileIdent: string, fileData: Buffer): Promise<boolean> {\n  return fileIdent.startsWith(await checksumFileStream(stream.Readable.from(fileData)));\n}\n\ninterface AttachmentFileManagerLogInfo {\n  fileIdent?: string;\n  storeId?: string | null;\n}\n\ninterface AttachmentFileInfo {\n  ident: string;\n  storageId: string | null;\n  file: AttachmentFile;\n}\n\ntype TransferJob = Promise<void>;\n"
  },
  {
    "path": "app/server/lib/AttachmentStore.ts",
    "content": "import {\n  ExternalStorage,\n  joinKeySegments,\n} from \"app/server/lib/ExternalStorage\";\nimport { MemoryWritableStream } from \"app/server/utils/streams\";\n\nimport * as stream from \"node:stream\";\nimport * as path from \"path\";\n\nimport * as fse from \"fs-extra\";\n\nexport type DocPoolId = string;\ntype FileId = string;\n\n// Minimum document info needed to know which document pool to use.\n// Compatible with Document entity for ease of use\nexport interface AttachmentStoreDocInfo {\n  id: string;\n  // We explicitly make this a union type instead of making the attribute optional because the\n  // programmer must make a conscious choice to mark it as null or undefined, not merely omit it.\n  // Omission could easily result in invalid behaviour.\n  trunkId: string | null | undefined;\n}\n\ninterface FileMetadata {\n  size: number;\n}\n\nexport interface AttachmentFile {\n  metadata: FileMetadata,\n  contentStream: stream.Readable,\n  // Used to optimise certain scenarios where the data *must* be in memory (e.g. SQLite read/writes)\n  contents?: Buffer\n}\n\nexport interface AttachmentFileInMemory extends AttachmentFile {\n  contents: Buffer;\n}\n\nexport function isAttachmentFileInMemory(file: AttachmentFile): file is AttachmentFileInMemory {\n  return file.contents !== undefined;\n}\n\nexport async function loadAttachmentFileIntoMemory(file: AttachmentFile): Promise<AttachmentFileInMemory> {\n  if (isAttachmentFileInMemory(file)) {\n    return file;\n  }\n  const memoryStream = new MemoryWritableStream();\n  await stream.promises.pipeline(file.contentStream, memoryStream);\n  const buffer = memoryStream.getBuffer();\n\n  // Use Object.assign because it gives type safety, without having to us `as` or copy the object.\n  return Object.assign(file, {\n    contents: buffer,\n    contentStream: stream.Readable.from(buffer),\n  });\n}\n\n/**\n * Gets the correct pool id for a given document, given the document's id and trunk id.\n *\n * Attachments are stored in a \"Document Pool\", which is used to manage the attachments' lifecycle.\n * Document pools are shared between snapshots and forks, but not between documents. This provides\n * quick forking and snapshotting (not having to copy attachments), while avoiding more complex\n * systems like reference tracking.\n *\n * Generally, the pool id of a document should be its trunk id if available (because it's a fork),\n * or the document's id (if it isn't a fork).\n *\n * This means that attachments need copying to a new pool when a document is copied.\n * Avoids other areas of the codebase having to understand how documents are mapped to pools.\n *\n * This is a key security measure, as only a specific document and its forks can access its\n * attachments. This helps prevent malicious documents being uploaded, which might attempt to\n * access another user's attachments.\n *\n * Therefore, it is CRITICAL that documents with different security domains (e.g from different\n * teams) do not share a document pool.\n * @param {AttachmentStoreDocInfo} docInfo - Document details needed to calculate the document\n *   pool.\n * @returns {string} - ID of the pool the attachments will be stored in.\n */\nexport function getDocPoolIdFromDocInfo(docInfo: AttachmentStoreDocInfo): string {\n  return docInfo.trunkId ?? docInfo.id;\n}\n\n/**\n * Provides access to external storage, specifically for storing attachments. Each store represents\n * a specific location to store attachments, e.g. \"/srv/grist/attachments\" on the filesystem.\n *\n * This is a general-purpose interface that should abstract over many different storage providers,\n * so shouldn't have methods which rely on one the features of one specific provider.\n *\n * `IAttachmentStore` is distinct from `ExternalStorage` as it's specific to attachments, and can\n * therefore not concern itself with some features ExternalStorage has (e.g versioning). This means\n * it can present a more straightforward interface for components which need to access attachment\n * files.\n *\n * A document pool needs specifying for all store operations, which should be calculated with\n * `getDocPoolIdFromDocInfo` See {@link getDocPoolIdFromDocInfo} for more details.\n */\nexport interface IAttachmentStore {\n  // Universally unique id, such that no two Grist installations should have the same store ids, if\n  // they're for different stores. This allows for explicit detection of unavailable stores.\n  readonly id: string;\n\n  // Check if attachment exists in the store.\n  exists(docPoolId: DocPoolId, fileId: FileId): Promise<boolean>;\n\n  // Upload attachment to the store.\n  upload(docPoolId: DocPoolId, fileId: FileId, fileData: stream.Readable): Promise<void>;\n\n  // Fetch the attachment from the store, including a readable stream for the attachment's contents.\n  download(docPoolId: DocPoolId, fileId: FileId): Promise<AttachmentFile>;\n\n  // Remove attachment from the store\n  delete(docPoolId: DocPoolId, fileId: FileId): Promise<void>;\n\n  // Remove attachments for all documents in the given document pool.\n  removePool(docPoolId: DocPoolId): Promise<void>;\n\n  // Close the storage object.\n  close(): Promise<void>;\n}\n\nexport class InvalidAttachmentExternalStorageError extends Error {\n  constructor(storeId: string, context?: string) {\n    const formattedContext = context ? `: ${context}` : \"\";\n    super(`External Storage for store '${storeId}' is invalid` + formattedContext);\n  }\n}\n\nexport class AttachmentStoreCreationError extends Error {\n  constructor(storeBackend: string, storeId: string, context?: string) {\n    const formattedContext = context ? `: ${context}` : \"\";\n    super(`Unable to create ${storeBackend} store '${storeId}'` + formattedContext);\n  }\n}\n\nexport interface ExternalStorageSupportingAttachments extends ExternalStorage {\n  uploadStream: NonNullable<ExternalStorage[\"uploadStream\"]>;\n  downloadStream: NonNullable<ExternalStorage[\"downloadStream\"]>;\n  removeAllWithPrefix: NonNullable<ExternalStorage[\"removeAllWithPrefix\"]>;\n}\n\nexport function storageSupportsAttachments(storage: ExternalStorage): storage is ExternalStorageSupportingAttachments {\n  return Boolean(storage.uploadStream && storage.downloadStream && storage.removeAllWithPrefix);\n}\n\nexport class ExternalStorageAttachmentStore implements IAttachmentStore {\n  constructor(\n    public id: string,\n    private _storage: ExternalStorageSupportingAttachments,\n  ) {}\n\n  public exists(docPoolId: string, fileId: string): Promise<boolean> {\n    return this._storage.exists(this._getKey(docPoolId, fileId));\n  }\n\n  public async upload(docPoolId: string, fileId: string, fileData: stream.Readable): Promise<void> {\n    await this._storage.uploadStream(this._getKey(docPoolId, fileId), fileData);\n  }\n\n  public async download(docPoolId: string, fileId: string): Promise<AttachmentFile> {\n    return await this._storage.downloadStream(this._getKey(docPoolId, fileId));\n  }\n\n  public async delete(docPoolId: string, fileId: string): Promise<void> {\n    await this._storage.remove(this._getKey(docPoolId, fileId));\n  }\n\n  public async removePool(docPoolId: string): Promise<void> {\n    await this._storage.removeAllWithPrefix(this._getPoolPrefix(docPoolId));\n  }\n\n  public async close(): Promise<void> {\n    await this._storage.close();\n  }\n\n  private _getPoolPrefix(docPoolId: string): string {\n    return docPoolId;\n  }\n\n  private _getKey(docPoolId: string, fileId: string): string {\n    return joinKeySegments([this._getPoolPrefix(docPoolId), fileId]);\n  }\n}\n\nexport class FilesystemAttachmentStore implements IAttachmentStore {\n  constructor(public readonly id: string, private _rootFolderPath: string) {\n  }\n\n  public async exists(docPoolId: DocPoolId, fileId: FileId): Promise<boolean> {\n    return fse.pathExists(this._createPath(docPoolId, fileId))\n      .catch(() => false);\n  }\n\n  public async upload(docPoolId: DocPoolId, fileId: FileId, fileData: stream.Readable): Promise<void> {\n    const filePath = this._createPath(docPoolId, fileId);\n    await fse.ensureDir(path.dirname(filePath));\n    const writeStream = fse.createWriteStream(filePath);\n    await stream.promises.pipeline(\n      fileData,\n      writeStream,\n    );\n  }\n\n  public async download(docPoolId: DocPoolId, fileId: FileId): Promise<AttachmentFile> {\n    const filePath = this._createPath(docPoolId, fileId);\n    const stat = await fse.stat(filePath);\n    return {\n      metadata: {\n        size: stat.size,\n      },\n      contentStream: fse.createReadStream(filePath),\n    };\n  }\n\n  public async delete(docPoolId: string, fileId: string): Promise<void> {\n    await fse.remove(this._createPath(docPoolId, fileId));\n  }\n\n  public async removePool(docPoolId: DocPoolId): Promise<void> {\n    await fse.remove(this._createPath(docPoolId));\n  }\n\n  public async close(): Promise<void> {\n    // Not needed here, no resources held.\n  }\n\n  private _createPath(docPoolId: DocPoolId, fileId: FileId = \"\"): string {\n    return path.join(this._rootFolderPath, docPoolId, fileId);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/AttachmentStoreProvider.ts",
    "content": "import { appSettings } from \"app/server/lib/AppSettings\";\nimport { FilesystemAttachmentStore, IAttachmentStore } from \"app/server/lib/AttachmentStore\";\nimport { create } from \"app/server/lib/create\";\nimport { ICreateAttachmentStoreOptions } from \"app/server/lib/ICreate\";\nimport log from \"app/server/lib/log\";\n\nimport path from \"path\";\n\nimport * as fse from \"fs-extra\";\nimport * as tmp from \"tmp-promise\";\n\nexport type AttachmentStoreId = string;\n\n/**\n * Creates an {@link IAttachmentStore} from a given store id, if the Grist installation is\n * configured with that store's unique id.\n *\n * Each store represents a specific location to store attachments at, for example a \"/attachments\"\n * bucket on MinIO, or \"/srv/grist/attachments\" on the filesystem.\n *\n * Attachments in Grist Documents are accompanied by the id of the store they're in, allowing Grist\n * to store/retrieve them as long as that store exists on the document's installation.\n */\nexport interface IAttachmentStoreProvider {\n  getStoreIdFromLabel(label: string): string;\n\n  // Returns the store associated with the given id, returning null if no store with that id exists.\n  getStore(id: AttachmentStoreId): Promise<IAttachmentStore | null>;\n\n  getAllStores(): Promise<IAttachmentStore[]>;\n\n  storeExists(id: AttachmentStoreId): boolean;\n\n  listAllStoreIds(): AttachmentStoreId[];\n}\n\n// All the information needed to instantiate an instance of a particular store type\nexport interface IAttachmentStoreSpecification {\n  name: string,\n  create: (storeId: string) => Promise<IAttachmentStore>,\n}\n\n// All the information needed to create a particular store instance\nexport interface IAttachmentStoreConfig {\n  // This is the name for the store, but it also used to construct the store ID.\n  label: string;\n  spec: IAttachmentStoreSpecification;\n}\n\n/**\n * Provides access to instances of attachment stores.\n *\n * Stores can be accessed using ID or Label.\n *\n * Labels are a convenient/user-friendly way to refer to stores.\n * Each label is unique within a Grist instance, but other instances may have stores that use\n * the same label.\n * E.g \"my-s3\" or \"myFolder\".\n *\n * IDs are globally unique - and are created by prepending the label with the installation UUID.\n * E.g \"22ec6867-67bc-414e-a707-da9204c84cab-my-s3\" or \"22ec6867-67bc-414e-a707-da9204c84cab-myFolder\"\n */\nexport class AttachmentStoreProvider implements IAttachmentStoreProvider {\n  private _storeDetailsById = new Map<string, IAttachmentStoreConfig>();\n\n  constructor(\n    storeConfigs: IAttachmentStoreConfig[],\n    private _installationUuid: string,\n  ) {\n    // It's convenient to have stores with a globally unique ID, so there aren't conflicts as\n    // documents are moved between installations. This is achieved by prepending the store labels\n    // with the installation ID. Enforcing that using AttachmentStoreProvider makes it\n    // much harder to accidentally bypass this constraint, as the provider should be the only way of\n    // accessing stores.\n    storeConfigs.forEach((storeConfig) => {\n      this._storeDetailsById.set(this.getStoreIdFromLabel(storeConfig.label), storeConfig);\n    });\n\n    const storeIds = Array.from(this._storeDetailsById.keys());\n    log.info(`AttachmentStoreProvider initialised with stores: ${storeIds}`);\n  }\n\n  public getStoreIdFromLabel(label: string): string {\n    return `${this._installationUuid}-${label}`;\n  }\n\n  public async getStore(id: AttachmentStoreId): Promise<IAttachmentStore | null> {\n    const storeDetails = this._storeDetailsById.get(id);\n    if (!storeDetails) { return null; }\n    return storeDetails.spec.create(id);\n  }\n\n  public async getAllStores(): Promise<IAttachmentStore[]> {\n    return await Promise.all(\n      Array.from(this._storeDetailsById.entries()).map(\n        ([storeId, storeConfig]) => storeConfig.spec.create(storeId),\n      ),\n    );\n  }\n\n  public storeExists(id: AttachmentStoreId): boolean {\n    return this._storeDetailsById.has(id);\n  }\n\n  public listAllStoreIds(): string[] {\n    return Array.from(this._storeDetailsById.keys());\n  }\n}\n\nasync function isAttachmentStoreOptionAvailable(option: ICreateAttachmentStoreOptions) {\n  try {\n    return await option.isAvailable();\n  } catch (error) {\n    log.error(`Error checking availability of store option '${option}'`, error);\n    return false;\n  }\n}\n\nfunction storeOptionIsNotUndefined(\n  option: ICreateAttachmentStoreOptions | undefined,\n): option is ICreateAttachmentStoreOptions {\n  return option !== undefined;\n}\n\nexport async function checkAvailabilityAttachmentStoreOptions(\n  allOptions: (ICreateAttachmentStoreOptions | undefined)[],\n) {\n  const options = allOptions.filter(storeOptionIsNotUndefined);\n  const availability = await Promise.all(options.map(isAttachmentStoreOptionAvailable));\n\n  return {\n    available: options.filter((option, index) => availability[index]),\n    unavailable: options.filter((option, index) => !availability[index]),\n  };\n}\n\n// Make a filesystem store that will be cleaned up on process exit.\n// This is only used when external attachments are in 'test' mode, which is used for some unit tests.\n// TODO: Remove this when setting up a filesystem store is possible using normal configuration options\nexport async function makeTempFilesystemStoreSpec(\n  name: string = \"filesystem\",\n) {\n  const tempFolder = await tmp.dir();\n  // Allow tests to override the temp directory used for attachments, otherwise Grist will\n  // use different temp directory after restarting the server.\n  const tempDir = process.env.GRIST_TEST_ATTACHMENTS_DIR ||\n    await fse.mkdtemp(path.join(tempFolder.path, \"filesystem-store-test-\"));\n\n  return {\n    rootDirectory: tempDir,\n    name,\n    create: async (storeId: string) => (new FilesystemAttachmentStore(storeId, tempDir)),\n  };\n}\n\nconst settings = appSettings.section(\"attachmentStores\");\nconst GRIST_EXTERNAL_ATTACHMENTS_MODE = settings.flag(\"mode\").requireString({\n  envVar: \"GRIST_EXTERNAL_ATTACHMENTS_MODE\",\n  defaultValue: \"snapshots\",\n});\n\nexport function getConfiguredStandardAttachmentStore(): string | undefined {\n  switch (GRIST_EXTERNAL_ATTACHMENTS_MODE) {\n    case \"snapshots\":\n      return \"snapshots\";\n    case \"test\":\n      return \"test-filesystem\";\n    default:\n      return undefined;\n  }\n}\n\nexport class UnsupportedExternalAttachmentsMode extends Error {\n  constructor(storeType: string) {\n    super(`Unsupported external attachments mode on this version of Grist: ${storeType}`);\n  }\n}\n\nexport async function getConfiguredAttachmentStoreConfigs(): Promise<IAttachmentStoreConfig[]> {\n  if (GRIST_EXTERNAL_ATTACHMENTS_MODE === \"snapshots\") {\n    const snapshotProvider = create.getAttachmentStoreOptions().snapshots;\n    // This shouldn't happen - it could only happen if a version of Grist removes the snapshot provider from ICreate.\n    if (snapshotProvider === undefined) {\n      throw new UnsupportedExternalAttachmentsMode(\"snapshots\");\n    }\n    if (!(await isAttachmentStoreOptionAvailable(snapshotProvider))) {\n      log.warn(\"External attachment store 'snapshots' requested, but no snapshot storage is configured.\");\n      return [];\n    }\n    return [{\n      label: \"snapshots\",\n      spec: snapshotProvider,\n    }];\n  }\n  // TODO This mode should be removed once stores can be configured fully via env vars.\n  if (GRIST_EXTERNAL_ATTACHMENTS_MODE === \"test\") {\n    return [{\n      label: \"test-filesystem\",\n      spec: await makeTempFilesystemStoreSpec(),\n    }];\n  }\n  if (!GRIST_EXTERNAL_ATTACHMENTS_MODE) {\n    return [];\n  }\n  // GRIST_EXTERNAL_ATTACHMENTS_MODE has some value that doesn't make sense.\n  throw new UnsupportedExternalAttachmentsMode(GRIST_EXTERNAL_ATTACHMENTS_MODE);\n}\n"
  },
  {
    "path": "app/server/lib/AuditEvent.ts",
    "content": "import { FullUser } from \"app/common/LoginSessionAPI\";\nimport { BasicRole, NonGuestRole } from \"app/common/roles\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { Config } from \"app/gen-server/entity/Config\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { PreviousAndCurrent } from \"app/gen-server/lib/homedb/Interfaces\";\n\nexport interface AuditEvent<\n  Action extends AuditEventAction = AuditEventAction,\n> {\n  /**\n   * The event ID.\n   */\n  id: string;\n  /**\n   * The action that was performed.\n   */\n  action: Action;\n  /**\n   * Who performed the `action` in the event.\n   */\n  actor: AuditEventActor;\n  /**\n   * Where the event originated from.\n   */\n  context: AuditEventContext;\n  /**\n   * When the event occurred, in RFC 3339 format.\n   */\n  timestamp: string;\n  /**\n   * Additional details about the event.\n   */\n  details?: AuditEventDetails[Action];\n}\n\nexport const AuditEventAction = StringUnion(\n  \"config.create\",\n  \"config.delete\",\n  \"config.update\",\n  \"document.change_access\",\n  \"document.clear_all_webhook_queues\",\n  \"document.clear_webhook_queue\",\n  \"document.create\",\n  \"document.delete\",\n  \"document.deliver_webhook_events\",\n  \"document.disable\",\n  \"document.duplicate\",\n  \"document.enable\",\n  \"document.fork\",\n  \"document.modify\",\n  \"document.move\",\n  \"document.move_to_trash\",\n  \"document.open\",\n  \"document.pin\",\n  \"document.reload\",\n  \"document.rename\",\n  \"document.replace\",\n  \"document.restore_from_trash\",\n  \"document.run_sql_query\",\n  \"document.send_to_google_drive\",\n  \"document.truncate_history\",\n  \"document.unpin\",\n  \"site.change_access\",\n  \"site.create\",\n  \"site.delete\",\n  \"site.rename\",\n  \"user.change_name\",\n  \"user.create_api_key\",\n  \"user.delete\",\n  \"user.delete_api_key\",\n  \"workspace.change_access\",\n  \"workspace.create\",\n  \"workspace.delete\",\n  \"workspace.move_to_trash\",\n  \"workspace.rename\",\n  \"workspace.restore_from_trash\",\n);\n\nexport type AuditEventAction = typeof AuditEventAction.type;\n\nexport type AuditEventActor =\n  | UserActor |\n  GuestActor |\n  SystemActor |\n  UnknownActor;\n\ninterface UserActor {\n  type: \"user\";\n  user: Pick<FullUser, \"id\" | \"name\" | \"email\">;\n}\n\ninterface GuestActor {\n  type: \"guest\";\n}\n\ninterface SystemActor {\n  type: \"system\";\n}\n\ninterface UnknownActor {\n  type: \"unknown\";\n}\n\nexport interface AuditEventContext {\n  site?: Pick<Organization, \"id\"> &\n    Partial<Pick<Organization, \"name\" | \"domain\">>;\n  ip_address?: string;\n  user_agent?: string;\n  session_id?: string;\n}\n\nexport interface AuditEventDetails {\n  \"config.create\": {\n    config: Pick<Config, \"id\" | \"key\" | \"value\"> & {\n      site?: Pick<Organization, \"id\" | \"name\" | \"domain\">;\n    };\n  };\n  \"config.delete\": {\n    config: Pick<Config, \"id\" | \"key\" | \"value\"> & {\n      site?: Pick<Organization, \"id\" | \"name\" | \"domain\">;\n    };\n  };\n  \"config.update\": PreviousAndCurrent<{\n    config: Pick<Config, \"id\" | \"key\" | \"value\"> & {\n      site?: Pick<Organization, \"id\" | \"name\" | \"domain\">;\n    };\n  }>;\n  \"document.change_access\": {\n    document: Pick<Document, \"id\" | \"name\">;\n    access_changes: {\n      public_access?: NonGuestRole | null;\n      max_inherited_access?: BasicRole | null;\n      users?: (Pick<User, \"id\" | \"name\"> & { email?: string } & {\n        access: NonGuestRole | null;\n      })[];\n    };\n  };\n  \"document.clear_all_webhook_queues\": {\n    document: Pick<Document, \"id\">;\n  };\n  \"document.clear_webhook_queue\": {\n    document: Pick<Document, \"id\">;\n    webhook: {\n      id: string;\n    };\n  };\n  \"document.create\": {\n    document: Pick<Document, \"id\" | \"name\"> & {\n      workspace?: Pick<Workspace, \"id\" | \"name\">;\n    };\n  };\n  \"document.enable\": {\n    document: Pick<Document, \"id\" | \"name\"> & {\n      workspace: Pick<Workspace, \"id\" | \"name\">;\n    };\n  };\n  \"document.delete\": {\n    document: Pick<Document, \"id\" | \"name\">;\n  };\n  \"document.disable\": {\n    document: Pick<Document, \"id\" | \"name\"> & {\n      workspace: Pick<Workspace, \"id\" | \"name\">;\n    };\n  };\n  \"document.deliver_webhook_events\": {\n    document: Pick<Document, \"id\">;\n    webhook: {\n      id: string;\n      events: {\n        delivered_to: string;\n        quantity: number;\n      };\n    };\n  };\n  \"document.duplicate\": {\n    original: {\n      document: Pick<Document, \"id\" | \"name\">;\n    };\n    duplicate: {\n      document: Pick<Document, \"id\" | \"name\"> & {\n        workspace: Pick<Workspace, \"id\">;\n      };\n    };\n    options: {\n      as_template: boolean;\n    };\n  };\n  \"document.fork\": {\n    document: Pick<Document, \"id\" | \"name\">;\n    fork: {\n      id: string;\n      document_id: string;\n      url_id: string;\n    };\n  };\n  \"document.modify\": {\n    action: {\n      num: number;\n      hash: string | null;\n    };\n    document: Pick<Document, \"id\">;\n  };\n  \"document.move\": PreviousAndCurrent<{\n    document: Pick<Document, \"id\" | \"name\"> & {\n      workspace: Pick<Workspace, \"id\" | \"name\">;\n    };\n  }>;\n  \"document.move_to_trash\": {\n    document: Pick<Document, \"id\" | \"name\">;\n  };\n  \"document.open\": {\n    document: Pick<Document, \"id\" | \"name\"> & {\n      url_id: string;\n      fork_id?: string;\n      snapshot_id?: string;\n    };\n  };\n  \"document.pin\": {\n    document: Pick<Document, \"id\" | \"name\">;\n  };\n  \"document.reload\": {\n    document: Pick<Document, \"id\">;\n  };\n  \"document.rename\": PreviousAndCurrent<{\n    document: Pick<Document, \"id\" | \"name\">;\n  }>;\n  \"document.replace\": {\n    document: Pick<Document, \"id\">;\n    fork?: {\n      document_id: string;\n    };\n    snapshot?: {\n      id: string;\n    };\n  };\n  \"document.restore_from_trash\": {\n    document: Pick<Document, \"id\" | \"name\"> & {\n      workspace: Pick<Workspace, \"id\" | \"name\">;\n    };\n  };\n  \"document.run_sql_query\": {\n    document: Pick<Document, \"id\">;\n    sql_query: {\n      statement: string;\n      arguments?: (string | number)[] | null;\n    };\n    options: {\n      timeout_ms?: number;\n    };\n  };\n  \"document.send_to_google_drive\": {\n    document: Pick<Document, \"id\">;\n  };\n  \"document.truncate_history\": {\n    document: Pick<Document, \"id\">;\n    options: {\n      keep_n_most_recent: number;\n    };\n  };\n  \"document.unpin\": {\n    document: Pick<Document, \"id\" | \"name\">;\n  };\n  \"site.change_access\": {\n    site: Pick<Organization, \"id\" | \"name\" | \"domain\">;\n    access_changes: {\n      users: (Pick<User, \"id\" | \"name\"> & { email?: string } & {\n        access: NonGuestRole | null;\n      })[];\n    };\n  };\n  \"site.create\": {\n    site: Pick<Organization, \"id\" | \"name\" | \"domain\">;\n  };\n  \"site.delete\": {\n    site: Pick<Organization, \"id\" | \"name\" | \"domain\">;\n    error?: string;\n  };\n  \"site.rename\": PreviousAndCurrent<{\n    site: Pick<Organization, \"id\" | \"name\" | \"domain\">;\n  }>;\n  \"user.change_name\": PreviousAndCurrent<{\n    user: Pick<User, \"id\" | \"name\"> & {\n      email?: string;\n    };\n  }>;\n  \"user.create_api_key\": {\n    user: Pick<User, \"id\" | \"name\"> & {\n      email?: string;\n    };\n  };\n  \"user.delete\": {\n    user: Pick<User, \"id\" | \"name\"> & {\n      email?: string;\n    };\n  };\n  \"user.delete_api_key\": {\n    user: Pick<User, \"id\" | \"name\"> & {\n      email?: string;\n    };\n  };\n  \"workspace.change_access\": {\n    workspace: Pick<Workspace, \"id\" | \"name\">;\n    access_changes: {\n      max_inherited_access?: BasicRole | null;\n      users?: (Pick<User, \"id\" | \"name\"> & { email?: string } & {\n        access: NonGuestRole | null;\n      })[];\n    };\n  };\n  \"workspace.create\": {\n    workspace: Pick<Workspace, \"id\" | \"name\">;\n  };\n  \"workspace.delete\": {\n    workspace: Pick<Workspace, \"id\" | \"name\">;\n    error?: string;\n  };\n  \"workspace.move_to_trash\": {\n    workspace: Pick<Workspace, \"id\" | \"name\">;\n  };\n  \"workspace.rename\": PreviousAndCurrent<{\n    workspace: Pick<Workspace, \"id\" | \"name\">;\n  }>;\n  \"workspace.restore_from_trash\": {\n    workspace: Pick<Workspace, \"id\" | \"name\">;\n  };\n}\n"
  },
  {
    "path": "app/server/lib/AuthSession.ts",
    "content": "/**\n * In Grist, there are two main types of request context: one is an express.Request object (for\n * loading pages, API calls, and other HTTP requests); the other is a method call via websocket,\n * where the context is held in Client.ts and determined by the request that created the websocket.\n *\n * Various properties of the context, such as the associated user, document, session, etc, have\n * evolved separately, and there is a slew of objects and flows for representing this state, as\n * well as methods that attempt to deal with it a bit more consistency.\n *\n * Here we make an attempt to simplify the situation:\n * (1) AuthSession represents the user and session, and is available for a Request as\n *     AuthSession.fromReq(req) and for a Client as client.authSession.\n * (2) DocSession has fields specific to a particular Grist document. It is made available in\n *     Request and in Client as the member .docSession, once it's known that those requests\n *     represent a particular document.\n */\n\nimport { ApiError } from \"app/common/ApiError\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport { ILogMeta } from \"app/server/lib/log\";\n\nimport moment from \"moment\";\n\nimport type { RequestWithLogin } from \"app/server/lib/Authorizer\";\n\nexport abstract class AuthSession {\n  // Create AuthSession from request. (This is very cheap to create.)\n  public static fromReq(req: RequestWithLogin): AuthSession {\n    return new AuthSessionForReq(req);\n  }\n\n  public static fromUser(fullUser: FullUser, org: string, altSessionId?: string): AuthSession {\n    return new AuthSessionForUser(fullUser, org, altSessionId);\n  }\n\n  public static unauthenticated(): AuthSession { return new UnauthenticatedAuthSession(); }\n\n  public abstract org?: string;\n  public abstract altSessionId: string | null;\n  public abstract userId: number | null;\n  public abstract userIsAuthorized: boolean;\n  public abstract fullUser: FullUser | null;\n\n  public get normalizedEmail(): string | undefined { return this.fullUser?.loginEmail ?? this.fullUser?.email; }\n  public get displayEmail(): string | undefined { return this.fullUser?.email; }\n  public get userAgeInDays(): number | undefined { return this._userAge ?? (this._userAge = this._calcAgeInDays()); }\n\n  private _userAge?: number;\n\n  public requiredUserId(): number {\n    return this.userId || apiFail(\"user not known\", 401);\n  }\n\n  public getLogMeta(): ILogMeta {\n    // Setting each field conditionally here to omit keys with undefined/null values.\n    const meta: ILogMeta = {};\n    const [org, email, userId, altSessionId, age] =\n      [this.org, this.normalizedEmail, this.userId, this.altSessionId, this.userAgeInDays];\n    if (org != null) { meta.org = org; }\n    if (email != null) { meta.email = email; }\n    if (userId != null) { meta.userId = userId; }\n    if (altSessionId != null) { meta.altSessionId = altSessionId; }\n    if (age != null) { meta.age = age; }\n    return meta;\n  }\n\n  private _calcAgeInDays() {\n    const firstLoginAt = this.fullUser?.firstLoginAt;\n    return firstLoginAt ? Math.floor(moment.duration(moment().diff(firstLoginAt)).asDays()) : undefined;\n  }\n}\n\nclass UnauthenticatedAuthSession extends AuthSession {\n  public readonly org = undefined;\n  public readonly altSessionId = null;\n  public readonly userId = null;\n  public readonly userIsAuthorized = false;\n  public readonly fullUser = null;\n}\n\nclass AuthSessionForReq extends AuthSession {\n  constructor(private _req: RequestWithLogin) { super(); }\n  public get org() { return this._req.org; }\n  public get altSessionId() { return this._req.altSessionId ?? null; }\n  public get userId() { return this._req.userId ?? null; }\n  public get userIsAuthorized() { return this._req.userIsAuthorized || false; }\n  public get fullUser() { return this._req.fullUser ?? null; }\n}\n\nclass AuthSessionForUser extends AuthSession {\n  constructor(private _fullUser: FullUser, private _org: string, private _altSessionId?: string) { super(); }\n  public get org() { return this._org; }\n  public get altSessionId() { return this._altSessionId ?? null; }\n  public get userId() { return this._fullUser.id; }\n  public get userIsAuthorized() { return !this._fullUser.anonymous; }\n  public get fullUser() { return this._fullUser; }\n}\n\nfunction apiFail(errMessage: string, errStatus: number): never {\n  throw new ApiError(errMessage, errStatus);\n}\n"
  },
  {
    "path": "app/server/lib/Authorizer.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { OpenDocMode } from \"app/common/DocListAPI\";\nimport { ErrorWithCode } from \"app/common/ErrorWithCode\";\nimport { ActivationState } from \"app/common/gristUrls\";\nimport { FullUser, UserProfile } from \"app/common/LoginSessionAPI\";\nimport { canEdit, canView, getWeakestRole } from \"app/common/roles\";\nimport { UserOptions } from \"app/common/UserAPI\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { DocAuthResult, HomeDBAuth } from \"app/gen-server/lib/homedb/Interfaces\";\nimport { AccessTokenInfo } from \"app/server/lib/AccessTokens\";\nimport { forceSessionChange, getSessionProfiles, getSessionUser, getSignInStatus, linkOrgWithEmail, SessionObj,\n  SessionUserObj, SignInStatus } from \"app/server/lib/BrowserSession\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { RequestWithOrg } from \"app/server/lib/extractOrg\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { COOKIE_MAX_AGE,\n  cookieName as sessionCookieName, getAllowedOrgForSessionID, getCookieDomain } from \"app/server/lib/gristSessions\";\nimport { makeId } from \"app/server/lib/idUtils\";\nimport log from \"app/server/lib/log\";\nimport { IPermitStore, Permit } from \"app/server/lib/Permit\";\nimport { allowHost, buildXForwardedForHeader, getOriginUrl, optStringParam } from \"app/server/lib/requestUtils\";\n\nimport { IncomingMessage } from \"http\";\n\nimport * as cookie from \"cookie\";\nimport { NextFunction, Request, RequestHandler, Response } from \"express\";\nimport onHeaders from \"on-headers\";\n\nexport interface RequestWithLogin extends Request {\n  sessionID: string;\n  session: SessionObj;\n  org?: string;\n  isCustomHost?: boolean;  // when set, the request's domain is a recognized custom host linked\n  // with the specified org.\n  fullUser?: FullUser;\n  users?: UserProfile[];\n  userId?: number;\n  user?: User;\n  userIsAuthorized?: boolean;   // If userId is for \"anonymous\", this will be false.\n  docAuth?: DocAuthResult;      // For doc requests, the docId and the user's access level.\n  specialPermit?: Permit;\n  accessToken?: AccessTokenInfo;\n  altSessionId?: string;   // a session id for use in trigger formulas and granular access rules\n  activation?: ActivationState;\n}\n\n/**\n * Extract the user id from a request, assuming we've added it via appropriate middleware.\n * Throws ApiError with code 401 (unauthorized) if the user id is missing.\n */\nexport function getUserId(req: Request): number {\n  const userId = (req as RequestWithLogin).userId;\n  if (!userId) {\n    throw new ApiError(\"user not known\", 401);\n  }\n  return userId;\n}\n\n/**\n * Extract the user object from a request, assuming we've added it via appropriate middleware.\n * Throws ApiError with code 401 (unauthorized) if the user is missing.\n */\nexport function getUser(req: Request): User {\n  const user = (req as RequestWithLogin).user;\n  if (!user) {\n    throw new ApiError(\"user not known\", 401);\n  }\n  return user;\n}\n\n/**\n * Extract the user profiles from a request, assuming we've added them via appropriate middleware.\n * Throws ApiError with code 401 (unauthorized) if the profiles are missing.\n */\nexport function getUserProfiles(req: Request): UserProfile[] {\n  const users = (req as RequestWithLogin).users;\n  if (!users) {\n    throw new ApiError(\"user profile not found\", 401);\n  }\n  return users;\n}\n\n// Extract the user id from a request, requiring it to be authorized (not an anonymous session).\nexport function getAuthorizedUserId(req: Request) {\n  const userId = getUserId(req);\n  if (isAnonymousUser(req)) {\n    throw new ApiError(\"user not authorized\", 401);\n  }\n  return userId;\n}\n\nexport function isAnonymousUser(req: Request) {\n  return !(req as RequestWithLogin).userIsAuthorized;\n}\n\n// True if Grist is configured for a single user without specific authorization\n// (classic standalone/electron mode).\nexport function isSingleUserMode(): boolean {\n  return process.env.GRIST_SINGLE_USER === \"1\";\n}\n\n/**\n * Returns a profile if it can be deduced from the request. This requires a\n * header to specify the users' email address.\n * A result of null means that the user should be considered known to be anonymous.\n * A result of undefined means we should go on to consider other authentication\n * methods (such as cookies).\n */\nexport function getRequestProfile(req: Request | IncomingMessage,\n  header: string): UserProfile | null | undefined {\n  let profile: UserProfile | null | undefined;\n\n  // Careful reading headers. If we have an IncomingMessage, there is no\n  // get() function, and header names are lowercased.\n  const headerContent = (\"get\" in req) ? req.get(header) : req.headers[header.toLowerCase()];\n  if (headerContent) {\n    const userEmail = headerContent.toString();\n    const [userName] = userEmail.split(\"@\", 1);\n    if (userEmail && userName) {\n      profile = {\n        email: userEmail,\n        name: userName,\n      };\n    }\n  }\n  // If no profile at this point, and header was present,\n  // treat as anonymous user, represented by null value.\n  // Don't go on to look at session.\n  if (!profile && headerContent !== undefined) {\n    profile = null;\n  }\n  return profile;\n}\n\nfunction setRequestUser(mreq: RequestWithLogin, dbManager: HomeDBAuth, user: User) {\n  mreq.user = user;\n  mreq.userId = user.id;\n  mreq.userIsAuthorized = (user.id !== dbManager.getAnonymousUserId());\n\n  const fullUser = dbManager.makeFullUser(user);\n  // This is dumb, but historically, we used 'email' field inconsistently; in this Authorizer\n  // flow, it was set to the normalized email, rather than the display email. The difference is\n  // visible in the value of the `user.Email` attribute seen by access rules for **API requests**,\n  // while requests from web UI, via websocket, have 'email' set to the display email. We preserve\n  // this awful discrepancy until we find courage to risk breaking existing access rules. (The\n  // worst of it is addressed by using cases-insensitive comparisons for UserAttributes.)\n  if (fullUser.loginEmail) {\n    fullUser.email = fullUser.loginEmail;\n  }\n  mreq.fullUser = fullUser;\n  if (!mreq.users) {\n    mreq.users = [fullUser];\n  }\n}\n\n/**\n * Returns the express request object with user information added, if it can be\n * found based on passed in headers or the session.  Specifically, sets:\n *   - req.userId: the id of the user in the database users table\n *   - req.userIsAuthorized: set if user has presented credentials that were accepted\n *     (the anonymous user has a userId but does not have userIsAuthorized set if,\n *     as would typically be the case, credentials were not presented)\n *   - req.users: set for org-and-session-based logins, with list of profiles in session\n */\nexport async function addRequestUser(\n  dbManager: HomeDBAuth, permitStore: IPermitStore,\n  options: {\n    gristServer: GristServer,\n    skipSession?: boolean,\n    overrideProfile?(req: Request | IncomingMessage): Promise<UserProfile | null | undefined>,\n  },\n  req: Request, res: Response, next: NextFunction,\n) {\n  const mreq = req as RequestWithLogin;\n  let profile: UserProfile | undefined;\n\n  // We support multiple method of authentication. This flag gets set once\n  // we need not try any more. Specifically, it is used to avoid processing\n  // anything else after setting an access token, for simplicity in reasoning\n  // about this case.\n  let authDone: boolean = false;\n\n  let hasApiKey: boolean = false;\n\n  // Support providing an access token via an `auth` query parameter.\n  // This is useful for letting the browser load assets like image\n  // attachments.\n  const auth = optStringParam(mreq.query.auth, \"auth\");\n  if (auth) {\n    const tokens = options.gristServer.getAccessTokens();\n    const token = await tokens.verify(auth);\n    mreq.accessToken = token;\n    // Once an accessToken is supplied, we don't consider anything else.\n    // User is treated as anonymous apart from having an accessToken.\n    authDone = true;\n  }\n\n  // Now, check for an apiKey\n  if (!authDone && mreq.headers?.authorization) {\n    // header needs to be of form \"Bearer XXXXXXXXX\" to apply\n    const parts = String(mreq.headers.authorization).split(\" \");\n    if (parts[0] === \"Bearer\") {\n      const user = parts[1] ? await dbManager.getUserByKey(parts[1]) : undefined;\n      if (!user) {\n        return res.status(401).send(\"Bad request: invalid API key\");\n      }\n      if (user.type === \"service\") {\n        const serviceAccount = (await dbManager.getServiceAccountByLoginWithOwner(user.loginEmail!))!;\n        if (serviceAccount.owner.disabledAt) {\n          return res.status(403).send(\"Owner account is disabled\");\n        }\n        if (!serviceAccount.isActive()) {\n          return res.status(401).send(\"Service Account has expired\");\n        }\n      }\n      if (user.id === dbManager.getAnonymousUserId()) {\n        // We forbid the anonymous user to present an api key.  That saves us\n        // having to think through the consequences of authorized access to the\n        // anonymous user's profile via the api (e.g. how should the api key be managed).\n        return res.status(401).send(\"Credentials cannot be presented for the anonymous user account via API key\");\n      }\n      setRequestUser(mreq, dbManager, user);\n      hasApiKey = true;\n    }\n  }\n\n  // Check if we have a boot key. This is a fallback mechanism for an\n  // administrator to authenticate themselves by demonstrating access\n  // to the environment.\n  if (!authDone && mreq.headers?.[\"x-boot-key\"]) {\n    const reqBootKey = String(mreq.headers[\"x-boot-key\"]);\n    const bootKey = options.gristServer.getBootKey();\n    if (!bootKey || bootKey !== reqBootKey) {\n      return res.status(401).send(\"Bad request: invalid Boot key\");\n    }\n    const admin = options.gristServer.getInstallAdmin();\n    const user = await admin.getAdminUser();\n    if (!user) {\n      return res.status(500).send(\"No admin user available\");\n    }\n    setRequestUser(mreq, dbManager, user);\n    authDone = true;\n  }\n\n  // Special permission header for internal housekeeping tasks\n  if (!authDone && mreq.headers?.permit) {\n    const permitKey = String(mreq.headers.permit);\n    try {\n      const permit = await permitStore.getPermit(permitKey);\n      if (!permit) { return res.status(401).send(\"Bad request: unknown permit\"); }\n      setRequestUser(mreq, dbManager, dbManager.getAnonymousUser());\n      mreq.specialPermit = permit;\n    } catch (err) {\n      log.error(`problem reading permit: ${err}`);\n      return res.status(401).send(\"Bad request: permit could not be read\");\n    }\n  }\n\n  // If we haven't already been authenticated, and this is not a GET/HEAD/OPTIONS, then\n  // require a header that would trigger a CORS pre-flight request, either:\n  //   - X-Requested-With: XMLHttpRequest\n  //       - https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#use-of-custom-request-headers\n  //       - https://markitzeroday.com/x-requested-with/cors/2017/06/29/csrf-mitigation-for-ajax-requests.html\n  //   - Content-Type: application/json\n  //       - https://www.directdefense.com/csrf-in-the-age-of-json/\n  // This is trivial for legitimate web clients to do, and an obstacle to\n  // nefarious ones.\n  if (\n    !mreq.userId &&\n    !(mreq.xhr || mreq.get(\"content-type\") === \"application/json\") &&\n    ![\"GET\", \"HEAD\", \"OPTIONS\"].includes(mreq.method)\n  ) {\n    return res.status(401).json({\n      error: \"Unauthenticated requests require one of the headers\" +\n        \"'Content-Type: application/json' or 'X-Requested-With: XMLHttpRequest'\",\n    });\n  }\n\n  // For some configurations, the user profile can be determined from the request.\n  // If this is the case, we won't use session information.\n  let skipSession: boolean = options.skipSession || authDone;\n  if (!authDone && !mreq.userId) {\n    const candidateProfile = await options.overrideProfile?.(mreq);\n    if (candidateProfile !== undefined) {\n      // Either a valid or a null profile tells us that another login system determined the user,\n      // and that we should skip sessions.\n      skipSession = true;\n      if (candidateProfile) {\n        profile = candidateProfile;\n        const user = await dbManager.getUserByLoginWithRetry(profile.email, { profile });\n        if (user) {\n          setRequestUser(mreq, dbManager, user);\n        }\n      }\n    }\n  }\n\n  // A bit of extra info we'll add to the \"Auth\" log message when this request passes the check\n  // for custom-host-specific sessionID.\n  let customHostSession = \"\";\n\n  if (!authDone && !skipSession) {\n    // If we haven't selected a user by other means, and have profiles available in the\n    // session, then select a user based on those profiles.\n    const session = mreq.session;\n    if (session && !session.altSessionId) {\n      // Create a default alternative session id for use in documents.\n      session.altSessionId = makeId();\n      forceSessionChange(session);\n    }\n    mreq.altSessionId = session?.altSessionId;\n    if (!mreq.userId && session?.users && session.users.length > 0 &&\n      mreq.org !== undefined) {\n      // Prevent using custom-domain sessionID to authorize to a different domain, since\n      // custom-domain owner could hijack such sessions.\n      const allowedOrg = getAllowedOrgForSessionID(mreq.sessionID);\n      if (allowedOrg) {\n        if (allowHost(req, allowedOrg.host)) {\n          customHostSession = ` custom-host-match ${allowedOrg.host}`;\n        } else {\n          // We need an exception for internal forwarding from home server to doc-workers. These use\n          // internal hostnames, so we can't expect a custom domain. These requests do include an\n          // Organization header, which we'll use to grant the exception, but security issues remain.\n          // TODO Issue 1: an attacker can use a custom-domain request to get an API key, which is an\n          // open door to all orgs accessible by this user.\n          // TODO Issue 2: Organization header is easy for an attacker (who has stolen a session\n          // cookie) to include too; it does nothing to prove that the request is internal.\n          const org = req.header(\"organization\");\n          if (org && org === allowedOrg.org) {\n            customHostSession = ` custom-host-fwd ${org}`;\n          } else {\n            // Log error and fail.\n            log.warn(\"Auth[%s]: sessionID for host %s org %s; wrong for host %s org %s\", mreq.method,\n              allowedOrg.host, allowedOrg.org, mreq.get(\"host\"), mreq.org);\n            return res.status(403).send(\"Bad request: invalid session ID\");\n          }\n        }\n      }\n\n      mreq.users = getSessionProfiles(session);\n\n      // If we haven't set a maxAge yet, set it now.\n      if (session?.cookie && !session.cookie.maxAge) {\n        if (COOKIE_MAX_AGE !== null) {\n          session.cookie.maxAge = COOKIE_MAX_AGE;\n          forceSessionChange(session);\n        }\n      }\n\n      // See if we have a profile linked with the active organization already.\n      // TODO: implement userSelector for rest API, to allow \"sticky\" user selection on pages.\n      let sessionUser: SessionUserObj | null = getSessionUser(session, mreq.org,\n        optStringParam(mreq.query.user, \"user\") || \"\");\n\n      if (!sessionUser) {\n        // No profile linked yet, so let's elect one.\n        // Choose a profile that is no worse than the others available.\n        const option = await dbManager.getBestUserForOrg(mreq.users, mreq.org);\n        if (option) {\n          // Modify request session object to link the current org with our choice of\n          // profile.  Express-session will save this change.\n          sessionUser = linkOrgWithEmail(session, option.email, mreq.org);\n          const userOptions: UserOptions = {};\n          if (sessionUser?.profile?.loginMethod === \"Email + Password\") {\n            // Link the session authSubject, if present, to the user. This has no effect\n            // if the user already has an authSubject set in the db.\n            userOptions.authSubject = sessionUser.authSubject;\n          }\n          // In this special case of initially linking a profile, we need to look up the user's info.\n          const user = await dbManager.getUserByLogin(option.email, { userOptions });\n          setRequestUser(mreq, dbManager, user);\n        } else {\n          // No profile has access to this org.  We could choose to\n          // link no profile, in which case user will end up\n          // immediately presented with a sign-in page, or choose to\n          // link an arbitrary profile (say, the first one the user\n          // logged in as), in which case user will end up with a\n          // friendlier page explaining the situation and offering to\n          // add an account to resolve it.  We go ahead and pick an\n          // arbitrary profile.\n          sessionUser = session.users[0];\n          if (!session.orgToUser) { throw new Error(\"Session misconfigured\"); }\n          // Express-session will save this change.\n          session.orgToUser[mreq.org] = 0;\n        }\n      }\n\n      profile = sessionUser?.profile ?? undefined;\n\n      // If we haven't computed a userId yet, check for one using an email address in the profile.\n      // A user record will be created automatically for emails we've never seen before.\n      if (profile && !mreq.userId) {\n        const userOptions: UserOptions = {};\n        if (profile?.loginMethod === \"Email + Password\") {\n          // Link the session authSubject, if present, to the user. This has no effect\n          // if the user already has an authSubject set in the db.\n          userOptions.authSubject = sessionUser.authSubject;\n        }\n        const user = await dbManager.getUserByLoginWithRetry(profile.email, { profile, userOptions });\n        if (user) {\n          setRequestUser(mreq, dbManager, user);\n        }\n      }\n    }\n  }\n\n  // Disabled users get no rights, not even public pages. Almost\n  // everything is forbidden once you've been disabled. You'll have to\n  // log out to see resources available to the anonymous user (except\n  // for session GET requests, as noted below)\n  if (mreq.user?.disabledAt) {\n    // In order to let a disabled user know that they're logged in and\n    // to let them log out, we'll grant them GET access to these two\n    // endpoints. Otherwise the 403 error page on the client side can't\n    // get an active user and thinks the user isn't logged in at all,\n    // which can be more confusing than necessary.\n    const isSessionGetRequest = (\n      [\"/session/access/active\", \"/session/access/all\"].includes(mreq.url) &&\n      mreq.method === \"GET\"\n    );\n\n    if (!isSessionGetRequest) {\n      throw new ApiError(\"User is disabled\", 403);\n    }\n  }\n\n  // If no userId has been found yet fall back on anonymous.\n  if (!mreq.userId) {\n    setRequestUser(mreq, dbManager, dbManager.getAnonymousUser());\n  }\n\n  if (mreq.userId) {\n    if (mreq.user?.options?.locale) {\n      mreq.language = mreq.user.options.locale;\n      // This is a synchronous call (as it was configured with initImmediate: false).\n      mreq.i18n.changeLanguage(mreq.language).catch(() => {});\n    }\n  }\n\n  const meta = {\n    customHostSession,\n    method: mreq.method,\n    host: mreq.get(\"host\"),\n    path: mreq.path,\n    org: mreq.org,\n    email: mreq.user?.loginEmail,\n    userId: mreq.userId,\n    altSessionId: mreq.altSessionId,\n  };\n  log.rawDebug(`Auth[${meta.method}]: ${meta.host} ${meta.path}`, meta);\n  if (hasApiKey) {\n    options.gristServer.getTelemetry().logEvent(mreq, \"apiUsage\", {\n      full: {\n        method: mreq.method,\n        userId: mreq.userId,\n        userAgent: mreq.headers[\"user-agent\"],\n      },\n    });\n  }\n\n  return next();\n}\n\n/**\n * Returns a handler that redirects the user to a login or signup page.\n */\nexport function redirectToLoginUnconditionally(\n  getLoginRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,\n  getSignUpRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,\n) {\n  return expressWrap(async (req: Request, resp: Response, next: NextFunction) => {\n    const mreq = req as RequestWithLogin;\n    // Tell express-session to set our cookie: session handling post-login relies on it.\n    forceSessionChange(mreq.session);\n\n    // Redirect to sign up if it doesn't look like the user has ever logged in (on\n    // this browser)  After logging in, `users` will be set in the session.  Even after\n    // logging out again, `users` will still be set.\n    const signUp: boolean = (mreq.session.users === undefined);\n    log.debug(`Authorizer: redirecting to ${signUp ? \"sign up\" : \"log in\"}`);\n    const redirectUrl = new URL(getOriginUrl(req) + req.originalUrl);\n    if (signUp) {\n      return resp.redirect(await getSignUpRedirectUrl(req, redirectUrl));\n    } else {\n      return resp.redirect(await getLoginRedirectUrl(req, redirectUrl));\n    }\n  });\n}\n\n/**\n * Middleware to redirects user to a login page when the user is not\n * logged in.  If allowExceptions is set, then we make an exception\n * for a team site allowing anonymous access, or a personal doc\n * allowing anonymous access, or the merged org.\n */\nexport function redirectToLogin(\n  allowExceptions: boolean,\n  getLoginRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,\n  getSignUpRedirectUrl: (req: Request, redirectUrl: URL) => Promise<string>,\n  dbManager: HomeDBManager,\n): RequestHandler {\n  const redirectUnconditionally = redirectToLoginUnconditionally(getLoginRedirectUrl,\n    getSignUpRedirectUrl);\n  return expressWrap(async (req: Request, resp: Response, next: NextFunction) => {\n    const mreq = req as RequestWithLogin;\n    // This will ensure that express-session will set our cookie if it hasn't already -\n    // we'll need it if we redirect.\n    forceSessionChange(mreq.session);\n    if (mreq.userIsAuthorized) { return next(); }\n\n    try {\n      // Otherwise it's an anonymous user. Proceed normally only if the org allows anon access,\n      // or if the org is not set (FlexServer._redirectToOrg will deal with that case).\n      if (mreq.userId && allowExceptions) {\n        // Anonymous user has qualified access to merged org.\n        // If no org is set, leave it to other middleware.  One common case where the\n        // org is not set is when it is embedded in the url, and the user visits '/'.\n        // If we immediately require a login, it could fail if no cookie exists yet.\n        // Also, '/o/docs' allows anonymous access.\n        if (!mreq.org || dbManager.isMergedOrg(mreq.org)) { return next(); }\n        const result = await dbManager.getOrg({ userId: mreq.userId }, mreq.org);\n        if (result.status === 200) { return next(); }\n      }\n\n      // In all other cases (including unknown org), redirect user to login or sign up.\n      return redirectUnconditionally(req, resp, next);\n    } catch (err) {\n      log.info(\"Authorizer failed to redirect\", err.message);\n      return resp.status(401).send(err.message);\n    }\n  });\n}\n\n/**\n * Sets mreq.docAuth if not yet set, and returns it.\n */\nexport async function getOrSetDocAuth(\n  mreq: RequestWithLogin, dbManager: HomeDBManager,\n  gristServer: GristServer,\n  urlId: string,\n): Promise<DocAuthResult> {\n  if (!mreq.docAuth) {\n    let effectiveUserId = getUserId(mreq);\n    if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId()) {\n      effectiveUserId = dbManager.getPreviewerUserId();\n    }\n\n    // A permit with a token gives us the userId associated with that token.\n    const tokenObj = mreq.accessToken;\n    if (tokenObj) {\n      effectiveUserId = tokenObj.userId;\n    }\n\n    mreq.docAuth = await dbManager.getDocAuthCached({ urlId, userId: effectiveUserId, org: mreq.org });\n\n    if (tokenObj) {\n      // Sanity check: does the current document match the document the token is\n      // for? If not, fail.\n      if (!mreq.docAuth.docId || mreq.docAuth.docId !== tokenObj.docId) {\n        throw new ApiError(\"token misuse\", 401);\n      }\n      // Limit access to read-only if specified.\n      if (tokenObj.readOnly) {\n        mreq.docAuth = { ...mreq.docAuth, access: getWeakestRole(\"viewers\", mreq.docAuth.access) };\n      }\n    }\n\n    // A permit with a user set to the anonymous user and linked to this document\n    // gets updated to full access.\n    if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId() &&\n      mreq.specialPermit.docId === mreq.docAuth.docId) {\n      mreq.docAuth = { ...mreq.docAuth, access: \"owners\" };\n    }\n  }\n  return mreq.docAuth;\n}\n\nexport interface ResourceSummary {\n  kind: \"doc\";\n  id: string | number;\n}\n\ninterface AssertAccessOptions {\n  openMode?: OpenDocMode,\n  // Normally removed docs are disallowed all access. Setting this\n  // property to `true` will allow access to removed docs, in addition\n  // to whatever other access is already granted or denied.\n  allowRemoved?: boolean,\n  // As above, but for disabled docs, which are normally otherwise\n  // disallowed in all cases.\n  allowDisabled?: boolean,\n}\n\nexport function assertAccess(\n  role: \"viewers\" | \"editors\" | \"owners\", docAuth: DocAuthResult, options: AssertAccessOptions = {}) {\n  const openMode = options.openMode || \"default\";\n  const details = { status: 403, accessMode: openMode };\n  if (docAuth.error) {\n    if ([400, 401, 403].includes(docAuth.error.status)) {\n      // For these error codes, we know our access level - forbidden. Make errors more uniform.\n      throw new ErrorWithCode(\"AUTH_NO_VIEW\", \"No view access\", details);\n    }\n    throw docAuth.error;\n  }\n\n  if (docAuth.removed && !options.allowRemoved) {\n    throw new ErrorWithCode(\"AUTH_NO_VIEW\", \"Document is deleted\", { status: 404 });\n  }\n\n  // Disabled docs have no permissions, except you can delete or undelete them\n  if (docAuth.disabled && !options.allowDisabled) {\n    throw new ErrorWithCode(\"AUTH_DOC_DISABLED\", \"Document is disabled\", { status: 403 });\n  }\n\n  // If docAuth has no error, the doc is accessible, but we should still check the level (in case\n  // it's possible to access the doc with a level less than \"viewer\").\n  if (!canView(docAuth.access)) {\n    throw new ErrorWithCode(\"AUTH_NO_VIEW\", \"No view access\", details);\n  }\n\n  if (role === \"editors\") {\n    // If opening in a fork or view mode, treat user as viewer and deny write access.\n    const access = (openMode === \"fork\" || openMode === \"view\") ?\n      getWeakestRole(\"viewers\", docAuth.access) : docAuth.access;\n    if (!canEdit(access)) {\n      throw new ErrorWithCode(\"AUTH_NO_EDIT\", \"No write access\", details);\n    }\n  }\n\n  if (role === \"owners\" && docAuth.access !== \"owners\") {\n    throw new ErrorWithCode(\"AUTH_NO_OWNER\", \"No owner access\", details);\n  }\n}\n\n/**\n * Pull out headers to pass along to a proxied service.  Focused primarily on\n * authentication.\n */\nexport function getTransitiveHeaders(\n  req: Request,\n  { includeOrigin }: { includeOrigin: boolean },\n): { [key: string]: string } {\n  const Authorization = req.get(\"Authorization\");\n  const Cookie = req.get(\"Cookie\");\n  const PermitHeader = req.get(\"Permit\");\n  const Organization = (req as RequestWithOrg).org;\n  const XRequestedWith = req.get(\"X-Requested-With\");\n  const UserAgent = req.get(\"User-Agent\");\n  const Origin = req.get(\"Origin\");  // Pass along the original Origin since it may\n  // play a role in granular access control.\n\n  const result: Record<string, string> = {\n    ...(Authorization ? { Authorization } : undefined),\n    ...(Cookie ? { Cookie } : undefined),\n    ...(Organization ? { Organization } : undefined),\n    ...(PermitHeader ? { Permit: PermitHeader } : undefined),\n    ...(XRequestedWith ? { \"X-Requested-With\": XRequestedWith } : undefined),\n    ...(UserAgent ? { \"User-Agent\": UserAgent } : undefined),\n    ...buildXForwardedForHeader(req),\n    ...((includeOrigin && Origin) ? { Origin } : undefined),\n  };\n  const extraHeader = process.env.GRIST_FORWARD_AUTH_HEADER;\n  const extraHeaderValue = extraHeader && req.get(extraHeader);\n  if (extraHeader && extraHeaderValue) {\n    result[extraHeader] = extraHeaderValue;\n  }\n  return result;\n}\n\nexport const signInStatusCookieName = sessionCookieName + \"_status\";\n\n// We expose a sign-in status in a cookie accessible to all subdomains, to assist in auto-signin.\n// Its value is SignInStatus (\"S\", \"M\" or unset). This middleware keeps this cookie in sync with\n// the session state.\n//\n// Note that this extra cookie isn't strictly necessary today: since it has similar settings to\n// the session cookie, subdomains can infer status from that one. It is here in anticipation that\n// we make sessions a host-only cookie, to avoid exposing it to externally-hosted subdomains of\n// getgrist.com. In that case, the sign-in status cookie would remain a 2nd-level domain cookie.\nexport function signInStatusMiddleware(req: Request, resp: Response, next: NextFunction) {\n  const mreq = req as RequestWithLogin;\n\n  let origSignInStatus: SignInStatus = \"\";\n  if (req.headers.cookie) {\n    const cookies = cookie.parse(req.headers.cookie);\n    origSignInStatus = cookies[signInStatusCookieName] || \"\";\n  }\n\n  onHeaders(resp, () => {\n    const newSignInStatus = getSignInStatus(mreq.session);\n    if (newSignInStatus !== origSignInStatus) {\n      // If not signed-in any more, set a past date to delete this cookie.\n      const expires = (newSignInStatus && mreq.session.cookie.expires) || new Date(0);\n      resp.append(\"Set-Cookie\", cookie.serialize(signInStatusCookieName, newSignInStatus, {\n        httpOnly: false,    // make available to client-side scripts\n        expires,\n        domain: getCookieDomain(req),\n        path: \"/\",\n        sameSite: \"lax\",    // same setting as for grist-sid is fine here.\n      }));\n    }\n  });\n  next();\n}\n"
  },
  {
    "path": "app/server/lib/BootProbes.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { BootProbeIds, BootProbeResult } from \"app/common/BootProbe\";\nimport { removeTrailingSlash } from \"app/common/gutil\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { expressWrap, jsonErrorHandler } from \"app/server/lib/expressWrap\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { DEFAULT_SESSION_SECRET } from \"app/server/lib/ICreate\";\n\nimport * as express from \"express\";\nimport fetch from \"node-fetch\";\nimport WS from \"ws\";\n\n/**\n * Self-diagnostics useful when installing Grist.\n */\nexport class BootProbes {\n  // List of probes.\n  public _probes = new Array<Probe>();\n\n  // Probes indexed by id.\n  public _probeById = new Map<string, Probe>();\n\n  public constructor(private _app: express.Application,\n    private _server: GristServer,\n    private _base: string,\n    private _middleware: express.Handler[] = []) {\n    this._addProbes();\n  }\n\n  public addEndpoints() {\n    // Return a list of available probes.\n    // GET /api/probes\n    this._app.use(`${this._base}/probes$`,\n      ...this._middleware,\n      expressWrap(async (_, res) => {\n        res.json({\n          probes: this._probes.map((probe) => {\n            return { id: probe.id, name: probe.name };\n          }),\n        });\n      }));\n\n    // Return result of running an individual probe.\n    // GET /api/probes/:probeId\n    this._app.use(`${this._base}/probes/:probeId`,\n      ...this._middleware,\n      expressWrap(async (req, res) => {\n        const probe = this._probeById.get(req.params.probeId);\n        if (!probe) {\n          throw new ApiError(\"unknown probe\", 400);\n        }\n        const result = await probe.apply(this._server, req);\n        res.json(result);\n      }));\n\n    // Fall-back for errors.\n    this._app.use(`${this._base}/probes`, jsonErrorHandler);\n  }\n\n  private _addProbes() {\n    this._probes.push(_homeUrlReachableProbe);\n    this._probes.push(_statusCheckProbe);\n    this._probes.push(_userProbe);\n    this._probes.push(_bootProbe);\n    this._probes.push(_hostHeaderProbe);\n    this._probes.push(_sandboxingProbe);\n    this._probes.push(_authenticationProbe);\n    this._probes.push(_webSocketsProbe);\n    this._probes.push(_sessionSecretProbe);\n    this._probes.push(_admins);\n    this._probeById = new Map(this._probes.map(p => [p.id, p]));\n  }\n}\n\n/**\n * An individual probe has an id, a name, an optional description,\n * and a method that returns a probe result.\n */\nexport interface Probe {\n  id: BootProbeIds;\n  name: string;\n  description?: string;\n  apply: (server: GristServer, req: express.Request) => Promise<BootProbeResult>;\n}\n\nconst _admins: Probe = {\n  id: \"admins\",\n  name: \"Currently defined install admins\",\n  apply: async (server, req) => {\n    try {\n      const users = await server.getInstallAdmin().getAdminUsers(req);\n      return {\n        status: \"success\",\n        details: { users },\n      };\n    } catch (e) {\n      return {\n        status: \"fault\",\n        details: { error: String(e) },\n      };\n    }\n  },\n};\n\nconst _homeUrlReachableProbe: Probe = {\n  id: \"reachable\",\n  name: \"Is home page available at expected URL\",\n  apply: async (server, req) => {\n    const url = server.getHomeInternalUrl();\n    const details: Record<string, any> = {\n      url,\n    };\n    try {\n      const resp = await fetch(url);\n      details.status = resp.status;\n      if (resp.status !== 200) {\n        throw new ApiError(await resp.text(), resp.status);\n      }\n      return {\n        status: \"success\",\n        details,\n      };\n    } catch (e) {\n      return {\n        details: {\n          ...details,\n          error: String(e),\n        },\n        status: \"fault\",\n      };\n    }\n  },\n};\n\nconst _webSocketsProbe: Probe = {\n  id: \"websockets\",\n  name: \"Can we open a websocket with the server\",\n  apply: async (server, req) => {\n    return new Promise((resolve) => {\n      const url = new URL(server.getHomeUrl(req));\n      url.protocol = (url.protocol === \"https:\") ? \"wss:\" : \"ws:\";\n      const ws = new WS.WebSocket(url.href);\n      const details: Record<string, any> = {\n        url,\n      };\n      ws.on(\"open\", () => {\n        ws.send('{\"msg\": \"Just nod if you can hear me.\"}');\n        resolve({\n          status: \"success\",\n          details,\n        });\n        ws.close();\n      });\n      ws.on(\"error\", (ev) => {\n        details.error = ev.message;\n        resolve({\n          status: \"fault\",\n          details,\n        });\n        ws.close();\n      });\n    });\n  },\n};\n\nconst _statusCheckProbe: Probe = {\n  id: \"health-check\",\n  name: \"Is an internal health check passing\",\n  apply: async (server, req) => {\n    const baseUrl = server.getHomeInternalUrl();\n    const url = new URL(baseUrl);\n    url.pathname = removeTrailingSlash(url.pathname) + \"/status\";\n    const details: Record<string, any> = {\n      url: url.href,\n    };\n    try {\n      const resp = await fetch(url);\n      details.status = resp.status;\n      if (resp.status !== 200) {\n        throw new Error(`Failed with status ${resp.status}`);\n      }\n      const txt = await resp.text();\n      if (!txt.includes(\"is alive\")) {\n        throw new Error(`Failed, page has unexpected content`);\n      }\n      return {\n        status: \"success\",\n        details,\n      };\n    } catch (e) {\n      return {\n        details: {\n          ...details,\n          error: String(e),\n        },\n        status: \"fault\",\n      };\n    }\n  },\n};\n\nconst _userProbe: Probe = {\n  id: \"system-user\",\n  name: \"Is the system user following best practice\",\n  apply: async () => {\n    const details = {\n      uid: process.getuid ? process.getuid() : \"unavailable\",\n    };\n    if (process.getuid && process.getuid() === 0) {\n      return {\n        details,\n        verdict: \"User appears to be root (UID 0)\",\n        status: \"warning\",\n      };\n    } else {\n      return {\n        status: \"success\",\n        details,\n      };\n    }\n  },\n};\n\nconst _bootProbe: Probe = {\n  id: \"boot-page\",\n  name: \"Is the boot page adequately protected\",\n  apply: async (server) => {\n    const bootKey = server.getBootKey() || \"\";\n    const hasBoot = Boolean(bootKey);\n    const details: Record<string, any> = {\n      bootKeySet: hasBoot,\n    };\n    if (!hasBoot) {\n      return { status: \"success\", details };\n    }\n    details.bootKeyLength = bootKey.length;\n    if (bootKey.length < 10) {\n      return {\n        verdict: \"Boot key length is shorter than 10.\",\n        details,\n        status: \"fault\",\n      };\n    }\n    return {\n      verdict: \"Boot key ideally should be removed after installation.\",\n      details,\n      status: \"warning\",\n    };\n  },\n};\n\n/**\n * Based on:\n * https://github.com/gristlabs/grist-core/issues/228#issuecomment-1803304438\n *\n * When GRIST_SERVE_SAME_ORIGIN is set, requests arriving to Grist need\n * to have an accurate Host header.\n */\nconst _hostHeaderProbe: Probe = {\n  id: \"host-header\",\n  name: \"Does the host header look correct\",\n  apply: async (server, req) => {\n    const host = req.header(\"host\");\n    const url = new URL(server.getHomeUrl(req));\n    const details = {\n      homeUrlHost: url.hostname,\n      headerHost: host,\n    };\n    if (url.hostname === \"localhost\") {\n      return {\n        status: \"none\",\n        details,\n      };\n    }\n    if (String(url.hostname).toLowerCase() !== String(host).toLowerCase()) {\n      return {\n        details,\n        status: \"hmm\",\n      };\n    }\n    return {\n      status: \"none\",\n      details,\n    };\n  },\n};\n\nconst _sandboxingProbe: Probe = {\n  id: \"sandboxing\",\n  name: \"Is document sandboxing effective\",\n  apply: async (server, req) => {\n    const details = await server.getSandboxInfo();\n    return {\n      status: (details?.configured && details?.functional) ? \"success\" : \"fault\",\n      details,\n    };\n  },\n};\n\nconst _authenticationProbe: Probe = {\n  id: \"authentication\",\n  name: \"Authentication system\",\n  apply: async (server, req) => {\n    // Check what provider is active, there is always one, even if there are errors.\n    const active = appSettings.section(\"login\").flag(\"active\").get();\n\n    if (!active) {\n      return {\n        status: \"fault\",\n        verdict: \"No active authentication provider\",\n      };\n    }\n\n    // Check if active provider has errors.\n    const error = appSettings.section(\"login\").flag(\"error\").get();\n    const provider = String(active);\n    const status = error ? \"fault\" : provider === \"no-auth\" ? \"warning\" : \"success\";\n    return {\n      status,\n      verdict: error ? String(error) : undefined,\n      details: {\n        provider,\n      },\n    };\n  },\n};\n\nconst _sessionSecretProbe: Probe = {\n  id: \"session-secret\",\n  name: \"Session secret\",\n  apply: async (server, req) => {\n    const usingDefaultSessionSecret = server.create.sessionSecret() === DEFAULT_SESSION_SECRET;\n    return {\n      status: usingDefaultSessionSecret ? \"warning\" : \"success\",\n      details: {\n        GRIST_SESSION_SECRET: process.env.GRIST_SESSION_SECRET ? \"set\" : \"not set\",\n      },\n    };\n  },\n};\n"
  },
  {
    "path": "app/server/lib/BrowserSession.ts",
    "content": "import { normalizeEmail } from \"app/common/emails\";\nimport { UserProfile } from \"app/common/LoginSessionAPI\";\nimport { SessionStore } from \"app/server/lib/gristSessions\";\nimport log from \"app/server/lib/log\";\nimport { fromCallback } from \"app/server/lib/serverUtils\";\n\nimport { Request } from \"express\";\n\n// Part of a session related to a single user.\nexport interface SessionUserObj {\n  // a grist-internal identify for the user, if known.\n  userId?: number;\n\n  // The user profile object.\n  profile?: UserProfile;\n\n  /**\n   * Unix time in seconds of the last successful login. Includes security\n   * verification prompts, such as those for configuring MFA preferences.\n   */\n  lastLoginTimestamp?: number;\n\n  /**\n   * The authentication provider. (Typically the JWT \"iss\".)\n   */\n  authProvider?: string;\n\n  /**\n   * Identifier for the user from the authentication provider. (Typically\n   * the JWT \"sub\".)\n   */\n  authSubject?: string;\n\n  // [UNUSED] Login ID token used to access AWS services.\n  idToken?: string;\n\n  // Login access token used to access other AWS services.\n  accessToken?: string;\n\n  // Login refresh token used to retrieve new ID and access tokens.\n  refreshToken?: string;\n\n  // State for SAML-mediated logins.\n  samlNameId?: string;\n  samlSessionIndex?: string;\n}\n\n// Session state maintained for a particular browser. It is identified by a cookie. There may be\n// several browser windows/tabs that share this cookie and this state.\nexport interface SessionObj {\n  // Session cookie.\n  // This is marked optional to reflect the reality of pre-existing code.\n  cookie?: any;\n\n  // A list of users we have logged in as.\n  // This is optional since the session may already exist.\n  users?: SessionUserObj[];\n\n  // map from org to an index into users[]\n  // This is optional since the session may already exist.\n  orgToUser?: { [org: string]: number };\n\n  // This gets set to encourage express-session to set a cookie. Was a boolean in the past.\n  alive?: number;\n\n  altSessionId?: string;  // An ID unique to the session, but which isn't related\n  // to the session id used to lookup the cookie. This ID\n  // is suitable for embedding in documents that allows\n  // anonymous editing (e.g. to allow the user to edit\n  // something they just added, without allowing the suer\n  // to edit other people's contributions).\n\n  oidc?: SessionOIDCInfo;\n}\n\nexport interface SessionOIDCInfo {\n  // more details on protections are available here: https://danielfett.de/2020/05/16/pkce-vs-nonce-equivalent-or-not/#special-case-error-responses\n  // code_verifier is used during OIDC authentication for PKCE protection, to protect against attacks like CSRF.\n  // PKCE + state are currently the best combination to protect against CSRF and code injection attacks.\n  code_verifier?: string;\n  // much like code_verifier, for OIDC providers that do not support PKCE.\n  nonce?: string;\n  // state is used to protect against Error Responses spoofs.\n  state?: string;\n  targetUrl?: string;\n  // Stores user claims signed by the issuer, store it to allow loging out.\n  idToken?: string;\n}\n\n// Make an artificial change to a session to encourage express-session to set a cookie.\nexport function forceSessionChange(session: SessionObj) {\n  session.alive = Number(session.alive || 0) + 1;\n}\n\n// We expose a sign-in status in a cookie accessible to all subdomains, to assist in auto-signin.\n// The values are:\n// - \"S\": the user is signed in once; in this case an automatic signin can be unambiguous and seamless.\n// - \"M\": the user is signed in multiple times.\n// - \"\": the user is not signed in.\nexport type SignInStatus = \"S\" | \"M\" | \"\";\n\nexport function getSignInStatus(sessionObj: SessionObj | null): SignInStatus {\n  const length = sessionObj?.users?.length;\n  return !length ? \"\" : (length === 1 ? \"S\" : \"M\");\n}\n\n/**\n * Extract the available user profiles from the session.\n *\n */\nexport function getSessionProfiles(session: SessionObj): UserProfile[] {\n  if (!session.users) { return []; }\n  return session.users.filter(user => user?.profile).map(user => user.profile!);\n}\n\n/**\n *\n * Gets user profile from the session for a given org, returning null if no profile is\n * found specific to that org.\n *\n */\nexport function getSessionUser(session: SessionObj, org: string,\n  userSelector: string): SessionUserObj | null {\n  if (!session.users) { return null; }\n  if (!session.users.length) { return null; }\n\n  if (userSelector) {\n    for (const user of session.users) {\n      if (user.profile?.email.toLowerCase() === userSelector.toLowerCase()) { return user; }\n    }\n  }\n\n  if (session.orgToUser?.[org] !== undefined &&\n    session.users.length > session.orgToUser[org]) {\n    return session.users[session.orgToUser[org]] || null;\n  }\n  return null;\n}\n\n/**\n *\n * Record which user to use by default for a given org in future.\n * This method mutates the session object passed to it.  It does not save it,\n * that is up to the caller.\n *\n */\nexport function linkOrgWithEmail(session: SessionObj, email: string, org: string): SessionUserObj {\n  if (!session.users || !session.orgToUser) { throw new Error(\"Session not set up\"); }\n  email = normalizeEmail(email);\n  for (let i = 0; i < session.users.length; i++) {\n    const iUser = session.users[i];\n    if (iUser?.profile && normalizeEmail(iUser.profile.email) === email) {\n      session.orgToUser[org] = i;\n      return iUser;\n    }\n  }\n  throw new Error(\"Failed to link org with email\");\n}\n\n/**\n *\n * This is a view of the session object, for a single organization (the \"scope\").\n *\n * Local caching is disabled in an environment where there is a home server (or we are\n * the home server).  In hosted Grist, per-instance caching would be a problem.\n *\n * We retain local caching for situations with a single server - especially electron.\n *\n */\nexport class ScopedSession {\n  private _sessionCache?: SessionObj;\n  private _live: boolean;  // if set, never cache session in memory.\n  private _altSessionId?: string;\n\n  /**\n   * Create an interface to the session identified by _sessionId, in the store identified\n   * by _sessionStore, for the organization identified by _scope.\n   */\n  constructor(private _sessionId: string,\n    private _sessionStore: SessionStore,\n    private _org: string,\n    private _userSelector: string) {\n    // Assume we need to skip cache in a hosted environment. GRIST_HOST is always set there.\n    // TODO: find a cleaner way to configure this flag.\n    this._live = Boolean(process.env.GRIST_HOST || process.env.GRIST_HOSTED);\n  }\n\n  public get org(): string { return this._org; }\n\n  /**\n   * Get the user entry from the current session.\n   * @param prev: if supplied, this session object is used rather than querying the session again.\n   * @return the user entry\n   */\n  public async getScopedSession(prev?: SessionObj): Promise<SessionUserObj> {\n    const session = prev || await this._getSession();\n    return getSessionUser(session, this._org, this._userSelector) || {};\n  }\n\n  // Retrieves the user profile from the session.\n  public async getSessionProfile(prev?: SessionObj): Promise<UserProfile | null> {\n    return (await this.getScopedSession(prev)).profile || null;\n  }\n\n  // Updates a user profile. The session may have multiple profiles associated with different\n  // email addresses. This will update the one with a matching email address, or add a new one.\n  // This is mainly used to know which emails are logged in in this session; fields like name and\n  // picture URL come from the database instead.\n  public async updateUserProfile(req: Request, profile: UserProfile | null): Promise<void> {\n    if (profile) {\n      await this.updateUser(req, { profile });\n    } else {\n      await this.clearScopedSession(req);\n    }\n  }\n\n  /**\n   * Updates the properties of the current session user.\n   *\n   * @param {Partial<SessionUserObj>} newProps New property values to set.\n   */\n  public async updateUser(req: Request, newProps: Partial<SessionUserObj>): Promise<void> {\n    await this.operateOnScopedSession(req, async user => ({ ...user, ...newProps }));\n  }\n\n  /**\n   *\n   * This performs an operation on the session object, limited to a single user entry.  The state of that\n   * user entry before and after the operation are returned.  LoginSession relies heavily on this method,\n   * to determine whether the change made by an operation merits certain follow-up work.\n   *\n   * @param op: Operation to perform.  Given a single user entry, and should return a single user entry.\n   * It is fine to modify the supplied user entry in place.\n   *\n   * @return a pair [prev, current] with the state of the single user entry before and after the operation.\n   *\n   */\n  public async operateOnScopedSession(req: Request, op: (user: SessionUserObj) =>\n  Promise<SessionUserObj>): Promise<[SessionUserObj, SessionUserObj]> {\n    const session = await this._getSession(req);\n    const user = await this.getScopedSession(session);\n    const oldUser = JSON.parse(JSON.stringify(user));            // Old version to compare against.\n    const newUser = await op(JSON.parse(JSON.stringify(user)));  // Modify a scratch version.\n    if (Object.keys(newUser).length === 0) {\n      await this.clearScopedSession(req, session);\n    } else {\n      await this._updateScopedSession(req, newUser, session);\n    }\n    return [oldUser, newUser];\n  }\n\n  /**\n   * This clears the current user entry from the session.\n   * @param prev: if supplied, this session object is used rather than querying the session again.\n   */\n  public async clearScopedSession(req: Request, prev?: SessionObj): Promise<void> {\n    const session = prev || await this._getSession(req);\n    this._clearUser(session);\n    await this._setSession(req, session);\n  }\n\n  public getAltSessionId(): string | undefined {\n    return this._altSessionId;\n  }\n\n  /**\n   * Read the state of the session.\n   */\n  private async _getSession(req?: Request): Promise<SessionObj> {\n    if (this._sessionCache) { return this._sessionCache; }\n    const reqSession = (req as any)?.session;\n    const session = ((await this._sessionStore.getAsync(this._sessionId)) || reqSession || {}) as SessionObj;\n    if (!this._live) { this._sessionCache = session; }\n    this._altSessionId = session.altSessionId;\n    return session;\n  }\n\n  /**\n   * Set the session to the supplied object.\n   */\n  private async _setSession(req: Request, session: SessionObj): Promise<void> {\n    try {\n      await this._sessionStore.setAsync(this._sessionId, session);\n      if (!this._live) { this._sessionCache = session; }\n      const reqSession = (req as any).session;\n      if (reqSession?.reload) {\n        await fromCallback(cb => reqSession.reload(cb));\n      }\n    } catch (e) {\n      // (I've copied this from old code, not sure if continuing after a session save error is\n      // something existing code depends on?)\n      // Report and keep going. This ensures that the session matches what's in the sessionStore.\n      log.error(`ScopedSession[${this._sessionId}]: Error updating sessionStore: ${e}`);\n    }\n  }\n\n  /**\n   * Update the session with the supplied user entry, replacing anything for that user already there.\n   * @param user: user entry to insert in session\n   * @param prev: if supplied, this session object is used rather than querying the session again.\n   *\n   */\n  private async _updateScopedSession(req: Request, user: SessionUserObj, prev?: SessionObj): Promise<void> {\n    const profile = user.profile;\n    if (!profile) {\n      throw new Error(\"No profile available\");\n    }\n    // We used to also check profile.email_verified, but we no longer create UserProfile objects\n    // unless the email is verified, so this check is no longer needed.\n    if (!profile.email) {\n      throw new Error(\"Profile has no email address\");\n    }\n\n    const session = prev || await this._getSession(req);\n    if (!session.users) { session.users = []; }\n    if (!session.orgToUser) { session.orgToUser = {}; }\n    let index = session.users.findIndex((u) => {\n      return Boolean(u.profile && normalizeEmail(u.profile.email) === normalizeEmail(profile.email));\n    });\n    if (index < 0) { index = session.users.length; }\n    session.orgToUser[this._org] = index;\n    session.users[index] = user;\n    await this._setSession(req, session);\n  }\n\n  /**\n   * This clears all user logins (not just the current login).\n   * In future, we may want to be able to log in and out selectively, slack style,\n   * but right now it seems confusing.\n   */\n  private _clearUser(session: SessionObj): void {\n    session.users = [];\n    session.orgToUser = {};\n  }\n}\n"
  },
  {
    "path": "app/server/lib/CellDataAccess.ts",
    "content": "import {\n  AddRecord,\n  BulkAddRecord,\n  BulkRemoveRecord,\n  BulkUpdateRecord,\n  DataAction,\n  DocAction,\n  getActionColValues,\n  getRowIds,\n  getRowIdsFromDocAction,\n  getSingleAction,\n  getTableId,\n  isAddRecord,\n  isBulkAction,\n  isBulkRemoveRecord,\n  isBulkUpdateRecord,\n  isDataAction,\n  isRemoveRecord,\n  isSomeAddRecordAction,\n  isSomeRemoveRecordAction,\n  isUpdateRecord,\n  UpdateRecord,\n} from \"app/common/DocActions\";\nimport { CommentContent } from \"app/common/DocComments\";\nimport { DocData } from \"app/common/DocData\";\nimport { ErrorWithCode } from \"app/common/ErrorWithCode\";\nimport { isCensored } from \"app/common/gristTypes\";\nimport { getSetMapValue, safeJsonParse } from \"app/common/gutil\";\nimport { MetaRowRecord, SingleCell } from \"app/common/TableData\";\nimport { GristObjCode } from \"app/plugin/GristData\";\n\nimport { isEqual } from \"lodash\";\n\n/**\n * Tests if the user can modify cell's data. Will modify the docData\n * to reflect the changes that are done by actions (without reverting if one of the actions fails).\n *\n * If user can't modify the cell, it will throw an error.\n */\nexport async function applyAndCheckActionsForCells(\n  docData: DocData,\n  docActions: DocAction[],\n  directActions: boolean[],\n  userIsOwner: boolean,\n  haveRules: boolean,\n  userRef: string,\n  hasAccess: (cell: SingleCellInfo, state: DocData) => Promise<boolean>,\n) {\n  // First check if we even have actions that modify cell's data.\n  const cellsActions = docActions.filter(isCellDataAction);\n\n  // If we don't have any actions, we are good to go.\n  if (cellsActions.length === 0) { return; }\n  const fail = () => {\n    throw new ErrorWithCode(\"ACL_DENY\", \"Cannot access cell\");\n  };\n\n  // In nutshell we will just test action one by one, and see if user\n  // can apply it. To do it, we need to keep track of a database state after\n  // each action (just like regular access is done). Unfortunately, cells' info\n  // can be partially updated, so we won't be able to determine what cells they\n  // are attached to. We will assume that bundle has a complete set of information, and\n  // with this assumption we will skip such actions, and wait for the whole cell to form.\n\n  // Create a view for current state.\n  const cellData = new CellData(docData);\n\n  // Some cells meta data will be added before rows (for example, when undoing). We will\n  // postpone checking of such actions until we have a full set of information.\n  let postponed: number[] = [];\n  // Now one by one apply all actions to the snapshot recording all changes\n  // to the cell table.\n\n  const zipped = docActions.map((a, i) => ({ docAction: a, isDirect: directActions[i] }));\n  for (const { docAction, isDirect } of zipped) {\n    if (!isDirect || !isCellDataAction(docAction)) {\n      docData.receiveAction(docAction);\n      continue;\n    }\n    // Convert any bulk actions to normal actions\n    for (const single of getSingleAction(docAction)) {\n      const id = getRowIdsFromDocAction(single)[0];\n      if (isAddRecord(single)) {\n        // Apply this action, as it might not have full information yet.\n        docData.receiveAction(single);\n        if (haveRules) {\n          const cell = cellData.getCell(id);\n          if (cell && cellData.isAttached(cell)) {\n            // If this is undo, action cell might not yet exist, so we need to check for that.\n            const haveRecord = docData.getTable(cell.tableId)?.hasRowId(cell.rowId);\n            if (!haveRecord) {\n              postponed.push(id);\n            } else if (!await hasAccess(cell, docData)) {\n              fail();\n            }\n          } else {\n            postponed.push(id);\n          }\n        }\n      } else if (isRemoveRecord(single)) {\n        // See if we can remove this cell.\n        const cell = cellData.getCell(id);\n        docData.receiveAction(single);\n        if (cell) {\n          // We can remove cell information for any row/column that was removed already.\n          const record = docData.getTable(cell.tableId)?.getRecord(cell.rowId);\n          if (!record || !cell.colId || !(cell.colId in record)) {\n            continue;\n          }\n          // Document owner can remove anything.\n          if (cell.userRef && cell.userRef !== (userRef || \"\") && !userIsOwner) {\n            fail();\n          }\n        }\n        postponed = postponed.filter(i => i !== id);\n      } else {\n        // We are updating a cell metadata. We will need to check if we can update it.\n        let cell = cellData.getCell(id);\n        if (!cell) {\n          return fail();\n        }\n\n        // We can update any cell if the column or table for this cell was removed already.\n        // In that case, cell is updated before being removed.\n        if (!cell.colId || !cell.tableId || !cell.rowId) {\n          docData.receiveAction(single);\n          continue;\n        }\n\n        // And if the cell was attached before, we will need to check if we can access it.\n        if (cellData.isAttached(cell) && haveRules && !await hasAccess(cell, docData)) {\n          fail();\n        }\n        // Now receive the action, and test if we can still see the cell (as the info might be moved\n        // to a different cell).\n        const before = cellData.getCellRecord(id);\n        docData.receiveAction(single);\n        cell = cellData.getCell(id)!;\n        const after = cellData.getCellRecord(id);\n\n        if (cellData.isAttached(cell) && haveRules && !await hasAccess(cell, docData)) {\n          fail();\n        }\n\n        // Anyone can toggle the parent property (as it is Ref field for the main thread which might be removed)\n        // Grist data engine has something that resembles ON DELETE CASCADE, but the children (dependencies) are removed\n        // after, not before, so there is a brief moment when references are invalid.\n        if (before && after &&\n          checkChangedIds(before, after, [\"parentId\"]) &&\n          wasToggled(before, after, \"parentId\")\n        ) {\n          continue;\n        }\n\n        // We can't update cells, that are not ours, unless we are owner and we are resolving a root comment.\n        if (cell.userRef && cell.userRef !== (userRef || \"\")) {\n          // Check if this is owner resolving a root comment\n          const isOwnerResolvingRoot =\n            userIsOwner &&\n            before && after &&\n            after.root &&\n            wasToggled(before, after, \"resolved\") &&\n            // Only resolved field changed (timeCreated and timeUpdated are automatic and not user-controlled)\n            checkChangedIds(before, after, [\"resolved\", \"timeUpdated\"]);\n\n          if (!isOwnerResolvingRoot) {\n            fail();\n          }\n        }\n      }\n    }\n  }\n  // Now test every cell that was added before row (so we added it, but without\n  // full information, like new rowId or tableId or colId).\n  for (const id of postponed) {\n    const cell = cellData.getCell(id);\n    if (cell && !cellData.isAttached(cell)) {\n      return fail();\n    }\n    if (haveRules && cell && !await hasAccess(cell, docData)) {\n      fail();\n    }\n  }\n}\n\n/**\n * Checks if the action is a data action that modifies a _grist_Cells table.\n */\nexport function isCellDataAction(a: DocAction): a is DataAction {\n  return getTableId(a) === \"_grist_Cells\" && isDataAction(a);\n}\n\ninterface SingleCellInfo extends SingleCell {\n  userRef: string;\n  id: number;\n}\n\ninterface SingleCellInfoWithData extends SingleCellInfo {\n  content: string;\n  parentId: number | null;\n}\n\n/**\n * Helper class that extends DocData with cell specific functions.\n */\nexport class CellData {\n  constructor(private _docData: DocData) {\n\n  }\n\n  /**\n   * Finds if there are any new comments in the actions.\n   */\n  public hasNewComments(actions: DocAction[]): boolean {\n    return actions.some((action) => {\n      if (!isCellDataAction(action)) { return false; }\n      if (isSomeAddRecordAction(action)) {\n        return true;\n      }\n      return false;\n    });\n  }\n\n  public getNewComments(actions: DocAction[]): MetaRowRecord<\"_grist_Cells\">[] {\n    const rows: MetaRowRecord<\"_grist_Cells\">[] = [];\n    for (const action of actions) {\n      if (!isCellDataAction(action) || !isSomeAddRecordAction(action)) { continue; }\n      for (const single of getSingleAction(action)) {\n        const commentRow = getActionColValues(single as AddRecord);\n        if (isCensored(commentRow.content)) {\n          // If the content is censored, we don't want to return it.\n          continue;\n        }\n        const id = getRowIds(single);\n        rows.push({ id, ...commentRow as any });\n      }\n    }\n    return rows;\n  }\n\n  /**\n   * Retrieves the audience (participants) for a given set of cell IDs.\n   *\n   * @param rowIds - An array of cell info IDs from the `_grist_Cells` table.\n   * @returns A map where the key is the cell ID and the value is an array of user references\n   *          (participants) associated with the whole thread, so all comments of the table/column/row\n   *          combination.\n   */\n  public getAudience(rowIds: number[]): Map<number, string[]> {\n    const result = new Map<number, string[]>(); // Stores the final mapping of cell IDs to participants.\n    const read = new Map<string, string[]>(); // Caches participants for specific table/column/row combinations.\n\n    for (const cId of rowIds) {\n      // Retrieve cell information for the given cell ID.\n      const cell = this.getCell(cId);\n      if (!cell) { continue; }\n      // Create a unique key for caching based on table, column, and row.\n      const tableId = cell.tableId;\n      const colId = cell.colId;\n      const rowId = cell.rowId;\n      const key = `${tableId}:${colId}:${rowId}`;\n\n      // If participants for this key are already cached, use them.\n      if (read.has(key)) {\n        result.set(cId, read.get(key) || []);\n      } else {\n        // Otherwise, compute participants for this table/column/row combination.\n        const participants = new Set(\n          this.readCells(tableId, new Set([rowId]), colId).flatMap((c) => {\n            const parsed = safeJsonParse(c.content, {}) as CommentContent; // Parse the cell content.\n            return [c.userRef, ...parsed.mentions || []]; // Include the user reference and any mentions.\n          }),\n        );\n\n        // Cache the computed participants for the key.\n        read.set(key, Array.from(participants));\n        // Add the participants to the result map for the current cell ID.\n        result.set(cId, Array.from(participants));\n      }\n    }\n\n    return result;\n  }\n\n  public getCell(cellId: number) {\n    const row = this._docData.getMetaTable(\"_grist_Cells\").getRecord(cellId);\n    return row ? this.convertToCellInfo(row) : null;\n  }\n\n  public getCellRecord(cellId: number) {\n    const row = this._docData.getMetaTable(\"_grist_Cells\").getRecord(cellId);\n    return row || null;\n  }\n\n  /**\n   * Generates a patch for cell metadata. It assumes, that engine removes all\n   * cell metadata when cell (table/column/row) is removed and the bundle contains,\n   * all actions that are needed to remove the cell and cell metadata.\n   */\n  public generatePatch(actions: DocAction[]) {\n    const removedCells = new Set<number>();\n    const addedCells = new Set<number>();\n    const updatedCells = new Set<number>();\n    function applyCellAction(action: DataAction) {\n      if (isSomeAddRecordAction(action)) {\n        for (const id of getRowIdsFromDocAction(action)) {\n          if (removedCells.has(id)) {\n            removedCells.delete(id);\n            updatedCells.add(id);\n          } else {\n            addedCells.add(id);\n          }\n        }\n      } else if (isRemoveRecord(action) || isBulkRemoveRecord(action)) {\n        for (const id of getRowIdsFromDocAction(action)) {\n          if (addedCells.has(id)) {\n            addedCells.delete(id);\n          } else {\n            removedCells.add(id);\n            updatedCells.delete(id);\n          }\n        }\n      } else {\n        for (const id of getRowIdsFromDocAction(action)) {\n          if (addedCells.has(id)) {\n            // ignore\n          } else {\n            updatedCells.add(id);\n          }\n        }\n      }\n    }\n\n    // Scan all actions and collect all cell ids that are added, removed or updated.\n    // When some rows are updated, include all cells for that row. Keep track of table\n    // renames.\n    const updatedRows = new Map<string, Set<number>>();\n    for (const action of actions) {\n      if (action[0] === \"RenameTable\") {\n        updatedRows.set(action[2], updatedRows.get(action[1]) || new Set());\n        continue;\n      }\n      if (action[0] === \"RemoveTable\") {\n        updatedRows.delete(action[1]);\n        continue;\n      }\n      if (isDataAction(action) && isCellDataAction(action)) {\n        applyCellAction(action);\n        continue;\n      }\n      if (!isDataAction(action)) { continue; }\n      // We don't care about new rows, as they don't have meta data at this moment.\n      // If regular rows are removed, we also don't care about them, as they will\n      // produce metadata removal.\n      // We only care about updates, as it might change the metadata visibility.\n      if (isUpdateRecord(action) || isBulkUpdateRecord(action)) {\n        if (getTableId(action).startsWith(\"_grist\")) { continue; }\n        // Updating a row, for us means that all metadata for this row should be refreshed.\n        for (const rowId of getRowIdsFromDocAction(action)) {\n          getSetMapValue(updatedRows, getTableId(action), () => new Set()).add(rowId);\n        }\n      }\n    }\n\n    for (const [tableId, rowIds] of updatedRows) {\n      for (const { id } of this.readCells(tableId, rowIds)) {\n        if (addedCells.has(id) || updatedCells.has(id) || removedCells.has(id)) {\n          // If we have this cell id in the list of added/updated/removed cells, ignore it.\n        } else {\n          updatedCells.add(id);\n        }\n      }\n    }\n\n    const insert = this.generateInsert([...addedCells]);\n    const update = this.generateUpdate([...updatedCells]);\n    const removes = this.generateRemovals([...removedCells]);\n    const patch: DocAction[] = [insert, update, removes].filter(Boolean) as DocAction[];\n    return patch.length ? patch : null;\n  }\n\n  public async censorCells(\n    docActions: DocAction[],\n    hasAccess: (cell: SingleCellInfo) => Promise<boolean>,\n  ) {\n    for (const action of docActions) {\n      if (!isCellDataAction(action)) { continue; }\n      if (isSomeRemoveRecordAction(action)) { continue; }\n      if (!isBulkAction(action)) {\n        const [, , rowId, colValues] = action;\n        const cell = this.getCell(rowId);\n        if (!cell || !await hasAccess(cell)) {\n          colValues.content = [GristObjCode.Censored];\n          colValues.userRef = \"\";\n        }\n      } else {\n        const [, , rowIds, colValues] = action;\n        for (let idx = 0; idx < rowIds.length; idx++) {\n          const cell = this.getCell(rowIds[idx]);\n          if (!cell || !await hasAccess(cell)) {\n            colValues.content[idx] = [GristObjCode.Censored];\n            colValues.userRef[idx] = \"\";\n          }\n        }\n      }\n    }\n    return docActions;\n  }\n\n  public convertToCellInfo(cell: MetaRowRecord<\"_grist_Cells\">): SingleCellInfoWithData {\n    const singleCell = {\n      id: cell.id,\n      tableId: this.getTableId(cell.tableRef) as string,\n      colId: this.getColId(cell.colRef) as string,\n      rowId: cell.rowId,\n      userRef: cell.userRef,\n      parentId: cell.parentId,\n      content: cell.content,\n    };\n    return singleCell;\n  }\n\n  public getColId(colRef: number) {\n    return this._docData.getMetaTable(\"_grist_Tables_column\").getValue(colRef, \"colId\");\n  }\n\n  public getTableId(tableRef: number) {\n    return this._docData.getMetaTable(\"_grist_Tables\").getValue(tableRef, \"tableId\");\n  }\n\n  public getTableRef(tableId: string) {\n    return this._docData.getMetaTable(\"_grist_Tables\").findRow(\"tableId\", tableId) || undefined;\n  }\n\n  public getColRef(tableId: string, colId: string) {\n    const parentId = this.getTableRef(tableId);\n    if (!parentId) {\n      throw new Error(`Table ${tableId} not found`);\n    }\n    const colRef = this._docData.getMetaTable(\"_grist_Tables_column\").findMatchingRowId(\n      { parentId, colId },\n    );\n    if (!colRef) {\n      throw new Error(`Column ${colId} not found in table ${tableId}`);\n    }\n    return colRef;\n  }\n\n  /**\n   * Returns all cells for a given table and row ids.\n   */\n  public readCells(tableId: string, rowIds: Set<number>, colId?: string) {\n    const tableRef = this.getTableRef(tableId);\n    const filter: Record<string, any> = { tableRef };\n    if (colId) {\n      filter.colRef = this.getColRef(tableId, colId);\n    }\n    const cells =  this._docData.getMetaTable(\"_grist_Cells\").filterRecords(filter).filter(r => rowIds.has(r.rowId));\n    return cells.map(this.convertToCellInfo.bind(this));\n  }\n\n  // Helper function that tells if a cell can be determined fully from the action itself.\n  // Otherwise we need to look in the docData.\n  public hasCellInfo(docAction: DocAction):\n      docAction is UpdateRecord | BulkUpdateRecord | AddRecord | BulkAddRecord {\n    if (!isDataAction(docAction)) { return false; }\n    if (!isSomeRemoveRecordAction(docAction)) {\n      const colValues = getActionColValues(docAction);\n      if (colValues.tableRef && colValues.colRef && colValues.rowId && colValues.userRef) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  /**\n   * Checks if cell is 'attached', i.e. it has a tableRef, colRef, rowId and userRef.\n   */\n  public isAttached(cell: SingleCellInfo) {\n    return Boolean(cell.tableId && cell.rowId && cell.colId && cell.userRef);\n  }\n\n  /**\n   * Reads all SingleCellInfo from docActions or from docData if action doesn't have enough information.\n   */\n  public convertToCells(action: DocAction): SingleCellInfo[] {\n    if (!isDataAction(action)) { return []; }\n    if (getTableId(action) !== \"_grist_Cells\") { return []; }\n    const result: { tableId: string, rowId: number, colId: string, id: number, userRef: string }[] = [];\n    if (isBulkAction(action)) {\n      const rowIds = getRowIds(action);\n      for (let idx = 0; idx < rowIds.length; idx++) {\n        if (this.hasCellInfo(action)) {\n          const colValues = getActionColValues(action);\n          result.push({\n            tableId: this.getTableId(colValues.tableRef[idx] as number) as string,\n            colId: this.getColId(colValues.colRef[idx] as number) as string,\n            rowId: colValues.rowId[idx] as number,\n            userRef: (colValues.userRef[idx] ?? \"\") as string,\n            id: rowIds[idx],\n          });\n        } else {\n          const cellInfo = this.getCell(rowIds[idx]);\n          if (cellInfo) {\n            result.push(cellInfo);\n          }\n        }\n      }\n    } else {\n      const rowId = getRowIds(action);\n      if (this.hasCellInfo(action)) {\n        const colValues = getActionColValues(action);\n        result.push({\n          tableId: this.getTableId(colValues.tableRef as number) as string,\n          colId: this.getColId(colValues.colRef as number) as string,\n          rowId: colValues.rowId as number,\n          userRef: colValues.userRef as string,\n          id: rowId,\n        });\n      } else {\n        const cellInfo = this.getCell(rowId);\n        if (cellInfo) {\n          result.push(cellInfo);\n        }\n      }\n    }\n    return result;\n  }\n\n  public generateInsert(ids: number[]): DataAction | null {\n    const action: BulkAddRecord = [\n      \"BulkAddRecord\",\n      \"_grist_Cells\",\n      [],\n      {\n        tableRef: [],\n        colRef: [],\n        type: [],\n        root: [],\n        content: [],\n        rowId: [],\n        userRef: [],\n        parentId: [],\n        timeCreated: [],\n        timeUpdated: [],\n        resolved: [],\n      },\n    ];\n    for (const cell of ids) {\n      const dataCell = this.getCellRecord(cell);\n      if (!dataCell) { continue; }\n      action[2].push(dataCell.id);\n      action[3].content.push(dataCell.content);\n      action[3].userRef.push(dataCell.userRef);\n      action[3].tableRef.push(dataCell.tableRef);\n      action[3].colRef.push(dataCell.colRef);\n      action[3].type.push(dataCell.type);\n      action[3].root.push(dataCell.root);\n      action[3].rowId.push(dataCell.rowId);\n      action[3].parentId.push(dataCell.parentId);\n      action[3].timeCreated.push(dataCell.timeCreated);\n      action[3].timeUpdated.push(dataCell.timeUpdated);\n      action[3].resolved.push(dataCell.resolved);\n    }\n    return action[2].length > 1 ? action :\n      action[2].length == 1 ? [...getSingleAction(action)][0] : null;\n  }\n\n  public generateRemovals(ids: number[]) {\n    const action: BulkRemoveRecord = [\n      \"BulkRemoveRecord\",\n      \"_grist_Cells\",\n      ids,\n    ];\n    return action[2].length > 1 ? action :\n      action[2].length == 1 ? [...getSingleAction(action)][0] : null;\n  }\n\n  public generateUpdate(ids: number[]) {\n    const action: BulkUpdateRecord = [\n      \"BulkUpdateRecord\",\n      \"_grist_Cells\",\n      [],\n      {\n        content: [],\n        userRef: [],\n        timeCreated: [],\n        timeUpdated: [],\n        resolved: [],\n      },\n    ];\n    for (const cell of ids) {\n      const dataCell = this.getCellRecord(cell);\n      if (!dataCell) { continue; }\n      action[2].push(dataCell.id);\n      action[3].content.push(dataCell.content);\n      action[3].userRef.push(dataCell.userRef);\n      action[3].timeCreated.push(dataCell.timeCreated);\n      action[3].timeUpdated.push(dataCell.timeUpdated);\n      action[3].resolved.push(dataCell.resolved);\n    }\n    return action[2].length > 1 ? action :\n      action[2].length == 1 ? [...getSingleAction(action)][0] : null;\n  }\n}\n\ntype CellField = keyof MetaRowRecord<\"_grist_Cells\">;\n\n/**\n * Checks if only expected fields were changed between before and after.\n * @returns true if only allowed fields (or nothing) changed in the row.\n */\nfunction checkChangedIds(\n  before: MetaRowRecord<\"_grist_Cells\">,\n  after: MetaRowRecord<\"_grist_Cells\">,\n  allowed: CellField[],\n) {\n  // All columns except id, we assume after has same keys.\n  const cols: CellField[] = Object.keys(before).filter(k => k !== \"id\") as CellField[];\n  const changed = cols.filter(c =>\n    !isEqual(\n      before[c],\n      after[c],\n    ),\n  );\n  if (changed.length === 0) {\n    return true;\n  }\n  const allowedSet = new Set(allowed);\n  return changed.every(c => allowedSet.has(c));\n}\n\n/**\n * Checks if a field was toggled between before and after.\n */\nfunction wasToggled(\n  before: MetaRowRecord<\"_grist_Cells\">,\n  after: MetaRowRecord<\"_grist_Cells\">,\n  field: CellField) {\n  return Boolean(before[field]) !== Boolean(after[field]);\n}\n"
  },
  {
    "path": "app/server/lib/Client.ts",
    "content": "import { BrowserSettings } from \"app/common/BrowserSettings\";\nimport { CommClientConnect, CommMessage, CommResponse, CommResponseError } from \"app/common/CommTypes\";\nimport { delay } from \"app/common/delay\";\nimport { ErrorWithCode } from \"app/common/ErrorWithCode\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { AuthSession } from \"app/server/lib/AuthSession\";\nimport { DocSession, DocSessionPrecursor } from \"app/server/lib/DocSession\";\nimport { GristServerSocket } from \"app/server/lib/GristServerSocket\";\nimport log from \"app/server/lib/log\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\nimport { MemoryPool } from \"app/server/lib/MemoryPool\";\nimport { fromCallback } from \"app/server/lib/serverUtils\";\nimport { shortDesc } from \"app/server/lib/shortDesc\";\n\nimport * as crypto from \"crypto\";\nimport { IncomingMessage } from \"http\";\n\nimport { i18n } from \"i18next\";\n\nimport type { Comm } from \"app/server/lib/Comm\";\n\n// How many messages and bytes to accumulate for a disconnected client before booting it.\n// The benefit is that a client who temporarily disconnects and reconnects without missing much,\n// would not need to reload the document.\nconst clientMaxMissedMessages = 100;\nconst clientMaxMissedBytes = 1_000_000;\n\nexport type ClientMethod = (client: Client, ...args: any[]) => Promise<unknown>;\n\n// How long the client state persists after a disconnect.\nconst clientRemovalTimeoutMs = 300 * 1000;   // 300s = 5 minutes.\n\n// How much memory to allow using for large JSON responses before waiting for some to clear.\n// Max total across all clients and all JSON responses.\nconst jsonResponseTotalReservation = 500 * 1024 * 1024;\n// Estimate of a single JSON response, used before we know how large it is. Together with the\n// above, it works to limit parallelism (to 25 responses that can be started in parallel).\nconst jsonResponseReservation = 20 * 1024 * 1024;\nexport const jsonMemoryPool = new MemoryPool(jsonResponseTotalReservation);\n\n// A hook for dependency injection.\nexport const Deps = { clientRemovalTimeoutMs, jsonResponseReservation };\n\n/**\n * Generates and returns a random string to use as a clientId. This is better\n * than numbering clients with consecutive integers; otherwise a reconnecting\n * client presenting the previous clientId to a restarted (new) server may\n * accidentally associate itself with a wrong session that happens to share the\n * same clientId. In other words, we need clientIds to be unique across server\n * restarts.\n * @returns {String} - random string to use as a new clientId.\n */\nfunction generateClientId(): string {\n  // Non-blocking version of randomBytes may fail if insufficient entropy is available without\n  // blocking. If we encounter that, we could either block, or maybe use less random values.\n  return crypto.randomBytes(8).toString(\"hex\");\n}\n\n/**\n * These are the types of messages that are allowed to be sent to the client even if the client is\n * not authorized to use this instance (e.g. not a member of the team for this subdomain).\n */\nconst MESSAGE_TYPES_NO_AUTH = new Set([\n  \"clientConnect\",\n]);\n\nvoid (MESSAGE_TYPES_NO_AUTH);\n\n/**\n * Class that encapsulates the information for a client. A Client may survive\n * across multiple websocket reconnects.\n * TODO: this could provide a cleaner interface.\n *\n * @param comm: parent Comm object\n * @param websocket: websocket connection\n * @param methods: a mapping from method names to server methods (must return promises)\n */\nexport class Client {\n  // Confidential to the backend and client themselves - should not be shared\n  public readonly clientId: string;\n  // Can be distributed as a unique identifier for this client\n  public readonly publicClientId: string;\n\n  public browserSettings: BrowserSettings = {};\n\n  private _log = new LogMethods(\"Client \", (extra?: object | null) => this.getLogMeta(extra || {}));\n\n  // Maps docFDs to DocSession objects.\n  private _docFDs: (DocSession | null)[] = [];\n\n  private _missedMessages = new Map<number, string>();\n  private _missedMessagesTotalLength: number = 0;\n  private _destroyTimer: NodeJS.Timeout | null = null;\n  private _destroyed: boolean = false;\n  private _websocket: GristServerSocket | null = null;\n  private _req: IncomingMessage | null = null;\n  private _authSession: AuthSession = AuthSession.unauthenticated();\n  private _nextSeqId: number = 0;     // Next sequence-ID for messages sent to the client\n\n  // Identifier for the current GristWSConnection object connected to this client.\n  private _counter: string | null = null;\n  private _i18Instance?: i18n;\n\n  constructor(\n    private _comm: Comm,\n    private _methods: Map<string, ClientMethod>,\n    private _locale: string,\n    i18Instance?: i18n,\n  ) {\n    this.clientId = generateClientId();\n    this.publicClientId = generateClientId();\n    this._i18Instance = i18Instance?.cloneInstance({\n      lng: this._locale,\n    });\n  }\n\n  public toString() { return `Client ${this.clientId} #${this._counter}`; }\n\n  public t(key: string, args?: any): string {\n    return this._i18Instance?.t(key, args) ?? key;\n  }\n\n  public setConnection(options: {\n    websocket: GristServerSocket;\n    req: IncomingMessage;\n    counter: string | null;\n    browserSettings: BrowserSettings;\n    authSession: AuthSession;\n  }) {\n    const { websocket, req, counter, browserSettings } = options;\n    this._websocket = websocket;\n    this._req = req;\n    this._counter = counter;\n    this.browserSettings = browserSettings;\n    if (!browserSettings.locale) { browserSettings.locale = this._locale; }\n    this._authSession = options.authSession;\n\n    websocket.onerror = (err: Error) => this._onError(err);\n    websocket.onclose = () => this._onClose();\n    websocket.onmessage = (msg: string) => this._onMessage(msg);\n  }\n\n  public get authSession(): AuthSession {\n    return this._authSession;\n  }\n\n  public getConnectionRequest(): IncomingMessage | null {\n    return this._req;\n  }\n\n  /**\n   * Returns DocSession for the given docFD, or throws an exception if this doc is not open.\n   */\n  public getDocSession(fd: number): DocSession {\n    const docSession = this._docFDs[fd];\n    if (!docSession) {\n      throw new Error(`Invalid docFD ${fd}`);\n    }\n    return docSession;\n  }\n\n  // Adds a new DocSession to this Client, and returns the new FD for it.\n  public addDocSession(activeDoc: ActiveDoc, docSessionPrecursor: DocSessionPrecursor): DocSession {\n    const fd = this._getNextDocFD();\n    const docSession = new DocSession(docSessionPrecursor, activeDoc, fd);\n    this._docFDs[fd] = docSession;\n    return docSession;\n  }\n\n  // Removes a DocSession from this Client, called when a doc is closed.\n  public removeDocSession(fd: number): void {\n    this._docFDs[fd] = null;\n  }\n\n  /**\n   * Closes all docs. Returns the number of documents closed.\n   */\n  public closeAllDocs(): number {\n    let count = 0;\n    for (let fd = 0; fd < this._docFDs.length; fd++) {\n      const docSession = this._docFDs[fd];\n      if (docSession?.activeDoc) {\n        // Note that this indirectly calls to removeDocSession(docSession.fd)\n        docSession.activeDoc.closeDoc(docSession)\n          .catch((e) => { this._log.warn(null, \"error closing docFD %d\", fd); });\n        count++;\n      }\n      this._docFDs[fd] = null;\n    }\n    return count;\n  }\n\n  public interruptConnection() {\n    if (this._websocket) {\n      this._websocket.removeAllListeners();\n      // It is important to keep an onerror handler, since otherwise\n      // errors bring down the server.\n      this._websocket.onerror = (err: Error) => {\n        this._log.warn(null, \"Error after interruption\", err);\n      };\n      this._websocket.terminate();  // close() is inadequate when ws routed via loadbalancer\n      this._websocket = null;\n    }\n  }\n\n  /**\n   * Sends a message to the client. If the send fails in a way that the message can't get queued\n   * (e.g. due to an unexpected exception in code), logs an error and interrupts the connection.\n   */\n  public async sendMessageOrInterrupt(messageObj: CommMessage | CommResponse | CommResponseError): Promise<void> {\n    try {\n      await this.sendMessage(messageObj);\n    } catch (e) {\n      this._log.error(null, \"sendMessage error\", e);\n      this.interruptConnection();\n    }\n  }\n\n  /**\n   * Sends a message to the client, queuing it up on failure or if the client is disconnected.\n   */\n  public async sendMessage(messageObj: CommMessage | CommResponse | CommResponseError): Promise<void> {\n    if (this._destroyed) {\n      return;\n    }\n\n    // Large responses require memory; with many connected clients this can crash the server. We\n    // manage it using a MemoryPool, waiting for free space to appear. This only controls the\n    // memory used to hold the JSON.stringify result. Once sent, the reservation is released.\n    //\n    // Actual process memory will go up also as the outgoing data is sitting in socket buffers,\n    // but this isn't part of Node's heap. If an outgoing buffer is full, websocket.send may\n    // block, and MemoryPool will delay other responses. There is a risk here of unresponsive\n    // clients exhausing the MemoryPool, perhaps intentionally. To mitigate, we could destroy\n    // clients that are too slow in reading. This isn't currently done.\n    //\n    // Also, we do not manage memory of responses moved to a client's _missedMessages queue. But\n    // we do limit those in size.\n    //\n    // Overall, a better solution would be to stream large responses, or to have the client\n    // request data piecemeal (as we'd have to for handling large data).\n\n    await jsonMemoryPool.withReserved(Deps.jsonResponseReservation, async (updateReservation) => {\n      if (this._destroyed) {\n        // If this Client got destroyed while waiting, stop here and release the reservation.\n        return;\n      }\n      const seqId = this._nextSeqId++;\n      const message: string = JSON.stringify({ ...messageObj, seqId });\n      const size = Buffer.byteLength(message, \"utf8\");\n      updateReservation(size);\n\n      // Log something useful about the message being sent.\n      if (\"error\" in messageObj && messageObj.error) {\n        this._log.warn(null, \"responding to #%d ERROR %s\", messageObj.reqId, messageObj.error);\n      }\n\n      if (this._websocket) {\n        // If we have a websocket, send the message.\n        try {\n          await this._sendToWebsocket(message);\n          // NOTE: A successful send does NOT mean the message was received. For a better system, see\n          // https://docs.microsoft.com/en-us/azure/azure-web-pubsub/howto-develop-reliable-clients\n          // (keeping a copy of messages until acked). With our system, we are more likely to be\n          // lacking the needed messages on reconnect, and having to reset the client.\n          return;\n        } catch (err) {\n          // Sending failed. Add the message to missedMessages.\n          this._log.warn(null, \"sendMessage: queuing after send error:\", err.toString());\n        }\n      }\n      if (this._missedMessages.size < clientMaxMissedMessages &&\n        this._missedMessagesTotalLength + message.length <= clientMaxMissedBytes) {\n        // Queue up the message.\n        // TODO: this keeps the memory but releases jsonMemoryPool reservation, which is wrong --\n        // it may allow too much memory to be used. This situation is rare, however, so maybe OK\n        // as is. Ideally, the queued messages could reserve memory in a \"nice to have\" mode, and\n        // if memory is needed for something more important, the queue would get dropped.\n        // (Holding on to the memory reservation here would creates a risk of freezing future\n        // responses, which seems *more* dangerous than a crash because a crash would at least\n        // lead to an eventual recovery.)\n        this._missedMessages.set(seqId, message);\n        this._missedMessagesTotalLength += message.length;\n      } else {\n        // Too many messages queued. Boot the client now, to make it reset when/if it reconnects.\n        this._log.warn(null, \"sendMessage: too many messages queued; booting client\");\n        this.destroy();\n      }\n    });\n  }\n\n  /**\n   * Called from Comm.ts to decide whether this Client is available to accept a new connection\n   * that requests the same clientId.\n   */\n  public canAcceptConnection(): boolean {\n    // Refuse reconnect if another websocket is currently active. It may be a new browser tab\n    // (which may reuse clientId from a copy of sessinStorage). It will need its own Client object.\n    return !this._websocket;\n  }\n\n  /**\n   * Complete initialization of a new connection, and send the initial 'clientConnect' message.\n   * See comments at the top of app/server/lib/Comm.ts for some relevant notes.\n   */\n  public async sendConnectMessage(\n    newClient: boolean, reuseClient: boolean, lastSeqId: number | null, parts: Partial<CommClientConnect>,\n  ): Promise<void> {\n    if (this._destroyTimer) {\n      clearTimeout(this._destroyTimer);\n      this._destroyTimer = null;\n    }\n\n    let missedMessages: string[] | undefined = undefined;\n    let seamlessReconnect = false;\n    if (!newClient && reuseClient && await this._isAuthorized()) {\n      // Websocket-level reconnect: existing browser tab reconnected to an existing Client object.\n      // We also check that the Client is still authorized to access all open docs. If not, we'll\n      // close the docs and tell the Client to reload the app.\n      missedMessages = this.getMissedMessages(lastSeqId);\n      if (missedMessages) {\n        // We have all the needed messages (possibly an empty array); can do a seamless reconnect.\n        seamlessReconnect = true;\n      }\n    }\n\n    // We collected any missed messages we need; clear the stored map of them.\n    this._missedMessages.clear();\n    this._missedMessagesTotalLength = 0;\n\n    let docsClosed: number | null = null;\n    if (!seamlessReconnect) {\n      // The browser client can't recover from missed messages and will need to reopen docs. Close\n      // all docs we kept open. If it's a new Client object, this is a no-op.\n      docsClosed = this.closeAllDocs();\n    }\n\n    // An existing browser client that can't recover, or that connected to a new Client object,\n    // will need to reopen docs. Tell it to reload.\n    const needReload = !newClient && !seamlessReconnect;\n\n    this._log.debug({ newClient, needReload, docsClosed, missedMessages: missedMessages?.length },\n      \"sending clientConnect\");\n\n    // Don't use sendMessage here, since we don't want to queue up this message on failure.\n    const clientConnectMsg: CommClientConnect = {\n      ...parts,\n      type: \"clientConnect\",\n      clientId: this.clientId,\n      missedMessages,\n      needReload,\n    };\n\n    try {\n      await this._sendToWebsocket(JSON.stringify(clientConnectMsg));\n\n      if (needReload) {\n        // If the client should reload, close the socket without waiting. This connection should\n        // not be used anyway, and we want it released by the time the new connection comes in.\n        this._websocket?.close();\n        return;\n      }\n\n      // A heavy-handed fix to T396, since 'clientConnect' is sometimes not seen in the browser,\n      // (seemingly when the 'message' event is triggered before 'open' on the native WebSocket.)\n      // See also my report at https://stackoverflow.com/a/48411315/328565\n      await delay(250);\n\n      if (!this._destroyed && this._websocket?.isOpen) {\n        await this._sendToWebsocket(JSON.stringify({ ...clientConnectMsg, dup: true }));\n      }\n    } catch (err) {\n      // It's possible that the connection was closed while we were preparing this response.\n      // We just warn, and let _onClose() take care of cleanup.\n      this._log.warn(null, \"failed to prepare or send clientConnect:\", err.toString());\n    }\n  }\n\n  // Get messages in order of their key in the _missedMessages map.\n  public getMissedMessages(lastSeqId: number | null): string[] | undefined {\n    const result: string[] = [];\n    if (lastSeqId !== null) {\n      for (let i = lastSeqId + 1; i < this._nextSeqId; i++) {\n        const m = this._missedMessages.get(i);\n        if (m === undefined) { return; }\n        result.push(m);\n      }\n    }\n    return result;\n  }\n\n  /**\n   * Destroys a client. If the same browser window reconnects later, it will get a new Client\n   * object and clientId.\n   */\n  public destroy() {\n    const docsClosed = this.closeAllDocs();\n    this._log.info({ docsClosed }, \"client gone\");\n    if (this._destroyTimer) {\n      clearTimeout(this._destroyTimer);\n      this._destroyTimer = null;\n    }\n    this._missedMessages.clear();\n    this._missedMessagesTotalLength = 0;\n    this._comm.removeClient(this);\n    this._destroyed = true;\n  }\n\n  public getLogMeta(meta: log.ILogMeta = {}): log.ILogMeta {\n    return {\n      ...meta,\n      ...this._authSession.getLogMeta(),\n      clientId: this.clientId,    // identifies a client connection, essentially a websocket\n      counter: this._counter,     // identifies a GristWSConnection in the connected browser tab\n    };\n  }\n\n  private async _onMessage(message: string): Promise<void> {\n    try {\n      await this._onMessageImpl(message);\n    } catch (err) {\n      this._log.warn(null, 'onMessage error received for message \"%s\": %s', shortDesc(message), err.stack);\n    }\n  }\n\n  /**\n   * Processes a request from a client. All requests from a client get a response, at least to\n   * indicate success or failure.\n   */\n  private async _onMessageImpl(message: string): Promise<void> {\n    const request = JSON.parse(message);\n    if (request.beat) {\n      // this is a heart beat, to keep the websocket alive.  No need to reply.\n      log.rawInfo(\"heartbeat\", {\n        ...this.getLogMeta(),\n        url: request.url,\n        docId: request.docId,  // caution: trusting client for docId for this purpose.\n      });\n      return;\n    }\n    let response: CommResponse | CommResponseError;\n    const method = this._methods.get(request.method);\n    if (!method) {\n      this._log.info(null, \"onMessage: unknown method\", shortDesc(message));\n      response = { reqId: request.reqId, error: `Unknown method ${request.method}` };\n    } else {\n      try {\n        response = { reqId: request.reqId, data: await method(this, ...request.args) };\n      } catch (error) {\n        const err: ErrorWithCode = error;\n        // Print the error stack, except for SandboxErrors, for which the JS stack isn't that useful.\n        // Also not helpful is the stack of AUTH_NO_VIEW|EDIT errors produced by the Authorizer.\n        const code: unknown = err.code;\n        const skipStack = (\n          !err.stack ||\n          err.stack.match(/^SandboxError:/) ||\n          (typeof code === \"string\" && code.startsWith(\"AUTH_NO\"))\n        );\n\n        this._log.warn(null, \"Responding to method %s with error: %s %s\",\n          request.method, skipStack ? err : err.stack, code || \"\");\n        response = { reqId: request.reqId, error: err.message };\n        if (err.code) {\n          response.errorCode = err.code;\n        }\n        if (err.details) {\n          response.details = err.details;\n        }\n        if (err.status) {\n          response.status = err.status;\n        }\n        if (typeof code === \"string\" && code === \"AUTH_NO_EDIT\" && err.accessMode === \"fork\") {\n          response.shouldFork = true;\n        }\n      }\n    }\n    await this.sendMessageOrInterrupt(response);\n  }\n\n  // Check that client still has access to all documents.  Used to determine whether\n  // a Comm client can be safely reused after a reconnect.  Without this check, the client\n  // would be reused even if access to a document has been lost (although an error would be\n  // issued later, on first use of the document).\n  private async _isAuthorized(): Promise<boolean> {\n    for (const docFD of this._docFDs) {\n      try {\n        if (docFD !== null) { await docFD.authorizer.assertAccess(\"viewers\"); }\n      } catch (e) {\n        return false;\n      }\n    }\n    return true;\n  }\n\n  // Returns the next unused docFD number.\n  private _getNextDocFD(): number {\n    let fd = 0;\n    while (this._docFDs[fd]) { fd++; }\n    return fd;\n  }\n\n  private _sendToWebsocket(message: string): Promise<void> {\n    return fromCallback(cb => this._websocket!.send(message, cb));\n  }\n\n  /**\n   * Processes an error on the websocket.\n   */\n  private _onError(err: Error) {\n    this._log.warn(null, \"onError\", err);\n    // TODO Make sure that this is followed by onClose when the connection is lost.\n  }\n\n  /**\n   * Processes the closing of a websocket.\n   */\n  private _onClose() {\n    this._websocket?.removeAllListeners();\n\n    // Remove all references to the websocket.\n    this._websocket = null;\n\n    if (!this._destroyed) {\n      // Schedule the client to be destroyed after a timeout. The timer gets cleared if the same\n      // client reconnects in the interim.\n      if (this._destroyTimer) {\n        this._log.warn(null, \"clearing previously scheduled destruction\");\n        clearTimeout(this._destroyTimer);\n      }\n      this._log.info(null, \"websocket closed; will discard client in %s sec\", Deps.clientRemovalTimeoutMs / 1000);\n      this._destroyTimer = setTimeout(() => this.destroy(), Deps.clientRemovalTimeoutMs);\n    }\n  }\n}\n"
  },
  {
    "path": "app/server/lib/Comm.ts",
    "content": "/**\n * The server's Comm object implements communication with the client.\n *\n * The server receives requests, to which it sends a response (or an error). The server can\n * also send asynchronous messages to the client. Available methods should be provided via\n * comm.registerMethods().\n *\n * To send async messages, you may call broadcastMessage() or sendDocMessage().\n *\n * See app/client/components/Comm.ts for other details of the communication protocol.\n *\n *\n * This module relies on the concept of a \"Client\" (see Client.ts). A Client corresponds to a\n * browser window, and should persist across brief disconnects. A Client has a 'clientId'\n * property, which uniquely identifies a client within the currently running server. Method\n * registered with Comm always receive a Client object as the first argument.\n *\n * NOTES:\n *\n * The communication setup involves primarily the modules app/server/lib/{Comm,Client}.ts, and\n * app/client/components/{Comm,GristWSConnection}.ts. In particular, these implement reconnect\n * logic, which is particularly confusing as done here because it combines two layers:\n *\n * - Websocket-level reconnects, where an existing browser tab may reconnect and attempt to\n *   restore state seamlessly by recovering any missed messages.\n *\n * - Application-level reconnects, where even in case of a failed websocket-level reconnect (e.g.\n *   a reloaded browser tab, or existing tab that can't recover missed messages), the tab may\n *   connect to existing state. This matters for undo/redo history (to allow a user to undo after\n *   reloading a browser tab), but the only thing this relies on is preserving the clientId.\n *\n * In other words, there is an opportunity for untangling and simplifying.\n */\n\nimport { parseFirstUrlPart } from \"app/common/gristUrls\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { UserProfile } from \"app/common/LoginSessionAPI\";\nimport * as version from \"app/common/version\";\nimport { HomeDBAuth } from \"app/gen-server/lib/homedb/Interfaces\";\nimport { AuthSession } from \"app/server/lib/AuthSession\";\nimport { ScopedSession } from \"app/server/lib/BrowserSession\";\nimport { Client, ClientMethod } from \"app/server/lib/Client\";\nimport { Hosts, RequestWithOrg } from \"app/server/lib/extractOrg\";\nimport { GristLoginMiddleware } from \"app/server/lib/GristServer\";\nimport { GristServerSocket } from \"app/server/lib/GristServerSocket\";\nimport { GristSocketServer } from \"app/server/lib/GristSocketServer\";\nimport log from \"app/server/lib/log\";\nimport { trustOrigin } from \"app/server/lib/requestUtils\";\nimport { localeFromRequest } from \"app/server/lib/ServerLocale\";\nimport { fromCallback } from \"app/server/lib/serverUtils\";\nimport { Sessions } from \"app/server/lib/Sessions\";\n\nimport { EventEmitter } from \"events\";\nimport * as http from \"http\";\nimport * as https from \"https\";\n\nimport { i18n } from \"i18next\";\n\nexport interface CommOptions {\n  sessions: Sessions;                   // A collection of all sessions for this instance of Grist\n  dbManager?: HomeDBAuth;                // HomeDBManager, just the part needed for auth.\n  settings?: { [key: string]: unknown };  // The config object containing instance settings including features.\n  hosts?: Hosts;  // If set, we use hosts.getOrgInfo(req) to extract an organization from a (possibly versioned) url.\n  loginMiddleware?: GristLoginMiddleware; // If set, use custom getProfile method if available\n  httpsServer?: https.Server;   // An optional HTTPS server to listen on too.\n  i18Instance?: i18n;           // The i18next instance to use for translations.\n}\n\n/**\n * Constructs a Comm object.\n * @param {Object} server - The HTTP server.\n * @param {Object} options.sessions - A collection of sessions\n * @param {Object} options.settings - The config object containing instance settings\n *  including features.\n * @param {Object} options.instanceManager - Instance manager, giving access to InstanceStore\n *  and per-instance objects. If null, HubUserClient will not be created.\n * @param {Object} options.hosts - Hosts object from extractOrg.ts. if set, we use\n *  hosts.getOrgInfo(req) to extract an organization from a (possibly versioned) url.\n */\nexport class Comm extends EventEmitter {\n  // Collection of all sessions; maps sessionIds to ScopedSession objects.\n  public readonly sessions: Sessions = this._options.sessions;\n  private _wss: GristSocketServer[] | null = null;\n\n  private _clients = new Map<string, Client>();   // Maps clientIds to Client objects.\n\n  private _methods = new Map<string, ClientMethod>();  // Maps method names to their implementation.\n\n  // For testing, we need a way to override the server version reported.\n  // For upgrading, we use this to set the server version for a defunct server\n  // to \"dead\" so that a client will know that it needs to periodically recheck\n  // for a valid server.\n  private _serverVersion: string | null = null;\n\n  constructor(private _server: http.Server, private _options: CommOptions) {\n    super();\n    this._wss = this._startServer();\n  }\n\n  /**\n   * Registers server methods.\n   * @param {Object[String:Function]} Mapping of method name to their implementations. All methods\n   *      receive the client as the first argument, and the arguments from the request.\n   */\n  public registerMethods(serverMethods: { [name: string]: ClientMethod }): void {\n    // Wrap methods to translate return values and exceptions to promises.\n    for (const methodName in serverMethods) {\n      this._methods.set(methodName, serverMethods[methodName]);\n    }\n  }\n\n  /**\n   * Returns the Client object associated with the given clientId, or throws an Error if not found.\n   */\n  public getClient(clientId: string): Client {\n    const client = this._clients.get(clientId);\n    if (!client) { throw new Error(\"Unrecognized clientId\"); }\n    return client;\n  }\n\n  /**\n   * Broadcasts an app-level message to all clients. Only suitable for non-doc-specific messages.\n   */\n  public broadcastMessage(type: \"docListAction\", data: unknown) {\n    for (const client of this._clients.values()) {\n      client.sendMessage({ type, data }).catch(() => {});\n    }\n  }\n\n  public removeClient(client: Client) {\n    this._clients.delete(client.clientId);\n  }\n\n  public async testServerShutdown() {\n    if (this._wss) {\n      for (const wssi of this._wss) {\n        await fromCallback(cb => wssi.close(cb));\n      }\n      this._wss = null;\n    }\n  }\n\n  public async testServerRestart() {\n    await this.testServerShutdown();\n    this._wss = this._startServer();\n  }\n\n  /**\n   * Destroy all clients, forcing reconnections.\n   */\n  public destroyAllClients() {\n    // Iterate over all clients.  Take a copy of the list of clients since it will be changing\n    // during the loop as we remove them one by one.\n    for (const client of Array.from(this._clients.values())) {\n      client.interruptConnection();\n      client.destroy();\n    }\n  }\n\n  /**\n   * Override the version string Comm will report to clients.\n   * Call with null to reset the override.\n   *\n   */\n  public setServerVersion(serverVersion: string | null) {\n    this._serverVersion = serverVersion;\n  }\n\n  /**\n   * Mark the server as active or inactive.  If inactive, any client that manages to\n   * connect to it will read a server version of \"dead\".\n   */\n  public setServerActivation(active: boolean) {\n    this._serverVersion = active ? null : \"dead\";\n  }\n\n  /**\n   * Returns a profile based on the request or session.\n   */\n  private async _getSessionProfile(\n    scopedSession: ScopedSession, req: http.IncomingMessage,\n  ): Promise<UserProfile | null> {\n    return (\n      (await this._options.loginMiddleware?.overrideProfile?.(req)) ??\n      (await scopedSession.getSessionProfile())\n    );\n  }\n\n  /**\n   * Processes a new websocket connection, and associates the websocket and a Client object.\n   */\n  private async _onWebSocketConnection(websocket: GristServerSocket, req: http.IncomingMessage) {\n    const params = new URL(req.url!, `ws://${req.headers.host}`).searchParams;\n    const existingClientId = params.get(\"clientId\");\n    const browserSettings = safeJsonParse(params.get(\"browserSettings\") || \"\", {});\n    const newClient = (params.get(\"newClient\") !== \"0\");  // Treat omitted as new, for the sake of tests.\n    const lastSeqIdStr = params.get(\"lastSeqId\");\n    const lastSeqId = lastSeqIdStr ? parseInt(lastSeqIdStr) : null;\n    const counter = params.get(\"counter\");\n    const userSelector = params.get(\"user\") || \"\";\n\n    // Associate an ID with each websocket, reusing the supplied one if it's valid.\n    let client: Client | undefined = this._clients.get(existingClientId!);\n    let reuseClient = true;\n    if (!client?.canAcceptConnection()) {\n      reuseClient = false;\n      client = new Client(this, this._methods, localeFromRequest(req), this._options.i18Instance);\n      this._clients.set(client.clientId, client);\n    }\n\n    log.rawInfo(\"Comm: Got Websocket connection\", { ...client.getLogMeta(), urlPath: req.url, reuseClient });\n\n    // Parse the cookie in the request to get the sessionId.\n    const sessionId = this.sessions.getSessionIdFromRequest(req);\n    const scopedSession = this.sessions.getOrCreateSession(sessionId!, (req as RequestWithOrg).org, userSelector);\n    const profile = await this._getSessionProfile(scopedSession, req);\n    const authSession = await getAuthSession(this._options.dbManager, scopedSession, profile);\n\n    client.setConnection({ websocket, req, counter, browserSettings, authSession });\n\n    await client.sendConnectMessage(newClient, reuseClient, lastSeqId, {\n      serverVersion: this._serverVersion || version.gitcommit,\n      settings: this._options.settings,\n    });\n  }\n\n  private _startServer() {\n    const servers = [this._server];\n    if (this._options.httpsServer) { servers.push(this._options.httpsServer); }\n    const wss = [];\n    for (const server of servers) {\n      const wssi = new GristSocketServer(server, {\n        verifyClient: async (req: http.IncomingMessage) => {\n          try {\n            if (this._options.hosts) {\n              // DocWorker ID (/dw/) and version tag (/v/) may be present in this request but are not\n              // needed. addOrgInfo assumes req.url starts with /o/ if present.\n              req.url = parseFirstUrlPart(\"dw\", req.url!).path;\n              req.url = parseFirstUrlPart(\"v\", req.url).path;\n              await this._options.hosts.addOrgInfo(req);\n            }\n\n            return trustOrigin(req);\n          } catch (err) {\n            // Consider exceptions (e.g. in parsing unexpected hostname) as failures to verify.\n            // In practice, we only see this happening for spammy/illegitimate traffic; there is\n            // no particular reason to log these.\n            return false;\n          }\n        },\n      });\n\n      wssi.onconnection = async (websocket: GristServerSocket, req) => {\n        try {\n          await this._onWebSocketConnection(websocket, req);\n        } catch (e) {\n          log.error(\"Comm connection for %s threw exception: %s\", req.url, e.message);\n          websocket.terminate();  // close() is inadequate when ws routed via loadbalancer\n        }\n      };\n      wss.push(wssi);\n    }\n    return wss;\n  }\n}\n\n// This is a subset of the logic in Authorizer addRequestUser(), but sufficient for websocket\n// connections, which rely on having an existing session.\nasync function getAuthSession(\n  dbManager: HomeDBAuth | undefined, scopedSession: ScopedSession, profile: UserProfile | null,\n): Promise<AuthSession> {\n  if (!dbManager) {\n    return AuthSession.unauthenticated();\n  }\n  const user = profile?.email ? await dbManager.getUserByLogin(profile.email) : dbManager.getAnonymousUser();\n  const fullUser = dbManager.makeFullUser(user);\n  return AuthSession.fromUser(fullUser, scopedSession.org, scopedSession.getAltSessionId());\n}\n"
  },
  {
    "path": "app/server/lib/ConfigBackendAPI.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { AuthProvider } from \"app/common/ConfigAPI\";\nimport { GETGRIST_COM_PROVIDER_KEY } from \"app/common/loginProviders\";\nimport { ActivationsManager } from \"app/gen-server/lib/ActivationsManager\";\nimport { appSettings, AppSettings } from \"app/server/lib/AppSettings\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { getGetGristComHost, readGetGristComConfigFromSettings } from \"app/server/lib/GetGristComConfig\";\nimport { getGlobalConfig } from \"app/server/lib/globalConfig\";\nimport log from \"app/server/lib/log\";\nimport {\n  getActiveLoginSystemType,\n  getActiveLoginSystemTypeSource,\n  NotConfiguredError,\n} from \"app/server/lib/loginSystemHelpers\";\nimport { LOGIN_SYSTEMS } from \"app/server/lib/loginSystems\";\nimport { sendOkReply, stringParam } from \"app/server/lib/requestUtils\";\n\nimport * as express from \"express\";\n\nexport class ConfigBackendAPI {\n  constructor(private _activations: ActivationsManager) {\n  }\n\n  public addEndpoints(app: express.Express, requireInstallAdmin: express.RequestHandler) {\n    // GET /api/config/auth-providers\n    // Returns available authentication providers.\n    app.get(\"/api/config/auth-providers\", requireInstallAdmin, expressWrap(async (req, res) => {\n      const providers = await this._buildProviderList();\n      return sendOkReply(req, res, providers);\n    }));\n\n    // POST /api/config/auth-providers/set-active\n    // Set active login system (will be active after restart)\n    app.post(\"/api/config/auth-providers/set-active\", requireInstallAdmin, expressWrap(async (req, resp) => {\n      const { providerKey } = req.body;\n      if (!providerKey) {\n        resp.status(400).send({ error: \"providerKey is required\" });\n        return;\n      }\n\n      // Get current providers to validate\n      const providers = await this._buildProviderList();\n      const provider = providers.find((p: AuthProvider) => p.key === providerKey);\n      if (!provider) {\n        resp.status(404).send({ error: \"Provider not found\" });\n        return;\n      }\n\n      await this._activations.updateAppEnvFile({ GRIST_LOGIN_SYSTEM_TYPE: providerKey });\n\n      await this._activations.updatePrefs({ onRestartClearSessions: true });\n\n      return sendOkReply(req, resp, { msg: \"ok\" });\n    }));\n\n    // PATCH /api/config/auth-providers?provider=...\n    app.patch(\"/api/config/auth-providers\", requireInstallAdmin, expressWrap(async (req, resp) => {\n      stringParam(req.query.provider, \"provider\", { allowed: [GETGRIST_COM_PROVIDER_KEY] });\n\n      const gristComSecret = \"GRIST_GETGRISTCOM_SECRET\";\n\n      // And expect just a secret key in the body.\n      const key = req.body[gristComSecret]?.split(/\\s+/).join(\"\");\n      if (!key) {\n        throw new ApiError(\"Request doesn't contain valid getgrist.com configuration parameter\", 400);\n      }\n      const newSettings = new AppSettings(\"grist\");\n      const currentEnvVars = (await this._activations.current()).prefs?.envVars || {};\n      const newEnvVars = { ...currentEnvVars, [gristComSecret]: key };\n      newSettings.setEnvVars(newEnvVars);\n      // Check configuration, we now expect that this is enough to configure the provider.\n      try {\n        readGetGristComConfigFromSettings(newSettings);\n      } catch (e) {\n        // If still not configured, something is wrong with the provided key, but we don't know what exactly,\n        // as the check function thinks nothing is configured, if the key was invalid it would have thrown earlier.\n        throw new ApiError(\"Error configuring provider with the provided key.\", 400);\n      }\n      await this._activations.updateAppEnvFile({ [gristComSecret]: key });\n      // TODO: Restart may not always be required. When this endpoint evolves to support other\n      // providers, be more nuanced about setting this.\n      await this._activations.updatePrefs({ onRestartClearSessions: true });\n      return sendOkReply(req, resp, { msg: \"ok\" });\n    }));\n\n    // Returns the getgrist.com host for the current configuration, needed for initial handshake.\n    // Notice: while the code is using current settings to determine the host, the secret key doesn't contain\n    // the GRIST_GETGRISTCOM_SP_HOST variable. It can be only set via env var. We still read from the current\n    // settings for consistency and future use.\n    // GET /api/config/auth-providers/config?provider=getgrist.com\n    app.get(\"/api/config/auth-providers/config\", requireInstallAdmin, expressWrap(async (req, resp) => {\n      stringParam(req.query.provider, \"provider\", { allowed: [GETGRIST_COM_PROVIDER_KEY] });\n      return sendOkReply(req, resp, {\n        GRIST_GETGRISTCOM_SP_HOST: getGetGristComHost(appSettings),\n      });\n    }));\n\n    app.get(\"/api/config/:key\", requireInstallAdmin, expressWrap((req, resp) => {\n      log.debug(\"config: requesting configuration\", req.params);\n\n      // Only one key is valid for now\n      if (req.params.key === \"edition\") {\n        resp.send({ value: getGlobalConfig().edition.get() });\n      } else {\n        resp.status(404).send({ error: \"Configuration key not found.\" });\n      }\n    }));\n\n    app.patch(\"/api/config\", requireInstallAdmin, expressWrap(async (req, resp) => {\n      const config = req.body.config;\n      log.debug(\"config: received new configuration item\", config);\n\n      // Only one key is valid for now\n      if (config.edition !== undefined) {\n        await getGlobalConfig().edition.set(config.edition);\n\n        resp.send({ msg: \"ok\" });\n      } else {\n        resp.status(400).send({ error: \"Invalid configuration key\" });\n      }\n    }));\n  }\n\n  /**\n   * Build the list of available authentication providers based on AppSettings.\n   */\n  private async _buildProviderList(): Promise<AuthProvider[]> {\n    // Read new settings to see what will be picked after restart.\n    const newSettings = new AppSettings(\"grist\");\n    newSettings.setEnvVars((await this._activations.current()).prefs?.envVars || {});\n\n    // Now build the list of providers and check their configuration status.\n    const providers: AuthProvider[] = [];\n    for (const { key, name, reader: configuredCheck, metadataReader } of LOGIN_SYSTEMS) {\n      const record: AuthProvider = {\n        name,\n        key,\n      };\n      try {\n        configuredCheck(newSettings);\n        record.metadata = metadataReader?.(newSettings) ?? {};\n        record.isConfigured = true;\n      } catch (e) {\n        if (e instanceof NotConfiguredError) {\n          record.isConfigured = false;\n        } else {\n          record.configError = (e as Error).message;\n        }\n      }\n      providers.push(record);\n    }\n\n    // Now figure out which one will be active after restart.\n    return _fillProviderInfo({ newSettings, currentSettings: appSettings, providers });\n  }\n}\n\n/**\n * Fill out the provider info fields based on current and selected providers.\n * Method is private, exported for tests.\n */\nexport function _fillProviderInfo({\n  currentSettings,\n  newSettings,\n  providers,\n}: {\n  currentSettings: AppSettings; // The current settings to get runtime state\n  newSettings: AppSettings; // The new settings to determine active provider\n  providers: AuthProvider[]; // List of available providers and their new configuration status\n}) {\n  // Read the error and current provider from current settings.\n  const loginSection = currentSettings.section(\"login\");\n  const active = String(loginSection.flag(\"active\").get() ?? \"\") || undefined;\n  const error = loginSection.flag(\"error\").get() as string | undefined;\n\n  // Which system is selected explicitly via env var (if any)\n  const newFromConfig = getActiveLoginSystemType(newSettings);\n  const isNewFixedByEnv = getActiveLoginSystemTypeSource(newSettings) === \"env\";\n\n  // If we don't have picked system, Grist will pick the first configured one\n  const next = newFromConfig || providers.find(p => p.isConfigured || p.configError)?.key || active;\n\n  // Now we know enough to properly fill out the rest of the fields for the ui.\n  for (const provider of providers) {\n    // We will mark provider as active if it is currently active and will be active after restart.\n    provider.isActive = provider.key === active && provider.key === next;\n\n    // We will mark provider as configured if it is configured without an error.\n    provider.isConfigured = provider.isConfigured && !provider.configError;\n\n    // If this is current provider pass the error that was reported.\n    provider.activeError = provider.key === active ? error : undefined;\n\n    // If those two errors are the same, clear configError to avoid duplication.\n    provider.configError = provider.activeError === provider.configError ? undefined : provider.configError;\n\n    // We will mark provider as selected by env if it matches the selected value from env.\n    provider.isSelectedByEnv = isNewFixedByEnv && provider.key === newFromConfig;\n\n    // Provider will be active after restart if it is the next one but not current one.\n    provider.willBeActive = provider.key === next && provider.key !== active;\n\n    // Provider will be disabled after restart if it is current one but not next one.\n    provider.willBeDisabled = provider.key === active && provider.key !== next;\n\n    // Provider can be activated if it is configured and not selected by env.\n    provider.canBeActivated = provider.isConfigured && provider.key !== next && !isNewFixedByEnv;\n  }\n\n  // For easy testing remove undefined and false values.\n  for (const provider of providers) {\n    for (const key of Object.keys(provider)) {\n      if (provider[key as keyof AuthProvider] === undefined ||\n        provider[key as keyof AuthProvider] === false) {\n        delete provider[key as keyof AuthProvider];\n      }\n    }\n  }\n\n  return providers;\n}\n"
  },
  {
    "path": "app/server/lib/DiscourseConnect.ts",
    "content": "/**\n * Endpoint to support DiscourseConnect, to allow users to use their Grist logins for the\n * Grist Community Forum.\n *\n * Adds one endpoint:\n *  - /discourse-connect: sends signed user info in a redirect to DISCOURSE_SITE.\n *\n * Expects environment variables:\n *  - DISCOURSE_SITE: URL of the Discourse site to which to redirect back.\n *  - DISCOURSE_CONNECT_SECRET: Secret for checking and adding signatures.\n *\n * This follows documentation at\n * https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045\n * The recommended Discourse configuration includes:\n *  - enable discourse connect: true\n *  - discourse connect url: GRIST_SITE/discourse-connect\n *  - discourse connect secret: DISCOURSE_CONNECT_SECRET\n *  - logout redirect (in Users): GRIST_SITE/logout?next=DISCOURSE_SITE\n */\n\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { getOriginUrl } from \"app/server/lib/requestUtils\";\n\nimport * as crypto from \"crypto\";\n\nimport type { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport type { Express, NextFunction, Request, RequestHandler, Response } from \"express\";\n\nconst DISCOURSE_CONNECT_SECRET = process.env.DISCOURSE_CONNECT_SECRET;\nconst DISCOURSE_SITE = process.env.DISCOURSE_SITE;\n\n// A hook for dependency injection. Allows tests to override these variables on the fly.\nexport const Deps = { DISCOURSE_CONNECT_SECRET, DISCOURSE_SITE };\n\n// Calculate payload signature using the given secret.\nexport function calcSignature(payload: string, secret: string) {\n  return crypto.createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n}\n\n// Check configuration and signature of the Discourse nonce in the request.\nfunction checkParams(req: Request, resp: Response, next: NextFunction) {\n  if (!Deps.DISCOURSE_SITE || !Deps.DISCOURSE_CONNECT_SECRET) {\n    throw new Error(\"DiscourseConnect not configured\");\n  }\n  const payload = String(req.query.sso || \"\");\n  const signature = String(req.query.sig || \"\");\n  if (calcSignature(payload, Deps.DISCOURSE_CONNECT_SECRET) !== signature) {\n    throw new Error(\"Invalid signature for Discourse SSO request\");\n  }\n  const params = new URLSearchParams(Buffer.from(payload, \"base64\").toString(\"utf8\"));\n  const nonce = params.get(\"nonce\");\n  if (!nonce) {\n    throw new Error(\"Invalid request for Discourse SSO\");\n  }\n  (req as any).discourseConnectNonce = nonce;\n  next();\n}\n\n// Respond to the DiscourseConnect request by redirecting back to discourse, including the user\n// info and a signature into the URL parameters.\nfunction discourseConnect(req: Request, resp: Response) {\n  const mreq = req as RequestWithLogin;\n  const nonce: string | undefined = (req as any).discourseConnectNonce;\n  if (!nonce) {\n    throw new Error(\"Invalid request for Discourse SSO\");\n  }\n  if (!mreq.userIsAuthorized || !mreq.user?.loginEmail) {\n    throw new Error(\"User is not authenticated\");\n  }\n  if (!req.query.user && mreq.users && mreq.users.length > 1) {\n    const origUrl = new URL(req.originalUrl, getOriginUrl(req));\n    const redirectUrl = new URL(\"/welcome/select-account\", getOriginUrl(req));\n    redirectUrl.searchParams.set(\"next\", origUrl.toString());\n    return resp.redirect(redirectUrl.toString());\n  }\n  const responseObj: { [key: string]: string } = {\n    nonce,\n    email: mreq.user.loginEmail,\n    // We don't treat user IDs as secret, so use the same ID with Discourse directly.\n    external_id: String(mreq.user.id),\n    // We could specify the username (used for @ mentions), but let Discourse create one for us\n    // (it bases it on name or email). The user can change it within Discourse.\n    // username,\n    name: mreq.user.name,\n    ...(mreq.user.picture ? { avatar_url: mreq.user.picture } : {}),\n    suppress_welcome_message: \"true\",\n  };\n  const responseString = new URLSearchParams(responseObj).toString();\n  const responsePayload = Buffer.from(responseString, \"utf8\").toString(\"base64\");\n  const responseSignature = calcSignature(responsePayload, Deps.DISCOURSE_CONNECT_SECRET!);\n  const redirectUrl = new URL(\"/session/sso_login\", Deps.DISCOURSE_SITE);\n  redirectUrl.search = new URLSearchParams({ sso: responsePayload, sig: responseSignature }).toString();\n  return resp.redirect(redirectUrl.toString());\n}\n\n/**\n * Attach the endpoint for /discourse-connect, as documented at\n * https://meta.discourse.org/t/discourseconnect-official-single-sign-on-for-discourse-sso/13045\n */\nexport function addDiscourseConnectEndpoints(app: Express, options: {\n  userIdMiddleware: RequestHandler,\n  redirectToLogin: RequestHandler,\n}) {\n  app.get(\"/discourse-connect\",\n    expressWrap(checkParams),      // Check early, to fail early if Discourse is misconfigured.\n    options.userIdMiddleware,\n    options.redirectToLogin,\n    expressWrap(discourseConnect),\n  );\n}\n"
  },
  {
    "path": "app/server/lib/DocApi.ts",
    "content": "import { concatenateSummaries, summarizeAction } from \"app/common/ActionSummarizer\";\nimport { createEmptyActionSummary } from \"app/common/ActionSummary\";\nimport { QueryFilters } from \"app/common/ActiveDocAPI\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { BrowserSettings } from \"app/common/BrowserSettings\";\nimport {\n  BulkColValues,\n  ColValues,\n  fromTableDataAction,\n  TableColValues,\n  TableRecordValue,\n  UserAction,\n} from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { DocState, DocStateComparison, DocStates } from \"app/common/DocState\";\nimport { INITIAL_FIELDS_COUNT } from \"app/common/Forms\";\nimport {\n  extractInfoFromColType,\n  extractTypeFromColType,\n  getReferencedTableId,\n  isBlankValue,\n  isFullReferencingType,\n  isRaisedException,\n  reencodeAsTypedCellValue,\n} from \"app/common/gristTypes\";\nimport { buildUrlId, parseUrlId, SHARE_KEY_PREFIX } from \"app/common/gristUrls\";\nimport { isAffirmative, safeJsonParse } from \"app/common/gutil\";\nimport { SortFunc } from \"app/common/SortFunc\";\nimport { Sort } from \"app/common/SortSpec\";\nimport { MetaRowRecord } from \"app/common/TableData\";\nimport {\n  ArchiveUploadResult,\n  CreatableArchiveFormats,\n  DocReplacementOptions,\n  ExpandTableOption,\n  NEW_DOCUMENT_CODE,\n} from \"app/common/UserAPI\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { HomeDBManager, makeDocAuthResult } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { QueryResult } from \"app/gen-server/lib/homedb/Interfaces\";\nimport * as Types from \"app/plugin/DocApiTypes\";\nimport DocApiTypesTI from \"app/plugin/DocApiTypes-ti\";\nimport { CellFormatType } from \"app/plugin/GristAPI\";\nimport GristDataTI from \"app/plugin/GristData-ti\";\nimport { OpOptions } from \"app/plugin/TableOperations\";\nimport { TableOperationsImpl, TableOperationsPlatform } from \"app/plugin/TableOperationsImpl\";\nimport { ActiveDoc, getRealTableId } from \"app/server/lib/ActiveDoc\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { getDocPoolIdFromDocInfo } from \"app/server/lib/AttachmentStore\";\nimport {\n  getConfiguredAttachmentStoreConfigs,\n  getConfiguredStandardAttachmentStore,\n  IAttachmentStoreProvider,\n} from \"app/server/lib/AttachmentStoreProvider\";\nimport {\n  assertAccess,\n  getAuthorizedUserId,\n  getOrSetDocAuth,\n  getTransitiveHeaders,\n  getUserId,\n  isAnonymousUser,\n  RequestWithLogin,\n} from \"app/server/lib/Authorizer\";\nimport { DocApiTriggers } from \"app/server/lib/DocApiTriggers\";\nimport {\n  getErrorPlatform,\n  handleSandboxError,\n  validate,\n  validateCore,\n  WithDocHandler,\n} from \"app/server/lib/DocApiUtils\";\nimport { DocManager } from \"app/server/lib/DocManager\";\nimport { docSessionFromRequest, makeExceptionalDocSession, OptDocSession } from \"app/server/lib/DocSession\";\nimport { DocWorker } from \"app/server/lib/DocWorker\";\nimport { IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport { DownloadOptions, parseExportParameters } from \"app/server/lib/Export\";\nimport { downloadDSV } from \"app/server/lib/ExportDSV\";\nimport { collectTableSchemaInFrictionlessFormat } from \"app/server/lib/ExportTableSchema\";\nimport { streamXLSX } from \"app/server/lib/ExportXLSX\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { filterDocumentInPlace } from \"app/server/lib/filterUtils\";\nimport { googleAuthTokenMiddleware } from \"app/server/lib/GoogleAuth\";\nimport { exportToDrive } from \"app/server/lib/GoogleExport\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { getAnonPlaygroundEnabled } from \"app/server/lib/gristSettings\";\nimport { HashUtil } from \"app/server/lib/HashUtil\";\nimport { makeForkIds } from \"app/server/lib/idUtils\";\nimport log from \"app/server/lib/log\";\nimport {\n  getDocId,\n  getDocScope,\n  getExtraAttachmentOptions,\n  getScope,\n  integerParam,\n  isParameterOn,\n  optBooleanParam,\n  optIntegerParam,\n  optStringParam,\n  sendOkReply,\n  sendReply,\n  stringParam,\n} from \"app/server/lib/requestUtils\";\nimport { runSQLQuery } from \"app/server/lib/runSQLQuery\";\nimport { ServerColumnGetters } from \"app/server/lib/ServerColumnGetters\";\nimport { localeFromRequest } from \"app/server/lib/ServerLocale\";\nimport { getDocSessionShare } from \"app/server/lib/sessionUtils\";\nimport {\n  fetchDoc,\n  globalUploadSet,\n  handleOptionalUpload,\n  handleUpload,\n  makeAccessId,\n  parseMultipartFormRequest,\n} from \"app/server/lib/uploads\";\n\nimport * as assert from \"assert\";\nimport * as path from \"path\";\n\nimport contentDisposition from \"content-disposition\";\nimport { Application, NextFunction, Request, RequestHandler, Response } from \"express\";\nimport * as _ from \"lodash\";\nimport LRUCache from \"lru-cache\";\nimport * as moment from \"moment\";\nimport fetch from \"node-fetch\";\nimport * as t from \"ts-interface-checker\";\n\n// This is NOT the number of docs that can be handled at a time.\n// It's a very generous upper bound of what that number might be.\n// If there are more docs than this for which API requests are being regularly made at any moment,\n// then the _dailyUsage cache may become unreliable and users may be able to exceed their allocated requests.\nconst MAX_ACTIVE_DOCS_USAGE_CACHE = 1000;\n\n// Schema validators for api endpoints that creates or updates records.\nconst {\n  RecordsPatch, RecordsPost, RecordsPut,\n  ColumnsPost, ColumnsPatch, ColumnsPut,\n  SqlPost,\n  TablesPost, TablesPatch,\n  SetAttachmentStorePost,\n} = t.createCheckers(DocApiTypesTI, GristDataTI);\n\nfor (const checker of [RecordsPatch, RecordsPost, RecordsPut, ColumnsPost, ColumnsPatch,\n  SqlPost, TablesPost, TablesPatch]) {\n  checker.setReportedPath(\"body\");\n}\n\nexport class DocWorkerApi {\n  // Map from docId to number of requests currently being handled for that doc\n  private _currentUsage = new Map<string, number>();\n\n  // Map from (docId, time period) combination produced by docPeriodicApiUsageKey\n  // to number of requests previously served for that combination.\n  // We multiply by 5 because there are 5 relevant keys per doc at any time (current/next day/hour and current minute).\n  private _dailyUsage = new LRUCache<string, number>({ max: 5 * MAX_ACTIVE_DOCS_USAGE_CACHE });\n\n  // Cap on the number of requests that can be outstanding on a single\n  // document via the rest doc api. When this limit is exceeded,\n  // incoming requests receive an immediate reply with status 429.\n  private _maxParallelRequestsPerDoc = appSettings.section(\"docApi\").flag(\"maxParallelRequestsPerDoc\")\n    .requireInt({\n      envVar: \"GRIST_MAX_PARALLEL_REQUESTS_PER_DOC\",\n      defaultValue: 10,\n      minValue: 0,\n    });\n\n  constructor(private _app: Application, private _docWorker: DocWorker,\n    private _docWorkerMap: IDocWorkerMap, private _docManager: DocManager,\n    private _dbManager: HomeDBManager, private _attachmentStoreProvider: IAttachmentStoreProvider,\n    private _grist: GristServer) {}\n\n  /**\n   * Adds endpoints for the doc api.\n   *\n   * Note that it expects bodyParser, userId, and jsonErrorHandler middleware to be set up outside\n   * to apply to these routes.\n   */\n  public addEndpoints() {\n    this._app.use((req, res, next) => {\n      if (req.url.startsWith(\"/api/s/\")) {\n        req.url = req.url.replace(\"/api/s/\", `/api/docs/${SHARE_KEY_PREFIX}`);\n      }\n      next();\n    });\n\n    // Some endpoints require the admin\n    const requireInstallAdmin = this._grist.getInstallAdmin().getMiddlewareRequireAdmin();\n\n    // check document exists (not soft deleted) and user can view it\n    const canView = expressWrap(this._assertAccess.bind(this, \"viewers\", false));\n    // check document exists (not soft deleted) and user can edit it\n    const canEdit = expressWrap(this._assertAccess.bind(this, \"editors\", false));\n    const checkAnonymousCreation = expressWrap(this._checkAnonymousCreation.bind(this));\n    const isOwner = expressWrap(this._assertAccess.bind(this, \"owners\", false));\n    // check user can edit document, with soft-deleted and disabled documents being acceptable\n    const canEditMaybeRemovedOrDisabled = expressWrap(this._assertAccess.bind(this, \"editors\", true));\n    // converts google code to access token and adds it to request object\n    const decodeGoogleToken = expressWrap(googleAuthTokenMiddleware.bind(null));\n\n    // Middleware to limit number of outstanding requests per document.  Will also\n    // handle errors like expressWrap would.\n    const throttled = this._apiThrottle.bind(this);\n\n    const withDoc = (callback: WithDocHandler) => throttled(this._requireActiveDoc(callback));\n    // Apply user actions to a document.\n    this._app.post(\"/api/docs/:docId/apply\", canEdit, withDoc(async (activeDoc, req, res) => {\n      const parseStrings = !isAffirmative(req.query.noparse);\n      res.json(await activeDoc.applyUserActions(docSessionFromRequest(req), req.body, { parseStrings }));\n    }));\n\n    async function readTable(\n      req: RequestWithLogin,\n      activeDoc: ActiveDoc,\n      tableId: string,\n      filters: QueryFilters,\n      params: QueryParameters & { immediate?: boolean },\n    ) {\n      // Option to skip waiting for document initialization.\n      const immediate = isAffirmative(params.immediate);\n      if (!Object.keys(filters).every(col => Array.isArray(filters[col]))) {\n        throw new ApiError(\"Invalid query: filter values must be arrays\", 400);\n      }\n      const session = docSessionFromRequest(req);\n      const { tableData } = await handleSandboxError(tableId, [], activeDoc.fetchQuery(\n        session, { tableId, filters }, !immediate));\n      // For metaTables we don't need to specify columns, search will infer it from the sort expression.\n      const isMetaTable = tableId.startsWith(\"_grist\");\n      const columns = isMetaTable ? null :\n        await handleSandboxError(\"\", [], activeDoc.getTableCols(session, tableId, true));\n      // Apply sort/limit parameters, if set.  TODO: move sorting/limiting into data engine\n      // and sql.\n      return applyQueryParameters(fromTableDataAction(tableData), params, columns);\n    }\n\n    async function getTableData(activeDoc: ActiveDoc, req: RequestWithLogin, optTableId?: string) {\n      const filters = req.query.filter ? JSON.parse(String(req.query.filter)) : {};\n      // Option to skip waiting for document initialization.\n      const immediate = isAffirmative(req.query.immediate);\n      const tableId = await getRealTableId(optTableId || req.params.tableId, { activeDoc, req });\n      const params = getQueryParameters(req);\n      return await readTable(req, activeDoc, tableId, filters, { ...params, immediate });\n    }\n\n    function asRecords(\n      columnData: TableColValues,\n      opts?: {\n        optTableId?: string;\n        includeHidden?: boolean;\n        includeId?: boolean;\n        cellFormat?: CellFormatType;\n      },\n    ): TableRecordValue[] {\n      const fieldNames = Object.keys(columnData).filter((k) => {\n        if (!opts?.includeId && k === \"id\") {\n          return false;\n        }\n        if (\n          !opts?.includeHidden &&\n          (k === \"manualSort\" || k.startsWith(\"gristHelper_\"))\n        ) {\n          return false;\n        }\n        return true;\n      });\n      const keepExceptions = (opts?.cellFormat === \"typed\");\n      return columnData.id.map((id, index) => {\n        const result: TableRecordValue = { id, fields: {} };\n        for (const key of fieldNames) {\n          let value = columnData[key][index];\n          if (!keepExceptions && isRaisedException(value)) {\n            _.set(result, [\"errors\", key], (value as string[])[1]);\n            value = null;\n          }\n          result.fields[key] = value;\n        }\n        return result;\n      });\n    }\n\n    async function getTableRecords(\n      activeDoc: ActiveDoc, req: RequestWithLogin,\n      opts?: { optTableId?: string; includeHidden?: boolean, cellFormat?: CellFormatType },\n    ): Promise<TableRecordValue[]> {\n      const columnData = await getTableData(activeDoc, req, opts?.optTableId);\n      return asRecords(columnData, opts);\n    }\n\n    // Get the specified table in column-oriented format\n    this._app.get(\"/api/docs/:docId/tables/:tableId/data\", canView,\n      withDoc(async (activeDoc, req, res) => {\n        res.json(await getTableData(activeDoc, req));\n      }),\n    );\n\n    // Get the specified table in record-oriented format\n    this._app.get(\"/api/docs/:docId/tables/:tableId/records\", canView,\n      withDoc(async (activeDoc, req, res) => {\n        const cellFormat = getCellFormatParameter(req);\n        const records = await getTableRecords(activeDoc, req,\n          { includeHidden: isAffirmative(req.query.hidden), cellFormat },\n        );\n        res.json({ records });\n      }),\n    );\n\n    // Get the columns of the specified table in recordish format\n    this._app.get(\"/api/docs/:docId/tables/:tableId/columns\", canView,\n      withDoc(async (activeDoc, req, res) => {\n        const tableId = await getRealTableId(req.params.tableId, { activeDoc, req });\n        const includeHidden = isAffirmative(req.query.hidden);\n        const columns = await handleSandboxError(\"\", [],\n          activeDoc.getTableCols(docSessionFromRequest(req), tableId, includeHidden));\n        res.json({ columns });\n      }),\n    );\n\n    // Get the tables of the specified document in recordish format\n    this._app.get(\"/api/docs/:docId/tables\", canView,\n      withDoc(async (activeDoc, req, res) => {\n        const expand = optStringParam(req.query.expand, \"expand\")?.split(\",\") ?? [];\n        const expandOptions = ExpandTableOption.checkAll(expand);\n        const tables = await handleSandboxError(\"\", [],\n          activeDoc.getTables(docSessionFromRequest(req), expandOptions));\n        res.json({ tables });\n      }),\n    );\n\n    // The upload should be a multipart post with an 'upload' field containing one or more files.\n    // Returns the list of rowIds for the rows created in the _grist_Attachments table.\n    this._app.post(\"/api/docs/:docId/attachments\", canEdit, withDoc(async (activeDoc, req, res) => {\n      const uploadResult = await handleUpload(req, res);\n      res.json(await activeDoc.addAttachments(docSessionFromRequest(req), uploadResult.uploadId));\n    }));\n\n    // Select the fields from an attachment record that we want to return to the user,\n    // and convert the timeUploaded from a number to an ISO string.\n    function cleanAttachmentRecord(record: MetaRowRecord<\"_grist_Attachments\">) {\n      const { fileName, fileSize, timeUploaded: time } = record;\n      const timeUploaded = (typeof time === \"number\") ? new Date(time).toISOString() : undefined;\n      return { fileName, fileSize, timeUploaded };\n    }\n\n    // Returns cleaned metadata for all attachments in /records format.\n    this._app.get(\"/api/docs/:docId/attachments\", canView, withDoc(async (activeDoc, req, res) => {\n      const rawRecords = await getTableRecords(activeDoc, req, { optTableId: \"_grist_Attachments\" });\n      const records = rawRecords.map(r => ({\n        id: r.id,\n        fields: cleanAttachmentRecord(r.fields as MetaRowRecord<\"_grist_Attachments\">),\n      }));\n      res.json({ records });\n    }));\n\n    // Starts transferring all attachments to the named store, if it exists.\n    this._app.post(\"/api/docs/:docId/attachments/transferAll\", isOwner, withDoc(async (activeDoc, req, res) => {\n      await activeDoc.startTransferringAllAttachmentsToDefaultStore();\n      // Respond with the current status to allow for immediate UI updates.\n      res.json(await activeDoc.attachmentTransferStatus());\n    }));\n\n    // Returns the status of any current / pending attachment transfers\n    this._app.get(\"/api/docs/:docId/attachments/transferStatus\", canView, withDoc(async (activeDoc, req, res) => {\n      res.json(await activeDoc.attachmentTransferStatus());\n    }));\n\n    this._app.get(\"/api/docs/:docId/attachments/store\", canView,\n      withDoc(async (activeDoc, req, res) => {\n        const storeId = await activeDoc.getAttachmentStore();\n        res.json({\n          type: storeId ? \"external\" : \"internal\",\n        });\n      }),\n    );\n\n    this._app.post(\"/api/docs/:docId/attachments/store\", isOwner, validate(SetAttachmentStorePost),\n      withDoc(async (activeDoc, req, res) => {\n        const body = req.body as Types.SetAttachmentStorePost;\n        if (body.type === \"internal\") {\n          await activeDoc.setAttachmentStoreFromLabel(docSessionFromRequest(req), undefined);\n        }\n\n        if (body.type === \"external\") {\n          const storeLabel = getConfiguredStandardAttachmentStore();\n          if (storeLabel === undefined) {\n            throw new ApiError(\"server is not configured with an external store\", 400);\n          }\n          // This store might not exist - that's acceptable, and should be handled elsewhere.\n          await activeDoc.setAttachmentStoreFromLabel(docSessionFromRequest(req), storeLabel);\n        }\n\n        res.json({\n          store: await activeDoc.getAttachmentStore(),\n        });\n      }),\n    );\n\n    this._app.get(\"/api/docs/:docId/attachments/stores\", isOwner,\n      withDoc(async (activeDoc, req, res) => {\n        const configs = await getConfiguredAttachmentStoreConfigs();\n        const labels: Types.AttachmentStoreDesc[] = configs.map(c => ({ label: c.label }));\n        res.json({ stores: labels });\n      }),\n    );\n\n    // Responds with an archive of all attachment contents, with suitable Content-Type and Content-Disposition.\n    this._app.get(\"/api/docs/:docId/attachments/archive\", canView, withDoc(async (activeDoc, req, res) => {\n      const archiveFormatStr = optStringParam(req.query.format, \"format\", {\n        allowed: CreatableArchiveFormats.values,\n        allowEmpty: true,\n      });\n\n      const archiveFormat = CreatableArchiveFormats.parse(archiveFormatStr) || \"zip\";\n      const archive = await activeDoc.getAttachmentsArchive(docSessionFromRequest(req), archiveFormat);\n      const docName = await this._getDownloadFilename(req, \"Attachments\", activeDoc.doc);\n      res.status(200)\n        .type(archive.mimeType)\n        // Construct a content-disposition header of the form 'attachment; filename=\"NAME\"'\n        .set(\"Content-Disposition\",\n          contentDisposition(`${docName}.${archive.fileExtension}`, { type: \"attachment\" }))\n        // Avoid storing because this could be huge.\n        .set(\"Cache-Control\", \"no-store\");\n\n      try {\n        await archive.packInto(res, { endDestStream: false });\n      } catch (err) {\n        // This only behaves sensibly if the 'download' attribute is on the <a> tag.\n        // Otherwise you get a poor user experience, such as:\n        // - No data written to the stream: open a new tab with a 500 error.\n        // - Destroy the stream: open a new tab with a connection reset error.\n        // - Return some data without res.destroy(): download shows as successful, despite being corrupt.\n        // Sending headers then resetting the connection shows as 'Download failed', regardless of the\n        // 'download' attribute being set.\n        res.destroy(err);\n        const meta = {\n          docId: activeDoc.doc?.id,\n          archiveFormat,\n          altSessionId: req.altSessionId,\n        };\n        if (err?.code === \"ERR_STREAM_PREMATURE_CLOSE\") {\n          log.rawWarn(\"Client closed archive download stream before completion\", meta);\n        } else {\n          log.rawError(`Error while packing attachment archive: ${err.stack ?? err.message}`, meta);\n        }\n      }\n      res.end();\n    }));\n\n    this._app.post(\"/api/docs/:docId/attachments/archive\", isOwner, withDoc(async (activeDoc, req, res) => {\n      let archivePromise: Promise<ArchiveUploadResult> | undefined;\n\n      await parseMultipartFormRequest(\n        req,\n        async (file) => {\n          if (archivePromise || !file.name.endsWith(\".tar\") || file.contentType !== \"application/x-tar\") { return; }\n          archivePromise = activeDoc.addMissingFilesFromArchive(docSessionFromRequest(req), file.stream);\n          await archivePromise;\n        },\n      );\n\n      if (!archivePromise) {\n        throw new ApiError(\"No .tar file found in request\", 400);\n      }\n\n      // parseMultipartFormRequest ignores handler errors.\n      // Await this here to ensure errors are thrown.\n      try {\n        res.json(await archivePromise);\n      } catch (err) {\n        if (err instanceof Error && err.message === \"Unexpected end of data\") {\n          throw new Error(\"File is not a valid .tar\");\n        }\n        throw err;\n      }\n    }));\n\n    // Returns cleaned metadata for a given attachment ID (i.e. a rowId in _grist_Attachments table).\n    this._app.get(\"/api/docs/:docId/attachments/:attId\", canView, withDoc(async (activeDoc, req, res) => {\n      const attId = integerParam(req.params.attId, \"attId\");\n      const options = getExtraAttachmentOptions(req);\n      const attRecord = await activeDoc.getAttachmentMetadata(docSessionFromRequest(req), attId, options);\n      res.json(cleanAttachmentRecord(attRecord));\n    }));\n\n    // Responds with attachment contents, with suitable Content-Type and Content-Disposition.\n    this._app.get(\"/api/docs/:docId/attachments/:attId/download\", canView, withDoc(async (activeDoc, req, res) => {\n      const attId = integerParam(req.params.attId, \"attId\");\n      const options = getExtraAttachmentOptions(req);\n      // getAttachmentData below will throw if user does not have access to attachment.\n      const attRecord = activeDoc.getAttachmentMetadataWithoutAccessControl(attId);\n      const fileIdent = attRecord.fileIdent as string;\n      const ext = path.extname(fileIdent);\n      const origName = attRecord.fileName as string;\n      const fileName = ext ? path.basename(origName, path.extname(origName)) + ext : origName;\n      const fileData = await activeDoc.getAttachmentData(docSessionFromRequest(req), attRecord, options);\n      res.status(200)\n        .type(ext)\n        // Construct a content-disposition header of the form 'attachment; filename=\"NAME\"'\n        .set(\"Content-Disposition\", contentDisposition(fileName, { type: \"attachment\" }))\n        .set(\"Cache-Control\", \"private, max-age=3600\")\n        .send(fileData);\n    }));\n\n    // Mostly for testing\n    this._app.post(\"/api/docs/:docId/attachments/updateUsed\", canEdit, withDoc(async (activeDoc, req, res) => {\n      await activeDoc.updateUsedAttachmentsIfNeeded();\n      res.json(null);\n    }));\n    this._app.post(\"/api/docs/:docId/attachments/removeUnused\", isOwner, withDoc(async (activeDoc, req, res) => {\n      const expiredOnly = isAffirmative(req.query.expiredonly);\n      const verifyFiles = isAffirmative(req.query.verifyfiles);\n      await activeDoc.removeUnusedAttachments(expiredOnly);\n      if (verifyFiles) {\n        await verifyAttachmentFiles(activeDoc);\n      }\n      res.json(null);\n    }));\n    this._app.post(\"/api/docs/:docId/attachments/verifyFiles\", isOwner, withDoc(async (activeDoc, req, res) => {\n      await verifyAttachmentFiles(activeDoc);\n      res.json(null);\n    }));\n\n    async function verifyAttachmentFiles(activeDoc: ActiveDoc) {\n      assert.deepStrictEqual(\n        await activeDoc.docStorage.all(`SELECT DISTINCT fileIdent AS ident FROM _grist_Attachments ORDER BY ident`),\n        await activeDoc.docStorage.all(`SELECT                       ident FROM _gristsys_Files    ORDER BY ident`),\n      );\n    }\n\n    // Adds records given in a column oriented format,\n    // returns an array of row IDs\n    this._app.post(\"/api/docs/:docId/tables/:tableId/data\", canEdit,\n      withDoc(async (activeDoc, req, res) => {\n        const colValues = req.body as BulkColValues;\n        const count = colValues[Object.keys(colValues)[0]].length;\n        const op = await getTableOperations(req, activeDoc);\n        const ids = await op.addRecords(count, colValues);\n        res.json(ids);\n      }),\n    );\n\n    // Adds records given in a record oriented format,\n    // returns in the same format as GET /records but without the fields object for now\n    // WARNING: The `req.body` object is modified in place.\n    this._app.post(\"/api/docs/:docId/tables/:tableId/records\", canEdit,\n      withDoc(async (activeDoc, req, res) => {\n        let body = req.body;\n        if (isAffirmative(req.query.flat)) {\n          if (!body.records && Array.isArray(body)) {\n            for (const [i, rec] of body.entries()) {\n              if (!rec.fields) {\n                // If ids arrive in a loosely formatted flat payload,\n                // remove them since we cannot honor them. If not loosely\n                // formatted, throw an error later. TODO: would be useful\n                // to have a way to exclude or rename fields via query\n                // parameters.\n                if (rec.id) { delete rec.id; }\n                body[i] = { fields: rec };\n              }\n            }\n            body = { records: body };\n          }\n        }\n        validateCore(RecordsPost, req, body);\n        const ops = await getTableOperations(req, activeDoc);\n        const records = await ops.create(body.records);\n        if (req.query.utm_source === \"grist-forms\") {\n          activeDoc.logTelemetryEvent(docSessionFromRequest(req), \"submittedForm\");\n        }\n        res.json({ records });\n      }),\n    );\n\n    // A GET /sql endpoint that takes a query like ?q=select+*+from+Table1\n    // Not very useful, apart from testing - see the POST endpoint for\n    // serious use.\n    // If SQL statements that modify the DB are ever supported, they should\n    // not be permitted by this endpoint.\n    this._app.get(\n      \"/api/docs/:docId/sql\", canView,\n      withDoc(async (activeDoc, req, res) => {\n        const sql = stringParam(req.query.q, \"q\");\n        await this._runSql(activeDoc, req, res, { sql });\n      }));\n\n    // A POST /sql endpoint, accepting a body like:\n    // { \"sql\": \"select * from Table1 where name = ?\", \"args\": [\"Paul\"] }\n    // Only SELECT statements are currently supported.\n    this._app.post(\n      \"/api/docs/:docId/sql\", canView, validate(SqlPost),\n      withDoc(async (activeDoc, req, res) => {\n        await this._runSql(activeDoc, req, res, req.body);\n      }));\n\n    // Create columns in a table, given as records of the _grist_Tables_column metatable.\n    this._app.post(\"/api/docs/:docId/tables/:tableId/columns\", canEdit, validate(ColumnsPost),\n      withDoc(async (activeDoc, req, res) => {\n        const body = req.body as Types.ColumnsPost;\n        const tableId = await getRealTableId(req.params.tableId, { activeDoc, req });\n        const actions = body.columns.map(({ fields, id: colId }) =>\n          // AddVisibleColumn adds the column to all widgets of the table.\n          // This isn't necessarily what the user wants, but it seems like a good default.\n          // Maybe there should be a query param to control this?\n          [\"AddVisibleColumn\", tableId, colId, fields || {}],\n        );\n        const { retValues } = await handleSandboxError(tableId, [],\n          activeDoc.applyUserActions(docSessionFromRequest(req), actions),\n        );\n        const columns = retValues.map(({ colId }) => ({ id: colId }));\n        res.json({ columns });\n      }),\n    );\n\n    // Create new tables in a doc. Unlike POST /records or /columns, each 'record' (table) should have a `columns`\n    // property in the same format as POST /columns above, and no `fields` property.\n    this._app.post(\"/api/docs/:docId/tables\", canEdit, validate(TablesPost),\n      withDoc(async (activeDoc, req, res) => {\n        const body = req.body as Types.TablesPost;\n        const actions = body.tables.map(({ columns, id }) => {\n          const colInfos = columns.map(({ fields, id: colId }) => ({ ...fields, id: colId }));\n          return [\"AddTable\", id, colInfos];\n        });\n        const { retValues } = await activeDoc.applyUserActions(docSessionFromRequest(req), actions);\n        const tables = retValues.map(({ table_id }) => ({ id: table_id }));\n        res.json({ tables });\n      }),\n    );\n\n    this._app.post(\"/api/docs/:docId/tables/:tableId/data/delete\", canEdit, withDoc(async (activeDoc, req, res) => {\n      const rowIds = req.body;\n      const op = await getTableOperations(req, activeDoc);\n      await op.destroy(rowIds);\n      res.json(null);\n    }));\n\n    // Download full document\n    // TODO: look at download behavior if ActiveDoc is shutdown during call (cannot\n    // use withDoc wrapper)\n    this._app.get(\"/api/docs/:docId/download\", canView, throttled(async (req, res) => {\n      // Support a dryRun flag to check if user has the right to download the\n      // full document.\n      const dryRun = isAffirmative(req.query.dryrun || req.query.dryRun);\n      const dryRunSuccess = () => res.status(200).json({ dryRun: \"allowed\" });\n\n      const filename = await this._getDownloadFilename(req);\n\n      // We want to be have a way download broken docs that ActiveDoc may not be able\n      // to load.  So, if the user owns the document, we unconditionally let them\n      // download.\n      if (await this._isOwner(req, { acceptTrunkForSnapshot: true })) {\n        if (dryRun) { dryRunSuccess(); return; }\n        try {\n          // We carefully avoid creating an ActiveDoc for the document being downloaded,\n          // in case it is broken in some way.  It is convenient to be able to download\n          // broken files for diagnosis/recovery.\n          return await this._docWorker.downloadDoc(req, res, this._docManager.storageManager, filename);\n        } catch (e) {\n          if (e.message?.match(/does not exist yet/)) {\n            // The document has never been seen on file system / s3.  It may be new, so\n            // we try again after having created an ActiveDoc for the document.\n            await this._getActiveDoc(req);\n            return this._docWorker.downloadDoc(req, res, this._docManager.storageManager, filename);\n          } else {\n            throw e;\n          }\n        }\n      } else {\n        // If the user is not an owner, we load the document as an ActiveDoc, and then\n        // check if the user has download permissions.\n        const activeDoc = await this._getActiveDoc(req);\n        if (!await activeDoc.canDownload(docSessionFromRequest(req))) {\n          throw new ApiError(\"not authorized to download this document\", 403);\n        }\n        if (dryRun) { dryRunSuccess(); return; }\n        return this._docWorker.downloadDoc(req, res, this._docManager.storageManager, filename);\n      }\n    }));\n\n    // Fork the specified document.\n    this._app.post(\"/api/docs/:docId/fork\", canView, withDoc(async (activeDoc, req, res) => {\n      const result = await activeDoc.fork(docSessionFromRequest(req));\n      res.json(result);\n    }));\n\n    // Initiate a fork.  Used internally to implement ActiveDoc.fork.  Only usable via a Permit.\n    this._app.post(\"/api/docs/:docId/create-fork\", canEdit, throttled(async (req, res) => {\n      const docId = stringParam(req.params.docId, \"docId\");\n      const srcDocId = stringParam(req.body.srcDocId, \"srcDocId\");\n      if (srcDocId !== req.specialPermit?.otherDocId) { throw new Error(\"access denied\"); }\n      const fname = await this._docManager.storageManager.prepareFork(srcDocId, docId);\n      await filterDocumentInPlace(docSessionFromRequest(req), fname, {\n        removeData: false,\n        removeHistory: false,\n        removeFullCopiesSpecialRight: true,\n        markAction: true,\n      });\n      res.json({ srcDocId, docId });\n    }));\n\n    // Update records given in column format\n    // The records to update are identified by their id column.\n    this._app.patch(\"/api/docs/:docId/tables/:tableId/data\", canEdit,\n      withDoc(async (activeDoc, req, res) => {\n        const columnValues = req.body;\n        const rowIds = columnValues.id;\n        // sandbox expects no id column\n        delete columnValues.id;\n        const ops = await getTableOperations(req, activeDoc);\n        await ops.updateRecords(columnValues, rowIds);\n        res.json(null);\n      }),\n    );\n\n    // Update records given in records format\n    this._app.patch(\"/api/docs/:docId/tables/:tableId/records\", canEdit, validate(RecordsPatch),\n      withDoc(async (activeDoc, req, res) => {\n        const body = req.body as Types.RecordsPatch;\n        const ops = await getTableOperations(req, activeDoc);\n        await ops.update(body.records);\n        res.json(null);\n      }),\n    );\n\n    // Delete records\n    this._app.post(\"/api/docs/:docId/tables/:tableId/records/delete\", canEdit,\n      withDoc(async (activeDoc, req, res) => {\n        const rowIds = req.body;\n        const op = await getTableOperations(req, activeDoc);\n        await op.destroy(rowIds);\n        res.json(null);\n      }),\n    );\n\n    // Update columns given in records format\n    this._app.patch(\"/api/docs/:docId/tables/:tableId/columns\", canEdit, validate(ColumnsPatch),\n      withDoc(async (activeDoc, req, res) => {\n        const tablesTable = activeDoc.docData!.getMetaTable(\"_grist_Tables\");\n        const columnsTable = activeDoc.docData!.getMetaTable(\"_grist_Tables_column\");\n        const tableId = await getRealTableId(req.params.tableId, { activeDoc, req });\n        const tableRef = tablesTable.findMatchingRowId({ tableId });\n        if (!tableRef) {\n          throw new ApiError(`Table not found \"${tableId}\"`, 404);\n        }\n        const body = req.body as Types.ColumnsPatch;\n        const columns: Types.Record[] = body.columns.map((col) => {\n          const id = columnsTable.findMatchingRowId({ parentId: tableRef, colId: col.id });\n          if (!id) {\n            throw new ApiError(`Column not found \"${col.id}\"`, 404);\n          }\n          return { ...col, id };\n        });\n        const ops = await getTableOperations(req, activeDoc, \"_grist_Tables_column\");\n        await ops.update(columns);\n        res.json(null);\n      }),\n    );\n\n    // Update tables given in records format\n    this._app.patch(\"/api/docs/:docId/tables\", canEdit, validate(TablesPatch),\n      withDoc(async (activeDoc, req, res) => {\n        const tablesTable = activeDoc.docData!.getMetaTable(\"_grist_Tables\");\n        const body = req.body as Types.TablesPatch;\n        const tables: Types.Record[] = body.tables.map((table) => {\n          const id = tablesTable.findMatchingRowId({ tableId: table.id });\n          if (!id) {\n            throw new ApiError(`Table not found \"${table.id}\"`, 404);\n          }\n          return { ...table, id };\n        });\n        const ops = await getTableOperations(req, activeDoc, \"_grist_Tables\");\n        await ops.update(tables);\n        res.json(null);\n      }),\n    );\n\n    // Add or update records given in records format\n    this._app.put(\"/api/docs/:docId/tables/:tableId/records\", canEdit, validate(RecordsPut),\n      withDoc(async (activeDoc, req, res) => {\n        const ops = await getTableOperations(req, activeDoc);\n        const body = req.body as Types.RecordsPut;\n        const options = {\n          add: !isAffirmative(req.query.noadd),\n          update: !isAffirmative(req.query.noupdate),\n          onMany: stringParam(req.query.onmany || \"first\", \"onmany\", {\n            allowed: [\"first\", \"none\", \"all\"],\n          }) as \"first\" | \"none\" | \"all\" | undefined,\n          allowEmptyRequire: isAffirmative(req.query.allow_empty_require),\n        };\n        await ops.upsert(body.records, options);\n        res.json(null);\n      }),\n    );\n\n    // Add or update records given in records format\n    this._app.put(\"/api/docs/:docId/tables/:tableId/columns\", canEdit, validate(ColumnsPut),\n      withDoc(async (activeDoc, req, res) => {\n        const tablesTable = activeDoc.docData!.getMetaTable(\"_grist_Tables\");\n        const columnsTable = activeDoc.docData!.getMetaTable(\"_grist_Tables_column\");\n        const tableId = await getRealTableId(req.params.tableId, { activeDoc, req });\n        const tableRef = tablesTable.findMatchingRowId({ tableId });\n        if (!tableRef) {\n          throw new ApiError(`Table not found \"${tableId}\"`, 404);\n        }\n        const body = req.body as Types.ColumnsPut;\n\n        const addActions: UserAction[] = [];\n        const updateActions: UserAction[] = [];\n        const updatedColumnsIds = new Set();\n\n        for (const col of body.columns) {\n          const id = columnsTable.findMatchingRowId({ parentId: tableRef, colId: col.id });\n          if (id) {\n            updateActions.push([\"UpdateRecord\", \"_grist_Tables_column\", id, col.fields]);\n            updatedColumnsIds.add(id);\n          } else {\n            addActions.push([\"AddVisibleColumn\", tableId, col.id, col.fields]);\n          }\n        }\n\n        const getRemoveAction = async () => {\n          const columns = await handleSandboxError(\"\", [],\n            activeDoc.getTableCols(docSessionFromRequest(req), tableId));\n          const columnsToRemove = columns\n            .map(col => col.fields.colRef)\n            .filter(colRef => !updatedColumnsIds.has(colRef));\n\n          return [\"BulkRemoveRecord\", \"_grist_Tables_column\", columnsToRemove];\n        };\n\n        const actions = [\n          ...(!isAffirmative(req.query.noupdate) ? updateActions : []),\n          ...(!isAffirmative(req.query.noadd) ? addActions : []),\n          ...(isAffirmative(req.query.replaceall) ? [await getRemoveAction()] : []),\n        ];\n        await handleSandboxError(tableId, [],\n          activeDoc.applyUserActions(docSessionFromRequest(req), actions),\n        );\n        res.json(null);\n      }),\n    );\n\n    this._app.delete(\"/api/docs/:docId/tables/:tableId/columns/:colId\", canEdit,\n      withDoc(async (activeDoc, req, res) => {\n        const { colId } = req.params;\n        const tableId = await getRealTableId(req.params.tableId, { activeDoc, req });\n        const actions = [[\"RemoveColumn\", tableId, colId]];\n        await handleSandboxError(tableId, [colId],\n          activeDoc.applyUserActions(docSessionFromRequest(req), actions),\n        );\n        res.json(null);\n      }),\n    );\n\n    // Reload a document forcibly (in fact this closes the doc, it will be automatically\n    // reopened on use).\n    this._app.post(\"/api/docs/:docId/force-reload\", canEdit, async (req, res) => {\n      const mreq = req as RequestWithLogin;\n      const activeDoc = await this._getActiveDoc(mreq);\n      const document = activeDoc.doc || { id: activeDoc.docName };\n      await activeDoc.reloadDoc();\n      this._logReloadDocumentEvents(mreq, document);\n      res.json(null);\n    });\n\n    this._app.post(\"/api/docs/:docId/recover\", canEdit, throttled(async (req, res) => {\n      const recoveryModeRaw = req.body.recoveryMode;\n      const recoveryMode = (typeof recoveryModeRaw === \"boolean\") ? recoveryModeRaw : undefined;\n      if (!await this._isOwner(req)) { throw new Error(\"Only owners can control recovery mode\"); }\n      this._docManager.setRecovery(getDocId(req), recoveryMode ?? true);\n      const activeDoc = await this._docManager.fetchDoc(docSessionFromRequest(req), getDocId(req), recoveryMode);\n      res.json({\n        recoveryMode: activeDoc.recoveryMode,\n      });\n    }));\n\n    // DELETE /api/docs/:docId\n    // Delete the specified doc.\n    this._app.delete(\"/api/docs/:docId\", canEditMaybeRemovedOrDisabled, throttled(async (req, res) => {\n      const { data } = await this._removeDoc(req, res, true);\n      if (data) { this._logDeleteDocumentEvents(req, data); }\n    }));\n\n    // POST /api/docs/:docId/remove\n    // Soft-delete the specified doc.  If query parameter \"permanent\" is set,\n    // delete permanently.\n    this._app.post(\"/api/docs/:docId/remove\", canEditMaybeRemovedOrDisabled, throttled(async (req, res) => {\n      const permanent = isParameterOn(req.query.permanent);\n      const { data } = await this._removeDoc(req, res, permanent);\n      if (data) {\n        if (permanent) {\n          this._logDeleteDocumentEvents(req, data);\n        } else {\n          this._logRemoveDocumentEvents(req, data);\n        }\n      }\n    }));\n\n    // POST /api/docs/:docId/disable\n    // Disables doc (removes all non-admin access except listing or deleting the doc)\n    this._app.post(\"/api/docs/:docId/disable\", requireInstallAdmin, expressWrap(async (req, res) => {\n      await this._toggleDisabledStatus(req, res, \"disable\");\n    }));\n\n    // POST /api/docs/:did/enable\n    // Enables the specified doc if it was previously disabled\n    this._app.post(\"/api/docs/:did/enable\", requireInstallAdmin, expressWrap(async (req, res) => {\n      await this._toggleDisabledStatus(req, res, \"enable\");\n    }));\n\n    this._app.get(\"/api/docs/:docId/snapshots\", canView, withDoc(async (activeDoc, req, res) => {\n      const docSession = docSessionFromRequest(req);\n      const { snapshots } = await activeDoc.getSnapshots(docSession, isAffirmative(req.query.raw));\n      res.json({ snapshots });\n    }));\n\n    this._app.get(\"/api/docs/:docId/usersForViewAs\", isOwner, withDoc(async (activeDoc, req, res) => {\n      const docSession = docSessionFromRequest(req);\n      res.json(await activeDoc.getUsersForViewAs(docSession));\n    }));\n\n    this._app.post(\"/api/docs/:docId/snapshots/remove\", isOwner, withDoc(async (activeDoc, req, res) => {\n      const docSession = docSessionFromRequest(req);\n      const snapshotIds = req.body.snapshotIds as string[];\n      if (snapshotIds) {\n        await activeDoc.removeSnapshots(docSession, snapshotIds);\n        res.json({ snapshotIds });\n        return;\n      }\n      if (req.body.select === \"unlisted\") {\n        // Remove any snapshots not listed in inventory.  Ideally, there should be no\n        // snapshots, and this undocumented feature is just for fixing up problems.\n        const full = (await activeDoc.getSnapshots(docSession, true)).snapshots.map(s => s.snapshotId);\n        const listed = new Set((await activeDoc.getSnapshots(docSession)).snapshots.map(s => s.snapshotId));\n        const unlisted = full.filter(snapshotId => !listed.has(snapshotId));\n        await activeDoc.removeSnapshots(docSession, unlisted);\n        res.json({ snapshotIds: unlisted });\n        return;\n      }\n      if (req.body.select === \"past\") {\n        // Remove all but the latest snapshot.  Useful for sanitizing history if something\n        // bad snuck into previous snapshots and they are not valuable to preserve.\n        const past = (await activeDoc.getSnapshots(docSession, true)).snapshots.map(s => s.snapshotId);\n        past.shift();  // remove current version.\n        await activeDoc.removeSnapshots(docSession, past);\n        res.json({ snapshotIds: past });\n        return;\n      }\n      throw new Error(\"please specify snapshotIds to remove\");\n    }));\n\n    this._app.post(\"/api/docs/:docId/flush\", canEdit, throttled(async (req, res) => {\n      const activeDocPromise = this._getActiveDocIfAvailable(req);\n      if (!activeDocPromise) {\n        // Only need to flush if doc is actually open.\n        res.json(false);\n        return;\n      }\n      const activeDoc = await activeDocPromise;\n      await activeDoc.flushDoc();\n      res.json(true);\n    }));\n\n    // Administrative endpoint, that checks if a document is in the expected group,\n    // and frees it for reassignment if not.  Has no effect if document is in the\n    // expected group.  Does not require specific rights.  Returns true if the document\n    // is freed up for reassignment, otherwise false.\n    //\n    // Optionally accepts a `group` query param for updating the document's group prior\n    // to (possible) reassignment. A blank string unsets the current group, if any.\n    // (Requires a special permit.)\n    this._app.post(\"/api/docs/:docId/assign\", canEdit, throttled(async (req, res) => {\n      const docId = getDocId(req);\n      const group = optStringParam(req.query.group, \"group\");\n      if (group !== undefined && req.specialPermit?.action === \"assign-doc\") {\n        if (group.trim() === \"\") {\n          await this._docWorkerMap.removeDocGroup(docId);\n        } else {\n          await this._docWorkerMap.updateDocGroup(docId, group);\n        }\n      }\n      const status = await this._docWorkerMap.getDocWorker(docId);\n      if (!status) { res.json(false); return; }\n      const workerGroup = await this._docWorkerMap.getWorkerGroup(status.docWorker.id);\n      const docGroup = await this._docWorkerMap.getDocGroup(docId);\n      if (docGroup === workerGroup) { res.json(false); return; }\n      const activeDoc = await this._getActiveDoc(req);\n      await activeDoc.flushDoc();\n      // flushDoc terminates once there's no pending operation on the document.\n      // There could still be async operations in progress.  We mute their effect,\n      // as if they never happened.\n      activeDoc.docClients.interruptAllClients();\n      activeDoc.setMuted();\n      await activeDoc.shutdown();\n      await this._docWorkerMap.releaseAssignment(status.docWorker.id, docId);\n      res.json(true);\n    }));\n\n    // This endpoint cannot use withDoc since it is expected behavior for the ActiveDoc it\n    // starts with to become muted.\n    this._app.post(\"/api/docs/:docId/replace\", canEdit, throttled(async (req, res) => {\n      const docSession = docSessionFromRequest(req);\n      const activeDoc = await this._getActiveDoc(req);\n      const options: DocReplacementOptions = {};\n      if (req.body.sourceDocId) {\n        options.sourceDocId = await this._confirmDocIdForRead(req, String(req.body.sourceDocId));\n        // Make sure that if we wanted to download the full source, we would be allowed.\n        const homeUrl = this._grist.getHomeInternalUrl(`/api/docs/${options.sourceDocId}/download?dryrun=1`);\n        const result = await fetch(homeUrl, {\n          method: \"GET\",\n          headers: {\n            ...getTransitiveHeaders(req, { includeOrigin: false }),\n            \"Content-Type\": \"application/json\",\n          },\n        });\n        if (result.status !== 200) {\n          const jsonResult = await result.json();\n          throw new ApiError(jsonResult.error, result.status);\n        }\n        // We should make sure the source document has flushed recently.\n        // It may not be served by the same worker, so work through the api.\n        await fetch(this._grist.getHomeInternalUrl(`/api/docs/${options.sourceDocId}/flush`), {\n          method: \"POST\",\n          headers: {\n            ...getTransitiveHeaders(req, { includeOrigin: false }),\n            \"Content-Type\": \"application/json\",\n          },\n        });\n        if (req.body.resetTutorialMetadata) {\n          const scope = getDocScope(req);\n          const tutorialTrunkId = options.sourceDocId;\n          await this._dbManager.connection.transaction(async (manager) => {\n            // Fetch the tutorial trunk so we can replace the tutorial fork's name.\n            const tutorialTrunk = await this._dbManager.getDoc({ ...scope, urlId: tutorialTrunkId }, manager);\n            await this._dbManager.updateDocument(\n              scope,\n              {\n                name: tutorialTrunk.name,\n                options: {\n                  tutorial: {\n                    lastSlideIndex: 0,\n                    percentComplete: 0,\n                  },\n                },\n              },\n              manager,\n            );\n          });\n          const { forkId } = parseUrlId(scope.urlId);\n          activeDoc.logTelemetryEvent(docSession, \"tutorialRestarted\", {\n            full: {\n              tutorialForkIdDigest: forkId,\n              tutorialTrunkIdDigest: tutorialTrunkId,\n            },\n          });\n        }\n      }\n      if (req.body.snapshotId) {\n        options.snapshotId = String(req.body.snapshotId);\n      }\n      const document = activeDoc.doc || { id: activeDoc.docName };\n      await activeDoc.replace(docSession, options);\n      this._logReplaceDocumentEvents(req, document, options);\n      res.json(null);\n    }));\n\n    this._app.get(\"/api/docs/:docId/states\", canView, withDoc(async (activeDoc, req, res) => {\n      const docSession = docSessionFromRequest(req);\n      res.json(await this._getStates(docSession, activeDoc));\n    }));\n\n    this._app.post(\"/api/docs/:docId/states/remove\", isOwner, withDoc(async (activeDoc, req, res) => {\n      const docSession = docSessionFromRequest(req);\n      const keep = integerParam(req.body.keep, \"keep\");\n      await activeDoc.deleteActions(docSession, keep);\n      this._logTruncateDocumentHistoryEvents(activeDoc, req, { keep });\n      res.json(null);\n    }));\n\n    this._app.get(\"/api/docs/:docId/compare/:docId2\", canView, withDoc(async (activeDoc, req, res) => {\n      const docSession = docSessionFromRequest(req);\n      if (!await activeDoc.canCopyEverything(docSession)) {\n        throw new ApiError(\"insufficient access\", 403);\n      }\n      const showDetails = isAffirmative(req.query.detail);\n      const maxRows = optIntegerParam(req.query.maxRows, \"maxRows\", {\n        nullable: true,\n        isValid: n => n > 0,\n      });\n      const docId2 = req.params.docId2;\n      const comp = await this._compareDoc(req, activeDoc, {\n        showDetails,\n        docId2,\n        maxRows,\n      });\n      res.json(comp);\n    }));\n\n    // Give details about what changed between two versions of a document.\n    this._app.get(\"/api/docs/:docId/compare\", canView, withDoc(async (activeDoc, req, res) => {\n      const docSession = docSessionFromRequest(req);\n      if (!await activeDoc.canCopyEverything(docSession)) {\n        throw new ApiError(\"insufficient access\", 403);\n      }\n      // This could be a relatively slow operation if actions are large.\n      const leftHash = stringParam(req.query.left || \"HEAD\", \"left\");\n      const rightHash = stringParam(req.query.right || \"HEAD\", \"right\");\n      const maxRows = optIntegerParam(req.query.maxRows, \"maxRows\", {\n        nullable: true,\n        isValid: n => n > 0,\n      });\n      const { states } = await this._getStates(docSession, activeDoc);\n      res.json(\n        await getChanges(docSession, activeDoc, {\n          states,\n          leftHash,\n          rightHash,\n          maxRows,\n        }),\n      );\n    }));\n\n    /**\n     * Take the content of the document, relative to a trunk, and make\n     * it a proposal to the trunk document.\n     */\n    this._app.post(\"/api/docs/:docId/propose\", canEdit, withDoc(async (activeDoc, req, res) => {\n      const urlId = activeDoc.docName;\n      const parts = parseUrlId(urlId || \"\");\n      const retracted = Boolean(req.body.retracted);\n      if (!parts.forkId) {\n        throw new ApiError(\"Can only propose from a fork\", 400);\n      }\n      const comparisonUrlId = parts.trunkId;\n      const comp = await this._compareDoc(req, activeDoc, {\n        showDetails: true,\n        docId2: comparisonUrlId,\n        maxRows: null,\n      });\n      const proposal = await this._dbManager.setProposal({\n        srcDocId: parts.forkId,\n        destDocId: parts.trunkId,\n        comparison: comp,\n        retracted,\n      });\n      if (!retracted) {\n        // Notify all subscribers of the proposal. This includes changes to an existing proposal;\n        // we may want to distinguish updates to existing proposals later, but for now it's simpler\n        // just to notify subscribers unconditionally.\n        await this._grist.getDocNotificationManager()?.notifySubscribersOfSuggestion(parts.trunkId, proposal);\n      }\n      res.json(proposal);\n    }));\n\n    /**\n     * List the proposals associated with a document.\n     * if an `outgoing` flag is provided, then proposals\n     * where the document is the source are listed. Otherwise\n     * proposals where the document is the desination are listed.\n     */\n    this._app.get(\"/api/docs/:docId/proposals\", canView, withDoc(async (activeDoc, req, res) => {\n      const docSession = docSessionFromRequest(req);\n      if (!await activeDoc.canCopyEverything(docSession)) {\n        throw new ApiError(\"access denied\", 400);\n      }\n      const parsed = parseUrlId(activeDoc.docName);\n      const docId = parsed.forkId || parsed.trunkId;\n      const isOutgoing = optBooleanParam(req.query.outgoing, \"outgoing\");\n      const options = isOutgoing ? {\n        srcDocId: docId,\n      } : {\n        destDocId: docId,\n      };\n      const result = await this._dbManager.getProposals(options);\n      const owner = await activeDoc.isOwner(docSession);\n      if (!owner) {\n        for (const proposal of result) {\n          const creator = proposal.srcDoc.creator;\n          if (creator.anonymous) {\n            proposal.srcDocId = \"hidden\";\n            proposal.srcDoc.id = \"hidden\";\n          }\n        }\n      }\n      await sendReply(req, res, {\n        data: { proposals: result },\n        status: 200,\n      });\n    }));\n\n    this._app.post(\"/api/docs/:docId/proposals/:proposalId/apply\", canEdit, withDoc(async (activeDoc, req, res) => {\n      const proposalId = integerParam(req.params.proposalId, \"proposalId\");\n      const docSession = docSessionFromRequest(req);\n      const changes = await activeDoc.applyProposal(docSession, proposalId);\n      await sendReply(req, res, { data: { proposalId, changes }, status: 200 });\n    }));\n\n    // Do an import targeted at a specific workspace. Although the URL fits ApiServer, this\n    // endpoint is handled only by DocWorker, so is handled here.\n    // This endpoint either uploads a new file to import, or accepts an existing uploadId.\n    this._app.post(\"/api/workspaces/:wid/import\", expressWrap(async (req, res) => {\n      const mreq = req as RequestWithLogin;\n      const userId = getUserId(req);\n      const wsId = integerParam(req.params.wid, \"wid\");\n\n      let params: { [key: string]: any } = {};\n      if (req.is(\"multipart/form-data\")) {\n        const formResult = await handleOptionalUpload(req, res);\n        params = formResult.parameters ?? {};\n        if (formResult.upload) {\n          params.uploadId = formResult.upload.uploadId;\n        }\n      } else {\n        params = req.body;\n      }\n\n      const uploadId = integerParam(params.uploadId, \"uploadId\");\n\n      const browserSettings = params.browserSettings ?? {\n        timezone: params.timezone,\n        locale: localeFromRequest(req),\n      };\n\n      const result = await this._importDocumentToWorkspace(mreq, {\n        userId,\n        uploadId,\n        workspaceId: wsId,\n        documentName: optStringParam(params.documentName, \"documentName\"),\n        browserSettings,\n      });\n      res.json(result);\n    }));\n\n    this._app.get(\"/api/docs/:docId/download/table-schema\", canView, withDoc(async (activeDoc, req, res) => {\n      const doc = await this._dbManager.getDoc(req);\n      const options = await this._getDownloadOptions(req, doc);\n      const tableSchema = await collectTableSchemaInFrictionlessFormat(activeDoc, req, options);\n      const apiPath = await this._grist.getResourceUrl(doc, \"api\");\n      const query = new URLSearchParams(req.query as { [key: string]: string });\n      const tableSchemaPath = `${apiPath}/download/csv?${query.toString()}`;\n      res.send({\n        format: \"csv\",\n        mediatype: \"text/csv\",\n        encoding: \"utf-8\",\n        path: tableSchemaPath,\n        dialect: {\n          delimiter: \",\",\n          doubleQuote: true,\n        },\n        ...tableSchema,\n      });\n    }));\n\n    this._app.get(\"/api/docs/:docId/download/csv\", canView, withDoc(async (activeDoc, req, res) => {\n      const options = await this._getDownloadOptions(req);\n\n      await downloadDSV(activeDoc, req, res, { ...options, delimiter: \",\" });\n    }));\n\n    this._app.get(\"/api/docs/:docId/download/tsv\", canView, withDoc(async (activeDoc, req, res) => {\n      const options = await this._getDownloadOptions(req);\n\n      await downloadDSV(activeDoc, req, res, { ...options, delimiter: \"\\t\" });\n    }));\n\n    this._app.get(\"/api/docs/:docId/download/dsv\", canView, withDoc(async (activeDoc, req, res) => {\n      const options = await this._getDownloadOptions(req);\n\n      await downloadDSV(activeDoc, req, res, { ...options, delimiter: \"💩\" });\n    }));\n\n    this._app.get(\"/api/docs/:docId/download/xlsx\", canView, withDoc(async (activeDoc, req, res) => {\n      const options: DownloadOptions = (!_.isEmpty(req.query) && !_.isEqual(Object.keys(req.query), [\"title\"])) ?\n        await this._getDownloadOptions(req) :\n        {\n          filename: await this._getDownloadFilename(req),\n          header: \"label\",\n        };\n      await downloadXLSX(activeDoc, req, res, options);\n    }));\n\n    this._app.get(\"/api/docs/:docId/send-to-drive\", canView, decodeGoogleToken, withDoc(exportToDrive));\n\n    /**\n     * Send a request to the assistant to get completions. Increases the\n     * usage of the assistant for the billing account in case of success.\n     */\n    this._app.post(\"/api/docs/:docId/assistant\", canView, withDoc(async (activeDoc, req, res) => {\n      const docSession = docSessionFromRequest(req);\n      const request = req.body;\n      res.json(await activeDoc.getAssistance(docSession, request));\n    }),\n    );\n\n    /**\n     * Create a document.\n     *\n     * When an upload is included, it is imported as the initial state of the document.\n     *\n     * When a source document id is included, its structure and (optionally) data is\n     * included in the new document.\n     *\n     * In all other cases, the document is left empty.\n     *\n     * If a workspace id is included, the document will be saved there instead of\n     * being left \"unsaved\".\n     *\n     * Returns the id of the created document.\n     *\n     * TODO: unify this with the other document creation and import endpoints.\n     */\n    this._app.post(\"/api/docs\", checkAnonymousCreation, expressWrap(async (req, res) => {\n      const mreq = req as RequestWithLogin;\n      const userId = getUserId(req);\n\n      let uploadId: number | undefined;\n      let parameters: { [key: string]: any };\n      if (req.is(\"multipart/form-data\")) {\n        const formResult = await handleOptionalUpload(req, res);\n        if (formResult.upload) {\n          uploadId = formResult.upload.uploadId;\n        }\n        parameters = formResult.parameters || {};\n      } else {\n        parameters = req.body;\n      }\n\n      const sourceDocumentId = optStringParam(parameters.sourceDocumentId, \"sourceDocumentId\");\n      const workspaceId = optIntegerParam(parameters.workspaceId, \"workspaceId\");\n      const browserSettings: BrowserSettings = {};\n      if (parameters.timezone) { browserSettings.timezone = parameters.timezone; }\n      browserSettings.locale = localeFromRequest(req);\n\n      let docId: string;\n      if (sourceDocumentId !== undefined) {\n        docId = await this._copyDocToWorkspace(req, {\n          userId,\n          sourceDocumentId,\n          workspaceId: integerParam(parameters.workspaceId, \"workspaceId\"),\n          documentName: stringParam(parameters.documentName, \"documentName\"),\n          asTemplate: optBooleanParam(parameters.asTemplate, \"asTemplate\"),\n        });\n      } else if (uploadId !== undefined) {\n        const result = await this._importDocumentToWorkspace(mreq, {\n          userId,\n          uploadId,\n          documentName: optStringParam(parameters.documentName, \"documentName\"),\n          workspaceId,\n          browserSettings,\n        });\n        docId = result.id;\n        this._logImportDocumentEvents(mreq, result);\n      } else if (workspaceId !== undefined) {\n        docId = await this._createNewSavedDoc(req, {\n          workspaceId: workspaceId,\n          documentName: optStringParam(parameters.documentName, \"documentName\"),\n        });\n      } else {\n        docId = await this._createNewUnsavedDoc(req, {\n          userId,\n          browserSettings,\n        });\n      }\n\n      return res.status(200).json(docId);\n    }));\n\n    this._app.post(\"/api/docs/:docId/copy\", canView, expressWrap(async (req, res) => {\n      const userId = getUserId(req);\n\n      const parameters: { [key: string]: any } = req.body;\n\n      const docId = await this._copyDocToWorkspace(req, {\n        userId,\n        sourceDocumentId: stringParam(req.params.docId, \"docId\"),\n        workspaceId: integerParam(parameters.workspaceId, \"workspaceId\"),\n        documentName: stringParam(parameters.documentName, \"documentName\"),\n        asTemplate: optBooleanParam(parameters.asTemplate, \"asTemplate\"),\n      });\n\n      return res.status(200).json(docId);\n    }));\n\n    /**\n     * Get the specified view section's form data.\n     */\n    this._app.get(\"/api/docs/:docId/forms/:vsId\", canView,\n      withDoc(async (activeDoc, req, res) => {\n        if (!activeDoc.docData) {\n          throw new ApiError(\"DocData not available\", 500);\n        }\n\n        const sectionId = integerParam(req.params.vsId, \"vsId\");\n        const docSession = docSessionFromRequest(req);\n        const linkId = getDocSessionShare(docSession);\n        if (linkId) {\n          /* If accessed via a share, the share's `linkId` will be present and\n           * we'll need to check that the form is in fact published, and that the\n           * share key is associated with the form, before granting access to the\n           * form. */\n          this._assertIsPublishedForm({\n            docData: activeDoc.docData,\n            linkId,\n            sectionId,\n          });\n        }\n\n        const Views_section = activeDoc.docData.getMetaTable(\"_grist_Views_section\");\n        const section = Views_section.getRecord(sectionId);\n        if (!section) {\n          throw new ApiError(\"Form not found\", 404, { code: \"FormNotFound\" });\n        }\n\n        const Views_section_field = activeDoc.docData.getMetaTable(\"_grist_Views_section_field\");\n        const Tables_column = activeDoc.docData.getMetaTable(\"_grist_Tables_column\");\n        const fields = Views_section_field\n          .filterRecords({ parentId: sectionId })\n          .filter((f) => {\n            const col = Tables_column.getRecord(f.colRef);\n            // Formulas are currently unsupported.\n            return col && !(col.isFormula && col.formula);\n          });\n\n        let { layoutSpec: formLayoutSpec } = section;\n        if (!formLayoutSpec) {\n          formLayoutSpec = JSON.stringify({\n            type: \"Layout\",\n            children: [\n              { type: \"Label\" },\n              { type: \"Label\" },\n              {\n                type: \"Section\",\n                children: [\n                  { type: \"Label\" },\n                  { type: \"Label\" },\n                  ...fields.slice(0, INITIAL_FIELDS_COUNT).map(f => ({\n                    type: \"Field\",\n                    leaf: f.id,\n                  })),\n                ],\n              },\n            ],\n          });\n        }\n\n        // Cache the table reads based on tableId. We are caching only the promise, not the result.\n        const table = _.memoize((tableId: string) =>\n          readTable(req, activeDoc, tableId, {}, {}).then(r => asRecords(r, { includeId: true })));\n\n        const getTableValues = async (tableId: string, colId: string) => {\n          const records = await table(tableId);\n          return records.map(r => [r.id as number, r.fields[colId]] as const);\n        };\n\n        const Tables = activeDoc.docData.getMetaTable(\"_grist_Tables\");\n\n        const getRefTableValues = async (col: MetaRowRecord<\"_grist_Tables_column\">) => {\n          const refTableId = getReferencedTableId(col.type);\n          let refColId: string;\n          if (col.visibleCol) {\n            const refCol = Tables_column.getRecord(col.visibleCol);\n            if (!refCol) { return []; }\n\n            refColId = refCol.colId as string;\n          } else {\n            refColId = \"id\";\n          }\n          if (!refTableId || typeof refTableId !== \"string\" || !refColId) { return []; }\n\n          const values = await getTableValues(refTableId, refColId);\n          return values.filter(([_id, value]) => !isBlankValue(value));\n        };\n\n        const formFields = await Promise.all(fields.map(async (field) => {\n          const col = Tables_column.getRecord(field.colRef);\n          if (!col) { throw new Error(`Column ${field.colRef} not found`); }\n\n          const fieldOptions = safeJsonParse(field.widgetOptions as string, {});\n          const colOptions = safeJsonParse(col.widgetOptions as string, {});\n          const options = { ...colOptions, ...fieldOptions };\n          const type = extractTypeFromColType(col.type as string);\n          const colId = col.colId as string;\n\n          return [field.id, {\n            colId,\n            description: fieldOptions.description || col.description,\n            question: options.question || col.label || colId,\n            options,\n            type,\n            refValues: isFullReferencingType(col.type) ? await getRefTableValues(col) : null,\n          }] as const;\n        }));\n        const formFieldsById = Object.fromEntries(formFields);\n\n        const getTableName = () => {\n          const rawSectionRef = Tables.getRecord(section.tableRef)?.rawViewSectionRef;\n          if (!rawSectionRef) { return null; }\n\n          const rawSection = activeDoc.docData!\n            .getMetaTable(\"_grist_Views_section\")\n            .getRecord(rawSectionRef);\n          return rawSection?.title ?? null;\n        };\n\n        const formTableId = await getRealTableId(String(section.tableRef), { activeDoc, req });\n        const formTitle = section.title || getTableName() || formTableId;\n\n        this._grist.getTelemetry().logEvent(req, \"visitedForm\", {\n          full: {\n            docIdDigest: activeDoc.docName,\n            userId: req.userId,\n            altSessionId: req.altSessionId,\n          },\n        });\n\n        res.status(200).json({\n          formFieldsById,\n          formLayoutSpec,\n          formTableId,\n          formTitle,\n        });\n      }),\n    );\n\n    // GET /api/docs/:docId/timings\n    // Checks if timing is on for the document.\n    this._app.get(\"/api/docs/:docId/timing\", isOwner, withDoc(async (activeDoc, req, res) => {\n      if (!activeDoc.isTimingOn) {\n        res.json({ status: \"disabled\" });\n      } else {\n        const timing =  await activeDoc.getTimings();\n        const status = timing ? \"active\" : \"pending\";\n        res.json({ status, timing });\n      }\n    }));\n\n    // POST /api/docs/:docId/timings/start\n    // Start a timing for the document.\n    this._app.post(\"/api/docs/:docId/timing/start\", isOwner, withDoc(async (activeDoc, req, res) => {\n      if (activeDoc.isTimingOn) {\n        res.status(400).json({ error: `Timing already started for ${activeDoc.docName}` });\n        return;\n      }\n      // isTimingOn flag is switched synchronously.\n      await activeDoc.startTiming();\n      res.sendStatus(200);\n    }));\n\n    // POST /api/docs/:docId/timings/stop\n    // Stop a timing for the document.\n    this._app.post(\"/api/docs/:docId/timing/stop\", isOwner, withDoc(async (activeDoc, req, res) => {\n      if (!activeDoc.isTimingOn) {\n        res.status(400).json({ error: `Timing not started for ${activeDoc.docName}` });\n        return;\n      }\n      res.json(await activeDoc.stopTiming());\n    }));\n\n    const webhooks = new DocApiTriggers(\n      this._app,\n      this._dbManager,\n    );\n\n    webhooks.addEndpoints({\n      withDoc,\n      checkOwner: this._isOwner.bind(this),\n      middlewares: {\n        isOwner,\n        canEdit,\n      },\n    });\n  }\n\n  /**\n   * Throws if the specified section is not a published form.\n   */\n  private _assertIsPublishedForm(params: {\n    docData: DocData,\n    linkId: string,\n    sectionId: number,\n  }) {\n    const { docData, linkId, sectionId } = params;\n\n    // Check that the request is for a valid section in the document.\n    const sections = docData.getMetaTable(\"_grist_Views_section\");\n    const section = sections.getRecord(sectionId);\n    if (!section) { throw new ApiError(\"Form not found\", 404, { code: \"FormNotFound\" }); }\n\n    // Check that the section is for a form.\n    const sectionShareOptions = safeJsonParse(section.shareOptions, {});\n    if (!sectionShareOptions.form) { throw new ApiError(\"Form not found\", 404, { code: \"FormNotFound\" }); }\n\n    // Check that the form is associated with a share.\n    const viewId = section.parentId;\n    const pages = docData.getMetaTable(\"_grist_Pages\");\n    const page = pages.getRecords().find(p => p.viewRef === viewId);\n    if (!page) { throw new ApiError(\"Form not found\", 404, { code: \"FormNotFound\" }); }\n\n    const shares = docData.getMetaTable(\"_grist_Shares\");\n    const share = shares.getRecord(page.shareRef);\n    if (!share) { throw new ApiError(\"Form not found\", 404, { code: \"FormNotFound\" }); }\n\n    // Check that the share's link id matches the expected link id.\n    if (share.linkId !== linkId) { throw new ApiError(\"Form not found\", 404, { code: \"FormNotFound\" }); }\n\n    // Finally, check that both the section and share are published.\n    if (!sectionShareOptions.publish || !safeJsonParse(share.options, {})?.publish) {\n      throw new ApiError(\"Form not published\", 404, { code: \"FormNotPublished\" });\n    }\n  }\n\n  private async _copyDocToWorkspace(req: Request, options: {\n    userId: number,\n    sourceDocumentId: string,\n    workspaceId: number,\n    documentName: string,\n    asTemplate?: boolean,\n  }): Promise<string> {\n    const mreq = req as RequestWithLogin;\n    const { userId, sourceDocumentId, workspaceId, documentName, asTemplate = false } = options;\n\n    // First, upload a copy of the document.\n    let uploadResult;\n    try {\n      const accessId = makeAccessId(req, getAuthorizedUserId(req));\n      uploadResult = await fetchDoc(this._grist, this._docWorkerMap, sourceDocumentId, req, accessId, asTemplate);\n      globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, `${documentName}.grist`);\n    } catch (err) {\n      if ((err as ApiError).status === 403) {\n        throw new ApiError(\"Insufficient access to document to copy it entirely\", 403);\n      }\n\n      throw err;\n    }\n\n    // Then, import the copy to the workspace.\n    const { id, title: name } = await this._docManager.importDocToWorkspace(mreq, {\n      userId,\n      uploadId: uploadResult.uploadId,\n      documentName,\n      workspaceId,\n      telemetryMetadata: {\n        limited: {\n          isImport: false,\n          sourceDocIdDigest: sourceDocumentId,\n        },\n        full: {\n          userId: mreq.userId,\n          altSessionId: mreq.altSessionId,\n        },\n      },\n    });\n    this._logDuplicateDocumentEvents(mreq, {\n      original: { id: sourceDocumentId },\n      duplicate: { id, name, workspace: { id: workspaceId } },\n      asTemplate,\n    })\n      .catch(e => log.error(\"DocApi failed to log duplicate document events\", e));\n    return id;\n  }\n\n  private async _importDocumentToWorkspace(mreq: RequestWithLogin, options: {\n    userId: number,\n    uploadId: number,\n    documentName?: string,\n    workspaceId?: number,\n    browserSettings?: BrowserSettings,\n  }) {\n    const result = await this._docManager.importDocToWorkspace(mreq, {\n      userId: options.userId,\n      uploadId: options.uploadId,\n      documentName: options.documentName,\n      workspaceId: options.workspaceId,\n      browserSettings: options.browserSettings,\n      telemetryMetadata: {\n        limited: {\n          isImport: true,\n          sourceDocIdDigest: undefined,\n        },\n        full: {\n          userId: mreq.userId,\n          altSessionId: mreq.altSessionId,\n        },\n      },\n    });\n    this._logImportDocumentEvents(mreq, result);\n    return result;\n  }\n\n  private async _createNewSavedDoc(req: Request, options: {\n    workspaceId: number,\n    documentName?: string,\n  }): Promise<string> {\n    const { documentName, workspaceId } = options;\n    const { status, data, errMessage } = await this._dbManager.addDocument(getScope(req), workspaceId, {\n      name: documentName ?? req.t(\"DocApi.UntitledDocument\"),\n    });\n    if (status !== 200) {\n      throw new ApiError(errMessage || \"unable to create document\", status);\n    }\n\n    this._logCreateDocumentEvents(req, data!);\n    return data!.id;\n  }\n\n  private async _createNewUnsavedDoc(req: Request, options: {\n    userId: number,\n    browserSettings?: BrowserSettings,\n  }): Promise<string> {\n    const mreq = req as RequestWithLogin;\n    const { userId, browserSettings } = options;\n    const isAnonymous = isAnonymousUser(req);\n    const result = makeForkIds({\n      userId,\n      isAnonymous,\n      trunkDocId: NEW_DOCUMENT_CODE,\n      trunkUrlId: NEW_DOCUMENT_CODE,\n    });\n    const id = result.docId;\n    await this._docManager.createNamedDoc(\n      makeExceptionalDocSession(\"nascent\", { req: mreq, browserSettings }),\n      id,\n    );\n    this._logCreateDocumentEvents(req as RequestWithLogin, { id, name: \"Untitled\" });\n    return id;\n  }\n\n  /**\n   * Check for read access to the given document, and return its\n   * canonical docId.  Throws error if read access not available.\n   * This method is used for documents that are not the main document\n   * associated with the request, but are rather an extra source to be\n   * read from, so the access information is not cached in the\n   * request.\n   */\n  private async _confirmDocIdForRead(req: Request, urlId: string): Promise<string> {\n    const docAuth = await makeDocAuthResult(this._dbManager.getDoc({ ...getScope(req), urlId }));\n    if (docAuth.error) { throw docAuth.error; }\n    assertAccess(\"viewers\", docAuth);\n    return docAuth.docId!;\n  }\n\n  private async _getDownloadFilename(req: Request, tableId?: string, optDoc?: Document): Promise<string> {\n    let filename = optStringParam(req.query.title, \"title\");\n    if (!filename) {\n      // Query DB for doc metadata to get the doc data.\n      const doc = optDoc || await this._dbManager.getDoc(req);\n      const docTitle = doc.name;\n      const suffix = tableId ? (tableId === docTitle ? \"\" : `-${tableId}`) : \"\";\n      filename = docTitle + suffix || \"document\";\n    }\n    return filename;\n  }\n\n  private async _getDownloadOptions(req: Request, doc?: Document): Promise<DownloadOptions> {\n    const params = parseExportParameters(req);\n    return {\n      ...params,\n      filename: await this._getDownloadFilename(req, params.tableId, doc),\n    };\n  }\n\n  private _getActiveDoc(req: RequestWithLogin): Promise<ActiveDoc> {\n    return this._docManager.fetchDoc(docSessionFromRequest(req), getDocId(req));\n  }\n\n  private _getActiveDocIfAvailable(req: RequestWithLogin): Promise<ActiveDoc> | undefined {\n    return this._docManager.getActiveDoc(getDocId(req));\n  }\n\n  /**\n   * Middleware to track the number of requests outstanding on each document, and to\n   * throw an exception when the maximum number of requests are already outstanding.\n   * Also throws an exception if too many requests (based on the user's product plan)\n   * have been made today for this document.\n   * Access to a document must already have been authorized.\n   */\n  private _apiThrottle(callback: (req: RequestWithLogin,\n    resp: Response,\n    next: NextFunction) => void | Promise<void>): RequestHandler {\n    return async (req, res, next) => {\n      const docId = getDocId(req);\n      try {\n        const count = this._currentUsage.get(docId) || 0;\n        this._currentUsage.set(docId, count + 1);\n        if (this._maxParallelRequestsPerDoc > 0 && count + 1 > this._maxParallelRequestsPerDoc) {\n          throw new ApiError(`Too many backlogged requests for document ${docId} - ` +\n            `try again later?`, 429);\n        }\n\n        if (await this._checkDailyDocApiUsage(req, docId)) {\n          throw new ApiError(`Exceeded daily limit for document ${docId}`, 429);\n        }\n\n        await callback(req as RequestWithLogin, res, next);\n      } catch (err) {\n        next(err);\n      } finally {\n        const count = this._currentUsage.get(docId);\n        if (count) {\n          if (count === 1) {\n            this._currentUsage.delete(docId);\n          } else {\n            this._currentUsage.set(docId, count - 1);\n          }\n        }\n      }\n    };\n  }\n\n  /**\n   * Usually returns true if too many requests (based on the user's product plan)\n   * have been made today for this document and the request should be rejected.\n   * Access to a document must already have been authorized.\n   * This is called frequently so it uses caches to check quickly in the common case,\n   * which allows a few ways for users to exceed the limit slightly if the timing works out,\n   * but these should be acceptable.\n   */\n  private async _checkDailyDocApiUsage(req: Request, docId: string): Promise<boolean> {\n    // Use the cached doc to avoid a database call.\n    // This leaves a small window (currently 5 seconds) for the user to bypass this limit after downgrading,\n    // or to be wrongly rejected after upgrading.\n    const doc = (req as RequestWithLogin).docAuth!.cachedDoc!;\n\n    const max = doc.workspace.org.billingAccount?.getFeatures().baseMaxApiUnitsPerDocumentPerDay;\n    if (!max) {\n      // This doc has no associated product (happens to new unsaved docs)\n      // or the product has no API limit. Allow the request through.\n      return false;\n    }\n\n    // Check the counts in the dailyUsage cache rather than waiting for redis.\n    // The cache will not have counts if this is the first request for this document served by this worker process\n    // or if so many other documents have been served since then that the keys were evicted from the LRU cache.\n    // Both scenarios are temporary and unlikely when usage has been exceeded.\n    // Note that if the limits are exceeded then `keys` below will be undefined,\n    // otherwise it will be an array of three keys corresponding to a day, hour, and minute.\n    const m = moment.utc();\n    const keys = getDocApiUsageKeysToIncr(docId, this._dailyUsage, max, m);\n    if (!keys) {\n      // The limit has been exceeded, reject the request.\n      return true;\n    }\n\n    // If Redis isn't configured, this is as far as we can go with checks.\n    if (!process.env.REDIS_URL) { return false; }\n\n    // Note the increased API usage on redis and in our local cache.\n    // Update redis in the background so that the rest of the request can continue without waiting for redis.\n    const cli = this._docWorkerMap.getRedisClient();\n    if (!cli) { throw new Error(\"redis unexpectedly not available\"); }\n    const multi = cli.multi();\n    for (let i = 0; i < keys.length; i++) {\n      const key = keys[i];\n      // Incrementing the local count immediately prevents many requests from being squeezed through every minute\n      // before counts are received from redis.\n      // But this cache is not 100% reliable and the count from redis may be higher.\n      this._dailyUsage.set(key, (this._dailyUsage.get(key) ?? 0) + 1);\n      const period = docApiUsagePeriods[i];\n      // Expire the key just so that it cleans itself up and saves memory on redis.\n      // Expire after two periods to handle 'next' buckets.\n      const expiry = 2 * 24 * 60 * 60 / period.periodsPerDay;\n      multi.incr(key).expire(key, expiry);\n    }\n    multi.execAsync().then((result) => {\n      for (let i = 0; i < keys.length; i++) {\n        const key = keys[i];\n        const newCount = Number(result![i * 2]);  // incrs are at even positions, expires at odd positions\n        // Theoretically this could be overwritten by a lower count that was requested earlier\n        // but somehow arrived after.\n        // This doesn't really matter, and the count on redis will still increase reliably.\n        this._dailyUsage.set(key, newCount);\n      }\n    }).catch(e => console.error(`Error tracking API usage for doc ${docId}`, e));\n\n    // Allow the request through.\n    return false;\n  }\n\n  /**\n   * Disallow document creation for anonymous users if GRIST_ANON_PLAYGROUND is set to false.\n   */\n  private async _checkAnonymousCreation(req: Request, res: Response, next: NextFunction) {\n    if (isAnonymousUser(req) && !getAnonPlaygroundEnabled()) {\n      throw new ApiError(\"Anonymous document creation is disabled\", 403);\n    }\n    next();\n  }\n\n  private async _assertAccess(role: \"viewers\" | \"editors\" | \"owners\" | null, allowRemovedOrDisabled: boolean,\n    req: Request, res: Response, next: NextFunction) {\n    const scope = getDocScope(req);\n    allowRemovedOrDisabled = scope.showAll || scope.showRemoved || allowRemovedOrDisabled;\n    const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, this._grist, scope.urlId);\n    if (role) {\n      assertAccess(role, docAuth, {\n        allowRemoved: allowRemovedOrDisabled,\n        allowDisabled: allowRemovedOrDisabled });\n    }\n    next();\n  }\n\n  /**\n   * Check if user is an owner of the document.\n   * If acceptTrunkForSnapshot is set, being an owner of the trunk of the document (if it is a\n   * snapshot) is sufficient. Uses cachedDoc, which could be stale if access has changed recently.\n   */\n  private async _isOwner(req: Request, options?: { acceptTrunkForSnapshot?: boolean }) {\n    const scope = getDocScope(req);\n    const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, this._grist, scope.urlId);\n    if (docAuth.access === \"owners\") {\n      return true;\n    }\n    if (options?.acceptTrunkForSnapshot && docAuth.cachedDoc?.trunkAccess === \"owners\") {\n      const parts = parseUrlId(scope.urlId);\n      if (parts.snapshotId) { return true; }\n    }\n    return false;\n  }\n\n  // Helper to generate a 503 if the ActiveDoc has been muted.\n  private _checkForMute(activeDoc: ActiveDoc | undefined) {\n    if (activeDoc?.muted) {\n      throw new ApiError(\"Document in flux - try again later\", 503);\n    }\n  }\n\n  /**\n   * Throws an error if, during processing, the ActiveDoc becomes \"muted\".  Also replaces any\n   * other error that may have occurred if the ActiveDoc becomes \"muted\", since the document\n   * shutting down during processing may have caused a variety of errors.\n   *\n   * Expects to be called within a handler that catches exceptions.\n   */\n  private _requireActiveDoc(callback: WithDocHandler): RequestHandler {\n    return async (req, res) => {\n      let activeDoc: ActiveDoc | undefined;\n      try {\n        activeDoc = await this._getActiveDoc(req as RequestWithLogin);\n        await callback(activeDoc, req as RequestWithLogin, res);\n        if (!res.headersSent) { this._checkForMute(activeDoc); }\n      } catch (err) {\n        this._checkForMute(activeDoc);\n        throw err;\n      }\n    };\n  }\n\n  private async _getStates(docSession: OptDocSession, activeDoc: ActiveDoc): Promise<DocStates> {\n    const states = await activeDoc.getRecentStates(docSession);\n    return {\n      states,\n    };\n  }\n\n  private async _removeDoc(req: Request, res: Response, permanent: boolean): Promise<QueryResult<Document>> {\n    const scope = getDocScope(req);\n    const docId = getDocId(req);\n    let result: QueryResult<Document>;\n    if (permanent) {\n      const { forkId } = parseUrlId(docId);\n      if (!forkId) {\n        // Soft delete the doc first, to de-list the document.\n        await this._dbManager.softDeleteDocument(scope);\n      }\n      // Delete document content from storage. Include forks if doc is a trunk.\n      const forks = forkId ? [] : await this._dbManager.getDocForks(docId);\n      const docsToDelete = [\n        docId,\n        ...forks.map(fork =>\n          buildUrlId({ forkId: fork.id, forkUserId: fork.createdBy!, trunkId: docId })),\n      ];\n      if (!forkId) {\n        // Delete all remote document attachments before the doc itself.\n        // This way we can re-attempt deletion if an error is thrown.\n        const attachmentStores = await this._attachmentStoreProvider.getAllStores();\n        log.debug(`Deleting all attachments for ${docId} from ${attachmentStores.length} stores`);\n        const poolDeletions = attachmentStores.map(\n          store => store.removePool(getDocPoolIdFromDocInfo({ id: docId, trunkId: null })),\n        );\n        await Promise.all(poolDeletions);\n      }\n      await Promise.all(docsToDelete.map(docName => this._docManager.deleteDoc(null, docName, true)));\n      // Permanently delete from database.\n      result = await this._dbManager.deleteDocument(scope);\n      this._dbManager.checkQueryResult(result);\n      await sendReply(req, res, { ...result, data: result.data!.id });\n    } else {\n      result = await this._dbManager.softDeleteDocument(scope);\n      await sendOkReply(req, res);\n    }\n    await this._dbManager.flushSingleDocAuthCache(scope, docId);\n    await this._docManager.interruptDocClients(docId);\n    return result;\n  }\n\n  /**\n   * This method should only be called from the docs/:docId/disable\n   * and docs/:docId/enable endpoints, which use middleware to check\n   * for admin access. We therefore assume admin access in the body of\n   * this function.\n   */\n  private async _toggleDisabledStatus(req: Request, res: Response, action: \"enable\" | \"disable\") {\n    const mreq = req as RequestWithLogin;\n    const docId = req.params.docId || req.params.did;\n\n    // We have admin access, so grant a special permit to this doc\n    mreq.specialPermit = { ...mreq.specialPermit, docId };\n\n    const scope = getDocScope(req);\n    const result = await this._dbManager.toggleDisableDocument(action, scope);\n\n    await sendOkReply(req, res);\n    await this._dbManager.flushSingleDocAuthCache(scope, docId);\n\n    if (action === \"disable\") {\n      await this._docManager.interruptDocClients(docId);\n    }\n\n    if (result.data) {\n      this._logDisableToggleDocumentEvents(action, mreq, result.data);\n    }\n  }\n\n  private async _runSql(\n    activeDoc: ActiveDoc,\n    req: RequestWithLogin,\n    res: Response,\n    options: Types.SqlPost,\n  ) {\n    try {\n      const records = await runSQLQuery(req, activeDoc, options);\n      this._logRunSQLQueryEvents(activeDoc, req, options);\n      res.status(200).json({\n        statement: options.sql,\n        records: records.map(\n          rec => ({\n            fields: rec,\n          }),\n        ),\n      });\n    } catch (e) {\n      if (e?.code === \"SQLITE_INTERRUPT\") {\n        res.status(400).json({\n          error: \"a slow statement resulted in a database interrupt\",\n        });\n      } else if (e?.code === \"SQLITE_ERROR\") {\n        res.status(400).json({\n          error: e?.message,\n        });\n      } else {\n        throw e;\n      }\n    }\n  }\n\n  private _logCreateDocumentEvents(\n    req: Request,\n    document: { id: string; name: string; workspace?: Workspace },\n  ) {\n    const mreq = req as RequestWithLogin;\n    const { id, name, workspace } = document;\n    const org = workspace?.org;\n    this._grist.getAuditLogger().logEvent(mreq, {\n      action: \"document.create\",\n      context: {\n        site: org ? _.pick(org, \"id\", \"name\", \"domain\") : undefined,\n      },\n      details: {\n        document: {\n          id,\n          name,\n          workspace: workspace ? _.pick(workspace, \"id\", \"name\") : undefined,\n        },\n      },\n    });\n    this._grist.getTelemetry().logEvent(mreq, \"documentCreated\", {\n      limited: {\n        docIdDigest: id,\n        sourceDocIdDigest: undefined,\n        isImport: false,\n        fileType: undefined,\n        isSaved: workspace !== undefined,\n      },\n      full: {\n        userId: mreq.userId,\n        altSessionId: mreq.altSessionId,\n      },\n    });\n    this._grist.getTelemetry().logEvent(mreq, \"createdDoc-Empty\", {\n      full: {\n        docIdDigest: id,\n        userId: mreq.userId,\n        altSessionId: mreq.altSessionId,\n      },\n    });\n  }\n\n  private _logDisableToggleDocumentEvents(action: \"enable\" | \"disable\", req: RequestWithLogin, document: Document) {\n    this._grist.getAuditLogger().logEvent(req, {\n      action: `document.${action}`,\n      context: {\n        site: _.pick(document.workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        document: {\n          ..._.pick(document, \"id\", \"name\"),\n          workspace: _.pick(document.workspace, \"id\", \"name\"),\n        },\n      },\n    });\n  }\n\n  private _logRemoveDocumentEvents(req: RequestWithLogin, document: Document) {\n    this._grist.getAuditLogger().logEvent(req, {\n      action: \"document.move_to_trash\",\n      context: {\n        site: _.pick(document.workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        document: _.pick(document, \"id\", \"name\"),\n      },\n    });\n  }\n\n  private _logDeleteDocumentEvents(req: RequestWithLogin, document: Document) {\n    // If we're deleting a fork, we need to get the org from the trunk.\n    const org = document.workspace?.org ?? document.trunk?.workspace.org;\n    this._grist.getAuditLogger().logEvent(req, {\n      action: \"document.delete\",\n      context: {\n        site: _.pick(org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        document: _.pick(document, \"id\", \"name\"),\n      },\n    });\n    this._grist.getTelemetry().logEvent(req, \"deletedDoc\", {\n      full: {\n        docIdDigest: document.id,\n        userId: req.userId,\n        altSessionId: req.altSessionId,\n      },\n    });\n  }\n\n  private _logImportDocumentEvents(\n    req: RequestWithLogin,\n    { id}: { id: string },\n  ) {\n    this._grist.getTelemetry().logEvent(req, \"createdDoc-FileImport\", {\n      full: {\n        docIdDigest: id,\n        userId: req.userId,\n        altSessionId: req.altSessionId,\n      },\n    });\n  }\n\n  private _logReplaceDocumentEvents(\n    req: RequestWithLogin,\n    document: { id: string; workspace?: Workspace },\n    { sourceDocId, snapshotId }: DocReplacementOptions,\n  ) {\n    const org = document.workspace?.org;\n    this._grist.getAuditLogger().logEvent(req, {\n      action: \"document.replace\",\n      context: {\n        site: org ? _.pick(org, \"id\") : undefined,\n      },\n      details: {\n        document: _.pick(document, \"id\"),\n        ...(snapshotId ?\n          { snapshot: { id: snapshotId } } :\n          sourceDocId ?\n            { fork: { document_id: sourceDocId } } :\n            undefined),\n      },\n    });\n  }\n\n  private async _logDuplicateDocumentEvents(req: RequestWithLogin, options: {\n    original: { id: string },\n    duplicate: { id: string; name: string; workspace: { id: number } },\n    asTemplate: boolean;\n  }) {\n    const original = await this._dbManager.getRawDocById(options.original.id);\n    const { duplicate, asTemplate } = options;\n    this._grist.getAuditLogger().logEvent(req, {\n      action: \"document.duplicate\",\n      context: {\n        site: _.pick(original.workspace.org, \"id\", \"name\", \"domain\"),\n      },\n      details: {\n        original: {\n          document: _.pick(original, \"id\", \"name\"),\n        },\n        duplicate: {\n          document: {\n            ..._.pick(duplicate, \"id\", \"name\"),\n            workspace: _.pick(duplicate.workspace, \"id\"),\n          },\n        },\n        options: {\n          as_template: asTemplate,\n        },\n      },\n    });\n    const isTemplateCopy = original.type === \"template\";\n    if (isTemplateCopy) {\n      this._grist.getTelemetry().logEvent(req, \"copiedTemplate\", {\n        full: {\n          templateId: parseUrlId(original.urlId || original.id).trunkId,\n          userId: req.userId,\n          altSessionId: req.altSessionId,\n        },\n      });\n    }\n    this._grist.getTelemetry().logEvent(\n      req,\n      `createdDoc-${isTemplateCopy ? \"CopyTemplate\" : \"CopyDoc\"}`,\n      {\n        full: {\n          docIdDigest: duplicate.id,\n          userId: req.userId,\n          altSessionId: req.altSessionId,\n        },\n      },\n    );\n  }\n\n  private _logReloadDocumentEvents(\n    req: RequestWithLogin,\n    document: { id: string; workspace?: Workspace },\n  ) {\n    const org = document.workspace?.org;\n    this._grist.getAuditLogger().logEvent(req, {\n      action: \"document.reload\",\n      context: {\n        site: org ? _.pick(org, \"id\") : undefined,\n      },\n      details: {\n        document: _.pick(document, \"id\"),\n      },\n    });\n  }\n\n  private _logTruncateDocumentHistoryEvents(\n    activeDoc: ActiveDoc,\n    req: RequestWithLogin,\n    { keep }: { keep: number },\n  ) {\n    const document = activeDoc.doc || { id: activeDoc.docName };\n    activeDoc.logAuditEvent(req, {\n      action: \"document.truncate_history\",\n      details: {\n        document: _.pick(document, \"id\"),\n        options: {\n          keep_n_most_recent: keep,\n        },\n      },\n    });\n  }\n\n  private _logRunSQLQueryEvents(\n    activeDoc: ActiveDoc,\n    req: RequestWithLogin,\n    { sql: statement, args, timeout: timeout_ms }: Types.SqlPost,\n  ) {\n    activeDoc.logAuditEvent(req, {\n      action: \"document.run_sql_query\",\n      details: {\n        document: {\n          id: activeDoc.docName,\n        },\n        sql_query: {\n          statement,\n          arguments: args,\n        },\n        options: {\n          timeout_ms,\n        },\n      },\n    });\n  }\n\n  private async _compareDoc(req: RequestWithLogin, activeDoc: ActiveDoc,\n    options: {\n      showDetails: boolean,\n      docId2: string,\n      maxRows: number | null | undefined,\n    }) {\n    const { showDetails, docId2, maxRows } = options;\n    const docSession = docSessionFromRequest(req);\n    const { states } = await this._getStates(docSession, activeDoc);\n    const ref = await fetch(this._grist.getHomeInternalUrl(`/api/docs/${docId2}/states`), {\n      headers: {\n        ...getTransitiveHeaders(req, { includeOrigin: false }),\n        \"Content-Type\": \"application/json\",\n      },\n    });\n    if (!ref.ok) {\n      throw new ApiError(await ref.text(), ref.status);\n    }\n    const states2: DocState[] = (await ref.json()).states;\n    const left = states[0];\n    const right = states2[0];\n    if (!left || !right) {\n      // This should not arise unless there's a bug.\n      throw new Error(\"document with no history\");\n    }\n    const rightHashes = new Set(states2.map(state => state.h));\n    const parent = states.find(state => rightHashes.has(state.h)) || null;\n    const leftChanged = parent && parent.h !== left.h;\n    const rightChanged = parent && parent.h !== right.h;\n    const summary = leftChanged ? (rightChanged ? \"both\" : \"left\") :\n      (rightChanged ? \"right\" : (parent ? \"same\" : \"unrelated\"));\n    const comparison: DocStateComparison = {\n      left, right, parent, summary,\n    };\n    if (showDetails && parent) {\n      // Calculate changes from the parent to the current version of this document.\n      const leftChanges = (\n        await getChanges(docSession, activeDoc, {\n          states,\n          leftHash: parent.h,\n          rightHash: \"HEAD\",\n          maxRows,\n        })\n      ).details!.rightChanges;\n\n      // Calculate changes from the (common) parent to the current version of the other document.\n      let url = `/api/docs/${docId2}/compare?left=${parent.h}`;\n      if (maxRows !== undefined) {\n        url += `&maxRows=${maxRows}`;\n      }\n      const rightChangesReq = await fetch(this._grist.getHomeInternalUrl(url), {\n        headers: {\n          ...getTransitiveHeaders(req, { includeOrigin: false }),\n          \"Content-Type\": \"application/json\",\n        },\n      });\n      const rightChanges = (await rightChangesReq.json()).details!.rightChanges;\n\n      // Add the left and right changes as details to the result.\n      comparison.details = { leftChanges, rightChanges };\n    }\n\n    return comparison;\n  }\n}\n\nexport function addDocApiRoutes(\n  app: Application, docWorker: DocWorker, docWorkerMap: IDocWorkerMap, docManager: DocManager, dbManager: HomeDBManager,\n  attachmentStoreProvider: IAttachmentStoreProvider, grist: GristServer,\n) {\n  const api = new DocWorkerApi(app, docWorker, docWorkerMap, docManager, dbManager, attachmentStoreProvider, grist);\n  api.addEndpoints();\n}\n\n/**\n * Options for returning results from a query about document data.\n * Currently these option don't affect the query itself, only the\n * results returned to the user.\n */\nexport interface QueryParameters {\n  sort?: string[];  // Columns names to sort by (ascending order by default,\n  // prepend \"-\" for descending order, can contain flags,\n  // see more in Sort.SortSpec).\n  limit?: number;   // Limit on number of rows to return.\n  cellFormat?: CellFormatType;\n}\n\n/**\n * Extract a sort parameter from a request, if present.  Follows\n * https://jsonapi.org/format/#fetching-sorting for want of a better\n * standard - comma separated, defaulting to ascending order, keys\n * prefixed by \"-\" for descending order.\n *\n * The sort parameter can either be given as a query parameter, or\n * as a header.\n */\nfunction getSortParameter(req: Request): string[] | undefined {\n  const sortString: string | undefined = optStringParam(req.query.sort, \"sort\") || req.get(\"X-Sort\");\n  if (!sortString) { return undefined; }\n  return sortString.split(\",\");\n}\n\n/**\n * Extract a limit parameter from a request, if present.  Should be a\n * simple integer.  The limit parameter can either be given as a query\n * parameter, or as a header.\n */\nfunction getLimitParameter(req: Request): number | undefined {\n  const limitString: string | undefined = optStringParam(req.query.limit, \"limit\") || req.get(\"X-Limit\");\n  if (!limitString) { return undefined; }\n  const limit = parseInt(limitString, 10);\n  if (isNaN(limit)) { throw new Error(\"limit is not a number\"); }\n  return limit;\n}\n\nfunction getCellFormatParameter(req: Request): CellFormatType | undefined {\n  const allowedCellFormats: CellFormatType[] = [\"normal\", \"typed\"];\n  return optStringParam(req.query.cellFormat, \"cellFormat\",\n    { allowed: allowedCellFormats }) as CellFormatType | undefined;\n}\n\n/**\n * Extract sort and limit parameters from request, if they are present.\n */\nfunction getQueryParameters(req: Request): QueryParameters {\n  return {\n    sort: getSortParameter(req),\n    limit: getLimitParameter(req),\n    cellFormat: getCellFormatParameter(req),\n  };\n}\n\n/**\n * Sort table contents being returned.  Sort keys with a '-' prefix\n * are sorted in descending order, otherwise ascending.  Contents are\n * modified in place. Sort keys can contain sort options.\n * Columns can be either expressed as a colId (name string) or as colRef (rowId number).\n */\nfunction applySort(\n  values: TableColValues,\n  sort: string[],\n  _columns: TableRecordValue[] | null = null) {\n  if (!sort) { return values; }\n\n  // First we need to prepare column description in ColValue format (plain objects).\n  // This format is used by ServerColumnGetters.\n  let properColumns: ColValues[] = [];\n\n  // We will receive columns information only for user tables, not for metatables. So\n  // if this is the case, we will infer them from the result.\n  if (!_columns) {\n    _columns = Object.keys(values).map((col, index) => ({ id: col, fields: { colRef: index } }));\n  } else { // For user tables, we will not get id column (as this column is not in the schema), so we need to\n    // make sure the column is there.\n\n    // This is enough information for ServerGetters\n    _columns = [..._columns, { id: \"id\", fields: { colRef: 0 } }];\n  }\n\n  // Once we have proper columns, we can convert them to format that ServerColumnGetters\n  // understand.\n  properColumns = _columns.map(c => ({\n    ...c.fields,\n    id: c.fields.colRef,\n    colId: c.id,\n  }));\n\n  // We will sort row indices in the values object, not rows ids.\n  const rowIndices = values.id.map((__, i) => i);\n  const getters = new ServerColumnGetters(rowIndices, values, properColumns);\n  const sortFunc = new SortFunc(getters);\n  const colIdToRef = new Map(properColumns.map(({ id, colId }) => [colId as string, id as number]));\n  sortFunc.updateSpec(Sort.parseNames(sort, colIdToRef));\n  rowIndices.sort(sortFunc.compare.bind(sortFunc));\n\n  // Sort resulting values according to the sorted index.\n  for (const key of Object.keys(values)) {\n    const col = values[key];\n    values[key] = rowIndices.map(i => col[i]);\n  }\n  return values;\n}\n\n/**\n * Truncate columns to the first N values.  Columns are modified in place.\n */\nfunction applyLimit(values: TableColValues, limit: number) {\n  // for no limit, or 0 limit, do not apply any restriction\n  if (!limit) { return values; }\n  for (const key of Object.keys(values)) {\n    values[key].splice(limit);\n  }\n  return values;\n}\n\n/**\n * Apply query parameters to table contents.  Contents are modified in place.\n */\nexport function applyQueryParameters(\n  values: TableColValues,\n  params: QueryParameters,\n  columns: TableRecordValue[] | null = null,\n): TableColValues {\n  if (params.sort) { applySort(values, params.sort, columns); }\n  if (params.limit) { applyLimit(values, params.limit); }\n\n  if (params.cellFormat === \"typed\") {\n    const colIdToType = new Map(columns?.map(c => [c.id, c.fields.type as string]));\n    for (const [colId, colValues] of Object.entries(values)) {\n      const colType = colIdToType.get(colId) || \"Any\";\n      const typeInfo = extractInfoFromColType(colType);\n      values[colId] = colValues.map(val => reencodeAsTypedCellValue(val, typeInfo));\n    }\n  }\n  return values;\n}\n\nasync function getTableOperations(\n  req: RequestWithLogin,\n  activeDoc: ActiveDoc,\n  tableId?: string): Promise<TableOperationsImpl> {\n  const options: OpOptions = {\n    parseStrings: !isAffirmative(req.query.noparse),\n  };\n  const realTableId = await getRealTableId(tableId ?? req.params.tableId, { activeDoc, req });\n  const platform: TableOperationsPlatform = {\n    ...getErrorPlatform(realTableId),\n    applyUserActions(actions, opts) {\n      if (!activeDoc) { throw new Error(\"no document\"); }\n      return activeDoc.applyUserActions(\n        docSessionFromRequest(req),\n        actions,\n        opts,\n      );\n    },\n  };\n  return new TableOperationsImpl(platform, options);\n}\n\nexport interface DocApiUsagePeriod {\n  unit: \"day\" | \"hour\" | \"minute\",\n  format: string;\n  periodsPerDay: number;\n}\n\nexport const docApiUsagePeriods: DocApiUsagePeriod[] = [\n  {\n    unit: \"day\",\n    format: \"YYYY-MM-DD\",\n    periodsPerDay: 1,\n  },\n  {\n    unit: \"hour\",\n    format: \"YYYY-MM-DDTHH\",\n    periodsPerDay: 24,\n  },\n  {\n    unit: \"minute\",\n    format: \"YYYY-MM-DDTHH:mm\",\n    periodsPerDay: 24 * 60,\n  },\n];\n\n/**\n * Returns a key used for redis and a local cache\n * which store the number of API requests made for the given document in the given period.\n * The key contains the current UTC date (and maybe hour and minute)\n * so that counts from previous periods are simply ignored and eventually evicted.\n * This means that the daily measured usage conceptually 'resets' at UTC midnight.\n * If `current` is false, returns a key for the next day/hour.\n */\nexport function docPeriodicApiUsageKey(docId: string, current: boolean, period: DocApiUsagePeriod, m: moment.Moment) {\n  if (!current) {\n    m = m.clone().add(1, period.unit);\n  }\n  return `doc-${docId}-periodicApiUsage-${m.format(period.format)}`;\n}\n\n/**\n * Checks whether the doc API usage fits within the daily maximum.\n * If so, returns an array of keys for each unit of time whose usage should be incremented.\n * If not, returns undefined.\n *\n * Description of the algorithm this is implementing:\n *\n * Maintain up to 5 buckets: current day, next day, current hour, next hour, current minute.\n * For each API request, check in order:\n * - if current_day < DAILY_LIMIT, allow; increment all 3 current buckets\n * - else if current_hour < DAILY_LIMIT/24, allow; increment next_day, current_hour, and\n * current_minute buckets.\n * - else if current_minute < DAILY_LIMIT/24/60, allow; increment next_day, next_hour, and\n * current_minute buckets.\n * - else reject.\n * I think it has pretty good properties:\n * - steady low usage may be maintained even if a burst exhausted the daily limit\n * - user could get close to twice the daily limit on the first day with steady usage after a\n * burst,\n *   but would then be limited to steady usage the next day.\n */\nexport function getDocApiUsageKeysToIncr(\n  docId: string, usage: LRUCache<string, number>, dailyMax: number, m: moment.Moment,\n): string[] | undefined {\n  // Start with keys for the current day, minute, and hour\n  const keys = docApiUsagePeriods.map(p => docPeriodicApiUsageKey(docId, true, p, m));\n  for (let i = 0; i < docApiUsagePeriods.length; i++) {\n    const period = docApiUsagePeriods[i];\n    const key = keys[i];\n    const periodMax = Math.ceil(dailyMax / period.periodsPerDay);\n    const count = usage.get(key) || 0;\n    if (count < periodMax) {\n      return keys;\n    }\n    // Allocation for the current day/hour/minute has been exceeded, increment the next day/hour/minute instead.\n    keys[i] = docPeriodicApiUsageKey(docId, false, period, m);\n  }\n  // Usage exceeded all the time buckets, so return undefined to reject the request.\n}\n\n/**\n * Converts `activeDoc` to XLSX and sends the converted data through `res`.\n */\nexport async function downloadXLSX(activeDoc: ActiveDoc, req: Request,\n  res: Response, options: DownloadOptions) {\n  const { filename } = options;\n  res.setHeader(\"Content-Type\", \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\");\n  res.setHeader(\"Content-Disposition\", contentDisposition(filename + \".xlsx\"));\n  return streamXLSX(activeDoc, req, res, options);\n}\n\n/**\n *\n * Calculate changes between two document versions identified by leftHash and rightHash.\n * If rightHash is the latest version of the document, the ActionSummary for it will\n * contain a copy of updated and added rows.\n *\n * Currently will fail if leftHash is not an ancestor of rightHash (this restriction could\n * be lifted, but is adequate for now).\n *\n */\nexport async function getChanges(\n  docSession: OptDocSession,\n  activeDoc: ActiveDoc,\n  options: {\n    states: DocState[];\n    leftHash: string;\n    rightHash: string;\n    maxRows?: number | null;\n  },\n): Promise<DocStateComparison> {\n  // The change calculation currently cannot factor in\n  // granular access rules, so we need broad read rights\n  // to execute it.\n  if (!await activeDoc.canCopyEverything(docSession)) {\n    throw new ApiError(\"insufficient access\", 403);\n  }\n\n  const { states, leftHash, rightHash, maxRows } = options;\n  const finder = new HashUtil(states);\n  const leftOffset = finder.hashToOffset(leftHash);\n  const rightOffset = finder.hashToOffset(rightHash);\n  if (rightOffset > leftOffset) {\n    throw new Error(\"Comparisons currently require left to be an ancestor of right\");\n  }\n  const actionNums: number[] = states.slice(rightOffset, leftOffset).map(state => state.n);\n  const actions = (await activeDoc.getActions(actionNums)).reverse();\n  let totalAction = createEmptyActionSummary();\n  for (const action of actions) {\n    if (!action) { continue; }\n    const summary = summarizeAction(action, {\n      maximumInlineRows: maxRows,\n    });\n    totalAction = concatenateSummaries([totalAction, summary]);\n  }\n  const result: DocStateComparison = {\n    left: states[leftOffset],\n    right: states[rightOffset],\n    parent: states[leftOffset],\n    summary: (leftOffset === rightOffset) ? \"same\" : \"right\",\n    details: {\n      leftChanges: { tableRenames: [], tableDeltas: {} },\n      rightChanges: totalAction,\n    },\n  };\n  return result;\n}\n"
  },
  {
    "path": "app/server/lib/DocApiTriggers.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { timeoutReached } from \"app/common/gutil\";\nimport { SchemaTypes } from \"app/common/schema\";\nimport { WebhookAction, WebhookFields, WebHookSecret } from \"app/common/Triggers\";\nimport TriggersTI from \"app/common/Triggers-ti\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { ActiveDoc, colIdToRef as colIdToReference, getRealTableId, tableIdToRef } from \"app/server/lib/ActiveDoc\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { getMetaTables, handleSandboxError, validate, WithDocHandler } from \"app/server/lib/DocApiUtils\";\nimport { docSessionFromRequest } from \"app/server/lib/DocSession\";\nimport log from \"app/server/lib/log\";\nimport { isUrlAllowed } from \"app/server/lib/Triggers\";\n\nimport { Application, RequestHandler, Response } from \"express\";\nimport * as _ from \"lodash\";\nimport * as t from \"ts-interface-checker\";\nimport { v4 as uuidv4 } from \"uuid\";\n\n// Maximum amount of time that a webhook endpoint can hold the mutex for in withDocTriggersLock.\nconst MAX_DOC_TRIGGERS_LOCK_MS = 15_000;\n\nexport interface WebhookSubscription {\n  unsubscribeKey: string;\n  webhookId: string;\n}\n\n// Schema validators for api endpoints that creates or updates records.\nconst {\n  WebhookPatch,\n  WebhookSubscribe,\n  WebhookSubscribeCollection,\n} = t.createCheckers(TriggersTI);\n\nexport class DocApiTriggers {\n  constructor(\n    private _app: Application,\n    private _dbManager: HomeDBManager,\n  ) {}\n\n  /**\n   * Adds endpoints for the doc api.\n   *\n   * Note that it expects bodyParser, userId, and jsonErrorHandler middleware to be set up outside\n   * to apply to these routes.\n   */\n  public addEndpoints(options: {\n    withDoc: (callback: WithDocHandler) => RequestHandler,\n    checkOwner: (req: RequestWithLogin) => Promise<boolean>,\n    middlewares: {\n      isOwner: RequestHandler;\n      canEdit: RequestHandler;\n    },\n  }) {\n    const { isOwner, canEdit } = options.middlewares;\n    const { withDoc, checkOwner } = options;\n\n    // Like withDoc, but only one such callback can run at a time per active doc.\n    // This is used for webhook endpoints to prevent simultaneous changes to configuration\n    // or clearing queues which could lead to weird problems.\n    const withDocTriggersLock = (callback: WithDocHandler) => withDoc(\n      async (activeDoc: ActiveDoc, req: RequestWithLogin, resp: Response) =>\n        await activeDoc.triggersLock.runExclusive(async () => {\n          // We don't want to hold the mutex indefinitely so that if one call gets stuck\n          // (especially while trying to apply user actions which are stalled by a full queue)\n          // another call which would clear a queue, disable a webhook, or fix something related\n          // can eventually succeed.\n          if (await timeoutReached(MAX_DOC_TRIGGERS_LOCK_MS, callback(activeDoc, req, resp), { rethrow: true })) {\n            log.rawError(`Webhook endpoint timed out, releasing mutex`,\n              { method: req.method, path: req.path, docId: activeDoc.docName });\n          }\n        }),\n    );\n\n    const registerWebhook = async (activeDoc: ActiveDoc, req: RequestWithLogin, webhook: WebhookFields) => {\n      if (activeDoc.isFork) {\n        throw new ApiError(\"Unsaved document copies cannot have webhooks\", 400);\n      }\n\n      const { fields, url, authorization } = await getWebhookSettings(activeDoc, req, null, webhook);\n      if (!fields.eventTypes?.length) {\n        throw new ApiError(`eventTypes must be a non-empty array`, 400);\n      }\n      if (!isUrlAllowed(url)) {\n        throw new ApiError(\"Provided url is forbidden\", 403);\n      }\n      if (!fields.tableRef) {\n        throw new ApiError(`tableId is required`, 400);\n      }\n\n      const unsubscribeKey = uuidv4();\n      const webhookSecret: WebHookSecret = { unsubscribeKey, url, authorization };\n      const secretValue = JSON.stringify(webhookSecret);\n      const webhookId = (await this._dbManager.addSecret(secretValue, activeDoc.docName)).id;\n\n      try {\n        const webhookAction: WebhookAction = { type: \"webhook\", id: webhookId };\n        const sandboxRes = await handleSandboxError(\"_grist_Triggers\", [], activeDoc.applyUserActions(\n          docSessionFromRequest(req),\n          [[\"AddRecord\", \"_grist_Triggers\", null, {\n            enabled: true,\n            ...fields,\n            actions: JSON.stringify([webhookAction]),\n          }]]));\n        return {\n          unsubscribeKey,\n          triggerId: sandboxRes.retValues[0],\n          webhookId,\n        };\n      } catch (err) {\n        // remove webhook\n        await this._dbManager.removeWebhook(webhookId, activeDoc.docName, \"\", false);\n        throw err;\n      } finally {\n        await activeDoc.sendWebhookNotification();\n      }\n    };\n\n    function getWebhookTriggerRecord(activeDoc: ActiveDoc, webhookId: string) {\n      const docData = activeDoc.docData!;\n      const triggersTable = docData.getMetaTable(\"_grist_Triggers\");\n      const trigger = triggersTable.getRecords().find((t) => {\n        const actions: any[] = JSON.parse((t.actions || \"[]\") as string);\n        return actions.some(action => action.id === webhookId && action?.type === \"webhook\");\n      });\n      if (!trigger) {\n        throw new ApiError(`Webhook not found \"${webhookId || \"\"}\"`, 404);\n      }\n      return trigger;\n    }\n\n    const removeWebhook = async (activeDoc: ActiveDoc, req: RequestWithLogin, res: Response) => {\n      const { unsubscribeKey } = req.body as WebhookSubscription;\n      const webhookId = req.params.webhookId ?? req.body.webhookId;\n\n      // owner does not need to provide unsubscribeKey\n      const checkKey = !(await checkOwner(req));\n      const triggerRowId = getWebhookTriggerRecord(activeDoc, webhookId).id;\n      // Validate unsubscribeKey before deleting trigger from document\n      await this._dbManager.removeWebhook(webhookId, activeDoc.docName, unsubscribeKey, checkKey);\n      activeDoc.webhookQueue.clearWebhookCache(webhookId);\n      activeDoc.triggers.clearCache();\n\n      await handleSandboxError(\"_grist_Triggers\", [], activeDoc.applyUserActions(\n        docSessionFromRequest(req),\n        [[\"RemoveRecord\", \"_grist_Triggers\", triggerRowId]]));\n\n      await activeDoc.sendWebhookNotification();\n\n      res.json({ success: true });\n    };\n\n    async function getWebhookSettings(activeDoc: ActiveDoc, req: RequestWithLogin,\n      webhookId: string | null, webhook: WebhookFields) {\n      const metaTables = await getMetaTables(activeDoc, req);\n      const tablesTable = activeDoc.docData!.getMetaTable(\"_grist_Tables\");\n      const trigger = webhookId ? getWebhookTriggerRecord(activeDoc, webhookId) : undefined;\n      let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, \"tableId\")! : undefined;\n      const { url, authorization, eventTypes, watchedColIds, isReadyColumn, name } = webhook;\n      const tableId = await getRealTableId(req.params.tableId || webhook.tableId, { metaTables });\n\n      const fields: Partial<SchemaTypes[\"_grist_Triggers\"]> = {};\n\n      if (url && !isUrlAllowed(url)) {\n        throw new ApiError(\"Provided url is forbidden\", 403);\n      }\n\n      if (eventTypes) {\n        if (!eventTypes.length) {\n          throw new ApiError(`eventTypes must be a non-empty array`, 400);\n        }\n        fields.eventTypes = [GristObjCode.List, ...eventTypes];\n      }\n\n      if (tableId !== undefined) {\n        if (watchedColIds) {\n          if (tableId !== currentTableId && currentTableId) {\n            // if the tableId changed, we need to reset the watchedColIds\n            fields.watchedColRefList = [GristObjCode.List];\n          } else {\n            if (!tableId) {\n              throw new ApiError(`Cannot find columns \"${watchedColIds}\" because table is not known`, 404);\n            }\n            fields.watchedColRefList = [GristObjCode.List, ...watchedColIds\n              .filter(colId => colId.trim() !== \"\")\n              .map(\n                (colId) => { return colIdToReference(metaTables, tableId, colId.trim().replace(/^\\$/, \"\")); },\n              )];\n          }\n        } else {\n          fields.watchedColRefList = [GristObjCode.List];\n        }\n        fields.tableRef = tableIdToRef(metaTables, tableId);\n        currentTableId = tableId;\n      }\n\n      if (isReadyColumn !== undefined) {\n        // When isReadyColumn is defined let's explicitly change the ready column to the new col\n        // id, null or empty string being a special case that unsets it.\n        if (isReadyColumn !== null && isReadyColumn !== \"\") {\n          if (!currentTableId) {\n            throw new ApiError(`Cannot find column \"${isReadyColumn}\" because table is not known`, 404);\n          }\n          fields.isReadyColRef = colIdToReference(metaTables, currentTableId, isReadyColumn);\n        } else {\n          fields.isReadyColRef = 0;\n        }\n      } else if (tableId) {\n        // When isReadyColumn is undefined but tableId was changed, let's unset the ready column\n        fields.isReadyColRef = 0;\n      }\n\n      // assign other field properties\n      Object.assign(fields, _.pick(webhook, [\"enabled\", \"memo\", \"condition\"]));\n      if (name) {\n        fields.label = name;\n      }\n      return {\n        fields,\n        url,\n        authorization,\n      };\n    }\n\n    // Add a new webhook and trigger\n    this._app.post(\"/api/docs/:docId/webhooks\", isOwner, validate(WebhookSubscribeCollection),\n      withDocTriggersLock(async (activeDoc, req, res) => {\n        const registeredWebhooks: WebhookSubscription[] = [];\n        for (const webhook of req.body.webhooks) {\n          const registeredWebhook = await registerWebhook(activeDoc, req, webhook.fields);\n          registeredWebhooks.push(registeredWebhook);\n        }\n        res.json({ webhooks: registeredWebhooks.map((rw) => {\n          return { id: rw.webhookId };\n        }) });\n      }),\n    );\n\n    /**\n     @deprecated please call to POST /webhooks instead, this endpoint is only for sake of backward\n        compatibility\n     */\n    this._app.post(\"/api/docs/:docId/tables/:tableId/_subscribe\", isOwner, validate(WebhookSubscribe),\n      withDocTriggersLock(async (activeDoc, req, res) => {\n        const registeredWebhook = await registerWebhook(activeDoc, req, req.body);\n        res.json(registeredWebhook);\n      }),\n    );\n\n    // Clears all outgoing webhooks in the queue for this document.\n    this._app.delete(\"/api/docs/:docId/webhooks/queue\", isOwner,\n      withDocTriggersLock(async (activeDoc, req, res) => {\n        await activeDoc.clearWebhookQueue();\n        await activeDoc.sendWebhookNotification();\n        this._logClearAllWebhookQueueEvents(activeDoc, req);\n        res.json({ success: true });\n      }),\n    );\n\n    // Remove webhook and trigger created above\n    this._app.delete(\"/api/docs/:docId/webhooks/:webhookId\", isOwner,\n      withDocTriggersLock(removeWebhook),\n    );\n\n    /**\n     @deprecated please call to DEL /webhooks instead, this endpoint is only for sake of backward\n        compatibility\n     */\n    this._app.post(\"/api/docs/:docId/tables/:tableId/_unsubscribe\", canEdit,\n      withDocTriggersLock(removeWebhook),\n    );\n\n    // Update a webhook\n    this._app.patch(\n      \"/api/docs/:docId/webhooks/:webhookId\", isOwner, validate(WebhookPatch),\n      withDocTriggersLock(async (activeDoc, req, res) => {\n        const docId = activeDoc.docName;\n        const webhookId = req.params.webhookId;\n        const { fields, url, authorization } = await getWebhookSettings(activeDoc, req, webhookId, req.body);\n        if (fields.enabled === false) {\n          await activeDoc.clearSingleWebhookQueue(webhookId);\n        }\n\n        const triggerRowId = getWebhookTriggerRecord(activeDoc, webhookId).id;\n\n        // update url and authorization header in homedb\n        if (url || authorization) {\n          await this._dbManager.updateWebhookUrlAndAuth({ id: webhookId, docId, url, auth: authorization });\n          activeDoc.webhookQueue.clearWebhookCache(webhookId); // clear cache\n        }\n\n        // then update document\n        if (Object.keys(fields).length) {\n          activeDoc.triggers.clearCache();\n          await handleSandboxError(\"_grist_Triggers\", [], activeDoc.applyUserActions(\n            docSessionFromRequest(req),\n            [[\"UpdateRecord\", \"_grist_Triggers\", triggerRowId, fields]]));\n        }\n\n        await activeDoc.sendWebhookNotification();\n\n        res.json({ success: true });\n      }),\n    );\n\n    // Clears a single webhook in the queue for this document.\n    this._app.delete(\"/api/docs/:docId/webhooks/queue/:webhookId\", isOwner,\n      withDocTriggersLock(async (activeDoc, req, res) => {\n        const webhookId = req.params.webhookId;\n        await activeDoc.clearSingleWebhookQueue(webhookId);\n        await activeDoc.sendWebhookNotification();\n        this._logClearWebhookQueueEvents(activeDoc, req, webhookId);\n        res.json({ success: true });\n      }),\n    );\n\n    // Lists all webhooks and their current status in the document.\n    this._app.get(\"/api/docs/:docId/webhooks\", isOwner,\n      withDocTriggersLock(async (activeDoc, req, res) => {\n        res.json(await activeDoc.webhooksSummary());\n      }),\n    );\n  }\n\n  private _logClearWebhookQueueEvents(\n    activeDoc: ActiveDoc,\n    req: RequestWithLogin,\n    webhookId: string,\n  ) {\n    const document = activeDoc.doc || { id: activeDoc.docName };\n    activeDoc.logAuditEvent(req, {\n      action: \"document.clear_webhook_queue\",\n      details: {\n        document: _.pick(document, \"id\"),\n        webhook: {\n          id: webhookId,\n        },\n      },\n    });\n  }\n\n  private _logClearAllWebhookQueueEvents(\n    activeDoc: ActiveDoc,\n    req: RequestWithLogin,\n  ) {\n    const document = activeDoc.doc || { id: activeDoc.docName };\n    activeDoc.logAuditEvent(req, {\n      action: \"document.clear_all_webhook_queues\",\n      details: {\n        document: _.pick(document, \"id\"),\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "app/server/lib/DocApiUtils.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { handleSandboxErrorOnPlatform, TableOperationsPlatform } from \"app/plugin/TableOperationsImpl\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { docSessionFromRequest } from \"app/server/lib/DocSession\";\nimport log from \"app/server/lib/log\";\n\nimport { Request, RequestHandler, Response } from \"express\";\nimport { Checker } from \"ts-interface-checker\";\n\nexport type WithDocHandler = (activeDoc: ActiveDoc, req: RequestWithLogin, resp: Response) => Promise<void>;\n\n/**\n * Middleware for validating request's body with a Checker instance.\n */\nexport function validate(checker: Checker): RequestHandler {\n  return (req, res, next) => {\n    validateCore(checker, req, req.body);\n    next();\n  };\n}\n\nexport function validateCore(checker: Checker, req: Request, body: any) {\n  try {\n    checker.check(body);\n  } catch (err) {\n    log.warn(`Error during api call to ${req.path}: Invalid payload: ${String(err)}`);\n    throw new ApiError(\"Invalid payload\", 400, { userError: String(err) });\n  }\n}\n\nexport function getErrorPlatform(tableId: string): TableOperationsPlatform {\n  return {\n    async getTableId() { return tableId; },\n    throwError(verb, text, status) {\n      throw new ApiError(verb + (verb ? \" \" : \"\") + text, status);\n    },\n    applyUserActions() {\n      throw new Error(\"no document\");\n    },\n  };\n}\n\n/**\n * Handles sandbox errors for the given engine request using backend platform options.\n */\nexport async function handleSandboxError<T>(tableId: string, colNames: string[], p: Promise<T>): Promise<T> {\n  return handleSandboxErrorOnPlatform(tableId, colNames, p, getErrorPlatform(tableId));\n}\n\n/**\n * Fetches meta tables for the active document associated with the request.\n */\nexport async function getMetaTables(activeDoc: ActiveDoc, req: RequestWithLogin) {\n  return await handleSandboxError(\"\", [],\n    activeDoc.fetchMetaTables(docSessionFromRequest(req)));\n}\n"
  },
  {
    "path": "app/server/lib/DocAuthorizer.ts",
    "content": "import { OpenDocMode } from \"app/common/DocListAPI\";\n// import {Document} from 'app/gen-server/entity/Document';\nimport { Role } from \"app/common/roles\";\nimport { DocAuthKey, DocAuthResult, HomeDBDocAuth } from \"app/gen-server/lib/homedb/Interfaces\";\nimport { assertAccess } from \"app/server/lib/Authorizer\";\nimport { AuthSession } from \"app/server/lib/AuthSession\";\n\n/**\n *\n * Handle authorization for a single document accessed by a given user.\n *\n */\nexport interface DocAuthorizer {\n  getAuthKey(): DocAuthKey;\n\n  // Check access, throw error if the requested level of access isn't available.\n  assertAccess(role: \"viewers\" | \"editors\" | \"owners\"): Promise<void>;\n\n  // Get the lasted access information calculated for the doc.  This is useful\n  // for logging - but access control itself should use assertAccess() to\n  // ensure the data is fresh.\n  getCachedAuth(): DocAuthResult;\n}\n\ninterface DocAuthorizerOptions {\n  dbManager: HomeDBDocAuth;\n  urlId: string;\n  openMode: OpenDocMode;\n  authSession: AuthSession;\n}\n\n/**\n *\n * Handle authorization for a single document and user.\n *\n */\nexport class DocAuthorizerImpl implements DocAuthorizer {\n  public readonly openMode: OpenDocMode;\n  private _key: DocAuthKey;\n  private _docAuth?: DocAuthResult;\n  constructor(\n    private _options: DocAuthorizerOptions,\n  ) {\n    this.openMode = _options.openMode;\n    const { dbManager, authSession } = _options;\n    const userId = authSession.userId || dbManager.getAnonymousUserId();\n    this._key = { urlId: _options.urlId, userId, org: authSession.org || \"\" };\n  }\n\n  public getAuthKey(): DocAuthKey {\n    return this._key;\n  }\n\n  public async assertAccess(role: \"viewers\" | \"editors\" | \"owners\"): Promise<void> {\n    const docAuth = await this._options.dbManager.getDocAuthCached(this._key);\n    this._docAuth = docAuth;\n    assertAccess(role, docAuth, { openMode: this.openMode });\n  }\n\n  public getCachedAuth(): DocAuthResult {\n    if (!this._docAuth) { throw Error(\"no cached authentication\"); }\n    return this._docAuth;\n  }\n}\n\nexport class DummyAuthorizer implements DocAuthorizer {\n  constructor(public role: Role | null, public docId: string) {}\n  public getAuthKey(): DocAuthKey { throw new Error(\"Not supported in standalone\"); }\n  public async assertAccess() { /* noop */ }\n  public getCachedAuth(): DocAuthResult {\n    return {\n      access: this.role,\n      docId: this.docId,\n      removed: false,\n      disabled: false,\n    };\n  }\n}\n"
  },
  {
    "path": "app/server/lib/DocClients.ts",
    "content": "/**\n * Module to manage the clients of an ActiveDoc. It keeps track of how many clients have the doc\n * open, and what FD they are using.\n */\n\nimport { CommDocEventType, CommMessage } from \"app/common/CommTypes\";\nimport { arrayRemove, timeoutReached } from \"app/common/gutil\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { Client } from \"app/server/lib/Client\";\nimport { DocSession, DocSessionPrecursor } from \"app/server/lib/DocSession\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\n\nimport EventEmitter from \"events\";\n\nexport const Deps = {\n  // Allow tests to impose a serial order for broadcasts if they need that for repeatability.\n  BROADCAST_ORDER: \"parallel\" as \"parallel\" | \"series\",\n  BROADCAST_TIMEOUT_MS: appSettings.section(\"client\").flag(\"broadcastTimeoutMs\").requireInt({\n    envVar: \"GRIST_BROADCAST_TIMEOUT_MS\",\n    defaultValue: 60_000,\n  }),\n  ENABLE_USER_PRESENCE: appSettings.section(\"userPresence\").flag(\"enable\").readBool({\n    envVar: \"GRIST_ENABLE_USER_PRESENCE\",\n    defaultValue: true,\n  }),\n};\n\nexport function isUserPresenceDisabled(): boolean {\n  return !Deps.ENABLE_USER_PRESENCE;\n}\n\nexport type ClientAddedEventListener = (session: DocSession) => void;\nexport type ClientRemovedEventListener = (session: DocSession) => void;\n\nexport class DocClients extends EventEmitter {\n  private _docSessions: DocSession[] = [];\n  private _log = new LogMethods(\"DocClients \", (s: DocSession | null) => this.activeDoc.getLogMeta(s));\n\n  constructor(\n    public readonly activeDoc: ActiveDoc,\n  ) {\n    super();\n  }\n\n  /**\n   * Returns the number of connected clients.\n   */\n  public clientCount(): number {\n    return this._docSessions.length;\n  }\n\n  /**\n   * Adds a client's open file to the list of connected clients.\n   */\n  public addClient(client: Client, docSessionPrecursor: DocSessionPrecursor): DocSession {\n    const docSession = client.addDocSession(this.activeDoc, docSessionPrecursor);\n    this._docSessions.push(docSession);\n    this._log.debug(docSession, \"now %d clients; new client is %s (fd %s)\",\n      this._docSessions.length, client.clientId, docSession.fd);\n    this._emitClientAdded(docSession);\n    return docSession;\n  }\n\n  public addClientAddedListener(listener: ClientAddedEventListener) {\n    this.on(\"clientAdded\", listener);\n  }\n\n  /**\n   * Removes a client from the list of connected clients for this document. In other words, closes\n   * this DocSession.\n   */\n  public removeClient(docSession: DocSession): void {\n    this._log.debug(docSession, \"removeClient\", docSession.client.clientId);\n    docSession.client.removeDocSession(docSession.fd);\n\n    if (arrayRemove(this._docSessions, docSession)) {\n      this._log.debug(docSession, \"now %d clients\", this._docSessions.length);\n    }\n\n    this._emitClientRemoved(docSession);\n  }\n\n  public addClientRemovedListener(listener: ClientRemovedEventListener) {\n    this.on(\"clientRemoved\", listener);\n  }\n\n  /**\n   * Removes all active clients from this document, i.e. closes all DocSessions.\n   */\n  public removeAllClients(): void {\n    this._log.debug(null, \"removeAllClients() removing %s docSessions\", this._docSessions.length);\n    const docSessions = this._docSessions.splice(0);\n    for (const docSession of docSessions) {\n      docSession.client.removeDocSession(docSession.fd);\n      this._emitClientRemoved(docSession);\n    }\n  }\n\n  public interruptAllClients() {\n    this._log.debug(null, \"interruptAllClients() interrupting %s docSessions\", this._docSessions.length);\n    for (const docSession of this._docSessions) {\n      docSession.client.interruptConnection();\n    }\n  }\n\n  /**\n   * Broadcasts a message to all clients of this document using Comm.sendDocMessage. Also sends all\n   * docAction to active doc's plugin manager.\n   * @param {Object} client: Originating client used to set the `fromSelf` flag in the message.\n   * @param {String} type: The type of the message, e.g. 'docUserAction'.\n   * @param {Object} messageData: The data for this type of message.\n   * @param {Object} filterMessage: Optional callback to filter message per client.\n   */\n  public async broadcastDocMessage(client: Client | null, type: CommDocEventType, messageData: any,\n    filterMessage?: (docSession: DocSession,\n      messageData: any) => Promise<any>): Promise<void> {\n    const send = async (target: DocSession) => {\n      const msg = await this._prepareMessage(target, type, messageData, filterMessage);\n      if (msg) {\n        const fromSelf = (target.client === client);\n        const isUnresponsive = await timeoutReached(\n          Deps.BROADCAST_TIMEOUT_MS,\n          target.client.sendMessageOrInterrupt({ ...msg, docFD: target.fd, fromSelf } as CommMessage),\n        );\n        // If client isn't responsive in a reasonable length of time, then don't\n        // keep waiting for it. BUT then the client state could get weird if it\n        // was just temporarily slow and future messages get through. So just\n        // declare bankruptcy on this connection and let the client try to\n        // reconnect and get back in a good state if it can.\n        if (isUnresponsive) {\n          target.client.interruptConnection();\n        }\n      }\n    };\n\n    if (Deps.BROADCAST_ORDER === \"parallel\") {\n      await Promise.all(this._docSessions.map(send));\n    } else {\n      for (const session of this._docSessions) {\n        await send(session);\n      }\n    }\n    if (type === \"docUserAction\" && messageData.docActions) {\n      for (const action of messageData.docActions) {\n        this.activeDoc.docPluginManager?.receiveAction(action);\n      }\n    }\n  }\n\n  /**\n   * Prepares a message to a single client. See broadcastDocMessage for parameters.\n   */\n  private async _prepareMessage(\n    target: DocSession, type: CommDocEventType, messageData: any,\n    filterMessage?: (docSession: DocSession, messageData: any) => Promise<any>,\n  ): Promise<{ type: CommDocEventType, data: unknown } | undefined> {\n    try {\n      // Make sure user still has view access.\n      await target.authorizer.assertAccess(\"viewers\");\n      if (!filterMessage) {\n        return { type, data: messageData };\n      } else {\n        try {\n          const filteredMessageData = await filterMessage(target, messageData);\n          if (filteredMessageData) {\n            return { type, data: filteredMessageData };\n          } else {\n            this._log.debug(target, \"skip broadcastDocMessage because it is not allowed for this client\");\n          }\n        } catch (e) {\n          if (e.code && e.code === \"NEED_RELOAD\") {\n            return { type: \"docShutdown\", data: null };\n          } else {\n            return { type: \"docUserAction\", data: { error: String(e) } };\n          }\n        }\n      }\n    } catch (e) {\n      if (e.code === \"AUTH_NO_VIEW\") {\n        // Skip sending data to this user, they have no view access.\n        this._log.debug(target, \"skip broadcastDocMessage because AUTH_NO_VIEW\");\n        // Go further and trigger a shutdown for this user, in case they are granted\n        // access again later.\n        return { type: \"docShutdown\", data: null };\n      } else {\n        // Propagate any totally unexpected exceptions.\n        throw e;\n      }\n    }\n  }\n\n  private _emitClientAdded(session: DocSession) {\n    this.emit(\"clientAdded\", session);\n  }\n\n  private _emitClientRemoved(session: DocSession) {\n    this.emit(\"clientRemoved\", session);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/DocManager.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { mapSetOrClear, MapWithTTL } from \"app/common/AsyncCreate\";\nimport { BrowserSettings } from \"app/common/BrowserSettings\";\nimport { delay } from \"app/common/delay\";\nimport { DocCreationInfo, DocEntry, DocListAPI,\n  OpenDocMode, OpenDocOptions, OpenLocalDocResult } from \"app/common/DocListAPI\";\nimport { DocumentSettings, DocumentSettingsChecker } from \"app/common/DocumentSettings\";\nimport { FilteredDocUsageSummary } from \"app/common/DocUsage\";\nimport { parseUrlId } from \"app/common/gristUrls\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { tbind } from \"app/common/tbind\";\nimport { TelemetryMetadataByLevel } from \"app/common/Telemetry\";\nimport { NEW_DOCUMENT_CODE } from \"app/common/UserAPI\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport {\n  getConfiguredStandardAttachmentStore,\n  IAttachmentStoreProvider,\n} from \"app/server/lib/AttachmentStoreProvider\";\nimport { isSingleUserMode, RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { Client } from \"app/server/lib/Client\";\nimport { DocAuthorizer, DocAuthorizerImpl, DummyAuthorizer } from \"app/server/lib/DocAuthorizer\";\nimport { DocSessionPrecursor,\n  makeExceptionalDocSession, makeOptDocSession, OptDocSession } from \"app/server/lib/DocSession\";\nimport * as docUtils from \"app/server/lib/docUtils\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { IDocStorageManager } from \"app/server/lib/IDocStorageManager\";\nimport { makeForkIds, makeId } from \"app/server/lib/idUtils\";\nimport { insightLogDecorate, insightLogEntry } from \"app/server/lib/InsightLog\";\nimport log from \"app/server/lib/log\";\nimport { PluginManager } from \"app/server/lib/PluginManager\";\nimport { getScope } from \"app/server/lib/requestUtils\";\nimport { checkAllegedGristDoc } from \"app/server/lib/serverUtils\";\nimport { getDocSessionCachedDoc } from \"app/server/lib/sessionUtils\";\nimport { OpenMode, SQLiteDB } from \"app/server/lib/SQLiteDB\";\nimport { getFileUploadInfo, globalUploadSet, makeAccessId, UploadInfo } from \"app/server/lib/uploads\";\n\nimport { EventEmitter } from \"events\";\nimport * as path from \"path\";\n\nimport isDeepEqual from \"lodash/isEqual\";\nimport merge from \"lodash/merge\";\nimport noop from \"lodash/noop\";\nimport pidusage from \"pidusage\";\n\n// A TTL in milliseconds to use for material that can easily be recomputed / refetched\n// but is a bit of a burden under heavy traffic.\nexport const DEFAULT_CACHE_TTL = 10000;\n\n// How long to remember that a document has been explicitly set in a\n// recovery mode.\nexport const RECOVERY_CACHE_TTL = 30000; // 30 seconds\n\n// How long to remember the timing mode of a document.\nexport const TIMING_ON_CACHE_TTL = 10 * 60 * 1000; // 10 minutes\n\nexport interface IMemoryLoadEstimator {\n  getTotalMemoryUsedMB(): number;\n}\n\n/**\n * DocManager keeps track of \"active\" Grist documents, i.e. those loaded\n * in-memory, with clients connected to them.\n */\nexport class DocManager extends EventEmitter implements IMemoryLoadEstimator {\n  /**\n   * Maps docName to promise for ActiveDoc object. Most of the time the promise\n   * will be long since resolved, with the resulting document cached.\n   */\n  private _activeDocs = new Map<string, Promise<ActiveDoc>>();\n\n  /**\n   * Maps ActiveDoc to memory used in MB.\n   */\n  private _memoryUsedMB = new Map<ActiveDoc, number>();\n\n  /**\n   * Maps docName to the SQLiteDB object, if available. The db may be\n   * closed by the time you read or use it.\n   */\n  private _sqliteDbs = new Map<string, SQLiteDB>();\n\n  // Remember recovery mode of documents.\n  private _inRecovery = new MapWithTTL<string, boolean>(RECOVERY_CACHE_TTL);\n\n  // Remember timing mode of documents, when document is recreated it is put in the same mode.\n  private _inTimingOn = new MapWithTTL<string, boolean>(TIMING_ON_CACHE_TTL);\n\n  constructor(\n    public readonly storageManager: IDocStorageManager,\n    public readonly pluginManager: PluginManager | null,\n    private _homeDbManager: HomeDBManager | null,\n    private _attachmentStoreProvider: IAttachmentStoreProvider,\n    public gristServer: GristServer,\n  ) {\n    super();\n  }\n\n  public setRecovery(docId: string, recovery: boolean) {\n    this._inRecovery.set(docId, recovery);\n  }\n\n  /**\n   * Will restore timing on a document when it is reloaded.\n   */\n  public restoreTimingOn(docId: string, timingOn: boolean) {\n    this._inTimingOn.set(docId, timingOn);\n  }\n\n  // attach a home database to the DocManager.  During some tests, it\n  // is awkward to have this set up at the point of construction.\n  public testSetHomeDbManager(dbManager: HomeDBManager) {\n    this._homeDbManager = dbManager;\n  }\n\n  public getHomeDbManager() {\n    return this._homeDbManager;\n  }\n\n  /**\n   * Returns an implementation of the DocListAPI for the given Client object.\n   */\n  public getDocListAPIImpl(client: Client): DocListAPI {\n    return {\n      getDocList: tbind(this.listDocs, this, client),\n      createNewDoc: tbind(this.createNewDoc, this, client),\n      importSampleDoc: tbind(this.importSampleDoc, this, client),\n      importDoc: tbind(this.importDoc, this, client),\n      deleteDoc: tbind(this.deleteDoc, this, client),\n      renameDoc: tbind(this.renameDoc, this, client),\n      openDoc: tbind(this.openDoc, this, client),\n    };\n  }\n\n  /**\n   * Returns the number of currently open docs.\n   */\n  public numOpenDocs(): number {\n    return this._activeDocs.size;\n  }\n\n  /**\n   * Returns a Map from docId to number of connected clients for each doc.\n   */\n  public async getDocClientCounts(): Promise<Map<string, number>> {\n    const values = await Promise.all(Array.from(this._activeDocs.values(), async (adocPromise) => {\n      const adoc = await adocPromise;\n      return [adoc.docName, adoc.docClients.clientCount()] as [string, number];\n    }));\n    return new Map(values);\n  }\n\n  /**\n   * Returns a promise for all known Grist documents and document invites to show in the doc list.\n   */\n  public async listDocs(client: Client): Promise<{ docs: DocEntry[], docInvites: DocEntry[] }> {\n    const docs = await this.storageManager.listDocs();\n    return { docs, docInvites: [] };\n  }\n\n  /**\n   * Creates a new document, fetches it, and adds a table to it.\n   * @returns {Promise:String} The name of the new document.\n   */\n  public async createNewDoc(client: Client): Promise<string> {\n    log.debug(\"DocManager.createNewDoc\");\n    const docSession = makeExceptionalDocSession(\"nascent\", { client });\n    return this.createNamedDoc(docSession, \"Untitled\");\n  }\n\n  /**\n   * Add an ActiveDoc created externally. This is a hook used by\n   * grist-static.\n   */\n  public addActiveDoc(docId: string, activeDoc: ActiveDoc) {\n    this._activeDocs.set(docId, Promise.resolve(activeDoc));\n  }\n\n  public async createNamedDoc(docSession: OptDocSession, docId: string): Promise<string> {\n    const activeDoc: ActiveDoc = await this.createNewEmptyDoc(docSession, docId);\n    await activeDoc.addInitialTable(docSession);\n    return activeDoc.docName;\n  }\n\n  /**\n   * Creates a new document, fetches it, and adds a table to it.\n   * @param {String} sampleDocName: Doc name of a sample document.\n   * @returns {Promise:String} The name of the new document.\n   */\n  public async importSampleDoc(client: Client, sampleDocName: string): Promise<string> {\n    const sourcePath = this.storageManager.getSampleDocPath(sampleDocName);\n    if (!sourcePath) {\n      throw new Error(`no path available to sample ${sampleDocName}`);\n    }\n    log.info(\"DocManager.importSampleDoc importing\", sourcePath);\n    const basenameHint = path.basename(sampleDocName);\n    const targetName = await docUtils.createNumbered(basenameHint, \"-\",\n      (name: string) => docUtils.createExclusive(this.storageManager.getPath(name)));\n\n    const targetPath = this.storageManager.getPath(targetName);\n    log.info(\"DocManager.importSampleDoc saving as\", targetPath);\n    await docUtils.copyFile(sourcePath, targetPath);\n    return targetName;\n  }\n\n  /**\n   * Processes an upload, containing possibly multiple files, to create a single new document, and\n   * returns the new document's name/id.\n   */\n  public async importDoc(client: Client, uploadId: number): Promise<string> {\n    const userId = this._homeDbManager ? client.authSession.requiredUserId() : null;\n    const result = await this._doImportDoc(makeOptDocSession(client),\n      globalUploadSet.getUploadInfo(uploadId, this.makeAccessId(userId)), { naming: \"classic\" });\n    return result.id;\n  }\n\n  // Import a document, assigning it a unique id distinct from its title. Cleans up uploadId.\n  public importDocWithFreshId(docSession: OptDocSession, userId: number, uploadId: number): Promise<DocCreationInfo> {\n    const accessId = this.makeAccessId(userId);\n    return this._doImportDoc(docSession, globalUploadSet.getUploadInfo(uploadId, accessId),\n      { naming: \"saved\" });\n  }\n\n  /**\n   * Do an import targeted at a specific workspace.\n   *\n   * `userId` should correspond to the user making the request.\n   *\n   * If workspaceId is omitted, an unsaved doc unassociated with a specific workspace\n   * will be created.\n   *\n   * Cleans up `uploadId` and returns creation info about the imported doc.\n   */\n  public async importDocToWorkspace(mreq: RequestWithLogin, options: {\n    userId: number,\n    uploadId: number,\n    documentName?: string,\n    workspaceId?: number,\n    browserSettings?: BrowserSettings,\n    telemetryMetadata?: TelemetryMetadataByLevel,\n  }): Promise<DocCreationInfo> {\n    if (!this._homeDbManager) { throw new Error(\"HomeDbManager not available\"); }\n\n    const { userId, uploadId, documentName, workspaceId, browserSettings, telemetryMetadata } = options;\n    const accessId = this.makeAccessId(userId);\n    const docSession = makeExceptionalDocSession(\"nascent\", { browserSettings });\n    const register = async (docId: string, uploadBaseFilename: string) => {\n      if (workspaceId === undefined || !this._homeDbManager) { return; }\n      const queryResult = await this._homeDbManager.addDocument(\n        { userId },\n        workspaceId,\n        { name: documentName ?? uploadBaseFilename },\n        docId,\n      );\n      if (queryResult.status !== 200) {\n        // TODO The ready-to-add document is not yet in storageManager, but is in the filesystem. It\n        // should get cleaned up in case of error here.\n        throw new ApiError(queryResult.errMessage || \"unable to add imported document\", queryResult.status);\n      }\n    };\n    const uploadInfo = globalUploadSet.getUploadInfo(uploadId, accessId);\n    const docCreationInfo = await this._doImportDoc(docSession, uploadInfo, {\n      naming: workspaceId ? \"saved\" : \"unsaved\",\n      register,\n      userId,\n    });\n    this.gristServer.getTelemetry().logEvent(mreq, \"documentCreated\", merge({\n      limited: {\n        docIdDigest: docCreationInfo.id,\n        fileType: uploadInfo.files[0].ext.trim().slice(1),\n        isSaved: workspaceId !== undefined,\n      },\n    }, telemetryMetadata));\n    return docCreationInfo;\n    // The imported document is associated with the worker that did the import.\n    // We could break that association (see /api/docs/:docId/assign for how) if\n    // we start using dedicated import workers.\n  }\n\n  /**\n   * Imports file at filepath into the app by creating a new document and adding the file to\n   *  the documents directory.\n   * @param {String} filepath - Path to the current location of the file on the server.\n   * @returns {Promise:String} The name of the new document.\n   */\n  public async importNewDoc(filepath: string): Promise<DocCreationInfo> {\n    const uploadId = globalUploadSet.registerUpload([await getFileUploadInfo(filepath)], null, noop, null);\n    return await this._doImportDoc(makeOptDocSession(null), globalUploadSet.getUploadInfo(uploadId, null),\n      { naming: \"classic\" });\n  }\n\n  /**\n   * Deletes the Grist files and directories for a given document name.\n   * @param {String} docName - The name of the Grist document to be deleted.\n   * @returns {Promise:String} The name of the deleted Grist document.\n   *\n   */\n  public async deleteDoc(client: Client | null, docName: string, deletePermanently: boolean): Promise<string> {\n    log.debug(\"DocManager.deleteDoc starting for %s\", docName);\n    const docPromise = this._activeDocs.get(docName);\n    if (docPromise) {\n      // Call activeDoc's shutdown method first, to remove the doc from internal structures.\n      const doc: ActiveDoc = await docPromise;\n      log.debug(\"DocManager.deleteDoc starting activeDoc shutdown\", docName);\n      await doc.shutdown();\n    }\n    await this.storageManager.deleteDoc(docName, deletePermanently);\n    return docName;\n  }\n\n  /**\n   * Interrupt all clients, forcing them to reconnect.  Handy when a document has changed\n   * status in some major way that affects access rights, such as being deleted or disabled.\n   */\n  public async interruptDocClients(docName: string) {\n    const docPromise = this._activeDocs.get(docName);\n    if (docPromise) {\n      const doc: ActiveDoc = await docPromise;\n      doc.docClients.interruptAllClients();\n    }\n  }\n\n  /**\n   * Opens a document. Adds the client as a subscriber to the document, and fetches and returns the\n   * document's metadata.\n   * @returns {Promise:Object} An object with properties:\n   *      `docFD` - the descriptor to use in further methods and messages about this document,\n   *      `doc` - the object with metadata tables.\n   */\n  @insightLogDecorate(\"DocManager\")\n  public async openDoc(client: Client, docId: string,\n    options?: OpenDocOptions): Promise<OpenLocalDocResult> {\n    if (typeof options === \"string\") {\n      throw new Error(\"openDoc call with outdated parameter type\");\n    }\n\n    const insightLog = insightLogEntry();\n    insightLog?.addMeta(client.getLogMeta());\n    insightLog?.addMeta({ docId });\n\n    const openMode: OpenDocMode = options?.openMode || \"default\";\n    const linkParameters = options?.linkParameters || {};\n    const originalUrlId = options?.originalUrlId;\n    let auth: DocAuthorizer;\n    const dbManager = this._homeDbManager;\n    if (!isSingleUserMode()) {\n      if (!dbManager) { throw new Error(\"HomeDbManager not available\"); }\n      // Sets up authorization of the document.\n      const org = client.authSession.org;\n      if (!org) { throw new Error(\"Documents can only be opened in the context of a specific organization\"); }\n\n      // We use docId in the key, and disallow urlId, so we can be sure that we are looking at the\n      // right doc when we re-query the DB over the life of the websocket.\n      const useShareUrlId = Boolean(originalUrlId && parseUrlId(originalUrlId).shareKey);\n      const urlId = useShareUrlId ? originalUrlId! : docId;\n      auth = new DocAuthorizerImpl({ dbManager, urlId, openMode, authSession: client.authSession });\n      await auth.assertAccess(\"viewers\");\n      const docAuth = auth.getCachedAuth();\n      if (docAuth.docId !== docId) {\n        // The only plausible way to end up here is if we called openDoc with a urlId rather\n        // than a docId.\n        throw new Error(`openDoc expected docId ${docAuth.docId} not urlId ${docId}`);\n      }\n    } else {\n      log.debug(`DocManager.openDoc not using authorization for ${docId} because GRIST_SINGLE_USER`);\n      auth = new DummyAuthorizer(\"owners\", docId);\n    }\n\n    const docSessionPrecursor: DocSessionPrecursor = new DocSessionPrecursor(client, auth, { linkParameters });\n    insightLog?.mark(\"openDocAuth\");\n\n    // Fetch the document, and continue when we have the ActiveDoc (which may be immediately).\n    return this._withUnmutedDoc(docSessionPrecursor, docId, async () => {\n      const activeDoc: ActiveDoc = await this.fetchDoc(docSessionPrecursor, docId);\n      insightLog?.mark(\"fetchDoc\");\n\n      // Get a fresh DocSession object.\n      const docSession = activeDoc.addClient(client, docSessionPrecursor);\n\n      // If opening in (pre-)fork mode, check if it is appropriate to treat the user as\n      // an owner for granular access purposes.\n      if (openMode === \"fork\") {\n        if (await activeDoc.canForkAsOwner(docSession)) {\n          // Mark the session specially and flush any cached access\n          // information.  It is easier to make this a property of the\n          // session than to try computing it later in the heat of\n          // battle, since it introduces a loop where a user property\n          // (user.Access) depends on evaluating rules, but rules need\n          // the user properties in order to be evaluated.  It is also\n          // somewhat justifiable even if permissions change later on\n          // the theory that the fork is theoretically happening at this\n          // instance).\n          docSession.forkingAsOwner = true;\n          activeDoc.flushAccess(docSession);\n        } else {\n          // TODO: it would be kind to pass on a message to the client\n          // to let them know they won't be able to fork.  They'll get\n          // an error when they make their first change.  But currently\n          // we only have the blunt instrument of throwing an error,\n          // which would prevent access to the document entirely.\n        }\n      }\n\n      const [metaTables, recentActions, user, userOverride] = await Promise.all([\n        activeDoc.fetchMetaTables(docSession),\n        activeDoc.getRecentMinimalActions(docSession),\n        activeDoc.getUser(docSession),\n        activeDoc.getUserOverride(docSession),\n      ]);\n      insightLog?.mark(\"fetchOther\");\n\n      let docUsage: FilteredDocUsageSummary | undefined;\n      try {\n        docUsage = await activeDoc.getFilteredDocUsageSummary(docSession);\n      } catch (e) {\n        log.warn(\"DocManager.openDoc failed to get doc usage\", e);\n      }\n      insightLog?.mark(\"getDocUsage\");\n\n      const result: OpenLocalDocResult = {\n        docFD: docSession.fd,\n        clientId: docSession.client.clientId,\n        doc: metaTables,\n        log: recentActions,\n        recoveryMode: activeDoc.recoveryMode,\n        user: user.toUserInfo(),\n        userOverride,\n        docUsage,\n        isTimingOn: activeDoc.isTimingOn,\n      };\n\n      if (!activeDoc.muted) {\n        this.emit(\"open-doc\", this.storageManager.getPath(activeDoc.docName));\n      }\n\n      this.gristServer.getTelemetry().logEvent(docSession, \"openedDoc\", {\n        full: {\n          docIdDigest: docId,\n          userId: client.authSession.userId,\n          altSessionId: client.authSession.altSessionId,\n        },\n      });\n\n      return { activeDoc, result };\n    });\n  }\n\n  /**\n   * Shut down all open docs.\n   */\n  public async shutdownDocs() {\n    await Promise.all(Array.from(\n      this._activeDocs.values(),\n      adocPromise => adocPromise.then(async (adoc) => {\n        log.debug(\"DocManager.shutdownDocs starting activeDoc shutdown\", adoc.docName);\n        await adoc.shutdown();\n      })));\n  }\n\n  /**\n   * Shut down all open docs, including doc storage and any related timers.\n   *\n   * This is called, in particular, on server shutdown.\n   */\n  public async shutdownAll() {\n    await this.shutdownDocs();\n    try {\n      await this.storageManager.closeStorage();\n    } catch (err) {\n      log.error(\"DocManager had problem shutting down storage: %s\", err.message);\n    }\n\n    // Clear any timeouts we might have.\n    this._inRecovery.clear();\n    this._inTimingOn.clear();\n\n    // Clear the setInterval that the pidusage module sets up internally.\n    pidusage.clear();\n  }\n\n  // Access a document by name.\n  public getActiveDoc(docName: string): Promise<ActiveDoc> | undefined {\n    return this._activeDocs.get(docName);\n  }\n\n  /**\n   * ActiveDoc uses this to register the SQLiteDB associated with it,\n   * when there is one. It might seem easier just to get it from\n   * activeDoc.docStorage when you need it, but you can end\n   * up in a loop or hung if you are checking during document\n   * initialization.\n   */\n  public registerSQLiteDB(docName: string, db: SQLiteDB) {\n    this._sqliteDbs.set(docName, db);\n  }\n\n  /**\n   * Remove any registered SQLiteDB for the document.\n   */\n  public unregisterSQLiteDB(docName: string) {\n    this._sqliteDbs.delete(docName);\n  }\n\n  /**\n   * Get the SQLiteDB backing an ActiveDoc, if there is one right\n   * now. If you get one, remember it could be closed at any time.\n   */\n  public getSQLiteDB(docName: string): SQLiteDB | undefined {\n    return this._sqliteDbs.get(docName);\n  }\n\n  public removeActiveDoc(activeDoc: ActiveDoc): void {\n    this.unregisterSQLiteDB(activeDoc.docName);\n    this._activeDocs.delete(activeDoc.docName);\n    this._memoryUsedMB.delete(activeDoc);\n  }\n\n  public async renameDoc(client: Client, oldName: string, newName: string): Promise<void> {\n    log.debug(\"DocManager.renameDoc %s -> %s\", oldName, newName);\n    const docPromise = this._activeDocs.get(oldName);\n    if (docPromise) {\n      const adoc: ActiveDoc = await docPromise;\n      await adoc.renameDocTo(makeOptDocSession(client), newName);\n      this._activeDocs.set(newName, docPromise);\n      const db = this._sqliteDbs.get(oldName);\n      if (db) {\n        this.registerSQLiteDB(newName, db);\n      }\n      this._activeDocs.delete(oldName);\n      this.unregisterSQLiteDB(oldName);\n    } else {\n      await this.storageManager.renameDoc(oldName, newName);\n    }\n  }\n\n  public markAsChanged(activeDoc: ActiveDoc, reason?: \"edit\") {\n    // Ignore changes if document is muted or in the middle of a migration.\n    if (!activeDoc.muted && !activeDoc.isMigrating()) {\n      this.storageManager.markAsChanged(activeDoc.docName, reason);\n    }\n  }\n\n  public async makeBackup(activeDoc: ActiveDoc, name: string): Promise<string> {\n    if (activeDoc.muted) { throw new Error(\"Document is disabled\"); }\n    return this.storageManager.makeBackup(activeDoc.docName, name);\n  }\n\n  /**\n   * Validate and copy an uploaded .grist file into the doc's storage path.\n   * Runs an SQLite integrity check on the source, copies it into place,\n   * and fixes up attachment store settings that may reference external stores\n   * not available in this environment.\n   */\n  public async importGristDoc(docSession: OptDocSession, docId: string, srcDocPath: string): Promise<void> {\n    // TODO: We should be skeptical of the upload file to close a possible\n    // security vulnerability. See https://phab.getgrist.com/T457.\n    const docPath: string = this.storageManager.getPath(docId);\n    await checkAllegedGristDoc(docSession, srcDocPath);\n    await docUtils.copyFile(srcDocPath, docPath);\n    await updateDocumentAttachmentStoreSettingToValidValue(docPath, this._attachmentStoreProvider);\n  }\n\n  /**\n   * Helper function for creating a new empty document that also emits an event.\n   * @param docSession The client session.\n   * @param basenameHint Suggested base name to use (no directory, no extension).\n   */\n  public async createNewEmptyDoc(docSession: OptDocSession, basenameHint: string): Promise<ActiveDoc> {\n    const docName = await this._createNewDoc(basenameHint);\n    return mapSetOrClear(this._activeDocs, docName,\n      this._createActiveDoc(docSession, docName)\n        .then(newDoc => newDoc.createEmptyDoc(docSession)));\n  }\n\n  /**\n   * Fetches an ActiveDoc object. Used by openDoc. If ActiveDoc is muted (for safe closing),\n   * wait for another.\n   */\n  public async fetchDoc(docSession: OptDocSession, docName: string,\n    wantRecoveryMode?: boolean): Promise<ActiveDoc> {\n    log.debug(\"DocManager.fetchDoc\", docName);\n    return this._withUnmutedDoc(docSession, docName, async () => {\n      const activeDoc = await this._fetchPossiblyMutedDoc(docSession, docName, wantRecoveryMode);\n      return { activeDoc, result: activeDoc };\n    });\n  }\n\n  public makeAccessId(userId: number | null): string | null {\n    return makeAccessId(this.gristServer, userId);\n  }\n\n  public isAnonymous(userId: number): boolean {\n    if (!this._homeDbManager) { throw new Error(\"HomeDbManager not available\"); }\n    return userId === this._homeDbManager.getAnonymousUserId();\n  }\n\n  public setMemoryUsedMB(activeDoc: ActiveDoc, memoryUsedMB: number) {\n    this._memoryUsedMB.set(activeDoc, memoryUsedMB);\n  }\n\n  public getTotalMemoryUsedMB(): number {\n    let result = 0;\n    for (const value of this._memoryUsedMB.values()) {\n      result += value;\n    }\n    return result;\n  }\n\n  /**\n   * Perform the supplied operation and return its result - unless the activeDoc it returns\n   * is found to be muted, in which case we retry.\n   */\n  private async _withUnmutedDoc<T>(docSession: OptDocSession, docName: string,\n    op: () => Promise<{ result: T, activeDoc: ActiveDoc }>): Promise<T> {\n    // Repeat until we acquire an ActiveDoc that is not muted (shutting down).\n    let markedAsMuted = false;\n    for (;;) {\n      const { result, activeDoc } = await op();\n      if (!activeDoc.muted) { return result; }\n      if (!markedAsMuted) {\n        insightLogEntry()?.mark(\"docIsMuted\");    // Mark the *first* time we find the doc muted.\n        markedAsMuted = true;\n      }\n      log.debug(\"DocManager._withUnmutedDoc waiting because doc is muted\", docName);\n      await delay(1000);\n    }\n  }\n\n  // Like fetchDoc(), but doesn't check if ActiveDoc returned is unmuted.\n  private async _fetchPossiblyMutedDoc(docSession: OptDocSession, docName: string,\n    wantRecoveryMode?: boolean): Promise<ActiveDoc> {\n    if (this._activeDocs.has(docName) && wantRecoveryMode !== undefined) {\n      const activeDoc = await this._activeDocs.get(docName);\n      if (activeDoc && activeDoc.recoveryMode !== wantRecoveryMode && await activeDoc.isOwner(docSession)) {\n        // shutting doc down to have a chance to re-open in the correct mode.\n        // TODO: there could be a battle with other users opening it in a different mode.\n        log.debug(\"DocManager._fetchPossiblyMutedDoc starting activeDoc shutdown\", docName);\n        await activeDoc.shutdown();\n      }\n    }\n    let activeDoc: ActiveDoc;\n    if (!this._activeDocs.has(docName)) {\n      activeDoc = await mapSetOrClear(\n        this._activeDocs, docName,\n        this._createActiveDoc(docSession, docName, wantRecoveryMode ?? this._inRecovery.get(docName))\n          .then((newDoc) => {\n            // Propagate backupMade events from newly opened activeDocs (consolidate all to DocMan)\n            newDoc.on(\"backupMade\", (bakPath: string) => {\n              this.emit(\"backupMade\", bakPath);\n            });\n            return newDoc.loadDoc(docSession);\n          }));\n    } else {\n      activeDoc = await this._activeDocs.get(docName)!;\n    }\n    return activeDoc;\n  }\n\n  private async _getDoc(docSession: OptDocSession, docName: string) {\n    const cachedDoc = getDocSessionCachedDoc(docSession);\n    if (cachedDoc) {\n      return cachedDoc;\n    }\n\n    let db: HomeDBManager;\n    try {\n      // For the sake of existing tests, get the db from gristServer where it may not exist and we should give up,\n      // rather than using this._homeDbManager which may exist and then it turns out the document itself doesn't.\n      db = this.gristServer.getHomeDBManager();\n    } catch (e) {\n      if (e.message === \"no db\") {\n        return;\n      }\n      throw e;\n    }\n\n    if (docSession.req) {\n      const scope = getScope(docSession.req);\n      if (scope.urlId) {\n        return db.getDoc(scope);\n      }\n    }\n\n    return await db.getRawDocById(docName);\n  }\n\n  private async _getDocUrls(doc: Document) {\n    try {\n      return {\n        docUrl: await this.gristServer.getResourceUrl(doc),\n        docApiUrl: await this.gristServer.getResourceUrl(doc, \"api\"),\n      };\n    } catch (e) {\n      // If there is no home url, we cannot construct links.  Accept this, for the benefit\n      // of legacy tests.\n      if (e.message !== \"need APP_HOME_URL\") {\n        throw e;\n      }\n    }\n  }\n\n  private async _createActiveDoc(docSession: OptDocSession, docName: string, safeMode?: boolean) {\n    const doc = await this._getDoc(docSession, docName);\n    // Get URL for document for use with SELF_HYPERLINK().\n    const docUrls = doc && await this._getDocUrls(doc);\n    const activeDoc = new ActiveDoc(this, docName, this._attachmentStoreProvider, { ...docUrls, safeMode, doc });\n    // Restore the timing mode of the document.\n    activeDoc.isTimingOn = this._inTimingOn.get(docName) || false;\n    return activeDoc;\n  }\n\n  /**\n   * Helper that implements doing the actual import of an uploaded set of files to create a new\n   * document.\n   */\n  private async _doImportDoc(docSession: OptDocSession, uploadInfo: UploadInfo,\n    options: {\n      naming: \"classic\" | \"saved\" | \"unsaved\",\n      register?: (docId: string, uploadBaseFilename: string) => Promise<void>,\n      userId?: number,\n    }): Promise<DocCreationInfo> {\n    try {\n      const fileCount = uploadInfo.files.length;\n      const hasGristDoc = Boolean(uploadInfo.files.find(f => extname(f.origName) === \".grist\"));\n      if (hasGristDoc && fileCount > 1) {\n        throw new Error(\"Grist docs must be uploaded individually\");\n      }\n      const first = uploadInfo.files[0].origName;\n      log.debug(`DocManager._doImportDoc: Received doc with name ${first}`);\n      const ext = extname(first);\n      const basename = path.basename(first, ext).trim() || \"Untitled upload\";\n      let id: string;\n      switch (options.naming) {\n        case \"saved\":\n          id = makeId();\n          break;\n        case \"unsaved\": {\n          const { userId } = options;\n          if (!userId) { throw new Error(\"unsaved import requires userId\"); }\n          if (!this._homeDbManager) { throw new Error(\"HomeDbManager not available\"); }\n          const isAnonymous = userId === this._homeDbManager.getAnonymousUserId();\n          id = makeForkIds({ userId, isAnonymous, trunkDocId: NEW_DOCUMENT_CODE,\n            trunkUrlId: NEW_DOCUMENT_CODE }).docId;\n          break;\n        }\n        case \"classic\":\n          id = basename;\n          break;\n        default:\n          throw new Error(\"naming mode not recognized\");\n      }\n      await options.register?.(id, basename);\n      if (ext === \".grist\") {\n        log.debug(`DocManager._doImportDoc: Importing .grist doc`);\n        const docName = await this._createNewDoc(id);\n        await this.importGristDoc(docSession, docName, uploadInfo.files[0].absPath);\n        // Go ahead and claim this document. If we wanted to serve it\n        // from a potentially different worker, we'd call addToStorage(docName)\n        // instead (we used to do this). The upload should already be happening\n        // on a randomly assigned worker due to the special treatment of the\n        // 'import' assignmentId.\n        await this.storageManager.prepareLocalDoc(docName);\n        this.storageManager.markAsChanged(docName, \"edit\");\n        return { title: basename, id: docName };\n      } else {\n        const doc = await this.createNewEmptyDoc(docSession, id);\n        await doc.oneStepImport(docSession, uploadInfo);\n        return { title: basename, id: doc.docName };\n      }\n    } catch (err) {\n      throw new ApiError(err.message, err.status || 400, {\n        tips: [{ action: \"ask-for-help\", message: \"Ask for help\" }],\n      });\n    } finally {\n      await globalUploadSet.cleanup(uploadInfo.uploadId);\n    }\n  }\n\n  // Returns the name for a new doc, based on basenameHint.\n  private async _createNewDoc(basenameHint: string): Promise<string> {\n    const docName: string = await docUtils.createNumbered(basenameHint, \"-\", async (name: string) => {\n      if (this._activeDocs.has(name)) {\n        throw new Error(\"Existing entry in active docs for: \" + name);\n      }\n      return docUtils.createExclusive(this.storageManager.getPath(name));\n    });\n    log.debug(\"DocManager._createNewDoc picked name\", docName);\n    await this.pluginManager?.pluginsLoaded;\n    return docName;\n  }\n}\n\n// Returns the extension of fpath (from last occurrence of \".\" to the end of the string), even\n// when the basename is empty or starts with a period.\nfunction extname(fpath: string): string {\n  return path.extname(\"X\" + fpath);\n}\n\nasync function updateDocumentAttachmentStoreSettingToValidValue(fname: string, provider: IAttachmentStoreProvider) {\n  return updateDocumentSettingsInPlace(fname, (oldSettings) => {\n    const attachmentStoreId = oldSettings?.attachmentStoreId;\n    if (!attachmentStoreId || provider.storeExists(attachmentStoreId)) {\n      return oldSettings;\n    }\n    const newStoreLabel = getConfiguredStandardAttachmentStore();\n    const newStoreId = newStoreLabel && provider.getStoreIdFromLabel(newStoreLabel);\n\n    return {\n      ...oldSettings,\n      attachmentStoreId: newStoreId,\n    };\n  });\n}\n\n// Updates the document's settings (_grist_DocInfo.docSettings) without loading the document.\nasync function updateDocumentSettingsInPlace(\n  fname: string,\n  makeChanges: (oldSettings: DocumentSettings | undefined) => DocumentSettings | undefined,\n) {\n  const db = await SQLiteDB.openDBRaw(fname, OpenMode.OPEN_EXISTING);\n  try {\n    const columns = await db.all(\"PRAGMA table_info(_grist_DocInfo)\");\n    // This protects against errors with old Grist document versions, before this column was introduced.\n    if (!columns.some(column => column.name === \"documentSettings\")) {\n      return;\n    }\n    const docInfoRow = await db.get(\"SELECT id, schemaVersion, documentSettings FROM _grist_DocInfo\");\n    // This is an edge case that shouldn't happen. If it does, our only options are to error or do nothing.\n    // Do nothing and log for now, so that we can track if this ever comes up.\n    if (!docInfoRow) {\n      log.warn(\"Doc has no rows in _grist_DocInfo - cannot update document settings.\");\n      return;\n    }\n\n    const parsedSettings: unknown = safeJsonParse(docInfoRow.documentSettings, undefined);\n\n    const isValidSettingsObject = DocumentSettingsChecker.test(parsedSettings);\n    // Throw if there's something expected in the settings object.\n    // This shouldn't occur unless there's a bug or a malformed doc, as DocSettings is backwards compatible.\n    if (parsedSettings && !isValidSettingsObject) {\n      DocumentSettingsChecker.check(parsedSettings);\n    }\n\n    const settings = parsedSettings && isValidSettingsObject ? parsedSettings : undefined;\n    const newSettings = makeChanges(settings);\n\n    // Avoid unnecessary DB updates\n    if (isDeepEqual(settings, newSettings)) {\n      return;\n    }\n\n    await db.run(\"UPDATE _grist_DocInfo SET documentSettings = ? WHERE id = ?\",\n      JSON.stringify(newSettings),\n      docInfoRow.id,\n    );\n  } finally {\n    await db.close();\n  }\n}\n"
  },
  {
    "path": "app/server/lib/DocPluginData.ts",
    "content": "import { Promisified } from \"app/common/tpromisified\";\nimport { Storage } from \"app/plugin/StorageAPI\";\nimport { DocStorage } from \"app/server/lib/DocStorage\";\n\n/**\n * DocPluginData implements a document's `Storage` for plugin.\n */\nexport class DocPluginData implements Promisified<Storage> {\n  constructor(private _docStorage: DocStorage, private _pluginId: string) {\n    // nothing to do here\n  }\n\n  public async getItem(key: string): Promise<any> {\n    const res = await this._docStorage.getPluginDataItem(this._pluginId, key);\n    if (typeof res === \"string\") {\n      return JSON.parse(res);\n    }\n    return res;\n  }\n\n  public hasItem(key: string): Promise<boolean> {\n    return this._docStorage.hasPluginDataItem(this._pluginId, key);\n  }\n\n  public setItem(key: string, value: any): Promise<void> {\n    return this._docStorage.setPluginDataItem(this._pluginId, key, JSON.stringify(value));\n  }\n\n  public removeItem(key: string): Promise<void> {\n    return this._docStorage.removePluginDataItem(this._pluginId, key);\n  }\n\n  public clear(): Promise<void> {\n    return this._docStorage.clearPluginDataItem(this._pluginId);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/DocPluginManager.ts",
    "content": "import { ApplyUAResult } from \"app/common/ActiveDocAPI\";\nimport { fromTableDataAction, TableColValues } from \"app/common/DocActions\";\nimport * as gutil from \"app/common/gutil\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { createRpcLogger, PluginInstance } from \"app/common/PluginInstance\";\nimport { Promisified } from \"app/common/tpromisified\";\nimport { ParseFileResult, ParseOptions } from \"app/plugin/FileParserAPI\";\nimport { checkers, GristTable } from \"app/plugin/grist-plugin-api\";\nimport { AccessTokenResult, GristDocAPI } from \"app/plugin/GristAPI\";\nimport { Storage } from \"app/plugin/StorageAPI\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { DocPluginData } from \"app/server/lib/DocPluginData\";\nimport { makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport { FileParserElement } from \"app/server/lib/FileParserElement\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport { SafePythonComponent } from \"app/server/lib/SafePythonComponent\";\nimport { UnsafeNodeComponent } from \"app/server/lib/UnsafeNodeComponent\";\nimport { createTmpDir } from \"app/server/lib/uploads\";\n\nimport * as path from \"path\";\n\nimport * as fse from \"fs-extra\";\n\n/**\n * Implements GristDocAPI interface.\n */\nclass GristDocAPIImpl implements GristDocAPI {\n  constructor(private _activeDoc: ActiveDoc) { }\n\n  public async getDocName() { return this._activeDoc.docName; }\n\n  public async listTables(): Promise<string[]> {\n    return this._activeDoc.docData!.getMetaTable(\"_grist_Tables\")\n      .getRecords()\n      .filter(r => !r.summarySourceTable)\n      .map(r => r.tableId);\n  }\n\n  public async fetchTable(tableId: string): Promise<TableColValues> {\n    return fromTableDataAction(await this._activeDoc.fetchTable(\n      makeExceptionalDocSession(\"plugin\"), tableId));\n  }\n\n  public applyUserActions(actions: any[][]): Promise<ApplyUAResult> {\n    return this._activeDoc.applyUserActions(makeExceptionalDocSession(\"plugin\"), actions);\n  }\n\n  // These implementations of GristDocAPI are from an early implementation of\n  // plugins that is incompatible with access control. No need to add new\n  // methods here.\n  public async getAccessToken(): Promise<AccessTokenResult> {\n    throw new Error(\"getAccessToken not implemented\");\n  }\n}\n\n/**\n * DocPluginManager manages plugins for a document.\n *\n * DocPluginManager instantiates asynchronously. Wait for the `ready` to resolve before using any\n * plugin.\n *\n */\nexport class DocPluginManager {\n  public readonly plugins: { [s: string]: PluginInstance } = {};\n  public readonly ready: Promise<any>;\n  public readonly gristDocAPI: GristDocAPI;\n\n  private _tmpDir: string;\n  private _pluginInstances: PluginInstance[];\n\n  constructor(\n    private _localPlugins: LocalPlugin[],\n    private _appRoot: string,\n    private _activeDoc: ActiveDoc,\n    private _server: GristServer,\n  ) {\n    this.gristDocAPI = new GristDocAPIImpl(_activeDoc);\n    this._pluginInstances = [];\n    this.ready = this._initialize();\n  }\n\n  public tmpDir(): string {\n    return this._tmpDir;\n  }\n\n  /**\n   * To be moved in ActiveDoc.js as a new implementation for ActiveDoc.importFile.\n   * Throws if no importers can parse the file.\n   */\n  public async parseFile(filePath: string, fileName: string, parseOptions: ParseOptions): Promise<ParseFileResult> {\n    // Support an existing grist json format directly for files with a \"jgrist\"\n    // extension.\n    if (path.extname(fileName) === \".jgrist\") {\n      try {\n        const result = JSON.parse(await fse.readFile(filePath, \"utf8\")) as ParseFileResult;\n        result.parseOptions = {};\n        // The parseOptions component isn't checked here, since it seems free-form.\n        checkers.ParseFileResult.check(result);\n        checkReferences(result.tables);\n        return result;\n      } catch (err) {\n        throw new Error(\"Grist json format could not be parsed: \" + err);\n      }\n    }\n\n    if (path.extname(fileName) === \".grist\") {\n      throw new Error(`To import a grist document use the \"Import document\" menu option on your home screen`);\n    }\n\n    const matchingFileParsers: FileParserElement[] = FileParserElement.getMatching(this._pluginInstances, fileName);\n\n    if (!this._tmpDir) {\n      throw new Error(\"DocPluginManager: initialization has not completed\");\n    }\n\n    // TODO: PluginManager shouldn't patch path here. Instead it should expose a method to create\n    // dataSources, that would move the file to under _tmpDir and return an object with the relative\n    // path.\n    filePath = path.relative(this._tmpDir, filePath);\n    log.debug(`parseFile: found ${matchingFileParsers.length} fileParser with matching file extensions`);\n    const messages = [];\n    for (const { plugin, parseFileStub } of matchingFileParsers) {\n      const name = plugin.definition.id;\n      try {\n        log.info(`DocPluginManager.parseFile: calling to ${name} with ${filePath}`);\n        const pathFlavor = process.platform === \"win32\" ? \"windows\" : \"posix\";\n        const result = await parseFileStub.parseFile({ path: filePath, origName: fileName, pathFlavor }, parseOptions);\n        checkers.ParseFileResult.check(result);\n        checkReferences(result.tables);\n        return result;\n      } catch (err) {\n        const cleanerMessage = err.message.replace(/^\\[Sandbox\\] (Exception)?/, \"\").trim();\n        messages.push(cleanerMessage);\n        log.warn(`DocPluginManager.parseFile: ${name} Failed parseFile `, err.message);\n        continue;\n      }\n    }\n\n    if (messages.length) {\n      const extToType: Record<string, string> = {\n        \".xlsx\": \"Excel\",\n        \".json\": \"JSON\",\n        \".csv\": \"CSV\",\n        \".tsv\": \"TSV\",\n        \".dsv\": \"PSV\",\n      };\n      const fileType = extToType[path.extname(fileName)] || path.extname(fileName);\n      throw new Error(`Failed to parse ${fileType} file.\\nError: ${messages.join(\"; \")}`);\n    }\n    throw new Error(`File format is not supported.`);\n  }\n\n  /**\n   * Returns a list of plugins definitions.\n   */\n  public getPlugins(): LocalPlugin[] {\n    return this._localPlugins;\n  }\n\n  /**\n   * Shut down all plugins for this document.\n   */\n  public async shutdown(): Promise<void> {\n    const names = Object.keys(this.plugins);\n    log.debug(\"DocPluginManager.shutdown cleaning up %s plugins\", names.length);\n    await Promise.all(names.map(name => this.plugins[name].shutdown()));\n    if (this._tmpDir) {\n      log.debug(\"DocPluginManager.shutdown removing tmpDir %s\", this._tmpDir);\n      await fse.remove(this._tmpDir);\n    }\n  }\n\n  /**\n   * Reload plugins: shutdown all plugins, clear list of plugins and load new ones. Returns a\n   * promise that resolves when initialisation is done.\n   */\n  public async reload(plugins: LocalPlugin[]): Promise<void> {\n    await this.shutdown();\n    this._pluginInstances = [];\n    this._localPlugins = plugins;\n    await this._initialize();\n  }\n\n  public receiveAction(action: any[]): void {\n    for (const plugin of this._pluginInstances) {\n      const unsafeNode = plugin.unsafeNode as UnsafeNodeComponent;\n      if (unsafeNode) {\n        unsafeNode.receiveAction(action);\n      }\n    }\n  }\n\n  private async _initialize(): Promise<void> {\n    this._tmpDir = (await createTmpDir({ prefix: \"grist-tmp-\", unsafeCleanup: true })).tmpDir;\n    for (const plugin of this._localPlugins) {\n      try {\n        // todo: once Comm has been replaced by grain-rpc, pluginInstance.rpc should forward '*' to client\n        const pluginInstance = new PluginInstance(plugin, createRpcLogger(log, `PLUGIN ${plugin.id}:`));\n        pluginInstance.rpc.registerForwarder(\"grist\", pluginInstance.rpc, \"\");\n        pluginInstance.rpc.registerImpl<GristDocAPI>(\"GristDocAPI\", this.gristDocAPI, checkers.GristDocAPI);\n        pluginInstance.rpc.registerImpl<Promisified<Storage>>(\"DocStorage\",\n          new DocPluginData(this._activeDoc.docStorage, plugin.id), checkers.Storage);\n        const components = plugin.manifest.components;\n        if (components) {\n          const { safePython, unsafeNode } = components;\n          if (safePython) {\n            const comp = pluginInstance.safePython = new SafePythonComponent(plugin, this._tmpDir,\n              this._activeDoc.docName, this._server);\n            pluginInstance.rpc.registerForwarder(safePython, comp);\n          }\n          if (unsafeNode) {\n            const gristDocPath = this._activeDoc.docStorage.docPath;\n            const comp = pluginInstance.unsafeNode = new UnsafeNodeComponent(plugin, pluginInstance.rpc, unsafeNode,\n              this._appRoot, gristDocPath);\n            pluginInstance.rpc.registerForwarder(unsafeNode, comp);\n          }\n        }\n        this._pluginInstances.push(pluginInstance);\n      } catch (err) {\n        log.info(`DocPluginInstance: failed to create instance ${plugin.id}: ${err.message}`);\n      }\n    }\n    for (const instance of this._pluginInstances) {\n      this.plugins[instance.definition.id] = instance;\n    }\n  }\n}\n\n/**\n * Checks that tables include all the tables referenced by tables columns. Throws an exception\n * otherwise.\n */\nfunction checkReferences(tables: GristTable[]) {\n  const tableIds = tables.map(table => table.table_name);\n  for (const table of tables) {\n    for (const col of table.column_metadata) {\n      const refTableId = gutil.removePrefix(col.type, \"Ref:\");\n      if (refTableId && !tableIds.includes(refTableId)) {\n        throw new Error(`Column type: ${col.type}, references an unknown table`);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/server/lib/DocSession.ts",
    "content": "import { BrowserSettings } from \"app/common/BrowserSettings\";\nimport { decodeLinkParameters } from \"app/common/gristUrls\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { AuthSession } from \"app/server/lib/AuthSession\";\nimport { Client } from \"app/server/lib/Client\";\n\nimport type { DocAuthorizer } from \"app/server/lib/DocAuthorizer\";\n\n/**\n * OptDocSession allows for certain ActiveDoc operations to work with or without an open document.\n * It is useful in particular for actions when importing a file to create a new document.\n */\nexport class OptDocSession extends AuthSession {\n  public readonly client: Client | null;\n  public readonly req?: RequestWithLogin;\n  public readonly browserSettings?: BrowserSettings;\n\n  /**\n   * Flag to indicate that user actions 'bundle' process is started and in progress (`true`),\n   * otherwise it's `false`\n   */\n  public shouldBundleActions?: boolean;\n\n  /**\n   * Indicates the actionNum of the previously applied action\n   * to which the first action in actions should be linked.\n   * Linked actions appear as one action and can be undone/redone in a single step.\n   */\n  public linkId?: number;\n\n  // special permissions for creating, plugins, system, and share access\n  public mode?: \"nascent\" | \"plugin\" | \"system\" | \"share\";\n  public authorizer?: DocAuthorizer;\n  public forkingAsOwner?: boolean;  // Set if it is appropriate in a pre-fork state to become an owner.\n  public linkParameters?: Record<string, string>;\n\n  private get _authSession(): AuthSession {\n    return this.client?.authSession ?? (this.req ? AuthSession.fromReq(this.req) : AuthSession.unauthenticated());\n  }\n\n  constructor(options: {\n    client?: Client | null,\n    browserSettings?: BrowserSettings,\n    req?: RequestWithLogin,\n    linkParameters?: Record<string, string>,\n  }) {\n    super();\n    this.client = options.client ?? null;\n    this.browserSettings = options.browserSettings ?? (this.client?.browserSettings);\n    this.req = options.req;\n    this.linkParameters = options.linkParameters ??\n      (this.req?.url ? decodeLinkParameters(new URLSearchParams(this.req.url.split(\"?\")[1])) : undefined);\n  }\n\n  // Expose AuthSession interface directly. Note that other AuthSession helper methods are also\n  // available, e.g. .normalizedEmail or .getLogMeta().\n  public get org() { return this._authSession.org; }\n  public get altSessionId() { return this._authSession.altSessionId; }\n  public get userId() { return this._authSession.userId; }\n  public get userIsAuthorized() { return this._authSession.userIsAuthorized; }\n  public get fullUser() { return this._authSession.fullUser; }\n\n  // Like AuthSession.getLogMeta(), but includes a bit more info when we have a Client.\n  public getLogMeta() { return this.client?.getLogMeta() || super.getLogMeta(); }\n}\n\nexport function makeOptDocSession(client: Client | null): OptDocSession {\n  return new OptDocSession({ client });\n}\n\n/**\n * Create an OptDocSession with special access rights.\n *  - nascent: user is treated as owner (because doc is being created)\n *  - plugin: user is treated as editor (because plugin access control is crude)\n *  - system: user is treated as owner (because of some operation bypassing access control)\n */\nexport function makeExceptionalDocSession(mode: \"nascent\" | \"plugin\" | \"system\" | \"share\",\n  options: { client?: Client,\n    req?: RequestWithLogin,\n    browserSettings?: BrowserSettings } = {}): OptDocSession {\n  const docSession = new OptDocSession(options);\n  docSession.mode = mode;\n  return docSession;\n}\n\n/**\n * Create an OptDocSession from a request.  Request should have user and doc access\n * middleware.\n */\nexport function docSessionFromRequest(req: RequestWithLogin): OptDocSession {\n  return new OptDocSession({ req });\n}\n\n/**\n * DocSessionPrecursor is used in DocManager while opening a document. It's close to a full\n * DocSession, and is used to create a full DocSession once we have an activeDoc and fd.\n */\nexport class DocSessionPrecursor extends OptDocSession {\n  public readonly client: Client;\n  public readonly authorizer: DocAuthorizer;\n\n  constructor(client: Client, authorizer: DocAuthorizer, options: {\n    linkParameters?: Record<string, string>,\n  }) {\n    super({ ...options, client });\n    this.client = client;\n    this.authorizer = authorizer;\n  }\n}\n\n/**\n * DocSession objects maintain information for a single session<->doc instance.\n * Unlike OptDocSession, it has a definite activeDoc, client, authorizer, and fd.\n */\nexport class DocSession extends DocSessionPrecursor {\n  public readonly activeDoc: ActiveDoc;\n  public readonly fd: number;\n\n  constructor(ds: DocSessionPrecursor, activeDoc: ActiveDoc, fd: number) {\n    super(ds.client, ds.authorizer, { linkParameters: ds.linkParameters });\n    this.activeDoc = activeDoc;\n    this.fd = fd;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/DocSnapshots.ts",
    "content": "import { ObjSnapshotWithMetadata } from \"app/common/DocSnapshot\";\nimport { SnapshotWindow } from \"app/common/Features\";\nimport { KeyedMutex } from \"app/common/KeyedMutex\";\nimport { KeyedOps } from \"app/common/KeyedOps\";\nimport { ExternalStorage } from \"app/server/lib/ExternalStorage\";\nimport log from \"app/server/lib/log\";\nimport { integerParam } from \"app/server/lib/requestUtils\";\n\nimport * as fse from \"fs-extra\";\nimport * as moment from \"moment-timezone\";\n\n/**\n * A subset of the ExternalStorage interface, focusing on maintaining a list of versions.\n */\nexport interface IInventory {\n  getSnapshotWindow?: (key: string) => Promise<SnapshotWindow | undefined>;\n  versions(key: string): Promise<ObjSnapshotWithMetadata[]>;\n  remove(key: string, snapshotIds: string[]): Promise<void>;\n}\n\n/**\n * A utility for pruning snapshots, so the number of snapshots doesn't get out of hand.\n */\nexport class DocSnapshotPruner {\n  private _closing: boolean = false;                        // when set, should ignore prune requests\n  private _prunes: KeyedOps;\n\n  // Specify store to be pruned, and delay before pruning.\n  constructor(private _ext: IInventory, _options: {\n    delayBeforeOperationMs?: number,\n    minDelayBetweenOperationsMs?: number\n  } = {}) {\n    this._prunes = new KeyedOps(key => this.prune(key), {\n      ..._options,\n      retry: false,\n      logError: (key, failureCount, err) => log.error(`Pruning document ${key} gave error ${err}`),\n    });\n  }\n\n  // Shut down.  Prunes scheduled for the future are run immediately.\n  // Can be called repeated safely.\n  public async close() {\n    this._closing = true;\n    this._prunes.expediteOperations();\n    await this.wait();\n  }\n\n  // Wait for all in-progress prunes to finish up in an orderly fashion.\n  public async wait() {\n    await this._prunes.wait(() => \"waiting for pruning to finish\");\n  }\n\n  // Note that a document has changed, and should be pruned (or repruned).  Pruning operation\n  // done as a background operation.  Returns true if a pruning operation has been scheduled.\n  public requestPrune(key: string): boolean {\n    // If closing down, do not accept any prune requests.\n    if (!this._closing) {\n      // Mark the key as needing work.\n      this._prunes.addOperation(key);\n    }\n    return this._prunes.hasPendingOperation(key);\n  }\n\n  // Get all snapshots for a document, and whether they should be kept or pruned.\n  public async classify(key: string): Promise<{ snapshot: ObjSnapshotWithMetadata, keep: boolean }[]> {\n    const snapshotWindow = await this._ext.getSnapshotWindow?.(key);\n    const versions = await this._ext.versions(key);\n    return shouldKeepSnapshots(versions, snapshotWindow).map((keep, index) => ({ keep, snapshot: versions[index] }));\n  }\n\n  // Prune the specified document immediately.  If no snapshotIds are provided, they\n  // will be chosen automatically.\n  public async prune(key: string, snapshotIds?: string[]) {\n    if (!snapshotIds) {\n      const versions = await this.classify(key);\n      const redundant = versions.filter(v => !v.keep);\n      snapshotIds = redundant.map(r => r.snapshot.snapshotId);\n      await this._ext.remove(key, snapshotIds);\n      log.info(`Pruned ${snapshotIds.length} versions of ${versions.length} for document ${key}`);\n    } else {\n      await this._ext.remove(key, snapshotIds);\n      log.info(`Pruned ${snapshotIds.length} externally selected versions for document ${key}`);\n    }\n  }\n}\n\n/**\n * Maintain a list of document versions, with metadata, so we can query versions and\n * make sensible pruning decisions without needing to HEAD each version (in the\n * steady state).\n *\n * The list of versions (with metadata) for a document is itself stored in S3.  This isn't\n * ideal since we cannot simply append a new version to the list without rewriting it in full.\n * But the alternatives have more serious problems, and this way folds quite well into the\n * existing pruning setup.\n *   - Storing in db would mean we'd need sharding sooner than otherwise\n *   - Storing in redis would similarly make this the dominant load driving redis\n *   - Storing in dynamodb would create more operational work\n *   - Using S3 metadata alone would be too slow\n *   - Using S3 tags could do some of what we want, but tags have serious limits\n *\n * Operations related to a particular document are serialized for clarity.\n *\n * The inventory is cached on the local file system, since we reuse the ExternalStorage\n * interface which is file based.\n */\nexport class DocSnapshotInventory implements IInventory {\n  private _needFlush = new Set<string>();\n  private _mutex = new KeyedMutex();\n\n  /**\n   * Expects to be given the store for documents, a store for metadata, and a method\n   * for naming cache files on the local filesystem.  The stores should be consistent.\n   */\n  constructor(\n    private _doc: ExternalStorage,\n    private _meta: ExternalStorage,\n    private _getFilename: (key: string) => Promise<string>,\n    public getSnapshotWindow: (key: string) => Promise<SnapshotWindow | undefined>,\n  ) {}\n\n  /**\n   * Start keeping inventory for a new document.\n   */\n  public async create(key: string) {\n    await this._mutex.runExclusive(key, async () => {\n      const fname = await this._getFilename(key);\n      await this._saveToFile(fname, []);\n      this._needFlush.add(key);\n    });\n  }\n\n  /**\n   * Return true if document inventory does not need to be saved and is not in flux.\n   */\n  public isSaved(key: string) {\n    return !this._needFlush.has(key) && !this._mutex.isLocked(key);\n  }\n\n  /**\n   * Add a new snapshot of a document to the existing inventory.  A prevSnapshotId may\n   * be supplied as a cross-check.  It will be matched against the most recent\n   * snapshotId in the inventory, and if it doesn't match the inventory will be\n   * recreated.\n   *\n   * The inventory is not automatically flushed to S3.  Call flush() to do that,\n   * or ask DocSnapshotPrune.requestPrune() to prune the document - it will flush\n   * after pruning.\n   *\n   * The snapshot supplied will be modified in place to a normalized form.\n   */\n  public async add(key: string, snapshot: ObjSnapshotWithMetadata, prevSnapshotId: string | null) {\n    await this.uploadAndAdd(key, async () => {\n      return { snapshot, prevSnapshotId };\n    });\n  }\n\n  /**\n   * Like add(), but takes an \"upload\" callback that allows\n   * preparing snapshot and prevSnapshotId atomically with the\n   * rest of the add operation, and thus serialized with any\n   * other operations such as versions(). This is important since an\n   * upload changes the list of versions as far as the external store\n   * is concerned, which could trigger a \"surprise\" and a full reload\n   * of the version list.\n   */\n  public async uploadAndAdd(key: string,\n    upload: () => Promise<{ snapshot?: ObjSnapshotWithMetadata,\n      prevSnapshotId: string | null }>) {\n    await this._mutex.runExclusive(key, async () => {\n      const { snapshot, prevSnapshotId } = await upload();\n      if (!snapshot) {\n        // the upload generated no snapshot, so there is nothing to do.\n        return;\n      }\n      const snapshots = await this._getSnapshots(key, prevSnapshotId);\n      // Could be already added if reconstruction happened.\n      if (snapshots[0] && snapshots[0].snapshotId === snapshot.snapshotId) { return; }\n      this._normalizeMetadata(snapshot);\n      snapshots.unshift(snapshot);\n      const fname = await this._getFilename(key);\n      await this._saveToFile(fname, snapshots);\n      // We don't write to s3 yet, but do mark the list as dirty.\n      this._needFlush.add(key);\n    });\n  }\n\n  /**\n   * Make sure the latest state of the inventory is stored in S3.\n   */\n  public async flush(key: string) {\n    await this._mutex.runExclusive(key, async () => {\n      await this._flush(key);\n    });\n  }\n\n  /**\n   * Wipe local cached state of the inventory.\n   */\n  public async clear(key: string) {\n    await this._mutex.runExclusive(key, async () => {\n      await this._flush(key);\n      const fname = await this._getFilename(key);\n      // NOTE: fse.remove succeeds also when the file does not exist.\n      await fse.remove(fname);\n    });\n  }\n\n  /**\n   * Remove a set of snapshots from the inventory, and then flush to S3.\n   */\n  public async remove(key: string, snapshotIds: string[]) {\n    await this._mutex.runExclusive(key, async () => {\n      const current = await this._getSnapshots(key, null);\n      const oldIds = new Set(snapshotIds);\n      if (oldIds.size > 0) {\n        const results = current.filter(v => !oldIds.has(v.snapshotId));\n        const fname = await this._getFilename(key);\n        await this._doc.remove(key, snapshotIds);\n        await this._saveToFile(fname, results);\n        this._needFlush.add(key);\n      }\n      await this._flush(key);\n    });\n  }\n\n  /**\n   * Read the cached version of the inventory if available, otherwise fetch\n   * it from S3.  If expectSnapshotId is set, the cached version is ignored if\n   * the most recent version listed is not the expected one.\n   */\n  public async versions(key: string, expectSnapshotId?: string | null): Promise<ObjSnapshotWithMetadata[]> {\n    return this._mutex.runExclusive(key, async () => {\n      return await this._getSnapshots(key, expectSnapshotId || null);\n    });\n  }\n\n  // Do whatever it takes to get an inventory of versions.\n  // Most recent versions returned first.\n  private async _getSnapshots(key: string, expectSnapshotId: string | null): Promise<ObjSnapshotWithMetadata[]> {\n    // Check if we have something useful cached on the local filesystem.\n    const fname = await this._getFilename(key);\n    let data = await this._loadFromFile(fname);\n    if (data && expectSnapshotId && data[0]?.snapshotId !== expectSnapshotId) {\n      data = null;\n    }\n\n    // If nothing yet, check if we have something useful in s3.\n    if (!data && await this._meta.exists(key)) {\n      await fse.remove(fname);\n      await this._meta.download(key, fname);\n      data = await this._loadFromFile(fname);\n      if (data && expectSnapshotId && data[0]?.snapshotId !== expectSnapshotId) {\n        data = null;\n      }\n    }\n\n    if (!data) {\n      // No joy, all we can do is reconstruct from individual s3 version HEAD metadata.\n      data = await this._reconstruct(key);\n      if (data) {\n        if (expectSnapshotId && data[0]?.snapshotId !== expectSnapshotId) {\n          // Surprising, since S3 ExternalInterface should have its own consistency\n          // checks. Not much we can do about it other than accept it.\n          log.error(`Surprise in getSnapshots, expected ${expectSnapshotId} for ${key} ` +\n            `but got ${data[0]?.snapshotId}`);\n        }\n        // Reconstructed data is precious.  Make sure it gets saved.\n        await this._saveToFile(fname, data);\n        this._needFlush.add(key);\n      }\n    }\n    return data;\n  }\n\n  // Load inventory from local file system, if available.\n  private async _loadFromFile(fname: string): Promise<ObjSnapshotWithMetadata[] | null> {\n    try {\n      if (await fse.pathExists(fname)) {\n        return JSON.parse(await fse.readFile(fname, \"utf8\"));\n      }\n      return null;\n    } catch (e) {\n      return null;\n    }\n  }\n\n  // Save inventory to local file system.\n  private async _saveToFile(fname: string, data: ObjSnapshotWithMetadata[]) {\n    await fse.outputFile(fname, JSON.stringify(data, null, 2), \"utf8\");\n  }\n\n  // This is a relatively expensive operation, calling the S3 api for every stored\n  // version of a document. In the steady state, we should rarely need to do this.\n  private async _reconstruct(key: string): Promise<ObjSnapshotWithMetadata[]> {\n    const snapshots = await this._doc.versions(key);\n    if (snapshots.length > 1) {\n      log.info(`Reconstructing history of ${key} (${snapshots.length} versions)`);\n    }\n    const results: ObjSnapshotWithMetadata[] = [];\n    for (const snapshot of snapshots) {\n      const head = await this._doc.head(key, snapshot.snapshotId);\n      if (head) {\n        this._normalizeMetadata(head);\n        results.push(head);\n      } else {\n        log.debug(`When reconstructing history of ${key}, did not find ${snapshot.snapshotId}`);\n      }\n    }\n    return results;\n  }\n\n  // Flush inventory to S3.\n  private async _flush(key: string) {\n    if (this._needFlush.has(key)) {\n      const fname = await this._getFilename(key);\n      await this._meta.upload(key, fname);\n      this._needFlush.delete(key);\n    }\n  }\n\n  // Normalize metadata.  We store a timestamp that is distinct from the S3 timestamp,\n  // recording when the file was changed by Grist.\n  // TODO: deal with possibility of this creating trouble with pruning if the local time is\n  // sufficiently wrong.\n  private _normalizeMetadata(snapshot: ObjSnapshotWithMetadata) {\n    if (snapshot?.metadata?.t) {\n      snapshot.lastModified = snapshot.metadata.t;\n      delete snapshot.metadata.t;\n    }\n  }\n}\n\n/**\n * Calculate which snapshots to keep.  Expects most recent snapshots to be first.\n * We keep:\n *   - The five most recent versions (including the current version)\n *   - The most recent version in every hour, for up to 25 distinct hours\n *   - The most recent version in every day, for up to 32 distinct days\n *   - The most recent version in every week, for up to 12 distinct weeks\n *   - The most recent version in every month, for up to 96 distinct months\n *   - The most recent version in every year, for up to 1000 distinct years\n *   - Anything with a label, for up to 32 days before the current version.\n * Calculations done in UTC, Gregorian calendar, ISO weeks (week starts with Monday).\n */\nexport function shouldKeepSnapshots(snapshots: ObjSnapshotWithMetadata[], snapshotWindow?: SnapshotWindow): boolean[] {\n  // Get current version\n  const current = snapshots[0];\n  if (!current) { return []; }\n\n  const tz = current.metadata?.tz || \"UTC\";\n\n  // Get time of current version\n  const start = moment.tz(current.lastModified, tz);\n  const capObjectString = process.env.GRIST_SNAPSHOT_TIME_CAP ||\n    '{\"hour\": 25, \"day\": 32, \"isoWeek\": 12, \"month\": 96, \"year\": 1000}';\n\n  // Parse the stringified JSON object into an actual object\n  const caps = JSON.parse(capObjectString);\n\n  // Extract the cap values for each bucket range and convert them to integers\n  const capHour = integerParam(caps.hour, \"GRIST_SNAPSHOT_TIMEBUCKET_CAP.hour\");\n  const capDay = integerParam(caps.day, \"GRIST_SNAPSHOT_TIMEBUCKET_CAP.day\");\n  const capIsoWeek = integerParam(caps.isoWeek, \"GRIST_SNAPSHOT_TIMEBUCKET_CAP.isoWeek\");\n  const capMonth = integerParam(caps.month, \"GRIST_SNAPSHOT_TIMEBUCKET_CAP.month\");\n  const capYear = integerParam(caps.year, \"GRIST_SNAPSHOT_TIMEBUCKET_CAP.year\");\n  // Track saved version per hour, day, week, month, year, and number of times a version\n  // has been saved based on a corresponding rule.\n  const buckets: TimeBucket[] = [\n    { range: \"hour\", prev: start, usage: 0, cap: capHour },\n    { range: \"day\", prev: start, usage: 0, cap: capDay },\n    { range: \"isoWeek\", prev: start, usage: 0, cap: capIsoWeek },\n    { range: \"month\", prev: start, usage: 0, cap: capMonth },\n    { range: \"year\", prev: start, usage: 0, cap: capYear },\n  ];\n\n  // For each snapshot starting with newest, check if it is worth saving by comparing\n  // it with the last saved snapshot based on hour, day, week, month, year\n  return snapshots.map((snapshot, index) => {\n    // Just to make extra sure we don't delete everything\n    if (index === 0) {\n      return true;\n    }\n\n    const date = moment.tz(snapshot.lastModified, tz);\n\n    // Limit snapshots to the given window corresponding to what the user has paid for\n    if (snapshotWindow && start.diff(date, snapshotWindow.unit, true) > snapshotWindow.count) {\n      return false;\n    }\n\n    // Keep 5 most recent versions if NUM_SNAPSHOT_KEEP not exist\n    let keep = index < integerParam(process.env.GRIST_SNAPSHOT_KEEP || 5, \"GRIST_SNAPSHOT_KEEP\");\n\n    for (const bucket of buckets) {\n      if (updateAndCheckRange(date, bucket)) { keep = true; }\n    }\n    // Preserve recent labelled snapshots in a naive and limited way.  No doubt this will\n    // be elaborated on if we make this a user-facing feature.\n    if (snapshot.metadata?.label &&\n      start.diff(date, \"days\") < 32) { keep = true; }\n    return keep;\n  });\n}\n\n/**\n * Check whether time `t` is in the same time-bucket as the time\n * stored in `prev` for that time-bucket, and the time-bucket has not\n * been used to its limit to justify saving versions.\n *\n * If all is good, we return true, store `t` in the appropriate\n * time-bucket in `prev`, and increment the usage count.  Note keeping\n * a single version can increment usage on several buckets.  This is\n * easy to change, but other variations have results that feel\n * counter-intuitive.\n */\nfunction updateAndCheckRange(t: moment.Moment, bucket: TimeBucket) {\n  if (bucket.usage < bucket.cap && !t.isSame(bucket.prev, bucket.range)) {\n    bucket.prev = t;\n    bucket.usage++;\n    return true;\n  }\n  return false;\n}\n\ninterface TimeBucket {\n  range: \"hour\" | \"day\" | \"isoWeek\" | \"month\" | \"year\",\n  prev: moment.Moment;   // last time stored in this bucket\n  usage: number;         // number of times this bucket justified saving a snapshot\n  cap: number;           // maximum number of usages permitted\n}\n"
  },
  {
    "path": "app/server/lib/DocStorage.ts",
    "content": "/**\n * Module to handle the storage of Grist documents.\n *\n * A Grist document is stored as a SQLite database file. We keep everything in a single database\n * file, including attachments, for the sake of having a single file represent a single \"document\"\n * or \"data set\".\n */\n\nimport { LocalActionBundle } from \"app/common/ActionBundle\";\nimport { BulkColValues, DocAction, TableColValues, TableDataAction, toTableDataAction } from \"app/common/DocActions\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { isList, isListType, isRefListType } from \"app/common/gristTypes\";\nimport * as marshal from \"app/common/marshal\";\nimport * as schema from \"app/common/schema\";\nimport { SingleCell } from \"app/common/TableData\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { ActionHistoryImpl } from \"app/server/lib/ActionHistoryImpl\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { combineExpr, ExpandedQuery } from \"app/server/lib/ExpandedQuery\";\nimport { IDocStorageManager } from \"app/server/lib/IDocStorageManager\";\nimport log from \"app/server/lib/log\";\nimport { OnDemandStorage } from \"app/server/lib/OnDemandActions\";\nimport { MinDBOptions } from \"app/server/lib/SqliteCommon\";\nimport { ISQLiteDB, MigrationHooks, OpenMode, PreparedStatement, quoteIdent,\n  ResultRow, RunResult, SchemaInfo, SQLiteDB } from \"app/server/lib/SQLiteDB\";\n\nimport assert from \"assert\";\nimport * as util from \"util\";\n\nimport * as bluebird from \"bluebird\";\nimport chunk from \"lodash/chunk\";\nimport cloneDeep from \"lodash/cloneDeep\";\nimport groupBy from \"lodash/groupBy\";\nimport * as _ from \"underscore\";\nimport { v4 as uuidv4 } from \"uuid\";\n\n// Run with environment variable NODE_DEBUG=db (may include additional comma-separated sections)\n// for verbose logging.\nconst debuglog = util.debuglog(\"db\");\n\nconst maxSQLiteVariables = 500;     // Actually could be 999, so this is playing it safe.\n\nconst PENDING_VALUE = [GristObjCode.Pending];\n\nconst SHRINK_RATIO_FOR_PUSH = 0.1;\n\n// Number of days that soft-deleted attachments are kept in file storage before being completely deleted.\n// Once a file is deleted it can't be restored by undo, so we want it to be impossible or at least extremely unlikely\n// that someone would delete a reference to an attachment and then undo that action this many days later.\nexport const ATTACHMENTS_EXPIRY_DAYS = 7;\n\n// Cleanup expired attachments every hour (also happens when shutting down).\nexport const REMOVE_UNUSED_ATTACHMENTS_DELAY = { delayMs: 60 * 60 * 1000, varianceMs: 30 * 1000 };\n\n/**\n * Check what way we want to access SQLite files.\n */\nexport function getSqliteMode() {\n  return appSettings.section(\"features\")\n    .section(\"sqlite\").flag(\"mode\").readString({\n      envVar: \"GRIST_SQLITE_MODE\",\n      acceptedValues: [\"wal\", \"sync\"],\n    }) as \"wal\" | \"sync\" | undefined;\n}\n\nexport class DocStorage implements ISQLiteDB, OnDemandStorage {\n  // ======================================================================\n  // Static fields\n  // ======================================================================\n\n  /**\n   * Schema for all system tables, i.e. those that are NOT known by the data engine. Regular\n   * metadata tables (such as _grist_DocInfo) are created via DocActions received from\n   * InitNewDoc useraction.\n   *\n   * The current \"Storage Version\" used by Grist is the length of the migrations list in its\n   * Schema.  We use it to track changes to how data is stored on disk, and changes to the schema\n   * of non-data-engine tables (such as _gristsys_* tables). By contrast, \"Schema Version\" keeps\n   * track of the version of data-engine metadata. In SQLite, we use \"PRAGMA user_version\" to\n   * store the storage version number.\n   */\n  public static docStorageSchema: SchemaInfo = {\n    async create(db: SQLiteDB): Promise<void> {\n      await db.exec(`CREATE TABLE _gristsys_Files (\n        id INTEGER PRIMARY KEY,\n        ident TEXT UNIQUE,\n        data BLOB,\n        storageId TEXT\n       )`);\n      await db.exec(`CREATE TABLE _gristsys_Action (\n        id INTEGER PRIMARY KEY,\n        \"actionNum\" BLOB DEFAULT 0,\n        \"time\" BLOB DEFAULT 0,\n        \"user\" BLOB DEFAULT '',\n        \"desc\" BLOB DEFAULT '',\n        \"otherId\" BLOB DEFAULT 0,\n        \"linkId\" BLOB DEFAULT 0,\n        \"json\" BLOB DEFAULT ''\n      )`);\n      await db.exec(`CREATE TABLE _gristsys_Action_step (\n        id INTEGER PRIMARY KEY,\n        \"parentId\" BLOB DEFAULT 0,\n        \"type\" BLOB DEFAULT '',\n        \"name\" BLOB DEFAULT '',\n        \"tableId\" BLOB DEFAULT '',\n        \"colIds\" BLOB DEFAULT '',\n        \"rowIds\" BLOB DEFAULT '',\n        \"values\" BLOB DEFAULT '',\n        \"json\" BLOB DEFAULT ''\n      )`);\n      await db.exec(`CREATE TABLE _gristsys_ActionHistory (\n        id INTEGER PRIMARY KEY,       -- Plain integer action ID (\"actionRef\")\n        actionHash TEXT UNIQUE,       -- Action checksum\n        parentRef INTEGER,            -- id of parent of this action\n        actionNum INTEGER,            -- distance from root of tree in actions\n        body BLOB                     -- content of action\n      )`);\n      await db.exec(`CREATE TABLE _gristsys_ActionHistoryBranch (\n        id INTEGER PRIMARY KEY,       -- Numeric branch ID\n        name TEXT UNIQUE,             -- Branch name\n        actionRef INTEGER             -- Latest action on branch\n      )`);\n      for (const branchName of [\"shared\", \"local_sent\", \"local_unsent\"]) {\n        await db.run(\"INSERT INTO _gristsys_ActionHistoryBranch(name) VALUES(?)\",\n          branchName);\n      }\n      // This is a single row table (enforced by the CHECK on 'id'), containing non-shared info.\n      // - ownerInstanceId is the id of the instance which owns this copy of the Grist doc.\n      // - docId is also kept here because it should not be changeable by UserActions.\n      await db.exec(`CREATE TABLE _gristsys_FileInfo (\n        id INTEGER PRIMARY KEY CHECK (id = 0),\n        docId TEXT DEFAULT '',\n        ownerInstanceId TEXT DEFAULT ''\n      )`);\n      await db.exec(\"INSERT INTO _gristsys_FileInfo (id) VALUES (0)\");\n      await db.exec(`CREATE TABLE _gristsys_PluginData (\n        id INTEGER PRIMARY KEY,      -- Plain integer plugin data id\n        pluginId TEXT NOT NULL,      -- Plugin id\n        key TEXT NOT NULL,           -- the key\n        value BLOB DEFAULT ''        -- the value associated with the key\n        );\n        -- Plugins have unique keys.\n        CREATE UNIQUE INDEX _gristsys_PluginData_unique_key on _gristsys_PluginData(pluginId, key);`);\n    },\n    migrations: [\n      async function(db: SQLiteDB): Promise<void> {\n        // Storage version 1 does not require a migration. Docs at v1 (or before) may not all\n        // be the same, and are only made uniform by v2.\n      },\n      async function(db: SQLiteDB): Promise<void> {\n        // Storage version 2. We change the types of all columns to BLOBs.\n        // This applies to all Grist tables, including metadata.\n        const migrationLabel = \"DocStorage.docStorageSchema.migrations[v1->v2]\";\n        const oldMaxPosDefault = String(Math.pow(2, 31) - 1);\n\n        function _upgradeTable(tableId: string) {\n          log.debug(`${migrationLabel}: table ${tableId}`);\n          // This returns rows with (at least) {name, type, dflt_value}.\n          return db.all(`PRAGMA table_info(${quoteIdent(tableId)})`)\n            .then((infoRows) => {\n              const colListSql = infoRows.map(info => quoteIdent(info.name)).join(\", \");\n              const colSpecSql = infoRows.map(_sqlColSpec).join(\", \");\n              const tmpTableId = DocStorage._makeTmpTableId(tableId);\n              debuglog(`${migrationLabel}: ${tableId} (${colSpecSql})`);\n              return db.runEach(\n                `CREATE TABLE ${quoteIdent(tmpTableId)} (${colSpecSql})`,\n                `INSERT INTO ${quoteIdent(tmpTableId)} SELECT ${colListSql} FROM ${quoteIdent(tableId)}`,\n                `DROP TABLE ${quoteIdent(tableId)}`,\n                `ALTER TABLE ${quoteIdent(tmpTableId)} RENAME TO ${quoteIdent(tableId)}`,\n              );\n            });\n        }\n\n        function _sqlColSpec(info: ResultRow): string {\n          if (info.name === \"id\") { return \"id INTEGER PRIMARY KEY\"; }\n          // Fix the default for PositionNumber and ManualPos types, if set to a wrong old value.\n          const dfltValue = (info.type === \"REAL\" && info.dflt_value === oldMaxPosDefault) ?\n            DocStorage._formattedDefault(\"PositionNumber\") :\n            // The string \"undefined\" is also an invalid default; fix that too.\n            (info.dflt_value === \"undefined\" ? \"NULL\" : info.dflt_value);\n\n          return DocStorage._sqlColSpecFromDBInfo(Object.assign({}, info, {\n            type: \"BLOB\",\n            dflt_value: dfltValue,\n          }));\n        }\n\n        // Some migration-type steps pre-date storage migrations. We can do them once for the first\n        // proper migration (i.e. this one, to v2), and then never worry about them for upgraded docs.\n\n        // Create table for files that wasn't always created in the past.\n        await db.exec(`CREATE TABLE IF NOT EXISTS _gristsys_Files (\n          id INTEGER PRIMARY KEY,\n          ident TEXT UNIQUE,\n          data BLOB\n         )`);\n        // Create _gristsys_Action.linkId column that wasn't always created in the past.\n        try {\n          await db.exec(\"ALTER TABLE _gristsys_Action ADD COLUMN linkId INTEGER\");\n          log.debug(\"${migrationLabel}: Column linkId added to _gristsys_Action\");\n        } catch (err) {\n          if (!(/duplicate/.test(err.message))) {\n            // ok if column already existed\n            throw err;\n          }\n        }\n        // Deal with the transition to blob types\n        const tblRows = await db.all(\"SELECT name FROM sqlite_master WHERE type='table'\");\n        for (const tblRow of tblRows) {\n          // Note that _gristsys_Action tables in the past used Grist actions to create appropriate\n          // tables, so docs from that period would use BLOBs. For consistency, we upgrade those tables\n          // too.\n          if (tblRow.name.startsWith(\"_grist_\") || !tblRow.name.startsWith(\"_\") ||\n            tblRow.name.startsWith(\"_gristsys_Action\")) {\n            await _upgradeTable(tblRow.name);\n          }\n        }\n      },\n      async function(db: SQLiteDB): Promise<void> {\n        // Storage version 3. Convert old _gristsys_Action* tables to _gristsys_ActionHistory*.\n        await db.exec(`CREATE TABLE IF NOT EXISTS _gristsys_ActionHistory (\n          id INTEGER PRIMARY KEY,\n          actionHash TEXT UNIQUE,\n          parentRef INTEGER,\n          actionNum INTEGER,\n          body BLOB\n        )`);\n        await db.exec(`CREATE TABLE IF NOT EXISTS _gristsys_ActionHistoryBranch (\n          id INTEGER PRIMARY KEY,\n          name TEXT UNIQUE,\n          actionRef INTEGER\n        )`);\n        for (const branchName of [\"shared\", \"local_sent\", \"local_unsent\"]) {\n          await db.run(\"INSERT OR IGNORE INTO _gristsys_ActionHistoryBranch(name) VALUES(?)\",\n            branchName);\n        }\n        // Migrate any ActionLog information as best we can\n        const actions = await db.all(\"SELECT * FROM _gristsys_Action ORDER BY actionNum\");\n        const steps = groupBy(await db.all(\"SELECT * FROM _gristsys_Action_step ORDER BY id\"),\n          \"parentId\");\n        await db.execTransaction(async () => {\n          const history = new ActionHistoryImpl(db);\n          await history.initialize();\n          for (const action of actions) {\n            const step = steps[action.actionNum] || [];\n            const crudeTranslation: LocalActionBundle = {\n              actionNum: history.getNextHubActionNum(),\n              actionHash: null,\n              parentActionHash: null,\n              envelopes: [],\n              info: [\n                0,\n                {\n                  time: action.time,\n                  user: action.user,\n                  inst: \"\",\n                  desc: action.desc,\n                  otherId: action.otherId,\n                  linkId: action.linkId,\n                },\n              ],\n              // Take what was logged as a UserAction and treat it as a DocAction.  Summarization\n              // currently depends on stored+undo fields to understand what changed in an ActionBundle.\n              // DocActions were not logged prior to this version, so we have to fudge things a little.\n              stored: [[0, JSON.parse(action.json) as DocAction]],\n              calc: [],\n              userActions: [JSON.parse(action.json)],\n              undo: step.map(row => JSON.parse(row.json)),\n            };\n            await history.recordNextShared(crudeTranslation);\n          }\n          await db.run(\"DELETE FROM _gristsys_Action_step\");\n          await db.run(\"DELETE FROM _gristsys_Action\");\n        });\n      },\n      async function(db: SQLiteDB): Promise<void> {\n        // Storage version 4. Maintain docId and ownerInstanceId in a single-row special table;\n        // for standalone sharing.\n        await db.exec(`CREATE TABLE _gristsys_FileInfo (\n          id INTEGER PRIMARY KEY CHECK (id = 0),\n          docId TEXT DEFAULT '',\n          ownerInstanceId TEXT DEFAULT ''\n        )`);\n        await db.exec(\"INSERT INTO _gristsys_FileInfo (id) VALUES (0)\");\n      },\n      async function(db: SQLiteDB): Promise<void> {\n        // Storage version 5. Add a table to maintain per-plugin data, for plugins' Storage API.\n        await db.exec(`CREATE TABLE _gristsys_PluginData (\n          id INTEGER PRIMARY KEY,\n          pluginId TEXT NOT NULL,\n          key TEXT NOT NULL,\n          value BLOB DEFAULT ''\n          );\n          CREATE UNIQUE INDEX IF NOT EXISTS _gristsys_PluginData_unique_key on _gristsys_PluginData(pluginId, key);`);\n      },\n\n      async function(db: SQLiteDB): Promise<void> {\n        // Storage version 6. Migration to fix columns in user tables which have an incorrect\n        // DEFAULT for their Grist type, due to bug T462.\n        const migrationLabel = \"DocStorage.docStorageSchema.migrations[v5->v6]\";\n\n        const colRows: ResultRow[] = await db.all(\"SELECT t.tableId, c.colId, c.type \" +\n          \"FROM _grist_Tables_column c JOIN _grist_Tables t ON c.parentId=t.id\");\n        const docSchema = new Map<string, string>();   // Maps tableId.colId to grist type.\n        for (const { tableId, colId, type } of colRows) {\n          docSchema.set(`${tableId}.${colId}`, type);\n        }\n\n        // Fixes defaults and affected null values in a particular table.\n        async function _fixTable(tableId: string) {\n          log.debug(`${migrationLabel}: table ${tableId}`);\n          // This returns rows with (at least) {name, type, dflt_value}.\n          const infoRows: ResultRow[] = await db.all(`PRAGMA table_info(${quoteIdent(tableId)})`);\n          const origColSpecSql = infoRows.map(_sqlColSpec).join(\", \");\n\n          // Get the column SQL for what the columns should be, and the value SQL for how to\n          // prepare the values to fill them in.\n          const fixes = infoRows.map(r => _getInfoAndValuesSql(r, tableId));\n          const newColSpecSql = fixes.map(pair => pair[0]).map(_sqlColSpec).join(\", \");\n          const valuesSql = fixes.map(pair => pair[1]).join(\", \");\n\n          // Rebuild the table only if any column's SQL (e.g. DEFAULT values) have changed.\n          if (newColSpecSql === origColSpecSql) {\n            debuglog(`${migrationLabel}: ${tableId} unchanged: (${newColSpecSql})`);\n          } else {\n            debuglog(`${migrationLabel}: ${tableId} changed: (${newColSpecSql})`);\n            const tmpTableId = DocStorage._makeTmpTableId(tableId);\n            return db.runEach(\n              `CREATE TABLE ${quoteIdent(tmpTableId)} (${newColSpecSql})`,\n              `INSERT INTO ${quoteIdent(tmpTableId)} SELECT ${valuesSql} FROM ${quoteIdent(tableId)}`,\n              `DROP TABLE ${quoteIdent(tableId)}`,\n              `ALTER TABLE ${quoteIdent(tmpTableId)} RENAME TO ${quoteIdent(tableId)}`,\n            );\n          }\n        }\n\n        // Look up the type for a single column, and if the default changed to non-NULL, construct\n        // the updated column SQL and the value SQL for how to prepare values.\n        function _getInfoAndValuesSql(info: ResultRow, tableId: string): [ResultRow, string] {\n          const qColId = quoteIdent(info.name);\n          const gristType = docSchema.get(`${tableId}.${info.name}`);\n          if (gristType) {\n            const dflt = DocStorage._formattedDefault(gristType);\n            if (info.dflt_value === \"NULL\" && dflt !== \"NULL\") {\n              return [{ ...info, dflt_value: dflt }, `IFNULL(${qColId}, ${dflt}) as ${qColId}`];\n            }\n          }\n          return [info, qColId];\n        }\n\n        function _sqlColSpec(info: ResultRow): string {\n          if (info.name === \"id\") { return \"id INTEGER PRIMARY KEY\"; }\n          return DocStorage._sqlColSpecFromDBInfo(info);\n        }\n\n        // Go through all user tables and fix them.\n        const tblRows = await db.all(\"SELECT name FROM sqlite_master WHERE type='table'\");\n        for (const tblRow of tblRows) {\n          if (!tblRow.name.startsWith(\"_\")) {\n            await _fixTable(tblRow.name);\n          }\n        }\n      },\n\n      async function(db: SQLiteDB): Promise<void> {\n        // Storage version 7. Migration to store formulas in SQLite.\n        // Here, we only create empty columns for each formula column in the document. We let\n        // ActiveDoc, when it calculates formulas on open, detect that this migration just\n        // happened, and save the calculated results.\n        const colRows: ResultRow[] = await db.all(\"SELECT t.tableId, c.colId, c.type \" +\n          \"FROM _grist_Tables_column c JOIN _grist_Tables t ON c.parentId=t.id WHERE c.isFormula\");\n\n        // Go table by table.\n        const tableColRows = groupBy(colRows, \"tableId\");\n        for (const tableId of Object.keys(tableColRows)) {\n          // There should be no columns conflicting with formula columns, but we check and skip\n          // them if there are.\n          const infoRows = await db.all(`PRAGMA table_info(${quoteIdent(tableId)})`);\n          const presentCols = new Set([...infoRows.map(row => row.name)]);\n          const newCols = tableColRows[tableId].filter(c => !presentCols.has(c.colId));\n\n          // Create all new columns.\n          for (const { colId, type } of newCols) {\n            await db.exec(`ALTER TABLE ${quoteIdent(tableId)} ` +\n              `ADD COLUMN ${DocStorage._columnDefWithBlobs(colId, type)}`);\n          }\n\n          // Fill them in with PENDING_VALUE. This way, on first load and Calculate, they would go\n          // from \"Loading...\" to their proper value. After the migration, they should never have\n          // PENDING_VALUE again.\n          const colListSql = newCols.map(c => `${quoteIdent(c.colId)}=?`).join(\", \");\n          const types = newCols.map(c => c.type);\n          const sqlParams = DocStorage._encodeColumnsToRows(types, newCols.map(c => [PENDING_VALUE]));\n          await db.run(`UPDATE ${quoteIdent(tableId)} SET ${colListSql}`, ...sqlParams[0]);\n        }\n      },\n\n      async function(db: SQLiteDB): Promise<void> {\n        // Storage version 8.\n        // Migration to add an index to _grist_Attachments.fileIdent for fast joining against _gristsys_Files.ident.\n        const tables = await db.all(`SELECT * FROM sqlite_master WHERE type='table' AND name='_grist_Attachments'`);\n        if (!tables.length) {\n          // _grist_Attachments is created in the first Python migration so doesn't exist here for new documents.\n          // createAttachmentsIndex is called separately by ActiveDoc for that.\n          return;\n        }\n        await createAttachmentsIndex(db);\n      },\n      async function(db: SQLiteDB): Promise<void> {\n        // Storage version 9.\n        // Migration to add `storage` column to _gristsys_Files, which can optionally refer to an external storage\n        // where the file is stored.\n        // Default should be NULL.\n        await db.exec(`ALTER TABLE _gristsys_Files ADD COLUMN storageId TEXT`);\n      },\n    ],\n  };\n\n  /**\n   * Decodes a database row object, returning a new object with decoded values. This is needed for\n   * Grist data, which is encoded.  Careful: doesn't handle booleans specially, should not\n   * be used within main Grist application.\n   */\n  public static decodeRowValues(dbRow: ResultRow): any {\n    return _.mapObject(dbRow, val => DocStorage._decodeValue(val, \"Any\", \"BLOB\"));\n  }\n\n  /**\n   * Internal helper to distinguish which tables contain information about the metadata\n   * that docstorage needs to keep track of\n   */\n  private static _isMetadataTable(tableId: string): boolean {\n    return tableId === \"_grist_Tables\" || tableId === \"_grist_Tables_column\";\n  }\n\n  /**\n   * Shortcut to get the SQL default for the given Grist type.\n   */\n  private static _formattedDefault(colType: string): any {\n    return gristTypes.getDefaultForType(colType, { sqlFormatted: true });\n  }\n\n  /**\n   * Join array of strings by prefixing each one with sep.\n   */\n  private static _prefixJoin(sep: string, array: string[]): string {\n    return array.length ? sep + array.join(sep) : \"\";\n  }\n\n  /**\n   * Internal helper to make a tmp table given a tableId\n   *\n   * @param {String} tableId\n   * @returns {String}\n   */\n  private static _makeTmpTableId(tableId: string): string {\n    return \"_tmp_\" + tableId;\n  }\n\n  private static _sqlColSpecFromDBInfo(info: ResultRow): string {\n    return `${quoteIdent(info.name)} ${info.type} DEFAULT ${info.dflt_value}`;\n  }\n\n  /**\n   * Converts an array of columns to an array of rows (suitable to use as sqlParams), encoding all\n   * values as needed, according to an array of Grist type strings (must be parallel to columns).\n   */\n  private static _encodeColumnsToRows(types: string[], valueColumns: any[]): any[][] {\n    const marshaller = new marshal.Marshaller({ version: 2 });\n    const rows = _.unzip(valueColumns);\n    for (const row of rows) {\n      for (let i = 0; i < row.length; i++) {\n        row[i] = DocStorage._encodeValue(marshaller, types[i], this._getSqlType(types[i]), row[i]);\n      }\n    }\n    return rows;\n  }\n\n  /**\n   * Encodes a single value for storing in SQLite. Numbers and text are stored as is, but complex\n   * types are marshalled and stored as BLOBs. We also marshal binary data, so that for encoded\n   * data, all BLOBs consistently contain marshalled data.\n   *\n   * Note that SQLite may contain tables that aren't used for Grist data (e.g. attachments), for\n   * which such encoding/marshalling is not used, and e.g. binary data is stored to BLOBs directly.\n   */\n  private static _encodeValue(\n    marshaller: marshal.Marshaller, gristType: string, sqlType: string, val: any,\n  ): Uint8Array | string | number | boolean | null {\n    const marshalled = () => {\n      marshaller.marshal(val);\n      return marshaller.dump();\n    };\n    if (gristType == \"ChoiceList\") {\n      // See also app/plugin/objtype.ts for decodeObject(). Here we manually check and decode\n      // the \"List\" object type.\n      if (isList(val) && val.every(tok => (typeof (tok) === \"string\"))) {\n        return JSON.stringify(val.slice(1));\n      }\n    } else if (isRefListType(gristType)) {\n      if (isList(val) && val.slice(1).every((tok: any) => (typeof (tok) === \"number\"))) {\n        return JSON.stringify(val.slice(1));\n      }\n    }\n    // Marshall anything non-primitive.\n    if (Array.isArray(val) || val instanceof Uint8Array || Buffer.isBuffer(val)) {\n      return marshalled();\n    }\n    // Leave nulls unchanged.\n    if (val === null) { return val; }\n    // Return undefined as null.\n    if (val === undefined) { return null; }\n    // At this point, we have a non-null primitive.  Check what is the Sqlite affinity\n    // of the destination.  May be NUMERIC, INTEGER, TEXT, or BLOB.  We handle REAL\n    // also even though it is not currently used.\n    const affinity = this._getAffinity(sqlType);\n    // For strings, numbers, and booleans, we have distinct strategies and problems.\n    switch (typeof (val)) {\n      case \"string\":\n        // Strings are easy with TEXT and BLOB affinity, they can be stored verbatim.\n        if (affinity === \"TEXT\" || affinity === \"BLOB\") { return val; }\n        // With an INTEGER, NUMERIC, or REAL affinity, we need to be careful since\n        // if the string looks like a number it will get cast.\n        // See vdbe.c:applyNumericAffinity in SQLite source code for\n        // details.  From reading the code, anything that doesn't start\n        // with '+', '-' or '.', or a digit, or whitespace is certainly safe.\n        // Whitespace is a little bit fuzzy, could perhaps depend on locale depending\n        // on how compiled?\n        if (!/[-+ \\t\\n\\r\\v0-9.]/.test(val.charAt(0))) {\n          return val;\n        }\n        // We could make further tests, but that'll increase our odds of\n        // getting it wrong and letting a string through that gets unexpectedly\n        // converted.  So marshall everything else.\n        return marshalled();\n      case \"number\":\n        // Marshal with TEXT affinity, and handle some other awkward cases.\n        if (affinity === \"TEXT\" || Number.isNaN(val) || Object.is(val, -0.0) ||\n          (sqlType === \"BOOLEAN\" && (val === 0 || val === 1))) {\n          return marshalled();\n        }\n        // Otherwise, SQLite will handle numbers safely.\n        return val;\n      case \"boolean\":\n        // Booleans are only safe to store in columns of grist type Bool\n        // (SQL type BOOLEAN), since they will be consistently unencoded as\n        // booleans.\n        return (sqlType === \"BOOLEAN\") ? val : marshalled();\n    }\n    return marshalled();\n  }\n\n  /**\n   * Decodes Grist data received from SQLite; the inverse of _encodeValue().\n   * Both Grist and SQL types are expected. Used to interpret Bool/BOOLEANs, and to parse\n   * ChoiceList values.\n   */\n  private static _decodeValue(val: any, gristType: string, sqlType: string): any {\n    if (val instanceof Uint8Array || Buffer.isBuffer(val)) {\n      val = marshal.loads(val);\n    }\n    if (gristType === \"Bool\") {\n      if (val === 0 || val === 1) {\n        // Boolean values come in as 0/1. If the column is of type \"Bool\", interpret those as\n        // true/false (note that the data engine does this too).\n        return Boolean(val);\n      }\n    }\n    if (isListType(gristType)) {\n      if (typeof val === \"string\" && val.startsWith(\"[\")) {\n        try {\n          return [\"L\", ...JSON.parse(val)];\n        } catch (e) {\n          // Fall through without parsing\n        }\n      }\n    }\n    return val;\n  }\n\n  /**\n   * Helper to return SQL snippet for column definition, using its colId and Grist type.\n   */\n  private static _columnDef(colId: string, colType: string): string {\n    const colSqlType = DocStorage._getSqlType(colType);\n    return `${quoteIdent(colId)} ${colSqlType} DEFAULT ${DocStorage._formattedDefault(colType)}`;\n  }\n\n  /**\n   * As _columnDef(), but column type is strictly Blobs.  Used to maintain an old migration.\n   * TODO: could probably rip out the Blob migration and update all related tests.\n   */\n  private static _columnDefWithBlobs(colId: string, colType: string): string {\n    return `${quoteIdent(colId)} BLOB DEFAULT ${DocStorage._formattedDefault(colType)}`;\n  }\n\n  /**\n   * Based on a Grist type, pick a good Sqlite SQL type name to use.  Sqlite columns\n   * are loosely typed, and the types named here are not all distinct in terms of\n   * 'affinities', but they are helpful as comments.  Type names chosen from:\n   *   https://www.sqlite.org/datatype3.html#affinity_name_examples\n   */\n  private static _getSqlType(colType: string | null): string {\n    switch (colType) {\n      case \"Bool\":\n        return \"BOOLEAN\";\n      case \"Choice\":\n      case \"Text\":\n        return \"TEXT\";\n      case \"ChoiceList\":\n      case \"RefList\":\n      case \"ReferenceList\":\n      case \"Attachments\":\n        return \"TEXT\";      // To be encoded as a JSON array of strings.\n      case \"Date\":\n        return \"DATE\";\n      case \"DateTime\":\n        return \"DATETIME\";\n      case \"Int\":\n      case \"Id\":\n      case \"Ref\":\n      case \"Reference\":\n        return \"INTEGER\";\n      case \"Numeric\":\n      case \"ManualSortPos\":\n      case \"PositionNumber\":\n        return \"NUMERIC\";\n    }\n    if (colType) {\n      if (colType.startsWith(\"Ref:\")) {\n        return \"INTEGER\";\n      }\n      if (colType.startsWith(\"RefList:\")) {\n        return \"TEXT\";      // To be encoded as a JSON array of strings.\n      }\n    }\n    return \"BLOB\";\n  }\n\n  /**\n   * For a SQL type, figure out the closest affinity in Sqlite.\n   * Only SQL types output by _getSqlType are recognized.\n   * Result is one of NUMERIC, INTEGER, TEXT, or BLOB.\n   * We don't use REAL, the only remaining affinity.\n   */\n  private static _getAffinity(colType: string | null): string {\n    switch (colType) {\n      case \"TEXT\":\n        return \"TEXT\";\n      case \"INTEGER\":\n        return \"INTEGER\";\n      case \"BOOLEAN\":\n      case \"DATE\":\n      case \"DATETIME\":\n      case \"NUMERIC\":\n        return \"NUMERIC\";\n    }\n    return \"BLOB\";\n  }\n\n  // ======================================================================\n  // Instance fields\n  // ======================================================================\n\n  public docPath: string; // path to document file on disk\n  private _db: SQLiteDB | null | undefined; // database handle\n\n  // Maintains { tableId: { colId: gristType } } mapping for all tables, including grist metadata\n  // tables (obtained from auto-generated schema.js).\n  private _docSchema: { [tableId: string]: { [colId: string]: string } };\n\n  private _cachedDataSize: number | null = null;\n\n  public constructor(public storageManager: IDocStorageManager, public docName: string) {\n    this.docPath = this.storageManager.getPath(docName);\n    this._db = null;\n    this._docSchema = Object.assign({}, schema.schema);\n  }\n\n  /**\n   * Opens an existing SQLite database and prepares it for use.\n   */\n  public openFile(hooks: MigrationHooks = {}): Promise<void> {\n    // It turns out to be important to return a bluebird promise, a lot of code outside\n    // of DocStorage ultimately depends on this.\n    return bluebird.Promise.resolve(this._openFile(OpenMode.OPEN_EXISTING, hooks))\n      .then(() => this._initDB())\n      .then(() => this._updateMetadata());\n  }\n\n  /**\n   * Creates a new SQLite database. Will throw an error if the database already exists.\n   * After a database is created it should be initialized by applying the InitNewDoc action\n   * or by executing the initialDocSql.\n   */\n  public createFile(options?: {\n    useExisting?: boolean,  // If set, it is ok if an sqlite file already exists\n    // where we would store the Grist document. Its content\n    // will not be touched. Useful when \"gristifying\" an\n    // existing SQLite DB.\n  }): Promise<void> {\n    // It turns out to be important to return a bluebird promise, a lot of code outside\n    // of DocStorage ultimately depends on this.\n    return bluebird.Promise.resolve(this._openFile(\n      options?.useExisting ? OpenMode.OPEN_EXISTING : OpenMode.CREATE_EXCL,\n      {}))\n      .then(() => this._initDB());\n    // Note that we don't call _updateMetadata() as there are no metadata tables yet anyway.\n  }\n\n  public isInitialized(): boolean {\n    return Boolean(this._db);\n  }\n\n  /**\n   * Initializes the database with proper settings.\n   */\n  public _initDB(): Promise<void> {\n    // Set options for speed across multiple OSes/Filesystems.\n    // WAL is fast and safe (guarantees consistency across crashes), but has disadvantages\n    // including generating unwanted extra files that can be tricky to deal with in renaming, etc\n    // the options for WAL are commented out\n    // Setting synchronous to OFF is the fastest method, but is not as safe, and could lead to\n    // a database being corrupted if the computer it is running on crashes.\n    // TODO: Switch setting to FULL, but don't wait for SQLite transactions to finish before\n    // returning responses to the user. Instead send error messages on unexpected errors.\n    const settings = [\n      \"PRAGMA trusted_schema = OFF;\",  // mitigation suggested by https://www.sqlite.org/security.html#untrusted_sqlite_database_files\n    ];\n    const sqliteMode = getSqliteMode();\n    if (sqliteMode === undefined) {\n      // Historically, Grist has used this setting.\n      settings.push(\"PRAGMA synchronous = OFF;\");\n    } else if (sqliteMode === \"sync\") {\n      // This is a safer, but potentially slower, setting for general use.\n      settings.push(\"PRAGMA synchronous = FULL;\");\n    } else if (sqliteMode === \"wal\") {\n      // This is a good modern setting for servers, but awkward\n      // on a Desktop for users who interact with their documents\n      // directly as files on the file system. With WAL, at any\n      // time, changes may be stored in a companion file rather\n      // than the .grist file.\n      settings.push(\"PRAGMA journal_mode = WAL;\");\n    }\n    return this._getDB().exec(settings.join(\"\\n\"));\n  }\n\n  /**\n   * Queries the database for Grist metadata and updates this._docSchema. It extends the auto-\n   * generated mapping in app/common/schema.js, to all tables, as `{tableId: {colId: gristType}}`.\n   */\n  public _updateMetadata(): Promise<void> {\n    return this.all(\"SELECT t.tableId, c.colId, c.type \" +\n      \"FROM _grist_Tables_column c JOIN _grist_Tables t ON c.parentId=t.id\")\n      .then((rows: ResultRow[]) => {\n        const s: { [key: string]: any } = {};\n        for (const { tableId, colId, type } of rows) {\n          const table = s.hasOwnProperty(tableId) ? s[tableId] : (s[tableId] = {});\n          table[colId] = type;\n        }\n        // Note that schema is what's imported from app/common/schema.js\n        this._docSchema = Object.assign(s, schema.schema);\n      })\n      .catch((err) => {\n        // This replicates previous logic for _updateMetadata.\n        // It matches errors from node-sqlite3 and better-sqlite3\n        if (err.message.startsWith(\"SQLITE_ERROR: no such table\") ||\n          err.message.startsWith(\"no such table:\")) {\n          err.message = `NO_METADATA_ERROR: ${this.docName} has no metadata`;\n          if (!err.cause) { err.cause = {}; }\n          err.cause.code = \"NO_METADATA_ERROR\";\n        }\n        throw err;\n      });\n  }\n\n  /**\n   * Closes the SQLite database.\n   */\n  public async shutdown(): Promise<void> {\n    if (!this._db) {\n      log.debug(\"DocStorage shutdown (trivial) success\");\n      return;\n    }\n    const db = this._getDB();\n    this._db = null;\n    await db.close();\n    log.debug(\"DocStorage shutdown success\");\n  }\n\n  /**\n   * Attaches the file to the document.\n   *\n   * TODO: This currently does not make the attachment available to the sandbox code. This is likely\n   * to be needed in the future, and a suitable API will need to be provided. Note that large blobs\n   * would be (very?) inefficient until node-sqlite3 adds support for incremental reading from a\n   * blob: https://github.com/mapbox/node-sqlite3/issues/424.\n   *\n   * @param {string} fileIdent - The unique identifier of the file in the database.\n   * @param {Buffer | undefined} fileData - Contents of the file.\n   * @param {string | undefined} storageId - Identifier of the store that file is stored in.\n   * @returns {Promise[Boolean]} True if the file got attached; false if this ident already exists.\n   */\n  public attachFileIfNew(\n    fileIdent: string,\n    fileData: Buffer | undefined,\n    storageId?: string,\n  ): Promise<boolean> {\n    return this.execTransaction(async (db) => {\n      const isNewFile = await this._addBasicFileRecord(db, fileIdent);\n      if (isNewFile) {\n        await this._updateFileRecord(db, fileIdent, fileData, storageId);\n      }\n      return isNewFile;\n    });\n  }\n\n  /**\n   * Attaches a file to the document, updating the file record if it already exists.\n   *\n   * TODO: This currently does not make the attachment available to the sandbox code. This is likely\n   * to be needed in the future, and a suitable API will need to be provided. Note that large blobs\n   * would be (very?) inefficient until node-sqlite3 adds support for incremental reading from a\n   * blob: https://github.com/mapbox/node-sqlite3/issues/424.\n   *\n   * @param {string} fileIdent - The unique identifier of the file in the database.\n   * @param {Buffer | undefined} fileData - Contents of the file.\n   * @param {string | undefined} storageId - Identifier of the store that file is stored in.\n   * @returns {Promise[Boolean]} True if the file got attached; false if this ident already exists.\n   */\n  public attachOrUpdateFile(\n    fileIdent: string,\n    fileData: Buffer | undefined,\n    storageId?: string,\n  ): Promise<boolean> {\n    return this.execTransaction(async (db) => {\n      const isNewFile = await this._addBasicFileRecord(db, fileIdent);\n      await this._updateFileRecord(db, fileIdent, fileData, storageId);\n      return isNewFile;\n    });\n  }\n\n  /**\n   * Reads and returns the data for the given attachment.\n   * @param {string} fileIdent - The unique identifier of a file, as used by attachFileIfNew.\n   *   file identifier.\n   * @returns {Promise[FileInfo | null]} - File information, or null if no record exists for that file identifier.\n   */\n  public async getFileInfo(fileIdent: string): Promise<FileInfo | null> {\n    const row = await this.get(`SELECT ident, storageId, data FROM _gristsys_Files WHERE ident=?`, fileIdent);\n    if (!row) {\n      return null;\n    }\n\n    return {\n      ident: row.ident as string,\n      storageId: (row.storageId ?? null) as (string | null),\n      // Use a zero buffer for now if it doesn't exist. Should be refactored to allow null.\n      data: row.data ? row.data as Buffer : Buffer.alloc(0),\n    };\n  }\n\n  /**\n   * Reads and returns the metadata for a file, without retrieving the file's contents.\n   * @param {string} fileIdent - The unique identifier of a file, as used by attachFileIfNew.\n   * @returns {Promise[FileInfo | null]} - File information, or null if no record exists for that\n   *   file identifier.\n   */\n  public async getFileInfoNoData(fileIdent: string): Promise<FileInfo | null> {\n    const row = await this.get(`SELECT ident, storageId FROM _gristsys_Files WHERE ident=?`, fileIdent);\n    if (!row) {\n      return null;\n    }\n\n    return {\n      ident: row.ident as string,\n      storageId: (row.storageId ?? null) as (string | null),\n      // Use a zero buffer for now if it doesn't exist. Should be refactored to allow null.\n      data: Buffer.alloc(0),\n    };\n  }\n\n  public async listAllFiles(): Promise<FileInfo[]> {\n    const rows = await this.all(`SELECT ident, storageId FROM _gristsys_Files`);\n\n    return rows.map(row => ({\n      ident: row.ident as string,\n      storageId: (row.storageId ?? null) as (string | null),\n      // Use a zero buffer for now to represent no data. Should be refactored to allow null.\n      data: Buffer.alloc(0),\n    }));\n  }\n\n  /**\n   * Fetches the given table from the database. See fetchQuery() for return value.\n   */\n  public fetchTable(tableId: string): Promise<Buffer> {\n    return this.fetchQuery({ tableId, filters: {} });\n  }\n\n  /**\n   * Returns as a number the next row id for the given table.\n   */\n  public async getNextRowId(tableId: string): Promise<number> {\n    const colData = await this.get(`SELECT MAX(id) as maxId FROM ${quoteIdent(tableId)}`);\n    if (!colData) {\n      throw new Error(`Error in DocStorage.getNextRowId: no table ${tableId}`);\n    }\n    return colData.maxId ? colData.maxId + 1 : 1;\n  }\n\n  /**\n   * Look up Grist type of column.\n   */\n  public getColumnType(tableId: string, colId: string): string | undefined {\n    return this._docSchema[tableId]?.[colId];\n  }\n\n  /**\n   * Fetches all rows of the table with the given rowIds.\n   */\n  public async fetchActionData(tableId: string, rowIds: number[], colIds?: string[]): Promise<TableDataAction> {\n    const colSpec = colIds ? [\"id\", ...colIds].map(c => quoteIdent(c)).join(\", \") : \"*\";\n    let fullValues: TableColValues | undefined;\n\n    // There is a limit to the number of arguments that may be passed in, so fetch data in chunks.\n    for (const rowIdChunk of chunk(rowIds, maxSQLiteVariables)) {\n      const sqlArg = rowIdChunk.map(() => \"?\").join(\",\");\n      const marshalled: Buffer = await this._getDB().allMarshal(\n        `SELECT ${colSpec} FROM ${quoteIdent(tableId)} WHERE id IN (${sqlArg})`, rowIdChunk);\n\n      const colValues: TableColValues = this.decodeMarshalledData(marshalled, tableId);\n      if (!fullValues) {\n        fullValues = colValues;\n      } else {\n        for (const col of Object.keys(colValues)) {\n          fullValues[col].push(...colValues[col]);\n        }\n      }\n    }\n    return toTableDataAction(tableId, fullValues || { id: [] });    // Return empty TableColValues if rowIds was empty.\n  }\n\n  /**\n   * Fetches a subset of the data specified by the given query, and returns an encoded TableData\n   * object, which is a marshalled dict mapping column ids (including 'id') to arrays of values.\n   *\n   * This now essentially subsumes the old fetchTable() method.\n   * Note that text is marshalled as unicode and blobs as binary strings (used to be binary strings\n   * for both before 2017-11-09). This allows blobs to be used exclusively for encoding types that\n   * are not easily stored as sqlite's native types.\n   */\n  public async fetchQuery(query: ExpandedQuery): Promise<Buffer> {\n    // Check if there are a lot of parameters, and if so, switch to a method that can support\n    // that.\n    const totalParameters = Object.values(query.filters).map(vs => vs.length).reduce((a, b) => a + b, 0);\n    if (totalParameters > maxSQLiteVariables) {\n      // Fall back on using temporary tables if there are many parameters.\n      return this._fetchQueryWithManyParameters(query);\n    }\n\n    // Convert query to SQL.\n    const params: any[] = query.where?.params || [];\n    const whereParts: string[] = [];\n    for (const colId of Object.keys(query.filters)) {\n      const values = query.filters[colId];\n      // If values is empty, \"IN ()\" works in SQLite (always false), but wouldn't work in Postgres.\n      whereParts.push(`${quoteIdent(query.tableId)}.${quoteIdent(colId)} IN (${values.map(() => \"?\").join(\", \")})`);\n      params.push(...values);\n    }\n    const sql = this._getSqlForQuery(query, whereParts);\n    return this._getDB().allMarshal(sql, ...params);\n  }\n\n  /**\n   * Fetches and returns the names of all tables in the database (including _gristsys_ tables).\n   */\n  public async getAllTableNames(): Promise<string[]> {\n    const rows = await this.all(\"SELECT name FROM sqlite_master WHERE type='table'\");\n    return rows.map(row => row.name);\n  }\n\n  /**\n   * Unmarshals and decodes data received from db.allMarshal() method (which we added to node-sqlite3).\n   * The data is a dictionary mapping column ids (including 'id') to arrays of values. This should\n   * be used for Grist data, which is encoded. For non-Grist data, use `marshal.loads()`.\n   *\n   * Note that we do NOT use this when loading data from a document, since the whole point of\n   * db.allMarshal() is to pass data directly to Python data engine without parsing in Node.\n   */\n  public decodeMarshalledData(marshalledData: Buffer | Uint8Array, tableId: string): TableColValues {\n    const columnValues: TableColValues = marshal.loads(marshalledData);\n    // Decode in-place to avoid unnecessary array creation.\n    for (const col of Object.keys(columnValues)) {\n      const type = this._getGristType(tableId, col);\n      const column = columnValues[col];\n      for (let i = 0; i < column.length; i++) {\n        column[i] = DocStorage._decodeValue(column[i], type, DocStorage._getSqlType(type));\n      }\n    }\n    return columnValues;\n  }\n\n  /**\n   * Variant of `decodeMarshalledData` that supports decoding data containing columns from\n   * multiple tables.\n   *\n   * Expects all column names in `marshalledData` to be prefixed with the table id and a\n   * trailing period (separator).\n   */\n  public decodeMarshalledDataFromTables(marshalledData: Buffer | Uint8Array): BulkColValues {\n    const columnValues: BulkColValues = marshal.loads(marshalledData);\n    // Decode in-place to avoid unnecessary array creation.\n    for (const col of Object.keys(columnValues)) {\n      const [tableId, colId] = col.split(\".\");\n      const type = this._getGristType(tableId, colId);\n      const column = columnValues[col];\n      for (let i = 0; i < column.length; i++) {\n        column[i] = DocStorage._decodeValue(column[i], type, DocStorage._getSqlType(type));\n      }\n    }\n    return columnValues;\n  }\n\n  /**\n   * Applies stored actions received from data engine to the database by converting them to SQL\n   * statements and executing a serialized transaction.\n   * @param {Array[DocAction]} docActions - Array of doc actions from DataEngine.\n   * @returns {Promise} - An empty promise, resolved if successfully committed to db.\n   */\n  public async applyStoredActions(docActions: DocAction[]): Promise<void> {\n    debuglog(\"DocStorage.applyStoredActions\");\n\n    docActions = this._compressStoredActions(docActions);\n    for (const action of docActions) {\n      try {\n        await this.applyStoredAction(action);\n      } catch (e) {\n        // If the table doesn't have a manualSort column, we'll try\n        // again without setting manualSort. This should never happen\n        // for regular Grist documents, but could happen for a\n        // \"gristified\" Sqlite database where we are choosing to\n        // leave the user tables untouched. The manualSort column doesn't\n        // make much sense outside the context of spreadsheets.\n        // TODO: it could be useful to make Grist more inherently aware of\n        // and tolerant of tables without manual sorting.\n        if (String(e).match(/no column named manualSort/)) {\n          const modifiedAction = this._considerWithoutManualSort(action);\n          if (modifiedAction) {\n            await this.applyStoredAction(modifiedAction);\n            return;\n          }\n        }\n        throw e;\n      }\n    }\n  }\n\n  // Apply a single stored action, dispatching to an appropriate\n  // _process_<ActionType> handler.\n  public async applyStoredAction(action: DocAction): Promise<void> {\n    const actionType = action[0];\n    const f = (this as any)[\"_process_\" + actionType];\n    if (!_.isFunction(f)) {\n      log.error(\"Unknown action: \" + actionType);\n    } else {\n      await f.apply(this, action.slice(1));\n      const tableId = action[1]; // The first argument is always tableId;\n      if (DocStorage._isMetadataTable(tableId) && actionType !== \"AddTable\") {\n        // We only need to update the metadata for actions that change\n        // the metadata. We don't update on AddTable actions\n        // because the additional of a table gives no additional data\n        // and if we tried to update when only _grist_Tables was added\n        // without _grist_Tables_column, we would get an error\n        await this._updateMetadata();\n      }\n    }\n  }\n\n  /**\n   * Internal helper to process AddTable action.\n   *\n   * @param {String} tableId - Table ID.\n   * @param {Array[Object]} columns - List of column objects with schema attributes.\n   * @returns {Promise} - A promise for the SQL execution.\n   */\n  public _process_AddTable(tableId: string, columns: any[]): Promise<void> {\n    const colSpecSql =\n      DocStorage._prefixJoin(\", \",\n        columns.map(c => DocStorage._columnDef(c.id, c.type)));\n\n    // Every table needs an \"id\" column, and it should be an \"integer primary key\" type so that it\n    // serves as the alias for the SQLite built-in \"rowid\" column. See\n    // https://www.sqlite.org/lang_createtable.html#rowid for details.\n    const sql = `CREATE TABLE ${quoteIdent(tableId)} (id INTEGER PRIMARY KEY${colSpecSql})`;\n    log.debug(\"AddTable SQL : \" + sql);\n\n    return this.exec(sql);\n  }\n\n  /**\n   * Internal helper to process UpdateRecord action.\n   *\n   * @param {String} tableId - Table Id.\n   * @param {String} rowId - Row Id.\n   * @param {Object} columnValues - Column object with keys as column names.\n   * @returns {Promise} - A promise for the SQL execution.\n   */\n  public _process_UpdateRecord(tableId: string, rowId: string, columnValues: any): Promise<void> {\n    // Do some small preprocessing to make this look like a BulkUpdateRecord\n    return this._process_BulkUpdateRecord(tableId, [rowId], _.mapObject(columnValues, (val: any) => [val]));\n  }\n\n  /**\n   * Internal helper to process AddRecord action.\n   *\n   * @param {String} tableId - Table ID.\n   * @param {Integer} rowId - Row ID.\n   * @param {Object} columnValues - Column object with keys as column names.\n   * @returns {Promise} - A promise for the SQL execution.\n   */\n  public _process_AddRecord(tableId: string, rowId: number, columnValues: any): Promise<void> {\n    // Do some small preprocessing to make this look like a BulkAddRecord\n    return this._process_BulkAddRecord(tableId, [rowId], _.mapObject(columnValues, (val: any) => [val]));\n  }\n\n  /**\n   * Internal helper to process BulkUpdateRecord action.\n   *\n   * @param {String} tableId - Table Id.\n   * @param {Array[String]} rowIds - List of Row Ids.\n   * @param {Object} columnValues - Column object with keys as column names and arrays of values.\n   * @returns {Promise} - Promise for SQL execution.\n   */\n  public _process_BulkUpdateRecord(tableId: string, rowIds: string[], columnValues: any): Promise<void> {\n    const cols = Object.keys(columnValues);\n    if (!rowIds.length || !cols.length) { return Promise.resolve(); }  // Nothing to do.\n\n    const colListSql = cols.map(c => quoteIdent(c) + \"=?\").join(\", \");\n    const sql = `UPDATE ${quoteIdent(tableId)} SET ${colListSql} WHERE id=?`;\n\n    const types = cols.map(c => this._getGristType(tableId, c));\n    const sqlParams = DocStorage._encodeColumnsToRows(types, cols.map(c => columnValues[c]).concat([rowIds]));\n\n    debuglog(\"DocStorage._maybeBulkUpdateRecord SQL: %s (%s rows)\", sql, sqlParams.length);\n    return this._applyMaybeBulkUpdateOrAddSql(sql, sqlParams);\n  }\n\n  /**\n   * Internal helper to process BulkAddRecord action.\n   *\n   * @param {String} tableId - Table ID.\n   * @param {Array[Integer]} rowIds - Array of row IDs to be inserted.\n   * @param {Array[Object]} columnValues - Array of column info objects.\n   * @returns {Promise} - Promise for SQL execution.\n   */\n  public _process_BulkAddRecord(\n    tableId: string, rowIds: number[], columnValues: { [key: string]: any },\n  ): Promise<void> {\n    if (rowIds.length === 0) { return Promise.resolve(); } // no rows means nothing to do\n\n    const cols = Object.keys(columnValues);\n    const colListSql = cols.map(c => quoteIdent(c) + \", \").join(\"\");\n    const placeholders = cols.map(c => \"?, \").join(\"\");\n    const sql = `INSERT INTO ${quoteIdent(tableId)} (${colListSql}id) VALUES (${placeholders}?)`;\n\n    const types = cols.map(c => this._getGristType(tableId, c));\n    const sqlParams =\n      DocStorage._encodeColumnsToRows(types,\n        cols.map(c => columnValues[c]).concat([rowIds]));\n\n    debuglog(\"DocStorage._maybeBulkAddRecord SQL: %s (%s rows)\", sql, sqlParams.length);\n    return this._applyMaybeBulkUpdateOrAddSql(sql, sqlParams);\n  }\n\n  /**\n   * Internal helper to process RemoveRecord action.\n   *\n   * @param {String} tableId - Table ID.\n   * @param {String} rowId   - Row ID.\n   * @returns {Promise} - A promise for the SQL execution.\n   */\n  public _process_RemoveRecord(tableId: string, rowId: string): Promise<RunResult> {\n    const sql = \"DELETE FROM \" + quoteIdent(tableId) + \" WHERE id=?\";\n    debuglog(\"RemoveRecord SQL: \" + sql, [rowId]);\n    return this.run(sql, rowId);\n  }\n\n  /**\n   * Internal helper to process ReplaceTableData action. It is identical to BulkAddRecord, but\n   * deletes all data from the table first.\n   */\n  public _process_ReplaceTableData(tableId: string, rowIds: number[], columnValues: any[]): Promise<void> {\n    return this.exec(\"DELETE FROM \" + quoteIdent(tableId))\n      .then(() => this._process_BulkAddRecord(tableId, rowIds, columnValues));\n  }\n\n  /**\n   * Internal helper to process BulkRemoveRecord action.\n   *\n   * @param {String} tableId        - Table ID.\n   * @param {Array[Integer]} rowIds - Array of row IDs to be deleted.\n   * @returns {Promise} - Promise for SQL execution.\n   */\n  public async _process_BulkRemoveRecord(tableId: string, rowIds: number[]): Promise<void> {\n    if (rowIds.length === 0) { return; }// If we have nothing to remove, done.\n\n    const chunkSize = 10;\n    const preSql = \"DELETE FROM \" + quoteIdent(tableId) + \" WHERE id IN (\";\n    const postSql = \")\";\n    const q = _.constant(\"?\");\n    const chunkParams = _.range(chunkSize).map(q).join(\",\");\n    const numChunks = Math.floor(rowIds.length / chunkSize);\n    const numLeftovers = rowIds.length % chunkSize;\n\n    if (numChunks > 0) {\n      debuglog(\"DocStorage.BulkRemoveRecord: splitting \" + rowIds.length +\n        \" deletes into chunks of size \" + chunkSize);\n      const stmt = await this.prepare(preSql + chunkParams + postSql);\n      for (const index of _.range(0, numChunks * chunkSize, chunkSize)) {\n        debuglog(\"DocStorage.BulkRemoveRecord: chunk delete \" + index + \"-\" + (index + chunkSize - 1));\n        await stmt.run(...rowIds.slice(index, index + chunkSize));\n      }\n      await stmt.finalize();\n    }\n\n    if (numLeftovers > 0) {\n      debuglog(\"DocStorage.BulkRemoveRecord: leftover delete \" + (numChunks * chunkSize) + \"-\" + (rowIds.length - 1));\n      const leftoverParams = _.range(numLeftovers).map(q).join(\",\");\n      await this.run(preSql + leftoverParams + postSql,\n        ...rowIds.slice(numChunks * chunkSize, rowIds.length));\n    }\n  }\n\n  /**\n   * Internal helper to process AddColumn action.\n   *\n   * @param {String} tableId - Table Id.\n   * @param {String} colId - Column Id.\n   * @param {Object} colInfo - Column info object.\n   * @returns {Promise} - A promise for the SQL execution.\n   */\n  public async _process_AddColumn(tableId: string, colId: string, colInfo: any): Promise<void> {\n    await this.exec(\n      `ALTER TABLE ${quoteIdent(tableId)} ADD COLUMN ${DocStorage._columnDef(colId, colInfo.type)}`);\n  }\n\n  /**\n   * Internal helper to process RenameColumn action.\n   *\n   * @param {String} tableId - Table ID.\n   * @param {String} fromColId - Column ID to rename.\n   * @param {String} toColId - New Column ID.\n   * @returns {Promise} - A promise for the SQL execution.\n   */\n  public async _process_RenameColumn(tableId: string, fromColId: string, toColId: string): Promise<void> {\n    if (fromColId === \"id\" || fromColId === \"manualSort\" || tableId.startsWith(\"_grist\")) {\n      throw new Error(\"Cannot rename internal Grist column\");\n    }\n    await this.exec(\n      `ALTER TABLE ${quoteIdent(tableId)} RENAME COLUMN ${quoteIdent(fromColId)} TO ${quoteIdent(toColId)}`);\n  }\n\n  /**\n   * Internal helper to process ModifyColumn action.\n   *\n   * Note that this requires access to the _grist_ tables, unlike many of the other actions.\n   *\n   * @param {String} tableId - Table ID.\n   * @param {String} colId   - Column ID.\n   * @param {Object} colInfo - Column info object.\n   * @returns {Promise} - A promise for the SQL execution.\n   */\n  public async _process_ModifyColumn(tableId: string, colId: string, colInfo: any): Promise<void> {\n    if (!colInfo) {\n      log.error(\"ModifyColumn action called without params.\");\n      return;\n    }\n    return this._alterColumn(tableId, colId, colId, colInfo.type);\n  }\n\n  /**\n   * Internal helper to process RemoveColumn action.\n   *\n   * @param {String} tableId - Table ID.\n   * @param {String} colId   - Column ID to rename.\n   * @returns {Promise} - A promise for the SQL execution.\n   */\n  public _process_RemoveColumn(tableId: string, colId: string): Promise<void> {\n    const quote = quoteIdent;\n    const tmpTableId = DocStorage._makeTmpTableId(tableId);\n\n    // Note that SQLite does not support easily dropping columns. To drop a column from a table, we\n    // need to follow the instructions at https://sqlite.org/lang_altertable.html Since we don't use\n    // indexes or triggers, we skip a few steps.\n    // TODO: SQLite has since added support for ALTER TABLE DROP COLUMN, should\n    // use that to be more efficient and less disruptive.\n\n    // This returns rows with (at least) {name, type, dflt_value}.\n    return this.all(`PRAGMA table_info(${quote(tableId)})`)\n      .then((infoRows) => {\n        const newInfoRows = infoRows.filter(row => (row.name !== colId && row.name !== \"id\"));\n        if (newInfoRows.length === infoRows.length) {\n          // Column was not found. That's ok, and happens when deleting formula column.\n          return;\n        }\n        const colListSql = DocStorage._prefixJoin(\", \", newInfoRows.map(info => quote(info.name)));\n        const colSpecSql = DocStorage._prefixJoin(\", \", newInfoRows.map(DocStorage._sqlColSpecFromDBInfo));\n        return this._getDB().runEach(\n          `CREATE TABLE ${quote(tmpTableId)} (id INTEGER PRIMARY KEY${colSpecSql})`,\n          `INSERT INTO ${quote(tmpTableId)} SELECT id${colListSql} FROM ${quote(tableId)}`,\n          `DROP TABLE ${quote(tableId)}`,\n          `ALTER TABLE ${quote(tmpTableId)} RENAME TO ${quote(tableId)}`,\n        );\n      });\n  }\n\n  /**\n   * Internal helper to process RenameTable action.\n   *\n   * @param {string} fromTableId - Old table id\n   * @param {string}   toTableId - New table id\n   * @returns {Promise} - A promise for the SQL execution.\n   */\n  public _process_RenameTable(fromTableId: string, toTableId: string): Promise<void> {\n    const sql: string[] = [];\n\n    if (fromTableId === toTableId) {\n      return Promise.resolve();\n    } else if (fromTableId.toLowerCase() === toTableId.toLowerCase()) {\n      const tmpTableId = DocStorage._makeTmpTableId(fromTableId);\n      sql.push(\"ALTER TABLE \" + quoteIdent(fromTableId) +\n        \" RENAME TO \" + quoteIdent(tmpTableId));\n      fromTableId = tmpTableId;\n    }\n\n    sql.push(\"ALTER TABLE \" + quoteIdent(fromTableId) +\n      \" RENAME TO \"  + quoteIdent(toTableId));\n\n    log.debug(\"RenameTable SQL: \" + sql);\n    return bluebird.Promise.each(sql, (stmt: string) => this.exec(stmt));\n  }\n\n  /**\n   * Internal helper to process RemoveTable action.\n   *\n   * @param {String} tableId - Table ID.\n   * @returns {Promise} - A promise for the SQL execution.\n   */\n  public _process_RemoveTable(tableId: string): Promise<void> {\n    const sql = \"DROP TABLE \" + quoteIdent(tableId);\n\n    log.debug(\"RemoveTable SQL: \" + sql);\n\n    return this.exec(sql);\n  }\n\n  public renameDocTo(newName: string): Promise<void> {\n    log.debug(\"DocStorage.renameDocTo: %s -> %s\", this.docName, newName);\n    return this.shutdown()\n      .then(() => this.storageManager.renameDoc(this.docName, newName))\n      .catch((err) => {\n        log.error(\"DocStorage: renameDocTo %s -> %s failed: %s\", this.docName, newName, err.message);\n        return this.openFile()\n          .then(function() {\n            throw err;\n          });\n      })\n      .then(() => {\n        this.docName = newName;\n        this.docPath = this.storageManager.getPath(newName);\n        return this.openFile();\n      });\n  }\n\n  /**\n   * Returns the total number of bytes used for storing attachments that haven't been soft-deleted.\n   * May be stale if ActiveDoc.updateUsedAttachmentsIfNeeded isn't called first.\n   */\n  public async getTotalAttachmentFileSizes(): Promise<number> {\n    const result = await this.get(`\n      SELECT SUM(len) AS total\n      FROM (\n        -- Using MAX(LENGTH()) instead of just LENGTH() is needed in the presence of GROUP BY\n        -- to make LENGTH() quickly read the stored length instead of actually reading the blob data.\n        -- We use LENGTH() in the first place instead of _grist_Attachments.fileSize because the latter can\n        -- be changed by users.\n        -- External attachments have no internal blob data, so we need to use fileSize for those.\n        -- To avoid tampering with fileSize in offline records, we update it whenever files are\n        -- uploaded or retrieved.\n        SELECT\n          CASE WHEN files.storageId IS NOT NULL\n            THEN MAX(meta.fileSize)\n            ELSE MAX(LENGTH(files.data))\n          END AS len\n        FROM _gristsys_Files AS files\n          JOIN _grist_Attachments AS meta\n            ON meta.fileIdent = files.ident\n        WHERE meta.timeDeleted IS NULL  -- Don't count soft-deleted attachments\n        -- Duplicate attachments (i.e. identical file contents) are only stored once in _gristsys_Files\n        -- but can be duplicated in _grist_Attachments, so the GROUP BY prevents adding up duplicated sizes.\n        GROUP BY meta.fileIdent\n      )\n    `);\n    return result!.total ?? 0;\n  }\n\n  /**\n   * Returns an array of objects where:\n   *   - `id` is a row ID of _grist_Attachments\n   *   - `used` is true if and only if `id` is in a list in a cell of type Attachments\n   *   - The value of `timeDeleted` in this row of _grist_Attachments needs to be updated\n   *     because its truthiness doesn't match `used`, i.e. either:\n   *       - a used attachment is marked as deleted, OR\n   *       - an unused attachment is not marked as deleted\n   */\n  public async scanAttachmentsForUsageChanges(): Promise<{ used: boolean, id: number }[]> {\n    // Array of SQL queries where attachment_ids contains JSON arrays (typically containg row IDs).\n    // Below we add one query for each column of type Attachments in the document.\n    // We always include this first dummy query because if the array is empty then the final SQL query\n    // will just have `()` causing a syntax error.\n    // We can't just return when there are no Attachments columns\n    // because we may still need to delete all remaining attachments.\n    const attachmentsQueries = [\"SELECT '[0]' AS attachment_ids\"];\n    for (const [tableId, cols] of Object.entries(this._docSchema)) {\n      for (const [colId, type] of Object.entries(cols)) {\n        if (type === \"Attachments\") {\n          attachmentsQueries.push(`\n            SELECT t.${quoteIdent(colId)} AS attachment_ids\n            FROM ${quoteIdent(tableId)} AS t\n            WHERE json_valid(attachment_ids)\n          `);\n        }\n      }\n    }\n\n    // `UNION ALL` instead of `UNION` because duplicate values are unlikely and deduplicating is not worth the cost\n    const allAttachmentsQuery = attachmentsQueries.join(\" UNION ALL \");\n\n    const sql = `\n      WITH all_attachment_ids(id) AS (\n        SELECT json_each.value AS id\n        FROM json_each(attachment_ids), (${allAttachmentsQuery})\n      )  -- flatten out all the lists of IDs into a simple column of IDs\n      SELECT id, id IN all_attachment_ids AS used\n      FROM _grist_Attachments\n      WHERE used != (timeDeleted IS NULL);  -- only include rows that need updating\n    `;\n    return (await this.all(sql)) as any[];\n  }\n\n  /**\n   * Collect all cells that refer to a particular attachment. Ideally this is\n   * something we could use an index for. Regular indexes in SQLite don't help.\n   * FTS5 works, but is somewhat overkill.\n   */\n  public async findAttachmentReferences(attId: number): Promise<SingleCell[]> {\n    const queries: string[] = [];\n    // Switch quotes so to insert a table or column name as a string literal\n    // rather than as an identifier.\n    function asLiteral(name: string) {\n      return quoteIdent(name).replace(/\"/g, \"'\");\n    }\n    for (const [tableId, cols] of Object.entries(this._docSchema)) {\n      for (const [colId, type] of Object.entries(cols)) {\n        if (type !== \"Attachments\") { continue; }\n        queries.push(`SELECT\n          t.id as rowId,\n          ${asLiteral(tableId)} as tableId,\n          ${asLiteral(colId)} as colId\n        FROM ${quoteIdent(tableId)} AS t, json_each(t.${quoteIdent(colId)}) as a\n        WHERE a.value = ${attId} AND json_valid(t.${quoteIdent(colId)})`);\n        // json_valid is needed because of https://github.com/gristlabs/grist-core/issues/1565\n      }\n    }\n    try {\n      return (await this.all(queries.join(\" UNION ALL \"))) as any[];\n    } catch (e) {\n      // We throw an informative error if we fail to process the attachment references, although this shouldn't happen\n      // cf: https://github.com/gristlabs/grist-core/issues/1565\n      const errorMessage = `findAttachmentReferences failed: unable to process attachment references` +\n        `for users with complicated access rules. Details: ${e.message}`;\n      log.error(errorMessage, e);\n      throw new Error(errorMessage);\n    }\n  }\n\n  /**\n   * Return row IDs of unused attachments in _grist_Attachments.\n   * Uses the timeDeleted column which is updated in ActiveDoc.updateUsedAttachmentsIfNeeded.\n   * @param expiredOnly: if true, only return attachments where timeDeleted is at least\n   *                     ATTACHMENTS_EXPIRY_DAYS days ago.\n   */\n  public async getSoftDeletedAttachmentIds(expiredOnly: boolean): Promise<number[]> {\n    const condition = expiredOnly ?\n      `datetime(timeDeleted, 'unixepoch') < datetime('now', '-${ATTACHMENTS_EXPIRY_DAYS} days')` :\n      \"timeDeleted IS NOT NULL\";\n\n    const rows = await this.all(`\n      SELECT id\n      FROM _grist_Attachments\n      WHERE ${condition}\n    `);\n    return rows.map(r => r.id);\n  }\n\n  /**\n   * Delete attachments from _gristsys_Files that have no matching metadata row in _grist_Attachments.\n   * This leaves any attachment files in any remote attachment stores, which will be cleaned up separately.\n   */\n  public async removeUnusedAttachments() {\n    const result = await this._getDB().run(`\n      DELETE FROM _gristsys_Files\n      WHERE ident IN (\n        SELECT ident\n        FROM _gristsys_Files\n        LEFT JOIN _grist_Attachments\n        ON fileIdent = ident\n        WHERE fileIdent IS NULL\n      )\n    `);\n    if (result.changes > 0) {\n      await this._markAsChanged(Promise.resolve());\n    }\n  }\n\n  public interrupt(): Promise<void> {\n    return this._getDB().interrupt();\n  }\n\n  public getOptions(): MinDBOptions | undefined {\n    return this._getDB().getOptions();\n  }\n\n  public all(sql: string, ...args: any[]): Promise<ResultRow[]> {\n    return this._getDB().all(sql, ...args);\n  }\n\n  public run(sql: string, ...args: any[]): Promise<RunResult> {\n    return this._markAsChanged(this._getDB().run(sql, ...args));\n  }\n\n  public exec(sql: string): Promise<void> {\n    return this._markAsChanged(this._getDB().exec(sql));\n  }\n\n  public prepare(sql: string): Promise<PreparedStatement> {\n    return this._getDB().prepare(sql);\n  }\n\n  public get(sql: string, ...args: any[]): Promise<ResultRow | undefined> {\n    return this._getDB().get(sql, ...args);\n  }\n\n  public execTransaction<T>(transx: (db1: SQLiteDB) => Promise<T>): Promise<T> {\n    const db = this._getDB();\n    return this._markAsChanged(db.execTransaction(() => transx(db)));\n  }\n\n  public runAndGetId(sql: string, ...params: any[]): Promise<number> {\n    const db = this._getDB();\n    return this._markAsChanged(db.runAndGetId(sql, ...params));\n  }\n\n  public requestVacuum(): Promise<boolean> {\n    const db = this._getDB();\n    return this._markAsChanged(db.requestVacuum());\n  }\n\n  /**\n   * Run a VACUUM on the document and mark it as changed only\n   * if the saved space is above the ratio defined in SHRINK_RATIO_FOR_PUSH.\n   * Therefore if the doc storage is a remote one (like S3), it would be pushed\n   * only when we meet this condition, otherwise it is assumed not to be necessary.\n   */\n  public async vacuum(): Promise<void> {\n    const db = this._getDB();\n\n    const initSize = await this.storageManager.getFsFileSize(this.docName);\n    log.rawInfo(\"Start Vacuum of doc \", { docId: this.docName });\n    await db.vacuum();\n    const size = await this.storageManager.getFsFileSize(this.docName);\n    if (size <= initSize * (1 - SHRINK_RATIO_FOR_PUSH)) {\n      log.rawInfo(\"Mark doc as changed because vacuuming saved more space than the minimal ratio.\", {\n        docId: this.docName,\n        minimalRatio: SHRINK_RATIO_FOR_PUSH,\n        initSize,\n        currentSize: size,\n      });\n      this._cachedDataSize = null;\n      this.storageManager.markAsChanged(this.docName);\n    }\n  }\n\n  public async getPluginDataItem(pluginId: string, key: string): Promise<any> {\n    const row = await this.get(\"SELECT value from _gristsys_PluginData WHERE pluginId = ? and key = ?\", pluginId, key);\n    if (row) {\n      return row.value;\n    }\n    return undefined;\n  }\n\n  public getDB(): SQLiteDB {\n    return this._getDB();\n  }\n\n  public async hasPluginDataItem(pluginId: string, key: string): Promise<any> {\n    const row = await this.get(\"SELECT value from _gristsys_PluginData WHERE pluginId=? and key=?\", pluginId, key);\n    return typeof row !== \"undefined\";\n  }\n\n  public async setPluginDataItem(pluginId: string, key: string, value: string): Promise<void> {\n    await this.run(\"INSERT OR REPLACE into _gristsys_PluginData (pluginId, key, value) values (?, ?, ?)\",\n      pluginId, key, value);\n  }\n\n  public async removePluginDataItem(pluginId: string, key: string): Promise<void> {\n    await this.run(\"DELETE from _gristsys_PluginData where pluginId = ? and key = ?\", pluginId, key);\n  }\n\n  public async clearPluginDataItem(pluginId: string): Promise<void> {\n    await this.run(\"DELETE from _gristsys_PluginData where pluginId = ?\", pluginId);\n  }\n\n  /**\n   * Get a list of indexes.  For use in tests.\n   */\n  public async testGetIndexes(): Promise<IndexInfo[]> {\n    return this._getIndexes();\n  }\n\n  /**\n   * Create the specified indexes if they don't already exist.  Remove indexes we\n   * created in the past that are not listed (leaving other indexes untouched).\n   */\n  public async updateIndexes(desiredIndexes: IndexColumns[]) {\n    // Find all indexes on user tables.\n    const indexes = await this._getIndexes();\n    // Keep track of indexes prior to calling this method and after the call to this method\n    // as two sets of \"tableId.colId\" strings.\n    const pre = new Set<string>(indexes.map(index => `${index.tableId}.${index.colId}`));\n    const post = new Set<string>();\n    for (const index of desiredIndexes) {\n      const idx = `${index.tableId}.${index.colId}`;\n      if (!pre.has(idx)) {\n        const name = `auto_index_${uuidv4().replace(/-/g, \"_\")}`;\n        log.debug(`DocStorage.updateIndexes: doc ${this.docName} adding index ${name} for ` +\n          `table ${index.tableId}, column ${index.colId}`);\n        await this.exec(`CREATE INDEX ${name} ON ${quoteIdent(index.tableId)}(${quoteIdent(index.colId)})`);\n        log.debug(`DocStorage.updateIndexes: doc ${this.docName} added index ${name} for ` +\n          `table ${index.tableId}, column ${index.colId}`);\n      }\n      post.add(idx);\n    }\n    for (const index of indexes) {\n      const idx = `${index.tableId}.${index.colId}`;\n      if (!post.has(idx) && index.indexId.startsWith(\"auto_index_\")) {\n        log.debug(`DocStorage.updateIndexes: doc ${this.docName} dropping index ${index.indexId} for ` +\n          `table ${index.tableId}, column ${index.colId}`);\n        await this.exec(`DROP INDEX ${index.indexId}`);\n        log.debug(`DocStorage.updateIndexes: doc ${this.docName} dropped index ${index.indexId} for ` +\n          `table ${index.tableId}, column ${index.colId}`);\n      }\n    }\n  }\n\n  /**\n   * Return the total size of data in the user + meta tables of the SQLite doc (excluding gristsys\n   * tables). Uses cached results if possible. Any change to data invalidates the cache, via\n   * _markAsChanged().\n   */\n  public async getDataSize(): Promise<number> {\n    return this._cachedDataSize ?? (this._cachedDataSize = await this.getDataSizeUncached());\n  }\n\n  /**\n   * Measure and return the total size of data in the user + meta tables of the SQLite doc\n   * (excluding gristsys tables). Note that this operation involves reading the entire database.\n   */\n  public async getDataSizeUncached(): Promise<number> {\n    const result = await this.get(`\n      SELECT SUM(pgsize - unused) AS totalSize\n      FROM dbstat\n      WHERE NOT (\n        name LIKE 'sqlite_%' OR\n        name LIKE '_gristsys_%'\n      );\n    `).catch((e) => {\n      if (String(e).match(/no such table: dbstat/)) {\n        // We are using a version of SQLite that doesn't have\n        // dbstat compiled in. But it would be sad to disable\n        // Grist entirely just because we can't track byte-count.\n        // So return NaN in this case.\n        return { totalSize: NaN };\n      }\n      throw e;\n    });\n    return result!.totalSize;\n  }\n\n  private async _markAsChanged<T>(promise: Promise<T>): Promise<T> {\n    try {\n      return await promise;\n    } finally {\n      this._cachedDataSize = null;\n      this.storageManager.markAsChanged(this.docName);\n    }\n  }\n\n  /**\n   * Creates a new or opens an existing SQLite database, depending on mode.\n   * @return {Promise<number>} Promise for user_version stored in the database.\n   */\n  private async _openFile(mode: number, hooks: MigrationHooks): Promise<number> {\n    try {\n      this._db = await SQLiteDB.openDB(this.docPath, DocStorage.docStorageSchema, mode, hooks);\n      log.debug(\"DB %s open successfully\", this.docName);\n      return this._db.getMigrationVersion();\n    } catch (err) {\n      log.debug(\"DB %s open error: %s\", this.docName, err);\n      throw err;\n    }\n  }\n\n  /**\n   * Internal helper for applying Bulk Update or Add Record sql\n   */\n  private async _applyMaybeBulkUpdateOrAddSql(sql: string, sqlParams: any[][]): Promise<void> {\n    if (sqlParams.length === 1) {\n      await this.run(sql, ...sqlParams[0]);\n    } else {\n      const stmt = await this.prepare(sql);\n      for (const param of sqlParams) {\n        await stmt.run(...param);\n      }\n      await stmt.finalize();\n    }\n  }\n\n  /**\n   * Read SQLite's metadata for tableId, and generate SQL for the altered version of the table.\n   * @param {string} colId: Existing colId to change or delete. We'll return null if it's missing.\n   * @param {string} newColId: New colId.\n   * @param {string|null} newColType: New grist type, or null to keep unchanged.\n   * @return {Promise<string|null>} New table SQL, or null when nothing changed or colId is missing.\n   */\n  private async _rebuildTableSql(tableId: string, colId: string, newColId: string,\n    newColType: string | null = null): Promise<RebuildResult | null> {\n    // This returns rows with (at least) {name, type, dflt_value}.\n    assert(newColId, \"newColId required\");\n    let infoRows = await this.all(`PRAGMA table_info(${quoteIdent(tableId)})`);\n\n    // Skip \"id\" column, and find the column we are modifying.\n    infoRows = infoRows.filter(row => (row.name !== \"id\"));\n    const colInfo = infoRows.find(info => (info.name === colId));\n    if (!colInfo) {\n      return null;      // Column not found.\n    }\n    const oldGristType = this._getGristType(tableId, colId);\n    const oldSqlType = colInfo.type || \"BLOB\";\n    const oldDefault = fixDefault(colInfo.dflt_value);\n    const newSqlType = newColType ? DocStorage._getSqlType(newColType) : oldSqlType;\n    const newDefault = fixDefault(newColType ? DocStorage._formattedDefault(newColType) : oldDefault);\n    const newInfo = { name: newColId, type: newSqlType, dflt_value: newDefault };\n    // Check if anything actually changed, and only rebuild the table then.\n    if (Object.keys(newInfo).every(p => ((newInfo as any)[p] === colInfo[p]))) {\n      return null;      // No changes.\n    }\n    Object.assign(colInfo, newInfo);\n    const colSpecSql = DocStorage._prefixJoin(\", \", infoRows.map(DocStorage._sqlColSpecFromDBInfo));\n    return {\n      sql: `CREATE TABLE ${quoteIdent(tableId)} (id INTEGER PRIMARY KEY${colSpecSql})`,\n      oldGristType,\n      newGristType: newColType || oldGristType,\n      oldDefault,\n      newDefault,\n      oldSqlType,\n      newSqlType,\n    };\n  }\n\n  /**\n   * Helper to alter a table to new table SQL, which is appropriate for renaming columns, or\n   * changing default values for a column, i.e. changes that don't affect on-disk content in any\n   * way. See https://sqlite.org/lang_altertable.html.\n   */\n  private async _alterTableSoft(tableId: string, newTableSql: string): Promise<void> {\n    // Procedure according to https://sqlite.org/lang_altertable.html: \"appropriate for ... renaming\n    // columns, or adding or removing or changing default values on a column.\"\n    const row = await this.get(\"PRAGMA schema_version\");\n    assert(row?.schema_version, \"Could not retrieve schema_version.\");\n    const newSchemaVersion = row.schema_version + 1;\n    const tmpTableId = DocStorage._makeTmpTableId(tableId);\n    await this._getDB().runEach(\n      \"PRAGMA writable_schema=ON\",\n      [\"UPDATE sqlite_master SET sql=? WHERE type='table' and name=?\", [newTableSql, tableId]],\n      `PRAGMA schema_version=${newSchemaVersion}`,\n      \"PRAGMA writable_schema=OFF\",\n      // The following are not in the instructions, but are needed for SQLite to notice the\n      // changes for subsequent queries.\n      `ALTER TABLE ${quoteIdent(tableId)} RENAME TO ${quoteIdent(tmpTableId)}`,\n      `ALTER TABLE ${quoteIdent(tmpTableId)} RENAME TO ${quoteIdent(tableId)}`,\n    );\n  }\n\n  private async _alterColumn(tableId: string, colId: string, newColId: string,\n    newColType: string | null = null): Promise<void> {\n    const result = await this._rebuildTableSql(tableId, colId, newColId, newColType);\n    if (result) {\n      const q = quoteIdent;\n      if (result.oldDefault !== result.newDefault) {\n        // This isn't strictly necessary, but addresses a SQLite quirk that breaks our tests\n        // (although likely unnoticeable in practice): an added column has \"holes\" for existing\n        // records that show up as the default value but don't actually store that default. When\n        // we do the soft-alter here, those values reflect the new default, i.e. change\n        // unexpectedly. Setting the default values explicitly prevents this unexpected change.\n        const dflt = result.oldDefault;\n        // (Note that comparison below must use \"IS\" rather than \"=\" to work for NULLs.)\n        await this.exec(`UPDATE ${q(tableId)} SET ${q(colId)}=${dflt} WHERE ${q(colId)} IS ${dflt}`);\n      }\n      await this._alterTableSoft(tableId, result.sql);\n\n      // For any marshalled objects, check if we can now unmarshall them if they are the\n      // native type.\n      if (result.newGristType !== result.oldGristType || result.newSqlType !== result.oldSqlType) {\n        const cells = await this.all(`SELECT id, ${q(colId)} as value FROM ${q(tableId)} ` +\n          `WHERE typeof(${q(colId)}) = 'blob'`);\n        const marshaller = new marshal.Marshaller({ version: 2 });\n        const sqlParams: [any, number][] = [];\n        for (const cell of cells) {\n          const id: number = cell.id;\n          const value: any = cell.value;\n          const decodedValue = DocStorage._decodeValue(value, result.oldGristType, result.oldSqlType);\n          const newValue = DocStorage._encodeValue(marshaller, result.newGristType, result.newSqlType, decodedValue);\n          if (!(newValue instanceof Uint8Array)) {\n            sqlParams.push([newValue, id]);\n          }\n        }\n        const sql = `UPDATE ${q(tableId)} SET ${q(colId)}=? WHERE id=?`;\n        await this._applyMaybeBulkUpdateOrAddSql(sql, sqlParams);\n      }\n    }\n  }\n\n  private _getGristType(tableId: string, colId: string): string {\n    return (this._docSchema[tableId]?.[colId]) || \"Any\";\n  }\n\n  private _getDB(): SQLiteDB {\n    if (!this._db) {\n      if (this._db === undefined) {\n        throw new Error(\"Tried to use DocStorage database before it was opened\");\n      } else {\n        throw new Error(\"Tried to use DocStorage database after it was closed\");\n      }\n    }\n    return this._db;\n  }\n\n  /**\n   * Get a list of user indexes\n   */\n  private async _getIndexes(): Promise<IndexInfo[]> {\n    // Find all indexes on user tables.\n    return await this.all(\"SELECT tbl_name as tableId, il.name as indexId, ii.name as colId \" +\n      \"FROM sqlite_master AS m, \" +\n      \"pragma_index_list(m.name) AS il, \" +\n      \"pragma_index_info(il.name) AS ii \" +\n      \"WHERE m.type='table' \" +\n      \"AND tbl_name NOT LIKE '_grist%' \" +\n      \"ORDER BY tableId, colId\") as any;\n  }\n\n  /**\n   * Implement a filtered query by adding any parameters into\n   * temporary tables, to avoid hitting an SQLite parameter limit.\n   * Backing for temporary tables lies outside of the document database,\n   * and operates with `synchronous=OFF` and `journal_mode=PERSIST`, so\n   * should be reasonably fast:\n   *   https://sqlite.org/tempfiles.html#temp_databases\n   */\n  private async _fetchQueryWithManyParameters(query: ExpandedQuery): Promise<Buffer> {\n    const db = this._getDB();\n    return db.execTransaction(async () => {\n      const tableNames: string[] = [];\n      const whereParts: string[] = [];\n      for (const colId of Object.keys(query.filters)) {\n        const values = query.filters[colId];\n        const tableName = `_grist_tmp_${tableNames.length}_${uuidv4().replace(/-/g, \"_\")}`;\n        await db.exec(`CREATE TEMPORARY TABLE ${tableName}(data)`);\n        tableNames.push(tableName);\n        for (const valuesChunk of chunk(values, maxSQLiteVariables)) {\n          const placeholders = valuesChunk.map(() => \"(?)\").join(\",\");\n          await db.run(`INSERT INTO ${tableName}(data) VALUES ${placeholders}`, valuesChunk);\n        }\n        whereParts.push(`${quoteIdent(query.tableId)}.${quoteIdent(colId)} IN (SELECT data FROM ${tableName})`);\n      }\n      const sql = this._getSqlForQuery(query, whereParts);\n      const params = query.where?.params || [];\n      try {\n        return await db.allMarshal(sql, ...params);\n      } finally {\n        await Promise.all(tableNames.map(tableName => db.exec(`DROP TABLE ${tableName}`)));\n      }\n    });\n  }\n\n  /**\n   * Construct SQL for an ExpandedQuery.  Expects that filters have been converted into\n   * a set of WHERE terms that should be ANDed.\n   */\n  private _getSqlForQuery(query: ExpandedQuery, whereParts: string[]) {\n    const whereCondition = combineExpr(\"AND\", [query.where?.clause, ...whereParts]);\n    const whereClause = whereCondition ? `WHERE ${whereCondition}` : \"\";\n    const limitClause = (typeof query.limit === \"number\") ? `LIMIT ${query.limit}` : \"\";\n    const joinClauses = query.joins ? query.joins.join(\" \") : \"\";\n    const selects = query.selects ? query.selects.join(\", \") : \"*\";\n    const sql = `SELECT ${selects} FROM ${quoteIdent(query.tableId)} ` +\n      `${joinClauses} ${whereClause} ${limitClause}`;\n    return sql;\n  }\n\n  // If we are being asked to add a record and then update several of its\n  // columns, compact that into a single action. For fully Grist-managed\n  // documents, this makes no difference. But if the underlying SQLite DB\n  // has extra constraints on columns, it can make a difference.\n  // TODO: consider dealing with other scenarios, especially a BulkAddRecord.\n  private _compressStoredActions(docActions: DocAction[]): DocAction[] {\n    if (docActions.length > 1) {\n      const first = docActions[0];\n      if (first[0] === \"AddRecord\" &&\n        docActions.slice(1).every(\n          // Check other actions are UpdateRecords for the same table and row.\n          a => a[0] === \"UpdateRecord\" && a[1] === first[1] && a[2] === first[2],\n        )) {\n        const merged = cloneDeep(first);\n        for (const a2 of docActions.slice(1)) {\n          Object.assign(merged[3], a2[3]);\n        }\n        docActions = [merged];\n      }\n    }\n    return docActions;\n  }\n\n  // If an action can have manualSort removed, go ahead and do it (after cloning),\n  // otherwise return null.\n  private _considerWithoutManualSort(act: DocAction): DocAction | null {\n    if (act[0] === \"AddRecord\" || act[0] === \"UpdateRecord\" ||\n      act[0] === \"BulkAddRecord\" || act[0] === \"BulkUpdateRecord\" &&\n      \"manualSort\" in act[3]) {\n      act = cloneDeep(act);\n      delete act[3].manualSort;\n      return act;\n    }\n    return null;\n  }\n\n  private async _addBasicFileRecord(db: SQLiteDB, fileIdent: string): Promise<boolean> {\n    // Try to insert a new record with the given ident. It'll fail the UNIQUE constraint if exists.\n    // Catching the violation is the simplest and most reliable way of doing this.\n    // If this function runs multiple times in parallel (which can happen), nothing guarantees the\n    // order that multiple `db.run` or `db.get` statements will run in (not even .execTransaction).\n    // This means it's not safe to check then insert - we just have to try the insert and see if it\n    // fails.\n    try {\n      await db.run(\"INSERT INTO _gristsys_Files (ident) VALUES (?)\", fileIdent);\n    } catch (err) {\n      // If UNIQUE constraint failed, this ident must already exist.\n      if (/^(SQLITE_CONSTRAINT: )?UNIQUE constraint failed/.test(err.message)) {\n        return false;\n      } else {\n        throw err;\n      }\n    }\n    return true;\n  }\n\n  private async _updateFileRecord(\n    db: SQLiteDB, fileIdent: string, fileData?: Buffer, storageId?: string,\n  ): Promise<void> {\n    await db.run(\"UPDATE _gristsys_Files SET data=?, storageId=? WHERE ident=?\", fileData, storageId, fileIdent);\n  }\n}\n\ninterface RebuildResult {\n  sql: string;\n  oldGristType: string;\n  newGristType: string;\n  oldDefault: string;\n  newDefault: string;\n  oldSqlType: string;\n  newSqlType: string;\n}\n\n// A summary of columns a database index is covering or should cover.\nexport interface IndexColumns {\n  tableId: string;     // name of table\n  colId: string;       // column indexed (only single-column indexes supported for now)\n}\n\n// A summary of a database index, including its name.\nexport interface IndexInfo extends IndexColumns {\n  indexId: string;     // name of index\n}\n\n/**\n * Creates an index that allows fast SQL JOIN between _grist_Attachments.fileIdent and _gristsys_Files.ident.\n */\nexport async function createAttachmentsIndex(db: ISQLiteDB) {\n  await db.exec(`CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent)`);\n}\n\n// Old docs may have incorrect quotes in their schema for default values\n// that node-sqlite3 may tolerate but not other wrappers. Patch such\n// material as we run into it.\nfunction fixDefault(def: string) {\n  return (def === '\"\"') ? \"''\" : def;\n}\n\n// Information on an attached file from _gristsys_files\nexport interface FileInfo {\n  ident: string;\n  storageId: string | null;\n  data: Buffer;\n}\n"
  },
  {
    "path": "app/server/lib/DocStorageManager.ts",
    "content": "import { DocEntry, DocEntryTag } from \"app/common/DocListAPI\";\nimport { DocSnapshots } from \"app/common/DocSnapshot\";\nimport { DocumentUsage } from \"app/common/DocUsage\";\nimport * as gutil from \"app/common/gutil\";\nimport { backupUsingBestConnection } from \"app/server/lib/backupSqliteDatabase\";\nimport { Comm } from \"app/server/lib/Comm\";\nimport * as docUtils from \"app/server/lib/docUtils\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { EmptySnapshotProgress, IDocStorageManager, SnapshotProgress } from \"app/server/lib/IDocStorageManager\";\nimport { IShell } from \"app/server/lib/IShell\";\nimport log from \"app/server/lib/log\";\n\nimport * as path from \"path\";\n\nimport * as bluebird from \"bluebird\";\nimport * as fse from \"fs-extra\";\nimport moment from \"moment\";\nimport { v4 as uuidv4 } from \"uuid\";\n\n/**\n * DocStorageManager manages Grist documents. This implementation deals with files in the file\n * system. An alternative implementation could provide the same public methods to implement\n * storage management for the hosted version of Grist.\n *\n * This file-based DocStorageManager uses file path as the docName identifying a document, with\n * one exception. For files in the docsRoot directory, the basename of the document is used\n * instead, with .grist extension stripped; primarily to maintain previous behavior and keep\n * clean-looking URLs. In all other cases, the realpath of the file (including .grist extension)\n * is the canonical docName.\n *\n */\nexport class DocStorageManager implements IDocStorageManager {\n  private _shell: IShell;\n\n  /**\n   * Initialize with the given root directory, which should be a fully-resolved path (i.e. using\n   * fs.realpath or docUtils.realPath).\n   * The file watcher is created if the optComm argument is given.\n   */\n  constructor(private _docsRoot: string, private _samplesRoot?: string,\n    private _comm?: Comm, shell?: IShell, private _gristServer?: GristServer) {\n    // If we have a way to communicate with clients, watch the docsRoot for changes.\n    this._shell = shell ?? {\n      trashItem() { throw new Error(\"Unable to move document to trash\"); },\n      showItemInFolder() { throw new Error(\"Unable to show item in folder\"); },\n    };\n  }\n\n  /**\n   * Returns the path to the given document. This is used by DocStorage.js, and is specific to the\n   * file-based storage implementation.\n   * @param {String} docName: The canonical docName.\n   * @returns {String} path: Filesystem path.\n   */\n  public getPath(docName: string): string {\n    docName += (path.extname(docName) === \".grist\" ? \"\" : \".grist\");\n    return path.resolve(this._docsRoot, docName);\n  }\n\n  public getSQLiteDB(docName: string) {\n    return this._gristServer?.getDocManager().getSQLiteDB(docName);\n  }\n\n  /**\n   * Returns the path to the given sample document.\n   */\n  public getSampleDocPath(sampleDocName: string): string | null {\n    return this._samplesRoot ? this.getPath(path.resolve(this._samplesRoot, sampleDocName)) : null;\n  }\n\n  /**\n   * Translates a possibly non-canonical docName to a canonical one (e.g. adds .grist to a path\n   * without .grist extension, and canonicalizes the path). All other functions deal with\n   * canonical docNames.\n   * @param {String} altDocName: docName which may not be the canonical one.\n   * @returns {Promise:String} Promise for the canonical docName.\n   */\n  public async getCanonicalDocName(altDocName: string): Promise<string> {\n    const p = await docUtils.realPath(this.getPath(altDocName));\n    return path.dirname(p) === this._docsRoot ? path.basename(p, \".grist\") : p;\n  }\n\n  /**\n   * Prepares a document for use locally. Returns whether the document is new (needs to be\n   * created). This is a no-op in the local DocStorageManager case.\n   */\n  public async prepareLocalDoc(docName: string): Promise<boolean> { return false; }\n\n  public async prepareToCreateDoc(docName: string): Promise<void> {\n    // nothing to do\n  }\n\n  public async prepareFork(srcDocName: string, destDocName: string): Promise<string> {\n    // This is implemented only to support old tests.\n    const output = this.getPath(destDocName);\n    return this._safeCopy(srcDocName, {\n      output,\n    });\n  }\n\n  /**\n   * Returns a promise for the list of docNames to show in the doc list. For the file-based\n   * storage, this will include all .grist files under the docsRoot.\n   * @returns {Promise:Array<DocEntry>} Promise for an array of objects with `name`, `size`,\n   * and `mtime`.\n   */\n  public listDocs(): Promise<DocEntry[]> {\n    return bluebird.Promise.all([\n      this._listDocs(this._docsRoot, \"\"),\n      this._samplesRoot ? this._listDocs(this._samplesRoot, \"sample\") : [],\n    ])\n      .spread((docsEntries: DocEntry[], samplesEntries: DocEntry[]) => {\n        return [...docsEntries, ...samplesEntries];\n      });\n  }\n\n  /**\n   * Deletes a document.\n   * @param {String} docName: docName of the document to delete.\n   * @returns {Promise} Resolved on success.\n   */\n  public async deleteDoc(docName: string, deletePermanently?: boolean): Promise<void> {\n    const docPath = this.getPath(docName);\n    // Keep this check, to protect against wiping out the whole disk or the user's home.\n    if (path.extname(docPath) !== \".grist\") {\n      return Promise.reject(new Error(\"Refusing to delete path which does not end in .grist\"));\n    } else if (deletePermanently) {\n      await fse.remove(docPath);\n    } else {\n      await this._shell.trashItem(docPath);\n    }\n  }\n\n  /**\n   * Renames a closed document. In the file-system case, moves files to reflect the new paths. For\n   * a document already open, use `docStorageInstance.renameDocTo()` instead.\n   * @param {String} oldName: original docName.\n   * @param {String} newName: new docName.\n   * @returns {Promise} Resolved on success.\n   */\n  public renameDoc(oldName: string, newName: string): Promise<void> {\n    const oldPath = this.getPath(oldName);\n    const newPath = this.getPath(newName);\n    return docUtils.createExclusive(newPath)\n      .catch(async (e: any) => {\n        if (e.code !== \"EEXIST\") { throw e; }\n        const isSame = await docUtils.isSameFile(oldPath, newPath);\n        if (!isSame) { throw e; }\n      })\n      .then(() => fse.rename(oldPath, newPath))\n    // Send 'renameDocs' event immediately after the rename. Previously, this used to be sent by\n    // DocManager after reopening the renamed doc. The extra delay caused issue T407, where\n    // chokidar.watch() triggered 'removeDocs' before 'renameDocs'.\n      .then(() => { this._sendDocListAction(\"renameDocs\", oldPath, [oldName, newName]); })\n      .catch((err: Error) => {\n        log.warn(\"DocStorageManager: rename %s -> %s failed: %s\", oldPath, newPath, err.message);\n        throw err;\n      });\n  }\n\n  /**\n   * Should create a backup of the file\n   * @param {String} docName - docName to backup\n   * @param {String} backupTag - string to identify backup, like foo.grist.$DATE.$TAG.bak\n   * @returns {Promise} Resolved on success, returns path to backup (to show user)\n   */\n  public makeBackup(docName: string, backupTag: string): Promise<string> {\n    // this need to persist between calling createNumbered and\n    // getting it's return value, to re-add the extension again (._.)\n    let ext: string;\n    let finalBakPath: string; // holds final value of path, with numbering\n\n    return bluebird.Promise.try(() => this._generateBackupFilePath(docName, backupTag))\n      .then((bakPath: string) => { // make a numbered migration if necessary\n        log.debug(`DocStorageManager: trying to make backup at ${bakPath}`);\n\n        // create a file at bakPath, adding numbers if necessary\n        ext = path.extname(bakPath); // persists to makeBackup closure\n        const bakPathPrefix = bakPath.slice(0, -ext.length);\n        return docUtils.createNumbered(bakPathPrefix, \"-\",\n          (pathPrefix: string) => docUtils.createExclusive(pathPrefix + ext),\n        );\n      }).tap((numberedBakPathPrefix: string) => { // do the copying, but return bakPath anyway\n        finalBakPath = numberedBakPathPrefix + ext;\n        log.info(`Backing up ${docName} to ${finalBakPath}`);\n        return this._safeCopy(docName, { output: finalBakPath });\n      }).then(() => {\n        log.debug(\"DocStorageManager: Backup made successfully at: %s\", finalBakPath);\n        return finalBakPath;\n      }).catch((err: Error) => {\n        log.error(\"DocStorageManager: Backup %s %s failed: %s\", docName, err.message);\n        throw err;\n      });\n  }\n\n  /**\n   * Electron version only. Shows the given doc in the file explorer.\n   */\n  public async showItemInFolder(docName: string): Promise<void> {\n    this._shell.showItemInFolder(this.getPath(docName));\n  }\n\n  public async closeStorage() {\n    // nothing to do\n  }\n\n  public async closeDocument(docName: string) {\n    // nothing to do\n  }\n\n  public markAsChanged(docName: string): void {\n    // nothing to do\n  }\n\n  public markAsEdited(docName: string): void {\n    // nothing to do\n  }\n\n  public scheduleUsageUpdate(\n    docName: string,\n    docUsage: DocumentUsage,\n    minimizeDelay = false,\n  ): void {\n    // nothing to do\n  }\n\n  public testReopenStorage(): void {\n    // nothing to do\n  }\n\n  public async addToStorage(id: string): Promise<void> {\n    // nothing to do\n  }\n\n  public prepareToCloseStorage(): void {\n    // nothing to do\n  }\n\n  public async flushDoc(docName: string): Promise<void> {\n    // nothing to do\n  }\n\n  public async getCopy(docName: string): Promise<string> {\n    return this._safeCopy(docName, {\n      postfix: uuidv4(),\n    });\n  }\n\n  public async getSnapshots(docName: string, skipMetadataCache?: boolean): Promise<DocSnapshots> {\n    throw new Error(\"getSnapshots not implemented\");\n  }\n\n  public removeSnapshots(docName: string, snapshotIds: string[]): Promise<void> {\n    throw new Error(\"removeSnapshots not implemented\");\n  }\n\n  public getSnapshotProgress(): SnapshotProgress {\n    return new EmptySnapshotProgress();\n  }\n\n  public async replace(docName: string, options: any): Promise<void> {\n    throw new Error(\"replacement not implemented\");\n  }\n\n  public async getFsFileSize(docName: string): Promise<number> {\n    return (await fse.stat(this.getPath(docName))).size;\n  }\n\n  /**\n   * Returns a promise for the list of docNames for all docs in the given directory.\n   * @returns {Promise:Array<Object>} Promise for an array of objects with `name`, `size`,\n   * and `mtime`.\n   */\n  private _listDocs(dirPath: string, tag: DocEntryTag): Promise<any[]> {\n    return fse.readdir(dirPath)\n    // Filter out for .grist files, and strip the .grist extension.\n      .then(entries => Promise.all(\n        entries.filter(e => (path.extname(e) === \".grist\"))\n          .map((e) => {\n            const docPath = path.resolve(dirPath, e);\n            return fse.stat(docPath)\n              .then(stat => getDocListFileInfo(docPath, stat, tag));\n          }),\n      ))\n    // Sort case-insensitively.\n      .then(entries => entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())))\n    // If the root directory is missing, just return an empty array.\n      .catch((err) => {\n        if (err.cause?.code === \"ENOENT\") { return []; }\n        throw err;\n      });\n  }\n\n  /**\n   * Generates the filename for the given document backup\n   * Backup names should look roughly like:\n   * ${basefilename}.grist.${YYYY-MM-DD}.${tag}.bak\n   *\n   * @returns {Promise} backup filepath (might need to createNumbered)\n   */\n  private _generateBackupFilePath(docName: string, backupTag: string): Promise<string> {\n    const dateString = moment().format(\"YYYY-MM-DD\");\n\n    return docUtils.realPath(this.getPath(docName))\n      .then((filePath: string) => {\n        const fileName = path.basename(filePath);\n        const fileDir = path.dirname(filePath);\n\n        const bakName = `${fileName}.${dateString}.${backupTag}.bak`;\n        return path.join(fileDir, bakName);\n      });\n  }\n\n  /**\n   * Helper to broadcast a docListAction for a single doc to clients. If the action is not on a\n   *  '.grist' file, it is not sent.\n   * @param {String} actionType - DocListAction type to send, 'addDocs' | 'removeDocs' | 'changeDocs'.\n   * @param {String} docPath - System path to the doc including the filename.\n   * @param {Any} data - Data to send as the message.\n   */\n  private _sendDocListAction(actionType: string, docPath: string, data: any): void {\n    if (this._comm && gutil.endsWith(docPath, \".grist\")) {\n      log.debug(`Sending ${actionType} action for doc ${getDocName(docPath)}`);\n      this._comm.broadcastMessage(\"docListAction\", { [actionType]: [data] });\n    }\n  }\n\n  private async _safeCopy(docName: string, options: {\n    postfix?: string,\n    output?: string,\n  }): Promise<string> {\n    return backupUsingBestConnection(this, docName, {\n      ...options,\n      log: err => log.error(\"DocStorageManager: copy failed for %s: %s\", docName, String(err)),\n    });\n  }\n}\n\n/**\n * Helper to return the docname (without .grist) given the path to the .grist file.\n */\nfunction getDocName(docPath: string): string {\n  return path.basename(docPath, \".grist\");\n}\n\n/**\n * Helper to get the stats used by the Grist DocList for a document.\n * @param {String} docPath - System path to the doc including the doc filename.\n * @param {Object} fsStat - fs.Stats object describing the file metadata.\n * @param {String} tag - The tag indicating the type of doc.\n * @return {Promise:Object} Promise for an object containing stats for the requested doc.\n */\nfunction getDocListFileInfo(docPath: string, fsStat: any, tag: DocEntryTag): DocEntry {\n  return {\n    docId: undefined,                // TODO: Should include docId if it exists\n    name: getDocName(docPath),\n    mtime: fsStat.mtime,\n    size: fsStat.size,\n    tag,\n  };\n}\n"
  },
  {
    "path": "app/server/lib/DocWorker.ts",
    "content": "/**\n * DocWorker collects the methods and endpoints that relate to a single Grist document.\n * In hosted environment, this comprises the functionality of the DocWorker instance type.\n */\n\nimport { isAffirmative } from \"app/common/gutil\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { assertAccess, getOrSetDocAuth, RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { Client } from \"app/server/lib/Client\";\nimport { Comm } from \"app/server/lib/Comm\";\nimport { DocSession, docSessionFromRequest } from \"app/server/lib/DocSession\";\nimport { filterDocumentInPlace } from \"app/server/lib/filterUtils\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { IDocStorageManager } from \"app/server/lib/IDocStorageManager\";\nimport log from \"app/server/lib/log\";\nimport {\n  getDocId, getExtraAttachmentOptions, integerParam,\n  optStringParam, stringParam,\n} from \"app/server/lib/requestUtils\";\n\nimport * as path from \"path\";\n\nimport contentDisposition from \"content-disposition\";\nimport * as express from \"express\";\nimport * as fse from \"fs-extra\";\nimport * as mimeTypes from \"mime-types\";\n\nexport interface AttachOptions {\n  comm: Comm;                             // Comm object for methods called via websocket\n  gristServer: GristServer;\n}\n\nexport class DocWorker {\n  private _comm: Comm;\n  private _gristServer: GristServer;\n  constructor(private _dbManager: HomeDBManager, options: AttachOptions) {\n    this._comm = options.comm;\n    this._gristServer = options.gristServer;\n  }\n\n  public async getAttachment(req: express.Request, res: express.Response): Promise<void> {\n    try {\n      const docSession = this._getDocSession(stringParam(req.query.clientId, \"clientId\"),\n        integerParam(req.query.docFD, \"docFD\"));\n      const activeDoc = docSession.activeDoc;\n      const options = getExtraAttachmentOptions(req);\n      const attId = integerParam(req.query.attId, \"attId\");\n      // Access control is done in getAttachmentData, below.\n      // It can be expensive, if only the attId is available,\n      // so only do it once. Important to review that information\n      // from attRecord doesn't leak. getAttachmentData should\n      // throw before anything is returned to the user, if they\n      // don't have access to the attachment.\n      const attRecord = activeDoc.getAttachmentMetadataWithoutAccessControl(attId);\n      const ext = path.extname(attRecord.fileIdent);\n      const type = mimeTypes.lookup(ext);\n\n      let inline = Boolean(req.query.inline);\n      // Serving up user-uploaded HTML files inline is an open door to XSS attacks.\n      if (type === \"text/html\") { inline = false; }\n\n      // Construct a content-disposition header of the form 'inline|attachment; filename=\"NAME\"'\n      const contentDispType = inline ? \"inline\" : \"attachment\";\n      const contentDispHeader = contentDisposition(stringParam(req.query.name, \"name\"), { type: contentDispType });\n      const data = await activeDoc.getAttachmentData(docSession, attRecord, options);\n      res.status(200)\n        .type(ext)\n        .set(\"Content-Disposition\", contentDispHeader)\n        .set(\"Cache-Control\", \"private, max-age=3600\")\n        .set(\"Content-Security-Policy\", \"sandbox; default-src: 'none'\")\n        .send(data);\n    } catch (err) {\n      res.status(404).send({ error: err.toString() });\n    }\n  }\n\n  public async downloadDoc(req: express.Request, res: express.Response,\n    storageManager: IDocStorageManager, filename: string): Promise<void> {\n    const mreq = req as RequestWithLogin;\n    const docId = getDocId(mreq);\n\n    // Get a copy of document for downloading.\n    const tmpPath = await storageManager.getCopy(docId);\n    let removeData: boolean = false;\n    let removeHistory: boolean = false;\n    if (isAffirmative(req.query.template)) {\n      removeData = removeHistory = true;\n    } else if (isAffirmative(req.query.nohistory)) {\n      removeHistory = true;\n    }\n\n    await filterDocumentInPlace(docSessionFromRequest(mreq), tmpPath, {\n      removeData,\n      removeHistory,\n      removeFullCopiesSpecialRight: true,\n      markAction: true,\n    });\n    // NOTE: We may want to reconsider the mimeType used for Grist files.\n    return res.type(\"application/x-sqlite3\")\n      .download(\n        tmpPath,\n        filename + \".grist\",\n        async (err: any) => {\n          if (err) {\n            if (err.message && /Request aborted/.test(err.message)) {\n              log.warn(`Download request aborted for doc ${docId}`, err);\n            } else {\n              log.error(`Download failure for doc ${docId}`, err);\n            }\n          }\n          await fse.unlink(tmpPath);\n        },\n      );\n  }\n\n  // Register main methods related to documents.\n  public registerCommCore(): void {\n    const comm = this._comm;\n    comm.registerMethods({\n      closeDoc: activeDocMethod.bind(null, null, \"closeDoc\"),\n      fetchTable: activeDocMethod.bind(null, \"viewers\", \"fetchTable\"),\n      fetchPythonCode: activeDocMethod.bind(null, \"viewers\", \"fetchPythonCode\"),\n      useQuerySet: activeDocMethod.bind(null, \"viewers\", \"useQuerySet\"),\n      disposeQuerySet: activeDocMethod.bind(null, \"viewers\", \"disposeQuerySet\"),\n      applyUserActions: activeDocMethod.bind(null, \"editors\", \"applyUserActions\"),\n      applyUserActionsById: activeDocMethod.bind(null, \"editors\", \"applyUserActionsById\"),\n      findColFromValues: activeDocMethod.bind(null, \"viewers\", \"findColFromValues\"),\n      getFormulaError: activeDocMethod.bind(null, \"viewers\", \"getFormulaError\"),\n      importFiles: activeDocMethod.bind(null, \"editors\", \"importFiles\"),\n      finishImportFiles: activeDocMethod.bind(null, \"editors\", \"finishImportFiles\"),\n      cancelImportFiles: activeDocMethod.bind(null, \"editors\", \"cancelImportFiles\"),\n      generateImportDiff: activeDocMethod.bind(null, \"editors\", \"generateImportDiff\"),\n      addAttachments: activeDocMethod.bind(null, \"editors\", \"addAttachments\"),\n      startBundleUserActions: activeDocMethod.bind(null, \"editors\", \"startBundleUserActions\"),\n      stopBundleUserActions: activeDocMethod.bind(null, \"editors\", \"stopBundleUserActions\"),\n      autocomplete: activeDocMethod.bind(null, \"viewers\", \"autocomplete\"),\n      fetchURL: activeDocMethod.bind(null, \"viewers\", \"fetchURL\"),\n      getActionSummaries: activeDocMethod.bind(null, \"viewers\", \"getActionSummaries\"),\n      reloadDoc: activeDocMethod.bind(null, \"editors\", \"reloadDoc\"),\n      fork: activeDocMethod.bind(null, \"viewers\", \"fork\"),\n      checkAclFormula: activeDocMethod.bind(null, \"viewers\", \"checkAclFormula\"),\n      getAclResources: activeDocMethod.bind(null, \"viewers\", \"getAclResources\"),\n      waitForInitialization: activeDocMethod.bind(null, \"viewers\", \"waitForInitialization\"),\n      getUsersForViewAs: activeDocMethod.bind(null, \"viewers\", \"getUsersForViewAs\"),\n      getAccessToken: activeDocMethod.bind(null, \"viewers\", \"getAccessToken\"),\n      getShare: activeDocMethod.bind(null, \"owners\", \"getShare\"),\n      startTiming: activeDocMethod.bind(null, \"owners\", \"startTiming\"),\n      stopTiming: activeDocMethod.bind(null, \"owners\", \"stopTiming\"),\n      getAssistantState: activeDocMethod.bind(null, \"owners\", \"getAssistantState\"),\n      listActiveUserProfiles: activeDocMethod.bind(null, null, \"listActiveUserProfiles\"),\n      applyProposal: activeDocMethod.bind(null, \"owners\", \"applyProposal\"),\n      getAssistance: activeDocMethod.bind(null, \"viewers\", \"getAssistance\"),\n    });\n  }\n\n  // Register methods related to plugins.\n  public registerCommPlugin(): void {\n    this._comm.registerMethods({\n      forwardPluginRpc: activeDocMethod.bind(null, \"editors\", \"forwardPluginRpc\"),\n      // TODO: consider not providing reloadPlugins on hosted grist, since it affects the\n      // plugin manager shared across docs on a given doc worker, and seems useful only in\n      // standalone case.\n      reloadPlugins: activeDocMethod.bind(null, \"editors\", \"reloadPlugins\"),\n    });\n  }\n\n  // Checks that document is accessible, and adds docAuth information to request.\n  // Otherwise issues a 403 access denied.\n  // (This is used for endpoints like /download, /gen-csv, /attachment.)\n  public async assertDocAccess(\n    req: express.Request,\n    res: express.Response,\n    next: express.NextFunction,\n  ) {\n    const mreq = req as RequestWithLogin;\n    let urlId: string | undefined;\n    try {\n      if (optStringParam(req.query.clientId, \"clientId\")) {\n        const activeDoc = this._getDocSession(stringParam(req.query.clientId, \"clientId\"),\n          integerParam(req.query.docFD, \"docFD\")).activeDoc;\n        // TODO: The docId should be stored in the ActiveDoc class. Currently docName is\n        // used instead, which will coincide with the docId for hosted grist but not for\n        // standalone grist.\n        urlId = activeDoc.docName;\n      } else {\n        // Otherwise, if being used without a client, expect the doc query parameter to\n        // be the docId.\n        urlId = stringParam(req.query.doc, \"doc\");\n      }\n      if (!urlId) { return res.status(403).send({ error: \"missing document id\" }); }\n\n      const docAuth = await getOrSetDocAuth(mreq, this._dbManager, this._gristServer, urlId);\n      assertAccess(\"viewers\", docAuth);\n      next();\n    } catch (err) {\n      log.info(`DocWorker can't access document ${urlId} with userId ${mreq.userId}: ${err}`);\n      res.status(err.status || 404).send({ error: err.toString() });\n    }\n  }\n\n  private _getDocSession(clientId: string, docFD: number): DocSession {\n    const client = this._comm.getClient(clientId);\n    return client.getDocSession(docFD);\n  }\n}\n\n/**\n * Translates calls from the browser client into calls of the form\n * `activeDoc.method(docSession, ...args)`.\n */\nasync function activeDocMethod(role: \"viewers\" | \"editors\" | \"owners\" | null, methodName: string, client: Client,\n  docFD: number, ...args: any[]): Promise<any> {\n  const docSession = client.getDocSession(docFD);\n  const activeDoc = docSession.activeDoc;\n  if (role) { await docSession.authorizer.assertAccess(role); }\n  // Include a basic log record for each ActiveDoc method call.\n  log.rawDebug(\"activeDocMethod\", activeDoc.getLogMeta(docSession, methodName));\n  return (activeDoc as any)[methodName](docSession, ...args);\n}\n"
  },
  {
    "path": "app/server/lib/DocWorkerLoadTracker.ts",
    "content": "import { clamp } from \"app/common/gutil\";\nimport { Interval } from \"app/common/Interval\";\nimport { DocWorkerMap } from \"app/gen-server/lib/DocWorkerMap\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { DocManager, IMemoryLoadEstimator } from \"app/server/lib/DocManager\";\nimport { DocWorkerInfo, IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport log from \"app/server/lib/log\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\n\nimport fs from \"node:fs/promises\";\nexport const Deps = {\n  docWorkerMaxMemoryMBForcedValue: appSettings\n    .section(\"docWorker\")\n    .flag(\"maxMemoryMB\")\n    .readInt({\n      envVar: \"GRIST_DOC_WORKER_MAX_MEMORY_MB\",\n      minValue: 1,\n    }),\n  docWorkerUpdateLoadIntervalMs: appSettings\n    .section(\"docWorker\")\n    .flag(\"updateLoadIntervalMs\")\n    .requireInt({\n      envVar: \"GRIST_DOC_WORKER_UPDATE_LOAD_INTERVAL_MS\",\n      minValue: 1,\n      defaultValue: 5 * 1000,\n    }),\n  docWorkerUpdateLoadVarianceMs: appSettings\n    .section(\"docWorker\")\n    .flag(\"updateLoadVarianceMs\")\n    .requireInt({\n      envVar: \"GRIST_DOC_WORKER_UPDATE_LOAD_VARIANCE_MS\",\n      minValue: 0,\n      defaultValue: 1 * 1000,\n    }),\n  docWorkerUsedMemoryBytesPath: appSettings\n    .section(\"docWorker\")\n    .flag(\"memoryUsagePath\")\n    .readString({\n      envVar: \"GRIST_DOC_WORKER_USED_MEMORY_BYTES_PATH\",\n    }),\n  docWorkerMaxMemoryBytesPath: appSettings\n    .section(\"docWorker\")\n    .flag(\"memoryCapacityPath\")\n    .readString({\n      envVar: \"GRIST_DOC_WORKER_MAX_MEMORY_BYTES_PATH\",\n    }),\n};\n\n/**\n * Returns a {@link DocWorkerLoadTracker} or `undefined` if `docWorkerMap` is\n * not backed by Redis.\n */\nexport function getDocWorkerLoadTracker(\n  docWorkerInfo: DocWorkerInfo,\n  docWorkerMap: IDocWorkerMap,\n  docManager: DocManager,\n): DocWorkerLoadTracker | undefined {\n  if (docWorkerMap instanceof DocWorkerMap) {\n    log.info(\"Creating Redis-based DocWorkerLoadTracker\");\n    return new DocWorkerLoadTracker(docWorkerInfo, docWorkerMap, docManager);\n  } else {\n    return undefined;\n  }\n}\n\n/**\n * Periodically updates doc worker load by pushing it to a Redis-backed\n * {@link IDocWorkerMap}.\n */\nexport class DocWorkerLoadTracker {\n  private _log = new LogMethods(\"DocWorkerLoadTracker \", () => ({}));\n  private _interval = new Interval(\n    this._updateLoad.bind(this),\n    {\n      delayMs: Deps.docWorkerUpdateLoadIntervalMs,\n      varianceMs: Deps.docWorkerUpdateLoadVarianceMs,\n    },\n    {\n      onError: e => this._log.error(null, \"failed to update worker load\", e),\n    },\n  );\n\n  constructor(\n    private _docWorkerInfo: DocWorkerInfo,\n    private _docWorkerMap: IDocWorkerMap,\n    private _docManager: IMemoryLoadEstimator,\n  ) {}\n\n  /**\n   * Starts periodically updating load.\n   */\n  public start() {\n    this._interval.enable();\n  }\n\n  /**\n   * Stops periodically updating load.\n   */\n  public stop() {\n    this._interval.disable();\n  }\n\n  /**\n   * Returns a number between `0.0` and `1.0` inclusive representing the load\n   * of a worker.\n   *\n   * A worker's load is the ratio of used to total memory, where used memory is\n   * the combined total of data engine memory across all loaded documents, and\n   * total memory is `GRIST_DOC_WORKER_MAX_MEMORY_MB`.\n   *\n   * If `GRIST_DOC_WORKER_MAX_MEMORY_MB` is unset, load will always be reported\n   * as 0, resulting in uniform random selection being used for the worker\n   * assignment algorithm.\n   */\n  public async getLoad() {\n    const memoryUsedMB = await this._getMemoryUsedMB();\n    const memoryTotalMB = await this._getMemoryTotalMB();\n    return clamp(memoryUsedMB / memoryTotalMB, 0.0, 1.0);\n  }\n\n  /**\n   * Return the amount of memory reported by a file of the system.\n   * This file should contain a value in bytes, the function will convert it\n   * to mega bytes.\n   *\n   * @param filePath The path to the file to read\n   * @param valueProcessor A function that may return an amount of memory if the file contains a special value.\n   *\n   * @returns The amount of memory reported by the file converted to megabytes.\n   */\n  private async _readValueFromFileInMB(\n    filePath: string,\n    valueProcessor?: (val: string) => number | undefined,\n  ): Promise<number> {\n    const rawVal = await fs.readFile(filePath, \"utf-8\");\n    const valInBytes = valueProcessor?.(rawVal) ?? parseInt(rawVal, 10);\n\n    if (isNaN(valInBytes)) {\n      throw new Error(\n        `Unexpected value (not a number) found in file in \"${filePath}\". value = ${rawVal.slice(0, 1000)}`,\n      );\n    }\n\n    return valInBytes / 1024 ** 2;\n  }\n\n  /**\n   * We read the memory used in this order:\n   * 1. If we have a path specified for a file that contains the memory used, read this file\n   * 2. Otherwise read instead the load using the estimation given by the doc manager\n   *    (less accurate, typically it does not include nodejs load).\n   *\n   * @returns The used memory\n   * @throws When the file at the path can't be read or doesn't contain a number.\n   */\n  private async _getMemoryUsedMB(): Promise<number> {\n    if (Deps.docWorkerUsedMemoryBytesPath !== undefined) {\n      return await this._readValueFromFileInMB(Deps.docWorkerUsedMemoryBytesPath);\n    }\n\n    return this._docManager.getTotalMemoryUsedMB();\n  }\n\n  /**\n   * We read the total memory available in this order:\n   * 1. If the admin specified an amount of total memory available through GRIST_DOC_WORKER_MAX_MEMORY_MB\n   *    then use it (to cover the case the administrator wants to pass a lower value than the actual\n   *    total memory, and have spare free memory for the current documents)\n   * 2. If the admin specified a path to read the total amount of memory, read it.\n   * 2.1. If the value is max, consider as \"Infinity\"\n   * 2.2. If the value is a number, return it\n   * 3. Return Infinity\n   *\n   * @returns The total memory we should consider as available for the worker.\n   * @throws When the file at the path can't be read or doesn't contain a valid value as described above.\n   */\n  private async _getMemoryTotalMB() {\n    if (Deps.docWorkerMaxMemoryMBForcedValue !== undefined) {\n      return Deps.docWorkerMaxMemoryMBForcedValue;\n    }\n    if (Deps.docWorkerMaxMemoryBytesPath !== undefined) {\n      return await this._readValueFromFileInMB(\n        Deps.docWorkerMaxMemoryBytesPath,\n        // When the value is \"max\", return Infinity, otherwise return undefined\n        // so the function reads what's probably an integer value.\n        val => val === \"max\" ? Infinity : undefined,\n      );\n    }\n\n    return Infinity;\n  }\n\n  private async _updateLoad() {\n    await this._docWorkerMap.setWorkerLoad(\n      this._docWorkerInfo,\n      await this.getLoad(),\n    );\n  }\n}\n"
  },
  {
    "path": "app/server/lib/DocWorkerMap.ts",
    "content": "/**\n * Defines the IDocWorkerMap interface we need to assign a DocWorker to a doc, and to look it up.\n * TODO This is not yet implemented, there is only a hard-coded stub.\n */\n\nimport { IChecksumStore } from \"app/server/lib/IChecksumStore\";\nimport { IElectionStore } from \"app/server/lib/IElectionStore\";\nimport { IPermitStores } from \"app/server/lib/Permit\";\n\nimport { RedisClient } from \"redis\";\n\nexport interface DocWorkerInfo {\n  id: string;\n\n  // The public base URL for the docWorker, which tells the browser how to connect to it. E.g.\n  // https://docworker-17.getgrist.com/ or http://localhost:8080/v/gtag/\n  publicUrl: string;\n\n  // The internal base URL for the docWorker.\n  internalUrl: string;\n\n  // If set, worker should accept work only for this named group.\n  group?: string;\n}\n\nexport interface DocStatus {\n  // MD5 hash of the SQLite file for this document as stored on S3. We use MD5 because it is\n  // automatically computed by S3 (except for multipart uploads). Null indicates a new file.\n  docMD5: string | null;\n\n  // DocWorker most recently, or currently, responsible for the file.\n  docWorker: DocWorkerInfo;\n\n  // Whether the file is currently open on this DocWorker.\n  isActive: boolean;\n}\n\n/**\n * Assignment of documents to workers, and other storage related to distributed work.\n */\nexport interface IDocWorkerMap extends IPermitStores, IElectionStore, IChecksumStore {\n  // Looks up which DocWorker is responsible for this docId.\n  getDocWorker(docId: string): Promise<DocStatus | null>;\n\n  // Assigns a DocWorker to this docId if one is not yet assigned.\n  assignDocWorker(docId: string): Promise<DocStatus>;\n\n  // Assigns a particular DocWorker to this docId if one is not yet assigned.\n  getDocWorkerOrAssign(docId: string, workerId: string): Promise<DocStatus>;\n\n  updateDocStatus(docId: string, checksum: string): Promise<void>;\n\n  addWorker(info: DocWorkerInfo): Promise<void>;\n\n  removeWorker(workerId: string): Promise<void>;\n\n  // Set whether worker is accepting new assignments.  This does not automatically\n  // release existing assignments.\n  setWorkerAvailability(workerId: string, available: boolean): Promise<void>;\n\n  setWorkerLoad(workerInfo: DocWorkerInfo, load: number): Promise<void>;\n\n  isWorkerRegistered(workerInfo: DocWorkerInfo): Promise<boolean>;\n\n  // Releases doc from worker, freeing it to be assigned elsewhere.\n  // Assignments should only be released for workers that are now unavailable.\n  releaseAssignment(workerId: string, docId: string): Promise<void>;\n\n  // Get all assignments for a worker.  Should only be queried for a worker that\n  // is currently unavailable.\n  getAssignments(workerId: string): Promise<string[]>;\n\n  getWorkerGroup(workerId: string): Promise<string | null>;\n\n  getDocGroup(docId: string): Promise<string | null>;\n\n  updateDocGroup(docId: string, docGroup: string): Promise<void>;\n\n  removeDocGroup(docId: string): Promise<void>;\n\n  getRedisClient(): RedisClient | null;\n}\n"
  },
  {
    "path": "app/server/lib/DocWorkerUtils.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { parseSubdomainStrictly } from \"app/common/gristUrls\";\nimport { removeTrailingSlash } from \"app/common/gutil\";\nimport { DocStatus, DocWorkerInfo, IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport { getAssignmentId } from \"app/server/lib/idUtils\";\nimport log from \"app/server/lib/log\";\nimport { adaptServerUrl } from \"app/server/lib/requestUtils\";\n\nimport * as express from \"express\";\nimport fetch, { RequestInit, Response as FetchResponse } from \"node-fetch\";\n\n/**\n * This method transforms a doc worker's public url as needed based on the request.\n *\n * For historic reasons, doc workers are assigned a public url at the time\n * of creation.  In production/staging, this is of the form:\n *   https://doc-worker-NNN-NNN-NNN-NNN.getgrist.com/v/VVVV/\n * and in dev:\n *   http://localhost:NNNN/v/VVVV/\n *\n * Prior to support for different base domains, this was fine.  Now that different\n * base domains are supported, a wrinkle arises.  When a web client communicates\n * with a doc worker, it is important that it accesses the doc worker via a url\n * containing the same base domain as the web page the client is on (for cookie\n * purposes).  Hence this method.\n *\n * If both the request and docWorkerUrl contain identifiable base domains (not localhost),\n * then the base domain of docWorkerUrl is replaced with that of the request.\n *\n * But wait, there's another wrinkle: custom domains. In this case, we have a single\n * domain available to serve a particular org from. This method will use the origin of req\n * and include a /dw/doc-worker-NNN-NNN-NNN-NNN/\n * (or /dw/local-NNNN/) prefix in all doc worker paths.  Once this is in place, it\n * will allow doc worker routing to be changed so it can be overlaid on a custom\n * domain.\n *\n * TODO: doc worker registration could be redesigned to remove the assumption\n * of a fixed base domain.\n */\nexport function customizeDocWorkerUrl(docWorkerUrlSeed: string, req: express.Request): string {\n  const docWorkerUrl = new URL(docWorkerUrlSeed);\n  const workerSubdomain = parseSubdomainStrictly(docWorkerUrl.hostname).org;\n  adaptServerUrl(docWorkerUrl, req);\n\n  // We wish to migrate to routing doc workers by path, so insert a doc worker identifier\n  // in the path (if not already present).\n  if (!docWorkerUrl.pathname.startsWith(\"/dw/\")) {\n    // When doc worker is localhost, the port number is necessary and sufficient for routing.\n    // Let's add a /dw/... prefix just for consistency.\n    const workerIdent = workerSubdomain || `local-${docWorkerUrl.port}`;\n    docWorkerUrl.pathname = `/dw/${workerIdent}${docWorkerUrl.pathname}`;\n  }\n  return docWorkerUrl.href;\n}\n\n/**\n *\n * Gets the worker responsible for a given assignment, and fetches a url\n * from the worker.\n *\n * If the fetch fails, we throw an exception, unless we see enough evidence\n * to unassign the worker and try again.\n *\n *  - If GRIST_MANAGED_WORKERS is set, we assume that we've arranged\n *    for unhealthy workers to be removed automatically, and that if a\n *    fetch returns a 404 with specific content, it is proof that the\n *    worker is no longer in existence. So if we see a 404 with that\n *    specific content, we can safely de-list the worker from redis,\n *    and repeat.\n *  - If GRIST_MANAGED_WORKERS is not set, we accept a broader set\n *    of failures as evidence of a missing worker.\n *\n * The specific content of a 404 that will be treated as evidence of\n * a doc worker not being present is:\n *  - A json format body\n *  - With a key called \"message\"\n *  - With the value of \"message\" being \"document worker not present\"\n *  In production, this is provided by a special doc-worker-* load balancer\n *  rule.\n *\n */\nexport async function getWorker(\n  docWorkerMap: IDocWorkerMap,\n  assignmentId: string,\n  urlPath: string,\n  config: RequestInit = {},\n) {\n  if (!useWorkerPool()) {\n    // This should never happen. We are careful to not use getWorker\n    // when everything is on a single server, since it is burdensome\n    // for self-hosted users to figure out the correct settings for\n    // the server to be able to contact itself, and there are cases\n    // of the defaults not working.\n    throw new Error(\"AppEndpoint.getWorker was called unnecessarily\");\n  }\n  let docStatus: DocStatus | undefined;\n  const workersAreManaged = Boolean(process.env.GRIST_MANAGED_WORKERS);\n  for (;;) {\n    docStatus = await docWorkerMap.assignDocWorker(assignmentId);\n    const configWithTimeout = { timeout: 10000, ...config };\n    const fullUrl = removeTrailingSlash(docStatus.docWorker.internalUrl) + urlPath;\n    try {\n      const resp: FetchResponse = await fetch(fullUrl, configWithTimeout);\n      if (resp.ok) {\n        return {\n          resp,\n          docStatus,\n        };\n      }\n      if (resp.status === 403) {\n        throw new ApiError(\"You do not have access to this document.\", resp.status);\n      }\n      if (resp.status !== 404) {\n        throw new ApiError(resp.statusText, resp.status);\n      }\n      let body: any;\n      try {\n        body = await resp.json();\n      } catch (e) {\n        throw new ApiError(resp.statusText, resp.status);\n      }\n      if (!(body?.message && body.message === \"document worker not present\")) {\n        throw new ApiError(resp.statusText, resp.status);\n      }\n      // This is a 404 with the expected content for a missing worker.\n    } catch (e) {\n      log.rawDebug(`AppEndpoint.getWorker failure`, {\n        url: fullUrl,\n        docId: assignmentId,\n        status: e.status,\n        message: String(e),\n        workerId: docStatus.docWorker.id,\n      });\n      // If workers are managed, no errors merit continuing except a 404.\n      // Otherwise, we continue if we see a system error (e.g. ECONNREFUSED).\n      // We don't accept timeouts since there is too much potential to\n      // bring down a single-worker deployment that has a hiccup.\n      if (workersAreManaged || !(e.type === \"system\")) {\n        throw e;\n      }\n    }\n    log.warn(`fetch from ${fullUrl} failed convincingly, removing that worker`);\n    await docWorkerMap.removeWorker(docStatus.docWorker.id);\n    docStatus = undefined;\n  }\n}\n\nexport type DocWorkerInfoOrSelfPrefix = {\n  docWorker: DocWorkerInfo,\n  selfPrefix?: never,\n} | {\n  docWorker?: never,\n  selfPrefix: string\n};\n\nexport async function getDocWorkerInfoOrSelfPrefix(\n  docId: string,\n  docWorkerMap?: IDocWorkerMap | null,\n  tag?: string,\n): Promise<DocWorkerInfoOrSelfPrefix> {\n  if (!useWorkerPool()) {\n    // Let the client know there is not a separate pool of workers,\n    // so they should continue to use the same base URL for accessing\n    // documents. For consistency, return a prefix to add into that\n    // URL, as there would be for a pool of workers. It would be nice\n    // to go ahead and provide the full URL, but that requires making\n    // more assumptions about how Grist is configured.\n    // Alternatives could be: have the client to send their base URL\n    // in the request; or use headers commonly added by reverse proxies.\n    const selfPrefix = \"/dw/self/v/\" + tag;\n    return { selfPrefix };\n  }\n\n  if (!docWorkerMap) {\n    throw new Error(\"no worker map\");\n  }\n  const assignmentId = getAssignmentId(docWorkerMap, docId);\n  const { docStatus } = await getWorker(docWorkerMap, assignmentId, \"/status\");\n  if (!docStatus) {\n    throw new Error(\"no worker\");\n  }\n  return { docWorker: docStatus.docWorker };\n}\n\n// Return true if document related endpoints are served by separate workers.\nexport function useWorkerPool() {\n  return process.env.GRIST_SINGLE_PORT !== \"true\";\n}\n"
  },
  {
    "path": "app/server/lib/ExcelFormatter.ts",
    "content": "import { CellValue } from \"app/common/DocActions\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport * as gutil from \"app/common/gutil\";\nimport { NumberFormatOptions } from \"app/common/NumberFormat\";\nimport { FormatOptions, formatUnknown, IsRightTypeFunc } from \"app/common/ValueFormatter\";\nimport { GristType } from \"app/plugin/GristData\";\nimport { decodeObject } from \"app/plugin/objtypes\";\n\nimport getSymbolFromCurrency from \"currency-symbol-map\";\nimport { Style } from \"exceljs\";\nimport moment from \"moment-timezone\";\n\ninterface WidgetOptions extends NumberFormatOptions {\n  textColor?: \"string\";\n  fillColor?: \"string\";\n  alignment?: \"left\" | \"center\" | \"right\";\n  dateFormat?: string;\n  timeFormat?: string;\n}\nclass BaseFormatter {\n  protected isRightType: IsRightTypeFunc;\n  protected widgetOptions: WidgetOptions;\n\n  constructor(public type: string, public opts: FormatOptions) {\n    this.isRightType = gristTypes.isRightType(gristTypes.extractTypeFromColType(type)) ||\n      gristTypes.isRightType(\"Any\")!;\n    this.widgetOptions = opts;\n  }\n\n  /**\n   * Formats a value that matches the type of this formatter. This should be overridden by derived\n   * classes to handle values in formatter-specific ways.\n   */\n  public format(value: any): any {\n    return value;\n  }\n\n  public style(): Partial<Style> {\n    const argb = (hex: string) => `FF${hex.substr(1)}`;\n    const style: Partial<Style> = {};\n    if (this.widgetOptions.fillColor) {\n      style.fill = {\n        type: \"pattern\",\n        pattern: \"solid\",\n        fgColor: { argb: argb(this.widgetOptions.fillColor) },\n      };\n    }\n    if (this.widgetOptions.textColor) {\n      style.font = {\n        color: { argb: argb(this.widgetOptions.textColor) },\n      };\n    }\n    if (this.widgetOptions.alignment) {\n      style.alignment = {\n        horizontal: this.widgetOptions.alignment,\n      };\n    }\n    if (this.widgetOptions.dateFormat) {\n      style.numFmt = excelDateFormat(this.widgetOptions.dateFormat, \"yyyy-mm-dd\");\n    }\n    if (this.widgetOptions.timeFormat) {\n      style.numFmt = excelDateFormat(this.widgetOptions.dateFormat!, \"yyyy-mm-dd\") + \" \" +\n        excelDateFormat(this.widgetOptions.timeFormat, \"h:mm am/pm\");\n    }\n    // For number formats - we will support default excel formatting only,\n    // those formats strings are the defaults that LibreOffice Calc is using.\n    if (this.widgetOptions.numMode) {\n      if (this.widgetOptions.numMode === \"currency\") {\n        // If currency name is undefined or null, it should be cast to unknown currency, because\n        // \"getSymbolFromCurrency\" expect argument to be string\n        const currencyName = this.widgetOptions.currency ?? \"\";\n        const currencySymbol = getSymbolFromCurrency(currencyName) ??\n          this.widgetOptions.currency ??\n          \"$\";\n        style.numFmt = `\"${currencySymbol} \"#,##0.000`;\n      } else if (this.widgetOptions.numMode === \"percent\") {\n        style.numFmt = \"0.00%\";\n      } else if (this.widgetOptions.numMode === \"decimal\") {\n        style.numFmt = \"0.00\";\n      } else if (this.widgetOptions.numMode === \"scientific\") {\n        style.numFmt = \"0.00E+00\";\n      }\n    }\n    return style;\n  }\n\n  /**\n   * Formats using this.format() if a value is of the right type for this formatter, or using\n   * formatUnknown (like AnyFormatter) otherwise, resulting in a string representation.\n   */\n  public formatAny(value: any): any {\n    return this.isRightType(value) ? this.format(value) : formatUnknown(value);\n  }\n}\n\nclass AnyFormatter extends BaseFormatter {\n  public format(value: any): any {\n    return formatUnknown(value);\n  }\n}\n\nclass ChoiceListFormatter extends BaseFormatter {\n  public format(value: any): any {\n    const obj = decodeObject(value);\n    if (Array.isArray(obj)) {\n      return obj.join(\"; \");\n    }\n    return formatUnknown(value);\n  }\n}\n\nclass UnsupportedFormatter extends BaseFormatter {\n  public format(value: any): any {\n    return \"\";\n  }\n}\n\nclass NumberFormatter extends BaseFormatter {\n  public format(value: any): any {\n    return Number.isFinite(value) ? value : \"\";\n  }\n}\n\nclass DateFormatter extends BaseFormatter {\n  private _timezone: string;\n\n  constructor(type: string, opts: WidgetOptions, timezone: string = \"UTC\") {\n    opts.dateFormat = opts.dateFormat || \"YYYY-MM-DD\";\n    super(type, opts);\n    this._timezone = timezone || \"UTC\";\n    // For native conversion - booleans are not a right type.\n    this.isRightType = (value: CellValue) => typeof value === \"number\";\n  }\n\n  public format(value: any): any {\n    if (value === null) { return \"\"; }\n    // convert time to correct timezone\n    const time = moment(value * 1000).tz(this._timezone);\n    // in case moment is not able to interpret this as a valid date\n    // fallback to formatUnknown, for example for 0, NaN, Infinity\n    if (!time) {\n      return formatUnknown(value);\n    }\n    // make it look like a local time\n    time.utc(true).local();\n    // moment objects are mutable so we can just return original object.\n    return time.toDate();\n  }\n}\n\nclass DateTimeFormatter extends DateFormatter {\n  constructor(type: string, opts: WidgetOptions) {\n    const timezone = gutil.removePrefix(type, \"DateTime:\") || \"\";\n    opts.timeFormat = opts.timeFormat === undefined ? \"h:mma\" : opts.timeFormat;\n    super(type, opts, timezone);\n  }\n}\n\nconst formatters: Partial<Record<GristType, typeof BaseFormatter>> = {\n  // for numbers - return javascript number\n  Numeric: NumberFormatter,\n  Int: NumberFormatter,\n  // for booleans - return javascript booleans\n  Bool: BaseFormatter,\n  // for dates - return javascript Date object\n  Date: DateFormatter,\n  DateTime: DateTimeFormatter,\n  ChoiceList: ChoiceListFormatter,\n  // for attachments - return blank cell\n  Attachments: UnsupportedFormatter,\n  // for anything else - return string (use default AnyFormatter)\n};\n\n/**\n * Takes column type and format options and returns a constructor with a format function that can\n * properly convert a value passed to it into the right javascript object for that column.\n * Exceljs library is using javascript primitives to specify correct excel type.\n */\nexport function createExcelFormatter(type: string, opts: FormatOptions): BaseFormatter {\n  const ctor = formatters[gristTypes.extractTypeFromColType(type) as GristType] || AnyFormatter;\n  return new ctor(type, opts);\n}\n\n// ----------------------------------------------------------------------\n// Helper functions\n// ----------------------------------------------------------------------\n\n// Mapping from moment-js basic date format tokens to excel numFmt basic tokens.\n// We will convert all our predefined format to excel ones, and try to do our\n// best on converting custom formats. If we fail on custom formats we will fall\n// back to default ones.\n// More on formats can be found:\n// https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.numberingformats?view=openxml-2.8.1\n// http://officeopenxml.com/WPdateTimeFieldSwitches.php\nconst mapping = new Map<string, string>();\nmapping.set(\"YYYY\", \"yyyy\");\nmapping.set(\"YY\", \"yy\");\nmapping.set(\"M\", \"m\");\nmapping.set(\"MM\", \"mm\");\nmapping.set(\"MMM\", \"mmm\");\nmapping.set(\"MMMM\", \"mmmm\");\nmapping.set(\"D\", \"d\");\nmapping.set(\"DD\", \"dd\");\nmapping.set(\"DDD\", \"ddd\");\nmapping.set(\"DDDD\", \"dddd\");\nmapping.set(\"Do\", \"dd\"); // no direct match\nmapping.set(\"L\", \"yyyy-mm-dd\");\nmapping.set(\"LL\", \"mmmmm d yyyy\");\nmapping.set(\"LLL\", \"mmmmm d yyyy h:mm am/pm\");\nmapping.set(\"LLLL\", \"ddd, mmmmm d yyyy h:mm am/pm\");\nmapping.set(\"h\", \"h\");\nmapping.set(\"HH\", \"hh\");\n// Minutes formats are the same as month's ones, but when they are after hour format\n// they are treated as minutes.\nmapping.set(\"m\", \"m\");\nmapping.set(\"mm\", \"mm\");\nmapping.set(\"mma\", \"mm am/pm\");\nmapping.set(\"ss\", \"ss\");\nmapping.set(\"s\", \"s\");\nmapping.set(\"a\", \"am/pm\");\nmapping.set(\"A\", \"am/pm\");\nmapping.set(\"S\", \"0\");\nmapping.set(\"SS\", \"00\");\nmapping.set(\"SSS\", \"000\");\nmapping.set(\"SSSS\", \"0000\");\nmapping.set(\"SSSSS\", \"00000\");\nmapping.set(\"SSSSSS\", \"000000\");\n// We will omit timezone formats\nmapping.set(\"z\", \"\");\nmapping.set(\"zz\", \"\");\nmapping.set(\"Z\", \"\");\nmapping.set(\"ZZ\", \"\");\n\n/**\n * Converts Moment js format string to excel numFormat\n * @param format Moment js format string\n * @param def Default excel format string\n */\nfunction excelDateFormat(format: string, def: string) {\n  // split format to chunks by common separator\n  const chunks = format.split(/([\\s:.,-/]+)/);\n\n  // try to map chunks\n  for (let i = 0; i < chunks.length; i += 2) {\n    const chunk = chunks[i];\n    if (mapping.has(chunk)) {\n      chunks[i] = mapping.get(chunk)!;\n    } else {\n      // fail on first mismatch\n      return def;\n    }\n  }\n  // fix the separators - they need to be prefixed by backslash\n  for (let i = 1; i < chunks.length; i += 2) {\n    const sep = chunks[i];\n    if (sep === \"-\") {\n      chunks[i] = \"\\\\-\";\n    }\n    if (sep.trim() === \"\") {\n      chunks[i] = \"\\\\\" + sep;\n    }\n  }\n\n  return chunks.join(\"\");\n}\n"
  },
  {
    "path": "app/server/lib/ExpandedQuery.ts",
    "content": "import { ServerQuery } from \"app/common/ActiveDocAPI\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { parseFormula } from \"app/common/Formula\";\nimport { removePrefix } from \"app/common/gutil\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { quoteIdent } from \"app/server/lib/SQLiteDB\";\n\n/**\n * Represents a query for Grist data with support for SQL-based\n * formulas.  Use of this representation should be limited to within a\n * trusted part of Grist since it assembles SQL strings.\n */\nexport interface ExpandedQuery extends ServerQuery {\n  // Errors detected for given columns because of formula issues.  We\n  // need to make sure the result of the query contains these error\n  // objects.  It is awkward to write a sql selection that constructs\n  // an error object, so instead we select 0 in the case of an error,\n  // and substitute in the error object in javascript after the SQL\n  // step.  That means we need to pass the error message along\n  // explicitly.\n  constants?: {\n    [colId: string]: [GristObjCode.Exception, string] | [GristObjCode.Pending];\n  };\n\n  // A list of join clauses to bring in data from other tables.\n  joins?: string[];\n\n  // A list of selections for regular data and data computed via formulas.\n  selects?: string[];\n}\n\n/**\n * Add JOINs and SELECTs to a query in order to implement formulas via SQL.\n *\n * Supports simple formulas that load a column via a reference.\n * The referenced column itself cannot (yet) be a formula.\n * Filtered columns cannot (yet) be a formula.\n *\n * If onDemandFormulas is set, ignore stored formula columns, and compute them using SQL.\n */\nexport function expandQuery(iquery: ServerQuery, docData: DocData, onDemandFormulas: boolean = true): ExpandedQuery {\n  const query: ExpandedQuery = {\n    tableId: iquery.tableId,\n    filters: iquery.filters,\n    limit: iquery.limit,\n    where: iquery.where,\n  };\n\n  // Start accumulating a set of joins and selects needed for the query.\n  const joins = new Set<string>();\n  const selects = new Set<string>();\n\n  // Iterate through all formulas, adding joins and selects as we go.\n  if (onDemandFormulas) {\n    // Look up the main table for the query.\n    const tables = docData.getMetaTable(\"_grist_Tables\");\n    const columns = docData.getMetaTable(\"_grist_Tables_column\");\n    const tableRef = tables.findRow(\"tableId\", query.tableId);\n    if (!tableRef) { throw new ApiError(\"table not found: \" + query.tableId, 404); }\n\n    // Find any references to other tables.\n    const dataColumns = columns.filterRecords({ parentId: tableRef, isFormula: false });\n    const references = new Map<string, string>();\n    for (const column of dataColumns) {\n      const refTableId = removePrefix(column.type as string, \"Ref:\");\n      if (refTableId) { references.set(column.colId as string, refTableId); }\n    }\n\n    selects.add(`${quoteIdent(query.tableId)}.id`);\n    for (const column of dataColumns) {\n      selects.add(`${quoteIdent(query.tableId)}.${quoteIdent(column.colId as string)}`);\n    }\n    const formulaColumns = columns.filterRecords({ parentId: tableRef, isFormula: true });\n    for (const column of formulaColumns) {\n      const formula = parseFormula(column.formula as string);\n      const colId = column.colId as string;\n      let sqlFormula = \"\";\n      let error = \"\";\n      if (formula.kind === \"foreignColumn\") {\n        const altTableId = references.get(formula.refColId);\n        const altTableRef = tables.findRow(\"tableId\", altTableId!);\n        if (altTableId && altTableRef) {\n          const altColumn = columns.filterRecords({ parentId: altTableRef, isFormula: false, colId: formula.colId });\n          // TODO: deal with a formula column in the other table.\n          if (altColumn.length > 0) {\n            const alias = `${query.tableId}_${formula.refColId}`;\n            joins.add(`LEFT JOIN ${quoteIdent(altTableId)} AS ${quoteIdent(alias)} ` +\n              `ON ${quoteIdent(alias)}.id = ` +\n              `${quoteIdent(query.tableId)}.${quoteIdent(formula.refColId)}`);\n            sqlFormula = `${quoteIdent(alias)}.${quoteIdent(formula.colId)}`;\n          } else {\n            error = \"Cannot find column\";\n          }\n        } else {\n          error = \"Cannot find table\";\n        }\n      } else if (formula.kind === \"column\") {\n        const altColumn = columns.filterRecords({ parentId: tableRef, isFormula: false, colId: formula.colId });\n        // TODO: deal with a formula column.\n        if (altColumn.length > 0) {\n          sqlFormula = `${quoteIdent(query.tableId)}.${quoteIdent(formula.colId)}`;\n        } else {\n          error = \"Cannot find column\";\n        }\n      } else if (formula.kind === \"literalNumber\") {\n        sqlFormula = `${formula.value}`;\n      } else if (formula.kind === \"error\") {\n        error = formula.msg;\n      } else {\n        throw new Error(\"Unrecognized type of formula\");\n      }\n      if (error) {\n        // We add a trivial selection, and store errors in the query for substitution later.\n        sqlFormula = \"0\";\n        if (!query.constants) { query.constants = {}; }\n        query.constants[colId] = [GristObjCode.Exception, error];\n      }\n      if (sqlFormula) {\n        selects.add(`${sqlFormula} as ${quoteIdent(colId)}`);\n      }\n    }\n  } else {\n    // Select all data and formula columns.\n    selects.add(`${quoteIdent(query.tableId)}.*`);\n  }\n\n  // Copy decisions to the query object, and return.\n  query.joins = [...joins];\n  query.selects = [...selects];\n  return query;\n}\n\nexport function getFormulaErrorForExpandQuery(docData: DocData, tableId: string, colId: string): CellValue {\n  // On-demand tables may produce several kinds of error messages, e.g. \"Formula not supported\" or\n  // \"Cannot find column\". We construct the full query to get the basic message for the requested\n  // column, then tack on the detail, which is fine to be the same for all of them.\n  const iquery: ServerQuery = { tableId, filters: {} };\n  const expanded = expandQuery(iquery, docData, true);\n  const constantValue = expanded.constants?.[colId];\n  if (constantValue?.length === 2) {\n    return [GristObjCode.Exception, constantValue[1],\n      `Not supported in on-demand tables.\n\nThis table is marked as an on-demand table. Such tables don't support most formulas. \\\nFor proper formula support, unmark it as on-demand.\n`];\n  }\n  return null;\n}\n\n/**\n * Build a query that relates two homogeneous tables sharing a common set of columns,\n * returning rows that exist in both tables (if they have differences), and rows from\n * `leftTableId` that don't exist in `rightTableId`.\n *\n * In practice, this is currently only used for generating diffs and add/update actions\n * for incremental imports into existing tables. Specifically, `leftTableId` is the\n * source table, and `rightTableId` is the destination table.\n *\n * Columns from the query result are prefixed with the table id and a '.' separator.\n *\n * NOTE: Intended for internal use from trusted parts of Grist only.\n *\n * @param {string} leftTableId Name of the left table in the comparison.\n * @param {string} rightTableId Name of the right table in the comparison.\n * @param {Map<string, string[]>} selectColumns Map of left table column ids to their matching equivalent(s)\n * from the right table. A single left column can be compared against 2 or more right columns, so the\n * values of `selectColumns` are arrays. All of these columns will be included in the result, aliased by\n * table id.\n * @param {Map<string, string>} joinColumns Map of right table column ids to their matching equivalent\n * from the left table. These columns are used to join `leftTableId` to `rightTableId`.\n * @returns {ExpandedQuery} The constructed query.\n */\nexport function buildComparisonQuery(leftTableId: string, rightTableId: string, selectColumns: Map<string, string[]>,\n  joinColumns: Map<string, string>): ExpandedQuery {\n  const query: ExpandedQuery = { tableId: leftTableId, filters: {} };\n\n  // Start accumulating the JOINS, SELECTS and WHERES needed for the query.\n  const joins: string[] = [];\n  const selects: string[] = [];\n  const wheres: string[] = [];\n\n  // Include the 'id' column from both tables.\n  selects.push(\n    `${quoteIdent(leftTableId)}.id AS ${quoteIdent(leftTableId + \".id\")}`,\n    `${quoteIdent(rightTableId)}.id AS ${quoteIdent(rightTableId + \".id\")}`,\n  );\n\n  // Select columns from both tables, using the table id as a prefix for each column name.\n  selectColumns.forEach((rightTableColumns, leftTableColumn) => {\n    const leftColumnAlias = `${leftTableId}.${leftTableColumn}`;\n    selects.push(`${quoteIdent(leftTableId)}.${quoteIdent(leftTableColumn)} AS ${quoteIdent(leftColumnAlias)}`);\n\n    rightTableColumns.forEach((colId) => {\n      const rightColumnAlias = `${rightTableId}.${colId}`;\n      selects.push(`${quoteIdent(rightTableId)}.${quoteIdent(colId)} AS ${quoteIdent(rightColumnAlias)}`,\n      );\n    });\n  });\n\n  /**\n   * Performance can suffer when large (right) tables have many duplicates for their join columns.\n   * Specifically, the number of rows returned by the query can be unreasonably large if each\n   * row from the left table is joined against up to N rows from the right table.\n   *\n   * To work around this, we de-duplicate the right table before joining, returning the first row id\n   * we find for a given group of join column values. In practice, this means that each row from\n   * the left table can only be matched with at most 1 equivalent row from the right table.\n   */\n  const dedupedRightTableQuery =\n    `SELECT MIN(id) AS id, ${[...joinColumns.keys()].map(v => quoteIdent(v)).join(\", \")} ` +\n    `FROM ${quoteIdent(rightTableId)} ` +\n    `GROUP BY ${[...joinColumns.keys()].map(v => quoteIdent(v)).join(\", \")}`;\n  const dedupedRightTableAlias = quoteIdent(\"deduped_\" + rightTableId);\n\n  // Join the left table to the (de-duplicated) right table, and include unmatched left rows.\n  const joinConditions: string[] = [];\n  joinColumns.forEach((leftTableColumn, rightTableColumn) => {\n    const leftExpression = `${quoteIdent(leftTableId)}.${quoteIdent(leftTableColumn)}`;\n    const rightExpression = `${dedupedRightTableAlias}.${quoteIdent(rightTableColumn)}`;\n    joinConditions.push(`${leftExpression} = ${rightExpression}`);\n  });\n  joins.push(\n    `LEFT JOIN (${dedupedRightTableQuery}) AS ${dedupedRightTableAlias} ` +\n    `ON ${joinConditions.join(\" AND \")}`);\n\n  // Finally, join the de-duplicated right table to the original right table to get all its columns.\n  joins.push(\n    `LEFT JOIN ${quoteIdent(rightTableId)} ` +\n    `ON ${dedupedRightTableAlias}.id = ${quoteIdent(rightTableId)}.id`);\n\n  // Filter out matching rows where all non-join columns from both tables are identical.\n  const whereConditions: string[] = [];\n  for (const [leftTableColumnId, rightTableColumnIds] of selectColumns.entries()) {\n    const leftColumnAlias = quoteIdent(`${leftTableId}.${leftTableColumnId}`);\n\n    for (const rightTableColId of rightTableColumnIds) {\n      // If this left/right column id pair was already used for joining, skip it.\n      if (joinColumns.get(rightTableColId) === leftTableColumnId) { continue; }\n\n      // Only include rows that have differences in column values.\n      const rightColumnAlias = quoteIdent(`${rightTableId}.${rightTableColId}`);\n      whereConditions.push(`${leftColumnAlias} IS NOT ${rightColumnAlias}`);\n    }\n  }\n  if (whereConditions.length > 0) {\n    wheres.push(combineExpr(\"OR\", whereConditions));\n  }\n\n  // Copy decisions to the query object, and return.\n  query.joins = joins;\n  query.selects = selects;\n\n  if (wheres) {\n    query.where = {\n      clause: combineExpr(\"AND\", [query.where?.clause, ...wheres]),\n      params: query.where?.params ?? [],\n    };\n  }\n  return query;\n}\n\nexport function combineExpr(operator: string, parts: (string | undefined)[]): string {\n  return parts.filter(p => Boolean(p)).map(p => `(${p})`).join(` ${operator} `);\n}\n"
  },
  {
    "path": "app/server/lib/Export.ts",
    "content": "import { FilterColValues } from \"app/common/ActiveDocAPI\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { buildColFilter } from \"app/common/ColumnFilterFunc\";\nimport { TableDataAction, TableDataActionSet } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport * as gutil from \"app/common/gutil\";\nimport { nativeCompare } from \"app/common/gutil\";\nimport { isTableCensored } from \"app/common/isHiddenTable\";\nimport { buildRowFilter, getLinkingFilterFunc } from \"app/common/RowFilterFunc\";\nimport { schema, SchemaTypes } from \"app/common/schema\";\nimport { SortFunc } from \"app/common/SortFunc\";\nimport { Sort } from \"app/common/SortSpec\";\nimport { MetaRowRecord, MetaTableData } from \"app/common/TableData\";\nimport { BaseFormatter, createFullFormatterFromDocData } from \"app/common/ValueFormatter\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { docSessionFromRequest } from \"app/server/lib/DocSession\";\nimport { optIntegerParam, optJsonParam, optStringParam } from \"app/server/lib/requestUtils\";\nimport { ServerColumnGetters } from \"app/server/lib/ServerColumnGetters\";\n\nimport * as express from \"express\";\nimport * as _ from \"underscore\";\n\n// Helper type for Cell Accessor\ntype Access = (row: number) => any;\n\n// Interface to document data used from an exporter worker thread (workerExporter.ts). Note that\n// parameters and returned values are plain data that can be passed over a MessagePort.\nexport interface ActiveDocSource {\n  getDocName(): Promise<string>;\n  fetchMetaTables(): Promise<TableDataActionSet>;\n  fetchTable(tableId: string): Promise<TableDataAction>;\n}\n\n// Implementation of ActiveDocSource using an ActiveDoc directly.\nexport class ActiveDocSourceDirect implements ActiveDocSource {\n  private _req: RequestWithLogin;\n\n  constructor(private _activeDoc: ActiveDoc, req: express.Request) {\n    this._req = req as RequestWithLogin;\n  }\n\n  public async getDocName() { return this._activeDoc.docName; }\n  public fetchMetaTables() { return this._activeDoc.fetchMetaTables(docSessionFromRequest(this._req)); }\n  public async fetchTable(tableId: string) {\n    const { tableData } = await this._activeDoc.fetchTable(docSessionFromRequest(this._req), tableId, true);\n    return tableData;\n  }\n}\n\n// Helper interface with information about the column\nexport interface ExportColumn {\n  id: number;\n  colId: string;\n  label: string;\n  type: string;\n  formatter: BaseFormatter;\n  parentPos: number;\n  description: string;\n}\n\n/**\n * Bare data that is exported - used to convert to various formats.\n */\nexport interface ExportData {\n  /**\n   * Table name or table id.\n   */\n  tableName: string;\n  /**\n   * Document name.\n   */\n  docName: string;\n  /**\n   * Row ids (filtered and sorted).\n   */\n  rowIds: number[];\n  /**\n   * Accessor for value in a column.\n   */\n  access: Access[];\n  /**\n   * Columns information (primary used for formatting).\n   */\n  columns: ExportColumn[];\n  /**\n   * Document settings\n   */\n  docSettings: DocumentSettings;\n}\n\nexport type ExportHeader = \"colId\" | \"label\";\n\n/**\n * Export parameters that identifies a section, filters, sort order.\n */\nexport interface ExportParameters {\n  tableId?: string;\n  viewSectionId?: number;\n  sortOrder?: number[];\n  filters?: Filter[];\n  linkingFilter?: FilterColValues;\n  header?: ExportHeader;\n}\n\n/**\n * Options parameters for CSV and XLSX export functions.\n */\nexport interface DownloadOptions extends ExportParameters {\n  filename: string;\n}\n\n/**\n * Gets export parameters from a request.\n */\nexport function parseExportParameters(req: express.Request): ExportParameters {\n  const tableId = optStringParam(req.query.tableId, \"tableId\");\n  const viewSectionId = optIntegerParam(req.query.viewSection, \"viewSection\");\n  const sortOrder = optJsonParam(req.query.activeSortSpec, []) as number[];\n  const filters: Filter[] = optJsonParam(req.query.filters, []);\n  const linkingFilter: FilterColValues = optJsonParam(req.query.linkingFilter, null);\n  const header = optStringParam(\n    req.query.header, \"header\", { allowed: [\"label\", \"colId\"] },\n  ) as ExportHeader | undefined;\n\n  return {\n    tableId,\n    viewSectionId,\n    sortOrder,\n    filters,\n    linkingFilter,\n    header,\n  };\n}\n\n// Helper for getting filtered metadata tables.\nasync function getMetaTables(activeDocSource: ActiveDocSource): Promise<TableDataActionSet> {\n  return safe(await activeDocSource.fetchMetaTables(), \"No metadata available in active document\");\n}\n\n// Makes assertion that value does exist or throws an error\nfunction safe<T>(value: T, msg: string): NonNullable<T> {\n  if (!value) { throw new ApiError(msg, 404); }\n  return value;\n}\n\n// Helper for getting table from filtered metadata.\nfunction safeTable<TableId extends keyof SchemaTypes>(metaTables: TableDataActionSet, tableId: TableId) {\n  const table = safe(metaTables[tableId], `No table '${tableId}' in document`);\n  const colTypes = safe(schema[tableId], `No table '${tableId}' in document schema`);\n  return new MetaTableData<TableId>(tableId, table, colTypes);\n}\n\n// Helper for getting record safely: it throws if the record is missing.\nfunction safeRecord<TableId extends keyof SchemaTypes>(table: MetaTableData<TableId>, id: number) {\n  return safe(table.getRecord(id), `No record ${id} in table ${table.tableId}`);\n}\n\n// Check that tableRef points to an uncensored table, or throw otherwise.\nfunction checkTableAccess(tables: MetaTableData<\"_grist_Tables\">, tableRef: number): void {\n  if (isTableCensored(tables, tableRef)) {\n    throw new ApiError(`Cannot find or access table`, 404);\n  }\n}\n\n/**\n * Builds export for all raw tables that are in doc.\n */\nexport async function doExportDoc(\n  activeDocSource: ActiveDocSource,\n  handleTable: (data: ExportData) => Promise<void>,\n): Promise<void> {\n  const metaTables = await getMetaTables(activeDocSource);\n  const tables = safeTable(metaTables, \"_grist_Tables\");\n  // select raw tables\n  const tableRefs = tables.filterRowIds({ summarySourceTable: 0 });\n  for (const tableRef of tableRefs) {\n    if (!isTableCensored(tables, tableRef)) {    // Omit censored tables\n      const data = await doExportTable(activeDocSource, { metaTables, tableRef });\n      await handleTable(data);\n    }\n  }\n}\n\n/**\n * Builds export data for section that can be used to produce files in various formats (csv, xlsx).\n */\nexport async function exportTable(\n  activeDoc: ActiveDoc,\n  tableRef: number,\n  req: express.Request,\n  { metaTables}: { metaTables?: TableDataActionSet } = {},\n): Promise<ExportData> {\n  return doExportTable(new ActiveDocSourceDirect(activeDoc, req), { metaTables, tableRef });\n}\n\nexport async function doExportTable(\n  activeDocSource: ActiveDocSource,\n  options: { metaTables?: TableDataActionSet, tableRef?: number, tableId?: string },\n) {\n  const metaTables = options.metaTables || await getMetaTables(activeDocSource);\n  const docData = new DocData((tableId) => { throw new Error(\"Unexpected DocData fetch\"); }, metaTables);\n  const tables = safeTable(metaTables, \"_grist_Tables\");\n  const metaColumns = safeTable(metaTables, \"_grist_Tables_column\");\n\n  let tableRef: number;\n  if (options.tableRef) {\n    tableRef = options.tableRef;\n  } else {\n    if (!options.tableId) { throw new Error(\"doExportTable: tableRef or tableId must be given\"); }\n    tableRef = tables.findRow(\"tableId\", options.tableId);\n    if (tableRef === 0) {\n      throw new ApiError(`Table ${options.tableId} not found.`, 404);\n    }\n  }\n\n  checkTableAccess(tables, tableRef);\n  const table = safeRecord(tables, tableRef);\n\n  // Select only columns that belong to this table.\n  const tableColumns = metaColumns.filterRecords({ parentId: tableRef })\n    // sort by parentPos and id, which should be the same order as in raw data\n    .sort((c1, c2) => nativeCompare(c1.parentPos, c2.parentPos) || nativeCompare(c1.id, c2.id));\n\n  // Produce a column description matching what user will see / expect to export\n  const columns: ExportColumn[] = tableColumns\n    .filter(tc => !gristTypes.isHiddenCol(tc.colId))    // Exclude helpers\n    .map<ExportColumn>((tc) => {\n    // for reference columns, return display column, and copy settings from visible column\n      const displayCol = metaColumns.getRecord(tc.displayCol) || tc;\n      return {\n        id: displayCol.id,\n        colId: displayCol.colId,\n        label: tc.label,\n        type: tc.type,\n        formatter: createFullFormatterFromDocData(docData, tc.id),\n        parentPos: tc.parentPos,\n        description: tc.description,\n      };\n    });\n\n  // fetch actual data\n  const tableData = await activeDocSource.fetchTable(table.tableId);\n  const rowIds = tableData[2];\n  const dataByColId = tableData[3];\n  // sort rows\n  const getters = new ServerColumnGetters(rowIds, dataByColId, columns);\n  // create cell accessors\n  const access = columns.map(col => getters.getColGetter(col.id)!);\n\n  let tableName = table.tableId;\n  // since tables ids are not very friendly, borrow name from a primary view\n  if (table.primaryViewId) {\n    const viewId = table.primaryViewId;\n    const views = safeTable(metaTables, \"_grist_Views\");\n    const view = safeRecord(views, viewId);\n    tableName = view.name;\n  }\n\n  const docInfo = safeRecord(safeTable(metaTables, \"_grist_DocInfo\"), 1);\n  const docSettings = gutil.safeJsonParse(docInfo.documentSettings, {});\n  const exportData: ExportData = {\n    tableName,\n    docName: await activeDocSource.getDocName(),\n    rowIds,\n    access,\n    columns,\n    docSettings,\n  };\n  return exportData;\n}\n\n/**\n * Builds export data for section that can be used to produce files in various formats (csv, xlsx).\n */\nexport async function exportSection(\n  activeDoc: ActiveDoc,\n  viewSectionId: number,\n  sortSpec: Sort.SortSpec | null,\n  filters: Filter[] | null,\n  linkingFilter: FilterColValues | null = null,\n  req: express.Request,\n  { metaTables}: { metaTables?: TableDataActionSet } = {},\n): Promise<ExportData> {\n  return doExportSection(new ActiveDocSourceDirect(activeDoc, req), viewSectionId, sortSpec,\n    filters, linkingFilter, { metaTables });\n}\n\nexport async function doExportSection(\n  activeDocSource: ActiveDocSource,\n  viewSectionId: number,\n  sortSpec: Sort.SortSpec | null,\n  filters: Filter[] | null,\n  linkingFilter: FilterColValues | null = null,\n  { metaTables}: { metaTables?: TableDataActionSet } = {},\n): Promise<ExportData> {\n  metaTables = metaTables || await getMetaTables(activeDocSource);\n  const docData = new DocData((tableId) => { throw new Error(\"Unexpected DocData fetch\"); }, metaTables);\n  const viewSections = safeTable(metaTables, \"_grist_Views_section\");\n  const viewSection = safeRecord(viewSections, viewSectionId);\n  safe(viewSection.tableRef, `Cannot find or access table`);\n  const tables = safeTable(metaTables, \"_grist_Tables\");\n  checkTableAccess(tables, viewSection.tableRef);\n  const table = safeRecord(tables, viewSection.tableRef);\n  const metaColumns = safeTable(metaTables, \"_grist_Tables_column\");\n  const columns = metaColumns.filterRecords({ parentId: table.id });\n  const viewSectionFields = safeTable(metaTables, \"_grist_Views_section_field\");\n  const fields = viewSectionFields.filterRecords({ parentId: viewSection.id });\n  const savedFilters = safeTable(metaTables, \"_grist_Filters\")\n    .filterRecords({ viewSectionRef: viewSection.id });\n\n  const fieldsByColRef = _.indexBy(fields, \"colRef\");\n  const savedFiltersByColRef = _.indexBy(savedFilters, \"colRef\");\n  const unsavedFiltersByColRef = _.indexBy(filters ?? [], \"colRef\");\n\n  // Produce a column description matching what user will see / expect to export\n  const viewify = (col: GristTablesColumn, field?: GristViewsSectionField): ExportColumn => {\n    const displayCol = metaColumns.getRecord(field?.displayCol || col.displayCol) || col;\n    return {\n      id: displayCol.id,\n      colId: displayCol.colId,\n      label: col.label,\n      type: col.type,\n      formatter: createFullFormatterFromDocData(docData, col.id, field?.id),\n      parentPos: col.parentPos,\n      description: col.description,\n    };\n  };\n  const buildFilters = (col: GristTablesColumn, field?: GristViewsSectionField) => {\n    const filterString = unsavedFiltersByColRef[col.id]?.filter || savedFiltersByColRef[col.id]?.filter;\n    const filterFunc = buildColFilter(filterString, col.type);\n    return {\n      filterFunc,\n      id: col.id,\n      colId: col.colId,\n      type: col.type,\n    };\n  };\n  const columnsForFilters = columns\n    .filter(column => !gristTypes.isHiddenCol(column.colId))\n    .map(column => buildFilters(column, fieldsByColRef[column.id]));\n  const viewColumns: ExportColumn[] = _.sortBy(fields, \"parentPos\")\n    .map(field => viewify(metaColumns.getRecord(field.colRef)!, field));\n\n  // The columns named in sort order need to now become display columns\n  sortSpec = sortSpec || gutil.safeJsonParse(viewSection.sortColRefs, []);\n  sortSpec = sortSpec!.map((colSpec) => {\n    const colRef = Sort.getColRef(colSpec);\n    if (typeof colRef !== \"number\") {\n      // colRef might be string for virtual tables, but we don't support them here.\n      throw new Error(`Unsupported colRef type: ${typeof colRef}`);\n    }\n    const col = metaColumns.getRecord(colRef);\n    if (!col) {\n      return 0;\n    }\n    const effectiveColRef = viewify(col, fieldsByColRef[colRef]).id;\n    return Sort.swapColRef(colSpec, effectiveColRef);\n  });\n\n  // fetch actual data\n  const tableData = await activeDocSource.fetchTable(table.tableId);\n  let rowIds = tableData[2];\n  const dataByColId = tableData[3];\n  // sort rows\n  const getters = new ServerColumnGetters(rowIds, dataByColId, columns);\n  const sorter = new SortFunc(getters);\n  sorter.updateSpec(sortSpec);\n  rowIds.sort((a, b) => sorter.compare(a, b));\n  // create cell accessors\n  const tableAccess = columnsForFilters.map(col => getters.getColGetter(col.id)!);\n  // create row filter based on all columns filter\n  const rowFilter = columnsForFilters\n    .map((col, c) => buildRowFilter(tableAccess[c], col.filterFunc))\n    .reduce((prevFilter, curFilter) => id => prevFilter(id) && curFilter(id), () => true);\n  // filter rows numbers\n  rowIds = rowIds.filter(rowFilter);\n\n  if (linkingFilter) {\n    rowIds = rowIds.filter(getLinkingFilterFunc(getters, linkingFilter));\n  }\n\n  const docInfo = safeRecord(safeTable(metaTables, \"_grist_DocInfo\"), 1);\n  const docSettings = gutil.safeJsonParse(docInfo.documentSettings, {});\n\n  const exportData: ExportData = {\n    rowIds,\n    docSettings,\n    tableName: table.tableId,\n    docName: await activeDocSource.getDocName(),\n    access: viewColumns.map(col => getters.getColGetter(col.id)!),\n    columns: viewColumns,\n  };\n  return exportData;\n}\n\ntype GristViewsSectionField = MetaRowRecord<\"_grist_Views_section_field\">;\ntype GristTablesColumn = MetaRowRecord<\"_grist_Tables_column\">;\n\n// Type for filters passed from the client\nexport interface Filter { colRef: number, filter: string }\n"
  },
  {
    "path": "app/server/lib/ExportDSV.ts",
    "content": "import { FilterColValues } from \"app/common/ActiveDocAPI\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { DownloadOptions, ExportData, ExportHeader, exportSection, exportTable, Filter } from \"app/server/lib/Export\";\nimport log from \"app/server/lib/log\";\n\nimport { promisify } from \"util\";\n\nimport contentDisposition from \"content-disposition\";\nimport { stringify } from \"csv\";\nimport * as express from \"express\";\n\nconst stringifyAsync = promisify(stringify);\n\nexport interface DownloadDsvOptions extends DownloadOptions {\n  delimiter: Delimiter;\n}\n\ntype Delimiter = \",\" | \"\\t\" | \"💩\";\n\n/**\n * Converts `activeDoc` to delimiter-separated values (e.g. CSV) and sends\n * the converted data through `res`.\n */\nexport async function downloadDSV(\n  activeDoc: ActiveDoc,\n  req: express.Request,\n  res: express.Response,\n  options: DownloadDsvOptions,\n) {\n  const { filename, tableId, viewSectionId, filters, sortOrder, linkingFilter, delimiter, header } = options;\n  const extension = getDSVFileExtension(delimiter);\n  log.info(`Generating ${extension} file...`);\n  let data;\n  if (viewSectionId) {\n    data = await makeDSVFromViewSection({\n      activeDoc, viewSectionId, sortOrder: sortOrder || null, filters: filters || null,\n      linkingFilter: linkingFilter || null, header, delimiter, req,\n    });\n  } else {\n    if (!tableId) {\n      throw new ApiError(\"tableId parameter is required\", 400);\n    }\n    data = await makeDSVFromTable({ activeDoc, tableId, header, delimiter, req });\n  }\n  res.set(\"Content-Type\", getDSVMimeType(delimiter));\n  res.setHeader(\"Content-Disposition\", contentDisposition(filename + extension));\n  res.send(data);\n}\n\n/**\n * Returns a DSV stream of a view section that can be transformed or parsed.\n *\n * See https://github.com/wdavidw/node-csv for API details.\n *\n * @param {Object} options - options for the export.\n * @param {Object} options.activeDoc - the activeDoc that the table being converted belongs to.\n * @param {Integer} options.viewSectionId - id of the viewsection to export.\n * @param {Integer[]} options.activeSortOrder (optional) - overriding sort order.\n * @param {Filter[]} options.filters (optional) - filters defined from ui.\n * @param {FilterColValues} options.linkingFilter (optional) - linking filter defined from ui.\n * @param {Delimiter} options.delimiter - delimiter to separate fields with\n * @param {string} options.header (optional) - which field of the column to use as header\n * @param {express.Request} options.req - the request object.\n *\n * @return {Promise<string>} Promise for the resulting DSV.\n */\nexport async function makeDSVFromViewSection({\n  activeDoc,\n  viewSectionId,\n  sortOrder = null,\n  filters = null,\n  linkingFilter = null,\n  delimiter,\n  header,\n  req,\n}: {\n  activeDoc: ActiveDoc,\n  viewSectionId: number,\n  sortOrder: number[] | null,\n  filters: Filter[] | null,\n  linkingFilter: FilterColValues | null,\n  header?: ExportHeader,\n  delimiter: Delimiter,\n  req: express.Request\n}) {\n  const data = await exportSection(activeDoc, viewSectionId, sortOrder, filters, linkingFilter, req);\n  const file = convertToDsv(data, { header, delimiter });\n  return file;\n}\n\n/**\n * Returns a DSV stream of a table that can be transformed or parsed.\n *\n * @param {Object} options - options for the export.\n * @param {Object} options.activeDoc - the activeDoc that the table being converted belongs to.\n * @param {Integer} options.tableId - id of the table to export.\n * @param {Delimiter} options.delimiter  - delimiter to separate fields with\n * @param {string} options.header (optional) - which field of the column to use as header\n * @param {express.Request} options.req - the request object.\n *\n * @return {Promise<string>} Promise for the resulting DSV.\n */\nexport async function makeDSVFromTable({ activeDoc, tableId, delimiter, header, req }: {\n  activeDoc: ActiveDoc,\n  tableId: string,\n  delimiter: Delimiter,\n  header?: ExportHeader,\n  req: express.Request\n}) {\n  if (!activeDoc.docData) {\n    throw new Error(\"No docData in active document\");\n  }\n\n  // Look up the table to make a CSV from.\n  const tables = activeDoc.docData.getMetaTable(\"_grist_Tables\");\n  const tableRef = tables.findRow(\"tableId\", tableId);\n\n  if (tableRef === 0) {\n    throw new ApiError(`Table ${tableId} not found.`, 404);\n  }\n\n  const data = await exportTable(activeDoc, tableRef, req);\n  const file = convertToDsv(data, { header, delimiter });\n  return file;\n}\n\ninterface ConvertToDsvOptions {\n  delimiter: Delimiter;\n  header?: ExportHeader;\n}\n\nfunction convertToDsv(data: ExportData, options: ConvertToDsvOptions) {\n  const { rowIds, access, columns: viewColumns } = data;\n  const { delimiter, header } = options;\n  // create formatters for columns\n  const formatters = viewColumns.map(col => col.formatter);\n  // Arrange the data into a row-indexed matrix, starting with column headers.\n  const colPropertyAsHeader = header ?? \"label\";\n  const csvMatrix = [viewColumns.map(col => col[colPropertyAsHeader])];\n  // populate all the rows with values as strings\n  rowIds.forEach((row) => {\n    csvMatrix.push(access.map((getter, c) => formatters[c].formatAny(getter(row))));\n  });\n  return stringifyAsync(csvMatrix, { delimiter });\n}\n\ntype DSVFileExtension = \".csv\" | \".tsv\" | \".dsv\";\n\nfunction getDSVFileExtension(delimiter: Delimiter): DSVFileExtension {\n  switch (delimiter) {\n    case \",\": {\n      return \".csv\";\n    }\n    case \"\\t\": {\n      return \".tsv\";\n    }\n    case \"💩\": {\n      return \".dsv\";\n    }\n  }\n}\n\ntype DSVMimeType =\n  | \"text/csv\" |\n  // Reference: https://www.iana.org/assignments/media-types/text/tab-separated-values\n  \"text/tab-separated-values\" |\n  // Note: not a registered MIME type, hence the \"x-\" prefix.\n  \"text/x-doo-separated-values\";\n\nfunction getDSVMimeType(delimiter: Delimiter): DSVMimeType {\n  switch (delimiter) {\n    case \",\": {\n      return \"text/csv\";\n    }\n    case \"\\t\": {\n      return \"text/tab-separated-values\";\n    }\n    case \"💩\": {\n      return \"text/x-doo-separated-values\";\n    }\n  }\n}\n"
  },
  {
    "path": "app/server/lib/ExportTableSchema.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { DownloadOptions, ExportColumn, exportTable } from \"app/server/lib/Export\";\n\nimport * as express from \"express\";\n\ninterface FrictionlessFields {\n  name: string;\n  type: string;\n  description?: string;\n  format?: string;\n  bareNumber?: boolean;\n  groupChar?: string;\n  decimalChar?: string;\n  gristFormat?: string;\n  constraints?: { enum: any };\n  trueValue?: string[];\n  falseValue?: string[];\n}\n\ninterface FrictionlessFormat {\n  name: string;\n  title: string;\n  schema: {\n    fields: FrictionlessFields[]\n  }\n}\n\n/**\n * Return a table schema for frictionless interoperability\n *\n * See https://specs.frictionlessdata.io/table-schema/#page-frontmatter-title for spec\n * @param {Object} activeDoc - the activeDoc that the table being converted belongs to.\n * @param {Object} options - options to get the table ID\n * @return {Promise<FrictionlessFormat>} Promise for the resulting schema.\n */\nexport async function collectTableSchemaInFrictionlessFormat(\n  activeDoc: ActiveDoc,\n  req: express.Request,\n  options: DownloadOptions,\n): Promise<FrictionlessFormat> {\n  const { tableId, header } = options;\n  if (!activeDoc.docData) {\n    throw new Error(\"No docData in active document\");\n  }\n  if (!tableId) {\n    throw new ApiError(\"tableId parameter is required\", 400);\n  }\n\n  // Look up the table to make a CSV from.\n  const settings = activeDoc.docData.docSettings();\n  const tables = activeDoc.docData.getMetaTable(\"_grist_Tables\");\n  const tableRef = tables.findRow(\"tableId\", tableId);\n\n  if (tableRef === 0) {\n    throw new ApiError(`Table ${tableId} not found.`, 404);\n  }\n\n  const { tableName, columns } = await exportTable(activeDoc, tableRef, req);\n  return {\n    name: tableId.toLowerCase().replace(/_/g, \"-\"),\n    title: tableName,\n    schema: {\n      fields: columns.map(col => ({\n        name: col[header || \"label\"],\n        ...(col.description ? { description: col.description } : {}),\n        ...buildTypeField(col, settings.locale),\n      })),\n    },\n  };\n}\n\nfunction buildTypeField(col: ExportColumn, locale: string): { type: string } & Partial<FrictionlessFields> {\n  const type = col.type.split(\":\", 1)[0];\n  const widgetOptions = col.formatter.widgetOpts;\n  switch (type) {\n    case \"Text\":\n      return {\n        type: \"string\",\n        format: widgetOptions.widget === \"HyperLink\" ? \"uri\" : \"default\",\n      };\n    case \"Numeric\":\n      return {\n        type: \"number\",\n        bareNumber: widgetOptions?.numMode === \"decimal\",\n        ...getNumberSeparators(locale),\n      };\n    case \"Integer\":\n      return {\n        type: \"integer\",\n        bareNumber: widgetOptions?.numMode === \"decimal\",\n        groupChar: getNumberSeparators(locale).groupChar,\n      };\n    case \"Date\":\n      return {\n        type: \"date\",\n        format: \"any\",\n        gristFormat: widgetOptions?.dateFormat || \"YYYY-MM-DD\",\n      };\n    case \"DateTime\":\n      return {\n        type: \"datetime\",\n        format: \"any\",\n        gristFormat: `${widgetOptions?.dateFormat} ${widgetOptions?.timeFormat}`,\n      };\n    case \"Bool\":\n      return {\n        type: \"boolean\",\n        trueValue: [\"TRUE\"],\n        falseValue: [\"FALSE\"],\n      };\n    case \"Choice\":\n      return {\n        type: \"string\",\n        constraints: { enum: widgetOptions?.choices },\n      };\n    case \"ChoiceList\":\n      return {\n        type: \"array\",\n        constraints: { enum: widgetOptions?.choices },\n      };\n    case \"Reference\":\n      return { type: \"string\" };\n    case \"ReferenceList\":\n      return { type: \"array\" };\n    default:\n      return { type: \"string\" };\n  }\n}\n\nfunction getNumberSeparators(locale: string) {\n  const numberWithGroupAndDecimalSeparator = 1000.1;\n  const parts = Intl.NumberFormat(locale).formatToParts(numberWithGroupAndDecimalSeparator);\n  return {\n    groupChar: parts.find(obj => obj.type === \"group\")?.value,\n    decimalChar: parts.find(obj => obj.type === \"decimal\")?.value,\n  };\n}\n"
  },
  {
    "path": "app/server/lib/ExportXLSX.ts",
    "content": "/**\n * Overview of Excel exports, which now use worker-threads.\n *\n * 1. The flow starts with the streamXLSX() method called in the main thread.\n * 2. It uses the 'piscina' library to call a makeXLSX* method in a worker thread, registered in\n *    workerExporter.ts, to export full doc, a table, or a section.\n * 3. Each of those methods calls a doMakeXLSX* method defined in that file. I.e. downloadXLSX()\n *    is called in the main thread, but makeXLSX() and doMakeXLSX() are called in the worker thread.\n * 4. doMakeXLSX* methods get data using an ActiveDocSource, which uses Rpc (from grain-rpc\n *    module) to request data over a message port from the ActiveDoc in the main thread.\n * 5. The resulting stream of Excel data is streamed back to the main thread using Rpc too.\n */\n\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { ActiveDocSource, ActiveDocSourceDirect, ExportParameters } from \"app/server/lib/Export\";\nimport log from \"app/server/lib/log\";\nimport { addAbortHandler } from \"app/server/lib/requestUtils\";\n\nimport { Writable } from \"stream\";\nimport { MessageChannel } from \"worker_threads\";\n\nimport * as express from \"express\";\nimport { Rpc } from \"grain-rpc\";\nimport { AbortController } from \"node-abort-controller\";\nimport Piscina from \"piscina\";\n\n// If this file is imported from within a worker thread, we'll create more thread pools from each\n// thread, with a potential for an infinite loop of doom. Better to catch that early.\nif (Piscina.isWorkerThread) {\n  throw new Error(\"ExportXLSX must not be imported from within a worker thread\");\n}\n\n// Configure the thread-pool to use for exporting XLSX files.\nconst exportPool = new Piscina({\n  filename: __dirname + \"/workerExporter.js\",\n  minThreads: 0,\n  maxThreads: 4,\n  maxQueue: 100,          // Fail if this many tasks are already waiting for a thread.\n  idleTimeout: 10_000,    // Drop unused threads after 10s of inactivity.\n});\n\n/**\n * Converts `activeDoc` to XLSX and sends to the given outputStream.\n */\nexport async function streamXLSX(activeDoc: ActiveDoc, req: express.Request,\n  outputStream: Writable, options: ExportParameters) {\n  log.debug(`Generating .xlsx file`);\n  const testDates = (req.hostname === \"localhost\");\n\n  const { port1, port2 } = new MessageChannel();\n  try {\n    const rpc = new Rpc({\n      sendMessage: async m => port1.postMessage(m),\n      logger: { info: (m) => {}, warn: m => log.warn(m) },\n    });\n    rpc.registerImpl<ActiveDocSource>(\"activeDocSource\", new ActiveDocSourceDirect(activeDoc, req));\n    rpc.on(\"message\", (chunk) => { outputStream.write(chunk); });\n    port1.on(\"message\", m => rpc.receiveMessage(m));\n\n    // For request cancelling to work, remember that such requests are forwarded via DocApiForwarder.\n    const abortController = new AbortController();\n    const cancelWorker = () => abortController.abort();\n\n    // When the worker thread is done, it closes the port on its side, and we listen to that to\n    // end the original request (the incoming HTTP request, in case of a download).\n    port1.on(\"close\", () => {\n      outputStream.end();\n      req.off(\"close\", cancelWorker);\n    });\n\n    addAbortHandler(req, outputStream, cancelWorker);\n\n    const run = (method: string, ...args: any[]) => exportPool.run({ port: port2, testDates, args }, {\n      name: method,\n      signal: abortController.signal,\n      transferList: [port2],\n    });\n\n    // hanlding 3 cases : full XLSX export (full file), view xlsx export, table xlsx export\n    try {\n      await run(\"makeXLSXFromOptions\", options);\n      log.debug(\"XLSX file generated\");\n    } catch (e) {\n      // We fiddle with errors in workerExporter to preserve extra properties like 'status'. Make\n      // the result an instance of Error again here (though we won't know the exact class).\n      throw (e instanceof Error) ? e : Object.assign(new Error(e.message), e);\n    }\n  } finally {\n    port1.close();\n    port2.close();\n  }\n}\n"
  },
  {
    "path": "app/server/lib/ExternalStorage.ts",
    "content": "import { ObjMetadata, ObjSnapshot, ObjSnapshotWithMetadata } from \"app/common/DocSnapshot\";\nimport log from \"app/server/lib/log\";\nimport { createTmpDir } from \"app/server/lib/uploads\";\n\nimport stream from \"node:stream\";\nimport * as path from \"path\";\n\nimport { delay } from \"bluebird\";\nimport * as fse from \"fs-extra\";\n\n// A special token representing a deleted document, used in places where a\n// checksum is expected otherwise.\nexport const DELETED_TOKEN = \"*DELETED*\";\n\nexport interface FileMetadata {\n  size: number;\n  snapshotId: string;\n}\n\nexport interface StreamDownloadResult {\n  metadata: FileMetadata,\n  contentStream: stream.Readable,\n}\n\n/**\n * An external store for the content of files.  The store may be either consistent\n * or eventually consistent.  Specifically, the `exists`, `download`, and `versions`\n * methods may return somewhat stale data from time to time.\n *\n * The store should be versioned; that is, uploads to a `key` should be assigned\n * a `snapshotId`, and be accessible later with that `key`/`snapshotId` pair.\n * When data is accessed by `snapshotId`, results should be immediately consistent.\n */\nexport interface ExternalStorage {\n  // Check if content exists in the store for a given key.\n  exists(key: string, snapshotId?: string): Promise<boolean>;\n\n  // Get side information for content, if content exists in the store.\n  head(key: string, snapshotId?: string): Promise<ObjSnapshotWithMetadata | null>;\n\n  // Upload content from file to the given key.  Returns a snapshotId if store supports that.\n  upload(key: string, fname: string, metadata?: ObjMetadata): Promise<string | null | typeof Unchanged>;\n\n  // Download content from key to given file.  Can download a specific version of the key\n  // if store supports that (should throw a fatal exception if not).\n  // Returns snapshotId of version downloaded.\n  download(key: string, fname: string, snapshotId?: string): Promise<string>;\n\n  // Remove content for this key from the store, if it exists.  Can delete specific versions\n  // if specified.  If no version specified, all versions are removed.  If versions specified,\n  // newest should be given first.\n  // If the content specified is not present on the store, no error is thrown.\n  remove(key: string, snapshotIds?: string[]): Promise<void>;\n\n  // Removes all keys which start with the given prefix\n  removeAllWithPrefix?(prefix: string): Promise<void>;\n\n  // List content versions that exist for the given key.  More recent versions should\n  // come earlier in the result list.\n  versions(key: string): Promise<ObjSnapshot[]>;\n\n  // Render the given key as something url-like, for log messages (e.g. \"s3://bucket/path\")\n  url(key: string): string;\n\n  // Check if an exception thrown by a store method should be treated as fatal.\n  // Non-fatal exceptions are those that may result from eventual consistency, and\n  // where a retry could help -- specifically \"not found\" exceptions.\n  isFatalError(err: any): boolean;\n\n  // Close the storage object.\n  close(): Promise<void>;\n\n  uploadStream?(key: string,\n    inStream: stream.Readable,\n    size?: number,\n    metadata?: ObjMetadata\n  ): Promise<string | null | typeof Unchanged>;\n  downloadStream?(key: string, snapshotId?: string): Promise<StreamDownloadResult>;\n}\n\n/**\n * Convenience wrapper to transform keys for an external store.\n * E.g. this could convert \"<docId>\" to \"v1/<docId>.grist\"\n */\nexport class KeyMappedExternalStorage implements ExternalStorage {\n  public uploadStream: ExternalStorage[\"uploadStream\"];\n  public downloadStream: ExternalStorage[\"downloadStream\"];\n  public removeAllWithPrefix: ExternalStorage[\"removeAllWithPrefix\"];\n\n  constructor(private _ext: ExternalStorage,\n    private _map: (key: string) => string) {\n    if (_ext.uploadStream !== undefined) {\n      const extUploadStream = _ext.uploadStream;\n      this.uploadStream =\n        (key, inStream, size, metadata) => extUploadStream.call(_ext, this._map(key), inStream, size, metadata);\n    }\n    if (_ext.downloadStream !== undefined) {\n      const extDownloadStream = _ext.downloadStream;\n      this.downloadStream =\n        (key, snapshotId) => extDownloadStream.call(_ext, this._map(key), snapshotId);\n    }\n    if (_ext.removeAllWithPrefix !== undefined) {\n      const extRemoveAllWithPrefix = _ext.removeAllWithPrefix;\n      this.removeAllWithPrefix =\n        prefix => extRemoveAllWithPrefix.call(_ext, this._map(prefix));\n    }\n  }\n\n  public exists(key: string, snapshotId?: string): Promise<boolean> {\n    return this._ext.exists(this._map(key), snapshotId);\n  }\n\n  public head(key: string, snapshotId?: string) {\n    return this._ext.head(this._map(key), snapshotId);\n  }\n\n  public upload(key: string, fname: string, metadata?: ObjMetadata) {\n    return this._ext.upload(this._map(key), fname, metadata);\n  }\n\n  public download(key: string, fname: string, snapshotId?: string) {\n    return this._ext.download(this._map(key), fname, snapshotId);\n  }\n\n  public remove(key: string, snapshotIds?: string[]): Promise<void> {\n    return this._ext.remove(this._map(key), snapshotIds);\n  }\n\n  public versions(key: string) {\n    return this._ext.versions(this._map(key));\n  }\n\n  public url(key: string) {\n    return this._ext.url(this._map(key));\n  }\n\n  public isFatalError(err: any) {\n    return this._ext.isFatalError(err);\n  }\n\n  public async close() {\n    // nothing to do\n  }\n}\n\n/**\n * A wrapper for an external store that uses checksums and retries\n * to compensate for eventual consistency.  With this wrapper, the\n * store either returns consistent results or fails with an error.\n *\n * This wrapper works by tracking what is in the external store,\n * using content hashes and ids placed in consistent stores.  These\n * consistent stores are:\n *\n *   - sharedHash: a key/value store containing expected checksums\n *     of content in the external store.  In our setup, this is\n *     implemented using Redis.  Populated on upload and checked on\n *     download.\n *   - localHash: a key/value store containing checksums of uploaded\n *     content.  In our setup, this is implemented on the worker's\n *     disk.  This is used to skip unnecessary uploads.  Populated\n *     on download and checked on upload.\n *   - latestVersion: a key/value store containing snapshotIds of\n *     uploads.  In our setup, this is implemented in the worker's\n *     memory.  Only affects the consistency of the `versions` method.\n *     Populated on upload and checked on `versions` calls.\n *     TODO: move to Redis if consistency of `versions` during worker\n *     transitions becomes important.\n *\n * It is not important for all this side information to persist very\n * long, just long enough to give the store time to become\n * consistent.\n *\n * Keys presented to this class should be file-system safe.\n */\nexport class ChecksummedExternalStorage implements ExternalStorage {\n  private _closed: boolean = false;\n\n  constructor(public readonly label: string, private _ext: ExternalStorage, private _options: {\n    maxRetries: number,         // how many time to retry inconsistent downloads\n    initialDelayMs: number,     // how long to wait before retrying\n    localHash: PropStorage,     // key/value store for hashes of downloaded content (file {Id}.grist-hash-{meta/doc})\n    sharedHash: PropStorage,    // key/value store for hashes of external content (typically Redis)\n    latestVersion: PropStorage, // key/value store for snapshotIds of uploads (a JS map object)\n    computeFileHash: (fname: string) => Promise<string>,  // compute hash for file\n  }) {\n  }\n\n  public async exists(key: string, snapshotId?: string): Promise<boolean> {\n    return this._retryWithExistenceCheck(\"exists\", key, snapshotId,\n      this._ext.exists.bind(this._ext));\n  }\n\n  public async head(key: string, snapshotId?: string) {\n    return this._retryWithExistenceCheck(\"head\", key, snapshotId,\n      this._ext.head.bind(this._ext));\n  }\n\n  public async upload(key: string, fname: string, metadata?: ObjMetadata) {\n    try {\n      // This is the hash computed from the local version of the file\n      const checksum = await this._options.computeFileHash(fname);\n      // This is the hash stored locally in persist/grist/docs/{docId}.grist-hash-{meta/doc}\n      const prevChecksum = await this._options.localHash.load(key);\n      if (prevChecksum && prevChecksum === checksum && !metadata?.label) {\n        // nothing to do, checksums match\n        const snapshotId = await this._options.latestVersion.load(key);\n        log.info(\"ext %s upload: %s unchanged, not sending (checksum %s, version %s)\", this.label, key,\n          checksum, snapshotId);\n        return Unchanged;\n      }\n      const snapshotId = await this._ext.upload(key, fname, metadata);\n      log.info(\"ext %s upload: %s checksum %s version %s\", this.label, this._ext.url(key), checksum, snapshotId);\n      if (typeof snapshotId === \"string\") { await this._options.latestVersion.save(key, snapshotId); }\n      await this._options.localHash.save(key, checksum);\n      await this._options.sharedHash.save(key, checksum);\n      return snapshotId;\n    } catch (err) {\n      log.error(\"ext %s upload: %s failure to send, error %s\", this.label, key, err.message);\n      throw err;\n    }\n  }\n\n  public async remove(key: string, snapshotIds?: string[]) {\n    try {\n      // Removing most recent version by id is not something we should be doing, and\n      // if we want to do it it would need to be done carefully - so just forbid it.\n      if (snapshotIds?.includes(await this._options.latestVersion.load(key) || \"\")) {\n        throw new Error(\"cannot remove most recent version of a document by id\");\n      }\n      await this._ext.remove(key, snapshotIds);\n      log.info(\"ext %s remove: %s version %s\", this.label, this._ext.url(key), snapshotIds || \"ALL\");\n      if (!snapshotIds) {\n        await this._options.latestVersion.save(key, DELETED_TOKEN);\n        await this._options.sharedHash.save(key, DELETED_TOKEN);\n      } else {\n        for (const snapshotId of snapshotIds) {\n          // Removing snapshots breaks their partial immutability, so we mark them\n          // as deleted in redis so that we don't get stale info from S3 if we check\n          // for their existence.  Nothing currently depends on this in practice.\n          await this._options.sharedHash.save(this._keyWithSnapshot(key, snapshotId), DELETED_TOKEN);\n        }\n      }\n    } catch (err) {\n      log.error(\"ext %s delete: %s failure to remove, error %s\", this.label, key, err.message);\n      throw err;\n    }\n  }\n\n  public download(key: string, fname: string, snapshotId?: string) {\n    return this.downloadTo(key, key, fname, snapshotId);\n  }\n\n  /**\n   * We may want to download material from one key and henceforth treat it as another\n   * key (specifically for forking a document).  Since this class cross-references the\n   * key in the external store with other consistent stores, it needs to know we are\n   * doing that.  So we add a downloadTo variant that takes before and after keys.\n   */\n  public async downloadTo(fromKey: string, toKey: string, fname: string, snapshotId?: string) {\n    return this._retry(\"download\", async () => {\n      const { tmpDir, cleanupCallback } = await createTmpDir({});\n      const tmpPath = path.join(tmpDir, `${toKey}-tmp`);  // NOTE: assumes key is file-system safe.\n      try {\n        const downloadedSnapshotId = await this._ext.download(fromKey, tmpPath, snapshotId);\n        const checksum = await this._options.computeFileHash(tmpPath);\n        log.info(\"ext %s download: %s%s%s with checksum %s and version %s saved to %s\", this.label, fromKey,\n          snapshotId ? ` [VersionId ${snapshotId}]` : \"\",\n          fromKey !== toKey ? ` as ${toKey}` : \"\",\n          checksum, downloadedSnapshotId, tmpPath);\n\n        // Check for consistency if mutable data fetched.\n        if (!snapshotId) {\n          const expectedChecksum = await this._options.sharedHash.load(fromKey);\n          // Let null docMD5s pass.  Otherwise we get stuck if redis is cleared.\n          // Otherwise, make sure what we've got matches what we expect to get.\n          // AWS S3 was eventually consistent, but now has stronger guarantees:\n          // https://aws.amazon.com/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/\n          //\n          // Previous to this change, if you overwrote an object in it,\n          // and then read from it, you may have got an old version for some time.\n          // We are confident this should not be the case anymore, though this has to be studied carefully.\n          // If a snapshotId was specified, we can skip this check.\n          if (expectedChecksum && expectedChecksum !== checksum) {\n            log.warn(`ext ${this.label} download: data for ${fromKey} has wrong checksum:` +\n              ` ${checksum} (expected ${expectedChecksum})`);\n          }\n        }\n\n        // Rename the temporary file to its proper name. The destination should NOT\n        // exist in this case, and this should fail if it does.\n        await fse.move(tmpPath, fname, { overwrite: false });\n        log.info(\"ext %s download: %s renamed from %s to %s\", this.label, fromKey, tmpPath, fname);\n        if (fromKey === toKey) {\n          // Save last S3 snapshot id observed for this key.\n          await this._options.latestVersion.save(toKey, downloadedSnapshotId);\n          // Save last S3 hash observed for this key (so if we have a version with that hash\n          // locally we can skip pushing it back needlessly later).\n          await this._options.localHash.save(toKey, checksum);\n        }\n\n        return downloadedSnapshotId;\n      } catch (err) {\n        log.error(\"ext %s download: failed to fetch data (%s): %s\", this.label, fromKey, err.message);\n        throw err;\n      } finally {\n        await cleanupCallback();\n      }\n    });\n  }\n\n  public async versions(key: string) {\n    return this._retry(\"versions\", async () => {\n      const snapshotId = await this._options.latestVersion.load(key);\n      if (snapshotId === DELETED_TOKEN) { return []; }\n      const result = await this._ext.versions(key);\n      if (snapshotId && (result.length === 0 || result[0].snapshotId !== snapshotId)) {\n        // Result is not consistent yet.\n        return undefined;\n      }\n      return result;\n    });\n  }\n\n  public url(key: string): string {\n    return this._ext.url(key);\n  }\n\n  public isFatalError(err: any): boolean {\n    return this._ext.isFatalError(err);\n  }\n\n  public async close() {\n    this._closed = true;\n  }\n\n  /**\n   * Call an operation until it returns a value other than undefined.\n   *\n   * While the operation returns undefined, it will be retried for some period.\n   * This period is chosen to be long enough for S3 to become consistent.\n   *\n   * If the operation throws an error, and that error is not fatal (as determined\n   * by `isFatalError`, then it will also be retried.  Fatal errors are thrown\n   * immediately.\n   *\n   * Once the operation returns a result, we pass that along.  If it fails to\n   * return a result after all the allowed retries, a special exception is thrown.\n   */\n  private async _retry<T>(name: string, operation: () => Promise<T | undefined>): Promise<T> {\n    let backoffCount = 1;\n    let backoffFactor = this._options.initialDelayMs;\n    const problems = new Array<[number, string | Error]>();\n    const start = Date.now();\n    while (backoffCount <= this._options.maxRetries) {\n      try {\n        const attemptStart = Date.now();\n        const result = await operation();\n        const [attemptMs, totalMs] = [Date.now() - attemptStart, Date.now() - start];\n        log.info(`operation ${name} took ${attemptMs} ms (attempt: ${backoffCount}, total: ${totalMs} ms)`);\n        if (result !== undefined) { return result; }\n        problems.push([Date.now() - start, \"not ready\"]);\n      } catch (err) {\n        if (this._ext.isFatalError(err)) {\n          throw err;\n        }\n        problems.push([Date.now() - start, err]);\n      }\n      // Wait some time before attempting to reload from s3.  The longer we wait, the greater\n      // the odds of success.  In practice, a second should be more than enough almost always.\n      await delay(Math.round(backoffFactor));\n      if (this._closed) { throw new Error(\"storage closed\"); }\n      backoffCount++;\n      backoffFactor *= 1.7;\n    }\n    log.error(`operation failed to become consistent: ${name} - ${problems}`);\n    throw new Error(`operation failed to become consistent: ${name} - ${problems}`);\n  }\n\n  /**\n   * Retry an operation which will fail if content does not exist, until it is consistent\n   * with our expectation of the content's existence.\n   */\n  private async _retryWithExistenceCheck<T>(label: string, key: string, snapshotId: string | undefined,\n    op: (key: string, snapshotId?: string) => Promise<T>): Promise<T> {\n    return this._retry(label, async () => {\n      const hash = await this._options.sharedHash.load(this._keyWithSnapshot(key, snapshotId));\n      const expected = hash !== null && hash !== DELETED_TOKEN;\n      const reported = await op(key, snapshotId);\n      // If we expect an object but store doesn't seem to have it, retry.\n      if (expected && !reported)         { return undefined; }\n      // If store says there is an object but that is not what we expected (if we\n      // expected anything), retry.\n      if (hash && !expected && reported) { return undefined; }\n      // If expectations are matched, or we don't have expectations, return.\n      return reported;\n    });\n  }\n\n  /**\n   * Generate a key to use with Redis for a document.  Add in snapshot information\n   * if that is present (snapshots are immutable, except that they can be deleted,\n   * so we only set checksums for them in Redis when they are deleted).\n   */\n  private _keyWithSnapshot(key: string, snapshotId?: string | null) {\n    return snapshotId ? `${key}--${snapshotId}` : key;\n  }\n}\n\n/**\n * Small interface for storing hashes and ids.\n */\nexport interface PropStorage {\n  save(key: string, val: string): Promise<void>;\n  load(key: string): Promise<string | null>;\n}\n\nexport const Unchanged = Symbol(\"Unchanged\");\n\nexport interface ExternalStorageSettings {\n  purpose: \"doc\" | \"meta\" | \"attachments\";\n  basePrefix?: string;\n  extraPrefix?: string;\n}\n\n/**\n * Function returning the core ExternalStorage implementation,\n * which may then be wrapped in additional layer(s) of ExternalStorage.\n * See ICreate.ExternalStorage.\n * Uses S3 by default in hosted Grist.\n*/\nexport type ExternalStorageCreator =\n  (purpose: ExternalStorageSettings[\"purpose\"], extraPrefix: string) => ExternalStorage | undefined;\n\nexport class UnsupportedPurposeError extends Error {\n  constructor(purpose: ExternalStorageSettings[\"purpose\"]) {\n    super(`create.ExternalStorage: unsupported purpose '${purpose}'`);\n  }\n}\n\nfunction stripTrailingSlash(text: string): string {\n  return text.endsWith(\"/\") ? text.slice(0, -1) : text;\n}\n\nfunction stripLeadingSlash(text: string): string {\n  return text.startsWith(\"/\") ? text.slice(1) : text;\n}\n\nexport function joinKeySegments(keySegments: string[]): string {\n  if (keySegments.length < 1) {\n    return \"\";\n  }\n  const firstPart = keySegments[0];\n  const remainingParts = keySegments.slice(1);\n  const strippedParts = [\n    stripTrailingSlash(firstPart),\n    ...remainingParts.map(stripTrailingSlash).map(stripLeadingSlash),\n  ];\n  return strippedParts.join(\"/\");\n}\n\n/**\n * The storage mapping we use for our SaaS. A reasonable default, but relies\n * on appropriate lifecycle rules being set up in the bucket.\n */\nexport function getExternalStorageKeyMap(settings: ExternalStorageSettings): (originalKey: string) => string {\n  const { basePrefix, extraPrefix, purpose } = settings;\n  let fullPrefix = basePrefix + (basePrefix?.endsWith(\"/\") ? \"\" : \"/\");\n  if (extraPrefix) {\n    fullPrefix += extraPrefix + (extraPrefix.endsWith(\"/\") ? \"\" : \"/\");\n  }\n\n  // Set up how we name files/objects externally.\n  let fileNaming: (originalKey: string) => string;\n  if (purpose === \"doc\") {\n    fileNaming = docId => `${docId}.grist`;\n  } else if (purpose === \"meta\") {\n    // Put this in separate prefix so a lifecycle rule can prune old versions of the file.\n    // Alternatively, could go in separate bucket.\n    fileNaming = docId => `assets/unversioned/${docId}/meta.json`;\n  } else if (purpose === \"attachments\") {\n    // Prefix-only - attachments system handles exact naming\n    fileNaming = attachmentPath => `attachments/${stripLeadingSlash(attachmentPath)}`;\n  } else {\n    throw new UnsupportedPurposeError(settings.purpose);\n  }\n  return originalKey => (fullPrefix + fileNaming(originalKey));\n}\n\nexport function wrapWithKeyMappedStorage(rawStorage: ExternalStorage, settings: ExternalStorageSettings) {\n  return new KeyMappedExternalStorage(rawStorage, getExternalStorageKeyMap(settings));\n}\n"
  },
  {
    "path": "app/server/lib/FileParserElement.ts",
    "content": "import { PluginInstance } from \"app/common/PluginInstance\";\nimport { ParseFileAPI } from \"app/plugin/FileParserAPI\";\nimport { FileParser } from \"app/plugin/PluginManifest\";\nimport { checkers } from \"app/plugin/TypeCheckers\";\n\nimport * as path from \"path\";\n\n/**\n * Encapsulates together a file parse contribution with its plugin instance and callable stubs for\n * `parseFile` implementation provided by the plugin.\n *\n * Implements as well a `getMatching` static method to get all file parsers matching a filename from\n * the list of plugin instances.\n *\n */\nexport class FileParserElement {\n  /**\n   * Get all file parser that matches fileName from the list of plugins instances.\n   */\n  public static getMatching(pluginInstances: PluginInstance[], fileName: string): FileParserElement[] {\n    const fileParserElements: FileParserElement[] = [];\n    for (const plugin of pluginInstances) {\n      const fileParsers = plugin.definition.manifest.contributions.fileParsers;\n      if (fileParsers) {\n        for (const fileParser of fileParsers) {\n          if (matchFileParser(fileParser, fileName)) {\n            fileParserElements.push(new FileParserElement(plugin, fileParser));\n          }\n        }\n      }\n    }\n    return fileParserElements;\n  }\n\n  public parseFileStub: ParseFileAPI;\n\n  private constructor(public plugin: PluginInstance, public fileParser: FileParser) {\n    this.parseFileStub = plugin.getStub<ParseFileAPI>(fileParser.parseFile, checkers.ParseFileAPI);\n  }\n}\n\nfunction matchFileParser(fileParser: FileParser, fileName: string): boolean {\n  const ext = path.extname(fileName).slice(1),\n    fileExtensions = fileParser.fileExtensions;\n  return fileExtensions?.includes(ext);\n}\n"
  },
  {
    "path": "app/server/lib/FlexServer.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { ICustomWidget } from \"app/common/CustomWidget\";\nimport { delay } from \"app/common/delay\";\nimport { encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,\n  GristLoadConfig, IGristUrlState, isOrgInPathOnly, LatestVersionAvailable, parseSubdomain,\n  sanitizePathTail } from \"app/common/gristUrls\";\nimport { getOrgUrlInfo } from \"app/common/gristUrls\";\nimport { isAffirmative } from \"app/common/gutil\";\nimport { UserProfile } from \"app/common/LoginSessionAPI\";\nimport { SandboxInfo } from \"app/common/SandboxInfo\";\nimport { tbind } from \"app/common/tbind\";\nimport * as version from \"app/common/version\";\nimport { ApiServer, getOrgFromRequest } from \"app/gen-server/ApiServer\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { ActivationsManager } from \"app/gen-server/lib/ActivationsManager\";\nimport { DocApiForwarder } from \"app/gen-server/lib/DocApiForwarder\";\nimport { getDocWorkerMap } from \"app/gen-server/lib/DocWorkerMap\";\nimport { Doom } from \"app/gen-server/lib/Doom\";\nimport { HomeDBManager, UserChange } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { Housekeeper } from \"app/gen-server/lib/Housekeeper\";\nimport { Usage } from \"app/gen-server/lib/Usage\";\nimport { AccessTokens, IAccessTokens } from \"app/server/lib/AccessTokens\";\nimport { createSandbox } from \"app/server/lib/ActiveDoc\";\nimport { attachAppEndpoint } from \"app/server/lib/AppEndpoint\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { attachEarlyEndpoints } from \"app/server/lib/attachEarlyEndpoints\";\nimport {\n  AttachmentStoreProvider,\n  checkAvailabilityAttachmentStoreOptions,\n  getConfiguredAttachmentStoreConfigs,\n  IAttachmentStoreProvider,\n} from \"app/server/lib/AttachmentStoreProvider\";\nimport { addRequestUser, getUser, getUserId, isAnonymousUser,\n  isSingleUserMode, redirectToLoginUnconditionally } from \"app/server/lib/Authorizer\";\nimport { redirectToLogin, RequestWithLogin, signInStatusMiddleware } from \"app/server/lib/Authorizer\";\nimport { forceSessionChange } from \"app/server/lib/BrowserSession\";\nimport { Comm } from \"app/server/lib/Comm\";\nimport { ConfigBackendAPI } from \"app/server/lib/ConfigBackendAPI\";\nimport { IGristCoreConfig } from \"app/server/lib/configCore\";\nimport { getAndClearSignupStateCookie } from \"app/server/lib/cookieUtils\";\nimport { create } from \"app/server/lib/create\";\nimport { createSavedDoc } from \"app/server/lib/createSavedDoc\";\nimport { addDiscourseConnectEndpoints } from \"app/server/lib/DiscourseConnect\";\nimport { addDocApiRoutes } from \"app/server/lib/DocApi\";\nimport { DocManager } from \"app/server/lib/DocManager\";\nimport { getSqliteMode } from \"app/server/lib/DocStorage\";\nimport { DocWorker } from \"app/server/lib/DocWorker\";\nimport { DocWorkerLoadTracker, getDocWorkerLoadTracker } from \"app/server/lib/DocWorkerLoadTracker\";\nimport { DocWorkerInfo, IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport { expressWrap, jsonErrorHandler, secureJsonErrorHandler } from \"app/server/lib/expressWrap\";\nimport { Hosts, RequestWithOrg } from \"app/server/lib/extractOrg\";\nimport { addGoogleAuthEndpoint } from \"app/server/lib/GoogleAuth\";\nimport { createGristJobs, GristJobs } from \"app/server/lib/GristJobs\";\nimport { DocTemplate, GristLoginMiddleware, GristLoginSystem, GristServer,\n  RequestWithGrist } from \"app/server/lib/GristServer\";\nimport { initGristSessions, SessionStore } from \"app/server/lib/gristSessions\";\nimport { IAssistant } from \"app/server/lib/IAssistant\";\nimport { IAuditLogger } from \"app/server/lib/IAuditLogger\";\nimport { IBilling } from \"app/server/lib/IBilling\";\nimport { IDocNotificationManager } from \"app/server/lib/IDocNotificationManager\";\nimport { IDocStorageManager } from \"app/server/lib/IDocStorageManager\";\nimport { EmitNotifier, INotifier } from \"app/server/lib/INotifier\";\nimport { InstallAdmin } from \"app/server/lib/InstallAdmin\";\nimport log, { logAsJson } from \"app/server/lib/log\";\nimport { disableCache, noop } from \"app/server/lib/middleware\";\nimport { ErrorInLoginMiddleware } from \"app/server/lib/MinimalLogin\";\nimport { OAuth2Clients } from \"app/server/lib/OAuth2Clients\";\nimport { IPermitStore } from \"app/server/lib/Permit\";\nimport { getAppPathTo, getAppRoot, getInstanceRoot, getUnpackedAppRoot } from \"app/server/lib/places\";\nimport { addPluginEndpoints, limitToPlugins } from \"app/server/lib/PluginEndpoint\";\nimport { PluginManager } from \"app/server/lib/PluginManager\";\nimport { createPubSubManager, IPubSubManager } from \"app/server/lib/PubSubManager\";\nimport { adaptServerUrl, getOrgUrl, getOriginUrl, getScope, integerParam, isParameterOn, optIntegerParam,\n  optStringParam, RequestWithGristInfo, stringArrayParam, stringParam, TEST_HTTPS_OFFSET,\n  trustOrigin } from \"app/server/lib/requestUtils\";\nimport { buildScimRouter } from \"app/server/lib/scim\";\nimport { ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage } from \"app/server/lib/sendAppPage\";\nimport { getDatabaseUrl, listenPromise, timeoutReached } from \"app/server/lib/serverUtils\";\nimport { Sessions } from \"app/server/lib/Sessions\";\nimport * as shutdown from \"app/server/lib/shutdown\";\nimport { TagChecker } from \"app/server/lib/TagChecker\";\nimport { ITelemetry } from \"app/server/lib/Telemetry\";\nimport { startTestingHooks } from \"app/server/lib/TestingHooks\";\nimport { getTestLoginSystem } from \"app/server/lib/TestLogin\";\nimport { UpdateManager } from \"app/server/lib/UpdateManager\";\nimport { addUploadRoute } from \"app/server/lib/uploads\";\nimport { buildWidgetRepository, getWidgetsInPlugins, IWidgetRepository } from \"app/server/lib/WidgetRepository\";\nimport { setupLocale } from \"app/server/localization\";\n\nimport * as http from \"http\";\nimport * as https from \"https\";\nimport { AddressInfo } from \"net\";\nimport * as path from \"path\";\n\nimport axios from \"axios\";\nimport express from \"express\";\nimport * as fse from \"fs-extra\";\nimport { i18n } from \"i18next\";\nimport i18Middleware from \"i18next-http-middleware\";\nimport mapValues from \"lodash/mapValues\";\nimport pick from \"lodash/pick\";\nimport morganLogger from \"morgan\";\nimport fetch from \"node-fetch\";\nimport * as serveStatic from \"serve-static\";\n\n// Health checks are a little noisy in the logs, so we don't show them all.\n// We show the first N health checks:\nconst HEALTH_CHECK_LOG_SHOW_FIRST_N = 10;\n// And we show every Nth health check:\nconst HEALTH_CHECK_LOG_SHOW_EVERY_N = 100;\n\n// DocID of Grist doc to collect the Welcome questionnaire responses, such\n// as \"GristNewUserInfo\".\nconst DOC_ID_NEW_USER_INFO = process.env.DOC_ID_NEW_USER_INFO;\n\n// PubSub channel we use to inform all servers when a new available Grist version is detected.\nconst latestVersionChannel = \"latestVersionAvailable\";\n\nexport interface FlexServerOptions {\n  dataDir?: string;\n\n  // Base domain for org hostnames, starting with \".\". Defaults to the base domain of APP_HOME_URL.\n  baseDomain?: string;\n  // Base URL for plugins, if permitted. Defaults to APP_UNTRUSTED_URL.\n  pluginUrl?: string;\n\n  // Global grist config options\n  settings?: IGristCoreConfig;\n}\n\nexport class FlexServer implements GristServer {\n  public readonly create = create;\n  public tagChecker: TagChecker;\n  public app: express.Express;\n  public deps = new Set<string>();\n  public appRoot: string;\n  public host: string;\n  public tag: string;\n  public info = new Array<[string, any]>();\n  public usage: Usage;\n  public housekeeper: Housekeeper;\n  public server: http.Server;\n  public httpsServer?: https.Server;\n  public settings?: IGristCoreConfig;\n  public worker: DocWorkerInfo;\n  public electronServerMethods: ElectronServerMethods;\n  public readonly docsRoot: string;\n  public readonly i18Instance: i18n;\n  private _activations: ActivationsManager;\n  private _comm: Comm;\n  private _deploymentType: GristDeploymentType;\n  private _dbManager: HomeDBManager;\n  private _defaultBaseDomain: string | undefined;\n  private _pluginUrl: string | undefined;\n  private _pluginUrlReady: boolean = false;\n  private _servesPlugins?: boolean;\n  private _bundledWidgets?: ICustomWidget[];\n  private _billing: IBilling;\n  private _installAdmin: InstallAdmin;\n  private _instanceRoot: string;\n  private _attachmentStoreProvider: IAttachmentStoreProvider;\n  private _docManager: DocManager;\n  private _docWorker: DocWorker;\n  private _hosts: Hosts;\n  private _pluginManager: PluginManager;\n  private _sessions: Sessions;\n  private _sessionStore: SessionStore;\n  private _storageManager: IDocStorageManager;\n  private _auditLogger: IAuditLogger;\n  private _telemetry: ITelemetry;\n  private _processMonitorStop?: () => void;    // Callback to stop the ProcessMonitor\n  private _docWorkerMap: IDocWorkerMap;\n  private _docWorkerLoadTracker?: DocWorkerLoadTracker;\n  private _widgetRepository: IWidgetRepository;\n  private _docNotificationManager: IDocNotificationManager | undefined | false = false;\n  private _pubSubManager: IPubSubManager = createPubSubManager(process.env.REDIS_URL);\n  private _assistant?: IAssistant;\n  private _accessTokens: IAccessTokens;\n  private _internalPermitStore: IPermitStore;  // store for permits that stay within our servers\n  private _externalPermitStore: IPermitStore;  // store for permits that pass through outside servers\n  private _disabled: boolean = false;\n  private _disableExternalStorage: boolean = false;\n  private _healthy: boolean = true;  // becomes false if a serious error has occurred and\n  // server cannot do its work.\n  private _healthCheckCounter: number = 0;\n  private _hasTestingHooks: boolean = false;\n  private _loginMiddleware: GristLoginMiddleware;\n  private _userIdMiddleware: express.RequestHandler;\n  private _trustOriginsMiddleware: express.RequestHandler;\n  private _docPermissionsMiddleware: express.RequestHandler;\n  // This middleware redirects to signin/signup for anon, except on merged org or for\n  // a team site that allows anon access.\n  private _redirectToLoginWithExceptionsMiddleware: express.RequestHandler;\n  // This unconditionally redirects to signin/signup for anon, for pages where anon access\n  // is never desired.\n  private _redirectToLoginWithoutExceptionsMiddleware: express.RequestHandler;\n  // This can be called to do a redirect to signin/signup in a nuanced situation.\n  private _redirectToLoginUnconditionally: express.RequestHandler | null;\n  private _redirectToOrgMiddleware: express.RequestHandler;\n  private _redirectToHostMiddleware: express.RequestHandler;\n  private _getLoginRedirectUrl: (req: express.Request, target: URL) => Promise<string>;\n  private _getSignUpRedirectUrl: (req: express.Request, target: URL) => Promise<string>;\n  private _getLogoutRedirectUrl: (req: express.Request, nextUrl: URL) => Promise<string>;\n  private _sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;\n  private _getLoginSystem: () => Promise<GristLoginSystem>;\n  // Set once ready() is called\n  private _isReady: boolean = false;\n  private _updateManager: UpdateManager;\n  private _sandboxInfo: SandboxInfo;\n  private _jobs?: GristJobs;\n  private _emitNotifier: EmitNotifier = new EmitNotifier();\n  private _latestVersionAvailable?: LatestVersionAvailable;\n  private _oauth2Clients?: OAuth2Clients;\n\n  constructor(public port: number, public name: string = \"flexServer\",\n    public readonly options: FlexServerOptions = {}) {\n    this._getLoginSystem = create.getLoginSystem.bind(create);\n    this.settings = options.settings;\n    this.app = express();\n    this.app.set(\"port\", port);\n\n    this.appRoot = getAppRoot();\n    this.host = process.env.GRIST_HOST || \"localhost\";\n    log.info(`== Grist version is ${version.version} (commit ${version.gitcommit})`);\n    this.info.push([\"appRoot\", this.appRoot]);\n    // Initialize locales files.\n    this.i18Instance = setupLocale(this.appRoot);\n    if (Array.isArray(this.i18Instance.options.preload)) {\n      this.info.push([\"i18:locale\", this.i18Instance.options.preload.join(\",\")]);\n    }\n    if (Array.isArray(this.i18Instance.options.ns)) {\n      this.info.push([\"i18:namespace\", this.i18Instance.options.ns.join(\",\")]);\n    }\n    // Add language detection middleware.\n    this.app.use(i18Middleware.handle(this.i18Instance));\n    // This directory hold Grist documents.\n    let docsRoot = path.resolve((this.options?.dataDir) ||\n      process.env.GRIST_DATA_DIR ||\n      getAppPathTo(this.appRoot, \"samples\"));\n    // In testing, it can be useful to separate out document roots used\n    // by distinct FlexServers.\n    if (process.env.GRIST_TEST_ADD_PORT_TO_DOCS_ROOT === \"true\") {\n      docsRoot = path.resolve(docsRoot, String(port));\n    }\n    // Create directory if it doesn't exist.\n    // TODO: track down all dependencies on 'samples' existing in tests and\n    // in dev environment, and remove them.  Then it would probably be best\n    // to simply fail if the docs root directory does not exist.\n    fse.mkdirpSync(docsRoot);\n    this.docsRoot = fse.realpathSync(docsRoot);\n    this.info.push([\"docsRoot\", this.docsRoot]);\n\n    this._deploymentType = this.create.deploymentType();\n    if (process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE) {\n      this._deploymentType = GristDeploymentTypes.check(process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE);\n    }\n\n    const homeUrl = process.env.APP_HOME_URL;\n    // The \"base domain\" is only a thing if orgs are encoded as a subdomain.\n    if (process.env.GRIST_ORG_IN_PATH === \"true\" || process.env.GRIST_SINGLE_ORG) {\n      this._defaultBaseDomain = options.baseDomain || (homeUrl && new URL(homeUrl).hostname);\n    } else {\n      this._defaultBaseDomain = options.baseDomain || (homeUrl && parseSubdomain(new URL(homeUrl).hostname).base);\n    }\n    this.info.push([\"defaultBaseDomain\", this._defaultBaseDomain]);\n    this._pluginUrl = options.pluginUrl || process.env.APP_UNTRUSTED_URL;\n\n    // We don't bother unsubscribing because that's automatic when we close this._pubSubManager.\n    void this.getPubSubManager().subscribe(latestVersionChannel, (message) => {\n      const latestVersionAvailable: LatestVersionAvailable = JSON.parse(message);\n      log.debug(\"FlexServer: setting latest version\", latestVersionAvailable);\n      this.setLatestVersionAvailable(latestVersionAvailable);\n    });\n\n    // The electron build is not supported at this time, but this stub\n    // implementation of electronServerMethods is present to allow kicking\n    // its tires.\n    let userConfig: any = {\n      recentItems: [],\n    };\n    this.electronServerMethods = {\n      onDocOpen(cb) {\n        // currently only a stub.\n        cb(\"\");\n      },\n      async getUserConfig() {\n        return userConfig;\n      },\n      async updateUserConfig(obj: any) {\n        userConfig = obj;\n      },\n      onBackupMade() {\n        log.info(\"backup skipped\");\n      },\n    };\n\n    this.app.use((req, res, next) => {\n      (req as RequestWithGrist).gristServer = this;\n      next();\n    });\n  }\n\n  public getHost(): string {\n    return `${this.host}:${this.getOwnPort()}`;\n  }\n\n  // Get a url for this server, based on the protocol it speaks (http), the host it\n  // runs on, and the port it listens on.  The url the client uses to communicate with\n  // the server may be different if there are intermediaries (such as a load-balancer\n  // terminating TLS).\n  public getOwnUrl(): string {\n    const port = this.getOwnPort();\n    return `http://${this.host}:${port}`;\n  }\n\n  /**\n   * Get a url for the home server api.  Called without knowledge of a specific\n   * request, so will default to a generic url.  Use of this method can render\n   * code incompatible with custom base domains (currently, sendgrid notifications\n   * via Notifier are incompatible for this reason).\n   */\n  public getDefaultHomeUrl(): string {\n    const homeUrl = process.env.APP_HOME_URL || (this._has(\"api\") && this.getOwnUrl());\n    if (!homeUrl) { throw new Error(\"need APP_HOME_URL\"); }\n    return homeUrl;\n  }\n\n  /**\n   * Same as getDefaultHomeUrl, but for internal use.\n   */\n  public getDefaultHomeInternalUrl(): string {\n    return process.env.APP_HOME_INTERNAL_URL || this.getDefaultHomeUrl();\n  }\n\n  /**\n   * Get a url for the home server api, adapting it to match the base domain in the\n   * requested url.  This adaptation is important for cookie-based authentication.\n   *\n   * If relPath is given, returns that path relative to homeUrl. If omitted, note that\n   * getHomeUrl() will still return a URL ending in \"/\".\n   */\n  public getHomeUrl(req: express.Request, relPath: string = \"\"): string {\n    // Get the default home url.\n    const homeUrl = new URL(relPath, this.getDefaultHomeUrl());\n    adaptServerUrl(homeUrl, req as RequestWithOrg);\n    return homeUrl.href;\n  }\n\n  /**\n   * Same as getHomeUrl, but for requesting internally.\n   */\n  public getHomeInternalUrl(relPath: string = \"\"): string {\n    const homeUrl = new URL(relPath, this.getDefaultHomeInternalUrl());\n    return homeUrl.href;\n  }\n\n  /**\n   * Get a home url that is appropriate for the given document.  For now, this\n   * returns a default that works for all documents.  That could change in future,\n   * specifically with custom domains (perhaps we might limit which docs can be accessed\n   * based on domain).\n   */\n  public async getHomeUrlByDocId(docId: string, relPath: string = \"\"): Promise<string> {\n    return new URL(relPath, this.getDefaultHomeInternalUrl()).href;\n  }\n\n  // Get the port number the server listens on.  This may be different from the port\n  // number the client expects when communicating with the server if there are intermediaries.\n  public getOwnPort(): number {\n    // Get the port from the server in case it was started with port 0.\n    return this.server ? (this.server.address() as AddressInfo).port : this.port;\n  }\n\n  /**\n   * Get interface to job queues.\n   */\n  public getJobs(): GristJobs {\n    return this._jobs || (this._jobs = createGristJobs());\n  }\n\n  /**\n   * Get a url to an org that should be accessible by all signed-in users. For now, this\n   * returns the base URL of the personal org (typically docs[-s]).\n   */\n  public getMergedOrgUrl(req: RequestWithLogin, pathname: string = \"/\"): string {\n    return this._getOrgRedirectUrl(req, this._dbManager.mergedOrgDomain(), pathname);\n  }\n\n  public getPermitStore(): IPermitStore {\n    if (!this._internalPermitStore) { throw new Error(\"no permit store available\"); }\n    return this._internalPermitStore;\n  }\n\n  public getExternalPermitStore(): IPermitStore {\n    if (!this._externalPermitStore) { throw new Error(\"no permit store available\"); }\n    return this._externalPermitStore;\n  }\n\n  public getSessions(): Sessions {\n    if (!this._sessions) { throw new Error(\"no sessions available\"); }\n    return this._sessions;\n  }\n\n  public getComm(): Comm {\n    if (!this._comm) { throw new Error(\"no Comm available\"); }\n    return this._comm;\n  }\n\n  public getDeploymentType(): GristDeploymentType {\n    return this._deploymentType;\n  }\n\n  public getHosts(): Hosts {\n    if (!this._hosts) { throw new Error(\"no hosts available\"); }\n    return this._hosts;\n  }\n\n  public getActivations(): ActivationsManager {\n    if (!this._activations) { throw new Error(\"no activations available\"); }\n    return this._activations;\n  }\n\n  public getHomeDBManager(): HomeDBManager {\n    if (!this._dbManager) { throw new Error(\"no home db available\"); }\n    return this._dbManager;\n  }\n\n  public getStorageManager(): IDocStorageManager {\n    if (!this._storageManager) { throw new Error(\"no storage manager available\"); }\n    return this._storageManager;\n  }\n\n  public getAuditLogger(): IAuditLogger {\n    if (!this._auditLogger) { throw new Error(\"no audit logger available\"); }\n    return this._auditLogger;\n  }\n\n  public getDocManager(): DocManager {\n    if (!this._docManager) { throw new Error(\"no document manager available\"); }\n    return this._docManager;\n  }\n\n  public getTelemetry(): ITelemetry {\n    if (!this._telemetry) { throw new Error(\"no telemetry available\"); }\n    return this._telemetry;\n  }\n\n  public getWidgetRepository(): IWidgetRepository {\n    if (!this._widgetRepository) { throw new Error(\"no widget repository available\"); }\n    return this._widgetRepository;\n  }\n\n  public hasNotifier(): boolean {\n    return !this._emitNotifier.isEmpty();\n  }\n\n  public getNotifier(): INotifier {\n    // We only warn if we are in a server that doesn't configure notifiers (i.e. not a home\n    // server). But actually having a working notifier isn't required.\n    if (!this._has(\"notifier\")) { throw new Error(\"no notifier available\"); }\n    // Expose a wrapper around it that emits actions.\n    return this._emitNotifier;\n  }\n\n  public getDocNotificationManager(): IDocNotificationManager | undefined {\n    if (this._docNotificationManager === false) {\n      // The special value of 'false' is used to create only on first call. Afterwards,\n      // the value may be undefined, but no longer false.\n      this._docNotificationManager = this.create.createDocNotificationManager(this);\n    }\n    return this._docNotificationManager;\n  }\n\n  public getPubSubManager(): IPubSubManager {\n    return this._pubSubManager;\n  }\n\n  public getAssistant(): IAssistant | undefined {\n    return this._assistant;\n  }\n\n  public getInstallAdmin(): InstallAdmin {\n    if (!this._installAdmin) { throw new Error(\"no InstallAdmin available\"); }\n    return this._installAdmin;\n  }\n\n  public getAccessTokens() {\n    if (this._accessTokens) { return this._accessTokens; }\n    this.addDocWorkerMap();\n    const cli = this._docWorkerMap.getRedisClient();\n    this._accessTokens = new AccessTokens(cli);\n    return this._accessTokens;\n  }\n\n  public getUpdateManager() {\n    if (!this._updateManager) { throw new Error(\"no UpdateManager available\"); }\n    return this._updateManager;\n  }\n\n  public getBilling(): IBilling {\n    if (!this._billing) {\n      if (!this._dbManager) { throw new Error(\"need dbManager\"); }\n      this._billing = this.create.Billing(this._dbManager, this);\n    }\n    return this._billing;\n  }\n\n  public sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void> {\n    if (!this._sendAppPage) { throw new Error(\"no _sendAppPage method available\"); }\n    return this._sendAppPage(req, resp, options);\n  }\n\n  public addLogging() {\n    if (this._check(\"logging\")) { return; }\n    if (!this._httpLoggingEnabled()) { return; }\n    // Add a timestamp token that matches exactly the formatting of non-morgan logs.\n    morganLogger.token(\"logTime\", (req: Request) => log.timestamp());\n    // Add an optional gristInfo token that can replace the url, if the url is sensitive.\n    morganLogger.token(\"gristInfo\", (req: RequestWithGristInfo) =>\n      req.gristInfo || req.originalUrl || req.url);\n    morganLogger.token(\"host\", (req: express.Request) => req.get(\"host\"));\n    morganLogger.token(\"body\", (req: express.Request) =>\n      req.is(\"application/json\") ? JSON.stringify(req.body) : undefined,\n    );\n\n    // For debugging, be careful not to enable logging in production (may log sensitive data)\n    const shouldLogBody = isAffirmative(process.env.GRIST_LOG_HTTP_BODY);\n\n    const msg = `:logTime :host :method :gristInfo ${shouldLogBody ? \":body \" : \"\"}` +\n      \":status :response-time ms - :res[content-length]\";\n    // In hosted Grist, render json so logs retain more organization.\n    function outputJson(tokens: any, req: any, res: any) {\n      return JSON.stringify({\n        timestamp: tokens.logTime(req, res),\n        host: tokens.host(req, res),\n        method: tokens.method(req, res),\n        path: tokens.gristInfo(req, res),\n        ...(shouldLogBody ? { body: tokens.body(req, res) } : {}),\n        status: tokens.status(req, res),\n        timeMs: parseFloat(tokens[\"response-time\"](req, res)) || undefined,\n        contentLength: parseInt(tokens.res(req, res, \"content-length\"), 10) || undefined,\n        altSessionId: req.altSessionId,\n      });\n    }\n    this.app.use(morganLogger(logAsJson ? outputJson : msg, {\n      skip: this._shouldSkipRequestLogging.bind(this),\n    }));\n  }\n\n  public addHealthCheck() {\n    if (this._check(\"health\")) { return; }\n    // Health check endpoint. if called with /hooks, testing hooks are required in order to be\n    // considered healthy.  Testing hooks are used only in server started for tests, and\n    // /status/hooks allows the tests to wait for them to be ready.\n    // If db=1 query parameter is included, status will include the status of DB connection.\n    // If redis=1 query parameter is included, status will include the status of the Redis connection.\n    // If docWorkerRegistered=1 query parameter is included, status will include the status of the\n    // doc worker registration in Redis.\n    this.app.get(\"/status(/hooks)?\", async (req, res) => {\n      const checks = new Map<string, Promise<boolean>>();\n      const timeout = optIntegerParam(req.query.timeout, \"timeout\") || 10_000;\n\n      // Check that the given promise resolves with no error within our timeout.\n      const asyncCheck = async (promise: Promise<unknown> | undefined) => {\n        if (!promise || await timeoutReached(timeout, promise) === true) {\n          return false;\n        }\n        return promise.then(() => true, () => false);     // Success => true, rejection => false\n      };\n\n      if (req.path.endsWith(\"/hooks\")) {\n        checks.set(\"hooks\", Promise.resolve(this._hasTestingHooks));\n      }\n      if (isParameterOn(req.query.db)) {\n        checks.set(\"db\", asyncCheck(this._dbManager.connection.query(\"SELECT 1\")));\n      }\n      if (isParameterOn(req.query.redis)) {\n        checks.set(\"redis\", asyncCheck(this._docWorkerMap.getRedisClient()?.pingAsync()));\n      }\n      if (isParameterOn(req.query.docWorkerRegistered) && this.worker) {\n        // Only check whether the doc worker is registered if we have a worker.\n        // The Redis client may not be connected, but in this case this has to\n        // be checked with the 'redis' parameter (the user may want to avoid\n        // removing workers when connection is unstable).\n        if (this._docWorkerMap.getRedisClient()?.connected) {\n          checks.set(\"docWorkerRegistered\", asyncCheck(\n            this._docWorkerMap.isWorkerRegistered(this.worker).then((isRegistered) => {\n              if (!isRegistered) { throw new Error(\"doc worker not registered\"); }\n              return isRegistered;\n            }),\n          ));\n        }\n      }\n      if (isParameterOn(req.query.ready)) {\n        checks.set(\"ready\", Promise.resolve(this._isReady));\n      }\n      let extra = \"\";\n      let ok = true;\n      let statuses: string[] = [];\n      // If we had any extra check, collect their status to report them.\n      if (checks.size > 0) {\n        const results = await Promise.all(checks.values());\n        ok = ok && results.every(r => r === true);\n        statuses = Array.from(checks.keys(), (key, i) => `${key} ${results[i] ? \"ok\" : \"not ok\"}`);\n        extra = ` (${statuses.join(\", \")})`;\n      }\n\n      const overallOk = ok && this._healthy;\n\n      if ((this._healthCheckCounter % 100) === 0 || !overallOk) {\n        log.rawDebug(`Healthcheck result`, {\n          host: req.get(\"host\"),\n          path: req.path,\n          query: req.query,\n          ok,\n          statuses,\n          healthy: this._healthy,\n          overallOk,\n          previousSuccessfulChecks: this._healthCheckCounter,\n        });\n      }\n\n      if (overallOk) {\n        this._healthCheckCounter++;\n        res.status(200).send(`Grist ${this.name} is alive${extra}.`);\n      } else {\n        this._healthCheckCounter = 0;  // reset counter if we ever go internally unhealthy.\n        res.status(500).send(`Grist ${this.name} is unhealthy${extra}.`);\n      }\n    });\n  }\n\n  /**\n   *\n   * Adds a /boot/$GRIST_BOOT_KEY page that shows diagnostics.\n   * Accepts any /boot/... URL in order to let the front end\n   * give some guidance if the user is stumbling around trying\n   * to find the boot page, but won't actually provide diagnostics\n   * unless GRIST_BOOT_KEY is set in the environment, and is present\n   * in the URL.\n   *\n   * We take some steps to make the boot page available even when\n   * things are going wrong, and should take more in future.\n   *\n   * When rendering the page a hardcoded 'boot' tag is used, which\n   * is used to ensure that static assets are served locally and\n   * we aren't relying on APP_STATIC_URL being set correctly.\n   *\n   * We use a boot key so that it is more acceptable to have this\n   * boot page living outside of the authentication system, which\n   * could be broken.\n   *\n   * TODO: there are some configuration problems that currently\n   * result in Grist not running at all. ideally they would result in\n   * Grist running in a limited mode that is enough to bring up the boot\n   * page.\n   *\n   */\n  public addBootPage() {\n    if (this._check(\"boot\")) { return; }\n    this.app.get(\"/boot(/*)?\", async (req, res) => {\n      // Doing a good redirect is actually pretty subtle and we might\n      // get it wrong, so just say /boot got moved.\n      res.send(\"The /boot/KEY page is now /admin?boot-key=KEY\");\n    });\n  }\n\n  public getBootKey(): string | undefined {\n    return appSettings.section(\"boot\").flag(\"key\").readString({\n      envVar: \"GRIST_BOOT_KEY\",\n    });\n  }\n\n  public denyRequestsIfNotReady() {\n    this.app.use((_req, res, next) => {\n      if (!this._isReady) {\n        // If ready() hasn't been called yet, don't continue, and\n        // give a clear error. This is to avoid exposing the service\n        // in a partially configured form.\n        return res.status(503).json({ error: \"Service unavailable during start up\" });\n      }\n      next();\n    });\n  }\n\n  public testAddRouter() {\n    if (this._check(\"router\")) { return; }\n    this.app.get(\"/test/router\", (req, res) => {\n      const act = optStringParam(req.query.act, \"act\") || \"none\";\n      const port = stringParam(req.query.port, \"port\");  // port is trusted in mock; in prod it is not.\n      if (act === \"add\" || act === \"remove\") {\n        const host = `localhost:${port}`;\n        return res.status(200).json({\n          act,\n          host,\n          url: `http://${host}`,\n          message: \"ok\",\n        });\n      }\n      return res.status(500).json({ error: \"unrecognized action\" });\n    });\n  }\n\n  public addCleanup() {\n    if (this._check(\"cleanup\")) { return; }\n    // Set up signal handlers. Note that nodemon sends SIGUSR2 to restart node.\n    shutdown.cleanupOnSignals(\"SIGINT\", \"SIGTERM\", \"SIGHUP\", \"SIGUSR2\");\n\n    // We listen for uncaughtExceptions / unhandledRejections, but do exit when they happen. It is\n    // a strong recommendation, which seems best to follow\n    // (https://nodejs.org/docs/latest-v18.x/api/process.html#warning-using-uncaughtexception-correctly).\n    // We do try to shutdown cleanly (i.e. do any planned cleanup), which goes somewhat against\n    // the recommendation to do only synchronous work.\n\n    let counter = 0;\n\n    // Note that this event catches also 'unhandledRejection' (origin should be either\n    // 'uncaughtException' or 'unhandledRejection').\n    process.on(\"uncaughtException\", (err, origin) => {\n      log.error(`UNHANDLED ERROR ${origin} (${counter}):`, err);\n      if (counter === 0) {\n        // Only call shutdown once. It's async and could in theory fail, in which case it would be\n        // another unhandledRejection, and would get caught and reported by this same handler.\n        void (shutdown.exit(1));\n      }\n      counter++;\n    });\n  }\n\n  public addTagChecker() {\n    if (this._check(\"tag\", \"!org\")) { return; }\n    // Handle requests that start with /v/TAG/ and set .tag property on them.\n    this.tag = version.gitcommit;\n    this.info.push([\"tag\", this.tag]);\n    this.tagChecker = new TagChecker(this.tag);\n    this.app.use(this.tagChecker.inspectTag);\n  }\n\n  /**\n   * To allow routing to doc workers via the path, doc workers remove any\n   * path prefix of the form /dw/...../ if present.  The prefix is not checked,\n   * just removed unconditionally.\n   * TODO: determine what the prefix should be, and check it, to catch bugs.\n   */\n  public stripDocWorkerIdPathPrefixIfPresent() {\n    if (this._check(\"strip_dw\", \"!tag\", \"!org\")) { return; }\n    this.app.use((req, resp, next) => {\n      const match = req.url.match(/^\\/dw\\/([-a-zA-Z0-9]+)([/?].*)?$/);\n      if (match) { req.url = sanitizePathTail(match[2]); }\n      next();\n    });\n  }\n\n  public addOrg() {\n    if (this._check(\"org\", \"homedb\", \"hosts\")) { return; }\n    this.app.use(this._hosts.extractOrg);\n  }\n\n  public setDirectory() {\n    if (this._check(\"dir\")) { return; }\n    process.chdir(getUnpackedAppRoot(this.appRoot));\n  }\n\n  public get instanceRoot() {\n    if (!this._instanceRoot) {\n      this._instanceRoot = getInstanceRoot();\n      this.info.push([\"instanceRoot\", this._instanceRoot]);\n    }\n    return this._instanceRoot;\n  }\n\n  public addStaticAndBowerDirectories() {\n    if (this._check(\"static_and_bower\", \"dir\")) { return; }\n    this.addTagChecker();\n    // Grist has static help files, which may be useful for standalone app,\n    // but for hosted grist the latest help is at support.getgrist.com.  Redirect\n    // to this page for the benefit of crawlers which currently rank the static help\n    // page link highly for historic reasons.\n    this.app.use(/^\\/help\\//, expressWrap(async (req, res) => {\n      res.redirect(\"https://support.getgrist.com\");\n    }));\n    // If there is a directory called \"static_ext\", serve material from there\n    // as well. This isn't used in grist-core but is handy for extensions such\n    // as an Electron app.\n    const staticExtDir = getAppPathTo(this.appRoot, \"static\") + \"_ext\";\n    const staticExtApp = fse.existsSync(staticExtDir) ?\n      express.static(staticExtDir, serveAnyOrigin) : null;\n    const staticApp = express.static(getAppPathTo(this.appRoot, \"static\"), serveAnyOrigin);\n    const bowerApp = express.static(getAppPathTo(this.appRoot, \"bower_components\"), serveAnyOrigin);\n    if (process.env.GRIST_LOCALES_DIR) {\n      const locales = express.static(process.env.GRIST_LOCALES_DIR, serveAnyOrigin);\n      this.app.use(\"/locales\", this.tagChecker.withTag(locales));\n    }\n    if (staticExtApp) { this.app.use(this.tagChecker.withTag(staticExtApp)); }\n    this.app.use(this.tagChecker.withTag(staticApp));\n    this.app.use(this.tagChecker.withTag(bowerApp));\n  }\n\n  // Some tests rely on testFOO.html files being served.\n  public addAssetsForTests() {\n    if (this._check(\"testAssets\", \"dir\")) { return; }\n    // Serve test[a-z]*.html for test purposes.\n    this.app.use(/^\\/(test[a-z]*.html)$/i, expressWrap(async (req, res) =>\n      res.sendFile(req.params[0], { root: getAppPathTo(this.appRoot, \"static\") })));\n  }\n\n  // Plugin operation relies currently on grist-plugin-api.js being available,\n  // and with Grist's static assets to be also available on the untrusted\n  // host.  The assets should be available without version tags, but not\n  // at the root level - we nest them in /plugins/assets.\n  public async addAssetsForPlugins() {\n    if (this._check(\"pluginUntaggedAssets\", \"dir\")) { return; }\n    this.app.use(/^\\/(grist-plugin-api.js)$/, expressWrap(async (req, res) =>\n      res.sendFile(req.params[0], { root: getAppPathTo(this.appRoot, \"static\") })));\n    // Plugins get access to static resources without a tag\n    this.app.use(\n      \"/plugins/assets\",\n      limitToPlugins(this, express.static(getAppPathTo(this.appRoot, \"static\"))));\n    this.app.use(\n      \"/plugins/assets\",\n      limitToPlugins(this, express.static(getAppPathTo(this.appRoot, \"bower_components\"))));\n    // Serve custom-widget.html message for anyone.\n    this.app.use(/^\\/(custom-widget.html)$/, expressWrap(async (req, res) =>\n      res.sendFile(req.params[0], { root: getAppPathTo(this.appRoot, \"static\") })));\n    this.addOrg();\n    addPluginEndpoints(this, await this._addPluginManager());\n\n    // Serve bundled custom widgets on the plugin endpoint.\n    const places = getWidgetsInPlugins(this, \"\");\n    if (places.length > 0) {\n      // For all widgets served in place, replace any copies of\n      // grist-plugin-api.js with this app's version of it.\n      // This is perhaps a bit rude, but beats the alternative\n      // of either using inconsistent bundled versions, or\n      // requiring network access.\n      this.app.use(/^\\/widgets\\/.*\\/(grist-plugin-api.js)$/, expressWrap(async (req, res) =>\n        res.sendFile(req.params[0], { root: getAppPathTo(this.appRoot, \"static\") })));\n    }\n    for (const place of places) {\n      this.app.use(\n        \"/widgets/\" + place.pluginId, this.tagChecker.withTag(\n          limitToPlugins(this, express.static(place.dir, serveAnyOrigin)),\n        ),\n      );\n    }\n  }\n\n  // Prepare cache for managing org-to-host relationship.\n  public addHosts() {\n    if (this._check(\"hosts\", \"homedb\")) { return; }\n    this._hosts = new Hosts(this._defaultBaseDomain, this._dbManager, this);\n  }\n\n  /**\n   * Delete all the storage related to a document, across the file system,\n   * external storage, and the home database. Since a doc worker may have\n   * the document open, this is done via the API.\n   */\n  public async hardDeleteDoc(docId: string) {\n    if (!this._internalPermitStore) {\n      throw new Error(\"permit store not available\");\n    }\n    // In general, documents can only be manipulated with the coordination of the\n    // document worker to which they are assigned.\n    const permitKey = await this._internalPermitStore.setPermit({ docId });\n    try {\n      const result = await fetch(await this.getHomeUrlByDocId(docId, `/api/docs/${docId}`), {\n        method: \"DELETE\",\n        headers: {\n          Permit: permitKey,\n        },\n      });\n      if (result.status !== 200) {\n        throw new ApiError((await result.json()).error, result.status);\n      }\n    } finally {\n      await this._internalPermitStore.removePermit(permitKey);\n    }\n  }\n\n  public async initHomeDBManager() {\n    if (this._check(\"homedb\")) { return; }\n    this._dbManager = new HomeDBManager(this, this._emitNotifier, this._pubSubManager);\n    this._dbManager.setPrefix(process.env.GRIST_ID_PREFIX || \"\");\n    await this._dbManager.connect();\n    await this._dbManager.initializeSpecialIds();\n    // Report which database we are using, without sensitive credentials.\n    this.info.push([\"database\", getDatabaseUrl(this._dbManager.connection.options, false)]);\n    // If the installation appears to be new, give it an id and a creation date.\n    this._activations = new ActivationsManager(this._dbManager);\n    appSettings.setEnvVars((await this._activations.current()).prefs?.envVars || {});\n    await this._activations.current();\n    this._installAdmin = await this.create.createInstallAdmin(this._dbManager);\n  }\n\n  public addDocWorkerMap() {\n    if (this._check(\"map\")) { return; }\n    this._docWorkerMap = getDocWorkerMap();\n    this._internalPermitStore = this._docWorkerMap.getPermitStore(\"internal\");\n    this._externalPermitStore = this._docWorkerMap.getPermitStore(\"external\");\n  }\n\n  // Set up the main express middleware used.  For a single user setup, without logins,\n  // all this middleware is currently a no-op.\n  public addAccessMiddleware() {\n    if (this._check(\"middleware\", \"map\", \"loginMiddleware\", isSingleUserMode() ? null : \"hosts\")) { return; }\n\n    if (!isSingleUserMode()) {\n      const skipSession = appSettings.section(\"login\").flag(\"skipSession\").readBool({\n        envVar: \"GRIST_IGNORE_SESSION\",\n      });\n      // Middleware to redirect landing pages to preferred host\n      this._redirectToHostMiddleware = this._hosts.redirectHost;\n      // Middleware to add the userId to the express request object.\n      this._userIdMiddleware = expressWrap(addRequestUser.bind(\n        null, this._dbManager, this._internalPermitStore,\n        {\n          overrideProfile: this._loginMiddleware.overrideProfile?.bind(this._loginMiddleware),\n          // Set this to false to stop Grist using a cookie for authentication purposes.\n          skipSession,\n          gristServer: this,\n        },\n      ));\n      this._trustOriginsMiddleware = expressWrap(trustOriginHandler);\n      // middleware to authorize doc access to the app. Note that this requires the userId\n      // to be set on the request by _userIdMiddleware.\n      this._docPermissionsMiddleware = expressWrap((...args) => this._docWorker.assertDocAccess(...args));\n      this._redirectToLoginWithExceptionsMiddleware = redirectToLogin(true,\n        this._getLoginRedirectUrl,\n        this._getSignUpRedirectUrl,\n        this._dbManager);\n      this._redirectToLoginWithoutExceptionsMiddleware = redirectToLogin(false,\n        this._getLoginRedirectUrl,\n        this._getSignUpRedirectUrl,\n        this._dbManager);\n      this._redirectToLoginUnconditionally = redirectToLoginUnconditionally(this._getLoginRedirectUrl,\n        this._getSignUpRedirectUrl);\n      this._redirectToOrgMiddleware = tbind(this._redirectToOrg, this);\n    } else {\n      this._userIdMiddleware = noop;\n      this._trustOriginsMiddleware = noop;\n      // For standalone single-user Grist, documents are stored on-disk\n      // with their filename equal to the document title, no document\n      // aliases are possible, and there is no access control.\n      // The _docPermissionsMiddleware is a no-op.\n      // TODO We might no longer have any tests for isSingleUserMode, or modes of operation.\n      this._docPermissionsMiddleware = noop;\n      this._redirectToLoginWithExceptionsMiddleware = noop;\n      this._redirectToLoginWithoutExceptionsMiddleware = noop;\n      this._redirectToLoginUnconditionally = null;  // there is no way to log in.\n      this._redirectToOrgMiddleware = noop;\n      this._redirectToHostMiddleware = noop;\n    }\n  }\n\n  /**\n   * Add middleware common to all API endpoints (including forwarding ones).\n   */\n  public addApiMiddleware() {\n    if (this._check(\"api-mw\", \"middleware\")) { return; }\n    // API endpoints need req.userId and need to support requests from different subdomains.\n    this.app.use(\"/api\", this._userIdMiddleware);\n    this.app.use(\"/api\", this._trustOriginsMiddleware);\n    this.app.use(\"/api\", disableCache);\n  }\n\n  /**\n   * Add error-handling middleware common to all API endpoints.\n   */\n  public addApiErrorHandlers() {\n    if (this._check(\"api-error\", \"api-mw\")) { return; }\n\n    // add a final not-found handler for api\n    this.app.use(\"/api\", (req, res) => {\n      res.status(404).send({ error: `not found: ${req.originalUrl}` });\n    });\n\n    // Add a final error handler for /api endpoints that reports errors as JSON.\n    this.app.use(\"/api/auth\", secureJsonErrorHandler);\n    this.app.use(\"/api\", jsonErrorHandler);\n  }\n\n  public addWidgetRepository() {\n    if (this._check(\"widgets\")) { return; }\n\n    this._widgetRepository = buildWidgetRepository(this);\n  }\n\n  public addHomeApi() {\n    if (this._check(\"api\", \"homedb\", \"json\", \"api-mw\")) { return; }\n\n    // ApiServer's constructor adds endpoints to the app.\n    new ApiServer(this, this.app, this._dbManager);\n  }\n\n  public addScimApi() {\n    if (this._check(\"scim\", \"api\", \"homedb\", \"json\", \"api-mw\")) { return; }\n\n    const scimRouter = isAffirmative(process.env.GRIST_ENABLE_SCIM) ?\n      buildScimRouter(this._dbManager, this._installAdmin) :\n      () => {\n        throw new ApiError(\"SCIM API is not enabled\", 501);\n      };\n\n    this.app.use(\"/api/scim\", scimRouter);\n  }\n\n  public addBillingApi() {\n    if (this._check(\"billing-api\", \"homedb\", \"json\", \"api-mw\")) { return; }\n    this.getBilling().addEndpoints(this.app);\n    this.getBilling().addEventHandlers();\n  }\n\n  public addBillingMiddleware() {\n    if (this._check(\"activation\", \"homedb\")) { return; }\n    this.getBilling().addMiddleware?.(this.app);\n  }\n\n  /**\n   * Add a /api/log endpoint that simply outputs client errors to our\n   * logs.  This is a minimal placeholder for a special-purpose\n   * service for dealing with client errors.\n   */\n  public addLogEndpoint() {\n    if (this._check(\"log-endpoint\", \"json\", \"api-mw\")) { return; }\n\n    this.app.post(\"/api/log\", async (req, resp) => {\n      const mreq = req as RequestWithLogin;\n      log.rawWarn(\"client error\", {\n        event: req.body.event,\n        docId: req.body.docId,\n        page: req.body.page,\n        browser: req.body.browser,\n        org: mreq.org,\n        email: mreq.user?.loginEmail,\n        userId: mreq.userId,\n        altSessionId: mreq.altSessionId,\n      });\n      return resp.status(200).send();\n    });\n  }\n\n  public addAuditLogger() {\n    if (this._check(\"audit-logger\", \"homedb\")) { return; }\n\n    this._auditLogger = this.create.AuditLogger(this._dbManager, this);\n  }\n\n  public async addTelemetry() {\n    if (this._check(\"telemetry\", \"homedb\", \"json\", \"api-mw\")) { return; }\n\n    this._telemetry = this.create.Telemetry(this._dbManager, this);\n    this._telemetry.addEndpoints(this.app);\n    await this._telemetry.start();\n\n    // Start up a monitor for memory and cpu usage.\n    this._processMonitorStop = this.create.startProcessMonitor(this._telemetry);\n  }\n\n  public async close() {\n    this._processMonitorStop?.();\n    await this._updateManager?.clear();\n    if (this.usage)  { await this.usage.close(); }\n    if (this._hosts) { this._hosts.close(); }\n    this._emitNotifier.removeAllListeners();\n    this._dbManager?.clearCaches();\n    this._installAdmin?.clearCaches();\n    if (this.server)      { this.server.close(); }\n    if (this.httpsServer) { this.httpsServer.close(); }\n    if (this.housekeeper) { await this.housekeeper.stop(); }\n    if (this._jobs)       { await this._jobs.stop(); }\n    await this._shutdown();\n    if (this._accessTokens) { await this._accessTokens.close(); }\n    // Do this after _shutdown, since DocWorkerMap is used during shutdown.\n    if (this._docWorkerMap) { await this._docWorkerMap.close(); }\n    if (this._sessionStore) { await this._sessionStore.close(); }\n    if (this._auditLogger) { await this._auditLogger.close(); }\n    if (this._billing) { await this._billing.close?.(); }\n    await this._pubSubManager.close();\n  }\n\n  public addDocApiForwarder() {\n    if (this._check(\"doc_api_forwarder\", \"!json\", \"homedb\", \"api-mw\", \"map\")) { return; }\n    const docApiForwarder = new DocApiForwarder(this._docWorkerMap, this._dbManager, this);\n    docApiForwarder.addEndpoints(this.app);\n  }\n\n  public addJsonSupport() {\n    if (this._check(\"json\")) { return; }\n    this.app.use(express.json({ limit: \"1mb\" }));  // Increase from the default 100kb\n  }\n\n  public addSessions() {\n    if (this._check(\"sessions\", \"loginMiddleware\")) { return; }\n    this.addTagChecker();\n    this.addOrg();\n\n    // Create the sessionStore and related objects.\n    const { sessions, sessionMiddleware, sessionStore } =\n      initGristSessions(getUnpackedAppRoot(this.instanceRoot), this);\n    this.app.use(sessionMiddleware);\n    this.app.use(signInStatusMiddleware);\n\n    // Create an endpoint for making cookies during testing.\n    this.app.get(\"/test/session\", async (req, res) => {\n      const mreq = req as RequestWithLogin;\n      forceSessionChange(mreq.session);\n      res.status(200).send(`Grist ${this.name} is alive and is interested in you.`);\n    });\n\n    this._sessions = sessions;\n    this._sessionStore = sessionStore;\n  }\n\n  // Close connections and stop accepting new connections.  Remove server from any lists\n  // it may be in.\n  public async stopListening(mode: \"crash\" | \"clean\" = \"clean\") {\n    if (!this._disabled) {\n      if (mode === \"clean\") {\n        await this._shutdown();\n        this._disabled = true;\n      } else {\n        this._disabled = true;\n        if (this._comm) {\n          this._comm.setServerActivation(false);\n          this._comm.destroyAllClients();\n        }\n      }\n      this.server.close();\n      if (this.httpsServer) { this.httpsServer.close(); }\n    }\n  }\n\n  public async createWorkerUrl(): Promise<{ url: string, host: string }> {\n    if (!process.env.GRIST_ROUTER_URL) {\n      throw new Error(\"No service available to create worker url\");\n    }\n    const w = await axios.get(process.env.GRIST_ROUTER_URL,\n      { params: { act: \"add\", port: this.getOwnPort() } });\n    log.info(`DocWorker registered itself via ${process.env.GRIST_ROUTER_URL} as ${w.data.url}`);\n    const statusUrl = `${w.data.url}/status`;\n    // We now wait for the worker to be available from the url that clients will\n    // use to connect to it.  This may take some time.  The main delay is the\n    // new target group and load balancer rule taking effect - typically 10-20 seconds.\n    // If we don't wait, the worker will end up registered for work and clients\n    // could end up trying to reach it to open documents - but the url they have\n    // won't work.\n    for (let tries = 0; tries < 600; tries++) {\n      await delay(1000);\n      try {\n        await axios.get(statusUrl);\n        return w.data;\n      } catch (err) {\n        log.debug(`While waiting for ${statusUrl} got error ${(err as Error).message}`);\n      }\n    }\n    throw new Error(`Cannot connect to ${statusUrl}`);\n  }\n\n  // Accept new connections again.  Add server to any lists it needs to be in to get work.\n  public async restartListening() {\n    if (!this._docWorkerMap) { throw new Error(\"expected to have DocWorkerMap\"); }\n    await this.stopListening(\"clean\");\n    if (this._disabled) {\n      if (this._storageManager) {\n        this._storageManager.testReopenStorage();\n      }\n      this._comm.setServerActivation(true);\n      if (this.worker) {\n        await this._startServers(this.server, this.httpsServer, this.name, this.port, false);\n        await this._addSelfAsWorker(this._docWorkerMap);\n        this._docWorkerLoadTracker?.start();\n      }\n      this._disabled = false;\n    }\n  }\n\n  public async addLandingPages() {\n    // TODO: check if isSingleUserMode() path can be removed from this method\n    if (this._check(\"landing\", \"map\", isSingleUserMode() ? null : \"homedb\")) { return; }\n    this.addSessions();\n\n    // Initialize _sendAppPage helper.\n    this._sendAppPage = makeSendAppPage({\n      server: this,\n      staticDir: getAppPathTo(this.appRoot, \"static\"),\n      tag: this.tag,\n      testLogin: isTestLoginAllowed(),\n      baseDomain: this._defaultBaseDomain,\n    });\n\n    const forceLogin = appSettings.section(\"login\").flag(\"forced\").readBool({\n      envVar: \"GRIST_FORCE_LOGIN\",\n    });\n\n    const forcedLoginMiddleware = forceLogin ? this._redirectToLoginWithoutExceptionsMiddleware : noop;\n\n    const welcomeNewUser: express.RequestHandler = isSingleUserMode() ?\n      (req, res, next) => next() :\n      expressWrap(async (req, res, next) => {\n        const mreq = req as RequestWithLogin;\n        const user = getUser(req);\n        if (user?.isFirstTimeUser) {\n          log.debug(`welcoming user: ${user.name}`);\n          // Reset isFirstTimeUser flag.\n          await this._dbManager.updateUser(user.id, { isFirstTimeUser: false });\n\n          // This is a good time to set some other flags, for showing a page with welcome question(s)\n          // to this new user and recording their sign-up with Google Tag Manager. These flags are also\n          // scoped to the user, but isFirstTimeUser has a dedicated DB field because it predates userPrefs.\n          // Note that the updateOrg() method handles all levels of prefs (for user, user+org, or org).\n          await this._dbManager.updateOrg(getScope(req), 0, { userPrefs: {\n            showNewUserQuestions: true,\n            recordSignUpEvent: true,\n          } });\n\n          // Give a chance to the login system to react to the first visit after signup.\n          this._loginMiddleware.onFirstVisit?.(req);\n\n          // If the assistant needs to perform some work (e.g. redirect to a new document with a\n          // particular prompt pre-filled), do it now.\n          //\n          // TODO: break out this and other parts of `welcomeNewUser` into separate Express middleware.\n          // `onFirstVisit` may send a response, which is why we awkwardly check `headersSent` wasn't\n          // set before resuming the current middleware. This wouldn't be necessary if `onFirstVisit`\n          // was a proper Express middleware that called `next` when not sending a response.\n          if (this._assistant?.version === 2 && this._assistant.onFirstVisit) {\n            await this._assistant.onFirstVisit(req, res);\n            if (res.headersSent) {\n              return;\n            }\n          }\n\n          // If we need to copy an unsaved document or template as part of sign-up, do so now\n          // and redirect to it.\n          const docId = await this._maybeCopyDocToHomeWorkspace(mreq, res);\n          if (docId) {\n            return res.redirect(this.getMergedOrgUrl(mreq, `/doc/${docId}`));\n          }\n\n          const domain = mreq.org ?? null;\n          if (!process.env.GRIST_SINGLE_ORG && this._dbManager.isMergedOrg(domain)) {\n            // We're logging in for the first time on the merged org; if the user has\n            // access to other team sites, forward the user to a page that lists all\n            // the teams they have access to.\n            const result = await this._dbManager.getMergedOrgs(user.id, user.id, domain);\n            const orgs = this._dbManager.unwrapQueryResult(result);\n            if (orgs.length > 1 && mreq.path === \"/\") {\n              // Only forward if the request is for the home page.\n              return res.redirect(this.getMergedOrgUrl(mreq, \"/welcome/teams\"));\n            }\n          }\n        }\n        if (mreq.org?.startsWith(\"o-\")) {\n          // We are on a team site without a custom subdomain.\n          const orgInfo = this._dbManager.unwrapQueryResult(\n            await this._dbManager.getOrg({ userId: user.id }, mreq.org),\n          );\n\n          // If the user is a billing manager for the org, and the org\n          // is supposed to have a custom subdomain, forward the user\n          // to a page to set it.\n\n          // TODO: this is more or less a hack for AppSumo signup flow,\n          // and could be removed if/when signup flow is revamped.\n\n          // If \"welcomeNewUser\" is ever added to billing pages, we'd need\n          // to avoid a redirect loop.\n\n          if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.getFeatures().vanityDomain) {\n            const prefix: string = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : \"\";\n            return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`);\n          }\n        }\n        next();\n      });\n\n    attachAppEndpoint({\n      app: this.app,\n      middleware: [\n        this._redirectToHostMiddleware,\n        this._userIdMiddleware,\n        forcedLoginMiddleware,\n        this._redirectToLoginWithExceptionsMiddleware,\n        this._redirectToOrgMiddleware,\n        welcomeNewUser,\n      ],\n      docMiddleware: [\n        // Same as middleware, except without login redirect middleware.\n        this._redirectToHostMiddleware,\n        this._userIdMiddleware,\n        forcedLoginMiddleware,\n        this._redirectToOrgMiddleware,\n        welcomeNewUser,\n      ],\n      formMiddleware: [\n        this._userIdMiddleware,\n        forcedLoginMiddleware,\n      ],\n      forceLogin: this._redirectToLoginUnconditionally,\n      docWorkerMap: isSingleUserMode() ? null : this._docWorkerMap,\n      sendAppPage: this._sendAppPage,\n      dbManager: this._dbManager,\n      plugins: (await this._addPluginManager()).getPlugins(),\n      gristServer: this,\n    });\n  }\n\n  public async addLoginMiddleware() {\n    if (this._check(\"loginMiddleware\", \"homedb\")) { return; }\n\n    // TODO: We could include a third mock provider of login/logout URLs for better tests. Or we\n    // could create a mock SAML identity provider for testing this using the SAML flow.\n    try {\n      const loginSystem = await this.resolveLoginSystem();\n      this._loginMiddleware = await loginSystem.getMiddleware(this);\n    } catch (err) {\n      // We need to start even if login middleware fails to initialize, and report it so that admins\n      // can fix the problem using the admin UI.\n      log.error(\"Error initializing login middleware:\", err);\n      appSettings.section(\"login\").flag(\"error\").set((err as Error).message);\n      // We don't fallback to MinimalLogin here, as it imposes some security risks if enabled\n      // unintentionally. This way, the admin is made aware of the problem and can take action using boot\n      // page instructions.\n      this._loginMiddleware = new ErrorInLoginMiddleware();\n    }\n    this._getLoginRedirectUrl = tbind(this._loginMiddleware.getLoginRedirectUrl, this._loginMiddleware);\n    this._getSignUpRedirectUrl = tbind(this._loginMiddleware.getSignUpRedirectUrl, this._loginMiddleware);\n    this._getLogoutRedirectUrl = tbind(this._loginMiddleware.getLogoutRedirectUrl, this._loginMiddleware);\n    const wildcardMiddleware = this._loginMiddleware.getWildcardMiddleware?.();\n    if (wildcardMiddleware?.length) {\n      this.app.use(wildcardMiddleware);\n    }\n  }\n\n  public addComm() {\n    if (this._check(\"comm\", \"start\", \"homedb\", \"loginMiddleware\")) { return; }\n    this._comm = new Comm(this.server, {\n      settings: {},\n      sessions: this._sessions,\n      hosts: this._hosts,\n      loginMiddleware: this._loginMiddleware,\n      httpsServer: this.httpsServer,\n      i18Instance: this.i18Instance,\n      dbManager: this.getHomeDBManager(),\n    });\n  }\n\n  /**\n   * Add endpoint that servers a javascript file with various api keys that\n   * are used by the client libraries.\n   */\n  public addClientSecrets() {\n    if (this._check(\"clientSecret\")) { return; }\n    this.app.get(\"/client-secret.js\", expressWrap(async (req, res) => {\n      const config = this.getGristConfig();\n      // Currently we are exposing only Google keys.\n      // Those keys are eventually visible by the client, but should be usable\n      // only from Grist's domains.\n      const secrets = {\n        googleClientId: config.googleClientId,\n      };\n      res.set(\"Content-Type\", \"application/javascript\");\n      res.status(200);\n      res.send(`\n        window.gristClientSecret = ${JSON.stringify(secrets)}\n      `);\n    }));\n  }\n\n  public async addLoginRoutes() {\n    if (this._check(\"login\", \"org\", \"sessions\", \"homedb\", \"hosts\")) { return; }\n    // TODO: We do NOT want Comm here at all, it's only being used for handling sessions, which\n    // should be factored out of it.\n    this.addComm();\n\n    const signinMiddleware = this._loginMiddleware.getLoginOrSignUpMiddleware ?\n      this._loginMiddleware.getLoginOrSignUpMiddleware() :\n      [];\n    this.app.get(\"/login\", ...signinMiddleware, expressWrap(this._redirectToLoginOrSignup.bind(this, {\n      signUp: false,\n    })));\n    this.app.get(\"/signup\", ...signinMiddleware, expressWrap(this._redirectToLoginOrSignup.bind(this, {\n      signUp: true,\n    })));\n    this.app.get(\"/signin\", ...signinMiddleware, expressWrap(this._redirectToLoginOrSignup.bind(this, {})));\n\n    if (isTestLoginAllowed()) {\n      // This is an endpoint for the dev environment that lets you log in as anyone.\n      // For a standard dev environment, it will be accessible at localhost:8080/test/login\n      // and localhost:8080/o/<org>/test/login.  Only available when GRIST_TEST_LOGIN is set.\n      // Handy when without network connectivity to reach Cognito.\n\n      log.warn(\"Adding a /test/login endpoint because GRIST_TEST_LOGIN is set. \" +\n        \"Users will be able to login as anyone.\");\n\n      this.app.get(\"/test/login\", expressWrap(async (req, res) => {\n        log.warn(\"Serving unauthenticated /test/login endpoint, made available because GRIST_TEST_LOGIN is set.\");\n\n        // Query parameter is called \"username\" for compatibility with Cognito.\n        const email = optStringParam(req.query.username, \"username\");\n        if (email) {\n          const redirect = optStringParam(req.query.next, \"next\");\n          const profile: UserProfile = {\n            email,\n            name: optStringParam(req.query.name, \"name\") || email,\n          };\n          const url = new URL(redirect || getOrgUrl(req));\n          // Make sure we update session for org we'll be redirecting to.\n          const { org } = await this._hosts.getOrgInfoFromParts(url.hostname, url.pathname);\n          const scopedSession = this._sessions.getOrCreateSessionFromRequest(req, { org });\n          await scopedSession.updateUserProfile(req, profile);\n          this._sessions.clearCacheIfNeeded({ email, org });\n          if (redirect) { return res.redirect(redirect); }\n        }\n        res.send(`<!doctype html>\n          <html><body>\n          <div class=\"modal-content-desktop\">\n            <h1>A Very Credulous Login Page</h1>\n            <p>\n              A minimal login screen to facilitate testing.\n              I'll believe anything you tell me.\n            </p>\n            <form>\n              <div>Email <input type=text name=username placeholder=email /></div>\n              <div>Name <input type=text name=name placeholder=name /></div>\n              <div>Dummy password <input type=text name=password placeholder=unused ></div>\n              <input type=hidden name=next value=\"${req.query.next || \"\"}\">\n              <div><input type=submit name=signInSubmitButton value=login></div>\n            </form>\n          </div>\n          </body></html>\n       `);\n      }));\n    }\n\n    this.app.get(\"/logout\", ...this._logoutMiddleware(), expressWrap(async (req, resp) => {\n      const signedOutUrl = new URL(getOrgUrl(req) + \"signed-out\");\n      const redirectUrl = await this._getLogoutRedirectUrl(req, signedOutUrl);\n      resp.redirect(redirectUrl);\n    }));\n\n    // Add a static \"signed-out\" page. This is where logout typically lands (e.g. after redirecting\n    // through SAML).\n    this.app.get(\"/signed-out\", expressWrap((req, resp) =>\n      this._sendAppPage(req, resp, { path: \"error.html\", status: 200, config: { errPage: \"signed-out\" } })));\n\n    const comment = await this._loginMiddleware.addEndpoints(this.app);\n    this.info.push([\"loginMiddlewareComment\", comment]);\n\n    addDiscourseConnectEndpoints(this.app, {\n      userIdMiddleware: this._userIdMiddleware,\n      redirectToLogin: this._redirectToLoginWithoutExceptionsMiddleware,\n    });\n  }\n\n  public async addTestingHooks(workerServers?: FlexServer[]) {\n    this._check(\"testinghooks\", \"comm\");\n    if (process.env.GRIST_TESTING_SOCKET) {\n      await startTestingHooks(process.env.GRIST_TESTING_SOCKET, this.port, this._comm, this,\n        workerServers || []);\n      this._hasTestingHooks = true;\n    }\n  }\n\n  // Returns a Map from docId to number of connected clients for each doc.\n  public async getDocClientCounts(): Promise<Map<string, number>> {\n    return this._docManager ? this._docManager.getDocClientCounts() : new Map();\n  }\n\n  // allow the document manager to be specified externally, for convenience in testing.\n  public testSetDocManager(docManager: DocManager) {\n    this._docManager = docManager;\n  }\n\n  // Add document-related endpoints and related support.\n  public async addDoc() {\n    this._check(\"doc\", \"start\", \"tag\", \"json\", isSingleUserMode() ?\n      null : \"homedb\", \"api-mw\", \"map\", \"telemetry\");\n    // add handlers for cleanup, if we are in charge of the doc manager.\n    if (!this._docManager) { this.addCleanup(); }\n    await this.addLoginMiddleware();\n    this.addComm();\n    // Check SQLite mode so it shows up in initial configuration readout\n    // (even though we don't need it until opening documents).\n    getSqliteMode();\n\n    await this.create.configure?.();\n\n    if (!isSingleUserMode()) {\n      const externalStorage = appSettings.section(\"externalStorage\");\n      const haveExternalStorage = Object.values(externalStorage.nested)\n        .some(storage => storage.flag(\"active\").getAsBool());\n      const disabled = externalStorage.flag(\"disable\")\n        .read({ envVar: \"GRIST_DISABLE_S3\" }).getAsBool();\n      if (disabled || !haveExternalStorage) {\n        this._disableExternalStorage = true;\n        externalStorage.flag(\"active\").set(false);\n      }\n      await this.create.checkBackend?.();\n      const workers = this._docWorkerMap;\n      const docWorkerId = await this._addSelfAsWorker(workers);\n\n      const storageManager = await this.create.createHostedDocStorageManager(\n        this, this.docsRoot, docWorkerId, this._disableExternalStorage, workers, this._dbManager,\n        this.create.ExternalStorage.bind(this.create),\n      );\n      this._storageManager = storageManager;\n    } else {\n      const samples = getAppPathTo(this.appRoot, \"public_samples\");\n      const storageManager = await this.create.createLocalDocStorageManager(\n        this.docsRoot, samples, this._comm, undefined, this);\n      this._storageManager = storageManager;\n    }\n\n    const pluginManager = await this._addPluginManager();\n\n    const allStoreOptions = Object.values(this.create.getAttachmentStoreOptions());\n    const checkedStoreOptions = await checkAvailabilityAttachmentStoreOptions(allStoreOptions);\n    log.info(\"Attachment store backend availability\", {\n      available: checkedStoreOptions.available.map(option => option.name),\n      unavailable: checkedStoreOptions.unavailable.map(option => option.name),\n    });\n\n    this._attachmentStoreProvider = this._attachmentStoreProvider || new AttachmentStoreProvider(\n      await getConfiguredAttachmentStoreConfigs(),\n      (await this.getActivations().current()).id,\n    );\n    this._docManager = this._docManager || new DocManager(this._storageManager,\n      pluginManager,\n      this._dbManager,\n      this._attachmentStoreProvider,\n      this);\n    const docManager = this._docManager;\n\n    shutdown.addCleanupHandler(null, this._shutdown.bind(this), 25000, \"FlexServer._shutdown\");\n\n    if (!isSingleUserMode()) {\n      this._docWorkerLoadTracker = getDocWorkerLoadTracker(\n        this.worker,\n        this._docWorkerMap,\n        docManager,\n      );\n      if (this._docWorkerLoadTracker) {\n        // Get the initial load value. If this call fails, the server will crash.\n        // This is meant to check whether the admin has correctly configured\n        // how to measure it.\n        const initialLoadValue = await this._docWorkerLoadTracker.getLoad();\n        await this._docWorkerMap.setWorkerLoad(this.worker, initialLoadValue);\n        this._docWorkerLoadTracker.start();\n      }\n      this._comm.registerMethods({\n        openDoc: docManager.openDoc.bind(docManager),\n      });\n      this._serveDocPage();\n    }\n\n    // Attach docWorker endpoints and Comm methods.\n    const docWorker = new DocWorker(this._dbManager, { comm: this._comm, gristServer: this });\n    this._docWorker = docWorker;\n\n    // Register the websocket comm functions associated with the docworker.\n    docWorker.registerCommCore();\n    docWorker.registerCommPlugin();\n\n    // Doc-specific endpoints require authorization; collect the relevant middleware in one list.\n    const docAccessMiddleware = [\n      this._userIdMiddleware,\n      this._docPermissionsMiddleware,\n      this.tagChecker.requireTag,\n    ];\n\n    this._addSupportPaths(docAccessMiddleware);\n\n    if (!isSingleUserMode()) {\n      addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager,\n        this._attachmentStoreProvider, this);\n    }\n  }\n\n  public async getSandboxInfo(): Promise<SandboxInfo> {\n    if (this._sandboxInfo) { return this._sandboxInfo; }\n\n    const flavor = process.env.GRIST_SANDBOX_FLAVOR || \"unknown\";\n    const info = this._sandboxInfo = {\n      flavor,\n      configured: flavor !== \"unsandboxed\",\n      functional: false,\n      effective: false,\n      sandboxed: false,\n      lastSuccessfulStep: \"none\",\n    } as SandboxInfo;\n    // Only meaningful on instances that handle documents.\n    if (!this._docManager) { return info; }\n    try {\n      const sandbox = createSandbox({\n        server: this,\n        docId: \"test\",  // The id is just used in logging - no\n        // document is created or read at this level.\n        preferredPythonVersion: \"3\",\n      });\n      info.flavor = sandbox.getFlavor();\n      info.configured = info.flavor !== \"unsandboxed\";\n      info.lastSuccessfulStep = \"create\";\n      const result = await sandbox.pyCall(\"get_version\");\n      if (typeof result !== \"number\") {\n        throw new Error(`Expected a number: ${result}`);\n      }\n      info.lastSuccessfulStep = \"use\";\n      await sandbox.shutdown();\n      info.lastSuccessfulStep = \"all\";\n      info.functional = true;\n      info.effective = ![\"skip\", \"unsandboxed\"].includes(info.flavor);\n    } catch (e) {\n      info.error = String(e);\n    }\n    return info;\n  }\n\n  public getInfo(key: string): any {\n    const infoPair = this.info.find(([keyToCheck]) => key === keyToCheck);\n    return infoPair?.[1];\n  }\n\n  public disableExternalStorage() {\n    if (this.deps.has(\"doc\")) {\n      throw new Error(\"disableExternalStorage called too late\");\n    }\n    this._disableExternalStorage = true;\n  }\n\n  public async getDoomTool() {\n    const dbManager = this.getHomeDBManager();\n    const permitStore = this.getPermitStore();\n    const notifier = this.getNotifier();\n    const loginSystem = await this.resolveLoginSystem();\n    const homeUrl = this.getHomeInternalUrl().replace(/\\/$/, \"\");\n    return new Doom(dbManager, permitStore, notifier, loginSystem, homeUrl);\n  }\n\n  public addAccountPage() {\n    const middleware = [\n      this._redirectToHostMiddleware,\n      this._userIdMiddleware,\n      this._redirectToLoginWithoutExceptionsMiddleware,\n    ];\n\n    this.app.get(\"/account\", ...middleware, expressWrap(async (req, resp) => {\n      return this._sendAppPage(req, resp, { path: \"app.html\", status: 200, config: {} });\n    }));\n\n    if (isAffirmative(process.env.GRIST_ACCOUNT_CLOSE)) {\n      this.app.delete(\"/api/doom/account\", expressWrap(async (req, resp) => {\n        // Make sure we have a valid user authenticated user here.\n        const userId = getUserId(req);\n\n        // Make sure we are deleting the correct user account (and not the anonymous user)\n        const requestedUser = integerParam(req.query.userid, \"userid\");\n        if (requestedUser !== userId || isAnonymousUser(req))  {\n          // This probably shouldn't happen, but if user has already deleted the account and tries to do it\n          // once again in a second tab, we might end up here. In that case we are returning false to indicate\n          // that account wasn't deleted.\n          return resp.status(200).json(false);\n        }\n\n        // We are a valid user, we can proceed with the deletion. Note that we will\n        // delete user as an admin, as we need to remove other resources that user\n        // might not have access to.\n\n        // Reuse Doom cli tool for account deletion. It won't allow to delete account if it has access\n        // to other (not public) team sites.\n        const doom = await this.getDoomTool();\n        const { data } = await doom.deleteUser(userId);\n        if (data) { this._logDeleteUserEvents(req as RequestWithLogin, data); }\n        return resp.status(200).json(true);\n      }));\n\n      this.app.get(\"/account-deleted\", ...this._logoutMiddleware(), expressWrap((req, resp) => {\n        return this._sendAppPage(\n          req, resp, { path: \"error.html\", status: 200, config: { errPage: \"account-deleted\" } },\n        );\n      }));\n\n      this.app.delete(\"/api/doom/org\", expressWrap(async (req, resp) => {\n        const mreq = req as RequestWithLogin;\n        const orgDomain = getOrgFromRequest(req);\n        if (!orgDomain) { throw new ApiError(\"Cannot determine organization\", 400); }\n\n        if (this._dbManager.isMergedOrg(orgDomain)) {\n          throw new ApiError(\"Cannot delete a personal site\", 400);\n        }\n\n        // Get org from the server.\n        const query = await this._dbManager.getOrg(getScope(mreq), orgDomain);\n        const org = this._dbManager.unwrapQueryResult(query);\n\n        if (!org || org.ownerId) {\n          // This shouldn't happen, but just in case test it.\n          throw new ApiError(\"Cannot delete an org with an owner\", 400);\n        }\n\n        if (!org.billingAccount.isManager) {\n          throw new ApiError(\"Only billing manager can delete a team site\", 403);\n        }\n\n        // Reuse Doom cli tool for org deletion. Note, this removes everything as a super user.\n        const deletedOrg = structuredClone(org);\n        const doom = await this.getDoomTool();\n        await doom.deleteOrg(org.id);\n        this._logDeleteSiteEvents(mreq, deletedOrg);\n        return resp.status(200).send();\n      }));\n    }\n  }\n\n  public addBillingPages() {\n    const middleware = [\n      this._redirectToHostMiddleware,\n      this._userIdMiddleware,\n      this._redirectToLoginWithoutExceptionsMiddleware,\n    ];\n\n    this.getBilling().addPages(this.app, middleware);\n  }\n\n  /**\n   * Add billing webhooks.  Strip signatures sign the raw body of the message, so\n   * we need to get these webhooks in before the bodyParser is added to parse json.\n   */\n  public addEarlyWebhooks() {\n    if (this._check(\"webhooks\", \"homedb\", \"!json\")) { return; }\n    this.getBilling().addWebhooks(this.app);\n  }\n\n  public addWelcomePaths() {\n    const middleware = [\n      this._redirectToHostMiddleware,\n      this._userIdMiddleware,\n      this._redirectToLoginWithoutExceptionsMiddleware,\n    ];\n\n    // These are some special-purpose welcome pages, with no middleware.\n    this.app.get(/\\/welcome\\/(signup|verify|teams|select-account)/, expressWrap(async (req, resp, next) => {\n      return this._sendAppPage(req, resp, { path: \"app.html\", status: 200, config: {}, googleTagManager: \"anon\" });\n    }));\n\n    /**\n     * A nuanced redirecting endpoint. For example, on docs.getgrist.com it does:\n     * 1) If logged in and no team site -> https://docs.getgrist.com/\n     * 2) If logged in and has team sites -> https://docs.getgrist.com/welcome/teams\n     * 3) If logged out but has a cookie -> /login, then 1 or 2\n     * 4) If entirely unknown -> /signup\n     */\n    this.app.get(\"/welcome/start\", [\n      this._redirectToHostMiddleware,\n      this._userIdMiddleware,\n    ], expressWrap(async (req, resp, next) => {\n      if (isAnonymousUser(req)) {\n        return this._redirectToLoginOrSignup({\n          nextUrl: new URL(getOrgUrl(req, \"/welcome/start\")),\n        }, req, resp);\n      }\n\n      await this._redirectToHomeOrWelcomePage(req as RequestWithLogin, resp);\n    }));\n\n    /**\n     * Like /welcome/start, but doesn't redirect anonymous users to sign in.\n     *\n     * Used by the client when the last site the user visited is unknown, and\n     * a suitable site is needed for the home page.\n     *\n     * For example, on templates.getgrist.com it does:\n     * 1) If logged in and no team site -> https://docs.getgrist.com/\n     * 2) If logged in and has team sites -> https://docs.getgrist.com/welcome/teams\n     * 3) If logged out -> https://docs.getgrist.com/\n     */\n    this.app.get(\"/welcome/home\", [\n      this._redirectToHostMiddleware,\n      this._userIdMiddleware,\n    ], expressWrap(async (req, resp) => {\n      const mreq = req as RequestWithLogin;\n      if (isAnonymousUser(req)) {\n        return resp.redirect(this.getMergedOrgUrl(mreq));\n      }\n\n      await this._redirectToHomeOrWelcomePage(mreq, resp, { redirectToMergedOrg: true });\n    }));\n\n    this.app.post(\"/welcome/info\", ...middleware, expressWrap(async (req, resp, next) => {\n      const userId = getUserId(req);\n      const user = getUser(req);\n      const orgName = stringParam(req.body.org_name, \"org_name\");\n      const orgRole = stringParam(req.body.org_role, \"org_role\");\n      const useCases = stringArrayParam(req.body.use_cases, \"use_cases\");\n      const useOther = stringParam(req.body.use_other, \"use_other\");\n      const row = {\n        UserID: userId,\n        Name: user.name,\n        Email: user.loginEmail,\n        org_name: orgName,\n        org_role: orgRole,\n        use_cases: [\"L\", ...useCases],\n        use_other: useOther,\n      };\n      try {\n        await this._recordNewUserInfo(row);\n      } catch (e) {\n        // If we failed to record, at least log the data, so we could potentially recover it.\n        log.rawWarn(`Failed to record new user info: ${e.message}`, { newUserQuestions: row });\n      }\n      const nonOtherUseCases = useCases.filter(useCase => useCase !== \"Other\");\n      for (const useCase of [...nonOtherUseCases, ...(useOther ? [`Other - ${useOther}`] : [])]) {\n        this.getTelemetry().logEvent(req as RequestWithLogin, \"answeredUseCaseQuestion\", {\n          full: {\n            userId,\n            useCase,\n          },\n        });\n      }\n\n      resp.status(200).send();\n    }), jsonErrorHandler); // Add a final error handler that reports errors as JSON.\n  }\n\n  public finalizeEndpoints() {\n    this.addApiErrorHandlers();\n\n    // add a final non-found handler for other content.\n    this.app.use(\"/\", expressWrap((req, resp) => {\n      if (this._sendAppPage) {\n        return this._sendAppPage(req, resp, { path: \"error.html\", status: 404, config: { errPage: \"not-found\" } });\n      } else {\n        return resp.status(404).json({ error: \"not found\" });\n      }\n    }));\n\n    // add a final error handler\n    this.app.use(async (err: any, req: express.Request, resp: express.Response, next: express.NextFunction) => {\n      // Delegate to default error handler when headers have already been sent, as express advises\n      // at https://expressjs.com/en/guide/error-handling.html#the-default-error-handler.\n      // Also delegates if no _sendAppPage method has been configured.\n      if (resp.headersSent || !this._sendAppPage) { return next(err); }\n      try {\n        const errPage = (\n          err.status === 403 ? \"access-denied\" :\n            err.status === 404 ? \"not-found\" :\n              \"other-error\"\n        );\n        const config = { errPage, errMessage: err.message || err };\n        await this._sendAppPage(req, resp, { path: \"error.html\", status: err.status || 400, config });\n      } catch (error) {\n        return next(error);\n      }\n    });\n  }\n\n  /**\n   * Check whether there's a local plugin port.\n   */\n  public servesPlugins() {\n    if (this._servesPlugins === undefined) {\n      throw new Error(\"do not know if server will serve plugins\");\n    }\n    return this._servesPlugins;\n  }\n\n  /**\n   * Declare that there will be a local plugin port.\n   */\n  public setServesPlugins(flag: boolean) {\n    this._servesPlugins = flag;\n  }\n\n  /**\n   * Get the base URL for plugins. Throws an error if the URL is not\n   * yet available.\n   */\n  public getPluginUrl() {\n    if (!this._pluginUrlReady) {\n      throw new Error(\"looked at plugin url too early\");\n    }\n    return this._pluginUrl;\n  }\n\n  public getPlugins() {\n    if (!this._pluginManager) {\n      throw new Error(\"plugin manager not available\");\n    }\n    return this._pluginManager.getPlugins();\n  }\n\n  public async finalizePlugins(userPort: number | null) {\n    if (isAffirmative(process.env.GRIST_TRUST_PLUGINS)) {\n      this._pluginUrl = this.getDefaultHomeUrl();\n    } else if (userPort !== null) {\n      // If plugin content is served from same host but on different port,\n      // run webserver on that port\n      const ports = await this.startCopy(\"pluginServer\", userPort);\n      // If Grist is running on a desktop, directly on the host, it\n      // can be convenient to leave the user port free for the OS to\n      // allocate by using GRIST_UNTRUSTED_PORT=0. But we do need to\n      // remember how to contact it.\n      if (process.env.APP_UNTRUSTED_URL === undefined) {\n        const url = new URL(this.getOwnUrl());\n        url.port = String(userPort || ports.serverPort);\n        this._pluginUrl = url.href;\n      }\n    }\n    this.info.push([\"pluginUrl\", this._pluginUrl]);\n    this.info.push([\"willServePlugins\", this._servesPlugins]);\n    this._pluginUrlReady = true;\n    const repo = buildWidgetRepository(this, { localOnly: true });\n    this._bundledWidgets = await repo.getWidgets();\n  }\n\n  public getBundledWidgets(): ICustomWidget[] {\n    if (!this._bundledWidgets) {\n      throw new Error(\"bundled widgets accessed too early\");\n    }\n    return this._bundledWidgets;\n  }\n\n  public summary() {\n    for (const [label, value] of this.info) {\n      log.info(\"== %s: %s\", label, value);\n    }\n    for (const item of appSettings.describeAll()) {\n      const txt =\n        ((item.value !== undefined) ? String(item.value) : \"-\") +\n        (item.foundInEnvVar ? ` [${item.foundInEnvVar}]` : \"\") +\n        (item.usedDefault ? \" [default]\" : \"\") +\n        ((item.wouldFindInEnvVar && !item.foundInEnvVar) ? ` [${item.wouldFindInEnvVar}]` : \"\");\n      log.info(\"== %s: %s\", item.name, txt);\n    }\n  }\n\n  public setReady(value: boolean) {\n    if (value) {\n      log.debug(\"FlexServer is ready\");\n    } else {\n      log.debug(\"FlexServer is no longer ready\");\n    }\n    this._isReady = value;\n  }\n\n  public checkOptionCombinations() {\n    // Check for some bad combinations we should warn about.\n    const allowedWebhookDomains = appSettings.section(\"integrations\").flag(\"allowedWebhookDomains\").readString({\n      envVar: \"ALLOWED_WEBHOOK_DOMAINS\",\n    });\n    const proxy = appSettings.section(\"integrations\").flag(\"proxy\").readString({\n      envVar: \"GRIST_HTTPS_PROXY\",\n    });\n    // If all webhook targets are accepted, and no proxy is defined, issue\n    // a warning. This warning can be removed by explicitly setting the proxy\n    // to the empty string.\n    if (allowedWebhookDomains === \"*\" && proxy === undefined) {\n      log.warn(\"Setting an ALLOWED_WEBHOOK_DOMAINS wildcard without a GRIST_HTTPS_PROXY exposes your internal network\");\n    }\n  }\n\n  public async start() {\n    if (this._check(\"start\")) { return; }\n\n    const servers = this._createServers();\n    this.server = servers.server;\n    this.httpsServer = servers.httpsServer;\n    await this._startServers(this.server, this.httpsServer, this.name, this.port, true);\n  }\n\n  public addNotifier() {\n    if (this._check(\"notifier\", \"start\", \"homedb\")) { return; }\n    // TODO: make Notifier aware of base domains, rather than sending emails with default\n    // base domain.\n    // Most notifications are ultimately triggered by requests with a base domain in them,\n    // and all that is needed is a refactor to pass that info along.  But there is also the\n    // case of notification(s) from stripe.  May need to associate a preferred base domain\n    // with org/user and persist that?\n    const primaryNotifier = this.create.Notifier(this._dbManager, this);\n    if (primaryNotifier) {\n      this._emitNotifier.setPrimaryNotifier(primaryNotifier);\n    }\n\n    // For doc notifications, if we are a home server, initialize endpoints and job handling.\n    this.getDocNotificationManager()?.initHomeServer(this.app);\n  }\n\n  public addAssistant() {\n    if (this._check(\"assistant\")) { return; }\n    this._assistant = this.create.Assistant(this);\n    if (this._assistant?.version === 2) {\n      this._assistant?.addEndpoints?.(this.app);\n    }\n  }\n\n  public getGristConfig(): GristLoadConfig {\n    return makeGristConfig({\n      homeUrl: this.getDefaultHomeUrl(),\n      extra: {},\n      baseDomain: this._defaultBaseDomain,\n    });\n  }\n\n  /**\n   * Get a url for a team site.\n   */\n  public async getOrgUrl(orgKey: string | number): Promise<string> {\n    const org = await this.getOrg(orgKey);\n    return this.getResourceUrl(org);\n  }\n\n  public async getOrg(orgKey: string | number) {\n    if (!this._dbManager) { throw new Error(\"database missing\"); }\n    const org = await this._dbManager.getOrg({\n      userId: this._dbManager.getPreviewerUserId(),\n      showAll: true,\n    }, orgKey);\n    return this._dbManager.unwrapQueryResult(org);\n  }\n\n  /**\n   * Get a url for an organization, workspace, or document.\n   */\n  public async getResourceUrl(resource: Organization | Workspace | Document,\n    purpose?: \"api\" | \"html\"): Promise<string> {\n    if (!this._dbManager) { throw new Error(\"database missing\"); }\n    const gristConfig = this.getGristConfig();\n    const state: IGristUrlState = {};\n    let org: Organization;\n    if (resource instanceof Organization) {\n      org = resource;\n    } else if (resource instanceof Workspace) {\n      org = resource.org;\n      state.ws = resource.id;\n    } else {\n      org = resource.workspace.org;\n      state.doc = resource.urlId || resource.id;\n      state.slug = getSlugIfNeeded(resource);\n    }\n    state.org = this._dbManager.normalizeOrgDomain(org.id, org.domain, org.ownerId);\n    state.api = purpose === \"api\";\n    if (!gristConfig.homeUrl) { throw new Error(\"Computing a resource URL requires a home URL\"); }\n    return encodeUrl(gristConfig, state, new URL(gristConfig.homeUrl));\n  }\n\n  public addUsage() {\n    if (this._check(\"usage\", \"start\", \"homedb\")) { return; }\n    this.usage = new Usage(this._dbManager);\n  }\n\n  public async addHousekeeper() {\n    if (this._check(\"housekeeper\", \"start\", \"homedb\", \"map\", \"json\", \"api-mw\")) { return; }\n    const store = this._docWorkerMap;\n    this.housekeeper = new Housekeeper(this._dbManager, this, this._internalPermitStore, store);\n    this.housekeeper.addEndpoints(this.app);\n    await this.housekeeper.start();\n  }\n\n  public async startCopy(name2: string, port2: number): Promise<{\n    serverPort: number,\n    httpsServerPort?: number,\n  }> {\n    const servers = this._createServers();\n    return this._startServers(servers.server, servers.httpsServer, name2, port2, true);\n  }\n\n  public addGoogleAuthEndpoint() {\n    if (this._check(\"google-auth\")) { return; }\n    const messagePage = makeMessagePage(getAppPathTo(this.appRoot, \"static\"));\n    addGoogleAuthEndpoint(this.app, messagePage);\n\n    // Sticking the more general OAuth2 clients here because it's a similar type of thing to\n    // Google auth. Should at least clarify the method name.\n    this._oauth2Clients = new OAuth2Clients(this);\n    log.info(\"Configured OAuth2 clients: %s\", this._oauth2Clients.getValidClients());\n    // Use the same middleware as for API calls.\n    const oauth2Middleware = [this._userIdMiddleware, this._trustOriginsMiddleware, disableCache];\n    this._oauth2Clients.attachEndpoints(this.app, oauth2Middleware);\n  }\n\n  /**\n   * Adds early API.\n   *\n   * These API endpoints are intentionally added before other middleware to\n   * minimize the impact of failures during startup. This includes, for\n   * example, endpoints used by the Admin Panel for status checks.\n   *\n   * It's also desirable for some endpoints to be loaded early so that they\n   * can set their own middleware, before any defaults are added.\n   * For example, `addJsonSupport` enforces strict parsing of JSON, but a\n   * handful of endpoints need relaxed parsing (e.g. /configs).\n   */\n  public addEarlyApi() {\n    if (this._check(\"early-api\", \"api-mw\", \"homedb\", \"!json\")) { return; }\n\n    attachEarlyEndpoints({\n      app: this.app,\n      gristServer: this,\n      userIdMiddleware: this._userIdMiddleware,\n    });\n  }\n\n  public addConfigEndpoints() {\n    // Need to be an admin to change the Grist config\n    const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin();\n\n    const configBackendAPI = new ConfigBackendAPI(this.getActivations());\n    configBackendAPI.addEndpoints(this.app, requireInstallAdmin);\n\n    // Some configurations may add extra endpoints. This seems a fine time to add them.\n    this.create.addExtraHomeEndpoints(this, this.app);\n  }\n\n  public getLatestVersionAvailable() {\n    return this._latestVersionAvailable;\n  }\n\n  public setLatestVersionAvailable(latestVersionAvailable: LatestVersionAvailable): void {\n    log.info(`Setting ${latestVersionAvailable.version} as the latest available version`);\n    this._latestVersionAvailable = latestVersionAvailable;\n  }\n\n  public async publishLatestVersionAvailable(latestVersionAvailable: LatestVersionAvailable): Promise<void> {\n    log.info(`Publishing ${latestVersionAvailable.version} as the latest available version`);\n\n    try {\n      await this.getPubSubManager().publish(latestVersionChannel, JSON.stringify(latestVersionAvailable));\n    } catch (error) {\n      log.error(`Error publishing latest version`, { error, latestVersionAvailable });\n    }\n  }\n\n  // Get the HTML template sent for document pages.\n  public async getDocTemplate(): Promise<DocTemplate> {\n    const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, \"static\"),\n      \"app.html\"), \"utf8\");\n    return {\n      page,\n      tag: this.tag,\n    };\n  }\n\n  public getTag(): string {\n    if (!this.tag) {\n      throw new Error(\"getTag called too early\");\n    }\n    return this.tag;\n  }\n\n  /**\n   * Close all documents currently held open.\n   */\n  public async testCloseDocs(): Promise<void> {\n    if (this._docManager) {\n      return this._docManager.shutdownDocs();\n    }\n  }\n\n  /**\n   * Make sure external storage of all docs is up to date.\n   */\n  public async testFlushDocs() {\n    const assignments = await this._docWorkerMap.getAssignments(this.worker.id);\n    for (const assignment of assignments) {\n      await this._storageManager.flushDoc(assignment);\n    }\n  }\n\n  public resolveLoginSystem(): Promise<GristLoginSystem> {\n    return isTestLoginAllowed() ?\n      getTestLoginSystem() : this._getLoginSystem();\n  }\n\n  public addUpdatesCheck() {\n    if (this._check(\"update\", \"json\")) { return; }\n\n    // For now we only are active for sass deployments.\n    if (this._deploymentType !== \"saas\") { return; }\n\n    this._updateManager = new UpdateManager(this.app, this);\n    this._updateManager.addEndpoints();\n  }\n\n  public setRestrictedMode(restrictedMode = true) {\n    this.getHomeDBManager().setReadonly(restrictedMode);\n  }\n\n  public isRestrictedMode() {\n    return this.getHomeDBManager().isReadonly();\n  }\n\n  public onUserChange(callback: (change: UserChange) => Promise<void>) {\n    this._emitNotifier.on(\"userChange\", callback);\n  }\n\n  public onStreamingDestinationsChange(callback: (orgId?: number) => Promise<void>) {\n    this._emitNotifier.on(\"streamingDestinationsChange\", callback);\n  }\n\n  public async getSigninUrl(\n    req: express.Request,\n    options: {\n      signUp?: boolean;\n      nextUrl?: URL;\n      params?: Record<string, string | undefined>;\n    },\n  ) {\n    let { nextUrl, signUp } = options;\n    const { params = {} } = options;\n\n    const mreq = req as RequestWithLogin;\n\n    // This will ensure that express-session will set our cookie if it hasn't already -\n    // we'll need it when we redirect back.\n    forceSessionChange(mreq.session);\n\n    // Redirect to the requested URL after successful login.\n    if (!nextUrl) {\n      const nextPath = optStringParam(req.query.next, \"next\");\n      nextUrl = new URL(getOrgUrl(req, nextPath));\n    }\n    if (signUp === undefined) {\n      // Like redirectToLogin in Authorizer, redirect to sign up if it doesn't look like the\n      // user has ever logged in on this browser.\n      signUp = (mreq.session.users === undefined);\n    }\n    const getRedirectUrl = signUp ? this._getSignUpRedirectUrl : this._getLoginRedirectUrl;\n    const url = new URL(await getRedirectUrl(req, nextUrl));\n    for (const [key, value] of Object.entries(params)) {\n      if (value !== undefined) {\n        url.searchParams.set(key, value);\n      }\n    }\n    return url.href;\n  }\n\n  /**\n   * Returns middleware that adds information about the user to the request.\n   *\n   * Specifically, sets:\n   *   - req.userId: the id of the user in the database users table\n   *   - req.userIsAuthorized: set if user has presented credentials that were accepted\n   *     (the anonymous user has a userId but does not have userIsAuthorized set if,\n   *     as would typically be the case, credentials were not presented)\n   *   - req.users: set for org-and-session-based logins, with list of profiles in session\n   */\n  public getUserIdMiddleware(): express.RequestHandler {\n    return this._userIdMiddleware;\n  }\n\n  // Adds endpoints that support imports and exports.\n  private _addSupportPaths(docAccessMiddleware: express.RequestHandler[]) {\n    if (!this._docWorker) { throw new Error(\"need DocWorker\"); }\n\n    const basicMiddleware = [this._userIdMiddleware, this.tagChecker.requireTag];\n\n    // Add the handling for the /upload route. Most uploads are meant for a DocWorker: they are put\n    // in temporary files, and the DocWorker needs to be on the same machine to have access to them.\n    // This doesn't check for doc access permissions because the request isn't tied to a document.\n    addUploadRoute(this, this.app, this._docWorkerMap, this._trustOriginsMiddleware, ...basicMiddleware);\n\n    this.app.get(\"/attachment\", ...docAccessMiddleware,\n      expressWrap(async (req, res) => this._docWorker.getAttachment(req, res)));\n  }\n\n  private _check(part: Part, ...precedents: (CheckKey | null)[]) {\n    if (this.deps.has(part)) { return true; }\n    for (const precedent of precedents) {\n      if (!precedent) { continue; }\n      if (precedent.startsWith(\"!\")) {\n        const antecedent = precedent.slice(1);\n        if (this._has(antecedent)) {\n          throw new Error(`${part} is needed before ${antecedent}`);\n        }\n      } else if (!this._has(precedent)) {\n        throw new Error(`${precedent} is needed before ${part}`);\n      }\n    }\n    this.deps.add(part);\n    return false;\n  }\n\n  private _has(part: string) {\n    return this.deps.has(part);\n  }\n\n  private async _addSelfAsWorker(workers: IDocWorkerMap): Promise<string> {\n    try {\n      this._healthy = true;\n      // Check if this is the first time calling this method.  In production,\n      // it always will be.  In testing, we may disconnect and reconnect the\n      // worker.  We only need to determine docWorkerId and this.worker once.\n      if (!this.worker) {\n        if (process.env.GRIST_ROUTER_URL) {\n          // register ourselves with the load balancer first.\n          const w = await this.createWorkerUrl();\n          const url = `${w.url}/v/${this.tag}/`;\n          // TODO: we could compute a distinct internal url here.\n          this.worker = {\n            id: w.host,\n            publicUrl: url,\n            internalUrl: url,\n          };\n        } else {\n          const url = (process.env.APP_DOC_URL || this.getOwnUrl()) + `/v/${this.tag}/`;\n          this.worker = {\n            // The worker id should be unique to this worker.\n            id: process.env.GRIST_DOC_WORKER_ID || `testDocWorkerId_${this.port}`,\n            publicUrl: url,\n            internalUrl: process.env.APP_DOC_INTERNAL_URL || url,\n          };\n        }\n        this.info.push([\"docWorkerId\", this.worker.id]);\n\n        if (process.env.GRIST_WORKER_GROUP) {\n          this.worker.group = process.env.GRIST_WORKER_GROUP;\n        }\n      } else {\n        if (process.env.GRIST_ROUTER_URL) {\n          await this.createWorkerUrl();\n        }\n      }\n      await workers.addWorker(this.worker);\n      await workers.setWorkerAvailability(this.worker.id, true);\n    } catch (err) {\n      this._healthy = false;\n      throw err;\n    }\n    return this.worker.id;\n  }\n\n  private async _removeSelfAsWorker(workers: IDocWorkerMap, docWorkerId: string) {\n    this._healthy = false;\n    this._docWorkerLoadTracker?.stop();\n    await workers.removeWorker(docWorkerId);\n    if (process.env.GRIST_ROUTER_URL) {\n      await axios.get(process.env.GRIST_ROUTER_URL,\n        { params: { act: \"remove\", port: this.getOwnPort() } });\n      log.info(`DocWorker unregistered itself via ${process.env.GRIST_ROUTER_URL}`);\n    }\n  }\n\n  // Called when server is shutting down.  Save any state that needs saving, and\n  // disentangle ourselves from outside world.\n  private async _shutdown(): Promise<void> {\n    if (!this.worker) { return; }\n    if (!this._storageManager) { return; }\n    if (!this._docWorkerMap) { return; }  // but this should never happen.\n\n    const workers = this._docWorkerMap;\n\n    // Pick up the pace on saving documents.\n    this._storageManager.prepareToCloseStorage();\n\n    // We urgently want to disable any new assignments.\n    await workers.setWorkerAvailability(this.worker.id, false);\n\n    // Enumerate the documents we are responsible for.\n    let assignments = await workers.getAssignments(this.worker.id);\n    let retries: number = 0;\n    while (assignments.length > 0 && retries < 3) {\n      await Promise.all(assignments.map(async (assignment) => {\n        log.info(\"FlexServer shutdown assignment\", assignment);\n        try {\n        // Start sending the doc to S3 if needed.\n          const flushOp = this._storageManager.closeDocument(assignment);\n\n          // Get access to the clients of this document.  This has the side\n          // effect of waiting for the ActiveDoc to finish initialization.\n          // This could include loading it from S3, an operation we could\n          // potentially abort as an optimization.\n          // TODO: abort any s3 loading as an optimization.\n          const docPromise = this._docManager.getActiveDoc(assignment);\n          const doc = docPromise && await docPromise;\n\n          await flushOp;\n          // At this instant, S3 and local document should be the same.\n\n          // We'd now like to make sure (synchronously) that:\n          //  - we never output anything new to S3 about this document.\n          //  - we never output anything new to user about this document.\n          // There could be asynchronous operations going on related to\n          // these documents, but if we can make sure that their effects\n          // do not reach the outside world then we can ignore them.\n          if (doc) {\n            doc.docClients.interruptAllClients();\n            doc.setMuted();\n          }\n\n          // Release this document for other workers to pick up.\n          // There is a small window of time here in which a client\n          // could reconnect to us.  The muted ActiveDoc will result\n          // in them being dropped again.\n          await workers.releaseAssignment(this.worker.id, assignment);\n        } catch (err) {\n          log.info(\"problem dealing with assignment\", assignment, err);\n        }\n      }));\n      // Check for any assignments that slipped through at the last minute.\n      assignments = await workers.getAssignments(this.worker.id);\n      retries++;\n    }\n    if (assignments.length > 0) {\n      log.error(\"FlexServer shutdown failed to release assignments:\", assignments);\n    }\n\n    await this._removeSelfAsWorker(workers, this.worker.id);\n    try {\n      await this._docManager.shutdownAll();\n    } catch (err) {\n      log.error(\"FlexServer shutdown problem\", err);\n    }\n    if (this._comm) {\n      this._comm.destroyAllClients();\n    }\n    log.info(\"FlexServer shutdown is complete\");\n  }\n\n  /**\n   * Middleware that redirects a request with a userId but without an org to an org-specific URL,\n   * after looking up the first org for this userId in DB.\n   */\n  private async _redirectToOrg(req: express.Request, resp: express.Response, next: express.NextFunction) {\n    const mreq = req as RequestWithLogin;\n    if (mreq.org || !mreq.userId) { return next(); }\n\n    // Redirect anonymous users to the merged org.\n    if (!mreq.userIsAuthorized) {\n      const redirectUrl = this.getMergedOrgUrl(mreq);\n      log.debug(`Redirecting anonymous user to: ${redirectUrl}`);\n      return resp.redirect(redirectUrl);\n    }\n\n    // We have a userId, but the request is for an unknown org. Redirect to an org that's\n    // available to the user. This matters in dev, and in prod when visiting a generic URL, which\n    // will here redirect to e.g. the user's personal org.\n    const result = await this._dbManager.getMergedOrgs(mreq.userId, mreq.userId, null);\n    const orgs = (result.status === 200) ? result.data : null;\n    const subdomain = orgs && orgs.length > 0 ? orgs[0].domain : null;\n    const redirectUrl = subdomain && this._getOrgRedirectUrl(mreq, subdomain);\n    if (redirectUrl) {\n      log.debug(`Redirecting userId ${mreq.userId} to: ${redirectUrl}`);\n      return resp.redirect(redirectUrl);\n    }\n    next();\n  }\n\n  /**\n   * Given a Request and a desired subdomain, returns a URL for a similar request that specifies that\n   * subdomain either in the hostname or in the path. Optionally passing pathname overrides url's\n   * path.\n   */\n  private _getOrgRedirectUrl(req: RequestWithLogin, subdomain: string, pathname: string = req.originalUrl): string {\n    const config = this.getGristConfig();\n    const { hostname, orgInPath } = getOrgUrlInfo(subdomain, req.get(\"host\")!, config);\n    const redirectUrl = new URL(pathname, getOriginUrl(req));\n    if (hostname) {\n      redirectUrl.hostname = hostname;\n    }\n    if (orgInPath) {\n      redirectUrl.pathname = `/o/${orgInPath}` + redirectUrl.pathname;\n    }\n    return redirectUrl.href;\n  }\n\n  // Create and initialize the plugin manager\n  private async _addPluginManager() {\n    if (this._pluginManager) { return this._pluginManager; }\n    // Only used as {userRoot}/plugins as a place for plugins in addition to {appRoot}/plugins\n    const userRoot = path.resolve(process.env.GRIST_USER_ROOT || getAppPathTo(this.appRoot, \".grist\"));\n    this.info.push([\"userRoot\", userRoot]);\n    // Some custom widgets may be included as an npm package called @gristlabs/grist-widget.\n    // The package doesn't actually  contain node code, but should be in the same vicinity\n    // as other packages that do, so we can use require.resolve on one of them to find it.\n    // This seems a little overcomplicated, but works well when grist-core is bundled within\n    // a larger project like grist-electron.\n    // TODO: maybe add a little node code to @gristlabs/grist-widget so it can be resolved\n    // directly?\n    const gristLabsModules = path.dirname(path.dirname(require.resolve(\"@gristlabs/express-session\")));\n    const bundledRoot = isAffirmative(process.env.GRIST_SKIP_BUNDLED_WIDGETS) ? undefined : path.join(\n      gristLabsModules, \"grist-widget\", \"dist\",\n    );\n    this.info.push([\"bundledRoot\", bundledRoot]);\n    const pluginManager = new PluginManager(this.appRoot, userRoot, bundledRoot);\n    // `initialize()` is asynchronous and reads plugins manifests; if PluginManager is used before it\n    // finishes, it will act as if there are no plugins.\n    // ^ I think this comment was here to justify calling initialize without waiting for\n    // the result.  I'm just going to wait, for determinism.\n    await pluginManager.initialize();\n    this._pluginManager = pluginManager;\n    return pluginManager;\n  }\n\n  // Serve the static app.html proxied for a document.\n  private _serveDocPage() {\n    // Serve the static app.html file.\n    // TODO: We should be the ones to fill in the base href here to ensure that the browser fetches\n    // the correct version of static files for this app.html.\n    this.app.get(\"/:docId/app.html\", this._userIdMiddleware, expressWrap(async (req, res) => {\n      res.json(await this.getDocTemplate());\n    }));\n  }\n\n  // Check whether logger should skip a line.  Careful, req and res are morgan-specific\n  // types, not Express.\n  private _shouldSkipRequestLogging(req: { url: string }, res: { statusCode: number }) {\n    if (req.url === \"/status\" && [200, 304].includes(res.statusCode) &&\n      this._healthCheckCounter > HEALTH_CHECK_LOG_SHOW_FIRST_N &&\n      this._healthCheckCounter % HEALTH_CHECK_LOG_SHOW_EVERY_N !== 1) {\n      return true;\n    }\n    return false;\n  }\n\n  private _createServers() {\n    // Start the app.\n    const server = logServer(http.createServer(getServerFlags(), this.app));\n    let httpsServer;\n    if (TEST_HTTPS_OFFSET) {\n      const certFile = process.env.GRIST_TEST_SSL_CERT;\n      const privateKeyFile = process.env.GRIST_TEST_SSL_KEY;\n      if (!certFile) { throw new Error(\"Set GRIST_TEST_SSL_CERT to location of certificate file\"); }\n      if (!privateKeyFile) { throw new Error(\"Set GRIST_TEST_SSL_KEY to location of private key file\"); }\n      log.debug(`https support: reading cert from ${certFile}`);\n      log.debug(`https support: reading private key from ${privateKeyFile}`);\n      httpsServer = logServer(https.createServer({\n        ...getServerFlags(),\n        key: fse.readFileSync(privateKeyFile, \"utf8\"),\n        cert: fse.readFileSync(certFile, \"utf8\"),\n      }, this.app));\n    }\n    return { server, httpsServer };\n  }\n\n  private async _startServers(server: http.Server, httpsServer: https.Server | undefined,\n    name: string, port: number, verbose: boolean) {\n    await listenPromise(server.listen(port, this.host));\n    const serverPort = (server.address() as AddressInfo).port;\n    if (verbose) { log.info(`${name} available at ${this.host}:${serverPort}`); }\n    let httpsServerPort: number | undefined;\n    if (TEST_HTTPS_OFFSET && httpsServer) {\n      if (port === 0) { throw new Error(\"cannot use https with OS-assigned port\"); }\n      httpsServerPort = port + TEST_HTTPS_OFFSET;\n      await listenPromise(httpsServer.listen(httpsServerPort, this.host));\n      if (verbose) { log.info(`${name} available at https://${this.host}:${httpsServerPort}`); }\n    }\n    return {\n      serverPort,\n      httpsServerPort,\n    };\n  }\n\n  private async _recordNewUserInfo(row: object) {\n    const urlId = DOC_ID_NEW_USER_INFO;\n    // If nowhere to record data, return immediately.\n    if (!urlId) { return; }\n    let body: string | undefined;\n    let permitKey: string | undefined;\n    try {\n      body = JSON.stringify(mapValues(row, value => [value]));\n\n      // Take an extra step to translate the special urlId to a docId. This is helpful to\n      // allow the same urlId to be used in production and in test. We need the docId for the\n      // specialPermit below, which we need to be able to write to this doc.\n      //\n      // TODO With proper forms support, we could give an origin-based permission to submit a\n      // form to this doc, and do it from the client directly.\n      const previewerUserId = this._dbManager.getPreviewerUserId();\n      const docAuth = await this._dbManager.getDocAuthCached({ urlId, userId: previewerUserId });\n      const docId = docAuth.docId;\n      if (!docId) {\n        throw new Error(`Can't resolve ${urlId}: ${docAuth.error}`);\n      }\n\n      permitKey = await this._internalPermitStore.setPermit({ docId });\n      const res = await fetch(await this.getHomeUrlByDocId(docId, `/api/docs/${docId}/tables/Responses/data`), {\n        method: \"POST\",\n        headers: { \"Permit\": permitKey, \"Content-Type\": \"application/json\" },\n        body,\n      });\n      if (res.status !== 200) {\n        throw new Error(`API call failed with ${res.status}`);\n      }\n    } finally {\n      if (permitKey) {\n        await this._internalPermitStore.removePermit(permitKey);\n      }\n    }\n  }\n\n  /**\n   * If signUp is true, redirect to signUp.\n   * If signUp is false, redirect to login.\n   * If signUp is not set, redirect to signUp if no cookie found, else login.\n   *\n   * If nextUrl is not supplied, it will be constructed from a path in\n   * the \"next\" query parameter.\n   */\n  private async _redirectToLoginOrSignup(\n    options: {\n      signUp?: boolean;\n      nextUrl?: URL;\n      params?: Record<string, string | undefined>;\n    },\n    req: express.Request, resp: express.Response,\n  ) {\n    const url = await this.getSigninUrl(req, options);\n    resp.redirect(url);\n  }\n\n  private async _redirectToHomeOrWelcomePage(\n    mreq: RequestWithLogin,\n    resp: express.Response,\n    options: { redirectToMergedOrg?: boolean } = {},\n  ) {\n    const { redirectToMergedOrg } = options;\n    const userId = getUserId(mreq);\n    const domain = getOrgFromRequest(mreq);\n    const orgs = this._dbManager.unwrapQueryResult(\n      await this._dbManager.getOrgs(userId, domain, {\n        ignoreEveryoneShares: true,\n      }),\n    );\n    if (orgs.length > 1) {\n      resp.redirect(getOrgUrl(mreq, \"/welcome/teams\"));\n    } else {\n      resp.redirect(redirectToMergedOrg ? this.getMergedOrgUrl(mreq) : getOrgUrl(mreq));\n    }\n  }\n\n  /**\n   * If a valid cookie was set during sign-up to copy a document to the\n   * user's Home workspace, copy it and return the id of the new document.\n   *\n   * If a valid cookie wasn't set or copying failed, return `null`.\n   */\n  private async _maybeCopyDocToHomeWorkspace(\n    req: RequestWithLogin,\n    resp: express.Response,\n  ): Promise<string | null> {\n    const state = getAndClearSignupStateCookie(req, resp);\n    if (!state) {\n      return null;\n    }\n\n    const { srcDocId } = state;\n    if (!srcDocId) { return null; }\n\n    let newDocId: string | null = null;\n    try {\n      newDocId = await createSavedDoc(this, req, { srcDocId });\n    } catch (e) {\n      log.error(`FlexServer failed to copy doc ${srcDocId} to Home workspace`, e);\n    }\n    return newDocId;\n  }\n\n  /**\n   * Creates set of middleware for handling logout requests and clears session. Used in any endpoint\n   * or a page that needs to log out the user and clear the session.\n   */\n  private _logoutMiddleware() {\n    const sessionClearMiddleware = expressWrap(async (req, resp, next) => {\n      const scopedSession = this._sessions.getOrCreateSessionFromRequest(req);\n      // Clear session so that user needs to log in again at the next request.\n      // SAML logout in theory uses userSession, so clear it AFTER we compute the URL.\n      // Express-session will save these changes.\n      const expressSession = (req as RequestWithLogin).session;\n      if (expressSession) { expressSession.users = []; expressSession.orgToUser = {}; }\n      await scopedSession.clearScopedSession(req);\n      // TODO: limit cache clearing to specific user.\n      this._sessions.clearCacheIfNeeded();\n      next();\n    });\n    const pluggedMiddleware = this._loginMiddleware.getLogoutMiddleware ?\n      this._loginMiddleware.getLogoutMiddleware() :\n      [];\n    return [...pluggedMiddleware, sessionClearMiddleware];\n  }\n\n  /**\n   * Returns true if GRIST_LOG_HTTP=\"true\" (or any truthy value).\n   * Returns true if GRIST_LOG_SKIP_HTTP=\"\" (empty string).\n   * Returns false otherwise.\n   *\n   * Also displays a deprecation warning if GRIST_LOG_SKIP_HTTP is set to any value (\"\", \"true\", whatever...),\n   * and throws an exception if GRIST_LOG_SKIP_HTTP and GRIST_LOG_HTTP are both set to make the server crash.\n   */\n  private _httpLoggingEnabled(): boolean {\n    const deprecatedOptionEnablesLog = process.env.GRIST_LOG_SKIP_HTTP === \"\";\n    const isGristLogHttpEnabled = isAffirmative(process.env.GRIST_LOG_HTTP);\n\n    if (process.env.GRIST_LOG_HTTP !== undefined && process.env.GRIST_LOG_SKIP_HTTP !== undefined) {\n      throw new Error(\"Both GRIST_LOG_HTTP and GRIST_LOG_SKIP_HTTP are set. \" +\n        \"Please remove GRIST_LOG_SKIP_HTTP and set GRIST_LOG_HTTP to the value you actually want.\");\n    }\n\n    if (process.env.GRIST_LOG_SKIP_HTTP !== undefined) {\n      const expectedGristLogHttpVal = deprecatedOptionEnablesLog ? \"true\" : \"false\";\n\n      log.warn(`Setting env variable GRIST_LOG_SKIP_HTTP=\"${process.env.GRIST_LOG_SKIP_HTTP}\" ` +\n        `is deprecated in favor of GRIST_LOG_HTTP=\"${expectedGristLogHttpVal}\"`);\n    }\n\n    return isGristLogHttpEnabled || deprecatedOptionEnablesLog;\n  }\n\n  private _logDeleteUserEvents(req: RequestWithLogin, user: User) {\n    this.getAuditLogger().logEvent(req, {\n      action: \"user.delete\",\n      details: {\n        user: {\n          ...pick(user, \"id\", \"name\"),\n          email: user.loginEmail,\n        },\n      },\n    });\n    this.getTelemetry().logEvent(req, \"deletedAccount\");\n  }\n\n  private _logDeleteSiteEvents(req: RequestWithLogin, org: Organization) {\n    this.getAuditLogger().logEvent(req, {\n      action: \"site.delete\",\n      details: {\n        site: pick(org, \"id\", \"name\", \"domain\"),\n      },\n    });\n    this.getTelemetry().logEvent(req, \"deletedSite\", {\n      full: {\n        siteId: org.id,\n        userId: req.userId,\n      },\n    });\n  }\n}\n\n/**\n * Set flags on the server, related to timeouts.\n * Note if you try to set very long timeouts, e.g. for a gnarly\n * import, you may run into browser limits. In firefox a relevant\n * configuration variable is network.http.response.timeout -\n * if you set that high, and set the flags here high, and\n * set everything right in your reverse proxy, you should\n * be able to have very long imports. (Clearly, it would be\n * better if long imports were made using a mechanism that\n * isn't just a single http request)\n */\nfunction getServerFlags(): https.ServerOptions {\n  const flags: https.ServerOptions = {};\n\n  // We used to set the socket timeout to 0, but that has been\n  // the default now since Node 13.\n\n  // The default timeouts that follow have a convoluted history.\n  // Basically, Grist Labs had a SaaS with a load balancer\n  // configured to have a 5 min idle timeout. It starts there.\n\n  // Then, there was a complicated issue:\n  //   https://adamcrowder.net/posts/node-express-api-and-aws-alb-502/\n  // which meant that the Grist server's keepAlive timeout should be\n  // longer than the load-balancer's. Otherwise it would produce occasional\n  // 502 errors when it sends a request to node just as node closes a\n  // connection.\n  // So keepAliveTimeout was set to 5*60+5 seconds.\n\n  // Then, there was another complicated issue:\n  //   https://github.com/nodejs/node/issues/27363\n  // which meant that the headersTimeout should be set higher than\n  // the keepAliveTimeout.\n  // So headersTimeout was set to 5*60+6 seconds.\n\n  // Node 18 introduced a requestTimeout that defaults to 5 minutes.\n  // That timeout is supposed to be longer than or same as headersTimeout.\n  // So requestTimeout is set to 5*60+6 seconds.\n\n  // Long story short, it is good to have these timeouts be longish\n  // so imports don't get interrupted too early (but Grist should\n  // probably change how long uploads are done).\n\n  const requestTimeoutMs = appSettings.section(\"server\").flag(\"requestTimeoutMs\").requireInt({\n    envVar: \"GRIST_REQUEST_TIMEOUT_MS\",\n    defaultValue: 306000,\n  });\n  flags.requestTimeout = requestTimeoutMs;\n\n  const headersTimeoutMs = appSettings.section(\"server\").flag(\"headersTimeoutMs\").requireInt({\n    envVar: \"GRIST_HEADERS_TIMEOUT_MS\",\n    defaultValue: 306000,\n  });\n  flags.headersTimeout = headersTimeoutMs;\n\n  // Likewise keepAlive\n  const keepAliveTimeoutMs = appSettings.section(\"server\").flag(\"keepAliveTimeoutMs\").requireInt({\n    envVar: \"GRIST_KEEP_ALIVE_TIMEOUT_MS\",\n    defaultValue: 305000,\n  });\n  flags.keepAliveTimeout = keepAliveTimeoutMs;\n\n  return flags;\n}\n\n/**\n * log some properties of the server.\n */\nfunction logServer<T extends https.Server | http.Server>(server: T): T {\n  log.info(\"Server timeouts: requestTimeout %s keepAliveTimeout %s headersTimeout %s\",\n    server.requestTimeout, server.keepAliveTimeout, server.headersTimeout);\n  return server;\n}\n\n// Returns true if environment is configured to allow unauthenticated test logins.\nfunction isTestLoginAllowed() {\n  return isAffirmative(process.env.GRIST_TEST_LOGIN);\n}\n\n// Check OPTIONS requests for allowed origins, and return heads to allow the browser to proceed\n// with a POST (or other method) request.\nfunction trustOriginHandler(req: express.Request, res: express.Response, next: express.NextFunction) {\n  res.header(\"Access-Control-Allow-Methods\", \"GET, PATCH, PUT, POST, DELETE, OPTIONS\");\n  if (trustOrigin(req, res)) {\n    res.header(\"Access-Control-Allow-Credentials\", \"true\");\n    res.header(\"Access-Control-Allow-Headers\", \"Authorization, Content-Type, X-Requested-With\");\n  } else {\n    // Any origin is allowed, but if it isn't trusted, then we don't allow credentials,\n    // i.e. no Cookie or Authorization header.\n    res.header(\"Access-Control-Allow-Origin\", \"*\");\n    res.header(\"Access-Control-Allow-Headers\", \"Content-Type, X-Requested-With\");\n    if (req.get(\"Cookie\") || req.get(\"Authorization\")) {\n      // In practice we don't expect to actually reach this point,\n      // as the browser should not include credentials in preflight (OPTIONS) requests,\n      // and should block real requests with credentials based on the preflight response.\n      // But having this means not having to rely on our understanding of browsers and CORS too much.\n      throw new ApiError(\"Credentials not supported for cross-origin requests\", 403);\n    }\n  }\n  if (\"OPTIONS\" === req.method) {\n    res.sendStatus(200);\n  } else {\n    next();\n  }\n}\n\n// Methods that Electron app relies on.\nexport interface ElectronServerMethods {\n  onDocOpen(cb: (filePath: string) => void): void;\n  getUserConfig(): Promise<any>;\n  updateUserConfig(obj: any): Promise<void>;\n  onBackupMade(cb: () => void): void;\n}\n\n// Allow static files to be requested from any origin.\nconst serveAnyOrigin: serveStatic.ServeStaticOptions = {\n  setHeaders: (res, filepath, stat) => {\n    res.setHeader(\"Access-Control-Allow-Origin\", \"*\");\n  },\n};\n\ntype Part =\n  \"activation\" |\n  \"api\" |\n  \"api-error\" |\n  \"api-mw\" |\n  \"assistant\" |\n  \"audit-logger\" |\n  \"billing-api\" |\n  \"boot\" |\n  \"cleanup\" |\n  \"clientSecret\" |\n  \"comm\" |\n  \"dir\" |\n  \"doc\" |\n  \"doc_api_forwarder\" |\n  \"early-api\" |\n  \"google-auth\" |\n  \"health\" |\n  \"homedb\" |\n  \"hosts\" |\n  \"housekeeper\" |\n  \"json\" |\n  \"landing\" |\n  \"log-endpoint\" |\n  \"logging\" |\n  \"login\" |\n  \"loginMiddleware\" |\n  \"map\" |\n  \"middleware\" |\n  \"notifier\" |\n  \"org\" |\n  \"pluginUntaggedAssets\" |\n  \"router\" |\n  \"scim\" |\n  \"sessions\" |\n  \"start\" |\n  \"static_and_bower\" |\n  \"strip_dw\" |\n  \"tag\" |\n  \"telemetry\" |\n  \"testAssets\" |\n  \"testinghooks\" |\n  \"update\" |\n  \"usage\" |\n  \"webhooks\" |\n  \"widgets\";\n\ntype CheckKey = Part | `!${Part}`;\n"
  },
  {
    "path": "app/server/lib/ForwardAuthLogin.ts",
    "content": "/**\n * To read more about setting up the Forward Auth flow and how to configure it through environmental variables, in a\n * single server setup, please visit:\n * https://support.getgrist.com/install/forwarded-headers\n */\n\nimport { ApiError } from \"app/common/ApiError\";\nimport { FORWARD_AUTH_PROVIDER_KEY } from \"app/common/loginProviders\";\nimport { AppSettings } from \"app/server/lib/AppSettings\";\nimport { getRequestProfile } from \"app/server/lib/Authorizer\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { GristLoginMiddleware, GristLoginSystem, GristServer, setUserInSession } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport { createLoginProviderFactory, NotConfiguredError } from \"app/server/lib/loginSystemHelpers\";\nimport { optStringParam } from \"app/server/lib/requestUtils\";\n\nimport * as express from \"express\";\nimport trimEnd from \"lodash/trimEnd\";\nimport trimStart from \"lodash/trimStart\";\n\n/**\n * Return a login system that can work in concert with middleware that\n * does authentication and then passes identity in a header.\n * There are two modes of operation, distinguished by whether GRIST_IGNORE_SESSION is set.\n *\n * 1. With sessions, and forward-auth on login endpoints.\n *\n *    For example, using traefik reverse proxy with traefik-forward-auth middleware:\n *\n *      https://github.com/thomseddon/traefik-forward-auth\n *\n *    Grist environment:\n *    - GRIST_FORWARD_AUTH_HEADER: set to a header that will contain\n *      authorized user emails, say \"x-forwarded-user\"\n *    - GRIST_FORWARD_AUTH_LOGOUT_PATH: set to a path that will trigger\n *      a logout (for traefik-forward-auth by default that is /_oauth/logout).\n *    - GRIST_FORWARD_AUTH_LOGIN_PATH: optionally set to override the default (/auth/login).\n *    - GRIST_IGNORE_SESSION: do NOT set, or set to a falsy value.\n *\n *    Reverse proxy:\n *    - Make sure your reverse proxy applies the forward auth middleware to\n *      GRIST_FORWARD_AUTH_LOGIN_PATH and GRIST_FORWARD_AUTH_LOGOUT_PATH.\n *    - If you want to allow anonymous access in some cases, make sure all\n *      other paths are free of the forward auth middleware - Grist will\n *      trigger it as needed by redirecting to /auth/login.\n *    - Grist only uses the configured header at login/logout. Once the user is logged in, Grist\n *      will use the session info to identify the user, until logout.\n *    - Optionally, tell the middleware where to forward back to after logout.\n *      (For traefik-forward-auth, you'd run it with LOGOUT_REDIRECT set to .../signed-out)\n *\n * 2. With no sessions, and forward-auth on all endpoints.\n *\n *    For example, using HTTP Basic Auth and server configuration that sets a header to the\n *    logged-in user (e.g. to REMOTE_USER with Apache).\n *\n *    Grist environment:\n *   - GRIST_IGNORE_SESSION: set to true. Grist sessions will not be used.\n *   - GRIST_FORWARD_AUTH_HEADER: set to to a header that will contain authorized user emails, say\n *     \"x-remote-user\".\n *\n *   Reverse proxy:\n *   - Make sure your reverse proxy sets the header you specified for all requests that may need\n *     login information. It is imperative that this header cannot be spoofed by the user, since\n *     Grist will trust whatever is in it.\n *\n * GRIST_PROXY_AUTH_HEADER is deprecated in favor of GRIST_FORWARD_AUTH_HEADER. It is currently\n * interpreted as a synonym, with a warning, but support for it may be dropped.\n *\n * Redirection logic currently assumes a single-site installation.\n */\n\n/**\n * Interface for Forward Auth configuration.\n */\nexport interface ForwardAuthConfig {\n  /** Header that will contain authorized user emails (e.g., \"x-forwarded-user\" or \"x-remote-user\"). */\n  readonly header: string;\n  /** Path that will trigger a logout (e.g., \"/_oauth/logout\" for traefik-forward-auth). */\n  readonly logoutPath: string;\n  /** Path for the login endpoint. */\n  readonly loginPath: string;\n  /** If true, Grist sessions will not be used, and the header will be checked on every request. */\n  readonly skipSession: boolean;\n}\n\n/**\n * Read Forward Auth configuration from application settings.\n * This reads configuration from env vars - should only be called when ForwardAuth is enabled.\n */\nexport function readForwardAuthConfigFromSettings(settings: AppSettings): ForwardAuthConfig {\n  const section = settings.section(\"login\").section(\"system\").section(FORWARD_AUTH_PROVIDER_KEY);\n  const headerSetting = section.flag(\"header\");\n\n  let header = \"\";\n  try {\n    header = headerSetting.requireString({\n      envVar: [\"GRIST_FORWARD_AUTH_HEADER\", \"GRIST_PROXY_AUTH_HEADER\"],\n    });\n  } catch (e) {\n    throw new NotConfiguredError((e as Error).message);\n  }\n\n  if (headerSetting.describe().foundInEnvVar === \"GRIST_PROXY_AUTH_HEADER\") {\n    log.warn(\"GRIST_PROXY_AUTH_HEADER is deprecated; interpreted as a synonym of GRIST_FORWARD_AUTH_HEADER\");\n  }\n\n  const logoutPath = section.flag(\"logoutPath\").requireString({\n    envVar: \"GRIST_FORWARD_AUTH_LOGOUT_PATH\",\n    defaultValue: \"\",\n  });\n\n  const loginPath = section.flag(\"loginPath\").requireString({\n    envVar: \"GRIST_FORWARD_AUTH_LOGIN_PATH\",\n    defaultValue: \"/auth/login\",\n  });\n\n  const skipSession = settings.section(\"login\").flag(\"skipSession\").readBool({\n    envVar: \"GRIST_IGNORE_SESSION\",\n    defaultValue: false,\n  }) || false;\n\n  return { header, logoutPath, loginPath, skipSession };\n}\n\nasync function getLoginSystem(settings: AppSettings): Promise<GristLoginSystem> {\n  const config = readForwardAuthConfigFromSettings(settings);\n  const { header, logoutPath, loginPath, skipSession } = config;\n\n  return {\n    async getMiddleware(gristServer: GristServer) {\n      async function getLoginRedirectUrl(req: express.Request, url: URL)  {\n        const target = new URL(trimEnd(gristServer.getHomeUrl(req), \"/\") +\n          \"/\" + trimStart(loginPath, \"/\"));\n        // In lieu of sanitizing the next url, we include only the path\n        // component. This will only work for single-domain installations.\n        target.searchParams.append(\"next\", url.pathname);\n        return target.href;\n      }\n      const middleware: GristLoginMiddleware = {\n        getLoginRedirectUrl,\n        getSignUpRedirectUrl: getLoginRedirectUrl,\n        async getLogoutRedirectUrl(req: express.Request) {\n          return trimEnd(gristServer.getHomeUrl(req), \"/\") + \"/\" +\n            trimStart(logoutPath, \"/\");\n        },\n        async addEndpoints(app: express.Express) {\n          app.get(loginPath, expressWrap(async (req, res) => {\n            const profile = getRequestProfile(req, header);\n            if (!profile) {\n              throw new ApiError(\"cannot find user\", 401);\n            }\n            await setUserInSession(req, gristServer, profile);\n            const target = new URL(gristServer.getHomeUrl(req));\n            const next = optStringParam(req.query.next, \"next\");\n            if (next) {\n              target.pathname = next;\n            }\n            res.redirect(target.href);\n          }));\n          return FORWARD_AUTH_PROVIDER_KEY;\n        },\n      };\n      if (skipSession) {\n        // With GRIST_IGNORE_SESSION, respect the header for all requests.\n        middleware.overrideProfile = async req => getRequestProfile(req, header);\n      }\n      return middleware;\n    },\n    async deleteUser() {\n      // If we could delete the user account in the external\n      // authentication system, this is our chance - but we can't.\n    },\n  };\n}\n\nexport const getForwardAuthLoginSystem = createLoginProviderFactory(\n  FORWARD_AUTH_PROVIDER_KEY,\n  getLoginSystem,\n);\n"
  },
  {
    "path": "app/server/lib/GetGristComConfig.ts",
    "content": "/**\n * Configuration for GetGrist.com login system.\n * This is essentially a preconfigured OIDC provider that connects to getgrist.com's authentication service.\n *\n * Expected environment variables:\n *    env GRIST_GETGRISTCOM_KEY\n *        The API key/client secret for authenticating with getgrist.com.\n *        This variable enables the GetGrist.com login system.\n */\n\nimport { GETGRIST_COM_PROVIDER_KEY } from \"app/common/loginProviders\";\nimport { appSettings, AppSettings } from \"app/server/lib/AppSettings\";\nimport { GristLoginSystem, GristServer } from \"app/server/lib/GristServer\";\nimport { createLoginProviderFactory, NotConfiguredError } from \"app/server/lib/loginSystemHelpers\";\nimport { OIDCBuilder, OIDCConfig } from \"app/server/lib/OIDCConfig\";\nimport { stringParam } from \"app/server/lib/requestUtils\";\n\nimport * as express from \"express\";\n\n/**\n * Read GetGrist.com configuration from application settings.\n */\nexport function readGetGristComConfigFromSettings(settings: AppSettings): OIDCConfig {\n  const spHost = getGetGristComHost(settings);\n  const secretJson = getDecodedGetGristSecretJson(settings);\n\n  const config: OIDCConfig = {\n    clientId: stringParam(secretJson.oidcClientId, \"oidcClientId\"),\n    clientSecret: stringParam(secretJson.oidcClientSecret, \"oidcClientSecret\"),\n    issuerUrl: stringParam(secretJson.oidcIssuer, \"oidcIssuer\"),\n    spHost: spHost,\n    scopes: \"openid email profile\",\n    emailPropertyKey: \"email\",\n    endSessionEndpoint: secretJson.oidcEndSessionEndpoint || \"\",\n    skipEndSessionEndpoint: !!secretJson.oidcSkipEndSessionEndpoint,\n    ignoreEmailVerified: false,\n    extraMetadata: {},\n    enabledProtections: new Set([\"PKCE\", \"STATE\"]),\n  };\n\n  return config;\n}\n\n/**\n * Read GetGrist.com metadata from application settings.\n */\nexport function readGetGristComMetadata(settings: AppSettings): Record<string, any> {\n  const secretJson = getDecodedGetGristSecretJson(settings);\n\n  const metadata = {\n    owner: secretJson.owner ?? null,\n  };\n\n  return metadata;\n}\n\n/**\n * Return the decoded GetGrist.com secret JSON.\n */\nfunction getDecodedGetGristSecretJson(settings: AppSettings) {\n  const section = settings.section(\"login\").section(\"system\").section(GETGRIST_COM_PROVIDER_KEY);\n  const secret = section.flag(\"secret\").readString({\n    envVar: \"GRIST_GETGRISTCOM_SECRET\",\n    censor: true,\n  });\n  if (!secret) {\n    throw new NotConfiguredError(\"getgrist.com login system is not configured: missing secret\");\n  }\n\n  const decodedSecret = Buffer.from(secret, \"base64\").toString(\"utf-8\");\n  const secretJson = JSON.parse(decodedSecret);\n\n  return secretJson;\n}\n\n/**\n * Return the host URL for GetGrist.com login system. By default and in 99% of cases, this is just APP_HOME_URL, but\n * there is an option to override it via GRIST_GETGRISTCOM_SP_HOST env var.\n * Notice that this doesn't come from the GRIST_GETGRISTCOM_SECRET, that secret contains only upstream OIDC provider\n * information.\n */\nexport function getGetGristComHost(settings = appSettings): string | undefined {\n  return settings.section(\"login\")\n    .section(\"system\")\n    .section(GETGRIST_COM_PROVIDER_KEY)\n    .flag(\"spHost\")\n    .readString({\n      envVar: \"GRIST_GETGRISTCOM_SP_HOST\",\n      defaultValue: process.env.APP_HOME_URL,\n    });\n}\n\n/**\n * Get the GetGrist.com login system.\n * This reuses the OIDC builder with preconfigured settings for getgrist.com.\n */\nasync function getLoginSystem(settings: AppSettings): Promise<GristLoginSystem> {\n  const oidcConfig = readGetGristComConfigFromSettings(settings);\n\n  return {\n    async getMiddleware(gristServer: GristServer) {\n      const oidcBuilder = await OIDCBuilder.build(gristServer.sendAppPage.bind(gristServer), oidcConfig);\n      return {\n        getLoginRedirectUrl: oidcBuilder.getLoginRedirectUrl.bind(oidcBuilder),\n        getSignUpRedirectUrl: oidcBuilder.getLoginRedirectUrl.bind(oidcBuilder),\n        getLogoutRedirectUrl: oidcBuilder.getLogoutRedirectUrl.bind(oidcBuilder),\n        async addEndpoints(app: express.Express) {\n          oidcBuilder.addEndpoints(app, gristServer.getSessions());\n          return GETGRIST_COM_PROVIDER_KEY;\n        },\n      };\n    },\n    async deleteUser() { },\n  };\n}\n\nexport const getGetGristComLoginSystem = createLoginProviderFactory(\n  GETGRIST_COM_PROVIDER_KEY,\n  getLoginSystem,\n);\n"
  },
  {
    "path": "app/server/lib/GoogleAuth.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { parseSubdomain } from \"app/common/gristUrls\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport log from \"app/server/lib/log\";\nimport { getOriginUrl, optStringParam, stringParam } from \"app/server/lib/requestUtils\";\n\nimport { URL } from \"url\";\n\nimport { auth } from \"@googleapis/oauth2\";\nimport * as express from \"express\";\n\n/**\n * Google Auth Endpoint for performing server side authentication. More information can be found\n * at https://developers.google.com/identity/protocols/oauth2/web-server.\n *\n * Environmental variables used:\n * - GOOGLE_CLIENT_ID :     key obtained from a Google Project, not secret can be shared publicly,\n *                          the same client id is used in Google Drive Plugin\n * - GOOGLE_CLIENT_SECRET:  secret key for Google Project, can't be shared - it is used to (d)encrypt\n *                          data that we obtain from Google Auth Service (all done in the api)\n * - GOOGLE_DRIVE_SCOPE:    scope requested for the Google drive integration (defaults to drive.file which allows\n *                          to create files and get files via Google Drive Picker)\n *\n * High level description:\n *\n * Each API endpoint that wants to talk to Google Api needs an access_token that identifies our application\n * and permits us to access some of the user data or work with the API on the user's behalf (examples for that are\n * Google Drive plugin and Google Export endpoint, [Send to Google Drive] feature on the UI).)\n * To obtain this token on the server-side, the application needs to redirect the user to a\n * Google Consent Screen - where the user can log into his account and give consent for the permissions\n * we need. Permissions are defined by SCOPES - that exactly describes what we are allowed to do.\n * More on scopes can be read on https://developers.google.com/identity/protocols/oauth2/scopes.\n * When we are redirecting the user to a Google Consent Screen, we are also sending a static URL for an endpoint\n * where Google will redirect the user after he gives us permissions or declines our request. For that, we are exposing\n * static URL http://docs.getgrist.com/auth/google (on prod) or http://localhost:8080/auth/google (dev).\n *\n * NOTE: Actually, we are exposing only auth/google endpoint that can be accessed in various ways, including any\n * subdomain, but Google will always redirect to the configured endpoint (example: http://docs.getgrist.com/auth/google)\n *\n * This endpoint will render a simple page (see /static/message.html) that will immediately post\n * a message to the parent window with a response from Google in the form of { code? : string, error?: string }.\n * Code returned from Google will be an encrypted access_token that the client-side code should use to invoke\n * the server-side API endpoint that wishes to call one of the Google API endpoints. A server-side endpoint can use\n * \"googleAuthTokenMiddleware\" middleware to convert code to access_token (by making a separate call to Google\n * for exchanging code for an access_token).\n * This access_token could be stored in the user's session for further use, but since we are making only a single call\n * very rarely, and access_token will expire eventually; it is better to acquire access_token every time.\n * More on storing access_token offline can be read on:\n * https://developers.google.com/identity/protocols/oauth2/web-server#obtainingaccesstokens\n *\n * How to use:\n *\n * To call server-side endpoint that expects access_token, first decorate it with \"googleAuthTokenMiddleware\"\n * middleware, then perform \"server-side\" authentication with Google on the client-side to acquire an encrypted token.\n * Client code should open up a popup window with an URL to Grist's Google Auth endpoint (/auth/google) and wait\n * for a message from a popup window containing an encrypted token.\n * Having encrypted token (\"code\"), a client can call the server-side endpoint by adding to the query string the code\n * acquired from the popup window. Server endpoint (decorated by \"googleAuthTokenMiddleware\") will get access_token\n * in a query string.\n */\n\n// Path for the auth endpoint.\nconst authHandlerPath = \"/auth/google\";\n\n// Redirect host after the Google Auth login form is completed. This reuses the same domain name\n// as for Cognito login.\nconst AUTH_SUBDOMAIN = process.env.GRIST_ID_PREFIX ? `docs-${process.env.GRIST_ID_PREFIX}` : \"docs\";\n\n/**\n * Return a full url for Google Auth handler. Examples are:\n *\n * http://localhost:8080/auth/google in dev\n * https://docs-s.getgrist.com in staging\n * https://docs.getgrist.com in prod\n */\nfunction getFullAuthEndpointUrl(): string {\n  const homeUrl = process.env.APP_HOME_URL;\n  // if homeUrl is localhost - (in dev environment) - use the development url\n  if (homeUrl && new URL(homeUrl).hostname === \"localhost\") {\n    return `${homeUrl}${authHandlerPath}`;\n  }\n  const homeBaseDomain = homeUrl && parseSubdomain(new URL(homeUrl).host).base;\n  const baseDomain = homeBaseDomain || \".getgrist.com\";\n  return `https://${AUTH_SUBDOMAIN}${baseDomain}${authHandlerPath}`;\n}\n\n/**\n * Middleware for obtaining access_token from Google Auth Service.\n * It expects code query parameter (provided by frontend code) add adds to access_token to query parameters.\n */\nexport async function googleAuthTokenMiddleware(\n  req: express.Request,\n  res: express.Response,\n  next: express.NextFunction) {\n  // If access token is in place, proceed\n  if (!optStringParam(req.query.code, \"code\")) {\n    throw new ApiError(\"Google Auth endpoint requires a code parameter in the query string\", 400);\n  } else {\n    try {\n      const oAuth2Client = getGoogleAuth();\n      // Decrypt code that was send back from Google Auth service. Uses GOOGLE_CLIENT_SECRET key.\n      const tokenResponse = await oAuth2Client.getToken(stringParam(req.query.code, \"code\"));\n      // Get the access token (access token will be present in a default request configuration).\n      const access_token = tokenResponse.tokens.access_token!;\n      req.query.access_token = access_token;\n      next();\n    } catch (err) {\n      log.error(\"GoogleAuth - Error\", err);\n      throw err;\n    }\n  }\n}\n\n/**\n * Adds a static Google Auth endpoint. This will be used by Google Auth Service to redirect back, after the user\n * finishes a signing and a consent flow. Google will pass 2 arguments in a query string:\n * - code: encrypted access token when user gave permissions\n * - error: error code when user declined our request.\n */\nexport function addGoogleAuthEndpoint(\n  expressApp: express.Application,\n  messagePage: (req: express.Request, res: express.Response, message: any) => any,\n) {\n  if (!process.env.GOOGLE_CLIENT_SECRET) {\n    log.warn(\"Failed to create GoogleAuth endpoint: GOOGLE_CLIENT_SECRET is not defined\");\n    expressApp.get(authHandlerPath, expressWrap(async (req: express.Request, res: express.Response) => {\n      throw new Error(\"Send to Google Drive is not configured.\");\n    }));\n    return;\n  }\n  log.info(`GoogleAuth - auth handler at ${getFullAuthEndpointUrl()}`);\n\n  expressApp.get(authHandlerPath, expressWrap(async (req: express.Request, res: express.Response) => {\n    // Test if the code is in a query string. Google sends it back after user has given a concent for\n    // our request. It is encrypted (with CLIENT_SECRET) and signed with redirect url.\n    // In state query parameter we will receive an url that was send as part of the request to Google.\n\n    if (optStringParam(req.query.code, \"code\")) {\n      log.debug(\"GoogleAuth - response from Google with valid code\");\n      messagePage(req, res, { code: stringParam(req.query.code, \"code\"),\n        origin: stringParam(req.query.state, \"state\") });\n    } else if (optStringParam(req.query.error, \"error\")) {\n      log.debug(\"GoogleAuth - response from Google with error code\", stringParam(req.query.error, \"error\"));\n      if (stringParam(req.query.error, \"error\") === \"access_denied\") {\n        messagePage(req, res, { error: stringParam(req.query.error, \"error\"),\n          origin: stringParam(req.query.state, \"state\") });\n      } else {\n        // This should not happen, either code or error is a mandatory query parameter.\n        throw new ApiError(\"Error authenticating with Google\", 500);\n      }\n    } else {\n      const oAuth2Client = getGoogleAuth();\n      const scope = stringParam(req.query.scope, \"scope\");\n      // Create url for origin parameter for a popup window.\n      const origin = getOriginUrl(req);\n      const authUrl = oAuth2Client.generateAuthUrl({\n        scope,\n        prompt: \"select_account\",\n        state: origin,\n      });\n      log.debug(`GoogleAuth - redirecting to Google consent screen`, {\n        authUrl,\n        scope,\n        state: origin,\n      });\n      res.redirect(authUrl);\n    }\n  }));\n}\n\n/**\n * Builds the OAuth2 Google client.\n */\nexport function getGoogleAuth() {\n  const CLIENT_ID = process.env.GOOGLE_CLIENT_ID;\n  const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;\n  const oAuth2Client = new auth.OAuth2(CLIENT_ID, CLIENT_SECRET, getFullAuthEndpointUrl());\n  return oAuth2Client;\n}\n"
  },
  {
    "path": "app/server/lib/GoogleExport.ts",
    "content": "import { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { streamXLSX } from \"app/server/lib/ExportXLSX\";\nimport log from \"app/server/lib/log\";\nimport { optStringParam } from \"app/server/lib/requestUtils\";\n\nimport { PassThrough, Stream } from \"stream\";\n\nimport { drive } from \"@googleapis/drive\";\nimport { Request, Response } from \"express\";\n\n/**\n * Endpoint logic for sending grist document to Google Drive. Grist document is first exported as an\n * excel file and then pushed to Google Drive as a Google Spreadsheet.\n */\nexport async function exportToDrive(\n  activeDoc: ActiveDoc,\n  req: Request,\n  res: Response,\n) {\n  // Token should come from auth middleware\n  const access_token = optStringParam(req.query.access_token, \"access_token\");\n  if (!access_token) {\n    throw new Error(\"No access token - Can't send file to Google Drive\");\n  }\n\n  const mreq = req as RequestWithLogin;\n  const meta = {\n    docId: activeDoc.docName,\n    userId: mreq.userId,\n    altSessionId: mreq.altSessionId,\n  };\n  // Prepare file for exporting.\n  log.debug(`Export to drive - Preparing file for export`, meta);\n  const name = (optStringParam(req.query.title, \"title\") || activeDoc.docName);\n  const stream = new PassThrough();\n\n  try {\n    // Send file to GDrive and get the url for a preview.\n    const [, url] = await Promise.all([\n      streamXLSX(activeDoc, req, stream, { tableId: \"\" }),\n      sendFileToDrive(name, stream, access_token),\n    ]);\n    activeDoc.logAuditEvent(mreq, {\n      action: \"document.send_to_google_drive\",\n      details: {\n        document: {\n          id: activeDoc.docName,\n        },\n      },\n    });\n    log.debug(`Export to drive - File exported, redirecting to Google Spreadsheet ${url}`, meta);\n    res.json({ url });\n  } catch (err) {\n    log.error(\"Export to drive - Error while sending file to GDrive\", meta, err);\n    // Test if google returned a valid error message.\n    if (err.errors?.length) {\n      throw new Error(err.errors[0].message);\n    } else {\n      throw err;\n    }\n  }\n}\n\n// Creates spreadsheet file in a Google drive, by sending an excel and requesting for conversion.\nasync function sendFileToDrive(fileNameNoExt: string, stream: Stream, oauth_token: string): Promise<string> {\n  // Here we are asking google drive to convert excel file to a google spreadsheet\n  const requestBody = {\n    // name of the spreadsheet to create\n    name: fileNameNoExt,\n    // mime type of the google spreadsheet\n    mimeType: \"application/vnd.google-apps.spreadsheet\",\n  };\n  // Define what gets send - excel file\n  const media = {\n    mimeType: \"application/vnd.ms-excel\",\n    body: stream,\n  };\n  const googleDrive = drive(\"v3\");\n  const fileRes = await googleDrive.files.create({\n    requestBody, // what to do with file - convert to spreadsheet\n    oauth_token, // access token\n    media, // file\n    fields: \"webViewLink\", // return webViewLink after creating file\n  });\n  const url = fileRes.data.webViewLink;\n  if (!url) {\n    throw new Error(\"Google Api has not returned valid response\");\n  }\n  return url;\n}\n"
  },
  {
    "path": "app/server/lib/GoogleImport.ts",
    "content": "import { getGoogleAuth } from \"app/server/lib/GoogleAuth\";\n\nimport { drive } from \"@googleapis/drive\";\nimport contentDisposition from \"content-disposition\";\nimport { Readable } from \"form-data\";\nimport { GaxiosError, GaxiosPromise } from \"gaxios\";\nimport { FetchError, Headers, Response as FetchResponse } from \"node-fetch\";\n\nconst\n  SPREADSHEETS_MIMETYPE = \"application/vnd.google-apps.spreadsheet\",\n  XLSX_MIMETYPE = \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\";\n\nexport async function downloadFromGDrive(url: string, code?: string) {\n  const fileId = fileIdFromUrl(url);\n  const key = process.env.GOOGLE_API_KEY;\n  if (!key) {\n    throw new Error(\"Can't download file from Google Drive. Api key is not configured\");\n  }\n  if (!fileId) {\n    throw new Error(`Can't download from ${url}. Url is not valid`);\n  }\n  const googleDrive = await initDriveApi(code);\n  const fileRes = await googleDrive.files.get({\n    key,\n    fileId,\n  });\n  if (fileRes.data.mimeType === SPREADSHEETS_MIMETYPE) {\n    let filename = fileRes.data.name;\n    if (filename && !filename.includes(\".\")) {\n      filename = `${filename}.xlsx`;\n    }\n    return await asFetchResponse(googleDrive.files.export(\n      { key, fileId, alt: \"media\", mimeType: XLSX_MIMETYPE },\n      { responseType: \"stream\" },\n    ), filename);\n  } else {\n    return await asFetchResponse(googleDrive.files.get(\n      { key, fileId, alt: \"media\" },\n      { responseType: \"stream\" },\n    ), fileRes.data.name);\n  }\n}\n\nasync function initDriveApi(code?: string) {\n  if (code) {\n    // Create drive with access token.\n    const auth = getGoogleAuth();\n    const token = await auth.getToken(code);\n    if (token.tokens) {\n      auth.setCredentials(token.tokens);\n    }\n    return drive({ version: \"v3\", auth: code ? auth : undefined });\n  }\n  // Create drive for public access.\n  return drive({ version: \"v3\" });\n}\n\nasync function asFetchResponse(req: GaxiosPromise<Readable>, filename?: string | null) {\n  try {\n    const res = await req;\n    const headers = new Headers(res.headers);\n    if (filename) {\n      headers.set(\"content-disposition\", contentDisposition(filename));\n    }\n    return new FetchResponse(res.data, {\n      headers,\n      status: res.status,\n      statusText: res.statusText,\n    });\n  } catch (err) {\n    const error: GaxiosError<Readable> = err;\n    if (!error.response) {\n      // Fetch throws exception on network error.\n      // https://github.com/node-fetch/node-fetch/blob/master/docs/ERROR-HANDLING.md\n      throw new FetchError(error.message, \"system\", error);\n    } else {\n      // Fetch returns failure response on http error\n      const resInit = error.response ? {\n        status: error.response.status,\n        headers: new Headers(error.response.headers),\n        statusText: error.response.statusText,\n      } : undefined;\n      return new FetchResponse(error.response.data, resInit);\n    }\n  }\n}\n\nexport function isDriveUrl(url: string) {\n  return !!fileIdFromUrl(url);\n}\n\nfunction fileIdFromUrl(url: string) {\n  if (!url) { return null; }\n  const match = /^https:\\/\\/(docs|drive).google.com\\/(spreadsheets|file)\\/d\\/([^/?]*)/i.exec(url);\n  return match ? match[3] : null;\n}\n"
  },
  {
    "path": "app/server/lib/GranularAccess.ts",
    "content": "import { ALL_PERMISSION_PROPS } from \"app/common/ACLPermissions\";\nimport { ACLRuleCollection, SPECIAL_RULES_TABLE_ID } from \"app/common/ACLRuleCollection\";\nimport { ActionGroup } from \"app/common/ActionGroup\";\nimport { createEmptyActionSummary } from \"app/common/ActionSummary\";\nimport { ApplyUAExtendedOptions, ServerQuery } from \"app/common/ActiveDocAPI\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { MapWithTTL } from \"app/common/AsyncCreate\";\nimport { AttachmentColumns, gatherAttachmentIds, getAttachmentColumns } from \"app/common/AttachmentColumns\";\nimport {\n  BulkAddRecord,\n  BulkColValues,\n  BulkRemoveRecord,\n  BulkUpdateRecord,\n  DataAction,\n  getActionColValues,\n  getColValues,\n  getRowIds,\n  getRowIdsFromDocAction,\n  isBulkAction,\n  isDataAction,\n  isSomeAddRecordAction,\n  isSomeRemoveRecordAction,\n} from \"app/common/DocActions\";\nimport { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from \"app/common/DocActions\";\nimport { getColIdsFromDocAction, TableDataAction, UserAction } from \"app/common/DocActions\";\nimport { DocComment, getMentions, makeDocComment } from \"app/common/DocComments\";\nimport { DocData } from \"app/common/DocData\";\nimport { UserOverride } from \"app/common/DocListAPI\";\nimport { DocUsageSummary, FilteredDocUsageSummary, UsageRecommendations } from \"app/common/DocUsage\";\nimport { normalizeEmail } from \"app/common/emails\";\nimport { ErrorWithCode } from \"app/common/ErrorWithCode\";\nimport { InfoEditor, RuleSet } from \"app/common/GranularAccessClause\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { getSetMapValue, isNonNullish, pruneArray } from \"app/common/gutil\";\nimport { isMetadataTable } from \"app/common/isHiddenTable\";\nimport { compilePredicateFormula, PredicateFormulaInput } from \"app/common/PredicateFormula\";\nimport { EmptyRecordView, InfoView, RecordView } from \"app/common/RecordView\";\nimport { canEdit, canView, isValidRole, Role } from \"app/common/roles\";\nimport { SingleCell } from \"app/common/TableData\";\nimport { User } from \"app/common/User\";\nimport { FullUser, UserAccessData } from \"app/common/UserAPI\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { getConfiguredStandardAttachmentStore } from \"app/server/lib/AttachmentStoreProvider\";\nimport { applyAndCheckActionsForCells, CellData, isCellDataAction } from \"app/server/lib/CellDataAccess\";\nimport { describeDocActions, DocActionsDescription } from \"app/server/lib/describeDocActions\";\nimport { DocAuthorizer, DummyAuthorizer } from \"app/server/lib/DocAuthorizer\";\nimport { DocClients } from \"app/server/lib/DocClients\";\nimport { OptDocSession } from \"app/server/lib/DocSession\";\nimport { DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY } from \"app/server/lib/DocStorage\";\nimport log from \"app/server/lib/log\";\nimport { IPermissionInfo, MixedPermissionSetWithContext,\n  PermissionInfo, PermissionSetWithContext } from \"app/server/lib/PermissionInfo\";\nimport { TablePermissionSetWithContext } from \"app/server/lib/PermissionInfo\";\nimport { integerParam } from \"app/server/lib/requestUtils\";\nimport { getRelatedRows } from \"app/server/lib/RowAccess\";\nimport { getDocSessionAccess, getDocSessionShare } from \"app/server/lib/sessionUtils\";\nimport { quoteIdent } from \"app/server/lib/SQLiteDB\";\n\nimport cloneDeep from \"lodash/cloneDeep\";\nimport fromPairs from \"lodash/fromPairs\";\nimport get from \"lodash/get\";\nimport memoize from \"lodash/memoize\";\n\n/**\n * A threshold beyond which for this installation it would be\n * better to use external attachments (if available).\n */\nconst GRIST_ATTACHMENTS_THRESHOLD_MB = appSettings.section(\"attachmentStores\").flag(\"threshold\").requireFloat({\n  envVar: \"GRIST_ATTACHMENTS_THRESHOLD_MB\",\n  defaultValue: 50,\n});\n\n// Check if a tableId is that of an ACL table.  Currently just _grist_ACLRules and\n// _grist_ACLResources are accepted.\nfunction isAclTable(tableId: string): boolean {\n  return [\"_grist_ACLRules\", \"_grist_ACLResources\"].includes(tableId);\n}\n\nconst ADD_OR_UPDATE_RECORD_ACTIONS = [\"AddOrUpdateRecord\", \"BulkAddOrUpdateRecord\"];\n\nfunction isAddOrUpdateRecordAction([actionName]: UserAction): boolean {\n  return ADD_OR_UPDATE_RECORD_ACTIONS.includes(String(actionName));\n}\n\n// A list of key metadata tables that need special handling.  Other metadata tables may\n// refer to material in some of these tables but don't need special handling.\n// TODO: there are other metadata tables that would need access control, or redesign -\n// specifically _grist_Attachments.\nconst STRUCTURAL_TABLES = new Set([\"_grist_Tables\", \"_grist_Tables_column\", \"_grist_Views\",\n  \"_grist_Views_section\", \"_grist_Views_section_field\",\n  \"_grist_ACLResources\", \"_grist_ACLRules\",\n  \"_grist_Shares\", \"_grist_Pages\"]);\n\n// Actions that won't be allowed (yet) for a user with nuanced access to a document.\n// A few may be innocuous, but that hasn't been figured out yet.\nconst SPECIAL_ACTIONS = new Set([\"InitNewDoc\",\n  \"EvalCode\",\n  \"UpdateSummaryViewSection\",\n  \"DetachSummaryViewSection\",\n  \"GenImporterView\",\n  \"MakeImportTransformColumns\",\n  \"FillTransformRuleColIds\",\n  \"TransformAndFinishImport\",\n  \"AddView\",\n  \"AddHiddenColumn\",\n  \"RespondToRequests\",\n]);\n\n// Odd-ball actions marked as deprecated or which seem unlikely to be used.\nconst SURPRISING_ACTIONS = new Set([\n  \"RemoveView\",\n  \"AddViewSection\",\n]);\n\n// Actions we'll allow unconditionally for now.\nconst OK_ACTIONS = new Set([\"Calculate\", \"UpdateCurrentTime\"]);\n\n// Other actions that are believed to be compatible with granular access.\n// Only add an action to OTHER_RECOGNIZED_ACTIONS if you know access control\n// has been handled for it, or it is clear that access control can be done\n// by looking at the Create/Update/Delete permissions for the DocActions it\n// will create.\nconst OTHER_RECOGNIZED_ACTIONS = new Set([\n  // Data actions.\n  \"AddRecord\",\n  \"BulkAddRecord\",\n  \"UpdateRecord\",\n  \"BulkUpdateRecord\",\n  \"RemoveRecord\",\n  \"BulkRemoveRecord\",\n  \"ReplaceTableData\",\n\n  // Data actions handled specially because of read needs.\n  \"AddOrUpdateRecord\",\n  \"BulkAddOrUpdateRecord\",\n\n  // Certain column actions are handled specially because of reads that\n  // don't fit the pattern of data actions.\n  \"ConvertFromColumn\",\n  \"CopyFromColumn\",\n\n  // Groups of actions.\n  \"ApplyDocActions\",\n  \"ApplyUndoActions\",\n\n  // Column-level schema changes.\n  \"AddColumn\",\n  \"AddVisibleColumn\",\n  \"RemoveColumn\",\n  \"RenameColumn\",\n  \"ModifyColumn\",\n  \"AddReverseColumn\",\n\n  // Table-level schema changes.\n  \"AddEmptyTable\",\n  \"AddTable\",\n  \"AddRawTable\",\n  \"RemoveTable\",\n  \"RenameTable\",\n\n  // A schema action handled specially because of read needs.\n  \"DuplicateTable\",\n\n  // Display column support.\n  \"SetDisplayFormula\",\n  \"MaybeCopyDisplayFormula\",\n\n  // Sundry misc.\n  \"RenameChoices\",\n  \"AddEmptyRule\",\n  \"CreateViewSection\",\n  \"RemoveViewSection\",\n]);\n\n// When an attachment is uploaded, it isn't immediately added to a cell in\n// the document. We grant the uploader a special period where they can freely\n// add or re-add the attachment to the document without access control fuss.\n// We keep that period within the time range where an unused attachment\n// would get deleted.\nconst UPLOADED_ATTACHMENT_OWNERSHIP_PERIOD =\n  (REMOVE_UNUSED_ATTACHMENTS_DELAY.delayMs - REMOVE_UNUSED_ATTACHMENTS_DELAY.varianceMs) / 2;\n\n// When a user undoes their own action or actions, checks of attachment ownership\n// are handled specially. This special handling will not apply for undoes of actions\n// older than this limit.\nconst HISTORICAL_ATTACHMENT_OWNERSHIP_PERIOD = 24 * 60 * 60 * 1000;\n\n// Transform columns are special. In case we have some rules defined they are only visible\n// to those with SCHEMA_EDIT permission.\nconst TRANSFORM_COLUMN_PREFIXES = [\"gristHelper_Converted\", \"gristHelper_Transform\"];\n\n/**\n * Checks if this is a special helper column used during type conversion.\n */\nfunction isTransformColumn(colId: string): boolean {\n  return TRANSFORM_COLUMN_PREFIXES.some(prefix => colId.startsWith(prefix));\n}\n\ninterface DocUpdateMessage {\n  actionGroup: ActionGroup;\n  docActions: DocAction[];\n  docUsage: DocUsageSummary;\n}\n\n/**\n * Granular access for a single bundle, in different phases.\n */\nexport interface GranularAccessForBundle {\n  canApplyBundle(): Promise<void>;\n  appliedBundle(): Promise<void>;\n  finishedBundle(): Promise<void>;\n  sendDocUpdateForBundle(actionGroup: ActionGroup, docUsage: DocUsageSummary): Promise<void>;\n\n  // Null means that there are no changes to tables. Empty list means that there are some changes\n  // but no user tables to list. We still deliver notification for empty list, it is just empty\n  // informing that something has changed.\n  getDirectTablesInBundle(userData: UserAccessData): Promise<DocActionsDescription | null>;\n  hasCommentsInBundle(): boolean;\n  getCommentsInBundle(userToFilterFor?: UserAccessData): Promise<DocComment[]>;\n}\n\n/**\n *\n * Manage granular access to a document.  This allows nuances other than the coarse\n * owners/editors/viewers distinctions.  Nuances are stored in the _grist_ACLResources\n * and _grist_ACLRules tables.\n *\n * When the document is being modified, the object's GranularAccess is called at various\n * steps of the process to check access rights.  The GranularAccess object stores some\n * state for an in-progress modification, to allow some caching of calculations across\n * steps and clients.  We expect modifications to be serialized, and the following\n * pattern of calls for modifications:\n *\n *  - assertCanMaybeApplyUserActions(), called with UserActions for an initial access check.\n *    Since not all checks can be done without analyzing UserActions into DocActions,\n *    it is ok for this call to pass even if a more definitive test later will fail.\n *  - getGranularAccessForBundle(), called once a possible bundle has been prepared\n *    (the UserAction has been compiled to DocActions).\n *  - canApplyBundle(), called when DocActions have been produced from UserActions,\n *    but before those DocActions have been applied to the DB.  If fails, the modification\n *    will be abandoned. This method will also finalize some bundle state,\n *    specifically the `maybeHasShareChanges` flag.\n *  - appliedBundle(), called when DocActions have been applied to the DB, but before\n *    those changes have been sent to clients.\n *  - sendDocUpdateForBundle() is called once a bundle has been applied, to notify\n *    client of changes.\n *  - finishedBundle(), called when completely done with modification and any needed\n *    client notifications, whether successful or failed.\n *\n *\n */\nexport class GranularAccess implements GranularAccessForBundle {\n  // The collection of all rules.\n  private _ruler = new Ruler(this);\n\n  // Cache of user attributes associated with the given docSession. It's a WeakMap, to allow\n  // garbage-collection once docSession is no longer in use.\n  private _userAttributesMap = new WeakMap<OptDocSession, UserAttributes>();\n  private _prevUserAttributesMap: WeakMap<OptDocSession, UserAttributes> | undefined;\n  private _attachmentUploads = new MapWithTTL<number, string>(UPLOADED_ATTACHMENT_OWNERSHIP_PERIOD);\n\n  // When broadcasting a sequence of DocAction[]s, this contains the state of\n  // affected rows for the relevant table before and after each DocAction.  It\n  // may contain some unaffected rows as well.\n  private _steps: Promise<ActionStep[]> | null = null;\n  // Intermediate metadata and rule state, if needed.\n  private _metaSteps: Promise<MetaStep[]> | null = null;\n  // Access control is done sequentially, bundle by bundle.  This is the current bundle.\n  private _activeBundle: {\n    docSession: OptDocSession,\n    userActions: UserAction[],\n    docActions: DocAction[],\n    isDirect: boolean[],\n    undo: DocAction[],\n    // Flag tracking whether a set of actions have been applied to the database or not.\n    applied: boolean,\n    // Flag for whether user actions mention a rule change (clients are asked to reload\n    // in this case).\n    hasDeliberateRuleChange: boolean,\n    // Flag for whether doc actions mention a rule change, even if passive due to\n    // schema changes.\n    hasAnyRuleChange: boolean,\n    maybeHasShareChanges: boolean,\n    options: ApplyUAExtendedOptions | null,\n    shareRef?: number;\n  } | null;\n\n  public constructor(\n    private _docData: DocData,\n    private _docStorage: DocStorage,\n    private _docClients: DocClients,\n    private _fetchQueryFromDB: (query: ServerQuery) => Promise<TableDataAction>,\n    private _recoveryMode: boolean,\n    private _homeDbManager: HomeDBManager | null,\n    private _docId: string) {\n  }\n\n  public async close() {\n    this._attachmentUploads.clear();\n  }\n\n  public getGranularAccessForBundle(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[],\n    userActions: UserAction[], isDirect: boolean[],\n    options: ApplyUAExtendedOptions | null): void {\n    if (this._activeBundle) { throw new Error(\"Cannot start a bundle while one is already in progress\"); }\n    // This should never happen - attempts to write to a pre-fork session should be\n    // caught by an Authorizer.  But let's be paranoid, since we may be pretending to\n    // be an owner for granular access purposes, and owners can write if we're not\n    // careful!\n    if (docSession.forkingAsOwner) { throw new Error(\"Should never modify a prefork\"); }\n    this._activeBundle = {\n      docSession, docActions, undo, userActions, isDirect,\n      applied: false, hasDeliberateRuleChange: false, hasAnyRuleChange: false,\n      maybeHasShareChanges: false,\n      options,\n    };\n    this._activeBundle.hasDeliberateRuleChange =\n      scanActionsRecursively(userActions, a => isAclTable(String(a[1])));\n    this._activeBundle.hasAnyRuleChange =\n      scanActionsRecursively(docActions, a => actionHasRuleChange(a));\n  }\n\n  /**\n   * Update granular access from DocData.\n   */\n  public async update() {\n    await this._ruler.update(this._docData);\n\n    // Also clear the per-docSession cache of user attributes.\n    this._userAttributesMap = new WeakMap();\n  }\n\n  /**\n   * Construct the UserInfo needed for evaluating rules. This also enriches the user with values\n   * created by user-attribute rules.\n   */\n  public async getUser(docSession: OptDocSession): Promise<User> {\n    const linkParameters = docSession.linkParameters || {};\n    let access: Role | null;\n    let fullUser: FullUser | null;\n    const attrs = this._getUserAttributes(docSession);\n    access = getDocSessionAccess(docSession);\n\n    const linkId = getDocSessionShare(docSession);\n    let shareRef: number = 0;\n    if (linkId) {\n      const rowIds = this._docData.getMetaTable(\"_grist_Shares\").filterRowIds({\n        linkId,\n      });\n      if (rowIds.length > 1) {\n        throw new Error(\"Share identifier is not unique\");\n      }\n      if (rowIds.length === 1) {\n        shareRef = rowIds[0];\n      }\n    }\n\n    if (docSession.forkingAsOwner) {\n      // For granular access purposes, we become an owner.\n      // It is a bit of a bluff, done on the understanding that this session will\n      // never be used to edit the document, and that any edits will be done on a\n      // fork.\n      access = \"owners\";\n    }\n\n    // If aclAsUserId/aclAsUser is set, then override user for acl purposes.\n    if (linkParameters.aclAsUserId || linkParameters.aclAsUser) {\n      if (access !== \"owners\") { throw new ErrorWithCode(\"ACL_DENY\", \"only an owner can override user\"); }\n      // Use cached overrides, or cache them on first use.\n      const override = attrs.override || (attrs.override = await this._getViewAsUser(linkParameters));\n      access = override.access;\n      fullUser = override.user;\n    } else if (linkId) {\n      // Anonymize user info for form submissions.\n      // Note: This is half-baked and doesn't account for other types of shares besides forms.\n      fullUser = this._homeDbManager?.makeFullUser(this._homeDbManager.getAnonymousUser()) ?? null;\n    } else {\n      fullUser = docSession.fullUser;\n    }\n    const user = new User();\n    user.Access = access;\n    user.ShareRef = shareRef || null;\n    const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() ||\n      fullUser?.id === null;\n    user.UserID = (!isAnonymous && fullUser?.id) || null;\n    user.Email = fullUser?.email || null;\n    user.Name = fullUser?.name || null;\n    // If viewed from a websocket, collect any link parameters included.\n    // TODO: could also get this from rest api access, just via a different route.\n    user.LinkKey = linkParameters;\n    // Include origin info if accessed via the rest api.\n    // TODO: could also get this for websocket access, just via a different route.\n    user.Origin = docSession.req?.get(\"origin\") || null;\n    user.SessionID = isAnonymous ? `a${docSession.altSessionId}` : `u${user.UserID}`;\n    user.IsLoggedIn = !isAnonymous;\n    user.UserRef = fullUser?.ref || null; // Empty string should be treated as null.\n\n    if (this._ruler.ruleCollection.ruleError && !this._recoveryMode) {\n      // It is important to signal that the doc is in an unexpected state,\n      // and prevent it opening.\n      throw this._ruler.ruleCollection.ruleError;\n    }\n\n    for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) {\n      if (clause.name in user) {\n        log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`);\n        continue;\n      }\n      if (attrs.rows[clause.name]) {\n        user[clause.name] = attrs.rows[clause.name];\n        continue;\n      }\n      let rec = new EmptyRecordView();\n      let rows: TableDataAction | undefined;\n      try {\n        // TODO: add indexes to db.\n        const noCase = clause.charId === \"Email\" ? ` COLLATE NOCASE` : \"\";\n        rows = await this._fetchQueryFromDB({\n          tableId: clause.tableId,\n          filters: {},\n          where: {\n            clause: `${quoteIdent(clause.lookupColId)}${noCase} = ?`,\n            // Use lodash's get() that supports paths, e.g. charId of 'a.b' would look up `user.a.b`.\n            params: [get(user, clause.charId)],\n          },\n        });\n      } catch (e) {\n        log.warn(`User attribute ${clause.name} failed`, e);\n      }\n      if (rows && rows[2].length > 0) { rec = new RecordView(rows, 0); }\n      user[clause.name] = rec;\n      attrs.rows[clause.name] = rec;\n    }\n    return user;\n  }\n\n  public async getCachedUser(docSession: OptDocSession): Promise<User> {\n    const access = await this._getAccess(docSession);\n    return access.getUser();\n  }\n\n  /**\n   * Represent fields from the session in an input object for ACL rules.\n   * Just one field currently, \"user\".\n   */\n  public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> {\n    return {\n      user: await this.getUser(docSession),\n      docId: this._docId,\n    };\n  }\n\n  /**\n   * Check whether user has any access to table.\n   */\n  public async hasTableAccess(docSession: OptDocSession, tableId: string) {\n    const pset = await this.getTableAccess(docSession, tableId);\n    return this.getReadPermission(pset) !== \"deny\";\n  }\n\n  /**\n   * Checks if user has read access to a cell. Optionally takes docData that will be used\n   * to retrieve the cell value instead of the current docData.\n   */\n  public async hasCellAccess(docSession: OptDocSession, cell: SingleCell, docData?: DocData): Promise<boolean> {\n    try {\n      await this.getCellValue(docSession, cell, docData);\n      return true;\n    } catch (err) {\n      if (err instanceof ErrorWithCode) { return false; }\n      throw err;\n    }\n  }\n\n  /**\n   * Get content of a given cell, if user has read access. Optionally takes docData that will be used\n   * to retrieve the cell value instead of the current docData.\n   * Throws if not.\n   */\n  public async getCellValue(docSession: OptDocSession, cell: SingleCell, docData?: DocData): Promise<CellValue> {\n    function fail(): never {\n      throw new ErrorWithCode(\"ACL_DENY\", \"Cannot access cell\");\n    }\n    const hasExceptionalAccess = this._hasExceptionalFullAccess(docSession);\n    if (!hasExceptionalAccess && !await this.hasTableAccess(docSession, cell.tableId)) { fail(); }\n    let rows: TableDataAction | null = null;\n    if (docData) {\n      const record = docData.getTable(cell.tableId)?.getRecord(cell.rowId);\n      if (record) {\n        rows = [\"TableData\", cell.tableId, [cell.rowId], getColValues([record])];\n      }\n    } else {\n      rows = await this._fetchQueryFromDB({\n        tableId: cell.tableId,\n        filters: { id: [cell.rowId] },\n      });\n    }\n    if (!rows || rows[2].length === 0) {\n      return fail();\n    }\n    const rec = new RecordView(rows, 0);\n    if (!hasExceptionalAccess) {\n      const input: PredicateFormulaInput = { ...await this.inputs(docSession), rec, newRec: rec };\n      const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);\n      const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;\n      if (rowAccess === \"deny\") { fail(); }\n      if (rowAccess !== \"allow\") {\n        const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;\n        if (colAccess === \"deny\") { fail(); }\n      }\n      const colValues = rows[3];\n      if (!(cell.colId in colValues)) { fail(); }\n    }\n    return rec.get(cell.colId);\n  }\n\n  /**\n   * Checks whether the specified cell is accessible by the user, and contains\n   * the specified attachment. Throws with ACL_DENY code if not.\n   */\n  public async assertAttachmentAccess(docSession: OptDocSession, cell: SingleCell, attId: number): Promise<void> {\n    const value = await this.getCellValue(docSession, cell);\n\n    // Need to check column is actually an attachment column.\n    if (this._docStorage.getColumnType(cell.tableId, cell.colId) !== \"Attachments\") {\n      throw new ErrorWithCode(\"ACL_DENY\", \"not an attachment column\");\n    }\n\n    // Check that material in cell includes the attachment.\n    if (!gristTypes.isList(value)) {\n      throw new ErrorWithCode(\"ACL_DENY\", \"not a list\");\n    }\n    if (value.indexOf(attId) <= 0) {\n      throw new ErrorWithCode(\"ACL_DENY\", \"attachment not present in cell\");\n    }\n  }\n\n  /**\n   * Check whether the specified attachment is known to have been uploaded\n   * by the user (identified by SessionID) recently.\n   */\n  public async isAttachmentUploadedByUser(docSession: OptDocSession, attId: number): Promise<boolean> {\n    const user = await this.getUser(docSession);\n    const id = user.SessionID || \"\";\n    return (this._attachmentUploads.get(attId) === id);\n  }\n\n  /**\n   * Find a cell in an attachment column that contains the specified attachment,\n   * and which is accessible by the user associated with the session.\n   */\n  public async findAttachmentCellForUser(docSession: OptDocSession, attId: number): Promise<SingleCell | undefined> {\n    // Find cells that refer to the given attachment.\n    const cells = await this._docStorage.findAttachmentReferences(attId);\n    // Run through them to see if the user has access to any of them.\n    // We'd expect in a typical document that this will be a small\n    // list of cells, typically 1 or less, but of course extreme cases\n    // are possible.\n    for (const possibleCell of cells) {\n      try {\n        await this.assertAttachmentAccess(docSession, possibleCell, attId);\n        return possibleCell;\n      } catch (e) {\n        if (e instanceof ErrorWithCode && e.code === \"ACL_DENY\") {\n          continue;\n        }\n        throw e;\n      }\n    }\n    // Nothing found.\n    return undefined;\n  }\n\n  /**\n   * Called after UserAction[]s have been applied in the sandbox, and DocAction[]s have been\n   * computed, but before we have committed those DocAction[]s to the database.  If this\n   * throws an exception, the sandbox changes will be reverted.\n   */\n  public async canApplyBundle() {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    const { docActions, docSession, isDirect } = this._activeBundle;\n    const currentUser = await this.getUser(docSession);\n    const userIsOwner = await this.isOwner(docSession);\n    if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) {\n      throw new ErrorWithCode(\"ACL_DENY\", \"Only owners can modify access rules\");\n    }\n    // Normally, viewer requests would never reach this point, but they can happen\n    // using the \"view as\" functionality where user is an owner wanting to preview the\n    // access level of another.  And again, the default access rules would normally\n    // forbid edit access to a viewer - but that can be overridden.\n    // An alternative to this check would be to sandwich user-defined access rules\n    // between some defaults.  Currently the defaults have lower priority than\n    // user-defined access rules.\n    if (!canEdit(await this.getNominalAccess(docSession))) {\n      throw new ErrorWithCode(\"ACL_DENY\", \"Only owners or editors can modify documents\");\n    }\n    if (this._ruler.haveRules()) {\n      await Promise.all(\n        docActions.map((action, actionIdx) => {\n          if (isDirect[actionIdx]) {\n            return this._checkIncomingDocAction({ docSession, action, actionIdx });\n          }\n          return Promise.resolve(undefined);\n        }));\n      const shares = this._docData.getMetaTable(\"_grist_Shares\");\n      /**\n       * This is a good point at which to determine whether we may be\n       * making a change to special shares. If we may be, then currently\n       * we will reload any connected web clients accessing the document\n       * via a share.\n       *\n       * The role of the `maybeHasShareChanges` flag is to trigger\n       * reloads of web clients that are accessing the document via a\n       * share, if share configuration may have changed. It doesn't\n       * actually impact access control itself. The sketch of order of\n       * operations given in the docstring for the GranularAccess\n       * class is helpful for understanding this flow.\n       *\n       * At the time of writing, web client support for special shares\n       * is not an official feature - but it is super convenient for testing\n       * and will be important later.\n       */\n      if (shares.getRowIds().length > 0 &&\n        docActions.some(action => isMetadataTable(getTableId(action)))) {\n        // TODO: could actually compare new rules with old rules and\n        // see if they've changed. Or could exclude some tables that\n        // could easily change without an impact on share rules,\n        // such as _grist_Attachments. Either improvement could\n        // greatly reduce unnecessary web client reloads for shares\n        // if that becomes an issue.\n        this._activeBundle.maybeHasShareChanges = true;\n      }\n    }\n\n    await this._canApplyCellActions(currentUser, userIsOwner);\n\n    if (this._recoveryMode) {\n      // Don't do any further checking in recovery mode.\n      return;\n    }\n\n    // If the actions change any rules, verify that we'll be able to handle the changed rules. If\n    // they are to cause an error, reject the action to avoid forcing user into recovery mode.\n    // WATCH OUT - this will trigger for \"passive\" changes caused by tableId/colId renames.\n    if (docActions.some(docAction => isAclTable(getTableId(docAction)))) {\n      // Create a tmpDocData with just the tables we care about, then update docActions to it.\n      const tmpDocData: DocData = new DocData(\n        (tableId) => { throw new Error(\"Unexpected DocData fetch\"); }, {\n          _grist_Tables: this._docData.getMetaTable(\"_grist_Tables\").getTableDataAction(),\n          _grist_Tables_column: this._docData.getMetaTable(\"_grist_Tables_column\").getTableDataAction(),\n          _grist_ACLResources: this._docData.getMetaTable(\"_grist_ACLResources\").getTableDataAction(),\n          _grist_ACLRules: this._docData.getMetaTable(\"_grist_ACLRules\").getTableDataAction(),\n          _grist_Shares: this._docData.getMetaTable(\"_grist_Shares\").getTableDataAction(),\n          // WATCH OUT - Shares may need more tables, check.\n        });\n      for (const da of docActions) {\n        tmpDocData.receiveAction(da);\n      }\n\n      // Use the post-actions data to process the rules collection, and throw error if that fails.\n      const ruleCollection = new ACLRuleCollection();\n      await ruleCollection.update(tmpDocData, { log, compile: compilePredicateFormula });\n      if (ruleCollection.ruleError) {\n        throw new ApiError(ruleCollection.ruleError.message, 400);\n      }\n      try {\n        ruleCollection.checkDocEntities(tmpDocData);\n      } catch (err) {\n        throw new ApiError(err.message, 400);\n      }\n    }\n\n    // TODO: any changes needed to this logic for shares?\n  }\n\n  /**\n   * This should be called after each action bundle has been applied to the database,\n   * but before the actions are broadcast to clients.  It will set us up to be able\n   * to efficiently filter those broadcasts.\n   *\n   * We expect actions bundles for a document to be applied+broadcast serially (the\n   * broadcasts can be parallelized, but should complete before moving on to further\n   * document mutation).\n   */\n  public async appliedBundle() {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    const { docActions } = this._activeBundle;\n    this._activeBundle.applied = true;\n    if (!this._ruler.haveRules()) { return; }\n    // Check if a table that affects user attributes has changed.  If so, put current\n    // attributes aside for later comparison, and clear cache.\n    const attrs = new Set([...this._ruler.ruleCollection.getUserAttributeRules().values()].map(r => r.tableId));\n    const attrChange = docActions.some(docAction => attrs.has(getTableId(docAction)));\n    if (attrChange) {\n      this._prevUserAttributesMap = this._userAttributesMap;\n      this._userAttributesMap = new WeakMap();\n    }\n    // If there's a schema change, zap permission cache.\n    const schemaChange = docActions.some(docAction => isSchemaAction(docAction));\n    if (attrChange || schemaChange) {\n      this._ruler.clearCache();\n    }\n  }\n\n  /**\n   * This should be called once an action bundle has been broadcast to\n   * all clients (or the bundle has been denied).  It will clean up\n   * any temporary state cached for filtering those broadcasts.\n   */\n  public async finishedBundle() {\n    if (!this._activeBundle) { return; }\n    if (this._activeBundle.applied) {\n      const { docActions } = this._activeBundle;\n      await this._updateRules(docActions);\n    }\n    this._steps = null;\n    this._metaSteps = null;\n    this._prevUserAttributesMap = undefined;\n    this._activeBundle = null;\n  }\n\n  /**\n   * Filter DocActions to be sent to a client.\n   */\n  public async filterOutgoingDocActions(docSession: OptDocSession, docActions: DocAction[]): Promise<DocAction[]> {\n    // If the user requested a rule change, trigger a reload.\n    if (this._activeBundle?.hasDeliberateRuleChange) {\n      // TODO: could avoid reloading in many cases, especially for an owner who has full\n      // document access.\n      throw new ErrorWithCode(\"NEED_RELOAD\", \"document needs reload, access rules changed\");\n    }\n\n    const linkId = getDocSessionShare(docSession);\n    if (linkId && this._activeBundle?.maybeHasShareChanges) {\n      throw new ErrorWithCode(\"NEED_RELOAD\", \"document needs reload, share may have changed\");\n    }\n\n    // Optimize case where there are no rules to enforce.\n    if (!this._ruler.haveRules()) { return docActions; }\n\n    // If user attributes have changed, trigger a reload.\n    await this._checkUserAttributes(docSession);\n\n    const actions = await Promise.all(\n      docActions.map((action, actionIdx) => this._filterOutgoingDocAction({ docSession, action, actionIdx })));\n    let result = ([] as ActionCursor[]).concat(...actions);\n    result = await this._filterOutgoingAttachments(result);\n\n    return await this._filterOutgoingCellInfo(docSession, docActions,\n      result.map(a => a.action));\n  }\n\n  /**\n   * Returns the list of tables which are updated by the active action bundle. Considers only\n   * actions visible to the given user, and only direct actions (e.g. not tables updated by\n   * formulas). This is used for notifications.\n   */\n  public async getDirectTablesInBundle(userData: UserAccessData): Promise<DocActionsDescription | null> {\n    try {\n      const filtered = await this._getOutgoingDocActionsForNotifications(userData);\n      return describeDocActions(filtered, this._docData);\n    } catch (err) {\n      if (err.code === \"NEED_RELOAD\") {\n        // If something changes that affects access and tells each client to reload, then consider\n        // it a change visible to all users, even though we can't tell which tables are affected.\n        const result: DocActionsDescription = { userTableNames: [], categories: [] };\n        // The error message normally mentions the reason for the reload, so get category from that.\n        if (err.message.includes(\"user attributes\")) {\n          result.categories.push(\"user attributes\");\n        } else if (err.message.includes(\"access rules\")) {\n          result.categories.push(\"access rules\");\n        } else if (err.message.includes(\"share\")) {\n          result.categories.push(\"forms\");\n        } else {\n          result.categories.push(\"metadata\");   // catch-all\n        }\n        return result;\n      }\n      throw err;\n    }\n  }\n\n  public hasCommentsInBundle() {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    const { docActions, isDirect } = this._activeBundle;\n    // We are only interested in direct actions that add comments.\n    return docActions.filter((_, index) => isDirect[index]).some((a) => {\n      return isDataAction(a) && isSomeAddRecordAction(a) && getTableId(a) === \"_grist_Cells\";\n    });\n  }\n\n  /**\n   * Get comments in the active bundle, filtering them for a specific user if requested.\n   */\n  public async getCommentsInBundle(userToFilterFor?: UserAccessData): Promise<DocComment[]> {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    try {\n      const filtered: DocAction[] = await this._getOutgoingDocActionsForNotifications(userToFilterFor);\n      const cellData = new CellData(this._docData);\n      const newComments = cellData.getNewComments(filtered);\n      const audienceMap = cellData.getAudience(newComments.map(r => r.id));\n\n      const docComments: DocComment[] = [];\n      for (const commentRow of newComments) {\n        const audience = audienceMap.get(commentRow.id) || [];\n        const mentions = getMentions(commentRow.content);\n        const docComment = makeDocComment(commentRow, audience, mentions);\n        if (!docComment) {\n          log.warn(`Comment row ${commentRow.id} does not have a valid comment`);\n          continue;\n        }\n        docComments.push(docComment);\n      }\n\n      return docComments;\n    } catch (err) {\n      if (err.code === \"NEED_RELOAD\") {\n        // If something changes that affects access and tells each client to reload, then we\n        // can't tell what comment actions are visible. This should never happen in normal use\n        // of comments (since we don't expect comment actions to be in the same bundle with\n        // access-changing actions), so just assume there are no comments to worry about.\n        return [];\n      }\n      throw err;\n    }\n  }\n\n  /**\n   * Filter an ActionGroup to be sent to a client.\n   */\n  public async filterActionGroup(\n    docSession: OptDocSession,\n    actionGroup: ActionGroup,\n    options: { role?: Role | null } = {},\n  ): Promise<ActionGroup> {\n    if (await this.allowActionGroup(docSession, actionGroup, options)) { return actionGroup; }\n    // For now, if there's any nuance at all, suppress the summary and description.\n    const result: ActionGroup = { ...actionGroup };\n    result.actionSummary = createEmptyActionSummary();\n    result.desc = \"\";\n    return result;\n  }\n\n  /**\n   * Check whether an ActionGroup can be sent to the client.  TODO: in future, we'll want\n   * to filter acceptable parts of ActionGroup, rather than denying entirely.\n   */\n  public async allowActionGroup(\n    docSession: OptDocSession,\n    _actionGroup: ActionGroup,\n    options: { role?: Role | null } = {},\n  ): Promise<boolean> {\n    return this.canReadEverything(docSession, options);\n  }\n\n  /**\n   * Figure out any recommendations based on usage to pass along.\n   */\n  public async getUsageRecommendations(\n    docSession: OptDocSession,\n    docUsage: DocUsageSummary,\n  ): Promise<UsageRecommendations> {\n    const rec: UsageRecommendations = {};\n    if (!this._docData.docSettings().attachmentStoreId &&\n      docUsage.attachmentsSizeBytes !== \"pending\" &&\n      docUsage.attachmentsSizeBytes >= GRIST_ATTACHMENTS_THRESHOLD_MB * 1024 * 1024 &&\n      getConfiguredStandardAttachmentStore() &&\n      await this.isOwner(docSession)) {\n      rec.recommendExternal = true;\n    }\n    return rec;\n  }\n\n  /**\n   * Filter DocUsageSummary to be sent to a client.\n   * Include usage recommendations.\n   */\n  public async filterDocUsageSummary(\n    docSession: OptDocSession,\n    docUsage: DocUsageSummary,\n    options: { role?: Role | null } = {},\n  ): Promise<FilteredDocUsageSummary> {\n    const usageRecommendations = await this.getUsageRecommendations(docSession, docUsage);\n    const result: FilteredDocUsageSummary = { ...docUsage, usageRecommendations };\n    // Owners can see everything all the time.\n    if (await this.isOwner(docSession)) {\n      return result;\n    }\n    const role = options.role ?? await this.getNominalAccess(docSession);\n    const hasEditRole = canEdit(role);\n    if (!hasEditRole) { result.dataLimitInfo.status = null; }\n    const hasFullReadAccess = await this.canReadEverything(docSession);\n    if (!hasEditRole || !hasFullReadAccess) {\n      result.rowCount = \"hidden\";\n      result.dataSizeBytes = \"hidden\";\n      result.attachmentsSizeBytes = \"hidden\";\n    }\n    return result;\n  }\n\n  /**\n   * Check the list of UserActions, throwing if something not permitted is found.\n   * The data engine is the definitive interpreter of UserActions, but we do what\n   * we can, and then rely on analysis of DocActions produced by the data engine\n   * later to finish the job. Any actions that read data and expose it in some way\n   * need to be caught at this point, since that won't be evident in the DocActions.\n   * So far, we've been restricting the permitted combinations of UserActions when\n   * data is read to make access control tractable. Likewise, any actions that might\n   * result in running user code that would not eventually be permitted needs to be\n   * caught now, since by the time it hits the data engine it is too late.\n   */\n  public async checkUserActions(docSession: OptDocSession, actions: UserAction[]): Promise<void> {\n    if (this._hasExceptionalFullAccess(docSession)) { return; }\n\n    // Checks are in no particular order.\n    await this._checkSimpleDataActions(docSession, actions);\n    await this._checkForSpecialOrSurprisingActions(docSession, actions);\n    await this._checkIfNeedsEarlySchemaPermission(docSession, actions);\n    await this._checkDuplicateTableAccess(docSession, actions);\n    await this._checkAddOrUpdateAccess(docSession, actions);\n  }\n\n  /**\n   * Called when it is permissible to partially fulfill the requested actions.\n   * Will remove forbidden actions in a very limited set of recognized circumstances.\n   * In fact, currently in only one circumstance:\n   *\n   *   - If there is a single requested action, and it is an ApplyUndoActions.\n   *     The goal being to let a user undo their action to the extent that it\n   *     is possible to do so.\n   *\n   * In this case, the list of actions nested in ApplyUndoActions will be extracted,\n   * treated as DocActions, and filtered to remove any component parts (at action,\n   * column, row, or individual cell level) that would be forbidden.\n   *\n   * Beyond pure data changes, there are no heroics - any schema change will\n   * result in prefiltering being skipped.\n   *\n   * Any filtering done here is NOT a security measure, and the output should\n   * not be granted any level of automatic trust.\n   */\n  public async prefilterUserActions(docSession: OptDocSession, actions: UserAction[],\n    options: ApplyUAExtendedOptions | null): Promise<UserAction[]> {\n    // Currently we only attempt prefiltering for an ApplyUndoActions.\n    if (actions.length !== 1) { return actions; }\n    const userAction = actions[0];\n    if (userAction[0] !== \"ApplyUndoActions\") { return actions; }\n\n    // Ok, this is an undo.  Unpack the requested undo actions.  For a bona\n    // fide ApplyUndoActions, these would be doc actions generated by the\n    // data engine and stored in action history.  But there is no actual\n    // restriction in how ApplyUndoActions could be generated.  Security\n    // is enforced separately, so we don't need to be paranoid here.\n    const docActions = userAction[1] as DocAction[];\n\n    // Bail out if there is any hint of a schema change.\n    // TODO: may want to also bail if an action we'd need to filter would\n    // affect a row id used later in the bundle.  Perhaps prefiltering\n    // should be restricted to bundles of updates only for that reason.\n    for (const action of docActions) {\n      if (!isDataAction(action) || isMetadataTable(getTableId(action))) {\n        return actions;\n      }\n    }\n\n    // Run through a simulation of access control on these actions,\n    // retaining only permitted material.\n    const proposedActions: UserAction[] = [];\n    try {\n      // Establish our doc actions as the current context for access control.\n      // We don't have undo information for them, but don't need to because\n      // they have not been applied to the db.  Treat all actions as \"direct\"\n      // since we could not trust claims of indirectness currently in\n      // any case (though we could rearrange to limit how undo actions are\n      // requested).\n      this.getGranularAccessForBundle(docSession, docActions, [], docActions,\n        docActions.map(() => true), options);\n      for (const [actionIdx, action] of docActions.entries()) {\n        // A single action might contain forbidden material at cell, row, column,\n        // or table level.  Retaining permitted material may require refactoring the\n        // single action into a series of actions.\n        try {\n          await this._checkIncomingDocAction({ docSession, action, actionIdx });\n          // Nothing forbidden!  Keep this action unchanged.\n          proposedActions.push(action);\n        } catch (e) {\n          if (String(e.code) !== \"ACL_DENY\") { throw e; }\n          const acts = await this._prefilterDocAction({ docSession, action, actionIdx });\n          proposedActions.push(...acts);\n          // Presumably we've changed the action.  Zap our cache of intermediate\n          // states, since it is stale now.  TODO: reorganize cache to so can avoid wasting\n          // time repeating work unnecessarily.  The cache was designed with all-or-nothing\n          // operations in mind, and is poorly suited to prefiltering.\n          // Note: the meaning of newRec is slippery in prefiltering, since it depends on\n          // state at the end of the bundle, but that state is unstable now.\n          // TODO look into prefiltering in cases using newRec in a many-action bundle.\n          this._steps = null;\n          this._metaSteps = null;\n        }\n      }\n    } finally {\n      await this.finishedBundle();\n    }\n    return [[\"ApplyUndoActions\", proposedActions]];\n  }\n\n  /**\n   * For changes that could include Python formulas, check for schema access early.\n   */\n  public needEarlySchemaPermission(a: UserAction | DocAction): boolean {\n    const name = a[0] as string;\n    // ConvertFromColumn and CopyFromColumn are hard to reason\n    // about, especially since they appear in bundles with other\n    // actions. We throw up our hands a bit here, and just make\n    // sure the user has schema permissions. Today, in Grist, that\n    // gives a lot of power. If this gets narrowed down in future,\n    // we'll have to rethink this.\n    const actionNames = [\n      \"ModifyColumn\",\n      \"SetDisplayFormula\",\n      \"ConvertFromColumn\",\n      \"CopyFromColumn\",\n      \"AddReverseColumn\",\n    ];\n    if (actionNames.includes(name)) {\n      return true;\n    } else if (isDataAction(a)) {\n      const tableId = getTableId(a);\n      if (tableId === \"_grist_Tables_column\" || tableId === \"_grist_Validations\") {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  /**\n   * Check whether access is simple, or there are granular nuances that need to be\n   * worked through.  Currently if there are no owner-only tables, then everyone's\n   * access is simple and without nuance.\n   */\n  public async hasNuancedAccess(docSession: OptDocSession): Promise<boolean> {\n    if (!this._ruler.haveRules()) { return false; }\n    return !await this.hasFullAccess(docSession);\n  }\n\n  /**\n   * Check if user is explicitly permitted to download/copy document.\n   * They may be allowed to download in any case, see canCopyEverything.\n   */\n  public async hasFullCopiesPermission(docSession: OptDocSession): Promise<boolean> {\n    return this.hasFullCopiesPermissionSync(await this._getAccess(docSession));\n  }\n\n  public hasFullCopiesPermissionSync(permInfo: PermissionInfo): boolean {\n    return permInfo.getColumnAccess(SPECIAL_RULES_TABLE_ID, \"FullCopies\").perms.read === \"allow\";\n  }\n\n  /**\n   * Check if user may view Access Rules.\n   */\n  public async hasAccessRulesPermission(docSession: OptDocSession): Promise<boolean> {\n    return this.hasAccessRulesPermissionSync(await this._getAccess(docSession));\n  }\n\n  public hasAccessRulesPermissionSync(permInfo: PermissionInfo): boolean {\n    return permInfo.getColumnAccess(SPECIAL_RULES_TABLE_ID, \"AccessRules\").perms.read === \"allow\";\n  }\n\n  /**\n   * Check if user is restricted from copying or downloading a doc, even if they can see it in full.\n   */\n  public async isRestrictedFromCopying(docSession: OptDocSession): Promise<boolean> {\n    return this.isRestrictedFromCopyingSync(await this._getAccess(docSession));\n  }\n\n  public isRestrictedFromCopyingSync(permInfo: PermissionInfo): boolean {\n    return permInfo.getColumnAccess(SPECIAL_RULES_TABLE_ID, \"DocCopies\").perms.read !== \"allow\";\n  }\n\n  /**\n   * Check whether user can read everything in document.  Checks both home-level and doc-level\n   * permissions.\n   */\n  public async canReadEverything(\n    docSession: OptDocSession,\n    options: { role?: Role | null } = {},\n  ): Promise<boolean> {\n    const access = options.role ?? await this.getNominalAccess(docSession);\n    if (!canView(access)) { return false; }\n    const permInfo = await this._getAccess(docSession);\n    return this.getReadPermission(permInfo.getFullAccess()) === \"allow\";\n  }\n\n  /**\n   * Allow if user can read all data, or is an owner.\n   * Might be worth making a special permission.\n   * At the time of writing, used for:\n   *   - findColFromValues\n   *   - autocomplete\n   *   - unfiltered access to attachment metadata\n   */\n  public async canScanData(docSession: OptDocSession): Promise<boolean> {\n    return await this.isOwner(docSession) || await this.canReadEverything(docSession);\n  }\n\n  /**\n   * Check whether user can copy everything in document.  Owners can always copy\n   * everything, even if there are rules that specify they cannot.\n   *\n   * There's a small wrinkle about access rules.  The content\n   * of _grist_ACLRules and Resources are only send to clients that are owners,\n   * but could be copied by others by other means (e.g. download) as long as all\n   * tables or columns are readable. This seems ok (no private info involved),\n   * just a bit inconsistent.\n   */\n  public async canCopyEverything(docSession: OptDocSession): Promise<boolean> {\n    if (!this._ruler.haveRules()) { return true; }\n    const permInfo = await this._getAccess(docSession);\n    return this.hasFullCopiesPermissionSync(permInfo) || (\n      await this.canReadEverything(docSession) &&\n      this.hasAccessRulesPermissionSync(permInfo) &&\n      !this.isRestrictedFromCopyingSync(permInfo)\n    );\n  }\n\n  /**\n   * Check whether user has full access to the document.  Currently that is interpreted\n   * as equivalent owner-level access to the document.\n   * TODO: uses of this method should be checked to see if they can be fleshed out\n   * now we have more of the ACL implementation done.\n   */\n  public hasFullAccess(docSession: OptDocSession): Promise<boolean> {\n    return this.isOwner(docSession);\n  }\n\n  /**\n   * Check whether user has owner-level access to the document.\n   */\n  public async isOwner(docSession: OptDocSession): Promise<boolean> {\n    const access = await this.getNominalAccess(docSession);\n    return access === \"owners\";\n  }\n\n  /**\n   *\n   * If the user does not have access to the full document, we need to filter out\n   * parts of the document metadata.  For simplicity, we overwrite rather than\n   * filter for now, so that the overall structure remains consistent.  We overwrite:\n   *\n   *   - names, textual ids, formulas, and other textual options\n   *   - foreign keys linking columns/views/sections back to a forbidden table\n   *\n   * On the client, a page with a blank name will be marked gracefully as unavailable.\n   *\n   * Some information leaks, for example the existence of private tables and how\n   * many columns they had, and something of the relationships between them. Long term,\n   * it could be better to zap rows entirely, and do the work of cleaning up any cross\n   * references to them.\n   *\n   */\n  public async filterMetaTables(docSession: OptDocSession,\n    tables: { [key: string]: TableDataAction }): Promise<{ [key: string]: TableDataAction }> {\n    // If user has right to read everything, return immediately.\n    if (await this.canReadEverything(docSession)) { return tables; }\n    // If we are going to modify metadata, make a copy.\n    tables = cloneDeep(tables);\n\n    // Prepare cell censorship information.\n    const cells = new CellData(this._docData).convertToCells(tables._grist_Cells);\n    let cellCensor: CellAccessHelper | undefined;\n    if (cells.length > 0) {\n      cellCensor = this._createCellAccess(docSession);\n      await cellCensor.calculate(cells);\n    }\n\n    const permInfo = await this._getAccess(docSession);\n    const censor = new CensorshipInfo(permInfo, this._ruler.ruleCollection, tables,\n      await this.hasAccessRulesPermission(docSession),\n      cellCensor);\n    if (cellCensor) {\n      censor.filter(tables._grist_Cells);\n    }\n\n    for (const tableId of STRUCTURAL_TABLES) {\n      censor.filter(tables[tableId]);\n    }\n    if (await this.needAttachmentControl(docSession)) {\n      // Attachments? No attachments here (whistles innocently).\n      // Computing which attachments user has access to would require\n      // looking at entire document, which we don't want to do. So instead\n      // we'll be sending this info on a need-to-know basis later.\n      const attachments = tables._grist_Attachments;\n      attachments[2] = [];\n      Object.values(attachments[3]).forEach((values) => {\n        values.length = 0;\n      });\n    }\n    return tables;\n  }\n\n  /**\n   * Distill the clauses for the given session and table, to figure out the\n   * access level and any row-level access functions needed.\n   */\n  public async getTableAccess(docSession: OptDocSession, tableId: string): Promise<TablePermissionSetWithContext> {\n    if (this._hasExceptionalFullAccess(docSession)) {\n      return {\n        perms: { read: \"allow\", create: \"allow\", delete: \"allow\", update: \"allow\", schemaEdit: \"allow\" },\n        ruleType: \"table\",\n        getMemos() { throw new Error(\"never needed\"); },\n      };\n    }\n    return (await this._getAccess(docSession)).getTableAccess(tableId);\n  }\n\n  /**\n   * Modify table data in place, removing any rows or columns to which access\n   * is not granted.\n   */\n  public async filterData(docSession: OptDocSession, data: TableDataAction) {\n    const permInfo = await this._getAccess(docSession);\n    const cursor: ActionCursor = { docSession, action: data, actionIdx: null };\n    const tableId = getTableId(data);\n    if (this.getReadPermission(permInfo.getTableAccess(tableId)) === \"mixed\") {\n      const readAccessCheck = this._readAccessCheck(docSession);\n      await this._filterRowsAndCells(cursor, data, data, readAccessCheck, { allowRowRemoval: true });\n    }\n\n    // Filter columns, omitting any to which the user has no access, regardless of rows.\n    this._filterColumns(\n      data[3],\n      colId => this.getReadPermission(permInfo.getColumnAccess(tableId, colId)) !== \"deny\");\n  }\n\n  public async getUserOverride(docSession: OptDocSession): Promise<UserOverride | undefined> {\n    await this.getUser(docSession);\n    return this._getUserAttributes(docSession).override;\n  }\n\n  public getReadPermission(ps: PermissionSetWithContext) {\n    return ps.perms.read;\n  }\n\n  public assertCanRead(ps: PermissionSetWithContext) {\n    accessChecks.fatal.read.get(ps);\n  }\n\n  /**\n   * Broadcast document changes to all clients, with appropriate filtering.\n   */\n  public async sendDocUpdateForBundle(actionGroup: ActionGroup, docUsage: DocUsageSummary) {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    const { docActions, docSession } = this._activeBundle;\n    const client = docSession?.client || null;\n    const message: DocUpdateMessage = { actionGroup, docActions, docUsage };\n    await this._docClients.broadcastDocMessage(client, \"docUserAction\",\n      message,\n      _docSession => this._filterDocUpdate(_docSession, message));\n  }\n\n  /**\n   * Called when uploads occur. We record the fact that the specified attachment\n   * ids originated in uploads by the current user, for a certain length of time.\n   * During that time, attempts by the user to use these attachment ids in an\n   * attachment column will be accepted. The user is identified by SessionID,\n   * which is a user id for logged in users, and a session-unique id for\n   * anonymous users accessing Grist from a browser.\n   *\n   * A remaining weakness of this protection could be if attachment ids were\n   * reused, and reused quickly. Attachments can be deleted after\n   * REMOVE_UNUSED_ATTACHMENTS_DELAY and on document shutdown. We keep\n   * UPLOADED_ATTACHMENT_OWNERSHIP_PERIOD less than REMOVE_UNUSED_ATTACHMENTS_DELAY,\n   * and wipe our records on document shutdown.\n   */\n  public async noteUploads(docSession: OptDocSession, attIds: number[]) {\n    const user = await this.getUser(docSession);\n    const id = user.SessionID;\n    if (!id) {\n      log.rawError(\"noteUploads needs a SessionID\", {\n        docId: this._docId,\n        attIds,\n        userId: user.UserID,\n      });\n      return;\n    }\n    for (const attId of attIds) {\n      this._attachmentUploads.set(attId, id);\n    }\n  }\n\n  // Remove cached access information for a given session.\n  public flushAccess(docSession: OptDocSession) {\n    this._ruler.flushAccess(docSession);\n    this._userAttributesMap.delete(docSession);\n    this._prevUserAttributesMap?.delete(docSession);\n  }\n\n  // Get a set of example users for playing with access control.\n  // We use the example.com domain, which is reserved for uses like this.\n  public getExampleViewAsUsers(): UserAccessData[] {\n    return [\n      { id: 0, email: \"owner@example.com\", name: \"Owner\", access: \"owners\" },\n      { id: 0, email: \"editor1@example.com\", name: \"Editor 1\", access: \"editors\" },\n      { id: 0, email: \"editor2@example.com\", name: \"Editor 2\", access: \"editors\" },\n      { id: 0, email: \"viewer@example.com\", name: \"Viewer\", access: \"viewers\" },\n      { id: 0, email: \"unknown@example.com\", name: \"Unknown User\", access: null },\n    ];\n  }\n\n  // Compile a list of users mentioned in user attribute tables keyed by email.\n  // If there is a Name column or an Access column, in the table, we use them.\n  public async collectViewAsUsersFromUserAttributeTables(): Promise<Partial<UserAccessData>[]> {\n    const result: Partial<UserAccessData>[] = [];\n    const seenEmails = new Set();\n    for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) {\n      if (clause.charId !== \"Email\") { continue; }\n      try {\n        const users = await this._fetchQueryFromDB({\n          tableId: clause.tableId,\n          filters: {},\n        });\n        const user = new RecordView(users, undefined);\n        const count = users[2].length;\n        for (let i = 0; i < count; i++) {\n          user.index = i;\n          const emailRaw = user.get(clause.lookupColId);\n          if (!emailRaw) { continue; }\n          const email = String(emailRaw);\n          const emailLower = email.toLowerCase();\n          // Avoid adding multiple users that differ only in case of email, since later we match\n          // case-insensitively anyway.\n          if (seenEmails.has(emailLower)) { continue; }\n          seenEmails.add(emailLower);\n          const name = user.get(\"Name\") || email.split(\"@\")[0];\n          const access = user.has(\"Access\") ? String(user.get(\"Access\")) : \"editors\";\n          result.push({\n            email,\n            name: name ? String(name) : undefined,\n            access: isValidRole(access) ? access : null,  // 'null' -> null a bit circuitously\n          });\n        }\n      } catch (e) {\n        log.warn(`User attribute ${clause.name} failed`, e);\n      }\n    }\n    return result;\n  }\n\n  /**\n   * Get the role the session user has for this document.  User may be overridden,\n   * in which case the role of the override is returned.\n   * The forkingAsOwner flag of docSession should not be respected for non-owners,\n   * so that the pseudo-ownership it offers is restricted to granular access within a\n   * document (as opposed to document-level operations).\n   */\n  public async getNominalAccess(docSession: OptDocSession): Promise<Role | null> {\n    const linkParameters = docSession.linkParameters || {};\n    const baseAccess = getDocSessionAccess(docSession);\n    if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === \"owners\") {\n      const info = await this.getUser(docSession);\n      return info.Access;\n    }\n    return baseAccess;\n  }\n\n  public async createSnapshotWithCells(docActions: DocAction[]) {\n    const rows = new Map(getRelatedRows(docActions));\n    const cellData = new CellData(this._docData);\n    for (const action of docActions) {\n      for (const cell of cellData.convertToCells(action)) {\n        if (!rows.has(cell.tableId)) { rows.set(cell.tableId, new Set()); }\n        rows.get(cell.tableId)?.add(cell.rowId);\n      }\n    }\n    // Don't need to sync _grist_Cells table, since we already have it.\n    rows.delete(\"_grist_Cells\");\n    // Populate a minimal in-memory version of the database with these rows.\n    const docData = new DocData(\n      async (tableId) => {\n        return {\n          tableData: await this._fetchQueryFromDB(\n            { tableId, filters: { id: [...rows.get(tableId)!] } }),\n        };\n      }, {\n        _grist_Cells: this._docData.getMetaTable(\"_grist_Cells\").getTableDataAction(),\n        // We need some basic table information to translate numeric ids to string ids (refs to ids).\n        _grist_Tables: this._docData.getMetaTable(\"_grist_Tables\").getTableDataAction(),\n        _grist_Tables_column: this._docData.getMetaTable(\"_grist_Tables_column\").getTableDataAction(),\n      },\n    );\n    // Load pre-existing rows touched by the bundle.\n    await Promise.all([...rows.keys()].map(tableId => docData.syncTable(tableId)));\n    return docData;\n  }\n\n  // Return true if attachment info must be sent on a need-to-know basis.\n  public async needAttachmentControl(docSession: OptDocSession) {\n    return !await this.canScanData(docSession);\n  }\n\n  /**\n   * An optimization to catch obvious access problems for simple data\n   * actions (such as UpdateRecord, BulkAddRecord, etc) early. Checks\n   * actions one by one (nesting into ApplyUndoActions and\n   * ApplyDocActions as needed) until meeting one that isn't a simple\n   * data action. Checks are crude, and limited to the table access\n   * level. Returns true if all actions were checked, false if\n   * not. Returning true does not imply the actions in the bundle are\n   * permissible; returning false does not imply they should be\n   * denied. Throwing an error DOES imply that an action was\n   * encountered that should be denied.\n   */\n  private async _checkSimpleDataActions(docSession: OptDocSession, actions: UserAction[]): Promise<boolean> {\n    for (const action of actions) {\n      if (!await this._checkSimpleDataAction(docSession, action)) {\n        return false;\n      }\n    }\n    return true;\n  }\n\n  /**\n   * Throws an error for simple data actions that the user cannot perform.\n   * Checking is only at the table level. Returns true if the action clearly\n   * does not change the document schema or metadata, otherwise false if it might.\n   */\n  private async _checkSimpleDataAction(docSession: OptDocSession, a: UserAction | DocAction): Promise<boolean> {\n    const name = a[0] as string;\n    if (name === \"ApplyUndoActions\") {\n      return this._checkSimpleDataActions(docSession, a[1] as UserAction[]);\n    } else if (name === \"ApplyDocActions\") {\n      return this._checkSimpleDataActions(docSession, a[1] as UserAction[]);\n    } else if (isDataAction(a)) {\n      const tableId = getTableId(a);\n      if (isMetadataTable(tableId)) {\n        return false;\n      }\n      const tableAccess = await this.getTableAccess(docSession, tableId);\n      const accessCheck = await this._getAccessForActionType(docSession, a, \"fatal\");\n      accessCheck.get(tableAccess);  // will throw if access denied.\n      return true;\n    } else {\n      // Any other action might change schema, so continuing could lead\n      // to false detections of failures. For example, renaming a column\n      // and then updating cells within it should be allowed.\n      return false;\n    }\n  }\n\n  private async _checkForSpecialOrSurprisingActions(docSession: OptDocSession,\n    actions: UserAction[]) {\n    await applyToActionsRecursively(actions, async (a) => {\n      const name = String(a[0]);\n      if (SPECIAL_ACTIONS.has(name)) {\n        if (await this.hasNuancedAccess(docSession)) {\n          throw new ErrorWithCode(\"ACL_DENY\", `Blocked by access rules: '${name}' actions need uncomplicated access`);\n        }\n      } else if (SURPRISING_ACTIONS.has(name)) {\n        if (!await this.hasFullAccess(docSession)) {\n          throw new ErrorWithCode(\"ACL_DENY\", `Blocked by access rules: '${name}' actions need full access`);\n        }\n      } else if (OK_ACTIONS.has(name)) {\n        // fine, anyone can do these at any time, continue.\n      } else if (OTHER_RECOGNIZED_ACTIONS.has(name)) {\n        // these are known actions that have not been specifically classified.\n      } else {\n        // we've hit something unexpected - perhaps a UserAction has been added\n        // without considering access control.\n        throw new ErrorWithCode(\"ACL_DENY\", `Blocked by access rules: '${name}' actions are not controlled`);\n      }\n    });\n  }\n\n  // AddOrUpdateRecord requires broad read access to a table.\n  // But tables can be renamed, and access can be granted and removed\n  // within a bundle.\n  //\n  // For now, we forbid the combination of AddOrUpdateRecord and\n  // with actions other than other AddOrUpdateRecords, or simple data\n  // changes.\n  //\n  // Access rules and user attributes might change during the bundle.\n  // We deny based on access rights at the beginning of the bundle,\n  // as for _checkPossiblePythonFormulaModification. This is on the\n  // theory that someone who can change access rights can do anything.\n  //\n  // There might be uses for applying AddOrUpdateRecord in a nuanced\n  // way within the scope of what a user can read, but there's no easy\n  // way to do that within the data engine as currently\n  // formulated. Could perhaps be done for on-demand tables though.\n  private async _checkAddOrUpdateAccess(docSession: OptDocSession, actions: UserAction[]) {\n    if (!scanActionsRecursively(actions, isAddOrUpdateRecordAction)) {\n      // Don't need to apply this particular check.\n      return;\n    }\n\n    await this._assertOnlyBundledWithSimpleDataActions(ADD_OR_UPDATE_RECORD_ACTIONS, actions);\n    // Check for read access, and that we're not touching metadata.\n    await applyToActionsRecursively(actions, async (a) => {\n      if (!isAddOrUpdateRecordAction(a)) { return; }\n      const actionName = String(a[0]);\n      const tableId = validTableIdString(a[1]);\n      if (isMetadataTable(tableId)) {\n        throw new Error(`${actionName} cannot yet be used on metadata tables`);\n      }\n      const tableAccess = await this.getTableAccess(docSession, tableId);\n      accessChecks.fatal.read.throwIfNotFullyAllowed(tableAccess);\n      accessChecks.fatal.update.throwIfDenied(tableAccess);\n      accessChecks.fatal.create.throwIfDenied(tableAccess);\n    });\n  }\n\n  /**\n   * Asserts that `actionNames` (if present in `actions`) are only bundled with simple data actions.\n   */\n  private async _assertOnlyBundledWithSimpleDataActions(actionNames: string | string[], actions: UserAction[]) {\n    const names = Array.isArray(actionNames) ? actionNames : [actionNames];\n    // Fail if being combined with anything that isn't a simple data action.\n    await applyToActionsRecursively(actions, async (a) => {\n      const name = String(a[0]);\n      if (!names.includes(name) && !(isDataAction(a) && !isMetadataTable(getTableId(a)))) {\n        throw new Error(`Can only combine ${names.join(\" and \")} with simple data changes`);\n      }\n    });\n  }\n\n  private async _checkIfNeedsEarlySchemaPermission(docSession: OptDocSession, actions: UserAction[]) {\n    // If changes could include Python formulas, then user must have\n    // +S before we even consider passing these to the data engine.\n    // Since we don't track rule or schema changes at this stage, we\n    // approximate with the user's access rights at beginning of\n    // bundle.\n    // We also check for +S in scenarios that are hard to break down\n    // in a more granular way, for example ConvertFromColumn and\n    // CopyFromColumn.\n    if (scanActionsRecursively(actions, a => this.needEarlySchemaPermission(a))) {\n      await this._assertSchemaAccess(docSession);\n    }\n  }\n\n  /**\n   * Like `_checkAddOrUpdateAccess`, but for DuplicateTable actions.\n   *\n   * Permitted only when a user has full access, or full table read and schema edit\n   * access for the table being duplicated.\n   *\n   * Currently, DuplicateTable cannot be combined with other action types, including\n   * simple data actions. This may be relaxed in the future, but should only be done\n   * after careful consideration of its implications.\n   */\n  private async _checkDuplicateTableAccess(docSession: OptDocSession, actions: UserAction[]) {\n    if (!scanActionsRecursively(actions, ([actionName]) => String(actionName) === \"DuplicateTable\")) {\n      // Don't need to apply this particular check.\n      return;\n    }\n\n    // Fail if being combined with another action.\n    await applyToActionsRecursively(actions, async ([actionName]) => {\n      if (String(actionName) !== \"DuplicateTable\") {\n        throw new Error(\"DuplicateTable currently cannot be combined with other actions\");\n      }\n    });\n\n    // Check for read and schema edit access, and that we're not duplicating metadata tables.\n    await applyToActionsRecursively(actions, async (a) => {\n      const tableId = validTableIdString(a[1]);\n      if (isMetadataTable(tableId)) {\n        throw new Error(\"DuplicateTable cannot be used on metadata tables\");\n      }\n      if (await this.hasFullAccess(docSession)) { return; }\n\n      const tableAccess = await this.getTableAccess(docSession, tableId);\n      accessChecks.fatal.read.throwIfNotFullyAllowed(tableAccess);\n      accessChecks.fatal.schemaEdit.throwIfDenied(tableAccess);\n\n      const includeData = a[3];\n      if (includeData) {\n        accessChecks.fatal.create.throwIfDenied(tableAccess);\n      }\n    });\n  }\n\n  /**\n   * Asserts that user has schema access.\n   */\n  private async _assertSchemaAccess(docSession: OptDocSession) {\n    if (this._hasExceptionalFullAccess(docSession)) { return; }\n    const permInfo = await this._getAccess(docSession);\n    accessChecks.fatal.schemaEdit.throwIfDenied(permInfo.getFullAccess());\n  }\n\n  // The AccessCheck for the \"read\" permission is used enough to merit a shortcut.\n  // We just need to be careful to retain unfettered access for exceptional sessions.\n  private _readAccessCheck(docSession: OptDocSession): IAccessCheck {\n    return this._hasExceptionalFullAccess(docSession) ? dummyAccessCheck : accessChecks.check.read;\n  }\n\n  // Return true for special system sessions or document-creation sessions, where\n  // unfettered access is appropriate.\n  private _hasExceptionalFullAccess(docSession: OptDocSession): boolean {\n    return docSession.mode === \"system\" || docSession.mode === \"nascent\";\n  }\n\n  /**\n   * This filters a message being broadcast to all clients to be appropriate for one\n   * particular client, if that client may need some material filtered out.\n   */\n  private async _filterDocUpdate(docSession: OptDocSession, message: DocUpdateMessage) {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    const role = await this.getNominalAccess(docSession);\n    const result = {\n      ...message,\n      docUsage: await this.filterDocUsageSummary(docSession, message.docUsage, { role }),\n    };\n    if (!this._ruler.haveRules() && !this._activeBundle.hasDeliberateRuleChange) {\n      return result;\n    }\n    result.actionGroup = await this.filterActionGroup(docSession, message.actionGroup, { role });\n    result.docActions = await this.filterOutgoingDocActions(docSession, message.docActions);\n    if (result.docActions.length === 0) { return null; }\n    return result;\n  }\n\n  private async _updateRules(docActions: DocAction[]) {\n    // If there is a rule change, redo from scratch for now.\n    // TODO: this is placeholder code. Should deal with connected clients.\n    if (docActions.some(docAction => isAclTable(getTableId(docAction)))) {\n      await this.update();\n      return;\n    }\n    const shares = this._docData.getMetaTable(\"_grist_Shares\");\n    if (shares.getRowIds().length > 0 &&\n      docActions.some(action => isMetadataTable(getTableId(action)))) {\n      await this.update();\n      return;\n    }\n    if (!shares && !this._ruler.haveRules()) {\n      return;\n    }\n    // If there is a schema change, redo from scratch for now.\n    if (docActions.some(docAction => isSchemaAction(docAction))) {\n      await this.update();\n    }\n  }\n\n  /**\n   * Strip out any denied columns from an action.  Returns null if nothing is left.\n   * accessCheck may throw if denials are fatal.\n   */\n  private _pruneColumns(a: DocAction, permInfo: IPermissionInfo, tableId: string,\n    accessCheck: IAccessCheck): DocAction | null {\n    permInfo = new TransformColumnPermissionInfo(permInfo);\n    if (a[0] === \"RemoveRecord\" || a[0] === \"BulkRemoveRecord\") {\n      return a;\n    } else if (a[0] === \"AddRecord\" || a[0] === \"BulkAddRecord\" || a[0] === \"UpdateRecord\" ||\n      a[0] === \"BulkUpdateRecord\" || a[0] === \"ReplaceTableData\" || a[0] === \"TableData\") {\n      const na = cloneDeep(a);\n      this._filterColumns(na[3], colId => accessCheck.get(permInfo.getColumnAccess(tableId, colId)) !== \"deny\");\n      if (Object.keys(na[3]).length === 0) { return null; }\n      return na;\n    } else if (a[0] === \"AddColumn\" || a[0] === \"RemoveColumn\" || a[0] === \"RenameColumn\" ||\n      a[0] === \"ModifyColumn\") {\n      const colId: string = a[2];\n      if (accessCheck.get(permInfo.getColumnAccess(tableId, colId)) === \"deny\") { return null; }\n    } else {\n      // Remaining cases of AddTable, RemoveTable, RenameTable should have\n      // been handled at the table level.\n    }\n    return a;\n  }\n\n  /**\n   * Strip out any denied rows from an action.  The action may be rewritten if rows\n   * become allowed or denied during the action.  An action to add newly-allowed\n   * rows may be included, or an action to remove newly-forbidden rows.  The result\n   * is a list rather than a single action.  It may be the empty list.\n   */\n  private async _pruneRows(cursor: ActionCursor): Promise<DocAction[]> {\n    const { action } = cursor;\n    // This only deals with Record-related actions.\n    if (!isDataAction(action)) { return [action]; }\n\n    // Get before/after state for this action.  Broadcasts to other users can make use of the\n    // same state, so we share it (and only compute it if needed).\n    const { rowsBefore, rowsAfter } = await this._getRowsBeforeAndAfter(cursor);\n\n    // Figure out which rows were forbidden to this session before this action vs\n    // after this action.  We need to know both so that we can infer the state of the\n    // client and send the correct change.\n    const orderedIds = getRowIdsFromDocAction(action);\n    const ids = new Set(orderedIds);\n    const forbiddenBefores = new Set(await this._getForbiddenRows(cursor, rowsBefore, ids));\n    const forbiddenAfters = new Set(await this._getForbiddenRows(cursor, rowsAfter, ids));\n\n    /**\n     * For rows forbidden before and after: just remove them.\n     * For rows allowed before and after: just leave them unchanged.\n     * For rows that were allowed before and are now forbidden:\n     *   - strip them from the current action.\n     *   - add a BulkRemoveRecord for them.\n     * For rows that were forbidden before and are now allowed:\n     *   - remove them from the current action.\n     *   - add a BulkAddRecord for them.\n     */\n\n    const removals = new Set<number>();      // rows to remove from current action.\n    const forceAdds = new Set<number>();     // rows to add, that were previously stripped.\n    const forceRemoves = new Set<number>();  // rows to remove, that have become forbidden.\n    for (const id of ids) {\n      const forbiddenBefore = forbiddenBefores.has(id);\n      const forbiddenAfter = forbiddenAfters.has(id);\n      if (!forbiddenBefore && !forbiddenAfter) { continue; }\n      if (forbiddenBefore && forbiddenAfter) {\n        removals.add(id);\n        continue;\n      }\n      // If we reach here, then access right to the row changed and we have fancy footwork to do.\n      if (forbiddenBefore) {\n        // The row was forbidden and now is allowed.  That's trivial if the row was just added.\n        if (action[0] === \"AddRecord\" || action[0] === \"BulkAddRecord\" ||\n          action[0] === \"ReplaceTableData\" || action[0] === \"TableData\") {\n          continue;\n        }\n        // Otherwise, strip the row from the current action.\n        removals.add(id);\n        if (action[0] === \"UpdateRecord\" || action[0] === \"BulkUpdateRecord\") {\n          // For updates, we need to send the entire row as an add, since the client\n          // doesn't know anything about it yet.\n          forceAdds.add(id);\n        } else {\n          // Remaining cases are [Bulk]RemoveRecord.\n        }\n      } else {\n        // The row was allowed and now is forbidden.\n        // If the action is a removal, that is just right.\n        if (action[0] === \"RemoveRecord\" || action[0] === \"BulkRemoveRecord\") { continue; }\n        // Otherwise, strip the row from the current action.\n        removals.add(id);\n        if (action[0] === \"UpdateRecord\" || action[0] === \"BulkUpdateRecord\") {\n          // For updates, we need to remove the entire row.\n          forceRemoves.add(id);\n        } else {\n          // Remaining cases are add-like actions.\n        }\n      }\n    }\n    // Execute our cunning plans for DocAction revisions.\n    const revisedDocActions = [\n      this._makeAdditions(rowsAfter, forceAdds),\n      this._removeRows(action, removals),\n      this._makeRemovals(rowsAfter, forceRemoves),\n    ].filter(isNonNullish);\n\n    // Check whether there are column rules for this table, and if so whether they are row\n    // dependent.  If so, we may need to update visibility of cells not mentioned in the\n    // original DocAction.\n    // No censorship is done here, all we do at this point is pull in any extra cells that need\n    // to be updated for the current client.  Censorship for these cells, and any cells already\n    // present in the DocAction, is done by _filterRowsAndCells.\n    if (!isSomeRemoveRecordAction(action)) {  // Nothing to do for remove actions, which don't have cell data.\n      const ruler = await this._getRuler(cursor);\n      const tableId = getTableId(action);\n      const ruleSets = ruler.ruleCollection.getAllColumnRuleSets(tableId);\n      for (const ruleSet of ruleSets) {\n        // Skip table-wide rules, we are only looking for column rules for cell-censoring.\n        if (ruleSet.colIds === \"*\") { continue; }\n\n        // If any column is already in the DocAction, we can skip checking if we need to add it.\n        const colValues = getActionColValues(action);\n        const colIdsToCheck: string[] = ruleSet.colIds.filter(colId => !(colId in colValues));\n        if (colIdsToCheck.length === 0) { continue; }\n\n        // If this column ruleSet is not row dependent, we have nothing to do.\n        const access = await ruler.getAccess(cursor.docSession);\n        if (access.getColumnRuleSetAspect(ruleSet).read !== \"mixed\") { continue; }\n\n        // Check accessibility of columns in this ruleSet before and after.\n        const _forbiddenBefores = new Set(await this._getForbiddenRows(cursor, rowsBefore, ids, ruleSet));\n        const _forbiddenAfters = new Set(await this._getForbiddenRows(cursor, rowsAfter, ids, ruleSet));\n        // For any column that is in a visible row and for which accessibility has changed,\n        // pull it into the doc actions.  We don't censor cells yet, that happens later\n        // (if that's what needs doing).\n        const changedIds = orderedIds.filter(id => !forceRemoves.has(id) && !removals.has(id) &&\n          (_forbiddenBefores.has(id) !== _forbiddenAfters.has(id)));\n        if (changedIds.length > 0) {\n          revisedDocActions.push(this._makeColumnUpdate(rowsAfter, colIdsToCheck, new Set(changedIds)));\n        }\n      }\n    }\n\n    // Return the results, also applying any cell-level access control.\n    const readAccessCheck = this._readAccessCheck(cursor.docSession);\n    const filteredDocActions: DocAction[] = [];\n    for (const a of revisedDocActions) {\n      const { filteredAction } =\n        await this._filterRowsAndCells({ ...cursor, action: a }, rowsAfter, rowsAfter, readAccessCheck,\n          { allowRowRemoval: false, copyOnModify: true });\n      if (filteredAction) { filteredDocActions.push(filteredAction); }\n    }\n    return filteredDocActions;\n  }\n\n  /**\n   * Like _pruneRows, but fails immediately if access to any row is forbidden.\n   * The accessCheck supplied should throw an error on denial.\n   */\n  private async _checkRows(cursor: ActionCursor, accessCheck: IAccessCheck): Promise<void> {\n    const { action } = cursor;\n    // This check applies to data changes only.\n    if (!isDataAction(action)) { return; }\n    const { rowsBefore, rowsAfter } = await this._getRowsForRecAndNewRec(cursor);\n    // If any change is needed, this call will fail immediately because we are using\n    // access checks that throw.\n    await this._filterRowsAndCells(cursor, rowsBefore, rowsAfter, accessCheck,\n      { allowRowRemoval: false });\n  }\n\n  private async _getRowsBeforeAndAfter(cursor: ActionCursor) {\n    const { rowsBefore, rowsAfter } = await this._getStep(cursor);\n    if (!rowsBefore || !rowsAfter) { throw new Error(\"Logic error: no rows available\"); }\n    return { rowsBefore, rowsAfter };\n  }\n\n  private async _getRowsForRecAndNewRec(cursor: ActionCursor) {\n    const steps = await this._getSteps();\n    if (cursor.actionIdx === null) { throw new Error(\"No step available\"); }\n    const { rowsBefore, rowsLast } = steps[cursor.actionIdx];\n    if (!rowsBefore) { throw new Error(\"Logic error: no previous rows available\"); }\n    if (rowsLast) {\n      return { rowsBefore, rowsAfter: rowsLast };\n    }\n    // When determining whether to apply an action, we choose to make newRec refer to the\n    // state at the end of the entire bundle.  So we look for the last pair of row snapshots\n    // for the same table.\n    // TODO: there's a problem that this could alias rows if row ids were reused within the\n    // same bundle. It is kind of a slippery idea. Likewise, column renames are slippery.\n    // We could solve a lot of slipperiness by having newRec not transition across schema\n    // changes, but we don't really have the option because formula updates happen late.\n    let tableId = getTableId(rowsBefore);\n    let last = cursor.actionIdx;\n    for (let i = last + 1; i < steps.length; i++) {\n      const act = steps[i].action;\n      if (getTableId(act) !== tableId) { continue; }\n      if (act[0] === \"RenameTable\") {\n        tableId = act[2];\n        continue;\n      }\n      last = i;\n    }\n    const rowsAfter = steps[cursor.actionIdx].rowsLast = steps[last].rowsAfter;\n    if (!rowsAfter) { throw new Error(\"Logic error: no next rows available\"); }\n    return { rowsBefore, rowsAfter };\n  }\n\n  /**\n   * Scrub any rows and cells to which access is not granted from an\n   * action. Returns filteredAction, which is the provided action, a\n   * modified copy of the provided action, or null. It is null if the\n   * action was entirely eliminated (and was not a bulk action). It is\n   * a modified copy if any scrubbing was needed and copyOnModify is\n   * set, otherwise the original is modified in place.\n   *\n   * Also returns censoredRows, a set of indexes of rows that have a\n   * censored value in them.\n   *\n   * If allowRowRemoval is false, then rows will not be removed, and if the user\n   * does not have access to a row and the action itself is not a remove action, then\n   * an error will be thrown.  This flag setting is used when filtering outgoing\n   * actions, where actions need rewriting elsewhere to reflect access changes to\n   * rows for each individual client.\n   */\n  private async _filterRowsAndCells(cursor: ActionCursor, rowsBefore: TableDataAction, rowsAfter: TableDataAction,\n    accessCheck: IAccessCheck,\n    options: {\n      allowRowRemoval?: boolean,\n      copyOnModify?: boolean,\n    }): Promise<{\n    filteredAction: DocAction | null,\n    censoredRows: Set<number>\n  }> {\n    const censoredRows = new Set<number>();\n    const ruler = await this._getRuler(cursor);\n    const { docSession, action } = cursor;\n    if (action && isSchemaAction(action)) {\n      return { filteredAction: action, censoredRows };\n    }\n    let filteredAction: DocAction | null = action;\n\n    // For user convenience, for creations and deletions we equate rec and newRec.\n    // This makes writing rules that control multiple permissions easier to write in\n    // practice.\n    let rowsRec = rowsBefore;\n    let rowsNewRec = rowsAfter;\n    if (isSomeAddRecordAction(action)) {\n      rowsRec = rowsAfter;\n    } else if (isSomeRemoveRecordAction(action)) {\n      rowsNewRec = rowsBefore;\n    }\n\n    const rec = new RecordView(rowsRec, undefined);\n    const newRec = new RecordView(rowsNewRec, undefined);\n    const input: PredicateFormulaInput = { ...await this.inputs(docSession), rec, newRec };\n\n    const [, tableId, , colValues] = action;\n    let filteredColValues: ColValues | BulkColValues | undefined | null = null;\n    const rowIds = getRowIdsFromDocAction(action);\n    const toRemove: number[] = [];\n\n    // Call this to make sure we are modifying a copy, not the original, if copyOnModify is set.\n    const copyOnNeed = () => {\n      if (filteredColValues === null) {\n        filteredAction = options?.copyOnModify ? cloneDeep(action) : action;\n        filteredColValues = filteredAction[3];\n      }\n      return filteredColValues;\n    };\n    let censorAt: (colId: string, idx: number) => void;\n    if (colValues === undefined) {\n      censorAt = () => 1;\n    } else if (Array.isArray(action[2])) {\n      censorAt = (colId, idx) => (copyOnNeed() as BulkColValues)[colId][idx] = [GristObjCode.Censored];\n    } else {\n      censorAt = colId => (copyOnNeed() as ColValues)[colId] = [GristObjCode.Censored];\n    }\n\n    // These map an index of a row in the action to its index in rowsBefore and in rowsAfter.\n    let getRecIndex: (idx: number) => number | undefined = idx => idx;\n    let getNewRecIndex: (idx: number) => number | undefined = idx => idx;\n    if (action !== rowsRec) {\n      const recIndexes = new Map(rowsRec[2].map((rowId, idx) => [rowId, idx]));\n      getRecIndex = idx => recIndexes.get(rowIds[idx]);\n    }\n    if (action !== rowsNewRec) {\n      const newRecIndexes = new Map(rowsNewRec[2].map((rowId, idx) => [rowId, idx]));\n      getNewRecIndex = idx => newRecIndexes.get(rowIds[idx]);\n    }\n\n    for (let idx = 0; idx < rowIds.length; idx++) {\n      rec.index = getRecIndex(idx);\n      newRec.index = getNewRecIndex(idx);\n\n      const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);\n      // getTableAccess() evaluates all column rules for THIS record. So it's really rowAccess.\n      const rowAccess = rowPermInfo.getTableAccess(tableId);\n      const access = accessCheck.get(rowAccess);\n      if (access === \"deny\") {\n        toRemove.push(idx);\n      } else if (access !== \"allow\" && colValues) {\n        // Go over column rules.\n        for (const colId of Object.keys(colValues)) {\n          const colAccess = rowPermInfo.getColumnAccess(tableId, colId);\n          if (accessCheck.get(colAccess) === \"deny\") {\n            censorAt(colId, idx);\n            censoredRows.add(idx);\n          }\n        }\n      }\n    }\n\n    if (toRemove.length > 0) {\n      if (options.allowRowRemoval) {\n        copyOnNeed();\n        if (Array.isArray(filteredAction[2])) {\n          this._removeRowsAt(toRemove, filteredAction[2], filteredAction[3]);\n        } else {\n          filteredAction = null;\n        }\n      } else {\n        // Artificially introduced removals are ok, otherwise this is suspect.\n        if (filteredAction[0] !== \"RemoveRecord\" && filteredAction[0] !== \"BulkRemoveRecord\") {\n          throw new Error(\"Unexpected row removal\");\n        }\n      }\n    }\n    return { filteredAction, censoredRows };\n  }\n\n  // Compute which of the row ids supplied are for rows forbidden for this session.\n  // If colRuleSet is supplied, check instead whether any columns covered by it are forbidden.\n  private async _getForbiddenRows(cursor: ActionCursor, data: TableDataAction, ids: Set<number>,\n    colRuleSet?: RuleSet): Promise<number[]> {\n    const ruler = await this._getRuler(cursor);\n    const rec = new RecordView(data, undefined);\n    const input: PredicateFormulaInput = { ...await this.inputs(cursor.docSession), rec };\n\n    const [, tableId, rowIds] = data;\n    const toRemove: number[] = [];\n    for (let idx = 0; idx < rowIds.length; idx++) {\n      rec.index = idx;\n      if (!ids.has(rowIds[idx])) { continue; }\n\n      const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);\n      if (!colRuleSet) {\n        // getTableAspect() evaluates all column rules for THIS record. So it's really rowAccess.\n        const rowAccess = rowPermInfo.getTableAspect(tableId);\n        if (rowAccess.read === \"deny\") {\n          toRemove.push(rowIds[idx]);\n        }\n      } else {\n        const colAccess = rowPermInfo.getColumnRuleSetAspect(colRuleSet);\n        if (colAccess.read === \"deny\") {\n          toRemove.push(rowIds[idx]);\n        }\n      }\n    }\n    return toRemove;\n  }\n\n  /**\n   * Removes the toRemove rows (indexes, not row ids) from the rowIds list and from\n   * the colValues structure.\n   *\n   * toRemove must be sorted, lowest to highest.\n   */\n  private _removeRowsAt(toRemove: number[], rowIds: number[], colValues: BulkColValues | ColValues | undefined) {\n    if (toRemove.length > 0) {\n      pruneArray(rowIds, toRemove);\n      if (colValues) {\n        for (const values of Object.values(colValues)) {\n          pruneArray(values, toRemove);\n        }\n      }\n    }\n  }\n\n  /**\n   * Remove columns from a ColumnValues parameter of certain DocActions, using a predicate for\n   * which columns to keep.\n   * Will retain manualSort columns regardless of wildcards.\n   */\n  private _filterColumns(data: BulkColValues | ColValues, shouldInclude: (colId: string) => boolean) {\n    for (const colId of Object.keys(data)) {\n      if (colId !== \"manualSort\" && !shouldInclude(colId)) {\n        delete data[colId];\n      }\n    }\n  }\n\n  /**\n   * Get PermissionInfo for the user represented by the given docSession. The returned object\n   * allows evaluating access level as far as possible without considering specific records.\n   *\n   * The result is cached in a WeakMap, and PermissionInfo does its own caching, so multiple calls\n   * to this._getAccess(docSession).someMethod() will reuse already-evaluated results.\n   */\n  private async _getAccess(docSession: OptDocSession): Promise<PermissionInfo> {\n    // TODO The intent of caching is to avoid duplicating rule evaluations while processing a\n    // single request. Caching based on docSession is riskier since those persist across requests.\n    return this._ruler.getAccess(docSession);\n  }\n\n  private _getUserAttributes(docSession: OptDocSession): UserAttributes {\n    // TODO Same caching intent and caveat as for _getAccess\n    return getSetMapValue(this._userAttributesMap as Map<OptDocSession, UserAttributes>, docSession,\n      () => new UserAttributes());\n  }\n\n  /**\n   * Check whether user attributes have changed.  If so, prompt client\n   * to reload the document, since we aren't sophisticated enough to\n   * figure out the changes to send.\n   */\n  private async _checkUserAttributes(docSession: OptDocSession) {\n    if (!this._prevUserAttributesMap) { return; }\n    const userAttrBefore = this._prevUserAttributesMap.get(docSession);\n    if (!userAttrBefore) { return; }\n    await this._getAccess(docSession);  // Makes sure user attrs have actually been computed.\n    const userAttrAfter = this._getUserAttributes(docSession);\n    for (const [tableId, rec] of Object.entries(userAttrAfter.rows)) {\n      const prev = userAttrBefore.rows[tableId];\n      if (!prev) {\n        throw new ErrorWithCode(\"NEED_RELOAD\", \"document needs reload, user attributes appeared\");\n      }\n      // We used to check for any change, but now we look specifically\n      // to see if the value of a previous key has changed. This omits\n      // newly introduced keys, which can happen when a new column is\n      // added. Reloading at the point a column is added can be\n      // disruptive, since the user creating the column may be mid-action,\n      // with the next action being to add it to a view for example. And\n      // at the point a column is added, it presumably can't be used in\n      // access rules yet. So we now don't reload in this case.\n      //\n      // It is possible to imagine some changes to access rules that\n      // might invalidate this reasoning. How bad would it be if the\n      // reload logic and access rules functionality become\n      // inconsistent? The purpose of the reload is to keep the\n      // webclient incremental cache in sync with what the user would\n      // see if they were to reopen the document. If we fail to reload\n      // when we should, it is a confusing experience, but the user\n      // doesn't get any extra data they haven't already received.\n      for (const key of prev.keys()) {\n        if (JSON.stringify(prev.get(key)) !== JSON.stringify(rec.get(key))) {\n          throw new ErrorWithCode(\"NEED_RELOAD\", \"document needs reload, user attributes changed\");\n        }\n      }\n    }\n  }\n\n  /**\n   * Get the \"View As\" user specified in link parameters.\n   * If aclAsUserId is set, we get the user with the specified id.\n   * If aclAsUser is set, we get the user with the specified email,\n   * from the database if possible, otherwise from user attribute\n   * tables or examples.\n   */\n  private async _getViewAsUser(linkParameters: Record<string, string>): Promise<UserOverride> {\n    // Look up user information in database, if available\n    const dbUser = linkParameters.aclAsUserId ?\n      (await this._homeDbManager?.getUser(integerParam(linkParameters.aclAsUserId, \"aclAsUserId\"))) :\n      (await this._homeDbManager?.getExistingUserByLogin(linkParameters.aclAsUser));\n\n    const dbAccess = dbUser ? await this._homeDbManager?.getDocAuthCached({\n      urlId: this._docId,\n      userId: dbUser.id,\n    }) : null;\n\n    // If we want to preview as an existing user who has access to the document, we will use users' real\n    // access level.\n    if (dbUser && dbAccess?.access) {\n      return {\n        access: dbAccess.access,\n        user: this._homeDbManager?.makeFullUser(dbUser) || null,\n      };\n    } else if (linkParameters.aclAsUser) {\n      // Look further for the user, in user attribute tables or examples.\n      const otherUsers = (await this.collectViewAsUsersFromUserAttributeTables())\n        .concat(this.getExampleViewAsUsers());\n      const email = normalizeEmail(linkParameters.aclAsUser);\n      const dummyUser = otherUsers.find(user => normalizeEmail(user?.email || \"\") === email);\n      if (!dummyUser) {\n        // Make sure the user is in the table or examples, otherwise we return no access.\n        return { access: null, user: null };\n      } else {\n        let access = dummyUser.access || null;\n        if (!access) {\n          // In case the dummy user has no access to the document, check if the document\n          // is shared publicly, and there is a default access for anonymous users.\n          const docAuth =  await this._homeDbManager?.getDocAuthCached({\n            urlId: this._docId,\n            userId: this._homeDbManager.getAnonymousUserId(),\n          });\n          access = docAuth?.access || null;\n        }\n        return {\n          access,\n          user: {\n            id: -1,\n            email: dummyUser.email!,\n            name: dummyUser.name || dummyUser.email!,\n          },\n        };\n      }\n    } else {\n      return { access: null, user: null };\n    }\n  }\n\n  /**\n   * Remove a set of rows from a DocAction.  If the DocAction ends up empty, null is returned.\n   * If the DocAction needs modification, it is copied first - the original is never\n   * changed.\n   */\n  private _removeRows(a: DocAction, rowIds: Set<number>): DocAction | null {\n    // If there are no rows, there's nothing to do.\n    if (isSchemaAction(a)) { return a; }\n    if (a[0] === \"AddRecord\" || a[0] === \"UpdateRecord\" || a[0] === \"RemoveRecord\") {\n      return rowIds.has(a[2]) ? null : a;\n    }\n    const na = cloneDeep(a);\n    const [, , oldIds, bulkColValues] = na;\n    const mask = oldIds.map((id, idx) => rowIds.has(id) ? idx : false).filter(v => v !== false) as number[];\n    this._removeRowsAt(mask, oldIds, bulkColValues);\n    if (oldIds.length === 0) { return null; }\n    return na;\n  }\n\n  /**\n   * Make a BulkAddRecord for a set of rows.\n   */\n  private _makeAdditions(data: TableDataAction, rowIds: Set<number>): BulkAddRecord | null {\n    if (rowIds.size === 0) { return null; }\n    // TODO: optimize implementation, this does an unnecessary clone.\n    const notAdded = data[2].filter(id => !rowIds.has(id));\n    const partialData = this._removeRows(data, new Set(notAdded)) as TableDataAction | null;\n    if (partialData === null) { return partialData; }\n    return [\"BulkAddRecord\", partialData[1], partialData[2], partialData[3]];\n  }\n\n  /**\n   * Make a BulkRemoveRecord for a set of rows.\n   */\n  private _makeRemovals(data: TableDataAction, rowIds: Set<number>): BulkRemoveRecord | null {\n    if (rowIds.size === 0) { return null; }\n    return [\"BulkRemoveRecord\", getTableId(data), [...rowIds]];\n  }\n\n  /**\n   * Make a BulkUpdateRecord for a list of columns across a set of rows.\n   */\n  private _makeColumnUpdate(data: TableDataAction, colIds: string[], rowIds: Set<number>): BulkUpdateRecord {\n    const dataRowIds = getRowIds(data);\n    const selectedRowIds = dataRowIds.filter(r => rowIds.has(r));\n    const origColValues = getActionColValues(data);\n    const selectedColValues: BulkColValues = {};\n    // Include only the columns listed in colIds list.\n    for (const colId of colIds) {\n      // Filter to leave only the rows present in rowIds set.\n      selectedColValues[colId] = origColValues[colId].filter((value, idx) => rowIds.has(dataRowIds[idx]));\n    }\n    return [\"BulkUpdateRecord\", getTableId(data), selectedRowIds, selectedColValues];\n  }\n\n  private async _getSteps(): Promise<ActionStep[]> {\n    if (!this._steps) {\n      this._steps = this._getUncachedSteps().catch((e) => {\n        log.error(\"step computation failed:\", e);\n        throw e;\n      });\n    }\n    return this._steps;\n  }\n\n  private async _getMetaSteps(): Promise<MetaStep[]> {\n    if (!this._metaSteps) {\n      this._metaSteps = this._getUncachedMetaSteps().catch((e) => {\n        log.error(\"meta step computation failed:\", e);\n        throw e;\n      });\n    }\n    return this._metaSteps;\n  }\n\n  /**\n   * Prepare to compute intermediate states of rows, as\n   * this._steps.  The computation should happen only if\n   * needed, which depends on the rules and actions.  The computation\n   * uses the state of the database, and so depends on whether the\n   * docActions have already been applied to the database or not, as\n   * determined by the this._applied flag, which should never be\n   * changed during any possible use of this._steps.\n   */\n  private async _getUncachedSteps(): Promise<ActionStep[]> {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    const { docActions, undo, applied } = this._activeBundle;\n    // For row access work, we'll need to know the state of affected rows before and\n    // after the actions.\n    // First figure out what rows in which tables are touched during the actions.\n    const rows = new Map(getRelatedRows(applied ? [...undo].reverse() : docActions));\n    // Populate a minimal in-memory version of the database with these rows.\n    // We need sufficient metadata to know column types, if there are any row additions.\n    // Otherwise we may assume a cell contains \"null\" when it should contain \"false\" for\n    // example (for a Bool column).\n    const metaData = {\n      _grist_Tables: this._docData.getMetaTable(\"_grist_Tables\").getTableDataAction(),\n      _grist_Tables_column: this._docData.getMetaTable(\"_grist_Tables_column\").getTableDataAction(),\n    };\n    const docData = new DocData(\n      async (tableId) => {\n        return {\n          tableData: await this._fetchQueryFromDB({ tableId, filters: { id: [...rows.get(tableId)!] } }),\n        };\n      },\n      metaData,\n    );\n    // Load pre-existing rows touched by the bundle.\n    await Promise.all([...rows.keys()].map(tableId => docData.syncTable(tableId)));\n    if (applied) {\n      // Apply the undo actions, since the docActions have already been applied to the db.\n      for (const docAction of [...undo].reverse()) { docData.receiveAction(docAction); }\n    }\n\n    // Now step forward, storing the before and after state for the table\n    // involved in each action.  We'll use this to compute row access changes.\n    // For simple changes, the rows will be just the minimal set needed.\n    //\n    // TODO Warning! For a large table / large change (e.g. for the result of a Calculate that\n    // touches every row), cloning is heavy processing that consumes memory and blocks the server.\n    // Further, we are making a full copy for each action in the list. For many actions, this\n    // risks both exhausting memory and making the server unresponsive.\n    //\n    // This could definitely be optimized.  E.g. for pure table updates, these\n    // states could be extracted while applying undo actions, with no need for\n    // a forward pass.  And for a series of updates to the same table, there'll\n    // be duplicated before/after states that could be optimized.\n    const steps = new Array<ActionStep>();\n    for (const docAction of docActions) {\n      const tableId = getTableId(docAction);\n      const tableData = docData.getTable(tableId);\n      const rowsBefore = cloneDeep(tableData?.getTableDataAction() || [\"TableData\", \"\", [], {}] as TableDataAction);\n      docData.receiveAction(docAction);\n      // If table is deleted, state afterwards doesn't matter.\n      const rowsAfter = docData.getTable(tableId) ?\n        cloneDeep(tableData?.getTableDataAction() || [\"TableData\", \"\", [], {}] as TableDataAction) :\n        rowsBefore;\n      const step: ActionStep = { action: docAction, rowsBefore, rowsAfter };\n      steps.push(step);\n    }\n    return steps;\n  }\n\n  /**\n   * Prepare to compute intermediate metadata and rules, as this._metaSteps.\n   */\n  private async _getUncachedMetaSteps(): Promise<MetaStep[]> {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    const { docActions, undo, applied } = this._activeBundle;\n\n    const needMeta = docActions.some(a => isSchemaAction(a) || isMetadataTable(getTableId(a)));\n    if (!needMeta) {\n      // Sometimes, the intermediate states are trivial.\n      // TODO: look into whether it would be worth caching attachment columns.\n      const attachmentColumns = getAttachmentColumns(this._docData);\n      return docActions.map(action => ({ action, attachmentColumns }));\n    }\n    const metaDocData = new DocData(\n      async (tableId) => {\n        const result = this._docData.getTable(tableId)?.getTableDataAction();\n        if (!result) { throw new Error(\"surprising load\"); }\n        return { tableData: result };\n      },\n      null,\n    );\n    // Read the structural tables.\n    await Promise.all([...STRUCTURAL_TABLES].map(tableId => metaDocData.syncTable(tableId)));\n    if (applied) {\n      for (const docAction of [...undo].reverse()) { metaDocData.receiveAction(docAction); }\n    }\n    let meta = {} as { [key: string]: TableDataAction };\n    // Metadata is stored as a hash of TableDataActions.\n    for (const tableId of STRUCTURAL_TABLES) {\n      meta[tableId] = cloneDeep(metaDocData.getTable(tableId)!.getTableDataAction());\n    }\n\n    // Now step forward, tracking metadata and rules through any changes that occur.\n    const steps = new Array<MetaStep>();\n    let ruler = this._ruler;\n    if (applied) {\n      // Rules may have changed - back them off to a copy of their original state.\n      ruler = new Ruler(this);\n      await ruler.update(metaDocData);\n    }\n    let replaceRuler = false;\n    for (const docAction of docActions) {\n      const tableId = getTableId(docAction);\n      const step: MetaStep = { action: docAction };\n      step.metaBefore = meta;\n      if (STRUCTURAL_TABLES.has(tableId)) {\n        metaDocData.receiveAction(docAction);\n        // make shallow copy of all tables\n        meta = { ...meta };\n        // replace table just modified with a deep copy\n        meta[tableId] = cloneDeep(metaDocData.getTable(tableId)!.getTableDataAction());\n      }\n      step.metaAfter = meta;\n      // replaceRuler logic avoids updating rules between paired changes of resources and rules.\n      if (actionHasRuleChange(docAction)) {\n        replaceRuler = true;\n      } else if (replaceRuler) {\n        ruler = new Ruler(this);\n        await ruler.update(metaDocData);\n        replaceRuler = false;\n      }\n      step.ruler = ruler;\n      step.attachmentColumns = getAttachmentColumns(metaDocData);\n      steps.push(step);\n    }\n    return steps;\n  }\n\n  /**\n   * Return any permitted parts of an action.  A completely forbidden\n   * action results in an empty list.  Forbidden columns and rows will\n   * be stripped from a returned action.  Rows with forbidden cells are\n   * extracted and returned in distinct actions (since they will have\n   * a distinct set of columns).\n   *\n   * This method should only be called with data actions, and will throw\n   * for anything else.\n   */\n  private async _prefilterDocAction(cursor: ActionCursor): Promise<DocAction[]> {\n    const { action, docSession } = cursor;\n    const tableId = getTableId(action);\n    const permInfo = await this._getStepAccess(cursor);\n    const tableAccess = permInfo.getTableAccess(tableId);\n    const accessCheck = await this._getAccessForActionType(docSession, action, \"check\");\n    const access = accessCheck.get(tableAccess);\n    if (access === \"deny\") {\n      // Filter out this action entirely.\n      return [];\n    } else if (access === \"allow\") {\n      // Retain this action entirely.\n      return [action];\n    } else if (access === \"mixedColumns\") {\n      // Retain some or all columns entirely.\n      const act = this._pruneColumns(action, permInfo, tableId, accessCheck);\n      return act ? [act] : [];\n    }\n    // The remainder is the mixed condition.\n\n    const { rowsBefore, rowsAfter } = await this._getRowsForRecAndNewRec(cursor);\n    const { censoredRows, filteredAction } = await this._filterRowsAndCells({ ...cursor, action: cloneDeep(action) },\n      rowsBefore, rowsAfter, accessCheck,\n      { allowRowRemoval: true });\n    if (filteredAction === null) {\n      return [];\n    }\n    if (!isDataAction(filteredAction)) {\n      throw new Error(\"_prefilterDocAction called with unexpected action\");\n    }\n    if (isSomeRemoveRecordAction(filteredAction)) {\n      // removals do not mention columns or cells, so no further complications.\n      return [filteredAction];\n    }\n\n    // Strip any forbidden columns.\n    this._filterColumns(\n      filteredAction[3],\n      colId => accessCheck.get(permInfo.getColumnAccess(tableId, colId)) !== \"deny\");\n    if (censoredRows.size === 0) {\n      // no cell censorship, so no further complications.\n      return [filteredAction];\n    }\n\n    return filterColValues(filteredAction, idx => censoredRows.has(idx), gristTypes.isCensored);\n  }\n\n  /**\n   * Tailor the information about a change reported to a given client. The action passed in\n   * is never modified. The actions output may differ in the following ways:\n   *   - Tables, columns or rows may be omitted if the client does not have access to them.\n   *   - Columns in structural metadata tables may be cleared if the client does not have\n   *     access to the resources they relate to.\n   *   - Columns in the _grist_Views table may be cleared or uncleared depending on changes\n   *     in other metadata tables.\n   *   - Rows may be inserted if the client newly acquires access to them via an update.\n   * TODO: I think that column rules controlling READ access using rec are not fully supported\n   * yet.  They work on first load, but if READ access is lost/gained updates won't be made.\n   */\n  private async _filterOutgoingDocAction(cursor: ActionCursor): Promise<ActionCursor[]> {\n    const { action } = cursor;\n    const tableId = getTableId(action);\n\n    let results: DocAction[] = [];\n    if (isMetadataTable(tableId)) {\n      // Granular access rules don't apply to metadata directly, instead there\n      // is a process of censorship (see later in this method).\n      results = [action];\n    } else {\n      const permInfo = await this._getStepAccess(cursor);\n      const tableAccess = permInfo.getTableAccess(tableId);\n      const access = this.getReadPermission(tableAccess);\n      const readAccessCheck = this._readAccessCheck(cursor.docSession);\n      if (access === \"deny\") {\n        // filter out this data.\n      } else if (access === \"allow\") {\n        results.push(action);\n      } else if (access === \"mixedColumns\") {\n        const act = this._pruneColumns(action, permInfo, tableId, readAccessCheck);\n        if (act) { results.push(act); }\n      } else {\n        // The remainder is the mixed condition.\n        for (const act of await this._pruneRows(cursor)) {\n          const prunedAct = this._pruneColumns(act, permInfo, tableId, readAccessCheck);\n          if (prunedAct) { results.push(prunedAct); }\n        }\n      }\n    }\n    const secondPass: DocAction[] = [];\n    for (const act of results) {\n      if (STRUCTURAL_TABLES.has(getTableId(act)) && isDataAction(act)) {\n        await this._filterOutgoingStructuralTables(cursor, act, secondPass);\n      } else {\n        secondPass.push(act);\n      }\n    }\n    return secondPass.map(act => ({ ...cursor, action: act }));\n  }\n\n  private async _filterOutgoingStructuralTables(cursor: ActionCursor, act: DataAction, results: DocAction[]) {\n    // Filter out sensitive columns from tables.\n    const permissionInfo = await this._getStepAccess(cursor);\n    const step = await this._getMetaStep(cursor);\n    if (!step.metaAfter) { throw new Error(\"missing metadata\"); }\n    act = cloneDeep(act); // Don't change original action.\n    const ruler = await this._getRuler(cursor);\n    const censor = new CensorshipInfo(permissionInfo,\n      ruler.ruleCollection,\n      step.metaAfter,\n      await this.hasAccessRulesPermission(cursor.docSession));\n    if (censor.filter(act)) {\n      results.push(act);\n    }\n\n    // There's a wrinkle to deal with. If we just added or removed a section, we need to\n    // reconsider whether the view containing it is visible.\n    if (getTableId(act) === \"_grist_Views_section\") {\n      if (!step.metaBefore) { throw new Error(\"missing prior metadata\"); }\n      const censorBefore = new CensorshipInfo(permissionInfo,\n        ruler.ruleCollection,\n        step.metaBefore,\n        await this.hasAccessRulesPermission(cursor.docSession));\n      // For all views previously censored, if they are now uncensored,\n      // add an UpdateRecord to expose them.\n      for (const v of censorBefore.censoredViews) {\n        if (!censor.censoredViews.has(v)) {\n          const table = step.metaAfter._grist_Views;\n          const idx = table[2].indexOf(v);\n          const name = table[3].name[idx];\n          results.push([\"UpdateRecord\", \"_grist_Views\", v, { name }]);\n        }\n      }\n      // For all views currently censored, if they were previously uncensored,\n      // add an UpdateRecord to censor them.\n      for (const v of censor.censoredViews) {\n        if (!censorBefore.censoredViews.has(v)) {\n          results.push([\"UpdateRecord\", \"_grist_Views\", v, { name: \"\" }]);\n        }\n      }\n    }\n  }\n\n  private async _checkIncomingDocAction(cursor: ActionCursor): Promise<void> {\n    await this._checkIncomingAttachmentChanges(cursor);\n    const { action, docSession } = cursor;\n    const accessCheck = await this._getAccessForActionType(docSession, action, \"fatal\");\n    const tableId = getTableId(action);\n    const permInfo = await this._getStepAccess(cursor);\n    const tableAccess = permInfo.getTableAccess(tableId);\n    const access = accessCheck.get(tableAccess);\n    if (access === \"allow\") { return; }\n    if (access === \"mixed\") {\n      // Deal with row-level access for the mixed condition.\n      await this._checkRows(cursor, accessCheck);\n    }\n    // Somewhat abusing prune method by calling it with an access function that\n    // throws on denial.\n    this._pruneColumns(action, permInfo, tableId, accessCheck);\n  }\n\n  /**\n   * Take a look at the DocAction and see if it might allow the user to\n   * introduce attachment ids into a cell. If so, make sure the user\n   * has the right to access any attachments mentioned.\n   */\n  private async _checkIncomingAttachmentChanges(cursor: ActionCursor): Promise<void> {\n    const { docSession } = cursor;\n    const attIds = await this._gatherAttachmentChanges(cursor);\n    for (const attId of attIds) {\n      if (!await this.isAttachmentUploadedByUser(docSession, attId) &&\n        !await this.findAttachmentCellForUser(docSession, attId)) {\n        throw new ErrorWithCode(\"ACL_DENY\", \"Cannot access attachment\", {\n          status: 403,\n        });\n      }\n    }\n  }\n\n  /**\n   * If user doesn't have sufficient rights, rewrite any attachment information\n   * as follows:\n   *   - Remove data actions (other than [Bulk]RemoveRecord) on the _grist_Attachments table\n   *   - Gather any attachment ids mentioned in data actions\n   *   - Prepend a BulkAddRecord for _grist_Attachments giving metadata for the attachments\n   * This will result in metadata being sent to clients more than necessary,\n   * but saves us keeping track of which clients already know about which\n   * attachments.\n   * We don't make any particular effort to retract attachment metadata from\n   * clients if they lose access to it later. They won't have access to the\n   * content of the attachment, and will lose metadata on a document reload.\n   */\n  private async _filterOutgoingAttachments(cursors: ActionCursor[]) {\n    if (cursors.length === 0) { return []; }\n    const docSession = cursors[0].docSession;\n    if (!await this.needAttachmentControl(docSession)) {\n      return cursors;\n    }\n    const result = [] as ActionCursor[];\n    const attIds = new Set<number>();\n    for (const cursor of cursors) {\n      const changes = await this._gatherAttachmentChanges(cursor);\n      // We assume here that ACL rules were already applied and columns were\n      // either removed or censored.\n      // Gather all attachment ids stored in user tables.\n      for (const attId of changes) {\n        attIds.add(attId);\n      }\n      const { action } = cursor;\n      // Remove any additions or updates to the _grist_Attachments table.\n      if (!isDataAction(action) || isSomeRemoveRecordAction(action) || getTableId(action) !== \"_grist_Attachments\") {\n        result.push(cursor);\n      }\n    }\n    // We removed all actions that created attachments, now send all attachments metadata\n    // we currently have that are related to actions being broadcast.\n    if (attIds.size > 0) {\n      const act = this._docData.getMetaTable(\"_grist_Attachments\")\n        .getBulkAddRecord([...attIds]);\n      result.unshift({\n        action: act,\n        docSession,\n        // For access control purposes, this new action will be under the\n        // same access rules as the first DocAction.\n        actionIdx: cursors[0].actionIdx,\n      });\n    }\n    return result;\n  }\n\n  private async _gatherAttachmentChanges(cursor: ActionCursor): Promise<Set<number>> {\n    const empty = new Set<number>();\n    const options = this._activeBundle?.options;\n    if (options?.fromOwnHistory && options.oldestSource &&\n      Date.now() - options.oldestSource < HISTORICAL_ATTACHMENT_OWNERSHIP_PERIOD) {\n      return empty;\n    }\n    const { action, docSession } = cursor;\n    if (!isDataAction(action)) { return empty; }\n    if (isSomeRemoveRecordAction(action)) { return empty; }\n    const tableId = getTableId(action);\n    const step = await this._getMetaStep(cursor);\n    const attachmentColumns = step.attachmentColumns;\n    if (!attachmentColumns) { return empty; }\n    const ac = attachmentColumns.get(tableId);\n    if (!ac) { return empty; }\n    const colIds = getColIdsFromDocAction(action) || [];\n    if (!colIds.some(colId => ac.has(colId))) { return empty; }\n    if (!await this.needAttachmentControl(docSession)) { return empty; }\n    return gatherAttachmentIds(attachmentColumns, action);\n  }\n\n  /**\n   * Suppress notifications of schema/metadata changes to users who have no permission to change\n   * schema. This is the current compromise to reduce unwanted notifications; it assumes that\n   * non-creators use the document as a data app and care about data changes.\n   */\n  private async _filterSchemaActionsForNotifications(\n    docSession: OptDocSession,\n    docActions: DocAction[],\n  ): Promise<DocAction[]> {\n    try {\n      await this._assertSchemaAccess(docSession);\n      return docActions;\n    } catch (e: unknown) {\n      if (e instanceof ErrorWithCode && e.code === \"ACL_DENY\") {\n        return docActions.filter((a) => {\n          const tableId = getTableId(a);\n          return (\n            !isSchemaAction(a) &&\n            (!isMetadataTable(tableId) || tableId === \"_grist_Cells\")\n          );\n        });\n      }\n\n      throw e;\n    }\n  }\n\n  private async _getRuler(cursor: ActionCursor) {\n    if (cursor.actionIdx === null) { return this._ruler; }\n    const step = await this._getMetaStep(cursor);\n    return step.ruler || this._ruler;\n  }\n\n  private async _getStepAccess(cursor: ActionCursor) {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    if (this._activeBundle.hasAnyRuleChange) {\n      const step = await this._getMetaStep(cursor);\n      if (step.ruler) { return step.ruler.getAccess(cursor.docSession); }\n    }\n    // No rule changes!\n    return this._getAccess(cursor.docSession);\n  }\n\n  private async _getStep(cursor: ActionCursor) {\n    if (cursor.actionIdx === null) { throw new Error(\"No step available\"); }\n    const steps = await this._getSteps();\n    return steps[cursor.actionIdx];\n  }\n\n  private async _getMetaStep(cursor: ActionCursor) {\n    if (cursor.actionIdx === null) { throw new Error(\"No step available\"); }\n    const steps = await this._getMetaSteps();\n    return steps[cursor.actionIdx];\n  }\n\n  // Get an AccessCheck appropriate for the specific action.\n  // TODO: deal with ReplaceTableData, which both deletes and creates rows.\n  private async _getAccessForActionType(docSession: OptDocSession, a: DocAction,\n    severity: \"check\" | \"fatal\"): Promise<IAccessCheck> {\n    if (this._hasExceptionalFullAccess(docSession)) {\n      return dummyAccessCheck;\n    }\n    const tableId = getTableId(a);\n    if (isMetadataTable(tableId) && tableId !== \"_grist_Cells\") {\n      if (tableId === \"_grist_Attachments\") {\n        // If the back end is adding/removing an attachment, all\n        // necessary authentication has happened, and we can go ahead\n        // and do it. Perhaps the back end should just use an\n        // exceptional session for this, rather than a special\n        // flag. That would change attribution of the action in the\n        // log, so I stuck with a flag, but I'm not sure if\n        // attribution is particularly useful in this case.\n        if (this._activeBundle?.options?.attachment) {\n          return dummyAccessCheck;\n        }\n        // Users cannot take actions on _grist_Attachments through the regular\n        // action interface.\n        throw new Error(\"_grist_Attachments modification is not allowed\");\n      }\n      // Actions on any metadata table currently require the schemaEdit flag.\n      // Exception: the cell info table, which needs to be reworked to be compatible\n      // with granular access.\n\n      // Another exception: ensure owners always have full access to ACL tables, so they\n      // can change rules and don't get stuck.\n      if (isAclTable(tableId) && await this.isOwner(docSession)) {\n        return dummyAccessCheck;\n      }\n      return accessChecks[severity].schemaEdit;\n    } else if (a[0] === \"UpdateRecord\" || a[0] === \"BulkUpdateRecord\") {\n      return accessChecks[severity].update;\n    } else if (a[0] === \"RemoveRecord\" || a[0] === \"BulkRemoveRecord\") {\n      return accessChecks[severity].delete;\n    } else if (a[0] === \"AddRecord\" || a[0] === \"BulkAddRecord\") {\n      return accessChecks[severity].create;\n    } else {\n      return accessChecks[severity].schemaEdit;\n    }\n  }\n\n  /**\n   * Filter outgoing actions and include or remove cell information from _grist_Cells.\n   */\n  private async _filterOutgoingCellInfo(docSession: OptDocSession, before: DocAction[], after: DocAction[]) {\n    // Rewrite bundle, simplifying all actions that are touching cell metadata.\n    const cellView = new CellData(this._docData);\n    const patch = cellView.generatePatch(before);\n\n    // If there is nothing to do, just return after state.\n    if (!patch) { return after; }\n\n    // Now remove all action that modify cell metadata from after.\n    // We will use the patch to reconstruct the cell metadata.\n    const result = after.filter(action => !isCellDataAction(action));\n\n    // Prepare checker, we need to use checker from the last step.\n    const cursor = {\n      docSession,\n      action: before[before.length - 1],\n      actionIdx: before.length - 1,\n    };\n    const ruler = await this._getRuler(cursor);\n    const permInfo = await ruler.getAccess(docSession);\n    const inputs = await this.inputs(docSession);\n    // Cache some data, as they are checked.\n    const readRows = memoize(this._fetchQueryFromDB.bind(this));\n    const hasAccess = async (cell: SingleCell) => {\n      // First check table access, maybe table is hidden.\n      const tableAccess = permInfo.getTableAccess(cell.tableId);\n      const access = this.getReadPermission(tableAccess);\n      if (access === \"deny\") { return false; }\n\n      // Check, if table is fully allowed (no ACL column/rows rules).\n      if (access === \"allow\") { return true; }\n\n      // Maybe there are only rules that hides this column completely.\n      if (access === \"mixedColumns\") {\n        const collAccess = this.getReadPermission(permInfo.getColumnAccess(cell.tableId, cell.colId));\n        if (collAccess === \"deny\") { return false; }\n        if (collAccess === \"allow\") { return true; }\n      }\n\n      // Probably there are rules at the cell level, check them.\n      const rows = await readRows({\n        tableId: cell.tableId,\n        filters: { id: [cell.rowId] },\n      });\n      // Make sure we have row.\n      if (!rows || rows[2].length === 0) {\n        if (cell.rowId) {\n          return false;\n        }\n      }\n      const rec = rows ? new RecordView(rows, 0) : undefined;\n      const input: PredicateFormulaInput = { ...inputs, rec, newRec: rec };\n      const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);\n      const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;\n      if (rowAccess === \"deny\") { return false; }\n      if (rowAccess !== \"allow\") {\n        const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;\n        if (colAccess === \"deny\") { return false; }\n      }\n      return true;\n    };\n\n    // Now censor the patch, so it only contains cells content that user has access to.\n    await cellView.censorCells(patch, cell => hasAccess(cell));\n\n    // And append it to the result.\n    result.push(...patch);\n\n    return result;\n  }\n\n  /**\n   * Tests if the user can modify cell's data.\n   */\n  private async _canApplyCellActions(currentUser: User, userIsOwner: boolean) {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    const { docActions, docSession } = this._activeBundle;\n    const snapShot = await this.createSnapshotWithCells(docActions);\n    await applyAndCheckActionsForCells(\n      snapShot,\n      docActions,\n      this._activeBundle.isDirect,\n      userIsOwner,\n      this._ruler.haveRules(),\n      currentUser.UserRef || \"\",\n      (cell, state) => this.hasCellAccess(docSession, cell, state),\n    );\n  }\n\n  private _createCellAccess(docSession: OptDocSession, docData?: DocData) {\n    return new CellAccessHelper(this, this._ruler, docSession, this._fetchQueryFromDB, docData);\n  }\n\n  private async _getOutgoingDocActionsForNotifications(\n    userData?: UserAccessData,\n  ): Promise<DocAction[]> {\n    if (!this._activeBundle) { throw new Error(\"no active bundle\"); }\n    const { docActions, isDirect, docSession } = this._activeBundle;\n    const relevant = docActions.filter((_, index) => isDirect[index]);\n    if (!userData) {\n      return relevant;\n    }\n    const userDocSession = new PseudoDocSession(userData, this._docId, docSession.org);\n    let filtered = await this.filterOutgoingDocActions(userDocSession, relevant);\n    filtered = await this._filterSchemaActionsForNotifications(userDocSession, filtered);\n    return filtered;\n  }\n}\n\n/**\n * A snapshots of rules and permissions at during one of more steps within a bundle.\n */\nexport class Ruler {\n  // The collection of all rules, with helpful accessors.\n  public ruleCollection = new ACLRuleCollection();\n\n  // Cache of PermissionInfo associated with the given docSession. It's a WeakMap, so should allow\n  // both to be garbage-collected once docSession is no longer in use.\n  private _permissionInfoMap = new WeakMap<OptDocSession, Promise<PermissionInfo>>();\n\n  public constructor(private _owner: RulerOwner) {}\n\n  public async getAccess(docSession: OptDocSession): Promise<PermissionInfo> {\n    // TODO The intent of caching is to avoid duplicating rule evaluations while processing a\n    // single request. Caching based on docSession is riskier since those persist across requests.\n    return getSetMapValue(this._permissionInfoMap as Map<OptDocSession, Promise<PermissionInfo>>, docSession,\n      async () => new PermissionInfo(this.ruleCollection, await this._owner.inputs(docSession)));\n  }\n\n  public flushAccess(docSession: OptDocSession) {\n    this._permissionInfoMap.delete(docSession);\n  }\n\n  /**\n   * Update granular access from DocData.\n   */\n  public async update(docData: DocData) {\n    await this.ruleCollection.update(docData, {\n      log,\n      compile: compilePredicateFormula,\n      enrichRulesForImplementation: true,\n    });\n\n    // Also clear the per-docSession cache of rule evaluations.\n    this.clearCache();\n  }\n\n  public clearCache() {\n    this._permissionInfoMap = new WeakMap();\n  }\n\n  public haveRules() {\n    return this.ruleCollection.haveRules();\n  }\n}\n\nexport interface RulerOwner {\n  getUser(docSession: OptDocSession): Promise<User>;\n  inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>;\n}\n\n/**\n * Information about a single step within a bundle.  We cache this information to share\n * when filtering output to several clients.\n */\nexport interface ActionStep {\n  action: DocAction;\n  rowsBefore: TableDataAction | undefined;  // only defined for actions modifying rows\n  rowsAfter: TableDataAction | undefined;   // only defined for actions modifying rows\n  rowsLast?: TableDataAction;             // cached calculation of where to point \"newRec\"\n}\nexport interface MetaStep {\n  action: DocAction;\n  metaBefore?: { [key: string]: TableDataAction };  // cached structural metadata before action\n  metaAfter?: { [key: string]: TableDataAction };   // cached structural metadata after action\n  ruler?: Ruler;                          // rules at this step\n  attachmentColumns?: AttachmentColumns;        // attachment columns after this step\n}\n\n/**\n * A pointer to a particular step within a bundle for a particular session.\n */\ninterface ActionCursor {\n  action: DocAction;\n  docSession: OptDocSession;\n  actionIdx: number | null;     // an index into where we are within the original\n  // DocActions, for access control purposes.\n  // Used for referencing a cache of intermediate\n  // access control state.\n}\n\n/**\n * A read-write view of a DataAction, for use in censorship.\n */\nclass RecordEditor implements InfoEditor {\n  private _rows: number[];\n  private _bulk: boolean;\n  private _data: ColValues | BulkColValues;\n  public constructor(public data: DataAction, public index: number | undefined,\n    public optional: boolean) {\n    const rows = data[2];\n    this._bulk = Array.isArray(rows);\n    this._rows = Array.isArray(rows) ? rows : [rows];\n    this._data = data[3] || {};\n  }\n\n  public get(colId: string): CellValue {\n    if (this.index === undefined) { return null; }\n    if (colId === \"id\") {\n      return this._rows[this.index];\n    }\n    return this._bulk ?\n      (this._data as BulkColValues)[colId][this.index] :\n      (this._data as ColValues)[colId];\n  }\n\n  public set(colId: string, val: CellValue): this {\n    if (this.index === undefined) { throw new Error(\"cannot set value of non-existent cell\"); }\n    if (colId === \"id\") { throw new Error(\"cannot change id\"); }\n    if (this.optional && !(colId in this._data)) { return this; }\n    if (this._bulk) {\n      (this._data as BulkColValues)[colId][this.index] = val;\n    } else {\n      (this._data as ColValues)[colId] = val;\n    }\n    return this;\n  }\n\n  public toJSON() {\n    if (this.index === undefined) { return {}; }\n    const results: { [key: string]: any } = {};\n    for (const key of Object.keys(this._data)) {\n      results[key] = this.get(key);\n    }\n    return results;\n  }\n}\n\n/**\n * Cache information about user attributes.\n */\nclass UserAttributes {\n  public rows: { [clauseName: string]: InfoView } = {};\n  public override?: UserOverride;\n}\n\ninterface IAccessCheck {\n  get(ps: PermissionSetWithContext): string;\n  throwIfDenied(ps: PermissionSetWithContext): void;\n  throwIfNotFullyAllowed(ps: PermissionSetWithContext): void;\n}\n\nclass AccessCheck implements IAccessCheck {\n  constructor(public access: \"update\" | \"delete\" | \"create\" | \"schemaEdit\" | \"read\",\n    public severity: \"check\" | \"fatal\") {\n  }\n\n  public get(ps: PermissionSetWithContext): string {\n    const result = ps.perms[this.access];\n    if (result !== \"deny\" || this.severity !== \"fatal\") { return result; }\n    this.throwIfDenied(ps);\n    return result;\n  }\n\n  public throwIfDenied(ps: PermissionSetWithContext): void {\n    const result = ps.perms[this.access];\n    if (result !== \"deny\") { return; }\n    this._throwError(ps);\n  }\n\n  public throwIfNotFullyAllowed(ps: PermissionSetWithContext): void {\n    const result = ps.perms[this.access];\n    if (result === \"allow\") { return; }\n    this._throwError(ps);\n  }\n\n  private _throwError(ps: PermissionSetWithContext): void {\n    const memos = ps.getMemos()[this.access];\n    const label =\n      this.access === \"schemaEdit\" ? \"structure\" :\n        this.access;\n    throw new ErrorWithCode(\"ACL_DENY\", `Blocked by ${ps.ruleType} ${label} access rules`, {\n      memos,\n      status: 403,\n    });\n  }\n}\n\nexport const accessChecks = {\n  check: fromPairs(ALL_PERMISSION_PROPS.map(prop => [prop, new AccessCheck(prop, \"check\")])),\n  fatal: fromPairs(ALL_PERMISSION_PROPS.map(prop => [prop, new AccessCheck(prop, \"fatal\")])),\n};\n\n// This AccessCheck allows everything.\nconst dummyAccessCheck: IAccessCheck = {\n  get() { return \"allow\"; },\n  throwIfDenied() {},\n  throwIfNotFullyAllowed() {},\n};\n\n/**\n * Helper class to calculate access for a set of cells in bulk. Used for initial\n * access check for a whole _grist_Cell table. Each cell can belong to a different\n * table and row, so here we will avoid loading rows multiple times and checking\n * the table access multiple time.\n */\nclass CellAccessHelper {\n  private _tableAccess = new Map<string, boolean>();\n  private _rowPermInfo = new Map<string, Map<number, PermissionInfo>>();\n  private _rows = new Map<string, TableDataAction>();\n  private _inputs!: PredicateFormulaInput;\n\n  constructor(\n    private _granular: GranularAccess,\n    private _ruler: Ruler,\n    private _docSession: OptDocSession,\n    private _fetchQueryFromDB?: (query: ServerQuery) => Promise<TableDataAction>,\n    private _state?: DocData,\n  ) { }\n\n  /**\n   * Resolves access for all cells, and save the results in the cache.\n   */\n  public async calculate(cells: SingleCell[]) {\n    this._inputs = await this._granular.inputs(this._docSession);\n    const tableIds = new Set(cells.map(cell => cell.tableId));\n    for (const tableId of tableIds) {\n      this._tableAccess.set(tableId, await this._granular.hasTableAccess(this._docSession, tableId));\n      if (this._tableAccess.get(tableId)) {\n        const rowIds = new Set(cells.filter(cell => cell.tableId === tableId).map(cell => cell.rowId));\n        const rows = await this._getRows(tableId, rowIds);\n        for (const [idx, rowId] of rows[2].entries()) {\n          if (rowIds.has(rowId) === false) { continue; }\n          const rec = new RecordView(rows, idx);\n          const input: PredicateFormulaInput = { ...this._inputs, rec, newRec: rec };\n          const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);\n          if (!this._rowPermInfo.has(tableId)) {\n            this._rowPermInfo.set(tableId, new Map());\n          }\n          this._rowPermInfo.get(tableId)!.set(rows[2][idx], rowPermInfo);\n          this._rows.set(tableId, rows);\n        }\n      }\n    }\n  }\n\n  /**\n   * Checks if user has a read access to a particular cell. Needs to be called after calculate().\n   */\n  public hasAccess(cell: SingleCell) {\n    const rowPermInfo = this._rowPermInfo.get(cell.tableId)?.get(cell.rowId);\n    if (!rowPermInfo) { return true; }\n    const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;\n    if (rowAccess === \"deny\") { return true; }\n    if (rowAccess !== \"allow\") {\n      const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;\n      if (colAccess === \"deny\") { return true; }\n    }\n    const colValues = this._rows.get(cell.tableId);\n    if (!colValues || !(cell.colId in colValues[3])) { return true; }\n    return false;\n  }\n\n  private async _getRows(tableId: string, rowIds: Set<number>) {\n    if (this._state) {\n      const rows = this._state.getTable(tableId)!.getTableDataAction();\n      return rows;\n    }\n    if (this._fetchQueryFromDB) {\n      return await this._fetchQueryFromDB({\n        tableId,\n        filters: { id: [...rowIds] },\n      });\n    }\n    return [\"TableData\", tableId, [], {}] as TableDataAction;\n  }\n}\n\n/**\n * Manage censoring metadata.\n *\n * For most metadata, censoring means blanking out certain fields, rather than removing rows,\n * (because the latter was too big of a change). In particular, these changes are relied on by\n * other code:\n *\n *  - Censored tables (from _grist_Tables) have cleared tableId field. To check for it, use the\n *    isTableCensored() helper in app/common/isHiddenTable.ts. This is used by exports to Excel.\n */\nexport class CensorshipInfo {\n  public censoredTables = new Set<number>();\n  public censoredSections = new Set<number>();\n  public censoredViews = new Set<number>();\n  public censoredColumns = new Set<number>();\n  public censoredFields = new Set<number>();\n  public censoredComments = new Set<number>();\n  public censored = {\n    _grist_Tables: this.censoredTables,\n    _grist_Tables_column: this.censoredColumns,\n    _grist_Views: this.censoredViews,\n    _grist_Views_section: this.censoredSections,\n    _grist_Views_section_field: this.censoredFields,\n    _grist_Cells: this.censoredComments,\n  };\n\n  public constructor(permInfo: PermissionInfo,\n    ruleCollection: ACLRuleCollection,\n    tables: { [key: string]: TableDataAction },\n    private _canViewACLs: boolean,\n    cellAccessInfo?: CellAccessHelper) {\n    // Collect a list of censored columns (by \"<tableRef> <colId>\").\n    const columnCode = (tableRef: number, colId: string) => `${tableRef} ${colId}`;\n    const censoredColumnCodes = new Set<string>();\n    const tableRefToTableId = new Map<number, string>();\n    const tableRefToIndex = new Map<number, number>();\n    const columnRefToColId = new Map<number, string>();\n    const uncensoredTables = new Set<number>();\n    // Scan for forbidden tables.\n    let rec = new RecordView(tables._grist_Tables, undefined);\n    let ids = getRowIdsFromDocAction(tables._grist_Tables);\n    for (let idx = 0; idx < ids.length; idx++) {\n      rec.index = idx;\n      const tableId = rec.get(\"tableId\") as string;\n      const tableRef = ids[idx];\n      tableRefToTableId.set(tableRef, tableId);\n      tableRefToIndex.set(tableRef, idx);\n      const tableAccess = permInfo.getTableAccess(tableId);\n      if (tableAccess.perms.read === \"deny\") {\n        this.censoredTables.add(tableRef);\n      } else if (tableAccess.perms.read === \"allow\") {\n        uncensoredTables.add(tableRef);\n      }\n    }\n    // Scan for forbidden columns.\n    ids = getRowIdsFromDocAction(tables._grist_Tables_column);\n    rec = new RecordView(tables._grist_Tables_column, undefined);\n    for (let idx = 0; idx < ids.length; idx++) {\n      rec.index = idx;\n      const tableRef = rec.get(\"parentId\") as number;\n      const colId = rec.get(\"colId\") as string;\n      const colRef = ids[idx];\n      columnRefToColId.set(colRef, colId);\n      if (uncensoredTables.has(tableRef)) { continue; }\n      const tableId = tableRefToTableId.get(tableRef);\n      if (!tableId) { throw new Error(\"table not found: \" + tableRef); }\n      if (this.censoredTables.has(tableRef) ||\n        (colId !== \"manualSort\" && permInfo.getColumnAccess(tableId, colId).perms.read === \"deny\")) {\n        censoredColumnCodes.add(columnCode(tableRef, colId));\n      }\n      if (isTransformColumn(colId) && permInfo.getColumnAccess(tableId, colId).perms.schemaEdit === \"deny\") {\n        censoredColumnCodes.add(columnCode(tableRef, colId));\n      }\n    }\n    // Collect a list of all sections and views containing a table to which the user has no access.\n    rec = new RecordView(tables._grist_Views_section, undefined);\n    ids = getRowIdsFromDocAction(tables._grist_Views_section);\n    for (let idx = 0; idx < ids.length; idx++) {\n      rec.index = idx;\n      if (!this.censoredTables.has(rec.get(\"tableRef\") as number)) { continue; }\n      const parentId = rec.get(\"parentId\") as number;\n      if (parentId) { this.censoredViews.add(parentId); }\n      this.censoredSections.add(ids[idx]);\n    }\n    // Collect a list of all columns from tables to which the user has no access.\n    rec = new RecordView(tables._grist_Tables_column, undefined);\n    ids = getRowIdsFromDocAction(tables._grist_Tables_column);\n    for (let idx = 0; idx < ids.length; idx++) {\n      rec.index = idx;\n      const parentId = rec.get(\"parentId\") as number;\n      if (this.censoredTables.has(parentId) ||\n        censoredColumnCodes.has(columnCode(parentId, rec.get(\"colId\") as string))) {\n        this.censoredColumns.add(ids[idx]);\n      }\n    }\n    // Collect a list of all fields from sections to which the user has no access.\n    rec = new RecordView(tables._grist_Views_section_field, undefined);\n    ids = getRowIdsFromDocAction(tables._grist_Views_section_field);\n    for (let idx = 0; idx < ids.length; idx++) {\n      rec.index = idx;\n      if (!this.censoredSections.has(rec.get(\"parentId\") as number) &&\n        !this.censoredColumns.has(rec.get(\"colRef\") as number)) { continue; }\n      this.censoredFields.add(ids[idx]);\n    }\n\n    // Now undo some of the above...\n    // Specifically, when a summary table is not censored, uncensor the source table's raw view section,\n    // so that the user can see the source table's title,\n    // which is used to construct the summary table's title. The section's fields remain censored.\n    // This would also be a sensible place to uncensor the source tableId, but that causes other problems.\n    rec = new RecordView(tables._grist_Tables, undefined);\n    ids = getRowIdsFromDocAction(tables._grist_Tables);\n    for (let idx = 0; idx < ids.length; idx++) {\n      rec.index = idx;\n      const tableRef = ids[idx];\n      const sourceTableRef = rec.get(\"summarySourceTable\") as number;\n      const sourceTableIndex = tableRefToIndex.get(sourceTableRef);\n      if (\n        this.censoredTables.has(tableRef) ||\n        !sourceTableRef ||\n        sourceTableIndex === undefined ||\n        !this.censoredTables.has(sourceTableRef)\n      ) { continue; }\n      rec.index = sourceTableIndex;\n      const rawViewSectionRef = rec.get(\"rawViewSectionRef\") as number;\n      this.censoredSections.delete(rawViewSectionRef);\n    }\n\n    // Collect a list of all cells metadata to which the user has no access.\n    rec = new RecordView(tables._grist_Cells, undefined);\n    ids = tables._grist_Cells ? getRowIdsFromDocAction(tables._grist_Cells) : [];\n    for (let idx = 0; idx < ids.length; idx++) {\n      rec.index = idx;\n      const isTableCensored = () => this.censoredTables.has(rec.get(\"tableRef\") as number);\n      const isColumnCensored = () => this.censoredColumns.has(rec.get(\"colRef\") as number);\n      const isCellCensored = () => {\n        if (!cellAccessInfo) { return false; }\n        const cell = {\n          tableId: tableRefToTableId.get(rec.get(\"tableRef\") as number)!,\n          colId: columnRefToColId.get(rec.get(\"colRef\") as number)!,\n          rowId: rec.get(\"rowId\") as number,\n        };\n        return !cell.tableId || !cell.colId || cellAccessInfo.hasAccess(cell);\n      };\n      if (isTableCensored() || isColumnCensored() || isCellCensored()) {\n        this.censoredComments.add(ids[idx]);\n      }\n    }\n  }\n\n  public filter(a: DataAction) {\n    const tableId = getTableId(a);\n    if ([\"_grist_ACLResources\", \"_grist_ACLRules\", \"_grist_Shares\"].includes(tableId)) {\n      if (!this._canViewACLs && a[0] === \"TableData\") {\n        a[2] = [];\n        a[3] = {};\n      }\n      return this._canViewACLs;\n    }\n    if (!(tableId in this.censored)) { return true; }\n\n    const rec = new RecordEditor(a, undefined, true);\n    const method = getCensorMethod(getTableId(a));\n    const censoredRows = (this.censored as any)[tableId] as Set<number>;\n    const ids = getRowIdsFromDocAction(a);\n    for (const [index, id] of ids.entries()) {\n      if (censoredRows.has(id)) {\n        rec.index = index;\n        method(rec);\n      }\n    }\n    return true;\n  }\n}\n\nfunction getCensorMethod(tableId: string): (rec: RecordEditor) => void {\n  switch (tableId) {\n    case \"_grist_Tables\":\n      return rec => rec.set(\"tableId\", \"\");\n    case \"_grist_Views\":\n      return rec => rec.set(\"name\", \"\");\n    case \"_grist_Views_section\":\n      return rec => rec.set(\"title\", \"\").set(\"tableRef\", 0);\n    case \"_grist_Tables_column\":\n      return rec => rec.set(\"label\", \"\").set(\"colId\", \"\").set(\"widgetOptions\", \"\")\n        .set(\"formula\", \"\").set(\"type\", \"Any\").set(\"parentId\", 0);\n    case \"_grist_Views_section_field\":\n      return rec => rec.set(\"widgetOptions\", \"\").set(\"filter\", \"\").set(\"parentId\", 0);\n    case \"_grist_Cells\":\n      return rec => rec.set(\"content\", [GristObjCode.Censored]).set(\"userRef\", \"\");\n    default:\n      throw new Error(`cannot censor ${tableId}`);\n  }\n}\n\nfunction scanActionsRecursively<T extends DocAction | UserAction>(actions: T[],\n  check: (action: T) => boolean): boolean {\n  for (const a of actions) {\n    if (a[0] === \"ApplyUndoActions\" || a[0] === \"ApplyDocActions\") {\n      return scanActionsRecursively(a[1] as T[], check);\n    }\n    if (check(a)) { return true; }\n  }\n  return false;\n}\n\nasync function applyToActionsRecursively(actions: (DocAction | UserAction)[],\n  op: (action: DocAction | UserAction) => Promise<void>): Promise<void> {\n  for (const a of actions) {\n    if (a[0] === \"ApplyUndoActions\" || a[0] === \"ApplyDocActions\") {\n      await applyToActionsRecursively(a[1] as UserAction[], op);\n    }\n    await op(a);\n  }\n}\n\n/**\n * Takes an action, and removes certain cells from it.  The action\n * passed in is modified in place, and also returned as part of a list\n * of derived actions.\n *\n * For a non-bulk action, any cell values that return true for\n * shouldFilterCell are removed.  For a bulk action, there's no way to\n * express that in general in a single action.  For a bulk action, for\n * any row (identified by row index, not rowId) that returns true for\n * shouldFilterRow, we remove cell values based on shouldFilterCell\n * and add the row to an action with just the remaining cell values.\n *\n * This is by no means a general-purpose function.  It is used only in\n * the implementation of partial undos.  If is factored out for\n * testing purposes.\n *\n * This method could be made unnecessary if a way were created to have\n * unambiguous \"holes\" in column value arrays, where values for some\n * rows are omitted.\n */\nexport function filterColValues(action: DataAction,\n  shouldFilterRow: (idx: number) => boolean,\n  shouldFilterCell: (value: CellValue) => boolean): DataAction[] {\n  if (isSomeRemoveRecordAction(action)) {\n    // removals do not have cells, so nothing to do.\n    return [action];\n  }\n\n  const colIds = Object.keys(action[3]).sort();\n  const colValues = action[3];\n\n  if (!isBulkAction(action)) {\n    for (const colId of colIds) {\n      if (shouldFilterCell((colValues as ColValues)[colId])) {\n        delete colValues[colId];\n      }\n    }\n    return [action];\n  }\n\n  const rowIds = action[2];\n\n  // For bulk operations, censored cells require us to reorganize into a set of actions\n  // with different columns.\n  const parts = new Map<string, typeof action>();\n  let at = 0;\n  for (let idx = 0; idx < rowIds.length; idx++) {\n    if (!shouldFilterRow(idx)) {\n      if (idx !== at) {\n        // Shuffle columnar data up as we remove rows.\n        rowIds[at] = rowIds[idx];\n        for (const colId of colIds) {\n          (colValues as BulkColValues)[colId][at] = (colValues as BulkColValues)[colId][idx];\n        }\n      }\n      at++;\n      continue;\n    }\n    // Some censored data in this row, so move the row to an action specialized\n    // for the set of columns this row has.\n    const keys: string[] = [];\n    const values: BulkColValues = {};\n    for (const colId of colIds) {\n      const value = (colValues as BulkColValues)[colId][idx];\n      if (!shouldFilterCell(value)) {\n        values[colId] = [value];\n        keys.push(colId);\n      }\n    }\n    const mergedKey = keys.join(\" \");\n    const peers = parts.get(mergedKey);\n    if (!peers) {\n      parts.set(mergedKey, [action[0], action[1], [rowIds[idx]], values]);\n    } else {\n      peers[2].push(rowIds[idx]);\n      for (const key of keys) {\n        peers[3][key].push(values[key][0]);\n      }\n    }\n  }\n  // Truncate columnar data.\n  rowIds.length = at;\n  for (const colId of colIds) {\n    (colValues as BulkColValues)[colId].length = at;\n  }\n  // Return all actions, in a consistent order for test purposes.\n  return [action, ...[...parts.keys()].sort().map(key => parts.get(key)!)];\n}\n\nexport function validTableIdString(tableId: any): string {\n  if (typeof tableId !== \"string\") { throw new Error(`Expected tableId to be a string`); }\n  return tableId;\n}\n\nfunction actionHasRuleChange(a: DocAction): boolean {\n  return isAclTable(getTableId(a)) || (\n    // Check if any helper columns have been specified while adding/updating a metadata record,\n    // as this will affect the result of `getHelperCols` in `ACLRuleCollection.ts` and thus the set of ACL resources.\n    // Note that removing a helper column doesn't directly trigger this code, but:\n    //  - It will typically be accompanied closely by unsetting the helper column on the metadata record.\n    //  - `getHelperCols` can handle non-existent helper columns and other similarly invalid metadata.\n    //  - Since the column is removed, ACL restrictions on it don't really matter.\n    isDataAction(a) &&\n    [\"_grist_Tables_column\", \"_grist_Views_section_field\"].includes(getTableId(a)) &&\n    Boolean(\n      a[3]?.hasOwnProperty(\"rules\") ||\n      a[3]?.hasOwnProperty(\"displayCol\"),\n    )\n  );\n}\n\n/**\n * Wrapper around a permission info object that overrides permissions for transform columns.\n */\nclass TransformColumnPermissionInfo implements IPermissionInfo {\n  constructor(private _inner: IPermissionInfo) {\n\n  }\n\n  public getColumnAccess(tableId: string, colId: string): MixedPermissionSetWithContext {\n    const access = this._inner.getColumnAccess(tableId, colId);\n    const isSchemaDenied = access.perms.schemaEdit === \"deny\";\n    // If this is a transform column, it's only accessible if the user has a schemaEdit access.\n    if (isSchemaDenied && isTransformColumn(colId)) {\n      return {\n        ...access,\n        perms: {\n          create: \"deny\",\n          read: \"deny\",\n          update: \"deny\",\n          delete: \"deny\",\n          schemaEdit: \"deny\",\n        },\n      };\n    }\n    return access;\n  }\n\n  public getTableAccess(tableId: string): TablePermissionSetWithContext {\n    return this._inner.getTableAccess(tableId);\n  }\n\n  public getFullAccess(): MixedPermissionSetWithContext {\n    return this._inner.getFullAccess();\n  }\n\n  public getRuleCollection(): ACLRuleCollection {\n    return this._inner.getRuleCollection();\n  }\n}\n\n/**\n * A version of DocSession that pretends to represent a particular user for the sake of applying\n * access rules to notifications for that user.\n */\nexport class PseudoDocSession extends OptDocSession {\n  public readonly client = null;\n  public authorizer: DocAuthorizer = new DummyAuthorizer(this._userData.access, this._docId);\n\n  constructor(private _userData: UserAccessData, private _docId: string, private _org: string | undefined) {\n    super({});\n  }\n\n  public get org() { return this._org; }\n  public get altSessionId() { return null; } // eslint-disable-line @typescript-eslint/class-literal-property-style\n  public get userId() { return this._userData.id; }\n  public get userIsAuthorized() { return !this._userData.anonymous; }\n  public get fullUser() { return this._userData; }\n}\n"
  },
  {
    "path": "app/server/lib/GristJobs.ts",
    "content": "import { getSetMapValue } from \"app/common/gutil\";\nimport { makeId } from \"app/server/lib/idUtils\";\nimport log from \"app/server/lib/log\";\n\nimport { Job as BullMQJob, JobsOptions, Queue, Worker } from \"bullmq\";\nimport IORedis from \"ioredis\";\n\n// Name of the queue for doc-notification emails. Let's define queue names in this file, to ensure\n// that different users of GristJobs don't accidentally use conflicting queue names.\nexport const docEmailsQueue = \"deq\";\n\n/**\n *\n * Support for queues.\n *\n * We use BullMQ for queuing, since it seems currently the best the\n * node ecosystem has to offer. BullMQ relies on Redis. Since queuing\n * is so handy, but we'd like most of Grist to be usable without Redis,\n * we make some effort to support queuing without BullMQ. This\n * may not be sustainable, we'll see.\n *\n * Important: if you put a job in a queue, it can outlast your process.\n * That has implications for testing and deployment, so be careful.\n *\n * Long running jobs may be a challenge. BullMQ cancelation\n * relies on non-open source features:\n *  https://docs.bullmq.io/bullmq-pro/observables/cancelation\n *\n */\nexport interface GristJobs {\n  /**\n   * All workers and jobs are scoped to individual named queues,\n   * with the real interfaces operating at that level.\n   */\n  queue(queueName?: string): GristQueueScope;\n\n  /**\n   * Shut everything down that we're responsible for.\n   * Set obliterate flag to destroy jobs even if they are\n   * stored externally (useful for testing).\n   */\n  stop(options?: StopOptions): Promise<void>;\n}\n\n/**\n * For a given queue, we can add jobs, or methods to process jobs,\n */\nexport interface GristQueueScope {\n  /**\n   * Add a job.\n   */\n  add(name: string, data: any, options?: JobAddOptions): Promise<void>;\n\n  /**\n   * Add a job handler for all jobs regardless of name.\n   * Handlers given by handleName take priority, but no\n   * job handling will happen until handleDefault has been\n   * called.\n   */\n  handleDefault(defaultCallback: JobHandler): void;\n\n  /**\n   * Add a job handler for jobs with a specific name.\n   * Handler will only be effective once handleAll is called\n   * to specify what happens to jobs not matching expected\n   * names.\n   */\n  handleName(name: string, callback: JobHandler): void;\n\n  /**\n   * Shut everything down that we're responsible for.\n   * Set obliterate flag to destroy jobs even if they are\n   * stored externally (useful for testing).\n   */\n  stop(options?: StopOptions): Promise<void>;\n}\n\n/**\n * The type of a function for handling jobs on a queue.\n */\nexport type JobHandler<Job extends GristJob = GristJob> = (job: Job) => Promise<any>;\n\n/**\n * The name used for a queue if no specific name is given.\n */\nexport const DEFAULT_QUEUE_NAME = \"default\";\n\n/**\n * BullMQ jobs are a string name, and then a data object.\n */\nexport interface GristJob {\n  name: string;\n  data: any;\n}\n\n/**\n * Options when adding a job. BullMQ has many more.\n */\ninterface JobAddOptions {\n  delay?: number;\n  jobId?: string;\n  repeat?: {\n    every: number;\n  }\n}\n\ninterface StopOptions {\n  obliterate?: boolean;\n}\n\nexport function createGristJobs(): GristJobs {\n  const connection = getRedisConnection();\n  return connection ? new GristBullMQJobs(connection) : new GristInMemoryJobs();\n}\n\nabstract class GristJobsBase<QS extends GristQueueScope> {\n  private _queues = new Map<string, QS>();\n  public queue(queueName: string = DEFAULT_QUEUE_NAME): QS {\n    return getSetMapValue(this._queues, queueName, () => this.createQueueScope(queueName));\n  }\n\n  public async stop(options: StopOptions = {}) {\n    await Promise.all(Array.from(this._queues.values(), q => q.stop(options)));\n    this._queues.clear();\n  }\n  protected abstract createQueueScope(queueName: string): QS;\n}\n\nclass GristInMemoryJobs extends GristJobsBase<GristInMemoryQueueScope> implements GristJobs {\n  protected createQueueScope(queueName: string) { return new GristInMemoryQueueScope(queueName); }\n}\n\n/**\n * Implementation for job functionality across the application.\n * Will use BullMQ, with an in-memory fallback if Redis is\n * unavailable.\n */\nexport class GristBullMQJobs extends GristJobsBase<GristBullMQQueueScope> implements GristJobs {\n  constructor(private _connection: IORedis) {\n    super();\n  }\n\n  /**\n   * Get BullMQ-compatible options for the queue.\n   */\n  public getQueueOptions() {\n    return {\n      connection: this._connection,\n      maxRetriesPerRequest: null,\n    };\n  }\n\n  public async stop(options: { obliterate?: boolean, } = {}) {\n    await super.stop();\n    this._connection.disconnect();\n  }\n\n  protected createQueueScope(queueName: string) { return new GristBullMQQueueScope(queueName, this); }\n}\n\n/**\n * Connect to Redis if available.\n */\nfunction getRedisConnection(): IORedis | undefined {\n  // Connect to Redis for use with BullMQ, if REDIS_URL is set.\n  const urlTxt = process.env.REDIS_URL || process.env.TEST_REDIS_URL;\n  if (!urlTxt) {\n    log.warn(\"Using in-memory queues, Redis is unavailable\");\n    return;\n  }\n  const conn = new IORedis(urlTxt, {\n    maxRetriesPerRequest: null,\n    // Back off faster and retry more slowly than the default, to avoid filling up logs needlessly.\n    retryStrategy: times => Math.min((times ** 2) * 50, 10000),\n  });\n  conn.on(\"error\", err => log.error(\"GristJobs: Redis connection error:\", String(err)));\n  log.info(\"Storing queues externally in Redis\");\n  return conn;\n}\n\ninterface IWorker {\n  close(): Promise<void>;\n}\n\nabstract class GristQueueScopeBase<Worker extends IWorker, Job extends GristJob = GristJob> {\n  protected _worker: Worker | undefined;\n  private _namedProcessors: Record<string, JobHandler<Job>> = {};\n\n  public constructor(public readonly queueName: string) {}\n\n  public getWorker(): Worker | undefined { return this._worker; }\n\n  public handleDefault(defaultCallback: JobHandler<Job>): void {\n    // The default callback passes any recognized named jobs to\n    // processors added with handleName(), then, if there is no\n    // specific processor, calls the defaultCallback.\n    const callback = async (job: Job) => {\n      const processor = this._namedProcessors[job.name] || defaultCallback;\n      return processor(job);\n    };\n    this._worker = this.createWorker(this.queueName, callback);\n  }\n\n  public handleName(name: string, callback: (data: Job) => Promise<any>) {\n    this._namedProcessors[name] = callback;\n  }\n\n  public async stop(options: StopOptions = {}) {\n    await this._worker?.close();\n    if (options.obliterate) {\n      await this.obliterate();\n    }\n  }\n\n  protected abstract obliterate(): Promise<void>;\n  protected abstract createWorker(queueName: string, callback: JobHandler<Job>): Worker;\n}\n\nclass GristInMemoryQueueScope extends GristQueueScopeBase<GristWorker> implements GristQueueScope {\n  public async add(name: string, data: any, options?: JobAddOptions) {\n    // If in memory, we hand a job directly to the single worker for their\n    // queue. This is very crude.\n    if (!this._worker) {\n      throw new Error(`no handler yet for ${this.queueName}`);\n    }\n    await this._worker.add(name, data, options);\n  }\n\n  protected override async obliterate(): Promise<void> {\n    await this._worker?.obliterate();\n  }\n\n  protected override createWorker(queueName: string, callback: JobHandler): GristWorker {\n    return new GristWorker(this.queueName, callback);\n  }\n}\n\n/**\n * Work with a particular named queue.\n */\nexport class GristBullMQQueueScope extends GristQueueScopeBase<Worker, BullMQJob> implements GristQueueScope {\n  private _queue: Queue | undefined;\n\n  public constructor(queueName: string, private _owner: GristBullMQJobs) { super(queueName); }\n\n  public getQueue(): Queue | undefined { return this._queue; }\n\n  public async add(name: string, data: any, options?: JobsOptions) {\n    await this._getQueue().add(name, data, {\n      // These settings are quite arbitrary, and should be\n      // revised when it matters, or made controllable.\n      removeOnComplete: {\n        age: 3600, // keep up to 1 hour\n        count: 1000, // keep up to 1000 jobs\n      },\n      removeOnFail: {\n        age: 24 * 3600, // keep up to 24 hours\n      },\n      ...options,\n    });\n  }\n\n  public getJobRedisKey(jobId: string): string {\n    // This isn't a well-documented method, so this was confirmed empirically.\n    return this._getQueue().toKey(jobId);\n  }\n\n  protected override async obliterate() {\n    await this._getQueue().obliterate({ force: true });\n  }\n\n  protected createWorker(queueName: string, callback: JobHandler<BullMQJob>): Worker {\n    const options = this._owner.getQueueOptions();\n    return new Worker(this.queueName, callback, options);\n  }\n\n  private _getQueue(): Queue {\n    return this._queue || (this._queue = new Queue(this.queueName, this._owner.getQueueOptions()));\n  }\n}\n\n/**\n * If running in memory without Redis, all jobs need to be\n * created and served by the the same process. This class\n * pretends to be a BullMQ worker, but accepts jobs directly\n * without any intermediate queue. This could be elaborated\n * in future if needed.\n */\nclass GristWorker {\n  private _jobs = new Map<string, NodeJS.Timeout>();\n\n  public constructor(public queueName: string,\n    private _callback: (job: GristJob) => Promise<void>) {\n  }\n\n  public async close() {\n    for (const job of this._jobs.keys()) {\n      // Key deletion is safe with the keys() iterator.\n      this._clearJob(job);\n    }\n  }\n\n  public async add(name: string, data: any, options?: JobAddOptions) {\n    if (options?.delay) {\n      if (options.repeat) {\n        // Unexpected combination.\n        throw new Error(\"cannot delay and repeat\");\n      }\n      const jobId = options.jobId || makeId();\n      this._clearJob(jobId);\n      this._jobs.set(jobId, setTimeout(() => this._callback({ name, data }),\n        options.delay));\n      return;\n    }\n    if (options?.repeat) {\n      const jobId = options.jobId || makeId();\n      this._clearJob(jobId);\n      this._jobs.set(jobId, setInterval(() => this._callback({ name, data }),\n        options.repeat.every));\n      return;\n    }\n    await this._callback({ name, data });\n  }\n\n  public async obliterate() {\n    await this.close();\n  }\n\n  private _clearJob(id: string) {\n    const job = this._jobs.get(id);\n    if (!job) { return; }\n    // We don't know if the job is a once-off or repeating,\n    // so we call both clearInterval and clearTimeout, which\n    // apparently works.\n    clearInterval(job);\n    clearTimeout(job);\n    this._jobs.delete(id);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/GristServer.ts",
    "content": "import { ICustomWidget } from \"app/common/CustomWidget\";\nimport { GristDeploymentType, GristLoadConfig, LatestVersionAvailable } from \"app/common/gristUrls\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { SandboxInfo } from \"app/common/SandboxInfo\";\nimport { UserProfile } from \"app/common/UserAPI\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { ActivationsManager } from \"app/gen-server/lib/ActivationsManager\";\nimport { Doom } from \"app/gen-server/lib/Doom\";\nimport { HomeDBManager, UserChange } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { IAccessTokens } from \"app/server/lib/AccessTokens\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { Comm } from \"app/server/lib/Comm\";\nimport { IGristCoreConfig, loadGristCoreConfig } from \"app/server/lib/configCore\";\nimport { create } from \"app/server/lib/create\";\nimport { DocManager } from \"app/server/lib/DocManager\";\nimport { Hosts } from \"app/server/lib/extractOrg\";\nimport { GristJobs } from \"app/server/lib/GristJobs\";\nimport { IAssistant } from \"app/server/lib/IAssistant\";\nimport { createNullAuditLogger, IAuditLogger } from \"app/server/lib/IAuditLogger\";\nimport { IBilling } from \"app/server/lib/IBilling\";\nimport { ICreate } from \"app/server/lib/ICreate\";\nimport { IDocNotificationManager } from \"app/server/lib/IDocNotificationManager\";\nimport { IDocStorageManager } from \"app/server/lib/IDocStorageManager\";\nimport { INotifier } from \"app/server/lib/INotifier\";\nimport { InstallAdmin } from \"app/server/lib/InstallAdmin\";\nimport { IPermitStore } from \"app/server/lib/Permit\";\nimport { IPubSubManager } from \"app/server/lib/PubSubManager\";\nimport { ISendAppPageOptions } from \"app/server/lib/sendAppPage\";\nimport { fromCallback } from \"app/server/lib/serverUtils\";\nimport { Sessions } from \"app/server/lib/Sessions\";\nimport { ITelemetry } from \"app/server/lib/Telemetry\";\nimport { IWidgetRepository } from \"app/server/lib/WidgetRepository\";\n\nimport { IncomingMessage } from \"http\";\n\nimport * as express from \"express\";\n\n/**\n *\n * Coordinate storage for documents across file systems,\n * external storage, and the home database.\n *\n */\nexport interface StorageCoordinator {\n  hardDeleteDoc(docId: string): Promise<void>;\n}\n\n/**\n * Basic information about a Grist server.  Accessible in many\n * contexts, including request handlers and ActiveDoc methods.\n */\nexport interface GristServer extends StorageCoordinator {\n  readonly create: ICreate;\n  settings?: IGristCoreConfig;\n  getHost(): string;\n  getHomeUrl(req: express.Request, relPath?: string): string;\n  getHomeInternalUrl(relPath?: string): string;\n  getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>;\n  getOwnUrl(): string;\n  getOrgUrl(orgKey: string | number): Promise<string>;\n  getMergedOrgUrl(req: RequestWithLogin, pathname?: string): string;\n  getResourceUrl(resource: Organization | Workspace | Document,\n    purpose?: \"api\" | \"html\"): Promise<string>;\n  getGristConfig(): GristLoadConfig;\n  getPermitStore(): IPermitStore;\n  getExternalPermitStore(): IPermitStore;\n  getSessions(): Sessions;\n  getComm(): Comm;\n  getDeploymentType(): GristDeploymentType;\n  getHosts(): Hosts;\n  getActivations(): ActivationsManager;\n  getInstallAdmin(): InstallAdmin;\n  getHomeDBManager(): HomeDBManager;\n  getStorageManager(): IDocStorageManager;\n  getAuditLogger(): IAuditLogger;\n  getTelemetry(): ITelemetry;\n  getWidgetRepository(): IWidgetRepository;\n  hasNotifier(): boolean;\n  getNotifier(): INotifier;\n  getDocNotificationManager(): IDocNotificationManager | undefined;\n  getPubSubManager(): IPubSubManager;\n  getAssistant(): IAssistant | undefined;\n  getDocTemplate(): Promise<DocTemplate>;\n  getTag(): string;\n  sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void>;\n  getAccessTokens(): IAccessTokens;\n  resolveLoginSystem(): Promise<GristLoginSystem>;\n  getPluginUrl(): string | undefined;\n  getPlugins(): LocalPlugin[];\n  servesPlugins(): boolean;\n  getBundledWidgets(): ICustomWidget[];\n  getBootKey(): string | undefined;\n  getSandboxInfo(): Promise<SandboxInfo>;\n  getInfo(key: string): any;\n  getJobs(): GristJobs;\n  getBilling(): IBilling;\n  getDoomTool(): Promise<Doom>;\n  getLatestVersionAvailable(): LatestVersionAvailable | undefined;\n  setLatestVersionAvailable(latestVersionAvailable: LatestVersionAvailable): void\n  publishLatestVersionAvailable(latestVersionAvailable: LatestVersionAvailable): Promise<void>;\n  setRestrictedMode(restrictedMode?: boolean): void;\n  getDocManager(): DocManager;\n  isRestrictedMode(): boolean;\n  onUserChange(callback: (change: UserChange) => Promise<void>): void;\n  onStreamingDestinationsChange(callback: (orgId?: number) => Promise<void>): void;\n  setReady(value: boolean): void;\n  getSigninUrl(req: express.Request, options: {\n    signUp?: boolean;\n    nextUrl?: URL;\n    params?: Record<string, string | undefined>;\n  }): Promise<string>;\n  getUserIdMiddleware(): express.RequestHandler;\n}\n\nexport interface GristLoginSystem {\n  getMiddleware(gristServer: GristServer): Promise<GristLoginMiddleware>;\n  deleteUser(user: User): Promise<void>;\n}\n\nexport interface GristLoginMiddleware {\n  getLoginRedirectUrl(req: express.Request, target: URL): Promise<string>;\n  getSignUpRedirectUrl(req: express.Request, target: URL): Promise<string>;\n  getLogoutRedirectUrl(req: express.Request, nextUrl: URL): Promise<string>;\n  // Optional middleware for the GET /login, /signup, and /signin routes.\n  getLoginOrSignUpMiddleware?(): express.RequestHandler[];\n  // Optional middleware for the GET /logout route.\n  getLogoutMiddleware?(): express.RequestHandler[];\n  // Optional middleware for all routes.\n  getWildcardMiddleware?(): express.RequestHandler[];\n  // Returns arbitrary string for log.\n  addEndpoints(app: express.Express): Promise<string>;\n  // Normally, the profile is obtained from the user's session object, which is set at login, and\n  // is identified by a session cookie. When given, overrideProfile() will be called first to\n  // extract the profile from each request. Result can be a profile, or null if anonymous\n  // (sessions will then not be used), or undefined to fall back to using session info.\n  overrideProfile?(req: express.Request | IncomingMessage): Promise<UserProfile | null | undefined>;\n  // Called on first visit to an app page after a signup, for reporting or telemetry purposes.\n  onFirstVisit?(req: express.Request): void;\n}\n\n/**\n * Set the user in the current session.\n */\nexport async function setUserInSession(req: express.Request, gristServer: GristServer, profile: UserProfile) {\n  const scopedSession = gristServer.getSessions().getOrCreateSessionFromRequest(req);\n  // Make sure session is up to date before operating on it.\n  // Behavior on a completely fresh session is a little awkward currently.\n  const reqSession = (req as any).session;\n  if (reqSession?.save) {\n    await fromCallback(cb => reqSession.save(cb));\n  }\n  await scopedSession.updateUserProfile(req, profile);\n}\n\nexport interface RequestWithGrist extends express.Request {\n  gristServer?: GristServer;\n}\n\nexport interface DocTemplate {\n  page: string,\n  tag: string,\n}\n\n/**\n * A very minimal GristServer object that throws an error if its bluff is\n * called.\n */\nexport function createDummyGristServer(): GristServer {\n  return {\n    create,\n    settings: loadGristCoreConfig(),\n    getHost() { return \"localhost:4242\"; },\n    getHomeUrl() { return \"http://localhost:4242\"; },\n    getHomeInternalUrl() { return \"http://localhost:4242\"; },\n    getHomeUrlByDocId() { return Promise.resolve(\"http://localhost:4242\"); },\n    getMergedOrgUrl() { return \"http://localhost:4242\"; },\n    getOwnUrl() { return \"http://localhost:4242\"; },\n    getPermitStore() { throw new Error(\"no permit store\"); },\n    getExternalPermitStore() { throw new Error(\"no external permit store\"); },\n    getGristConfig() { return { homeUrl: \"\", timestampMs: 0, serveSameOrigin: true, checkForLatestVersion: false }; },\n    getOrgUrl() { return Promise.resolve(\"\"); },\n    getResourceUrl() { return Promise.resolve(\"\"); },\n    getSessions() { throw new Error(\"no sessions\"); },\n    getComm() { throw new Error(\"no comms\"); },\n    getDeploymentType() { return \"core\"; },\n    getHosts() { throw new Error(\"no hosts\"); },\n    getActivations() { throw new Error(\"no activations\"); },\n    getInstallAdmin() { throw new Error(\"no install admin\"); },\n    getHomeDBManager() { throw new Error(\"no db\"); },\n    getStorageManager() { throw new Error(\"no storage manager\"); },\n    getAuditLogger() { return createNullAuditLogger(); },\n    getTelemetry() { return createDummyTelemetry(); },\n    getWidgetRepository() { throw new Error(\"no widget repository\"); },\n    getNotifier() { throw new Error(\"no notifier\"); },\n    getDocNotificationManager(): IDocNotificationManager | undefined { return undefined; },\n    getPubSubManager(): IPubSubManager { throw new Error(\"no PubSubManager\"); },\n    hasNotifier() { return false; },\n    getAssistant() { return undefined; },\n    getDocTemplate() { throw new Error(\"no doc template\"); },\n    getTag() { return \"tag\"; },\n    sendAppPage() { return Promise.resolve(); },\n    getAccessTokens() { throw new Error(\"no access tokens\"); },\n    resolveLoginSystem() { throw new Error(\"no login system\"); },\n    getPluginUrl() { return undefined; },\n    servesPlugins() { return false; },\n    getPlugins() { return []; },\n    getBundledWidgets() { return []; },\n    getBootKey() { return undefined; },\n    getSandboxInfo() { throw new Error(\"no sandbox\"); },\n    getInfo(key: string) { return undefined; },\n    getJobs(): GristJobs { throw new Error(\"no job system\"); },\n    getBilling() { throw new Error(\"no billing\"); },\n    getDoomTool() { throw new Error(\"no doom tool\"); },\n    getLatestVersionAvailable() { throw new Error(\"no version checking\"); },\n    setLatestVersionAvailable() { /* do nothing */ },\n    publishLatestVersionAvailable() { return Promise.resolve(); },\n    setRestrictedMode() { /* do nothing */ },\n    getDocManager() { throw new Error(\"no DocManager\"); },\n    isRestrictedMode() { return false; },\n    onUserChange() { /* do nothing */ },\n    onStreamingDestinationsChange() { /* do nothing */ },\n    hardDeleteDoc() { return Promise.resolve(); },\n    setReady() { /* do nothing */ },\n    getSigninUrl() { return Promise.resolve(\"\"); },\n    getUserIdMiddleware() { throw new Error(\"no user id middleware\"); },\n  };\n}\n\nexport function createDummyTelemetry(): ITelemetry {\n  return {\n    addEndpoints() { /* do nothing */ },\n    start() { return Promise.resolve(); },\n    logEvent() { /* do nothing */ },\n    logEventAsync() { return Promise.resolve(); },\n    shouldLogEvent() { return false; },\n    getTelemetryConfig() { return undefined; },\n    fetchTelemetryPrefs() { return Promise.resolve(); },\n  };\n}\n"
  },
  {
    "path": "app/server/lib/GristServerSocket.ts",
    "content": "import * as EIO from \"engine.io\";\nimport * as WS from \"ws\";\n\nexport abstract class GristServerSocket {\n  public abstract set onerror(handler: (err: Error) => void);\n  public abstract set onclose(handler: () => void);\n  public abstract set onmessage(handler: (data: string) => void);\n  public abstract removeAllListeners(): void;\n  public abstract close(): void;\n  public abstract terminate(): void;\n  public abstract get isOpen(): boolean;\n  public abstract send(data: string, cb?: (err?: Error) => void): void;\n}\n\nexport class GristServerSocketEIO extends GristServerSocket {\n  private _eventHandlers: { event: string, handler: (...args: any[]) => void }[] = [];\n  private _messageCounter = 0;\n\n  // Engine.IO only invokes send() callbacks on success. We keep a map of\n  // send callbacks for messages in flight so that we can invoke them for\n  // any messages still unsent upon receiving a \"close\" event.\n  private _messageCallbacks = new Map<number, (err: Error) => void>();\n\n  constructor(private _socket: EIO.Socket) { super(); }\n\n  public set onerror(handler: (err: Error) => void) {\n    // Note that as far as I can tell, Engine.IO sockets never emit \"error\"\n    // but instead include error information in the \"close\" event.\n    this._socket.on(\"error\", handler);\n    this._eventHandlers.push({ event: \"error\", handler });\n  }\n\n  public set onclose(handler: () => void) {\n    const wrappedHandler = (reason: string, description: any) => {\n      // In practice, when available, description has more error details,\n      // possibly in the form of an Error object.\n      const maybeErr = description ?? reason;\n      const err = maybeErr instanceof Error ? maybeErr : new Error(maybeErr);\n      for (const cb of this._messageCallbacks.values()) {\n        cb(err);\n      }\n      this._messageCallbacks.clear();\n\n      handler();\n    };\n    this._socket.on(\"close\", wrappedHandler);\n    this._eventHandlers.push({ event: \"close\", handler: wrappedHandler });\n  }\n\n  public set onmessage(handler: (data: string) => void) {\n    const wrappedHandler = (msg: Buffer) => {\n      handler(msg.toString());\n    };\n    this._socket.on(\"message\", wrappedHandler);\n    this._eventHandlers.push({ event: \"message\", handler: wrappedHandler });\n  }\n\n  public removeAllListeners() {\n    for (const { event, handler } of this._eventHandlers) {\n      this._socket.off(event, handler);\n    }\n    this._eventHandlers = [];\n  }\n\n  public close() {\n    this._socket.close();\n  }\n\n  // Terminates the connection without waiting for the client to close its own side.\n  public terminate() {\n    // Trigger a normal close. For the polling transport, this is sufficient and instantaneous.\n    this._socket.close(/* discard */ true);\n  }\n\n  public get isOpen() {\n    return this._socket.readyState === \"open\";\n  }\n\n  public send(data: string, cb?: (err?: Error) => void) {\n    const msgNum = this._messageCounter++;\n    if (cb) {\n      this._messageCallbacks.set(msgNum, cb);\n    }\n    this._socket.send(data, {}, () => {\n      if (cb && this._messageCallbacks.delete(msgNum)) {\n        // send was successful: pass no Error to callback\n        cb();\n      }\n    });\n  }\n}\n\nexport class GristServerSocketWS extends GristServerSocket {\n  private _eventHandlers: { event: string, handler: (...args: any[]) => void }[] = [];\n\n  constructor(private _ws: WS.WebSocket) { super(); }\n\n  public set onerror(handler: (err: Error) => void) {\n    this._ws.on(\"error\", handler);\n    this._eventHandlers.push({ event: \"error\", handler });\n  }\n\n  public set onclose(handler: () => void) {\n    this._ws.on(\"close\", handler);\n    this._eventHandlers.push({ event: \"close\", handler });\n  }\n\n  public set onmessage(handler: (data: string) => void) {\n    const wrappedHandler = (msg: Buffer) => handler(msg.toString());\n    this._ws.on(\"message\", wrappedHandler);\n    this._eventHandlers.push({ event: \"message\", handler: wrappedHandler });\n  }\n\n  public removeAllListeners() {\n    // Avoiding websocket.removeAllListeners() because WS.Server registers listeners\n    // internally for websockets it keeps track of, and we should not accidentally remove those.\n    for (const { event, handler } of this._eventHandlers) {\n      this._ws.off(event, handler);\n    }\n    this._eventHandlers = [];\n  }\n\n  public close() {\n    this._ws.close();\n  }\n\n  public terminate() {\n    this._ws.terminate();\n  }\n\n  public get isOpen() {\n    return this._ws.readyState === WS.OPEN;\n  }\n\n  public send(data: string, cb?: (err?: Error) => void) {\n    this._ws.send(data, cb);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/GristSocketServer.ts",
    "content": "import { GristServerSocket, GristServerSocketEIO, GristServerSocketWS } from \"app/server/lib/GristServerSocket\";\n\nimport * as http from \"http\";\nimport * as net from \"net\";\nimport * as stream from \"stream\";\n\nimport * as EIO from \"engine.io\";\nimport { EngineRequest } from \"engine.io/build/transport\";\nimport * as WS from \"ws\";\n\nconst MAX_PAYLOAD = 100e6;\n\nexport interface GristSocketServerOptions {\n  // Check if this request should be accepted. To produce a valid response (perhaps a rejection),\n  // this callback should not throw.\n  verifyClient?: (request: http.IncomingMessage) => Promise<boolean>;\n}\n\nexport class GristSocketServer {\n  private _wsServer: WS.Server;\n  private _eioServer: EIO.Server;\n  private _connectionHandler: (socket: GristServerSocket, req: http.IncomingMessage) => void;\n  private _originalHttpServerListeners: Function[];\n\n  constructor(private _httpServer: http.Server, private _options?: GristSocketServerOptions) {\n    this._handleEIOConnection = this._handleEIOConnection.bind(this);\n    this._handleHTTPUpgrade = this._handleHTTPUpgrade.bind(this);\n    this._handleHTTPRequest = this._handleHTTPRequest.bind(this);\n    this._closeSocketServers = this._closeSocketServers.bind(this);\n\n    this._wsServer = new WS.Server({ noServer: true, maxPayload: MAX_PAYLOAD });\n\n    this._eioServer = new EIO.Server({\n      // We only use Engine.IO for its polling transport,\n      // so we disable the built-in Engine.IO upgrade mechanism.\n      allowUpgrades: false,\n      transports: [\"polling\"],\n      maxHttpBufferSize: MAX_PAYLOAD,\n      cors: {\n        // This will cause Engine.IO to reflect any client-provided Origin into\n        // the Access-Control-Allow-Origin header, essentially disabling the\n        // protection offered by the Same-Origin Policy. This sounds insecure\n        // but is actually the security model of native WebSockets (they are\n        // not covered by SOP; any webpage can open a WebSocket connecting to\n        // any other domain, including the target domain's cookies; it is up to\n        // the receiving server to check the request's Origin header). Since\n        // the connection attempt is validated in `verifyClient` later,\n        // it is safe to let any client attempt a connection here.\n        origin: true,\n        // We need to allow the client to send its cookies. See above for the\n        // reasoning on why it is safe to do so.\n        credentials: true,\n        methods: [\"GET\", \"POST\"],\n      },\n    });\n\n    this._addEIOServerListeners();\n\n    this._addHTTPServerListeners();\n  }\n\n  public set onconnection(handler: (socket: GristServerSocket, req: http.IncomingMessage) => void) {\n    this._connectionHandler = handler;\n  }\n\n  /**\n   * Closes the WS servers and removes any associated connection handlers.\n   *\n   * Removal of connection handlers is done as a precaution for scenarios where\n   * a new GristSocketServer is instantiated after a previous one was closed.\n   * Currently, this only happens during tests where the Comm object is shut\n   * down or restarted. (See `Comm.testServerShutdown` and\n   * `Comm.testServerRestart`.)\n   *\n   * If handlers are not removed, requests to the HTTP server associated with\n   * this GristSocketServer will continue to be handled by listeners for a\n   * previous GristSocketServer.\n   */\n  public close(cb: (...args: any[]) => void) {\n    this._removeEIOServerListeners();\n    this._removeHTTPServerListeners();\n    this._closeSocketServers(cb);\n  }\n\n  private _addEIOServerListeners() {\n    this._eioServer.on(\"connection\", this._handleEIOConnection);\n  }\n\n  private _addHTTPServerListeners() {\n    // At this point an Express app is installed as the handler for the server's\n    // \"request\" event. We need to install our own listener instead, to intercept\n    // requests that are meant for the Engine.IO polling implementation.\n    this._originalHttpServerListeners = this._httpServer.listeners(\"request\");\n    this._httpServer.removeAllListeners(\"request\");\n\n    this._httpServer.on(\"upgrade\", this._handleHTTPUpgrade);\n    this._httpServer.on(\"request\", this._handleHTTPRequest);\n    this._httpServer.on(\"close\", this._closeSocketServers);\n  }\n\n  private _removeEIOServerListeners() {\n    this._eioServer.off(\"connection\", this._handleEIOConnection);\n  }\n\n  private _removeHTTPServerListeners() {\n    this._httpServer.off(\"upgrade\", this._handleHTTPUpgrade);\n    this._httpServer.off(\"request\", this._handleHTTPRequest);\n    this._httpServer.off(\"close\", this._closeSocketServers);\n\n    for (const listener of this._originalHttpServerListeners) {\n      this._httpServer.on(\"request\", listener as any);\n    }\n    this._originalHttpServerListeners = [];\n  }\n\n  private _closeSocketServers(cb: (...args: any[]) => void) {\n    this._eioServer.close();\n\n    // Terminate all clients. WS.Server used to do it automatically in close() but no\n    // longer does (see https://github.com/websockets/ws/pull/1904#discussion_r668844565).\n    for (const ws of this._wsServer.clients) {\n      ws.terminate();\n    }\n    this._wsServer.close(cb);\n  }\n\n  private _handleEIOConnection(socket: EIO.Socket) {\n    const req = socket.request;\n    (socket as any).request = null; // Free initial request as recommended in the Engine.IO documentation\n    this._connectionHandler?.(new GristServerSocketEIO(socket), req);\n  }\n\n  private _handleHTTPUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) {\n    destroyOnRejection(socket, async () => {\n      if (this._options?.verifyClient && !await this._options.verifyClient(req)) {\n        // Because we are handling an \"upgrade\" event, we don't have access to\n        // a \"response\" object, just the raw socket. We can still construct\n        // a well-formed HTTP error response.\n        socket.write(\"HTTP/1.1 403 Forbidden\\r\\n\\r\\n\");\n        socket.destroy();\n        return;\n      }\n      this._wsServer.handleUpgrade(req, socket, head, (client) => {\n        this._connectionHandler?.(new GristServerSocketWS(client), req);\n      });\n    });\n  }\n\n  private _handleHTTPRequest(req: http.IncomingMessage, res: http.ServerResponse) {\n    destroyOnRejection(req.socket, async () => {\n      // Intercept requests that have transport=polling in their querystring\n      if (/[&?]transport=polling(&|$)/.test(req.url ?? \"\")) {\n        if (this._options?.verifyClient && !await this._options.verifyClient(req)) {\n          res.writeHead(403).end();\n          return;\n        }\n\n        this._eioServer.handleRequest(req as EngineRequest, res);\n      } else {\n        // Otherwise fallback to the pre-existing listener(s)\n        for (const listener of this._originalHttpServerListeners) {\n          listener.call(this._httpServer, req, res);\n        }\n      }\n    });\n  }\n}\n\n/**\n * Wrapper for server event handlers that catches rejected promises, which would otherwise\n * lead to \"unhandledRejection\" and process exit. Instead we abort the connection, which helps\n * in testing this scenario. This is a fallback; in reality, handlers should never throw.\n */\nfunction destroyOnRejection(socket: stream.Duplex, func: () => Promise<void>) {\n  func().catch((_e) => {\n    socket.destroy();\n  });\n}\n"
  },
  {
    "path": "app/server/lib/HashUtil.ts",
    "content": "import { DocState } from \"app/common/DocState\";\n\n/**\n *\n * Helper class to support a small subset of git-style references for state hashes:\n *   HEAD = the most recent state\n *   [HASH]^1 = the parent of [HASH]\n *   [HASH]~1 = the parent of [HASH]\n *   [HASH]~2 = the grandparent of [HASH]\n *   [HASH]^1^1 = the grandparent of [HASH]\n *   [HASH]~3 = the great grandparent of [HASH]\n * For git, where commits have multiple parents, \"~\" refers to the first parent,\n * and \"^1\" also refers to the first parent.  For grist, there are only first parents\n * (unless/until we start tracking history across merges).\n *\n */\nexport class HashUtil {\n  /**\n   * To construct, provide a list of states, most recent first.\n   */\n  constructor(private _state: DocState[]) {}\n\n  /**\n   * Find the named hash in the list of states, allowing for aliases.\n   * Returns an index into the list of states provided in constructor.\n   */\n  public hashToOffset(hash: string): number {\n    const parts = hash.split(/([~^][0-9]*)/);\n    hash = parts.shift() || \"\";\n    let offset = hash === \"HEAD\" ? 0 : this._state.findIndex(state => state.h === hash);\n    if (offset < 0) { throw new Error(\"Cannot read hash\"); }\n    for (const part of parts) {\n      if (part === \"^\" || part === \"^1\") {\n        offset++;\n      } else if (part.startsWith(\"~\")) {\n        offset += parseInt(part.slice(1) || \"1\", 10);\n      } else if (part === \"\") {\n        // pass\n      } else {\n        throw new Error(\"cannot parse hash\");\n      }\n    }\n    return offset;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/HostedMetadataManager.ts",
    "content": "import { DocumentMetadata } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport log from \"app/server/lib/log\";\n\n// Callback that persists the updated metadata to storage for each document.\nexport type SaveDocsMetadataFunc = (metadata: { [docId: string]: DocumentMetadata }) => Promise<any>;\n\n/**\n * HostedMetadataManager handles pushing document metadata changes to the Home database when\n * a doc is updated. Currently updates doc updatedAt time and usage.\n */\nexport class HostedMetadataManager {\n  // Document metadata mapped by docId.\n  private _metadata: { [docId: string]: DocumentMetadata } = {};\n\n  // Set if the class holder is closing and no further pushes should be scheduled.\n  private _closing: boolean = false;\n\n  // Last push time in ms since epoch.\n  private _lastPushTime: number = 0.0;\n\n  // Callback for next opportunity to push changes.\n  private _timeout: NodeJS.Timeout | null = null;\n\n  // Maintains the update Promise to wait on it if the class is closing.\n  private _push: Promise<void> | null;\n\n  // The default delay in milliseconds between metadata pushes to the database.\n  private readonly _minPushDelayMs: number;\n\n  /**\n   * Create an instance of HostedMetadataManager.\n   * The minPushDelay is the default delay in seconds between metadata pushes to the database.\n   */\n  constructor(private _saveDocsMetadata: SaveDocsMetadataFunc, minPushDelay: number = 60) {\n    this._minPushDelayMs = minPushDelay * 1000;\n  }\n\n  /**\n   * Close the manager. Send out any pending updates and prevent more from being scheduled.\n   */\n  public async close(): Promise<void> {\n    // Pushes will no longer be scheduled.\n    this._closing = true;\n    // Wait for outgoing pushes to finish before proceeding.\n    if (this._push) { await this._push; }\n    if (this._timeout) {\n      // Since an update was scheduled, perform one final update now.\n      this._update();\n      if (this._push) { await this._push; }\n    }\n  }\n\n  /**\n   * Schedule a call to _update some time from now.  When the update is made, it will\n   * store the given metadata in the updated_at and usage columns of the docs table for\n   * the specified document.\n   *\n   * If `minimizeDelay` is true, the push will be scheduled with minimum delay (0ms) and\n   * will cancel/overwrite an already scheduled push (if present).\n   */\n  public scheduleUpdate(docId: string, metadata: DocumentMetadata, minimizeDelay = false): void {\n    if (this._closing) { return; }\n\n    // Update metadata even if an update is already scheduled - if the update has not yet occurred,\n    // the more recent metadata will be used.\n    this._setOrUpdateMetadata(docId, metadata);\n    if (this._timeout && !minimizeDelay) { return; }\n\n    this._schedulePush(minimizeDelay ? 0 : undefined);\n  }\n\n  public setDocsMetadata(docUpdateMap: { [docId: string]: DocumentMetadata }): Promise<any> {\n    return this._saveDocsMetadata(docUpdateMap);\n  }\n\n  /**\n   * Push all metadata updates to the database.\n   */\n  private _update(): void {\n    if (this._timeout) {\n      clearTimeout(this._timeout);\n      this._timeout = null;\n    }\n    if (this._push) { return; }\n    this._push = this._performUpdate()\n      .catch((err) => {\n        log.error(\"HostedMetadataManager error performing update: \", err);\n      })\n      .then(() => {\n        this._push = null;\n        if (!this._closing && !this._timeout && Object.keys(this._metadata).length !== 0) {\n        // If we have metadata that hasn't been pushed up yet, but no push scheduled,\n        // go ahead and schedule an immediate push. This can happen if `scheduleUpdate`\n        // is called frequently with minimizeDelay set to true, particularly when\n        // _performUpdate is taking a bit longer than normal to complete.\n          this._schedulePush(0);\n        }\n      });\n  }\n\n  /**\n   * This is called by the update function to actually perform the update. This should not\n   * be called unless to force an immediate update.\n   */\n  private async _performUpdate(): Promise<void> {\n    // Await the database if it is not yet connected.\n    const docUpdates = this._metadata;\n    this._metadata = {};\n    this._lastPushTime = Date.now();\n    await this.setDocsMetadata(docUpdates);\n  }\n\n  /**\n   * Schedule a metadata push.\n   *\n   * If `delayMs` is specified, the push will be scheduled to occur at least that\n   * number of milliseconds in the future. If `delayMs` is unspecified, the push\n   * will be scheduled to occur at least `_minPushDelayMs` after the last push time.\n   *\n   * If called while a push is already scheduled, that push will be cancelled and\n   * replaced with this one.\n   */\n  private _schedulePush(delayMs?: number): void {\n    if (delayMs === undefined) {\n      delayMs = Math.round(this._minPushDelayMs - (Date.now() - this._lastPushTime));\n    }\n    if (this._timeout) { clearTimeout(this._timeout); }\n    this._timeout = setTimeout(() => this._update(), delayMs < 0 ? 0 : delayMs);\n  }\n\n  /**\n   * Adds `docId` and its `metadata` to the list of queued updates, merging any existing values.\n   */\n  private _setOrUpdateMetadata(docId: string, metadata: DocumentMetadata): void {\n    if (!this._metadata[docId]) {\n      this._metadata[docId] = metadata;\n    } else {\n      const { updatedAt, usage } = metadata;\n      if (updatedAt) { this._metadata[docId].updatedAt = updatedAt; }\n      if (usage !== undefined) { this._metadata[docId].usage = usage; }\n    }\n  }\n}\n"
  },
  {
    "path": "app/server/lib/HostedStorageManager.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { mapGetOrSet } from \"app/common/AsyncCreate\";\nimport { delay } from \"app/common/delay\";\nimport { DocEntry } from \"app/common/DocListAPI\";\nimport { DocSnapshots } from \"app/common/DocSnapshot\";\nimport { DocumentUsage } from \"app/common/DocUsage\";\nimport { Features } from \"app/common/Features\";\nimport { buildUrlId, parseUrlId } from \"app/common/gristUrls\";\nimport { KeyedOps } from \"app/common/KeyedOps\";\nimport { DocReplacementOptions, NEW_DOCUMENT_CODE } from \"app/common/UserAPI\";\nimport { backupUsingBestConnection } from \"app/server/lib/backupSqliteDatabase\";\nimport { checksumFile } from \"app/server/lib/checksumFile\";\nimport { DocSnapshotInventory, DocSnapshotPruner } from \"app/server/lib/DocSnapshots\";\nimport { IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport {\n  ChecksummedExternalStorage,\n  DELETED_TOKEN,\n  ExternalStorage,\n  ExternalStorageCreator, ExternalStorageSettings,\n  Unchanged,\n} from \"app/server/lib/ExternalStorage\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { HostedMetadataManager, SaveDocsMetadataFunc } from \"app/server/lib/HostedMetadataManager\";\nimport { EmptySnapshotProgress, IDocStorageManager, SnapshotProgress } from \"app/server/lib/IDocStorageManager\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\nimport { OpenMode, SQLiteDB } from \"app/server/lib/SQLiteDB\";\n\nimport * as path from \"path\";\n\nimport * as fse from \"fs-extra\";\nimport { v4 as uuidv4 } from \"uuid\";\n\n// Check for a valid document id.\nconst docIdRegex = /^[-=_\\w~%]+$/;\n\n// Wait this long after a change to the document before trying to make a backup of it.\nconst GRIST_BACKUP_DELAY_SECS = parseInt(process.env.GRIST_BACKUP_DELAY_SECS || \"15\", 10);\n\nfunction checkValidDocId(docId: string): void {\n  if (!docIdRegex.test(docId)) {\n    throw new Error(`Invalid docId ${docId}`);\n  }\n}\n\nexport interface HostedStorageCallbacks {\n  // Saves the given metadata for the specified documents.\n  setDocsMetadata: SaveDocsMetadataFunc,\n  // Retrieves account features enabled for the given document.\n  getDocFeatures: (docId: string) => Promise<Features | undefined>\n}\n\nexport interface HostedStorageOptions {\n  secondsBeforePush: number;\n  secondsBeforeFirstRetry: number;\n  pushDocUpdateTimes: boolean;\n}\n\nconst defaultOptions: HostedStorageOptions = {\n  secondsBeforePush: GRIST_BACKUP_DELAY_SECS,\n  secondsBeforeFirstRetry: 3.0,\n  pushDocUpdateTimes: true,\n};\n\n/**\n * HostedStorageManager manages Grist files in the hosted environment for a particular DocWorker.\n * These files are stored on S3 and synced to the local file system. It matches the interface of\n * DocStorageManager (used for standalone Grist), but is more limited, e.g. does not expose the\n * list of local files.\n *\n * In hosted environment, documents are uniquely identified by docId, which serves as the\n * canonical docName. This ID does not change on renaming. HostedStorageManager knows nothing of\n * friendlier doc names.\n *\n * TODO: Listen (to Redis?) to find out when a local document has been deleted or renamed.\n *  (In case of rename, something on the DocWorker needs to inform the client about the rename)\n * TODO: Do something about the active flag in redis DocStatus.\n * TODO: Add an explicit createFlag in DocStatus for clarity and simplification.\n */\nexport class HostedStorageManager implements IDocStorageManager {\n  // Handles pushing doc metadata changes when the doc is updated.\n  private _metadataManager: HostedMetadataManager | null = null;\n\n  // Maps docId to the promise for when the document is present on the local filesystem.\n  private _localFiles = new Map<string, Promise<boolean>>();\n\n  // Label to put in metadata for a document.  Only one label supported per snapshot currently.\n  // Holds the label that should be associated with a backup when a labeled backup is being made.\n  private _labels = new Map<string, string>();\n\n  // Time at which document was last changed.\n  private _timestamps = new Map<string, string>();\n\n  // Statistics related to snapshot generation.\n  private _snapshotProgress = new Map<string, SnapshotProgress>();\n\n  // Access external storage.\n  private _ext: ChecksummedExternalStorage;\n  private _extMeta: ChecksummedExternalStorage;\n\n  // Prune external storage.\n  private _pruner: DocSnapshotPruner;\n\n  // Access to version information about documents.\n  private _inventory: DocSnapshotInventory;\n\n  // A set of filenames currently being created or downloaded.\n  private _prepareFiles = new Set<string>();\n\n  // Ongoing and scheduled uploads for documents.\n  private _uploads: KeyedOps;\n\n  // Set once the manager has been closed.\n  private _closed: boolean = false;\n\n  private _baseStore: ExternalStorage;  // External store for documents, without checksumming.\n\n  // Latest version ids of documents.\n  private _latestVersions = new Map<string, string>();\n  private _latestMetaVersions = new Map<string, string>();\n\n  private _log = new LogMethods(\"HostedStorageManager \", (docId: string | null) => ({ docId }));\n\n  /**\n   * Initialize with the given root directory, which should be a fully-resolved path.\n   * If s3Bucket is blank, S3 storage will be disabled.\n   */\n  constructor(\n    private _gristServer: GristServer,\n    private _docsRoot: string,\n    private _docWorkerId: string,\n    private _disableS3: boolean,\n    private _docWorkerMap: IDocWorkerMap,\n    callbacks: HostedStorageCallbacks,\n    createExternalStorage: ExternalStorageCreator,\n    options: HostedStorageOptions = defaultOptions,\n  ) {\n    const creator = (purpose: ExternalStorageSettings[\"purpose\"]) => createExternalStorage(purpose, \"\");\n    // We store documents either in a test store, or in an s3 store\n    // at s3://<s3Bucket>/<s3Prefix><docId>.grist\n    const externalStoreDoc = this._disableS3 ? undefined : creator(\"doc\");\n    if (!externalStoreDoc) { this._disableS3 = true; }\n    const secondsBeforePush = options.secondsBeforePush;\n    if (options.pushDocUpdateTimes) {\n      this._metadataManager = new HostedMetadataManager(callbacks.setDocsMetadata.bind(callbacks));\n    }\n    this._uploads = new KeyedOps(key => this._pushToS3(key), {\n      delayBeforeOperationMs: secondsBeforePush * 1000,\n      retry: true,\n      logError: (key, failureCount, err) => {\n        this._log.error(null, \"error pushing %s (%d): %s\", key, failureCount, err);\n      },\n      scheduleFromFirstAdd: true,\n    });\n\n    if (!this._disableS3) {\n      this._baseStore = externalStoreDoc!;\n      // Whichever store we have, we use checksums to deal with\n      // eventual consistency.\n      this._ext = this._getChecksummedExternalStorage(\"doc\", this._baseStore,\n        this._latestVersions, options);\n\n      const baseStoreMeta = creator(\"meta\");\n      if (!baseStoreMeta) {\n        throw new Error('bug: external storage should be created for \"meta\" if it is created for \"doc\"');\n      }\n      this._extMeta = this._getChecksummedExternalStorage(\"meta\", baseStoreMeta,\n        this._latestMetaVersions,\n        options);\n\n      this._inventory = new DocSnapshotInventory(\n        this._ext,\n        this._extMeta,\n        async (docId) => {\n          const dir = this.getAssetPath(docId);\n          await fse.mkdirp(dir);\n          return path.join(dir, \"meta.json\");\n        },\n        async (docId) => {\n          const features = await callbacks.getDocFeatures(docId);\n          return features?.snapshotWindow;\n        },\n      );\n\n      // The pruner could use an inconsistent store without any real loss overall,\n      // but tests are easier if it is consistent.\n      this._pruner = new DocSnapshotPruner(this._inventory, {\n        delayBeforeOperationMs: 0,  // prune as soon as we've made a first upload.\n        minDelayBetweenOperationsMs: secondsBeforePush * 4000,  // ... but wait awhile before\n        // pruning again.\n      });\n    }\n  }\n\n  /**\n   * Send a document to S3, without doing anything fancy.  Assumes this is the first time\n   * the object is written in S3 - so no need to worry about consistency.\n   */\n  public async addToStorage(docId: string) {\n    if (this._disableS3) { return; }\n    this._uploads.addOperation(docId);\n    await this._uploads.expediteOperationAndWait(docId);\n  }\n\n  public getPath(docName: string): string {\n    return this.getAssetPath(docName) + \".grist\";\n  }\n\n  public getSQLiteDB(docName: string) {\n    return this._gristServer.getDocManager().getSQLiteDB(docName);\n  }\n\n  // Where to store files related to a document locally.  Document goes in <assetPath>.grist,\n  // and other files go in <assetPath>/ directory.\n  public getAssetPath(docName: string): string {\n    checkValidDocId(docName);\n    return path.join(this._docsRoot, path.basename(docName, \".grist\"));\n  }\n\n  // We don't deal with sample docs\n  public getSampleDocPath(sampleDocName: string): string | null { return null; }\n\n  /**\n   * Translates a possibly non-canonical docName to a canonical one. Returns a bare docId,\n   * stripping out any possible path components or .grist extension. (We don't expect these to\n   * ever be used, but stripping seems better than asserting.)\n   */\n  public async getCanonicalDocName(altDocName: string): Promise<string> {\n    return path.basename(altDocName, \".grist\");\n  }\n\n  /**\n   * Read some statistics related to generating snapshots.\n   */\n  public getSnapshotProgress(docName: string): SnapshotProgress {\n    let snapshotProgress = this._snapshotProgress.get(docName);\n    if (!snapshotProgress) {\n      snapshotProgress = new EmptySnapshotProgress();\n      this._snapshotProgress.set(docName, snapshotProgress);\n    }\n    return snapshotProgress;\n  }\n\n  /**\n   * Prepares a document for use locally. Here we sync the doc from S3 to the local filesystem.\n   * Returns whether the document is new (needs to be created).\n   * Calling this method multiple times in parallel for the same document is treated as a sign\n   * of a bug.\n   *\n   * The optional srcDocName parameter is set when preparing a fork.\n   */\n  public async prepareLocalDoc(docName: string, srcDocName?: string): Promise<boolean> {\n    // We could be reopening a document that is still closing down.\n    // Wait for that to happen.  TODO: we could also try to interrupt the closing-down process.\n    await this.closeDocument(docName);\n\n    if (this._prepareFiles.has(docName)) {\n      throw new Error(`Tried to call prepareLocalDoc('${docName}') twice in parallel`);\n    }\n\n    try {\n      this._prepareFiles.add(docName);\n      const isNew = !(await this._claimDocument(docName, srcDocName));\n      return isNew;\n    } finally {\n      this._prepareFiles.delete(docName);\n    }\n  }\n\n  public async prepareToCreateDoc(docName: string): Promise<void> {\n    await this.prepareLocalDoc(docName, \"new\");\n    if (this._inventory) {\n      await this._inventory.create(docName);\n      await this._onInventoryChange(docName);\n    }\n    this.markAsChanged(docName);\n  }\n\n  /**\n   * Initialize one document from another, associating the result with the current\n   * worker.\n   */\n  public async prepareFork(srcDocName: string, destDocName: string): Promise<string> {\n    await this.prepareLocalDoc(destDocName, srcDocName);\n    this.markAsChanged(destDocName);  // Make sure fork is actually stored in S3, even\n    // if no changes are made, since we'd refuse to\n    // create it later.\n    return this.getPath(destDocName);\n  }\n\n  // Gets a copy of the document, eg. for downloading.  Returns full file path.\n  // Copy won't change if edits are made to the document.  It is caller's responsibility\n  // to delete the result.\n  public async getCopy(docName: string): Promise<string> {\n    const present = await this._claimDocument(docName);\n    if (!present) {\n      throw new Error(\"cannot copy document that does not exist yet\");\n    }\n    return await this._prepareBackup(docName, { postfix: uuidv4() });\n  }\n\n  public async replace(docId: string, options: DocReplacementOptions): Promise<void> {\n    // Make sure the current version of the document is flushed.\n    await this.flushDoc(docId);\n\n    // Figure out the source s3 key to copy from.  For this purpose, we need to\n    // remove any snapshotId embedded in the document id.\n    const rawSourceDocId = options.sourceDocId || docId;\n    const parts = parseUrlId(rawSourceDocId);\n    const sourceDocId = buildUrlId({ ...parts, snapshotId: undefined });\n    const snapshotId = options.snapshotId || parts.snapshotId;\n\n    if (sourceDocId === docId && !snapshotId) { return; }\n\n    // Basic implementation for when S3 is not available.\n    if (this._disableS3) {\n      if (snapshotId) {\n        throw new Error(\"snapshots not supported without S3\");\n      }\n      if (await fse.pathExists(this.getPath(sourceDocId))) {\n        await this._prepareBackup(sourceDocId, {\n          output: this.getPath(docId),\n        });\n        return;\n      } else {\n        throw new Error(`cannot find ${docId}`);\n      }\n    }\n\n    // While replacing, move the current version of the document aside.  If a problem\n    // occurs, move it back.\n    // The document is not open at this point, so we don't have to\n    // worry about SQLite WAL-related files.\n    const docPath = this.getPath(docId);\n    const tmpPath = `${docPath}-replacing`;\n    // NOTE: fse.remove succeeds also when the file does not exist.\n    await fse.remove(tmpPath);\n    if (await fse.pathExists(docPath)) {\n      await fse.move(docPath, tmpPath);\n    }\n    try {\n      // Fetch new content from S3.\n      if (!await this._fetchFromS3(docId, { sourceDocId, snapshotId })) {\n        throw new Error(\"Cannot fetch document\");\n      }\n      // Make sure the new content is considered new.\n      // NOTE: fse.remove succeeds also when the file does not exist.\n      await fse.remove(this._getHashFile(this.getPath(docId)));\n      this.markAsChanged(docId, \"edit\");\n      // Invalidate usage; it'll get re-computed the next time the document is opened.\n      this.scheduleUsageUpdate(docId, null, true);\n    } catch (err) {\n      this._log.error(docId, \"problem replacing doc: %s\", err);\n      await fse.move(tmpPath, docPath, { overwrite: true });\n      throw err;\n    } finally {\n      // NOTE: fse.remove succeeds also when the file does not exist.\n      await fse.remove(tmpPath);\n    }\n    // Flush the document immediately if it has been changed.\n    await this.flushDoc(docId);\n  }\n\n  // We don't deal with listing documents.\n  public async listDocs(): Promise<DocEntry[]> { return []; }\n\n  public async deleteDoc(docName: string, deletePermanently?: boolean): Promise<void> {\n    if (!deletePermanently) {\n      throw new Error(\"HostedStorageManager only implements permanent deletion in deleteDoc\");\n    }\n    await this.closeDocument(docName);\n    if (!this._disableS3) {\n      await this._ext.remove(docName);\n      await this._extMeta.remove(docName);\n    }\n    // NOTE: fse.remove succeeds also when the file does not exist.\n    await fse.remove(this.getPath(docName));\n    await fse.remove(this._getHashFile(this.getPath(docName), \"doc\"));\n    await fse.remove(this._getHashFile(this.getPath(docName), \"meta\"));\n    await fse.remove(this.getAssetPath(docName));\n  }\n\n  // We don't implement document renames.\n  public async renameDoc(oldName: string, newName: string): Promise<void> {\n    throw new Error(\"HostedStorageManager does not implement renameDoc\");\n  }\n\n  /**\n   * We handle backups by syncing the current version of the file as a new object version in S3,\n   * with the requested backupTag as metadata.\n   */\n  public async makeBackup(docName: string, backupTag: string): Promise<string> {\n    if (this._labels.get(docName)) {\n      await this.flushDoc(docName);\n    }\n    this._labels.set(docName, backupTag);\n    this.markAsChanged(docName);\n    await this.flushDoc(docName);\n    // TODO: make an alternative way to store backups if operating without an external\n    // store.\n    return this._ext ?\n      (this._ext.url(docName) + \" (\" + this._latestVersions.get(docName) + \")\") :\n      \"no-external-storage-enabled\";\n  }\n\n  /**\n   * Electron version only. Shows the given doc in the file explorer.\n   */\n  public async showItemInFolder(docName: string): Promise<void> {\n    throw new Error(\"HostedStorageManager does not implement showItemInFolder\");\n  }\n\n  /**\n   * Close the storage manager.  Make sure any pending changes reach S3 first.\n   */\n  public async closeStorage(): Promise<void> {\n    await this._uploads.wait(() =>  this._log.info(null, \"waiting for closeStorage to finish\"));\n\n    // Close metadata manager.\n    if (this._metadataManager) { await this._metadataManager.close(); }\n\n    // Finish up everything incoming.  This is most relevant for tests.\n    // Wait for any downloads to wind up, since there's no easy way to cancel them.\n    while (this._prepareFiles.size > 0) { await delay(100); }\n    await Promise.all(this._localFiles.values());\n\n    this._closed = true;\n    if (this._ext) { await this._ext.close(); }\n    if (this._pruner) { await this._pruner.close(); }\n  }\n\n  /**\n   * Allow storage manager to be used again - used in tests.\n   */\n  public testReopenStorage() {\n    this._closed = false;\n  }\n\n  public async testWaitForPrunes() {\n    if (this._pruner) { await this._pruner.wait(); }\n  }\n\n  /**\n   * Get direct access to the external store - used in tests.\n   */\n  public testGetExternalStorage(): ExternalStorage {\n    return this._baseStore;\n  }\n\n  // return true if document and inventory is backed up to external store (if attached).\n  public isAllSaved(docName: string): boolean {\n    return !this._uploads.hasPendingOperation(docName) &&\n      (this._inventory ? this._inventory.isSaved(docName) : true);\n  }\n\n  // pick up the pace of pushing to s3, from leisurely to urgent.\n  public prepareToCloseStorage() {\n    if (this._pruner) {\n      this._pruner.close().catch(e => this._log.error(null, \"pruning error %s\", e));\n    }\n    this._uploads.expediteOperations();\n  }\n\n  // forcibly stop operations that might otherwise retry indefinitely,\n  // for testing purposes.\n  public async testStopOperations() {\n    this._uploads.stopOperations();\n    await this._uploads.wait();\n  }\n\n  /**\n   * Finalize any operations involving the named document.\n   */\n  public async closeDocument(docName: string): Promise<void> {\n    if (this._localFiles.has(docName)) {\n      await this._localFiles.get(docName);\n    }\n    this._localFiles.delete(docName);\n    return this.flushDoc(docName);\n  }\n\n  /**\n   * Make sure document is backed up to s3.\n   */\n  public async flushDoc(docName: string): Promise<void> {\n    while (!this.isAllSaved(docName)) {\n      this._log.info(docName, \"waiting for document to finish\");\n      await this._uploads.expediteOperationAndWait(docName);\n      await this._inventory?.flush(docName);\n      if (!this.isAllSaved(docName)) {\n        // Throttle slightly in case this operation ends up looping excessively.\n        await delay(1000);\n      }\n    }\n  }\n\n  /**\n   * This is called when a document may have been changed, via edits or migrations etc.\n   */\n  public markAsChanged(docName: string, reason?: string): void {\n    const now = new Date();\n    const snapshotProgress = this.getSnapshotProgress(docName);\n    snapshotProgress.lastChangeAt = now.getTime();\n    snapshotProgress.changes++;\n    const timestamp = now.toISOString();\n    this._timestamps.set(docName, timestamp);\n    try {\n      if (parseUrlId(docName).snapshotId) { return; }\n      if (this._localFiles.has(docName)) {\n        // Make sure the file is marked as locally present (it may be newly created).\n        this._localFiles.set(docName, Promise.resolve(true));\n      }\n      if (this._disableS3) { return; }\n      if (this._closed) { throw new Error(\"HostedStorageManager.markAsChanged called after closing\"); }\n      if (!this._uploads.hasPendingOperation(docName)) {\n        snapshotProgress.lastWindowStartedAt = now.getTime();\n        snapshotProgress.windowsStarted++;\n      }\n      this._uploads.addOperation(docName);\n    } finally {\n      if (reason === \"edit\") {\n        this._markAsEdited(docName, timestamp);\n      }\n    }\n  }\n\n  /**\n   * Schedule an update to a document's usage column.\n   *\n   * If `minimizeDelay` is true, HostedMetadataManager will attempt to\n   * minimize delays by scheduling the update to occur as soon as possible.\n   */\n  public scheduleUsageUpdate(\n    docName: string,\n    docUsage: DocumentUsage | null,\n    minimizeDelay = false,\n  ): void {\n    const { forkId, snapshotId } = parseUrlId(docName);\n    if (!this._metadataManager || forkId || snapshotId) { return; }\n\n    this._metadataManager.scheduleUpdate(\n      docName,\n      { usage: docUsage },\n      minimizeDelay,\n    );\n  }\n\n  /**\n   * Check if there is a pending change to be pushed to S3.\n   */\n  public needsUpdate(): boolean {\n    return this._uploads.hasPendingOperations();\n  }\n\n  public async removeSnapshots(docName: string, snapshotIds: string[]): Promise<void> {\n    if (this._disableS3) { return; }\n    await this._pruner.prune(docName, snapshotIds);\n  }\n\n  public async getSnapshots(docName: string, skipMetadataCache?: boolean): Promise<DocSnapshots> {\n    if (this._disableS3) {\n      return {\n        snapshots: [{\n          snapshotId: \"current\",\n          lastModified: new Date().toISOString(),\n          docId: docName,\n        }],\n      };\n    }\n    const versions = skipMetadataCache ?\n      await this._ext.versions(docName) :\n      await this._inventory.versions(docName, this._latestVersions.get(docName) || null);\n    const parts = parseUrlId(docName);\n    return {\n      snapshots: versions\n        .map((v) => {\n          return {\n            ...v,\n            docId: buildUrlId({ ...parts, snapshotId: v.snapshotId }),\n          };\n        }),\n    };\n  }\n\n  public async getFsFileSize(docName: string): Promise<number> {\n    return (await fse.stat(this.getPath(docName))).size;\n  }\n\n  /**\n   * This is called when a document was edited by the user.\n   */\n  private _markAsEdited(docName: string, timestamp: string): void {\n    if (!this._metadataManager) { return; }\n\n    const { forkId, snapshotId } = parseUrlId(docName);\n    if (snapshotId) { return; }\n\n    // Schedule a metadata update for the modified doc.\n    const docId = forkId || docName;\n    this._metadataManager.scheduleUpdate(docId, { updatedAt: timestamp });\n  }\n\n  /**\n   * Makes sure a document is assigned to this worker, adding an\n   * assignment if it has none.  If the document is present in\n   * external storage, fetch it.  Return true if the document was\n   * fetched.\n   *\n   * The document can optionally be copied from an alternative\n   * source (srcDocName).  This is useful for forking.\n   *\n   * If srcDocName is 'new', checks for the document in external storage\n   * are skipped.\n   */\n  private async _claimDocument(docName: string,\n    srcDocName?: string): Promise<boolean> {\n    // AsyncCreate.mapGetOrSet ensures we don't start multiple promises to talk to S3/Redis\n    // and that we clean up the failed key in case of failure.\n    return mapGetOrSet(this._localFiles, docName, async () => {\n      if (this._closed) { throw new Error(\"HostedStorageManager._ensureDocumentIsPresent called after closing\"); }\n      checkValidDocId(docName);\n\n      const { trunkId, forkId, snapshotId } = parseUrlId(docName);\n\n      const canCreateFork = Boolean(srcDocName);\n\n      const docStatus = await this._docWorkerMap.getDocWorkerOrAssign(docName, this._docWorkerId);\n      if (!docStatus.isActive) { throw new Error(`Doc is not active on a DocWorker: ${docName}`); }\n      if (docStatus.docWorker.id !== this._docWorkerId) {\n        throw new Error(`Doc belongs to a different DocWorker (${docStatus.docWorker.id}): ${docName}`);\n      }\n\n      if (srcDocName === \"new\") { return false; }\n\n      if (this._disableS3) {\n        // skip S3, just use file system\n        let present: boolean = await fse.pathExists(this.getPath(docName));\n        if ((forkId || snapshotId) && !present) {\n          if (!canCreateFork) { throw new ApiError(\"Document fork not found\", 404); }\n          if (snapshotId && snapshotId !== \"current\") {\n            throw new ApiError(`cannot find snapshot ${snapshotId} of ${docName}`, 404);\n          }\n          if (await fse.pathExists(this.getPath(trunkId))) {\n            await this._prepareBackup(trunkId, {\n              output: this.getPath(docName),\n            });\n            present = true;\n          }\n        }\n        return present;\n      }\n\n      const existsLocally = await fse.pathExists(this.getPath(docName));\n      if (existsLocally) {\n        if (!docStatus.docMD5 || docStatus.docMD5 === DELETED_TOKEN || docStatus.docMD5 === \"unknown\") {\n          // New doc appears to already exist, but may not exist in S3.\n          // Let's check.\n          const head = await this._ext.head(docName);\n          const lastLocalVersionSeen = this._latestVersions.get(docName);\n          if (head && lastLocalVersionSeen !== head.snapshotId) {\n            // Exists in S3, with a version not known to be latest seen\n            // by this worker - so wipe local version and defer to S3.\n            await this._wipeCache(docName);\n          } else {\n            // Go ahead and use local version.\n            return true;\n          }\n        } else {\n          // Doc exists locally and in S3 (according to redis).\n          // Make sure the checksum matches.\n          const checksum = await this._getHash(await this._prepareBackup(docName));\n          if (checksum === docStatus.docMD5) {\n            // Fine, accept the doc as existing on our file system.\n            return true;\n          } else {\n            this._log.info(docName, \"Local hash does not match redis: %s vs %s\", checksum, docStatus.docMD5);\n            // The file that exists locally does not match S3.  But S3 is the canonical version.\n            // On the assumption that the local file is outdated, delete it.\n            // TODO: may want to be more careful in case the local file has modifications that\n            // simply never made it to S3 due to some kind of breakage.\n            await this._wipeCache(docName);\n          }\n        }\n      }\n      return this._fetchFromS3(docName, {\n        sourceDocId: srcDocName,\n        trunkId: forkId ? trunkId : undefined, snapshotId, canCreateFork,\n      });\n    });\n  }\n\n  /**\n   * Remove local version of a document, and state related to it.\n   */\n  private async _wipeCache(docName: string) {\n    // NOTE: fse.remove succeeds also when the file does not exist.\n    await fse.remove(this.getPath(docName));\n    await fse.remove(this._getHashFile(this.getPath(docName), \"doc\"));\n    await fse.remove(this._getHashFile(this.getPath(docName), \"meta\"));\n    await this._inventory.clear(docName);\n    this._latestVersions.delete(docName);\n    this._latestMetaVersions.delete(docName);\n  }\n\n  /**\n   * Fetch a document from s3 and save it locally as destId.grist\n   *\n   * If the document is not present in s3:\n   *  + If it has a trunk:\n   *    - If we do not not have permission to create a fork, we throw an error\n   *    - Else we fetch the document from the trunk instead\n   *  + Otherwise return false\n   *\n   * Forks of fork will not spark joy at this time.  An attempt to\n   * fork a fork will result in a new fork of the original trunk.\n   */\n  private async _fetchFromS3(destId: string, options: { sourceDocId?: string,\n    trunkId?: string,\n    snapshotId?: string,\n    canCreateFork?: boolean }): Promise<boolean> {\n    const destIdWithoutSnapshot = buildUrlId({ ...parseUrlId(destId), snapshotId: undefined });\n    let sourceDocId = options.sourceDocId || destIdWithoutSnapshot;\n    if (!await this._ext.exists(destIdWithoutSnapshot)) {\n      if (!options.trunkId) { return false; }   // Document not found in S3\n      // No such fork in s3 yet, try from trunk (if we are allowed to create the fork).\n      if (!options.canCreateFork) { throw new ApiError(\"Document fork not found\", 404); }\n      // The special NEW_DOCUMENT_CODE trunk means we should create an empty document.\n      if (options.trunkId === NEW_DOCUMENT_CODE) { return false; }\n      if (!await this._ext.exists(options.trunkId)) { throw new ApiError(\"Cannot find original\", 404); }\n      sourceDocId = options.trunkId;\n    }\n    await this._ext.downloadTo(sourceDocId, destId, this.getPath(destId), options.snapshotId);\n    return true;\n  }\n\n  /**\n   * Get a checksum for the given file (absolute path).\n   */\n  private _getHash(srcPath: string): Promise<string> {\n    return checksumFile(srcPath, \"md5\");\n  }\n\n  /**\n   * We'll save hashes in a file with the suffix -hash.\n   */\n  private _getHashFile(docPath: string, family: string = \"doc\"): string {\n    return docPath + `-hash-${family}`;\n  }\n\n  /**\n   * Makes a copy of a document to a file with the suffix -backup.  The copy is\n   * made using Sqlite's backup API.  The backup is made incrementally so the db\n   * is never locked for long by the backup.  The backup process will survive\n   * transient locks on the db.\n   */\n  private async _prepareBackup(docId: string, options: {\n    postfix?: string,\n    output?: string,\n  } = {}): Promise<string> {\n    return backupUsingBestConnection(this, docId, {\n      ...options,\n      log: err => this._log.debug(docId, `Problem making backup, will retry once because db closed: ${err}`),\n    });\n  }\n\n  /**\n   * Send a document to S3.\n   */\n  private async _pushToS3(docId: string): Promise<void> {\n    let tmpPath: string | null = null;\n\n    const snapshotProgress = this.getSnapshotProgress(docId);\n    try {\n      if (this._prepareFiles.has(docId)) {\n        throw new Error(\"too soon to consider pushing\");\n      }\n      tmpPath = await this._prepareBackup(docId);\n      const docMetadata = await this._getDocMetadata(tmpPath);\n      const label = this._labels.get(docId);\n      const t = this._timestamps.get(docId) || new Date().toISOString();\n      this._labels.delete(docId);\n      // Keep metadata keys simple, short, and lowercase.\n      const metadata = {\n        ...docMetadata,\n        ...label && { label },\n        t,\n      };\n      let changeMade: boolean = false;\n      await this._inventory.uploadAndAdd(docId, async () => {\n        const prevSnapshotId = this._latestVersions.get(docId) || null;\n        const newSnapshotId = await this._ext.upload(docId, tmpPath!, metadata);\n        snapshotProgress.lastWindowDoneAt = Date.now();\n        snapshotProgress.windowsDone++;\n        if (newSnapshotId === Unchanged) {\n          // Nothing uploaded because nothing changed\n          snapshotProgress.skippedPushes++;\n          return { prevSnapshotId };\n        }\n        if (!newSnapshotId) {\n          // This is unexpected.\n          throw new Error(\"No snapshotId allocated after upload\");\n        }\n        snapshotProgress.pushes++;\n        const snapshot = {\n          lastModified: t,\n          snapshotId: newSnapshotId,\n          metadata,\n        };\n        changeMade = true;\n        return { snapshot, prevSnapshotId };\n      });\n      if (changeMade) {\n        await this._onInventoryChange(docId);\n      }\n    } catch (e) {\n      snapshotProgress.errors++;\n      // Snapshot window completion time deliberately not set.\n      throw e;\n    } finally {\n      // Clean up backup.\n      // NOTE: fse.remove succeeds also when the file does not exist.\n      if (tmpPath) { await fse.remove(tmpPath); }\n    }\n  }\n\n  // Make sure inventory change is followed up on.\n  private async _onInventoryChange(docId: string) {\n    const scheduled = this._pruner.requestPrune(docId);\n    if (!scheduled) {\n      await this._inventory.flush(docId);\n    }\n  }\n\n  // Extract actionHash, actionNum, and timezone from a document backup.\n  private async _getDocMetadata(fname: string): Promise<{ [key: string]: string }> {\n    const result: Record<string, string> = {};\n    const db = await SQLiteDB.openDBRaw(fname, OpenMode.OPEN_READONLY);\n    try {\n      const actionQuery = await db.get(\"select actionHash, actionNum from _gristsys_ActionHistoryBranch as b \" +\n        \"left join _gristsys_ActionHistory as h on h.id = b.actionRef \" +\n        \"where b.name = ?\", \"shared\");\n      const h = actionQuery?.actionHash;\n      if (h) { result.h = h; }\n      const n = actionQuery?.actionNum;\n      if (n) { result.n = String(n); }\n    } catch (e) {\n      // Tolerate files that don't have _gristsys_* yet (although we don't need to).\n    }\n    try {\n      const tzQuery = await db.get(\"select timezone from _grist_DocInfo where id = 1\");\n      const tz = tzQuery?.timezone;\n      if (tz) { result.tz = tz; }\n    } catch (e) {\n      // Tolerate files that don't have _grist_DocInfo yet.\n    }\n    await db.close();\n    return result;\n  }\n\n  // Wrap external storage in a checksum-aware decorator this will retry until\n  // consistency.\n  private _getChecksummedExternalStorage(family: string, core: ExternalStorage,\n    versions: Map<string, string>,\n    options: HostedStorageOptions) {\n    return new ChecksummedExternalStorage(family, core, {\n      maxRetries: 4,\n      initialDelayMs: options.secondsBeforeFirstRetry * 1000,\n      computeFileHash: this._getHash.bind(this),\n      sharedHash: {\n        save: async (key, checksum) => {\n          await this._docWorkerMap.updateChecksum(family, key, checksum);\n        },\n        load: async (key) => {\n          return await this._docWorkerMap.getChecksum(family, key);\n        },\n      },\n      localHash: {\n        save: async (key, checksum) => {\n          const fname = this._getHashFile(this.getPath(key), family);\n          await fse.writeFile(fname, checksum);\n        },\n        load: async (key) => {\n          const fname = this._getHashFile(this.getPath(key), family);\n          if (!await fse.pathExists(fname)) { return null; }\n          return await fse.readFile(fname, \"utf8\");\n        },\n      },\n      latestVersion: {\n        save: async (key, ver) => {\n          versions.set(key, ver);\n        },\n        load: async key => versions.get(key) || null,\n      },\n    });\n  }\n}\n"
  },
  {
    "path": "app/server/lib/IAssistant.ts",
    "content": "import { ApplyUAResult } from \"app/common/ActiveDocAPI\";\nimport {\n  AssistanceContextV1,\n  AssistanceMessage,\n  AssistanceRequestV1,\n  AssistanceRequestV2,\n  AssistanceResponseV1,\n  AssistanceResponseV2,\n  AssistanceState,\n} from \"app/common/Assistance\";\nimport { AssistantProvider } from \"app/common/Assistant\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { OptDocSession } from \"app/server/lib/DocSession\";\n\nimport * as express from \"express\";\n\n/**\n * An assistant can help a user do things with their document by interfacing\n * with an external LLM endpoint.\n */\nexport type IAssistant = AssistantV1 | AssistantV2;\n\nexport interface AssistantV1 {\n  readonly provider: AssistantProvider;\n  readonly version: 1;\n  /**\n   * Service a request for assistance.\n   */\n  getAssistance(\n    session: OptDocSession,\n    doc: AssistanceDoc,\n    request: AssistanceRequestV1\n  ): Promise<AssistanceResponseV1>;\n}\n\nexport interface AssistantV2 {\n  readonly provider: AssistantProvider;\n  readonly version: 2;\n  /**\n   * Service a request for assistance.\n   */\n  getAssistance(\n    session: OptDocSession,\n    doc: AssistanceDoc,\n    request: AssistanceRequestV2\n  ): Promise<AssistanceResponseV2>;\n  addEndpoints?(app: express.Express): void;\n  onFirstVisit?(req: express.Request, res: Express.Response): Promise<void>;\n}\n\nexport interface AssistantV1Options {\n  apiKey?: string;\n  completionEndpoint?: string;\n  model?: string;\n  longerContextModel?: string;\n  maxTokens?: number;\n}\n\nexport interface AssistantV2Options extends AssistantV1Options {\n  maxToolCalls?: number;\n  structuredOutput?: boolean;\n}\n\nexport function isAssistantV2(assistant: IAssistant): assistant is AssistantV2 {\n  return assistant.version === 2;\n}\n\n/**\n * Document-related methods for use in the implementation of assistants.\n * Somewhat ad-hoc currently.\n */\nexport interface AssistanceDoc extends ActiveDoc {\n  /**\n   * Generate a particular prompt coded in the data engine for some reason.\n   * It makes python code for some tables, and starts a function body with\n   * the given docstring.\n   * Marked \"V1\" to suggest that it is a particular prompt and it would\n   * be great to try variants.\n   */\n  assistanceSchemaPromptV1(\n    session: OptDocSession,\n    options: AssistanceSchemaPromptV1Context\n  ): Promise<string>;\n  /**\n   * Some tweaks to a formula after it has been generated.\n   *\n   * Only used by version 1 of the AI assistant.\n   */\n  assistanceFormulaTweak(txt: string): Promise<string>;\n  /**\n   * Compute the existing formula and return the result along with recorded values\n   * of (possibly nested) attributes of `rec`.\n   * Used by AI assistance to fix an incorrect formula.\n   *\n   * Only used by version 1 of the AI assistant.\n   */\n  assistanceEvaluateFormula(\n    options: AssistanceContextV1\n  ): Promise<AssistanceFormulaEvaluationResult>;\n}\n\nexport type AssistanceSchemaPromptGenerator = (\n  options?: AssistanceSchemaPromptV1Options,\n) => Promise<AssistanceMessage>;\n\nexport interface AssistanceSchemaPromptV1Options {\n  includeAllTables?: boolean;\n  includeLookups?: boolean;\n}\n\nexport interface AssistanceSchemaPromptV1Context\n  extends AssistanceSchemaPromptV1Options {\n  tableId: string;\n  colId: string;\n}\n\nexport interface AssistanceFormulaEvaluationResult {\n  /**\n   * True if an exception was raised.\n   */\n  error: boolean;\n  /**\n   * Representation of the return value or exception message.\n   */\n  result: string;\n  /**\n   * Recorded attributes of `rec` at the time of evaluation.\n   * Keys may be e.g. \"rec.foo.bar\" for nested attributes.\n   */\n  attributes: Record<string, string>;\n  /**\n   * The code that was evaluated, without special Grist syntax.\n   */\n  formula: string;\n}\n\nexport interface OpenAIChatCompletion {\n  choice: {\n    message: {\n      content: string | null;\n      refusal?: string;\n      tool_calls?: OpenAIToolCall[];\n    };\n    finish_reason: string;\n  };\n  state: AssistanceState;\n}\n\ninterface OpenAIToolCall {\n  id: string;\n  type: \"function\";\n  function: {\n    name: string;\n    arguments: string;\n  };\n}\n\nexport type OpenAITool = OpenAIFunction;\n\ninterface OpenAIFunction {\n  type: \"function\";\n  function: {\n    /**\n     * The function's name (e.g. `get_weather`).\n     */\n    name: string;\n    /**\n     * Details on when and how to use the function.\n     */\n    description?: string;\n    /**\n     * JSON schema defining the function's input arguments.\n     */\n    parameters?: JSONSchema;\n    /**\n     * Whether to enforce strict mode for the function call.\n     *\n     * https://platform.openai.com/docs/guides/function-calling?api-mode=responses#strict-mode.\n     */\n    strict?: boolean;\n  };\n}\n\n/**\n * Subset of JSON Schema supported by OpenAI for Structured Outputs.\n *\n * https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas\n */\ninterface JSONSchema {\n  /**\n   * The property types(s) (e.g. `\"string\"`, `\"null\"`, `\"array\"`).\n   */\n  type?: string | string[];\n  /**\n   * Description of the property.\n   */\n  description?: string;\n  /**\n   * Minimum value for numeric properties.\n   */\n  minimum?: number;\n  /**\n   * Maximum value for numeric properties.\n   */\n  maximum?: number;\n  /**\n   * Allowed values (e.g. `[\"error\", \"warning\"]`).\n   */\n  enum?: any[];\n  /**\n   * Schema for array items.\n   *\n   * Required if `type` is `\"array\"`.\n   */\n  items?: JSONSchema;\n  /**\n   * Schema for sub-properties.\n   */\n  properties?: Record<string, JSONSchema>;\n  /**\n   * Names of required sub-properties.\n   *\n   * Required if `type` is `\"object\"`.\n   */\n  required?: string[];\n  /**\n   * Whether to allow properties not listed in `properties`.\n   */\n  additionalProperties?: boolean;\n}\n\ninterface BaseFunctionCallResult {\n  appliedActions: ApplyUAResult[];\n}\n\nexport interface FunctionCallSuccess extends BaseFunctionCallResult {\n  ok: true;\n  result: any;\n}\n\ninterface FunctionCallFailure extends BaseFunctionCallResult {\n  ok: false;\n  error: string;\n}\n\nexport type FunctionCallResult = FunctionCallSuccess | FunctionCallFailure;\n"
  },
  {
    "path": "app/server/lib/IAuditLogger.ts",
    "content": "import {\n  AuditEventAction,\n  AuditEventActor,\n  AuditEventContext,\n  AuditEventDetails,\n} from \"app/server/lib/AuditEvent\";\nimport { RequestOrSession } from \"app/server/lib/sessionUtils\";\n\nexport interface IAuditLogger {\n  /**\n   * Logs an audit event.\n   */\n  logEvent<Action extends AuditEventAction>(\n    requestOrSession: RequestOrSession,\n    properties: AuditEventProperties<Action>\n  ): void;\n  /**\n   * Logs an audit event or throws an error on failure.\n   */\n  logEventOrThrow<Action extends AuditEventAction>(\n    requestOrSession: RequestOrSession,\n    properties: AuditEventProperties<Action>\n  ): Promise<void>;\n  /**\n   * Close any resources used by the logger.\n   */\n  close(): Promise<void>;\n}\n\nexport interface AuditEventProperties<\n  Action extends AuditEventAction = AuditEventAction,\n> {\n  /**\n   * The action that was performed.\n   */\n  action: Action;\n  /**\n   * Who performed the `action` in the event.\n   */\n  actor?: AuditEventActor;\n  /**\n   * Where the event originated from.\n   */\n  context?: Pick<AuditEventContext, \"site\">;\n  /**\n   * Additional details about the event.\n   */\n  details?: AuditEventDetails[Action];\n}\n\nexport function createNullAuditLogger(): IAuditLogger {\n  return {\n    logEvent() { /* do nothing */ },\n    logEventOrThrow() { return Promise.resolve(); },\n    close() { return Promise.resolve(); },\n  };\n}\n"
  },
  {
    "path": "app/server/lib/IBilling.ts",
    "content": "import * as express from \"express\";\n\nexport interface IBilling {\n  addEndpoints(app: express.Express): void;\n  addEventHandlers(): void;\n  addWebhooks(app: express.Express): void;\n  addMiddleware?(app: express.Express): void;\n  addPages(app: express.Express, middleware: express.RequestHandler[]): void;\n  close?(): Promise<void>;\n}\n\nexport interface ActivationStatus {\n  inGoodStanding: boolean;\n  isInTrial: boolean;\n  expirationDate: string | null;\n}\n\nexport class ComposedBilling implements IBilling {\n  private _billings: IBilling[];\n  constructor(billings: (IBilling | null)[] = []) {\n    this._billings = billings.filter(b => !!b) as IBilling[];\n  }\n\n  public async close(): Promise<void> {\n    for (const billing of this._billings) {\n      await billing.close?.();\n    }\n  }\n\n  public addEndpoints(app: express.Express): void {\n    for (const billing of this._billings) {\n      billing.addEndpoints(app);\n    }\n  }\n\n  public addEventHandlers(): void {\n    for (const billing of this._billings) {\n      billing.addEventHandlers();\n    }\n  }\n\n  public addWebhooks(app: express.Express): void {\n    for (const billing of this._billings) {\n      billing.addWebhooks(app);\n    }\n  }\n\n  public addMiddleware(app: express.Express): void {\n    for (const billing of this._billings) {\n      billing.addMiddleware?.(app);\n    }\n  }\n\n  public addPages(app: express.Express, middleware: express.RequestHandler[]): void {\n    for (const billing of this._billings) {\n      billing.addPages(app, middleware);\n    }\n  }\n}\n\nexport class EmptyBilling extends ComposedBilling {\n  constructor() {\n    super([]);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/IChecksumStore.ts",
    "content": "/**\n * Interface for storing checksums.  Family is a short string, to allow storing\n * checksums for different namespaces.\n */\nexport interface IChecksumStore {\n  updateChecksum(family: string, key: string, checksum: string): Promise<void>;\n  getChecksum(family: string, key: string): Promise<string | null>;\n}\n"
  },
  {
    "path": "app/server/lib/ICreate.ts",
    "content": "import { GristDeploymentType } from \"app/common/gristUrls\";\nimport { getThemeBackgroundSnippet } from \"app/common/Themes\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport {\n  AttachmentStoreCreationError,\n  ExternalStorageAttachmentStore, storageSupportsAttachments,\n} from \"app/server/lib/AttachmentStore\";\nimport { IAttachmentStore } from \"app/server/lib/AttachmentStore\";\nimport { getCoreLoginSystem } from \"app/server/lib/coreLogins\";\nimport { DocStorageManager } from \"app/server/lib/DocStorageManager\";\nimport { ExternalStorage, ExternalStorageCreator, UnsupportedPurposeError } from \"app/server/lib/ExternalStorage\";\nimport { createDummyTelemetry, GristLoginSystem, GristServer } from \"app/server/lib/GristServer\";\nimport { HostedStorageManager } from \"app/server/lib/HostedStorageManager\";\nimport { IAssistant } from \"app/server/lib/IAssistant\";\nimport { createNullAuditLogger, IAuditLogger } from \"app/server/lib/IAuditLogger\";\nimport { EmptyBilling, IBilling } from \"app/server/lib/IBilling\";\nimport { IDocNotificationManager } from \"app/server/lib/IDocNotificationManager\";\nimport { IDocStorageManager } from \"app/server/lib/IDocStorageManager\";\nimport { INotifier } from \"app/server/lib/INotifier\";\nimport { InstallAdmin, SimpleInstallAdmin } from \"app/server/lib/InstallAdmin\";\nimport { ISandbox, ISandboxCreationOptions } from \"app/server/lib/ISandbox\";\nimport { createSandbox, SpawnFn } from \"app/server/lib/NSandbox\";\nimport * as ProcessMonitor from \"app/server/lib/ProcessMonitor\";\nimport { SqliteVariant } from \"app/server/lib/SqliteCommon\";\nimport { ITelemetry } from \"app/server/lib/Telemetry\";\n\nimport { Express } from \"express\";\n\n// In the past, the session secret was used as an additional\n// protection passed on to expressjs-session for security when\n// generating session IDs, in order to make them less guessable.\n// Quoting the upstream documentation,\n//\n//     Using a secret that cannot be guessed will reduce the ability\n//     to hijack a session to only guessing the session ID (as\n//     determined by the genid option).\n//\n//   https://expressjs.com/en/resources/middleware/session.html\n//\n// However, since this change,\n//\n//   https://github.com/gristlabs/grist-core/commit/24ce54b586e20a260376a9e3d5b6774e3fa2b8b8#diff-d34f5357f09d96e1c2ba63495da16aad7bc4c01e7925ab1e96946eacd1edb094R121-R124\n//\n// session IDs are now completely randomly generated in a cryptographically\n// secure way, so there is no danger of session IDs being guessable.\n// This makes the value of the session secret less important. The only\n// concern is that changing the secret will invalidate existing\n// sessions and force users to log in again.\nexport const DEFAULT_SESSION_SECRET =\n  \"Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh\";\n\nexport interface ICreate {\n  // Create a space to store files externally, for storing either:\n  //  - documents. This store should be versioned, and can be eventually consistent.\n  //  - meta. This store need not be versioned, and can be eventually consistent.\n  // For test purposes an extra prefix may be supplied.  Stores with different prefixes\n  // should not interfere with each other.\n  ExternalStorage: ExternalStorageCreator;\n\n  // Creates a IDocStorageManager for storing documents on the local machine.\n  createLocalDocStorageManager(\n    ...args: ConstructorParameters<typeof DocStorageManager>\n  ): Promise<IDocStorageManager>;\n\n  // Creates a IDocStorageManager for storing documents on an external storage (e.g S3)\n  createHostedDocStorageManager(\n    ...args: ConstructorParameters<typeof HostedStorageManager>\n  ): Promise<IDocStorageManager>;\n\n  Billing(dbManager: HomeDBManager, gristConfig: GristServer): IBilling;\n  Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier | undefined;\n  AuditLogger(dbManager: HomeDBManager, gristConfig: GristServer): IAuditLogger;\n  Telemetry(dbManager: HomeDBManager, gristConfig: GristServer): ITelemetry;\n  Assistant(gristConfig: GristServer): IAssistant | undefined;\n\n  NSandbox(options: ISandboxCreationOptions): ISandbox;\n\n  // Create the logic to determine which users are authorized to manage this Grist installation.\n  createInstallAdmin(dbManager: HomeDBManager): Promise<InstallAdmin>;\n\n  deploymentType(): GristDeploymentType;\n  sessionSecret(): string;\n  // Check configuration of the app early enough to show on startup.\n  configure?(): Promise<void>;\n  // Optionally perform sanity checks on the configured storage, throwing a fatal error if it is not functional\n  checkBackend?(): Promise<void>;\n  // Return a string containing 1 or more HTML tags to insert into the head element of every\n  // static page.\n  getExtraHeadHtml?(): string;\n  getStorageOptions?(name: string): ICreateStorageOptions | undefined;\n  getAttachmentStoreOptions(): { [key: string]: ICreateAttachmentStoreOptions | undefined };\n  getSqliteVariant?(): SqliteVariant;\n  getSandboxVariants?(): Record<string, SpawnFn>;\n\n  getLoginSystem(): Promise<GristLoginSystem>;\n\n  addExtraHomeEndpoints(gristServer: GristServer, app: Express): void;\n  areAdminControlsAvailable(): boolean;\n  createDocNotificationManager(gristServer: GristServer): IDocNotificationManager | undefined;\n  startProcessMonitor(telemetry: ITelemetry): StopCallback | undefined;\n}\n\ntype StopCallback = () => void;\n\nexport interface ICreateStorageOptions {\n  name: string;\n  check(): boolean;\n  checkBackend?(): Promise<void>;\n  create(purpose: \"doc\" | \"meta\" | \"attachments\", extraPrefix: string): ExternalStorage | undefined;\n}\n\nexport interface ICreateAttachmentStoreOptions {\n  name: string;\n  isAvailable(): Promise<boolean>;\n  create(storeId: string): Promise<IAttachmentStore>;\n}\n\n/**\n * This class provides a `create` object that defines various core\n * aspects of a Grist installation, such as what kind of billing or\n * sandbox to use, if any.\n *\n * The intended use of this class is to initialise Grist with\n * different settings and providers, to facilitate different editions\n * such as standard, enterprise or cloud-hosted.\n */\nexport class BaseCreate implements ICreate {\n  constructor(\n    private readonly _deploymentType: GristDeploymentType,\n    private _storage: ICreateStorageOptions[] = [],\n  ) {}\n\n  public deploymentType(): GristDeploymentType { return this._deploymentType; }\n  public Billing(dbManager: HomeDBManager, gristConfig: GristServer): IBilling {\n    return new EmptyBilling();\n  }\n\n  public Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier | undefined {\n    return undefined;\n  }\n\n  public ExternalStorage(...[purpose, extraPrefix]: Parameters<ExternalStorageCreator>): ExternalStorage | undefined {\n    for (const s of this._storage) {\n      if (s.check()) {\n        return s.create(purpose, extraPrefix);\n      }\n    }\n    return undefined;\n  }\n\n  public AuditLogger(dbManager: HomeDBManager, gristConfig: GristServer) {\n    return createNullAuditLogger();\n  }\n\n  public Telemetry(dbManager: HomeDBManager, gristConfig: GristServer): ITelemetry {\n    return createDummyTelemetry();\n  }\n\n  public Assistant(gristConfig: GristServer): IAssistant | undefined {\n    return undefined;\n  }\n\n  public NSandbox(options: ISandboxCreationOptions): ISandbox {\n    return createSandbox(\"unsandboxed\", options);\n  }\n\n  public sessionSecret(): string {\n    return process.env.GRIST_SESSION_SECRET || DEFAULT_SESSION_SECRET;\n  }\n\n  public async configure() {\n    for (const s of this._storage) {\n      if (s.check()) {\n        break;\n      }\n    }\n  }\n\n  public async checkBackend() {\n    for (const s of this._storage) {\n      if (s.check()) {\n        await s.checkBackend?.();\n        break;\n      }\n    }\n  }\n\n  public getExtraHeadHtml() {\n    const elements: string[] = [];\n    if (process.env.APP_STATIC_INCLUDE_CUSTOM_CSS === \"true\") {\n      elements.push('<link id=\"grist-custom-css\" rel=\"stylesheet\" href=\"custom.css\" crossorigin=\"anonymous\">');\n    }\n    elements.push(getThemeBackgroundSnippet());\n    return elements.join(\"\\n\");\n  }\n\n  public getStorageOptions(name: string) {\n    return this._storage.find(s => s.name === name);\n  }\n\n  public getAttachmentStoreOptions() {\n    return {\n      // 'snapshots' provider uses the ExternalStorage provider set up for doc snapshots for attachments\n      snapshots: {\n        name: \"snapshots\",\n        isAvailable: async () => {\n          try {\n            const storage = this.ExternalStorage(\"attachments\", \"\");\n            return storage ? storageSupportsAttachments(storage) : false;\n          } catch (e) {\n            if (e instanceof UnsupportedPurposeError) {\n              return false;\n            }\n            throw e;\n          }\n        },\n        create: async (storeId: string) => {\n          const storage = this.ExternalStorage(\"attachments\", \"\");\n          // This *should* always pass due to the `isAvailable` check above being run earlier.\n          if (!(storage && storageSupportsAttachments(storage))) {\n            throw new AttachmentStoreCreationError(\"snapshots\", storeId,\n              \"External storage does not support attachments\");\n          }\n          return new ExternalStorageAttachmentStore(\n            storeId,\n            storage,\n          );\n        },\n      },\n    };\n  }\n\n  public async createInstallAdmin(dbManager: HomeDBManager): Promise<InstallAdmin> {\n    return new SimpleInstallAdmin(dbManager);\n  }\n\n  public async getLoginSystem(): Promise<GristLoginSystem> {\n    return getCoreLoginSystem();\n  }\n\n  public async createLocalDocStorageManager(...args: ConstructorParameters<typeof DocStorageManager>) {\n    return new DocStorageManager(...args);\n  }\n\n  public async createHostedDocStorageManager(...args: ConstructorParameters<typeof HostedStorageManager>) {\n    return new HostedStorageManager(...args);\n  }\n\n  public addExtraHomeEndpoints(gristServer: GristServer, app: Express) {}\n  public areAdminControlsAvailable(): boolean { return false; }\n  public createDocNotificationManager(gristServer: GristServer): IDocNotificationManager | undefined {\n    return undefined;\n  }\n\n  public startProcessMonitor(telemetry: ITelemetry) {\n    return ProcessMonitor.start(telemetry);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/IDocNotificationManager.ts",
    "content": "import { EmailActionPayload } from \"app/server/lib/WebhookQueue\";\n\nimport type { Proposal } from \"app/common/UserAPI\";\nimport type { OptDocSession } from \"app/server/lib/DocSession\";\nimport type { GranularAccessForBundle } from \"app/server/lib/GranularAccess\";\nimport type { Express } from \"express\";\n\nexport interface IDocNotificationManager {\n  /**\n   * Initialize the home-server side of of notifications handling: endpoints for configuration,\n   * and handling of queued jobs to deliver emails.\n   */\n  initHomeServer(app: Express): void;\n\n  /**\n   * Prepare a notification for a particular change (included into the accessControl argument).\n   */\n  notifySubscribers(docSession: OptDocSession, docId: string, accessControl: GranularAccessForBundle): Promise<void>;\n\n  /**\n   * Prepare a notification for a particular suggestion.\n   */\n  notifySubscribersOfSuggestion(docId: string, proposal: Proposal): Promise<void>;\n  /**\n   * Process row-level notifications (emails) for a document.\n   */\n  rowNotification(docId: string, actions: EmailActionPayload[]): Promise<void>;\n}\n"
  },
  {
    "path": "app/server/lib/IDocStorageManager.ts",
    "content": "import { DocEntry } from \"app/common/DocListAPI\";\nimport { DocSnapshots } from \"app/common/DocSnapshot\";\nimport { DocumentUsage } from \"app/common/DocUsage\";\nimport { DocReplacementOptions } from \"app/common/UserAPI\";\nimport { SQLiteDB } from \"app/server/lib/SQLiteDB\";\n\nexport interface IDocStorageManager {\n  getPath(docName: string): string;\n  getSQLiteDB(docName: string): SQLiteDB | undefined;\n  getSampleDocPath(sampleDocName: string): string | null;\n  getCanonicalDocName(altDocName: string): Promise<string>;\n\n  // This method must not be called for the same docName twice in parallel.\n  // In the current implementation, it is called in the context of an\n  // AsyncCreate[docName].\n  prepareLocalDoc(docName: string): Promise<boolean>;\n  prepareToCreateDoc(docName: string): Promise<void>;\n  prepareFork(srcDocName: string, destDocName: string): Promise<string>;  // Returns filename.\n\n  listDocs(): Promise<DocEntry[]>;\n  deleteDoc(docName: string, deletePermanently?: boolean): Promise<void>;\n  renameDoc(oldName: string, newName: string): Promise<void>;\n  makeBackup(docName: string, backupTag: string): Promise<string>;\n  showItemInFolder(docName: string): Promise<void>;\n  closeStorage(): Promise<void>;\n  closeDocument(docName: string): Promise<void>;\n  // Mark document as needing a backup (due to edits, migrations, etc).\n  // If reason is set to 'edit' the user-facing timestamp on the document should be updated.\n  markAsChanged(docName: string, reason?: \"edit\"): void;\n  scheduleUsageUpdate(docName: string, usage: DocumentUsage | null, minimizeDelay?: boolean): void;\n  testReopenStorage(): void;                // restart storage during tests\n  addToStorage(docName: string): Promise<void>;  // add a new local document to storage\n  prepareToCloseStorage(): void;            // speed up sync with remote store\n  getCopy(docName: string): Promise<string>;  // get an immutable copy of a document\n\n  flushDoc(docName: string): Promise<void>; // flush a document to persistent storage\n  // If skipMetadataCache is set, then any caching of snapshots lists should be skipped.\n  // Metadata may not be returned in this case.\n  getSnapshots(docName: string, skipMetadataCache?: boolean): Promise<DocSnapshots>;\n  removeSnapshots(docName: string, snapshotIds: string[]): Promise<void>;\n  // Get information about how snapshot generation is going.\n  getSnapshotProgress(docName: string): SnapshotProgress;\n  replace(docName: string, options: DocReplacementOptions): Promise<void>;\n  getFsFileSize(docName: string): Promise<number>;\n}\n\n/**\n * A very minimal implementation of IDocStorageManager that is just\n * enough to allow an ActiveDoc to open and get to work.\n */\nexport class TrivialDocStorageManager implements IDocStorageManager {\n  public getPath(docName: string): string { return docName; }\n  public getSQLiteDB() { return undefined; }\n  public getSampleDocPath() { return null; }\n  public async getCanonicalDocName(altDocName: string) { return altDocName; }\n  public async prepareLocalDoc() { return false; }\n  public async prepareToCreateDoc() { }\n  public async prepareFork(): Promise<never> { throw new Error(\"no\"); }\n  public async listDocs() { return []; }\n  public async deleteDoc(): Promise<never> { throw new Error(\"no\"); }\n  public async renameDoc(): Promise<never> { throw new Error(\"no\"); }\n  public async makeBackup(): Promise<never> { throw new Error(\"no\"); }\n  public async showItemInFolder(): Promise<never> { throw new Error(\"no\"); }\n  public async closeStorage() {}\n  public async closeDocument() {}\n  public markAsChanged() {}\n  public scheduleUsageUpdate() {}\n  public testReopenStorage() {}\n  public async addToStorage(): Promise<never> { throw new Error(\"no\"); }\n  public prepareToCloseStorage() {}\n  public async getCopy(): Promise<never> { throw new Error(\"no\"); }\n  public async flushDoc() {}\n  public async getSnapshots(): Promise<never> { throw new Error(\"no\"); }\n  public async removeSnapshots(): Promise<never> { throw new Error(\"no\"); }\n  public getSnapshotProgress(): SnapshotProgress { return new EmptySnapshotProgress(); }\n  public async replace(): Promise<never> { throw new Error(\"no\"); }\n  public async getFsFileSize(): Promise<number> { throw new Error(\"no\"); }\n}\n\n/**\n * Some summary information about how snapshot generation is going.\n * Any times are in ms.\n * All information is within the lifetime of a doc worker, not global.\n */\nexport interface SnapshotProgress {\n  /** The last time the document was marked as having changed. */\n  lastChangeAt?: number;\n\n  /**\n   * The last time a save window started for the document (checking to see\n   * if it needs to be pushed, and pushing it if so, possibly waiting\n   * quite some time to bundle any other changes).\n   */\n  lastWindowStartedAt?: number;\n\n  /**\n   * The last time the document was either pushed or determined to not\n   * actually need to be pushed, after having been marked as changed.\n   */\n  lastWindowDoneAt?: number;\n\n  /** Number of times the document was pushed. */\n  pushes: number;\n\n  /** Number of times the document was not pushed because no change found. */\n  skippedPushes: number;\n\n  /** Number of times there was an error trying to push. */\n  errors: number;\n\n  /**\n   * Number of times the document was marked as changed.\n   * Will generally be a lot greater than saves.\n   */\n  changes: number;\n\n  /** Number of times a save window was started. */\n  windowsStarted: number;\n\n  /** Number of times a save window was completed. */\n  windowsDone: number;\n}\n\nexport class EmptySnapshotProgress implements SnapshotProgress {\n  public pushes: number = 0;\n  public skippedPushes: number = 0;\n  public errors: number = 0;\n  public changes: number = 0;\n  public windowsStarted: number = 0;\n  public windowsDone: number = 0;\n}\n"
  },
  {
    "path": "app/server/lib/IElectionStore.ts",
    "content": "/**\n * Get a revokable named exclusive lock with a TTL.  This is convenient for housekeeping\n * tasks, which can be done by any server, but should preferably be only done by one\n * at a time.\n */\nexport interface IElectionStore {\n  /**\n   * Try to get a lock called <name> for a specified duration.  If the named lock\n   * has already been taken, null is returned, otherwise a secret is returned.\n   * The secret can be used to remove the lock before the duration has expired.\n   */\n  getElection(name: string, durationInMs: number): Promise<string | null>;\n\n  /**\n   * Remove a named lock, presenting the secret returned by getElection() as\n   * a cross-check.\n   */\n  removeElection(name: string, electionKey: string): Promise<void>;\n\n  /**\n   * Close down access to the store.\n   */\n  close(): Promise<void>;\n}\n"
  },
  {
    "path": "app/server/lib/INotifier.ts",
    "content": "/**\n * INotifier defines the interface for events that should result in notifications to users\n * via transactional emails (or future generalizations).\n *\n * Although this interface is async, it is best if the implementation\n * remains very fast and reliable. Any delays here will impact API\n * calls. Calls to place something in Redis may be acceptable.\n * Retrying email notifications, no.\n *\n * In practice, the notifier that does the delivery of notifications is wrapped into an\n * EmitNotifier, with fire-and-forget semantics. Its methods return without waiting for the async\n * calls.\n *\n * EmitNotifier also distributes events to other internal subscribers, e.g. via\n * FlexServer.onUserChange and FlexServer.onStreamingDestinationsChange.\n *\n * Some notifier activity ought to be replaced with\n * a job queue and queue workers. In particular, processing of\n * FlexServer.onUserChange could do with moving to a queue since\n * it may require communication with external billing software and\n * should be robust to delays and failures there. More generally,\n * if notications are subject to delays and failures and we wish to\n * be robust, a job queue would be a good idea for all of this.\n */\n\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport * as roles from \"app/common/roles\";\nimport { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { UserChange, UserIdDelta } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { SendGridConfig, SendGridMailWithTemplateId, TwoFactorEvent } from \"app/gen-server/lib/NotifierTypes\";\nimport { DocNotificationEvent, DocNotificationTemplateBase } from \"app/gen-server/lib/NotifierTypes\";\nimport log from \"app/server/lib/log\";\n\nimport { EventEmitter } from \"events\";\n\ninterface INotifierMethods {\n  /**\n   * These methods are all called when a thing happens, with\n   * the intent to notify anyone who should know.\n   */\n  addUser(\n    userId: number,\n    resource: Organization | Workspace | Document,\n    delta: UserIdDelta,\n    membersBefore: Map<roles.NonGuestRole, User[]>\n  ): Promise<void>;\n  addBillingManager(hostUserId: number, addUserId: number, orgs: Organization[]): Promise<void>;\n  firstLogin(user: FullUser): Promise<void>;\n  firstOAuthLogin(user: FullUser): Promise<void>;\n  teamCreator(userId: number): Promise<void>;\n  userChange(change: UserChange): Promise<void>;\n  trialPeriodEndingSoon(account: BillingAccount, subscription: { trial_end: number | null }): Promise<void>;\n  trialingSubscription(account: BillingAccount): Promise<void>;\n  scheduledCall(userRef: string): Promise<void>;\n  twoFactorStatusChanged(event: TwoFactorEvent, userId: number, method?: \"TOTP\" | \"SMS\"): Promise<void>;\n\n  /**\n   * A slightly different kind of event that is lurking around.\n   */\n  streamingDestinationsChange(orgId: number | null): Promise<void>;\n\n  /**\n   * Deliver notification of a doc change, comment, or suggestion.\n   * Other code is responsible for preparing the payload; this method only needs to deliver it.\n   */\n  docNotification(\n    event: DocNotificationEvent, userId: number, templateData: DocNotificationTemplateBase\n  ): Promise<void>;\n\n  /**\n   * This is a bit confusing. It isn't a notification, but\n   * a request to purge a user from the notification system,\n   * e.g. to remove from any email lists.\n   */\n  deleteUser(userId: number): Promise<void>;\n}\n\nexport interface INotifier extends INotifierMethods {\n  /**\n   * For old tests, we preserve some weird old methods.\n   * This may need further refactoring or elimination.\n   */\n  testSendGridExtensions?(): TestSendGridExtensions | undefined;\n}\n\nexport interface TestSendGridExtensions {\n  // Get template IDs etc.\n  getConfig(): SendGridConfig;\n\n  // Intercept outgoing messages for test purposes.\n  setSendMessageCallback(op: (body: SendGridMailWithTemplateId,\n    description: string) => Promise<void>): void;\n}\n\n/**\n * EmitNotifier wraps another INotifier, but introduces two differences:\n * - The wrapped INotifier is optional; if not set via setPrimaryNotifier(), it's just not called.\n * - Each call returns immediately; any errors in the underlying async call are caught and logged.\n * - It is an EventEmitter; for every INotifier method it emits an event with the same name and\n *   the same arguments, to allow other code to subscribe.\n */\nexport class EmitNotifier extends EventEmitter implements INotifier {\n  public addUser = this._wrapEvent(\"addUser\");\n  public addBillingManager = this._wrapEvent(\"addBillingManager\");\n  public firstLogin = this._wrapEvent(\"firstLogin\");\n  public firstOAuthLogin = this._wrapEvent(\"firstOAuthLogin\");\n  public teamCreator = this._wrapEvent(\"teamCreator\");\n  public userChange = this._wrapEvent(\"userChange\");\n  public trialPeriodEndingSoon = this._wrapEvent(\"trialPeriodEndingSoon\");\n  public trialingSubscription = this._wrapEvent(\"trialingSubscription\");\n  public scheduledCall = this._wrapEvent(\"scheduledCall\");\n  public streamingDestinationsChange = this._wrapEvent(\"streamingDestinationsChange\");\n  public twoFactorStatusChanged = this._wrapEvent(\"twoFactorStatusChanged\");\n  public docNotification = this._wrapEvent(\"docNotification\");\n  public deleteUser = this._wrapEvent(\"deleteUser\");\n\n  private _primaryNotifier: INotifier | null = null;\n  private _testPendingNotifications = 0;\n\n  public setPrimaryNotifier(notifier: INotifier) { this._primaryNotifier = notifier; }\n\n  public isEmpty() { return !this._primaryNotifier; }\n\n  public testSendGridExtensions() { return this._primaryNotifier?.testSendGridExtensions?.(); }\n  public testPendingNotifications(): number { return this._testPendingNotifications; }\n\n  private _wrapEvent<Name extends keyof INotifierMethods>(methodName: Name): INotifier[Name] {\n    return async (...args: any[]) => {\n      this._callPrimary(methodName, ...args)\n        .catch(e => log.error(\"Notifier failed\", e));\n\n      // Also emit as an event that others could listen to.\n      this.emit(methodName, ...args);\n    };\n  }\n\n  private async _callPrimary(methodName: keyof INotifierMethods, ...args: any[]) {\n    if (!this._primaryNotifier) { return; }\n    this._testPendingNotifications++;\n    try {\n      await (this._primaryNotifier[methodName] as any)(...args);\n    } finally {\n      this._testPendingNotifications--;\n    }\n  }\n}\n"
  },
  {
    "path": "app/server/lib/ISandbox.ts",
    "content": "import log from \"app/server/lib/log\";\nimport { ISandboxOptions } from \"app/server/lib/NSandbox\";\n\n/**\n * Starting to whittle down the options used when creating a sandbox, to leave more\n * freedom in how the sandbox works.\n */\nexport interface ISandboxCreationOptions {\n  comment?: string;      // an argument to add in command line when possible, so it shows in `ps`\n\n  logCalls?: boolean;\n  logMeta?: log.ILogMeta;\n  logTimes?: boolean;\n\n  // This batch of options is used by SafePythonComponent, so are important for importers.\n  entryPoint?: string;   // main script to call - leave undefined for default\n  sandboxMount?: string; // if defined, make this path available read-only as \"/sandbox\"\n  importMount?: string;  // if defined, make this path available read-only as \"/importdir\"\n\n  preferredPythonVersion?: \"3\";\n\n  sandboxOptions?: Partial<ISandboxOptions>;\n}\n\nexport interface ISandbox {\n  shutdown(): Promise<unknown>;  // TODO: tighten up this type.\n  pyCall(funcName: string, ...varArgs: unknown[]): Promise<any>;\n  reportMemoryUsage(): Promise<number>;\n  getFlavor(): string;\n  isProcessDown(): boolean;\n  getLastResponseNumBytes?(): number | undefined;\n}\n\nexport interface ISandboxCreator {\n  create(options: ISandboxCreationOptions): ISandbox;\n}\n"
  },
  {
    "path": "app/server/lib/IShell.ts",
    "content": "export interface IShell {\n  trashItem(docPath: string): Promise<void>;\n  showItemInFolder(docPath: string): void;\n}\n"
  },
  {
    "path": "app/server/lib/ITestingHooks-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const ClientJsonMemoryLimits = t.iface([], {\n  \"totalSize\": t.opt(\"number\"),\n  \"jsonResponseReservation\": t.opt(\"number\"),\n  \"maxReservationSize\": t.opt(t.union(\"number\", \"null\")),\n});\n\nexport const ITestingHooks = t.iface([], {\n  \"getPort\": t.func(\"number\"),\n  \"setLoginSessionProfile\": t.func(\"void\", t.param(\"gristSidCookie\", \"string\"), t.param(\"profile\", t.union(\"UserProfile\", \"null\")), t.param(\"org\", \"string\", true)),\n  \"setServerVersion\": t.func(\"void\", t.param(\"version\", t.union(\"string\", \"null\"))),\n  \"disconnectClients\": t.func(\"void\"),\n  \"commShutdown\": t.func(\"void\"),\n  \"commRestart\": t.func(\"void\"),\n  \"commSetClientPersistence\": t.func(\"number\", t.param(\"ttlMs\", \"number\")),\n  \"commSetClientJsonMemoryLimits\": t.func(\"ClientJsonMemoryLimits\", t.param(\"limits\", \"ClientJsonMemoryLimits\")),\n  \"closeDocs\": t.func(\"void\"),\n  \"setDocWorkerActivation\": t.func(\"void\", t.param(\"workerId\", \"string\"), t.param(\"active\", t.union(t.lit(\"active\"), t.lit(\"inactive\"), t.lit(\"crash\")))),\n  \"flushAuthorizerCache\": t.func(\"void\"),\n  \"flushDocs\": t.func(\"void\"),\n  \"getDocClientCounts\": t.func(t.array(t.tuple(\"string\", \"number\"))),\n  \"setActiveDocTimeout\": t.func(\"number\", t.param(\"seconds\", \"number\")),\n  \"setDiscourseConnectVar\": t.func(t.union(\"string\", \"null\"), t.param(\"varName\", \"string\"), t.param(\"value\", t.union(\"string\", \"null\"))),\n  \"setWidgetRepositoryUrl\": t.func(\"void\", t.param(\"url\", \"string\")),\n  \"getMemoryUsage\": t.func(\"object\"),\n  \"tickleUnhandledErrors\": t.func(\"void\", t.param(\"errType\", \"string\")),\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  ClientJsonMemoryLimits,\n  ITestingHooks,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/server/lib/ITestingHooks.ts",
    "content": "import { UserProfile } from \"app/common/LoginSessionAPI\";\n\nexport interface ClientJsonMemoryLimits {\n  totalSize?: number;\n  jsonResponseReservation?: number;\n  maxReservationSize?: number | null;\n}\n\nexport interface ITestingHooks {\n  getPort(): Promise<number>;\n  setLoginSessionProfile(gristSidCookie: string, profile: UserProfile | null, org?: string): Promise<void>;\n  setServerVersion(version: string | null): Promise<void>;\n  disconnectClients(): Promise<void>;\n  commShutdown(): Promise<void>;\n  commRestart(): Promise<void>;\n  commSetClientPersistence(ttlMs: number): Promise<number>;\n  commSetClientJsonMemoryLimits(limits: ClientJsonMemoryLimits): Promise<ClientJsonMemoryLimits>;\n  closeDocs(): Promise<void>;\n  setDocWorkerActivation(workerId: string, active: \"active\" | \"inactive\" | \"crash\"): Promise<void>;\n  flushAuthorizerCache(): Promise<void>;\n  flushDocs(): Promise<void>;\n  getDocClientCounts(): Promise<[string, number][]>;\n  setActiveDocTimeout(seconds: number): Promise<number>;\n  setDiscourseConnectVar(varName: string, value: string | null): Promise<string | null>;\n  setWidgetRepositoryUrl(url: string): Promise<void>;\n  getMemoryUsage(): Promise<object>;  // actually NodeJS.MemoryUsage\n  tickleUnhandledErrors(errType: string): Promise<void>;\n}\n"
  },
  {
    "path": "app/server/lib/InsightLog.ts",
    "content": "/**\n * Helper to get insight into what's happening in complex calls, e.g. applyUserActions. It helps\n * to collect metadata for a log message, and timestamps of stages, and logs them all at once.\n *\n * To use, decorate a method, then use insightLogEntry() within it.\n *    @insightLogDecorate(\"ClassName\")\n *    public async myMethod(...) {\n *      const insightLog = insightLogEntry();\n *      insightLog?.mark(\"foo\");\n *      insightLog?.addMeta({docId, email, custom});\n *      insightLog?.mark(\"bar\");\n *    }\n *\n * All the collected data will be logged with \"ClassName myMethod done\" message  when the async\n * method resolves (or rejects). In addition, if the method is running for longer than 5000 ms\n * (configurable via the second argument to insightLogDecorate), it will be logged with \"ClassName\n * myMethod running\" message.\n *\n * In case of nested decorated calls, only the entry for the outermost one is filled in and logged.\n */\n\nimport log from \"app/server/lib/log\";\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nconst asyncLocalStorage = new AsyncLocalStorage<InsightLogEntry>();\n\nexport function insightLogDecorate(prefix: string, logIfLongerThanMs: number = 5000) {\n  return function decorate(target: unknown, propertyKey: string, descriptor: PropertyDescriptor) {\n    const origFunc = descriptor.value;\n    descriptor.value = async function(this: unknown) {\n      const callback = () => origFunc.apply(this, arguments);\n      // If already within a decorated call, pass through seamlessly to the decorated function.\n      if (asyncLocalStorage.getStore()) {\n        return callback();\n      }\n      return insightLogWrap(`${prefix} ${propertyKey}`, callback, logIfLongerThanMs);\n    };\n  };\n}\n\nexport async function insightLogWrap<T>(\n  prefix: string, callback: () => Promise<T>, logIfLongerThanMs: number = 5000,\n): Promise<T> {\n  const entry = new InsightLogEntry();\n  const timer = setTimeout(() => log.rawInfo(`${prefix} running`, entry.getMeta()), logIfLongerThanMs);\n  try {\n    return await asyncLocalStorage.run(entry, callback);\n  } finally {\n    clearTimeout(timer);\n    entry.mark(\"end\");\n    log.rawInfo(`${prefix} done`, entry.getMeta());\n  }\n}\n\nexport function insightLogEntry(): InsightLogEntry | undefined {\n  return asyncLocalStorage.getStore();\n}\n\nclass InsightLogEntry {\n  private _start = Date.now();\n  private _meta: log.ILogMeta = {\n    startTs: this._start,\n    start: new Date(this._start).toISOString(),\n  };\n\n  // Add a property \"mark_{label}\" with the ms elapsed since start.\n  public mark(label: string) {\n    this._meta[\"mark_\" + label] = Date.now() - this._start;\n  }\n\n  // Add some more metadata properties to the message to be logged.\n  public addMeta(values: log.ILogMeta) {\n    Object.assign(this._meta, values);\n  }\n\n  public getMeta(): log.ILogMeta {\n    return this._meta;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/InstallAdmin.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { normalizeEmail } from \"app/common/emails\";\nimport { InstallAdminInfo } from \"app/common/LoginSessionAPI\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { HomeDBManager, SUPPORT_EMAIL } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { getUser, RequestWithLogin } from \"app/server/lib/Authorizer\";\n\nimport express from \"express\";\n\n/**\n * Class implementing the logic to determine whether a user is authorized to manage the Grist\n * installation.\n */\nexport abstract class InstallAdmin {\n  // Returns true if user is authorized to manage the Grist installation.\n  public abstract isAdminUser(user: User): Promise<boolean>;\n\n  // Returns an administrator user to use as a last resort, needed\n  // if a boot key is used.\n  public abstract getAdminUser(): Promise<User>;\n\n  // Clear any cached information.\n  public abstract clearCaches(): void;\n\n  // Returns all possible admin users\n  public abstract getAdminUsers(req: express.Request): Promise<InstallAdminInfo[]>;\n\n  // Returns true if req is authenticated (contains a user) and the user is authorized to manage\n  // the Grist installation. This should not fail, only return true or false.\n  public async isAdminReq(req: express.Request): Promise<boolean> {\n    const user = (req as RequestWithLogin).user;\n    return user ? (await this.isAdminUser(user)) : false;\n  }\n\n  // Returns middleware that fails unless the request includes an authenticated user and this user\n  // is authorized to manage the Grist installation.\n  public getMiddlewareRequireAdmin(): express.RequestHandler {\n    return this._requireAdmin.bind(this);\n  }\n\n  private async _requireAdmin(req: express.Request, resp: express.Response, next: express.NextFunction) {\n    try {\n      // getUser() will fail with 401 if user is not present.\n      if (!await this.isAdminUser(getUser(req))) {\n        throw new ApiError(\"Access denied\", 403);\n      }\n      next();\n    } catch (err) {\n      next(err);\n    }\n  }\n}\n\n// Considers the user whose email matches GRIST_ADMIN_EMAIL env var, if set, to be the\n// installation admin.\n// If GRIST_ADMIN_EMAIL is not set, we fall back on GRIST_DEFAULT_EMAIL, and finally\n// GRIST_SUPPORT_EMAIL, which defaults to support@getgrist.com.\nexport class SimpleInstallAdmin extends InstallAdmin {\n  private _installAdminEmail = getAdminEmail();\n  private _defaultEmail = getDefaultEmail();\n\n  public constructor(private _dbManager: HomeDBManager) {\n    super();\n  }\n\n  public override async getAdminUser(): Promise<User> {\n    return this._dbManager.getUserByLoginWithRetry(this._adminEmail);\n  }\n\n  public override async isAdminUser(user: User): Promise<boolean> {\n    if (!user.loginEmail || !this._adminEmail) { return false; }\n    return normalizeEmail(user.loginEmail) === normalizeEmail(this._adminEmail);\n  }\n\n  public override clearCaches(): void {\n  }\n\n  private get _adminEmail(): string {\n    return this._installAdminEmail || this._defaultEmail || SUPPORT_EMAIL;\n  }\n\n  public override async getAdminUsers(req: express.Request): Promise<InstallAdminInfo[]> {\n    if (this._installAdminEmail) {\n      const admin = await this._dbManager.getUserByLogin(this._installAdminEmail);\n      return [{\n        user: admin.toUserProfile(),\n        reason: req.t(\"admin.accountByAdminEmail\", { adminEmail: this._installAdminEmail }),\n      }];\n    } else if (this._defaultEmail) {\n      const admin = await this._dbManager.getUserByLogin(this._defaultEmail);\n      return [{\n        user: admin.toUserProfile(),\n        reason: req.t(\"admin.accountByEmail\", { defaultEmail: this._defaultEmail }),\n      }];\n    } else {\n      return [{\n        user: null,\n        reason: req.t(\"admin.noAdminEmail\"),\n      }];\n    }\n  }\n}\n\n/**\n * Returns the value of `GRIST_ADMIN_EMAIL` from `settings`, falling back\n * to the value of `GRIST_DEFAULT_EMAIL`.\n */\nexport function getAdminOrDefaultEmail(settings = appSettings): string | undefined {\n  return getAdminEmail(settings) || getDefaultEmail(settings);\n}\n\n/**\n * Returns the value of `GRIST_ADMIN_EMAIL` from `settings`.\n */\nfunction getAdminEmail(settings = appSettings): string | undefined {\n  return settings.section(\"access\").flag(\"installAdminEmail\").readString({\n    envVar: \"GRIST_ADMIN_EMAIL\",\n  });\n}\n\n/**\n * Returns the value of `GRIST_DEFAULT_EMAIL` from `settings`.\n */\nfunction getDefaultEmail(settings = appSettings): string | undefined {\n  return settings.section(\"access\").flag(\"defaultEmail\").readString({\n    envVar: \"GRIST_DEFAULT_EMAIL\",\n  });\n}\n"
  },
  {
    "path": "app/server/lib/LogMethods.ts",
    "content": "import log from \"app/server/lib/log\";\n\nexport type ILogMeta = log.ILogMeta;\n\n/**\n * Helper for logging with metadata. The created object has methods similar to those of the `log`\n * module, but with an extra required first argument. The produced messages get metadata produced\n * by the constructor callback applied to that argument, and the specified prefix.\n *\n * Usage:\n *    _log = new LogMethods(prefix, (info) => ({...logMetadata...}))\n *    _log.info(info, \"hello %\", name);\n *    _log.warn(info, \"hello %\", name);\n *    etc.\n */\nexport class LogMethods<Info> {\n  constructor(\n    private _prefix: string,\n    private _getMeta: (info: Info) => log.ILogMeta,\n  ) {}\n\n  public debug(info: Info, msg: string, ...args: any[]) { this.log(\"debug\", info, msg, ...args); }\n  public info(info: Info, msg: string, ...args: any[]) { this.log(\"info\", info, msg, ...args); }\n  public warn(info: Info, msg: string, ...args: any[]) { this.log(\"warn\", info, msg, ...args); }\n  public error(info: Info, msg: string, ...args: any[]) { this.log(\"error\", info, msg, ...args); }\n\n  public log(level: string, info: Info, msg: string, ...args: any[]): void {\n    log.origLog(level, this._prefix + msg, ...args, this._getMeta(info));\n  }\n\n  // Log with the given level, and include the provided log metadata in addition to that produced\n  // by _getMeta(info).\n  public rawLog(level: string, info: Info, msg: string, meta: ILogMeta): void {\n    log.origLog(level, this._prefix + msg, { ...this._getMeta(info), ...meta });\n  }\n}\n"
  },
  {
    "path": "app/server/lib/LoginSystemConfig.ts",
    "content": "import { GristLoginSystem } from \"app/server/lib/GristServer\";\n\nimport type { AppSettings } from \"app/server/lib/AppSettings\";\n\n/**\n * Configuration for a login system provider. It is used by the ConfigBackendAPI to list the providers\n * and check if they are properly configured.\n */\nexport interface LoginSystemConfig {\n  /** Unique identifier key for the login provider (e.g., 'oidc', 'saml'). */\n  key: string;\n\n  /** Human-readable name of the login system (e.g., 'OIDC', 'SAML'). */\n  name: string;\n\n  /** Function that reads and parses the provider's configuration from app settings. */\n  reader: (settings: AppSettings) => any;\n\n  /** Function that builds an instance of the login system based on app settings. */\n  builder: (settings: AppSettings) => Promise<GristLoginSystem | null>;\n\n  /**\n   * Optional function that returns metadata about the provider.\n   *\n   * This is only used to read the owner from GetGrist.com configuration for\n   * sending to the client. We can't currently send configuration directly to\n   * the client because they may contain sensitive values, like the client\n   * secret. But it should be possible to do so if we add filtering or censoring,\n   * and it seems useful in general to share most configuration values with the\n   * client.\n   *\n   * TODO: Remove this and send filtered/censored configuration returned by `reader`\n   * to the client instead.\n   */\n  metadataReader?: (settings: AppSettings) => Record<string, any>;\n}\n"
  },
  {
    "path": "app/server/lib/MemoryPool.ts",
    "content": "import Deque from \"double-ended-queue\";\n\n/**\n * Usage:\n *\n * OPTION 1, using a callback, which may be async (but doesn't have to be).\n *\n *   await mpool.withReserved(initialSize, async (updateReservation) => {\n *     ...\n *     updateReservation(newSize);   // if needed\n *     ...\n *   });\n *\n * OPTION 2, lower-level.\n *\n * Note: dispose() MUST be called (e.g. using try/finally). If not called, other work will\n * eventually deadlock waiting for it.\n *\n *   const memoryReservation = await mpool.waitAndReserve(initialSize);\n *   try {\n *     ...\n *     memoryReservation.updateReservation(newSize1);   // if needed\n *     memoryReservation.updateReservation(newSize2);   // if needed\n *     ...\n *   } finally {\n *     memoryReservation.dispose();\n *   }\n *\n * With both options, it's common for the initialSize to be a pool estimate. You may call\n * updateReservation() to update it. If it lowers the estimate, other work may unblock. If it\n * raises it, it may delay future work, but will have no impact on work that's already unblocked.\n * So it's always safer for initialSize to be an overestimate.\n *\n * When it's hard to estimate initialSize in bytes, you may specify it as e.g.\n * memPool.getTotalSize() / 20. This way at most 20 such parallel tasks may be unblocked at a\n * time, and further ones will wait until some release their memory or revise down their estimate.\n */\nexport class MemoryPool {\n  private _reservedSize: number = 0;\n  private _queue = new Deque<MemoryAwaiter>();\n\n  constructor(private _totalSize: number) {}\n\n  public getTotalSize(): number { return this._totalSize; }\n  public getReservedSize(): number { return this._reservedSize; }\n  public getAvailableSize(): number { return this._totalSize - this._reservedSize; }\n  public isEmpty(): boolean { return this._reservedSize === 0; }\n  public hasSpace(size: number): boolean { return this._reservedSize + size <= this._totalSize; }\n\n  // To avoid failures, allow reserving more than totalSize when memory pool is empty.\n  public hasSpaceOrIsEmpty(size: number): boolean { return this.hasSpace(size) || this.isEmpty(); }\n\n  public numWaiting(): number { return this._queue.length; }\n\n  public async waitAndReserve(size: number): Promise<MemoryReservation> {\n    if (this.hasSpaceOrIsEmpty(size)) {\n      this._updateReserved(size);\n    } else {\n      await new Promise<void>(resolve => this._queue.push({ size, resolve }));\n    }\n    return new MemoryReservation(size, this._updateReserved.bind(this));\n  }\n\n  public async withReserved(size: number, callback: (updateRes: UpdateReservation) => void | Promise<void>) {\n    const memRes = await this.waitAndReserve(size);\n    try {\n      return await callback(memRes.updateReservation.bind(memRes));\n    } finally {\n      memRes.dispose();\n    }\n  }\n\n  // Update the total size. Returns the old size. This is intended for testing.\n  public setTotalSize(newTotalSize: number): number {\n    const oldTotalSize = this._totalSize;\n    this._totalSize = newTotalSize;\n    this._checkWaiting();\n    return oldTotalSize;\n  }\n\n  private _checkWaiting() {\n    while (!this._queue.isEmpty() && this.hasSpaceOrIsEmpty(this._queue.peekFront()!.size)) {\n      const item = this._queue.shift()!;\n      this._updateReserved(item.size);\n      item.resolve();\n    }\n  }\n\n  private _updateReserved(sizeDelta: number): void {\n    this._reservedSize += sizeDelta;\n    this._checkWaiting();\n  }\n}\n\ntype UpdateReservation = (sizeDelta: number) => void;\n\nexport class MemoryReservation {\n  constructor(private _size: number, private _updateReserved: UpdateReservation) {}\n\n  public updateReservation(newSize: number) {\n    this._updateReserved(newSize - this._size);\n    this._size = newSize;\n  }\n\n  public dispose() {\n    this.updateReservation(0);\n    this._updateReserved = undefined as any;    // Make sure we don't keep using it after dispose\n  }\n}\n\ninterface MemoryAwaiter {\n  size: number;\n  resolve: () => void;\n}\n"
  },
  {
    "path": "app/server/lib/MinIOExternalStorage.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { ObjMetadata, ObjSnapshotWithMetadata, toExternalMetadata, toGristMetadata } from \"app/common/DocSnapshot\";\nimport { ExternalStorage, StreamDownloadResult } from \"app/server/lib/ExternalStorage\";\n\nimport { IncomingMessage } from \"http\";\nimport * as stream from \"node:stream\";\n\nimport * as fse from \"fs-extra\";\nimport * as minio from \"minio\";\n\n// The minio-js v8.0.0 typings are sometimes incorrect. Here are some workarounds.\ninterface MinIOClient extends\n  // Some of them are not directly extendable, must be omitted first and then redefined.\n  Omit<minio.Client, \"listObjects\" | \"getBucketVersioning\" | \"removeObjects\">\n{\n  // The official typing returns `Promise<Readable>`, dropping some useful metadata.\n  getObject(bucket: string, key: string, options: { versionId?: string }): Promise<IncomingMessage>;\n  // The official typing dropped \"options\" in their .d.ts file, but it is present in the underlying impl.\n  listObjects(bucket: string, key: string, recursive: boolean,\n    options: { IncludeVersion?: boolean }): minio.BucketStream<minio.BucketItem>;\n  // The released v8.0.0 wrongly returns `Promise<void>`; borrowed from PR #1297\n  getBucketVersioning(bucketName: string): Promise<MinIOVersioningStatus>;\n  // The released v8.0.0 typing is outdated; copied over from commit 8633968.\n  removeObjects(bucketName: string, objectsList: RemoveObjectsParam): Promise<RemoveObjectsResponse[]>\n}\n\ntype MinIOVersioningStatus = \"\" | {\n  Status: \"Enabled\" | \"Suspended\",\n  MFADelete?: string,\n  ExcludeFolders?: boolean,\n  ExcludedPrefixes?: { Prefix: string }[]\n};\n\ntype RemoveObjectsParam = string[] | { name: string, versionId?: string }[];\n\ntype RemoveObjectsResponse = null | undefined | {\n  Error?: {\n    Code?: string\n    Message?: string\n    Key?: string\n    VersionId?: string\n  }\n};\n\n/**\n * An external store implemented using the MinIO client, which\n * will work with MinIO and other S3-compatible storage.\n */\nexport class MinIOExternalStorage implements ExternalStorage {\n  // Specify bucket to use, and optionally the max number of keys to request\n  // in any call to listObjectVersions (used for testing)\n  constructor(\n    public bucket: string,\n    public options: {\n      endPoint: string,\n      port?: number,\n      useSSL?: boolean,\n      accessKey: string,\n      secretKey: string,\n      region: string\n    },\n    private _batchSize?: number,\n    private _s3 = new minio.Client(options) as unknown as MinIOClient,\n  ) {\n  }\n\n  public async exists(key: string, snapshotId?: string) {\n    return Boolean(await this.head(key, snapshotId));\n  }\n\n  public async head(key: string, snapshotId?: string): Promise<ObjSnapshotWithMetadata | null> {\n    try {\n      const head = await this._s3.statObject(\n        this.bucket, key,\n        snapshotId ? { versionId: snapshotId } : {},\n      );\n      if (!head.lastModified || !head.versionId) {\n        // AWS documentation says these fields will be present.\n        throw new Error(\"MinIOExternalStorage.head did not get expected fields\");\n      }\n      return {\n        lastModified: head.lastModified.toISOString(),\n        snapshotId: head.versionId,\n        ...head.metaData && { metadata: toGristMetadata(head.metaData) },\n      };\n    } catch (err) {\n      // NotFound and NoSuchKey are \"expected\" errors when checking for existence of a document\n      // and should return a falsy null.\n      // Other errors like 'ECONNRESET' and 'InternalError' are fatal errors and should be thrown\n      // in order to avoid weird behavior when MinIO is experiencing hiccups\n      if (this.isExpectedNotFoundError(err)) { return null; }\n      throw err;\n    }\n  }\n\n  public async uploadStream(key: string, inStream: stream.Readable, size?: number, metadata?: ObjMetadata) {\n    const result = await this._s3.putObject(\n      this.bucket, key, inStream, size,\n      metadata ? { Metadata: toExternalMetadata(metadata) } : undefined,\n    );\n    // Empirically VersionId is available in result for buckets with versioning enabled.\n    return result.versionId || null;\n  }\n\n  public async upload(key: string, fname: string, metadata?: ObjMetadata) {\n    // calling putObject with a file size will let MinIO be clever about uploading in multiple parts or not.\n    const stat = await fse.lstat(fname);\n    const filestream = fse.createReadStream(fname);\n    try {\n      return await this.uploadStream(key, filestream, stat.size, metadata);\n    } finally {\n      filestream.destroy();\n    }\n  }\n\n  public async downloadStream(key: string, snapshotId?: string): Promise<StreamDownloadResult> {\n    const request = await this._s3.getObject(\n      this.bucket, key,\n      snapshotId ? { versionId: snapshotId } : {},\n    );\n    const statusCode = request.statusCode || 500;\n    if (statusCode >= 300) {\n      throw new ApiError(\"download error\", statusCode);\n    }\n    // See https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/requests-using-stream-objects.html\n    // for an example of streaming data.\n    const headers = request.headers;\n    // For a versioned bucket, the header 'x-amz-version-id' contains a version id.\n    const downloadedSnapshotId = String(headers[\"x-amz-version-id\"] || \"\");\n    const fileSize = Number(headers[\"content-length\"]);\n    if (Number.isNaN(fileSize)) {\n      throw new ApiError(\"download error - bad file size\", 500);\n    }\n    return {\n      metadata: {\n        snapshotId: downloadedSnapshotId,\n        size: fileSize,\n      },\n      contentStream: request,\n    };\n  }\n\n  public async download(key: string, fname: string, snapshotId?: string) {\n    const fileStream = fse.createWriteStream(fname);\n    const download = await this.downloadStream(key, snapshotId);\n    await stream.promises.pipeline(download.contentStream, fileStream);\n    return download.metadata.snapshotId;\n  }\n\n  public async remove(key: string, snapshotIds?: string[]) {\n    if (snapshotIds) {\n      await this._deleteVersions(key, snapshotIds);\n    } else {\n      await this._deleteAllVersions(key);\n    }\n  }\n\n  public async removeAllWithPrefix(prefix: string) {\n    const objects = await this._listObjects(this.bucket, prefix, true, { IncludeVersion: true });\n    const objectsToDelete = objects.filter(o => o.name !== undefined).map(o => ({\n      name: o.name!,\n      versionId: (o as any).versionId as (string | undefined),\n    }));\n    await this._deleteObjects(objectsToDelete);\n  }\n\n  public async hasVersioning(): Promise<boolean> {\n    const versioning = await this._s3.getBucketVersioning(this.bucket);\n    // getBucketVersioning() may return an empty string when versioning has never been enabled.\n    // This situation is not addressed in minio-js v8.0.0, but included in our workaround.\n    return versioning !== \"\" && versioning?.Status === \"Enabled\";\n  }\n\n  public async versions(key: string, options?: { includeDeleteMarkers?: boolean }) {\n    const results = await this._listObjects(this.bucket, key, false, { IncludeVersion: true });\n    return results\n      .filter(v => v.name === key &&\n        v.lastModified && (v as any).versionId &&\n        (options?.includeDeleteMarkers || !(v as any).isDeleteMarker))\n      .map(v => ({\n        lastModified: v.lastModified!.toISOString(),\n        // Circumvent inconsistency of MinIO API with versionId by casting it to string\n        // PR to MinIO so we don't have to do that anymore:\n        // https://github.com/minio/minio-js/pull/1193\n        snapshotId: String((v as any).versionId),\n      }));\n  }\n\n  public url(key: string) {\n    return `minio://${this.bucket}/${key}`;\n  }\n\n  public isFatalError(err: any) {\n    // Fatal errors are all errors that are neither expected nor retryable.\n    return !this.isExpectedNotFoundError(err) && !this.isRetryableError(err);\n  }\n\n  public isExpectedNotFoundError(err: any) {\n    // NotFound and NoSuchKey are \"expected\" errors when checking for existence of a document\n    // Other errors like 'ECONNRESET' and 'InternalError' were added to the list by mistake\n    // which caused problems like overriding an existing document when MinIO was experiencing hiccups.\n    return err.code === \"NotFound\" || err.code === \"NoSuchKey\";\n  }\n\n  public isRetryableError(err: any) {\n    // Retry MinIO requests on some common transient errors.\n    return err.code === \"ECONNRESET\" || err.code === \"InternalError\" ||\n      err.code === \"EAI_AGAIN\";\n  }\n\n  public async close() {\n    // nothing to do\n  }\n\n  // Delete all versions of an object.\n  public async _deleteAllVersions(key: string) {\n    const vs = await this.versions(key, { includeDeleteMarkers: true });\n    await this._deleteVersions(key, vs.map(v => v.snapshotId));\n  }\n\n  // Delete a batch of versions for an object.\n  private async _deleteVersions(key: string, versions: (string | undefined)[]) {\n    return this._deleteObjects(\n      versions.filter(v => v).map(versionId => ({\n        name: key,\n        versionId,\n      })),\n    );\n  }\n\n  // Delete an arbitrary number of objects, batched appropriately.\n  private async _deleteObjects(objects: { name: string, versionId?: string }[]): Promise<void> {\n    // Max number of keys per request for AWS S3 is 1000, see:\n    //   https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html\n    // Stick to this maximum in case we are using this client to talk to AWS.\n    const N = this._batchSize || 1000;\n    for (let i = 0; i < objects.length; i += N) {\n      const batch = objects.slice(i, i + N);\n      if (batch.length === 0) { continue; }\n      await this._s3.removeObjects(this.bucket, batch);\n    }\n  }\n\n  private async _listObjects(...args: Parameters<MinIOClient[\"listObjects\"]>): Promise<minio.BucketItem[]> {\n    const bucketItemStream = this._s3.listObjects(...args);\n    const results: minio.BucketItem[] = [];\n    for await (const data of bucketItemStream) {\n      results.push(data);\n    }\n    return results;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/MinimalLogin.ts",
    "content": "import { UserProfile } from \"app/common/UserAPI\";\nimport { GristLoginMiddleware, GristLoginSystem, GristServer, setUserInSession } from \"app/server/lib/GristServer\";\nimport { getFallbackLoginProvider } from \"app/server/lib/loginSystemHelpers\";\n\nimport { Request } from \"express\";\n\n/**\n * Returns a login system that supports a single hard-coded user, or undefined if minimal login is disabled.\n */\nasync function buildMinimalLoginSystem(): Promise<GristLoginSystem> {\n  return {\n    async getMiddleware(gristServer: GristServer) {\n      async function getLoginRedirectUrl(req: Request, url: URL) {\n        await setUserInSession(req, gristServer, getDefaultProfile());\n        return url.href;\n      }\n      return {\n        getLoginRedirectUrl,\n        getSignUpRedirectUrl: getLoginRedirectUrl,\n        async getLogoutRedirectUrl(req: Request, url: URL) {\n          return url.href;\n        },\n        async addEndpoints() {\n          // If working without a login system, make sure default user exists.\n          const dbManager = gristServer.getHomeDBManager();\n          const profile = getDefaultProfile();\n          const user = await dbManager.getUserByLoginWithRetry(profile.email, { profile });\n          if (user) {\n            // No need to survey this user!\n            user.isFirstTimeUser = false;\n            await user.save();\n          }\n          return \"no-logins\";\n        },\n      };\n    },\n    async deleteUser() {\n      // nothing to do\n    },\n  };\n}\n\n/**\n * Minimal login system is a fallback login system that allows logging in as a single default user. It is\n * always configured.\n */\nexport const getMinimalLoginSystem = getFallbackLoginProvider(\n  \"minimal\",\n  buildMinimalLoginSystem,\n);\n\nfunction getDefaultProfile(): UserProfile {\n  return {\n    email: process.env.GRIST_DEFAULT_EMAIL || \"you@example.com\",\n    name: \"You\",\n  };\n}\n\n/**\n * Returns a fallback login system that blocks all authentication attempts.\n *\n * This is used as a last resort when no login system is selected explicitly or configured properly, so that\n * it can be selected automatically. It is a variant of the minimal login system that does not allow any logins at all.\n */\nexport async function getBlockedLoginSystem(): Promise<GristLoginSystem> {\n  return {\n    async getMiddleware() {\n      return new ErrorInLoginMiddleware();\n    },\n    async deleteUser() {\n      // nothing to do\n    },\n  };\n}\n\nexport class ErrorInLoginMiddleware implements GristLoginMiddleware {\n  public getLoginRedirectUrl(req: Request, url: URL): Promise<string> {\n    throw new Error(\"No login system is configured\");\n  }\n\n  public getSignUpRedirectUrl(req: Request, url: URL): Promise<string> {\n    throw new Error(\"No login system is configured\");\n  }\n\n  public getLogoutRedirectUrl(req: Request, url: URL): Promise<string> {\n    throw new Error(\"No login system is configured\");\n  }\n\n  public async addEndpoints(): Promise<string> {\n    return \"no-provider\";\n  }\n}\n"
  },
  {
    "path": "app/server/lib/NSandbox.ts",
    "content": "/**\n * JS controller for the pypy sandbox.\n */\n\nimport { arrayToString } from \"app/common/arrayToString\";\nimport * as marshal from \"app/common/marshal\";\nimport { create } from \"app/server/lib/create\";\nimport { ISandbox, ISandboxCreationOptions, ISandboxCreator } from \"app/server/lib/ISandbox\";\nimport log from \"app/server/lib/log\";\nimport { getAppRoot, getAppRootFor, getUnpackedAppRoot } from \"app/server/lib/places\";\nimport {\n  DirectProcessControl,\n  ISandboxControl,\n  NoProcessControl,\n  ProcessInfo,\n  SubprocessControl,\n} from \"app/server/lib/SandboxControl\";\nimport { getPyodideSettings } from \"app/server/lib/SandboxPyodide\";\nimport * as sandboxUtil from \"app/server/lib/sandboxUtil\";\nimport * as shutdown from \"app/server/lib/shutdown\";\n\nimport { ChildProcess, fork, spawn, SpawnOptionsWithoutStdio } from \"child_process\";\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { Stream, Writable } from \"stream\";\n\nimport * as _ from \"lodash\";\nimport * as which from \"which\";\n\ntype SandboxMethod = (...args: any[]) => any;\n\n/**\n *\n * A collection of options for weird and wonderful ways to run Grist.\n * The sandbox at heart is just python, but run in different ways\n * (sandbox 'flavors': docker, gvisor, and unsandboxed).\n *\n * The \"command\" is an external program/container to call to run the\n * sandbox, and it depends on sandbox flavor.\n * For gvisor and unsandboxed, command is the path to an\n * external program to run.  For docker, it is the name of an image.\n *\n * Once python is running, ordinarily some Grist code should be\n * started by setting `useGristEntrypoint` (the only exception is\n * in tests) which runs grist/main.py.\n */\nexport interface ISandboxOptions {\n  // External program or container to call to run the sandbox.\n  command?: string;\n  // The arguments to pass first to the sandbox process.\n  testSandboxArgs: string[];\n  // The arguments to pass first to the python process.\n  testPythonArgs: string[];\n  // Extra arguments that get appended to the end of sandbox command, after anything else.\n  // Implemented only to enable workaround for a Grist desktop Flatpak sandbox issue\n  appendArgs?: string[];\n  // an argument to add to the command line when possible, that should be shown in the `ps` output\n  // for the sandbox process. Intended to be a document name or id\n  comment?: string;\n\n  // Mandatory for gvisor; ignored by other methods.\n  preferredPythonVersion?: string;\n\n  // TODO: update\n  // ISandboxCreationOptions to talk about directories instead of\n  // mounts, since it may not be possible to remap directories as\n  // mounts (e.g. for unsandboxed operation).\n  importDir?: string;  // a directory containing data file(s) to import by plugins\n\n  // Whether to use newer 3-pipe operation\n  minimalPipeMode?: boolean;\n  // Whether to override time + randomness\n  deterministicMode?: boolean;\n\n  // Functions made available to the sandboxed process.\n  exports?: { [name: string]: SandboxMethod };\n  // (Not implemented) Whether to log all system calls from the python sandbox.\n  logCalls?: boolean;\n  // Whether to log time taken by calls to python sandbox.\n  logTimes?: boolean;\n  // Log metadata (e.g. including docId) to report in all log messages.\n  logMeta?: log.ILogMeta;\n\n  // Should be set for everything except tests, which may want to pass arguments to python directly.\n  // Now defaults to true.\n  useGristEntrypoint?: boolean;\n}\n\n/**\n * We interact with sandboxes as a separate child process. Data engine work is done\n * across standard input and output streams from and to this process. We also monitor\n * and control resource utilization via a distinct control interface.\n *\n * More recently, a sandbox may not be a separate OS process, but (for\n * example) a web worker. In this case, a pair of callbacks (getData and\n * sendData) replace pipes.\n */\nexport interface SandboxProcess {\n  name: string;\n  child?: ChildProcess;\n  control: () => ISandboxControl;\n  dataToSandboxDescriptor?: number;    // override sandbox's 'stdin' for data\n  dataFromSandboxDescriptor?: number;  // override sandbox's 'stdout' for data\n  getData?: (cb: (data: any) => void) => void;  // use a callback instead of a pipe to get data\n  sendData?: (data: any) => void;  // use a callback instead of a pipe to send data\n}\n\ninterface CallResponse {\n  data: unknown;\n  numBytes: number;   // Size of the marshalled version of the response, for diagnostics.\n}\n\ntype ResolveRejectPair = [(value: CallResponse) => void, (reason?: unknown) => void];\n\n// Type for basic message identifiers, available as constants in sandboxUtil.\ntype MsgCode = null | true | false;\n\n// Optional root folder to store binary data sent to and from the sandbox\n// See test_replay.py\nconst recordBuffersRoot = process.env.RECORD_SANDBOX_BUFFERS_DIR;\n\nexport class NSandbox implements ISandbox {\n  public readonly childProc?: ChildProcess;\n  private _control: ISandboxControl;\n  private _logTimes: boolean;\n  private _exportedFunctions: { [name: string]: SandboxMethod };\n  private _marshaller = new marshal.Marshaller({ stringToBuffer: false, version: 2 });\n  private _unmarshaller = new marshal.Unmarshaller({ bufferToString: false });\n\n  // Members used for reading from the sandbox process.\n  private _pendingReads: ResolveRejectPair[] = [];\n  private _isReadClosed = false;\n  private _isWriteClosed = false;\n\n  private _logMeta: log.ILogMeta;\n  private _streamToSandbox?: Writable;\n  private _streamFromSandbox: Stream;\n  private _dataToSandbox?: (data: any) => void;\n  private _lastStderr: Uint8Array;  // Record last error line seen.\n\n  // Size of the last pyCall() response in bytes.\n  private _lastResponseNumBytes: number | undefined = undefined;\n\n  // Create a unique subdirectory for each sandbox process so they can be replayed separately\n  private _recordBuffersDir = recordBuffersRoot ? path.resolve(recordBuffersRoot, new Date().toISOString()) : null;\n\n  /*\n   * Callers may listen to events from sandbox.childProc (a ChildProcess), e.g. 'close' and 'error'.\n   * The sandbox listens for 'aboutToExit' event on the process, to properly shut down.\n   *\n   * Grist interacts with the sandbox via message passing through pipes to an isolated\n   * process.  Some read-only shared code is made available to the sandbox.\n   * For plugins, read-only data files are made available.\n   *\n   * Variants can be activated by passing in a non-default \"spawner\" function.\n   *\n   */\n  constructor(options: ISandboxOptions, spawner: SpawnFn = sandboxed) {\n    this._logTimes = Boolean(options.logTimes || options.logCalls);\n    this._exportedFunctions = options.exports || {};\n\n    const sandboxProcess = spawner(options);\n    this.childProc = sandboxProcess.child;\n    this._logMeta = {\n      sandboxPid: this.childProc?.pid,\n      flavor: spawner.name,\n      ...options.logMeta,\n    };\n    if (spawner.name !== sandboxProcess.name) {\n      this._logMeta.subflavor = sandboxProcess.name;\n    }\n\n    // Handle childProc events early, especially the 'error' event which may lead to node exiting.\n    // Creating a gvisor checkpoint will cause the sandbox to\n    // exit abruptly, there is no need to report this as an error.\n    if (!process.env.GRIST_CHECKPOINT_MAKE) {\n      this.childProc?.on(\"close\", this._onExit.bind(this));\n    }\n    this.childProc?.on(\"error\", this._onError.bind(this));\n\n    this._control = sandboxProcess.control();\n\n    if (this.childProc) {\n      if (options.minimalPipeMode !== false) {\n        this._initializeMinimalPipeMode(sandboxProcess);\n      } else {\n        this._initializeFivePipeMode(sandboxProcess);\n      }\n    } else {\n      // No child process. In this case, there should be a callback for\n      // receiving and sending data.\n      if (!sandboxProcess.getData) {\n        throw new Error(\"no way to get data from sandbox\");\n      }\n      if (!sandboxProcess.sendData) {\n        throw new Error(\"no way to send data to sandbox\");\n      }\n      sandboxProcess.getData(data => this._onSandboxData(data));\n      this._dataToSandbox = sandboxProcess.sendData;\n    }\n\n    // On shutdown, shutdown the child process cleanly, and wait for it to exit.\n    shutdown.addCleanupHandler(this, this.shutdown);\n\n    if (this._recordBuffersDir) {\n      log.rawDebug(`Recording sandbox buffers in ${this._recordBuffersDir}`, this._logMeta);\n      fs.mkdirSync(this._recordBuffersDir, { recursive: true });\n    }\n  }\n\n  /**\n   * Shuts down the sandbox process cleanly, and wait for it to exit.\n   * @return {Promise} Promise that's resolved with [code, signal] when the sandbox exits.\n   */\n  public async shutdown() {\n    log.rawDebug(\"Sandbox shutdown starting\", this._logMeta);\n    shutdown.removeCleanupHandlers(this);\n\n    // The signal ensures the sandbox process exits even if it's hanging in an infinite loop or\n    // long computation. It doesn't get a chance to clean up, but since it is sandboxed, there is\n    // nothing it needs to clean up anyway.\n    const timeoutID = setTimeout(async () => {\n      log.rawWarn(\"Sandbox sending SIGKILL\", this._logMeta);\n      await this._control.kill();\n    }, 1000);\n\n    const result = await new Promise<void>((resolve, reject) => {\n      if (this._isWriteClosed) { resolve(); }\n      this.childProc?.on(\"error\", reject);\n      this.childProc?.on(\"close\", resolve);\n      this.childProc?.on(\"exit\", resolve);\n      this._close();\n    }).finally(() => this._control.close());\n\n    // In the normal case, the kill timer is pending when the process exits, and we can clear it. If\n    // the process got killed, the timer is invalid, and clearTimeout() does nothing.\n    clearTimeout(timeoutID);\n    return result;\n  }\n\n  /**\n   * Makes a call to the python process implementing our calling convention on stdin/stdout.\n   * @param funcName The name of the python RPC function to call.\n   * @param args Arguments to pass to the given function.\n   * @returns A promise for the return value from the Python function.\n   */\n  public async pyCall(funcName: string, ...varArgs: unknown[]): Promise<any> {\n    const startTime = Date.now();\n    this._sendData(sandboxUtil.CALL, Array.from(arguments));\n    const slowCallCheck = setTimeout(() => {\n      // Log calls that take some time, can be a useful symptom of misconfiguration\n      // (or just benign if the doc is big).\n      log.rawWarn(\"Slow pyCall\", { ...this._logMeta, funcName });\n    }, 10000);\n    try {\n      const { data, numBytes } = await this._pyCallWait(funcName, startTime);\n      this._lastResponseNumBytes = numBytes;\n      return data;\n    } finally {\n      clearTimeout(slowCallCheck);\n    }\n  }\n\n  public getLastResponseNumBytes(): number | undefined {\n    return this._lastResponseNumBytes;\n  }\n\n  /**\n   * Returns the RSS (resident set size) of the sandbox process, in bytes.\n   */\n  public async reportMemoryUsage() {\n    const { memory } = await this._control.getUsage();\n    log.rawDebug(\"Sandbox memory\", { memory, ...this._logMeta });\n    return memory;\n  }\n\n  public isProcessDown() {\n    return this._isReadClosed || this._isWriteClosed;\n  }\n\n  public getFlavor() {\n    return this._logMeta.subflavor || this._logMeta.flavor;\n  }\n\n  /**\n   * Get ready to communicate with a sandbox process using stdin,\n   * stdout, and stderr.\n   */\n  private _initializeMinimalPipeMode(sandboxProcess: SandboxProcess) {\n    log.rawDebug(\"3-pipe Sandbox started\", this._logMeta);\n    if (!this.childProc) {\n      throw new Error(\"child process required\");\n    }\n    if (sandboxProcess.dataToSandboxDescriptor) {\n      this._streamToSandbox =\n        (this.childProc.stdio as Stream[])[sandboxProcess.dataToSandboxDescriptor] as Writable;\n    } else {\n      if (!this.childProc.stdin) {\n        throw new Error(\"stdin required\");\n      }\n      this._streamToSandbox = this.childProc.stdin;\n    }\n    if (sandboxProcess.dataFromSandboxDescriptor) {\n      this._streamFromSandbox =\n        (this.childProc.stdio as Stream[])[sandboxProcess.dataFromSandboxDescriptor];\n    } else {\n      if (!this.childProc.stdout) {\n        throw new Error(\"stdout required\");\n      }\n      this._streamFromSandbox = this.childProc.stdout;\n    }\n    this._initializeStreamEvents();\n  }\n\n  /**\n   * Get ready to communicate with a sandbox process using stdin,\n   * stdout, and stderr, and two extra FDs. This was a nice way\n   * to have a clean, separate data channel, when supported.\n   */\n  private _initializeFivePipeMode(sandboxProcess: SandboxProcess) {\n    log.rawDebug(\"5-pipe Sandbox started\", this._logMeta);\n    if (!this.childProc) {\n      throw new Error(\"child process required\");\n    }\n    if (sandboxProcess.dataFromSandboxDescriptor || sandboxProcess.dataToSandboxDescriptor) {\n      throw new Error(\"cannot override file descriptors in 5 pipe mode\");\n    }\n    this._streamToSandbox = (this.childProc.stdio as Stream[])[3] as Writable;\n    this._streamFromSandbox = (this.childProc.stdio as Stream[])[4];\n    this.childProc.stdout!.on(\"data\", sandboxUtil.makeLinePrefixer(\"Sandbox stdout: \", this._logMeta));\n    this._initializeStreamEvents();\n  }\n\n  /**\n   * Set up logging and events on streams to/from a sandbox.\n   */\n  private _initializeStreamEvents() {\n    if (!this.childProc) {\n      throw new Error(\"child process required\");\n    }\n    if (!this._streamToSandbox) {\n      throw new Error(\"expected streamToSandbox to be configured\");\n    }\n    const sandboxStderrLogger = sandboxUtil.makeLogLinePrefixer(\"Sandbox stderr: \", this._logMeta);\n    this.childProc.stderr!.on(\"data\", (data) => {\n      this._lastStderr = data;\n      sandboxStderrLogger(data);\n    });\n\n    this._streamFromSandbox.on(\"data\", (data) => {\n      try {\n        this._onSandboxData(data);\n      } catch (err) {\n        this._streamFromSandbox.emit(\"error\", err);\n      }\n    });\n    this._streamFromSandbox.on(\"end\", () => this._onSandboxClose());\n    this._streamFromSandbox.on(\"error\", (err) => {\n      log.rawError(`Sandbox error reading: ${err}`, this._logMeta);\n      this._onSandboxClose();\n    });\n\n    this._streamToSandbox.on(\"error\", (err) => {\n      if (!this._isWriteClosed) {\n        log.rawError(`Sandbox error writing: ${err}`, this._logMeta);\n      }\n    });\n  }\n\n  private async _pyCallWait(funcName: string, startTime: number): Promise<CallResponse> {\n    try {\n      return await new Promise((resolve, reject) => {\n        this._pendingReads.push([resolve, reject]);\n      });\n    } catch (e) {\n      throw new sandboxUtil.SandboxError(e.message);\n    } finally {\n      if (this._logTimes) {\n        log.rawDebug(\"NSandbox pyCall\", {\n          ...this._logMeta,\n          funcName,\n          loadMs: Date.now() - startTime,\n        });\n      }\n    }\n  }\n\n  private _close() {\n    this._control?.prepareToClose();    // ?. operator in case _control failed to get initialized.\n    if (!this._isWriteClosed) {\n      // Close the pipe to the sandbox, which should cause the sandbox to exit cleanly.\n      this._streamToSandbox?.end();\n      this._isWriteClosed = true;\n    }\n  }\n\n  private _onExit(code: number, signal: string) {\n    const expected = this._isWriteClosed;\n    this._close();\n    if (expected) {\n      log.rawDebug(`Sandbox exited with code ${code} signal ${signal}`, this._logMeta);\n    } else {\n      log.rawWarn(`Sandbox unexpectedly exited with code ${code} signal ${signal}`, this._logMeta);\n    }\n  }\n\n  private _onError(err: Error) {\n    this._close();\n    log.rawWarn(`Sandbox could not be spawned: ${err}`, this._logMeta);\n  }\n\n  /**\n   * Send a message to the sandbox process with the given message code and data.\n   */\n  private _sendData(msgCode: MsgCode, data: any) {\n    if (this._isReadClosed) {\n      throw this._sandboxClosedError(\"PipeToSandbox\");\n    }\n    this._marshaller.marshal(msgCode);\n    this._marshaller.marshal(data);\n    const buf = this._marshaller.dumpAsBuffer();\n    if (this._recordBuffersDir) {\n      fs.appendFileSync(path.resolve(this._recordBuffersDir, \"input\"), buf);\n    }\n    if (this._streamToSandbox) {\n      return this._streamToSandbox.write(buf);\n    } else {\n      if (!this._dataToSandbox) {\n        throw new Error(\"no way to send data to sandbox\");\n      }\n      this._dataToSandbox(buf);\n      return true;\n    }\n  }\n\n  /**\n   * Process a buffer of data received from the sandbox process.\n   */\n  private _onSandboxData(data: any) {\n    this._unmarshaller.parse(data, (buf) => {\n      const value = marshal.loads(buf, { bufferToString: true });\n      if (this._recordBuffersDir) {\n        fs.appendFileSync(path.resolve(this._recordBuffersDir, \"output\"), buf);\n      }\n      this._onSandboxMsg(value[0], value[1], buf.length);\n    });\n  }\n\n  /**\n   * Process the closing of the pipe by the sandboxed process.\n   */\n  private _onSandboxClose() {\n    this._control.prepareToClose();\n    this._isReadClosed = true;\n    // Clear out all reads pending on PipeFromSandbox, rejecting them with the given error.\n    const err = this._sandboxClosedError(\"PipeFromSandbox\");\n\n    this._pendingReads.forEach(resolvePair => resolvePair[1](err));\n    this._pendingReads = [];\n  }\n\n  /**\n   * Generate an error message for a pipe to the sandbox. Include the\n   * last stderr line seen from the sandbox - more reliable than\n   * error results send via the standard protocol.\n   */\n  private _sandboxClosedError(label: string) {\n    const parts = [`${label} is closed`];\n    if (this._lastStderr) {\n      parts.push(arrayToString(this._lastStderr));\n    }\n    return new sandboxUtil.SandboxError(parts.join(\": \"));\n  }\n\n  /**\n   * Process a parsed message from the sandboxed process.\n   */\n  private _onSandboxMsg(msgCode: MsgCode, data: any, numBytes: number) {\n    if (msgCode === sandboxUtil.CALL) {\n      // Handle calls FROM the sandbox.\n      if (!Array.isArray(data) || data.length === 0) {\n        log.rawWarn(\"Sandbox invalid call from the sandbox\", this._logMeta);\n      } else {\n        const fname = data[0];\n        const args = data.slice(1);\n        log.rawDebug(`Sandbox got call to ${fname} (${args.length} args)`, this._logMeta);\n        Promise.resolve()\n          .then(() => {\n            const func = this._exportedFunctions[fname];\n            if (!func) { throw new Error(\"No such exported function: \" + fname); }\n            return func(...args);\n          })\n          .then((ret) => {\n            this._sendData(sandboxUtil.DATA, ret);\n          }, (err) => {\n            this._sendData(sandboxUtil.EXC, err.toString());\n          })\n          .catch((err) => {\n            log.rawDebug(`Sandbox sending response failed: ${err}`, this._logMeta);\n          });\n      }\n    } else {\n      // Handle return values for calls made to the sandbox.\n      const resolvePair = this._pendingReads.shift();\n      if (resolvePair) {\n        if (msgCode === sandboxUtil.EXC) {\n          resolvePair[1](new Error(data));\n        } else if (msgCode === sandboxUtil.DATA) {\n          resolvePair[0]({ data, numBytes });\n        } else {\n          log.rawWarn(\"Sandbox invalid message from sandbox\", this._logMeta);\n        }\n      }\n    }\n  }\n}\n\n/**\n * Functions for spawning all of the currently supported sandboxes.\n */\nconst spawners = {\n  unsandboxed,        // No sandboxing, straight to host python.\n  // This offers no protection to the host.\n  docker,             // Run sandboxes in distinct docker containers.\n  gvisor,             // Gvisor's runsc sandbox.\n  macSandboxExec,     // Use \"sandbox-exec\" on Mac.\n  pyodide,            // Run data engine using pyodide.\n  skip: unsandboxed,  // Same as unsandboxed. Used to mean that the\n  // user deliberately doesn't want sandboxing.\n  // The \"unsandboxed\" setting is ambiguous in this\n  // respect.\n  sandboxed,          // Use whatever sandboxing is available. Tries in\n  // order: gvisor, macSandboxExec, then finally\n  // falling back on pyodide (which can be made\n  // to run anywhere).\n};\n\nfunction isFlavor(flavor: string): flavor is keyof typeof spawners {\n  return flavor in spawners;\n}\n\n/**\n * A sandbox factory.  This doesn't do very much beyond remembering a default\n * flavor of sandbox (which at the time of writing differs between hosted grist and\n * grist-core), and trying to regularize creation options a bit.\n *\n * The flavor of sandbox to use can be overridden by some environment variables:\n *   - GRIST_SANDBOX_FLAVOR: should be one of the spawners (gvisor, unsandboxed, docker,\n *     macSandboxExec)\n *   - GRIST_SANDBOX: a program or image name to run as the sandbox.\n *     For unsandboxed, should be an absolute path to python within a virtualenv\n *     with all requirements installed.\n *     For docker, it should be `grist-docker-sandbox` (an image built via makefile\n *     in `sandbox/docker`) or a derived image.  For gvisor, it should be the full path\n *     to `sandbox/gvisor/run.py` (if runsc available locally) or to\n *     `sandbox/gvisor/wrap_in_docker.sh` (if runsc should be run using the docker\n *     image built in that directory).\n */\nexport class NSandboxCreator implements ISandboxCreator {\n  private _flavor: string;\n  private _spawner: SpawnFn;\n  private _command?: string;\n  private _commandArgs: string[];\n  private _commandAppendArgs?: string[];\n  private _preferredPythonVersion?: string;\n\n  public constructor(options: {\n    defaultFlavor: string,\n    command?: string,\n    commandArgs?: string[],\n    commandAppendArgs?: string[],\n    preferredPythonVersion?: string,\n  }) {\n    const flavor = options.defaultFlavor;\n    if (!isFlavor(flavor)) {\n      const variants = create.getSandboxVariants?.();\n      if (!variants?.[flavor]) {\n        throw new Error(`Unrecognized sandbox flavor: ${flavor}`);\n      } else {\n        this._spawner = variants[flavor];\n      }\n    } else {\n      this._spawner = spawners[flavor];\n    }\n    this._flavor = flavor;\n    this._command = options.command;\n    this._commandArgs = options.commandArgs ?? [];\n    this._commandAppendArgs = options.commandAppendArgs;\n    this._preferredPythonVersion = options.preferredPythonVersion;\n  }\n\n  public create(options: ISandboxCreationOptions): ISandbox {\n    const sandboxArgs: string[] = [\n      ...this._commandArgs,\n      ...(options.sandboxOptions?.testSandboxArgs ?? []),\n    ];\n    const appendArgs: string[] = [\n      ...(this._commandAppendArgs ?? []),\n      ...(options.sandboxOptions?.appendArgs ?? []),\n    ];\n\n    const translatedOptions: ISandboxOptions = {\n      minimalPipeMode: true,\n      deterministicMode: Boolean(process.env.LIBFAKETIME_PATH),\n      logCalls: options.logCalls,\n      logMeta: { flavor: this._flavor, command: this._command,\n        entryPoint: options.entryPoint || \"(default)\",\n        ...options.logMeta },\n      logTimes: options.logTimes,\n      command: this._command,\n      preferredPythonVersion: this._preferredPythonVersion || options.preferredPythonVersion || \"3\",\n      useGristEntrypoint: true,\n      importDir: options.importMount,\n      ...options.sandboxOptions,\n      testPythonArgs: options.sandboxOptions?.testPythonArgs ?? [],\n      testSandboxArgs: sandboxArgs,\n      appendArgs,\n    };\n    return new NSandbox(translatedOptions, this._spawner);\n  }\n}\n\n// A function that takes sandbox options and starts a sandbox process.\nexport type SpawnFn = (options: ISandboxOptions) => SandboxProcess;\n\nconst hasRunsc = checkCommandExists(\"runsc\");\nconst hasSandboxExec = checkCommandExists(\"sandbox-exec\");\n\n/**\n * Currently for sandboxing use gvisor if available, otherwise\n * try native sandboxing on macs, otherwise fall back on pyodide.\n */\nfunction sandboxed(options: ISandboxOptions): SandboxProcess {\n  if (hasRunsc) {\n    return gvisor(options);\n  } else if (hasSandboxExec) {\n    return macSandboxExec(options);\n  }\n  return pyodide(options);\n}\n\n/*\n * Helper function to run python without sandboxing.  GRIST_SANDBOX should have\n * been set with an absolute path to a version of python within a virtualenv that\n * has all the dependencies installed (e.g. the sandbox_venv3 virtualenv created\n * by `./build python3`.  Using system python works too, if all dependencies have\n * been installed globally.\n */\nfunction unsandboxed(options: ISandboxOptions): SandboxProcess {\n  const { testSandboxArgs, testPythonArgs, appendArgs, importDir } = options;\n  const paths = getAbsolutePaths(options);\n\n  const commandArgs = [\n    // No sandbox here, so apply the sandbox args to Python instead of ignoring them.\n    ...testSandboxArgs,\n    ...testPythonArgs,\n    ...(options.useGristEntrypoint !== false ? [paths.main] : []),\n    ...(options.comment ? [options.comment] : []),\n    ...(appendArgs ?? []),\n  ];\n\n  const spawnOptions = {\n    stdio: [\"pipe\", \"pipe\", \"pipe\"] as \"pipe\"[],\n    env: {\n      PYTHONPATH: paths.engine,\n      IMPORTDIR: importDir,\n      ...getInsertedEnv(options),\n      ...getWrappingEnv(options),\n    },\n  };\n  if (!options.minimalPipeMode) {\n    spawnOptions.stdio.push(\"pipe\", \"pipe\");\n  }\n  const command = findPython(options.command);\n  const child = adjustedSpawn(command, commandArgs,\n    { cwd: path.join(process.cwd(), \"sandbox\"), ...spawnOptions });\n  return {\n    name: \"unsandboxed\",\n    child,\n    control: () => new DirectProcessControl(child, options.logMeta),\n  };\n}\n\nfunction pyodide(options: ISandboxOptions): SandboxProcess {\n  const pyodideSettings = getPyodideSettings(options);\n  const {\n    cwd, dataFromSandboxDescriptor, dataToSandboxDescriptor, scriptPath, stdio,\n  } = pyodideSettings;\n\n  if (options.minimalPipeMode === false) {\n    throw new Error(\"pyodide only supports 3-pipe operation\");\n  }\n  options.minimalPipeMode = true;\n\n  const paths = getAbsolutePaths(options);\n  // We will fork with three regular pipes (stdin, stdout, stderr), then\n  // ipc (mandatory for calling fork), and a replacement pipe for stdin\n  // and for stdout.\n  // The regular stdin always opens non-blocking in node, which is a pain\n  // in this case, so we just use a different pipe. There's a different\n  // problem with stdout, with the same solution.\n  const spawnOptions = {\n    stdio,\n    env: {\n      PYTHONPATH: paths.engine,\n      IMPORTDIR: options.importDir,\n      // If running in electron, forces the child process to behave as plain Node.js (no Chromium or browser)\n      ELECTRON_RUN_AS_NODE: \"1\",\n      ...getInsertedEnv(options),\n      ...getWrappingEnv(options),\n    },\n  };\n\n  let child: ChildProcess;\n\n  const command = options.command ?? pyodideSettings.command;\n  if (command) {\n    const args = [\n      ...pyodideSettings.args,\n\n      ...options.testSandboxArgs,\n      // Ignore options.pythonArgs - no python process runs for pyodide\n      \"--\",\n      scriptPath,\n      ...(options.comment ? [options.comment] : []),\n      ...(options.appendArgs ?? []),\n    ];\n    log.rawDebug(\"Launching Pyodide sandbox via spawn\", { command: options.command, args, cwd, spawnOptions });\n    child = spawn(\n      command,\n      args,\n      { cwd, ...spawnOptions },\n    );\n  } else {\n    log.rawDebug(\"Launching Pyodide sandbox via fork\", { scriptPath, cwd, spawnOptions });\n    child = fork(\n      scriptPath,\n      { cwd, ...spawnOptions },\n    );\n  }\n\n  return {\n    name: \"pyodide\",\n    child,\n    control: () => new DirectProcessControl(child, options.logMeta),\n    dataToSandboxDescriptor,\n    dataFromSandboxDescriptor,\n  };\n}\n\n/**\n * Helper function to run python in gvisor's runsc, with multiple\n * sandboxes run within the same container.  GRIST_SANDBOX should\n * point to `sandbox/gvisor/run.py` (to map call onto gvisor's runsc\n * directly) or `wrap_in_docker.sh` (to use runsc within a container).\n * Be sure to read setup instructions in that directory.\n */\nfunction gvisor(options: ISandboxOptions): SandboxProcess {\n  let command = options.command;\n  if (!command) {\n    try {\n      // If runsc is available directly on the host, use the wrapper\n      // utility in sandbox/gvisor/run.py to run it.\n      which.sync(\"runsc\");\n      command = \"sandbox/gvisor/run.py\";\n    } catch (e) {\n      // Otherwise, don't try any heroics, user will need to\n      // explicitly set the command.\n      throw new Error(\"runsc not found\");\n    }\n  }\n  if (options.minimalPipeMode === false) {\n    throw new Error(\"gvisor only supports 3-pipe operation\");\n  }\n  const paths = getAbsolutePaths(options);\n  const wrapperArgs = new FlagBag({ env: \"-E\", mount: \"-m\" });\n  wrapperArgs.push(...options.testSandboxArgs);\n  wrapperArgs.addEnv(\"PYTHONPATH\", paths.engine);\n  wrapperArgs.addAllEnv(getInsertedEnv(options));\n  wrapperArgs.addMount(paths.sandboxDir);\n  if (paths.importDir) {\n    wrapperArgs.addMount(paths.importDir);\n    wrapperArgs.addEnv(\"IMPORTDIR\", paths.importDir);\n  }\n  if (options.deterministicMode) {\n    wrapperArgs.push(\"--faketime\", FAKETIME);\n  }\n\n  // Check for local virtual environments created with core's\n  // install:python3 targets. They'll need\n  // some extra sharing to make available in the sandbox.\n  const venv = path.join(getAppRootFor(getAppRoot(), \"sandbox\"), \"sandbox_venv3\");\n  if (fs.existsSync(venv)) {\n    wrapperArgs.addMount(venv);\n    wrapperArgs.push(\"-s\", path.join(venv, \"bin\", \"python\"));\n  }\n\n  const pythonArgs = [\n    ...options.testPythonArgs,\n    ...(options.useGristEntrypoint !== false ? [paths.main] : []),\n  ];\n\n  const appendArgs = [\n    ...(options.comment ? [options.comment] : []),\n    ...(options.appendArgs ?? []),\n  ];\n\n  // For a regular sandbox not being used for importing, if GRIST_CHECKPOINT is set\n  // try to restore from it. If GRIST_CHECKPOINT_MAKE is set, try to recreate the\n  // checkpoint (this is an awkward place to do it, but avoids mismatches\n  // between the checkpoint and how it gets used later).\n  // If a sandbox is being used for import, it will have a special mount we can't\n  // deal with easily right now. Should be possible to do in future if desired.\n  if (options.useGristEntrypoint !== false && process.env.GRIST_CHECKPOINT &&\n    !paths.importDir) {\n    if (process.env.GRIST_CHECKPOINT_MAKE) {\n      const child =\n        adjustedSpawn(command, [...wrapperArgs.get(), \"--checkpoint\", process.env.GRIST_CHECKPOINT,\n          `python3`, \"--\", ...pythonArgs, ...appendArgs]);\n      // We don't want process control for this.\n      return { name: \"gvisor\", child, control: () => new NoProcessControl(child) };\n    }\n    wrapperArgs.push(\"--restore\");\n    wrapperArgs.push(process.env.GRIST_CHECKPOINT);\n  }\n  const child = adjustedSpawn(command, [...wrapperArgs.get(), `python3`, \"--\", ...pythonArgs, ...appendArgs]);\n  const childPid = child.pid;\n  if (!childPid) {\n    throw new Error(`failed to spawn python3`);\n  }\n\n  // For gvisor under ptrace, main work is done by a traced process identifiable as\n  // being labeled \"exe\" and having a parent also labeled \"exe\".\n  const recognizeTracedProcess = (p: ProcessInfo) => {\n    return p.label.includes(\"exe\") && p.parentLabel.includes(\"exe\");\n  };\n  // The traced process is managed by a regular process called \"runsc-sandbox\"\n  const recognizeSandboxProcess = (p: ProcessInfo) => {\n    return p.label.includes(\"runsc-sandbox\");\n  };\n  // If docker is in use, this process control will log a warning message and do nothing.\n  return {\n    name: \"gvisor\",\n    child,\n    control: () => new SubprocessControl({\n      pid: childPid,\n      recognizers: {\n        sandbox: recognizeSandboxProcess,   // this process we start and stop\n        memory: recognizeTracedProcess,     // measure memory for the ptraced process\n        cpu: recognizeTracedProcess,        // measure cpu for the ptraced process\n        traced: recognizeTracedProcess,     // the ptraced process\n      },\n      logMeta: options.logMeta,\n    }),\n  };\n}\n\n/**\n * Helper function to run python in a container. Each sandbox run in a\n * distinct container.  GRIST_SANDBOX should be the name of an image where\n * `python` can be run and all Grist dependencies are installed.  See\n * `sandbox/docker` for more.\n */\nfunction docker(options: ISandboxOptions): SandboxProcess {\n  const { command } = options;\n  if (options.minimalPipeMode === false) {\n    throw new Error(\"docker only supports 3-pipe operation (although runc has --preserve-file-descriptors)\");\n  }\n  const paths = getAbsolutePaths(options);\n  const wrapperArgs = new FlagBag({ env: \"--env\", mount: \"-v\" });\n  wrapperArgs.push(...options.testSandboxArgs);\n  if (paths.importDir) {\n    wrapperArgs.addMount(`${paths.importDir}:/importdir:ro`);\n  }\n  wrapperArgs.addMount(`${paths.engine}:/grist:ro`);\n  wrapperArgs.addAllEnv(getInsertedEnv(options));\n  wrapperArgs.addEnv(\"PYTHONPATH\", \"grist:thirdparty\");\n\n  const commandParts = [\n    // DETERMINISTIC_MODE is already set by getInsertedEnv().  We also take\n    // responsibility here for running faketime around python.\n    ...(options.deterministicMode ? [\"faketime\", \"-f\", FAKETIME] : []),\n    \"python\",\n  ];\n\n  const pythonArgs = [\n    ...options.testPythonArgs,\n    ...(options.useGristEntrypoint !== false ? [\"grist/main.py\"] : []),\n  ];\n\n  const appendArgs = [\n    ...(options.comment ? [options.comment] : []),\n    ...(options.appendArgs ?? []),\n  ];\n\n  const dockerPath = which.sync(\"docker\");\n  const child = spawn(dockerPath, [\n    \"run\", \"--rm\", \"-i\", \"--network\", \"none\",\n    ...wrapperArgs.get(),\n    command || \"grist-docker-sandbox\",  // this is the docker image to use\n    ...commandParts,\n    ...pythonArgs,\n    ...appendArgs,\n  ]);\n  log.rawDebug(\"cannot do process control via docker yet\", { ...options.logMeta });\n  return { name: \"docker\", child, control: () => new NoProcessControl(child) };\n}\n\n/**\n * Helper function to run python using the sandbox-exec command\n * available on MacOS.  This command is a bit shady - not much public\n * documentation for it, and what there is has been marked deprecated\n * for a few releases.  But mac sandboxing seems to rely heavily on\n * the infrastructure this command is a thin wrapper around, and there's\n * no obvious native sandboxing alternative.\n */\nfunction macSandboxExec(options: ISandboxOptions): SandboxProcess {\n  if (options.minimalPipeMode === false) {\n    throw new Error(\"macSandboxExec flavor only supports 3-pipe operation\");\n  }\n  const paths = getAbsolutePaths(options);\n\n  const env = {\n    PYTHONPATH: paths.engine,\n    IMPORTDIR: paths.importDir,\n    ...getInsertedEnv(options),\n    ...getWrappingEnv(options),\n  };\n  const command = findPython(options.command);\n  const realPath = realpathSync(command);\n  log.rawDebug(\"macSandboxExec found a python\", { ...options.logMeta, command: realPath });\n\n  // Prepare sandbox profile\n  const profile: string[] = [];\n\n  // Deny everything by default, including network\n  profile.push(\"(version 1)\", \"(deny default)\");\n\n  // Allow execution of the command, either by name provided or ultimate symlink if different\n  profile.push(`(allow process-exec (literal ${JSON.stringify(command)}))`);\n  profile.push(`(allow process-exec (literal ${JSON.stringify(realPath)}))`);\n\n  // There are now a series of extra read and execute permissions added, to deal with the\n  // twisted maze of symlinks around python on a mac.\n\n  // For python symlinks to work, we need to allow reading all the intermediate directories\n  // (this is determined experimentally, perhaps it can be more precise).\n  const intermediatePaths = new Set<string>();\n  for (const target of [command, realPath]) {\n    const parts = path.dirname(target).split(path.sep);\n    for (let i = 1; i < parts.length; i++) {\n      const p = path.join(\"/\", ...parts.slice(0, i));\n      intermediatePaths.add(p);\n    }\n  }\n  for (const p of intermediatePaths) {\n    profile.push(`(allow file-read* (literal ${JSON.stringify(p)}))`);\n  }\n\n  // Grant read access to everything within an enclosing bin directory of original command.\n  if (path.dirname(command).split(path.sep).pop() === \"bin\") {\n    const p = path.join(path.dirname(command), \"..\");\n    profile.push(`(allow file-read* (subpath ${JSON.stringify(p)}))`);\n  }\n\n  // Grant read+execute access to everything within an enclosing bin directory of final target.\n  if (path.dirname(realPath).split(path.sep).pop() === \"bin\") {\n    const p = path.join(path.dirname(realPath), \"..\");\n    profile.push(`(allow file-read* (subpath ${JSON.stringify(p)}))`);\n    profile.push(`(allow process-exec (subpath ${JSON.stringify(p)}))`);\n  }\n\n  // Sundry extra permissions that proved necessary. These work at the time of writing for\n  // python versions installed by brew. Other arrangements could need tweaking.\n  profile.push(`(allow file-read* (subpath \"/usr/local/\"))`);\n  profile.push(`(allow file-read* (subpath \"/opt/homebrew/\"))`);\n  profile.push(\"(allow sysctl-read)\");  // needed for os.uname()\n  // From another python installation variant.\n  profile.push(`(allow file-read* (subpath \"/usr/lib/\"))`);\n  profile.push(`(allow file-read* (subpath \"/System/Library/Frameworks/\"))`);\n  profile.push(`(allow file-read* (subpath \"/Library/Apple/usr/libexec/oah/\"))`);\n\n  // Give access to Grist material.\n  const cwd = path.join(process.cwd(), \"sandbox\");\n  profile.push(`(allow file-read* (subpath ${JSON.stringify(paths.sandboxDir)}))`);\n  profile.push(`(allow file-read* (subpath ${JSON.stringify(cwd)}))`);\n  if (options.importDir) {\n    profile.push(`(allow file-read* (subpath ${JSON.stringify(paths.importDir)}))`);\n  }\n\n  const pythonArgs = [\n    ...options.testPythonArgs,\n    ...(options.useGristEntrypoint !== false ? [paths.main] : []),\n  ];\n\n  const appendArgs = [\n    ...(options.comment ? [options.comment] : []),\n    ...(options.appendArgs ?? []),\n  ];\n\n  const profileString = profile.join(\"\\n\");\n  const child = spawn(\"/usr/bin/sandbox-exec\",\n    [...options.testSandboxArgs, \"-p\", profileString, command, ...pythonArgs, ...appendArgs],\n    { cwd, env });\n  return {\n    name: \"macSandboxExec\",\n    child,\n    control: () => new DirectProcessControl(child, options.logMeta),\n  };\n}\n\n/**\n * Collect environment variables that should end up set within the sandbox.\n */\nexport function getInsertedEnv(options: ISandboxOptions) {\n  const env: NodeJS.ProcessEnv = {\n    // use stdin/stdout/stderr only.\n    PIPE_MODE: (options.minimalPipeMode !== false) ? \"minimal\" : \"classic\",\n  };\n\n  if (options.deterministicMode) {\n    // Making time and randomness act deterministically for testing purposes.\n    // See test/utils/recordPyCalls.ts\n    // tells python to seed the random module\n    env.DETERMINISTIC_MODE = \"1\";\n  }\n\n  if (process.env.GRIST_TRUTHY_VALUES) {\n    env.GRIST_TRUTHY_VALUES = process.env.GRIST_TRUTHY_VALUES;\n  }\n\n  if (process.env.GRIST_FALSY_VALUES) {\n    env.GRIST_FALSY_VALUES = process.env.GRIST_FALSY_VALUES;\n  }\n\n  return env;\n}\n\n/**\n * Collect environment variables to activate faketime if needed.  The paths\n * here only make sense for unsandboxed operation. For gvisor,\n * faketime doesn't work, and must be done inside the sandbox.  For docker,\n * likewise wrapping doesn't make sense.  In those cases, LIBFAKETIME_PATH can\n * just be set to ON to activate faketime in a sandbox dependent manner.\n */\nfunction getWrappingEnv(options: ISandboxOptions) {\n  const env: NodeJS.ProcessEnv = options.deterministicMode ? {\n    // Making time and randomness act deterministically for testing purposes.\n    // See test/utils/recordPyCalls.ts\n    FAKETIME,  // setting for libfaketime\n    // For Linux\n    LD_PRELOAD: process.env.LIBFAKETIME_PATH,\n\n    // For Mac (https://github.com/wolfcw/libfaketime/blob/master/README.OSX)\n    DYLD_INSERT_LIBRARIES: process.env.LIBFAKETIME_PATH,\n    DYLD_FORCE_FLAT_NAMESPACE: \"1\",\n  } : {};\n  return env;\n}\n\n/**\n * Extract absolute paths from options.  By sticking with the directory\n * structure on the host rather than remapping, we can simplify nesting\n * wrappers, or cases where remapping isn't possible.  It does leak the names\n * of the host directories though, and there could be silly complications if the\n * directories have spaces or other idiosyncrasies.  When committing to a sandbox\n * technology, for stand-alone Grist, it would be worth rethinking this.\n */\nfunction getAbsolutePaths(options: ISandboxOptions) {\n  // Get path to sandbox directory - this is a little idiosyncratic to work well\n  // in grist-core.  It is important to use real paths since we may be viewing\n  // the file system through a narrow window in a container.\n  const sandboxDir = path.join(realpathSync(path.join(process.cwd(), \"sandbox\", \"grist\")),\n    \"..\");\n  // Copy plugin options, and then make them absolute.\n  if (options.importDir) {\n    options.importDir = realpathSync(options.importDir);\n  }\n  return {\n    sandboxDir,\n    importDir: options.importDir,\n    main: path.join(sandboxDir, \"grist/main.py\"),\n    engine: path.join(sandboxDir, \"grist\"),\n  };\n}\n\n/**\n * A tiny abstraction to make code setting up command line arguments a bit\n * easier to read.  The sandboxes are quite similar in spirit, but differ\n * a bit in exact flags used.\n */\nclass FlagBag {\n  private _args: string[] = [];\n\n  constructor(private _options: { env: \"--env\" | \"-E\", mount: \"-m\" | \"-v\" }) {\n  }\n\n  // channel env variables for sandbox via -E / --env\n  public addEnv(key: string, value: string | undefined) {\n    this._args.push(this._options.env, key + \"=\" + (value || \"\"));\n  }\n\n  // Channel all of the supplied env variables\n  public addAllEnv(env: NodeJS.ProcessEnv) {\n    for (const [key, value] of _.toPairs(env)) {\n      this.addEnv(key, value);\n    }\n  }\n\n  // channel shared directory for sandbox via -m / -v\n  public addMount(share: string) {\n    this._args.push(this._options.mount, share);\n  }\n\n  // add some ad-hoc arguments\n  public push(...args: string[]) {\n    this._args.push(...args);\n  }\n\n  // get the final list of arguments\n  public get() { return this._args; }\n}\n\n// Standard time to default to if faking time.\nconst FAKETIME = \"2020-01-01 00:00:00\";\n\n/**\n * Find a plausible version of python to run, if none provided.\n * The preferred version is only used if command is not specified.\n */\nfunction findPython(command: string | undefined): string {\n  if (command) { return command; }\n  // No command specified.  In this case, grist-core looks for a \"venv\"\n  // virtualenv; a python3 virtualenv would be in \"sandbox_venv3\".\n  // TODO: rationalize this, it is a product of haphazard growth.\n  const prefs = [\"sandbox_venv3\"];\n  for (const venv of prefs) {\n    const base = getUnpackedAppRoot();\n    // Try a battery of possible python executable paths when python is installed\n    // in a standalone directory.\n    // This battery of possibilities comes from Electron packaging, where python\n    // is bundled with Grist. Not all the possibilities are needed (there are\n    // multiple popular python bundles per OS).\n    for (const possiblePath of [[\"bin\", \"python\"], [\"bin\", \"python3\"],\n      [\"Scripts\", \"python.exe\"], [\"python.exe\"]] as const) {\n      const pythonPath = path.join(base, venv, ...possiblePath);\n      if (fs.existsSync(pythonPath)) {\n        return pythonPath;\n      }\n    }\n  }\n  // Fall back on system python.\n  const systemPrefs = [\"3.11\", \"3.10\", \"3.9\", \"3\", \"\"];\n  for (const version of systemPrefs) {\n    const pythonPath = which.sync(`python${version}`, { nothrow: true });\n    if (pythonPath) {\n      return pythonPath;\n    }\n  }\n  throw new Error(\"Cannot find Python\");\n}\n\nfunction getCommandFromEnv(pythonVersion?: string) {\n  return process.env[\"GRIST_SANDBOX\" + (pythonVersion || \"\")] ||\n    process.env.GRIST_SANDBOX;\n}\n\nfunction getCommandArgsFromEnv() {\n  const argsString = process.env.GRIST_TEST_SANDBOX_ARGS;\n  const extraArgsString = process.env.GRIST_SANDBOX_APPEND_ARGS;\n  return {\n    args: argsString ? argsString.split(\" \") : [],\n    extraArgs: extraArgsString ? extraArgsString.split(\" \") : [],\n  };\n}\n\n/**\n * Create a sandbox. The defaultFlavorSpec is a guide to which sandbox\n * to create, based on the desired python version. Examples:\n *   unsandboxed               # no sandboxing\n *   2:gvisor                  # run python3 in gvisor\n *   3:macSandboxExec,docker   # run python3 with sandbox-exec, anything else in docker\n * If no particular python version is desired, the first sandbox listed will be used.\n * The defaultFlavorSpec can be overridden by GRIST_SANDBOX_FLAVOR.\n * The commands run can be overridden by GRIST_SANDBOX2 (for python2), GRIST_SANDBOX3 (for python3),\n * or GRIST_SANDBOX (for either, if more specific variable is not specified).\n * For documents with no preferred python version specified, 3 is used\n * TODO: This machinery can likely be removed now.\n */\nexport function createSandbox(defaultFlavorSpec: string, options: ISandboxCreationOptions): ISandbox {\n  const flavors = (process.env.GRIST_SANDBOX_FLAVOR || defaultFlavorSpec).split(\",\");\n  const preferredPythonVersion = options.preferredPythonVersion || \"3\";\n  for (const flavorAndVersion of flavors) {\n    const parts = flavorAndVersion.trim().split(\":\", 2);\n    const flavor = parts[parts.length - 1];\n    const version = parts.length === 2 ? parts[0] : \"*\";\n    if (preferredPythonVersion === version || version === \"*\" || !preferredPythonVersion) {\n      const args = getCommandArgsFromEnv();\n      const creator = new NSandboxCreator({\n        defaultFlavor: flavor,\n        command: getCommandFromEnv(preferredPythonVersion),\n        commandArgs: args.args,\n        commandAppendArgs: args.extraArgs,\n        preferredPythonVersion,\n      });\n      return creator.create(options);\n    }\n  }\n  throw new Error(\"Failed to create a sandbox\");\n}\n\n/**\n * The realpath function may not be available, just return the\n * path unchanged if it is not. Specifically, this happens when\n * compiled for use in a browser environment.\n */\nfunction realpathSync(src: string) {\n  try {\n    return fs.realpathSync(src);\n  } catch (e) {\n    return src;\n  }\n}\n\nfunction adjustedSpawn(cmd: string, args: string[], options?: SpawnOptionsWithoutStdio) {\n  const oomScoreAdj = process.env.GRIST_SANDBOX_OOM_SCORE_ADJ;\n  if (oomScoreAdj) {\n    return spawn(\"choom\", [\"-n\", oomScoreAdj, \"--\", cmd, ...args], options);\n  } else {\n    return spawn(cmd, args, options);\n  }\n}\n\nfunction checkCommandExists(cmd: string) {\n  try {\n    which.sync(cmd);\n    return true;\n  } catch (e) {\n    if (!String(e).match(/not found/)) {\n      throw e;\n    }\n    return false;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/NullSandbox.ts",
    "content": "import { ISandbox } from \"app/server/lib/ISandbox\";\n\nexport class UnavailableSandboxMethodError extends Error {\n  constructor(message: string) {\n    super(message);\n  }\n}\n\nexport class NullSandbox implements ISandbox {\n  public async shutdown(): Promise<unknown> {\n    throw new UnavailableSandboxMethodError(\"shutdown is not available\");\n  }\n\n  public async pyCall(_funcName: string, ..._varArgs: unknown[]) {\n    throw new UnavailableSandboxMethodError(\"pyCall is not available\");\n  }\n\n  public async reportMemoryUsage(): Promise<never> {\n    throw new UnavailableSandboxMethodError(\"reportMemoryUsage is not available\");\n  }\n\n  public getFlavor() {\n    return \"null\";\n  }\n\n  public isProcessDown() { return true; }\n}\n"
  },
  {
    "path": "app/server/lib/OAuth2Clients.ts",
    "content": "/**\n * The server-side support for integration as an OAuth2 client. This is when Grist is registered\n * with another service with a clientId and clientSecret. We provide endpoints:\n *  1. For the client to start the authorization flow: /oauth2/:integration/authorize.\n *  2. For the service to redirect back to: /oauth2/:integration/callback (this should typically\n *     get registered with the service)\n *  3. To get existing tokens, if any: /oauth2/:integration/tokens.\n *\n * Note that each integration requires a clientId and a clientSecret to be provided via\n * environment variables. Without that, it's unconfigured and can't be used. Grist client can\n * check for that using the /tokens endpoint, which returns different errors.\n *\n * TODO The current implementation stores access_token (as well as refresh_token, if provided) in\n * the active user's session, and returns it to the browser. In the future, we probably want to\n * allow the user to choose to store these with an org or with a document, to enable an\n * integration for others. We'll probably need to proxy requests to the resource provider through\n * the server, rather than share access token with the browser: because access token is not OK to\n * expose to other users, because CORS may block direct requests from browser, and because\n * proxying seems to be the recommended pattern: see\n * https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#name-backend-for-frontend-bff.\n */\nimport { ApiError } from \"app/common/ApiError\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { User } from \"app/common/User\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { getAuthorizedUserId, RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { SessionOIDCInfo, SessionUserObj } from \"app/server/lib/BrowserSession\";\nimport { expressWrap, secureJsonErrorHandler } from \"app/server/lib/expressWrap\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport { ProtectionsManager } from \"app/server/lib/oidc/Protections\";\nimport { agents } from \"app/server/lib/ProxyAgent\";\nimport { allowHost, stringParam } from \"app/server/lib/requestUtils\";\n\nimport express from \"express\";\nimport pick from \"lodash/pick\";\nimport openidClient, { Client, ClientMetadata, Issuer, IssuerMetadata, TokenSet } from \"openid-client\";\n\ntype IntegrationId = \"airtable\" | \"smartsheet\";\n\n/**\n * For the sake of tests, we can override any integration's settings. For example:\n * {\"airtable\": {\"issuerMetadata\": {\"authorization_endpoint\": ...}}}\n */\nconst testOverrides = safeJsonParse(process.env.TEST_GRIST_OAUTH2_CLIENTS_OVERRIDES || \"\", null);\n\n/**\n * The REDIRECT URI to register with the other service is {redirectHost}/oauth2/{integrationId}/callback.\n */\nconst redirectHost = () => appSettings.section(\"oauth2\").flag(\"redirectHost\").readString({\n  envVar: \"OAUTH2_GRIST_HOST\",\n  defaultValue: process.env.APP_HOME_URL,   // NOTE: This variable may not be set.\n});\n\n/**\n * Each supported integration is defined using IntegrationSpec.\n * - All integrations share a few endpoints:\n *      GET /oauth2/:integration/(authorize|callback|token)\n *      DELETE /oauth2/:integration/token\n * - The REDIRECT URI to register with the other service is {redirectHost}/oauth2/{integrationId}/callback.\n * - Each integration specifies the env vars to hold clientId and clientSecret.\n * - OAuth2 session state is in {flow, tokenSet} object in session.oauth2[integration].\n * - Each integration defines protections to use ([\"PKCE\", \"STATE] is recommended).\n * - Each integration specifies the scope to ask for (depends on service).\n * - After initialization, `.client` is set if suitable env vars were found, or null otherwise.\n */\ninterface Integration {\n  id: IntegrationId;\n  name: string;\n  envVarClientId: string;\n  envVarClientSecret: string;\n  issuerMetadata: IssuerMetadata;\n  extraMetadata?: Partial<ClientMetadata>;\n  protections: ProtectionsManager;\n  scope: string;\n  redirectUri: string;\n  client?: Client | null;\n}\n\ninterface ValidIntegration extends Integration {\n  client: Client;\n}\n\nconst INTEGRATIONS = new Map<IntegrationId, Integration>();\n\nfunction initializeIntegrations() {\n  const spHost = redirectHost();\n  if (!spHost) {\n    log.warn(\"OAuth2 clients require OAUTH2_GRIST_HOST or APP_HOME_URL to be set\");\n    return;\n  }\n  const redirectUri = (id: IntegrationId) =>\n    new URL(OAUTH2_ENDPOINTS.callback.replace(\":integration\", id), spHost).href;\n\n  // Airtable OAuth reference: https://airtable.com/developers/web/api/oauth-reference. It asks\n  // for both STATE and PKCE protections.\n  INTEGRATIONS.set(\"airtable\", initIntegration({\n    id: \"airtable\",\n    name: \"Airtable\",\n    envVarClientId: \"OAUTH2_AIRTABLE_CLIENT_ID\",\n    envVarClientSecret: \"OAUTH2_AIRTABLE_CLIENT_SECRET\",\n    issuerMetadata: {\n      issuer: \"airtable\",\n      authorization_endpoint: \"https://airtable.com/oauth2/v1/authorize\",\n      token_endpoint: \"https://airtable.com/oauth2/v1/token\",\n    },\n    protections: new ProtectionsManager(new Set([\"PKCE\", \"STATE\"])),\n    scope: \"data.records:read schema.bases:read\",\n    redirectUri: redirectUri(\"airtable\"),\n  }));\n\n  // Smartsheet OAuth: https://developers.smartsheet.com/api/smartsheet/guides/advanced-topics/oauth.\n  // It only seems to support STATE (not PKCE) protection. It's /token endpoint needs client\n  // secret included in the body of the post (hence \"client_secret_post\" for token auth method).\n  INTEGRATIONS.set(\"smartsheet\", initIntegration({\n    id: \"smartsheet\",\n    name: \"Smartsheet\",\n    envVarClientId: \"OAUTH2_SMARTSHEET_CLIENT_ID\",\n    envVarClientSecret: \"OAUTH2_SMARTSHEET_CLIENT_SECRET\",\n    issuerMetadata: {\n      issuer: \"smartsheet\",\n      authorization_endpoint: \"https://app.smartsheet.com/b/authorize\",\n      token_endpoint: \"https://api.smartsheet.com/2.0/token\",\n    },\n    extraMetadata: {\n      token_endpoint_auth_method: \"client_secret_post\",\n    },\n    protections: new ProtectionsManager(new Set([\"STATE\"])),\n    scope: \"READ_SHEETS\",\n    redirectUri: redirectUri(\"smartsheet\"),\n  }));\n}\n\nfunction initIntegration(spec: Integration): Integration {\n  const section = appSettings.section(\"oauth2\").section(spec.id);\n  const clientId = section.flag(\"clientId\").readString({\n    envVar: spec.envVarClientId,\n  });\n\n  let client: Client | null = null;\n  if (clientId) {\n    const clientSecret = section.flag(\"clientSecret\").requireString({\n      envVar: spec.envVarClientSecret,\n      censor: true,\n    });\n    if (testOverrides?.[spec.id]) {\n      spec = { ...spec, ...testOverrides[spec.id] };\n    }\n    const issuer = new Issuer(spec.issuerMetadata);\n\n    client = new issuer.Client({\n      client_id: clientId,\n      client_secret: clientSecret,\n      redirect_uris: [spec.redirectUri],\n      response_types: [\"code\"],\n      ...spec.extraMetadata,\n    });\n  }\n  return { ...spec, client };\n}\n\ninterface IntegrationSessionInfo {\n  flow?: SessionOIDCInfo;\n  // We need to postMessage from spHost origin to the origin of the opening page, which may be\n  // different when running in an environment with subdomains. We remember it temporarily here.\n  openerOrigin?: string;\n  tokenSet?: TokenSet;\n}\n\ninterface SessionUserObjWithOAuth extends SessionUserObj {\n  oauth2?: { [key: string]: IntegrationSessionInfo };\n}\n\nconst OAUTH2_ENDPOINTS = {\n  authorize: \"/oauth2/:integration/authorize\", // Client should open a window with this URL.\n  callback: \"/oauth2/:integration/callback\", // To register with authorization server as redirectUri.\n  token: \"/oauth2/:integration/token\", // Get stored tokens; 400=not configured, 401=no valid tokens.\n};\n\nexport class OAuth2Clients {\n  private _sessions = this._gristServer.getSessions();\n\n  constructor(private _gristServer: GristServer) {\n    // Tell openid-client library to use the trusted proxy agent if one is configured.\n    if (agents.trusted) {\n      openidClient.custom.setHttpOptionsDefaults({ agent: agents.trusted });\n    }\n    initializeIntegrations();\n  }\n\n  public getValidClients(): IntegrationId[] {\n    return [...INTEGRATIONS.values()].filter(it => it.client).map(it => it.id);\n  }\n\n  public attachEndpoints(app: express.Express, middleware: express.RequestHandler[]) {\n    /**\n     * Redirect to the resource provider's authorization endpoint.\n     */\n    app.get(OAUTH2_ENDPOINTS.authorize, middleware, expressWrap(async (req, res) => {\n      const openerOrigin = stringParam(req.query.openerOrigin, \"openerOrigin\");\n      if (openerOrigin && !allowHost(req, new URL(openerOrigin))) {\n        // This is outside try/catch because a problem with the origin can't be reported back to the opener.\n        throw new ApiError(\"Untrusted opener origin\", 403);\n      }\n      try {\n        assertUserIsAuthorized(req);\n        const { id, client, protections, scope } = this._getIntegration(req.params.integration);\n        const session = this._sessions.getOrCreateSessionFromRequest(req);\n        const flowInfo = protections.generateSessionInfo();\n        await session.operateOnScopedSession(req, async (user: SessionUserObjWithOAuth) => {\n          const previousInfo = user.oauth2?.[id] || {};\n          user.oauth2 = { ...user.oauth2, [id]: { ...previousInfo, openerOrigin, flow: flowInfo } };\n          return user;\n        });\n        const authUrl = client.authorizationUrl({\n          scope,\n          ...protections.forgeAuthUrlParams(flowInfo),\n        });\n        res.redirect(authUrl);\n      } catch (e) {\n        log.warn(`OAuth2 authorize error: ${e.message}`);\n        // Short-circuit to the error response we'd get after a redirect, i.e. respond with the\n        // HTML page that closes the popup and sends the error code back to the window opener.\n        const payload = { error: String(e.message) };\n        return res.send(renderEndFlowHtmlTemplate(payload, openerOrigin));\n      }\n    }));\n\n    /**\n     * Receive redirect back from resource provider, to complete authorization code flow and get\n     * access tokens. Returns HTML to close the popup window and post back the credential.\n     */\n    app.get(OAUTH2_ENDPOINTS.callback, middleware, expressWrap(async (req, res) => {\n      assertUserIsAuthorized(req);\n      const { id, name, client, protections, redirectUri } = this._getIntegration(req.params.integration);\n      let payload = {};\n      let openerOrigin: string | undefined;\n      try {\n        const params = client.callbackParams(req);\n        const state = stringParam(params.state, \"state\");\n        const sessionUsers = getRequiredSessionUsers(req);\n        const sessionUser = sessionUsers.find(u => u.oauth2?.[id].flow?.state === state);\n        if (!sessionUser) {\n          throw new Error(\"Session expired\");\n        }\n\n        const userSelector = sessionUser.profile?.email;\n        if (!userSelector) {\n          throw new Error(\"Session missing profile email\");\n        }\n\n        const info = sessionUser.oauth2?.[id];\n        const flow = info?.flow;\n        if (!flow) { throw new Error(`Session missing ${name} info`); }\n        if (!info.openerOrigin) { throw new Error(`Session missing openerOrigin`); }\n        openerOrigin = info.openerOrigin;\n\n        // This is the verification critical to security.\n        const checks = protections.getCallbackChecks(flow);\n\n        const tokenSet = await client.oauthCallback(redirectUri, params, checks);\n\n        // Store the result in the session, dropping other transient info.\n        const session = this._sessions.getOrCreateSession(req.sessionID, undefined, userSelector);\n        await session.operateOnScopedSession(req, async (user: SessionUserObjWithOAuth) => {\n          user.oauth2 = { ...user.oauth2, [id]: { tokenSet } };\n          return user;\n        });\n      } catch (e) {\n        log.warn(`OAuth2 callback error: ${e.message}. Response`, e.response?.body);\n        payload = { error: String(e.message) };\n        // We are normally careful about only posting data back to the opener if that opener is on\n        // an allowed origin (e.g. not evil.com). But in case of an error, we may not have the\n        // origin, and the error message is presumably not so sensitive. So post it regardless.\n        // (It so happens that we now avoid posting anything sensitive in all cases.)\n        if (!openerOrigin) {\n          openerOrigin = \"*\";\n        }\n      }\n\n      // ENDPOINT.authorize redirects through the resource provider to here. The client opens it\n      // in a new window, and here we should serve a page that sends back our redirect result and\n      // closes itself. It needs an actual HTML page.\n      return res.send(renderEndFlowHtmlTemplate(payload, openerOrigin));\n    }));\n\n    app.options(OAUTH2_ENDPOINTS.token, middleware, expressWrap((req, res) => {\n      // For OPTIONS requests, the included trustOriginHandler middleware will actually respond.\n      res.sendStatus(200);\n    }));\n\n    /**\n     * Fetch access token if have one in the session, or 400 if integration is not configured, or\n     * 401 if the token is missing or expired.\n     */\n    app.get(OAUTH2_ENDPOINTS.token, middleware, expressWrap(async (req, res) => {\n      log.warn(`REQ ${req.method} ${req.url} ${req.params.integration}`);\n      assertUserIsAuthorized(req);\n      const { id, name } = this._getIntegration(req.params.integration);\n      const session = this._sessions.getOrCreateSessionFromRequest(req);\n      const sessionUser: SessionUserObjWithOAuth = await session.getScopedSession(req.session);\n      const tokenSet = sessionUser.oauth2?.[id]?.tokenSet;\n      if (!tokenSet) {\n        throw new ApiError(`Not authorized with ${name}`, 401);\n      }\n      if (typeof tokenSet.expires_at === \"number\" && Date.now() / 1000 >= tokenSet.expires_at) {\n        // TODO actually we could (should) attempt to get a new access_token using refresh token\n        // if we have one.\n        throw new ApiError(`${name} token expired`, 401);\n      }\n      res.json(pick(tokenSet, \"access_token\", \"expires_at\"));\n    }), secureJsonErrorHandler);\n\n    /**\n     * Delete the token stored in the session, along with other session material for this integration.\n     */\n    app.delete(OAUTH2_ENDPOINTS.token, middleware, expressWrap(async (req, res) => {\n      assertUserIsAuthorized(req);\n      const id = req.params.integration;\n      const session = this._sessions.getOrCreateSessionFromRequest(req);\n      const sessionUser: SessionUserObjWithOAuth = await session.getScopedSession(req.session);\n      if (sessionUser.oauth2?.[id] !== undefined) {\n        await session.operateOnScopedSession(req, async (user: SessionUserObjWithOAuth) => {\n          delete user.oauth2?.[id];\n          return user;\n        });\n      }\n      res.json(null);\n    }));\n  }\n\n  private _getIntegration(id: string): ValidIntegration {\n    const integration = INTEGRATIONS.get(id as IntegrationId);\n    if (!integration) {\n      throw new ApiError(`Unknown integration ${id}`, 404);\n    }\n    if (!integration.client) {\n      throw new ApiError(`Integration not configured: ${id}`, 400);\n    }\n    return integration as ValidIntegration;\n  }\n}\n\nfunction assertUserIsAuthorized(req: express.Request): asserts req is RequestWithLogin & { user: User } {\n  getAuthorizedUserId(req);\n}\n\nfunction getRequiredSessionUsers(req: express.Request): SessionUserObjWithOAuth[] {\n  const mreq = req as RequestWithLogin;\n  if (!mreq.session) { throw new Error(\"no session available\"); }\n\n  return mreq.session.users ?? [];\n}\n\n// This is a template for a HTML page served at the end of authorization to the popup window. It\n// post the payload back to the window opener, and closes the window. It's tiny enough that it's\n// OK to include the HTML directly into code here.\nconst END_FLOW_HTML_TEMPLATE = `\n<!DOCTYPE html>\n<html>\n<body>\n<script>\n  if (opener) {\n    opener.postMessage({{PAYLOAD}}, {{OPENER_ORIGIN}});\n    close();\n  }\n</script>\n</body>\n</html>\n`;\n\n// We avoid posting sensitive payloads because we don't have to, but in case we need to in the\n// future, we validate that openerOrigin is an allowed origin. Otherwise, evil.com could open our\n// /authorize endpoint and receive back the payload.\nfunction renderEndFlowHtmlTemplate(payload: object, openerOrigin: string) {\n  return END_FLOW_HTML_TEMPLATE\n    .replace(\"{{PAYLOAD}}\", JSON.stringify(payload))\n    .replace(\"{{OPENER_ORIGIN}}\", JSON.stringify(openerOrigin));\n}\n"
  },
  {
    "path": "app/server/lib/OIDCConfig.ts",
    "content": "/**\n * Configuration for OpenID Connect (OIDC), useful for enterprise single-sign-on logins.\n * A good informative overview of OIDC is at https://developer.okta.com/blog/2019/10/21/illustrated-guide-to-oauth-and-oidc\n * Note:\n *    SP is \"Service Provider\", in our case, the Grist application.\n *    IdP is the \"Identity Provider\", somewhere users log into, e.g. Okta or Google Apps.\n *\n * We also use optional attributes for the user's name, for which we accept any of:\n *    given_name + family_name\n *    name\n *\n * Expected environment variables:\n *    env GRIST_OIDC_SP_HOST=https://<your-domain>\n *        Host at which our /oauth2 endpoint will live. Optional, defaults to `APP_HOME_URL`.\n *    env GRIST_OIDC_IDP_ISSUER\n *        The issuer URL for the IdP, passed to node-openid-client, see: https://github.com/panva/node-openid-client/blob/a84d022f195f82ca1c97f8f6b2567ebcef8738c3/docs/README.md#issuerdiscoverissuer.\n *        This variable turns on the OIDC login system.\n *    env GRIST_OIDC_IDP_CLIENT_ID\n *        The client ID for the application, as registered with the IdP.\n *    env GRIST_OIDC_IDP_CLIENT_SECRET\n *        The client secret for the application, as registered with the IdP.\n *    env GRIST_OIDC_IDP_SCOPES\n *        The scopes to request from the IdP, as a space-separated list. Defaults to \"openid email profile\".\n *    env GRIST_OIDC_SP_PROFILE_NAME_ATTR\n *        The key of the attribute to use for the user's name.\n *        If omitted, the name will either be the concatenation of \"given_name\" + \"family_name\" or the \"name\" attribute.\n *    env GRIST_OIDC_SP_PROFILE_EMAIL_ATTR\n *        The key of the attribute to use for the user's email. Defaults to \"email\".\n *    env GRIST_OIDC_IDP_END_SESSION_ENDPOINT\n *        If set, overrides the IdP's end_session_endpoint with an alternative URL to redirect user upon logout\n *        (for an IdP that has a logout endpoint but does not support the OIDC RP-Initiated Logout specification).\n *    env GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT\n *        If set to \"true\", on logout, there won't be any attempt to call the IdP's end_session_endpoint\n *        (the user will remain logged in in the IdP).\n *    env GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED\n *        If set to \"true\", the user will be allowed to login even if the email is not verified by the IDP.\n *        Defaults to false.\n *    env GRIST_OIDC_IDP_ENABLED_PROTECTIONS\n *        A comma-separated list of protections to enable. Supported values are \"PKCE\", \"STATE\", \"NONCE\"\n *        (or you may set it to \"UNPROTECTED\" alone, to disable any protections if you *really* know what you do!).\n *        Defaults to \"PKCE,STATE\", which is the recommended settings.\n *        It's highly recommended that you enable STATE, and at least one of PKCE or NONCE,\n *        depending on what your OIDC provider requires/supports.\n *    env GRIST_OIDC_IDP_ACR_VALUES\n *        A space-separated list of ACR values to request from the IdP. Optional.\n *    env GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA\n *        A JSON object with extra client metadata to pass to openid-client. Optional.\n *        Be aware that setting this object may override any other values passed to the openid client.\n *        More info: https://github.com/panva/node-openid-client/tree/main/docs#new-clientmetadata-jwks-options\n *    env GRIST_OIDC_SP_HTTP_TIMEOUT\n *        The timeout in milliseconds for HTTP requests to the IdP. The default value is set to 3500 by the\n *        openid-client library. See: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing-http-requests\n *\n * This version of OIDCConfig has been tested with Keycloak OIDC IdP following the instructions\n * at:\n *   https://www.keycloak.org/getting-started/getting-started-docker\n *\n * /!\\ CAUTION: For production, be sure to use https for all URLs. /!\\\n *\n * For development of this module on localhost, these settings should work:\n *   - GRIST_OIDC_SP_HOST=http://localhost:8484 (or whatever port you use for Grist)\n *   - GRIST_OIDC_IDP_ISSUER=http://localhost:8080/realms/myrealm (replace 8080 by the port you use for keycloak)\n *   - GRIST_OIDC_IDP_CLIENT_ID=my_grist_instance\n *   - GRIST_OIDC_IDP_CLIENT_SECRET=YOUR_SECRET (as set in keycloak)\n *   - GRIST_OIDC_IDP_SCOPES=\"openid email profile\"\n */\n\nimport { OIDC_PROVIDER_KEY } from \"app/common/loginProviders\";\nimport { UserProfile } from \"app/common/LoginSessionAPI\";\nimport { StringUnionError } from \"app/common/StringUnion\";\nimport { appSettings, AppSettings } from \"app/server/lib/AppSettings\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { SessionObj } from \"app/server/lib/BrowserSession\";\nimport { GristLoginSystem, GristServer } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport { createLoginProviderFactory, NotConfiguredError } from \"app/server/lib/loginSystemHelpers\";\nimport { EnabledProtection, EnabledProtectionString, ProtectionsManager } from \"app/server/lib/oidc/Protections\";\nimport { agents } from \"app/server/lib/ProxyAgent\";\nimport { getOriginUrl } from \"app/server/lib/requestUtils\";\nimport { SendAppPageFunction } from \"app/server/lib/sendAppPage\";\nimport { Sessions } from \"app/server/lib/Sessions\";\n\nimport * as express from \"express\";\nimport pick from \"lodash/pick\";\nimport {\n  Client, ClientMetadata, custom, errors as OIDCError, Issuer, TokenSet, UserinfoResponse,\n} from \"openid-client\";\n\n// OIDC callback endpoint path.\nconst OIDC_CALLBACK_ENDPOINT = \"/oauth2/callback\";\n\nfunction formatTokenForLogs(token: TokenSet) {\n  const showValueInClear = [\"token_type\", \"expires_in\", \"expires_at\", \"scope\"];\n  const result: Record<string, any> = {};\n  for (const [key, value] of Object.entries(token)) {\n    if (typeof value !== \"function\") {\n      result[key] = showValueInClear.includes(key) ? value : \"REDACTED\";\n    }\n  }\n  return result;\n}\n\nclass ErrorWithUserFriendlyMessage extends Error {\n  constructor(errMessage: string, public readonly userFriendlyMessage: string) {\n    super(errMessage);\n  }\n}\n\n/**\n * Interface for OIDC configuration.\n */\nexport interface OIDCConfig {\n  /**\n   * Host at which our /oauth2 endpoint will live (e.g., https://your-domain.com).\n   * Notice: that the configuration reader actually requires this to be set explicitly,\n   * but the OIDCConfig interface allows it to be optional for other providers (like e.g., getgrist.com).\n   */\n  spHost?: string;\n  /** The issuer URL for the IdP (Identity Provider). */\n  readonly issuerUrl: string;\n  /** The client ID for the application, as registered with the IdP. */\n  readonly clientId: string;\n  /** The client secret for the application, as registered with the IdP. */\n  readonly clientSecret: string;\n  /** The timeout in milliseconds for HTTP requests to the IdP. */\n  readonly httpTimeout?: number;\n  /** The key of the attribute to use for the user's name. If omitted, will use given_name + family_name or \"name\". */\n  readonly namePropertyKey?: string;\n  /** The key of the attribute to use for the user's email. */\n  readonly emailPropertyKey: string;\n  /** Alternative URL to redirect user upon logout (overrides the IdP's end_session_endpoint). */\n  readonly endSessionEndpoint: string;\n  /** If true, won't attempt to call the IdP's end_session_endpoint on logout. */\n  readonly skipEndSessionEndpoint: boolean;\n  /** A space-separated list of ACR (Authentication Context Class Reference) values to request from the IdP. */\n  readonly acrValues?: string;\n  /** If true, allows login even if the email is not verified by the IdP. */\n  readonly ignoreEmailVerified: boolean;\n  /** Extra client metadata to pass to openid-client. */\n  readonly extraMetadata: Partial<ClientMetadata>;\n  /** Set of enabled security protections (PKCE, STATE, NONCE). */\n  readonly enabledProtections: Set<EnabledProtectionString>;\n  /** The scopes to request from the IdP, as a space-separated list (e.g., \"openid email profile\"). */\n  readonly scopes: string;\n}\n\n/**\n * Reads OIDC configuration from AppSettings into a JSON structure.\n * Structure follows what is defined in the app settings keys.\n */\nexport function readOIDCConfigFromSettings(settings: AppSettings): OIDCConfig {\n  const section = settings.section(\"login\").section(\"system\").section(OIDC_PROVIDER_KEY);\n\n  let issuerUrl = \"\";\n  try {\n    issuerUrl = section.flag(\"issuer\").requireString({\n      envVar: \"GRIST_OIDC_IDP_ISSUER\",\n    });\n  } catch (e) {\n    throw new NotConfiguredError((e as Error).message);\n  }\n\n  const spHost = section.flag(\"spHost\").requireString({\n    envVar: \"GRIST_OIDC_SP_HOST\",\n    defaultValue: process.env.APP_HOME_URL,\n  });\n\n  const clientId = section.flag(\"clientId\").requireString({\n    envVar: \"GRIST_OIDC_IDP_CLIENT_ID\",\n  });\n\n  const clientSecret = section.flag(\"clientSecret\").requireString({\n    envVar: \"GRIST_OIDC_IDP_CLIENT_SECRET\",\n    censor: true,\n  });\n\n  const httpTimeout = section.flag(\"httpTimeout\").readInt({\n    envVar: \"GRIST_OIDC_SP_HTTP_TIMEOUT\",\n    minValue: 0, // 0 means no timeout\n  });\n\n  const namePropertyKey = section.flag(\"namePropertyKey\").readString({\n    envVar: \"GRIST_OIDC_SP_PROFILE_NAME_ATTR\",\n  });\n\n  const emailPropertyKey = section.flag(\"emailPropertyKey\").requireString({\n    envVar: \"GRIST_OIDC_SP_PROFILE_EMAIL_ATTR\",\n    defaultValue: \"email\",\n  });\n\n  const endSessionEndpoint = section.flag(\"endSessionEndpoint\").readString({\n    envVar: \"GRIST_OIDC_IDP_END_SESSION_ENDPOINT\",\n    defaultValue: \"\",\n  })!;\n\n  const skipEndSessionEndpoint = section.flag(\"skipEndSessionEndpoint\").readBool({\n    envVar: \"GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT\",\n    defaultValue: false,\n  })!;\n\n  const acrValues = section.flag(\"acrValues\").readString({\n    envVar: \"GRIST_OIDC_IDP_ACR_VALUES\",\n  })!;\n\n  const ignoreEmailVerified = section.flag(\"ignoreEmailVerified\").readBool({\n    envVar: \"GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED\",\n    defaultValue: false,\n  })!;\n\n  const extraMetadata = JSON.parse(section.flag(\"extraClientMetadata\").readString({\n    envVar: \"GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA\",\n  }) || \"{}\");\n\n  const enabledProtections = buildEnabledProtections(section);\n\n  const scopes = process.env.GRIST_OIDC_IDP_SCOPES || \"openid email profile\";\n\n  return {\n    spHost,\n    issuerUrl,\n    clientId,\n    clientSecret,\n    httpTimeout,\n    namePropertyKey,\n    emailPropertyKey,\n    endSessionEndpoint,\n    skipEndSessionEndpoint,\n    acrValues,\n    ignoreEmailVerified,\n    extraMetadata,\n    enabledProtections,\n    scopes,\n  };\n}\n\nfunction buildEnabledProtections(section: AppSettings): Set<EnabledProtectionString> {\n  const enabledProtections = section.flag(\"enabledProtections\").readString({\n    envVar: \"GRIST_OIDC_IDP_ENABLED_PROTECTIONS\",\n    defaultValue: \"PKCE,STATE\",\n  })!.split(\",\");\n  if (enabledProtections.length === 1 && enabledProtections[0] === \"UNPROTECTED\") {\n    log.warn(\"You chose to enable OIDC connection with no protection, you are exposed to vulnerabilities.\" +\n      \" Please never do that in production.\");\n    return new Set();\n  }\n  try {\n    return new Set(EnabledProtection.checkAll(enabledProtections));\n  } catch (e) {\n    if (e instanceof StringUnionError) {\n      throw new TypeError(`OIDC: Invalid protection in GRIST_OIDC_IDP_ENABLED_PROTECTIONS: ${e.actual}.` +\n        ` Expected at least one of these values: \"${e.values.join(\",\")}\"`,\n      );\n    }\n    throw e;\n  }\n}\n\nexport class OIDCBuilder {\n  /**\n   * Handy alias to create an OIDCBuilder instance and initialize it.\n   */\n  public static async build(\n    sendAppPage: SendAppPageFunction,\n    config?: OIDCConfig,\n  ): Promise<OIDCBuilder> {\n    const builder = new OIDCBuilder(sendAppPage, config);\n    await builder.initOIDC();\n    return builder;\n  }\n\n  protected _client: Client;\n  private _config: OIDCConfig;\n  private _redirectUrl: string | null;\n  private _protectionManager: ProtectionsManager;\n\n  constructor(\n    private _sendAppPage: SendAppPageFunction,\n    config?: OIDCConfig,\n  ) {\n    // Use provided config or read from global appSettings\n    this._config = config ?? readOIDCConfigFromSettings(appSettings);\n  }\n\n  public async initOIDC(): Promise<void> {\n    this._protectionManager = new ProtectionsManager(this._config.enabledProtections);\n\n    this._redirectUrl = this._config.spHost ? new URL(OIDC_CALLBACK_ENDPOINT, this._config.spHost).href : null;\n    custom.setHttpOptionsDefaults({\n      ...(agents.trusted !== undefined ? { agent: agents.trusted } : {}),\n      ...(this._config.httpTimeout !== undefined ? { timeout: this._config.httpTimeout } : {}),\n    });\n    await this._initClient({\n      issuerUrl: this._config.issuerUrl,\n      clientId: this._config.clientId,\n      clientSecret: this._config.clientSecret,\n      extraMetadata: this._config.extraMetadata,\n    });\n\n    if (this._client.issuer.metadata.end_session_endpoint === undefined &&\n      !this._config.endSessionEndpoint && !this._config.skipEndSessionEndpoint) {\n      throw new Error(\"The Identity provider does not propose end_session_endpoint. \" +\n        \"If that is expected, please set GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true \" +\n        \"or provide an alternative logout URL in GRIST_OIDC_IDP_END_SESSION_ENDPOINT\");\n    }\n    log.info(`OIDCConfig: initialized with issuer ${this._config.issuerUrl}`);\n  }\n\n  public addEndpoints(app: express.Application, sessions: Sessions): void {\n    app.get(OIDC_CALLBACK_ENDPOINT, this.handleCallback.bind(this, sessions));\n  }\n\n  public async handleCallback(sessions: Sessions, req: express.Request, res: express.Response): Promise<void> {\n    let mreq;\n    try {\n      mreq = this._getRequestWithSession(req);\n    } catch (err) {\n      log.warn(\"OIDCConfig callback:\", err.message);\n      return this._sendErrorPage(req, res);\n    }\n\n    let targetUrl: string | undefined;\n\n    try {\n      const params = this._client.callbackParams(req);\n      if (!mreq.session.oidc) {\n        throw new Error(\"Missing OIDC information associated to this session\");\n      }\n\n      targetUrl = mreq.session.oidc.targetUrl;\n\n      const checks = this._protectionManager.getCallbackChecks(mreq.session.oidc);\n\n      // The callback function will compare the protections present in the params and the ones we retrieved\n      // from the session. If they don't match, it will throw an error.\n      const tokenSet = await this._client.callback(this._redirectUrl ?? undefined, params, checks);\n      log.debug(\"Got tokenSet: %o\", formatTokenForLogs(tokenSet));\n\n      const userInfo = await this._client.userinfo(tokenSet);\n      log.debug(\"Got userinfo: %o\", userInfo);\n\n      if (!this._config.ignoreEmailVerified && userInfo.email_verified !== true) {\n        throw new ErrorWithUserFriendlyMessage(\n          `OIDCConfig: email not verified for ${userInfo.email}`,\n          req.t(\"oidc.emailNotVerifiedError\"),\n        );\n      }\n\n      const profile = this._makeUserProfileFromUserInfo(userInfo);\n      log.info(`OIDCConfig: got OIDC response for ${profile.email} (${profile.name}) redirecting to ${targetUrl}`);\n\n      const scopedSession = sessions.getOrCreateSessionFromRequest(req);\n      await scopedSession.operateOnScopedSession(req, async user => Object.assign(user, {\n        profile,\n      }));\n\n      // We clear the previous session info, like the states, nonce or the code verifier, which\n      // now that we are authenticated.\n      // We store the idToken for later, especially for the logout\n      mreq.session.oidc = {\n        idToken: tokenSet.id_token,\n      };\n      res.redirect(targetUrl ?? \"/\");\n    } catch (err) {\n      log.error(`OIDC callback failed: ${err.stack}`);\n      const maybeResponse = this._maybeExtractDetailsFromError(err);\n      if (maybeResponse) {\n        log.error(\"Response received: %o\",  maybeResponse);\n      }\n\n      // Delete entirely the session data when the login failed.\n      // This way, we prevent several login attempts.\n      //\n      // Also session deletion must be done before sending the response.\n      delete mreq.session.oidc;\n\n      await this._sendErrorPage(req, res, err.userFriendlyMessage, targetUrl);\n    }\n  }\n\n  public async getLoginRedirectUrl(req: express.Request, targetUrl: URL): Promise<string> {\n    const mreq = this._getRequestWithSession(req);\n\n    mreq.session.oidc = {\n      targetUrl: targetUrl.href,\n      ...this._protectionManager.generateSessionInfo(),\n    };\n\n    return this._client.authorizationUrl({\n      scope: this._config.scopes,\n      acr_values: this._config.acrValues,\n      ...this._protectionManager.forgeAuthUrlParams(mreq.session.oidc),\n    });\n  }\n\n  public async getLogoutRedirectUrl(req: express.Request, redirectUrl: URL): Promise<string> {\n    // For IdPs that don't have end_session_endpoint, we just redirect to the requested page.\n    if (this._config.skipEndSessionEndpoint) {\n      return redirectUrl.href;\n    }\n    // Alternatively, we could use a logout URL specified by configuration.\n    if (this._config.endSessionEndpoint) {\n      return this._config.endSessionEndpoint;\n    }\n    // Ignore redirectUrl because OIDC providers don't allow variable redirect URIs\n    const stableRedirectUri = new URL(\"/signed-out\", getOriginUrl(req)).href;\n    const session: SessionObj | undefined = (req as RequestWithLogin).session;\n    return this._client.endSessionUrl({\n      post_logout_redirect_uri: stableRedirectUri,\n      id_token_hint: session?.oidc?.idToken,\n    });\n  }\n\n  public supportsProtection(protection: EnabledProtectionString) {\n    return this._protectionManager.supportsProtection(protection);\n  }\n\n  protected async _initClient({ issuerUrl, clientId, clientSecret, extraMetadata }:\n  { issuerUrl: string, clientId: string, clientSecret: string, extraMetadata: Partial<ClientMetadata> },\n  ): Promise<void> {\n    try {\n      const issuer = await Issuer.discover(issuerUrl);\n      this._client = new issuer.Client({\n        client_id: clientId,\n        client_secret: clientSecret,\n        redirect_uris: this._redirectUrl ? [this._redirectUrl] : undefined,\n        response_types: [\"code\"],\n        ...extraMetadata,\n      });\n    } catch (err) {\n      log.error(`Failed to initialize OIDC client for issuer ${issuerUrl}: ${(err as Error).stack}`, err);\n      throw new Error(\n        `Failed to initialize OIDC client for issuer ${issuerUrl}: ${(err as Error).message}`,\n      );\n    }\n  }\n\n  private _sendErrorPage(\n    req: express.Request,\n    res: express.Response,\n    userFriendlyMessage?: string,\n    targetUrl?: string,\n  ) {\n    return this._sendAppPage(req, res, {\n      path: \"error.html\",\n      status: 500,\n      config: {\n        errPage: \"signin-failed\",\n        errMessage: userFriendlyMessage,\n        // Always set an errTargetUrl so that the browser isn't left on the callback URL.\n        errTargetUrl: targetUrl ?? \"/\",\n      },\n    });\n  }\n\n  private _getRequestWithSession(req: express.Request) {\n    const mreq = req as RequestWithLogin;\n    if (!mreq.session) { throw new Error(\"no session available\"); }\n\n    return mreq;\n  }\n\n  private _makeUserProfileFromUserInfo(userInfo: UserinfoResponse): Partial<UserProfile> {\n    return {\n      email: String(userInfo[this._config.emailPropertyKey]),\n      name: this._extractName(userInfo),\n      // extra fields could be returned by the IdP that we might want to store\n      extra: pick(userInfo, process.env.GRIST_IDP_EXTRA_PROPS?.split(\",\") || []),\n    };\n  }\n\n  private _extractName(userInfo: UserinfoResponse): string | undefined {\n    if (this._config.namePropertyKey) {\n      return (userInfo[this._config.namePropertyKey] as any)?.toString();\n    }\n    const fname = userInfo.given_name ?? \"\";\n    const lname = userInfo.family_name ?? \"\";\n\n    return `${fname} ${lname}`.trim() || userInfo.name;\n  }\n\n  /**\n   * Returns some response details from either OIDCClient's RPError or OPError,\n   * which are handy for error logging.\n   */\n  private _maybeExtractDetailsFromError(error: Error) {\n    if (error instanceof OIDCError.OPError || error instanceof OIDCError.RPError) {\n      const { response } = error;\n      if (response) {\n        // Ensure that we don't log a buffer (which might be noisy), at least for now, unless we're sure that\n        // would be relevant.\n        const isBodyPureObject = response.body && Object.getPrototypeOf(response.body) === Object.prototype;\n        return {\n          body: isBodyPureObject ? response.body : undefined,\n          statusCode: response.statusCode,\n          statusMessage: response.statusMessage,\n        };\n      }\n    }\n    return null;\n  }\n}\n\n/**\n * Get the OIDC login system.\n * This is the final method that ties everything together:\n * - Uses the config reader to read from AppSettings\n * - Passes the config to the builder\n * - Returns the login system\n */\nasync function getLoginSystem(settings: AppSettings): Promise<GristLoginSystem> {\n  const config = readOIDCConfigFromSettings(settings);\n  return {\n    async getMiddleware(gristServer: GristServer) {\n      // Build the middleware using the config\n      const oidcBuilder = await OIDCBuilder.build(gristServer.sendAppPage.bind(gristServer), config);\n      return {\n        getLoginRedirectUrl: oidcBuilder.getLoginRedirectUrl.bind(oidcBuilder),\n        getSignUpRedirectUrl: oidcBuilder.getLoginRedirectUrl.bind(oidcBuilder),\n        getLogoutRedirectUrl: oidcBuilder.getLogoutRedirectUrl.bind(oidcBuilder),\n        async addEndpoints(app: express.Express) {\n          oidcBuilder.addEndpoints(app, gristServer.getSessions());\n          return OIDC_PROVIDER_KEY;\n        },\n      };\n    },\n    async deleteUser() { },\n  };\n}\n\nexport const getOIDCLoginSystem = createLoginProviderFactory(\n  OIDC_PROVIDER_KEY,\n  getLoginSystem,\n);\n"
  },
  {
    "path": "app/server/lib/OnDemandActions.ts",
    "content": "import { AlternateActions, AlternateStorage } from \"app/common/AlternateActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { TableData } from \"app/common/TableData\";\nimport { IndexColumns } from \"app/server/lib/DocStorage\";\n\nexport type { ProcessedAction } from \"app/common/AlternateActions\";\nexport type OnDemandStorage = AlternateStorage;\n\n/**\n * Handle converting UserActions to DocActions for onDemand tables.\n */\nexport class OnDemandActions extends AlternateActions {\n  private _tablesMeta: TableData = this._docData.getMetaTable(\"_grist_Tables\");\n  private _columnsMeta: TableData = this._docData.getMetaTable(\"_grist_Tables_column\");\n\n  constructor(_storage: OnDemandStorage, private _docData: DocData,\n    private _forceOnDemand: boolean = false) {\n    super(_storage);\n  }\n\n  // TODO: Ideally a faster data structure like an index by tableId would be used to decide whether\n  // the table is onDemand.\n  public isOnDemand(tableId: string): boolean {\n    if (this._forceOnDemand) { return true; }\n    const tableRef = this._tablesMeta.findRow(\"tableId\", tableId);\n    // OnDemand tables must have a record in the _grist_Tables metadata table.\n    return tableRef ? Boolean(this._tablesMeta.getValue(tableRef, \"onDemand\")) : false;\n  }\n\n  public usesAlternateStorage(tableId: string): boolean {\n    return this.isOnDemand(tableId);\n  }\n\n  /**\n   * Compute the indexes we would like to have, given the current schema.\n   */\n  public getDesiredIndexes(): IndexColumns[] {\n    const desiredIndexes: IndexColumns[] = [];\n    for (const c of this._columnsMeta.getRecords()) {\n      const t = this._tablesMeta.getRecord(c.parentId as number);\n      if (t && t.onDemand && c.type && (c.type as string).startsWith(\"Ref:\")) {\n        desiredIndexes.push({ tableId: t.tableId as string, colId: c.colId as string });\n      }\n    }\n    return desiredIndexes;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/OpenAIAssistantV1.ts",
    "content": "import {\n  AssistanceMessage,\n  AssistanceRequestV1,\n  AssistanceResponseV1,\n} from \"app/common/Assistance\";\nimport { AssistantProvider } from \"app/common/Assistant\";\nimport { delay } from \"app/common/delay\";\nimport { DocAction } from \"app/common/DocActions\";\nimport {\n  getProviderFromHostname,\n  getUserHash,\n  NonRetryableError,\n  QuotaExceededError,\n  RetryableError,\n  TokensExceededError,\n  TokensExceededFirstMessageError,\n  TokensExceededLaterMessageError,\n} from \"app/server/lib/Assistant\";\nimport { OptDocSession } from \"app/server/lib/DocSession\";\nimport {\n  AssistanceDoc,\n  AssistanceSchemaPromptGenerator,\n  AssistanceSchemaPromptV1Options,\n  AssistantV1,\n  AssistantV1Options,\n} from \"app/server/lib/IAssistant\";\nimport log from \"app/server/lib/log\";\nimport { agents } from \"app/server/lib/ProxyAgent\";\n\nimport fetch from \"node-fetch\";\n\n// These are mocked/replaced in tests.\n// fetch is also replacing in the runCompletion script to add caching.\nexport const DEPS = { fetch, delayTime: 1000, agents };\n\n/**\n * A flavor of assistant for use with the OpenAI chat completion endpoint\n * and tools with a compatible endpoint (e.g. llama-cpp-python).\n * Tested primarily with gpt-4o.\n *\n * Uses the ASSISTANT_CHAT_COMPLETION_ENDPOINT endpoint if set, else an\n * OpenAI endpoint. Passes ASSISTANT_API_KEY or OPENAI_API_KEY in a\n * header if set. An api key is required for the default OpenAI endpoint.\n *\n * If a model string is set in ASSISTANT_MODEL, this will be passed\n * along. For the default OpenAI endpoint, a gpt-4o variant will be\n * set by default.\n *\n * If a request fails because of context length limitation, and\n * ASSISTANT_LONGER_CONTEXT_MODEL is set, the request will be retried\n * with that model.\n *\n * An optional ASSISTANT_MAX_TOKENS can be specified.\n */\nexport class OpenAIAssistantV1 implements AssistantV1 {\n  public static readonly VERSION = 1;\n  public static readonly DEFAULT_MODEL = \"gpt-4o-2024-08-06\";\n  public static readonly DEFAULT_LONGER_CONTEXT_MODEL = \"\";\n\n  private _apiKey = this._options.apiKey;\n  private _endpoint =\n    this._options.completionEndpoint ??\n    \"https://api.openai.com/v1/chat/completions\";\n\n  private _model = this._options.model;\n  private _longerContextModel = this._options.longerContextModel;\n  private _maxTokens = this._options.maxTokens;\n\n  public constructor(private _options: AssistantV1Options) {\n    if (!this._apiKey && !_options.completionEndpoint) {\n      throw new Error(\n        \"Please set ASSISTANT_API_KEY or ASSISTANT_CHAT_COMPLETION_ENDPOINT\",\n      );\n    }\n\n    if (!_options.completionEndpoint) {\n      this._model ||= OpenAIAssistantV1.DEFAULT_MODEL;\n      this._longerContextModel ||=\n        OpenAIAssistantV1.DEFAULT_LONGER_CONTEXT_MODEL;\n    }\n  }\n\n  public async getAssistance(\n    optSession: OptDocSession,\n    doc: AssistanceDoc,\n    request: AssistanceRequestV1,\n  ): Promise<AssistanceResponseV1> {\n    const generatePrompt = this._buildSchemaPromptGenerator(\n      optSession,\n      doc,\n      request,\n    );\n    const messages = request.state?.messages || [];\n    const newMessages: AssistanceMessage[] = [];\n    if (messages.length === 0) {\n      newMessages.push(await generatePrompt());\n    }\n    if (request.context.evaluateCurrentFormula) {\n      const result = await doc.assistanceEvaluateFormula(request.context);\n      let message =\n        \"Evaluating this code:\\n\\n```python\\n\" + result.formula + \"\\n```\\n\\n\";\n      if (Object.keys(result.attributes).length > 0) {\n        const attributes = Object.entries(result.attributes)\n          .map(([k, v]) => `${k} = ${v}`)\n          .join(\"\\n\");\n        message += `where:\\n\\n${attributes}\\n\\n`;\n      }\n      message += `${result.error ? \"raises an exception\" : \"returns\"}: ${\n        result.result\n      }`;\n      newMessages.push({\n        role: \"system\",\n        content: message,\n      });\n    }\n    newMessages.push({\n      role: \"user\",\n      content: request.text,\n    });\n    messages.push(...newMessages);\n\n    const newMessagesStartIndex = messages.length - newMessages.length;\n    for (const [index, { role, content }] of newMessages.entries()) {\n      doc.logTelemetryEvent(optSession, \"assistantSend\", {\n        full: {\n          version: 1,\n          conversationId: request.conversationId,\n          context: request.context,\n          prompt: {\n            index: newMessagesStartIndex + index,\n            role,\n            content,\n          },\n        },\n      });\n    }\n\n    const completion = await this._getCompletion(messages, {\n      generatePrompt,\n      user: getUserHash(optSession),\n    });\n    messages.push({ role: \"assistant\", content: completion });\n\n    // It's nice to have this ready to uncomment for debugging.\n    // console.log(completion);\n\n    const response = await completionToResponse(\n      doc,\n      request,\n      completion,\n    );\n    if (response.suggestedFormula) {\n      // Show the tweaked version of the suggested formula to the user (i.e. the one that's\n      // copied when the Apply button is clicked).\n      response.reply = replaceMarkdownCode(\n        completion,\n        response.suggestedFormula,\n      );\n    } else {\n      response.reply = completion;\n    }\n    response.state = { messages };\n    doc.logTelemetryEvent(optSession, \"assistantReceive\", {\n      full: {\n        version: 1,\n        conversationId: request.conversationId,\n        context: request.context,\n        response: {\n          index: messages.length - 1,\n          content: completion,\n        },\n        suggestedFormula: response.suggestedFormula,\n      },\n    });\n    return response;\n  }\n\n  public get version(): AssistantV1[\"version\"] {\n    return OpenAIAssistantV1.VERSION;\n  }\n\n  public get provider(): AssistantProvider {\n    return getProviderFromHostname(this._endpoint);\n  }\n\n  private async _fetchCompletion(\n    messages: AssistanceMessage[],\n    params: { user: string; model?: string },\n  ) {\n    const { user, model } = params;\n    const apiResponse = await DEPS.fetch(this._endpoint, {\n      method: \"POST\",\n      headers: {\n        ...(this._apiKey ?\n          {\n            \"Authorization\": `Bearer ${this._apiKey}`,\n            \"api-key\": this._apiKey,\n          } :\n          undefined),\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        messages,\n        temperature: 0,\n        ...(model ? { model } : undefined),\n        user,\n        ...(this._maxTokens ?\n          {\n            max_tokens: this._maxTokens,\n          } :\n          undefined),\n      }),\n      ...(DEPS.agents.trusted ? { agent: DEPS.agents.trusted } : {}),\n    });\n    const resultText = await apiResponse.text();\n    const result = JSON.parse(resultText);\n    const errorCode = result.error?.code;\n    const errorMessage = result.error?.message;\n    if (\n      errorCode === \"context_length_exceeded\" ||\n      result.choices?.[0].finish_reason === \"length\"\n    ) {\n      log.warn(\"AI context length exceeded: \", errorMessage);\n      if (messages.length <= 2) {\n        throw new TokensExceededFirstMessageError();\n      } else {\n        throw new TokensExceededLaterMessageError();\n      }\n    }\n    if (errorCode === \"insufficient_quota\") {\n      log.error(\"AI service provider billing quota exceeded!!!\");\n      throw new QuotaExceededError();\n    }\n    if (apiResponse.status !== 200) {\n      throw new Error(\n        `AI service provider API returned status ${apiResponse.status}: ${resultText}`,\n      );\n    }\n    return result.choices[0].message.content;\n  }\n\n  private async _fetchCompletionWithRetries(\n    messages: AssistanceMessage[],\n    params: {\n      user: string;\n      model?: string;\n    },\n  ): Promise<any> {\n    let attempts = 0;\n    const maxAttempts = 3;\n    while (attempts < maxAttempts) {\n      try {\n        return await this._fetchCompletion(messages, params);\n      } catch (e) {\n        if (e instanceof NonRetryableError) {\n          throw e;\n        }\n\n        attempts += 1;\n        if (attempts === maxAttempts) {\n          throw new RetryableError(e.toString());\n        }\n\n        log.warn(`Waiting and then retrying after error: ${e}`);\n        await delay(DEPS.delayTime);\n      }\n    }\n  }\n\n  private async _getCompletion(\n    messages: AssistanceMessage[],\n    params: {\n      generatePrompt: AssistanceSchemaPromptGenerator;\n      user: string;\n    },\n  ): Promise<string> {\n    const { generatePrompt, user } = params;\n\n    // First try fetching the completion with the default model.\n    try {\n      return await this._fetchCompletionWithRetries(messages, {\n        user,\n        model: this._model,\n      });\n    } catch (e) {\n      if (!(e instanceof TokensExceededError)) {\n        throw e;\n      }\n    }\n\n    // If we hit the token limit and a model with a longer context length is\n    // available, try it.\n    if (this._longerContextModel) {\n      try {\n        return await this._fetchCompletionWithRetries(messages, {\n          user,\n          model: this._longerContextModel,\n        });\n      } catch (e) {\n        if (!(e instanceof TokensExceededError)) {\n          throw e;\n        }\n      }\n    }\n\n    // If we (still) hit the token limit, try a shorter schema prompt as a last resort.\n    const prompt = await generatePrompt({\n      includeAllTables: false,\n      includeLookups: false,\n    });\n    return await this._fetchCompletionWithRetries(\n      [prompt, ...messages.slice(1)],\n      {\n        user,\n        model: this._longerContextModel || this._model,\n      },\n    );\n  }\n\n  private _buildSchemaPromptGenerator(\n    optSession: OptDocSession,\n    doc: AssistanceDoc,\n    request: AssistanceRequestV1,\n  ): AssistanceSchemaPromptGenerator {\n    return async options => ({\n      role: \"system\",\n      content:\n        \"You are a helpful assistant for a user of software called Grist. \" +\n        \"Below are one or more fake Python classes representing the structure of the user's data. \" +\n        \"The function at the end needs completing. \" +\n        \"The user will probably give a description of what they want the function (a 'formula') to return. \" +\n        \"If so, your response should include the function BODY as Python code in a markdown block. \" +\n        \"Your response will be automatically concatenated to the code below, so you mustn't repeat any of it. \" +\n        \"You cannot change the function signature or define additional functions or classes. \" +\n        \"It should be a pure function that performs some computation and returns a result. \" +\n        \"It CANNOT perform any side effects such as adding/removing/modifying rows/columns/cells/tables/etc. \" +\n        \"It CANNOT interact with files/databases/networks/etc. \" +\n        \"It CANNOT display images/charts/graphs/maps/etc. \" +\n        \"If the user asks for these things, tell them that you cannot help. \" +\n        \"\\n\\n\" +\n        \"```python\\n\" +\n        (await makeSchemaPromptV1(optSession, doc, request, options)) +\n        \"\\n```\",\n    });\n  }\n}\n\n/**\n * Test assistant that mimics ChatGPT and just returns the input.\n */\nexport class EchoAssistantV1 implements AssistantV1 {\n  public static readonly VERSION = 1;\n\n  public async getAssistance(\n    _docSession: OptDocSession,\n    doc: AssistanceDoc,\n    request: AssistanceRequestV1,\n  ): Promise<AssistanceResponseV1> {\n    if (request.text === \"ERROR\") {\n      throw new Error(\"ERROR\");\n    }\n    if (request.text === \"SLOW\") {\n      await new Promise(r => setTimeout(r, 1000));\n    }\n\n    const messages = request.state?.messages || [];\n    if (messages.length === 0) {\n      messages.push({\n        role: \"system\",\n        content: \"\",\n      });\n    }\n    messages.push({\n      role: \"user\",\n      content: request.text,\n    });\n    const completion = request.text!;\n    const history = { messages };\n    history.messages.push({\n      role: \"assistant\",\n      content: completion,\n    });\n    const response = await completionToResponse(\n      doc,\n      request,\n      completion,\n      completion,\n    );\n    response.state = history;\n    return response;\n  }\n\n  public get version(): AssistantV1[\"version\"] {\n    return EchoAssistantV1.VERSION;\n  }\n\n  public get provider(): AssistantProvider { // eslint-disable-line @typescript-eslint/class-literal-property-style\n    return null;\n  }\n}\n\n/**\n * Returns a new Markdown string with the contents of its first multi-line code block\n * replaced with `replaceValue`.\n */\nfunction replaceMarkdownCode(markdown: string, replaceValue: string) {\n  return markdown.replace(\n    /```\\w*\\n(.*)```/s,\n    \"```python\\n\" + replaceValue + \"\\n```\",\n  );\n}\n\nasync function makeSchemaPromptV1(\n  session: OptDocSession,\n  doc: AssistanceDoc,\n  request: AssistanceRequestV1,\n  options: AssistanceSchemaPromptV1Options = {},\n) {\n  return doc.assistanceSchemaPromptV1(session, {\n    tableId: request.context.tableId,\n    colId: request.context.colId,\n    ...options,\n  });\n}\n\nasync function completionToResponse(\n  doc: AssistanceDoc,\n  request: AssistanceRequestV1,\n  completion: string,\n  reply?: string,\n): Promise<AssistanceResponseV1> {\n  const suggestedFormula = await doc.assistanceFormulaTweak(completion) || undefined;\n  // Suggest an action only if the completion is non-empty (that is,\n  // it actually looked like code).\n  const suggestedActions: DocAction[] = suggestedFormula ? [[\n    \"ModifyColumn\",\n    request.context.tableId,\n    request.context.colId, {\n      formula: suggestedFormula,\n    },\n  ]] : [];\n  return {\n    suggestedActions,\n    suggestedFormula,\n    reply,\n  };\n}\n"
  },
  {
    "path": "app/server/lib/Patch.ts",
    "content": "/**\n *\n * An implementation of daff (tabular diff tool) to apply changes.\n * Incomplete and naive.\n *\n */\n\nimport { TableDelta } from \"app/common/ActionSummary\";\nimport { PatchItem, PatchLog } from \"app/common/ActiveDocAPI\";\nimport { UserAction } from \"app/common/DocActions\";\nimport { DocStateComparisonDetails } from \"app/common/DocState\";\nimport { isHiddenCol } from \"app/common/gristTypes\";\nimport { MetaRowRecord, MetaTableData } from \"app/common/TableData\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { OptDocSession } from \"app/server/lib/DocSession\";\n\nexport class Patch {\n  private _otherId: number | undefined;\n  private _linkId: number | undefined;\n  private _columnsByTableIdAndColId: Record<string, Record<string, MetaRowRecord<\"_grist_Tables_column\">>> = {};\n  private _columns: MetaTableData<\"_grist_Tables_column\">;\n  private _tables: MetaTableData<\"_grist_Tables\">;\n\n  public constructor(private _activeDoc: ActiveDoc, private _docSession: OptDocSession) {\n    // Prepare information about columns for easy access. Perhaps this is overkill\n    // since most proposals will be small?\n    const columns = this._activeDoc.docData?.getMetaTable(\"_grist_Tables_column\");\n    const tables = this._activeDoc.docData?.getMetaTable(\"_grist_Tables\");\n    if (!columns || !tables) {\n      // Should never happen.\n      throw new Error(\"Attempt to patch before document is initialized\");\n    }\n    this._columns = columns;\n    this._tables = tables;\n  }\n\n  /**\n   * Apply the given comparison as a patch. Return a list of notes.\n   * The returned list is currently haphazard, for debugging purposes only.\n   */\n  public async applyChanges(details: DocStateComparisonDetails): Promise<PatchLog> {\n    const changes: PatchItem[] = [];\n    try {\n      const summary = details.leftChanges;\n\n      // Throw an error if there are structural changes. We'll be able\n      // to handle those! But not yet.\n      if (summary.tableRenames.length > 0) {\n        throw new Error(\"table-level changes cannot be handled yet\");\n      }\n      for (const [, delta] of Object.entries(summary.tableDeltas)) {\n        if (delta.columnRenames.length > 0) {\n          throw new Error(\"column-level changes cannot be handled yet\");\n        }\n      }\n\n      for (const [tableId, delta] of Object.entries(summary.tableDeltas)) {\n        // Ignore metadata for now.\n        if (tableId.startsWith(\"_grist_\")) { continue; }\n        if (delta.removeRows.length > 0) {\n          changes.push(...await this._removeRows(tableId, delta));\n        }\n        if (delta.addRows.length > 0) {\n          changes.push(...await this._addRows(tableId, delta));\n        }\n        if (delta.updateRows.length > 0) {\n          changes.push(...await this._updateRows(tableId, delta));\n        }\n      }\n    } catch (e) {\n      changes.push({\n        msg: String(e),\n        fail: true,\n      });\n    }\n    const applied = changes.some(change => !change.fail);\n    return { changes, applied };\n  }\n\n  private async _updateRows(tableId: string, delta: TableDelta): Promise<PatchItem[]> {\n    const changes: PatchItem[] = [];\n    const addedRows = new Set(delta.addRows);\n    // Rows marked as added and updated, we handle with just adding.\n    const rows = delta.updateRows.filter(r => !addedRows.has(r));\n    const columnDeltas = delta.columnDeltas;\n    for (const row of rows) {\n      for (const [colId, columnDelta] of Object.entries(columnDeltas)) {\n        const cellDelta = columnDelta[row];\n        if (!cellDelta) {\n          changes.push({\n            msg: \"there is a row that does not exist anymore\",\n          });\n          continue;\n        }\n        const pre = cellDelta[0]?.[0];\n        const post = cellDelta[1]?.[0];\n        changes.push(await this._changeCell(delta, tableId, row, colId, pre, post));\n      }\n    }\n    return changes;\n  }\n\n  private async _addRows(tableId: string, delta: TableDelta): Promise<PatchItem[]> {\n    const changes: PatchItem[] = [];\n    const rows = delta.addRows;\n    const columnDeltas = delta.columnDeltas;\n    for (const row of rows) {\n      const rec: Record<string, any> = {};\n      for (const [colId, columnDelta] of Object.entries(columnDeltas)) {\n        const cellDelta = columnDelta[row];\n        if (!cellDelta) {\n          changes.push({\n            msg: \"there is a row that does not exist anymore\",\n          });\n          continue;\n        }\n        rec[colId] = cellDelta[1]?.[0];\n      }\n      changes.push(await this._doAdd(delta, tableId, row, rec));\n    }\n    return changes;\n  }\n\n  private async _removeRows(tableId: string, delta: TableDelta): Promise<PatchItem[]> {\n    const changes: PatchItem[] = [];\n    const rows = delta.removeRows;\n    const columnDeltas = delta.columnDeltas;\n    for (const row of rows) {\n      const rec: Record<string, any> = {};\n      for (const [colId, columnDelta] of Object.entries(columnDeltas)) {\n        const cellDelta = columnDelta[row];\n        if (!cellDelta) {\n          changes.push({\n            msg: \"there is a row that does not exist anymore\",\n          });\n          continue;\n        }\n        rec[colId] = cellDelta[0]?.[0];\n      }\n      changes.push(await this._doRemove(delta, tableId, row, rec));\n    }\n    return changes;\n  }\n\n  private async _applyUserActions(actions: UserAction[]) {\n    const result = await this._activeDoc.applyUserActions(\n      this._docSession, actions, {\n        otherId: this._otherId,\n        linkId: this._linkId,\n      },\n    );\n    if (!this._otherId) {\n      this._otherId = result.actionNum;\n    }\n    this._linkId = result.actionNum;\n  }\n\n  private async _doAdd(delta: TableDelta, tableId: string,\n    rowId: number, rec: Record<string, any>): Promise<PatchItem> {\n    for (const colId of Object.keys(rec)) {\n      if (this._shouldSkip(tableId, colId)) {\n        delete rec[colId];\n      }\n    }\n    await this._applyUserActions([\n      [\"AddRecord\", tableId, null, rec],\n    ]);\n    return {\n      msg: \"added a record\",\n    };\n  }\n\n  private async _doRemove(delta: TableDelta, tableId: string, rowId: number,\n    rec: Record<string, any>): Promise<PatchItem> {\n    await this._applyUserActions([\n      [\"RemoveRecord\", tableId, rowId],\n    ]);\n    return {\n      msg: \"removed a record\",\n    };\n  }\n\n  private async _changeCell(delta: TableDelta, tableId: string, rowId: number, colId: string,\n    pre: any, post: any): Promise<PatchItem> {\n    if (this._shouldSkip(tableId, colId)) {\n      return {\n        msg: \"skipped a cell\",\n      };\n    }\n    await this._applyUserActions([\n      [\"UpdateRecord\", tableId, rowId, { [colId]: post }],\n    ]);\n    return {\n      msg: \"updated a cell\",\n    };\n  }\n\n  /**\n   * Skip formula columns or certain special columns.\n   */\n  private _shouldSkip(tableId: string, colId: string): boolean {\n    const prop = this._getTableColumn(tableId, colId);\n    // Careful, isFormula set, with a blank formula, means\n    // an empty column.\n    // Hidden columns are currently gristHelper_ columns\n    // (for conditional formatting, so formula columns in\n    // any case, or manualSort. Changing manualSort is\n    // complicated, let's not get into it yet.\n    return (Boolean(prop.isFormula) && Boolean(prop.formula)) ||\n      isHiddenCol(colId);\n  }\n\n  private _getTableColumn(tableId: string, colId: string) {\n    const column = this._getTableColumns(tableId)[colId];\n    if (!column) {\n      throw new Error(`column not found: ${colId}`);\n    }\n    return column;\n  }\n\n  private _getTableColumns(tableId: string) {\n    if (this._columnsByTableIdAndColId[tableId]) {\n      return this._columnsByTableIdAndColId[tableId];\n    }\n    const table = this._tables.findRecord(\"tableId\", tableId);\n    if (!table) {\n      throw new Error(`table not found: ${tableId}`);\n    }\n    const columns = this._columns.getRecords().filter(rec => rec.parentId === table.id);\n    this._columnsByTableIdAndColId[tableId] = Object.fromEntries(columns.map(rec => [String(rec.colId), rec]));\n    return this._columnsByTableIdAndColId[tableId];\n  }\n}\n"
  },
  {
    "path": "app/server/lib/PermissionInfo.ts",
    "content": "import { ALL_PERMISSION_PROPS, emptyPermissionSet,\n  makePartialPermissions, mergePartialPermissions, mergePermissions,\n  MixedPermissionSet, PartialPermissionSet, PermissionSet, TablePermissionSet,\n  toMixed } from \"app/common/ACLPermissions\";\nimport { ACLRuleCollection } from \"app/common/ACLRuleCollection\";\nimport { RuleSet } from \"app/common/GranularAccessClause\";\nimport { getSetMapValue } from \"app/common/gutil\";\nimport { PredicateFormulaInput } from \"app/common/PredicateFormula\";\nimport { User } from \"app/common/User\";\nimport log from \"app/server/lib/log\";\n\nimport { mapValues } from \"lodash\";\n\n/**\n * A PermissionSet with context about how it was created.  Allows us to produce more\n * informative error messages.\n */\nexport interface PermissionSetWithContextOf<T = PermissionSet> {\n  perms: T;\n  ruleType: \"full\" | \"table\" | \"column\" | \"row\";\n  getMemos: () => MemoSet;\n}\n\nexport type MixedPermissionSetWithContext = PermissionSetWithContextOf<MixedPermissionSet>;\nexport type TablePermissionSetWithContext = PermissionSetWithContextOf<TablePermissionSet>;\nexport type PermissionSetWithContext = PermissionSetWithContextOf<PermissionSet<string>>;\n\n// Accumulator for memos of relevant rules.\nexport type MemoSet = PermissionSet<string[]>;\n\n// Merge MemoSets by collecting all memos with de-duplication.\nexport function mergeMemoSets(psets: MemoSet[]): MemoSet {\n  const result: Partial<MemoSet> = {};\n  for (const prop of ALL_PERMISSION_PROPS) {\n    const merged = new Set<string>();\n    for (const p of psets) {\n      for (const memo of p[prop]) {\n        merged.add(memo);\n      }\n    }\n    result[prop] = [...merged];\n  }\n  return result as MemoSet;\n}\n\nexport function emptyMemoSet(): MemoSet {\n  return {\n    read: [],\n    create: [],\n    update: [],\n    delete: [],\n    schemaEdit: [],\n  };\n}\n\n/**\n * Abstract base class for processing rules given a particular input.\n * Main use of this class will be to calculate permissions, but will also\n * be used to calculate metadata about permissions.\n *\n * Whichever aspect (e.g. permissions or memos) we are processing, this abstract class implements\n * the rules of precedence: column rules are applied first, followed by table default rules, then\n * doc default rules.\n */\nabstract class RuleInfo<MixedT extends TableT, TableT> {\n  // Construct a RuleInfo for a particular input, which is a combination of user and\n  // optionally a record.\n  constructor(protected _acls: ACLRuleCollection, protected _input: PredicateFormulaInput) {}\n\n  // Merge the results for a particular column, falling back to table and doc defaults.\n  public getColumnAspect(tableId: string, colId: string): MixedT {\n    const ruleSet: RuleSet | undefined = this._acls.getColumnRuleSet(tableId, colId);\n    return ruleSet ? this._processColumnRule(ruleSet) : this._getTableDefaultAspect(tableId);\n  }\n\n  // Merge the results for all columns of a table, falling back to table and doc defaults.\n  public getTableAspect(tableId: string): TableT {\n    const columnAccess = this._acls.getAllColumnRuleSets(tableId).map(rs => this._processColumnRule(rs));\n    columnAccess.push(this._getTableDefaultAspect(tableId));\n    return this._mergeTableAccess(columnAccess);\n  }\n\n  // Merge the results from all rules in a doc: columns, tables, and falling back to doc defaults.\n  public getFullAspect(): MixedT {\n    const tableAccess = this._acls.getAllTableIds().map(tableId => this.getTableAspect(tableId));\n    tableAccess.push(this._getDocDefaultAspect());\n\n    return this._mergeFullAccess(tableAccess);\n  }\n\n  // Merge the results for a particular column RuleSet, falling back to table and doc defaults.\n  public getColumnRuleSetAspect(ruleSet: RuleSet): MixedT {\n    return this._processColumnRule(ruleSet);\n  }\n\n  public getUser(): User {\n    return this._input.user! as User;\n  }\n\n  protected abstract _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedT): MixedT;\n  protected abstract _mergeTableAccess(access: MixedT[]): TableT;\n  protected abstract _mergeFullAccess(access: TableT[]): MixedT;\n\n  private _getTableDefaultAspect(tableId: string): MixedT {\n    const ruleSet: RuleSet | undefined = this._acls.getTableDefaultRuleSet(tableId);\n    return ruleSet ? this._processRule(ruleSet, () => this._getDocDefaultAspect()) :\n      this._getDocDefaultAspect();\n  }\n\n  private _getDocDefaultAspect(): MixedT {\n    return this._processRule(this._acls.getDocDefaultRuleSet());\n  }\n\n  private _processColumnRule(ruleSet: RuleSet): MixedT {\n    return this._processRule(ruleSet, () => this._getTableDefaultAspect(ruleSet.tableId));\n  }\n}\n\n/**\n * Pool memos from rules, on the assumption that access has been denied and we are looking\n * for possible explanations to offer the user.\n */\nexport class MemoInfo extends RuleInfo<MemoSet, MemoSet> {\n  protected _processRule(ruleSet: RuleSet, defaultAccess?: () => MemoSet): MemoSet {\n    const pset = extractMemos(ruleSet, this._input);\n    return defaultAccess ? mergeMemoSets([pset, defaultAccess()]) : pset;\n  }\n\n  protected _mergeTableAccess(access: MemoSet[]): MemoSet {\n    return mergeMemoSets(access);\n  }\n\n  protected _mergeFullAccess(access: MemoSet[]): MemoSet {\n    return mergeMemoSets(access);\n  }\n}\n\nexport interface IPermissionInfo {\n  getColumnAccess(tableId: string, colId: string): MixedPermissionSetWithContext;\n  getTableAccess(tableId: string): TablePermissionSetWithContext;\n  getFullAccess(): MixedPermissionSetWithContext;\n  getRuleCollection(): ACLRuleCollection;\n}\n\n/**\n * Helper for evaluating rules given a particular user and optionally a record. It evaluates rules\n * for a column, table, or document, with caching to avoid evaluating the same rule multiple times.\n */\nexport class PermissionInfo extends RuleInfo<MixedPermissionSet, TablePermissionSet> implements IPermissionInfo {\n  private _ruleResults = new Map<RuleSet, MixedPermissionSet>();\n\n  // Get permissions for \"tableId:colId\", defaulting to \"tableId:*\" and \"*:*\" as needed.\n  // If 'mixed' is returned, different rows may have different permissions. It should never return\n  // 'mixed' if the input includes `rec`.\n  // Wrap permissions with information about how they were computed.  This allows\n  // us to issue more informative error messages.\n  public getColumnAccess(tableId: string, colId: string): MixedPermissionSetWithContext {\n    return {\n      perms: this.getColumnAspect(tableId, colId),\n      ruleType: \"column\",\n      getMemos: () => new MemoInfo(this._acls, this._input).getColumnAspect(tableId, colId),\n    };\n  }\n\n  // Combine permissions from all rules for the given table.\n  // If 'mixedColumns' is returned, different columns have different permissions, but they do NOT\n  // depend on rows. If 'mixed' is returned, some permissions depend on rows.\n  // Wrap permission sets for better error messages.\n  public getTableAccess(tableId: string): TablePermissionSetWithContext {\n    return {\n      perms: this.getTableAspect(tableId),\n      ruleType: this._input?.rec ? \"row\" : \"table\",\n      getMemos: () => new MemoInfo(this._acls, this._input).getTableAspect(tableId),\n    };\n  }\n\n  // Combine permissions from all rules throughout.\n  // If 'mixed' is returned, then different tables, rows, or columns have different permissions.\n  // Wrap permission sets for better error messages.\n  public getFullAccess(): MixedPermissionSetWithContext {\n    return {\n      perms: this.getFullAspect(),\n      ruleType: \"full\",\n      getMemos: () => new MemoInfo(this._acls, this._input).getFullAspect(),\n    };\n  }\n\n  public getRuleCollection() {\n    return this._acls;\n  }\n\n  protected _processRule(ruleSet: RuleSet, defaultAccess?: () => MixedPermissionSet): MixedPermissionSet {\n    return getSetMapValue(this._ruleResults, ruleSet, () => {\n      const pset = evaluateRule(ruleSet, this._input);\n      return toMixed(defaultAccess ? mergePartialPermissions(pset, defaultAccess()) : pset);\n    });\n  }\n\n  protected _mergeTableAccess(access: MixedPermissionSet[]): TablePermissionSet {\n    return mergePermissions(access, bits => (\n      bits.every(b => b === \"allow\") ? \"allow\" :\n        bits.every(b => b === \"deny\") ? \"deny\" :\n          bits.every(b => b === \"allow\" || b === \"deny\") ? \"mixedColumns\" :\n            \"mixed\"\n    ));\n  }\n\n  protected _mergeFullAccess(access: TablePermissionSet[]): MixedPermissionSet {\n    return mergePermissions(access, bits => (\n      bits.every(b => b === \"allow\") ? \"allow\" :\n        bits.every(b => b === \"deny\") ? \"deny\" :\n          \"mixed\"\n    ));\n  }\n}\n\n/**\n * Evaluate a RuleSet on a given input (user and optionally record). If a record is needed but not\n * included, the result may include permission values like 'allowSome', 'denySome', or 'mixed' (for\n * rules with memo).\n */\nfunction evaluateRule(ruleSet: RuleSet, input: PredicateFormulaInput): PartialPermissionSet {\n  let pset: PartialPermissionSet = emptyPermissionSet();\n  for (const rule of ruleSet.body) {\n    try {\n      if (rule.matchFunc!(input)) {\n        pset = mergePartialPermissions(pset, rule.permissions);\n      }\n    } catch (e) {\n      if (e.code === \"NEED_ROW_DATA\") {\n        pset = mergePartialPermissions(pset, makePartialPermissions(rule.permissions));\n        if (rule.memo) {\n          // Quick reminder:\n          // - memos are only shown for denies, if user can't update/delete/create, they are not shown when user\n          //   can't read. Schema permissions are not row dependent.\n          // - memos can be extracted if ACL allows something, but they are not shown.\n          // - partial permissions are merged, so denySome + deny = deny, and allowSome + allow = allow.\n          // - but allow + denySome + deny = mixed, and allow + allowSome + deny = mixed.\n          // - mixed is a final state, it can't be combined with anything else and disables any optimizations (forces\n          //   row checks).\n          // - allowSome and denySome are not final states, they will be replaced by allow/deny/mixed.\n\n          // If rule has a memo, it will be shown if user is denied access to something, only if:\n          // - this rule denied this access\n          // - this rule would have allowed this access, but it didn't pass (e.g. row check failed, different user, etc)\n          //\n          // But there is one problem. If there is mix of deny and denySome (so some rules deny access based on the row,\n          // and some denies access unconditionally - or based on a user), the overall access is denied, and there is no\n          // reason to know exactly which row dependent rule denied it. So the access is denied at table/column level,\n          // without actually scanning the rows. This is a good optimization, but it means that we won't be able to show\n          // the memo, because we won't have the row data (rec in input) to test which rule (a row dependent rule)\n          // matched the data (see extractMemos below).\n\n          // To fix that, we need to convert denySome to mixed, which is a final state, and will force row checks, as\n          // the optimizer won't be able to tell if the access is denied or not, without actually scanning each row that\n          // is touched in the bundle. With that, we will be able to determine which rule denied the access, as the\n          // check will be performed for each row.\n\n          // We don't need to do that for allowSome, as this bit is converted only to \"allow\" or \"mixed\". When it is\n          // \"allow\", the memos won't be shown, and when it is \"mixed\", rows will be scanned anyway.\n\n          // We'll replace only denySome in create/update/delete bits. read doesn't show memo and schemaEdit is not row\n          // dependent.\n          const dataChangePerms: (keyof PermissionSet)[] = [\"create\", \"update\", \"delete\"];\n          const changesData = (perm: string) => dataChangePerms.includes(perm as keyof PermissionSet);\n          pset = mapValues(pset, (val, perm) => val === \"denySome\" && changesData(perm) ? \"mixed\" : val);\n        }\n      } else {\n        // Unexpected error. Interpret rule pessimistically.\n        // Anything it would explicitly allow, no longer allow through this rule.\n        // Anything it would explicitly deny, go ahead and deny.\n        pset = mergePartialPermissions(pset, mapValues(rule.permissions, val => (val === \"allow\" ? \"\" : val)));\n        const prefixedTableName = input.docId ? `${input.docId}.${ruleSet.tableId}` : ruleSet.tableId;\n        log.warn(\"ACLRule for %s (`%s`) failed: %s\", prefixedTableName, rule.aclFormula, e.message);\n      }\n    }\n  }\n  return pset;\n}\n\n/**\n * If a rule has a memo, and passes, add that memo for all permissions it denies.\n * If a rule has a memo, and fails, add that memo for all permissions it allows.\n */\nfunction extractMemos(ruleSet: RuleSet, input: PredicateFormulaInput): MemoSet {\n  const pset = emptyMemoSet();\n  for (const rule of ruleSet.body) {\n    try {\n      const passing = rule.matchFunc!(input);\n      for (const prop of ALL_PERMISSION_PROPS) {\n        const p = rule.permissions[prop];\n        const memos: string[] = pset[prop];\n        if (rule.memo) {\n          if (passing && p === \"deny\") {\n            memos.push(rule.memo);\n          } else if (!passing && p === \"allow\") {\n            memos.push(rule.memo);\n          }\n        }\n      }\n    } catch (e) {\n      if (e.code !== \"NEED_ROW_DATA\") {\n        // If a rule is failing unexpectedly, give some information via memos.\n        // TODO: Could give a more structured result.\n        for (const prop of ALL_PERMISSION_PROPS) {\n          pset[prop].push(`Rule [${rule.aclFormula}] for ${ruleSet.tableId} has an error: ${e.message}`);\n        }\n      }\n    }\n  }\n  return pset;\n}\n"
  },
  {
    "path": "app/server/lib/Permit.ts",
    "content": "/**\n * An exceptional grant of rights on a resource, for when work needs to be\n * initiated by Grist systems rather than a user.  Cases where this may happen:\n *\n *   - Deletion of documents and workspaces in the trash\n *\n * Permits are stored in redis (or, in a single-process dev environment, in memory)\n * as json, in keys that expire within minutes.  The keys should be effectively\n * unguessable.\n *\n * To use a permit:\n *\n *   - Prepare a Permit object that includes the id of the document or\n *     workspace to be operated on.\n *\n *   - It the operation you care about involves the database, check\n *     that \"allowSpecialPermit\" is enabled for it in HomeDBManager\n *     (currently only deletion of docs, and deleting/viewing workspaces\n *     has this enabled).\n *\n *   - Save the permit in the permit store, with setPermit, noting its\n *     generated key.\n *\n *   - Call the API with a \"Permit: <permit-key>\" header.\n *\n *   - Optionally, remove the permit with removePermit().\n */\nexport interface Permit {\n  docId?: string;       // A particular document.\n  workspaceId?: number; // A particular workspace.\n  org?: string | number;  // A particular org.\n  otherDocId?: string;  // For operations involving two documents.\n  sessionId?: string;   // A particular session.\n  url?: string;         // A particular url.\n  action?: string;      // A string denoting what kind of action the permit applies to.\n}\n\n/* A store of permits */\nexport interface IPermitStore {\n\n  // Store a permit, and return the key it is stored in.\n  // Permits are transient, and will expire.\n  setPermit(permit: Permit, ttlMs?: number): Promise<string>;\n\n  // Get any permit associated with the given key, or null if none.\n  getPermit(permitKey: string): Promise<Permit | null>;\n\n  // Remove any permit associated with the given key.\n  removePermit(permitKey: string): Promise<void>;\n\n  // Close down the permit store.\n  close(): Promise<void>;\n\n  // Get the permit key prefix.\n  getKeyPrefix(): string;\n}\n\nexport interface IPermitStores {\n  getPermitStore(prefix: string, defaultTtlMs?: number): IPermitStore;\n}\n\n// Create a well formatted permit key from a seed string.\nexport function formatPermitKey(seed: string, prefix: string) {\n  return `permit-${prefix}-${seed}`;\n}\n\n// Check that permit key is well formatted.\nexport function checkPermitKey(key: string, prefix: string): boolean {\n  return key.startsWith(`permit-${prefix}-`);\n}\n"
  },
  {
    "path": "app/server/lib/PluginEndpoint.ts",
    "content": "import { FlexServer } from \"app/server/lib/FlexServer\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport { PluginManager } from \"app/server/lib/PluginManager\";\n\nimport * as path from \"path\";\n\nimport * as express from \"express\";\nimport * as mimeTypes from \"mime-types\";\n\n// Get the host serving plugin material\nexport function getUntrustedContentHost(origin: string | undefined): string | undefined {\n  if (!origin) { return; }\n  return new URL(origin).host;\n}\n\n// Add plugin endpoints to be served on untrusted host\nexport function addPluginEndpoints(server: FlexServer, pluginManager: PluginManager) {\n  if (server.servesPlugins()) {\n    server.app.get(/^\\/plugins\\/(installed|builtIn)\\/([^/]+)\\/(.+)/, (req, res) =>\n      servePluginContent(req, res, pluginManager, server));\n  }\n}\n\n// Serve content for plugins with various checks that it is being accessed as we expect.\nfunction servePluginContent(req: express.Request, res: express.Response,\n  pluginManager: PluginManager,\n  gristServer: GristServer) {\n  const pluginUrl = gristServer.getPluginUrl();\n  const untrustedContentHost = getUntrustedContentHost(pluginUrl);\n  if (!untrustedContentHost) {\n    // not expected\n    throw new Error(\"plugin host unexpectedly not set\");\n  }\n\n  const pluginKind = req.params[0];\n  const pluginId = req.params[1];\n  const pluginPath = req.params[2];\n\n  // We should not serve untrusted content (as from plugins) from the same domain as the main app\n  // (at least not html pages), as it's an open door to XSS attacks.\n  // - For hosted version, we serve it from a separate domain name.\n  // - For electron version, we give access to protected <webview> content based on a special header.\n  // - We also allow \"application/javascript\" content from the main domain for serving the\n  //   WebWorker main script, since that's hard to distinguish in electron case, and should not\n  //   enable XSS.\n  if (matchHost(req.get(\"host\"), untrustedContentHost) ||\n    req.get(\"X-From-Plugin-WebView\") === \"true\" ||\n    mimeTypes.lookup(path.extname(pluginPath)) === \"application/javascript\") {\n    const dirs = pluginManager.dirs();\n    const contentRoot = pluginKind === \"installed\" ? dirs.installed :\n      (pluginKind === \"builtIn\" ? dirs.builtIn : dirs.bundled);\n    // Note that pluginPath may not be safe, but `sendFile` with the \"root\" option restricts\n    // relative paths to be within the root folder (see the 3rd party library unit-test:\n    // https://github.com/pillarjs/send/blob/3daa901cf731b86187e4449fa2c52f971e0b3dbc/test/send.js#L1363)\n    return res.sendFile(`${pluginId}/${pluginPath}`, { root: contentRoot });\n  }\n\n  log.warn(`Refusing to serve untrusted plugin content on ${req.get(\"host\")}`);\n  res.status(403).end(\"Plugin content is not accessible to this request\");\n}\n\n// Middleware to restrict some assets to untrusted host.\nexport function limitToPlugins(gristServer: GristServer,\n  handler: express.RequestHandler) {\n  return function(req: express.Request, resp: express.Response, next: express.NextFunction) {\n    const pluginUrl = gristServer.getPluginUrl();\n    const host = getUntrustedContentHost(pluginUrl);\n    if (!host) { return next(); }\n    if (matchHost(req.get(\"host\"), host) || req.get(\"X-From-Plugin-WebView\") === \"true\") {\n      return handler(req, resp, next);\n    }\n    return next();\n  };\n}\n\n// Compare hosts, bearing in mind that if they happen to be on port 443 the\n// port number may or may not be included.  This assumes we are serving over https.\nfunction matchHost(host1: string | undefined, host2: string) {\n  if (!host1) { return false; }\n  if (host1 === host2) { return true; }\n  if (!host1.includes(\":\")) { host1 += \":443\"; }\n  if (!host2.includes(\":\")) { host2 += \":443\"; }\n  return host1 === host2;\n}\n"
  },
  {
    "path": "app/server/lib/PluginManager.ts",
    "content": "import { DirectoryScanEntry, LocalPlugin } from \"app/common/plugin\";\nimport log from \"app/server/lib/log\";\nimport { readManifest } from \"app/server/lib/manifest\";\nimport { getAppPathTo } from \"app/server/lib/places\";\n\nimport * as path from \"path\";\n\nimport * as fse from \"fs-extra\";\n\n/**\n * Various plugins' related directories.\n */\nexport interface PluginDirectories {\n  /**\n   * Directory where built in plugins are located.\n   */\n  readonly builtIn?: string;\n  /**\n   * Directory where user installed plugins are located.\n   */\n  readonly installed?: string;\n  /**\n   * Yet another option, for plugins that are included\n   * during a build but not part of the codebase itself.\n   */\n  readonly bundled?: string;\n}\n\n/**\n *\n * The plugin manager class is responsible for providing both built in and installed plugins and\n * spawning server side plugins's.\n *\n * Usage:\n *\n *  const pluginManager = new PluginManager(appRoot, userRoot);\n *  await pluginManager.initialize();\n *\n */\nexport class PluginManager {\n  public pluginsLoaded: Promise<void>;\n\n  // ========== Instance members and methods ==========\n  private _dirs: PluginDirectories;\n  private _validPlugins: LocalPlugin[] = [];\n  private _entries: DirectoryScanEntry[] = [];\n\n  /**\n   * @param {string} userRoot: path to user's grist directory; `null` is allowed, to only uses built in plugins.\n   *\n   */\n  public constructor(public appRoot?: string, userRoot?: string,\n    public bundledRoot?: string) {\n    this._dirs = {\n      installed: userRoot ? path.join(userRoot, \"plugins\") : undefined,\n      builtIn: appRoot ? getAppPathTo(appRoot, \"plugins\") : undefined,\n      bundled: bundledRoot ? getAppPathTo(bundledRoot, \"plugins\") : undefined,\n    };\n  }\n\n  public dirs(): PluginDirectories { return this._dirs; }\n\n  /**\n   * Create tmp dir and load plugins.\n   */\n  public async initialize(): Promise<void> {\n    try {\n      await (this.pluginsLoaded = this.loadPlugins());\n    } catch (err) {\n      log.error(\"PluginManager's initialization failed: \", err);\n      throw err;\n    }\n  }\n\n  /**\n   * Re-load plugins (literally re-run `loadPlugins`).\n   */\n  // TODO: it's not clear right now what we do on reload. Do we deactivate plugins that were removed\n  // from the fs? Do we update plugins that have changed on the fs ?\n  public async reloadPlugins(): Promise<void> {\n    return await this.loadPlugins();\n  }\n\n  /**\n   * Discover both builtIn and user installed plugins. Logs any failures that happens when scanning\n   * a directory (ie: manifest missing or manifest validation errors etc...)\n   */\n  public async loadPlugins(): Promise<void> {\n    this._entries = [];\n\n    // Load user installed plugins\n    if (this._dirs.installed) {\n      this._entries.push(...await scanDirectory(this._dirs.installed, \"installed\"));\n    }\n\n    // Load builtIn plugins\n    if (this._dirs.builtIn) {\n      this._entries.push(...await scanDirectory(this._dirs.builtIn, \"builtIn\"));\n    }\n\n    // Load bundled plugins\n    if (this._dirs.bundled) {\n      this._entries.push(...await scanDirectory(this._dirs.bundled, \"bundled\"));\n    }\n\n    if (!process.env.GRIST_EXPERIMENTAL_PLUGINS ||\n      process.env.GRIST_EXPERIMENTAL_PLUGINS === \"0\") {\n      // Remove experimental plugins\n      this._entries = this._entries.filter((entry) => {\n        if (entry.manifest?.experimental) {\n          log.warn(\"Ignoring experimental plugin %s\", entry.id);\n          return false;\n        }\n        return true;\n      });\n    }\n\n    this._validPlugins = this._entries.filter(entry => !entry.errors).map(entry => entry as LocalPlugin);\n\n    this._logScanningReport();\n  }\n\n  public getPlugins(): LocalPlugin[] {\n    return this._validPlugins;\n  }\n\n  private _logScanningReport() {\n    const invalidPlugins = this._entries.filter(entry => entry.errors);\n    if (invalidPlugins.length) {\n      for (const plugin of invalidPlugins) {\n        log.warn(`Error loading plugins: Failed to load extension from ${plugin.path}\\n` +\n          (plugin.errors!).map(m => \"  - \" + m).join(\"\\n  \"),\n        );\n      }\n    }\n    log.info(`Found ${this._validPlugins.length} valid plugins on the system`);\n    for (const p of this._validPlugins) {\n      log.debug(\"PLUGIN %s -- %s\", p.id, p.path);\n    }\n  }\n}\n\nasync function scanDirectory(dir: string, kind: \"installed\" | \"builtIn\" | \"bundled\"): Promise<DirectoryScanEntry[]> {\n  const plugins: DirectoryScanEntry[] = [];\n  let listDir;\n\n  try {\n    listDir = await fse.readdir(dir);\n  } catch (e) {\n    // Non existing dir is treated as an empty dir.\n    // It is hard for user to avoid Grist checking a dir,\n    // so phrase the message as information rather than error.\n    log.info(`No plugins found in directory: ${dir}`);\n    return [];\n  }\n\n  for (const id of listDir) {\n    const folderPath = path.join(dir, id),\n      plugin: DirectoryScanEntry = {\n        path: folderPath,\n        id: `${kind}/${id}`,\n      };\n    try {\n      plugin.manifest = await readManifest(folderPath);\n    } catch (e) {\n      plugin.errors = [];\n      if (e.message) {\n        plugin.errors.push(e.message);\n      }\n      if (e.notices) {\n        plugin.errors.push(...e.notices);\n      }\n    }\n    plugins.push(plugin);\n  }\n  return plugins;\n}\n"
  },
  {
    "path": "app/server/lib/ProcessMonitor.ts",
    "content": "import { ITelemetry } from \"app/server/lib/Telemetry\";\n\nconst MONITOR_PERIOD_MS = 5_000;        // take a look at memory usage this often\nconst MEMORY_DELTA_FRACTION = 0.1;      // fraction by which usage should change to get reported\nconst CPU_DELTA_FRACTION = 0.1;         // by how much cpu usage should change to get reported\nconst MONITOR_LOG_PERIOD_MS = 600_000;  // log usage at least this often\n\nlet _timer: NodeJS.Timeout | undefined;\nlet _lastTickTime: number = Date.now();\nlet _lastReportTime: number = 0;\nlet _lastReportedHeapUsed: number = 0;\nlet _lastCpuUsage: NodeJS.CpuUsage = { system: 0, user: 0 };\nlet _lastReportedCpuAverage: number = 0;\n\n/**\n * Monitor process memory (heap) and CPU usage, reporting as telemetry on an interval, and more\n * often when usage ticks up or down by a big enough delta.\n *\n * There is a single global process monitor, reporting to the `telemetry` object passed into the\n * first call to start().\n *\n * Returns a function that stops the monitor, or null if there was already a process monitor\n * running, and no new one was started.\n *\n * Reports:\n *  - heapUsedMB:   Size of JS heap in use, in MiB.\n *  - heapTotalMB:  Total heap size, in MiB, allocated for JS by v8.\n *  - cpuAverage:   Fraction between 0 and 1, cpu usage over the last MONITOR_PERIOD_MS. Note it\n *                  includes usage from all threads, so may exceed 1.\n *  - intervalMs:   Interval (in milliseconds) over which cpuAverage is reported. Being much\n *                  higher than MONITOR_PERIOD_MS is a sign of being CPU bound for that long.\n */\nexport function start(telemetry: ITelemetry): (() => void) | undefined {\n  if (!_timer) {\n    // Initialize variables needed for accurate first-tick measurement.\n    _lastTickTime = Date.now();\n    _lastCpuUsage = process.cpuUsage();\n    _timer = setInterval(() => monitor(telemetry), MONITOR_PERIOD_MS);\n\n    return function stop() {\n      clearInterval(_timer);\n      _timer = undefined;\n    };\n  }\n}\n\nfunction monitor(telemetry: ITelemetry) {\n  const memoryUsage = process.memoryUsage();\n  const heapUsed = memoryUsage.heapUsed;\n  const cpuUsage = process.cpuUsage();\n  const now = Date.now();\n\n  const intervalMs = now - _lastTickTime;\n  // Note that cpuUsage info is in microseconds, while intervalMs is milliseconds.\n  const cpuAverage = (cpuUsage.system + cpuUsage.user - _lastCpuUsage.system - _lastCpuUsage.user) /\n    1000 / intervalMs;\n  _lastCpuUsage = cpuUsage;\n  _lastTickTime = now;\n\n  // Report usage when:\n  // (a) enough time has passed (MONITOR_LOG_PERIOD_MS)\n  // (b) memory usage ticked up or down enough since the last report\n  // (c) average cpu usage ticked up or down enough since the last report\n  if (\n    now > _lastReportTime + MONITOR_LOG_PERIOD_MS ||\n    Math.abs(heapUsed - _lastReportedHeapUsed) > _lastReportedHeapUsed * MEMORY_DELTA_FRACTION ||\n    Math.abs(cpuAverage - _lastReportedCpuAverage) > CPU_DELTA_FRACTION\n  ) {\n    telemetry.logEvent(null, \"processMonitor\", {\n      full: {\n        heapUsedMB: Math.round(memoryUsage.heapUsed / 1024 / 1024),\n        heapTotalMB: Math.round(memoryUsage.heapTotal / 1024 / 1024),\n        cpuAverage: Math.round(cpuAverage * 100) / 100,\n        intervalMs,\n      },\n    });\n    _lastReportedHeapUsed = heapUsed;\n    _lastReportedCpuAverage = cpuAverage;\n    _lastReportTime = now;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/ProxyAgent.ts",
    "content": "import { appSettings } from \"app/server/lib/AppSettings\";\nimport log from \"app/server/lib/log\";\n\nimport fetch, { RequestInit } from \"node-fetch\";\nimport { ProxyAgent, ProxyAgentOptions } from \"proxy-agent\";\n\n/**\n * GristProxyAgent derives from ProxyAgent which is a class that is responsible for proxying the request using either\n * HttpProxyAgent or HttpsProxyAgent (or other supported proxy agents)\n * depending on the URL requested when using fetch().\n *\n * We configure the getProxyForUrl to not let ProxyAgent magically read the env variables\n * itself (using `proxy-from-env` module), we already do that ourselves and need to keep the control for that.\n */\nexport class GristProxyAgent extends ProxyAgent {\n  constructor(public readonly proxyUrl: string, opts?: Omit<ProxyAgentOptions, \"getProxyForUrl\">) {\n    super({\n      ...opts,\n      getProxyForUrl: () => this.proxyUrl,\n    });\n  }\n}\n\nfunction getProxyAgentConfiguration() {\n  const proxyForTrustedRequestsUrl = appSettings.section(\"proxy\").readString({\n    envVar: [\"HTTPS_PROXY\", \"https_proxy\"],\n    preferredEnvVar: \"HTTPS_PROXY\",\n  });\n\n  const proxyForUntrustedRequestsUrl = appSettings.section(\"proxy\").readString({\n    envVar: [\"GRIST_PROXY_FOR_UNTRUSTED_URLS\", \"GRIST_HTTPS_PROXY\"],\n    preferredEnvVar: \"GRIST_PROXY_FOR_UNTRUSTED_URLS\",\n  });\n\n  return {\n    proxyForTrustedRequestsUrl,\n    proxyForUntrustedRequestsUrl,\n  };\n}\n\nfunction generateProxyAgents() {\n  const { proxyForTrustedRequestsUrl, proxyForUntrustedRequestsUrl } = getProxyAgentConfiguration();\n\n  if (process.env.GRIST_HTTPS_PROXY) {\n    log.warn(\"GRIST_HTTPS_PROXY is deprecated in favor of GRIST_PROXY_FOR_UNTRUSTED_URLS. \" +\n      `Please rather set GRIST_PROXY_FOR_UNTRUSTED_URLS=\"${proxyForUntrustedRequestsUrl}\"`);\n  }\n\n  return {\n    trusted: proxyForTrustedRequestsUrl ? new GristProxyAgent(proxyForTrustedRequestsUrl) : undefined,\n    untrusted: (proxyForUntrustedRequestsUrl && proxyForUntrustedRequestsUrl !== \"direct\") ?\n      new GristProxyAgent(proxyForUntrustedRequestsUrl) : undefined,\n  };\n}\n\n/**\n *\n * Check whether there is explicit proxy configuration for untrusted\n * requests (regardless of whether it is to set a proxy, or to use\n * direct requests)\n *\n */\nexport function isUntrustedRequestBehaviorSet() {\n  const config = getProxyAgentConfiguration();\n  return config.proxyForUntrustedRequestsUrl !== undefined;\n}\n\nexport const test_generateProxyAgents = generateProxyAgents;\n\n// Instantiate all the possible agents at startup.\nexport const agents = generateProxyAgents();\n\n/**\n * If configured using GRIST_PROXY_FOR_UNTRUSTED_URLS env var, use node-fetch with configured proxy agent\n * Otherwise just use fetch without agent.\n *\n * If the request failed with agent, log a warning with relevant information.\n *\n * FIXME: Remove it and rather let the caller log a warning?\n * The original function has been introduced by this commit:\n * https://github.com/gristlabs/grist-core/commit/be5cb9124a5d1fec8c2ed6dff5cdbf786fac2991\n * Here are written thoughts and doubts about this function:\n * https://github.com/gristlabs/grist-core/pull/1363#discussion_r2034871615\n */\nexport async function fetchUntrustedWithAgent(requestUrl: URL | string, options?: Omit<RequestInit, \"agent\">) {\n  const agent = agents.untrusted;\n  if (!agent) {\n    // No proxy is configured, just use the default agent.\n    return await fetch(requestUrl, options);\n  }\n  requestUrl = new URL(requestUrl);\n\n  try {\n    return await fetch(requestUrl, { ...options, agent });\n  } catch (e) {\n    // Include info helpful for diagnosing issues (but not the potentially sensitive full requestUrl).\n    log.rawWarn(`ProxyAgent error ${e}`,\n      { proxy: agent.proxyUrl, reqProtocol: requestUrl.protocol, requestHost: requestUrl.origin });\n    throw e;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/PubSubCache.ts",
    "content": "import { mapGetOrSet, MapWithCustomExpire } from \"app/common/AsyncCreate\";\nimport { makeId } from \"app/server/lib/idUtils\";\nimport { IPubSubManager, UnsubscribeCallbackPromise } from \"app/server/lib/PubSubManager\";\n\n/**\n * Cache of value, with a TTL and invalidations.\n */\nexport class PubSubCache<Key, Value> {\n  private _selfId: string = makeId();\n\n  // Invariant: if _cache[key] is set, then _watchedKeys[key] is set.\n  private _cache: MapWithCustomExpire<Key, Promise<Value>>;\n  private _watchedKeys = new Map<Key, UnsubscribeCallbackPromise>();\n\n  constructor(private _options: {\n    pubSubManager: IPubSubManager,\n    fetch: (key: Key) => Promise<Value>;      // Fetch the value corresponding to the given key.\n    getChannel: (key: Key) => string;         // Turn a key into a channel to use for pub-sub.\n    ttlMs: number;    // How long to cache the value; we subscribe to invalidations until it expires.\n  }) {\n    this._cache = new MapWithCustomExpire<Key, Promise<Value>>(this._options.ttlMs, this._onExpire.bind(this));\n  }\n\n  /**\n   * Get the value at the given key. It will use the cache, or fetch the value if needed. It will\n   * reset the expiration, and will subscribe to pubsub invalidations, if not yet subscribed.\n   */\n  public getValue(key: Key): Promise<Value> {\n    // If there is a cached key, use directly; otherwise, get or create a subscription, and wait\n    // it to take effect, to be sure to catch any invalidation that happens after the fetch.\n    return mapGetOrSet(this._cache, key, async () => {\n      // Find key in _watchedKeys, or create a new subscription to invalidations.\n      await mapGetOrSet(this._watchedKeys, key, () => this._subscribe(key));\n      return this._options.fetch(key);\n    });\n  }\n\n  /**\n   * Invalidate the given keys, across PubSubCache instances in all servers. In the current\n   * server, the invalidation is synchronous.\n   */\n  public async invalidateKeys(keys: Key[]) {\n    // Invalidate our own cache synchronously.\n    for (const key of keys) {\n      this._cache.delete(key);\n    }\n    // They key to invalidate is in the channel name; for the message, we only include our own\n    // unique ID to avoid a duplicate invalidation when we receive our own published message.\n    await this._options.pubSubManager.publishBatch(\n      keys.map(key => ({ channel: this._options.getChannel(key), message: this._selfId })));\n  }\n\n  /**\n   * Clear the cache and remove all pubsub subscriptions.\n   */\n  public clear() {\n    this._cache.clear();\n    try {\n      for (const ucbPromise of this._watchedKeys.values()) {\n        ucbPromise.unsubscribeCB();\n      }\n    } finally {\n      this._watchedKeys.clear();\n    }\n  }\n\n  /**\n   * Create a pubsub subscription to invalidation messages for the given key.\n   */\n  private _subscribe(key: Key): UnsubscribeCallbackPromise {\n    return this._options.pubSubManager.subscribe(this._options.getChannel(key),\n      // When we receive a message, process the invalidation unless it matches our own unique\n      // ID, which indicates this invalidation came from us and already got processed.\n      msg => (msg === this._selfId ? null : this._cache.delete(key)),\n    );\n  }\n\n  /**\n   * When a key expires, unsubscribe from pubsub. We'll re-subscribe next time we need it.\n   */\n  private _onExpire(key: Key) {\n    const ucbPromise = this._watchedKeys.get(key);\n    this._watchedKeys.delete(key);\n    ucbPromise?.unsubscribeCB();\n  }\n}\n"
  },
  {
    "path": "app/server/lib/PubSubManager.ts",
    "content": "/**\n * RedisPubSubManager simplifies and optimizes pub-sub with Redis:\n *\n * 1. It's a single object in the server, reusing a pair of connections, so that other code\n *    doesn't need to create or maintain additional connections.\n * 2. It exposes a simple interface.\n * 3. It provides an in-memory fallback, to avoid the need for special code paths for\n *    single-server instances without Redis available.\n * 4. It automatically prefixes channels to scope them to the current Redis database using\n *    getPubSubPrefix(), so that calling code doesn't have to worry about it.\n */\nimport { mapGetOrSet } from \"app/common/AsyncCreate\";\nimport { arrayRemove, removePrefix, setDefault } from \"app/common/gutil\";\nimport log from \"app/server/lib/log\";\nimport { getPubSubPrefix } from \"app/server/lib/serverUtils\";\n\nimport IORedis from \"ioredis\";\n\n/**\n * Creates a new PubSubManager, either redis-based or in-memory, depending on whether redisUrl is\n * truthy. E.g. createPubSubManager(process.env.REDIS_URL).\n */\nexport function createPubSubManager(redisUrl: string | undefined): IPubSubManager {\n  return redisUrl ?\n    new PubSubManagerRedis(redisUrl) :\n    new PubSubManagerNoRedis();\n}\n\n// See PubSubManagerBase below for documentation.\nexport interface IPubSubManager {\n  close(): Promise<void>;\n  subscribe(channel: string, callback: Callback): UnsubscribeCallbackPromise;\n  publish(channel: string, message: string): Promise<void>;\n  publishBatch(batch: { channel: string, message: string }[]): Promise<void>;\n}\n\nexport type Callback = (message: string) => void;\nexport type UnsubscribeCallback = () => void;\n\n// When subscribing, we return the unsubscribe callback both as a promise and as a direct\n// property. This makes it easy to use when it's important to await the promise or handle a\n// rejection, and makes it easy to unsubscribe even if the subscription promise isn't yet\n// resolved (e.g. if we are reconnecting).\nexport interface UnsubscribeCallbackPromise extends Promise<UnsubscribeCallback> {\n  unsubscribeCB: UnsubscribeCallback;\n}\n\nabstract class PubSubManagerBase implements IPubSubManager {\n  protected _subscribePromises = new Map<string, Promise<void>>();\n  protected _subscriptions = new Map<string, Callback[]>();\n\n  constructor() {}\n\n  /**\n   * Close the manager, and close any connections used.\n   */\n  public async close() {\n    this._subscriptions.clear();\n    this._subscribePromises.clear();\n  }\n\n  /**\n   * Subscribes to the given channel with the given callback. Returns a cleanup function which you\n   * should call to remove this subscription.\n   *\n   * - In Redis, the channel gets prefixed with getPubSubPrefix() to scope it to the current Redis DB.\n   * - It's OK to subscribe multiple callbacks to the same channel. When the last one is removed,\n   *   the Redis subscription get removed.\n   *\n   * The returned unsubscribe callbcak is returned both as a promise and as the .unsubscribeCB\n   * property on this promise. If logging the error is sufficient error-handling, you may use\n   * .unsubscribeCB without waiting for the promise: an error message gets logged in case of\n   * error, and using .unsubscribeCB is fine even if there was a subscribe error.\n   */\n  public subscribe(channel: string, callback: Callback): UnsubscribeCallbackPromise {\n    const subscribePromise = mapGetOrSet(this._subscribePromises, channel, () => {\n      const promise = this._redisSubscribe(channel);\n      promise.catch(err => log.error(`PubSubManager: failed to subscribe to ${channel}: ${err}`));\n      return promise;\n    });\n\n    const callbacks = setDefault(this._subscriptions, channel, []);\n    callbacks.push(callback);\n\n    // If subscription actually fails, don't keep the callback in the list.\n    subscribePromise.catch(err => arrayRemove(callbacks, callback));\n\n    // The unsubscribe callback is available immediately (it's just a function to call\n    // unsubscribe). We make it available both as a promise result, and as a property of the\n    // promise. E.g. for removing a subscription, it may be called immediately without having to\n    // wait for the promise to resolve.\n    const unsubscribeCB: UnsubscribeCallback = () => this._unsubscribe(channel, callback);\n    return Object.assign(subscribePromise.then(() => unsubscribeCB), {\n      unsubscribeCB,\n    });\n  }\n\n  /**\n   * Publishes a message to the given channel.\n   *\n   * - In Redis, the channel gets prefixed with getPubSubPrefix() to scope it to the current Redis DB.\n   */\n  public abstract publish(channel: string, message: string): Promise<void>;\n\n  /**\n   * Just like multiple publish calls, but in a single batch.\n   */\n  public abstract publishBatch(batch: { channel: string, message: string }[]): Promise<void>;\n\n  protected abstract _redisSubscribe(channel: string): Promise<void>;\n  protected abstract _redisUnsubscribe(channel: string): Promise<void>;\n\n  protected _deliverMessage(channel: string, message: string) {\n    const callbacks = this._subscriptions.get(channel);\n    callbacks?.forEach(cb => cb(message));\n  }\n\n  private _unsubscribe(channel: string, callback: Callback): void {\n    const callbacks = this._subscriptions.get(channel);\n    if (callbacks) {\n      arrayRemove(callbacks, callback);\n      if (callbacks.length === 0) {\n        this._subscriptions.delete(channel);\n        this._subscribePromises.delete(channel);\n        this._redisUnsubscribe(channel)\n          .catch(err => log.error(`PubSubManager: failed to unsubscribe from ${channel}: ${err}`));\n      }\n    }\n  }\n}\n\nclass PubSubManagerNoRedis extends PubSubManagerBase {\n  public async publish(channel: string, message: string) { this._deliverMessage(channel, message); }\n  public async publishBatch(batch: { channel: string, message: string }[]) {\n    batch.forEach(({ channel, message }) => this._deliverMessage(channel, message));\n  }\n\n  protected async _redisSubscribe(channel: string): Promise<void> {}\n  protected async _redisUnsubscribe(channel: string): Promise<void> {}\n}\n\nclass PubSubManagerRedis extends PubSubManagerBase {\n  private _redisSub: IORedis;\n  private _redisPub: IORedis;\n  private _pubSubPrefix: string = getPubSubPrefix();\n\n  constructor(redisUrl: string) {\n    super();\n    // Back off faster and retry more slowly than the default, to avoid filling up logs needlessly.\n    const retryStrategy = (times: number) => Math.min((times ** 2) * 50, 10000);\n\n    // Redis acting as a subscriber can't run other commands. Need a separate one for publishing.\n    this._redisSub = new IORedis(redisUrl, { retryStrategy });\n    this._redisPub = new IORedis(redisUrl, { retryStrategy });\n\n    this._redisSub.on(\"error\", err => log.error(\"PubSubManagerRedis: redisSub connection error:\", String(err)));\n    this._redisPub.on(\"error\", err => log.error(\"PubSubManagerRedis: redisPub connection error:\", String(err)));\n\n    this._redisSub.on(\"message\", (fullChannel, message) => {\n      const channel = this._unprefixChannel(fullChannel);\n      if (channel != null) {\n        this._deliverMessage(channel, message);\n      }\n    });\n  }\n\n  public async close() {\n    this._redisSub.disconnect();\n    this._redisPub.disconnect();\n    await super.close();\n  }\n\n  public async publish(channel: string, message: string): Promise<void> {\n    await this._redisPub.publish(this._prefixChannel(channel), message);\n  }\n\n  public async publishBatch(batch: { channel: string, message: string }[]): Promise<void> {\n    let pipeline = this._redisPub.pipeline();\n    for (const { channel, message } of batch) {\n      pipeline = pipeline.publish(this._prefixChannel(channel), message);\n    }\n    await pipeline.exec();\n  }\n\n  protected async _redisSubscribe(channel: string): Promise<void> {\n    await this._redisSub.subscribe(this._prefixChannel(channel));\n  }\n\n  protected async _redisUnsubscribe(channel: string): Promise<void> {\n    await this._redisSub.unsubscribe(this._prefixChannel(channel));\n  }\n\n  private _prefixChannel = (channel: string) => this._pubSubPrefix + channel;\n  private _unprefixChannel = (fullChannel: string) => removePrefix(fullChannel, this._pubSubPrefix);\n}\n"
  },
  {
    "path": "app/server/lib/Requests.ts",
    "content": "import { SandboxRequest } from \"app/common/ActionBundle\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport { httpEncoding } from \"app/server/lib/httpEncoding\";\nimport log from \"app/server/lib/log\";\nimport { fetchUntrustedWithAgent } from \"app/server/lib/ProxyAgent\";\n\nimport * as path from \"path\";\n\nimport * as fse from \"fs-extra\";\nimport chunk from \"lodash/chunk\";\nimport fromPairs from \"lodash/fromPairs\";\nimport zipObject from \"lodash/zipObject\";\nimport * as tmp from \"tmp\";\n\nexport class DocRequests {\n  // Request responses are briefly cached in files only to handle multiple requests in a formula\n  // and only as long as needed to finish calculating all formulas.\n  // When _numPending reaches 0 again, _cacheDir is deleted.\n  private _numPending: number = 0;\n  private _cacheDir: tmp.DirResult | null = null;\n\n  constructor(private readonly _activeDoc: ActiveDoc) {}\n\n  public async handleRequestsBatchFromUserActions(requests: Record<string, SandboxRequest>) {\n    const numRequests = Object.keys(requests).length;\n    this._numPending += numRequests;\n    try {\n      // Perform batches of requests in parallel for speed, and hope it doesn't cause rate limiting...\n      for (const keys of chunk(Object.keys(requests), 10)) {\n        const responses: Response[] = await Promise.all(keys.map(async (key) => {\n          const request = requests[key];\n          const response = await this.handleSingleRequestWithCache(key, request);\n          return {\n            ...response,\n            // Tells the engine which cell(s) made the request and should be recalculated to use the response\n            deps: request.deps,\n          };\n        }));\n        // Tell the sandbox which previous responses we have cached in files.\n        // This lets it know it can immediately and synchronously get those responses again.\n        const cachedRequestKeys = await fse.readdir(this._cacheDir!.name);\n        // Recalculate formulas using this batch of responses.\n        const action = [\"RespondToRequests\", zipObject(keys, responses), cachedRequestKeys];\n        await this._activeDoc.applyUserActions(makeExceptionalDocSession(\"system\"), [action]);\n      }\n    } finally {\n      this._numPending -= numRequests;\n      if (this._numPending === 0) {\n        log.debug(`Removing DocRequests._cacheDir: ${this._cacheDir!.name}`);\n        this._cacheDir!.removeCallback();\n        this._cacheDir = null;\n      }\n    }\n  }\n\n  public async handleSingleRequestWithCache(key: string, request: SandboxRequest): Promise<Response> {\n    if (!this._cacheDir) {\n      // Use the sync API because otherwise multiple requests being handled at the same time\n      // all reach this point, `await`, and create different dirs.\n      // `unsafeCleanup: true` means the directory can be deleted even if it's not empty, which is what we expect.\n      this._cacheDir = tmp.dirSync({ unsafeCleanup: true });\n      log.debug(`Created DocRequests._cacheDir: ${this._cacheDir.name}`);\n    }\n\n    const cachePath = path.resolve(this._cacheDir.name, key);\n    try {\n      const result = await fse.readJSON(cachePath);\n      result.content = Buffer.from(result.content, \"base64\");\n      return result;\n    } catch {\n      const result = await this._handleSingleRequestRaw(request);\n      const resultForJson = { ...result } as any;\n      if (\"content\" in result) {\n        resultForJson.content = result.content.toString(\"base64\");\n      }\n      fse.writeJSON(cachePath, resultForJson).catch(e => log.warn(`Failed to save response to cache file: ${e}`));\n      return result;\n    }\n  }\n\n  private async _handleSingleRequestRaw(request: SandboxRequest): Promise<Response> {\n    try {\n      if (process.env.GRIST_ENABLE_REQUEST_FUNCTION != \"1\") {\n        throw new Error(\"REQUEST is not enabled\");\n      }\n      const { url, method, body, params, headers } = request;\n      const urlObj = new URL(url);\n      log.rawInfo(\"Handling sandbox request\", { host: urlObj.host, docId: this._activeDoc.docName });\n      for (const [param, value] of Object.entries(params || {})) {\n        urlObj.searchParams.append(param, value);\n      }\n      const response = await fetchUntrustedWithAgent(urlObj, {\n        headers: headers || {},\n        method,\n        body,\n      });\n      const content = await response.buffer();\n      const { status, statusText } = response;\n      const encoding = httpEncoding(response.headers.get(\"content-type\"), content);\n      return {\n        content, status, statusText, encoding,\n        headers: fromPairs([...response.headers]),\n      };\n    } catch (e) {\n      return { error: String(e) };\n    }\n  }\n}\n\ninterface SuccessfulResponse {\n  content: Buffer;\n  status: number;\n  statusText: string;\n  encoding?: string;\n  headers: Record<string, string>;\n}\n\ninterface RequestError {\n  error: string;\n}\n\ntype Response = RequestError | SuccessfulResponse;\n"
  },
  {
    "path": "app/server/lib/RowAccess.ts",
    "content": "import { DocAction, getRowIdsFromDocAction, getTableId } from \"app/common/DocActions\";\nimport { getSetMapValue } from \"app/common/gutil\";\n\n/**\n * A little class for tracking pre-existing rows touched by a sequence of DocActions for\n * a given table.\n */\nclass RowIdTracker {\n  public blockedIds = new Set<number>();  // row ids minted within the DocActions (so NOT pre-existing).\n  public blocked: boolean = false;        // set if all pre-existing rows are wiped/\n  public ids = new Set<number>();         // set of pre-existing rows touched.\n}\n\n/**\n * This gets a list of pre-existing rows that the DocActions may touch.  Returns\n * a list of form [tableId, Set{rowId1, rowId2, ...}].\n */\nexport function getRelatedRows(docActions: DocAction[]): readonly (readonly [string, Set<number>])[] {\n  // Relate tableIds for tables with what they were before the actions, if renamed.\n  const tableIds = new Map<string, string>();      // key is current tableId\n  const rowIds = new Map<string, RowIdTracker>();  // key is pre-existing tableId\n  const addedTables = new Set<string>();  // track newly added tables to ignore; key is current tableId\n  for (const docAction of docActions) {\n    const currentTableId = getTableId(docAction);\n    const tableId = tableIds.get(currentTableId) || currentTableId;\n    if (docAction[0] === \"RenameTable\") {\n      if (addedTables.has(currentTableId)) {\n        addedTables.delete(currentTableId);\n        addedTables.add(docAction[2]);\n        continue;\n      }\n      tableIds.delete(currentTableId);\n      tableIds.set(docAction[2], tableId);\n      continue;\n    }\n    if (docAction[0] === \"AddTable\") {\n      addedTables.add(currentTableId);\n    }\n    if (docAction[0] === \"RemoveTable\") {\n      addedTables.delete(currentTableId);\n      continue;\n    }\n    if (addedTables.has(currentTableId)) { continue; }\n\n    // tableId will now be that prior to docActions, regardless of renames.\n    const tracker = getSetMapValue(rowIds, tableId, () => new RowIdTracker());\n\n    if (docAction[0] === \"RemoveRecord\" || docAction[0] === \"BulkRemoveRecord\" ||\n      docAction[0] === \"UpdateRecord\" || docAction[0] === \"BulkUpdateRecord\") {\n      // All row ids mentioned are external, unless created within this set of DocActions.\n      if (!tracker.blocked) {\n        for (const id of getRowIdsFromDocAction(docAction)) {\n          if (!tracker.blockedIds.has(id)) { tracker.ids.add(id); }\n        }\n      }\n    } else if (docAction[0] === \"AddRecord\" || docAction[0] === \"BulkAddRecord\") {\n      // All row ids mentioned are created within this set of DocActions, and are not external.\n      for (const id of getRowIdsFromDocAction(docAction)) { tracker.blockedIds.add(id); }\n    } else if (docAction[0] === \"ReplaceTableData\" || docAction[0] === \"TableData\") {\n      // No pre-existing rows can be referred to for this table from now on.\n      tracker.blocked = true;\n    }\n  }\n\n  return [...rowIds.entries()].map(([tableId, tracker]) => [tableId, tracker.ids] as const);\n}\n"
  },
  {
    "path": "app/server/lib/SQLiteDB.ts",
    "content": "/**\n * SQLiteDB provides a clean Promise-based interface to SQLite along with an organized way to\n * specify the initial structure of the database and migrations when this structure changes.\n *\n * Here's a simple example,\n *\n *    const schemaInfo: SQLiteDB.SchemaInfo = {\n *      async create(db: SQLiteDB.SQLiteDB) {\n *        await db.exec(\"CREATE TABLE Foo (A TEXT)\");\n *      },\n *      migrations: [\n *        async function(db: SQLiteDB.SQLiteDB) {\n *          await db.exec(\"CREATE TABLE Foo (A TEXT)\");\n *        }\n *      ],\n *    }\n *    const db = await SQLiteDB.openDB(\"pathToDB\", schemaInfo, SQLiteDB.OpenMode.OPEN_CREATE);\n *\n * Note how the create() function and the first migration are identical here. But they'll diverge\n * once we make a change to the schema. E.g. the next change could look like this:\n *\n *    const schemaInfo: SQLiteDB.SchemaInfo = {\n *      async create(db: SQLiteDB.SQLiteDB) {\n *        await db.exec(\"CREATE TABLE Foo (A TEXT, B NUMERIC)\");\n *      },\n *      migrations: [\n *        async function(db: SQLiteDB.SQLiteDB) {\n *          await db.exec(\"CREATE TABLE Foo (A TEXT)\");\n *        },\n *        async function(db: SQLiteDB.SQLiteDB) {\n *          await db.exec(\"ALTER TABLE Foo ADD COLUMN B NUMERIC\");\n *        }\n *      ],\n *    }\n *    const db = await SQLiteDB.openDB(\"pathToDB\", schemaInfo, SQLiteDB.OpenMode.OPEN_CREATE);\n *\n * Now a new document will have two columns. A document created with the first version of the code\n * will gain a second column when opened with the new code. If a migration happened during open,\n * you may examine two properties of the returned db object:\n *\n *    db.migrationBackupPath -- set to the path of the pre-migration backup file.\n *    db.migrationError -- set to the Error object if the migration failed.\n *\n * This module uses SQLite's \"user_version\" pragma to keep track of the version number of a\n * migration. It does not require, support, or record backwards migrations, but it will warn of\n * inconsistencies that may arise during development. In that case, remember you have a backup\n * from each migration.\n *\n * If you are starting with an existing unversioned DB, the first migration should have code to\n * bring such DBs to a common state.\n *\n *    const schemaInfo: SQLiteDB.SchemaInfo = {\n *      async create(db: SQLiteDB.SQLiteDB) {\n *        await db.exec(\"CREATE TABLE Foo (A TEXT)\");\n *        await db.exec(\"CREATE TABLE Bar (B TEXT)\");\n *      },\n *      migrations: [\n *        async function(db: SQLiteDB.SQLiteDB) {\n *          await db.exec(\"CREATE TABLE IF NOT EXISTS Foo (A TEXT)\");\n *          await db.exec(\"CREATE TABLE IF NOT EXISTS Bar (B TEXT)\");\n *        }\n *      ],\n *    }\n *    const db = await SQLiteDB.openDB(\"pathToDB\", schemaInfo, SQLiteDB.OpenMode.OPEN_CREATE);\n *\n * Once using this module with versioning, future changes would be made by adding one item to the\n * \"migrations\" array, and modifying create() to create correct new documents.\n */\n\nimport { delay } from \"app/common/delay\";\nimport { ErrorWithCode } from \"app/common/ErrorWithCode\";\nimport { timeFormat } from \"app/common/timeFormat\";\nimport { create } from \"app/server/lib/create\";\nimport * as docUtils from \"app/server/lib/docUtils\";\nimport log from \"app/server/lib/log\";\nimport {\n  Backup, MinDB, MinDBOptions, MinRunResult, PreparedStatement, ResultRow,\n  SqliteVariant, Statement } from \"app/server/lib/SqliteCommon\";\nimport { NodeSqliteVariant } from \"app/server/lib/SqliteNode\";\n\nimport assert from \"assert\";\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nimport * as fse from \"fs-extra\";\nimport fromPairs from \"lodash/fromPairs\";\nimport isEqual from \"lodash/isEqual\";\nimport noop from \"lodash/noop\";\nimport range from \"lodash/range\";\n\nexport type { PreparedStatement, ResultRow, Statement };\nexport type RunResult = MinRunResult;\n\n// A little bit of async local storage, for nested transactions.\nconst asyncLocalStorage = new AsyncLocalStorage<boolean>();\n\nfunction getVariant(): SqliteVariant {\n  return create.getSqliteVariant?.() || new NodeSqliteVariant();\n}\n\n// Describes how to create a new DB or migrate an old one. Any changes to the DB must be reflected\n// in the 'create' function, and added as new entries in the 'migrations' array. Existing\n// 'migration' entries may not be modified; they are used to migrate older DBs.\nexport interface SchemaInfo {\n  // Creates a structure for a new DB (i.e. execs CREATE TABLE statements).\n  readonly create: DBFunc;\n\n  // List of functions that perform DB migrations from one version to the next. This array's\n  // length determines the schema version, which is stored in user_version SQLite property.\n  //\n  // The very first migration should normally be identical to the original version of create().\n  // I.e. initially SchemaInfo should be { create: X, migrations: [X] }, where the two X's\n  // represent two copies of the same code. Don't go for code reuse here. When the schema is\n  // modified, you will change it to { create: X2, migrations: [X, Y] }. Keeping the unchanged\n  // copy of X is important as a reference to see that X + Y produces the same DB as X2.\n  //\n  // If you may open DBs created without versioning (e.g. predate use of this module), such DBs\n  // will go through all migrations including the very first one. In this case, the first\n  // migration's job is to bring any older DB to the same consistent state.\n  readonly migrations: readonly DBFunc[];\n}\n\nexport type DBFunc = (db: SQLiteDB) => Promise<void>;\n\nexport enum OpenMode {\n  OPEN_CREATE,      // Open DB or create if doesn't exist (the default mode for sqlite3 module)\n  OPEN_EXISTING,    // Open DB or fail if doesn't exist\n  OPEN_READONLY,    // Open DB in read-only mode or fail if doesn't exist.\n  CREATE_EXCL,      // Create new DB or fail if it already exists.\n}\n\n/**\n * Callbacks to use if a migration is run, so that backups are made.\n */\nexport interface MigrationHooks {\n  beforeMigration?(currentVersion: number, newVersion: number): Promise<void>;\n  afterMigration?(newVersion: number, success: boolean): Promise<void>;\n}\n\n/**\n * An interface implemented both by SQLiteDB and DocStorage (by forwarding).  Methods\n * documented in SQLiteDB.\n */\nexport interface ISQLiteDB {\n  exec(sql: string): Promise<void>;\n  run(sql: string, ...params: any[]): Promise<RunResult>;\n  get(sql: string, ...params: any[]): Promise<ResultRow | undefined>;\n  all(sql: string, ...params: any[]): Promise<ResultRow[]>;\n  prepare(sql: string, ...params: any[]): Promise<PreparedStatement>;\n  execTransaction<T>(callback: () => Promise<T>): Promise<T>;\n  runAndGetId(sql: string, ...params: any[]): Promise<number>;\n  requestVacuum(): Promise<boolean>;\n  backup?(filename: string): Backup;\n}\n\n/**\n * Wrapper around sqlite3.Database. This class provides many of the same methods, but promisified.\n * In addition, it offers:\n *\n *    SQLiteDB.openDB(): Opens a DB, and initialize or migrate it to correct schema.\n *    db.execTransaction(cb): Runs a callback in the context of a new DB transaction.\n */\nexport class SQLiteDB implements ISQLiteDB {\n  /**\n   * Opens a database or creates a new one, according to OpenMode enum. The schemaInfo specifies\n   * how to initialize a new database, and how to migrate an existing one from an older version.\n   * If the database was migrated, its \"migrationBackupPath\" property will be set.\n   *\n   * If a migration was needed but failed, the DB remains unchanged, and gets opened anyway.\n   * We report the migration error, and expose it via .migrationError property.\n   */\n  public static async openDB(dbPath: string, schemaInfo: SchemaInfo,\n    mode: OpenMode = OpenMode.OPEN_CREATE,\n    hooks: MigrationHooks = {}): Promise<SQLiteDB> {\n    const db = await SQLiteDB.openDBRaw(dbPath, mode);\n    const userVersion: number = await db.getMigrationVersion();\n\n    // It's possible that userVersion is 0 for a non-empty DB if it was created without this\n    // module. In that case, we apply migrations starting with the first one.\n    if (userVersion === 0 && (await isGristEmpty(db))) {\n      await db._initNewDB(schemaInfo);\n    } else if (mode === OpenMode.CREATE_EXCL) {\n      await db.close();\n      throw new ErrorWithCode(\"EEXISTS\", `EEXISTS: Database already exists: ${dbPath}`);\n    } else {\n      // Don't attempt migrations in OPEN_READONLY mode.\n      if (mode === OpenMode.OPEN_READONLY) {\n        const targetVer: number = schemaInfo.migrations.length;\n        if (userVersion < targetVer) {\n          db._migrationError = new Error(`SQLiteDB[${dbPath}] needs migration but is readonly`);\n        }\n      } else {\n        try {\n          db._migrationBackupPath = await db._migrate(userVersion, schemaInfo, hooks);\n        } catch (err) {\n          db._migrationError = err;\n        }\n      }\n      await db._reportSchemaDiscrepancies(schemaInfo);\n    }\n    return db;\n  }\n\n  /**\n   * Opens a database or creates a new one according to OpenMode value. Does not check for or do\n   * any migrations.\n   */\n  public static async openDBRaw(dbPath: string,\n    mode: OpenMode = OpenMode.OPEN_CREATE): Promise<SQLiteDB> {\n    const minDb: MinDB = await getVariant().opener(dbPath, mode);\n    if (SQLiteDB._addOpens(dbPath, 1) > 1) {\n      log.warn(\"SQLiteDB[%s] avoid opening same DB more than once\", dbPath);\n    }\n    return new SQLiteDB(minDb, dbPath);\n  }\n\n  /**\n   * Reads the migration version from the database without any attempts to migrate it.\n   */\n  public static async getMigrationVersion(dbPath: string): Promise<number> {\n    const db = await SQLiteDB.openDBRaw(dbPath, OpenMode.OPEN_READONLY);\n    try {\n      return await db.getMigrationVersion();\n    } finally {\n      await db.close();\n    }\n  }\n\n  // It is a bad idea to open the same database file multiple times, because simultaneous use can\n  // cause SQLITE_BUSY errors, and artificial delays (default of 1 sec) when there is contention.\n  // We keep track of open DB paths, and warn if one is opened multiple times.\n  private static _openPaths = new Map<string, number>();\n\n  // Convert the \"create\" function from schemaInfo into a DBMetadata object that describes the\n  // tables, columns, and types. This is used for checking if an open database matches the\n  // schema we expect, including after a migration, and reporting discrepancies.\n  private static async _getExpectedMetadata(schemaInfo: SchemaInfo): Promise<DBMetadata> {\n    // We cache the result and associate it with the create function, since it's not that cheap to\n    // build. To build the metadata, we open an in-memory DB and apply \"create\" function to it.\n    // Note that for tiny DBs it takes <10ms.\n    if (!dbMetadataCache.has(schemaInfo.create)) {\n      const db = await SQLiteDB.openDB(\":memory:\", schemaInfo, OpenMode.CREATE_EXCL);\n      dbMetadataCache.set(schemaInfo.create, await db.collectMetadata());\n      await db.close();\n    }\n    return dbMetadataCache.get(schemaInfo.create)!;\n  }\n\n  // Private helper to keep track of opens for the same path. Returns the number of times this\n  // path is open, after adding the delta. Use delta of +1 for open, -1 for close.\n  private static _addOpens(dbPath: string, delta: number): number {\n    const newCount = (SQLiteDB._openPaths.get(dbPath) || 0) + delta;\n    if (newCount > 0) {\n      SQLiteDB._openPaths.set(dbPath, newCount);\n    } else {\n      SQLiteDB._openPaths.delete(dbPath);\n    }\n    return newCount;\n  }\n\n  private _prevTransaction: Promise<any> = Promise.resolve();\n  private _migrationBackupPath: string | null = null;\n  private _migrationError: Error | null = null;\n  private _needVacuum: boolean = false;\n  private _closed: boolean = false;\n  private _paused: Promise<void> | undefined = undefined;\n  private _pauseResolve: (() => void) | undefined = undefined;\n  private _pauseReject: ((reason?: any) => void) | undefined = undefined;\n\n  private constructor(protected _db: MinDB, private _dbPath: string) {\n  }\n\n  public async interrupt(): Promise<void> {\n    return this._db.interrupt?.();\n  }\n\n  public backup(filename: string): Backup {\n    if (!this._db.backup) {\n      throw new Error(\"SQLite wrapper does not support backups\");\n    }\n    return this._db.backup(filename);\n  }\n\n  public getOptions(): MinDBOptions | undefined {\n    return this._db.getOptions?.();\n  }\n\n  public async all(sql: string, ...args: any[]): Promise<ResultRow[]> {\n    const result = await this._db.all(sql, ...args);\n    return result;\n  }\n\n  public async run(sql: string, ...args: any[]): Promise<MinRunResult> {\n    await this._applyPause();\n    return this._db.run(sql, ...args);\n  }\n\n  public async exec(sql: string, options?: {\n    // For testing purposes, don't respect pause.\n    testIgnorePause?: boolean\n  }): Promise<void> {\n    if (!options?.testIgnorePause) {\n      await this._applyPause();\n    }\n    return this._db.exec(sql);\n  }\n\n  public prepare(sql: string): Promise<PreparedStatement> {\n    return this._db.prepare(sql);\n  }\n\n  public get(sql: string, ...args: any[]): Promise<ResultRow | undefined> {\n    return this._db.get(sql, ...args);\n  }\n\n  /**\n   * If a DB was migrated on open, this will be set to the path of the pre-migration backup copy.\n   * If migration failed, open throws with unchanged DB and no backup file.\n   */\n  public get migrationBackupPath(): string | null { return this._migrationBackupPath; }\n\n  /**\n   * If a needed migration failed, the DB will be opened anyway, with this property set to the\n   * error. E.g. you may use it like so:\n   *    sdb = await SQLiteDB.openDB(...)\n   *    if (sdb.migrationError) { throw sdb.migrationError; }\n   */\n  public get migrationError(): Error | null { return this._migrationError; }\n\n  // The following methods mirror https://github.com/mapbox/node-sqlite3/wiki/API, but return\n  // Promises. We use fromCallback() rather than use promisify, to get better type-checking.\n\n  public async allMarshal(sql: string, ...params: any[]): Promise<Buffer> {\n    return this._db.allMarshal(sql, ...params);\n  }\n\n  /**\n   * VACUUM the DB either immediately or, if in a transaction, after that transaction.\n   */\n  public async requestVacuum(): Promise<boolean> {\n    if (this._inTransaction) {\n      this._needVacuum = true;\n      return false;\n    }\n    await this.vacuum();\n    log.info(\"SQLiteDB[%s]: DB VACUUMed\", this._dbPath);\n    this._needVacuum = false;\n    return true;\n  }\n\n  public async vacuum(): Promise<void> {\n    await this._db.limitAttach(1);  // VACUUM implementation uses ATTACH.\n    try {\n      await this.exec(\"VACUUM\");\n    } finally {\n      await this._db.limitAttach(0);  // Outside of VACUUM, we don't allow ATTACH.\n    }\n  }\n\n  /**\n   * Run each of the statements in turn. Each statement is either a string, or an array of arguments\n   * to db.run, e.g. [sqlString, [params...]].\n   */\n  public async runEach(...statements: (string | [string, any[]])[]): Promise<void> {\n    for (const stmt of statements) {\n      try {\n        if (Array.isArray(stmt)) {\n          await this.run(stmt[0], ...stmt[1]);\n        } else {\n          await this.exec(stmt);\n        }\n      } catch (err) {\n        log.warn(`SQLiteDB: Failed to run ${stmt}`);\n        throw err;\n      }\n    }\n  }\n\n  public async close(): Promise<void> {\n    const alreadyClosed = this._closed;\n    this._closed = true;\n    if (!alreadyClosed) {\n      this._pauseReject?.(new Error(\"SQLiteDB closing, writes should stop\"));\n      let tries: number = 0;\n      // We might not be able to close immediately if a backup is in\n      // progress. We will retry for about 10 seconds. Worst case is\n      // if a backup was in progress and is right at the last step of\n      // the backup and a lot of edits were made while the previous\n      // steps were in progress.\n      while (tries < 100) {\n        tries++;\n        try {\n          await this._db.close();\n          break;\n        } catch (e) {\n          if (String(e).match(/SQLITE_BUSY: unable to close due to unfinalized statements or unfinished backups/)) {\n            // Try again! now that this._closed is set, any pending backup should stop.\n            // It will stop quickly if in the middle of a backup, or more slowly if on\n            // the last step.\n            if (tries % 10 === 1) {\n              log.debug(\"SQLiteDB[%s]: waiting to close\", this._dbPath);\n            }\n            await delay(100);\n          } else {\n            throw e;\n          }\n        }\n      }\n      SQLiteDB._addOpens(this._dbPath, -1);\n    }\n  }\n\n  public isClosed(): boolean {\n    return this._closed;\n  }\n\n  /**\n   * As for run(), but captures the last_insert_rowid after the statement executes.  This\n   * is sqlite's rowid for the last insert made on this database connection. This method\n   * is only useful if the sql is actually an INSERT operation, but we don't check this.\n   */\n  public async runAndGetId(sql: string, ...params: any[]): Promise<number> {\n    return this._db.runAndGetId(sql, ...params);\n  }\n\n  /**\n   * Runs callback() in the context of a new DB transaction, committing on success and rolling\n   * back on error in the callback. The callback may return a promise, which will be waited for.\n   * The callback is called with no arguments.\n   *\n   * This method can be nested.  The result is one big merged transaction that will succeed or\n   * roll back as a single unit.\n   *\n   * We also expect execTransaction()s to be strictly ordered. For example, if many are\n   * started in quick succession, without waiting for previous ones to complete, later transactions\n   * should be able to rely on previous ones being complete before they run.\n   *\n   */\n  public async execTransaction<T>(callback: () => Promise<T>): Promise<T> {\n    // If in a transaction, merge any nested transactions into the main one.\n    if (this._inTransaction) {\n      return callback();\n    }\n    // If we're going to pause, pause now before we get into the\n    // transaction.\n    await this._applyPause();\n    try {\n      // Create a promise that:\n      //   - Waits for any previous transaction to complete (swallowing errors).\n      //   - Runs the supplied callback within a BEGIN/COMMIT block.\n      //   - Runs that callback with async local storage set, to\n      //     facilitate nesting.\n      // Then assign that promise to _prevTransaction and wait for it.\n      return await (\n        this._prevTransaction =\n          this._prevTransaction.catch(noop).then(\n            () => asyncLocalStorage.run(true, () => this._execTransactionImpl(callback)),\n          )\n      );\n    } finally {\n      if (this._needVacuum) {\n        await this.requestVacuum();\n      }\n    }\n  }\n\n  /**\n   * Returns the 'user_version' saved in the database that reflects the current DB schema. It is 0\n   * initially, and we update it to 1 or higher when initializing or migrating the database.\n   */\n  public async getMigrationVersion(): Promise<number> {\n    const row = await this.get(\"PRAGMA user_version\");\n    return (row?.user_version) || 0;\n  }\n\n  /**\n   * Creates a DBMetadata object mapping DB's table names to column names to column types. Used\n   * for reporting discrepancies in DB schema, and exposed for tests.\n   *\n   * Optionally, a list of table names can be supplied, and metadata will be omitted for any\n   * tables not named in that list.\n   */\n  public async collectMetadata(names?: string[]): Promise<DBMetadata> {\n    const tables = await this.all(\"SELECT name FROM sqlite_master WHERE type='table'\");\n    const metadata: DBMetadata = {};\n    for (const t of tables) {\n      if (names && !names.includes(t.name)) { continue; }\n      const infoRows = await this.all(`PRAGMA table_info(${quoteIdent(t.name)})`);\n      const columns = fromPairs(infoRows.map(r => [r.name, r.type]));\n      metadata[t.name] = columns;\n    }\n    return metadata;\n  }\n\n  /**\n   * Call this if you'd like the next write to be held until unpause() is\n   * called. Used by the backup system.\n   */\n  public pause() {\n    if (this._paused || this._closed) { return; }\n    this._paused = new Promise((resolve, reject) => {\n      this._pauseResolve = resolve;\n      this._pauseReject = reject;\n    });\n  }\n\n  /**\n   * Completes or cancels any pause on writing.\n   */\n  public unpause() {\n    this._pauseResolve?.();\n    this._pauseResolve = undefined;\n    this._pauseReject = undefined;\n    this._paused = undefined;\n  }\n\n  // Implementation of execTransction.\n  private async _execTransactionImpl<T>(callback: () => Promise<T>): Promise<T> {\n    await this.exec(\"BEGIN\");\n    try {\n      // It is important that nothing in this callback will honor\n      // pauses or we could deadlock, paused before we reach the\n      // COMMIT below. Since _inTransaction is true for the duration\n      // of this callback, that should be the case.\n      const value = await callback();\n      await this.exec(\"COMMIT\");\n      return value;\n    } catch (err) {\n      try {\n        await this.exec(\"ROLLBACK\");\n      } catch (rollbackErr) {\n        log.error(\"SQLiteDB[%s]: Rollback failed: %s\", this._dbPath, rollbackErr);\n      }\n      throw err;    // Throw the original error from the transaction.\n    }\n  }\n\n  /**\n   * Applies schemaInfo.create function to initialize a new DB.\n   */\n  private async _initNewDB(schemaInfo: SchemaInfo): Promise<void> {\n    await this.execTransaction(async () => {\n      const targetVer: number = schemaInfo.migrations.length;\n      await schemaInfo.create(this);\n      await this.exec(`PRAGMA user_version = ${targetVer}`);\n    });\n  }\n\n  /**\n   * Applies migrations to this database according to MigrationInfo. In all cases, checks the\n   * database schema against MigrationInfo.currentSchema, and warns of discrepancies.\n   *\n   * If migration succeeded, it leaves a backup file and returns its path. If no migration was\n   * needed, returns null. If migration failed, leaves DB unchanged and throws Error.\n   */\n  private async _migrate(actualVer: number, schemaInfo: SchemaInfo,\n    hooks: MigrationHooks): Promise<string | null> {\n    const targetVer: number = schemaInfo.migrations.length;\n    let backupPath: string | null = null;\n    let success: boolean = false;\n\n    if (actualVer > targetVer) {\n      log.warn(\"SQLiteDB[%s]: DB is at version %s ahead of target version %s\",\n        this._dbPath, actualVer, targetVer);\n    } else if (actualVer < targetVer) {\n      log.info(\"SQLiteDB[%s]: DB needs migration from version %s to %s\",\n        this._dbPath, actualVer, targetVer);\n      const versions = range(actualVer, targetVer);\n      backupPath = await createBackupFile(this._dbPath, actualVer);\n      await hooks.beforeMigration?.(actualVer, targetVer);\n      try {\n        await this.execTransaction(async () => {\n          for (const versionNum of versions) {\n            await schemaInfo.migrations[versionNum](this);\n          }\n          await this.exec(`PRAGMA user_version = ${targetVer}`);\n        });\n        success = true;\n        // After a migration, reduce the sqlite file size. This must be run outside a transaction.\n        await this.vacuum();\n\n        log.info(\"SQLiteDB[%s]: DB backed up to %s, migrated to %s\",\n          this._dbPath, backupPath, targetVer);\n      } catch (err) {\n        // If the transaction failed, we trust SQLite to have left the DB in unmodified state, so\n        // we remove the pointless backup.\n        await fse.remove(backupPath);\n        backupPath = null;\n        log.warn(\"SQLiteDB[%s]: DB migration from %s to %s failed: %s\",\n          this._dbPath, actualVer, targetVer, err);\n        err.message = `SQLiteDB[${this._dbPath}] migration to ${targetVer} failed: ${err.message}`;\n        throw err;\n      } finally {\n        await hooks.afterMigration?.(targetVer, success);\n      }\n    }\n    return backupPath;\n  }\n\n  private async _reportSchemaDiscrepancies(schemaInfo: SchemaInfo): Promise<void> {\n    // Regardless of where we started, warn if DB doesn't match expected schema.\n    const expected = await SQLiteDB._getExpectedMetadata(schemaInfo);\n    const metadata = await this.collectMetadata(Object.keys(expected));\n    for (const tname in expected) {\n      if (expected.hasOwnProperty(tname) && !isEqual(metadata[tname], expected[tname])) {\n        log.warn(\"SQLiteDB[%s]: table %s does not match schema: %s != %s\",\n          this._dbPath, tname, JSON.stringify(metadata[tname]), JSON.stringify(expected[tname]));\n      }\n    }\n  }\n\n  private async _applyPause() {\n    if (this._inTransaction) { return; }\n    await this._paused;\n  }\n\n  private get _inTransaction() {\n    return asyncLocalStorage.getStore() !== undefined;\n  }\n}\n\n// Every SchemaInfo.create function determines a DB structure. We can get it by initializing a\n// dummy DB, and we use it to do sanity checking, in particular after migrations. To avoid\n// creating dummy DBs multiple times, the result is cached, keyed by the \"create\" function itself.\nconst dbMetadataCache = new Map<DBFunc, DBMetadata>();\nexport interface DBMetadata {\n  [tableName: string]: {\n    [colName: string]: string;      // Maps column name to SQLite type, e.g. \"TEXT\".\n  };\n}\n\n// Helper to see if a database is empty of grist metadata tables.\nasync function isGristEmpty(db: SQLiteDB): Promise<boolean> {\n  return (await db.get(\"SELECT count(*) as count FROM sqlite_master WHERE name LIKE '_grist%'\"))!.count === 0;\n}\n\n/**\n * Copies filePath to \"filePath.YYYY-MM-DD.V0[-N].bak\", adding \"-N\" suffix (starting at \"-2\") if\n * needed to ensure the path is new. Returns the backup path.\n */\nasync function createBackupFile(filePath: string, versionNum: number): Promise<string> {\n  const backupPath = await docUtils.createNumberedTemplate(\n    `${filePath}.${timeFormat(\"D\", new Date())}.V${versionNum}{NUM}.bak`,\n    docUtils.createExclusive);\n  await docUtils.copyFile(filePath, backupPath);\n  return backupPath;\n}\n\n/**\n * Validate and quote SQL identifiers such as table and column names.\n */\nexport function quoteIdent(ident: string): string {\n  assert(/^[\\w.]+$/.test(ident), `SQL identifier is not valid: ${ident}`);\n  return `\"${ident}\"`;\n}\n"
  },
  {
    "path": "app/server/lib/SafePythonComponent.ts",
    "content": "import { LocalPlugin } from \"app/common/plugin\";\nimport { BaseComponent, createRpcLogger } from \"app/common/PluginInstance\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { ISandbox } from \"app/server/lib/ISandbox\";\nimport log from \"app/server/lib/log\";\n\nimport { IMsgCustom, IMsgRpcCall } from \"grain-rpc\";\n\n// TODO safePython component should be able to call other components function\n// TODO calling a function on safePython component with a name that was not register chould fail\n// gracefully.\n\n/**\n * The safePython component used by a PluginInstance.\n *\n * It uses `NSandbox` implementation of rpc for calling methods within the sandbox.\n */\nexport class SafePythonComponent extends BaseComponent {\n  private _sandbox?: ISandbox;\n  private _logMeta: log.ILogMeta;\n\n  // safe python component does not need pluginInstance.rpc because it is not possible to forward\n  // calls to other component from within python\n  constructor(_localPlugin: LocalPlugin,\n    private _tmpDir: string,\n    docName: string, private _server: GristServer,\n    rpcLogger = createRpcLogger(log, `PLUGIN ${_localPlugin.id} SafePython:`)) {\n    super(_localPlugin.manifest, rpcLogger);\n    this._logMeta = { plugin: _localPlugin.id, docId: docName };\n  }\n\n  /**\n   * `SafePythonComponent` activation creates the Sandbox. Throws if the plugin has no `safePyton`\n   * components.\n   */\n  protected async activateImplementation(): Promise<void> {\n    if (!this._tmpDir) {\n      throw new Error(\"Sanbox should have a tmpDir\");\n    }\n    this._sandbox = this._server.create.NSandbox({\n      importMount: this._tmpDir,\n      logTimes: true,\n      logMeta: this._logMeta,\n      preferredPythonVersion: \"3\",\n    });\n  }\n\n  protected async deactivateImplementation(): Promise<void> {\n    log.info(\"SafePython deactivating ...\");\n    if (!this._sandbox) {\n      log.info(\"  sandbox is undefined\");\n    }\n    if (this._sandbox) {\n      await this._sandbox.shutdown();\n      log.info(\"SafePython done deactivating the sandbox\");\n      delete this._sandbox;\n    }\n  }\n\n  protected doForwardCall(c: IMsgRpcCall): Promise<any> {\n    if (!this._sandbox) { throw new Error(\"Component should have be activated\"); }\n    const { meth, iface, args } = c;\n    const funcName = meth === \"invoke\" ? iface : iface + \".\" + meth;\n    return this._sandbox.pyCall(funcName, ...args);\n  }\n\n  protected doForwardMessage(c: IMsgCustom): Promise<any> {\n    throw new Error(\"Forwarding messages to python sandbox is not supported\");\n  }\n}\n"
  },
  {
    "path": "app/server/lib/SamlConfig.ts",
    "content": "/**\n * Configuration for SAML, useful for enterprise single-sign-on logins.\n * A good informative overview of SAML is at https://www.okta.com/integrate/documentation/saml/\n * Note:\n *    SP is \"Service Provider\", in our case, the Grist application.\n *    IdP is the \"Identity Provider\", somewhere users log into, e.g. Okta or Google Apps.\n *\n * We expect IdP to provide us with name_id, a unique identifier for the user.\n * We also use optional attributes for the user's name, for which we accept any of:\n *    FirstName\n *    LastName\n *    http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname\n *    http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname\n *\n * Note that the code is based on the example at https://github.com/Clever/saml2\n *\n * To read more about Grist SAML flow and configuration through environmental variables, in a single server\n * setup, please visit:\n * https://support.getgrist.com/install/saml/\n *\n * Expected environment variables:\n *    env GRIST_SAML_SP_HOST=https://<your-domain>\n *        Host at which our /saml/assert endpoint will live; identifies our application.\n *    env GRIST_SAML_SP_KEY\n *        Path to file with our private key, PEM format.\n *    env GRIST_SAML_SP_CERT\n *        Path to file with our public key, PEM format.\n *    env GRIST_SAML_IDP_LOGIN\n *        Login url to redirect user to for log-in.\n *    env GRIST_SAML_IDP_LOGOUT\n *        Logout URL to redirect user to for log-out.\n *    env GRIST_SAML_IDP_SKIP_SLO\n *        If set and non-empty, don't attempt \"Single Logout\" flow (which I haven't gotten to\n *        work), but simply redirect to GRIST_SAML_IDP_LOGOUT after clearing session.\n *    env GRIST_SAML_IDP_CERTS\n *        Comma-separated list of paths for certificates from identity provider, PEM format.\n *    env GRIST_SAML_IDP_UNENCRYPTED\n *        If set and non-empty, allow unencrypted assertions, relying on https for privacy.\n *\n * This version of SamlConfig has been tested with Auth0 SAML IdP following the instructions\n * at:\n *   https://auth0.com/docs/protocols/saml-protocol/configure-auth0-as-saml-identity-provider\n * When running on localhost and http, the settings tested were with:\n *   - GRIST_SAML_IDP_SKIP_SLO not set\n *   - GRIST_SAML_SP_HOST=http://localhost:8080 or 8484\n *   - GRIST_SAML_IDP_UNENCRYPTED=1\n *   - GRIST_SAML_IDP_LOGIN=https://...auth0.com/samlp/xxxx\n *   - GRIST_SAML_IDP_LOGOUT=https://...auth0.com/samlp/xxxx  # these are same for Auth0\n *   - GRIST_SAML_IDP_CERTS=.../auth0.pem   # downloaded per Auth0 instructions\n *   - GRIST_SAML_SP_KEY=.../saml.pem       # created\n *   - GRIST_SAML_SP_CERT=.../saml.crt      # created\n *\n * Created and used the key/cert pair following instructions here:\n *   https://auth0.com/docs/protocols/saml-protocol/saml-sso-integrations/sign-and-encrypt-saml-requests#use-custom-certificate-to-sign-requests\n *   https://auth0.com/docs/protocols/saml-protocol/saml-sso-integrations/sign-and-encrypt-saml-requests#auth0-as-the-saml-identity-provider\n *\n */\n\nimport { SAML_PROVIDER_KEY } from \"app/common/loginProviders\";\nimport { AppSettings } from \"app/server/lib/AppSettings\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { GristLoginSystem, GristServer } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport { createLoginProviderFactory, NotConfiguredError } from \"app/server/lib/loginSystemHelpers\";\nimport { Permit } from \"app/server/lib/Permit\";\nimport { getOriginUrl } from \"app/server/lib/requestUtils\";\nimport { fromCallback } from \"app/server/lib/serverUtils\";\nimport { Sessions } from \"app/server/lib/Sessions\";\n\nimport * as express from \"express\";\nimport * as fse from \"fs-extra\";\nimport * as saml2 from \"saml2-js\";\n\n/**\n * Interface for SAML configuration.\n */\nexport interface SamlConfig {\n  /** Host at which our /saml/assert endpoint will live; identifies our application. */\n  readonly spHost: string;\n  /** The private key content, PEM format. */\n  readonly spKey: string;\n  /** The public key content, PEM format. */\n  readonly spCert: string;\n  /** Login url to redirect user to for log-in. */\n  readonly idpLogin: string;\n  /** Logout URL to redirect user to for log-out. */\n  readonly idpLogout: string;\n  /** If true, don't attempt \"Single Logout\" flow, but simply redirect to idpLogout after clearing session. */\n  readonly skipSlo: boolean;\n  /** List of certificate contents, PEM format. */\n  readonly idpCerts: string[];\n  /** If true, allow unencrypted assertions, relying on https for privacy. */\n  readonly allowUnencrypted: boolean;\n}\n\n/**\n * Read SAML configuration from application settings.\n * When reading from environment variables, the cert/key values are file paths,\n * so we read the file contents here.\n */\nexport function readSamlConfigFromSettings(settings: AppSettings): SamlConfig {\n  const section = settings.section(\"login\").section(\"system\").section(SAML_PROVIDER_KEY);\n\n  let spHost = \"\";\n  try {\n    spHost = section.flag(\"spHost\").requireString({\n      envVar: \"GRIST_SAML_SP_HOST\",\n    });\n  } catch (e) {\n    throw new NotConfiguredError((e as Error).message);\n  }\n\n  const spKeyPath = section.flag(\"spKey\").requireString({\n    envVar: \"GRIST_SAML_SP_KEY\",\n  });\n\n  const spCertPath = section.flag(\"spCert\").requireString({\n    envVar: \"GRIST_SAML_SP_CERT\",\n  });\n\n  const idpLogin = section.flag(\"idpLogin\").requireString({\n    envVar: \"GRIST_SAML_IDP_LOGIN\",\n  });\n\n  const idpLogout = section.flag(\"idpLogout\").requireString({\n    envVar: \"GRIST_SAML_IDP_LOGOUT\",\n  });\n\n  const skipSlo = section.flag(\"idpSkipSlo\").readBool({\n    envVar: \"GRIST_SAML_IDP_SKIP_SLO\",\n    defaultValue: false,\n  })!;\n\n  const idpCertsPaths = section.flag(\"idpCerts\").requireString({\n    envVar: \"GRIST_SAML_IDP_CERTS\",\n  }).split(\",\").map(p => p.trim());\n\n  const allowUnencrypted = section.flag(\"idpUnencrypted\").readBool({\n    envVar: \"GRIST_SAML_IDP_UNENCRYPTED\",\n    defaultValue: false,\n  })!;\n\n  // Read the file contents from paths\n  const spKey = fse.readFileSync(spKeyPath, { encoding: \"utf8\" });\n  const spCert = fse.readFileSync(spCertPath, { encoding: \"utf8\" });\n  const idpCerts = idpCertsPaths.map((p: string) => fse.readFileSync(p, { encoding: \"utf8\" }));\n\n  return {\n    spHost,\n    spKey,\n    spCert,\n    idpLogin,\n    idpLogout,\n    skipSlo,\n    idpCerts,\n    allowUnencrypted,\n  };\n}\n\nexport class SamlBuilder {\n  /**\n   * Handy alias to create a SamlBuilder instance and initialize it.\n   */\n  public static async build(\n    gristServer: GristServer,\n    config: SamlConfig,\n  ): Promise<SamlBuilder> {\n    const builder = new SamlBuilder(gristServer, config);\n    await builder.initSaml();\n    return builder;\n  }\n\n  private _serviceProvider: saml2.ServiceProvider;\n  private _identityProvider: saml2.IdentityProvider;\n  private _config: SamlConfig;\n\n  protected constructor(\n    private _gristServer: GristServer,\n    config: SamlConfig,\n  ) {\n    this._config = config;\n  }\n\n  // Initialize the SAML state using the certificate contents from config.\n  public async initSaml(): Promise<void> {\n    const spHost = this._config.spHost;\n    const spOptions: saml2.ServiceProviderOptions = {\n      entity_id: `${spHost}/saml/metadata.xml`,\n      private_key: this._config.spKey,\n      certificate: this._config.spCert,\n      assert_endpoint: `${spHost}/saml/assert`,\n      notbefore_skew: 5,      // allow 5 seconds of time skew\n      sign_get_request: true,  // Auth0 requires this. If it is a problem for others, could make optional.\n    };\n    this._serviceProvider = new saml2.ServiceProvider(spOptions);\n\n    const idpOptions: saml2.IdentityProviderOptions = {\n      sso_login_url: this._config.idpLogin,\n      sso_logout_url: this._config.idpLogout,\n      certificates: this._config.idpCerts,\n      // Encrypted assertions are recommended, but not necessary when over https.\n      allow_unencrypted_assertion: this._config.allowUnencrypted,\n    };\n    this._identityProvider = new saml2.IdentityProvider(idpOptions);\n    log.info(`SamlConfig set with host ${spHost}, IdP ${this._config.idpLogin}`);\n  }\n\n  // Return a login URL to which to redirect the user to log in. Once logged in, the user will be\n  // redirected to redirectUrl\n  public async getLoginRedirectUrl(req: express.Request, redirectUrl: URL): Promise<string> {\n    const sp = this._serviceProvider;\n    const idp = this._identityProvider;\n    const { permit: relay_state, samlNameId } = await this._prepareAppState(req, redirectUrl, {\n      action: \"login\",\n      waitMinutes: 20,\n    });\n    const force_authn = samlNameId === undefined;  // If logged out locally, ignore any\n    // log in state retained by IdP.\n    return fromCallback(cb => sp.create_login_request_url(idp, { relay_state, force_authn }, cb));\n  }\n\n  // Returns the URL to log the user out of SAML IdentityProvider.\n  public async getLogoutRedirectUrl(req: express.Request, redirectUrl: URL): Promise<string> {\n    if (this._config.skipSlo) {\n      // TODO: This does NOT eventually take us to redirectUrl.\n      return this._config.idpLogout;\n    }\n\n    const sp = this._serviceProvider;\n    const idp = this._identityProvider;\n\n    // 2020: Not sure what I am doing wrong here, but all my attempt to use \"Single Logout\" fail with\n    // a \"400 Bad Request\" error message from Okta.\n    // 2021: This doesn't fail with Auth0 (now owned by Okta), but also doesn't seem to do anything.\n\n    const { permit: relay_state, samlNameId, samlSessionIndex } = await this._prepareAppState(req, redirectUrl, {\n      action: \"logout\",\n      waitMinutes: 1,\n    });\n\n    const options: saml2.CreateLogoutRequestUrlOptions = {\n      name_id: samlNameId,\n      session_index: samlSessionIndex,\n      relay_state,\n    };\n    return fromCallback<string>(cb => sp.create_logout_request_url(idp, options, cb));\n  }\n\n  // Adds several /saml/* endpoints to the given express app, to support SAML logins.\n  public addSamlEndpoints(app: express.Express, sessions: Sessions): void {\n    const sp = this._serviceProvider;\n    const idp = this._identityProvider;\n\n    // A purely informational endpoint, which simply dumps the SAML metadata.\n    app.get(\"/saml/metadata.xml\", (req, res) => {\n      res.type(\"application/xml\");\n      res.send(sp.create_metadata());\n    });\n\n    // Starting point for login. It redirects to the IdP, and then to /saml/assert.\n    app.get(\"/saml/login\", expressWrap(async (req, res, next) => {\n      res.redirect(await this.getLoginRedirectUrl(req, new URL(getOriginUrl(req))));\n    }));\n\n    // Assert endpoint for when the login completes as POST.\n    app.post(\"/saml/assert\", express.urlencoded({ extended: true }), expressWrap(async (req, res, next) => {\n      const { redirectUrl, sessionId, unsolicited, action } = await this._processInitialRequest(req);\n      const samlResponse: saml2.SAMLAssertResponse = await fromCallback(\n        cb => sp.post_assert(idp, { request_body: req.body }, cb),\n      );\n      if (action === \"login\") {\n        const samlUser = samlResponse.user;\n        if (!samlUser?.name_id) {\n          log.warn(`SamlConfig: bad SAML response: ${JSON.stringify(samlUser)}`);\n          throw new Error(\"Invalid user info in SAML response\");\n        }\n\n        // An example IdP response is at https://github.com/Clever/saml2#assert_response. Saml2-js\n        // maps some standard attributes as user.given_name, user.surname, which we use if\n        // available. Otherwise we use user.attributes which has the form {Name: [Value]}.\n        const fname = (samlUser as any).given_name || samlUser.attributes?.FirstName || \"\";\n        const lname = (samlUser as any).surname || samlUser.attributes?.LastName || \"\";\n        const email = (samlUser as any).email || samlUser.name_id;\n        const profile = {\n          email,\n          name: `${fname} ${lname}`.trim(),\n        };\n\n        const samlSessionIndex = samlUser.session_index;\n        const samlNameId = samlUser.name_id;\n        log.info(`SamlConfig: got SAML response${unsolicited ? \" (unsolicited)\" : \"\"} for ` +\n          `${profile.email} (${profile.name}) redirecting to ${redirectUrl}`);\n\n        const scopedSession = sessions.getOrCreateSessionFromRequest(req, { sessionId });\n        await scopedSession.operateOnScopedSession(req, async user => Object.assign(user, {\n          profile,\n          samlSessionIndex,\n          samlNameId,\n        }));\n      }\n      res.redirect(redirectUrl);\n    }));\n  }\n\n  private async _processInitialRequest(req: express.Request) {\n    const relayState: string = req.body.RelayState;\n    const sessionId = this._gristServer.getSessions().getSessionIdFromRequest(req) || undefined;\n\n    if (!relayState) {\n      // Presumably an IdP-inititated signin.\n      return {\n        sessionId,\n        redirectUrl: getOriginUrl(req),\n        unsolicited: true,\n        action: \"login\",\n      };\n    }\n\n    const permitStore = this._gristServer.getExternalPermitStore();\n    const state = await permitStore.getPermit(relayState);\n    if (!state) {\n      // Presumably an IdP-inititated signin without a permit, but\n      // let's check to see if it has a redirect URL.\n      return {\n        sessionId,\n        redirectUrl: checkRedirectUrl(relayState, req).href,\n        unsolicited: true,\n        action: \"login\",\n      };\n    }\n\n    await permitStore.removePermit(relayState);\n    return {\n      sessionId: state.sessionId,\n      // Trust this URL because it could only have come from us (i.e. we should've checked it\n      // earlier if it was untrusted).\n      redirectUrl: state.url || \"\",\n      unsolicited: false,\n      action: state.action || \"\",\n    };\n  }\n\n  /**\n   *\n   * Login and logout involves redirecting to a SAML IdP, which will then POST some information\n   * back to Grist.  The POST won't have Grist's cookie, because of relatively new SameSite\n   * behavior.  Grist's cookie is SameSite=Lax, which withholds cookies from POSTs initiated\n   * on a different site.  That's a good setting in general, but for this case we need\n   * to link what the identity provider sends us with the session.  We place some state\n   * in the permit store temporarily and pass the permit key through the request chain\n   * so it is available when needed.\n   *\n   */\n  private async _prepareAppState(req: express.Request, redirectUrl: URL, options: {\n    action: \"login\" | \"logout\",   // We'll need to remember whether we are logging in or out.\n    waitMinutes: number        // State may need to linger quite some time for login,\n    // less so for logout.\n  }) {\n    const permitStore = this._gristServer.getExternalPermitStore();\n    const sessionId = this._gristServer.getSessions().getSessionIdFromRequest(req);\n    if (!sessionId) { throw new Error(\"no session available\"); }\n    const state: Permit = {\n      url: redirectUrl.href,\n      sessionId,\n      action: options.action,\n    };\n    const scopedSession = this._gristServer.getSessions().getOrCreateSessionFromRequest(req);\n    const userSession = await scopedSession.getScopedSession();\n    const samlNameId = userSession.samlNameId;\n    const samlSessionIndex = userSession.samlSessionIndex;\n    const permit = await permitStore.setPermit(state, options.waitMinutes * 60 * 1000);\n    return { permit, samlNameId, samlSessionIndex };\n  }\n}\n\nfunction checkRedirectUrl(untrustedUrl: string, req: express.Request): URL {\n  const originUrl = new URL(getOriginUrl(req));\n  try {\n    const url = new URL(untrustedUrl);\n    if (url.origin !== originUrl.origin) {\n      throw new Error(\"unexpected origin\");\n    }\n    return url;\n  } catch (e) {\n    log.warn(`SamlConfig: ignoring invalid redirect URL: ${e.message}`);\n  }\n  return originUrl;\n}\n\n/**\n * Return SAML login system if enabled, or undefined otherwise.\n */\nasync function getLoginSystem(settings: AppSettings): Promise<GristLoginSystem> {\n  const samlConfig = readSamlConfigFromSettings(settings);\n  return {\n    async getMiddleware(gristServer: GristServer) {\n      const config = await SamlBuilder.build(gristServer, samlConfig);\n      return {\n        getLoginRedirectUrl: config.getLoginRedirectUrl.bind(config),\n        // For saml, always use regular login page, users are enrolled externally.\n        // TODO: is there a better link to give here?\n        getSignUpRedirectUrl: config.getLoginRedirectUrl.bind(config),\n        getLogoutRedirectUrl: config.getLogoutRedirectUrl.bind(config),\n        async addEndpoints(app: express.Express) {\n          config.addSamlEndpoints(app, gristServer.getSessions());\n          return SAML_PROVIDER_KEY;\n        },\n      };\n    },\n    async deleteUser() {\n      // If we could delete the user account in the external\n      // authentication system, this is our chance - but we can't.\n    },\n  };\n}\n\nexport const getSamlLoginSystem = createLoginProviderFactory(\n  SAML_PROVIDER_KEY,\n  getLoginSystem,\n);\n"
  },
  {
    "path": "app/server/lib/SandboxControl.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport log from \"app/server/lib/log\";\nimport { Throttle } from \"app/server/lib/Throttle\";\n\nimport * as childProcess from \"child_process\";\nimport * as util from \"util\";\n\nimport pidusage from \"pidusage\";\n\nconst execFile = util.promisify(childProcess.execFile);\n\n/**\n * Sandbox usage information that we log periodically (currently just memory).\n */\nexport interface ISandboxUsage {\n  memory: number;\n}\n\n/**\n * Control interface for a sandbox. Looks like it doesn't do much, but there may be\n * background activities (specifically, throttling).\n */\nexport interface ISandboxControl {\n  getUsage(): Promise<ISandboxUsage>;  // Poll usage information for the sandbox.\n  prepareToClose(): void;              // Start shutting down (but don't wait).\n  close(): Promise<void>;              // Wait for shut down.\n  kill(): Promise<void>;               // Send kill signals to any related processes.\n}\n\n/**\n * Control a single process directly. A thin wrapper around the Throttle class.\n */\nexport class DirectProcessControl implements ISandboxControl {\n  private _pid: number;\n  private _throttle?: Throttle;\n\n  constructor(private _process: childProcess.ChildProcess, logMeta?: log.ILogMeta) {\n    if (!_process.pid) { throw new Error(`process identifier (PID) is undefined`); }\n\n    this._pid = _process.pid;\n    if (process.env.GRIST_THROTTLE_CPU) {\n      this._throttle = new Throttle({\n        pid: this._pid,\n        logMeta: { ...logMeta, pid: _process.pid },\n      });\n    }\n  }\n\n  public async close() {\n    this.prepareToClose();\n  }\n\n  public prepareToClose() {\n    this._throttle?.stop();\n    this._throttle = undefined;\n  }\n\n  public async kill() {\n    this._process.kill(\"SIGKILL\");\n  }\n\n  public async getUsage() {\n    const memory = (await pidusage(this._pid)).memory;\n    return { memory };\n  }\n}\n\n/**\n * Dummy control interface that does no monitoring or throttling.\n */\nexport class NoProcessControl implements ISandboxControl {\n  constructor(private _process: childProcess.ChildProcess) {\n  }\n\n  public async close() {\n  }\n\n  public prepareToClose() {\n  }\n\n  public async kill() {\n    this._process.kill(\"SIGKILL\");\n  }\n\n  public async getUsage() {\n    return { memory: Infinity };\n  }\n}\n\n/**\n * Control interface when multiple processes are involved, playing different roles.\n * This is entirely conceived with gvisor's runsc in mind.\n *\n * As a process is starting up, we scan it and its children (recursively) for processes\n * that match certain \"recognizers\". For gvisor runsc, we'll be picking out a sandbox\n * process from its peers handling filesystem access, and a ptraced process that is\n * effectively the data engine.\n *\n * This setup is very much developed by inspection, and could have weaknesses.\n * TODO: check if more processes need to be included in memory counting.\n * TODO: check if there could be multiple ptraced processes to deal with if user were\n * to create extra processes within sandbox (which we don't yet attempt to prevent).\n *\n * The gvisor container could be configured with operating system help to limit\n * CPU usage in various ways, but I don't yet see a way to get something analogous\n * to Throttle's operation.\n */\nexport class SubprocessControl implements ISandboxControl {\n  private _throttle?: Throttle;\n  private _monitoredProcess: Promise<ProcessInfo | null>;\n  private _active: boolean;\n  private _foundDocker: boolean = false;\n\n  constructor(private _options: {\n    pid: number,   // pid of process opened by Grist\n    recognizers: {\n      sandbox: (p: ProcessInfo) => boolean,  // we will stop/start this process for throttling\n      memory?: (p: ProcessInfo) => boolean,  // read memory from this process (default: sandbox)\n      cpu?: (p: ProcessInfo) => boolean,     // read cpu from this process    (default: sandbox)\n      traced?: (p: ProcessInfo) => boolean,  // stop this as well for throttling (default: none)\n    },\n    logMeta?: log.ILogMeta,\n  }) {\n    this._active = true;\n    this._monitoredProcess = this._scan().catch((e) => {\n      log.rawDebug(`Subprocess control failure: ${e}`, this._options.logMeta || {});\n      return null;\n    });\n  }\n\n  public async close() {\n    this.prepareToClose();\n    await this._monitoredProcess.catch(() => null);\n  }\n\n  public prepareToClose() {\n    this._active = false;\n    this._throttle?.stop();\n    this._throttle = undefined;\n  }\n\n  public async kill() {\n    if (this._foundDocker) {\n      process.kill(this._options.pid, \"SIGKILL\");\n      return;\n    }\n    for (const proc of await this._getAllProcesses()) {\n      try {\n        process.kill(proc.pid, \"SIGKILL\");\n      } catch (e) {\n        // Don't worry if process is already killed.\n        if (e.code !== \"ESRCH\") { throw e; }\n      }\n    }\n  }\n\n  public async getUsage() {\n    try {\n      const monitoredProcess = await this._monitoredProcess;\n      if (!monitoredProcess) { return { memory: Infinity }; }\n      const pid = monitoredProcess.pid;\n      const memory = (await pidusage(pid)).memory;\n      return { memory };\n    } catch (e) {\n      return { memory: Infinity };\n    }\n  }\n\n  /**\n   * Look for the desired children. Should be run once on process startup.\n   * This method will check all children once per second until if finds the\n   * desired ones or we are closed.\n   *\n   * It returns information about the child to be monitored by getUsage().\n   * It also has a side effect of kicking off throttling.\n   */\n  private async _scan(): Promise<ProcessInfo> {\n    while (this._active) {\n      const processes = await this._getAllProcesses();\n      const unrecognizedProcess = undefined as ProcessInfo | undefined;\n      const recognizedProcesses = {\n        sandbox: unrecognizedProcess,\n        memory: unrecognizedProcess,\n        cpu: unrecognizedProcess,\n        traced: unrecognizedProcess,\n      };\n      let missing = false;\n      for (const key of Object.keys(recognizedProcesses) as (keyof typeof recognizedProcesses)[]) {\n        const recognizer = this._options.recognizers[key];\n        if (!recognizer) { continue; }\n        for (const proc of processes) {\n          if (proc.label.includes(\"docker\")) {\n            this._foundDocker = true;\n            throw new Error(\"docker barrier found\");\n          }\n          if (recognizer(proc)) {\n            recognizedProcesses[key] = proc;\n            continue;\n          }\n        }\n        if (!recognizedProcesses[key]) { missing = true; }\n      }\n      if (!missing) {\n        this._configure(recognizedProcesses);\n        return recognizedProcesses.memory || recognizedProcesses.sandbox!;  // sandbox recognizer is mandatory\n      }\n      await delay(1000);\n    }\n    throw new Error(\"not found\");\n  }\n\n  /**\n   * Having found the desired children, we configure ourselves here, kicking off\n   * throttling if needed.\n   */\n  private _configure(processes: { sandbox?: ProcessInfo, cpu?: ProcessInfo,\n    memory?: ProcessInfo, traced?: ProcessInfo }) {\n    if (!processes.sandbox) { return; }\n    if (process.env.GRIST_THROTTLE_CPU) {\n      this._throttle = new Throttle({\n        pid: processes.sandbox.pid,\n        readPid: processes.cpu?.pid,\n        tracedPid: processes.traced?.pid,\n        logMeta: { ...this._options.logMeta,\n          pid: processes.sandbox.pid,\n          otherPids: [processes.cpu?.pid,\n            processes.memory?.pid,\n            processes.traced?.pid] },\n      });\n    }\n  }\n\n  /**\n   * Return the root process and all its (nested) children.\n   */\n  private _getAllProcesses(): Promise<ProcessInfo[]> {\n    const rootProcess = { pid: this._options.pid, label: \"root\", parentLabel: \"\" };\n    return this._addChildren([rootProcess]);\n  }\n\n  /**\n   * Take a list of processes, and add children of all those processes,\n   * recursively.\n   */\n  private async _addChildren(processes: ProcessInfo[]): Promise<ProcessInfo[]> {\n    const nestedProcesses = await Promise.all(processes.map(async (proc) => {\n      const children = await this._getChildren(proc.pid, proc.label);\n      return [proc, ...await this._addChildren(children)];\n    }));\n    return ([] as ProcessInfo[]).concat(...nestedProcesses);\n  }\n\n  /**\n   * Figure out the direct children of a parent process.\n   */\n  private async _getChildren(pid: number, parentLabel: string): Promise<ProcessInfo[]> {\n    // Use \"pgrep\" to find children of a process, in the absence of any better way.\n    // This only needs to happen a few times as sandbox is starting up, so doesn't need\n    // to be super-optimized.\n    // This currently is only good for Linux. Mechanically, it will run on Macs too,\n    // but process naming is slightly different. But this class is currently only useful\n    // for gvisor's runsc, which runs on Linux only.\n    const cmd =\n      execFile(\"pgrep\", [\"--list-full\", \"--parent\", String(pid)])\n        .catch(() => execFile(\"pgrep\", [\"-l\", \"-P\", String(pid)]))   // mac version of pgrep\n        .catch(() => ({ stdout: \"\" }));\n    const result = (await cmd).stdout;\n    const parts = result\n      .split(\"\\n\")\n      .map(line => line.trim())\n      .map(line => line.split(\" \", 2))\n      .map((part) => {\n        return {\n          pid: parseInt(part[0], 10) || 0,\n          label: part[1] || \"\",\n          parentLabel,\n        };\n      });\n    return parts.filter(part => part.pid !== 0);\n  }\n}\n\n/**\n * The information we need about processes is their pid, some kind of label (whatever\n * pgrep reports, which is a version of their command line), and the label of the process's\n * parent (blank if it has none).\n */\nexport interface ProcessInfo {\n  pid: number;\n  label: string;\n  parentLabel: string;\n}\n"
  },
  {
    "path": "app/server/lib/SandboxPyodide.ts",
    "content": "/**\n *\n * Utilities related to the pyodide sandbox for the data engine.\n * Material for the sandbox is in the sandbox/pyodide directory and\n * may require separate installation steps to make it available.\n *\n * Pyodide is run via deno. The GRIST_PYODIDE_SKIP_DENO=1 flag can be\n * used to call it directly, but this is not a good sandbox.\n *\n */\n\nimport { isAffirmative } from \"app/common/gutil\";\nimport { ISandboxOptions } from \"app/server/lib/NSandbox\";\nimport { getUnpackedAppRoot } from \"app/server/lib/places\";\n\nimport fs from \"fs\";\nimport path from \"path\";\n\nexport interface PyodideSettings {\n  scriptPath: string,\n  cwd: string,\n  command?: string,\n  args: string[],\n  dataToSandboxDescriptor?: number,\n  dataFromSandboxDescriptor?: number,\n  stdio: (\"pipe\" | \"ipc\")[],\n}\n\nexport function getPyodideSettings(options: ISandboxOptions): PyodideSettings {\n  const base = getUnpackedAppRoot();\n  const scriptPath = fs.realpathSync(\n    path.resolve(base, \"sandbox\", \"pyodide\", \"pipe.js\"),\n  );\n  const cwd = fs.realpathSync(path.resolve(process.cwd(), \"sandbox\"));\n\n  // If user doesn't want Deno, we call pyodide using node. This involves\n  // some fancy footwork about pipes, and is less secure. The option exists\n  // since running via deno has not yet been widely tested in all the\n  // environments pyodide is running in, including desktop environments.\n  // In such environments, users may trust the documents they created\n  // personally and disruption in the name of security could seem unreasonable.\n  if (isAffirmative(process.env.GRIST_PYODIDE_SKIP_DENO)) {\n    return {\n      scriptPath,\n      cwd,\n      args: [],\n\n      // Cannot use normal descriptor with nodejs, since node\n      // makes it non-blocking. Can be worked around in linux and osx, but\n      // for windows just using a different file descriptor seems simplest.\n      // In the sandbox, calling async methods from emscripten code is\n      // possible but would require more changes to the data engine code\n      // than seems reasonable at this time. The top level sandbox.run\n      // can be tweaked to step operations, which actually works for a\n      // lot of things, but not for cases where the sandbox calls back\n      // into node (e.g. for column type guessing). TLDR: just switching\n      // to FD 4 and reading synchronously is more practical solution.\n      dataToSandboxDescriptor: 4,\n\n      // There's an equally long but different\n      // story about why stdout is a bit messed up under pyodide with\n      // node right now.\n      dataFromSandboxDescriptor: 5,\n\n      // Provide the promised descriptors.\n      stdio: [\"ignore\", \"ignore\", \"pipe\", \"ipc\", \"pipe\", \"pipe\"] as (\"pipe\" | \"ipc\")[],\n    };\n  }\n\n  // We expect to find a deno binary alongside pyodide.\n  const command = findDenoBinary(path.join(base, \"sandbox\", \"pyodide\", \"_build\", \"worker\"));\n\n  // When running pyodide, we initially need broad read access to the pyodide\n  // sandbox directory. We drop this access before running user code with pyodide.\n  const readDir = fs.realpathSync(\n    path.resolve(base, \"sandbox\", \"pyodide\"),\n  );\n\n  // Pyodide maintains its own cache of packages. The pipe.js process supplies\n  // packages, and pyodide will want to copy them into a cache. So we give\n  // access to a directory for that purpose. We drop this access before\n  // running user code.\n  const writeDir = fs.realpathSync(\n    path.resolve(base, \"sandbox\", \"pyodide\", \"_build\", \"cache\"),\n  );\n\n  // The code for the data engine lives outside the sandbox/pyodide directory,\n  // in sandbox/grist. We give access to this, then drop it before running\n  // user code.\n  const gristDir = fs.realpathSync(\n    path.resolve(base, \"sandbox\", \"grist\"),\n  );\n\n  // The list of packages the data engine needs is read straight from\n  // sandbox/requirements.txt. We drop access to this before running user\n  // code.\n  const reqFile = fs.realpathSync(\n    path.resolve(base, \"sandbox\", \"requirements.txt\"),\n  );\n\n  // If the sandbox is being used to do an import, we'll need read access\n  // to that too. We will not drop read access to this.\n  const importDir = options.importDir ? fs.realpathSync(path.resolve(options.importDir)) : undefined;\n  const importAllow = importDir ? [`--allow-read=${importDir}`] : [];\n\n  // Compared to node, we run with a simpler pipe setup, and with\n  // explicit permissions that we then drop as soon as we can.\n  return {\n    scriptPath,\n    cwd,\n    command,\n    stdio: [\"pipe\", \"pipe\", \"pipe\"],\n    args: [\n      `--allow-read=${readDir}`,\n      `--allow-read=${gristDir}`,\n      `--allow-read=${reqFile}`,\n      `--allow-write=${writeDir}`,\n      \"--allow-env\",\n      ...importAllow,\n    ],\n  };\n}\n\n/**\n * Find the Deno binary installed via npm in node_modules.\n * This is a bit tricky. There is a .bin/deno[.exe] file\n * you can run, but it can be a wrapper that relies on node\n * being available and in a certain location (/usr/bin/node).\n * So we have to go rummaging. We take the list of optional\n * dependencies in node_modules/deno, and look through them\n * for a binary - there should be one, the right one for the\n * OS.\n */\nfunction findDenoBinary(dir: string): string {\n  if (!denoBinaryCache[dir]) {\n    denoBinaryCache[dir] = findDenoBinaryUncached(dir);\n  }\n  return denoBinaryCache[dir];\n}\n\n/**\n * Find the Deno binary within a directory, caching the\n * result.\n */\nfunction findDenoBinaryUncached(dir: string): string {\n  const denoPkgDir = path.join(dir, \"node_modules\", \"deno\");\n  const pkgJsonPath = path.join(denoPkgDir, \"package.json\");\n\n  if (!fs.existsSync(pkgJsonPath)) {\n    throw new Error(\"npm deno package not found\");\n  }\n\n  const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, \"utf8\"));\n  const optionalDeps: Record<string, string> =\n    pkg.optionalDependencies ?? {};\n\n  for (const depName of Object.keys(optionalDeps)) {\n    const depDir = path.join(dir, \"node_modules\", depName);\n    if (!fs.existsSync(depDir)) {\n      continue;\n    }\n\n    // The native executable is named `deno` (or `deno.exe` on Windows)\n    const exeName = process.platform === \"win32\" ? \"deno.exe\" : \"deno\";\n    const exePath = path.join(depDir, exeName);\n\n    if (fs.existsSync(exePath)) {\n      return exePath;\n    }\n  }\n\n  throw new Error(\"Native Deno executable not found\");\n}\n\nconst denoBinaryCache: Record<string, string> = {};\n"
  },
  {
    "path": "app/server/lib/ServerColumnGetters.ts",
    "content": "import { ColumnGetter, ColumnGetters, ColumnGettersByColId } from \"app/common/ColumnGetters\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { choiceGetter } from \"app/common/SortFunc\";\nimport { Sort } from \"app/common/SortSpec\";\nimport { BulkColValues } from \"app/plugin/GristData\";\n\n/**\n *\n * An implementation of ColumnGetters for the server, currently\n * drawing on the data and metadata prepared for CSV export.\n *\n */\nexport class ServerColumnGetters implements ColumnGetters, ColumnGettersByColId {\n  private _rowIndices: Map<number, number>;\n  private _colIndices: Map<number, string>;\n\n  constructor(rowIds: number[], private _dataByColId: BulkColValues, private _columns: any[]) {\n    this._rowIndices = new Map<number, number>(rowIds.map((rowId, index) => [rowId, index] as [number, number]));\n    this._colIndices = new Map<number, string>(_columns.map(col => [col.id, col.colId] as [number, string]));\n  }\n\n  public getColGetter(colSpec: Sort.ColSpec): ColumnGetter | null {\n    const colRef = Sort.getColRef(colSpec);\n    if (typeof colRef !== \"number\") {\n      // colRef might be string for virtual tables, but we don't support them here.\n      throw new Error(`Unsupported colRef type: ${typeof colRef}`);\n    }\n    const colId = this._colIndices.get(colRef);\n    if (colId === undefined) {\n      return null;\n    }\n    let getter = this.getColGetterByColId(colId);\n    if (!getter) {\n      return null;\n    }\n    const details = Sort.specToDetails(colSpec);\n    if (details.orderByChoice) {\n      const rowModel = this._columns.find(c => c.id == colRef);\n      if (rowModel?.type === \"Choice\") {\n        const choices: string[] = safeJsonParse(rowModel.widgetOptions, {}).choices || [];\n        getter = choiceGetter(getter, choices);\n      }\n    }\n    return getter;\n  }\n\n  public getManualSortGetter(): ((rowId: number) => any) | null {\n    const manualSortCol = this._columns.find(c => c.colId === gristTypes.MANUALSORT);\n    if (!manualSortCol) {\n      return null;\n    }\n    return this.getColGetter(manualSortCol.id);\n  }\n\n  public getColGetterByColId(colId: string): ColumnGetter | null {\n    if (colId === \"id\") {\n      return (rowId: number) => rowId;\n    }\n    const col = this._dataByColId[colId];\n    if (!col) {\n      return null;\n    }\n    return (rowId: number) => {\n      const idx = this._rowIndices.get(rowId);\n      if (idx === undefined) {\n        return null;\n      }\n      return col[idx];\n    };\n  }\n}\n"
  },
  {
    "path": "app/server/lib/ServerLocale.ts",
    "content": "import { locales } from \"app/common/Locales\";\n\nimport { IncomingMessage } from \"http\";\n\nimport { parse as languageParser } from \"accept-language-parser\";\n\n/**\n * Returns the locale from a request, falling back to `defaultLocale`\n * if unable to determine the locale.\n */\nexport function localeFromRequest(req: IncomingMessage, defaultLocale: string = \"en-US\") {\n  const language = languageParser(req.headers[\"accept-language\"]!)[0];\n  if (!language) { return defaultLocale; }\n\n  const locale = `${language.code}-${language.region}`;\n  const supports = locales.some(l => l.code === locale);\n  return supports ? locale : defaultLocale;\n}\n"
  },
  {
    "path": "app/server/lib/Sessions.ts",
    "content": "import { ScopedSession } from \"app/server/lib/BrowserSession\";\nimport { RequestWithOrg } from \"app/server/lib/extractOrg\";\nimport { cookieName, SessionStore } from \"app/server/lib/gristSessions\";\n\nimport { IncomingMessage } from \"http\";\n\nimport * as cookie from \"cookie\";\nimport * as cookieParser from \"cookie-parser\";\nimport { Request } from \"express\";\n\n/**\n *\n * A collection of all the sessions relevant to this instance of Grist.\n *\n * This collection was previously maintained by the Comm object.  This\n * class is added as a stepping stone to disentangling session management\n * from code related to websockets.\n *\n * The collection caches all existing interfaces to sessions.\n * ScopedSessions play an important role in\n * hosted Grist and address per-organization scoping of identity.\n *\n * TODO: now this is separated out, we could refactor to share sessions\n * across organizations.  Currently, when a user moves between organizations,\n * the session interfaces are not shared.  This was for simplicity in working\n * with existing code.\n *\n */\nexport class Sessions {\n  private _sessions = new Map<string, ScopedSession>();\n\n  constructor(private _sessionSecret: string, private _sessionStore: SessionStore) {\n  }\n\n  /**\n   * Get the session id and organization from the request (or just pass it in if known), and\n   * return the identified session.\n   */\n  public getOrCreateSessionFromRequest(req: RequestWithOrg | Request, options?: {\n    sessionId?: string,\n    org?: string\n  }): ScopedSession {\n    const sid = options?.sessionId ?? this.getSessionIdFromRequest(req);\n    const org = options?.org ?? (\"org\" in req ? req.org : undefined);\n    if (!sid) { throw new Error(\"session not found\"); }\n    return this.getOrCreateSession(sid, org, \"\");  // TODO: allow for tying to a preferred user.\n  }\n\n  /**\n   * Get or create a session given the session id and organization name.\n   */\n  public getOrCreateSession(sid: string, org: string | undefined, userSelector: string = \"\"): ScopedSession {\n    org = org || \"\";\n    const key = this._getSessionOrgKey(sid, org, userSelector);\n    if (!this._sessions.has(key)) {\n      const scopedSession = new ScopedSession(sid, this._sessionStore, org, userSelector);\n      this._sessions.set(key, scopedSession);\n    }\n    return this._sessions.get(key)!;\n  }\n\n  /**\n   * Called when a session is modified, and any caching should be invalidated.\n   * Currently just removes all caching, if there is any. This caching is a bit\n   * of a weird corner of Grist, it is used in development for historic reasons\n   * but not in production.\n   * TODO: make more fine grained, or rethink.\n   */\n  public clearCacheIfNeeded(options?: {\n    email?: string,\n    org?: string | null,\n    sessionID?: string,\n  }) {\n    if (!(process.env.GRIST_HOST || process.env.GRIST_HOSTED)) {\n      this._sessions.clear();\n    }\n  }\n\n  /**\n   * Returns the sessionId from the signed grist cookie.\n   */\n  public getSessionIdFromCookie(gristCookie: string): string | false {\n    return cookieParser.signedCookie(gristCookie, this._sessionSecret);\n  }\n\n  /**\n   * Get the session id from the grist cookie.  Returns null if no cookie found.\n   */\n  public getSessionIdFromRequest(req: Request | IncomingMessage): string | null {\n    if (req.headers.cookie) {\n      const cookies = cookie.parse(req.headers.cookie);\n      const sessionId = this.getSessionIdFromCookie(cookies[cookieName]);\n      if (sessionId) { return sessionId; }\n    }\n    return (req as any).sessionID || null;  // sessionID set by express-session\n  }\n\n  /**\n   * Clear all sessions from the session store.\n   * This will remove all session data from Redis/SQLite and clear the in-memory cache.\n   */\n  public async clearAllSessions(): Promise<void> {\n    this._sessions.clear();\n    await this._sessionStore.clearAsync();\n  }\n\n  /**\n   * Get a per-organization, per-session key.\n   * Grist has historically cached sessions in memory by their session id.\n   * With the introduction of per-organization identity, that cache is now\n   * needs to be keyed by the session id and organization name.\n   * Also, clients may now want to be tied to a particular user available within\n   * a session, so we add that into key too.\n   */\n  private _getSessionOrgKey(sid: string, org: string, userSelector: string): string {\n    return `${sid}__${org}__${userSelector}`;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/Sharing.ts",
    "content": "import {\n  ActionBundle,\n  ActionInfo,\n  Envelope,\n  getEnvContent,\n  LocalActionBundle,\n  SandboxActionBundle,\n  UserActionBundle,\n} from \"app/common/ActionBundle\";\nimport { summarizeAction } from \"app/common/ActionSummarizer\";\nimport { ApplyUAExtendedOptions, ApplyUAResult } from \"app/common/ActiveDocAPI\";\nimport { DocAction, getNumRows, SYSTEM_ACTIONS, UserAction } from \"app/common/DocActions\";\nimport { ActionHistory, asActionGroup, getActionUndoInfo } from \"app/server/lib/ActionHistory\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { makeExceptionalDocSession, OptDocSession } from \"app/server/lib/DocSession\";\nimport { GranularAccessForBundle } from \"app/server/lib/GranularAccess\";\nimport { insightLogEntry } from \"app/server/lib/InsightLog\";\nimport log from \"app/server/lib/log\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\nimport { shortDesc } from \"app/server/lib/shortDesc\";\n\nimport assert from \"assert\";\n\nimport { Mutex } from \"async-mutex\";\nimport isEqual from \"lodash/isEqual\";\n\n// Don't log details of action bundles in production.\nconst LOG_ACTION_BUNDLE = (process.env.NODE_ENV !== \"production\");\n\ninterface ApplyResult {\n  /**\n   * Access denied exception if the user does not have permission to apply the action.\n   */\n  failure?: Error,\n  /**\n   * Result of applying user actions. If there is a failure, it contains result of reverting\n   * those actions that should be persisted (probably extra actions caused by nondeterministic\n   * functions).\n   */\n  result?: {\n    accessControl: GranularAccessForBundle,\n    bundle: SandboxActionBundle,\n  }\n}\n\nexport class Sharing {\n  private _userActionLock = new Mutex();\n  private _log = new LogMethods(\"Sharing \", (s: OptDocSession) => this._activeDoc.getLogMeta(s));\n\n  constructor(private _activeDoc: ActiveDoc, private _actionHistory: ActionHistory, private _modificationLock: Mutex) {\n    assert(_actionHistory.isInitialized());\n  }\n\n  /** Returns the instanceId if the doc is shared or null otherwise. */\n  // eslint-disable-next-line @typescript-eslint/class-literal-property-style\n  public get instanceId(): string | null { return null; }\n\n  /**\n   * The only public interface. This may be called at any time, but the work happens for at most\n   * one action at a time.\n   */\n  public addUserAction(docSession: OptDocSession, action: UserActionBundle): Promise<ApplyUAResult> {\n    return this._userActionLock.runExclusive(async () => {\n      try {\n        return await this._doApplyUserActions(action.info, action.userActions, docSession, action.options || null);\n      } catch (e) {\n        this._log.warn(docSession, \"Unable to apply action...\", e);\n        throw e;\n      }\n    });\n  }\n\n  private async _doApplyUserActions(info: ActionInfo, userActions: UserAction[],\n    docSession: OptDocSession,\n    options: ApplyUAExtendedOptions | null): Promise<ApplyUAResult> {\n    const client = docSession?.client;\n\n    if (docSession?.linkId) {\n      info.linkId = docSession.linkId;\n    }\n\n    const insightLog = insightLogEntry();\n    const { result, failure } =\n      await this._modificationLock.runExclusive(() => this._applyActionsToDataEngine(docSession, userActions, options));\n\n    // ACL check failed, and we don't have anything to save. Just rethrow the error.\n    if (failure && !result) {\n      throw failure;\n    }\n\n    assert(result, \"result should be defined if failure is not\");\n\n    const sandboxActionBundle = result.bundle;\n    const accessControl = result.accessControl;\n    const undo = getEnvContent(result.bundle.undo);\n\n    try {\n      const isSystemAction = (userActions.length === 1 && SYSTEM_ACTIONS.has(userActions[0][0] as string));\n      // `internal` is true if users shouldn't be able to undo the actions. Applies to:\n      // - Calculate/UpdateCurrentTime because it's not considered as performed by a particular client.\n      // - Adding attachment metadata when uploading attachments,\n      //   because then the attachment file may get hard-deleted and redo won't work properly.\n      // - Action was rejected but it had some side effects (e.g. NOW() or UUID() formulas).\n      const internal =\n        isSystemAction ||\n        userActions.every(a => a[0] === \"AddRecord\" && a[1] === \"_grist_Attachments\") ||\n        !!failure;\n\n      // A trivial action does not merit allocating an actionNum,\n      // logging, and sharing. It's best not to log the\n      // action that calculates formula values when the document is opened cold\n      // (without cached ActiveDoc) if it doesn't change anything - otherwise we'll end up with spam\n      // log entries for each time the document is opened cold.\n      const trivial = internal && sandboxActionBundle.stored.length === 0;\n\n      const actionNum = trivial ? 0 : this._actionHistory.getNextLocalActionNum();\n\n      const localActionBundle: LocalActionBundle = {\n        actionNum,\n        // The ActionInfo should go into the envelope that includes all recipients.\n        info: [findOrAddAllEnvelope(sandboxActionBundle.envelopes), info],\n        envelopes: sandboxActionBundle.envelopes,\n        stored: sandboxActionBundle.stored,\n        calc: sandboxActionBundle.calc,\n        undo,\n        userActions,\n        actionHash: null,        // Gets set below by _actionHistory.recordNext...\n        parentActionHash: null,  // Gets set below by _actionHistory.recordNext...\n      };\n\n      const logMeta = {\n        actionNum,\n        linkId: info.linkId,\n        otherId: info.otherId,\n        numDocActions: localActionBundle.stored.length,\n        numRows: localActionBundle.stored.reduce((n, env) => n + getNumRows(env[1]), 0),\n        ...(sandboxActionBundle.numBytes ? { numBytes: sandboxActionBundle.numBytes } : {}),\n      };\n      insightLog?.addMeta(logMeta);\n      if (LOG_ACTION_BUNDLE) {\n        this._logActionBundle(`_doApplyUserActions`, localActionBundle);\n      }\n\n      // If the document has shut down in the meantime, and this was just a \"Calculate\" action,\n      // return a trivial result.  This is just to reduce noisy warnings in migration tests.\n      if (this._activeDoc.isShuttingDown && isSystemAction) {\n        return {\n          actionNum: localActionBundle.actionNum,\n          actionHash: localActionBundle.actionHash,\n          retValues: [],\n          isModification: false,\n        };\n      }\n\n      // Apply the action to the database, and record in the action log.\n      if (!trivial) {\n        await this._activeDoc.docStorage.execTransaction(async () => {\n          insightLog?.mark(\"docStorageTxn\");\n          await this._activeDoc.applyStoredActionsToDocStorage(getEnvContent(localActionBundle.stored));\n\n          // Before sharing is enabled, actions are immediately marked as \"shared\" (as if accepted\n          // by the hub). The alternative of keeping actions on the \"local\" branch until sharing is\n          // enabled is less suitable, because such actions could have empty envelopes, and cannot\n          // be shared. Once sharing is enabled, we would share a snapshot at that time.\n          await this._actionHistory.recordNextShared(localActionBundle);\n\n          if (client?.clientId && !internal) {\n            this._actionHistory.setActionUndoInfo(\n              localActionBundle.actionHash!,\n              getActionUndoInfo(localActionBundle, client.clientId, sandboxActionBundle.retValues));\n          }\n        });\n        insightLog?.mark(\"docStorage\");\n      }\n      await this._activeDoc.processActionBundle(localActionBundle);\n      insightLog?.mark(\"processBundle\");\n\n      // Don't trigger webhooks for single Calculate actions, this causes a deadlock on document load.\n      // See gh issue #799\n      const isSingleCalculateAction = userActions.length === 1 && userActions[0][0] === \"Calculate\";\n      const actionSummary = !isSingleCalculateAction ?\n        await this._activeDoc.handleTriggers(localActionBundle) :\n        summarizeAction(localActionBundle);\n\n      // Opportunistically use actionSummary to see if _grist_Shares was\n      // changed.\n      if (actionSummary.tableDeltas._grist_Shares) {\n        // This is a little risky, since it entangles us with home db\n        // availability. But we aren't doing a lot...?\n        await this._activeDoc.syncShares(makeExceptionalDocSession(\"system\"));\n        insightLog?.mark(\"syncShares\");\n      }\n\n      insightLog?.addMeta({ docRowCount: sandboxActionBundle.rowCount.total });\n      await this._activeDoc.updateRowCount(sandboxActionBundle.rowCount, docSession);\n      insightLog?.mark(\"updateRowCount\");\n\n      // Broadcast the action to connected browsers.\n      const actionGroup = asActionGroup(this._actionHistory, localActionBundle, {\n        clientId: client?.clientId,\n        retValues: sandboxActionBundle.retValues,\n        internal,\n      });\n      actionGroup.actionSummary = actionSummary;\n      await accessControl.appliedBundle();\n      insightLog?.mark(\"accessRulesApplied\");\n      await accessControl.sendDocUpdateForBundle(actionGroup, this._activeDoc.getDocUsageSummary());\n      insightLog?.mark(\"sendDocUpdate\");\n      await this._activeDoc.notifySubscribers(docSession, accessControl);\n      insightLog?.mark(\"notifySubscribers\");\n      // If the action was rejected, throw an exception, by this point data-engine should be in\n      // sync with the database, and everyone should have the same view of the document.\n      if (failure) {\n        throw failure;\n      }\n      if (docSession) {\n        docSession.linkId = docSession.shouldBundleActions ? localActionBundle.actionNum : 0;\n      }\n      return {\n        actionNum: localActionBundle.actionNum,\n        actionHash: localActionBundle.actionHash,\n        retValues: sandboxActionBundle.retValues,\n        isModification: sandboxActionBundle.stored.length > 0,\n      };\n    } finally {\n      // Make sure the bundle is marked as complete, even if some miscellaneous error occurred.\n      await accessControl.finishedBundle();\n      insightLog?.mark(\"accessRulesFinish\");\n    }\n  }\n\n  /** Log an action bundle to the debug log. */\n  private _logActionBundle(prefix: string, actionBundle: ActionBundle) {\n    actionBundle.stored.forEach((envAction, i) =>\n      log.debug(\"%s: stored #%s [%s]: %s\", prefix, i, envAction[0],\n        shortDesc(envAction[1])));\n    actionBundle.calc.forEach((envAction, i) =>\n      log.debug(\"%s: calc #%s [%s]: %s\", prefix, i, envAction[0],\n        shortDesc(envAction[1])));\n  }\n\n  private async _applyActionsToDataEngine(\n    docSession: OptDocSession,\n    userActions: UserAction[],\n    options: ApplyUAExtendedOptions | null): Promise<ApplyResult> {\n    const applyResult = await this._activeDoc.applyActionsToDataEngine(docSession, userActions);\n    const insightLog = insightLogEntry();\n    insightLog?.mark(\"dataEngine\");\n    let accessControl = this._startGranularAccessForBundle(docSession, applyResult, userActions, options);\n    try {\n      // TODO: see if any of the code paths that have no docSession are relevant outside\n      // of tests.\n      await accessControl.canApplyBundle();\n      insightLog?.mark(\"accessRulesCheck\");\n      return { result: { bundle: applyResult, accessControl } };\n    } catch (applyExc) {\n      insightLog?.mark(\"dataEngineReverting\");\n      try {\n        // We can't apply those actions, so we need to revert them.\n        const undoResult = await this._activeDoc.applyActionsToDataEngine(docSession, [\n          [\"ApplyUndoActions\", getEnvContent(applyResult.undo)],\n        ]);\n\n        // We managed to reject and undo actions in the data-engine. Now we need to calculate if we have any extra\n        // actions generated by the undo (it can happen for nondeterministic formulas). If we have them, we will need to\n        // test if they pass ACL check and persist them in the database in order to keep the data engine in sync with\n        // the database. If we have any extra actions, we will simulate that only those actions were applied and return\n        // fake bundle together with the access failure. If we don't have any extra actions, we will just return the\n        // failure.\n        const extraBundle = this._createExtraBundle(undoResult, getEnvContent(applyResult.undo));\n\n        // If we have the same number of actions and they are equal, we can assume that the data-engine is in sync.\n        if (!extraBundle) {\n          // We stored what we send, we don't have any extra actions to save, we can just return the failure.\n          await accessControl.finishedBundle();\n          return { failure: applyExc };\n        }\n\n        // We have some extra actions, so we need to prepare a fake bundle (only with the extra actions) and\n        // return the failure, so the caller can persist the extra actions and report the failure.\n        // Finish the access control for the origBundle.\n        await accessControl.finishedBundle();\n        // Start a new one. We assume that all actions are indirect, so this is basically a no-op, but we are doing it\n        // nevertheless to make sure they pass access control.\n        // NOTE: we assume that docActions can be used as userActions here. This is not always the case (as we might\n        // have a special logic that targets UserActions directly), but in this scenario, the extra bundle should\n        // contain only indirect data actions (mostly UpdateRecord) that are produced by comparing UserTables in the\n        // data-engine.\n        accessControl = this._startGranularAccessForBundle(docSession, extraBundle, extraBundle.stored, options);\n        // Check if the extra bundle is allowed.\n        await accessControl.canApplyBundle();\n        // We are ok, we can store extra actions and report back the exception.\n        return { result: { bundle: extraBundle, accessControl }, failure: applyExc };\n      } catch (rollbackExc) {\n        this._log.error(docSession, \"Failed to apply undo of rejected action\", rollbackExc.message);\n        await accessControl.finishedBundle();\n        this._log.debug(docSession, \"Sharing._applyActionsToDataEngine starting ActiveDoc.shutdown\");\n        await this._activeDoc.shutdown();\n        throw rollbackExc;\n      }\n    }\n  }\n\n  private _startGranularAccessForBundle(\n    docSession: OptDocSession | null,\n    bundle: SandboxActionBundle,\n    userActions: UserAction[],\n    options: ApplyUAExtendedOptions | null,\n  ) {\n    const undo = getEnvContent(bundle.undo);\n    const docActions = getEnvContent(bundle.stored).concat(getEnvContent(bundle.calc));\n    const isDirect = getEnvContent(bundle.direct);\n    return this._activeDoc.getGranularAccessForBundle(\n      docSession || makeExceptionalDocSession(\"share\"),\n      docActions,\n      undo,\n      userActions,\n      isDirect,\n      options,\n    );\n  }\n\n  /**\n   * Calculates the extra bundle that effectively was applied to the data engine.\n   * @param undoResult Result of applying undo actions to the data engine.\n   * @param undoSource Actions that were sent to perform the undo.\n   * @returns A bundle with extra actions that were applied to the data engine or null if there are no extra actions.\n   */\n  private _createExtraBundle(undoResult: SandboxActionBundle, undoSource: DocAction[]): SandboxActionBundle | null {\n    // First check that what we sent is what we stored, since those are undo actions, they should be identical. We\n    // need to reverse the order of undo actions (they are reversed in data-engine by ApplyUndoActions)\n    const sent = undoSource.slice().reverse();\n    const storedHead = getEnvContent(undoResult.stored).slice(0, sent.length);\n    // If we have less actions or they are not equal, we need need to fail immediately, this was not expected.\n    if (undoResult.stored.length < undoSource.length) {\n      throw new Error(\"There are less actions stored then expected\");\n    }\n    if (!storedHead.every((action, i) => isEqual(action, sent[i]))) {\n      throw new Error(\"Stored actions differ from sent actions\");\n    }\n    // If we have the same number of actions and they are equal there is nothing to return.\n    if (undoResult.stored.length === undoSource.length) {\n      return null;\n    }\n    // Create a fake bundle simulating only those extra actions that were applied.\n    return {\n      envelopes: undoResult.envelopes, // Envelops are not supported, so we can use the first one (which is always #ALL)\n      stored: undoResult.stored.slice(undoSource.length),\n      // All actions are treated as direct, we want to perform ACL check on them.\n      direct: undoResult.direct.slice(undoSource.length),\n      calc: [], // Calc actions are also not used anymore.\n      undo: [], // We won't allow to undo this one.\n      retValues: undoResult.retValues.slice(undoSource.length),\n      rowCount: undoResult.rowCount,\n    };\n  }\n}\n\nconst allToken: string = \"#ALL\";\n\n/**\n * Returns the index of the envelope containing the '#ALL' recipient, adding such an envelope to\n * the provided array if it wasn't already there.\n */\nexport function findOrAddAllEnvelope(envelopes: Envelope[]): number {\n  const i = envelopes.findIndex(e => e.recipients.includes(allToken));\n  if (i >= 0) { return i; }\n  envelopes.push({ recipients: [allToken] });\n  return envelopes.length - 1;\n}\n"
  },
  {
    "path": "app/server/lib/SqliteCommon.ts",
    "content": "import { Marshaller } from \"app/common/marshal\";\nimport { OpenMode, quoteIdent } from \"app/server/lib/SQLiteDB\";\n\n/**\n * Code common to SQLite wrappers.\n */\n\n/**\n * It is important that Statement exists - but we don't expect\n * anything of it.\n */\nexport interface Statement {} // eslint-disable-line @typescript-eslint/no-empty-object-type\n\n// Some facts about the wrapper implementation.\nexport interface MinDBOptions {\n  // is interruption implemented?\n  canInterrupt: boolean;\n\n  // Do all methods apart from exec() process at most one\n  // statement?\n  bindableMethodsProcessOneStatement: boolean;\n}\n\nexport interface MinDB {\n  // This method is expected to be able to handle multiple\n  // semicolon-separated statements, as for sqlite3_exec:\n  //   https://www.sqlite.org/c3ref/exec.html\n  exec(sql: string): Promise<void>;\n\n  // For all these methods, sql should ultimately be passed\n  // to sqlite3_prepare_v2 or later, and any tail text ignored after\n  // the first complete statement, so only the first statement is\n  // used if there are multiple.\n  //   https://www.sqlite.org/c3ref/prepare.html\n  run(sql: string, ...params: any[]): Promise<MinRunResult>;\n  get(sql: string, ...params: any[]): Promise<ResultRow | undefined>;\n  all(sql: string, ...params: any[]): Promise<ResultRow[]>;\n  prepare(sql: string, ...params: any[]): Promise<PreparedStatement>;\n  runAndGetId(sql: string, ...params: any[]): Promise<number>;\n  allMarshal(sql: string, ...params: any[]): Promise<Buffer>;\n\n  close(): Promise<void>;\n\n  /**\n   * Limit the number of ATTACHed databases permitted.\n   */\n  limitAttach(maxAttach: number): Promise<void>;\n\n  /**\n   * Stop all current queries.\n   */\n  interrupt?(): Promise<void>;\n\n  /**\n   * Get some facts about the wrapper.\n   */\n  getOptions?(): MinDBOptions;\n\n  backup?(filename: string): Backup;\n}\n\nexport interface MinRunResult {\n  changes: number;\n}\n\n// Describes the result of get() and all() database methods.\nexport interface ResultRow {\n  [column: string]: any;\n}\n\nexport interface PreparedStatement {\n  run(...params: any[]): Promise<MinRunResult>;\n  finalize(): Promise<void>;\n  columns(): string[];\n}\n\nexport interface SqliteVariant {\n  opener(dbPath: string, mode: OpenMode): Promise<MinDB>;\n}\n\nexport interface Backup {\n  remaining: number;\n  failed: boolean;\n  step(pages: number,\n    callback?: (err: Error | null) => void): void;\n  finish(callback?: (err: Error | null) => void): void;\n}\n\n/**\n * A crude implementation of Grist marshalling.\n * There is a fork of node-sqlite3 that has Grist\n * marshalling built in, at:\n *   https://github.com/gristlabs/node-sqlite3\n * If using a version of SQLite without this built\n * in, another option is to add custom functions\n * to do it. This object has the initialize, step,\n * and finalize callbacks typically needed to add\n * a custom aggregration function.\n */\nexport const gristMarshal = {\n  initialize(): GristMarshalIntermediateValue {\n    return {};\n  },\n  step(accum: GristMarshalIntermediateValue, ...row: any[]) {\n    if (!accum.names || !accum.values) {\n      accum.names = row.map(value => String(value));\n      accum.values = row.map(() => []);\n    } else {\n      for (const [i, v] of row.entries()) {\n        accum.values[i].push(v);\n      }\n    }\n    return accum;\n  },\n  finalize(accum: GristMarshalIntermediateValue) {\n    const marshaller = new Marshaller({ version: 2, keysAreBuffers: true });\n    const result: Record<string, any[]> = {};\n    if (accum.names && accum.values) {\n      for (const [i, name] of accum.names.entries()) {\n        result[name] = accum.values[i];\n      }\n    }\n    marshaller.marshal(result);\n    return marshaller.dumpAsBuffer();\n  },\n};\n\n/**\n * An intermediate value used during an aggregation.\n */\ninterface GristMarshalIntermediateValue {\n  // The names of the columns, once known.\n  names?: string[];\n  // Values stored in the columns.\n  // There is one element in the outermost array per column.\n  // That element contains a list of values stored in that column.\n  values?: any[][];\n}\n\n/**\n * Run Grist marshalling as a SQLite query, assuming\n * a custom aggregation has been added as \"grist_marshal\".\n * The marshalled result needs to contain the column\n * identifiers embedded in it. This is a little awkward\n * to organize - hence the hacky UNION here. This is\n * for compatibility with the existing marshalling method,\n * which could be replaced instead.\n */\nexport async function allMarshalQuery(db: MinDB, sql: string, ...params: any[]): Promise<Buffer> {\n  const statement = await db.prepare(sql);\n  const columns = statement.columns();\n  const quotedColumnList = columns.map(quoteIdent).join(\",\");\n  const query = await db.all(`select grist_marshal(${quotedColumnList}) as buf FROM ` +\n    `(select ${quotedColumnList} UNION ALL select * from (` + sql + \"))\", ..._fixParameters(params));\n  return query[0].buf;\n}\n\n/**\n * Booleans need to be cast to 1 or 0 for SQLite.\n * The node-sqlite3 wrapper does this automatically, but other\n * wrappers do not.\n */\nfunction _fixParameters(params: any[]) {\n  return params.map(p => p === true ? 1 : (p === false ? 0 : p));\n}\n"
  },
  {
    "path": "app/server/lib/SqliteNode.ts",
    "content": "import { fromCallback } from \"app/server/lib/serverUtils\";\nimport { Backup, MinDB, MinDBOptions, PreparedStatement,\n  ResultRow, SqliteVariant } from \"app/server/lib/SqliteCommon\";\nimport { OpenMode, RunResult } from \"app/server/lib/SQLiteDB\";\n\nimport * as sqlite3 from \"@gristlabs/sqlite3\";\n\nexport class NodeSqliteVariant implements SqliteVariant {\n  public opener(dbPath: string, mode: OpenMode): Promise<MinDB> {\n    return NodeSqlite3DatabaseAdapter.opener(dbPath, mode);\n  }\n}\n\nexport class NodeSqlite3PreparedStatement implements PreparedStatement {\n  public constructor(private _statement: sqlite3.Statement) {\n  }\n\n  public async run(...params: any[]): Promise<RunResult> {\n    return fromCallback(cb => this._statement.run(...params, cb));\n  }\n\n  public async finalize() {\n    await fromCallback(cb => this._statement.finalize(cb));\n  }\n\n  public columns(): string[] {\n    // This method is only needed if marshalling is not built in -\n    // and node-sqlite3 has marshalling built in.\n    throw new Error(\"not available (but should not be needed)\");\n  }\n}\n\nexport class NodeSqlite3DatabaseAdapter implements MinDB {\n  public static async opener(dbPath: string, mode: OpenMode): Promise<any> {\n    const sqliteMode: number =\n      (mode === OpenMode.OPEN_READONLY ? sqlite3.OPEN_READONLY : sqlite3.OPEN_READWRITE) |\n      (mode === OpenMode.OPEN_CREATE || mode === OpenMode.CREATE_EXCL ? sqlite3.OPEN_CREATE : 0);\n    let _db: sqlite3.Database;\n    await fromCallback((cb) => { _db = new sqlite3.Database(dbPath, sqliteMode, cb); });\n    const result = new NodeSqlite3DatabaseAdapter(_db!);\n    await result.limitAttach(0);  // Outside of VACUUM, we don't allow ATTACH.\n    return result;\n  }\n\n  public constructor(protected _db: sqlite3.Database) {\n    // Default database to serialized execution. See https://github.com/mapbox/node-sqlite3/wiki/Control-Flow\n    // This isn't enough for transactions, which we serialize explicitly.\n    this._db.serialize();\n  }\n\n  public async exec(sql: string): Promise<void> {\n    return fromCallback(cb => this._db.exec(sql, cb));\n  }\n\n  public async run(sql: string, ...params: any[]): Promise<RunResult> {\n    return new Promise((resolve, reject) => {\n      function callback(this: RunResult, err: Error | null) {\n        if (err) {\n          reject(err);\n        } else {\n          resolve(this);\n        }\n      }\n      this._db.run(sql, ...params, callback);\n    });\n  }\n\n  public async get(sql: string, ...params: any[]): Promise<ResultRow | undefined> {\n    return fromCallback(cb => this._db.get(sql, ...params, cb));\n  }\n\n  public async all(sql: string, ...params: any[]): Promise<ResultRow[]> {\n    return fromCallback(cb => this._db.all(sql, params, cb));\n  }\n\n  public async prepare(sql: string): Promise<PreparedStatement> {\n    let stmt: sqlite3.Statement | undefined;\n    // The original interface is a little strange; we resolve to Statement if prepare() succeeded.\n    await fromCallback((cb) => { stmt = this._db.prepare(sql, cb); }).then(() => stmt);\n    if (!stmt) { throw new Error(\"could not prepare statement\"); }\n    return new NodeSqlite3PreparedStatement(stmt);\n  }\n\n  public async close() {\n    await fromCallback(cb => this._db.close(cb));\n  }\n\n  public async interrupt(): Promise<void> {\n    this._db.interrupt();\n  }\n\n  public backup(filename: string): Backup {\n    return (this._db as sqlite3.DatabaseWithBackup).backup(filename);\n  }\n\n  public getOptions(): MinDBOptions {\n    return {\n      canInterrupt: true,\n      bindableMethodsProcessOneStatement: true,\n    };\n  }\n\n  public async allMarshal(sql: string, ...params: any[]): Promise<Buffer> {\n    // allMarshal isn't in the typings, because it is our addition to our fork of sqlite3 JS lib.\n    return fromCallback(cb => (this._db as any).allMarshal(sql, ...params, cb));\n  }\n\n  public async runAndGetId(sql: string, ...params: any[]): Promise<number> {\n    const result = await this.run(sql, ...params);\n    return (result as any).lastID;\n  }\n\n  public async limitAttach(maxAttach: number) {\n    const SQLITE_LIMIT_ATTACHED = (sqlite3 as any).LIMIT_ATTACHED;\n    // Work around node-sqlite3 bug when `.configure()` is called while a\n    // query is running.\n    // See this issue upstream: https://github.com/TryGhost/node-sqlite3/issues/1838\n    await new Promise<void>((resolve) => {\n      (this._db as any).wait(() => {\n        // Do call `configure()` here in the callback. According to the node-sqlite3's code, we are sure\n        // that the internal `Database::pending` attribute equals to 0 [^1],\n        // making sure that the `Database::Configure()` function's call to `Database.Process()`[^2] dequeues\n        // and applies the new limit immediately.\n        // [^1]: https://github.com/TryGhost/node-sqlite3/blob/528e15ae605bac7aab8de60dd7c46e9fdc1fffd0/src/database.cc#L651\n        // [^2]: https://github.com/TryGhost/node-sqlite3/blob/528e15ae605bac7aab8de60dd7c46e9fdc1fffd0/src/database.cc#L390\n        //\n        // Also cast because types are out of date.\n        (this._db as any).configure(\"limit\", SQLITE_LIMIT_ATTACHED, maxAttach);\n        resolve();\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "app/server/lib/TableMetadataLoader.ts",
    "content": "import { BulkColValues, TableColValues, TableDataAction, toTableDataAction } from \"app/common/DocActions\";\nimport log from \"app/server/lib/log\";\n\nimport fromPairs from \"lodash/fromPairs\";\n\n/**\n *\n * Handle fetching tables from the database and pushing them to the data engine during\n * document load.  The goal is to allow opening a document and viewing its contents\n * without needing to wait for the data engine.\n *\n * Fetches are done in parallel, but will be bottlenecked by node-sqlite3 and then\n * sqlite itself.  Pushes are limited to concurrency of 3, and will be bottlenecked\n * by pipe to engine in any case.\n *\n * Historically, there is some tolerance for missing tables.  TableMetadataLoader retains\n * that tolerance.\n *\n * The TableMetadataLoader doesn't play a role in document creation or migrations.\n *\n * This class is only used for loading metadata. There is no need to use it for\n * user tables, since the server never needs those tables, they should be passed\n * on to the data engine without caching. Everything the TableMetadataLoader loads persists\n * until clean() is called.\n *\n */\nexport class TableMetadataLoader {\n  // Promises of buffers for tables being fetched from database, by tableId.\n  private _fetches = new Map<string, Promise<Buffer | null>>();\n\n  // Set of all tableIds for tables that are fully fetched from database.\n  private _fetched = new Set<string>();\n\n  // Operation promises for tables being loaded into the data engine.\n  private _pushes = new Map<string, Promise<void>>();\n\n  // Set of all tableIds for tables fully loaded into the data engine.\n  private _pushed = new Set<string>();\n\n  // Unpacked tables, for reading within node. Only done if requested.\n  private _tables = new Map<string, TableDataAction>();\n\n  // Operation promise for loading core schema (table and column list) into the data engine.\n  private _corePush: Promise<void> | undefined;\n\n  // True once core push is complete.\n  private _corePushed: boolean = false;\n\n  // The number of promises currently pending.\n  private _pending: number = 0;\n\n  // Buffers will only be pushed to data engine once startStreamingToEngine() is called.\n  private _allowPushes: boolean = false;\n\n  // TableMetadataLoader requires access to database, and the ability to call the data engine.\n  constructor(private _options: {\n    decodeBuffer(buffer: Buffer, tableId: string): TableColValues,\n    fetchTable(tableId: string): Promise<Buffer>,\n    loadMetaTables(tables: Buffer, columns: Buffer): Promise<any>,\n    loadTable(tableId: string, buffer: Buffer): Promise<any>,\n  }) {\n  }\n\n  // Start sending tables to data engine as they are fetched.\n  public startStreamingToEngine() {\n    this._allowPushes = true;\n    this._update();\n  }\n\n  // Start fetching a table from the database, if it isn't already on the way.\n  public startFetchingTable(tableId: string): void {\n    if (!this._fetches.has(tableId)) {\n      this._fetches.set(tableId, this._counted(this.opFetch(tableId)));\n    }\n  }\n\n  // Read out a table as a Buffer.\n  public async fetchTableAsBuffer(tableId: string): Promise<Buffer> {\n    this.startFetchingTable(tableId);\n    const buffer = await this._fetches.get(tableId);\n    if (!buffer) {\n      throw new Error(`required table not found: ${tableId}`);\n    }\n    return buffer;\n  }\n\n  // Read out a table as a TableDataAction. Table is cached in this._tables.\n  public async fetchTableAsAction(tableId: string): Promise<TableDataAction> {\n    let cachedTable = this._tables.get(tableId);\n    if (cachedTable) { return cachedTable; }\n    const buffer = await this.fetchTableAsBuffer(tableId);\n    const values = this._options.decodeBuffer(buffer, tableId);\n    cachedTable = toTableDataAction(tableId, values);\n    this._tables.set(tableId, cachedTable);\n    return cachedTable;\n  }\n\n  // Read content of table as BulkColValues. Does not include row ids.\n  public async fetchBulkColValuesWithoutIds(tableId: string): Promise<BulkColValues> {\n    const table = await this.fetchTableAsAction(tableId);\n    return table[3];\n  }\n\n  // Read out all tables requested thus far as TableDataActions.\n  public async fetchTablesAsActions(): Promise<Record<string, TableDataAction>> {\n    for (const [tableId, opFetch] of this._fetches.entries()) {\n      if (!await opFetch) {\n        // Tolerate missing tables.\n        continue;\n      }\n      await this.fetchTableAsAction(tableId);\n    }\n    return fromPairs([...this._tables.entries()]);\n  }\n\n  // Wait for all operations to complete.\n  public async wait() {\n    while (this._pending > 0) {\n      await Promise.all(this._fetches.values());\n      await this._corePush;\n      await Promise.all(this._pushes.values());\n    }\n  }\n\n  // Wipe all stored state.\n  public async clean() {\n    await this.wait();\n    this._fetches.clear();\n    this._fetched.clear();\n    this._pushes.clear();\n    this._pushed.clear();\n    this._corePush = undefined;\n    this._corePushed = false;\n    this._tables.clear();\n    this._pending = 0;\n  }\n\n  // Core push operation. Before we can send arbitrary tables to engine, we must call\n  // load_meta_tables with tables and columns.\n  public async opCorePush() {\n    const tables = await this.fetchTableAsBuffer(\"_grist_Tables\");\n    const columns = await this.fetchTableAsBuffer(\"_grist_Tables_column\");\n    await this._options.loadMetaTables(tables, columns);\n    this._corePushed = true;\n    // It appears to be bad and unnecessary to send tables and columns outside of core push.\n    this._pushed.add(\"_grist_Tables\");\n    this._pushed.add(\"_grist_Tables_column\");\n    this._update();\n  }\n\n  // Operation to fetch a single table from database.\n  public async opFetch(tableId: string) {\n    try {\n      return await this._options.fetchTable(tableId);\n    } catch (err) {\n      if (/no such table/.test(err.message)) { return null; }\n      throw err;\n    } finally {\n      this._fetched.add(tableId);\n      this._update();\n    }\n  }\n\n  // Operation to push a single table to the data engine.\n  public async opPush(tableId: string) {\n    const buffer = await this._fetches.get(tableId);\n    // Tolerate missing tables.\n    if (buffer) {\n      await this._options.loadTable(tableId, buffer);\n    }\n    this._pushed.add(tableId);\n    this._update();\n  }\n\n  // Called after any operation has completed, to see if there's any more work we can start\n  // doing.\n  private _update() {\n    // If pushes are not allowed yet, there's no possibility of follow-on work.\n    if (!this._allowPushes) { return; }\n\n    // Get a list of new pushes that will be needed.\n    const newPushes = new Set([...this._fetched]\n      .filter(tableId => !(this._pushes.has(tableId) ||\n        this._pushed.has(tableId))));\n\n    // Be careful to do the core push first, once we can.\n    if (!this._corePushed) {\n      if (this._corePush === undefined && newPushes.has(\"_grist_Tables\") && newPushes.has(\"_grist_Tables_column\")) {\n        this._corePush = this._counted(this.opCorePush()).catch((e) => {\n          log.warn(`TableMetadataLoader opCorePush failed: ${e}`);\n        });\n      }\n      return;\n    }\n\n    // Start new pushes. Sort to give a bit more determinism, but the order depends on a lot\n    // of low-level details (meaning DocRegressionTest is not on a very firm foundation).\n    for (const tableId of [...newPushes].sort()) {\n      // Put a limit on the number of outstanding pushes permitted.\n      if (this._pushes.size >= this._pushed.size + 3) { break; }\n      const promise = this._counted(this.opPush(tableId));\n      this._pushes.set(tableId, promise);\n      // Mark the promise as handled to avoid \"unhandledRejection\", but without affecting other\n      // code (which will still see `promise`, not the new promise returned by `.catch()`).\n      promise.catch(() => {});\n    }\n  }\n\n  // Wrapper to keep track of pending promises.\n  private async _counted<T>(op: Promise<T>): Promise<T> {\n    this._pending++;\n    try {\n      return await op;\n    } finally {\n      this._pending--;\n    }\n  }\n}\n"
  },
  {
    "path": "app/server/lib/TagChecker.ts",
    "content": "import { tbind } from \"app/common/tbind\";\n\nimport { NextFunction, Request, RequestHandler, Response } from \"express\";\n\nexport type RequestWithTag = Request & { tag: string | null };\n\n/**\n *\n * Middleware to handle a /v/TAG/ prefix on urls.\n *\n */\nexport class TagChecker {\n  // Use app.use(tagChecker.inspectTag) to strip /v/TAG/ from urls (if it is present).\n  // If the tag is present and matches what is expected, then `tag` is set on the request.\n  // If the tag is present but does not match what is expected, a 400 response is returned.\n  // If the tag is absent, `tag` is not set on the request.\n  public readonly inspectTag: RequestHandler = tbind(this._inspectTag, this);\n\n  // Use app.get('/path', tagChecker.requireTag, ...) to serve something only if the tag was\n  // present in the url.  If the tag was not present, the route will not match and express will\n  // look further.\n  public readonly requireTag: RequestHandler = tbind(this._requireTag, this);\n\n  // pass in the tag to expect.\n  public constructor(public tag: string) {\n  }\n\n  // Like requireTag but for use wrapping other handlers in app.use().\n  // Whatever it wraps will be skipped if that tag was not set.\n  // See https://github.com/expressjs/express/issues/2591\n  public withTag(handler: RequestHandler) {\n    return function fn(req: Request, resp: Response, next: NextFunction) {\n      if (!(req as RequestWithTag).tag) { return next(); }\n      return handler(req, resp, next);\n    };\n  }\n\n  // Removes tag from url if present.\n  // Returns [remainder, tagInUrl, isMatch]\n  private _removeTag(url: string): [string, string | null, boolean] {\n    if (url.startsWith(\"/v/\")) {\n      const taggedUrl = url.match(/^\\/v\\/([a-zA-Z0-9.\\-_]+)(\\/.*)/);\n      if (taggedUrl) {\n        const tag = taggedUrl[1];\n        // Turn off tag matching as we transition to serving\n        // static resources from CDN.  We don't have version-sensitive\n        // routing, so under ordinary operation landing page html served\n        // by one home server could have its assets served by another home server.\n        // Once the CDN is active, those asset requests won't reach the home\n        // servers.  TODO: turn tag matching back on when tag mismatches\n        // imply a bug.\n        return [taggedUrl[2], tag, true];\n      }\n    }\n    return [url, null, true];\n  }\n\n  private async _inspectTag(req: Request, resp: Response, next: NextFunction) {\n    const [newUrl, urlTag, isOk] = this._removeTag(req.url);\n    if (!isOk) {\n      return resp.status(400).send({ error: \"Tag mismatch\",\n        expected: this.tag,\n        received: urlTag });\n    }\n    req.url = newUrl;\n    (req as RequestWithTag).tag = urlTag;\n    return next();\n  }\n\n  private async _requireTag(req: Request, resp: Response, next: NextFunction) {\n    if ((req as RequestWithTag).tag) { return next(); }\n    return next(\"route\");\n  }\n}\n"
  },
  {
    "path": "app/server/lib/Telemetry.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { TelemetryConfig } from \"app/common/gristUrls\";\nimport { assertIsDefined } from \"app/common/gutil\";\nimport { TelemetryPrefsWithSources } from \"app/common/InstallAPI\";\nimport {\n  buildTelemetryEventChecker,\n  Level,\n  TelemetryContracts,\n  TelemetryEvent,\n  TelemetryEventChecker,\n  TelemetryEvents,\n  TelemetryLevel,\n  TelemetryLevels,\n  TelemetryMetadata,\n  TelemetryMetadataByLevel,\n  TelemetryRetentionPeriod,\n} from \"app/common/Telemetry\";\nimport { Activation } from \"app/gen-server/entity/Activation\";\nimport { ActivationsManager } from \"app/gen-server/lib/ActivationsManager\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { hashId } from \"app/server/lib/hashingUtils\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\nimport { stringParam } from \"app/server/lib/requestUtils\";\nimport { getAuthSession, getLogMeta, isRequest, RequestOrSession } from \"app/server/lib/sessionUtils\";\n\nimport * as cookie from \"cookie\";\nimport * as express from \"express\";\nimport merge from \"lodash/merge\";\nimport pickBy from \"lodash/pickBy\";\nimport fetch from \"node-fetch\";\n\ninterface RequestWithMatomoVisitorId extends RequestWithLogin {\n  /**\n   * Extracted from a cookie set by Matomo.\n   *\n   * Used by an AWS Lambda (LogsToMatomo_grist) to associate Grist telemetry\n   * events with Matomo visits.\n   */\n  matomoVisitorId?: string | null;\n}\n\nexport interface ITelemetry {\n  start(): Promise<void>;\n  logEvent(\n    requestOrSession: RequestOrSession,\n    name: TelemetryEvent,\n    metadata?: TelemetryMetadataByLevel\n  ): void;\n  logEventAsync(\n    requestOrSession: RequestOrSession,\n    name: TelemetryEvent,\n    metadata?: TelemetryMetadataByLevel\n  ): Promise<void>;\n  shouldLogEvent(name: TelemetryEvent): boolean;\n  addEndpoints(app: express.Express): void;\n  getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined;\n  fetchTelemetryPrefs(): Promise<void>;\n}\n\nconst MAX_PENDING_FORWARD_EVENT_REQUESTS = 25;\n\n/**\n * Manages telemetry for Grist.\n */\nexport class Telemetry implements ITelemetry {\n  private _activation: Activation | undefined;\n  private _telemetryPrefs: TelemetryPrefsWithSources | undefined;\n  private readonly _deploymentType = this._gristServer.getDeploymentType();\n  private readonly _shouldForwardTelemetryEvents = this._deploymentType !== \"saas\";\n  private readonly _forwardTelemetryEventsUrl = process.env.GRIST_TELEMETRY_URL ||\n    \"https://telemetry.getgrist.com/api/telemetry\";\n\n  private _numPendingForwardEventRequests = 0;\n  private readonly _logger = new LogMethods<RequestOrSession | undefined>(\"Telemetry \", requestOrSession =>\n    getLogMeta(requestOrSession));\n\n  private readonly _telemetryLogger = new LogMethods<string>(\"Telemetry \", eventType => ({\n    eventType,\n  }));\n\n  private _checkTelemetryEvent: TelemetryEventChecker | undefined;\n\n  constructor(private _dbManager: HomeDBManager, private _gristServer: GristServer) {\n\n  }\n\n  public async start() {\n    await this.fetchTelemetryPrefs();\n  }\n\n  /**\n   * Logs a telemetry `event` and its `metadata`.\n   *\n   * Depending on the deployment type, this will either forward the\n   * data to an endpoint (set via GRIST_TELEMETRY_URL) or log it\n   * directly. In hosted Grist, telemetry is logged directly, and\n   * subsequently sent to an OpenSearch instance via CloudWatch. In\n   * other deployment types, telemetry is forwarded to an endpoint\n   * of hosted Grist, which then handles logging to OpenSearch.\n   *\n   * Note that `metadata` is grouped by telemetry level, with only the\n   * groups meeting the current telemetry level being included in\n   * what's logged. If the current telemetry level is `off`, nothing\n   * will be logged. Otherwise, `metadata` will be filtered according\n   * to the current telemetry level, keeping only the groups that are\n   * less than or equal to the current level.\n   *\n   * Additionally, runtime checks are also performed to verify that the\n   * event and metadata being passed in are being logged appropriately\n   * for the configured telemetry level. If any checks fail, an error\n   * is thrown.\n   *\n   * Example:\n   *\n   * The following will only log the `rowCount` if the telemetry level is set\n   * to `limited`, and will log both the `method` and `userId` if the telemetry\n   * level is set to `full`:\n   *\n   * ```\n   * logEvent('documentUsage', {\n   *   limited: {\n   *     rowCount: 123,\n   *   },\n   *   full: {\n   *     userId: 1586,\n   *   },\n   * });\n   * ```\n   */\n  public async logEventAsync(\n    requestOrSession: RequestOrSession,\n    event: TelemetryEvent,\n    metadata?: TelemetryMetadataByLevel,\n  ) {\n    await this._checkAndLogEvent(requestOrSession, event, metadata);\n  }\n\n  /**\n   * Non-async variant of `logEventAsync`.\n   *\n   * Convenient for fire-and-forget usage.\n   */\n  public logEvent(\n    requestOrSession: RequestOrSession,\n    event: TelemetryEvent,\n    metadata?: TelemetryMetadataByLevel,\n  ) {\n    this.logEventAsync(requestOrSession, event, metadata).catch((e) => {\n      this._logger.error(requestOrSession, `failed to log telemetry event ${event}`, e);\n    });\n  }\n\n  public addEndpoints(app: express.Application) {\n    /**\n     * Logs telemetry events and their metadata.\n     *\n     * Clients of this endpoint may be external Grist instances, so the behavior\n     * varies based on the presence of an `eventSource` key in the event metadata.\n     *\n     * If an `eventSource` key is present, the telemetry event will be logged\n     * directly, as the request originated from an external source; runtime checks\n     * of telemetry data are skipped since they should have already occured at the\n     * source. Otherwise, the event will only be logged after passing various\n     * checks.\n     */\n    app.post(\"/api/telemetry\", expressWrap(async (req, resp) => {\n      const mreq = req as RequestWithLogin;\n      const event = stringParam(req.body.event, \"event\", { allowed: TelemetryEvents.values }) as TelemetryEvent;\n      if (\"eventSource\" in (req.body.metadata ?? {})) {\n        this._telemetryLogger.rawLog(\"info\", getEventType(event), event, {\n          ...(removeNullishKeys(req.body.metadata)),\n          eventName: event,\n        });\n      } else {\n        try {\n          this._assertTelemetryIsReady();\n          await this._checkAndLogEvent(mreq, event, merge(\n            {\n              full: {\n                userId: mreq.userId,\n                altSessionId: mreq.altSessionId,\n              },\n            },\n            req.body.metadata,\n          ));\n        } catch (e) {\n          this._logger.error(mreq, `failed to log telemetry event ${event}`, e);\n          throw new ApiError(`Telemetry failed to log telemetry event ${event}`, 500);\n        }\n      }\n      return resp.status(200).send();\n    }));\n  }\n\n  public getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined {\n    const prefs = this._telemetryPrefs;\n    if (!prefs) {\n      this._logger.error(requestOrSession, \"getTelemetryConfig called but telemetry preferences are undefined\");\n      return undefined;\n    }\n\n    return {\n      telemetryLevel: prefs.telemetryLevel.value,\n    };\n  }\n\n  public async fetchTelemetryPrefs() {\n    this._activation = await this._gristServer.getActivations().current();\n    await this._fetchTelemetryPrefs();\n  }\n\n  // Checks if the event should be logged.\n  public shouldLogEvent(event: TelemetryEvent): boolean {\n    return Boolean(this._prepareToLogEvent(event));\n  }\n\n  private async _fetchTelemetryPrefs() {\n    this._telemetryPrefs = await getTelemetryPrefs(this._dbManager, this._activation);\n    this._checkTelemetryEvent = buildTelemetryEventChecker(this._telemetryPrefs.telemetryLevel.value);\n  }\n\n  private _prepareToLogEvent(\n    event: TelemetryEvent,\n  ): { checkTelemetryEvent: TelemetryEventChecker, telemetryLevel: TelemetryLevel } | undefined {\n    if (!this._checkTelemetryEvent) {\n      this._logger.error(null, \"telemetry event checker is undefined\");\n      return;\n    }\n\n    const prefs = this._telemetryPrefs;\n    if (!prefs) {\n      this._logger.error(null, \"telemetry preferences are undefined\");\n      return;\n    }\n\n    const telemetryLevel = prefs.telemetryLevel.value;\n    if (TelemetryContracts[event] && TelemetryContracts[event].minimumTelemetryLevel > Level[telemetryLevel]) {\n      return;\n    }\n    return { checkTelemetryEvent: this._checkTelemetryEvent, telemetryLevel };\n  }\n\n  private async _checkAndLogEvent(\n    requestOrSession: RequestOrSession,\n    event: TelemetryEvent,\n    metadata?: TelemetryMetadataByLevel,\n  ) {\n    const result = this._prepareToLogEvent(event);\n    if (!result) {\n      return;\n    }\n\n    metadata = filterMetadata(metadata, result.telemetryLevel);\n    result.checkTelemetryEvent(event, metadata);\n\n    if (this._shouldForwardTelemetryEvents) {\n      await this._forwardEvent(requestOrSession, event, metadata);\n    } else {\n      this._logEvent(requestOrSession, event, metadata);\n    }\n  }\n\n  private _logEvent(\n    requestOrSession: RequestOrSession,\n    event: TelemetryEvent,\n    metadata: TelemetryMetadata = {},\n  ) {\n    const isAnonymousUser = metadata.userId === this._dbManager.getAnonymousUserId();\n    let isInternalUser: boolean | undefined;\n    let isTeamSite: boolean | undefined;\n    let visitorId: string | null | undefined;\n    if (requestOrSession) {\n      const authSession = getAuthSession(requestOrSession);\n      if (isRequest(requestOrSession) && isAnonymousUser) {\n        visitorId = this._getAndSetMatomoVisitorId(requestOrSession);\n      }\n      const email = authSession.normalizedEmail;\n      if (email) {\n        isInternalUser = email !== \"anon@getgrist.com\" && email.endsWith(\"@getgrist.com\");\n      }\n      const org = authSession.org;\n      if (org && !process.env.GRIST_SINGLE_ORG) {\n        isTeamSite = !this._dbManager.isMergedOrg(org);\n      }\n    }\n    const { category: eventCategory } = TelemetryContracts[event];\n    this._telemetryLogger.rawLog(\"info\", getEventType(event), event, {\n      ...metadata,\n      eventName: event,\n      ...(eventCategory !== undefined ? { eventCategory } : undefined),\n      eventSource: `grist-${this._deploymentType}`,\n      installationId: this._activation!.id,\n      ...(isInternalUser !== undefined ? { isInternalUser } : undefined),\n      ...(isTeamSite !== undefined ? { isTeamSite } : undefined),\n      ...(visitorId ? { visitorId } : undefined),\n      ...(isAnonymousUser ? { userId: undefined } : undefined),\n    });\n  }\n\n  private _getAndSetMatomoVisitorId(req: RequestWithMatomoVisitorId) {\n    if (req.matomoVisitorId === undefined) {\n      const cookies = cookie.parse(req.headers.cookie || \"\");\n      const matomoVisitorCookie = Object.entries(cookies)\n        .find(([key]) => key.startsWith(\"_pk_id\"));\n      if (matomoVisitorCookie) {\n        req.matomoVisitorId = (matomoVisitorCookie[1] as string).split(\".\")[0];\n      } else {\n        req.matomoVisitorId = null;\n      }\n    }\n    return req.matomoVisitorId;\n  }\n\n  private async _forwardEvent(\n    requestOrSession: RequestOrSession,\n    event: TelemetryEvent,\n    metadata?: TelemetryMetadata,\n  ) {\n    if (this._numPendingForwardEventRequests === MAX_PENDING_FORWARD_EVENT_REQUESTS) {\n      this._logger.warn(requestOrSession, \"exceeded the maximum number of pending forwardEvent calls \" +\n      `(${MAX_PENDING_FORWARD_EVENT_REQUESTS}). Skipping forwarding of event ${event}.`);\n      return;\n    }\n\n    try {\n      this._numPendingForwardEventRequests += 1;\n      const { category: eventCategory } = TelemetryContracts[event];\n\n      if (metadata) {\n        if (\"installationId\" in metadata ||\n          \"eventSource\" in metadata ||\n          \"eventName\" in metadata ||\n          \"eventCategory\" in metadata) {\n          throw new Error(\"metadata contains reserved keys\");\n        }\n      }\n\n      await this._doForwardEvent(JSON.stringify({\n        event,\n        metadata: {\n          ...metadata,\n          eventName: event,\n          ...(eventCategory !== undefined ? { eventCategory } : undefined),\n          eventSource: `grist-${this._deploymentType}`,\n          installationId: this._activation!.id,\n        },\n      }));\n    } catch (e) {\n      this._logger.error(requestOrSession, `failed to forward telemetry event ${event}`, e);\n    } finally {\n      this._numPendingForwardEventRequests -= 1;\n    }\n  }\n\n  private async _doForwardEvent(payload: string) {\n    await fetch(this._forwardTelemetryEventsUrl, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: payload,\n    });\n  }\n\n  private _assertTelemetryIsReady() {\n    try {\n      assertIsDefined(\"activation\", this._activation);\n    } catch (e) {\n      this._logger.error(null, \"activation is undefined\", e);\n      throw new ApiError(\"Telemetry is not ready\", 500);\n    }\n  }\n}\n\nexport async function getTelemetryPrefs(\n  db: HomeDBManager,\n  activation?: Activation,\n): Promise<TelemetryPrefsWithSources> {\n  const GRIST_TELEMETRY_LEVEL = appSettings.section(\"telemetry\").flag(\"level\").readString({\n    envVar: \"GRIST_TELEMETRY_LEVEL\",\n  });\n  if (GRIST_TELEMETRY_LEVEL !== undefined) {\n    const value = TelemetryLevels.check(GRIST_TELEMETRY_LEVEL);\n    return {\n      telemetryLevel: {\n        value,\n        source: \"environment-variable\",\n      },\n    };\n  }\n\n  const { prefs } = activation ?? await new ActivationsManager(db).current();\n  return {\n    telemetryLevel: {\n      value: prefs?.telemetry?.telemetryLevel ?? \"off\",\n      source: \"preferences\",\n    },\n  };\n}\n\n/**\n * Returns a new, filtered metadata object, or undefined if `metadata` is undefined.\n *\n * Filtering currently:\n *  - removes keys in groups that exceed `telemetryLevel`\n *  - removes keys with values of null or undefined\n *  - hashes the values of keys suffixed with \"Digest\" (e.g. doc ids, fork ids)\n *  - flattens the entire metadata object (i.e. removes the nesting of keys under\n *    \"limited\" or \"full\")\n */\nexport function filterMetadata(\n  metadata: TelemetryMetadataByLevel | undefined,\n  telemetryLevel: TelemetryLevel,\n): TelemetryMetadata | undefined {\n  if (!metadata) { return; }\n\n  let filteredMetadata: TelemetryMetadata = {};\n  for (const level of [\"limited\", \"full\"] as const) {\n    if (Level[telemetryLevel] < Level[level]) { break; }\n\n    filteredMetadata = { ...filteredMetadata, ...metadata[level] };\n  }\n\n  filteredMetadata = removeNullishKeys(filteredMetadata);\n  filteredMetadata = hashDigestKeys(filteredMetadata);\n\n  return filteredMetadata;\n}\n\n/**\n * Returns a copy of `object` with all null and undefined keys removed.\n */\nexport function removeNullishKeys(object: Record<string, any>) {\n  return pickBy(object, value => value !== null && value !== undefined);\n}\n\n/**\n * Returns a copy of `metadata`, replacing the values of all keys suffixed\n * with \"Digest\" with the result of hashing the value. The hash is prefixed with\n * the first 4 characters of the original value, to assist with troubleshooting.\n */\nexport function hashDigestKeys(metadata: TelemetryMetadata): TelemetryMetadata {\n  const filteredMetadata: TelemetryMetadata = {};\n  Object.entries(metadata).forEach(([key, value]) => {\n    if (key.endsWith(\"Digest\") && typeof value === \"string\") {\n      filteredMetadata[key] = hashId(value);\n    } else {\n      filteredMetadata[key] = value;\n    }\n  });\n  return filteredMetadata;\n}\n\ntype TelemetryEventType = \"telemetry\" | \"telemetry-short-retention\";\n\nconst EventTypeByRetentionPeriod: Record<TelemetryRetentionPeriod, TelemetryEventType> = {\n  indefinitely: \"telemetry\",\n  short: \"telemetry-short-retention\",\n};\n\nfunction getEventType(event: TelemetryEvent) {\n  const { retentionPeriod } = TelemetryContracts[event];\n  return EventTypeByRetentionPeriod[retentionPeriod];\n}\n"
  },
  {
    "path": "app/server/lib/TestLogin.ts",
    "content": "import { SUPPORT_EMAIL } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { GristLoginSystem, GristServer } from \"app/server/lib/GristServer\";\n\nimport { Request } from \"express\";\n\n/**\n * Return a login system for testing. Just enough to use the test/login endpoint\n * available when GRIST_TEST_LOGIN=1 is set.\n */\nexport async function getTestLoginSystem(): Promise<GristLoginSystem> {\n  return {\n    async getMiddleware(gristServer: GristServer) {\n      async function getLoginRedirectUrl(req: Request, url: URL)  {\n        const target = new URL(gristServer.getHomeUrl(req, \"test/login\"));\n        target.searchParams.append(\"next\", url.href);\n        return target.href || url.href;\n      }\n      return {\n        getLoginRedirectUrl,\n        async getLogoutRedirectUrl(req: Request, url: URL) {\n          return url.href;\n        },\n        getSignUpRedirectUrl: getLoginRedirectUrl,\n        async addEndpoints() {\n          // Make sure support user has a test api key if needed.\n          if (process.env.TEST_SUPPORT_API_KEY) {\n            const dbManager = gristServer.getHomeDBManager();\n            const user = await dbManager.getUserByLogin(SUPPORT_EMAIL);\n            user.apiKey = process.env.TEST_SUPPORT_API_KEY;\n            await user.save();\n          }\n          return \"test-login\";\n        },\n      };\n    },\n    async deleteUser() {\n      // nothing to do\n    },\n  };\n}\n"
  },
  {
    "path": "app/server/lib/TestingHooks.ts",
    "content": "import { UserProfile } from \"app/common/LoginSessionAPI\";\nimport { Deps as ActiveDocDeps } from \"app/server/lib/ActiveDoc\";\nimport { Deps as CommClientDeps } from \"app/server/lib/Client\";\nimport * as Client from \"app/server/lib/Client\";\nimport { Comm } from \"app/server/lib/Comm\";\nimport { Deps as DiscourseConnectDeps } from \"app/server/lib/DiscourseConnect\";\nimport { FlexServer } from \"app/server/lib/FlexServer\";\nimport { ClientJsonMemoryLimits, ITestingHooks } from \"app/server/lib/ITestingHooks\";\nimport ITestingHooksTI from \"app/server/lib/ITestingHooks-ti\";\nimport log from \"app/server/lib/log\";\nimport { connect, fromCallback } from \"app/server/lib/serverUtils\";\nimport { WidgetRepositoryImpl } from \"app/server/lib/WidgetRepository\";\n\nimport { EventEmitter } from \"events\";\nimport * as net from \"net\";\n\nimport { Request } from \"express\";\nimport { IMessage, Rpc } from \"grain-rpc\";\nimport * as t from \"ts-interface-checker\";\n\nconst tiCheckers = t.createCheckers(ITestingHooksTI, { UserProfile: t.name(\"object\") });\n\nexport function startTestingHooks(socketPath: string, port: number,\n  comm: Comm, flexServer: FlexServer,\n  workerServers: FlexServer[]): Promise<net.Server> {\n  // Create socket server listening on the given path for testing connections.\n  return new Promise((resolve, reject) => {\n    const server = net.createServer();\n    server.on(\"error\", reject);\n    server.on(\"listening\", () => resolve(server));\n    server.on(\"connection\", (socket) => {\n      // On connection, create an Rpc object communicating over that socket.\n      const rpc = connectToSocket(new Rpc({ logger: {} }), socket);\n      // Register the testing implementation.\n      rpc.registerImpl(\"testing\",\n        new TestingHooks(port, comm, flexServer, workerServers),\n        tiCheckers.ITestingHooks);\n    });\n    server.listen(socketPath);\n  });\n}\n\nfunction connectToSocket(rpc: Rpc, socket: net.Socket): Rpc {\n  socket.setEncoding(\"utf8\");\n  // Poor-man's JSON processing, only OK because this is for testing only. If multiple messages\n  // are received quickly, they may arrive in the same buf, and JSON.parse will fail.\n  socket.on(\"data\", (buf: string) => rpc.receiveMessage(JSON.parse(buf)));\n  rpc.setSendMessage((m: IMessage) => fromCallback(cb => socket.write(JSON.stringify(m), \"utf8\", cb)));\n  return rpc;\n}\n\nexport interface TestingHooksClient extends ITestingHooks {\n  close(): void;\n}\n\nexport async function connectTestingHooks(socketPath: string): Promise<TestingHooksClient> {\n  const socket = await connect(socketPath);\n  const rpc = connectToSocket(new Rpc({ logger: {} }), socket);\n  return Object.assign(rpc.getStub<TestingHooks>(\"testing\", tiCheckers.ITestingHooks), {\n    close: () => socket.end(),\n  });\n}\n\nexport class TestingHooks implements ITestingHooks {\n  constructor(\n    private _port: number,\n    private _comm: Comm,\n    private _server: FlexServer,\n    private _workerServers: FlexServer[],\n  ) {}\n\n  public async getPort(): Promise<number> {\n    log.info(\"TestingHooks.getPort called\");\n    return this._port;\n  }\n\n  public async setLoginSessionProfile(\n    gristSidCookie: string, profile: UserProfile | null, org?: string,\n  ): Promise<void> {\n    log.info(\"TestingHooks.setLoginSessionProfile called with\", gristSidCookie, profile, org);\n    const sessions = this._server.getSessions();\n    const sessionId = sessions.getSessionIdFromCookie(gristSidCookie);\n    const scopedSession = sessions.getOrCreateSession(sessionId as string, org);\n    const req = {} as Request;\n    await scopedSession.updateUserProfile(req, profile);\n    this._server.getSessions().clearCacheIfNeeded({ email: profile?.email, org });\n  }\n\n  public async setServerVersion(version: string | null): Promise<void> {\n    log.info(\"TestingHooks.setServerVersion called with\", version);\n    this._comm.setServerVersion(version);\n    for (const server of this._workerServers) {\n      server.getComm().setServerVersion(version);\n    }\n  }\n\n  public async disconnectClients(): Promise<void> {\n    log.info(\"TestingHooks.disconnectClients called\");\n    this._comm.destroyAllClients();\n    for (const server of this._workerServers) {\n      server.getComm().destroyAllClients();\n    }\n  }\n\n  public async commShutdown(): Promise<void> {\n    log.info(\"TestingHooks.commShutdown called\");\n    await this._comm.testServerShutdown();\n    for (const server of this._workerServers) {\n      await server.getComm().testServerShutdown();\n    }\n  }\n\n  public async commRestart(): Promise<void> {\n    log.info(\"TestingHooks.commRestart called\");\n    await this._comm.testServerRestart();\n    for (const server of this._workerServers) {\n      await server.getComm().testServerRestart();\n    }\n  }\n\n  // Set how long new clients will persist after disconnection.\n  // Returns the previous value.\n  public async commSetClientPersistence(ttlMs: number): Promise<number> {\n    log.info(\"TestingHooks.commSetClientPersistence called with\", ttlMs);\n    const prev = CommClientDeps.clientRemovalTimeoutMs;\n    CommClientDeps.clientRemovalTimeoutMs = ttlMs;\n    return prev;\n  }\n\n  // Set one or more limits that Client.ts can use for JSON responses, in bytes.\n  // Returns the old limits.\n  // - totalSize limits total amount of memory Client allocates to JSON response\n  // - jsonResponseReservation sets the initial amount reserved for each response\n  // - maxReservationSize monkey-patches reservation logic to fail when reservation exceeds the\n  //      given amount, to simulate unexpected failures.\n  public async commSetClientJsonMemoryLimits(limits: ClientJsonMemoryLimits): Promise<ClientJsonMemoryLimits> {\n    log.info(\"TestingHooks.commSetClientJsonMemoryLimits called with\", limits);\n    const previous: ClientJsonMemoryLimits = {};\n    if (limits.totalSize !== undefined) {\n      previous.totalSize = Client.jsonMemoryPool.setTotalSize(limits.totalSize);\n    }\n    if (limits.jsonResponseReservation !== undefined) {\n      previous.jsonResponseReservation = CommClientDeps.jsonResponseReservation;\n      CommClientDeps.jsonResponseReservation = limits.jsonResponseReservation;\n    }\n    if (limits.maxReservationSize !== undefined) {\n      previous.maxReservationSize = null;\n      const orig = Object.getPrototypeOf(Client.jsonMemoryPool)._updateReserved;\n      if (limits.maxReservationSize === null) {\n        (Client.jsonMemoryPool as any)._updateReserved = orig;\n      } else {\n        // Monkey-patch reservation logic to simulate unexpected failures.\n        const jsonMemoryThrowLimit = limits.maxReservationSize;\n        function updateReservedWithLimit(this: typeof Client.jsonMemoryPool, sizeDelta: number) {\n          const newSize: number = (this as any)._reservedSize + sizeDelta;\n          log.warn(`TestingHooks _updateReserved reserving ${newSize}, limit ${jsonMemoryThrowLimit}`);\n          if (newSize > jsonMemoryThrowLimit) {\n            throw new Error(`TestingHooks: hit JsonMemoryThrowLimit: ${newSize} > ${jsonMemoryThrowLimit}`);\n          }\n          return orig.call(this, sizeDelta);\n        }\n        (Client.jsonMemoryPool as any)._updateReserved = updateReservedWithLimit;\n      }\n    }\n    return previous;\n  }\n\n  public async closeDocs(): Promise<void> {\n    log.info(\"TestingHooks.closeDocs called\");\n    if (this._server) {\n      await this._server.testCloseDocs();\n    }\n    for (const server of this._workerServers) {\n      await server.testCloseDocs();\n    }\n  }\n\n  public async setDocWorkerActivation(workerId: string, active: \"active\" | \"inactive\" | \"crash\"):\n  Promise<void> {\n    log.info(\"TestingHooks.setDocWorkerActivation called with\", workerId, active);\n    const matches = this._workerServers.filter(\n      server => server.worker.id === workerId ||\n        server.worker.publicUrl === workerId ||\n        (server.worker.publicUrl.startsWith(\"http://localhost:\") &&\n          workerId.startsWith(\"http://localhost:\") &&\n          new URL(server.worker.publicUrl).host === new URL(workerId).host));\n    if (matches.length !== 1) {\n      throw new Error(`could not find worker: ${workerId}`);\n    }\n    const server = matches[0];\n    switch (active) {\n      case \"active\":\n        await server.restartListening();\n        break;\n      case \"inactive\":\n        await server.stopListening();\n        break;\n      case \"crash\":\n        await server.stopListening(\"crash\");\n        break;\n    }\n  }\n\n  public async flushAuthorizerCache(): Promise<void> {\n    log.info(\"TestingHooks.flushAuthorizerCache called\");\n    this._server.getHomeDBManager().flushDocAuthCache();\n    for (const server of this._workerServers) {\n      server.getHomeDBManager().flushDocAuthCache();\n    }\n  }\n\n  public async flushDocs(): Promise<void> {\n    log.info(\"TestingHooks.flushDocs called\");\n    for (const server of this._workerServers) {\n      await server.testFlushDocs();\n    }\n  }\n\n  // Returns a Map from docId to number of connected clients for all open docs across servers,\n  // but represented as an array of pairs, to be serializable.\n  public async getDocClientCounts(): Promise<[string, number][]> {\n    log.info(\"TestingHooks.getDocClientCounts called\");\n    const counts = new Map<string, number>();\n    for (const server of [this._server, ...this._workerServers]) {\n      const c = await server.getDocClientCounts();\n      for (const [key, val] of c) {\n        counts.set(key, (counts.get(key) || 0) + val);\n      }\n    }\n    return Array.from(counts);\n  }\n\n  // Sets the seconds for ActiveDoc timeout, and returns the previous value.\n  public async setActiveDocTimeout(seconds: number): Promise<number> {\n    const prev = ActiveDocDeps.ACTIVEDOC_TIMEOUT;\n    ActiveDocDeps.ACTIVEDOC_TIMEOUT = seconds;\n    return prev;\n  }\n\n  // Sets env vars for the DiscourseConnect module, and returns the previous value.\n  public async setDiscourseConnectVar(varName: string, value: string | null): Promise<string | null> {\n    const key = varName as keyof typeof DiscourseConnectDeps;\n    const prev = DiscourseConnectDeps[key] || null;\n    if (value == null) {\n      delete DiscourseConnectDeps[key];\n    } else {\n      DiscourseConnectDeps[key] = value;\n    }\n    return prev;\n  }\n\n  public async setWidgetRepositoryUrl(url: string): Promise<void> {\n    const repo = this._server.getWidgetRepository() as WidgetRepositoryImpl;\n    if (!(repo instanceof WidgetRepositoryImpl)) {\n      throw new Error(\"Unsupported widget repository\");\n    }\n    repo.testOverrideUrl(url);\n  }\n\n  public async getMemoryUsage(): Promise<NodeJS.MemoryUsage> {\n    return process.memoryUsage();\n  }\n\n  // This is for testing the handling of unhandled exceptions and rejections.\n  public async tickleUnhandledErrors(errType: \"exception\" | \"rejection\" | \"error-event\"): Promise<void> {\n    if (errType === \"exception\") {\n      setTimeout(() => { throw new Error(\"TestingHooks: Fake exception\"); }, 0);\n    } else if (errType === \"rejection\") {\n      void (Promise.resolve(null).then(() => { throw new Error(\"TestingHooks: Fake rejection\"); }));\n    } else if (errType === \"error-event\") {\n      const emitter = new EventEmitter();\n      setTimeout(() => emitter.emit(\"error\", new Error(\"TestingHooks: Fake error-event\")), 0);\n    } else {\n      throw new Error(`Unrecognized errType ${errType}`);\n    }\n  }\n}\n"
  },
  {
    "path": "app/server/lib/Throttle.ts",
    "content": "/**\n *\n * Simple CPU throttling implementation.\n *\n * For this setup, a sandbox attempting to use 100% of cpu over an\n * extended period will end up throttled, in the steady-state, to\n * 10% of cpu.\n *\n * Very simple mechanism to begin with.  \"ctime\" is measured for the\n * sandbox, being the cumulative time charged to the user (directly or\n * indirectly) by the OS for that process.  If the average increase in\n * ctime over a time period is over 10% (targetRate) of that time period,\n * throttling kicks in, and the process will be paused/unpaused via\n * signals on a duty cycle.\n *\n * Left for future work: more careful shaping of CPU throttling, and\n * factoring in a team-site level credit system or similar.\n *\n */\n\nimport { Interval } from \"app/common/Interval\";\nimport log from \"app/server/lib/log\";\n\nimport pidusage from \"pidusage\";\n\n/**\n * Parameters related to throttling.\n */\nexport interface ThrottleTiming {\n  dutyCyclePositiveMs: number;        // when throttling, how much uninterrupted time to give\n  // the process before pausing it.  The length of the\n  // non-positive cycle is chosen to achieve the desired\n  // cpu usage.\n  samplePeriodMs: number;             // how often to sample cpu usage and update throttling\n  targetAveragingPeriodMs: number;    // (rough) time span to average cpu usage over.\n  minimumAveragingPeriodMs: number;   // minimum time span before throttling is considered.\n  // No throttling will occur before a process has run\n  // for at least this length of time.\n  minimumLogPeriodMs: number;         // minimum time between log messages about throttling.\n  targetRate: number;                 // when throttling, aim for this fraction of cpu usage\n  // per unit time.\n  maxThrottle: number;                // maximum ratio of negative duty cycle phases to\n  // positive.\n  traceNudgeOffset: number;           // milliseconds to wait before sending a second signal\n  // to a traced process.\n}\n\n/**\n * Some parameters that seem reasonable defaults.\n */\nconst defaultThrottleTiming: ThrottleTiming = {\n  dutyCyclePositiveMs: 50,\n  samplePeriodMs: 1000,\n  targetAveragingPeriodMs: 20000,\n  minimumAveragingPeriodMs: 6000,\n  minimumLogPeriodMs: 10000,\n  targetRate: 0.25,\n  maxThrottle: 10,\n  traceNudgeOffset: 5,  // unlikely to be honored very precisely, but doesn't need to be.\n};\n\n/**\n * A sample of cpu usage.\n */\ninterface MeterSample {\n  time: number;           // time at which sample was made (as reported by Date.now())\n  cpuDuration: number;    // accumulated \"ctime\" measured by pidusage\n  offDuration: number;    // accumulated clock time for which process was paused (approximately)\n}\n\n/**\n * A throttling implementation for a process.  Supply a pid, and it will try to keep that\n * process from consuming too much cpu until stop() is called.\n */\nexport class Throttle {\n  private _timing: ThrottleTiming =\n    this._options.timing || defaultThrottleTiming;         // overall timing parameters\n\n  private _dutyCycleTimeout: NodeJS.Timeout | undefined;   // driver for throttle duty cycle\n  private _traceNudgeTimeout: NodeJS.Timeout | undefined;  // schedule a nudge to a traced process\n  private _throttleFactor: number = 0;                     // relative length of paused phase\n  private _sample: MeterSample | undefined;                // latest measurement.\n  private _anchor: MeterSample | undefined;                // sample from past for averaging\n  private _nextAnchor: MeterSample | undefined;            // upcoming replacement for _anchor\n  private _lastLogTime: number | undefined;                // time of last throttle log message\n  private _offDuration: number = 0;                        // cumulative time spent paused\n  private _stopped: boolean = false;                       // set when stop has been called\n  private _active: boolean = true;                         // set when we are not trying to pause process\n\n  // Interval for CPU measurements.\n  private _meteringInterval: Interval = new Interval(\n    () => this._update(),\n    { delayMs: this._timing.samplePeriodMs },\n    { onError: e => this._log(`Throttle error: ${e}`, this._options.logMeta) },\n  );\n\n  /**\n   * Start monitoring the given process and throttle as needed.\n   * If readPid is set, CPU usage will be read for that process.\n   * If tracedPid is set, then that process will be sent a STOP signal\n   * whenever the main process is sent a STOP, and then another STOP\n   * signal will be sent again shortly after.\n   *\n   * The tracedPid wrinkle is to deal with gvisor on a ptrace platform.\n   * From `man ptrace`:\n   *\n   * \"While being traced, the tracee will stop each time a signal is\n   * delivered, even if the signal is being ignored.  (An exception is\n   * SIGKILL, which has its usual effect.)  The tracer will be\n   * notified at its next call to waitpid(2) (or one of the related\n   * \"wait\" system calls); that call will return a status value\n   * containing information that indicates the cause of the stop in\n   * the tracee.  While the tracee is stopped, the tracer can use\n   * various ptrace requests to inspect and modify the tracee.  The\n   * tracer then causes the tracee to continue, optionally ignoring\n   * the delivered signal (or even delivering a different signal\n   * instead).\"\n   *\n   * So what sending a STOP to a process being traced by gvisor will\n   * do is not obvious. In practice it appears to have no effect\n   * (other than presumably giving gvisor a change to examine it).\n   * So for gvisor, we send a STOP to the tracing process, and a STOP\n   * to the tracee, and then a little later a STOP to the tracee again\n   * (since there's no particular guarantee about order of signal\n   * delivery). This isn't particularly elegant, but in tests, this\n   * seems to do the job, while sending STOP to any one process does\n   * not.\n   *\n   * Alternatively, gvisor runsc does have \"pause\" and \"resume\"\n   * commands that could be looked into more.\n   *\n   */\n  constructor(private readonly _options: {\n    pid: number,          // main pid to stop/continue\n    readPid?: number,     // pid to read cpu usage of, if different to main\n    tracedPid?: number,   // pid of a traced process to signal\n    logMeta: log.ILogMeta,\n    timing?: ThrottleTiming\n  }) {\n    this._meteringInterval.enable();\n  }\n\n  /**\n   * Stop all activity.\n   */\n  public stop() {\n    this._stopped = true;\n    this._stopMetering();\n    this._stopTraceNudge();\n    this._stopThrottling();\n  }\n\n  /**\n   * Read the last cpu usage sample made, for test purposes.\n   */\n  public get testStats(): MeterSample | undefined {\n    return this._sample;\n  }\n\n  /**\n   * Measure cpu usage and update whether and how much we are throttling the process.\n   */\n  private async _update() {\n    // Measure cpu usage to date.\n    let cpuDuration: number;\n    try {\n      cpuDuration = (await pidusage(this._options.readPid || this._options.pid)).ctime;\n    } catch (e) {\n      // process may have disappeared.\n      this._log(`Throttle measurement error: ${e}`, this._options.logMeta);\n      return;\n    }\n    const now = Date.now();\n    const current: MeterSample = { time: now, cpuDuration, offDuration: this._offDuration };\n    this._sample = current;\n\n    // Measuring cpu usage was an async operation, so check that we haven't been stopped\n    // in the meantime.  Otherwise we could sneak in and restart a throttle duty cycle.\n    if (this._stopped) { return; }\n\n    // We keep a reference point in the past called the \"anchor\".  Whenever the anchor\n    // becomes sufficiently old, we replace it with something newer.\n    if (!this._anchor) { this._anchor = current; }\n    if (this._nextAnchor && now - this._anchor.time > this._timing.targetAveragingPeriodMs * 2) {\n      this._anchor = this._nextAnchor;\n      this._nextAnchor = undefined;\n    }\n    // Keep a replacement for the current anchor in mind.\n    if (!this._nextAnchor && now - this._anchor.time > this._timing.targetAveragingPeriodMs) {\n      this._nextAnchor = current;\n    }\n    // Check if the anchor is sufficiently old for averages to be meaningful enough\n    // to support throttling.\n    const dt = current.time - this._anchor.time;\n    if (dt < this._timing.minimumAveragingPeriodMs) { return; }\n\n    // Calculate the average cpu use per second since the anchor.\n    const rate = (current.cpuDuration - this._anchor.cpuDuration) / dt;\n\n    // If that rate is less than our target rate, don't bother throttling.\n    const targetRate = this._timing.targetRate;\n    if (rate <= targetRate) {\n      this._updateThrottle(0);\n      return;\n    }\n\n    // Calculate how much time the sandbox was paused since the anchor.  This is\n    // approximate, since we don't line up duty cycles with this update function,\n    // but it should be good enough for throttling purposes.\n    const off = current.offDuration - this._anchor.offDuration;\n    // If the sandbox was never allowed to run, wait a bit longer for a duty cycle to complete.\n    // This should never happen unless time constants are set too tight relative to the\n    // maximum length of duty cycle.\n    const on = dt - off;\n    if (on <= 0) { return; }\n\n    // Calculate the average cpu use per second while the sandbox is unpaused.\n    const rateWithoutThrottling = (current.cpuDuration - this._anchor.cpuDuration) / on;\n\n    // Now pick a throttle level such that, if the sandbox continues using cpu\n    // at rateWithoutThrottling when it is unpaused, the overall rate matches\n    // the targetRate.\n    //   one duty cycle lasts: quantum * (1 + throttleFactor)\n    //      (positive cycle lasts 1 quantum; non-positive cycle duration is that of\n    //       positive cycle scaled by throttleFactor)\n    //   cpu use for this cycle is: quantum * rateWithoutThrottling\n    //   cpu use per second is therefore: rateWithoutThrottling / (1 + throttleFactor)\n    //   so: throttleFactor = (rateWithoutThrottling / targetRate) - 1\n    const throttleFactor = rateWithoutThrottling / targetRate - 1;\n\n    // Apply the throttle.  Place a cap on it so the duty cycle does not get too long.\n    // This cap means that low targetRates could be unobtainable.\n    this._updateThrottle(Math.min(throttleFactor, this._timing.maxThrottle));\n\n    if (!this._lastLogTime || now - this._lastLogTime > this._timing.minimumLogPeriodMs) {\n      this._lastLogTime = now;\n      this._log(\"throttle\", { ...this._options.logMeta,\n        throttle: Math.round(this._throttleFactor),\n        throttledRate: Math.round(rate * 100),\n        rate: Math.round(rateWithoutThrottling * 100) });\n    }\n  }\n\n  /**\n   * Start/stop the throttling duty cycle as necessary.\n   */\n  private _updateThrottle(factor: number) {\n    // For small factors, let the process run continuously.\n    if (factor < 0.001) {\n      if (this._dutyCycleTimeout) { this._stopThrottling(); }\n      this._throttleFactor = 0;\n      return;\n    }\n    // Set the throttle factor to apply and make sure the duty cycle is running.\n    this._throttleFactor = factor;\n    if (!this._dutyCycleTimeout) { this._throttle(true); }\n  }\n\n  /**\n   * Send CONTinue or STOP signal to process.\n   */\n  private _letProcessRun(on: boolean) {\n    this._active = on;\n    try {\n      process.kill(this._options.pid, on ? \"SIGCONT\" : \"SIGSTOP\");\n      const tracedPid = this._options.tracedPid;\n      if (tracedPid && !on) {\n        process.kill(tracedPid, \"SIGSTOP\");\n        if (this._timing.traceNudgeOffset > 0) {\n          this._stopTraceNudge();\n          this._traceNudgeTimeout = setTimeout(() => {\n            if (!this._active) { process.kill(tracedPid, \"SIGSTOP\"); }\n          }, this._timing.traceNudgeOffset);\n        }\n      }\n    } catch (e) {\n      // process may have disappeared\n      this._log(`Throttle error: ${e}`, this._options.logMeta);\n    }\n  }\n\n  /**\n   * Send CONTinue or STOP signal to process, and schedule next step\n   * in duty cycle.\n   */\n  private _throttle(on: boolean) {\n    this._letProcessRun(on);\n    const dt = this._timing.dutyCyclePositiveMs * (on ? 1.0 : this._throttleFactor);\n    if (!on) { this._offDuration += dt; }\n    this._dutyCycleTimeout = setTimeout(() => this._throttle(!on), dt);\n  }\n\n  /**\n   * Make sure measurement of cpu is stopped.\n   */\n  private _stopMetering() {\n    this._meteringInterval.disable();\n  }\n\n  private _stopTraceNudge() {\n    if (this._traceNudgeTimeout) {\n      clearTimeout(this._traceNudgeTimeout);\n      this._traceNudgeTimeout = undefined;\n    }\n  }\n\n  /**\n   * Make sure duty cycle is stopped and process is left in running state.\n   */\n  private _stopThrottling() {\n    if (this._dutyCycleTimeout) {\n      clearTimeout(this._dutyCycleTimeout);\n      this._dutyCycleTimeout = undefined;\n      this._letProcessRun(true);\n    }\n  }\n\n  private _log(msg: string, meta: log.ILogMeta) {\n    log.rawDebug(msg, meta);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/TimeQuery.ts",
    "content": "import { ITimeData } from \"app/common/TimeQuery\";\nimport { ISQLiteDB, quoteIdent, ResultRow } from \"app/server/lib/SQLiteDB\";\n\nexport class SQLiteTimeData implements ITimeData {\n  public constructor(public db: ISQLiteDB) {\n  }\n\n  public async getColIds(tableId: string): Promise<string[]> {\n    const columns = await this.db.all(`PRAGMA table_info(${quoteIdent(tableId)})`);\n    return columns.map(column => column.name);\n  }\n\n  public async fetch(tableId: string, colIds: string[], rowIds?: number[]): Promise<ResultRow[]> {\n    if (rowIds) { throw new Error(\"not yet\"); }\n    return this.db.all(\n      `select ${colIds.map(quoteIdent).join(\",\")} from ${quoteIdent(tableId)}`);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/Triggers.ts",
    "content": "import { LocalActionBundle } from \"app/common/ActionBundle\";\nimport { summarizeAction } from \"app/common/ActionSummarizer\";\nimport { ActionSummary, TableDelta } from \"app/common/ActionSummary\";\nimport { ApiError } from \"app/common/ApiError\";\nimport {\n  fromTableDataAction,\n  getRowFromBulkColValues,\n  RowRecord,\n  TableColValues,\n  TableDataAction,\n} from \"app/common/DocActions\";\nimport { getSetMapValue, safeJsonParse } from \"app/common/gutil\";\nimport { CompiledPredicateFormula, compilePredicateFormula, ParsedPredicateFormula } from \"app/common/PredicateFormula\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { MetaRowRecord } from \"app/common/TableData\";\nimport { CellDelta } from \"app/common/TabularDiff\";\nimport { TriggerAction } from \"app/common/Triggers\";\nimport TriggersTI from \"app/common/Triggers-ti\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport log from \"app/server/lib/log\";\nimport { matchesBaseDomain } from \"app/server/lib/requestUtils\";\nimport { type ActionPayload, type ActionQueue } from \"app/server/lib/WebhookQueue\";\n\nimport { promisifyAll } from \"bluebird\";\nimport * as _ from \"lodash\";\nimport { RedisClient } from \"redis\";\nimport * as t from \"ts-interface-checker\";\n\n// Schema validators for api endpoints that creates or updates records.\nconst {\n  TriggerAction: TriggerActionTI,\n} = t.createCheckers(TriggersTI);\n\npromisifyAll(RedisClient.prototype);\n\n// Only owners can manage triggers, but any user's activity can trigger them\n// and the corresponding actions get the full values\nconst docSession = makeExceptionalDocSession(\"system\");\n\n// Describes the change in existence to a record, which determines the event type\ninterface RecordDelta {\n  existedBefore: boolean;\n  existedAfter: boolean;\n}\n\ntype RecordDeltas = Map<number, RecordDelta>;\n\nexport interface TriggerCondition {\n  text: string;\n  parsed: ParsedPredicateFormula;\n}\n\nexport const allowedEventTypes = StringUnion(\"add\", \"update\");\n\ntype EventType = typeof allowedEventTypes.type;\n\ntype Trigger = MetaRowRecord<\"_grist_Triggers\">;\n\n// Work to do after fetching values from the document\ninterface Task {\n  /**\n   * Calculated delta for each table modified in the action bundle.\n   */\n  tableDelta: TableDelta;\n  /**\n   * List of triggers attached to this table from the document (raw entries from _grist_Triggers).\n   */\n  triggers: Trigger[];\n  /**\n   * Promises that will resolve with data for all records modified in the table.\n   */\n  tableDataAction: Promise<TableDataAction>;\n  /**\n   * Information for each record in this table describing if the record was just added or updated.\n   */\n  recordDeltas: RecordDeltas;\n}\n\n// Processes triggers for records changed as described in action bundles, initiating webhooks.\n// The interesting stuff starts in the handle() method.\n// This class identifies which triggers should fire based on document changes,\n// then delegates actual webhook queue management and HTTP delivery to WebhookQueue.\n// Triggers are configured in the document, while details of webhooks (URLs) are kept\n// in the Secrets table of the Home DB.\nexport class DocTriggers {\n  // Cache for compiled trigger expressions, keyed by the expression text.\n  private _conditionCache = new Map<string, CompiledPredicateFormula | null>();\n\n  constructor(\n    private _activeDoc: ActiveDoc,\n    private _jobQueue: ActionQueue,\n  ) {}\n\n  /**\n   * Clears the cache for compiled trigger expressions.\n   */\n  public clearCache(): void {\n    this._conditionCache.clear();\n  }\n\n  // Called after applying actions to a document and updating its data.\n  // Checks for triggers configured in a meta table,\n  // and whether any of those triggers monitor tables which were modified by the actions\n  // in the given bundle.\n  // If so, generates events which are pushed to the local and redis queues.\n  //\n  // Returns an ActionSummary generated from the given LocalActionBundle.\n  //\n  // Generating the summary here makes it easy to specify which columns need to\n  // have all their changes included in the summary without truncation\n  // so that we can accurately identify which records are ready for sending.\n  public async handle(localActionBundle: LocalActionBundle): Promise<ActionSummary> {\n    const docData = this._activeDoc.docData;\n    if (!docData) {\n      return summarizeAction(localActionBundle);\n    }  // Happens on doc creation while processing InitNewDoc action.\n\n    const triggersTable = docData.getMetaTable(\"_grist_Triggers\");\n    const getTableId = docData.getMetaTable(\"_grist_Tables\").getRowPropFunc(\"tableId\");\n\n    const triggersByTableRef = _.groupBy(triggersTable.getRecords().filter(t => t.enabled), \"tableRef\");\n    const triggersByTableId: [string, Trigger[]][] = [];\n\n    // First we need a list of columns which must be included in full in the action summary\n    const isReadyColIds: string[] = [];\n    let hasWatchedCols = false;\n    for (const tableRef of Object.keys(triggersByTableRef).sort()) {\n      const triggers = triggersByTableRef[tableRef];\n      const tableId = getTableId(Number(tableRef));  // groupBy makes tableRef a string\n      triggersByTableId.push([tableId, triggers]);\n      for (const trigger of triggers) {\n        if (trigger.isReadyColRef) {\n          const colId = this._getColId(trigger.isReadyColRef);\n          if (colId) {\n            isReadyColIds.push(colId);\n          }\n        }\n        if (trigger.watchedColRefList) {\n          hasWatchedCols = true;\n        }\n      }\n    }\n\n    const summary = summarizeAction(localActionBundle, {\n      // Unset the default limit (10) for row deltas if there are any watched\n      // columns; full row deltas are needed to determine which columns were\n      // modified.\n      // TODO: find a better solution (maybe a field like `updateColumns`\n      // in the summary, containing only the IDs of modified columns).\n      maximumInlineRows: hasWatchedCols ? null : undefined,\n      alwaysPreserveColIds: isReadyColIds,\n    });\n\n    // Work to do after fetching values from the document\n    const tasks: Task[] = [];\n\n    // For each table in the document which is monitored by one or more triggers...\n    for (const [tableId, triggers] of triggersByTableId) {\n      const tableDelta = summary.tableDeltas[tableId];\n      // ...if the monitored table was modified by the summarized actions,\n      // fetch the modified/created records and note the work that needs to be done.\n      if (tableDelta) {\n        const recordDeltas = this._getRecordDeltas(tableDelta);\n        const filters = { id: [...recordDeltas.keys()] };\n\n        // Fetch the modified records in full so they can be sent in webhooks\n        // They will also be used to check if the record is ready\n        const tableDataAction = this._activeDoc.fetchQuery(docSession, { tableId, filters }, true)\n          .then(tableFetchResult => tableFetchResult.tableData);\n        tasks.push({ tableDelta, triggers, tableDataAction, recordDeltas });\n      }\n    }\n\n    // Fetch values from document DB in parallel\n    await Promise.all(tasks.map(t => t.tableDataAction));\n\n    const events: ActionPayload[] = [];\n    for (const task of tasks) {\n      events.push(...this._handleTask(task, await task.tableDataAction));\n    }\n\n    await this.enqueue(events);\n\n    return summary;\n  }\n\n  // public for tests\n  public async enqueue(events: ActionPayload[]) {\n    if (!events.length) {\n      return;\n    }\n    this._log(\"Total number of webhook events generated by bundle\", { numEvents: events.length });\n    await this._jobQueue.enqueue(events);\n  }\n\n  // Converts a table to tableId by looking it up in _grist_Tables.\n  private _getTableId(rowId: number) {\n    const docData = this._activeDoc.docData;\n    if (!docData) {\n      throw new Error(\"ActiveDoc not ready\");\n    }\n    return docData.getMetaTable(\"_grist_Tables\").getValue(rowId, \"tableId\");\n  }\n\n  // Return false if colRef does not belong to tableRef\n  private _validateColId(colRef: number, tableRef: number) {\n    const docData = this._activeDoc.docData;\n    if (!docData) {\n      throw new Error(\"ActiveDoc not ready\");\n    }\n    return docData.getMetaTable(\"_grist_Tables_column\").getValue(colRef, \"parentId\") === tableRef;\n  }\n\n  // Converts a column ref to colId by looking it up in _grist_Tables_column. If tableRef is\n  // provided, check whether col belongs to table and throws if not.\n  private _getColId(rowId: number, tableRef?: number) {\n    const docData = this._activeDoc.docData;\n    if (!docData) {\n      throw new Error(\"ActiveDoc not ready\");\n    }\n    if (!rowId) { return \"\"; }\n    const colId = docData.getMetaTable(\"_grist_Tables_column\").getValue(rowId, \"colId\");\n    if (tableRef !== undefined &&\n      docData.getMetaTable(\"_grist_Tables_column\").getValue(rowId, \"parentId\") !== tableRef) {\n      throw new ApiError(`Column ${colId} does not belong to table ${this._getTableId(tableRef)}`, 400);\n    }\n    return colId;\n  }\n\n  private _log(msg: string, { level = \"info\", ...meta }: any = {}) {\n    log.origLog(level, \"WebhookQueue: \" + msg, {\n      ...meta,\n      docId: this._activeDoc.docName,\n    });\n  }\n\n  private _getRecordDeltas(tableDelta: TableDelta): RecordDeltas {\n    const recordDeltas = new Map<number, RecordDelta>();\n    tableDelta.updateRows.forEach(id =>\n      recordDeltas.set(id, { existedBefore: true, existedAfter: true }));\n    // A row ID can appear in both updateRows and addRows, although it probably shouldn't\n    // Added row IDs override updated rows because they didn't exist before\n    tableDelta.addRows.forEach(id =>\n      recordDeltas.set(id, { existedBefore: false, existedAfter: true }));\n\n    // If we allow subscribing to deletion in the future\n    // delta.removeRows.forEach(id =>\n    //   recordDeltas.set(id, {existedBefore: true, existedAfter: false}));\n\n    return recordDeltas;\n  }\n\n  private _handleTask(\n    { tableDelta, triggers, recordDeltas }: Task,\n    tableDataAction: TableDataAction,\n  ) {\n    const bulkColValues = fromTableDataAction(tableDataAction);\n\n    const meta = { numTriggers: triggers.length, numRecords: bulkColValues.id.length };\n    this._log(`Processing triggers`, meta);\n\n    const makePayload = _.memoize((rowIndex: number) =>\n      _.mapValues(bulkColValues, col => col[rowIndex]) as RowRecord,\n    );\n\n    const result: ActionPayload[] = [];\n    for (const trigger of triggers) {\n      const actions = JSON.parse(trigger.actions) as TriggerAction[];\n      if (!actions.length) {\n        continue;\n      }\n\n      // Validate trigger actions and skip trigger if any action is invalid, while logging stats about it.\n      actions.forEach(action => TriggerActionTI.check(action));\n\n      if (trigger.isReadyColRef) {\n        if (!this._validateColId(trigger.isReadyColRef, trigger.tableRef)) {\n          // ready column does not belong to table, let's ignore trigger and log stats\n          for (const action of actions) {\n            const colId = this._getColId(trigger.isReadyColRef); // no validation\n            const tableId = this._getTableId(trigger.tableRef);\n            const error = `isReadyColumn is not valid: colId ${colId} does not belong to ${tableId}`;\n            this._log(error, { level: \"warn\", actionId: action.id, triggerId: trigger.id });\n          }\n          continue;\n        }\n      }\n\n      if (trigger.watchedColRefList) {\n        for (const colRef of trigger.watchedColRefList.slice(1)) {\n          if (!this._validateColId(colRef as number, trigger.tableRef)) {\n            // column does not belong to table, let's ignore trigger and log stats\n            for (const action of actions) {\n              const colId = this._getColId(colRef as number); // no validation\n              const tableId = this._getTableId(trigger.tableRef);\n              const error = `column is not valid: colId ${colId} does not belong to ${tableId}`;\n              this._log(error, { level: \"warn\", actionId: action.id, triggerId: trigger.id });\n            }\n            continue;\n          }\n        }\n      }\n\n      // TODO: would be worth checking that the trigger's fields are valid (ie: eventTypes, url,\n      // ...) as there's no guarantee that they are.\n\n      const rowIndexesToSend: number[] = _.range(bulkColValues.id.length).filter((rowIndex) => {\n        const rowId = bulkColValues.id[rowIndex];\n        return this._shouldTriggerActions(\n          trigger, bulkColValues, rowIndex, rowId, recordDeltas.get(rowId)!, tableDelta,\n        );\n      },\n      );\n\n      for (const action of actions) {\n        for (const rowIndex of rowIndexesToSend) {\n          const event = { id: action.id, action, payload: makePayload(rowIndex) };\n          result.push(event);\n        }\n      }\n    }\n\n    this._log(\"Generated events from triggers\", { numEvents: result.length, ...meta });\n\n    return result;\n  }\n\n  /**\n   * Determines if actions should be triggered for a single record and trigger.\n   */\n  private _shouldTriggerActions(\n    trigger: Trigger,\n    bulkColValues: TableColValues,\n    rowIndex: number,\n    rowId: number,\n    recordDelta: RecordDelta,\n    tableDelta: TableDelta,\n  ): boolean {\n    let readyBefore: boolean;\n    if (!trigger.isReadyColRef) {\n      // User hasn't configured a column, so all records are considered ready immediately\n      readyBefore = recordDelta.existedBefore;\n    } else {\n      const isReadyColId = this._getColId(trigger.isReadyColRef)!;\n\n      // Must be the actual boolean `true`, not just anything truthy\n      const isReady = bulkColValues[isReadyColId][rowIndex] === true;\n      if (!isReady) {\n        return false;\n      }\n\n      const cellDelta: CellDelta | undefined = tableDelta.columnDeltas[isReadyColId]?.[rowId];\n      if (!recordDelta.existedBefore) {\n        readyBefore = false;\n      } else if (!cellDelta) {\n        // Cell wasn't changed, and the record is ready now, so it was ready before.\n        // This requires that the ActionSummary contains all changes to the isReady column.\n        readyBefore = true;\n      } else {\n        const deltaBefore = cellDelta[0];\n        if (deltaBefore === null) {\n          // The record didn't exist before, so it definitely wasn't ready\n          // (although we probably shouldn't reach this since we already checked recordDelta.existedBefore)\n          readyBefore = false;\n        } else if (deltaBefore === \"?\") {\n          // The ActionSummary shouldn't contain this kind of delta at all\n          // since it comes from a single action bundle, not a combination of summaries.\n          this._log('Unexpected deltaBefore === \"?\"', { level: \"warn\", trigger });\n          readyBefore = true;\n        } else {\n          // Only remaining case is that deltaBefore is a single-element array containing the previous value.\n          const [valueBefore] = deltaBefore;\n\n          // Must be the actual boolean `true`, not just anything truthy\n          readyBefore = valueBefore === true;\n        }\n      }\n    }\n\n    const colIdsToCheck: string[] = [];\n    if (trigger.watchedColRefList) {\n      for (const colRef of trigger.watchedColRefList.slice(1)) {\n        colIdsToCheck.push(this._getColId(colRef as number)!);\n      }\n    }\n\n    let eventType: EventType;\n    if (readyBefore) {\n      // check if any of the columns to check were changed to consider this an update\n      if (colIdsToCheck.length === 0 || colIdsToCheck.some(colId => tableDelta.columnDeltas[colId]?.[rowId])) {\n        eventType = \"update\";\n      } else {\n        return false;\n      }\n      // If we allow subscribing to deletion in the future\n      // if (recordDelta.existedAfter) {\n      //   eventType = \"update\";\n      // } else {\n      //   eventType = \"remove\";\n      // }\n    } else {\n      eventType = \"add\";\n    }\n\n    // If we are interested in event types make sure this is one of them\n    if (trigger.eventTypes && !trigger.eventTypes.includes(eventType)) {\n      return false;\n    }\n\n    // Check custom expression if present\n    const compiledFormula = getSetMapValue(\n      this._conditionCache,\n      trigger.condition,\n      () => this._compileFormula(trigger),\n    );\n    if (compiledFormula) {\n      try {\n        // Compile and evaluate the expression using 'trigger' variant\n        return Boolean(compiledFormula(triggerFormulaContext(\n          { bulkColValues, rowIndex, rowId, recordDelta, tableDelta },\n        )));\n      } catch (e) {\n        this._log(`Error evaluating trigger expression: ${e}`, { level: \"warn\", trigger: trigger.id });\n        // On error, don't trigger (safer default)\n        return false;\n      }\n    }\n\n    return true;\n  }\n\n  private _compileFormula(trigger: Trigger) {\n    const condition: Partial<TriggerCondition> = safeJsonParse(trigger.condition as string, {});\n    const parsed = condition.parsed;\n    if (parsed) {\n      try {\n        // Compile and evaluate the expression using 'trigger' variant\n        const compiledFormula = compilePredicateFormula(\n          parsed,\n          { variant: \"trigger\" },\n        );\n        return compiledFormula;\n      } catch (e) {\n        this._log(`Error evaluating trigger expression: ${e}`, { level: \"warn\", trigger: trigger.id });\n        // On error, don't trigger (safer default)\n        return null;\n      }\n    }\n  }\n}\n\nexport function isUrlAllowed(urlString: string) {\n  let url: URL;\n  try {\n    url = new URL(urlString);\n  } catch (e) {\n    return false;\n  }\n\n  // Support at most https and http.\n  if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n    return false;\n  }\n\n  // Support a wildcard that allows all domains.\n  // Allow either https or http if it is set.\n  if (process.env.ALLOWED_WEBHOOK_DOMAINS === \"*\") {\n    return true;\n  }\n\n  // http (no s) is only allowed for localhost for testing.\n  // localhost still needs to be explicitly permitted, and it shouldn't be outside dev\n  if (url.protocol !== \"https:\" && url.hostname !== \"localhost\") {\n    return false;\n  }\n\n  return (process.env.ALLOWED_WEBHOOK_DOMAINS || \"\").split(\",\").some(domain =>\n    domain && matchesBaseDomain(url.host, domain),\n  );\n}\n\n/**\n * Builds the context object for evaluating trigger formulas.\n */\nfunction triggerFormulaContext(props: {\n  bulkColValues: TableColValues;\n  rowIndex: number;\n  rowId: number;\n  recordDelta: RecordDelta;\n  tableDelta: TableDelta;\n}) {\n  const { bulkColValues, rowIndex, rowId, recordDelta, tableDelta } = props;\n\n  // Build the current record from bulkColValues\n  const rec: RowRecord = rowRecordFromTableColValues({ bulkColValues, rowIndex, rowId });\n\n  // Build rec (an old version) from the cell deltas\n  const oldRec: RowRecord = { ...rec, ...rowRecordFromCellDeltas(tableDelta, rowId) };\n\n  // If record didn't exist before, oldRec should have empty values.\n  // We use an empty record (with id = 0 and null field values) rather than setting oldRec itself\n  // to null for several reasons:\n  // 1. It aligns with our data model where all rows exist but can be empty\n  //    (but differs a bit from what ACL does, where each column has a default \"empty\" value)\n  // 2. It allows expressions like oldRec.Col to work without null checks or throwing errors\n  // 3. Users can check if a record existed before with: oldRec.id == 0\n  // 4. It's more intuitive for users who may not be familiar with Python\n  // 5. It allows as in the future to still use expression like `not oldRec` by treating empty record\n  //    as a class that overrides __bool__ to return False, but still allows field access without errors.\n  if (!recordDelta.existedBefore) {\n    for (const colId of Object.keys(oldRec)) {\n      oldRec[colId] = null;\n    }\n    oldRec.id = 0;\n  }\n\n  return { rec, oldRec };\n}\n\n/**\n * Builds a RowRecord for a specific row from bulk column values.\n */\nfunction rowRecordFromTableColValues(props: {\n  bulkColValues: TableColValues;\n  rowIndex: number;\n  rowId: number;\n}): RowRecord {\n  const { bulkColValues, rowIndex, rowId } = props;\n  return { ...getRowFromBulkColValues(bulkColValues, rowIndex), id: rowId };\n}\n\n/**\n * Builds a RowRecord for a specific row from cell deltas.\n */\nfunction rowRecordFromCellDeltas(tableDelta: TableDelta, rowId: number): RowRecord {\n  const oldRec: RowRecord = { id: rowId };\n  for (const [colId, colDelta] of Object.entries(tableDelta.columnDeltas)) {\n    const cellDelta = colDelta[rowId];\n    if (cellDelta) {\n      const deltaBefore = cellDelta[0];\n      if (deltaBefore !== null && deltaBefore !== \"?\" && Array.isArray(deltaBefore)) {\n        oldRec[colId] = deltaBefore[0];\n      }\n    }\n  }\n  return oldRec;\n}\n"
  },
  {
    "path": "app/server/lib/UnsafeNodeComponent.ts",
    "content": "import { ActionRouter } from \"app/common/ActionRouter\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { BaseComponent, createRpcLogger, warnIfNotReady } from \"app/common/PluginInstance\";\nimport { GristAPI, RPC_GRISTAPI_INTERFACE } from \"app/plugin/GristAPI\";\nimport log from \"app/server/lib/log\";\nimport { getAppPathTo } from \"app/server/lib/places\";\nimport { makeLinePrefixer } from \"app/server/lib/sandboxUtil\";\nimport { exitPromise, timeoutReached } from \"app/server/lib/serverUtils\";\n\nimport { ChildProcess, fork, ForkOptions } from \"child_process\";\nimport * as path from \"path\";\n\nimport * as fse from \"fs-extra\";\nimport { IMessage, IMsgCustom, IMsgRpcCall, Rpc } from \"grain-rpc\";\n\n// Error for not yet implemented api.\nclass NotImplemented extends Error {\n  constructor(name: string) {\n    super(`calling ${name} from UnsafeNode is not yet implemented`);\n  }\n}\n\n/**\n * The unsafeNode component used by a PluginInstance.\n *\n */\nexport class UnsafeNodeComponent extends BaseComponent {\n  private _child?: ChildProcess;   /* plugin node code will run as separate process */\n  private _exited: Promise<void>;  /* fulfulled when process has completed */\n  private _rpc: Rpc;\n  private _pluginPath: string;\n  private _pluginId: string;\n  private _actionRouter: ActionRouter;\n\n  private _gristAPI: GristAPI = {\n    render() { throw new NotImplemented(\"render\"); },\n    dispose() { throw new NotImplemented(\"dispose\"); },\n    subscribe: (tableId: string) => this._actionRouter.subscribeTable(tableId),\n    unsubscribe: (tableId: string) => this._actionRouter.unsubscribeTable(tableId),\n  };\n\n  /**\n   *\n   * @arg parent: the plugin instance this component is part of\n   * @arg _mainPath: main script file to run\n   * @arg appRoot: root path for application (important for setting a good NODE_PATH)\n   * @arg _gristDocPath: path to the current Grist doc (to which this plugin applies).\n   *\n   */\n  constructor(plugin: LocalPlugin, pluginRpc: Rpc, private _mainPath: string, public appRoot: string,\n    private _gristDocPath: string,\n    rpcLogger = createRpcLogger(log, `PLUGIN ${plugin.id}/${_mainPath} UnsafeNode:`)) {\n    super(plugin.manifest, rpcLogger);\n    this._pluginPath = plugin.path;\n    this._pluginId = plugin.id;\n    this._rpc = new Rpc({\n      sendMessage: msg => this.sendMessage(msg),\n      logger: rpcLogger,\n    });\n    this._rpc.registerForwarder(\"*\", pluginRpc);\n    this._rpc.registerImpl<GristAPI>(RPC_GRISTAPI_INTERFACE, this._gristAPI);\n    this._actionRouter = new ActionRouter(this._rpc);\n  }\n\n  public async sendMessage(data: IMessage): Promise<void> {\n    if (!this._child) {\n      await this.activateImplementation();\n    }\n    this._child!.send(data);\n    return Promise.resolve();\n  }\n\n  public receiveAction(action: any[]) {\n    this._actionRouter.process(action)\n      .catch((err: any) => log.warn(\"unsafeNode[%s] receiveAction failed with %s\",\n        this._child ? this._child.pid : \"NULL\", err));\n  }\n\n  /**\n   *\n   * Create the child node process needed for this component.\n   *\n   */\n  protected async activateImplementation(): Promise<void> {\n    log.info(`unsafeNode operating in ${this._pluginPath}`);\n    const base = this._pluginPath;\n    const script = path.resolve(base, this._mainPath);\n    await fse.access(script, fse.constants.R_OK);\n    // Time to set up the node search path the client will see.\n    // We take our own, via Module.globalPaths, a poorly documented\n    // method listing the search path for the active node program\n    // https://github.com/nodejs/node/blob/master/test/parallel/test-module-globalpaths-nodepath.js\n\n    // eslint-disable-next-line @typescript-eslint/no-require-imports\n    const paths = require(\"module\").globalPaths.slice().concat([\n      // add the path to the plugin itself\n      path.resolve(base),\n      // add the path to grist's public api\n      getAppPathTo(this.appRoot, \"public-api\"),\n      // add the path to the node_modules packaged with grist, in electron form\n      getAppPathTo(this.appRoot, \"node_modules\"),\n    ]);\n    const env = Object.assign({}, process.env, {\n      NODE_PATH: paths.join(path.delimiter),\n      GRIST_PLUGIN_PATH: `${this._pluginId}/${this._mainPath}`,\n      GRIST_DOC_PATH: this._gristDocPath,\n    });\n    const electronVersion: string = (process.versions as any).electron;\n    if (electronVersion) {\n      // Pass along the fact that we are running under an electron-ified node, for the purposes of\n      // finding binaries (sqlite3 in particular).\n      env.ELECTRON_VERSION = electronVersion;\n    }\n    const child = this._child = fork(script, [], {\n      env,\n      stdio: [\"ignore\", \"pipe\", \"pipe\", \"ipc\"],\n    } as ForkOptions);  // Explicit cast only because node-6 typings mistakenly omit stdio property\n\n    log.info(\"unsafeNode[%s] started %s\", child.pid, script);\n\n    // Important to use exitPromise() before events from child may be received, so don't call\n    // yield or await between fork and here.\n    this._exited = exitPromise(child)\n      .then(code => log.info(\"unsafeNode[%s] exited with %s\", child.pid, code))\n      .catch(err => log.warn(\"unsafeNode[%s] failed with %s\", child.pid, err))\n      .then(() => { this._child = undefined; });\n\n    child.stdout!.on(\"data\", makeLinePrefixer(\"PLUGIN stdout: \"));\n    child.stderr!.on(\"data\", makeLinePrefixer(\"PLUGIN stderr: \"));\n\n    warnIfNotReady(this._rpc, 3000, \"Plugin isn't ready; be sure to call grist.ready() from plugin\");\n    child.on(\"message\", this._rpc.receiveMessage.bind(this._rpc));\n  }\n\n  /**\n   *\n   * Remove the child node process needed for this component.\n   *\n   */\n  protected async deactivateImplementation(): Promise<void> {\n    if (!this._child) {\n      log.info(\"unsafeNode deactivating: no child process\");\n    } else {\n      log.info(\"unsafeNode[%s] deactivate: disconnecting child\", this._child.pid);\n      this._child.disconnect();\n      if (await timeoutReached(2000, this._exited)) {\n        log.info(\"unsafeNode[%s] deactivate: sending SIGTERM\", this._child.pid);\n        this._child.kill(\"SIGTERM\");\n      }\n      if (await timeoutReached(5000, this._exited)) {\n        log.warn(\"unsafeNode[%s] deactivate: child still has not exited\", this._child.pid);\n      } else {\n        log.info(\"unsafeNode deactivate: child exited\");\n      }\n    }\n  }\n\n  protected doForwardCall(c: IMsgRpcCall): Promise<any> {\n    return this._rpc.forwardCall({ ...c, mdest: \"\" });\n  }\n\n  protected async doForwardMessage(c: IMsgCustom): Promise<any> {\n    return this._rpc.forwardMessage({ ...c, mdest: \"\" });\n  }\n}\n"
  },
  {
    "path": "app/server/lib/UpdateManager.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { MapWithTTL } from \"app/common/AsyncCreate\";\nimport { GristDeploymentType } from \"app/common/gristUrls\";\nimport { naturalCompare } from \"app/common/SortFunc\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { optIntegerParam, optStringParam } from \"app/server/lib/requestUtils\";\n\nimport { rateLimit } from \"express-rate-limit\";\nimport { AbortController, AbortSignal } from \"node-abort-controller\";\nimport fetch from \"node-fetch\";\nimport * as semver from \"semver\";\n\nimport type * as express from \"express\";\n\n// URL to show to the client where the new version for docker based deployments can be found.\nconst DOCKER_IMAGE_SITE = \"https://hub.docker.com/r/gristlabs/grist\";\n\n// URL to show to the client where the new version for docker based deployments can be found.\nconst DOCKER_ENDPOINT = process.env.GRIST_TEST_UPDATE_DOCKER_HUB_URL ||\n  \"https://hub.docker.com/v2/namespaces/gristlabs/repositories/grist/tags\";\n// Timeout for the request to the external resource.\nconst REQUEST_TIMEOUT = optIntegerParam(process.env.GRIST_TEST_UPDATE_REQUEST_TIMEOUT, \"\") ?? 10000; // 10s\n// Delay between retries in case of rate limiting.\nconst RETRY_TIMEOUT = optIntegerParam(process.env.GRIST_TEST_UPDATE_RETRY_TIMEOUT, \"\") ?? 4000; // 4s\n// We cache the good result for an hour.\nconst GOOD_RESULT_TTL = optIntegerParam(process.env.GRIST_TEST_UPDATE_CHECK_TTL, \"\") ?? 60 * 60 * 1000; // 1h\n// We cache the bad result errors from external resources for a minute.\nconst BAD_RESULT_TTL = optIntegerParam(process.env.GRIST_TEST_UPDATE_ERROR_TTL, \"\") ?? 60 * 1000; // 1m\n\nconst OLDEST_RECOMMENDED_VERSION = process.env.GRIST_OLDEST_RECOMMENDED_VERSION;\n\n// A hook for tests to override the default values.\nexport const Deps = {\n  DOCKER_IMAGE_SITE,\n  DOCKER_ENDPOINT,\n  REQUEST_TIMEOUT,\n  RETRY_TIMEOUT,\n  GOOD_RESULT_TTL,\n  BAD_RESULT_TTL,\n  OLDEST_RECOMMENDED_VERSION,\n};\n\n/**\n * JSON returned to the client (exported for tests).\n */\nexport interface LatestVersion {\n  /**\n   * Latest version of core component of the client.\n   */\n  latestVersion: string;\n  /**\n   * If there were any critical updates after client's version. Undefined if\n   * we don't know client version or couldn't figure this out for some other reason.\n   */\n  isCritical?: boolean;\n  /**\n   * Url where the client can download the latest version (if applicable)\n   */\n  updateURL?: string;\n\n  /**\n   * When the latest version was updated (in ISO format).\n   */\n  updatedAt?: string;\n}\n\nexport class UpdateManager {\n  // Cache for the latest version of the client.\n  private _latestVersion: MapWithTTL<\n    GristDeploymentType,\n    // We cache the promise, so that we can wait for the first request.\n    // This promise will always resolves, but can be resolved with an error.\n    Promise<ApiError | LatestVersion>\n  >;\n\n  private _abortController = new AbortController();\n\n  public constructor(\n    private _app: express.Application,\n    private _server: GristServer,\n  ) {\n    this._latestVersion = new MapWithTTL<GristDeploymentType, Promise<ApiError | LatestVersion>>(Deps.GOOD_RESULT_TTL);\n  }\n\n  public addEndpoints() {\n    // Make sure that config is ok, so that we are not surprised when client asks as about that.\n    if (Deps.DOCKER_ENDPOINT) {\n      try {\n        new URL(Deps.DOCKER_ENDPOINT);\n      } catch (err) {\n        throw new Error(\n          `Invalid value for GRIST_UPDATE_DOCKER_URL, expected URL: ${Deps.DOCKER_ENDPOINT}`,\n        );\n      }\n    }\n\n    // Rate limit the requests to the version API, so that we don't get spammed.\n    // 30 requests per second, per IP. The requests are cached so, we should be fine, but make\n    // sure it doesn't get out of hand. On dev laptop I could go up to 600 requests per second.\n    // (30 was picked by hand, to not hit the limit during tests).\n    const limiter = rateLimit({\n      windowMs: 1000,\n      limit: 30,\n      legacyHeaders: true,\n    });\n\n    // Support both POST and GET requests.\n    this._app.use(\"/api/version\", limiter, expressWrap(async (req, res) => {\n      // Get some telemetry from the body request.\n      const payload = (name: string) => req.body?.[name] ?? req.query[name];\n\n      // This is the most interesting part for us, to track installation ids and match them\n      // with the version of the client.\n      const deploymentId = optStringParam(\n        payload(\"installationId\"),\n        \"installationId\",\n      );\n\n      // Deployment type of the client (we expect this to be 'core' for most of the cases).\n      const deploymentType = optStringParam(\n        payload(\"deploymentType\"),\n        \"deploymentType\",\n      ) as GristDeploymentType | undefined;\n\n      const currentVersion = optStringParam(\n        payload(\"currentVersion\"),\n        \"currentVersion\",\n      );\n\n      this._server\n        .getTelemetry()\n        .logEvent(req as RequestWithLogin, \"checkedUpdateAPI\", {\n          full: {\n            deploymentId,\n            deploymentType,\n            currentVersion,\n          },\n        });\n\n      // For now we will just check the latest tag of docker stable image, assuming\n      // that this is what the client wants. In the future we might have different\n      // implementation based on the client deployment type.\n      const deploymentToCheck = \"core\";\n      const versionChecker: VersionChecker = getLatestStableDockerVersion;\n\n      // To not spam the docker hub with requests, we will cache the good result for an hour.\n      // We are actually caching the promise, so subsequent requests will wait for the first one.\n      if (!this._latestVersion.has(deploymentToCheck)) {\n        const task = versionChecker(this._abortController.signal).catch(err => err);\n        this._latestVersion.set(deploymentToCheck, task);\n      }\n      const resData = await this._latestVersion.get(deploymentToCheck)!;\n      if (resData instanceof ApiError) {\n        // If the request has failed for any reason, we will throw the error to the client,\n        // but shorten the TTL to 1 minute, so that the next client will try after that time.\n        this._latestVersion.setWithCustomTTL(deploymentToCheck, Promise.resolve(resData), Deps.BAD_RESULT_TTL);\n        throw resData;\n      }\n      // Check if the version we're reporting is critical for the caller.\n      const oldestVersion = Deps.OLDEST_RECOMMENDED_VERSION;\n      if (currentVersion && oldestVersion) {\n        try {\n          resData.isCritical = semver.gt(oldestVersion, currentVersion);\n        } catch (e) {\n          throw new ApiError(\n            `/api/version got a bad version number ${currentVersion} (incomparable with ${oldestVersion})`,\n            400,\n          );\n        }\n      }\n\n      res.json(resData);\n    }));\n  }\n\n  public async clear() {\n    this._abortController.abort();\n    for (const task of this._latestVersion.values()) {\n      await task.catch(() => {});\n    }\n    this._latestVersion.clear();\n\n    // This function just clears cache and state, we should end with a fine state.\n    this._abortController = new AbortController();\n  }\n}\n\ntype VersionChecker = (signal: AbortSignal) => Promise<LatestVersion>;\n\n/**\n * Get the latest stable version of docker image from the hub.\n */\nexport async function getLatestStableDockerVersion(signal: AbortSignal): Promise<LatestVersion> {\n  try {\n    // Find stable tag.\n    const tags = await listRepositoryTags(signal);\n    const stableTag = tags.find(tag => tag.name === \"stable\");\n    if (!stableTag) {\n      throw new ApiError(\"No stable tag found\", 404);\n    }\n\n    // Now find all tags with the same image.\n    const up = tags\n      // Filter by digest.\n      .filter(tag => tag.digest === stableTag.digest)\n      // Name should be a version number in a correct format (should start with a number or v and number).\n      .filter(tag => /^v?\\d+/.test(tag.name))\n      // And sort it in natural order (so that 1.1.10 is after 1.1.9).\n      .sort(compare(\"name\"));\n\n    const last = up[up.length - 1];\n    // Panic if we don't have any tags that looks like version numbers.\n    if (!last) {\n      throw new ApiError(\"No stable image found\", 404);\n    }\n    return {\n      latestVersion: last.name,\n      updatedAt: last.tag_last_pushed,\n      // Versions are not critical, upgrades are, so we'll set that\n      // later when we know the version the user is currently at.\n      isCritical: false,\n      updateURL: Deps.DOCKER_IMAGE_SITE,\n    };\n  } catch (err) {\n    // Make sure to throw only ApiErrors (cache depends on that).\n    if (err instanceof ApiError) {\n      throw err;\n    }\n    throw new ApiError(err.message, 500);\n  }\n}\n\n// Shape of the data from the Docker Hub API.\ninterface DockerTag {\n  name: string;\n  digest: string;\n  tag_last_pushed: string;\n}\n\ninterface DockerResponse {\n  results: DockerTag[];\n  next: string | null;\n}\n\n// https://docs.docker.com/docker-hub/api/latest/#tag/repositories/\n// paths/~1v2~1namespaces~1%7Bnamespace%7D~1repositories~1%7Brepository%7D~1tags/get\nasync function listRepositoryTags(signal: AbortSignal): Promise<DockerTag[]> {\n  const tags: DockerTag[] = [];\n\n  // In case of rate limiting, we will retry the request 20 times.\n  // This is for all pages, so we might hit the limit multiple times.\n  let MAX_RETRIES = 20;\n\n  const url = new URL(Deps.DOCKER_ENDPOINT);\n  url.searchParams.set(\"page_size\", \"100\");\n  let next: string | null = url.toString();\n\n  // We assume have a maximum of 100 000 tags, if that is not enough, we will have to change this.\n  let MAX_LOOPS = 1000;\n\n  while (next && MAX_LOOPS-- > 0) {\n    const response = await fetch(next, { signal, timeout: Deps.REQUEST_TIMEOUT });\n    if (response.status === 429) {\n      // We hit the rate limit, let's wait a bit and try again.\n      await new Promise(resolve => setTimeout(resolve, Deps.RETRY_TIMEOUT));\n      if (signal.aborted) {\n        throw new Error(\"Aborted\");\n      }\n      if (MAX_RETRIES-- <= 0) {\n        throw new Error(\"Too many retries\");\n      }\n      continue;\n    }\n    if (response.status !== 200) {\n      throw new ApiError(await response.text(), response.status);\n    }\n    const json: DockerResponse = await response.json();\n    tags.push(...json.results);\n    next = json.next;\n  }\n  if (MAX_LOOPS <= 0) {\n    throw new Error(\"Too many tags found\");\n  }\n  return tags;\n}\n\n/**\n * Helper for sorting in natural order (1.1.10 is after 1.1.9).\n */\nfunction compare<T>(prop: keyof T) {\n  return (a: T, b: T) => {\n    return naturalCompare(a[prop], b[prop]);\n  };\n}\n"
  },
  {
    "path": "app/server/lib/UserPresence.ts",
    "content": "import { VisibleUserProfile } from \"app/common/ActiveDocAPI\";\nimport { CommDocUserPresenceUpdate } from \"app/common/CommTypes\";\nimport * as roles from \"app/common/roles\";\nimport { ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, FullUser, getRealAccess } from \"app/common/UserAPI\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { DocClients, isUserPresenceDisabled } from \"app/server/lib/DocClients\";\nimport { DocSession } from \"app/server/lib/DocSession\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\n\nimport { fromPairs } from \"lodash\";\n\nexport class UserPresence {\n  private _presenceSessionsById = new Map<string, UserPresenceSession>();\n\n  private _log = new LogMethods(\"UserPresence \", (s: DocSession | null) => this._activeDoc.getLogMeta(s));\n\n  constructor(private _docClients: DocClients) {\n    this._docClients.addClientAddedListener(this._onNewDocSession.bind(this));\n    this._docClients.addClientRemovedListener(this._onEndedDocSession.bind(this));\n  }\n\n  public async listVisibleUserProfiles(viewingDocSession: DocSession): Promise<VisibleUserProfile[]> {\n    if (isUserPresenceDisabled()) { return []; }\n    const viewingId = getIdFromDocSession(viewingDocSession);\n    const otherPresenceSessions = Array.from(this._presenceSessionsById.values()).filter(\n      otherSession => otherSession.id !== viewingId,\n    );\n    const docUserRoles = await this._getDocUserRoles();\n    const userProfiles = otherPresenceSessions.map(\n      s => getVisibleUserProfileFromDocSession(s, viewingDocSession, docUserRoles),\n    );\n    return userProfiles.filter((s?: VisibleUserProfile): s is VisibleUserProfile => s !== undefined);\n  }\n\n  private _onNewDocSession(docSession: DocSession) {\n    const id = getIdFromDocSession(docSession);\n    const _existingPresenceSession = this._presenceSessionsById.get(id);\n    if (!_existingPresenceSession) {\n      const newPresenceSession = new UserPresenceSession(docSession);\n      this._presenceSessionsById.set(id, newPresenceSession);\n      this._broadcastUserPresenceSessionUpdate(newPresenceSession);\n    } else {\n      _existingPresenceSession.addDocSession(docSession);\n    }\n  }\n\n  private _onEndedDocSession(docSession: DocSession) {\n    const id = getIdFromDocSession(docSession);\n    const _existingPresenceSession = this._presenceSessionsById.get(id);\n    if (!_existingPresenceSession) {\n      this._log.error(docSession, \"No user presence session exists for closing doc session\");\n      return;\n    }\n\n    _existingPresenceSession.removeDocSession(docSession);\n    if (_existingPresenceSession.totalDocSessions > 0) { return; }\n\n    this._presenceSessionsById.delete(id);\n    this._broadcastUserPresenceSessionRemoval(_existingPresenceSession);\n  }\n\n  private _broadcastUserPresenceSessionUpdate(presenceSession: UserPresenceSession) {\n    if (isUserPresenceDisabled()) { return; }\n    // Loading the doc user roles first allows the callback to be quick + synchronous,\n    // avoiding a potentially linear series of async calls.\n    this._getDocUserRoles()\n      .then(docUserRoles => this._docClients.broadcastDocMessage(\n        null,\n        \"docUserPresenceUpdate\",\n        undefined,\n        async (destSession: DocSession): Promise<CommDocUserPresenceUpdate[\"data\"] | undefined> => {\n          if (presenceSession.hasDocSession(destSession)) { return; }\n          const profile = getVisibleUserProfileFromDocSession(presenceSession, destSession, docUserRoles);\n          if (!profile) { return; }\n          return {\n            id: presenceSession.publicId,\n            profile,\n          };\n        },\n      ))\n      .catch((err) => {\n        this._log.error(null, \"failed to broadcast user presence session update: %s\", err);\n      });\n  }\n\n  private _broadcastUserPresenceSessionRemoval(presenceSession: UserPresenceSession) {\n    if (isUserPresenceDisabled()) { return; }\n    this._docClients.broadcastDocMessage(\n      null,\n      \"docUserPresenceUpdate\",\n      undefined,\n      async (): Promise<CommDocUserPresenceUpdate[\"data\"] | undefined> => {\n        return {\n          id: presenceSession.publicId,\n          profile: null,\n        };\n      },\n    ).catch((err) => {\n      this._log.error(null, \"failed to broadcast user presence session removal: %s\", err);\n    });\n  }\n\n  private async _getDocUserRoles(): Promise<UserIdRoleMap> {\n    const homeDb = this._activeDoc.getHomeDbManager();\n    const authCache = homeDb?.caches;\n    const docId = this._activeDoc.doc?.id;\n\n    // Not enough information - no useful data to be had here.\n    if (!homeDb || !docId || !authCache) {\n      return {};\n    }\n\n    const queryResult = await authCache.getDocAccess(docId);\n    const { users, maxInheritedRole } = homeDb.unwrapQueryResult(queryResult);\n\n    return fromPairs(users.map(user => [user.id, getRealAccess(user, { maxInheritedRole })]));\n  }\n\n  private get _activeDoc() {\n    return this._docClients.activeDoc;\n  }\n}\n\ninterface UserIdRoleMap {\n  [id: string]: roles.Role | null\n}\n\nfunction getVisibleUserProfileFromDocSession(\n  userPresenceSession: UserPresenceSession, viewingSession: DocSession, docUserRoles: UserIdRoleMap,\n): VisibleUserProfile | undefined {\n  // To see other users, you need to be a non-public user (i.e. added to the document), and have\n  // at least editor permissions.\n  if (!viewingSession.client.authSession.userId) {\n    return undefined;\n  }\n\n  const viewerRole = docUserRoles[viewingSession.client.authSession.userId];\n  if (!roles.canEdit(viewerRole)) {\n    return undefined;\n  }\n\n  const user = userPresenceSession.user;\n  const userId = userPresenceSession.userId;\n  const userEmail = user?.loginEmail ?? user?.email;\n  const explicitUserRole = userId ? docUserRoles[userId] : null;\n  // Only signed-in users that have explicit document access or are a member of the org / workspace\n  // have visible details by default.\n  const isAnonymous = !explicitUserRole || userEmail === ANONYMOUS_USER_EMAIL || userEmail === EVERYONE_EMAIL;\n  return {\n    id: userPresenceSession.publicId,\n    name: (isAnonymous ? \"Anonymous User\" : user?.name) || \"Unknown User\",\n    email: isAnonymous ? undefined : user?.email,\n    picture: isAnonymous ? undefined : user?.picture,\n    isAnonymous,\n  };\n}\n\nconst GRIST_USER_PRESENCE_ICON_PER_TAB = Boolean(appSettings.section(\"userPresence\").flag(\"iconPerTab\").readBool({\n  envVar: \"GRIST_USER_PRESENCE_ICON_PER_TAB\",\n  defaultValue: false,\n}));\n\nfunction getIdFromDocSession(session: DocSession): string {\n  // Forces every client to have a unique user presence session. Intended to ease frontend testing.\n  if (GRIST_USER_PRESENCE_ICON_PER_TAB) {\n    return session.client.publicClientId;\n  }\n  const authSession = session.client.authSession;\n  return (\n    (authSession.userIsAuthorized && authSession.userId?.toString()) ||\n    authSession.altSessionId ||\n    session.client.clientId\n  );\n}\n\nclass UserPresenceSession {\n  // Used internally to match doc sessions and presence sessions, should not be sent to the client.\n  public readonly id: string;\n  public readonly userId: number | null;\n  // Unique identifier for this user on the clients.\n  public readonly publicId: string;\n  public get user() { return this._user; }\n\n  private _docSessions = new Set<DocSession>();\n  private _user: FullUser | null;\n\n  constructor(initialSession: DocSession) {\n    this.id = getIdFromDocSession(initialSession);\n    this.userId = initialSession.client.authSession.userId;\n    // Any globally unique value will work, this is convenient.\n    this.publicId = initialSession.client.publicClientId;\n    this.addDocSession(initialSession);\n  }\n\n  public addDocSession(docSession: DocSession): void {\n    this._user = docSession.fullUser ?? this.user;\n    this._docSessions.add(docSession);\n  }\n\n  public removeDocSession(session: DocSession): void {\n    this._docSessions.delete(session);\n  }\n\n  public get totalDocSessions() {\n    return this._docSessions.size;\n  }\n\n  public hasDocSession(docSession: DocSession): boolean {\n    return this._docSessions.has(docSession);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/WebhookQueue.ts",
    "content": "import { MapWithTTL } from \"app/common/AsyncCreate\";\nimport { WebhookMessageType } from \"app/common/CommTypes\";\nimport {\n  EmailAction,\n  TriggerAction,\n  WebhookAction,\n  WebhookBatchStatus,\n  WebHookSecret,\n  WebhookStatus,\n  WebhookSummary,\n  WebhookSummaryCollection,\n  WebhookUsage,\n} from \"app/common/Triggers\";\nimport { RowRecord } from \"app/plugin/GristData\";\nimport { decodeObject } from \"app/plugin/objtypes\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport log from \"app/server/lib/log\";\nimport { fetchUntrustedWithAgent } from \"app/server/lib/ProxyAgent\";\nimport { matchesBaseDomain } from \"app/server/lib/requestUtils\";\nimport { delayAbort } from \"app/server/lib/serverUtils\";\nimport { LogSanitizer } from \"app/server/utils/LogSanitizer\";\n\nimport { promisifyAll } from \"bluebird\";\nimport * as _ from \"lodash\";\nimport { AbortController, AbortSignal } from \"node-abort-controller\";\nimport { createClient, Multi, RedisClient } from \"redis\";\n\npromisifyAll(RedisClient.prototype);\n\n/**\n * A payload calculated for the action to use. Each action has only access to a single record. Notice\n * that this is a whole record, without any restrictions.\n */\nexport interface ActionPayload<A extends TriggerAction = TriggerAction> {\n  id: string; // Action id (each action has unique id, for webhooks this a an id from home db)\n  payload: RowRecord; // The record data to use with the action\n  action: A;\n}\n\n/** Payload for webhook actions specifically. */\nexport type WebhookActionPayload = ActionPayload<WebhookAction>;\n\n/** Payload for email actions specifically. */\nexport type EmailActionPayload = ActionPayload<EmailAction>;\n\nconst MAX_QUEUE_SIZE =\n  process.env.GRIST_MAX_QUEUE_SIZE ? parseInt(process.env.GRIST_MAX_QUEUE_SIZE, 10) : 1000;\n\nconst WEBHOOK_CACHE_TTL = 10_000;\n\nconst WEBHOOK_STATS_CACHE_TTL = 1000 /* s */ * 60 /* m */ * 24/* h */;\n\n// A time to wait for between retries of a webhook. Exposed for tests.\nconst TRIGGER_WAIT_DELAY =\n  process.env.GRIST_TRIGGER_WAIT_DELAY ? parseInt(process.env.GRIST_TRIGGER_WAIT_DELAY, 10) : 1000;\n\nconst TRIGGER_MAX_ATTEMPTS =\n  process.env.GRIST_TRIGGER_MAX_ATTEMPTS ? parseInt(process.env.GRIST_TRIGGER_MAX_ATTEMPTS, 10) : 20;\n\nexport interface ActionQueue<T extends ActionPayload = ActionPayload> {\n  enqueue(events: T[]): Promise<void>;\n}\n\ntype ActionExecutor<T extends ActionPayload = ActionPayload> = ActionQueue<T> | ((events: T[]) => Promise<void>);\n\n// Maps action type discriminator strings to their specific payload types.\ninterface ActionPayloadMap {\n  webhook: WebhookActionPayload;\n  email: EmailActionPayload;\n}\n\n/**\n * An ActionQueue that routes events to different queues based on action type.\n * It accepts the general ActionPayload union and is responsible for casting\n * each event to the correct specific payload type before handing it off to\n * the registered queue or function for that action type.\n */\nexport class ComposedActionQueue implements ActionQueue {\n  private _queueMap = new Map<string, ActionQueue<ActionPayload>>();\n\n  public use<K extends keyof ActionPayloadMap>(\n    type: K,\n    queue: ActionExecutor<ActionPayloadMap[K]>,\n  ) {\n    if (typeof queue === \"function\") {\n      this._queueMap.set(type, { enqueue: queue });\n    } else {\n      this._queueMap.set(type, queue);\n    }\n  }\n\n  public async enqueue(events: ActionPayload[]) {\n    const eventsByType = _.groupBy(events, e => e.action.type);\n    const promises: Promise<void>[] = [];\n    for (const [type, typeEvents] of Object.entries(eventsByType)) {\n      const queue = this._queueMap.get(type);\n      if (queue) {\n        promises.push(queue.enqueue(typeEvents));\n      } else {\n        log.warn(\"ComposedActionQueue: no queue for action type\", type);\n      }\n    }\n    await Promise.allSettled(promises);\n    await Promise.all(promises); // Rethrow any errors.\n  }\n}\n\n// Manages webhook event queuing and HTTP delivery.\n// Events are placed on an in-memory queue which is replicated on redis as backup.\n// The same class instance consumes the queue and sends webhook requests in the background - see _sendLoop().\n// This class is used by DocTriggers which handles the trigger logic and delegates\n// webhook delivery to this class.\nexport class WebhookQueue implements ActionQueue<WebhookActionPayload> {\n  // Events that need to be sent to webhooks in FIFO order.\n  // This is the primary place where events are stored and consumed,\n  // while a copy of this queue is kept on redis as a backup.\n  // Modifications to this queue should be replicated on the redis queue.\n  private _webHookEventQueue: WebhookActionPayload[] = [];\n\n  // DB cache for webhook secrets\n  private _webhookCache = new MapWithTTL<string, WebHookSecret>(WEBHOOK_CACHE_TTL);\n\n  // Set to true by shutdown().\n  // Indicates that loops (especially for sending requests) should stop.\n  private _shuttingDown: boolean = false;\n\n  // true if there is a webhook request sending loop running in the background\n  // to ensure only one loop is running at a time.\n  private _sending: boolean = false;\n\n  // Client lazily initiated by _redisClient getter, since most documents don't have triggers\n  // and therefore don't need a redis connection.\n  private _redisClientField: RedisClient | undefined;\n\n  // Promise which resolves after we finish fetching the backup queue from redis on startup.\n  private _getRedisQueuePromise: Promise<void> | undefined;\n\n  // Abort controller for the loop that sends webhooks.\n  private _loopAbort: AbortController | undefined;\n\n  private _sanitizer = new LogSanitizer();\n  private _stats: WebhookStatistics;\n  constructor(private _activeDoc: ActiveDoc) {\n    const redisUrl = process.env.REDIS_URL;\n    this._stats = new WebhookStatistics(this._docId, _activeDoc, () => this._redisClient ?? null);\n\n    if (redisUrl) {\n      // We create a transient client just for this purpose because it makes it easy\n      // to quit it afterwards and avoid keeping a client open for documents without triggers.\n      this._getRedisQueuePromise = this._getRedisQueue(createClient(redisUrl));\n    }\n  }\n\n  public async enqueue(events: WebhookActionPayload[]) {\n    await this._pushToRedisQueue(events);\n    this._webHookEventQueue.push(...events);\n    this._startSendLoop();\n    // Prevent further document activity while the queue is too full.\n    while (this._drainingQueue && !this._shuttingDown) {\n      const sendNotificationPromise =  this._activeDoc.sendWebhookNotification(WebhookMessageType.Overflow);\n      const delayPromise = delayAbort(5000, this._loopAbort?.signal).catch(() => {});\n      await Promise.all([sendNotificationPromise, delayPromise]);\n    }\n  }\n\n  public shutdown() {\n    this._shuttingDown = true;\n    this._loopAbort?.abort();\n    this._webhookCache.clear();\n    if (!this._sending) {\n      void (this._redisClientField?.quitAsync());\n    }\n  }\n\n  /**\n   * Creates summary for all webhooks in the document.\n   */\n  public async summary(): Promise<WebhookSummaryCollection> {\n    // Prepare some data we will use.\n    const docData = this._activeDoc.docData!;\n    const triggersTable = docData.getMetaTable(\"_grist_Triggers\");\n    const getTableId = docData.getMetaTable(\"_grist_Tables\").getRowPropFunc(\"tableId\");\n    const getColId = docData.getMetaTable(\"_grist_Tables_column\").getRowPropFunc(\"colId\");\n    const getUrl = async (id: string) => (await this._getWebHook(id))?.url ?? \"\";\n    const getAuthorization = async (id: string) => (await this._getWebHook(id))?.authorization ?? \"\";\n    const getUnsubscribeKey = async (id: string) => (await this._getWebHook(id))?.unsubscribeKey ?? \"\";\n    const resultTable: WebhookSummary[] = [];\n\n    // Go through all triggers int the document that we have.\n    for (const t of triggersTable.getRecords()) {\n      // Each trigger has associated table and a bunch of trigger actions (currently only 1 that is webhook).\n      const actions = JSON.parse(t.actions) as TriggerAction[];\n      // Get only webhooks for this trigger.\n      const webhookActions = actions.filter(act => act.type === \"webhook\");\n      for (const act of webhookActions) {\n        // Url, probably should be hidden for non-owners (but currently this API is owners only).\n        const url = await getUrl(act.id);\n        const authorization = await getAuthorization(act.id);\n        // Same story, should be hidden.\n        const unsubscribeKey = await getUnsubscribeKey(act.id);\n        if (!url || !unsubscribeKey) {\n          // Webhook might have been deleted in the mean time.\n          continue;\n        }\n        const decodedWatchedColRefList = decodeObject(t.watchedColRefList) as number[] || [];\n        // Report some basic info and usage stats.\n        const entry: WebhookSummary = {\n          // Id of the webhook\n          id: act.id,\n          fields: {\n            // Url, probably should be hidden for non-owners (but currently this API is owners only).\n            url,\n            authorization,\n            unsubscribeKey,\n            // Other fields used to register this webhook.\n            eventTypes: decodeObject(t.eventTypes) as string[],\n            isReadyColumn: getColId(t.isReadyColRef) ?? null,\n            watchedColIds: decodedWatchedColRefList.map(columnRef => getColId(columnRef)),\n            tableId: getTableId(t.tableRef) ?? null,\n            // For future use - for now every webhook is enabled.\n            enabled: t.enabled,\n            name: t.label,\n            memo: t.memo,\n          },\n          // Create some statics and status info.\n          usage: await this._stats.getUsage(act.id, this._webHookEventQueue),\n        };\n        resultTable.push(entry);\n      }\n    }\n    return { webhooks: resultTable };\n  }\n\n  public clearWebhookCache(id: string) {\n    this._webhookCache.delete(id);\n  }\n\n  public async clearWebhookQueue() {\n    this._log(\"Webhook being queue cleared\");\n    // Make sure we are after start and in sync with redis.\n    if (this._getRedisQueuePromise) {\n      await this._getRedisQueuePromise;\n    }\n    // Clear in-memory queue.\n    const removed = this._webHookEventQueue.splice(0, this._webHookEventQueue.length).length;\n    // Notify the loop that it should restart.\n    this._loopAbort?.abort();\n    // If we have backup in redis, clear it also.\n    // NOTE: this is subject to a race condition, currently it is not possible, but any future modification probably\n    // will require some kind of locking over the queue (or a rewrite)\n    if (removed && this._redisClient) {\n      await this._redisClient.multi().del(this._redisQueueKey).execAsync();\n    }\n    await this._stats.clear();\n    this._log(\"Webhook queue cleared\", { numRemoved: removed });\n  }\n\n  public async clearSingleWebhookQueue(webhookId: string) {\n    this._log(\"Single webhook queue being cleared\", { webhookId });\n    // Make sure we are after start and in sync with redis.\n    if (this._getRedisQueuePromise) {\n      await this._getRedisQueuePromise;\n    }\n    // Clear in-memory queue for given webhook key.\n    const lengthBefore = this._webHookEventQueue.length;\n    this._webHookEventQueue = this._webHookEventQueue.filter(e => e.id !== webhookId);\n    const removed = lengthBefore - this._webHookEventQueue.length;\n\n    // Notify the loop that it should restart.\n    this._loopAbort?.abort();\n    // If we have backup in redis, clear it also.\n    // NOTE: this is subject to a race condition, currently it is not possible, but any future modification probably\n    // will require some kind of locking over the queue (or a rewrite)\n    if (removed && this._redisClient) {\n      const multi = this._redisClient.multi();\n      multi.del(this._redisQueueKey);\n\n      // Re-add all the remaining events to the queue.\n      if (this._webHookEventQueue.length) {\n        const strings = this._webHookEventQueue.map(e => JSON.stringify(e));\n        multi.rpush(this._redisQueueKey, ...strings);\n      }\n      await multi.execAsync();\n    }\n    await this._stats.clear();\n    this._log(\"Single webhook queue cleared\", { numRemoved: removed, webhookId });\n  }\n\n  private get _docId() {\n    return this._activeDoc.docName;\n  }\n\n  private get _redisQueueKey() {\n    return `webhook-queue-${this._docId}`;\n  }\n\n  private get _drainingQueue() {\n    return this._webHookEventQueue.length >= MAX_QUEUE_SIZE;\n  }\n\n  private _log(msg: string, { level = \"info\", ...meta }: any = {}) {\n    log.origLog(level, \"WebhookQueue: \" + msg, {\n      ...meta,\n      docId: this._docId,\n      queueLength: this._webHookEventQueue.length,\n      drainingQueue: this._drainingQueue,\n      shuttingDown: this._shuttingDown,\n      sending: this._sending,\n      redisClient: Boolean(this._redisClientField),\n    });\n  }\n\n  private async _pushToRedisQueue(events: WebhookActionPayload[]) {\n    const strings = events.map(e => JSON.stringify(e));\n    try {\n      await this._redisClient?.rpushAsync(this._redisQueueKey, ...strings);\n    } catch (e) {\n      // It's very hard to test this with integration tests, because it requires a redis failure.\n      // And it's not easy to simulate redis failure.\n      // So on this point we have only unit test in core/test/server/utils/LogSanitizer.ts\n      throw this._sanitizer.sanitize(e);\n    }\n  }\n\n  private async _getRedisQueue(redisClient: RedisClient) {\n    const strings = await redisClient.lrangeAsync(this._redisQueueKey, 0, -1);\n    if (strings.length) {\n      this._log(\"Webhook events found on redis queue\", { numEvents: strings.length });\n      const events = strings.map(s => JSON.parse(s));\n      this._webHookEventQueue.unshift(...events);\n      this._startSendLoop();\n    }\n    await redisClient.quitAsync();\n  }\n\n  private async _getWebHook(id: string): Promise<WebHookSecret | undefined> {\n    let webhook = this._webhookCache.get(id);\n    if (!webhook) {\n      const secret = await this._activeDoc.getHomeDbManager()?.getSecret(id, this._docId);\n      if (!secret) {\n        this._log(`No webhook secret found`, { level: \"warn\", id });\n        return;\n      }\n      webhook = JSON.parse(secret);\n      this._webhookCache.set(id, webhook!);\n    }\n    return webhook!;\n  }\n\n  private async _getWebHookUrl(id: string): Promise<string | undefined> {\n    const url = (await this._getWebHook(id))?.url ?? \"\";\n    if (!isUrlAllowed(url)) {\n      // TODO: this is not a good place for a validation.\n      this._log(`Webhook not sent to forbidden URL`, { level: \"warn\", url });\n      return;\n    }\n    return url;\n  }\n\n  private _startSendLoop() {\n    if (!this._sending) {  // only run one loop at a time\n      this._sending = true;\n      this._sendLoop().catch((e) => {  // run _sendLoop asynchronously (in the background)\n        this._log(`_sendLoop failed: ${e}`, { level: \"error\" });\n        this._sending = false;  // otherwise the following line will complete instantly\n        this._startSendLoop();  // restart the loop on failure\n      });\n    }\n  }\n\n  // Consumes the webhook event queue and sends HTTP requests.\n  // Should only be called if there are events to send.\n  // Managed by _startSendLoop. Runs in the background. Only one loop should run at a time.\n  // Runs until shutdown.\n  private async _sendLoop() {\n    this._log(\"Starting _sendLoop\");\n\n    // TODO delay/prevent shutting down while queue isn't empty?\n    while (!this._shuttingDown) {\n      this._loopAbort = new AbortController();\n      if (!this._webHookEventQueue.length) {\n        await delayAbort(TRIGGER_WAIT_DELAY, this._loopAbort.signal).catch(() => {});\n        continue;\n      }\n      const id = this._webHookEventQueue[0].id;\n      const batch = _.takeWhile(this._webHookEventQueue.slice(0, 100), { id });\n      const body = JSON.stringify(batch.map(e => e.payload));\n      const url = await this._getWebHookUrl(id);\n      const authorization = (await this._getWebHook(id))?.authorization || \"\";\n      if (this._loopAbort.signal.aborted) {\n        continue;\n      }\n      let meta: { webhookId: string; host: string, quantity: number } | undefined;\n      let success: boolean;\n      if (!url) {\n        success = true;\n      } else {\n        await this._stats.logStatus(id, \"sending\");\n        meta = { webhookId: id, host: new URL(url).host, quantity: batch.length };\n        this._log(\"Sending batch of webhook events\", meta);\n        this._activeDoc.logTelemetryEvent(null, \"sendingWebhooks\", {\n          limited: { numEvents: meta.quantity },\n        });\n        success = await this._sendWebhookWithRetries(\n          id, url, authorization, body, batch.length, this._loopAbort.signal);\n        if (this._loopAbort.signal.aborted) {\n          continue;\n        }\n      }\n\n      if (this._loopAbort.signal.aborted) {\n        continue;\n      }\n\n      this._webHookEventQueue.splice(0, batch.length);\n\n      let multi: Multi | null = null;\n      if (this._redisClient) {\n        multi = this._redisClient.multi();\n        multi.ltrim(this._redisQueueKey, batch.length, -1);\n      }\n\n      if (!success) {\n        this._log(\"Failed to send batch of webhook events\", { ...meta, level: \"warn\" });\n        if (!this._drainingQueue) {\n          // Put the failed events at the end of the queue to try again later\n          // while giving other URLs a chance to receive events.\n          this._webHookEventQueue.push(...batch);\n          if (multi) {\n            const strings = batch.map(e => JSON.stringify(e));\n            multi.rpush(this._redisQueueKey, ...strings);\n          }\n          // We are postponed, so mark that.\n          await this._stats.logStatus(id, \"postponed\");\n        } else {\n          // We are draining the queue and we skipped some events, so mark that.\n          await this._stats.logStatus(id, \"error\");\n          await this._stats.logBatch(id, \"rejected\");\n        }\n      } else {\n        await this._stats.logStatus(id, \"idle\");\n        if (meta) {\n          this._log(\"Successfully sent batch of webhook events\", meta);\n          const { webhookId, host, quantity } = meta;\n          this._activeDoc.logAuditEvent(null, {\n            action: \"document.deliver_webhook_events\",\n            actor: {\n              type: \"system\",\n            },\n            details: {\n              document: {\n                id: this._docId,\n              },\n              webhook: {\n                id: webhookId,\n                events: {\n                  delivered_to: host,\n                  quantity,\n                },\n              },\n            },\n          });\n        }\n      }\n\n      await multi?.execAsync();\n    }\n\n    this._log(\"Ended _sendLoop\");\n\n    this._redisClient?.quitAsync().catch(e =>\n      // Catch error to prevent sendLoop being restarted\n      this._log(\"Error quitting redis: \" + e, { level: \"warn\" }),\n    );\n  }\n\n  private get _redisClient() {\n    if (this._redisClientField) {\n      return this._redisClientField;\n    }\n    const redisUrl = process.env.REDIS_URL;\n    if (redisUrl) {\n      this._log(\"Creating redis client\");\n      this._redisClientField = createClient(redisUrl);\n    }\n    return this._redisClientField;\n  }\n\n  private get _maxWebhookAttempts() {\n    if (this._shuttingDown) {\n      return 0;\n    }\n    return this._drainingQueue ? Math.min(5, TRIGGER_MAX_ATTEMPTS) : TRIGGER_MAX_ATTEMPTS;\n  }\n\n  private async _sendWebhookWithRetries(\n    id: string, url: string, authorization: string, body: string, size: number, signal: AbortSignal) {\n    const maxWait = 64;\n    let wait = 1;\n    for (let attempt = 0; attempt < this._maxWebhookAttempts; attempt++) {\n      if (this._shuttingDown) {\n        return false;\n      }\n      try {\n        if (attempt > 0) {\n          await this._stats.logStatus(id, \"retrying\");\n        }\n        const response = await fetchUntrustedWithAgent(url, {\n          method: \"POST\",\n          body,\n          headers: {\n            \"Content-Type\": \"application/json\",\n            ...(authorization ? { Authorization: authorization } : {}),\n          },\n          signal,\n        });\n        if (response.ok) {\n          await this._stats.logBatch(id, \"success\", {\n            size, httpStatus: response.status, error: null, attempts: attempt + 1,\n          });\n          return true;\n        }\n        await this._stats.logBatch(id, \"failure\", {\n          httpStatus: response.status,\n          error: await response.text(),\n          attempts: attempt + 1,\n          size,\n        });\n        this._log(`Webhook responded with non-200 status`, { level: \"warn\", status: response.status, attempt });\n      } catch (e) {\n        await this._stats.logBatch(id, \"failure\", {\n          httpStatus: null,\n          error: (e.message || \"Unrecognized error during fetch\"),\n          attempts: attempt + 1,\n          size,\n        });\n        this._log(`Webhook sending error: ${e}`, { level: \"warn\", attempt });\n      }\n\n      if (signal.aborted) {\n        return false;\n      }\n\n      // Don't wait any more if this is the last attempt.\n      if (attempt >= this._maxWebhookAttempts - 1) {\n        return false;\n      }\n\n      // Wait `wait` seconds, checking this._shuttingDown every second.\n      for (let waitIndex = 0; waitIndex < wait; waitIndex++) {\n        if (this._shuttingDown) {\n          return false;\n        }\n        try {\n          await delayAbort(TRIGGER_WAIT_DELAY, signal);\n        } catch (e) {\n          // If signal was aborted, don't log anything as we probably was cleared.\n          return false;\n        }\n      }\n      if (wait < maxWait) {\n        wait *= 2;\n      }\n    }\n    return false;\n  }\n}\n\nexport function isUrlAllowed(urlString: string) {\n  let url: URL;\n  try {\n    url = new URL(urlString);\n  } catch (e) {\n    return false;\n  }\n\n  // Support at most https and http.\n  if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n    return false;\n  }\n\n  // Support a wildcard that allows all domains.\n  // Allow either https or http if it is set.\n  if (process.env.ALLOWED_WEBHOOK_DOMAINS === \"*\") {\n    return true;\n  }\n\n  // http (no s) is only allowed for localhost for testing.\n  // localhost still needs to be explicitly permitted, and it shouldn't be outside dev\n  if (url.protocol !== \"https:\" && url.hostname !== \"localhost\") {\n    return false;\n  }\n\n  return (process.env.ALLOWED_WEBHOOK_DOMAINS || \"\").split(\",\").some(domain =>\n    domain && matchesBaseDomain(url.host, domain),\n  );\n}\n\n/**\n * Implementation detail, helper to provide a persisted storage to a derived class.\n */\nclass PersistedStore<Keys> {\n  /** In memory fallback if redis is not available */\n  private _statsCache = new MapWithTTL<string, string>(WEBHOOK_STATS_CACHE_TTL);\n  private _redisKey: string;\n\n  constructor(\n    docId: string,\n    private _activeDoc: ActiveDoc,\n    private _redisClientDep: () => RedisClient | null,\n  ) {\n    this._redisKey = `webhooks:${docId}:statistics`;\n  }\n\n  public async clear() {\n    this._statsCache.clear();\n    if (this._redisClient) {\n      await this._redisClient.delAsync(this._redisKey).catch(() => {});\n    }\n  }\n\n  protected async markChange() {\n    await this._activeDoc.sendWebhookNotification();\n  }\n\n  protected async set(id: string, keyValues: [Keys, string][]) {\n    if (this._redisClient) {\n      const multi = this._redisClient.multi();\n      for (const [key, value] of keyValues) {\n        multi.hset(this._redisKey, `${id}:${key}`, value);\n        multi.expire(this._redisKey, WEBHOOK_STATS_CACHE_TTL);\n      }\n      await multi.execAsync();\n    } else {\n      for (const [key, value] of keyValues) {\n        this._statsCache.set(`${id}:${key}`, value);\n      }\n    }\n  }\n\n  protected async get(id: string, keys: Keys[]): Promise<[Keys, string][]> {\n    if (this._redisClient) {\n      const values = (await this._redisClient.hgetallAsync(this._redisKey)) || {};\n      return keys.map(key => [key, values[`${id}:${key}`] || \"\"]);\n    } else {\n      return keys.map(key => [key, this._statsCache.get(`${id}:${key}`) || \"\"]);\n    }\n  }\n\n  private get _redisClient() {\n    return this._redisClientDep();\n  }\n}\n\n/**\n * Helper class that monitors and saves (either in memory or in Redis) usage statics and current\n * status of webhooks.\n */\nclass WebhookStatistics extends PersistedStore<StatsKey> {\n  /**\n   * Retrieves and calculates all the statistics for a given webhook.\n   * @param id Webhook ID\n   * @param queue Current webhook task queue\n   */\n  public async getUsage(id: string, queue: WebhookActionPayload[]): Promise<WebhookUsage | null> {\n    // Get all the keys from the store for this webhook, and create a dictionary.\n    const values: Record<StatsKey, string> = _.fromPairs(await this.get(id, [\n      `batchStatus`,\n      `httpStatus`,\n      `errorMessage`,\n      `size`,\n      `status`,\n      `updatedTime`,\n      `lastFailureTime`,\n      `lastSuccessTime`,\n      `lastErrorMessage`,\n      `lastHttpStatus`,\n      `attempts`,\n    ])) as Record<StatsKey, string>;\n\n    // If everything is empty, we don't have any stats yet.\n    if (Array.from(Object.values(values)).every(v => !v)) {\n      return {\n        status: \"idle\",\n        numWaiting: queue.filter(e => e.id === id).length,\n        lastEventBatch: null,\n      };\n    }\n\n    const usage: WebhookUsage = {\n      // Overall status of the webhook.\n      status: values.status as WebhookStatus || \"idle\",\n      numWaiting: queue.filter(x => x.id === id).length,\n      updatedTime: parseInt(values.updatedTime || \"0\", 10),\n      // Last values from batches.\n      lastEventBatch: null,\n      lastSuccessTime: parseInt(values.lastSuccessTime, 10),\n      lastFailureTime: parseInt(values.lastFailureTime, 10),\n      lastErrorMessage: values.lastErrorMessage || null,\n      lastHttpStatus: values.lastHttpStatus ? parseInt(values.lastHttpStatus, 10) : null,\n    };\n\n    // If we have a batchStatus (so we actually run it at least once - or it wasn't cleared).\n    if (values.batchStatus) {\n      usage.lastEventBatch = {\n        status: values.batchStatus as WebhookBatchStatus,\n        httpStatus: values.httpStatus ? parseInt(values.httpStatus || \"0\", 10) : null,\n        errorMessage: values.errorMessage || null,\n        size: parseInt(values.size || \"0\", 10),\n        attempts: parseInt(values.attempts || \"0\", 10),\n      };\n    }\n\n    return usage;\n  }\n\n  /**\n   * Logs a status of a webhook. Now is passed as a parameter so that updates that happen in almost the same\n   * millisecond were seen as the same update.\n   */\n  public async logStatus(id: string, status: WebhookStatus, now?: number | null) {\n    const stats: [StatsKey, string][] = [\n      [\"status\", status],\n      [\"updatedTime\", (now ?? Date.now()).toString()],\n    ];\n    if (status === \"sending\") {\n      // clear any error message that could have been left from an earlier bad state (ie: invalid\n      // fields)\n      stats.push([\"errorMessage\", \"\"]);\n    }\n    await this.set(id, stats);\n    await this.markChange();\n  }\n\n  public async logInvalid(id: string, errorMessage: string) {\n    await this.logStatus(id, \"invalid\");\n    await this.set(id, [\n      [\"errorMessage\", errorMessage],\n    ]);\n    await this.markChange();\n  }\n\n  /**\n   * Logs a status of the active batch.\n   */\n  public async logBatch(\n    id: string,\n    status: WebhookBatchStatus,\n    stats?: {\n      httpStatus?: number | null,\n      error?: string | null,\n      size?: number | null,\n      attempts?: number | null,\n    },\n  ) {\n    const now = Date.now();\n\n    // Update batchStats.\n    const batchStats: [StatsKey, string][] = [\n      [`batchStatus`, status],\n      [`updatedTime`, now.toString()],\n    ];\n    if (stats?.httpStatus !== undefined) {\n      batchStats.push([`httpStatus`, (stats.httpStatus || \"\").toString()]);\n    }\n    if (stats?.attempts !== undefined) {\n      batchStats.push([`attempts`, (stats.attempts || \"0\").toString()]);\n    }\n    if (stats?.error !== undefined) {\n      batchStats.push([`errorMessage`, stats?.error || \"\"]);\n    }\n    if (stats?.size !== undefined) {\n      batchStats.push([`size`, (stats.size || \"\").toString()]);\n    }\n\n    const batchSummary: [StatsKey, string][] = [];\n    // Update webhook stats.\n    if (status === \"success\") {\n      batchSummary.push([`lastSuccessTime`, now.toString()]);\n    } else if (status === \"failure\") {\n      batchSummary.push([`lastFailureTime`, now.toString()]);\n    }\n    if (stats?.error) {\n      batchSummary.push([`lastErrorMessage`, stats.error]);\n    }\n    if (stats?.httpStatus) {\n      batchSummary.push([`lastHttpStatus`, (stats.httpStatus || \"\").toString()]);\n    }\n    await this.set(id, batchStats.concat(batchSummary));\n    await this.markChange();\n  }\n}\n\ntype StatsKey =\n  \"batchStatus\" |\n  \"httpStatus\" |\n  \"errorMessage\" |\n  \"attempts\" |\n  \"size\" |\n  \"updatedTime\" |\n  \"lastFailureTime\" |\n  \"lastSuccessTime\" |\n  \"lastErrorMessage\" |\n  \"lastHttpStatus\" |\n  \"status\";\n"
  },
  {
    "path": "app/server/lib/WidgetRepository.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { AsyncCreate } from \"app/common/AsyncCreate\";\nimport { ICustomWidget } from \"app/common/CustomWidget\";\nimport { isAffirmative, removeTrailingSlash } from \"app/common/gutil\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport { agents } from \"app/server/lib/ProxyAgent\";\n\nimport * as path from \"path\";\nimport * as url from \"url\";\n\nimport * as fse from \"fs-extra\";\nimport LRUCache from \"lru-cache\";\nimport fetch from \"node-fetch\";\n\nexport const Deps = {\n  /** Static url for UrlWidgetRepository */\n  STATIC_URL: process.env.GRIST_WIDGET_LIST_URL,\n};\n\n/**\n * Widget Repository returns list of available Custom Widgets.\n */\nexport interface IWidgetRepository {\n  getWidgets(): Promise<ICustomWidget[]>;\n}\n\n/**\n *\n * A widget repository that lives on disk.\n *\n * The _widgetFile should point to a json file containing a\n * list of custom widgets, in the format used by the grist-widget\n * repo:\n *   https://github.com/gristlabs/grist-widget\n *\n * The file can use relative URLs. The URLs will be interpreted\n * as relative to the _widgetBaseUrl.\n *\n * If a _source is provided, it will be passed along in the\n * widget listings.\n *\n */\nexport class DiskWidgetRepository implements IWidgetRepository {\n  constructor(private _widgetFile: string,\n    private _widgetBaseUrl: string,\n    private _source?: any) {}\n\n  public async getWidgets(): Promise<ICustomWidget[]> {\n    const txt = await fse.readFile(this._widgetFile, { encoding: \"utf8\" });\n    const widgets: ICustomWidget[] = JSON.parse(txt);\n    fixUrls(widgets, this._widgetBaseUrl);\n    if (this._source) {\n      for (const widget of widgets) {\n        widget.source = this._source;\n      }\n    }\n    return widgets;\n  }\n}\n\n/**\n *\n * A wrapper around a widget repository that delays creating it\n * until the first call to getWidgets().\n *\n */\nexport class DelayedWidgetRepository implements IWidgetRepository {\n  private _repo: AsyncCreate<IWidgetRepository | undefined>;\n\n  constructor(_makeRepo: () => Promise<IWidgetRepository | undefined>) {\n    this._repo = new AsyncCreate(_makeRepo);\n  }\n\n  public async getWidgets(): Promise<ICustomWidget[]> {\n    const repo = await this._repo.get();\n    if (!repo) { return []; }\n    return repo.getWidgets();\n  }\n}\n\n/**\n *\n * A wrapper around a list of widget repositories that concatenates\n * their results.\n *\n */\nexport class CombinedWidgetRepository implements IWidgetRepository {\n  constructor(private _repos: IWidgetRepository[]) {}\n\n  public async getWidgets(): Promise<ICustomWidget[]> {\n    const allWidgets: ICustomWidget[] = [];\n    for (const repo of this._repos) {\n      allWidgets.push(...await repo.getWidgets());\n    }\n    return allWidgets;\n  }\n}\n\n/**\n * Repository that gets a list of widgets from a URL.\n */\nexport class UrlWidgetRepository implements IWidgetRepository {\n  constructor(private _staticUrl = Deps.STATIC_URL,\n    private _required: boolean = true) {}\n\n  public async getWidgets(): Promise<ICustomWidget[]> {\n    if (!this._staticUrl) {\n      log.warn(\n        \"WidgetRepository: Widget repository is not configured.\" + (!Deps.STATIC_URL ?\n          \" Missing GRIST_WIDGET_LIST_URL environmental variable.\" :\n          \"\"),\n      );\n      return [];\n    }\n    try {\n      const response = await fetch(this._staticUrl, { agent: agents.trusted });\n      if (!response.ok) {\n        if (response.status === 404) {\n          throw new ApiError(\"WidgetRepository: Remote widget list not found\", 404);\n        } else {\n          const body = await response.text().catch(() => \"\");\n          throw new ApiError(\n            `WidgetRepository: Remote server returned an error: ${body || response.statusText}`, response.status,\n          );\n        }\n      }\n      const widgets = await response.json().catch(() => null);\n      if (!widgets || !Array.isArray(widgets)) {\n        throw new ApiError(\"WidgetRepository: Error reading widget list\", 500);\n      }\n      fixUrls(widgets, this._staticUrl);\n      return widgets;\n    } catch (err) {\n      if (this._required) {\n        if (!(err instanceof ApiError)) {\n          throw new ApiError(String(err), 500);\n        }\n        throw err;\n      } else {\n        log.error(\"WidgetRepository: Error fetching widget list - \" +\n          String(err));\n        return [];\n      }\n    }\n  }\n}\n\n/**\n * Default repository that gets list of available widgets from multiple\n * sources.\n */\nexport class WidgetRepositoryImpl implements IWidgetRepository {\n  protected _staticUrl: string | undefined;\n  private _diskWidgets?: IWidgetRepository;\n  private _urlWidgets: UrlWidgetRepository;\n  private _combinedWidgets: CombinedWidgetRepository;\n\n  constructor(_options: {\n    staticUrl?: string,\n    gristServer?: GristServer,\n  }) {\n    const { staticUrl, gristServer } = _options;\n    if (gristServer) {\n      this._diskWidgets = new DelayedWidgetRepository(async () => {\n        const places = getWidgetsInPlugins(gristServer);\n        const files = places.map(\n          place => new DiskWidgetRepository(\n            place.file,\n            place.urlBase,\n            {\n              pluginId: place.pluginId,\n              name: place.name,\n            }));\n        return new CombinedWidgetRepository(files);\n      });\n    }\n    this.testSetUrl(staticUrl);\n  }\n\n  /**\n   * Method exposed for testing, overrides widget url.\n   */\n  public testOverrideUrl(overrideUrl: string | undefined) {\n    this.testSetUrl(overrideUrl);\n  }\n\n  public testSetUrl(overrideUrl: string | undefined) {\n    const repos: IWidgetRepository[] = [];\n    this._staticUrl = overrideUrl ?? Deps.STATIC_URL;\n    if (this._staticUrl) {\n      const optional = isAffirmative(process.env.GRIST_WIDGET_LIST_URL_OPTIONAL);\n      this._urlWidgets = new UrlWidgetRepository(this._staticUrl,\n        !optional);\n      repos.push(this._urlWidgets);\n    }\n    if (this._diskWidgets) { repos.push(this._diskWidgets); }\n    this._combinedWidgets = new CombinedWidgetRepository(repos);\n  }\n\n  public async getWidgets(): Promise<ICustomWidget[]> {\n    return this._combinedWidgets.getWidgets();\n  }\n}\n\n/**\n * Version of WidgetRepository that caches successful result for 2 minutes.\n */\nclass CachedWidgetRepository extends WidgetRepositoryImpl {\n  private _cache = new LRUCache<1, ICustomWidget[]>({ maxAge: 1000 * 60 /* minute */ * 2 });\n  public async getWidgets() {\n    // Don't cache for localhost\n    if (this._staticUrl?.startsWith(\"http://localhost\")) {\n      this._cache.reset();\n    }\n    if (this._cache.has(1)) {\n      log.debug(\"WidgetRepository: Widget list taken from the cache.\");\n      return this._cache.get(1)!;\n    }\n    const list = await super.getWidgets();\n    // Cache only if there are some widgets.\n    if (list.length) { this._cache.set(1, list); }\n    return list;\n  }\n\n  public testOverrideUrl(overrideUrl: string) {\n    super.testOverrideUrl(overrideUrl);\n    this._cache.reset();\n  }\n}\n\n/**\n * Returns widget repository implementation.\n */\nexport function buildWidgetRepository(gristServer?: GristServer,\n  options?: {\n    localOnly: boolean\n  }) {\n  return new CachedWidgetRepository({\n    gristServer,\n    ...(options?.localOnly ? { staticUrl: \"\" } : undefined),\n  });\n}\n\nfunction fixUrls(widgets: ICustomWidget[], baseUrl: string) {\n  // If URLs are relative, make them absolute, interpreting them\n  // relative to the supplied base.\n  for (const widget of widgets) {\n    if (!(url.parse(widget.url).protocol)) {\n      widget.url = new URL(widget.url, baseUrl).href;\n    }\n  }\n}\n\n/**\n * Information about widgets in a plugin. We need to coordinate\n * URLs with location on disk.\n */\nexport interface CustomWidgetsInPlugin {\n  pluginId: string,\n  urlBase: string,\n  dir: string,\n  file: string,\n  name: string,\n}\n\n/**\n * Get a list of widgets available locally via plugins.\n */\nexport function getWidgetsInPlugins(gristServer: GristServer,\n  pluginUrl?: string) {\n  const places: CustomWidgetsInPlugin[] = [];\n  const plugins = gristServer.getPlugins();\n  pluginUrl = pluginUrl ?? gristServer.getPluginUrl();\n  if (pluginUrl === undefined) { return []; }\n  for (const plugin of plugins) {\n    const components = plugin.manifest.components;\n    if (!components.widgets) { continue; }\n    const urlBase =\n      removeTrailingSlash(pluginUrl) + \"/v/\" +\n      gristServer.getTag() + \"/widgets/\" + plugin.id + \"/\";\n    places.push({\n      urlBase,\n      dir: path.resolve(plugin.path, path.dirname(components.widgets)),\n      file: path.join(plugin.path, components.widgets),\n      name: plugin.manifest.name || plugin.id,\n      pluginId: plugin.id,\n    });\n  }\n  return places;\n}\n"
  },
  {
    "path": "app/server/lib/attachEarlyEndpoints.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport {\n  ConfigKey,\n  ConfigKeyChecker,\n  ConfigValue,\n  ConfigValueCheckers,\n} from \"app/common/Config\";\nimport { InstallPrefs } from \"app/common/Install\";\nimport { getOrgKey } from \"app/gen-server/ApiServer\";\nimport { Config } from \"app/gen-server/entity/Config\";\nimport {\n  PreviousAndCurrent,\n  QueryResult,\n} from \"app/gen-server/lib/homedb/Interfaces\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { BootProbes } from \"app/server/lib/BootProbes\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport {\n  getScope,\n  sendOkReply,\n  sendReply,\n  stringParam,\n} from \"app/server/lib/requestUtils\";\nimport { updateGristServerLatestVersion } from \"app/server/lib/updateChecker\";\n\nimport {\n  Application,\n  json,\n  NextFunction,\n  Request,\n  RequestHandler,\n  Response,\n} from \"express\";\nimport pick from \"lodash/pick\";\n\nexport interface AttachOptions {\n  app: Application;\n  gristServer: GristServer;\n  userIdMiddleware: RequestHandler;\n}\n\n/**\n * Attaches endpoints that should be available as early as possible\n * in the Grist startup process.\n *\n * These endpoints comprise a baseline for troubleshooting a faulty\n * installation of Grist. Currently, this includes the landing page\n * for the Admin Panel, and API endpoints for restarting the install,\n * checking the status of various install probes, reading/writing install\n * prefs, and reading/writing install and org configuration.\n *\n * Only the bare minimum middleware needed for these endpoints to function\n * should be added beforehand (e.g. `userIdMiddleware`).\n */\nexport function attachEarlyEndpoints(options: AttachOptions) {\n  const { app, gristServer, userIdMiddleware } = options;\n\n  // Admin endpoint needs to have very little middleware since each\n  // piece of middleware creates a new way to fail and leave the admin\n  // panel inaccessible. Generally the admin panel should report problems\n  // rather than failing entirely.\n  app.get(\n    \"/admin/:subpath(*)?\",\n    userIdMiddleware,\n    expressWrap(async (req, res) => {\n      return gristServer.sendAppPage(req, res, {\n        path: \"app.html\",\n        status: 200,\n        config: { adminControls: gristServer.create.areAdminControlsAvailable() },\n      });\n    }),\n  );\n\n  const requireInstallAdmin = gristServer\n    .getInstallAdmin()\n    .getMiddlewareRequireAdmin();\n\n  const adminMiddleware = [requireInstallAdmin];\n  app.use(\"/api/admin\", adminMiddleware);\n  app.use(\"/api/install\", adminMiddleware);\n\n  const probes = new BootProbes(app, gristServer, \"/api\", adminMiddleware);\n  probes.addEndpoints();\n\n  app.post(\n    \"/api/admin/restart\",\n    expressWrap(async (req, res) => {\n      const mreq = req as RequestWithLogin;\n      const meta = {\n        host: mreq.get(\"host\"),\n        path: mreq.path,\n        email: mreq.user?.loginEmail,\n      };\n      log.rawDebug(`Restart[${mreq.method}] starting:`, meta);\n      res.on(\"finish\", () => {\n        // If we have IPC with parent process (e.g. when running under\n        // Docker) tell the parent that we have a new environment so it\n        // can restart us.\n        log.rawDebug(`Restart[${mreq.method}] finishing:`, meta);\n        if (process.send && process.env.GRIST_RUNNING_UNDER_SUPERVISOR) {\n          log.rawDebug(`Restart[${mreq.method}] requesting supervisor to restart home server:`, meta);\n          process.send({ action: \"restart\" });\n        }\n      });\n      if (!process.env.GRIST_RUNNING_UNDER_SUPERVISOR) {\n        // On the topic of http response codes, thus spake MDN:\n        // \"409: This response is sent when a request conflicts with the current state of the server.\"\n        return res.status(409).send({\n          error:\n            \"Cannot automatically restart the Grist server to enact changes. Please restart server manually.\",\n        });\n      }\n      // We're going down, so we're no longer ready to serve requests.\n      gristServer.setReady(false);\n      return res.status(200).send({ msg: \"ok\" });\n    }),\n  );\n\n  // Restrict this endpoint to install admins.\n  app.get(\n    \"/api/install/prefs\",\n    expressWrap(async (_req, res) => {\n      const prefs = await gristServer.getActivations().getPrefsWithSources();\n      return sendOkReply(null, res, prefs);\n    }),\n  );\n\n  app.patch(\n    \"/api/install/prefs\",\n    json({ limit: \"1mb\" }),\n    expressWrap(async (req, res) => {\n      const prefs = req.body;\n      await gristServer.getActivations().updatePrefs(prefs);\n\n      if ((prefs as InstallPrefs).telemetry) {\n        // Make sure the Telemetry singleton picks up the changes to telemetry preferences.\n        // TODO: if there are multiple home server instances, notify them all of changes to\n        // preferences (via Redis Pub/Sub).\n        await gristServer.getTelemetry().fetchTelemetryPrefs();\n      }\n\n      return res.status(200).send();\n    }),\n  );\n\n  // Retrieves the latest version of the client from Grist SAAS endpoint.\n  app.get(\n    \"/api/install/updates\",\n    expressWrap(async (_req, res) => {\n      try {\n        const updateData = await updateGristServerLatestVersion(gristServer, true);\n        res.json(updateData);\n      } catch (error) {\n        res.status(error.status);\n        if (typeof error.details === \"object\") {\n          res.json(error.details);\n        } else {\n          res.send(error.details);\n        }\n      }\n    }),\n  );\n\n  app.get(\n    \"/api/install/configs/:key\",\n    hasValidConfigKey,\n    expressWrap(async (req, res) => {\n      const key = stringParam(req.params.key, \"key\") as ConfigKey;\n      const configResult = await gristServer\n        .getHomeDBManager()\n        .getInstallConfig(key);\n      const result = pruneConfigAPIResult(configResult);\n      return sendReply(req, res, result);\n    }),\n  );\n\n  app.put(\n    \"/api/install/configs/:key\",\n    json({ limit: \"1mb\", strict: false }),\n    hasValidConfig,\n    expressWrap(async (req, res) => {\n      const key = stringParam(req.params.key, \"key\") as ConfigKey;\n      const value = req.body as ConfigValue;\n      const configResult = await gristServer\n        .getHomeDBManager()\n        .updateInstallConfig(key, value);\n      if (configResult.data) {\n        logCreateOrUpdateConfigEvents(req, configResult.data);\n      }\n      const result = pruneConfigAPIResult(configResult);\n      return sendReply(req, res, result);\n    }),\n  );\n\n  app.delete(\n    \"/api/install/configs/:key\",\n    hasValidConfigKey,\n    expressWrap(async (req, res) => {\n      const key = stringParam(req.params.key, \"key\") as ConfigKey;\n      const { data, ...result } = await gristServer\n        .getHomeDBManager()\n        .deleteInstallConfig(key);\n      if (data) {\n        logDeleteConfigEvents(req, data);\n      }\n      return sendReply(req, res, result);\n    }),\n  );\n\n  app.get(\n    \"/api/orgs/:oid/configs/:key\",\n    hasValidConfigKey,\n    expressWrap(async (req, res) => {\n      const org = getOrgKey(req);\n      const key = stringParam(req.params.key, \"key\") as ConfigKey;\n      const configResult = await gristServer\n        .getHomeDBManager()\n        .getOrgConfig(getScope(req), org, key);\n      const result = pruneConfigAPIResult(configResult);\n      return sendReply(req, res, result);\n    }),\n  );\n\n  app.put(\n    \"/api/orgs/:oid/configs/:key\",\n    json({ limit: \"1mb\", strict: false }),\n    hasValidConfig,\n    expressWrap(async (req, res) => {\n      const key = stringParam(req.params.key, \"key\") as ConfigKey;\n      const org = getOrgKey(req);\n      const value = req.body as ConfigValue;\n      const configResult = await gristServer\n        .getHomeDBManager()\n        .updateOrgConfig(getScope(req), org, key, value);\n      if (configResult.data) {\n        logCreateOrUpdateConfigEvents(req, configResult.data);\n      }\n      const result = pruneConfigAPIResult(configResult);\n      return sendReply(req, res, result);\n    }),\n  );\n\n  app.delete(\n    \"/api/orgs/:oid/configs/:key\",\n    hasValidConfigKey,\n    expressWrap(async (req, res) => {\n      const org = getOrgKey(req);\n      const key = stringParam(req.params.key, \"key\") as ConfigKey;\n      const { data, status } = await gristServer\n        .getHomeDBManager()\n        .deleteOrgConfig(getScope(req), org, key);\n      if (data) {\n        logDeleteConfigEvents(req, data);\n      }\n      return sendReply(req, res, { status });\n    }),\n  );\n\n  function logCreateOrUpdateConfigEvents(\n    req: Request,\n    config: Config | PreviousAndCurrent<Config>,\n  ) {\n    const mreq = req as RequestWithLogin;\n    if (\"previous\" in config) {\n      const { previous, current } = config;\n      gristServer.getAuditLogger().logEvent(mreq, {\n        action: \"config.update\",\n        context: {\n          site: current.org ?\n            pick(current.org, \"id\", \"name\", \"domain\") :\n            undefined,\n        },\n        details: {\n          previous: {\n            config: {\n              ...pick(previous, \"id\", \"key\", \"value\"),\n              site: previous.org ?\n                pick(previous.org, \"id\", \"name\", \"domain\") :\n                undefined,\n            },\n          },\n          current: {\n            config: {\n              ...pick(current, \"id\", \"key\", \"value\"),\n              site: current.org ?\n                pick(current.org, \"id\", \"name\", \"domain\") :\n                undefined,\n            },\n          },\n        },\n      });\n    } else {\n      gristServer.getAuditLogger().logEvent(mreq, {\n        action: \"config.create\",\n        context: {\n          site: config.org ?\n            pick(config.org, \"id\", \"name\", \"domain\") :\n            undefined,\n        },\n        details: {\n          config: {\n            ...pick(config, \"id\", \"key\", \"value\"),\n            site: config.org ?\n              pick(config.org, \"id\", \"name\", \"domain\") :\n              undefined,\n          },\n        },\n      });\n    }\n  }\n\n  function logDeleteConfigEvents(req: Request, config: Config) {\n    gristServer.getAuditLogger().logEvent(req as RequestWithLogin, {\n      action: \"config.delete\",\n      context: {\n        site: config.org ? pick(config.org, \"id\", \"name\", \"domain\") : undefined,\n      },\n      details: {\n        config: {\n          ...pick(config, \"id\", \"key\", \"value\"),\n          site: config.org ?\n            pick(config.org, \"id\", \"name\", \"domain\") :\n            undefined,\n        },\n      },\n    });\n  }\n}\n\nfunction pruneConfigAPIResult(\n  result: QueryResult<Config | PreviousAndCurrent<Config>>,\n) {\n  if (!result.data) {\n    return result as unknown as QueryResult<undefined>;\n  }\n\n  const config = \"previous\" in result.data ? result.data.current : result.data;\n  return {\n    ...result,\n    data: {\n      ...pick(config, \"id\", \"key\", \"value\", \"createdAt\", \"updatedAt\"),\n      ...(config.org ?\n        { org: pick(config.org, \"id\", \"name\", \"domain\") } :\n        undefined),\n    },\n  };\n}\n\nfunction hasValidConfig(req: Request, _res: Response, next: NextFunction) {\n  try {\n    assertValidConfig(req);\n    next();\n  } catch (e) {\n    next(e);\n  }\n}\n\nfunction hasValidConfigKey(req: Request, _res: Response, next: NextFunction) {\n  try {\n    assertValidConfigKey(req);\n    next();\n  } catch (e) {\n    next(e);\n  }\n}\n\nfunction assertValidConfig(req: Request) {\n  assertValidConfigKey(req);\n  const key = stringParam(req.params.key, \"key\") as ConfigKey;\n  try {\n    ConfigValueCheckers[key].check(req.body);\n  } catch (err) {\n    log.warn(\n      `Error during API call to ${req.path}: invalid config value (${String(\n        err,\n      )})`,\n    );\n    throw new ApiError(\"Invalid config value\", 400, { userError: String(err) });\n  }\n}\n\nfunction assertValidConfigKey(req: Request) {\n  try {\n    ConfigKeyChecker.check(req.params.key);\n  } catch (err) {\n    log.warn(\n      `Error during API call to ${req.path}: invalid config key (${String(\n        err,\n      )})`,\n    );\n    throw new ApiError(\"Invalid config key\", 400, { userError: String(err) });\n  }\n}\n"
  },
  {
    "path": "app/server/lib/backupSqliteDatabase.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { IDocStorageManager } from \"app/server/lib/IDocStorageManager\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\nimport { fromCallback } from \"app/server/lib/serverUtils\";\nimport { Backup } from \"app/server/lib/SqliteCommon\";\nimport { SQLiteDB } from \"app/server/lib/SQLiteDB\";\n\nimport * as sqlite3 from \"@gristlabs/sqlite3\";\nimport * as fse from \"fs-extra\";\n\n// This constant controls how many pages of the database we back up in a single step.\n// The larger it is, the faster the backup overall, but the slower each step is.\n// Slower steps result in longer periods when the database is locked, without any\n// opportunity for a waiting client to get in and make a write.\n// The size of a page, as far as sqlite is concerned, is 4096 bytes.\nconst PAGES_TO_BACKUP_PER_STEP = 1024;  // Backup is made in 4MB chunks.\n\n// Between steps of the backup, we pause in case a client is waiting to make a write.\n// The shorter the pause, the greater the odds that the client won't be able to make\n// its write, but the faster the backup will complete.\nconst PAUSE_BETWEEN_BACKUP_STEPS_IN_MS = 10;\n\n/**\n * Make a copy of a sqlite database safely and without locking it for long periods, using the\n * sqlite backup api.\n * @param src: database to copy\n * @param dest: file to which we copy the database\n * @param testProgress: a callback used for test purposes to monitor detailed timing of backup.\n * @param label: a tag to add to log messages\n * @return dest\n */\nexport async function backupSqliteDatabase(mainDb: SQLiteDB | undefined,\n  src: string, dest: string,\n  testProgress?: (e: BackupEvent) => void,\n  label?: string,\n  logMeta: object = {}): Promise<string> {\n  const _log = new LogMethods<null>(\"backupSqliteDatabase: \", () => logMeta);\n  _log.debug(null, `starting copy of ${src} (${label})`);\n  /**\n   * When available, we backup from an sqlite3 interface held by an SQLiteDB\n   * object that is already managing the source (that's mainDb). Otherwise, we will need\n   * to make our own (that's this db).\n   */\n  let db: sqlite3.DatabaseWithBackup | null = null;\n  let success: boolean = false;\n  let maxStepTimeMs: number = 0;\n  let maxNonFinalStepTimeMs: number = 0;\n  let finalStepTimeMs: number = 0;\n  let numSteps: number = 0;\n  let backup: Backup | undefined = undefined;\n  try {\n    // NOTE: fse.remove succeeds also when the file does not exist.\n    await fse.remove(dest);  // Just in case some previous process terminated very badly.\n    // Sqlite will try to open any existing material at this\n    // path prior to overwriting it.\n\n    // Ignore the supplied database connection if already closed.\n    if (mainDb?.isClosed()) {\n      mainDb = undefined;\n    }\n    if (mainDb) {\n      // We'll we working from an already configured SqliteDB interface,\n      // don't need to do anything special.\n      _log.info(null, `copying ${src} (${label}) using source connection`);\n    } else {\n      // We need to open an interface to SQLite.\n      await fromCallback((cb) => { db = new sqlite3.Database(dest, cb) as sqlite3.DatabaseWithBackup; });\n      // Turn off protections that can slow backup steps.  If the app or OS\n      // crashes, the backup may be corrupt.  In Grist use case, if app or OS\n      // crashes, no use will be made of backup, so we're OK.\n      // This sets flags matching the --async option to .backup in the sqlite3\n      // shell program: https://www.sqlite.org/src/info/7b6a605b1883dfcb\n      await fromCallback(cb => db!.exec(\"PRAGMA synchronous=OFF; PRAGMA journal_mode=OFF;\", cb));\n    }\n    if (testProgress) { testProgress({ action: \"open\", phase: \"before\" }); }\n    // If using mainDb, it could close any time we yield and come back.\n    if (mainDb?.isClosed()) { throw new Error(\"source closed\"); }\n    backup = mainDb ? mainDb.backup(dest) : db!.backup(src, \"main\", \"main\", false);\n    if (testProgress) { testProgress({ action: \"open\", phase: \"after\" }); }\n    let remaining: number = -1;\n    let prevError: Error | null = null;\n    let errorMsgTime: number = 0;\n    let restartMsgTime: number = 0;\n    let busyCount: number = 0;\n    for (;;) {\n      // For diagnostic purposes, issue a message if the backup appears to have been\n      // restarted by sqlite.  The symptom of a restart we use is that the number of\n      // pages remaining in the backup increases rather than decreases.  That number\n      // is reported by backup.remaining (after an initial period of where sqlite\n      // doesn't yet know how many pages there are and reports -1).\n      // So as not to spam the log if the user is making a burst of changes, we report\n      // this message at most once a second.\n      // See https://www.sqlite.org/c3ref/backup_finish.html and\n      // https://github.com/mapbox/node-sqlite3/pull/1116 for api details.\n      numSteps++;\n      const stepStart = Date.now();\n      if (remaining >= 0 && backup.remaining > remaining && stepStart - restartMsgTime > 1000) {\n        _log.info(null, `copy of ${src} (${label}) restarted`);\n        restartMsgTime = stepStart;\n        testProgress?.({ action: \"restart\" });\n      }\n      remaining = backup.remaining;\n      testProgress?.({ action: \"step\", phase: \"before\" });\n      let isCompleted: boolean = false;\n      if (mainDb?.isClosed()) { throw new Error(\"source closed\"); }\n      try {\n        isCompleted = Boolean(await fromCallback(cb => backup!.step(PAGES_TO_BACKUP_PER_STEP, cb)));\n      } catch (err) {\n        testProgress?.({ action: \"error\", error: String(err) });\n        if (String(err).match(/SQLITE_BUSY/)) {\n          busyCount++;\n          if (busyCount === 10 && mainDb) {\n            _log.info(null, `pausing (${src} ${label}): serializing backup`);\n            mainDb?.pause();\n          }\n        }\n        if (String(err) !== String(prevError) || Date.now() - errorMsgTime > 1000) {\n          _log.info(null, `error (${src} ${label}): ${err}`);\n          errorMsgTime = Date.now();\n        }\n        prevError = err;\n        if (backup.failed) { throw new Error(`backupSqliteDatabase (${src} ${label}): internal copy failed`); }\n      } finally {\n        const stepTimeMs = Date.now() - stepStart;\n        // Keep track of the longest step taken.\n        if (stepTimeMs > maxStepTimeMs) { maxStepTimeMs = stepTimeMs; }\n        if (isCompleted) {\n          // Keep track of the duration of last step taken, independently.\n          // When backing up using the source connection, the last step does\n          // more than simply copying pages.\n          finalStepTimeMs = stepTimeMs;\n        } else if (stepTimeMs > maxNonFinalStepTimeMs) {\n          // Keep track of the longest step taken that was just copying\n          // pages. Since we bound the number of pages to copy, all else\n          // being equal the timing of these steps should be fairly\n          // consistent, and a long delay is in fact a good sign of problems.\n          maxNonFinalStepTimeMs = stepTimeMs;\n        }\n      }\n      testProgress?.({ action: \"step\", phase: \"after\" });\n      if (isCompleted) {\n        _log.info(null, `copy of ${src} (${label}) completed successfully`);\n        success = true;\n        break;\n      }\n      await delay(PAUSE_BETWEEN_BACKUP_STEPS_IN_MS);\n    }\n  } finally {\n    mainDb?.unpause();\n    if (backup) { await fromCallback(cb => backup!.finish(cb)); }\n    testProgress?.({ action: \"close\", phase: \"before\" });\n    try {\n      if (db) { await fromCallback(cb => db!.close(cb)); }\n    } catch (err) {\n      _log.debug(null, `problem stopping copy of ${src} (${label}): ${err}`);\n    }\n    if (!success) {\n      // Something went wrong, remove backup if it was started.\n      try {\n        // NOTE: fse.remove succeeds also when the file does not exist.\n        await fse.remove(dest);\n      } catch (err) {\n        _log.debug(null, `problem removing copy of ${src} (${label}): ${err}`);\n      }\n    }\n    testProgress?.({ action: \"close\", phase: \"after\" });\n    _log.rawLog(\"debug\", null, `stopped copy of ${src} (${label})`, {\n      finalStepTimeMs,\n      maxStepTimeMs,\n      maxNonFinalStepTimeMs,\n      numSteps,\n    });\n  }\n  return dest;\n}\n\n/**\n * A summary of an event during a backup.  Emitted for test purposes, to check timing.\n */\nexport interface BackupEvent {\n  action: \"step\" | \"close\" | \"open\" | \"restart\" | \"error\";\n  phase?: \"before\" | \"after\";\n  error?: string;\n}\n\n/**\n *\n * Calls an operation with an optional database connection. If a\n * database connection was supplied, and gets closed during the\n * operation, and the operation failed, then we retry the operation,\n * calling a logging function with the error.\n *\n * This is used for making backups, where we use a database connection\n * handled externally if available. We can make the backup with or\n * without that connection, but we should use it if available (so\n * backups can terminate under constant changes made using that\n * connection), which is how we got backed into this awkward retry corner.\n *\n */\nexport async function retryOnClose<T>(db: SQLiteDB | undefined,\n  log: (err: Error) => void,\n  op: () => Promise<T>): Promise<T> {\n  const wasClosed = db?.isClosed();\n  try {\n    return await op();\n  } catch (err) {\n    if (wasClosed || !db?.isClosed()) {\n      throw err;\n    }\n    log(err);\n    return await op();\n  }\n}\n\n/**\n * Make a backup of the given document using either an already\n * open connection, or a fresh one if to existing connection is\n * available. Handle connection availability dropping during\n * the call.\n */\nexport async function backupUsingBestConnection(\n  storageManager: IDocStorageManager,\n  docId: string, options: {\n    log: (err: Error) => void,\n    postfix?: string,\n    output?: string,\n  }) {\n  const postfix = options.postfix ?? \"backup\";\n  const docPath = storageManager.getPath(docId);\n  const outPath = options.output || `${docPath}-${postfix}`;\n  const db = storageManager.getSQLiteDB(docId);\n  return retryOnClose(\n    db,\n    options.log,\n    () => backupSqliteDatabase(db, docPath, outPath, undefined, postfix, { docId }),\n  );\n}\n"
  },
  {
    "path": "app/server/lib/checksumFile.ts",
    "content": "import { BinaryToTextEncoding, createHash, Hash } from \"crypto\";\nimport * as fs from \"fs\";\nimport { Readable, Transform, TransformCallback } from \"node:stream\";\n\n/**\n * Computes hash of the file at the given path, using 'sha1' by default, or any algorithm\n * supported by crypto.createHash().\n */\nexport async function checksumFile(filePath: string, algorithm: string = \"sha1\"): Promise<string> {\n  const stream = fs.createReadStream(filePath);\n  return checksumFileStream(stream, algorithm);\n}\n\nexport async function checksumFileStream(stream: Readable, algorithm: string = \"sha1\"): Promise<string> {\n  const shaSum = createHash(algorithm);\n  try {\n    stream.on(\"data\", data => shaSum.update(data));\n    await new Promise<void>((resolve, reject) => {\n      stream.on(\"end\", resolve);\n      stream.on(\"error\", reject);\n    });\n    return shaSum.digest(\"hex\");\n  } finally {\n    stream.removeAllListeners();      // Isn't strictly necessary.\n  }\n}\n\nexport class HashPassthroughStream extends Transform {\n  private _hash: Hash;\n\n  constructor(algorithm: string = \"sha1\") {\n    super();\n    this._hash = createHash(algorithm);\n  }\n\n  public override _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback) {\n    this._hash.update(chunk, encoding);\n    callback(null, chunk);\n  }\n\n  public getDigest(encoding: BinaryToTextEncoding = \"hex\"): string {\n    if (this.readable) {\n      throw new Error(\"HashPassthroughStream must be closed before getting digest\");\n    }\n    return this._hash.digest(encoding);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/config.ts",
    "content": "import * as fse from \"fs-extra\";\n\n// Export dependencies for stubbing in tests.\nexport const Deps = {\n  readFile: fse.readFileSync,\n  writeFile: fse.writeFile,\n  pathExists: fse.pathExistsSync,\n};\n\n/**\n * Readonly config value - no write access.\n */\nexport interface IReadableConfigValue<T> {\n  get(): T;\n}\n\n/**\n * Writeable config value. Write behaviour is asynchronous and defined by the implementation.\n */\nexport interface IWritableConfigValue<T> extends IReadableConfigValue<T> {\n  set(value: T): Promise<void>;\n}\n\ntype FileContentsValidator<T> = (value: any) => T | null;\n\nexport class MissingConfigFileError extends Error {\n  public name: string = \"MissingConfigFileError\";\n\n  constructor(message: string) {\n    super(message);\n  }\n}\n\nexport class ConfigValidationError extends Error {\n  public name: string = \"ConfigValidationError\";\n\n  constructor(message: string) {\n    super(message);\n  }\n}\n\nexport interface ConfigAccessors<ValueType> {\n  get: () => ValueType,\n  set?: (value: ValueType) => Promise<void>\n}\n\n/**\n * Provides type safe access to an underlying JSON file.\n *\n * Multiple FileConfigs for the same file shouldn't be used, as they risk going out of sync.\n */\nexport class FileConfig<FileContents> {\n  /**\n   * Creates a new type-safe FileConfig, by loading and checking the contents of the file with `validator`.\n   * @param configPath - Path to load.\n   * @param validator - Validates the contents are in the correct format, and converts to the correct type.\n   *  Should throw an error or return null if not valid.\n   */\n  public static create<CreateConfigFileContents>(\n    configPath: string,\n    validator: FileContentsValidator<CreateConfigFileContents>,\n  ): FileConfig<CreateConfigFileContents> {\n    // Start with empty object, as it can be upgraded to a full config.\n    let rawFileContents: any = {};\n\n    if (Deps.pathExists(configPath)) {\n      rawFileContents = JSON.parse(Deps.readFile(configPath, \"utf8\"));\n    }\n\n    let fileContents = null;\n\n    try {\n      fileContents = validator(rawFileContents);\n    } catch (error) {\n      const configError =\n        new ConfigValidationError(`Config at ${configPath} failed validation: ${error.message}`);\n      configError.cause = error;\n      throw configError;\n    }\n\n    if (!fileContents) {\n      throw new ConfigValidationError(`Config at ${configPath} failed validation - check the format?`);\n    }\n\n    return new FileConfig<CreateConfigFileContents>(configPath, fileContents);\n  }\n\n  constructor(private _filePath: string, private _rawConfig: FileContents) {\n  }\n\n  public get<Key extends keyof FileContents>(key: Key): FileContents[Key] {\n    return this._rawConfig[key];\n  }\n\n  public async set<Key extends keyof FileContents>(key: Key, value: FileContents[Key]) {\n    this._rawConfig[key] = value;\n    await this.persistToDisk();\n  }\n\n  public async persistToDisk() {\n    await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2) + \"\\n\");\n  }\n}\n\n/**\n * Creates a function for creating accessors for a given key.\n * Propagates undefined values, so if no file config is available, accessors are undefined.\n * @param fileConfig - Config to load/save values to.\n */\nexport function fileConfigAccessorFactory<FileContents>(\n  fileConfig?: FileConfig<FileContents>,\n): <Key extends keyof FileContents>(key: Key) => ConfigAccessors<FileContents[Key]> | undefined {\n  if (!fileConfig) { return key => undefined; }\n  return key => ({\n    get: () => fileConfig.get(key),\n    set: value => fileConfig.set(key, value),\n  });\n}\n\n/**\n * Creates a config value optionally backed by persistent storage.\n * Can be used as an in-memory value without persistent storage.\n * @param defaultValue - Value to use if no persistent value is available.\n * @param persistence - Accessors for saving/loading persistent value.\n */\nexport function createConfigValue<ValueType>(\n  defaultValue: ValueType,\n  persistence?: ConfigAccessors<ValueType> | ConfigAccessors<ValueType | undefined>,\n): IWritableConfigValue<ValueType> {\n  let inMemoryValue = (persistence?.get());\n  return {\n    get(): ValueType {\n      return inMemoryValue ?? defaultValue;\n    },\n    async set(value: ValueType) {\n      if (persistence?.set) {\n        await persistence.set(value);\n      }\n      inMemoryValue = value;\n    },\n  };\n}\n"
  },
  {
    "path": "app/server/lib/configCore.ts",
    "content": "import { isAffirmative } from \"app/common/gutil\";\nimport {\n  createConfigValue,\n  FileConfig,\n  fileConfigAccessorFactory,\n  IWritableConfigValue,\n} from \"app/server/lib/config\";\nimport { convertToCoreFileContents, IGristCoreConfigFileLatest } from \"app/server/lib/configCoreFileFormats\";\n\nexport type Edition = \"core\" | \"enterprise\";\n\n/**\n * Config options for Grist Core.\n */\nexport interface IGristCoreConfig {\n  edition: IWritableConfigValue<Edition>;\n}\n\nexport function loadGristCoreConfigFile(configPath?: string): IGristCoreConfig {\n  const fileConfig = configPath ? FileConfig.create(configPath, convertToCoreFileContents) : undefined;\n  return loadGristCoreConfig(fileConfig);\n}\n\nexport function loadGristCoreConfig(fileConfig?: FileConfig<IGristCoreConfigFileLatest>): IGristCoreConfig {\n  const fileConfigValue = fileConfigAccessorFactory(fileConfig);\n  return {\n    edition: createConfigValue<Edition>(\n      isAffirmative(process.env.GRIST_FORCE_ENABLE_ENTERPRISE) ? \"enterprise\" : \"core\",\n      fileConfigValue(\"edition\"),\n    ),\n  };\n}\n"
  },
  {
    "path": "app/server/lib/configCoreFileFormats-ti.ts",
    "content": "/**\n * This module was automatically generated by `ts-interface-builder`\n */\nimport * as t from \"ts-interface-checker\";\n// tslint:disable:object-literal-key-quotes\n\nexport const IGristCoreConfigFileLatest = t.name(\"IGristCoreConfigFileV1\");\n\nexport const IGristCoreConfigFileV1 = t.iface([], {\n  \"version\": t.lit(\"1\"),\n  \"edition\": t.opt(t.union(t.lit(\"core\"), t.lit(\"enterprise\"))),\n});\n\nexport const IGristCoreConfigFileV0 = t.iface([], {\n  \"version\": \"undefined\",\n});\n\nconst exportedTypeSuite: t.ITypeSuite = {\n  IGristCoreConfigFileLatest,\n  IGristCoreConfigFileV1,\n  IGristCoreConfigFileV0,\n};\nexport default exportedTypeSuite;\n"
  },
  {
    "path": "app/server/lib/configCoreFileFormats.ts",
    "content": "import configCoreTI from \"app/server/lib/configCoreFileFormats-ti\";\n\nimport { CheckerT, createCheckers } from \"ts-interface-checker\";\n\n/**\n * Latest core config file format\n */\nexport type IGristCoreConfigFileLatest = IGristCoreConfigFileV1;\n\n/**\n * Format of config files on disk - V1\n */\nexport interface IGristCoreConfigFileV1 {\n  version: \"1\"\n  edition?: \"core\" | \"enterprise\"\n}\n\n/**\n * Format of config files on disk - V0\n */\nexport interface IGristCoreConfigFileV0 {\n  version: undefined;\n}\n\nexport const checkers = createCheckers(configCoreTI) as\n  {\n    IGristCoreConfigFileV0: CheckerT<IGristCoreConfigFileV0>,\n    IGristCoreConfigFileV1: CheckerT<IGristCoreConfigFileV1>,\n    IGristCoreConfigFileLatest: CheckerT<IGristCoreConfigFileLatest>,\n  };\n\nfunction upgradeV0toV1(config: IGristCoreConfigFileV0): IGristCoreConfigFileV1 {\n  return {\n    ...config,\n    version: \"1\",\n  };\n}\n\nexport function convertToCoreFileContents(input: any): IGristCoreConfigFileLatest | null {\n  if (!(input instanceof Object)) {\n    return null;\n  }\n\n  let configObject = { ...input };\n\n  if (checkers.IGristCoreConfigFileV0.test(configObject)) {\n    configObject = upgradeV0toV1(configObject);\n  }\n\n  // This will throw an exception if the config object is still not in the correct format.\n  checkers.IGristCoreConfigFileLatest.check(configObject);\n\n  return configObject;\n}\n"
  },
  {
    "path": "app/server/lib/configureMinIOExternalStorage.ts",
    "content": "import { appSettings } from \"app/server/lib/AppSettings\";\nimport { wrapWithKeyMappedStorage } from \"app/server/lib/ExternalStorage\";\nimport { MinIOExternalStorage } from \"app/server/lib/MinIOExternalStorage\";\n\nexport function configureMinIOExternalStorage(purpose: \"doc\" | \"meta\" | \"attachments\", extraPrefix: string) {\n  const options = checkMinIOExternalStorage();\n  if (!options?.bucket) { return undefined; }\n  return wrapWithKeyMappedStorage(new MinIOExternalStorage(options.bucket, options), {\n    basePrefix: options.prefix,\n    extraPrefix,\n    purpose,\n  });\n}\n\nexport function checkMinIOExternalStorage() {\n  const settings = appSettings.section(\"externalStorage\").section(\"minio\");\n  const bucket = settings.flag(\"bucket\").readString({\n    envVar: [\"GRIST_DOCS_MINIO_BUCKET\", \"TEST_MINIO_BUCKET\"],\n    preferredEnvVar: \"GRIST_DOCS_MINIO_BUCKET\",\n  });\n  if (!bucket) { return undefined; }\n  const region = settings.flag(\"bucketRegion\").requireString({\n    envVar: [\"GRIST_DOCS_MINIO_BUCKET_REGION\"],\n    preferredEnvVar: \"GRIST_DOCS_MINIO_BUCKET_REGION\",\n    defaultValue: \"us-east-1\",\n  });\n  const prefix = settings.flag(\"prefix\").requireString({\n    envVar: [\"GRIST_DOCS_MINIO_PREFIX\"],\n    preferredEnvVar: \"GRIST_DOCS_MINIO_PREFIX\",\n    defaultValue: \"docs/\",\n  });\n  const endPoint = settings.flag(\"endpoint\").requireString({\n    envVar: [\"GRIST_DOCS_MINIO_ENDPOINT\"],\n    preferredEnvVar: \"GRIST_DOCS_MINIO_ENDPOINT\",\n  });\n  const port = settings.flag(\"port\").read({\n    envVar: [\"GRIST_DOCS_MINIO_PORT\"],\n    preferredEnvVar: \"GRIST_DOCS_MINIO_PORT\",\n  }).getAsInt();\n  const useSSL = settings.flag(\"useSsl\").read({\n    envVar: [\"GRIST_DOCS_MINIO_USE_SSL\"],\n  }).getAsBool();\n  const accessKey = settings.flag(\"accessKey\").requireString({\n    envVar: [\"GRIST_DOCS_MINIO_ACCESS_KEY\"],\n    censor: true,\n  });\n  const secretKey = settings.flag(\"secretKey\").requireString({\n    envVar: [\"GRIST_DOCS_MINIO_SECRET_KEY\"],\n    censor: true,\n  });\n  settings.flag(\"url\").set(`minio://${bucket}/${prefix}`);\n  settings.flag(\"active\").set(true);\n  return {\n    endPoint,\n    port,\n    bucket, prefix,\n    useSSL,\n    accessKey,\n    secretKey,\n    region,\n  };\n}\n\nexport async function checkMinIOBucket() {\n  const options = checkMinIOExternalStorage();\n  if (!options) {\n    throw new Error(\"Configuration check failed for MinIO backend storage.\");\n  }\n\n  const externalStorage = new MinIOExternalStorage(options.bucket, options);\n  if (!await externalStorage.hasVersioning()) {\n    await externalStorage.close();\n    throw new Error(`FATAL: the MinIO bucket \"${options.bucket}\" does not have versioning enabled`);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/configureOpenAIAssistantV1.ts",
    "content": "import { getAssistantV1Options } from \"app/server/lib/Assistant\";\nimport { AssistantV1 } from \"app/server/lib/IAssistant\";\nimport { EchoAssistantV1, OpenAIAssistantV1 } from \"app/server/lib/OpenAIAssistantV1\";\n\nexport function configureOpenAIAssistantV1(): AssistantV1 | undefined {\n  const options = getAssistantV1Options();\n  if (!options.apiKey && !options.completionEndpoint) {\n    return undefined;\n  } else if (options.apiKey === \"test\") {\n    return new EchoAssistantV1();\n  } else {\n    return new OpenAIAssistantV1(options);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/cookieUtils.ts",
    "content": "import { safeJsonParse } from \"app/common/gutil\";\nimport { getCookieDomain } from \"app/server/lib/gristSessions\";\n\nimport * as cookie from \"cookie\";\nimport * as express from \"express\";\n\ninterface SignupState {\n  srcDocId?: string;\n  assistantState?: string;\n}\n\nconst SIGNUP_STATE_COOKIE_NAME = \"gr_signup_state\";\n\nexport function getAndClearSignupStateCookie(\n  req: express.Request,\n  res: express.Response,\n): SignupState | undefined {\n  const cookies = cookie.parse(req.headers.cookie ?? \"\");\n  const signupState = cookies[SIGNUP_STATE_COOKIE_NAME];\n  if (!signupState) {\n    return undefined;\n  }\n\n  clearSignupStateCookie(req, res);\n\n  return safeJsonParse(signupState, {});\n}\n\nexport function setSignupStateCookie(\n  req: express.Request,\n  res: express.Response,\n  state: SignupState,\n) {\n  res.cookie(\n    SIGNUP_STATE_COOKIE_NAME,\n    JSON.stringify(state),\n    getSignupStateCookieOptions(req),\n  );\n}\n\nfunction clearSignupStateCookie(req: express.Request, res: express.Response) {\n  res.clearCookie(SIGNUP_STATE_COOKIE_NAME, getSignupStateCookieOptions(req));\n}\n\nfunction getSignupStateCookieOptions(\n  req: express.Request,\n): express.CookieOptions {\n  return {\n    maxAge: 1000 * 60 * 60,\n    httpOnly: true,\n    path: \"/\",\n    domain: getCookieDomain(req),\n    sameSite: \"lax\",\n  };\n}\n"
  },
  {
    "path": "app/server/lib/coreCreator.ts",
    "content": "import { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport {\n  checkMinIOBucket,\n  checkMinIOExternalStorage,\n  configureMinIOExternalStorage,\n} from \"app/server/lib/configureMinIOExternalStorage\";\nimport { configureOpenAIAssistantV1 } from \"app/server/lib/configureOpenAIAssistantV1\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { BaseCreate, ICreateStorageOptions } from \"app/server/lib/ICreate\";\nimport { Telemetry } from \"app/server/lib/Telemetry\";\n\nexport class CoreCreate extends BaseCreate {\n  constructor() {\n    const storage: ICreateStorageOptions[] = [\n      {\n        name: \"minio\",\n        check: () => checkMinIOExternalStorage() !== undefined,\n        checkBackend: () => checkMinIOBucket(),\n        create: configureMinIOExternalStorage,\n      },\n    ];\n    super(\"core\", storage);\n  }\n\n  public override Telemetry(dbManager: HomeDBManager, gristServer: GristServer) {\n    return new Telemetry(dbManager, gristServer);\n  }\n\n  public override Assistant() {\n    return configureOpenAIAssistantV1();\n  }\n}\n"
  },
  {
    "path": "app/server/lib/coreLogins.ts",
    "content": "import {\n  FORWARD_AUTH_PROVIDER_KEY,\n  GETGRIST_COM_PROVIDER_KEY,\n  OIDC_PROVIDER_KEY,\n  SAML_PROVIDER_KEY,\n} from \"app/common/loginProviders\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { getForwardAuthLoginSystem, readForwardAuthConfigFromSettings } from \"app/server/lib/ForwardAuthLogin\";\nimport {\n  getGetGristComLoginSystem,\n  readGetGristComConfigFromSettings,\n  readGetGristComMetadata,\n} from \"app/server/lib/GetGristComConfig\";\nimport { GristLoginSystem } from \"app/server/lib/GristServer\";\nimport { LoginSystemConfig } from \"app/server/lib/LoginSystemConfig\";\nimport { getMinimalLoginSystem } from \"app/server/lib/MinimalLogin\";\nimport { getOIDCLoginSystem, readOIDCConfigFromSettings } from \"app/server/lib/OIDCConfig\";\nimport { readSamlConfigFromSettings } from \"app/server/lib/SamlConfig\";\nimport { getSamlLoginSystem } from \"app/server/lib/SamlConfig\";\n\nexport async function getCoreLoginSystem(): Promise<GristLoginSystem> {\n  const builders = LOGIN_SYSTEMS.map(ls => ls.builder);\n  for (const builder of builders) {\n    const loginSystem = await builder(appSettings);\n    if (loginSystem) {\n      return loginSystem;\n    }\n  }\n  // Fallback to minimal login system if none configured.\n  return await getMinimalLoginSystem(appSettings);\n}\n\n/**\n * List of supported login systems along with their configuration readers in order of preference.\n */\nexport const LOGIN_SYSTEMS: LoginSystemConfig[] = [\n  { key: GETGRIST_COM_PROVIDER_KEY,\n    name: \"Sign in with getgrist.com\",\n    reader: readGetGristComConfigFromSettings,\n    builder: getGetGristComLoginSystem,\n    metadataReader: readGetGristComMetadata,\n  },\n  { key: OIDC_PROVIDER_KEY,\n    name: \"OIDC\",\n    reader: readOIDCConfigFromSettings,\n    builder: getOIDCLoginSystem,\n  },\n  { key: SAML_PROVIDER_KEY,\n    name: \"SAML\",\n    reader: readSamlConfigFromSettings,\n    builder: getSamlLoginSystem,\n  },\n  { key: FORWARD_AUTH_PROVIDER_KEY,\n    name: \"Forwarded headers\",\n    reader: readForwardAuthConfigFromSettings,\n    builder: getForwardAuthLoginSystem,\n  },\n];\n"
  },
  {
    "path": "app/server/lib/createSavedDoc.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { localeCompare } from \"app/common/gutil\";\nimport { getTransitiveHeaders, getUserId } from \"app/server/lib/Authorizer\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { getScope } from \"app/server/lib/requestUtils\";\n\nimport * as express from \"express\";\n\n/**\n * Creates a new document in a workspace on the user's personal site.\n *\n * The workspace is chosen automatically, and is the first workspace\n * in alphabetical order that's owned by the user, mirroring the behavior\n * of creating a document in the client.\n *\n * If `options.srcDocId` is specified, the created document will be a copy\n * of that document. Otherwise, a blank document will be created.\n */\nexport async function createSavedDoc(\n  server: GristServer,\n  req: express.Request,\n  options: { srcDocId?: string } = {},\n): Promise<string> {\n  const { srcDocId } = options;\n  const dbManager = server.getHomeDBManager();\n  const userId = getUserId(req);\n  const doc = srcDocId ?\n    await dbManager.getDoc({ userId, urlId: srcDocId }) :\n    undefined;\n  if (srcDocId && !doc) {\n    throw new ApiError(`Doc ${srcDocId} not found`, 400);\n  }\n\n  const workspacesQueryResult = await dbManager.getOrgWorkspaces(\n    getScope(req),\n    0,\n  );\n  const workspaces = dbManager.unwrapQueryResult(workspacesQueryResult);\n  const userWorkspaces = workspaces\n    .filter(w => !w.isSupportWorkspace && w.owner?.id === userId)\n    .sort((a, b) => localeCompare(a.name, b.name));\n  if (userWorkspaces.length === 0) {\n    throw new ApiError(\n      `User ${userId} has no workspaces in their personal site`,\n      500,\n    );\n  }\n\n  const [workspace] = userWorkspaces;\n  const createDocUrl = server.getHomeInternalUrl(\"/api/docs\");\n  const response = await fetch(createDocUrl, {\n    headers: {\n      ...getTransitiveHeaders(req, { includeOrigin: false }),\n      \"Content-Type\": \"application/json\",\n    },\n    method: \"POST\",\n    body: JSON.stringify({\n      sourceDocumentId: doc?.id,\n      workspaceId: workspace.id,\n      documentName: doc?.name,\n    }),\n  });\n  const body = await response.json();\n  if (!response.ok) {\n    throw new ApiError(\n      `Unable to create document in workspace ${workspace.name}`,\n      response.status,\n      body,\n    );\n  }\n\n  return body;\n}\n"
  },
  {
    "path": "app/server/lib/dbUtils.ts",
    "content": "import { synchronizeProducts } from \"app/gen-server/entity/Product\";\nimport { codeRoot } from \"app/server/lib/places\";\n\nimport { Mutex } from \"async-mutex\";\nimport { DatabaseType, DataSource, DataSourceOptions } from \"typeorm\";\n\n// Summary of migrations found in database and in code.\ninterface MigrationSummary {\n  migrationsInDb: string[];\n  migrationsInCode: string[];\n  pendingMigrations: string[];\n}\n\n// Find the migrations in the database, the migrations in the codebase, and compare the two.\nexport async function getMigrations(dataSource: DataSource): Promise<MigrationSummary> {\n  let migrationsInDb: string[];\n  try {\n    migrationsInDb = (await dataSource.query(\"select name from migrations\")).map((rec: any) => rec.name);\n  } catch (e) {\n    // If no migrations have run, there'll be no migrations table - which is fine,\n    // it just means 0 migrations run yet.  Sqlite+Postgres report this differently,\n    // so any query error that mentions the name of our table is treated as ok.\n    // Everything else is unexpected.\n    if (!(e.name === \"QueryFailedError\" && e.message.includes(\"migrations\"))) {\n      throw e;\n    }\n    migrationsInDb = [];\n  }\n  // get the migration names in codebase.\n  // They are a bit hidden, see typeorm/src/migration/MigrationExecutor::getMigrations\n  const migrationsInCode: string[] = dataSource.migrations.map(m => (m.constructor as any).name);\n  const pendingMigrations = migrationsInCode.filter(m => !migrationsInDb.includes(m));\n  return {\n    migrationsInDb,\n    migrationsInCode,\n    pendingMigrations,\n  };\n}\n\n/**\n * Run any needed migrations, and make sure products are up to date.\n */\nexport async function updateDb(dataSource?: DataSource) {\n  dataSource = dataSource || await getOrCreateConnection();\n  await runMigrations(dataSource);\n  await synchronizeProducts(dataSource, true);\n}\n\nexport function getConnectionName() {\n  return process.env.TYPEORM_NAME || \"default\";\n}\n\n/**\n * Get a connection to db if one exists, or create one. Serialized to\n * avoid duplication.\n */\nlet gristDataSource: DataSource | null = null;\nconst connectionMutex = new Mutex();\nexport async function getOrCreateConnection(): Promise<DataSource> {\n  return connectionMutex.runExclusive(async () => {\n    // If multiple servers are started within the same process, we\n    // share the database connection.  This saves locking trouble\n    // with Sqlite.\n    if (!gristDataSource?.isInitialized) {\n      let settings = getTypeORMSettings();\n      if (settings.type === \"postgres\") {\n        // We're having problems with the Postgres JIT compiler slowing\n        // down a particular query, so we'll turn it off for this\n        // session.\n        //\n        // If some day Postgres's JIT compiler gets smarter and has a\n        // better cost function that knows it's a bad idea to compile\n        // certain queries, we might then want to revisit this\n        // workaround and remove it.\n        //\n        // General JIT documentation in Postgres, including other more\n        // fine-tuned possible configuratin options to consider in the\n        // future:\n        //\n        //   https://www.postgresql.org/docs/current/jit.html\n        //\n        // Note that this passes options valid for the duration of the\n        // session (i.e. the connection) into libpq via PGOPTIONS:\n        //\n        //  https://www.postgresql.org/docs/current/config-setting.html#CONFIG-SETTING-SHELL\n        settings = getTypeORMSettings({ extra: { options: \"-c jit=off\" } });\n      }\n\n      gristDataSource = new DataSource(settings);\n      await gristDataSource.initialize();\n      if (settings.type === \"sqlite\") {\n        // When using Sqlite, set a busy timeout of 3s to tolerate a little\n        // interference from connections made by tests. Logging doesn't show\n        // any particularly slow queries, but bad luck is possible.\n        // This doesn't affect when Postgres is in use. It also doesn't have\n        // any impact when there is a single connection to the db, as is the\n        // case when Grist is run as a single process.\n        await gristDataSource.query(\"PRAGMA busy_timeout = 3000\");\n      }\n    }\n    return gristDataSource;\n  });\n}\n\nexport async function runMigrations(dataSource: DataSource) {\n  return await withSqliteForeignKeyConstraintDisabled(dataSource, async () => {\n    await dataSource.runMigrations({ transaction: \"all\" });\n  });\n}\n\nexport async function undoLastMigration(dataSource: DataSource) {\n  return await withSqliteForeignKeyConstraintDisabled(dataSource, async () => {\n    await dataSource.transaction(async (tr) => {\n      await tr.connection.undoLastMigration();\n    });\n  });\n}\n\n// on SQLite, migrations fail if we don't temporarily disable foreign key\n// constraint checking.  This is because for sqlite typeorm copies each\n// table and rebuilds it from scratch for each schema change.\n// Also, we need to disable foreign key constraint checking outside of any\n// transaction, or it has no effect.\nexport async function withSqliteForeignKeyConstraintDisabled<T>(\n  dataSource: DataSource, cb: () => Promise<T>,\n): Promise<T> {\n  const sqlite = getDatabaseType(dataSource) === \"sqlite\";\n  if (sqlite) { await dataSource.query(\"PRAGMA foreign_keys = OFF;\"); }\n  try {\n    return await cb();\n  } finally {\n    if (sqlite) { await dataSource.query(\"PRAGMA foreign_keys = ON;\"); }\n  }\n}\n\nexport function getDatabaseType(dataSource: DataSource): DatabaseType {\n  return dataSource.driver.options.type;\n}\n\n// Replace the old janky ormconfig.js file, which was always a source of\n// pain to use since it wasn't properly integrated into the typescript\n// project.\nexport function getTypeORMSettings(overrideConf?: Partial<DataSourceOptions>): DataSourceOptions {\n  // If we have a redis server available, tell typeorm.  Then any queries built with\n  // .cache() called on them will be cached via redis.\n  // We use a separate environment variable for the moment so that we don't have to\n  // enable this until we really need it.\n  const redisUrl = process.env.TYPEORM_REDIS_URL ? new URL(process.env.TYPEORM_REDIS_URL) : undefined;\n  const cache = redisUrl ? {\n    cache: {\n      type: \"redis\",\n      options: {\n        host: redisUrl.hostname,\n        port: parseInt(redisUrl.port || \"6379\", 10),\n      },\n    } as const,\n  } : undefined;\n\n  return {\n    name: getConnectionName(),\n    type: (process.env.TYPEORM_TYPE as any) || \"sqlite\",  // officially, TYPEORM_CONNECTION -\n    // but if we use that, this file will never\n    // be read, and we can't configure\n    // caching otherwise.\n    database: process.env.TYPEORM_DATABASE || \"landing.db\",\n    username: process.env.TYPEORM_USERNAME || undefined,\n    password: process.env.TYPEORM_PASSWORD || undefined,\n    host: process.env.TYPEORM_HOST || undefined,\n    port: process.env.TYPEORM_PORT ? parseInt(process.env.TYPEORM_PORT, 10) : undefined,\n    synchronize: false,\n    migrationsRun: false,\n    logging: process.env.TYPEORM_LOGGING === \"true\",\n    maxQueryExecutionTime: process.env.TYPEORM_LOG_SLOW_MS ? parseInt(process.env.TYPEORM_LOG_SLOW_MS) : undefined,\n    entities: [\n      `${codeRoot}/app/gen-server/entity/*.js`,\n    ],\n    migrations: [\n      `${codeRoot}/app/gen-server/migration/*.js`,        // migration files don't actually get packaged.\n    ],\n    subscribers: [\n      `${codeRoot}/app/gen-server/subscriber/*.js`,\n    ],\n    ...cache,\n    ...overrideConf,\n    ...JSON.parse(process.env.TYPEORM_EXTRA || \"{}\"),\n  };\n}\n"
  },
  {
    "path": "app/server/lib/describeDocActions.ts",
    "content": "import { DocAction, getTableId } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { isMetadataTable } from \"app/common/isHiddenTable\";\nimport { SchemaTypes } from \"app/common/schema\";\n\nexport interface DocActionsDescription {\n  userTableNames: string[];\n  categories: DocActionCategory[];\n}\n\n// These both define categories, and determine the order we'll report them in.\nconst allCategories = [\n  \"metadata\",     // catch-all for unknown stuff\n  \"settings\",\n  \"structure\",\n  \"layouts\",\n  \"forms\",\n  \"webhooks\",\n  \"access rules\",\n  \"user attributes\",\n] as const;\n\n// This becomes the union of strings, i.e. \"metadata\"|\"settings\"|etc.\nexport type DocActionCategory = typeof allCategories[number];\n\n/**\n * Turns a list of doc actions into an object to describe them, intended for notifications.\n * The description includes a list of user tables, translated to their friendlier names,\n * and a list of categories for metadata tables.\n *\n * Because the intent is notifications, changes to the _grist_Cells table are ignored, since they\n * mean comments, and comments have their own configuration for notifications, so it's clearer to\n * exclude them from docChanges.\n */\nexport function describeDocActions(docActions: DocAction[], docData: DocData): DocActionsDescription | null {\n  if (docActions.length === 0) { return null; }\n  const userTableNameSet = new Set<string>();\n  const categorySet = new Set<DocActionCategory>();\n  for (const action of docActions) {\n    const tableId = getTableId(action);\n    if (!isMetadataTable(tableId)) {\n      userTableNameSet.add(getTableName(tableId, docData) || tableId);\n    } else {\n      const category = categoryMap[tableId as keyof SchemaTypes] || \"metadata\";\n      if (category === IGNORE) { continue; }\n      categorySet.add(category);\n    }\n  }\n  if (userTableNameSet.size === 0 && categorySet.size === 0) { return null; }\n  return { userTableNames: [...userTableNameSet], categories: [...categorySet] };\n}\n\n/**\n * Sort categories in a consistent order, following the order of allCategories.\n */\nexport function sortDocActionCategories(categories: Set<DocActionCategory>): DocActionCategory[] {\n  return allCategories.filter(c => categories.has(c));\n}\n\n// A sentinel value for tables that shouldn't get reported.\nconst IGNORE = Symbol(\"ignore\");\n\nconst categoryMap: { [tableId in keyof SchemaTypes]: DocActionCategory | typeof IGNORE | null } = {\n  _grist_DocInfo: \"settings\",\n  _grist_Tables: \"structure\",\n  _grist_Tables_column: \"structure\",\n  _grist_Imports: null,                   // deprecated (will fall back to \"metadata\")\n  _grist_External_database: null,         // deprecated\n  _grist_External_table: null,            // deprecated\n  _grist_TableViews: null,                // deprecated\n  _grist_TabItems: null,                  // deprecated\n  _grist_TabBar: \"layouts\",\n  _grist_Pages: \"layouts\",\n  _grist_Views: \"layouts\",\n  _grist_Views_section: \"layouts\",\n  _grist_Views_section_field: \"layouts\",\n  _grist_Validations: null,               // deprecated\n  _grist_REPL_Hist: null,                 // deprecated\n  _grist_Attachments: IGNORE,            // accompanied by a user table change, or only reflects cleanup\n  _grist_Triggers: \"webhooks\",\n  _grist_ACLRules: \"access rules\",\n  _grist_ACLResources: \"access rules\",\n  _grist_ACLPrincipals: null,             // deprecated\n  _grist_ACLMemberships: null,            // deprecated\n  _grist_Filters: \"layouts\",\n  _grist_Cells: IGNORE,                  // ignore comments for the purpose of notifications.\n  _grist_Shares: \"forms\",\n};\n\nfunction getTableName(tableId: string, docData: DocData) {\n  const tableRec = docData.getMetaTable(\"_grist_Tables\").findRecord(\"tableId\", tableId);\n  const vsRec = tableRec && docData.getMetaTable(\"_grist_Views_section\").getRecord(tableRec.rawViewSectionRef);\n  return vsRec?.title;\n}\n"
  },
  {
    "path": "app/server/lib/docUtils.d.ts",
    "content": "export function makeIdentifier(name: string): string;\nexport function copyFile(src: string, dest: string): Promise<void>;\nexport function createNumbered(name: string, separator: string, creator: (path: string) => Promise<void>,\n  startNum?: number): Promise<string>;\nexport function createNumberedTemplate(template: string, creator: (path: string) => Promise<void>): Promise<string>;\nexport function createExclusive(path: string): Promise<void>;\nexport function realPath(path: string): Promise<string>;\nexport function pathExists(path: string): Promise<boolean>;\nexport function isSameFile(path1: string, path2: string): Promise<boolean>;\n"
  },
  {
    "path": "app/server/lib/docUtils.js",
    "content": "/**\n * Functions generally useful when dealing with Grist documents.\n */\n\n\n\nvar fs = require(\"fs\");\nvar fsPath = require(\"path\");\nvar Promise = require(\"bluebird\");\nPromise.promisifyAll(fs);\n\nvar nonIdentRegex = /[^\\w_]+/g;\n\n/**\n * Given a string, converts it to a Grist identifier. Identifiers consist of lowercase\n * alphanumeric characters and the underscore.\n * @param {String} name The name to convert.\n * @returns {String} Identifier.\n */\nfunction makeIdentifier(name) {\n  // Lowercase and replace consecutive invalid characters with underscores.\n  return name.toLowerCase().replace(nonIdentRegex, \"_\");\n}\nexports.makeIdentifier = makeIdentifier;\n\n\n/**\n * Copies a file, returning a promise that is resolved (with no value) when the copy is complete.\n * TODO This needs a unittest.\n */\nfunction copyFile(sourcePath, destPath) {\n  var sourceStream, destStream;\n  return new Promise(function(resolve, reject) {\n    sourceStream = fs.createReadStream(sourcePath);\n    destStream = fs.createWriteStream(destPath);\n\n    sourceStream.on(\"error\", reject);\n    destStream.on(\"error\", reject);\n    destStream.on(\"finish\", resolve);\n\n    sourceStream.pipe(destStream);\n  })\n    .finally(function() {\n      if (destStream) { destStream.destroy(); }\n      if (sourceStream) { sourceStream.destroy(); }\n    });\n}\nexports.copyFile = copyFile;\n\n\n/**\n * Helper for creating numbered files. Tries to call creator() with name, then (name + separator +\n * \"2\") and so on with incrementing numbers, as long as the promise returned by creator() is\n * rejected with err.code of 'EEXIST'. Creator() must return a promise.\n * @param {String} name The first name to try.\n * @param {String} separator The separator between name and appended numbers.\n * @param {Function} creator The function to call with successive names. Must return a promise.\n * @param {Number} startNum Optional number to start with; omit to try an unnumbered name first.\n * @returns {Promise} Promise for the first name for which creator() succeeded.\n */\nfunction createNumbered(name, separator, creator, startNum) {\n  var fullName = name + (startNum === undefined ? \"\" : separator + startNum);\n  var nextNum = (startNum === undefined ? 2 : startNum + 1);\n  return creator(fullName)\n    .then(() => fullName)\n    .catch(function(err) {\n      if (err.cause && err.cause.code !== \"EEXIST\")\n        throw err;\n      return createNumbered(name, separator, creator, nextNum);\n    });\n}\nexports.createNumbered = createNumbered;\n\n/**\n * An easier-to-use alternative to createNumbered. Pass in a template string containing the\n * special token \"{NUM}\". It will first call creator() with \"{NUM}\" removed, then with \"{NUM}\"\n * replaced by \"-2\", \"-3\", etc, until creator() succeeds, and will return the value for which it\n * succeeded.\n */\nfunction createNumberedTemplate(template, creator) {\n  const [prefix, suffix] = template.split(\"{NUM}\");\n  if (typeof prefix !== \"string\" || typeof suffix !== \"string\") {\n    throw new Error(`createNumberedTemplate: invalid template ${template}`);\n  }\n  return createNumbered(prefix, \"-\", (uniqPrefix) => creator(uniqPrefix + suffix))\n    .then((uniqPrefix) => uniqPrefix + suffix);\n}\nexports.createNumberedTemplate = createNumberedTemplate;\n\n/**\n * Creates a new file, failing if the path already exists.\n * @param {String} path: The path to try creating.\n * @returns {Promise} Resolved if the path was created, rejected if it already existed (with\n *      err.cause.code === EEXIST) or if there was another error creating it.\n */\nfunction createExclusive(path) {\n  return fs.openAsync(path, \"wx\").then(fd => fs.closeAsync(fd));\n}\nexports.createExclusive = createExclusive;\n\n\n/**\n * Returns the canonicalized absolute path for the given path, using fs.realpath, but allowing\n * non-existent paths. In case of non-existent path, the longest existing prefix is resolved and\n * the rest kept unchanged.\n * @param {String} path: Path to resolve.\n * @return {Promise:String} Promise for the resolved path.\n */\nfunction realPath(path) {\n  return fs.realpathAsync(path)\n    .catch(() =>\n      realPath(fsPath.dirname(path))\n        .then(dir => fsPath.join(dir, fsPath.basename(path)))\n    );\n}\nexports.realPath = realPath;\n\n\n/**\n * Returns a promise that resolves to true or false based on whether the path exists. If other\n * errors occur, this promise may still be rejected.\n */\nfunction pathExists(path) {\n  return fs.accessAsync(path)\n    .then(() => true)\n    .catch({code: \"ENOENT\"}, () => false)\n    .catch({code: \"ENOTDIR\"}, () => false);\n}\nexports.pathExists = pathExists;\n\n/**\n * Returns a promise that resolves to true or false based on whether the two paths point to the\n * same file. If errors occur, this promise may be rejected.\n */\nfunction isSameFile(path1, path2) {\n  return Promise.join(fs.lstatAsync(path1), fs.lstatAsync(path2), (stat1, stat2) => {\n    if (stat1.dev === stat2.dev && stat1.ino === stat2.ino) {\n      return true;\n    }\n    return false;\n  })\n    .catch({code: \"ENOENT\"}, () => false)\n    .catch({code: \"ENOTDIR\"}, () => false);\n}\nexports.isSameFile = isSameFile;\n"
  },
  {
    "path": "app/server/lib/expressWrap.ts",
    "content": "import { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport log from \"app/server/lib/log\";\n\nimport * as express from \"express\";\n\nexport type AsyncRequestHandler = (\n  req: express.Request,\n  res: express.Response,\n  next: express.NextFunction,\n) => Promise<any> | any; // eslint-disable-line @typescript-eslint/no-redundant-type-constituents\n\n/**\n * Wrapper for async express endpoints to catch errors and forward them to the error handler.\n */\nexport function expressWrap(callback: AsyncRequestHandler): express.RequestHandler {\n  return async (req, res, next) => {\n    try {\n      await callback(req, res, next);\n    } catch (err) {\n      next(err);\n    }\n  };\n}\n\ninterface JsonErrorHandlerOptions {\n  shouldLogBody?: boolean;\n  shouldLogParams?: boolean;\n}\n\n/**\n * Returns a custom error-handling middleware that responds to errors in json.\n *\n * Currently allows for toggling of logging request bodies and params.\n */\nconst buildJsonErrorHandler = (options: JsonErrorHandlerOptions = {}): express.ErrorRequestHandler => {\n  const { shouldLogBody, shouldLogParams } = options;\n  return (err, req, res, _next) => {\n    const mreq = req as RequestWithLogin;\n    const meta = {\n      path: mreq.path,\n      userId: mreq.userId,\n      altSessionId: mreq.altSessionId,\n      body: shouldLogBody !== false ? req.body : undefined,\n      params: shouldLogParams !== false ? req.params : undefined,\n    };\n    const headersNote = res.headersSent ? \" (headersSent)\" : \"\";\n    log.rawWarn(`Error during api call to ${meta.path}${headersNote}: ${err.message}`, meta);\n    let details = err.details && { ...err.details };\n    const status = details?.status || err.status || 500;\n    if (details) {\n      // Remove some details exposed for websocket API only.\n      delete details.accessMode;\n      delete details.status;  // TODO: reconcile err.status and details.status, no need for both.\n      if (Object.keys(details).length === 0) { details = undefined; }\n    }\n    if (res.headersSent) {\n      // If we've already sent headers, attempt to set them to something else will fail. E.g. this\n      // can happen with downloads if a request gets aborted. If so, just close the response; we\n      // already reported the error above.\n      res.end();\n    } else {\n      res.status(status).json({ error: err.message || \"internal error\", details });\n    }\n  };\n};\n\n/**\n * Error-handling middleware that responds to errors in json. The status code is taken from\n * error.status property (for which ApiError is convenient), and defaults to 500.\n */\nexport const jsonErrorHandler: express.ErrorRequestHandler = buildJsonErrorHandler();\n\n/**\n * Variant of `jsonErrorHandler` that skips logging request bodies and params.\n *\n * Should be used for sensitive routes, such as those under '/api/auth/'.\n */\nexport const secureJsonErrorHandler: express.ErrorRequestHandler = buildJsonErrorHandler({\n  shouldLogBody: false,\n  shouldLogParams: false,\n});\n\n/**\n * Middleware that responds with a 404 status and a json error object.\n */\nexport const jsonNotFoundHandler: express.RequestHandler = (req, res, next) => {\n  res.status(404).json({ error: `not found: ${req.url}` });\n};\n"
  },
  {
    "path": "app/server/lib/extractOrg.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { mapGetOrSet, MapWithTTL } from \"app/common/AsyncCreate\";\nimport { extractOrgParts, getHostType, getSingleOrg } from \"app/common/gristUrls\";\nimport { isAffirmative } from \"app/common/gutil\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { getOriginUrl } from \"app/server/lib/requestUtils\";\n\nimport { IncomingMessage } from \"http\";\n\nimport { NextFunction, Request, RequestHandler, Response } from \"express\";\n\n// How long we cache information about the relationship between\n// orgs and custom hosts.  The higher this is, the fewer requests\n// to the DB needed, but the longer it will take for changes\n// to custom host setting to take effect.  Also, since the caching\n// is done on individual servers/workers, it could be inconsistent\n// between servers/workers for some time.  During this period,\n// redirect cycles are possible.\n// Units are milliseconds.\nconst ORG_HOST_CACHE_TTL = 60 * 1000;\n\nexport interface RequestOrgInfo {\n  org: string;\n  isCustomHost: boolean;   // when set, the request's domain is a recognized custom host linked\n  // with the specified org.\n\n  // path remainder after stripping /o/{org} if any.\n  url: string;\n}\n\nexport type RequestWithOrg = Request & Partial<RequestOrgInfo>;\n\n/**\n * Manage the relationship between orgs and custom hosts in the url.\n */\nexport class Hosts {\n  // Cache of orgs (e.g. \"fancy\" of \"fancy.getgrist.com\") associated with custom hosts\n  // (e.g. \"www.fancypants.com\")\n  private _host2org = new MapWithTTL<string, Promise<string | undefined>>(ORG_HOST_CACHE_TTL);\n  // Cache of custom hosts associated with orgs.\n  private _org2host = new MapWithTTL<string, Promise<string | undefined>>(ORG_HOST_CACHE_TTL);\n\n  // baseDomain should start with \".\". It may be undefined for localhost or single-org mode.\n  constructor(private _baseDomain: string | undefined, private _dbManager: HomeDBManager,\n    private _gristServer: GristServer | undefined) {\n  }\n\n  /**\n   * Use app.use(hosts.extractOrg) to set req.org, req.isCustomHost, and to strip\n   *  /o/ORG/ from urls (when present).\n   *\n   * If Host header has a getgrist.com subdomain, then it must match the value in /o/ORG (when\n   * present), and req.org will be set to the subdomain. On mismatch, a 400 response is returned.\n   *\n   * If Host header is a localhost domain, then req.org is set to the value in /o/ORG when\n   * present, and to \"\" otherwise.\n   *\n   * If Host header is something else, we query the db for an org whose host value matches.\n   * If found, req.org is set appropriately, and req.isCustomHost is set to true.\n   * If not found, a 'Domain not recognized' error is thrown, showing an error page.\n   */\n  public get extractOrg(): RequestHandler {\n    return this._extractOrg.bind(this);\n  }\n\n  // Extract org info in a request. This applies to the low-level IncomingMessage type (rather\n  // than express.Request that derives from it) to be usable with websocket requests too.\n  public async getOrgInfo(req: IncomingMessage): Promise<RequestOrgInfo> {\n    const host = req.headers.host!;\n    const info = await this.getOrgInfoFromParts(host, req.url!);\n    // \"Organization\" header is used in proxying to doc worker, so respect it if\n    // no org info found in url.\n    if (!info.org && req.headers.organization) {\n      info.org = req.headers.organization as string;\n    }\n    return info;\n  }\n\n  // Extract org, isCustomHost, and the URL with /o/ORG stripped away. Throws ApiError for\n  // mismatching org or invalid custom domain. Hostname should not include port.\n  public async getOrgInfoFromParts(host: string, urlPath: string): Promise<RequestOrgInfo> {\n    const hostname = host.split(\":\")[0];    // Strip out port (ignores IPv6 but is OK for us).\n\n    // Extract the org from the host and URL path.\n    const parts = extractOrgParts(hostname, urlPath);\n\n    // If the server is configured to serve a single hard-wired org, respect that.\n    const singleOrg = getSingleOrg();\n    if (singleOrg) {\n      return { org: singleOrg, url: parts.pathRemainder, isCustomHost: false };\n    }\n\n    const hostType = this._getHostType(host);\n    if (hostType === \"native\") {\n      if (parts.mismatch) {\n        throw new ApiError(`Wrong org for this domain: ` +\n          `'${parts.orgFromPath}' does not match '${parts.orgFromHost}'`, 400);\n      }\n      return { org: parts.subdomain || \"\", url: parts.pathRemainder, isCustomHost: false };\n    } else if (hostType === \"plugin\") {\n      return { org: \"\", url: parts.pathRemainder, isCustomHost: false };\n    } else {\n      // Otherwise check for a custom host.\n      const org = await mapGetOrSet(this._host2org, hostname, async () => {\n        const o = await this._dbManager.connection.manager.findOne(Organization, { where: { host: hostname } });\n        return o?.domain || undefined;\n      });\n      if (!org) { throw new ApiError(`Domain not recognized: ${hostname}`, 404); }\n\n      // Strip any stray /o/.... that has been added to a url with a custom host.\n      // TODO: it would eventually be cleaner to make sure we don't make those\n      // additions in the first place.\n\n      // To check for mismatch, compare to org, since orgFromHost is not expected to match.\n      if (parts.orgFromPath && parts.orgFromPath !== org) {\n        throw new ApiError(`Wrong org for this domain: ` +\n          `'${parts.orgFromPath}' does not match '${org}'`, 400);\n      }\n      return { org, isCustomHost: true, url: parts.pathRemainder };\n    }\n  }\n\n  public async addOrgInfo<T extends IncomingMessage>(req: T): Promise<T & RequestOrgInfo> {\n    return Object.assign(req, await this.getOrgInfo(req));\n  }\n\n  /**\n   * Use app.use(hosts.redirectHost) to ensure (by redirecting if necessary)\n   * that the domain in the url matches the preferred domain for the current org.\n   * Expects that the extractOrg has been used first.\n   */\n  public get redirectHost(): RequestHandler {\n    return this._redirectHost.bind(this);\n  }\n\n  public close() {\n    this._host2org.clear();\n    this._org2host.clear();\n  }\n\n  private async _extractOrg(req: Request, resp: Response, next: NextFunction) {\n    try {\n      await this.addOrgInfo(req);\n      return next();\n    } catch (err) {\n      return resp.status(err.status || 500).send({ error: err.message });\n    }\n  }\n\n  private async _redirectHost(req: Request, resp: Response, next: NextFunction) {\n    const { org } = req as RequestWithOrg;\n\n    if (org && this._getHostType(req.headers.host!) === \"native\" && !this._dbManager.isMergedOrg(org)) {\n      // Check if the org has a preferred host.\n      const orgHost = await mapGetOrSet(this._org2host, org, async () => {\n        const o = await this._dbManager.connection.manager.findOne(Organization, { where: { domain: org } });\n        return o?.host || undefined;\n      });\n      if (orgHost && orgHost !== req.hostname) {\n        const url = new URL(getOriginUrl(req) + req.path);\n        url.hostname = orgHost;  // assigning hostname rather than host preserves port.\n        return resp.redirect(url.href);\n      }\n    }\n    return next();\n  }\n\n  private _getHostType(host: string) {\n    const pluginUrl = isAffirmative(process.env.GRIST_TRUST_PLUGINS) ?\n      undefined : this._gristServer?.getPluginUrl();\n    return getHostType(host, { baseDomain: this._baseDomain, pluginUrl });\n  }\n}\n"
  },
  {
    "path": "app/server/lib/filterUtils.ts",
    "content": "import { DocumentSettings } from \"app/common/DocumentSettings\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { ActionHistoryImpl } from \"app/server/lib/ActionHistoryImpl\";\nimport { OptDocSession } from \"app/server/lib/DocSession\";\nimport { OpenMode, quoteIdent, SQLiteDB } from \"app/server/lib/SQLiteDB\";\n\n/**\n * Filter a Grist document when it is copied or downloaded.  Changes that should\n * likely be always made:\n *   - Any FullCopies special rules are removed.\n * Optional changes:\n *   - Removing all data rows.\n *   - Removing action history.\n * In the future, the changes could be made conditional on the user.  This would\n * allow us for example to permit downloads of documents with row-level filters\n * in place.\n */\nexport async function filterDocumentInPlace(docSession: OptDocSession, filename: string, options: {\n  removeData: boolean,\n  removeHistory: boolean,\n  removeFullCopiesSpecialRight: boolean,\n  markAction: boolean,\n}) {\n  // We ignore docSession for now, since no changes are user-dependent yet.\n  if (options.markAction) {\n    await markAction(filename);\n  }\n  if (options.removeData) {\n    await removeData(filename);\n  }\n  if (options.removeHistory) {\n    await removeHistory(filename);\n  }\n  if (options.removeFullCopiesSpecialRight) {\n    await removeFullCopiesSpecialRight(filename);\n  }\n}\n\nasync function removeFullCopiesSpecialRight(filename: string) {\n  // The change we need to make is simple, so we open the doc as a SQLite DB.\n  // Note: the change is not entered in document history.\n  const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING);\n  // Fetch ids of any special resources mentioning FullCopies (ideally there would be\n  // at most one).\n  const resourceIds = (\n    await db.all(\"SELECT id FROM _grist_ACLResources \" +\n      \"WHERE tableId='*SPECIAL' AND colIds='FullCopies'\")\n  ).map(row => row.id as number);\n  if (resourceIds.length > 0) {\n    // Remove any related rules.\n    await db.run(`DELETE FROM _grist_ACLRules WHERE resource IN (${resourceIds})`);\n    // Remove the resources.\n    await db.run(`DELETE FROM _grist_ACLResources WHERE id IN (${resourceIds})`);\n  }\n  await db.close();\n}\n\n/**\n * Remove rows from all user tables.\n */\nasync function removeData(filename: string) {\n  const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING);\n  const tableIds = (await db.all(\"SELECT name FROM sqlite_master WHERE type='table'\"))\n    .map(row => row.name as string)\n    .filter(name => !name.startsWith(\"_grist\"));\n  for (const tableId of tableIds) {\n    await db.run(`DELETE FROM ${quoteIdent(tableId)}`);\n  }\n  await db.run(`DELETE FROM _grist_Attachments`);\n  await db.run(`DELETE FROM _gristsys_Files`);\n  await db.close();\n}\n\n/**\n * Wipe as much history as we can.\n */\nasync function removeHistory(filename: string) {\n  const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING);\n  const history = new ActionHistoryImpl(db);\n  await history.deleteActions(1);\n  await db.close();\n}\n\nasync function markAction(filename: string) {\n  const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING);\n  const history = new ActionHistoryImpl(db);\n  const states = await history.getRecentStates(1);\n  if (states.length > 0) {\n    const documentSettings: string = (await db.all(\"SELECT documentSettings FROM _grist_DocInfo\"))[0]?.documentSettings;\n    const documentSettingsObj: DocumentSettings = safeJsonParse(documentSettings, {});\n    documentSettingsObj.baseAction = states[0];\n    await db.run(\"UPDATE _grist_DocInfo SET documentSettings = ?\",\n      JSON.stringify(documentSettingsObj));\n  }\n  await db.close();\n}\n"
  },
  {
    "path": "app/server/lib/gristSessions.ts",
    "content": "import { parseSubdomain } from \"app/common/gristUrls\";\nimport { isNumber } from \"app/common/gutil\";\nimport { RequestWithOrg } from \"app/server/lib/extractOrg\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport { fromCallback } from \"app/server/lib/serverUtils\";\nimport { Sessions } from \"app/server/lib/Sessions\";\n\nimport * as crypto from \"crypto\";\nimport * as path from \"path\";\n\nimport session from \"@gristlabs/express-session\";\nimport { promisifyAll } from \"bluebird\";\nimport * as express from \"express\";\nimport assignIn from \"lodash/assignIn\";\nimport { createClient } from \"redis\";\n\nexport const cookieName = process.env.GRIST_SESSION_COOKIE || \"grist_sid\";\n\nexport const COOKIE_MAX_AGE =\n  process.env.COOKIE_MAX_AGE === \"none\" ? null :\n    isNumber(process.env.COOKIE_MAX_AGE || \"\") ? Number(process.env.COOKIE_MAX_AGE) :\n      90 * 24 * 60 * 60 * 1000;  // 90 days in milliseconds\n\n// RedisStore and SqliteStore are expected to provide a set/get interface for sessions.\nexport interface SessionStore {\n  getAsync(sid: string): Promise<any>;\n  setAsync(sid: string, session: any): Promise<void>;\n  clearAsync(): Promise<void>;\n  close(): Promise<void>;\n}\n\n/**\n *\n * A V1 session.  A session can be associated with a number of users.\n * There may be a preferred association between users and organizations:\n * specifically, if from the url we can tell that we are showing material\n * for a given organization, we should pick a user that has access to that\n * organization.\n *\n * This interface plays no role at all yet!  Working on refactoring existing\n * sessions step by step to get closer to this.\n *\n */\nexport interface IGristSession {\n\n  // V1 Hosted Grist - known available users.\n  users: {\n    userId?: number;\n  }[];\n\n  // V1 Hosted Grist - known user/org relationships.\n  orgs: {\n    orgId: number;\n    userId: number;\n  }[];\n}\n\nfunction createSessionStoreFactory(sessionsDB: string): () => SessionStore {\n  if (process.env.REDIS_URL) {\n    // Note that ./build excludes this module from the electron build.\n    // eslint-disable-next-line @typescript-eslint/no-require-imports\n    const RedisStore = require(\"connect-redis\")(session);\n    promisifyAll(RedisStore.prototype);\n    return () => {\n      const client = createClient(process.env.REDIS_URL);\n      client.on(\"error\",\n        (err: unknown) => {\n          log.error(`createSessionStoreFactory: redisClient error`, String(err));\n        },\n      );\n      const store = new RedisStore({ client });\n      return assignIn(store, {\n        async close() {\n          // Quit the client, so that it doesn't attempt to reconnect (which matters for some\n          // tests), and so that node becomes close-able.\n          await fromCallback(cb => store.client.quit(cb));\n        } });\n    };\n  } else {\n    // eslint-disable-next-line @typescript-eslint/no-require-imports\n    const SQLiteStore = require(\"@gristlabs/connect-sqlite3\")(session);\n    promisifyAll(SQLiteStore.prototype);\n    return () => {\n      const store = new SQLiteStore({\n        dir: path.dirname(sessionsDB),\n        db: path.basename(sessionsDB),    // SQLiteStore no longer appends a .db suffix.\n        table: \"sessions\",\n      });\n      // In testing, and monorepo's \"yarn start\", session is accessed from multiple\n      // processes, so could hit lock failures.\n      // connect-sqlite3 has a concurrentDb: true flag that can be set, but\n      // it puts the database in WAL mode, which would have implications\n      // for self-hosters (a second file to think about). Instead we just\n      // set a busy timeout.\n      store.db.run(\"PRAGMA busy_timeout = 1000\");\n\n      return assignIn(store, { async close() {} });\n    };\n  }\n}\n\nexport function getAllowedOrgForSessionID(sessionID: string): { org: string, host: string } | null {\n  if (sessionID.startsWith(\"c-\") && sessionID.includes(\"@\")) {\n    const [, org, host] = sessionID.split(\"@\");\n    if (!host) { throw new Error(\"Invalid session ID\"); }\n    return { org, host };\n  }\n  // Otherwise sessions start with 'g-', but we also accept older sessions without a prefix.\n  return null;\n}\n\n/**\n * Set up Grist Sessions, either in a sqlite db or via redis.\n * @param instanceRoot: path to storage area in case we need to make a sqlite db.\n */\nexport function initGristSessions(instanceRoot: string, server: GristServer) {\n  // TODO: We may need to evaluate the usage of space in the SQLite store grist-sessions.db\n  // since entries are created on the first get request.\n  const sessionsDB: string = path.join(instanceRoot, \"grist-sessions.db\");\n\n  // The extra step with the creator function is used in server.js to create a new session store\n  // after unpausing the server.\n  const sessionStoreCreator = createSessionStoreFactory(sessionsDB);\n  const sessionStore = sessionStoreCreator();\n\n  // Use a separate session IDs for custom domains than for native ones. Because a custom domain\n  // cookie could be stolen (with some effort) by the custom domain's owner, we limit the damage\n  // by only honoring custom-domain cookies for requests to that domain.\n  const generateId = (req: RequestWithOrg) => {\n    // Generate 256 bits of cryptographically random data to use as the session ID.\n    // This ensures security against brute-force session hijacking even without signing the session ID.\n    const randomNumbers = crypto.getRandomValues(new Uint8Array(32));\n    const uid = Buffer.from(randomNumbers).toString(\"hex\");\n    return req.isCustomHost ? `c-${uid}@${req.org}@${req.get(\"host\")}` : `g-${uid}`;\n  };\n  const sessionSecret = server.create.sessionSecret();\n  const sessionMiddleware = session({\n    secret: sessionSecret,\n    resave: false,\n    saveUninitialized: false,\n    name: cookieName,\n    requestDomain: getCookieDomain,\n    genid: generateId,\n    cookie: {\n      sameSite: \"lax\",\n\n      // We do not initially set max-age, leaving the cookie as a\n      // session cookie until there's a successful login.  On the\n      // redis back-end, the session associated with the cookie will\n      // persist for 24 hours if there is no successful login.  Once\n      // there is a successful login, max-age will be set to\n      // COOKIE_MAX_AGE, making the cookie a persistent cookie.  The\n      // session associated with the cookie will receive an updated\n      // time-to-live, so that it persists for COOKIE_MAX_AGE.\n    },\n    store: sessionStore,\n  });\n\n  const sessions = new Sessions(sessionSecret, sessionStore);\n\n  return { sessions, sessionSecret, sessionStore, sessionMiddleware };\n}\n\nexport function getCookieDomain(req: express.Request) {\n  const mreq = req as RequestWithOrg;\n  if (mreq.isCustomHost) {\n    // For custom hosts, omit the domain to make it a \"host-only\" cookie, to avoid it being\n    // included into subdomain requests (since we would not control all the subdomains).\n    return undefined;\n  }\n\n  const adaptDomain = process.env.GRIST_ADAPT_DOMAIN === \"true\";\n  const fixedDomain = process.env.GRIST_SESSION_DOMAIN || process.env.GRIST_DOMAIN;\n\n  if (adaptDomain) {\n    const reqDomain = parseSubdomain(req.get(\"host\"));\n    if (reqDomain.base) { return reqDomain.base.split(\":\")[0]; }\n  }\n  return fixedDomain;\n}\n"
  },
  {
    "path": "app/server/lib/gristSettings.ts",
    "content": "import { appSettings } from \"app/server/lib/AppSettings\";\n\nimport memoize from \"lodash/memoize\";\n\nexport function getTemplateOrg() {\n  let org = appSettings.section(\"templates\").flag(\"org\").readString({\n    envVar: \"GRIST_TEMPLATE_ORG\",\n  });\n  if (!org) { return null; }\n\n  if (process.env.GRIST_ID_PREFIX) {\n    org += `-${process.env.GRIST_ID_PREFIX}`;\n  }\n  return org;\n}\n\nexport function getUserPresenceMaxUsers(): number {\n  return appSettings.section(\"userPresence\").flag(\"maxUsers\").requireInt({\n    envVar: \"GRIST_USER_PRESENCE_MAX_USERS\",\n    defaultValue: 99,\n    minValue: 0,\n    maxValue: 99,\n  });\n}\n\nexport function getOnboardingTutorialDocId() {\n  return appSettings.section(\"tutorials\").flag(\"onboardingTutorialDocId\").readString({\n    envVar: \"GRIST_ONBOARDING_TUTORIAL_DOC_ID\",\n  });\n}\n\nexport const getAnonPlaygroundEnabled = memoize(() =>\n  appSettings.section(\"orgs\").flag(\"enableAnonPlayground\").readBool({\n    envVar: \"GRIST_ANON_PLAYGROUND\",\n    defaultValue: getCanAnyoneCreateOrgs(),\n  }),\n);\n\nexport const getCanAnyoneCreateOrgs = memoize(() =>\n  appSettings.section(\"orgs\").flag(\"canAnyoneCreateOrgs\").readBool({\n    envVar: \"GRIST_ORG_CREATION_ANYONE\",\n    defaultValue: true,\n  }),\n);\n\nexport const getPersonalOrgsEnabled = memoize(() =>\n  appSettings.section(\"orgs\").flag(\"enablePersonalOrgs\").readBool({\n    envVar: \"GRIST_PERSONAL_ORGS\",\n    defaultValue: getCanAnyoneCreateOrgs(),\n  }),\n);\n"
  },
  {
    "path": "app/server/lib/guessExt.ts",
    "content": "import * as path from \"path\";\n\nimport { fromFile } from \"file-type\";\nimport { extension, lookup } from \"mime-types\";\n\n/**\n * Get our best guess of the file extension, based on its original extension (as received from the\n * user), mimeType (as reported by the browser upload, or perhaps some API), and the file\n * contents.\n *\n * The resulting extension is used to choose a parser for imports, and to present the file back\n * to the user for attachments.\n */\nexport async function guessExt(filePath: string, fileName: string, mimeType: string | null): Promise<string> {\n  const origExt = path.extname(fileName).toLowerCase();   // Has the form \".xls\"\n\n  let mimeExt = extension(mimeType);          // Has the form \"xls\"\n  mimeExt = mimeExt ? \".\" + mimeExt : null;   // Use the more comparable form \".xls\"\n  if (mimeExt === \".json\") {\n    // It's common for JSON APIs to specify MIME type, but origExt might come from a URL with\n    // periods that don't indicate a meaningful extension. Trust mime-type here.\n    return mimeExt;\n  }\n\n  if (origExt === \".csv\") {\n    // File type detection doesn't work for these, and mime type can't be trusted. E.g. Windows\n    // may report \"application/vnd.ms-excel\" for .csv files. See\n    // https://github.com/ManifoldScholar/manifold/issues/2409#issuecomment-545152220\n    return origExt;\n  }\n\n  // If extension and mime type agree, let's call it a day.\n  if (origExt && (origExt === mimeExt || lookup(origExt.slice(1)) === mimeType)) {\n    return origExt;\n  }\n\n  // If not, let's take a look at the file contents.\n  const detected = await fromFile(filePath);\n  const detectedExt = detected ? \".\" + detected.ext : null;\n  if (detectedExt) {\n    // For the types for which detection works, we think we should prefer it.\n    return detectedExt;\n  }\n\n  if (mimeExt === \".txt\" || mimeExt === \".bin\") {\n    // text/plain (txt) and application/octet-stream (bin) are too generic, only use them if we\n    // don't have anything better.\n    return origExt || mimeExt;\n  }\n  // In other cases, it's a tough call.\n  return origExt || mimeExt;\n}\n"
  },
  {
    "path": "app/server/lib/hashingUtils.ts",
    "content": "import { createHash } from \"crypto\";\n\n/**\n * Returns a hash of `id` prefixed with the first 4 characters of `id`. The first 4\n * characters are included to assist with troubleshooting.\n *\n * Useful for situations where potentially sensitive identifiers are logged, such as\n * doc ids of docs that have public link sharing enabled.\n */\nexport function hashId(id: string): string {\n  return `${id.slice(0, 4)}:${createHash(\"sha256\").update(id.slice(4)).digest(\"base64\")}`;\n}\n"
  },
  {
    "path": "app/server/lib/httpEncoding.ts",
    "content": "// Based on the source code of the Body.textConverted method in node-fetch\nexport function httpEncoding(header: string | null, content: Buffer): string | undefined {\n  let res: RegExpExecArray | null = null;\n\n  // header\n  if (header) {\n    res = /charset=([^;]*)/i.exec(header);\n  }\n\n  // no charset in content type, peek at response body for at most 1024 bytes\n  const str = content.slice(0, 1024).toString();\n\n  // html5\n  if (!res && str) {\n    res = /<meta.+?charset=(['\"])(.+?)\\1/i.exec(str);\n  }\n\n  // html4\n  if (!res && str) {\n    res = /<meta\\s+?http-equiv=(['\"])content-type\\1\\s+?content=(['\"])(.+?)\\2/i.exec(str);\n\n    if (res) {\n      res = /charset=(.*)/i.exec(res.pop()!);\n    }\n  }\n\n  // xml\n  if (!res && str) {\n    res = /<\\?xml.+?encoding=(['\"])(.+?)\\1/i.exec(str);\n  }\n\n  // found charset\n  if (res) {\n    let charset = res.pop();\n\n    // prevent decode issues when sites use incorrect encoding\n    // ref: https://hsivonen.fi/encoding-menu/\n    if (charset === \"gb2312\" || charset === \"gbk\") {\n      charset = \"gb18030\";\n    }\n    return charset;\n  }\n}\n"
  },
  {
    "path": "app/server/lib/idUtils.ts",
    "content": "import { ForkResult } from \"app/common/ActiveDocAPI\";\nimport { buildUrlId, parseUrlId } from \"app/common/gristUrls\";\nimport { padStart } from \"app/common/gutil\";\nimport { IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\n\nimport * as shortUUID from \"short-uuid\";\n\n// make an id that is a standard UUID compressed into fewer characters.\nexport function makeId(): string {\n  // Generate a flickr-style id, by converting a regular uuid interpreted\n  // as a hex number (without dashes) into a number expressed in a bigger\n  // base. That number is encoded as characters chosen for url safety and\n  // lack of confusability. The character encoding zero is '1'.  We pad the\n  // result so that the length of the id remains consistent, since there is\n  // routing that depends on the id length exceeding a minimum threshold.\n  return padStart(shortUUID.generate(), 22, \"1\");\n}\n\n/**\n * Construct an id for a fork, given the userId, whether the user is the anonymous user,\n * and the id of a reference document (the trunk).\n * If the userId is null, the user will be treated as the anonymous user.\n */\nexport function makeForkIds(options: { userId: number | null, isAnonymous: boolean,\n  trunkDocId: string, trunkUrlId: string }): ForkResult {\n  const forkId = makeId();\n  const forkUserId = options.isAnonymous ? undefined :\n    (options.userId !== null ? options.userId : undefined);\n  // TODO: we will want to support forks of forks, but for now we do not -\n  // forks are always forks of the trunk.\n  const docId = parseUrlId(options.trunkDocId).trunkId;\n  const urlId = parseUrlId(options.trunkUrlId).trunkId;\n  return {\n    forkId,\n    docId: buildUrlId({ trunkId: docId, forkId, forkUserId }),\n    urlId: buildUrlId({ trunkId: urlId, forkId, forkUserId }),\n  };\n}\n\n// This used to do a hack for importing, but now does nothing.\n// Instead, the server will interpret the special docId \"import\".\nexport function getAssignmentId(docWorkerMap: IDocWorkerMap, docId: string): string {\n  return docId;\n}\n\n// Get the externalId to use for an AppSumo user.  AppSumo identifies users by\n// an activation email, so we just use that (with an appsumo/ prefix it allow\n// for other families of id in the future).\nexport function getExternalIdForAppSumoUser(email: string) {\n  return `appsumo/${email}`;\n}\n"
  },
  {
    "path": "app/server/lib/initialDocSql.ts",
    "content": "/* eslint-disable */\n/*** THIS FILE IS AUTO-GENERATED BY app/server/generateInitialDocSql.ts ***/\n\n\nexport const GRIST_DOC_SQL = `\nPRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\nCREATE TABLE IF NOT EXISTS \"_grist_DocInfo\" (id INTEGER PRIMARY KEY, \"docId\" TEXT DEFAULT '', \"peers\" TEXT DEFAULT '', \"basketId\" TEXT DEFAULT '', \"schemaVersion\" INTEGER DEFAULT 0, \"timezone\" TEXT DEFAULT '', \"documentSettings\" TEXT DEFAULT '');\nINSERT INTO _grist_DocInfo VALUES(1,'','','',46,'','');\nCREATE TABLE IF NOT EXISTS \"_grist_Tables\" (id INTEGER PRIMARY KEY, \"tableId\" TEXT DEFAULT '', \"primaryViewId\" INTEGER DEFAULT 0, \"summarySourceTable\" INTEGER DEFAULT 0, \"onDemand\" BOOLEAN DEFAULT 0, \"rawViewSectionRef\" INTEGER DEFAULT 0, \"recordCardViewSectionRef\" INTEGER DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_Tables_column\" (id INTEGER PRIMARY KEY, \"parentId\" INTEGER DEFAULT 0, \"parentPos\" NUMERIC DEFAULT 1e999, \"colId\" TEXT DEFAULT '', \"type\" TEXT DEFAULT '', \"widgetOptions\" TEXT DEFAULT '', \"isFormula\" BOOLEAN DEFAULT 0, \"formula\" TEXT DEFAULT '', \"label\" TEXT DEFAULT '', \"description\" TEXT DEFAULT '', \"untieColIdFromLabel\" BOOLEAN DEFAULT 0, \"summarySourceCol\" INTEGER DEFAULT 0, \"displayCol\" INTEGER DEFAULT 0, \"visibleCol\" INTEGER DEFAULT 0, \"rules\" TEXT DEFAULT NULL, \"reverseCol\" INTEGER DEFAULT 0, \"recalcWhen\" INTEGER DEFAULT 0, \"recalcDeps\" TEXT DEFAULT NULL);\nCREATE TABLE IF NOT EXISTS \"_grist_Imports\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"origFileName\" TEXT DEFAULT '', \"parseFormula\" TEXT DEFAULT '', \"delimiter\" TEXT DEFAULT '', \"doublequote\" BOOLEAN DEFAULT 0, \"escapechar\" TEXT DEFAULT '', \"quotechar\" TEXT DEFAULT '', \"skipinitialspace\" BOOLEAN DEFAULT 0, \"encoding\" TEXT DEFAULT '', \"hasHeaders\" BOOLEAN DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_External_database\" (id INTEGER PRIMARY KEY, \"host\" TEXT DEFAULT '', \"port\" INTEGER DEFAULT 0, \"username\" TEXT DEFAULT '', \"dialect\" TEXT DEFAULT '', \"database\" TEXT DEFAULT '', \"storage\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_External_table\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"databaseRef\" INTEGER DEFAULT 0, \"tableName\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_TableViews\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"viewRef\" INTEGER DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_TabItems\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"viewRef\" INTEGER DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_TabBar\" (id INTEGER PRIMARY KEY, \"viewRef\" INTEGER DEFAULT 0, \"tabPos\" NUMERIC DEFAULT 1e999);\nCREATE TABLE IF NOT EXISTS \"_grist_Pages\" (id INTEGER PRIMARY KEY, \"viewRef\" INTEGER DEFAULT 0, \"indentation\" INTEGER DEFAULT 0, \"pagePos\" NUMERIC DEFAULT 1e999, \"shareRef\" INTEGER DEFAULT 0, \"options\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_Views\" (id INTEGER PRIMARY KEY, \"name\" TEXT DEFAULT '', \"type\" TEXT DEFAULT '', \"layoutSpec\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_Views_section\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"parentId\" INTEGER DEFAULT 0, \"parentKey\" TEXT DEFAULT '', \"title\" TEXT DEFAULT '', \"description\" TEXT DEFAULT '', \"defaultWidth\" INTEGER DEFAULT 0, \"borderWidth\" INTEGER DEFAULT 0, \"theme\" TEXT DEFAULT '', \"options\" TEXT DEFAULT '', \"chartType\" TEXT DEFAULT '', \"layoutSpec\" TEXT DEFAULT '', \"filterSpec\" TEXT DEFAULT '', \"sortColRefs\" TEXT DEFAULT '', \"linkSrcSectionRef\" INTEGER DEFAULT 0, \"linkSrcColRef\" INTEGER DEFAULT 0, \"linkTargetColRef\" INTEGER DEFAULT 0, \"embedId\" TEXT DEFAULT '', \"rules\" TEXT DEFAULT NULL, \"shareOptions\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_Views_section_field\" (id INTEGER PRIMARY KEY, \"parentId\" INTEGER DEFAULT 0, \"parentPos\" NUMERIC DEFAULT 1e999, \"colRef\" INTEGER DEFAULT 0, \"width\" INTEGER DEFAULT 0, \"widgetOptions\" TEXT DEFAULT '', \"displayCol\" INTEGER DEFAULT 0, \"visibleCol\" INTEGER DEFAULT 0, \"filter\" TEXT DEFAULT '', \"rules\" TEXT DEFAULT NULL);\nCREATE TABLE IF NOT EXISTS \"_grist_Validations\" (id INTEGER PRIMARY KEY, \"formula\" TEXT DEFAULT '', \"name\" TEXT DEFAULT '', \"tableRef\" INTEGER DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_REPL_Hist\" (id INTEGER PRIMARY KEY, \"code\" TEXT DEFAULT '', \"outputText\" TEXT DEFAULT '', \"errorText\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_Attachments\" (id INTEGER PRIMARY KEY, \"fileIdent\" TEXT DEFAULT '', \"fileName\" TEXT DEFAULT '', \"fileType\" TEXT DEFAULT '', \"fileSize\" INTEGER DEFAULT 0, \"fileExt\" TEXT DEFAULT '', \"imageHeight\" INTEGER DEFAULT 0, \"imageWidth\" INTEGER DEFAULT 0, \"timeDeleted\" DATETIME DEFAULT NULL, \"timeUploaded\" DATETIME DEFAULT NULL);\nCREATE TABLE IF NOT EXISTS \"_grist_Triggers\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"eventTypes\" TEXT DEFAULT NULL, \"isReadyColRef\" INTEGER DEFAULT 0, \"actions\" TEXT DEFAULT '', \"label\" TEXT DEFAULT '', \"memo\" TEXT DEFAULT '', \"enabled\" BOOLEAN DEFAULT 0, \"watchedColRefList\" TEXT DEFAULT NULL, \"options\" TEXT DEFAULT '', \"condition\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_ACLRules\" (id INTEGER PRIMARY KEY, \"resource\" INTEGER DEFAULT 0, \"permissions\" INTEGER DEFAULT 0, \"principals\" TEXT DEFAULT '', \"aclFormula\" TEXT DEFAULT '', \"aclColumn\" INTEGER DEFAULT 0, \"aclFormulaParsed\" TEXT DEFAULT '', \"permissionsText\" TEXT DEFAULT '', \"rulePos\" NUMERIC DEFAULT 1e999, \"userAttributes\" TEXT DEFAULT '', \"memo\" TEXT DEFAULT '');\nINSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');\nCREATE TABLE IF NOT EXISTS \"_grist_ACLResources\" (id INTEGER PRIMARY KEY, \"tableId\" TEXT DEFAULT '', \"colIds\" TEXT DEFAULT '');\nINSERT INTO _grist_ACLResources VALUES(1,'','');\nCREATE TABLE IF NOT EXISTS \"_grist_ACLPrincipals\" (id INTEGER PRIMARY KEY, \"type\" TEXT DEFAULT '', \"userEmail\" TEXT DEFAULT '', \"userName\" TEXT DEFAULT '', \"groupName\" TEXT DEFAULT '', \"instanceId\" TEXT DEFAULT '');\nINSERT INTO _grist_ACLPrincipals VALUES(1,'group','','','Owners','');\nINSERT INTO _grist_ACLPrincipals VALUES(2,'group','','','Admins','');\nINSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors','');\nINSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers','');\nCREATE TABLE IF NOT EXISTS \"_grist_ACLMemberships\" (id INTEGER PRIMARY KEY, \"parent\" INTEGER DEFAULT 0, \"child\" INTEGER DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_Filters\" (id INTEGER PRIMARY KEY, \"viewSectionRef\" INTEGER DEFAULT 0, \"colRef\" INTEGER DEFAULT 0, \"filter\" TEXT DEFAULT '', \"pinned\" BOOLEAN DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_Cells\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"colRef\" INTEGER DEFAULT 0, \"rowId\" INTEGER DEFAULT 0, \"root\" BOOLEAN DEFAULT 0, \"parentId\" INTEGER DEFAULT 0, \"type\" INTEGER DEFAULT 0, \"content\" TEXT DEFAULT '', \"userRef\" TEXT DEFAULT '', \"timeCreated\" DATETIME DEFAULT NULL, \"timeUpdated\" DATETIME DEFAULT NULL, \"resolved\" BOOLEAN DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_Shares\" (id INTEGER PRIMARY KEY, \"linkId\" TEXT DEFAULT '', \"options\" TEXT DEFAULT '', \"label\" TEXT DEFAULT '', \"description\" TEXT DEFAULT '');\nCREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);\nCOMMIT;\n`;\n\nexport const GRIST_DOC_WITH_TABLE1_SQL = `\nPRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\nCREATE TABLE IF NOT EXISTS \"_grist_DocInfo\" (id INTEGER PRIMARY KEY, \"docId\" TEXT DEFAULT '', \"peers\" TEXT DEFAULT '', \"basketId\" TEXT DEFAULT '', \"schemaVersion\" INTEGER DEFAULT 0, \"timezone\" TEXT DEFAULT '', \"documentSettings\" TEXT DEFAULT '');\nINSERT INTO _grist_DocInfo VALUES(1,'','','',46,'','');\nCREATE TABLE IF NOT EXISTS \"_grist_Tables\" (id INTEGER PRIMARY KEY, \"tableId\" TEXT DEFAULT '', \"primaryViewId\" INTEGER DEFAULT 0, \"summarySourceTable\" INTEGER DEFAULT 0, \"onDemand\" BOOLEAN DEFAULT 0, \"rawViewSectionRef\" INTEGER DEFAULT 0, \"recordCardViewSectionRef\" INTEGER DEFAULT 0);\nINSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3);\nCREATE TABLE IF NOT EXISTS \"_grist_Tables_column\" (id INTEGER PRIMARY KEY, \"parentId\" INTEGER DEFAULT 0, \"parentPos\" NUMERIC DEFAULT 1e999, \"colId\" TEXT DEFAULT '', \"type\" TEXT DEFAULT '', \"widgetOptions\" TEXT DEFAULT '', \"isFormula\" BOOLEAN DEFAULT 0, \"formula\" TEXT DEFAULT '', \"label\" TEXT DEFAULT '', \"description\" TEXT DEFAULT '', \"untieColIdFromLabel\" BOOLEAN DEFAULT 0, \"summarySourceCol\" INTEGER DEFAULT 0, \"displayCol\" INTEGER DEFAULT 0, \"visibleCol\" INTEGER DEFAULT 0, \"rules\" TEXT DEFAULT NULL, \"reverseCol\" INTEGER DEFAULT 0, \"recalcWhen\" INTEGER DEFAULT 0, \"recalcDeps\" TEXT DEFAULT NULL);\nINSERT INTO _grist_Tables_column VALUES(1,1,1,'manualSort','ManualSortPos','',0,'','manualSort','',0,0,0,0,NULL,0,0,NULL);\nINSERT INTO _grist_Tables_column VALUES(2,1,2,'A','Any','',1,'','A','',0,0,0,0,NULL,0,0,NULL);\nINSERT INTO _grist_Tables_column VALUES(3,1,3,'B','Any','',1,'','B','',0,0,0,0,NULL,0,0,NULL);\nINSERT INTO _grist_Tables_column VALUES(4,1,4,'C','Any','',1,'','C','',0,0,0,0,NULL,0,0,NULL);\nCREATE TABLE IF NOT EXISTS \"_grist_Imports\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"origFileName\" TEXT DEFAULT '', \"parseFormula\" TEXT DEFAULT '', \"delimiter\" TEXT DEFAULT '', \"doublequote\" BOOLEAN DEFAULT 0, \"escapechar\" TEXT DEFAULT '', \"quotechar\" TEXT DEFAULT '', \"skipinitialspace\" BOOLEAN DEFAULT 0, \"encoding\" TEXT DEFAULT '', \"hasHeaders\" BOOLEAN DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_External_database\" (id INTEGER PRIMARY KEY, \"host\" TEXT DEFAULT '', \"port\" INTEGER DEFAULT 0, \"username\" TEXT DEFAULT '', \"dialect\" TEXT DEFAULT '', \"database\" TEXT DEFAULT '', \"storage\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_External_table\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"databaseRef\" INTEGER DEFAULT 0, \"tableName\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_TableViews\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"viewRef\" INTEGER DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_TabItems\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"viewRef\" INTEGER DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_TabBar\" (id INTEGER PRIMARY KEY, \"viewRef\" INTEGER DEFAULT 0, \"tabPos\" NUMERIC DEFAULT 1e999);\nINSERT INTO _grist_TabBar VALUES(1,1,1);\nCREATE TABLE IF NOT EXISTS \"_grist_Pages\" (id INTEGER PRIMARY KEY, \"viewRef\" INTEGER DEFAULT 0, \"indentation\" INTEGER DEFAULT 0, \"pagePos\" NUMERIC DEFAULT 1e999, \"shareRef\" INTEGER DEFAULT 0, \"options\" TEXT DEFAULT '');\nINSERT INTO _grist_Pages VALUES(1,1,0,1,0,'');\nCREATE TABLE IF NOT EXISTS \"_grist_Views\" (id INTEGER PRIMARY KEY, \"name\" TEXT DEFAULT '', \"type\" TEXT DEFAULT '', \"layoutSpec\" TEXT DEFAULT '');\nINSERT INTO _grist_Views VALUES(1,'Table1','raw_data','');\nCREATE TABLE IF NOT EXISTS \"_grist_Views_section\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"parentId\" INTEGER DEFAULT 0, \"parentKey\" TEXT DEFAULT '', \"title\" TEXT DEFAULT '', \"description\" TEXT DEFAULT '', \"defaultWidth\" INTEGER DEFAULT 0, \"borderWidth\" INTEGER DEFAULT 0, \"theme\" TEXT DEFAULT '', \"options\" TEXT DEFAULT '', \"chartType\" TEXT DEFAULT '', \"layoutSpec\" TEXT DEFAULT '', \"filterSpec\" TEXT DEFAULT '', \"sortColRefs\" TEXT DEFAULT '', \"linkSrcSectionRef\" INTEGER DEFAULT 0, \"linkSrcColRef\" INTEGER DEFAULT 0, \"linkTargetColRef\" INTEGER DEFAULT 0, \"embedId\" TEXT DEFAULT '', \"rules\" TEXT DEFAULT NULL, \"shareOptions\" TEXT DEFAULT '');\nINSERT INTO _grist_Views_section VALUES(1,1,1,'record','','',100,1,'','','','','','[]',0,0,0,'',NULL,'');\nINSERT INTO _grist_Views_section VALUES(2,1,0,'record','','',100,1,'','','','','','',0,0,0,'',NULL,'');\nINSERT INTO _grist_Views_section VALUES(3,1,0,'single','','',100,1,'','','','','','',0,0,0,'',NULL,'');\nCREATE TABLE IF NOT EXISTS \"_grist_Views_section_field\" (id INTEGER PRIMARY KEY, \"parentId\" INTEGER DEFAULT 0, \"parentPos\" NUMERIC DEFAULT 1e999, \"colRef\" INTEGER DEFAULT 0, \"width\" INTEGER DEFAULT 0, \"widgetOptions\" TEXT DEFAULT '', \"displayCol\" INTEGER DEFAULT 0, \"visibleCol\" INTEGER DEFAULT 0, \"filter\" TEXT DEFAULT '', \"rules\" TEXT DEFAULT NULL);\nINSERT INTO _grist_Views_section_field VALUES(1,1,1,2,0,'',0,0,'',NULL);\nINSERT INTO _grist_Views_section_field VALUES(2,1,2,3,0,'',0,0,'',NULL);\nINSERT INTO _grist_Views_section_field VALUES(3,1,3,4,0,'',0,0,'',NULL);\nINSERT INTO _grist_Views_section_field VALUES(4,2,4,2,0,'',0,0,'',NULL);\nINSERT INTO _grist_Views_section_field VALUES(5,2,5,3,0,'',0,0,'',NULL);\nINSERT INTO _grist_Views_section_field VALUES(6,2,6,4,0,'',0,0,'',NULL);\nINSERT INTO _grist_Views_section_field VALUES(7,3,7,2,0,'',0,0,'',NULL);\nINSERT INTO _grist_Views_section_field VALUES(8,3,8,3,0,'',0,0,'',NULL);\nINSERT INTO _grist_Views_section_field VALUES(9,3,9,4,0,'',0,0,'',NULL);\nCREATE TABLE IF NOT EXISTS \"_grist_Validations\" (id INTEGER PRIMARY KEY, \"formula\" TEXT DEFAULT '', \"name\" TEXT DEFAULT '', \"tableRef\" INTEGER DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_REPL_Hist\" (id INTEGER PRIMARY KEY, \"code\" TEXT DEFAULT '', \"outputText\" TEXT DEFAULT '', \"errorText\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_Attachments\" (id INTEGER PRIMARY KEY, \"fileIdent\" TEXT DEFAULT '', \"fileName\" TEXT DEFAULT '', \"fileType\" TEXT DEFAULT '', \"fileSize\" INTEGER DEFAULT 0, \"fileExt\" TEXT DEFAULT '', \"imageHeight\" INTEGER DEFAULT 0, \"imageWidth\" INTEGER DEFAULT 0, \"timeDeleted\" DATETIME DEFAULT NULL, \"timeUploaded\" DATETIME DEFAULT NULL);\nCREATE TABLE IF NOT EXISTS \"_grist_Triggers\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"eventTypes\" TEXT DEFAULT NULL, \"isReadyColRef\" INTEGER DEFAULT 0, \"actions\" TEXT DEFAULT '', \"label\" TEXT DEFAULT '', \"memo\" TEXT DEFAULT '', \"enabled\" BOOLEAN DEFAULT 0, \"watchedColRefList\" TEXT DEFAULT NULL, \"options\" TEXT DEFAULT '', \"condition\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"_grist_ACLRules\" (id INTEGER PRIMARY KEY, \"resource\" INTEGER DEFAULT 0, \"permissions\" INTEGER DEFAULT 0, \"principals\" TEXT DEFAULT '', \"aclFormula\" TEXT DEFAULT '', \"aclColumn\" INTEGER DEFAULT 0, \"aclFormulaParsed\" TEXT DEFAULT '', \"permissionsText\" TEXT DEFAULT '', \"rulePos\" NUMERIC DEFAULT 1e999, \"userAttributes\" TEXT DEFAULT '', \"memo\" TEXT DEFAULT '');\nINSERT INTO _grist_ACLRules VALUES(1,1,63,'[1]','',0,'','',1e999,'','');\nCREATE TABLE IF NOT EXISTS \"_grist_ACLResources\" (id INTEGER PRIMARY KEY, \"tableId\" TEXT DEFAULT '', \"colIds\" TEXT DEFAULT '');\nINSERT INTO _grist_ACLResources VALUES(1,'','');\nCREATE TABLE IF NOT EXISTS \"_grist_ACLPrincipals\" (id INTEGER PRIMARY KEY, \"type\" TEXT DEFAULT '', \"userEmail\" TEXT DEFAULT '', \"userName\" TEXT DEFAULT '', \"groupName\" TEXT DEFAULT '', \"instanceId\" TEXT DEFAULT '');\nINSERT INTO _grist_ACLPrincipals VALUES(1,'group','','','Owners','');\nINSERT INTO _grist_ACLPrincipals VALUES(2,'group','','','Admins','');\nINSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors','');\nINSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers','');\nCREATE TABLE IF NOT EXISTS \"_grist_ACLMemberships\" (id INTEGER PRIMARY KEY, \"parent\" INTEGER DEFAULT 0, \"child\" INTEGER DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_Filters\" (id INTEGER PRIMARY KEY, \"viewSectionRef\" INTEGER DEFAULT 0, \"colRef\" INTEGER DEFAULT 0, \"filter\" TEXT DEFAULT '', \"pinned\" BOOLEAN DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_Cells\" (id INTEGER PRIMARY KEY, \"tableRef\" INTEGER DEFAULT 0, \"colRef\" INTEGER DEFAULT 0, \"rowId\" INTEGER DEFAULT 0, \"root\" BOOLEAN DEFAULT 0, \"parentId\" INTEGER DEFAULT 0, \"type\" INTEGER DEFAULT 0, \"content\" TEXT DEFAULT '', \"userRef\" TEXT DEFAULT '', \"timeCreated\" DATETIME DEFAULT NULL, \"timeUpdated\" DATETIME DEFAULT NULL, \"resolved\" BOOLEAN DEFAULT 0);\nCREATE TABLE IF NOT EXISTS \"_grist_Shares\" (id INTEGER PRIMARY KEY, \"linkId\" TEXT DEFAULT '', \"options\" TEXT DEFAULT '', \"label\" TEXT DEFAULT '', \"description\" TEXT DEFAULT '');\nCREATE TABLE IF NOT EXISTS \"Table1\" (id INTEGER PRIMARY KEY, \"manualSort\" NUMERIC DEFAULT 1e999, \"A\" BLOB DEFAULT NULL, \"B\" BLOB DEFAULT NULL, \"C\" BLOB DEFAULT NULL);\nCREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);\nCOMMIT;\n`;\n"
  },
  {
    "path": "app/server/lib/log.ts",
    "content": "/**\n * Configures grist logging. This is merely a customization of the 'winston' logging module,\n * and all winston methods are available. Additionally provides log.timestamp() function.\n * Usage:\n *    import log from 'app/server/lib/log';\n *    log.info(...);\n */\n\nimport { timeFormat } from \"app/common/timeFormat\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\n\nimport * as winston from \"winston\";\n\nconst logAsJson = appSettings.section(\"log\").flag(\"json\").readBool({\n  envVar: [\"GRIST_LOG_AS_JSON\", \"GRIST_HOSTED_VERSION\"],\n  preferredEnvVar: \"GRIST_LOG_AS_JSON\",\n  defaultValue: false,\n});\n\ninterface LogWithTimestamp extends winston.LoggerInstance {\n  timestamp(): string;\n  // We'd like to log raw json, for convenience of parsing downstream.\n  // We have a customization that interferes with meta arguments, and\n  // existing log messages that depend on that customization.  For\n  // clarity then, we just add \"raw\" flavors of the primary level\n  // methods that pass their object argument through to winston.\n  rawError(msg: string, meta: ILogMeta): void;\n  rawInfo(msg: string, meta: ILogMeta): void;\n  rawWarn(msg: string, meta: ILogMeta): void;\n  rawDebug(msg: string, meta: ILogMeta): void;\n  origLog(level: string, msg: string, ...args: any[]): void;\n}\n\n/**\n * Hack winston to provide a saner behavior with regard to its optional arguments. Winston allows\n * two optional arguments at the end: \"meta\" (if object) and \"callback\" (if function). We don't\n * use them, but we do use variable number of arguments as in log.info(\"foo %s\", foo). If foo is\n * an object, winston dumps it in an ugly way, not at all as intended. We fix by always appending\n * {} to the end of the arguments, so that winston sees an empty meta object.\n * We can add support for callback if ever needed.\n */\nconst origLog = winston.Logger.prototype.log;\nwinston.Logger.prototype.log = function(level: string, msg: string, ...args: any[]) {\n  return origLog.call(this, level, msg, ...args, {});\n};\n\nconst rawLog = new (winston.Logger)();\nconst log: LogWithTimestamp = Object.assign(rawLog, {\n  timestamp,\n  /**\n   * Versions of log.info etc that take a meta parameter.  For\n   * winston, logs are streams of info objects.  Info objects\n   * have two mandatory fields, level and message.  They can\n   * have other fields, called \"meta\" fields.  When logging\n   * in json, those fields are added directly to the json,\n   * rather than stringified into the message field, which\n   * is what we want and why we are adding these variants.\n   */\n  rawError: (msg: string, meta: ILogMeta) => origLog.call(log, \"error\", msg, meta),\n  rawInfo: (msg: string, meta: ILogMeta) => origLog.call(log, \"info\", msg, meta),\n  rawWarn: (msg: string, meta: ILogMeta) => origLog.call(log, \"warn\", msg, meta),\n  rawDebug: (msg: string, meta: ILogMeta) => origLog.call(log, \"debug\", msg, meta),\n  origLog,\n  add: rawLog.add.bind(rawLog),  // Explicitly pass add method along - otherwise\n  // there's an odd glitch under Electron.\n});\n\n/**\n * Returns the current timestamp as a string in the same format as used in logging.\n */\nfunction timestamp() {\n  return timeFormat(\"A\", new Date());\n}\n\nconst fileTransportOptions = {\n  stream: process.stderr,\n  level: process.env.GRIST_LOG_LEVEL || \"debug\",\n  timestamp: log.timestamp,\n  colorize: true,\n  json: logAsJson,\n};\n\n// Configure logging to use console and simple timestamps.\nlog.add(winston.transports.File, fileTransportOptions);\n\n// Also update the default logger to use the same format.\nwinston.remove(winston.transports.Console);\nwinston.add(winston.transports.File, fileTransportOptions);\n\n// It's a little tricky to export a type when the top-level export is an object.\ndeclare namespace log {\n  interface ILogMeta {\n    [key: string]: any;\n  }\n}\nexport type ILogMeta = log.ILogMeta;\n\nexport { logAsJson };\nexport default log;\n"
  },
  {
    "path": "app/server/lib/loginSystemHelpers.ts",
    "content": "import { AppSettings } from \"app/server/lib/AppSettings\";\nimport { GristLoginSystem } from \"app/server/lib/GristServer\";\n\n/**\n * Get the selected login system type from app settings.\n * This checks the GRIST_LOGIN_SYSTEM_TYPE environment variable.\n * Returns undefined if not explicitly set.\n */\nexport function getActiveLoginSystemType(settings: AppSettings) {\n  const flag = settings.section(\"login\").flag(\"type\");\n  // Just trigger the read, notice that this does cache the result.\n  const value = flag.readString({\n    envVar: \"GRIST_LOGIN_SYSTEM_TYPE\",\n  });\n  return value;\n}\n\n/**\n * Get the source of the active login system type from app settings.\n */\nexport function getActiveLoginSystemTypeSource(settings: AppSettings) {\n  const flag = settings.section(\"login\").flag(\"type\");\n  // Just trigger the read, notice that this does cache the result.\n  const value = flag.readString({\n    envVar: \"GRIST_LOGIN_SYSTEM_TYPE\",\n  });\n  if (value) {\n    const source = flag.describe().source;\n    return source;\n  }\n  return null;\n}\n\n/**\n * Exception thrown by the login system configuration readers when the system is not configured.\n */\nexport class NotConfiguredError extends Error {\n\n}\n\n/**\n * Helper to get a login provider if it is selected or no other provider is selected.\n */\nexport function createLoginProviderFactory(\n  key: string,\n  builder: (settings: AppSettings) => Promise<GristLoginSystem>,\n): (settings: AppSettings) => Promise<GristLoginSystem | null> {\n  return async (settings: AppSettings) => {\n    // First check what provider is selected explicitly.\n    const selected = getActiveLoginSystemType(settings);\n\n    // If some other provider is explicitly selected, skip this one.\n    if (selected && selected !== key) {\n      return null;\n    }\n\n    // No other is selected, or we are the selected one, try to build our provider.\n    try {\n      const system = await builder(settings);\n      // If we are here, the provider is configured, set it as active.\n      settings.section(\"login\").flag(\"active\").set(key);\n      return system;\n    } catch (e) {\n      // Otherwise, ignore NotConfiguredError to allow fallback.\n      if (e instanceof NotConfiguredError) {\n        return null;\n      }\n      // Since some configuration is present, but there was some other error, set this provider as active\n      // to avoid trying other providers, as user implicitly selected this one.\n      settings.section(\"login\").flag(\"active\").set(key);\n      throw e;\n    }\n  };\n}\n\n/**\n * Helper to get a login provider as a fallback option.\n * This will always try to build the provider, and if it fails, it will re-throw the exception.\n */\nexport function getFallbackLoginProvider(\n  key: string,\n  builder: (settings: AppSettings) => Promise<GristLoginSystem>,\n): (settings: AppSettings) => Promise<GristLoginSystem> {\n  return async (settings: AppSettings) => {\n    const system = await builder(settings);\n    // If we are here, the provider is configured, set it as active.\n    settings.section(\"login\").flag(\"active\").set(key);\n    return system;\n  };\n}\n"
  },
  {
    "path": "app/server/lib/manifest.ts",
    "content": "import { BarePlugin } from \"app/plugin/PluginManifest\";\nimport PluginManifestTI from \"app/plugin/PluginManifest-ti\";\n\nimport * as path from \"path\";\n\nimport * as fse from \"fs-extra\";\nimport * as yaml from \"js-yaml\";\nimport { createCheckers } from \"ts-interface-checker\";\n\nconst manifestChecker = createCheckers(PluginManifestTI).BarePlugin;\n/**\n * Validate the manifest and generate appropriate errors.\n */\n// TODO: should validate that the resources referenced within the manifest are located within the\n// plugin folder\n// TODO: Need a comprehensive test that triggers every notices;\nfunction isValidManifest(manifest: any, notices: string[]): boolean {\n  if (!manifest) {\n    notices.push(\"missing manifest\");\n    return false;\n  }\n  try {\n    manifestChecker.check(manifest);\n  } catch (e) {\n    notices.push(`Invalid manifest: ${e.message}`);\n    return false;\n  }\n  try {\n    manifestChecker.strictCheck(manifest);\n  } catch (e) {\n    notices.push(`WARNING: ${e.message}`);\n    /* but don't fail */\n  }\n  if (Object.keys(manifest.contributions).length === 0) {\n    notices.push(\"WARNING: no valid contributions\");\n  }\n  return true;\n}\n\n/**\n * A ManifestError is an error caused by a wrongly formatted manifest or missing manifest. The\n * `notices` property holds a user-friendly description of the error(s).\n */\nexport class ManifestError extends Error {\n  constructor(public notices: string[], message: string = \"\") {\n    super(message);\n  }\n}\n\n/**\n * Parse the manifest. Look first for a Yaml manifest and then if missing for a Json manifest.\n */\nexport async function readManifest(pluginPath: string): Promise<BarePlugin> {\n  const notices: string[] = [];\n  const manifest: any = await _readManifest(pluginPath);\n  // We allow contributions and components to be omitted as shorthand\n  // for being the empty object.\n  if (!manifest.contributions) { manifest.contributions = {}; }\n  if (!manifest.components) { manifest.components = {}; }\n  if (isValidManifest(manifest, notices)) {\n    return manifest as BarePlugin;\n  }\n  throw new ManifestError(notices);\n}\n\nasync function _readManifest(pluginPath: string): Promise<object> {\n  async function readManifestFile(fileExtension: string): Promise<string> {\n    return await fse.readFile(path.join(pluginPath, \"manifest.\" + fileExtension), \"utf8\");\n  }\n  try {\n    return yaml.load(await readManifestFile(\"yml\")) as object;\n  } catch (e) {\n    if (e instanceof yaml.YAMLException) {\n      throw new Error(\"error parsing yaml manifest: \" + e.message);\n    }\n  }\n  try {\n    return JSON.parse(await readManifestFile(\"json\"));\n  } catch (e) {\n    if (e instanceof SyntaxError) {\n      throw new Error(\"error parsing json manifest\" + e.message);\n    }\n    throw new Error(\"cannot read manifest file: \" + e.message);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/middleware.ts",
    "content": "import { NextFunction, Request, Response } from \"express\";\n\n/**\n * Sets Cache-Control response header to \"no-cache\".\n */\nexport function disableCache(_req: Request, res: Response, next: NextFunction) {\n  res.header(\"Cache-Control\", \"no-cache\");\n\n  next();\n}\n\n/**\n * Calls the `next` function.\n */\nexport function noop(_req: Request, _res: Response, next: NextFunction) {\n  next();\n}\n"
  },
  {
    "path": "app/server/lib/oidc/Protections.ts",
    "content": "import { StringUnion } from \"app/common/StringUnion\";\nimport { SessionOIDCInfo } from \"app/server/lib/BrowserSession\";\n\nimport { AuthorizationParameters, generators, OpenIDCallbackChecks } from \"openid-client\";\n\nexport const EnabledProtection = StringUnion(\n  \"STATE\",\n  \"NONCE\",\n  \"PKCE\",\n);\nexport type EnabledProtectionString = typeof EnabledProtection.type;\n\ninterface Protection {\n  generateSessionInfo(): SessionOIDCInfo;\n  forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters;\n  getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks;\n}\n\nfunction checkIsSet(value: string | undefined, message: string): string {\n  if (!value) { throw new Error(message); }\n  return value;\n}\n\nclass PKCEProtection implements Protection {\n  public generateSessionInfo(): SessionOIDCInfo {\n    return {\n      code_verifier: generators.codeVerifier(),\n    };\n  }\n\n  public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters {\n    return {\n      code_challenge: generators.codeChallenge(checkIsSet(sessionInfo.code_verifier, \"Login is stale\")),\n      code_challenge_method: \"S256\",\n    };\n  }\n\n  public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks {\n    return {\n      code_verifier: checkIsSet(sessionInfo.code_verifier, \"Login is stale\"),\n    };\n  }\n}\n\nclass NonceProtection implements Protection {\n  public generateSessionInfo(): SessionOIDCInfo {\n    return {\n      nonce: generators.nonce(),\n    };\n  }\n\n  public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters {\n    return {\n      nonce: sessionInfo.nonce,\n    };\n  }\n\n  public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks {\n    return {\n      nonce: checkIsSet(sessionInfo.nonce, \"Login is stale\"),\n    };\n  }\n}\n\nclass StateProtection implements Protection {\n  public generateSessionInfo(): SessionOIDCInfo {\n    return {\n      state: generators.state(),\n    };\n  }\n\n  public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters {\n    return {\n      state: sessionInfo.state,\n    };\n  }\n\n  public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks {\n    return {\n      state: checkIsSet(sessionInfo.state, \"Login or logout failed to complete\"),\n    };\n  }\n}\n\nexport class ProtectionsManager implements Protection {\n  private _protections: Protection[] = [];\n\n  constructor(private _enabledProtections: Set<EnabledProtectionString>) {\n    if (this._enabledProtections.has(\"STATE\")) {\n      this._protections.push(new StateProtection());\n    }\n    if (this._enabledProtections.has(\"NONCE\")) {\n      this._protections.push(new NonceProtection());\n    }\n    if (this._enabledProtections.has(\"PKCE\")) {\n      this._protections.push(new PKCEProtection());\n    }\n  }\n\n  public generateSessionInfo(): SessionOIDCInfo {\n    const sessionInfo: SessionOIDCInfo = {};\n    for (const protection of this._protections) {\n      Object.assign(sessionInfo, protection.generateSessionInfo());\n    }\n    return sessionInfo;\n  }\n\n  public forgeAuthUrlParams(sessionInfo: SessionOIDCInfo): AuthorizationParameters {\n    const authParams: AuthorizationParameters = {};\n    for (const protection of this._protections) {\n      Object.assign(authParams, protection.forgeAuthUrlParams(sessionInfo));\n    }\n    return authParams;\n  }\n\n  public getCallbackChecks(sessionInfo: SessionOIDCInfo): OpenIDCallbackChecks {\n    const checks: OpenIDCallbackChecks = {};\n    for (const protection of this._protections) {\n      Object.assign(checks, protection.getCallbackChecks(sessionInfo));\n    }\n    return checks;\n  }\n\n  public supportsProtection(protection: EnabledProtectionString) {\n    return this._enabledProtections.has(protection);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/places.ts",
    "content": "/**\n * Utilities related to the layout of the application and where parts are stored.\n */\n\nimport * as path from \"path\";\n\n/**\n * codeRoot is the directory containing ./app with all the JS code.\n */\nexport const codeRoot = path.dirname(path.dirname(path.dirname(__dirname)));\n\nlet _cachedAppRoot: string | undefined;\n\n/**\n * Returns the appRoot, i.e. the directory containing ./sandbox, ./node_modules,\n * etc.\n */\nexport function getAppRoot(): string {\n  if (_cachedAppRoot) { return _cachedAppRoot; }\n  _cachedAppRoot = getAppRootWithoutCaching();\n  return _cachedAppRoot;\n}\n\n// Uncached version of getAppRoot()\nfunction getAppRootWithoutCaching(): string {\n  if (process.env.APP_ROOT_PATH) { return process.env.APP_ROOT_PATH; }\n  if (codeRoot.endsWith(\"/_build/core\") || codeRoot.endsWith(\"\\\\_build\\\\core\")) {\n    return path.dirname(path.dirname(codeRoot));\n  }\n  return (codeRoot.endsWith(\"/_build\") || codeRoot.endsWith(\"\\\\_build\")) ? path.dirname(codeRoot) : codeRoot;\n}\n\n/**\n * When packaged as an electron application, most files are stored in a .asar\n * archive.  Most, but not all.  This method takes the \"application root\"\n * which is that .asar file in packaged form, and returns a directory where\n * remaining files are available on the regular filesystem.\n */\nexport function getUnpackedAppRoot(appRoot: string = getAppRoot()): string {\n  if (path.basename(appRoot) == \"app.asar\") {\n    return path.resolve(path.dirname(appRoot), \"app.asar.unpacked\");\n  }\n  if (path.dirname(appRoot).endsWith(\"app.asar\")) {\n    return path.resolve(path.dirname(path.dirname(appRoot)),\n      \"app.asar.unpacked\", \"core\");\n  }\n  return path.resolve(path.dirname(appRoot), path.basename(appRoot, \".asar\"));\n}\n\n/**\n * Return the correct root for a given subdirectory.\n */\nexport function getAppRootFor(appRoot: string, subdirectory: string): string {\n  if ([\"sandbox\", \"plugins\", \"public-api\"].includes(subdirectory)) {\n    return getUnpackedAppRoot(appRoot);\n  }\n  return appRoot;\n}\n\n/**\n * Return the path to a given subdirectory, from the correct appRoot.\n */\nexport function getAppPathTo(appRoot: string, subdirectory: string): string {\n  return path.resolve(getAppRootFor(appRoot, subdirectory), subdirectory);\n}\n\n/**\n * Returns the instance root. Defaults to appRoot, unless overridden by GRIST_INST_DIR.\n */\nexport function getInstanceRoot() {\n  return path.resolve(process.env.GRIST_INST_DIR || getAppRoot());\n}\n"
  },
  {
    "path": "app/server/lib/reportTimeTaken.ts",
    "content": "import log from \"app/server/lib/log\";\n\nexport function reportTimeTaken<T>(locationLabel: string, callback: () => T): T {\n  const start = Date.now();\n  try {\n    return callback();\n  } finally {\n    const timeTaken = Date.now() - start;\n    log.debug(\"Time taken in %s: %s ms\", locationLabel, timeTaken);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/requestUtils.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail } from \"app/common/gristUrls\";\nimport * as gutil from \"app/common/gutil\";\nimport { SingleCell } from \"app/common/TableData\";\nimport { DocScope, Scope } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { QueryResult } from \"app/gen-server/lib/homedb/Interfaces\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { getUserId, RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { RequestWithOrg } from \"app/server/lib/extractOrg\";\nimport { RequestWithGrist } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport { Permit } from \"app/server/lib/Permit\";\n\nimport { IncomingMessage } from \"http\";\nimport { Writable } from \"stream\";\nimport { TLSSocket } from \"tls\";\n\nimport { Request, Response } from \"express\";\n\nconst shouldLogApiDetails = appSettings.section(\"log\").flag(\"apiDetails\").readBool({\n  envVar: [\"GRIST_LOG_API_DETAILS\", \"GRIST_HOSTED_VERSION\"],\n  preferredEnvVar: \"GRIST_LOG_API_DETAILS\",\n  defaultValue: false,\n});\n\n// Offset to https ports in dev/testing environment.\nexport const TEST_HTTPS_OFFSET = process.env.GRIST_TEST_HTTPS_OFFSET ?\n  parseInt(process.env.GRIST_TEST_HTTPS_OFFSET, 10) : undefined;\n\n// Database fields that we permit in entities but don't want to cross the api.\nconst INTERNAL_FIELDS = new Set([\n  \"apiKey\", \"billingAccountId\", \"firstLoginAt\", \"lastConnectionAt\", \"filteredOut\", \"ownerId\", \"gracePeriodStart\",\n  \"stripeCustomerId\", \"stripeSubscriptionId\", \"stripeProductId\", \"userId\", \"isFirstTimeUser\", \"allowGoogleLogin\",\n  \"authSubject\", \"usage\", \"createdBy\", \"unsubscribeKey\",\n]);\n\n/**\n * Adapt a home-server or doc-worker URL to match the hostname in the request URL. For custom\n * domains and when GRIST_SERVE_SAME_ORIGIN is set, we replace the full hostname; otherwise just\n * the base of the hostname. The changes to url are made in-place.\n *\n * For dev purposes, port is kept but possibly adjusted for TEST_HTTPS_OFFSET. Note that if port\n * is different from req's port, it is not considered same-origin for CORS purposes, but would\n * still receive cookies.\n */\nexport function adaptServerUrl(url: URL, req: RequestWithOrg): void {\n  const reqBaseDomain = parseSubdomain(req.hostname).base;\n\n  if (process.env.GRIST_SERVE_SAME_ORIGIN === \"true\" || req.isCustomHost) {\n    url.hostname = req.hostname;\n  } else if (reqBaseDomain) {\n    const subdomain: string | undefined = parseSubdomain(url.hostname).org || DEFAULT_HOME_SUBDOMAIN;\n    url.hostname = `${subdomain}${reqBaseDomain}`;\n  }\n\n  // In dev/test environment we can turn on a flag to adjust URLs to use https.\n  if (TEST_HTTPS_OFFSET && url.port && url.protocol === \"http:\") {\n    url.port = String(parseInt(url.port, 10) + TEST_HTTPS_OFFSET);\n    url.protocol = \"https:\";\n  }\n}\n\n/**\n * If org is not encoded in domain, prefix it to path - otherwise leave path unchanged.\n * The domain is extracted from the request, so this method is only useful for constructing\n * urls that stay within that domain.\n */\nexport function addOrgToPathIfNeeded(req: RequestWithOrg, path: string): string {\n  return (isOrgInPathOnly(req.hostname) && req.org) ? `/o/${req.org}${path}` : path;\n}\n\n/**\n * If org is known, prefix it to path unconditionally.\n */\nexport function addOrgToPath(req: RequestWithOrg, path: string): string {\n  return req.org ? `/o/${req.org}${path}` : path;\n}\n\n/**\n * Get url to the org associated with the request.\n */\nexport function getOrgUrl(req: Request, path: string = \"/\") {\n  // Be careful to include a leading slash in path, to ensure we don't modify the origin or org.\n  return getOriginUrl(req) + addOrgToPathIfNeeded(req, sanitizePathTail(path));\n}\n\n/**\n * Returns true for requests from permitted origins.  For such requests, if\n * a Response object is provided, an \"Access-Control-Allow-Origin\" header is added\n * to the response.  Vary: Origin is also set to reflect the fact that the headers\n * are a function of the origin, to prevent inappropriate caching on the browser's side.\n */\nexport function trustOrigin(req: IncomingMessage, resp?: Response): boolean {\n  // TODO: We may want to consider changing allowed origin values in the future.\n  // Note that the request origin is undefined for non-CORS requests.\n  const origin = req.headers.origin;\n  if (!origin) { return true; } // Not a CORS request.\n  if (!allowHost(req, new URL(origin))) { return false; }\n\n  if (resp) {\n    // For a request to a custom domain, the full hostname must match.\n    resp.header(\"Access-Control-Allow-Origin\", origin);\n    resp.header(\"Vary\", \"Origin\");\n  }\n  return true;\n}\n\n// Returns whether req satisfies the given allowedHost. Unless req is to a custom domain, it is\n// enough if only the base domains match. Differing ports are allowed, which helps in dev/testing.\nexport function allowHost(req: IncomingMessage, allowedHost: string | URL) {\n  const proto = getEndUserProtocol(req);\n  const actualUrl = new URL(getOriginUrl(req));\n  const allowedUrl = (typeof allowedHost === \"string\") ? new URL(`${proto}://${allowedHost}`) : allowedHost;\n  log.rawDebug(\"allowHost: \", {\n    req: (new URL(req.url!, `http://${req.headers.host}`).href),\n    origin: req.headers.origin,\n    actualUrl: actualUrl.hostname,\n    allowedUrl: allowedUrl.hostname,\n  });\n  if ((req as RequestWithOrg).isCustomHost) {\n    // For a request to a custom domain, the full hostname must match.\n    return actualUrl.hostname === allowedUrl.hostname;\n  } else {\n    // For requests to a native subdomains, only the base domain needs to match.\n    const allowedDomain = parseSubdomain(allowedUrl.hostname);\n    const actualDomain = parseSubdomain(actualUrl.hostname);\n    return actualDomain.base ?\n      actualDomain.base === allowedDomain.base :\n      actualUrl.hostname === allowedUrl.hostname;\n  }\n}\n\nexport function matchesBaseDomain(domain: string, baseDomain: string) {\n  return domain === baseDomain || domain.endsWith(\".\" + baseDomain);\n}\n\nexport function isParameterOn(parameter: any): boolean {\n  return gutil.isAffirmative(parameter);\n}\n\n/**\n * Get Scope from request, and make sure it has everything needed for a document.\n */\nexport function getDocScope(req: Request): DocScope {\n  const scope = getScope(req);\n  if (!scope.urlId) { throw new Error(\"document required\"); }\n  return scope as DocScope;\n}\n\n/**\n * Extract information included in the request that may restrict the scope of\n * that request.  Not all requests will support all restrictions.\n *\n * - userId - Mandatory.  Produced by authentication middleware.\n *     Information returned and actions taken will be limited by what\n *     that user has access to.\n *\n * - org - Optional.  Extracted by middleware.  Limits\n *     information/action to the given org.  Not every endpoint\n *     respects this limit.  Possible exceptions include endpoints for\n *     listing orgs a user has access to, and endpoints with an org id\n *     encoded in them.\n *\n * - urlId - Optional.  Embedded as \"did\" (or \"docId\") path parameter in endpoints related\n *     to documents.  Specifies which document the request pertains to.  Can\n *     be a urlId or a docId.\n *\n * - includeSupport - Optional.  Embedded as \"includeSupport\" query parameter.\n *     Just a few endpoints support this, it is a very specific \"hack\" for including\n *     an example workspace in org listings.\n *\n * - showRemoved - Optional.  Embedded as \"showRemoved\" query parameter.\n *     Supported by many endpoints.  When absent, request is limited\n *     to docs/workspaces that have not been removed.  When present, request\n *     is limited to docs/workspaces that have been removed.\n */\nexport function getScope(req: Request): Scope {\n  const { specialPermit, docAuth } = req as RequestWithLogin;\n  const urlId = req.params.did || req.params.docId || docAuth?.docId || undefined;\n  const userId = getUserId(req);\n  const org = (req as RequestWithOrg).org;\n  const includeSupport = isParameterOn(req.query.includeSupport);\n  const showRemoved = isParameterOn(req.query.showRemoved);\n  return { urlId, userId, org, includeSupport, showRemoved, specialPermit };\n}\n\n/**\n * If scope is for the given userId, return a new Scope with the special permit added.\n */\nexport function addPermit(scope: Scope, userId: number, specialPermit: Permit): Scope {\n  return { ...scope, ...(scope.userId === userId ? { specialPermit } : {}) };\n}\n\nexport interface SendReplyOptions {\n  allowedFields?: Set<string>;\n}\n\n// Return a JSON response reflecting the output of a query.\n// Filter out keys we don't want crossing the api.\n// Set req to null to not log any information about request.\nexport async function sendReply<T>(\n  req: Request | null,\n  res: Response,\n  result: QueryResult<T>,\n  options: SendReplyOptions = {},\n) {\n  const data = pruneAPIResult(result.data, options.allowedFields);\n  if (shouldLogApiDetails && req) {\n    const mreq = req as RequestWithLogin;\n    const docId = mreq.docAuth?.docId;\n    log.rawDebug(\"api call\", {\n      url: req.url,\n      userId: mreq.userId,\n      altSessionId: mreq.altSessionId,\n      email: mreq.user?.loginEmail,\n      org: mreq.org,\n      params: req.params,\n      ...(docId ? { docId } : {}),\n    });\n  }\n  res.status(result.status);\n  if (result.status >= 200 && result.status < 300) {\n    return res.json(data ?? null); // can't handle undefined\n  } else {\n    return res.json({ error: result.errMessage });\n  }\n}\n\nexport async function sendOkReply<T>(\n  req: Request | null,\n  res: Response,\n  result?: T,\n  options: SendReplyOptions = {},\n) {\n  return sendReply(req, res, { status: 200, data: result }, options);\n}\n\nexport function pruneAPIResult<T>(data: T, allowedFields?: Set<string>): T {\n  // TODO: This can be optimized by pruning data recursively without serializing in between. But\n  // it's fairly fast even with serializing (on the order of 15usec/kb).\n  const output = JSON.stringify(data,\n    (key: string, value: any) => {\n      // Do not include removedAt field if it is not set.  It is not relevant to regular\n      // situations where the user is working with non-deleted resources.\n      if (key === \"removedAt\" && value === null) { return undefined; }\n      // Same for disabledAt\n      if (key === \"disabledAt\" && value === null) { return undefined; }\n      // Don't bother sending option fields if there are no options set.\n      if (key === \"options\" && value === null) { return undefined; }\n      // Don't prune anything that is explicitly allowed.\n      if (allowedFields?.has(key)) { return value; }\n      // User connect id is not used in regular configuration, so we remove it from the response, when\n      // it's not filled.\n      if (key === \"connectId\" && value === null) { return undefined; }\n      return INTERNAL_FIELDS.has(key) ? undefined : value;\n    });\n  return output !== undefined ? JSON.parse(output) : undefined;\n}\n\n/**\n * Access the canonical docId associated with the request.  Must have already authorized.\n */\nexport function getDocId(req: Request) {\n  const mreq = req as RequestWithLogin;\n  // We should always have authorized by now.\n  if (!mreq.docAuth?.docId) { throw new ApiError(`unknown document`, 500); }\n  return mreq.docAuth.docId;\n}\n\nexport interface StringParamOptions {\n  allowed?: readonly string[];\n  /* Defaults to true. */\n  allowEmpty?: boolean;\n}\n\nexport function optStringParam(p: any, name: string, options: StringParamOptions = {}): string | undefined {\n  if (p === undefined) { return p; }\n\n  return stringParam(p, name, options);\n}\n\nexport function stringParam(p: any, name: string, options: StringParamOptions = {}): string {\n  const { allowed, allowEmpty = true } = options;\n  if (p === null || p === undefined) {\n    throw new ApiError(`${name} parameter is required`, 400);\n  }\n  if (typeof p !== \"string\") {\n    throw new ApiError(`${name} parameter should be a string: ${p}`, 400);\n  }\n  if (!allowEmpty && p === \"\") {\n    throw new ApiError(`${name} parameter cannot be empty`, 400);\n  }\n  if (allowed && !allowed.includes(p)) {\n    throw new ApiError(`${name} parameter ${p} should be one of ${allowed}`, 400);\n  }\n  return p;\n}\n\nexport function stringArrayParam(p: any, name: string): string[] {\n  if (!Array.isArray(p)) {\n    throw new ApiError(`${name} parameter should be an array: ${p}`, 400);\n  }\n  if (p.some(el => typeof el !== \"string\")) {\n    throw new ApiError(`${name} parameter should be a string array: ${p}`, 400);\n  }\n\n  return p;\n}\n\nexport function optIntegerParam(\n  p: any,\n  name: string,\n  options?: { nullable?: false; isValid?: (n: number) => boolean },\n): number | undefined;\nexport function optIntegerParam(\n  p: any,\n  name: string,\n  options: { nullable: true; isValid?: (n: number) => boolean },\n): number | null | undefined;\nexport function optIntegerParam(\n  p: any,\n  name: string,\n  options: { nullable?: boolean; isValid?: (n: number) => boolean } = {},\n): number | undefined {\n  if (p === undefined) {\n    return p;\n  }\n  if (options.nullable && p === \"null\") {\n    return p;\n  }\n\n  return integerParam(p, name, options);\n}\n\nexport function integerParam(\n  p: any,\n  name: string,\n  options: { isValid?: (n: number) => boolean } = {},\n): number {\n  const { isValid } = options;\n  let result: number | null = null;\n  if (typeof p === \"number\") {\n    result = Math.floor(p);\n  } else if (typeof p === \"string\") {\n    result = parseInt(p, 10);\n  }\n  if (result === null || Number.isNaN(result)) {\n    throw new ApiError(\n      `${name} parameter cannot be understood as an integer: ${p}`,\n      400,\n    );\n  }\n  if (isValid && !isValid(result)) {\n    throw new ApiError(`${name} parameter is invalid: ${p}`, 400);\n  }\n\n  return result;\n}\n\nexport function optBooleanParam(p: any, name: string): boolean | undefined {\n  if (p === undefined) { return p; }\n\n  return booleanParam(p, name);\n}\n\nexport function booleanParam(p: any, name: string): boolean {\n  if (typeof p === \"boolean\") { return p; }\n  if (gutil.isAffirmative(p)) { return true; }\n  if (String(p) === \"false\") { return false; }\n  throw new ApiError(`${name} parameter should be a boolean: ${p}`, 400);\n}\n\nexport function optJsonParam(p: any, defaultValue: any): any {\n  if (typeof p !== \"string\") { return defaultValue; }\n  return gutil.safeJsonParse(p, defaultValue);\n}\n\nexport interface RequestWithGristInfo extends Request {\n  gristInfo?: string;\n}\n\n/**\n * Returns original request origin. In case, when a client was connected to proxy\n * or load balancer, it reads protocol from forwarded headers.\n * More can be read on:\n * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto\n * https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html\n */\nexport function getOriginUrl(req: IncomingMessage) {\n  const host = req.headers.host;\n  const protocol = getEndUserProtocol(req);\n  return `${protocol}://${host}`;\n}\n\n/**\n * Returns the original request IP address.\n *\n * If the request was made through a proxy or load balancer, the IP address\n * is read from forwarded headers. See:\n *\n *  - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For\n *  - https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html\n */\nexport function getOriginIpAddress(req: IncomingMessage) {\n  return (\n    // May contain multiple comma-separated values; the first one is the original.\n    (req.headers[\"x-forwarded-for\"] as string | undefined)\n      ?.split(\",\")\n      .map(value => value.trim())[0] ||\n      req.socket?.remoteAddress ||\n      undefined\n  );\n}\n\n/**\n * Returns the request's \"X-Forwarded-For\" header, with the request's IP address\n * appended to its value.\n *\n * If the header is absent from the request, a new header will be returned.\n */\nexport function buildXForwardedForHeader(req: Request): { \"X-Forwarded-For\": string } | undefined {\n  const values = req.get(\"X-Forwarded-For\")?.split(\",\").map(value => value.trim()) ?? [];\n  if (req.socket.remoteAddress) { values.push(req.socket.remoteAddress); }\n  return values.length > 0 ? { \"X-Forwarded-For\": values.join(\", \") } : undefined;\n}\n\n/**\n * Get the protocol to use in Grist URLs that are intended to be reachable\n * from a user's browser. Use the protocol in APP_HOME_URL if available,\n * otherwise X-Forwarded-Proto is set on the provided request, otherwise\n * the protocol of the request itself.\n */\nexport function getEndUserProtocol(req: IncomingMessage) {\n  if (process.env.APP_HOME_URL) {\n    return new URL(process.env.APP_HOME_URL).protocol.replace(\":\", \"\");\n  }\n  // TODO we shouldn't blindly trust X-Forwarded-Proto. See the Express approach:\n  // https://expressjs.com/en/5x/api.html#trust.proxy.options.table\n  return req.headers[\"x-forwarded-proto\"] || ((req.socket as TLSSocket)?.encrypted ? \"https\" : \"http\");\n}\n\n/**\n * In some configurations, session information may be cached by the server.\n * When session information changes, give the server a chance to clear its\n * cache if needed.\n */\nexport function clearSessionCacheIfNeeded(req: Request, options?: {\n  email?: string,\n  org?: string | null,\n  sessionID?: string,\n}) {\n  (req as RequestWithGrist).gristServer?.getSessions().clearCacheIfNeeded(options);\n}\n\nexport function addAbortHandler(req: Request, res: Writable, op: () => void) {\n  // It became hard to detect aborted connections in node 16.\n  // In node 14, req.on('close', ...) did the job.\n  // The following is a work-around, until a better way is discovered\n  // or added. Aborting a req will typically lead to 'close' being called\n  // on the response, without writableFinished being set.\n  //   https://github.com/nodejs/node/issues/38924\n  //   https://github.com/nodejs/node/issues/40775\n  res.on(\"close\", () => {\n    const aborted = !res.writableFinished;\n    if (aborted) {\n      op();\n    }\n  });\n}\n\n/**\n   * Attachment-related endpoints can be given some extra flags to\n   * specify a cell in which the attachment is expected to be, so user\n   * access to the attachment can be proven efficiently (otherwise we\n   * have to search for a proof).  A `maybeNew` flag can be set to\n   * specify that the attachment may be a recent upload that is not\n   * yet referenced in the document.\n   */\nexport function getExtraAttachmentOptions(req: Request): {\n  cell?: SingleCell,\n  maybeNew?: boolean,\n} {\n  const tableId = optStringParam(req.query.tableId, \"tableId\");\n  const colId = optStringParam(req.query.colId, \"colId\");\n  const rowId = optIntegerParam(req.query.rowId, \"rowId\");\n  if ((tableId || colId || rowId) && !(tableId && colId && rowId)) {\n    throw new ApiError(\"define all of tableId, colId and rowId, or none.\", 400);\n  }\n  const cell = (tableId && colId && rowId) ? { tableId, colId, rowId } : undefined;\n  const maybeNew = gutil.isAffirmative(req.query.maybeNew);\n  return { cell, maybeNew };\n}\n"
  },
  {
    "path": "app/server/lib/runSQLQuery.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport * as Types from \"app/plugin/DocApiTypes\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { docSessionFromRequest, OptDocSession } from \"app/server/lib/DocSession\";\nimport log from \"app/server/lib/log\";\nimport { optIntegerParam } from \"app/server/lib/requestUtils\";\nimport { isRequest, RequestOrSession } from \"app/server/lib/sessionUtils\";\n\n// Maximum duration of a `runSQLQuery` call. Does not apply to internal calls to SQLite.\nconst MAX_CUSTOM_SQL_MSEC = appSettings\n  .section(\"integrations\")\n  .section(\"sql\")\n  .flag(\"timeout\")\n  .requireInt({\n    envVar: \"GRIST_SQL_TIMEOUT_MSEC\",\n    defaultValue: 1000,\n  });\n\n/**\n * Executes a SQL SELECT statement on a document and returns the result.\n */\nexport async function runSQLQuery(\n  requestOrSession: NonNullable<RequestOrSession>,\n  activeDoc: ActiveDoc,\n  options: Types.SqlPost,\n) {\n  let docSession: OptDocSession;\n  if (isRequest(requestOrSession)) {\n    docSession = docSessionFromRequest(requestOrSession);\n  } else {\n    docSession = requestOrSession;\n  }\n  if (!(await activeDoc.canCopyEverything(docSession))) {\n    throw new ApiError(\"insufficient document access\", 403);\n  }\n\n  const statement = options.sql.replace(/;$/, \"\");\n  // A very loose test, just for early error message\n  if (!statement.toLowerCase().includes(\"select\")) {\n    throw new ApiError(\"only select statements are supported\", 400);\n  }\n\n  const sqlOptions = activeDoc.docStorage.getOptions();\n  if (\n    !sqlOptions?.canInterrupt ||\n    !sqlOptions?.bindableMethodsProcessOneStatement\n  ) {\n    throw new ApiError(\"The available SQLite wrapper is not adequate\", 500);\n  }\n  const timeout = Math.max(\n    0,\n    Math.min(\n      MAX_CUSTOM_SQL_MSEC,\n      optIntegerParam(options.timeout, \"timeout\") || MAX_CUSTOM_SQL_MSEC,\n    ),\n  );\n  // Wrap in a select to commit to the SELECT branch of SQLite\n  // grammar. Note ; isn't a problem.\n  //\n  // The underlying SQLite functions used will only process the\n  // first statement in the supplied text. For node-sqlite3, the\n  // remainder is placed in a \"tail string\" ignored by that library.\n  // So a Robert'); DROP TABLE Students;-- style attack isn't applicable.\n  //\n  // Since Grist is used with multiple SQLite wrappers, not just\n  // node-sqlite3, we have added a bindableMethodsProcessOneStatement\n  // flag that will need adding for each wrapper, and this endpoint\n  // will not operate unless that flag is set to true.\n  //\n  // The text is wrapped in select * from (USER SUPPLIED TEXT) which\n  // puts SQLite unconditionally onto the SELECT branch of its\n  // grammar. It is straightforward to break out of such a wrapper\n  // with multiple statements, but again, only the first statement\n  // is processed.\n  const wrappedStatement = `select * from (${statement})`;\n  const interrupt = setTimeout(async () => {\n    try {\n      await activeDoc.docStorage.interrupt();\n    } catch (e) {\n      // Should be unreachable, but just in case...\n      log.error(\"runSQL interrupt failed with error \", e);\n    }\n  }, timeout);\n  try {\n    return await activeDoc.docStorage.all(\n      wrappedStatement,\n      ...(options.args || []),\n    );\n  } finally {\n    clearTimeout(interrupt);\n  }\n}\n"
  },
  {
    "path": "app/server/lib/sandboxUtil.ts",
    "content": "/**\n * Various utilities and constants for communicating with the python sandbox.\n */\nimport * as MemBuffer from \"app/common/MemBuffer\";\nimport log from \"app/server/lib/log\";\n\n/**\n * SandboxError is an error type for reporting errors forwarded from the sandbox.\n */\nexport class SandboxError extends Error {\n  constructor(message: string) {\n    super(\"[Sandbox] \" + (message || \"Python reported an error\"));\n  }\n}\n\n/**\n * Special msgCode values that precede msgBody to indicate what kind of message it is.\n * These all cost one byte. If we needed more, we should probably switch to a number (5 bytes)\n *    CALL = call to the other side. The data must be an array of [func_name, arguments...]\n *    DATA = data must be a value to return to a call from the other side\n *    EXC = data must be an exception to return to a call from the other side\n */\nexport const CALL = null;\nexport const DATA = true;\nexport const EXC = false;\n\n/**\n * Returns a function that takes data buffers and logs them to log.info() with the given prefix.\n * The logged output is line-oriented, so that the prefix is only inserted at the start of a line.\n * Binary data is encoded as with JSON.stringify.\n */\nexport function makeLinePrefixer(prefix: string, logMeta: object) {\n  return _makeLinePrefixer(prefix, logMeta, text => text.indexOf(\"\\n\"));\n}\n\n/**\n * Same as makeLinePrefixer, but avoids splitting lines except when a line starts with '[', since\n * the sandbox prefixes all log messages with \"[LEVEL]\" prefix.\n */\nexport function makeLogLinePrefixer(prefix: string, logMeta: object) {\n  return _makeLinePrefixer(prefix, logMeta, (text) => {\n    const newline = text.indexOf(\"\\n[\");\n    // If no next log message, split at the last newline. Any earlier newlines would be included.\n    return (newline !== -1) ? newline : text.lastIndexOf(\"\\n\");\n  });\n}\n\nfunction _makeLinePrefixer(prefix: string, logMeta: object, findLineEnd: (text: string) => number) {\n  let partial = \"\";\n  return (data: Uint8Array) => {\n    partial += MemBuffer.arrayToString(data);\n    let newline;\n    while (partial && (newline = findLineEnd(partial)) !== -1) {\n      const line = partial.slice(0, newline);\n      partial = partial.slice(newline + 1);\n      // Escape some parts of the string by serializing it to JSON (without the quotes).\n      log.origLog(\"info\", \"%s%s\", prefix,\n        JSON.stringify(line).slice(1, -1).replace(/\\\\(['\"\\\\])/g, \"$1\").replace(/\\\\n/g, \"\\n\"),\n        logMeta);\n    }\n  };\n}\n"
  },
  {
    "path": "app/server/lib/scim/index.ts",
    "content": "import { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { InstallAdmin } from \"app/server/lib/InstallAdmin\";\nimport { buildScimRouterv2 } from \"app/server/lib/scim/v2/ScimV2Api\";\n\nimport * as express from \"express\";\n\nconst buildScimRouter = (dbManager: HomeDBManager, installAdmin: InstallAdmin) => {\n  const v2 = buildScimRouterv2(dbManager, installAdmin);\n  const scim = express.Router();\n  scim.use(\"/v2\", v2);\n  return scim;\n};\n\nexport { buildScimRouter };\n"
  },
  {
    "path": "app/server/lib/scim/v2/BaseController.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { LogMethods } from \"app/server/lib/LogMethods\";\nimport { RequestContext } from \"app/server/lib/scim/v2/ScimTypes\";\n\nimport SCIMMY from \"scimmy\";\n\nexport class BaseController {\n  protected logger = new LogMethods(this.constructor.name, () => ({}));\n  protected invalidIdError: string;\n\n  constructor(\n    protected dbManager: HomeDBManager,\n    protected checkAccess: (context: RequestContext) => void,\n  ) {}\n\n  protected getIdFromResource(resource: SCIMMY.Types.Resource) {\n    const id = parseInt(resource.id!, 10);\n    if (Number.isNaN(id)) {\n      throw new SCIMMY.Types.Error(400, \"invalidValue\", this.invalidIdError);\n    }\n    return id;\n  }\n\n  /**\n   * Apply the passed filter if it exists, otherwise return directly the passed result.\n   *\n   * This also circumvents the issue that filter.match just returns any[]\n   * (See: https://github.com/scimmyjs/scimmy/pull/87)\n   */\n  protected maybeApplyFilter<T extends SCIMMY.Types.Schema>(\n    prefilteredResults: T[], filter?: SCIMMY.Types.Filter,\n  ): T[] {\n    return filter ? filter.match(prefilteredResults) : prefilteredResults;\n  }\n\n  /**\n   * Runs the passed callback and handles any errors that might occur.\n   * Also checks if the user has access to the operation.\n   * Any public method of this class should be run through this method.\n   *\n   * @param context The request context to check access for the user\n   * @param cb The callback to run\n   */\n  protected async runAndHandleErrors<T>(context: RequestContext, cb: () => Promise<T>): Promise<T> {\n    try {\n      this.checkAccess(context);\n      return await cb();\n    } catch (err) {\n      if (err instanceof ApiError) {\n        this.logger.error(null, \" ApiError: \", err.status, err.message);\n        if (err.status === 409) {\n          throw new SCIMMY.Types.Error(err.status, \"uniqueness\", err.message);\n        }\n        throw new SCIMMY.Types.Error(err.status, null!, err.message);\n      }\n      if (err instanceof SCIMMY.Types.Error) {\n        this.logger.error(null, \" SCIMMY.Types.Error: \", err.message);\n        throw err;\n      }\n      // By default, return a 500 error\n      this.logger.error(null, \" Error: \", err.message);\n      throw new SCIMMY.Types.Error(500, null!, err.message);\n    }\n  }\n}\n"
  },
  {
    "path": "app/server/lib/scim/v2/ScimGroupController.ts",
    "content": "import { Group } from \"app/gen-server/entity/Group\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { BaseController } from \"app/server/lib/scim/v2/BaseController\";\nimport { RequestContext } from \"app/server/lib/scim/v2/ScimTypes\";\nimport { toGroupDescriptor, toSCIMMYGroup } from \"app/server/lib/scim/v2/ScimUtils\";\n\nimport SCIMMY from \"scimmy\";\n\ntype GroupSchema = SCIMMY.Schemas.Group;\ntype GroupResource = SCIMMY.Resources.Group;\n\nclass ScimGroupController extends BaseController {\n  public constructor(\n    dbManager: HomeDBManager,\n    checkAccess: (context: RequestContext) => void,\n  ) {\n    super(dbManager, checkAccess);\n    this.invalidIdError = \"Invalid passed group ID\";\n  }\n\n  /**\n   * Gets a single group with the passed ID.\n   *\n   * @param resource The SCIMMY group resource performing the operation\n   * @param context The request context\n   */\n  public async getSingleGroup(resource: GroupResource, context: RequestContext): Promise<GroupSchema> {\n    return this.runAndHandleErrors(context, async () => {\n      const id = this.getIdFromResource(resource);\n      const group = await this.dbManager.getGroupWithMembersById(id);\n      if (!group || group.type !== Group.TEAM_TYPE) {\n        throw new SCIMMY.Types.Error(404, null!, `Group with ID ${id} not found`);\n      }\n      return toSCIMMYGroup(group);\n    });\n  }\n\n  /**\n   * Gets all groups.\n   * @param resource The SCIMMY group resource performing the operation\n   * @param context The request context\n   * @returns All groups\n   */\n  public async getGroups(resource: GroupResource, context: RequestContext): Promise<GroupSchema[]> {\n    return this.runAndHandleErrors(context, async () => {\n      const scimmyGroup = (await this.dbManager.getGroupsWithMembersByType(Group.TEAM_TYPE))\n        .map(group => toSCIMMYGroup(group));\n      return this.maybeApplyFilter(scimmyGroup, resource.filter);\n    });\n  }\n\n  /**\n   * Creates a new group with the passed data.\n   *\n   * @param data The data to create the group with\n   * @param context The request context\n   */\n  public async createGroup(data: GroupSchema, context: RequestContext): Promise<GroupSchema> {\n    return this.runAndHandleErrors(context, async () => {\n      const groupDescriptor = toGroupDescriptor(data);\n      const group = await this.dbManager.createGroup(groupDescriptor);\n      return toSCIMMYGroup(group);\n    });\n  }\n\n  /**\n   * Overwrites a group with the passed data.\n   *\n   * @param resource The SCIMMY group resource performing the operation\n   * @param data The data to overwrite the group with\n   * @param context The request context\n   */\n  public async overwriteGroup(\n    resource: GroupResource, data: GroupSchema, context: RequestContext,\n  ): Promise<GroupSchema> {\n    return this.runAndHandleErrors(context, async () => {\n      const id = this.getIdFromResource(resource);\n      const groupDescriptor = toGroupDescriptor(data);\n      const group = await this.dbManager.overwriteTeamGroup(id, groupDescriptor);\n      return toSCIMMYGroup(group);\n    });\n  }\n\n  /**\n   * Deletes a group with the passed ID.\n   *\n   * @param resource The SCIMMY group resource performing the operation\n   * @param context The request context\n   *\n   */\n  public async deleteGroup(resource: GroupResource, context: RequestContext): Promise<void> {\n    return this.runAndHandleErrors(context, async () => {\n      const id = this.getIdFromResource(resource);\n      await this.dbManager.deleteGroup(id, Group.TEAM_TYPE);\n    });\n  }\n}\n\nexport function getScimGroupConfig(\n  dbManager: HomeDBManager, checkAccess: (context: RequestContext) => void,\n) {\n  const controller = new ScimGroupController(dbManager, checkAccess);\n\n  return {\n    egress: async (resource: GroupResource, context: RequestContext): Promise<GroupSchema | GroupSchema[]> => {\n      if (resource.id) {\n        return await controller.getSingleGroup(resource, context);\n      }\n      return await controller.getGroups(resource, context);\n    },\n    ingress: async (resource: GroupResource, data: GroupSchema, context: RequestContext): Promise<GroupSchema> => {\n      if (resource.id) {\n        return await controller.overwriteGroup(resource, data, context);\n      }\n      return await controller.createGroup(data, context);\n    },\n    degress: async (resource: GroupResource, context: RequestContext): Promise<void> => {\n      return await controller.deleteGroup(resource, context);\n    },\n  };\n}\n"
  },
  {
    "path": "app/server/lib/scim/v2/ScimRoleController.ts",
    "content": "import { Group } from \"app/gen-server/entity/Group\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { BaseController } from \"app/server/lib/scim/v2/BaseController\";\nimport { SCIMMYRoleResource } from \"app/server/lib/scim/v2/roles/SCIMMYRoleResource\";\nimport { SCIMMYRoleSchema } from \"app/server/lib/scim/v2/roles/SCIMMYRoleSchema\";\nimport { RequestContext } from \"app/server/lib/scim/v2/ScimTypes\";\nimport { toRoleDescriptor, toSCIMMYRole } from \"app/server/lib/scim/v2/ScimUtils\";\n\nimport SCIMMY from \"scimmy\";\n\nclass ScimRoleController extends BaseController {\n  public constructor(\n    dbManager: HomeDBManager,\n    checkAccess: (context: RequestContext) => void,\n  ) {\n    super(dbManager, checkAccess);\n    this.invalidIdError = \"Invalid passed role ID\";\n  }\n\n  /**\n   * Gets a single group with the passed ID.\n   *\n   * @param resource The SCIMMY resource of the group to get\n   * @param context The request context\n   */\n  public async getSingleRole(resource: SCIMMYRoleResource, context: RequestContext): Promise<SCIMMYRoleSchema> {\n    return this.runAndHandleErrors(context, async () => {\n      const id = this.getIdFromResource(resource);\n      const role = await this.dbManager.getGroupWithMembersById(id, { aclRule: true });\n      if (!role || role.type !== Group.ROLE_TYPE) {\n        throw new SCIMMY.Types.Error(404, null!, `Role with ID ${id} not found`);\n      }\n      return toSCIMMYRole(role);\n    });\n  }\n\n  /**\n   * Gets all groups.\n   * @param resource The SCIMMY resource with the filters to apply on the results\n   * @param context The request context\n   * @returns All groups\n   */\n  public async getRoles(resource: SCIMMYRoleResource, context: RequestContext): Promise<SCIMMYRoleSchema[]> {\n    return this.runAndHandleErrors(context, async () => {\n      const scimmyGroup = (await this.dbManager.getGroupsWithMembersByType(Group.ROLE_TYPE, { aclRule: true }))\n        .map(role => toSCIMMYRole(role));\n      return this.maybeApplyFilter(scimmyGroup, resource.filter);\n    });\n  }\n\n  /**\n   * Overwrites a group with the passed data.\n   *\n   * @param resource The SCIMMY role resource to overwrite\n   * @param data The data to overwrite the group with\n   * @param context The request context\n   */\n  public async overwriteRole(\n    resource: SCIMMYRoleResource, data: SCIMMYRoleSchema, context: RequestContext,\n  ): Promise<SCIMMYRoleSchema> {\n    return this.runAndHandleErrors(context, async () => {\n      const id = this.getIdFromResource(resource);\n      const groupDescriptor = toRoleDescriptor(data);\n      const role = await this.dbManager.overwriteRoleGroup(id, groupDescriptor);\n      return toSCIMMYRole(role);\n    });\n  }\n}\n\nexport function getScimRoleConfig(\n  dbManager: HomeDBManager, checkAccess: (context: RequestContext) => void,\n) {\n  const controller = new ScimRoleController(dbManager, checkAccess);\n  return {\n    egress: async (resource: SCIMMYRoleResource, context: RequestContext) => {\n      if (resource.id) {\n        return await controller.getSingleRole(resource, context);\n      }\n      return await controller.getRoles(resource, context);\n    },\n    ingress: async (resource: SCIMMYRoleResource, data: SCIMMYRoleSchema, context: RequestContext) => {\n      if (resource.id) {\n        return await controller.overwriteRole(resource, data, context);\n      }\n      throw new SCIMMY.Types.Error(501, null!, \"Cannot create Roles.\");\n    },\n    degress: async () => {\n      throw new SCIMMY.Types.Error(501, null!, \"Cannot delete roles\");\n    },\n  };\n}\n"
  },
  {
    "path": "app/server/lib/scim/v2/ScimTypes.ts",
    "content": "export interface RequestContext {\n  path: string;\n  isAdmin: boolean;\n  isScimUser: boolean;\n}\n"
  },
  {
    "path": "app/server/lib/scim/v2/ScimUserController.ts",
    "content": "import { Login } from \"app/gen-server/entity/Login\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { HomeDBManager, Scope } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { BaseController } from \"app/server/lib/scim/v2/BaseController\";\nimport { RequestContext } from \"app/server/lib/scim/v2/ScimTypes\";\nimport { toSCIMMYUser, toUserProfile } from \"app/server/lib/scim/v2/ScimUtils\";\n\nimport SCIMMY from \"scimmy\";\nimport { Filter } from \"scimmy/types\";\nimport {\n  FindOptionsWhere, LessThan, LessThanOrEqual, MoreThan, MoreThanOrEqual,\n  Not, ObjectLiteral, Raw,\n} from \"typeorm\";\n\ntype UserSchema = SCIMMY.Schemas.User;\ntype UserResource = SCIMMY.Resources.User;\n\nclass ScimUserController extends BaseController {\n  public constructor(\n    dbManager: HomeDBManager,\n    checkAccess: (context: RequestContext) => void,\n  ) {\n    super(dbManager, checkAccess);\n    this.invalidIdError = \"Invalid passed user ID\";\n  }\n\n  /**\n   * Gets a single login user with the passed ID.\n   *\n   * @param resource The SCIMMY user resource performing the operation\n   * @param context The request context\n   */\n  public async getSingleUser(resource: UserResource, context: RequestContext) {\n    return this.runAndHandleErrors(context, async () => {\n      const id = this.getIdFromResource(resource);\n      const user = await this.dbManager.getUser(id);\n      if (user?.type !== \"login\") {\n        throw new SCIMMY.Types.Error(404, null!, `User with ID ${id} not found`);\n      }\n      return toSCIMMYUser(user);\n    });\n  }\n\n  /**\n   * Gets all login users or filters them based on the passed filter.\n   *\n   * @param resource The SCIMMY user resource performing the operation\n   * @param context The request context\n   */\n  public async getUsers(resource: UserResource, context: RequestContext): Promise<UserSchema[]> {\n    return this.runAndHandleErrors(context, async (): Promise<UserSchema[]> => {\n      let users: User[];\n\n      const match = this._extractOpAndEmailFromSimpleFilter(resource.filter);\n\n      // If we match the case where the caller just want to filter by the email address\n      // take an optimised branch where we query the database by filtering the entries.\n      if (match) {\n        const { op, value } = match;\n        // Let's fix the maximum number of results to 200 (the default value set by Scimmy\n        // for the maximum number of resources returned in a response).\n        // It's a reasonable limit for a query to the DB.\n        const filterMaxResults = SCIMMY.Config.get().filter.maxResults;\n        users = await this.dbManager.findUsers({\n          where: {\n            logins: this._filterByLoginEmail(op, value),\n            type: \"login\",\n          },\n          take: filterMaxResults + 1,\n        });\n        // Cf https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.1\n        //\n        if (users.length > filterMaxResults) {\n          throw new SCIMMY.Types.Error(\n            // The status should be 400, but Scimmy requires a status 413.\n            // PR submitted to fix that: https://github.com/scimmyjs/scimmy/pull/100\n            413,\n            \"tooMany\",\n            \"Please refine the filter to limit the number of results to less than \" + filterMaxResults,\n          );\n        }\n      // Otherwise we fetch all the users and let Scimmy apply the potential filter.\n      } else {\n        users = await this.dbManager.getUsers({ type: \"login\" });\n      }\n\n      const scimmyUsers = users.map(user => toSCIMMYUser(user));\n      return this.maybeApplyFilter(scimmyUsers, resource.filter, {\n        alreadyFiltered: Boolean(match),\n      });\n    });\n  }\n\n  /**\n   * Creates a new user with the passed data.\n   *\n   * @param data The data to create the user with\n   * @param context The request context\n   */\n  public async createUser(data: UserSchema, context: RequestContext) {\n    return this.runAndHandleErrors(context, async () => {\n      await this._checkEmailCanBeUsed(data.userName);\n      const userProfile = toUserProfile(data);\n      const newUser = await this.dbManager.getUserByLoginWithRetry(userProfile.email, {\n        profile: userProfile,\n      });\n      return toSCIMMYUser(newUser);\n    });\n  }\n\n  /**\n   * Overwrite a user with the passed data.\n   *\n   * @param resource The SCIMMY user resource performing the operation\n   * @param data The data to overwrite the user with\n   * @param context The request context\n   */\n  public async overwriteUser(resource: UserResource, data: UserSchema, context: RequestContext) {\n    return this.runAndHandleErrors(context, async () => {\n      const id = this.getIdFromResource(resource);\n      if (this.dbManager.getSpecialUserIds().includes(id)) {\n        throw new SCIMMY.Types.Error(403, null!, \"System user modification not permitted.\");\n      }\n      const user = await this.dbManager.getUser(id);\n      if (user?.type !== \"login\") {\n        throw new SCIMMY.Types.Error(404, null!, \"unable to find user to update\");\n      }\n      await this._checkEmailCanBeUsed(data.userName, id);\n      const updatedUser = await this.dbManager.overwriteUser(id, toUserProfile(data));\n      return toSCIMMYUser(updatedUser);\n    });\n  }\n\n  /**\n   * Deletes a user with the passed ID.\n   *\n   * @param resource The SCIMMY user resource performing the operation\n   * @param context The request context\n   */\n  public async deleteUser(resource: UserResource, context: RequestContext) {\n    return this.runAndHandleErrors(context, async () => {\n      const id = this.getIdFromResource(resource);\n      if (this.dbManager.getSpecialUserIds().includes(id)) {\n        throw new SCIMMY.Types.Error(403, null!, \"System user deletion not permitted.\");\n      }\n      const user = await this.dbManager.getUser(id);\n      if (user?.type !== \"login\") {\n        throw new SCIMMY.Types.Error(404, null!, \"user not found\");\n      }\n      const fakeScope: Scope = { userId: id };\n      // FIXME: deleteUser should probably be rewritten to not require a scope. We should move\n      //        the scope creation to a controller.\n      await this.dbManager.deleteUser(fakeScope, id);\n    });\n  }\n\n  protected maybeApplyFilter<T extends SCIMMY.Types.Schema>(\n    prefilteredResults: T[], filter?: SCIMMY.Types.Filter, { alreadyFiltered } = { alreadyFiltered: false },\n  ): T[] {\n    return alreadyFiltered ? prefilteredResults : super.maybeApplyFilter(prefilteredResults, filter);\n  }\n\n  /**\n   * Checks if the passed email can be used for a new user or by the existing user.\n   *\n   * @param email The email to check\n   * @param userIdToUpdate The ID of the user to update. Pass this when updating a user,\n   * so it won't raise an error if the passed email is already used by this user.\n   */\n  private async _checkEmailCanBeUsed(email: string, userIdToUpdate?: number) {\n    const existingUser = await this.dbManager.getExistingUserByLogin(email);\n    if (existingUser !== undefined && existingUser.id !== userIdToUpdate) {\n      throw new SCIMMY.Types.Error(409, \"uniqueness\", \"An existing user with the passed email exist.\");\n    }\n  }\n\n  private _extractOpAndEmailFromSimpleFilter(\n    filter?: SCIMMY.Types.Filter,\n  ): { op: Filter.ValidComparisonStrings, value: string } | null {\n    // Ensure we only have a simple filter, with no logical operators (AND / OR / NOT)\n    // If the filter has a OR operator, the filter array would have more than one element\n    // (in which case we don't treat the case and let scimmy do that).\n    const firstFilter = filter?.[0];\n    // Also if the filter has a AND operator, the object would have more than one property.\n    const propNames = firstFilter && typeof firstFilter === \"object\" ? Object.keys(firstFilter) : [];\n    if (filter?.length !== 1 || propNames.length !== 1) {\n      return null;\n    }\n    // Convert the keys to lowercase\n    const propName = propNames[0];\n    // NOTE: Have to convert the property name to lower case. See this issue:\n    // https://github.com/scimmyjs/scimmy/issues/97\n    if (propName.toLowerCase() === \"username\") {\n      return { op: firstFilter[propName][0], value: firstFilter[propName][1] };\n    }\n    if (propName.toLowerCase() === \"email\") {\n      const emailFilter = firstFilter[propName];\n      const emailKeys = Object.keys(emailFilter);\n      if (emailKeys.length === 1 && emailKeys[0].toLowerCase() === \"value\") {\n        const emailValueComp = emailFilter[emailKeys[0]];\n        return { op: emailValueComp[0], value: emailValueComp[1] };\n      }\n    }\n    return null;\n  }\n\n  private _filterByLoginEmail(\n    operator: Filter.ValidComparisonStrings, value: string,\n  ): FindOptionsWhere<Login> | undefined {\n    const escapeLikePattern = (value: string) => value.replace(/[\\\\%_]/g, \"\\\\$&\");\n    const likeWithEscape = (params: ObjectLiteral) => Raw(col => `${col} LIKE :value ESCAPE '\\\\'`, params);\n\n    switch (operator) {\n      case \"eq\":\n        return { email: value };\n      case \"ne\":\n        return { email: Not(value) };\n      case \"pr\":\n        return undefined; // Email is not null, so don't filter anything\n      case \"sw\":\n        return { email: likeWithEscape({ value: `${escapeLikePattern(value)}%` }) };\n      case \"ew\":\n        return { email: likeWithEscape({ value: `%${escapeLikePattern(value)}` }) };\n      case \"co\":\n        return { email: likeWithEscape({ value: `%${escapeLikePattern(value)}%` }) };\n      case \"lt\":\n        return { email: LessThan(value) };\n      case \"le\":\n        return { email: LessThanOrEqual(value) };\n      case \"gt\":\n        return { email: MoreThan(value) };\n      case \"ge\":\n        return { email: MoreThanOrEqual(value) };\n      case \"np\": // Surprisingly seems supported by Scimmy but not specified in RFC. We don't support it.\n      default:\n        // This should not happen, as Scimmy checks the syntax of the filters.\n        // But let's add a safe-guard here.\n        throw new SCIMMY.Types.Error(400, \"invalidFilter\", \"Unknown operator: \" + operator);\n    }\n  }\n}\n\nexport function getScimUserConfig(\n  dbManager: HomeDBManager, checkAccess: (context: RequestContext) => void,\n) {\n  const controller = new ScimUserController(dbManager, checkAccess);\n\n  return {\n    egress: async (\n      resource: UserResource, context: RequestContext,\n    ): Promise<UserSchema | UserSchema[]> => {\n      if (resource.id) {\n        return await controller.getSingleUser(resource, context);\n      }\n      return await controller.getUsers(resource, context);\n    },\n    ingress: async (\n      resource: UserResource, data: UserSchema, context: RequestContext,\n    ): Promise<UserSchema> => {\n      if (resource.id) {\n        return await controller.overwriteUser(resource, data, context);\n      }\n      return await controller.createUser(data, context);\n    },\n    degress: async (resource: UserResource, context: RequestContext): Promise<void> => {\n      return await controller.deleteUser(resource, context);\n    },\n  };\n}\n"
  },
  {
    "path": "app/server/lib/scim/v2/ScimUtils.ts",
    "content": "import { normalizeEmail } from \"app/common/emails\";\nimport { UserProfile } from \"app/common/LoginSessionAPI\";\nimport { AclRuleDoc, AclRuleOrg, AclRuleWs } from \"app/gen-server/entity/AclRule\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { GroupWithMembersDescriptor } from \"app/gen-server/lib/homedb/Interfaces\";\nimport log from \"app/server/lib/log\";\nimport { SCIMMYRoleSchema } from \"app/server/lib/scim/v2/roles/SCIMMYRoleSchema\";\n\nimport SCIMMY from \"scimmy\";\n\nconst SCIM_API_BASE_PATH = \"/api/scim/v2\";\nconst SCIMMY_USER_TYPE = \"User\";\nconst SCIMMY_GROUP_TYPE = \"Group\";\nconst SCIMMY_ROLE_TYPE = \"Role\";\n\n/**\n * Converts a user from your database to a SCIMMY user\n */\nexport function toSCIMMYUser(user: User): SCIMMY.Schemas.User {\n  if (!user.logins) {\n    throw new Error(\"User must have at least one login\");\n  }\n  const locale = user.options?.locale ?? \"en\";\n  return new SCIMMY.Schemas.User({\n    id: String(user.id),\n    userName: user.loginEmail,\n    displayName: user.name,\n    name: {\n      formatted: user.name,\n    },\n    locale,\n    preferredLanguage: locale, // Assume preferredLanguage is the same as locale\n    photos: user.picture ? [{\n      value: user.picture,\n      type: \"photo\",\n      primary: true,\n    }] : undefined,\n    emails: [{\n      value: user.logins[0].displayEmail,\n      primary: true,\n    }],\n  });\n}\n\nexport function toUserProfile(scimUser: SCIMMY.Schemas.User): UserProfile {\n  const emailValue = scimUser.emails?.[0]?.value;\n  if (emailValue && normalizeEmail(emailValue) !== normalizeEmail(scimUser.userName)) {\n    log.warn(`userName \"${scimUser.userName}\" differ from passed primary email \"${emailValue}\".` +\n      \"That should be OK, but be aware that the userName will be ignored in favor of the email to identify the user.\");\n  }\n  return {\n    name: scimUser.displayName ?? \"\", // The empty string will be transformed to a named deduced from the\n    // email by the HomeDBManager\n    picture: scimUser.photos?.[0]?.value,\n    locale: scimUser.locale,\n    email: emailValue ?? scimUser.userName,\n  };\n}\n\nfunction toSCIMMYMembers(group: Group): SCIMMY.Schemas.Group[\"members\"] {\n  return [\n    ...group.memberUsers.map((member: User) => ({\n      value: String(member.id),\n      display: member.name,\n      $ref: `${SCIM_API_BASE_PATH}/Users/${member.id}`,\n      type: SCIMMY_USER_TYPE,\n    })),\n    ...group.memberGroups\n      .filter((member: Group) => member.type === Group.TEAM_TYPE)\n      .map((member: Group) => ({\n        value: String(member.id),\n        display: member.name,\n        $ref: `${SCIM_API_BASE_PATH}/Groups/${member.id}`,\n        type: SCIMMY_GROUP_TYPE,\n      })),\n    ...group.memberGroups\n      .filter((member: Group) => member.type === Group.ROLE_TYPE)\n      .map((member: Group) => ({\n        value: String(member.id),\n        display: member.name,\n        $ref: `${SCIM_API_BASE_PATH}/Roles/${member.id}`,\n        type: SCIMMY_ROLE_TYPE,\n      })),\n  ];\n}\n\nexport function toSCIMMYGroup(group: Group): SCIMMY.Schemas.Group {\n  return new SCIMMY.Schemas.Group({\n    id: String(group.id),\n    displayName: group.name,\n    members: toSCIMMYMembers(group),\n  });\n}\n\nexport function toSCIMMYRole(role: Group): SCIMMYRoleSchema {\n  const { aclRule } = role;\n  return new SCIMMYRoleSchema({\n    id: String(role.id),\n    displayName: role.name,\n    docId: aclRule instanceof AclRuleDoc ? aclRule.docId : undefined,\n    workspaceId: aclRule instanceof AclRuleWs ? aclRule.workspaceId : undefined,\n    orgId: aclRule instanceof AclRuleOrg ? aclRule.orgId : undefined,\n    members: toSCIMMYMembers(role),\n  });\n}\n\nfunction parseId(id: string, type: typeof SCIMMY_USER_TYPE | typeof SCIMMY_GROUP_TYPE): number {\n  const parsedId = parseInt(id, 10);\n  if (Number.isNaN(parsedId)) {\n    throw new SCIMMY.Types.Error(400, \"invalidValue\", `Invalid ${type} member ID: ${id}`);\n  }\n  return parsedId;\n}\n\nfunction membersDescriptors(\n  members: NonNullable<SCIMMY.Schemas.Group[\"members\"]>,\n): Pick<GroupWithMembersDescriptor, \"memberUsers\" | \"memberGroups\"> {\n  return {\n    memberUsers: members\n      .filter(member => member.type === SCIMMY_USER_TYPE)\n      .map(member => parseId(member.value, SCIMMY_USER_TYPE)),\n    memberGroups: members\n      .filter(member => member.type === SCIMMY_GROUP_TYPE)\n      .map(member => parseId(member.value, SCIMMY_GROUP_TYPE)),\n  };\n}\n\nexport function toGroupDescriptor(scimGroup: SCIMMY.Schemas.Group): GroupWithMembersDescriptor {\n  const members = scimGroup.members ?? [];\n  return {\n    name: scimGroup.displayName,\n    type: Group.TEAM_TYPE,\n    ...membersDescriptors(members),\n  };\n}\n\nexport function toRoleDescriptor(scimRole: SCIMMYRoleSchema): GroupWithMembersDescriptor {\n  const members = scimRole.members ?? [];\n  return {\n    name: scimRole.displayName,\n    type: Group.ROLE_TYPE,\n    ...membersDescriptors(members),\n  };\n}\n"
  },
  {
    "path": "app/server/lib/scim/v2/ScimV2Api.ts",
    "content": "import { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { InstallAdmin } from \"app/server/lib/InstallAdmin\";\nimport { SCIMMYRoleResource } from \"app/server/lib/scim/v2/roles/SCIMMYRoleResource\";\nimport { getScimGroupConfig } from \"app/server/lib/scim/v2/ScimGroupController\";\nimport { getScimRoleConfig } from \"app/server/lib/scim/v2/ScimRoleController\";\nimport { RequestContext } from \"app/server/lib/scim/v2/ScimTypes\";\nimport { getScimUserConfig } from \"app/server/lib/scim/v2/ScimUserController\";\n\nimport * as express from \"express\";\nimport SCIMMY from \"scimmy\";\nimport SCIMMYRouters from \"scimmy-routers\";\n\nconst WHITELISTED_PATHS_FOR_NON_ADMINS = [\"/Me\", \"/Schemas\", \"/ResourceTypes\", \"/ServiceProviderConfig\"];\n\nconst buildScimRouterv2 = (dbManager: HomeDBManager, installAdmin: InstallAdmin) => {\n  const v2 = express.Router();\n\n  function checkAccess(context: RequestContext) {\n    const { isAdmin, isScimUser, path } = context;\n    if (!isAdmin && !isScimUser && !WHITELISTED_PATHS_FOR_NON_ADMINS.includes(path)) {\n      throw new SCIMMY.Types.Error(403, null!, \"You are not authorized to access this resource\");\n    }\n  }\n\n  SCIMMY.Resources.declare(SCIMMY.Resources.User, getScimUserConfig(dbManager, checkAccess));\n  SCIMMY.Resources.declare(SCIMMY.Resources.Group, getScimGroupConfig(dbManager, checkAccess));\n  SCIMMY.Resources.declare(SCIMMYRoleResource, getScimRoleConfig(dbManager, checkAccess));\n\n  const scimmyRouter = new SCIMMYRouters({\n    type: \"bearer\",\n    handler: async (request: express.Request) => {\n      const mreq = request as RequestWithLogin;\n      if (mreq.userId === undefined) {\n        // Note that any Error thrown here is automatically converted into a 403 response by SCIMMYRouters.\n        throw new Error(\"You are not authorized to access this resource!\");\n      }\n\n      if (mreq.userId === dbManager.getAnonymousUserId()) {\n        throw new Error(\"Anonymous users cannot access SCIM resources\");\n      }\n\n      return String(mreq.userId); // SCIMMYRouters requires the userId to be a string.\n    },\n    context: async (req: express.Request): Promise<RequestContext> => {\n      const mreq = req as RequestWithLogin;\n      const isAdmin = await installAdmin.isAdminReq(mreq);\n      const isScimUser = Boolean(\n        process.env.GRIST_SCIM_EMAIL && mreq.user?.loginEmail === process.env.GRIST_SCIM_EMAIL,\n      );\n      const path = mreq.path;\n      return { isAdmin, isScimUser, path };\n    },\n  });\n\n  return v2.use(\"/\", scimmyRouter);\n};\n\nexport { buildScimRouterv2 };\n"
  },
  {
    "path": "app/server/lib/scim/v2/roles/SCIMMYRoleResource.ts",
    "content": "import { SCIMMYRoleSchema } from \"app/server/lib/scim/v2/roles/SCIMMYRoleSchema\";\n\nimport SCIMMY from \"scimmy\";\n\n/**\n * SCIMMY Role Resource. Heavily inspired by SCIMMY Group Resource.\n * https://github.com/scimmyjs/scimmy/blob/8b4333edc566a04cd5390ee4aa3272d021610d77/src/lib/resources/group.js\n */\nexport class SCIMMYRoleResource extends SCIMMY.Types.Resource<SCIMMYRoleSchema> {\n  // NB: must be a getter, cannot override this property with readonly attribute\n  public static get endpoint() { // eslint-disable-line @typescript-eslint/class-literal-property-style\n    return \"/Roles\";\n  }\n\n  public static get schema() {\n    return SCIMMYRoleSchema;\n  }\n\n  public static basepath(): string;\n  public static basepath(path: string): typeof SCIMMYRoleResource;\n  // Required by SCIMMY. This seems to be a method with the same logic for every Resouces:\n  // https://github.com/scimmyjs/scimmy/blob/8b4333edc566a04cd5390ee4aa3272d021610d77/src/lib/resources/group.js#L22-L27\n  public static basepath(path?: string) {\n    if (path === undefined) {\n      return SCIMMYRoleResource._basepath;\n    } else {\n      SCIMMYRoleResource._basepath = (path.endsWith(SCIMMYRoleResource.endpoint) ?\n        path :\n        `${path}${SCIMMYRoleResource.endpoint}`);\n    }\n\n    return SCIMMYRoleResource;\n  }\n\n  /** @implements {SCIMMY.Types.Resource.ingress<typeof SCIMMY.Resources.User, SCIMMY.Schemas.User>} */\n  public static ingress(handler: SCIMMY.Types.Resource.IngressHandler<any, any>) {\n    this._ingress = handler;\n    return this;\n  }\n\n  /** @implements {SCIMMY.Types.Resource.egress<typeof SCIMMY.Resources.User, SCIMMY.Schemas.User>} */\n  public static egress(handler: SCIMMY.Types.Resource.EgressHandler<any, SCIMMYRoleSchema>) {\n    this._egress = handler;\n    return this;\n  }\n\n  /** @implements {SCIMMY.Types.Resource.degress<typeof SCIMMY.Resources.User>} */\n  public static degress(handler: SCIMMY.Types.Resource.DegressHandler<any>) {\n    this._degress = handler;\n    return this;\n  }\n\n  private static _basepath: string;\n\n  /** @private */\n  private static _ingress: SCIMMY.Types.Resource.IngressHandler<SCIMMYRoleResource, SCIMMYRoleSchema> = () => {\n    throw new SCIMMY.Types.Error(501, null!, `Method 'ingress' not implemented by resource '${this.name}'`);\n  };\n\n  /** @private */\n  private static _egress: SCIMMY.Types.Resource.EgressHandler<SCIMMYRoleResource, SCIMMYRoleSchema> = () => {\n    throw new SCIMMY.Types.Error(501, null!, `Method 'egress' not implemented by resource '${this.name}'`);\n  };\n\n  /** @private */\n  private static _degress: SCIMMY.Types.Resource.DegressHandler<SCIMMYRoleResource> = () => {\n    throw new SCIMMY.Types.Error(501, null!, `Method 'degress' not implemented by resource '${this.name}'`);\n  };\n\n  /**\n   * Instantiate a new SCIM User resource and parse any supplied parameters\n   * @internal\n   */\n  constructor(...params: any[]) {\n    super(...params);\n  }\n\n  /**\n    * @implements {SCIMMY.Types.Resource#read}\n    * @example\n    * // Retrieve group with ID \"1234\"\n    * await new SCIMMY.Resources.Group(\"1234\").read();\n    * @example\n    * // Retrieve groups with a group name starting with \"A\"\n    * await new SCIMMY.Resources.Group({filter: 'displayName sw \"A\"'}).read();\n    */\n  public async read(ctx: any) {\n    try {\n      const source = await SCIMMYRoleResource._egress(this, ctx);\n      const target = (this.id ? [source].flat().shift() : source);\n\n      // If not looking for a specific resource, make sure egress returned an array\n      if (!this.id && Array.isArray(target)) {\n        return new SCIMMY.Messages.ListResponse(target\n          .map(u => new SCIMMYRoleSchema(\n            u, \"out\", SCIMMYRoleResource.basepath(), this.attributes),\n          ), this.constraints);\n      } else if (target instanceof Object) { // For specific resources, make sure egress returned an object\n        return new SCIMMYRoleSchema(target, \"out\", SCIMMYRoleResource.basepath(), this.attributes);\n      } else { // Otherwise, egress has not been implemented correctly\n        throw new SCIMMY.Types.Error(\n          500, null!, `Unexpected ${target === undefined ? \"empty\" : \"invalid\"} value returned by egress handler`,\n        );\n      }\n    } catch (ex) {\n      if (ex instanceof SCIMMY.Types.Error) {\n        throw ex;\n      } else if (ex instanceof TypeError) {\n        throw new SCIMMY.Types.Error(400, \"invalidValue\", ex.message);\n      } else {\n        throw new SCIMMY.Types.Error(404, null!, `Resource ${this.id} not found`);\n      }\n    }\n  }\n\n  /**\n     * @implements {SCIMMY.Types.Resource#write}\n     * @example\n     * // Create a new group with displayName \"A Group\"\n     * await new SCIMMY.Resources.Group().write({displayName: \"A Group\"});\n     * @example\n     * // Set members attribute for group with ID \"1234\"\n     * await new SCIMMY.Resources.Group(\"1234\").write({members: [{value: \"5678\"}]});\n     */\n  public async write(instance: SCIMMYRoleSchema, ctx: any) {\n    if (instance === undefined) {\n      throw new SCIMMY.Types.Error(\n        400, \"invalidSyntax\", `Missing request body payload for ${this.id ? \"PUT\" : \"POST\"} operation`,\n      );\n    }\n    if (Object(instance) !== instance || Array.isArray(instance)) {\n      throw new SCIMMY.Types.Error(\n        400, \"invalidSyntax\",\n        `Operation ${this.id ? \"PUT\" : \"POST\"} expected request body payload to be single complex value`,\n      );\n    }\n\n    try {\n      const target = await SCIMMYRoleResource._ingress(this, new SCIMMYRoleSchema(instance, \"in\"), ctx);\n\n      // Make sure ingress returned an object\n      if (target instanceof Object) {\n        return new SCIMMYRoleSchema(target, \"out\", SCIMMYRoleResource.basepath(), this.attributes);\n      } else { // Otherwise, ingress has not been implemented correctly\n        throw new SCIMMY.Types.Error(500, null!,\n          `Unexpected ${target === undefined ? \"empty\" : \"invalid\"} value returned by ingress handler`,\n        );\n      }\n    } catch (ex) {\n      if (ex instanceof SCIMMY.Types.Error) {\n        throw ex;\n      } else if (ex instanceof TypeError) {\n        throw new SCIMMY.Types.Error(400, \"invalidValue\", ex.message);\n      } else {\n        throw new SCIMMY.Types.Error(404, null!, `Resource ${this.id} not found`);\n      }\n    }\n  }\n\n  /**\n   * @implements {SCIMMY.Types.Resource#patch}\n   * @see SCIMMY.Messages.PatchOp\n   */\n  public async patch(message: any, ctx: any) {\n    if (!this.id) {\n      throw new SCIMMY.Types.Error(404, null!, \"PATCH operation must target a specific resource\");\n    }\n    if (message === undefined) {\n      throw new SCIMMY.Types.Error(400, \"invalidSyntax\", \"Missing message body from PatchOp request\");\n    }\n    if (Object(message) !== message || Array.isArray(message)) {\n      throw new SCIMMY.Types.Error(\n        400, \"invalidSyntax\", \"PatchOp request expected message body to be single complex value\",\n      );\n    }\n\n    return (await new SCIMMY.Messages.PatchOp(message)\n      .apply((await this.read(ctx)) as SCIMMYRoleSchema, async instance => await this.write(instance, ctx))\n      // NOTE: A bit odd, but the type suggest that we should have an instance of Schema,\n      // but the upstream code suggests that it can be undefined\n      .then(instance => !instance ? undefined :\n        new SCIMMYRoleSchema(instance, \"out\", SCIMMYRoleResource.basepath(), this.attributes)))!;\n  }\n\n  /**\n   * @implements {SCIMMY.Types.Resource#dispose}\n   * @example\n   * // Delete user with ID \"1234\"\n   * await new SCIMMY.Resources.User(\"1234\").dispose();\n   */\n  public async dispose(ctx: any) {\n    if (!this.id) {\n      throw new SCIMMY.Types.Error(\n        404, null!, \"DELETE operation must target a specific resource\",\n      );\n    }\n    try {\n      await SCIMMYRoleResource._degress(this, ctx);\n    } catch (ex) {\n      if (ex instanceof SCIMMY.Types.Error) {\n        throw ex;\n      } else if (ex instanceof TypeError) {\n        throw new SCIMMY.Types.Error(500, null!, ex.message);\n      } else {\n        throw new SCIMMY.Types.Error(404, null!, `Resource ${this.id} not found`);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/server/lib/scim/v2/roles/SCIMMYRoleSchema.ts",
    "content": "import SCIMMY from \"scimmy\";\n\nconst { Attribute, SchemaDefinition } = SCIMMY.Types;\n\n/**\n * SCIMMY Role Schema.\n * Heavily inspired by SCIMMY Group Schema by Sam Lee-Lindsay.\n * https://github.com/scimmyjs/scimmy/blob/8b4333edc566a04cd5390ee4aa3272d021610d77/src/lib/schemas/group.js\n */\nexport class SCIMMYRoleSchema extends SCIMMY.Types.Schema {\n  public static get definition() {\n    return this._definition;\n  }\n\n  private static _definition = (function() {\n    // Clone the Groups schema definition\n    return new SchemaDefinition(\n      \"Role\", \"urn:ietf:params:scim:schemas:Grist:1.0:Role\", \"Role in Grist\", [\n        new Attribute(\"string\", \"displayName\", {\n          mutable: false, direction: \"out\" }),\n        new Attribute(\"complex\", \"members\",\n          { multiValued: true, uniqueness: false, description: \"A list of members of the Role.\" },\n          [\n            new Attribute(\"string\", \"value\",\n              { mutable: \"immutable\", description: \"Identifier of the member of this Role.\" }),\n            new Attribute(\"string\", \"display\",\n              { mutable: \"immutable\", description: \"Human-readable name of the member of this Role.\" }),\n            new Attribute(\"reference\", \"$ref\",\n              {\n                mutable: \"immutable\",\n                referenceTypes: [\"User\", \"Group\", \"Role\"],\n                description: \"The URI corresponding to a SCIM resource that is a member of this Role.\",\n              },\n            ),\n            new Attribute(\"string\", \"type\",\n              {\n                mutable: \"immutable\",\n                canonicalValues: [\"User\", \"Group\", \"Role\"],\n                description: \"A label indicating the type of resource, e.g., 'User', 'Role' or 'Group'.\",\n              },\n            ),\n          ],\n        ),\n        new Attribute(\"string\", \"docId\", { required: false, description: \"The docId associated to this role.\",\n          mutable: false, direction: \"out\" }),\n        new Attribute(\"integer\", \"workspaceId\", { required: false, description: \"The workspaceId for this role\",\n          mutable: false, direction: \"out\" }),\n        new Attribute(\"integer\", \"orgId\", { required: false, description: \"The orgId for this role\",\n          mutable: false, direction: \"out\" }),\n      ]);\n  })();\n\n  public displayName: string;\n  public docId: string | undefined;\n  public workspaceId: number | undefined;\n  public orgId: number | undefined;\n  public members: SCIMMY.Schemas.Group[\"members\"];\n\n  constructor(resource: object, direction = \"both\", basepath?: string, filters?: SCIMMY.Types.Filter) {\n    super(resource, direction);\n    Object.assign(this, SCIMMYRoleSchema._definition.coerce(resource, direction, basepath, filters));\n  }\n}\n"
  },
  {
    "path": "app/server/lib/selectBy.ts",
    "content": "import {\n  buildLinkNodes,\n  isValidLink,\n  LinkNode,\n  LinkNodeOperations,\n  LinkNodeSection,\n  LinkNodeTable,\n} from \"app/common/LinkNode\";\nimport { MetaRowRecord } from \"app/common/TableData\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport {\n  getTableById,\n  getTableColumnsByTableId,\n  getWidgetById,\n  getWidgetsByPageId,\n} from \"app/server/lib/ActiveDocUtils\";\n\nimport { pick } from \"lodash\";\n\nexport interface SelectByOption {\n  link_from_widget_id: number;\n  link_from_column_id: string | null;\n  link_to_column_id: string | null;\n}\n\nexport function getSelectByOptions(\n  doc: ActiveDoc,\n  widgetId: number,\n): SelectByOption[] {\n  const targetWidget = getWidgetById(doc, widgetId);\n  const sourceWidgets = getWidgetsByPageId(doc, targetWidget.parentId);\n  const targetNodes = createNodes(doc, [targetWidget]);\n  const sourceNodes = createNodes(doc, sourceWidgets);\n\n  const options: SelectByOption[] = [];\n  for (const sourceNode of sourceNodes) {\n    const validTargetNodes = targetNodes.filter(targetNode =>\n      isValidLink(sourceNode, targetNode),\n    );\n    for (const targetNode of validTargetNodes) {\n      options.push({\n        link_from_widget_id: sourceNode.section.id,\n        link_from_column_id: sourceNode.column?.colId ?? null,\n        link_to_column_id: targetNode.column?.colId ?? null,\n      });\n    }\n  }\n  return options;\n}\n\nfunction createNodes(\n  doc: ActiveDoc,\n  widgets: MetaRowRecord<\"_grist_Views_section\">[],\n): LinkNode[] {\n  const operations: LinkNodeOperations = {\n    getTableById: id => getLinkNodeTableById(doc, id),\n    getSectionById: id => getLinkNodeSection(doc, id),\n  };\n  const sections = widgets.map(({ id }) => getLinkNodeSection(doc, id));\n  return buildLinkNodes(sections, operations);\n}\n\nfunction getLinkNodeTableById(doc: ActiveDoc, id: number): LinkNodeTable {\n  const table = getTableById(doc, id);\n  const maybeSummaryTable = table.summarySourceTable ?\n    getTableById(doc, table.summarySourceTable) :\n    undefined;\n  return {\n    id: table.id,\n    tableId: maybeSummaryTable?.tableId ?? table.tableId,\n    isSummaryTable: Boolean(\n      maybeSummaryTable && maybeSummaryTable.tableId !== table.tableId,\n    ),\n    columns: getTableColumnsByTableId(doc, id).map(c =>\n      pick(c, \"id\", \"colId\", \"label\", \"type\", \"summarySourceCol\"),\n    ),\n  };\n}\n\nfunction getLinkNodeSection(\n  doc: ActiveDoc,\n  idOrWidget: number | MetaRowRecord<\"_grist_Views_section\">,\n): LinkNodeSection {\n  const widget =\n    typeof idOrWidget === \"number\" ?\n      getWidgetById(doc, idOrWidget) :\n      idOrWidget;\n  const table = getTableById(doc, widget.tableRef);\n  const maybeSummaryTable = table.summarySourceTable ?\n    getTableById(doc, table.summarySourceTable) :\n    undefined;\n  return {\n    ...pick(\n      widget,\n      \"id\",\n      \"tableRef\",\n      \"parentId\",\n      \"parentKey\",\n      \"title\",\n      \"linkSrcSectionRef\",\n      \"linkSrcColRef\",\n      \"linkTargetColRef\",\n    ),\n    tableId: maybeSummaryTable?.tableId ?? table.tableId,\n  };\n}\n"
  },
  {
    "path": "app/server/lib/sendAppPage.ts",
    "content": "import { AssistantConfig } from \"app/common/Assistant\";\nimport {\n  commonUrls,\n  Features,\n  FormFraming,\n  getContactSupportUrl,\n  getFreeCoachingCallUrl,\n  getHelpCenterUrl,\n  getOnboardingVideoId,\n  getPageTitleSuffix,\n  getTermsOfServiceUrl,\n  getWebinarsUrl,\n  GristLoadConfig,\n  IFeature, ImplicitlyEnabledFeatures,\n} from \"app/common/gristUrls\";\nimport { isAffirmative } from \"app/common/gutil\";\nimport { getTagManagerSnippet } from \"app/common/tagManager\";\nimport { Document } from \"app/common/UserAPI\";\nimport { AttachedCustomWidgets, IAttachedCustomWidget } from \"app/common/widgetTypes\";\nimport { SUPPORT_EMAIL } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { appSettings } from \"app/server/lib/AppSettings\";\nimport { isAnonymousUser, isSingleUserMode, RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { RequestWithOrg } from \"app/server/lib/extractOrg\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport {\n  getAnonPlaygroundEnabled, getCanAnyoneCreateOrgs,\n  getOnboardingTutorialDocId, getPersonalOrgsEnabled,\n  getTemplateOrg,\n  getUserPresenceMaxUsers,\n} from \"app/server/lib/gristSettings\";\nimport { getSupportedEngineChoices } from \"app/server/lib/serverUtils\";\nimport { readLoadedLngs, readLoadedNamespaces } from \"app/server/localization\";\n\nimport * as path from \"path\";\n\nimport * as express from \"express\";\nimport * as fse from \"fs-extra\";\nimport * as handlebars from \"handlebars\";\nimport jsesc from \"jsesc\";\nimport difference from \"lodash/difference\";\n\nconst { escapeExpression } = handlebars.Utils;\n\n/**\n * Return the translation given the key.\n *\n * @param req\n * @param key The key of the translation (which will be prefixed by `sendAppPage`)\n * @param args The args to pass to the translation string (optional)\n */\nconst translate = (req: express.Request, key: string, args?: any) =>  req.t(`sendAppPage.${key}`, args)?.toString();\n\nconst GRIST_FEATURE_FORM_FRAMING = appSettings.section(\"features\").flag(\"formFraming\")\n  .requireString({\n    envVar: \"GRIST_FEATURE_FORM_FRAMING\",\n    defaultValue: \"border\",\n    acceptedValues: [\"border\", \"minimal\"],\n  });\n\nexport interface ISendAppPageOptions {\n  path: string;        // Ignored if .content is present (set to \"\" for clarity).\n  content?: string;\n  status: number;\n  config: Partial<GristLoadConfig>;\n  tag?: string;        // If present, override version tag.\n\n  // If present, enable Google Tag Manager on this page (if GOOGLE_TAG_MANAGER_ID env var is set).\n  // Used on the welcome page to track sign-ups. We don't intend to use it for in-app analytics.\n  // Set to true to insert tracker unconditionally; false to omit it; \"anon\" to insert\n  // it only when the user is not logged in.\n  googleTagManager?: true | false | \"anon\";\n}\n\nexport interface MakeGristConfigOptions {\n  homeUrl: string | null;\n  extra: Partial<GristLoadConfig>;\n  baseDomain?: string;\n  req?: express.Request;\n  server?: GristServer | null;\n}\n\nexport function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfig {\n  const { homeUrl, extra, baseDomain, req, server } = options;\n  // .invalid is a TLD the IETF promises will never exist.\n  const pluginUrl = process.env.APP_UNTRUSTED_URL || \"http://plugins.invalid\";\n  const pathOnly = (process.env.GRIST_ORG_IN_PATH === \"true\") ||\n    (homeUrl && new URL(homeUrl).hostname === \"localhost\") || false;\n  const mreq = req as RequestWithOrg | undefined;\n\n  // Configure form framing behavior.\n\n  const config: GristLoadConfig = {\n    homeUrl,\n    org: process.env.GRIST_SINGLE_ORG || (mreq && mreq.org),\n    baseDomain,\n    // True if no subdomains or separate servers are defined for the home servers or doc workers.\n    serveSameOrigin: !baseDomain && pathOnly,\n    singleOrg: process.env.GRIST_SINGLE_ORG,\n    helpCenterUrl: getHelpCenterUrl(),\n    termsOfServiceUrl: getTermsOfServiceUrl(),\n    freeCoachingCallUrl: getFreeCoachingCallUrl(),\n    onboardingTutorialVideoId: getOnboardingVideoId(),\n    webinarsUrl: getWebinarsUrl(),\n    contactSupportUrl: getContactSupportUrl(),\n    pathOnly,\n    supportAnon: shouldSupportAnon(),\n    enableAnonPlayground: getAnonPlaygroundEnabled(),\n    canAnyoneCreateOrgs: getCanAnyoneCreateOrgs(),\n    enablePersonalOrgs: getPersonalOrgsEnabled(),\n    supportEngines: getSupportedEngineChoices(),\n    features: getFeatures(),\n    pageTitleSuffix: configuredPageTitleSuffix(),\n    pluginUrl,\n    stripeAPIKey: process.env.STRIPE_PUBLIC_API_KEY,\n    googleClientId: process.env.GOOGLE_CLIENT_ID,\n    googleDriveScope: process.env.GOOGLE_DRIVE_SCOPE,\n    helpScoutBeaconId: process.env.HELP_SCOUT_BEACON_ID_V2,\n    maxUploadSizeImport: (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || undefined,\n    maxUploadSizeAttachment: (Number(process.env.GRIST_MAX_UPLOAD_ATTACHMENT_MB) * 1024 * 1024) || undefined,\n    timestampMs: Date.now(),\n    enableWidgetRepository: Boolean(process.env.GRIST_WIDGET_LIST_URL) ||\n      ((server?.getBundledWidgets().length || 0) > 0),\n    survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),\n    tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,\n    activation: (req as RequestWithLogin | undefined)?.activation,\n    latestVersionAvailable: server?.getLatestVersionAvailable(),\n    automaticVersionCheckingAllowed: isAffirmative(process.env.GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING),\n    enableCustomCss: isAffirmative(process.env.APP_STATIC_INCLUDE_CUSTOM_CSS),\n    supportedLngs: readLoadedLngs(req?.i18n),\n    namespaces: readLoadedNamespaces(req?.i18n),\n    assistant: getAssistantConfig(server),\n    permittedCustomWidgets: getPermittedCustomWidgets(server),\n    supportEmail: SUPPORT_EMAIL,\n    userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,\n    telemetry: server?.getTelemetry().getTelemetryConfig(req as RequestWithLogin | undefined),\n    deploymentType: server?.getDeploymentType(),\n    forceEnableEnterprise: isAffirmative(process.env.GRIST_FORCE_ENABLE_ENTERPRISE),\n    templateOrg: getTemplateOrg(),\n    onboardingTutorialDocId: getOnboardingTutorialDocId(),\n    canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE),\n    experimentalPlugins: isAffirmative(process.env.GRIST_EXPERIMENTAL_PLUGINS),\n    notifierEnabled: server?.hasNotifier(),\n    formFraming: GRIST_FEATURE_FORM_FRAMING as FormFraming,\n    adminDefinedUrls: process.env.GRIST_CUSTOM_COMMON_URLS,\n    userPresenceMaxUsers: getUserPresenceMaxUsers(),\n    warnBeforeSharingPublicly: isAffirmative(process.env.GRIST_WARN_BEFORE_SHARING_PUBLICLY),\n    // TODO: Add to BootProbes and remove this. We don't need to include this in every page.\n    runningUnderSupervisor: isAffirmative(process.env.GRIST_RUNNING_UNDER_SUPERVISOR),\n  };\n  return {\n    ...config,\n    ...extra,\n  };\n}\n\n/**\n * Creates a method that will send html page that will immediately post a message to a parent window.\n * Primary used for Google Auth Grist's endpoint, but can be used in future in any other server side\n * authentication flow.\n */\nexport function makeMessagePage(staticDir: string) {\n  return async (req: express.Request, resp: express.Response, message: any) => {\n    const fileContent = await fse.readFile(path.join(staticDir, \"message.html\"), \"utf8\");\n    const content = fileContent.replace(\n      \"<!-- INSERT MESSAGE -->\",\n      `<script>window.message = ${jsesc(message, { isScriptContext: true, json: true })};</script>`,\n    );\n    resp.status(200).type(\"html\").send(content);\n  };\n}\n\nexport type SendAppPageFunction =\n  (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;\n\n/**\n * Send a simple template page, read from file at pagePath (relative to static/), with certain\n * placeholders replaced.\n */\nexport function makeSendAppPage({ server, staticDir, tag, testLogin, baseDomain }: {\n  server: GristServer, staticDir: string, tag: string, testLogin?: boolean, baseDomain?: string\n}): SendAppPageFunction {\n  // If env var GRIST_INCLUDE_CUSTOM_SCRIPT_URL is set, load it in a <script> tag on all app pages.\n  const customScriptUrl = process.env.GRIST_INCLUDE_CUSTOM_SCRIPT_URL;\n  const insertCustomScript: string = customScriptUrl ?\n    `<script src=\"${customScriptUrl}\" crossorigin=\"anonymous\"></script>` : \"\";\n\n  return async (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => {\n    const config = makeGristConfig({\n      homeUrl: !isSingleUserMode() ? server.getHomeUrl(req) : null,\n      extra: options.config,\n      baseDomain,\n      req,\n      server,\n    });\n\n    // We could cache file contents in memory, but the filesystem does caching too, and compared\n    // to that, the performance gain is unlikely to be meaningful. So keep it simple here.\n    const fileContent = options.content || await fse.readFile(path.join(staticDir, options.path), \"utf8\");\n\n    const needTagManager = (options.googleTagManager === \"anon\" && isAnonymousUser(req)) ||\n      options.googleTagManager === true;\n    const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : \"\";\n    const staticTag = options.tag || tag;\n    // If boot tag is used, serve assets locally, otherwise respect\n    // APP_STATIC_URL.\n    const staticOrigin = staticTag === \"boot\" ? \"\" : (process.env.APP_STATIC_URL || \"\");\n    const staticBaseUrl = `${staticOrigin}/v/${staticTag}/`;\n    const customHeadHtmlSnippet = server.create.getExtraHeadHtml?.() ?? \"\";\n    const warning = testLogin ? \"<div class=\\\"dev_warning\\\">Authentication is not enforced</div>\" : \"\";\n    // Preload all languages that will be used or are requested by client.\n    const preloads = req.languages\n      .filter(lng => (readLoadedLngs(req.i18n)).includes(lng))\n      .map(lng => lng.replace(\"-\", \"_\"))\n      .map(lng =>\n        readLoadedNamespaces(req.i18n).map(ns =>\n          `<link rel=\"preload\" href=\"locales/${lng}.${ns}.json\" as=\"fetch\" type=\"application/json\" crossorigin>`,\n        ).join(\"\\n\"),\n      ).join(\"\\n\");\n    const content = fileContent\n      .replace(\"<!-- INSERT WARNING -->\", warning)\n      .replace(\"<!-- INSERT TITLE -->\", getDocName(config) ?? escapeExpression(translate(req, \"Loading...\")))\n      .replace(\"<!-- INSERT META -->\", getPageMetadataHtmlSnippet(req, config))\n      .replace(\"<!-- INSERT TITLE SUFFIX -->\", getPageTitleSuffix(server.getGristConfig()))\n      .replace(\"<!-- INSERT BASE -->\", `<base href=\"${staticBaseUrl}\">` + tagManagerSnippet)\n      .replace(\"<!-- INSERT LOCALE -->\", preloads)\n      .replace(\"<!-- INSERT CUSTOM -->\", customHeadHtmlSnippet)\n      .replace(\"<!-- INSERT CUSTOM SCRIPT -->\", insertCustomScript)\n      .replace(\n        \"<!-- INSERT CONFIG -->\",\n        `<script>window.gristConfig = ${jsesc(config, { isScriptContext: true, json: true })};</script>`,\n      );\n    logVisitedPageTelemetryEvent(req as RequestWithLogin, {\n      server,\n      pagePath: options.path,\n      docId: config.assignmentId,\n    });\n    resp.status(options.status).type(\"html\").send(content);\n  };\n}\n\ninterface LogVisitedPageEventOptions {\n  server: GristServer;\n  pagePath: string;\n  docId?: string;\n}\n\nfunction logVisitedPageTelemetryEvent(req: RequestWithLogin, options: LogVisitedPageEventOptions) {\n  const { server, pagePath, docId } = options;\n\n  // Construct a fake URL and append the utm_* parameters from the original URL.\n  // We avoid using the original URL here because it may contain sensitive identifiers,\n  // such as link key parameters and site/doc ids.\n  const url = new URL(\"fake\", server.getMergedOrgUrl(req));\n  for (const [key, value] of Object.entries(req.query)) {\n    if (key.startsWith(\"utm_\")) {\n      url.searchParams.set(key, String(value));\n    }\n  }\n\n  server.getTelemetry().logEvent(req, \"visitedPage\", {\n    full: {\n      docIdDigest: docId,\n      url: url.toString(),\n      path: pagePath,\n      userAgent: req.headers[\"user-agent\"],\n      userId: req.userId,\n      altSessionId: req.altSessionId,\n    },\n  });\n}\n\nfunction shouldSupportAnon() {\n  // Enable UI for anonymous access if a flag is explicitly set in the environment\n  return process.env.GRIST_SUPPORT_ANON === \"true\";\n}\n\nfunction getFeatures(): IFeature[] {\n  const disabledFeatures = process.env.GRIST_HIDE_UI_ELEMENTS?.split(\",\") ?? [];\n  const explicitFeatures = process.env.GRIST_UI_FEATURES?.split(\",\") ?? Features.values;\n  const enabledFeatures = explicitFeatures.concat(ImplicitlyEnabledFeatures);\n  return Features.checkAll(difference(enabledFeatures, disabledFeatures));\n}\n\nfunction getAssistantConfig(gristServer?: GristServer | null): AssistantConfig | undefined {\n  const assistant = gristServer?.getAssistant();\n  if (!assistant) {\n    return undefined;\n  }\n\n  const { provider, version } = assistant;\n  return { provider, version };\n}\n\nfunction getPermittedCustomWidgets(gristServer?: GristServer | null): IAttachedCustomWidget[] {\n  if (!process.env.PERMITTED_CUSTOM_WIDGETS && gristServer) {\n    // The PERMITTED_CUSTOM_WIDGETS environment variable is a bit of\n    // a drag. If there are bundled widgets that overlap with widgets\n    // described in the codebase, let's just assume they are permitted.\n    const widgets = gristServer.getBundledWidgets();\n    const names = new Set(AttachedCustomWidgets.values as string[]);\n    const namesFound: IAttachedCustomWidget[] = [];\n    for (const widget of widgets) {\n      // Permitted custom widgets are identified so many ways across the\n      // code! Why? TODO: cut down on identifiers.\n      const name = widget.widgetId.replace(\"@gristlabs/widget-\", \"custom.\");\n      if (names.has(name)) {\n        namesFound.push(name as IAttachedCustomWidget);\n      }\n    }\n    return AttachedCustomWidgets.checkAll(namesFound);\n  }\n  const widgetsList = process.env.PERMITTED_CUSTOM_WIDGETS?.split(\",\").map(widgetName => `custom.${widgetName}`) ?? [];\n  return AttachedCustomWidgets.checkAll(widgetsList);\n}\n\nfunction configuredPageTitleSuffix() {\n  const result = process.env.GRIST_PAGE_TITLE_SUFFIX;\n  return result === \"_blank\" ? \"\" : result;\n}\n\n/**\n * Returns the doc name.\n *\n * Note: The string returned is escaped and safe to insert into HTML.\n *\n */\nfunction getDocName(config: GristLoadConfig): string | null {\n  const maybeDoc = getDocFromConfig(config);\n\n  return maybeDoc && escapeExpression(maybeDoc.name);\n}\n\n/**\n * Returns a string representation of 0 or more HTML metadata elements.\n *\n * Currently includes the noindex tag, and the document description and\n * thumbnail if the requested page is for a document and the document has one\n * set.\n *\n * Note: The string returned is escaped and safe to insert into HTML.\n */\nfunction getPageMetadataHtmlSnippet(req: express.Request, config: GristLoadConfig): string {\n  const metadataElements: string[] = [];\n  const maybeDoc = getDocFromConfig(config);\n\n  if (maybeDoc?.options?.allowIndex !== true) {\n    metadataElements.push('<meta name=\"robots\" content=\"noindex\">');\n  }\n\n  metadataElements.push('<meta property=\"og:type\" content=\"website\">');\n  metadataElements.push('<meta name=\"twitter:card\" content=\"summary_large_image\">');\n\n  const description = maybeDoc?.options?.description ?\n    escapeExpression(maybeDoc.options.description) :\n    translate(req, \"og-description\");\n  metadataElements.push(`<meta name=\"description\" content=\"${description}\">`);\n  metadataElements.push(`<meta property=\"og:description\" content=\"${description}\">`);\n  metadataElements.push(`<meta name=\"twitter:description\" content=\"${description}\">`);\n\n  const openGraphPreviewImage = process.env.GRIST_OPEN_GRAPH_PREVIEW_IMAGE || commonUrls.openGraphPreviewImage;\n  const image = escapeExpression(maybeDoc?.options?.icon ?? openGraphPreviewImage);\n  metadataElements.push(`<meta name=\"thumbnail\" content=\"${image}\">`);\n  metadataElements.push(`<meta property=\"og:image\" content=\"${image}\">`);\n  metadataElements.push(`<meta name=\"twitter:image\" content=\"${image}\">`);\n\n  const maybeDocTitle = getDocName(config);\n  const title = maybeDocTitle ? maybeDocTitle + getPageTitleSuffix(config) : translate(req, \"og-title\");\n  // NB: We don't generate the content of the <title> tag here.\n  metadataElements.push(`<meta property=\"og:title\" content=\"${title}\">`);\n  metadataElements.push(`<meta name=\"twitter:title\" content=\"${title}\">`);\n\n  return metadataElements.join(\"\\n\");\n}\n\nfunction getDocFromConfig(config: GristLoadConfig): Document | null {\n  if (!config.getDoc || !config.assignmentId) { return null; }\n\n  return config.getDoc[config.assignmentId] ?? null;\n}\n"
  },
  {
    "path": "app/server/lib/serverUtils.ts",
    "content": "import { EngineCode } from \"app/common/DocumentSettings\";\nimport { OptDocSession } from \"app/server/lib/DocSession\";\nimport log from \"app/server/lib/log\";\nimport { getLogMeta } from \"app/server/lib/sessionUtils\";\nimport { OpenMode, SQLiteDB } from \"app/server/lib/SQLiteDB\";\n\nimport { ChildProcess } from \"child_process\";\nimport * as net from \"net\";\nimport * as path from \"path\";\n\nimport bluebird from \"bluebird\";\nimport range from \"lodash/range\";\nimport { AbortSignal } from \"node-abort-controller\";\nimport { ConnectionOptions } from \"typeorm\";\nimport { v4 as uuidv4 } from \"uuid\";\n// This method previously lived in this file. Re-export to avoid changing imports all over.\nexport { timeoutReached } from \"app/common/gutil\";\n\n/**\n * Promisify a node-style callback function. E.g.\n *    fromCallback(cb => someAsyncFunc(someArgs, cb));\n * This is merely a type-checked version of bluebird.fromCallback().\n * (Note that providing it using native Promises is also easy, but bluebird's big benefit is\n * support of long stack traces (when enabled for debugging).\n */\ntype NodeCallback<T> = (err: Error | undefined | null, value?: T) => void;\ntype NodeCallbackFunc<T> = (cb: NodeCallback<T>) => void;\nconst _fromCallback = bluebird.fromCallback;\nexport function fromCallback<T>(nodeFunc: NodeCallbackFunc<T>): Promise<T> {\n  return _fromCallback(nodeFunc);\n}\n\n/**\n * Finds and returns a promise for the first available TCP port.\n * @param {Number} firstPort: First port number to check, defaults to 8000.\n * @param {Number} optCount: Number of ports to check, defaults to 200.\n * @returns Promise<Number>: Promise for an available port.\n */\nexport function getAvailablePort(firstPort: number = 8000, optCount: number = 200): Promise<number> {\n  const lastPort = firstPort + optCount - 1;\n  function checkNext(port: number): Promise<number> {\n    if (port > lastPort) {\n      throw new Error(\"No available ports between \" + firstPort + \" and \" + lastPort);\n    }\n    return new bluebird((resolve: (p: number) => void, reject: (e: Error) => void) => {\n      const server = net.createServer();\n      server.on(\"error\", reject);\n      server.on(\"close\", () => resolve(port));\n      server.listen(port, \"localhost\", () => server.close());\n    })\n      .catch(() => checkNext(port + 1));\n  }\n  return bluebird.try(() => checkNext(firstPort));\n}\n\n/**\n * Promisified version of net.connect(). Takes the same arguments, and returns a Promise for the\n * connected socket. (Types are specified as in @types/node.)\n */\nexport function connect(options: { port: number, host?: string, localAddress?: string, localPort?: string,\n  family?: number, allowHalfOpen?: boolean; }): Promise<net.Socket>;\nexport function connect(port: number, host?: string): Promise<net.Socket>;\nexport function connect(sockPath: string): Promise<net.Socket>;\nexport function connect(arg: any, ...moreArgs: any[]): Promise<net.Socket> {\n  return new Promise((resolve, reject) => {\n    const s = net.connect(arg, ...moreArgs, () => resolve(s));\n    s.on(\"error\", reject);\n  });\n}\n\n/**\n * Promisified version of net.Server.listen().\n */\nexport function listenPromise<T extends net.Server>(server: T): Promise<void> {\n  return new Promise<void>((resolve, reject) => server.once(\"listening\", resolve).once(\"error\", reject));\n}\n\n/**\n * Returns whether the path `inner` is contained within the directory `outer`.\n */\nexport function isPathWithin(outer: string, inner: string): boolean {\n  const rel = path.relative(outer, inner);\n  const index = rel.indexOf(path.sep);\n  const firstDir = index < 0 ? rel : rel.slice(0, index);\n  return firstDir !== \"..\";\n}\n\n/**\n * Returns a promise that's resolved when `child` exits, or rejected if it could not be started.\n * The promise resolves to the numeric exit code, or the string signal that terminated the child.\n *\n * Note that this must be called synchronously after creating the ChildProcess, since a delay may\n * cause the 'error' or 'exit' message from the child to be missed, and the resulting exitPromise\n * would then hang forever.\n */\nexport function exitPromise(child: ChildProcess): Promise<number | string> {\n  return new Promise((resolve, reject) => {\n    // Called if process could not be spawned, or could not be killed(!), or sending a message failed.\n    child.on(\"error\", reject);\n    child.on(\"exit\", (code: number, signal: string) => resolve(signal || code));\n  });\n}\n\n/**\n * Get database url in DATABASE_URL format popularized by heroku, suitable for\n * use by psql, sqlalchemy, etc.\n */\nexport function getDatabaseUrl(options: ConnectionOptions, includeCredentials: boolean): string {\n  if (options.type === \"sqlite\") {\n    return `sqlite://${options.database}`;\n  } else if (options.type === \"postgres\") {\n    const pass = options.password ? `:${options.password}` : \"\";\n    const creds = includeCredentials && options.username ? `${options.username}${pass}@` : \"\";\n    const port = options.port ? `:${options.port}` : \"\";\n    return `postgres://${creds}${options.host}${port}/${options.database}`;\n  } else {\n    return `${options.type}://?`;\n  }\n}\n\n/**\n * Collect checks to be applied to incoming documents that are alleged to be\n * Grist documents. For now, the only check is a sqlite-level integrity check,\n * as suggested by https://www.sqlite.org/security.html#untrusted_sqlite_database_files\n */\nexport async function checkAllegedGristDoc(docSession: OptDocSession, fname: string) {\n  const db = await SQLiteDB.openDBRaw(fname, OpenMode.OPEN_READONLY);\n  try {\n    const integrityCheckResults = await db.all(\"PRAGMA integrity_check\");\n    if (integrityCheckResults.length !== 1 || integrityCheckResults[0].integrity_check !== \"ok\") {\n      const uuid = uuidv4();\n      log.info(\"Integrity check failure on import\", {\n        uuid,\n        integrityCheckResults,\n        ...getLogMeta(docSession),\n      });\n      throw new Error(`Document failed integrity checks - is it corrupted? Event ID: ${uuid}`);\n    }\n  } finally {\n    await db.close();\n  }\n}\n\n/**\n * Only offer choices of engine on experimental deployments (staging/dev).\n */\nexport function getSupportedEngineChoices(): EngineCode[] {\n  return [\"python3\"];\n}\n\n/**\n * Returns a promise that resolves in the given number of milliseconds or rejects\n * when the given signal is raised.\n */\nexport async function delayAbort(msec: number, signal?: AbortSignal): Promise<void> {\n  let cleanup: () => void = () => {};\n  try {\n    await new Promise<void>((resolve, reject) => {\n      const timeout = setTimeout(() => resolve(), msec);\n      signal?.addEventListener(\"abort\", reject);\n      cleanup = () => {\n        // Be careful to clean up both the timer and the listener to avoid leaks.\n        clearTimeout(timeout);\n        signal?.removeEventListener(\"abort\", reject);\n      };\n    });\n  } finally {\n    cleanup();\n  }\n}\n\n/**\n * For a Redis URI, we expect no path component, or a path component\n * that is an integer database number. We'd like to scope pub/sub to\n * individual databases. Redis doesn't do that, so we construct a\n * key prefix to have the same effect.\n *   https://redis.io/docs/manual/pubsub/#database--scoping\n */\nexport function getPubSubPrefix(): string {\n  const redisUrl = process.env.REDIS_URL || process.env.TEST_REDIS_URL;\n  if (!redisUrl) { return \"db-x-\"; }\n  const dbNumber = new URL(redisUrl).pathname.replace(/^\\//, \"\");\n  if (dbNumber.match(/[^0-9]/)) {\n    throw new Error(\"REDIS_URL has an unexpected structure\");\n  }\n  return `db-${dbNumber}-`;\n}\n\n/**\n * Calculates the period when the yearly subscription is expected to reset its usage. The period tells us\n * where we expected the reset date to be. Start date is inclusive, end date is exclusive.\n */\nexport function expectedResetDate(startMs: number, endMs: number, now?: number): number | null {\n  const DAY = 24 * 60 * 60 * 1000;\n\n  const nowMs = now || new Date().getTime();\n\n  // Validate params.\n  if (startMs > endMs) {\n    return null; // start after end\n  }\n\n  // If now is outside the valid period we don't expect a reset at all.\n  const validPeriod = period(startMs, endMs);\n  if (!validPeriod.has(nowMs)) {\n    return null;\n  }\n\n  // Make sure it is a yearly period (more or less).\n  if ((endMs - startMs) < 360 * DAY) {\n    return null;\n  }\n\n  // Bind the calculation to the start date, this doesn't change.\n  const endOf = calcPeriodEnd.bind(null, startMs);\n\n  // Now find the period we are in. In a yearly subscription we have 12 periods. Generate each period\n  // align to the start and end date.\n  const periods = range(0, 12).map((nr) => {\n    if (nr === 0) {\n      return period(startMs, endOf(nr));\n    } else if (nr !== 11) {\n      return period(endOf(nr - 1), endOf(nr));\n    } else {\n      return period(endOf(nr - 1), endMs);\n    }\n  });\n\n  // We expect the reset date only if we are after first period.\n  const current = periods.slice(1).find(p => p.has(nowMs));\n  return current?.[0] ?? null;\n\n  function period(start: number, end: number) {\n    return Object.assign([start, end] as [number, number], {\n      has(x: number) {\n        return x >= start && x < end;\n      },\n    });\n  }\n}\n\n/**\n * It tries to do what Stripe does https://docs.stripe.com/billing/subscriptions/billing-cycle For\n * reference:\n * - A monthly subscription with a billing cycle anchor date of September 2 always bills on the 2nd\n *   day of the month.\n * - A monthly subscription with a billing cycle anchor date of January 31 bills the last day of the\n *   month closest to the anchor date, so February 28 (or February 29 in a leap year), then March\n *   31, April 30, and so on.\n * - A weekly subscription with a billing cycle anchor date of Friday, June 3 bills every Friday\n *   thereafter.\n */\nfunction calcPeriodEnd(anchor: number, nr: number) {\n  // Extract parts of a date anchor component.\n  const calDay = new Date(anchor).getUTCDate();\n  const calMonth = new Date(anchor).getUTCMonth();\n  const calYear = new Date(anchor).getUTCFullYear();\n  const calHour = new Date(anchor).getUTCHours();\n  const calMinute = new Date(anchor).getUTCMinutes();\n  const calSecond = new Date(anchor).getUTCSeconds();\n\n  // We want to find a date in next month that is as close to the anchor date as possible.\n  // In practice we will shift from 31 to 28 maximum.\n  // Constructing a date this way can move the day across year boundaries.\n  const validNextMonthStart = new Date(Date.UTC(calYear, calMonth + nr + 1, 1));\n\n  let maxIterations = 40;\n\n  function iterate(shift = 0): number {\n    // Safe guard against infinite loops.\n    if (maxIterations-- < 0) {\n      throw new Error(\"Too many iterations in expectedResetDate\");\n    }\n    // We start by building up a date in the next month in the same day as the anchor date.\n    const targetDate = new Date(Date.UTC(\n      validNextMonthStart.getUTCFullYear(),\n      validNextMonthStart.getUTCMonth(),\n      calDay + shift,\n      calHour,\n      calMinute,\n      calSecond,\n    ));\n\n    // If the month didn't change we are done.\n    if (targetDate.getUTCMonth() === validNextMonthStart.getUTCMonth()) {\n      return targetDate.getTime();\n    }\n    // Else shift one day earlier and try again.\n    return iterate(shift - 1);\n  }\n\n  return iterate();\n}\n"
  },
  {
    "path": "app/server/lib/sessionUtils.ts",
    "content": "import { DocumentUsage } from \"app/common/DocUsage\";\nimport { Role } from \"app/common/roles\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { AuthSession } from \"app/server/lib/AuthSession\";\nimport { OptDocSession } from \"app/server/lib/DocSession\";\nimport { ILogMeta } from \"app/server/lib/log\";\n\nimport { IncomingMessage } from \"http\";\n\nexport type RequestOrSession = RequestWithLogin | OptDocSession | null;\n\nexport function isRequest(\n  requestOrSession: RequestOrSession,\n): requestOrSession is RequestWithLogin {\n  return Boolean(requestOrSession && \"get\" in requestOrSession);\n}\n\nexport function getAuthSession(requestOrSession: RequestOrSession | null): AuthSession {\n  if (!requestOrSession) { return AuthSession.unauthenticated(); }\n  if (isRequest(requestOrSession)) { return AuthSession.fromReq(requestOrSession); }\n  return requestOrSession;\n}\n\n/**\n * Extract the raw `IncomingMessage` from `requestOrSession`, if available.\n */\nexport function getRequest(requestOrSession: RequestOrSession): IncomingMessage | null {\n  if (!requestOrSession) { return null; }\n\n  // The location of the request depends on the context, which include REST\n  // API calls to document endpoints and WebSocket sessions.\n  if (isRequest(requestOrSession)) {\n    return requestOrSession;\n  } else if (requestOrSession.req) {\n    // A REST API call to a document endpoint.\n    return requestOrSession.req;\n  } else if (requestOrSession.client) {\n    // A WebSocket session.\n    return requestOrSession.client.getConnectionRequest();\n  } else {\n    return null;\n  }\n}\n\n/**\n * Extract access, userId, email, and client (if applicable) from\n * `requestOrSession`, for logging purposes.\n */\nexport function getLogMeta(requestOrSession: RequestOrSession | undefined): ILogMeta {\n  if (!requestOrSession) { return {}; }\n  if (isRequest(requestOrSession)) {\n    return getAuthSession(requestOrSession).getLogMeta();\n  } else {\n    return {\n      ...requestOrSession.getLogMeta(),\n      access: getDocSessionAccessOrNull(requestOrSession),\n    };\n  }\n}\n\n/**\n * Extract user's role from OptDocSession.  Method depends on whether using web\n * sockets or rest api.  Assumes that access has already been checked by wrappers\n * for api methods and that cached access information is therefore available.\n *\n * TODO: it could be nicer to move this to be a method of OptDocSession now that OptDocSession is\n * a class. It would also allow us to put 'access' property into OptDocSession.getLogMeta(), and\n * that, in turn, would let us remove a special case from getLogMeta() above.\n */\nexport function getDocSessionAccess(docSession: OptDocSession): Role {\n  // \"nascent\" DocSessions are for when a document is being created, and user is\n  // its only owner as yet.\n  // \"system\" DocSessions are for access without access control.\n  if (docSession.mode === \"nascent\" || docSession.mode === \"system\") { return \"owners\"; }\n  // \"plugin\" DocSessions are for access from plugins, which is currently quite crude,\n  // and granted only to editors.\n  if (docSession.mode === \"plugin\") { return \"editors\"; }\n  if (docSession.authorizer) {\n    const access = docSession.authorizer.getCachedAuth().access;\n    if (!access) { throw new Error(\"getDocSessionAccess expected authorizer.getCachedAuth\"); }\n    return access;\n  }\n  if (docSession.req) {\n    const access =  docSession.req.docAuth?.access;\n    if (!access) { throw new Error(\"getDocSessionAccess expected req.docAuth.access\"); }\n    return access;\n  }\n  throw new Error(\"getDocSessionAccess could not find access information in DocSession\");\n}\n\nexport function getDocSessionShare(docSession: OptDocSession): string | null {\n  return _getCachedDoc(docSession)?.linkId || null;\n}\n\n/**\n * Get document usage seen in db when we were last checking document\n * access. Not necessarily a live value when using a websocket\n * (although we do recheck access periodically).\n */\nexport function getDocSessionUsage(docSession: OptDocSession): DocumentUsage | null {\n  return _getCachedDoc(docSession)?.usage || null;\n}\n\nfunction _getCachedDoc(docSession: OptDocSession): Document | null {\n  if (docSession.authorizer) {\n    return docSession.authorizer.getCachedAuth().cachedDoc || null;\n  }\n  if (docSession.req) {\n    return docSession.req.docAuth?.cachedDoc || null;\n  }\n  return null;\n}\n\nexport function getDocSessionAccessOrNull(docSession: OptDocSession): Role | null {\n  try {\n    return getDocSessionAccess(docSession);\n  } catch (err) {\n    return null;\n  }\n}\n\n/**\n * Get cached information about the document, if available.  May be stale.\n */\nexport function getDocSessionCachedDoc(docSession: OptDocSession): Document | undefined {\n  return (docSession.req)?.docAuth?.cachedDoc;\n}\n"
  },
  {
    "path": "app/server/lib/shortDesc.ts",
    "content": "import { inspect } from \"util\";\n\nimport defaults from \"lodash/defaults\";\nimport identity from \"lodash/identity\";\n\nfunction truncateString(s: string | Uint8Array, maxLen: number, optStringMapper?: (arg: any) => string): string {\n  const m: (arg: any) => string = optStringMapper || identity;\n  return s.length <= maxLen ? m(s) : m(s.slice(0, maxLen)) + \"... (\" + s.length + \" length)\";\n}\n\nfunction formatUint8Array(array: Uint8Array): string {\n  const s = Buffer.from(array).toString(\"binary\");\n  // eslint-disable-next-line no-control-regex\n  return s.replace(/[\\x00-\\x1f\\x7f-\\xff]/g, \"?\");\n}\n\ninterface DescLimits {\n  maxArrayLength: number;\n  maxObjectKeys: number;\n  maxStringLength: number;\n  maxBufferLength: number;\n}\n\nconst defaultLimits: DescLimits = {\n  maxArrayLength: 5,\n  maxObjectKeys: 10,\n  maxStringLength: 80,\n  maxBufferLength: 80,\n};\n\n/**\n * Produce a human-readable concise description of the object as a string. Similar to\n * util.inspect(), but more concise and more readable.\n * @param {Object} optLimits: Optional limits on how much of a value to include. Supports\n *    maxArrayLength, maxObjectKeys, maxStringLength, maxBufferLength.\n */\nexport function shortDesc(topObj: any, optLimits?: DescLimits): string {\n  const lim = defaults(optLimits || {}, defaultLimits);\n  function _shortDesc(obj: any): string {\n    if (Array.isArray(obj)) {\n      return \"[\" +\n        obj.slice(0, lim.maxArrayLength).map(_shortDesc).join(\", \") +\n        (obj.length > lim.maxArrayLength ? \", ... (\" + obj.length + \" items)\" : \"\") +\n        \"]\";\n    } else if (obj instanceof Uint8Array) {\n      return \"b'\" + truncateString(obj, lim.maxBufferLength, formatUint8Array) + \"'\";\n    } else if (obj && typeof obj === \"object\" && !Buffer.isBuffer(obj)) {\n      const keys = Object.keys(obj);\n      return \"{\" + keys.slice(0, lim.maxObjectKeys).map(function(key) {\n        return key + \": \" + _shortDesc(obj[key]);\n      }).join(\", \") +\n      (keys.length > lim.maxObjectKeys ? \", ... (\" + keys.length + \" keys)\" : \"\") +\n      \"}\";\n    } else if (typeof obj === \"string\") {\n      return inspect(truncateString(obj, lim.maxStringLength));\n    } else {\n      return inspect(obj);\n    }\n  }\n  return _shortDesc(topObj);\n}\n"
  },
  {
    "path": "app/server/lib/shutdown.js",
    "content": "/**\n * Module for managing graceful shutdown.\n */\n\n\nimport log from \"app/server/lib/log\";\nimport Promise from \"bluebird\";\n\nimport os from \"node:os\";\n\nvar cleanupHandlers = [];\n\nvar signalsHandled = {};\n\n/**\n * Adds a handler that should be run on shutdown.\n * @param {Object} context The context with which to run the method, and which can be used to\n *    remove cleanup handlers.\n * @param {Function} method The method to run. It may return a promise, which will be waited on.\n * @param {Number} timeout Timeout in ms, for how long to wait for the returned promise. Required\n *    because it's no good for a cleanup handler to block the shutdown process indefinitely.\n * @param {String} name A title to show in log messages to distinguish one handler from another.\n */\nexport function addCleanupHandler(context, method, timeout = 1000, name = \"unknown\") {\n  cleanupHandlers.push({\n    context,\n    method,\n    timeout,\n    name\n  });\n}\n\n/**\n * Removes all cleanup handlers with the given context.\n * @param {Object} context The context with which once or more cleanup handlers were added.\n */\nexport function removeCleanupHandlers(context) {\n  // Maybe there should be gutil.removeFilter(func) which does this in-place.\n  cleanupHandlers = cleanupHandlers.filter(function(handler) {\n    return handler.context !== context;\n  });\n}\n\n\nvar _cleanupHandlersPromise = null;\n\n/**\n * Internal helper which runs all cleanup handlers, with the right contexts and timeouts,\n * waits for them, and reports and swallows any errors. It returns a promise that should always be\n * fulfilled.\n */\nfunction runCleanupHandlers() {\n  if (!_cleanupHandlersPromise) {\n    // Switch out cleanupHandlers, to leave an empty array at the end.\n    var handlers = cleanupHandlers;\n    cleanupHandlers = [];\n    _cleanupHandlersPromise = Promise.all(handlers.map(function(handler) {\n      return Promise.try(handler.method.bind(handler.context)).timeout(handler.timeout)\n        .catch(function(err) {\n          log.warn(`Cleanup error for '${handler.name}' handler: ` + err);\n        });\n    }));\n  }\n  return _cleanupHandlersPromise;\n}\n\n/**\n * Internal helper to exit on a signal. It runs the cleanup handlers, and then\n * exits propagating the same signal code than the one caught.\n */\nfunction signalExit(signal) {\n  var prog = \"grist[\" + process.pid + \"]\";\n  log.info(\"Server %s got signal %s; cleaning up (%d handlers)\",\n    prog, signal, cleanupHandlers.length);\n  function dup() {\n    log.info(\"Server %s ignoring duplicate signal %s\", prog, signal);\n  }\n  process.on(signal, dup);\n  return runCleanupHandlers()\n    .finally(function() {\n      log.info(\"Server %s exiting on %s\", prog, signal);\n      process.removeListener(signal, dup);\n      delete signalsHandled[signal];\n      // Exit with the expected exit code for being killed by this signal.\n      // Unlike re-sending the same signal, the explicit exit works even\n      // in a situation when Grist is the init (pid 1) process in a container\n      // See https://github.com/gristlabs/grist-core/pull/830 (and #892)\n      const signalNumber = os.constants.signals[signal];\n      process.exit(process.pid, 128 + signalNumber);\n    });\n}\n\n/**\n * For the given signals, run cleanup handlers (which may be asynchronous) before re-sending the\n * signals to the process. This should only be used for signals that normally kill the process.\n * E.g. cleanupOnSignals('SIGINT', 'SIGTERM', 'SIGUSR2');\n */\nexport function cleanupOnSignals(varSignalNames) {\n  for (var i = 0; i < arguments.length; i++) {\n    var signal = arguments[i];\n    if (signalsHandled[signal]) { continue; }\n    signalsHandled[signal] = true;\n    process.once(signal, signalExit.bind(null, signal));\n  }\n}\n\n/**\n * Run cleanup handlers and exit the process with the given exit code (0 if omitted).\n */\nexport function exit(optExitCode) {\n  var prog = \"grist[\" + process.pid + \"]\";\n  var code = optExitCode || 0;\n  log.info(\"Server %s cleaning up\", prog);\n  return runCleanupHandlers()\n    .finally(function() {\n      log.info(\"Server %s exiting with code %s\", prog, code);\n      process.exit(code);\n    });\n}\n"
  },
  {
    "path": "app/server/lib/updateChecker.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { commonUrls, LatestVersionAvailable } from \"app/common/gristUrls\";\nimport { isAffirmative } from \"app/common/gutil\";\nimport { naturalCompare } from \"app/common/SortFunc\";\nimport { version as installedVersion } from \"app/common/version\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { LatestVersion } from \"app/server/lib/UpdateManager\";\n\nexport async function checkForUpdates(gristServer: GristServer): Promise<LatestVersion> {\n  // Prepare data for the telemetry that endpoint might expect.\n  const installationId = (await gristServer.getActivations().current()).id;\n  const deploymentType = gristServer.getDeploymentType();\n  const currentVersion = installedVersion;\n  const response = await fetch(\n    process.env.GRIST_TEST_VERSION_CHECK_URL || commonUrls.versionCheck,\n    {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({\n        installationId,\n        deploymentType,\n        currentVersion,\n      }),\n    },\n  );\n\n  if (!response.ok) {\n    const errorData = response.headers.get(\"content-type\")?.includes(\"application/json\") ?\n      await response.json() :\n      await response.text();\n    throw new ApiError(\"Version update checking failed\", response.status, errorData);\n  }\n\n  return await response.json();\n}\n\nexport async function updateGristServerLatestVersion(\n  gristServer: GristServer,\n  forceCheck = false,\n): Promise<LatestVersionAvailable | null> {\n  // We only automatically check for versions in certain situations,\n  // such as for example, in Docker images that enable\n  // `GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING`. If `doItAnyway` is\n  // true, we check, as this means the user explicitly requested a\n  // one-time version check.\n  const activation = await gristServer.getActivations().current();\n  const prefEnabled = activation.prefs?.checkForLatestVersion ?? true;\n  const envvarEnabled = isAffirmative(process.env.GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING);\n  const doIt = (envvarEnabled && prefEnabled) || forceCheck;\n  if (!doIt) {\n    return null;\n  }\n\n  const response = await checkForUpdates(gristServer);\n\n  // naturalCompare correctly sorts version numbers.\n  const versions = [installedVersion, response.latestVersion];\n  versions.sort(naturalCompare);\n\n  const latestVersionAvailable: LatestVersionAvailable = {\n    version: response.latestVersion,\n    isNewer: versions[1] !== installedVersion,\n    isCritical: response.isCritical ?? false,\n    dateChecked: Date.now(),\n    releaseUrl: response.updateURL,\n  };\n\n  await gristServer.publishLatestVersionAvailable(latestVersionAvailable);\n  return latestVersionAvailable;\n}\n"
  },
  {
    "path": "app/server/lib/uploads.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { InactivityTimer } from \"app/common/InactivityTimer\";\nimport { FetchUrlOptions, FileUploadResult, UPLOAD_URL_PATH, UploadResult } from \"app/common/uploads\";\nimport { getUrlFromPrefix } from \"app/common/UserAPI\";\nimport { getAuthorizedUserId, getTransitiveHeaders, getUserId, isSingleUserMode,\n  RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport { getDocWorkerInfoOrSelfPrefix } from \"app/server/lib/DocWorkerUtils\";\nimport { expressWrap } from \"app/server/lib/expressWrap\";\nimport { downloadFromGDrive, isDriveUrl } from \"app/server/lib/GoogleImport\";\nimport { GristServer, RequestWithGrist } from \"app/server/lib/GristServer\";\nimport { guessExt } from \"app/server/lib/guessExt\";\nimport log from \"app/server/lib/log\";\nimport { fetchUntrustedWithAgent } from \"app/server/lib/ProxyAgent\";\nimport { optStringParam } from \"app/server/lib/requestUtils\";\nimport { isPathWithin } from \"app/server/lib/serverUtils\";\nimport * as shutdown from \"app/server/lib/shutdown\";\nimport { drainWhenSettled } from \"app/server/utils/streams\";\n\nimport stream from \"node:stream\";\nimport * as path from \"path\";\n\nimport { fromCallback } from \"bluebird\";\nimport * as contentDisposition from \"content-disposition\";\nimport { Application, Request, RequestHandler, Response } from \"express\";\nimport * as fse from \"fs-extra\";\nimport pick from \"lodash/pick\";\nimport * as multiparty from \"multiparty\";\nimport { Response as FetchResponse } from \"node-fetch\";\nimport * as tmp from \"tmp\";\n\n// After some time of inactivity, clean up the upload. We give an hour, which seems generous,\n// except that if one is toying with import options, and leaves the upload in an open browser idle\n// for an hour, it will get cleaned up. TODO Address that; perhaps just with some UI messages.\nconst INACTIVITY_CLEANUP_MS = 60 * 60 * 1000;     // an hour, very generously.\n\n// A hook for dependency injection.\nexport const Deps = { fetch: fetchUntrustedWithAgent, INACTIVITY_CLEANUP_MS };\n\n// An optional UploadResult, with parameters.\nexport interface FormResult {\n  upload?: UploadResult;\n  parameters?: { [key: string]: string };\n}\n\n/**\n * Adds an upload route to the given express app, listening for POST requests at UPLOAD_URL_PATH.\n */\nexport function addUploadRoute(\n  server: GristServer,\n  expressApp: Application,\n  docWorkerMap: IDocWorkerMap,\n  ...handlers: RequestHandler[]\n): void {\n  // When doing a cross-origin post, the browser will check for access with options prior to posting.\n  // We need to reassure it that the request will be accepted before it will go ahead and post.\n  expressApp.options([`/${UPLOAD_URL_PATH}`, \"/copy\"], ...handlers, async (req, res) => {\n    // Origin is checked by middleware - if we get this far, we are ok.\n    res.status(200).send();\n  });\n\n  expressApp.post(`/${UPLOAD_URL_PATH}`, ...handlers, expressWrap(async (req: Request, res: Response) => {\n    try {\n      const uploadResult: UploadResult = await handleUpload(req, res);\n      res.status(200).send(JSON.stringify(uploadResult));\n    } catch (err) {\n      req.resume();\n      if (err.message && /Request aborted/.test(err.message)) {\n        log.warn(\"File upload request aborted\", err);\n      } else {\n        log.error(\"Error uploading file\", err);\n      }\n      // Respond with a JSON error like jsonErrorHandler does for API calls,\n      // to make it easier for the caller to parse it.\n      res.status(err.status || 500).json({ error: err.message || \"internal error\" });\n    }\n  }));\n\n  // Like upload, but copy data from a document already known to us.\n  expressApp.post(`/copy`, ...handlers, expressWrap(async (req: Request, res: Response) => {\n    const docId = optStringParam(req.query.doc, \"doc\");\n    const name = optStringParam(req.query.name, \"name\");\n    if (!docId) { throw new Error(\"doc must be specified\"); }\n    const accessId = makeAccessId(req, getAuthorizedUserId(req));\n    try {\n      const uploadResult: UploadResult = await fetchDoc(server, docWorkerMap, docId, req, accessId,\n        req.query.template === \"1\");\n      if (name) {\n        globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, name);\n      }\n      res.status(200).send(JSON.stringify(uploadResult));\n    } catch (err) {\n      if ((err as ApiError).status === 403) {\n        res.status(403).json({ error: \"Insufficient access to document to copy it entirely\" });\n        return;\n      }\n      throw err;\n    }\n  }));\n}\n\n/**\n * Create a FileUploadInfo for the given file.\n */\nexport async function getFileUploadInfo(filePath: string): Promise<FileUploadInfo> {\n  return {\n    absPath: filePath,\n    origName: path.basename(filePath),\n    size: (await fse.stat(filePath)).size,\n    ext: path.extname(filePath).toLowerCase(),\n  };\n}\n\n/**\n * Implementation of the express /upload route.\n */\nexport async function handleUpload(req: Request, res: Response): Promise<UploadResult> {\n  const { upload } = await handleOptionalUpload(req, res);\n  if (!upload) { throw new ApiError(\"missing payload\", 400); }\n  return upload;\n}\n\nexport interface MultipartFormFile {\n  name: string;\n  contentType: string;\n  stream: stream.Readable;\n}\n\n/**\n * Processes a request containing a multipart/form-data body.\n * @param {e.Request} req - Request to read body from\n * @param {(file: MultipartFormFile) => Promise<void>} onFile\n *  Called for each file found. The returned promise should only resolve\n *  when the handler is finished with the data stream, otherwise errors might occur.\n * @param {(name: string, value: string) => void} onField\n *  Called for each field found.\n * @returns {Promise<void>}\n *  Promise, resolves when all parts of the form have been handled, or an error has occurred.\n */\nexport async function parseMultipartFormRequest(\n  req: Request,\n  onFile: (file: MultipartFormFile) => Promise<void> = () => Promise.resolve(),\n  onField: (name: string, value: string) => void = () => {},\n): Promise<void> {\n  let resolveFinished: (() => void) = () => {};\n  let rejectFinished: ((reason: any) => void) = () => {};\n  const finished = new Promise<void>((resolve, reject) => {\n    resolveFinished = resolve;\n    rejectFinished = reject;\n  });\n  const form = new multiparty.Form({ autoField: true });\n  const partPromises: Promise<void>[] = [];\n  // This only emits files, due to autoField being true\n  form.on(\"part\", (part: any) => {\n    // If the underlying stream breaks, we should unblock the caller.\n    part.on(\"error\", (err: any) => rejectFinished(err));\n\n    const filename = part.filename;\n    const contentType = part.headers[\"content-type\"];\n    const contentStream = part;\n\n    if (!(contentStream instanceof stream.Readable)) {\n      // This should never happen in practice, but checking this gives us full type safety, despite\n      // the multiparty library not being typed.\n      throw new Error(\"File contents is not a readable stream\");\n    }\n\n    // The stream needs to be drained for the request to continue. If something goes wrong\n    // in the `onFile` callback, drainWhenSettled guarantees that.\n    partPromises.push(drainWhenSettled(part,\n      onFile({\n        name: (typeof filename == \"string\") ? filename : \"\",\n        contentType: (typeof contentType == \"string\") ? contentType : \"\",\n        stream: contentStream,\n      }),\n    // No sensible way to handle errors from this promise - so do nothing here, and assume the callback\n    // handles errors sensibly.\n    ).catch(() => {}));\n  });\n  form.on(\"field\", onField);\n  form.on(\"error\", function(err: any) {\n    rejectFinished(err);\n  });\n  form.on(\"close\", function() {\n    resolveFinished();\n  });\n  form.parse(req);\n  try {\n    await finished;\n  } finally {\n    // Waiting for all part handlers to settle makes using this function more intuitive.\n    // Need to wait for the parsing to be finished first, to ensure all part exist.\n    await Promise.allSettled(partPromises);\n  }\n}\n\n/**\n * Process form data that may contain an upload, returning that upload (if present)\n * and any parameters.\n */\nexport async function handleOptionalUpload(req: Request, res: Response): Promise<FormResult> {\n  const { tmpDir, cleanupCallback } = await createTmpDir({});\n  const mreq = req as RequestWithLogin;\n  const meta = {\n    org: mreq.org,\n    email: mreq.user?.loginEmail,\n    userId: mreq.userId,\n    altSessionId: mreq.altSessionId,\n  };\n\n  log.rawDebug(`Prepared to receive upload into tmp dir ${tmpDir}`, meta);\n\n  // Note that we don't limit upload sizes here, since this endpoint doesn't know what kind of\n  // upload it is, and some uploads are unlimited (e.g. uploading .grist files). Limits are\n  // checked in the client, and should be enforced on the server where an upload is processed.\n  const form = new multiparty.Form({ uploadDir: tmpDir });\n  const [formFields, formFiles] = await fromCallback((cb: any) => form.parse(req, cb),\n    { multiArgs: true });\n\n  // 'upload' is the name of the form field containing file data.\n  let upload: UploadResult | undefined;\n  if (formFiles.upload) {\n    const uploadedFiles: FileUploadInfo[] = [];\n    for (const file of formFiles.upload) {\n      const mimeType = file.headers[\"content-type\"];\n      log.rawDebug(`Received file ${file.originalFilename} (${file.size} bytes)`, meta);\n      uploadedFiles.push({\n        absPath: file.path,\n        origName: file.originalFilename,\n        size: file.size,\n        ext: await guessExt(file.path, file.originalFilename, mimeType),\n      });\n    }\n    const accessId = makeAccessId(req, getUserId(req));\n    const uploadId = globalUploadSet.registerUpload(uploadedFiles, tmpDir, cleanupCallback, accessId);\n    const files: FileUploadResult[] = uploadedFiles.map(f => pick(f, [\"origName\", \"size\", \"ext\"]));\n    log.rawDebug(`Created uploadId ${uploadId} in tmp dir ${tmpDir}`, meta);\n    upload = { uploadId, files };\n  }\n  const parameters: { [key: string]: string } = {};\n  for (const key of Object.keys(formFields)) {\n    parameters[key] = formFields[key][0];\n  }\n  return { upload, parameters };\n}\n\n/**\n * Represents a single uploaded file on the server side. Only the FileUploadResult part is exposed\n * to the browser for information purposes.\n */\nexport interface FileUploadInfo extends FileUploadResult {\n  absPath: string;      // Absolute path to the file on disk.\n}\n\n/**\n * Represents a complete upload on the server side. It may be a temporary directory containing a\n * list of files (not subdirectories), or a collection of non-temporary files. The\n * cleanupCallback() is responsible for removing the temporary directory. It should be a no-op for\n * non-temporary files.\n */\nexport interface UploadInfo {\n  uploadId: number;             // ID of the upload\n\n  files: FileUploadInfo[];      // List of all files included in the upload.\n\n  tmpDir: string | null;          // Temporary directory to remove, containing this upload.\n  // If present, all files must be direct children of this directory.\n\n  cleanupCallback: CleanupCB;   // Callback to clean up this upload, including removing tmpDir.\n  cleanupTimer: InactivityTimer;\n  accessId: string | null;          // Optional identifier for access control purposes.\n}\n\ntype CleanupCB = () => void | Promise<void>;\n\nexport class UploadSet {\n  private _uploads = new Map<number, UploadInfo>();\n  private _nextId: number = 0;\n\n  /**\n   * Register a new upload.\n   */\n  public registerUpload(files: FileUploadInfo[], tmpDir: string | null, cleanupCallback: CleanupCB,\n    accessId: string | null): number {\n    const uploadId = this._nextId++;\n    const cleanupTimer = new InactivityTimer(() => this.cleanup(uploadId), Deps.INACTIVITY_CLEANUP_MS);\n    this._uploads.set(uploadId, { uploadId, files, tmpDir, cleanupCallback, cleanupTimer, accessId });\n    cleanupTimer.ping();\n    return uploadId;\n  }\n\n  /**\n   * Returns full info for the given uploadId, if authorized.\n   */\n  public getUploadInfo(uploadId: number, accessId: string | null): UploadInfo {\n    const info = this._getUploadInfoWithoutAuthorization(uploadId);\n    if (info.accessId !== accessId) {\n      throw new ApiError(\"access denied\", 403);\n    }\n    return info;\n  }\n\n  /**\n   * Clean up a particular upload.\n   */\n  public async cleanup(uploadId: number): Promise<void> {\n    log.debug(\"UploadSet: cleaning up uploadId %s\", uploadId);\n    const info = this._getUploadInfoWithoutAuthorization(uploadId);\n    info.cleanupTimer.disable();\n    this._uploads.delete(uploadId);\n    await info.cleanupCallback();\n  }\n\n  /**\n   * Clean up all uploads in this UploadSet. It may be used again after this call (it's called\n   * multiple times in tests).\n   */\n  public async cleanupAll(): Promise<void> {\n    log.info(\"UploadSet: cleaning up all %d uploads in set\", this._uploads.size);\n    const uploads = Array.from(this._uploads.values());\n    this._uploads.clear();\n    this._nextId = 0;\n    for (const info of uploads) {\n      try {\n        info.cleanupTimer.disable();\n        await info.cleanupCallback();\n      } catch (err) {\n        log.warn(`Error cleaning upload ${info.uploadId}: ${err}`);\n      }\n    }\n  }\n\n  /**\n   * Changes the name of an uploaded file. It is an error to use if the upload set has more than one\n   * file and it will throw.\n   */\n  public changeUploadName(uploadId: number, accessId: string | null, name: string) {\n    const info = this.getUploadInfo(uploadId, accessId);\n    if (info.files.length > 1) {\n      throw new Error(\"UploadSet.changeUploadName cannot operate on multiple files\");\n    }\n    info.files[0].origName = name;\n  }\n\n  /**\n   * Returns full info for the given uploadId, without checking authorization.\n   */\n  private _getUploadInfoWithoutAuthorization(uploadId: number): UploadInfo {\n    const info = this._uploads.get(uploadId);\n    if (!info) { throw new ApiError(`Unknown upload ${uploadId}`, 404); }\n    // If the upload is being used, reschedule the inactivity timeout.\n    info.cleanupTimer.ping();\n    return info;\n  }\n}\n\n// Maintains uploads created on this host.\nexport const globalUploadSet: UploadSet = new UploadSet();\n\n// Registers a handler to clean up on exit. We do this intentionally: even though module `tmp` has\n// its own logic to clean up, that logic isn't triggered when the server is killed with a signal.\nshutdown.addCleanupHandler(null, () => globalUploadSet.cleanupAll());\n\n/**\n * Moves this upload to a new directory. A new temporary subdirectory is created there first. If\n * the upload contained temporary files, those are moved; if non-temporary files, those are\n * copied. Aside from new file locations, the rest of the upload info stays unchanged.\n *\n * In any case, the previous cleanupCallback is run, and a new one created for the new tmpDir.\n *\n * This is used specifically for placing uploads into a location accessible by sandboxed code.\n */\nexport async function moveUpload(uploadInfo: UploadInfo, newDir: string): Promise<void> {\n  if (uploadInfo.tmpDir && isPathWithin(newDir, uploadInfo.tmpDir)) {\n    // Upload is already within newDir.\n    return;\n  }\n  log.debug(\"UploadSet: moving uploadId %s to %s\", uploadInfo.uploadId, newDir);\n  const { tmpDir, cleanupCallback } = await createTmpDir({ dir: newDir });\n  const move: boolean = Boolean(uploadInfo.tmpDir);\n  const files: FileUploadInfo[] = [];\n  for (const f of uploadInfo.files) {\n    const absPath = path.join(tmpDir, path.basename(f.absPath));\n    await (move ? fse.move(f.absPath, absPath) : fse.copy(f.absPath, absPath));\n    files.push({ ...f, absPath });\n  }\n  try {\n    await uploadInfo.cleanupCallback();\n  } catch (err) {\n    // This is unexpected, but if the move succeeded, let's warn but not fail on cleanup error.\n    log.warn(`Error cleaning upload ${uploadInfo.uploadId} after move: ${err}`);\n  }\n  Object.assign(uploadInfo, { files, tmpDir, cleanupCallback });\n}\n\ninterface TmpDirResult {\n  tmpDir: string;\n  cleanupCallback: CleanupCB;\n}\n\n/**\n * Helper to create a temporary directory. It's a simple wrapper around tmp.dir, but replaces the\n * cleanup callback with an asynchronous version.\n */\nexport async function createTmpDir(options: tmp.DirOptions): Promise<TmpDirResult> {\n  const fullOptions = { prefix: \"grist-upload-\", unsafeCleanup: true, ...options };\n\n  const [tmpDir, tmpCleanup]: [string, CleanupCB] = await fromCallback(\n    (cb: any) => tmp.dir(fullOptions, cb), { multiArgs: true });\n\n  // The `tmp` library sometimes forcibly resolves the path,\n  // doing it here makes it predictable behaviour and resistant to library behaviour changes.\n  const realTmpDir = await fse.realpath(tmpDir);\n\n  async function cleanupCallback() {\n    // Using fs-extra is better because it's asynchronous.\n    await fse.remove(realTmpDir);\n    try {\n      // Still call the original callback, so that `tmp` module doesn't keep remembering about\n      // this directory and doesn't try to delete it again on exit.\n      await tmpCleanup();\n    } catch (err) {\n      // OK if it fails because the dir is already removed.\n    }\n  }\n  return { tmpDir: realTmpDir, cleanupCallback };\n}\n\n/**\n * Register a new upload with resource fetched from a public url. Returns corresponding UploadInfo.\n */\nexport async function fetchURL(url: string, accessId: string | null, options?: FetchUrlOptions): Promise<UploadResult> {\n  return _fetchURL(url, accessId, { fileName: path.basename(url), ...options });\n}\n\n/**\n * Register a new upload with resource fetched from a url, optionally including credentials in\n * request. Returns corresponding UploadInfo.\n */\nasync function _fetchURL(url: string, accessId: string | null, options?: FetchUrlOptions): Promise<UploadResult> {\n  try {\n    const code = options?.googleAuthorizationCode;\n    let fileName = options?.fileName ?? \"\";\n    const headers = options?.headers;\n    let response: FetchResponse;\n    if (isDriveUrl(url)) {\n      response = await downloadFromGDrive(url, code);\n      fileName = \"\"; // Read the file name from headers.\n    } else {\n      response = await Deps.fetch(url, {\n        redirect: \"follow\",\n        follow: 10,\n        headers,\n      });\n    }\n    await _checkForError(response);\n    if (fileName === \"\") {\n      const disposition = response.headers.get(\"content-disposition\") || \"\";\n      fileName = contentDisposition.parse(disposition).parameters.filename || \"document.grist\";\n    }\n    const mimeType = response.headers.get(\"content-type\");\n    const { tmpDir, cleanupCallback } = await createTmpDir({});\n    // Any name will do for the single file in tmpDir, but note that fileName may not be valid.\n    const destPath = path.join(tmpDir, \"upload-content\");\n    await new Promise<void>((resolve, reject) => {\n      const dest = fse.createWriteStream(destPath, { autoClose: true });\n      response.body.on(\"error\", reject);\n      dest.on(\"error\", reject);\n      dest.on(\"finish\", resolve);\n      response.body.pipe(dest);\n    });\n    const uploadedFile: FileUploadInfo = {\n      absPath: path.resolve(destPath),\n      origName: fileName,\n      size: (await fse.stat(destPath)).size,\n      ext: await guessExt(destPath, fileName, mimeType),\n    };\n    log.debug(`done fetching url: ${url} to ${destPath}`);\n    const uploadId = globalUploadSet.registerUpload([uploadedFile], tmpDir, cleanupCallback, accessId);\n    return { uploadId, files: [pick(uploadedFile, [\"origName\", \"size\", \"ext\"])] };\n  } catch (err) {\n    if (err?.code === \"EPROTO\" || // https vs http error\n      err?.code === \"ECONNREFUSED\" || // server does not listen\n      err?.code === \"ENOTFOUND\") { // could not resolve domain\n      throw new ApiError(`Can't connect to the server. The URL seems to be invalid. Error code ${err.code}`, 400);\n    }\n    throw err;\n  }\n}\n\n/**\n * Fetches a Grist doc potentially managed by a different doc worker.  Passes on credentials\n * supplied in the current request.\n */\nexport async function fetchDoc(\n  server: GristServer,\n  docWorkerMap: IDocWorkerMap,\n  urlId: string,\n  req: Request,\n  accessId: string | null,\n  template: boolean,\n): Promise<UploadResult> {\n  // Prepare headers that preserve credentials of current user.\n  const headers = getTransitiveHeaders(req, { includeOrigin: false });\n\n  // Resolve urlId to the full docId needed to find the right doc worker.\n  const docId = (await server.getHomeDBManager().getRawDocById(urlId)).id;\n  // Find the doc worker responsible for the document we wish to copy.\n  // The backend needs to be well configured for this to work.\n  const { selfPrefix, docWorker } = await getDocWorkerInfoOrSelfPrefix(docId, docWorkerMap, server.getTag());\n  const docWorkerUrl = docWorker ? docWorker.internalUrl : getUrlFromPrefix(server.getHomeInternalUrl(), selfPrefix);\n  const apiBaseUrl = docWorkerUrl.replace(/\\/*$/, \"/\");\n\n  // Download the document, in full or as a template.\n  const url = new URL(`api/docs/${docId}/download?template=${Number(template)}`, apiBaseUrl);\n  return _fetchURL(url.href, accessId, { headers });\n}\n\n// Re-issue failures as exceptions.\nasync function _checkForError(response: FetchResponse) {\n  if (response.status === 403) {\n    throw new ApiError(\"Access to this resource was denied.\", response.status);\n  }\n  if (response.ok) {\n    const contentType = response.headers.get(\"content-type\");\n    if (contentType?.startsWith(\"text/html\")) {\n      // Probably we hit some login page\n      if (response.url.startsWith(\"https://accounts.google.com\")) {\n        throw new ApiError(\"Importing directly from a Google Drive URL is not supported yet. \" +\n          'Use the \"Import from Google Drive\" menu option instead.', 403);\n      } else {\n        throw new ApiError(\"Could not import the requested file, check if you have all required permissions.\", 403);\n      }\n    }\n    return;\n  }\n  const body = await response.json().catch(() => ({}));\n  if (response.status === 404) {\n    throw new ApiError(\"File can't be found at the requested URL.\", 404);\n  } else if (response.status >= 500 && response.status < 600) {\n    throw new ApiError(`Remote server returned an error (${body.error || response.statusText})`,\n      response.status, body.details);\n  } else {\n    throw new ApiError(body.error || response.statusText, response.status, body.details);\n  }\n}\n\n/**\n * Create an access identifier, combining the userId supplied with the host of the\n * doc worker.  Returns null if userId is null or in standalone mode.\n * Adding host information makes workers sharing a process more useful models of\n * full-blown isolated workers.\n */\nexport function makeAccessId(worker: string | Request | GristServer, userId: number | null): string | null {\n  if (isSingleUserMode()) { return null; }\n  if (userId === null) { return null; }\n  let host: string;\n  if (typeof worker === \"string\") {\n    host = worker;\n  } else if (\"getHost\" in worker) {\n    host = worker.getHost();\n  } else {\n    const gristServer = (worker as RequestWithGrist).gristServer;\n    if (!gristServer) { throw new Error(\"Problem accessing server with upload\"); }\n    host = gristServer.getHost();\n  }\n  return `${userId}:${host}`;\n}\n"
  },
  {
    "path": "app/server/lib/workerExporter.ts",
    "content": "import { FilterColValues } from \"app/common/ActiveDocAPI\";\nimport { createExcelFormatter } from \"app/server/lib/ExcelFormatter\";\nimport { ActiveDocSource, doExportDoc, doExportSection, doExportTable,\n  ExportData, ExportHeader, ExportParameters, Filter } from \"app/server/lib/Export\";\nimport log from \"app/server/lib/log\";\n\nimport { Stream } from \"stream\";\nimport { PassThrough } from \"stream\";\nimport { MessagePort, threadId } from \"worker_threads\";\n\nimport { Alignment, Border, Buffer as ExcelBuffer,\n  Fill, stream as ExcelWriteStream, Workbook } from \"exceljs\";\nimport { Rpc } from \"grain-rpc\";\n\nexport const makeXLSXFromOptions = handleExport(doMakeXLSXFromOptions);\n\nfunction handleExport<T extends any[]>(\n  make: (a: ActiveDocSource, testDates: boolean, output: Stream, ...args: T) => Promise<void | ExcelBuffer>,\n) {\n  return async function({ port, testDates, args}: { port: MessagePort, testDates: boolean, args: T }) {\n    try {\n      const start = Date.now();\n      log.debug(\"workerExporter %s %s: started\", threadId, make.name);\n      const rpc = new Rpc({\n        sendMessage: async m => port.postMessage(m),\n        logger: { info: (m) => {}, warn: m => log.warn(m) },\n      });\n      const activeDocSource = rpc.getStub<ActiveDocSource>(\"activeDocSource\");\n      port.on(\"message\", m => rpc.receiveMessage(m));\n      const outputStream = new PassThrough();\n      bufferedPipe(outputStream, chunk => rpc.postMessage(chunk));\n      await make(activeDocSource, testDates, outputStream, ...args);\n      port.close();\n      log.debug(\"workerExporter %s %s: done in %s ms\", threadId, make.name, Date.now() - start);\n    } catch (e) {\n      log.debug(\"workerExporter %s %s: error %s\", threadId, make.name, String(e));\n      // When Error objects move across threads, they keep only the 'message' property. We can\n      // keep other properties (like 'status') if we throw a plain object instead. (Didn't find a\n      // good reference on this, https://github.com/nodejs/node/issues/35506 is vaguely related.)\n      throw { message: e.message, ...e };\n    }\n  };\n}\n\n// ExcelJS's WorkbookWriter produces many tiny writes (even though they pass through zipping). To\n// reduce overhead and context switching, buffer them and pass on in chunks. (In practice, this\n// helps performance only slightly.)\nfunction bufferedPipe(stream: Stream, callback: (chunk: Buffer) => void, threshold = 64 * 1024) {\n  let buffers: Buffer[] = [];\n  let length = 0;\n  let flushed = 0;\n\n  function flush() {\n    if (length > 0) {\n      const data = Buffer.concat(buffers);\n      flushed += data.length;\n      callback(data);\n      buffers = [];\n      length = 0;\n    }\n  }\n\n  stream.on(\"data\", (chunk) => {\n    // Whenever data is written to the stream, add it to the buffer.\n    buffers.push(chunk);\n    length += chunk.length;\n    // If the buffer is large enough, post it to the callback. Also post the very first chunk:\n    // since this becomes an HTTP response, a quick first chunk lets the browser prompt the user\n    // more quickly about what to do with the download.\n    if (length >= threshold || flushed === 0) {\n      flush();\n    }\n  });\n\n  stream.on(\"end\", flush);\n}\n\nexport async function doMakeXLSXFromOptions(\n  activeDocSource: ActiveDocSource,\n  testDates: boolean,\n  stream: Stream,\n  options: ExportParameters,\n) {\n  const { tableId, viewSectionId, filters, sortOrder, linkingFilter, header } = options;\n  if (viewSectionId) {\n    return doMakeXLSXFromViewSection({ activeDocSource, testDates, stream, viewSectionId, header,\n      sortOrder: sortOrder || null, filters: filters || null, linkingFilter: linkingFilter || null });\n  } else if (tableId) {\n    return doMakeXLSXFromTable({ activeDocSource, testDates, stream, tableId, header });\n  } else {\n    return doMakeXLSX({ activeDocSource, testDates, stream, header });\n  }\n}\n\n/**\n * @async\n * Returns a XLSX stream of a view section that can be transformed or parsed.\n *\n * @param {Object} options - options for the export.\n * @param {Object} options.activeDocSource - the activeDoc that the table being converted belongs to.\n * @param {Integer} options.viewSectionId - id of the viewsection to export.\n * @param {Integer[]} options.activeSortOrder (optional) - overriding sort order.\n * @param {Filter[]} options.filters (optional) - filters defined from ui.\n * @param {FilterColValues} options.linkingFilter (optional)\n * @param {Stream} options.stream - the stream to write to.\n * @param {boolean} options.testDates - whether to use static dates for testing.\n * @param {string} options.header (optional) - which field of the column to use as header\n */\nasync function doMakeXLSXFromViewSection({\n  activeDocSource, testDates, stream, viewSectionId, sortOrder, filters, linkingFilter, header,\n}: {\n  activeDocSource: ActiveDocSource,\n  testDates: boolean,\n  stream: Stream,\n  viewSectionId: number,\n  sortOrder: number[] | null,\n  filters: Filter[] | null,\n  linkingFilter: FilterColValues | null,\n  header?: ExportHeader,\n}) {\n  const data = await doExportSection(activeDocSource, viewSectionId, sortOrder, filters, linkingFilter);\n  const { exportTable, end } = convertToExcel(stream, testDates, { header });\n  exportTable(data);\n  return end();\n}\n\n/**\n * @async\n * Returns a XLSX stream of a table that can be transformed or parsed.\n *\n * @param {Object} options - options for the export.\n * @param {Object} options.activeDocSource - the activeDoc that the table being converted belongs to.\n * @param {Integer} options.tableId - id of the table to export.\n * @param {Stream} options.stream - the stream to write to.\n * @param {boolean} options.testDates - whether to use static dates for testing.\n * @param {string} options.header (optional) - which field of the column to use as header\n *\n */\nasync function doMakeXLSXFromTable({ activeDocSource, testDates, stream, tableId, header}: {\n  activeDocSource: ActiveDocSource,\n  testDates: boolean,\n  stream: Stream,\n  tableId: string,\n  header?: ExportHeader,\n}) {\n  const data = await doExportTable(activeDocSource, { tableId });\n  const { exportTable, end } = convertToExcel(stream, testDates, { header });\n  exportTable(data);\n  return end();\n}\n\n/**\n * Creates excel document with all tables from an active Grist document.\n */\nasync function doMakeXLSX({ activeDocSource, testDates, stream, header}: {\n  activeDocSource: ActiveDocSource,\n  testDates: boolean,\n  stream: Stream,\n  header?: ExportHeader,\n}): Promise<void | ExcelBuffer> {\n  const { exportTable, end } = convertToExcel(stream, testDates, { header });\n  await doExportDoc(activeDocSource, async (table: ExportData) => exportTable(table));\n  return end();\n}\n\n/**\n * Converts export data to an excel file.\n * If a stream is provided, use it via the more memory-efficient\n * WorkbookWriter, otherwise fall back on using a Workbook directly,\n * and return a buffer.\n * (The second option is for grist-static; at the time of writing\n * WorkbookWriter doesn't appear to be available in a browser context).\n */\nfunction convertToExcel(stream: Stream | undefined, testDates: boolean, options: { header?: ExportHeader }): {\n  exportTable: (table: ExportData) => void,\n  end: () => Promise<void | ExcelBuffer>,\n} {\n  // Create workbook and add single sheet to it. Using the WorkbookWriter interface avoids\n  // creating the entire Excel file in memory, which can be very memory-heavy. See\n  // https://github.com/exceljs/exceljs#streaming-xlsx-writercontents. (The options useStyles and\n  // useSharedStrings replicate more closely what was used previously.)\n  // If there is no stream, write with a Workbook.\n  const wb: Workbook | ExcelWriteStream.xlsx.WorkbookWriter = stream ?\n    new ExcelWriteStream.xlsx.WorkbookWriter({ useStyles: true, useSharedStrings: true, stream }) :\n    new Workbook();\n  const maybeCommit = stream ? (t: any) => t.commit() : (t: any) => {};\n  if (testDates) {\n    // HACK: for testing, we will keep static dates\n    const date = new Date(Date.UTC(2018, 11, 1, 0, 0, 0));\n    wb.modified = date;\n    wb.created = date;\n    wb.lastPrinted = date;\n    wb.creator = \"test\";\n    wb.lastModifiedBy = \"test\";\n  }\n  // Prepare border - some of the cells can have background colors, in that case border will\n  // not be visible\n  const borderStyle: Border = {\n    color: { argb: \"FFE2E2E3\" }, // dark gray - default border color for gdrive\n    style: \"thin\",\n  };\n  const borders = {\n    left: borderStyle,\n    right: borderStyle,\n    top: borderStyle,\n    bottom: borderStyle,\n  };\n  const headerBackground: Fill = {\n    type: \"pattern\",\n    pattern: \"solid\",\n    fgColor: { argb: \"FFEEEEEE\" }, // gray\n  };\n  const headerFontColor = {\n    color: {\n      argb: \"FF000000\", // black\n    },\n  };\n  const centerAlignment: Partial<Alignment> = {\n    horizontal: \"center\",\n  };\n  function exportTable(table: ExportData) {\n    const { columns, rowIds, access, tableName } = table;\n    const ws = wb.addWorksheet(sanitizeWorksheetName(tableName));\n    // Build excel formatters.\n    const formatters = columns.map(col => createExcelFormatter(col.formatter.type, col.formatter.widgetOpts));\n    // Generate headers for all columns with correct styles for whole column.\n    // Actual header style for a first row will be overwritten later.\n    const colHeader = options.header ?? \"label\";\n    ws.columns = columns.map((col, c) => ({ header: col[colHeader], style: formatters[c].style() }));\n    // style up the header row\n    for (let i = 1; i <= columns.length; i++) {\n      // apply to all rows (including header)\n      ws.getColumn(i).border = borders;\n      // apply only to header\n      const header = ws.getCell(1, i);\n      header.fill = headerBackground;\n      header.font = headerFontColor;\n      header.alignment = centerAlignment;\n    }\n    // Make each column a little wider.\n    ws.columns.forEach((column) => {\n      if (!column.header) {\n        return;\n      }\n      // 14 points is about 100 pixels in a default font (point is around 7.5 pixels)\n      column.width = column.header.length < 14 ? 14 : column.header.length;\n    });\n    // Populate excel file with data\n    for (const row of rowIds) {\n      maybeCommit(ws.addRow(access.map((getter, c) => formatters[c].formatAny(getter(row)))));\n    }\n    maybeCommit(ws);\n  }\n  async function end(): Promise<void | ExcelBuffer> {\n    if (!stream) {\n      return wb.xlsx.writeBuffer();\n    }\n    return maybeCommit(wb);\n  }\n  return { exportTable, end };\n}\n\n/**\n * Removes invalid characters, see https://github.com/exceljs/exceljs/pull/1484\n */\nexport function sanitizeWorksheetName(tableName: string): string {\n  return tableName\n    // Convert invalid characters to spaces\n    .replace(/[*?:/\\\\[\\]]/g, \" \")\n\n    // Collapse multiple spaces into one\n    .replace(/\\s+/g, \" \")\n\n    // Trim spaces and single quotes from the ends\n    .replace(/^['\\s]+/, \"\")\n    .replace(/['\\s]+$/, \"\");\n}\n\n// This method exists only to make Piscina happier. With it,\n// Piscina will load this file using a regular require(),\n// which under Electron will deal fine with Electron's ASAR\n// app bundle. Without it, Piscina will try fancier methods\n// that aren't at the time of writing correctly patched to\n// deal with an ASAR app bundle, and so report that this\n// file doesn't exist instead of exporting an XLSX file.\n//   https://github.com/gristlabs/grist-electron/issues/9\nexport default function doNothing() {\n}\n"
  },
  {
    "path": "app/server/localization.ts",
    "content": "import { appSettings } from \"app/server/lib/AppSettings\";\nimport log from \"app/server/lib/log\";\n\nimport { lstatSync, readdirSync, readFileSync } from \"fs\";\nimport path from \"path\";\n\nimport { createInstance, i18n } from \"i18next\";\nimport { LanguageDetector } from \"i18next-http-middleware\";\n\nexport function setupLocale(appRoot: string): i18n {\n  // We are using custom instance and leave the global object intact.\n  const instance = createInstance();\n  // By default locales are located in the appRoot folder, unless the environment variable\n  // GRIST_LOCALES_DIR is set.\n  const localeDir = process.env.GRIST_LOCALES_DIR || path.join(appRoot, \"static\", \"locales\");\n  const preload: [string, string, string][] = [];\n  const supportedNamespaces = new Set<string>();\n  const supportedLngs = new Set<string>();\n\n  for (const fileName of readdirSync(localeDir)) {\n    const fullPath = path.join(localeDir, fileName);\n    const isDirectory = lstatSync(fullPath).isDirectory();\n    if (isDirectory) {\n      continue;\n    }\n    const baseName = path.basename(fileName, \".json\");\n    const lang = baseName.split(\".\")[0]?.replace(/_/g, \"-\");\n    const namespace = baseName.split(\".\")[1];\n    if (!lang || !namespace) {\n      throw new Error(\"Unrecognized resource file \" + fileName);\n    }\n    supportedNamespaces.add(namespace);\n    preload.push([namespace, lang, fullPath]);\n    supportedLngs.add(lang);\n  }\n\n  if (!supportedLngs.has(\"en\") || !supportedNamespaces.has(\"server\")) {\n    throw new Error(\"Missing server English language file\");\n  }\n  // Initialize localization language detector plugin that will read the language from the request.\n  instance.use(LanguageDetector);\n\n  let errorDuringLoad: Error | undefined;\n  instance.init({\n    defaultNS: \"server\",\n    ns: [...supportedNamespaces],\n    fallbackLng: \"en\",\n    detection: {\n      lookupCookie: \"grist_user_locale\",\n    },\n  }, (err: any) => {\n    if (err) {\n      errorDuringLoad = err;\n    }\n  }).catch((err: any) => {\n    // This should not happen, the promise should be resolved synchronously, without\n    // any errors reported.\n    log.error(\"i18next failed unexpectedly\", err);\n  });\n  if (errorDuringLoad) {\n    log.error(\"i18next failed to load\", errorDuringLoad);\n    throw errorDuringLoad;\n  }\n  // Load all files synchronously.\n  // First sort by ns, which will put \"client\" first. That lets us check for a\n  // client key which, if absent, means the language should be ignored.\n  preload.sort((a, b) => a[0].localeCompare(b[0]));\n  const offerAll = appSettings.section(\"locale\").flag(\"offerAllLanguages\").readBool({\n    envVar: \"GRIST_OFFER_ALL_LANGUAGES\",\n  });\n  const shouldIgnoreLng = new Set<string>();\n  for (const [ns, lng, fullPath] of preload) {\n    const data = JSON.parse(readFileSync(fullPath, \"utf8\"));\n    // If the \"Translators: please ...\" key in \"App\" has not been translated,\n    // ignore this language for this and later namespaces.\n    if (!offerAll && ns === \"client\" &&\n      !Object.keys(data.App || {}).some(key => key.includes(\"Translators: please\"))) {\n      shouldIgnoreLng.add(lng);\n      log.debug(`skipping incomplete language ${lng} (set GRIST_OFFER_ALL_LANGUAGES if you want it)`);\n    }\n    if (!shouldIgnoreLng.has(lng)) {\n      instance.addResourceBundle(lng, ns, data);\n    }\n  }\n  return instance;\n}\n\nexport function readLoadedLngs(instance?: i18n): readonly string[] {\n  if (!instance) { return []; }\n  return Object.keys(instance?.services.resourceStore.data);\n}\n\nexport function readLoadedNamespaces(instance?: i18n): readonly string[] {\n  if (!instance) { return []; }\n  if (Array.isArray(instance?.options.ns)) {\n    return instance.options.ns;\n  }\n  return instance?.options.ns ? [instance.options.ns as string] : [\"server\"];\n}\n"
  },
  {
    "path": "app/server/tsconfig.json",
    "content": "{\n  \"extends\": \"../../buildtools/tsconfig-base.json\",\n  \"references\": [\n    { \"path\": \"../common\" },\n  ],\n  \"include\": [\n    \"**/*\",\n    \"../gen-server/**/*\",\n    \"../../stubs/app/server/**/*\"\n  ]\n}\n"
  },
  {
    "path": "app/server/utils/LogSanitizer.ts",
    "content": "/**\n * A sanitizer interface that provides methods to sanitize log entries.\n */\ninterface ISanitizer {\n  /**\n   * Sanitizes the provided log entry. Should be called only if canSanitize returns true.\n   * @param {any} entry - The log entry to sanitize.\n   * @returns {any} The sanitized log entry.\n   */\n  sanitize(entry: any): any;\n\n  /**\n   * Checks if the sanitizer can handle the given log entry.\n   * @param {any} entry - The log entry to check.\n   * @returns {boolean} True if the sanitizer can handle the log entry, false otherwise.\n   */\n  canSanitize(entry: any): boolean;\n}\n\n/**\n * A log sanitizer class that sanitizes logs to avoid leaking sensitive/private data.\n * only the first applicable sanitizer (as determined by canSanitize) will be applied\n * Currently, it is hardcoded to sanitize only logs from Redis rpush command.\n */\nexport class LogSanitizer {\n  private _sanitizers: ISanitizer[] = [\n    new RedisSanitizer(),\n  ];\n\n  /**\n   * Sanitizes the provided log entry using a predefined set of sanitizers.\n   * @param {any} log - The log entry to sanitize.\n   * @returns {any} The sanitized log entry.\n   */\n  public sanitize(log: any): any {\n    for (const sanitizer of this._sanitizers) {\n      if (sanitizer.canSanitize(log)) {\n        return sanitizer.sanitize(log);\n      }\n    }\n    return log;\n  }\n}\n\n/**\n * A sanitizer implementation for Redis logs.\n */\nclass RedisSanitizer implements ISanitizer {\n  /**\n   * Sanitizes the Redis log entry by replacing sensitive data in the payload with \"[sanitized]\".\n   * @param {any} entry - The Redis log entry to sanitize.\n   * @returns {any} The sanitized Redis log entry.\n   */\n  public sanitize(entry: any): any {\n    // REDIS log structure looks like this: the first arg is the name of the queue,\n    // and the rest are the data that was pushed to the queue. Therefore, we are omitting the first arg.\n    for (let i = 1; i < entry.args.length; i++) {\n      let arg = entry.args[i];\n      let parsedArg: any = null;\n      parsedArg = JSON.parse(arg);\n      if (parsedArg?.payload) {\n        parsedArg.payload = \"[sanitized]\";\n      }\n      arg = JSON.stringify(parsedArg);\n      entry.args[i] = arg;\n    }\n    return entry;\n  }\n\n  /**\n   * Checks if the given log entry corresponds to a Redis rpush command.\n   * @param {any} entry - The log entry to check.\n   * @returns {boolean} True if the log entry is a Redis rpush command, false otherwise.\n   */\n  public canSanitize(entry: any): boolean {\n    // We are only interested in rpush commands\n    return (\n      typeof entry === \"object\" &&\n      entry.command?.toLowerCase() === \"rpush\".toLowerCase() &&\n      entry.args &&\n      Array.isArray(entry.args)\n    );\n  }\n}\n"
  },
  {
    "path": "app/server/utils/gristify.ts",
    "content": "import { ColInfoWithId } from \"app/common/DocActions\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { AttachmentStoreProvider } from \"app/server/lib/AttachmentStoreProvider\";\nimport { DocManager } from \"app/server/lib/DocManager\";\nimport { makeExceptionalDocSession, OptDocSession } from \"app/server/lib/DocSession\";\nimport { DocStorage } from \"app/server/lib/DocStorage\";\nimport { createDummyGristServer } from \"app/server/lib/GristServer\";\nimport { TrivialDocStorageManager } from \"app/server/lib/IDocStorageManager\";\nimport { DBMetadata, OpenMode, quoteIdent, SQLiteDB } from \"app/server/lib/SQLiteDB\";\n\n/**\n * A utility class for modifying a SQLite file to be viewed/edited with Grist.\n */\nexport class Gristifier {\n  public constructor(private _filename: string) {\n  }\n\n  /**\n   * Add Grist metadata tables to a SQLite file. After this action,\n   * the file can be opened as a Grist document, with partial functionality.\n   * Level of functionality will depend on the nature of the tables in the\n   * SQLite file.\n   *\n   * The `user_version` slot of SQLite will be modified by this operation,\n   * losing whatever was in it previously.\n   *\n   * A \"manualSort\" column may be added to tables by specifying `addSort`,\n   * to support a notion of order that exists in spreadsheets.\n   *\n   * Grist is very finicky about primary keys, and tables that don't match\n   * its expectations cannot be viewed or edited directly at the moment.\n   * Instead, views are added supporting selects, updates, inserts, and\n   * deletes. Structure changes (e.g. adding/removing columns) are not\n   * supported unfortunately.\n   *\n   * This is very much an experiment, with plenty of limits and\n   * sharp edges. In general it isn't possible to treat an arbitrary\n   * SQLite file as a Grist document, but in particular cases it can\n   * work and be very useful.\n   */\n  public async gristify(options: { addSort?: boolean }) {\n    // Remove any existing Grist material from the file.\n    await this.degristify();\n\n    // Enumerate user tables and columns.\n    const inventory = await this._getUserTables();\n\n    // Grist keeps a schema number in the SQLite \"user_version\" slot,\n    // so we need to zap it. This is the one destructive operation\n    // involved in gristification.\n    // TODO: consider moving schema information somewhere more neutral.\n    await this._zapUserVersion();\n\n    // Open the file as an empty Grist document, creating Grist metadata\n    // tables.\n    const docManager = new DocManager(\n      new TrivialDocStorageManager(), null, null,\n      new AttachmentStoreProvider([], \"\"), createDummyGristServer(),\n    );\n    const activeDoc = new ActiveDoc(docManager, this._filename);\n    const docSession = makeExceptionalDocSession(\"system\");\n    await activeDoc.createEmptyDoc(docSession, { useExisting: true });\n    await activeDoc.waitForInitialization();\n\n    // Now \"create\" user tables and columns with Grist. The creation\n    // will be fictitious since the tables and columns already exist -\n    // they just don't have metadata describing them to Grist.\n    const outcomes: TableOutcome[] = [];\n    for (const [tableId, table] of Object.entries(inventory)) {\n      const columnDefs = this._collectColumnDefinitions(table);\n      if (!(\"id\" in columnDefs)) {\n        // Can't handle this table in Grist directly at the moment, but\n        // we can do something via a view.\n        await this._createView(docSession, activeDoc, tableId, Object.keys(table), columnDefs);\n        outcomes.push({ tableId, viewed: true, reason: \"id complications\" });\n      } else {\n        await this._registerTable(docSession, activeDoc, tableId, columnDefs);\n        if (options.addSort) {\n          await this._addManualSort(activeDoc, tableId);\n          outcomes.push({ tableId, addManualSort: true });\n        } else {\n          outcomes.push({ tableId });\n        }\n      }\n    }\n    await activeDoc.shutdown();\n\n    // Give a final readout of what happened for every table, since the\n    // conversion process is quite noisy.\n    for (const outcome of outcomes) {\n      console.log(JSON.stringify(outcome));\n    }\n  }\n\n  /**\n   * Remove all Grist metadata tables. Warning: attachments are considered metadata.\n   */\n  public async degristify() {\n    const db = await SQLiteDB.openDBRaw(this._filename);\n    const tables = await db.all(\n      `SELECT name FROM sqlite_master WHERE type='table' ` +\n      `  AND name LIKE '_grist%'`,\n    );\n    for (const table of tables) {\n      console.log(`Removing ${table.name}`);\n      await db.exec(`DROP TABLE ${quoteIdent(table.name)}`);\n    }\n    const views = await db.all(\n      `SELECT name FROM sqlite_master WHERE type='view' ` +\n      `  AND name LIKE 'GristView%'`,\n    );\n    for (const view of views) {\n      console.log(`Removing ${view.name}`);\n      await db.exec(`DROP VIEW ${quoteIdent(view.name)}`);\n    }\n    await db.close();\n  }\n\n  /**\n   * Run a query on an SQLite file. Pass the result through\n   * standard Grist decoding for BLOBs. This is handy for\n   * reading objects of the wrong type stored as BLOBs to\n   * protect them in a \"cell\", or for reading action history,\n   * e.g.\n   *  select * from _gristsys_ActionHistory order by actionNum\n   * This will do the same it would from the sqlite3 utility,\n   * except BLOBs will be expanded.\n   */\n  public async query(queryString: string, options: {\n    json?: boolean,\n  }) {\n    const db = await SQLiteDB.openDBRaw(this._filename, OpenMode.OPEN_READONLY);\n    try {\n      const results = await db.all(queryString);\n      const decodedResults = results.map(row => DocStorage.decodeRowValues(row));\n      if (options.json) {\n        console.log(JSON.stringify(decodedResults, null, 2));\n      } else {\n        console.table(decodedResults);\n      }\n    } finally {\n      await db.close();\n    }\n  }\n\n  /**\n   * Make definitions for the table's columns. This is very crude, it handles\n   * integers and leaves everything else as \"Any\".\n   */\n  private _collectColumnDefinitions(table: DBMetadata[string]) {\n    const defs: Record<string, ColInfoWithId> = {};\n    for (const [colId, info] of Object.entries(table)) {\n      if (colId.startsWith(\"manualSort\")) { continue; }\n      const type = info.toLowerCase();\n      const c: ColInfoWithId = {\n        id: colId,\n        type: \"Any\",\n        isFormula: false,\n        formula: \"\",\n      };\n      // see https://www.sqlite.org/datatype3.html#determination_of_column_affinity\n      if (type.includes(\"int\")) {\n        c.type = \"Int\";\n      }\n      if (colId === \"id\") {\n        if (c.type !== \"Int\") {\n          // Grist can only support integer id columns.\n          // For now, just rename this column out of the way to id2, and use\n          // a view to map SQLite's built-in ROWID to the id column.\n          // TODO: could collide with a column called \"id2\".\n          c.id = \"id2\";\n        }\n      }\n      defs[c.id] = c;\n    }\n    return defs;\n  }\n\n  /**\n   * Support tables that don't have an integer column called \"id\" through views.\n   * It would be better to enhance Grist to support a wider variety of scenarios,\n   * but this is helpful for now.\n   */\n  private async _createView(docSession: OptDocSession, activeDoc: ActiveDoc, tableId: string,\n    cols: string[], columnDefs: Record<string, ColInfoWithId>) {\n    const newName = `GristView_${tableId}`;\n    function quote(name: string) {\n      return quoteIdent(name === \"id\" ? \"id2\" : name);\n    }\n    function quoteForSelect(name: string) {\n      if (name === \"id\") { return \"id as id2\"; }\n      return quoteIdent(name);\n    }\n\n    // View table tableId via a view GristView_tableId, with id and manualSort supplied\n    // from ROWID. SQLite tables may not have a ROWID, but this is relatively rare.\n    await activeDoc.docStorage.exec(`CREATE VIEW ${quoteIdent(newName)} AS SELECT ` +\n      [\"ROWID AS id\", \"ROWID AS manualSort\", ...cols.map(quoteForSelect)].join(\", \") +\n      ` FROM ${quoteIdent(tableId)}`);\n\n    // Make an INSTEAD OF UPDATE trigger, so that if someone tries to update the view,\n    // we instead update the underlying table. Updates of manualSort or id are just ignored.\n    // The trigger is a little awkward to write since we need to compare OLD and NEW\n    // to see what changed - updating unchanged material could needlessly run afoul of\n    // constraints.\n    const updateTrigger = `CREATE TRIGGER ${quoteIdent(\"trigger_update_\" + newName)} ` +\n      `INSTEAD OF UPDATE ON ${quoteIdent(newName)} BEGIN ` +\n      cols.map(col =>\n        `UPDATE ${quoteIdent(tableId)} SET ` +\n        `${quoteIdent(col)} = NEW.${quote(col)} ` +\n        ` WHERE OLD.${quote(col)} <> NEW.${quote(col)} ` +\n        ` AND ${quoteIdent(tableId)}.ROWID = NEW.ROWID`,\n      ).join(\"; \") +\n      `; END`;\n    await activeDoc.docStorage.exec(updateTrigger);\n\n    // Make an INSTEAD OF INSERT trigger.\n    const insertTrigger = `create trigger ${quoteIdent(\"trigger_insert_\" + newName)} ` +\n      `INSTEAD OF INSERT ON ${quoteIdent(newName)} BEGIN ` +\n      `INSERT INTO ${quoteIdent(tableId)}` +\n      \"(\" + cols.map(quoteIdent).join(\",\") + \") VALUES(\" +\n      cols.map(col => `NEW.${quote(col)}`).join(\", \") +\n      `); END`;\n    await activeDoc.docStorage.exec(insertTrigger);\n\n    // Make an INSTEAD OF DELETE trigger.\n    const deleteTrigger = `create trigger ${quoteIdent(\"trigger_delete_\" + newName)} ` +\n      `INSTEAD OF DELETE ON ${quoteIdent(newName)} BEGIN ` +\n      `DELETE FROM ${quoteIdent(tableId)} WHERE ${quoteIdent(tableId)}.ROWID = OLD.ROWID` +\n      `; END`;\n    await activeDoc.docStorage.exec(deleteTrigger);\n\n    const result = await this._registerTable(docSession, activeDoc, newName, columnDefs);\n\n    // Now, tweak the Grist metadata to make the table name the expected one\n    // (the table id as far as Grist is concerned must remain that of the view)\n    const id = result.retValues[0].id;\n    await activeDoc.docStorage.run(\"update _grist_Views_section set title = ? \" +\n      \"where id in (select rawViewSectionRef from _grist_Tables where id = ?)\",\n    [tableId, id]);\n    await activeDoc.docStorage.run(\"update _grist_Views set name = ? \" +\n      \"where id in (select primaryViewId from _grist_Tables where id = ?)\",\n    [tableId, id]);\n  }\n\n  private async _getUserTables(): Promise<DBMetadata> {\n    // Enumerate existing tables and columns.\n    const db = await SQLiteDB.openDBRaw(this._filename);\n    const inventory = await db.collectMetadata();\n    await db.close();\n    // We are not interested in the special \"sqlite_sequence\" table.\n    delete inventory.sqlite_sequence;\n    return inventory;\n  }\n\n  private async _zapUserVersion(): Promise<void> {\n    const db = await SQLiteDB.openDBRaw(this._filename);\n    await db.exec(`PRAGMA user_version = 0`);\n    await db.close();\n  }\n\n  private async _addManualSort(activeDoc: ActiveDoc, tableId: string) {\n    const db = activeDoc.docStorage;\n    await db.exec(`ALTER TABLE ${quoteIdent(tableId)} ADD COLUMN manualSort INTEGER`).catch(e => null);\n    await db.exec(`UPDATE ${quoteIdent(tableId)} SET manualSort = id`);\n  }\n\n  private async _registerTable(docSession: OptDocSession, activeDoc: ActiveDoc,\n    tableId: string, args: Record<string, ColInfoWithId>) {\n    delete args.id;\n    activeDoc.onlyAllowMetaDataActionsOnDb(true);\n    const result = await activeDoc.applyUserActions(docSession, [\n      [\"AddTable\", tableId, Object.values(args)],\n    ]);\n    activeDoc.onlyAllowMetaDataActionsOnDb(false);\n    return result;\n  }\n}\n\ninterface TableOutcome {\n  tableId: string;\n  skipped?: boolean;\n  viewed?: boolean;\n  addManualSort?: boolean;\n  reason?: string;\n}\n"
  },
  {
    "path": "app/server/utils/pruneActionHistory.ts",
    "content": "import * as gutil from \"app/common/gutil\";\nimport { ActionHistoryImpl } from \"app/server/lib/ActionHistoryImpl\";\nimport { create } from \"app/server/lib/create\";\nimport { DocStorage } from \"app/server/lib/DocStorage\";\nimport * as docUtils from \"app/server/lib/docUtils\";\nimport log from \"app/server/lib/log\";\n\n/**\n * A utility script for cleaning up the action log.\n *\n * @param {String} docPath - The path to the document from the current directory including\n *  the document name.\n * @param {Int} keepN - The number of recent actions to keep. Must be at least 1. Defaults to 1\n *  if not provided.\n */\nexport async function pruneActionHistory(docPath: string, keepN: number) {\n  if (!docPath || !gutil.endsWith(docPath, \".grist\")) {\n    throw new Error(\"Invalid document: Document should be a valid .grist file\");\n  }\n\n  const storageManager = await create.createLocalDocStorageManager(\".\", \".\");\n  const docStorage = new DocStorage(storageManager, docPath);\n  const backupPath = gutil.removeSuffix(docPath, \".grist\") + \"-backup.grist\";\n\n  // If the backup already exists, abort. Otherwise, create a backup copy and continue.\n  const exists = await docUtils.pathExists(backupPath);\n  if (exists) { throw new Error(\"Backup file already exists, aborting pruneActionHistory\"); }\n  await docUtils.copyFile(docPath, backupPath);\n  await docStorage.openFile();\n  try {\n    const history = new ActionHistoryImpl(docStorage);\n    await history.initialize();\n    await history.deleteActions(keepN);\n  } finally {\n    await docStorage.shutdown();\n  }\n}\n\n/**\n * Variant that accepts and parses command line arguments.\n */\nexport async function pruneActionHistoryFromConsole(argv: string[]): Promise<number> {\n  if (argv.length === 0) {\n    log.error(\"Please supply document name, and optionally the number of actions to preserve (default=1)\");\n    return 1;\n  }\n  const docPath = argv[0];\n  const keepN = parseInt(argv[1], 10) || 1;\n  try {\n    await pruneActionHistory(docPath, keepN);\n  } catch (e) {\n    log.error(e);\n    return 1;\n  }\n  return 0;\n}\n\nif (require.main === module) {\n  pruneActionHistoryFromConsole(process.argv.slice(2)).catch((e) => {\n    log.error(\"pruneActionHistory failed: %s\", e);\n    process.exit(1);\n  });\n}\n"
  },
  {
    "path": "app/server/utils/showAuditLogEvents.ts",
    "content": "import { AuditEventAction, AuditEventDetails } from \"app/server/lib/AuditEvent\";\n\nimport groupBy from \"lodash/groupBy\";\n\ninterface Options {\n  type: AuditEventType;\n}\n\ntype AuditEventType = \"installation\" | \"site\";\n\nexport function showAuditLogEvents({ type }: Options) {\n  switch (type) {\n    case \"installation\": {\n      showInstallationEvents();\n      break;\n    }\n    case \"site\": {\n      showSiteEvents();\n      break;\n    }\n  }\n}\n\nfunction showInstallationEvents() {\n  console.log(\"---\\ntitle: Audit log events\\n---\\n\");\n  console.log(\n    \"# Audit log events for your self-managed instance {: .tag-ee }\",\n  );\n  const events = Object.entries(AuditEvents).filter(([, { type }]) => {\n    const types = Array.isArray(type) ? type : [type];\n    return types.includes(\"installation\");\n  });\n  showEvents(events);\n}\n\nfunction showSiteEvents() {\n  console.log(\"---\\ntitle: Audit log events\\n---\\n\");\n  console.log(\"# Audit log events for your team site {: .tag-business .tag-ee }\");\n  console.log(\n    `!!! note\n    The events on this page appear in the audit log of a [team site]` +\n    `(../teams.md). For events that appear in a [Self-Managed Grist instance]` +\n    `(../self-managed.md), see [\"Audit log events for your self-managed instance\"]` +\n    `(../install/audit-log-events.md).\\n`,\n  );\n  const events = Object.entries(AuditEvents).filter(([, { type }]) => {\n    const types = Array.isArray(type) ? type : [type];\n    return types.includes(\"site\");\n  });\n  showEvents(events);\n}\n\nfunction showEvents(events: [string, AuditEvent<AuditEventAction>][]) {\n  const eventsByCategory = groupBy(\n    events,\n    ([name]) => name.split(\".\")?.[0] ?? \"other\",\n  );\n  for (const [category, categoryEvents] of Object.entries(\n    eventsByCategory,\n  ).sort((a, b) => a[0].localeCompare(b[0]))) {\n    console.log(`\\n## ${category}`);\n    for (const [action, event] of categoryEvents.sort((a, b) =>\n      a[0].localeCompare(b[0]),\n    )) {\n      const { description, properties, sample } = event;\n      console.log(`\\n### ${action}\\n`);\n      console.log(`${description}\\n`);\n      if (Object.keys(properties).length === 0) {\n        continue;\n      }\n\n      console.log(\"#### Details\\n\");\n      console.log(\"| Property | Type | Description |\");\n      console.log(\"| -------- | ---- | ----------- |\");\n      showEventProperties(properties);\n      console.log(\"\\n#### Sample\\n\");\n      console.log(\"```json\");\n      console.log(JSON.stringify(sample, null, 2));\n      console.log(\"```\");\n    }\n  }\n}\n\nfunction showEventProperties(\n  properties: AuditEventProperties<object>,\n  prefix = \"\",\n) {\n  for (const [key, { type, description, optional, ...rest }] of Object.entries(\n    properties,\n  )) {\n    const name = prefix + key + (optional ? \" *(optional)*\" : \"\");\n    const types = (Array.isArray(type) ? type : [type]).map(t => `\\`${t}\\``);\n    console.log(`| ${name} | ${types.join(\" or \")} | ${description} |`);\n    if (\"properties\" in rest) {\n      showEventProperties(rest.properties, `${prefix + key}.`);\n    }\n  }\n}\n\ntype AuditEvents = {\n  [Action in keyof AuditEventDetails]: Action extends AuditEventAction ?\n    AuditEvent<Action> :\n    never;\n};\n\ninterface AuditEvent<Action extends AuditEventAction> {\n  type: AuditEventType | AuditEventType[];\n  description: string;\n  properties: AuditEventProperties<AuditEventDetails[Action]>;\n  sample: AuditEventDetails[Action];\n}\n\ntype AuditEventProperties<T> = {\n  [K in keyof T]: T[K] extends (object & { length?: never }) | undefined ?\n    AuditEventProperty & { properties: AuditEventProperties<T[K]> } :\n    AuditEventProperty & { properties?: AuditEventProperties<T[K]> };\n};\n\ninterface AuditEventProperty {\n  type: string | string[];\n  description: string;\n  optional?: boolean;\n}\n\nconst AuditEvents: AuditEvents = {\n  \"config.create\": {\n    type: [\"installation\", \"site\"],\n    description: \"A configuration item was created.\",\n    properties: {\n      config: {\n        type: \"object\",\n        description: \"The created configuration item.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The configuration item ID.\",\n          },\n          key: {\n            type: \"string\",\n            description: \"The configuration item key.\",\n          },\n          value: {\n            type: \"any\",\n            description: \"The configuration item value.\",\n            properties: {} as any,\n          },\n          site: {\n            type: \"object\",\n            description: \"The site this configuration item belongs to.\",\n            optional: true,\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The site ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The site name.\",\n              },\n              domain: {\n                type: \"string\",\n                description: \"The site domain.\",\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      config: {\n        id: 18,\n        key: \"audit_log_streaming_destinations\",\n        value: [\n          {\n            id: \"ee6971af-80f5-4654-9bd2-5c6ab33e7ccf\",\n            name: \"splunk\",\n            url: \"https://hec.example.com:8088/services/collector/event\",\n            token: \"Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0\",\n          },\n        ],\n        site: {\n          id: 42,\n          name: \"Grist Labs\",\n          domain: \"gristlabs\",\n        },\n      },\n    },\n  },\n  \"config.delete\": {\n    type: [\"installation\", \"site\"],\n    description: \"A configuration item was deleted.\",\n    properties: {\n      config: {\n        type: \"object\",\n        description: \"The deleted configuration item.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The configuration item ID.\",\n          },\n          key: {\n            type: \"string\",\n            description: \"The configuration item key.\",\n          },\n          value: {\n            type: \"any\",\n            description: \"The configuration item value.\",\n            properties: {} as any,\n          },\n          site: {\n            type: \"object\",\n            description: \"The site this configuration item belonged to.\",\n            optional: true,\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The site ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The site name.\",\n              },\n              domain: {\n                type: \"string\",\n                description: \"The site domain.\",\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      config: {\n        id: 18,\n        key: \"audit_log_streaming_destinations\",\n        value: [\n          {\n            id: \"ee6971af-80f5-4654-9bd2-5c6ab33e7ccf\",\n            name: \"splunk\",\n            url: \"https://hec.example.com:8088/services/collector/event\",\n            token: \"Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0\",\n          },\n        ],\n        site: {\n          id: 42,\n          name: \"Grist Labs\",\n          domain: \"gristlabs\",\n        },\n      },\n    },\n  },\n  \"config.update\": {\n    type: [\"installation\", \"site\"],\n    description: \"A configuration item was updated.\",\n    properties: {\n      previous: {\n        type: \"object\",\n        description: \"The previous versions of affected resources.\",\n        properties: {\n          config: {\n            type: \"object\",\n            description: \"The previous configuration item.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The configuration item ID.\",\n              },\n              key: {\n                type: \"string\",\n                description: \"The configuration item key.\",\n              },\n              value: {\n                type: \"any\",\n                description: \"The configuration item value.\",\n                properties: {} as any,\n              },\n              site: {\n                type: \"object\",\n                description: \"The site this configuration item belongs to.\",\n                optional: true,\n                properties: {\n                  id: {\n                    type: \"number\",\n                    description: \"The site ID.\",\n                  },\n                  name: {\n                    type: \"string\",\n                    description: \"The site name.\",\n                  },\n                  domain: {\n                    type: \"string\",\n                    description: \"The site domain.\",\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n      current: {\n        type: \"object\",\n        description: \"The current versions of affected resources.\",\n        properties: {\n          config: {\n            type: \"object\",\n            description: \"The current configuration item.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The configuration item ID.\",\n              },\n              key: {\n                type: \"string\",\n                description: \"The configuration item key.\",\n              },\n              value: {\n                type: \"any\",\n                description: \"The configuration item value.\",\n                properties: {} as any,\n              },\n              site: {\n                type: \"object\",\n                description: \"The site this configuration item belongs to.\",\n                optional: true,\n                properties: {\n                  id: {\n                    type: \"number\",\n                    description: \"The site ID.\",\n                  },\n                  name: {\n                    type: \"string\",\n                    description: \"The site name.\",\n                  },\n                  domain: {\n                    type: \"string\",\n                    description: \"The site domain.\",\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      previous: {\n        config: {\n          id: 18,\n          key: \"audit_log_streaming_destinations\",\n          value: [\n            {\n              id: \"ee6971af-80f5-4654-9bd2-5c6ab33e7ccf\",\n              name: \"splunk\",\n              url: \"https://hec.example.com:8088/services/collector/event\",\n              token: \"Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0\",\n            },\n          ],\n          site: {\n            id: 42,\n            name: \"Grist Labs\",\n            domain: \"gristlabs\",\n          },\n        },\n      },\n      current: {\n        config: {\n          id: 18,\n          key: \"audit_log_streaming_destinations\",\n          value: [\n            {\n              id: \"ee6971af-80f5-4654-9bd2-5c6ab33e7ccf\",\n              name: \"splunk\",\n              url: \"https://hec.example.com:8088/services/collector/event\",\n              token: \"Splunk B5A79AAD-D822-46CC-80D1-819F80D7BFB0\",\n            },\n            {\n              id: \"8f421760-14e9-4d11-b10a-f51d82041e0f\",\n              name: \"other\",\n              url: \"https://other.example.com/events\",\n            },\n          ],\n          site: {\n            id: 42,\n            name: \"Grist Labs\",\n            domain: \"gristlabs\",\n          },\n        },\n      },\n    },\n  },\n  \"document.change_access\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document's access was changed.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n        },\n      },\n      access_changes: {\n        type: \"object\",\n        description: \"The access changes.\",\n        properties: {\n          public_access: {\n            type: [\"string\", \"null\"],\n            description: \"The new public access level.\",\n            optional: true,\n          },\n          max_inherited_access: {\n            type: [\"string\", \"null\"],\n            description:\n              \"The new maximum access level that can be inherited from the document's workspace or site.\",\n            optional: true,\n          },\n          users: {\n            type: \"Array<object>\",\n            description: \"The new access levels of individual users.\",\n            optional: true,\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n      },\n      access_changes: {\n        public_access: \"viewers\",\n        max_inherited_access: null,\n        users: [\n          {\n            id: 146,\n            name: \"Flapjack Toasty\",\n            email: \"flapjack@example.com\",\n            access: \"owners\",\n          },\n        ],\n      },\n    },\n  },\n  \"document.clear_all_webhook_queues\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document's webhook queues were cleared.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The created document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n      },\n    },\n  },\n  \"document.clear_webhook_queue\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document's webhook queue was cleared.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n        },\n      },\n      webhook: {\n        type: \"object\",\n        description: \"The webhook.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The webhook ID.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n      },\n      webhook: {\n        id: \"17f8328e-0523-41fe-89aa-ae180bebb26e\",\n      },\n    },\n  },\n  \"document.create\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was created.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The created document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n          workspace: {\n            type: \"object\",\n            description: \"The document's workspace.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The workspace ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The workspace name.\",\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n        workspace: {\n          id: 97,\n          name: \"Secret Plans\",\n        },\n      },\n    },\n  },\n  \"document.delete\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was permanently deleted.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The deleted document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n      },\n    },\n  },\n  \"document.deliver_webhook_events\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document's webhook successfully delivered events.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n        },\n      },\n      webhook: {\n        type: \"object\",\n        description: \"The webhook.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The webhook ID.\",\n          },\n          events: {\n            type: \"object\",\n            description: \"The delivered webhook events.\",\n            properties: {\n              delivered_to: {\n                type: \"string\",\n                description: \"Where the webhook events were delivered to.\",\n              },\n              quantity: {\n                type: \"number\",\n                description:\n                  \"The number of webhook events that were delivered.\",\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n      },\n      webhook: {\n        id: \"17f8328e-0523-41fe-89aa-ae180bebb26e\",\n        events: {\n          delivered_to: \"example.com\",\n          quantity: 3,\n        },\n      },\n    },\n  },\n  \"document.disable\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was disabled.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The disabled document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n          workspace: {\n            type: \"object\",\n            description: \"The document's workspace.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The workspace ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The workspace name.\",\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n        workspace: {\n          id: 97,\n          name: \"Secret Plans\",\n        },\n      },\n    },\n  },\n  \"document.duplicate\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was duplicated.\",\n    properties: {\n      original: {\n        type: \"object\",\n        description: \"The resources that were duplicated.\",\n        properties: {\n          document: {\n            type: \"object\",\n            description: \"The document that was duplicated.\",\n            properties: {\n              id: {\n                type: \"string\",\n                description: \"The document ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The document name.\",\n              },\n            },\n          },\n        },\n      },\n      duplicate: {\n        type: \"object\",\n        description: \"The newly-duplicated resources.\",\n        properties: {\n          document: {\n            type: \"object\",\n            description: \"The newly-duplicated document.\",\n            properties: {\n              id: {\n                type: \"string\",\n                description: \"The document ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The document name.\",\n              },\n              workspace: {\n                type: \"object\",\n                description: \"The document's workspace.\",\n                properties: {\n                  id: {\n                    type: \"number\",\n                    description: \"The workspace ID\",\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n      options: {\n        type: \"object\",\n        description: \"The options used to duplicate the document.\",\n        properties: {\n          as_template: {\n            type: \"boolean\",\n            description: \"Include the structure without any data.\",\n          },\n        },\n      },\n    },\n    sample: {\n      original: {\n        document: {\n          id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n          name: \"Project Lollipop\",\n        },\n      },\n      duplicate: {\n        document: {\n          id: \"fFKKA6qjXJd9sNLhpw6iPn\",\n          name: \"Project Lollipop V2\",\n          workspace: {\n            id: 92,\n          },\n        },\n      },\n      options: {\n        as_template: false,\n      },\n    },\n  },\n  \"document.enable\": {\n    type: [\"installation\", \"site\"],\n    description: \"A disabled document was re-enabled.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The enabled document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n          workspace: {\n            type: \"object\",\n            description: \"The document's workspace.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The workspace ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The workspace name.\",\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n        workspace: {\n          id: 97,\n          name: \"Secret Plans\",\n        },\n      },\n    },\n  },\n  \"document.fork\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was forked.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The document that was forked.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n        },\n      },\n      fork: {\n        type: \"object\",\n        description: \"The newly-forked document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The fork ID.\",\n          },\n          document_id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          url_id: {\n            type: \"string\",\n            description: \"The URL ID.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n      },\n      fork: {\n        id: \"fGGyPYea1ueFiVW382uuAY\",\n        document_id: \"mRM8ydxxLkc6Ewo56jsDGx~fGGyPYea1ueFiVW382uuAY~9\",\n        url_id: \"mRM8ydxxLkc6~fGGyPYea1ueFiVW382uuAY~9\",\n      },\n    },\n  },\n  \"document.modify\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was modified.\",\n    properties: {\n      action: {\n        type: \"object\",\n        description: \"The action.\",\n        properties: {\n          num: {\n            type: \"number\",\n            description: \"The action number.\",\n          },\n          hash: {\n            type: [\"string\", \"null\"],\n            description: \"The action hash.\",\n          },\n        },\n      },\n      document: {\n        type: \"object\",\n        description: \"The document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n        },\n      },\n    },\n    sample: {\n      action: {\n        num: 7,\n        hash: \"825f859cf9628d9df90c1b25e31c723bb1c05c061cab6d1d9ccfea340e68d638\",\n      },\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n      },\n    },\n  },\n  \"document.move\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was moved to a different workspace.\",\n    properties: {\n      previous: {\n        type: \"object\",\n        description: \"The previous versions of affected resources.\",\n        properties: {\n          document: {\n            type: \"object\",\n            description: \"The previous document.\",\n            properties: {\n              id: {\n                type: \"string\",\n                description: \"The document ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The document name.\",\n              },\n              workspace: {\n                type: \"object\",\n                description: \"The document's workspace.\",\n                properties: {\n                  id: {\n                    type: \"number\",\n                    description: \"The workspace ID.\",\n                  },\n                  name: {\n                    type: \"string\",\n                    description: \"The workspace name.\",\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n      current: {\n        type: \"object\",\n        description: \"The current versions of affected resources.\",\n        properties: {\n          document: {\n            type: \"object\",\n            description: \"The current document.\",\n            properties: {\n              id: {\n                type: \"string\",\n                description: \"The document ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The document name.\",\n              },\n              workspace: {\n                type: \"object\",\n                description: \"The document's workspace.\",\n                properties: {\n                  id: {\n                    type: \"number\",\n                    description: \"The workspace ID.\",\n                  },\n                  name: {\n                    type: \"string\",\n                    description: \"The workspace name.\",\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      previous: {\n        document: {\n          id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n          name: \"Project Lollipop\",\n          workspace: {\n            id: 97,\n            name: \"Secret Plans\",\n          },\n        },\n      },\n      current: {\n        document: {\n          id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n          name: \"Project Lollipop\",\n          workspace: {\n            id: 98,\n            name: \"Not So Secret Plans\",\n          },\n        },\n      },\n    },\n  },\n  \"document.move_to_trash\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was moved to the trash.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The removed document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n      },\n    },\n  },\n  \"document.open\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was opened.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The opened document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n          url_id: {\n            type: \"string\",\n            description: \"The URL ID.\",\n          },\n          fork_id: {\n            type: \"string\",\n            description: \"The fork ID.\",\n            optional: true,\n          },\n          snapshot_id: {\n            type: \"string\",\n            description: \"The snapshot ID.\",\n            optional: true,\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n        url_id: \"mRM8ydxxLkc6~fGGyPYea1ueFiVW382uuAY~9\",\n        fork_id: \"fGGyPYea1ueFiVW382uuAY\",\n      },\n    },\n  },\n  \"document.pin\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was pinned.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The pinned document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n      },\n    },\n  },\n  \"document.reload\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was reloaded.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The reloaded document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n      },\n    },\n  },\n  \"document.rename\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was renamed.\",\n    properties: {\n      previous: {\n        type: \"object\",\n        description: \"The previous versions of affected resources.\",\n        properties: {\n          document: {\n            type: \"object\",\n            description: \"The previous document.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The document ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The document name.\",\n              },\n            },\n          },\n        },\n      },\n      current: {\n        type: \"object\",\n        description: \"The current versions of affected resources.\",\n        properties: {\n          document: {\n            type: \"object\",\n            description: \"The current document.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The document ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The document name.\",\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      previous: {\n        document: {\n          id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n          name: \"Project Lollipop\",\n        },\n      },\n      current: {\n        document: {\n          id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n          name: \"Competitive Analysis\",\n        },\n      },\n    },\n  },\n  \"document.replace\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was replaced.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The document that was replaced.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n        },\n      },\n      fork: {\n        type: \"object\",\n        description: \"The fork that the document was replaced with.\",\n        optional: true,\n        properties: {\n          document_id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n        },\n      },\n      snapshot: {\n        type: \"object\",\n        description: \"The snapshot that the document was replaced with.\",\n        optional: true,\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The snapshot ID.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n      },\n      fork: {\n        document_id: \"mRM8ydxxLkc6Ewo56jsDGx~fGGyPYea1ueFiVW382uuAY~9\",\n      },\n    },\n  },\n  \"document.restore_from_trash\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was restored from the trash.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The restored document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n          workspace: {\n            type: \"object\",\n            description: \"The document's workspace.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The workspace ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The workspace name.\",\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n        workspace: {\n          id: 97,\n          name: \"Secret Plans\",\n        },\n      },\n    },\n  },\n  \"document.run_sql_query\": {\n    type: [\"installation\", \"site\"],\n    description: \"A SQL query was run against a document.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The queried document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n        },\n      },\n      sql_query: {\n        type: \"object\",\n        description: \"The SQL query.\",\n        properties: {\n          statement: {\n            type: \"string\",\n            description: \"The SQL statement.\",\n          },\n          arguments: {\n            type: \"Array<string | number>\",\n            description:\n              \"The arguments passed to parameters in the SQL statement.\",\n            optional: true,\n          },\n        },\n      },\n      options: {\n        type: \"object\",\n        description: \"The options used to query the document.\",\n        properties: {\n          timeout_ms: {\n            type: \"number\",\n            description:\n              \"Timeout in milliseconds after which operations on the document will be interrupted.\",\n            optional: true,\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n      },\n      sql_query: {\n        statement: \"SELECT * FROM Pets WHERE popularity >= ?\",\n        arguments: [50],\n      },\n      options: {\n        timeout_ms: 500,\n      },\n    },\n  },\n  \"document.send_to_google_drive\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was sent to Google Drive.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The sent document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n      },\n    },\n  },\n  \"document.truncate_history\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document's history was truncated.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n        },\n      },\n      options: {\n        type: \"object\",\n        description: \"The options used to truncate the document's history.\",\n        properties: {\n          keep_n_most_recent: {\n            type: \"number\",\n            description: \"The number of recent history actions to keep.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n      },\n      options: {\n        keep_n_most_recent: 3,\n      },\n    },\n  },\n  \"document.unpin\": {\n    type: [\"installation\", \"site\"],\n    description: \"A document was unpinned.\",\n    properties: {\n      document: {\n        type: \"object\",\n        description: \"The unpinned document.\",\n        properties: {\n          id: {\n            type: \"string\",\n            description: \"The document ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The document name.\",\n          },\n        },\n      },\n    },\n    sample: {\n      document: {\n        id: \"mRM8ydxxLkc6Ewo56jsDGx\",\n        name: \"Project Lollipop\",\n      },\n    },\n  },\n  \"site.change_access\": {\n    type: [\"installation\", \"site\"],\n    description: \"A site's access was changed.\",\n    properties: {\n      site: {\n        type: \"object\",\n        description: \"The site.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The site ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The site name.\",\n          },\n          domain: {\n            type: \"string\",\n            description: \"The site domain.\",\n          },\n        },\n      },\n      access_changes: {\n        type: \"object\",\n        description: \"The access changes.\",\n        properties: {\n          users: {\n            type: \"Array<object>\",\n            description: \"The new access levels of individual users.\",\n          },\n        },\n      },\n    },\n    sample: {\n      site: {\n        id: 42,\n        name: \"Grist Labs\",\n        domain: \"gristlabs\",\n      },\n      access_changes: {\n        users: [\n          {\n            id: 146,\n            name: \"Flapjack Toasty\",\n            email: \"flapjack@example.com\",\n            access: \"owners\",\n          },\n        ],\n      },\n    },\n  },\n  \"site.create\": {\n    type: [\"installation\"],\n    description: \"A site was created.\",\n    properties: {\n      site: {\n        type: \"object\",\n        description: \"The created site.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The site ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The site name.\",\n          },\n          domain: {\n            type: \"string\",\n            description: \"The site domain.\",\n          },\n        },\n      },\n    },\n    sample: {\n      site: {\n        id: 42,\n        name: \"Grist Labs\",\n        domain: \"gristlabs\",\n      },\n    },\n  },\n  \"site.delete\": {\n    type: [\"installation\"],\n    description: \"A site was permanently deleted.\",\n    properties: {\n      site: {\n        type: \"object\",\n        description: \"The deleted site.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The site ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The site name.\",\n          },\n          domain: {\n            type: \"string\",\n            description: \"The site domain.\",\n          },\n        },\n      },\n    },\n    sample: {\n      site: {\n        id: 42,\n        name: \"Grist Labs\",\n        domain: \"gristlabs\",\n      },\n    },\n  },\n  \"site.rename\": {\n    type: [\"installation\", \"site\"],\n    description: \"A site was renamed.\",\n    properties: {\n      previous: {\n        type: \"object\",\n        description: \"The previous versions of affected resources.\",\n        properties: {\n          site: {\n            type: \"object\",\n            description: \"The previous site.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The site ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The site name.\",\n              },\n              domain: {\n                type: \"string\",\n                description: \"The site domain.\",\n              },\n            },\n          },\n        },\n      },\n      current: {\n        type: \"object\",\n        description: \"The current versions of affected resources.\",\n        properties: {\n          site: {\n            type: \"object\",\n            description: \"The current site.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The site ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The site name.\",\n              },\n              domain: {\n                type: \"string\",\n                description: \"The site domain.\",\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      previous: {\n        site: {\n          id: 42,\n          name: \"Grist Labs\",\n          domain: \"gristlabs\",\n        },\n      },\n      current: {\n        site: {\n          id: 42,\n          name: \"ACME Unlimited\",\n          domain: \"acme\",\n        },\n      },\n    },\n  },\n  \"user.change_name\": {\n    type: [\"installation\"],\n    description: \"A user's name was changed.\",\n    properties: {\n      previous: {\n        type: \"object\",\n        description: \"The previous versions of affected resources.\",\n        properties: {\n          user: {\n            type: \"object\",\n            description: \"The previous user.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The user ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The user's name.\",\n              },\n              email: {\n                type: \"string\",\n                description: \"The user's email.\",\n                optional: true,\n              },\n            },\n          },\n        },\n      },\n      current: {\n        type: \"object\",\n        description: \"The current versions of affected resources.\",\n        properties: {\n          user: {\n            type: \"object\",\n            description: \"The current user.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The user ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The user's name.\",\n              },\n              email: {\n                type: \"string\",\n                description: \"The user's email.\",\n                optional: true,\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      previous: {\n        user: {\n          id: 146,\n          name: \"Flapjack Waffleflap\",\n          email: \"flapjack@example.com\",\n        },\n      },\n      current: {\n        user: {\n          id: 146,\n          name: \"Flapjack Toasty\",\n          email: \"flapjack@example.com\",\n        },\n      },\n    },\n  },\n  \"user.create_api_key\": {\n    type: [\"installation\"],\n    description: \"A user API key was created.\",\n    properties: {\n      user: {\n        type: \"object\",\n        description: \"The user.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The user ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The user's name.\",\n          },\n          email: {\n            type: \"string\",\n            description: \"The user's email.\",\n            optional: true,\n          },\n        },\n      },\n    },\n    sample: {\n      user: {\n        id: 146,\n        name: \"Flapjack Waffleflap\",\n        email: \"flapjack@example.com\",\n      },\n    },\n  },\n  \"user.delete\": {\n    type: [\"installation\"],\n    description: \"A user was permanently deleted.\",\n    properties: {\n      user: {\n        type: \"object\",\n        description: \"The user.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The user ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The user's name.\",\n          },\n          email: {\n            type: \"string\",\n            description: \"The user's email.\",\n            optional: true,\n          },\n        },\n      },\n    },\n    sample: {\n      user: {\n        id: 146,\n        name: \"Flapjack Waffleflap\",\n        email: \"flapjack@example.com\",\n      },\n    },\n  },\n  \"user.delete_api_key\": {\n    type: [\"installation\"],\n    description: \"A user API key was deleted.\",\n    properties: {\n      user: {\n        type: \"object\",\n        description: \"The user.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The user ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The user's name.\",\n          },\n          email: {\n            type: \"string\",\n            description: \"The user's email.\",\n            optional: true,\n          },\n        },\n      },\n    },\n    sample: {\n      user: {\n        id: 146,\n        name: \"Flapjack Waffleflap\",\n        email: \"flapjack@example.com\",\n      },\n    },\n  },\n  \"workspace.change_access\": {\n    type: [\"installation\", \"site\"],\n    description: \"A workspace's access was changed.\",\n    properties: {\n      workspace: {\n        type: \"object\",\n        description: \"The workspace.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The workspace ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The workspace name.\",\n          },\n        },\n      },\n      access_changes: {\n        type: \"object\",\n        description: \"The access changes.\",\n        properties: {\n          max_inherited_access: {\n            type: [\"string\", \"null\"],\n            description:\n              \"The new maximum access level that can be inherited from the workspace's site.\",\n            optional: true,\n          },\n          users: {\n            type: \"Array<object>\",\n            description: \"The new access levels of individual users.\",\n            optional: true,\n          },\n        },\n      },\n    },\n    sample: {\n      workspace: {\n        id: 97,\n        name: \"Secret Plans\",\n      },\n      access_changes: {\n        max_inherited_access: \"editors\",\n        users: [\n          {\n            id: 146,\n            name: \"Flapjack Toasty\",\n            email: \"flapjack@example.com\",\n            access: \"editors\",\n          },\n        ],\n      },\n    },\n  },\n  \"workspace.create\": {\n    type: [\"installation\", \"site\"],\n    description: \"A workspace was created.\",\n    properties: {\n      workspace: {\n        type: \"object\",\n        description: \"The created workspace.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The workspace ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The workspace name.\",\n          },\n        },\n      },\n    },\n    sample: {\n      workspace: {\n        id: 97,\n        name: \"Secret Plans\",\n      },\n    },\n  },\n  \"workspace.delete\": {\n    type: [\"installation\", \"site\"],\n    description: \"A workspace was permanently deleted.\",\n    properties: {\n      workspace: {\n        type: \"object\",\n        description: \"The deleted workspace.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The workspace ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The workspace name.\",\n          },\n        },\n      },\n    },\n    sample: {\n      workspace: {\n        id: 97,\n        name: \"Secret Plans\",\n      },\n    },\n  },\n  \"workspace.move_to_trash\": {\n    type: [\"installation\", \"site\"],\n    description: \"A workspace was moved to the trash.\",\n    properties: {\n      workspace: {\n        type: \"object\",\n        description: \"The removed workspace.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The workspace ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The workspace name.\",\n          },\n        },\n      },\n    },\n    sample: {\n      workspace: {\n        id: 97,\n        name: \"Secret Plans\",\n      },\n    },\n  },\n  \"workspace.rename\": {\n    type: [\"installation\", \"site\"],\n    description: \"A workspace was renamed.\",\n    properties: {\n      previous: {\n        type: \"object\",\n        description: \"The previous versions of affected resources.\",\n        properties: {\n          workspace: {\n            type: \"object\",\n            description: \"The previous workspace.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The workspace ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The workspace name.\",\n              },\n            },\n          },\n        },\n      },\n      current: {\n        type: \"object\",\n        description: \"The current versions of affected resources.\",\n        properties: {\n          workspace: {\n            type: \"object\",\n            description: \"The current workspace.\",\n            properties: {\n              id: {\n                type: \"number\",\n                description: \"The workspace ID.\",\n              },\n              name: {\n                type: \"string\",\n                description: \"The workspace name.\",\n              },\n            },\n          },\n        },\n      },\n    },\n    sample: {\n      previous: {\n        workspace: {\n          id: 97,\n          name: \"Secret Plans\",\n        },\n      },\n      current: {\n        workspace: {\n          id: 97,\n          name: \"Retreat Docs\",\n        },\n      },\n    },\n  },\n  \"workspace.restore_from_trash\": {\n    type: [\"installation\", \"site\"],\n    description: \"A workspace was restored from the trash.\",\n    properties: {\n      workspace: {\n        type: \"object\",\n        description: \"The restored workspace.\",\n        properties: {\n          id: {\n            type: \"number\",\n            description: \"The workspace ID.\",\n          },\n          name: {\n            type: \"string\",\n            description: \"The workspace name.\",\n          },\n        },\n      },\n    },\n    sample: {\n      workspace: {\n        id: 97,\n        name: \"Secret Plans\",\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "app/server/utils/streams.ts",
    "content": "import { promises, Readable, Writable } from \"stream\";\n\n// Creates a writable stream that can be retrieved as a buffer.\n// Sub-optimal implementation, as we end up with *at least* two copies in memory one in `buffers`,\n// and one produced by `Buffer.concat` at the end.\nexport class MemoryWritableStream extends Writable {\n  private _buffers: Buffer[] = [];\n\n  public getBuffer(): Buffer {\n    return Buffer.concat(this._buffers);\n  }\n\n  public _write(chunk: any, encoding: BufferEncoding, callback: (error?: (Error | null)) => void) {\n    if (typeof (chunk) == \"string\") {\n      this._buffers.push(Buffer.from(chunk, encoding));\n    } else {\n      this._buffers.push(chunk);\n    }\n    callback();\n  }\n}\n\n/**\n * Drains a readable stream if it has any more data after the promise settles.\n * @param {Readable} stream - A readable stream that needs to be drained.\n * @param {Promise<T>} promise - A promise that should only resolve once it's finished with the\n *   stream.\n * @returns {Promise<T>} - A new promise with the same state as the original, unless the stream\n *   draining errors.\n */\nexport async function drainWhenSettled<T>(stream: Readable, promise: Promise<T>): Promise<T> {\n  try {\n    return await promise;\n  } finally {\n    if (stream.readable) {\n      stream.resume();\n    }\n    await promises.finished(stream);\n  }\n}\n"
  },
  {
    "path": "app/tsconfig.json",
    "content": "{\n  \"extends\": \"../buildtools/tsconfig-base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    { \"path\": \"./client\" },\n    { \"path\": \"./server\" },\n    { \"path\": \"./common\" },\n  ]\n}\n"
  },
  {
    "path": "buildtools/.grist-ee-version",
    "content": "date-2026-03-09-16-02-03\n"
  },
  {
    "path": "buildtools/build.sh",
    "content": "#!/usr/bin/env bash\n\nset -eEu -o pipefail\n\nPROJECT=\"\"\nWEBPACK_MODE=\"--mode production\"\nMODE=\"${1:-}\"\nif [[ -e ext/app ]]; then\n  PROJECT=\"tsconfig-ext.json\"\n  echo \"Using extra app directory\"\nelif [[ \"${MODE}\" == \"prod\" ]]; then\n  PROJECT=\"tsconfig-prod.json\"\n  echo \"Building for production\"\nelse\n  WEBPACK_MODE=\"--mode development\"\n  echo \"No extra app directory found\"\nfi\n\nWEBPACK_CONFIG=buildtools/webpack.config.js\nif [[ -e ext/buildtools/webpack.config.js ]]; then\n  # Allow webpack config file to be replaced (useful\n  # for grist-static)\n  WEBPACK_CONFIG=ext/buildtools/webpack.config.js\nfi\n\nset -x\nnode buildtools/sanitize_translations.js\ntsc --build $PROJECT\nbuildtools/update_type_info.sh app\nwebpack --config $WEBPACK_CONFIG $WEBPACK_MODE\nwebpack --config buildtools/webpack.check.js $WEBPACK_MODE\nwebpack --config buildtools/webpack.api.config.js $WEBPACK_MODE\ncat app/client/*.css app/client/*/*.css > static/bundle.css\n"
  },
  {
    "path": "buildtools/checkout-ext-directory.sh",
    "content": "#!/usr/bin/env bash\n\n# This checks out the ext/ directory from the extra repo (e.g.\n# grist-ee or grist-desktop) depending on the supplied repo name.\n\nset -e\n\nrepo=$1\ndir=$(dirname $0)\n\nif [[ \"$repo\" = \"\" ]]; then\n  echo \"+ Please supply a repo to checkout (such as grist-ee)\"\n  exit 1\nfi\n\nref=$(cat $dir/.$repo-version)\n\necho \"+ Fetching $repo\"\ngit -c advice.detachedHead=false clone --quiet --branch $ref \\\n    --depth 1 --filter=tree:0 \"https://github.com/gristlabs/$repo\"\npushd $repo > /dev/null\ngit sparse-checkout set ext\ngit checkout\npopd > /dev/null\n\necho \"+ Installing as ext directory\"\nrm -rf ./ext\nmv $repo/ext .\nrm -rf $repo\n"
  },
  {
    "path": "buildtools/fly-deploy.js",
    "content": "const crypto = require(\"crypto\");\nconst util = require(\"util\");\nconst childProcess = require(\"child_process\");\nconst fs = require(\"fs/promises\");\n\nconst exec = util.promisify(childProcess.exec);\n\nconst org = \"grist-labs\";\nconst expirationSec = 30 * 24 * 60 * 60;  // 30 days\n\nfunction getAppName() {\n  return sanitizeName(getBranchName(), {\n    prefix: \"grist-\",\n    maxLength: 62,\n    separator: \"-\",\n    replacePattern: /[\\W|_]+/g\n  });\n}\n\nfunction getVolumeName() {\n  return sanitizeName(getBranchName(), {\n    prefix: \"gv_\",\n    maxLength: 30,\n    separator: \"_\",\n    replacePattern: /\\W+/g\n  });\n}\n\nconst getBranchName = () => {\n  if (!process.env.BRANCH_NAME) { console.log(\"Usage: Need BRANCH_NAME env var\"); process.exit(1); }\n  return process.env.BRANCH_NAME;\n};\n\nasync function main() {\n  switch (process.argv[2]) {\n    case \"deploy\": {\n      const name = getAppName();\n      const volName = getVolumeName();\n      if (!await appExists(name)) {\n        await appCreate(name);\n        await volCreate(name, volName);\n      } else {\n        // Check if volume exists, and create it if not. This is needed because there was an API\n        // change in flyctl (mandatory -y flag) and some apps were created without a volume.\n        if (!(await volList(name)).length) {\n          await volCreate(name, volName);\n        }\n      }\n      await prepConfig(name, volName);\n      await appDeploy(name);\n      break;\n    }\n    case \"destroy\": {\n      const name = getAppName();\n      if (await appExists(name)) {\n        await appDestroy(name);\n      }\n      break;\n    }\n    case \"clean\": {\n      const staleApps = await findStaleApps();\n      for (const appName of staleApps) {\n        await appDestroy(appName);\n      }\n      break;\n    }\n    default: {\n      console.log(`Usage:\n  deploy:   create (if needed) and deploy fly app grist-{BRANCH_NAME}.\n  destroy:  destroy fly app grist-{BRANCH_NAME}\n  clean:    destroy all grist-* fly apps whose time has come\n            (according to FLY_DEPLOY_EXPIRATION env var set at deploy time)\n\n  DRYRUN=1 in environment will show what would be done\n`);\n      process.exit(1);\n    }\n  }\n}\n\nfunction getDockerTag(name) {\n  return `registry.fly.io/${name}:latest`;\n}\n\nconst appExists = (name) => runFetch(`flyctl status -a ${name}`).then(() => true).catch(() => false);\n// We do not deploy at the create stage, since the Docker image isn't ready yet.\n// Assigning --image prevents flyctl from making inferences based on the codebase and provisioning unnecessary postgres/redis instances.\nconst appCreate = (name) => runAction(`flyctl launch --no-deploy --auto-confirm --image ${getDockerTag(name)} --name ${name} -r ewr -o ${org}`);\nconst volCreate = (name, vol) => runAction(`flyctl volumes create ${vol} -s 1 -r ewr -y -a ${name}`);\nconst volList = (name) => runFetch(`flyctl volumes list -a ${name} -j`).then(({stdout}) => JSON.parse(stdout));\nconst appDeploy = async (name) => {\n  try {\n    await runAction(\"flyctl auth docker\");\n    await runAction(`docker image tag grist-core:preview ${getDockerTag(name)}`);\n    await runAction(`docker push ${getDockerTag(name)}`);\n    await runAction(`flyctl deploy --app ${name} --image ${getDockerTag(name)}`);\n  } catch (e) {\n    console.log(`Error occurred when deploying: ${e}`);\n    process.exit(1);\n  }\n};\n\nasync function appDestroy(name) {\n  await runAction(`flyctl apps destroy ${name} -y`);\n}\n\nasync function prepConfig(name, volName) {\n  const configPath = \"./fly.toml\";\n  const configTemplatePath = \"./buildtools/fly-template.toml\";\n  const envVarsPath = \"./buildtools/fly-template.env\";\n  const template = await fs.readFile(configTemplatePath, {encoding: \"utf8\"});\n\n  // Parse envVarsPath manually to avoid the need to install npm modules. It supports comments,\n  // strips whitespace, and splits on \"=\". (If not for comments, we could've used json.)\n  // The reason it's separate is to allow it to come from untrusted branches.\n  const envVars = [];\n  const envVarsContent = await fs.readFile(envVarsPath, {encoding: \"utf8\"});\n  for (const line of envVarsContent.split(/\\n/)) {\n    const match = /^(?:\\s*([^#=]+?)\\s*=\\s*([^#]*?))?\\s*(?:#.*)?$/.exec(line);\n    if (!match) {\n      throw new Error(`Invalid syntax in ${envVarsPath}, in ${line}`);\n    }\n    // The regexp also matches empty lines, but if match[1] is present, then we have key=value.\n    if (match[1]) {\n      envVars.push(`  ${stringifyTomlString(match[1])} = ${stringifyTomlString(match[2])}`);\n    }\n  }\n\n  // Calculate the time when we can destroy the app, used by findStaleApps.\n  const expiration = new Date(Date.now() + expirationSec * 1000).toISOString();\n  const config = template\n    .replace(/{APP_NAME}/g, name)\n    .replace(/{VOLUME_NAME}/g, volName)\n    .replace(/{FLY_DEPLOY_EXPIRATION}/g, expiration)\n\n    // If there are any process.env vars starting with \"FLY_ENV__\",\n    // append them (without the prefix) at the point after the line\n    // with the <FLY_ENV__> tag.\n    .replace(/<FLY_ENV__>.*/, `$&\\n${extraVars()}`)\n\n    // If there are any env vars, append them after line with <fly-template.env> tag.\n    .replace(/<fly-template.env>.*/, `$&\\n${envVars.join(\"\\n\")}`);\n\n  await fs.writeFile(configPath, config);\n}\n\nfunction extraVars() {\n  const lines = [];\n  for (const [name, value] of Object.entries(process.env)) {\n    if (name.startsWith(\"FLY_ENV__\")) {\n      const varName = name.slice(\"FLY_ENV__\".length);\n      lines.push(`  ${stringifyTomlString(varName)} = ${stringifyTomlString(value)}`);\n    }\n  }\n  return lines.join(\"\\n\");\n}\n\n// Stringify a string for safe inclusion into toml. (We are careful not to allow it to escape\n// being a string.)\nfunction stringifyTomlString(str) {\n  // JSON.stringify() is sufficient to produce a safe TOML string.\n  return JSON.stringify(String(str));\n}\n\n// We have strict limits on how long names can be, but we want them\n// both human readable and reasonably unique, so make some trade-offs.\nfunction sanitizeName(branchName, options) {\n  const { prefix, maxLength, separator, replacePattern } = options;\n  const sanitized = branchName.toLowerCase().replace(replacePattern, separator);\n  const name = prefix + sanitized;\n\n  if (name.length <= maxLength) {\n    return name;\n  }\n\n  // Add a short hash to avoid colliding with similarly named branches.\n  const hashLength = 6;\n  const hash = crypto.createHash(\"sha256\").update(branchName).digest(\"hex\").substring(0, hashLength);\n  const maxBranchLength = maxLength - prefix.length - hash.length - separator.length;\n\n  // Take from the end of the branch name.\n  const branchPart = sanitized.substring(sanitized.length - maxBranchLength);\n  return prefix + branchPart + separator + hash;\n}\n\nfunction runFetch(cmd) {\n  console.log(`Running: ${cmd}`);\n  return exec(cmd);\n}\n\nasync function runAction(cmd) {\n  if (process.env.DRYRUN) {\n    console.log(`Would run: ${cmd}`);\n    return;\n  }\n  console.log(`Running: ${cmd}`);\n  const cp = childProcess.spawn(cmd, {shell: true, stdio: \"inherit\"});\n  return new Promise((resolve, reject) => {\n    cp.on(\"error\", reject);\n    cp.on(\"exit\", function (code) {\n      if (code === 0) {\n        resolve();\n      } else {\n        reject(new Error(`exited with code ${code}`));\n      }\n    });\n  });\n}\n\nasync function findStaleApps() {\n  const {stdout} = await runFetch(`flyctl apps list -j`);\n  const list = JSON.parse(stdout);\n  const appNames = [];\n  for (const app of list) {\n    if (app.Organization?.Slug !== org) {\n      continue;\n    }\n    const {stdout} = await runFetch(`flyctl config display -a ${app.Name}`);\n    const expiration = JSON.parse(stdout).env?.FLY_DEPLOY_EXPIRATION;\n    if (!expiration) {\n      continue;\n    }\n    const expired = (Date.now() > Number(new Date(expiration)));\n    if (isNaN(expired)) {\n      console.warn(`Skipping ${app.Name} with invalid expiration ${expiration}`);\n    } else if (!expired) {\n      console.log(`Skipping ${app.Name}; not reached expiration of ${expiration}`);\n    } else {\n      console.log(`Will clean ${app.Name}; expired at ${expiration}`);\n      appNames.push(app.Name);\n    }\n  }\n  return appNames;\n}\n\nmain().catch(err => { console.warn(\"ERROR\", err); process.exit(1); });\n"
  },
  {
    "path": "buildtools/fly-template.env",
    "content": "# Values here will override the environment in fly-template.toml. When deploying for previews,\n# fly-template.toml is taken from the main branch, but this file is taken from your branch.\n# The syntax is:\n#   KEY=VALUE\n# whitespace is trimmed; \"#\" starts a comment. No support for quoting or multiline values.\n#\n# Note: Do NOT put secrets here.\nPERMITTED_CUSTOM_WIDGETS=calendar\nGRIST_EXPERIMENTAL_PLUGINS=1\nGRIST_SINGLE_ORG=docs\nGRIST_SANDBOX_FLAVOR=gvisor\nGRIST_EXTERNAL_ATTACHMENTS_MODE=test\n"
  },
  {
    "path": "buildtools/fly-template.toml",
    "content": "# When deploying from a Github Action (i.e. for previews), this file is taken from the main\n# branch, rather than from the branch with your changes. I.e. your changes to this file will not\n# be used. However, environment variable from fly-template.env will get used from your branch.\n\napp = \"{APP_NAME}\"\nprimary_region = \"ewr\"\nkill_signal = \"SIGINT\"\nkill_timeout = 5\nprocesses = []\n\n[env]\n  # <fly-template.env> -- this tag gets replaced with values from fly-template.env.\n  APP_DOC_URL=\"https://{APP_NAME}.fly.dev\"\n  APP_HOME_URL=\"https://{APP_NAME}.fly.dev\"\n  APP_STATIC_URL=\"https://{APP_NAME}.fly.dev\"\n  ALLOWED_WEBHOOK_DOMAINS=\"webhook.site\"\n  PORT = \"8080\"\n  FLY_DEPLOY_EXPIRATION = \"{FLY_DEPLOY_EXPIRATION}\"\n  # <FLY_ENV__> -- fly-deploy.js will insert extra env variables here.\n\n[experimental]\n  allowed_public_ports = []\n  auto_rollback = true\n\n[[services]]\n  http_checks = []\n  internal_port = 8080\n  processes = [\"app\"]\n  protocol = \"tcp\"\n  script_checks = []\n  [services.concurrency]\n    hard_limit = 25\n    soft_limit = 20\n    type = \"connections\"\n\n  [[services.ports]]\n    force_https = true\n    handlers = [\"http\"]\n    port = 80\n\n  [[services.ports]]\n    handlers = [\"tls\", \"http\"]\n    port = 443\n\n  [[services.tcp_checks]]\n    grace_period = \"1s\"\n    interval = \"15s\"\n    restart_limit = 0\n    timeout = \"2s\"\n\n[mounts]\nsource=\"{VOLUME_NAME}\"\ndestination=\"/persist\"\n\n[[vm]]\n  memory = '1gb'\n  cpu_kind = 'shared'\n  cpus = 1\n"
  },
  {
    "path": "buildtools/genIconCSS.ts",
    "content": "import * as path from \"path\";\n\nimport { fromCallback } from \"bluebird\";\nimport { program } from \"commander\";\nimport * as fse from \"fs-extra\";\nimport glob from \"glob\";\nimport { union } from \"lodash\";\nimport { optimize } from \"svgo\";\n\nasync function main() {\n  program\n    .description(\"Generate CSS library of icons\")\n    .usage(\"[options] <paths...>\")\n    .option(\"-t --tsfile [tsfile]\", \"Output TypeScript type definition\")\n    .parse(process.argv);\n\n  const inputPaths = program.args;\n\n  if (inputPaths.length === 0) {\n    program.outputHelp();\n    process.exit(1);\n    return;\n  }\n\n  // Create a unique list of file paths from command-line arguments, with globbing\n  const files: string[] = union(...await Promise.all(inputPaths.map(p => fromCallback<string[]>(cb => glob(p, cb)))));\n\n  // Iterate over the files, optimizing SVGs and storing them as base64 data URI root variables\n  console.log(\"/* This file is auto-generated by buildtools/genIconCSS.ts */\");\n  console.log(\"/* Do not edit it manually. */\\n\");\n  console.log(\"@layer grist-base {\");\n  console.log(\"  :root {\");\n  for (const file of files) {\n    const filename = path.basename(file, \".svg\");\n    const result = optimize(await fse.readFile(file, { encoding: \"utf8\" }), {\n      datauri: \"base64\",\n      plugins: [\n        {\n          name: \"preset-default\",\n          params: {\n            overrides: {\n              convertPathData: false,\n            },\n          },\n        },\n      ],\n    });\n    console.log(`    --icon-${filename}: url('${result.data}');`);\n  }\n  console.log(\"  }\");\n  console.log(\"}\");\n\n  // Write out all the icon names as a TypeScript type\n  if (program.opts().tsfile) {\n    const baseNames = files.map(f => JSON.stringify(path.basename(f, \".svg\")));\n    await fse.writeFile(program.opts().tsfile,\n      `// This file is auto-generated by buildtools/genIconCSS.ts\\n` +\n      `// Do not edit it manually.\\n\\n` +\n      `export const IconList = [\\n` +\n      `  ${baseNames.join(\",\\n  \")}\\n` +\n      `] as const;\\n\\n` +\n      `export type IconName = (typeof IconList)[number];\\n`,\n    );\n  }\n}\n\nif (require.main === module) {\n  main().catch(err => console.error(\"Error\", err));\n}\n"
  },
  {
    "path": "buildtools/generate_locale_list.js",
    "content": "/**\n * This file generates list of supported locales for DocumentSettings editor,\n * stored in core/app/common/LocaleCodes.ts\n *\n * To regenerate the list run this script on linux (should be the run on the same OS as the one\n * being use to host Grist):\n *  node generate_locale_list.js\n *\n * Full list of codes was taken from https://lh.2xlibre.net/locales/.\n * List was modified by:\n * - removing tl-PH (PHILIPPINES Tagalog) as it is translated the same in English as Filipino.\n * - changing KV (Kosovo) to XK as this one is supported on Chrome (also in Node, Firefox and Python)\n *\n * For node:\n * - List of supported locales is generated by Intl.DisplayNames.supportedLocalesOf(locale) method.\n *\n * For python\n * - List is generated by using locale module, by script:\n *  import locale\n *  print(\", \".join(sorted(set([('\"' + l.replace(\"_\", \"-\").split(\".\")[0] + '\"') for l in locale.locale_alias.values()]))))\n */\n\n// Locale codes from https://lh.2xlibre.net/locales/\nconst localeCodes = [\n  \"aa-DJ\", \"aa-ER\", \"aa-ET\", \"af-ZA\", \"agr-PE\", \"ak-GH\", \"am-ET\",\n  \"an-ES\", \"anp-IN\", \"ar-AE\", \"ar-BH\", \"ar-DZ\", \"ar-EG\", \"ar-IN\",\n  \"ar-IQ\", \"ar-JO\", \"ar-KW\", \"ar-LB\", \"ar-LY\", \"ar-MA\", \"ar-OM\",\n  \"ar-QA\", \"ar-SA\", \"ar-SD\", \"ar-SS\", \"ar-SY\", \"ar-TN\", \"ar-YE\",\n  \"as-IN\", \"ast-ES\", \"ayc-PE\", \"az-AZ\", \"az-IR\", \"be-BY\", \"bem-ZM\",\n  \"ber-DZ\", \"ber-MA\", \"bg-BG\", \"bhb-IN\", \"bho-IN\", \"bho-NP\", \"bi-VU\",\n  \"bn-BD\", \"bn-IN\", \"bo-CN\", \"bo-IN\", \"br-FR\", \"brx-IN\", \"bs-BA\",\n  \"byn-ER\", \"ca-AD\", \"ca-ES\", \"ca-FR\", \"ca-IT\", \"ce-RU\", \"chr-US\",\n  \"ckb-IQ\", \"cmn-TW\", \"crh-UA\", \"csb-PL\", \"cs-CZ\", \"cv-RU\", \"cy-GB\",\n  \"da-DK\", \"de-AT\", \"de-BE\", \"de-CH\", \"de-DE\", \"de-IT\", \"de-LI\",\n  \"de-LU\", \"doi-IN\", \"dsb-DE\", \"dv-MV\", \"dz-BT\", \"el-CY\", \"el-GR\",\n  \"en-AG\", \"en-AU\", \"en-BW\", \"en-CA\", \"en-DK\", \"en-GB\", \"en-HK\",\n  \"en-IE\", \"en-IL\", \"en-IN\", \"en-NG\", \"en-NZ\", \"en-PH\", \"en-SC\",\n  \"en-SG\", \"en-US\", \"en-ZA\", \"en-ZM\", \"en-ZW\", \"es-AR\", \"es-BO\",\n  \"es-CL\", \"es-CO\", \"es-CR\", \"es-CU\", \"es-DO\", \"es-EC\", \"es-ES\",\n  \"es-GT\", \"es-HN\", \"es-MX\", \"es-NI\", \"es-PA\", \"es-PE\", \"es-PR\",\n  \"es-PY\", \"es-SV\", \"es-US\", \"es-UY\", \"es-VE\", \"et-EE\", \"eu-ES\",\n  \"fa-IR\", \"ff-SN\", \"fi-FI\", \"fil-PH\", \"fo-FO\", \"fr-BE\", \"fr-CA\",\n  \"fr-CH\", \"fr-FR\", \"fr-LU\", \"fur-IT\", \"fy-DE\", \"fy-NL\", \"ga-IE\",\n  \"gd-GB\", \"gez-ER\", \"gez-ET\", \"gl-ES\", \"gu-IN\", \"gv-GB\", \"hak-TW\",\n  \"ha-NG\", \"he-IL\", \"hif-FJ\", \"hi-IN\", \"hne-IN\", \"hr-HR\", \"hsb-DE\",\n  \"ht-HT\", \"hu-HU\", \"hy-AM\", \"ia-FR\", \"id-ID\", \"ig-NG\", \"ik-CA\",\n  \"is-IS\", \"it-CH\", \"it-IT\", \"iu-CA\", \"ja-JP\", \"kab-DZ\", \"ka-GE\",\n  \"kk-KZ\", \"kl-GL\", \"km-KH\", \"kn-IN\", \"kok-IN\", \"ko-KR\", \"ks-IN\",\n  \"ku-TR\", \"kw-GB\", \"ky-KG\", \"lb-LU\", \"lg-UG\", \"li-BE\", \"lij-IT\",\n  \"li-NL\", \"ln-CD\", \"lo-LA\", \"lt-LT\", \"lv-LV\", \"lzh-TW\", \"mag-IN\",\n  \"mai-IN\", \"mai-NP\", \"mfe-MU\", \"mg-MG\", \"mhr-RU\", \"mi-NZ\", \"miq-NI\",\n  \"mjw-IN\", \"mk-MK\", \"ml-IN\", \"mni-IN\", \"mn-MN\", \"mnw-MM\", \"mr-IN\",\n  \"ms-MY\", \"mt-MT\", \"my-MM\", \"nan-TW\", \"nb-NO\", \"nds-DE\", \"nds-NL\",\n  \"ne-NP\", \"nhn-MX\", \"niu-NU\", \"niu-NZ\", \"nl-AW\", \"nl-BE\", \"nl-NL\",\n  \"nn-NO\", \"nr-ZA\", \"nso-ZA\", \"oc-FR\", \"om-ET\", \"om-KE\", \"or-IN\",\n  \"os-RU\", \"pa-IN\", \"pap-AN\", \"pap-AW\", \"pap-CW\", \"pa-PK\", \"pl-PL\",\n  \"ps-AF\", \"pt-BR\", \"pt-PT\", \"quz-PE\", \"raj-IN\", \"ro-RO\", \"ru-RU\",\n  \"ru-UA\", \"rw-RW\", \"sah-RU\", \"sa-IN\", \"sat-IN\", \"sc-IT\", \"sd-IN\",\n  \"se-NO\", \"sgs-LT\", \"shn-MM\", \"shs-CA\", \"sid-ET\", \"si-LK\", \"sk-SK\",\n  \"sl-SI\", \"sm-WS\", \"so-DJ\", \"so-ET\", \"so-KE\", \"so-SO\", \"sq-AL\",\n  \"sq-XK\", \"sq-MK\", \"sr-ME\", \"sr-RS\", \"ss-ZA\", \"st-ZA\", \"sv-FI\",\n  \"sv-SE\", \"sw-KE\", \"sw-TZ\", \"szl-PL\", \"ta-IN\", \"ta-LK\", \"tcy-IN\",\n  \"te-IN\", \"tg-TJ\", \"the-NP\", \"th-TH\", \"ti-ER\", \"ti-ET\", \"tig-ER\",\n  \"tk-TM\", \"tn-ZA\", \"to-TO\", \"tpi-PG\", \"tr-CY\", \"tr-TR\",\n  \"ts-ZA\", \"tt-RU\", \"ug-CN\", \"uk-UA\", \"unm-US\", \"ur-IN\", \"ur-PK\",\n  \"uz-UZ\", \"ve-ZA\", \"vi-VN\", \"wa-BE\", \"wae-CH\", \"wal-ET\", \"wo-SN\",\n  \"xh-ZA\", \"yi-US\", \"yo-NG\", \"yue-HK\", \"yuw-PG\", \"zh-CN\", \"zh-HK\",\n  \"zh-SG\", \"zh-TW\", \"zu-ZA\"\n];\n\n// Locales supported in the OS being used to run this script, which should be what's used for running Grist in production.\nconst inNode = new Set(Intl.DisplayNames.supportedLocalesOf(localeCodes));\n\n// Locale supported in Python 3.8.\n// Currently Python locales support is not that important, but might be in the future.\nconst inPython = new Set([\n  \"C\", \"aa-DJ\", \"aa-ER\", \"aa-ET\", \"af-ZA\", \"agr-PE\", \"ak-GH\", \"am-ET\",\n  \"an-ES\", \"anp-IN\", \"ar-AA\", \"ar-AE\", \"ar-BH\", \"ar-DZ\", \"ar-EG\", \"ar-IN\",\n  \"ar-IQ\", \"ar-JO\", \"ar-KW\", \"ar-LB\", \"ar-LY\", \"ar-MA\", \"ar-OM\", \"ar-QA\",\n  \"ar-SA\", \"ar-SD\", \"ar-SS\", \"ar-SY\", \"ar-TN\", \"ar-YE\", \"as-IN\", \"ast-ES\",\n  \"ayc-PE\", \"az-AZ\", \"az-IR\", \"be-BY\", \"bem-ZM\", \"ber-DZ\", \"ber-MA\",\n  \"bg-BG\", \"bhb-IN\", \"bho-IN\", \"bho-NP\", \"bi-VU\", \"bn-BD\", \"bn-IN\", \"bo-CN\",\n  \"bo-IN\", \"br-FR\", \"brx-IN\", \"bs-BA\", \"byn-ER\", \"ca-AD\", \"ca-ES\", \"ca-FR\",\n  \"ca-IT\", \"ce-RU\", \"chr-US\", \"ckb-IQ\", \"cmn-TW\", \"crh-UA\", \"cs-CZ\",\n  \"csb-PL\", \"cv-RU\", \"cy-GB\", \"da-DK\", \"de-AT\", \"de-BE\", \"de-CH\", \"de-DE\",\n  \"de-IT\", \"de-LI\", \"de-LU\", \"doi-IN\", \"dv-MV\", \"dz-BT\", \"ee-EE\", \"el-CY\",\n  \"el-GR\", \"en-AG\", \"en-AU\", \"en-BE\", \"en-BW\", \"en-CA\", \"en-DK\", \"en-DL\",\n  \"en-EN\", \"en-GB\", \"en-HK\", \"en-IE\", \"en-IL\", \"en-IN\", \"en-NG\", \"en-NZ\",\n  \"en-PH\", \"en-SC\", \"en-SG\", \"en-US\", \"en-ZA\", \"en-ZM\", \"en-ZS\", \"en-ZW\",\n  \"eo\", \"eo-EO\", \"eo-US\", \"eo-XX\", \"es-AR\", \"es-BO\", \"es-CL\", \"es-CO\",\n  \"es-CR\", \"es-CU\", \"es-DO\", \"es-EC\", \"es-ES\", \"es-GT\", \"es-HN\", \"es-MX\",\n  \"es-NI\", \"es-PA\", \"es-PE\", \"es-PR\", \"es-PY\", \"es-SV\", \"es-US\", \"es-UY\",\n  \"es-VE\", \"et-EE\", \"eu-ES\", \"eu-FR\", \"fa-IR\", \"ff-SN\", \"fi-FI\", \"fil-PH\",\n  \"fo-FO\", \"fr-BE\", \"fr-CA\", \"fr-CH\", \"fr-FR\", \"fr-LU\", \"fur-IT\", \"fy-DE\",\n  \"fy-NL\", \"ga-IE\", \"gd-GB\", \"gez-ER\", \"gez-ET\", \"gl-ES\", \"gu-IN\", \"gv-GB\",\n  \"ha-NG\", \"hak-TW\", \"he-IL\", \"hi-IN\", \"hif-FJ\", \"hne-IN\", \"hr-HR\",\n  \"hsb-DE\", \"ht-HT\", \"hu-HU\", \"hy-AM\", \"ia\", \"ia-FR\", \"id-ID\", \"ig-NG\",\n  \"ik-CA\", \"is-IS\", \"it-CH\", \"it-IT\", \"iu-CA\", \"iw-IL\", \"ja-JP\", \"ka-GE\",\n  \"kab-DZ\", \"kk-KZ\", \"kl-GL\", \"km-KH\", \"kn-IN\", \"ko-KR\", \"kok-IN\", \"ks-IN\",\n  \"ku-TR\", \"kw-GB\", \"ky-KG\", \"lb-LU\", \"lg-UG\", \"li-BE\", \"li-NL\", \"lij-IT\",\n  \"ln-CD\", \"lo-LA\", \"lt-LT\", \"lv-LV\", \"lzh-TW\", \"mag-IN\", \"mai-IN\",\n  \"mai-NP\", \"mfe-MU\", \"mg-MG\", \"mhr-RU\", \"mi-NZ\", \"miq-NI\", \"mjw-IN\",\n  \"mk-MK\", \"ml-IN\", \"mn-MN\", \"mni-IN\", \"mr-IN\", \"ms-MY\", \"mt-MT\", \"my-MM\",\n  \"nan-TW\", \"nb-NO\", \"nds-DE\", \"nds-NL\", \"ne-NP\", \"nhn-MX\", \"niu-NU\",\n  \"niu-NZ\", \"nl-AW\", \"nl-BE\", \"nl-NL\", \"nn-NO\", \"no-NO\", \"nr-ZA\", \"nso-ZA\",\n  \"ny-NO\", \"oc-FR\", \"om-ET\", \"om-KE\", \"or-IN\", \"os-RU\", \"pa-IN\", \"pa-PK\",\n  \"pap-AN\", \"pap-AW\", \"pap-CW\", \"pd-DE\", \"pd-US\", \"ph-PH\", \"pl-PL\", \"pp-AN\",\n  \"ps-AF\", \"pt-BR\", \"pt-PT\", \"quz-PE\", \"raj-IN\", \"ro-RO\", \"ru-RU\", \"ru-UA\",\n  \"rw-RW\", \"sa-IN\", \"sat-IN\", \"sc-IT\", \"sd-IN\", \"sd-PK\", \"se-NO\", \"sgs-LT\",\n  \"sh-HR\", \"shn-MM\", \"shs-CA\", \"si-LK\", \"sid-ET\", \"sk-SK\", \"sl-CS\", \"sl-SI\",\n  \"sm-WS\", \"so-DJ\", \"so-ET\", \"so-KE\", \"so-SO\", \"sq-AL\", \"sq-MK\", \"sr-CS\",\n  \"sr-ME\", \"sr-RS\", \"ss-ZA\", \"st-ZA\", \"sv-FI\", \"sv-SE\", \"sw-KE\", \"sw-TZ\",\n  \"szl-PL\", \"ta-IN\", \"ta-LK\", \"tcy-IN\", \"te-IN\", \"tg-TJ\", \"th-TH\", \"the-NP\",\n  \"ti-ER\", \"ti-ET\", \"tig-ER\", \"tk-TM\", \"tl-PH\", \"tn-ZA\", \"to-TO\", \"tpi-PG\",\n  \"tr-CY\", \"tr-TR\", \"ts-ZA\", \"tt-RU\", \"ug-CN\", \"uk-UA\", \"unm-US\", \"ur-IN\",\n  \"ur-PK\", \"uz-UZ\", \"ve-ZA\", \"vi-VN\", \"wa-BE\", \"wae-CH\", \"wal-ET\", \"wo-SN\",\n  \"xh-ZA\", \"yi-US\", \"yo-NG\", \"yue-HK\", \"yuw-PG\", \"zh-CN\", \"zh-HK\", \"zh-SG\",\n  \"zh-TW\", \"zu-ZA\"\n]);\n\n// Generate file content\nconst isSupported = (locale) => [inNode, inPython].every(set => set.has(locale));\nconst supportedList = localeCodes.filter(isSupported);\n// Convert to text, 7 codes per line\nconst supportedText = supportedList\n  .map(locale => `\"${locale}\"`)\n  .reduce((list, locale) => {\n    let line = list.pop() || [];\n    if (line.length > 6) {\n      list.push(line);\n      line = [];\n    }\n    line.push(locale);\n    list.push(line);\n    return list;\n  }, [])\n  .map(line => \"  \" + line.join(\", \"))\n  .join(\",\\n\");\n\nconst fileContent = `// This file was generated by core/buildtools/generate_locale_list.js at ${new Date().toISOString()}\nexport const localeCodes = [\n${supportedText}\n];\n`;\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nfs.writeFileSync(path.join(__dirname, \"../app/common/LocaleCodes.ts\"), fileContent);\n"
  },
  {
    "path": "buildtools/generate_translation_keys.js",
    "content": "/**\n * Generating translations keys:\n *\n * This code walk through all the files in client directory and its children\n * Get the all keys called by our makeT utils function\n * And add only the new one on our en.client.json file\n *\n */\n\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst Parser = require(\"i18next-scanner\").Parser;\nconst englishKeys = require(\"../static/locales/en.client.json\");\nconst _ = require(\"lodash\");\n\nconst parser = new Parser({\n  keySeparator: \"/\",\n  nsSeparator: null,\n});\n\nasync function* walk(dirs) {\n  for (const dir of dirs) {\n    for await (const d of await fs.promises.opendir(dir)) {\n      const entry = path.join(dir, d.name);\n      if (d.isDirectory()) yield* walk([entry]);\n      else if (d.isFile()) yield entry;\n    }\n  }\n}\n\nconst customHandler = (fileName) => (key, options) => {\n  const keyWithFile = `${fileName}/${key}`;\n  if (Object.keys(options).includes(\"count\") === true) {\n    const keyOne = `${keyWithFile}_one`;\n    const keyOther = `${keyWithFile}_other`;\n    parser.set(keyOne, key);\n    parser.set(keyOther, key);\n  } else {\n    parser.set(keyWithFile, key);\n  }\n};\n\nfunction sort(obj) {\n  if (typeof obj !== \"object\" || Array.isArray(obj))\n    return obj;\n  const sortedObject = {};\n  const keys = Object.keys(obj).sort();\n  keys.forEach(key => sortedObject[key] = sort(obj[key]));\n  return sortedObject;\n}\n\nconst getKeysFromFile = (filePath, fileName) => {\n  const content = fs.readFileSync(filePath, \"utf-8\");\n  parser.parseFuncFromString(\n    content,\n    {\n      list: [\n        \"i18next.t\",\n        \"t\", // To match the file-level t function created with makeT\n      ],\n    },\n    customHandler(fileName)\n  );\n  const keys = parser.get({ sort: true });\n  return keys;\n};\n\n// It is highly desirable to retain existing order, to not generate\n// unnecessary merges/conflicts, so we do a specialized merge.\nfunction merge(target, scanned, newKeys = []) {\n  let merges = 0;\n  for (const key of Object.keys(scanned)) {\n    if (!(key in target)) {\n      console.log(\"Merging key\", {key});\n      newKeys.push(key);\n      target[key] = scanned[key];\n      merges++;\n    } else if (typeof target[key] === \"object\") {\n      merges += merge(target[key], scanned[key], newKeys);\n    } else if (scanned[key] !== target[key]) {\n      if (!key.endsWith(\"_one\")) {\n        console.log(\"Value difference\", {key, value: target[key]});\n      }\n    }\n  }\n  return merges;\n}\n\n// Look for keys that are listed in json file but not found in source\n// code. These may be stale and need deleting in weblate.\nfunction reportUnrecognizedKeys(originalKeys, foundKeys) {\n  let unknowns = 0;\n  for (const section of Object.keys(originalKeys)) {\n    if (!(section in foundKeys)) {\n      console.log(\"Unknown section found\", {section});\n      unknowns++;\n    } else {\n      for (const key of Object.keys(originalKeys[section])) {\n        if (!(key in foundKeys[section])) {\n          console.log(\"Unknown key found\", {section, key});\n          unknowns++;\n        }\n      }\n    }\n  }\n  return unknowns;\n}\n\nasync function walkTranslation(dirs) {\n  const originalKeys = _.cloneDeep(englishKeys);\n  for await (const p of walk(dirs)) {\n    const { name } = path.parse(p);\n    if (p.endsWith(\".map\")) { continue; }\n    getKeysFromFile(p, name);\n  }\n  const keys = parser.get({ sort: true });\n  const foundKeys = _.cloneDeep(keys.en.translation);\n  const newKeys = [];\n  const mergeCount = merge(englishKeys, sort(keys.en.translation), newKeys);\n  await fs.promises.writeFile(\n    \"static/locales/en.client.json\",\n    JSON.stringify(englishKeys, null, 4) + \"\\n\",  // match weblate's default\n    \"utf-8\"\n  );\n  // Now, print a report of unrecognized keys - candidates\n  // for deletion in weblate.\n  const unknownCount = reportUnrecognizedKeys(originalKeys, foundKeys);\n  console.log(`Found ${unknownCount} unknown key(s).`);\n  console.log(`Make ${mergeCount} merge(s).`);\n  // Print a summary for use in PR descriptions.\n  if (newKeys.length > 0) {\n    console.log(\"TRANSLATION_SUMMARY_START\");\n    console.log(`Added ${newKeys.length} new translation key(s):\\n`);\n    for (const key of newKeys.slice(0, 20)) {\n      console.log(`- \\`${key}\\``);\n    }\n    if (newKeys.length > 20) {\n      console.log(`- ... and ${newKeys.length - 20} more`);\n    }\n    console.log(\"TRANSLATION_SUMMARY_END\");\n  }\n}\n\nwalkTranslation([\"_build/app/client\", ...process.argv.slice(2)]);\n"
  },
  {
    "path": "buildtools/install_chrome_for_tests.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nCHROME_VERSION=\"132.0.6834.110-1\"\n\nif [[ \"$1\" != \"-y\" ]]; then\n  echo \"Usage: $0 -y\"\n  echo \"Installs Google Chrome and chromedriver for running end-to-end Selenium tests in GitHub.\"\n  exit 1\nfi\nif [[ \"$(uname -s)\" != \"Linux\" ]]; then\n  echo \"Error: This script can only be run on Linux.\"\n  exit 1\nfi\nif [[ \"$(uname -m)\" != \"x86_64\" ]]; then\n  echo \"Error: This script can only be run on amd64 architecture.\"\n  exit 1\nfi\n\n# Google's official repo no longer keeps old versions, using UChicago mirror instead.\n# Original URL (may work again in future):\n#   https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb\ncurl -sS -o /tmp/chrome.deb https://mirror.cs.uchicago.edu/google-chrome/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb \\\n  && sudo apt-get install --allow-downgrades -y /tmp/chrome.deb \\\n  && rm /tmp/chrome.deb \\\n  && sudo rm /usr/bin/chromedriver \\\n  && node_modules/selenium-webdriver/bin/linux/selenium-manager --driver chromedriver\n"
  },
  {
    "path": "buildtools/prepare_ee.sh",
    "content": "#!/usr/bin/env bash\n\n# This checks out the Grist Labs extensions (grist-ee) into\n# the ext directory, and installs any extra node packages\n# they need in ../node_modules. If this directory doesn't\n# exist, the user is given a chance to abort (add -y to\n# force the action).\n\nset -e\n\nif [[ \"$1\" != \"-y\" && ! -e \"../node_modules\" ]]; then\n  echo \"+ This will place material in ../node_modules\"\n  echo \"+ Hit ^C to abort, or Enter to continue\"\n  read\nfi\n\nset -x  # Show commands\n./buildtools/checkout-ext-directory.sh grist-ee\nyarn install --cwd ext --modules-folder ../../node_modules/\n{ set +x; } 2>/dev/null  # Hide commands again\necho \"+ Updated ext and ../node_modules\"\n"
  },
  {
    "path": "buildtools/prepare_python.sh",
    "content": "#!/usr/bin/env bash\n\nset -eEu -o pipefail\n\n# Use a built-in standalone version of Python if available in a directory\n# called python. This is used for Electron packaging. The standalone Python\n# will have extra packages installed, and then be moved to a standard location\n# (sandbox_venv3).\nfor possible_path in python/bin/python python/bin/python3 \\\n                     python/Scripts/python.exe python/python.exe; do\n  if [[ -e $possible_path ]]; then\n    echo \"found $possible_path\"\n    if [[ -e sandbox_venv3 ]]; then\n     echo \"Have Python3 sandbox\"\n      exit 0\n    fi\n    echo \"Updating Python3 packages\"\n    $possible_path -m pip install --no-deps -r sandbox/requirements.txt\n    echo \"Moving ./python to sandbox_venv3\"\n    mv ./python sandbox_venv3\n    echo \"Python3 packages ready in sandbox_venv3\"\n    exit 0\n  fi\ndone\n\necho \"Use Python3 if available and recent enough\"\n! [ -x \"$(command -v python3)\" ] && echo \"Error: python3 must be installed\" && exit 1\n! python3 -c 'import sys; assert sys.version_info >= (3,9)' 2> /dev/null && echo \"Error: python must be >= 3.9\" && exit 1\n\n# Default to python3 if recent enough.\necho \"Making Python3 sandbox\"\npython3 -m venv sandbox_venv3\necho \"Updating Python3 packages\"\nsandbox_venv3/bin/pip install --no-deps -r sandbox/requirements.txt\necho \"Python3 packages ready in sandbox_venv3\"\nexit 0\n"
  },
  {
    "path": "buildtools/sanitize_translations.js",
    "content": "// This file should be run during build. It will go through all the translations in the static/locales\n// directory, and pass every key and value through the sanitizer.\n\nconst fs = require(\"fs\");\nconst path = require(\"path\");\n// Initialize purifier.\nconst createDOMPurify = require(\"dompurify\");\nconst { JSDOM } = require(\"jsdom\");\nconst window = new JSDOM(\"\").window;\nconst DOMPurify = createDOMPurify(window);\nDOMPurify.addHook(\"uponSanitizeAttribute\", handleSanitizeAttribute);\nfunction handleSanitizeAttribute(node) {\n  if (!(\"target\" in node)) { return; }\n  node.setAttribute(\"target\", \"_blank\");\n}\n\n// If the the first arg is test, do the self test.\nif (process.argv[2] === \"test\") {\n  selfTest();\n  process.exit(0);\n}\n\nconst directoryPath = readDirectoryPath();\n\nconst fileStream = fs.readdirSync(directoryPath)\n  .map((file) => path.join(directoryPath, file))\n// Make sure it's a file\n  .filter((file) => fs.lstatSync(file).isFile())\n// Make sure it is json file\n  .filter((file) => file.endsWith(\".json\"))\n// Read the contents and put it into an array [path, json]\n  .map((file) => [file, JSON.parse(fs.readFileSync(file, \"utf8\"))]);\n\nconst sanitized = fileStream.map(([file, json]) => {\n  return [file, json, invalidValues(json)];\n});\n\nconst onlyDifferent = sanitized.filter(([file, json, invalidKeys]) => {\n  return invalidKeys.length > 0;\n});\n\nif (onlyDifferent.length > 0) {\n  console.error(\"The following files contain invalid values:\");\n  onlyDifferent.forEach(([file, json, invalidKeys]) => {\n    console.error(`File: ${file}`);\n    console.error(`Values: ${invalidKeys.join(\", \")}`);\n  });\n  process.exit(1);\n}\n\nfunction invalidValues(json) {\n  // This is recursive function as some keys can be objects themselves, but all values are either\n  // strings or objects.\n  return Object.values(json).reduce((acc, value) => {\n    if (typeof value === \"string\") {\n      const sanitized = purify(value);\n      if (value !== sanitized) {\n        acc.push(value);\n      }\n    } else if (typeof value === \"object\") {\n      acc.push(...invalidValues(value));\n    }\n    return acc;\n  }, []);\n}\n\n\nfunction readDirectoryPath() {\n  // Directory path is optional, it defaults to static/locales, but can be passed as an argument.\n  const args = process.argv.slice(2);\n  if (args.length > 1) {\n    console.error(\"Too many arguments, expected at most 1 argument.\");\n    process.exit(1);\n  }\n  return args[0] || path.join(__dirname, \"../static/locales\");\n}\n\nfunction purify(inputString) {\n  // This removes any html tags from the string\n  return DOMPurify.sanitize(inputString, { ALLOWED_TAGS: [] });\n}\n\nfunction selfTest() {\n  const okDir = createTmpDir();\n  const okFile = path.join(okDir, \"ok.json\");\n  fs.writeFileSync(okFile, JSON.stringify({ \"key\": \"value\" }));\n\n  const badDir = createTmpDir();\n  const badFile = path.join(badDir, \"bad.json\");\n  fs.writeFileSync(badFile, JSON.stringify({ \"key\": \"<script>alert('xss')</script>\" }));\n\n  // Run this script in the okDir, it should pass (return value 0)\n  const okResult = exitCode(`node ${__filename} ${okDir}`);\n  if (okResult !== 0) {\n    console.error(\"Self test failed, expected 0 for okDir\");\n    process.exit(1);\n  }\n\n  // Run this script in the badDir, it should fail (return value 1)\n  const badResult = exitCode(`node ${__filename} ${badDir}`);\n  if (badResult !== 1) {\n    console.error(\"Self test failed, expected 1 for badDir\");\n    process.exit(1);\n  }\n\n  console.log(\"Self test passed\");\n\n  function createTmpDir() {\n    const os = require(\"os\");\n    const tmpDir = os.tmpdir();\n    const prefix = path.join(tmpDir, \"tmp-folder-\");\n    const tmpFolderPath = fs.mkdtempSync(prefix);\n    return tmpFolderPath;\n  }\n\n  function exitCode(args) {\n    const {execSync} = require(\"child_process\");\n    try {\n      execSync(args); // will throw if exit code is not 0\n      return 0;\n    } catch (e) {\n      return 1;\n    }\n  }\n}"
  },
  {
    "path": "buildtools/tsconfig-base-ext.json",
    "content": "{\n  \"extends\": \"./tsconfig-base.json\",\n  \"compilerOptions\": {\n    \"paths\": {\n      \"*\": [\n        \"*\",\n        \"ext/*\",\n        \"stubs/*\"\n      ],\n    }\n  }\n}\n"
  },
  {
    "path": "buildtools/tsconfig-base.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es2017\",\n    \"module\": \"commonjs\",\n    \"allowSyntheticDefaultImports\": true,\n    \"esModuleInterop\": true,\n    \"isolatedModules\": true,\n    \"strict\": true,\n    \"strictPropertyInitialization\": false,\n    \"useUnknownInCatchVariables\": false,\n    \"skipLibCheck\": true,\n    \"sourceMap\": true,\n    \"noImplicitAny\": true,\n    \"noUnusedLocals\": true,\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"allowJs\": true,\n    \"baseUrl\": \"..\",\n    \"rootDir\": \"..\",\n    \"outDir\": \"../_build\",\n    \"paths\": {\n      \"*\": [\n        \"*\",\n        \"grist-core/*\",\n        \"stubs/*\",\n        \"ext/*\"\n      ],\n    },\n    \"composite\": true,\n    \"types\" : [],\n    \"plugins\": [{\n      \"name\": \"typescript-eslint-language-service\"\n    }],\n    \"emitDecoratorMetadata\": true,\n    \"experimentalDecorators\": true,\n    \"jsx\": \"react\"\n  }\n}\n"
  },
  {
    "path": "buildtools/update_schema.sh",
    "content": "#!/usr/bin/env bash\n\n# Regenerates typescript files with schema and sql for grist documents.\n# This needs to run whenever the document schema is changed in the data\n# engine, maintained in python code. It propagates the schema information\n# to a typescript file, and updates SQL code for initializing new documents.\n#\n# To preview what it will do, call as:\n#   buildtools/update_schema.sh schema.ts sql.ts\n# This will put schema.ts and sql.ts files in your working directory.\n# Run without any arguments to modify application files.\n#   buildtools/update_schema.sh\n# (you can see the differences with git diff if in a git repository).\n\nset -e\n\nschema_ts=$1\nsql_ts=$2\nif [[ -z \"$schema_ts\" ]]; then\n  # Default to regenerating regular suspects.\n  schema_ts=app/common/schema.ts\n  sql_ts=app/server/lib/initialDocSql.ts\nfi\nif [[ -z \"$sql_ts\" ]]; then\n  echo \"Need both a schema and sql target\"\n  exit 1\nfi\n\n# Prepare new version of schema file.\n# Define custom python path locally, do not let it bleed over to node, since it\n# could interfere with sandbox operation.\nif [[ -e sandbox_venv3/bin/python ]]; then\n  # Use our virtual env if available.\n  PYTHON=sandbox_venv3/bin/python\nelse\n  # Fall back on system.\n  PYTHON=python\nfi\nPYTHONPATH=sandbox/grist:sandbox/thirdparty $PYTHON -B sandbox/gen_js_schema.py > $schema_ts.tmp\n\n# Prepare new version of sql file.\nexport NODE_PATH=_build:_build/core:_build/ext:_build/stubs\nBUILD=$(test -e _build/core && echo \"_build/core\" || echo \"_build\")\nnode $BUILD/app/server/generateInitialDocSql.js $sql_ts.tmpdoc > $sql_ts.tmp\n\nrm $sql_ts.tmpdoc.grist\nmv $schema_ts.tmp $schema_ts\nmv $sql_ts.tmp $sql_ts\n"
  },
  {
    "path": "buildtools/update_type_info.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\n# updates any Foo*-ti.ts files $root that are older than Foo.ts\n\nforce=false\nif [[ \"$1\" == \"--force\" ]]; then\n  force=true\n  shift\nfi\n\nroot=$1\nif [[ -z \"$root\" ]]; then\n  echo \"Usage: $0 [--force] app\"\n  exit 1\nfi\n\nfor root in \"$@\"; do\n  for ti in $(find $root/ -iname \"*-ti.ts\"); do\n    root=$(basename $ti -ti.ts)\n    dir=$(dirname $ti)\n    src=\"$dir/$root.ts\"\n    # Check if source file exists\n    if [ ! -e $src ]; then\n      echo \"Cannot find src $src for $ti, aborting\"\n      exit 1\n    fi\n    # Check if source file is newer than the type info file or force flag is set\n    if [ \"$force\" = true ] || [ $src -nt $ti ]; then\n      echo \"Updating $ti from $src\"\n      node_modules/.bin/ts-interface-builder $src\n    fi\n  done\ndone\n"
  },
  {
    "path": "buildtools/webpack.api.config.js",
    "content": "const path = require(\"path\");\n\n// Get path to top-level node_modules if in a yarn workspace.\n// Otherwise node_modules one level up won't get resolved.\n// This is used in Electron packaging.\nconst base = path.dirname(path.dirname(require.resolve(\"grainjs/package.json\")));\n\nmodule.exports = {\n  target: \"web\",\n  entry: {\n    \"grist-plugin-api\": \"app/plugin/grist-plugin-api\",\n  },\n  output: {\n    sourceMapFilename: \"[file].map\",\n    path: path.resolve(\"./static\"),\n    library: \"grist\"\n  },\n  devtool: \"source-map\",\n  node: false,\n  resolve: {\n    extensions: [\".ts\", \".js\"],\n    modules: [\n      path.resolve(\".\"),\n      path.resolve(\"./ext\"),\n      path.resolve(\"./stubs\"),\n      path.resolve(\"./node_modules\"),\n      base,\n    ],\n    fallback: {\n      \"path\": require.resolve(\"path-browserify\"),\n    },\n  },\n  optimization: {\n    minimize: false, // keep class names in code\n  },\n  module: {\n    rules: [\n      {\n        test: /\\.(js|ts)?$/,\n        loader: \"esbuild-loader\",\n        options: {\n          target: \"es2017\",\n          sourcemap: true,\n        },\n        exclude: /node_modules/\n      },\n    ]\n  }\n};\n"
  },
  {
    "path": "buildtools/webpack.check.js",
    "content": "import path from \"path\";\n\nexport default {\n  target: \"web\",\n  mode: \"production\",\n  entry: \"./app/client/browserCheck\",\n  output: {\n    path: path.resolve(\"./static\"),\n    filename: \"browser-check.js\"\n  },\n  resolve: {\n    extensions: [\".ts\", \".js\"],\n  },\n  module: {\n    rules: [\n      {\n        test: /\\.(js|ts)?$/,\n        loader: \"esbuild-loader\",\n        options: {\n          target: \"es2017\",\n          sourcemap: true,\n        },\n        exclude: /node_modules/\n      },\n    ]\n  }\n};\n"
  },
  {
    "path": "buildtools/webpack.config.js",
    "content": "const fs = require(\"fs\");\nconst MomentLocalesPlugin = require(\"moment-locales-webpack-plugin\");\nconst { ProvidePlugin } = require(\"webpack\");\nconst path = require(\"path\");\n\n// Get path to top-level node_modules if in a yarn workspace.\n// Otherwise node_modules one level up won't get resolved.\n// This is used in Electron packaging.\nconst base = path.dirname(path.dirname(require.resolve(\"grainjs/package.json\")));\n\nconst extraModulePaths = (process.env.WEBPACK_EXTRA_MODULE_PATHS || \"\")\n  .split(path.delimiter).filter(Boolean);\n\nif (process.env.WEBPACK_EXTRA_MODULE_PATHS === undefined &&\n    fs.existsSync(\"ext\")) {\n  console.warn(\"Including ../node_modules because ext is present\");\n  // When adding extensions to Grist, the node packages are\n  // placed one directory up from the main repository. This\n  // is annoying, but goes with the grain of how node looks\n  // for node_modules directories. But webpack doesn't match\n  // that, so add `../node_modules` if the `ext` exists and\n  // the user isn't controlling module paths.\n  extraModulePaths.push(path.resolve(\"../node_modules\"));\n}\n\nmodule.exports = {\n  target: \"web\",\n  entry: {\n    main: \"app/client/app\",\n    errorPages: \"app/client/errorMain\",\n    apiconsole: \"app/client/apiconsole\",\n    billing: \"app/client/billingMain\",\n    form: \"app/client/formMain\",\n    // Include client test harness if it is present (it won't be in\n    // docker image).\n    ...(fs.existsSync(\"test/client-harness/client.js\") ? {\n      test: \"test/client-harness/client\",\n    } : {}),\n  },\n  output: {\n    filename: \"[name].bundle.js\",\n    sourceMapFilename: \"[file].map\",\n    path: path.resolve(\"./static\"),\n    // Workaround for a known issue with webpack + onerror under chrome, see:\n    //   https://github.com/webpack/webpack/issues/5681\n    // \"We use a source map plugin here with this special configuration\n    // because if we do not - the window.onerror function does not work properly in chrome\n    // and it swallows the errors because normally source maps have begin with webpack:///\n    // here we are changing how the module file names are created\n    // See this bug\n    // https://bugs.chromium.org/p/chromium/issues/detail?id=765909\n    //  See this for syntax\n    // https://webpack.js.org/configuration/output/#output-devtoolmodulefilenametemplate\n    // \"\n    devtoolModuleFilenameTemplate: \"[resourcePath]?[loaders]\",\n    crossOriginLoading: \"anonymous\",\n  },\n  // This creates .map files, and takes webpack a couple of seconds to rebuild while developing,\n  // but provides correct mapping back to typescript, and allows breakpoints to be set in\n  // typescript (\"cheap-module-eval-source-map\" is faster, but breakpoints are largely broken).\n  devtool: \"source-map\",\n  resolve: {\n    extensions: [\".ts\", \".js\"],\n    modules: [\n      path.resolve(\".\"),\n      path.resolve(\"./ext\"),\n      path.resolve(\"./stubs\"),\n      path.resolve(\"./node_modules\"),\n      base,\n      ...extraModulePaths,\n    ],\n    fallback: {\n      \"path\": require.resolve(\"path-browserify\"),\n      \"process\": require.resolve(\"process/browser\"),\n    },\n  },\n  module: {\n    rules: [\n      {\n        test: /\\.(js|ts)?$/,\n        loader: \"esbuild-loader\",\n        options: {\n          target: \"es2017\",\n          sourcemap: true,\n        },\n        exclude: /node_modules/\n      },\n      { test: /\\.js$/,\n        use: [\"source-map-loader\"],\n        enforce: \"pre\"\n      },\n      {\n        test: /\\.css$/,\n        type: \"asset/resource\"\n      }\n    ]\n  },\n  plugins: [\n    // Some modules assume presence of Buffer and process.\n    new ProvidePlugin({\n      process: \"process\",\n      Buffer: [\"buffer\", \"Buffer\"]\n    }),\n    // To strip all locales except “en”\n    new MomentLocalesPlugin()\n  ],\n  externals: {\n    // for test bundle: jsdom should not be touched within browser\n    jsdom: \"alert\",\n    // for test bundle: jquery will be available as jQuery\n    jquery: \"jQuery\"\n  },\n};\n"
  },
  {
    "path": "crowdin.yml",
    "content": "files:\n  - source: /static/locales/en.*.json\n    translation: /static/locales/%two_letters_code%.%original_file_name%\n    translation_replace:\n      \"en.\": ''\n"
  },
  {
    "path": "docker-compose-examples/grist-local-testing/README.md",
    "content": "This is the simplest example that runs Grist, suitable for local testing.\n\nIt is STRONGLY RECOMMENDED not to use this container in a way that makes it accessible to the internet.\nThis setup lacks basic security or authentication.\n\nOther examples demonstrate how to set up authentication and HTTPS.\n\nSee https://support.getgrist.com/self-managed for more information.\n\n## How to run this example\n\nTo run this example, change to the directory containing this example, and run:\n```sh\ndocker compose up\n```\nThen you should be able to visit your local Grist instance at <http://localhost:8484>.\n\nThis will start an instance that stores its documents and files in the `persist/` subdirectory.\nYou can change this location using the `PERSIST_DIR` environment variable.\n"
  },
  {
    "path": "docker-compose-examples/grist-local-testing/docker-compose.yml",
    "content": "services:\n  grist:\n    image: gristlabs/grist:latest\n    volumes:\n      # Where to store persistent data, such as documents.\n      - ${PERSIST_DIR:-./persist}/grist:/persist\n    ports:\n      - 8484:8484\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-basic-auth/README.md",
    "content": "This is the simplest example of Grist with authentication and HTTPS encryption.\n\nIt uses Traefik as:\n- A reverse proxy to manage certificates and provide HTTPS support\n- A basic authentication provided using Traefik's Basic Auth middleware.\n\nThis setup, after configuring HTTPS certificates correctly, should be acceptable on the public internet.\n\nHowever, it doesn't allow a user to sign-out due to the way browsers handle basic authentication.\n\nYou may want to try a more secure authentication setup such Authelia, Authentik or traefik-forward-auth.\nThe OIDC auth example demonstrates a setup using Authelia.\n\nSee https://support.getgrist.com/self-managed for more information.\n\n## How to run this example\n\nThis example can be run with `docker compose up`.\n\nThe default login is:\n- Username: `test@example.org`\n- Password: `test`\n\nThis can be changed in `./configs/traefik-dynamic-config.yaml`. Instructions on how to do this are available in that file.\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-basic-auth/configs/traefik-config.yml",
    "content": "providers:\n  # Enables reading docker label config values\n  docker: {}\n  # Read additional config from this file.\n  file:\n    directory: \"/etc/traefik/dynamic\"\n\nentrypoints:\n  # Defines a secure entrypoint using TLS encryption\n  websecure:\n    address: \":443\"\n    http:\n      tls: true\n  # Defines an insecure entrypoint that redirects to the secure one.\n  web:\n    address: \":80\"\n    http:\n      # Redirects HTTP to HTTPS\n      redirections:\n        entrypoint:\n          to: \"websecure\"\n          scheme: \"https\"\n\n# Enables automatic certificate renewal\ncertificatesResolvers:\n  letsencrypt:\n    acme:\n      email: \"my_email@example.com\"\n      storage: /acme/acme.json\n      tlschallenge: true\n\n# Enables the web UI\n# This is disabled by default for security, but can be useful to debugging traefik.\napi:\n  # insecure: true\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-basic-auth/configs/traefik-dynamic-config.yml",
    "content": "http:\n  # Declaring the user list\n  middlewares:\n    grist-basic-auth:\n      basicAuth:\n        # The header that Grist will listen for authenticated usernames on.\n        headerField: \"X-Forwarded-User\"\n        # This is the list of users, in the format username:password.\n        # Passwords can be created using `htpasswd`\n        # E.g: `htpasswd -nB test@example.org`\n        users:\n          # The default username is \"test@example.org\". The default password is \"test\".\n          - \"test@example.org:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/\"\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-basic-auth/docker-compose.yml",
    "content": "services:\n  grist:\n    image: gristlabs/grist:latest\n    environment:\n      # Sets the header to look at for authentication\n      GRIST_FORWARD_AUTH_HEADER: X-Forwarded-User\n      # Forces Grist to only use a single team called 'Example'\n      GRIST_SINGLE_ORG: my-grist-team   # alternatively, GRIST_ORG_IN_PATH: \"true\" for multi-team operation\n      # Force users to login (disable anonymous access)\n      GRIST_FORCE_LOGIN: true\n      # Base URL Grist redirects to when navigating. Change this to your domain.\n      APP_HOME_URL: https://grist.localhost\n      # Default email for the \"Admin\" account\n      GRIST_DEFAULT_EMAIL: test@example.org\n    volumes:\n      # Where to store persistent data, such as documents.\n      - ${PERSIST_DIR}/grist:/persist\n    labels:\n      - \"traefik.http.services.grist.loadbalancer.server.port=8484\"\n      - \"traefik.http.routers.grist.rule=Host(`grist.localhost`)\"\n      - \"traefik.http.routers.grist.tls.certresolver=letsencrypt\"\n      - \"traefik.http.routers.grist-auth.rule=Host(`grist.localhost`) && (PathPrefix(`/auth/login`) || PathPrefix(`/_oauth`))\"\n      - \"traefik.http.routers.grist-auth.middlewares=grist-basic-auth@file\"\n      - \"traefik.http.routers.grist-auth.tls.certresolver=letsencrypt\"\n\n  traefik:\n    image: traefik:latest\n    ports:\n      # HTTP Ports\n      - \"80:80\"\n      - \"443:443\"\n      # The Web UI (enabled by --api.insecure=true)\n      # - \"8080:8080\"\n    volumes:\n      # Set the config file for traefik - this is loaded automatically.\n      - ./configs/traefik-config.yml:/etc/traefik/traefik.yml\n      # Set the config file for the dynamic config, such as middleware.\n      - ./configs/traefik-dynamic-config.yml:/etc/traefik/dynamic/dynamic-config.yml\n      # Certificate location, if automatic certificate setup is enabled.\n      - ./configs/acme:/acme\n      # Traefik needs docker access when configured via docker labels.\n      - /var/run/docker.sock:/var/run/docker.sock\n    depends_on:\n      - grist\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/README.md",
    "content": "This is an example of Grist with Authelia for OIDC authentication, and Traefik for HTTP encryption and routing.\n\nOIDC enables authentication using many existing providers, including Google, Microsoft, Amazon and Okta.\n\nThis example uses Authelia, which is a locally hosted OIDC provider, so that it can work without further setup. \nHowever, Authelia could be easily replaced by one of the providers listed above, or other self-hosted alternatives,\nsuch as Authentik or Dex.\n\nThis example could be hosted on a dedicated server, with the following changes:\n- DNS setup\n- HTTPS / Certificate setup (e.g Let's encrypt)\n\nSee https://support.getgrist.com/install/oidc for more information on using Grist with OIDC.\n\n## How to run this example\n\nTo run this example, you'll first need to generate several secrets needed by Authelia.\n\nThis is automated for you in `generateSecureSecrets.sh`, which uses Authelia's docker image to populate the `./secrets` directory.\n\nThis 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.\n\nThe self-signed certificate will cause a security warning in the web browser when you try to visit Grist.\nThis is fine for local testing and can be bypassed, but correct certificates should be set up if Grist is being made\navailable on the internet.\n\n### Users\n\nThe default username is `test`, with password `test`.\n\nYou can add or modify users in ./configs/authelia/user-database.yml. Additional instructions are provided in that file.\n\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/configs/authelia/configuration.yml",
    "content": "# yamllint disable rule:comments-indentation\n\n###############################################################################\n##         Original configuration file available at this URL:                ##\n##   https://github.com/authelia/authelia/blob/master/config.template.yml    ##\n##                                                                           ##\n##     This file is an edited version to support Grist as an OIDC client     ##\n###############################################################################\n\n#------------------------------------------------------------------------------\n\n###############################################################################\n##                           Authelia Configuration                          ##\n###############################################################################\n\n##\n## Notes:\n##\n##    - the default location of this file is assumed to be configuration.yml unless otherwise noted\n##    - when using docker the container expects this by default to be at /config/configuration.yml\n##    - the default location where this file is loaded from can be overridden with the X_AUTHELIA_CONFIG environment var\n##    - the comments in this configuration file are helpful but users should consult the official documentation on the\n##      website at https://www.authelia.com/ or https://www.authelia.com/configuration/prologue/introduction/\n##    - this configuration file template is not automatically updated\n##\n\n## Certificates directory specifies where Authelia will load trusted certificates (public portion) from in addition to\n## the system certificates store.\n## They should be in base64 format, and have one of the following extensions: *.cer, *.crt, *.pem.\n# certificates_directory: '/config/certificates/'\n\n## The theme to display: light, dark, grey, auto.\n# theme: 'light'\n\n## Set the default 2FA method for new users and for when a user has a preferred method configured that has been\n## disabled. This setting must be a method that is enabled.\n## Options are totp, webauthn, mobile_push.\n# default_2fa_method: ''\n\n##\n## Server Configuration\n##\nserver:\n  ## The address for the Main server to listen on in the address common syntax.\n  ## Formats:\n  ##  - [<scheme>://]<hostname>[:<port>][/<path>]\n  ##  - [<scheme>://][hostname]:<port>[/<path>]\n  ## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', or 'unix'.\n  ## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '9091'.\n  ## If the path is specified this configures the router to handle both the `/` path and the configured path.\n  address: 'tcp://:9091/'\n\n  ## Set the path on disk to Authelia assets.\n  ## Useful to allow overriding of specific static assets.\n  # asset_path: '/config/assets/'\n\n  ## Disables writing the health check vars to /app/.healthcheck.env which makes healthcheck.sh return exit code 0.\n  ## This is disabled by default if either /app/.healthcheck.env or /app/healthcheck.sh do not exist.\n  # disable_healthcheck: false\n\n  ## Authelia by default doesn't accept TLS communication on the server port. This section overrides this behaviour.\n  # tls:\n    ## The path to the DER base64/PEM format private key.\n    # key: ''\n\n    ## The path to the DER base64/PEM format public certificate.\n    # certificate: ''\n\n    ## The list of certificates for client authentication.\n    # client_certificates: []\n\n  ## Server headers configuration/customization.\n  # headers:\n\n    ## The CSP Template. Read the docs.\n    # csp_template: ''\n\n  ## Server Buffers configuration.\n  # buffers:\n\n    ## Buffers usually should be configured to be the same value.\n    ## Explanation at https://www.authelia.com/c/server#buffer-sizes\n    ## Read buffer size adjusts the server's max incoming request size in bytes.\n    ## Write buffer size does the same for outgoing responses.\n\n    ## Read buffer.\n    # read: 4096\n\n    ## Write buffer.\n    # write: 4096\n\n  ## Server Timeouts configuration.\n  # timeouts:\n\n    ## Read timeout in the duration common syntax.\n    # read: '6 seconds'\n\n    ## Write timeout in the duration common syntax.\n    # write: '6 seconds'\n\n    ## Idle timeout in the duration common syntax.\n    # idle: '30 seconds'\n\n  ## Server Endpoints configuration.\n  ## This section is considered advanced and it SHOULD NOT be configured unless you've read the relevant documentation.\n  endpoints:\n    ## Enables the pprof endpoint.\n    # enable_pprof: false\n\n    ## Enables the expvars endpoint.\n    # enable_expvars: false\n\n    ## Configure the authz endpoints.\n    authz:\n      forward-auth:\n        implementation: 'ForwardAuth'\n        authn_strategies: []\n      # ext-authz:\n        # implementation: 'ExtAuthz'\n        # authn_strategies: []\n      # auth-request:\n        # implementation: 'AuthRequest'\n        # authn_strategies: []\n      # legacy:\n        # implementation: 'Legacy'\n        # authn_strategies: []\n\n##\n## Log Configuration\n##\nlog:\n  ## Level of verbosity for logs: info, debug, trace.\n  level: 'debug'\n\n  ## Format the logs are written as: json, text.\n  # format: 'json'\n\n  ## File path where the logs will be written. If not set logs are written to stdout.\n  # file_path: '/config/authelia.log'\n\n  ## Whether to also log to stdout when a log_file_path is defined.\n  # keep_stdout: false\n\n##\n## Telemetry Configuration\n##\ntelemetry:\n\n  ##\n  ## Metrics Configuration\n  ##\n  metrics:\n    ## Enable Metrics.\n    enabled: false\n\n    ## The address for the Metrics server to listen on in the address common syntax.\n    ## Formats:\n    ##  - [<scheme>://]<hostname>[:<port>][/<path>]\n    ##  - [<scheme>://][hostname]:<port>[/<path>]\n    ## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', or 'unix'.\n    ## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '9959'.\n    ## If the path is not specified it defaults to `/metrics`.\n    # address: 'tcp://:9959/metrics'\n\n    ## Metrics Server Buffers configuration.\n    # buffers:\n\n      ## Read buffer.\n      # read: 4096\n\n      ## Write buffer.\n      # write: 4096\n\n    ## Metrics Server Timeouts configuration.\n    # timeouts:\n\n      ## Read timeout in the duration common syntax.\n      # read: '6 seconds'\n\n      ## Write timeout in the duration common syntax.\n      # write: '6 seconds'\n\n      ## Idle timeout in the duration common syntax.\n      # idle: '30 seconds'\n\n##\n## TOTP Configuration\n##\n## Parameters used for TOTP generation.\ntotp:\n  ## Disable TOTP.\n  disable: false\n\n  ## The issuer name displayed in the Authenticator application of your choice.\n  # issuer: 'authelia.com'\n\n  ## The TOTP algorithm to use.\n  ## It is CRITICAL you read the documentation before changing this option:\n  ## https://www.authelia.com/c/totp#algorithm\n  # algorithm: 'SHA1'\n\n  ## The number of digits a user has to input. Must either be 6 or 8.\n  ## Changing this option only affects newly generated TOTP configurations.\n  ## It is CRITICAL you read the documentation before changing this option:\n  ## https://www.authelia.com/c/totp#digits\n  # digits: 6\n\n  ## The period in seconds a Time-based One-Time Password is valid for.\n  ## Changing this option only affects newly generated TOTP configurations.\n  # period: 30\n\n  ## The skew controls number of Time-based One-Time Passwords either side of the current one that are valid.\n  ## Warning: before changing skew read the docs link below.\n  # skew: 1\n  ## See: https://www.authelia.com/c/totp#input-validation to read\n  ## the documentation.\n\n  ## The size of the generated shared secrets. Default is 32 and is sufficient in most use cases, minimum is 20.\n  # secret_size: 32\n\n  ## The allowed algorithms for a user to pick from.\n  # allowed_algorithms:\n  # - 'SHA1'\n\n  ## The allowed digits for a user to pick from.\n  # allowed_digits:\n  # - 6\n\n  ## The allowed periods for a user to pick from.\n  # allowed_periods:\n  # - 30\n\n  ## Disable the reuse security policy which prevents replays of one-time password code values.\n  # disable_reuse_security_policy: false\n\n##\n## WebAuthn Configuration\n##\n## Parameters used for WebAuthn.\nwebauthn:\n  ## Disable WebAuthn.\n  disable: false\n\n  ## The interaction timeout for WebAuthn dialogues in the duration common syntax.\n  # timeout: '60 seconds'\n\n  ## The display name the browser should show the user for when using WebAuthn to login/register.\n  # display_name: 'Authelia'\n\n  ## Conveyance preference controls if we collect the attestation statement including the AAGUID from the device.\n  ## Options are none, indirect, direct.\n  # attestation_conveyance_preference: 'indirect'\n\n  ## User verification controls if the user must make a gesture or action to confirm they are present.\n  ## Options are required, preferred, discouraged.\n  # user_verification: 'preferred'\n\n##\n## Duo Push API Configuration\n##\n## Parameters used to contact the Duo API. Those are generated when you protect an application of type\n## \"Partner Auth API\" in the management panel.\n# duo_api:\n  # disable: false\n  # hostname: 'api-123456789.example.com'\n  # integration_key: 'ABCDEF'\n  ## Secret can also be set using a secret: https://www.authelia.com/c/secrets\n  # secret_key: '1234567890abcdefghifjkl'\n  # enable_self_enrollment: false\n\n##\n## Identity Validation Configuration\n##\n## This configuration tunes the identity validation flows.\nidentity_validation:\n\n  ## Reset Password flow. Adjusts how the reset password flow operates.\n  reset_password:\n    ## Maximum allowed time before the JWT is generated and when the user uses it in the duration common syntax.\n    # jwt_lifespan: '5 minutes'\n\n    ## The algorithm used for the Reset Password JWT.\n    # jwt_algorithm: 'HS256'\n\n    ## The secret key used to sign and verify the JWT.\n    # jwt_secret: 'a_very_important_secret'\n\n  ## Elevated Session flows. Adjusts the flow which require elevated sessions for example managing credentials, adding,\n  ## removing, etc.\n  # elevated_session:\n    ## Maximum allowed lifetime after the One-Time Code is generated that it is considered valid.\n    # code_lifespan: '5 minutes'\n\n    ## Maximum allowed lifetime after the user uses the One-Time Code and the user must perform the validation again in\n    ## the duration common syntax.\n    # elevation_lifespan: '10 minutes'\n\n    ## Number of characters the one-time password contains.\n    # characters: 8\n\n    ## In addition to the One-Time Code requires the user performs a second factor authentication.\n    # require_second_factor: false\n\n    ## Skips the elevation requirement and entry of the One-Time Code if the user has performed second factor\n    ## authentication.\n    # skip_second_factor: false\n\n##\n## NTP Configuration\n##\n## This is used to validate the servers time is accurate enough to validate TOTP.\n# ntp:\n  ## The address of the NTP server to connect to in the address common syntax.\n  ## Format: [<scheme>://]<hostname>[:<port>].\n  ## Square brackets indicate optional portions of the format. Scheme must be 'udp', 'udp4', or 'udp6'.\n  ## The default scheme is 'udp'. The default port is '123'.\n  # address: 'udp://time.cloudflare.com:123'\n\n  ## NTP version.\n  # version: 4\n\n  ## Maximum allowed time offset between the host and the NTP server in the duration common syntax.\n  # max_desync: '3 seconds'\n\n  ## Disables the NTP check on startup entirely. This means Authelia will not contact a remote service at all if you\n  ## set this to true, and can operate in a truly offline mode.\n  # disable_startup_check: false\n\n  ## The default of false will prevent startup only if we can contact the NTP server and the time is out of sync with\n  ## the NTP server more than the configured max_desync. If you set this to true, an error will be logged but startup\n  ## will continue regardless of results.\n  # disable_failure: false\n\n##\n## Authentication Backend Provider Configuration\n##\n## Used for verifying user passwords and retrieve information such as email address and groups users belong to.\n##\n## The available providers are: `file`, `ldap`. You must use only one of these providers.\nauthentication_backend:\n  ## Password Reset Options.\n  password_reset:\n    ## Disable both the HTML element and the API for reset password functionality.\n    disable: false\n\n    ## External reset password url that redirects the user to an external reset portal. This disables the internal reset\n    ## functionality.\n    # custom_url: ''\n\n  ## The amount of time to wait before we refresh data from the authentication backend in the duration common syntax.\n  ## To disable this feature set it to 'disable', this will slightly reduce security because for Authelia, users will\n  ## always belong to groups they belonged to at the time of login even if they have been removed from them in LDAP.\n  ## To force update on every request you can set this to '0' or 'always', this will increase processor demand.\n  ## See the below documentation for more information.\n  ## Refresh Interval docs: https://www.authelia.com/c/1fa#refresh-interval\n  # refresh_interval: '5 minutes'\n\n  ##\n  ## LDAP (Authentication Provider)\n  ##\n  ## This is the recommended Authentication Provider in production\n  ## because it allows Authelia to offload the stateful operations\n  ## onto the LDAP service.\n  # ldap:\n    ## The address of the directory server to connect to in the address common syntax.\n    ## Format: [<scheme>://]<hostname>[:<port>].\n    ## Square brackets indicate optional portions of the format. Scheme must be 'ldap', 'ldaps', or 'ldapi`.\n    ## The default scheme is 'ldapi' if the address is an absolute path otherwise it's 'ldaps'.\n    ## The default port is '636', unless the scheme is 'ldap' in which case it's '389'.\n    # address: 'ldaps://127.0.0.1:636'\n\n    ## The LDAP implementation, this affects elements like the attribute utilised for resetting a password.\n    ## Acceptable options are as follows:\n    ## - 'activedirectory' - for Microsoft Active Directory.\n    ## - 'freeipa' - for FreeIPA.\n    ## - 'lldap' - for lldap.\n    ## - 'custom' - for custom specifications of attributes and filters.\n    ## This currently defaults to 'custom' to maintain existing behaviour.\n    ##\n    ## Depending on the option here certain other values in this section have a default value, notably all of the\n    ## attribute mappings have a default value that this config overrides, you can read more about these default values\n    ## at https://www.authelia.com/c/ldap#defaults\n    # implementation: 'custom'\n\n    ## The dial timeout for LDAP in the duration common syntax.\n    # timeout: '5 seconds'\n\n    ## Use StartTLS with the LDAP connection.\n    # start_tls: false\n\n    # tls:\n      ## The server subject name to check the servers certificate against during the validation process.\n      ## This option is not required if the certificate has a SAN which matches the address options hostname.\n      # server_name: 'ldap.example.com'\n\n      ## Skip verifying the server certificate entirely. In preference to setting this we strongly recommend you add the\n      ## certificate or the certificate of the authority signing the certificate to the certificates directory which is\n      ## defined by the `certificates_directory` option at the top of the configuration.\n      ## It's important to note the public key should be added to the directory, not the private key.\n      ## This option is strongly discouraged but may be useful in some self-signed situations where validation is not\n      ## important to the administrator.\n      # skip_verify: false\n\n      ## Minimum TLS version for the connection.\n      # minimum_version: 'TLS1.2'\n\n      ## Maximum TLS version for the connection.\n      # maximum_version: 'TLS1.3'\n\n      ## The certificate chain used with the private_key if the server requests TLS Client Authentication\n      ## i.e. Mutual TLS.\n      # certificate_chain: |\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n\n      ## The private key used with the certificate_chain if the server requests TLS Client Authentication\n      ## i.e. Mutual TLS.\n      # private_key: |\n        # -----BEGIN RSA PRIVATE KEY-----\n        # ...\n        # -----END RSA PRIVATE KEY-----\n\n    ## The distinguished name of the container searched for objects in the directory information tree.\n    ## See also: additional_users_dn, additional_groups_dn.\n    # base_dn: 'dc=example,dc=com'\n\n    ## The additional_users_dn is prefixed to base_dn and delimited by a comma when searching for users.\n    ## 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.\n    # additional_users_dn: 'ou=users'\n\n    ## The users filter used in search queries to find the user profile based on input filled in login form.\n    ## Various placeholders are available in the user filter which you can read about in the documentation which can\n    ## be found at: https://www.authelia.com/c/ldap#users-filter-replacements\n    ##\n    ## Recommended settings are as follows:\n    ## - Microsoft Active Directory: (&({username_attribute}={input})(objectCategory=person)(objectClass=user))\n    ## - OpenLDAP:\n    ##   - (&({username_attribute}={input})(objectClass=person))\n    ##   - (&({username_attribute}={input})(objectClass=inetOrgPerson))\n    ##\n    ## To allow sign in both with username and email, one can use a filter like\n    ## (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))\n    # users_filter: '(&({username_attribute}={input})(objectClass=person))'\n\n    ## The additional_groups_dn is prefixed to base_dn and delimited by a comma when searching for groups.\n    ## 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.\n    # additional_groups_dn: 'ou=groups'\n\n    ## The groups filter used in search queries to find the groups based on relevant authenticated user.\n    ## Various placeholders are available in the groups filter which you can read about in the documentation which can\n    ## be found at: https://www.authelia.com/c/ldap#groups-filter-replacements\n    ##\n    ## If your groups use the `groupOfUniqueNames` structure use this instead:\n    ##    (&(uniqueMember={dn})(objectClass=groupOfUniqueNames))\n    # groups_filter: '(&(member={dn})(objectClass=groupOfNames))'\n\n    ## The group search mode to use. Options are 'filter' or 'memberof'. It's essential to read the docs if you wish to\n    ## use 'memberof'. Also 'filter' is the best choice for most use cases.\n    # group_search_mode: 'filter'\n\n    ## Follow referrals returned by the server.\n    ## This is especially useful for environments where read-only servers exist. Only implemented for write operations.\n    # permit_referrals: false\n\n    ## The username and password of the admin user.\n    # user: 'cn=admin,dc=example,dc=com'\n    ## Password can also be set using a secret: https://www.authelia.com/c/secrets\n    # password: 'password'\n\n    ## The attributes for users and objects from the directory server.\n    # attributes:\n\n      ## The distinguished name attribute if your directory server supports it. Users should read the docs before\n      ## configuring. Only used for the 'memberof' group search mode.\n      # distinguished_name: ''\n\n      ## The attribute holding the username of the user. This attribute is used to populate the username in the session\n      ## information. For your information, Microsoft Active Directory usually uses 'sAMAccountName' and OpenLDAP\n      ## usually uses 'uid'. Beware that this attribute holds the unique identifiers for the users binding the user and\n      ## the configuration stored in database; therefore only single value attributes are allowed and the value must\n      ## never be changed once attributed to a user otherwise it would break the configuration for that user.\n      ## Technically non-unique attributes like 'mail' can also be used but we don't recommend using them, we instead\n      ## advise to use a filter to perform alternative lookups and the attributes mentioned above\n      ## (sAMAccountName and uid) to follow https://datatracker.ietf.org/doc/html/rfc2307.\n      # username: 'uid'\n\n      ## The attribute holding the display name of the user. This will be used to greet an authenticated user.\n      # display_name: 'displayName'\n\n      ## The attribute holding the mail address of the user. If multiple email addresses are defined for a user, only\n      ## the first one returned by the directory server is used.\n      # mail: 'mail'\n\n      ## The attribute which provides distinguished names of groups an object is a member of.\n      ## Only used for the 'memberof' group search mode.\n      # member_of: 'memberOf'\n\n      ## The attribute holding the name of the group.\n      # group_name: 'cn'\n\n  ##\n  ## File (Authentication Provider)\n  ##\n  ## With this backend, the users database is stored in a file which is updated when users reset their passwords.\n  ## Therefore, this backend is meant to be used in a dev environment and not in production since it prevents Authelia\n  ## to be scaled to more than one instance. The options under 'password' have sane defaults, and as it has security\n  ## implications it is highly recommended you leave the default values. Before considering changing these settings\n  ## please read the docs page below:\n  ## https://www.authelia.com/r/passwords#tuning\n  ##\n  ## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness\n  ##\n  file:\n    path: '/config/users_database.yml'\n    watch: true\n    search:\n      email: false\n      case_insensitive: false\n    #password:\n      #algorithm: 'argon2'\n      # argon2:\n        # variant: 'argon2id'\n        # iterations: 3\n        # memory: 65536\n        # parallelism: 4\n        # key_length: 32\n        # salt_length: 16\n      # scrypt:\n        # iterations: 16\n        # block_size: 8\n        # parallelism: 1\n        # key_length: 32\n        # salt_length: 16\n      # pbkdf2:\n        # variant: 'sha512'\n        # iterations: 310000\n        # salt_length: 16\n      # sha2crypt:\n        # variant: 'sha512'\n        # iterations: 50000\n        # salt_length: 16\n      # bcrypt:\n        # variant: 'standard'\n        # cost: 12\n\n##\n## Password Policy Configuration.\n##\npassword_policy:\n\n  ## The standard policy allows you to tune individual settings manually.\n  standard:\n    enabled: false\n\n    ## Require a minimum length for passwords.\n    min_length: 8\n\n    ## Require a maximum length for passwords.\n    max_length: 0\n\n    ## Require uppercase characters.\n    require_uppercase: true\n\n    ## Require lowercase characters.\n    require_lowercase: true\n\n    ## Require numeric characters.\n    require_number: true\n\n    ## Require special characters.\n    require_special: true\n\n  ## zxcvbn is a well known and used password strength algorithm. It does not have tunable settings.\n  zxcvbn:\n    enabled: false\n\n    ## Configures the minimum score allowed.\n    min_score: 3\n\n##\n## Privacy Policy Configuration\n##\n## Parameters used for displaying the privacy policy link and drawer.\nprivacy_policy:\n\n  ## Enables the display of the privacy policy using the policy_url.\n  enabled: false\n\n  ## Enables the display of the privacy policy drawer which requires users accept the privacy policy\n  ## on a per-browser basis.\n  require_user_acceptance: false\n\n  ## The URL of the privacy policy document. Must be an absolute URL and must have the 'https://' scheme.\n  ## If the privacy policy enabled option is true, this MUST be provided.\n  policy_url: ''\n\n##\n## Access Control Configuration\n##\n## Access control is a list of rules defining the authorizations applied for one resource to users or group of users.\n##\n## If 'access_control' is not defined, ACL rules are disabled and the 'bypass' rule is applied, i.e., access is allowed\n## to anyone. Otherwise restrictions follow the rules defined.\n##\n## Note: One can use the wildcard * to match any subdomain.\n## It must stand at the beginning of the pattern. (example: *.example.com)\n##\n## Note: You must put patterns containing wildcards between simple quotes for the YAML to be syntactically correct.\n##\n## Definition: A 'rule' is an object with the following keys: 'domain', 'subject', 'policy' and 'resources'.\n##\n## - 'domain' defines which domain or set of domains the rule applies to.\n##\n## - 'subject' defines the subject to apply authorizations to. This parameter is optional and matching any user if not\n##    provided. If provided, the parameter represents either a user or a group. It should be of the form\n##    'user:<username>' or 'group:<groupname>'.\n##\n## - 'policy' is the policy to apply to resources. It must be either 'bypass', 'one_factor', 'two_factor' or 'deny'.\n##\n## - 'resources' is a list of regular expressions that matches a set of resources to apply the policy to. This parameter\n##   is optional and matches any resource if not provided.\n##\n## Note: the order of the rules is important. The first policy matching (domain, resource, subject) applies.\naccess_control:\n  ## Default policy can either be 'bypass', 'one_factor', 'two_factor' or 'deny'. It is the policy applied to any\n  ## resource if there is no policy to be applied to the user.\n  default_policy: 'one_factor'\n\n  # networks:\n    # - name: 'internal'\n    #   networks:\n        # - '10.10.0.0/16'\n        # - '192.168.2.0/24'\n    # - name: 'VPN'\n    #   networks: '10.9.0.0/16'\n\n  # rules:\n    ## Rules applied to everyone\n    # - domain: 'public.example.com'\n    #   policy: 'bypass'\n\n    ## Domain Regex examples. Generally we recommend just using a standard domain.\n    # - domain_regex: '^(?P<User>\\w+)\\.example\\.com$'\n    #   policy: 'one_factor'\n    # - domain_regex: '^(?P<Group>\\w+)\\.example\\.com$'\n    #   policy: 'one_factor'\n    # - domain_regex:\n      #  - '^appgroup-.*\\.example\\.com$'\n      #  - '^appgroup2-.*\\.example\\.com$'\n    #   policy: 'one_factor'\n    # - domain_regex: '^.*\\.example\\.com$'\n    #   policy: 'two_factor'\n\n    # - domain: 'secure.example.com'\n    #   policy: 'one_factor'\n    ## Network based rule, if not provided any network matches.\n    #   networks:\n        # - 'internal'\n        # - 'VPN'\n        # - '192.168.1.0/24'\n        # - '10.0.0.1'\n\n    # - domain:\n        # - 'secure.example.com'\n        # - 'private.example.com'\n    #   policy: 'two_factor'\n\n    # - domain: 'singlefactor.example.com'\n    #   policy: 'one_factor'\n\n    ## Rules applied to 'admins' group\n    # - domain: 'mx2.mail.example.com'\n    #   subject: 'group:admins'\n    #   policy: 'deny'\n\n    # - domain: '*.example.com'\n    #   subject:\n        # - 'group:admins'\n        # - 'group:moderators'\n    #   policy: 'two_factor'\n\n    ## Rules applied to 'dev' group\n    # - domain: 'dev.example.com'\n    #   resources:\n        # - '^/groups/dev/.*$'\n    #   subject: 'group:dev'\n    #   policy: 'two_factor'\n\n    ## Rules applied to user 'john'\n    # - domain: 'dev.example.com'\n    #   resources:\n        # - '^/users/john/.*$'\n    #   subject: 'user:john'\n    #   policy: 'two_factor'\n\n    ## Rules applied to user 'harry'\n    # - domain: 'dev.example.com'\n    #   resources:\n        # - '^/users/harry/.*$'\n    #   subject: 'user:harry'\n    #   policy: 'two_factor'\n\n    ## Rules applied to user 'bob'\n    # - domain: '*.mail.example.com'\n    #   subject: 'user:bob'\n    #   policy: 'two_factor'\n    # - domain: 'dev.example.com'\n    #   resources:\n    #     - '^/users/bob/.*$'\n    #   subject: 'user:bob'\n    #   policy: 'two_factor'\n\n##\n## Session Provider Configuration\n##\n## The session cookies identify the user once logged in.\n## The available providers are: `memory`, `redis`. Memory is the provider unless redis is defined.\nsession:\n  ## The secret to encrypt the session data. This is only used with Redis / Redis Sentinel.\n  ## Secret can also be set using a secret: https://www.authelia.com/c/secrets\n  # secret: 'insecure_session_secret'\n\n  ## Cookies configures the list of allowed cookie domains for sessions to be created on.\n  ## Undefined values will default to the values below.\n  cookies:\n    -\n      ## The name of the session cookie.\n      #name: 'authelia_session'\n\n      ## The domain to protect.\n      ## Note: the Authelia portal must also be in that domain.\n      domain: '{{ mustEnv \"APP_DOMAIN\" }}'\n\n      ## Required. The fully qualified URI of the portal to redirect users to on proxies that support redirections.\n      ## Rules:\n      ##   - MUST use the secure scheme 'https://'\n      ##   - The above 'domain' option MUST either:\n      ##      - Match the host portion of this URI.\n      ##      - Match the suffix of the host portion when prefixed with '.'.\n      authelia_url: 'https://auth.{{ mustEnv \"APP_DOMAIN\" }}'\n\n      ## Optional. The fully qualified URI used as the redirection location if the portal is accessed directly. Not\n      ## configuring this option disables the automatic redirection behaviour.\n      ##\n      ## Note: this parameter is optional. If not provided, user won't be redirected upon successful authentication\n      ## unless they were redirected to Authelia by the proxy.\n      ##\n      ## Rules:\n      ##   - MUST use the secure scheme 'https://'\n      ##   - MUST not match the 'authelia_url' option.\n      ##   - The above 'domain' option MUST either:\n      ##      - Match the host portion of this URI.\n      ##      - Match the suffix of the host portion when prefixed with '.'.\n      default_redirection_url: 'https://{{ mustEnv \"APP_DOMAIN\" }}'\n\n      ## Sets the Cookie SameSite value. Possible options are none, lax, or strict.\n      ## Please read https://www.authelia.com/c/session#same_site\n      # same_site: 'lax'\n\n      ## The value for inactivity, expiration, and remember_me are in seconds or the duration common syntax.\n      ## All three of these values affect the cookie/session validity period. Longer periods are considered less secure\n      ## because a stolen cookie will last longer giving attackers more time to spy or attack.\n\n      ## The inactivity time before the session is reset. If expiration is set to 1h, and this is set to 5m, if the user\n      ## does not select the remember me option their session will get destroyed after 1h, or after 5m since the last\n      ## time Authelia detected user activity.\n      # inactivity: '5 minutes'\n\n      ## The time before the session cookie expires and the session is destroyed if remember me IS NOT selected by the\n      ## user.\n      # expiration: '1 hour'\n\n      ## The time before the cookie expires and the session is destroyed if remember me IS selected by the user. Setting\n      ## this value to -1 disables remember me for this session cookie domain. If allowed and the user uses the remember\n      ## me checkbox this overrides the expiration option and disables the inactivity option.\n      # remember_me: '1 month'\n\n  ## Cookie Session Domain default 'name' value.\n  name: 'authelia_session'\n\n  ## Cookie Session Domain default 'same_site' value.\n  same_site: 'lax'\n\n  ## Cookie Session Domain default 'inactivity' value.\n  inactivity: '5m'\n\n  ## Cookie Session Domain default 'expiration' value.\n  expiration: '1h'\n\n  ## Cookie Session Domain default 'remember_me' value.\n  remember_me: '1M'\n\n  ##\n  ## Redis Provider\n  ##\n  ## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness\n  ##\n  # redis:\n    # host: '127.0.0.1'\n    # port: 6379\n    ## Use a unix socket instead\n    # host: '/var/run/redis/redis.sock'\n\n    ## Username used for redis authentication. This is optional and a new feature in redis 6.0.\n    # username: 'authelia'\n\n    ## Password can also be set using a secret: https://www.authelia.com/c/secrets\n    # password: 'authelia'\n\n    ## This is the Redis DB Index https://redis.io/commands/select (sometimes referred to as database number, DB, etc).\n    # database_index: 0\n\n    ## The maximum number of concurrent active connections to Redis.\n    # maximum_active_connections: 8\n\n    ## The target number of idle connections to have open ready for work. Useful when opening connections is slow.\n    # minimum_idle_connections: 0\n\n    ## The Redis TLS configuration. If defined will require a TLS connection to the Redis instance(s).\n    # tls:\n      ## The server subject name to check the servers certificate against during the validation process.\n      ## This option is not required if the certificate has a SAN which matches the host option.\n      # server_name: 'myredis.example.com'\n\n      ## Skip verifying the server certificate entirely. In preference to setting this we strongly recommend you add the\n      ## certificate or the certificate of the authority signing the certificate to the certificates directory which is\n      ## defined by the `certificates_directory` option at the top of the configuration.\n      ## It's important to note the public key should be added to the directory, not the private key.\n      ## This option is strongly discouraged but may be useful in some self-signed situations where validation is not\n      ## important to the administrator.\n      # skip_verify: false\n\n      ## Minimum TLS version for the connection.\n      # minimum_version: 'TLS1.2'\n\n      ## Maximum TLS version for the connection.\n      # maximum_version: 'TLS1.3'\n\n      ## The certificate chain used with the private_key if the server requests TLS Client Authentication\n      ## i.e. Mutual TLS.\n      # certificate_chain: |\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n\n      ## The private key used with the certificate_chain if the server requests TLS Client Authentication\n      ## i.e. Mutual TLS.\n      # private_key: |\n        # -----BEGIN RSA PRIVATE KEY-----\n        # ...\n        # -----END RSA PRIVATE KEY-----\n\n    ## The Redis HA configuration options.\n    ## This provides specific options to Redis Sentinel, sentinel_name must be defined (Master Name).\n    # high_availability:\n      ## Sentinel Name / Master Name.\n      # sentinel_name: 'mysentinel'\n\n      ## Specific username for Redis Sentinel. The node username and password is configured above.\n      # sentinel_username: 'sentinel_specific_user'\n\n      ## Specific password for Redis Sentinel. The node username and password is configured above.\n      # sentinel_password: 'sentinel_specific_pass'\n\n      ## The additional nodes to pre-seed the redis provider with (for sentinel).\n      ## If the host in the above section is defined, it will be combined with this list to connect to sentinel.\n      ## For high availability to be used you must have either defined; the host above or at least one node below.\n      # nodes:\n        # - host: 'sentinel-node1'\n        #   port: 6379\n        # - host: 'sentinel-node2'\n        #   port: 6379\n\n      ## Choose the host with the lowest latency.\n      # route_by_latency: false\n\n      ## Choose the host randomly.\n      # route_randomly: false\n\n##\n## Regulation Configuration\n##\n## This mechanism prevents attackers from brute forcing the first factor. It bans the user if too many attempts are made\n## in a short period of time.\n# regulation:\n  ## The number of failed login attempts before user is banned. Set it to 0 to disable regulation.\n  # max_retries: 3\n\n  ## The time range during which the user can attempt login before being banned in the duration common syntax. The user\n  ## is banned if the authentication failed 'max_retries' times in a 'find_time' seconds window.\n  # find_time: '2 minutes'\n\n  ## The length of time before a banned user can login again in the duration common syntax.\n  # ban_time: '5 minutes'\n\n##\n## Storage Provider Configuration\n##\n## The available providers are: `local`, `mysql`, `postgres`. You must use one and only one of these providers.\nstorage:\n  ## The encryption key that is used to encrypt sensitive information in the database. Must be a string with a minimum\n  ## length of 20. Please see the docs if you configure this with an undesirable key and need to change it, you MUST use\n  ## the CLI to change this in the database if you want to change it from a previously configured value.\n  # encryption_key: 'you_must_generate_a_random_string_of_more_than_twenty_chars_and_configure_this'\n\n  ##\n  ## Local (Storage Provider)\n  ##\n  ## This stores the data in a SQLite3 Database.\n  ## This is only recommended for lightweight non-stateful installations.\n  ##\n  ## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness\n  ##\n  local:\n    ## Path to the SQLite3 Database.\n    path: '/persist/db.sqlite3'\n\n  ##\n  ## MySQL / MariaDB (Storage Provider)\n  ##\n  # mysql:\n    ## The address of the MySQL server to connect to in the address common syntax.\n    ## Format: [<scheme>://]<hostname>[:<port>].\n    ## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', or 'unix`.\n    ## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '3306'.\n    # address: 'tcp://127.0.0.1:3306'\n\n    ## The database name to use.\n    # database: 'authelia'\n\n    ## The username used for SQL authentication.\n    # username: 'authelia'\n\n    ## The password used for SQL authentication.\n    ## Can also be set using a secret: https://www.authelia.com/c/secrets\n    # password: 'mypassword'\n\n    ## The connection timeout in the duration common syntax.\n    # timeout: '5 seconds'\n\n    ## MySQL TLS settings. Configuring this requires TLS.\n    # tls:\n      ## The server subject name to check the servers certificate against during the validation process.\n      ## This option is not required if the certificate has a SAN which matches the address options hostname.\n      # server_name: 'mysql.example.com'\n\n      ## Skip verifying the server certificate entirely. In preference to setting this we strongly recommend you add the\n      ## certificate or the certificate of the authority signing the certificate to the certificates directory which is\n      ## defined by the `certificates_directory` option at the top of the configuration.\n      ## It's important to note the public key should be added to the directory, not the private key.\n      ## This option is strongly discouraged but may be useful in some self-signed situations where validation is not\n      ## important to the administrator.\n      # skip_verify: false\n\n      ## Minimum TLS version for the connection.\n      # minimum_version: 'TLS1.2'\n\n      ## Maximum TLS version for the connection.\n      # maximum_version: 'TLS1.3'\n\n      ## The certificate chain used with the private_key if the server requests TLS Client Authentication\n      ## i.e. Mutual TLS.\n      # certificate_chain: |\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n\n      ## The private key used with the certificate_chain if the server requests TLS Client Authentication\n      ## i.e. Mutual TLS.\n      # private_key: |\n        # -----BEGIN RSA PRIVATE KEY-----\n        # ...\n        # -----END RSA PRIVATE KEY-----\n\n  ##\n  ## PostgreSQL (Storage Provider)\n  ##\n  # postgres:\n    ## The address of the PostgreSQL server to connect to in the address common syntax.\n    ## Format: [<scheme>://]<hostname>[:<port>].\n    ## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', or 'unix`.\n    ## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '5432'.\n    # address: 'tcp://127.0.0.1:5432'\n\n    ## The database name to use.\n    # database: 'authelia'\n\n    ## The schema name to use.\n    # schema: 'public'\n\n    ## The username used for SQL authentication.\n    # username: 'authelia'\n\n    ## The password used for SQL authentication.\n    ## Can also be set using a secret: https://www.authelia.com/c/secrets\n    # password: 'mypassword'\n\n    ## The connection timeout in the duration common syntax.\n    # timeout: '5 seconds'\n\n    ## PostgreSQL TLS settings. Configuring this requires TLS.\n    # tls:\n      ## The server subject name to check the servers certificate against during the validation process.\n      ## This option is not required if the certificate has a SAN which matches the address options hostname.\n      # server_name: 'postgres.example.com'\n\n      ## Skip verifying the server certificate entirely. In preference to setting this we strongly recommend you add the\n      ## certificate or the certificate of the authority signing the certificate to the certificates directory which is\n      ## defined by the `certificates_directory` option at the top of the configuration.\n      ## It's important to note the public key should be added to the directory, not the private key.\n      ## This option is strongly discouraged but may be useful in some self-signed situations where validation is not\n      ## important to the administrator.\n      # skip_verify: false\n\n      ## Minimum TLS version for the connection.\n      # minimum_version: 'TLS1.2'\n\n      ## Maximum TLS version for the connection.\n      # maximum_version: 'TLS1.3'\n\n      ## The certificate chain used with the private_key if the server requests TLS Client Authentication\n      ## i.e. Mutual TLS.\n      # certificate_chain: |\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n\n      ## The private key used with the certificate_chain if the server requests TLS Client Authentication\n      ## i.e. Mutual TLS.\n      # private_key: |\n        # -----BEGIN RSA PRIVATE KEY-----\n        # ...\n        # -----END RSA PRIVATE KEY-----\n\n##\n## Notification Provider\n##\n## Notifications are sent to users when they require a password reset, a WebAuthn registration or a TOTP registration.\n## The available providers are: filesystem, smtp. You must use only one of these providers.\nnotifier:\n  ## You can disable the notifier startup check by setting this to true.\n  disable_startup_check: false\n\n  ##\n  ## File System (Notification Provider)\n  ##\n  ## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness\n  ##\n  filesystem:\n    filename: '/persist/notification.txt'\n\n  ##\n  ## SMTP (Notification Provider)\n  ##\n  ## Use a SMTP server for sending notifications. Authelia uses the PLAIN or LOGIN methods to authenticate.\n  ## [Security] By default Authelia will:\n  ##   - force all SMTP connections over TLS including unauthenticated connections\n  ##      - use the disable_require_tls boolean value to disable this requirement\n  ##        (only works for unauthenticated connections)\n  ##   - validate the SMTP server x509 certificate during the TLS handshake against the hosts trusted certificates\n  ##     (configure in tls section)\n  # smtp:\n    ## The address of the SMTP server to connect to in the address common syntax.\n    # address: 'smtp://127.0.0.1:25'\n\n    ## The connection timeout in the duration common syntax.\n    # timeout: '5 seconds'\n\n    ## The username used for SMTP authentication.\n    # username: 'test'\n\n    ## The password used for SMTP authentication.\n    ## Can also be set using a secret: https://www.authelia.com/c/secrets\n    # password: 'password'\n\n    ## The sender is used to is used for the MAIL FROM command and the FROM header.\n    ## If this is not defined and the username is an email, we use the username as this value. This can either be just\n    ## an email address or the RFC5322 'Name <email address>' format.\n    # sender: 'Authelia <admin@example.com>'\n\n    ## HELO/EHLO Identifier. Some SMTP Servers may reject the default of localhost.\n    # identifier: 'localhost'\n\n    ## Subject configuration of the emails sent. {title} is replaced by the text from the notifier.\n    # subject: '[Authelia] {title}'\n\n    ## This address is used during the startup check to verify the email configuration is correct.\n    ## It's not important what it is except if your email server only allows local delivery.\n    # startup_check_address: 'test@authelia.com'\n\n    ## By default we require some form of TLS. This disables this check though is not advised.\n    # disable_require_tls: false\n\n    ## Disables sending HTML formatted emails.\n    # disable_html_emails: false\n\n    # tls:\n      ## The server subject name to check the servers certificate against during the validation process.\n      ## This option is not required if the certificate has a SAN which matches the address options hostname.\n      # server_name: 'smtp.example.com'\n\n      ## Skip verifying the server certificate entirely. In preference to setting this we strongly recommend you add the\n      ## certificate or the certificate of the authority signing the certificate to the certificates directory which is\n      ## defined by the `certificates_directory` option at the top of the configuration.\n      ## It's important to note the public key should be added to the directory, not the private key.\n      ## This option is strongly discouraged but may be useful in some self-signed situations where validation is not\n      ## important to the administrator.\n      # skip_verify: false\n\n      ## Minimum TLS version for the connection.\n      # minimum_version: 'TLS1.2'\n\n      ## Maximum TLS version for the connection.\n      # maximum_version: 'TLS1.3'\n\n      ## The certificate chain used with the private_key if the server requests TLS Client Authentication\n      ## i.e. Mutual TLS.\n      # certificate_chain: |\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n\n      ## The private key used with the certificate_chain if the server requests TLS Client Authentication\n      ## i.e. Mutual TLS.\n      # private_key: |\n        # -----BEGIN RSA PRIVATE KEY-----\n        # ...\n        # -----END RSA PRIVATE KEY-----\n\n##\n## Identity Providers\n##\nidentity_providers:\n\n  ##\n  ## OpenID Connect (Identity Provider)\n  ##\n  ## It's recommended you read the documentation before configuration of this section:\n  ## https://www.authelia.com/c/oidc\n  oidc:\n    ## The hmac_secret is used to sign OAuth2 tokens (authorization code, access tokens and refresh tokens).\n    ## HMAC Secret can also be set using a secret: https://www.authelia.com/c/secrets\n    hmac_secret: {{ secret (mustEnv \"HMAC_SECRET_FILE\") }}\n\n    ## The JWK's issuer option configures multiple JSON Web Keys. It's required that at least one of the JWK's\n    ## configured has the RS256 algorithm. For RSA keys (RS or PS) the minimum is a 2048 bit key.\n    jwks:\n    -\n      ## Key ID embedded into the JWT header for key matching. Must be an alphanumeric string with 7 or less characters.\n      ## This value is automatically generated if not provided. It's recommended to not configure this.\n      # key_id: 'example'\n\n      ## The key algorithm used with this key.\n      algorithm: 'RS256'\n\n      ## The key use expected with this key. Currently only 'sig' is supported.\n      use: 'sig'\n\n      ## Required Private Key in PEM DER form.\n      key: {{ secret (mustEnv \"JWT_PRIVATE_KEY_FILE\") | mindent 10 \"|\" | msquote }}\n\n\n      ## Optional matching certificate chain in PEM DER form that matches the key. All certificates within the chain\n      ## must be valid and current, and from top to bottom each certificate must be signed by the subsequent one.\n      # certificate_chain: |\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n        # -----BEGIN CERTIFICATE-----\n        # ...\n        # -----END CERTIFICATE-----\n\n    ## Enables additional debug messages.\n    # enable_client_debug_messages: false\n\n    ## SECURITY NOTICE: It's not recommended changing this option and values below 8 are strongly discouraged.\n    # minimum_parameter_entropy: 8\n\n    ## SECURITY NOTICE: It's not recommended changing this option, and highly discouraged to have it set to 'never'\n    ## for security reasons.\n    # enforce_pkce: 'public_clients_only'\n\n    ## SECURITY NOTICE: It's not recommended changing this option. We encourage you to read the documentation and fully\n    ## understanding it before enabling this option.\n    # enable_jwt_access_token_stateless_introspection: false\n\n    ## The signing algorithm used for signing the discovery and metadata responses. An issuer JWK with a matching\n    ## algorithm must be available when configured. Most clients completely ignore this and it has a performance cost.\n    # discovery_signed_response_alg: 'none'\n\n    ## The signing key id used for signing the discovery and metadata responses. An issuer JWK with a matching key id\n    ## must be available when configured. Most clients completely ignore this and it has a performance cost.\n    # discovery_signed_response_key_id: ''\n\n    ## Authorization Policies which can be utilized by clients. The 'policy_name' is an arbitrary value that you pick\n    ## which is utilized as the value for the 'authorization_policy' on the client.\n    # authorization_policies:\n      # policy_name:\n        # default_policy: 'two_factor'\n        # rules:\n          # - policy: 'one_factor'\n          #   subject: 'group:services'\n\n    ## The lifespans configure the expiration for these token types in the duration common syntax. In addition to this\n    ## syntax the lifespans can be customized per-client.\n    # lifespans:\n      ## Configures the default/fallback lifespan for given token types. This behaviour applies to all clients and all\n      ## grant types but you can override this behaviour using the custom lifespans.\n      # access_token: '1 hour'\n      # authorize_code: '1 minute'\n      # id_token: '1 hour'\n      # refresh_token: '90 minutes'\n\n    ## Cross-Origin Resource Sharing (CORS) settings.\n    # cors:\n      ## List of endpoints in addition to the metadata endpoints to permit cross-origin requests on.\n      # endpoints:\n        #  - 'authorization'\n        #  - 'pushed-authorization-request'\n        #  - 'token'\n        #  - 'revocation'\n        #  - 'introspection'\n        #  - 'userinfo'\n\n      ## List of allowed origins.\n      ## Any origin with https is permitted unless this option is configured or the\n      ## allowed_origins_from_client_redirect_uris option is enabled.\n      # allowed_origins:\n        # - 'https://example.com'\n\n      ## Automatically adds the origin portion of all redirect URI's on all clients to the list of allowed_origins,\n      ## provided they have the scheme http or https and do not have the hostname of localhost.\n      # allowed_origins_from_client_redirect_uris: false\n\n    ## Clients is a list of known clients and their configuration.\n    clients:\n      -\n        ## The Client ID is the OAuth 2.0 and OpenID Connect 1.0 Client ID which is used to link an application to a\n        ## configuration.\n        client_id: 'grist-local'\n\n        ## The description to show to users when they end up on the consent screen. Defaults to the ID above.\n        client_name: 'Grist'\n\n        ## The client secret is a shared secret between Authelia and the consumer of this client.\n        # yamllint disable-line rule:line-length\n        client_secret: {{ secret (mustEnv \"GRIST_CLIENT_SECRET_DIGEST_FILE\") }}\n\n        ## Sector Identifiers are occasionally used to generate pairwise subject identifiers. In most cases this is not\n        ## necessary. It is critical to read the documentation for more information.\n        # sector_identifier_uri: 'https://example.com/sector.json'\n\n        ## Sets the client to public. This should typically not be set, please see the documentation for usage.\n        # public: false\n\n        ## Redirect URI's specifies a list of valid case-sensitive callbacks for this client.\n        redirect_uris:\n          - {{ mustEnv \"GRIST_OAUTH_CALLBACK_URL\" | quote }}\n\n        ## Request URI's specifies a list of valid case-sensitive TLS-secured URIs for this client for use as\n        ## URIs to fetch Request Objects.\n        # request_uris:\n          # - 'https://oidc.example.com:8080/oidc/request-object.jwk'\n\n        ## Audience this client is allowed to request.\n        # audience: []\n\n        ## Scopes this client is allowed to request.\n        scopes:\n          - 'openid'\n          - 'groups'\n          - 'email'\n          - 'profile'\n\n        ## Grant Types configures which grants this client can obtain.\n        ## It's not recommended to define this unless you know what you're doing.\n        # grant_types:\n          # - 'authorization_code'\n\n        ## Response Types configures which responses this client can be sent.\n        ## It's not recommended to define this unless you know what you're doing.\n        # response_types:\n          # - 'code'\n\n        ## Response Modes configures which response modes this client supports.\n        # response_modes:\n          # - 'form_post'\n          # - 'query'\n\n        ## The policy to require for this client; one_factor or two_factor. Can also be the key names for the\n        ## authorization policies section.\n        authorization_policy: 'one_factor'\n\n        ## The custom lifespan name to use for this client. This must be configured independent of the client before\n        ## utilization. Custom lifespans are reusable similar to authorization policies.\n        # lifespan: ''\n\n        ## The consent mode controls how consent is obtained.\n        # consent_mode: 'auto'\n\n        ## This value controls the duration a consent on this client remains remembered when the consent mode is\n        ## configured as 'auto' or 'pre-configured' in the duration common syntax.\n        # pre_configured_consent_duration: '1 week'\n\n        ## Requires the use of Pushed Authorization Requests for this client when set to true.\n        # require_pushed_authorization_requests: false\n\n        ## Enforces the use of PKCE for this client when set to true.\n        # require_pkce: false\n\n        ## Enforces the use of PKCE for this client when configured, and enforces the specified challenge method.\n        ## Options are 'plain' and 'S256'.\n        # pkce_challenge_method: 'S256'\n\n        ## The permitted client authentication method for the Token Endpoint for this client.\n        ## For confidential client types this value defaults to 'client_secret_basic' and for the public client types it\n        ## defaults to 'none' per the specifications.\n        # token_endpoint_auth_method: 'client_secret_basic'\n\n        ## The permitted client authentication signing algorithm for the Token Endpoint for this client when using\n        ## the 'client_secret_jwt' or 'private_key_jwt' token_endpoint_auth_method.\n        # token_endpoint_auth_signing_alg: 'RS256'\n\n        ## The signing algorithm which must be used for request objects. A client JWK with a matching algorithm must be\n        ## available if configured.\n        # request_object_signing_alg: 'RS256'\n\n        ## The signing algorithm used for signing the authorization response. An issuer JWK with a matching algorithm\n        ## must be available when configured. Configuring this value enables the  JWT Secured Authorization Response\n        ## Mode (JARM) for this client. JARM is not understood by a majority of clients so you should only configure\n        ## this when you know it's supported.\n        ## Has no effect if authorization_signed_response_key_id is configured.\n        # authorization_signed_response_alg: 'none'\n\n        ## The signing key id used for signing the authorization response. An issuer JWK with a matching key id must be\n        ## available when configured. Configuring this value enables the JWT Secured Authorization Response Mode (JARM)\n        ## for this client. JARM is not understood by a majority of clients so you should only configure this when you\n        ## know it's supported.\n        # authorization_signed_response_key_id: ''\n\n        ## The signing algorithm used for ID Tokens. An issuer JWK with a matching algorithm must be available when\n        ## configured. Has no effect if id_token_signed_response_key_id is configured.\n        # id_token_signed_response_alg: 'RS256'\n\n        ## The signing key id used for ID Tokens. An issuer JWK with a matching key id must be available when\n        ## configured.\n        # id_token_signed_response_key_id: ''\n\n        ## The signing algorithm used for Access Tokens. An issuer JWK with a matching algorithm must be available.\n        ## Has no effect if access_token_signed_response_key_id is configured. Values other than 'none' enable RFC9068\n        ## for this client.\n        # access_token_signed_response_alg: 'none'\n\n        ## The signing key id used for Access Tokens. An issuer JWK with a matching key id must be available when\n        ## configured. Values other than a blank value enable RFC9068 for this client.\n        # access_token_signed_response_key_id: ''\n\n        ## The signing algorithm used for User Info responses. An issuer JWK with a matching algorithm must be\n        ## available. Has no effect if userinfo_signing_key_id is configured.\n        # userinfo_signed_response_alg: 'none'\n\n        ## The signing key id used for User Info responses. An issuer JWK with a matching key id must be available when\n        ## configured.\n        # userinfo_signed_response_key_id: ''\n\n        ## The signing algorithm used for Introspection responses. An issuer JWK with a matching algorithm must be\n        ## available when configured. Has no effect if introspection_signed_response_key_id is configured.\n        # introspection_signed_response_alg: 'none'\n\n        ## The signing key id used for Introspection responses. An issuer JWK with a matching key id must be available\n        ## when configured.\n        # introspection_signed_response_key_id: ''\n\n        ## Trusted public keys configuration for request object signing for things such as 'private_key_jwt'.\n        ## URL of the HTTPS endpoint which serves the keys. Please note the 'jwks_uri' and the 'jwks' option below\n        ## are mutually exclusive.\n        # jwks_uri: 'https://app.example.com/jwks.json'\n\n        ## Trusted public keys configuration for request object signing for things such as 'private_key_jwt'.\n        ## List of JWKs known and registered with this client. It's recommended to use the 'jwks_uri' option if\n        ## available due to key rotation. Please note the 'jwks' and the 'jwks_uri' option above are mutually exclusive.\n        # jwks:\n          # -\n            ## Key ID used to match the JWT's to an individual identifier. This option is required if configured.\n            # key_id: 'example'\n\n            ## The key algorithm expected with this key.\n            # algorithm: 'RS256'\n\n            ## The key use expected with this key. Currently only 'sig' is supported.\n            # use: 'sig'\n\n            ## Required Public Key in PEM DER form.\n            # key: |\n              # -----BEGIN RSA PUBLIC KEY-----\n              # ...\n              # -----END RSA PUBLIC KEY-----\n\n            ## The matching certificate chain in PEM DER form that matches the key if available.\n            # certificate_chain: |\n              # -----BEGIN CERTIFICATE-----\n              # ...\n              # -----END CERTIFICATE-----\n              # -----BEGIN CERTIFICATE-----\n              # ...\n              # -----END CERTIFICATE-----\n...\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/configs/authelia/users_database.yml",
    "content": "# Primary users file.\n\n# Passwords are generated using 'authelia crypto hash generate argon2'\n# E.g:\n#     docker run authelia/authelia:4 authelia crypto hash generate argon2 --password \"test\"\n# See https://www.authelia.com/reference/guides/passwords/#yaml-format\n\nusers:\n  test:\n    disabled: false\n    displayname: 'Test'\n    password: '$argon2id$v=19$m=65536,t=3,p=4$j1Jub3z0jWBmXNOjNpRK5w$d5176FINCAuzdT3uehQqMS08FC4fadAGrqyZL+0W+p4'\n    email: 'test@example.org'\n    groups: []\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/configs/traefik/config.yml",
    "content": "providers:\n  # Enables reading docker label config values\n  docker: {}\n\nentrypoints:\n  # Defines a secure entrypoint using TLS encryption\n  websecure:\n    address: \":443\"\n    http:\n      tls: true\n  # Defines an insecure entrypoint that redirects to the secure one.\n  web:\n    address: \":80\"\n    http:\n      # Redirects HTTP to HTTPS\n      redirections:\n        entrypoint:\n          to: \"websecure\"\n          scheme: \"https\"\n\n# Enables automatic certificate renewal\ncertificatesResolvers:\n  letsencrypt:\n    acme:\n      email: \"my_email@example.com\"\n      storage: /acme/acme.json\n      tlschallenge: true\n\napi:\n  insecure: true\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/docker-compose.yml",
    "content": "secrets:\n  # These secrets are used by Authelia\n  JWT_SECRET:\n    file: ${SECRETS_DIR}/JWT_SECRET\n  SESSION_SECRET:\n    file: ${SECRETS_DIR}/SESSION_SECRET\n  STORAGE_ENCRYPTION_KEY:\n    file: ${SECRETS_DIR}/STORAGE_ENCRYPTION_KEY\n  # These secrets are for using Authelia as an OIDC provider\n  HMAC_SECRET:\n    file: ${SECRETS_DIR}/HMAC_SECRET\n  JWT_PRIVATE_KEY:\n    file: ${SECRETS_DIR}/certs/private.pem\n  GRIST_CLIENT_SECRET_DIGEST:\n    file: ${SECRETS_DIR}/GRIST_CLIENT_SECRET_DIGEST\n\nservices:\n  grist:\n    image: gristlabs/grist:latest\n    environment:\n      # The URL of given OIDC provider. Used for redirects, among other things.\n      GRIST_OIDC_IDP_ISSUER: https://${AUTHELIA_DOMAIN}\n      # Client ID, as configured with the OIDC provider.\n      GRIST_OIDC_IDP_CLIENT_ID: grist-local\n      # Client secret, as provided by the OIDC provider.\n      GRIST_OIDC_IDP_CLIENT_SECRET: ${GRIST_CLIENT_SECRET}\n      # The URL to redirect to with the OIDC provider to log out.\n      # Some OIDC providers will automatically configure this.\n      GRIST_OIDC_IDP_END_SESSION_ENDPOINT: https://${AUTHELIA_DOMAIN}/logout\n      # Allow self-signed certificates so this example behaves correctly.\n      # REMOVE THIS IF HOSTING ON THE INTERNET.\n      NODE_TLS_REJECT_UNAUTHORIZED: 0\n\n      # Forces Grist to only use a single team called 'Example'\n      GRIST_SINGLE_ORG: my-grist-team   # alternatively, GRIST_ORG_IN_PATH: \"true\" for multi-team operation\n      # Force users to login (disable anonymous access)\n      GRIST_FORCE_LOGIN: true\n      # Base URL Grist redirects to when navigating. Change this to your domain.\n      APP_HOME_URL: https://${GRIST_DOMAIN}\n      # Default email for the \"Admin\" account\n      GRIST_DEFAULT_EMAIL: ${DEFAULT_EMAIL:-test@example.org}\n    restart: always\n    volumes:\n      # Where to store persistent data, such as documents.\n      - ${PERSIST_DIR}/grist:/persist\n    labels:\n      - \"traefik.http.services.grist.loadbalancer.server.port=8484\"\n      - \"traefik.http.routers.grist.rule=Host(`${GRIST_DOMAIN}`)\"\n      - \"traefik.http.routers.grist.service=grist\"\n      # Uncomment and configure in traefik-config.yml to enable automatic HTTPS certificate setup.\n      #- \"traefik.http.routers.grist.tls.certresolver=letsencrypt\"\n    depends_on:\n      # Grist attempts to setup OIDC when it starts, making a request to the OIDC service.\n      # This will fail if Authelia isn't ready and reachable.\n      # Traefik will only start routing to Authelia when it's registered as healthy.\n      # Making Grist wait for Authelia to be healthy should avoid this issue.\n      authelia:\n        condition: service_healthy\n      traefik:\n        condition: service_started\n\n  traefik:\n    image: traefik:latest\n    ports:\n      # HTTP Ports\n      - \"80:80\"\n      - \"443:443\"\n      # The Web UI (enabled by --api.insecure=true)\n      - \"8080:8080\"\n      - \"8082:8082\"\n    volumes:\n      # Set the config file for traefik - this is loaded automatically.\n      - ./configs/traefik/config.yml:/etc/traefik/traefik.yml\n      # Certificate location, if automatic certificate setup is enabled.\n      - ./secrets/acme_certificates:/acme\n      # Traefik needs docker access when configured via docker labels.\n      - /var/run/docker.sock:/var/run/docker.sock\n    networks:\n      default:\n        aliases:\n          # Enables Grist to resolve this domain to Traefik when doing OIDC setup.\n          - ${AUTHELIA_DOMAIN}\n\n  authelia:\n    image: authelia/authelia:4\n    secrets:\n      - HMAC_SECRET\n      - JWT_SECRET\n      - JWT_PRIVATE_KEY\n      - GRIST_CLIENT_SECRET_DIGEST\n      - SESSION_SECRET\n      - STORAGE_ENCRYPTION_KEY\n    environment:\n      AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE: '/run/secrets/JWT_SECRET'\n      AUTHELIA_SESSION_SECRET_FILE: '/run/secrets/SESSION_SECRET'\n      AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE: '/run/secrets/STORAGE_ENCRYPTION_KEY'\n      HMAC_SECRET_FILE: '/run/secrets/HMAC_SECRET'\n      JWT_PRIVATE_KEY_FILE: '/run/secrets/JWT_PRIVATE_KEY'\n      # Domain Grist is hosted at. Custom variable that's interpolated into the Authelia config\n      APP_DOMAIN: ${GRIST_DOMAIN}\n      # Where Authelia should redirect to after successful authentication.\n      GRIST_OAUTH_CALLBACK_URL: https://${GRIST_DOMAIN}/oauth2/callback\n      # Hash of the client secret provided to Grist.\n      GRIST_CLIENT_SECRET_DIGEST_FILE: \"/run/secrets/GRIST_CLIENT_SECRET_DIGEST\"\n    volumes:\n      - ./configs/authelia:/config\n      - ${PERSIST_DIR}/authelia:/persist\n    command:\n      - 'authelia'\n      - '--config=/config/configuration.yml'\n      # Enables templating in the config file\n      - '--config.experimental.filters=template'\n    labels:\n      - \"traefik.http.services.authelia.loadbalancer.server.port=9091\"\n      - \"traefik.http.routers.authelia.rule=Host(`${AUTHELIA_DOMAIN}`)\"\n      - \"traefik.http.routers.authelia.service=authelia\"\n      # Uncomment and configure in traefik-config.yml to enable automatic HTTPS certificate setup.\n      #- \"traefik.http.routers.authelia.tls.certresolver=letsencrypt\"\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/env-template",
    "content": "GRIST_DOMAIN=grist.localhost\nAUTHELIA_DOMAIN=auth.grist.localhost\nDEFAULT_EMAIL=test@example.org\nPERSIST_DIR=./persist\nSECRETS_DIR=./secrets\nGRIST_CLIENT_SECRET=\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/generateSecureSecrets.sh",
    "content": "# Helper script to securely generate random secrets for Authelia.\n\nSCRIPT_DIR=$(dirname $0)\n\n# Copy over template files to final locations\ncp -R \"$SCRIPT_DIR/secrets_template\" \"$SCRIPT_DIR/secrets\"\ncp \"$SCRIPT_DIR/env-template\" \"$SCRIPT_DIR/.env\"\n\n# Parses an Aurelia generated secret for the value\nfunction getSecret {\n  cut -d \":\" -f 2 <<< \"$1\" | tr -d '[:blank:]'\n}\n\nfunction generateSecureString {\n  getSecret \"$(docker run authelia/authelia:4 authelia crypto rand --charset=rfc3986 --length=\"$1\")\"\n}\n\ngenerateSecureString 128 > \"$SCRIPT_DIR/secrets/HMAC_SECRET\"\ngenerateSecureString 128 > \"$SCRIPT_DIR/secrets/JWT_SECRET\"\ngenerateSecureString 128 > \"$SCRIPT_DIR/secrets/SESSION_SECRET\"\ngenerateSecureString 128 > \"$SCRIPT_DIR/secrets/STORAGE_ENCRYPTION_KEY\"\n\n# Generates the OIDC secret key for the Grist client\nCLIENT_SECRET_OUTPUT=\"$(docker run authelia/authelia:4 authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986)\"\nCLIENT_SECRET=$(getSecret \"$(grep 'Password' <<< $CLIENT_SECRET_OUTPUT)\")\nsed -i \"/GRIST_CLIENT_SECRET=$/d\" \"$SCRIPT_DIR/.env\"\necho \"GRIST_CLIENT_SECRET=$CLIENT_SECRET\" >> \"$SCRIPT_DIR/.env\"\ngetSecret \"$(grep 'Digest' <<< $CLIENT_SECRET_OUTPUT)\" >> \"$SCRIPT_DIR/secrets/GRIST_CLIENT_SECRET_DIGEST\"\n\n# Generate JWT certificates Authelia needs for OIDC\ndocker run -v ./secrets/certs:/certs authelia/authelia:4 authelia crypto certificate rsa generate -d /certs\n\n"
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/secrets_template/GRIST_CLIENT_SECRET_DIGEST",
    "content": ""
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/secrets_template/HMAC_SECRET",
    "content": ""
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/secrets_template/JWT_SECRET",
    "content": ""
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/secrets_template/SESSION_SECRET",
    "content": ""
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/secrets_template/STORAGE_ENCRYPTION_KEY",
    "content": ""
  },
  {
    "path": "docker-compose-examples/grist-traefik-oidc-auth/secrets_template/certs/private.pem",
    "content": ""
  },
  {
    "path": "docker-compose-examples/grist-with-keycloak-postgres-redis-minio/README.md",
    "content": "This example shows how to start up Grist that:\n- Uses Postgres as a home database,\n- Redis as a state store.\n- MinIO for snapshot storage\n- and Keycloak for managing and authenticating users.\n\nIt is STRONGLY RECOMMENDED not to use this container in a way that makes it accessible to the internet.\nThis setup lacks basic security or authentication.\n\nOther examples demonstrate how to set up authentication and HTTPS.\n\nSee https://support.getgrist.com/self-managed for more information.\n\nThis setup is based on one provided by vviers (https://github.com/vviers).\n\n## How to run this example\n\n- Start the services with `docker compose up`\n- Go to `localhost:8080` and log in as an admin to keycloak with the username \"admin\" and password \"admin\"\n- Follow these steps : https://www.keycloak.org/getting-started/getting-started-docker and https://support.getgrist.com/install/oidc/#example-keycloak\n\nNB : Before running this example, it's very strongly recommended to update the `_PASSWORD` environment variables\nin `.env` to be long, randomly generated passwords."
  },
  {
    "path": "docker-compose-examples/grist-with-keycloak-postgres-redis-minio/docker-compose.yml",
    "content": "services:\n  grist:\n    image: gristlabs/grist:latest\n    restart: on-failure\n    environment:\n      # Postgres database setup\n      TYPEORM_DATABASE: grist\n      TYPEORM_USERNAME: grist\n      TYPEORM_HOST: grist-db\n      TYPEORM_LOGGING: false\n      TYPEORM_PASSWORD: ${DATABASE_PASSWORD}\n      TYPEORM_PORT: 5432\n      TYPEORM_TYPE: postgres\n\n      # Redis setup\n      REDIS_URL: redis://grist-redis\n\n      # MinIO setup. This requires the bucket set up on the MinIO instance with versioning enabled.\n      GRIST_DOCS_MINIO_ACCESS_KEY: grist\n      GRIST_DOCS_MINIO_SECRET_KEY: ${MINIO_PASSWORD}\n      GRIST_DOCS_MINIO_USE_SSL: 0\n      GRIST_DOCS_MINIO_BUCKET: grist-docs\n      GRIST_DOCS_MINIO_ENDPOINT: grist-minio\n      GRIST_DOCS_MINIO_PORT: 9000\n\n      # External Storage for attachments\n      GRIST_EXTERNAL_ATTACHMENTS_MODE: \"snapshots\"\n\n      GRIST_OIDC_SP_HOST: http://localhost:8484\n      GRIST_OIDC_IDP_ISSUER: http://172.17.0.1:8080/realms/myrealm # Host network\n      GRIST_OIDC_IDP_SCOPES: openid profile email\n\n      # the ID you chose for the Keycloak client\n      GRIST_OIDC_IDP_CLIENT_ID: gristclient\n\n      # the client secret generated by Keycloak retrieved earlier\n      GRIST_OIDC_IDP_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}\n\n      GRIST_OIDC_SP_EXTRA_PROPS_TO_STORE: email_verified,preferred_username\n\n    volumes:\n      # Where to store persistent data, such as documents.\n      - ${PERSIST_DIR}/grist:/persist\n    ports:\n      - 8484:8484\n    depends_on:\n      grist-db:\n        condition: service_started\n      grist-redis:\n        condition: service_started\n      grist-minio:\n        condition: service_started\n      minio-setup:\n        condition: service_completed_successfully\n      kc_postgresql:\n        condition: service_started\n      keycloak:\n        condition: service_healthy\n\n    develop:\n      watch:\n      - action: sync+restart\n        path: ../../_build\n        target: /grist/_build\n\n\n  grist-db:\n    image: postgres:alpine\n    environment:\n        POSTGRES_DB: grist\n        POSTGRES_USER: grist\n        POSTGRES_PASSWORD: ${DATABASE_PASSWORD}\n    ports:\n      - \"5434:5432\"\n    volumes:\n      - ${PERSIST_DIR}/postgres:/var/lib/postgresql/data\n\n  grist-redis:\n    image: redis:alpine\n    volumes:\n      - ${PERSIST_DIR}/redis:/data\n\n  grist-minio:\n    image: minio/minio:latest\n    environment:\n      MINIO_ROOT_USER: grist\n      MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}\n    volumes:\n      - ${PERSIST_DIR}/minio:/data\n    command:\n      server /data --console-address=\":9001\"\n\n  # This sets up the buckets required in MinIO. It is only needed to make this example work.\n  # It isn't necessary for deployment and can be safely removed.\n  minio-setup:\n    image: minio/mc\n    environment:\n      MINIO_PASSWORD: ${MINIO_PASSWORD}\n    depends_on:\n      grist-minio:\n        condition: service_started\n    restart: on-failure\n    entrypoint: >\n      /bin/sh -c \"\n      /usr/bin/mc alias set myminio http://grist-minio:9000 grist '$MINIO_PASSWORD';\n      /usr/bin/mc mb myminio/grist-docs;\n      /usr/bin/mc anonymous set public myminio/grist-docs;\n      /usr/bin/mc version enable myminio/grist-docs;\n      \"\n\n  kc_postgresql:\n    image: postgres:alpine\n    ports:\n      - \"5433:5432\"\n    environment:\n      # Postgresql db container configuration\n      - POSTGRES_DB=keycloak\n      - POSTGRES_USER=grist\n      - POSTGRES_PASSWORD=pass\n\n      # App database configuration\n      - DB_HOST=kc_postgresql\n      - DB_NAME=keycloak\n      - DB_USER=grist\n      - DB_PASSWORD=pass\n      - DB_PORT=5433\n\n  keycloak:\n    image: quay.io/keycloak/keycloak:26.2.0\n    volumes:\n      - ${PERSIST_DIR}/keycloak/auth:/opt/keycloak/data/import\n    command:\n      - start-dev\n      - --import-realm\n    environment:\n      KC_BOOTSTRAP_ADMIN_USERNAME: admin\n      KC_BOOTSTRAP_ADMIN_PASSWORD: admin\n      KC_DB: postgres\n      KC_DB_URL_HOST: kc_postgresql\n      KC_DB_URL_DATABASE: keycloak\n      KC_DB_PASSWORD: pass\n      KC_DB_USERNAME: grist\n      KC_DB_SCHEMA: public\n      KC_HEALTH_ENABLED: true\n      #PROXY_ADDRESS_FORWARDING: 'true'\n    ports:\n      - \"8080:8080\"\n      - \"8083:8083\"\n      - \"9080:9000\"\n    depends_on:\n      - kc_postgresql\n    healthcheck:\n      # This healthcheck is a way to wait for Keycloak to be ready. It only checks Keycloak's health once since healthchecks in KC are sometimes weird : https://github.com/keycloak/keycloak/discussions/10575\n      test: [\"CMD-SHELL\", \"if [ ! -f /tmp/health.txt ]; then touch /tmp/health.txt && exec 3<>/dev/tcp/127.0.0.1/9000;echo -e 'GET /health/ready HTTP/1.1\\r\\nhost: http://localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi; else echo \\\"Healthcheck already executed\\\"; fi\"]"
  },
  {
    "path": "docker-compose-examples/grist-with-postgres-redis-minio/README.md",
    "content": "This example shows how to start up Grist that:\n- Uses Postgres as a home database,\n- Redis as a state store.\n- MinIO for snapshot storage\n\nIt is STRONGLY RECOMMENDED not to use this container in a way that makes it accessible to the internet.\nThis setup lacks basic security or authentication.\n\nOther examples demonstrate how to set up authentication and HTTPS.\n\nSee https://support.getgrist.com/self-managed for more information.\n\nThis setup is based on one provided by Akito (https://github.com/theAkito).\n\n## How to run this example\n\nBefore running this example, it's very strongly recommended to update the `_PASSWORD` environment variables\nin `.env` to be long, randomly generated passwords.\n\nThis example can be run with `docker compose up`.\n"
  },
  {
    "path": "docker-compose-examples/grist-with-postgres-redis-minio/docker-compose.yml",
    "content": "services:\n  grist:\n    image: gristlabs/grist:latest\n    environment:\n      # Postgres database setup\n      TYPEORM_DATABASE: grist\n      TYPEORM_USERNAME: grist\n      TYPEORM_HOST: grist-db\n      TYPEORM_LOGGING: false\n      TYPEORM_PASSWORD: ${DATABASE_PASSWORD}\n      TYPEORM_PORT: 5432\n      TYPEORM_TYPE: postgres\n\n      # Redis setup\n      REDIS_URL: redis://grist-redis\n\n      # MinIO setup. This requires the bucket set up on the MinIO instance with versioning enabled.\n      GRIST_DOCS_MINIO_ACCESS_KEY: grist\n      GRIST_DOCS_MINIO_SECRET_KEY: ${MINIO_PASSWORD}\n      GRIST_DOCS_MINIO_USE_SSL: 0\n      GRIST_DOCS_MINIO_BUCKET: grist-docs\n      GRIST_DOCS_MINIO_ENDPOINT: grist-minio\n      GRIST_DOCS_MINIO_PORT: 9000\n\n    volumes:\n      # Where to store persistent data, such as documents.\n      - ${PERSIST_DIR}/grist:/persist\n    ports:\n      - 8484:8484\n    depends_on:\n      - grist-db\n      - grist-redis\n      - grist-minio\n      - minio-setup\n\n  grist-db:\n    image: postgres:alpine\n    environment:\n        POSTGRES_DB: grist\n        POSTGRES_USER: grist\n        POSTGRES_PASSWORD: ${DATABASE_PASSWORD}\n    volumes:\n      - ${PERSIST_DIR}/postgres:/var/lib/postgresql/data\n\n  grist-redis:\n    image: redis:alpine\n    volumes:\n      - ${PERSIST_DIR}/redis:/data\n\n  grist-minio:\n    image: minio/minio:latest\n    environment:\n      MINIO_ROOT_USER: grist\n      MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}\n    volumes:\n      - ${PERSIST_DIR}/minio:/data\n    command:\n      server /data --console-address=\":9001\"\n\n  # This sets up the buckets required in MinIO. It is only needed to make this example work.\n  # It isn't necessary for deployment and can be safely removed.\n  minio-setup:\n    image: minio/mc\n    environment:\n      MINIO_PASSWORD: ${MINIO_PASSWORD}\n    depends_on:\n      grist-minio:\n        condition: service_started\n    restart: on-failure\n    entrypoint: >\n      /bin/sh -c \"\n      /usr/bin/mc alias set myminio http://grist-minio:9000 grist '$MINIO_PASSWORD';\n      /usr/bin/mc mb myminio/grist-docs;\n      /usr/bin/mc anonymous set public myminio/grist-docs;\n      /usr/bin/mc version enable myminio/grist-docs;\n      \"\n"
  },
  {
    "path": "documentation/database.md",
    "content": "# Database\n\n> [!WARNING]\n> This documentation is meant to describe the state of the database. The reader should be aware that some undocumented changes may have been done after its last updates, and for this purpose should check the git history of this file.\n>\n> Also contributions are welcome! :heart:\n\nGrist manages two databases:\n1. The Home Database;\n2. The Document Database (also known as \"the grist document\");\n\nThe Home database is responsible for things related to the instance, such as:\n - the users and the groups registered on the instance,\n - the billing,\n - the organisations (also called sites), the workspaces,\n - the documents' metadata (such as ID, name, or workspace under which it is located);\n - the access permissions (ACLs) to organisations, workspaces and documents (access to the content of the document is controlled by the document itself);\n\nA Grist Document contains data such as:\n - The tables, pages, views data;\n - The ACL *inside* to access to all or part of tables (rows or columns);\n\n## The Document Database\n\n### Inspecting the Document\n\nA Grist Document (with the `.grist` extension) is actually a SQLite database. You may download a document like [this one](https://api.getgrist.com/o/templates/api/docs/keLK5sVeyfPkxyaXqijz2x/download?template=false&nohistory=false) and inspect its content using a tool such as the `sqlite3` command:\n\n````\n$ sqlite3 Flashcards.grist\nsqlite> .tables\nFlashcards_Data                   _grist_TabBar\nFlashcards_Data_summary_Card_Set  _grist_TabItems\nGristDocTour                      _grist_TableViews\n_grist_ACLMemberships             _grist_Tables\n_grist_ACLPrincipals              _grist_Tables_column\n_grist_ACLResources               _grist_Triggers\n_grist_ACLRules                   _grist_Validations\n_grist_Attachments                _grist_Views\n_grist_Cells                      _grist_Views_section\n_grist_DocInfo                    _grist_Views_section_field\n_grist_External_database          _gristsys_Action\n_grist_External_table             _gristsys_ActionHistory\n_grist_Filters                    _gristsys_ActionHistoryBranch\n_grist_Imports                    _gristsys_Action_step\n_grist_Pages                      _gristsys_FileInfo\n_grist_REPL_Hist                  _gristsys_Files\n_grist_Shares                     _gristsys_PluginData\n````\n\n:warning: If you want to ensure that you will not alter a document's contents, make a backup copy beforehand.\n\n`_grist_*` tables are managed by the data engine. Their schema is defined in [`sandbox/grist/schema.py`](/sandbox/grist/schema.py).\n\n`_gristsys_*` tables are managed by the Node.js process. Their schema is defined in [`app/server/lib/DocStorage.ts`](/app/server/lib/DocStorage.ts).\n\n### The migrations\n\nFor information on document database migrations, please consult [the documentation for migrations](./migrations.md#document-database).\n\n## The Home Database\n\nThe home database may either be a SQLite or a PostgreSQL database depending on how the Grist instance has been installed. For details, please refer to the `TYPEORM_*` env variables in the [README](../README.md#database-variables).\n\nUnless otherwise configured, the home database is a SQLite file. In the default Docker image, it is stored at this location: `/persist/home.sqlite3`.\n\nThe schema below is the same (except for minor differences in the column types), regardless of what the database type is.\n\n### The Schema\n\nThe database schema is the following:\n\n![Schema of the home database](./images/homedb-schema.svg)\n\n> [!NOTE]\n> For simplicity's sake, we have removed tables related to the billing and to the migrations.\n\nIf you want to generate the above schema by yourself, you may run the following command using [SchemaCrawler](https://www.schemacrawler.com/) ([a docker image is available for a quick run](https://www.schemacrawler.com/docker-image.html)):\n````bash\n# You may adapt the --database argument to fit with the actual file name\n# You may also remove the `--grep-tables` option and all that follows to get the full schema.\n# database path needs to be absolute in some schemacrawler implementations\n$ schemacrawler --server=sqlite --database=$(pwd)/landing.db --info-level=standard \\\n  --portable=names --command=schema --output-format=svg \\\n  --output-file=/tmp/graph.svg \\\n  --grep-tables=\"products|billing_accounts|limits|billing_account_managers|activations|migrations\" \\\n  --invert-match\n````\n\n### `orgs` table\n\nStores organisations (also called \"Team sites\") information.\n\n| Column name | Description |\n| ------------- | -------------- |\n| id | The primary key |\n| name | The name as displayed in the UI |\n| domain | The part that should be added in the URL |\n| owner_id | The id of the user who owns the org |\n| host | ??? |\n\n### `workspaces` table\n\nStores workspaces information.\n\n| Column name | Description |\n| ------------- | -------------- |\n| id | The primary key |\n| name | The name as displayed in the UI |\n| org_id | The organisation to which the workspace belongs |\n| removed_at | If not null, stores the date when the workspaces has been placed in the trash (it will be hard deleted after 30 days) |\n\n### `docs` table\n\nStores document information that is not portable, which means that it does not store the document data nor the ACL rules (see the \"Document Database\" section).\n\n| Column name | Description |\n| ------------- | -------------- |\n| id | The primary key |\n| name | The name as displayed in the UI |\n| workspace_id | The workspace the document belongs to |\n| is_pinned | Whether the document has been pinned or not |\n| url_id | Short version of the `id`, as displayed in the URL |\n| removed_at | If not null, stores the date when the workspaces has been placed in the trash (it will be hard deleted after 30 days) |\n| options | Serialized options as described in the [DocumentOptions](https://github.com/gristlabs/grist-core/blob/4567fad94787c20f65db68e744c47d5f44b932e4/app/common/UserAPI.ts#L125-L135) interface |\n| grace_period_start | Specific to getgrist.com (TODO describe it) |\n| usage | stats about the document (see [DocumentUsage](https://github.com/gristlabs/grist-core/blob/4567fad94787c20f65db68e744c47d5f44b932e4/app/common/DocUsage.ts)) |\n| trunk_id | If set, the current document is a fork (only from a tutorial), and this column references the original document |\n| type | If set, the current document is a special one (as specified in [DocumentType](https://github.com/gristlabs/grist-core/blob/4567fad94787c20f65db68e744c47d5f44b932e4/app/common/UserAPI.ts#L123)) |\n\n### `aliases` table\n\nAliases for documents.\n\nFIXME: What's the difference between `docs.url_id` and `alias.url_id`?\n\n| Column name | Description |\n| ------------- | -------------- |\n| url_id | The URL alias for the doc_id |\n| org_id | The organisation the document belongs to |\n| doc_id | The document id |\n\n### `acl_rules` table\n\nPermissions to access either a document, workspace or an organisation.\n\n| Column name | Description |\n| ------------- | -------------- |\n| id | The primary key |\n| permissions | The permissions granted to the group. See below. |\n| type | Either equals to `ACLRuleOrg`, `ACLRuleWs` or `ACLRuleDoc` |\n| org_id | The org id associated to this ACL (if set, workspace_id and doc_id are null) |\n| workspace_id | The workspace id associated to this ACL (if set, doc_id and org_id are null) |\n| doc_id | The document id associated to this ACL (if set, workspace_id and org_id are null) |\n| group_id | The group of users for which the ACL applies |\n\n<a name=\"acl-permissions\"></a>\nThe permissions are stored as an integer which is read in its binary form which allows to make bitwise operations:\n\n| Name | Value (binary) | Description |\n| --------------- | --------------- | --------------- |\n| VIEW | +0b00000001 | can view |\n| UPDATE | +0b00000010 | can update |\n| ADD | +0b00000100 | can add |\n| REMOVE | +0b00001000 | can remove |\n| SCHEMA_EDIT | +0b00010000 | can change schema of tables |\n| ACL_EDIT | +0b00100000 | can edit the ACL (docs) or manage the teams (orgs and workspaces) of the resource |\n| (reserved) | +0b01000000 | (reserved bit for the future) |\n| PUBLIC | +0b10000000 | virtual bit meaning that the resource is shared publicly (not currently used) |\n\nYou notice that the permissions can be then composed:\n - EDITOR permissions = `VIEW | UPDATE | ADD | REMOVE` = `0b00000001+0b00000010+0b00000100+0b00001000` = `0b00001111` = `15`\n - ADMIN permissions = `EDITOR | SCHEMA_EDIT` = `0b00001111+0b00010000` = `0b00011111` = `31`\n - OWNER permissions = `ADMIN | ACL_EDIT` = `0b00011111+0b00100000` = `0b0011111` = `63`\n\nFor more details about that part, please refer [to the code](https://github.com/gristlabs/grist-core/blob/192e2f36ba77ec67069c58035d35205978b9215e/app/gen-server/lib/Permissions.ts).\n\n### `secrets` table\n\nStores secret informations related to documents, so the document may not store them (otherwise someone who downloads a doc may access them). Used to store the unsubscribe key and the target url of Webhooks.\n\n| Column name | Description |\n| ------------- | -------------- |\n| id | The primary key |\n| value | The value of the secret (despite the table name, its stored unencrypted) |\n| doc_id | The document id |\n\n### `prefs` table\n\nStores the user's preferences. It can either be scoped globally or to an organization.\n\n| Column name | Description |\n| ------------- | -------------- |\n| org_id | If set, the preferences are specific to the referenced organization. Otherwise, the user's preferences are global. |\n| user_id | the user for whom preferences applies. |\n| prefs | The serialized JSON of the preferences. If specific to an organization, it's [an UserOrgPrefs object](https://github.com/gristlabs/grist-core/blob/f53e2e3d6085443e173dda913fe995361d42b0f8/app/common/Prefs.ts#L41) or otherwise [an UserPrefs object](https://github.com/gristlabs/grist-core/blob/f53e2e3d6085443e173dda913fe995361d42b0f8/app/common/Prefs.ts#L17). |\n\n### `shares` table\n\nStores special grants for documents for anyone having the key.\n\n| Column name | Description |\n| ------------- | -------------- |\n| id | The primary key |\n| key | A long string secret to identify the share. Suitable for URLs. Unique across the database / installation. |\n| link_id | A string to identify the share. This identifier is common to the home database and the document specified by docId. It need only be unique within that document, and is not a secret. |\n| doc_id | The document to which the share belongs |\n| options | Any overall qualifiers on the share |\n\nFor more information, please refer [to the comments in the code](https://github.com/gristlabs/grist-core/blob/f53e2e3d6085443e173dda913fe995361d42b0f8/app/gen-server/entity/Share.ts).\n\n### `groups` table\n\nThe groups are entities that may contain either other groups and/or users.\n\n| Column name   | Description    |\n|--------------- | --------------- |\n| id | The primary key   |\n| name   | The name (see the 5 types of groups below) |\n\nOnly 5 types of groups exist, which corresponds actually to Roles (for the permissions, please refer to the [ACL rules permissions details](#acl-permissions)):\n - `owners` (see the `OWNERS` permissions)\n - `editors` (see the `EDITORS` permissions)\n - `viewers` (see the `VIEWS` permissions)\n - `members`\n - `guests`\n\n`viewers`, `members` and `guests` have basically the same rights (like viewers), the only difference between them is that:\n - `viewers` are explicitly allowed to view the resource and its descendants;\n - `members` are specific to the organisations and are meant to allow access to be granted to individual documents or workspaces, rather than the full team site.\n - `guests` are (FIXME: help please on this one :))\n\nEach time a resource is created, the groups corresponding to the roles above are created (except the `members` which are specific to organisations).\n\n### `group_groups` table\n\nThe table which allows groups to contain other groups. It is also used for the inheritance mechanism (see below).\n\n| Column name   | Description    |\n|--------------- | --------------- |\n| group_id | The id of the group containing the subgroup |\n| subgroup_id   | The id of the subgroup |\n\n### `group_users` table\n\nThe table which assigns users to groups.\n\n| Column name   | Description    |\n|--------------- | --------------- |\n| group_id | The id of the group containing the user |\n| user_id   | The id of the user |\n| created_at | datetime where the user was added to a group |\n\n### `groups`, `group_groups`, `group_users` and inheritances\n\nWe mentioned earlier that the groups currently holds the roles with the associated permissions.\n\nThe database stores the inheritances of rights as described below.\n\nLet's imagine that a user is granted the role of *Owner* for the \"Org1\" organisation, s/he therefore belongs to the group \"Org1 Owners\" (whose ID is `id_org1_owner_grp`) which also belongs to the \"WS1 Owners\" (whose ID is `id_ws1_owner_grp`) by default. In other words, this user is by default owner of both the Org1 organization and of the WS1 workspace.\n\nThe below schema illustrates both the inheritance of between the groups and the state of the database:\n\n![BDD state by default](./images/BDD-doc-inheritance-default.svg) <!-- Use diagrams.net and import ./images/BDD.drawio to edit this image -->\n\nThis inheritance can be changed through the Users management popup in the Contextual Menu for the Workspaces:\n\n![The drop-down list after \"Inherit access:\" in the workspaces Users Management popup](./images/ws-users-management-popup.png)\n\nIf you change the inherit access to \"View Only\", here is what happens:\n\n![BDD state after inherit access has changed, the `group_groups.group_id` value has changed](./images/BDD-doc-inheritance-after-change.svg) <!-- Use diagrams.net and import ./images/BDD.drawio to edit this image -->\n\nThe Org1 owners now belongs to the \"WS1 Viewers\" group, and the user despite being *Owner* of \"Org1\" can only view the workspace WS1 and its documents because s/he only gets the Viewer role for this workspace. Regarding the database, `group_groups` which holds the group inheritance has been updated, so the parent group for `id_org1_owner_grp` is now `id_ws1_viewers_grp`.\n\n### `users` table\n\nStores `users` information.\n\n| Column name | Description |\n|--------------- | --------------- |\n| id | The primary key |\n| name | The user's name |\n| api_key | If generated, the [HTTP API Key](https://support.getgrist.com/rest-api/) used to authenticate the user |\n| picture | The URL to the user's picture (must be provided by the SSO Identity Provider) |\n| first_login_at | The date of the first login |\n| disabled_at | If not null, the date at which the user was disabled |\n| is_first_time_user | Whether the user discovers Grist (used to trigger the Welcome Tour) |\n| options | Serialized options as described in [UserOptions](https://github.com/gristlabs/grist-core/blob/513e13e6ab57c918c0e396b1d56686e45644ee1a/app/common/UserAPI.ts#L169-L179) interface |\n| connect_id | Used by [GristConnect](https://github.com/gristlabs/grist-ee/blob/5ae19a7dfb436c8a3d67470b993076e51cf83f21/ext/app/server/lib/GristConnect.ts) in Enterprise Edition to identify user in external provider |\n| ref | Used to identify a user in the automated tests |\n\n### `logins` table\n\nStores information related to the identification.\n\n> [!NOTE]\n> A user may have many `logins` records associated to him/her, like several emails used for identification.\n\n\n| Column name | Description |\n|--------------- | --------------- |\n| id | The primary key |\n| user_id | The user's id |\n| email | The normalized email address used for equality and indexing (specifically converted to lower case) |\n| display_email | The user's email address as displayed in the UI |\n\n### The migrations\n\nFor information on home database migrations, please consult [the documentation for migrations](./migrations.md#home-database).\n\n"
  },
  {
    "path": "documentation/develop.md",
    "content": "# Development\n\nPlease as a first start, tell the community about your intent to develop a feature or fix a bug. Search for the associated issue if it exists or open one with steps to reproduce (for bugs) or a [user story](https://en.wikipedia.org/wiki/User_story#Principle) (for features).\n\n## Setup\n\n### Prerequisites\n\nTo setup your environment, you would need to install the following dependencies:\n - git\n - [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md) (recommended) or nodejs installed on your system\n - Chromium to run the end-to-end tests\n - Python (preferably Python 3.11, minimum 3.9) and virtualenv\n\n### Clone the repository\n\n```bash\n$ git clone https://github.com/gristlabs/grist-core\n```\n\nAnd then, enter the grist-core root directory:\n\n```bash\n$ cd grist-core/\n```\n\n### Setup nodejs\n\n#### Using nvm (recommanded)\n\nYou need to install the supported nodejs version as well as yarn. To do so, in the grist-core root directory, run the following command to install nodejs via nvm:\n\n```bash\n$ nvm install\n```\n\nNow check that node is installed in the version specified in the `.nvmrc` file:\n\n```bash\n$ node --version\n```\n\nThen install yarn (the `-g` flag here means that yarn will be available globally):\n```bash\n$ npm install -g yarn\n```\n\nNow each time you want to load nodejs and yarn in your environment, just run the following command at grist-core root directory:\n\n```bash\n$ nvm use\n```\n\n#### Using nodejs\n\nYou can also use nodejs installed in your system. To prevent incompatibilities, ensure that the `node --version` command reports a version equal or greater to the one in `.nvmrc`.\n\n### Install the python packages\n\nBe sure to have Python and virtualenv installed. On debian-based Linux distributions, you can simply run the following command as root:\n\n```bash\n# apt install python3.11 python3.11-venv\n```\n\n### Install the project dependencies and build\n\nFirst install the nodejs dependencies:\n\n```bash\n$ yarn install\n```\n\nThen prepare the virtual environment with all the python dependencies:\n\n```bash\n$ yarn install:python\n```\n\nFinally run this to do an initial build:\n\n```bash\n$ yarn run build\n```\n\n## Start the server in development mode\n\nJust run the following command:\n```bash\n$ yarn start\n```\n\nEach time you change something, just reload the webpage in your browser.\n\nHappy coding!\n\n### Pick an issue\n\nLost on what you can do to help? If you are new to Grist, you may just pick one of the issues labelled `good first issue`:\n\nhttps://github.com/gristlabs/grist-core/labels/good%20first%20issue\n\n## Debug the server\n\nYou can debug the NodeJS application using this command:\n\n```bash\n$ yarn start:debug\n```\n\nAnd start using your nodejs debugger client (like the Chrome Devtools). See https://nodejs.org/en/docs/guides/debugging-getting-started#inspector-clients\n\n\n## Coding Rules\n\nThe coding rules are enforced by the Eslint tool. You can run it using the\n`yarn lint` command and you may run `yarn lint:fix` to make eslint try to\nfix them for you (most of them can be fixed this way).\n\n\n## Run tests\n\nYou may run the tests using one of these commands:\n - `yarn test` to run all the tests\n - `yarn test:debug` to run the tests in debug mode, which will stop after first failure, not clean up after tests and will provide more detailed output (with screenshots) in the `_build/test_output` directory\n - `yarn test:smoke` to run the minimal test checking Grist can open, create and edit a document\n - `yarn test:nbrowser` to run the end-to-end tests (⚠️ see warning below)\n - `yarn test:nbrowser:ci` to run the end-to-end tests in headless mode, suitable for continuous integration environments\n - `yarn test:nbrowser:debug` to run the end-to-end tests in debug mode\n - `yarn test:client` to run the tests for the client libraries\n - `yarn test:common` to run the tests for the common libraries shared between the client and the server\n - `yarn test:server` and `yarn test:gen-server` to run the backend tests depending on where the feature you would like to test resides (respectively `app/server` or `app/gen-server`)\n - `yarn test:docker` to run some end-to-end tests under docker\n - `yarn test:python` to run the data engine tests\n - `yarn test:stubs` to run the end-to-end tests with stubs, which are simplified versions of the tests for faster execution   \n\nAlso some options that may interest you, especially in order to troubleshoot:\n - `GREP_TESTS=\"pattern\"` in order to filter the tests to run, for example: `GREP_TESTS=\"Boot\" yarn test:nbrowser`\n - `VERBOSE=1` in order to view logs when a server is spawned (especially useful to debug the end-to-end and backend tests)\n - `SERVER_NODE_OPTIONS=\"node options\"` in order to pass options to the server being tested,\n   for example: `SERVER_NODE_OPTIONS=\"--inspect --inspect-brk\" GREP_TESTS=\"Boot\" yarn test:nbrowser`\n   to run the tests with the debugger (you should close the debugger each time the node process should stop)\n - `MOCHA_WEBDRIVER_HEADLESS=1` to run the end-to-end tests in headless mode, meaning a browser window won't be opened\n - `NO_CLEANUP=1` to not restart/clean state after test ends, used with `DEBUG=1`\n - `DEBUG=1` to keep the server running after the tests are done and to provide more detailed output\n - `MOCHA_WEBDRIVER_LOGDIR=/tmp/grist-tests` to specify the directory where the logs of the end-to-end tests will be stored (together with screenshots of the browser at the time of failure)\n - `TYPEORM_DATABASE=/path/to/test-database.sqlite` (adapt the path to wherever you want the file to be created) in order to\n   debug tests that work with a database. You may then inspect the database using the `sqlite3` command.\n - `TYPEORM_LOGGING=true` to print every SQL commands during the tests\n\n## End-to-end tests\n\nEnd-to-end tests work by simulating user mouse clicks and keyboard inputs in an actual chrome browser. By default, running `yarn test:nbrowser` opens a new browser window where automated \"user\" interactions happen.\n\n### Headless mode\n\nYou can use the `MOCHA_WEBDRIVER_HEADLESS` env var to start the tests in headless mode, meaning a browser window won't be opened:\n\n```\nMOCHA_WEBDRIVER_HEADLESS=1 yarn run test:nbrowser\n```\n\nRunning in headless mode allows you to run the tests in background, without the risk of automated tests catching window focus while you are doing something else.\n\nRunning in normal mode helps you understand better what happens when writing or debugging tests.\n\n### Browser version issues\n\nEnd-to-end tests are run in the GitHub CI with a specific _Chrome_ version that is known to run the tests smoothly.\n\n⚠️ A current issue is that tests don't run properly with _Chrome for Testing_ binaries, or with _Chrome_ starting with version 134.\n\n**If you don't have any tests randomly failing while running them locally: great! You can move on.**\n\nOtherwise, you should make sure that the local test suite uses _Chrome v132_ or _Chrome v133_, and not a _Chrome for Testing_ variant.\n\nIn order to do that, you can use an env var to let the script know about a specific chrome binary to use. For example, if your Chrome (v132 or 133) path is `/usr/bin/google-chrome`:\n\n```\nTEST_CHROME_BINARY_PATH=\"/usr/bin/google-chrome\" yarn run test:nbrowser\n```\n\n#### Using an older Chrome version than the one you have already installed\n\nYou might already have Chrome v134+ installed and feel stuck!\n\nOne solution is to build yourself a docker container matching what the GitHub actions does. Meaning, with node, python etc, an integrated Chrome v133 binary, and run tests inside that container.\n\nAnother solution on Linux, is to just install an old Chrome version on your system directly.\n\nA simple trick is to install an old Chrome _Beta_ binary, in order to not mess with your current Chrome install.\n\n#### Debian-based distro\n\nYou can do the same as the `buildtools/install_chrome_for_tests.sh` script, but target an old version of Chrome _Beta_ like this:\n\n```bash\ncurl -sS -o /tmp/chrome.deb http://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-beta/google-chrome-beta_133.0.6943.35-1_amd64.deb \\\n  && sudo apt-get install --allow-downgrades -y /tmp/chrome.deb \\\n  && rm /tmp/chrome.deb \\\n```\n\nOpen `google-chrome-beta` one time manually to confirm any first-loads modals that would prevent tests to run correctly.\n\nThen run tests with:\n\n```\nSE_BROWSER_VERSION=133.0.6943.35 \\\nSE_DRIVER_VERSION=133.0.6943.141 \\\nTEST_CHROME_BINARY_PATH=\"/usr/bin/google-chrome-beta\" \\\nyarn run test:nbrowser\n```\n\n#### Archlinux\n\nDownload the google-chrome-beta aur tarball matching the needed version and manually install it:\n\n- download and extract [this aur tarball](https://aur.archlinux.org/cgit/aur.git/snapshot/aur-56ac6350a4f727c76f7e0c406233e7cad0a45b5f.tar.gz) (matching Chrome Beta [v133](https://aur.archlinux.org/cgit/aur.git/commit/PKGBUILD?h=google-chrome-beta&id=56ac6350a4f727c76f7e0c406233e7cad0a45b5f))\n- `cd` in the extracted directory and `makepkg -si`.\n\nOpen `google-chrome-beta` one time manually to confirm any first-loads modals that would prevent tests to run correctly.\n\nThen run tests with:\n\n```\nSE_BROWSER_VERSION=133.0.6943.35 \\\nSE_DRIVER_VERSION=133.0.6943.141 \\\nTEST_CHROME_BINARY_PATH=\"/usr/bin/google-chrome-beta\" \\\nyarn run test:nbrowser\n```\n\n#### macOS\n\nUnfortunately there is no easy way in macOS to pin Chrome version without it auto-updating. If you absolutely need to run tests locally for now:\n\n- create a docker image matching the GitHub CI environment in order to run the tests inside a Linux environment having a pinned Chrome version\n- or… help us fix the tests (sorry)!\n\nNote that tests are always run against pull requests and you can also rely on the GitHub CI instead.\n\n## Develop widgets\n\nCheck out this repository: https://github.com/gristlabs/grist-widget#readme\n\n## Documentation\n\nSome documentation to help you starting developing:\n - [Overview of Grist Components](./overview.md)\n - [The database](./database.md)\n - [GrainJS & Grist Front-End Libraries](./grainjs.md)\n - [GrainJS Documentation](https://github.com/gristlabs/grainjs/) (The library used to build the DOM)\n - [The user support documentation](https://support.getgrist.com/)\n"
  },
  {
    "path": "documentation/disposal.md",
    "content": "# Disposal and Cleanup\n\nGarbage-collected languages make you think that you don't need to worry about cleanup for your objects. In reality, there are still often cases when you do. This page gives some examples, and describes a library to simplify it.\n\n## What's the problem\n\nIn the examples, we care about a situation when you have a JS object that is responsible for certain UI, i.e. DOM, listening to DOM changes to update state elsewhere, and listening to outside changes to update state to the DOM.\n\n### DOM Elements\n\nSo this JS object knows how to create the DOM. Removing the DOM, when the component is to be removed, is usually easy: `parentNode.removeNode(child)`. Since it's a manual operation, you may define some method to do this, named perhaps \"destroy\" or \"dispose\" or \"cleanup\".\n\nIf there is logic tied to your DOM either via JQuery events, or KnockoutJS bindings, you'll want to clean up the node specially: for JQuery, use `.remove()` or `.empty()` methods; for KnockoutJS, use `ko.removeNode()` or `ko.cleanNode()`. KnockoutJS's methods automatically call JQuery-related cleanup functions if JQuery is loaded in the page.\n\n### Subscriptions and Computed Observables\n\nBut there is more. Consider this knockout code, adapted from their simplest example of a computed observable:\n\n```js\nfunction FullNameWidget(firstName, lastName) {\n   this.fullName = ko.computed(function() {\n      return firstName() + \" \" + lastName();\n   });\n   // ...\n}\n```\n\nHere we have a constructor for a component which takes two observables as constructor parameters, and creates a new observable which depends on the two inputs. Whenever `firstName` or `lastName` changes, `this.fullName` get recomputed. This makes it easy to create knockout-based bindings, e.g. to have a DOM element reflect the full name when either first or last name changes.\n\nNow, what happens when this component is destroyed? It removes its associated DOM. Now when `firstName` or `lastName` change, there are no visible changes. But the function to recompute `this.fullName` still gets called, and still retains a reference to `this`, preventing the object from being garbage-collected.\n\nThe issue is that `this.fullName` is subscribed to `firstName` and `lastName` observables. It needs to be unsubscribed when the component is destroyed.\n\nKnockoutJS recognizes it, and makes it easy: just call `this.firstName.dispose()`. We just have to remember to do it when we destroy the component.\n\nThis situation would exist without knockout too: the issue is that the component is listening to external changes to update the DOM that it is responsible for. When the component is gone, it should stop listening.\n\n### Tying life of subscriptions to DOM\n\nSince the situation above is so common in KnockoutJS, it offers some assistance. Specifically, when a computed observable is created using knockout's own binding syntax (by specifying a JS expression in an HTML attribute), knockout will clean it up automatically when the DOM node is removed using `ko.removeNode()` or `ko.cleanNode()`.\n\nKnockout also allows to tie other cleanup to DOM node removal, documented at the [Custom disposal logic](http://knockoutjs.com/documentation/custom-bindings-disposal.html) page.\n\nIn the example above, you could use `ko.utils.domNodeDisposal.addDisposeCallback(node, function() { self.fullName.dispose(); })`, and when you destroy the component and remove the `node` via `ko.removeNode()` or `ko.cleanNode()`, the `fullName` observable will be properly disposed.\n\n### Other knockout subscriptions\n\nThere are other situations with subscriptions. For example, we may want to subscribe to a `viewId` observable, and when it changes, replace the currently-rendered View component. This might look like so\n\n```js\nfunction GristDoc() {\n   this.viewId = ko.observable();\n   this.viewId.subscribe(function(viewId) {\n      this.loadView(viewId);\n   }, this);\n}\n```\n\nOnce GristDoc is destroyed, the subscription to `this.viewId` still exists, so `this.viewId` retains a reference to `this` (for calling the callback). Technically, there is no problem: as long as there are no references to `this.viewId` from outside this object, the whole cycle should be garbage-collected.\n\nBut it's very risky: if anything else has a reference to `this.viewId` (e.g. if `this.viewId` is itself subscribed to, say, `window.history` changes), then the entire `GristDoc` is unavailable to garbage-collection, including all the DOM to which it probably retains references even after that DOM is detached from the page.\n\nBeside the memory leak, it means that when `this.viewId` changes, it will continue calling `this.loadView()`, continuing to update DOM that no one will ever see. Over time, that would of course slow down the browser, but would be hard to detect and debug.\n\nAgain, KnockoutJS offers a way to unsubscribe: `.subscribe()` returns a `ko.subscription` object, which in turn has a `dispose()` method. We just need to call it, and the callback will be unsubscribed.\n\n### Backbone Events\n\nTo be clear, the problem isn't with Knockout, it's with the idea of subscribing to outside events. Backbone allows listening to events, which creates the same problem, and Backbone offers a similar solution.\n\nFor example, let's say you have a component that listens to an outside event and does stuff. With a made-up example, you might have a constructor like:\n\n```js\nfunction Game(basket) {\n   basket.on('points:scored', function(team, points) {\n      // Update UI to show updated points for the team.\n   });\n}\n```\n\nLet's say that a `Game` object is destroyed, and a new one created, but the `basket` persists across Games. As the user continues to score points on the basket, the old (supposedly destroyed) Game object continues to have that inline callback called. It may not be showing anything, but only because the DOM it's updating is no longer attached to the page. It's still taking resources, and may even continue to send stuff to the server.\n\nWe need to clean up when we destroy the Game object. In this example, it's pretty annoying. We'd have to save the `basket` object and callback in member variables (like `this.basket`, `this.callback`), so that in the cleanup method, we could call `this.basket.off('points:scored', this.callback)`.\n\nMany people have gotten bitten with that in Backbone (see this [stackoverflow post](http://stackoverflow.com/questions/14041042/backbone-0-9-9-difference-between-listento-and-on) with a bunch of links to blog posts about it).\n\nBackbone's solution is the `listenTo()` method. You'd use it like so:\n\n```js\nfunction Game(basket) {\n   this.listenTo(basket, 'points:scored', function(team, points) {\n      // Update UI to show updated points for the team.\n   });\n}\n```\n\nThen when you destroy the Game object, you only have to call `this.stopListening()`. It keeps track of what you listened to, and unsubscribes. You just have to remember to call it. (Certain objects in Backbone will call `stopListening()` automatically when they are being cleaned up.)\n\n### Internal events\n\nIf a component listens to an event on a DOM element it itself owns, and if it's using JQuery, then we don't need to do anything special. If on destruction of the component, we clean up the DOM element using `ko.removeNode()`, the JQuery event bindings should automatically be removed. (This hasn't been rigorously verified, but if correct, is a reason to use JQuery for browser events rather than native `addEventListener`.)\n\n## How to do cleanup uniformly\n\nSince we need to destroy the components' DOM explicitly, the components should provide a method to call for that. By analogy with KnockoutJS, let's call it `dispose()`.\n\n- We know that it needs to remove the DOM that the component is responsible for, probably using `ko.removeNode`.\n- If the component used Backbone's `listenTo()`, it should call `stopListening()` to unsubscribe from Backbone events.\n- If the component maintains any knockout subscriptions or computed observables, it should call `.dispose()` on them.\n- If the component owns other components, then those should be cleaned up recursively, by calling `.dispose()` on those.\n\nThe trick is how to make it easy to remember to do all necessary cleanup. I propose keeping track when the object to clean up first enters the picture.\n\n## 'Disposable' class\n\nThe idea is to have a class that can be mixed into (or inherited by) any object, and whose purpose is to keep track of things this object \"owns\", that it should be responsible for cleaning up. To combine the examples above:\n\n```js\nfunction Component(firstName, lastName, basket) {\n   this.fullName = this.autoDispose(ko.computed(function() {\n         return firstName() + \" \" + lastName();\n   }));\n\n   this.viewId = ko.observable();\n   this.autoDispose(this.viewId.subscribe(function(viewId) {\n      this.loadView(viewId);\n   }, this));\n\n   this.ourDom = this.autoDispose(somewhere.appendChild(some_dom_we_create));\n\n   this.listenTo(basket, 'points:scored', function(team, points) {\n      // Update UI to show updated points for the team.\n   });\n}\n```\n\nNote the `this.autoDispose()` calls. They mark the argument as being owned by `this`. When `this.dispose()` is called, those values get disposed of as well.\n\nThe disposal itself is fairly straightforward: if the object has a `dispose` method, we'll call that. If it's a DOM node, we'll call `ko.removeNode` on it. The `dispose()` method of Disposable objects will always call `this.stopListening()` if such a method exists, so that subscriptions using Backbone's `listenTo` are cleaned up automatically.\n\nTo do additional cleanup when `dispose()` is called, the derived class can override `dispose()`, do its other cleanup, then call `Disposable.prototype.dispose.call(this)`.\n\nFor convenience, Disposable class provides a few other methods:\n\n- `disposeRelease(part)`: releases an owned object, so that it doesn't get auto-disposed.\n- `disposeDiscard(part)`: disposes of an owned object early (rather than wait for `this.dispose`).\n- `isDisposed()`: returns whether `this.dispose()` has already been called.\n\n### Destroying destroyed objects\n\nThere is one more  thing that Disposable class's `dispose()` method will do: destroy the object, as in ruin, wreck, wipe out. Specifically, it will go through all properties of `this`, and set each to a junk value. This achieves two goals:\n\n1. In any of the examples above, if you forgot to mark anything with `this.autoDispose()`, and some callback continues to be called after the object has been destroyed, you'll get errors. Not just silent waste of resources that slow down the site and are hard to detect.\n\n2. It removes references, potentially breaking references. Imagine that something wrongly retains a reference to a destroyed object (which logically nothing should, but something might by mistake). If it tries to use the object, it will fail (see point 1). But even if it doesn't access the object, it's preventing the garbage collector from cleaning any of the object. If we break references, then in this situation the GC can still collect all the properties of the destroyed object.\n\n## Conclusion\n\nAll JS client-side components that need cleanup (e.g. maintain DOM, observables, listen to events, or subscribe to anything), should inherit from `Disposable`. To destroy them, call their `.dispose()` method. Whenever they take responsibility for any piece that requires cleanup, they should wrap that piece in `this.autoDispose()`.\n\nThis should go a long way towards avoiding leaks and slowdowns.\n"
  },
  {
    "path": "documentation/grainjs.md",
    "content": "# GrainJS & Grist Front-End Libraries\n\nIn the beginning of working on Grist, we chose to build DOM using pure Javascript, and used Knockout.js to tie DOM elements and properties to variables, called “observables”. This allowed us to describe the DOM structure in one place, using JS, and to keep the dynamic aspects of it separated into observables. These observables served as the model of the UI; other code could update these observables to cause UI to update, without knowing the details of the DOM construction.\n\nOver time, we used the lessons we learned to make a new library implementing these same ideas, which we called GrainJS. It is open-source, written in TypeScript, and available at https://github.com/gristlabs/grainjs.\n\n## [GrainJS documentation](https://github.com/gristlabs/grainjs#documentation)\n\nGrainJS documentation is available at https://github.com/gristlabs/grainjs#documentation. It’s the best place to start, since most Grist code is now based on GrainJS, and new code should be written using it too.\n\n## Older Grist Code\n\nBefore GrainJS, Grist code was based on a combination of Knockout and custom dom-building building functions.\n\n### Knockout Observables\n\nYou can find full documentation of knockout at https://knockoutjs.com/documentation/introduction.html, but you shouldn’t need it. If you’ve read GrainJS documentation, here are the main differences.\n\nCreating and using observables:\n\n```js\nimport * as ko from 'knockout';\n\nconst kObs = ko.observable(17);\nkObs();      // Returns 17\nkObs(8);\nkObs();      // Returns 8\nkObs.peek(); // Returns 8\n```\n\n```js\nimport {Computed, Observable} from 'grainjs';\n\nconst gObs = Observable.create(null, 17)\ngObs.get();     // Returns 17\ngObs.set(8);\n\ngObs.get();     // Returns 8\n```\n\nCreating and using computed observables\n\n```js\nko.computed(() => kObs() * 10);\n```\n\n```js\nComputed.create(null, use => use(gObs) * 10);\n```\n\nNote that in Knockout, the dependency on `kObs()` is created implicitly — because `kObs()` was called in the context of the computed's callback. In case of GrainJS, the dependency is created because the `gObs` observable was examined using the callback's `use()` function.\n\nIn Knockout, the `.peek()` method allows looking at an observable’s value quickly without any potential dependency-creation. So technically, `kObs.peek()` is what’s equivalent to `gObs.get()`.\n\n### Building DOM\n\nOlder Grist code builds DOM using the `dom()` function defined in [`app/client/lib/dom.js`](../app/client/lib/dom.js). It is entirely analogous to [dom() in GrainJS](https://github.com/gristlabs/grainjs/blob/master/docs/basics.md#dom-construction).\n\nThe method `dom.on('click', (ev) => { ... })` allows attaching an event listener during DOM construction. It is similar to the same-named method in GrainJS ([dom.on](https://github.com/gristlabs/grainjs/blob/master/docs/basics.md#dom-events)), but is implemented actually using JQuery.\n\nMethods `dom.onDispose`, and `dom.autoDispose` are analogous to GrainJS, but rely on Knockout’s cleanup.\n\nFor DOM bindings, which allow tying DOM properties to observable values, there is a [`app/client/lib/koDom.js`](../app/client/lib/koDom.js) module. For example:\n\n```js\nimport * as dom from 'app/client/lib/dom';\nimport * as kd from 'app/client/lib/koDom';\n\ndom(\n  'div',\n  kd.toggleClass('active', isActiveObs),\n  kd.text(() => vm.nameObs().toUpperCase()),\n)\n```\n\nNote that `koDom` methods work only with Knockout observables. Most dom-methods are very similar to GrainJS, but there are a few differences.\n\nIn place of GrainJS’s `dom.cls`, older code uses `kd.toggleClass` to toggle a constant class name, and `kd.cssClass` to set a class named by an observable value.\n\nWhat GrainJS calls `dom.domComputed`, is called `kd.scope` in older code; and `dom.forEach` is called `kd.foreach` (all lowercase).\n\nObservable arrays, primarily needed for `kd.foreach`, are implemented in [`app/client/lib/koArray.js`](../app/client/lib/koArray.js). There is an assortment of tools around them, not particularly well organized.\n\n### Old Disposables\n\nWe had to dispose resources before GrainJS, and the tools to simplify that live in [`app/client/lib/dispose.js`](../app/client/lib/dispose.js). In particular, it provides a `Disposable` class, with a similar `this.autoDispose()` method to that of GrainJS.\n\nWhat GrainJS calls `this.onDispose()`, is called `this.autoDisposeCallback()` in older code.\n\nThe older `Disposable` class also provides a static `create()` method, but that one does NOT take an `owner` callback as the first argument, as it pre-dates that idea. This makes it quite annoying to use side-by-side classes that extend older or newer `Disposable`.\n\n### Saving Observables\n\nThe module `app/client/models/modelUtil.js` provides some very Grist-specific tools that doesn’t exist in GrainJS at all. In particular, it allows extending observables (regular or computed) with something it calls a “save interface”: `addSaveInterface(observable, saveFunc)` adds to an observable methods:\n\n* `.saveOnly(value)` — calls `saveFunc(value)`.\n* `.save()` — calls `saveFunc(obs.peek())`.\n* `.setAndSave(value)` — calls `obs(value); saveFunc(value)`.\n\nThese are used in practice for observables created that represent pieces of data in a Grist document, such as metadata values or cells in user tables, and in these cases `saveFunc` is arranged to send a UserAction to Grist to update the stored value in the document.\n\nThis should help you understand what you see, and you may use it in new code if it uses existing old-style “saveable” observables. But in new code, there is no reason to package up this functionality with an observable. For example, if some UI component allows changing a value, have it accept a callback to call with the new value. Depending on what you need, this callback could set an observable, or it could send an action to the server.\n\n### DocModel\n\nThe metadata of a Grist document, which drives the UI of the Grist application, is organized into a `DocModel`, which contains tables, each table with rows, and each row with a set of observables for each field:\n\n* `DocModel` — in [`app/client/models/DocModel.ts`](../app/client/models/DocModel.ts)\n* `MetaTableModel` — in [`app/client/models/MetaTableModel.js`](../app/client/models/MetaTableModel.js) (for metadata tables, which Grist frontend understands and uses)\n  * `MetaRowModel` — in [`app/client/models/MetaRowModel.js`](../app/client/models/MetaRowModel.js). These have particular typed fields, and are enhanced with helpful computeds, according to the table to which they belong to, using classes in [`app/client/models/entities`](../app/client/models/entities/).\n* `DataTableModel` — in [`app/client/models/DataTableModel.js`](../app/client/models/DataTableModel.js) (for user-data tables, which Grist can only treat generically)\n  * `DataRowModel` — in [`app/client/models/DataRowModel.ts`](../app/client/models/DataRowModel.ts).\n* `BaseRowModel` — base class for `MetaRowModel` and `DataRowModel`.\n\nA `RowModel` contains an observable for each field. While there is old-style code that uses these observables, they all remain knockout observables.\n\nNote that new code can use these knockout observables fairly seemlessly. For instance, a knockout observable can be used with GrainJS dom-methods, or as a dependency of a GrainJS computed.\n\nEventually, it would be nice to convert old-style code to use the newer libraries (and convert to TypeScript in the process), and to drop the need for old-style code entirely.\n"
  },
  {
    "path": "documentation/grist-data-format.md",
    "content": "# Grist Data Format\n\nThis document describes the data format used in the Grist REST API and Custom\nWidget API. It covers how column types map to cell values, and the encoding of\nspecial types.\n\nFor API endpoint details, see the [Grist API reference](https://support.getgrist.com/api/).\n\n## Cell Values\n\nEach cell in a Grist table holds a `CellValue`. In JSON, a CellValue is one of:\n\n- `number` — used for Numeric, Int, Date, DateTime, Ref, and similar types\n- `string` — used for Text, Choice, and for mismatched values (e.g. `\"N/A\"` in a Numeric column)\n- `boolean` — used for Bool (`true` / `false`)\n- `null` — represents an empty cell\n- `[code, args...]` — a typed cell value, for lists, errors, and other special values (see below)\n\nThe interpretation of a raw `number` or `string` depends on the column's type. For\nexample, `86400` in a Date column means one day after the Unix epoch (1970-01-02),\nwhile in a Numeric column it is simply the number 86400.\n\n## Column Types and Their Values\n\nA column's type is a string. Some types include a parameter after a colon, for\nexample `Ref:People` or `DateTime:America/New_York`.\n\nThe base types (the part before any colon) are:\n\n| Column Type   | Cell Value Format                      | Default Value | Notes |\n|---------------|----------------------------------------|---------------|-------|\n| `Text`        | `string`                               | `\"\"`          | |\n| `Numeric`     | `number`                               | `0`           | Double-precision float |\n| `Int`         | `number`                               | `0`           | Integer |\n| `Bool`        | `boolean`                              | `false`       | |\n| `Date`        | `number` (seconds since Unix epoch)    | `null`        | Seconds to midnight UTC of that date |\n| `DateTime`    | `number` (seconds since Unix epoch)    | `null`        | Full type is `DateTime:<timezone>`, e.g. `DateTime:America/New_York`. May be fractional for sub-second precision |\n| `Choice`      | `string`                               | `\"\"`          | One of a configured set of options |\n| `ChoiceList`  | `[\"L\", string, ...]`                   | `null`        | List of chosen options |\n| `Ref`         | `number` (row ID)                      | `0`           | Full type is `Ref:<TableId>`, e.g. `Ref:People`. A value of `0` means empty |\n| `RefList`     | `[\"L\", number, ...]`                   | `null`        | Full type is `RefList:<TableId>`. List of row IDs |\n| `Attachments` | `[\"L\", number, ...]`                   | `null`        | List of attachment IDs (a RefList to `_grist_Attachments`) |\n| `Any`         | any CellValue                          | `null`        | No type constraint |\n\nInternal types (used by Grist internally, not available to create via the UI):\n\n| Column Type      | Cell Value Format | Default Value |\n|------------------|-------------------|---------------|\n| `Id`             | `number`          | `0`           |\n| `ManualSortPos`  | `number`          | `Infinity`    |\n| `PositionNumber` | `number`          | `Infinity`    |\n\n### Date and DateTime\n\nDate and DateTime values are stored as **seconds since the Unix epoch** (1970-01-01\n00:00:00 UTC). This is _not_ milliseconds — divide by 1000 if converting from\nJavaScript `Date.getTime()`.\n\n- **Date** columns store whole-day values. The number represents seconds to midnight\n  UTC on that date. For example, `86400` = 1970-01-02.\n- **DateTime** columns store full timestamps as floating-point numbers, supporting\n  sub-second precision (e.g. `1704945919.123`). The column type includes a timezone,\n  e.g. `DateTime:Europe/London`. The stored number is always in UTC; the timezone\n  controls display formatting.\n\n### References\n\n- **Ref** columns store the `id` (row ID) of the referenced record as a plain\n  number. A value of `0` means the reference is empty.\n- **RefList** columns store a list of row IDs as `[\"L\", id1, id2, ...]`, or `null`\n  when empty.\n\n### ChoiceList\n\nA ChoiceList cell contains `null` (no choices selected) or a list encoded as\n`[\"L\", \"option1\", \"option2\", ...]`.\n\n## Typed Cell Values\n\nWhen a cell value is an array, its first element is a single-character type code.\nThese are called \"typed cell values\" and represent lists, errors, and other\nstructured data.\n\n| Code | Name           | Format                              | Description |\n|------|----------------|-------------------------------------|-------------|\n| `L`  | List           | `[\"L\", item, ...]`                  | A list of values. Used for ChoiceList, RefList, and Attachments |\n| `l`  | LookUp         | `[\"l\", value, options]`             | An instruction to set a Ref/RefList by looking up the given value rather than specifying a row ID directly. Used when sending values via the API, not in responses |\n| `O`  | Dict           | `[\"O\", {key: value}]`              | A dictionary/object |\n| `D`  | DateTime       | `[\"D\", timestamp, timezone]`        | DateTime value, e.g. `[\"D\", 1704945919, \"UTC\"]` |\n| `d`  | Date           | `[\"d\", timestamp]`                  | Date value, e.g. `[\"d\", 1704844800]` |\n| `R`  | Reference      | `[\"R\", tableId, rowId]`             | Reference, e.g. `[\"R\", \"People\", 17]` |\n| `r`  | ReferenceList  | `[\"r\", tableId, [rowId, ...]]`      | Reference list, e.g. `[\"r\", \"People\", [1, 2]]` |\n| `E`  | Exception      | `[\"E\", name, message?, details?]`   | A formula error |\n| `P`  | Pending        | `[\"P\"]`                             | Value is not yet computed |\n| `C`  | Censored       | `[\"C\"]`                             | Value hidden by access rules |\n| `U`  | Unmarshallable | `[\"U\", repr]`                       | Value that could not be serialized |\n| `V`  | Versions       | `[\"V\", versionObj]`                 | Used in document comparisons |\n| `S`  | Skip           | `[\"S\"]`                             | Placeholder used in diffs to indicate unchanged rows |\n\n### When Typed Cell Values Appear\n\nBy default, most API responses use compact representations: a Date is just a\n`number`, a Ref is just a `number`, Text is just a `string`. The typed forms\n(`[\"d\", ...]`, `[\"R\", ...]`, etc.) appear in responses in these cases:\n\n- **List types** (ChoiceList, RefList, Attachments) — always `[\"L\", ...]`.\n- **Errors** — appear as `[\"E\", ...]`.\n- **Type mismatches** — in formula columns, when a cell holds a typed value that\n  doesn't match the column's type (e.g. `[\"d\", 1704844800]` appearing in a\n  Numeric column).\n- **`Any` columns** — where values carry their own type information.\n\n### `cellFormat=typed`\n\nBoth the REST API (`/records` and `/data` endpoints) and the Custom Widget API\nsupport an option to return all values using typed cell values. With\n`?cellFormat=typed`, every cell value carries its type explicitly:\n\n- Date values become `[\"d\", timestamp]` instead of bare numbers\n- DateTime values become `[\"D\", timestamp, timezone]`\n- Ref values become `[\"R\", tableId, rowId]`\n- RefList values become `[\"r\", tableId, [rowIds...]]` instead of `[\"L\", ...]`\n- Attachments become `[\"r\", \"_grist_Attachments\", [ids...]]`\n- ChoiceList stays `[\"L\", ...]`\n- Errors remain as `[\"E\", ...]` (in `/records`, they are no longer separated into\n  the `errors` field)\n- Primitives (Text, Numeric, Int, Bool) remain as primitives\n\nThis is useful when reading data from a table without knowing the column types\nin advance, since every value is self-describing.\n\n### Errors\n\nWhen a formula produces an error, the cell value is encoded as\n`[\"E\", exceptionName, message?, details?]`.\n\nIn the default `/records` response format, errors are extracted into a separate\n`errors` field on the record, and the cell value is returned as `null`. With\n`?cellFormat=typed`, errors remain inline as `[\"E\", ...]` values.\n\nCommon exception names:\n\n| Exception Name       | Displayed As   | Meaning |\n|----------------------|----------------|---------|\n| `ZeroDivisionError`  | `#DIV/0!`      | Division by zero |\n| `TypeError`          | `#TypeError`   | Wrong type in formula |\n| `ValueError`         | `#ValueError`  | Invalid value |\n| `InvalidTypedValue`  | `#Invalid ...` | Value doesn't match column type |\n\n## Naming Rules\n\nTable and column identifiers must match `[A-Za-z][A-Za-z0-9_]*` — they start with a\nletter, followed by letters, digits, or underscores. Names are case-sensitive but\nmust be unique case-insensitively (i.e. you cannot have both `Name` and `name` in\nthe same table).\n\nSome identifiers are reserved for internal use, such as `id`, `manualSort`, and\ncolumns starting with `gristHelper_`.\n\n## SQL Endpoint\n\n`POST /api/docs/{docId}/sql` accepts a SQL query against the document's SQLite\ndatabase. See the [Grist API reference](https://support.getgrist.com/api/) for\ndetails.\n\nThe SQL endpoint queries the SQLite database directly, so values appear in their\nstorage representation rather than the API's JSON format. The storage details\nbelow reflect current implementation and may change in future versions.\n\n- **Bool** values are stored as `0`/`1`, not `true`/`false`.\n- **ChoiceList** values are stored as JSON arrays of strings, e.g.\n  `'[\"red\",\"blue\"]'` rather than the API's `[\"L\", \"red\", \"blue\"]`.\n- **RefList** and **Attachments** values are stored as JSON arrays of row IDs,\n  e.g. `'[1,2,3]'` rather than the API's `[\"L\", 1, 2, 3]`.\n- Some non-primitive values (errors, complex objects) are stored as binary\n  [Python marshal](https://docs.python.org/3/library/marshal.html) blobs.\n"
  },
  {
    "path": "documentation/images/BDD.drawio",
    "content": "<mxfile host=\"app.diagrams.net\" modified=\"2024-05-03T07:57:06.222Z\" agent=\"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0\" etag=\"d5mpqxfjE_YjavJEgyO5\" version=\"24.3.1\" type=\"device\" pages=\"2\">\n  <diagram name=\"doc - Inheritance : default\" id=\"HMcLKXGEOIWPtuluTPJ-\">\n    <mxGraphModel dx=\"1654\" dy=\"872\" grid=\"0\" gridSize=\"10\" guides=\"1\" tooltips=\"1\" connect=\"1\" arrows=\"1\" fold=\"1\" page=\"1\" pageScale=\"1\" pageWidth=\"827\" pageHeight=\"1169\" math=\"0\" shadow=\"0\">\n      <root>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-0\" />\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-1\" parent=\"uSf0n1dOcknmwi0iKCrK-0\" />\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-2\" value=\"\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;\" parent=\"uSf0n1dOcknmwi0iKCrK-1\" source=\"uSf0n1dOcknmwi0iKCrK-3\" target=\"uSf0n1dOcknmwi0iKCrK-4\" edge=\"1\">\n          <mxGeometry relative=\"1\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-3\" value=\"Org1\" style=\"rounded=0;whiteSpace=wrap;html=1;\" parent=\"uSf0n1dOcknmwi0iKCrK-1\" vertex=\"1\">\n          <mxGeometry x=\"109\" y=\"493.5\" width=\"120\" height=\"60\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-4\" value=\"Workspace1\" style=\"rounded=0;whiteSpace=wrap;html=1;\" parent=\"uSf0n1dOcknmwi0iKCrK-1\" vertex=\"1\">\n          <mxGeometry x=\"109\" y=\"317\" width=\"120\" height=\"60\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-12\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;\" parent=\"uSf0n1dOcknmwi0iKCrK-1\" source=\"uSf0n1dOcknmwi0iKCrK-6\" target=\"uSf0n1dOcknmwi0iKCrK-10\" edge=\"1\">\n          <mxGeometry relative=\"1\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-20\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;entryPerimeter=0;\" parent=\"uSf0n1dOcknmwi0iKCrK-1\" source=\"uSf0n1dOcknmwi0iKCrK-22\" target=\"uSf0n1dOcknmwi0iKCrK-6\" edge=\"1\">\n          <mxGeometry relative=\"1\" as=\"geometry\">\n            <Array as=\"points\">\n              <mxPoint x=\"459\" y=\"595\" />\n              <mxPoint x=\"548\" y=\"595\" />\n            </Array>\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-21\" value=\"\" style=\"group;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-1\" vertex=\"1\" connectable=\"0\">\n          <mxGeometry x=\"437.5\" y=\"611\" width=\"43\" height=\"65\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-22\" value=\"Some user\" style=\"html=1;verticalLabelPosition=bottom;align=center;labelBackgroundColor=#ffffff;verticalAlign=top;strokeWidth=2;strokeColor=#0080F0;shadow=0;dashed=0;shape=mxgraph.ios7.icons.user;\" parent=\"uSf0n1dOcknmwi0iKCrK-21\" vertex=\"1\">\n          <mxGeometry x=\"6.5\" y=\"35\" width=\"30\" height=\"30\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-24\" value=\"group_users\" style=\"shape=table;startSize=30;container=1;collapsible=0;childLayout=tableLayout;strokeColor=default;fontSize=16;fontStyle=1\" parent=\"uSf0n1dOcknmwi0iKCrK-1\" vertex=\"1\">\n          <mxGeometry x=\"713\" y=\"593\" width=\"359\" height=\"118\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-25\" value=\"\" style=\"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-24\" vertex=\"1\">\n          <mxGeometry y=\"30\" width=\"359\" height=\"44\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-26\" value=\"&lt;b&gt;group_id&lt;/b&gt;\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-25\" vertex=\"1\">\n          <mxGeometry width=\"179\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"179\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-27\" value=\"&lt;b&gt;user_id&lt;/b&gt;\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-25\" vertex=\"1\">\n          <mxGeometry x=\"179\" width=\"180\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"180\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-28\" value=\"\" style=\"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-24\" vertex=\"1\">\n          <mxGeometry y=\"74\" width=\"359\" height=\"44\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-29\" value=\"id_org1_owner_grp\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-28\" vertex=\"1\">\n          <mxGeometry width=\"179\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"179\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-30\" value=\"id_some_user\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-28\" vertex=\"1\">\n          <mxGeometry x=\"179\" width=\"180\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"180\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-31\" value=\"group_groups\" style=\"shape=table;startSize=30;container=1;collapsible=0;childLayout=tableLayout;strokeColor=default;fontSize=16;fontStyle=1\" parent=\"uSf0n1dOcknmwi0iKCrK-1\" vertex=\"1\">\n          <mxGeometry x=\"711\" y=\"288\" width=\"359\" height=\"118\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-32\" value=\"\" style=\"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-31\" vertex=\"1\">\n          <mxGeometry y=\"30\" width=\"359\" height=\"44\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-33\" value=\"&lt;b&gt;group_id&lt;/b&gt;\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-32\" vertex=\"1\">\n          <mxGeometry width=\"179\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"179\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-34\" value=\"&lt;b&gt;subgroup_id&lt;/b&gt;\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-32\" vertex=\"1\">\n          <mxGeometry x=\"179\" width=\"180\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"180\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-35\" value=\"\" style=\"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-31\" vertex=\"1\">\n          <mxGeometry y=\"74\" width=\"359\" height=\"44\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-36\" value=\"&lt;div&gt;id_ws1_owner_grp&lt;/div&gt;\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-35\" vertex=\"1\">\n          <mxGeometry width=\"179\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"179\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-37\" value=\"id_org1_owner_grp\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"uSf0n1dOcknmwi0iKCrK-35\" vertex=\"1\">\n          <mxGeometry x=\"179\" width=\"180\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"180\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-5\" value=\"\" style=\"group;labelBackgroundColor=default;labelBorderColor=none;\" parent=\"uSf0n1dOcknmwi0iKCrK-1\" vertex=\"1\" connectable=\"0\">\n          <mxGeometry x=\"523\" y=\"464\" width=\"50\" height=\"78\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-6\" value=\"&lt;font style=&quot;font-size: 16px;&quot;&gt;Org1 Owners&lt;/font&gt;\" style=\"sketch=0;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#505050;shape=mxgraph.mscae.intune.user_group;labelBackgroundColor=default;spacingTop=9;\" parent=\"uSf0n1dOcknmwi0iKCrK-5\" vertex=\"1\">\n          <mxGeometry y=\"41\" width=\"50\" height=\"37\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-7\" value=\"\" style=\"shape=image;html=1;verticalAlign=top;verticalLabelPosition=bottom;labelBackgroundColor=#ffffff;imageAspect=0;aspect=fixed;image=https://cdn0.iconfinder.com/data/icons/phosphor-fill-vol-2/256/crown-simple-fill-128.png\" parent=\"uSf0n1dOcknmwi0iKCrK-5\" vertex=\"1\">\n          <mxGeometry x=\"3.5\" width=\"43\" height=\"43\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-9\" value=\"\" style=\"group\" parent=\"uSf0n1dOcknmwi0iKCrK-1\" vertex=\"1\" connectable=\"0\">\n          <mxGeometry x=\"523\" y=\"281\" width=\"50\" height=\"83\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-10\" value=\"&lt;div style=&quot;font-size: 16px; padding-left: 0px; margin-top: 10px;&quot;&gt;&lt;font style=&quot;font-size: 16px;&quot;&gt;&lt;span style=&quot;background-color: rgb(255, 255, 255);&quot;&gt;Ws1 Owners&lt;/span&gt;&lt;/font&gt;&lt;/div&gt;\" style=\"sketch=0;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#505050;shape=mxgraph.mscae.intune.user_group\" parent=\"uSf0n1dOcknmwi0iKCrK-9\" vertex=\"1\">\n          <mxGeometry y=\"46\" width=\"50\" height=\"37\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"uSf0n1dOcknmwi0iKCrK-11\" value=\"\" style=\"shape=image;html=1;verticalAlign=top;verticalLabelPosition=bottom;labelBackgroundColor=#ffffff;imageAspect=0;aspect=fixed;image=https://cdn0.iconfinder.com/data/icons/phosphor-fill-vol-2/256/crown-simple-fill-128.png\" parent=\"uSf0n1dOcknmwi0iKCrK-9\" vertex=\"1\">\n          <mxGeometry x=\"3.5\" width=\"43\" height=\"43\" as=\"geometry\" />\n        </mxCell>\n      </root>\n    </mxGraphModel>\n  </diagram>\n  <diagram name=\"doc - inheritance : after change\" id=\"ejp4Dg6iXyrIoHg3_VKk\">\n    <mxGraphModel dx=\"1654\" dy=\"872\" grid=\"0\" gridSize=\"10\" guides=\"1\" tooltips=\"1\" connect=\"1\" arrows=\"1\" fold=\"1\" page=\"1\" pageScale=\"1\" pageWidth=\"827\" pageHeight=\"1169\" math=\"0\" shadow=\"0\">\n      <root>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-0\" />\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-1\" parent=\"cy84TbzhjBedF44X58Xk-0\" />\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-2\" value=\"\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;\" parent=\"cy84TbzhjBedF44X58Xk-1\" source=\"cy84TbzhjBedF44X58Xk-3\" target=\"cy84TbzhjBedF44X58Xk-4\" edge=\"1\">\n          <mxGeometry relative=\"1\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-3\" value=\"Org1\" style=\"rounded=0;whiteSpace=wrap;html=1;\" parent=\"cy84TbzhjBedF44X58Xk-1\" vertex=\"1\">\n          <mxGeometry x=\"109\" y=\"493.5\" width=\"120\" height=\"60\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-4\" value=\"Workspace1\" style=\"rounded=0;whiteSpace=wrap;html=1;\" parent=\"cy84TbzhjBedF44X58Xk-1\" vertex=\"1\">\n          <mxGeometry x=\"109\" y=\"317\" width=\"120\" height=\"60\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-5\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;dashed=1;\" parent=\"cy84TbzhjBedF44X58Xk-1\" source=\"cy84TbzhjBedF44X58Xk-24\" target=\"cy84TbzhjBedF44X58Xk-27\" edge=\"1\">\n          <mxGeometry relative=\"1\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-6\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;entryPerimeter=0;\" parent=\"cy84TbzhjBedF44X58Xk-1\" source=\"cy84TbzhjBedF44X58Xk-8\" target=\"cy84TbzhjBedF44X58Xk-24\" edge=\"1\">\n          <mxGeometry relative=\"1\" as=\"geometry\">\n            <Array as=\"points\">\n              <mxPoint x=\"459\" y=\"595\" />\n              <mxPoint x=\"548\" y=\"595\" />\n            </Array>\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-7\" value=\"\" style=\"group\" parent=\"cy84TbzhjBedF44X58Xk-1\" vertex=\"1\" connectable=\"0\">\n          <mxGeometry x=\"437.5\" y=\"611\" width=\"43\" height=\"65\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-8\" value=\"Some user\" style=\"html=1;verticalLabelPosition=bottom;align=center;labelBackgroundColor=#ffffff;verticalAlign=top;strokeWidth=2;strokeColor=#0080F0;shadow=0;dashed=0;shape=mxgraph.ios7.icons.user;\" parent=\"cy84TbzhjBedF44X58Xk-7\" vertex=\"1\">\n          <mxGeometry x=\"6.5\" y=\"35\" width=\"30\" height=\"30\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-9\" value=\"group_users\" style=\"shape=table;startSize=30;container=1;collapsible=0;childLayout=tableLayout;strokeColor=default;fontSize=16;fontStyle=1\" parent=\"cy84TbzhjBedF44X58Xk-1\" vertex=\"1\">\n          <mxGeometry x=\"758\" y=\"589\" width=\"359\" height=\"118\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-10\" value=\"\" style=\"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=16;\" parent=\"cy84TbzhjBedF44X58Xk-9\" vertex=\"1\">\n          <mxGeometry y=\"30\" width=\"359\" height=\"44\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-11\" value=\"&lt;b&gt;group_id&lt;/b&gt;\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"cy84TbzhjBedF44X58Xk-10\" vertex=\"1\">\n          <mxGeometry width=\"179\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"179\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-12\" value=\"&lt;b&gt;user_id&lt;/b&gt;\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"cy84TbzhjBedF44X58Xk-10\" vertex=\"1\">\n          <mxGeometry x=\"179\" width=\"180\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"180\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-13\" value=\"\" style=\"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=16;\" parent=\"cy84TbzhjBedF44X58Xk-9\" vertex=\"1\">\n          <mxGeometry y=\"74\" width=\"359\" height=\"44\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-14\" value=\"id_org1_owner_grp\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"cy84TbzhjBedF44X58Xk-13\" vertex=\"1\">\n          <mxGeometry width=\"179\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"179\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-15\" value=\"id_some_user\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"cy84TbzhjBedF44X58Xk-13\" vertex=\"1\">\n          <mxGeometry x=\"179\" width=\"180\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"180\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-23\" value=\"\" style=\"group;labelBackgroundColor=default;labelBorderColor=none;\" parent=\"cy84TbzhjBedF44X58Xk-1\" vertex=\"1\" connectable=\"0\">\n          <mxGeometry x=\"523\" y=\"464\" width=\"50\" height=\"78\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-24\" value=\"&lt;font style=&quot;font-size: 16px;&quot;&gt;Org1 Owners&lt;/font&gt;\" style=\"sketch=0;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#505050;shape=mxgraph.mscae.intune.user_group;labelBackgroundColor=default;spacingTop=9;\" parent=\"cy84TbzhjBedF44X58Xk-23\" vertex=\"1\">\n          <mxGeometry y=\"41\" width=\"50\" height=\"37\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-25\" value=\"\" style=\"shape=image;html=1;verticalAlign=top;verticalLabelPosition=bottom;labelBackgroundColor=#ffffff;imageAspect=0;aspect=fixed;image=https://cdn0.iconfinder.com/data/icons/phosphor-fill-vol-2/256/crown-simple-fill-128.png\" parent=\"cy84TbzhjBedF44X58Xk-23\" vertex=\"1\">\n          <mxGeometry x=\"3.5\" width=\"43\" height=\"43\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-26\" value=\"\" style=\"group\" parent=\"cy84TbzhjBedF44X58Xk-1\" vertex=\"1\" connectable=\"0\">\n          <mxGeometry x=\"401\" y=\"281\" width=\"50\" height=\"83\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-27\" value=\"&lt;div style=&quot;font-size: 16px; padding-left: 0px; margin-top: 10px;&quot;&gt;&lt;font style=&quot;font-size: 16px;&quot;&gt;&lt;span style=&quot;background-color: rgb(255, 255, 255);&quot;&gt;Ws1 Owners&lt;/span&gt;&lt;/font&gt;&lt;/div&gt;\" style=\"sketch=0;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#505050;shape=mxgraph.mscae.intune.user_group\" parent=\"cy84TbzhjBedF44X58Xk-26\" vertex=\"1\">\n          <mxGeometry y=\"46\" width=\"50\" height=\"37\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"cy84TbzhjBedF44X58Xk-28\" value=\"\" style=\"shape=image;html=1;verticalAlign=top;verticalLabelPosition=bottom;labelBackgroundColor=#ffffff;imageAspect=0;aspect=fixed;image=https://cdn0.iconfinder.com/data/icons/phosphor-fill-vol-2/256/crown-simple-fill-128.png\" parent=\"cy84TbzhjBedF44X58Xk-26\" vertex=\"1\">\n          <mxGeometry x=\"3.5\" width=\"43\" height=\"43\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"Cej_1C5x5ezJ23L6Zn-K-0\" value=\"\" style=\"shape=image;html=1;verticalAlign=top;verticalLabelPosition=bottom;labelBackgroundColor=#ffffff;imageAspect=0;aspect=fixed;image=https://cdn4.iconfinder.com/data/icons/essentials-72/24/039_-_Cross-128.png\" parent=\"cy84TbzhjBedF44X58Xk-1\" vertex=\"1\">\n          <mxGeometry x=\"406\" y=\"434.5\" width=\"41\" height=\"41\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"Cej_1C5x5ezJ23L6Zn-K-2\" value=\"NEW\" style=\"dashed=0;html=1;rounded=1;strokeColor=#6554C0;fontSize=12;align=center;fontStyle=1;strokeWidth=2;fontColor=#6554C0\" parent=\"cy84TbzhjBedF44X58Xk-1\" vertex=\"1\">\n          <mxGeometry x=\"668\" y=\"445\" width=\"50\" height=\"20\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"Cej_1C5x5ezJ23L6Zn-K-6\" style=\"edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;\" parent=\"cy84TbzhjBedF44X58Xk-1\" source=\"cy84TbzhjBedF44X58Xk-24\" target=\"Cej_1C5x5ezJ23L6Zn-K-5\" edge=\"1\">\n          <mxGeometry relative=\"1\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"Cej_1C5x5ezJ23L6Zn-K-5\" value=\"&lt;div style=&quot;font-size: 16px; padding-left: 0px; margin-top: 10px;&quot;&gt;&lt;font style=&quot;font-size: 16px;&quot;&gt;&lt;span style=&quot;background-color: rgb(255, 255, 255);&quot;&gt;Ws1 Viewers&lt;/span&gt;&lt;/font&gt;&lt;/div&gt;&lt;div style=&quot;font-size: 16px; padding-left: 0px; margin-top: 10px;&quot;&gt;&lt;font style=&quot;font-size: 16px;&quot;&gt;&lt;span style=&quot;background-color: rgb(255, 255, 255);&quot;&gt;&lt;br&gt;&lt;/span&gt;&lt;/font&gt;&lt;/div&gt;\" style=\"sketch=0;pointerEvents=1;shadow=0;dashed=0;html=1;strokeColor=none;labelPosition=center;verticalLabelPosition=bottom;verticalAlign=top;align=center;fillColor=#505050;shape=mxgraph.mscae.intune.user_group\" parent=\"cy84TbzhjBedF44X58Xk-1\" vertex=\"1\">\n          <mxGeometry x=\"634\" y=\"327\" width=\"50\" height=\"37\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"rRc6SIQjJta77vA1fWc8-0\" value=\"group_groups\" style=\"shape=table;startSize=30;container=1;collapsible=0;childLayout=tableLayout;strokeColor=default;fontSize=16;fontStyle=1\" parent=\"cy84TbzhjBedF44X58Xk-1\" vertex=\"1\">\n          <mxGeometry x=\"758\" y=\"288\" width=\"359\" height=\"118\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"rRc6SIQjJta77vA1fWc8-1\" value=\"\" style=\"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=16;\" parent=\"rRc6SIQjJta77vA1fWc8-0\" vertex=\"1\">\n          <mxGeometry y=\"30\" width=\"359\" height=\"44\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"rRc6SIQjJta77vA1fWc8-2\" value=\"&lt;b&gt;group_id&lt;/b&gt;\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"rRc6SIQjJta77vA1fWc8-1\" vertex=\"1\">\n          <mxGeometry width=\"179\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"179\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"rRc6SIQjJta77vA1fWc8-3\" value=\"&lt;b&gt;subgroup_id&lt;/b&gt;\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"rRc6SIQjJta77vA1fWc8-1\" vertex=\"1\">\n          <mxGeometry x=\"179\" width=\"180\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"180\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"rRc6SIQjJta77vA1fWc8-4\" value=\"\" style=\"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=16;\" parent=\"rRc6SIQjJta77vA1fWc8-0\" vertex=\"1\">\n          <mxGeometry y=\"74\" width=\"359\" height=\"44\" as=\"geometry\" />\n        </mxCell>\n        <mxCell id=\"rRc6SIQjJta77vA1fWc8-5\" value=\"&lt;div&gt;&lt;strike&gt;&lt;b&gt;&lt;font color=&quot;#ff3333&quot;&gt;id_ws1_owner_grp&lt;/font&gt;&lt;/b&gt;&lt;/strike&gt;&lt;/div&gt;&lt;b&gt;&lt;font color=&quot;#00cc00&quot;&gt;id_ws1_viewers_grp&lt;/font&gt;&lt;/b&gt;\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"rRc6SIQjJta77vA1fWc8-4\" vertex=\"1\">\n          <mxGeometry width=\"179\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"179\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n        <mxCell id=\"rRc6SIQjJta77vA1fWc8-6\" value=\"id_org1_owner_grp\" style=\"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;fontSize=16;\" parent=\"rRc6SIQjJta77vA1fWc8-4\" vertex=\"1\">\n          <mxGeometry x=\"179\" width=\"180\" height=\"44\" as=\"geometry\">\n            <mxRectangle width=\"180\" height=\"44\" as=\"alternateBounds\" />\n          </mxGeometry>\n        </mxCell>\n      </root>\n    </mxGraphModel>\n  </diagram>\n</mxfile>\n"
  },
  {
    "path": "documentation/migrations.md",
    "content": "# Migrations and schema changes\n\nGrist uses [two types of databases](database.md):\n* The home database, which stores data for the entire Grist instance\n* Document databases, which each store data for an individual document\n\nIn code, these are governed by three database schemas, each responsible for different areas:\n* [/app/gen-server](/app/gen-server) - A TypeORM schema for the home database\n* [schema.py](/sandbox/grist/schema.py) - A python schema for `_grist` tables in the document database\n* [DocStorage.ts](/app/server/lib/DocStorage.ts) - A typescript schema for `_gristsys` tables in the document database\n\nEach of these has their own approach to changes and migrations, detailed below:\n\n## Document database\n### `_grist_*` tables\n\nIf you change Grist schema, i.e. the schema of the Grist metadata tables (in [`sandbox/grist/schema.py`](../sandbox/grist/schema.py)), you'll have to increment the `SCHEMA_VERSION` (on top of that file) and create a migration. A migration is a set of actions that would get applied to a document at the previous version, to make it satisfy the new schema.\n\nTypescript has its own copy of the schema at [schema.ts](/app/common/schema.ts), which needs updating whenever the python schema changes.\nThe process for this is described in [schema.ts](/app/common/schema.ts).\n\nTo add a migration, add a function to [`sandbox/grist/migrations.py`](/sandbox/grist/migrations.py), of this form (using the new version number):\n\n```python\n@migration(schema_version=11)\ndef migration11(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_Views_section', 'embedId', 'Text'),\n  ])\n```\n\nSome migrations need to actually add or modify the data in a document. You can look at other migrations in that file for examples.\n\nIf you are doing anything other than adding a column or a table, you must read this document to the end.\n\n### `_gristsys_*` tables\n\nThe schema and migrations for `_gristsys_*` tables are defined in [DocStorage.ts](/app/server/lib/DocStorage.ts).\n\nThese tables are managed by the Node.js server and not by the data engine.\n\nMigrations are implemented as functions which run raw SQLite queries against the database.\n\n### Philosophy of migrations\n\nMigrations are tricky. Normally, we think about the software we are writing, but migrations work with documents that were created by an older version of the software, which may not have the logic you think our software has, and MAY have logic that the current version knows nothing about.\n\nThis is why migrations code uses its own \"dumb\" implementation for loading and examining data (see [`sandbox/grist/table_data_set.py`](../sandbox/grist/table_data_set.py)), because trying to load an older document using our primary code base will usually fail, since the document will not satisfy our current assumptions.\n\n### Restrictions\n\nThe rules below should make it at least barely possible to share documents by people who are not all on the same Grist version (even so, it will require more work). It should also make it somewhat safe to upgrade and then open the document with a previous version.\n\nWARNING: Do not remove, modify, or rename metadata tables or columns.\n\nMark old columns and tables as deprecated using a comment. We may want to add a feature to mark them in code, to prevent their use in new versions. For now, it's enough to add a comment and remove references to the deprecated entities throughout code. An important goal is to prevent adding same-named entities in the future, or reusing the same column with a different meaning. So please add a comment of the form:\n\n```python\n# <columnName> is deprecated as of version XX. Do not remove or reuse.\n```\n\nTo justify keeping old columns around, consider what would happen if A (at version 10) communicates with B (at version 11). If column \"foo\" exists in v10, and is deleted in v11, then A may send actions that refer to \"foo\", and B would consider them invalid, since B's code has no idea what \"foo\" is. The solution is that B needs to still know about \"foo\", hence we don't remove old columns.\n\nSimilar justification applies to renaming columns, or modifying them (e.g. changing a type).\n\nWARNING: If you change the meaning or type of a column, you have to create a new column with a new name.\n\nYou'll also need to write a migration to fill it from the old column, and would mark the old column as deprecated.\n\n## Home database\n\nHome database migrations are handled by TypeORM ([documentation](https://typeorm.io/migrations)). \nThe migration files are located at [`app/gen-server/migration`](/app/gen-server/migration) and are run at startup (so you don't have to worry about running them yourself).\n"
  },
  {
    "path": "documentation/overview.md",
    "content": "# Overview of Grist Components\n\n## Example Setup\n\nGrist can be run as a single server, or as a composition of several. Here we describe a scalable setup used by Grist Labs. A single server would work fine for most individual organizations running Grist, but the concepts are still useful when working on the codebase.\n\n![Infrastructure](images/infrastructure.png)\n\nGrist Labs runs Grist as two primary kinds of servers: Home Servers and Doc Workers.\n\n* **Home Servers:** handle most user requests around documents, such as listing documents, checking access and sharing, handling API requests (forwarding to Doc Worker if needed), and more.\n* **Doc Workers:** responsible for in-document interactions. Each open document is assigned to a single Doc Worker. This Doc Worker has a local copy of the corresponding SQLite file containing the document data, and spawns a sandboxed python interpreter responsible for evaluating formulas in the document (the “data engine”).\n\nFor load balancing, Application Load Balancer (ALB) is used.\n\n* **ALB:** Application Load Balancer handles SSL, and forwards HTTP requests to Home Servers and in some cases (mainly for websockets) to Doc Workers.\n\nFor storage, Home Servers and Doc Workers rely on HomeDB, Redis, and S3.\n\n* **Home DB:** Postgres database (AWS RDS) containing information about users, orgs, workspaces, documents, sharing, and billing.\n* **S3:** used for long-term storage of documents. It is primarily used by Doc Workers, which fetch SQLite files from S3 when a doc gets opened, and sync back SQLite files regularly as the document is modified by the user. Grist also supports other S3-compatible stores through [MinIO](https://support.getgrist.com/install/cloud-storage/#s3-compatible-stores-via-minio-client).\n* **Redis:** Redis instance (AWS ElastiCache) keeps track of which Doc Workers are available, and which open documents are assigned to which Doc Workers.\n\n### Home Servers\n\nHome Servers have just one node process. They communicate with HomeDB, Redis, and Doc Workers. They don’t open documents or start subprocesses. Browser clients interact with them using plain HTTP requests.\n\nHome Servers also serve static files (e.g. the JS bundle with the entirety of the client-side app), and can be configured to serve assets from an external source (e.g. AWS CloudFront and S3).\n\n### Doc Workers\n\nDoc Workers deal with documents. They bring documents (SQLite files) from S3 to local disk, start a sandboxed Python interpreter for each one, and communicate with it to apply changes to a document. Browsers interact with Doc Workers directly via websockets. When browsers make doc-specific HTTP API requests, those get forwarded to Doc Workers via Home Servers.\n\n![Doc Worker](images/doc-worker.png)\n\n## Loading Documents\n\nWhen a user opens a document, the Home Server is responsible for picking a Doc Worker. Once a document is assigned to a Doc Worker, other users (browser tabs) opening the same document will be serviced by the same worker.\n\nWhen a Doc Worker is assigned a document, it brings a copy of the backing SQLite file (also known as `.grist` file) from S3 to local disk. It instantiates an ActiveDoc object in Node, which connects various components while the document is open.\n\nOn open, Doc Worker starts up a data engine process. This is a Python process, executed in a sandboxed environment because it runs user formulas (which can be arbitrary Python code). This process remains active as long as the doc is open on this Doc Worker.\n\nOn open, all document data gets read from SQLite file and loaded into the Python data engine. The data engine doesn’t have direct access to the SQLite file. (Loading data fully into memory is limiting and not great, but that’s what happens today. There is one exception: tables marked as [“on-demand” tables](https://support.getgrist.com/on-demand-tables/) are not loaded into the data engine.)\n\nA typical client, from Grist’s point of view, is a browser tab or an API client. A browser tab differs from an API client by communicating via a websocket, and receiving updates when data changes. Calls from either kind of client are translated into method calls on [ActiveDoc](#server-side).\n\nWhen a doc is opened, the browser requests the full content of metadata tables (these are all the tables starting with the `_grist_` prefix). Other tables are fetched in full when needed to display data. Once fetched, data is maintained in memory in the browser using the updates that come via websocket. In practice, this means that data is fully in memory, once in the Python data engine, and also in the Javascript environment of each tab that has the document open. (Again, on-demand tables are an exception.)\n\n## Changes to documents\n\nA user-initiated change to a document is sent to the server as a **User Action**. Most of these are simple data changes such as `UpateRecord`, `AddRecord`, `RenameTable`, etc. There are some fancier ones like `CreateViewSection`. Here is a typical life of a User Action:\n\n* Created in the frontend client as the effect of some actual action by the user, like typing into a cell.\n* Sent from the client to Node via WebSocket.\n* Forwarded from Node to Python data engine.\n* Converted by Python data engine into a series of **Doc Actions**. Doc Actions are only simple data or schema changes. These include results of formula calculations. These Doc Actions update the in-memory representation of the doc inside the data engine, and get returned to Node.\n* Node translates Doc Actions to SQL to update the local SQLite file. (It also periodically syncs this file to S3.)\n* Node forwards the Doc Actions to all browsers connected via websocket, including the client that sent the action originally. All clients update their in-memory representation of the doc using these Doc Actions.\n* Node responds to the original client with return value of the action (e.g. rowId of an added record).\n\nThe authoritative list of available User Actions is the list of all the methods of [`sandbox/grist/useractions.py`](../sandbox/grist/useractions.py) with `@useraction` decorator.\n\nDoc Actions are handled both in Python and Node. Here is the full list of Doc Actions:\n\n```typescript\n// Data Actions\nexport type AddRecord = ['AddRecord', string, number, ColValues];\nexport type BulkAddRecord = ['BulkAddRecord', string, number[], BulkColValues];\nexport type RemoveRecord = ['RemoveRecord', string, number];\nexport type BulkRemoveRecord = ['BulkRemoveRecord', string, number[]];\nexport type UpdateRecord = ['UpdateRecord', string, number, ColValues];\nexport type BulkUpdateRecord = ['BulkUpdateRecord', string, number[], BulkColValues];\n\nexport type ReplaceTableData = ['ReplaceTableData', string, number[], BulkColValues];\n\n// This is the format in which data comes when we fetch a table from the sandbox.\nexport type TableDataAction = ['TableData', string, number[], BulkColValues];\n\n// Schema Actions\nexport type AddColumn = ['AddColumn', string, string, ColInfo];\nexport type RemoveColumn = ['RemoveColumn', string, string];\nexport type RenameColumn = ['RenameColumn', string, string, string];\nexport type ModifyColumn = ['ModifyColumn', string, string, ColInfo];\n\nexport type AddTable = ['AddTable', string, ColInfoWithId[]];\nexport type RemoveTable = ['RemoveTable', string];\nexport type RenameTable = ['RenameTable', string, string];\n```\n\nData actions take a string `tableId` and a numeric `rowId` (or a list of them, for “bulk” actions) and a set of values:\n\n```typescript\nexport interface ColValues { [colId: string]: CellValue; }\nexport interface BulkColValues { [colId: string]: CellValue[]; }\n```\n\nIn case of “bulk” actions, note that the values are column oriented.\n\nNote also that all Doc Actions are themselves valid User Actions, i.e. User Actions are a superset of Doc Actions. User Actions, however, are less strict. For example, the User Actions `AddRecord` is typically used with a rowId of `null`; on processing it, the data engine picks the next unused rowId, and produces a similar `AddRecord` Doc Action, with an actual number for rowId.\n\n## Codebase Overview\n\n### Server Side\n\nMost server code lives in [**`app/server`**](../app/server/).\n\n* [`app/server/lib/`**`FlexServer.ts`**](../app/server/lib/FlexServer.ts)\n    Sets up Express endpoints and initializes all other components to run the home server or doc worker or to serve static files. Hence the “Flex” in the name. The home servers and doc workers run using the same code, and parameters and environment variables determine which type of server it will be. It’s possible to run all servers in the same process.\n* [`app/server/lib/`**`ActiveDoc.ts`**](../app/server/lib/ActiveDoc.ts)\n    The central dispatcher for everything related to an open document — it connects NSandbox, DocStorage, GranularAccess components (described below), as well as connected clients, and shuttles user actions and doc actions between them.\n* [`app/server/lib/`**`GranularAccess.ts`**](../app/server/lib/GranularAccess.ts)\n    Responsible for granular access control. It checks user actions coming from clients before they are sent to the data engine, then again after the data engine translates them (reversing them if needed), and filters what gets sent to the clients based on what they have permission to see.\n* [`app/server/lib/`**`NSandbox.ts`**](../app/server/lib/NSandbox.ts)\n    Starts a subprocess with a sandboxed Python process running the data engine, and sets up pipes to and from it to allow making RPC-like calls to the data engine.\n* [`app/server/lib/`**`DocStorage.ts`**](../app/server/lib/DocStorage.ts)\n    Responsible for storing Grist data in a SQLite file. It satisfies fetch requests by retrieving data from SQLite, and knows how to translate every Doc Action into suitable SQL updates.\n* [`app/server/lib/`**`HostedStorageManager.ts`**](../app/server/lib/HostedStorageManager.ts)\n    Responsible for getting files to and from storage, syncing docs to S3 (or an S3-compatible store) when they change locally, and creating and pruning snapshots.\n\nSome code related to the Home DB lives in [**`app/gen-server`**](../app/gen-server/).\n\n* [`app/gen-server/lib/homedb/`**`HomeDBManager.ts`**](../app/gen-server/lib/homedb/HomeDBManager.ts)\n    Responsible for dealing with HomeDB: it handles everything related to sharing, as well as finding, listing, updating docs, workspaces, and orgs (aka “team sites”). It also handles authorization needs — checking what objects a user is allowed to access, looking up users by email, etc.\n\n### Common\n\nThe [**`app/common`**](../app/common/) directory contains files that are included both in the server-side, and in the client-side JS bundle. It’s an assortment of utilities, libraries, and typings.\n\n* [`app/common/`**`TableData.ts`**](../app/common/TableData.ts)\n    Maintains data of a Grist table in memory, and knows how to apply Doc Actions to it to keep it up-to-date.\n* [`app/common/`**`DocData.ts`**](../app/common/DocData.ts)\n    Maintains a set of TableData objects, in other words all data for a Grist document, including the logic for applying Doc Actions to keep the in-memory data up-to-date.\n* [`app/common/`**`gutil.ts`**](../app/common/gutil.ts)\n    Assorted functions and helpers like `removePrefix`, `countIf`, or `sortedIndex`.\n\n### Client Side\n\nMuch of the application is on the browser side. The code for that all lives in [`app/client`](../app/client/). It uses some lower-level libraries for working with DOM, specifically GrainJS (https://github.com/gristlabs/grainjs#documentation). Older code uses knockout and some library files that are essentially a precursor to GrainJS. These live in [`app/client/lib`](../app/client/lib/). See also [GrainJS & Grist Front-End Libraries](grainjs.md).\n\n* [**`app/client/models`**](../app/client/models/)\n    Contains modules responsible for maintaining client-side data.\n    * [`app/client/models/`**`TableData.ts`**](../app/client/models/TableData.ts), [`app/client/models/`**`DocData.ts`**](../app/client/models/DocData.ts)\n        Enhancements of same-named classes in [`app/common`](../app/common/) (see [above](#common)) which add some client-side functionality like helpers to send User Actions.\n    * [`app/client/models/`**`DocModel.ts`**](../app/client/models/DocModel.ts)\n        Maintains *observable* data models, for all metadata and user data tables in a document. For metadata tables, the individual records are enhanced to be specific to each type of metadata, using classes in [`app/client/models/entities`](../app/client/models/entities/). For example, `docModel.columns` is a `MetaTableModel` containing records of type `ColumnRec` (from [`app/client/models/entities/ColumnRec.ts`](../app/client/models/entities/ColumnRec.ts)) which are derived from `MetaRowModel`.\n    * [`app/client/models/`**`TableModel.js`**](../app/client/models/TableModel.js)\n        Base class for `MetaTableModel` and `DataTableModel`. It wraps `TableData` to make the data observable, i.e. to make it possible to subscribe to changes in it. This is the basis for how we build most UI.\n    * [`app/client/models/`**`MetaTableModel.js`**](../app/client/models/MetaTableModel.js)\n        Maintains data for a metadata table, making it available as observable arrays of `MetaRowModel`s. The difference between metadata tables and user tables is that the Grist app knows what’s in metadata, and relies on it for its functionality. We also assume that metadata tables are small enough that we can instantiate [observables](https://github.com/gristlabs/grainjs/blob/master/docs/basics.md#observables) for all fields of all rows.\n    * [`app/client/models/`**`DataTableModel.js`**](../app/client/models/DataTableModel.js)\n        Maintains data for a user table, making it available as `LazyArrayModel`s (defined in the same file), which are used as the basis for `koDomScrolly` (see `app/client/lib/koDomScrolly.js` below).\n    * [`app/client/models/`**`BaseRowModel.js`**](../app/client/models/BaseRowModel.js)\n        An observable model for a record (aka row) of a user-data or metadata table. It takes a reference to the containing TableModel, a rowId, and a list of column names, and creates an observable for each field.\n    * [`app/client/models/`**`MetaRowModel.js`**](../app/client/models/MetaRowModel.js)\n        Extends BaseRowModel for built-in (metadata) tables. It has an observable for every field, and in addition gets enhanced with various table-specific [computeds](https://github.com/gristlabs/grainjs/blob/master/docs/basics.md#computed-observables) and methods. Each module in [`app/client/models/entities/`](../app/client/models/entities/) becomes an extension of a `MetaRowModel`.\n    * [`app/client/models/`**`DataRowModel.ts`**](../app/client/models/DataRowModel.ts)\n        Extends BaseRowModel for user tables. There are few assumption we can make about those, so it adds little, and is mainly used for the observables it creates for each field. These observables are extended with a “save interface”, so that calling `field.save()` will translate to sending an action to the server. Note that `BaseRowModel` are not instantiated for *all* rows of a table, but only for the visible ones. As a table is scrolled, the same `BaseRowModel` gets updated to reflect a new row, so that the associated DOM gets updated rather than rebuilt (and is moved around to where it’s expected to be in the scrolled position).\n    * [**`app/client/models/entities/`**](../app/client/models/entities/)\n        Table-specific extensions of `MetaRowModel`, such as `ColumnRec`, `ViewFieldRec`, `ViewSectionRec`, etc.\n* **[`app/client/ui`](../app/client/ui/), [`app/client/components`](../app/client/components/), [`app/client/ui2018`](../app/client/ui2018/)**\n    For obscure reasons, client-side components are largely shuffled between these three directories. There isn’t a clear rule where to put things, but most new components are placed into `app/client/ui`.\n    * [`app/client/components/`**`GristDoc.ts`**](../app/client/components/GristDoc.ts)\n        The hub for everything related to an open document, similar to ActiveDoc on the server side. It contains the objects for communicating with the server, objects containing the in-memory data, it knows the currently active page, cursor, etc.\n    * [`app/client/components/`**`GridView.js`**](../app/client/components/GridView.js)\n        The component for the most powerful “page widget” we have: the mighty grid. It’s one of the oldest pieces of code in Grist. And biggest. In code, we often refer to “page widgets” (like grid) as “view sections”, and sometimes also as just “views” (hence “GridView”).\n    * [`app/client/components/`**`BaseView.js`**](../app/client/components/BaseView.js)\n        Base component for all page widgets: GridView, DetailView (used for Cards and Card Lists), ChartView, and CustomView. It’s takes care of setting up various data-related features, such as column filtering and link-filtering, and has some other state and methods shared by different types of page widgets.\n    * [`app/client/components/`**`Comm.ts`**](../app/client/components/Comm.ts) and [`app/client/components/`**`DocComm.ts`**](../app/client/components/DocComm.ts)\n        Implement communication with the NodeJS Doc Worker via websocket; specifically they implements an RPC-like interface, so that client-side code can call methods such as `applyUserActions`.\n    * [`app/client/components/`**`GristWSConnection.ts`**](../app/client/components/GristWSConnection.ts)\n        Implements the lower-level websocket communication, including finding the Doc Worker’s address, connecting the websocket, and reconnecting on disconnects.\n    * [`app/client/ui/`**`UserManager.ts`**](../app/client/ui/UserManager.ts)\n        Implements the UI component for managing the access of users to a document, workspace, or team site.\n* [**`app/client/aclui`**](../app/client/aclui/)\n    Contains the pieces of the UI component for editing granular access control rules.\n* [**`app/client/lib`**](../app/client/lib/)\n    Contains lower-level utilities and widgets. Some highlights:\n    * [`app/client/lib/`**`autocomplete.ts`**](../app/client/lib/autocomplete.ts)\n        The latest of the several autocomplete-like dropdowns we’ve used. It’s what’s used for the dropdowns in Reference columns, for example.\n    * [`app/client/lib/`**`TokenField.ts`**](../app/client/lib/TokenField.ts)\n        Our own token-field library, used for ChoiceList columns.\n    * [`app/client/lib/`**`dom.js`**](../app/client/lib/dom.js), [**`koDom.js`**](../app/client/lib/koDom.js), [**`koArray.js`**](../app/client/lib/koArray.js)\n        Utilities superceded by GrainJS but still used by a bunch of code.\n    * [`app/client/lib/`**`koDomScrolly.js`**](../app/client/lib/koDomScrolly.js)\n        A special beast used for scrolling a very long list of rows by limiting rendering to those that are visible, and trying to reuse the rendered DOM as much as possible. It is a key component of grid and card-list views that allows them to list tens of thousands of rows fairly easily.\n* [`app/client/widgets`](../app/client/widgets/)\n    Contains code for cell widgets, such as `TextBox`, `CheckBox`, or `DateTextBox`, and for the corresponding editors, such as `TextEditor`, `DateEditor`, `ReferenceEditor`, `FormulaEditor`, etc.\n    * [`app/client/widgets/`**`FieldBuilder.ts`**](../app/client/widgets/FieldBuilder.ts)\n        A FieldBuilder is created for each column to render the cells in it (using a widget like `TextBox`), as well as to render the column-specific configuration UI, and to instantiate the editor for this cell when the user starts to edit it.\n    * [`app/client/widgets/`**`FieldEditor.ts`**](../app/client/widgets/FieldEditor.ts)\n        Instantiated when the user starts editing a cell. It creates the actual editor (like `TextEditor`), and takes care of various logic that’s shared between editors, such as handling Enter/Escape commands, and actually saving the updated value.\n\n### Python Data Engine\n\nUser-created formulas are evaluated by Python in a process we call the “data engine”, or the “sandbox” (since it runs in a sandboxed environment). Its job is to evaluate formulas and also keep track of dependencies, so that when a cell changes, all affected formula can be automatically recalculated.\n\n* [**`sandbox/grist/`**](../sandbox/grist/)\n    Contains all data engine code.\n    * [`sandbox/grist/`**`engine.py`**](../sandbox/grist/engine.py)\n        Central class for the documents data engine. It has the implementation of most methods that Node can call, and is responsible to dispatch User Actions, evaluate formulas, and collect the resulting Doc Actions.\n    * [`sandbox/grist/`**`useractions.py`**](../sandbox/grist/useractions.py)\n        Contains the implementation of all User Actions. Even simple ones require some work (e.g. a user should not manually set values to a formula column). Actions to metadata tables often trigger other work — e.g. updating metadata for a column may produce an additional schema action such as `RenameColumn` for the user table that corresponds to the metadata. Other complex User Actions (such as `CreateViewSection`) are implemented here because it’s easier and allows for simple single-step undos.\n"
  },
  {
    "path": "documentation/translations.md",
    "content": "# Internationalization and Localization\n\n## General description\n\nLocalization support (translations) in Grist is implemented via\n[https://www.i18next.com](https://www.i18next.com/overview/plugins-and-utils) javascript library. It\nis used both on the server (node) and the client side (browser). It has very good documentation,\nsupports all needed features (like interpolation, pluralization and context), and has a rich plugin\necosystem. It is also very popular and widely used.\n\n## Localization setup\n\nResource files are located in a `static/locales` directory, but Grist can be configured to read them\nfrom any other location by using the `GRIST_LOCALES_DIR` environmental variable. All resource files\nare read when the server starts. The default and required language code is `en` (English), all other\nlanguages are optional and will be supported if server can find a resource file with proper language\ncode. Languages are resolved hierarchically, from most specific to a general one, for example, for\nPolish code _pl-PL_, the library will first try _pl-PL_, then _pl_, and then will fallback to a\ndefault language _en_ (https://www.i18next.com/principles/translation-resolution).\n\nAll language variants (e.g., _fr-FR_, _pl-PL_, _en-UK_) are supported if Grist can find a main\nlanguage resource file. For example, to support a _fr-FR_ language code, Grist expects to have at\nleast _fr.client.json_ file. The main language file will be used as a default fallback for all French\nlanguage codes like _fr-FR_ or _fr-CA_, in case there is no resource file for a specif variant (like\n`fr-CA.client.json`) or some keys are missing from the variant file.\n\nHere is an example of a language resource file `en.client.json` currently used by Grist:\n\n```json\n{\n  \"AddNewButton\": {\n    \"AddNew\": \"Add New\"\n  },\n  \"DocMenu\": {\n    \"OtherSites\": \"Other Sites\",\n    \"OtherSitesWelcome\": \"You are on the {{siteName}} site. You also have access to the following sites:\",\n    \"OtherSitesWelcome_personal\": \"You are on your personal site. You also have access to the following sites:\",\n    \"AllDocuments\": \"All Documents\",\n    \"ExamplesAndTemplates\": \"Examples and Templates\",\n    \"MoreExamplesAndTemplates\": \"More Examples and Templates\"\n  },\n  \"HomeIntro\": {\n    \"Welcome\": \"Welcome to Grist!\",\n    \"SignUp\": \"Sign up\"\n  }\n}\n```\n\nIt maps a key to a translated message. It also has an example of interpolation and context features\nin the `DocMenu.OtherSitesWelcome` resource key. More information about how to use those features can be\nfound at https://www.i18next.com/translation-function/interpolation and\nhttps://www.i18next.com/translation-function/context.\n\nClient and server code (node.js) use separate resource files. A resource file name format\nfollows a pattern: [language code].[product].json (e.g. `pl-Pl.client.json`, `en-US.client.json`,\n`en.client.json`). Grist can be packaged as several different products, and each product can have its\nown translation files that are added to the core. Products are supported by leveraging `i18next`\nfeature called `namespaces` https://www.i18next.com/principles/namespaces.\n\nFor now we use only two products called `client` and `server`.\nEach of them is then organized by filename, in order to avoid conflicts.\n\n## Translation instruction\n\n### Client\n\nThe entry point for all translations is a function exported from 'app/client/lib/localization'.\n\n```ts\nimport {t} from 'app/client/lib/localization';\n```\n\nIt is a wrapper around `i18next` exported method with the same interface\nhttps://www.i18next.com/overview/api#t. As a future improvement, all resource keys used in\ntranslation files will be extracted and converted to a TypeScript definition file, for a “compile”\ntime error detection and and better development experience. Here are couple examples how this method\nis used:\n\n_app/client/ui.DocMenu.ts_\n\n```ts\n  css.otherSitesHeader(\n    t('DocMenu.OtherSites'),\n    .....\n  ),\n  dom.maybe((use) => !use(hideOtherSitesObs), () => {\n    const personal = Boolean(home.app.currentOrg?.owner);\n    const siteName = home.app.currentOrgName;\n    return [\n      dom('div',\n        t('DocMenu.OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }),\n        testId('other-sites-message')\n```\n\n_app/client/ui/HomeIntro.ts_\n\n```ts\nfunction makeAnonIntro(homeModel: HomeModel) {\n  const signUp = cssLink({href: getLoginOrSignupUrl()}, t('HomeIntro.SignUp'));\n  return [\n    css.docListHeader(t('HomeIntro.Welcome'), testId('welcome-title')),\n```\n\n\nFunction `t` on the client side is also able to use `DomContents` values (so material produced by\nthe GrainJS library) for interpolation. For example:\n\n```ts\ndom('span', t('Argument', {\n  arg1: dom('span', 'First'),\n  arg2: dom.domComputed(obs, (value) => dom('span', value))\n}));\n```\n\nSome things are not supported at this moment and will need to be addressed in future development\ntasks:\n\n- Date time picker component. It has its own resource files that are already imported by Grist but\n  not used in the main application. https://bootstrap-datepicker.readthedocs.io/en/latest/i18n.html\n- Static HTML files used as a placeholder (for example, for Custom widgets).\n- Formatting dates. Grist is using `moment.js` library, which has its own i18n support. Date formats\n  used by Grist are shared between client, server and sandbox code and are not compatible with\n  `i18next` library.\n\n### Server\n\nFor server-side code, Grist is using https://github.com/i18next/i18next-http-middleware plugin,\nwhich exposes `i18next` API in the `Request` object. It automatically detects user language (from\nrequest headers) and configures all API methods to use the proper language (either requested by the\nclient or a default one). `Comm` object and `webSocket` API use a very similar approach, each\n`Client` object has its own instance of `i18next` library configured with a proper language (also\ndetected from the HTTP headers).\n\nNaturally, most of the text that should be translated on the server side is used by the Error\nhandlers. This requires a significant amount of work to change how errors are reported to the\nclient, and it is still in a design state.\n\nHere is an example of how to use the API to translate a message from an HTTP endpoint in\n`HomeServer`.\n\n_app/server/lib/sendAppPage.ts_\n\n```ts\nfunction getPageTitle(req: express.Request, config: GristLoadConfig): string {\n  const maybeDoc = getDocFromConfig(config);\n  if (!maybeDoc) {\n    return req.t('sendAppPage.Loading') + '...';\n  }\n\n  return handlebars.Utils.escapeExpression(maybeDoc.name);\n}\n```\n\n## Translation generation\n\nIn order to collect the keys, so they are added to the `en.client.json` file, you may run the following steps:\n1. Call the `t()` function as stated above\n2. Build the project using `yarn build` (or `yarn start`)\n3. Call the command `yarn generate:translation`, which scans the source in javascript (hence the `yarn build` above), and adds the missing keys in `en.client.json`\n\nYou may change locally the translations to check whether the localized strings are taken into account and revert afterwards.\n\n### Next steps\n\n- Annotate all client code and create all resource files in `en.client.json` and `en.server.json` files.\n  Almost all static text is ready for translation.\n- Store language settings with the user profile and allow a user to change it on the Account Page.\n  Consider also adding a cookie-based solution that custom widgets can use, or extend the\n  **WidgetFrame** component so that it can pass current user language to the hosted widget page.\n- Generate type declaration files at build time to provide `missing key` error detection as soon as\n  possible.\n- Dynamically Include calendar control language resource files based on the currently selected\n  language.\n- Refactor server-side code that is handling errors or creating user-facing messages. Currently,\n  error messages are created at the place where the Error has occurred. Preferably errors should\n  include error codes and all information needed to assemble the error message by the client code.\n- Add localization support to the `moment.js` library to format dates properly according to the\n  currently selected language.\n- Add support for custom HTML page translation. For example `custom-widget.html`\n"
  },
  {
    "path": "documentation/urls.md",
    "content": "Document URLs\n-----------------\n\nStatus: WIP\n\nOptions\n * An id (e.g. google)\n * Several ids (e.g. airtable)\n * A text name\n * Several text names (e.g. github)\n * An id and friendly name (e.g. dropbox)\n\nLeaning towards an id and friendly name.  Only id is interpreted by router.  Name is checked only to make sure it matches current name of document.  If not, we redirect to revised url before proceeding.\n\nLength of ids depends on whether we'll be using them for obscurity to enable anyone-who-has-link-can-view style security.\n\nPossible URLs\n---------------\n\n * docs.getgrist.com/viwpHfmtMHmKBUSyh/Document+Name\n * orgname.getgrist.com/viwpHfmtMHmKBUSyh/Document+Name\n * getgrist.com/d/viwpHfmtMHmKBUSyh/Document+Name\n * getgrist.com/d/tblWVZDtvlsIFsuOR/viwpHfmtMHmKBUSyh/Document+Name\n * getgrist.com/d/dd5bf494e709246c7601e27722e3aee656b900082c3f5f1598ae1475c35c2c4b/Document+Name\n * getgrist.com/doc/fTSIMrZT3fDTvW7XDBq1b7nhWa24Zl55EVpsaO3TBBE/Document%20Name\n\nOrganization subdomains\n------------------------------\nOrganizations get to choose a subdomain, and will access their workspaces and documents at `orgname.getgrist.com`. In addition, personal workspaces need to be uniquely determined by a URL, using `docs-` followed by the numeric id of the \"personal organization\":\n\n* docs-1234.getgrist.com/\n* docs.getgrist.com/o/docs-1234/\n\nSince subdomains need to play along with all the other subdomains we use for getgrist.com, the following is a list of names that may NOT be used by any organization:\n\n* `docs-\\d+` to identify personal workspaces\n* Anything that starts with underscore (`_`) (this includes special subdomains like `_domainkey`)\n* Subdomains used by us for various purposes. As of 2018-10-09, these include:\n  * aws\n  * gristlogin\n  * issues \n  * metrics\n  * phab\n  * releases\n  * test\n  * vpn\n  * www\n\nSome more reserved subdomains:\n * doc-worker-NN\n * v1-* (this could be released eventually, but currently in our code and/or routing \"v1-mock\", \"v1-docs\", \"v1-static\", and any other \"v1-*\" are special\n * docs\n * api\n"
  },
  {
    "path": "eslint.config.js",
    "content": "const globals = require(\"globals\");\nconst path = require(\"path\");\n\nconst babelParser = require(\"@babel/eslint-parser\");\nconst js = require(\"@eslint/js\");\nconst stylistic = require(\"@stylistic/eslint-plugin\");\nconst tsParser = require(\"@typescript-eslint/parser\");\nconst typescriptEslint = require(\"@typescript-eslint/eslint-plugin\");\nconst { defineConfig, globalIgnores } = require(\"eslint/config\");\nconst { importX } = require(\"eslint-plugin-import-x\");\n\nconst projectRoot = process.cwd();\n\nmodule.exports = defineConfig([\n  globalIgnores([\n    \"*\",\n    \"!app/\",\n    \"app/**/*-ti.ts\",\n    \"ext/**/*-ti.ts\",\n    \"!test/\",\n    \"!plugins/\",\n    \"!sandbox/\",\n    \"sandbox/pyodide/_build/\",\n    \"!stubs/\",\n    \"!buildtools/\",\n    \"!ext/\",\n    \"!core/\",\n    \"core/static/*.js\",\n    \"test/video-scripts/\",\n  ]),\n  {\n    extends: [\n      \"js/recommended\"\n    ],\n\n    plugins: {\n      js,\n      \"@stylistic\": stylistic\n    },\n\n    languageOptions: {\n      globals: {\n        ...globals.node,\n        ...globals.mocha,\n        globalThis: true,\n      },\n\n      parser: babelParser,\n      ecmaVersion: 2018,\n\n      parserOptions: {\n        requireConfigFile: false,\n      },\n    },\n\n    rules: {\n      \"no-prototype-builtins\": \"off\",\n      \"no-unused-vars\": [\"error\", {\n        args: \"none\",\n        caughtErrors: \"none\",\n      }],\n\n      \"@stylistic/brace-style\": [\"error\", \"1tbs\", { allowSingleLine: true }],\n      \"@stylistic/comma-spacing\": \"error\",\n      \"@stylistic/indent\": [\"error\", 2],\n      \"@stylistic/no-trailing-spaces\": \"error\",\n      \"@stylistic/quotes\": [\"error\", \"double\", { avoidEscape: true, allowTemplateLiterals: \"always\" }],\n      \"@stylistic/semi-spacing\": \"error\",\n      \"@stylistic/semi\": [\"error\", \"always\"],\n    },\n  }, {\n    files: [\"**/*.ts\"],\n\n    extends: [\n      \"js/recommended\",\n      \"@stylistic/recommended\",\n      \"@typescript-eslint/recommended\",\n      \"@typescript-eslint/recommended-type-checked\",\n      \"@typescript-eslint/stylistic-type-checked\",\n    ],\n\n    languageOptions: {\n      parser: tsParser,\n      sourceType: \"module\",\n      ecmaVersion: 2018,\n\n      parserOptions: {\n        tsconfigRootDir: projectRoot,\n        project: [\n          path.join(projectRoot, \"tsconfig.eslint.json\"),\n        ],\n      },\n\n      globals: {\n        ...globals.node,\n        ...globals.browser,\n        ...globals.mocha,\n        Promise: true,\n        globalThis: true\n      },\n    },\n\n    plugins: {\n      js,\n      \"@typescript-eslint\": typescriptEslint,\n      \"@stylistic\": stylistic,\n      \"@import-x\": importX,\n    },\n\n    ignores: [\n      \"*\",\n      \"!app\",\n      \"!test\",\n      \"!plugins\",\n      \"!buildtools\",\n      \"!stubs\",\n    ],\n\n    settings: {\n      \"import-x/internal-regex\": \"^(app|test|stubs|plugins)/\"\n    },\n\n    rules: {\n      \"curly\": [\"error\", \"all\"],\n      \"no-console\": \"off\",\n      \"no-inner-declarations\": \"off\",\n      \"no-prototype-builtins\": \"off\",\n      \"no-restricted-imports\": [\"error\", {\n        patterns: [{\n          group: [\"./\", \"../\"],\n          message: \"Relative imports are not allowed\",\n        }],\n      }],\n      \"no-shadow\": \"off\",\n      \"no-undef\": \"off\",\n      \"no-unused-expressions\": [\"error\", {\n        allowShortCircuit: true,\n        allowTernary: true,\n      }],\n      \"prefer-rest-params\": \"off\",\n\n      \"@import-x/order\": [\n        \"error\",\n        {\n          \"newlines-between\": \"always\",\n          \"groups\": [\n            [\"internal\", \"index\"],\n            \"builtin\",\n            \"external\",\n            \"type\",\n          ],\n          \"named\": { enabled: true, import: true, export: false },\n          \"alphabetize\": {\n            order: \"asc\",\n            caseInsensitive: true,\n          },\n        },\n      ],\n\n      \"@stylistic/block-spacing\": [\"error\", \"always\"],\n      \"@stylistic/brace-style\": [\"error\", \"1tbs\", { allowSingleLine: true }],\n      \"@stylistic/comma-spacing\": \"error\",\n      \"@stylistic/function-call-spacing\": \"error\",\n      \"@stylistic/indent\": [\"error\", 2],\n      \"@stylistic/keyword-spacing\": \"error\",\n      \"@stylistic/max-len\": [\"error\", {\n        code: 120,\n        ignoreUrls: true,\n        ignoreRegExpLiterals: true,\n        ignoreTemplateLiterals: true,\n      }],\n      \"@stylistic/operator-linebreak\": [\"error\", \"after\"],\n      \"@stylistic/no-multi-spaces\": \"off\",\n      \"@stylistic/no-trailing-spaces\": \"error\",\n      \"@stylistic/no-whitespace-before-property\": \"error\",\n      \"@stylistic/quotes\": [\"error\", \"double\", {\n        avoidEscape: true,\n        allowTemplateLiterals: \"always\",\n      }],\n      \"@stylistic/max-statements-per-line\": \"off\",\n      \"@stylistic/multiline-ternary\": \"off\",\n      \"@stylistic/semi\": [\"error\", \"always\"],\n      \"@stylistic/semi-spacing\": \"error\",\n      \"@stylistic/space-before-function-paren\": [\"error\", {\n        anonymous: \"never\",\n        named: \"never\",\n        asyncArrow: \"always\",\n        catch: \"always\"\n      }],\n      \"@stylistic/space-infix-ops\": \"error\",\n      \"@stylistic/spaced-comment\": \"error\",\n      \"@stylistic/switch-colon-spacing\": \"error\",\n      \"@stylistic/type-annotation-spacing\": \"error\",\n\n      \"@typescript-eslint/ban-types\": \"off\",\n      \"@typescript-eslint/consistent-indexed-object-style\": \"off\",\n      \"@typescript-eslint/dot-notation\": [\"error\", {\n        allowPrivateClassPropertyAccess: true,\n        allowProtectedClassPropertyAccess: true,\n      }],\n      \"@typescript-eslint/explicit-member-accessibility\": [\"error\", {\n        overrides: {\n          constructors: \"off\",\n        },\n      }],\n      \"@typescript-eslint/explicit-module-boundary-types\": \"off\",\n      \"@typescript-eslint/member-ordering\": [\"error\", {\n        default: [\n          \"public-static-field\",\n          \"public-static-method\",\n          \"protected-static-field\",\n          \"private-static-field\",\n          \"static-field\",\n          \"protected-static-method\",\n          \"private-static-method\",\n          \"static-method\",\n          \"public-field\",\n          \"protected-field\",\n          \"private-field\",\n          \"field\",\n          \"public-constructor\",\n          \"protected-constructor\",\n          \"private-constructor\",\n          \"constructor\",\n          \"public-method\",\n          \"protected-method\",\n          \"private-method\",\n          \"method\",\n        ],\n      }],\n      \"@typescript-eslint/naming-convention\": [\"error\", {\n        selector: \"memberLike\",\n\n        filter: {\n          match: false,\n          regex: \"(listenTo)\",\n        },\n\n        modifiers: [\"private\"],\n        format: [\"camelCase\"],\n        leadingUnderscore: \"require\",\n      }],\n      \"@typescript-eslint/no-empty-function\": \"off\",\n      \"@typescript-eslint/no-explicit-any\": \"off\",\n      \"@typescript-eslint/no-inferrable-types\": \"off\",\n      \"@typescript-eslint/no-misused-promises\": [\"error\", {\n        checksVoidReturn: false,\n      }],\n      \"@typescript-eslint/no-namespace\": \"off\",\n      \"@typescript-eslint/no-non-null-assertion\": \"off\",\n      \"@typescript-eslint/no-this-alias\": \"off\",\n      \"@typescript-eslint/no-type-alias\": [\"error\", {\n        allowAliases: \"always\",\n        allowCallbacks: \"always\",\n        allowConditionalTypes: \"always\",\n        allowConstructors: \"always\",\n        allowLiterals: \"in-unions-and-intersections\",\n        allowMappedTypes: \"always\",\n        allowTupleTypes: \"always\",\n        allowGenerics: \"always\",\n      }],\n      \"@typescript-eslint/no-unsafe-argument\": \"off\",\n      \"@typescript-eslint/no-unsafe-assignment\": \"off\",\n      \"@typescript-eslint/no-unsafe-call\": \"off\",\n      \"@typescript-eslint/no-unsafe-member-access\": \"off\",\n      \"@typescript-eslint/no-unsafe-return\": \"off\",\n      \"@typescript-eslint/no-unused-vars\": [\"error\", {\n        vars: \"all\",\n        args: \"none\",\n        ignoreRestSiblings: false,\n        caughtErrors: \"none\",\n      }],\n      \"@typescript-eslint/no-var-requires\": \"off\",\n      \"@typescript-eslint/prefer-nullish-coalescing\": \"off\",\n\n      \"@typescript-eslint/prefer-regexp-exec\": \"off\",\n      \"@typescript-eslint/require-await\": \"off\",\n      \"@typescript-eslint/restrict-plus-operands\": \"off\",\n      \"@typescript-eslint/restrict-template-expressions\": \"off\",\n      \"@typescript-eslint/unbound-method\": \"off\",\n\n      // FIXME: The below set of rules should be activated at some points\n      \"@stylistic/no-mixed-operators\": \"off\",\n      \"@stylistic/member-delimiter-style\": \"off\",\n      \"@typescript-eslint/no-unsafe-function-type\": \"off\",\n      \"@typescript-eslint/no-base-to-string\": \"off\",\n      \"@typescript-eslint/no-unsafe-enum-comparison\": \"off\",\n    },\n  },\n]);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"grist-core\",\n  \"version\": \"1.7.11\",\n  \"license\": \"Apache-2.0\",\n  \"description\": \"Grist is the evolution of spreadsheets\",\n  \"homepage\": \"https://github.com/gristlabs/grist-core\",\n  \"repository\": \"git://github.com/gristlabs/grist-core.git\",\n  \"scripts\": {\n    \"start\": \"sandbox/watch.sh\",\n    \"start:debug\": \"NODE_INSPECT=--inspect sandbox/watch.sh\",\n    \"start:debug-brk\": \"NODE_INSPECT=--inspect-brk sandbox/watch.sh\",\n    \"install:python\": \"buildtools/prepare_python.sh\",\n    \"install:ee\": \"buildtools/prepare_ee.sh\",\n    \"build\": \"buildtools/build.sh\",\n    \"build:prod\": \"buildtools/build.sh prod\",\n    \"start:prod\": \"sandbox/run.sh\",\n    \"test\": \"./test/test_env.sh mocha ${DEBUG:+-b --no-exit} --slow 8000 $([ -z $DEBUG ] && echo --forbid-only) -g \\\"${GREP_TESTS}\\\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/nbrowser_with_stubs/**/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'\",\n    \"test:debug\": \"NO_CLEANUP=1 DEBUG=1 MOCHA_WEBDRIVER_LOGDIR=_build/test_output npm test\",\n    \"test:nbrowser\": \"TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt ./test/test_env.sh mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \\\"${GREP_TESTS}\\\" --slow 8000 '_build/test/nbrowser/**/*.js'\",\n    \"test:nbrowser:debug\": \"NO_CLEANUP=1 DEBUG=1 MOCHA_WEBDRIVER_LOGDIR=_build/test_output npm run test:nbrowser\",\n    \"test:nbrowser:ci\": \"MOCHA_WEBDRIVER_HEADLESS=1 MOCHA_WEBDRIVER_LOGDIR=_build/test_output npm run test:nbrowser\",\n    \"test:projects\": \"./test/test_env.sh mocha ${DEBUG:+'-b'} -g \\\"${GREP_TESTS}\\\" '_build/test/projects/**/*.js'\",\n    \"test:projects:serve\": \"webpack-dev-server --config test/fixtures/projects/webpack.config.js\",\n    \"test:stubs\": \"GRIST_TEST_LOGIN=1 ./test/test_env.sh mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \\\"${GREP_TESTS}\\\" --slow 8000 '_build/test/nbrowser_with_stubs/**/*.js'\",\n    \"test:client\": \"./test/test_env.sh mocha ${DEBUG:+'-b'} -g \\\"${GREP_TESTS}\\\" '_build/test/client/**/*.js'\",\n    \"test:common\": \"./test/test_env.sh mocha ${DEBUG:+'-b'} -g \\\"${GREP_TESTS}\\\" '_build/test/common/**/*.js'\",\n    \"test:server\": \"TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt ./test/test_env.sh mocha ${DEBUG:+'-b'} -g \\\"${GREP_TESTS}\\\" '_build/test/server/**/*.js'\",\n    \"test:gen-server\": \"TEST_SUITE=gen-server TEST_SUITE_FOR_TIMINGS=gen-server TIMINGS_FILE=test/timings/gen-server.txt ./test/test_env.sh mocha ${DEBUG:+'-b'} -g \\\"${GREP_TESTS}\\\" '_build/test/gen-server/**/*.js'\",\n    \"test:smoke\": \"./test/test_env.sh mocha _build/test/nbrowser/Smoke.js\",\n    \"test:docker\": \"./test/test_under_docker.sh\",\n    \"test:python\": \"sandbox_venv3/bin/python sandbox/grist/runtests.py ${GREP_TESTS:+discover -p \\\"test*${GREP_TESTS}*.py\\\"}\",\n    \"cli\": \"./app/cli.sh\",\n    \"lint\": \"eslint --max-warnings=0 --cache --cache-strategy content .\",\n    \"lint:fix\": \"eslint --max-warnings=0 --cache --cache-strategy=content --fix .\",\n    \"lint:ci\": \"eslint --max-warnings=0 .\",\n    \"generate:translation\": \"NODE_PATH=_build:_build/ext:_build/stubs node buildtools/generate_translation_keys.js\",\n    \"generate:schema:ts\": \"buildtools/update_schema.sh\",\n    \"generate:icons\": \"ts-node buildtools/genIconCSS.ts -t app/client/ui2018/IconList.ts static/ui-icons/**/*.svg > static/icons/icons.css\"\n  },\n  \"keywords\": [\n    \"grist\",\n    \"spreadsheet\",\n    \"database\"\n  ],\n  \"author\": {\n    \"name\": \"Grist Labs Inc.\",\n    \"email\": \"info@getgrist.com\"\n  },\n  \"private\": false,\n  \"devDependencies\": {\n    \"@babel/core\": \"7.28.5\",\n    \"@babel/eslint-parser\": \"7.28.5\",\n    \"@stylistic/eslint-plugin\": \"5.6.1\",\n    \"@types/accept-language-parser\": \"1.5.2\",\n    \"@types/backbone\": \"1.3.43\",\n    \"@types/chai\": \"4.1.7\",\n    \"@types/chai-as-promised\": \"7.1.0\",\n    \"@types/color-convert\": \"2.0.4\",\n    \"@types/content-disposition\": \"0.5.2\",\n    \"@types/decompress\": \"^4.2.7\",\n    \"@types/diff-match-patch\": \"1.0.32\",\n    \"@types/double-ended-queue\": \"2.1.0\",\n    \"@types/express\": \"4.17.17\",\n    \"@types/form-data\": \"2.2.1\",\n    \"@types/fs-extra\": \"5.0.4\",\n    \"@types/glob\": \"7.1.1\",\n    \"@types/http-proxy\": \"1.17.9\",\n    \"@types/i18next-fs-backend\": \"1.1.2\",\n    \"@types/image-size\": \"0.0.29\",\n    \"@types/js-yaml\": \"4.0.9\",\n    \"@types/jsdom\": \"21.1.6\",\n    \"@types/jsesc\": \"3.0.1\",\n    \"@types/jsonwebtoken\": \"7.2.8\",\n    \"@types/lodash\": \"4.14.117\",\n    \"@types/lru-cache\": \"5.1.1\",\n    \"@types/mime-types\": \"2.1.0\",\n    \"@types/mocha\": \"10.0.1\",\n    \"@types/moment-timezone\": \"0.5.9\",\n    \"@types/mousetrap\": \"1.6.2\",\n    \"@types/node\": \"22.18.12\",\n    \"@types/node-fetch\": \"2.6.2\",\n    \"@types/pidusage\": \"2.0.5\",\n    \"@types/plotly.js\": \"2.12.1\",\n    \"@types/proper-lockfile\": \"4.1.2\",\n    \"@types/qrcode\": \"1.4.2\",\n    \"@types/redlock\": \"3.0.2\",\n    \"@types/saml2-js\": \"2.0.1\",\n    \"@types/selenium-webdriver\": \"4.1.22\",\n    \"@types/semver\": \"7.7.1\",\n    \"@types/sinon\": \"17.0.3\",\n    \"@types/sqlite3\": \"3.1.6\",\n    \"@types/swagger-ui\": \"3.52.4\",\n    \"@types/tmp\": \"0.2.6\",\n    \"@types/underscore\": \"1.11.15\",\n    \"@types/uuid\": \"10.0.0\",\n    \"@types/webpack-dev-server\": \"4.7.2\",\n    \"@types/which\": \"2.0.1\",\n    \"@types/ws\": \"^8\",\n    \"@typescript-eslint/eslint-plugin\": \"8.48.1\",\n    \"@typescript-eslint/parser\": \"8.48.1\",\n    \"app-module-path\": \"2.2.0\",\n    \"chai\": \"4.2.0\",\n    \"chai-as-promised\": \"7.1.1\",\n    \"chance\": \"1.0.16\",\n    \"chokidar-cli\": \"3.0.0\",\n    \"decompress\": \"^4.2.1\",\n    \"esbuild-loader\": \"4.3.0\",\n    \"eslint\": \"9.39.1\",\n    \"eslint-plugin-import-x\": \"4.16.1\",\n    \"glob\": \"7.1.3\",\n    \"http-proxy\": \"1.18.1\",\n    \"i18next-scanner\": \"4.4.0\",\n    \"mocha\": \"11.0.1\",\n    \"mocha-webdriver\": \"0.3.4\",\n    \"moment-locales-webpack-plugin\": \"^1.2.0\",\n    \"nock\": \"13.5.5\",\n    \"nodemon\": \"^3.1.9\",\n    \"otplib\": \"12.0.1\",\n    \"proper-lockfile\": \"4.1.2\",\n    \"sinon\": \"19.0.2\",\n    \"source-map-loader\": \"^0.2.4\",\n    \"svgo\": \"3.3.2\",\n    \"ts-interface-builder\": \"0.3.2\",\n    \"ts-node\": \"10.9.2\",\n    \"typescript\": \"4.9.4\",\n    \"webpack\": \"5.104.1\",\n    \"webpack-cli\": \"6.0.1\",\n    \"webpack-dev-server\": \"5.2.2\",\n    \"why-is-node-running\": \"2.2.2\"\n  },\n  \"dependencies\": {\n    \"@emoji-mart/data\": \"1.2.1\",\n    \"@googleapis/drive\": \"8.14.0\",\n    \"@googleapis/oauth2\": \"1.0.7\",\n    \"@gristlabs/connect-sqlite3\": \"0.9.11-grist.5\",\n    \"@gristlabs/express-session\": \"1.18.1-grist1\",\n    \"@gristlabs/grist-widget\": \"^0.0.5\",\n    \"@gristlabs/moment-guess\": \"1.2.4-grist.1\",\n    \"@gristlabs/sqlite3\": \"5.1.4-grist.8\",\n    \"@popperjs/core\": \"2.11.8\",\n    \"@types/tar-stream\": \"^3.1.3\",\n    \"@types/zip-stream\": \"^7.0.0\",\n    \"accept-language-parser\": \"1.5.0\",\n    \"ace-builds\": \"1.23.3\",\n    \"airtable\": \"^0.12.2\",\n    \"async-mutex\": \"0.2.4\",\n    \"axios\": \"1.13.6\",\n    \"backbone\": \"1.3.3\",\n    \"bootstrap-datepicker\": \"1.9.0\",\n    \"bowser\": \"2.7.0\",\n    \"bullmq\": \"5.8.7\",\n    \"collect-js-deps\": \"^0.1.1\",\n    \"color-convert\": \"2.0.1\",\n    \"colord\": \"2.9.3\",\n    \"commander\": \"9.3.0\",\n    \"components-jqueryui\": \"1.12.1\",\n    \"connect-redis\": \"6.1.3\",\n    \"cookie\": \"0.7.0\",\n    \"cookie-parser\": \"1.4.7\",\n    \"csv\": \"6.3.8\",\n    \"currency-symbol-map\": \"5.1.0\",\n    \"diff-match-patch\": \"1.0.5\",\n    \"dompurify\": \"3.2.4\",\n    \"double-ended-queue\": \"2.1.0-0\",\n    \"emoji-mart\": \"5.6.0\",\n    \"emoji-regex\": \"10.4.0\",\n    \"engine.io\": \"6.6.2\",\n    \"engine.io-client\": \"6.6.2\",\n    \"exceljs\": \"4.2.1\",\n    \"express\": \"4.21.2\",\n    \"express-rate-limit\": \"7.2.0\",\n    \"file-type\": \"16.5.4\",\n    \"fs-extra\": \"7.0.0\",\n    \"grain-rpc\": \"0.1.7\",\n    \"grainjs\": \"1.0.2\",\n    \"handlebars\": \"4.7.7\",\n    \"highlight.js\": \"10.7.3\",\n    \"i18n-iso-countries\": \"6.1.0\",\n    \"i18next\": \"21.9.1\",\n    \"i18next-http-middleware\": \"3.3.2\",\n    \"image-size\": \"0.6.3\",\n    \"jquery\": \"3.5.0\",\n    \"js-yaml\": \"4.1.1\",\n    \"jsdom\": \"26.1.0\",\n    \"jsesc\": \"3.0.2\",\n    \"jsonwebtoken\": \"9.0.2\",\n    \"knockout\": \"3.5.0\",\n    \"locale-currency\": \"0.0.2\",\n    \"lodash\": \"4.17.23\",\n    \"lru-cache\": \"5.1.1\",\n    \"marked\": \"14.0.0\",\n    \"marked-highlight\": \"2.1.4\",\n    \"marked-linkify-it\": \"3.1.11\",\n    \"minio\": \"8.0.5\",\n    \"moment\": \"2.29.4\",\n    \"moment-timezone\": \"0.5.35\",\n    \"morgan\": \"1.9.1\",\n    \"mousetrap\": \"1.6.2\",\n    \"multiparty\": \"4.2.2\",\n    \"node-abort-controller\": \"3.0.1\",\n    \"node-fetch\": \"2.6.7\",\n    \"openid-client\": \"5.6.1\",\n    \"p-limit\": \"7.3.0\",\n    \"pg\": \"8.6.0\",\n    \"pidusage\": \"4.0.0\",\n    \"piscina\": \"3.2.0\",\n    \"plotly.js-basic-dist\": \"2.13.2\",\n    \"popper-max-size-modifier\": \"0.2.0\",\n    \"popweasel\": \"0.1.23\",\n    \"prom-client\": \"14.2.0\",\n    \"proxy-agent\": \"6.5.0\",\n    \"qrcode\": \"1.5.0\",\n    \"randomcolor\": \"0.5.3\",\n    \"redis\": \"~3.1.2\",\n    \"redlock\": \"3.1.2\",\n    \"reflect-metadata\": \"0.2.2\",\n    \"saml2-js\": \"4.0.2\",\n    \"scimmy\": \"1.3.5\",\n    \"scimmy-routers\": \"1.3.2\",\n    \"semver\": \"7.7.3\",\n    \"short-uuid\": \"5.2.0\",\n    \"slugify\": \"1.6.6\",\n    \"swagger-ui-dist\": \"5.11.0\",\n    \"tar-stream\": \"^3.1.7\",\n    \"tmp\": \"0.2.5\",\n    \"tmp-promise\": \"3.0.3\",\n    \"ts-interface-checker\": \"1.0.2\",\n    \"typeorm\": \"0.3.27\",\n    \"underscore\": \"1.13.8\",\n    \"uuid\": \"10.0.0\",\n    \"winston\": \"2.4.5\",\n    \"ws\": \"8.18.0\",\n    \"zip-stream\": \"~6\"\n  },\n  \"resolutions\": {\n    \"**/ip\": \"https://registry.npmjs.org/neoip/-/neoip-2.1.0.tgz\",\n    \"jquery\": \"3.5.0\",\n    \"ts-interface-checker\": \"1.0.2\",\n    \"@gristlabs/sqlite3\": \"5.1.4-grist.8\"\n  },\n  \"mocha\": {\n    \"require\": [\n      \"test/setupPaths\",\n      \"source-map-support/register\",\n      \"test/report-why-tests-hang\",\n      \"test/init-mocha-webdriver\",\n      \"test/split-tests\",\n      \"test/chai-as-promised\"\n    ]\n  }\n}\n"
  },
  {
    "path": "plugins/core/manifest.yml",
    "content": "name: core\nversion: 0.0.0\ndescription: Grist core features\ncomponents:\n  safePython: sandbox/main.py\ncontributions:\n  fileParsers:\n    - fileExtensions: [\"csv\", \"tsv\", \"dsv\", \"txt\"]\n      parseFile:\n        component: safePython\n        name: csv_parser\n    - fileExtensions: [\"xlsx\", \"xlsm\"]\n      parseFile:\n        component: safePython\n        name: xls_parser\n    - fileExtensions: [\"json\"]\n      parseFile:\n        component: safePython\n        name: json_parser\n\nscripts:\n  build:\n  test:\n"
  },
  {
    "path": "publiccode.yml",
    "content": "# This repository adheres to the publiccode.yml standard by including this \n# metadata file that makes public software easily discoverable.\n# More info at https://github.com/italia/publiccode.yml\n\npubliccodeYmlVersion: '0.2'\ncategories:\n  - data-collection\n  - crm\n  - compliance-management\n  - office\ndependsOn:\n  open:\n    - name: NodeJS\n      optional: false\n      version: ''\n      versionMax: ''\n      versionMin: '18'\n    - name: Python\n      optional: false\n      version: ''\n      versionMax: ''\n      versionMin: '3.9'\n    - name: Yarn\n      optional: true\n      version: ''\n      versionMax: ''\n      versionMin: ''\n    - name: Postgresql\n      optional: true\n      version: ''\n      versionMax: ''\n      versionMin: ''\n    - name: Redis\n      optional: true\n      version: ''\n      versionMax: ''\n      versionMin: ''\ndescription:\n  en:\n    apiDocumentation: 'https://support.getgrist.com/api/'\n    documentation: 'https://support.getgrist.com/'\n    features:\n      - database\n      - spreadsheet\n      - low-code\n      - no-code\n      - form generation\n      - webhook\n      - calendar\n      - map\n      - python formulas\n    genericName: collaborative spreadsheet\n    longDescription: |\n      Grist is a hybrid database/spreadsheet, meaning that:\n\n      - Columns work like they do in databases: they are named, and they hold one kind of data.\n      - Columns can be filled by formula, spreadsheet-style, with automatic updates when referenced cells change.\n\n\n      This difference can confuse people coming directly from Excel or Google\n      Sheets. Give it a chance! There's also a [Grist for Spreadsheet\n      Users](https://www.getgrist.com/blog/grist-for-spreadsheet-users/) article\n      to help get you oriented. If you're coming from Airtable, you'll find the\n      model familiar (and there's also our [Grist vs\n      Airtable](https://www.getgrist.com/blog/grist-v-airtable/) article for a\n      direct comparison).\n\n      Here are some specific feature highlights of Grist:\n\n      - Python formulas.\n          - Full [Python syntax is supported](https://support.getgrist.com/formulas/#python), including the standard library.\n          - Many [Excel functions](https://support.getgrist.com/functions/) also available.\n          - 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)).\n      - A portable, self-contained format.\n          - Based on SQLite, the most widely deployed database engine.\n          - Any tool that can read SQLite can read numeric and text data from a Grist file.\n          - Enables [backups](https://support.getgrist.com/exports/#backing-up-an-entire-document) that you can confidently restore in full.\n          - Great for moving between different hosts.\n      - Can be displayed on a static website with [`grist-static`](https://github.com/gristlabs/grist-static) – no special server needed.\n      - A self-contained desktop app for viewing and editing locally: [`grist-electron`](https://github.com/gristlabs/grist-electron).\n      - Convenient editing and formatting features.\n          - Choices and [choice lists](https://support.getgrist.com/col-types/#choice-list-columns), for adding colorful tags to records.\n          - [References](https://support.getgrist.com/col-refs/#creating-a-new-reference-list-column) and reference lists, for cross-referencing records in other tables.\n          - [Attachments](https://support.getgrist.com/col-types/#attachment-columns), to include media or document files in records.\n          - Dates and times, toggles, and special numerics such as currency all have specialized editors and formatting options.\n          - [Conditional Formatting](https://support.getgrist.com/conditional-formatting/), letting you control the style of cells with formulas to draw attention to important information.\n      - Drag-and-drop dashboards.\n          - [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.\n          - [Summary tables](https://support.getgrist.com/summary-tables/) for summing and counting across groups.\n          - [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.\n          - [Filter bar](https://support.getgrist.com/search-sort-filter/#filter-buttons) for quick slicing and dicing.\n      - [Incremental imports](https://support.getgrist.com/imports/#updating-existing-records).\n          - Import a CSV of the last three months activity from your bank...\n          - ...and import new activity a month later without fuss or duplication.\n      - Integrations.\n          - 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/).\n          - Import/export to Google drive, Excel format, CSV.\n          - Link data with [custom widgets](https://support.getgrist.com/widget-custom/#_top), hosted externally.\n          - Configurable outgoing webhooks.\n      - [Many templates](https://templates.getgrist.com/) to get you started, from investment research to organizing treasure hunts.\n      - Access control options.\n          - (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)\n          - Share [individual documents](https://support.getgrist.com/sharing/), workspaces, or [team sites](https://support.getgrist.com/team-sharing/).\n          - Control access to [individual rows, columns, and tables](https://support.getgrist.com/access-rules/).\n          - Control access based on cell values and user attributes.\n      - Self-maintainable.\n          - Useful for intranet operation and specific compliance requirements.\n      - Sandboxing options for untrusted documents.\n          - On Linux or with Docker, you can enable [gVisor](https://github.com/google/gvisor) sandboxing at the individual document level.\n          - On macOS, you can use native sandboxing.\n          - On any OS, including Windows, you can use a wasm-based sandbox.\n      - Translated to many languages.\n      - `F1` key brings up some quick help. This used to go without saying, but in general Grist has good keyboard support.\n    shortDescription: |-\n      Grist is a modern relational spreadsheet. It combines the flexibility of a\n\n      spreadsheet with the robustness of a database.\n    videos:\n      - 'https://www.youtube.com/watch?v=XYZ_ZGSxU00'\ndevelopmentStatus: stable\ninputTypes:\n  - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\n  - text/csv\nit:\n  conforme:\n    gdpr: false\n    lineeGuidaDesign: false\n    misureMinimeSicurezza: false\n    modelloInteroperabilita: false\n  countryExtensionVersion: '0.2'\n  piattaforme:\n    anpr: false\n    cie: false\n    pagopa: false\n    spid: false\nlandingURL: 'https://getgrist.com'\nlegal:\n  license: Apache-2.0\nlocalisation:\n  availableLanguages:\n    - en\n    - fr\n    - ru\n    - de\n    - es\n    - pt\n    - zh\n    - it\n    - ja\n    - 'no'\n    - ro\n    - sl\n    - uk\n  localisationReady: true\nlogo: |-\n  https://raw.githubusercontent.com/gristlabs/grist-core/master/static/img/logo-grist.png\nmaintenance:\n  contacts:\n    - affiliation: Grist Labs\n      email: paul@getgrist.com\n      name: Paul Fitzpatrick\n  type: internal\nname: Grist\noutputTypes:\n  - application/x-sqlite3\nplatforms:\n  - web\nreleaseDate: '2024-06-12'\nroadmap: 'https://github.com/gristlabs/grist-core/projects/1'\nsoftwareType: standalone/other\nsoftwareVersion: 1.1.15\nurl: 'https://github.com/gristlabs/grist-core'\nusedBy:\n  - 'ANCT (https://anct.gouv.fr)'\n  - 'DINUM (https://www.numerique.gouv.fr/dinum/)'\n"
  },
  {
    "path": "sandbox/MANIFEST.in",
    "content": "# see bundle_as_wheel.sh\n\ninclude grist/tzdata.data\n"
  },
  {
    "path": "sandbox/bundle_as_wheel.sh",
    "content": "#!/usr/bin/env bash\n\n# Package up Grist code as a stand-alone wheel.\n# This is useful for grist-static.\n# It is the reason why MANIFEST.in and setup.py are present.\n\nset -e\n\n# Clean up any previous packaging.\nrm -rf dist foo.egg-info grist.egg-info build\n\n# Go ahead and run packaging again.\npython3 setup.py bdist_wheel\n\necho \"\"\necho \"Result is in the dist directory:\"\nls dist\n"
  },
  {
    "path": "sandbox/docker/Dockerfile",
    "content": "FROM python:3.11\n\nCOPY requirements.txt /tmp/requirements.txt\n\nRUN \\\n  pip3 install -r /tmp/requirements.txt\n\nRUN \\\n  apt-get update && \\\n  apt-get install -y faketime\n\nRUN useradd --shell /bin/bash sandbox\nUSER sandbox\nWORKDIR /\n"
  },
  {
    "path": "sandbox/docker/Makefile",
    "content": "image:\n\tcp ../requirements.txt .  # docker build requires files to be present.\n\tdocker build -t grist-docker-sandbox .\n"
  },
  {
    "path": "sandbox/docker_entrypoint.sh",
    "content": "#!/usr/bin/env bash\nset -Eeuo pipefail\n\n# Runs the command provided as arguments, but attempts to configure permissions first.\n\nimportant_read_dirs=(\"/grist\" \"/persist\")\nwrite_dir=\"/persist\"\ncurrent_user_id=$(id -u)\n\n# We want to avoid running Grist as root if possible.\n# Try to setup permissions and de-elevate to a normal user.\nif [[ $current_user_id == 0 ]]; then\n  target_user=${GRIST_DOCKER_USER:-grist}\n  target_group=${GRIST_DOCKER_GROUP:-grist}\n\n  # Make sure the target user owns everything that Grist needs write access to.\n  find $write_dir ! -user \"$target_user\" -exec chown \"$target_user\" \"{}\" +\n\n  # Make a home directory for the target user, in case anything needs to access it.\n  export HOME=\"/grist_user_homes/${target_user}\"\n  mkdir -p \"$HOME\"\n  chown -R \"$target_user\":\"$target_group\" \"$HOME\"\n\n  # Restart as the target user, replacing the current process (replacement is needed for security).\n  # Alternative tools to setpriv are: chroot, gosu.\n  # Need to use `exec` to close the parent shell, to avoid vulnerabilities: https://github.com/tianon/gosu/issues/37\n  exec setpriv --reuid \"$target_user\" --regid \"$target_group\" --init-groups /usr/bin/env bash \"$0\" \"$@\"\nfi\n\n# Validate that this user has access to the top level of each important directory.\n# There might be a benefit to testing individual files, but this is simpler as the dir may start empty.\nfor dir in \"${important_read_dirs[@]}\"; do\n  if ! { test -r \"$dir\" ;} ; then\n    echo \"Running Grist as user $(id -u) with primary group $(id -g)\"\n    echo \"Invalid permissions, cannot read '$dir'. Aborting.\" >&2\n    exit 1\n  fi\ndone\nfor dir in \"${important_write_dirs[@]}\"; do\n  if ! { test -r \"$dir\" && test -w \"$dir\" ;} ; then\n    echo \"Running Grist as user $(id -u) with primary group $(id -g)\"\n    echo \"Invalid permissions, cannot write '$dir'. Aborting.\" >&2\n    exit 1\n  fi\ndone\n\n# Allow running commands in the current directory, specifically \"cli\".\nexport PATH=$PWD:$PATH\nexec /usr/bin/tini -s -- \"$@\"\n"
  },
  {
    "path": "sandbox/gen_js_schema.py",
    "content": "#!/usr/bin/env python -B\n\"\"\"\nGenerates a JS schema file from sandbox/grist/schema.py.\n\"\"\"\n\nimport schema   # pylint: disable=import-error\n\n# These are the types that appear in Grist metadata columns.\n_ts_types = {\n  \"Bool\":           \"boolean\",\n  \"DateTime\":       \"number\",\n  \"Int\":            \"number\",\n  \"PositionNumber\": \"number\",\n  \"Ref\":            \"number\",\n  \"RefList\":        \"[GristObjCode.List, ...number[]]|null\",  # Non-primitive values are encoded\n  \"ChoiceList\":     \"[GristObjCode.List, ...string[]]|null\",\n  \"Text\":           \"string\",\n}\n\ndef get_ts_type(col_type):\n  col_type = col_type.split(':', 1)[0]      # Strip suffix for Ref:, DateTime:, etc.\n  return _ts_types.get(col_type, \"CellValue\")\n\ndef main():\n  print(\"\"\"\\\n/* eslint-disable */\n\n/*** THIS FILE IS AUTO-GENERATED BY %s ***/\n\nimport { GristObjCode } from \"app/plugin/GristData\";\n\n// tslint:disable:object-literal-key-quotes\n\nexport const SCHEMA_VERSION = %d;\n\nexport const schema = {\n\"\"\" % ('core/sandbox/gen_js_schema.py', schema.SCHEMA_VERSION))\n  # The script name is hardcoded since the Grist sandbox can be\n  # at different paths depending on how Grist is installed, and\n  # we don't want unnecessary changes to generated files.\n\n  for table in schema.schema_create_actions():\n    print('  \"%s\": {' % table.table_id)\n    for column in table.columns:\n      print('    %-20s: \"%s\",' % (column['id'], column['type']))\n    print('  },\\n')\n\n  print(\"\"\"};\n\nexport interface SchemaTypes {\n\"\"\")\n  for table in schema.schema_create_actions():\n    print('  \"%s\": {' % table.table_id)\n    for column in table.columns:\n      print('    %s: %s;' % (column['id'], get_ts_type(column['type'])))\n    print('  };\\n')\n  print(\"}\")\n\nif __name__ == '__main__':\n  main()\n"
  },
  {
    "path": "sandbox/grist/acl.py",
    "content": "# This file used to implement (partially) old plans for granular ACLs.\n# It now retains only the minimum needed to keep new documents openable by old code,\n# and to produce the ActionBundles expected by other code.\n\nimport json\nimport logging\n\nimport action_obj\nimport predicate_formula\nfrom predicate_formula import NamedEntity, parse_predicate_formula_json, TreeConverter\n\nlog = logging.getLogger(__name__)\n\n\nclass Permissions(object):\n  # Permission types and their combination are represented as bits of a single integer.\n  VIEW          = 0x1\n  UPDATE        = 0x2\n  ADD           = 0x4\n  REMOVE        = 0x8\n  SCHEMA_EDIT   = 0x10\n  ACL_EDIT      = 0x20\n  EDITOR        = VIEW | UPDATE | ADD | REMOVE\n  ADMIN         = EDITOR | SCHEMA_EDIT\n  OWNER         = ADMIN | ACL_EDIT\n\n\n# Special recipients, or instanceIds. ALL is the special recipient for schema actions that\n# should be shared with all collaborators of the document.\nALL = '#ALL'\nALL_SET = frozenset([ALL])\n\n\ndef parse_acl_formulas(col_values):\n  \"\"\"\n  Populates `aclFormulaParsed` by parsing `aclFormula` for all `col_values`.\n  \"\"\"\n  if 'aclFormula' not in col_values:\n    return\n\n  col_values['aclFormulaParsed'] = [parse_predicate_formula_json(v)\n                                    for v\n                                    in col_values['aclFormula']]\n\n\nclass _ACLEntityCollector(TreeConverter):\n  def __init__(self):\n    self.entities = []    # NamedEntity list\n\n  def visit_Attribute(self, node):\n    parent = self.visit(node.value)\n\n    # We recognize a couple of specific patterns for entities that may be affected by renames.\n    if parent == ['Name', 'rec'] or parent == ['Name', 'newRec']:\n      # rec.COL refers to the column from the table that the rule is on.\n      self.entities.append(NamedEntity('recCol', node.last_token.startpos, node.attr, None))\n    elif parent == ['Name', 'user']:\n      # user.ATTR is a user attribute.\n      self.entities.append(NamedEntity('userAttr', node.last_token.startpos, node.attr, None))\n    elif parent[0] == 'Attr' and parent[1] == ['Name', 'user']:\n      # user.ATTR.COL is a column from the lookup table of the UserAttribute ATTR.\n      self.entities.append(\n          NamedEntity('userAttrCol', node.last_token.startpos, node.attr, parent[2]))\n\n    return [\"Attr\", parent, node.attr]\n\n\ndef acl_read_split(action_group):\n  \"\"\"\n  Returns an ActionBundle containing actions from the given action_group, all in one envelope.\n  With the deprecation of old-style ACL rules, envelopes are not used at all, and only kept to\n  avoid triggering unrelated code changes.\n  \"\"\"\n  bundle = action_obj.ActionBundle()\n  bundle.envelopes.append(action_obj.Envelope(ALL_SET))\n  bundle.stored.extend((0, da) for da in action_group.stored)\n  bundle.direct.extend((0, flag) for flag in action_group.direct)\n  bundle.calc.extend((0, da) for da in action_group.calc)\n  bundle.undo.extend((0, da) for da in action_group.undo)\n  bundle.retValues = action_group.retValues\n  return bundle\n\n\ndef prepare_acl_table_renames(useractions, table_renames_dict):\n  \"\"\"\n  Given a dict of table renames of the form {table_id: new_table_id}, returns a callback\n  that will apply updates to the affected ACL rules and resources.\n  \"\"\"\n  # If there are ACLResources that refer to the renamed table, prepare updates for those.\n  resource_updates = []\n  for resource_rec in useractions.get_docmodel().aclResources.all:\n    if resource_rec.tableId in table_renames_dict:\n      resource_updates.append((resource_rec, {'tableId': table_renames_dict[resource_rec.tableId]}))\n\n  # Collect updates for any ACLRules with UserAttributes that refer to the renamed table.\n  rule_updates = []\n  for rule_rec in useractions.get_docmodel().aclRules.all:\n    if rule_rec.userAttributes:\n      try:\n        rule_info = json.loads(rule_rec.userAttributes)\n        if rule_info.get(\"tableId\") in table_renames_dict:\n          rule_info[\"tableId\"] = table_renames_dict[rule_info.get(\"tableId\")]\n          rule_updates.append((rule_rec, {'userAttributes': json.dumps(rule_info)}))\n      except Exception as e:\n        log.warning(\"Error examining aclRule: %s\", e)\n\n  def do_renames():\n    useractions.doBulkUpdateFromPairs('_grist_ACLResources', resource_updates)\n    useractions.doBulkUpdateFromPairs('_grist_ACLRules', rule_updates)\n  return do_renames\n\n\ndef perform_acl_rule_renames(useractions, col_renames_dict):\n  \"\"\"\n  Given a dict of column renames of the form {(table_id, col_id): new_col_id}, returns a callback\n  that will apply updates to the affected ACL rules and resources.\n  \"\"\"\n  # Collect updates for ACLResources that refer to the renamed columns.\n  resource_updates = []\n  for resource_rec in useractions.get_docmodel().aclResources.all:\n    t = resource_rec.tableId\n    if resource_rec.colIds and resource_rec.colIds != '*':\n      new_col_ids = ','.join((col_renames_dict.get((t, c)) or c)\n                             for c in resource_rec.colIds.split(','))\n      if new_col_ids != resource_rec.colIds:\n        resource_updates.append((resource_rec, {'colIds': new_col_ids}))\n\n  # Collect updates for any ACLRules with UserAttributes that refer to the renamed column.\n  rule_updates = []\n  user_attr_tables = {}   # Maps name of user attribute to its lookup table\n  for rule_rec in useractions.get_docmodel().aclRules.all:\n    if rule_rec.userAttributes:\n      try:\n        rule_info = json.loads(rule_rec.userAttributes)\n        user_attr_tables[rule_info.get('name')] = rule_info.get('tableId')\n        new_col_id = col_renames_dict.get((rule_info.get(\"tableId\"), rule_info.get(\"lookupColId\")))\n        if new_col_id:\n          rule_info[\"lookupColId\"] = new_col_id\n          rule_updates.append((rule_rec, {'userAttributes': json.dumps(rule_info)}))\n      except Exception as e:\n        log.warning(\"Error examining aclRule: %s\", e)\n\n  acl_resources_table = useractions.get_docmodel().aclResources.table\n  # Go through again checking if anything in ACL formulas is affected by the rename.\n  for rule_rec in useractions.get_docmodel().aclRules.all:\n\n    if not rule_rec.aclFormula:\n      continue\n    acl_formula = rule_rec.aclFormula\n\n    def renamer(subject):\n      if subject.type == 'recCol':\n        table_id = acl_resources_table.get_record(int(rule_rec.resource)).tableId\n      elif subject.type == 'userAttrCol':\n        table_id = user_attr_tables.get(subject.extra)\n      else:\n        return None\n      col_id = subject.name\n      return col_renames_dict.get((table_id, col_id))\n\n    new_acl_formula = predicate_formula.process_renames(acl_formula, _ACLEntityCollector(), renamer)\n    # No need to check for syntax errors, but this \"if\" statement must be present.\n    # See perform_dropdown_condition_renames for more info.\n    if new_acl_formula != acl_formula:\n      new_rule_record = {\n        \"aclFormula\": new_acl_formula,\n        \"aclFormulaParsed\": parse_predicate_formula_json(new_acl_formula)\n      }\n      rule_updates.append((rule_rec, new_rule_record))\n\n  useractions.doBulkUpdateFromPairs('_grist_ACLResources', resource_updates)\n  useractions.doBulkUpdateFromPairs('_grist_ACLRules', rule_updates)\n"
  },
  {
    "path": "sandbox/grist/action_obj.py",
    "content": "\"\"\"\nThis module defines ActionGroup, ActionEnvelope, and ActionBundle -- classes that together\nrepresent the result of applying a UserAction to a document.\n\nIn general, UserActions refer to logical actions performed by the user. DocActions are the\nindividual steps to which UserActions translate.\n\nA list of UserActions applied together translates to multiple DocActions, packaged into an\nActionGroup. In a separate step, this ActionGroup is split up according to ACL rules into and\nActionBundle consisting of ActionEnvelopes, each containing a smaller set of actions associated\nwith the set of recipients who should receive them.\n\"\"\"\nimport actions\nfrom action_summary import ActionSummary\n\nclass ActionGroup(object):\n  \"\"\"\n  ActionGroup packages different types of doc actions for returning them to the instance.\n\n  The ActionGroup stores actions produced by the engine in the course of processing one or more\n  UserActions, plus an array of return values, one for each UserAction.\n  \"\"\"\n  def __init__(self):\n    self.calc     = []\n    self.stored   = []\n    self.direct   = []\n    self.undo     = []\n    self.retValues = []\n    self.summary = ActionSummary()\n    self.requests = {}\n\n  def flush_calc_changes(self):\n    \"\"\"\n    Merge the changes from self.summary into self.stored and self.undo, and clear the summary.\n    \"\"\"\n    length_before = len(self.stored)\n    self.summary.convert_deltas_to_actions(self.stored, self.undo)\n    count = len(self.stored) - length_before\n    self.direct += [False] * count\n    self.summary = ActionSummary()\n\n  def flush_calc_changes_for_column(self, table_id, col_id):\n    \"\"\"\n    Merge the changes for the given column from self.summary into self.stored and self.undo, and\n    remove that column from the summary.\n    \"\"\"\n    length_before = len(self.stored)\n    self.summary.pop_column_delta_as_actions(table_id, col_id, self.stored, self.undo)\n    count = len(self.stored) - length_before\n    self.direct += [False] * count\n\n  def check_sanity(self):\n    if len(self.stored) != len(self.direct):\n      raise AssertionError(\"failed to track origin of actions\")\n\n  def get_repr(self):\n    return {\n      \"calc\":     [actions.get_action_repr(a) for a in self.calc],\n      \"stored\":   [actions.get_action_repr(a) for a in self.stored],\n      \"undo\":     [actions.get_action_repr(a) for a in self.undo],\n      \"direct\": self.direct,\n      \"retValues\": self.retValues\n    }\n\n  @classmethod\n  def from_json_obj(cls, data):\n    ag = ActionGroup()\n    ag.calc   = [actions.action_from_repr(a) for a in data.get('calc', [])]\n    ag.stored = [actions.action_from_repr(a) for a in data.get('stored', [])]\n    ag.undo   = [actions.action_from_repr(a) for a in data.get('undo', [])]\n    ag.retValues = data.get('retValues', [])\n    return ag\n\n\nclass Envelope(object):\n  \"\"\"\n  Envelope contains information about recipients as a set (or frozenset) of instanceIds.\n  \"\"\"\n  def __init__(self, recipient_set):\n    self.recipients = recipient_set\n\n  def to_json_obj(self):\n    return {\"recipients\": sorted(self.recipients)}\n\nclass ActionBundle(object):\n  \"\"\"\n  ActionBundle contains actions arranged into envelopes, i.e. split up by sets of recipients.\n  Note that different Envelopes contain different sets of recipients (which may overlap however).\n  \"\"\"\n  def __init__(self):\n    self.envelopes = []\n    self.stored = []          # Pairs of (envIndex, docAction)\n    self.direct = []          # Pairs of (envIndex, boolean)\n    self.calc = []            # Pairs of (envIndex, docAction)\n    self.undo = []            # Pairs of (envIndex, docAction)\n    self.retValues = []\n    self.rules = set()        # RowIds of ACLRule records used to construct this ActionBundle.\n\n  def to_json_obj(self):\n    return {\n      \"envelopes\": [e.to_json_obj() for e in self.envelopes],\n      \"stored\":    [(env, actions.get_action_repr(a)) for (env, a) in self.stored],\n      \"direct\":    self.direct,\n      \"calc\":      [(env, actions.get_action_repr(a)) for (env, a) in self.calc],\n      \"undo\":      [(env, actions.get_action_repr(a)) for (env, a) in self.undo],\n      \"retValues\": self.retValues,\n      \"rules\":     sorted(self.rules)\n    }\n"
  },
  {
    "path": "sandbox/grist/action_summary.py",
    "content": "\"\"\"\nRepresentation of changes due to some actions, similar to app/common/ActionSummary on node side.\nIt's used for collecting calculated values for formula columns.\n\"\"\"\nfrom collections import namedtuple\n\nimport actions\nfrom objtypes import equal_encoding\n\n# Pairs of before/after names of tables and columns.  None represents non-existence for `before`,\n# while \"defunct_name\" (i.e. `-{name}`) represents non-existence for `after`. This way,\n# addition and removal of tables/columns can be represented.\n#\n# Note that changes are keyed using the last known name, or \"defunct_name\" for entities that have\n# been removed.\nLabelDelta = namedtuple('before', 'after')\n\nclass ActionSummary(object):\n  # This is a class (similar to app/common/ActionSummary on node side) to summarize a list of\n  # docactions to easily answer questions such as whether a column was added.\n  def __init__(self):\n    self._tables = {}         # maps tableId to TableDelta\n    self._table_renames = LabelRenames()\n\n  def add_changes(self, table_id, col_id, changes):\n    \"\"\"\n    Record changes for the given table and column, in the form (row_id, before, after).\n    \"\"\"\n    col_deltas = self._forTable(table_id).column_deltas.setdefault(col_id, {})\n    for (row_id, before, after) in changes:\n      # If a change was already recorded, update the 'after' value and keep the 'before' one.\n      previous = col_deltas.get(row_id)\n      col_deltas[row_id] = (previous[0] if previous else before, after)\n\n  def convert_deltas_to_actions(self, out_stored, out_undo):\n    \"\"\"\n    Go through all prepared deltas, construct DocActions for them, and add them to out_stored\n    and out_undo lists.\n    \"\"\"\n    for table_id in sorted(self._tables):\n      table_delta = self._tables[table_id]\n      for col_id in sorted(table_delta.column_deltas):\n        column_delta = table_delta.column_deltas[col_id]\n        self._changes_to_actions(table_id, col_id, column_delta, out_stored, out_undo)\n\n  def pop_column_delta_as_actions(self, table_id, col_id, out_stored, out_undo):\n    \"\"\"\n    Remove deltas for a particular column, and convert the removed deltas to DocActions. Add\n    those to out_stored and out_undo lists.\n    \"\"\"\n    table_delta = self._tables.get(table_id)\n    col_delta = table_delta and table_delta.column_deltas.pop(col_id, None)\n    return self._changes_to_actions(table_id, col_id, col_delta or {}, out_stored, out_undo)\n\n  def update_new_rows_map(self, table_id, temp_row_ids, final_row_ids):\n    \"\"\"\n    Add a mapping from temporary negative row_ids to the final ones, for rows added to the given\n    table. The two lists must have the same length; only negative row_ids are remembered. If a\n    negative row_id was already used, its mapping will be overridden.\n    \"\"\"\n    t = self._forTable(table_id)\n    t.temp_row_ids.update((a, b) for (a, b) in zip(temp_row_ids, final_row_ids) if a and a < 0)\n\n  def translate_new_row_ids(self, table_id, row_ids):\n    \"\"\"\n    Translate any temporary (negative) row_ids to their final values, using mappings created by\n    update_new_rows_map().\n    \"\"\"\n    t = self._forTable(table_id)\n    return [t.temp_row_ids.get(r, r) for r in row_ids]\n\n  def _changes_to_actions(self, table_id, col_id, column_delta, out_stored, out_undo):\n    \"\"\"\n    Given a column and a dict of column_deltas for it, of the form {row_id: (before_value,\n    after_value)}, creates DocActions and adds them to out_stored and out_undo lists.\n    \"\"\"\n    if not column_delta:\n      return\n    full_row_ids = sorted(r for r, (before, after) in column_delta.items()\n                          if not equal_encoding(before, after))\n\n    defunct = is_defunct(table_id) or is_defunct(col_id)\n    table_id = root_name(table_id)\n    col_id = root_name(col_id)\n\n    def update_action(filtered_row_ids, delta_index):\n      values = [column_delta[r][delta_index] for r in filtered_row_ids]\n      return actions.BulkUpdateRecord(table_id, filtered_row_ids, {col_id: values}).simplify()\n\n    if not defunct:\n      row_ids_after = self.filter_out_gone_rows(table_id, full_row_ids)\n      if row_ids_after:\n        out_stored.append(update_action(row_ids_after, 1))\n\n    if self.is_created(table_id, col_id) and not defunct:\n      # A newly-created column, and not replacing a defunct one. Don't generate undo actions.\n      return\n\n    ## Maybe add one or two undo update actions for rows that existed before the change.\n    row_ids_before = self.filter_out_new_rows(table_id, full_row_ids)\n\n    if defunct:\n      preserved_row_ids = []\n    else:\n      preserved_row_ids = self.filter_out_gone_rows(table_id, row_ids_before)\n\n    preserved_row_ids_set = set(preserved_row_ids)\n    defunct_row_ids = [r for r in row_ids_before if r not in preserved_row_ids_set]\n\n    if preserved_row_ids:\n      out_undo.append(update_action(preserved_row_ids, 0))\n\n    if defunct_row_ids:\n      # Updates for deleted rows/columns/tables should come after they're re-added.\n      # So we need to insert the undos *before*.\n      out_undo.insert(0, update_action(defunct_row_ids, 0))\n\n  def _forTable(self, table_id):\n    return self._tables.get(table_id) or self._tables.setdefault(table_id, TableDelta())\n\n  def is_created(self, table_id, col_id):\n    if self._table_renames.is_created(table_id):\n      return True\n    t = self._tables.get(table_id)\n    return t and t.column_renames.is_created(col_id)\n\n  def filter_out_new_rows(self, table_id, row_ids):\n    t = self._tables.get(table_id)\n    if not t:\n      return row_ids\n    return [r for r in row_ids if t._rows_present_before.get(r) != False]\n\n  def filter_out_gone_rows(self, table_id, row_ids):\n    t = self._tables.get(table_id)\n    if not t:\n      return row_ids\n    return [r for r in row_ids if t._rows_present_after.get(r) != False]\n\n  def add_records(self, table_id, row_ids):\n    t = self._forTable(table_id)\n    for r in row_ids:\n      # An addition means the row was initially absent, unless we already processed its removal.\n      t._rows_present_before.setdefault(r, False)\n      t._rows_present_after[r] = True\n\n  def remove_records(self, table_id, row_ids):\n    t = self._forTable(table_id)\n    for r in row_ids:\n      # A removal means the row was initially present, unless it was already marked as new.\n      t._rows_present_before.setdefault(r, True)\n      t._rows_present_after[r] = False\n\n  def add_column(self, table_id, col_id):\n    return self.rename_column(table_id, None, col_id)\n\n  def remove_column(self, table_id, col_id):\n    return self.rename_column(table_id, col_id, defunct_name(col_id))\n\n  def rename_column(self, table_id, old_col_id, new_col_id):\n    t = self._forTable(table_id)\n    t.column_renames.add_rename(old_col_id, new_col_id)\n    if old_col_id in t.column_deltas:\n      t.column_deltas[new_col_id] = t.column_deltas.pop(old_col_id)\n\n  def add_table(self, table_id):\n    self.rename_table(None, table_id)\n\n  def remove_table(self, table_id):\n    self.rename_table(table_id, defunct_name(table_id))\n\n  def rename_table(self, old_table_id, new_table_id):\n    self._table_renames.add_rename(old_table_id, new_table_id)\n    if old_table_id in self._tables:\n      self._tables[new_table_id] = self._tables.pop(old_table_id)\n\nclass TableDelta(object):\n  def __init__(self):\n    # Each map maps rowId to True or False. If a row was added and later removed, both will be\n    # False. If removed, then added, both will be True. If neither, it will not be in the map.\n    self._rows_present_before = {}\n    self._rows_present_after = {}\n    self.column_renames = LabelRenames()\n    self.column_deltas = {}   # maps col_id to the dict {row_id: (before_value, after_value)}\n\n    # Map of negative row_ids that may be used in [Bulk]AddRecord actions to the final row_ids for\n    # those rows; to allow translating Reference values added in the same action bundle.\n    self.temp_row_ids = {}\n\n\nclass LabelRenames(object):\n  \"\"\"\n  Maintains a set of renames, for tables in a doc, or for columns in a table. For now, we only\n  maintain the knowledge of the original name, since we only need to answer limited questions.\n  \"\"\"\n  def __init__(self):\n    self._new_to_old = {}\n\n  def add_rename(self, before, after):\n    original = self._new_to_old.pop(before, before)\n    self._new_to_old[after] = original\n\n  def is_created(self, latest_name):\n    return self._new_to_old.get(latest_name, latest_name) is None\n\n\ndef defunct_name(name):\n  return '-' + name\n\ndef is_defunct(name):\n  return name.startswith('-')\n\ndef root_name(name):\n  return name[1:] if name.startswith('-') else name\n"
  },
  {
    "path": "sandbox/grist/actions.py",
    "content": "\"\"\"\nactions.py defines the action objects used in the Python code, and functions to convert between\nthem and the serializable docActions objects used to communicate with the outside.\n\nWhen communicating with Node, docActions are represented as arrays [actionName, arguments...].\n\"\"\"\n\nfrom collections import namedtuple\nimport inspect\n\nimport objtypes\n\ndef _eq_with_type(self, other):\n  # pylint: disable=unidiomatic-typecheck\n  return tuple(self) == tuple(other) and type(self) == type(other)\n\ndef _ne_with_type(self, other):\n  return not _eq_with_type(self, other)\n\ndef namedtuple_eq(typename, field_names):\n  \"\"\"\n  Just like namedtuple, but these objects are only considered equal to other objects of the same\n  type (not just to any tuple with the same values).\n  \"\"\"\n  n = namedtuple(typename, field_names)\n  n.__eq__ = _eq_with_type\n  n.__ne__ = _ne_with_type\n  return n\n\n# For Record actions, the parameters are as follows:\n#     table_id: string name of the table.\n#     row_id:   numeric row identifier\n#     row_ids:  list of row identifiers\n#     columns:  dictionary mapping col_id (string name of column) to the value for the given\n#               row_id, or an array of values parallel to the array of row_ids.\nAddRecord = namedtuple_eq('AddRecord', ('table_id', 'row_id', 'columns'))\nBulkAddRecord = namedtuple_eq('BulkAddRecord', ('table_id', 'row_ids', 'columns'))\nRemoveRecord = namedtuple_eq('RemoveRecord', ('table_id', 'row_id'))\nBulkRemoveRecord = namedtuple_eq('BulkRemoveRecord', ('table_id', 'row_ids'))\nUpdateRecord = namedtuple_eq('UpdateRecord', ('table_id', 'row_id', 'columns'))\nBulkUpdateRecord = namedtuple_eq('BulkUpdateRecord', ('table_id', 'row_ids', 'columns'))\n\n# Identical to BulkAddRecord, but implies emptying out the table first.\nReplaceTableData = namedtuple_eq('ReplaceTableData', BulkAddRecord._fields)\n\n# For Column actions, the parameters are:\n#     table_id: string name of the table.\n#     col_id:   string name of column\n#     col_info: dictionary with particular keys\n#         type :      string type of the column\n#         isFormula:  bool, whether it is a formula column\n#         formula:    string text of the formula, or empty string\n#     Other keys may be set in col_info (e.g. widgetOptions, label) but are not currently used in\n#     the schema (only such values from the metadata tables is used).\nAddColumn = namedtuple_eq('AddColumn', ('table_id', 'col_id', 'col_info'))\nRemoveColumn = namedtuple_eq('RemoveColumn', ('table_id', 'col_id'))\nRenameColumn = namedtuple_eq('RenameColumn', ('table_id', 'old_col_id', 'new_col_id'))\nModifyColumn = namedtuple_eq('ModifyColumn', ('table_id', 'col_id', 'col_info'))\n\n# For Table actions, the parameters are:\n#     table_id: string name of the table.\n#     columns:  array of col_info objects, as described for Column actions above, containing also:\n#         id:         string name of the column (aka col_id in Column actions)\nAddTable = namedtuple_eq('AddTable', ('table_id', 'columns'))\nRemoveTable = namedtuple_eq('RemoveTable', ('table_id',))\nRenameTable = namedtuple_eq('RenameTable', ('old_table_id', 'new_table_id'))\n\n# Identical to BulkAddRecord, just a clearer type name for loading or fetching data.\nTableData = namedtuple_eq('TableData', BulkAddRecord._fields)\n\naction_types = dict((key, val) for (key, val) in globals().items()\n                    if inspect.isclass(val) and issubclass(val, tuple))\n\n# This is the set of names of all the actions that affect the schema.\nschema_actions = {name for name in action_types\n                  if name.endswith(\"Column\") or name.endswith(\"Table\")}\n\ndef _add_simplify(SingleActionType, BulkActionType):\n  \"\"\"\n  Add .simplify method to \"Bulk\" actions, which returns None for no rows, non-Bulk version for a\n  single row, and the original action otherwise.\n  \"\"\"\n  if len(SingleActionType._fields) < 3:\n    def get_first(self):\n      return SingleActionType(self.table_id, self.row_ids[0])\n  else:\n    def get_first(self):\n      return SingleActionType(self.table_id, self.row_ids[0],\n                              { key: col[0] for key, col in self.columns.items()})\n  def simplify(self):\n    return None if not self.row_ids else (get_first(self) if len(self.row_ids) == 1 else self)\n\n  BulkActionType.simplify = simplify\n\n_add_simplify(AddRecord, BulkAddRecord)\n_add_simplify(RemoveRecord, BulkRemoveRecord)\n_add_simplify(UpdateRecord, BulkUpdateRecord)\n\n\ndef get_action_repr(action_obj):\n  \"\"\"\n  Converts an action object, such as UpdateRecord into a docAction array.\n  \"\"\"\n  return [action_obj.__class__.__name__] + list(encode_objects(action_obj))\n\ndef action_from_repr(doc_action):\n  \"\"\"\n  Converts a docAction array into an object such as UpdateRecord.\n  \"\"\"\n  action_type = action_types.get(doc_action[0])\n  if not action_type:\n    raise ValueError('Unknown action %s' % (doc_action[0],))\n\n  try:\n    return decode_objects(action_type(*doc_action[1:]))\n  except TypeError as e:\n    raise TypeError(\"%s: %s\" % (doc_action[0], str(e)))\n\n\ndef convert_recursive_helper(converter, data):\n  \"\"\"\n  Given JSON-like data (a nested collection of lists or arrays), which may include Action tuples,\n  replaces all primitive values with converter(value). It should be used as follows:\n\n      def my_convert(data):\n        if data needs conversion:\n          return converted_value\n        return convert_recursive_helper(my_convert, data)\n  \"\"\"\n  if isinstance(data, dict):\n    return {converter(k): converter(v) for k, v in data.items()}\n  elif isinstance(data, list):\n    return [converter(el) for el in data]\n  elif isinstance(data, tuple):\n    return type(data)(*[converter(el) for el in data])\n  else:\n    return data\n\ndef convert_action_values(converter, action):\n  \"\"\"\n  Replaces all data values in an action that includes actual data with converter(value).\n  \"\"\"\n  if isinstance(action, (AddRecord, UpdateRecord)):\n    return type(action)(action.table_id, action.row_id,\n                        {k: converter(v) for k, v in action.columns.items()})\n  if isinstance(action, (BulkAddRecord, BulkUpdateRecord, ReplaceTableData, TableData)):\n    return type(action)(\n      action.table_id, action.row_ids,\n      {k: [converter(value) for value in values] for k, values in action.columns.items()}\n    )\n  return action\n\ndef convert_recursive_in_action(converter, data):\n  \"\"\"\n  Like convert_recursive_helper, but only values of Grist cells (i.e. individual values in data\n  columns) get passed through converter.\n  \"\"\"\n  def inner(data):\n    if isinstance(data, tuple):\n      return convert_action_values(converter, data)\n    return convert_recursive_helper(inner, data)\n  return inner(data)\n\ndef encode_objects(data):\n  return convert_recursive_in_action(objtypes.encode_object, data)\n\ndef decode_objects(data, decoder=objtypes.decode_object):\n  \"\"\"\n  Decode objects in values of a DocAction or a data structure containing DocActions.\n  \"\"\"\n  return convert_recursive_in_action(decoder, data)\n\ndef decode_bulk_values(bulk_values, decoder=objtypes.decode_object):\n  \"\"\"\n  Decode objects in values of the form {col_id: array_of_values}, as present in bulk DocActions\n  and UserActions.\n  \"\"\"\n  return {\n    k: [decoder(value) for value in values]\n    for k, values in bulk_values.items()\n  }\n\ndef transpose_bulk_action(bulk_action):\n  \"\"\"\n  Generates namedtuples for records in a bulk action such as BulkAddRecord. Such actions store\n  values by columns, so in effect this transposes them, yielding them by rows.\n  \"\"\"\n  items = sorted(bulk_action.columns.items())\n  RecordType = namedtuple('Record', ['id'] + [col_id for (col_id, values) in items])\n  for row in zip(bulk_action.row_ids, *[values for (col_id, values) in items]):\n    yield RecordType(*row)\n\n\ndef prune_actions(action_list, table_id, col_id):\n  \"\"\"\n  Modifies action_list in-place, removing any mention of (table_id, col_id). Both must be given\n  and not None in this implementation.\n  \"\"\"\n  keep = []\n  for a in action_list:\n    if getattr(a, 'table_id', None) == table_id:\n      if hasattr(a, 'columns'):\n        a.columns.pop(col_id, None)\n        if not a.columns:\n          continue\n      if getattr(a, 'col_id', None) == col_id:\n        continue\n    keep.append(a)\n  action_list[:] = keep\n"
  },
  {
    "path": "sandbox/grist/attribute_recorder.py",
    "content": "import reprlib\n\nimport records\n\n\nclass AttributeRecorder(object):\n  \"\"\"\n  Wrapper around a Record that records attribute accesses.\n  Used to generate a prompt for the AI with basic 'debugging' info.\n  \"\"\"\n  def __init__(self, inner, name, attributes):\n    assert isinstance(inner, records.Record)\n    self._inner = inner\n    self._name = name\n    self._attributes = attributes\n\n  def __getattr__(self, name):\n    \"\"\"\n    Record attribute access.\n    If the result is a Record or RecordSet, wrap that with AttributeRecorder\n    to also record nested attribute values.\n    \"\"\"\n    result = getattr(self._inner, name)\n    full_name = \"{}.{}\".format(self._name, name)\n    if isinstance(result, records.Record):\n      result = AttributeRecorder(result, full_name, self._attributes)\n    elif isinstance(result, records.RecordSet):\n      # Use a tuple to imply immutability so that the AI doesn't try appending.\n      # Don't try recording attributes of all contained records, just record the first access.\n      # Pretend that the attribute is always accessed from the first record for simplicity.\n      result = tuple(AttributeRecorder(r, full_name + \"[0]\", self._attributes) for r in result)\n    self._attributes.setdefault(full_name, safe_repr(result))\n    return result\n\n  def __repr__(self):\n    # The usual Record repr looks like Table1[2] which may surprise the AI.\n    return \"{}(id={})\".format(self._inner._table.table_id, self._inner._row_id)\n\n\narepr = reprlib.Repr()\narepr.maxlevel = 3\narepr.maxtuple = 3\narepr.maxlist = 3\narepr.maxarray = 3\narepr.maxdict = 4\narepr.maxset = 3\narepr.maxfrozenset = 3\narepr.maxdeque = 3\narepr.maxstring = 40\narepr.maxlong = 20\narepr.maxother = 60\n\n\ndef safe_repr(x):\n  try:\n    return arepr.repr(x)\n  except Exception:\n    # Copied from Repr.repr_instance in Python 3.\n    return '<%s instance at %#x>' % (x.__class__.__name__, id(x))\n"
  },
  {
    "path": "sandbox/grist/autocomplete_context.py",
    "content": "\"\"\"\nHelper class for handling formula autocomplete.\n\nIt's intended to use with rlcompleter.Completer. It allows finding global names using\nlowercase searches, and adds function usage information to some results.\n\"\"\"\nimport inspect\nimport re\nfrom collections import namedtuple, defaultdict\nimport reprlib\nimport builtins\n\nimport column\nfrom table import UserTable\n\n# funcname is the function name, e.g. \"MAX\"\n# argspec is the signature, e.g. \"(arg, *more_args)\"\n# isgrist is a boolean for whether this function should be in Grist documentation.\nCompletion = namedtuple('Completion', ['funcname', 'argspec', 'isgrist'])\n\ndef is_grist_func(func):\n  try:\n    return inspect.getmodule(func).__name__.startswith('functions.')\n  except Exception as e:\n    return e\n\nclass AutocompleteContext(object):\n  def __init__(self, usercode_context):\n    # rlcompleter is case-sensitive. This is hard to work around while maintaining attribute\n    # lookups. As a middle ground, we only introduce lowercase versions of all global names.\n    self._context = {\n      key: value for key, value in usercode_context.items()\n      # Don't propose unimplemented functions in autocomplete\n      if not (value and callable(value) and getattr(value, 'unimplemented', None))\n    }\n\n    # Add some common non-lowercase builtins, so that we include them into the case-handling below.\n    self._context.update({\n      'True': True,\n      'False': False,\n      'None': None,\n    })\n\n    # Prepare detailed Completion objects for functions where we can supply more info.\n    # TODO It would be nice to include builtin functions too, but getargspec doesn't work there.\n    self._functions = {\n      # Add in the important UserTable methods, with custom friendlier descriptions.\n      '.lookupOne': Completion('.lookupOne', '(colName=<value>, ...)', True),\n      '.lookupRecords': Completion('.lookupRecords', '(colName=<value>, ...)', True),\n      '.Record': Completion('.Record', '', True),\n      '.RecordSet': Completion('.RecordSet', '', True),\n    }\n    for key, value in self._context.items():\n      if value and callable(value):\n        argspec = str(inspect.signature(value))  # pylint: disable=no-member\n        self._functions[key] = Completion(key, argspec, is_grist_func(value))\n\n    for key, value in self._context.copy().items():\n      if isinstance(value, UserTable):\n        for func in [\".lookupOne\", \".lookupRecords\"]:\n          # Add fake variable names like `Table1.lookupOne` to the context.\n          # This allows the method to be suggested\n          # even before the user finishes typing the table name.\n          # Such a variable name isn't actually possible, so it doesn't matter what value we set.\n          self._context[key + func] = None\n          self._functions[key + func] = self._functions[func]._replace(funcname=key + func)\n\n    # Remember the original name for each lowercase one.\n    self._lowercase = {}\n    for key in self._context:\n      lower = key.lower()\n      if lower == key:\n        continue\n      if not any((lower in d) for d in (self._context, self._lowercase, builtins.__dict__)):\n        self._lowercase[lower] = key\n      else:\n        # This is still good enough to find a match for, and translate back to the original.\n        # It allows rlcompleter to match e.g. 'max' against 'max', 'Max', and 'MAX' (using keys\n        # 'max', 'max*', and 'max**', respectively).\n        lower += '*'\n        if lower in self._lowercase:\n          lower += '*'\n        self._lowercase[lower] = key\n\n    # Lowercase 'value' is used in trigger formulas, and is not the same as 'VALUE'.\n    self._lowercase.pop('value', None)\n\n    # Add the lowercase names to the context, and to the detailed completions in _functions.\n    for lower, key in self._lowercase.items():\n      self._context[lower] = self._context[key]\n      if key in self._functions:\n        self._functions[lower] = self._functions[key]\n\n  def get_context(self):\n    return self._context\n\n  def process_result(self, result):\n    # 'for' suggests the autocompletion 'for ' in python 3\n    result = result.rstrip()\n\n    # Table.lookup methods are special to allow completion just from the table name.\n    match = re.search(r'\\w+\\.(lookupOne|lookupRecords)$', result, re.IGNORECASE)\n    if match:\n      funcname = match.group().lower()\n      funcname = self._lowercase.get(match, funcname)\n      func = self._functions.get(funcname)\n      if func:\n        return tuple(func)\n\n    # Callables are returned by rlcompleter with a trailing \"(\".\n    if result.endswith('('):\n      funcname = result[0:-1]\n      dot = funcname.rfind(\".\")\n      key = funcname[dot:] if dot >= 0 else funcname\n      completion = self._functions.get(key)\n      # Return the detailed completion if we have it, or the result string otherwise.\n      if completion:\n        # For methods (eg \".lookupOne\"), use the original result as funcname (eg \"Foo.lookupOne\").\n        if dot >= 0:\n          varname = funcname[:dot]\n          funcname = self._lowercase.get(varname, varname) + key\n          completion = completion._replace(funcname=funcname)\n        return tuple(completion)\n\n      return result\n\n    # Return translation from lowercase if there is one, or the result string otherwise.\n    return self._lowercase.get(result, result)\n\n\ndef lookup_autocomplete_options(lookup_table, formula_table, reverse_only):\n  \"\"\"\n  Returns a list of strings to add to `Table.lookupRecords(` (or lookupOne)\n  to suggest arguments for the method.\n  `lookup_table` is the table that the method is being called on.\n  `formula_table` is the table that the formula is being written in.\n  `reverse_only` should be True to only suggest 'reverse reference' lookup arguments\n  (i.e. `<refcol>=$id`) and no other reference lookups (i.e. `<refcol>=$<other refcol>`).\n  \"\"\"\n  # dict mapping tables to lists of col_ids in `formula_table` that are references\n  # to the the table with that table_id.\n  # In particular `$id` is treated as a reference to `formula_table`.\n  ref_cols = defaultdict(list, {formula_table: [\"id\"]})\n  if not reverse_only:\n    for col_id, col in formula_table.all_columns.items():\n      # Note that we can't support reflist columns in the current table,\n      # as there is no `IN()` function to do the opposite of the `CONTAINS()` function.\n      if isinstance(col, column.ReferenceColumn) and column.is_visible_column(col_id):\n        ref_cols[col._target_table].append(col_id)\n\n  # Find referencing columns in the lookup table that target tables in ref_cols.\n  results = []\n  for lookup_col_id, lookup_col in lookup_table.all_columns.items():\n    if not column.is_visible_column(lookup_col_id):\n      continue\n    if isinstance(lookup_col, column.ReferenceColumn):\n      value_template = \"${}\"\n    elif isinstance(lookup_col, column.ReferenceListColumn):\n      value_template = \"CONTAINS(${})\"\n    else:\n      continue\n    target_table_id = lookup_col._target_table\n    for ref_col_id in ref_cols[target_table_id]:\n      value = value_template.format(ref_col_id)\n      results.append(\"{}={})\".format(lookup_col_id, value))\n  return results\n\n\ndef eval_suggestion(suggestion, rec, user):\n  \"\"\"\n  Evaluate a simple string of Python code,\n  and return a limited string representation of the result,\n  or None if this isn't possible.\n  Only supports code starting with `rec` or `user`,\n  followed by any number of attribute accesses, nothing else.\n  \"\"\"\n\n  if not isinstance(suggestion, str):\n    # `suggestion` is a tuple corresponding to a function\n    return None\n\n  parts = suggestion.split(\".\")\n  if parts[0] == \"rec\":\n    result = rec\n  elif parts[0] == \"user\":\n    result = user\n    if parts in ([\"user\"], [\"user\", \"LinkKey\"]):\n      # `user` and `user.LinkKey` have no useful string representation.\n      return None\n  else:\n    # Other variables are not supported since we can't know their values.\n    return None\n\n  parts = parts[1:]  # attribute names, if any\n  for part in parts:\n    try:\n      result = getattr(result, part)\n    except Exception:\n      return None\n\n  # Convert the value to a string and truncate the length if needed.\n  return repr_example(result)[:arepr.maxother]\n\n\nclass AutocompleteExampleRepr(reprlib.Repr):\n  \"\"\"\n  The default repr for dates and datetimes is long and ugly.\n  This class is used so that repr_example is mostly the same as repr,\n  but dates look the way they're formatted in Grist.\n  \"\"\"\n  @staticmethod\n  def repr_date(obj, _level):\n    # e.g. \"2019-12-31\"\n    return obj.strftime(\"%Y-%m-%d\")\n\n  @staticmethod\n  def repr_datetime(obj, _level):\n    # e.g. \"2019-12-31 1:23pm\"\n    return obj.strftime(\"%Y-%m-%d %-I:%M%p\").lower()\n\n\narepr = AutocompleteExampleRepr()\n# Set the same high value for all limits, because we just want to avoid\n# sending huge strings to the client, but the truncation shouldn't be visible in the UI.\narepr.maxother = 200\narepr.maxtuple = arepr.maxother\narepr.maxlist = arepr.maxother\narepr.maxarray = arepr.maxother\narepr.maxdict = arepr.maxother\narepr.maxset = arepr.maxother\narepr.maxfrozenset = arepr.maxother\narepr.maxdeque = arepr.maxother\narepr.maxstring = arepr.maxother\narepr.maxlong = arepr.maxother\n\ndef repr_example(x):\n  try:\n    return arepr.repr(x)\n  except Exception:\n    # Copied from Repr.repr_instance in Python 3.\n    return '<%s instance at %#x>' % (x.__class__.__name__, id(x))\n"
  },
  {
    "path": "sandbox/grist/codebuilder.py",
    "content": "import ast\nimport contextlib\nimport itertools\nimport logging\nimport re\nfrom os.path import commonprefix\n\nimport astroid\nimport asttokens\n\nimport friendly_errors\nimport textbuilder\nlog = logging.getLogger(__name__)\n\n\nDOLLAR_REGEX = re.compile(r'\\$(?=[a-zA-Z_][a-zA-Z_0-9]*)')\n\n# For functions needing lazy evaluation, the slice for which arguments to wrap in a lambda.\nLAZY_ARG_FUNCTIONS = {\n  'IF': slice(1, 3),\n  'ISERR': slice(0, 1),\n  'ISERROR': slice(0, 1),\n  'IFERROR': slice(0, 1),\n  'PEEK': slice(0, 1),\n}\n\n\nclass GristSyntaxError(SyntaxError):\n  \"\"\"\n  Indicates a formula is invalid in a Grist-specific way.\n  \"\"\"\n\n\n# Matches newlines that are followed by a non-empty line.\nindent_line_re = re.compile(r'^(?=.*\\S)', re.M)\n\ndef _indent(body, indent):\n  \"\"\"Indents all lines in body (which should be a textbuilder.Builder), except empty ones.\"\"\"\n  patches = textbuilder.make_regexp_patches(body.get_text(), indent_line_re, indent)\n  return textbuilder.Replacer(body, patches)\n\ndef _count_leading_spaces(text):\n  return len(text) - len(text.lstrip(' '))\n\n\ndef make_formula_body(formula, default_value, assoc_value=None, indent=''):\n  \"\"\"\n  Given a formula, returns a textbuilder.Builder object suitable to be the body of a function,\n  with the formula transformed to replace `$foo` with `rec.foo`, and to insert `return` if\n  appropriate. Assoc_value is associated with textbuilder.Text() to be returned by map_back_patch.\n  \"\"\"\n  formula_body = _do_make_formula_body(formula, default_value, assoc_value=assoc_value)\n  indented_formula_body = _indent(formula_body, indent=indent)\n\n  if indent and getattr(formula_body, 'have_multiline_strings', None):\n    # Here we fix multi-line strings, i.e. triple-quoted string literals with actual newlines in\n    # them, which should not have gotten indented. It's easier to indent wholesale above, then\n    # unindent only the parts that we can positively identify as multi-line strings in the AST.\n\n    # The \"have_multiline_strings\" attribute is an optimization, to skip all this work when we\n    # never had a multiline string in the first place. (There is a noticeable slowdown to tests\n    # if we apply the re-parsing below to _all_ formula.)\n\n    # Add a dummy function definition, to enable parsing the already-indented body.\n    dummy_def = 'def f():\\n'\n    builder = textbuilder.Combiner([dummy_def, indented_formula_body])\n\n    # Always include a removal of the dummy function definition we added.\n    unindent_patches = []\n    unindent_patches.append(textbuilder.Patch(0, len(dummy_def), dummy_def, ''))\n\n    atok = asttokens.ASTText(builder.get_text())\n    for node in ast.walk(atok.tree):\n      if isinstance(node, (ast.Constant, ast.JoinedStr)) and \"\\n\" in atok.get_text(node):\n        # We have a constant or f-string that spans multiple lines. If so, revert its indentation.\n        start, end = atok.get_text_range(node)\n        indented_text = atok.get_text(node)\n        unindented_text = indented_text.replace('\\n' + indent, '\\n')\n        unindent_patches.append(textbuilder.Patch(start, end, indented_text, unindented_text))\n\n    return textbuilder.Replacer(builder, unindent_patches)\n  else:\n    return indented_formula_body\n\n\n\ndef _do_make_formula_body(formula, default_value, assoc_value=None):\n  # For documentation, see make_formula_body above.\n\n  if isinstance(formula, bytes):\n    formula = formula.decode('utf8')\n\n  if not formula.strip():\n    return textbuilder.Text('return ' + repr(default_value), assoc_value)\n\n  formula_builder_text = textbuilder.Text(formula, assoc_value)\n\n  # Remove any common leading whitespace. In python, extra indent should not be an error, but\n  # it is in Grist because we parse the formula body before it gets inserted into a function (i.e.\n  # as if at module level). We have to do it using textbuilder as elsewhere (making changes to a\n  # formula without remembering positions of changes will lead to errors when renaming).\n  formula_builder_text = _dedent(formula_builder_text)\n  formula = formula_builder_text.get_text()\n\n  # Start with a temporary builder, since we need to translate \"$\" before we can parse the code at\n  # all (namely, we turn '$foo' into 'DOLLARfoo' first). Once we can parse the code, we'll create\n  # a proper set of patches. Note that we initially translate into 'DOLLARfoo' rather than\n  # 'rec.foo', so that the translated entity is a single token: this makes for more precisely\n  # reported errors if there are any.\n  tmp_patches = textbuilder.make_regexp_patches(formula, DOLLAR_REGEX, 'DOLLAR')\n  # Use a new \"Text\" builder from the updated formula. It is only  used to translate positions in\n  # \"DOLLARfoo\" formulas back to positions in \"$foo\" formulas (for creating patches to\n  # formula_builder_text), and then discarded. In particular, assoc_value doesn't matter for it.\n  tmp_formula = textbuilder.Replacer(textbuilder.Text(formula, None), tmp_patches)\n\n  atok = asttokens.ASTText(tmp_formula.get_text(), filename=code_filename)\n  # Parse the formula into an abstract syntax tree (AST), catching syntax errors.\n  # Constructing ASTText doesn't parse the code, but the .tree property does.\n  try:\n    tree = atok.tree\n  except SyntaxError as e:\n    return textbuilder.Text(_create_syntax_error_code(tmp_formula, formula, e))\n\n  # Once we have a tree, go through it and create a subset of the dollar patches that are actually\n  # relevant. E.g. this is where we'll skip the \"$foo\" patches that appear in strings or comments.\n  patches = []\n  have_multiline_strings = False\n  for node in ast.walk(tree):\n    if isinstance(node, (ast.Constant, ast.JoinedStr)) and \"\\n\" in atok.get_text(node):\n      have_multiline_strings = True\n\n    if isinstance(node, ast.Name) and node.id.startswith('DOLLAR'):\n      startpos = atok.get_text_range(node)[0]\n      input_pos = tmp_formula.map_back_offset(startpos)\n      m = DOLLAR_REGEX.match(formula, input_pos)\n      # If there is no match, then we must have had a \"DOLLARblah\" identifier that didn't come\n      # from translating a \"$\" prefix.\n      if m:\n        patches.append(textbuilder.make_patch(formula, m.start(0), m.end(0), 'rec.'))\n\n    # Wrap arguments to the top-level \"IF()\" function into lambdas, for lazy evaluation. This is\n    # to ensure it's not affected by an exception in the unused value, to match Excel behavior.\n    if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):\n      lazy_args_slice = LAZY_ARG_FUNCTIONS.get(node.func.id)\n      if lazy_args_slice:\n        for arg in node.args[lazy_args_slice]:\n          start, end = map(tmp_formula.map_back_offset, atok.get_text_range(arg))\n          patches.append(textbuilder.make_patch(formula, start, start, 'lambda: ('))\n          patches.append(textbuilder.make_patch(formula, end, end, ')'))\n\n  # If the last statement is an expression that has its result unused (an ast.Expr node),\n  # then insert a \"return\" keyword.\n  last_statement = tree.body[-1] if tree.body else None\n  if isinstance(last_statement, ast.Expr):\n    startpos = atok.get_text_range(last_statement)[0]\n    input_pos = tmp_formula.map_back_offset(startpos)\n    patches.append(textbuilder.make_patch(formula, input_pos, input_pos, \"return \"))\n  elif last_statement is None:\n    # If we have an empty body (e.g. just a comment), add a 'pass' at the end.\n    patches.append(textbuilder.make_patch(formula, len(formula), len(formula), '\\npass'))\n  elif not any(\n      # Raise an error if the user forgot to return anything. For performance:\n      # - Use type() instead of isinstance()\n      # - Check last_statement first to try avoiding walking the tree\n      type(node) == ast.Return  # pylint: disable=unidiomatic-typecheck\n      for node in itertools.chain([last_statement], ast.walk(tree))\n  ):\n    message = \"No `return` statement, and the last line isn't an expression.\"\n    if isinstance(last_statement, ast.Assign):\n      message += \" If you want to check for equality, use `==` instead of `=`.\"\n    error = GristSyntaxError(message, ('<string>', 1, 1, \"\"))\n    return textbuilder.Text(_create_syntax_error_code(tmp_formula, formula, error))\n\n  # Apply the new set of patches to the original formula to get the real output.\n  final_formula = textbuilder.Replacer(formula_builder_text, patches)\n\n  # Somewhat hackily pass back an optimization hint for whether this formula needs to unindent\n  # multiline strings.\n  final_formula.have_multiline_strings = have_multiline_strings\n\n  # Try parsing again before returning it just in case we have new syntax errors. These are\n  # possible in cases when a single token ('DOLLARfoo') is valid but an expression ('rec.foo') is\n  # not, e.g. `foo($bar=1)` or `def $foo()`.\n  # Also check for common mistakes: assigning to `rec` or its attributes (e.g. `$foo = 1`).\n  with use_inferences(InferRecAssignment, InferRecAttrAssignment):\n    try:\n      astroid.parse(final_formula.get_text())\n    except (astroid.AstroidSyntaxError, SyntaxError) as e:\n      error = getattr(e, \"error\", e)  # extract SyntaxError from AstroidSyntaxError\n      return textbuilder.Text(_create_syntax_error_code(final_formula, formula, error))\n\n  # We return the text-builder object whose .get_text() is the final formula.\n  return final_formula\n\n\n_whitespace_only_re = re.compile('^[ \\t]+$', re.MULTILINE)\n_leading_whitespace_re = re.compile('(^[ \\t]*)(?:[^ \\t\\n])', re.MULTILINE)\n\ndef _dedent(body):\n  \"\"\"\n  Dedents all lines in body (which should be a textbuilder.Builder), which share leading\n  whitespace, like textwrap.dedent().\n  \"\"\"\n  text = body.get_text()\n  text_stripped = _whitespace_only_re.sub('', text)\n  indents = _leading_whitespace_re.findall(text_stripped)\n  shared_indent = commonprefix(indents)\n  if shared_indent:\n    regexp = re.compile(r'^' + shared_indent, re.MULTILINE)\n    patches = textbuilder.make_regexp_patches(text, regexp, '')\n    return textbuilder.Replacer(body, patches)\n  return body\n\n\ndef get_dollar_replacer(formula):\n  \"\"\"\n  Returns a textbuilder.Replacer that would replace all dollar signs (\"$\") in the given\n  formula with \"rec.\". The Replacer tracks extra info we can later use to restore the\n  dollar signs back. To get the processed text, call .get_text() on the Replacer.\n  \"\"\"\n  formula_builder_text = textbuilder.Text(formula)\n  tmp_patches = textbuilder.make_regexp_patches(formula, DOLLAR_REGEX, 'DOLLAR')\n  tmp_formula = textbuilder.Replacer(formula_builder_text, tmp_patches)\n  atok = asttokens.ASTText(tmp_formula.get_text())\n  patches = []\n  for node in ast.walk(atok.tree):\n    if isinstance(node, ast.Name) and node.id.startswith('DOLLAR'):\n      startpos = atok.get_text_range(node)[0]\n      input_pos = tmp_formula.map_back_offset(startpos)\n      m = DOLLAR_REGEX.match(formula, input_pos)\n      if m:\n        patches.append(textbuilder.make_patch(formula, m.start(0), m.end(0), 'rec.'))\n  final_formula = textbuilder.Replacer(formula_builder_text, patches)\n  return final_formula\n\n\ndef _create_syntax_error_code(builder, input_text, err):\n  \"\"\"\n  Returns the text for a function that raises the given SyntaxError and includes the offending\n  code in a commented-out form. In addition, it translates the error's position from builder's\n  output to input_text.\n  \"\"\"\n  output_ln = asttokens.LineNumbers(builder.get_text())\n  input_ln = asttokens.LineNumbers(input_text)\n  # A SyntaxError contains .lineno and .offset (1-based), which we need to translate to offset\n  # within the transformed text, so that it can be mapped back to an offset in the original text,\n  # and finally translated back into a line number and 1-based position to report to the user. An\n  # example is that \"$x*\" is translated to \"return x*\", and the syntax error in the transformed\n  # python code (line 2 offset 9) needs to be translated to be in line 2 offset 3.\n  output_offset = output_ln.line_to_offset(err.lineno, err.offset - 1 if err.offset else 0)\n  input_offset = builder.map_back_offset(output_offset)\n  line, col = input_ln.offset_to_line(input_offset)\n  input_text_line = input_text.splitlines()[line - 1]\n\n  message = err.args[0]\n  err_type = type(err)\n  if isinstance(err, GristSyntaxError):\n    # Just use SyntaxError in the final code\n    err_type = SyntaxError\n  else:\n    # Add explanation from friendly-traceback.\n    # Only supported in Python 3.\n    # Not helpful for Grist-specific errors.\n    # Needs to use the source code, so save it to its source cache.\n    save_to_linecache(builder.get_text())\n    message += friendly_errors.friendly_message(err)\n\n  return \"%s\\nraise %s(%r, ('usercode', %r, %r, %r))\" % (\n    textbuilder.line_start_re.sub('# ', input_text.rstrip()),\n    err_type.__name__, message, line, col + 1, input_text_line)\n\n#----------------------------------------------------------------------\n\ndef infer(node):\n  try:\n    return next(node.infer(), None)\n  except astroid.exceptions.InferenceError as e:\n    return \"InferenceError on %r: %r\" % (node, e)\n\n\n_lookup_method_names = ('lookupOne', 'lookupRecords')\n_prev_next_functions = ('PREVIOUS', 'NEXT', 'RANK')\n_lookup_find_methods = ('lt', 'le', 'gt', 'ge', 'eq', 'previous', 'next')\n\ndef _is_table(node):\n  \"\"\"\n  Return true if obj is a class defining a user table.\n  \"\"\"\n  return (isinstance(node, astroid.nodes.ClassDef) and node.decorators and\n          node.decorators.nodes[0].as_string() == 'grist.UserTable')\n\ndef _is_local(node):\n  \"\"\"\n  Returns true if node is a Name node for an innermost variable.\n  \"\"\"\n  return isinstance(node, astroid.nodes.Name) and node.name in node.scope().locals\n\n\n@contextlib.contextmanager\ndef use_inferences(*inference_tips):\n  transform_args = [(cls.node_class, astroid.inference_tip(cls.infer), cls.filter)\n                    for cls in inference_tips]\n  for args in transform_args:\n    astroid.MANAGER.register_transform(*args)\n  yield\n  for args in transform_args:\n    astroid.MANAGER.unregister_transform(*args)\n\n\nclass InferenceTip:\n  \"\"\"\n  Base class for inference tips. A derived class can implement the filter() and infer() class\n  methods, and then register() will put that inference helper into use.\n  \"\"\"\n  node_class = None\n\n  @classmethod\n  def filter(cls, node):\n    raise NotImplementedError()\n\n  @classmethod\n  def infer(cls, node, context):\n    raise NotImplementedError()\n\n\nclass InferReferenceColumn(InferenceTip):\n  \"\"\"\n  Inference helper to treat the return value of `grist.Reference(\"Foo\")` as an instance of the\n  table `Foo`.\n  \"\"\"\n  node_class = astroid.nodes.Call\n\n  @classmethod\n  def filter(cls, node):\n    return (isinstance(node.func, astroid.nodes.Attribute) and\n            node.func.as_string() in ('grist.Reference', 'grist.ReferenceList'))\n\n  @classmethod\n  def infer(cls, node, context=None):\n    table_id = node.args[0].value\n    table_class = next(node.root().igetattr(table_id), None)\n    if table_class:\n      yield astroid.bases.Instance(table_class)\n\n\ndef _get_formula_type(function_node):\n  decorators = function_node.decorators.nodes if function_node.decorators else ()\n  for dec in decorators:\n    if (isinstance(dec, astroid.nodes.Call) and\n        dec.func.as_string() == 'grist.formulaType'):\n      return dec.args[0]\n  return None\n\n\nclass InferReferenceFormula(InferenceTip):\n  \"\"\"\n  Inference helper to treat functions decorated with `grist.formulaType(grist.Reference(\"Foo\"))`\n  as returning instances of table `Foo`.\n  \"\"\"\n  node_class = astroid.nodes.FunctionDef\n\n  @classmethod\n  def filter(cls, node):\n    # All methods on tables are really used as properties.\n    return _is_table(node.parent.frame())\n\n  @classmethod\n  def infer(cls, node, context=None):\n    ftype = _get_formula_type(node)\n    if ftype and InferReferenceColumn.filter(ftype):\n      return InferReferenceColumn.infer(ftype, context)\n    return node.infer_call_result(node.parent.frame(), context)\n\n\nclass InferLookupReference(InferenceTip):\n  \"\"\"\n  Inference helper to treat the return value of `Table.lookupRecords(...)` as returning instances\n  of table `Table`.\n  \"\"\"\n  node_class = astroid.nodes.Call\n\n  @classmethod\n  def filter(cls, node):\n    return (isinstance(node.func, astroid.nodes.Attribute) and\n            node.func.attrname in _lookup_method_names and\n            _is_table(infer(node.func.expr)))\n\n  @classmethod\n  def infer(cls, node, context=None):\n    yield astroid.bases.Instance(infer(node.func.expr))\n\n\nclass InferAllReference(InferenceTip):\n  \"\"\"\n  Inference helper to treat the return value of `Table.all` as returning instances\n  of table `Table`.\n  \"\"\"\n  node_class = astroid.nodes.Attribute\n\n  @classmethod\n  def filter(cls, node):\n    return node.attrname == \"all\" and _is_table(infer(node.expr))\n\n  @classmethod\n  def infer(cls, node, context=None):\n    yield astroid.bases.Instance(infer(node.expr))\n\n\nclass InferLookupFindResult(InferenceTip):\n  \"\"\"\n  Inference helper to treat the return value of `Table.lookupRecords(...).find.lt(...)` as\n  returning instances of table `Table`.\n  \"\"\"\n  node_class = astroid.nodes.Call\n\n  @classmethod\n  def filter(cls, node):\n    func = node.func\n    if isinstance(func, astroid.nodes.Attribute) and func.attrname in _lookup_find_methods:\n      p_expr = func.expr\n      if isinstance(p_expr, astroid.nodes.Attribute) and p_expr.attrname in ('find', '_find'):\n        obj = infer(p_expr.expr)\n        if isinstance(obj, astroid.bases.Instance) and _is_table(obj._proxied):\n          return True\n    return False\n\n  @classmethod\n  def infer(cls, node, context=None):\n    # A bit of fuzziness here: node.func.expr.expr is the result of lookupRecords(). It so happens\n    # that at the moment it is already of type Instance(table), as if a single record rather than\n    # a list, to support recognizing `.ColId` attributes. So we return the same type.\n    yield infer(node.func.expr.expr)\n\n\nclass InferPrevNextResult(InferenceTip):\n  \"\"\"\n  Inference helper to treat the return value of PREVIOUS(...) and NEXT(...) as returning instances\n  of table `Table`.\n  \"\"\"\n  node_class = astroid.nodes.Call\n\n  @classmethod\n  def filter(cls, node):\n    return (isinstance(node.func, astroid.nodes.Name) and\n        node.func.name in _prev_next_functions and\n        node.args)\n\n  @classmethod\n  def infer(cls, node, context=None):\n    yield infer(node.args[0])\n\n\nclass InferComprehensionBase(InferenceTip):\n  node_class = astroid.nodes.AssignName\n  reference_inference_class = None\n\n  @classmethod\n  def filter(cls, node):\n    compr = node.parent\n    if not isinstance(compr, astroid.nodes.Comprehension):\n      return False\n    if isinstance(compr.iter, cls.reference_inference_class.node_class):\n      return cls.reference_inference_class.filter(compr.iter)\n    return False\n\n  @classmethod\n  def infer(cls, node, context=None):\n    return cls.reference_inference_class.infer(node.parent.iter)\n\n\nclass InferLookupComprehension(InferComprehensionBase):\n  reference_inference_class = InferLookupReference\n\n\nclass InferAllComprehension(InferComprehensionBase):\n  reference_inference_class = InferAllReference\n\n\nclass InferRecAssignment(InferenceTip):\n  \"\"\"\n  Inference helper to raise exception on assignment to `rec`.\n  \"\"\"\n  node_class = astroid.nodes.AssignName\n\n  @classmethod\n  def filter(cls, node):\n    if node.name == 'rec':\n      raise GristSyntaxError('Grist disallows assignment to the special variable \"rec\"',\n          ('<string>', node.lineno, node.col_offset, \"\"))\n\n  @classmethod\n  def infer(cls, node, context):\n    raise NotImplementedError()\n\nclass InferRecAttrAssignment(InferenceTip):\n  \"\"\"\n  Inference helper to raise exception on assignment to `rec`.\n  \"\"\"\n  node_class = astroid.nodes.AssignAttr\n\n  @classmethod\n  def filter(cls, node):\n    if isinstance(node.expr, astroid.nodes.Name) and node.expr.name == 'rec':\n      raise GristSyntaxError(\"You can't assign a value to a column with `=`. \"\n                             \"If you mean to check for equality, use `==` instead.\",\n          ('<string>', node.lineno, node.col_offset, \"\"))\n\n  @classmethod\n  def infer(cls, node, context):\n    raise NotImplementedError()\n\n#----------------------------------------------------------------------\n\ndef parse_grist_names(builder):\n  \"\"\"\n  Returns a list of tuples (col_info, start_pos, table_id, col_id):\n    col_info:   (table_id, col_id) for the formula the name is found in. It is the value passed\n                in by gencode.py to codebuilder.make_formula_body().\n    start_pos:  Index of the start character of the name in col_info.formula\n    table_id:   Parsed name when the tuple is for a table name; the name of the column's table\n                when the tuple is for a column name.\n    col_id:     None when tuple is for a table name; col_id when the tuple is for a column name.\n  \"\"\"\n  code_text = builder.get_text()\n\n  with use_inferences(InferReferenceColumn, InferReferenceFormula, InferLookupReference,\n                      InferLookupComprehension, InferAllReference, InferAllComprehension,\n                      InferLookupFindResult, InferPrevNextResult):\n    atok = asttokens.ASTText(code_text, tree=astroid.builder.parse(code_text))\n\n  def make_tuple(start, end, table_id, col_id):\n    name = col_id or table_id\n    assert end - start == len(name)\n    patch = textbuilder.Patch(start, end, name, name)\n    assert code_text[start:end] == name\n    patch_source = builder.map_back_patch(patch)\n    if not patch_source:\n      return None\n    in_text, in_value, in_patch = patch_source\n    if in_value:\n      return (in_value, in_patch.start, table_id, col_id)\n    return None\n\n  # Helper for collecting column IDs mentioned in order_by/group_by parameters, so that\n  # those can be updated when a column is renamed.\n  def list_order_group_by_tuples(table_id, node):\n    for start, end, col_id in parse_order_group_by(atok, node):\n      if code_text[start:end] == col_id:\n        yield make_tuple(start, end, table_id, col_id)\n\n  parsed_names = []\n  for node in asttokens.util.walk(atok.tree, include_joined_str=True):\n    if isinstance(node, astroid.nodes.Name):\n      obj = infer(node)\n      if _is_table(obj) and not _is_local(node):\n        start, end = atok.get_text_range(node)\n        parsed_names.append(make_tuple(start, end, node.name, None))\n\n    elif isinstance(node, astroid.nodes.Attribute):\n      obj = infer(node.expr)\n      if isinstance(obj, astroid.bases.Instance):\n        cls = obj._proxied\n        if _is_table(cls):\n          end = atok.get_text_range(node)[1]\n          start = end - len(node.attrname)\n          if code_text[start:end] == node.attrname:\n            parsed_names.append(make_tuple(start, end, cls.name, node.attrname))\n\n    elif isinstance(node, astroid.nodes.Keyword):\n      func = node.parent.func\n      if isinstance(func, astroid.nodes.Attribute) and func.attrname in _lookup_method_names:\n        obj = infer(func.expr)\n        if _is_table(obj) and node.arg is not None:   # Skip **kwargs, which have arg value of None\n          table_id = obj.name\n          start = atok.get_text_range(node)[0]\n          end = start + len(node.arg)\n          if node.arg == 'order_by':\n            # Rename values in 'order_by' arguments to lookup methods.\n            parsed_names.extend(list_order_group_by_tuples(table_id, node.value))\n          elif code_text[start:end] == node.arg:\n            parsed_names.append(make_tuple(start, end, table_id, node.arg))\n\n      elif (isinstance(func, astroid.nodes.Name)\n          # Rename values in 'order_by' and 'group_by' arguments to PREVIOUS() and NEXT().\n          and func.name in _prev_next_functions\n          and node.arg in ('order_by', 'group_by')\n          and node.parent.args):\n        obj = infer(node.parent.args[0])\n        if isinstance(obj, astroid.bases.Instance):\n          cls = obj._proxied\n          if _is_table(cls):\n            table_id = cls.name\n            parsed_names.extend(list_order_group_by_tuples(table_id, node.value))\n\n  return [name for name in parsed_names if name]\n\n\ncode_filename = \"usercode\"\n\ndef parse_order_group_by(atok, node):\n  \"\"\"\n  order_by and group_by parameters take the form of a column ID string, optionally prefixed by a\n  \"-\", or a tuple of them. We parse out the list of (start, end, col_id) tuples for each column ID\n  mentioned, to support automatic formula updates when a mentioned column is renamed.\n  \"\"\"\n  if isinstance(node, astroid.nodes.Const):\n    if isinstance(node.value, str):\n      start, end = atok.get_text_range(node)\n      # Account for opening/closing quote, and optional leading \"-\".\n      return [(start + 2, end - 1, node.value[1:]) if node.value.startswith(\"-\") else\n              (start + 1, end - 1, node.value)]\n  elif isinstance(node, astroid.nodes.Tuple):\n    return [t for e in node.elts for t in parse_order_group_by(atok, e)]\n  return []\n\ndef save_to_linecache(source_code):\n  \"\"\"\n  Makes source code available to friendly-traceback and traceback formatting in general.\n  \"\"\"\n  import friendly_traceback.source_cache    # pylint: disable=import-error,import-outside-toplevel\n\n  friendly_traceback.source_cache.cache.add(code_filename, source_code)\n"
  },
  {
    "path": "sandbox/grist/column.py",
    "content": "import json\nimport logging\nimport types\nfrom collections import namedtuple\nfrom numbers import Number\n\nimport actions\nimport depend\nimport objtypes\nimport usertypes\nimport relabeling\nimport relation\nimport reverse_references\nimport moment\nfrom sortedcontainers import SortedListWithKey\n\nlog = logging.getLogger(__name__)\n\nMANUAL_SORT = 'manualSort'\nMANUAL_SORT_COL_INFO = {\n  'id': MANUAL_SORT,\n  'type': 'ManualSortPos',\n  'formula': '',\n  'isFormula': False\n}\nMANUAL_SORT_DEFAULT = 2147483647.0\n\nSPECIAL_COL_IDS = {'id', MANUAL_SORT}\n\n\ndef is_visible_column(col_id):\n  \"\"\"\n  Returns whether this is an id of a column that's intended to be shown to the user.\n  \"\"\"\n  return col_id not in SPECIAL_COL_IDS and not col_id.startswith(('#', 'gristHelper_'))\n\ndef is_virtual_column(col_id):\n  \"\"\"\n  Returns whether col_id is of a special column that does not get communicated outside of the\n  sandbox. Lookup maps are an example.\n  \"\"\"\n  return col_id.startswith('#')\n\ndef is_validation_column_name(name):\n  return name.startswith(\"validation___\")\n\nColInfo = namedtuple('ColInfo', ('type_obj', 'is_formula', 'method'))\n\ndef get_col_info(col_model, default_func=None):\n  if isinstance(col_model, types.FunctionType):\n    type_obj = getattr(col_model, 'grist_type', usertypes.Any())\n    return ColInfo(type_obj, is_formula=True, method=col_model)\n  else:\n    return ColInfo(col_model, is_formula=False, method=col_model.default_func or default_func)\n\n\nclass BaseColumn(object):\n  \"\"\"\n  BaseColumn holds a column of data, whether raw or computed.\n  \"\"\"\n  def __init__(self, table, col_id, col_info):\n    self.type_obj = col_info.type_obj\n    self._is_right_type = self.type_obj.is_right_type\n    self._data = []\n    self.col_id = col_id\n    self.table_id = table.table_id\n    self.node = depend.Node(self.table_id, col_id)\n    self._is_formula = col_info.is_formula\n    self._is_private = bool(col_info.method) and getattr(col_info.method, 'is_private', False)\n    self.update_method(col_info.method)\n\n    # Always initialize to include the special empty record at index 0.\n    self.growto(1)\n\n  def update_method(self, method):\n    \"\"\"\n    After rebuilding user code, we reuse existing column objects, but need to replace their\n    'method' function. The method may refer to variables in the generated \"usercode\" module, and\n    it's important that all such references are to the rebuilt \"usercode\" module.\n    \"\"\"\n    self.method = method\n\n  def is_formula(self):\n    \"\"\"\n    Whether this is a formula column. Note that a non-formula column may have an associated\n    method, which is used to fill in defaults when a record is added.\n    \"\"\"\n    return self._is_formula\n\n  def is_private(self):\n    \"\"\"\n    Returns whether this method is private to the sandbox. If so, changes to this column do not\n    get communicated to outside the sandbox via actions.\n    \"\"\"\n    return self._is_private\n\n  def has_formula(self):\n    \"\"\"\n    has_formula is true if formula is set, whether or not this is a formula column.\n    \"\"\"\n    return self.method is not None\n\n  def clear(self):\n    self._data = []\n    self.growto(1)    # Always include the special empty record at index 0.\n\n  def destroy(self):\n    \"\"\"\n    Called when the column is deleted.\n    \"\"\"\n    del self._data[:]\n\n  def growto(self, size):\n    if len(self._data) < size:\n      self._data.extend([self.getdefault()] * (size - len(self._data)))\n\n  def size(self):\n    return len(self._data)\n\n  def set(self, row_id, value):\n    \"\"\"\n    Sets the value of this column for the given row_id. Value should be as returned by convert(),\n    i.e. of the right type, or alttext, or error (but should NOT be random wrong types).\n    \"\"\"\n    try:\n      self._data[row_id] = value\n    except IndexError:\n      self.growto(row_id + 1)\n      self._data[row_id] = value\n\n  def unset(self, row_id):\n    \"\"\"\n    Sets the value for the given row_id to the default value.\n    \"\"\"\n    self.set(row_id, self.getdefault())\n\n  def get_cell_value(self, row_id, restore=False):\n    \"\"\"\n    Returns the \"rich\" value for the given row_id, i.e. the value that would be seen by formulas.\n    E.g. for ReferenceColumns it'll be the referred-to Record object. For cells containing\n    alttext, this will be an AltText object. For RaisedException objects that represent a thrown\n    error, this will re-raise that error.\n    \"\"\"\n    raw = self.raw_get(row_id)\n\n    if isinstance(raw, objtypes.RaisedException):\n      # For trigger formulas, we want to restore the previous value to recalculate\n      # the cell one more time.\n      if restore and raw.has_user_input():\n        raw = raw.user_input\n      elif isinstance(raw.error, depend.CircularRefError):\n        # Wrapping a CircularRefError in a CellError is often redundant, but\n        # TODO a CellError is still useful if the calling cell is not involved in the cycle\n        raise raw.error\n      else:\n        raise objtypes.CellError(self.table_id, self.col_id, row_id, raw.error)\n\n    # Inline _convert_raw_value here because this is particularly hot code, called on every access\n    # of any data field in a formula.\n    if self._is_right_type(raw):\n      return self._make_rich_value(raw)\n    return self._alt_text(raw)\n\n  def _convert_raw_value(self, raw):\n    if self._is_right_type(raw):\n      return self._make_rich_value(raw)\n    return self._alt_text(raw)\n\n  def _alt_text(self, raw):\n    return usertypes.AltText(str(raw), self.type_obj.typename())\n\n  def _make_rich_value(self, typed_value):\n    \"\"\"\n    Called by get_cell_value() with a value of the right type for this column. Should be\n    implemented by derived classes to produce a \"rich\" version of the value.\n    \"\"\"\n    # pylint: disable=no-self-use\n    return typed_value\n\n  def raw_get(self, row_id):\n    \"\"\"\n    Returns the value stored for the given row_id. This may be an error or alttext, and it does\n    not convert to a richer object.\n    \"\"\"\n    try:\n      return self._data[row_id]\n    except IndexError:\n      return self.getdefault()\n\n  def safe_get(self, row_id):\n    \"\"\"\n    Returns a value of the right type, or the default value if the stored value had a wrong type.\n    \"\"\"\n    raw = self.raw_get(row_id)\n    return raw if self.type_obj.is_right_type(raw) else self.getdefault()\n\n  def getdefault(self):\n    \"\"\"\n    Returns the default value for this column. This is a static default; the implementation of\n    \"default formula\" logic is separate.\n    \"\"\"\n    return self.type_obj.default\n\n  def sample_value(self):\n    \"\"\"\n    Returns a sample value for this column, used for auto-completions. E.g. for a date, this\n    returns an actual datetime object rather than None (only its attributes should matter).\n    \"\"\"\n    return self.type_obj.default\n\n  def copy_from_column(self, other_column):\n    \"\"\"\n    Replace this column's data entirely with data from another column of the same exact type.\n    \"\"\"\n    self._data[:] = other_column._data\n\n  def convert(self, value_to_convert):\n    \"\"\"\n    Converts a value of any type to this column's type, returning either the converted value (for\n    which is_right_type is true), or an alttext string, or an error object.\n    \"\"\"\n    return self.type_obj.convert(value_to_convert)\n\n  def prepare_new_values(self, row_ids, values, ignore_data=False, action_summary=None):\n    \"\"\"\n    This allows us to modify values and also produce adjustments to existing records.\n\n    Returns the pair (new_values, adjustments), where new_values is a list to replace `values`\n    (one for each row_id), and adjustments is a list of additional docactions to apply, e.g. to\n    adjust other rows.\n\n    If ignore_data is True, makes adjustments without regard to the existing data; this is used\n    for processing ReplaceTableData actions.\n    \"\"\"\n    # pylint: disable=no-self-use, unused-argument\n    return values, []\n\n  def recalc_from_reverse_values(self):\n    pass    # Only two-way references implement this\n\n\nclass DataColumn(BaseColumn):\n  \"\"\"\n  DataColumn describes a column of raw data, and holds it.\n  \"\"\"\n  pass\n\n\nclass ChoiceColumn(DataColumn):\n  def rename_choices(self, renames):\n    row_ids = []\n    values = []\n    for row_id, value in enumerate(self._data):\n      if value is not None and self.type_obj.is_right_type(value):\n        value = self._rename_cell_choice(renames, value)\n        if value is not None:\n          row_ids.append(row_id)\n          values.append(value)\n    return row_ids, values\n\n  def _rename_cell_choice(self, renames, value):\n    # pylint: disable=no-self-use\n    return renames.get(value)\n\n\nclass BoolColumn(BaseColumn):\n  def set(self, row_id, value):\n    # When 1 or 1.0 is loaded, we should see it as True, and similarly 0 as False. This is similar\n    # to how, after loading a number into a DateColumn, we should see a date, except we adjust\n    # booleans at set() time.\n    bool_value = True if value == 1 else (False if value == 0 else value)\n    super(BoolColumn, self).set(row_id, bool_value)\n\nclass NumericColumn(BaseColumn):\n  def set(self, row_id, value):\n    # Make sure any integers are treated as floats to avoid truncation.\n    # Uses `type(value) == int` rather than `isintance(value, int)` to specifically target\n    # ints and not bools (which are singleton instances the class int in python).  But\n    # perhaps something should be done about bools also?\n    # pylint: disable=unidiomatic-typecheck\n    super(NumericColumn, self).set(row_id, float(value) if type(value) == int else value)\n\n_sample_date = moment.ts_to_date(0)\n_sample_datetime = moment.ts_to_dt(0, None, moment.TZ_UTC)\n\nclass DateColumn(NumericColumn):\n  \"\"\"\n  DateColumn contains numerical timestamps represented as seconds since epoch, in type float,\n  to midnight of specific UTC dates. Accessing them yields date objects.\n  \"\"\"\n  def _make_rich_value(self, typed_value):\n    return moment.ts_to_date(typed_value) if isinstance(typed_value, float) else typed_value\n\n  def sample_value(self):\n    return _sample_date\n\nclass DateTimeColumn(NumericColumn):\n  \"\"\"\n  DateTimeColumn contains numerical timestamps represented as seconds since epoch, in type float,\n  and a timestamp associated with the column. Accessing them yields datetime objects.\n  \"\"\"\n  def __init__(self, table, col_id, col_info):\n    super(DateTimeColumn, self).__init__(table, col_id, col_info)\n    self._timezone = col_info.type_obj.timezone\n\n  def _make_rich_value(self, typed_value):\n    return (moment.ts_to_dt(typed_value, self._timezone)\n        if isinstance(typed_value, float) else typed_value)\n\n  def sample_value(self):\n    return _sample_datetime\n\n\nclass SafeSortKey(object):\n  \"\"\"\n  Sort key that deals with errors raised by normal comparison,\n  in particular for values of different types or values that can't\n  be compared at all (e.g. None).\n  \"\"\"\n\n  __slots__ = (\"value\",)\n\n  def __init__(self, value):\n    self.value = value\n\n  def __repr__(self):\n    return \"MixedTypesKey({self.value!r})\".format(self=self)\n\n  def __eq__(self, other):\n    return self.value == other.value\n\n  def __lt__(self, other):\n    try:\n      return self.value < other.value\n    except TypeError:\n      if type(self.value) is type(other.value):\n        return id(self.value) < id(other.value)\n      else:\n        return self.type_position() < other.type_position()\n\n  def type_position(self):\n    # Fallback order similar to Python 2:\n    # - None is less than everything else\n    # - Numbers are less than other types\n    # - Other types are ordered by type name\n    # The first two elements use the fact that False < True (because 0 < 1)\n    return (\n      self.value is not None,\n      not isinstance(self.value, Number),\n      type(self.value).__name__,\n    )\n\n\nclass PositionColumn(NumericColumn):\n  def __init__(self, table, col_id, col_info):\n    super(PositionColumn, self).__init__(table, col_id, col_info)\n    # This is a list of row_ids, ordered by the position.\n    self._sorted_rows = SortedListWithKey(key=lambda x: SafeSortKey(self.raw_get(x)))\n\n  def clear(self):\n    super(PositionColumn, self).clear()\n    self._sorted_rows.clear()\n\n  def set(self, row_id, value):\n    self._sorted_rows.discard(row_id)\n    super(PositionColumn, self).set(row_id, value)\n    if value != self.getdefault():\n      self._sorted_rows.add(row_id)\n\n  def copy_from_column(self, other_column):\n    super(PositionColumn, self).copy_from_column(other_column)\n    self._sorted_rows = SortedListWithKey(other_column._sorted_rows[:],\n                                          key=lambda x: SafeSortKey(self.raw_get(x)))\n\n  def prepare_new_values(self, row_ids, values, ignore_data=False, action_summary=None):\n    # This does the work of adjusting positions and relabeling existing rows with new position\n    # (without changing sort order) to make space for the new positions. Note that this is also\n    # used for updating a position for an existing row: we'll find a new value for it; later when\n    # this value is set, the old position will be removed and the new one added.\n    if ignore_data:\n      rows = []\n    else:\n      rows = self._sorted_rows\n    # prepare_inserts expects floats as keys, not MixedTypesKeys\n    rows = SortedListWithKey(rows, key=self.raw_get)\n    adjustments, new_values = relabeling.prepare_inserts(rows, values)\n    adj_action = _adjustments_to_action(self.node,\n        [(self._sorted_rows[i], pos) for (i, pos) in adjustments])\n    return new_values, ([adj_action] if adj_action else [])\n\n\nclass ChoiceListColumn(ChoiceColumn):\n  \"\"\"\n  ChoiceListColumn's default value is None, but is presented to formulas as the empty list.\n  \"\"\"\n  def set(self, row_id, value):\n    # When a JSON string is loaded, set it to a tuple parsed from it. When a list is loaded,\n    # convert to a tuple to keep values immutable.\n    if isinstance(value, str) and value.startswith(u'['):\n      try:\n        value = tuple(json.loads(value))\n      except Exception:\n        pass\n    elif isinstance(value, list):\n      value = tuple(value)\n    super(ChoiceListColumn, self).set(row_id, value)\n\n  def _make_rich_value(self, typed_value):\n    return () if typed_value is None else typed_value\n\n  def _rename_cell_choice(self, renames, value):\n    if any((v in renames) for v in value):\n      return tuple(renames.get(choice, choice) for choice in value)\n    return None\n\n\nclass BaseReferenceColumn(BaseColumn):\n  \"\"\"\n  Base class for ReferenceColumn and ReferenceListColumn.\n  \"\"\"\n  def __init__(self, table, col_id, col_info):\n    super(BaseReferenceColumn, self).__init__(table, col_id, col_info)\n    # We can assume that all tables have been instantiated, but not all initialized.\n    target_table_id = self.type_obj.table_id\n    self._table = table\n    self._target_table = table._engine.tables.get(target_table_id, None)\n    self._relation = relation.ReferenceRelation(table.table_id, target_table_id, col_id)\n    # Note that we need to remove these back-references when the column is removed.\n    if self._target_table:\n      self._target_table._back_references.add(self)\n\n    self._reverse_source_node = self.type_obj.reverse_source_node()\n    if self._reverse_source_node:\n      _multimap_add(self._table._reverse_cols_by_source_node, self._reverse_source_node, self)\n\n\n  def destroy(self):\n    # Destroy the column and remove the back-reference we created in the constructor.\n    super(BaseReferenceColumn, self).destroy()\n    if self._reverse_source_node:\n      _multimap_remove(self._table._reverse_cols_by_source_node, self._reverse_source_node, self)\n\n    if self._target_table:\n      self._target_table._back_references.remove(self)\n\n  def _update_references(self, row_id, old_value, new_value):\n    for r in self._value_iterable(old_value):\n      self._relation.remove_reference(row_id, r)\n    for r in self._value_iterable(new_value):\n      self._relation.add_reference(row_id, r)\n\n  def _clean_up_value(self, value):\n    raise NotImplementedError()\n\n  def _value_iterable(self, value):\n    raise NotImplementedError()\n\n  def _list_to_value(self, value_as_list):\n    raise NotImplementedError()\n\n  def set(self, row_id, value):\n    old = self.safe_get(row_id)\n    super(BaseReferenceColumn, self).set(row_id, self._clean_up_value(value))\n    new = self.safe_get(row_id)\n    self._update_references(row_id, old, new)\n\n  def copy_from_column(self, other_column):\n    super(BaseReferenceColumn, self).copy_from_column(other_column)\n    self._relation.clear()\n    # This is hacky: we should have an interface to iterate through values of a column. (As it is,\n    # self._data may include values for non-existent rows; it works here because those values are\n    # falsy, which makes them ignored by self._update_references).\n    for row_id, value in enumerate(self._data):\n      if self.type_obj.is_right_type(value):\n        self._update_references(row_id, None, value)\n\n  def sample_value(self):\n    return self._target_table.sample_record\n\n  def get_updates_for_removed_target_rows(self, target_row_ids):\n    \"\"\"\n    Returns a list of pairs of (row_id, new_value) for values in this column that need to be\n    updated when target_row_ids are removed from the referenced table.\n    \"\"\"\n    affected_rows = sorted(self._relation.get_affected_rows(target_row_ids))\n    return [(row_id, self._raw_get_without(row_id, target_row_ids)) for row_id in affected_rows]\n\n  def prepare_new_values(self, row_ids, values, ignore_data=False, action_summary=None):\n    values = [self._clean_up_value(v) for v in values]\n    reverse_cols = self._target_table._reverse_cols_by_source_node.get(self.node, [])\n    adjustments = []\n    if reverse_cols:\n      old_values = [self.raw_get(r) for r in row_ids]\n      reverse_adjustments = reverse_references.get_reverse_adjustments(\n          row_ids, old_values, values, self._value_iterable, self._relation)\n\n      if reverse_adjustments:\n        for reverse_col in reverse_cols:\n          adjustments.append(_adjustments_to_action(\n            reverse_col.node,\n            [(row_id, reverse_col._list_to_value(value)) for (row_id, value) in reverse_adjustments]\n          ))\n\n    return values, adjustments\n\n  def recalc_from_reverse_values(self):\n    \"\"\"\n    Generates actions to update reverse column based on this column.\n    \"\"\"\n    if not self._reverse_source_node:\n      return None\n    rev_table_id, rev_col_id = self._reverse_source_node\n    reverse_col = self._target_table.get_column(rev_col_id)\n    reverse_adjustments = []\n    for target_row_id in self._target_table.row_ids:\n      reverse_value = self._relation.get_affected_rows((target_row_id,))\n      reverse_adjustments.append((target_row_id, sorted(reverse_value)))\n    return _adjustments_to_action(reverse_col.node,\n        [(row_id, reverse_col._list_to_value(value)) for (row_id, value) in reverse_adjustments])\n\n  def _raw_get_without(self, _row_id, _target_row_ids):\n    \"\"\"\n    Returns a Ref or RefList cell value with the specified target_row_ids removed, assuming one of\n    them is actually present in the value. For References, it just leaves the default value.\n    \"\"\"\n    return self.getdefault()\n\n  def _lookup(self, reference_lookup, value):\n    col_id = (\n        reference_lookup.options.get(\"column\")\n        or self._target_table._engine.docmodel\n            .get_column_rec(self.table_id, self.col_id).visibleCol.colId\n        or \"id\"\n    )\n    value = objtypes.decode_object(value)\n    return self._target_table.lookup_one_record(**{col_id: value})\n\nclass UniqueReferenceError(ValueError):\n  pass\n\nclass ReferenceColumn(BaseReferenceColumn):\n  \"\"\"\n  ReferenceColumn contains IDs of rows in another table. Accessing them yields the records in the\n  other table.\n  \"\"\"\n  def _make_rich_value(self, typed_value):\n    # If we refer to an invalid table, return integers rather than fail completely.\n    if not self._target_table:\n      return typed_value\n    # For a Reference, values must either refer to an existing record, or be 0. In all tables,\n    # the 0 index will contain the all-defaults record.\n    return self._target_table.Record(typed_value, self._relation)\n\n  def _value_iterable(self, value):\n    return (value,) if value and self.type_obj.is_right_type(value) else ()\n\n  def _list_to_value(self, value_as_list):\n    if len(value_as_list) > 1:\n      raise UniqueReferenceError(\"UNIQUE reference constraint violated\")\n    return value_as_list[0] if value_as_list else 0\n\n  def _clean_up_value(self, value):\n    # Allow float values that are small integers. In practice, this only turns out to be relevant\n    # in rare cases (such as undo of Ref->Numeric conversion).\n    if type(value) == float and value.is_integer():   # pylint:disable=unidiomatic-typecheck\n      if value > 0 and objtypes.is_int_short(int(value)):\n        return int(value)\n    return value\n\n  def prepare_new_values(self, row_ids, values, ignore_data=False, action_summary=None):\n    if action_summary and values:\n      values = action_summary.translate_new_row_ids(self._target_table.table_id, values)\n    return super(ReferenceColumn, self).prepare_new_values(row_ids, values,\n        ignore_data=ignore_data, action_summary=action_summary)\n\n  def convert(self, val):\n    if isinstance(val, objtypes.ReferenceLookup):\n      val = self._lookup(val, val.value) or self._alt_text(val.alt_text)\n    elif isinstance(val, list):\n      val = val[0] if val else 0\n    return super(ReferenceColumn, self).convert(val)\n\n\nclass ReferenceListColumn(BaseReferenceColumn):\n  \"\"\"\n  ReferenceListColumn maintains for each row a list of references (row IDs) into another table.\n  Accessing them yields RecordSets.\n  \"\"\"\n  def _clean_up_value(self, value):\n    if isinstance(value, str):\n      # This is second part of a \"hack\" we have to do when we rename tables. During\n      # the rename, we briefly change all Ref columns to Int columns (to lose the table\n      # part), and then back to Ref columns. The values during this change are stored\n      # as serialized strings, which we expect to understand when the column is back to\n      # being a Ref column. We can either end up with a list of ints, or a RecordList\n      # serialized as a string.\n      # TODO: explain why we need to do this and why we have chosen the Int column\n      try:\n        # If it's a string that looks like JSON, try to parse it as such.\n        if value.startswith('['):\n          parsed = json.loads(value)\n\n          # It must be list of integers.\n          if not isinstance(parsed, list):\n            return value\n\n          # All of them must be positive integers\n          if all(isinstance(v, int) and v > 0 for v in parsed):\n            return parsed\n        else:\n          # Else try to parse it as a RecordList\n          return objtypes.RecordList.from_repr(value)\n      except Exception:\n        pass\n    return value\n\n  def _value_iterable(self, value):\n    return value if value and self.type_obj.is_right_type(value) else ()\n\n  def _list_to_value(self, value_as_list):\n    return value_as_list or None\n\n  def _make_rich_value(self, typed_value):\n    if typed_value is None:\n      typed_value = []\n    # If we refer to an invalid table, return integers rather than fail completely.\n    if not self._target_table:\n      return typed_value\n    return self._target_table.RecordSet(typed_value, self._relation)\n\n  def _raw_get_without(self, row_id, target_row_ids):\n    \"\"\"\n    Returns the RefList cell value at row_id with the specified target_row_ids removed.\n    \"\"\"\n    raw = self.raw_get(row_id)\n    if self.type_obj.is_right_type(raw):\n      raw = [r for r in raw if r not in target_row_ids] or None\n    return raw\n\n  def convert(self, val):\n    if isinstance(val, objtypes.ReferenceLookup):\n      result = []\n      values = val.value\n      if not isinstance(values, list):\n        values = [values]\n      for value in values:\n        lookup_value = self._lookup(val, value)\n        if not lookup_value:\n          return self._alt_text(val.alt_text)\n        result.append(lookup_value)\n      val = result\n\n    if val:\n      if isinstance(val, int):\n        val = [val]\n      elif self._target_table and isinstance(val, self._target_table.Record):\n        val = [val.id]\n\n    return super(ReferenceListColumn, self).convert(val)\n\ndef _multimap_add(mapping, key, value):\n  mapping.setdefault(key, []).append(value)\n\ndef _multimap_remove(mapping, key, value):\n  if key in mapping and value in mapping[key]:\n    mapping[key].remove(value)\n    if not mapping[key]:\n      del mapping[key]\n\ndef _adjustments_to_action(node, row_value_pairs):\n  # Takes a depend.Node and a list of (row_id, value) pairs, and returns a BulkUpdateRecord action.\n  if not row_value_pairs:\n    return None\n  row_ids, values = zip(*row_value_pairs)\n  return actions.BulkUpdateRecord(node.table_id, row_ids, {node.col_id: values})\n\n# Set up the relationship between usertypes objects and column objects.\nusertypes.BaseColumnType.ColType = DataColumn\nusertypes.Reference.ColType = ReferenceColumn\nusertypes.ReferenceList.ColType = ReferenceListColumn\nusertypes.Choice.ColType = ChoiceColumn\nusertypes.ChoiceList.ColType = ChoiceListColumn\nusertypes.DateTime.ColType = DateTimeColumn\nusertypes.Date.ColType = DateColumn\nusertypes.PositionNumber.ColType = PositionColumn\nusertypes.Bool.ColType = BoolColumn\nusertypes.Numeric.ColType = NumericColumn\n\ndef create_column(table, col_id, col_info):\n  return col_info.type_obj.ColType(table, col_id, col_info)\n"
  },
  {
    "path": "sandbox/grist/csv_patch.py",
    "content": "import re\nimport csv\nfrom functools import reduce\n\n# Monkey-patch csv.Sniffer class, in which the quote/delimiter detection has silly bugs in the\n# regexp that it uses. It also seems poorly-implemented in other ways. We can probably do better\n# by not using csv.Sniffer at all.\n# The method below is a modified copy of the same-named method in the standard csv.Sniffer class.\ndef _guess_quote_and_delimiter(_self, data, delimiters):\n  \"\"\"\n  Looks for text enclosed between two identical quotes\n  (the probable quotechar) which are preceded and followed\n  by the same character (the probable delimiter).\n  For example:\n           ,'some text',\n  The quote with the most wins, same with the delimiter.\n  If there is no quotechar the delimiter can't be determined\n  this way.\n  \"\"\"\n\n  regexp = re.compile(\n    r\"\"\"\n    (?:(?P<delim>[^\\w\\n\"\\'])|^|\\n)  # delimiter or start-of-line\n    (?P<space>\\ ?)           # optional initial space\n    (?P<quote>[\"\\']).*?(?P=quote)   # quote-surrounded field\n    (?:(?P=delim)|$|\\r?\\n)      # delimiter or end-of-line\n    \"\"\", re.VERBOSE | re.DOTALL | re.MULTILINE)\n  matches = regexp.findall(data)\n\n  if not matches:\n    # (quotechar, doublequote, delimiter, skipinitialspace)\n    return ('', False, None, 0)\n  quotes = {}\n  delims = {}\n  spaces = 0\n  for m in matches:\n    n = regexp.groupindex['quote'] - 1\n    key = m[n]\n    if key:\n      quotes[key] = quotes.get(key, 0) + 1\n    try:\n      n = regexp.groupindex['delim'] - 1\n      key = m[n]\n    except KeyError:\n      continue\n    if key and (delimiters is None or key in delimiters):\n      delims[key] = delims.get(key, 0) + 1\n    try:\n      n = regexp.groupindex['space'] - 1\n    except KeyError:\n      continue\n    if m[n]:\n      spaces += 1\n\n  quotechar = reduce(lambda a, b, _quotes = quotes:\n             (_quotes[a] > _quotes[b]) and a or b, quotes.keys())\n\n  if delims:\n    delim = reduce(lambda a, b, _delims = delims:\n             (_delims[a] > _delims[b]) and a or b, delims.keys())\n    skipinitialspace = delims[delim] == spaces\n    if delim == '\\n': # most likely a file with a single column\n      delim = ''\n  else:\n    # there is *no* delimiter, it's a single column of quoted data\n    delim = ''\n    skipinitialspace = 0\n\n  # if we see an extra quote between delimiters, we've got a\n  # double quoted format\n  dq_regexp = re.compile(\n               (r\"((%(delim)s)|^)\\W*%(quote)s[^%(delim)s\\n]*%(quote)\" +\n                r\"s[^%(delim)s\\n]*%(quote)s\\W*((%(delim)s)|$)\") % \\\n               {'delim':re.escape(delim), 'quote':quotechar}, re.MULTILINE)\n\n\n\n  if dq_regexp.search(data):\n    doublequote = True\n  else:\n    doublequote = False\n\n  return (quotechar, doublequote, delim, skipinitialspace)\n\ncsv.Sniffer._guess_quote_and_delimiter = _guess_quote_and_delimiter\n"
  },
  {
    "path": "sandbox/grist/depend.py",
    "content": "\"\"\"\ndepend.py provides classes and functions to manage the dependency graph for grist formulas.\n\nConceptually, all dependency relationships are the Edges (Node1, Relation, Node2), meaning that\nNode1 depends on Node2. Each Node represents a column in a particular table (could be a derived\ntable, such as for subtotals). The Relation determines the row mapping, i.e. which rows in Node1\ncolumn need to be recomputed when a row changes in Node2 column.\n\nWhen a formula is evaluated, the Record and RecordSet objects maintain a reference to the Relation\nin use, while property access determines which Nodes (or columns) depend on one another.\n\"\"\"\n\n# Note: this is partly inspired by the implementation of the ninja build system, see\n# https://github.com/martine/ninja/blob/master/src/graph.h\n\n# Idea for the future: we can consider the concept from ninja of \"order-only deps\", which are\n# needed before we can build the outputs, but which don't cause the outputs to rebuild. Support\n# for this (with computed values properly persisted) could allow some cool use cases, like columns\n# that recompute manually rather than automatically.\n\nfrom collections import namedtuple\nfrom sortedcontainers import SortedSet\n\nclass Node(namedtuple('Node', ('table_id', 'col_id'))):\n  \"\"\"\n  Each Node in the dependency graph represents a column in a table.\n  \"\"\"\n  __slots__ = ()    # This is a memory-saving device to keep these objects small\n\n  def __str__(self):\n    return '[%s.%s]' % (self.table_id, self.col_id)\n\n\nclass Edge(namedtuple('Edge', ('out_node', 'in_node', 'relation'))):\n  \"\"\"\n  Each Edge connects two Nodes using a Relation. It says that out_node depends on in_node, so that\n  a change to in_node should trigger a recomputation of out_node.\n  \"\"\"\n  __slots__ = ()    # This is a memory-saving device to keep these objects small\n\n  def __str__(self):\n    return '[%s.%s: %s.%s @ %s]' % (self.out_node.table_id, self.out_node.col_id,\n                                    self.in_node.table_id, self.in_node.col_id, self.relation)\n\n\nclass CircularRefError(RuntimeError):\n  \"\"\"\n  Exception thrown when a formula column references itself, directly or indirectly.\n  \"\"\"\n  pass\n\n\nclass _AllRows(object):\n  \"\"\"\n  Special constant that indicates to `invalidate_deps` that all rows are affected and an entire\n  column is to be invalidated.\n  \"\"\"\n  pass\n\nALL_ROWS = _AllRows()\n\nclass Graph(object):\n  \"\"\"\n  Represents the dependency graph for all data in a grist document.\n  \"\"\"\n  def __init__(self):\n    # The set of all Edges, i.e. the complete dependency graph.\n    self._all_edges = set()\n\n    # Map from node to the set of edges having it as the in_node (i.e. edges to dependents).\n    self._in_node_map = {}\n\n    # Map from node to the set of edges having it as the out_node (i.e. edges to dependencies).\n    self._out_node_map = {}\n\n  def dump_graph(self):\n    \"\"\"\n    Print out the graph to stdout, for debugging.\n    \"\"\"\n    print(\"Dependency graph (%d edges):\" % len(self._all_edges))\n    for edge in sorted(self._all_edges):\n      print(\"  %s\" % (edge,))\n\n  def add_edge(self, out_node, in_node, relation):\n    \"\"\"\n    Adds an edge to the global dependency graph: out_node depends on in_node, i.e. a change to\n    in_node should trigger a recomputation of out_node.\n    \"\"\"\n    edge = Edge(out_node, in_node, relation)\n    self._all_edges.add(edge)\n    self._in_node_map.setdefault(edge.in_node, set()).add(edge)\n    self._out_node_map.setdefault(edge.out_node, set()).add(edge)\n\n  def clear_dependencies(self, out_node):\n    \"\"\"\n    Removes all edges which affect the given out_node, i.e. all of its dependencies.\n    \"\"\"\n    remove_edges = self._out_node_map.pop(out_node, ())\n    for edge in remove_edges:\n      self._all_edges.remove(edge)\n      self._in_node_map.get(edge.in_node, set()).remove(edge)\n      edge.relation.reset_all()\n\n  def reset_dependencies(self, node, dirty_rows):\n    \"\"\"\n    For edges the given node depends on, reset the given output rows. This is called just before\n    the rows get recomputed, to allow the relations to clear out state for those rows if needed.\n    \"\"\"\n    in_edges = self._out_node_map.get(node, ())\n    for edge in in_edges:\n      edge.relation.reset_rows(dirty_rows)\n\n  def remove_node_if_unused(self, node):\n    \"\"\"\n    Removes the given node if it has no dependents. Returns True if the node is gone, False if the\n    node has dependents.\n    \"\"\"\n    if self._in_node_map.get(node, None):\n      return False\n    self.clear_dependencies(node)\n    self._in_node_map.pop(node, None)\n    return True\n\n  def invalidate_deps(self, dirty_node, dirty_rows, recompute_map, include_self=True):\n    \"\"\"\n    Invalidates the given rows in the given node, and all of its dependents, i.e. all the nodes\n    that recursively depend on dirty_node. If include_self is False, then skips the given node\n    (e.g. if the node is raw data rather than formula). Results are added to recompute_map, which\n    is a dict mapping Nodes to sets of rows that need to be recomputed.\n\n    If dirty_rows is ALL_ROWS, the whole column is affected, and dependencies get recomputed from\n    scratch. ALL_ROWS propagates to all dependent columns, so those also get recomputed in full.\n    \"\"\"\n    to_invalidate = [(dirty_node, dirty_rows)]\n\n    while to_invalidate:\n      dirty_node, dirty_rows = to_invalidate.pop()\n      if include_self:\n        if recompute_map.get(dirty_node) == ALL_ROWS:\n          continue\n        if dirty_rows == ALL_ROWS:\n          recompute_map[dirty_node] = ALL_ROWS\n          # If all rows are being recomputed, clear the dependencies of the affected column. (We add\n          # dependencies in the course of recomputing, but we can only start from an empty set of\n          # dependencies if we are about to recompute all rows.)\n          self.clear_dependencies(dirty_node)\n        else:\n          out_rows = recompute_map.setdefault(dirty_node, SortedSet())\n          prev_count = len(out_rows)\n          out_rows.update(dirty_rows)\n          # Don't bother recursing into dependencies if we didn't actually update anything.\n          if len(out_rows) <= prev_count:\n            continue\n\n      include_self = True\n\n      for edge in self._in_node_map.get(dirty_node, ()):\n        affected_rows = edge.relation.get_affected_rows(dirty_rows)\n\n        # Previously this was:\n        #   self.invalidate_deps(edge.out_node, affected_rows, recompute_map, include_self=True)\n        # but that led to a recursion error, so now we do the equivalent\n        # without actual recursion, hence the while loop\n        to_invalidate.append((edge.out_node, affected_rows))\n"
  },
  {
    "path": "sandbox/grist/docactions.py",
    "content": "import logging\n\nimport actions\nimport schema\nfrom objtypes import strict_equal\n\nlog = logging.getLogger(__name__)\n\nclass DocActions(object):\n  def __init__(self, engine):\n    self._engine = engine\n\n  #----------------------------------------\n  # Actions on records.\n  #----------------------------------------\n\n  def AddRecord(self, table_id, row_id, column_values):\n    self.BulkAddRecord(\n      table_id, [row_id], {key: [val] for key, val in column_values.items()})\n\n  def BulkAddRecord(self, table_id, row_ids, column_values):\n    table = self._engine.tables[table_id]\n    for row_id in row_ids:\n      assert row_id not in table.row_ids, \\\n          \"docactions.[Bulk]AddRecord for existing record #%s\" % row_id\n\n    self._engine.out_actions.undo.append(actions.BulkRemoveRecord(table_id, row_ids).simplify())\n    self._engine.out_actions.summary.add_records(table_id, row_ids)\n\n    self._engine.add_records(table_id, row_ids, column_values)\n\n  def RemoveRecord(self, table_id, row_id):\n    return self.BulkRemoveRecord(table_id, [row_id])\n\n  def BulkRemoveRecord(self, table_id, row_ids):\n    table = self._engine.tables[table_id]\n\n    # Ignore records that don't exist in the table.\n    row_ids = [r for r in row_ids if r in table.row_ids]\n    if not row_ids:\n      return\n\n    # Collect the undo values, and unset all values in the column (i.e. set to defaults), just to\n    # make sure we don't have stale values hanging around.\n    undo_values = {}\n    for column in table.all_columns.values():\n      if not column.is_private() and column.col_id != \"id\":\n        col_values = [column.raw_get(r) for r in row_ids]\n        default = column.getdefault()\n        # If this column had all default values, don't include it into the undo BulkAddRecord.\n        if not all(strict_equal(val, default) for val in col_values):\n          undo_values[column.col_id] = col_values\n      for row_id in row_ids:\n        column.unset(row_id)\n\n    # Generate the undo action.\n    self._engine.out_actions.undo.append(\n        actions.BulkAddRecord(table_id, row_ids, undo_values).simplify())\n    self._engine.out_actions.summary.remove_records(table_id, row_ids)\n\n    # Invalidate the deleted rows, so that anything that depends on them gets recomputed.\n    self._engine.invalidate_records(table_id, row_ids)\n\n  def UpdateRecord(self, table_id, row_id, columns):\n    self.BulkUpdateRecord(\n      table_id, [row_id], {key: [val] for key, val in columns.items()})\n\n  def BulkUpdateRecord(self, table_id, row_ids, columns):\n    table = self._engine.tables[table_id]\n    for row_id in row_ids:\n      assert row_id in table.row_ids, \\\n          \"docactions.[Bulk]UpdateRecord for non-existent record #%s\" % row_id\n\n    # Load the updated values.\n    undo_values = {}\n    for col_id, values in columns.items():\n      col = table.get_column(col_id)\n      undo_values[col_id] = [col.raw_get(r) for r in row_ids]\n      for (row_id, value) in zip(row_ids, values):\n        col.set(row_id, value)\n\n      # Non-formula columns may get invalidated and recalculated if they have a trigger formula.\n      # Prevent such recalculation if we set an explicit value for them (we want to prevent it\n      # even if triggered by something else within the same useraction).\n      if not col.is_formula():\n        self._engine.prevent_recalc(col.node, row_ids, should_prevent=True)\n\n    # Generate the undo action.\n    self._engine.out_actions.undo.append(\n        actions.BulkUpdateRecord(table_id, row_ids, undo_values).simplify())\n\n    # Invalidate the updated rows, just for the columns that got changed (and, as always,\n    # anything that depends on them).\n    self._engine.invalidate_records(table_id, row_ids, col_ids=columns.keys())\n\n    # If the column update changes its trigger-formula conditions, rebuild dependencies.\n    if (table_id == \"_grist_Tables_column\" and\n       (\"recalcWhen\" in columns or \"recalcDeps\" in columns)):\n      self._engine.trigger_columns_changed()\n\n  def ReplaceTableData(self, table_id, row_ids, column_values):\n    old_data = self._engine.fetch_table(table_id, formulas=False)\n    self._engine.out_actions.undo.append(actions.ReplaceTableData(*old_data))\n    self._engine.out_actions.summary.remove_records(table_id, old_data[1])\n    self._engine.out_actions.summary.add_records(table_id, row_ids)\n    self._engine.load_table(actions.TableData(table_id, row_ids, column_values))\n\n  #----------------------------------------\n  # Actions on columns.\n  #----------------------------------------\n\n  def AddColumn(self, table_id, col_id, col_info):\n    table = self._engine.tables[table_id]\n    assert not table.has_column(col_id), \"Column %s already exists in %s\" % (col_id, table_id)\n\n    # Add the new column to the schema object maintained in the engine.\n    self._engine.schema[table_id].columns[col_id] = schema.dict_to_col(col_info, col_id=col_id)\n    self._engine.rebuild_usercode()\n    self._engine.new_column_name(table)\n\n    # Generate the undo action.\n    self._engine.out_actions.undo.append(actions.RemoveColumn(table_id, col_id))\n    self._engine.out_actions.summary.add_column(table_id, col_id)\n\n  def RemoveColumn(self, table_id, col_id):\n    table = self._engine.tables[table_id]\n    assert table.has_column(col_id), \"Column %s not in table %s\" % (col_id, table_id)\n\n    # Generate (if needed) the undo action to restore the data.\n    undo_action = None\n    column = table.get_column(col_id)\n    if not column.is_private():\n      default = column.getdefault()\n      # Add to undo a BulkUpdateRecord for non-default values in the column being removed.\n      undo_values = [(r, column.raw_get(r)) for r in table.row_ids\n                     if not strict_equal(column.raw_get(r), default)]\n\n    # Remove the specified column from the schema object.\n    colinfo = self._engine.schema[table_id].columns.pop(col_id)\n    self._engine.rebuild_usercode()\n\n    # Generate the undo action(s); if for a formula column, add them to the calc summary.\n    if undo_values:\n      if column.is_formula():\n        changes = [(r, v, default) for (r, v) in undo_values]\n        self._engine.out_actions.summary.add_changes(table_id, col_id, changes)\n      else:\n        row_ids = [r for (r, v) in undo_values]\n        values = [v for (r, v) in undo_values]\n        undo_action = actions.BulkUpdateRecord(table_id, row_ids, {col_id: values}).simplify()\n        self._engine.out_actions.undo.append(undo_action)\n\n    self._engine.out_actions.undo.append(actions.AddColumn(\n      table_id, col_id, schema.col_to_dict(colinfo, include_id=False)))\n    self._engine.out_actions.summary.remove_column(table_id, col_id)\n\n  def RenameColumn(self, table_id, old_col_id, new_col_id):\n    table = self._engine.tables[table_id]\n\n    assert table.has_column(old_col_id), \"Column %s not in table %s\" % (old_col_id, table_id)\n    assert not table.has_column(new_col_id), \\\n        \"Column %s already exists in %s\" % (new_col_id, table_id)\n    old_column = table.get_column(old_col_id)\n\n    # Replace the renamed column in the schema object.\n    schema_table_info = self._engine.schema[table_id]\n    colinfo = schema_table_info.columns.pop(old_col_id)\n    schema_table_info.columns[new_col_id] = colinfo._replace(colId=new_col_id)\n\n    self._engine.rebuild_usercode()\n    self._engine.new_column_name(table)\n\n    # We replaced the old column with a new Column object (not strictly necessary, but simpler).\n    # For a raw data column, we need to copy over the data from the old column object.\n    new_column = table.get_column(new_col_id)\n    new_column.copy_from_column(old_column)\n\n    # Generate the undo action.\n    self._engine.out_actions.undo.append(actions.RenameColumn(table_id, new_col_id, old_col_id))\n    self._engine.out_actions.summary.rename_column(table_id, old_col_id, new_col_id)\n\n  def ModifyColumn(self, table_id, col_id, col_info):\n    table = self._engine.tables[table_id]\n    assert table.has_column(col_id), \"Column %s not in table %s\" % (col_id, table_id)\n    old_column = table.get_column(col_id)\n\n    # Modify the specified column in the schema object.\n    schema_table_info = self._engine.schema[table_id]\n    old = schema_table_info.columns[col_id]\n    new = schema.SchemaColumn(col_id,\n                              col_info.get('type', old.type),\n                              bool(col_info.get('isFormula', old.isFormula)),\n                              col_info.get('formula', old.formula),\n                              col_info.get('reverseColId', old.reverseColId))\n    if new == old:\n      log.info(\"ModifyColumn called which was a noop\")\n      return\n\n    undo_col_info = {k: v for k, v in\n                     schema.col_to_dict(old, include_id=False, include_default=True).items()\n                     if k in col_info}\n\n    # Remove the column from the schema, then re-add it, to force creation of a new column object.\n    schema_table_info.columns.pop(col_id)\n    self._engine.rebuild_usercode()\n\n    schema_table_info.columns[col_id] = new\n    self._engine.rebuild_usercode()\n\n    # Fill in the new column with the values from the old column.\n    new_column = table.get_column(col_id)\n    for row_id in table.row_ids:\n      new_column.set(row_id, old_column.raw_get(row_id))\n\n    # Generate the undo action.\n    self._engine.out_actions.undo.append(actions.ModifyColumn(table_id, col_id, undo_col_info))\n\n  #----------------------------------------\n  # Actions on tables.\n  #----------------------------------------\n\n  def AddTable(self, table_id, columns):\n    assert table_id not in self._engine.tables, \"Table %s already exists\" % table_id\n\n    # Update schema, and re-generate the module code.\n    self._engine.schema[table_id] = schema.SchemaTable(table_id, schema.dict_list_to_cols(columns))\n    self._engine.rebuild_usercode()\n\n    # Generate the undo action.\n    self._engine.out_actions.undo.append(actions.RemoveTable(table_id))\n    self._engine.out_actions.summary.add_table(table_id)\n\n  def RemoveTable(self, table_id):\n    assert table_id in self._engine.tables, \"Table %s doesn't exist\" % table_id\n\n    # Create undo actions to restore all the data records of this table.\n    table_data = self._engine.fetch_table(table_id, formulas=True)\n    undo_action = actions.BulkAddRecord(*table_data).simplify()\n    if undo_action:\n      self._engine.out_actions.undo.append(undo_action)\n\n    # Update schema, and re-generate the module code.\n    schema_table = self._engine.schema.pop(table_id)\n    self._engine.rebuild_usercode()\n\n    # Generate the undo action.\n    self._engine.out_actions.undo.append(actions.AddTable(\n      table_id, schema.cols_to_dict_list(schema_table.columns)))\n    self._engine.out_actions.summary.remove_table(table_id)\n\n  def RenameTable(self, old_table_id, new_table_id):\n    assert old_table_id in self._engine.tables, \"Table %s doesn't exist\" % old_table_id\n    assert new_table_id not in self._engine.tables, \"Table %s already exists\" % new_table_id\n\n    old_table = self._engine.tables[old_table_id]\n\n    # Update schema, and re-generate the module code.\n    old = self._engine.schema.pop(old_table_id)\n    self._engine.schema[new_table_id] = schema.SchemaTable(new_table_id, old.columns)\n    self._engine.rebuild_usercode()\n\n    # Copy over all columns from the old table to the new.\n    new_table = self._engine.tables[new_table_id]\n    for new_column in new_table.all_columns.values():\n      if not new_column.is_private():\n        new_column.copy_from_column(old_table.get_column(new_column.col_id))\n    new_table.grow_to_max()   # We need to bring formula columns to the right size too.\n\n    # Generate the undo action.\n    self._engine.out_actions.undo.append(actions.RenameTable(new_table_id, old_table_id))\n    self._engine.out_actions.summary.rename_table(old_table_id, new_table_id)\n\n# end\n"
  },
  {
    "path": "sandbox/grist/docmodel.py",
    "content": "\"\"\"\nThis file provides convenient access to document metadata that is internal to the sandbox.\nSpecifically, it has handles to the metadata tables, and adds helpful formula columns to tables\nwhich exist only in the sandbox and are not communicated to the client.\n\nIt is similar in purpose to DocModel.js on the client side.\n\"\"\"\nimport itertools\n\nimport functions\nimport records\nimport usertypes\nimport relabeling\nimport table\nimport moment\nfrom schema import RecalcWhen\n\n# pylint:disable=redefined-outer-name\n\ndef _record_set(table_id, group_by, sort_by=None):\n  @usertypes.formulaType(usertypes.ReferenceList(table_id))\n  def func(rec, table):\n    lookup_table = table.docmodel.get_table(table_id)\n    return lookup_table.lookupRecords(sort_by=sort_by, **{group_by: rec.id})\n  return func\n\n\ndef _record_ref_list_set(table_id, group_by, sort_by=None):\n  @usertypes.formulaType(usertypes.ReferenceList(table_id))\n  def func(rec, table):\n    lookup_table = table.docmodel.get_table(table_id)\n    return lookup_table.lookupRecords(sort_by=sort_by, **{group_by: functions.CONTAINS(rec.id)})\n  return func\n\n\ndef _record_inverse(table_id, ref_col):\n  @usertypes.formulaType(usertypes.Reference(table_id))\n  def func(rec, table):\n    lookup_table = table.docmodel.get_table(table_id)\n    return lookup_table.lookupOne(**{ref_col: rec.id})\n  return func\n\n\nclass MetaTableExtras(object):\n  \"\"\"\n  Container class for enhancements to metadata table models. The members (formula methods) defined\n  for a nested class here will automatically be added as members to same-named metadata table.\n  \"\"\"\n  # pylint: disable=no-self-argument,no-member,unused-argument,not-an-iterable\n  class _grist_DocInfo(object):\n    @usertypes.formulaType(usertypes.Any())\n    def tzinfo(rec, table):\n      # pylint: disable=no-self-use\n      try:\n        return moment.tzinfo(rec.timezone)\n      except KeyError:\n        return moment.TZ_UTC\n\n  class _grist_Tables(object):\n    columns = _record_set('_grist_Tables_column', 'parentId', sort_by='parentPos')\n    viewSections = _record_set('_grist_Views_section', 'tableRef')\n    summaryTables = _record_set('_grist_Tables', 'summarySourceTable')\n\n    def summaryKey(rec, table):\n      \"\"\"\n      Returns the tuple of sorted colRefs for summary columns. This uniquely identifies a summary\n      table among other summary tables for the same source table.\n      \"\"\"\n      # pylint: disable=not-an-iterable\n      return (tuple(sorted(int(c.summarySourceCol) for c in rec.columns if c.summarySourceCol))\n              if rec.summarySourceTable else None)\n\n    def setAutoRemove(rec, table):\n      \"\"\"\n      Marks the table for removal if it's a summary table with no more (non-raw) view sections.\n      \"\"\"\n      is_summary_table = rec.summarySourceTable\n      view_sections_table = table.docmodel.get_table('_grist_Views_section')\n      has_view_sections = view_sections_table.lookupOne(isRaw=False, tableRef=rec.id)\n      table.docmodel.setAutoRemove(rec, is_summary_table and not has_view_sections)\n\n\n  class _grist_Tables_column(object):\n    viewFields = _record_set('_grist_Views_section_field', 'colRef')\n    summaryGroupByColumns = _record_set('_grist_Tables_column', 'summarySourceCol')\n    usedByCols = _record_set('_grist_Tables_column', 'displayCol')\n    usedByFields = _record_set('_grist_Views_section_field', 'displayCol')\n    ruleUsedByCols = _record_ref_list_set('_grist_Tables_column', 'rules')\n    ruleUsedByFields = _record_ref_list_set('_grist_Views_section_field', 'rules')\n    ruleUsedByTables = _record_ref_list_set('_grist_Views_section', 'rules')\n\n    def tableId(rec, table):\n      return rec.parentId.tableId\n\n    def numDisplayColUsers(rec, table):\n      \"\"\"\n      Returns the number of cols and fields using this col as a display col\n      \"\"\"\n      return len(rec.usedByCols) + len(rec.usedByFields)\n\n    def numRuleColUsers(rec, table):\n      \"\"\"\n      Returns the number of cols and fields using this col as a rule\n      \"\"\"\n      return len(rec.ruleUsedByCols) + len(rec.ruleUsedByFields)\n\n    def numRuleTableUsers(rec, table):\n      \"\"\"\n      Returns the number of tables using this col as a rule\n      \"\"\"\n      return len(rec.ruleUsedByTables)\n\n    def recalcOnChangesToSelf(rec, table):\n      \"\"\"\n      Whether the column is a trigger-formula column that depends on itself, used for\n      data-cleaning. (A manual change to it will trigger its own recalculation.)\n      \"\"\"\n      return rec.recalcWhen == RecalcWhen.DEFAULT and rec.id in rec.recalcDeps\n\n    def setAutoRemove(rec, table):\n      \"\"\"Marks the col for removal if it's a display/rule helper col with no more users.\"\"\"\n      as_display = rec.colId.startswith('gristHelper_Display') and rec.numDisplayColUsers == 0\n      as_col_rule = rec.colId.startswith('gristHelper_ConditionalRule') and rec.numRuleColUsers == 0\n      as_row_rule = (\n        rec.colId.startswith('gristHelper_RowConditionalRule') and rec.numRuleTableUsers == 0\n      )\n      table.docmodel.setAutoRemove(rec, as_display or as_col_rule or as_row_rule)\n\n\n  class _grist_Views(object):\n    viewSections = _record_set('_grist_Views_section', 'parentId')\n    tabBarItems = _record_set('_grist_TabBar', 'viewRef')\n    primaryViewTable = _record_inverse('_grist_Tables', 'primaryViewId')\n    pageItems = _record_set('_grist_Pages', 'viewRef')\n\n  class _grist_Views_section(object):\n    fields = _record_set('_grist_Views_section_field', 'parentId', sort_by='parentPos')\n\n    def isRaw(rec, table):\n      return rec.tableRef.rawViewSectionRef == rec\n\n    def isRecordCard(rec, table):\n      return rec.tableRef.recordCardViewSectionRef == rec\n\n  class _grist_Filters(object):\n    def setAutoRemove(rec, table):\n      \"\"\"Marks the filter for removal if its column no longer exists.\"\"\"\n      table.docmodel.setAutoRemove(rec, not rec.colRef)\n\n\n  class _grist_Cells(object):\n    def setAutoRemove(rec, table):\n      if rec.type == 1: # Cell info of type 1 == Comments\n        # Remove if discussion is removed.\n        noParent = not rec.root and not rec.parentId\n        if rec.tableRef and rec.rowId:\n          tableRef = table.docmodel.get_table(rec.tableRef.tableId)\n          row = tableRef.lookupOne(id=rec.rowId)\n        else:\n          row = False\n        # Remove if row is removed, column is removed, table is removed or all comments are removed.\n        no_cell = not rec.colRef or not rec.tableRef or not row\n        table.docmodel.setAutoRemove(rec, noParent or no_cell)\n\n\ndef enhance_model(model_class):\n  \"\"\"\n  Given a metadata model class, add all members (formula methods) to it from the same-named inner\n  class of MetaTableExtras. The added members are marked as private; the resulting Column objects\n  will have col.is_private() as true.\n  \"\"\"\n  extras_class = getattr(MetaTableExtras, model_class.__name__, None)\n  if not extras_class:\n    return\n  for name, member in extras_class.__dict__.items():\n    if not name.startswith(\"__\"):\n      member.__name__ = name\n      member.is_private = True\n      setattr(model_class, name, member)\n\n# There is a single instance of DocModel per sandbox process and\n# global_docmodel is a reference to it\nglobal_docmodel = None\n\nclass DocModel(object):\n  \"\"\"\n  This class defines more convenient handles to all metadata tables. In addition, it sets\n  table.docmodel member for each of these tables to itself. Note that it deals with\n  table.UserTable objects (rather than the lower-level table.Table objects).\n  \"\"\"\n  def __init__(self, engine):\n    self._engine = engine\n    global global_docmodel # pylint: disable=global-statement\n    global_docmodel = self\n\n    # Set of records scheduled for automatic removal.\n    self._auto_remove_set = set()\n\n  def update_tables(self):\n    \"\"\"\n    Update the table handles we maintain to correspond to the current Engine tables.\n    \"\"\"\n    self.doc_info                = self._prep_table(\"_grist_DocInfo\")\n    self.tables                  = self._prep_table(\"_grist_Tables\")\n    self.columns                 = self._prep_table(\"_grist_Tables_column\")\n    self.tab_bar                 = self._prep_table(\"_grist_TabBar\")\n    self.views                   = self._prep_table(\"_grist_Views\")\n    self.view_sections           = self._prep_table(\"_grist_Views_section\")\n    self.view_fields             = self._prep_table(\"_grist_Views_section_field\")\n    self.validations             = self._prep_table(\"_grist_Validations\")\n    self.repl_hist               = self._prep_table(\"_grist_REPL_Hist\")\n    self.attachments             = self._prep_table(\"_grist_Attachments\")\n    self.pages                   = self._prep_table(\"_grist_Pages\")\n    self.aclResources            = self._prep_table(\"_grist_ACLResources\")\n    self.aclRules                = self._prep_table(\"_grist_ACLRules\")\n    self.triggers                = self._prep_table(\"_grist_Triggers\")\n    self.filters                 = self._prep_table(\"_grist_Filters\")\n    self.cells                   = self._prep_table(\"_grist_Cells\")\n\n  def _prep_table(self, name):\n    \"\"\"\n    Helper that gets the table with the given name, and sets its .doc attribute to DocModel.\n    \"\"\"\n    user_table = self._engine.tables[name].user_table\n    user_table.docmodel = self\n    return user_table\n\n  def get_table(self, table_id):\n    return self._engine.tables[table_id].user_table\n\n\n  def get_table_rec(self, table_id):\n    \"\"\"Returns the table record for the given table name, or raises ValueError.\"\"\"\n    table_rec = self.tables.lookupOne(tableId=table_id)\n    if not table_rec:\n      raise ValueError(\"No such table: %s\" % table_id)\n    return table_rec\n\n  def get_column_rec(self, table_id, col_id):\n    \"\"\"Returns the column record for the given table and column names, or raises ValueError.\"\"\"\n    col_rec = self.columns.lookupOne(tableId=table_id, colId=col_id)\n    if not col_rec:\n      raise ValueError(\"No such column: %s.%s\" % (table_id, col_id))\n    return col_rec\n\n\n  def setAutoRemove(self, record, yes_or_no):\n    \"\"\"\n    Marks a record for automatic removal. To use, create a formula in your table, e.g.\n    'setAutoRemove', which calls `table.docmodel.setAutoRemove(boolean_value)`. Whenever it gets\n    reevaluated and the boolean_value is true, the record will be automatically removed.\n    It's mostly used for metadata tables. It's also used for summary table rows with empty groups,\n    which requires a bit of extra care.\n    \"\"\"\n    if yes_or_no:\n      self._auto_remove_set.add(record)\n    else:\n      self._auto_remove_set.discard(record)\n\n  def apply_auto_removes(self):\n    \"\"\"\n    Remove the records marked for removal.\n    \"\"\"\n    # Sort to make sure removals are done in deterministic order.\n    gone_records = sorted(\n      self._auto_remove_set,\n      # Remove tables last to prevent errors trying to remove rows or columns from deleted tables.\n      key=lambda r: (r._table.table_id == \"_grist_Tables\", r)\n    )\n    self._auto_remove_set.clear()\n    # setAutoRemove is called by formulas, notably summary tables, and shouldn't be blocked by ACL.\n    with self._engine.user_actions.indirect_actions():\n      self.remove(gone_records)\n    return bool(gone_records)\n\n  def remove(self, records):\n    \"\"\"\n    Removes all records in the given iterable of Records.\n    \"\"\"\n    for table_id, group in itertools.groupby(records, lambda r: r._table.table_id):\n      self._engine.user_actions.BulkRemoveRecord(table_id, [int(r) for r in group])\n\n  def update(self, records, **col_values):\n    \"\"\"\n    Updates all records in the given list of Records or a RecordSet; col_values maps column ids to\n    values. The values may either be a list of the length len(records), or a non-list value that\n    will be used for all records.\n    \"\"\"\n    record_list = list(records)\n    if not record_list:\n      return\n    table_id = record_list[0]._table.table_id\n    # Make sure these are all records from the same table.\n    assert all(r._table.table_id == table_id for r in record_list)\n    row_ids = [int(r) for r in record_list]\n    values = _unify_col_values(col_values, len(record_list))\n    self._engine.user_actions.BulkUpdateRecord(table_id, row_ids, values)\n\n  def add(self, record_set_or_table, **col_values):\n    \"\"\"\n    Add new records for the given table; col_values maps column ids to values. Values may either\n    be lists (all of the same length), or non-list values that will be used for all added records.\n    Either a UserTable or a RecordSet may used as the first argument. If it is a RecordSet created\n    with lookupRecords, it may set additional col_values.\n    Returns a list of inserted records.\n    \"\"\"\n    assert isinstance(record_set_or_table, (records.RecordSet, table.UserTable))\n    count = _get_col_values_count(col_values)\n    values = _unify_col_values(col_values, count)\n\n    if isinstance(record_set_or_table, records.RecordSet):\n      table_obj = record_set_or_table._table\n      group_by = record_set_or_table._group_by\n      if group_by:\n        values.update((k, [v] * count) for k, v in group_by.items() if k not in values)\n    else:\n      table_obj = record_set_or_table.table\n\n    row_ids = self._engine.user_actions.BulkAddRecord(table_obj.table_id, [None] * count, values)\n    return [table_obj.Record(r, None) for r in row_ids]\n\n  def insert(self, record_set, position, **col_values):\n    \"\"\"\n    Add new records using col_values, inserting them into record_set according to position.\n    This may only be used when record_set is sorted by a field of type PositionNumber; in\n    particular it must be the result of lookupRecords() with 'sort_by' parameter.\n    Position may be numeric (to compare to other sort_by values), or None to insert at the end.\n    Returns a list of inserted records.\n    \"\"\"\n    assert isinstance(record_set, records.RecordSet), \\\n        \"docmodel.insert() may only be used on a RecordSet, not %s\" % type(record_set)\n    sort_by = getattr(record_set, '_sort_by', None)\n    assert sort_by, \\\n        \"docmodel.insert() may only be used on a sorted RecordSet\"\n    column = record_set._table.get_column(sort_by)\n    assert isinstance(column.type_obj, usertypes.PositionNumber), \\\n        \"docmodel.insert() may only be used on a RecordSet sorted by PositionNumber type column\"\n\n    col_values[sort_by] = float('inf') if position is None else position\n    return self.add(record_set, **col_values)\n\n  def insert_after(self, record_set, position, **col_values):\n    \"\"\"\n    Same as insert, but when position is equal to the position of an existing record, inserts\n    after that record; and when position is None, inserts at the beginning.\n    \"\"\"\n    # We can reuse insert() by just using the next float for position. As long as positions of\n    # existing records are different, that would necessarily place the new records correctly.\n    pos = float('-inf') if position is None else relabeling.nextfloat(position)\n    return self.insert(record_set, pos, **col_values)\n\n\ndef _unify_col_values(col_values, count):\n  \"\"\"\n  Helper that converts a dict mapping keys to values or lists of values to all lists. Non-list\n  values get turned into lists by repeating them count times.\n  \"\"\"\n  assert all(len(v) == count for v in col_values.values() if isinstance(v, list))\n  return {k: (v if isinstance(v, list) else [v] * count)\n          for k, v in col_values.items()}\n\ndef _get_col_values_count(col_values):\n  \"\"\"\n  Helper that returns the length of the first list in among the values of col_values. If none of\n  the values is a list, returns 1.\n  \"\"\"\n  first_list = next((v for v in col_values.values() if isinstance(v, list)), None)\n  return len(first_list) if first_list is not None else 1\n"
  },
  {
    "path": "sandbox/grist/dropdown_condition.py",
    "content": "import json\nimport logging\nimport usertypes\n\nfrom predicate_formula import NamedEntity, parse_predicate_formula_json, TreeConverter\nimport predicate_formula\n\nlog = logging.getLogger(__name__)\n\nclass _DCEntityCollector(TreeConverter):\n  def __init__(self):\n    self.entities = []\n\n  def visit_Attribute(self, node):\n    parent = self.visit(node.value)\n\n    if parent == [\"Name\", \"choice\"]:\n      self.entities.append(NamedEntity(\"choiceAttr\", node.last_token.startpos, node.attr, None))\n    elif parent == [\"Name\", \"rec\"]:\n      self.entities.append(NamedEntity(\"recCol\", node.last_token.startpos, node.attr, None))\n\n    return [\"Attr\", parent, node.attr]\n\n\ndef perform_dropdown_condition_renames(useractions, renames):\n  \"\"\"\n  Given a dict of column renames of the form {(table_id, col_id): new_col_id}, applies updates\n  to the affected dropdown condition formulas.\n  \"\"\"\n  updates = []\n\n  for col in useractions.get_docmodel().columns.all:\n    if not col.widgetOptions:\n      continue\n\n    # Find all columns in the document that have dropdown conditions.\n    try:\n      widget_options = json.loads(col.widgetOptions)\n      dc_formula = widget_options[\"dropdownCondition\"][\"text\"]\n    except (ValueError, KeyError):\n      continue\n\n    # Find out what table this column refers to and belongs to.\n    ref_table_id = usertypes.get_referenced_table_id(col.type)\n    self_table_id = col.parentId.tableId\n\n    def renamer(subject):\n      # subject.type is either choiceAttr or recCol, see _DCEntityCollector.\n      table_id = ref_table_id if subject.type == \"choiceAttr\" else self_table_id\n      # Dropdown conditions stay in widgetOptions, even when the current column type can't make\n      # use of them. Thus, attributes of \"choice\" do not make sense for columns other than Ref and\n      # RefList, but they may exist.\n      # We set ref_table_id to None in this case, so table_id will be None for stray choiceAttrs,\n      # therefore the subject will not be renamed.\n      # Columns of \"rec\" are still renamed accordingly.\n      return renames.get((table_id, subject.name))\n\n    new_dc_formula = predicate_formula.process_renames(dc_formula, _DCEntityCollector(), renamer)\n\n    # The data engine stops processing remaining formulas when it hits an internal exception during\n    # this renaming procedure. Parsing could potentially raise SyntaxErrors, so we must be careful\n    # not to parse a possibly syntactically wrong formula, or handle SyntaxErrors explicitly.\n    # Note that new_dc_formula was obtained from process_renames, where syntactically wrong formulas\n    # are left untouched. It is anticipated that rename-induced changes will not introduce new\n    # SyntaxErrors, so if the formula text is updated, the new version must be valid, hence safe\n    # to parse without error handling.\n    # This also serves as an optimization to avoid unnecessary parsing operations.\n    if new_dc_formula != dc_formula:\n      widget_options[\"dropdownCondition\"][\"text\"] = new_dc_formula\n      widget_options[\"dropdownCondition\"][\"parsed\"] = parse_predicate_formula_json(new_dc_formula)\n      updates.append((col, {\"widgetOptions\": json.dumps(widget_options)}))\n\n  # Update the dropdown condition in the database.\n  useractions.doBulkUpdateFromPairs('_grist_Tables_column', updates)\n\n\ndef parse_dropdown_conditions(col_values):\n  \"\"\"\n  Parses any unparsed dropdown conditions in `col_values`.\n  \"\"\"\n  if 'widgetOptions' not in col_values:\n    return\n\n  col_values['widgetOptions'] = [parse_dropdown_condition(widget_options_json)\n                                 for widget_options_json\n                                 in col_values['widgetOptions']]\n\ndef parse_dropdown_condition(widget_options_json):\n  \"\"\"\n  Parses `dropdownCondition.text` in `widget_options_json` and stores the parsed\n  representation in `dropdownCondition.parsed`.\n\n  If `dropdownCondition.parsed` is already set, parsing is skipped (as an optimization).\n  Clients are responsible for including just `dropdownCondition.text` when creating new\n  (or updating existing) dropdown conditions.\n\n  Returns an updated copy of `widget_options_json` or the original widget_options_json\n  if parsing was skipped.\n  \"\"\"\n  try:\n    widget_options = json.loads(widget_options_json)\n    if 'dropdownCondition' not in widget_options:\n      return widget_options_json\n\n    dropdown_condition = widget_options['dropdownCondition']\n    if 'parsed' in dropdown_condition:\n      return widget_options_json\n\n    dropdown_condition['parsed'] = parse_predicate_formula_json(dropdown_condition['text'])\n    return json.dumps(widget_options)\n  except (TypeError, ValueError):\n    return widget_options_json\n"
  },
  {
    "path": "sandbox/grist/engine.py",
    "content": "# pylint:disable=too-many-lines\n\"\"\"\nThe data engine ties the code generated from the schema with the document data, and with\ndependency tracking.\n\"\"\"\nimport itertools\nimport logging\nimport re\nimport rlcompleter\nimport sys\nimport time\nimport traceback\nfrom collections import namedtuple, OrderedDict, defaultdict\n\nfrom collections.abc import Hashable\nfrom sortedcontainers import SortedSet\n\nimport acl\nimport actions\nimport action_obj\nfrom attribute_recorder import AttributeRecorder\nfrom autocomplete_context import AutocompleteContext, lookup_autocomplete_options, eval_suggestion\nfrom codebuilder import DOLLAR_REGEX\nimport depend\nimport docactions\nimport docmodel\nfrom fake_std_streams import FakeStdStreams\nimport gencode\nimport match_counter\nimport objtypes\nfrom objtypes import strict_equal\nfrom relation import SingleRowsIdentityRelation\nimport sandbox\nimport schema\nfrom schema import RecalcWhen\nimport table as table_module\nfrom timing import DummyTiming\nfrom user import User # pylint:disable=wrong-import-order\nimport useractions\nimport column\nimport urllib_patch  # noqa imported for side effect # pylint:disable=unused-import\n\nlog = logging.getLogger(__name__)\n\n\nclass OrderError(Exception):\n  \"\"\"\n  An exception thrown and handled internally, representing when\n  evaluating a formula for a cell requires a value from another cell\n  (or lookup) that has not yet itself been evaluated.  Formulas used\n  to be evaluated recursively, on the program stack, but now ordering\n  is organized explicitly by watching for this exception and adapting\n  evaluation order appropriately.\n  \"\"\"\n  def __init__(self, message, node, row_id):\n    super(OrderError, self).__init__(message)\n    self.node = node               # The column of the cell evaluated out of order.\n    self.row_id = row_id           # The row_id of the cell evaluated out of order.\n    self.requiring_node = None     # The column of the original cell being evaluated.\n                                   # Added later since not known at point of exception.\n    self.requiring_row_id = None   # the row_id of the original cell being evaluated\n\n  def set_requirer(self, node, row_id):\n    self.requiring_node = node\n    self.requiring_row_id = row_id\n\n\nclass RequestingError(Exception):\n  \"\"\"\n  An exception thrown and handled internally, a bit like OrderError.\n  Indicates that the formula called the REQUEST function and needs to delegate an HTTP request\n  to the NodeJS server.\n  \"\"\"\n  pass\n\n\n# An item of work to be done by Engine._update\nWorkItem = namedtuple('WorkItem', ('node', 'row_ids', 'locks'))\n\n# skip private members, and methods we don't want to expose to users.\nskipped_completions = re.compile(r'\\.(_|lookupOrAddDerived|getSummarySourceGroup)')\n\n# The schema for the data is documented in gencode.py.\n\n# There is a general process by which values get recomputed. There are two stages:\n# (1) when raw data is loaded or changed by an action, it marks things as \"dirty\".\n#     This is done using engine.recompute_map, which maps Nodes to sets of dirty rows.\n# (2) when up-to-date data is needed, _recompute is called, and updates the dirty rows.\n#     Up-to-date data is needed when it's required externally (e.g. to send to client), and\n#     may be needed recursively when other data is being recomputed.\n\n# In this implementation, rows are identified by a row_id, which functions like an index, so that\n# data may be stored in lists and typed arrays. This is very memory-efficient when row_ids are\n# dense, but bad when they get too sparse. TODO The proposed solution is to have a condense\n# operation which renumbers row_ids when they get too sparse.\n\n# TODO:\n# We should support types SubRecord, SubRecordList, and SubRecordMap. Original thought was to\n# represent them as derived tables with special names, such as \"Foo.field\". This breaks several\n# assumptions about how to organize generated code. Instead, we can use derived tables with valid\n# names (such as \"Foo_field\"), and add an actual column \"field\" with an appropriate type. This\n# column may refer to derived tables or independent tables. Derived tables would have an extra\n# property, marking them as derived, which would affect certain UI decisions.\n\n\nclass Engine(object):\n  \"\"\"\n  The Engine is the core of the grist per-document logic. Some of its methods form the API exposed\n  to the Node controller. These are:\n\n    Initialization:\n\n      load_empty()\n        Initializes an empty document; useful for newly-created documents.\n\n      load_meta_tables(meta_tables, meta_columns)\n      load_table(table_data)\n      load_done()\n        These three must be called in-order to initialize a non-empty document.\n        - First, load_meta_tables() must be called with data for the two special metadata tables\n          containing the schema. It returns the list of other table names the data engine expects.\n        - Then load_table() must be called once for each of the other tables (both special tables,\n          and user tables), with that table's data (no need to call it for empty tables).\n        - Finally, load_done() must be called once to finish initialization.\n          NOTE: instead of load_done(), Grist now applies the no-op 'Calculate' user action.\n\n    Other methods:\n\n      fetch_table(table_id, formulas)\n        Returns a TableData object containing the full data for the table. Formula columns\n        are included only if formulas is True.\n\n      apply_user_actions(user_actions, user)\n        Applies a list of UserActions, which are tuples consisting of the name of the action\n        method (as defind in useractions.py) and the arguments to it. Returns ActionGroup tuple,\n        containing several categories of DocActions, including the results of computations.\n  \"\"\"\n\n  def __init__(self):\n    # The document data, including logic (formulas), and metadata (tables prefixed with \"_grist_\").\n    self.tables = {}                # Maps table IDs (or names) to Table objects.\n\n    # Schema contains information about tables and columns, needed in particular to generate the\n    # code, from which in turn we create all the Table and Column objects. Schema is an\n    # OrderedDict of tableIds to schema.SchemaTable objects. Each of those contains a .columns\n    # OrderedDict of colId to schema.SchemaColumns objects. Order is used when generating code.\n    self.schema = OrderedDict()\n\n    # A more convenient interface to the document metadata.\n    self.docmodel = docmodel.DocModel(self)\n\n    # The module containing the compiled user code generated from the schema.\n    self.gencode = gencode.GenCode()\n\n    # Maintain the dependency graph of what Nodes (columns) depend on what other Nodes.\n    self.dep_graph = depend.Graph()\n\n    # Maps Nodes to sets of dirty rows (that need to be recomputed).\n    self.recompute_map = {}\n\n    # Maps Nodes to sets of done rows (to avoid recomputing in an infinite loop).\n    self._recompute_done_map = {}\n\n    # Contains Nodes once an exception value has been seen for them.\n    self._is_node_exception_reported = set()\n\n    # Contains Edges (node1, node2, relation) already seen during formula accesses.\n    self._recompute_edge_set = set()\n\n    # Sanity-check counter to check if we are making progress.\n    self._recompute_done_counter = 0\n\n    # Maps Nodes to a list of [rowId, value] pairs for cells that have been changed.\n    # Ordered to preserve the order in which first change was made to a column.\n    # This allows actions to be emitted in a legacy order that a lot of tests depend\n    # on.  Not necessary to functioning, just a convenience.\n    self._changes_map = OrderedDict()\n\n    # This is set when we are running engine._update_loop, which has the ability to\n    # evaluate dependencies.  We check this flag in engine._recompute_in_order, which will\n    # start an update loop if called without one already in place.\n    self._in_update_loop = False\n\n    # A set of (node, row_id) cell references.  When evaluating a formula, a dependency\n    # on any of these cells implies a circular dependency.\n    self._locked_cells = set()\n\n    # Set to True by the PEEK() function to temporarily disable dependency tracking\n    self._peeking = False\n\n    # The lists of actions of different kinds, built up while applying an action.\n    self.out_actions = action_obj.ActionGroup()\n\n    # What's currently being computed\n    self._current_node = None\n    self._current_row_id = None\n    self._is_current_node_formula = False  # True for formula columns, False for trigger formulas\n\n    # Certain recomputations are triggered by a particular doc action. This keep track of it.\n    self._triggering_doc_action = None\n\n    # The list of columns that got deleted while applying an action.\n    self._gone_columns = []\n\n    # The set of potentially unused LookupMapColumns.\n    self._unused_lookups = set()\n\n    # Create the formula tracer that can be overridden to trace formula evaluations. It is called\n    # with the Column and Record object for the formula about to be evaluated. It's used in tests.\n    self.formula_tracer = lambda col, record: None\n\n    # Create the object that knows how to interpret UserActions.\n    self.doc_actions = docactions.DocActions(self)\n\n    # Create the object that knows how to interpret UserActions.\n    self.user_actions = useractions.UserActions(self)\n\n    # Map from node to set of row_ids, for cells that should not be recalculated because they are\n    # data columns manually changed in this UserAction.\n    self._prevent_recompute_map = {}\n\n    # Whether any trigger columns may need to have their dependencies rebuilt.\n    self._have_trigger_columns_changed = True\n\n    # A flag for when a useraction causes a schema change, to verify consistency afterwards.\n    self._schema_updated = False\n\n    # Set to false temporarily to suppress rebuild_usercode for performance.\n    # Used when importing which can add many columns which calls rebuild_usercode each time.\n    self._should_rebuild_usercode = True\n\n    # Stores an exception representing the first unevaluated cell met while recomputing the\n    # current cell.\n    self._cell_required_error = None\n\n    # User that is currently applying user actions.\n    self._user = None\n\n    # In general you should access the property autocomplete_context instead,\n    # which initialises this attribute lazily when it's needed by autocomplete.\n    # When the schema changes and usercode is regenerated, this needs to be updated,\n    # but creating a new AutocompleteContext is quite expensive, so we instead\n    # clear this cached context on schema changes and let the property recreate it as needed.\n    self._autocomplete_context = None\n\n    self._table_stats = {\"meta\": [], \"user\": []}\n\n    #### Attributes used by the REQUEST function:\n    # True when the formula should synchronously call the exported JS method to make the request\n    # immediately instead of reevaluating the formula later. Used when reevaluating a single\n    # formula cell to get an error traceback.\n    self._sync_request = False\n    # dict of string keys to responses, set by the RespondToRequests user action to reevaluate\n    # formulas based on a batch of completed requests.\n    self._request_responses = {}\n    # set of string keys identifying requests that are currently cached in files and can thus\n    # be fetched synchronously via the exported JS method. This allows a single formula to\n    # make multiple different requests without needing to keep all the responses in memory.\n    self._cached_request_keys = set()\n\n    self._timing = DummyTiming()\n\n  @property\n  def autocomplete_context(self):\n    # See the comment on _autocomplete_context in __init__ above.\n    if self._autocomplete_context is None:\n      self._autocomplete_context = AutocompleteContext(self.gencode.usercode.__dict__)\n    return self._autocomplete_context\n\n  def record_table_stats(self, table_data, table_data_repr):\n    table_id = table_data.table_id\n    category = \"meta\" if table_id.startswith(\"_grist\") else \"user\"\n    result = dict(\n      rows=len(table_data.row_ids),\n      columns=len(table_data.columns),\n      bytes=len(table_data_repr or \"\"),\n      table_id=table_id,\n    )\n    result[\"cells\"] = result[\"rows\"] * result[\"columns\"]\n    self._table_stats[category].append(result)\n\n  def get_table_stats(self):\n    result = defaultdict(int, num_user_tables=len(self._table_stats[\"user\"]))\n\n    for table in self._table_stats[\"meta\"]:\n      for field in [\"rows\", \"bytes\"]:\n        key = \"%s_%s\" % (table[\"table_id\"], field)\n        result[key] = table[field]\n\n    for table in self._table_stats[\"user\"]:\n      for field in table:\n        if field == \"table_id\":\n          continue\n        key = \"user_%s\" % field\n        result[key] += table[field]\n\n    return dict(result)\n\n  def load_empty(self):\n    \"\"\"\n    Initialize an empty document, e.g. a newly-created one.\n    \"\"\"\n    self.load_meta_tables(actions.TableData('_grist_Tables', [], {}),\n                          actions.TableData('_grist_Tables_column', [], {}))\n    self.load_done()\n\n  def load_meta_tables(self, meta_tables, meta_columns):\n    \"\"\"\n    Must be the first method to call for this Engine. The arguments must contain the data for the\n    _grist_Tables and _grist_Tables_column tables, in the form of actions.TableData.\n    Returns the list of all the other table names that data engine expects to be loaded.\n    \"\"\"\n    self.schema = schema.build_schema(meta_tables, meta_columns)\n\n    # Compile the user-defined module code (containing all formulas in particular).\n    self.rebuild_usercode()\n\n    # Load the data into the now-existing metadata tables. This isn't used directly, it's just a\n    # mirror of the schema for storage and for looking at.\n    self.load_table(meta_tables)\n    self.load_table(meta_columns)\n    return sorted(table_id for table_id in self.tables\n                  if table_id not in (meta_tables.table_id, meta_columns.table_id))\n\n  def load_table(self, data):\n    \"\"\"\n    Must be called for each of the metadata tables (except the ones given to load_meta), and for\n    each user-defined table. The argument is an actions.TableData object.\n    \"\"\"\n    table = self.tables[data.table_id]\n\n    # Clear all columns, whether or not they are present in the data.\n    for column in table.all_columns.values():\n      column.clear()\n\n    # Only load columns that aren't stored.\n    columns = {col_id: data for (col_id, data) in data.columns.items()\n               if table.has_column(col_id)}\n\n    # Add the records.\n    self.add_records(data.table_id, data.row_ids, columns)\n\n  def load_done(self):\n    \"\"\"\n    Finalizes the loading of data into this Engine.\n    NOTE: instead of load_done(), Grist now applies the no-op 'Calculate' user action.\n    \"\"\"\n    self._bring_all_up_to_date()\n\n  def add_records(self, table_id, row_ids, column_values):\n    \"\"\"\n    Helper to add records to the given table, with row_ids and column_values having the same\n    interpretation as in TableData or BulkAddRecords. It's used both for the initial loading of\n    data, and for BulkAddRecords itself.\n    \"\"\"\n    table = self.tables[table_id]\n\n    growto_size = (max(row_ids) + 1) if row_ids else 1\n\n    # Create the new records.\n    id_column = table.get_column('id')\n    id_column.growto(growto_size)\n    for row_id in row_ids:\n      id_column.set(row_id, row_id)\n\n    # Resize all columns to the full table size.\n    table.grow_to_max()\n\n    # Load the new values.\n    for col_id, values in column_values.items():\n      column = table.get_column(col_id)\n      column.growto(growto_size)\n      for row_id, value in zip(row_ids, values):\n        column.set(row_id, value)\n\n    # Invalidate new records to cause the formula columns to get recomputed.\n    self.invalidate_records(table_id, row_ids)\n\n  def fetch_table(self, table_id, formulas=True, private=False, query=None):\n    \"\"\"\n    Returns TableData object representing all data in this table.\n    \"\"\"\n    table = self.tables[table_id]\n    column_values = {}\n\n    query_cols = []\n    if query:\n      for col_id, values in query.items():\n        col = table.get_column(col_id)\n        try:\n          # Try to use a set for speed.\n          values = set(values)\n        except TypeError:\n          # Values contains an unhashable value, leave it as a list.\n          pass\n        query_cols.append((col, values))\n    row_ids = []\n    for r in table.row_ids:\n      for (c, values) in query_cols:\n        try:\n          if c.raw_get(r) not in values:\n            break\n        except TypeError:\n          # values is a set but c.raw_get(r) is unhashable, so it's definitely not in values\n          break\n      else:\n        # No break, i.e. all columns matched\n        row_ids.append(r)\n\n    for c in table.all_columns.values():\n      # pylint: disable=too-many-boolean-expressions\n      if ((formulas or not c.is_formula())\n          and (private or not c.is_private())\n          and c.col_id != \"id\" and not column.is_virtual_column(c.col_id)):\n        column_values[c.col_id] = [c.raw_get(r) for r in row_ids]\n\n    return actions.TableData(table_id, row_ids, column_values)\n\n  def fetch_table_schema(self):\n    return self.gencode.get_user_text()\n\n  def fetch_meta_tables(self, formulas=True):\n    \"\"\"\n    Returns {table_id: TableData} mapping for all metadata tables (those starting with '_grist_').\n\n    Note the slight naming difference with load_meta_tables: that one expects just two\n    extra-special tables, whereas fetch_meta_tables returns all special tables.\n    \"\"\"\n    return {table_id: self.fetch_table(table_id, formulas=formulas)\n            for table_id in self.tables if table_id.startswith('_grist_')}\n\n  def find_col_from_values(self, values, n, opt_table_id=None):\n    \"\"\"\n    Returns a list of colRefs for columns whose values match a given list. The results are ordered\n    from best to worst according to the number of matches of distinct values.\n\n    If n is non-zero, limits the results to that number. If opt_table_id is given, search only\n    that table for matching columns.\n    \"\"\"\n    start_time = time.time()\n    # Exclude default values, since these will often result in matching new/incomplete columns.\n    # If a value is unhashable, set() will fail, so we check for that.\n    sample = set(v for v in values if isinstance(v, Hashable))\n    matched_cols = []\n\n    # If the column has no values, return\n    if not sample:\n      return []\n\n    search_cols = (self.docmodel.get_table_rec(opt_table_id).columns\n                   if opt_table_id in self.tables else self.docmodel.columns.all)\n\n    m = match_counter.MatchCounter(sample)\n    # Iterates through each valid column in the document, counting matches.\n    for c in search_cols:\n      if (not (gencode._is_special_table(c.tableId) or c.parentId.summarySourceTable) and\n          column.is_visible_column(c.colId) and\n          not c.type.startswith('Ref')):\n        table = self.tables[c.tableId]\n        col = table.get_column(c.colId)\n        matches = m.count_unique(col.raw_get(r) for r in itertools.islice(table.row_ids, 1000))\n        if matches > 0:\n          matched_cols.append((matches, c.id))\n\n    # Sorts the matched columns by the matches, then select the best-matching columns\n    matched_cols.sort(reverse=True)\n    if n:\n      matched_cols = matched_cols[:n]\n\n    log.info('Found column from values in %.3fs', time.time() - start_time)\n    return [c[1] for c in matched_cols]\n\n  def assert_schema_consistent(self):\n    \"\"\"\n    Asserts that the internally-stored schema is equivalent to the schema as represented by the\n    special tables of metadata.\n    \"\"\"\n    meta_tables = self.fetch_table('_grist_Tables')\n    meta_columns = self.fetch_table('_grist_Tables_column')\n    gen_schema = schema.build_schema(meta_tables, meta_columns)\n    gen_schema_dicts = {k: (t.tableId, dict(t.columns))\n                        for k, t in gen_schema.items()}\n    cur_schema_dicts = {k: (t.tableId, dict(t.columns))\n                        for k, t in self.schema.items()}\n    if cur_schema_dicts != gen_schema_dicts:\n      import pprint\n      import difflib\n      a = (pprint.pformat(cur_schema_dicts) + \"\\n\").splitlines(True)\n      b = (pprint.pformat(gen_schema_dicts) + \"\\n\").splitlines(True)\n      raise AssertionError(\"Internal schema different from that in metadata:\\n\" +\n          \"\".join(difflib.unified_diff(a, b, fromfile=\"internal\", tofile=\"metadata\")))\n\n    # Check there are no stray column records (they aren't picked up by schema diffs, but will\n    # cause inconsistencies with future tables).\n    # TODO: This inconsistency can be triggered by undo of an AddTable action if the table\n    # acquired more columns in subsequent actions. We may want to check for similar situations\n    # with other metadata, e.g. ViewSection fields, where they'd cause different symptoms.\n    # (Or better ensure consistency by design by applying undo correctly, probably via rebase).\n    valid_table_refs = set(meta_tables.row_ids)\n    col_parent_ids = set(meta_columns.columns['parentId'])\n    if col_parent_ids > valid_table_refs:\n      collist = sorted(actions.transpose_bulk_action(meta_columns),\n                       key=lambda c: (c.parentId, c.parentPos))\n      reverse_col_id = schema.get_reverse_col_id_lookup_func(collist)\n      raise AssertionError(\"Internal schema inconsistent; extra columns in metadata:\\n\"\n          + \"\\n\".join('  #%s %s' %\n                      (c.id, schema.SchemaColumn(c.colId, c.type, bool(c.isFormula), c.formula,\n                        reverse_col_id(c)))\n                      for c in collist if c.parentId not in valid_table_refs))\n\n  def dump_state(self):\n    self.dep_graph.dump_graph()\n    self.dump_recompute_map()\n\n  def dump_recompute_map(self):\n    log.debug(\"Recompute map (%d nodes):\", len(self.recompute_map))\n    for node, dirty_rows in self.recompute_map.items():\n      log.debug(\"  Node %s: %s\", node, dirty_rows)\n\n  def _use_node(self, node, relation, row_ids=[]):\n    # This is used whenever a formula accesses any part of any record. It's hot code, and\n    # it's worth optimizing.\n\n    if self._peeking:\n      return\n\n    if self._is_current_node_formula:\n      # Add an edge to indicate that the node being computed depends on the node passed in.\n      # Note that during evaluation, we only *add* dependencies. We *remove* them by clearing them\n      # whenever ALL rows for a node are invalidated (on schema changes and reloads).\n      edge = (self._current_node, node, relation)\n      if edge not in self._recompute_edge_set:\n        self.dep_graph.add_edge(*edge)\n        self._recompute_edge_set.add(edge)\n\n    # This check is not essential here, but is an optimization that saves cycles.\n    if self.recompute_map.get(node) is None:\n      return\n\n    self._recompute(node, row_ids)\n\n  def _pre_update(self):\n    \"\"\"\n    Called at beginning of _bring_all_up_to_date or _bring_mlookups_up_to_date.\n    Makes sure cell change accumulation is reset.\n    \"\"\"\n    self._changes_map = OrderedDict()\n    self._recompute_done_map = {}\n    self._locked_cells = set()\n    self._is_node_exception_reported = set()\n    self._recompute_edge_set = set()\n    self._cell_required_error = None\n\n  def _post_update(self):\n    \"\"\"\n    Called at end of _bring_all_up_to_date or _bring_mlookups_up_to_date.\n    Issues actions for any accumulated cell changes.\n    \"\"\"\n    for node, changes in self._changes_map.items():\n      table = self.tables[node.table_id]\n      col = table.get_column(node.col_id)\n      # If there are changes, save them in out_actions.\n      if changes and not col.is_private():\n        self.out_actions.summary.add_changes(node.table_id, node.col_id, changes)\n\n    self._pre_update()  # empty lists/sets/maps\n\n  def _update_loop(self, work_items, ignore_other_changes=False):\n    \"\"\"\n    Called to compute the specified cells, including any nested dependencies.\n    Consumes OrderError exceptions, and reacts to them with a strategy for\n    reordering cell evaluation.  That strategy is currently simple:\n      * Maintain a stack of work item triplets.  Each work item has:\n         - A node (table/column pair).\n         - A list of row_ids to compute (this can be None, meaning \"all\").\n         - A list of row_ids to \"unlock\" once finished.\n      * Until stack is empty, take a work item off the stack and attempt to\n        _recompute the specified rows of the specified node.\n         - If an OrderError is received, first check it is for a cell we\n           requested (_recompute will opportunistically try to compute\n           other cells we haven't asked for, and it is important for the\n           purposes of cycle detection to discount that).\n         - If so, \"lock\" that cell, push the current work item back on the\n           stack (remembering which cell to unlock later), and add a new\n           work item for the cell that threw the OrderError.\n           + The \"lock\" serves only for cycle detection.\n           + The order of stack placement means that the cell that threw\n             the OrderError will now be evaluated before the cell that\n             depends on it.\n         - If not, ignore the OrderError.  If we actually need that cell,\n           We'll get back to it later as we work up the work_items stack.\n      * The _recompute method, as mentioned, will attempt to compute not\n        just the requested rows of a particular column, but any other dirty\n        cells in that column.  This is an important optimization for the\n        common case of columns with non-self-referring dependencies.\n    \"\"\"\n    self._in_update_loop = True\n    while self.recompute_map:\n      self._recompute_done_counter = 0\n      self._expected_done_counter = 0\n      while work_items:\n        node, row_ids, locks = work_items.pop()\n        try:\n          self._recompute_step(node, require_rows=row_ids)\n        except OrderError as e:\n          # Need to schedule re-ordered evaluation\n          assert node == e.requiring_node\n          assert (not row_ids) or (e.requiring_row_id in row_ids)\n          # Put current work item back on stack, and don't dispose its locks\n          work_items.append(WorkItem(node, row_ids, locks))\n          locks = []\n          # Add a new work item for the cell we are following up, and lock\n          # it to forbid circular dependencies\n          lock = (node, e.requiring_row_id)\n          work_items.append(WorkItem(e.node, [e.row_id], [lock]))\n          self._locked_cells.add(lock)\n        # Discard any locks once work item is complete\n        for lock in locks:\n          if lock not in self._locked_cells:\n            # If cell is already unlocked, don't double-count it.\n            continue\n          self._locked_cells.discard(lock)\n          # Sanity check: make sure we've computed at least one more cell\n          self._expected_done_counter += 1\n          if self._recompute_done_counter < self._expected_done_counter:\n            raise Exception('data engine not making progress updating dependencies')\n      if ignore_other_changes:\n        # For _bring_mlookups_up_to_date, we should only wait for the work items\n        # explicitly requested.\n        break\n      # Sanity check that we computed at least one cell.\n      if self.recompute_map and self._recompute_done_counter == 0:\n        raise Exception('data engine not making progress updating formulas')\n      # Figure out remaining work to do, maintaining classic Grist ordering.\n      work_items = self._make_sorted_work_items(self.recompute_map.keys())\n    self._in_update_loop = False\n\n  def _make_sorted_work_items(self, nodes):     # pylint:disable=no-self-use\n    # Build WorkItems from a list of nodes, maintaining classic Grist ordering (in order by name).\n    # WorkItems are processed from the end (hence reverse=True). Additionally, we sort all\n    # #lookups to be processed first. See note in _bring_mlookups_up_to_date why this is important.\n    nodes = sorted(nodes, reverse=True, key=lambda n: (not n.col_id.startswith('#lookup'), n))\n    return [WorkItem(node, None, []) for node in nodes]\n\n  def _bring_all_up_to_date(self):\n    # Bring all nodes up to date. We iterate in sorted order of the keys so that the order is\n    # deterministic (which is helpful for tests in particular).\n    self._pre_update()\n    try:\n      # Figure out remaining work to do, maintaining classic Grist ordering.\n      work_items = self._make_sorted_work_items(self.recompute_map.keys())\n      self._update_loop(work_items)\n      # Check if any potentially unused LookupMaps are still unused, and if so, delete them.\n      for lookup_map in self._unused_lookups:\n        if self.dep_graph.remove_node_if_unused(lookup_map.node):\n          self.delete_column(lookup_map)\n    finally:\n      self._unused_lookups.clear()\n      self._post_update()\n\n  def _bring_mlookups_up_to_date(self, triggering_doc_action):\n    # Just bring the *metadata* lookup nodes up to date.\n    #\n    # In general, lookup nodes don't know exactly what depends on them until they are\n    # recomputed. So invalidating lookup nodes doesn't complete all invalidation; further\n    # invalidations may be generated in the course of recomputing the lookup nodes.\n    #\n    # We use some private formulas on metadata tables internally (e.g. for a list columns of a\n    # table). This method is part of a somewhat hacky solution in apply_doc_action: to force\n    # recomputation of lookup nodes to ensure that we see up-to-date results between applying doc\n    # actions.\n    #\n    # For regular data, correct values aren't needed until we recompute formulas. So we process\n    # lookups before other formulas, but do not need to update lookups after each doc_action.\n    #\n    # In addition, we expose the triggering doc_action so that lookupOrAddDerived can avoid adding\n    # a record to a derived table when the trigger itself is a change to the derived table. This\n    # currently only happens on undo, and is admittedly an ugly workaround.\n    self._pre_update()\n    try:\n      self._triggering_doc_action = triggering_doc_action\n      nodes = [node for node in self.recompute_map\n               if node.col_id.startswith('#lookup') and node.table_id.startswith('_grist_')]\n      work_items = self._make_sorted_work_items(nodes)\n      self._update_loop(work_items, ignore_other_changes=True)\n    finally:\n      self._triggering_doc_action = None\n      self._post_update()\n\n  def is_triggered_by_table_action(self, table_id):\n    # Workaround for lookupOrAddDerived that prevents AddRecord from being created when the\n    # trigger is itself an action for the same table. See comments for _bring_mlookups_up_to_date.\n    a = self._triggering_doc_action\n    return a and getattr(a, 'table_id', None) == table_id\n\n  def bring_col_up_to_date(self, col_obj):\n    \"\"\"\n    Public interface to recompute a column if it is dirty. It also generates a calc or stored\n    action and adds it into self.out_actions object.\n    \"\"\"\n    self._pre_update()\n    try:\n      self._recompute_done_map.pop(col_obj.node, None)\n      self._recompute(col_obj.node)\n    finally:\n      self._post_update()\n\n  def get_formula_error(self, table_id, col_id, row_id):\n    \"\"\"\n    Returns an error message (traceback) for one concrete cell which user clicked.\n    It is sufficient in case when we want to get traceback for only one formula cell with error,\n    not recomputing the whole column and dependent columns as well. So it recomputes the formula\n    for this cell and returns error message with details.\n    \"\"\"\n    result = self.get_formula_value(table_id, col_id, row_id)\n    table = self.tables[table_id]\n    col = table.get_column(col_id)\n    # If the error is gone for a trigger formula\n    if col.has_formula() and not col.is_formula():\n      if not isinstance(result, objtypes.RaisedException):\n        # Get the error stored in the cell\n        # and change it to show to the user that no traceback is available\n        error_in_cell = objtypes.decode_object(col.raw_get(row_id))\n        assert isinstance(error_in_cell, objtypes.RaisedException)\n        return error_in_cell.no_traceback()\n    return result\n\n  def get_formula_value(self, table_id, col_id, row_id, record_attributes=None):\n    table = self.tables[table_id]\n    col = table.get_column(col_id)\n    checkpoint = self._get_undo_checkpoint()\n    # Makes calls to REQUEST synchronous, since raising a RequestingError can't work here.\n    self._sync_request = True\n    try:\n      return self._recompute_one_cell(table, col, row_id, record_attributes=record_attributes)\n    finally:\n      # It is possible for formula evaluation to have side-effects that produce DocActions (e.g.\n      # lookupOrAddDerived() creates those). In case of get_formula_error(), these aren't fully\n      # processed (e.g. don't get applied to DocStorage), so it's important to reverse them.\n      self._sync_request = False\n      self._undo_to_checkpoint(checkpoint)\n\n  def _recompute(self, node, row_ids=None):\n    \"\"\"\n    Make sure cells of a node are up to date, recomputing as necessary.  Can optionally\n    be limited to a list of rows that are of interest.\n    \"\"\"\n    if self._in_update_loop:\n      # This is a nested evaluation.  If there are in fact any cells to evaluate,\n      # this must result in an OrderError.  We let engine._recompute_step\n      # take care of figuring this out.\n      self._recompute_step(node, allow_evaluation=False, require_rows=row_ids)\n    else:\n      # Sometimes _use_node is called from outside _update_loop.  In this case,\n      # we start an _update_loop to compute whatever is required.  Otherwise\n      # nested dependencies would not get computed.\n      self._update_loop([WorkItem(node, row_ids, [])], ignore_other_changes=True)\n\n\n  def _recompute_step(self, node, allow_evaluation=True, require_rows=None): # pylint: disable=too-many-statements\n    \"\"\"\n    Recomputes a node (i.e. column), evaluating the appropriate formula for the given rows\n    to get new values. Only columns whose .has_formula() is true should ever have invalidated rows\n    in recompute_map (this includes data columns with a default formula, for newly-added records).\n\n    If `allow_evaluation` is false, any time we would recompute a node, we instead throw\n    an OrderError exception.  This is used to \"flatten\" computation - instead of evaluating\n    nested dependencies on the program stack, an external loop will evaluate them in an\n    unnested order.  Remember that formulas may access other columns, and column access calls\n    engine._use_node, which calls _recompute to bring those nodes up to date.\n\n    Recompute records changes in _changes_map, which is used later to generate appropriate\n    BulkUpdateRecord actions, either calc (for formulas) or stored (for non-formula columns).\n    \"\"\"\n\n    dirty_rows = self.recompute_map.get(node, None)\n    if dirty_rows is None:\n      return\n\n    table = self.tables[node.table_id]\n    col = table.get_column(node.col_id)\n    assert col.has_formula(), \"Engine._recompute: called on no-formula node %s\" % (node,)\n\n    # Get a sorted list of row IDs, excluding deleted rows (they will sometimes end up in\n    # recompute_map) and rows already done (since _recompute_done_map got cleared).\n    if node not in self._recompute_done_map:\n      # Before starting to evaluate a formula, call reset_rows()\n      # on all relations with nodes we depend on. E.g. this is\n      # used for lookups, so that we can reset stored lookup\n      # information for rows that are about to get reevaluated.\n      self.dep_graph.reset_dependencies(node, dirty_rows)\n      self._recompute_done_map[node] = set()\n\n    exclude = self._recompute_done_map[node]\n    if dirty_rows == depend.ALL_ROWS:\n      dirty_rows = SortedSet(r for r in table.row_ids if r not in exclude)\n      self.recompute_map[node] = dirty_rows\n\n    exempt = self._prevent_recompute_map.get(node, None)\n    if exempt:\n      # If allow_evaluation=False we're not supposed to actually compute dirty_rows.\n      # But we may need to compute them later,\n      # so ensure self.recompute_map[node] isn't mutated by separating it from dirty_rows.\n      # Therefore dirty_rows is assigned a new value. Note that -= would be a mutation.\n      dirty_rows = dirty_rows - exempt\n      if allow_evaluation:\n        self.recompute_map[node] = dirty_rows\n\n    require_rows = sorted(require_rows or [])\n\n    previous_current_node = self._current_node\n    previous_is_current_node_formula = self._is_current_node_formula\n    self._current_node = node\n    # Prevents dependency creation for non-formula nodes. A non-formula column may include a\n    # formula to eval for a newly-added record. Those shouldn't create dependencies.\n    self._is_current_node_formula = col.is_formula()\n\n    changes = None\n    cleaned = []    # this lists row_ids that can be removed from dirty_rows once we are no\n                    # longer iterating on it.\n    try:\n      require_count = len(require_rows)\n      for i, row_id in enumerate(itertools.chain(require_rows, dirty_rows)):\n        required = i < require_count or require_count == 0\n        if require_count and row_id not in dirty_rows:\n          # Nothing need be done for required rows that are already up to date.\n          continue\n        if row_id not in table.row_ids or row_id in exclude:\n          # We can declare victory for absent or excluded rows.\n          cleaned.append(row_id)\n          continue\n        if not allow_evaluation:\n          # We're not actually in a position to evaluate this cell, we need to just\n          # report that we needed an _update_loop will arrange for us to be called\n          # again in a better order.\n          if required:\n            msg = 'Cell value not available yet'\n            err = OrderError(msg, node, row_id)\n            if not self._cell_required_error:\n              # Cache the exception in case user consumes it or modifies it in their formula.\n              self._cell_required_error = OrderError(msg, node, row_id)\n            raise err\n          # For common-case formulas, all cells in a column are likely to fail in the same way,\n          # so don't bother trying more from this column until we've reordered.\n          return\n        save_value = True\n        value = None\n        try:\n          # We figure out if we've hit a cycle here.  If so, we just let _recompute_on_cell\n          # know, so it can set the cell value appropriately and do some other bookkeeping.\n          cycle = required and (node, row_id) in self._locked_cells\n          value = self._recompute_one_cell(table, col, row_id, cycle=cycle, node=node)\n        except RequestingError:\n          # The formula will be evaluated again soon when we have a response.\n          save_value = False\n        except OrderError as e:\n          if not required:\n            # We're out of order, but for a cell we were evaluating opportunistically.\n            # Don't throw an exception, since it could lead us off on a wild goose\n            # chase - let _update_loop focus on one path at a time.\n            return\n          # Keep track of why this cell was needed.\n          e.requiring_node = node\n          e.requiring_row_id = row_id\n          raise e\n\n        # Successfully evaluated a cell!  Unlock it if it was locked, so other cells can\n        # use it without triggering a cyclic dependency error.\n        self._locked_cells.discard((node, row_id))\n\n        if isinstance(value, objtypes.RaisedException):\n          is_first = node not in self._is_node_exception_reported\n          if is_first:\n            self._is_node_exception_reported.add(node)\n            log.info(\"Formula error in %s: %s\", node, value.details)\n            # strip out details after logging\n            value = objtypes.RaisedException(value.error, user_input=value.user_input)\n\n        # TODO: validation columns should be wrapped to always return True/False (catching\n        # exceptions), so that we don't need special handling here.\n        if column.is_validation_column_name(col.col_id):\n          value = (value in (True, None))\n\n        if save_value:\n          # Convert the value, and if needed, set, and include into the returned action.\n          value = col.convert(value)\n          previous = col.raw_get(row_id)\n          if not strict_equal(value, previous):\n            if not changes:\n              changes = self._changes_map.setdefault(node, [])\n            changes.append((row_id, previous, value))\n            col.set(row_id, value)\n\n        exclude.add(row_id)\n        cleaned.append(row_id)\n        self._recompute_done_counter += 1\n    finally:\n      self._current_node = previous_current_node\n      self._is_current_node_formula = previous_is_current_node_formula\n      # Usually dirty_rows refers to self.recompute_map[node], so this modifies both\n      dirty_rows -= cleaned\n\n      # However it's possible for them to be different\n      # (see above where `exempt` is nonempty and allow_evaluation=True)\n      # so here we check self.recompute_map[node] directly\n      if not self.recompute_map[node]:\n        self.recompute_map.pop(node)\n\n  def _requesting(self, key, args):\n    \"\"\"\n    Called by the REQUEST function. If we don't have a response already and we can't\n    synchronously get it from the JS side, then note the request to be made in JS asynchronously\n    and raise RequestingError to indicate that the formula\n    should be evaluated again later when we have a response.\n    \"\"\"\n    # This will make the formula reevaluate periodically with the UpdateCurrentTime action.\n    # This assumes that the response changes with time and having the latest data is ideal.\n    # We will probably want to reconsider this to avoid making unwanted requests,\n    # along with avoiding refreshing the request when the doc is loaded with the Calculate action.\n    self.use_current_time()\n\n    if key in self._request_responses:\n      # This formula is being reevaluated in a RespondToRequests action, and the response is ready.\n      return self._request_responses[key]\n    elif self._sync_request or key in self._cached_request_keys:\n      # Not always ideal, but in this case the best strategy is to make the request immediately\n      # and block while waiting for a response.\n      return sandbox.call_external(\"request\", key, args)\n\n    # We can't get a response to this request now. Note the request so it can be delegated.\n    table_id, column_id = self._current_node\n    (self.out_actions.requests  # `out_actions.requests` is returned by apply_user_actions\n         # Here is where the request arguments are stored if they haven't been already\n         .setdefault(key, args)\n         # While all this stores the cell that made the request so that it can be invalidated later\n         .setdefault(\"deps\", {})\n         .setdefault(table_id, {})\n         .setdefault(column_id, [])\n         .append(self._current_row_id))\n\n    # As with OrderError, note the exception so it gets raised even if the formula catches it\n    self._cell_required_error = RequestingError()\n\n    raise RequestingError()\n\n  def _recompute_one_cell(self, table, col, row_id, cycle=False, node=None, record_attributes=None):\n    \"\"\"\n    Recomputes an one formula cell and returns a value.\n    The value can be:\n      - the recomputed value in case there are no errors\n      - exception\n      - exception with details if flag include_details is set\n    \"\"\"\n    self._current_row_id = row_id\n\n    # Baffling, but keeping a reference to current generated \"usercode\" module protects against a\n    # seeming garbage-collection bug: if during formula evaluation the module gets regenerated\n    # (e.g. a side-effect causes a formula column to change to non-formula), the stale-module\n    # formula code that's still running will see None values in the usermodule's module-dictionary;\n    # just keeping this extra reference allows stale formulas to see valid values.\n    usercode_reference = self.gencode.usercode\n\n    checkpoint = self._get_undo_checkpoint()\n    record = table.Record(row_id, table._identity_relation)\n    if record_attributes is not None:\n      assert isinstance(record_attributes, dict)\n      assert col.is_formula()\n      assert not cycle\n      record = AttributeRecorder(record, \"rec\", record_attributes)\n    value = None\n    with self._timing.measure(col.node):\n      try:\n        if cycle:\n          raise depend.CircularRefError(\"Circular Reference\")\n        if not col.is_formula():\n          value = col.get_cell_value(int(record), restore=True)\n          with FakeStdStreams():\n            result = col.method(record, table.user_table, value, self._user)\n        else:\n          with FakeStdStreams():\n            result = col.method(record, table.user_table)\n        if self._cell_required_error:\n          raise self._cell_required_error  # pylint: disable=raising-bad-type\n        self.formula_tracer(col, record)\n        return result\n      except MemoryError:\n        # Don't try to wrap memory errors.\n        raise\n      except:  # pylint: disable=bare-except\n        # Since col.method runs untrusted user code, we use a bare except to catch all\n        # exceptions (even those not derived from BaseException).\n\n        # Before storing the exception value, make sure there isn't an OrderError pending.\n        # If there is, we will raise it after undoing any side effects.\n        order_error = self._cell_required_error\n\n        # Otherwise, we use sys.exc_info to recover the raised exception object.\n        regular_error = sys.exc_info()[1] if not order_error else None\n\n        # It is possible for formula evaluation to have side-effects that produce DocActions (e.g.\n        # lookupOrAddDerived() creates those). If there is an error, undo any such side-effects.\n        self._undo_to_checkpoint(checkpoint)\n\n        # Now we can raise the order error, if there was one.  Cell evaluation will be reordered\n        # in response.\n        if order_error:\n          self._timing.mark(\"order_error\")\n          self._cell_required_error = None\n          raise order_error  # pylint: disable=raising-bad-type\n\n        self.formula_tracer(col, record)\n\n        include_details = (node not in self._is_node_exception_reported) if node else True\n        if not col.is_formula():\n          return objtypes.RaisedException(regular_error, include_details, user_input=value)\n        else:\n          return objtypes.RaisedException(regular_error, include_details)\n\n  def convert_action_values(self, action):\n    \"\"\"\n    Given a BulkUpdateRecord or BulkAddRecord action, convert the values using the appropriate\n    Column objects, replacing them with the right-type value, alttext, or error objects.\n    \"\"\"\n    table_id, row_ids, column_values = action\n    table = self.tables[action.table_id]\n    new_values = {}\n    extra_actions = []\n    for col_id, values in column_values.items():\n      col_obj = table.get_column(col_id)\n      values = [col_obj.convert(val) for val in values]\n\n      # If there are values for any PositionNumber columns, ensure PositionNumbers are ordered as\n      # intended but are all unique, which may require updating other positions.\n      nvalues, adjustments = col_obj.prepare_new_values(row_ids, values,\n          action_summary=self.out_actions.summary)\n      extra_actions.extend(adjustments)\n\n      new_values[col_id] = nvalues\n\n    if isinstance(action, (actions.BulkAddRecord, actions.ReplaceTableData)):\n      # Make sure we call prepare_new_values() for ALL columns when adding rows. The for-loop\n      # above does it for columns explicitly mentioned; this section does it for the other\n      # columns, using their default values as input to prepare_new_values().\n      ignore_data = isinstance(action, actions.ReplaceTableData)\n      for col_id, col_obj in table.all_columns.items():\n        if col_id in column_values or column.is_virtual_column(col_id) or col_obj.is_formula():\n          continue\n        defaults = [col_obj.getdefault() for r in row_ids]\n        # We use defaults to get new values or adjustments. If we are replacing data, we'll make\n        # the adjustments without regard to the existing data.\n        nvalues, adjustments = col_obj.prepare_new_values(row_ids, defaults,\n            ignore_data=ignore_data,\n            action_summary=self.out_actions.summary)\n        extra_actions.extend(adjustments)\n        if nvalues != defaults:\n          new_values[col_id] = nvalues\n\n    # Return action of the same type (e.g. BulkUpdateAction, BulkAddAction), but with new values,\n    # as well as any extra actions that were generated (as could happen for position adjustments).\n    return (type(action)(table_id, row_ids, new_values), extra_actions)\n\n  def trim_update_action(self, action):\n    \"\"\"\n    Takes a BulkUpdateAction, and returns a new BulkUpdateAction with only those rows that\n    actually cause any changes.\n    \"\"\"\n    table_id, row_ids, column_values = action\n    table = self.tables[action.table_id]\n\n    # Collect for each column the Column object and a list of new values.\n    cols = [(table.get_column(col_id), values) for (col_id, values) in column_values.items()]\n\n    # In comparisons below, we rely here on Python's \"==\" operator to check for equality. After a\n    # type conversion, it may compare the new type to the old, e.g. 1 == 1.0 == True. It's\n    # important that such equality is acceptable also to JS and to DocStorage. So far, it seems\n    # just right.\n\n    # Find columns for which any value actually changed.\n    cols = [(col_obj, values) for (col_obj, values) in cols\n            if any(values[i] != col_obj.raw_get(row_id) for (i, row_id) in enumerate(row_ids))]\n\n    # Now find the indices of rows for which any value actually changed from what's in its Column.\n    row_subset = [i for i, row_id in enumerate(row_ids)\n                  if any(values[i] != col_obj.raw_get(row_id) for (col_obj, values) in cols)]\n\n    # Create and return a new action with just the selected subset of rows.\n    return actions.BulkUpdateRecord(\n      action.table_id,\n      [row_ids[i] for i in row_subset],\n      {col_obj.col_id: [values[i] for i in row_subset]\n       for (col_obj, values) in cols}\n    )\n\n  def invalidate_records(self, table_id, row_ids=depend.ALL_ROWS, col_ids=None,\n                         data_cols_to_recompute=frozenset()):\n    \"\"\"\n    Invalidate the records with the given row_ids. If col_ids is given, only those columns are\n    invalidated (otherwise all columns). If data_cols_to_recompute is given, then non-formula\n    col_ids that have an associated formula will get invalidated too, to cause recomputation.\n\n    Note that it's not just about formula columns; pure data columns need to cause invalidation of\n    formula columns that depend on them. Those data columns that have an associated formula may\n    additionally (typically on AddRecord) be themselves invalidated, to cause recomputation.\n    \"\"\"\n    table = self.tables[table_id]\n    columns = (table.all_columns.values()\n               if col_ids is None else [table.get_column(c) for c in col_ids])\n    for column in columns:\n      # If data_cols_to_recompute includes this column, compute its default formula. This\n      # flag is set on AddRecord and BulkAddRecord, when a default formula needs to be computed.\n      self.invalidate_column(column, row_ids, column.col_id in data_cols_to_recompute)\n\n  def invalidate_column(self, col_obj, row_ids=depend.ALL_ROWS, recompute_data_col=False):\n    # Normally, only formula columns use include_self (to recompute themselves). However, if\n    # recompute_data_col is set, default formulas will also be computed.\n    include_self = col_obj.is_formula() or (col_obj.has_formula() and recompute_data_col)\n    self.dep_graph.invalidate_deps(col_obj.node, row_ids, self.recompute_map,\n                                   include_self=include_self)\n\n  def prevent_recalc(self, node, row_ids, should_prevent):\n    prevented = self._prevent_recompute_map.setdefault(node, set())\n    if should_prevent:\n      prevented.update(row_ids)\n    else:\n      prevented.difference_update(row_ids)\n\n  def rebuild_usercode(self):\n    \"\"\"\n    Compiles the usercode from the schema, and updates all tables and columns to match.\n    \"\"\"\n    if not self._should_rebuild_usercode:\n      return\n\n    self.gencode.make_module(self.schema)\n\n    # Re-populate self.tables, reusing existing tables whenever possible.\n    old_tables = self.tables\n\n    self.tables = {}\n    sorted_tables = []\n    for table_id, user_table in self.gencode.usercode.__dict__.items():\n      if not  isinstance(user_table, table_module.UserTable):\n        continue\n      self.tables[table_id] = table = (\n          old_tables.get(table_id) or table_module.Table(table_id, self)\n      )\n\n      # Process non-summary tables first so that summary tables\n      # can read correct metadata about their source tables\n      key = (hasattr(user_table.Model, '_summarySourceTable'), table_id)\n      sorted_tables.append((key, table, user_table))\n    sorted_tables.sort()\n\n    # Now update the table model for each table, and tie it to its UserTable object.\n    for _, table, user_table in sorted_tables:\n      self._update_table_model(table, user_table)\n      user_table._set_table_impl(table)\n\n    # For any tables that are gone, use self._update_table_model to clean them up.\n    for table_id, table in old_tables.items():\n      if table_id not in self.tables:\n        self._update_table_model(table, None)\n\n    # Update docmodel with references to the updated metadata tables.\n    self.docmodel.update_tables()\n\n    # Set flag to rebuild dependencies of trigger columns after any potential renames, etc.\n    self.trigger_columns_changed()\n\n    # Clear the cached context used for autocompletions.\n    # See the comment on _autocomplete_context in __init__.\n    self._autocomplete_context = None\n\n  def trigger_columns_changed(self):\n    self._have_trigger_columns_changed = True\n\n  def _update_table_model(self, table, user_table):\n    \"\"\"\n    Updates the given Table object to match the given user_table (from usercode module). This\n    builds new columns as needed, and cleans up. To clean up state for a table getting removed,\n    pass in user_table of None.\n    \"\"\"\n    # Save the dict of columns before the update.\n    old_columns = table.all_columns.copy()\n\n    if user_table is None:\n      new_columns = {}\n    else:\n      # Update the table's model. This also builds new columns if needed.\n      table._rebuild_model(user_table)\n      new_columns = table.all_columns\n\n    added_col_ids = new_columns.keys() - old_columns.keys()\n    deleted_col_ids = old_columns.keys() - new_columns.keys()\n\n    # Invalidate the columns that got added and anything that depends on them.\n    if added_col_ids:\n      self.invalidate_records(table.table_id, col_ids=added_col_ids)\n\n    for col_id in deleted_col_ids:\n      self.invalidate_column(old_columns[col_id])\n\n    # Schedule deleted columns for clean-up.\n    for c in deleted_col_ids:\n      self.delete_column(old_columns[c])\n\n    if user_table is None:\n      for c in table.get_helper_columns():\n        self.delete_column(c)\n\n  def _maybe_update_trigger_dependencies(self):\n    if not self._have_trigger_columns_changed:\n      return\n    self._have_trigger_columns_changed = False\n\n    # Without being very smart, if trigger-formula dependencies change for any columns, rebuild\n    # them for all columns. Specifically, we will create nodes and edges in the dependency graph.\n    for table_id, table in self.tables.items():\n      if table_id.startswith('_grist_'):\n        # We can skip metadata tables, there are no trigger-formulas there.\n        continue\n      for col_id, col_obj in table.all_columns.items():\n        if col_obj.is_formula() or not col_obj.has_formula():\n          continue\n        col_rec = self.docmodel.columns.lookupOne(tableId=table_id, colId=col_id)\n\n        out_node = depend.Node(table_id, col_id)\n        rel = SingleRowsIdentityRelation(table_id)\n        self.dep_graph.clear_dependencies(out_node)\n\n        # When we have explicit dependencies, add them to dep_graph.\n        if col_rec.recalcWhen == RecalcWhen.DEFAULT:\n          for dc in col_rec.recalcDeps:\n            in_node = depend.Node(table_id, dc.colId)\n            edge = depend.Edge(out_node, in_node, rel)\n            if edge not in self._recompute_edge_set:\n              self._recompute_edge_set.add(edge)\n              self.dep_graph.add_edge(*edge)\n\n\n  def delete_column(self, col_obj):\n    # Remove the column from its table.\n    if col_obj.table_id in self.tables:\n      self.tables[col_obj.table_id].delete_column(col_obj)\n\n    # Invalidate anything that depends on the column being deleted. The column may be gone from\n    # the table itself, so we use invalidate_column directly.\n    self.invalidate_column(col_obj)\n    # Remove reference to the column from the dependency graph and the recompute_map.\n    self.dep_graph.clear_dependencies(col_obj.node)\n    self.recompute_map.pop(col_obj.node, None)\n    # Mark the column to be destroyed at the end of applying this docaction.\n    self._gone_columns.append(col_obj)\n\n\n  def new_column_name(self, table):\n    \"\"\"\n    Invalidate anything that referenced unknown columns, in case the newly-added name fixes the\n    broken reference.\n    \"\"\"\n    self.dep_graph.invalidate_deps(table._new_columns_node, depend.ALL_ROWS, self.recompute_map,\n                                   include_self=False)\n\n  def update_current_time(self):\n    self.dep_graph.invalidate_deps(self._current_time_node, depend.ALL_ROWS, self.recompute_map,\n                                   include_self=False)\n\n  def use_current_time(self):\n    \"\"\"\n    Add a dependency on the current time to the current evaluating node,\n    so that calling update_current_time() will invalidate the node and cause its reevaluation.\n    \"\"\"\n    if not self._current_node:\n      return\n    table_id = self._current_node[0]\n    table = self.tables[table_id]\n    self._use_node(self._current_time_node, table._identity_relation)\n\n  _current_time_node = (\"#now\", None)\n\n  def mark_lookupmap_for_cleanup(self, lookup_map_column):\n    \"\"\"\n    Once a LookupMapColumn seems no longer used, it's added here. We'll check after recomputing\n    everything, and if still unused, will clean it up.\n    \"\"\"\n    self._unused_lookups.add(lookup_map_column)\n\n  def count_rows(self):\n    result = {\"total\": 0}\n    for table_rec in self.docmodel.tables.all:\n      if useractions.is_user_table(table_rec.tableId):\n        count = self.tables[table_rec.tableId]._num_rows()\n        result[table_rec.id] = count\n        result[\"total\"] += count\n    return result\n\n  def apply_user_actions(self, user_actions, user=None):\n    \"\"\"\n    Applies the list of user_actions. Returns an ActionGroup.\n    \"\"\"\n    # We currently recompute everything and send all calc actions back on every change. If clients\n    # only need a subset of data loaded, it would be better to filter calc actions, and\n    # include only those the clients care about. For side-effects, we might want to recompute\n    # everything, and only filter what we send.\n\n    self.out_actions = action_obj.ActionGroup()\n    self._user = User(user, self.tables) if user else None\n\n    # These should usually be empty, but may be populated by the RespondToRequests action.\n    self._request_responses = {}\n    self._cached_request_keys = set()\n\n    checkpoint = self._get_undo_checkpoint()\n    try:\n      for user_action in user_actions:\n        self._schema_updated = False\n\n        # At the start of each useraction, clear exemptions. These are used to avoid recalcs of\n        # trigger-formula columns for which the same useractions sets an explicit value.\n        self._prevent_recompute_map.clear()\n\n        self.out_actions.retValues.append(self._apply_one_user_action(user_action))\n\n        # If the UserAction touched the schema, check that it is now consistent with metadata.\n        if self._schema_updated:\n          self.assert_schema_consistent()\n\n    except Exception as e:\n      # Save full exception info, so that we can rethrow accurately even if undo also fails.\n      exc_info = sys.exc_info()\n      # If we get an exception, we should revert all changes applied so far, to keep things\n      # consistent internally as well as with the clients and database outside of the sandbox\n      # (which won't see any changes in case of an error).\n      log.info(\"Failed to apply useractions; reverting: %r\", e)\n      self._undo_to_checkpoint(checkpoint)\n\n      # Check schema consistency again. If this fails, something is really wrong (we tried to go\n      # back to a good state but failed). We'll just report it loudly.\n      try:\n        if self._schema_updated:\n          self.assert_schema_consistent()\n      except Exception:\n        log.error(\"Inconsistent schema after revert on failure: %s\", traceback.format_exc())\n      raise\n\n    # If needed, rebuild dependencies for trigger formulas.\n    self._maybe_update_trigger_dependencies()\n\n    # Note that recalculations and auto-removals get included after processing all useractions.\n    self._bring_all_up_to_date()\n\n    # Apply any triggered record removals. If anything does get removed, recalculate what's needed.\n    while self.docmodel.apply_auto_removes():\n      self._bring_all_up_to_date()\n\n    self.out_actions.flush_calc_changes()\n    self.out_actions.check_sanity()\n    self._user = None\n    self._request_responses = {}\n    self._cached_request_keys = set()\n    return self.out_actions\n\n  def acl_split(self, action_group):\n    \"\"\"\n    Splits ActionGroups, as returned e.g. from apply_user_actions, by permissions. Returns a\n    single ActionBundle containing of all of the original action_groups.\n    \"\"\"\n    # pylint:disable=no-self-use\n    return acl.acl_read_split(action_group)\n\n  def _apply_one_user_action(self, user_action):\n    \"\"\"\n    Applies a single user action to the document, without running any triggered updates.\n    A UserAction is a tuple whose first element is the name of the action.\n    \"\"\"\n    log.debug(\"applying user_action %s\", user_action)\n    return getattr(self.user_actions, user_action.__class__.__name__)(*user_action)\n\n  def apply_doc_action(self, doc_action):\n    \"\"\"\n    Applies a doc action, which is a step of a user action. It is represented by an Action object\n    as defined in actions.py.\n    \"\"\"\n    self._gone_columns = []\n\n    action_name = doc_action.__class__.__name__\n    saved_schema = None\n    if action_name in actions.schema_actions:\n      self._schema_updated = True\n      # Make a copy of the schema. If a bug causes a docaction to fail after modifying schema, we\n      # restore it, or we'll end up with mismatching schema and metadata.\n      saved_schema = schema.clone_schema(self.schema)\n\n    try:\n      getattr(self.doc_actions, action_name)(*doc_action)\n    except Exception:\n      # Save full exception info, so that we can rethrow accurately even if this clause also fails.\n      exc_info = sys.exc_info()\n      if saved_schema:\n        log.info(\"Restoring schema and usercode on exception\")\n        self.schema = saved_schema\n        self._should_rebuild_usercode = True\n        try:\n          self.rebuild_usercode()\n        except Exception:\n          log.error(\"Error rebuilding usercode after restoring schema: %s\", traceback.format_exc())\n      raise\n\n    # If any columns got deleted, destroy them to clear _back_references in other tables, and to\n    # force errors if anything still uses them. Also clear them from calc actions if needed.\n    for col in self._gone_columns:\n      # Calc actions may already be generated if the column deletion was triggered by auto-removal.\n      actions.prune_actions(self.out_actions.calc, col.table_id, col.col_id)\n      col.destroy()\n\n    # We normally recompute formulas before returning to the user; but some formulas are also used\n    # internally in-between applying doc actions. We have this workaround to ensure that those are\n    # up-to-date after each doc action. See more in comments for _bring_mlookups_up_to_date.\n    # We check _in_update_loop to avoid a recursive call (happens when a formula produces an\n    # action, as for derived/summary tables).\n    if not self._in_update_loop:\n      self._bring_mlookups_up_to_date(doc_action)\n\n  def autocomplete(self, txt, table_id, column_id, row_id, user):\n    \"\"\"\n    Return a list of suggested completions of the python fragment supplied.\n    \"\"\"\n    table = self.tables[table_id]\n\n    # Table.lookup methods are special to suggest arguments after '('\n    match = re.match(r\"(\\w+)\\.(lookupRecords|lookupOne)\\($\", txt)\n    if match:\n      # Get the 'Table1' in 'Table1.lookupRecords('\n      lookup_table_id = match.group(1)\n      if lookup_table_id in self.tables:\n        lookup_table = self.tables[lookup_table_id]\n        # Add a keyword argument with no value for each column name in the lookup table.\n        result = [\n          txt + col_id + \"=\"\n          for col_id in lookup_table.all_columns\n          if column.is_visible_column(col_id) or col_id == 'id'\n        ]\n        # Add specific complete lookups involving reference columns.\n        result += [\n          txt + option\n          for option in lookup_autocomplete_options(lookup_table, table, reverse_only=False)\n        ]\n        # Add a dummy empty example value for each result to produce the correct shape.\n        result = [(r, None) for r in result]\n        return sorted(result)\n\n    # replace $ with rec. and add a dummy rec object\n    tweaked_txt = DOLLAR_REGEX.sub(r'rec.', txt)\n    # convert a bare $ with nothing after it also\n    if txt == '$':\n      tweaked_txt = 'rec.'\n\n    autocomplete_context = self.autocomplete_context\n    context = autocomplete_context.get_context()\n    context['rec'] = table.sample_record\n\n    # Remove values from the context that need to be recomputed.\n    context.pop('value', None)\n    context.pop('user', None)\n\n    col = table.get_column(column_id) if table.has_column(column_id) else None\n    if col and not col.is_formula():\n      # Add trigger formula completions.\n      context['value'] = col.sample_value()\n      context['user'] = User(user, self.tables, is_sample=True)\n\n    completer = rlcompleter.Completer(context)\n    results = []\n    at = 0\n    while True:\n      # Get a possible completion.  Result will be None or \"<tweaked_txt><extra suggestion>\"\n      result = completer.complete(tweaked_txt, at)\n      at += 1\n      if not result:\n        break\n      if skipped_completions.search(result):\n        continue\n      result = autocomplete_context.process_result(result)\n      results.append(result)\n      funcname = result[0]\n      # Suggest reverse reference lookups, specifically only for .lookupRecords(),\n      # not for .lookupOne().\n      if isinstance(result, tuple) and funcname.endswith(\".lookupRecords\"):\n        lookup_table_id = funcname.split(\".\")[0]\n        if lookup_table_id in self.tables:\n          lookup_table = self.tables[lookup_table_id]\n          results += [\n            funcname + \"(\" + option\n            for option in lookup_autocomplete_options(lookup_table, table, reverse_only=True)\n          ]\n\n    ### Add example values to all results where possible.\n    if row_id == \"new\":\n      row_id = table.row_ids.max()\n    rec = table.Record(row_id)\n    # Don't use the same user object as above because we don't want is_sample=True,\n    # which is only needed for the sake of suggesting completions.\n    # Here we want to show actual values.\n    user_obj = User(user, self.tables)\n    results = [\n      (result, eval_suggestion(result, rec, user_obj))\n      for result in results\n    ]\n\n    # If we changed the prefix (expanding the $ symbol) we now need to change it back.\n    if tweaked_txt != txt:\n      results = [(txt + result[len(tweaked_txt):], value) for result, value in results]\n    # pylint:disable=unidiomatic-typecheck\n    results.sort(key=lambda r: r[0][0] if type(r[0]) == tuple else r[0])\n    return results\n\n  def _get_undo_checkpoint(self):\n    \"\"\"\n    You may call _get_undo_checkpoint() and pass its result into _undo_to_checkpoint() to undo\n    DocActions saved since the first call; but only while in a single apply_user_actions() call.\n    \"\"\"\n    # We produce a tuple of lengths: one for each of the properties of out_actions ActionObj.\n    aobj = self.out_actions\n    return (len(aobj.calc), len(aobj.stored), len(aobj.undo), len(aobj.retValues))\n\n  def _undo_to_checkpoint(self, checkpoint):\n    \"\"\"\n    See _get_undo_checkpoint() above.\n    \"\"\"\n    # Check if out_actions ActionObj grew at all since _get_undo_checkpoint(). If yes, revert by\n    # applying any undo actions, and trim it back to original state (if we don't trim it, it will\n    # only grow further, with undo actions themselves getting applied as new doc actions).\n    new_checkpoint = self._get_undo_checkpoint()\n    if new_checkpoint != checkpoint:\n      (len_calc, len_stored, len_undo, len_ret) = checkpoint\n      undo_actions = self.out_actions.undo[len_undo:]\n      log.info(\"Reverting %d doc actions\", len(undo_actions))\n      self.user_actions.ApplyUndoActions([actions.get_action_repr(a) for a in undo_actions])\n      del self.out_actions.calc[len_calc:]\n      del self.out_actions.stored[len_stored:]\n      del self.out_actions.direct[len_stored:]\n      del self.out_actions.undo[len_undo:]\n      del self.out_actions.retValues[len_ret:]\n\n\n# end\n"
  },
  {
    "path": "sandbox/grist/fake_std_streams.py",
    "content": "import os\nimport sys\nimport io\n\n\nclass FakeStdStreams(object):\n  \"\"\"\n  Redirects stdout and stderr to StringIO.\n  \"\"\"\n  def __enter__(self):\n    self._orig_stdout = sys.stdout\n    self._orig_stderr = sys.stderr\n    sys.stdout = io.StringIO()\n    sys.stderr = io.StringIO()\n\n  def __exit__(self, exc_type, exc_val, exc_tb):\n    sys.stdout = self._orig_stdout\n    sys.stderr = self._orig_stderr\n\n\nif os.environ.get('VERBOSE'):\n  # Don't disable stdio streams if VERBOSE is on. This is helpful when debugging tests with\n  # logging messages or print() calls.\n  class DummyFakeStdStreams(object):\n    def __enter__(self):\n      pass\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n      pass\n  FakeStdStreams = DummyFakeStdStreams\n"
  },
  {
    "path": "sandbox/grist/formula_prompt.py",
    "content": "import ast\nimport json\nimport re\nimport textwrap\n\nimport asttokens\nimport asttokens.util\n\nimport attribute_recorder\nimport objtypes\nfrom codebuilder import make_formula_body\nfrom column import is_visible_column, BaseReferenceColumn\nfrom objtypes import RaisedException\nimport records\n\n\ndef column_type(engine, table_id, col_id):\n  col_rec = engine.docmodel.get_column_rec(table_id, col_id)\n  typ = col_rec.type\n  parts = typ.split(\":\")\n  if parts[0] == \"Ref\":\n    return parts[1]\n  elif parts[0] == \"RefList\":\n    return \"list[{}]\".format(parts[1])\n  elif typ == \"Choice\":\n    return choices(col_rec)\n  elif typ == \"ChoiceList\":\n    return \"tuple[{}, ...]\".format(choices(col_rec))\n  elif typ == \"Any\":\n    table = engine.tables[table_id]\n    col = table.get_column(col_id)\n    values = [col.raw_get(row_id) for row_id in table.row_ids]\n    return values_type(values)\n  else:\n    return dict(\n      Text=\"str\",\n      Numeric=\"float\",\n      Int=\"int\",\n      Bool=\"bool\",\n      Date=\"datetime.date\",\n      DateTime=\"datetime.datetime\",\n      Any=\"Any\",\n      Attachments=\"Any\",\n    )[parts[0]]\n\ndef choices(col_rec):\n  try:\n    widget_options = json.loads(col_rec.widgetOptions)\n    return \"Literal{}\".format(widget_options[\"choices\"])\n  except (ValueError, KeyError):\n    return 'str'\n\n\ndef values_type(values):\n  types = set(type(v) for v in values) - {RaisedException}\n  optional = type(None) in types # pylint: disable=unidiomatic-typecheck\n  types.discard(type(None))\n\n  if types == {int, float}:\n    types = {float}\n\n  if len(types) != 1:\n    return \"Any\"\n\n  [typ] = types\n  val = next(v for v in values if isinstance(v, typ))\n\n  if isinstance(val, records.Record):\n    type_name = val._table.table_id\n  elif isinstance(val, records.RecordSet):\n    type_name = \"list[{}]\".format(val._table.table_id)\n  elif isinstance(val, list):\n    type_name = \"list[{}]\".format(values_type(val))\n  elif isinstance(val, set):\n    type_name = \"set[{}]\".format(values_type(val))\n  elif isinstance(val, tuple):\n    type_name = \"tuple[{}, ...]\".format(values_type(val))\n  elif isinstance(val, dict):\n    type_name = \"dict[{}, {}]\".format(values_type(val.keys()), values_type(val.values()))\n  else:\n    type_name = typ.__name__\n\n  if optional:\n    type_name = \"Optional[{}]\".format(type_name)\n\n  return type_name\n\n\ndef referenced_tables(engine, table_id):\n  result = set()\n  queue = [table_id]\n  while queue:\n    cur_table_id = queue.pop()\n    if cur_table_id in result:\n      continue\n    result.add(cur_table_id)\n    for col_id, col in visible_columns(engine, cur_table_id):\n      if isinstance(col, BaseReferenceColumn):\n        target_table_id = col._target_table.table_id\n        if not target_table_id.startswith(\"_\"):\n          queue.append(target_table_id)\n  return result - {table_id}\n\ndef all_other_tables(engine, table_id):\n  result = set(t for t in engine.tables.keys() if not t.startswith('_grist'))\n  return result - {table_id} - {'GristDocTour'}\n\ndef visible_columns(engine, table_id):\n  return [\n    (col_id, col)\n    for col_id, col in engine.tables[table_id].all_columns.items()\n    if is_visible_column(col_id)\n  ]\n\n\ndef class_schema(engine, table_id, exclude_col_id=None, lookups=False):\n  result = \"class {}:\\n\".format(table_id)\n\n  if lookups:\n\n    # Build a lookupRecords and lookupOne method for each table, providing some arguments hints\n    # for the columns that are visible.\n    lookupRecords_args = []\n    lookupOne_args = []\n    for col_id, col in visible_columns(engine, table_id):\n      if col_id != exclude_col_id:\n        lookupOne_args.append(col_id + '=None')\n        lookupRecords_args.append('%s=%s' % (col_id, col_id))\n    lookupOne_args.append('sort_by=None')\n    lookupRecords_args.append('sort_by=sort_by')\n    lookupOne_args_line = ', '.join(lookupOne_args)\n    lookupRecords_args_line = ', '.join(lookupRecords_args)\n\n    result += \"    def __len__(self):\\n\"\n    result += \"        return len(%s.lookupRecords())\\n\" % table_id\n    result += \"    @staticmethod\\n\"\n    result += \"    def lookupRecords(%s) -> list[%s]:\\n\" % (lookupOne_args_line, table_id)\n    result += \"       ...\\n\"\n    result += \"    @staticmethod\\n\"\n    result += \"    def lookupOne(%s) -> %s:\\n\" % (lookupOne_args_line, table_id)\n    result += \"       '''\\n\"\n    result += \"       Filter for one result matching the keys provided.\\n\"\n    result += \"       To control order, use e.g. `sort_by='Key' or `sort_by='-Key'`.\\n\"\n    result += \"       '''\\n\"\n    result += \"       return %s.lookupRecords(%s)[0]\\n\" % (table_id, lookupRecords_args_line)\n    result += \"\\n\"\n\n  for col_id, col in visible_columns(engine, table_id):\n    if col_id != exclude_col_id:\n      result += \"    {}: {}\\n\".format(col_id, column_type(engine, table_id, col_id))\n  result += \"\\n\"\n  return result\n\n\ndef get_formula_prompt(engine, table_id, col_id, include_all_tables=True, lookups=True):\n  result = \"\"\n  other_tables = (all_other_tables(engine, table_id)\n    if include_all_tables else referenced_tables(engine, table_id))\n  for other_table_id in sorted(other_tables):\n    result += class_schema(engine, other_table_id, None, lookups)\n\n  result += class_schema(engine, table_id, col_id, lookups)\n\n  return_type = column_type(engine, table_id, col_id)\n  result += \"def {}(rec: {}) -> {}:\\n\".format(col_id, table_id, return_type)\n  return result\n\ndef indent(text, prefix, predicate=None):\n  return textwrap.indent(text, prefix, predicate) # pylint: disable = no-member\n\n\ndef convert_completion(completion):\n  # Extract code from a markdown code block if needed.\n  match = re.search(r\"```\\w*\\n(.*)```\", completion, re.DOTALL)\n  if match:\n    completion = match.group(1)\n\n  result = textwrap.dedent(completion)\n  atok = asttokens.ASTText(result)\n\n  try:\n    # Constructing ASTText doesn't parse the code, but the .tree property does.\n    stmts = atok.tree.body\n  except SyntaxError:\n    # If we don't have valid Python code, don't suggest a formula at all\n    return \"\"\n\n  # If the code starts with imports, save them for later.\n  # In particular, the model may return something like:\n  #  from datetime import date\n  #  def my_column():\n  #     ...\n  # We want to return just the function body, but we need to keep the import,\n  # i.e. move it 'inside the function'.\n  imports = \"\"\n  while stmts and isinstance(stmts[0], (ast.Import, ast.ImportFrom)):\n    imports += atok.get_text(stmts.pop(0)) + \"\\n\"\n\n  # Sometimes the model repeats the provided classes, remove them.\n  stmts = [stmt for stmt in stmts if not isinstance(stmt, ast.ClassDef)]\n\n  # If the remaining code consists only of a function definition, extract the body.\n  if len(stmts) == 1 and isinstance(stmts[0], ast.FunctionDef):\n    func_body_stmts = stmts[0].body\n    if (\n      len(func_body_stmts) > 1 and\n      isinstance(func_body_stmts[0], ast.Expr) and\n      isinstance(func_body_stmts[0].value, ast.Str)\n    ):\n      # Skip the docstring.\n      first_stmt = func_body_stmts[1]\n    else:\n      first_stmt = func_body_stmts[0]\n    result_lines = result.splitlines()[first_stmt.lineno - 1:]\n    result = \"\\n\".join(result_lines)\n    result = textwrap.dedent(result)\n\n    if imports:\n      result = imports + \"\\n\" + result\n\n  # Now convert `rec.` to `$` and remove redundant `return ` at the end.\n  atok = asttokens.ASTText(result)\n  try:\n    # Constructing ASTText doesn't parse the code, but the .tree property does.\n    tree = atok.tree\n  except SyntaxError:\n    # In case the above extraction somehow messed things up\n    return \"\"\n\n  replacements = []\n  for node in ast.walk(tree):\n    if isinstance(node, ast.Attribute):\n      start, end = atok.get_text_range(node.value)\n      end += 1\n      if result[start:end] == \"rec.\":\n        replacements.append((start, end, \"$\"))\n\n  last_stmt = tree.body[-1]\n  if isinstance(last_stmt, ast.Return):\n    start, _ = atok.get_text_range(last_stmt)\n    expected = \"return \"\n    end = start + len(expected)\n    if result[start:end] == expected:\n      replacements.append((start, end, \"\"))\n\n  result = asttokens.util.replace(result, replacements)\n\n  return result.strip()\n\n\ndef evaluate_formula(engine, table_id, col_id, row_id):\n  grist_formula = engine.docmodel.get_column_rec(table_id, col_id).formula\n  assert grist_formula\n  plain_formula = make_formula_body(grist_formula, default_value=None).get_text()\n\n  attributes = {}\n  result = engine.get_formula_value(table_id, col_id, row_id, record_attributes=attributes)\n  if isinstance(result, objtypes.RaisedException):\n    name, message = result.encode_args()[:2]\n    result = \"%s: %s\" % (name, message)\n    error = True\n  else:\n    result = attribute_recorder.safe_repr(result)\n    error = False\n  return dict(\n    error=error,\n    formula=plain_formula,\n    result=result,\n    attributes=attributes,\n  )\n"
  },
  {
    "path": "sandbox/grist/friendly_errors.py",
    "content": "def friendly_message(exc):\n  \"\"\"\n  Returns a string to append to a standard error message.\n  If possible, the string contains a friendly explanation of the error.\n  Otherwise, the string is empty.\n  \"\"\"\n  try:\n    if \"has no column\" in str(exc):\n      # Avoid the standard AttributeError explanation\n      return \"\"\n\n    # Imported locally because it's Python 3 only\n    from friendly_traceback.core import FriendlyTraceback\n\n    fr = FriendlyTraceback(type(exc), exc, exc.__traceback__)\n    fr.assign_generic()\n    fr.assign_cause()\n\n    generic = fr.info[\"generic\"]  # broad explanation for the exception class\n    cause = fr.info.get(\"cause\")  # more specific explanation\n\n    if \"https://github.com\" in generic:\n      # This is a placeholder message when there is no explanation,\n      # with a suggestion to report the case on GitHub.\n      return \"\"\n\n    if \"All built-in exceptions defined by Python are derived from `Exception`\" in generic:\n      # Unhelpful explanation for a generic `Exception`\n      return \"\"\n\n    # Add a blank line between the standard message and the friendly message\n    result = \"\\n\\n\" + generic\n\n    # Check for the placeholder message again in the cause\n    if cause and \"https://github.com\" not in cause:\n      result += \"\\n\" + cause\n\n    result = result.rstrip()\n    if isinstance(exc, SyntaxError):\n      result += \"\\n\\n\"\n\n    return result\n  except (Exception, SystemExit):\n    # This can go wrong in many ways, it's not worth propagating the error.\n    # friendly-traceback raises SystemExit when it encounters an internal error.\n    # Note that SystemExit is not a subclass of Exception.\n    return \"\"\n"
  },
  {
    "path": "sandbox/grist/functions/__init__.py",
    "content": "# pylint: disable=wildcard-import, unused-argument\nfrom .date import *\nfrom .info import *\nfrom .logical import *\nfrom .lookup import *\nfrom .math import *\nfrom .stats import *\nfrom .text import *\nfrom .schedule import *\nfrom .prevnext import *   # pylint: disable=import-error\n\n# Export all uppercase names, for use with `from functions import *`.\n__all__ = [k for k in dir() if not k.startswith('_') and k.isupper()]\n"
  },
  {
    "path": "sandbox/grist/functions/date.py",
    "content": "# -*- coding: utf-8 -*-\nimport calendar\nimport datetime\nimport dateutil.parser\n\nimport moment\nimport docmodel\n\n# pylint: disable=no-member\n\n_excel_date_zero = datetime.datetime(1899, 12, 30)\n\n\ndef _make_datetime(value):\n  if isinstance(value, datetime.datetime):\n    return value\n  elif isinstance(value, datetime.date):\n    return datetime.datetime.combine(value, datetime.time())\n  elif isinstance(value, datetime.time):\n    return datetime.datetime.combine(datetime.date.today(), value)\n  elif isinstance(value, str):\n    return dateutil.parser.parse(value)\n  else:\n    raise ValueError('Invalid date %r' % (value,))\n\ndef _make_date(value):\n  # datetime.datetime is a subclass of datetime.date so it has to be checked first\n  if isinstance(value, datetime.datetime):\n    return value.date()\n  elif isinstance(value, datetime.date):\n    return value\n  elif isinstance(value, str):\n    # Note: if the string is just a time, the result will be the current day\n    return dateutil.parser.parse(value).date()\n  else:\n    # Note: unlike _make_datetime, this does not accept a datetime.time\n    raise ValueError('Invalid date %r' % (value,))\n\ndef _get_global_tz():\n  # If doc_info record is missing (e.g. in tests), default to UTC. We should not return None,\n  # since that would produce naive datetime objects, which is not what we want.\n  dm = docmodel.global_docmodel\n  return (dm.doc_info.lookupOne(id=1).tzinfo if dm else None) or moment.TZ_UTC\n\ndef _get_tzinfo(zonelabel):\n  \"\"\"\n  A helper that returns a `datetime.tzinfo` instance for zonelabel. Returns the global\n  document timezone if zonelabel is None.\n  \"\"\"\n  return moment.tzinfo(zonelabel) if zonelabel else _get_global_tz()\n\ndef DTIME(value, tz=None):\n  \"\"\"\n  Returns the value converted to a python `datetime` object. The value may be a\n  `string`, `date` (interpreted as midnight on that day), `time` (interpreted as a\n  time-of-day today), or an existing `datetime`.\n\n  The returned `datetime` will have its timezone set to the `tz` argument, or the\n  document's default timezone when `tz` is omitted or None. If the input is itself a\n  `datetime` with the timezone set, it is returned unchanged (no changes to its timezone).\n\n  >>> DTIME(datetime.date(2017, 1, 1))\n  datetime.datetime(2017, 1, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> DTIME(datetime.date(2017, 1, 1), 'Europe/Paris')\n  datetime.datetime(2017, 1, 1, 0, 0, tzinfo=moment.tzinfo('Europe/Paris'))\n  >>> DTIME(datetime.datetime(2017, 1, 1))\n  datetime.datetime(2017, 1, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> DTIME(datetime.datetime(2017, 1, 1, tzinfo=moment.tzinfo('UTC')))\n  datetime.datetime(2017, 1, 1, 0, 0, tzinfo=moment.tzinfo('UTC'))\n  >>> DTIME(datetime.datetime(2017, 1, 1, tzinfo=moment.tzinfo('UTC')), 'Europe/Paris')\n  datetime.datetime(2017, 1, 1, 0, 0, tzinfo=moment.tzinfo('UTC'))\n  >>> DTIME(\"1/1/2008\")\n  datetime.datetime(2008, 1, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  \"\"\"\n  value = _make_datetime(value)\n  return value if value.tzinfo else value.replace(tzinfo=_get_tzinfo(tz))\n\n\ndef XL_TO_DATE(value, tz=None):\n  \"\"\"\n  Converts a provided Excel serial number representing a date into a `datetime` object.\n  Value is interpreted as the number of days since December 30, 1899.\n\n  (This corresponds to Google Sheets interpretation. Excel starts with Dec. 31, 1899 but wrongly\n  considers 1900 to be a leap year. Excel for Mac should be configured to use 1900 date system,\n  i.e. uncheck \"Use the 1904 date system\" option.)\n\n  The returned `datetime` will have its timezone set to the `tz` argument, or the\n  document's default timezone when `tz` is omitted or None.\n\n  >>> XL_TO_DATE(41100.1875)\n  datetime.datetime(2012, 7, 10, 4, 30, tzinfo=moment.tzinfo('America/New_York'))\n  >>> XL_TO_DATE(39448)\n  datetime.datetime(2008, 1, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> XL_TO_DATE(40982.0625)\n  datetime.datetime(2012, 3, 14, 1, 30, tzinfo=moment.tzinfo('America/New_York'))\n\n  More tests:\n  >>> XL_TO_DATE(0)\n  datetime.datetime(1899, 12, 30, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> XL_TO_DATE(-1)\n  datetime.datetime(1899, 12, 29, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> XL_TO_DATE(1)\n  datetime.datetime(1899, 12, 31, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> XL_TO_DATE(1.5)\n  datetime.datetime(1899, 12, 31, 12, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> XL_TO_DATE(61.0)\n  datetime.datetime(1900, 3, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  \"\"\"\n  return DTIME(_excel_date_zero, tz) + datetime.timedelta(days=value)\n\n\ndef DATE_TO_XL(date_value):\n  \"\"\"\n  Converts a Python `date` or `datetime` object to the serial number as used by\n  Excel, with December 30, 1899 as serial number 1.\n\n  See XL_TO_DATE for more explanation.\n\n  >>> DATE_TO_XL(datetime.date(2008, 1, 1))\n  39448.0\n  >>> DATE_TO_XL(datetime.date(2012, 3, 14))\n  40982.0\n  >>> DATE_TO_XL(datetime.datetime(2012, 3, 14, 1, 30))\n  40982.0625\n\n  More tests:\n  >>> DATE_TO_XL(datetime.date(1900, 1, 1))\n  2.0\n  >>> DATE_TO_XL(datetime.datetime(1900, 1, 1))\n  2.0\n  >>> DATE_TO_XL(datetime.datetime(1900, 1, 1, 12, 0))\n  2.5\n  >>> DATE_TO_XL(datetime.datetime(1900, 1, 1, 12, 0, tzinfo=moment.tzinfo('America/New_York')))\n  2.5\n  >>> DATE_TO_XL(datetime.date(1900, 3, 1))\n  61.0\n  >>> DATE_TO_XL(datetime.datetime(2008, 1, 1))\n  39448.0\n  >>> DATE_TO_XL(XL_TO_DATE(39488))\n  39488.0\n  >>> dt_ny = XL_TO_DATE(39488)\n  >>> dt_paris = moment.tz(dt_ny, 'America/New_York').tz('Europe/Paris').datetime()\n  >>> DATE_TO_XL(dt_paris)\n  39488.0\n  \"\"\"\n  # If date_value is `naive` it's ok to pass tz to both DTIME as it won't affect the\n  # result.\n  return (DTIME(date_value) - DTIME(_excel_date_zero)).total_seconds() / 86400.\n\n\ndef DATE(year, month, day):\n  \"\"\"\n  Returns the `datetime.datetime` object that represents a particular date.\n  The DATE function is most useful in formulas where year, month, and day are formulas, not\n  constants.\n\n  If year is between 0 and 1899 (inclusive), adds 1900 to calculate the year.\n  >>> DATE(108, 1, 2)\n  datetime.date(2008, 1, 2)\n  >>> DATE(2008, 1, 2)\n  datetime.date(2008, 1, 2)\n\n  If month is greater than 12, rolls into the following year.\n  >>> DATE(2008, 14, 2)\n  datetime.date(2009, 2, 2)\n\n  If month is less than 1, subtracts that many months plus 1, from the first month in the year.\n  >>> DATE(2008, -3, 2)\n  datetime.date(2007, 9, 2)\n\n  If day is greater than the number of days in the given month, rolls into the following months.\n  >>> DATE(2008, 1, 35)\n  datetime.date(2008, 2, 4)\n\n  If day is less than 1, subtracts that many days plus 1, from the first day of the given month.\n  >>> DATE(2008, 1, -15)\n  datetime.date(2007, 12, 16)\n\n  More tests:\n  >>> DATE(1900, 1, 1)\n  datetime.date(1900, 1, 1)\n  >>> DATE(1900, 0, 0)\n  datetime.date(1899, 11, 30)\n  \"\"\"\n  if year < 1900:\n    year += 1900\n  norm_month = (month - 1) % 12 + 1\n  norm_year = year + (month - 1) // 12\n  return datetime.date(norm_year, norm_month, 1) + datetime.timedelta(days=day - 1)\n\n\ndef DATEDIF(start_date, end_date, unit):\n  \"\"\"\n  Calculates the number of days, months, or years between two dates.\n  Unit indicates the type of information that you want returned:\n\n    - \"Y\": The number of complete years in the period.\n    - \"M\": The number of complete months in the period.\n    - \"D\": The number of days in the period.\n    - \"MD\": The difference between the days in start_date and end_date. The months and years of the\n      dates are ignored.\n    - \"YM\": The difference between the months in start_date and end_date. The days and years of the\n      dates are ignored.\n    - \"YD\": The difference between the days of start_date and end_date. The years of the dates are\n      ignored.\n\n  Two complete years in the period (2)\n  >>> DATEDIF(DATE(2001, 1, 1), DATE(2003, 1, 1), \"Y\")\n  2\n\n  440 days between June 1, 2001, and August 15, 2002 (440)\n  >>> DATEDIF(DATE(2001, 6, 1), DATE(2002, 8, 15), \"D\")\n  440\n\n  75 days between June 1 and August 15, ignoring the years of the dates (75)\n  >>> DATEDIF(DATE(2001, 6, 1), DATE(2012, 8, 15), \"YD\")\n  75\n\n  The difference between 1 and 15, ignoring the months and the years of the dates (14)\n  >>> DATEDIF(DATE(2001, 6, 1), DATE(2002, 8, 15), \"MD\")\n  14\n\n  More tests:\n  >>> DATEDIF(DATE(1969, 7, 16), DATE(1969, 7, 24), \"D\")\n  8\n  >>> DATEDIF(DATE(2014, 1, 1), DATE(2015, 1, 1), \"M\")\n  12\n  >>> DATEDIF(DATE(2014, 1, 2), DATE(2015, 1, 1), \"M\")\n  11\n  >>> DATEDIF(DATE(2014, 1, 1), DATE(2024, 1, 1), \"Y\")\n  10\n  >>> DATEDIF(DATE(2014, 1, 2), DATE(2024, 1, 1), \"Y\")\n  9\n  >>> DATEDIF(DATE(1906, 10, 16), DATE(2004, 2, 3), \"YM\")\n  3\n  >>> DATEDIF(DATE(2016, 2, 14), DATE(2016, 3, 14), \"YM\")\n  1\n  >>> DATEDIF(DATE(2016, 2, 14), DATE(2016, 3, 13), \"YM\")\n  0\n  >>> DATEDIF(DATE(2008, 10, 16), DATE(2019, 12, 3), \"MD\")\n  17\n  >>> DATEDIF(DATE(2008, 11, 16), DATE(2019, 1, 3), \"MD\")\n  18\n  >>> DATEDIF(DATE(2016, 2, 29), DATE(2017, 2, 28), \"Y\")\n  0\n  >>> DATEDIF(DATE(2016, 2, 29), DATE(2017, 2, 29), \"Y\")\n  1\n  >>> DATEDIF(\"2001/1/1\", \"2003/1/1\", \"Y\")\n  2\n\n  Technically, the number of days between these two datetimes is 0:\n  >>> first_date, second_date = \"2020-01-01 13:00 UTC+12:00\", \"2020-01-02 13:00 UTC-12:00\"\n  >>> (dateutil.parser.parse(first_date) - dateutil.parser.parse(second_date)).days\n  0\n\n  But DATEDIF only looks at the date part, so it returns 1.\n  >>> DATEDIF(first_date, second_date, \"D\")\n  1\n\n  Compatibility Note: Google Sheets doesn't have timezone support, but it also\n  only looks at the date part of datetime values. So in Sheets:\n  `DATEDIF(1.9, 2.1, \"D\") == 1` and `DATEDIF(2.1, 2.3, \"D\") == 0`\n  \"\"\"\n  start_date = _make_date(start_date)\n  end_date = _make_date(end_date)\n  if unit == 'D':\n    return (end_date - start_date).days\n  elif unit == 'M':\n    months = (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month)\n    month_delta = 0 if start_date.day <= end_date.day else 1\n    return months - month_delta\n  elif unit == 'Y':\n    years = end_date.year - start_date.year\n    year_delta = 0 if (start_date.month, start_date.day) <= (end_date.month, end_date.day) else 1\n    return years - year_delta\n  elif unit == 'MD':\n    month_delta = 0 if start_date.day <= end_date.day else 1\n    return (end_date - DATE(end_date.year, end_date.month - month_delta, start_date.day)).days\n  elif unit == 'YM':\n    month_delta = 0 if start_date.day <= end_date.day else 1\n    return (end_date.month - start_date.month - month_delta) % 12\n  elif unit == 'YD':\n    year_delta = 0 if (start_date.month, start_date.day) <= (end_date.month, end_date.day) else 1\n    return (end_date - DATE(end_date.year - year_delta, start_date.month, start_date.day)).days\n  else:\n    raise ValueError('Invalid unit %s' % (unit,))\n\n\ndef DATEVALUE(date_string, tz=None):\n  \"\"\"\n  Converts a date that is stored as text to a `datetime` object.\n\n  >>> DATEVALUE(\"1/1/2008\")\n  datetime.datetime(2008, 1, 1, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> DATEVALUE(\"30-Jan-2008\")\n  datetime.datetime(2008, 1, 30, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> DATEVALUE(\"2008-12-11\")\n  datetime.datetime(2008, 12, 11, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> DATEVALUE(\"5-JUL\").replace(year=2000)\n  datetime.datetime(2000, 7, 5, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n\n  In case of ambiguity, prefer M/D/Y format.\n  >>> DATEVALUE(\"1/2/3\")\n  datetime.datetime(2003, 1, 2, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n\n  More tests:\n  >>> DATEVALUE(\"8/22/2011\")\n  datetime.datetime(2011, 8, 22, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> DATEVALUE(\"22-MAY-2011\")\n  datetime.datetime(2011, 5, 22, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> DATEVALUE(\"2011/02/23\")\n  datetime.datetime(2011, 2, 23, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> DATEVALUE(\"11/3/2011\")\n  datetime.datetime(2011, 11, 3, 0, 0, tzinfo=moment.tzinfo('America/New_York'))\n  >>> DATE_TO_XL(DATEVALUE(\"11/3/2011\"))\n  40850.0\n  >>> DATEVALUE(\"asdf\")\n  Traceback (most recent call last):\n  ...\n  {}: Unknown string format: asdf\n  \"\"\"\n  return dateutil.parser.parse(date_string).replace(tzinfo=_get_tzinfo(tz))\n\n\nDATEVALUE.__doc__ = DATEVALUE.__doc__.format(\n  \"dateutil.parser._parser.ParserError\"\n)\n\n\ndef DAY(date):\n  \"\"\"\n  Returns the day of a date, as an integer ranging from 1 to 31. Same as `date.day`.\n\n  >>> DAY(DATE(2011, 4, 15))\n  15\n  >>> DAY(\"5/31/2012\")\n  31\n  >>> DAY(datetime.datetime(1900, 1, 1))\n  1\n  \"\"\"\n  return _make_datetime(date).day\n\n\ndef DAYS(end_date, start_date):\n  \"\"\"\n  Returns the number of days between two dates. Same as `(end_date - start_date).days`.\n\n  >>> DAYS(\"3/15/11\",\"2/1/11\")\n  42\n  >>> DAYS(DATE(2011, 12, 31), DATE(2011, 1, 1))\n  364\n  >>> DAYS(\"2/1/11\", \"3/15/11\")\n  -42\n  \"\"\"\n  return (_make_datetime(end_date) - _make_datetime(start_date)).days\n\n\ndef EDATE(start_date, months):\n  \"\"\"\n  Returns the date that is the given number of months before or after `start_date`. Use\n  EDATE to calculate maturity dates or due dates that fall on the same day of the month as the\n  date of issue.\n\n  >>> EDATE(DATE(2011, 1, 15), 1)\n  datetime.date(2011, 2, 15)\n  >>> EDATE(DATE(2011, 1, 15), -1)\n  datetime.date(2010, 12, 15)\n  >>> EDATE(DATE(2011, 1, 15), 2)\n  datetime.date(2011, 3, 15)\n  >>> EDATE(DATE(2012, 3, 1), 10)\n  datetime.date(2013, 1, 1)\n  >>> EDATE(DATE(2012, 5, 1), -2)\n  datetime.date(2012, 3, 1)\n  \"\"\"\n  return DATE(start_date.year, start_date.month + months, start_date.day)\n\n\ndef DATEADD(start_date, days=0, months=0, years=0, weeks=0):\n  \"\"\"\n  Returns the date a given number of days, months, years, or weeks away from `start_date`. You may\n  specify arguments in any order if you specify argument names. Use negative values to subtract.\n\n  For example, `DATEADD(date, 1)` is the same as `DATEADD(date, days=1)`, ands adds one day to\n  `date`. `DATEADD(date, years=1, days=-1)` adds one year minus one day.\n\n  >>> DATEADD(DATE(2011, 1, 15), 1)\n  datetime.date(2011, 1, 16)\n  >>> DATEADD(DATE(2011, 1, 15), months=1, days=-1)\n  datetime.date(2011, 2, 14)\n  >>> DATEADD(DATE(2011, 1, 15), years=-2, months=1, days=3, weeks=2)\n  datetime.date(2009, 3, 4)\n  >>> DATEADD(DATE(1975, 4, 30), years=50, weeks=-5)\n  datetime.date(2025, 3, 26)\n  \"\"\"\n  return DATE(start_date.year + years, start_date.month + months,\n              start_date.day + days + weeks * 7)\n\n\ndef EOMONTH(start_date, months):\n  \"\"\"\n  Returns the date for the last day of the month that is the indicated number of months before or\n  after start_date. Use EOMONTH to calculate maturity dates or due dates that fall on the last day\n  of the month.\n\n  >>> EOMONTH(DATE(2011, 1, 1), 1)\n  datetime.date(2011, 2, 28)\n  >>> EOMONTH(DATE(2011, 1, 15), -3)\n  datetime.date(2010, 10, 31)\n  >>> EOMONTH(DATE(2012, 3, 1), 10)\n  datetime.date(2013, 1, 31)\n  >>> EOMONTH(DATE(2012, 5, 1), -2)\n  datetime.date(2012, 3, 31)\n  \"\"\"\n  return DATE(start_date.year, start_date.month + months + 1, 1) - datetime.timedelta(days=1)\n\n\ndef HOUR(time):\n  \"\"\"\n  Returns the hour of a `datetime`, as an integer from 0 (12:00 A.M.) to 23 (11:00 P.M.).\n  Same as `time.hour`.\n\n  >>> HOUR(XL_TO_DATE(0.75))\n  18\n  >>> HOUR(\"7/18/2011 7:45\")\n  7\n  >>> HOUR(\"4/21/2012\")\n  0\n  \"\"\"\n  return _make_datetime(time).hour\n\n\ndef ISOWEEKNUM(date):\n  \"\"\"\n  Returns the ISO week number of the year for a given date.\n\n  >>> ISOWEEKNUM(\"3/9/2012\")\n  10\n  >>> [ISOWEEKNUM(DATE(2000 + y, 1, 1)) for y in [0,1,2,3,4,5,6,7,8]]\n  [52, 1, 1, 1, 1, 53, 52, 1, 1]\n  \"\"\"\n  return _make_datetime(date).isocalendar()[1]\n\n\ndef MINUTE(time):\n  \"\"\"\n  Returns the minutes of `datetime`, as an integer from 0 to 59.\n  Same as `time.minute`.\n\n  >>> MINUTE(XL_TO_DATE(0.75))\n  0\n  >>> MINUTE(\"7/18/2011 7:45\")\n  45\n  >>> MINUTE(\"12:59:00 PM\")\n  59\n  >>> MINUTE(datetime.time(12, 58, 59))\n  58\n  \"\"\"\n  return _make_datetime(time).minute\n\n\ndef MONTH(date):\n  \"\"\"\n  Returns the month of a date represented, as an integer from from 1 (January) to 12 (December).\n  Same as `date.month`.\n\n  >>> MONTH(DATE(2011, 4, 15))\n  4\n  >>> MONTH(\"5/31/2012\")\n  5\n  >>> MONTH(datetime.datetime(1900, 1, 1))\n  1\n  \"\"\"\n  return _make_datetime(date).month\n\n\ndef NOW(tz=None):\n  \"\"\"\n  Returns the `datetime` object for the current time.\n  \"\"\"\n  engine = docmodel.global_docmodel._engine\n  engine.use_current_time()\n  return datetime.datetime.now(_get_tzinfo(tz))\n\n\ndef SECOND(time):\n  \"\"\"\n  Returns the seconds of `datetime`, as an integer from 0 to 59.\n  Same as `time.second`.\n\n  >>> SECOND(XL_TO_DATE(0.75))\n  0\n  >>> SECOND(\"7/18/2011 7:45:13\")\n  13\n  >>> SECOND(datetime.time(12, 58, 59))\n  59\n  \"\"\"\n\n  return _make_datetime(time).second\n\n\ndef TODAY(tz=None):\n  \"\"\"\n  Returns the `date` object for the current date.\n  \"\"\"\n  return NOW(tz=tz).date()\n\n\n_weekday_type_map = {\n  # type: (first day of week (according to date.weekday()), number to return for it)\n  1: (6, 1),\n  2: (0, 1),\n  3: (0, 0),\n  11: (0, 1),\n  12: (1, 1),\n  13: (2, 1),\n  14: (3, 1),\n  15: (4, 1),\n  16: (5, 1),\n  17: (6, 1),\n}\n\ndef WEEKDAY(date, return_type=1):\n  \"\"\"\n  Returns the day of the week corresponding to a date. The day is given as an integer, ranging\n  from 1 (Sunday) to 7 (Saturday), by default.\n\n  Return_type determines the type of the returned value.\n\n    - 1 (default) - Returns 1 (Sunday) through 7 (Saturday).\n    - 2   - Returns 1 (Monday) through 7 (Sunday).\n    - 3   - Returns 0 (Monday) through 6 (Sunday).\n    - 11  - Returns 1 (Monday) through 7 (Sunday).\n    - 12  - Returns 1 (Tuesday) through 7 (Monday).\n    - 13  - Returns 1 (Wednesday) through 7 (Tuesday).\n    - 14  - Returns 1 (Thursday) through 7 (Wednesday).\n    - 15  - Returns 1 (Friday) through 7 (Thursday).\n    - 16  - Returns 1 (Saturday) through 7 (Friday).\n    - 17  - Returns 1 (Sunday) through 7 (Saturday).\n\n  >>> WEEKDAY(DATE(2008, 2, 14))\n  5\n  >>> WEEKDAY(DATE(2012, 3, 1))\n  5\n  >>> WEEKDAY(DATE(2012, 3, 1), 1)\n  5\n  >>> WEEKDAY(DATE(2012, 3, 1), 2)\n  4\n  >>> WEEKDAY(\"3/1/2012\", 3)\n  3\n\n  More tests:\n  >>> WEEKDAY(XL_TO_DATE(10000), 1)\n  4\n  >>> WEEKDAY(DATE(1901, 1, 1))\n  3\n  >>> WEEKDAY(DATE(1901, 1, 1), 2)\n  2\n  >>> [WEEKDAY(DATE(2008, 2, d)) for d in [10, 11, 12, 13, 14, 15, 16, 17]]\n  [1, 2, 3, 4, 5, 6, 7, 1]\n  >>> [WEEKDAY(DATE(2008, 2, d), 1) for d in [10, 11, 12, 13, 14, 15, 16, 17]]\n  [1, 2, 3, 4, 5, 6, 7, 1]\n  >>> [WEEKDAY(DATE(2008, 2, d), 17) for d in [10, 11, 12, 13, 14, 15, 16, 17]]\n  [1, 2, 3, 4, 5, 6, 7, 1]\n  >>> [WEEKDAY(DATE(2008, 2, d), 2) for d in [10, 11, 12, 13, 14, 15, 16, 17]]\n  [7, 1, 2, 3, 4, 5, 6, 7]\n  >>> [WEEKDAY(DATE(2008, 2, d), 3) for d in [10, 11, 12, 13, 14, 15, 16, 17]]\n  [6, 0, 1, 2, 3, 4, 5, 6]\n  \"\"\"\n  if return_type not in _weekday_type_map:\n    raise ValueError(\"Invalid return type %s\" % (return_type,))\n  (first, index) = _weekday_type_map[return_type]\n  return (_make_datetime(date).weekday() - first) % 7 + index\n\n\ndef WEEKNUM(date, return_type=1):\n  \"\"\"\n  Returns the week number of a specific date. For example, the week containing January 1 is the\n  first week of the year, and is numbered week 1.\n\n  Return_type determines which week is considered the first week of the year.\n\n    - 1 (default) - Week 1 is the first week starting Sunday that contains January 1.\n    - 2   - Week 1 is the first week starting Monday that contains January 1.\n    - 11  - Week 1 is the first week starting Monday that contains January 1.\n    - 12  - Week 1 is the first week starting Tuesday that contains January 1.\n    - 13  - Week 1 is the first week starting Wednesday that contains January 1.\n    - 14  - Week 1 is the first week starting Thursday that contains January 1.\n    - 15  - Week 1 is the first week starting Friday that contains January 1.\n    - 16  - Week 1 is the first week starting Saturday that contains January 1.\n    - 17  - Week 1 is the first week starting Sunday that contains January 1.\n    - 21  - ISO 8601 Approach: Week 1 is the first week starting Monday that contains January 4.\n          Equivalently, it is the week that contains the first Thursday of the year.\n\n  >>> WEEKNUM(DATE(2012, 3, 9))\n  10\n  >>> WEEKNUM(DATE(2012, 3, 9), 2)\n  11\n  >>> WEEKNUM('1/1/1900')\n  1\n  >>> WEEKNUM('2/1/1900')\n  5\n\n  More tests:\n  >>> WEEKNUM('2/1/1909', 2)\n  6\n  >>> WEEKNUM('1/1/1901', 21)\n  1\n  >>> [WEEKNUM(DATE(2012, 3, 9), t) for t in [1,2,11,12,13,14,15,16,17,21]]\n  [10, 11, 11, 11, 11, 11, 11, 10, 10, 10]\n  \"\"\"\n  if return_type == 21:\n    return ISOWEEKNUM(date)\n  if return_type not in _weekday_type_map:\n    raise ValueError(\"Invalid return type %s\" % (return_type,))\n  (first, index) = _weekday_type_map[return_type]\n  date = _make_datetime(date)\n  jan1 = datetime.datetime(date.year, 1, 1)\n  week1_start = jan1 - datetime.timedelta(days=(jan1.weekday() - first) % 7)\n  return (date - week1_start).days // 7 + 1\n\n\ndef YEAR(date):\n  \"\"\"\n  Returns the year corresponding to a date as an integer.\n  Same as `date.year`.\n\n  >>> YEAR(DATE(2011, 4, 15))\n  2011\n  >>> YEAR(\"5/31/2030\")\n  2030\n  >>> YEAR(datetime.datetime(1900, 1, 1))\n  1900\n  \"\"\"\n  return _make_datetime(date).year\n\n\ndef _date_360(y, m, d):\n  return y * 360 + m * 30 + d\n\ndef _last_of_feb(date):\n  return date.month == 2 and (date + datetime.timedelta(days=1)).month == 3\n\ndef YEARFRAC(start_date, end_date, basis=0):\n  \"\"\"\n  Calculates the fraction of the year represented by the number of whole days between two dates.\n\n  Basis is the type of day count basis to use.\n\n    * `0` (default) - US (NASD) 30/360\n    * `1`   - Actual/actual\n    * `2`   - Actual/360\n    * `3`   - Actual/365\n    * `4`   - European 30/360\n    * `-1`  - Actual/actual (Google Sheets variation)\n\n  This function is useful for financial calculations. For compatibility with Excel, it defaults to\n  using the NASD standard calendar. For use in non-financial settings, option `-1` is\n  likely the best choice.\n\n  See <https://en.wikipedia.org/wiki/360-day_calendar> for explanation of\n  the US 30/360 and European 30/360 methods. See <http://www.dwheeler.com/yearfrac/> for analysis of\n  Excel's particular implementation.\n\n  Basis `-1` is similar to `1`, but differs from Excel when dates span both leap and non-leap years.\n  It matches the calculation in Google Sheets, counting the days in each year as a fraction of\n  that year's length.\n\n  Fraction of the year between 1/1/2012 and 7/30/12, omitting the Basis argument.\n  >>> \"%.8f\" % YEARFRAC(DATE(2012, 1, 1), DATE(2012, 7, 30))\n  '0.58055556'\n\n  Fraction between same dates, using the Actual/Actual basis argument. Because 2012 is a Leap\n  year, it has a 366 day basis.\n  >>> \"%.8f\" % YEARFRAC(DATE(2012, 1, 1), DATE(2012, 7, 30), 1)\n  '0.57650273'\n\n  Fraction between same dates, using the Actual/365 basis argument. Uses a 365 day basis.\n  >>> \"%.8f\" % YEARFRAC(DATE(2012, 1, 1), DATE(2012, 7, 30), 3)\n  '0.57808219'\n\n  More tests:\n  >>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30)), 10)\n  0.4972222222\n  >>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30), 0), 10)\n  0.4972222222\n  >>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30), 1), 10)\n  0.4945355191\n  >>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30), 2), 10)\n  0.5027777778\n  >>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30), 3), 10)\n  0.495890411\n  >>> round(YEARFRAC(DATE(2012, 1, 1), DATE(2012, 6, 30), 4), 10)\n  0.4972222222\n  >>> [YEARFRAC(DATE(2012, 1, 1), DATE(2012, 1, 1), t) for t in [0, 1, -1, 2, 3, 4]]\n  [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]\n  >>> [round(YEARFRAC(DATE(1985, 3, 15), DATE(2016, 2, 29), t), 6) for t in [0, 1, -1, 2, 3, 4]]\n  [30.955556, 30.959617, 30.961202, 31.411111, 30.980822, 30.955556]\n  >>> [round(YEARFRAC(DATE(2001, 2, 28), DATE(2016, 3, 31), t), 6) for t in [0, 1, -1, 2, 3, 4]]\n  [15.086111, 15.085558, 15.086998, 15.305556, 15.09589, 15.088889]\n  >>> [round(YEARFRAC(DATE(1968, 4, 7), DATE(2011, 2, 14), t), 6) for t in [0, 1, -1, 2, 3, 4]]\n  [42.852778, 42.855578, 42.855521, 43.480556, 42.884932, 42.852778]\n\n  Here we test \"basis 1\" on leap and non-leap years.\n  >>> [round(YEARFRAC(DATE(2015, 1, 1), DATE(2015, 3, 1), t), 6) for t in [1, -1]]\n  [0.161644, 0.161644]\n  >>> [round(YEARFRAC(DATE(2016, 1, 1), DATE(2016, 3, 1), t), 6) for t in [1, -1]]\n  [0.163934, 0.163934]\n  >>> [round(YEARFRAC(DATE(2015, 1, 1), DATE(2016, 1, 1), t), 6) for t in [1, -1]]\n  [1.0, 1.0]\n  >>> [round(YEARFRAC(DATE(2016, 1, 1), DATE(2017, 1, 1), t), 6) for t in [1, -1]]\n  [1.0, 1.0]\n  >>> [round(YEARFRAC(DATE(2016, 2, 29), DATE(2017, 1, 1), t), 6) for t in [1, -1]]\n  [0.838798, 0.838798]\n  >>> [round(YEARFRAC(DATE(2014, 12, 15), DATE(2015, 3, 15), t), 6) for t in [1, -1]]\n  [0.246575, 0.246575]\n\n  For these examples, Google Sheets differs from Excel, and we match Excel here.\n  >>> [round(YEARFRAC(DATE(2015, 12, 15), DATE(2016, 3, 15), t), 6) for t in [1, -1]]\n  [0.248634, 0.248761]\n  >>> [round(YEARFRAC(DATE(2015, 1, 1), DATE(2016, 2, 29), t), 6) for t in [1, -1]]\n  [1.160055, 1.161202]\n  >>> [round(YEARFRAC(DATE(2015, 1, 1), DATE(2016, 2, 28), t), 6) for t in [1, -1]]\n  [1.157319, 1.15847]\n  >>> [round(YEARFRAC(DATE(2015, 3, 1), DATE(2016, 2, 29), t), 6) for t in [1, -1]]\n  [0.997268, 0.999558]\n  >>> [round(YEARFRAC(DATE(2015, 3, 1), DATE(2016, 2, 28), t), 6) for t in [1, -1]]\n  [0.99726, 0.996826]\n  >>> [round(YEARFRAC(DATE(2016, 3, 1), DATE(2017, 1, 1), t), 6) for t in [1, -1]]\n  [0.838356, 0.836066]\n  >>> [round(YEARFRAC(DATE(2015, 1, 1), DATE(2017, 1, 1), t), 6) for t in [1, -1]]\n  [2.000912, 2.0]\n  \"\"\"\n  # pylint: disable=too-many-return-statements\n  # This function is actually completely crazy. The rules are strange too. We'll follow the logic\n  # in http://www.dwheeler.com/yearfrac/excel-ooxml-yearfrac.pdf\n  if start_date == end_date:\n    return 0.0\n  if start_date > end_date:\n    start_date, end_date = end_date, start_date\n\n  d1, m1, y1 = start_date.day, start_date.month, start_date.year\n  d2, m2, y2 = end_date.day, end_date.month, end_date.year\n\n  if basis == 0:\n    if d1 == 31:\n      d1 = 30\n    if d1 == 30 and d2 == 31:\n      d2 = 30\n    if _last_of_feb(start_date):\n      d1 = 30\n      if _last_of_feb(end_date):\n        d2 = 30\n    return (_date_360(y2, m2, d2) - _date_360(y1, m1, d1)) / 360.0\n\n  elif basis == 1:\n    # This implements Excel's convoluted logic.\n    if (y1 + 1, m1, d1) >= (y2, m2, d2):\n      # Less than or equal to one year.\n      if y1 == y2 and calendar.isleap(y1):\n        year_length = 366.0\n      elif (y1, m1, d1) < (y2, 2, 29) <= (y2, m2, d2) and calendar.isleap(y2):\n        year_length = 366.0\n      elif (y1, m1, d1) <= (y1, 2, 29) < (y2, m2, d2) and calendar.isleap(y1):\n        year_length = 366.0\n      else:\n        year_length = 365.0\n    else:\n      year_length = (datetime.date(y2 + 1, 1, 1) - datetime.date(y1, 1, 1)).days / (y2 + 1.0 - y1)\n    return (end_date - start_date).days / year_length\n\n  elif basis == -1:\n    # This is Google Sheets implementation. Call it an overkill, but I think it's more sensible.\n    #\n    # Excel's logic has the unfortunate property that YEARFRAC(a, b) + YEARFRAC(b, c) is not\n    # always equal to YEARFRAC(a, c). Google Sheets implements a variation that does have this\n    # property, counting the days in each year as a fraction of that year's length (as if each day\n    # is counted as 1/365 or 1/366 depending on the year).\n    #\n    # The one redeeming quality of Excel's logic is that YEARFRAC for two days that differ by\n    # exactly one year is 1.0 (not always true for GS). But in GS version, YEARFRAC between any\n    # two Jan 1 is always a whole number (not always true in Excel).\n    if y1 == y2:\n      return _one_year_frac(start_date, end_date)\n    return (\n      + _one_year_frac(start_date, datetime.date(y1 + 1, 1, 1))\n      + (y2 - y1 - 1)\n      + _one_year_frac(datetime.date(y2, 1, 1), end_date)\n    )\n\n  elif basis == 2:\n    return (end_date - start_date).days / 360.0\n\n  elif basis == 3:\n    return (end_date - start_date).days / 365.0\n\n  elif basis == 4:\n    if d1 == 31:\n      d1 = 30\n    if d2 == 31:\n      d2 = 30\n    return (_date_360(y2, m2, d2) - _date_360(y1, m1, d1)) / 360.0\n\n  raise ValueError('Invalid basis argument %r' % (basis,))\n\ndef _one_year_frac(start_date, end_date):\n  year_length = 366.0 if calendar.isleap(start_date.year) else 365.0\n  return (end_date - start_date).days / year_length\n\n\n# Constants for moon phase calculations.\n_new_moon_date = datetime.date(1900, 1, 1)    # Known new moon.\n_synodic_month = 29.530588853                 # Length of synodic month, in days.\n\ndef MOONPHASE(date, output=\"emoji\"):\n  \"\"\"\n  Returns the phase of the moon on the given date. The output defaults to a moon-phase emoji.\n\n  - With `output=\"days\"`, the output is the age of the moon in days (new moon being 0).\n  - With `output=\"fraction\"`, the output is the fraction of the lunar month since new moon.\n\n  The calculation isn't astronomically precise, but good enough for wolves and sailors.\n\n  Do NOT! use `output=\"lunacy\"`.\n\n  >>> MOONPHASE(datetime.date(1900, 1, 1), \"days\")\n  0.0\n  >>> MOONPHASE(datetime.date(1900, 1, 1), \"fraction\")\n  0.0\n  >>> MOONPHASE(datetime.datetime(1900, 1, 1)) == '🌑'\n  True\n  >>> MOONPHASE(datetime.date(1900, 1, 15)) == '🌕'\n  True\n  >>> MOONPHASE(datetime.date(1900, 1, 30)) == '🌑'\n  True\n  >>> [MOONPHASE(DATEADD(datetime.date(2023, 4, 1), days=4*n)) for n in range(8)] == ['🌔', '🌕', '🌖', '🌗', '🌘', '🌑', '🌒', '🌓']\n  True\n  >>> [round(MOONPHASE(DATEADD(datetime.date(2023, 4, 1), days=4*n), \"days\"), 1) for n in range(8)]\n  [10.4, 14.4, 18.4, 22.4, 26.4, 0.9, 4.9, 8.9]\n  \"\"\"\n  days = (_make_datetime(date).date() - _new_moon_date).total_seconds() / 86400.\n  age = days % _synodic_month\n  phase = age / _synodic_month\n  if output == \"fraction\":\n    return phase\n  elif output == \"days\":\n    return age\n  else:\n    # DRAW THE MOON'S PHASES WITH EMOJI. ALL MOON PHASES ARE BEAUTIFUL, EVEN (near) INSTANT\n    # ONES LIKE NEW, QUARTER, AND FULL (my fave, AWOOOO!) TO BE FAIR TO ALL PHASES, DIVIDE UP\n    # EACH QUARTER INTO 10% FOR THE SHORT PHASES, 15% FOR THE LONG ONES.\n    quarter, frac = divmod((phase + 0.05) % 1, 0.25)\n    index = int(quarter) * 2 + int(frac > 0.1)\n    if output == \"lunacy\":\n      return \"🐺\" if index == 4 else \"🕺\"\n    return [\"🌑\", \"🌒\", \"🌓\", \"🌔\", \"🌕\", \"🌖\", \"🌗\", \"🌘\"][index]\n\ndef NETWORKDAYS(start_date, end_date, holidays=[]):\n  \"\"\"\n  Calculates the net or number of work days between two dates.\n  The work days are Monday through Friday, excluding the dates in the `holidays` list.\n\n  For example, here are the first 2 weeks of January 2020:\n  ```\n  Mo Tu We Th Fr Sa Su\n         1  2  3  4  5\n   6  7  8  9 10 11 12\n  ```\n\n  >>> NETWORKDAYS(DATE(2020, 1, 1), DATE(2020, 1, 10))\n  8\n  >>> NETWORKDAYS(DATE(2020, 1, 1), DATE(2020, 1, 10), [DATE(2020, 1, 6)])\n  7\n\n  If the holiday falls on a weekend, it is ignored:\n  >>> NETWORKDAYS(DATE(2020, 1, 1), DATE(2020, 1, 10), [DATE(2020, 1, 5)])\n  8\n\n  If the start date is after the end date, return a negative count of days:\n  >>> NETWORKDAYS(DATE(2020, 1, 10), DATE(2020, 1, 1))\n  -8\n\n  Datetime objects are also accepted:\n  >>> NETWORKDAYS(DTIME(\"2020-1-1 13:00\"), DTIME(\"2020-1-10\"))\n  8\n  >>> NETWORKDAYS(DTIME(\"2020-1-1 13:00\"), DTIME(\"2020-1-10\"), [DTIME(\"2020-1-7 12:00\")])\n  7\n\n  Strings are also accepted:\n  >>> NETWORKDAYS(\"2020-1-1\", \"2020-1-10\")\n  8\n  >>> NETWORKDAYS(\"2020-1-1\", \"2020-1-10\", [\"2020-1-6\"])\n  7\n  >>> NETWORKDAYS(\"1/1/2013\", \"2/1/2013\")\n  24\n  >>> NETWORKDAYS(\"1/1/2013\", \"2/1/2013\", [\"1/1/2013\", \"1/21/2013\", \"2/18/2013\",\"5/27/2013\"])\n  22\n  >>> NETWORKDAYS(\"3/1/2013\", \"7/1/2013\", [\"1/1/2013\", \"1/21/2013\", \"2/18/2013\",\"5/27/2013\"])\n  86\n  \"\"\"\n  start_date = _make_date(start_date)\n  end_date = _make_date(end_date)\n\n  # Convert holidays to a set of dates for faster lookup\n  holidays = set(_make_date(h) for h in holidays)\n\n  # Initialize the weekday count\n  weekday_count = 0\n  one_day = datetime.timedelta(days=1)\n\n  # If the start date is after the end date, return a negative count\n  # This follows the behavior of Excel and Google Sheets.\n  sign_of_count = 1\n  if start_date > end_date:\n    sign_of_count = -1\n    start_date, end_date = end_date, start_date\n\n  # Loop through the range of dates\n  current_date = start_date\n  while current_date <= end_date:\n    # Check if the current date is a weekday (Monday=0, Sunday=6)\n    # 0 to 4 are weekdays\n    if current_date.weekday() < 5 and not current_date in holidays:\n      weekday_count += 1\n    current_date += one_day\n\n  return sign_of_count * weekday_count\n"
  },
  {
    "path": "sandbox/grist/functions/info.py",
    "content": "# -*- coding: UTF-8 -*-\n# pylint: disable=unused-argument\n\nfrom __future__ import absolute_import\nimport datetime\nimport hashlib\nimport json as json_module\nimport math\nimport numbers\nimport re\n\nimport chardet\nimport urllib.parse\n\nimport column\nimport docmodel\nfrom functions import date      # pylint: disable=import-error\nfrom functions.unimplemented import unimplemented\nfrom objtypes import CellError\nfrom usertypes import AltText   # pylint: disable=import-error\nfrom records import Record, RecordSet\n\n@unimplemented\ndef ISBLANK(value):\n  \"\"\"\n  Returns whether a value refers to an empty cell. It isn't implemented in Grist. To check for an\n  empty string, use `value == \"\"`.\n  \"\"\"\n  raise NotImplementedError()\n\n\ndef ISERR(value):\n  \"\"\"\n  Checks whether a value is an error. In other words, it returns true\n  if using `value` directly would raise an exception.\n\n  NOTE: Grist implements this by automatically wrapping the argument to use lazy evaluation.\n\n  A more Pythonic approach to checking for errors is:\n  ```\n  try:\n    ... value ...\n  except Exception, err:\n    ... do something about the error ...\n  ```\n\n  For example:\n\n  >>> ISERR(\"Hello\")\n  False\n\n  More tests:\n  >>> ISERR(lambda: (1/0.1))\n  False\n  >>> ISERR(lambda: (1/0.0))\n  True\n  >>> ISERR(lambda: \"test\".bar())\n  True\n  >>> ISERR(lambda: \"test\".upper())\n  False\n  >>> ISERR(lambda: AltText(\"A\"))\n  False\n  >>> ISERR(lambda: float('nan'))\n  False\n  >>> ISERR(lambda: None)\n  False\n  \"\"\"\n  return lazy_value_or_error(value) is _error_sentinel\n\n\ndef ISERROR(value):\n  \"\"\"\n  Checks whether a value is an error or an invalid value. It is similar to `ISERR`, but also\n  returns true for an invalid value such as NaN or a text value in a Numeric column.\n\n  NOTE: Grist implements this by automatically wrapping the argument to use lazy evaluation.\n\n  >>> ISERROR(\"Hello\")\n  False\n  >>> ISERROR(AltText(\"fail\"))\n  True\n  >>> ISERROR(float('nan'))\n  True\n\n  More tests:\n  >>> ISERROR(AltText(\"\"))\n  True\n  >>> [ISERROR(v) for v in [0, None, \"\", \"Test\", 17.0]]\n  [False, False, False, False, False]\n  >>> ISERROR(lambda: (1/0.1))\n  False\n  >>> ISERROR(lambda: (1/0.0))\n  True\n  >>> ISERROR(lambda: \"test\".bar())\n  True\n  >>> ISERROR(lambda: \"test\".upper())\n  False\n  >>> ISERROR(lambda: AltText(\"A\"))\n  True\n  >>> ISERROR(lambda: float('nan'))\n  True\n  >>> ISERROR(lambda: None)\n  False\n  \"\"\"\n  return is_error(lazy_value_or_error(value))\n\n\ndef ISLOGICAL(value):\n  \"\"\"\n  Checks whether a value is `True` or `False`.\n\n  >>> ISLOGICAL(True)\n  True\n  >>> ISLOGICAL(False)\n  True\n  >>> ISLOGICAL(0)\n  False\n  >>> ISLOGICAL(None)\n  False\n  >>> ISLOGICAL(\"Test\")\n  False\n  \"\"\"\n  return isinstance(value, bool)\n\n\ndef ISNA(value):\n  \"\"\"\n  Checks whether a value is the error `#N/A`.\n\n  >>> ISNA(float('nan'))\n  True\n  >>> ISNA(0.0)\n  False\n  >>> ISNA('text')\n  False\n  >>> ISNA(float('-inf'))\n  False\n  \"\"\"\n  return isinstance(value, float) and math.isnan(value)\n\n\ndef ISNONTEXT(value):\n  \"\"\"\n  Checks whether a value is non-textual.\n\n  >>> ISNONTEXT(\"asdf\")\n  False\n  >>> ISNONTEXT(\"\")\n  False\n  >>> ISNONTEXT(AltText(\"text\"))\n  False\n  >>> ISNONTEXT(17.0)\n  True\n  >>> ISNONTEXT(None)\n  True\n  >>> ISNONTEXT(datetime.date(2011, 1, 1))\n  True\n  \"\"\"\n  return not ISTEXT(value)\n\n\ndef ISNUMBER(value):\n  \"\"\"\n  Checks whether a value is a number.\n\n  >>> ISNUMBER(17)\n  True\n  >>> ISNUMBER(-123.123423)\n  True\n  >>> ISNUMBER(False)\n  True\n  >>> ISNUMBER(float('nan'))\n  True\n  >>> ISNUMBER(float('inf'))\n  True\n  >>> ISNUMBER('17')\n  False\n  >>> ISNUMBER(None)\n  False\n  >>> ISNUMBER(datetime.date(2011, 1, 1))\n  False\n\n  More tests:\n  >>> ISNUMBER(AltText(\"text\"))\n  False\n  >>> ISNUMBER('')\n  False\n  \"\"\"\n  return isinstance(value, numbers.Number)\n\n\ndef ISREF(value):\n  \"\"\"\n  Checks whether a value is a table record.\n\n  For example, if a column `person` is of type Reference to the `People` table,\n  then `ISREF($person)` is `True`.\n  Similarly, `ISREF(People.lookupOne(name=$name))` is `True`. For any other type of value,\n  `ISREF()` would evaluate to `False`.\n\n  >>> ISREF(17)\n  False\n  >>> ISREF(\"Roger\")\n  False\n\n  \"\"\"\n  return isinstance(value, Record)\n\n\ndef ISREFLIST(value):\n  \"\"\"\n  Checks whether a value is a [`RecordSet`](#recordset),\n  the type of values in Reference List columns.\n\n  For example, if a column `people` is of type Reference List to the `People` table,\n  then `ISREFLIST($people)` is `True`.\n  Similarly, `ISREFLIST(People.lookupRecords(name=$name))` is `True`. For any other type of value,\n  `ISREFLIST()` would evaluate to `False`.\n\n  >>> ISREFLIST(17)\n  False\n  >>> ISREFLIST(\"Roger\")\n  False\n\n  \"\"\"\n  return isinstance(value, RecordSet)\n\n\ndef ISTEXT(value):\n  \"\"\"\n  Checks whether a value is text.\n\n  >>> ISTEXT(\"asdf\")\n  True\n  >>> ISTEXT(\"\")\n  True\n  >>> ISTEXT(AltText(\"text\"))\n  True\n  >>> ISTEXT(17.0)\n  False\n  >>> ISTEXT(None)\n  False\n  >>> ISTEXT(datetime.date(2011, 1, 1))\n  False\n  \"\"\"\n  return isinstance(value, (str, AltText))\n\n\n# Regexp for matching email. See ISEMAIL for justification.\n_email_regexp = re.compile(\n  r\"\"\"\n  ^\\w                             # Start with an alphanumeric character\n  [\\w%+/='-]*  (\\.[\\w%+/='-]+)*   # Elsewhere allow also a few other special characters\n                                  # But no two consecutive periods\n  @\n  ([A-Za-z0-9]                    # Each part of hostname must start with alphanumeric\n    ([A-Za-z0-9-]*[A-Za-z0-9])?\\. # May have dashes inside, but end in alphanumeric\n  )+\n  [A-Za-z]{2,24}$                 # Restrict top-level domain to length {2,24} (theoretically,\n                                  # the max length is 63 bytes as per RFC 1034). Google seems\n                                  # to use a whitelist for TLDs longer than 2 characters.\n  \"\"\", re.UNICODE | re.VERBOSE)\n\n\n# Regexp for matching hostname part of URLs (see also ISURL). Duplicates part of _email_regexp.\n_hostname_regexp = re.compile(\n  r\"\"\"^\n  ([A-Za-z0-9]                    # Each part of hostname must start with alphanumeric\n    ([A-Za-z0-9-]*[A-Za-z0-9])?\\. # May have dashes inside, but end in alphanumeric\n  )+\n  [A-Za-z]{2,6}$                  # Restrict top-level domain to length {2,6}. Google seems\n  \"\"\", re.VERBOSE)\n\n\ndef ISEMAIL(value):\n  u\"\"\"\n  Returns whether a value is a valid email address.\n\n  Note that checking email validity is not an exact science. The technical standard considers many\n  email addresses valid that are not used in practice, and would not be considered valid by most\n  users. Instead, we follow Google Sheets implementation, with some differences, noted below.\n\n  >>> ISEMAIL(\"Abc.123@example.com\")\n  True\n  >>> ISEMAIL(\"Bob_O-Reilly+tag@example.com\")\n  True\n  >>> ISEMAIL(\"John Doe\")\n  False\n  >>> ISEMAIL(\"john@aol...com\")\n  False\n\n  More tests:                                             Google Sheets   Grist\n                                                          -------------   -----\n  >>> ISEMAIL(\"Abc@example.com\")                              # True,     True\n  True\n  >>> ISEMAIL(\"Abc.123@example.com\")                          # True,     True\n  True\n  >>> ISEMAIL(\"foo@bar.com\")                                  # True,     True\n  True\n  >>> ISEMAIL(\"asdf@com.zt\")                                  # True,     True\n  True\n  >>> ISEMAIL(\"Bob_O-Reilly+tag@example.com\")                 # True,     True\n  True\n  >>> ISEMAIL(\"john@server.department.company.com\")           # True,     True\n  True\n  >>> ISEMAIL(\"asdf@mail.ru\")                                 # True,     True\n  True\n  >>> ISEMAIL(\"fabio@foo.qwer.COM\")                           # True,     True\n  True\n  >>> ISEMAIL(\"user+mailbox/department=shipping@example.com\") # False,    True\n  True\n  >>> ISEMAIL(u\"user+mailbox/department=shipping@example.com\") # False,    True\n  True\n  >>> ISEMAIL(\"customer/department=shipping@example.com\")     # False,    True\n  True\n  >>> ISEMAIL(\"Bob_O'Reilly+tag@example.com\")                 # False,    True\n  True\n  >>> ISEMAIL(\"marie@isola.corsica\")                          # False,    True\n  True\n  >>> ISEMAIL(\"fabio@disapproved.solutions\")                  # False,    True\n  True\n  >>> ISEMAIL(u\"фыва@mail.ru\")                                # False,    True\n  True\n  >>> ISEMAIL(\"my@baddash.-.com\")                             # True,     False\n  False\n  >>> ISEMAIL(\"my@baddash.-a.com\")                            # True,     False\n  False\n  >>> ISEMAIL(\"my@baddash.b-.com\")                            # True,     False\n  False\n  >>> ISEMAIL(\"john@-.com\")                                   # True,     False\n  False\n  >>> ISEMAIL(\"!def!xyz%abc@example.com\")                     # False,    False\n  False\n  >>> ISEMAIL(\"!#$%&'*+-/=?^_`.{|}~@example.com\")             # False,    False\n  False\n  >>> ISEMAIL(u\"伊昭傑@郵件.商務\")                             # False,    False\n  False\n  >>> ISEMAIL(u\"राम@मोहन.ईन्फो\")                                    # False,    Fale\n  False\n  >>> ISEMAIL(u\"юзер@екзампл.ком\")                             # False,    False\n  False\n  >>> ISEMAIL(u\"θσερ@εχαμπλε.ψομ\")                             # False,    False\n  False\n  >>> ISEMAIL(u\"葉士豪@臺網中心.tw\")                           # False,    False\n  False\n  >>> ISEMAIL(u\"jeff@臺網中心.tw\")                             # False,    False\n  False\n  >>> ISEMAIL(u\"葉士豪@臺網中心.台灣\")                         # False,    False\n  False\n  >>> ISEMAIL(u\"jeff葉@臺網中心.tw\")                           # False,    False\n  False\n  >>> ISEMAIL(\"my．name@domain.com\")                          # False,    False\n  False\n  >>> ISEMAIL(\"my.name@domain．com\")                          # False,    False\n  False\n  >>> ISEMAIL(\"my@.leadingdot.com\")                           # False,    False\n  False\n  >>> ISEMAIL(\"my@．．leadingfwdot.com\")                      # False,    False\n  False\n  >>> ISEMAIL(\"my@..twodots.com\")                             # False,    False\n  False\n  >>> ISEMAIL(\"my@twodots..com\")                              # False,    False\n  False\n  >>> ISEMAIL(\".leadingdot@domain.com\")                       # False,    False\n  False\n  >>> ISEMAIL(\"..twodots@domain.com\")                         # False,    False\n  False\n  >>> ISEMAIL(\"twodots..here@domain.com\")                     # False,    False\n  False\n  >>> ISEMAIL(\"me@⒈wouldbeinvalid.com\")                       # False,    False\n  False\n  >>> ISEMAIL(\"Foo Bar <a+2asdf@qwer.bar.com>\")               # False,    False\n  False\n  >>> ISEMAIL(\"Abc\\\\@def@example.com\")                        # False,    False\n  False\n  >>> ISEMAIL(\"foo@bar@google.com\")                           # False,    False\n  False\n  >>> ISEMAIL(\"john@aol...com\")                               # False,    False\n  False\n  >>> ISEMAIL(\"x@ทีเอชนิค.ไทย\")                                 # False,    False\n  False\n  >>> ISEMAIL(\"asdf@mail\")                                    # False,    False\n  False\n  >>> ISEMAIL(\"example@良好Mail.中国\")                        # False,    False\n  False\n  \"\"\"\n  return bool(_email_regexp.match(value))\n\n\n_url_regexp = re.compile(\n  r\"\"\"^\n  ((ftp|http|https|gopher|mailto|news|telnet|aim)://)?\n  (\\w+@)?                         # Allow 'user@' part, esp. useful for mailto: URLs.\n  ([A-Za-z0-9]                    # Each part of hostname must start with alphanumeric\n    ([A-Za-z0-9-]*[A-Za-z0-9])?\\. # May have dashes inside, but end in alphanumeric\n  )+\n  [A-Za-z]{2,24}                  # Restrict top-level domain to length {2,24} (theoretically,\n                                  # the max length is 63 bytes as per RFC 1034). Google seems\n                                  # to use a whitelist for TLDs longer than 2 characters.\n  ([/?][-\\w!#$%&'()*+,./:;=?@~]*)?$ # Notably, this excludes <, >, and \".\n  \"\"\", re.VERBOSE)\n\n\ndef ISURL(value):\n  \"\"\"\n  Checks whether a value is a valid URL. It does not need to be fully qualified, or to include\n  \"http://\" and \"www\". It does not follow a standard, but attempts to work similarly to ISURL in\n  Google Sheets, and to return True for text that is likely a URL.\n\n  Valid protocols include ftp, http, https, gopher, mailto, news, telnet, and aim.\n\n  >>> ISURL(\"http://www.getgrist.com\")\n  True\n  >>> ISURL(\"https://foo.com/test_(wikipedia)#cite-1\")\n  True\n  >>> ISURL(\"mailto://user@example.com\")\n  True\n  >>> ISURL(\"http:///a\")\n  False\n\n  More tests:\n  >>> ISURL(\"http://www.google.com\")\n  True\n  >>> ISURL(\"www.google.com/\")\n  True\n  >>> ISURL(\"google.com\")\n  True\n  >>> ISURL(\"http://a.b-c.de\")\n  True\n  >>> ISURL(\"a.b-c.de\")\n  True\n  >>> ISURL(\"http://j.mp/---\")\n  True\n  >>> ISURL(\"ftp://foo.bar/baz\")\n  True\n  >>> ISURL(\"https://foo.com/blah_(wikipedia)#cite-1\")\n  True\n  >>> ISURL(\"mailto://user@google.com\")\n  True\n  >>> ISURL(\"http://user@www.google.com\")\n  True\n  >>> ISURL(\"http://foo.com/!#$%25&'()*+,-./=?@_~\")\n  True\n  >>> ISURL(\"http://collectivite.isla.corsica\")\n  True\n  >>> ISURL(\"http://../\")\n  False\n  >>> ISURL(\"http://??/\")\n  False\n  >>> ISURL(\"a.-b.cd\")\n  False\n  >>> ISURL(\"http://foo.bar?q=Spaces should be encoded \")\n  False\n  >>> ISURL(\"//\")\n  False\n  >>> ISURL(\"///a\")\n  False\n  >>> ISURL(\"http:///a\")\n  False\n  >>> ISURL(\"bar://www.google.com\")\n  False\n  >>> ISURL(\"http:// shouldfail.com\")\n  False\n  >>> ISURL(\"ftps://foo.bar/\")\n  False\n  >>> ISURL(\"http://-error-.invalid/\")\n  False\n  >>> ISURL(\"http://0.0.0.0\")\n  False\n  >>> ISURL(\"http://.www.foo.bar/\")\n  False\n  >>> ISURL(\"http://.www.foo.bar./\")\n  False\n  >>> ISURL(\"example.com/file[/].html\")\n  False\n  >>> ISURL(\"http://example.com/file[/].html\")\n  False\n  >>> ISURL(\"http://mw1.google.com/kml-samples/gp/seattle/gigapxl/$[level]/r$[y]_c$[x].jpg\")\n  False\n  >>> ISURL(\"http://foo.com/>\")\n  False\n  \"\"\"\n  value = value.strip()\n  if ' ' in value:        # Disallow spaces inside value.\n    return False\n  return bool(_url_regexp.match(value))\n\n\ndef N(value):\n  \"\"\"\n  Returns the value converted to a number. True/False are converted to 1/0. A date is converted to\n  Excel-style serial number of the date. Anything else is converted to 0.\n\n  >>> N(7)\n  7\n  >>> N(7.1)\n  7.1\n  >>> N(\"Even\")\n  0\n  >>> N(\"7\")\n  0\n  >>> N(True)\n  1\n  >>> N(datetime.datetime(2011, 4, 17))\n  40650.0\n  \"\"\"\n  if ISNUMBER(value):\n    return value\n  if isinstance(value, datetime.date):\n    return date.DATE_TO_XL(value)\n  return 0\n\n\ndef NA():\n  \"\"\"\n  Returns the \"value not available\" error, `#N/A`.\n\n  >>> math.isnan(NA())\n  True\n  \"\"\"\n  return float('nan')\n\n\n@unimplemented\ndef TYPE(value):\n  \"\"\"\n  Returns a number associated with the type of data passed into the function. This is not\n  implemented in Grist. Use `isinstance(value, type)` or `type(value)`.\n  \"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef CELL(info_type, reference):\n  \"\"\"\n  Returns the requested information about the specified cell. This is not implemented in Grist\n  \"\"\"\n  raise NotImplementedError()\n\n\ndef PEEK(func):\n  \"\"\"\n  Evaluates the given expression without creating dependencies\n  or requiring that referenced values are up to date, using whatever value it finds in a cell.\n  This is useful for preventing circular reference errors, particularly in trigger formulas.\n\n  For example, if the formula for `A` depends on `$B` and the formula for `B` depends on `$A`,\n  then normally this would raise a circular reference error because each value needs to be\n  calculated before the other. But if `A` uses `PEEK($B)` then it will simply get the value\n  already stored in `$B` without requiring that `$B` is first calculated to the latest value.\n  Therefore `A` will be calculated first, and `B` can use `$A` without problems.\n  \"\"\"\n  engine = docmodel.global_docmodel._engine\n  engine._peeking = True\n  try:\n    return func()\n  finally:\n    engine._peeking = False\n\n\ndef RECORD(record_or_list, dates_as_iso=False, expand_refs=0):\n  \"\"\"\n  Returns a Python dictionary with all fields in the given record. If a list of records is given,\n  returns a list of corresponding Python dictionaries.\n\n  If dates_as_iso is set, Date and DateTime values are converted to string using ISO 8601 format.\n\n  If expand_refs is set to 1 or higher, Reference values are replaced with a RECORD representation\n  of the referenced record, expanding the given number of levels.\n\n  Error values present in cells of the record are replaced with None value, and a special key of\n  \"_error_\" gets added containing the error messages for those cells. For example:\n  `{\"Ratio\": None, \"_error_\": {\"Ratio\": \"ZeroDivisionError: integer division or modulo by zero\"}}`\n\n  Note that care is needed to avoid circular references when using RECORD(), since it creates a\n  dependency on every cell in the record. In case of RECORD(rec), the cell containing this call\n  will be omitted from the resulting dictionary.\n\n  For example:\n  ```\n  RECORD($Person)\n  RECORD(rec)\n  RECORD(People.lookupOne(First_Name=\"Alice\"))\n  RECORD(People.lookupRecords(Department=\"HR\"))\n  ```\n  \"\"\"\n  if isinstance(record_or_list, Record):\n    return _prepare_record_dict(record_or_list, dates_as_iso=dates_as_iso, expand_refs=expand_refs)\n\n  try:\n    records = list(record_or_list)\n    assert all(isinstance(r, Record) for r in records)\n  except Exception:\n    raise ValueError('RECORD() requires a Record or an iterable of Records')\n\n  return [_prepare_record_dict(r, dates_as_iso=dates_as_iso, expand_refs=expand_refs)\n          for r in records]\n\n\ndef _prepare_record_dict(record, dates_as_iso=False, expand_refs=0):\n  table_id = record._table.table_id\n  docmodel = record._table._engine.docmodel\n  columns = docmodel.get_table_rec(table_id).columns\n  current_node = record._table._engine._current_node\n\n  result = {'id': int(record)}\n  errors = {}\n  for col in columns:\n    col_id = col.colId\n    # Skip helper columns.\n    if not column.is_visible_column(col_id):\n      continue\n\n    # Avoid trying to access the cell being evaluated, since cycles get detected even if the\n    # CircularRef exception is caught. TODO This is hacky, and imperfect. If another column\n    # references a column containing the RECORD(rec) call, CircularRefError will still happen.\n    if current_node == (table_id, col_id):\n      continue\n\n    try:\n      val = getattr(record, col_id)\n      if dates_as_iso and isinstance(val, datetime.date):\n        val = val.isoformat()\n      elif expand_refs and isinstance(val, (Record, RecordSet)):\n        # Reduce expand_refs levels.\n        if val:\n          val = RECORD(val, dates_as_iso=dates_as_iso, expand_refs=expand_refs - 1)\n        else:\n          val = None\n      result[col_id] = val\n    except Exception as e:\n      result[col_id] = None\n      while isinstance(e, CellError):\n        # The extra information from CellError is redundant here\n        e = e.error  # pylint: disable=no-member\n      errors[col_id] = \"%s: %s\" % (type(e).__name__, str(e))\n\n  if errors:\n    result[\"_error_\"] = errors\n  return result\n\n\n# Unique sentinel value to represent that a lazy value evaluates with an exception.\n_error_sentinel = object()\n\ndef lazy_value_or_error(value):\n  \"\"\"\n  Evaluates a value like lazy_value(), but returns _error_sentinel on exception.\n  \"\"\"\n  try:\n    return value() if callable(value) else value\n  except Exception:\n    return _error_sentinel\n\ndef is_error(value):\n  \"\"\"\n  Checks whether a value is an invalid value or _error_sentinel.\n  \"\"\"\n  return ((value is _error_sentinel)\n      or isinstance(value, AltText)\n      or (isinstance(value, float) and math.isnan(value)))\n\n\ndef _replicate_requests_body_args(data=None, json=None):\n  \"\"\"\n  Replicate some of the behaviour of requests.post, specifically the data and \n  json args.\n\n  Returns a tuple of (body, extra_headers)\n  \"\"\"\n  if data is None and json is None:\n      return None, {}\n\n  elif data is not None and json is None:\n    if isinstance(data, str):\n      body = data\n      extra_headers = {}\n    else:\n      body = urllib.parse.urlencode(data)\n      extra_headers = {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      }\n    return body, extra_headers\n\n  elif json is not None and data is None:\n    if isinstance(json, str):\n      body = json\n    else:\n      body = json_module.dumps(json)\n    extra_headers = {\n      \"Content-Type\": \"application/json\",\n    }\n    return body, extra_headers\n\n  elif data is not None and json is not None:\n    # From testing manually with requests 2.28.2, data overrides json if both\n    # supplied. However, this is probably a mistake on behalf of the caller, so\n    # we choose to throw an error instead\n    raise ValueError(\"`data` and `json` cannot be supplied to REQUEST at the same time\")\n\n\n@unimplemented\n# ^ This excludes this function from autocomplete while in beta\n# and marks it as unimplemented in the docs.\n# It also makes grist-help expect to see the string 'raise NotImplemented' in the function source,\n# which it does now, because of this comment. Removing this comment will currently break the docs.\ndef REQUEST(url, params=None, headers=None, method=\"GET\", data=None, json=None):\n  # Makes an HTTP request with an API similar to `requests.request`.\n  # Actually jumps through hoops internally to make the request asynchronously (usually)\n  # while feeling synchronous to the formula writer.\n\n  # When making a POST or PUT request, REQUEST supports `data` and `json` args, from `requests.request`:\n  #   - `args` as str: Used as the request body\n  #   - `args` as other types: Form encoded and used as the request body. The correct header is also set.\n  #   - `json` as str: Used as the request body. The correct header is also set.\n  #   - `json` as other types: JSON encoded and set as the request body. The correct header is also set.\n  body, _headers = _replicate_requests_body_args(data=data, json=json)\n\n  # Extra headers that make us consistent with requests.post must not override\n  # user-supplied headers.\n  _headers.update(headers or {})\n\n  # Requests are identified by a string key in various places.\n  # The same arguments should produce the same key so the request is only made once.\n  args = dict(url=url, params=params, headers=_headers, method=method, body=body)\n\n  args_json = json_module.dumps(args, sort_keys=True)\n  key = hashlib.sha256(args_json.encode()).hexdigest()\n\n  # This may either return the raw response data or it may raise a special exception\n  # to delegate the request and reevaluate the formula later.\n  response_dict = docmodel.global_docmodel._engine._requesting(key, args)\n\n  if \"error\" in response_dict:\n    # Indicates a complete failure to make the request, such as a connection problem.\n    # An unsuccessful status code like 404 or 500 doesn't raise this error.\n    raise HTTPError(response_dict[\"error\"])\n\n  return Response(**response_dict)\n\n\nclass HTTPError(Exception):\n  pass\n\n\nclass Response(object):\n  \"\"\"\n  Similar to the Response class from the `requests` library.\n  \"\"\"\n  def __init__(self, content, status, statusText, headers, encoding=None):\n    self.content = content  # raw bytes\n    self.status_code = status  # e.g. 404\n    self.reason = statusText  # e.g. \"Not Found\"\n    self.headers = CaseInsensitiveDict(headers)\n    self.encoding = encoding or self.apparent_encoding or \"utf-8\"\n\n  @property\n  def text(self):\n    return self.content.decode(self.encoding)\n\n  def json(self, **kwargs):\n    return json_module.loads(self.text, **kwargs)\n\n  @property\n  def ok(self):\n    return self.status_code < 400\n\n  def raise_for_status(self):\n    if not self.ok:\n      raise HTTPError(\"Request failed with status %s\" % self.status_code)\n\n  @property\n  def apparent_encoding(self):\n    return chardet.detect(self.content)[\"encoding\"]\n\n  def close(self):\n    pass  # nothing to do\n\n\nclass CaseInsensitiveDict(dict):\n  \"\"\"\n  Similar to dict but treats all keys (which must be strings) case-insensitively,\n  e.g. `d[\"foo\"]` and `d[\"FOO\"]` are equivalent.\n  \"\"\"\n  def __init__(self, *args, **kwargs):\n    dict.__init__(self, *args, **kwargs)\n    for k in list(self):\n      # Convert key to lowercase\n      self[k] = dict.pop(self, k)\n\n  def update(self, E=None, **F):\n    dict.update(self.__class__(E or {}))\n    dict.update(self.__class__(**F))\n\n\ndef _forward_dict_method(name):\n  # Replace method 'name' where the first argument is a key with a version that lowercases the key\n  def method(self, key, *args, **kwargs):\n    return getattr(dict, name)(self, key.lower(), *args, **kwargs)\n  return method\n\nfor _name in \"__getitem__ __setitem__ __delitem__ __contains__ get setdefault pop has_key\".split():\n  setattr(CaseInsensitiveDict, _name, _forward_dict_method(_name))\n"
  },
  {
    "path": "sandbox/grist/functions/logical.py",
    "content": "from .info import lazy_value_or_error, is_error\nfrom usertypes import AltText   # pylint: disable=unused-import,import-error\n\n\ndef AND(logical_expression, *logical_expressions):\n  \"\"\"\n  Returns True if all of the arguments are logically true, and False if any are false.\n  Same as `all([value1, value2, ...])`.\n\n  >>> AND(1)\n  True\n  >>> AND(0)\n  False\n  >>> AND(1, 1)\n  True\n  >>> AND(1,2,3,4)\n  True\n  >>> AND(1,2,3,4,0)\n  False\n  \"\"\"\n  return all((logical_expression,) + logical_expressions)\n\n\ndef FALSE():\n  \"\"\"\n  Returns the logical value `False`. You may also use the value `False` directly. This\n  function is provided primarily for compatibility with other spreadsheet programs.\n\n  >>> FALSE()\n  False\n  \"\"\"\n  return False\n\n\ndef IF(logical_expression, value_if_true, value_if_false):\n  \"\"\"\n  Returns one value if a logical expression is `True` and another if it is `False`.\n\n  The equivalent Python expression is:\n  ```\n  value_if_true if logical_expression else value_if_false\n  ```\n\n  Since Grist supports multi-line formulas, you may also use Python blocks such as:\n  ```\n  if logical_expression:\n    return value_if_true\n  else:\n    return value_if_false\n  ```\n\n  NOTE: Grist follows Excel model by only evaluating one of the value expressions, by\n  automatically wrapping the expressions to use lazy evaluation. This allows `IF(False, 1/0, 1)`\n  to evaluate to `1` rather than raise an exception.\n\n  >>> IF(12, \"Yes\", \"No\")\n  'Yes'\n  >>> IF(None, \"Yes\", \"No\")\n  'No'\n  >>> IF(True, 0.85, 0.0)\n  0.85\n  >>> IF(False, 0.85, 0.0)\n  0.0\n\n  More tests:\n  >>> IF(True, lambda: (1/0), lambda: (17))  # doctest: +IGNORE_EXCEPTION_DETAIL\n  Traceback (most recent call last):\n  ...\n  ZeroDivisionError: integer division or modulo by zero\n  >>> IF(False, lambda: (1/0), lambda: (17))\n  17\n  \"\"\"\n  return lazy_value(value_if_true) if logical_expression else lazy_value(value_if_false)\n\n\ndef IFERROR(value, value_if_error=\"\"):\n  \"\"\"\n  Returns the first argument if it is not an error value, otherwise returns the second argument if\n  present, or a blank if the second argument is absent.\n\n  NOTE: Grist handles values that raise an exception by wrapping them to use lazy evaluation.\n\n  >>> IFERROR(float('nan'), \"**NAN**\")\n  '**NAN**'\n  >>> IFERROR(17.17, \"**NAN**\")\n  17.17\n  >>> IFERROR(\"Text\")\n  'Text'\n  >>> IFERROR(AltText(\"hello\"))\n  ''\n\n  More tests:\n  >>> IFERROR(lambda: (1/0.1), \"X\")\n  10.0\n  >>> IFERROR(lambda: (1/0.0), \"X\")\n  'X'\n  >>> IFERROR(lambda: AltText(\"A\"), \"err\")\n  'err'\n  >>> IFERROR(lambda: None, \"err\")\n\n  >>> IFERROR(lambda: foo.bar, 123)\n  123\n  >>> IFERROR(lambda: \"test\".bar(), 123)\n  123\n  >>> IFERROR(lambda: \"test\".bar())\n  ''\n  >>> IFERROR(lambda: \"test\".upper(), 123)\n  'TEST'\n  \"\"\"\n  value = lazy_value_or_error(value)\n  return value if not is_error(value) else value_if_error\n\n\ndef NOT(logical_expression):\n  \"\"\"\n  Returns the opposite of a logical value: `NOT(True)` returns `False`; `NOT(False)` returns\n  `True`. Same as `not logical_expression`.\n\n  >>> NOT(123)\n  False\n  >>> NOT(0)\n  True\n  \"\"\"\n  return not logical_expression\n\n\ndef OR(logical_expression, *logical_expressions):\n  \"\"\"\n  Returns True if any of the arguments is logically true, and false if all of the\n  arguments are false.\n  Same as `any([value1, value2, ...])`.\n\n  >>> OR(1)\n  True\n  >>> OR(0)\n  False\n  >>> OR(1, 1)\n  True\n  >>> OR(0, 1)\n  True\n  >>> OR(0, 0)\n  False\n  >>> OR(0,False,0.0,\"\",None)\n  False\n  >>> OR(0,None,3,0)\n  True\n  \"\"\"\n  return any((logical_expression,) + logical_expressions)\n\n\ndef TRUE():\n  \"\"\"\n  Returns the logical value `True`. You may also use the value `True` directly. This\n  function is provided primarily for compatibility with other spreadsheet programs.\n\n  >>> TRUE()\n  True\n  \"\"\"\n  return True\n\ndef lazy_value(value):\n  \"\"\"\n  Evaluates a lazy value by calling it when it's a callable, or returns it unchanged otherwise.\n  \"\"\"\n  return value() if callable(value) else value\n"
  },
  {
    "path": "sandbox/grist/functions/lookup.py",
    "content": "# pylint: disable=redefined-builtin, line-too-long\nfrom collections import OrderedDict, namedtuple\nimport os\n\nimport urllib.parse\nfrom .unimplemented import unimplemented\n\n@unimplemented\ndef ADDRESS(row, column, absolute_relative_mode, use_a1_notation, sheet):\n  \"\"\"Returns a cell reference as a string.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef CHOOSE(index, choice1, choice2):\n  \"\"\"Returns an element from a list of choices based on index.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef COLUMN(cell_reference=None):\n  \"\"\"Returns the column number of a specified cell, with `A=1`.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef COLUMNS(range):\n  \"\"\"Returns the number of columns in a specified array or range.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef GETPIVOTDATA(value_name, any_pivot_table_cell, original_column_1, pivot_item_1=None, *args):\n  \"\"\"Extracts an aggregated value from a pivot table that corresponds to the specified row and column headings.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef HLOOKUP(search_key, range, index, is_sorted):\n  \"\"\"Horizontal lookup. Searches across the first row of a range for a key and returns the value of a specified cell in the column found.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef HYPERLINK(url, link_label):\n  \"\"\"Creates a hyperlink inside a cell.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef INDEX(reference, row, column):\n  \"\"\"Returns the content of a cell, specified by row and column offset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef INDIRECT(cell_reference_as_string):\n  \"\"\"Returns a cell reference specified by a string.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef LOOKUP(search_key, search_range_or_search_result_array, result_range=None):\n  \"\"\"Looks through a row or column for a key and returns the value of the cell in a result range located in the same position as the search row or column.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef MATCH(search_key, range, search_type):\n  \"\"\"Returns the relative position of an item in a range that matches a specified value.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef OFFSET(cell_reference, offset_rows, offset_columns, height, width):\n  \"\"\"Returns a range reference shifted a specified number of rows and columns from a starting cell reference.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef ROW(cell_reference):\n  \"\"\"Returns the row number of a specified cell.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef ROWS(range):\n  \"\"\"Returns the number of rows in a specified array or range.\"\"\"\n  raise NotImplementedError()\n\ndef SELF_HYPERLINK(label=None, page=None, **kwargs):\n  \"\"\"\n  Creates a link to the current document.  All parameters are optional.\n\n  The returned string is in URL format, optionally preceded by a label and a space\n  (the format expected for Grist Text columns with the HyperLink option enabled).\n\n  A numeric page number can be supplied, which will create a link to the\n  specified page.  To find the numeric page number you need, visit a page\n  and examine its URL for a `/p/NN` part.\n\n  Any number of arguments of the form `LinkKey_NAME` may be provided, to set\n  `user.LinkKey.NAME` values that will be available in access rules.  For example,\n  if a rule allows users to view rows when `user.LinkKey.Code == rec.Code`,\n  we might want to create links with `SELF_HYPERLINK(LinkKey_Code=$Code)`.\n\n  >>> SELF_HYPERLINK()\n  u'https://docs.getgrist.com/sbaltsirg/Example'\n  >>> SELF_HYPERLINK(label='doc')\n  u'doc https://docs.getgrist.com/sbaltsirg/Example'\n  >>> SELF_HYPERLINK(page=2)\n  u'https://docs.getgrist.com/sbaltsirg/Example/p/2'\n  >>> SELF_HYPERLINK(LinkKey_Code='X1234')\n  u'https://docs.getgrist.com/sbaltsirg/Example?Code_=X1234'\n  >>> SELF_HYPERLINK(label='order', page=3, LinkKey_Code='X1234', LinkKey_Name='Bi Ngo')\n  u'order https://docs.getgrist.com/sbaltsirg/Example/p/3?Code_=X1234&Name_=Bi+Ngo'\n  >>> SELF_HYPERLINK(Linky_Link='Link')\n  Traceback (most recent call last):\n  ...\n  TypeError: unexpected keyword argument 'Linky_Link' (not of form LinkKey_NAME)\n  \"\"\"\n  txt = os.environ.get('DOC_URL')\n  if not txt:\n    return None\n  txt = str(txt)\n  if page:\n    txt += \"/p/{}\".format(page)\n  if kwargs:\n    parts = list(urllib.parse.urlparse(txt))\n    query = OrderedDict(urllib.parse.parse_qsl(parts[4]))\n    for [key, value] in sorted(kwargs.items()):\n      key_parts = key.split('LinkKey_')\n      if len(key_parts) == 2 and key_parts[0] == '':\n        query[key_parts[1] + '_'] = value\n      else:\n        raise TypeError(\"unexpected keyword argument '{}' (not of form LinkKey_NAME)\".format(key))\n    parts[4] = urllib.parse.urlencode(query)\n    txt = urllib.parse.urlunparse(parts)\n  if label:\n    txt = u\"{} {}\".format(label, txt)\n  return txt\n\ndef VLOOKUP(table, **field_value_pairs):\n  \"\"\"\n  Vertical lookup. Searches the given table for a record matching the given `field=value`\n  arguments. If multiple records match, returns one of them. If none match, returns the special\n  empty record.\n\n  The returned object is a record whose fields are available using `.field` syntax. For example,\n  `VLOOKUP(Employees, EmployeeID=$EmpID).Salary`.\n\n  Note that `VLOOKUP` isn't commonly needed in Grist, since [Reference columns](col-refs.md) are the\n  best way to link data between tables, and allow simple efficient usage such as `$Person.Age`.\n\n  `VLOOKUP` is exactly quivalent to `table.lookupOne(**field_value_pairs)`. See\n  [lookupOne](#lookupone).\n\n  For example:\n  ```\n  VLOOKUP(People, First_Name=\"Lewis\", Last_Name=\"Carroll\")\n  VLOOKUP(People, First_Name=\"Lewis\", Last_Name=\"Carroll\").Age\n  ```\n  \"\"\"\n  return table.lookupOne(**field_value_pairs)\n\n\nclass _NoMatchEmpty(object):\n  \"\"\"\n  Singleton sentinel value for CONTAINS match_empty parameter to indicate no argument was passed\n  and no value should match against empty lists in lookups.\n  \"\"\"\n  def __repr__(self):\n    return \"no_match_empty\"\n\n\nclass _Contains(namedtuple(\"_Contains\", \"value match_empty\")):\n  \"\"\"\n  Use this marker with [UserTable.lookupRecords](#lookuprecords) to find records\n  where a field of a list type (such as `Choice List` or `Reference List`) contains the given value.\n\n  For example:\n\n      MoviesTable.lookupRecords(genre=CONTAINS(\"Drama\"))\n\n  will return records in `MoviesTable` where the column `genre`\n  is a list or other container such as `[\"Comedy\", \"Drama\"]`,\n  i.e. `\"Drama\" in $genre`.\n\n  Note that the column being looked up (e.g. `genre`)\n  must have values of a container type such as list, tuple, or set.\n  In particular the values mustn't be strings, e.g. `\"Comedy-Drama\"` won't match\n  even though `\"Drama\" in \"Comedy-Drama\"` is `True` in Python.\n  It also won't match substrings within container elements, e.g. `[\"Comedy-Drama\"]`.\n\n  You can optionally pass a second argument `match_empty` to indicate a value that\n  should be matched against empty lists in the looked up column.\n\n  For example, given this formula:\n\n      MoviesTable.lookupRecords(genre=CONTAINS(g, match_empty=''))\n\n  If `g` is `''` (i.e. equal to `match_empty`) then the column `genre` in the returned records\n  will either be an empty list (or other container) or a list containing `g` as usual.\n  \"\"\"\n  # While users should apply this marker to values in queries, internally\n  # the marker is moved to the column ID so that the LookupMapColumn knows how to\n  # update its index correctly for that column.\n  # The _Contains class is used internally, especially with isinstance()\n  # The CONTAINS function is for users\n  # Having a function as the interface makes things like docs and autocomplete\n  # work more consistently\n\n  no_match_empty = _NoMatchEmpty()\n\n\ndef CONTAINS(value, match_empty=_Contains.no_match_empty):\n  try:\n    hash(match_empty)\n  except TypeError:\n    raise TypeError(\"match_empty must be hashable\")\n\n  return _Contains(value, match_empty)\n\nCONTAINS.__doc__ = _Contains.__doc__\n"
  },
  {
    "path": "sandbox/grist/functions/math.py",
    "content": "# pylint: disable=unused-argument\n\nfrom __future__ import absolute_import\n\nimport datetime\nimport math as _math\nimport operator\nimport random\nimport uuid\nfrom functools import reduce  # pylint: disable=redefined-builtin\nfrom functions.info import ISNUMBER, ISLOGICAL\nfrom functions.unimplemented import unimplemented\nimport roman\n\n# Iterates through elements of iterable arguments, or through individual args when not iterable.\ndef _chain(*values_or_iterables):\n  for v in values_or_iterables:\n    try:\n      v = iter(v)\n    except TypeError:\n      yield v\n    else:\n      for x in v:\n        yield x\n\n\n# Iterates through iterable or other arguments, skipping non-numeric ones.\ndef _chain_numeric(*values_or_iterables):\n  for v in _chain(*values_or_iterables):\n    if ISNUMBER(v) and not ISLOGICAL(v):\n      yield v\n\n\n# Iterates through iterable or other arguments, replacing non-numeric ones with 0 (or True with 1).\ndef _chain_numeric_a(*values_or_iterables):\n  for v in _chain(*values_or_iterables):\n    yield int(v) if ISLOGICAL(v) else v if ISNUMBER(v) else 0\n\n\n# Iterates through iterable or other arguments, only including numbers, dates, and datetimes.\ndef _chain_numeric_or_date(*values_or_iterables):\n  for v in _chain(*values_or_iterables):\n    if ISNUMBER(v) and not ISLOGICAL(v) or isinstance(v, (datetime.date, datetime.datetime)):\n      yield v\n\n\ndef _round_toward_zero(value):\n  return _math.floor(value) if value >= 0 else _math.ceil(value)\n\ndef _round_away_from_zero(value):\n  return _math.ceil(value) if value >= 0 else _math.floor(value)\n\ndef ABS(value):\n  \"\"\"\n  Returns the absolute value of a number.\n\n  >>> ABS(2)\n  2\n  >>> ABS(-2)\n  2\n  >>> ABS(-4)\n  4\n  \"\"\"\n  return abs(value)\n\ndef ACOS(value):\n  \"\"\"\n  Returns the inverse cosine of a value, in radians.\n\n  >>> round(ACOS(-0.5), 9)\n  2.094395102\n  >>> round(ACOS(-0.5)*180/PI(), 10)\n  120.0\n  \"\"\"\n  return _math.acos(value)\n\ndef ACOSH(value):\n  \"\"\"\n  Returns the inverse hyperbolic cosine of a number.\n\n  >>> ACOSH(1)\n  0.0\n  >>> round(ACOSH(10), 7)\n  2.9932228\n  \"\"\"\n  return _math.acosh(value)\n\ndef ARABIC(roman_numeral):\n  \"\"\"\n  Computes the value of a Roman numeral.\n\n  >>> ARABIC(\"LVII\")\n  57\n  >>> ARABIC('mcmxii')\n  1912\n  \"\"\"\n  return roman.fromRoman(roman_numeral.upper())\n\ndef ASIN(value):\n  \"\"\"\n  Returns the inverse sine of a value, in radians.\n\n  >>> round(ASIN(-0.5), 9)\n  -0.523598776\n  >>> round(ASIN(-0.5)*180/PI(), 10)\n  -30.0\n  >>> round(DEGREES(ASIN(-0.5)), 10)\n  -30.0\n  \"\"\"\n  return _math.asin(value)\n\ndef ASINH(value):\n  \"\"\"\n  Returns the inverse hyperbolic sine of a number.\n\n  >>> round(ASINH(-2.5), 9)\n  -1.647231146\n  >>> round(ASINH(10), 9)\n  2.99822295\n  \"\"\"\n  return _math.asinh(value)\n\ndef ATAN(value):\n  \"\"\"\n  Returns the inverse tangent of a value, in radians.\n\n  >>> round(ATAN(1), 9)\n  0.785398163\n  >>> ATAN(1)*180/PI()\n  45.0\n  >>> DEGREES(ATAN(1))\n  45.0\n  \"\"\"\n  return _math.atan(value)\n\ndef ATAN2(x, y):\n  \"\"\"\n  Returns the angle between the x-axis and a line segment from the origin (0,0) to specified\n  coordinate pair (`x`,`y`), in radians.\n\n  >>> round(ATAN2(1, 1), 9)\n  0.785398163\n  >>> round(ATAN2(-1, -1), 9)\n  -2.35619449\n  >>> ATAN2(-1, -1)*180/PI()\n  -135.0\n  >>> DEGREES(ATAN2(-1, -1))\n  -135.0\n  >>> round(ATAN2(1,2), 9)\n  1.107148718\n  \"\"\"\n  return _math.atan2(y, x)\n\ndef ATANH(value):\n  \"\"\"\n  Returns the inverse hyperbolic tangent of a number.\n\n  >>> round(ATANH(0.76159416), 9)\n  1.00000001\n  >>> round(ATANH(-0.1), 9)\n  -0.100335348\n  \"\"\"\n  return _math.atanh(value)\n\ndef CEILING(value, factor=1):\n  \"\"\"\n  Rounds a number up to the nearest multiple of factor, or the nearest integer if the factor is\n  omitted or 1.\n\n  >>> CEILING(2.5, 1)\n  3\n  >>> CEILING(-2.5, -2)\n  -4\n  >>> CEILING(-2.5, 2)\n  -2\n  >>> CEILING(1.5, 0.1)\n  1.5\n  >>> CEILING(0.234, 0.01)\n  0.24\n  \"\"\"\n  return int(_math.ceil(float(value) / factor)) * factor\n\ndef COMBIN(n, k):\n  \"\"\"\n  Returns the number of ways to choose some number of objects from a pool of a given size of\n  objects.\n\n  >>> COMBIN(8,2)\n  28\n  >>> COMBIN(4,2)\n  6\n  >>> COMBIN(10,7)\n  120\n  \"\"\"\n  # From http://stackoverflow.com/a/4941932/328565\n  k = min(k, n-k)\n  if k == 0:\n    return 1\n  numer = reduce(operator.mul, range(n, n-k, -1))\n  denom = reduce(operator.mul, range(1, k+1))\n  return numer//denom\n\ndef COS(angle):\n  \"\"\"\n  Returns the cosine of an angle provided in radians.\n\n  >>> round(COS(1.047), 7)\n  0.5001711\n  >>> round(COS(60*PI()/180), 10)\n  0.5\n  >>> round(COS(RADIANS(60)), 10)\n  0.5\n  \"\"\"\n  return _math.cos(angle)\n\ndef COSH(value):\n  \"\"\"\n  Returns the hyperbolic cosine of any real number.\n\n  >>> round(COSH(4), 6)\n  27.308233\n  >>> round(COSH(EXP(1)), 7)\n  7.6101251\n  \"\"\"\n  return _math.cosh(value)\n\ndef DEGREES(angle):\n  \"\"\"\n  Converts an angle value in radians to degrees.\n\n  >>> round(DEGREES(ACOS(-0.5)), 10)\n  120.0\n  >>> DEGREES(PI())\n  180.0\n  \"\"\"\n  return _math.degrees(angle)\n\ndef EVEN(value):\n  \"\"\"\n  Rounds a number up to the nearest even integer, rounding away from zero.\n\n  >>> EVEN(1.5)\n  2\n  >>> EVEN(3)\n  4\n  >>> EVEN(2)\n  2\n  >>> EVEN(-1)\n  -2\n  \"\"\"\n  return int(_round_away_from_zero(float(value) / 2)) * 2\n\ndef EXP(exponent):\n  \"\"\"\n  Returns Euler's number, e (~2.718) raised to a power.\n\n  >>> round(EXP(1), 8)\n  2.71828183\n  >>> round(EXP(2), 7)\n  7.3890561\n  \"\"\"\n  return _math.exp(exponent)\n\ndef FACT(value):\n  \"\"\"\n  Returns the factorial of a number.\n\n  >>> FACT(5)\n  120\n  >>> FACT(1.9)\n  1\n  >>> FACT(0)\n  1\n  >>> FACT(1)\n  1\n  >>> FACT(-1)\n  Traceback (most recent call last):\n    ...\n  ValueError: factorial() not defined for negative values\n  \"\"\"\n  return _math.factorial(int(value))\n\ndef FACTDOUBLE(value):\n  \"\"\"\n  Returns the \"double factorial\" of a number.\n\n  >>> FACTDOUBLE(6)\n  48\n  >>> FACTDOUBLE(7)\n  105\n  >>> FACTDOUBLE(3)\n  3\n  >>> FACTDOUBLE(4)\n  8\n  \"\"\"\n  return reduce(operator.mul, range(value, 1, -2))\n\ndef FLOOR(value, factor=1):\n  \"\"\"\n  Rounds a number down to the nearest integer multiple of specified significance.\n\n  >>> FLOOR(3.7,2)\n  2\n  >>> FLOOR(-2.5,-2)\n  -2\n  >>> FLOOR(2.5,-2)\n  Traceback (most recent call last):\n    ...\n  ValueError: factor argument invalid\n  >>> FLOOR(1.58,0.1)\n  1.5\n  >>> FLOOR(0.234,0.01)\n  0.23\n  \"\"\"\n  if (factor < 0) != (value < 0):\n    raise ValueError(\"factor argument invalid\")\n  return int(_math.floor(float(value) / factor)) * factor\n\ndef _gcd(a, b):\n  while a != 0:\n    if a > b:\n      a, b = b, a\n    a, b = b % a, a\n  return b\n\ndef GCD(value1, *more_values):\n  \"\"\"\n  Returns the greatest common divisor of one or more integers.\n\n  >>> GCD(5, 2)\n  1\n  >>> GCD(24, 36)\n  12\n  >>> GCD(7, 1)\n  1\n  >>> GCD(5, 0)\n  5\n  >>> GCD(0, 5)\n  5\n  >>> GCD(5)\n  5\n  >>> GCD(14, 42, 21)\n  7\n  \"\"\"\n  values = [v for v in (value1,) + more_values if v]\n  if not values:\n    return 0\n  if any(v < 0 for v in values):\n    raise ValueError(\"gcd requires non-negative values\")\n  return reduce(_gcd, map(int, values))\n\ndef INT(value):\n  \"\"\"\n  Rounds a number down to the nearest integer that is less than or equal to it.\n\n  >>> INT(8.9)\n  8\n  >>> INT(-8.9)\n  -9\n  >>> 19.5-INT(19.5)\n  0.5\n  \"\"\"\n  return int(_math.floor(value))\n\ndef _lcm(a, b):\n  return a * b // _gcd(a, b)\n\ndef LCM(value1, *more_values):\n  \"\"\"\n  Returns the least common multiple of one or more integers.\n\n  >>> LCM(5, 2)\n  10\n  >>> LCM(24, 36)\n  72\n  >>> LCM(0, 5)\n  0\n  >>> LCM(5)\n  5\n  >>> LCM(10, 100)\n  100\n  >>> LCM(12, 18)\n  36\n  >>> LCM(12, 18, 24)\n  72\n  \"\"\"\n  values = (value1,) + more_values\n  if any(v < 0 for v in values):\n    raise ValueError(\"gcd requires non-negative values\")\n  if any(v == 0 for v in values):\n    return 0\n  return reduce(_lcm, map(int, values))\n\ndef LN(value):\n  \"\"\"\n  Returns the the logarithm of a number, base e (Euler's number).\n\n  >>> round(LN(86), 7)\n  4.4543473\n  >>> round(LN(2.7182818), 7)\n  1.0\n  >>> round(LN(EXP(3)), 10)\n  3.0\n  \"\"\"\n  return _math.log(value)\n\ndef LOG(value, base=10):\n  \"\"\"\n  Returns the the logarithm of a number given a base.\n\n  >>> LOG(10)\n  1.0\n  >>> LOG(8, 2)\n  3.0\n  >>> round(LOG(86, 2.7182818), 7)\n  4.4543473\n  \"\"\"\n  return _math.log(value, base)\n\ndef LOG10(value):\n  \"\"\"\n  Returns the the logarithm of a number, base 10.\n\n  >>> round(LOG10(86), 9)\n  1.934498451\n  >>> LOG10(10)\n  1.0\n  >>> LOG10(100000)\n  5.0\n  >>> LOG10(10**5)\n  5.0\n  \"\"\"\n  return _math.log10(value)\n\ndef MOD(dividend, divisor):\n  \"\"\"\n  Returns the result of the modulo operator, the remainder after a division operation.\n\n  >>> MOD(3, 2)\n  1\n  >>> MOD(-3, 2)\n  1\n  >>> MOD(3, -2)\n  -1\n  >>> MOD(-3, -2)\n  -1\n  \"\"\"\n  return dividend % divisor\n\ndef MROUND(value, factor):\n  \"\"\"\n  Rounds one number to the nearest integer multiple of another.\n\n  >>> MROUND(10, 3)\n  9\n  >>> MROUND(-10, -3)\n  -9\n  >>> round(MROUND(1.3, 0.2), 10)\n  1.4\n  >>> MROUND(5, -2)\n  Traceback (most recent call last):\n    ...\n  ValueError: factor argument invalid\n  \"\"\"\n  if (factor < 0) != (value < 0):\n    raise ValueError(\"factor argument invalid\")\n  return int(_round_toward_zero(float(value) / factor + 0.5)) * factor\n\ndef MULTINOMIAL(value1, *more_values):\n  \"\"\"\n  Returns the factorial of the sum of values divided by the product of the values' factorials.\n\n  >>> MULTINOMIAL(2, 3, 4)\n  1260\n  >>> MULTINOMIAL(3)\n  1\n  >>> MULTINOMIAL(1,2,3)\n  60\n  >>> MULTINOMIAL(0,2,4,6)\n  13860\n  \"\"\"\n  s = value1\n  res = 1\n  for v in more_values:\n    s += v\n    res *= COMBIN(s, v)\n  return res\n\ndef NUM(value):\n  \"\"\"\n  For a Python floating-point value that's actually an integer, returns a Python integer type.\n  Otherwise, returns the value unchanged. This is helpful sometimes when a value comes from a\n  Numeric Grist column (represented as floats), but when int values are actually expected.\n\n  >>> NUM(-17.0)\n  -17\n  >>> NUM(1.5)\n  1.5\n  >>> NUM(4)\n  4\n  >>> NUM(\"NA\")\n  'NA'\n  \"\"\"\n  if isinstance(value, float) and value.is_integer():\n    return int(value)\n  return value\n\ndef ODD(value):\n  \"\"\"\n  Rounds a number up to the nearest odd integer.\n\n  >>> ODD(1.5)\n  3\n  >>> ODD(3)\n  3\n  >>> ODD(2)\n  3\n  >>> ODD(-1)\n  -1\n  >>> ODD(-2)\n  -3\n  \"\"\"\n  return int(_round_away_from_zero(float(value + 1) / 2)) * 2 - 1\n\ndef PI():\n  \"\"\"\n  Returns the value of Pi to 14 decimal places.\n\n  >>> round(PI(), 9)\n  3.141592654\n  >>> round(PI()/2, 9)\n  1.570796327\n  >>> round(PI()*9, 8)\n  28.27433388\n  \"\"\"\n  return _math.pi\n\ndef POWER(base, exponent):\n  \"\"\"\n  Returns a number raised to a power.\n\n  >>> POWER(5,2)\n  25.0\n  >>> round(POWER(98.6,3.2), 3)\n  2401077.222\n  >>> round(POWER(4,5.0/4), 9)\n  5.656854249\n  \"\"\"\n  return _math.pow(base, exponent)\n\n\ndef PRODUCT(factor1, *more_factors):\n  \"\"\"\n  Returns the result of multiplying a series of numbers together. Each argument may be a number or\n  an array.\n\n  >>> PRODUCT([5,15,30])\n  2250\n  >>> PRODUCT([5,15,30], 2)\n  4500\n  >>> PRODUCT(5,15,[30],[2])\n  4500\n\n  More tests:\n  >>> PRODUCT([2, True, None, \"\", False, \"0\", 5])\n  10\n  >>> PRODUCT([2, True, None, \"\", False, 0, 5])\n  0\n  \"\"\"\n  return reduce(operator.mul, _chain_numeric(factor1, *more_factors))\n\ndef QUOTIENT(dividend, divisor):\n  \"\"\"\n  Returns one number divided by another, without the remainder.\n\n  >>> QUOTIENT(5, 2)\n  2\n  >>> QUOTIENT(4.5, 3.1)\n  1\n  >>> QUOTIENT(-10, 3)\n  -3\n  \"\"\"\n  return TRUNC(float(dividend) / divisor)\n\ndef RADIANS(angle):\n  \"\"\"\n  Converts an angle value in degrees to radians.\n\n  >>> round(RADIANS(270), 6)\n  4.712389\n  \"\"\"\n  return _math.radians(angle)\n\ndef RAND():\n  \"\"\"\n  Returns a random number between 0 inclusive and 1 exclusive.\n  \"\"\"\n  return random.random()\n\ndef RANDBETWEEN(low, high):\n  \"\"\"\n  Returns a uniformly random integer between two values, inclusive.\n  \"\"\"\n  return random.randrange(low, high + 1)\n\ndef ROMAN(number, form_unused=None):\n  \"\"\"\n  Formats a number in Roman numerals. The second argument is ignored in this implementation.\n\n  >>> ROMAN(499,0)\n  'CDXCIX'\n  >>> ROMAN(499.2,0)\n  'CDXCIX'\n  >>> ROMAN(57)\n  'LVII'\n  >>> ROMAN(1912)\n  'MCMXII'\n  \"\"\"\n  # TODO: Maybe we should support the second argument.\n  return roman.toRoman(int(number))\n\ndef ROUND(value, places=0):\n  \"\"\"\n  Rounds a number to a certain number of decimal places,\n  by default to the nearest whole number if the number of places is not given.\n\n  Rounds away from zero ('up' for positive numbers)\n  in the case of a tie, i.e. when the last digit is 5.\n\n  >>> ROUND(1.4)\n  1.0\n  >>> ROUND(1.5)\n  2.0\n  >>> ROUND(2.5)\n  3.0\n  >>> ROUND(-2.5)\n  -3.0\n  >>> ROUND(2.15, 1)\n  2.2\n  >>> ROUND(-1.475, 2)\n  -1.48\n  >>> ROUND(21.5, -1)\n  20.0\n  >>> ROUND(626.3,-3)\n  1000.0\n  >>> ROUND(1.98,-1)\n  0.0\n  >>> ROUND(-50.55,-2)\n  -100.0\n  >>> ROUND(0)\n  0.0\n  \"\"\"\n  p = 10 ** places\n  if value >= 0:\n    return float(_math.floor((value * p) + 0.5)) / p\n  else:\n    return float(_math.ceil((value * p) - 0.5)) / p\n\n\ndef ROUNDDOWN(value, places=0):\n  \"\"\"\n  Rounds a number to a certain number of decimal places, always rounding down towards zero.\n\n  >>> ROUNDDOWN(3.2, 0)\n  3\n  >>> ROUNDDOWN(76.9,0)\n  76\n  >>> ROUNDDOWN(3.14159, 3)\n  3.141\n  >>> ROUNDDOWN(-3.14159, 1)\n  -3.1\n  >>> ROUNDDOWN(31415.92654, -2)\n  31400\n  \"\"\"\n  factor = 10**-places\n  return int(_round_toward_zero(float(value) / factor)) * factor\n\ndef ROUNDUP(value, places=0):\n  \"\"\"\n  Rounds a number to a certain number of decimal places, always rounding up away from zero.\n\n  >>> ROUNDUP(3.2,0)\n  4\n  >>> ROUNDUP(76.9,0)\n  77\n  >>> ROUNDUP(3.14159, 3)\n  3.142\n  >>> ROUNDUP(-3.14159, 1)\n  -3.2\n  >>> ROUNDUP(31415.92654, -2)\n  31500\n  \"\"\"\n  factor = 10**-places\n  return int(_round_away_from_zero(float(value) / factor)) * factor\n\ndef SERIESSUM(x, n, m, a):\n  \"\"\"\n  Given parameters x, n, m, and a, returns the power series sum a_1*x^n + a_2*x^(n+m)\n  + ... + a_i*x^(n+(i-1)m), where i is the number of entries in range `a`.\n\n  >>> SERIESSUM(1,0,1,1)\n  1\n  >>> SERIESSUM(2,1,0,[1,2,3])\n  12\n  >>> SERIESSUM(-3,1,1,[2,4,6])\n  -132\n  >>> round(SERIESSUM(PI()/4,0,2,[1,-1./FACT(2),1./FACT(4),-1./FACT(6)]), 6)\n  0.707103\n  \"\"\"\n  return sum(coef*pow(x, n+i*m) for i, coef in enumerate(_chain(a)))\n\ndef SIGN(value):\n  \"\"\"\n  Given an input number, returns `-1` if it is negative, `1` if positive, and `0` if it is zero.\n\n  >>> SIGN(10)\n  1\n  >>> SIGN(4.0-4.0)\n  0\n  >>> SIGN(-0.00001)\n  -1\n  \"\"\"\n  return 0 if value == 0 else int(_math.copysign(1, value))\n\ndef SIN(angle):\n  \"\"\"\n  Returns the sine of an angle provided in radians.\n\n  >>> round(SIN(PI()), 10)\n  0.0\n  >>> SIN(PI()/2)\n  1.0\n  >>> round(SIN(30*PI()/180), 10)\n  0.5\n  >>> round(SIN(RADIANS(30)), 10)\n  0.5\n  \"\"\"\n  return _math.sin(angle)\n\ndef SINH(value):\n  \"\"\"\n  Returns the hyperbolic sine of any real number.\n\n  >>> round(2.868*SINH(0.0342*1.03), 7)\n  0.1010491\n  \"\"\"\n  return _math.sinh(value)\n\ndef SQRT(value):\n  \"\"\"\n  Returns the positive square root of a positive number.\n\n  >>> SQRT(16)\n  4.0\n  >>> SQRT(-16)\n  Traceback (most recent call last):\n    ...\n  ValueError: math domain error\n  >>> SQRT(ABS(-16))\n  4.0\n  \"\"\"\n  return _math.sqrt(value)\n\n\ndef SQRTPI(value):\n  \"\"\"\n  Returns the positive square root of the product of Pi and the given positive number.\n\n  >>> round(SQRTPI(1), 6)\n  1.772454\n  >>> round(SQRTPI(2), 6)\n  2.506628\n  \"\"\"\n  return _math.sqrt(_math.pi * value)\n\n@unimplemented\ndef SUBTOTAL(function_code, range1, range2):\n  \"\"\"\n  Returns a subtotal for a vertical range of cells using a specified aggregation function.\n  \"\"\"\n  raise NotImplementedError()\n\n\ndef SUM(value1, *more_values):\n  \"\"\"\n  Returns the sum of a series of numbers. Each argument may be a number or an array.\n  Non-numeric values are ignored.\n\n  >>> SUM([5,15,30])\n  50\n  >>> SUM([5.,15,30], 2)\n  52.0\n  >>> SUM(5,15,[30],[2])\n  52\n\n  More tests:\n  >>> SUM([10.25, None, \"\", False, \"other\", 20.5])\n  30.75\n  >>> SUM([True, \"3\", 4], True)\n  6\n  \"\"\"\n  return sum(_chain_numeric_a(value1, *more_values))\n\n\n@unimplemented\ndef SUMIF(records, criterion, sum_range):\n  \"\"\"\n  Returns a conditional sum across a range.\n  \"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef SUMIFS(sum_range, criteria_range1, criterion1, *args):\n  \"\"\"\n  Returns the sum of a range depending on multiple criteria.\n  \"\"\"\n  raise NotImplementedError()\n\ndef SUMPRODUCT(array1, *more_arrays):\n  \"\"\"\n  Multiplies corresponding components in two equally-sized arrays,\n  and returns the sum of those products.\n\n  >>> SUMPRODUCT([3,8,1,4,6,9], [2,6,5,7,7,3])\n  156\n  >>> SUMPRODUCT([], [], [])\n  0\n  >>> SUMPRODUCT([-0.25], [-2], [-3])\n  -1.5\n  >>> SUMPRODUCT([-0.25, -0.25], [-2, -2], [-3, -3])\n  -3.0\n  \"\"\"\n  return sum(reduce(operator.mul, values) for values in zip(array1, *more_arrays))\n\n@unimplemented\ndef SUMSQ(value1, value2):\n  \"\"\"\n  Returns the sum of the squares of a series of numbers and/or cells.\n  \"\"\"\n  raise NotImplementedError()\n\ndef TAN(angle):\n  \"\"\"\n  Returns the tangent of an angle provided in radians.\n\n  >>> round(TAN(0.785), 8)\n  0.99920399\n  >>> round(TAN(45*PI()/180), 10)\n  1.0\n  >>> round(TAN(RADIANS(45)), 10)\n  1.0\n  \"\"\"\n  return _math.tan(angle)\n\ndef TANH(value):\n  \"\"\"\n  Returns the hyperbolic tangent of any real number.\n\n  >>> round(TANH(-2), 6)\n  -0.964028\n  >>> TANH(0)\n  0.0\n  >>> round(TANH(0.5), 6)\n  0.462117\n  \"\"\"\n  return _math.tanh(value)\n\ndef TRUNC(value, places=0):\n  \"\"\"\n  Truncates a number to a certain number of significant digits by omitting less significant\n  digits.\n\n  >>> TRUNC(8.9)\n  8\n  >>> TRUNC(-8.9)\n  -8\n  >>> TRUNC(0.45)\n  0\n  \"\"\"\n  # TRUNC seems indistinguishable from ROUNDDOWN.\n  return ROUNDDOWN(value, places)\n\ndef UUID():\n  \"\"\"\n  Generate a random UUID-formatted string identifier.\n\n  Since UUID() produces a different value each time it's called, it is best to use it in\n  [trigger formula](formulas.md#trigger-formulas) for new records.\n  This would only calculate UUID() once and freeze the calculated value. By contrast, a regular\n  formula may get recalculated any time the document is reloaded, producing a different value for\n  UUID() each time.\n  \"\"\"\n  try:\n    uid = uuid.uuid4()\n  except Exception:\n    # Pynbox doesn't support the above because it doesn't support `os.urandom()`.\n    # Using the `random` module is less secure but should be OK.\n    byts = bytes([random.randrange(0, 256) for _ in range(0, 16)])\n    uid = uuid.UUID(bytes=byts, version=4)\n  return str(uid)\n"
  },
  {
    "path": "sandbox/grist/functions/prevnext.py",
    "content": "def PREVIOUS(rec, *, group_by=(), order_by):\n  \"\"\"\n  Finds the previous record in the table according to the order specified by `order_by`, and\n  grouping specified by `group_by`. Each of these arguments may be a column ID or a tuple of\n  column IDs, and `order_by` allows column IDs to be prefixed with \"-\" to reverse sort order.\n\n  For example,\n  ```python\n  PREVIOUS(rec, order_by=\"Date\")    # The previous record when sorted by increasing Date.\n  PREVIOUS(rec, order_by=\"-Date\")   # The previous record when sorted by decreasing Date.\n  ```\n\n  You may use `group_by` to search for the previous record within a filtered group. For example,\n  this finds the previous record with the same Account as `rec`, when records are filtered by the\n  Account of `rec` and sorted by increasing Date:\n  ```python\n  PREVIOUS(rec, group_by=\"Account\", order_by=\"Date\")\n  ```\n\n  When multiple records have the same `order_by` values (e.g. the same Date in the examples above),\n  the order is determined by the relative position of rows in views. This is done internally by\n  falling back to the special column `manualSort` and the row ID column `id`.\n\n  Use `order_by=None` to find the previous record in an unsorted table (when rows may be\n  rearranged by dragging them manually). For example:\n  ```python\n  PREVIOUS(rec, order_by=None)      # The previous record in the unsorted list of records.\n  ```\n\n  You may specify multiple column IDs as a tuple, for both `group_by` and `order_by`. This can be\n  used to match views sorted by multiple columns. For example:\n  ```python\n  PREVIOUS(rec, group_by=(\"Account\", \"Year\"), order_by=(\"Date\", \"-Amount\"))\n  ```\n  \"\"\"\n  return _sorted_lookup(rec, group_by=group_by, order_by=order_by)._find.previous(rec)\n\ndef NEXT(rec, *, group_by=(), order_by):\n  \"\"\"\n  Finds the next record in the table according to the order specified by `order_by`, and\n  grouping specified by `group_by`. See [`PREVIOUS`](#previous) for details.\n  \"\"\"\n  return _sorted_lookup(rec, group_by=group_by, order_by=order_by)._find.next(rec)\n\ndef RANK(rec, *, group_by=(), order_by, order=\"asc\"):\n  \"\"\"\n  Returns the rank (or position) of this record in the table according to the order specified by\n  `order_by`, and grouping specified by `group_by`. See [`PREVIOUS`](#previous) for details of\n  these parameters.\n\n  The `order` parameter may be `\"asc\"` (which is the default) or `\"desc\"`.\n\n  When `order` is `\"asc\"` or omitted, the first record in the group in the sorted order would have\n  the rank of 1. When `order` is `\"desc\"`, the last record in the sorted order would have the rank\n  of 1.\n\n  If there are multiple groups, there will be multiple records with the same rank. In particular,\n  each group will have a record with rank 1.\n\n  For example, `RANK(rec, group_by=\"Year\", order_by=\"Score\", order=\"desc\")` will return the rank of\n  the current record (`rec`) among all the records in its table for the same year, ordered by\n  decreasing score.\n  \"\"\"\n  return _sorted_lookup(rec, group_by=group_by, order_by=order_by)._find.rank(rec, order=order)\n\n\ndef _sorted_lookup(rec, *, group_by, order_by):\n  if isinstance(group_by, str):\n    group_by = (group_by,)\n  return rec._table.lookup_records(**{c: getattr(rec, c) for c in group_by}, order_by=order_by)\n"
  },
  {
    "path": "sandbox/grist/functions/schedule.py",
    "content": "from datetime import datetime, timedelta\nimport re\nfrom .date import DATEADD, NOW, DTIME\n\n# Limit exports to schedule, so that upper-case constants like MONTH_NAMES, DAY_NAMES don't end up\n# exposed as if Excel-style functions (or break docs generation).\n__all__ = ['SCHEDULE']\n\nMONTH_NAMES = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august',\n  'september', 'october', 'november', 'december']\n# Regex list of lowercase weekdays with characters after the first three made optional\nDAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']\n\ndef SCHEDULE(schedule, start=None, count=10, end=None):\n  \"\"\"\n  Returns the list of `datetime` objects generated according to the `schedule` string. Starts at\n  `start`, which defaults to NOW(). Generates at most `count` results (10 by default). If `end` is\n  given, stops there.\n\n  The schedule has the format \"INTERVAL: SLOTS, ...\". For example:\n\n      annual: Jan-15, Apr-15, Jul-15  -- Three times a year on given dates at midnight.\n      annual: 1/15, 4/15, 7/15        -- Same as above.\n      monthly: /1 2pm, /15 2pm        -- The 1st and the 15th of each month, at 2pm.\n      3-months: /10, +1m /20           -- Every 3 months on the 10th of month 1, 20th of month 2.\n      weekly: Mo 9am, Tu 9am, Fr 2pm  -- Three times a week at specified times.\n      2-weeks: Mo, +1w Tu             -- Every 2 weeks on Monday of week 1, Tuesday of week 2.\n      daily: 07:30, 21:00             -- Twice a day at specified times.\n      2-day: 12am, 4pm, +1d 8am       -- Three times every two days, evenly spaced.\n      hourly: :15, :45                -- 15 minutes before and after each hour.\n      4-hour: :00, 1:20, 2:40         -- Three times every 4 hours, evenly spaced.\n      10-minute: +0s                  -- Every 10 minutes on the minute.\n\n  INTERVAL must be either of the form `N-unit` where `N` is a number and `unit` is one of `year`,\n  `month`, `week`, `day`, `hour`; or one of the aliases: `annual`, `monthly`, `weekly`, `daily`,\n  `hourly`, which mean `1-year`, `1-month`, etc.\n\n  SLOTS support the following units:\n\n      `Jan-15` or `1/15`    -- Month and day of the month; available when INTERVAL is year-based.\n      `/15`                 -- Day of the month, available when INTERVAL is month-based.\n      `Mon`, `Mo`, `Friday` -- Day of the week (or abbreviation), when INTERVAL is week-based.\n      10am, 1:30pm, 15:45   -- Time of day, available for day-based or longer intervals.\n      :45, :00              -- Minutes of the hour, available when INTERVAL is hour-based.\n      +1d, +15d             -- How many days to add to start of INTERVAL.\n      +1w                   -- How many weeks to add to start of INTERVAL.\n      +1m                   -- How many months to add to start of INTERVAL.\n\n  The SLOTS are always relative to the INTERVAL rather than to `start`. Week-based intervals start\n  on Sunday. E.g. `weekly: +1d, +4d` is the same as `weekly: Mon, Thu`, and generates times on\n  Mondays and Thursdays regardless of `start`.\n\n  The first generated time is determined by the *unit* of the INTERVAL without regard to the\n  multiple. E.g. both \"2-week: Mon\" and \"3-week: Mon\" start on the first Monday after `start`, and\n  then generate either every second or every third Monday after that. Similarly, `24-hour: :00`\n  starts with the first top-of-the-hour after `start` (not with midnight), and then repeats every\n  24 hours. To start with the midnight after `start`, use `daily: 0:00`.\n\n  For interval units of a day or longer, if time-of-day is not specified, it defaults to midnight.\n\n  The time zone of `start` determines the time zone of the generated times.\n\n  >>> def show(dates): return [d.strftime(\"%Y-%m-%d %H:%M\") for d in dates]\n  >>> start = datetime(2018, 9, 4, 14, 0);   # 2pm on Tue, Sep 4 2018.\n\n  >>> show(SCHEDULE('annual: Jan-15, Apr-15, Jul-15, Oct-15', start=start, count=4))\n  ['2018-10-15 00:00', '2019-01-15 00:00', '2019-04-15 00:00', '2019-07-15 00:00']\n\n  >>> show(SCHEDULE('annual: 1/15, 4/15, 7/15', start=start, count=4))\n  ['2019-01-15 00:00', '2019-04-15 00:00', '2019-07-15 00:00', '2020-01-15 00:00']\n\n  >>> show(SCHEDULE('monthly: /1 2pm, /15 5pm', start=start, count=4))\n  ['2018-09-15 17:00', '2018-10-01 14:00', '2018-10-15 17:00', '2018-11-01 14:00']\n\n  >>> show(SCHEDULE('3-months: /10, +1m /20', start=start, count=4))\n  ['2018-09-10 00:00', '2018-10-20 00:00', '2018-12-10 00:00', '2019-01-20 00:00']\n\n  >>> show(SCHEDULE('weekly: Mo 9am, Tu 9am, Fr 2pm', start=start, count=4))\n  ['2018-09-07 14:00', '2018-09-10 09:00', '2018-09-11 09:00', '2018-09-14 14:00']\n\n  >>> show(SCHEDULE('2-weeks: Mo, +1w Tu', start=start, count=4))\n  ['2018-09-11 00:00', '2018-09-17 00:00', '2018-09-25 00:00', '2018-10-01 00:00']\n\n  >>> show(SCHEDULE('daily: 07:30, 21:00', start=start, count=4))\n  ['2018-09-04 21:00', '2018-09-05 07:30', '2018-09-05 21:00', '2018-09-06 07:30']\n\n  >>> show(SCHEDULE('2-day: 12am, 4pm, +1d 8am', start=start, count=4))\n  ['2018-09-04 16:00', '2018-09-05 08:00', '2018-09-06 00:00', '2018-09-06 16:00']\n\n  >>> show(SCHEDULE('hourly: :15, :45', start=start, count=4))\n  ['2018-09-04 14:15', '2018-09-04 14:45', '2018-09-04 15:15', '2018-09-04 15:45']\n\n  >>> show(SCHEDULE('4-hour: :00, +1H :20, +2H :40', start=start, count=4))\n  ['2018-09-04 14:00', '2018-09-04 15:20', '2018-09-04 16:40', '2018-09-04 18:00']\n  \"\"\"\n  return Schedule(schedule).series(start or NOW(), end, count=count)\n\nclass Delta(object):\n  \"\"\"\n  Similar to timedelta, keeps intervals by unit. Specifically, this is needed for months\n  and years, since those can't be represented exactly with a timedelta.\n  \"\"\"\n  def __init__(self):\n    self._timedelta = timedelta(0)\n    self._months = 0\n\n  def add_interval(self, number, unit):\n    if unit == 'months':\n      self._months += number\n    elif unit == 'years':\n      self._months += number * 12\n    else:\n      self._timedelta += timedelta(**{unit: number})\n    return self\n\n  def add_to(self, dtime):\n    return datetime.combine(DATEADD(dtime, months=self._months), dtime.timetz()) + self._timedelta\n\n\nclass Schedule(object):\n  \"\"\"\n  Schedule parses a schedule spec into an interval and slots in the constructor. Then the series()\n  method applies it to any start/end dates.\n  \"\"\"\n  def __init__(self, spec_string):\n    parts = spec_string.split(\":\", 1)\n    if len(parts) != 2:\n      raise ValueError(\"schedule must have the form INTERVAL: SLOTS, ...\")\n\n    count, unit = _parse_interval(parts[0].strip())\n    self._interval_unit = unit\n    self._interval = Delta().add_interval(count, unit)\n    self._slots = [_parse_slot(t, self._interval_unit) for t in parts[1].split(\",\")]\n\n  def series(self, start_dtime, end_dtime, count=10):\n    # Start with a preceding unit boundary, then check the slots within that unit and start with\n    # the first one that's at start_dtime or later.\n    start_dtime = DTIME(start_dtime)\n    end_dtime = end_dtime and DTIME(end_dtime)\n    dtime = _round_down_to_unit(start_dtime, self._interval_unit)\n    while True:\n      for slot in self._slots:\n        if count <= 0:\n          return\n        out = slot.add_to(dtime)\n        if out < start_dtime:\n          continue\n        if end_dtime is not None and out > end_dtime:\n          return\n        yield out\n        count -= 1\n      dtime = self._interval.add_to(dtime)\n\ndef _fail(message):\n  raise ValueError(message)\n\ndef _round_down_to_unit(dtime, unit):\n  \"\"\"\n  Rounds datetime down to the given unit. Weeks are rounded to start of Sunday.\n  \"\"\"\n  tz = dtime.tzinfo\n  return ( datetime(dtime.year, 1, 1, tzinfo=tz)                               if unit == 'years'\n      else datetime(dtime.year, dtime.month, 1, tzinfo=tz)                     if unit == 'months'\n      else (dtime - timedelta(days=dtime.isoweekday() % 7))\n           .replace(hour=0, minute=0, second=0, microsecond=0)                 if unit == 'weeks'\n      else dtime.replace(hour=0, minute=0, second=0, microsecond=0)            if unit == 'days'\n      else dtime.replace(minute=0, second=0, microsecond=0)                    if unit == 'hours'\n      else dtime.replace(second=0, microsecond=0)                              if unit == 'minutes'\n      else dtime.replace(microsecond=0)                                        if unit == 'seconds'\n      else _fail(\"Invalid unit %s\" % unit)\n  )\n\n_UNITS = ('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds')\n_VALID_UNITS = set(_UNITS)\n_SINGULAR_UNITS = dict(zip(('year', 'month', 'week', 'day', 'hour', 'minute', 'second'), _UNITS))\n_SHORT_UNITS = dict(zip(('y', 'm', 'w', 'd', 'H', 'M', 'S'), _UNITS))\n\n_INTERVAL_ALIASES = {\n  'annual':   (1, 'years'),\n  'monthly':  (1, 'months'),\n  'weekly':   (1, 'weeks'),\n  'daily':    (1, 'days'),\n  'hourly':   (1, 'hours'),\n}\n\n_INTERVAL_RE = re.compile(r'^(?P<num>\\d+)[-\\s]+(?P<unit>[a-z]+)$', re.I)\n\n# Maps weekday names, including 2- and 3-letter abbreviations, to numbers 0 through 6.\nWEEKDAY_OFFSETS = {}\nfor (i, name) in enumerate(DAY_NAMES):\n  WEEKDAY_OFFSETS[name] = i\n  WEEKDAY_OFFSETS[name[:3]] = i\n  WEEKDAY_OFFSETS[name[:2]] = i\n\n# Maps month names, including 3-letter abbreviations, to numbers 0 through 11.\nMONTH_OFFSETS = {}\nfor (i, name) in enumerate(MONTH_NAMES):\n  MONTH_OFFSETS[name] = i\n  MONTH_OFFSETS[name[:3]] = i\n\n\ndef _parse_interval(interval_str):\n  \"\"\"\n  Given a spec like \"daily\" or \"3-week\", returns (N, unit), such as (1, \"days\") or (3, \"weeks\").\n  \"\"\"\n  interval_str = interval_str.lower()\n  if interval_str in _INTERVAL_ALIASES:\n    return _INTERVAL_ALIASES[interval_str]\n\n  m = _INTERVAL_RE.match(interval_str)\n  if not m:\n    raise ValueError(\"Not a valid interval '%s'\" % interval_str)\n  num = int(m.group(\"num\"))\n  unit = m.group(\"unit\")\n  unit = _SINGULAR_UNITS.get(unit, unit)\n  if unit not in _VALID_UNITS:\n    raise ValueError(\"Unknown unit '%s' in interval '%s'\" % (unit, interval_str))\n  return (num, unit)\n\n\ndef _parse_slot(slot_str, parent_unit):\n  \"\"\"\n  Parses a slot in one of several recognized formats. Allowed formats depend on parent_unit, e.g.\n  'Jan-15' is valid when parent_unit is 'years', but not when it is 'hours'. We also disallow\n  using the same unit more than once, which is confusing, e.g. \"+1d +2d\" or \"9:30am +2H\".\n  Returns a Delta object.\n  \"\"\"\n  parts = slot_str.split()\n  if not parts:\n    raise ValueError(\"At least one slot must be specified\")\n\n  delta = Delta()\n  seen_units = set()\n  allowed_slot_types = _ALLOWED_SLOTS_BY_UNIT.get(parent_unit) or ('delta',)\n\n  # Slot parts go through parts like \"Jan-15 16pm\", collecting the offsets into a single Delta.\n  for part in parts:\n    m = _SLOT_RE.match(part)\n    if not m:\n      raise ValueError(\"Invalid slot '%s'\" % part)\n    for slot_type in allowed_slot_types:\n      if m.group(slot_type):\n        # If there is a group for one slot type, that's the only group. We find and use the\n        # corresponding parser, then move on to the next slot part.\n        for count, unit in _SLOT_PARSERS[slot_type](m):\n          delta.add_interval(count, unit)\n          if unit in seen_units:\n            raise ValueError(\"Duplicate unit %s in '%s'\" % (unit, slot_str))\n          seen_units.add(unit)\n        break\n    else:\n      # If none of the allowed slot types was found, it must be a disallowed one.\n      raise ValueError(\"Invalid slot '%s' for unit '%s'\" % (part, parent_unit))\n  return delta\n\n# We parse all slot types using one big regex. The constants below define one part of the regex\n# for each slot type (e.g. to match \"Jan-15\" or \"5:30am\" or \"+1d\"). Note that all group names\n# (defined with (?P<NAME>...)) must be distinct.\n_DATE_RE = r'(?:(?P<month_name>[a-z]+)-|(?P<month_num>\\d+)/)(?P<month_day>\\d+)'\n_MDAY_RE = r'/(?P<month_day2>\\d+)'\n_WDAY_RE = r'(?P<weekday>[a-z]+)'\n_TIME_RE = r'(?P<hours>\\d+)(?:\\:(?P<minutes>\\d{2})(?P<ampm1>am|pm)?|(?P<ampm2>am|pm))'\n_MINS_RE = r':(?P<minutes2>\\d{2})'\n_DELTA_RE = r'\\+(?P<count>\\d+)(?P<unit>[a-z]+)'\n\n# The regex parts are combined and compiled here. Only one group will match, corresponding to one\n# slot type. Different slot types depend on the unit of the overall interval.\n_SLOT_RE = re.compile(\n    r'^(?:(?P<date>%s)|(?P<mday>%s)|(?P<wday>%s)|(?P<time>%s)|(?P<mins>%s)|(?P<delta>%s))$' %\n    (_DATE_RE, _MDAY_RE, _WDAY_RE, _TIME_RE, _MINS_RE, _DELTA_RE), re.IGNORECASE)\n\n# Slot types that make sense for each unit of overall interval. If not listed (e.g. \"minutes\")\n# then only \"delta\" slot type is allowed.\n_ALLOWED_SLOTS_BY_UNIT = {\n  'years': ('date', 'time', 'delta'),\n  'months': ('mday', 'time', 'delta'),\n  'weeks': ('wday', 'time', 'delta'),\n  'days': ('time', 'delta'),\n  'hours': ('mins', 'delta'),\n}\n\n# The helper methods below parse one slot type each, given a regex match that matched that slot\n# type. These are combined and used via the _SLOT_PARSERS dict below.\ndef _parse_slot_date(m):\n  mday = int(m.group(\"month_day\"))\n  month_name = m.group(\"month_name\")\n  month_num = m.group(\"month_num\")\n  if month_name:\n    name = month_name.lower()\n    if name not in MONTH_OFFSETS:\n      raise ValueError(\"Unknown month '%s'\" % month_name)\n    mnum = MONTH_OFFSETS[name]\n  else:\n    mnum = int(month_num) - 1\n  return [(mnum, 'months'), (mday - 1, 'days')]\n\ndef _parse_slot_mday(m):\n  mday = int(m.group(\"month_day2\"))\n  return [(mday - 1, 'days')]\n\ndef _parse_slot_wday(m):\n  wday = m.group(\"weekday\").lower()\n  if wday not in WEEKDAY_OFFSETS:\n    raise ValueError(\"Unknown day of the week '%s'\" % wday)\n  return [(WEEKDAY_OFFSETS[wday], \"days\")]\n\ndef _parse_slot_time(m):\n  hours = int(m.group(\"hours\"))\n  minutes = int(m.group(\"minutes\") or 0)\n  ampm = m.group(\"ampm1\") or m.group(\"ampm2\")\n  if ampm:\n    hours = (hours % 12) + (12 if ampm.lower() == \"pm\" else 0)\n  return [(hours, 'hours'), (minutes, 'minutes')]\n\ndef _parse_slot_mins(m):\n  minutes = int(m.group(\"minutes2\"))\n  return [(minutes, 'minutes')]\n\ndef _parse_slot_delta(m):\n  count = int(m.group(\"count\"))\n  unit = m.group(\"unit\")\n  if unit not in _SHORT_UNITS:\n    raise ValueError(\"Unknown unit '%s' in interval '%s'\" % (unit, m.group()))\n  return [(count, _SHORT_UNITS[unit])]\n\n_SLOT_PARSERS = {\n  'date': _parse_slot_date,\n  'mday': _parse_slot_mday,\n  'wday': _parse_slot_wday,\n  'time': _parse_slot_time,\n  'mins': _parse_slot_mins,\n  'delta': _parse_slot_delta,\n}\n"
  },
  {
    "path": "sandbox/grist/functions/stats.py",
    "content": "# pylint: disable=redefined-builtin, line-too-long, unused-argument\nimport datetime\n\nfrom .math import _chain, _chain_numeric, _chain_numeric_a, _chain_numeric_or_date\nfrom .info import ISNUMBER, ISLOGICAL\nfrom .date import DATE, DTIME       # pylint: disable=unused-import\nfrom .unimplemented import unimplemented\n\ndef _average(iterable):\n  total, count = 0.0, 0\n  for value in iterable:\n    total += value\n    count += 1\n  return total / count\n\ndef _default_if_empty(iterable, default):\n  \"\"\"\n  Yields all values from iterable, except when it is empty, yields just the single default value.\n  \"\"\"\n  empty = True\n  for value in iterable:\n    empty = False\n    yield value\n  if empty:\n    yield default\n\n\n@unimplemented\ndef AVEDEV(value1, value2):\n  \"\"\"Calculates the average of the magnitudes of deviations of data from a dataset's mean.\"\"\"\n  raise NotImplementedError()\n\n\ndef AVERAGE(value, *more_values):\n  \"\"\"\n  Returns the numerical average value in a dataset, ignoring non-numerical values.\n\n  Each argument may be a value or an array. Values that are not numbers, including logical\n  and blank values, and text representations of numbers, are ignored.\n\n  >>> AVERAGE([2, -1.0, 11])\n  4.0\n  >>> AVERAGE([2, -1, 11, \"Hello\"])\n  4.0\n  >>> AVERAGE([2, -1, \"Hello\", DATE(2015,1,1)], True, [False, \"123\", \"\", 11])\n  4.0\n  >>> AVERAGE(False, True)\n  Traceback (most recent call last):\n    ...\n  ZeroDivisionError: float division by zero\n  \"\"\"\n  return _average(_chain_numeric(value, *more_values))\n\n\ndef AVERAGEA(value, *more_values):\n  \"\"\"\n  Returns the numerical average value in a dataset, counting non-numerical values as 0.\n\n  Each argument may be a value of an array. Values that are not numbers, including dates and text\n  representations of numbers, are counted as 0 (zero). Logical value of True is counted as 1, and\n  False as 0.\n\n  >>> AVERAGEA([2, -1.0, 11])\n  4.0\n  >>> AVERAGEA([2, -1, 11, \"Hello\"])\n  3.0\n  >>> AVERAGEA([2, -1, \"Hello\", DATE(2015,1,1)], True, [False, \"123\", \"\", 11.5])\n  1.5\n  >>> AVERAGEA(False, True)\n  0.5\n  \"\"\"\n  return _average(_chain_numeric_a(value, *more_values))\n\n# Note that Google Sheets offers a similar function, called AVERAGE.WEIGHTED\n# (https://support.google.com/docs/answer/9084098?hl=en)\ndef AVERAGE_WEIGHTED(pairs):\n  \"\"\"\n  Given a list of (value, weight) pairs, finds the average of the values weighted by the\n  corresponding weights. Ignores any pairs with a non-numerical value or weight.\n\n  If you have two lists, of values and weights, use the Python built-in zip() function to create a\n  list of pairs.\n\n  >>> AVERAGE_WEIGHTED(((95, .25), (90, .1), (\"X\", .5), (85, .15), (88, .2), (82, .3), (70, None)))\n  87.7\n  >>> AVERAGE_WEIGHTED(zip([95, 90, \"X\", 85, 88, 82, 70], [25, 10, 50, 15, 20, 30, None]))\n  87.7\n  >>> AVERAGE_WEIGHTED(zip([95, 90, False, 85, 88, 82, 70], [.25, .1, .5, .15, .2, .3, True]))\n  87.7\n  \"\"\"\n  sum_value, sum_weight = 0.0, 0.0\n  for value, weight in pairs:\n    # The type-checking here is the same as used by _chain_numeric.\n    if ISNUMBER(value) and not ISLOGICAL(value) and ISNUMBER(weight) and not ISLOGICAL(weight):\n      sum_value += value * weight\n      sum_weight += weight\n  return sum_value / sum_weight\n\n\n@unimplemented\ndef AVERAGEIF(criteria_range, criterion, average_range=None):\n  \"\"\"Returns the average of a range depending on criteria.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef AVERAGEIFS(average_range, criteria_range1, criterion1, *args):\n  \"\"\"Returns the average of a range depending on multiple criteria.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef BINOMDIST(num_successes, num_trials, prob_success, cumulative):\n  \"\"\"\n  Calculates the probability of drawing a certain number of successes (or a maximum number of\n  successes) in a certain number of tries given a population of a certain size containing a\n  certain number of successes, with replacement of draws.\n  \"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef CONFIDENCE(alpha, standard_deviation, pop_size):\n  \"\"\"Calculates the width of half the confidence interval for a normal distribution.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef CORREL(data_y, data_x):\n  \"\"\"Calculates r, the Pearson product-moment correlation coefficient of a dataset.\"\"\"\n  raise NotImplementedError()\n\ndef COUNT(value, *more_values):\n  \"\"\"\n  Returns the count of numerical and date/datetime values in a dataset,\n  ignoring other types of values.\n\n  Each argument may be a value or an array. Values that are not numbers or dates, including logical\n  and blank values, and text representations of numbers, are ignored.\n\n  >>> COUNT([2, -1.0, 11])\n  3\n  >>> COUNT([2, -1, 11, \"Hello\"])\n  3\n  >>> COUNT([DATE(2000, 1, 1), DATE(2000, 1, 2), DATE(2000, 1, 3), \"Hello\"])\n  3\n  >>> COUNT([2, -1, \"Hello\", DATE(2015,1,1)], True, [False, \"123\", \"\", 11.5])\n  4\n  >>> COUNT(False, True)\n  0\n  \"\"\"\n  return sum(1 for _ in _chain_numeric_or_date(value, *more_values))\n\n\ndef COUNTA(value, *more_values):\n  \"\"\"\n  Returns the count of all values in a dataset, including non-numerical values.\n\n  Each argument may be a value or an array.\n\n  >>> COUNTA([2, -1.0, 11])\n  3\n  >>> COUNTA([2, -1, 11, \"Hello\"])\n  4\n  >>> COUNTA([2, -1, \"Hello\", DATE(2015,1,1)], True, [False, \"123\", \"\", 11.5])\n  9\n  >>> COUNTA(False, True)\n  2\n  \"\"\"\n  return sum(1 for _ in _chain(value, *more_values))\n\n\n@unimplemented\ndef COVAR(data_y, data_x):\n  \"\"\"Calculates the covariance of a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef CRITBINOM(num_trials, prob_success, target_prob):\n  \"\"\"Calculates the smallest value for which the cumulative binomial distribution is greater than or equal to a specified criteria.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef DEVSQ(value1, value2):\n  \"\"\"Calculates the sum of squares of deviations based on a sample.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef EXPONDIST(x, lambda_, cumulative):\n  \"\"\"Returns the value of the exponential distribution function with a specified lambda at a specified value.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef F_DIST(x, degrees_freedom1, degrees_freedom2, cumulative):\n  \"\"\"\n  Calculates the left-tailed F probability distribution (degree of diversity) for two data sets\n  with given input x. Alternately called Fisher-Snedecor distribution or Snedecor's F\n  distribution.\n  \"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef F_DIST_RT(x, degrees_freedom1, degrees_freedom2):\n  \"\"\"\n  Calculates the right-tailed F probability distribution (degree of diversity) for two data sets\n  with given input x. Alternately called Fisher-Snedecor distribution or Snedecor's F\n  distribution.\n  \"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef FDIST(x, degrees_freedom1, degrees_freedom2):\n  \"\"\"\n  Calculates the right-tailed F probability distribution (degree of diversity) for two data sets\n  with given input x. Alternately called Fisher-Snedecor distribution or Snedecor's F\n  distribution.\n  \"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef FISHER(value):\n  \"\"\"Returns the Fisher transformation of a specified value.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef FISHERINV(value):\n  \"\"\"Returns the inverse Fisher transformation of a specified value.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef FORECAST(x, data_y, data_x):\n  \"\"\"Calculates the expected y-value for a specified x based on a linear regression of a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef GEOMEAN(value1, value2):\n  \"\"\"Calculates the geometric mean of a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef HARMEAN(value1, value2):\n  \"\"\"Calculates the harmonic mean of a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef HYPGEOMDIST(num_successes, num_draws, successes_in_pop, pop_size):\n  \"\"\"Calculates the probability of drawing a certain number of successes in a certain number of tries given a population of a certain size containing a certain number of successes, without replacement of draws.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef INTERCEPT(data_y, data_x):\n  \"\"\"Calculates the y-value at which the line resulting from linear regression of a dataset will intersect the y-axis (x=0).\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef KURT(value1, value2):\n  \"\"\"Calculates the kurtosis of a dataset, which describes the shape, and in particular the \"peakedness\" of that dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef LARGE(data, n):\n  \"\"\"Returns the nth largest element from a data set, where n is user-defined.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef LOGINV(x, mean, standard_deviation):\n  \"\"\"Returns the value of the inverse log-normal cumulative distribution with given mean and standard deviation at a specified value.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef LOGNORMDIST(x, mean, standard_deviation):\n  \"\"\"Returns the value of the log-normal cumulative distribution with given mean and standard deviation at a specified value.\"\"\"\n  raise NotImplementedError()\n\n\ndef MAX(value, *more_values):\n  \"\"\"\n  Returns the maximum value in a dataset, ignoring values other than numbers and dates/datetimes.\n\n  Each argument may be a value or an array. Values that are not numbers or dates, including logical\n  and blank values, and text representations of numbers, are ignored. Returns 0 if the arguments\n  contain no numbers or dates.\n\n  >>> MAX([2, -1.5, 11.5])\n  11.5\n  >>> MAX([2, -1.5, \"Hello\"], True, [False, \"123\", \"\", 11.5])\n  11.5\n  >>> MAX(True, -123)\n  -123\n  >>> MAX(\"123\", -123)\n  -123\n  >>> MAX(\"Hello\", \"123\", True, False)\n  0\n  >>> MAX(DATE(2015, 1, 1), DATE(2015, 1, 2))\n  datetime.date(2015, 1, 2)\n  >>> MAX(DATE(2015, 1, 1), datetime.datetime(2015, 1, 1, 12, 34, 56))\n  datetime.datetime(2015, 1, 1, 12, 34, 56)\n  >>> MAX(DATE(2015, 1, 2), datetime.datetime(2015, 1, 1, 12, 34, 56))\n  datetime.date(2015, 1, 2)\n  \"\"\"\n  values = _default_if_empty(_chain_numeric_or_date(value, *more_values), 0)\n  return max(values, key=_compare_date_datetime_key)\n\n\ndef MAXA(value, *more_values):\n  \"\"\"\n  Returns the maximum numeric value in a dataset.\n\n  Each argument may be a value of an array. Values that are not numbers, including dates and text\n  representations of numbers, are counted as 0 (zero). Logical value of True is counted as 1, and\n  False as 0. Returns 0 if the arguments contain no numbers.\n\n  >>> MAXA([2, -1.5, 11.5])\n  11.5\n  >>> MAXA([2, -1.5, \"Hello\", DATE(2015, 1, 1)], True, [False, \"123\", \"\", 11.5])\n  11.5\n  >>> MAXA(True, -123)\n  1\n  >>> MAXA(\"123\", -123)\n  0\n  >>> MAXA(\"Hello\", \"123\", DATE(2015, 1, 1))\n  0\n  \"\"\"\n  return max(_default_if_empty(_chain_numeric_a(value, *more_values), 0))\n\n\ndef MEDIAN(value, *more_values):\n  \"\"\"\n  Returns the median value in a numeric dataset, ignoring non-numerical values.\n\n  Each argument may be a value or an array. Values that are not numbers, including logical\n  and blank values, and text representations of numbers, are ignored.\n\n  Produces an error if the arguments contain no numbers.\n\n  The median is the middle number when all values are sorted. So half of the values in the dataset\n  are less than the median, and half of the values are greater. If there is an even number of\n  values in the dataset, returns the average of the two numbers in the middle.\n\n  >>> MEDIAN(1, 2, 3, 4, 5)\n  3\n  >>> MEDIAN(3, 5, 1, 4, 2)\n  3\n  >>> MEDIAN(range(10))\n  4.5\n  >>> MEDIAN(\"Hello\", \"123\", DATE(2015, 1, 1), 12.3)\n  12.3\n  >>> MEDIAN(\"Hello\", \"123\", DATE(2015, 1, 1))\n  Traceback (most recent call last):\n    ...\n  ValueError: MEDIAN requires at least one number\n  \"\"\"\n  values = sorted(_chain_numeric(value, *more_values))\n  if not values:\n    raise ValueError(\"MEDIAN requires at least one number\")\n  count = len(values)\n  if count % 2 == 0:\n    return (values[count // 2 - 1] + values[count // 2]) / 2.0\n  else:\n    return values[(count - 1) // 2]\n\n\ndef _compare_date_datetime_key(x):\n  # Convert dates and naive datetimes to timezone-aware datetimes for sorting.\n  if isinstance(x, (datetime.date, datetime.datetime)):\n    return DTIME(x)\n  else:\n    return x\n\n\ndef MIN(value, *more_values):\n  \"\"\"\n  Returns the minimum value in a dataset, ignoring values other than numbers and dates/datetimes.\n\n  Each argument may be a value or an array. Values that are not numbers or dates, including logical\n  and blank values, and text representations of numbers, are ignored. Returns 0 if the arguments\n  contain no numbers or dates.\n\n  >>> MIN([2, -1.5, 11.5])\n  -1.5\n  >>> MIN([2, -1.5, \"Hello\"], True, [False, \"123\", \"\", 11.5])\n  -1.5\n  >>> MIN(True, 123)\n  123\n  >>> MIN(\"-123\", 123)\n  123\n  >>> MIN(\"Hello\", \"123\", True, False)\n  0\n  >>> MIN(DATE(2015, 1, 1), DATE(2015, 1, 2))\n  datetime.date(2015, 1, 1)\n  >>> MIN(DATE(2015, 1, 1), datetime.datetime(2015, 1, 1, 12, 34, 56))\n  datetime.date(2015, 1, 1)\n  >>> MIN(DATE(2015, 1, 2), datetime.datetime(2015, 1, 1, 12, 34, 56))\n  datetime.datetime(2015, 1, 1, 12, 34, 56)\n  \"\"\"\n  values = _default_if_empty(_chain_numeric_or_date(value, *more_values), 0)\n  return min(values, key=_compare_date_datetime_key)\n\ndef MINA(value, *more_values):\n  \"\"\"\n  Returns the minimum numeric value in a dataset.\n\n  Each argument may be a value of an array. Values that are not numbers, including dates and text\n  representations of numbers, are counted as 0 (zero). Logical value of True is counted as 1, and\n  False as 0. Returns 0 if the arguments contain no numbers.\n\n  >>> MINA([2, -1.5, 11.5])\n  -1.5\n  >>> MINA([2, -1.5, \"Hello\", DATE(2015, 1, 1)], True, [False, \"123\", \"\", 11.5])\n  -1.5\n  >>> MINA(True, 123)\n  1\n  >>> MINA(\"-123\", 123)\n  0\n  >>> MINA(\"Hello\", \"123\", DATE(2015, 1, 1))\n  0\n  \"\"\"\n  return min(_default_if_empty(_chain_numeric_a(value, *more_values), 0))\n\n\n@unimplemented\ndef MODE(value1, value2):\n  \"\"\"Returns the most commonly occurring value in a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef NEGBINOMDIST(num_failures, num_successes, prob_success):\n  \"\"\"Calculates the probability of drawing a certain number of failures before a certain number of successes given a probability of success in independent trials.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef NORMDIST(x, mean, standard_deviation, cumulative):\n  \"\"\"\n  Returns the value of the normal distribution function (or normal cumulative distribution\n  function) for a specified value, mean, and standard deviation.\n  \"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef NORMINV(x, mean, standard_deviation):\n  \"\"\"Returns the value of the inverse normal distribution function for a specified value, mean, and standard deviation.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef NORMSDIST(x):\n  \"\"\"Returns the value of the standard normal cumulative distribution function for a specified value.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef NORMSINV(x):\n  \"\"\"Returns the value of the inverse standard normal distribution function for a specified value.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef PEARSON(data_y, data_x):\n  \"\"\"Calculates r, the Pearson product-moment correlation coefficient of a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef PERCENTILE(data, percentile):\n  \"\"\"Returns the value at a given percentile of a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef PERCENTRANK(data, value, significant_digits=None):\n  \"\"\"Returns the percentage rank (percentile) of a specified value in a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef PERCENTRANK_EXC(data, value, significant_digits=None):\n  \"\"\"Returns the percentage rank (percentile) from 0 to 1 exclusive of a specified value in a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef PERCENTRANK_INC(data, value, significant_digits=None):\n  \"\"\"Returns the percentage rank (percentile) from 0 to 1 inclusive of a specified value in a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef PERMUT(n, k):\n  \"\"\"Returns the number of ways to choose some number of objects from a pool of a given size of objects, considering order.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef POISSON(x, mean, cumulative):\n  \"\"\"\n  Returns the value of the Poisson distribution function (or Poisson cumulative distribution\n  function) for a specified value and mean.\n  \"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef PROB(data, probabilities, low_limit, high_limit=None):\n  \"\"\"Given a set of values and corresponding probabilities, calculates the probability that a value chosen at random falls between two limits.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef QUARTILE(data, quartile_number):\n  \"\"\"Returns a value nearest to a specified quartile of a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef RANK_AVG(value, data, is_ascending=None):\n  \"\"\"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.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef RANK_EQ(value, data, is_ascending=None):\n  \"\"\"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 top rank of the entries will be returned.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef RSQ(data_y, data_x):\n  \"\"\"Calculates the square of r, the Pearson product-moment correlation coefficient of a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef SKEW(value1, value2):\n  \"\"\"Calculates the skewness of a dataset, which describes the symmetry of that dataset about the mean.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef SLOPE(data_y, data_x):\n  \"\"\"Calculates the slope of the line resulting from linear regression of a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef SMALL(data, n):\n  \"\"\"Returns the nth smallest element from a data set, where n is user-defined.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef STANDARDIZE(value, mean, standard_deviation):\n  \"\"\"Calculates the normalized equivalent of a random variable given mean and standard deviation of the distribution.\"\"\"\n  raise NotImplementedError()\n\n# This should make us all cry a little. Because the sandbox does not do Python3 (which has\n# statistics package), and because it does not do numpy (because it's native and hasn't been built\n# for it), we have to implement simple stats functions by hand.\n# TODO: switch to use the statistics package instead, once we upgrade to Python3.\n#\n# The following implementation of stdev is taken from https://stackoverflow.com/a/27758326/328565\ndef _mean(data):\n  return sum(data) / float(len(data))\n\ndef _ss(data):\n  \"\"\"Return sum of square deviations of sequence data.\"\"\"\n  c = _mean(data)\n  return sum((x-c)**2 for x in data)\n\ndef _stddev(data, ddof=0):\n  \"\"\"Calculates the population standard deviation\n  by default; specify ddof=1 to compute the sample\n  standard deviation.\"\"\"\n  n = len(data)\n  ss = _ss(data)\n  pvar = ss/(n-ddof)\n  return pvar**0.5\n\n# The examples in the doctests below come from https://support.google.com/docs/answer/3094054 and\n# related articles, which helps ensure correctness and compatibility.\ndef STDEV(value, *more_values):\n  \"\"\"\n  Calculates the standard deviation based on a sample, ignoring non-numerical values.\n\n  >>> STDEV([2, 5, 8, 13, 10])\n  4.277849927241488\n  >>> STDEV([2, 5, 8, 13, 10, True, False, \"Test\"])\n  4.277849927241488\n  >>> STDEV([2, 5, 8, 13, 10], 3, 12, 15)\n  4.810702354423639\n  >>> STDEV([2, 5, 8, 13, 10], [3, 12, 15])\n  4.810702354423639\n  >>> STDEV([5])\n  Traceback (most recent call last):\n    ...\n  ZeroDivisionError: float division by zero\n  \"\"\"\n  return _stddev(list(_chain_numeric(value, *more_values)), 1)\n\ndef STDEVA(value, *more_values):\n  \"\"\"\n  Calculates the standard deviation based on a sample, setting text to the value `0`.\n\n  >>> STDEVA([2, 5, 8, 13, 10])\n  4.277849927241488\n  >>> STDEVA([2, 5, 8, 13, 10, True, False, \"Test\"])\n  4.969550137731641\n  >>> STDEVA([2, 5, 8, 13, 10], 1, 0, 0)\n  4.969550137731641\n  >>> STDEVA([2, 5, 8, 13, 10], [1, 0, 0])\n  4.969550137731641\n  >>> STDEVA([5])\n  Traceback (most recent call last):\n    ...\n  ZeroDivisionError: float division by zero\n  \"\"\"\n  return _stddev(list(_chain_numeric_a(value, *more_values)), 1)\n\ndef STDEVP(value, *more_values):\n  \"\"\"\n  Calculates the standard deviation based on an entire population, ignoring non-numerical values.\n\n  >>> STDEVP([2, 5, 8, 13, 10])\n  3.8262252939417984\n  >>> STDEVP([2, 5, 8, 13, 10, True, False, \"Test\"])\n  3.8262252939417984\n  >>> STDEVP([2, 5, 8, 13, 10], 3, 12, 15)\n  4.5\n  >>> STDEVP([2, 5, 8, 13, 10], [3, 12, 15])\n  4.5\n  >>> STDEVP([5])\n  0.0\n  \"\"\"\n  return _stddev(list(_chain_numeric(value, *more_values)), 0)\n\ndef STDEVPA(value, *more_values):\n  \"\"\"\n  Calculates the standard deviation based on an entire population, setting text to the value `0`.\n\n  >>> STDEVPA([2, 5, 8, 13, 10])\n  3.8262252939417984\n  >>> STDEVPA([2, 5, 8, 13, 10, True, False, \"Test\"])\n  4.648588495446763\n  >>> STDEVPA([2, 5, 8, 13, 10], 1, 0, 0)\n  4.648588495446763\n  >>> STDEVPA([2, 5, 8, 13, 10], [1, 0, 0])\n  4.648588495446763\n  >>> STDEVPA([5])\n  0.0\n  \"\"\"\n  return _stddev(list(_chain_numeric_a(value, *more_values)), 0)\n\n@unimplemented\ndef STEYX(data_y, data_x):\n  \"\"\"Calculates the standard error of the predicted y-value for each x in the regression of a dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef T_INV(probability, degrees_freedom):\n  \"\"\"Calculates the negative inverse of the one-tailed TDIST function.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef T_INV_2T(probability, degrees_freedom):\n  \"\"\"Calculates the inverse of the two-tailed TDIST function.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef TDIST(x, degrees_freedom, tails):\n  \"\"\"Calculates the probability for Student's t-distribution with a given input (x).\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef TINV(probability, degrees_freedom):\n  \"\"\"Calculates the inverse of the two-tailed TDIST function.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef TRIMMEAN(data, exclude_proportion):\n  \"\"\"Calculates the mean of a dataset excluding some proportion of data from the high and low ends of the dataset.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef TTEST(range1, range2, tails, type):\n  \"\"\"Returns the probability associated with t-test. Determines whether two samples are likely to have come from the same two underlying populations that have the same mean.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef VAR(value1, value2):\n  \"\"\"Calculates the variance based on a sample.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef VARA(value1, value2):\n  \"\"\"Calculates an estimate of variance based on a sample, setting text to the value `0`.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef VARP(value1, value2):\n  \"\"\"Calculates the variance based on an entire population.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef VARPA(value1, value2):\n  \"\"\"Calculates the variance based on an entire population, setting text to the value `0`.\"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef WEIBULL(x, shape, scale, cumulative):\n  \"\"\"\n  Returns the value of the Weibull distribution function (or Weibull cumulative distribution\n  function) for a specified shape and scale.\n  \"\"\"\n  raise NotImplementedError()\n\n@unimplemented\ndef ZTEST(data, value, standard_deviation):\n  \"\"\"Returns the two-tailed P-value of a Z-test with standard distribution.\"\"\"\n  raise NotImplementedError()\n"
  },
  {
    "path": "sandbox/grist/functions/test_schedule.py",
    "content": "from datetime import date, datetime, timedelta\nimport os\nimport timeit\nimport unittest\n\nimport moment\nfrom . import schedule\nfrom functions.date import DTIME\nfrom functions import date as _date\n\nDT = DTIME\n\nTICK = timedelta.resolution\n_orig_global_tz_getter = None\n\nclass TestSchedule(unittest.TestCase):\n  def assertDate(self, date_or_dtime, expected_str):\n    \"\"\"Formats date_or_dtime and compares the formatted value.\"\"\"\n    return self.assertEqual(date_or_dtime.strftime(\"%Y-%m-%d %H:%M:%S\"), expected_str)\n\n  def assertDateIso(self, date_or_dtime, expected_str):\n    \"\"\"Formats date_or_dtime and compares the formatted value.\"\"\"\n    return self.assertEqual(date_or_dtime.isoformat(' '), expected_str)\n\n  def assertDelta(self, delta, months=0, **timedelta_args):\n    \"\"\"Asserts that the given delta corresponds to the given number of various units.\"\"\"\n    self.assertEqual(delta._months, months)\n    self.assertEqual(delta._timedelta, timedelta(**timedelta_args))\n\n  @classmethod\n  def setUpClass(cls):\n    global _orig_global_tz_getter # pylint: disable=global-statement\n    _orig_global_tz_getter = _date._get_global_tz\n    _date._get_global_tz = lambda: moment.tzinfo('America/New_York')\n\n  @classmethod\n  def tearDownClass(cls):\n    _date._get_global_tz = _orig_global_tz_getter\n\n\n  def test_round_down_to_unit(self):\n    RDU = schedule._round_down_to_unit\n    self.assertDate(RDU(DT(\"2018-09-04 14:38:11\"), \"years\"), \"2018-01-01 00:00:00\")\n    self.assertDate(RDU(DT(\"2018-01-01 00:00:00\"), \"years\"), \"2018-01-01 00:00:00\")\n    self.assertDate(RDU(DT(\"2018-01-01 00:00:00\") - TICK, \"years\"), \"2017-01-01 00:00:00\")\n\n    self.assertDate(RDU(DT(\"2018-09-04 14:38:11\"), \"months\"), \"2018-09-01 00:00:00\")\n    self.assertDate(RDU(DT(\"2018-09-01 00:00:00\"), \"months\"), \"2018-09-01 00:00:00\")\n    self.assertDate(RDU(DT(\"2018-09-01 00:00:00\") - TICK, \"months\"), \"2018-08-01 00:00:00\")\n\n    # Note that 9/4 was a Tuesday, so start of the week (Sunday) is 9/2\n    self.assertDate(RDU(DT(\"2018-09-04 14:38:11\"), \"weeks\"), \"2018-09-02 00:00:00\")\n    self.assertDate(RDU(DT(\"2018-09-02 00:00:00\"), \"weeks\"), \"2018-09-02 00:00:00\")\n    self.assertDate(RDU(DT(\"2018-09-02 00:00:00\") - TICK, \"weeks\"), \"2018-08-26 00:00:00\")\n\n    self.assertDate(RDU(DT(\"2018-09-04 14:38:11\"), \"days\"), \"2018-09-04 00:00:00\")\n    self.assertDate(RDU(DT(\"2018-09-04 00:00:00\"), \"days\"), \"2018-09-04 00:00:00\")\n    self.assertDate(RDU(DT(\"2018-09-04 00:00:00\") - TICK, \"days\"), \"2018-09-03 00:00:00\")\n\n    self.assertDate(RDU(DT(\"2018-09-04 14:38:11\"), \"hours\"), \"2018-09-04 14:00:00\")\n    self.assertDate(RDU(DT(\"2018-09-04 14:00:00\"), \"hours\"), \"2018-09-04 14:00:00\")\n    self.assertDate(RDU(DT(\"2018-09-04 14:00:00\") - TICK, \"hours\"), \"2018-09-04 13:00:00\")\n\n    self.assertDate(RDU(DT(\"2018-09-04 14:38:11\"), \"minutes\"), \"2018-09-04 14:38:00\")\n    self.assertDate(RDU(DT(\"2018-09-04 14:38:00\"), \"minutes\"), \"2018-09-04 14:38:00\")\n    self.assertDate(RDU(DT(\"2018-09-04 14:38:00\") - TICK, \"minutes\"), \"2018-09-04 14:37:00\")\n\n    self.assertDate(RDU(DT(\"2018-09-04 14:38:11\"), \"seconds\"), \"2018-09-04 14:38:11\")\n    self.assertDate(RDU(DT(\"2018-09-04 14:38:11\") - TICK, \"seconds\"), \"2018-09-04 14:38:10\")\n\n    with self.assertRaisesRegex(ValueError, r\"Invalid unit inches\"):\n      RDU(DT(\"2018-09-04 14:38:11\"), \"inches\")\n\n  def test_round_down_to_unit_tz(self):\n    RDU = schedule._round_down_to_unit\n    dt = datetime(2018, 1, 1, 0, 0, 0, tzinfo=moment.tzinfo(\"America/New_York\"))\n    self.assertDateIso(RDU(dt, \"years\"), \"2018-01-01 00:00:00-05:00\")\n    self.assertDateIso(RDU(dt - TICK, \"years\"), \"2017-01-01 00:00:00-05:00\")\n\n    self.assertDateIso(RDU(dt, \"months\"), \"2018-01-01 00:00:00-05:00\")\n    self.assertDateIso(RDU(dt - TICK, \"months\"), \"2017-12-01 00:00:00-05:00\")\n\n    # 2018-01-01 is a Monday\n    self.assertDateIso(RDU(dt, \"weeks\"), \"2017-12-31 00:00:00-05:00\")\n    self.assertDateIso(RDU(dt - timedelta(days=1) - TICK, \"weeks\"), \"2017-12-24 00:00:00-05:00\")\n\n    self.assertDateIso(RDU(dt, \"days\"), \"2018-01-01 00:00:00-05:00\")\n    self.assertDateIso(RDU(dt - TICK, \"days\"), \"2017-12-31 00:00:00-05:00\")\n\n    self.assertDateIso(RDU(dt, \"hours\"), \"2018-01-01 00:00:00-05:00\")\n    self.assertDateIso(RDU(dt - TICK, \"hours\"), \"2017-12-31 23:00:00-05:00\")\n\n  def test_parse_interval(self):\n    self.assertEqual(schedule._parse_interval(\"annual\"), (1, \"years\"))\n    self.assertEqual(schedule._parse_interval(\"daily\"), (1, \"days\"))\n    self.assertEqual(schedule._parse_interval(\"1-year\"), (1, \"years\"))\n    self.assertEqual(schedule._parse_interval(\"1 year\"), (1, \"years\"))\n    self.assertEqual(schedule._parse_interval(\"1  Years\"), (1, \"years\"))\n    self.assertEqual(schedule._parse_interval(\"25-months\"), (25, \"months\"))\n    self.assertEqual(schedule._parse_interval(\"3-day\"), (3, \"days\"))\n    self.assertEqual(schedule._parse_interval(\"2-hour\"), (2, \"hours\"))\n    with self.assertRaisesRegex(ValueError, \"Not a valid interval\"):\n      schedule._parse_interval(\"1Year\")\n    with self.assertRaisesRegex(ValueError, \"Not a valid interval\"):\n      schedule._parse_interval(\"1y\")\n    with self.assertRaisesRegex(ValueError, \"Unknown unit\"):\n      schedule._parse_interval(\"1-daily\")\n\n  def test_parse_slot(self):\n    self.assertDelta(schedule._parse_slot('Jan-15', 'years'), months=0, days=14)\n    self.assertDelta(schedule._parse_slot('1/15', 'years'), months=0, days=14)\n    self.assertDelta(schedule._parse_slot('march-1', 'years'), months=2, days=0)\n    self.assertDelta(schedule._parse_slot('03/09', 'years'), months=2, days=8)\n\n    self.assertDelta(schedule._parse_slot('/15', 'months'), days=14)\n    self.assertDelta(schedule._parse_slot('/1', 'months'), days=0)\n\n    self.assertDelta(schedule._parse_slot('Mon', 'weeks'), days=1)\n    self.assertDelta(schedule._parse_slot('tu', 'weeks'), days=2)\n    self.assertDelta(schedule._parse_slot('Friday', 'weeks'), days=5)\n\n    self.assertDelta(schedule._parse_slot('10am', 'days'), hours=10)\n    self.assertDelta(schedule._parse_slot('1:30pm', 'days'), hours=13, minutes=30)\n    self.assertDelta(schedule._parse_slot('15:45', 'days'), hours=15, minutes=45)\n    self.assertDelta(schedule._parse_slot('Apr-1 9am', 'years'), months=3, days=0, hours=9)\n    self.assertDelta(schedule._parse_slot('/3 12:30', 'months'), days=2, hours=12, minutes=30)\n    self.assertDelta(schedule._parse_slot('Sat 6:15pm', 'weeks'), days=6, hours=18, minutes=15)\n\n    self.assertDelta(schedule._parse_slot(':45', 'hours'), minutes=45)\n    self.assertDelta(schedule._parse_slot(':00', 'hours'), minutes=00)\n\n    self.assertDelta(schedule._parse_slot('+1d', 'days'), days=1)\n    self.assertDelta(schedule._parse_slot('+15d', 'months'), days=15)\n    self.assertDelta(schedule._parse_slot('+3w', 'weeks'), weeks=3)\n    self.assertDelta(schedule._parse_slot('+2m', 'years'), months=2)\n    self.assertDelta(schedule._parse_slot('+1y', 'years'), months=12)\n\n    # Test a few combinations.\n    self.assertDelta(schedule._parse_slot('+1y 4/5 3:45pm +30S', 'years'),\n        months=15, days=4, hours=15, minutes=45, seconds=30)\n    self.assertDelta(schedule._parse_slot('+2w Wed +6H +20M +40S', 'weeks'),\n        weeks=2, days=3, hours=6, minutes=20, seconds=40)\n    self.assertDelta(schedule._parse_slot('+2m /20 11pm', 'months'), months=2, days=19, hours=23)\n    self.assertDelta(schedule._parse_slot('+2M +30S', 'minutes'), minutes=2, seconds=30)\n\n  def test_parse_slot_errors(self):\n    # Test failures with duplicate units\n    with self.assertRaisesRegex(ValueError, 'Duplicate unit'):\n      schedule._parse_slot('+1d +2d', 'weeks')\n    with self.assertRaisesRegex(ValueError, 'Duplicate unit'):\n      schedule._parse_slot('9:30am +2H', 'days')\n    with self.assertRaisesRegex(ValueError, 'Duplicate unit'):\n      schedule._parse_slot('/15 +1d', 'months')\n    with self.assertRaisesRegex(ValueError, 'Duplicate unit'):\n      schedule._parse_slot('Feb-1 12:30pm +20M', 'years')\n\n    # Test failures with improper slot types\n    with self.assertRaisesRegex(ValueError, 'Invalid slot.*for unit'):\n      schedule._parse_slot('Feb-1', 'weeks')\n    with self.assertRaisesRegex(ValueError, 'Invalid slot.*for unit'):\n      schedule._parse_slot('Monday', 'months')\n    with self.assertRaisesRegex(ValueError, 'Invalid slot.*for unit'):\n      schedule._parse_slot('4/15', 'hours')\n    with self.assertRaisesRegex(ValueError, 'Invalid slot.*for unit'):\n      schedule._parse_slot('/1', 'years')\n\n    # Test failures with outright invalid slot syntax.\n    with self.assertRaisesRegex(ValueError, 'Invalid slot'):\n      schedule._parse_slot('Feb:1', 'weeks')\n    with self.assertRaisesRegex(ValueError, 'Invalid slot'):\n      schedule._parse_slot('/1d', 'months')\n    with self.assertRaisesRegex(ValueError, 'Invalid slot'):\n      schedule._parse_slot('10', 'hours')\n    with self.assertRaisesRegex(ValueError, 'Invalid slot'):\n      schedule._parse_slot('H1', 'years')\n\n    # Test failures with unknown values\n    with self.assertRaisesRegex(ValueError, 'Unknown month'):\n      schedule._parse_slot('februarium-1', 'years')\n    with self.assertRaisesRegex(ValueError, 'Unknown day of the week'):\n      schedule._parse_slot('snu', 'weeks')\n    with self.assertRaisesRegex(ValueError, 'Unknown unit'):\n      schedule._parse_slot('+1t', 'hours')\n\n  def test_schedule(self):\n    # A few more examples. The ones in doctest strings are those that help documentation; the rest\n    # are in this file to keep the size of the main file more manageable.\n\n    # Note that the start of 2018-01-01 is a Monday\n    self.assertEqual(list(schedule.SCHEDULE(\n      \"1-week: +1d 9:30am, +4d 3:30pm\", start=datetime(2018,1,1), end=datetime(2018,1,31))),\n      [\n        DT(\"2018-01-01 09:30:00\"), DT(\"2018-01-04 15:30:00\"),\n        DT(\"2018-01-08 09:30:00\"), DT(\"2018-01-11 15:30:00\"),\n        DT(\"2018-01-15 09:30:00\"), DT(\"2018-01-18 15:30:00\"),\n        DT(\"2018-01-22 09:30:00\"), DT(\"2018-01-25 15:30:00\"),\n        DT(\"2018-01-29 09:30:00\"),\n      ])\n\n    self.assertEqual(list(schedule.SCHEDULE(\n      \"3-month: +0d 12pm\", start=datetime(2018,1,1), end=datetime(2018,6,30))),\n      [DT('2018-01-01 12:00:00'), DT('2018-04-01 12:00:00')])\n\n    # Ensure we can use date() object for start/end too.\n    self.assertEqual(list(schedule.SCHEDULE(\n      \"3-month: +0d 12pm\", start=date(2018,1,1), end=date(2018,6,30))),\n      [DT('2018-01-01 12:00:00'), DT('2018-04-01 12:00:00')])\n\n    # We can even use strings.\n    self.assertEqual(list(schedule.SCHEDULE(\n      \"3-month: +0d 12pm\", start=\"2018-01-01\", end=\"2018-06-30\")),\n      [DT('2018-01-01 12:00:00'), DT('2018-04-01 12:00:00')])\n\n  def test_timezone(self):\n    # Verify that the time zone of `start` determines the time zone of generated times.\n    tz_ny = moment.tzinfo(\"America/New_York\")\n    self.assertEqual([d.isoformat(' ') for d in schedule.SCHEDULE(\n      \"daily: 9am\", count=4, start=datetime(2018, 2, 14, tzinfo=tz_ny))],\n      [ '2018-02-14 09:00:00-05:00', '2018-02-15 09:00:00-05:00',\n        '2018-02-16 09:00:00-05:00', '2018-02-17 09:00:00-05:00' ])\n\n    tz_la = moment.tzinfo(\"America/Los_Angeles\")\n    self.assertEqual([d.isoformat(' ') for d in schedule.SCHEDULE(\n      \"daily: 9am, 4:30pm\", count=4, start=datetime(2018, 2, 14, 9, 0, tzinfo=tz_la))],\n      [ '2018-02-14 09:00:00-08:00', '2018-02-14 16:30:00-08:00',\n        '2018-02-15 09:00:00-08:00', '2018-02-15 16:30:00-08:00' ])\n\n    tz_utc = moment.tzinfo(\"UTC\")\n    self.assertEqual([d.isoformat(' ') for d in schedule.SCHEDULE(\n      \"daily: 9am, 4:30pm\", count=4, start=datetime(2018, 2, 14, 17, 0, tzinfo=tz_utc))],\n      [ '2018-02-15 09:00:00+00:00', '2018-02-15 16:30:00+00:00',\n        '2018-02-16 09:00:00+00:00', '2018-02-16 16:30:00+00:00' ])\n\n  # This is not really a test but just a way to see some timing information about Schedule\n  # implementation. Run with env PY_TIMING_TESTS=1 in the environment, and the console output will\n  # include the measured times.\n  @unittest.skipUnless(os.getenv(\"PY_TIMING_TESTS\") == \"1\", \"Set PY_TIMING_TESTS=1 for timing\")\n  def test_timing(self):\n    N = 1000\n    sched = \"weekly: Mo 10:30am, We 10:30am\"\n    setup = \"\"\"\nfrom functions import schedule\nfrom datetime import datetime\n\"\"\"\n    setup = \"from functions import test_schedule as t\"\n\n    expected_result = [\n      datetime(2018, 9, 24, 10, 30), datetime(2018, 9, 26, 22, 30),\n      datetime(2018, 10, 1, 10, 30), datetime(2018, 10, 3, 22, 30),\n    ]\n    self.assertEqual(timing_schedule_full(), expected_result)\n    t = min(timeit.repeat(stmt=\"t.timing_schedule_full()\", setup=setup, number=N, repeat=3))\n    print(\"\\n*** SCHEDULE call with 4 points: %.2f us\" % (t * 1000000 / N))\n\n    t = min(timeit.repeat(stmt=\"t.timing_schedule_init()\", setup=setup, number=N, repeat=3))\n    print(\"*** Schedule constructor: %.2f us\" % (t * 1000000 / N))\n\n    self.assertEqual(timing_schedule_series(), expected_result)\n    t = min(timeit.repeat(stmt=\"t.timing_schedule_series()\", setup=setup, number=N, repeat=3))\n    print(\"*** Schedule series with 4 points: %.2f us\" % (t * 1000000 / N))\n\ndef timing_schedule_full():\n  return list(schedule.SCHEDULE(\"weekly: Mo 10:30am, We 10:30pm\",\n    start=datetime(2018, 9, 23), count=4))\n\ndef timing_schedule_init():\n  return schedule.Schedule(\"weekly: Mo 10:30am, We 10:30pm\")\n\ndef timing_schedule_series(sched=schedule.Schedule(\"weekly: Mo 10:30am, We 10:30pm\")):\n  return list(sched.series(datetime(2018, 9, 23), None, count=4))\n"
  },
  {
    "path": "sandbox/grist/functions/text.py",
    "content": "# -*- coding: UTF-8 -*-\n\nimport datetime\nimport numbers\nimport re\n\nimport dateutil.parser\nimport phonenumbers\n\nfrom usertypes import AltText  # pylint: disable=import-error\nfrom .math import ROUND\nfrom .unimplemented import unimplemented\n\n\ndef CHAR(table_number):\n  \"\"\"\n  Convert a number into a character according to the current Unicode table.\n  Same as `chr(number)`.\n\n  >>> CHAR(65)\n  u'A'\n  >>> CHAR(33)\n  u'!'\n  \"\"\"\n  return chr(table_number)\n\n\n# See http://stackoverflow.com/a/93029/328565\n_control_chars = ''.join(map(chr, list(range(0,32)) + list(range(127,160))))\n_control_char_re = re.compile('[%s]' % re.escape(_control_chars))\n\ndef CLEAN(text):\n  \"\"\"\n  Returns the text with the non-printable characters removed.\n\n  This removes both characters with values 0 through 31, and other Unicode characters in the\n  \"control characters\" category.\n\n  >>> CLEAN(CHAR(9) + \"Monthly report\" + CHAR(10))\n  u'Monthly report'\n  \"\"\"\n  return _control_char_re.sub('', text)\n\n\ndef CODE(string):\n  \"\"\"\n  Returns the numeric Unicode map value of the first character in the string provided.\n  Same as `ord(string[0])`.\n\n  >>> CODE(\"A\")\n  65\n  >>> CODE(\"!\")\n  33\n  >>> CODE(\"!A\")\n  33\n  \"\"\"\n  return ord(string[0])\n\n\ndef CONCATENATE(string, *more_strings):\n  u\"\"\"\n  Joins together any number of text strings into one string. Also available under the name\n  `CONCAT`. Similar to the Python expression `\"\".join(array_of_strings)`.\n\n  >>> CONCATENATE(\"Stream population for \", \"trout\", \" \", \"species\", \" is \", 32, \"/mile.\")\n  u'Stream population for trout species is 32/mile.'\n  >>> CONCATENATE(\"In \", 4, \" days it is \", datetime.date(2016,1,1))\n  u'In 4 days it is 2016-01-01'\n  >>> CONCATENATE(\"abc\")\n  u'abc'\n  >>> CONCATENATE(0, \"abc\")\n  u'0abc'\n  >>> assert CONCATENATE(2, u\" crème \", u\"brûlée\") == u'2 crème brûlée'\n  >>> assert CONCATENATE(2,  \" crème \", u\"brûlée\") == u'2 crème brûlée'\n  >>> assert CONCATENATE(2,  \" crème \",  \"brûlée\") == u'2 crème brûlée'\n  \"\"\"\n  return u''.join(\n    val.decode('utf8') if isinstance(val, bytes) else   # pylint:disable=no-member\n    str(val)\n    for val in (string,) + more_strings\n  )\n\n\ndef CONCAT(string, *more_strings):\n  \"\"\"\n  Joins together any number of text strings into one string. Also available under the name\n  `CONCATENATE`. Similar to the Python expression `\"\".join(array_of_strings)`.\n\n  >>> CONCAT(\"Stream population for \", \"trout\", \" \", \"species\", \" is \", 32, \"/mile.\")\n  u'Stream population for trout species is 32/mile.'\n  >>> CONCAT(\"In \", 4, \" days it is \", datetime.date(2016,1,1))\n  u'In 4 days it is 2016-01-01'\n  >>> CONCAT(\"abc\")\n  u'abc'\n  >>> CONCAT(0, \"abc\")\n  u'0abc'\n  >>> assert CONCAT(2, u\" crème \", u\"brûlée\") == u'2 crème brûlée'\n  \"\"\"\n  return CONCATENATE(string, *more_strings)\n\ndef DOLLAR(number, decimals=2):\n  \"\"\"\n  Formats a number into a formatted dollar amount, with decimals rounded to the specified place (.\n  If decimals value is omitted, it defaults to 2.\n\n  >>> DOLLAR(1234.567)\n  '$1,234.57'\n  >>> DOLLAR(1234.567, -2)\n  '$1,200'\n  >>> DOLLAR(-1234.567, -2)\n  '($1,200)'\n  >>> DOLLAR(-0.123, 4)\n  '($0.1230)'\n  >>> DOLLAR(99.888)\n  '$99.89'\n  >>> DOLLAR(0)\n  '$0.00'\n  >>> DOLLAR(10, 0)\n  '$10'\n  \"\"\"\n  formatted = \"${:,.{}f}\".format(ROUND(abs(number), decimals), max(0, decimals))\n  return formatted if number >= 0 else \"(\" + formatted + \")\"\n\n\ndef EXACT(string1, string2):\n  \"\"\"\n  Tests whether two strings are identical. Same as `string2 == string2`.\n\n  >>> EXACT(\"word\", \"word\")\n  True\n  >>> EXACT(\"Word\", \"word\")\n  False\n  >>> EXACT(\"w ord\", \"word\")\n  False\n  \"\"\"\n  return string1 == string2\n\n\ndef FIND(find_text, within_text, start_num=1):\n  \"\"\"\n  Returns the position at which a string is first found within text.\n\n  Find is case-sensitive. The returned position is 1 if within_text starts with find_text.\n  Start_num specifies the character at which to start the search, defaulting to 1 (the first\n  character of within_text).\n\n  If find_text is not found, or start_num is invalid, raises ValueError.\n\n  >>> FIND(\"M\", \"Miriam McGovern\")\n  1\n  >>> FIND(\"m\", \"Miriam McGovern\")\n  6\n  >>> FIND(\"M\", \"Miriam McGovern\", 3)\n  8\n  >>> FIND(\" #\", \"Hello world # Test\")\n  12\n  >>> FIND(\"gle\", \"Google\", 1)\n  4\n  >>> FIND(\"GLE\", \"Google\", 1)\n  Traceback (most recent call last):\n  ...\n  ValueError: substring not found\n  >>> FIND(\"page\", \"homepage\")\n  5\n  >>> FIND(\"page\", \"homepage\", 6)\n  Traceback (most recent call last):\n  ...\n  ValueError: substring not found\n  \"\"\"\n  return within_text.index(find_text, start_num - 1) + 1\n\n\ndef FIXED(number, decimals=2, no_commas=False):\n  \"\"\"\n  Formats a number with a fixed number of decimal places (2 by default), and commas.\n  If no_commas is True, then omits the commas.\n\n  >>> FIXED(1234.567, 1)\n  '1,234.6'\n  >>> FIXED(1234.567, -1)\n  '1,230'\n  >>> FIXED(-1234.567, -1, True)\n  '-1230'\n  >>> FIXED(44.332)\n  '44.33'\n  >>> FIXED(3521.478, 2, False)\n  '3,521.48'\n  >>> FIXED(-3521.478, 1, True)\n  '-3521.5'\n  >>> FIXED(3521.478, 0, True)\n  '3521'\n  >>> FIXED(3521.478, -2, True)\n  '3500'\n  \"\"\"\n  comma_flag = '' if no_commas else ','\n  return \"{:{}.{}f}\".format(ROUND(number, decimals), comma_flag, max(0, decimals))\n\n\ndef LEFT(string, num_chars=1):\n  \"\"\"\n  Returns a substring of length num_chars from the beginning of the given string. If num_chars is\n  omitted, it is assumed to be 1. Same as `string[:num_chars]`.\n\n  >>> LEFT(\"Sale Price\", 4)\n  'Sale'\n  >>> LEFT('Swededn')\n  'S'\n  >>> LEFT('Text', -1)\n  Traceback (most recent call last):\n  ...\n  ValueError: num_chars invalid\n  \"\"\"\n  if num_chars < 0:\n    raise ValueError(\"num_chars invalid\")\n  return string[:num_chars]\n\n\ndef LEN(text):\n  \"\"\"\n  Returns the number of characters in a text string, or the number of items in a list. Same as\n  [`len`](https://docs.python.org/3/library/functions.html#len) in python.\n  See [Record Set](#recordset) for an example of using `len` on a list of records.\n\n  >>> LEN(\"Phoenix, AZ\")\n  11\n  >>> LEN(\"\")\n  0\n  >>> LEN(\"     One   \")\n  11\n  \"\"\"\n  return len(text)\n\n\ndef LOWER(text):\n  \"\"\"\n  Converts a specified string to lowercase. Same as `text.lower()`.\n\n  >>> LOWER(\"E. E. Cummings\")\n  'e. e. cummings'\n  >>> LOWER(\"Apt. 2B\")\n  'apt. 2b'\n  \"\"\"\n  return text.lower()\n\n\ndef MID(text, start_num, num_chars):\n  \"\"\"\n  Returns a segment of a string, starting at start_num. The first character in text has\n  start_num 1.\n\n  >>> MID(\"Fluid Flow\", 1, 5)\n  'Fluid'\n  >>> MID(\"Fluid Flow\", 7, 20)\n  'Flow'\n  >>> MID(\"Fluid Flow\", 20, 5)\n  ''\n  >>> MID(\"Fluid Flow\", 0, 5)\n  Traceback (most recent call last):\n  ...\n  ValueError: start_num invalid\n  \"\"\"\n  if start_num < 1:\n    raise ValueError(\"start_num invalid\")\n  return text[start_num - 1 : start_num - 1 + num_chars]\n\n\noutput_formats = {\n    \"+\":        phonenumbers.PhoneNumberFormat.INTERNATIONAL,\n    \"INTL\":     phonenumbers.PhoneNumberFormat.INTERNATIONAL,\n    \"#\":        phonenumbers.PhoneNumberFormat.NATIONAL,\n    \"NATL\":     phonenumbers.PhoneNumberFormat.NATIONAL,\n    \"*\":        phonenumbers.PhoneNumberFormat.E164,\n    \"E164\":     phonenumbers.PhoneNumberFormat.E164,\n    \"tel\":      phonenumbers.PhoneNumberFormat.RFC3966,\n    \"RFC3966\":  phonenumbers.PhoneNumberFormat.RFC3966,\n}\n\ndef PHONE_FORMAT(value, country=None, format=None):  # pylint: disable=redefined-builtin\n  \"\"\"\n  Formats a phone number.\n\n  With no optional arguments, the number must start with \"+\" and the international dialing prefix,\n  and will be formatted as an international number, e.g. `+12345678901` becomes `+1 234-567-8901`.\n\n  The `country` argument allows specifying a 2-letter country code (e.g. \"US\" or \"GB\") for\n  interpreting phone numbers that don't start with \"+\". E.g. `PHONE_FORMAT('2025555555', 'US')`\n  would be seen as a US number and formatted as \"(202) 555-5555\". Phone numbers that start with\n  \"+\" ignore `country`. E.g. `PHONE_FORMAT('+33555555555', 'US')` is a French number because '+33'\n  is the international prefix for France.\n\n  The `format` argument specifies the output format, according to this table:\n\n    - `\"#\"` or `\"NATL\"` (default) - use the national format, without the international dialing\n      prefix, when possible. E.g. `(234) 567-8901` for \"US\", or `02 34 56 78 90` for \"FR\". If\n      `country` is omitted, or the number does not correspond to the given country, the\n      international format is used instead.\n    - `\"+\"` or `\"INTL\"` - international format, e.g. `+1 234-567-8901` or\n      `+33 2 34 56 78 90`.\n    - `\"*\"` or `\"E164\"` - E164 format, like international but with no separators, e.g.\n      `+12345678901`.\n    - `\"tel\"` or `\"RFC3966\"` - format suitable to use as a [hyperlink](col-types.md#hyperlinks),\n      e.g. 'tel:+1-234-567-8901'.\n\n  When specifying the `format` argument, you may omit the `country` argument. I.e.\n  `PHONE_FORMAT(value, \"tel\")` is equivalent to `PHONE_FORMAT(value, None, \"tel\")`.\n\n  For more details, see the [phonenumbers](https://github.com/daviddrysdale/python-phonenumbers)\n  Python library, which underlies this function.\n\n  >>> PHONE_FORMAT(\"+12345678901\")\n  u'+1 234-567-8901'\n  >>> PHONE_FORMAT(\"2345678901\", \"US\")\n  u'(234) 567-8901'\n  >>> PHONE_FORMAT(\"2345678901\", \"GB\")\n  u'023 4567 8901'\n  >>> PHONE_FORMAT(\"2345678901\", \"GB\", \"+\")\n  u'+44 23 4567 8901'\n  >>> PHONE_FORMAT(\"+442345678901\", \"GB\")\n  u'023 4567 8901'\n  >>> PHONE_FORMAT(\"+12345678901\", \"GB\")\n  u'+1 234-567-8901'\n  >>> PHONE_FORMAT(\"(234) 567-8901\")    # doctest: +IGNORE_EXCEPTION_DETAIL\n  Traceback (most recent call last):\n  ...\n  NumberParseException: (0) Missing or invalid default region.\n  >>> PHONE_FORMAT(\"(234)567 89-01\", \"US\", \"tel\")\n  u'tel:+1-234-567-8901'\n  >>> PHONE_FORMAT(\"2/3456/7890\", \"FR\", '#')\n  u'02 34 56 78 90'\n  >>> PHONE_FORMAT(\"+33234567890\", '#')\n  u'+33 2 34 56 78 90'\n  >>> PHONE_FORMAT(\"+33234567890\", 'tel')\n  u'tel:+33-2-34-56-78-90'\n  >>> PHONE_FORMAT(\"tel:+1-234-567-8901\", country=\"US\", format=\"*\")\n  u'+12345678901'\n  >>> PHONE_FORMAT(33234567890)\n  Traceback (most recent call last):\n  ...\n  TypeError: Phone number must be a text value. \\\nIf formatting a value from a Numeric column, convert that column to Text first.\n  \"\"\"\n  if not value:\n    return value\n\n  if not isinstance(value, str):\n    raise TypeError(\"Phone number must be a text value. \" +\n      \"If formatting a value from a Numeric column, convert that column to Text first.\")\n\n  if format is None and country in output_formats:\n    format = country\n    country = None\n  parsed = phonenumbers.parse(str(value), country)\n  out_fmt = output_formats.get(format or \"#\")\n  if out_fmt is None:\n    raise ValueError(\"Unrecognized phone format; try +, INTL, #, NATL, *, E164, tel, or RFC3966\")\n\n  if out_fmt == phonenumbers.PhoneNumberFormat.NATIONAL and not country:\n    # With no country, we lose info in NATIONAL format (because numbers must be specified with an\n    # international prefix, and the output would discard it). Use INTERNATIONAL instead.\n    out_fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL\n\n  result = phonenumbers.format_number(parsed, out_fmt)\n\n  # If using a national format with a country, check that we don't garble numbers with a different\n  # international prefix. If so, use an international format. E.g. for\n  # PHONE_FORMAT('+12345678901', 'FR'), the output should include the US dialing prefix.\n  if (out_fmt == phonenumbers.PhoneNumberFormat.NATIONAL and country and\n      phonenumbers.parse(result, country) != parsed):\n    result = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.INTERNATIONAL)\n\n  return result\n\n\ndef PROPER(text):\n  \"\"\"\n  Capitalizes each word in a specified string. It converts the first letter of each word to\n  uppercase, and all other letters to lowercase. Same as `text.title()`.\n\n  >>> PROPER('this is a TITLE')\n  'This Is A Title'\n  >>> PROPER('2-way street')\n  '2-Way Street'\n  >>> PROPER('76BudGet')\n  '76Budget'\n  \"\"\"\n  return text.title()\n\n\ndef REGEXEXTRACT(text, regular_expression):\n  \"\"\"\n  Extracts the first part of text that matches regular_expression.\n\n  >>> REGEXEXTRACT(\"Google Doc 101\", \"[0-9]+\")\n  '101'\n  >>> REGEXEXTRACT(\"The price today is $826.25\", \"[0-9]*\\\\.[0-9]+[0-9]+\")\n  '826.25'\n\n  If there is a parenthesized expression, it is returned instead of the whole match.\n  >>> REGEXEXTRACT(\"(Content) between brackets\", \"\\\\(([A-Za-z]+)\\\\)\")\n  'Content'\n  >>> REGEXEXTRACT(\"Foo\", \"Bar\")\n  Traceback (most recent call last):\n  ...\n  ValueError: REGEXEXTRACT text does not match\n  \"\"\"\n  m = re.search(regular_expression, text)\n  if not m:\n    raise ValueError(\"REGEXEXTRACT text does not match\")\n  return m.group(1) if m.lastindex else m.group(0)\n\n\ndef REGEXMATCH(text, regular_expression):\n  \"\"\"\n  Returns whether a piece of text matches a regular expression.\n\n  >>> REGEXMATCH(\"Google Doc 101\", \"[0-9]+\")\n  True\n  >>> REGEXMATCH(\"Google Doc\", \"[0-9]+\")\n  False\n  >>> REGEXMATCH(\"The price today is $826.25\", \"[0-9]*\\\\.[0-9]+[0-9]+\")\n  True\n  >>> REGEXMATCH(\"(Content) between brackets\", \"\\\\(([A-Za-z]+)\\\\)\")\n  True\n  >>> REGEXMATCH(\"Foo\", \"Bar\")\n  False\n  \"\"\"\n  return bool(re.search(regular_expression, text))\n\n\ndef REGEXREPLACE(text, regular_expression, replacement):\n  \"\"\"\n  Replaces all parts of text matching the given regular expression with replacement text.\n\n  >>> REGEXREPLACE(\"Google Doc 101\", \"[0-9]+\", \"777\")\n  'Google Doc 777'\n  >>> REGEXREPLACE(\"Google Doc\", \"[0-9]+\", \"777\")\n  'Google Doc'\n  >>> REGEXREPLACE(\"The price is $826.25\", \"[0-9]*\\\\.[0-9]+[0-9]+\", \"315.75\")\n  'The price is $315.75'\n  >>> REGEXREPLACE(\"(Content) between brackets\", \"\\\\(([A-Za-z]+)\\\\)\", \"Word\")\n  'Word between brackets'\n  >>> REGEXREPLACE(\"Foo\", \"Bar\", \"Baz\")\n  'Foo'\n  \"\"\"\n  return re.sub(regular_expression, replacement, text)\n\n\ndef REPLACE(text, position, length, new_text):\n  \"\"\"\n  Replaces part of a text string with a different text string. Position is counted from 1.\n\n  >>> REPLACE(\"abcdefghijk\", 6, 5, \"*\")\n  'abcde*k'\n  >>> REPLACE(\"2009\", 3, 2, \"10\")\n  '2010'\n  >>> REPLACE('123456', 1, 3, '@')\n  '@456'\n  >>> REPLACE('foo', 1, 0, 'bar')\n  'barfoo'\n  >>> REPLACE('foo', 0, 1, 'bar')\n  Traceback (most recent call last):\n  ...\n  ValueError: position invalid\n  \"\"\"\n  if position < 1:\n    raise ValueError(\"position invalid\")\n  return text[:position - 1] + new_text + text[position - 1 + length:]\n\n\ndef REPT(text, number_times):\n  \"\"\"\n  Returns specified text repeated a number of times. Same as `text * number_times`.\n\n  The result of the REPT function cannot be longer than 32767 characters, or it raises a\n  ValueError.\n\n  >>> REPT(\"*-\", 3)\n  '*-*-*-'\n  >>> REPT('-', 10)\n  '----------'\n  >>> REPT('-', 0)\n  ''\n  >>> len(REPT('---', 10000))\n  30000\n  >>> REPT('---', 11000)\n  Traceback (most recent call last):\n  ...\n  ValueError: number_times invalid\n  >>> REPT('-', -1)\n  Traceback (most recent call last):\n  ...\n  ValueError: number_times invalid\n  \"\"\"\n  if number_times < 0 or len(text) * number_times > 32767:\n    raise ValueError(\"number_times invalid\")\n  return text * int(number_times)\n\n\ndef RIGHT(string, num_chars=1):\n  \"\"\"\n  Returns a substring of length num_chars from the end of a specified string. If num_chars is\n  omitted, it is assumed to be 1. Same as `string[-num_chars:]`.\n\n  >>> RIGHT(\"Sale Price\", 5)\n  'Price'\n  >>> RIGHT('Stock Number')\n  'r'\n  >>> RIGHT('Text', 100)\n  'Text'\n  >>> RIGHT('Text', -1)\n  Traceback (most recent call last):\n  ...\n  ValueError: num_chars invalid\n  \"\"\"\n  if num_chars < 0:\n    raise ValueError(\"num_chars invalid\")\n  return string[-num_chars:]\n\n\ndef SEARCH(find_text, within_text, start_num=1):\n  \"\"\"\n  Returns the position at which a string is first found within text, ignoring case.\n\n  Find is case-sensitive. The returned position is 1 if within_text starts with find_text.\n  Start_num specifies the character at which to start the search, defaulting to 1 (the first\n  character of within_text).\n\n  If find_text is not found, or start_num is invalid, raises ValueError.\n  >>> SEARCH(\"e\", \"Statements\", 6)\n  7\n  >>> SEARCH(\"margin\", \"Profit Margin\")\n  8\n  >>> SEARCH(\" \", \"Profit Margin\")\n  7\n  >>> SEARCH('\"', 'The \"boss\" is here.')\n  5\n  >>> SEARCH(\"gle\", \"Google\")\n  4\n  >>> SEARCH(\"GLE\", \"Google\")\n  4\n  \"\"\"\n  # .lower() isn't always correct for unicode. See http://stackoverflow.com/a/29247821/328565\n  return within_text.lower().index(find_text.lower(), start_num - 1) + 1\n\n\ndef SUBSTITUTE(text, old_text, new_text, instance_num=None):\n  u\"\"\"\n  Replaces existing text with new text in a string. It is useful when you know the substring of\n  text to replace. Use REPLACE when you know the position of text to replace.\n\n  If instance_num is given, it specifies which occurrence of old_text to replace. If omitted, all\n  occurrences are replaced.\n\n  Same as `text.replace(old_text, new_text)` when instance_num is omitted.\n\n  >>> SUBSTITUTE(\"Sales Data\", \"Sales\", \"Cost\")\n  u'Cost Data'\n  >>> SUBSTITUTE(\"Quarter 1, 2008\", \"1\", \"2\", 1)\n  u'Quarter 2, 2008'\n  >>> SUBSTITUTE(\"Quarter 1, 2011\", \"1\", \"2\", 3)\n  u'Quarter 1, 2012'\n\n  More tests:\n  >>> SUBSTITUTE(\"Hello world\", \"\", \"-\")\n  u'Hello world'\n  >>> SUBSTITUTE(\"Hello world\", \" \", \"-\")\n  u'Hello-world'\n  >>> SUBSTITUTE(\"Hello world\", \" \", 12.1)\n  u'Hello12.1world'\n  >>> SUBSTITUTE(u\"Hello world\", u\" \", 12.1)\n  u'Hello12.1world'\n  >>> SUBSTITUTE(\"Hello world\", \"world\", \"\")\n  u'Hello '\n  >>> SUBSTITUTE(\"Hello\", \"world\", \"\")\n  u'Hello'\n\n  Overlapping matches are all counted when looking for instance_num.\n  >>> SUBSTITUTE('abababab', 'abab', 'xxxx')\n  u'xxxxxxxx'\n  >>> SUBSTITUTE('abababab', 'abab', 'xxxx', 1)\n  u'xxxxabab'\n  >>> SUBSTITUTE('abababab', 'abab', 'xxxx', 2)\n  u'abxxxxab'\n  >>> SUBSTITUTE('abababab', 'abab', 'xxxx', 3)\n  u'ababxxxx'\n  >>> SUBSTITUTE('abababab', 'abab', 'xxxx', 4)\n  u'abababab'\n  >>> SUBSTITUTE('abababab', 'abab', 'xxxx', 0)\n  Traceback (most recent call last):\n  ...\n  ValueError: instance_num invalid\n  >>> SUBSTITUTE( \"crème\",  \"è\", \"e\")\n  u'creme'\n  >>> SUBSTITUTE(u\"crème\", u\"è\", \"e\")\n  u'creme'\n  >>> SUBSTITUTE(u\"crème\",  \"è\", \"e\")\n  u'creme'\n  >>> SUBSTITUTE( \"crème\", u\"è\", \"e\")\n  u'creme'\n  \"\"\"\n  text = str(text)\n  old_text = str(old_text)\n  new_text = str(new_text)\n\n  if not old_text:\n    return text\n\n  if instance_num is None:\n    return text.replace(old_text, new_text)\n\n  if instance_num <= 0:\n    raise ValueError(\"instance_num invalid\")\n\n  # No trivial way to replace nth occurrence.\n  i = -1\n  for c in range(instance_num):\n    i = text.find(old_text, i + 1)\n    if i < 0:\n      return text\n  return text[:i] + new_text + text[i + len(old_text):]\n\n\ndef T(value):\n  \"\"\"\n  Returns value if value is text, or the empty string when value is not text.\n\n  >>> T('Text')\n  u'Text'\n  >>> T(826)\n  u''\n  >>> T('826')\n  u'826'\n  >>> T(False)\n  u''\n  >>> T('100 points')\n  u'100 points'\n  >>> T(AltText('Text'))\n  u'Text'\n  >>> T(float('nan'))\n  u''\n  \"\"\"\n  return (value.decode('utf8') if isinstance(value, bytes) else\n          value if isinstance(value, str) else\n          str(value) if isinstance(value, AltText) else u\"\")\n\ndef TASTEME(food):\n  \"\"\"\n  For any given piece of text, decides if it is tasty or not.\n\n  This is not serious. It appeared as an Easter egg, and is kept as such. It is in fact a puzzle\n  to figure out the underlying simple rule. It has been surprisingly rarely cracked, even after\n  reading the source code, which is freely available and may entertain Python fans.\n\n  >>> TASTEME('Banana')\n  True\n  >>> TASTEME('Garlic')\n  False\n  \"\"\"\n  chews = re.findall(r'\\b[A-Z]+\\b', food.upper())\n  claw = slice(2, None)\n  spit = lambda chow: chow[claw]\n  return (chews or None) and not all(fang != snap\n    for bite in chews for fang, snap in zip(bite, spit(bite)))\n\n\n@unimplemented\ndef TEXT(number, format_type):    # pylint: disable=unused-argument\n  \"\"\"\n  Converts a number into text according to a specified format. It is not yet implemented in\n  Grist. You can use the similar Python functions str() to convert numbers into strings, and\n  optionally format() to specify the number format.\n  \"\"\"\n  raise NotImplementedError()\n\n\n_trim_re = re.compile(r'  +')\n\ndef TRIM(text):\n  \"\"\"\n  Removes all spaces from text except for single spaces between words. Note that TRIM does not\n  remove other whitespace such as tab or newline characters.\n\n  >>> TRIM(\" First Quarter\\\\n    Earnings     \")\n  'First Quarter\\\\n Earnings'\n  >>> TRIM(\"\")\n  ''\n  \"\"\"\n  return _trim_re.sub(' ', text.strip())\n\n\ndef UPPER(text):\n  \"\"\"\n  Converts a specified string to uppercase. Same as `text.upper()`.\n\n  >>> UPPER(\"e. e. cummings\")\n  'E. E. CUMMINGS'\n  >>> UPPER(\"Apt. 2B\")\n  'APT. 2B'\n  \"\"\"\n  return text.upper()\n\n\ndef VALUE(text):\n  \"\"\"\n  Converts a string in accepted date, time or number formats into a number or date.\n\n  >>> VALUE(\"$1,000\")\n  1000\n  >>> assert VALUE(\"16:48:00\") - VALUE(\"12:00:00\") == datetime.timedelta(0, 17280)\n  >>> VALUE(\"01/01/2012\")\n  datetime.datetime(2012, 1, 1, 0, 0)\n  >>> VALUE(\"\")\n  0\n  >>> VALUE(0)\n  0\n  >>> VALUE(\"826\")\n  826\n  >>> VALUE(\"-826.123123123\")\n  -826.123123123\n  >>> VALUE(float('nan'))\n  nan\n  >>> VALUE(\"Invalid\")\n  Traceback (most recent call last):\n  ...\n  ValueError: text cannot be parsed to a number\n  >>> VALUE(\"13/13/13\")\n  Traceback (most recent call last):\n  ...\n  ValueError: text cannot be parsed to a number\n  \"\"\"\n  # This is not particularly robust, but makes an attempt to handle a number of cases: numbers,\n  # including optional comma separators, dates/times, leading dollar-sign.\n  if isinstance(text, (numbers.Number, datetime.date)):\n    return text\n  text = text.strip().lstrip('$')\n  nocommas = text.replace(',', '')\n  if nocommas == \"\":\n    return 0\n\n  try:\n    return int(nocommas)\n  except ValueError:\n    pass\n\n  try:\n    return float(nocommas)\n  except ValueError:\n    pass\n\n  try:\n    return dateutil.parser.parse(text)\n  except ValueError:\n    pass\n\n  raise ValueError('text cannot be parsed to a number')\n"
  },
  {
    "path": "sandbox/grist/functions/unimplemented.py",
    "content": "\"\"\"\nDecorator that marks functions as not implemented. It sets func.unimplemented=True.\nUsage:\n\n@unimplemented\ndef func(...):\n  raise NotImplemented\n\"\"\"\ndef unimplemented(func):\n  func.unimplemented = True\n  return func\n"
  },
  {
    "path": "sandbox/grist/gencode.py",
    "content": "\"\"\"\ngencode.py is the module that generates a python module based on the schema in a grist document.\nAn example of the module it generates is available in usercode.py.\n\nThe schema for grist data is:\n  <schema> = [ <table_info> ]\n  <table_info> = {\n    \"tableId\": <string>,\n    \"columns\": [ <column_info> ],\n  }\n  <column_info> = {\n    \"id\": <string>,\n    \"type\": <string>\n    \"isFormula\": <boolean>,\n    \"formula\": <opt_string>,\n  }\n\"\"\"\nimport logging\nimport types\nfrom collections import OrderedDict\n\nimport codebuilder\nfrom column import is_visible_column\nimport summary\nimport table\nimport textbuilder\nfrom usertypes import get_type_default\nlog = logging.getLogger(__name__)\n\nindent_str = \"  \"\n\n#----------------------------------------------------------------------\n\ndef get_grist_type(col_type, reverse_col_id=None):\n  \"\"\"Returns code for a grist usertype object given a column type string.\"\"\"\n  col_type_split = col_type.split(':', 1)\n  typename = col_type_split[0]\n  if typename == 'Ref':\n    typename = 'Reference'\n  elif typename == 'RefList':\n    typename = 'ReferenceList'\n\n  arg = col_type_split[1] if len(col_type_split) > 1 else ''\n  arg = arg.strip().replace(\"'\", \"\\\\'\")\n\n  args = []\n  if arg:\n    args.append(\"'%s'\" % arg)\n  if reverse_col_id and typename in ('Reference', 'ReferenceList'):\n    args.append('reverse_of=' + repr(reverse_col_id))\n  return \"grist.%s(%s)\" % (typename, \", \".join(args))\n\n\nclass GenCode:\n  \"\"\"\n  GenCode generates the Python code for a Grist document, including converting formulas to Python\n  functions and producing a Python specification of all the tables with data and formula fields.\n\n  To save the costly work of generating formula code, it maintains a formula cache. It is a\n  dictionary mapping (table_id, col_id, formula) to a textbuilder.Builder. On each run of\n  make_module(), it will use the previously cached values for lookups, and replace the contents\n  of the cache with current values. If ever we need to generate code for unrelated schemas, to\n  benefit from the cache, a separate GenCode object should be used for each schema.\n  \"\"\"\n  def __init__(self):\n    self._formula_cache = {}\n    self._new_formula_cache = {}\n    self._full_builder = None\n    self._user_builder = None\n    self._usercode = None\n\n  def _make_formula_field(self, col_info, table_id, *, name=None, include_type=True,\n      additional_params=(), indent=''):\n    \"\"\"Returns the code for a formula field.\"\"\"\n    # If the caller didn't specify a special name, use the colId\n    name = name or col_info.colId\n\n    decl = indent + \"def %s(%s):\\n\" % (\n      name,\n      ', '.join(['rec', 'table'] + list(additional_params))\n    )\n\n    # This is where we get to use the formula cache, and save the work of rebuilding formulas.\n    key = (table_id, col_info.colId, col_info.formula)\n    body = self._formula_cache.get(key)\n    if body is None:\n      default = get_type_default(col_info.type)\n      # If we have a table_id like `Table._Summary`, then we don't want to actually associate\n      # this field with any real table/column.\n      assoc_value = None if table_id.endswith(\"._Summary\") else (table_id, col_info.colId)\n      body = codebuilder.make_formula_body(col_info.formula, default, assoc_value,\n          indent=indent_str + indent)\n    self._new_formula_cache[key] = body\n\n    decorator = ''\n    if include_type and col_info.type != 'Any':\n      decorator = (indent +\n          '@grist.formulaType(%s)\\n' % get_grist_type(col_info.type, col_info.reverseColId))\n    return textbuilder.Combiner(['\\n' + decorator + decl, body, '\\n'])\n\n\n  def _make_data_field(self, col_info, table_id, indent=''):\n    \"\"\"Returns the code for a data field.\"\"\"\n    parts = []\n    if col_info.formula:\n      parts.append(self._make_formula_field(col_info, table_id,\n                                            name=table.get_default_func_name(col_info.colId),\n                                            include_type=False,\n                                            additional_params=['value', 'user'],\n                                            indent=indent))\n    parts.append(indent + \"%s = %s\\n\" % (col_info.colId,\n      get_grist_type(col_info.type, col_info.reverseColId)))\n    return textbuilder.Combiner(parts)\n\n\n  def _make_field(self, col_info, table_id, indent=''):\n    \"\"\"Returns the code for a field.\"\"\"\n    assert not col_info.colId.startswith(\"_\")\n    if col_info.isFormula:\n      return self._make_formula_field(col_info, table_id, indent=indent)\n    else:\n      return self._make_data_field(col_info, table_id, indent=indent)\n\n\n  def _make_table_model(self, table_info, summary_tables, filter_for_user=False):\n    \"\"\"\n    Returns the code for a table model.\n    If filter_for_user is True, includes only user-visible columns.\n    \"\"\"\n    table_id = table_info.tableId\n    source_table_id = summary.decode_summary_table_name(table_info)\n\n    # Sort columns by \"isFormula\" to output all data columns before all formula columns.\n    columns = sorted(table_info.columns.values(), key=lambda c: c.isFormula)\n    if filter_for_user:\n      columns = [c for c in columns if is_visible_column(c.colId)]\n    parts = [\"@grist.UserTable\\nclass %s:\\n\" % table_id]\n    if source_table_id:\n      parts.append(indent_str + \"_summarySourceTable = %r\\n\" % source_table_id)\n\n    for col_info in columns:\n      parts.append(self._make_field(col_info, table_id, indent=indent_str))\n\n    if summary_tables:\n      # Include summary formulas, for the user's information.\n      formulas = OrderedDict((c.colId, c) for s in summary_tables\n                             for c in s.columns.values() if c.isFormula)\n      parts.append(\"\\n\" + indent_str + \"class _Summary:\\n\")\n      for col_info in formulas.values():\n        # Associate this field with the fake table `table_id + \"._Summary\"`.\n        # We don't know which summary table each formula belongs to, there might be several,\n        # and we don't care here because this is just for display in the code view.\n        # The real formula will be associated with the real summary table elsewhere.\n        # Previously this field was accidentally associated with the source table, causing bugs.\n        parts.append(self._make_field(col_info, table_id + \"._Summary\", indent=indent_str * 2))\n\n    if len(parts) == 1:\n      parts.append(indent_str + \"pass\")\n\n    return textbuilder.Combiner(parts)\n\n  def make_module(self, schema):\n    \"\"\"Regenerates the code text and usercode module from updated document schema.\"\"\"\n    # Collect summary tables to group them by source table.\n    summary_tables = {}\n    for table_info in schema.values():\n      source_table_id = summary.decode_summary_table_name(table_info)\n      if source_table_id:\n        summary_tables.setdefault(source_table_id, []).append(table_info)\n\n    fullparts = [\"import grist\\n\" +\n                 \"from functions import *       # global uppercase functions\\n\" +\n                 \"import datetime, math, re     # modules commonly needed in formulas\\n\"]\n    userparts = fullparts[:]\n    for table_info in sorted(schema.values(), key=lambda t: t.tableId):\n      fullparts.append(\"\\n\\n\")\n      fullparts.append(self._make_table_model(table_info, summary_tables.get(table_info.tableId)))\n      if not (\n          _is_special_table(table_info.tableId) or\n          summary.decode_summary_table_name(table_info)\n      ):\n        userparts.append(\"\\n\\n\")\n        userparts.append(self._make_table_model(table_info, summary_tables.get(table_info.tableId),\n          filter_for_user=True))\n\n    # Once all formulas are generated, replace the formula cache with the newly-populated version.\n    self._formula_cache = self._new_formula_cache\n    self._new_formula_cache = {}\n    self._full_builder = textbuilder.Combiner(fullparts)\n    self._user_builder = textbuilder.Combiner(userparts)\n    self._usercode = exec_module_text(self._full_builder.get_text())\n\n  def get_user_text(self):\n    \"\"\"Returns the text of the user-facing part of the generated code.\"\"\"\n    return self._user_builder.get_text()\n\n  @property\n  def usercode(self):\n    \"\"\"Returns the generated usercode module.\"\"\"\n    return self._usercode\n\n  def grist_names(self):\n    return codebuilder.parse_grist_names(self._full_builder)\n\n\ndef _is_special_table(table_id):\n  return table_id.startswith(\"_grist_\")\n\n\ndef exec_module_text(module_text):\n  mod = types.ModuleType(codebuilder.code_filename)\n  codebuilder.save_to_linecache(module_text)\n  code_obj = compile(module_text, codebuilder.code_filename, \"exec\")\n  # pylint: disable=exec-used\n  exec(code_obj, mod.__dict__)\n  return mod\n"
  },
  {
    "path": "sandbox/grist/grist.py",
    "content": "\"\"\"\nThis file packages together other modules needed for usercode in order to create\na consistent API accessible with only \"import grist\".\n\"\"\"\n# pylint: disable=unused-import\n\n# These imports are used in processing generated usercode.\nfrom usertypes import Any, Text, Blob, Int, Bool, Date, DateTime, \\\n  Numeric, Choice, ChoiceList, Id, Attachments, AltText, ifError\nfrom usertypes import PositionNumber, ManualSortPos, Reference, ReferenceList, formulaType\nfrom table import UserTable\nfrom records import Record, RecordSet\nfrom column import SafeSortKey\n\nDOCS = [(__name__, (Record, RecordSet, UserTable)),\n        ('lookup', (UserTable.lookupOne, UserTable.lookupRecords))]\n"
  },
  {
    "path": "sandbox/grist/identifiers.py",
    "content": "# coding=utf-8\n\"\"\"\nA module for creating and sanitizing identifiers\n\"\"\"\nimport itertools\nimport logging\nimport re\nimport unicodedata\nfrom keyword import iskeyword\nfrom string import ascii_uppercase\n\nlog = logging.getLogger(__name__)\n\n_invalid_ident_char_re = re.compile(r'[^a-zA-Z0-9_]+')\n_invalid_ident_start_re = re.compile(r'^(?=[0-9_])')\n\ndef _sanitize_ident(ident, prefix=\"c\", capitalize=False):\n  \"\"\"\n  Helper for pick_ident, which given a suggested identifier, massages it to ensure it's valid for\n  python (and sqlite). In particular, leaves only alphanumeric characters, and prepends `prefix`\n  if it doesn't start with a letter.\n\n  Returns empty string if there are no valid identifier characters, so consider using as\n  (_sanitize_ident(...) or \"your_default\").\n  \"\"\"\n  ident = u\"\" if ident is None else str(ident)\n\n  # https://stackoverflow.com/a/517974/2482744\n  # Separate out combining characters (e.g. accents)\n  ident = unicodedata.normalize('NFKD', ident)\n  # then remove them completely\n  # This means that 'é' becomes 'e' instead of '_' or 'e_'\n  ident = \"\".join(c for c in ident if not unicodedata.combining(c))\n\n  # TODO allow non-ascii characters in identifiers when using Python 3\n  ident = _invalid_ident_char_re.sub('_', ident).lstrip('_')\n  ident = _invalid_ident_start_re.sub(prefix, ident)\n  if not ident:\n    return ident\n\n  if capitalize:\n    # Just capitalize the first letter (do NOT lowercase other letters like str.title() does).\n    ident = ident[0].capitalize() + ident[1:]\n\n  # Prevent names that are illegal to assign to\n  while iskeyword(ident):\n    ident = prefix + ident\n  return ident\n\n_ends_in_digit_re = re.compile(r'\\d$')\n\ndef _add_suffix(ident_base, avoid=set(), next_suffix=1):\n  \"\"\"\n  Helper which appends a numerical suffix to ident_base, incrementing it until the result doesn't\n  conflict with anything in the `avoid` set.\n  \"\"\"\n  if _ends_in_digit_re.search(ident_base):\n    ident_base += \"_\"\n\n  while True:\n    ident = \"%s%d\" % (ident_base, next_suffix)\n    if ident.upper() not in avoid:\n      return ident\n    next_suffix += 1\n\ndef _maybe_add_suffix(ident, avoid):\n  \"\"\"\n  Returns the first of ident, ident2, ident3 etc. that's not in the `avoid` set.\n  \"\"\"\n  return ident if (ident.upper() not in avoid) else _add_suffix(ident, avoid, 2)\n\ndef _uppercase(avoid):\n  return {name.upper() for name in avoid}\n\ndef pick_table_ident(ident, avoid=set()):\n  \"\"\"\n  Given a suggested identifier (which may be None), creates a sanitized table identifier,\n  possibly with a numerical suffix that doesn't conflict with anything in the `avoid` set.\n  \"\"\"\n  avoid = _uppercase(avoid)\n  ident = _sanitize_ident(ident, prefix=\"T\", capitalize=True)\n  return _maybe_add_suffix(ident, avoid) if ident else _add_suffix(\"Table\", avoid, 1)\n\ndef pick_col_ident(ident, avoid=set()):\n  \"\"\"\n  Given a suggested identifier (which may be None), creates a sanitized column identifier,\n  possibly with a numerical suffix that doesn't conflict with anything in the `avoid` set.\n  \"\"\"\n  avoid = _uppercase(avoid)\n  ident = _sanitize_ident(ident, prefix=\"c\")\n  return _maybe_add_suffix(ident, avoid) if ident else _gen_ident(avoid)\n\n\ndef pick_col_ident_list(ident_list, avoid=set()):\n  \"\"\"\n  Given a list of suggested identifiers (which may be invalid), returns a list of valid sanitized\n  unique identifiers, that don't conflict with anything in the `avoid` set or with each other.\n  \"\"\"\n  avoid = _uppercase(avoid)\n  result = []\n  for ident in ident_list:\n    ident = pick_col_ident(ident, avoid=avoid)\n    avoid.add(ident.upper())\n    result.append(ident)\n  return result\n\ndef _gen_ident(avoid):\n  \"\"\"\n  Helper for pick_ident, which generates a valid identifier\n  when pick_ident is called without a suggested identifier or default.\n  It returns the first identifier that does not conflict with any elements of the avoid set.\n  The identifier is a letter or combination of letters that follows a\n  similar pattern to what excel uses for naming columns.\n   i.e. A, B, ... Z, AA, AB, ... AZ, BA, etc\n  \"\"\"\n  avoid = _uppercase(avoid)\n  for letter in _make_letters():\n    if letter not in avoid:\n      return letter\n\ndef _make_letters():\n  length = 1\n  while True:\n    for letters in itertools.product(ascii_uppercase, repeat=length):\n      yield ''.join(letters)\n    length +=1\n"
  },
  {
    "path": "sandbox/grist/import_actions.py",
    "content": "import logging\n\nimport column\nimport identifiers\n\nlog = logging.getLogger(__name__)\n\n# Prefix for transform columns created during imports.\n_import_transform_col_prefix = 'gristHelper_Import_'\n\ndef _gen_colids(transform_rule):\n  \"\"\"\n  For a transform_rule with colIds = None,\n  fills in colIds generated from labels and returns the updated\n  transform_rule.\n  \"\"\"\n\n  dest_cols = transform_rule[\"destCols\"]\n\n  if any(dc[\"colId\"] for dc in dest_cols):\n    raise ValueError(\"transform_rule already has colIds in _gen_colids\")\n\n  col_labels = [dest_col[\"label\"] for dest_col in dest_cols]\n  col_ids = identifiers.pick_col_ident_list(col_labels, avoid={'id'})\n\n  for dest_col, col_id in zip(dest_cols, col_ids):\n    dest_col[\"colId\"] = col_id\n\n  return transform_rule\n\n\ndef _strip_prefixes(transform_rule):\n  \"If transform_rule has prefixed _col_ids, strips prefix\"\n\n  dest_cols = transform_rule[\"destCols\"]\n  for dest_col in dest_cols:\n    colId = dest_col[\"colId\"]\n    if colId and colId.startswith(_import_transform_col_prefix):\n      dest_col[\"colId\"] = colId[len(_import_transform_col_prefix):]\n\n\nclass ImportActions(object):\n\n  def __init__(self, useractions, docmodel, engine):\n    self._useractions = useractions\n    self._docmodel = docmodel\n    self._engine = engine\n\n\n\n  ########################\n  ##        NOTES\n  # transform_rule is an object like this: {\n  #   destCols: [ { colId, label, type, formula }, ... ],\n  #   ..., # other params unused in sandbox\n  # }\n  #\n  # colId is defined if into_new_table, otherwise is None\n\n  # GenImporterView gets a hidden table with a preview of the import data (~100 rows)\n  # It adds formula cols and viewsections to the hidden table for the user to\n  # preview and edit import options. GenImporterView can start with a default transform_rule\n  # from table columns, or use one that's passed in (for reimporting).\n\n  # client/components/Importer.ts then puts together transform_rule, which\n  # specifies destination column formulas, types, labels, and colIds. It only contains colIds\n  # if importing into an existing table, and they are sometimes prefixed with\n  # _import_transform_col_prefix (if transform_rule comes from client)\n\n\n  def _MakeDefaultTransformRule(self, hidden_table_id, dest_table_id):\n    \"\"\"\n    Makes a basic transform_rule.dest_cols copying all the source cols\n    hidden_table_id: table with src data\n    dest_table_id: table data is going to\n      If dst_table is null, copy all src columns\n      If dst_table exists, copy all dst columns, and make copy formulas if any names match\n\n    returns transform_rule with only destCols filled in\n    \"\"\"\n\n    tables = self._docmodel.tables\n    hidden_table_rec = tables.lookupOne(tableId=hidden_table_id)\n\n    # will use these to set default formulas (if column names match in src and dest table)\n    src_cols = {c.colId: c for c in hidden_table_rec.columns}\n\n    target_table = tables.lookupOne(tableId=dest_table_id) if dest_table_id else hidden_table_rec\n    target_cols = target_table.columns\n    # makes dest_cols for each column in target_cols (defaults to same columns as hidden_table)\n\n\n    #loop through visible, non-formula target columns\n    dest_cols = []\n    for c in target_cols:\n      if column.is_visible_column(c.colId) and (not c.isFormula or c.formula == \"\"):\n        source_col = src_cols.get(c.colId)\n        dest_col = {\n          \"label\":    c.label,\n          \"colId\":    c.colId if dest_table_id else None, #should be None if into new table\n          \"type\":     c.type,\n          \"widgetOptions\": getattr(c, \"widgetOptions\", \"\"),\n          \"formula\":  (\"$\" + source_col.colId) if source_col else '',\n        }\n        if source_col and c.type.startswith(\"Ref:\"):\n          ref_table_id = c.type.split(':')[1]\n          visible_col = c.visibleCol\n          if visible_col:\n            dest_col[\"visibleCol\"] = visible_col.id\n            dest_col[\"formula\"] = '{}.lookupOne({}=${c}) or (${c} and str(${c}))'.format(\n                ref_table_id, visible_col.colId, c=source_col.colId)\n\n        dest_cols.append(dest_col)\n\n    return {\"destCols\": dest_cols}\n    # doesnt generate other fields of transform_rule, but sandbox only used destCols\n\n\n  def _MakeImportTransformColumns(self, hidden_table_id, transform_rule, gen_all):\n    \"\"\"\n    Makes prefixed columns in the grist hidden import table (hidden_table_id)\n\n    hidden_table_id: id of temporary hidden table in which columns are made\n    transform_rule: defines columns to make (colids must be filled in!)\n\n    gen_all: If true, all columns will be generated\n      If false, formulas that just copy will be skipped\n\n    returns list of newly created colrefs (rowids into _grist_Tables_column)\n    \"\"\"\n\n    tables = self._docmodel.tables\n    hidden_table_rec = tables.lookupOne(tableId=hidden_table_id)\n    src_cols = {c.colId for c in hidden_table_rec.columns}\n    log.debug(\"destCols: %r\", transform_rule['destCols'])\n\n    dest_cols = transform_rule['destCols']\n\n    log.debug(\"_MakeImportTransformColumns: %s\", \"gen_all\" if gen_all else \"optimize\")\n\n    # Calling rebuild_usercode once per added column is wasteful and can be very slow.\n    self._engine._should_rebuild_usercode = False\n\n    #create prefixed formula column for each of dest_cols\n    #take formula from transform_rule\n    new_cols = []\n    try:\n      for c in dest_cols:\n        # skip copy columns (unless gen_all)\n        formula = c[\"formula\"].strip()\n        isCopyFormula = (formula.startswith(\"$\") and formula[1:] in src_cols)\n\n        if gen_all or not isCopyFormula:\n          # If colId specified, use that. Otherwise, use the (sanitized) label.\n          col_id = c[\"colId\"] or identifiers.pick_col_ident(c[\"label\"])\n          visible_col_ref = c.get(\"visibleCol\", 0)\n          new_col_id = _import_transform_col_prefix + col_id\n          new_col_spec = {\n            \"label\": c[\"label\"],\n            \"type\": c[\"type\"],\n            \"widgetOptions\": c.get(\"widgetOptions\", \"\"),\n            \"isFormula\": True,\n            \"formula\": c[\"formula\"],\n            \"visibleCol\": visible_col_ref,\n          }\n          result = self._useractions.doAddColumn(hidden_table_id, new_col_id, new_col_spec)\n          new_col_id, new_col_ref = result[\"colId\"], result[\"colRef\"]\n\n          if visible_col_ref:\n            visible_col_id = self._docmodel.columns.table.get_record(visible_col_ref).colId\n            self._useractions.SetDisplayFormula(hidden_table_id, None, new_col_ref,\n                '${}.{}'.format(new_col_id, visible_col_id))\n\n          new_cols.append(new_col_ref)\n    finally:\n      self._engine._should_rebuild_usercode = True\n    self._engine.rebuild_usercode()\n\n    return new_cols\n\n\n  def DoGenImporterView(self, source_table_id, dest_table_id, transform_rule, options):\n    \"\"\"\n    Generates formula columns for transformed importer columns, and optionally a new viewsection.\n\n    source_table_id: id of temporary hidden table, containing source data and used for preview.\n    dest_table_id: id of table to import to, or None for new table.\n    transform_rule: transform_rule to reuse (if it still applies), if None will generate new one\n    options: a dictionary with optional keys:\n      createViewSection: defaults to True, in which case creates a new view-section to show the\n        generated columns, for use in review, and remove any previous ones.\n      genAll: defaults to True; if False, transform formulas that just copy will not be generated.\n      refsAsInts: if set, treat Ref columns as type Int for a new dest_table. This is used when\n        finishing imports from multi-table sources (e.g. from json) to avoid issues such as\n        importing linked tables in the wrong order. Caller is expected to fix these up separately.\n\n    Returns and object with:\n      transformRule: updated (normalized) transform rule, or a newly generated one.\n      viewSectionRef: rowId of the newly added section, present only if createViewSection is set.\n    \"\"\"\n    createViewSection = options.get(\"createViewSection\", True)\n    genAll = options.get(\"genAll\", True)\n    refsAsInts = options.get(\"refsAsInts\", True)\n\n    if createViewSection:\n      src_table_rec = self._docmodel.tables.lookupOne(tableId=source_table_id)\n\n      # ======== Cleanup old sections/columns\n\n      # Transform columns are those that start with a special prefix.\n      old_cols = [c for c in src_table_rec.columns\n                  if c.colId.startswith(_import_transform_col_prefix)]\n      old_sections = {field.parentId for c in old_cols for field in c.viewFields}\n      self._docmodel.remove(old_sections)\n      self._docmodel.remove(old_cols)\n\n    #======== Prepare/normalize transform_rule, Create new formula columns\n    # Defaults to duplicating dest_table columns (or src_table columns for a new table)\n    # If transform_rule provided, use that\n\n    if transform_rule is None:\n      transform_rule = self._MakeDefaultTransformRule(source_table_id, dest_table_id)\n\n    # ensure prefixes, colIds are correct\n    _strip_prefixes(transform_rule)\n\n    if not dest_table_id: # into new table: 'colId's are undefined\n      _gen_colids(transform_rule)\n\n      # Treat destination Ref:* columns as Int instead, for new tables, to avoid issues when\n      # importing linked tables in the wrong order. Caller is expected to fix up afterwards.\n      if refsAsInts:\n        for col in transform_rule[\"destCols\"]:\n          if col[\"type\"].startswith(\"Ref:\"):\n            col[\"type\"] = \"Int\"\n\n    else:\n      if None in (dc[\"colId\"] for dc in transform_rule[\"destCols\"]):\n        errstr = \"colIds must be defined in transform_rule for importing into existing table: \"\n        raise ValueError(errstr + repr(transform_rule))\n\n    new_cols = self._MakeImportTransformColumns(source_table_id, transform_rule, gen_all=genAll)\n\n    result = {\"transformRule\": transform_rule}\n    if createViewSection:\n      #=========  Create new transform view section.\n      new_section = self._docmodel.add(self._docmodel.view_sections,\n                                      tableRef=src_table_rec.id,\n                                      parentKey='record',\n                                      borderWidth=1, defaultWidth=100,\n                                      sortColRefs='[]')[0]\n      self._docmodel.add(new_section.fields, colRef=new_cols)\n      result[\"viewSectionRef\"] = new_section.id\n\n    return result\n"
  },
  {
    "path": "sandbox/grist/imports/__init__.py",
    "content": "import warnings\n\noriginal_formatwarning = warnings.formatwarning\n\n# The if...elif...else block is inspired from the \n# implementation of ensure_text of `six` package\n# delivered under MIT license by Benjamin Peterson\n# https://github.com/benjaminp/six/blob/4a765bffe847d65f918c70de1d7240f8b7a00767/six.py#L944\ndef formatwarning(*args, **kwargs):\n  \"\"\"\n  Fixes an error on Jenkins where byte strings (instead of unicode)\n  were being written to stderr due to a warning from an internal library.\n  \"\"\"\n  s = original_formatwarning(*args, **kwargs)\n\n  if isinstance(s, bytes):\n    return s.decode()\n  elif isinstance(s, str):\n    return s\n  else:\n    raise TypeError(\"not expecting type '%s'\" % type(s))\n\nwarnings.formatwarning = formatwarning\n"
  },
  {
    "path": "sandbox/grist/imports/fixtures/test_encoding_utf8.csv",
    "content": "Name,Age,Επάγγελμα,Πόλη\nJohn Smith,30,Γιατρός,Athens\nΜαρία Παπαδοπούλου,25,Engineer,Thessaloniki\nΔημήτρης Johnson,40,Δικηγόρος,Piraeus\n"
  },
  {
    "path": "sandbox/grist/imports/fixtures/test_excel_types.csv",
    "content": "int1,int2,textint,bigint,num2,bignum,date1,date2,datetext,datetimetext\r-1234123,5,12345678902345689,320150170634561830,123456789.1234560000,7.22597E+86,12/22/15 11:59 AM,\"December 20, 2015\",12/22/2015,12/22/2015 00:00:00\r,,,,,,,,,12/22/2015 13:15:00\r,,,,,,,,,02/27/2018 16:08:39\r"
  },
  {
    "path": "sandbox/grist/imports/fixtures/test_import_csv.csv",
    "content": "FIRST_NAME,LAST_NAME,PHONE,VALUE,DATE\nJohn,Moor,201-343-3434,45,2018-02-27 16:08:39 +0000\nTim,Kale,201.343.3434,4545,2018-02-27 16:08:39 +0100\nJenny,Jo,2013433434,0,2018-02-27 16:08:39 -0100\nLily,Smit,(201)343-3434,4,\n"
  },
  {
    "path": "sandbox/grist/imports/fixtures/test_isdigit.csv",
    "content": "PHONE,VALUE,DATE\n201-¾᠓𑄺꤈꤈꧐꤆,¹5,2018-0²-27 16:08:39 +0000\n"
  },
  {
    "path": "sandbox/grist/imports/fixtures/test_long_cell.csv",
    "content": "ID,LongText\n17,\"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Neque viverra justo nec ultrices dui sapien eget mi proin. Id aliquet risus feugiat in ante. Duis convallis convallis tellus id interdum velit laoreet id. Nunc faucibus a pellentesque sit amet. Enim ut tellus elementum sagittis vitae et. Nam at lectus urna duis convallis. In iaculis nunc sed augue lacus. Aenean et tortor at risus. Ultrices gravida dictum fusce ut placerat orci nulla. Felis eget nunc lobortis mattis aliquam faucibus purus in massa. Nibh sit amet commodo nulla facilisi. Amet justo donec enim diam vulputate ut pharetra sit. Pellentesque elit eget gravida cum sociis. Sed vulputate mi sit amet mauris commodo quis.\n\nCursus sit amet dictum sit amet. Nam at lectus urna duis convallis convallis. Sollicitudin nibh sit amet commodo. Sit amet tellus cras adipiscing enim eu. Egestas purus viverra accumsan in. Sit amet porttitor eget dolor morbi non. Sem viverra aliquet eget sit amet tellus cras adipiscing. Morbi blandit cursus risus at ultrices mi tempus imperdiet nulla. Bibendum enim facilisis gravida neque convallis. Ipsum dolor sit amet consectetur. Et ultrices neque ornare aenean. Urna neque viverra justo nec. Ac felis donec et odio pellentesque diam. Arcu risus quis varius quam quisque id diam. Id aliquet lectus proin nibh. Purus sit amet volutpat consequat mauris nunc. Quam elementum pulvinar etiam non quam lacus suspendisse. Sed adipiscing diam donec adipiscing tristique risus nec. Molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit sed. Diam quis enim lobortis scelerisque fermentum dui.\n\nMalesuada fames ac turpis egestas sed tempus urna et. In aliquam sem fringilla ut morbi tincidunt augue interdum. Turpis massa tincidunt dui ut ornare lectus sit amet est. Rhoncus aenean vel elit scelerisque mauris pellentesque pulvinar. Tincidunt ornare massa eget egestas purus viverra accumsan. Egestas fringilla phasellus faucibus scelerisque. Lectus mauris ultrices eros in cursus turpis massa. Vulputate eu scelerisque felis imperdiet proin fermentum leo. Tristique senectus et netus et malesuada. Nec nam aliquam sem et tortor consequat id porta.\n\nCursus sit amet dictum sit amet. Nam at lectus urna duis convallis convallis. Sollicitudin nibh sit amet commodo. Sit amet tellus cras adipiscing enim eu. Egestas purus viverra accumsan in. Sit amet porttitor eget dolor morbi non. Sem viverra aliquet eget sit amet tellus cras adipiscing. Morbi blandit cursus risus at ultrices mi tempus imperdiet nulla. Bibendum enim facilisis gravida neque convallis. Ipsum dolor sit amet consectetur. Et ultrices neque ornare aenean. Urna neque viverra justo nec. Ac felis donec et odio pellentesque diam. Arcu risus quis varius quam quisque id diam. Id aliquet lectus proin nibh. Purus sit amet volutpat consequat mauris nunc. Quam elementum pulvinar etiam non quam lacus suspendisse. Sed adipiscing diam donec adipiscing tristique risus nec. Molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit sed. Diam quis enim lobortis scelerisque fermentum dui.\n\nMalesuada fames ac turpis egestas sed tempus urna et. In aliquam sem fringilla ut morbi tincidunt augue interdum. Turpis massa tincidunt dui ut ornare lectus sit amet est. Rhoncus aenean vel elit scelerisque mauris pellentesque pulvinar. Tincidunt ornare massa eget egestas purus viverra accumsan. Egestas fringilla phasellus faucibus scelerisque. Lectus mauris ultrices eros in cursus turpis massa. Vulputate eu scelerisque felis imperdiet proin fermentum leo. Tristique senectus et netus et malesuada. Nec nam aliquam sem et tortor consequat id porta.\n\nCursus sit amet dictum sit amet. Nam at lectus urna duis convallis convallis. Sollicitudin nibh sit amet commodo. Sit amet tellus cras adipiscing enim eu. Egestas purus viverra accumsan in. Sit amet porttitor eget dolor morbi non. Sem viverra aliquet eget sit amet tellus cras adipiscing. Morbi blandit cursus risus at ultrices mi tempus imperdiet nulla. Bibendum enim facilisis gravida neque convallis. Ipsum dolor sit amet consectetur. Et ultrices neque ornare aenean. Urna neque viverra justo nec. Ac felis donec et odio pellentesque diam. Arcu risus quis varius quam quisque id diam. Id aliquet lectus proin nibh. Purus sit amet volutpat consequat mauris nunc. Quam elementum pulvinar etiam non quam lacus suspendisse. Sed adipiscing diam donec adipiscing tristique risus nec. Molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit sed. Diam quis enim lobortis scelerisque fermentum dui.\n\nMalesuada fames ac turpis egestas sed tempus urna et. In aliquam sem fringilla ut morbi tincidunt augue interdum. Turpis massa tincidunt dui ut ornare lectus sit amet est. Rhoncus aenean vel elit scelerisque mauris pellentesque pulvinar. Tincidunt ornare massa eget egestas purus viverra accumsan. Egestas fringilla phasellus faucibus scelerisque. Lectus mauris ultrices eros in cursus turpis massa. Vulputate eu scelerisque felis imperdiet proin fermentum leo. Tristique senectus et netus et malesuada. Nec nam aliquam sem et tortor consequat id porta.\n\nCursus sit amet dictum sit amet. Nam at lectus urna duis convallis convallis. Sollicitudin nibh sit amet commodo. Sit amet tellus cras adipiscing enim eu. Egestas purus viverra accumsan in. Sit amet porttitor eget dolor morbi non. Sem viverra aliquet eget sit amet tellus cras adipiscing. Morbi blandit cursus risus at ultrices mi tempus imperdiet nulla. Bibendum enim facilisis gravida neque convallis. Ipsum dolor sit amet consectetur. Et ultrices neque ornare aenean. Urna neque viverra justo nec. Ac felis donec et odio pellentesque diam. Arcu risus quis varius quam quisque id diam. Id aliquet lectus proin nibh. Purus sit amet volutpat consequat mauris nunc. Quam elementum pulvinar etiam non quam lacus suspendisse. Sed adipiscing diam donec adipiscing tristique risus nec. Molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit sed. Diam quis enim lobortis scelerisque fermentum dui.\n\nMalesuada fames ac turpis egestas sed tempus urna et. In aliquam sem fringilla ut morbi tincidunt augue interdum. Turpis massa tincidunt dui ut ornare lectus sit amet est. Rhoncus aenean vel elit scelerisque mauris pellentesque pulvinar. Tincidunt ornare massa eget egestas purus viverra accumsan. Egestas fringilla phasellus faucibus scelerisque. Lectus mauris ultrices eros in cursus turpis massa. Vulputate eu scelerisque felis imperdiet proin fermentum leo. Tristique senectus et netus et malesuada. Nec nam aliquam sem et tortor consequat id porta.\n\nCursus sit amet dictum sit amet. Nam at lectus urna duis convallis convallis. Sollicitudin nibh sit amet commodo. Sit amet tellus cras adipiscing enim eu. Egestas purus viverra accumsan in. Sit amet porttitor eget dolor morbi non. Sem viverra aliquet eget sit amet tellus cras adipiscing. Morbi blandit cursus risus at ultrices mi tempus imperdiet nulla. Bibendum enim facilisis gravida neque convallis. Ipsum dolor sit amet consectetur. Et ultrices neque ornare aenean. Urna neque viverra justo nec. Ac felis donec et odio pellentesque diam. Arcu risus quis varius quam quisque id diam. Id aliquet lectus proin nibh. Purus sit amet volutpat consequat mauris nunc. Quam elementum pulvinar etiam non quam lacus suspendisse. Sed adipiscing diam donec adipiscing tristique risus nec. Molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit sed. Diam quis enim lobortis scelerisque fermentum dui.\n\nMalesuada fames ac turpis egestas sed tempus urna et. In aliquam sem fringilla ut morbi tincidunt augue interdum. Turpis massa tincidunt dui ut ornare lectus sit amet est. Rhoncus aenean vel elit scelerisque mauris pellentesque pulvinar. Tincidunt ornare massa eget egestas purus viverra accumsan. Egestas fringilla phasellus faucibus scelerisque. Lectus mauris ultrices eros in cursus turpis massa. Vulputate eu scelerisque felis imperdiet proin fermentum leo. Tristique senectus et netus et malesuada. Nec nam aliquam sem et tortor consequat id porta.\n\"\n"
  },
  {
    "path": "sandbox/grist/imports/import_csv.py",
    "content": "\"\"\"\nPlugin for importing CSV files\n\"\"\"\nimport codecs\nimport csv\nimport logging\n\nimport chardet\n\nimport parse_data\nfrom imports import import_utils\n\nimport sys\n# Remove rather small default limit on individual csv fields.\n# While hand-written CSV files are unlikely to have large fields,\n# there can be large fields when CSV is used for interoperability.\ncsv.field_size_limit(sys.maxsize)\n\nlog = logging.getLogger(__name__)\nlog.setLevel(logging.INFO)\n\nSCHEMA = [\n          {\n            'name': 'lineterminator',\n            'type': 'string',\n            'visible': True,\n          },\n          {\n            'name': 'include_col_names_as_headers',\n            'type': 'boolean',\n            'visible': True,\n          },\n          {\n            'name': 'delimiter',\n            'type': 'string',\n            'visible': True,\n          },\n          {\n            'name': 'skipinitialspace',\n            'type': 'boolean',\n            'visible': True,\n          },\n          {\n            'name': 'quotechar',\n            'type': 'string',\n            'visible': True,\n          },\n          {\n            'name': 'doublequote',\n            'type': 'boolean',\n            'visible': True,\n          },\n\n          {\n            'name': 'quoting',\n            'type': 'number',\n            'visible': False,       # Not supported by messytables\n          },\n          {\n            'name': 'escapechar',\n            'type': 'string',\n            'visible': False,       # Not supported by messytables\n          },\n          {\n            'name': 'start_with_row',\n            'type': 'number',\n            'visible': False,       # Not yet implemented\n          },\n          {\n            'name': 'NUM_ROWS',\n            'type': 'number',\n            'visible': False,\n          },\n          {\n            'name': 'encoding',\n            'type': 'string',\n            'visible': True,\n          }\n          ]\n\ndef parse_file_source(file_source, options):\n  parsing_options, export_list = parse_file(import_utils.get_path(file_source), options)\n  return {\"parseOptions\": parsing_options, \"tables\": export_list}\n\ndef parse_file(file_path, parse_options=None):\n  \"\"\"\n  Reads a file path and parse options that are passed in using ActiveDoc.importFile()\n  and returns a tuple with parsing options (users' or guessed) and an object formatted so that\n  it can be used by grist for a bulk add records action.\n  \"\"\"\n  parse_options = parse_options or {}\n\n  given_encoding = parse_options.get('encoding')\n  encoding = given_encoding or detect_encoding(file_path)\n  log.info(\"Using encoding %s (%s)\", encoding, \"given\" if given_encoding else \"detected\")\n\n  try:\n    return _parse_with_encoding(file_path, parse_options, encoding)\n  except Exception as e:\n    encoding = 'utf-8'\n    # For valid encodings, we can do our best and report count of errors. But an invalid encoding\n    # or one with a BOM will produce an exception. For those, fall back to utf-8.\n    parsing_options, export_list = _parse_with_encoding(file_path, parse_options, encoding)\n    parsing_options[\"WARNING\"] = \"{}: {}. Falling back to {}.\\n{}\".format(\n        type(e).__name__, e, encoding, parsing_options.get(\"WARNING\", \"\"))\n    return parsing_options, export_list\n\n\ndef _parse_with_encoding(file_path, parse_options, encoding):\n  codec_errors = CodecErrorsReplace()\n  codecs.register_error('custom', codec_errors)\n  with codecs.open(file_path, mode=\"r\", encoding=encoding, errors=\"custom\") as f:\n    parsing_options, export_list = _parse_open_file(f, parse_options=parse_options)\n    parsing_options[\"encoding\"] = encoding\n    if codec_errors.error_count:\n      parsing_options[\"WARNING\"] = (\n          \"Using encoding %s, encountered %s errors. Use Import Options to change\" %\n          (encoding, codec_errors.error_count))\n    return parsing_options, export_list\n\n\ndef _guess_dialect(file_obj):\n  try:\n    # Restrict allowed delimiters to prevent guessing other char than this list.\n    dialect = csv.Sniffer().sniff(file_obj.read(100000), delimiters=['\\t', ',', ';', '|'])\n    log.info(\"Guessed dialect %s\", dict(dialect.__dict__))\n    # Mimic messytables default for now.\n    dialect.lineterminator = \"\\n\"\n    dialect.doublequote = True\n    return dialect\n  except csv.Error:\n    log.info(\"Cannot guess dialect using Excel as fallback.\")\n    return csv.excel\n  finally:\n    file_obj.seek(0)\n\ndef _parse_open_file(file_obj, parse_options=None):\n  csv_keys = ['delimiter', 'quotechar', 'lineterminator', 'doublequote', 'skipinitialspace']\n  options = {}\n  dialect = _guess_dialect(file_obj)\n\n  csv_options = {}\n  for key in csv_keys:\n    value = parse_options.get(key, getattr(dialect, key, None))\n    if value is not None:\n      csv_options[key] = value\n\n  reader = csv.reader(file_obj, **csv_options)\n\n  rows = list(reader)\n  sample_len = 100\n  sample_rows = rows[:sample_len]\n  data_offset, headers = import_utils.headers_guess(sample_rows)\n\n  # Make sure all header values are strings.\n  for i, header in enumerate(headers):\n    if not isinstance(header, str):\n      headers[i] = str(header)\n\n  log.info(\"Guessed data_offset as %s\", data_offset)\n  log.info(\"Guessed headers as: %s\", headers)\n\n  have_guessed_headers = any(headers)\n  include_col_names_as_headers = parse_options.get('include_col_names_as_headers',\n                                                   have_guessed_headers)\n\n  if include_col_names_as_headers and not have_guessed_headers:\n    # use first line as headers\n    data_offset, first_row = import_utils.find_first_non_empty_row(sample_rows)\n    headers = import_utils.expand_headers(first_row, data_offset, sample_rows)\n\n  elif not include_col_names_as_headers and have_guessed_headers:\n    # move guessed headers to data\n    data_offset -= 1\n    headers = [''] * len(headers)\n\n  rows = rows[data_offset:]\n  num_rows = parse_options.get('NUM_ROWS', 0)\n  table_data_with_types = parse_data.get_table_data(rows, len(headers), num_rows)\n\n  # Identify and remove empty columns, and populate separate metadata and data lists.\n  column_metadata = []\n  table_data = []\n  for col_data, header in zip(table_data_with_types, headers):\n    if not header and all(val == \"\" for val in col_data[\"data\"]):\n      continue # empty column\n    data = col_data.pop(\"data\")\n    col_data[\"id\"] = header\n    column_metadata.append(col_data)\n    table_data.append(data)\n\n  if not table_data:\n    log.info(\"No data found. Aborting CSV import.\")\n    # Don't add tables with no columns.\n    return {}, []\n\n  guessed = reader.dialect\n  quoting = parse_options.get('quoting')\n  options = {\"delimiter\": parse_options.get('delimiter', guessed.delimiter),\n             \"doublequote\": parse_options.get('doublequote', guessed.doublequote),\n             \"lineterminator\": parse_options.get('lineterminator', guessed.lineterminator),\n             \"quotechar\": parse_options.get('quotechar', guessed.quotechar),\n             \"skipinitialspace\": parse_options.get('skipinitialspace', guessed.skipinitialspace),\n             \"include_col_names_as_headers\": include_col_names_as_headers,\n             \"start_with_row\": 1,\n             \"NUM_ROWS\": num_rows,\n             \"SCHEMA\": SCHEMA\n             }\n\n  log.info(\"Output table with %d columns\", len(column_metadata))\n  for c in column_metadata:\n    log.debug(\"Output column %s\", c)\n\n  export_list = [{\n    \"table_name\": None,\n    \"column_metadata\": column_metadata,\n    \"table_data\": table_data\n  }]\n\n  return options, export_list\n\n\nclass CodecErrorsReplace(object):\n  def __init__(self):\n    self.error_count = 0\n    self.first_error = None\n\n  def __call__(self, error):\n    self.error_count += 1\n    if not self.first_error:\n      self.first_error = error\n    return codecs.replace_errors(error)\n\n\ndef detect_encoding(file_path):\n  # Use line-by-line detection as suggested in\n  # https://chardet.readthedocs.io/en/latest/usage.html#advanced-usage.\n  # Using a fixed-sized sample is worse as the sample may end mid-character.\n  detector = chardet.UniversalDetector()\n  with codecs.open(file_path, \"rb\") as f:\n    for line in f.readlines():\n      detector.feed(line)\n      if detector.done:\n        break\n  detector.close()\n  encoding = detector.result[\"encoding\"]\n  # Default to utf-8, and always prefer it over ASCII as the most common superset.\n  if not encoding or encoding == 'ascii':\n    encoding = 'utf-8'\n  return encoding\n"
  },
  {
    "path": "sandbox/grist/imports/import_csv_test.py",
    "content": "# This Python file uses the following encoding: utf-8\n# pylint:disable=line-too-long\nimport csv\nimport os\nimport textwrap\nimport tempfile\nimport unittest\nfrom io import StringIO\n\nfrom imports import import_csv\n\n\ndef _get_fixture(filename):\n  return os.path.join(os.path.dirname(__file__), \"fixtures\", filename)\n\n# For a non-utf8 fixture, there is a problem with 'arc diff' which can't handle files with\n# non-utf8 encodings. So create one on the fly.\nnon_utf8_fixture = None\nnon_utf8_file = None\ndef setUpModule():\n  global non_utf8_file, non_utf8_fixture    # pylint:disable=global-statement\n  with open(_get_fixture('test_encoding_utf8.csv')) as f:\n    non_utf8_file = tempfile.NamedTemporaryFile(mode='wb')\n    non_utf8_file.write(f.read().encode('iso-8859-7'))\n    non_utf8_file.flush()\n  non_utf8_fixture = non_utf8_file.name\n\ndef tearDownModule():\n  non_utf8_file.close()\n\nclass TestImportCSV(unittest.TestCase):\n\n  maxDiff = None\n\n  def _check_options(self, computed, **expected):\n    \"\"\"Check the options returned by `parse_file`.\n\n    Pass as kwarg any non default option as expected.\n    \"\"\"\n    default = {\"delimiter\": \",\",\n               \"doublequote\": True,\n               \"lineterminator\": \"\\n\",\n               \"quotechar\": '\"',\n               \"skipinitialspace\": False,\n               \"include_col_names_as_headers\": True,\n               \"start_with_row\": 1}\n    # Don't check those values, which are not real options.\n    computed.pop(\"NUM_ROWS\", None)\n    computed.pop(\"SCHEMA\", None)\n    default.update(expected)\n    self.assertEqual(computed, default)\n\n  def _check_col(self, sheet, index, name, _typename, values):\n    self.assertEqual(sheet[\"column_metadata\"][index][\"id\"], name)\n    # Previously, strings were parsed and types were guessed in CSV imports.\n    # Now all data is kept as strings and the column type is left as Any\n    # so that type guessing and parsing can happen elsewhere.\n    # To avoid updating 85 calls to _check_col, the typename argument was kept but can be ignored,\n    # and all values are converted back to strings for comparison.\n    self.assertEqual(sheet[\"column_metadata\"][index][\"type\"], \"Any\")\n    values = [str(v) for v in values]\n    self.assertEqual(sheet[\"table_data\"][index], values)\n\n  def _check_num_cols(self, sheet, exp_cols):\n    self.assertEqual(len(sheet[\"column_metadata\"]), exp_cols)\n    self.assertEqual(len(sheet[\"table_data\"]), exp_cols)\n\n\n  def test_csv_types(self):\n    options, parsed_file = import_csv.parse_file(_get_fixture('test_excel_types.csv'), parse_options='')\n    sheet = parsed_file[0]\n    self._check_options(options, encoding='utf-8')\n\n    self._check_col(sheet, 0, \"int1\", \"Int\", [-1234123, '', ''])\n    self._check_col(sheet, 1, \"int2\", \"Int\", [5, '', ''])\n    self._check_col(sheet, 2, \"textint\", \"Text\", [\"12345678902345689\", '', ''])\n    self._check_col(sheet, 3, \"bigint\", \"Text\", [\"320150170634561830\", '', ''])\n    self._check_col(sheet, 4, \"num2\", \"Numeric\", ['123456789.1234560000', '', ''])\n    self._check_col(sheet, 5, \"bignum\", \"Numeric\", ['7.22597E+86', '', ''])\n    self._check_col(sheet, 6, \"date1\", \"DateTime\",\n                    [u'12/22/15 11:59 AM', u'', u''])\n    self._check_col(sheet, 7, \"date2\", \"Date\",\n                    [u'December 20, 2015', u'', u''])\n    self._check_col(sheet, 8, \"datetext\", \"Date\",\n                    [u'12/22/2015', u'', u''])\n    self._check_col(sheet, 9, \"datetimetext\", \"DateTime\",\n                    [u'12/22/2015 00:00:00', u'12/22/2015 13:15:00', u'02/27/2018 16:08:39'])\n\n\n  def test_user_parse_options(self):\n    options = {u'parse_options': {\"escapechar\": None, \"include_col_names_as_headers\": True,\n                                 \"lineterminator\": \"\\n\", \"skipinitialspace\": False,\n                                 \"limit_rows\": False, \"quoting\": 0, \"start_with_row\": 1,\n                                 \"delimiter\": \",\", \"NUM_ROWS\":10,\n                                 \"quotechar\": \"\\\"\", \"doublequote\":True}}\n    parsed_options, parsed_file = import_csv.parse_file(_get_fixture('test_import_csv.csv'),\n                                        **options)\n    parsed_options.pop(\"SCHEMA\")  # This key was not passed.\n    # Those keys are not returned by parse_file, so remove them for now, before comparing.\n    options[\"parse_options\"].pop(\"limit_rows\")\n    options[\"parse_options\"].pop(\"quoting\")\n    options[\"parse_options\"].pop(\"escapechar\")\n    options[\"parse_options\"][\"encoding\"] = \"utf-8\"   # Expected encoding\n    self.assertEqual(options[\"parse_options\"], parsed_options)\n    self._check_options(parsed_options, encoding='utf-8')\n    parsed_file = parsed_file[0]\n\n    self._check_num_cols(parsed_file, 5)\n    self._check_col(parsed_file, 0, \"FIRST_NAME\", \"Text\", ['John', 'Tim', 'Jenny', 'Lily'])\n    self._check_col(parsed_file, 1, \"LAST_NAME\", \"Text\", ['Moor', 'Kale', 'Jo', 'Smit'])\n    self._check_col(parsed_file, 2, \"PHONE\", \"Text\", ['201-343-3434', '201.343.3434',\n                                                      '2013433434', '(201)343-3434'])\n    self._check_col(parsed_file, 3, \"VALUE\", \"Int\", [45, 4545, 0, 4])\n    self._check_col(parsed_file, 4, \"DATE\", \"DateTime\",\n                    [u'2018-02-27 16:08:39 +0000',\n                     u'2018-02-27 16:08:39 +0100',\n                     u'2018-02-27 16:08:39 -0100',\n                     u''])\n\n  def test_wrong_cols1(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      name1, name2, name3\n      a1,b1,c1\n      a2,b2\n      a3\n      \"\"\"))\n\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options={})\n    self._check_options(options, lineterminator='\\r\\n')\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"name1\", \"Text\", [\"a1\", \"a2\", \"a3\"])\n    self._check_col(parsed_file, 1, \"name2\", \"Text\", [\"b1\", \"b2\", \"\"])\n    self._check_col(parsed_file, 2, \"name3\", \"Text\", [\"c1\", \"\", \"\"])\n\n  def test_wrong_cols2(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      name1\n      a1,b1\n      a2,b2,c2\n      \"\"\"))\n\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options={})\n    self._check_options(options, lineterminator='\\r\\n')\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"name1\", \"Text\", [\"a1\", \"a2\"])\n    self._check_col(parsed_file, 1, \"\", \"Text\", [\"b1\", \"b2\"])\n    self._check_col(parsed_file, 2, \"\", \"Text\", [\"\", \"c2\"])\n\n  def test_offset(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      ,,,,,,,\n      name1,name2,name3\n      a1,b1,c1\n      a2,b2,c2\n      a3,b3,c3,d4\n      \"\"\"))\n\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options={})\n    self._check_options(options, lineterminator='\\r\\n')\n    parsed_file = parsed_file[0]\n\n    self._check_num_cols(parsed_file, 4)\n    self._check_col(parsed_file, 0, \"name1\", \"Text\", [\"a1\", \"a2\", \"a3\"])\n    self._check_col(parsed_file, 1, \"name2\", \"Text\", [\"b1\", \"b2\", \"b3\"])\n    self._check_col(parsed_file, 2, \"name3\", \"Text\", [\"c1\", \"c2\", \"c3\"])\n    self._check_col(parsed_file, 3, \"\", \"Text\", [\"\", \"\", \"d4\"])\n\n  def test_offset_no_header(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      4,b1,c1\n      4,b2,c2\n      4,b3,c3\n      \"\"\"))\n\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options={})\n    self._check_options(options, include_col_names_as_headers=False)\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"\", \"Int\", [4, 4, 4])\n    self._check_col(parsed_file, 1, \"\", \"Text\", [\"b1\", \"b2\", \"b3\"])\n    self._check_col(parsed_file, 2, \"\", \"Text\", [\"c1\", \"c2\", \"c3\"])\n\n  def test_empty_headers(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      ,,-,-\n      b,a,a,a,a\n      b,a,a,a,a\n      b,a,a,a,a\n      \"\"\"))\n\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options={})\n    self._check_options(options, lineterminator='\\r\\n')\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 5)\n    self._check_col(parsed_file, 0, \"\", \"Text\", [\"b\", \"b\", \"b\"])\n    self._check_col(parsed_file, 1, \"\", \"Text\", [\"a\", \"a\", \"a\"])\n    self._check_col(parsed_file, 2, \"-\", \"Text\", [\"a\", \"a\", \"a\"])\n    self._check_col(parsed_file, 3, \"-\", \"Text\", [\"a\", \"a\", \"a\"])\n    self._check_col(parsed_file, 4, \"\", \"Text\", [\"a\", \"a\", \"a\"])\n\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      -,-,-,-,-,-\n      b,a,a,a,a\n      b,a,a,a,a\n      b,a,a,a,a\n      \"\"\"))\n\n    parsed_file = import_csv._parse_open_file(file_obj, parse_options={})[1][0]\n    self._check_num_cols(parsed_file, 6)\n    self._check_col(parsed_file, 0, \"-\", \"Text\", [\"b\", \"b\", \"b\"])\n    self._check_col(parsed_file, 1, \"-\", \"Text\", [\"a\", \"a\", \"a\"])\n    self._check_col(parsed_file, 2, \"-\", \"Text\", [\"a\", \"a\", \"a\"])\n    self._check_col(parsed_file, 3, \"-\", \"Text\", [\"a\", \"a\", \"a\"])\n    self._check_col(parsed_file, 4, \"-\", \"Text\", [\"a\", \"a\", \"a\"])\n    self._check_col(parsed_file, 5, \"-\", \"Text\", [\"\", \"\", \"\"])\n\n  def test_guess_missing_user_option(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      name1,;name2,;name3\n      a1,;b1,;c1\n      a2,;b2,;c2\n      a3,;b3,;c3\n      \"\"\"))\n    parse_options = {\"delimiter\": ';',\n                     \"escapechar\": None,\n                     \"lineterminator\": '\\r\\n',\n                     \"quotechar\": '\"',\n                     \"quoting\": csv.QUOTE_MINIMAL}\n\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options=parse_options)\n    self._check_options(options, lineterminator='\\r\\n', delimiter=';')\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"name1,\", \"Text\", [\"a1,\", \"a2,\", \"a3,\"])\n    self._check_col(parsed_file, 1, \"name2,\", \"Text\", [\"b1,\", \"b2,\", \"b3,\"])\n    self._check_col(parsed_file, 2, \"name3\", \"Text\", [\"c1\", \"c2\", \"c3\"])\n\n    # Sniffer detects delimiters in order [',', '\\t', ';', ' ', ':'],\n    # so for this file_obj it will be ','\n    parsed_file = import_csv._parse_open_file(file_obj, parse_options={})[1][0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"name1\", \"Text\", [\"a1\", \"a2\", \"a3\"])\n    self._check_col(parsed_file, 1, \";name2\", \"Text\", [\";b1\", \";b2\", \";b3\"])\n    self._check_col(parsed_file, 2, \";name3\", \"Text\", [\";c1\", \";c2\", \";c3\"])\n\n  def test_one_line_file_no_header(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      2,name2,name3\n      \"\"\"))\n\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options={})\n    self._check_options(options, include_col_names_as_headers=False)\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"\", \"Int\", [2])\n    self._check_col(parsed_file, 1, \"\", \"Text\", [\"name2\"])\n    self._check_col(parsed_file, 2, \"\", \"Text\", [\"name3\"])\n\n  def test_one_line_file_with_header(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      name1,name2,name3\n      \"\"\"))\n\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options={})\n    self._check_options(options)\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"name1\", \"Text\", [])\n    self._check_col(parsed_file, 1, \"name2\", \"Text\", [])\n    self._check_col(parsed_file, 2, \"name3\", \"Text\", [])\n\n  def test_empty_file(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      \"\"\"))\n\n    parsed_file = import_csv._parse_open_file(file_obj, parse_options={})\n    self.assertEqual(parsed_file, ({}, []))\n\n  def test_option_num_rows(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      name1,name2,name3\n      a1,b1,c1\n      a2,b2,c2\n      a3,b3,c3\n      \"\"\"))\n\n    parse_options = {}\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options=parse_options)\n    self._check_options(options)\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"name1\", \"Text\", ['a1', 'a2', 'a3'])\n    self._check_col(parsed_file, 1, \"name2\", \"Text\", ['b1', 'b2', 'b3'])\n    self._check_col(parsed_file, 2, \"name3\", \"Text\", ['c1', 'c2', 'c3'])\n\n    parse_options = {\"NUM_ROWS\": 2}\n    parsed_file = import_csv._parse_open_file(file_obj, parse_options=parse_options)[1][0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"name1\", \"Text\", [\"a1\", \"a2\"])\n    self._check_col(parsed_file, 1, \"name2\", \"Text\", [\"b1\", \"b2\"])\n    self._check_col(parsed_file, 2, \"name3\", \"Text\", [\"c1\", \"c2\"])\n\n    parse_options = {\"NUM_ROWS\": 10}\n    parsed_file = import_csv._parse_open_file(file_obj, parse_options=parse_options)[1][0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"name1\", \"Text\", ['a1', 'a2', 'a3'])\n    self._check_col(parsed_file, 1, \"name2\", \"Text\", ['b1', 'b2', 'b3'])\n    self._check_col(parsed_file, 2, \"name3\", \"Text\", ['c1', 'c2', 'c3'])\n\n  def test_option_num_rows_no_header(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      ,,\n      ,,\n      a1,1,c1\n      a2,2,c2\n      a3,3,c3\n      \"\"\"))\n\n    parse_options = {}\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options=parse_options)\n    self._check_options(options, include_col_names_as_headers=False)\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"\", \"Text\", ['a1', 'a2', 'a3'])\n    self._check_col(parsed_file, 1, \"\", \"Int\", [1, 2, 3])\n    self._check_col(parsed_file, 2, \"\", \"Text\", ['c1', 'c2', 'c3'])\n\n    parse_options = {\"NUM_ROWS\": 2}\n    parsed_file = import_csv._parse_open_file(file_obj, parse_options=parse_options)[1][0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"\", \"Text\", ['a1', 'a2'])\n    self._check_col(parsed_file, 1, \"\", \"Int\", [1, 2])\n    self._check_col(parsed_file, 2, \"\", \"Text\", ['c1', 'c2'])\n\n  def test_option_use_col_name_as_header(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      name1,name2,name3\n      a1,1,c1\n      a2,2,c2\n      a3,3,c3\n      \"\"\"))\n\n    parse_options = {\"include_col_names_as_headers\": False}\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options=parse_options)\n    self._check_options(options, include_col_names_as_headers=False)\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"\", \"Text\", [\"name1\", \"a1\", \"a2\", \"a3\"])\n    self._check_col(parsed_file, 1, \"\", \"Text\", [\"name2\", \"1\", \"2\", \"3\"])\n    self._check_col(parsed_file, 2, \"\", \"Text\", [\"name3\", \"c1\", \"c2\", \"c3\"])\n\n    parse_options = {\"include_col_names_as_headers\": True}\n    parsed_file = import_csv._parse_open_file(file_obj, parse_options=parse_options)[1][0]\n    self._check_num_cols(parsed_file, 3)\n    self._check_col(parsed_file, 0, \"name1\", \"Text\", [\"a1\", \"a2\", \"a3\"])\n    self._check_col(parsed_file, 1, \"name2\", \"Int\", [1, 2, 3])\n    self._check_col(parsed_file, 2, \"name3\", \"Text\", [\"c1\", \"c2\", \"c3\"])\n\n  def test_option_use_col_name_as_header_no_headers(self):\n    file_obj = StringIO(textwrap.dedent(\n      \"\"\"\\\n      ,,,\n      ,,,\n      n1,2,n3\n      a1,1,c1,d1\n      a2,4,c2\n      a3,5,c3\n      \"\"\"))\n\n    parse_options = {\"include_col_names_as_headers\": False}\n    options, parsed_file = import_csv._parse_open_file(file_obj, parse_options=parse_options)\n    self._check_options(options, include_col_names_as_headers=False, lineterminator='\\r\\n')\n    parsed_file = parsed_file[0]\n    self._check_num_cols(parsed_file, 4)\n    self._check_col(parsed_file, 0, \"\", \"Text\", [\"n1\", \"a1\", \"a2\", \"a3\"])\n    self._check_col(parsed_file, 1, \"\", \"Int\", [2, 1, 4, 5])\n    self._check_col(parsed_file, 2, \"\", \"Text\", [\"n3\", \"c1\", \"c2\", \"c3\"])\n    self._check_col(parsed_file, 3, \"\", \"Text\", [\"\", \"d1\", \"\", \"\"])\n\n    parse_options = {\"include_col_names_as_headers\": True}\n    parsed_file = import_csv._parse_open_file(file_obj, parse_options=parse_options)[1][0]\n    self._check_num_cols(parsed_file, 4)\n    self._check_col(parsed_file, 0, \"n1\", \"Text\", [\"a1\", \"a2\", \"a3\"])\n    self._check_col(parsed_file, 1, \"2\", \"Int\", [1, 4, 5])\n    self._check_col(parsed_file, 2, \"n3\", \"Text\", [\"c1\", \"c2\", \"c3\"])\n    self._check_col(parsed_file, 3, \"\", \"Text\", [ \"d1\", \"\", \"\"])\n\n  def test_csv_with_very_long_cell(self):\n    options, parsed_file = import_csv.parse_file(_get_fixture('test_long_cell.csv'), parse_options='')\n    self._check_options(options, encoding='utf-8')\n    sheet = parsed_file[0]\n    long_cell = sheet[\"table_data\"][1][0]\n    self.assertEqual(len(long_cell), 8058)\n    self._check_col(sheet, 0, \"ID\", \"Int\", [17])\n    self._check_col(sheet, 1, \"LongText\", \"Text\", [long_cell])\n\n  def test_csv_with_surprising_isdigit(self):\n    options, parsed_file = import_csv.parse_file(_get_fixture('test_isdigit.csv'), parse_options='')\n    self._check_options(options, encoding='utf-8')\n    sheet = parsed_file[0]\n    self._check_num_cols(sheet, 3)\n    self._check_col(sheet, 0, \"PHONE\", \"Text\", [u'201-¾᠓𑄺꤈꤈꧐꤆'])\n    self._check_col(sheet, 1, \"VALUE\", \"Text\", [u'¹5'])\n    self._check_col(sheet, 2, \"DATE\", \"Text\", [u'2018-0²-27 16:08:39 +0000'])\n\n  def test_csv_encoding_detection_utf8(self):\n    options, parsed_file = import_csv.parse_file(_get_fixture('test_encoding_utf8.csv'), parse_options='')\n    self._check_options(options, encoding='utf-8')\n    sheet = parsed_file[0]\n    self._check_col(sheet, 0, \"Name\", \"Text\", [u'John Smith', u'Μαρία Παπαδοπούλου', u'Δημήτρης Johnson'])\n    self._check_col(sheet, 2, \"Επάγγελμα\", \"Text\", [u'Γιατρός', u'Engineer', u'Δικηγόρος'])\n\n  def test_csv_encoding_detection_greek(self):\n    # ISO-8859-7 is close to CP1253, and this fixure file would be identical in these two.\n    options, parsed_file = import_csv.parse_file(non_utf8_fixture, parse_options='')\n    self._check_options(options, encoding='ISO-8859-7')\n    sheet = parsed_file[0]\n    self._check_col(sheet, 0, \"Name\", \"Text\", [u'John Smith', u'Μαρία Παπαδοπούλου', u'Δημήτρης Johnson'])\n    self._check_col(sheet, 2, \"Επάγγελμα\", \"Text\", [u'Γιατρός', u'Engineer', u'Δικηγόρος'])\n\n    # Similar enough encoding that the result is correct.\n    options, parsed_file = import_csv.parse_file(non_utf8_fixture, parse_options={\"encoding\": \"cp1253\"})\n    self._check_options(options, encoding='cp1253')   # The encoding should be respected\n    sheet = parsed_file[0]\n    self._check_col(sheet, 0, \"Name\", \"Text\", [u'John Smith', u'Μαρία Παπαδοπούλου', u'Δημήτρης Johnson'])\n    self._check_col(sheet, 2, \"Επάγγελμα\", \"Text\", [u'Γιατρός', u'Engineer', u'Δικηγόρος'])\n\n  def test_csv_encoding_errors_are_handled(self):\n    # With ascii, we'll get many decoding errors, but parsing should still succeed.\n    parse_options = {\n      \"encoding\": \"ascii\",\n      \"include_col_names_as_headers\": True,\n    }\n    options, parsed_file = import_csv.parse_file(non_utf8_fixture, parse_options=parse_options)\n    self._check_options(options,\n        encoding='ascii',\n        WARNING='Using encoding ascii, encountered 108 errors. Use Import Options to change')\n    sheet = parsed_file[0]\n    self._check_col(sheet, 0, \"Name\", \"Text\", [u'John Smith', u'����� ������������', u'�������� Johnson'])\n    self._check_col(sheet, 2, \"���������\", \"Text\", [u'�������', u'Engineer', u'���������'])\n\n  def test_csv_encoding_mismatch(self):\n    # Here we use a wrong single-byte encoding, to check that it succeeds even if with nonsense.\n    parse_options = {\n      \"encoding\": \"cp1254\",\n      \"include_col_names_as_headers\": True,\n    }\n    options, parsed_file = import_csv.parse_file(non_utf8_fixture, parse_options=parse_options)\n    self._check_options(options, encoding='cp1254')\n    sheet = parsed_file[0]\n    self._check_col(sheet, 0, \"Name\", \"Text\", [u'John Smith', u'Ìáñßá Ğáğáäïğïıëïõ', u'ÄçìŞôñçò Johnson'])\n    self._check_col(sheet, 2, \"ÅğÜããåëìá\", \"Text\", [u'Ãéáôñüò', u'Engineer', u'Äéêçãüñïò'])\n\n\nif __name__ == '__main__':\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/imports/import_json.py",
    "content": "\"\"\"\nThe import_json module converts json file into a list of grist tables.\n\nIt supports data being structured as a list of record, turning each\nobject into a row and each object's key into a column. For\nexample:\n```\n[{'a': 1, 'b': 'tree'}, {'a': 4, 'b': 'flowers'}, ... ]\n```\nis turned into a table with two columns 'a' of type 'Numeric' and 'b' of\ntype 'Text'.\n\nNested object are stored as references to a distinct table where the\nnested object is stored. For example:\n```\n[{'a': {'b': 4}}, ...]\n```\nis turned into a column 'a' of type 'Ref:my_import_name.a', and into\nanother table 'my_import_name.a' with a column 'b' of type\n'Numeric'. (Nested-nested objects are supported as well and the module\nassumes no limit to the number of level of nesting you can do.)\n\nEach value which is not an object will be stored into a column with id\n'' (empty string). For example:\n```\n['apple', 'peach', ... ]\n```\nis turned into a table with an un-named column that stores the values.\n\nArrays are stored as a list of references to a table where the content\nof the array is stored. For example:\n```\n[{'items': [{'a':'apple'}, {'a':'peach'}]}, {'items': [{'a':'cucumber'}, {'a':'carots'}, ...]}, ...]\n```\nis turned into a column named 'items' of type\n'RefList:my_import_name.items' which points to another table named\n'my_import_name.items' which has a column 'a' of type Text.\n\nData could be structured with an object at the root as well in which\ncase, the object is considered to represent a single row, and gets\nturned into a table with one row.\n\nA column's type is defined by the type of its first value that is not\nNone (ie: if another value with different type is stored in the same\ncolumn, the column's type remains unchanged), 'Text' otherwise.\n\nUsage:\nimport import_json\n# if you have a file to parse\nimport_json.parse_file(file_path)\n\n# if data is already encoded with python's standard containers (dict and list)\nimport_json.dumps(data, import_name)\n\n\nTODO:\n  - references should map to appropriate column type ie: `Ref:{$colname}` and\n    `RefList:{$colname}` (which depends on T413).\n  - Allows user to set the uniqueValues options per table.\n  - User should be able to choose some objects to be imported as\n    indexes: for instance:\n```\n{\n  'pink lady': {'type': 'apple', 'taste': 'juicy'},\n  'gala':      {'type': 'apple', 'taste': 'tart'},\n  'comice':    {'type': 'pear', 'taste': 'lemon'},\n   ...\n}\n```\n   could be mapped to columns 'type', 'taste' and a 3rd that holds the\n   property 'name'.\n\n\"\"\"\nimport os\nimport json\nfrom collections import OrderedDict, namedtuple\nfrom itertools import count, chain\n\nfrom imports import import_utils\n\nRef = namedtuple('Ref', ['table_name', 'rowid'])\nRow = namedtuple('Row', ['values', 'parent', 'ref'])\nCol = namedtuple('Col', ['type', 'values'])\n\nGRIST_TYPES={\n  int: \"Numeric\",\n  float: \"Numeric\",\n  bool: \"Bool\",\n  str: \"Text\",\n}\n\nSCHEMA = [{\n  'name': 'includes',\n  'label': 'Includes (list of tables separated by semicolon)',\n  'type': 'string',\n  'visible': True\n}, {\n  'name': 'excludes',\n  'label': 'Excludes (list of tables separated by semicolon)',\n  'type': 'string',\n  'visible': True\n}]\n\nDEFAULT_PARSE_OPTIONS = {\n  'includes': '',\n  'excludes': '',\n  'SCHEMA': SCHEMA\n}\n\ndef parse_file(file_source, parse_options):\n  \"Deserialize `file_source` into a python object and dumps it into jgrist form\"\n  path = import_utils.get_path(file_source)\n  name, ext = os.path.splitext(file_source['origName'])\n  if 'SCHEMA' not in parse_options:\n    parse_options.update(DEFAULT_PARSE_OPTIONS)\n  with open(path, 'r') as json_file:\n    data = json.loads(json_file.read())\n\n    return dumps(data, name, parse_options)\n\ndef dumps(data, name = \"\", parse_options = DEFAULT_PARSE_OPTIONS):\n  \" Serializes `data` to a jgrist formatted object. \"\n  tables = Tables(parse_options)\n  if not isinstance(data, list):\n    # put simple record into a list\n    data = [data]\n  for val in data:\n    tables.add_row(name, val)\n  return {\n    'tables': tables.dumps(),\n    'parseOptions': parse_options\n  }\n\n\nclass Tables(object):\n  \"\"\"\n  Tables maintains the list of tables indexed by their name. Each table\n  is a list of row. A row is a dictionary mapping columns id to a value.\n  \"\"\"\n\n  def __init__(self, parse_options):\n    self._tables = OrderedDict()\n    self._includes_opt = list(filter(None, parse_options['includes'].split(';')))\n    self._excludes_opt = list(filter(None, parse_options['excludes'].split(';')))\n\n\n  def dumps(self):\n    \" Dumps tables in jgrist format \"\n    return [_dump_table(name, rows) for name, rows in self._tables.items()]\n\n  def add_row(self, table, value, parent = None):\n    \"\"\"\n    Adds a row to `table` and fill it with the content of value, then\n    returns a Ref object pointing to this row. Returns None if the row\n    was excluded. Calls itself recursively to add nested object and\n    lists.\n    \"\"\"\n    row = None\n    if self._is_included(table):\n      rows = self._tables.setdefault(table, [])\n      row = Row(OrderedDict(), parent, Ref(table, len(rows)+1))\n      rows.append(row)\n\n    # we need a dictionary to map values to the row's columns\n    value = _dictify(value)\n    for (k, val) in sorted(value.items()):\n      if isinstance(val, dict):\n        val = self.add_row(table + '_' + k, val)\n        if row and val:\n          row.values[k] = val.ref\n      elif isinstance(val, list):\n        for list_val in val:\n          self.add_row(table + '_' + k, list_val, row)\n      else:\n        if row and self._is_included(table + '_' + k):\n          row.values[k] = val\n    return row\n\n\n  def _is_included(self, property_path):\n    is_included = (any(property_path.startswith(inc) for inc in self._includes_opt)\n                   if self._includes_opt else True)\n    is_excluded = (any(property_path.startswith(exc) for exc in self._excludes_opt)\n                   if self._excludes_opt else False)\n    return is_included and not is_excluded\n\n\ndef first_available_key(dictionary, name):\n  \"\"\"\n  Returns the first of (name, name2, name3 ...) that is not a key of\n  dictionary.\n  \"\"\"\n  names = chain([name], (\"{}{}\".format(name, i) for i in count(2)))\n  return next(n for n in names if n not in dictionary)\n\n\ndef _dictify(value):\n  \"\"\"\n  Converts non-dictionary value to a dictionary with a single\n  empty-string key mapping to the given value. Or returns the value\n  itself if it's already a dictionary. This is useful to map values to\n  row's columns.\n  \"\"\"\n  return value if isinstance(value, dict) else {'': value}\n\n\ndef _dump_table(name, rows):\n  \"Converts a list of rows into a jgrist table and set 'table_name' to name.\"\n  columns = _transpose([r.values for r in rows])\n  # find ref to first parent\n  ref = next((r.parent.ref for r in rows if r.parent), None)\n  if ref:\n    # adds a column to store ref to parent\n    col_id = first_available_key(columns, ref.table_name)\n    columns[col_id] = Col(_grist_type(ref),\n                          [row.parent.ref if row.parent else None for row in rows])\n  return {\n    'column_metadata': [{'id': key, 'type': col.type} for (key, col) in columns.items()],\n    'table_data': [[_dump_value(val) for val in col.values] for col in columns.values()],\n    'table_name': name\n  }\n\ndef _transpose(rows):\n  \"\"\"\n  Transposes a collection of dictionary mapping key to values into a\n  dictionary mapping key to values. Values are encoded into a tuple\n  made of the grist_type of the first value that is not None and the\n  collection of values.\n  \"\"\"\n  transpose = OrderedDict()\n  values = OrderedDict()\n  for row in reversed(rows):\n    values.update(row)\n  for key, val in values.items():\n    transpose[key] = Col(_grist_type(val), [row.get(key, None) for row in rows])\n  return transpose\n\n\ndef _dump_value(value):\n  \" Serialize a value.\"\n  if isinstance(value, Ref):\n    return value.rowid\n  return value\n\n\ndef _grist_type(value):\n  \" Returns the grist type for value. \"\n  val_type = type(value)\n  if val_type == Ref:\n    return 'Ref:{}'.format(value.table_name)\n  return GRIST_TYPES.get(val_type, 'Text')\n"
  },
  {
    "path": "sandbox/grist/imports/import_json_test.py",
    "content": "from unittest import TestCase\nfrom imports import import_json\n\nclass TestImportJSON(TestCase):\n\n  maxDiff = None\n\n  def test_simple_json_array(self):\n    grist_tables = import_json.dumps([{'a': 1, 'b': 'baba'}, {'a': 4, 'b': 'abab'}], '')\n    self.assertEqual(grist_tables['tables'], [{\n      'column_metadata': [\n        {'id': 'a', 'type': 'Numeric'}, {'id': 'b', 'type': 'Text'}],\n      'table_data': [[1, 4],  ['baba', 'abab']],\n      'table_name': ''\n    }])\n\n  def test_missing_data(self):\n    grist_tables = import_json.dumps([{'a': 1}, {'b': 'abab'}, {'a': 4}])\n    self.assertEqual(grist_tables['tables'], [{\n      'column_metadata': [\n        {'id': 'a', 'type': 'Numeric'}, {'id': 'b', 'type': 'Text'}],\n      'table_data': [[1, None, 4],  [None, 'abab', None]],\n      'table_name': ''\n    }])\n\n  def test_even_more_simple_array(self):\n    self.assertEqual(\n      import_json.dumps(['apple', 'pear', 'banana'], '')['tables'],\n      [{\n        'column_metadata': [\n          {'id': '', 'type': 'Text'}],\n        'table_data': [['apple', 'pear', 'banana']],\n        'table_name': ''\n        }])\n\n  def test_mixing_simple_and_even_more_simple(self):\n    self.assertEqual(\n      import_json.dumps(['apple', 'pear', {'a': 'some cucumbers'}, 'banana'], '')['tables'],\n      [{\n        'column_metadata': [\n          {'id': '', 'type': 'Text'},\n          {'id': 'a', 'type': 'Text'}],\n        'table_data': [['apple', 'pear', None, 'banana'], [None, None, 'some cucumbers', None]],\n        'table_name': ''\n        }])\n\n  def test_array_with_reference(self):\n    # todo: reference should follow Grist's format\n    self.assertEqual(\n      import_json.dumps([{'a': {'b': 2}, 'c': 'foo'}], 'Hello')['tables'],\n      [{\n          'column_metadata': [\n            {'id': 'a', 'type': 'Ref:Hello_a'}, {'id': 'c', 'type': 'Text'}\n          ],\n          'table_data': [[1],  ['foo']],\n          'table_name': 'Hello'\n        }, {\n          'column_metadata': [\n            {'id': 'b', 'type': 'Numeric'}\n          ],\n          'table_data': [[2]],\n          'table_name': 'Hello_a'\n        }])\n\n  def test_nested_nested_object(self):\n    self.assertEqual(\n      import_json.dumps([{'a': {'b': 2, 'd': {'a': 'sugar'}}, 'c': 'foo'}], 'Hello')['tables'],\n      [{\n          'column_metadata': [\n            {'id': 'a', 'type': 'Ref:Hello_a'}, {'id': 'c', 'type': 'Text'}\n          ],\n          'table_data': [[1],  ['foo']],\n          'table_name': 'Hello'\n        }, {\n          'column_metadata': [\n            {'id': 'b', 'type': 'Numeric'}, {'id': 'd', 'type': 'Ref:Hello_a_d'}\n          ],\n          'table_data': [[2], [1]],\n          'table_name': 'Hello_a'\n        }, {\n          'column_metadata': [\n            {'id': 'a', 'type': 'Text'}\n          ],\n          'table_data': [['sugar']],\n          'table_name': 'Hello_a_d'\n        }])\n\n\n  def test_array_with_list(self):\n    self.assertEqual(\n      import_json.dumps([{'a': ['ES', 'FR', 'US']}, {'a': ['FR']}], 'Hello')['tables'],\n      [{\n        'column_metadata': [],\n        'table_data': [],\n        'table_name': 'Hello'\n        }, {\n          'column_metadata': [{'id': '', 'type': 'Text'}, {'id': 'Hello', 'type': 'Ref:Hello'}],\n          'table_data': [['ES', 'FR', 'US', 'FR'], [1, 1, 1, 2]],\n          'table_name': 'Hello_a'\n        }])\n\n  def test_array_with_list_of_dict(self):\n    self.assertEqual(\n      import_json.dumps([{'a': [{'b': 1}, {'b': 4}]}, {'c': 2}], 'Hello')['tables'],\n      [ {\n          'column_metadata': [{'id': 'c', 'type': 'Numeric'}],\n          'table_data': [[None, 2]],\n          'table_name': 'Hello'\n        }, {\n          'column_metadata': [\n            {'id': 'b', 'type': 'Numeric'},\n            {'id': 'Hello', 'type': 'Ref:Hello'}\n          ],\n          'table_data': [[1, 4], [1, 1]],\n          'table_name': 'Hello_a'\n        }])\n\n\n  def test_array_of_array(self):\n    self.assertEqual(\n      import_json.dumps([['FR', 'US'], ['ES', 'CH']], 'Hello')['tables'],\n      [{\n          'column_metadata': [],\n          'table_data': [],\n          'table_name': 'Hello'\n        }, {\n          'column_metadata': [{'id': '', 'type': 'Text'}, {'id': 'Hello', 'type': 'Ref:Hello'}],\n          'table_data': [['FR', 'US', 'ES', 'CH'], [1, 1, 2, 2]],\n          'table_name': 'Hello_'\n        }, ])\n\n\n  def test_json_dict(self):\n    self.assertEqual(\n      import_json.dumps({\n        'foo': [{'a': 1, 'b': 'santa'}, {'a': 4, 'b': 'cats'}],\n        'bar': [{'c': 2, 'd': 'ducks'}, {'c': 5, 'd': 'dogs'}],\n        'status': {'success': True, 'time': '5s'}\n      }, 'Hello')['tables'], [{\n          'table_name': 'Hello',\n          'column_metadata': [{'id': 'status', 'type': 'Ref:Hello_status'}],\n          'table_data': [[1]]\n        }, {\n          'table_name': 'Hello_bar',\n          'column_metadata': [\n            {'id': 'c', 'type': 'Numeric'},\n            {'id': 'd', 'type': 'Text'},\n            {'id': 'Hello', 'type': 'Ref:Hello'}\n          ],\n          'table_data': [[2, 5], ['ducks', 'dogs'], [1, 1]]\n        }, {\n          'table_name': 'Hello_foo',\n          'column_metadata': [\n            {'id': 'a', 'type': 'Numeric'},\n            {'id': 'b', 'type': 'Text'},\n            {'id': 'Hello', 'type': 'Ref:Hello'}],\n          'table_data': [[1, 4], ['santa', 'cats'], [1, 1]]\n        }, {\n          'table_name': 'Hello_status',\n          'column_metadata': [\n            {'id': 'success', 'type': 'Bool'},\n            {'id': 'time', 'type': 'Text'}\n          ],\n          'table_data': [[True], ['5s']]\n        }])\n\n  def test_json_types(self):\n    self.assertEqual(import_json.dumps({\n      'a': 3, 'b': 3.14, 'c': True, 'd': 'name', 'e': -4, 'f': '3.14', 'g': None\n    }, 'Hello')['tables'],\n      [{\n        'table_name': 'Hello',\n        'column_metadata': [\n          {'id': 'a', 'type': 'Numeric'},\n          {'id': 'b', 'type': 'Numeric'},\n          {'id': 'c', 'type': 'Bool'},\n          {'id': 'd', 'type': 'Text'},\n          {'id': 'e', 'type': 'Numeric'},\n          {'id': 'f', 'type': 'Text'},\n          {'id': 'g', 'type': 'Text'}\n        ],\n      'table_data': [[3], [3.14], [True], ['name'], [-4], ['3.14'], [None]]\n      }])\n\n  def test_type_is_defined_with_first_value(self):\n    tables = import_json.dumps([{'a': 'some text'}, {'a': 3}], '')\n    self.assertIsNotNone(tables['tables'])\n    self.assertIsNotNone(tables['tables'][0])\n    self.assertIsNotNone(tables['tables'][0]['column_metadata'])\n    self.assertIsNotNone(tables['tables'][0]['column_metadata'][0])\n    self.assertEqual(tables['tables'][0]['column_metadata'][0]['type'], 'Text')\n\n  def test_first_unique_key(self):\n    self.assertEqual(import_json.first_available_key({'a': 1}, 'a'), 'a2')\n    self.assertEqual(import_json.first_available_key({'a': 1}, 'b'), 'b')\n    self.assertEqual(import_json.first_available_key({'a': 1, 'a2': 1}, 'a'), 'a3')\n\n\ndef dump_tables(options):\n  data = {\n    \"foos\": [\n      {'foo': 1, 'link': [1, 2]},\n      {'foo': 2, 'link': [1, 2]}\n    ],\n    \"bar\": {'hi': 'santa'}\n  }\n  return [t for t in import_json.dumps(data, 'FooBar', options)['tables']]\n\n\nclass TestParseOptions(TestCase):\n\n  maxDiff = None\n\n  # helpers\n  def assertColInTable(self, tables, **kwargs):\n    table = next(t for t in tables if t['table_name'] == kwargs['table_name'])\n    self.assertEqual(any(col['id'] == kwargs['col_id'] for col in table['column_metadata']),\n                     kwargs['present'])\n\n  def assertTableNamesEqual(self, tables, expected_table_names):\n    table_names = [t['table_name'] for t in tables]\n    self.assertEqual(sorted(table_names), sorted(expected_table_names))\n\n  def test_including_empty_string_includes_all(self):\n    tables = dump_tables({'includes': '', 'excludes': ''})\n    self.assertTableNamesEqual(tables, ['FooBar', 'FooBar_bar', 'FooBar_foos', 'FooBar_foos_link'])\n\n  def test_including_foos_includes_nested_object_and_removes_ref_to_table_not_included(self):\n    tables = dump_tables({'includes': 'FooBar_foos', 'excludes': ''})\n    self.assertTableNamesEqual(tables, ['FooBar_foos', 'FooBar_foos_link'])\n    self.assertColInTable(tables, table_name='FooBar_foos', col_id='FooBar', present=False)\n    tables = dump_tables({'includes': 'FooBar_foos_link', 'excludes': ''})\n    self.assertTableNamesEqual(tables, ['FooBar_foos_link'])\n    self.assertColInTable(tables, table_name='FooBar_foos_link', col_id='FooBar_foos',\n                          present=False)\n\n  def test_excluding_foos_excludes_nested_object_and_removes_link_to_excluded_table(self):\n    tables = dump_tables({'includes': '', 'excludes': 'FooBar_foos'})\n    self.assertTableNamesEqual(tables, ['FooBar', 'FooBar_bar'])\n    self.assertColInTable(tables, table_name='FooBar', col_id='foos', present=False)\n\n  def test_excludes_works_on_nested_object_that_are_included(self):\n    tables = dump_tables({'includes': 'FooBar_foos', 'excludes': 'FooBar_foos_link'})\n    self.assertTableNamesEqual(tables, ['FooBar_foos'])\n\n  def test_excludes_works_on_property(self):\n    tables = dump_tables({'includes': '', 'excludes': 'FooBar_foos_foo'})\n    self.assertTableNamesEqual(tables, ['FooBar', 'FooBar_foos', 'FooBar_foos_link', 'FooBar_bar'])\n    self.assertColInTable(tables, table_name='FooBar_foos', col_id='foo', present=False)\n\n  def test_works_with_multiple_includes(self):\n    tables = dump_tables({'includes': 'FooBar_foos_link', 'excludes': ''})\n    self.assertTableNamesEqual(tables, ['FooBar_foos_link'])\n    tables = dump_tables({'includes': 'FooBar_foos_link;FooBar_bar', 'excludes': ''})\n    self.assertTableNamesEqual(tables, ['FooBar_bar', 'FooBar_foos_link'])\n\n  def test_works_with_multiple_excludes(self):\n    tables = dump_tables({'includes': '', 'excludes': 'FooBar_foos_link;FooBar_bar'})\n    self.assertTableNamesEqual(tables, ['FooBar', 'FooBar_foos'])\n"
  },
  {
    "path": "sandbox/grist/imports/import_utils.py",
    "content": "\"\"\"\nHelper functions for import plugins\n\"\"\"\nimport itertools\nimport logging\nimport os\nfrom collections import defaultdict\nfrom pathlib import Path, PureWindowsPath, PurePosixPath\n\nlog = logging.getLogger(__name__)\n\ndef column_count_modal(rows):\n  \"\"\" Return the modal value of columns in the row_set's\n  sample. This can be assumed to be the number of columns\n  of the table. \"\"\"\n  counts = defaultdict(int)\n  for row in rows:\n    length = len([c for c in row if not empty(c)])\n    if length > 1:\n      counts[length] += 1\n  if not counts:\n    return 0\n  return max(list(counts.items()), key=lambda k_v: k_v[1])[0]\n\n\n\ndef empty(value):\n  \"\"\" Stringify the value and check that it has a length. \"\"\"\n  if value is None:\n    return True\n  if not isinstance(value, str):\n    value = str(value)\n  return not value.strip()\n\n# Get path to an imported file.\ndef get_path(file_source):\n  \"\"\"Constructs the full path to an imported file, handling cross-platform path conventions.\"\"\"\n  importdir = os.environ.get('IMPORTDIR') or '/importdir'\n  file_source_path = file_source['path']\n  path_flavor = file_source.get('pathFlavor', 'posix')\n  \n  # Parse the incoming path using the appropriate pathlib class\n  if path_flavor == \"windows\":\n    incoming_path = PureWindowsPath(file_source_path)\n  else:\n    incoming_path = PurePosixPath(file_source_path)\n  \n  final_path = Path(importdir) / incoming_path\n  return str(final_path)\n\ndef capitalize(word):\n  \"\"\"Capitalize the first character in the word (without lowercasing the rest).\"\"\"\n  return word[0].capitalize() + word[1:]\n\ndef _is_numeric(text):\n  for t in (float, int,):\n    try:\n      t(text)\n      return True\n    except (ValueError, OverflowError, TypeError):\n      pass\n  return False\n\n\ndef _is_header(header, data_rows):\n  \"\"\"\n  Returns whether header can be considered a legitimate header for data_rows.\n  \"\"\"\n  # See if the row has any non-text values.\n  for cell in header:\n    if not (isinstance(cell, str) or cell is None) or _is_numeric(cell):\n      return False\n\n\n  # If it's all text, see if the values in the first row repeat in other rows. That's uncommon for\n  # a header.\n  for row in data_rows:\n    for cell, header_cell in zip(row, header):\n      if cell and cell == header_cell:\n        return False\n\n  return True\n\ndef _count_nonempty(row):\n  \"\"\"\n  Returns the count of cells in row, ignoring trailing empty cells.\n  \"\"\"\n  count = 0\n  for i, c in enumerate(row):\n    if not empty(c):\n      count = i + 1\n  return count\n\n\ndef find_first_non_empty_row(rows):\n  \"\"\"\n  Returns (data_offset, header) of the first row with non-empty fields\n  or (0, []) if there are no non-empty rows.\n  \"\"\"\n  tolerance = 1\n  modal = column_count_modal(rows)\n  for i, row in enumerate(rows):\n    length = _count_nonempty(row)\n    if length >= modal - tolerance:\n      return i + 1, row\n  # No non-empty rows.\n  return 0, []\n\n\ndef expand_headers(headers, data_offset, rows):\n  \"\"\"\n  Returns expanded header to have enough columns for all rows in the given sample.\n  \"\"\"\n  row_length = max(itertools.chain([len(headers)],\n                                   (_count_nonempty(r) for r in itertools.islice(rows, data_offset,\n                                                                                 None))))\n  header_values = [h.strip() if h else '' for h in headers] + [u''] * (row_length - len(headers))\n  return header_values\n\n\ndef headers_guess(rows):\n  \"\"\"\n  Our own smarter version of messytables.headers_guess, which also guesses as to whether one of\n  the first rows is in fact a header. Returns (data_offset, headers) where data_offset is the\n  index of the first line of data, and headers is the list of guessed headers (which will contain\n  empty strings if the file had no headers).\n  \"\"\"\n  # Messytables guesses at the length of data rows, and then assumes that the first row that has\n  # close to that many non-empty fields is the header, where by \"close\" it means 1 less.\n  #\n  # For Grist, it's better to mistake headers for data than to mistake data for headers. Note that\n  # there is csv.Sniffer().has_header(), which tries to be clever, but it's messes up too much.\n  #\n  # We only consider for the header the first row with non-empty cells. It is a header if\n  #   - it has no non-text fields\n  #   - none of the fields have a value that repeats in that column of data\n\n  # Find the first row with non-empty fields.\n  data_offset, header = find_first_non_empty_row(rows)\n  if not header:\n    return data_offset, header\n\n  # Let's see if row is really a header.\n  if not _is_header(header, itertools.islice(rows, data_offset, None)):\n    data_offset -= 1\n    header = []\n\n  # Expand header to have enough columns for all rows in the given sample.\n  header_values = expand_headers(header, data_offset, rows)\n\n  return data_offset, header_values\n"
  },
  {
    "path": "sandbox/grist/imports/import_xls.py",
    "content": "\"\"\"\nThis module reads a file path that is passed in using ActiveDoc.importFile()\nand returns a object formatted so that it can be used by grist for a bulk add records action\n\"\"\"\nimport logging\n\nimport openpyxl\nfrom openpyxl.utils.datetime import from_excel\nfrom openpyxl.worksheet import _reader    # pylint:disable=no-name-in-module\n\nimport parse_data\nfrom imports import import_utils\n\nlog = logging.getLogger(__name__)\nlog.setLevel(logging.WARNING)\n\n\n# Some strange Excel files have values that are marked as dates but are invalid as dates.\n# Normally, openpyxl converts these to `#VALUE!`, but keeping the original value is better.\n# In the case where this was encountered, the original value is a large number:\n# the 6281228502068 in test_excel_strange_dates.\n# Here we monkeypatch openpyxl to keep the original value.\ndef new_from_excel(value, *args, **kwargs):\n  try:\n    return from_excel(value, *args, **kwargs)\n  except (ValueError, OverflowError):\n    return value\n_reader.from_excel = new_from_excel\n\n\ndef import_file(file_source):\n  path = import_utils.get_path(file_source)\n  parse_options, tables = parse_file(path)\n  return {\"parseOptions\": parse_options, \"tables\": tables}\n\n\ndef parse_file(file_path):\n  with open(file_path, \"rb\") as f:\n    return parse_open_file(f)\n\n\ndef parse_open_file(file_obj):\n  workbook = openpyxl.load_workbook(\n    file_obj,\n    read_only=True,\n    keep_vba=False,\n    data_only=True,\n    keep_links=False,\n  )\n\n  skipped_tables = 0\n  export_list = []\n  # A table set is a collection of tables:\n  for sheet in workbook:\n    # openpyxl fails to read xlsx files with incorrect dimensions; we reset here as a precaution.\n    # See https://openpyxl.readthedocs.io/en/stable/optimized.html#worksheet-dimensions.\n    sheet.reset_dimensions()\n\n    table_name = sheet.title\n    rows = [\n      list(row)\n      for row in sheet.iter_rows(values_only=True)\n      # Exclude empty rows, i.e. rows with only empty values.\n      # `if not any(row)` would be slightly faster, but would count `0` as empty.\n      if not set(row) <= {None, \"\"}\n    ]\n    # Resetting dimensions via openpyxl causes rows to not be padded. Make sure\n    # sample rows are padded; get_table_data will handle padding the rest.\n    sample = _with_padding(rows[:1000])\n    data_offset, headers = import_utils.headers_guess(sample)\n    rows = rows[data_offset:]\n\n    # Make sure all header values are strings.\n    for i, header in enumerate(headers):\n      if header is None:\n        headers[i] = u''\n      elif not isinstance(header, str):\n        headers[i] = str(header)\n\n    log.debug(\"Guessed data_offset as %s\", data_offset)\n    log.debug(\"Guessed headers as: %s\", headers)\n\n    table_data_with_types = parse_data.get_table_data(rows, len(headers))\n\n    # Identify and remove empty columns, and populate separate metadata and data lists.\n    column_metadata = []\n    table_data = []\n    for col_data, header in zip(table_data_with_types, headers):\n      if not header and all(val == \"\" for val in col_data[\"data\"]):\n        continue # empty column\n      data = col_data.pop(\"data\")\n      col_data[\"id\"] = header\n      column_metadata.append(col_data)\n      table_data.append(data)\n\n    if not table_data:\n      # Don't add tables with no columns.\n      skipped_tables += 1\n      continue\n\n    log.info(\"Output table %r with %d columns\", table_name, len(column_metadata))\n    for c in column_metadata:\n      log.debug(\"Output column %s\", c)\n    export_list.append({\n      \"table_name\": table_name,\n      \"column_metadata\": column_metadata,\n      \"table_data\": table_data\n    })\n\n  if not export_list:\n    if skipped_tables:\n      raise Exception(\"No tables found ({} empty tables skipped)\".format(skipped_tables))\n    raise Exception(\"No tables found\")\n\n  parse_options = {}\n  return parse_options, export_list\n\ndef _with_padding(rows):\n  if not rows:\n    return []\n  max_width = max(len(row) for row in rows)\n  min_width = min(len(row) for row in rows)\n  if min_width == max_width:\n    return rows\n  for row in rows:\n    row.extend([\"\"] * (max_width - len(row)))\n  return rows\n"
  },
  {
    "path": "sandbox/grist/imports/import_xls_test.py",
    "content": "# This Python file uses the following encoding: utf-8\nimport calendar\nimport datetime\nimport math\nimport os\nimport unittest\n\nfrom imports import import_xls\n\ndef _get_fixture(filename):\n  return [os.path.join(os.path.dirname(__file__), \"fixtures\", filename)]\n\n\nclass TestImportXLS(unittest.TestCase):\n\n  maxDiff = None  # Display full diff if any.\n\n  def _check_col(self, sheet, index, name, typename, values):\n    self.assertEqual(sheet[\"column_metadata\"][index][\"id\"], name)\n    self.assertEqual(sheet[\"column_metadata\"][index][\"type\"], typename)\n    if typename == \"Any\":\n      # Convert values to strings to reduce changes to tests after imports were overhauled.\n      values = [str(v) for v in values]\n    self.assertEqual(sheet[\"table_data\"][index], values)\n\n  def test_excel(self):\n    parsed_file = import_xls.parse_file(*_get_fixture('test_excel.xlsx'))\n\n    # check that column type was correctly set to numeric and values are properly parsed\n    self.assertEqual(parsed_file[1][0][\"column_metadata\"][0], {\"type\": \"Numeric\", \"id\": \"numbers\"})\n    self.assertEqual(parsed_file[1][0][\"table_data\"][0], [1, 2, 3, 4, 5, 6, 7, 8])\n\n    # check that column type was correctly set to text and values are properly parsed\n    self.assertEqual(parsed_file[1][0][\"column_metadata\"][1], {\"type\": \"Any\", \"id\": \"letters\"})\n    self.assertEqual(parsed_file[1][0][\"table_data\"][1],\n      [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\", \"h\"])\n\n    # check that column type was correctly set to bool and values are properly parsed\n    self.assertEqual(parsed_file[1][0][\"column_metadata\"][2], {\"type\": \"Bool\", \"id\": \"boolean\"})\n    self.assertEqual(parsed_file[1][0][\"table_data\"][2],\n      [True, False, True, False, True, False, True, False])\n\n    # check that column type was correctly set to text and values are properly parsed\n    self.assertEqual(parsed_file[1][0][\"column_metadata\"][3],\n                     {\"type\": \"Any\", \"id\": \"corner-cases\"})\n    self.assertEqual(parsed_file[1][0][\"table_data\"][3],\n      # The type is detected as text, so all values should be text.\n      [u'=function()', u'3', u'two spaces after  ',\n        u'  two spaces before', u'!@#$', u'€€€', u'√∫abc$$', u'line\\nbreak'])\n\n    # check that multiple tables are created when there are multiple sheets in a document\n    self.assertEqual(parsed_file[1][0][\"table_name\"], u\"Sheet1\")\n    self.assertEqual(parsed_file[1][1][\"table_name\"], u\"Sheet2\")\n    self.assertEqual(parsed_file[1][1][\"table_data\"][0], [\"a\", \"b\", \"c\", \"d\"])\n\n  def test_excel_types(self):\n    parsed_file = import_xls.parse_file(*_get_fixture('test_excel_types.xlsx'))\n    sheet = parsed_file[1][0]\n    self._check_col(sheet, 0, \"int1\", \"Numeric\", [-1234123, None, None])\n    self._check_col(sheet, 1, \"int2\", \"Numeric\", [5, None, None])\n    self._check_col(sheet, 2, \"textint\", \"Any\", [\"12345678902345689\", '', ''])\n    self._check_col(sheet, 3, \"bigint\", \"Any\", [\"320150170634561830\", '', ''])\n    self._check_col(sheet, 4, \"num2\", \"Numeric\", [123456789.123456, None, None])\n    self._check_col(sheet, 5, \"bignum\", \"Numeric\", [math.exp(200), None, None])\n    self._check_col(sheet, 6, \"date1\", \"DateTime\",\n             [calendar.timegm(datetime.datetime(2015, 12, 22, 11, 59, 00).timetuple()), None, None])\n    self._check_col(sheet, 7, \"date2\", \"Date\",\n             [calendar.timegm(datetime.datetime(2015, 12, 20, 0, 0, 0).timetuple()), None, None])\n    self._check_col(sheet, 8, \"datetext\", \"Any\", ['12/22/2015', '', ''])\n    self._check_col(sheet, 9, \"datetimetext\", \"Any\",\n                    [u'12/22/2015', u'12/22/2015 1:15pm', u'2018-02-27 16:08:39 +0000'])\n\n  def test_excel_type_detection(self):\n    # This tests goes over the second sheet of the fixture doc, which has multiple rows that try\n    # to throw off the type detection.\n    parsed_file = import_xls.parse_file(*_get_fixture('test_excel_types.xlsx'))\n    sheet = parsed_file[1][1]\n    self._check_col(sheet, 0, \"date_with_other\", \"DateTime\",\n                    [1467676800.0, 1451606400.0, 1451692800.0, 1454544000.0, 1199577600.0,\n                     1467732614.0, u'n/a',       1207958400.0, 1451865600.0, 1451952000.0,\n                     None, 1452038400.0, 1451549340.0, 1483214940.0, None,\n                     1454544000.0, 1199577600.0, 1451692800.0, 1451549340.0, 1483214940.0])\n    self._check_col(sheet, 1, \"float_not_int\", \"Numeric\",\n                    [1,2,3,4,5,None,6,7,8,9,10,10.25,11,12,13,14,15,16,17,18])\n    self._check_col(sheet, 2, \"int_not_bool\", \"Any\",\n                    [0, 0, 1, 0, 1, 0, 0, 1, 0, 2, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0])\n    self._check_col(sheet, 3, \"float_not_bool\", \"Any\",\n                    [0, 0, 1, 0, 1, 0, 0, 1, 0, 0.5, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0])\n    self._check_col(sheet, 4, \"text_as_bool\", \"Any\",\n                    [0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0])\n    self._check_col(sheet, 5, \"int_as_bool\", \"Numeric\",\n                    [0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0])\n    self._check_col(sheet, 6, \"float_not_date\", \"Any\",\n                    [4.0, 6.0, 4.0, 4.0, 6.0, 4.0, '--', 6.0, 4.0, 4.0, 4.0, 4.0, 4.0, 6.0, 6.0,\n                     4.0, 6.0, '3-4', 4.0, 6.5])\n    self._check_col(sheet, 7, \"float_not_text\", \"Numeric\",\n                    [-10.25, -8.00, -5.75, -3.50, \"n/a\", '  1.  ', \"   ???   \", 5.50, None, \"-\",\n                     12.25, 0.00, None, 0.00, \"--\", 23.50, \"NA\", 28.00, 30.25, 32.50])\n\n  def test_excel_numeric_gs(self):\n    # openpyxl sometimes sees floats for int values when those values come from Google Sheets (if\n    # saved via Excel, they'll look like ints). Check that they don't get imported with \".0\" suffix.\n    parsed_file = import_xls.parse_file(*_get_fixture('test_excel_numeric_gs.xlsx'))\n    sheet = parsed_file[1][0]\n    self._check_col(sheet, 0, \"TagId\", \"Numeric\", [10, 20, 30, 40.5])\n    self._check_col(sheet, 1, \"TagName\", \"Any\", [\"foo\", \"300\", \"bar\", \"300.1\"])\n\n  def test_excel_single_merged_cell(self):\n    # An older version had a bug where a single cell marked as 'merged' would cause an exception.\n    parsed_file = import_xls.parse_file(*_get_fixture('test_single_merged_cell.xlsx'))\n    tables = parsed_file[1]\n    self.assertEqual(tables, [{\n      'table_name': u'Transaction Report',\n      'column_metadata': [\n        {'type': 'Any', 'id': u''},\n        {'type': 'Numeric', 'id': u'Start'},\n        {'type': 'Numeric', 'id': u''},\n        {'type': 'Numeric', 'id': u''},\n        {'type': 'Any', 'id': u'Seek no easy ways'},\n      ],\n      'table_data': [\n        [u'SINGLE MERGED', u'The End'],\n        [1637384.52, None],\n        [2444344.06, None],\n        [2444344.06, None],\n        [u'', u''],\n      ],\n    }])\n\n  def test_excel_strange_dates(self):\n    # Check that we don't fail when encountering unusual dates and times (e.g. 0 or 38:00:00).\n    parsed_file = import_xls.parse_file(*_get_fixture('strange_dates.xlsx'))\n    tables = parsed_file[1]\n    # We test non-failure, but the result is not really what we want. E.g. \"1:10\" and \"100:20:30\"\n    # would be best left as text.\n    self.assertEqual(tables, [{\n      'table_name': u'Sheet1',\n      'column_metadata': [\n        {'id': 'a', 'type': 'Any'},\n        {'id': 'b', 'type': 'Date'},\n        {'id': 'c', 'type': 'Any'},\n        {'id': 'd', 'type': 'Any'},\n        {'id': 'e', 'type': 'DateTime'},\n        {'id': 'f', 'type': 'Date'},\n        {'id': 'g', 'type': 'Any'},\n        {'id': 'h', 'type': 'Date'},\n        {'id': 'i', 'type': 'Any'},\n        {'id': 'j', 'type': 'Numeric'},\n      ],\n      'table_data': [\n        [u'21:14:00'],\n        [1568851200.0],\n        [u'01:10:00'],\n        [u'10:20:30'],\n        [-2208713970.0],\n        [-2207347200.0],\n        [u'7/4/1776'],\n        [205286400.0],\n        ['00:00:00'],\n        [6281228502068],\n      ],\n    }])\n\n  def test_empty_rows(self):\n    # Check that empty rows aren't imported,\n    # and that files with lots of empty rows are imported quickly.\n    # The fixture file is mostly empty but has data in the last row,\n    # with over a million empty rows in between.\n    parsed_file = import_xls.parse_file(*_get_fixture('test_empty_rows.xlsx'))\n    tables = parsed_file[1]\n    self.assertEqual(tables, [{\n      'table_name': u'Sheet1',\n      'column_metadata': [\n        {'id': 'a', 'type': 'Numeric'},\n        {'id': 'b', 'type': 'Numeric'},\n      ],\n      'table_data': [\n        [0, None, 1],\n        [u'', 0, 2],\n      ],\n    }])\n\n  def test_invalid_dimensions(self):\n    # Check that files with invalid dimensions (typically a result of software\n    # incorrectly writing the xlsx file) are imported correctly. Previously, Grist\n    # would fail to import any rows from such files due to how openpyxl parses them.\n    parsed_file = import_xls.parse_file(*_get_fixture('test_invalid_dimensions.xlsx'))\n    tables = parsed_file[1]\n    self.assertEqual(tables, [{\n      'table_name': 'Sheet1',\n      'column_metadata': [\n        {'id': u'A', 'type': 'Numeric'},\n        {'id': u'B', 'type': 'Numeric'},\n        {'id': u'C', 'type': 'Numeric'},\n      ],\n      'table_data': [\n        [1, 2, 3],\n        [4, 5, 6],\n        [7, 8, 9],\n      ],\n    }])\n\n  def test_falsy_cells(self):\n    # Falsy cells should be parsed as Numeric, not Date.\n    parsed_file = import_xls.parse_file(*_get_fixture('test_falsy_cells.xlsx'))\n    tables = parsed_file[1]\n    self.assertEqual(tables, [{\n      'table_name': 'Sheet1',\n      'column_metadata': [\n        {'id': u'A', 'type': 'Bool'},\n        {'id': u'B', 'type': 'Numeric'},\n      ],\n      'table_data': [\n        [False, False],\n        [0, 0],\n      ],\n    }])\n\n  def test_boolean(self):\n    parsed_file = import_xls.parse_file(*_get_fixture('test_boolean.xlsx'))\n    tables = parsed_file[1]\n    self.assertEqual(tables, [{\n      'table_name': 'Sheet1',\n      'column_metadata': [\n        {'id': u'A', 'type': 'Bool'},\n        {'id': u'B', 'type': 'Bool'},\n        {'id': u'C', 'type': 'Any'},\n      ],\n      'table_data': [\n        [True, False],\n        [False, False],\n        ['true', 'False'],\n      ],\n    }])\n\n  def test_header_with_none_cell(self):\n    parsed_file = import_xls.parse_file(*_get_fixture('test_headers_with_none_cell.xlsx'))\n    tables = parsed_file[1]\n    self.assertEqual(tables, [{\n      'table_name': 'Sheet1',\n      'column_metadata': [\n        {'id': u'header1', 'type': 'Any'},\n        {'id': u'header2', 'type': 'Any'},\n        {'id': u'header3', 'type': 'Any'},\n        {'id': u'header4', 'type': 'Any'},\n      ],\n      'table_data': [\n        ['foo1', 'foo2'],\n        ['bar1', 'bar2'],\n        ['baz1', 'baz2'],\n        ['boz1', 'boz2'],\n      ],\n    }])\n\n\nif __name__ == '__main__':\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/imports/register.py",
    "content": "def register_import_parsers(sandbox):\n  def parse_csv(file_source, options):\n    from imports.import_csv import parse_file_source\n    return parse_file_source(file_source, options)\n\n  sandbox.register(\"csv_parser.parseFile\", parse_csv)\n\n  def parse_excel(file_source, parse_options):\n    # pylint: disable=unused-argument\n    from imports.import_xls import import_file\n    return import_file(file_source)\n\n  sandbox.register(\"xls_parser.parseFile\", parse_excel)\n\n  def parse_json(file_source, parse_options):\n    from imports.import_json import parse_file\n    return parse_file(file_source, parse_options)\n\n  sandbox.register(\"json_parser.parseFile\", parse_json)\n"
  },
  {
    "path": "sandbox/grist/imports/test_imports.py",
    "content": "\"\"\"\nImports only run in Python 3 sandboxes, so the tests here only run in Python 3.\nThe test files in this directory have been renamed to look like 'import_xls_test.py' instead\nof 'test_import_xls.py' so that they're not discovered automatically by default.\n`load_tests` below then discovers that pattern directly, but only in Python 3.\nThis allows the tests to be skipped without having to specify a pattern when discovering tests.\nThe downside is that if you *do* want to specify a pattern, that probably won't work.\nThe reason for skipping entire files from being discovered instead of skipping TestCase classes\nis that just importing the test file will fail with an error, both because we manually raise\nan exception and because dependencies are missing.\n\"\"\"\n\nimport os\n\n\ndef load_tests(loader, standard_tests, _pattern):\n\n  this_dir = os.path.join(os.path.dirname(__file__))\n  package_tests = list(loader.discover(start_dir=this_dir, pattern='*_test.py'))\n  if len(package_tests) < 3:\n    raise Exception(\"Expected more import tests to be discovered\")\n  standard_tests.addTests(package_tests)\n  return standard_tests\n"
  },
  {
    "path": "sandbox/grist/lookup.py",
    "content": "# Lookups are hard.\n#\n# Example to explain the relationship of various lookup helpers.\n# Let's say we have this formula (notation [People.Rate] means a column \"Rate\" in table \"People\").\n#     [People.Rate] = Rates.lookupRecords(Email=$Email, sort_by=\"Date\")\n#\n# Conceptually, a good representation is to think of a helper table \"UniqueRateEmails\", which\n# contains a list of unique Email values in the table Rates. These are all the values that\n# lookupRecords() can find.\n#\n# So conceptually, it helps to imagine a table with the following columns:\n#     [UniqueRateEmails.Email] = each Email in Rates\n#     [UniqueRateEmails.lookedUpRates] = {r.id for r in Rates if r.Email == $Email}\n#       -- this is the set of row_ids of all Rates with the email of this UniqueRateEmails row.\n#     [UniqueRateEmails.lookedUpRatesSorted] = sorted($lookedUpRates)  # sorted by Date.\n#\n# We don't _actually_ create a helper table. (That would be a lot over overhead from all the extra\n# tracking for recalculations.)\n#\n# We have two helper columns in the Rates table (the one in which we are looking up):\n#     [Rate.#lookup#Email] (LookupMapColumn)\n#       This is responsible to know which Rate rows correspond to which Emails (using a\n#       SimpleLookupMapping helper). For any email, it can produce the set of row_ids of Rate\n#       records.\n#\n#       - It depends on [Rate.Email], so that changes to Email cause a recalculation.\n#       - When it gets recalculated, it\n#         - updates internal maps.\n#         - invalidates affected callers.\n#\n#     [Rate.#lookup#Email#Date] (SortedLookupMapColumn)\n#       For each set of Rate results, this maintains a list of Rate row_ids sorted by Date.\n#\n#       - It depends on [Rate.Date] so that changes to Date cause a recalculation.\n#       - When its do_lookup() is called, it creates\n#         - a dependency between the caller [People.Rate] and itself [Rate.#lookup#Email#Date]\n#           using a special _LookupRelation (which it keeps track of).\n#         - a dependency between the caller [People.Rate] and unsorted lookup [Rate.#lookup#Email]\n#           using another _LookupRelation (which [Rate.#lookup#Email] keeps track of).\n#       - When it gets recalculated, which means that order of the lookup result has changed:\n#         - it clears the cached sorted version of the lookup result\n#         - uses its _LookupRelations to invalidate affected callers.\n\nimport itertools\nimport logging\nfrom abc import abstractmethod\n\nimport column\nimport depend\nimport records\nimport relation\nfrom sort_key import make_sort_key\nimport twowaymap\nfrom twowaymap import LookupSet\nimport usertypes\nfrom functions.lookup import _Contains\n\nlog = logging.getLogger(__name__)\n\n\nclass NoValueColumn(column.BaseColumn):\n  # Override various column methods, since (Sorted)LookupMapColumn doesn't care to store any\n  # values. To outside code, it looks like a column of None's.\n  def raw_get(self, row_id):\n    return None\n  def convert(self, value_to_convert):\n    return None\n  def get_cell_value(self, row_id, restore=False):\n    return None\n  def set(self, row_id, value):\n    pass\n\n\nclass LookupMapColumn(NoValueColumn):\n  \"\"\"\n  Conceptually a LookupMapColumn is associated with a table (\"target table\") and maintains for\n  each row a key (which is a tuple of values from the named columns), which is fast to look up.\n  The lookup is generally performed in a formula in a different table (\"referring table\").\n\n  LookupMapColumn is similar to a FormulaColumn in that it needs to do some computation whenever\n  one of its dependencies changes: namely, it needs to update the index.\n\n  Although it acts as a column, a LookupMapColumn isn't included among its table's columns, and\n  doesn't have a column id.\n\n  Compared to relational database, LookupMapColumn is analogous to a database index.\n  \"\"\"\n\n  def __init__(self, table, col_id, col_ids_tuple):\n    # Note that self._recalc_rec_method is passed in as the formula's \"method\".\n    col_info = column.ColInfo(usertypes.Any(), is_formula=True, method=self._recalc_rec_method)\n    super(LookupMapColumn, self).__init__(table, col_id, col_info)\n\n    # For performance, prefer SimpleLookupMapping when no CONTAINS is used in lookups.\n    if any(isinstance(col_id, _Contains) for col_id in col_ids_tuple):\n      self._mapping = ContainsLookupMapping(col_ids_tuple)\n    else:\n      self._mapping = SimpleLookupMapping(col_ids_tuple)\n\n    engine = table._engine\n    engine.invalidate_column(self)\n    self._relation_tracker = _RelationTracker(engine, self)\n\n  def _recalc_rec_method(self, rec, _table):\n    \"\"\"\n    LookupMapColumn acts as a formula column, and this method is the \"formula\" called whenever\n    a dependency changes. If LookupMapColumn indexes columns (A,B), then a change to A or B would\n    cause the LookupMapColumn to be invalidated for the corresponding rows, and brought up to date\n    during formula recomputation by calling this method. It shold take O(1) time per affected row.\n    \"\"\"\n    affected_keys = self._mapping.update_record(rec)\n    self._relation_tracker.invalidate_affected_keys(affected_keys)\n\n  def _do_fast_empty_lookup(self):\n    \"\"\"\n    Simplified version of do_lookup for a lookup column with no key columns\n    to make Table._num_rows as fast as possible.\n    \"\"\"\n    return self._mapping.lookup_by_key((), default=())\n\n  def _do_fast_lookup(self, key):\n    key = tuple(_extract(val) for val in key)\n    return self._mapping.lookup_by_key(key, default=LookupSet())\n\n  @property\n  def sort_key(self):\n    return None\n\n  def do_lookup(self, key):\n    \"\"\"\n    Looks up key in the lookup map and returns a tuple with two elements: the list of matching\n    records (sorted), and the Relation object for those records, relating\n    the current frame to the returned records. Returns an empty set if no records match.\n    \"\"\"\n    key = tuple(_extract(val) for val in key)\n    row_ids, rel = self._do_lookup_with_sort(key, (), None)\n    return row_ids, rel\n\n  def _do_lookup_with_sort(self, key, sort_spec, sort_key):\n    rel = self._relation_tracker.update_relation_from_current_node(key)\n    row_id_set = self._do_fast_lookup(key)\n    row_ids = row_id_set.sorted_versions.get(sort_spec)\n    if row_ids is None:\n      row_ids = sorted(row_id_set, key=sort_key)\n      row_id_set.sorted_versions[sort_spec] = row_ids\n    return row_ids, rel\n\n  def _reset_sorted_versions(self, rec, sort_spec):\n    # For the lookup keys in rec, find the associated LookupSets, and clear the cached\n    # .sorted_versions entry for the given sort_spec. Used when only sort-by columns change.\n    # Returns the set of affected keys.\n    new_keys = set(self._mapping.get_new_keys_iter(rec))\n    for key in new_keys:\n      row_ids = self._mapping.lookup_by_key(key, default=LookupSet())\n      row_ids.sorted_versions.pop(sort_spec, None)\n    return new_keys\n\n  def unset(self, row_id):\n    # This is called on record removal, and is necessary to deal with removed records.\n    affected_keys = self._mapping.remove_row_id(row_id)\n    self._relation_tracker.invalidate_affected_keys(affected_keys)\n\n  def _get_keys(self, row_id):\n    # For _LookupRelation to know which keys are affected when the given looked-up row_id changes.\n    return self._mapping.get_mapped_keys(row_id)\n\n#----------------------------------------------------------------------\n\nclass SortedLookupMapColumn(NoValueColumn):\n  \"\"\"\n  A SortedLookupMapColumn is associated with a LookupMapColumn and a set of columns used for\n  sorting. It lives in the table containing the looked-up data. It is like a FormulaColumn in that\n  it has a method triggered for a record whenever any of the sort columns change for that record.\n\n  This method, in turn, invalidates lookups using the relations maintained by the LookupMapColumn.\n  \"\"\"\n  def __init__(self, table, col_id, lookup_col, sort_spec):\n    # Before creating the helper column, check that all dependencies are actually valid col_ids.\n    sort_col_ids = [(c[1:] if c.startswith('-') else c) for c in sort_spec]\n\n    for c in sort_col_ids:\n      if not table.has_column(c):\n        raise KeyError(\"Table %s has no column %s\" % (table.table_id, c))\n\n    # Note that different LookupSortHelperColumns may exist with the same sort_col_ids but\n    # different sort_keys because they could differ in order of columns and ASC/DESC flags.\n    col_info = column.ColInfo(usertypes.Any(), is_formula=True, method=self._recalc_rec_method)\n    super(SortedLookupMapColumn, self).__init__(table, col_id, col_info)\n    self._lookup_col = lookup_col\n\n    self._sort_spec = sort_spec\n    self._sort_col_ids = sort_col_ids\n    self._sort_key = make_sort_key(table, sort_spec)\n\n    self._engine = table._engine\n    self._engine.invalidate_column(self)\n    self._relation_tracker = _RelationTracker(self._engine, self)\n\n  @property\n  def sort_key(self):\n    return self._sort_key\n\n  def do_lookup(self, key):\n    \"\"\"\n    Looks up key in the lookup map and returns a tuple with two elements: the list of matching\n    records (sorted), and the Relation object for those records, relating\n    the current frame to the returned records. Returns an empty set if no records match.\n    \"\"\"\n    key = tuple(_extract(val) for val in key)\n    self._relation_tracker.update_relation_from_current_node(key)\n    row_ids, rel = self._lookup_col._do_lookup_with_sort(key, self._sort_spec, self._sort_key)\n    return row_ids, rel\n\n  def _recalc_rec_method(self, rec, _table):\n    # Create dependencies on all the sort columns.\n    for col_id in self._sort_col_ids:\n      getattr(rec, col_id)\n\n    affected_keys = self._lookup_col._reset_sorted_versions(rec, self._sort_spec)\n    self._relation_tracker.invalidate_affected_keys(affected_keys)\n\n  def _get_keys(self, row_id):\n    # For _LookupRelation to know which keys are affected when the given looked-up row_id changes.\n    return self._lookup_col._get_keys(row_id)\n\n#----------------------------------------------------------------------\n\nclass BaseLookupMapping(object):\n  def __init__(self, col_ids_tuple):\n    self._col_ids_tuple = col_ids_tuple\n\n    # Two-way map between rowIds of the target table (on the left) and key tuples (on the right).\n    # Multiple rows can naturally map to the same key.\n    # A single row can map to multiple keys when CONTAINS() is used.\n    self._row_key_map = self._make_row_key_map()\n\n  @abstractmethod\n  def _make_row_key_map(self):\n    raise NotImplementedError\n\n  @abstractmethod\n  def get_mapped_keys(self, row_id):\n    \"\"\"\n    Get the set of keys associated with the given target row id, as stored in our mapping.\n    \"\"\"\n    raise NotImplementedError\n\n  @abstractmethod\n  def get_new_keys_iter(self, rec):\n    \"\"\"\n    Returns an iterator over the current value of all keys represented by the given record.\n    Typically, it's just one key, but when list-type columns are involved, then could be several.\n    \"\"\"\n    raise NotImplementedError\n\n  @abstractmethod\n  def update_record(self, rec):\n    \"\"\"\n    Update the mapping to reflect the current value of all keys represented by the given record,\n    and return all the affected keys, i.e. the set of all the keys that changed (old and new).\n    \"\"\"\n    raise NotImplementedError\n\n  def remove_row_id(self, row_id):\n    old_keys = self.get_mapped_keys(row_id)\n    for old_key in old_keys:\n      self._row_key_map.remove(row_id, old_key)\n    return old_keys\n\n  def lookup_by_key(self, key, default=None):\n    return self._row_key_map.lookup_right(key, default=default)\n\n\nclass SimpleLookupMapping(BaseLookupMapping):\n  def _make_row_key_map(self):\n    return twowaymap.TwoWayMap(left=LookupSet, right=\"single\")\n\n  def _get_mapped_key(self, row_id):\n    return self._row_key_map.lookup_left(row_id)\n\n  def get_mapped_keys(self, row_id):\n    return {self._get_mapped_key(row_id)}\n\n  def get_new_keys_iter(self, rec):\n    # Note that getattr(rec, _col_id) is what creates the correct dependency, as well as ensures\n    # that the columns used to index by are brought up-to-date (in case they are formula columns).\n    return [tuple(_extract(getattr(rec, _col_id)) for _col_id in self._col_ids_tuple)]\n\n  def update_record(self, rec):\n    old_key = self._get_mapped_key(rec._row_id)\n    new_key = self.get_new_keys_iter(rec)[0]\n    if new_key == old_key:\n      return set()\n    try:\n      self._row_key_map.insert(rec._row_id, new_key)\n    except TypeError:\n      # If key is not hashable, ignore it, just remove the old_key then.\n      self._row_key_map.remove(rec._row_id, old_key)\n      new_key = None\n\n    # Both keys are affected when present.\n    return {k for k in (old_key, new_key) if k is not None}\n\n\nclass ContainsLookupMapping(BaseLookupMapping):\n  def _make_row_key_map(self):\n    return twowaymap.TwoWayMap(left=LookupSet, right=set)\n\n  def get_mapped_keys(self, row_id):\n    # Need to copy the return value since it's the actual set\n    # stored in the map and may be modified\n    return set(self._row_key_map.lookup_left(row_id, ()))\n\n  def get_new_keys_iter(self, rec):\n    # Create a key in the index for every combination of values in columns\n    # looked up with CONTAINS()\n    new_keys_groups = []\n    for col_id in self._col_ids_tuple:\n      # Note that getattr() is what creates the correct dependency, as well as ensures\n      # that the columns used to index by are brought up-to-date (in case they are formula columns).\n      group = getattr(rec, extract_column_id(col_id))\n\n      if isinstance(col_id, _Contains):\n        # Check that the cell targeted by CONTAINS() has an appropriate type.\n        # Don't iterate over characters of a string.\n        # group = [] essentially means there are no new keys in this call\n        if isinstance(group, (bytes, str,)):\n          group = []\n        elif not group and col_id.match_empty != _Contains.no_match_empty:\n          group = [col_id.match_empty]\n      else:\n        group = [group]\n\n      try:\n        # We only care about the unique key values\n        group = set(group)\n      except TypeError:\n        group = []\n\n      new_keys_groups.append([_extract(v) for v in group])\n\n    return itertools.product(*new_keys_groups)\n\n  def update_record(self, rec):\n    new_keys = set(self.get_new_keys_iter(rec))\n\n    row_id = rec._row_id\n    old_keys = self.get_mapped_keys(row_id)\n\n    for old_key in old_keys - new_keys:\n      self._row_key_map.remove(row_id, old_key)\n\n    for new_key in new_keys - old_keys:\n      self._row_key_map.insert(row_id, new_key)\n\n    # Affected keys are those that were either newly inserted or newly removed.\n    return new_keys ^ old_keys\n\n#----------------------------------------------------------------------\n\nclass _RelationTracker(object):\n  \"\"\"\n  Helper used by (Sorted)LookupMapColumn to keep track of the _LookupRelations between referring\n  nodes and that column.\n  \"\"\"\n  def __init__(self, engine, lookup_map):\n    self._engine = engine\n    self._lookup_map = lookup_map\n\n    # Map of referring Node to _LookupRelation. Different tables may do lookups using a\n    # (Sorted)LookupMapColumn, and that creates a dependency from other Nodes to us, with a\n    # relation between referring rows and the lookup keys. This map stores these relations.\n    self._lookup_relations = {}\n\n  def update_relation_from_current_node(self, key):\n    \"\"\"\n    Looks up key in the lookup map and returns a tuple with two elements: the list of matching\n    records (sorted), and the Relation object for those records, relating\n    the current frame to the returned records. Returns an empty set if no records match.\n    \"\"\"\n    engine = self._engine\n    if engine._is_current_node_formula:\n      rel = self._get_relation(engine._current_node)\n      rel._add_lookup(engine._current_row_id, key)\n    else:\n      rel = None\n\n    # The _use_node call brings the _lookup_map column up-to-date, and creates a dependency on it.\n    # Relation of None isn't valid, but it happens to be unused when there is no current_frame.\n    engine._use_node(self._lookup_map.node, rel)\n    return rel\n\n  def invalidate_affected_keys(self, affected_keys):\n    # For each known relation, figure out which referring rows are affected, and invalidate them.\n    # The engine will notice that there have been more invalidations, and recompute things again.\n    for rel in self._lookup_relations.values():\n      rel.invalidate_affected_keys(affected_keys, self._engine)\n\n  def _get_relation(self, referring_node):\n    \"\"\"\n    Helper which returns an existing or new _LookupRelation object for the given referring Node.\n    \"\"\"\n    rel = self._lookup_relations.get(referring_node)\n    if not rel:\n      rel = _LookupRelation(self._lookup_map, self, referring_node)\n      self._lookup_relations[referring_node] = rel\n    return rel\n\n  def _delete_relation(self, referring_node):\n    self._lookup_relations.pop(referring_node, None)\n    if not self._lookup_relations:\n      self._engine.mark_lookupmap_for_cleanup(self._lookup_map)\n\n\nclass _LookupRelation(relation.Relation):\n  \"\"\"\n  _LookupRelation maintains a mapping between rows of a table doing a lookup to the rows getting\n  returned from the lookup. Lookups are implemented using a LookupMapColumn, and a _LookupRelation\n  with in conjunction with its LookupMapColumn.\n\n  _LookupRelation are created and owned by LookupMapColumn, and should not be created directly by\n  other code.\n  \"\"\"\n\n  def __init__(self, lookup_map, relation_tracker, referring_node):\n    super(_LookupRelation, self).__init__(referring_node.table_id, lookup_map.table_id)\n    self._lookup_map = lookup_map\n    self._relation_tracker = relation_tracker\n    self._referring_node = referring_node\n\n    # Maps referring rows to keys, where multiple rows may map to the same key AND one row may\n    # map to multiple keys (if a formula does multiple lookup calls).\n    self._row_key_map = twowaymap.TwoWayMap(left=set, right=set)\n\n    # This is for an optimization. We may invalidate the same key many times (including O(N)\n    # times), which will lead to invalidating the same O(N) records over and over, resulting in\n    # O(N^2) work. By remembering the keys we invalidated, we can avoid that waste.\n    self._invalidated_keys_cache = set()\n\n  def __str__(self):\n    return \"_LookupRelation(%s->%s)\" % (self._referring_node, self.target_table)\n\n  def get_affected_rows(self, target_row_ids):\n    if target_row_ids == depend.ALL_ROWS:\n      return depend.ALL_ROWS\n    # Each target row (result of a lookup by key)\n    # is associated with a set of keys,and all rows that\n    # looked up an affected key are affected by a change to any associated row. We remember which\n    # rows looked up which key in self._row_key_map, so that when some target row changes to a new\n    # key, we can know which referring rows need to be recomputed.\n    return self.get_affected_rows_by_keys(\n      set().union(*[self._lookup_map._get_keys(r) for r in target_row_ids])\n    )\n\n  def invalidate_affected_keys(self, affected_keys, engine):\n    affected_rows = self.get_affected_rows_by_keys(affected_keys - self._invalidated_keys_cache)\n    if affected_rows:\n      node = self._referring_node\n      engine.invalidate_records(node.table_id, affected_rows, col_ids=(node.col_id,))\n      self._invalidated_keys_cache.update(affected_keys)\n\n  def get_affected_rows_by_keys(self, keys):\n    \"\"\"\n    This is used by LookupMapColumn to know which rows got affected when a target row changed to\n    have a different key. Keys can be any iterable. A key of None is allowed and affects nothing.\n    \"\"\"\n    affected_rows = set()\n    for key in keys:\n      if key is not None:\n        affected_rows.update(self._row_key_map.lookup_right(key, default=()))\n    return affected_rows\n\n  def _add_lookup(self, referring_row_id, key):\n    \"\"\"\n    Helper used by LookupMapColumn to store the fact that the given key was looked up in the\n    process of computing the given referring_row_id.\n    \"\"\"\n    self._row_key_map.insert(referring_row_id, key)\n    self._reset_invalidated_keys_cache()\n\n  def reset_rows(self, referring_rows):\n    \"\"\"\n    Called when starting to compute a formula, so that mappings for the given referring_rows can\n    be cleared as they are about to be rebuilt.\n    \"\"\"\n    # Clear out references from referring_rows.\n    if referring_rows == depend.ALL_ROWS:\n      self._row_key_map.clear()\n    else:\n      for row_id in referring_rows:\n        self._row_key_map.remove_left(row_id)\n    self._reset_invalidated_keys_cache()\n\n  def reset_all(self):\n    \"\"\"\n    Called when the dependency using this relation is reset, and this relation is no longer used.\n    \"\"\"\n    # In this case also, remove it from the LookupMapColumn. Once all relations are gone, the\n    # lookup map can get cleaned up.\n    self._row_key_map.clear()\n    self._relation_tracker._delete_relation(self._referring_node)\n    self._reset_invalidated_keys_cache()\n\n  def _reset_invalidated_keys_cache(self):\n    # When the invalidations take effect (i.e. invalidated columns get recomputed), the engine\n    # resets the relations for the affected rows. We use that, as well as any change to the\n    # relation, as a signal to clear _invalidated_keys_cache. Its purpose is only to serve while\n    # going down a helper (Sorted)LookupMapColumn.\n    self._invalidated_keys_cache.clear()\n\n\ndef extract_column_id(c):\n  if isinstance(c, _Contains):\n    return c.value\n  else:\n    return c\n\ndef _extract(cell_value):\n  \"\"\"\n  When cell_value is a Record, returns its rowId. Otherwise returns the value unchanged.\n  This is to allow lookups to work with reference columns.\n  \"\"\"\n  if isinstance(cell_value, records.Record):\n    return cell_value._row_id\n  return cell_value\n"
  },
  {
    "path": "sandbox/grist/main.py",
    "content": "\"\"\"\nThis module defines what sandbox functions are made available to the Node controller,\nand starts the grist sandbox. See engine.py for the API documentation.\n\"\"\"\nimport os\nimport random\nimport sys\nimport time\n\nfrom timing import DummyTiming, Timing\nsys.path.append('thirdparty')\n# pylint: disable=wrong-import-position\n\nimport logging\nimport marshal\nimport functools\n\nimport actions\nimport engine\nimport formula_prompt\nimport migrations\nimport schema\nimport useractions\nimport objtypes\nfrom predicate_formula import parse_predicate_formula\nfrom sandbox import get_default_sandbox\nfrom imports.register import register_import_parsers\n\n# Handler for logging, which flushes each message.\nclass FlushingStreamHandler(logging.StreamHandler):\n  def emit(self, record):\n    super(FlushingStreamHandler, self).emit(record)\n    self.flush()\n\n# Configure logging module to produce messages with log level and logger name.\nlogging.basicConfig(format=\"[%(levelname)s] [%(name)s] %(message)s\",\n    handlers=[FlushingStreamHandler(sys.stderr)],\n    level=logging.INFO)\n\n# The default level is INFO. If a different level is desired, add a call like this:\n#   log.setLevel(logging.WARNING)\nlog = logging.getLogger(__name__)\n\ndef table_data_from_db(table_name, table_data_repr):\n  if table_data_repr is None:\n    return actions.TableData(table_name, [], {})\n  table_data_parsed = marshal.loads(table_data_repr)\n  table_data_parsed = {key.decode(\"utf8\"): value for key, value in table_data_parsed.items()}\n  id_col = table_data_parsed.pop(\"id\")\n  return actions.TableData(table_name, id_col,\n                           actions.decode_bulk_values(table_data_parsed, _decode_db_value))\n\ndef _decode_db_value(value):\n  # Decode database values received from SQLite's allMarshal() call. These are encoded by\n  # marshalling certain types and storing as BLOBs (received in Python as binary strings, as\n  # opposed to text which is received as unicode). See also encodeValue() in DocStorage.js\n  t = type(value)\n  if t is bytes:\n    return objtypes.decode_object(marshal.loads(value))\n  else:\n    return value\n\ndef run(sandbox):\n  eng = engine.Engine()\n\n  def export(method):\n    # Wrap each method so that it logs a message that it's being called.\n    @functools.wraps(method)\n    def wrapper(*args, **kwargs):\n      log.debug(\"calling %s\", method.__name__)\n      return method(*args, **kwargs)\n\n    sandbox.register(method.__name__, wrapper)\n\n  def load_and_record_table_data(table_name, table_data_repr):\n    result = table_data_from_db(table_name, table_data_repr)\n    eng.record_table_stats(result, table_data_repr)\n    return result\n\n  @export\n  def apply_user_actions(action_reprs, user=None):\n    action_group = eng.apply_user_actions([useractions.from_repr(u) for u in action_reprs], user)\n    result = dict(\n      rowCount=eng.count_rows(),\n      **eng.acl_split(action_group).to_json_obj()\n    )\n    if action_group.requests:\n      result[\"requests\"] = action_group.requests\n    return result\n\n  @export\n  def fetch_table(table_id, formulas=True, query=None):\n    return actions.get_action_repr(eng.fetch_table(table_id, formulas=formulas, query=query))\n\n  @export\n  def fetch_table_schema():\n    return eng.fetch_table_schema()\n\n  @export\n  def autocomplete(txt, table_id, column_id, row_id, user):\n    return eng.autocomplete(txt, table_id, column_id, row_id, user)\n\n  @export\n  def find_col_from_values(values, n, opt_table_id):\n    return eng.find_col_from_values(values, n, opt_table_id)\n\n  @export\n  def fetch_meta_tables(formulas=True):\n    return {table_id: actions.get_action_repr(table_data)\n            for (table_id, table_data) in eng.fetch_meta_tables(formulas).items()}\n\n  @export\n  def load_meta_tables(meta_tables, meta_columns):\n    return eng.load_meta_tables(load_and_record_table_data(\"_grist_Tables\", meta_tables),\n                                load_and_record_table_data(\"_grist_Tables_column\", meta_columns))\n\n  @export\n  def load_table(table_name, table_data):\n    return eng.load_table(load_and_record_table_data(table_name, table_data))\n\n  @export\n  def get_table_stats():\n    return eng.get_table_stats()\n\n  @export\n  def create_migrations(all_tables, metadata_only=False):\n    doc_actions = migrations.create_migrations(\n      {t: table_data_from_db(t, data) for t, data in all_tables.items()}, metadata_only)\n    return [actions.get_action_repr(action) for action in doc_actions]\n\n  @export\n  def get_version():\n    return schema.SCHEMA_VERSION\n\n  @export\n  def initialize(doc_url):\n    if os.environ.get(\"DETERMINISTIC_MODE\"):\n      random.seed(1)\n    else:\n      # Make sure we have randomness, even if we are being cloned from a checkpoint\n      random.seed()\n    if doc_url:\n      os.environ['DOC_URL'] = doc_url\n\n  @export\n  def get_formula_error(table_id, col_id, row_id):\n    return objtypes.encode_object(eng.get_formula_error(table_id, col_id, row_id))\n\n  @export\n  def get_formula_prompt(table_id, col_id, include_all_tables=True, lookups=True):\n    return formula_prompt.get_formula_prompt(eng, table_id, col_id, include_all_tables, lookups)\n\n  @export\n  def convert_formula_completion(completion):\n    return formula_prompt.convert_completion(completion)\n\n  @export\n  def evaluate_formula(table_id, col_id, row_id):\n    return formula_prompt.evaluate_formula(eng, table_id, col_id, row_id)\n\n  @export\n  def start_timing():\n    eng._timing = Timing()\n\n  @export\n  def stop_timing():\n    stats = eng._timing.get()\n    eng._timing = DummyTiming()\n    return stats\n\n  @export\n  def get_timings():\n    return eng._timing.get(False)\n\n  # Echo input for testing\n  @export\n  def test_echo(msg):\n    return msg\n\n  # Throw an expection for testing\n  @export\n  def test_fail(msg):\n    raise Exception(msg)\n\n  # File read/write methods for testing\n  @export\n  def test_write_file(filename, contents):\n    with open(filename, \"a\") as f:\n      f.write(contents)\n\n  @export\n  def test_read_file(filename):\n    with open(filename) as f:\n      return f.read()\n\n  @export\n  def test_get_sandbox_root():\n    return os.path.realpath(os.path.join(__file__, '..'))\n\n  @export\n  def test_list_files(path, include_files=True):\n    paths = []\n    for root, _, fnames in os.walk(path):\n      paths.append(root)\n      if include_files:\n        for fname in sorted(fnames):\n          paths.append(fname)\n    return paths\n\n  # Some sundry operations for tests only\n  @export\n  def test_operation(delay, operation, *inputs):\n    if delay != 0:\n      # We don't have time.sleep() available in the sandbox, so wait with a busy loop.\n      end = time.time() + delay\n      while time.time() < end:\n        pass\n    if operation == 'uppercase':\n      return inputs[0].upper()\n    if operation == 'triple':\n      return inputs[0] * 3\n    if operation == 'bigToSmall':\n      return len(inputs[0])\n    if operation == 'smallToBig':\n      return '*' * inputs[0]\n    raise Exception('unrecognized operation')\n\n  @export\n  def test_fork(nb):\n    \"\"\"\n    Fork `nb` child processes for testing.\n    Children exit immediately with status 0 so they do not continue running the\n    sandbox main loop. The parent waits for all children and returns the list\n    of child PIDs. Only the original parent ever returns from this function.\n    \"\"\"\n    count = nb or 1\n    child_pids = []\n    for _ in range(count):\n      pid = os.fork()\n      if pid == 0:\n        # Child process: exit immediately to avoid running the sandbox main loop.\n        os._exit(0)\n      else:\n        # Parent process: remember child PID so we can reap it.\n        child_pids.append(pid)\n    # Parent: wait for all children to avoid leaving zombies.\n    for pid in child_pids:\n      os.waitpid(pid, 0)\n    return child_pids\n\n\n  @export\n  def test_tz_data():\n    import moment   # pylint: disable=import-outside-toplevel\n    return moment.read_tz_raw_data()\n\n  export(parse_predicate_formula)\n  export(eng.load_empty)\n  export(eng.load_done)\n\n  register_import_parsers(sandbox)\n\n  log.info(\"Ready\")  # This log message is significant for checkpointing.\n  sandbox.run()\n\ndef main():\n  run(get_default_sandbox())\n\nif __name__ == \"__main__\":\n  main()\n"
  },
  {
    "path": "sandbox/grist/match_counter.py",
    "content": "\"\"\"\nSimple class which, given a sample, can quickly count the size of overlap with an iterable.\nAll elements of sample must be hashable.\n\nThis is mainly in its own file in order to be able to test and time possible alternative\nimplementations.\n\"\"\"\nclass MatchCounter(object):\n  def __init__(self, sample):\n    self.sample = set(sample)\n\n  def count_unique(self, iterable):\n    \"\"\"\n    Returns the count of unique elements of iterable that are present in sample. The sample may\n    only contain hashable elements, so non-hashable elements of iterable are never counted.\n    \"\"\"\n    # The simplest implementation is 5 times faster:\n    #     len(self.sample.intersection(iterable))\n    # but fails if iterable can ever contain non-hashable values (e.g. list). This is the next\n    # best alternative. Attempting to skip non-hashable values with `isinstance(v, Hashable)` is\n    # another order of magnitude slower.\n    seen = set()\n    for v in iterable:\n      try:\n        if v in self.sample:\n          seen.add(v)\n      except TypeError:\n        # Non-hashable values can't possibly be in self.sample, so just don't count those.\n        pass\n\n    return len(seen)\n"
  },
  {
    "path": "sandbox/grist/migrations.py",
    "content": "# pylint:disable=too-many-lines\nimport json\nimport logging\nimport re\nfrom collections import defaultdict\n\nimport actions\nimport identifiers\nimport schema\nimport summary\nimport table_data_set\nfrom column import is_visible_column\n\nlog = logging.getLogger(__name__)\n\n# PHILOSOPHY OF MIGRATIONS.\n#\n# We should probably never remove, modify, or rename metadata tables or columns.\n# Instead, we should only add.\n#\n# We can mark old columns/tables as deprecated, which should be ignored except to prevent us from\n# adding same-named entities in the future.\n#\n# If we change the meaning of a column, we have to create a new column with a new name.\n#\n# This should make it at least barely possible to share documents by people who are not all on the\n# same Grist version (even so, it will require more work). It should also make it somewhat safe to\n# upgrade and then open the document with a previous version.\n#\n# After each migration you probably should run these commands:\n# ./test/upgradeDocument public_samples/*.grist\n# UPDATE_REGRESSION_DATA=1 GREP_TESTS=DocRegressionTests ./test/testrun.sh server\n# ./test/upgradeDocument core/test/fixtures/docs/Hello.grist\n# If you have trouble doing it with gvisor, you can prefix those commands with\n# GRIST_SANDBOX_FLAVOR=unsandboxed\n# but be careful, this will run the document without sandboxing.\n\nall_migrations = {}\n\ndef noop_migration(_all_tables):\n  return []\n\n# Each migration function includes a .need_all_tables attribute. See migration() decorator.\nnoop_migration.need_all_tables = False\n\n\ndef create_migrations(all_tables, metadata_only=False):\n  \"\"\"\n  Creates and returns a list of DocActions needed to bring this document to\n  schema.SCHEMA_VERSION.\n    all_tables: all tables or just the metadata tables (those named with _grist_ prefix) as a\n      dictionary mapping table name to TableData.\n    metadata_only: should be set if only metadata tables are passed in. If ALL tables are\n      required to process migrations, this method will raise a \"need all tables...\" exception.\n  \"\"\"\n  try:\n    doc_version = all_tables['_grist_DocInfo'].columns[\"schemaVersion\"][0]\n  except Exception:\n    doc_version = 0\n\n  # We create a TableDataSet, and populate it with the subset of the current schema that matches\n  # all_tables. For missing items, we make up tables and incomplete columns, which should be OK\n  # since we would not be adding new records to deprecated columns.\n  # Note that this approach makes it NOT OK to change column types.\n  tdset = table_data_set.TableDataSet()\n\n  # For each table in the provided metadata tables, create an AddTable action.\n  user_schema = schema.build_schema(all_tables['_grist_Tables'],\n                                    all_tables['_grist_Tables_column'],\n                                    include_builtin=False)\n  for t in user_schema.values():\n    tdset.apply_doc_action(actions.AddTable(t.tableId, schema.cols_to_dict_list(t.columns)))\n\n  # For each old table/column, construct an AddTable action using the current schema.\n  new_schema = {a.table_id: a for a in schema.schema_create_actions()}\n  for table_id, data in sorted(all_tables.items()):\n    # User tables should already be in tdset; the rest must be metadata tables.\n    # (If metadata_only is true, there is simply nothing to skip here.)\n    if table_id not in tdset.all_tables:\n      new_col_info = {}\n      if table_id in new_schema:\n        new_col_info = {c['id']: c for c in new_schema[table_id].columns}\n      # Use an incomplete default for unknown (i.e. deprecated) columns; some uses of the column\n      # would be invalid, such as adding a new record with missing values.\n      col_info = sorted([new_col_info.get(col_id, {'id': col_id}) for col_id in data.columns],\n                        key=lambda c: list(c.items()))\n      tdset.apply_doc_action(actions.AddTable(table_id, col_info))\n\n    # And load in the original data, interpreting the TableData object as BulkAddRecord action.\n    tdset.apply_doc_action(actions.BulkAddRecord(*data))\n\n  migration_actions = []\n  for version in range(doc_version + 1, schema.SCHEMA_VERSION + 1):\n    migration_func = all_migrations.get(version, noop_migration)\n    if migration_func.need_all_tables and metadata_only:\n      raise Exception(\"need all tables for migration to %s\" % version)\n    migration_actions.extend(all_migrations.get(version, noop_migration)(tdset))\n\n  # Note that if we are downgrading versions (i.e. doc_version is higher), then the following is\n  # the only action we include into the migration.\n  migration_actions.append(actions.UpdateRecord('_grist_DocInfo', 1, {\n    'schemaVersion': schema.SCHEMA_VERSION\n  }))\n  return migration_actions\n\ndef get_last_migration_version():\n  \"\"\"\n  Returns the last schema version number for which we have a migration defined.\n  \"\"\"\n  return max(all_migrations)\n\ndef migration(schema_version, need_all_tables=False):\n  \"\"\"\n  Decorator for migrations that associates the decorated migration function with the given\n  schema_version. This decorated function will be run to migrate forward to schema_version.\n\n  Migrations are first attempted with only metadata tables, but if any required migration function\n  is marked with need_all_tables=True, then the migration will be retried with all tables.\n\n  NOTE: new migrations should NOT set need_all_tables=True; it would require more work to process\n  very large documents safely (including those containing on-demand tables).\n  \"\"\"\n  def add_migration(migration_func):\n    migration_func.need_all_tables = need_all_tables\n    all_migrations[schema_version] = migration_func\n    return migration_func\n  return add_migration\n\n# A little shorthand to make AddColumn actions more concise.\ndef add_column(table_id, col_id, col_type, *args, **kwargs):\n  return actions.AddColumn(table_id, col_id,\n                           schema.make_column(col_id, col_type, *args, **kwargs))\n\n# Another shorthand to only add a column if it isn't already there.\ndef maybe_add_column(tdset, table_id, col_id, col_type, *args, **kwargs):\n  if col_id not in tdset.all_tables[table_id].columns:\n    return add_column(table_id, col_id, col_type, *args, **kwargs)\n  return None\n\n# Returns the next unused row id for the records of the table given by table_id.\ndef next_id(tdset, table_id):\n  row_ids = tdset.all_tables[table_id].row_ids\n  return max(row_ids) + 1 if row_ids else 1\n\n# Parses a json string, but returns an empty object for invalid json.\ndef safe_parse(json_str):\n  try:\n    return json.loads(json_str)\n  except ValueError:\n    return {}\n\n@migration(schema_version=1)\ndef migration1(tdset):\n  \"\"\"\n  Add TabItems table, and populate based on existing sections.\n  \"\"\"\n  doc_actions = []\n\n  # The very first migration is extra-lax, and creates some tables that are missing in some test\n  # docs. That's only because we did not distinguish schema version before migrations were\n  # implemented. Other migrations should not need such conditionals.\n  if '_grist_Attachments' not in tdset.all_tables:\n    doc_actions.append(actions.AddTable(\"_grist_Attachments\", [\n      schema.make_column(\"fileIdent\",    \"Text\"),\n      schema.make_column(\"fileName\",     \"Text\"),\n      schema.make_column(\"fileType\",     \"Text\"),\n      schema.make_column(\"fileSize\",     \"Int\"),\n      schema.make_column(\"timeUploaded\", \"DateTime\")\n    ]))\n\n  if '_grist_TabItems' not in tdset.all_tables:\n    doc_actions.append(actions.AddTable(\"_grist_TabItems\", [\n      schema.make_column(\"tableRef\",     \"Ref:_grist_Tables\"),\n      schema.make_column(\"viewRef\",      \"Ref:_grist_Views\"),\n    ]))\n\n  if 'schemaVersion' not in tdset.all_tables['_grist_DocInfo'].columns:\n    doc_actions.append(add_column('_grist_DocInfo', 'schemaVersion', 'Int'))\n\n  doc_actions.extend([\n    add_column('_grist_Attachments', 'imageHeight', 'Int'),\n    add_column('_grist_Attachments', 'imageWidth', 'Int'),\n  ])\n\n  view_sections = actions.transpose_bulk_action(tdset.all_tables['_grist_Views_section'])\n  rows = sorted({(s.tableRef, s.parentId) for s in view_sections})\n  if rows:\n    values = {'tableRef': [r[0] for r in rows],\n              'viewRef':  [r[1] for r in rows]}\n    row_ids = list(range(1, len(rows) + 1))\n    doc_actions.append(actions.ReplaceTableData('_grist_TabItems', row_ids, values))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=2)\ndef migration2(tdset):\n  \"\"\"\n  Add TableViews table, and populate based on existing sections.\n  Add TabBar table, and populate based on existing views.\n  Add PrimaryViewId to Tables and populated using relatedViews\n  \"\"\"\n  # Maps tableRef to viewRef\n  primary_views = {}\n\n  # Associate each view with a single table; this dict includes primary views.\n  views_to_table = {}\n\n  # For each table, find a view to serve as the primary view.\n  view_sections = actions.transpose_bulk_action(tdset.all_tables['_grist_Views_section'])\n  for s in view_sections:\n    if s.tableRef not in primary_views and s.parentKey == \"record\":\n      # The view containing this section is a good candidate for primary view.\n      primary_views[s.tableRef] = s.parentId\n\n    if s.parentId not in views_to_table:\n      # The first time we see a (view, table) combination, associate the view with that table.\n      views_to_table[s.parentId] = s.tableRef\n\n  def create_primary_views_action(primary_views):\n    row_ids = sorted(primary_views.keys())\n    values = {'primaryViewId': [primary_views[r] for r in row_ids]}\n    return actions.BulkUpdateRecord('_grist_Tables', row_ids, values)\n\n  def create_tab_bar_action(views_to_table):\n    row_ids = list(range(1, len(views_to_table) + 1))\n    return actions.ReplaceTableData('_grist_TabBar', row_ids, {\n      'viewRef': sorted(views_to_table.keys())\n    })\n\n  def create_table_views_action(views_to_table, primary_views):\n    related_views = sorted(set(views_to_table.keys()) - set(primary_views.values()))\n    row_ids = list(range(1, len(related_views) + 1))\n    return actions.ReplaceTableData('_grist_TableViews', row_ids, {\n      'tableRef': [views_to_table[v] for v in related_views],\n      'viewRef':  related_views,\n    })\n\n  return tdset.apply_doc_actions([\n    actions.AddTable('_grist_TabBar', [\n      schema.make_column('viewRef', 'Ref:_grist_Views'),\n    ]),\n    actions.AddTable('_grist_TableViews', [\n      schema.make_column('tableRef', 'Ref:_grist_Tables'),\n      schema.make_column('viewRef', 'Ref:_grist_Views'),\n    ]),\n    add_column('_grist_Tables', 'primaryViewId', 'Ref:_grist_Views'),\n    create_primary_views_action(primary_views),\n    create_tab_bar_action(views_to_table),\n    create_table_views_action(views_to_table, primary_views)\n  ])\n\n@migration(schema_version=3)\ndef migration3(tdset):\n  \"\"\"\n  There is no longer a \"Derived\" type for columns, and summary tables use the type suitable for\n  the column being summarized. For old documents, convert \"Derived\" type to \"Any\", and adjust the\n  usage of \"lookupOrAddDerived()\" function.\n  \"\"\"\n  # Note that this is a complicated migration, and mainly acceptable because it is before our very\n  # first release. For a released product, a change like this should be done in a backwards\n  # compatible way: keep but deprecate 'Derived'; introduce a lookupOrAddDerived2() to use for new\n  # summary tables, but keep the old interface as well for existing ones. The reason is that such\n  # migrations are error-prone and may mess up customers' data.\n  doc_actions = []\n  tables = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables']))\n  tables_map = {t.id: t for t in tables}\n  columns = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables_column']))\n\n  # Convert columns from type 'Derived' to type 'Any'\n  affected_cols = [c for c in columns if c.type == 'Derived']\n  if affected_cols:\n    doc_actions.extend(\n      actions.ModifyColumn(tables_map[c.parentId].tableId, c.colId, {'type': 'Any'})\n      for c in affected_cols\n    )\n    doc_actions.append(actions.BulkUpdateRecord(\n      '_grist_Tables_column',\n      [c.id for c in affected_cols],\n      {'type': ['Any' for c in affected_cols]}\n    ))\n\n  # Convert formulas of the form '.lookupOrAddDerived($x,$y)' to '.lookupOrAddDerived(x=$x,y=$y)'\n  formula_re = re.compile(r'(\\w+).lookupOrAddDerived\\((.*?)\\)')\n  arg_re = re.compile(r'^\\$(\\w+)$')\n  def replace(match):\n    args = \", \".join(arg_re.sub(r'\\1=$\\1', arg.strip()) for arg in match.group(2).split(\",\"))\n    return '%s.lookupOrAddDerived(%s)' % (match.group(1), args)\n\n  formula_updates = []\n  for c in columns:\n    new_formula = c.formula and formula_re.sub(replace, c.formula)\n    if new_formula != c.formula:\n      formula_updates.append((c, new_formula))\n\n  if formula_updates:\n    doc_actions.extend(\n      actions.ModifyColumn(tables_map[c.parentId].tableId, c.colId, {'formula': f})\n      for c, f in formula_updates\n    )\n    doc_actions.append(actions.BulkUpdateRecord(\n      '_grist_Tables_column',\n      [c.id for c, f in formula_updates],\n      {'formula': [f for c, f in formula_updates]}\n    ))\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=4)\ndef migration4(tdset):\n  \"\"\"\n  Add TabPos column to TabBar table\n  \"\"\"\n  doc_actions = []\n  row_ids = tdset.all_tables['_grist_TabBar'].row_ids\n  doc_actions.append(add_column('_grist_TabBar', 'tabPos', 'PositionNumber'))\n  doc_actions.append(actions.BulkUpdateRecord('_grist_TabBar', row_ids, {'tabPos': row_ids}))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=5)\ndef migration5(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_Views', 'primaryViewTable', 'Ref:_grist_Tables',\n               formula='_grist_Tables.lookupOne(primaryViewId=$id)', isFormula=True),\n  ])\n\n@migration(schema_version=6)\ndef migration6(tdset):\n  # This undoes the previous migration, since primaryViewTable is now a formula private to the\n  # sandbox rather than part of the document schema.\n  return tdset.apply_doc_actions([\n    actions.RemoveColumn('_grist_Views', 'primaryViewTable'),\n  ])\n\n@migration(schema_version=7)\ndef migration7(tdset):\n  \"\"\"\n  Add summarySourceTable/summarySourceCol fields to metadata, and adjust existing summary tables\n  to correspond to the new style.\n  \"\"\"\n  # Note: this migration has some faults.\n  # - It doesn't delete viewSectionFields for columns it removes (if a user added some special\n  #   columns manually.\n  # - It doesn't fix types of Reference columns that refer to old-style summary tables\n  #   (if the user created some such columns manually).\n\n  doc_actions = [action for action in [\n    maybe_add_column(tdset, '_grist_Tables', 'summarySourceTable', 'Ref:_grist_Tables'),\n    maybe_add_column(tdset, '_grist_Tables_column', 'summarySourceCol', 'Ref:_grist_Tables_column')\n  ] if action]\n\n  # Maps tableRef to Table object.\n  tables_map = {t.id: t for t in actions.transpose_bulk_action(tdset.all_tables['_grist_Tables'])}\n\n  # Maps tableName to tableRef\n  table_name_to_ref = {t.tableId: t.id for t in tables_map.values()}\n\n  # List of Column objects\n  columns = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables_column']))\n\n  # Maps columnRef to Column object.\n  columns_map_by_ref = {c.id: c for c in columns}\n\n  # Maps (tableRef, colName) to Column object.\n  columns_map_by_table_colid = {(c.parentId, c.colId): c for c in columns}\n\n  # Set of all tableNames.\n  table_name_set = set(table_name_to_ref.keys())\n\n  remove_cols = []        # List of columns to remove\n  formula_updates = []    # List of (column, new_table_name, new_formula) pairs\n  table_renames = []      # List of (table, new_name) pairs\n  source_tables = []      # List of (table, summarySourceTable) pairs\n  source_cols = []        # List of (column, summarySourceColumn) pairs\n\n  # Summary tables used to be named as \"Summary_<SourceName>_<ColRef1>_<ColRef2>\". This regular\n  # expression parses that.\n  summary_re = re.compile(r'^Summary_(\\w+?)((?:_\\d+)*)$')\n  for t in tables_map.values():\n    m = summary_re.match(t.tableId)\n    if not m or m.group(1) not in table_name_to_ref:\n      continue\n    # We have a valid summary table.\n    source_table_name = m.group(1)\n    source_table_ref = table_name_to_ref[source_table_name]\n    groupby_colrefs = [int(x) for x in m.group(2).strip(\"_\").split(\"_\")]\n    # Prepare a new-style name for the summary table. Be sure not to conflict with existing tables\n    # or with each other (i.e. don't rename multiple tables to the same name).\n    groupby_col_ids = [columns_map_by_ref[c].colId for c in groupby_colrefs]\n    new_name = summary.encode_summary_table_name(source_table_name, groupby_col_ids)\n    new_name = identifiers.pick_table_ident(new_name, avoid=table_name_set)\n    table_name_set.add(new_name)\n    log.warning(\"Upgrading summary table %s for %s(%s) to %s\",\n      t.tableId, source_table_name, groupby_colrefs, new_name)\n\n    # Remove the \"lookupOrAddDerived\" column from the source table (which is named using the\n    # summary table name for its colId).\n    remove_cols.extend(c for c in columns\n                       if c.parentId == source_table_ref and c.colId == t.tableId)\n\n    # Upgrade the \"group\" formula in the summary table.\n    expected_group_formula = \"%s.lookupRecords(%s=$id)\" % (source_table_name, t.tableId)\n    new_formula = \"table.getSummarySourceGroup(rec)\"\n    formula_updates.extend((c, new_name, new_formula) for c in columns\n                           if (c.parentId == t.id and c.colId == \"group\" and\n                               c.formula == expected_group_formula))\n\n    # Schedule a rename of the summary table.\n    table_renames.append((t, new_name))\n\n    # Set summarySourceTable fields on the metadata.\n    source_tables.append((t, source_table_ref))\n\n    # Set summarySourceCol fields in the metadata. We need to find the right summary column.\n    groupby_cols = set()\n    for col_ref in groupby_colrefs:\n      src_col = columns_map_by_ref.get(col_ref)\n      sum_col = columns_map_by_table_colid.get((t.id, src_col.colId)) if src_col else None\n      if sum_col:\n        groupby_cols.add(sum_col)\n        source_cols.append((sum_col, src_col.id))\n      else:\n        log.warning(\"Upgrading summary table %s: couldn't find column %s\", t.tableId, col_ref)\n\n    # Finally, we have to remove all non-formula columns that are not groupby-columns (e.g.\n    # 'manualSort'), because the new approach assumes ALL non-formula columns are for groupby.\n    remove_cols.extend(c for c in columns\n                       if c.parentId == t.id and c not in groupby_cols and not c.isFormula)\n\n  # Create all the doc actions from the arrays we prepared.\n\n  # Process remove_cols\n  doc_actions.extend(\n    actions.RemoveColumn(tables_map[c.parentId].tableId, c.colId) for c in remove_cols)\n  doc_actions.append(actions.BulkRemoveRecord(\n    '_grist_Tables_column', [c.id for c in remove_cols]))\n\n  # Process table_renames\n  doc_actions.extend(\n    actions.RenameTable(t.tableId, new) for (t, new) in table_renames)\n  doc_actions.append(actions.BulkUpdateRecord(\n    '_grist_Tables', [t.id for t, new in table_renames],\n    {'tableId': [new for t, new in table_renames]}\n  ))\n\n  # Process source_tables and source_cols\n  doc_actions.append(actions.BulkUpdateRecord(\n    '_grist_Tables', [t.id for t, ref in source_tables],\n    {'summarySourceTable': [ref for t, ref in source_tables]}\n  ))\n  doc_actions.append(actions.BulkUpdateRecord(\n    '_grist_Tables_column', [t.id for t, ref in source_cols],\n    {'summarySourceCol': [ref for t, ref in source_cols]}\n  ))\n\n  # Process formula_updates. Do this last since recalculation of these may cause new records added\n  # to summary tables, so we should have all the tables correctly set up by this time.\n  doc_actions.extend(\n    actions.ModifyColumn(table_id, c.colId, {'formula': f})\n    for c, table_id, f in formula_updates)\n  doc_actions.append(actions.BulkUpdateRecord(\n    '_grist_Tables_column', [c.id for c, t, f in formula_updates],\n    {'formula': [f for c, t, f in formula_updates]}\n  ))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=8)\ndef migration8(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_Tables_column', 'untieColIdFromLabel', 'Bool'),\n  ])\n\n@migration(schema_version=9)\ndef migration9(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_Tables_column', 'displayCol', 'Ref:_grist_Tables_column'),\n    add_column('_grist_Views_section_field', 'displayCol', 'Ref:_grist_Tables_column'),\n  ])\n\n@migration(schema_version=10)\ndef migration10(tdset):\n  \"\"\"\n  Add displayCol to all reference cols, with formula $<ref_col_id>.<visible_col_id>\n  (Note that displayCol field was added in the previous migration.)\n  \"\"\"\n  doc_actions = []\n  tables = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables']))\n  columns = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables_column']))\n\n  # Maps tableRef to tableId.\n  tables_map = {t.id: t.tableId for t in tables}\n\n  # Maps tableRef to sets of colIds in the tables. Used to prevent repeated colIds.\n  table_col_ids = {t.id: set(tdset.all_tables[t.tableId].columns.keys()) for t in tables}\n\n  # Get the next sequential column row id.\n  row_id = next_id(tdset, '_grist_Tables_column')\n\n  for c in columns:\n    # If a column is a reference with an unset display column, add a display column.\n    if c.type.startswith('Ref:') and not c.displayCol:\n      # Get visible_col_id. If not found, row id is used and no display col is necessary.\n      visible_col_id = \"\"\n      try:\n        visible_col_id = json.loads(c.widgetOptions).get('visibleCol')\n        if not visible_col_id:\n          continue\n      except Exception:\n        continue   # If invalid widgetOptions, skip this column.\n\n      # Set formula to use the current visibleCol in widgetOptions.\n      formula = (\"$%s.%s\" % (c.colId, visible_col_id))\n\n      # Get a unique colId for the display column, and add it to the set of used ids.\n      used_col_ids = table_col_ids[c.parentId]\n      display_col_id = identifiers.pick_col_ident('gristHelper_Display', avoid=used_col_ids)\n      used_col_ids.add(display_col_id)\n\n      # Add all actions to the list.\n      doc_actions.append(add_column(tables_map[c.parentId], 'gristHelper_Display', 'Any',\n        formula=formula, isFormula=True))\n      doc_actions.append(actions.AddRecord('_grist_Tables_column', row_id, {\n        'parentPos': 1.0,\n        'label': 'gristHelper_Display',\n        'isFormula': True,\n        'parentId': c.parentId,\n        'colId': 'gristHelper_Display',\n        'formula': formula,\n        'widgetOptions': '',\n        'type': 'Any'\n      }))\n      doc_actions.append(actions.UpdateRecord('_grist_Tables_column', c.id, {'displayCol': row_id}))\n\n      # Increment row id to the next unused.\n      row_id += 1\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=11)\ndef migration11(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_Views_section', 'embedId', 'Text'),\n  ])\n\n@migration(schema_version=12)\ndef migration12(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_Views_section', 'options', 'Text')\n  ])\n\n@migration(schema_version=13)\ndef migration13(tdset):\n  # Adds a basketId to the entire document to take advantage of basket functionality.\n  # From this version on, embedId is deprecated.\n  return tdset.apply_doc_actions([\n    add_column('_grist_DocInfo', 'basketId', 'Text')\n  ])\n\n@migration(schema_version=14)\ndef migration14(tdset):\n  # Create the ACL table AND also the default ACL groups, default resource, and the default rule.\n  # These match the actions applied to new document by 'InitNewDoc' useraction (as of v14).\n  return tdset.apply_doc_actions([\n    actions.AddTable('_grist_ACLMemberships', [\n      schema.make_column('parent', 'Ref:_grist_ACLPrincipals'),\n      schema.make_column('child', 'Ref:_grist_ACLPrincipals'),\n    ]),\n    actions.AddTable('_grist_ACLPrincipals', [\n      schema.make_column('userName', 'Text'),\n      schema.make_column('groupName', 'Text'),\n      schema.make_column('userEmail', 'Text'),\n      schema.make_column('instanceId', 'Text'),\n      schema.make_column('type', 'Text'),\n    ]),\n    actions.AddTable('_grist_ACLResources', [\n      schema.make_column('colIds', 'Text'),\n      schema.make_column('tableId', 'Text'),\n    ]),\n    actions.AddTable('_grist_ACLRules', [\n      schema.make_column('aclFormula', 'Text'),\n      schema.make_column('principals', 'Text'),\n      schema.make_column('resource', 'Ref:_grist_ACLResources'),\n      schema.make_column('aclColumn', 'Ref:_grist_Tables_column'),\n      schema.make_column('permissions', 'Int'),\n    ]),\n\n    # Set up initial ACL data.\n    actions.BulkAddRecord('_grist_ACLPrincipals', [1,2,3,4], {\n      'type':       ['group', 'group', 'group', 'group'],\n      'groupName':  ['Owners', 'Admins', 'Editors', 'Viewers'],\n    }),\n    actions.AddRecord('_grist_ACLResources', 1, {\n      'tableId': '', 'colIds': ''\n    }),\n    actions.AddRecord('_grist_ACLRules', 1, {\n      'resource': 1, 'permissions': 0x3F, 'principals': '[1]'\n    }),\n  ])\n\n@migration(schema_version=15)\ndef migration15(tdset):\n  # Adds a filter JSON property to each field.\n  # From this version on, filterSpec in _grist_Views_section is deprecated.\n  doc_actions = [\n    add_column('_grist_Views_section_field', 'filter', 'Text')\n  ]\n\n  # Get all section and field data to move section filter data to the fields\n  sections = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Views_section']))\n  fields = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Views_section_field']))\n\n  specs = {s.id: safe_parse(s.filterSpec) for s in sections}\n\n  # Move filter data from sections to fields\n  for f in fields:\n    # If the field belongs to the section and the field's colRef is in its filterSpec,\n    # pull the filter setting from the section.\n    filter_spec = specs.get(f.parentId)\n    if filter_spec and str(f.colRef) in filter_spec:\n      doc_actions.append(actions.UpdateRecord('_grist_Views_section_field', f.id, {\n        'filter': json.dumps(filter_spec[str(f.colRef)])\n      }))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=16)\ndef migration16(tdset):\n  # Add visibleCol to columns and view fields, and set it from columns' and fields' widgetOptions.\n  doc_actions = [\n    add_column('_grist_Tables_column', 'visibleCol', 'Ref:_grist_Tables_column'),\n    add_column('_grist_Views_section_field', 'visibleCol', 'Ref:_grist_Tables_column'),\n  ]\n\n  # Maps tableId to table, for looking up target table as listed in \"Ref:*\" types.\n  tables = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables']))\n  tables_by_id = {t.tableId: t for t in tables}\n\n  # Allow looking up columns by ref or by (tableRef, colId)\n  columns = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables_column']))\n  columns_by_ref = {c.id: c for c in columns}\n  columns_by_id = {(c.parentId, c.colId): c.id for c in columns}\n\n  # Helper which returns the {'visibleCol', 'widgetOptions'} update visibleCol should be set.\n  def convert_visible_col(col, widget_options):\n    if not col.type.startswith('Ref:'):\n      return None\n\n    # To set visibleCol, we need to know the target table. Skip if we can't find it.\n    target_table = tables_by_id.get(col.type[len('Ref:'):])\n    if not target_table:\n      return None\n\n    try:\n      parsed_options = json.loads(widget_options)\n    except Exception:\n      return None   # If invalid widgetOptions, skip this column.\n\n    visible_col_id = parsed_options.pop('visibleCol', None)\n    if not visible_col_id:\n      return None\n\n    # Find visible_col_id as the column name in the appropriate table.\n    target_col_ref = (0 if visible_col_id == 'id' else\n                      columns_by_id.get((target_table.id, visible_col_id), None))\n    if target_col_ref is None:\n      return None\n\n    # Use compact separators without whitespace, to match how JS encodes JSON.\n    return {'visibleCol': target_col_ref,\n            'widgetOptions': json.dumps(parsed_options, separators=(',', ':')) }\n\n  for c in columns:\n    new_values = convert_visible_col(c, c.widgetOptions)\n    if new_values:\n      doc_actions.append(actions.UpdateRecord('_grist_Tables_column', c.id, new_values))\n\n  fields = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Views_section_field']))\n  for f in fields:\n    c = columns_by_ref.get(f.colRef)\n    if c:\n      new_values = convert_visible_col(c, f.widgetOptions)\n      if new_values:\n        doc_actions.append(actions.UpdateRecord('_grist_Views_section_field', f.id, new_values))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n# This is actually the only migration that requires all tables because it modifies user data\n# (specifically, any columns of the deprecated \"Image\" type).\n@migration(schema_version=17, need_all_tables=True)\ndef migration17(tdset):\n  \"\"\"\n  There is no longer an \"Image\" type for columns, as \"Attachments\" now serves as a\n  display type for arbitrary files including images. Convert \"Image\" columns to \"Attachments\"\n  columns.\n  \"\"\"\n  doc_actions = []\n  tables = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables']))\n  tables_map = {t.id: t for t in tables}\n  columns = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables_column']))\n\n  # Convert columns from type 'Image' to type 'Attachments'\n  affected_cols = [c for c in columns if c.type == 'Image']\n  conv = lambda val: [val] if isinstance(val, int) and val > 0 else []\n  if affected_cols:\n    # Update the types in the data tables\n    doc_actions.extend(\n      actions.ModifyColumn(tables_map[c.parentId].tableId, c.colId, {'type': 'Attachments'})\n      for c in affected_cols\n    )\n    # Update the types in the metadata tables\n    doc_actions.append(actions.BulkUpdateRecord(\n      '_grist_Tables_column',\n      [c.id for c in affected_cols],\n      {'type': ['Attachments' for c in affected_cols]}\n    ))\n    # Update the values to lists\n    for c in affected_cols:\n      if c.isFormula:\n        # Formula columns don't have data stored in DB, should not have data changes.\n        continue\n      table_id = tables_map[c.parentId].tableId\n      table = tdset.all_tables[table_id]\n      doc_actions.append(\n        actions.BulkUpdateRecord(table_id, table.row_ids,\n          {c.colId: [conv(val) for val in table.columns[c.colId]]})\n      )\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=18)\ndef migration18(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_DocInfo', 'timezone', 'Text'),\n    # all documents prior to this migration have been created in New York\n    actions.UpdateRecord('_grist_DocInfo', 1, {'timezone': 'America/New_York'})\n  ])\n\n@migration(schema_version=19)\ndef migration19(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_Tables', 'onDemand', 'Bool'),\n  ])\n\n@migration(schema_version=20)\ndef migration20(tdset):\n  \"\"\"\n  Add _grist_Pages table and populate based on existing TableViews entries, ie: tables are sorted\n  alphabetically by their `tableId` and views are gathered within their corresponding table and\n  sorted by their id.\n  \"\"\"\n  tables = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables']))\n  table_map = {t.id: t for t in tables}\n  table_views = list(actions.transpose_bulk_action(tdset.all_tables['_grist_TableViews']))\n  # Old docs may include \"Other views\", not associated with any table. Don't include those in\n  # table_views_map: they'll get included but not sorted or grouped by tableId.\n  table_views_map = {tv.viewRef: table_map[tv.tableRef].tableId\n                     for tv in table_views if tv.tableRef in table_map}\n  views = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Views']))\n  def view_key(view):\n    \"\"\"\n    Returns (\"Table1\", 2) where \"Table1\" is the view's tableId and 2 the view id. For\n    primary view (ie: not referenced in _grist_TableViews) returns (\"Table1\", -1). Useful\n    to get the list of views sorted in the same way as in the Table side pane. We use -1\n    for primary view to make sure they come first among all the views of the same table.\n    \"\"\"\n    if view.id in table_views_map:\n      return (table_views_map[view.id], view.id)\n    # the name of primary view's is the same as the tableId\n    return (view.name, -1)\n  views.sort(key=view_key)\n  row_ids = list(range(1, len(views) + 1))\n  return tdset.apply_doc_actions([\n    actions.AddTable('_grist_Pages', [\n      schema.make_column('viewRef', 'Ref:_grist_Views'),\n      schema.make_column('pagePos', 'PositionNumber'),\n      schema.make_column('indentation', 'Int'),\n    ]),\n    actions.ReplaceTableData('_grist_Pages', row_ids, {\n      'viewRef': [v.id for v in views],\n      'pagePos': row_ids,\n      'indentation': [1 if v.id in table_views_map else 0 for v in views]\n    })\n  ])\n\n@migration(schema_version=21)\ndef migration21(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_ACLRules', 'aclFormulaParsed', 'Text'),\n    add_column('_grist_ACLRules', 'permissionsText', 'Text'),\n    add_column('_grist_ACLRules', 'rulePos', 'PositionNumber'),\n    add_column('_grist_ACLRules', 'userAttributes', 'Text'),\n  ])\n\n\n@migration(schema_version=22)\ndef migration22(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_Tables_column', 'recalcWhen', 'Int'),\n    add_column('_grist_Tables_column', 'recalcDeps', 'RefList:_grist_Tables_column'),\n  ])\n\n@migration(schema_version=23)\ndef migration23(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_DocInfo', 'documentSettings', 'Text'),\n    actions.UpdateRecord('_grist_DocInfo', 1, {'documentSettings': '{\"locale\":\"en-US\"}'})\n  ])\n\n\n@migration(schema_version=24)\ndef migration24(tdset):\n  return tdset.apply_doc_actions([\n    actions.AddTable('_grist_Triggers', [\n      schema.make_column(\"tableRef\", \"Ref:_grist_Tables\"),\n      schema.make_column(\"eventTypes\", \"ChoiceList\"),\n      schema.make_column(\"isReadyColRef\", \"Ref:_grist_Tables_column\"),\n      schema.make_column(\"actions\", \"Text\"),  # JSON\n    ]),\n  ])\n\n@migration(schema_version=25)\ndef migration25(tdset):\n  \"\"\"\n  Add _grist_Filters table and populate based on existing filters stored\n  in _grist_Views_section_field.\n\n  From this version on, filter in _grist_Views_section_field is deprecated.\n  \"\"\"\n  doc_actions = [\n    actions.AddTable('_grist_Filters', [\n      schema.make_column(\"viewSectionRef\", \"Ref:_grist_Views_section\"),\n      schema.make_column(\"colRef\", \"Ref:_grist_Tables_column\"),\n      schema.make_column(\"filter\", \"Text\"),\n    ])\n  ]\n\n  # Move existing field filters to _grist_Filters.\n  fields = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Views_section_field']))\n  col_info = { 'filter': [], 'colRef': [], 'viewSectionRef': [] }\n  for f in fields:\n    if not f.filter:\n      continue\n\n    col_info['filter'].append(f.filter)\n    col_info['colRef'].append(f.colRef)\n    col_info['viewSectionRef'].append(f.parentId)\n\n  num_filters = len(col_info['filter'])\n  if num_filters > 0:\n    doc_actions.append(actions.BulkAddRecord('_grist_Filters', [None] * num_filters, col_info))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n\n@migration(schema_version=26)\ndef migration26(tdset):\n  \"\"\"\n  Add rawViewSectionRef column to _grist_Tables\n  and new raw view sections for each 'normal' table.\n  \"\"\"\n  doc_actions = [add_column('_grist_Tables', 'rawViewSectionRef', 'Ref:_grist_Views_section')]\n\n  tables = list(actions.transpose_bulk_action(tdset.all_tables[\"_grist_Tables\"]))\n  columns = list(actions.transpose_bulk_action(tdset.all_tables[\"_grist_Tables_column\"]))\n  views = {view.id: view\n           for view in actions.transpose_bulk_action(tdset.all_tables[\"_grist_Views\"])}\n\n  new_view_section_id = next_id(tdset, \"_grist_Views_section\")\n\n  for table in sorted(tables, key=lambda t: t.tableId):\n    old_view = views.get(table.primaryViewId)\n    if not (table.primaryViewId and old_view):\n      continue\n\n    table_columns = [\n      col for col in columns\n      if table.id == col.parentId and is_visible_column(col.colId)\n    ]\n    table_columns.sort(key=lambda c: c.parentPos)\n    fields = {\n      \"parentId\": [new_view_section_id] * len(table_columns),\n      \"colRef\": [col.id for col in table_columns],\n      \"parentPos\": [col.parentPos for col in table_columns],\n    }\n    field_ids = [None] * len(table_columns)\n\n    doc_actions += [\n      actions.AddRecord(\n        \"_grist_Views_section\", new_view_section_id, {\n          \"tableRef\": table.id,\n          \"parentId\": 0,\n          \"parentKey\": \"record\",\n          \"title\": old_view.name,\n          \"defaultWidth\": 100,\n          \"borderWidth\": 1,\n        }),\n      actions.UpdateRecord(\n        \"_grist_Tables\", table.id, {\n          \"rawViewSectionRef\": new_view_section_id,\n        }),\n      actions.BulkAddRecord(\n        \"_grist_Views_section_field\", field_ids, fields\n      ),\n    ]\n\n    new_view_section_id += 1\n\n  return tdset.apply_doc_actions(doc_actions)\n\n\n@migration(schema_version=27)\ndef migration27(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_Tables_column', 'rules', 'RefList:_grist_Tables_column'),\n    add_column('_grist_Views_section_field', 'rules', 'RefList:_grist_Tables_column'),\n  ])\n\n\n@migration(schema_version=28)\ndef migration28(tdset):\n  doc_actions = [add_column('_grist_Attachments', 'timeDeleted', 'DateTime')]\n\n  tables = list(actions.transpose_bulk_action(tdset.all_tables[\"_grist_Tables\"]))\n  columns = list(actions.transpose_bulk_action(tdset.all_tables[\"_grist_Tables_column\"]))\n\n  for table in tables:\n    for col in columns:\n      if table.id == col.parentId and col.type == \"Attachments\":\n        # This looks like it doesn't change anything,\n        # but it makes DocStorage realise that the sqlType has changed\n        # so it converts marshalled blobs to JSON\n        doc_actions.append(actions.ModifyColumn(table.tableId, col.colId, {\"type\": \"Attachments\"}))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n\n@migration(schema_version=29)\ndef migration29(tdset):\n  # This migration is fixing an error on summary tables with conditional rules.\n  # On summary tables all formula columns with the same name were updated together,\n  # which caused a situation where some summary columns have rules from diffrent tables.\n  # This migration is removing those rules in such columns.\n\n  tables = {table.id: table\n          for table in actions.transpose_bulk_action(tdset.all_tables[\"_grist_Tables\"])}\n  columns = {col.id: col\n          for col in actions.transpose_bulk_action(tdset.all_tables[\"_grist_Tables_column\"])}\n  doc_actions = []\n\n  def is_valid_rule(parentId, rule_id):\n    # Valid rule should be an existing column,\n    rule_col = columns.get(rule_id)\n    # in the same table.\n    return rule_col and rule_col.parentId == parentId\n\n  for col in columns.values():\n    if col.rules:\n      # Parse rules (they are a json encoded array like '[15]')\n      rules = safe_parse(col.rules)\n      # Remove all conditional styles if anything about rules is invalid.\n      if not (\n        isinstance(rules, list) and\n        all(is_valid_rule(col.parentId, ruleId) for ruleId in rules)\n      ):\n        doc_actions.append(actions.UpdateRecord('_grist_Tables_column', col.id, {\n          \"rules\": None,\n          \"widgetOptions\": summary._copy_widget_options(col.widgetOptions)\n        }))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=30)\ndef migration30(tdset):\n  \"\"\"\n  Add raw view sections for each summary table. This is similar to migration 26, but for\n  summary tables instead of user tables.\n  \"\"\"\n  doc_actions = []\n\n  tables = list(actions.transpose_bulk_action(tdset.all_tables[\"_grist_Tables\"]))\n  columns = list(actions.transpose_bulk_action(tdset.all_tables[\"_grist_Tables_column\"]))\n\n  new_view_section_id = next_id(tdset, \"_grist_Views_section\")\n\n  for table in sorted(tables, key=lambda t: t.tableId):\n    if not table.summarySourceTable:\n      continue\n\n    table_columns = [\n      col for col in columns\n      if table.id == col.parentId and is_visible_column(col.colId)\n    ]\n    table_columns.sort(key=lambda c: c.parentPos)\n    fields = {\n      \"parentId\": [new_view_section_id] * len(table_columns),\n      \"colRef\": [col.id for col in table_columns],\n      \"parentPos\": [col.parentPos for col in table_columns],\n    }\n    field_ids = [None] * len(table_columns)\n\n    doc_actions += [\n      actions.AddRecord(\n        \"_grist_Views_section\", new_view_section_id, {\n          \"tableRef\": table.id,\n          \"parentId\": 0,\n          \"parentKey\": \"record\",\n          \"title\": \"\",\n          \"defaultWidth\": 100,\n          \"borderWidth\": 1,\n        }\n      ),\n      actions.UpdateRecord(\n        \"_grist_Tables\", table.id, {\n          \"rawViewSectionRef\": new_view_section_id,\n        })\n      ,\n      actions.BulkAddRecord(\n        \"_grist_Views_section_field\", field_ids, fields\n      ),\n    ]\n\n    new_view_section_id += 1\n\n  return tdset.apply_doc_actions(doc_actions)\n\n\n@migration(schema_version=31)\ndef migration31(tdset):\n  columns = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables_column']))\n  tables = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables']))\n  acl_resources = list(actions.transpose_bulk_action(tdset.all_tables['_grist_ACLResources']))\n\n  tables_by_ref = {t.id: t for t in tables}\n  columns_by_table_ref = defaultdict(list)\n  for col in columns:\n    columns_by_table_ref[col.parentId].append(col)\n\n  table_name_set = {t.tableId for t in tables}\n\n  table_renames = []      # List of (table, new_name) pairs\n\n  for t in tables_by_ref.values():\n    if not t.summarySourceTable:\n      continue\n    source_table = tables_by_ref[t.summarySourceTable]\n    # Prepare a new-style name for the summary table. Be sure not to conflict with existing tables\n    # or with each other (i.e. don't rename multiple tables to the same name).\n    groupby_col_ids = [c.colId for c in columns_by_table_ref[t.id] if c.summarySourceCol]\n    new_name = summary.encode_summary_table_name(source_table.tableId, groupby_col_ids)\n    if new_name == t.tableId:\n      continue\n    new_name = identifiers.pick_table_ident(new_name, avoid=table_name_set)\n    table_name_set.add(new_name)\n    log.warning(\"Upgrading summary table %s for %s(%s) to %s\",\n      t.tableId, source_table.tableId, groupby_col_ids, new_name)\n\n    # Schedule a rename of the summary table.\n    table_renames.append((t, new_name))\n\n  doc_actions = [\n    actions.RenameTable(t.tableId, new)\n    for (t, new) in table_renames\n  ]\n  if table_renames:\n    doc_actions.append(\n      actions.BulkUpdateRecord(\n        '_grist_Tables', [t.id for t, new in table_renames],\n        {'tableId': [new for t, new in table_renames]}\n      )\n    )\n\n  # Update formulas in all columns containing old-style names like 'GristSummary_'\n  for col in columns:\n    if 'GristSummary_' not in col.formula:\n      continue\n    formula = col.formula\n    for table, new_name in table_renames:\n      # Use regex to only match whole words\n      formula = re.sub(r'\\b%s\\b' % table.tableId, new_name, formula)\n    doc_actions.append(actions.UpdateRecord('_grist_Tables_column', col.id, {'formula': formula}))\n\n  table_renames_dict = {t.tableId: new for t, new in table_renames}\n  for resource in acl_resources:\n    new_name = table_renames_dict.get(resource.tableId)\n    if new_name:\n      doc_actions.append(\n        actions.UpdateRecord('_grist_ACLResources', resource.id, {'tableId': new_name})\n      )\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=32)\ndef migration32(tdset):\n  return tdset.apply_doc_actions([\n    add_column('_grist_Views_section', 'rules', 'RefList:_grist_Tables_column'),\n  ])\n\n@migration(schema_version=33)\ndef migration33(tdset):\n  \"\"\"\n  Add _grist_Cells table\n  \"\"\"\n  doc_actions = [\n    actions.AddTable('_grist_Cells', [\n      schema.make_column(\"tableRef\",       \"Ref:_grist_Tables\"),\n      schema.make_column(\"colRef\",         \"Ref:_grist_Tables_column\"),\n      schema.make_column(\"rowId\",          \"Int\"),\n      schema.make_column(\"root\",           \"Bool\"),\n      schema.make_column(\"parentId\",       \"Ref:_grist_Cells\"),\n      schema.make_column(\"type\",           \"Int\"),\n      schema.make_column(\"content\",        \"Text\"),\n      schema.make_column(\"userRef\",        \"Text\"),\n    ]),\n  ]\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=34)\ndef migration34(tdset):\n  \"\"\"\n  Add pinned column to _grist_Filters and populate based on existing sections.\n\n  When populating, pinned will be set to true for filters that either belong to\n  a section where the filter bar is toggled or a raw view section.\n\n  From this version on, _grist_Views_section.options.filterBar is deprecated.\n  \"\"\"\n  doc_actions = [add_column('_grist_Filters', 'pinned', 'Bool')]\n\n  tables = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables']))\n  sections = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Views_section']))\n  filters = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Filters']))\n  raw_section_ids = set(t.rawViewSectionRef for t in tables)\n  filter_bar_by_section_id = {\n    # Pre-migration, raw sections always showed the filter bar in the UI. Since we want\n    # existing raw section filters to continue appearing in the filter bar, we'll pretend\n    # here that raw sections have a filterBar value of True. Note that after this migration\n    # it will be possible for raw sections to have unpinned filters.\n    s.id: bool(s.id in raw_section_ids or safe_parse(s.options).get('filterBar', False))\n    for s in sections\n  }\n\n  # List of (filter_rec, pinned) pairs.\n  filter_updates = []\n  for filter_rec in filters:\n    filter_updates.append((\n      filter_rec,\n      filter_bar_by_section_id.get(filter_rec.viewSectionRef, False)\n    ))\n\n  if filter_updates:\n    doc_actions.append(actions.BulkUpdateRecord(\n      '_grist_Filters',\n      [filter_rec.id for filter_rec, _ in filter_updates],\n      {'pinned': [pinned for _, pinned in filter_updates]},\n    ))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=35)\ndef migration35(tdset):\n  \"\"\"\n  Add memo column to _grist_ACLRules and populate with comments stored in\n  _grist_ACLRules.aclFormula.\n\n  From this version on, comments in _grist_ACLRules.aclFormula will no longer\n  be used as memos.\n  \"\"\"\n  doc_actions = [add_column('_grist_ACLRules', 'memo', 'Text')]\n\n  acl_rules = list(actions.transpose_bulk_action(tdset.all_tables['_grist_ACLRules']))\n\n  # List of (acl_rule_rec, memo) pairs.\n  acl_rule_updates = []\n  for acl_rule_rec in acl_rules:\n    acl_formula = safe_parse(acl_rule_rec.aclFormulaParsed)\n    if not acl_formula or acl_formula[0] != 'Comment':\n      continue\n\n    acl_rule_updates.append((\n      acl_rule_rec,\n      acl_formula[2]\n    ))\n\n  if acl_rule_updates:\n    doc_actions.append(actions.BulkUpdateRecord(\n      '_grist_ACLRules',\n      [acl_rule_rec.id for acl_rule_rec, _ in acl_rule_updates],\n      {'memo': [memo for _, memo in acl_rule_updates]},\n    ))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=36)\ndef migration36(tdset):\n  \"\"\"\n  Add description to column\n  \"\"\"\n  return tdset.apply_doc_actions([add_column('_grist_Tables_column', 'description', 'Text')])\n\n@migration(schema_version=37)\ndef migration37(tdset):\n  \"\"\"\n  Add fileExt column to _grist_Attachments.\n  \"\"\"\n  return tdset.apply_doc_actions([add_column('_grist_Attachments', 'fileExt', 'Text')])\n\n@migration(schema_version=38)\ndef migration38(tdset):\n  \"\"\"\n  Through a mishap, this migration ended up conflicted across two version of Grist.\n  In one version, it added webhook related columns. In another it added a description\n  to widgets. Sorry if this impacted you. Migration 39 does the best we can to\n  smooth over the divergence, and this migration now does nothing (though in the\n  past it did one of two possible things).\n  \"\"\"\n  return tdset.apply_doc_actions([])\n\n@migration(schema_version=39)\ndef migration39(tdset):\n  \"\"\"\n  Adds memo, label, and enabled flag to triggers (for webhooks).\n  Adds a description to widgets.\n  \"\"\"\n  doc_actions = []\n  if 'memo' not in tdset.all_tables['_grist_Triggers'].columns:\n    doc_actions += [add_column('_grist_Triggers', 'memo', 'Text'),\n                    add_column('_grist_Triggers', 'label', 'Text'),\n                    add_column('_grist_Triggers', 'enabled', 'Bool')]\n    triggers = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Triggers']))\n    doc_actions.append(actions.BulkUpdateRecord(\n      '_grist_Triggers',\n      [t.id for t in triggers],\n      {'enabled': [True for t in triggers]}\n    ))\n  if 'description' not in tdset.all_tables['_grist_Views_section'].columns:\n    doc_actions.append(add_column('_grist_Views_section', 'description', 'Text'))\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=40)\ndef migration40(tdset):\n  \"\"\"\n  Adds a recordCardViewSectionRef column to _grist_Tables, populating it\n  for each non-summary table in _grist_Tables that has a rawViewSectionRef.\n  \"\"\"\n  doc_actions = [\n    add_column(\n      '_grist_Tables',\n      'recordCardViewSectionRef',\n      'Ref:_grist_Views_section'\n    ),\n  ]\n\n  tables = list(actions.transpose_bulk_action(tdset.all_tables[\"_grist_Tables\"]))\n  columns = list(actions.transpose_bulk_action(tdset.all_tables[\"_grist_Tables_column\"]))\n\n  new_view_section_id = next_id(tdset, \"_grist_Views_section\")\n\n  for table in sorted(tables, key=lambda t: t.tableId):\n    if not table.rawViewSectionRef or table.summarySourceTable:\n      continue\n\n    table_columns = [\n      col for col in columns\n      if table.id == col.parentId and is_visible_column(col.colId)\n    ]\n    table_columns.sort(key=lambda c: c.parentPos)\n    fields = {\n      \"parentId\": [new_view_section_id] * len(table_columns),\n      \"colRef\": [col.id for col in table_columns],\n      \"parentPos\": [col.parentPos for col in table_columns],\n    }\n    field_ids = [None] * len(table_columns)\n\n    doc_actions += [\n      actions.AddRecord(\"_grist_Views_section\", new_view_section_id, {\n        \"tableRef\": table.id,\n        \"parentId\": 0,\n        \"parentKey\": \"single\",\n        \"title\": \"\",\n        \"defaultWidth\": 100,\n        \"borderWidth\": 1,\n      }),\n      actions.UpdateRecord(\"_grist_Tables\", table.id, {\n        \"recordCardViewSectionRef\": new_view_section_id,\n      }),\n      actions.BulkAddRecord(\"_grist_Views_section_field\", field_ids, fields),\n    ]\n\n    new_view_section_id += 1\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=41)\ndef migration41(tdset):\n  \"\"\"\n  Add a table for tracking special shares.\n  \"\"\"\n  doc_actions = [\n    actions.AddTable(\"_grist_Shares\", [\n      schema.make_column(\"linkId\", \"Text\"),\n      schema.make_column(\"options\", \"Text\"),\n      schema.make_column(\"label\", \"Text\"),\n      schema.make_column(\"description\", \"Text\"),\n    ]),\n    add_column('_grist_Pages', 'shareRef', 'Ref:_grist_Shares'),\n    add_column('_grist_Views_section', 'shareOptions', 'Text'),\n  ]\n\n  return tdset.apply_doc_actions(doc_actions)\n\n@migration(schema_version=42)\ndef migration42(tdset):\n  \"\"\"\n  Adds column to register which table columns are triggered in webhooks.\n  \"\"\"\n  return tdset.apply_doc_actions([\n    add_column('_grist_Triggers', 'watchedColRefList', 'RefList:_grist_Tables_column'),\n    add_column('_grist_Triggers', 'options', 'Text'),\n  ])\n\n@migration(schema_version=43)\ndef migration43(tdset):\n  \"\"\"\n  Adds reverseCol for two-way references.\n  \"\"\"\n  return tdset.apply_doc_actions([\n    add_column('_grist_Tables_column', 'reverseCol', 'Ref:_grist_Tables_column')])\n\n@migration(schema_version=44)\ndef migration44(tdset):\n  \"\"\"\n  Add an options column to _grist_Pages.\n  \"\"\"\n  return tdset.apply_doc_actions([\n    add_column('_grist_Pages', 'options', 'Text')\n  ])\n\n@migration(schema_version=45)\ndef migration45(tdset):\n  \"\"\"\n  Move timeCreated, timeUpdated, and resolved fields from JSON content to separate columns\n  in _grist_Cells table for better access control.\n  \"\"\"\n  doc_actions = [\n    add_column('_grist_Cells', 'timeCreated', 'DateTime'),\n    add_column('_grist_Cells', 'timeUpdated', 'DateTime'),\n    add_column('_grist_Cells', 'resolved', 'Bool'),\n  ]\n\n  # Migrate existing data from JSON content to new columns\n  cells = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Cells']))\n\n  if cells:\n    time_created_values = []\n    time_updated_values = []\n    resolved_values = []\n    content_updates = []\n\n    for cell in cells:\n      content = safe_parse(cell.content)\n\n      # Make sure content is a dict\n      if not isinstance(content, dict):\n        content = {}\n\n      time_created = content.get('timeCreated')\n      time_updated = content.get('timeUpdated')\n\n      # Convert milliseconds to seconds for DateTime columns\n      time_created_values.append(int(time_created / 1000) if time_created is not None else 0)\n      time_updated_values.append(int(time_updated / 1000) if time_updated is not None else 0)\n      resolved_values.append(bool(content.get('resolved', False)))\n\n      # Remove these fields from JSON content if they exist\n      if any(key in content for key in ['timeCreated', 'timeUpdated', 'resolved']):\n        content.pop('timeCreated', None)\n        content.pop('timeUpdated', None)\n        content.pop('resolved', None)\n        content_updates.append(json.dumps(content))\n      else:\n        content_updates.append(cell.content)\n\n    # Update all cells with the extracted values\n    cell_ids = [cell.id for cell in cells]\n    doc_actions.append(actions.BulkUpdateRecord('_grist_Cells', cell_ids, {\n      'timeCreated': time_created_values,\n      'timeUpdated': time_updated_values,\n      'resolved': resolved_values,\n      'content': content_updates,\n    }))\n\n  return tdset.apply_doc_actions(doc_actions)\n\n\n@migration(schema_version=46)\ndef migration46(tdset):\n  \"\"\"\n  Adds column to store trigger condition, a new way to define when triggers are invoked.\n  \"\"\"\n  return tdset.apply_doc_actions([\n    add_column('_grist_Triggers', 'condition', 'Text'),\n  ])\n"
  },
  {
    "path": "sandbox/grist/moment.py",
    "content": "from datetime import datetime, timedelta, tzinfo as _tzinfo\nfrom collections import namedtuple\nimport marshal\nfrom time import time\nimport bisect\nimport os\nimport iso8601\n\ntry:\n  from functools import lru_cache\nexcept ImportError:\n  from backports.functools_lru_cache import lru_cache  # noqa\n\n\n# This is prepared by sandbox/install_tz.py\nZoneRecord = namedtuple(\"ZoneRecord\", (\"name\", \"abbrs\", \"offsets\", \"untils\"))\n\n# moment.py mirrors core functionality of moment-timezone.js\n# Documentation: http://momentjs.com/timezone/docs/\n\nEPOCH = datetime(1970, 1, 1)\nDATE_EPOCH = EPOCH.date()\n\nCURRENT_DATE = DATE_EPOCH + timedelta(seconds=time())\n\n_TZDATA = None\n\n# Returns a dictionary mapping timezone name to ZoneRecord object. It reads the data on first\n# call, caches it, and returns cached data on all future calls.\ndef get_tz_data():\n  global _TZDATA    # pylint: disable=global-statement\n  if _TZDATA is None:\n    all_zones = read_tz_raw_data()\n    # The marshalled data is an array of tuples (name, abbrs, offsets, untils)\n    _TZDATA = {x[0]: ZoneRecord._make(x) for x in all_zones}\n  return _TZDATA\n\n# Reads and returns the marshalled tzdata file (produced by sandbox/install_tz.py).\n# The return value is a list of tuples (name, abbrs, offsets, untils).\ndef read_tz_raw_data():\n  tzfile = os.path.join(os.path.dirname(__file__), \"tzdata.data\")\n  with open(tzfile, \"rb\") as tzdata:\n    return marshal.load(tzdata)\n\n\n# Converts a UTC datetime to timestamp in milliseconds.\ndef utc_to_ts_ms(dt):\n  return (dt.replace(tzinfo=None) - EPOCH).total_seconds() * 1000\n\n# Converts timestamp in seconds to datetime in the given timezone. If tzinfo is given, then zone\n# is ignored and may be None.\n@lru_cache(maxsize=1024)\ndef ts_to_dt(timestamp, zone, tzinfo=None):\n  return (EPOCH_UTC + timedelta(seconds=timestamp)).astimezone(tzinfo or zone.get_tzinfo(None))\n\n# Converts datetime to timestamp in seconds. Optional timezone may be given to serve as the\n# default if dt is unaware (has no associated timezone).\ndef dt_to_ts(dt, timezone=None):\n  offset = dt.utcoffset()\n  if offset is None:\n    offset = timezone.dt_offset(dt) if timezone else timedelta(0)\n  return (dt.replace(tzinfo=None) - offset - EPOCH).total_seconds()\n\n# Converts timestamp in seconds to date.\n@lru_cache(maxsize=1024)\ndef ts_to_date(timestamp):\n  return DATE_EPOCH + timedelta(seconds=timestamp)\n\n# Converts date to timestamp of the midnight in seconds, in the given timezone, or UTC by default.\ndef date_to_ts(date, timezone=None):\n  ts = (date - DATE_EPOCH).total_seconds()\n  return ts if not timezone else ts - timezone.offset(ts * 1000).total_seconds()\n\n# Parses a datetime in the ISO format, YYYY-MM-DDTHH:MM:SS.mmmmmm+HH:MM. Most parts are optional;\n# see https://pypi.org/project/iso8601/ for details. Returns a timestamp in seconds.\ndef parse_iso(date_string, timezone=None):\n  dt = iso8601.parse_date(date_string, default_timezone=None)\n  return dt_to_ts(dt, timezone)\n\n# Parses a date in ISO format, ignoring all time components. Returns timestamp of UTC midnight.\ndef parse_iso_date(date_string):\n  dt = iso8601.parse_date(date_string, default_timezone=None)\n  return date_to_ts(dt.date())\n\n\nclass tz(object):\n  \"\"\"Implements basics of moment.js and moment-timezone.js\"\"\"\n  # dt (datetime / number) - Either a local datetime in the time of the\n  #   provided timezone or a timestamp since epoch in milliseconds.\n  # zonelabel (string) - The name of the timezone; should correspond to\n  #   one of the names in the moment-timezone json data.\n  def __init__(self, dt, zonelabel=\"UTC\"):\n    self._tzinfo = tzinfo(zonelabel)\n    if isinstance(dt, datetime):\n      timestamp = dt_to_ts(dt.replace(tzinfo=self._tzinfo)) * 1000\n    elif isinstance(dt, (float, int,)):\n      timestamp = dt\n    else:\n      raise TypeError(\"'dt' should be a datetime object or a numeric type\")\n    self.timestamp = timestamp\n\n  # Returns the timestamp in seconds\n  def timestamp_s(self):\n    return self.timestamp / 1000\n\n  # Changes the timezone to the one corresponding to 'zonelabel' without\n  #   changing the underlying time since epoch.\n  def tz(self, zonelabel):\n    self._tzinfo = tzinfo(zonelabel)\n    return self\n\n  # Returns a datetime object with the moment-timezone object's local time and the timezone\n  #   at the current timestamp.\n  def datetime(self):\n    return ts_to_dt(self.timestamp / 1000.0, None, self._tzinfo)\n\n  def zoneName(self):\n    return self._tzinfo.zone.name\n\n  def zoneAbbr(self):\n    return self._tzinfo.zone.abbr(self.timestamp)\n\n  def zoneOffset(self):\n    return self._tzinfo.zone.offset(self.timestamp)\n\n\nclass TzInfo(_tzinfo):\n  \"\"\"\n  Implements datetime.tzinfo interface using moment-timezone data. If favor_offset is used, it\n  tells which offset to favor when a datetime is ambiguous. If None, the offset that's in effect\n  earlier is favored.\n  \"\"\"\n  def __init__(self, zone, favor_offset):\n    super(TzInfo, self).__init__()\n    self.zone = zone\n    self._favor_offset = favor_offset\n\n  def utcoffset(self, dt):\n    \"\"\"Implementation of tzinfo.utcoffset interface.\"\"\"\n    return self.zone.dt_offset(dt, self._favor_offset)\n\n  def tzname(self, dt):\n    \"\"\"Implementation of tzinfo.tzname interface.\"\"\"\n    abbr = self.zone.dt_tzname(dt, self._favor_offset)\n    return abbr\n\n  def dst(self, dt):\n    \"\"\"Implementation of tzinfo.dst interface.\"\"\"\n    return self.utcoffset(dt) - self.zone.standard_offset\n\n  def fromutc(self, dt):\n    # This produces a datetime with a specific offset, and sets tzinfo that favors that offset.\n    offset = self.zone.offset(utc_to_ts_ms(dt))\n    return (dt + offset).replace(tzinfo=self.zone.get_tzinfo(offset))\n\n  def __repr__(self):\n    \"\"\"\n    Produces a friendly representation\n    >>> moment.tzinfo('America/New_York')\n    moment.tzinfo('America/New_York')\n    \"\"\"\n    return 'moment.tzinfo({!r})'.format(self.zone.name)\n\n\nclass Zone(object):\n  \"\"\"\n  Implements the zone object of moment-timezone.js, and contains the logic needed by TzInfo.\n  This is the class that interfaces directly with moment-timezone data.\n  \"\"\"\n  def __init__(self, zonelabel):\n    \"\"\"\n    Creates a Zone object for the given zonelabel, which must be a string key into the\n    moment-timezone json data.\n    \"\"\"\n    zone_data = get_tz_data()[zonelabel]\n    self.name = zonelabel\n    self.untils = zone_data.untils[:-1]   # In ms. We omit the trailing None value.\n    self.abbrs = zone_data.abbrs\n    self.offsets = zone_data.offsets      # Offsets in minutes.\n    self.standard_offset = timedelta(minutes=-self.offsets[0])\n    # \"Until\" times adjusted by the corresponding offsets. These are used in translating from\n    # datetime to absolute timestamp.\n    self.offset_untils = [until - offset * 60000 for (until, offset) in\n                          zip(self.untils, self.offsets)]\n    # Cache of TzInfo objects for this Zone, used by get_tzinfo(). There could be multiple TzInfo\n    # objects, one for each possible offset, but their behavior only differs for ambiguous time.\n    self._tzinfo = {}\n\n  def dt_offset(self, dt, favor_offset=None):\n    \"\"\"Returns the timedelta for timezone offset east of UTC at the given datetime.\"\"\"\n    i = self._index_dt(dt, favor_offset)\n    return timedelta(minutes = -self.offsets[i])\n\n  def dt_tzname(self, dt, favor_offset=None):\n    \"\"\"Returns the timezone abbreviation (e.g. EST or EDT) at the given datetime.\"\"\"\n    i = self._index_dt(dt, favor_offset)\n    return self.abbrs[i]\n\n  def offset(self, timestamp_ms):\n    \"\"\"Returns the timedelta for timezone offset east of UTC at the given ms timestamp.\"\"\"\n    i = self._index(timestamp_ms)\n    return timedelta(minutes = -self.offsets[i])\n\n  def abbr(self, timestamp_ms):\n    \"\"\"Returns the timezone abbreviation (e.g. EST or EDT) at the given ms timestamp.\"\"\"\n    i = self._index(timestamp_ms)\n    return self.abbrs[i]\n\n  def _index(self, timestamp):\n    \"\"\"Helper to return the index into the offsets data corresponding to the given timestamp.\"\"\"\n    return bisect.bisect_right(self.untils, timestamp)\n\n  def _index_dt(self, dt, favor_offset):\n    \"\"\"\n    Helper to return the index into the offsets data corresponding to the given datetime.\n    In case of ambiguous dates, will favor the given favor_offset. If it is None or doesn't match\n    the later of the two offsets, will use the offset that's was in effect earlier.\n    \"\"\"\n    timestamp = utc_to_ts_ms(dt)\n    i = bisect.bisect_right(self.offset_untils, timestamp)\n    if i < len(self.offset_untils) and timestamp >= self.untils[i] - self.offsets[i + 1] * 60000:\n      # We have an ambiguous time and can use self.offsets[i] or self.offsets[i + 1]. If\n      # favor_offset matches the later offset, use that. Otherwise, prefer the earlier one.\n      if timedelta(minutes=-self.offsets[i + 1]) == favor_offset:\n        return i + 1\n    return i\n\n  def get_tzinfo(self, favor_offset):\n    \"\"\"\n    Returns a TzInfo object for this Zone that favors the given offset in case of ambiguity.\n    If favor_offset is none, ambiguous times are resolved to the offset that comes into effect\n    earlier. This is used with a particular offset by TzInfo.fromutc() method, which is part of\n    implementation of TzInfo.astimezone(). We distinguish ambiguous times by using TzInfo variants\n    that favor one offset or another for different meanings of the ambiguous times.\n    \"\"\"\n    return (self._tzinfo.get(favor_offset) or\n            self._tzinfo.setdefault(favor_offset, TzInfo(self, favor_offset)))\n\n\n\n_zone_cache = {}\n\ndef get_zone(zonelabel):\n  \"\"\"Returns Zone(zonelabel), with caching.\"\"\"\n  return (_zone_cache.get(zonelabel) or\n          _zone_cache.setdefault(zonelabel, Zone(zonelabel)))\n\ndef tzinfo(zonelabel, favor_offset=None):\n  \"\"\"\n  Returns TzInfo instance for zonelabel, with the optional favor_offset (mainly for internal use\n  by astimezone via fromutc).\n  \"\"\"\n  return get_zone(zonelabel).get_tzinfo(favor_offset)\n\n\n# Some more globals that rely on the machinery above.\nTZ_UTC = tzinfo('UTC')\nEPOCH_UTC = EPOCH.replace(tzinfo=TZ_UTC)    # Same as EPOCH, but an \"aware\" instance.\n"
  },
  {
    "path": "sandbox/grist/objtypes.py",
    "content": "\"\"\"\nThis module implements handling of non-primitive objects as values in Grist data cells. It is\ncurrently only used to handle errors thrown from formulas.\n\nNon-primitive values are represented in actions as [type_name, args...].\n  objtypes.register_converter() - registers a new supported object type.\n  objtypes.encode_object(obj)   - returns a marshallable list representation.\n  objtypes.decode_object(val)   - returns an object represented by the [name, args...] argument.\n\nIf an object cannot be encoded or decoded, an \"UnmarshallableValue\" is returned instead\nof the form ['U', repr(obj)].\n\"\"\"\n# pylint: disable=too-many-return-statements\nimport traceback\nfrom datetime import date, datetime\nfrom math import isnan\n\nimport friendly_errors\nimport moment\nimport records\nimport depend\n\n\nclass UnmarshallableError(ValueError):\n  \"\"\"\n  Error raised when an object cannot be represented in an action by Grist. It happens if the\n  object is of a type for which there is no registered converter, or if encoding it involves\n  values that cannot be marshalled.\n  \"\"\"\n  pass\n\n\nclass ConversionError(ValueError):\n  \"\"\"\n  Indicates a failure to convert a value between Grist types. We don't usually expose it to the\n  user, since such a failure normally results in silent alttext.\n  \"\"\"\n  pass\n\n\nclass InvalidTypedValue(ValueError):\n  \"\"\"\n  Indicates that AltText was in place of a typed value and produced an error. The value of AltText\n  is included into the exception, both to be more informative, and to sort displayCols properly.\n  \"\"\"\n  def __init__(self, typename, value):\n    super(InvalidTypedValue, self).__init__(typename)\n    self.typename = typename\n    self.value = value\n\n  def __str__(self):\n    return \"Invalid %s: %s\" % (self.typename, self.value)\n\n\nclass AltText(object):\n  \"\"\"\n  Represents a text value in a non-text column. The separate class allows formulas to access\n  wrong-type values. We use a wrapper rather than expose text directly to formulas, because with\n  text there is a risk that e.g. a formula that's supposed to add numbers would add two strings\n  with unexpected result.\n  \"\"\"\n  def __init__(self, text, typename=None):\n    self._text = text\n    self._typename = typename\n\n  def __str__(self):\n    return self._text\n\n  def __int__(self):\n    # This ensures that AltText values that look like ints may be cast back to int.\n    # Convert to float first, since python does not allow casting strings with decimals to int.\n    return int(float(self._text))\n\n  def __float__(self):\n    # This ensures that AltText values that look like floats may be cast back to float.\n    return float(self._text)\n\n  def __repr__(self):\n    return '%s(%r)' % (self.__class__.__name__, self._text)\n\n  # Allow comparing to AltText(\"something\")\n  def __eq__(self, other):\n    return isinstance(other, self.__class__) and self._text == other._text\n\n  def __ne__(self, other):\n    return not self.__eq__(other)\n\n  def __hash__(self):\n    return hash((self.__class__, self._text))\n\n  def __getattr__(self, name):\n    # On attempt to do $foo.Bar on an AltText value such as \"hello\", raise an exception that will\n    # show up as e.g. \"Invalid Ref: hello\" or \"Invalid Date: hello\".\n    raise InvalidTypedValue(self._typename, self._text)\n\n\nclass UnmarshallableValue(object):\n  \"\"\"\n  Represents an UnmarshallableValue. There is nothing we can do with it except encode it back.\n  \"\"\"\n  def __init__(self, value_repr):\n    self.value_repr = value_repr\n\n\n# Unique sentinel value representing a pending value. It's encoded as ['P'], and shown to the user\n# as \"Loading...\" text. With the switch to stored formulas, it's currently only used when a\n# document was just migrated.\n_pending_sentinel = object()\n\n# A placeholder for a value hidden by access control rules.\n# Depending on the types of the columns involved, copying\n# a censored value and pasting elsewhere will either use\n# CensoredValue.__repr__ (python) or CensoredValue.toString (typescript)\n# so they should match\nclass CensoredValue(object):\n  def __repr__(self):\n    return 'CENSORED'\n\n_censored_sentinel = CensoredValue()\n\n\ndef is_int_short(value):\n  return -(1<<31) <= value < (1<<31)\n\ndef safe_shift(arg, default=None):\n  value = arg.pop(0) if arg else None\n  return default if value is None else value\n\ndef safe_repr(obj):\n  \"\"\"\n  Like repr(obj) but falls back to a simpler \"<type-name>\" string when repr() itself fails.\n  \"\"\"\n  try:\n    return repr(obj)\n  except Exception:\n    return '<' + type(obj).__name__ + '>'\n\ndef strict_equal(a, b):\n  \"\"\"Checks the equality of the types of the values as well as the values, and handle errors.\"\"\"\n  # pylint: disable=unidiomatic-typecheck\n  # Try/catch needed because some comparisons may fail (e.g. datetimes with different tzinfo)\n  try:\n    return type(a) == type(b) and a == b\n  except Exception:\n    return False\n\ndef equal_encoding(a, b):\n  # Compare NaNs as equal.\n  if isinstance(a, float) and isinstance(b, float):\n    return a == b or (isnan(a) and isnan(b))\n\n  # Compare bools as equal only to bools (these are distinguishable from numbers in JSON, and we\n  # take care to distinguish them in DB too).\n  if isinstance(a, bool) or isinstance(b, bool):\n    # pylint: disable=unidiomatic-typecheck\n    return type(a) == type(b) and a == b\n\n  # Note for simple types, encode_object is trivial, and will result in a non-type-specific\n  # comparison (e.g. 1 and 1.0 will compare equal, as would \"a\" and u\"a\"). This is to capture\n  # equivalence of values in their JSON representations.\n  return encode_object(a) == encode_object(b)\n\ndef encode_object(value):\n  \"\"\"\n  Produces a Grist-encoded version of the value, e.g. turning a Date into ['d', timestamp].\n  Returns ['U', repr(value)] if it fails to encode otherwise.\n  \"\"\"\n  # pylint: disable=unidiomatic-typecheck\n  try:\n    # A primitive type can be returned directly.\n    if type(value) in (str, float, bool) or value is None:\n      return value\n    # Other instances of these types must be derived; cast these to the primitive type to ensure\n    # they are marshallable.\n    elif isinstance(value, str):\n      return str(value)\n    elif isinstance(value, float):\n      return float(value)\n    elif isinstance(value, bool):\n      return bool(value)\n    elif isinstance(value, bytes):\n      return value.decode('utf8')\n    elif isinstance(value, int):\n      if not is_int_short(value):\n        return ['U', str(value)]\n      # Cast to a primitive type to ensure it's marshallable (e.g. enum.IntEnum would not be).\n      return int(value)\n    elif isinstance(value, AltText):\n      return str(value)\n    elif isinstance(value, records.Record):\n      return ['R', value._table.table_id, value._row_id]\n    elif isinstance(value, RecordStub):\n      return ['R', value.table_id, value.row_id]\n    elif isinstance(value, datetime):\n      return ['D', moment.dt_to_ts(value), value.tzinfo.zone.name if value.tzinfo else 'UTC']\n    elif isinstance(value, date):\n      return ['d', moment.date_to_ts(value)]\n    elif isinstance(value, RaisedException):\n      return ['E'] + value.encode_args()\n    elif isinstance(value, (list, tuple)):\n      return ['L'] + [encode_object(item) for item in value]\n    elif isinstance(value, records.RecordSet):\n      return ['r', value._table.table_id, value._get_encodable_row_ids()]\n    elif isinstance(value, RecordSetStub):\n      return ['r', value.table_id, value.row_ids]\n    elif isinstance(value, dict):\n      if not all(isinstance(key, str) for key in value):\n        raise UnmarshallableError(\"Dict with non-string keys\")\n      return ['O', {key: encode_object(val) for key, val in value.items()}]\n    elif value == _pending_sentinel:\n      return ['P']\n    elif value == _censored_sentinel:\n      return ['C']\n    elif isinstance(value, UnmarshallableValue):\n      return ['U', value.value_repr]\n  except Exception as e:\n    pass\n  # We either don't know how to convert the value, or failed during the conversion. Instead we\n  # return an \"UnmarshallableValue\" object, with repr() of the value to show to the user.\n  return ['U', safe_repr(value)]\n\ndef decode_object(value):\n  \"\"\"\n  Given a Grist-encoded value, returns an object represented by it.\n  If typename is unknown, or construction fails for any reason, returns (not raises!)\n  RaisedException with the original exception in its .error property.\n  \"\"\"\n  try:\n    if not isinstance(value, (list, tuple)):\n      return value\n    code = value[0]\n    args = value[1:]\n    if code == 'R':\n      return RecordStub(args[0], args[1])\n    elif code == 'r':\n      return RecordSetStub(args[0], args[1])\n    elif code == 'D':\n      return moment.ts_to_dt(args[0], moment.Zone(args[1]))\n    elif code == 'd':\n      return moment.ts_to_date(args[0])\n    elif code == 'E':\n      return RaisedException.decode_args(*args)\n    elif code == 'L':\n      return [decode_object(item) for item in args]\n    elif code == 'l':\n      return ReferenceLookup(*args)\n    elif code == 'O':\n      return {decode_object(key): decode_object(val) for key, val in args[0].items()}\n    elif code == 'P':\n      return _pending_sentinel\n    elif code == 'C':\n      return _censored_sentinel\n    elif code == 'U':\n      return UnmarshallableValue(args[0])\n    raise KeyError(\"Unknown object type code %r\" % code)\n  except Exception as e:\n    return RaisedException(e)\n\n#----------------------------------------------------------------------\n\nclass RaisedException(object):\n  \"\"\"\n  RaisedException is a special type of object which indicates that a value in a cell isn't a plain\n  value but an exception to be raised. All caught exceptions are wrapped in RaisedException. The\n  original exception is saved in the .error attribute. The traceback is saved in .details\n  attribute only when needed (flag include_details is set).\n\n  RaisedException is registered under a special short name (\"E\") to save bytes since it's such a\n  widely-used wrapper. To encode_args, it simply returns the entire encoded stored error, e.g.\n  RaisedException(ValueError(\"foo\")) is encoded as [\"E\", \"ValueError\", \"foo\"].\n\n  When user_input is passed, RaisedException(ValueError(\"foo\"), user_input=2) is encoded as:\n  [\"E\", \"ValueError\", \"foo\", {u: 2}].\n  \"\"\"\n\n  # Marker object that indicates that there was no user input.\n  NO_INPUT = object()\n\n  def __init__(self, error, include_details=False, user_input=NO_INPUT):\n    self.user_input = user_input\n    self.error = error\n    self.details = None\n    self._encoded_error = None\n    self._name = None\n    self._message = None\n    if error is not None:\n      self._fill_from_error(self.has_user_input(), include_details)\n      error.__traceback__ = None\n\n  def encode_args(self):\n    if self._encoded_error is not None:\n      return self._encoded_error\n    if self.has_user_input():\n      user_input = {\"u\": encode_object(self.user_input)}\n    else:\n      user_input = None\n    result = [self._name, self._message, self.details, user_input]\n    # Trim last values that are None\n    while len(result) > 1 and result[-1] is None:\n      result.pop()\n    self._encoded_error = result\n    return result\n\n  def _fill_from_error(self, include_message=False, include_details=False):\n    # TODO: We should probably return all args, to communicate the error details to the browser\n    # and to DB (for when we store formula results). There are two concerns: one is that it's\n    # potentially quite verbose; the other is that it's makes the tests more annoying (again b/c\n    # verbose).\n    error = self.error\n    location = \"\"\n    while isinstance(error, CellError):\n      if not location:\n        location = \"\\n(in referenced cell {error.location})\".format(error=error)\n      error = error.error\n    self._name = type(error).__name__\n    if include_details:\n      self.details = traceback.format_exc()\n      self._message = str(error) + location\n      if not (isinstance(error, (SyntaxError, depend.CircularRefError)) or error != self.error):\n        # For SyntaxError, the friendly message was already added earlier.\n        # CircularRefError and CellError are Grist-specific and have no friendly message.\n        self._message += friendly_errors.friendly_message(error)\n    elif isinstance(error, InvalidTypedValue):\n      self._message = error.typename\n      self.details = error.value\n    elif include_message:\n      self._message = str(error) + location\n\n  def has_user_input(self):\n    return self.user_input is not RaisedException.NO_INPUT\n\n  def no_traceback(self):\n    exc = RaisedException(None)\n    exc._name = self._name\n    exc.error = self.error\n    exc.user_input = self.user_input\n    exc.details = \"This error is left over from before, and \" + \\\n                  \"the formula hasn't been triggered since then.\"\n    exc._message = self._message\n    return exc\n\n  @classmethod\n  def decode_args(cls, *args):\n    exc = cls(None)\n    args = list(args)\n    assert args\n    exc._name = safe_shift(args)\n    exc._message = safe_shift(args)\n    exc.details = safe_shift(args)\n    exc.user_input = safe_shift(args, {})\n    exc.user_input = decode_object(exc.user_input.get(\"u\", RaisedException.NO_INPUT))\n    return exc\n\nclass CellError(Exception):\n  def __init__(self, table_id, col_id, row_id, error):\n    super(CellError, self).__init__(table_id, col_id, row_id, error)\n    self.table_id = table_id\n    self.col_id = col_id\n    self.row_id = row_id\n    self.error = error\n\n  def __str__(self):\n    return (\n      \"{self.error.__class__.__name__} in referenced cell {self.location}\"\n    ).format(self=self)\n\n  @property\n  def location(self):\n    return \"{self.table_id}[{self.row_id}].{self.col_id}\".format(self=self)\n\n\nclass RecordList(list):\n  \"\"\"\n  A static method to recreate a RecordList from the output of __repr__.\n  It only restores the row_ids. The group_by and sort_by attributes are not restored.\n  \"\"\"\n  @staticmethod\n  def from_repr(repr_str):\n    if not repr_str.startswith('RecordList(['):\n      raise ValueError(\"Invalid RecordList representation\")\n    # This is a string representation of a RecordList, which we can parse.\n    # > RecordList([1,2,3], group_by=%r, sort_by=%r)\n    # Match only rows, as group_by and sort_by are not used and can be stale.\n    numbers = repr_str.split('[')[1].split(']')[0].split(',')\n    return RecordList([int(v) for v in numbers])\n\n  \"\"\"\n  Just like list but allows setting custom attributes, which we use for remembering _group_by and\n  _sort_by attributes when storing RecordSet as usertypes.ReferenceList type.\n  \"\"\"\n  def __init__(self, row_ids, group_by=None, sort_by=None, sort_key=None):\n    list.__init__(self, row_ids)\n    self._group_by = group_by       # None or a tuple of col_ids\n    self._sort_by = sort_by         # None or a tuple of col_ids, optionally prefixed with \"-\"\n    self._sort_key = sort_key       # Comparator function (see sort_key.py)\n\n  def __repr__(self):\n    return \"RecordList(%s, group_by=%r, sort_by=%r)\" % (\n      list.__repr__(self), self._group_by, self._sort_by)\n\n\n# We don't currently have a good way to convert an incoming marshalled record to a proper Record\n# object for an appropriate table. We don't expect incoming marshalled records at all, but if such\n# a thing happens, we'll construct this RecordStub.\nclass RecordStub(object):\n  def __init__(self, table_id, row_id):\n    self.table_id = table_id\n    self.row_id = row_id\n\n\nclass RecordSetStub(object):\n  def __init__(self, table_id, row_ids):\n    self.table_id = table_id\n    self.row_ids = row_ids\n\n\nclass ReferenceLookup(object):\n  def __init__(self, value, options=None):\n    self.value = value\n    self.options = options or {}\n\n  @property\n  def alt_text(self):\n    result = self.options.get(\"raw\")\n    if result is None:\n      values = self.value\n      if not isinstance(values, list):\n        values = [values]\n      result = \", \".join(map(str, values))\n    return result\n"
  },
  {
    "path": "sandbox/grist/parse_data.py",
    "content": "\"\"\"\nThis module implements a way to detect and convert types that's better than messytables (at least\nin some relevant cases).\n\nIt has a simple interface: get_table_data(row_set) which returns a list of columns, each a\ndictionary with \"type\" and \"data\" fields, where \"type\" is a Grist type string, and data is a list\nof values. All \"data\" lists will have the same length.\n\"\"\"\n\nimport datetime\nimport logging\nimport re\nimport moment # TODO grist internal libraries might not be available to plugins in the future.\n\nlog = logging.getLogger(__name__)\nlog.setLevel(logging.WARNING)\n\n\n# Typecheck using type(value) instead of isinstance(value, some_type) makes parsing 25% faster\n# pylint:disable=unidiomatic-typecheck\n\n\n# Our approach to type detection is different from that of messytables.\n# We first go through each cell in a sample of rows, checking if it's one of the basic\n# types, and keep a count of successes for each. We use the counts to decide the basic types (e.g.\n# numeric vs text). Then we go through the full data set converting to the chosen basic type.\n# During this process, we keep counts of suitable Grist types to consider (e.g. Int vs Numeric).\n# We use those counts to produce the selected Grist type at the end.\n\n# Previously string values were used here for type guessing and were parsed to typed values.\n# That process now happens elsewhere, and this module only handles the case\n# where the imported data already contains actual numbers or dates.\n# This happens for Excel sheets but not CSV files.\n\n\nclass BaseConverter(object):\n  @classmethod\n  def test(cls, value):\n    try:\n      cls.convert(value)\n      return True\n    except Exception:\n      return False\n\n  @classmethod\n  def convert(cls, value):\n    \"\"\"Implement to convert imported value to a basic type.\"\"\"\n    raise NotImplementedError()\n\n  @classmethod\n  def get_grist_column(cls, values):\n    \"\"\"\n    Given an array of values returned successfully by convert(), return a tuple of\n    (grist_type_string, grist_values), where grist_values is an array of values suitable for the\n    returned grist type.\n    \"\"\"\n    raise NotImplementedError()\n\n\nnumeric_types = (int, float, complex, type(None))\n\nclass NumericConverter(BaseConverter):\n  \"\"\"Handles the Grist Numeric type\"\"\"\n\n  @classmethod\n  def convert(cls, value):\n    if type(value) is bool:\n      return int(value)\n    elif type(value) in numeric_types:\n      return value\n    raise ValueError()\n\n  @classmethod\n  def get_grist_column(cls, values):\n    return (\"Numeric\", values)\n\n\nclass BooleanConverter(BaseConverter):\n  \"\"\"Handles the Grist Bool type\"\"\"\n\n  @classmethod\n  def convert(cls, value):\n    if value is False or value is True:\n      return value\n    raise ValueError()\n\n  @classmethod\n  def get_grist_column(cls, values):\n    return (\"Bool\", values)\n\n\nclass SimpleDateTimeConverter(BaseConverter):\n  \"\"\"Handles Date and DateTime values which are already instances of datetime.datetime.\"\"\"\n\n  @classmethod\n  def convert(cls, value):\n    if type(value) is datetime.datetime:\n      return value\n    elif value is None:\n      return None\n    raise ValueError()\n\n  @classmethod\n  def _is_date(cls, value):\n    return value is None or value.time() == datetime.time()\n\n  @classmethod\n  def get_grist_column(cls, values):\n    grist_type = \"Date\" if all(cls._is_date(v) for v in values) else \"DateTime\"\n    grist_values = [(v if (v is None) else moment.dt_to_ts(v))\n                    for v in values]\n    return grist_type, grist_values\n\n\nclass AnyConverter(BaseConverter):\n  \"\"\"\n  Fallback converter that converts everything to strings.\n  Type guessing and parsing of the strings will happen elsewhere.\n  \"\"\"\n  @classmethod\n  def convert(cls, value):\n    if value is None:\n      return u''\n    return str(value)\n\n  @classmethod\n  def get_grist_column(cls, values):\n    return (\"Any\", values)\n\n\nclass ColumnDetector(object):\n  \"\"\"\n  ColumnDetector accepts calls to `add_value()`, and keeps track of successful conversions to\n  different basic types. At the end `get_converter()` method returns the class of the most\n  suitable converter.\n  \"\"\"\n  # Converters are listed in the order of preference, which is only used if two converters succeed\n  # on the same exact number of values. Text is always a fallback.\n  converters = [SimpleDateTimeConverter, BooleanConverter, NumericConverter]\n\n  # If this many non-junk values or more can't be converted, fall back to text.\n  _text_threshold = 0.10\n\n  # Junk values: these aren't counted when deciding whether to fall back to text.\n  _junk_re = re.compile(r'^\\s*(|-+|\\?+|n/?a)\\s*$', re.I)\n\n  def __init__(self):\n    self._counts = [0] * len(self.converters)\n    self._count_nonjunk = 0\n    self._count_total = 0\n    self._data = []\n\n  def add_value(self, value):\n    self._count_total += 1\n    if value is None or (type(value) in (str,) and self._junk_re.match(value)):\n      return\n\n    self._data.append(value)\n\n    self._count_nonjunk += 1\n    for i, conv in enumerate(self.converters):\n      if conv.test(value):\n        self._counts[i] += 1\n\n  def get_converter(self):\n    # We find the max by count, and secondarily by minimum index in the converters list.\n    count, neg_index = max((c, -i) for (i, c) in enumerate(self._counts))\n    if count > 0 and count >= self._count_nonjunk * (1 - self._text_threshold):\n      return self.converters[-neg_index]\n    return AnyConverter\n\n\ndef _guess_basic_types(rows, num_columns):\n  column_detectors = [ColumnDetector() for i in range(num_columns)]\n  for row in rows:\n    for cell, detector in zip(row, column_detectors):\n      detector.add_value(cell)\n\n  return [detector.get_converter() for detector in column_detectors]\n\n\nclass ColumnConverter(object):\n  \"\"\"\n  ColumnConverter converts and collects values using the passed-in converter object. At the end\n  `get_grist_column()` method returns a column of converted data.\n  \"\"\"\n  def __init__(self, converter):\n    self._converter = converter\n    self._all_col_values = []     # Initially this has None's for converted values\n    self._converted_values = []   # A list of all converted values\n    self._converted_indices = []  # Indices of the converted values into self._all_col_values\n\n  def convert_and_add(self, value):\n    # For some reason, we get 'str' type rather than 'unicode' for empty strings.\n    # Correct this, since all text should be unicode.\n    value = u\"\" if value == \"\" else value\n\n    # Integer values sometimes show up as ints (from Excel), sometimes as floats (from Google).\n    # Make them consistently ints; this avoid addition of \".0\" suffix when converting to text.\n    if type(value) is float and value.is_integer():\n      value = int(value)\n\n    try:\n      conv = self._converter.convert(value)\n      self._converted_values.append(conv)\n      self._converted_indices.append(len(self._all_col_values))\n      self._all_col_values.append(None)\n    except Exception:\n      self._all_col_values.append(str(value))\n\n  def get_grist_column(self):\n    \"\"\"\n    Returns a dictionary {\"type\": grist_type, \"data\": grist_value_array}.\n    \"\"\"\n    grist_type, grist_values = self._converter.get_grist_column(self._converted_values)\n    for i, v in zip(self._converted_indices, grist_values):\n      self._all_col_values[i] = v\n    return {\"type\": grist_type, \"data\": self._all_col_values}\n\n\ndef get_table_data(rows, num_columns, num_rows=0):\n  converters = _guess_basic_types(rows[:1000], num_columns)\n  col_converters = [ColumnConverter(c) for c in converters]\n  for num, row in enumerate(rows):\n    if num_rows and num == num_rows:\n      break\n\n    if num % 10000 == 0:\n      log.info(\"Processing row %d\", num)\n\n    # Make sure we have a value for every column.\n    missing_values = len(converters) - len(row)\n    if missing_values > 0:\n      row.extend([\"\"] * missing_values)\n\n    for cell, conv in zip(row, col_converters):\n      conv.convert_and_add(cell)\n\n  return [conv.get_grist_column() for conv in col_converters]\n"
  },
  {
    "path": "sandbox/grist/predicate_formula.py",
    "content": "import ast\nimport io\nimport json\nimport tokenize\nimport sys\nfrom collections import namedtuple\nimport asttokens\nimport textbuilder\nfrom codebuilder import get_dollar_replacer\n\n# Entities encountered in predicate formulas, which may get renamed.\n#   type : 'recCol'|'userAttr'|'userAttrCol',\n#   start_pos: number,        # start position of the token in the code.\n#   name: string,             # the name that may be updated by a rename.\n#   extra: string|None,       # name of userAttr in case of userAttrCol; otherwise None.\nNamedEntity = namedtuple('NamedEntity', ('type', 'start_pos', 'name', 'extra'))\n\ndef parse_predicate_formula(formula):\n  \"\"\"\n  Parse a predicate formula expression into a parse tree that we can interpret in JS, e.g.\n  \"rec.office == 'Seattle' and user.email in ['sally@', 'xie@']\".\n\n  The idea is to support enough to express ACL rules and dropdown conditions flexibly, but we\n  don't need to support too much, since expressions should be reasonably simple.\n\n  The returned tree has the form [NODE_TYPE, arguments...], with these NODE_TYPEs supported:\n    And|Or                  ...values\n    Add|Sub|Mult|Div|Mod    left, right\n    Not                     operand\n    Eq|NotEq|Lt|LtE|Gt|GtE  left, right\n    Is|IsNot|In|NotIn       left, right\n    List                    ...elements\n    Const                   value (number, string, bool)\n    Name                    name (string)\n    Attr                    node, attr_name\n    Comment                 node, comment\n  \"\"\"\n  if isinstance(formula, bytes):\n    formula = formula.decode('utf8')\n  try:\n    formula = get_dollar_replacer(formula).get_text()\n    tree = ast.parse(formula, mode='eval')\n    result = TreeConverter().visit(tree)\n    for part in tokenize.generate_tokens(io.StringIO(formula).readline):\n      if part[0] == tokenize.COMMENT and part[1].startswith('#'):\n        result = ['Comment', result, part[1][1:].strip()]\n        break\n    return result\n  except SyntaxError as e:\n    # In case of an error, include line and offset.\n    _, _, exc_traceback = sys.exc_info()\n    raise SyntaxError(\"%s on line %s col %s\" % (e.args[0], e.lineno, e.offset)).with_traceback(exc_traceback)\n\ndef parse_predicate_formula_json(formula):\n  \"\"\"\n  As parse_predicate_formula(), but stringifies the result, and converts falsy\n  values to empty string.\n  \"\"\"\n  return json.dumps(parse_predicate_formula(formula)) if formula else \"\"\n\nnamed_constants = {\n  'True': True,\n  'False': False,\n  'None': None,\n}\n\n\ndef process_renames(formula, collector, renamer):\n  \"\"\"\n  Given a predicate formula, a collector and a renamer, rename all references in the formula\n  that the renamer wants to rename. This is used to automatically update references in an ACL\n  or dropdown condition formula when a column it refers to has been renamed.\n\n  The collector should be a subclass of TreeConverter that collects related NamedEntity's and\n  stores them in the field \"entities\". See acl._ACLEntityCollector for an example.\n\n  The renamer should be a function taking a NamedEntity as its only argument. It should return\n  a new name for this NamedEntity when it wants to rename this entity, or None otherwise.\n  \"\"\"\n  patches = []\n  # \"$\" can be used to refer to \"rec.\" in Grist formulas, but it is not valid Python.\n  # We need to replace it with \"rec.\" before parsing the formula, and restore it back after\n  # the surgery.\n  # Keep the dollar replacer object, so that later we know how to restore properly.\n  dollar_replacer = get_dollar_replacer(formula)\n  formula_nodollar = dollar_replacer.get_text()\n  try:\n    atok = asttokens.ASTTokens(formula_nodollar, tree=ast.parse(formula_nodollar, mode='eval'))\n    collector.visit(atok.tree)\n  except SyntaxError:\n    # Don't do anything to a syntactically wrong formula.\n    return formula\n\n  for subject in collector.entities:\n    new_name = renamer(subject)\n    if new_name is not None:\n      _, _, patch = dollar_replacer.map_back_patch(\n        textbuilder.make_patch(dollar_replacer.get_text(), subject.start_pos,\n                               subject.start_pos + len(subject.name), new_name)\n      )\n      patches.append(patch)\n\n  return textbuilder.Replacer(textbuilder.Text(formula), patches).get_text()\n\n\nclass TreeConverter(ast.NodeVisitor):\n  # AST nodes are documented here: https://docs.python.org/2/library/ast.html#abstract-grammar\n  # pylint:disable=no-self-use\n\n  def visit_Expression(self, node):\n    return self.visit(node.body)\n\n  def visit_BoolOp(self, node):\n    return [node.op.__class__.__name__] + [self.visit(v) for v in node.values]\n\n  def visit_BinOp(self, node):\n    if not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod)):\n      return self.generic_visit(node)\n    return [node.op.__class__.__name__, self.visit(node.left), self.visit(node.right)]\n\n  def visit_UnaryOp(self, node):\n    if not isinstance(node.op, (ast.Not)):\n      return self.generic_visit(node)\n    return [node.op.__class__.__name__, self.visit(node.operand)]\n\n  def visit_Compare(self, node):\n    # We don't try to support chained comparisons like \"1 < 2 < 3\" (though it wouldn't be hard).\n    if len(node.ops) != 1 or len(node.comparators) != 1:\n      raise SyntaxError(\"Can't use chained comparisons\")\n    return [node.ops[0].__class__.__name__, self.visit(node.left), self.visit(node.comparators[0])]\n\n  def visit_Name(self, node):\n    if node.id in named_constants:\n      return [\"Const\", named_constants[node.id]]\n    return [\"Name\", node.id]\n\n  def visit_Constant(self, node):\n    return [\"Const\", node.value]\n\n  visit_NameConstant = visit_Constant\n\n  def visit_Attribute(self, node):\n    return [\"Attr\", self.visit(node.value), node.attr]\n\n  def visit_Num(self, node):\n    return [\"Const\", node.n]\n\n  def visit_Str(self, node):\n    return [\"Const\", node.s]\n\n  def visit_List(self, node):\n    return [\"List\"] + [self.visit(e) for e in node.elts]\n\n  def visit_Tuple(self, node):\n    return self.visit_List(node)    # We don't distinguish tuples and lists\n\n  def visit_Call(self, node):\n    args = [self.visit(v) for v in node.args]\n    if node.keywords:\n      # E.g. foo(a, b=2, c=3) becomes [Call, foo, a, [keywords, [b, 2], [c, 3]]]\n      args.append(['keywords'] + [[v.arg, self.visit(v.value)] for v in node.keywords])\n    return [\"Call\", self.visit(node.func)] + args\n\n  def generic_visit(self, node):\n    raise SyntaxError(\"Unsupported syntax at %s:%s\" % (node.lineno, node.col_offset + 1))\n"
  },
  {
    "path": "sandbox/grist/records.py",
    "content": "\"\"\"\nImplements the base classes for Record and RecordSet objects used to represent records in Grist\ntables. Individual tables use derived versions of these, which add per-column properties.\n\"\"\"\n\nfrom bisect import bisect_left, bisect_right\nimport functools\nimport sys\n\n@functools.total_ordering\nclass Record(object):\n  \"\"\"\n  Name: Record, rec\n\n  A Record represents a record of data. It is the primary means of accessing values in formulas. A\n  Record for a particular table has a property for each data and formula column in the table.\n\n  In a formula, `$field` is translated to `rec.field`, where `rec` is the Record for which the\n  formula is being evaluated.\n\n  For example:\n  ```\n  def Full_Name(rec, table):\n    return rec.First_Name + ' ' + rec.LastName\n\n  def Name_Length(rec, table):\n    return len(rec.Full_Name)\n  ```\n  \"\"\"\n\n  # Some documentation for method-like parts of Record, which aren't actually methods.\n  _DOC_EXTRA = (\n    \"\"\"\n    Name: $Field, rec.Field\n    Usage: __$__*Field* or __rec__*.Field*\n\n    Access the field named \"Field\" of the current record. E.g. `$First_Name` or `rec.First_Name`.\n    \"\"\",\n    \"\"\"\n    Name: $group, rec.group\n    Usage: __$group__\n\n    In a [summary table](summary-tables.md), `$group` is a special field\n    containing the list of Records that are summarized by the current summary line.  E.g. the\n    formula `len($group)` counts the number of those records being summarized in each row.\n\n    See [RecordSet](#recordset) for useful properties offered by the returned object.\n\n    Examples:\n    ```\n    sum($group.Amount)                        # Sum of the Amount field in the matching records\n    sum(r.Amount for r in $group)             # Same as sum($group.Amount)\n    sum(r.Amount for r in $group if r > 0)    # Sum of only the positive amounts\n    sum(r.Shares * r.Price for r in $group)   # Sum of shares * price products\n    ```\n    \"\"\"\n  )\n\n  # Slots are an optimization to avoid the need for a per-object __dict__.\n  __slots__ = ('_row_id', '_source_relation')\n\n  # Per-table derived classes override this and set it to the appropriate Table object.\n  _table = None\n\n  # Record is always a thin class, containing essentially a reference to a row in the table. The\n  # properties to access individual fields of a row are provided in per-table derived classes.\n  def __init__(self, row_id, relation=None):\n    \"\"\"\n    Creates a Record object.\n      table - Table object, in which this record lives.\n      row_id - The ID of the record within table.\n      relation - Relation object for how this record was obtained; used in dependency tracking.\n\n    In general you shouldn't call this constructor directly, but rather:\n\n        table.Record(row_id, relation)\n\n    which provides the table argument automatically.\n    \"\"\"\n    self._row_id = row_id\n    self._source_relation = relation or self._table._identity_relation\n\n  # Existing fields are added as @property methods in table.py. When no field is found, raise a\n  # more informative AttributeError.\n  def __getattr__(self, name):\n    return self._table._attribute_error(name, self._source_relation)\n\n  def __hash__(self):\n    return hash((self._table, self._row_id))\n\n  def __eq__(self, other):\n    return (isinstance(other, Record) and\n            (self._table, self._row_id) == (other._table, other._row_id))\n\n  def __ne__(self, other):\n    return not self.__eq__(other)\n\n  def __lt__(self, other):\n    return (self._table.table_id, self._row_id) < (other._table.table_id, other._row_id)\n\n  def __int__(self):\n    return self._row_id\n\n  def __nonzero__(self):\n    return bool(self._row_id)\n\n  __bool__ = __nonzero__\n\n  def __repr__(self):\n    return \"%s[%s]\" % (self._table.table_id, self._row_id)\n\n  def _exists(self):\n    # Whether the record exists: helpful for the rare cases when examining a record with a\n    # non-zero rowId which has just been deleted.\n    return self._row_id in self._table.row_ids\n\n  def _clone_with_relation(self, src_relation):\n    return self._table.Record(self._row_id,\n                              relation=src_relation.compose(self._source_relation))\n\n\nclass RecordSet(object):\n  \"\"\"\n  A RecordSet represents a collection of records, as returned by `Table.lookupRecords()` or\n  `$group` property in summary views.\n\n  A RecordSet allows iterating through the records:\n  ```\n  sum(r.Amount for r in Students.lookupRecords(First_Name=\"John\", Last_Name=\"Doe\"))\n  min(r.DueDate for r in Tasks.lookupRecords(Owner=\"Bob\"))\n  ```\n\n  RecordSets also provide a convenient way to access the list of values for a particular field for\n  all the records, as `record_set.Field`. For example, the examples above are equivalent to:\n  ```\n  sum(Students.lookupRecords(First_Name=\"John\", Last_Name=\"Doe\").Amount)\n  min(Tasks.lookupRecords(Owner=\"Bob\").DueDate)\n  ```\n\n  You can get the number of records in a RecordSet using `len`, e.g. `len($group)`.\n  \"\"\"\n\n  # Slots are an optimization to avoid the need for a per-object __dict__.\n  __slots__ = ('_row_ids', '_source_relation', '_group_by', '_sort_by', '_sort_key')\n\n  # Per-table derived classes override this and set it to the appropriate Table object.\n  _table = None\n\n  # Methods should be named with a leading underscore to avoid interfering with access to\n  # user-defined fields.\n  def __init__(self, row_ids, relation=None, group_by=None, sort_by=None, sort_key=None):\n    \"\"\"\n    group_by may be a dictionary mapping column names to values that are all the same for the given\n    RecordSet. sort_by may be the column name used for sorting this record set. Both are set by\n    lookupRecords, and used when using RecordSet to insert new records.\n    \"\"\"\n    self._row_ids = row_ids\n    self._source_relation = relation or self._table._identity_relation\n    # If row_ids is itself a RecordList, default to its _group_by, _sort_by, _sort_key properties.\n    self._group_by = group_by or getattr(row_ids, '_group_by', None)\n    self._sort_by = sort_by or getattr(row_ids, '_sort_by', None)\n    self._sort_key = sort_key or getattr(row_ids, '_sort_key', None)\n\n  def __len__(self):\n    return len(self._row_ids)\n\n  def __nonzero__(self):\n    return bool(self._row_ids)\n\n  __bool__ = __nonzero__\n\n  def __eq__(self, other):\n    return (isinstance(other, RecordSet) and\n        (self._table, self._row_ids) == (other._table, other._row_ids))\n\n  def __ne__(self, other):\n    return not self.__eq__(other)\n\n  def __iter__(self):\n    for row_id in self._row_ids:\n      yield self._table.Record(row_id, self._source_relation)\n\n  def __contains__(self, item):\n    \"\"\"item may be a Record or its row_id.\"\"\"\n    if isinstance(item, int):\n      return item in self._row_ids\n    if isinstance(item, Record) and item._table == self._table:\n      return int(item) in self._row_ids\n    return False\n\n  def get_one(self):\n    # Pick the first record in the sorted order, or empty/sample record for empty RecordSet\n    row_id = self._row_ids[0] if self._row_ids else 0\n    return self._table.Record(row_id, self._source_relation)\n\n  def __getitem__(self, index):\n    # Allows subscripting a RecordSet as r[0] or r[-1].\n    row_id = self._row_ids[index]\n    return self._table.Record(row_id, self._source_relation)\n\n  def __getattr__(self, name):\n    return self._table._attribute_error(name, self._source_relation)\n\n  def __repr__(self):\n    return \"%s[%s]\" % (self._table.table_id, self._row_ids)\n\n  def _at(self, index):\n    \"\"\"\n    Returns element of RecordSet at the given index when the index is valid and non-negative.\n    Otherwise returns the empty/sample record.\n    \"\"\"\n    row_id = self._row_ids[index] if (0 <= index < len(self._row_ids)) else 0\n    return self._table.Record(row_id, self._source_relation)\n\n  def _clone_with_relation(self, src_relation):\n    return self._table.RecordSet(self._row_ids,\n                                 relation=src_relation.compose(self._source_relation),\n                                 group_by=self._group_by,\n                                 sort_by=self._sort_by,\n                                 sort_key=self._sort_key)\n\n  def _get_encodable_row_ids(self):\n    \"\"\"\n    Returns stored rowIds as a simple list or tuple type, even if actually stored as RecordList.\n    \"\"\"\n    # pylint: disable=unidiomatic-typecheck\n    if type(self._row_ids) in (list, tuple):\n      return self._row_ids\n    else:\n      return list(self._row_ids)\n\n  def _get_sort_key(self):\n    if not self._sort_key:\n      if self._sort_by:\n        raise ValueError(\"Sorted by %s but no sort_key\" % (self._sort_by,))\n      raise ValueError(\"Can only use 'find' methods in a sorted reference list\")\n    return self._sort_key\n\n  def _to_local_row_id(self, item):\n    if isinstance(item, int):\n      return item\n    if isinstance(item, Record) and item._table == self._table:\n      return int(item)\n    raise ValueError(\"unexpected search item\")    # Need better error\n\n  @property\n  def find(self):\n    # pylint: disable=line-too-long\n    \"\"\"\n    Name: find.*\n    Usage: RecordSet.**find.\\\\***(value)\n\n    A set of methods for finding values in sorted sets of records, as returned by\n    [`lookupRecords`](#lookuprecords). For example:\n    ```\n    Transactions.lookupRecords(..., order_by=\"Date\").find.lt($Date)\n    Table.lookupRecords(..., order_by=(\"Foo\", \"Bar\")).find.le(foo, bar)\n    ```\n\n    If the `find` attribute is shadowed by a same-named user column, you may use `_find` instead.\n\n    In the following methods, \"less\" is best understood as \"before\"\n    and \"greater\" is best understood as \"after\".\n    For example, if you use a negative `order_by` on a simple integer column,\n    then the meaning of \"less than\" and \"greater than\" will be flipped.\n\n    The methods available are:\n\n    - __`lt`__: (\"less than\") Finds the nearest (last) record where the sort values are **before** the\n      given values.\n    - __`le`__: (\"less than or equal to\") Finds the last record where the sort values are **equal to\n      or before** the given values.\n    - __`gt`__: (\"greater than\") Finds the nearest (first) record where the sort values are **after**\n      the given values.\n    - __`ge`__: (\"greater than or equal to\") Finds the first record where the sort values are **equal\n      to or after** the given values.\n    - __`eq`__: (\"equal to\") Finds the first record where the sort values are **equal to** the given\n      values.\n\n\n    Example from [our Payroll template](https://templates.getgrist.com/5pHLanQNThxk/Payroll).\n    Each person has a history of pay rates, in the Rates table. To find a rate applicable on a\n    certain date, here is how you can do it old-style:\n    ```python\n    # Get all the rates for the Person and Role in this row.\n    rates = Rates.lookupRecords(Person=$Person, Role=$Role)\n\n    # Pick out only those rates whose Rate_Start is on or before this row's Date.\n    past_rates = [r for r in rates if r.Rate_Start <= $Date]\n\n    # Select the latest of past_rates, i.e. maximum by Rate_Start.\n    rate = max(past_rates, key=lambda r: r.Rate_Start)\n\n    # Return the Hourly_Rate from the relevant Rates record.\n    return rate.Hourly_Rate\n    ```\n\n    With the new methods, it is much simpler:\n    ```python\n    rates = Rates.lookupRecords(Person=$Person, Role=$Role, order_by=\"Rate_Start\")\n    rate = rates.find.le($Date)\n    return rate.Hourly_Rate\n    ```\n\n    Note that this is also much faster when there are many rates for the same Person and Role.\n    \"\"\"\n    return FindOps(self)\n\n  @property\n  def _find(self):\n    return FindOps(self)\n\n  def _find_eq(self, *values):\n    found = self._bisect_find(bisect_left, 0, _min_row_id, values)\n    if found:\n      # 'found' means that we found a row that's greater-than-or-equal-to the values we are\n      # looking for. To check if the row is actually \"equal\", it remains to check if it is stictly\n      # greater than the passed-in values.\n      key = self._get_sort_key()\n      if key(found._row_id, values) < key(found._row_id):\n        return self._table.Record(0, self._source_relation)\n    return found\n\n  def _bisect_index(self, bisect_func, search_row_id, search_values=None):\n    key = self._get_sort_key()\n    # Note that 'key' argument is only available from Python 3.10.\n    return bisect_func(self._row_ids, key(search_row_id, search_values), key=key)\n\n  def _bisect_find(self, bisect_func, shift, search_row_id, search_values=None):\n    i = self._bisect_index(bisect_func, search_row_id, search_values=search_values)\n    return self._at(i + shift)\n\n_min_row_id = -sys.float_info.max\n_max_row_id = sys.float_info.max\n\nclass FindOps(object):\n  def __init__(self, record_set):\n    self._rset = record_set\n\n  def previous(self, row):\n    row_id = self._rset._to_local_row_id(row)\n    return self._rset._bisect_find(bisect_left, -1, row_id)\n\n  def next(self, row):\n    row_id = self._rset._to_local_row_id(row)\n    return self._rset._bisect_find(bisect_right, 0, row_id)\n\n  def rank(self, row, order=\"asc\"):\n    row_id = self._rset._to_local_row_id(row)\n    index = self._rset._bisect_index(bisect_left, row_id)\n    if order == \"asc\":\n      return index + 1\n    elif order == \"desc\":\n      return len(self._rset) - index\n    else:\n      raise ValueError(\"The 'order' parameter must be \\\"asc\\\" (default) or \\\"desc\\\"\")\n\n  def lt(self, *values):\n    return self._rset._bisect_find(bisect_left, -1, _min_row_id, values)\n\n  def le(self, *values):\n    return self._rset._bisect_find(bisect_right, -1, _max_row_id, values)\n\n  def gt(self, *values):\n    return self._rset._bisect_find(bisect_right, 0, _max_row_id, values)\n\n  def ge(self, *values):\n    return self._rset._bisect_find(bisect_left, 0, _min_row_id, values)\n\n  def eq(self, *values):\n    return self._rset._find_eq(*values)\n\n\ndef adjust_record(relation, value):\n  \"\"\"\n  Helper to adjust a Record's source relation to be the composition with the given relation. This\n  is used to wrap values like `foo.bar`: if `bar` is a Record, then its source relation should be\n  the composition of the source relation of `foo` and the relation associated with `bar`.\n  \"\"\"\n  if isinstance(value, (Record, RecordSet)):\n    return value._clone_with_relation(relation)\n  return value\n"
  },
  {
    "path": "sandbox/grist/relabeling.py",
    "content": "\"\"\"\nThis module is used in the implementation of ordering of records in Grist. Order is maintained\nusing floating-point \"positions\". E.g. inserting a record will normally add a record with position\nbeing the average of its neighbor's positions.\n\nThe difficulty is that it's possible (and sometimes easy) to get floats closer and closer\ntogether, until they are too close (and average of neighbors is equal to one of them). This\nrequires adjusting existing positions.\n\nThis problem is known in computer science as the List-Labeling Problem. There are known algorithms\nwhich maintain ordered labels using fixed number of bits. We use an approach that requires\namortized log(N) relabelings per insert.\n\nFor references:\n  [Wikipedia] https://en.wikipedia.org/wiki/Order-maintenance_problem\n    The Wikipedia article describes in particular an approach using Scapegoat Trees.\n  [Bender] http://erikdemaine.org/papers/DietzSleator_ESA2002/paper.pdf\n    This paper by Bender et al is the best I found that describes the theory and a reasonably\n    simple solution that doesn't require explicit trees. This is what we rely on here.\n\nWhat complicates our approach is that inserts never modify positions directly; instead, when we\nhave items to insert, we need to prepare adjustments (both to new and existing positions), which\nare then turned into DocActions to be communicated and applied (both in memory and in storage).\n\nThe interface offered by this class is a single `prepare_inserts()` function, which takes a sorted\nlist and a list of keys, and returns the adjustments to existing records and to the new keys.\n\nNote that we rely heavily here on availability of a sorted container, for which we use the\nsortedcontainers module from here:\n  http://www.grantjenks.com/docs/sortedcontainers/sortedlist.html\n  https://github.com/grantjenks/sorted_containers\n\nNote also that unlike the original paper we deal with floats rather than integers. This is to\nmaximize the number of usable bits, since other parts of the system (namely Javascript) don't\nsupport 64-bits integers. We also avoid renumbering everything when we double the number of\nelements. The changes aren't vetted theoretically, and may break some conclusions from the paper.\n\nThroughout this file, \"key\" refers to the floating point value that's called a \"label\" in\nlist-labeling papers, \"position\" elsewhere in Grist code, and \"key\" in sortedcontainers docs.\n\"\"\"\n\nimport bisect\nimport itertools\nimport math\nimport struct\n\nfrom sortedcontainers import SortedList, SortedListWithKey\n\n\ndef prepare_inserts_dumb(sortedlist, keys):\n  \"\"\"\n  This is the dumb implementation of repositioning: whenever we don't have enough space to insert\n  keys, just renumber everything 1 through N.\n  \"\"\"\n  # It's still a bit tricky to do this because we need to return adjustments to existing and new\n  # keys, without actually inserting and renumbering.\n  ins_groups, ungroup_func = _group_insertions(sortedlist, keys)\n  insertions = []\n  adjustments = []\n\n  def get_endpoints(index, count):\n    before = sortedlist._key(sortedlist[index - 1]) if index > 0 else 0.0\n    after = (sortedlist._key(sortedlist[index])\n             if index < len(sortedlist) else before + count + 1)\n    return (before, after)\n\n  def is_valid_insert(index, count):\n    before, after = get_endpoints(index, count)\n    return is_valid_range(before, get_range(before, after, count), after)\n\n  if all(is_valid_insert(index, ins_count) for index, ins_count in ins_groups):\n    for index, ins_count in ins_groups:\n      before, after = get_endpoints(index, ins_count)\n      insertions.extend(get_range(before, after, ins_count))\n  else:\n    next_key = 1.0\n    prev_index = 0\n    # Complete the renumbering by forcing an extra empty group at the end.\n    ins_groups.append((len(sortedlist), 0))\n    for index, ins_count in ins_groups:\n      adj_count = index - prev_index\n      adjustments.extend(zip(range(prev_index, index),\n                                        frange_from(next_key, adj_count)))\n      next_key += adj_count\n      insertions.extend(frange_from(next_key, ins_count))\n      next_key += ins_count\n      prev_index = index\n\n  return adjustments, ungroup_func(insertions)\n\n\ndef prepare_inserts(sortedlist, keys):\n  \"\"\"\n  Takes a SortedListWithKey and a list of keys to insert. The keys should be floats.\n  Returns two lists: [(index, new_key), ...], [new_keys...]\n\n  The first list contains pairs for existing items in sortedlist that need to be adjusted to have\n  new keys (these will not change the ordering). The second is a list of new keys to use in place\n  of keys. To avoid reorderings, adjustments should be applied before insertions.\n  \"\"\"\n  worklist = ListWithAdjustments(sortedlist)\n  ins_groups, ungroup_func = _group_insertions(sortedlist, keys)\n  for index, ins_count in ins_groups:\n    worklist.prep_inserts_at_index(index, ins_count)\n  return worklist.get_adjustments(), ungroup_func(worklist.get_insertions())\n\n\ndef _group_insertions(sortedlist, keys):\n  \"\"\"\n  Given a list of keys to insert into sortedlist, returns the pair:\n    [(index, count), ...] pairs for how many items to insert immediately before each index.\n    ungroup(new_keys): a function that rearranges new keys to match the original keys.\n  \"\"\"\n  # We'll go through keys to insert in increasing order, to process consecutive keys together.\n  ins_keys = sorted((key, i) for i, key in enumerate(keys))\n  # We group by the index at which a new key is to be inserted.\n  ins_groups = [(index, len(list(ins_iter))) for index, ins_iter in\n            itertools.groupby(ins_keys, key=lambda pair: sortedlist.bisect_key_left(pair[0]))]\n  indices = [i for key, i in ins_keys]\n  def ungroup(new_keys):\n    return [key for _, key in sorted(zip(indices, new_keys))]\n\n  return ins_groups, ungroup\n\n\ndef frange_from(start, count):\n  return [start + i for i in range(count)]\n\n\ndef nextfloat(x):\n  \"\"\"\n  Returns the next representable float after the float x. This is useful to indicate insertions\n  AFTER ane existing element.\n  (See http://stackoverflow.com/a/10426033/328565 for implementation info).\n  \"\"\"\n  n = struct.unpack('<q', struct.pack('<d', x or 0.0))[0]\n  n += (1 if n >= 0 else -1)\n  return struct.unpack('<d', struct.pack('<q', n))[0]\n\ndef prevfloat(x):\n  n = struct.unpack('<q', struct.pack('<d', x or 0.0))[0]\n  n -= (1 if n >= 0 else -1)\n  return struct.unpack('<d', struct.pack('<q', n))[0]\n\nclass ListWithAdjustments(object):\n  \"\"\"\n  To prepare inserts, we adjust elements to be inserted and elements in the underlying list. We\n  don't want to actually touch the underlying list, but we need to remember the adjustments,\n  because later adjustments may depend on and readjust earlier ones.\n  \"\"\"\n  def __init__(self, orig_list):\n    \"\"\"\n    Orig_list must be a a SortedListWithKey.\n    \"\"\"\n    self._orig_list = orig_list\n    self._key = orig_list._key\n\n    # Stores pairs (i, new_key) where i is an index into orig_list.\n    #   Note that adjustments don't affect the order in the original list, so the list is sorted\n    #   both on keys an on indices; and a missing index i means that (i, orig_key) fits into the\n    #   adjustments list both by key and by index.\n    self._adjustments = SortedListWithKey(key=lambda pair: pair[1])\n\n    # Stores keys for new insertions.\n    self._insertions = SortedList()\n\n  def get_insertions(self):\n    return self._insertions\n\n  def get_adjustments(self):\n    return self._adjustments\n\n  def _adj_bisect_key_left(self, key):\n    \"\"\"\n    Works as bisect_key_left(key) on the orig_list as if all adjustments have been applied.\n    \"\"\"\n    adj_index = self._adjustments.bisect_key_left(key)\n    adj_next = (self._adjustments[adj_index][0] if adj_index < len(self._adjustments)\n                else len(self._orig_list))\n    adj_prev = self._adjustments[adj_index - 1][0] if adj_index > 0 else -1\n    orig_index = self._orig_list.bisect_key_left(key)\n    if adj_prev < orig_index and orig_index < adj_next:\n      return orig_index\n    return adj_next\n\n  def _adj_get_key(self, index):\n    \"\"\"\n    Returns the key corresponding to the given index into orig_list as if all adjustments have\n    been applied.\n    \"\"\"\n    i = bisect.bisect_left(self._adjustments, (index, float('-inf')))\n    if i < len(self._adjustments) and self._adjustments[i][0] == index:\n      return self._adjustments[i][1]\n    return self._key(self._orig_list[index])\n\n  def count_range(self, begin, end):\n    \"\"\"\n    Returns the number of elements with keys in the half-open interval [begin, end).\n    \"\"\"\n    adj_begin = self._adj_bisect_key_left(begin)\n    adj_end = self._adj_bisect_key_left(end)\n    ins_begin = self._insertions.bisect_left(begin)\n    ins_end = self._insertions.bisect_left(end)\n    return (adj_end - adj_begin) + (ins_end - ins_begin)\n\n  def _adjust_range(self, begin, end):\n    \"\"\"\n    Make changes to stored adjustments and insertions to distribute them equally in the half-open\n    interval of keys [begin, end).\n    \"\"\"\n    adj_begin = self._adj_bisect_key_left(begin)\n    adj_end = self._adj_bisect_key_left(end)\n    ins_begin = self._insertions.bisect_left(begin)\n    ins_end = self._insertions.bisect_left(end)\n    self._do_adjust_range(adj_begin, adj_end, ins_begin, ins_end, begin, end)\n\n  def _adjust_all(self):\n    \"\"\"\n    Renumber everything to be equally distributed in the open interval (new_begin, new_end).\n    \"\"\"\n    orig_len = len(self._orig_list)\n    ins_len = len(self._insertions)\n    self._do_adjust_range(0, orig_len, 0, ins_len, 0.0, orig_len + ins_len + 1.0)\n\n  def _do_adjust_range(self, adj_begin, adj_end, ins_begin, ins_end, new_begin_key, new_end_key):\n    \"\"\"\n    Implements renumbering as used by _adjust_range() and _adjust_all().\n    \"\"\"\n    count = (adj_end - adj_begin) + (ins_end - ins_begin)\n\n    prev_keys = ([(self._adj_get_key(i), False, i) for i in range(adj_begin, adj_end)] +\n                 [(self._insertions[i], True, i) for i in range(ins_begin, ins_end)])\n    prev_keys.sort()\n    new_keys = get_range(new_begin_key, new_end_key, count)\n\n    for (old_key, is_insert, i), new_key in zip(prev_keys, new_keys):\n      if is_insert:\n        self._insertions.remove(old_key)\n        self._insertions.add(new_key)\n      else:\n        # (i, old_key) pair may not be among _adjustments, so we discard() rather than remove().\n        self._adjustments.discard((i, old_key))\n        self._adjustments.add((i, new_key))\n\n  def prep_inserts_at_index(self, index, count):\n    # This is the crux of the algorithm, inspired by the [Bender] paper (cited above).\n    # Here's a brief summary of the algorithm, and of our departures from it.\n    # - The algorithm inserts keys while it is able. When there isn't enough space, it walks\n    #   enclosing intervals around the key it wants to insert, doubling the interval each time,\n    #   until it finds an interval that doesn't overflow. The overflow threshold is calculated in\n    #   such a way that the bigger the interval, the smaller the density it seeks.\n    # - The algorithm uses integers, picking the number of bits to work for list length between\n    #   n/2 and 2n, and rebuilding from scratch any time length moves out of this range. We don't\n    #   rebuild anything, don't change number of bits, and use floats. This breaks some of the\n    #   theoretical results, and thinking about floats is much harder than about integers. So we\n    #   are not on particularly solid ground with these changes (but it seems to work).\n    # - We try different thresholds, which seems to perform better. This is mentioned in \"Variable\n    #   T\" section of [Bender] paper, but our approach isn't quite the same. So it's also on shaky\n    #   theoretical ground.\n    assert count > 0\n    begin = self._adj_get_key(index - 1) if index > 0 else 0.0\n    end = self._adj_get_key(index) if index < len(self._orig_list) else begin + count + 1\n    if begin < 0 or end <= 0 or math.isinf(max(begin, end)):\n      # This should only happen if we have some invalid positions (e.g. from before we started\n      # using this logic). In this case, just renumber everything 1 through n (leaving space so\n      # that the count insertions take the first count integers).\n      self._insertions.update([begin if index > 0 else float('-inf')] * count)\n      self._adjust_all()\n      return\n\n    self._insertions.update(get_range(begin, end, count))\n    if not is_valid_range(begin, self._insertions.irange(begin, end), end):\n      assert self.count_range(begin, end) > 0\n      min_key, max_key = self._find_sparse_enough_range(begin, end)\n      self._adjust_range(min_key, max_key)\n      assert is_valid_range(begin, self._insertions.irange(begin, end), end)\n\n  def _find_sparse_enough_range(self, begin, end):\n    # frac is a parameter used for relabeling, corresponding to 2/T in [Bender]. Its\n    # interpretation is that frac^i is the overflow limit for intervals of size 2^i.\n    for frac in (1.14, 1.3):\n      thresh = 1\n      for i in range(64):\n        rbegin, rend = range_around_float(begin, i)\n        assert self.count_range(rbegin, rend) > 0\n        if end <= rend and self.count_range(rbegin, rend) < thresh:\n          return (rbegin, rend)\n        thresh *= frac\n    raise ValueError(\"This isn't expected\")\n\n\ndef is_valid_range(begin, iterable, end):\n  \"\"\"\n  Return true if all inserted keys in the range [begin, end] are distinct, and different from\n  the endpoints.\n  \"\"\"\n  return all_distinct(itertools.chain((begin,), iterable, (end,)))\n\n\ndef all_distinct(iterable):\n  \"\"\"\n  Returns true if none of the consecutive items in the iterable are the same.\n  \"\"\"\n  a, b = itertools.tee(iterable)\n  next(b, None)\n  return all(x != y for x, y in zip(a, b))\n\n\ndef range_around_float(x, i):\n  \"\"\"\n  Returns a pair (min, max) of floats such that the half-open interval [min,max) contains 2^i\n  representable floats, with x among them.\n  \"\"\"\n  # This is hard to explain (so easy for this to be wrong). m is in [0.5, 1), with 52 bits of\n  # precision (for 64-bit double-precision floats, as Python uses). We are trying to zero-out the\n  # last i bits of the precision. So we shift the mantissa left by (52-i) bits, round down\n  # (zeroing out remaining i bits), then shift back.\n  m, e = math.frexp(x)\n  mf = math.floor(math.ldexp(m, 53 - i))\n  exp = e + i - 53\n  return (math.ldexp(mf, exp), math.ldexp(mf + 1, exp))\n\n\ndef get_range(start, end, count):\n  \"\"\"\n  Returns an equally-distributed list of floats greater than start and less than end.\n  \"\"\"\n  step = float(end - start) / (count + 1)\n  # Ensure all resulting values are strictly less than end.\n  limit = prevfloat(end)\n  return [min(start + step * k, limit) for k in range(1, count + 1)]\n"
  },
  {
    "path": "sandbox/grist/relation.py",
    "content": "\"\"\"\nA Relation represent mapping between rows, and used in determining which rows need to be\nrecomputed when something changes.\n\nRelations can be determined by a foreign key or another form of lookup, and they may be composed.\n\n  For example, if Person.zip is the formula 'rec.school.address.zip', it involves three Relations:\n  ReferenceRelation between Person and School tables, another ReferenceRelation between School and\n  Address tables. Together, they form ComposedRelation relation between Person and Address tables.\n\n\"\"\"\nimport depend\n\nclass Relation(object):\n  \"\"\"\n  Represents a row mapping between two tables. The arguments are table IDs (not actual tables).\n  \"\"\"\n  def __init__(self, referring_table, target_table):\n    self.referring_table = referring_table\n    self.target_table = target_table\n\n    # Maps the relation objects that we wrap to the resulting composed relations.\n    self._target_relations = {}\n\n  def get_affected_rows(self, input_rows):\n    \"\"\"\n    Given an iterable over input (dependency) rows, returns a `set` of output (dependent) rows.\n    \"\"\"\n    raise NotImplementedError()\n\n  def reset_all(self):\n    \"\"\"\n    Called when the dependency using this relation is reset, and this relation is no longer used.\n    \"\"\"\n    self.reset_rows(depend.ALL_ROWS)\n\n  def reset_rows(self, referring_rows):\n    \"\"\"\n    Call when starting to compute a formula to tell a Relation that it can start with a clean\n    slate for all row_ids in the passed-in iterable.\n    \"\"\"\n    pass\n\n  def compose(self, other_relation):\n    r = self._target_relations.get(other_relation)\n    if r is None:\n      r = self._target_relations[other_relation] = ComposedRelation(self, other_relation)\n    return r\n\nclass IdentityRelation(Relation):\n  \"\"\"\n  The trivial mapping, used to represent the relation between fields of the same record.\n  \"\"\"\n  def __init__(self, table_id):\n    super(IdentityRelation, self).__init__(table_id, table_id)\n\n  def get_affected_rows(self, input_rows):\n    return input_rows\n\n  def __str__(self):\n    return \"Identity(%s)\" % self.referring_table\n\n  # Important: we intentionally do not optimize compose() for an IdentityRelation, since\n  # (Identity + Rel) is not the same Rel when it comes to reset_rows() calls. [See test_lookups.py\n  # test_dependencies_relations_bug for a detailed description of a bug this can cause.]\n\n\nclass SingleRowsIdentityRelation(IdentityRelation):\n  \"\"\"\n  Represents an identity relation, but one which refuses to pass along ALL_ROWS. In other words,\n  if a full column changed (i.e. ALL_ROWS), none of the dependent cells will be considered\n  changed. But when specific rows are changed, those changes propagate.\n\n  This is used for trigger formulas, to ensure they don't recalculate in full when a dependency\n  column is renamed or modified (as opposed to particular records).\n  \"\"\"\n  def get_affected_rows(self, input_rows):\n    return [] if input_rows == depend.ALL_ROWS else input_rows\n\n\nclass ComposedRelation(Relation):\n  \"\"\"\n  Represents a composition of two Relations. E.g. if referring side maps Students to Schools, and\n  target_side maps Schools to Addresses, then the composition maps Students to Addresses (so a\n  Student records depend on Address records, and changes to Address records affect Students).\n  \"\"\"\n  def __init__(self, referring_side, target_side):\n    assert referring_side.target_table == target_side.referring_table\n    super(ComposedRelation, self).__init__(referring_side.referring_table,\n                                           target_side.target_table)\n    self.source_relation = referring_side\n    self.target_relation = target_side\n\n  def get_affected_rows(self, input_rows):\n    return self.source_relation.get_affected_rows(\n      self.target_relation.get_affected_rows(input_rows))\n\n  def reset_rows(self, referring_rows):\n    # In the example from the doc-string, this says that certain Students are being recomputed, so\n    # no longer refer to any Schools. It doesn't say anything about Schools' dependence on\n    # Addresses, so there is nothing to reset in self.target_relation.\n    self.source_relation.reset_rows(referring_rows)\n\n  def __str__(self):\n    return \"%s + %s\" % (self.source_relation, self.target_relation)\n\n\nclass ReferenceRelation(Relation):\n  \"\"\"\n  Base class for Relations between records in two tables.\n  \"\"\"\n  def __init__(self, referring_table, target_table, ref_col_id):\n    super(ReferenceRelation, self).__init__(referring_table, target_table)\n    self.inverse_map = {}     # maps target rows to sets of referring rows\n    self._ref_col_id = ref_col_id\n\n  def __str__(self):\n    return \"ReferenceRelation(%s.%s)\" % (self.referring_table, self._ref_col_id)\n\n  def get_affected_rows(self, input_rows):\n    # Each input row (target of the reference link) may be pointed to by multiple references,\n    # so we need to take the union of all of those sets.\n    if input_rows == depend.ALL_ROWS:\n      return depend.ALL_ROWS\n    affected_rows = set()\n    for target_row_id in input_rows:\n      affected_rows.update(self.inverse_map.get(target_row_id, ()))\n    return affected_rows\n\n  def add_reference(self, referring_row_id, target_row_id):\n    self.inverse_map.setdefault(target_row_id, set()).add(referring_row_id)\n\n  def remove_reference(self, referring_row_id, target_row_id):\n    self.inverse_map[target_row_id].discard(referring_row_id)\n\n  def clear(self):\n    self.inverse_map.clear()\n"
  },
  {
    "path": "sandbox/grist/reverse_references.py",
    "content": "from collections import defaultdict\nfrom usertypes import get_referenced_table_id\n\nclass _RefUpdates(object):\n  def __init__(self):\n    self.removals = set()\n    self.additions = set()\n\ndef get_reverse_adjustments(row_ids, old_values, new_values, value_iterator, relation):\n  \"\"\"\n  Generates data for updating reverse columns, based on changes to this column\n  \"\"\"\n\n  # Stores removals and addons for each target row\n  affected_target_rows = defaultdict(_RefUpdates)\n\n  # Iterate over changes to source column (my column)\n  for (source_row_id, old_value, new_value) in zip(row_ids, old_values, new_values):\n    if new_value != old_value:\n      # Treat old_values as removals, and new_values as additions\n      for target_row_id in value_iterator(old_value):\n        affected_target_rows[target_row_id].removals.add(source_row_id)\n      for target_row_id in value_iterator(new_value):\n        affected_target_rows[target_row_id].additions.add(source_row_id)\n\n  # Now in affected_target_rows, we have the changes (deltas), now we are going to convert them\n  # to updates (full list of values) in target columns.\n\n  adjustments = []\n\n  # For each target row (that needs to be updated, and was change in our column)\n  for target_row_id, updates in affected_target_rows.items():\n    # Get the value stored in that column by using our own relation object (which should store\n    # correct values - the same that are stored in that reverse column). `reverse_value` is the\n    # value in that reverse cell\n    reverse_value = relation.get_affected_rows((target_row_id,))\n\n    # Now make the adjustments using calculated deltas\n    for source_row_id in updates.removals:\n      reverse_value.discard(source_row_id)\n    for source_row_id in updates.additions:\n      reverse_value.add(source_row_id)\n    adjustments.append((target_row_id, sorted(reverse_value)))\n\n  return adjustments\n\n\ndef check_desired_reverse_col(col_type, desired_reverse_col):\n  if not desired_reverse_col:\n    raise ValueError(\"invalid column specified in reverseCol\")\n  if desired_reverse_col.reverseCol:\n    raise ValueError(\"reverseCol specifies an existing two-way reference column\")\n  ref_table_id = get_referenced_table_id(col_type)\n  if not ref_table_id:\n    raise ValueError(\"reverseCol may only be set on a column with a reference type\")\n  if desired_reverse_col.tableId != ref_table_id:\n    raise ValueError(\"reverseCol must be a column in the target table\")\n\n\ndef pick_reverse_col_label(docmodel, col_rec):\n  ref_table_id = get_referenced_table_id(col_rec.type)\n  ref_table_rec = docmodel.get_table_rec(ref_table_id)\n\n  # First try the source table title.\n  source_table_rec = col_rec.parentId\n  reverse_label = source_table_rec.rawViewSectionRef.title or source_table_rec.tableId\n\n  # If that name already exists (as a label), add the source column's name as a suffix.\n  avoid_set = set(c.label for c in ref_table_rec.columns)\n  if reverse_label in avoid_set:\n    return reverse_label + \"-\" + (col_rec.label or col_rec.colId)\n  return reverse_label\n"
  },
  {
    "path": "sandbox/grist/runtests.py",
    "content": "\"\"\"\nHelper to run Python unittests in the sandbox. They can be run directly as follows:\n\n  ./sandbox/nacl/bin/run -E PYTHONPATH=/thirdparty python -m unittest discover -v -s /grist\n\nThis modules makes this a bit easier, and adds support for --xunit option, needed for running\ntests under 'arc unit' and under Jenkins.\n\n  ./sandbox/nacl/bin/run python /grist/runtests.py [--xunit]\n\"\"\"\nimport codecs\nimport logging\nimport os\nimport sys\nimport unittest\nsys.path.append('/thirdparty')\n\ndef main():\n  # Change to the directory of this file (/grist in sandbox), to discover everything under it.\n  os.chdir(os.path.dirname(__file__))\n\n  argv = sys.argv[:]\n  test_runner = None\n  if \"--xunit\" in argv:\n    import xmlrunner\n    argv.remove(\"--xunit\")\n    utf8_stdout = sys.stdout\n    test_runner = xmlrunner.XMLTestRunner(stream=utf8_stdout)\n\n  if \"-v\" in argv or \"--verbose\" in argv:\n    logging.basicConfig(level=logging.DEBUG)\n\n  if all(arg.startswith(\"-\") for arg in argv[1:]):\n    argv.insert(1, \"discover\")\n\n  unittest.main(module=None, argv=argv, testRunner=test_runner)\n\nif __name__ == '__main__':\n  main()\n"
  },
  {
    "path": "sandbox/grist/sandbox.py",
    "content": "\"\"\"\nImplements the python side of the data engine sandbox, which allows us to register functions on\nthe python side and call them from Node.js.\n\nUsage:\n  import sandbox\n  sandbox.register(func_name, func)\n  sandbox.call_external(\"hello\", 1, 2, 3)\n  sandbox.run()\n\"\"\"\n\nimport os\nimport logging\nimport marshal\nimport sys\nimport traceback\n\nlog = logging.getLogger(__name__)\n\nclass CarefulReader(object):\n  \"\"\"\n  Wrap a pipe when reading from Pyodide, to work around marshaling\n  panicking if fewer bytes are read in a block than it was expecting.\n  Just wait for more.\n  \"\"\"\n\n  def __init__(self, file_):\n    self._file = file_\n\n  def write(self, data):\n    return self._file.write(data)\n\n  def read(self, size):\n    return self._file.read(size)\n\n  def readinto(self, b):\n    result = self._file.readinto(b)\n    while result is not None and result < len(b):\n      bview = memoryview(b)\n      result += self._file.readinto(bview[result:])\n    return result\n\n  def __getattr__(self, attr):\n    return getattr(self._file, attr)\n\n\nclass Sandbox(object):\n  \"\"\"\n  This class works in conjunction with Sandbox.js to allow function calls\n  between the Node process and this sandbox.\n\n  The sandbox provides two pipes to send data to and from the sandboxed\n  process. Data on these is serialized using `marshal` module. All messages are comprised of a\n  msgCode followed immediatedly by msgBody, with the following msgCodes:\n    CALL = call to the other side. The data must be an array of [func_name, arguments...]\n    DATA = data must be a value to return to a call from the other side\n    EXC = data must be an exception to return to a call from the other side\n\n  Optionally, a callback can be supplied instead of an output pipe.\n  \"\"\"\n\n  CALL = None\n  DATA = True\n  EXC = False\n\n  def __init__(self, external_input, external_output, external_output_method=None):\n    self._functions = {}\n    self._external_input = external_input\n    self._external_output = external_output\n    self._external_output_method = external_output_method\n\n  @classmethod\n  def connected_to_js_pipes(cls):\n    \"\"\"\n    Send data on two specially-opened side channels.\n    \"\"\"\n    external_input = os.fdopen(3, \"rb\", 64 * 1024)\n    external_output = os.fdopen(4, \"wb\", 64 * 1024)\n    return cls(external_input, external_output)\n\n  @classmethod\n  def use_common_pipes(cls):\n    \"\"\"\n    Send data via stdin/stdout, rather than specially-opened side channels.\n    Duplicate stdin/stdout, close, and reopen as binary file objects.\n    \"\"\"\n    os.dup2(0, 3)\n    os.dup2(1, 4)\n    os.close(0)\n    os.close(1)\n    sys.stdout = sys.stderr\n    return Sandbox.connected_to_js_pipes()\n\n  @classmethod\n  def use_pyodide(cls):\n    # pylint: disable=import-error,no-member\n    import js  # Get pyodide object.\n    external_input = CarefulReader(sys.stdin.buffer)\n    external_output_method = lambda data: js.sendFromSandbox(data)\n    sys.stdout = sys.stderr\n    return cls(external_input, None, external_output_method)\n\n  def _send_to_js(self, msgCode, msgBody):\n    # (Note that marshal version 2 is the default; we specify it explicitly for clarity. The\n    # difference with version 0 is that version 2 uses a faster binary format for floats.)\n\n    # For large data, JS's Unmarshaller is very inefficient parsing it if it gets it piecewise.\n    # It's much better to ensure the whole blob is sent as one write. We marshal the resulting\n    # buffer again so that the reader can quickly tell how many bytes to expect.\n    buf = marshal.dumps((msgCode, msgBody), 2)\n    if self._external_output:\n      marshal.dump(buf, self._external_output, 2)\n      self._external_output.flush()\n    elif self._external_output_method:\n      buf = marshal.dumps(buf, 2)\n      self._external_output_method(buf)\n    else:\n      raise Exception('no data output method')\n\n  def call_external(self, name, *args):\n    self._send_to_js(Sandbox.CALL, (name,) + args)\n    (msgCode, data) = self.run(break_on_response=True)\n    if msgCode == Sandbox.EXC:\n      raise Exception(data)\n    return data\n\n  def register(self, func_name, func):\n    self._functions[func_name] = func\n\n  def run(self, break_on_response=False):\n    while True:\n      try:\n        msgCode = marshal.load(self._external_input)\n        data = marshal.load(self._external_input)\n      except EOFError:\n        break\n      if msgCode != Sandbox.CALL:\n        if break_on_response:\n          return (msgCode, data)\n        continue\n\n      if not isinstance(data, list) or len(data) < 1:\n        raise ValueError(\"Bad call \" + data)\n      try:\n        fname = data[0]\n        args = data[1:]\n        ret = self._functions[fname](*args)\n        self._send_to_js(Sandbox.DATA, ret)\n      except Exception as e:\n        log.warn(\"Call error in %s: %s\", fname, traceback.format_exc())\n        self._send_to_js(Sandbox.EXC, \"%s %s\" % (type(e).__name__, e))\n    if break_on_response:\n      raise Exception(\"Sandbox disconnected unexpectedly\")\n\ndefault_sandbox = None\n\ndef get_default_sandbox():\n  global default_sandbox\n  if default_sandbox is None:\n    if os.environ.get('PIPE_MODE') == 'minimal':\n      default_sandbox = Sandbox.use_common_pipes()\n    elif os.environ.get('PIPE_MODE') == 'pyodide':\n      default_sandbox = Sandbox.use_pyodide()\n    else:\n      default_sandbox = Sandbox.connected_to_js_pipes()\n  return default_sandbox\n\ndef call_external(name, *args):\n  return get_default_sandbox().call_external(name, *args)\n\ndef register(func_name, func):\n  get_default_sandbox().register(func_name, func)\n\ndef run():\n  get_default_sandbox().run()\n"
  },
  {
    "path": "sandbox/grist/schema.py",
    "content": "\"\"\"\nschema.py defines the schema of the tables describing Grist's own data structures. While users can\ncreate tables, add and remove columns, etc, Grist stores various document metadata (about the\nusers' tables, views, etc.) also in tables.\n\nBefore changing this file, please review:\n  /documentation/migrations.md\n\n\"\"\"\n\nimport itertools\nfrom collections import OrderedDict, namedtuple\n\nimport actions\n\nSCHEMA_VERSION = 46\n\ndef make_column(col_id, col_type, formula='', isFormula=False):\n  return {\n    \"id\": col_id,\n    \"type\": col_type,\n    \"isFormula\": isFormula,\n    \"formula\": formula\n  }\n\ndef schema_create_actions():\n  return [\n    # The document-wide metadata. It's all contained in a single record with id=1.\n    actions.AddTable(\"_grist_DocInfo\", [\n      make_column(\"docId\",        \"Text\"), # DEPRECATED: docId is now stored in _gristsys_FileInfo\n      make_column(\"peers\",        \"Text\"), # DEPRECATED: now _grist_ACLPrincipals is used for this\n\n      # Basket id of the document for online storage, if a Basket has been created for it.\n      make_column(\"basketId\",     \"Text\"),\n\n      # Version number of the document. It tells us how to migrate it to reach SCHEMA_VERSION.\n      make_column(\"schemaVersion\", \"Int\"),\n\n      # Document timezone.\n      make_column(\"timezone\", \"Text\"),\n\n      # Document settings (excluding timezone).\n      make_column(\"documentSettings\", \"Text\"), # JSON string describing document settings\n    ]),\n\n    # The names of the user tables. This does NOT include built-in tables.\n    actions.AddTable(\"_grist_Tables\", [\n      make_column(\"tableId\",      \"Text\"),\n      make_column(\"primaryViewId\",\"Ref:_grist_Views\"),\n\n      # For a summary table, this points to the corresponding source table.\n      make_column(\"summarySourceTable\", \"Ref:_grist_Tables\"),\n\n      # A table may be marked as \"onDemand\", which will keep its data out of the data engine, and\n      # only available to the frontend when requested.\n      make_column(\"onDemand\",     \"Bool\"),\n\n      make_column(\"rawViewSectionRef\", \"Ref:_grist_Views_section\"),\n      make_column(\"recordCardViewSectionRef\", \"Ref:_grist_Views_section\"),\n    ]),\n\n    # All columns in all user tables.\n    actions.AddTable(\"_grist_Tables_column\", [\n      make_column(\"parentId\",     \"Ref:_grist_Tables\"),\n      make_column(\"parentPos\",    \"PositionNumber\"),\n      make_column(\"colId\",        \"Text\"),\n      make_column(\"type\",         \"Text\"),\n      make_column(\"widgetOptions\",\"Text\"), # JSON extending column's widgetOptions\n      make_column(\"isFormula\",    \"Bool\"),\n      make_column(\"formula\",      \"Text\"),\n      make_column(\"label\",        \"Text\"),\n      make_column(\"description\",  \"Text\"),\n\n      # Normally a change to label changes colId as well, unless untieColIdFromLabel is True.\n      # (We intentionally pick a variable whose default value is false.)\n      make_column(\"untieColIdFromLabel\", \"Bool\"),\n\n      # For a group-by column in a summary table, this points to the corresponding source column.\n      make_column(\"summarySourceCol\", \"Ref:_grist_Tables_column\"),\n      # Points to a display column, if it exists, for this column.\n      make_column(\"displayCol\",       \"Ref:_grist_Tables_column\"),\n      # For Ref cols only, points to the column in the pointed-to table, which is to be displayed.\n      # E.g. Foo.person may have a visibleCol pointing to People.Name, with the displayCol\n      # pointing to Foo._gristHelper_DisplayX column with the formula \"$person.Name\".\n      make_column(\"visibleCol\",       \"Ref:_grist_Tables_column\"),\n      # Points to formula columns that hold conditional formatting rules.\n      make_column(\"rules\",       \"RefList:_grist_Tables_column\"),\n\n      # For Ref/RefList columns only, points to the corresponding reverse reference column.\n      # This column will get automatically set to reverse of the references in reverseCol.\n      make_column(\"reverseCol\",       \"Ref:_grist_Tables_column\"),\n\n      # Instructions when to recalculate the formula on a column with isFormula=False (previously\n      # known as a \"default formula\"). Values are RecalcWhen constants defined below.\n      make_column(\"recalcWhen\",       \"Int\"),\n\n      # List of fields that should trigger a calculation of a formula in a data column. Only\n      # applies when recalcWhen is RecalcWhen.DEFAULT, and defaults to the empty list.\n      make_column(\"recalcDeps\",       \"RefList:_grist_Tables_column\"),\n    ]),\n\n    # DEPRECATED: Previously used to keep import options, and allow the user to change them.\n    actions.AddTable(\"_grist_Imports\", [\n      make_column(\"tableRef\",     \"Ref:_grist_Tables\"),\n      make_column(\"origFileName\", \"Text\"),\n      make_column(\"parseFormula\", \"Text\", isFormula=True,\n                  formula=\"grist.parseImport(rec, table._engine)\"),\n\n      # The following translate directly to csv module options. We can use csv.Sniffer to guess\n      # them based on a sample of the data (it also guesses hasHeaders option).\n      make_column(\"delimiter\",    \"Text\",     formula=\"','\"),\n      make_column(\"doublequote\",  \"Bool\",     formula=\"True\"),\n      make_column(\"escapechar\",   \"Text\"),\n      make_column(\"quotechar\",    \"Text\",     formula=\"'\\\"'\"),\n      make_column(\"skipinitialspace\", \"Bool\"),\n\n      # Other parameters Grist understands.\n      make_column(\"encoding\",     \"Text\",     formula=\"'utf8'\"),\n      make_column(\"hasHeaders\",   \"Bool\"),\n    ]),\n\n    # DEPRECATED: Previously - All external database credentials attached to the document\n    actions.AddTable(\"_grist_External_database\", [\n      make_column(\"host\",         \"Text\"),\n      make_column(\"port\",         \"Int\"),\n      make_column(\"username\",     \"Text\"),\n      make_column(\"dialect\",      \"Text\"),\n      make_column(\"database\",     \"Text\"),\n      make_column(\"storage\",      \"Text\"),\n    ]),\n\n    # DEPRECATED: Previously - Reference to a table from an external database\n    actions.AddTable(\"_grist_External_table\", [\n      make_column(\"tableRef\",     \"Ref:_grist_Tables\"),\n      make_column(\"databaseRef\",  \"Ref:_grist_External_database\"),\n      make_column(\"tableName\",    \"Text\"),\n    ]),\n\n    # DEPRECATED: Document tabs that represent a cross-reference between Tables and Views\n    actions.AddTable(\"_grist_TableViews\", [\n      make_column(\"tableRef\",     \"Ref:_grist_Tables\"),\n      make_column(\"viewRef\",      \"Ref:_grist_Views\"),\n    ]),\n\n    # DEPRECATED: Previously used to cross-reference between Tables and Views\n    actions.AddTable(\"_grist_TabItems\", [\n      make_column(\"tableRef\",     \"Ref:_grist_Tables\"),\n      make_column(\"viewRef\",      \"Ref:_grist_Views\"),\n    ]),\n\n    actions.AddTable(\"_grist_TabBar\", [\n      make_column(\"viewRef\",      \"Ref:_grist_Views\"),\n      make_column(\"tabPos\",        \"PositionNumber\"),\n    ]),\n\n    # Table for storing the tree of pages. 'pagePos' and 'indentation' columns gives how a page is\n    # shown in the panel: 'pagePos' determines the page overall position when no pages are collapsed\n    # (ie: all pages are visible) and 'indentation' gives the level of nesting (depth). Note that\n    # the parent-child relationships between pages have to be inferred from the variation of\n    # `indentation` between consecutive pages. For instance a difference of +1 between two\n    # consecutive pages means that the second page is the child of the first page. A difference of 0\n    # means that both are siblings and a difference of -1 means that the second page is a sibling to\n    # the first page parent.\n    actions.AddTable(\"_grist_Pages\", [\n      make_column(\"viewRef\", \"Ref:_grist_Views\"),\n      make_column(\"indentation\", \"Int\"),\n      make_column(\"pagePos\", \"PositionNumber\"),\n      make_column(\"shareRef\", \"Ref:_grist_Shares\"),\n      make_column(\"options\", \"Text\"),\n    ]),\n\n    # All user views.\n    actions.AddTable(\"_grist_Views\", [\n      make_column(\"name\",         \"Text\"),\n      make_column(\"type\",         \"Text\"),    # TODO: Should this be removed?\n      make_column(\"layoutSpec\",   \"Text\"),    # JSON string describing the view layout\n    ]),\n\n    # The sections of user views (e.g. a view may contain a list section and a detail section).\n    # Different sections may need different parameters, so this table includes columns for all\n    # possible parameters, and any given section will use some subset, depending on its type.\n    actions.AddTable(\"_grist_Views_section\", [\n      make_column(\"tableRef\",           \"Ref:_grist_Tables\"),\n      make_column(\"parentId\",           \"Ref:_grist_Views\"),\n      # parentKey is the type of view section, such as 'list', 'detail', or 'single'.\n      # TODO: rename this (e.g. to \"sectionType\").\n      make_column(\"parentKey\",          \"Text\"),\n      make_column(\"title\",              \"Text\"),\n      make_column(\"description\",        \"Text\"),\n      make_column(\"defaultWidth\",       \"Int\", formula=\"100\"),\n      make_column(\"borderWidth\",        \"Int\", formula=\"1\"),\n      make_column(\"theme\",              \"Text\"),\n      make_column(\"options\",            \"Text\"),\n      make_column(\"chartType\",          \"Text\"),\n      make_column(\"layoutSpec\",         \"Text\"), # JSON string describing the record layout\n      # filterSpec is deprecated as of version 15. Do not remove or reuse.\n      make_column(\"filterSpec\",         \"Text\"),\n      make_column(\"sortColRefs\",        \"Text\"),\n      make_column(\"linkSrcSectionRef\",  \"Ref:_grist_Views_section\"),\n      make_column(\"linkSrcColRef\",      \"Ref:_grist_Tables_column\"),\n      make_column(\"linkTargetColRef\",   \"Ref:_grist_Tables_column\"),\n      # embedId is deprecated as of version 12. Do not remove or reuse.\n      make_column(\"embedId\",            \"Text\"),\n      # Points to formula columns that hold conditional formatting rules for this view section.\n      make_column(\"rules\",              \"RefList:_grist_Tables_column\"),\n      make_column(\"shareOptions\",       \"Text\"),\n    ]),\n    # The fields of a view section.\n    actions.AddTable(\"_grist_Views_section_field\", [\n      make_column(\"parentId\",     \"Ref:_grist_Views_section\"),\n      make_column(\"parentPos\",    \"PositionNumber\"),\n      make_column(\"colRef\",       \"Ref:_grist_Tables_column\"),\n      make_column(\"width\",        \"Int\"),\n      make_column(\"widgetOptions\",\"Text\"), # JSON extending field's widgetOptions\n      # Points to a display column, if it exists, for this field.\n      make_column(\"displayCol\",   \"Ref:_grist_Tables_column\"),\n      # For Ref cols only, may override the column to be displayed fromin the pointed-to table.\n      make_column(\"visibleCol\",   \"Ref:_grist_Tables_column\"),\n      # DEPRECATED: replaced with _grist_Filters in version 25. Do not remove or reuse.\n      make_column(\"filter\",       \"Text\"),\n      # Points to formula columns that hold conditional formatting rules for this field.\n      make_column(\"rules\",       \"RefList:_grist_Tables_column\"),\n    ]),\n\n    # The code for all of the validation rules available to a Grist document\n    actions.AddTable(\"_grist_Validations\", [\n      make_column(\"formula\",      \"Text\"),\n      make_column(\"name\",         \"Text\"),\n      make_column(\"tableRef\",     \"Int\")\n    ]),\n\n    # The input code and output text and compilation/runtime errors for usercode\n    actions.AddTable(\"_grist_REPL_Hist\", [\n      make_column(\"code\",         \"Text\"),\n      make_column(\"outputText\",   \"Text\"),\n      make_column(\"errorText\",    \"Text\")\n    ]),\n\n    # All of the attachments attached to this document.\n    actions.AddTable(\"_grist_Attachments\", [\n      make_column(\"fileIdent\",    \"Text\"), # Checksum of the file contents. It identifies the file\n                                           # data in the _gristsys_Files table.\n      make_column(\"fileName\",     \"Text\"), # User defined file name\n      make_column(\"fileType\",     \"Text\"), # A string indicating the MIME type of the data\n      make_column(\"fileSize\",     \"Int\"),  # The size in bytes\n      # The file extension, including the \".\" prefix.\n      # Prior to April 2023, this column did not exist, so attachments created before then have a\n      # blank fileExt. The extension may still be present in fileName, so a migration can backfill\n      # some older attachments if the need arises.\n      make_column(\"fileExt\",      \"Text\"),\n      make_column(\"imageHeight\",  \"Int\"),  # height in pixels\n      make_column(\"imageWidth\",   \"Int\"),  # width in pixels\n      make_column(\"timeDeleted\",  \"DateTime\"),\n      make_column(\"timeUploaded\", \"DateTime\")\n    ]),\n\n\n    # Triggers subscribing to changes in tables\n    actions.AddTable(\"_grist_Triggers\", [\n      make_column(\"tableRef\", \"Ref:_grist_Tables\"),\n      make_column(\"eventTypes\", \"ChoiceList\"),\n      make_column(\"isReadyColRef\", \"Ref:_grist_Tables_column\"),\n      make_column(\"actions\", \"Text\"),  # JSON\n      make_column(\"label\", \"Text\"),\n      make_column(\"memo\", \"Text\"),\n      make_column(\"enabled\", \"Bool\"),\n      make_column(\"watchedColRefList\", \"RefList:_grist_Tables_column\"),\n      make_column(\"options\", \"Text\"),\n      # Empty string or a JSON object with at least 2 fields:\n      # - 'text'   - simple expression entered by user (or built by UI)\n      # - 'parsed' - parsed representation of the expression (done by the engine when updated)\n      # - ....     - any other things we may need in the future (like data for UI builder)\n\n      # When adding or updating this column it can also be just a formula string, in that case\n      # engine will replace it with a full object (having both 'text' and 'parsed' fields).\n      make_column(\"condition\", \"Text\"),\n    ]),\n\n    # All of the ACL rules.\n    actions.AddTable('_grist_ACLRules', [\n      make_column('resource',     'Ref:_grist_ACLResources'),\n      make_column('permissions',  'Int'),     # DEPRECATED: permissionsText is used instead.\n      make_column('principals',   'Text'),    # DEPRECATED\n\n      # Text of match formula, in restricted Python syntax; \"\" for default rule.\n      make_column('aclFormula',   'Text'),\n\n      make_column('aclColumn',    'Ref:_grist_Tables_column'),  # DEPRECATED\n\n      # JSON representation of the parse tree of matchFunc; \"\" for default rule.\n      make_column('aclFormulaParsed', 'Text'),\n\n      # Permissions in the form '[+<bits>][-<bits>]' where <bits> is a string of\n      # C,R,U,D,S characters, each appearing at most once. Or the special values\n      # 'all' or 'none'. The empty string does not affect permissions.\n      make_column('permissionsText', 'Text'),\n\n      # Rules for one resource are ordered by increasing rulePos. The default rule\n      # should be at the end (later rules would have no effect).\n      make_column('rulePos',      'PositionNumber'),\n\n      # If non-empty, this rule adds extra user attributes. It should contain JSON\n      # of the form {name, tableId, lookupColId, charId}, and should be tied to the\n      # resource *:*. It acts by looking up user[charId] in the given tableId on the\n      # given lookupColId, and adds the full looked-up record as user[name], which\n      # becomes available to matchFunc. These rules are processed in order of rulePos,\n      # which should list them before regular rules.\n      make_column('userAttributes', 'Text'),\n\n      # Text of memo associated with this rule, if any. Prior to version 35, this was\n      # stored within aclFormula.\n      make_column('memo',           'Text'),\n    ]),\n\n    # Note that the special resource with tableId of '' and colIds of '' should be ignored. It is\n    # present to satisfy older versions of Grist (before Nov 2020).\n    actions.AddTable('_grist_ACLResources', [\n      make_column('tableId',      'Text'),    # Name of the table this rule applies to, or '*'\n      make_column('colIds',       'Text'),    # Comma-separated list of colIds, or '*'\n    ]),\n\n    # DEPRECATED: All of the principals used by ACL rules, including users, groups, and instances.\n    actions.AddTable('_grist_ACLPrincipals', [\n      make_column('type',         'Text'),    # 'user', 'group', or 'instance'\n      make_column('userEmail',    'Text'),    # For 'user' principals\n      make_column('userName',     'Text'),    # For 'user' principals\n      make_column('groupName',    'Text'),    # For 'group' principals\n      make_column('instanceId',   'Text'),    # For 'instance' principals\n\n      # docmodel.py defines further `name` and `allInstances`, and members intended as helpers\n      # only: `memberships`, `children`, and `descendants`.\n    ]),\n\n    # DEPRECATED: Table for containment relationships between Principals, e.g. user contains\n    # multiple instances, group contains multiple users, and groups may contain other groups.\n    actions.AddTable('_grist_ACLMemberships', [\n      make_column('parent', 'Ref:_grist_ACLPrincipals'),\n      make_column('child',  'Ref:_grist_ACLPrincipals'),\n    ]),\n\n    actions.AddTable('_grist_Filters', [\n      make_column(\"viewSectionRef\", \"Ref:_grist_Views_section\"),\n      make_column(\"colRef\",         \"Ref:_grist_Tables_column\"),\n      # JSON string describing the default filter as map from either an `included` or an\n      # `excluded` string to an array of column values:\n      # Ex1: { included: ['foo', 'bar'] }\n      # Ex2: { excluded: ['apple', 'orange'] }\n      make_column(\"filter\",         \"Text\"),\n      # Filters can be pinned to the filter bar, which causes a button to be displayed\n      # that opens the filter menu when clicked.\n      make_column(\"pinned\",         \"Bool\"),\n    ]),\n\n    # Additional metadata for cells\n    actions.AddTable('_grist_Cells', [\n      make_column(\"tableRef\",       \"Ref:_grist_Tables\"),\n      make_column(\"colRef\",         \"Ref:_grist_Tables_column\"),\n      make_column(\"rowId\",          \"Int\"),\n      # Cell metadata is stored as in hierarchical structure.\n      # We need to mark the root of the tree as we use autoremove feature of the engine. Without it\n      # we won't be able to detect if the root of the tree is deleted (root doesn't have parent, so\n      # it looks like a deleted leaf).\n      make_column(\"root\",           \"Bool\"),\n      make_column(\"parentId\",       \"Ref:_grist_Cells\"),\n      # Type of information, currently we have only one type Comments (with value 1).\n      make_column(\"type\",           \"Int\"),\n      # JSON representation of the metadata.\n      make_column(\"content\",        \"Text\"),\n      make_column(\"userRef\",        \"Text\"),\n      # Comment-specific fields (moved from JSON content for better access control)\n      make_column(\"timeCreated\",    \"DateTime\"),\n      make_column(\"timeUpdated\",    \"DateTime\"),\n      make_column(\"resolved\",       \"Bool\"),\n    ]),\n\n    actions.AddTable('_grist_Shares', [\n      make_column('linkId',         'Text'),   # Used to match records in home db without\n                                               # necessarily trusting the document much.\n      make_column('options',        'Text'),\n      make_column('label',          'Text'),\n      make_column('description',    'Text'),\n    ]),\n  ]\n\n\nclass RecalcWhen(object):\n  \"\"\"\n  Constants for column's recalcWhen field, which determine when a formula associated with a data\n  column would get calculated.\n  \"\"\"\n  DEFAULT = 0         # Calculate on new records or when any field in recalcDeps changes. If\n                      # recalcDeps includes this column itself, it's a \"data-cleaning\" formula.\n  NEVER = 1           # Don't calculate automatically (but user can trigger manually)\n  MANUAL_UPDATES = 2  # Calculate on new records and on manual updates to any data field.\n\n\n# These are little structs to represent the document schema that's used in code generation.\n# Schema itself (as stored by Engine) is an OrderedDict(tableId -> SchemaTable), with\n# SchemaTable.columns being an OrderedDict(colId -> SchemaColumn).\n# Note: reverseColId produces types like grist.ReferenceList(\"Table\", reverse_of=\"ColId\")\n# used for two-way references.\nSchemaTable = namedtuple('SchemaTable', ('tableId', 'columns'))\nSchemaColumn = namedtuple('SchemaColumn', ('colId', 'type', 'isFormula', 'formula', 'reverseColId'))\n\n# Helpers to convert between schema structures and dicts used in schema actions.\ndef dict_to_col(col, col_id=None):\n  \"\"\"Convert dict as used in AddColumn/AddTable actions to a SchemaColumn object.\"\"\"\n  return SchemaColumn(col_id or col[\"id\"], col[\"type\"], bool(col[\"isFormula\"]), col[\"formula\"],\n      col.get(\"reverseColId\"))\n\ndef col_to_dict(col, include_id=True, include_default=False):\n  \"\"\"\n  Convert SchemaColumn to dict to use in AddColumn/AddTable actions.\n  Set include_default=True to include default values explicitly, e.g. override previous values.\n  \"\"\"\n  ret = {\"type\": col.type, \"isFormula\": col.isFormula, \"formula\": col.formula}\n  if col.reverseColId or include_default:\n    ret[\"reverseColId\"] = col.reverseColId\n  if include_id:\n    ret[\"id\"] = col.colId\n  return ret\n\ndef dict_list_to_cols(dict_list):\n  \"\"\"Convert list of column dicts to an OrderedDict of SchemaColumns.\"\"\"\n  return OrderedDict((c[\"id\"], dict_to_col(c)) for c in dict_list)\n\ndef cols_to_dict_list(cols):\n  \"\"\"Convert OrderedDict of SchemaColumns to an array of column dicts.\"\"\"\n  return [col_to_dict(c) for c in cols.values()]\n\ndef clone_schema(schema):\n  return OrderedDict((t, SchemaTable(s.tableId, s.columns.copy()))\n                     for (t, s) in schema.items())\n\ndef get_reverse_col_id_lookup_func(collist):\n  \"\"\"\n  Given a list of _grist_Tables_column records, return a function that takes a record and\n  returns its reverseColId.\n  \"\"\"\n  col_ref_to_col_id = {c.id: c.colId for c in collist}\n  return lambda c: col_ref_to_col_id.get(getattr(c, 'reverseCol', 0))\n\ndef build_schema(meta_tables, meta_columns, include_builtin=True):\n  \"\"\"\n  Arguments are TableData objects for the _grist_Tables and _grist_Tables_column tables.\n  Returns the schema object for engine.py, used in particular in gencode.py.\n  \"\"\"\n  assert meta_tables.table_id == '_grist_Tables'\n  assert meta_columns.table_id == '_grist_Tables_column'\n\n  # Schema is an OrderedDict.\n  schema = OrderedDict()\n  if include_builtin:\n    for t in schema_create_actions():\n      schema[t.table_id] = SchemaTable(t.table_id, dict_list_to_cols(t.columns))\n\n  # Construct a list of columns sorted by table and position.\n  collist = sorted(actions.transpose_bulk_action(meta_columns),\n                   key=lambda c: (c.parentId, c.parentPos))\n  coldict = {t: list(cols) for t, cols in itertools.groupby(collist, lambda r: r.parentId)}\n\n  # Translate reverseCol in metadata to reverseColId in schema structure.\n  reverse_col_id = get_reverse_col_id_lookup_func(collist)\n\n  for t in actions.transpose_bulk_action(meta_tables):\n    columns = OrderedDict(\n        (c.colId, SchemaColumn(c.colId, c.type, bool(c.isFormula), c.formula, reverse_col_id(c)))\n        for c in coldict[t.id])\n    schema[t.tableId] = SchemaTable(t.tableId, columns)\n  return schema\n"
  },
  {
    "path": "sandbox/grist/sort_key.py",
    "content": "from numbers import Number\n\ndef make_sort_key(table, sort_spec):\n  \"\"\"\n  table: Table object from table.py\n  sort_spec: tuple of column IDs, optionally prefixed by '-' to invert the sort order.\n\n  Returns a key class for comparing row_ids, i.e. with the returned SortKey, the expression\n  SortKey(r1) < SortKey(r2) is true iff r1 comes before r2 according to sort_spec.\n\n  The returned SortKey also allows comparing values that aren't in the table:\n  SortKey(row_id, (v1, v2, ...)) will act as if the values of the columns mentioned in\n  sort_spec are v1, v2, etc.\n  \"\"\"\n  col_sort_spec = []\n  for col_spec in sort_spec:\n    col_id, sign = (col_spec[1:], -1) if col_spec.startswith('-') else (col_spec, 1)\n    col_obj = table.get_column(col_id)\n    col_sort_spec.append((col_obj, sign))\n\n  class SortKey(object):\n    __slots__ = (\"row_id\", \"values\")\n\n    def __init__(self, row_id, values=None):\n      # When values are provided, row_id is not used for access but is used for comparison, so\n      # must still be comparable to any valid row_id (e.g. must not be None). We use\n      # +-sys.float_info.max in records.py for this.\n      self.row_id = row_id\n      self.values = values or tuple(c.get_cell_value(row_id) for (c, _) in col_sort_spec)\n\n    def __lt__(self, other):\n      for (a, b, (col_obj, sign)) in zip(self.values, other.values, col_sort_spec):\n        try:\n          if a < b:\n            return sign == 1\n          if b < a:\n            return sign == -1\n        except TypeError:\n          # Use fallback values to maintain order similar to Python2 (this matches the fallback\n          # logic in SafeSortKey in column.py).\n          # - None is less than everything else\n          # - Numbers are less than other types\n          # - Other types are ordered by type name\n          af = ( (0 if a is None else 1), (0 if isinstance(a, Number) else 1), type(a).__name__ )\n          bf = ( (0 if b is None else 1), (0 if isinstance(b, Number) else 1), type(b).__name__ )\n          if af < bf:\n            return sign == 1\n          if bf < af:\n            return sign == -1\n\n      # Fallback order is by ascending row_id.\n      return self.row_id < other.row_id\n\n  return SortKey\n"
  },
  {
    "path": "sandbox/grist/sort_specs.py",
    "content": "COL_SEPARATOR = \":\"\n\n\"\"\"\nHelper module for sort expressions.\nSort expressions are encoded as a positive number for ascending column,\nnegative number for descending column. Can also be encoded as strings in a form:\n'-1:flag' or '1:flag;flag'\nFlags can be:\n- emptyLast to put empty values at the end.\n- orderByChoice: to order column by choice entry index rather then choice value.\n- naturalSort: to treat strings containing numbers as numbers and sort them accordingly.\n\"\"\"\n\ndef col_ref(col_spec):\n  \"\"\"\n  Gets column row id from column expression\n  \"\"\"\n  return abs(col_spec if isinstance(col_spec, int) else int(col_spec.split(COL_SEPARATOR)[0]))\n\ndef direction(col_spec):\n  \"\"\"\n  Gets direction for column expression (1 for ascending - 1 for descending).\n  \"\"\"\n  if isinstance(col_spec, int):\n    return 1 if col_spec >= 0 else -1\n  else:\n    assert col_spec\n    return 1 if col_spec[0] != \"-\" else -1\n\ndef swap_col_ref(col_spec, new_col_ref):\n  \"\"\"\n  Swaps colRef in colSpec preserving direction and options (used for display columns).\n  \"\"\"\n  new_spec = direction(col_spec) * new_col_ref\n  if isinstance(col_spec, int):\n    return new_spec\n  else:\n    parts = col_spec.split(COL_SEPARATOR)\n    parts[0] = str(new_spec)\n    return COL_SEPARATOR.join(parts)\n"
  },
  {
    "path": "sandbox/grist/summary.py",
    "content": "from collections import namedtuple\nimport json\nimport logging\n\nfrom column import is_visible_column\nimport sort_specs\n\nlog = logging.getLogger(__name__)\n\nColInfo = namedtuple('ColInfo', ('colId', 'type', 'isFormula', 'formula',\n                                 'widgetOptions', 'label'))\n\n\ndef make_col_info(col=None, **values):\n  \"\"\"Return a ColInfo() with the given fields, optionally copying values from the given column.\"\"\"\n  for key in ColInfo._fields:\n    values.setdefault(key, getattr(col, key) if col else None)\n  return ColInfo(**values)\n\ndef _make_sum_col_info(col):\n  \"\"\"Return a ColInfo() for the sum formula column for column col.\"\"\"\n  return make_col_info(col=col, isFormula=True,\n                        formula='SUM($group.%s)' % col.colId)\n\n\ndef get_colinfo_dict(col_info, with_id=False):\n  \"\"\"Return a dict suitable to use with AddColumn or AddTable (when with_id=True) actions.\"\"\"\n  col_values = {k: v for k, v in col_info._asdict().items()\n                     if v is not None and k != 'colId'}\n  if with_id:\n    col_values['id'] = col_info.colId\n  return col_values\n\n\ndef skip_rules_update(col, col_values):\n  \"\"\"\n  Rules for summary tables can't be derived from source columns. This function\n  removes (and kips original) rules settings when updating summary tables.\n  \"\"\"\n\n  # Remove rules from updates.\n  col_values = {k: v for k, v in col_values.items() if k != 'rules'}\n\n  try:\n    # New widgetOptions to use.\n    new_widgetOptions = json.loads(col_values.get('widgetOptions', ''))\n  except ValueError:\n    # If we are not updating widgetOptions (or they are\n    # not a valid json string, i.e. in tests), just return the original updates.\n    return col_values\n\n  try:\n    # Original widgetOptions (maybe with styling rules \"ruleOptions\").\n    widgetOptions = json.loads(col.widgetOptions or '')\n  except ValueError:\n    widgetOptions = {}\n\n  # Keep the original rulesOptions if any, and ignore any new one.\n  new_widgetOptions.pop(\"rulesOptions\", \"\")\n  rulesOptions = widgetOptions.get('rulesOptions')\n  if rulesOptions:\n    new_widgetOptions['rulesOptions'] = rulesOptions\n\n  col_values['widgetOptions'] = json.dumps(new_widgetOptions)\n  return col_values\n\n\ndef _copy_widget_options(options):\n  \"\"\"Copies widgetOptions for a summary group-by column (omitting conditional formatting rules)\"\"\"\n  if not options:\n    return options\n  try:\n    options = json.loads(options)\n  except ValueError:\n    # widgetOptions are not always a valid json value (especially in tests)\n    return options\n  return json.dumps({k: v for k, v in options.items() if k != \"rulesOptions\"})\n\n\ndef encode_summary_table_name(source_table_id, groupby_col_ids):\n  \"\"\"\n  Create a summary table name based on the source table ID and the groupby column IDs.\n  \"\"\"\n  result = source_table_id + '_summary'\n  if groupby_col_ids:\n    result += '_' + '_'.join(sorted(groupby_col_ids))\n  return result\n\n\ndef decode_summary_table_name(summary_table_info):\n  \"\"\"\n  Extract the name of the source table from the summary table schema info.\n  \"\"\"\n  # To generate code, we need to know for each summary table, what its source table is. It would be\n  # easy if we had access to metadata records, but (at least for now) we generate all code based on\n  # schema only. So we use the type of special 'group' column in the summary table.\n  group_col = summary_table_info.columns.get('group')\n  if (\n      group_col\n      and 'getSummarySourceGroup' in group_col.formula\n      and group_col.type.startswith('RefList:')\n  ):\n    return group_col.type[8:]\n  return None\n\n\ndef _group_colinfo(source_table):\n  \"\"\"Returns ColInfo() for the 'group' column that must be present in every summary table.\"\"\"\n  return make_col_info(colId='group', type='RefList:%s' % source_table.tableId,\n                        isFormula=True, formula='table.getSummarySourceGroup(rec)')\n\n\ndef _update_sort_spec(sort_spec, old_table, new_table):\n  \"\"\"\n  Replace column references in the sort spec (which is a JSON string encoding a list of column\n  refs, negated for descending) with references to the new table. Returns the new JSON string,\n  or empty string in case of a problem.\n  \"\"\"\n  old_cols_map = {c.id: c.colId for c in old_table.columns}\n  new_cols_map = {c.colId: c.id for c in new_table.columns}\n\n  # When adjusting, we take a possibly negated old colRef, and produce a new colRef.\n  # If anything is gone, we return 0, which will be excluded from the new sort spec.\n  def adjust(col_spec):\n    old_colref = sort_specs.col_ref(col_spec)\n    new_colref = new_cols_map.get(old_cols_map.get(old_colref), 0)\n    return sort_specs.swap_col_ref(col_spec, new_colref)\n\n  try:\n    old_sort_spec = json.loads(sort_spec)\n    new_sort_spec = [adjust(col_spec) for col_spec in old_sort_spec]\n    new_sort_spec = [col_spec for col_spec in new_sort_spec if sort_specs.col_ref(col_spec)]\n    return json.dumps(new_sort_spec, separators=(',', ':'))\n  except Exception:\n    log.warning(\"update_summary_section: can't parse sortColRefs JSON; clearing sortColRefs\")\n    return ''\n\n\ndef summary_groupby_col_type(source_type):\n  \"\"\"\n  Returns the type of a groupby column in a summary table\n  given the type of the corresponding column in the source table.\n  Most types are returned unchanged.\n  When a source table is grouped by a list-type (RefList/ChoiceList) column\n  the column is 'flattened' into the corresponding non-list type\n  in the summary table.\n  \"\"\"\n  if source_type == 'ChoiceList':\n    return 'Choice'\n  else:\n    return source_type.replace('RefList:', 'Ref:')\n\n\nclass SummaryActions(object):\n\n  def __init__(self, useractions, docmodel):\n    self.useractions = useractions\n    self.docmodel = docmodel\n\n  def _get_or_add_columns(self, table, all_colinfo):\n    \"\"\"\n    Given a table record and a list of ColInfo objects, generates a list of corresponding column\n    records in the table, creating appropriate columns if they don't yet exist.\n    \"\"\"\n    prior = {c.colId: c for c in table.columns}\n    for ci in all_colinfo:\n      col = prior.get(ci.colId)\n      if col and col.formula == ci.formula:\n        yield col\n      else:\n        result = self.useractions.doAddColumn(table.tableId, ci.colId,\n                                              get_colinfo_dict(ci, with_id=False))\n        yield self.docmodel.columns.table.get_record(result['colRef'])\n\n\n  def _get_or_create_summary(self, source_table, source_groupby_columns, formula_colinfo):\n    \"\"\"\n    Finds a summary table or creates a new one, based on source_table, grouped by the columns\n    in groupby_colinfo, and containing formulas in formula_colinfo. Source_table should be a\n    Record from _grist_Tables, and other arguments should be lists of ColInfo objects.\n    Returns the tuple (summary_table, groupby_columns, formula_columns).\n    \"\"\"\n    key = tuple(sorted(int(c) for c in source_groupby_columns))\n\n    groupby_colinfo = [\n      make_col_info(\n        col=c,\n        isFormula=False,\n        formula='',\n        widgetOptions=_copy_widget_options(c.widgetOptions),\n        type=summary_groupby_col_type(c.type)\n      )\n      for c in source_groupby_columns\n    ]\n    summary_table = next((t for t in source_table.summaryTables if t.summaryKey == key), None)\n    created = False\n    if not summary_table:\n      groupby_col_ids = [c.colId for c in groupby_colinfo]\n      result = self.useractions.doAddTable(\n        encode_summary_table_name(source_table.tableId, groupby_col_ids),\n        [get_colinfo_dict(ci, with_id=True) for ci in groupby_colinfo + formula_colinfo],\n        summarySourceTableRef=source_table.id,\n        raw_section=True,\n        record_card_section=False)\n      summary_table = self.docmodel.tables.table.get_record(result['id'])\n      created = True\n      # Note that in this case, _get_or_add_columns() below should not add any new columns,\n      # but only return existing ones. (The table may contain extra columns, e.g. 'manualSort',\n      # at least in theory.)\n\n    groupby_columns = list(self._get_or_add_columns(summary_table, groupby_colinfo))\n    formula_columns = list(self._get_or_add_columns(summary_table, formula_colinfo))\n\n    if created:\n      # Set the summarySourceCol field for all the group-by columns in the table.\n      self.docmodel.update(groupby_columns,\n                           summarySourceCol=[c.id for c in source_groupby_columns],\n                           visibleCol=[c.visibleCol for c in source_groupby_columns])\n      for col in groupby_columns:\n        self.useractions.maybe_copy_display_formula(col.summarySourceCol, col)\n      assert summary_table.summaryKey == key\n\n    return (summary_table, groupby_columns, formula_columns)\n\n\n  def update_summary_section(self, view_section, source_table, source_groupby_columns):\n    source_groupby_colset = set(source_groupby_columns)\n    groupby_colids = {c.colId for c in source_groupby_columns}\n\n    # Go through columns figuring out which ones we'll keep.\n    prev_group_cols, formula_colinfo = [], []\n    for col in view_section.tableRef.columns:\n      srcCol = col.summarySourceCol\n      # Records implement __hash__, so we can look them up in sets.\n      if srcCol in source_groupby_colset:\n        prev_group_cols.append(col)\n      elif col.isFormula and col.colId not in groupby_colids:\n        formula_colinfo.append(make_col_info(col))\n      else:\n        # if user is removing a numeric column from the group by columns we must add it back as a\n        # sum formula column\n        self._append_sister_column_if_any(formula_colinfo, source_table, srcCol)\n\n    # All fields with a column that we don't keep, must be deleted\n    colid_keep_set = set(c.colId for c in prev_group_cols + formula_colinfo)\n    delete_fields = [f for f in view_section.fields if f.colRef.colId not in colid_keep_set]\n\n    have_group_col = any(ci.colId == 'group' for ci in formula_colinfo)\n    if not have_group_col:\n      formula_colinfo.append(_group_colinfo(source_table))\n\n    # Get column records for all the columns we should have in our section.\n    summary_table, groupby_columns, formula_columns = self._get_or_create_summary(\n      source_table, source_groupby_columns, formula_colinfo)\n\n    if not have_group_col:\n      # We've added the \"group\" column; now restore the lists to match what we want in fields.\n      formula_colinfo.pop()\n      formula_columns.pop()\n\n    # Remember the original table, which we need later to adjust the sort spec (sortColRefs).\n    orig_table = view_section.tableRef\n\n    # This line is a bit hard to explain: we unset viewSection.tableRef before updating all the\n    # fields, and then set it to the correct value. Note how undo will reverse the operations, and\n    # produce the same sequence (unset, update fields, set). Client-side code relies on this to\n    # avoid having to deal with inconsistent view sections while fields are being updated.\n    self.docmodel.update([view_section], tableRef=0)\n\n    # Delete fields no longer relevant.\n    self.docmodel.remove(delete_fields)\n\n    # Update fields for all formula fields and reused group-by fields to point to new columns.\n    colid_to_field_map = {field.colRef.colId: field for field in view_section.fields}\n    prev_group_fields = [\n      colid_to_field_map[col.colId] for col in prev_group_cols\n      if col.colId in colid_to_field_map\n    ]\n    source_col_map = dict(zip(source_groupby_columns, groupby_columns))\n    prev_group_columns = [source_col_map[f.colRef.summarySourceCol] for f in prev_group_fields]\n    visible_formula_columns = [c for c in formula_columns if c.colId in colid_to_field_map]\n    formula_fields = [colid_to_field_map[c.colId] for c in visible_formula_columns]\n    self.docmodel.update(formula_fields + prev_group_fields,\n                         colRef=[c.id for c in visible_formula_columns + prev_group_columns])\n\n    # Finally, we need to create fields for newly-added group-by columns. If there were missing\n    # fields for any group-by columns before, they'll be created now.\n    new_group_columns = [c for c in groupby_columns if c not in prev_group_columns]\n\n    # Insert these after the last existing group-by field.\n    insert_pos = prev_group_fields[-1].parentPos if prev_group_fields else None\n    new_group_fields = self.docmodel.insert_after(view_section.fields, insert_pos,\n                                                  colRef=[c.id for c in new_group_columns])\n\n    # Reorder the group-by fields if needed, to match the order requested.\n    group_col_to_field = {f.colRef: f for f in prev_group_fields + new_group_fields}\n    group_fields = [group_col_to_field[c] for c in groupby_columns]\n    group_positions = [field.parentPos for field in group_fields]\n    sorted_positions = sorted(group_positions)\n    if sorted_positions != group_positions:\n      self.docmodel.update(group_fields, parentPos=sorted_positions)\n\n    update_args = {}\n    if view_section.sortColRefs:\n      # Fix the sortSpec to refer to the new columns.\n      update_args['sortColRefs'] = _update_sort_spec(\n        view_section.sortColRefs, orig_table, summary_table)\n\n    # Finally update the section to point to the new table.\n    self.docmodel.update([view_section], tableRef=summary_table.id, **update_args)\n\n\n  def _find_sister_column(self, source_table, col_id):\n    \"\"\"Returns a summary formula column for source_table with the given col_id, or None.\"\"\"\n    for t in source_table.summaryTables:\n      c = self.docmodel.columns.lookupOne(parentId=t.id, colId=col_id, isFormula=True)\n      if c:\n        return c\n    return None\n\n  def _append_sister_column_if_any(self, all_colinfo, source_table, col):\n    \"\"\"\n    Appends a col info for one sister column of col (in source_table) if it finds one, else, and if\n    col is of numeric type appends the col info for the sum col, else do nothing.\n    \"\"\"\n    c = self._find_sister_column(source_table, col.colId)\n    if c:\n      all_colinfo.append(make_col_info(col=c))\n    elif col.type in ('Int', 'Numeric'):\n      all_colinfo.append(_make_sum_col_info(col))\n\n\n  def _create_summary_colinfo(self, source_table, source_groupby_columns):\n    \"\"\"Come up automatically with a list of columns to include into a summary table.\"\"\"\n    # Column 'group' defines the group of records that map to this summary line.\n    all_colinfo = [_group_colinfo(source_table)]\n\n    # For every column in the source data, if there is a same-named formula column in another\n    # summary table, use it here; otherwise if it's a numerical column, automatically add a\n    # same-named column with the sum of the values in the group.\n    groupby_col_ids = {c.colId for c in source_groupby_columns}\n    for col in source_table.columns:\n      if col.colId in groupby_col_ids or col.colId == 'group' or not is_visible_column(col.colId):\n        continue\n      self._append_sister_column_if_any(all_colinfo, source_table, col)\n\n    # Add a default 'count' column for the number of records in the group, unless a different\n    # 'count' was already added (which we would then prefer as presumably more useful). We add the\n    # default 'count' right after 'group', to make it the first of the visible formula columns.\n    if not any(c.colId == 'count' for c in all_colinfo):\n      all_colinfo.insert(1, make_col_info(colId='count', type='Int',\n                                           isFormula=True, formula='len($group)'))\n    return all_colinfo\n\n\n  def create_new_summary_section(self, source_table, source_groupby_columns, view, section_type):\n    formula_colinfo = list(self._create_summary_colinfo(source_table, source_groupby_columns))\n    summary_table, groupby_columns, formula_columns = self._get_or_create_summary(\n      source_table, source_groupby_columns, formula_colinfo)\n\n    section = self.docmodel.add(view.viewSections, tableRef=summary_table.id,\n                                parentKey=section_type)[0]\n    self.docmodel.add(section.fields,\n                      colRef=[c.id for c in groupby_columns + formula_columns\n                              if c.colId != \"group\"])\n    return section\n\n\n  def detach_summary_section(self, view_section):\n    \"\"\"\n    Create a real table equivalent to the given summary section, and update the section to show\n    the new table instead of the summary.\n    \"\"\"\n    source_table_id = view_section.tableRef.summarySourceTable.tableId\n\n    # Get a list of columns that we need for the new table.\n    fields = view_section.fields\n    field_col_recs = [f.colRef for f in fields]\n\n    # Prepare the column info for each column.\n    col_info = [make_col_info(col=c) for c in field_col_recs if c.colId != 'group']\n\n    # Prepare the 'group' column, which is that one column that's different from the original.\n    group_args = ', '.join(\n      '%s=%s' % (\n        c.summarySourceCol.colId,\n        (\n          'CONTAINS($%s, match_empty=\"\")' if c.summarySourceCol.type == 'ChoiceList' else\n          'CONTAINS($%s, match_empty=0)' if c.summarySourceCol.type.startswith('Reflist') else\n          '$%s'\n        ) % c.colId,\n      )\n      for c in field_col_recs if c.summarySourceCol\n    )\n    col_info.append(make_col_info(colId='group', type='RefList:%s' % source_table_id,\n                                   isFormula=True,\n                                   formula='%s.lookupRecords(%s)' % (source_table_id, group_args)))\n\n    # Create the new table.\n    res = self.useractions.AddTable(None, [get_colinfo_dict(ci, with_id=True) for ci in col_info])\n    new_table = self.docmodel.tables.table.get_record(res[\"id\"])\n\n    # Remember the original table, which we need later e.g. to adjust the sort spec (sortColRefs).\n    orig_table = view_section.tableRef\n\n    # Populate the new table.\n    old_data = self.useractions._engine.fetch_table(orig_table.tableId, formulas=False)\n    self.useractions.ReplaceTableData(new_table.tableId, old_data.row_ids, old_data.columns)\n\n    # Unset viewSection.tableRef before updating the fields, to avoid having inconsistencies. (See\n    # longer explanation in update_summary_section().)\n    self.docmodel.update([view_section], tableRef=0)\n\n    # Update all fields to point to new columns.\n    new_col_dict = {c.colId: c.id for c in new_table.columns}\n    self.docmodel.update(fields, colRef=[new_col_dict[c.colId] for c in field_col_recs])\n\n    # If the section is sorted, fix the sortSpec to refer to the new columns.\n    update_args = {}\n    if view_section.sortColRefs:\n      update_args['sortColRefs'] = _update_sort_spec(\n        view_section.sortColRefs, orig_table, new_table)\n\n    # Update the section to point to the new table.\n    self.docmodel.update([view_section], tableRef=new_table.id, **update_args)\n"
  },
  {
    "path": "sandbox/grist/table.py",
    "content": "import collections\nimport itertools\nimport logging\nimport types\nimport column\nimport depend\nimport docmodel\nimport functions\nimport lookup\nfrom records import adjust_record, Record as BaseRecord, RecordSet as BaseRecordSet\nimport relation as relation_module    # \"relation\" is used too much as a variable name below.\nimport usertypes\n\nlog = logging.getLogger(__name__)\n\n\ndef get_default_func_name(col_id):\n  return \"_default_\" + col_id\n\ndef get_validation_func_name(index):\n  return \"validation___%d\" % index\n\nclass UserTable(object):\n  \"\"\"\n  Each data table in the document is represented in the code by an instance of `UserTable` class.\n  These names are always capitalized. A UserTable provides access to all the records in the table,\n  as well as methods to look up particular records.\n\n  Every table in the document is available to all formulas.\n  \"\"\"\n  # UserTables are only created in auto-generated code by using UserTable as decorator for a table\n  # model class. I.e.\n  #\n  #   @grist.UserTable\n  #   class Students:\n  #     ...\n  #\n  # makes the \"Students\" identifier an actual UserTable instance, so that Students.lookupRecords\n  # and so on can be used.\n\n  def __init__(self, model_class):\n    docmodel.enhance_model(model_class)\n    self.Model = model_class\n    self.table = None\n\n  def _set_table_impl(self, table_impl):\n    self.table = table_impl\n\n  @property\n  def Record(self):\n    return self.table.Record\n\n  @property\n  def RecordSet(self):\n    return self.table.RecordSet\n\n  # Note these methods are named camelCase since they are a public interface exposed to formulas,\n  # and we decided camelCase was a more user-friendly choice for user-facing functions.\n  def lookupRecords(self, **field_value_pairs):\n    \"\"\"\n    Name: lookupRecords\n    Usage: UserTable.__lookupRecords__(Field_In_Lookup_Table=value, ...)\n    Returns a [RecordSet](#recordset) matching the given field=value arguments. The value may be\n    any expression,\n    most commonly a field in the current row (e.g. `$SomeField`) or a constant (e.g. a quoted string\n    like `\"Some Value\"`) (examples below).\n\n    For example:\n    ```\n    People.lookupRecords(Email=$Work_Email)\n    People.lookupRecords(First_Name=\"George\", Last_Name=\"Washington\")\n    ```\n\n    You may set the optional `order_by` parameter to the column ID by which to sort the results.\n    You can prefix the column ID with \"-\" to reverse the order. You can also specify multiple\n    column IDs as a tuple (e.g. `order_by=(\"Account\", \"-Date\")`).\n\n    For example:\n    ```\n    Transactions.lookupRecords(Account=$Account, order_by=\"Date\")\n    Transactions.lookupRecords(Account=$Account, order_by=\"-Date\")\n    Transactions.lookupRecords(Active=True, order_by=(\"Account\", \"-Date\"))\n    ```\n\n    For records with equal `order_by` fields, the results are sorted according to how they appear\n    in views (which is determined by the special `manualSort` column). You may set `order_by=None`\n    to match the order of records in unsorted views.\n\n    By default, with no `order_by`, records are sorted by row ID, as if with `order_by=\"id\"`.\n\n    For backward compatibility, `sort_by` may be used instead of `order_by`, but only allows a\n    single field, and falls back to row ID (rather than `manualSort`).\n\n    See [RecordSet](#recordset) for useful properties offered by the returned object. In\n    particular, methods like [`.find.le`](#find_) allow searching for nearest values.\n\n    See [CONTAINS](#contains) for an example utilizing `UserTable.lookupRecords` to find records\n    where a field of a list type (such as `Choice List` or `Reference List`) contains the given\n    value.\n\n    Learn more about [lookupRecords](references-lookups.md#lookuprecords).\n    \"\"\"\n    return self.table.lookup_records(**field_value_pairs)\n\n  def lookupOne(self, **field_value_pairs):\n    # pylint: disable=line-too-long\n    \"\"\"\n    Name: lookupOne\n    Usage: UserTable.__lookupOne__(Field_In_Lookup_Table=value, ...)\n    Returns a [Record](#record) matching the given field=value arguments. The value may be any\n    expression,\n    most commonly a field in the current row (e.g. `$SomeField`) or a constant (e.g. a quoted string\n    like `\"Some Value\"`).\n\n    For example:\n    ```\n    People.lookupOne(First_Name=\"Lewis\", Last_Name=\"Carroll\")\n    People.lookupOne(Email=$Work_Email)\n    ```\n\n    Learn more about [lookupOne](references-lookups.md#lookupone).\n\n    If multiple records are found, the first match is returned. You may set the optional `order_by`\n    parameter to the column ID by which to sort the matches, to determine which of them is\n    returned as the first one. By default, the record with the lowest row ID is returned.\n\n    See [`lookupRecords`](#lookuprecords) for details of all available options and behavior of\n    `order_by` (and of its legacy alternative, `sort_by`).\n\n    For example:\n    ```\n    Tasks.lookupOne(Project=$id, order_by=\"Priority\")  # Task with the smallest Priority.\n    Rates.lookupOne(Person=$id, order_by=\"-Date\")      # Rate with the latest Date.\n    ```\n    \"\"\"\n    return self.table.lookup_one_record(**field_value_pairs)\n\n  def lookupOrAddDerived(self, **kwargs):\n    return self.table.lookupOrAddDerived(**kwargs)\n\n  def getSummarySourceGroup(self, rec):\n    return self.table.getSummarySourceGroup(rec)\n\n  @property\n  def all(self):\n    \"\"\"\n    Name: all\n    Usage: UserTable.__all__\n\n    The list of all the records in this table.\n\n    For example, this evaluates to the number of records in the table `Students`.\n    ```\n    len(Students.all)\n    ```\n\n    This evaluates to the sum of the `Population` field for every record in the table `Countries`.\n    ```\n    sum(r.Population for r in Countries.all)\n    ```\n    \"\"\"\n    return self.lookupRecords()\n\n  def __dir__(self):\n    # Suppress member properties when listing dir(TableClass). This affects rlcompleter, with the\n    # result that auto-complete will only return class properties, not member properties added in\n    # the constructor.\n    return []\n\n  def __getattr__(self, item):\n    if self.table.has_column(item):\n      raise AttributeError(\n        \"To retrieve all values in a column, use `{table_id}.all.{item}`. \"\n        \"Tables have no attribute '{item}'\".format(table_id=self.table.table_id, item=item)\n      )\n    super(UserTable, self).__getattribute__(item)\n\n  def __iter__(self):\n    raise TypeError(\n      \"To iterate (loop) over all records in a table, use `{table_id}.all`. \"\n      \"Tables are not directly iterable.\".format(table_id=self.table.table_id)\n    )\n\n\nclass Table(object):\n  \"\"\"\n  Table represents a table with all its columns and data.\n  \"\"\"\n\n  class RowIDs(object):\n    \"\"\"\n    Helper container that represents the set of valid row IDs in this table.\n    \"\"\"\n    def __init__(self, id_column):\n      self._id_column = id_column\n\n    def __contains__(self, row_id):\n      return 0 < row_id < self._id_column.size() and self._id_column.raw_get(row_id) > 0\n\n    def __iter__(self):\n      for row_id in range(self._id_column.size()):\n        if self._id_column.raw_get(row_id) > 0:\n          yield row_id\n\n    def max(self):\n      last = self._id_column.size() - 1\n      while last > 0 and last not in self:\n        last -= 1\n      return last\n\n\n  def __init__(self, table_id, engine):\n    # The id of the table is the name of its class.\n    self.table_id = table_id\n\n    # Each table maintains a reference to the engine that owns it.\n    self._engine = engine\n\n    # The UserTable object for this table, set in _rebuild_model\n    self.user_table = None\n\n    # Store the identity Relation for this table.\n    self._identity_relation = relation_module.IdentityRelation(table_id)\n\n    # Set of ReferenceColumn objects that refer to this table\n    self._back_references = set()\n\n    # Maps the depend.Node of the source column (possibly in a different table) to column(s) in\n    # this table that have that source column as reverseColRef: {Node: [Column, ...]}.\n    self._reverse_cols_by_source_node = {}\n\n    # Store the constant Node for \"new columns\". Accessing invalid columns creates a dependency\n    # on this node, and triggers recomputation when columns are added or renamed.\n    self._new_columns_node = depend.Node(self.table_id, None)\n\n    # Collection of special columns that this table maintains, which include LookupMapColumns\n    # and formula columns for maintaining summary tables. These persist across table rebuilds, and\n    # get cleaned up with delete_column().\n    self._special_cols = {}\n\n    # Maintain Column objects both as a mapping from col_id and as an ordered list.\n    self.all_columns = collections.OrderedDict()\n\n    # This column is always present.\n    self._id_column = column.create_column(self, 'id', column.get_col_info(usertypes.Id()))\n\n    # The `row_ids` member offers some useful interfaces:\n    #     * if row_id in table.row_ids\n    #     * for row_id in table.row_ids\n    self.row_ids = self.RowIDs(self._id_column)\n\n    # For a summary table, this is a reference to the Table object for the source table.\n    self._summary_source_table = None\n\n    # For a summary table, the name of the special helper column auto-added to the source table.\n    self._summary_helper_col_id = None\n\n    # For a summary table, True in the common case where every source record belongs\n    # to just one group in the summary table, False if grouping by list columns\n    # which are 'flattened' so source records may appear in multiple groups\n    self._summary_simple = None\n\n    # Add Record and RecordSet subclasses with correct `_table` attribute, which will also hold a\n    # field attribute for each column.\n    class Record(BaseRecord):\n      __slots__ = ()\n      _table = self\n\n    class RecordSet(BaseRecordSet):\n      __slots__ = ()\n      _table = self\n\n    self.Record = Record\n    self.RecordSet = RecordSet\n\n    # For use in _num_rows. The attribute isn't strictly needed,\n    # but it makes _num_rows slightly faster, and only creating the lookup map when _num_rows\n    # is called seems to be too late, at least for unit tests.\n    self._empty_lookup_column = self._get_lookup_map(())\n\n  def _num_rows(self):\n    \"\"\"\n    Similar to `len(self.lookup_records())` but faster and doesn't create dependencies.\n    \"\"\"\n    return len(self._empty_lookup_column._do_fast_empty_lookup())\n\n  @property\n  def sample_record(self):\n    \"\"\"\n    Used for auto-completion as a record with correct properties of correct types.\n    \"\"\"\n    # Create a type with a property for each column. We use property-methods rather than\n    # plain attributes because this sample record is created before all tables have initialized, so\n    # reference values (using .sample_record for other tables) are not yet available.\n    props = {}\n    for col in self.all_columns.values():\n      if not (column.is_visible_column(col.col_id) or col.col_id == 'id'):\n        continue\n      # Note c=col to bind at lambda-creation time; see\n      # https://stackoverflow.com/questions/10452770/python-lambdas-binding-to-local-values\n      props[col.col_id] = property(lambda _self, c=col: c.sample_value())\n      if col.col_id == 'id':\n        # The column lookup below doesn't work for the id column\n        continue\n      # For columns with a visible column (i.e. most Reference/ReferenceList columns),\n      # we also want to show how to get that visible column instead of the 'raw' record\n      # returned by the reference column itself.\n      col_rec = self._engine.docmodel.get_column_rec(self.table_id, col.col_id)\n      visible_col_id = col_rec.visibleCol.colId\n      if visible_col_id:\n        # This creates a fake attribute like `RefCol.VisibleCol` which isn't valid syntax normally,\n        # to show the `.VisibleCol` part before the user has typed the `.`\n        props[col.col_id + \".\" + visible_col_id] = property(\n          lambda _self, c=col, v=visible_col_id: getattr(c.sample_value(), v)\n        )\n\n    RecType = type(self.table_id, (), props)\n    return RecType()\n\n  def _rebuild_model(self, user_table):\n    \"\"\"\n    Sets class-wide properties from a new Model class for the table (inner class within the table\n    class), and rebuilds self.all_columns from the new Model, reusing columns with existing names.\n    \"\"\"\n    self.user_table = user_table\n    self.Model = user_table.Model\n\n    new_cols = collections.OrderedDict()\n    new_cols['id'] = self._id_column\n\n    # List of Columns in the same order as they appear in the generated Model definition.\n    col_items = [c for c in self.Model.__dict__.items() if not c[0].startswith(\"_\")]\n    col_items.sort(key=lambda c: self._get_sort_order(c[1]))\n\n    for col_id, col_model in col_items:\n      default_func = self.Model.__dict__.get(get_default_func_name(col_id))\n      new_cols[col_id] = self._create_or_update_col(col_id, col_model, default_func)\n\n    # Note that we reuse previous special columns like lookup maps, since those not affected by\n    # column changes should stay the same. These get removed when unneeded using other means.\n    new_cols.update(sorted(self._special_cols.items()))\n\n    self._update_record_classes(self.all_columns, new_cols)\n\n    # Set the new columns.\n    self.all_columns = new_cols\n\n    # Make sure any new columns get resized to the full table size.\n    self.grow_to_max()\n\n    # If this is a summary table, auto-create a necessary helper formula in the source table.\n    summary_src = getattr(self.Model, '_summarySourceTable', None)\n    if summary_src not in self._engine.tables:\n      self._summary_source_table = None\n      self._summary_helper_col_id = None\n      self._summary_simple = None\n    else:\n      self._summary_source_table = self._engine.tables[summary_src]\n      self._summary_helper_col_id = \"#summary#%s\" % self.table_id\n      # Figure out the group-by columns: these are all the non-formula columns.\n      groupby_cols = tuple(sorted(col_id for (col_id, col_model) in col_items\n                                  if not isinstance(col_model, types.FunctionType)))\n      self._summary_simple = not any(\n        isinstance(\n          self._summary_source_table.all_columns.get(group_col),\n          (column.ChoiceListColumn, column.ReferenceListColumn)\n        )\n        for group_col in groupby_cols\n      )\n      # Add the special helper column to the source table.\n      self._summary_source_table._add_update_summary_col(self, groupby_cols)\n\n  def _add_update_summary_col(self, summary_table, groupby_cols):\n    # TODO: things need to be removed also from summary_cols when a summary table is deleted.\n\n    # Grouping by list columns is significantly more complex and this comes with a\n    # performance cost, so in the common case we use the simpler older implementation\n    # In particular _updateSummary returns (possibly creating) just one reference\n    # instead of a list, which getSummarySourceGroup looks up directly instead\n    # of using CONTAINS, which in turn allows using SimpleLookupMapColumn\n    # instead of the similarly slower and more complicated ContainsLookupMapColumn\n    # All of these branches should be interchangeable and produce equivalent results\n    # when no list columns or CONTAINS are involved,\n    # especially since we need to be able to summarise by a combination of list and non-list\n    # columns or lookupRecords with a combination of CONTAINS and normal values,\n    # these are just performance optimisations\n    if summary_table._summary_simple:\n      @usertypes.formulaType(usertypes.Reference(summary_table.table_id))\n      def _updateSummary(rec, table):  # pylint: disable=unused-argument\n        # summary table output should be treated as we treat formula columns, for acl purposes\n        with self._engine.user_actions.indirect_actions():\n          return summary_table.lookupOrAddDerived(**{c: getattr(rec, c) for c in groupby_cols})\n\n    else:\n      @usertypes.formulaType(usertypes.ReferenceList(summary_table.table_id))\n      def _updateSummary(rec, table):  # pylint: disable=unused-argument\n        # Create a row in the summary table for every combination of values in\n        # list type columns\n        lookup_values = []\n        for group_col in groupby_cols:\n          lookup_value = getattr(rec, group_col)\n          group_col_obj = self.all_columns[group_col]\n          if isinstance(group_col_obj, (column.ChoiceListColumn, column.ReferenceListColumn)):\n            # Check that ChoiceList/ReferenceList cells have appropriate types.\n            # Don't iterate over characters of a string.\n            if isinstance(lookup_value, (bytes, str)):\n              return []\n            try:\n              # We only care about the unique choices\n              lookup_value = set(lookup_value)\n            except TypeError:\n              return []\n\n            if not lookup_value:\n              if isinstance(group_col_obj, column.ChoiceListColumn):\n                lookup_value = {\"\"}\n              else:\n                lookup_value = {0}\n\n          else:\n            lookup_value = [lookup_value]\n          lookup_values.append(lookup_value)\n\n        result = []\n        values_to_add = {}\n        new_row_ids = []\n\n        for values_tuple in sorted(itertools.product(*lookup_values)):\n          values_dict = dict(zip(groupby_cols, values_tuple))\n          row_id = summary_table.lookup_one_record(**values_dict)._row_id\n          if row_id:\n            result.append(row_id)\n          else:\n            for col, value in values_dict.items():\n              values_to_add.setdefault(col, []).append(value)\n            new_row_ids.append(None)\n\n        if new_row_ids and not self._engine.is_triggered_by_table_action(summary_table.table_id):\n          # summary table output should be treated as we treat formula columns, for acl purposes\n          with self._engine.user_actions.indirect_actions():\n            result += self._engine.user_actions.BulkAddRecord(\n              summary_table.table_id, new_row_ids, values_to_add\n            )\n\n        return result\n\n    _updateSummary.is_private = True\n    col_id = summary_table._summary_helper_col_id\n    if self.has_column(col_id):\n      # If type changed between Reference/ReferenceList, replace completely.\n      # pylint: disable=unidiomatic-typecheck\n      if type(self.get_column(col_id).type_obj) != type(_updateSummary.grist_type):\n        self.delete_column(self.get_column(col_id))\n    col_obj = self._create_or_update_col(col_id, _updateSummary)\n    self._add_special_col(col_obj)\n\n  def get_helper_columns(self):\n    \"\"\"\n    Returns a list of columns from other tables that are only needed for the sake of this table.\n    \"\"\"\n    if self._summary_source_table and self._summary_helper_col_id:\n      helper_col = self._summary_source_table.get_column(self._summary_helper_col_id)\n      return [helper_col]\n    return []\n\n  def _create_or_update_col(self, col_id, col_model, default_func=None):\n    \"\"\"\n    Helper to update an existing column with a new model, or create a new column object.\n    \"\"\"\n    col_info = column.get_col_info(col_model, default_func)\n    col_obj = self.all_columns.get(col_id)\n    if col_obj:\n      # This is important for when a column has NOT changed, since although the formula method is\n      # unchanged, it's important to use the new instance of it from the newly built module.\n      col_obj.update_method(col_info.method)\n    else:\n      col_obj = column.create_column(self, col_id, col_info)\n      self._engine.invalidate_column(col_obj)\n    return col_obj\n\n  @staticmethod\n  def _get_sort_order(col_model):\n    \"\"\"\n    We sort columns according to the order in which they appear in the model definition. To\n    detect this order, we sort data columns by _creation_order, and formula columns by the\n    function's source-code line number.\n    \"\"\"\n    return ((0, col_model._creation_order)\n            if not isinstance(col_model, types.FunctionType) else\n            (1, col_model.__code__.co_firstlineno))\n\n  def next_row_id(self):\n    \"\"\"\n    Returns the ID of the next row that can be added to this table.\n    \"\"\"\n    return self.row_ids.max() + 1\n\n  def grow_to_max(self):\n    \"\"\"\n    Resizes all columns as needed so that all valid row_ids are valid indices into all columns.\n    \"\"\"\n    size = self.row_ids.max() + 1\n    for col_obj in self.all_columns.values():\n      col_obj.growto(size)\n\n  def get_column(self, col_id):\n    \"\"\"\n    Returns the column with the given column ID.\n    \"\"\"\n    return self.all_columns[col_id]\n\n  def has_column(self, col_id):\n    \"\"\"\n    Returns whether col_id represents a valid column in the table.\n    \"\"\"\n    return col_id in self.all_columns\n\n  def lookup_records(self, **kwargs):\n    \"\"\"\n    Returns a Record matching the given column=value arguments. It creates the necessary\n    dependencies, so that the formula will get re-evaluated if needed. It also creates and starts\n    maintaining a lookup index to make such lookups fast.\n    \"\"\"\n    # The tuple of keys used determines the LookupMap we need.\n    sort_by = kwargs.pop('sort_by', None)\n    order_by = kwargs.pop('order_by', 'id')   # For backward compatibility\n    key = []\n    col_ids = []\n    for col_id in sorted(kwargs):\n      value = kwargs[col_id]\n      if isinstance(value, lookup._Contains):\n        # While users should use CONTAINS on lookup values,\n        # the marker is moved to col_id so that the LookupMapColumn knows how to\n        # update its index correctly for that column.\n        col_id = value._replace(value=col_id)\n        value = value.value\n      else:\n        col = self.get_column(col_id)\n        # Convert `value` to the correct type of rich value for that column\n        value = col._convert_raw_value(col.convert(value))\n      key.append(value)\n      col_ids.append(col_id)\n    col_ids = tuple(col_ids)\n    key = tuple(key)\n\n    lookup_map = self._get_lookup_map(col_ids)\n    sort_spec = make_sort_spec(order_by, sort_by, self.has_column('manualSort'))\n    if sort_spec:\n      sorted_lookup_map = self._get_sorted_lookup_map(lookup_map, sort_spec)\n    else:\n      sorted_lookup_map = lookup_map\n\n    row_ids, rel = sorted_lookup_map.do_lookup(key)\n    return self.RecordSet(row_ids, rel, group_by=kwargs, sort_by=sort_by,\n        sort_key=sorted_lookup_map.sort_key)\n\n  def lookup_one_record(self, **kwargs):\n    return self.lookup_records(**kwargs).get_one()\n\n  def _get_lookup_map(self, col_ids_tuple):\n    \"\"\"\n    Helper which returns the LookupMapColumn for the given combination of lookup columns. A\n    LookupMap behaves a bit like a formula column in that it depends on the passed-in columns and\n    gets updated whenever any of them change.\n    \"\"\"\n    # LookupMapColumn is a Node, so identified by (table_id, col_id) pair, so we make up a col_id\n    # to identify this lookup object uniquely in this Table.\n    lookup_col_id = \"#lookup#\" + \":\".join(map(str, col_ids_tuple))\n    lmap = self._special_cols.get(lookup_col_id)\n    if not lmap:\n      # Check that the table actually has all the columns we looking up.\n      for c in col_ids_tuple:\n        c = lookup.extract_column_id(c)\n        if not self.has_column(c):\n          raise KeyError(\"Table %s has no column %s\" % (self.table_id, c))\n      lmap = lookup.LookupMapColumn(self, lookup_col_id, col_ids_tuple)\n      self._add_special_col(lmap)\n    return lmap\n\n  def _get_sorted_lookup_map(self, lookup_map, sort_spec):\n    helper_col_id = lookup_map.col_id + \"#\" + \":\".join(sort_spec)\n    # Find or create a helper col for the given sort_spec.\n    helper_col = self._special_cols.get(helper_col_id)\n    if not helper_col:\n      helper_col = lookup.SortedLookupMapColumn(self, helper_col_id, lookup_map, sort_spec)\n      self._add_special_col(helper_col)\n    return helper_col\n\n  def delete_column(self, col_obj):\n    assert col_obj.table_id == self.table_id\n    self._special_cols.pop(col_obj.col_id, None)\n    self.all_columns.pop(col_obj.col_id, None)\n    self._remove_field_from_record_classes(col_obj.col_id)\n\n  def _add_special_col(self, col_obj):\n    assert col_obj.table_id == self.table_id\n    self._special_cols[col_obj.col_id] = col_obj\n    self.all_columns[col_obj.col_id] = col_obj\n    self._add_field_to_record_classes(col_obj)\n\n  def lookupOrAddDerived(self, **kwargs):\n    record = self.lookup_one_record(**kwargs)\n    if not record._row_id and not self._engine.is_triggered_by_table_action(self.table_id):\n      record._row_id = self._engine.user_actions.AddRecord(self.table_id, None, kwargs)\n    return record\n\n  def getSummarySourceGroup(self, rec):\n    if self._summary_source_table:\n      # See comment in _add_update_summary_col.\n      # _summary_source_table._summary_simple determines whether\n      # the column named self._summary_helper_col_id is a single reference\n      # or a reference list.\n      lookup_value = rec if self._summary_simple else functions.CONTAINS(rec)\n      result = self._summary_source_table.lookup_records(**{\n        self._summary_helper_col_id: lookup_value\n      })\n\n      # Remove rows with empty groups\n      self._engine.docmodel.setAutoRemove(rec, not result)\n      return result\n    else:\n      return None\n\n  def get(self, **kwargs):\n    \"\"\"\n    Returns the first row_id matching the given column=value arguments. This is intended for grist\n    internal code rather than for user formulas, because it doesn't create the necessary\n    dependencies.\n    \"\"\"\n    # TODO: It should use indices, to avoid linear searching\n    # TODO: It should create dependencies as needed when used from formulas.\n    # TODO: It should return Record instead, for convenience of user formulas\n    col_values = [(self.all_columns[col_id], value) for (col_id, value) in kwargs.items()]\n    for row_id in self.row_ids:\n      if all(col.raw_get(row_id) == value for col, value in col_values):\n        return row_id\n    raise KeyError(\"'get' found no matching record\")\n\n  def filter(self, **kwargs):\n    \"\"\"\n    Generates all row_ids matching the given column=value arguments. This is intended for grist\n    internal code rather than for user formulas, because it doesn't create the necessary\n    dependencies. Use filter_records() to generate Record objects instead.\n    \"\"\"\n    # TODO: It should use indices, to avoid linear searching\n    # TODO: It should create dependencies as needed when used from formulas.\n    # TODO: It should return Record instead, for convenience of user formulas\n    col_values = [(self.all_columns[col_id], value) for (col_id, value) in kwargs.items()]\n    for row_id in self.row_ids:\n      if all(col.raw_get(row_id) == value for col, value in col_values):\n        yield row_id\n\n  def get_record(self, row_id):\n    \"\"\"\n    Returns a Record object corresponding to the given row_id. This is intended for grist internal\n    code rather than user formulas.\n    \"\"\"\n    # We don't set up any dependencies, so it would be incorrect to use this from formulas.\n    # We no longer assert, however, since such calls may still happen e.g. while applying\n    # user-actions caused by formula side-effects (e.g. as triggered by lookupOrAddDerived())\n    if row_id not in self.row_ids:\n      raise KeyError(\"'get_record' found no matching record\")\n    return self.Record(row_id, None)\n\n  def filter_records(self, **kwargs):\n    \"\"\"\n    Generator for Record objects for all the rows matching the given column=value arguments.\n    This is intended for grist internal code rather than user formula. You may call this with no\n    arguments to generate all Records in the table.\n    \"\"\"\n    # See note in get_record() about using this call from formulas.\n\n    for row_id in self.filter(**kwargs):\n      yield self.Record(row_id, None)\n\n\n  # TODO: document everything here.\n\n  # Equivalent to accessing record.foo, but only used in very limited cases now (field accessor is\n  # more optimized).\n  def _get_col_obj_value(self, col_obj, row_id, relation):\n    # creates a dependency and brings formula columns up-to-date.\n    self._engine._use_node(col_obj.node, relation, (row_id,))\n    value = col_obj.get_cell_value(row_id)\n    return adjust_record(relation, value)\n\n  def _attribute_error(self, col_id, relation):\n    self._engine._use_node(self._new_columns_node, relation)\n    raise AttributeError(\"Table '%s' has no column '%s'\" % (self.table_id, col_id))\n\n  # Called when record_set.foo is accessed\n  def _get_col_obj_subset(self, col_obj, row_ids, relation):\n    self._engine._use_node(col_obj.node, relation, row_ids)\n\n    # We construct and return a RecordSet if values are References or ReferenceLists. Match that\n    # behavior for empty lists of references (e.g. T.lookupRecords(...).RefCol).\n    if not row_ids and isinstance(col_obj, column.BaseReferenceColumn):\n      return col_obj._target_table.RecordSet([], None)\n\n    values = [col_obj.get_cell_value(row_id) for row_id in row_ids]\n\n    # When all the values are the same type of Record (i.e. all references to the same table)\n    # combine them into a single RecordSet for that table instead of a list\n    # so that more attribute accesses can be chained,\n    # e.g. record_set.foo.bar where `foo` is a Reference column.\n    value_types = list(set(map(type, values)))\n    if len(value_types) == 1 and issubclass(value_types[0], BaseRecord):\n      return values[0]._table.RecordSet(\n        # This is different from row_ids: these are the row IDs referenced by these Records,\n        # whereas row_ids are where the values were being stored.\n        [val._row_id for val in values],\n        relation.compose(values[0]._source_relation),\n      )\n    elif len(value_types) == 1 and issubclass(value_types[0], BaseRecordSet):\n      return col_obj._target_table.RecordSet(\n        col_obj.convert(values),\n        relation.compose(values[0]._source_relation),\n      )\n    else:\n      return [adjust_record(relation, value) for value in values]\n\n  #----------------------------------------\n\n  def _update_record_classes(self, old_columns, new_columns):\n    for col_id in old_columns:\n      if col_id not in new_columns:\n        self._remove_field_from_record_classes(col_id)\n\n    for col_id, col_obj in new_columns.items():\n      if col_obj != old_columns.get(col_id):\n        self._add_field_to_record_classes(col_obj)\n\n  def _add_field_to_record_classes(self, col_obj):\n    node = col_obj.node\n    use_node = self._engine._use_node\n\n    @property\n    def record_field(rec):\n      # This is equivalent to _get_col_obj_value(), but is extra-optimized with _get_col_obj_value()\n      # and adjust_record() inlined, since this is particularly hot code, called on every access of\n      # any data field in a formula.\n      use_node(node, rec._source_relation, (rec._row_id,))\n      value = col_obj.get_cell_value(rec._row_id)\n      if isinstance(value, (BaseRecord, BaseRecordSet)):\n        return value._clone_with_relation(rec._source_relation)\n      return value\n\n    @property\n    def recordset_field(recset):\n      return self._get_col_obj_subset(col_obj, recset._row_ids, recset._source_relation)\n\n    setattr(self.Record, col_obj.col_id, record_field)\n    setattr(self.RecordSet, col_obj.col_id, recordset_field)\n\n  def _remove_field_from_record_classes(self, col_id):\n    # Check if col_id is in the immediate dictionary of self.Record[Set]; if missing, or inherited\n    # from the base class (e.g. \"find\"), there is nothing to delete.\n    if col_id in self.Record.__dict__:\n      delattr(self.Record, col_id)\n    if col_id in self.RecordSet.__dict__:\n      delattr(self.RecordSet, col_id)\n\n\ndef make_sort_spec(order_by, sort_by, has_manual_sort):\n  # Note that rowId is always an automatic fallback.\n  if sort_by:\n    if not isinstance(sort_by, str):\n      # pylint: disable=line-too-long\n      raise TypeError(\"sort_by must be a string column ID, with optional '-'; use order_by for tuples\")\n    # No fallback to 'manualSort' here, for backward compatibility.\n    return (sort_by,)\n\n  if not isinstance(order_by, tuple):\n    # Suppot None and single-string specs (for a single column)\n    if isinstance(order_by, str):\n      order_by = (order_by,)\n    elif order_by is None:\n      order_by = ()\n    else:\n      raise TypeError(\"order_by must be a string column ID, with optional '-', or a tuple of them\")\n\n  # Check if 'id' is mentioned explicitly. If so, then no fallback to 'manualSort', or anything\n  # else, since row IDs are unique. Also, drop the 'id' column itself because the row ID fallback\n  # is mandatory and automatic.\n  if 'id' in order_by:\n    return order_by[:order_by.index('id')]\n\n  # Fall back to manualSort, but only if it exists in the table and not yet mentioned in order_by.\n  if has_manual_sort and 'manualSort' not in order_by:\n    return order_by + ('manualSort',)\n\n  return order_by\n"
  },
  {
    "path": "sandbox/grist/table_data_set.py",
    "content": "import logging\n\nimport actions\nfrom usertypes import get_type_default\n\nlog = logging.getLogger(__name__)\n\nclass TableDataSet(object):\n  \"\"\"\n  TableDataSet represents the full data of a Grist document as a dictionary mapping tableId to\n  actions.TableData. It then allows applying arbitrary doc-actions, and updates its representation\n  of the document accordingly. The dictionary is available as the object's `all_tables` member.\n\n  This is used, in particular, for migrations, which need to access data with minimal assumptions\n  about its interpretation.\n\n  Note that to initialize a TableDataSet, the schema is needed, so it should be done by applying\n  AddTable actions, followed by BulkAddRecord or ReplaceTableData actions.\n  \"\"\"\n\n  def __init__(self):\n    # Dictionary of { tableId: actions.TableData object }\n    self.all_tables = {}\n\n    # Dictionary of { tableId: { colId: values }} where values come from AddTable, as modified by\n    # Add/ModifyColumn actions.\n    self._schema = {}\n\n  def apply_doc_action(self, action):\n    try:\n      getattr(self, action.__class__.__name__)(*action)\n    except Exception as e:\n      log.warning(\"ERROR applying action %s: %s\", action, e)\n      raise\n\n  def apply_doc_actions(self, doc_actions):\n    for a in doc_actions:\n      self.apply_doc_action(a)\n    return doc_actions\n\n  def get_col_info(self, table_id, col_id):\n    return self._schema[table_id][col_id]\n\n  def get_schema(self):\n    return self._schema\n\n  #----------------------------------------\n  # Actions on records.\n  #----------------------------------------\n  def AddRecord(self, table_id, row_id, columns):\n    self.BulkAddRecord(table_id, [row_id], {key: [val] for key, val in columns.items()})\n\n  def BulkAddRecord(self, table_id, row_ids, columns):\n    table_data = self.all_tables[table_id]\n    table_data.row_ids.extend(row_ids)\n    for col, values in table_data.columns.items():\n      if col in columns:\n        values.extend(columns[col])\n      else:\n        col_info = self._schema[table_id][col]\n        default = get_type_default(col_info['type'])\n        values.extend([default] * len(row_ids))\n\n  def RemoveRecord(self, table_id, row_id):\n    return self.BulkRemoveRecord(table_id, [row_id])\n\n  def BulkRemoveRecord(self, table_id, row_ids):\n    table_data = self.all_tables[table_id]\n    remove_set = set(row_ids)\n    for col, values in table_data.columns.items():\n      values[:] = [v for r, v in zip(table_data.row_ids, values) if r not in remove_set]\n    table_data.row_ids[:] = [r for r in table_data.row_ids if r not in remove_set]\n\n  def UpdateRecord(self, table_id, row_id, columns):\n    self.BulkUpdateRecord(\n      table_id, [row_id], {key: [val] for key, val in columns.items()})\n\n  def BulkUpdateRecord(self, table_id, row_ids, columns):\n    table_data = self.all_tables[table_id]\n    rowid_map = {r:i for i, r in enumerate(table_data.row_ids)}\n    table_indices = [rowid_map[r] for r in row_ids]\n    for col, values in columns.items():\n      if col in table_data.columns:\n        col_values = table_data.columns[col]\n        for i, v in zip(table_indices, values):\n          col_values[i] = v\n\n  def ReplaceTableData(self, table_id, row_ids, columns):\n    table_data = self.all_tables[table_id]\n    del table_data.row_ids[:]\n    for col, values in table_data.columns.items():\n      del values[:]\n    self.BulkAddRecord(table_id, row_ids, columns)\n\n  #----------------------------------------\n  # Actions on columns.\n  #----------------------------------------\n\n  def AddColumn(self, table_id, col_id, col_info):\n    self._schema[table_id][col_id] = col_info\n    default = get_type_default(col_info['type'])\n    table_data = self.all_tables[table_id]\n    table_data.columns[col_id] = [default] * len(table_data.row_ids)\n\n  def RemoveColumn(self, table_id, col_id):\n    self._schema[table_id].pop(col_id, None)\n    table_data = self.all_tables[table_id]\n    table_data.columns.pop(col_id, None)\n\n  def RenameColumn(self, table_id, old_col_id, new_col_id):\n    self._schema[table_id][new_col_id] = self._schema[table_id].pop(old_col_id)\n    table_data = self.all_tables[table_id]\n    table_data.columns[new_col_id] = table_data.columns.pop(old_col_id)\n\n  def ModifyColumn(self, table_id, col_id, col_info):\n    self._schema[table_id][col_id].update(col_info)\n\n  #----------------------------------------\n  # Actions on tables.\n  #----------------------------------------\n  def AddTable(self, table_id, columns):\n    self.all_tables[table_id] = actions.TableData(table_id, [], {c['id']: [] for c in columns})\n    self._schema[table_id] = {c['id']: c.copy() for c in columns}\n\n  def RemoveTable(self, table_id):\n    del self.all_tables[table_id]\n    del self._schema[table_id]\n\n  def RenameTable(self, old_table_id, new_table_id):\n    table_data = self.all_tables.pop(old_table_id)\n    self.all_tables[new_table_id] = actions.TableData(new_table_id, table_data.row_ids,\n                                              table_data.columns)\n    self._schema[new_table_id] = self._schema.pop(old_table_id)\n"
  },
  {
    "path": "sandbox/grist/test_acl_formula.py",
    "content": "# -*- coding: utf-8 -*-\n# pylint:disable=line-too-long\n\nimport test_engine\n\nclass TestACLFormulaUserActions(test_engine.EngineTestCase):\n  def test_acl_actions(self):\n    # Adding or updating ACLRules automatically includes aclFormula compilation.\n\n    # Single Add\n    out_actions = self.apply_user_action(\n      ['AddRecord', '_grist_ACLRules', None, {\"resource\": 1, \"aclFormula\": \"user.UserID == 7\"}],\n    )\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"AddRecord\", \"_grist_ACLRules\", 1, {\"resource\": 1, \"aclFormula\": \"user.UserID == 7\",\n        \"aclFormulaParsed\": '[\"Eq\", [\"Attr\", [\"Name\", \"user\"], \"UserID\"], [\"Const\", 7]]',\n        \"rulePos\": 1.0\n      }],\n    ]})\n\n    # Single Update\n    out_actions = self.apply_user_action(\n      ['UpdateRecord', '_grist_ACLRules', 1, {\n        \"aclFormula\": \"user.UserID == 8\",\n        \"aclFormulaParsed\": \"hello\"\n      }],\n    )\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"UpdateRecord\", \"_grist_ACLRules\", 1, {\n        \"aclFormula\": \"user.UserID == 8\",\n        \"aclFormulaParsed\": '[\"Eq\", [\"Attr\", [\"Name\", \"user\"], \"UserID\"], [\"Const\", 8]]',\n      }],\n    ]})\n\n    # BulkAddRecord\n    out_actions = self.apply_user_action(['BulkAddRecord', '_grist_ACLRules', [None, None], {\n      \"resource\": [1, 1],\n      \"aclFormula\": [\"user.IsGood\", \"user.IsBad\"],\n      \"aclFormulaParsed\": [\"[1]\", '[\"ignored\"]'],   # Should get overwritten\n    }])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [ 'BulkAddRecord', '_grist_ACLRules', [2, 3], {\n        \"resource\": [1, 1],\n        \"aclFormula\": [\"user.IsGood\", \"user.IsBad\"],\n        \"aclFormulaParsed\": [                         # Gets overwritten\n          '[\"Attr\", [\"Name\", \"user\"], \"IsGood\"]',\n          '[\"Attr\", [\"Name\", \"user\"], \"IsBad\"]',\n        ],\n        \"rulePos\": [2.0, 3.0],                        # Gets filled in.\n      }],\n    ]})\n\n    # BulkUpdateRecord\n    out_actions = self.apply_user_action(['BulkUpdateRecord', '_grist_ACLRules', [2, 3], {\n      \"aclFormula\": [\"not user.IsGood\", \"\"],\n    }])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      ['BulkUpdateRecord', '_grist_ACLRules', [2, 3], {\n        \"aclFormula\": [\"not user.IsGood\", \"\"],\n        \"aclFormulaParsed\": ['[\"Not\", [\"Attr\", [\"Name\", \"user\"], \"IsGood\"]]', ''],\n      }],\n    ]})\n"
  },
  {
    "path": "sandbox/grist/test_acl_renames.py",
    "content": "# -*- coding: utf-8 -*-\n\nimport json\n\nimport test_engine\nimport testsamples\nimport useractions\n\nuser_attr1 = {\n    'name': 'School',\n    'charId': 'Email',\n    'tableId': 'Schools',\n    'lookupColId': 'LiasonEmail',\n}\n\nclass TestACLRenames(test_engine.EngineTestCase):\n\n  def setUp(self):\n    super(TestACLRenames, self).setUp()\n\n    self.load_sample(testsamples.sample_students)\n\n    # Add column to Schools to use with User Attribute.\n    self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (\n      ['AddColumn', 'Schools', 'LiasonEmail', {'type': 'Text'}],\n      ['AddRecord', '_grist_ACLResources', -1, {'tableId': '*', 'colIds': '*'}],\n      ['AddRecord', '_grist_ACLRules', None, {\n        'resource': -1,\n        'userAttributes': json.dumps(user_attr1),\n      }],\n      ['AddRecord', '_grist_ACLResources', -2, {\n        'tableId': 'Students', 'colIds': 'firstName,lastName'\n      }],\n      ['AddRecord', '_grist_ACLResources', -3, {\n        'tableId': 'Students', 'colIds': '*'\n      }],\n      ['AddRecord', '_grist_ACLRules', None, {\n        'resource': -2,\n        # Include comments and unicode to check that renaming respects all that.\n        'aclFormula': '( rec.schoolName !=  # ünîcødé comment\\n  user.School.name)',\n        'permissionsText': 'none',\n      }],\n      ['AddRecord', '_grist_ACLRules', None, {\n        'resource': -2,\n        # Test whether both \"$\" and \"rec.\" are preserved while renaming.\n        'aclFormula': '( $firstName not in rec.schoolName or $schoolName + $lastName == rec.firstName)',\n        'permissionsText': 'all',\n      }],\n      ['AddRecord', '_grist_ACLRules', None, {\n        'resource': -3,\n        'permissionsText': 'all'\n      }],\n    )])\n\n    # Here's what we expect to be in the ACL tables (for reference in tests below).\n    self.assertTableData('_grist_ACLResources', cols=\"subset\", data=[\n      ['id',  'tableId',  'colIds'],\n      [1,     '*',        '*'],\n      [2,     'Students', 'firstName,lastName'],\n      [3,     'Students', '*'],\n    ])\n    self.assertTableData('_grist_ACLRules', cols=\"subset\", data=[\n      ['id',  'resource', 'aclFormula', 'permissionsText', 'userAttributes'],\n      [1,     1,          '',           '',                json.dumps(user_attr1)],\n      [2,     2,  '( rec.schoolName !=  # ünîcødé comment\\n  user.School.name)', 'none', ''],\n      [3,     2,  '( $firstName not in rec.schoolName or $schoolName + $lastName == rec.firstName)', 'all', ''],\n      [4,     3,          '',           'all',              ''],\n    ])\n\n  def test_acl_table_renames(self):\n    # Rename some tables.\n    self.apply_user_action(['RenameTable', 'Students', 'Estudiantes'])\n    self.apply_user_action(['RenameTable', 'Schools', 'Escuelas'])\n\n    user_attr1_renamed = dict(user_attr1, tableId='Escuelas')\n\n    # Check the result of both renames.\n    self.assertTableData('_grist_ACLResources', cols=\"subset\", data=[\n      ['id', 'tableId', 'colIds'],\n      [1,     '*',        '*'],\n      [2,     'Estudiantes', 'firstName,lastName'],\n      [3,     'Estudiantes', '*'],\n    ])\n    self.assertTableData('_grist_ACLRules', cols=\"subset\", data=[\n      ['id',  'resource', 'aclFormula', 'permissionsText', 'userAttributes'],\n      [1,     1,          '',           '',                json.dumps(user_attr1_renamed)],\n      [2,     2,  '( rec.schoolName !=  # ünîcødé comment\\n  user.School.name)', 'none', ''],\n      [3,     2,  '( $firstName not in rec.schoolName or $schoolName + $lastName == rec.firstName)', 'all', ''],\n      [4,     3,          '',           'all',              ''],\n    ])\n\n  def test_acl_column_renames(self):\n    # Rename some columns.\n    self.apply_user_action(['RenameColumn', 'Students', 'lastName', 'Family_Name'])\n    self.apply_user_action(['RenameColumn', 'Schools', 'name', 'schoolName'])\n    self.apply_user_action(['RenameColumn', 'Students', 'schoolName', 'escuela'])\n    self.apply_user_action(['RenameColumn', 'Schools', 'LiasonEmail', 'AdminEmail'])\n\n    user_attr1_renamed = dict(user_attr1, lookupColId='AdminEmail')\n\n    # Check the result of both renames.\n    self.assertTableData('_grist_ACLResources', cols=\"subset\", data=[\n      ['id', 'tableId', 'colIds'],\n      [1,     '*',        '*'],\n      [2,     'Students', 'firstName,Family_Name'],\n      [3,     'Students', '*'],\n    ])\n    self.assertTableData('_grist_ACLRules', cols=\"subset\", data=[\n      ['id',  'resource', 'aclFormula', 'permissionsText', 'userAttributes'],\n      [1,     1,          '',           '',                json.dumps(user_attr1_renamed)],\n      [2,     2,  '( rec.escuela !=  # ünîcødé comment\\n  user.School.schoolName)', 'none', ''],\n      [3,     2,  '( $firstName not in rec.escuela or $escuela + $Family_Name == rec.firstName)', 'all', ''],\n      [4,     3,          '',           'all',              ''],\n    ])\n\n  def test_multiple_renames(self):\n    # Combine several renames into one bundle.\n    self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (\n      ['RenameColumn', 'Students', 'firstName', 'Given_Name'],\n      ['RenameColumn', 'Students', 'lastName', 'Family_Name'],\n      ['RenameTable', 'Students', 'Students2'],\n      ['RenameColumn', 'Students2', 'schoolName', 'escuela'],\n      ['RenameColumn', 'Schools', 'name', 'schoolName'],\n    )])\n    self.assertTableData('_grist_ACLResources', cols=\"subset\", data=[\n      ['id', 'tableId',     'colIds'],\n      [1,     '*',          '*'],\n      [2,     'Students2',  'Given_Name,Family_Name'],\n      [3,     'Students2',  '*'],\n    ])\n    self.assertTableData('_grist_ACLRules', cols=\"subset\", data=[\n      ['id',  'resource', 'aclFormula', 'permissionsText', 'userAttributes'],\n      [1,     1,          '',           '',                json.dumps(user_attr1)],\n      [2,     2,  '( rec.escuela !=  # ünîcødé comment\\n  user.School.schoolName)', 'none', ''],\n      [3,     2,  '( $Given_Name not in rec.escuela or $escuela + $Family_Name == rec.Given_Name)', 'all', ''],\n      [4,     3,          '',           'all',              ''],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_actions.py",
    "content": "import unittest\n\nimport actions\n\nclass TestActions(unittest.TestCase):\n  action_obj1 = actions.UpdateRecord(\"foo\", 17, {\"bar\": \"baz\"})\n  doc_action1 = [\"UpdateRecord\", \"foo\", 17, {\"bar\": \"baz\"}]\n\n  def test_convert(self):\n    self.assertEqual(actions.get_action_repr(self.action_obj1), self.doc_action1)\n    self.assertEqual(actions.action_from_repr(self.doc_action1), self.action_obj1)\n\n    with self.assertRaises(ValueError) as err:\n      actions.action_from_repr([\"Foo\", \"bar\"])\n    self.assertTrue(\"Foo\" in str(err.exception))\n\n  def test_prune_actions(self):\n    # prune_actions is in-place, so we make a new list every time.\n    def alist():\n      return [\n        actions.BulkUpdateRecord(\"Table1\", [1,2,3], {'Foo': [10,20,30]}),\n        actions.BulkUpdateRecord(\"Table2\", [1,2,3], {'Foo': [10,20,30], 'Bar': ['a','b','c']}),\n        actions.UpdateRecord(\"Table1\", 17, {'Foo': 10}),\n        actions.UpdateRecord(\"Table2\", 18, {'Foo': 10, 'Bar': 'a'}),\n        actions.AddRecord(\"Table1\", 17, {'Foo': 10}),\n        actions.BulkAddRecord(\"Table2\", 18, {'Foo': 10, 'Bar': 'a'}),\n        actions.ReplaceTableData(\"Table2\", 18, {'Foo': 10, 'Bar': 'a'}),\n        actions.RemoveRecord(\"Table1\", 17),\n        actions.BulkRemoveRecord(\"Table2\", [17,18]),\n        actions.AddColumn(\"Table1\", \"Foo\", {\"type\": \"Text\"}),\n        actions.RenameColumn(\"Table1\", \"Foo\", \"Bar\"),\n        actions.ModifyColumn(\"Table1\", \"Foo\", {\"type\": \"Text\"}),\n        actions.RemoveColumn(\"Table1\", \"Foo\"),\n        actions.AddTable(\"THello\", [{\"id\": \"Foo\"}, {\"id\": \"Bar\"}]),\n        actions.RemoveTable(\"THello\"),\n        actions.RenameTable(\"THello\", \"TWorld\"),\n      ]\n\n    def prune(table_id, col_id):\n      a = alist()\n      actions.prune_actions(a, table_id, col_id)\n      return a\n\n    self.assertEqual(prune('Table1', 'Foo'), [\n      actions.BulkUpdateRecord(\"Table2\", [1,2,3], {'Foo': [10,20,30], 'Bar': ['a','b','c']}),\n      actions.UpdateRecord(\"Table2\", 18, {'Foo': 10, 'Bar': 'a'}),\n      actions.BulkAddRecord(\"Table2\", 18, {'Foo': 10, 'Bar': 'a'}),\n      actions.ReplaceTableData(\"Table2\", 18, {'Foo': 10, 'Bar': 'a'}),\n      actions.RemoveRecord(\"Table1\", 17),\n      actions.BulkRemoveRecord(\"Table2\", [17,18]),\n      # It doesn't do anything with column renames; it can be addressed if needed.\n      actions.RenameColumn(\"Table1\", \"Foo\", \"Bar\"),\n      # It doesn't do anything with AddTable, which is expected.\n      actions.AddTable(\"THello\", [{\"id\": \"Foo\"}, {\"id\": \"Bar\"}]),\n      actions.RemoveTable(\"THello\"),\n      actions.RenameTable(\"THello\", \"TWorld\"),\n    ])\n\n    self.assertEqual(prune('Table2', 'Foo'), [\n      actions.BulkUpdateRecord(\"Table1\", [1,2,3], {'Foo': [10,20,30]}),\n      actions.BulkUpdateRecord(\"Table2\", [1,2,3], {'Bar': ['a','b','c']}),\n      actions.UpdateRecord(\"Table1\", 17, {'Foo': 10}),\n      actions.UpdateRecord(\"Table2\", 18, {'Bar': 'a'}),\n      actions.AddRecord(\"Table1\", 17, {'Foo': 10}),\n      actions.BulkAddRecord(\"Table2\", 18, {'Bar': 'a'}),\n      actions.ReplaceTableData(\"Table2\", 18, {'Bar': 'a'}),\n      actions.RemoveRecord(\"Table1\", 17),\n      actions.BulkRemoveRecord(\"Table2\", [17,18]),\n      actions.AddColumn(\"Table1\", \"Foo\", {\"type\": \"Text\"}),\n      actions.RenameColumn(\"Table1\", \"Foo\", \"Bar\"),\n      actions.ModifyColumn(\"Table1\", \"Foo\", {\"type\": \"Text\"}),\n      actions.RemoveColumn(\"Table1\", \"Foo\"),\n      actions.AddTable(\"THello\", [{\"id\": \"Foo\"}, {\"id\": \"Bar\"}]),\n      actions.RemoveTable(\"THello\"),\n      actions.RenameTable(\"THello\", \"TWorld\"),\n    ])\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_codebuilder.py",
    "content": "# -*- coding: utf-8 -*-\nimport unittest\n\nfrom asttokens.util import fstring_positions_work\n\nimport codebuilder\nimport test_engine\n\ndef make_body(formula, default=None, indent=''):\n  return codebuilder.make_formula_body(formula, default, indent=indent).get_text()\n\nclass TestCodeBuilder(test_engine.EngineTestCase):\n  def test_make_formula_body(self):\n    # Test simple usage.\n    self.assertEqual(make_body(\"\"), \"return None\")\n    self.assertEqual(make_body(\"\", 0.0), \"return 0.0\")\n    self.assertEqual(make_body(\"\", \"\"), \"return ''\")\n    self.assertEqual(make_body(\"  \"), \"return None\")\n    self.assertEqual(make_body(\"  \", \"-\"), \"return '-'\")\n    self.assertEqual(make_body(\"\\n\\t\"), \"return None\")\n    self.assertEqual(make_body(\"$foo\"), \"return rec.foo\")\n    self.assertEqual(make_body(\"rec.foo\"), \"return rec.foo\")\n    self.assertEqual(make_body(\"return $foo\"), \"return rec.foo\")\n    self.assertEqual(make_body(\"return $f123\"), \"return rec.f123\")\n    self.assertEqual(make_body(\"return rec.foo\"), \"return rec.foo\")\n    self.assertEqual(make_body(\"$foo if $bar else max($foo.bar.baz)\"),\n                     \"return rec.foo if rec.bar else max(rec.foo.bar.baz)\")\n\n    # Check that we don't mistake our temporary representation of \"$\" for the real thing.\n    self.assertEqual(make_body(\"return DOLLARfoo\"), \"return DOLLARfoo\")\n\n    # Test that we don't translate $foo inside string literals or comments.\n    self.assertEqual(make_body(\"$foo or '$foo'\"), \"return rec.foo or '$foo'\")\n    self.assertEqual(make_body(\"$foo * 2 # $foo\"), \"return rec.foo * 2 # $foo\")\n    self.assertEqual(make_body(\"$foo * 2 # $foo\\n$bar\"), \"rec.foo * 2 # $foo\\nreturn rec.bar\")\n    self.assertEqual(make_body(\"$foo or '\\\\'$foo\\\\''\"), \"return rec.foo or '\\\\'$foo\\\\''\")\n    self.assertEqual(make_body('$foo or \"\"\"$foo\"\"\"'), 'return rec.foo or \"\"\"$foo\"\"\"')\n    self.assertEqual(make_body('$foo or \"\"\"Some \"$foos\" stay\"\"\"'),\n                     'return rec.foo or \"\"\"Some \"$foos\" stay\"\"\"')\n\n    # Check that we only insert a return appropriately.\n    self.assertEqual(make_body('if $foo:\\n  return 1\\nelse:\\n  return 2\\n'),\n                                    'if rec.foo:\\n  return 1\\nelse:\\n  return 2\\n')\n    self.assertEqual(make_body('a = $foo\\nmax(a, a*2)'), 'a = rec.foo\\nreturn max(a, a*2)')\n\n    # Check that return gets inserted correctly when there is a multi-line expression.\n    self.assertEqual(make_body('($foo or\\n $bar)'), 'return (rec.foo or\\n rec.bar)')\n    self.assertEqual(make_body('return ($foo or\\n  $bar)'), 'return (rec.foo or\\n  rec.bar)')\n    self.assertEqual(make_body('if $foo: return 17'), 'if rec.foo: return 17')\n    self.assertEqual(make_body('$foo\\n# return $bar'), 'return rec.foo\\n# return $bar')\n\n    # Test that formulas with a single string literal work, including multi-line string literals.\n    self.assertEqual(make_body('\"test\"'), 'return \"test\"')\n    self.assertEqual(make_body('(\"\"\"test1\\ntest2\\ntest3\"\"\")'), 'return (\"\"\"test1\\ntest2\\ntest3\"\"\")')\n    self.assertEqual(make_body('\"\"\"test1\\ntest2\\ntest3\"\"\"'), 'return \"\"\"test1\\ntest2\\ntest3\"\"\"')\n    self.assertEqual(make_body('\"\"\"test1\\\\ntest2\\\\ntest3\"\"\"'), 'return \"\"\"test1\\\\ntest2\\\\ntest3\"\"\"')\n\n    self.assertEqual(make_body('(\"\"\"test1\\ntest2\\ntest3\"\"\")', indent='  '),\n        '  return (\"\"\"test1\\ntest2\\ntest3\"\"\")')\n    self.assertEqual(make_body('\"\"\"test1\\ntest2\\ntest3\"\"\"', indent='    '),\n        '    return \"\"\"test1\\ntest2\\ntest3\"\"\"')\n    self.assertEqual(make_body('\"\"\"test1\\\\ntest2\\\\ntest3\"\"\"', indent='  '),\n        '  return \"\"\"test1\\\\ntest2\\\\ntest3\"\"\"')\n\n    # Same, with single quotes.\n    self.assertEqual(make_body(\"'test'\"), \"return 'test'\")\n    self.assertEqual(make_body(\"('''test1\\ntest2\\ntest3''')\"), \"return ('''test1\\ntest2\\ntest3''')\")\n    self.assertEqual(make_body(\"'''test1\\ntest2\\ntest3'''\"), \"return '''test1\\ntest2\\ntest3'''\")\n    self.assertEqual(make_body(\"'''test1\\\\ntest2\\\\ntest3'''\"), \"return '''test1\\\\ntest2\\\\ntest3'''\")\n    self.assertEqual(make_body(\"'''test1\\\\ntest2\\\\ntest3'''\", indent='  '),\n        \"  return '''test1\\\\ntest2\\\\ntest3'''\")\n\n    # And with mixing quotes\n    self.assertEqual(make_body(\"'''test1\\\"\\\"\\\" +\\\\\\n  \\\"\\\"\\\"test2'''\"),\n                     \"return '''test1\\\"\\\"\\\" +\\\\\\n  \\\"\\\"\\\"test2'''\")\n    self.assertEqual(make_body(\"'''test1\\\"\\\"\\\" +\\\\\\n  \\\"\\\"\\\"test2'''\", indent='  '),\n                     \"  return '''test1\\\"\\\"\\\" +\\\\\\n  \\\"\\\"\\\"test2'''\")\n    self.assertEqual(make_body(\"'''test1''' +\\\\\\n  \\\"\\\"\\\"test2\\\"\\\"\\\"\"),\n                     \"return '''test1''' +\\\\\\n  \\\"\\\"\\\"test2\\\"\\\"\\\"\")\n    self.assertEqual(make_body(\"'''test1\\\"\\\"\\\"\\n\\\"\\\"\\\"test2'''\"),\n                     \"return '''test1\\\"\\\"\\\"\\n\\\"\\\"\\\"test2'''\")\n    self.assertEqual(make_body(\"'''test1'''\\n\\\"\\\"\\\"test2\\\"\\\"\\\"\"),\n                     \"'''test1'''\\nreturn \\\"\\\"\\\"test2\\\"\\\"\\\"\")\n\n    if fstring_positions_work():\n      self.assertEqual(\n        make_body(\"f'{$foo + 1 + $bar} 2 {3 + $baz}' + $foo2 + f'{4 + $bar2}!'\"),\n        \"return f'{rec.foo + 1 + rec.bar} 2 {3 + rec.baz}' + rec.foo2 + f'{4 + rec.bar2}!'\"\n      )\n\n    # Test that we produce valid code when \"$foo\" occurs in invalid places.\n    raise_code = (\"raise SyntaxError('invalid syntax\\\\n\\\\n\"\n                  \"A `SyntaxError` occurs when Python cannot understand your code.\\\\n\\\\n', \"\n                  \"('usercode', 1, 5, 'foo($bar=1)'))\")\n    self.assertEqual(make_body('foo($bar=1)'),\n                     \"# foo($bar=1)\\n\" + raise_code)\n\n    raise_code = (\"raise SyntaxError('invalid syntax\\\\n\\\\n\"\n                  \"A `SyntaxError` occurs when Python cannot understand your code.\\\\n\\\\n', \"\n                  \"('usercode', 1, 5, 'def $bar(): return 3'))\")\n    self.assertEqual(make_body('def $bar(): return 3'),\n                     \"# def $bar(): return 3\\n\" + raise_code)\n\n    # If $ is a syntax error, we don't want to turn it into a different syntax error.\n    raise_code = (\"raise SyntaxError('invalid syntax\\\\n\\\\n\"\n                  \"A `SyntaxError` occurs when Python cannot understand your code.\\\\n\\\\n', \"\n                  \"('usercode', 1, 17, '$foo + (\\\"$%.2f\\\" $ ($17.5))'))\")\n    self.assertEqual(make_body('$foo + (\"$%.2f\" $ ($17.5))'),\n                     '# $foo + (\"$%.2f\" $ ($17.5))\\n' + raise_code)\n\n    raise_code = (\"raise SyntaxError('invalid syntax\\\\n\\\\n\"\n                  \"A `SyntaxError` occurs when Python cannot understand your code.\\\\n\\\\n\"\n                  \"I am guessing that you wrote `$` by mistake.\\\\n\"\n                  \"Removing it and writing `return  bar` seems to fix the error.\\\\n\\\\n', \"\n                  \"('usercode', 4, 10, '  return $ bar'))\")\n    self.assertEqual(make_body('if $foo:\\n' +\n                               '  return $foo\\n' +\n                               'else:\\n' +\n                               '  return $ bar\\n'),\n                     '# if $foo:\\n' +\n                     '#   return $foo\\n' +\n                     '# else:\\n' +\n                     '#   return $ bar\\n' +\n                     raise_code)\n\n    # Check for reasonable behaviour with non-empty text and no statements.\n    self.assertEqual(make_body('# comment'), '# comment\\npass')\n\n    self.assertEqual(make_body('rec = 1; rec'), \"# rec = 1; rec\\n\" +\n                     \"raise SyntaxError('Grist disallows assignment \" +\n                     \"to the special variable \\\"rec\\\"', ('usercode', 1, 1, 'rec = 1; rec'))\")\n    self.assertEqual(make_body('for rec in []: return rec'), \"# for rec in []: return rec\\n\" +\n                     \"raise SyntaxError('Grist disallows assignment \" +\n                     \"to the special variable \\\"rec\\\"', \"\n                     \"('usercode', 1, 4, 'for rec in []: return rec'))\")\n\n    # some legitimates use of rec\n    body = (\"\"\"\nfoo = rec\n[rec for x in rec]\nfor a in rec:\n  t = a\n[rec for x in rec]\nreturn rec\n\"\"\")\n    self.assertEqual(make_body(body), body)\n\n    # mostly legitimate use of rec but one failing\n    body = (\"\"\"\nfoo = rec\n[1 for rec in []]\nfor a in rec:\n  t = a\n[rec for x in rec]\nreturn rec\n\"\"\")\n\n    self.assertRegex(make_body(body),\n                     r\"raise SyntaxError\\('Grist disallows assignment\" +\n                     r\" to the special variable \\\"rec\\\"', \"\n                     r\"\\('usercode', 3, 7, '\\[1 for rec in \\[\\]\\]'\\)\\)\")\n\n    self.assertEqual(make_body('rec.foo = 1; rec'), \"# rec.foo = 1; rec\\n\" +\n                     \"raise SyntaxError(\\\"You can't assign a value to a column with `=`. \"\n                     \"If you mean to check for equality, use `==` instead.\\\", \"\n                     \"('usercode', 1, 1, 'rec.foo = 1; rec'))\")\n\n    self.assertEqual(make_body('$foo = 1; rec'), \"# $foo = 1; rec\\n\" +\n                     \"raise SyntaxError(\\\"You can't assign a value to a column with `=`. \"\n                     \"If you mean to check for equality, use `==` instead.\\\", \"\n                     \"('usercode', 1, 1, '$foo = 1; rec'))\")\n\n    self.assertEqual(make_body('assert foo'), \"# assert foo\\n\" +\n                     'raise SyntaxError(\"No `return` statement, '\n                     \"and the last line isn't an expression.\\\", \"\n                     \"('usercode', 1, 1, 'assert foo'))\")\n\n    self.assertEqual(make_body('foo = 1'), \"# foo = 1\\n\" +\n                     'raise SyntaxError(\"No `return` statement, '\n                     \"and the last line isn't an expression.\"\n                     \" If you want to check for equality, use `==` instead of `=`.\\\", \"\n                     \"('usercode', 1, 1, 'foo = 1'))\")\n\n  def test_make_formula_body_unicode(self):\n    # Test that we don't fail when strings include unicode characters\n    self.assertEqual(make_body(\"'résumé' + $foo\"), u\"return 'résumé' + rec.foo\")\n\n    # Or when a unicode object is passed in, rather than a byte string\n    self.assertEqual(make_body(u\"'résumé' + $foo\"), u\"return 'résumé' + rec.foo\")\n\n    # Check the return type of make_body()\n    self.assertEqual(type(make_body(\"foo\")), str)\n    self.assertEqual(type(make_body(u\"foo\")), str)\n\n  def test_make_formula_body_unicode_token_bug(self):\n    # Python < 3.12 has a bug in tokenizing certain unicode characters in variable names.\n    # This was worked around in https://github.com/gristlabs/asttokens/pull/82\n    # Surprisingly this test passes either way, but keeping it as a potentially tricky case.\n    self.assertEqual(\n      make_body(\n        \"℘℘··℘℘2=℘℘··℘℘2($foo+℘℘··℘℘2*℘℘··℘℘2+$bar)\\n\"\n        \"℘℘··℘℘2=1+a℘℘··℘℘b+a℘℘··℘℘2b\\n\"\n        \"℘℘··℘℘2==℘℘··℘℘2($foo+℘℘··℘℘2*℘℘··℘℘2+$bar)\"\n      ),\n      (\n        \"℘℘··℘℘2=℘℘··℘℘2(rec.foo+℘℘··℘℘2*℘℘··℘℘2+rec.bar)\\n\"\n        \"℘℘··℘℘2=1+a℘℘··℘℘b+a℘℘··℘℘2b\\n\"\n        \"return ℘℘··℘℘2==℘℘··℘℘2(rec.foo+℘℘··℘℘2*℘℘··℘℘2+rec.bar)\"\n      ),\n    )\n\n  def test_wrap_logical(self):\n    self.assertEqual(make_body(\"IF($foo, $bar, $baz)\"),\n        \"return IF(rec.foo, lambda: (rec.bar), lambda: (rec.baz))\")\n    self.assertEqual(make_body(\"return IF(FOO(x,y), BAR(x,y) * 2, BAZ(x,y) + 5)\"),\n        \"return IF(FOO(x,y), lambda: (BAR(x,y) * 2), lambda: (BAZ(x,y) + 5))\")\n    self.assertEqual(make_body(\"\"\"\ny = $Test\nx = IF( FOO(x,y) or 6,\n  BAR($x,y).blahh ,\n  Foo.lookupRecords(foo=$foo.bar,\n    bar=True\n  ).baz\n )\nreturn x or y\n\"\"\"), \"\"\"\ny = rec.Test\nx = IF( FOO(x,y) or 6,\n  lambda: (BAR(rec.x,y).blahh) ,\n  lambda: (Foo.lookupRecords(foo=rec.foo.bar,\n    bar=True\n  ).baz)\n )\nreturn x or y\n\"\"\")\n    self.assertEqual(make_body(\"IF($A == 0, IF($B > 5, 'Test1'), IF($C < 10, 'Test2', 'Test3'))\"),\n        \"return IF(rec.A == 0, \" +\n          \"lambda: (IF(rec.B > 5, lambda: ('Test1'))), \" +\n          \"lambda: (IF(rec.C < 10, lambda: ('Test2'), lambda: ('Test3'))))\"\n    )\n\n  def test_wrap_error(self):\n    self.assertEqual(make_body(\"ISERR($foo.bar)\"), \"return ISERR(lambda: (rec.foo.bar))\")\n    self.assertEqual(make_body(\"ISERROR(1 / 0)\"), \"return ISERROR(lambda: (1 / 0))\")\n    self.assertEqual(make_body(\"IFERROR($foo + #\\n  1 / 0, 'XX')\"),\n        \"return IFERROR(lambda: (rec.foo + #\\n  1 / 0), 'XX')\")\n\n    # Check that extra parentheses are OK.\n    self.assertEqual(make_body(\"IFERROR((($foo + 1) / 0))\"),\n        \"return IFERROR((lambda: ((rec.foo + 1) / 0)))\")\n\n    # Check that missing arguments is OK\n    self.assertEqual(make_body(\"ISERR()\"), \"return ISERR()\")\n\n\n  def test_leading_whitespace(self):\n    self.assertEqual(make_body(\" $A + 1\"), \"return rec.A + 1\")\n\n    self.assertEqual(make_body(\"\"\"\n  if $A:\n    return $A\n\n  $B\n\"\"\"), \"\"\"\nif rec.A:\n  return rec.A\n\nreturn rec.B\n\"\"\")\n"
  },
  {
    "path": "sandbox/grist/test_column_actions.py",
    "content": "import logging\n\nimport testutil\nimport test_engine\nfrom test_engine import Table, Column, View, Section, Field\n\nlog = logging.getLogger(__name__)\n\nclass TestColumnActions(test_engine.EngineTestCase):\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Address\", [\n        [21, \"city\",        \"Text\",       False, \"\", \"\", \"\"],\n      ]]\n    ],\n    \"DATA\": {\n      \"Address\": [\n        [\"id\",  \"city\"       ],\n        [11,    \"New York\"   ],\n        [12,    \"Colombia\"   ],\n        [13,    \"New Haven\"  ],\n        [14,    \"West Haven\" ]],\n    }\n  })\n\n  @test_engine.test_undo\n  def test_column_updates(self):\n    # Verify various automatic adjustments for column updates\n    # (1) that label gets synced to colId unless untieColIdFromLabel is set.\n    # (2) that unsetting untieColId syncs the label to colId.\n    # (3) that a complex BulkUpdateRecord for _grist_Tables_column is processed correctly.\n    self.load_sample(self.sample)\n\n    self.apply_user_action([\"AddColumn\", \"Address\", \"foo\", {\"type\": \"Numeric\"}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",  \"label\",  \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"city\",   \"\",       \"Text\",     False                 ],\n      [ 22,     1,            \"foo\",    \"foo\",    \"Numeric\",  False                 ],\n    ])\n\n    # Check that label is synced to colId, via either ModifyColumn or UpdateRecord useraction.\n    self.apply_user_action([\"ModifyColumn\", \"Address\", \"city\", {\"label\": \"Hello\"}])\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"label\": \"World\"}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",  \"label\",  \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Hello\",  \"Hello\",  \"Text\",     False                 ],\n      [ 22,     1,            \"World\",  \"World\",  \"Numeric\",  False                 ],\n    ])\n\n    # But check that a rename or an update that includes colId is not affected by label.\n    self.apply_user_action([\"RenameColumn\", \"Address\", \"Hello\", \"Hola\"])\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", 22,\n                            {\"label\": \"Foo\", \"colId\": \"Bar\"}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",  \"label\",  \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Hola\",   \"Hello\",  \"Text\",     False                 ],\n      [ 22,     1,            \"Bar\",    \"Foo\",    \"Numeric\",  False                 ],\n    ])\n\n    # Check that setting untieColIdFromLabel doesn't change anything immediately.\n    self.apply_user_action([\"BulkUpdateRecord\", \"_grist_Tables_column\", [21,22],\n                            {\"untieColIdFromLabel\": [True, True]}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",  \"label\",  \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Hola\",   \"Hello\",  \"Text\",     True                  ],\n      [ 22,     1,            \"Bar\",    \"Foo\",    \"Numeric\",  True                  ],\n    ])\n\n    # Check that ModifyColumn and UpdateRecord useractions no longer copy label to colId.\n    self.apply_user_action([\"ModifyColumn\", \"Address\", \"Hola\", {\"label\": \"Hello\"}])\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"label\": \"World\"}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",  \"label\",  \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Hola\",   \"Hello\",  \"Text\",     True                  ],\n      [ 22,     1,            \"Bar\",    \"World\",  \"Numeric\",  True                  ],\n    ])\n\n    # Check that unsetting untieColIdFromLabel syncs label, whether label is provided or not.\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", 21,\n                            {\"untieColIdFromLabel\": False, \"label\": \"Alice\"}])\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", 22,\n                            {\"untieColIdFromLabel\": False}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",  \"label\",  \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Alice\",  \"Alice\",  \"Text\",     False                 ],\n      [ 22,     1,            \"World\",  \"World\",  \"Numeric\",  False                 ],\n    ])\n\n    # Check that column names still get sanitized and disambiguated.\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"label\": \"Alice M\"}])\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"label\": \"Alice-M\"}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",    \"label\",    \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Alice_M\",  \"Alice M\",  \"Text\",     False                 ],\n      [ 22,     1,            \"Alice_M2\", \"Alice-M\",  \"Numeric\",  False                 ],\n    ])\n\n    # Check that a column rename doesn't avoid its own name.\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"label\": \"Alice*M\"}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",    \"label\",    \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Alice_M\",  \"Alice*M\",  \"Text\",     False                 ],\n      [ 22,     1,            \"Alice_M2\", \"Alice-M\",  \"Numeric\",  False                 ],\n    ])\n\n    # Untie colIds and tie them again, and make sure it doesn't cause unneeded renames.\n    self.apply_user_action([\"BulkUpdateRecord\", \"_grist_Tables_column\", [21,22],\n                            { \"untieColIdFromLabel\": [True, True] }])\n    self.apply_user_action([\"BulkUpdateRecord\", \"_grist_Tables_column\", [21,22],\n                            { \"untieColIdFromLabel\": [False, False] }])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",    \"label\",    \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Alice_M\",  \"Alice*M\",  \"Text\",     False                 ],\n      [ 22,     1,            \"Alice_M2\", \"Alice-M\",  \"Numeric\",  False                 ],\n    ])\n\n    # Check that disambiguating also works correctly for bulk updates.\n    self.apply_user_action([\"BulkUpdateRecord\", \"_grist_Tables_column\", [21,22],\n                            {\"label\": [\"Bob Z\", \"Bob-Z\"]}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",  \"label\",  \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Bob_Z\",  \"Bob Z\",  \"Text\",     False                 ],\n      [ 22,     1,            \"Bob_Z2\", \"Bob-Z\",  \"Numeric\",  False                 ],\n    ])\n\n    # Same for changing colIds directly.\n    self.apply_user_action([\"BulkUpdateRecord\", \"_grist_Tables_column\", [21,22],\n                            {\"colId\": [\"Carol X\", \"Carol-X\"]}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",    \"label\",  \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Carol_X\",  \"Bob Z\",  \"Text\",     False                 ],\n      [ 22,     1,            \"Carol_X2\", \"Bob-Z\",  \"Numeric\",  False                 ],\n    ])\n\n    # Check confusing bulk updates with different keys changing for different records.\n    out_actions = self.apply_user_action([\"BulkUpdateRecord\", \"_grist_Tables_column\", [21,22], {\n      \"label\": [\"Bob Z\", \"Bob-Z\"],          # Unchanged from before.\n      \"untieColIdFromLabel\": [True, False]\n    }])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Address\", \"Carol_X2\", \"Bob_Z\"],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [21, 22],\n       {\"colId\": [\"Carol_X\", \"Bob_Z\"],      # Note that only one column is changing.\n        \"untieColIdFromLabel\": [True, False]\n        # No update to label, they get trimmed as unchanged.\n       }\n      ],\n    ]})\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\",    \"label\",  \"type\",     \"untieColIdFromLabel\" ],\n      [ 21,     1,            \"Carol_X\",  \"Bob Z\",  \"Text\",     True                  ],\n      [ 22,     1,            \"Bob_Z\",    \"Bob-Z\",  \"Numeric\",  False                 ],\n    ])\n\n  #----------------------------------------------------------------------\n\n  address_table_data =  [\n    [\"id\",  \"city\",     \"state\", \"amount\" ],\n    [ 21,   \"New York\", \"NY\"   , 1.       ],\n    [ 22,   \"Albany\",   \"NY\"   , 2.       ],\n    [ 23,   \"Seattle\",  \"WA\"   , 3.       ],\n    [ 24,   \"Chicago\",  \"IL\"   , 4.       ],\n    [ 25,   \"Bedford\",  \"MA\"   , 5.       ],\n    [ 26,   \"New York\", \"NY\"   , 6.       ],\n    [ 27,   \"Buffalo\",  \"NY\"   , 7.       ],\n    [ 28,   \"Bedford\",  \"NY\"   , 8.       ],\n    [ 29,   \"Boston\",   \"MA\"   , 9.       ],\n    [ 30,   \"Yonkers\",  \"NY\"   , 10.      ],\n    [ 31,   \"New York\", \"NY\"   , 11.      ],\n  ]\n\n  sample2 = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Address\", [\n        [11, \"city\",        \"Text\",       False, \"\", \"\", \"\"],\n        [12, \"state\",       \"Text\",       False, \"\", \"\", \"\"],\n        [13, \"amount\",      \"Numeric\",    False, \"\", \"\", \"\"],\n      ]]\n    ],\n    \"DATA\": {\n      \"Address\": address_table_data\n    }\n  })\n\n  def init_sample_data(self):\n    # Add a new view with a section, and a new table to that view, and a summary table.\n    self.load_sample(self.sample2)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", None, None])\n    self.apply_user_action([\"AddEmptyTable\", None])\n    self.apply_user_action([\"CreateViewSection\", 2, 1, \"record\", None, None])\n    self.apply_user_action([\"CreateViewSection\", 1, 1, \"record\", [12], None])\n    self.apply_user_action([\"BulkAddRecord\", \"Table1\", [None]*3, {\n      \"A\": [\"a\", \"b\", \"c\"],\n      \"B\": [\"d\", \"e\", \"f\"],\n      \"C\": [\"\", \"\", \"\"]\n    }])\n\n    # Verify the new structure of tables and views.\n    self.assertTables([\n      Table(1, \"Address\", primaryViewId=0, summarySourceTable=0, columns=[\n        Column(11, \"city\",  \"Text\", False, \"\", 0),\n        Column(12, \"state\", \"Text\", False, \"\", 0),\n        Column(13, \"amount\", \"Numeric\", False, \"\", 0),\n      ]),\n      Table(2, \"Table1\", 2, 0, columns=[\n        Column(14, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(15, \"A\", \"Text\", False, \"\", 0),\n        Column(16, \"B\", \"Text\", False, \"\", 0),\n        Column(17, \"C\", \"Any\", True, \"\", 0),\n      ]),\n      Table(3, \"Address_summary_state\", 0, 1, columns=[\n        Column(18, \"state\", \"Text\", False, \"\", summarySourceCol=12),\n        Column(19, \"group\", \"RefList:Address\", True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(20, \"count\", \"Int\", True, summarySourceCol=0, formula=\"len($group)\"),\n        Column(21, \"amount\", \"Numeric\", True, summarySourceCol=0, formula=\"SUM($group.amount)\"),\n      ]),\n    ])\n    self.assertViews([\n      View(1, sections=[\n        Section(1, parentKey=\"record\", tableRef=1, fields=[\n          Field(1, colRef=11),\n          Field(2, colRef=12),\n          Field(3, colRef=13),\n        ]),\n        Section(5, parentKey=\"record\", tableRef=2, fields=[\n          Field(13, colRef=15),\n          Field(14, colRef=16),\n          Field(15, colRef=17),\n        ]),\n        Section(7, parentKey=\"record\", tableRef=3, fields=[\n          Field(19, colRef=18),\n          Field(20, colRef=20),\n          Field(21, colRef=21),\n        ]),\n      ]),\n      View(2, sections=[\n        Section(2, parentKey=\"record\", tableRef=2, fields=[\n          Field(4, colRef=15),\n          Field(5, colRef=16),\n          Field(6, colRef=17),\n        ]),\n      ])\n    ])\n    self.assertTableData('Address', data=self.address_table_data)\n    self.assertTableData('Table1', data=[\n      [\"id\", \"A\", \"B\", \"C\", \"manualSort\"],\n      [ 1,   \"a\", \"d\", None,    1.0],\n      [ 2,   \"b\", \"e\", None,    2.0],\n      [ 3,   \"c\", \"f\", None,    3.0],\n    ])\n    self.assertTableData(\"Address_summary_state\", cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\", \"amount\"          ],\n      [ 1,    \"NY\",     7,      1.+2+6+7+8+10+11  ],\n      [ 2,    \"WA\",     1,      3.                ],\n      [ 3,    \"IL\",     1,      4.                ],\n      [ 4,    \"MA\",     2,      5.+9              ],\n    ])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_column_removals(self):\n    # Verify removal of fields when columns are removed.\n\n    self.init_sample_data()\n\n    # Add link{Src,Target}ColRef to ViewSections. These aren't actually meaningful links, but they\n    # should still get cleared automatically when columns get removed.\n    self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2, {\n      'linkSrcSectionRef': 1,\n      'linkSrcColRef': 11,\n      'linkTargetColRef': 16\n    }])\n    self.assertTableData('_grist_Views_section', cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"linkSrcSectionRef\",  \"linkSrcColRef\",  \"linkTargetColRef\"],\n      [2,     1,                    11,               16                ],\n    ])\n\n    # Test that we can remove multiple columns using BulkUpdateRecord.\n    self.apply_user_action([\"BulkRemoveRecord\", '_grist_Tables_column', [11, 16]])\n\n    # Test that link{Src,Target}colRef back-references get unset.\n    self.assertTableData('_grist_Views_section', cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"linkSrcSectionRef\",  \"linkSrcColRef\",  \"linkTargetColRef\"],\n      [2,     1,                    0,                0                 ],\n    ])\n\n    # Test that columns and section fields got removed.\n    self.assertTables([\n      Table(1, \"Address\", primaryViewId=0, summarySourceTable=0, columns=[\n        Column(12, \"state\", \"Text\", False, \"\", 0),\n        Column(13, \"amount\", \"Numeric\", False, \"\", 0),\n      ]),\n      Table(2, \"Table1\", 2, 0, columns=[\n        Column(14, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(15, \"A\", \"Text\", False, \"\", 0),\n        Column(17, \"C\", \"Any\", True, \"\", 0),\n      ]),\n      Table(3, \"Address_summary_state\", 0, 1, columns=[\n        Column(18, \"state\", \"Text\", False, \"\", summarySourceCol=12),\n        Column(19, \"group\", \"RefList:Address\", True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(20, \"count\", \"Int\", True, summarySourceCol=0, formula=\"len($group)\"),\n        Column(21, \"amount\", \"Numeric\", True, summarySourceCol=0, formula=\"SUM($group.amount)\"),\n      ]),\n    ])\n    self.assertViews([\n      View(1, sections=[\n        Section(1, parentKey=\"record\", tableRef=1, fields=[\n          Field(2, colRef=12),\n          Field(3, colRef=13),\n        ]),\n        Section(5, parentKey=\"record\", tableRef=2, fields=[\n          Field(13, colRef=15),\n          Field(15, colRef=17),\n        ]),\n        Section(7, parentKey=\"record\", tableRef=3, fields=[\n          Field(19, colRef=18),\n          Field(20, colRef=20),\n          Field(21, colRef=21),\n        ]),\n      ]),\n      View(2, sections=[\n        Section(2, parentKey=\"record\", tableRef=2, fields=[\n          Field(4, colRef=15),\n          Field(6, colRef=17),\n        ]),\n      ])\n    ])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_summary_column_removals(self):\n    # Verify that when we remove a column used for summary-table group-by, it updates summary\n    # tables appropriately.\n\n    self.init_sample_data()\n\n    # Test that we cannot remove group-by columns from summary tables directly.\n    with self.assertRaisesRegex(ValueError, \"cannot remove .* group-by\"):\n      self.apply_user_action([\"BulkRemoveRecord\", '_grist_Tables_column', [20,18]])\n\n    # Test that group-by columns in summary tables get removed.\n    self.apply_user_action([\"BulkRemoveRecord\", '_grist_Tables_column', [11,12,16]])\n\n    # Verify the new structure of tables and views.\n    self.assertTables([\n      Table(1, \"Address\", primaryViewId=0, summarySourceTable=0, columns=[\n        Column(13, \"amount\", \"Numeric\", False, \"\", 0),\n      ]),\n      Table(2, \"Table1\", 2, 0, columns=[\n        Column(14, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(15, \"A\", \"Text\", False, \"\", 0),\n        Column(17, \"C\", \"Any\", True, \"\", 0),\n      ]),\n      # Note that the summary table here switches to a new one, without the deleted group-by.\n      Table(4, \"Address_summary\", 0, 1, columns=[\n        Column(23, \"count\", \"Int\", True, summarySourceCol=0, formula=\"len($group)\"),\n        Column(24, \"amount\", \"Numeric\", True, summarySourceCol=0, formula=\"SUM($group.amount)\"),\n        Column(22, \"group\", \"RefList:Address\", True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n      ]),\n    ])\n    self.assertViews([\n      View(1, sections=[\n        Section(1, parentKey=\"record\", tableRef=1, fields=[\n          Field(3, colRef=13),\n        ]),\n        Section(5, parentKey=\"record\", tableRef=2, fields=[\n          Field(13, colRef=15),\n          Field(15, colRef=17),\n        ]),\n        Section(7, parentKey=\"record\", tableRef=4, fields=[\n          Field(20, colRef=23),\n          Field(21, colRef=24),\n        ]),\n      ]),\n      View(2, sections=[\n        Section(2, parentKey=\"record\", tableRef=2, fields=[\n          Field(4, colRef=15),\n          Field(6, colRef=17),\n        ]),\n      ])\n    ])\n\n    # Verify the data itself.\n    self.assertTableData('Address', data=[\n      [\"id\",  \"amount\" ],\n      [ 21,   1.       ],\n      [ 22,   2.       ],\n      [ 23,   3.       ],\n      [ 24,   4.       ],\n      [ 25,   5.       ],\n      [ 26,   6.       ],\n      [ 27,   7.       ],\n      [ 28,   8.       ],\n      [ 29,   9.       ],\n      [ 30,   10.      ],\n      [ 31,   11.      ],\n    ])\n    self.assertTableData('Table1', data=[\n      [\"id\", \"A\", \"C\", \"manualSort\"],\n      [ 1,   \"a\", None,    1.0],\n      [ 2,   \"b\", None,    2.0],\n      [ 3,   \"c\", None,    3.0],\n    ])\n    self.assertTableData(\"Address_summary\", cols=\"subset\", data=[\n      [ \"id\", \"count\", \"amount\"          ],\n      [ 1,     7+1+1+2,   1.+2+6+7+8+10+11+3+4+5+9  ],\n    ])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_column_sort_removals(self):\n    # Verify removal of sort spec entries when columns are removed.\n\n    self.init_sample_data()\n\n    # Add sortSpecs to ViewSections.\n    self.apply_user_action(['BulkUpdateRecord', '_grist_Views_section', [2, 3, 5],\n      {'sortColRefs': ['[15, -16]', '[-15, 16, 17]', '[19]']}\n    ])\n    self.assertTableData('_grist_Views_section', cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"sortColRefs\"  ],\n      [2,     '[15, -16]'    ],\n      [3,     '[-15, 16, 17]'],\n      [5,     '[19]'         ],\n    ])\n\n    # Remove column, and check that the correct sortColRefs items are removed.\n    self.apply_user_action([\"RemoveRecord\", '_grist_Tables_column', 16])\n    self.assertTableData('_grist_Views_section', cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"sortColRefs\"],\n      [2,     '[15]'       ],\n      [3,     '[-15, 17]'  ],\n      [5,     '[19]'       ],\n    ])\n\n    # Update sortColRefs for next test.\n    self.apply_user_action(['UpdateRecord', '_grist_Views_section', 3,\n      {'sortColRefs': '[-15, -16, 17]'}\n    ])\n\n    # Remove multiple columns using BulkUpdateRecord, and check that the sortSpecs are updated.\n    self.apply_user_action([\"BulkRemoveRecord\", '_grist_Tables_column', [15, 17, 19]])\n    self.assertTableData('_grist_Views_section', cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"sortColRefs\"],\n      [2,     '[]'         ],\n      [3,     '[-16]'      ],\n      [5,     '[]'         ],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_completion.py",
    "content": "import datetime\nimport sys\n\nimport test_engine\nimport testsamples\nfrom autocomplete_context import repr_example, eval_suggestion\nfrom schema import RecalcWhen\n\n\nclass TestCompletion(test_engine.EngineTestCase):\n  user = {\n    'Name': 'Foo',\n    'UserID': 1,\n    'UserRef': '1',\n    'StudentInfo': ['Students', 1],\n    'LinkKey': {},\n    'Origin': None,\n    'Email': 'foo@example.com',\n    'Access': 'owners',\n    'SessionID': 'u1',\n    'IsLoggedIn': True,\n    'ShareRef': None\n  }\n\n  def setUp(self):\n    super(TestCompletion, self).setUp()\n    self.load_sample(testsamples.sample_students)\n\n    # To test different column types, we add some differently-typed columns to the sample.\n    self.add_column('Students', 'school', type='Ref:Schools', visibleCol=10)\n    self.add_column('Students', 'homeAddress', type='Ref:Address', visibleCol=21)\n    self.add_column('Students', 'birthDate', type='Date')\n    self.add_column('Students', 'lastVisit', type='DateTime:America/New_York')\n    self.add_column('Schools', 'yearFounded', type='Int')\n    self.add_column('Schools', 'budget', type='Numeric')\n    self.add_column('Schools', 'lastModified',\n      type=\"DateTime:America/Los_Angeles\", isFormula=False, formula=\"NOW()\",\n      recalcWhen=RecalcWhen.MANUAL_UPDATES\n    )\n    self.add_column('Schools', 'lastModifier',\n      type=\"Text\", isFormula=False, formula=\"foo@getgrist.com\",\n      recalcWhen=RecalcWhen.MANUAL_UPDATES\n    )\n    self.update_record('Schools', 3, budget='123.45', yearFounded='2010', lastModified='2018-01-01')\n    self.update_record('Students', 1, homeAddress=11, school=1)\n    # Create a summary table of Students grouped by school\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [22], None])\n\n  def test_keyword(self):\n    self.assertEqual(self.autocomplete(\"for\", \"Address\", \"city\"),\n                     [\"for\", \"format(\"])\n\n  def test_grist(self):\n    self.assertEqual(self.autocomplete(\"gri\", \"Address\", \"city\"),\n                     [\"grist\"])\n\n  def test_value(self):\n    # Should only appear if column exists and is a trigger formula.\n    self.assertEqual(\n      self.autocomplete(\"val\", \"Schools\", \"lastModified\"),\n      [\"value\"]\n    )\n    self.assertEqual(\n      self.autocomplete(\"val\", \"Students\", \"schoolCities\"),\n      []\n    )\n    self.assertEqual(\n      self.autocomplete(\"val\", \"Students\", \"nonexistentColumn\"),\n      []\n    )\n    self.assertEqual(self.autocomplete(\"valu\", \"Schools\", \"lastModifier\"),\n                     [\"value\"])\n    # Should have same type as column.\n    self.assert_autocomplete_includes(\"value.\", \"Schools\", \"lastModifier\",\n      {'value.startswith(', 'value.replace(', 'value.title('}\n    )\n    self.assert_autocomplete_includes(\"value.\", \"Schools\", \"lastModified\",\n      {'value.month', 'value.strftime(', 'value.replace('}\n    )\n    self.assert_autocomplete_includes(\"value.m\", \"Schools\", \"lastModified\",\n      {'value.month', 'value.minute'}\n    )\n\n  def test_user(self):\n    # Should only appear if column exists and is a trigger formula.\n    self.assertEqual(self.autocomplete(\"use\", \"Schools\", \"lastModified\"),\n                     [\"user\"])\n    self.assertEqual(self.autocomplete(\"use\", \"Students\", \"schoolCities\"),\n                     [])\n    self.assertEqual(self.autocomplete(\"use\", \"Students\", \"nonexistentColumn\"),\n                     [])\n    self.assertEqual(self.autocomplete(\"user\", \"Schools\", \"lastModifier\"),\n                     [\"user\"])\n    self.assertEqual(\n      self.autocomplete(\"user.\", \"Schools\", \"lastModified\", row_id=2),\n      [\n        ('user.Access', \"'owners'\"),\n        ('user.Email', \"'foo@example.com'\"),\n        ('user.IsLoggedIn', 'True'),\n        ('user.LinkKey', None),\n        ('user.Name', \"'Foo'\"),\n        ('user.Origin', 'None'),\n        ('user.SessionID', \"'u1'\"),\n        ('user.ShareRef', 'None'),\n        ('user.StudentInfo', 'Students[1]'),\n        ('user.UserID', '1'),\n        ('user.UserRef', \"'1'\")\n      ]\n    )\n    # Should follow user attribute references and autocomplete those types.\n    self.assertEqual(\n      self.autocomplete(\"user.StudentInfo.\", \"Schools\", \"lastModified\", row_id=2),\n      [\n        ('user.StudentInfo.birthDate', 'None'),\n        ('user.StudentInfo.firstName', \"'Barack'\"),\n        ('user.StudentInfo.homeAddress', 'Address[11]'),\n        ('user.StudentInfo.homeAddress.city', \"'New York'\"),\n        ('user.StudentInfo.id', '1'),\n        ('user.StudentInfo.lastName', \"'Obama'\"),\n        ('user.StudentInfo.lastVisit', 'None'),\n        ('user.StudentInfo.school', 'Schools[1]'),\n        ('user.StudentInfo.school.name', \"'Columbia'\"),\n        ('user.StudentInfo.schoolCities', repr(u'New York:Colombia')),\n        ('user.StudentInfo.schoolIds', repr(u'1:2')),\n        ('user.StudentInfo.schoolName', \"'Columbia'\"),\n      ]\n    )\n    # Should not show user attribute completions if user doesn't have attribute.\n    user2 = {\n      'Name': 'Bar',\n      'Origin': None,\n      'Email': 'baro@example.com',\n      'LinkKey': {},\n      'UserID': 2,\n      'UserRef': '2',\n      'Access': 'owners',\n      'SessionID': 'u2',\n      'IsLoggedIn': True,\n      'ShareRef': None\n    }\n    self.assertEqual(\n      self.autocomplete(\"user.\", \"Schools\", \"lastModified\", user2, row_id=2),\n      [\n        ('user.Access', \"'owners'\"),\n        ('user.Email', \"'baro@example.com'\"),\n        ('user.IsLoggedIn', 'True'),\n        ('user.LinkKey', None),\n        ('user.Name', \"'Bar'\"),\n        ('user.Origin', 'None'),\n        ('user.SessionID', \"'u2'\"),\n        ('user.ShareRef', 'None'),\n        ('user.UserID', '2'),\n        ('user.UserRef', \"'2'\"),\n      ]\n    )\n    self.assertEqual(\n      self.autocomplete(\"user.StudentInfo.\", \"Schools\", \"schoolCities\", user2),\n      []\n    )\n\n  def test_function(self):\n    self.assertEqual(self.autocomplete(\"MEDI\", \"Address\", \"city\"),\n                     [('MEDIAN', '(value, *more_values)', True)])\n    self.assert_autocomplete_includes(\"ma\", \"Address\", \"city\", {\n      ('MAX', '(value, *more_values)', True),\n      ('MAXA', '(value, *more_values)', True),\n      'map(',\n      'math',\n      'max(',\n    })\n\n  def test_member(self):\n    self.assertEqual(self.autocomplete(\"datetime.tz\", \"Address\", \"city\"),\n                     [\"datetime.tzinfo(\"])\n\n  def test_case_insensitive(self):\n    self.assertEqual(self.autocomplete(\"medi\", \"Address\", \"city\"),\n        [('MEDIAN', '(value, *more_values)', True)])\n    self.assertEqual(self.autocomplete(\"std\", \"Address\", \"city\"), [\n      ('STDEV', '(value, *more_values)', True),\n      ('STDEVA', '(value, *more_values)', True),\n      ('STDEVP', '(value, *more_values)', True),\n      ('STDEVPA', '(value, *more_values)', True)\n    ])\n    self.assertEqual(\n      self.autocomplete(\"stu\", \"Address\", \"city\"),\n      [\n        'Students',\n        ('Students.lookupOne', '(colName=<value>, ...)', True),\n        ('Students.lookupRecords', '(colName=<value>, ...)', True),\n        'Students.lookupRecords(homeAddress=$id)',\n        'Students_summary_school',\n        ('Students_summary_school.lookupOne', '(colName=<value>, ...)', True),\n        ('Students_summary_school.lookupRecords', '(colName=<value>, ...)', True)\n      ],\n    )\n\n    # Add a table name whose lowercase version conflicts with a builtin.\n    self.apply_user_action(['AddTable', 'Max', []])\n    self.assertEqual(self.autocomplete(\"max\", \"Address\", \"city\"), [\n      ('MAX', '(value, *more_values)', True),\n      ('MAXA', '(value, *more_values)', True),\n      'Max',\n      ('Max.lookupOne', '(colName=<value>, ...)', True),\n      ('Max.lookupRecords', '(colName=<value>, ...)', True),\n      'max(',\n    ])\n    self.assertEqual(self.autocomplete(\"MAX\", \"Address\", \"city\"), [\n      ('MAX', '(value, *more_values)', True),\n      ('MAXA', '(value, *more_values)', True),\n    ])\n\n\n  def test_suggest_globals_and_tables(self):\n    # Should suggest globals and table names.\n    self.assertEqual(self.autocomplete(\"ME\", \"Address\", \"city\"),\n        [('MEDIAN', '(value, *more_values)', True)])\n    self.assertEqual(\n      self.autocomplete(\"Ad\", \"Address\", \"city\"),\n      [\n        'Address',\n        ('Address.lookupOne', '(colName=<value>, ...)', True),\n        ('Address.lookupRecords', '(colName=<value>, ...)', True),\n      ],\n    )\n    self.assertGreaterEqual(set(self.autocomplete(\"S\", \"Address\", \"city\")), {\n      'Schools',\n      'Students',\n      ('SUM', '(value1, *more_values)', True),\n      ('STDEV', '(value, *more_values)', True),\n    })\n    self.assertGreaterEqual(set(self.autocomplete(\"s\", \"Address\", \"city\")), {\n      'Schools',\n      'Students',\n      'sum(',\n      ('SUM', '(value1, *more_values)', True),\n      ('STDEV', '(value, *more_values)', True),\n    })\n    self.assertEqual(\n      self.autocomplete(\"Addr\", \"Schools\", \"budget\"),\n      [\n        'Address',\n        ('Address.lookupOne', '(colName=<value>, ...)', True),\n        ('Address.lookupRecords', '(colName=<value>, ...)', True),\n      ],\n    )\n\n  def test_suggest_columns(self):\n    self.assertEqual(self.autocomplete(\"$ci\", \"Address\", \"city\"),\n                     [\"$city\"])\n    self.assertEqual(self.autocomplete(\"rec.i\", \"Address\", \"city\"),\n                     [\"rec.id\"])\n    self.assertEqual(len(self.autocomplete(\"$\", \"Address\", \"city\")),\n                     2)\n\n    # A few more detailed examples.\n    self.assertEqual(self.autocomplete(\"$\", \"Students\", \"school\"),\n                     ['$birthDate', '$firstName', '$homeAddress', '$homeAddress.city',\n                      '$id', '$lastName', '$lastVisit',\n                      '$school', '$school.name', '$schoolCities', '$schoolIds', '$schoolName'])\n    self.assertEqual(self.autocomplete(\"$fi\", \"Students\", \"birthDate\"),\n                     ['$firstName'])\n    self.assertEqual(self.autocomplete(\"$school\", \"Students\", \"lastVisit\"),\n        ['$school', '$school.name', '$schoolCities', '$schoolIds', '$schoolName'])\n\n  def test_suggest_lookup_methods(self):\n    # Should suggest lookup formulas for tables.\n    address_dot_completion = self.autocomplete(\"Address.\", \"Students\", \"firstName\")\n    # In python 3.9.7, rlcompleter stops adding parens for property attributes,\n    # see https://bugs.python.org/issue44752 - seems like a minor issue, so leave test\n    # tolerant.\n    property_aware_completer = address_dot_completion[0] == 'Address.Record'\n    self.assertEqual(address_dot_completion, [\n      'Address.Record' if property_aware_completer else ('Address.Record', '', True),\n      'Address.RecordSet' if property_aware_completer else ('Address.RecordSet', '', True),\n      'Address.all',\n      ('Address.lookupOne', '(colName=<value>, ...)', True),\n      ('Address.lookupRecords', '(colName=<value>, ...)', True),\n    ])\n\n    self.assertEqual(\n      self.autocomplete(\"Address.lookup\", \"Students\", \"lastName\"),\n      [\n        ('Address.lookupOne', '(colName=<value>, ...)', True),\n        ('Address.lookupRecords', '(colName=<value>, ...)', True),\n      ]\n    )\n\n    self.assertEqual(\n      self.autocomplete(\"address.look\", \"Students\", \"schoolName\"),\n      [\n        ('Address.lookupOne', '(colName=<value>, ...)', True),\n        ('Address.lookupRecords', '(colName=<value>, ...)', True),\n      ]\n    )\n\n  def test_suggest_column_type_methods(self):\n    # Should treat columns as correct types.\n    self.assert_autocomplete_includes(\"$firstName.\", \"Students\", \"firstName\",\n      {'$firstName.startswith(', '$firstName.replace(', '$firstName.title('}\n    )\n    self.assert_autocomplete_includes(\"$birthDate.\", \"Students\", \"lastName\",\n      {'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('}\n    )\n    self.assert_autocomplete_includes(\"$lastVisit.m\", \"Students\", \"firstName\",\n      {'$lastVisit.month', '$lastVisit.minute'}\n    )\n    self.assert_autocomplete_includes(\"$school.\", \"Students\", \"firstName\",\n      {'$school.address', '$school.name', '$school.yearFounded', '$school.budget'}\n    )\n    self.assertEqual(self.autocomplete(\"$school.year\", \"Students\", \"lastName\"),\n                     ['$school.yearFounded'])\n    self.assert_autocomplete_includes(\"$yearFounded.\", \"Schools\", \"budget\",\n      {\n        '$yearFounded.denominator',    # Only integers have this\n        '$yearFounded.bit_length(',    # and this\n        '$yearFounded.real'\n      }\n    )\n    self.assert_autocomplete_includes(\"$budget.\", \"Schools\", \"budget\",\n      {'$budget.is_integer(', '$budget.real'}    # Only floats have this\n    )\n\n  def test_suggest_follows_references(self):\n    # Should follow references and autocomplete those types.\n    self.assertEqual(\n      self.autocomplete(\"$school.name.st\", \"Students\", \"firstName\"),\n      ['$school.name.startswith(', '$school.name.strip(']\n    )\n    self.assert_autocomplete_includes(\"$school.yearFounded.\",\"Students\", \"firstName\",\n      {\n        '$school.yearFounded.denominator',\n        '$school.yearFounded.bit_length(',\n        '$school.yearFounded.real'\n      }\n    )\n\n    self.assertEqual(\n      self.autocomplete(\"$school.address.\", \"Students\", \"lastName\"),\n      ['$school.address.city', '$school.address.id']\n    )\n    self.assertEqual(\n      self.autocomplete(\"$school.address.city.st\", \"Students\", \"lastName\"),\n      ['$school.address.city.startswith(', '$school.address.city.strip(']\n    )\n\n  def test_suggest_lookup_early(self):\n    # For part of a table name, suggest lookup methods early,\n    # including a 'reverse reference' lookup, i.e. `<refcol to current table>=$id`,\n    # but only for `lookupRecords`, not `lookupOne`.\n    self.assertEqual(\n      self.autocomplete(\"stu\", \"Schools\", \"name\"),\n      [\n        'Students',\n        ('Students.lookupOne', '(colName=<value>, ...)', True),\n        ('Students.lookupRecords', '(colName=<value>, ...)', True),\n        # i.e. Students.school is a reference to Schools\n        'Students.lookupRecords(school=$id)',\n        'Students_summary_school',\n        ('Students_summary_school.lookupOne', '(colName=<value>, ...)', True),\n        ('Students_summary_school.lookupRecords', '(colName=<value>, ...)', True),\n        'Students_summary_school.lookupRecords(school=$id)',\n      ],\n    )\n    self.assertEqual(\n      self.autocomplete(\"scho\", \"Address\", \"city\"),\n      [\n        'Schools',\n        ('Schools.lookupOne', '(colName=<value>, ...)', True),\n        ('Schools.lookupRecords', '(colName=<value>, ...)', True),\n        # i.e. Schools.address is a reference to Address\n        'Schools.lookupRecords(address=$id)',\n      ],\n    )\n\n    # Same as above, but the formula is being entered in 'Students' instead of 'Address',\n    # which means there's no reverse reference to suggest.\n    self.assertEqual(\n      self.autocomplete(\"scho\", \"Students\", \"firstName\"),\n      [\n        'Schools',\n        ('Schools.lookupOne', '(colName=<value>, ...)', True),\n        ('Schools.lookupRecords', '(colName=<value>, ...)', True),\n      ],\n    )\n\n    # Test from within a summary table\n    self.assertEqual(\n      self.autocomplete(\"stu\", \"Students_summary_school\", \"count\"),\n      [\n        'Students',\n        ('Students.lookupOne', '(colName=<value>, ...)', True),\n        ('Students.lookupRecords', '(colName=<value>, ...)', True),\n        'Students_summary_school',\n        ('Students_summary_school.lookupOne', '(colName=<value>, ...)', True),\n        ('Students_summary_school.lookupRecords', '(colName=<value>, ...)', True),\n      ],\n    )\n\n  def test_suggest_lookup_arguments(self):\n    # Typing in the full `.lookupRecords(` should suggest keyword argument (i.e. column) names,\n    # in addition to reference lookups, including the reverse reference lookups above.\n    self.assertEqual(\n      self.autocomplete(\"Schools.lookupRecords(\", \"Address\", \"city\"),\n      [\n        'Schools.lookupRecords(address=',\n        'Schools.lookupRecords(address=$id)',\n        'Schools.lookupRecords(budget=',\n        'Schools.lookupRecords(id=',\n        'Schools.lookupRecords(lastModified=',\n        'Schools.lookupRecords(lastModifier=',\n        'Schools.lookupRecords(name=',\n        'Schools.lookupRecords(yearFounded=',\n      ],\n    )\n\n    # In addition to reverse reference lookups, suggest other lookups involving two reference\n    # columns (one from the looked up table, one from the current table) targeting the same table,\n    # e.g. `address=$homeAddress` in the two cases below.\n    self.assertEqual(\n      self.autocomplete(\"Schools.lookupRecords(\", \"Students\", \"firstName\"),\n      [\n        'Schools.lookupRecords(address=',\n        'Schools.lookupRecords(address=$homeAddress)',\n        'Schools.lookupRecords(budget=',\n        'Schools.lookupRecords(id=',\n        'Schools.lookupRecords(lastModified=',\n        'Schools.lookupRecords(lastModifier=',\n        'Schools.lookupRecords(name=',\n        'Schools.lookupRecords(yearFounded=',\n      ],\n    )\n\n    self.assertEqual(\n      self.autocomplete(\"Students.lookupRecords(\", \"Schools\", \"name\"),\n      [\n        'Students.lookupRecords(birthDate=',\n        'Students.lookupRecords(firstName=',\n        'Students.lookupRecords(homeAddress=',\n        'Students.lookupRecords(homeAddress=$address)',\n        'Students.lookupRecords(id=',\n        'Students.lookupRecords(lastName=',\n        'Students.lookupRecords(lastVisit=',\n        'Students.lookupRecords(school=',\n        'Students.lookupRecords(school=$id)',\n        'Students.lookupRecords(schoolCities=',\n        'Students.lookupRecords(schoolIds=',\n        'Students.lookupRecords(schoolName=',\n      ],\n    )\n\n    # Add some more reference columns to test that all combinations are offered\n    self.add_column('Students', 'homeAddress2', type='Ref:Address')\n    self.add_column('Schools', 'address2', type='Ref:Address')\n    # This leads to `Students.lookupRecords(moreAddresses=CONTAINS($address[2]))`\n    self.add_column('Students', 'moreAddresses', type='RefList:Address')\n    # This doesn't affect anything, because there's no way to do the opposite of CONTAINS()\n    self.add_column('Schools', 'otherAddresses', type='RefList:Address')\n    self.assertEqual(\n      self.autocomplete(\"Students.lookupRecords(\", \"Schools\", \"name\"),\n      [\n        'Students.lookupRecords(birthDate=',\n        'Students.lookupRecords(firstName=',\n        'Students.lookupRecords(homeAddress2=',\n        'Students.lookupRecords(homeAddress2=$address)',\n        'Students.lookupRecords(homeAddress2=$address2)',\n        'Students.lookupRecords(homeAddress=',\n        'Students.lookupRecords(homeAddress=$address)',\n        'Students.lookupRecords(homeAddress=$address2)',\n        'Students.lookupRecords(id=',\n        'Students.lookupRecords(lastName=',\n        'Students.lookupRecords(lastVisit=',\n        'Students.lookupRecords(moreAddresses=',\n        'Students.lookupRecords(moreAddresses=CONTAINS($address))',\n        'Students.lookupRecords(moreAddresses=CONTAINS($address2))',\n        'Students.lookupRecords(school=',\n        'Students.lookupRecords(school=$id)',\n        'Students.lookupRecords(schoolCities=',\n        'Students.lookupRecords(schoolIds=',\n        'Students.lookupRecords(schoolName=',\n      ],\n    )\n\n  def autocomplete(self, formula, table, column, user=None, row_id=None):\n    \"\"\"\n    Mild convenience over self.engine.autocomplete.\n    Only returns suggestions without example values, unless row_id is specified.\n    \"\"\"\n    user = user or self.user\n    results = self.engine.autocomplete(formula, table, column, row_id or 1, user)\n    if row_id is None:\n      return [result for result, value in results]\n    else:\n      return results\n\n  def assert_autocomplete_includes(self, formula, table, column, expected, user=None, row_id=None):\n    completions = self.autocomplete(formula, table, column, user=user, row_id=row_id)\n\n    def replace_completion(completion):\n      if isinstance(completion, str) and completion.endswith('()'):\n        # Python 3.10+ autocompletes the closing paren for methods with no arguments.\n        # This allows the test to check for `somestring.title(` and work across Python versions.\n        assert sys.version_info >= (3, 10)\n        return completion[:-1]\n      return completion\n\n    completions = set(replace_completion(completion) for completion in completions)\n    self.assertGreaterEqual(completions, expected)\n\n  def test_example_values(self):\n    self.assertEqual(\n      self.autocomplete(\"$\", \"Schools\", \"name\", row_id=1),\n      [\n        ('$address', 'Address[11]'),\n        ('$budget', '0.0'),\n        ('$id', '1'),\n        ('$lastModified', 'None'),\n        ('$lastModifier', repr(u'')),\n        ('$name', \"'Columbia'\"),\n        ('$yearFounded', '0'),\n      ],\n    )\n\n    self.assertEqual(\n      self.autocomplete(\"$\", \"Schools\", \"name\", row_id=3),\n      [\n        ('$address', 'Address[13]'),\n        ('$budget', '123.45'),\n        ('$id', '3'),\n        ('$lastModified', '2018-01-01 12:00am'),\n        ('$lastModifier', None),\n        ('$name', \"'Yale'\"),\n        ('$yearFounded', '2010'),\n      ],\n    )\n\n    self.assertEqual(\n      self.autocomplete(\"$\", \"Address\", \"name\", row_id=1),\n      [\n        ('$city', repr(u'')),  # for Python 2/3 compatibility\n        ('$id', '0'),  # row_id 1 doesn't exist!\n      ],\n    )\n    self.assertEqual(\n      self.autocomplete(\"$\", \"Address\", \"name\", row_id=11),\n      [\n        ('$city', \"'New York'\"),\n        ('$id', '11'),\n      ],\n    )\n    self.assertEqual(\n      self.autocomplete(\"$\", \"Address\", \"name\", row_id='new'),\n      [\n        ('$city', \"'West Haven'\"),\n        ('$id', '14'),  # row_id 'new' gets replaced with the maximum row ID in the table\n      ],\n    )\n\n    self.assertEqual(\n      self.autocomplete(\"$\", \"Students\", \"name\", row_id=1),\n      [\n        ('$birthDate', 'None'),\n        ('$firstName', \"'Barack'\"),\n        ('$homeAddress', 'Address[11]'),\n        ('$homeAddress.city', \"'New York'\"),\n        ('$id', '1'),\n        ('$lastName', \"'Obama'\"),\n        ('$lastVisit', 'None'),\n        ('$school', 'Schools[1]'),\n        ('$school.name', \"'Columbia'\"),\n        ('$schoolCities', repr(u'New York:Colombia')),\n        ('$schoolIds', repr(u'1:2')),\n        ('$schoolName', \"'Columbia'\"),\n      ],\n    )\n\n    self.assertEqual(\n      self.autocomplete(\"rec\", \"Students\", \"name\", row_id=1),\n      [\n        # Mixture of suggestions with and without values\n        (('RECORD', '(record_or_list, dates_as_iso=False, expand_refs=0)', True), None),\n        ('rec', 'Students[1]'),\n      ],\n    )\n\n  def test_repr(self):\n    date = datetime.date(2019, 12, 31)\n    dtime = datetime.datetime(2019, 12, 31, 13, 23)\n    self.assertEqual(repr_example(date), \"2019-12-31\")\n    self.assertEqual(repr_example(dtime), \"2019-12-31 1:23pm\")\n    self.assertEqual(repr_example([1, 'a', dtime, date]),\n                     \"[1, 'a', 2019-12-31 1:23pm, 2019-12-31]\")\n\n    prefix = \"<BadRepr instance at 0x\"\n    self.assertEqual(repr_example(BadRepr())[:len(prefix)], prefix)\n\n    big_list = [9] * 100000\n    self.assertEqual(len(big_list), 100000)\n    big_list_repr = repr_example(big_list)\n    self.assertEqual(len(big_list_repr), 605)\n    self.assertEqual(big_list_repr, \"[%s...]\" % (\"9, \" * 200))\n\n  def test_eval_suggestion(self):\n    class Record(object):\n      def __init__(self, name):\n        self.name = name\n\n      def __repr__(self):\n        return \"Record(%s)\" % self.name\n\n      @property\n      def bad(self):\n        raise Exception(\"bad\")\n\n    rec = Record('rec')\n    rec.subrec = Record('subrec')\n    rec.subrec.meaning = 42\n    rec.bad_repr = BadRepr()\n    rec.big = \"a\" * 100000\n    user = Record('user')\n    user.email = 'my_email'\n    user.LinkKey = Record('LinkKey')\n    user.LinkKey.id = 123\n\n    self.assertEqual(eval_suggestion('rec', rec, user), 'Record(rec)')\n    self.assertEqual(eval_suggestion('rec.subrec', rec, user), 'Record(subrec)')\n    self.assertEqual(eval_suggestion('rec.subrec.meaning', rec, user), '42')\n\n    self.assertEqual(eval_suggestion('rec.spam', rec, user), None)  # doesn't exist\n    self.assertEqual(eval_suggestion('rec.bad', rec, user), None)  # property raises an error\n\n    # attribute exists, but repr() raises an error\n    prefix = \"<BadRepr instance at 0x\"\n    self.assertEqual(eval_suggestion('rec.bad_repr', rec, user)[:len(prefix)], prefix)\n\n    # attribute exists, but repr() is too long and gets truncated\n    big_repr = repr_example(rec.big)\n    self.assertEqual(eval_suggestion('rec.big', rec, user), big_repr)\n    self.assertEqual(len(big_repr), 200)\n\n    # No string representations for these two\n    self.assertEqual(eval_suggestion('user', rec, user), None)\n    self.assertEqual(eval_suggestion('user.LinkKey', rec, user), None)\n\n    self.assertEqual(eval_suggestion('user.email', rec, user), \"'my_email'\")\n    self.assertEqual(eval_suggestion('user.LinkKey.id', rec, user), '123')\n\n    self.assertEqual(eval_suggestion('user.spam', rec, user), None)  # doesn't exist\n    self.assertEqual(eval_suggestion('user.bad', rec, user), None)  # property raises an error\n\n    self.assertEqual(eval_suggestion('subrec', rec, user), None)  # other variables not supported\n\n\nclass BadRepr(object):\n  def __repr__(self):\n    raise Exception(\"Bad repr\")\n"
  },
  {
    "path": "sandbox/grist/test_date_types.py",
    "content": "\"\"\"\nTest that Date/DateTimes produce correct types as seen by other formulas.\n\"\"\"\nimport datetime\nimport testutil\nimport test_engine\nimport moment\n\ndef D(year, month, day):\n  return moment.date_to_ts(datetime.date(year, month, day))\n\nclass TestDateTypes(test_engine.EngineTestCase):\n  def test_date_types(self):\n    date1 = D(2025, 12, 6)\n    datetime1 = D(2025, 12, 6) + (9 * 60 + 30) * 60   # Make it 9:30 on that date.\n    dateZero = D(1970, 1, 1)        # This should just be 0\n    datetimeZero = D(1970, 1, 1)    # This should just be 0\n    self.load_sample(testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Test\", [\n          [1, \"DateCol\",        \"Date\",     False],\n          [2, \"DateColType\",    \"Any\",      True, \"type($DateCol).__name__\"],\n          [3, \"DTCol\",          \"DateTime:UTC\", False],\n          [4, \"DTColType\",      \"Any\",      True, \"type($DTCol).__name__\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Test\": [\n          [\"id\",  \"DateCol\",      \"DTCol\"],\n          [   1,  date1,          datetime1],\n          [   2,  dateZero,       datetimeZero],\n          [   3,  0,              0],\n          [   5,  None,           None],\n          [   6,  \"n/a\",          \"unknown\"],\n        ]\n      }\n    }))\n\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [1], None])\n    self.assertTableData('Test', cols=\"all\", data=[\n      [\"id\",  \"DateCol\",    \"DTCol\",      \"DateColType\",  \"DTColType\"],\n      [   1,  date1,        datetime1,    \"date\",         \"datetime\"],\n      [   2,  dateZero,     datetimeZero, \"date\",         \"datetime\"],\n      [   3,  0,            0,            \"date\",         \"datetime\"],\n      [   5,  None,         None,         \"NoneType\",     \"NoneType\"],\n      [   6,  \"n/a\",        \"unknown\",    \"AltText\",      \"AltText\"],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_default_formulas.py",
    "content": "import time\nimport logging\nimport testutil\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\nclass TestDefaultFormulas(test_engine.EngineTestCase):\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Customers\", [\n        [1, \"Name\",       \"Text\",           False, \"\", \"\", \"\"],\n        [2, \"Region\",     \"Ref:Regions\",    False, \"\", \"\", \"\"],\n        [3, \"RegName\",    \"Text\",           True,  \"$Region.Region\", \"\", \"\"],\n        [4, \"SalesRep\",   \"Text\",           False, \"$Region.Rep\", \"\", \"\"],\n        [5, \"CID\",        \"Int\",            False, \"$id + 1000\", \"\", \"\"],\n      ]],\n      [2, \"Regions\", [\n        [11, \"Region\",    \"Text\",           False, \"\", \"\", \"\"],\n        [12, \"Rep\",       \"Text\",           False, \"\", \"\", \"\"]\n      ]],\n    ],\n    \"DATA\": {\n      \"Customers\": [\n        [\"id\",\"Name\",     \"Region\",   \"SalesRep\", \"CID\"],\n        [1,   \"Dolphin\",  2,          \"Neptune\",  0 ],\n      ],\n      \"Regions\": [\n        [\"id\",  \"Region\",     \"Rep\"],\n        [1,     \"Pacific\",    \"Watatsumi\"],\n        [2,     \"Atlantic\",   \"Poseidon\"],\n        [3,     \"Indian\",     \"Neptune\"],\n        [4,     \"Arctic\",     \"Poseidon\"],\n      ],\n    }\n  })\n\n  def test_default_formula_plain(self):\n    self.load_sample(self.sample)\n\n    # The defaults don't affect data that's loaded\n    self.assertTableData(\"Customers\", data=[\n      [\"id\",\"Name\",     \"Region\", \"RegName\",    \"SalesRep\", \"CID\" ],\n      [1,   \"Dolphin\",  2,        \"Atlantic\",   \"Neptune\",         0],\n    ])\n\n    # Defaults affect new records\n    self.add_record(\"Customers\", Name=\"Shark\", Region=2)\n    self.add_record(\"Customers\", Name=\"Squid\", Region=1)\n    self.assertTableData(\"Customers\", data=[\n      [\"id\",\"Name\",     \"Region\", \"RegName\",    \"SalesRep\", \"CID\"],\n      [1,   \"Dolphin\",  2,        \"Atlantic\",   \"Neptune\",  0],\n      [2,   \"Shark\",    2,        \"Atlantic\",   \"Poseidon\", 1002],    # New record\n      [3,   \"Squid\",    1,        \"Pacific\",    \"Watatsumi\",1003],    # New record\n    ])\n\n    # Changed defaults don't affect previously-added records\n    self.modify_column('Customers', 'CID', formula='$id + 2000')\n    self.add_record(\"Customers\", Name=\"Hammerhead\", Region=3)\n    self.assertTableData(\"Customers\", data=[\n      [\"id\",\"Name\",     \"Region\", \"RegName\",    \"SalesRep\", \"CID\"],\n      [1,   \"Dolphin\",  2,        \"Atlantic\",   \"Neptune\",  0],\n      [2,   \"Shark\",    2,        \"Atlantic\",   \"Poseidon\", 1002],\n      [3,   \"Squid\",    1,        \"Pacific\",    \"Watatsumi\",1003],\n      [4,   \"Hammerhead\", 3,      \"Indian\",     \"Neptune\",  2004],    # New record\n    ])\n\n    # Defaults don't affect changes to existing records\n    self.update_record(\"Customers\", 2, Region=3)\n    self.assertTableData(\"Customers\", data=[\n      [\"id\",\"Name\",     \"Region\", \"RegName\",    \"SalesRep\", \"CID\"],\n      [1,   \"Dolphin\",  2,        \"Atlantic\",   \"Neptune\",  0],\n      [2,   \"Shark\",    3,        \"Indian\",     \"Poseidon\", 1002],    # Region changed\n      [3,   \"Squid\",    1,        \"Pacific\",    \"Watatsumi\",1003],\n      [4,   \"Hammerhead\", 3,      \"Indian\",     \"Neptune\",  2004],\n    ])\n\n\n  def test_default_formula_with_lookups(self):\n    self.load_sample(self.sample)\n    self.modify_column('Customers', 'RegName', isFormula=False, formula=\"\")\n    self.modify_column('Customers', 'Region', isFormula=False,\n        formula=\"Regions.lookupOne(Region=$RegName)\")\n    self.assertTableData(\"Customers\", data=[\n      [\"id\",\"Name\",     \"Region\", \"RegName\",    \"SalesRep\", \"CID\" ],\n      [1,   \"Dolphin\",  2,        \"Atlantic\",   \"Neptune\",  0],\n    ])\n\n    # Lookup-based defaults work.\n    self.add_record(\"Customers\", Name=\"Shark\", RegName=\"Atlantic\")\n    self.add_record(\"Customers\", Name=\"Squid\", RegName=\"Pacific\")\n    self.assertTableData(\"Customers\", data=[\n      [\"id\",\"Name\",     \"Region\", \"RegName\",    \"SalesRep\", \"CID\"],\n      [1,   \"Dolphin\",  2,        \"Atlantic\",   \"Neptune\",  0],\n      [2,   \"Shark\",    2,        \"Atlantic\",   \"Poseidon\", 1002],    # New record\n      [3,   \"Squid\",    1,        \"Pacific\",    \"Watatsumi\",1003],    # New record\n    ])\n\n  def test_time_defaults(self):\n    self.load_sample(self.sample)\n    self.add_column('Customers', 'AddTime',\n        type=\"DateTime:America/Los_Angeles\", isFormula=False, formula=\"NOW()\")\n    self.add_column('Customers', 'AddDate',\n        type=\"Date\", isFormula=False, formula=\"TODAY()\")\n\n    self.assertTableData(\"Customers\", data=[\n      [\"id\",\"Name\",     \"Region\", \"RegName\",    \"SalesRep\", \"CID\",  \"AddTime\",  \"AddDate\" ],\n      [1,   \"Dolphin\",  2,        \"Atlantic\",   \"Neptune\",  0,      None,       None      ],\n    ])\n    self.add_record(\"Customers\", Name=\"Shark\", Region=2)\n    self.add_record(\"Customers\", Name=\"Squid\", Region=1)\n\n    now = time.time()\n    midnight = now - (now % (24*60*60))\n\n    # Check columns except AddTime, which we check separately below.\n    self.assertTableData(\"Customers\", cols=\"subset\", data=[\n      [\"id\",\"Name\",     \"Region\", \"RegName\",    \"SalesRep\", \"CID\",  \"AddDate\"],\n      [1,   \"Dolphin\",  2,        \"Atlantic\",   \"Neptune\",  0,      None],\n      [2,   \"Shark\",    2,        \"Atlantic\",   \"Poseidon\", 1002,   midnight],    # New record\n      [3,   \"Squid\",    1,        \"Pacific\",    \"Watatsumi\",1003,   midnight],    # New record\n    ])\n\n    # AddTime column is hard to be precise about, check it separately. Note that the timestamp\n    # does not depend on timezone, and should not change based on the timezone in the column type.\n    observed_data = self.engine.fetch_table('Customers')\n    self.assertEqual(observed_data.columns['AddTime'][0], None)\n    self.assertLessEqual(abs(observed_data.columns['AddTime'][1] - now), 2)\n    self.assertLessEqual(abs(observed_data.columns['AddTime'][2] - now), 2)\n"
  },
  {
    "path": "sandbox/grist/test_depend.py",
    "content": "import testutil\nimport test_engine\n\nclass TestDependencies(test_engine.EngineTestCase):\n  sample_desc = {\n    \"SCHEMA\": [\n      [1, \"Table1\", [\n        [1, \"Prev\",       \"Ref:Table1\",  True, \"Table1.lookupOne(id=$id-1)\", \"\", \"\"],\n        [2, \"Value\",      \"Numeric\",     False, \"\", \"\", \"\"],\n        [3, \"Sum\",        \"Numeric\",     True, \"($Prev.Sum or 0) + $Value\", \"\", \"\"],\n      ]]\n    ],\n    \"DATA\": {\n      \"Table1\": [\n        [\"id\",\"Value\"],\n      ] + [[n + 1, n + 1] for n in range(3200)]\n    }\n  }\n\n  def test_recursive_column_dependencies(self):\n    sample = testutil.parse_test_sample(self.sample_desc)\n    self.load_sample(sample)\n    self.apply_user_action(['Calculate'])\n\n    # The Sum column contains a cumulative total of the Value column\n    self.assertTableData(\"Table1\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"Value\", \"Sum\"],\n      [1,    1,       1],\n      [2,    2,       3],\n      [3,    3,       6],\n      [3200, 3200,    5121600],\n    ])\n\n    # Updating the first Value causes a cascade of changes to Sum,\n    # invalidating dependencies one cell at a time.\n    # Previously this cause a recursion error.\n    self.update_record(\"Table1\", 1, Value=11)\n    self.assertTableData(\"Table1\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"Value\", \"Sum\"],\n      [1,    11,      11],\n      [2,    2,       13],\n      [3,    3,       16],\n      [3200, 3200,    5121610],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_derived.py",
    "content": "import logging\nimport actions\n\nimport testutil\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\ndef _bulk_update(table_name, col_names, row_data):\n  return actions.BulkUpdateRecord(\n    *testutil.table_data_from_rows(table_name, col_names, row_data))\n\nclass TestDerived(test_engine.EngineTestCase):\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Customers\", [\n        [1, \"firstName\",   \"Text\",        False, \"\", \"\", \"\"],\n        [2, \"lastName\",    \"Text\",        False, \"\", \"\", \"\"],\n        [3, \"state\",       \"Text\",        False, \"\", \"\", \"\"],\n      ]],\n      [2, \"Orders\", [\n        [10, \"year\",       \"Int\",            False, \"\", \"\", \"\"],\n        [11, \"customer\",   \"Ref:Customers\",  False, \"\", \"\", \"\"],\n        [12, \"product\",    \"Text\",           False, \"\", \"\", \"\"],\n        [13, \"amount\",     \"Numeric\",        False, \"\", \"\", \"\"],\n      ]],\n    ],\n    \"DATA\": {\n      \"Customers\": [\n        [\"id\", \"firstName\", \"lastName\", \"state\"],\n        [1,   \"Lois\",     \"Long\",     \"NY\"],\n        [2,   \"Felix\",    \"Myers\",    \"NY\"],\n        [3,   \"Grace\",    \"Hawkins\",  \"CT\"],\n        [4,   \"Bessie\",   \"Green\",    \"NJ\"],\n        [5,   \"Jerome\",   \"Daniel\",   \"CT\"],\n      ],\n      \"Orders\": [\n        [\"id\",  \"year\", \"customer\", \"product\", \"amount\" ],\n        [1,     2012,   3,          \"A\",        15  ],\n        [2,     2013,   2,          \"A\",        15  ],\n        [3,     2013,   3,          \"A\",        15  ],\n        [4,     2014,   1,          \"B\",        35  ],\n        [5,     2014,   5,          \"B\",        35  ],\n        [6,     2014,   3,          \"A\",        16  ],\n        [7,     2015,   1,          \"A\",        17  ],\n        [8,     2015,   2,          \"B\",        36  ],\n        [9,     2015,   3,          \"B\",        36  ],\n        [10,    2015,   5,          \"A\",        17  ],\n      ]\n    }\n  })\n\n  def test_group_by_one(self):\n    \"\"\"\n    Test basic summary table operation, for a table grouped by one columns.\n    \"\"\"\n    self.load_sample(self.sample)\n\n    # Create a derived table summarizing count and total of orders by year.\n    self.apply_user_action([\"CreateViewSection\", 2, 0, 'record', [10], None])\n\n    # Check the results.\n    self.assertPartialData(\"Orders_summary_year\", [\"id\", \"year\", \"count\", \"amount\", \"group\" ], [\n      [1,   2012,   1,  15,   [1]],\n      [2,   2013,   2,  30,   [2,3]],\n      [3,   2014,   3,  86,   [4,5,6]],\n      [4,   2015,   4,  106,  [7,8,9,10]],\n    ])\n\n    # Updating amounts should cause totals to be updated in the summary.\n    out_actions = self.update_records(\"Orders\", [\"id\", \"amount\"], [\n      [1, 14],\n      [2, 14]\n    ])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.BulkUpdateRecord(\"Orders\", [1,2], {'amount': [14, 14]}),\n        actions.BulkUpdateRecord(\"Orders_summary_year\", [1,2], {'amount': [14, 29]})\n      ],\n      \"calls\": {\"Orders_summary_year\": {\"amount\": 2}}\n    })\n\n    # Changing a record from one product to another should cause the two affected lines to change.\n    out_actions = self.update_record(\"Orders\", 10, year=2012)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Orders\", 10, {\"year\": 2012}),\n        actions.BulkUpdateRecord(\"Orders_summary_year\", [1,4], {\"amount\": [31.0, 89.0]}),\n        actions.BulkUpdateRecord(\"Orders_summary_year\", [1,4], {\"count\": [2,3]}),\n        actions.BulkUpdateRecord(\"Orders_summary_year\", [1,4], {\"group\": [[1,10], [7,8,9]]}),\n      ],\n      \"calls\": {\"Orders_summary_year\": {\"group\": 2, \"amount\": 2, \"count\": 2},\n                \"Orders\": {\"#lookup##summary#Orders_summary_year\": 1,\n                           \"#summary#Orders_summary_year\": 1}}\n    })\n\n    self.assertPartialData(\"Orders_summary_year\", [\"id\", \"year\", \"count\", \"amount\", \"group\" ], [\n      [1,   2012,   2,  31.0,   [1,10]],\n      [2,   2013,   2,  29.0,   [2,3]],\n      [3,   2014,   3,  86.0,   [4,5,6]],\n      [4,   2015,   3,  89.0,   [7,8,9]],\n    ])\n\n    # Changing a record to a new year that wasn't in the summary should cause an add-record.\n    out_actions = self.update_record(\"Orders\", 10, year=1999)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Orders\", 10, {\"year\": 1999}),\n        actions.AddRecord(\"Orders_summary_year\", 5, {'year': 1999}),\n        actions.BulkUpdateRecord(\"Orders_summary_year\", [1,5], {\"amount\": [14.0, 17.0]}),\n        actions.BulkUpdateRecord(\"Orders_summary_year\", [1,5], {\"count\": [1,1]}),\n        actions.BulkUpdateRecord(\"Orders_summary_year\", [1,5], {\"group\": [[1], [10]]}),\n      ],\n      \"calls\": {\n        \"Orders_summary_year\": {\n          '#lookup#year': 1, \"group\": 2, \"amount\": 2, \"count\": 2, \"#lookup#\": 1\n        },\n        \"Orders\": {\"#lookup##summary#Orders_summary_year\": 1,\n                   \"#summary#Orders_summary_year\": 1}}\n    })\n\n    self.assertPartialData(\"Orders_summary_year\", [\"id\", \"year\", \"count\", \"amount\", \"group\" ], [\n      [1,   2012,   1,  14.0,   [1]],\n      [2,   2013,   2,  29.0,   [2,3]],\n      [3,   2014,   3,  86.0,   [4,5,6]],\n      [4,   2015,   3,  89.0,   [7,8,9]],\n      [5,   1999,   1,  17.0,   [10]],\n    ])\n\n\n  def test_group_by_two(self):\n    \"\"\"\n    Test a summary table created by grouping on two columns.\n    \"\"\"\n    self.load_sample(self.sample)\n\n    self.apply_user_action([\"CreateViewSection\", 2, 0, 'record', [10, 12], None])\n    self.assertPartialData(\"Orders_summary_product_year\", [\n      \"id\", \"year\", \"product\", \"count\", \"amount\", \"group\"\n    ], [\n      [1,   2012,   \"A\",  1,  15.0,   [1]],\n      [2,   2013,   \"A\",  2,  30.0,   [2,3]],\n      [3,   2014,   \"B\",  2,  70.0,   [4,5]],\n      [4,   2014,   \"A\",  1,  16.0,   [6]],\n      [5,   2015,   \"A\",  2,  34.0,   [7,10]],\n      [6,   2015,   \"B\",  2,  72.0,   [8,9]],\n    ])\n\n    # Changing a record from one product to another should cause the two affected lines to change,\n    # or new lines to be created as needed.\n    out_actions = self.update_records(\"Orders\", [\"id\", \"product\"], [\n      [2, \"B\"],\n      [6, \"B\"],\n      [7, \"C\"],\n    ])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.BulkUpdateRecord(\"Orders\", [2, 6, 7], {\"product\": [\"B\", \"B\", \"C\"]}),\n        actions.AddRecord(\"Orders_summary_product_year\", 7, {'year': 2013, 'product': 'B'}),\n        actions.AddRecord(\"Orders_summary_product_year\", 8, {'year': 2015, 'product': 'C'}),\n        actions.RemoveRecord(\"Orders_summary_product_year\", 4),\n        actions.BulkUpdateRecord(\"Orders_summary_product_year\", [2,3,5,7,8], {\n          \"amount\": [15.0, 86.0, 17.0, 15.0, 17.0]\n        }),\n        actions.BulkUpdateRecord(\"Orders_summary_product_year\", [2,3,5,7,8], {\n          \"count\": [1, 3, 1, 1, 1]\n        }),\n        actions.BulkUpdateRecord(\"Orders_summary_product_year\", [2,3,5,7,8], {\n          \"group\": [[3], [4,5,6], [10], [2], [7]]\n        }),\n      ],\n    })\n\n    # Verify the results.\n    self.assertPartialData(\"Orders_summary_product_year\", [\n      \"id\", \"year\", \"product\", \"count\", \"amount\", \"group\"\n    ], [\n      [1,   2012,   \"A\",  1,  15.0,   [1]],\n      [2,   2013,   \"A\",  1,  15.0,   [3]],\n      [3,   2014,   \"B\",  3,  86.0,   [4,5,6]],\n      [5,   2015,   \"A\",  1,  17.0,   [10]],\n      [6,   2015,   \"B\",  2,  72.0,   [8,9]],\n      [7,   2013,   \"B\",  1,  15.0,   [2]],\n      [8,   2015,   \"C\",  1,  17.0,   [7]],\n    ])\n\n  def test_group_with_references(self):\n    \"\"\"\n    Test summary tables grouped on indirect values. In this example we want for each\n    customer.state, the number of customers and the total of their orders, which we can do either\n    as a summary on the Customers table, or a summary on the Orders table.\n    \"\"\"\n    self.load_sample(self.sample)\n\n    # Create a summary on the Customers table. Adding orders involves a lookup for each customer.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, 'record', [3], None])\n    self.add_column(\"Customers_summary_state\", \"totalAmount\",\n      formula=\"sum(sum(Orders.lookupRecords(customer=c).amount) for c in $group)\")\n\n    self.assertPartialData(\"Customers_summary_state\", [\"id\", \"state\", \"count\", \"totalAmount\"], [\n      [1,   \"NY\",   2,  103.0 ],\n      [2,   \"CT\",   2,  134.0 ],\n      [3,   \"NJ\",   1,    0.0 ],\n    ])\n\n    # # Create the same summary on the Orders table, looking up 'state' via the Customer reference.\n    # self.apply_user_action([\"AddDerivedTableSource\", \"Summary4\", \"Orders\",\n    #                         {\"state\": \"$customer.state\"}])\n    # self.add_column(\"Summary4\", \"numCustomers\", formula=\"len(set($source_Orders.customer))\")\n    # self.add_column(\"Summary4\", \"totalAmount\", formula=\"sum($source_Orders.amount)\")\n\n    # self.assertPartialData(\"Summary4\", [\"id\", \"state\", \"numCustomers\", \"totalAmount\"], [\n    #   [1,   \"CT\",   2,  134.0 ],\n    #   [2,   \"NY\",   2,  103.0 ],\n    # ])\n\n    # In either case, changing an amount (from 36->37 for a CT customer) should update summaries.\n    out_actions = self.update_record('Orders', 9, amount=37)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Orders\", 9, {\"amount\": 37}),\n        actions.UpdateRecord(\"Customers_summary_state\", 2, {\"totalAmount\": 135.0}),\n      ]\n    })\n\n    # In either case, changing a customer's state should trigger recomputation too.\n    # We are changing a NY customer with $51 in orders to MA.\n    self.update_record('Customers', 2, state=\"MA\")\n    self.assertPartialData(\"Customers_summary_state\", [\"id\", \"state\", \"count\", \"totalAmount\"], [\n      [1,   \"NY\",   1,   52.0 ],\n      [2,   \"CT\",   2,  135.0 ],\n      [3,   \"NJ\",   1,    0.0 ],\n      [4,   \"MA\",   1,   51.0 ],\n    ])\n    # self.assertPartialData(\"Summary4\", [\"id\", \"state\", \"numCustomers\", \"totalAmount\"], [\n    #   [1,   \"CT\",   2,  135.0 ],\n    #   [2,   \"NY\",   1,   52.0 ],\n    #   [3,   \"MA\",   1,   51.0 ],\n    # ])\n\n    # Similarly, changing an Order to refer to a different customer should update both tables.\n    # Here we are changing a $17 order (#7) for a NY customer (#1) to a NJ customer (#4).\n    out_actions = self.update_record(\"Orders\", 7, customer=4)\n    # self.assertPartialOutActions(out_actions, {\n    #   \"stored\": [actions.UpdateRecord(\"Orders\", 7, {\"customer\": 4}),\n    #              actions.AddRecord(\"Summary4\", 4, {\"state\": \"NJ\"}),\n    #              actions.UpdateRecord(\"Summary4\", 4, {\"manualSort\": 4.0})]\n    # })\n    self.assertPartialData(\"Customers_summary_state\", [\"id\", \"state\", \"count\", \"totalAmount\"], [\n      [1,   \"NY\",   1,   35.0 ],\n      [2,   \"CT\",   2,  135.0 ],\n      [3,   \"NJ\",   1,   17.0 ],\n      [4,   \"MA\",   1,   51.0 ],\n    ])\n    # self.assertPartialData(\"Summary4\", [\"id\", \"state\", \"numCustomers\", \"totalAmount\"], [\n    #   [1,   \"CT\",   2,  135.0 ],\n    #   [2,   \"NY\",   1,   35.0 ],\n    #   [3,   \"MA\",   1,   51.0 ],\n    #   [4,   \"NJ\",   1,   17.0 ],\n    # ])\n\n  def test_deletions(self):\n    self.load_sample(self.sample)\n\n    # Create a summary table summarizing count and total of orders by year.\n    self.apply_user_action([\"CreateViewSection\", 2, 0, 'record', [10], None])\n    self.assertPartialData(\"Orders_summary_year\", [\"id\", \"year\", \"count\", \"amount\", \"group\" ], [\n      [1,   2012,   1,  15.0,   [1]],\n      [2,   2013,   2,  30.0,   [2,3]],\n      [3,   2014,   3,  86.0,   [4,5,6]],\n      [4,   2015,   4,  106.0,  [7,8,9,10]],\n    ])\n\n    # Update a record so that a new line appears in the summary table.\n    out_actions_update = self.update_record(\"Orders\", 1, year=2007)\n    self.assertPartialData(\"Orders_summary_year\", [\"id\", \"year\", \"count\", \"amount\", \"group\" ], [\n      [2,   2013,   2,  30.0,   [2,3]],\n      [3,   2014,   3,  86.0,   [4,5,6]],\n      [4,   2015,   4,  106.0,  [7,8,9,10]],\n      [5,   2007,   1,  15.0,   [1]],\n    ])\n\n    self.assertPartialOutActions(out_actions_update, {\n      'stored': [\n        ['UpdateRecord', 'Orders', 1, {'year': 2007}],\n        ['AddRecord', 'Orders_summary_year', 5, {'year': 2007}],\n        ['RemoveRecord', 'Orders_summary_year', 1],\n        ['UpdateRecord', 'Orders_summary_year', 5, {'amount': 15.0}],\n        ['UpdateRecord', 'Orders_summary_year', 5, {'count': 1}],\n        ['UpdateRecord', 'Orders_summary_year', 5, {'group': ['L', 1]}],\n      ],\n      'undo': [\n        ['UpdateRecord', 'Orders_summary_year', 1, {'group': ['L', 1]}],\n        ['UpdateRecord', 'Orders_summary_year', 1, {'count': 1}],\n        ['UpdateRecord', 'Orders_summary_year', 1, {'amount': 15.0}],\n        ['UpdateRecord', 'Orders', 1, {'year': 2012}],\n        ['RemoveRecord', 'Orders_summary_year', 5],\n        ['AddRecord', 'Orders_summary_year', 1, {'group': ['L'], 'year': 2012}],\n      ]})\n\n    # Undo and ensure that the new line is gone from the summary table.\n    out_actions_undo = self.apply_undo_actions(out_actions_update.undo)\n    self.assertPartialData(\"Orders_summary_year\", [\"id\", \"year\", \"count\", \"amount\", \"group\" ], [\n      [1,   2012,   1,  15.0,   [1]],\n      [2,   2013,   2,  30.0,   [2,3]],\n      [3,   2014,   3,  86.0,   [4,5,6]],\n      [4,   2015,   4,  106.0,  [7,8,9,10]],\n    ])\n    self.assertPartialOutActions(out_actions_undo, {\n      \"stored\": out_actions_update.undo[::-1],\n      \"calls\": {\n        \"Orders_summary_year\": {\n          \"#lookup#\": 1, \"#lookup#year\": 1, \"group\": 1, \"amount\": 1, \"count\": 1\n        },\n        \"Orders\": {\n          \"#lookup##summary#Orders_summary_year\": 1, \"#summary#Orders_summary_year\": 1,\n        },\n      },\n    })\n"
  },
  {
    "path": "sandbox/grist/test_display_cols.py",
    "content": "import logging\n\nimport testutil\nimport test_engine\nfrom test_engine import Table, Column\n\nlog = logging.getLogger(__name__)\n\nclass TestUserActions(test_engine.EngineTestCase):\n  ref_sample = testutil.parse_test_sample({\n    # pylint: disable=line-too-long\n    \"SCHEMA\": [\n      [1, \"Television\", [\n        [21, \"show\",    \"Text\", False, \"\", \"\", \"\"],\n        [22, \"network\", \"Text\", False, \"\", \"\", \"\"],\n        [23, \"viewers\", \"Int\",  False, \"\", \"\", \"\"]\n      ]]\n    ],\n    \"DATA\": {\n      \"Television\": [\n        [\"id\",  \"show\"           , \"network\", \"viewers\"],\n        [11,    \"Game of Thrones\", \"HBO\"    , 100],\n        [12,    \"Narcos\"         , \"Netflix\", 500],\n        [13,    \"Today\"          , \"NBC\"    , 200],\n        [14,    \"Empire\"         , \"Fox\"    , 300]],\n    }\n  })\n\n  def test_display_cols(self):\n    # Test the implementation of display columns which adds a column modified by\n    # a formula as a display version of the original column.\n\n    self.load_sample(self.ref_sample)\n\n    # Add a new table for People so that we get the associated views and fields.\n    self.apply_user_action(['AddTable', 'Favorites', [{'id': 'favorite', 'type':\n      'Ref:Television'}]])\n    self.apply_user_action(['BulkAddRecord', 'Favorites', [1,2,3,4,5], {\n      'favorite': [2, 4, 1, 4, 3]\n    }])\n    self.assertTables([\n      Table(1, \"Television\", 0, 0, columns=[\n        Column(21, \"show\",    \"Text\", False,  \"\", 0),\n        Column(22, \"network\", \"Text\", False,  \"\", 0),\n        Column(23, \"viewers\", \"Int\",  False,  \"\", 0),\n      ]),\n      Table(2, \"Favorites\", 1, 0, columns=[\n        Column(24, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(25, \"favorite\", \"Ref:Television\", False,  \"\", 0),\n      ]),\n    ])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,          25,            0],\n      [2,          25,            0],\n      [3,          25,            0],\n    ])\n    self.assertTableData(\"Favorites\", cols=\"subset\", data=[\n      [\"id\",  \"favorite\"],\n      [1, 2],\n      [2, 4],\n      [3, 1],\n      [4, 4],\n      [5, 3]\n    ])\n\n    # Add an extra view for the new table to test multiple fields at once\n    self.apply_user_action(['AddView', 'Favorites', 'raw_data', 'Extra View'])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,          25,            0],\n      [2,          25,            0],\n      [3,          25,            0],\n      [4,          25,            0],\n    ])\n\n    # Set display formula for 'favorite' column.\n    # A \"gristHelper_Display\" column with the requested formula should be added and set as the\n    # displayCol of the favorite column.\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', None, 25, '$favorite.show'])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id >= 25), data=[\n      [\"id\", \"colId\", \"parentId\", \"displayCol\", \"formula\"],\n      [25, \"favorite\",      2, 26, \"\"],\n      [26, \"gristHelper_Display\", 2,  0, \"$favorite.show\"]\n    ])\n\n    # Set display formula for 'favorite' column fields.\n    # A single \"gristHelper_Display2\" column should be added with the requested formula, since both\n    # require the same formula. The fields' colRefs should be set to the new column.\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', 1, None, '$favorite.network'])\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', 4, None, '$favorite.network'])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id >= 25), data=[\n      [\"id\", \"colId\", \"parentId\", \"displayCol\", \"formula\"],\n      [25, \"favorite\",       2, 26, \"\"],\n      [26, \"gristHelper_Display\",  2,  0, \"$favorite.show\"],\n      [27, \"gristHelper_Display2\", 2,  0, \"$favorite.network\"],\n    ])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,   25,   27],\n      [2,   25,    0],\n      [3,   25,    0],\n      [4,   25,   27],\n    ])\n\n    # Change display formula for a field.\n    # Since the field is changing to use a formula not yet held by a display column,\n    # a new display column should be added with the desired formula.\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', 4, None, '$favorite.viewers'])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id >= 25), data=[\n      [\"id\", \"colId\", \"parentId\", \"displayCol\", \"formula\"],\n      [25, \"favorite\",       2, 26, \"\"],\n      [26, \"gristHelper_Display\",  2,  0, \"$favorite.show\"],\n      [27, \"gristHelper_Display2\", 2,  0, \"$favorite.network\"],\n      [28, \"gristHelper_Display3\", 2,  0, \"$favorite.viewers\"]\n    ])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,   25,   27],\n      [2,   25,    0],\n      [3,   25,    0],\n      [4,   25,   28],\n    ])\n\n    # Remove a field.\n    # This should also remove the display column used by that field, since it is not used\n    # by any other fields.\n    self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 4])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id >= 25), data=[\n      [\"id\", \"colId\", \"parentId\", \"displayCol\", \"formula\"],\n      [25, \"favorite\",       2, 26, \"\"],\n      [26, \"gristHelper_Display\",  2,  0, \"$favorite.show\"],\n      [27, \"gristHelper_Display2\", 2,  0, \"$favorite.network\"],\n    ])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,   25,   27],\n      [2,   25,    0],\n      [3,   25,    0],\n    ])\n\n    # Add a new column with a formula.\n    self.apply_user_action(['AddVisibleColumn', 'Favorites', 'fav_viewers', {\n      'formula': '$favorite.viewers'\n    }])\n    # Add a field back for the favorites table and set its display formula to the\n    # same formula that the new column has. Make sure that the new column is NOT used as\n    # the display column.\n    self.apply_user_action(['AddRecord', '_grist_Views_section_field', None, {\n      'parentId': 3,\n      'colRef': 25\n    }])\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', 8, None, '$favorite.viewers'])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id >= 25), data=[\n      [\"id\", \"colId\", \"parentId\", \"displayCol\", \"formula\"],\n      [25, \"favorite\",       2, 26, \"\"],\n      [26, \"gristHelper_Display\",  2,  0, \"$favorite.show\"],\n      [27, \"gristHelper_Display2\", 2,  0, \"$favorite.network\"],\n      [28, \"fav_viewers\",    2,  0, \"$favorite.viewers\"],\n      [29, \"gristHelper_Display3\", 2,  0, \"$favorite.viewers\"]\n    ])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,   25,   27],\n      [2,   25,    0],\n      [3,   25,    0],\n      [4,   28,    0], # fav_viewers field\n      [5,   28,    0], # fav_viewers field\n      [6,   28,    0], # fav_viewers field\n      [7,   28,    0], # re-added field w/ display col\n      [8,   25,    29], # fav_viewers field\n    ])\n\n    # Change the display formula for a field to be the same as the other field, then remove\n    # the field.\n    # The display column should not be removed since it is still in use.\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', 8, None, '$favorite.network'])\n    self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 8])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id >= 25), data=[\n      [\"id\", \"colId\", \"parentId\", \"displayCol\", \"formula\"],\n      [25, \"favorite\",      2, 26, \"\"],\n      [26, \"gristHelper_Display\", 2,  0, \"$favorite.show\"],\n      [27, \"gristHelper_Display2\",2,  0, \"$favorite.network\"],\n      [28, \"fav_viewers\",   2,  0, \"$favorite.viewers\"],\n    ])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,   25,   27],\n      [2,   25,   0],\n      [3,   25,   0],\n      [4,   28,   0],\n      [5,   28,   0],\n      [6,   28,   0],\n      [7,   28,   0],\n    ])\n\n    # Clear field display formula, then set it again.\n    # Clearing the display formula should remove the display column, since it is no longer\n    # used by any column or field.\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', 1, None, ''])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id >= 25), data=[\n      [\"id\", \"colId\", \"parentId\", \"displayCol\", \"formula\"],\n      [25, \"favorite\",      2, 26, \"\"],\n      [26, \"gristHelper_Display\", 2,  0, \"$favorite.show\"],\n      [28, \"fav_viewers\",   2,  0, \"$favorite.viewers\"],\n    ])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,   25,   0],\n      [2,   25,   0],\n      [3,   25,   0],\n      [4,   28,   0],\n      [5,   28,   0],\n      [6,   28,   0],\n      [7,   28,   0],\n    ])\n    # Setting the display formula should add another display column.\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', 1, None, '$favorite.viewers'])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id >= 25), data=[\n      [\"id\", \"colId\", \"parentId\", \"displayCol\", \"formula\"],\n      [25, \"favorite\",      2, 26, \"\"],\n      [26, \"gristHelper_Display\", 2,  0, \"$favorite.show\"],\n      [28, \"fav_viewers\",   2,  0, \"$favorite.viewers\"],\n      [29, \"gristHelper_Display2\",2,  0, \"$favorite.viewers\"],\n    ])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,   25,  29],\n      [2,   25,   0],\n      [3,   25,   0],\n      [4,   28,   0],\n      [5,   28,   0],\n      [6,   28,   0],\n      [7,   28,   0],\n    ])\n\n    # Change column display formula.\n    # This should re-use the current display column since it is only used by the column.\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', None, 25, '$favorite.network'])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id >= 25), data=[\n      [\"id\", \"colId\", \"parentId\", \"displayCol\", \"formula\"],\n      [25, \"favorite\",      2, 26, \"\"],\n      [26, \"gristHelper_Display\",2,  0, \"$favorite.network\"],\n      [28, \"fav_viewers\",   2,  0, \"$favorite.viewers\"],\n      [29, \"gristHelper_Display2\",2,  0, \"$favorite.viewers\"],\n    ])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,   25,  29],\n      [2,   25,   0],\n      [3,   25,   0],\n      [4,   28,   0],\n      [5,   28,   0],\n      [6,   28,   0],\n      [7,   28,   0],\n    ])\n\n    # Remove column.\n    # This should remove the display column used by the column.\n    self.apply_user_action(['RemoveColumn', \"Favorites\", \"favorite\"])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id >= 25), data=[\n      [\"id\", \"colId\", \"parentId\", \"displayCol\", \"formula\"],\n      [28, \"fav_viewers\",   2,  0, \"$favorite.viewers\"]\n    ])\n    self.assertTableData(\"_grist_Views_section_field\", cols=\"subset\", data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [4,   28,   0],\n      [5,   28,   0],\n      [6,   28,   0],\n      [7,   28,   0],\n    ])\n\n\n  def test_display_col_removal(self):\n    # Test that when removing a column, we don't produce unnecessary calc actions for a display\n    # column that may also get auto-removed.\n\n    self.load_sample(self.ref_sample)\n\n    # Create a display column.\n    self.apply_user_action(['SetDisplayFormula', 'Television', None, 21, '$show.upper()'])\n\n    # Verify the state of columns and display columns.\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [\"id\",  \"colId\",                \"type\", \"displayCol\", \"formula\" ],\n      [21,    \"show\",                 \"Text\", 24          , \"\"        ],\n      [22,    \"network\",              \"Text\", 0           , \"\"        ],\n      [23,    \"viewers\",              \"Int\",  0           , \"\"        ],\n      [24,    \"gristHelper_Display\",  \"Any\",  0           , \"$show.upper()\"]\n    ])\n    self.assertTableData(\"Television\", cols=\"all\", data=[\n      [\"id\",  \"show\"           , \"network\", \"viewers\",  \"gristHelper_Display\"],\n      [11,    \"Game of Thrones\", \"HBO\"    , 100,        \"GAME OF THRONES\"],\n      [12,    \"Narcos\"         , \"Netflix\", 500,        \"NARCOS\"],\n      [13,    \"Today\"          , \"NBC\"    , 200,        \"TODAY\"],\n      [14,    \"Empire\"         , \"Fox\"    , 300,        \"EMPIRE\"],\n    ])\n\n    # Remove the column that has a displayCol referring to it.\n    out_actions = self.apply_user_action(['RemoveColumn', 'Television', 'show'])\n\n    # Verify that the resulting actions don't include any calc actions.\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"BulkRemoveRecord\", \"_grist_Tables_column\", [21, 24]],\n        [\"RemoveColumn\", \"Television\", \"show\"],\n        [\"RemoveColumn\", \"Television\", \"gristHelper_Display\"],\n      ],\n      \"calc\": []\n    })\n\n    # Verify the state of columns and display columns afterwards.\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [\"id\",  \"colId\",                \"type\", \"displayCol\", \"formula\" ],\n      [22,    \"network\",              \"Text\", 0           , \"\"        ],\n      [23,    \"viewers\",              \"Int\",  0           , \"\"        ],\n    ])\n    self.assertTableData(\"Television\", cols=\"all\", data=[\n      [\"id\",  \"network\", \"viewers\"  ],\n      [11,    \"HBO\"    , 100        ],\n      [12,    \"Netflix\", 500        ],\n      [13,    \"NBC\"    , 200        ],\n      [14,    \"Fox\"    , 300        ],\n    ])\n\n  def test_display_col_and_field_removal(self):\n    # When there are different displayCols associated with the column and with the field, removal\n    # takes more steps, and order of produced actions matters.\n    self.load_sample(self.ref_sample)\n\n    # Add a table for people, which includes an associated view.\n    self.apply_user_action(['AddTable', 'People', [\n      {'id': 'name', 'type': 'Text'},\n      {'id': 'favorite', 'type': 'Ref:Television',\n       'widgetOptions': '\\\"{\\\"alignment\\\":\\\"center\\\",\\\"visibleCol\\\":\\\"show\\\"}\\\"'},\n    ]])\n    self.apply_user_action(['BulkAddRecord', 'People', [1,2,3], {\n      'name': ['Bob', 'Jim', 'Don'],\n      'favorite': [12, 11, 13]\n    }])\n\n    # Add a display formula for the 'favorite' column. A \"gristHelper_Display\" column with the\n    # requested formula should be added and set as the displayCol of the favorite column.\n    self.apply_user_action(['SetDisplayFormula', 'People', None, 26, '$favorite.show'])\n\n    # Set display formula for 'favorite' column field.\n    # A single \"gristHelper_Display2\" column should be added with the requested formula.\n    self.apply_user_action(['SetDisplayFormula', 'People', 2, None, '$favorite.network'])\n\n    expected_tables1 = [\n      Table(1, \"Television\", 0, 0, columns=[\n        Column(21, \"show\",    \"Text\", False,  \"\", 0),\n        Column(22, \"network\", \"Text\", False,  \"\", 0),\n        Column(23, \"viewers\", \"Int\",  False,  \"\", 0),\n      ]),\n      Table(2, \"People\", 1, 0, columns=[\n        Column(24, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(25, \"name\", \"Text\", False,  \"\", 0),\n        Column(26, \"favorite\", \"Ref:Television\", False,  \"\", 0),\n        Column(27, \"gristHelper_Display\", \"Any\", True, \"$favorite.show\", 0),\n        Column(28, \"gristHelper_Display2\", \"Any\", True, \"$favorite.network\", 0)\n      ]),\n    ]\n    expected_data1 = [\n      [\"id\", \"name\", \"favorite\", \"gristHelper_Display\", \"gristHelper_Display2\"],\n      [1,    \"Bob\",  12,         \"Narcos\",              \"Netflix\"],\n      [2,    \"Jim\",  11,         \"Game of Thrones\",     \"HBO\"],\n      [3,    \"Don\",  13,         \"Today\",               \"NBC\"]\n    ]\n    self.assertTables(expected_tables1)\n    self.assertTableData(\"People\", cols=\"subset\", data=expected_data1)\n    self.assertTableData(\n      \"_grist_Views_section_field\", cols=\"subset\", rows=lambda r: r.parentId.parentId, data=[\n      [\"id\", \"parentId\", \"colRef\", \"displayCol\"],\n      [1,    1,          25,       0],\n      [2,    1,          26,       28],\n    ])\n\n    # Now remove the 'favorite' column.\n    out_actions = self.apply_user_action(['RemoveColumn', 'People', 'favorite'])\n\n    # The associated field and both displayCols should be gone.\n    self.assertTables([\n      expected_tables1[0],\n      Table(2, \"People\", 1, 0, columns=[\n        Column(24, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(25, \"name\", \"Text\", False,  \"\", 0),\n      ]),\n    ])\n    self.assertTableData(\n      \"_grist_Views_section_field\", cols=\"subset\", rows=lambda r: r.parentId.parentId, data=[\n      [\"id\", \"parentId\", \"colRef\", \"displayCol\"],\n      [1,    1,          25,       0],\n    ])\n\n    # Verify that the resulting actions don't include any extraneous calc actions.\n    # pylint:disable=line-too-long\n    self.assertOutActions(out_actions, {\n      \"stored\": [\n        [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [2, 4, 6]],\n        [\"BulkRemoveRecord\", \"_grist_Tables_column\", [26, 27]],\n        [\"RemoveColumn\", \"People\", \"favorite\"],\n        [\"RemoveColumn\", \"People\", \"gristHelper_Display\"],\n        [\"RemoveRecord\", \"_grist_Tables_column\", 28],\n        [\"RemoveColumn\", \"People\", \"gristHelper_Display2\"],\n      ],\n      \"direct\": [True, True, True, True, False, False],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"People\", [1, 2, 3], {\"gristHelper_Display2\": [\"Netflix\", \"HBO\", \"NBC\"]}],\n        [\"BulkUpdateRecord\", \"People\", [1, 2, 3], {\"gristHelper_Display\": [\"Narcos\", \"Game of Thrones\", \"Today\"]}],\n        [\"BulkAddRecord\", \"_grist_Views_section_field\", [2, 4, 6], {\"colRef\": [26, 26, 26], \"displayCol\": [28, 0, 0], \"parentId\": [1, 2, 3], \"parentPos\": [2.0, 4.0, 6.0]}],\n        [\"BulkAddRecord\", \"_grist_Tables_column\", [26, 27], {\"colId\": [\"favorite\", \"gristHelper_Display\"], \"displayCol\": [27, 0], \"formula\": [\"\", \"$favorite.show\"], \"isFormula\": [False, True], \"label\": [\"favorite\", \"gristHelper_Display\"], \"parentId\": [2, 2], \"parentPos\": [6.0, 7.0], \"type\": [\"Ref:Television\", \"Any\"], \"widgetOptions\": [\"\\\"{\\\"alignment\\\":\\\"center\\\",\\\"visibleCol\\\":\\\"show\\\"}\\\"\", \"\"]}],\n        [\"BulkUpdateRecord\", \"People\", [1, 2, 3], {\"favorite\": [12, 11, 13]}],\n        [\"AddColumn\", \"People\", \"favorite\", {\"formula\": \"\", \"isFormula\": False, \"type\": \"Ref:Television\"}],\n        [\"AddColumn\", \"People\", \"gristHelper_Display\", {\"formula\": \"$favorite.show\", \"isFormula\": True, \"type\": \"Any\"}],\n        [\"AddRecord\", \"_grist_Tables_column\", 28, {\"colId\": \"gristHelper_Display2\", \"formula\": \"$favorite.network\", \"isFormula\": True, \"label\": \"gristHelper_Display2\", \"parentId\": 2, \"parentPos\": 8.0, \"type\": \"Any\"}],\n        [\"AddColumn\", \"People\", \"gristHelper_Display2\", {\"formula\": \"$favorite.network\", \"isFormula\": True, \"type\": \"Any\"}],\n      ],\n    })\n\n    # Now undo; expect the structure and values restored.\n    stored_actions = out_actions.get_repr()[\"stored\"]\n    undo_actions = out_actions.get_repr()[\"undo\"]\n    out_actions = self.apply_user_action(['ApplyUndoActions', undo_actions])\n    self.assertTables(expected_tables1)\n    self.assertTableData(\"People\", cols=\"subset\", data=expected_data1)\n    self.assertTableData(\n      \"_grist_Views_section_field\", cols=\"subset\", rows=lambda r: r.parentId.parentId, data=[\n      [\"id\", \"parentId\", \"colRef\", \"displayCol\"],\n      [1,    1,          25,       0],\n      [2,    1,          26,       28],\n    ])\n\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": reversed(undo_actions),\n    })\n\n  def test_display_col_copying(self):\n    # Test that when switching types and using CopyFromColumn, displayCol is set/unset correctly.\n\n    self.load_sample(self.ref_sample)\n\n    # Add a new table for People so that we get the associated views and fields.\n    self.apply_user_action(['AddTable', 'Favorites', [\n      {'id': 'favorite', 'type': 'Ref:Television'},\n      {'id': 'favorite2', 'type': 'Text'}]])\n    self.apply_user_action(['BulkAddRecord', 'Favorites', [1,2,3,4,5], {\n      'favorite': [2, 4, 1, 4, 3]\n    }])\n\n    # Set a displayCol.\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', None, 25, '$favorite.show'])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id > 24), data=[\n      [\"id\" , \"colId\"               , \"parentId\", \"displayCol\", \"type\",   \"formula\"],\n      [25   , \"favorite\"            , 2         , 27          , \"Ref:Television\", \"\"],\n      [26   , \"favorite2\"           , 2         , 0           , \"Text\",   \"\"],\n      [27   , \"gristHelper_Display\" , 2         , 0           , \"Any\",    \"$favorite.show\"],\n    ])\n\n    # Copy 'favorite' to 'favorite2': displayCol should be set on the latter.\n    self.apply_user_action(['CopyFromColumn', 'Favorites', 'favorite', 'favorite2', None])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id > 24), data=[\n      [\"id\" , \"colId\"               , \"parentId\", \"displayCol\", \"type\",   \"formula\"],\n      [25   , \"favorite\"            , 2         , 27          , \"Ref:Television\", \"\"],\n      [26   , \"favorite2\"           , 2         , 28          , \"Ref:Television\", \"\"],\n      [27   , \"gristHelper_Display\" , 2         , 0           , \"Any\",    \"$favorite.show\"],\n      [28   , \"gristHelper_Display2\", 2         , 0           , \"Any\",    \"$favorite2.show\"],\n    ])\n\n    # SetDisplyFormula to a different formula: displayCol should get reused.\n    self.apply_user_action(['SetDisplayFormula', 'Favorites', None, 25, '$favorite.network'])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id > 24), data=[\n      [\"id\" , \"colId\"               , \"parentId\", \"displayCol\", \"type\",   \"formula\"],\n      [25   , \"favorite\"            , 2         , 27          , \"Ref:Television\", \"\"],\n      [26   , \"favorite2\"           , 2         , 28          , \"Ref:Television\", \"\"],\n      [27   , \"gristHelper_Display\" , 2         , 0           , \"Any\",    \"$favorite.network\"],\n      [28   , \"gristHelper_Display2\", 2         , 0           , \"Any\",    \"$favorite2.show\"],\n    ])\n\n    # Copy again; the destination displayCol should get adjusted but reused.\n    self.apply_user_action(['CopyFromColumn', 'Favorites', 'favorite', 'favorite2', None])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id > 24), data=[\n      [\"id\" , \"colId\"               , \"parentId\", \"displayCol\", \"type\",   \"formula\"],\n      [25   , \"favorite\"            , 2         , 27          , \"Ref:Television\", \"\"],\n      [26   , \"favorite2\"           , 2         , 28          , \"Ref:Television\", \"\"],\n      [27   , \"gristHelper_Display\" , 2         , 0           , \"Any\",    \"$favorite.network\"],\n      [28   , \"gristHelper_Display2\", 2         , 0           , \"Any\",    \"$favorite2.network\"],\n    ])\n\n    # If we change column type, the displayCol should get unset and deleted.\n    out_actions = self.apply_user_action(['ModifyColumn', 'Favorites', 'favorite',\n                                          {'type': 'Numeric'}])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id > 24), data=[\n      [\"id\" , \"colId\"               , \"parentId\", \"displayCol\", \"type\",           \"formula\"],\n      [25   , \"favorite\"            , 2         , 0           , \"Numeric\",        \"\"],\n      [26   , \"favorite2\"           , 2         , 28          , \"Ref:Television\", \"\"],\n      [28   , \"gristHelper_Display2\", 2         , 0           , \"Any\",    \"$favorite2.network\"],\n    ])\n\n    # Copy again; the destination displayCol should now get deleted too.\n    self.apply_user_action(['CopyFromColumn', 'Favorites', 'favorite', 'favorite2', None])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.id > 24), data=[\n      [\"id\" , \"colId\"               , \"parentId\", \"displayCol\", \"type\",      \"formula\"],\n      [25   , \"favorite\"            , 2         , 0           , \"Numeric\",   \"\"],\n      [26   , \"favorite2\"           , 2         , 0           , \"Numeric\",   \"\"],\n    ])\n\n  def test_display_col_table_rename(self):\n    self.load_sample(self.ref_sample)\n\n    # Add a table for people to get an associated view.\n    self.apply_user_action(['AddTable', 'People', [\n      {'id': 'name', 'type': 'Text'},\n      {'id': 'favorite', 'type': 'Ref:Television',\n       'widgetOptions': '\\\"{\\\"alignment\\\":\\\"center\\\",\\\"visibleCol\\\":\\\"show\\\"}\\\"'},\n      {'id': 'network', 'type': 'Any', 'isFormula': True,\n       'formula': 'Television.lookupOne(show=rec.favorite.show).network'}]])\n    self.apply_user_action(['BulkAddRecord', 'People', [1,2,3], {\n      'name': ['Bob', 'Jim', 'Don'],\n      'favorite': [12, 11, 13]\n    }])\n\n    # Add a display formula for the 'favorite' column.\n    # A \"gristHelper_Display\" column with the requested formula should be added and set as the\n    # displayCol of the favorite column.\n    self.apply_user_action(['SetDisplayFormula', 'People', None, 26, '$favorite.show'])\n\n    # Set display formula for 'favorite' column field.\n    # A single \"gristHelper_Display2\" column should be added with the requested formula.\n    self.apply_user_action(['SetDisplayFormula', 'People', 1, None, '$favorite.network'])\n\n    # Check that the tables are set up as expected.\n    self.assertTables([\n      Table(1, \"Television\", 0, 0, columns=[\n        Column(21, \"show\",    \"Text\", False,  \"\", 0),\n        Column(22, \"network\", \"Text\", False,  \"\", 0),\n        Column(23, \"viewers\", \"Int\",  False,  \"\", 0),\n      ]),\n      Table(2, \"People\", 1, 0, columns=[\n        Column(24, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(25, \"name\", \"Text\", False,  \"\", 0),\n        Column(26, \"favorite\", \"Ref:Television\", False,  \"\", 0),\n        Column(27, \"network\", \"Any\", True,\n          \"Television.lookupOne(show=rec.favorite.show).network\", 0),\n        Column(28, \"gristHelper_Display\", \"Any\", True, \"$favorite.show\", 0),\n        Column(29, \"gristHelper_Display2\", \"Any\", True, \"$favorite.network\", 0)\n      ]),\n    ])\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"name\", \"favorite\", \"network\"],\n      [1,    \"Bob\",  12,         \"Netflix\"],\n      [2,    \"Jim\",  11,         \"HBO\"],\n      [3,    \"Don\",  13,         \"NBC\"]\n    ])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.parentId.id == 2),\n    data=[\n      [\"id\", \"colId\",                \"parentId\", \"displayCol\", \"formula\"],\n      [24,   \"manualSort\",           2,          0,            \"\"],\n      [25,   \"name\",                 2,          0,            \"\"],\n      [26,   \"favorite\",             2,          28,           \"\"],\n      [27,   \"network\",              2,          0,\n        \"Television.lookupOne(show=rec.favorite.show).network\"],\n      [28,   \"gristHelper_Display\",  2,          0,            \"$favorite.show\"],\n      [29,   \"gristHelper_Display2\", 2,          0,            \"$favorite.network\"]\n    ])\n    self.assertTableData(\n      \"_grist_Views_section_field\", cols=\"subset\", rows=lambda r: r.parentId.parentId, data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,    25,       29],\n      [2,    26,       0],\n      [3,    27,       0]\n    ])\n\n    # Rename the referenced table.\n    out_actions = self.apply_user_action(['RenameTable', 'Television', 'Television2'])\n\n    # Verify the resulting actions.\n    # This tests a bug fix where table renames would cause widgetOptions and displayCols\n    # of columns referencing the renamed table to be unset. See https://phab.getgrist.com/T206.\n    # Ensure that no actions are generated to unset the widgetOptions and the displayCols of the\n    # field or column.\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"People\", \"favorite\", {\"type\": \"Int\"}],\n        [\"RenameTable\", \"Television\", \"Television2\"],\n        [\"UpdateRecord\", \"_grist_Tables\", 1, {\"tableId\": \"Television2\"}],\n        [\"ModifyColumn\", \"People\", \"favorite\", {\"type\": \"Ref:Television2\"}],\n        [\"ModifyColumn\", \"People\", \"network\",\n          {\"formula\": \"Television2.lookupOne(show=rec.favorite.show).network\"}],\n        [\"BulkUpdateRecord\", \"_grist_Tables_column\", [26, 27], {\n          \"formula\": [\"\", \"Television2.lookupOne(show=rec.favorite.show).network\"],\n          \"type\": [\"Ref:Television2\", \"Any\"]\n        }]\n      ],\n      \"calc\": []\n    })\n\n    # Verify that the tables have responded as expected to the change.\n    self.assertTables([\n      Table(1, \"Television2\", 0, 0, columns=[\n        Column(21, \"show\",    \"Text\", False,  \"\", 0),\n        Column(22, \"network\", \"Text\", False,  \"\", 0),\n        Column(23, \"viewers\", \"Int\",  False,  \"\", 0),\n      ]),\n      Table(2, \"People\", 1, 0, columns=[\n        Column(24, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(25, \"name\", \"Text\", False,  \"\", 0),\n        Column(26, \"favorite\", \"Ref:Television2\", False,  \"\", 0),\n        Column(27, \"network\", \"Any\", True,\n          \"Television2.lookupOne(show=rec.favorite.show).network\", 0),\n        Column(28, \"gristHelper_Display\", \"Any\", True, \"$favorite.show\", 0),\n        Column(29, \"gristHelper_Display2\", \"Any\", True, \"$favorite.network\", 0)\n      ]),\n    ])\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"name\", \"favorite\", \"network\"],\n      [1,    \"Bob\",  12,         \"Netflix\"],\n      [2,    \"Jim\",  11,         \"HBO\"],\n      [3,    \"Don\",  13,         \"NBC\"]\n    ])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=(lambda r: r.parentId.id == 2),\n    data=[\n      [\"id\", \"colId\",                \"parentId\", \"displayCol\", \"formula\"],\n      [24,   \"manualSort\",           2,          0,            \"\"],\n      [25,   \"name\",                 2,          0,            \"\"],\n      [26,   \"favorite\",             2,          28,           \"\"],\n      [27,   \"network\",              2,          0,\n        \"Television2.lookupOne(show=rec.favorite.show).network\"],\n      [28,   \"gristHelper_Display\",  2,          0,            \"$favorite.show\"],\n      [29,   \"gristHelper_Display2\", 2,          0,            \"$favorite.network\"]\n    ])\n    self.assertTableData(\n      \"_grist_Views_section_field\", cols=\"subset\", rows=lambda r: r.parentId.parentId, data=[\n      [\"id\", \"colRef\", \"displayCol\"],\n      [1,    25,       29],\n      [2,    26,       0],\n      [3,    27,       0]\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_docmodel.py",
    "content": "import logging\nimport actions\n\nimport testsamples\nimport test_engine\nfrom test_engine import Table, Column\n\nlog = logging.getLogger(__name__)\n\nclass TestDocModel(test_engine.EngineTestCase):\n\n  def test_meta_tables(self):\n    \"\"\"\n    Test changes to records accessed via lookup.\n    \"\"\"\n    self.load_sample(testsamples.sample_students)\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"columns\"], [\n      [1,   [1,2,4,5,6]],\n      [2,   [10,12]],\n      [3,   [21]],\n    ])\n\n    # Test that adding a column produces a change to 'columns' without emitting an action.\n    out_actions = self.add_column('Students', 'test', type='Text', isFormula=False)\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"columns\"], [\n      [1,   [1,2,4,5,6,22]],\n      [2,   [10,12]],\n      [3,   [21]],\n    ])\n    self.assertPartialOutActions(out_actions, {\n      \"calc\": [],\n      \"stored\": [\n        [\"AddColumn\", \"Students\", \"test\",\n         {\"formula\": \"\", \"isFormula\": False, \"type\": \"Text\"}\n        ],\n        [\"AddRecord\", \"_grist_Tables_column\", 22,\n         {\"colId\": \"test\", \"formula\": \"\", \"isFormula\": False, \"label\": \"test\",\n          \"parentId\": 1, \"parentPos\": 6.0, \"type\": \"Text\", \"widgetOptions\": \"\"}\n        ],\n      ],\n      \"undo\": [\n        [\"RemoveColumn\", \"Students\", \"test\"],\n        [\"RemoveRecord\", \"_grist_Tables_column\", 22],\n      ]\n    })\n\n    # Undo the AddColumn action. Check that actions are in correct order, and still produce undos.\n    out_actions = self.apply_user_action(\n      ['ApplyUndoActions', [actions.get_action_repr(a) for a in out_actions.undo]])\n    self.assertPartialOutActions(out_actions, {\n      \"calc\": [],\n      \"stored\": [\n        [\"RemoveRecord\", \"_grist_Tables_column\", 22],\n        [\"RemoveColumn\", \"Students\", \"test\"],\n      ],\n      \"undo\": [\n        [\"AddRecord\", \"_grist_Tables_column\", 22, {\"colId\": \"test\", \"label\": \"test\",\n         \"parentId\": 1, \"parentPos\": 6.0, \"type\": \"Text\"}],\n        [\"AddColumn\", \"Students\", \"test\", {\"formula\": \"\", \"isFormula\": False, \"type\": \"Text\"}],\n      ]\n    })\n\n    # Test that when we add a table, .column is set correctly.\n    out_actions = self.apply_user_action(['AddTable', 'Test2', [\n      {'id': 'A', 'type': 'Text'},\n      {'id': 'B', 'type': 'Numeric'},\n      {'id': 'C', 'type': 'Numeric', 'formula': 'len($A)', 'isFormula': True}\n    ]])\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"columns\"], [\n      [1,   [1,2,4,5,6]],\n      [2,   [10,12]],\n      [3,   [21]],\n      [4,   [22,23,24,25]],\n    ])\n    self.assertPartialData(\"_grist_Tables_column\", [\"id\", \"colId\", \"parentId\"], [\n      [1, \"firstName\",    1],\n      [2, \"lastName\",     1],\n      [4, \"schoolName\",   1],\n      [5, \"schoolIds\",    1],\n      [6, \"schoolCities\", 1],\n      [10, \"name\",        2],\n      [12, \"address\",     2],\n      [21, \"city\",        3],\n      # Newly added columns:\n      [22,  'manualSort', 4],\n      [23,  'A',          4],\n      [24,  'B',          4],\n      [25,  'C',          4],\n    ])\n\n  def test_add_column_position(self):\n    self.load_sample(testsamples.sample_students)\n\n    # Client may send AddColumn actions with fractional positions. Test that it works.\n    # TODO: this should probably use parentPos in the future and be done via metadata AddRecord.\n    out_actions = self.add_column('Students', 'test', type='Text', _position=2.75)\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"columns\"], [\n      [1,   [1,2,22,4,5,6]],\n      [2,   [10,12]],\n      [3,   [21]],\n    ])\n\n    out_actions = self.add_column('Students', None, type='Text', _position=6)\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"columns\"], [\n      [1,   [1,2,22,4,5,6,23]],\n      [2,   [10,12]],\n      [3,   [21]],\n    ])\n    self.assertPartialData(\"_grist_Tables_column\", [\"id\", \"colId\", \"parentId\"], [\n      [1, \"firstName\",    1],\n      [2, \"lastName\",     1],\n      [4, \"schoolName\",   1],\n      [5, \"schoolIds\",    1],\n      [6, \"schoolCities\", 1],\n      [10, \"name\",        2],\n      [12, \"address\",     2],\n      [21, \"city\",        3],\n      [22, \"test\",        1],\n      [23, \"A\",           1],\n    ])\n\n  def assertRecordSet(self, record_set, expected_row_ids):\n    self.assertEqual(list(record_set.id), expected_row_ids)\n\n  def test_lookup_recompute(self):\n    self.load_sample(testsamples.sample_students)\n    self.apply_user_action(['AddTable', 'Test2', [\n      {'id': 'A', 'type': 'Text'},\n      {'id': 'B', 'type': 'Numeric'},\n    ]])\n    self.apply_user_action(['AddTable', 'Test3', [\n      {'id': 'A', 'type': 'Text'},\n      {'id': 'B', 'type': 'Numeric'},\n    ]])\n    self.apply_user_action(['AddViewSection', 'Section2', 'record', 1, 'Test2'])\n    self.apply_user_action(['AddViewSection', 'Section3', 'record', 1, 'Test3'])\n    self.assertPartialData('_grist_Views', [\"id\"], [\n      [1],\n      [2],\n    ])\n    self.assertPartialData('_grist_Views_section', [\"id\", \"parentId\", \"tableRef\"], [\n      [1, 1, 4],\n      [2, 0, 4],\n      [3, 0, 4],\n      [4, 2, 5],\n      [5, 0, 5],\n      [6, 0, 5],\n      [7, 1, 4],\n      [8, 1, 5],\n    ])\n    self.assertPartialData('_grist_Views_section_field', [\"id\", \"parentId\", \"parentPos\"], [\n      [1,  1,  1.0],\n      [2,  1,  2.0],\n      [3,  2,  3.0],\n      [4,  2,  4.0],\n      [5,  3,  5.0],\n      [6,  3,  6.0],\n      [7,  4,  7.0],\n      [8,  4,  8.0],\n      [9,  5,  9.0],\n      [10, 5, 10.0],\n      [11, 6, 11.0],\n      [12, 6, 12.0],\n      [13, 7, 13.0],\n      [14, 7, 14.0],\n      [15, 8, 15.0],\n      [16, 8, 16.0],\n    ])\n\n    table = self.engine.docmodel.tables.lookupOne(tableId='Test2')\n    self.assertRecordSet(table.viewSections, [1, 2, 3, 7])\n    self.assertRecordSet(list(table.viewSections)[0].fields, [1, 2])\n    self.assertRecordSet(list(table.viewSections)[3].fields, [13, 14])\n    view = self.engine.docmodel.views.lookupOne(id=1)\n    self.assertRecordSet(view.viewSections, [1, 7, 8])\n\n    self.engine.docmodel.remove(set(table.viewSections) -\n      {table.rawViewSectionRef, table.recordCardViewSectionRef})\n    self.assertRecordSet(view.viewSections, [8])\n\n\n  def test_modifications(self):\n    # Test the add/remove/update methods of DocModel.\n    self.load_sample(testsamples.sample_students)\n    table = self.engine.docmodel.get_table('Students')\n    records = table.lookupRecords(lastName='Bush')\n    self.assertEqual([r.id for r in records], [2, 4])\n    self.assertEqual([r.schoolName for r in records], [\"Yale\", \"Yale\"])\n    self.assertEqual([r.firstName for r in records], [\"George W\", \"George H\"])\n\n    # Test the update() method.\n    self.engine.docmodel.update(records, schoolName=\"Test\", firstName=[\"george w\", \"george h\"])\n    self.assertEqual([r.schoolName for r in records], [\"Test\", \"Test\"])\n    self.assertEqual([r.firstName for r in records], [\"george w\", \"george h\"])\n\n    # Test the remove() method.\n    self.engine.docmodel.remove(records)\n    records = table.lookupRecords(lastName='Bush')\n    self.assertEqual(list(records), [])\n    self.assertTableData(\"Students\", cols=\"subset\", data=[\n        [\"id\",\"firstName\",\"lastName\", \"schoolName\" ],\n        [1,   \"Barack\",   \"Obama\",    \"Columbia\"   ],\n        [3,   \"Bill\",     \"Clinton\",  \"Columbia\"   ],\n        [5,   \"Ronald\",   \"Reagan\",   \"Eureka\"     ],\n        [6,   \"Gerald\",   \"Ford\",     \"Yale\"       ]])\n\n    # Test the add() method.\n    self.engine.docmodel.add(table, schoolName=\"Foo\", firstName=[\"X\", \"Y\"])\n    self.assertTableData(\"Students\", cols=\"subset\", data=[\n        [\"id\",\"firstName\",\"lastName\", \"schoolName\" ],\n        [1,   \"Barack\",   \"Obama\",    \"Columbia\"   ],\n        [3,   \"Bill\",     \"Clinton\",  \"Columbia\"   ],\n        [5,   \"Ronald\",   \"Reagan\",   \"Eureka\"     ],\n        [6,   \"Gerald\",   \"Ford\",     \"Yale\"       ],\n        [7,   \"X\",        \"\",         \"Foo\"        ],\n        [8,   \"Y\",        \"\",         \"Foo\"        ],\n    ])\n\n  def test_inserts(self):\n    # Test the insert() method. We do this on the columns metadata table, so that we can sort by\n    # a PositionNumber column.\n    self.load_sample(testsamples.sample_students)\n    student_columns = self.engine.docmodel.tables.lookupOne(tableId='Students').columns\n    school_columns = self.engine.docmodel.tables.lookupOne(tableId='Schools').columns\n\n    # Should go at the end of the Students table.\n    cols = self.engine.docmodel.insert(student_columns, None, colId=[\"a\", \"b\"], type=\"Text\")\n    # Should go at the start of the Schools table.\n    self.engine.docmodel.insert_after(school_columns, None, colId=\"foo\", type=\"Int\")\n    # Should go before the new \"a\", \"b\" columns of the Students table.\n    self.engine.docmodel.insert(student_columns, cols[0].parentPos, colId=\"bar\", type=\"Date\")\n\n    # Verify that the right columns were added to the right tables. This doesn't check positions.\n    self.assertTables([\n      Table(1, \"Students\", 0, 0, columns=[\n        Column(1, \"firstName\",    \"Text\",  False, \"\", 0),\n        Column(2, \"lastName\",     \"Text\",  False, \"\", 0),\n        Column(4, \"schoolName\",   \"Text\",  False, \"\", 0),\n        Column(5, \"schoolIds\",    \"Text\",  True,\n               \"':'.join(str(id) for id in Schools.lookupRecords(name=$schoolName).id)\", 0),\n        Column(6, \"schoolCities\", \"Text\",  True,\n               \"':'.join(r.address.city for r in Schools.lookupRecords(name=$schoolName))\", 0),\n        Column(22, \"a\",           \"Text\", False, \"\", 0),\n        Column(23, \"b\",           \"Text\", False, \"\", 0),\n        Column(25, \"bar\",         \"Date\", False, \"\", 0),\n      ]),\n      Table(2, \"Schools\", 0, 0, columns=[\n        Column(10, \"name\",        \"Text\", False, \"\", 0),\n        Column(12, \"address\",     \"Ref:Address\",False, \"\", 0),\n        Column(24, \"foo\",         \"Int\", False, \"\", 0),\n      ]),\n      Table(3, \"Address\", 0, 0, columns=[\n        Column(21, \"city\",        \"Text\", False, \"\", 0),\n      ])\n    ])\n\n    # Verify that positions are set such that the order is what we asked for.\n    student_columns = self.engine.docmodel.tables.lookupOne(tableId='Students').columns\n    self.assertEqual(list(map(int, student_columns)), [1,2,4,5,6,25,22,23])\n    school_columns = self.engine.docmodel.tables.lookupOne(tableId='Schools').columns\n    self.assertEqual(list(map(int, school_columns)), [24,10,12])\n"
  },
  {
    "path": "sandbox/grist/test_dropdown_condition.py",
    "content": "# -*- coding: utf-8 -*-\n# pylint:disable=line-too-long\nimport json\n\nimport test_engine\n\nclass TestDropdownConditionUserActions(test_engine.EngineTestCase):\n  def test_dropdown_condition_col_actions(self):\n    self.apply_user_action(['AddTable', 'Table1', [\n      {'id': 'A', 'type': 'Text'},\n      {'id': 'B', 'type': 'Text'},\n      {'id': 'C', 'type': 'Text'},\n    ]])\n\n    # Check that setting dropdownCondition.text automatically sets a parsed version.\n    out_actions = self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 1, {\n        \"widgetOptions\": json.dumps({\n          \"dropdownCondition\": {\n            \"text\": 'choice.Role == \"Manager\"',\n          },\n        }),\n    }])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"UpdateRecord\", \"_grist_Tables_column\", 1, {\n        \"widgetOptions\": \"{\\\"dropdownCondition\\\": {\\\"text\\\": \"\n          + \"\\\"choice.Role == \\\\\\\"Manager\\\\\\\"\\\", \\\"parsed\\\": \"\n          + \"\\\"[\\\\\\\"Eq\\\\\\\", [\\\\\\\"Attr\\\\\\\", [\\\\\\\"Name\\\\\\\", \\\\\\\"choice\\\\\\\"], \"\n          + \"\\\\\\\"Role\\\\\\\"], [\\\\\\\"Const\\\\\\\", \\\\\\\"Manager\\\\\\\"]]\\\"}}\"\n      }]\n    ]})\n    out_actions = self.apply_user_action(['BulkUpdateRecord', '_grist_Tables_column', [2, 3], {\n      \"widgetOptions\": [\n        json.dumps({\n          \"dropdownCondition\": {\n            \"text\": 'choice == \"Manager\"',\n          },\n        }),\n        json.dumps({\n          \"dropdownCondition\": {\n            \"text\": '$Role == \"Manager\"',\n          },\n        }),\n      ],\n    }])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [2, 3], {\n        \"widgetOptions\": [\n          \"{\\\"dropdownCondition\\\": {\\\"text\\\": \\\"choice == \"\n            + \"\\\\\\\"Manager\\\\\\\"\\\", \\\"parsed\\\": \\\"[\\\\\\\"Eq\\\\\\\", \"\n            + \"[\\\\\\\"Name\\\\\\\", \\\\\\\"choice\\\\\\\"], [\\\\\\\"Const\\\\\\\", \\\\\\\"Manager\\\\\\\"]]\\\"}}\",\n          \"{\\\"dropdownCondition\\\": {\\\"text\\\": \\\"$Role == \"\n            + \"\\\\\\\"Manager\\\\\\\"\\\", \\\"parsed\\\": \\\"[\\\\\\\"Eq\\\\\\\", \"\n            + \"[\\\\\\\"Attr\\\\\\\", [\\\\\\\"Name\\\\\\\", \\\\\\\"rec\\\\\\\"], \\\\\\\"Role\\\\\\\"], \"\n            + \"[\\\\\\\"Const\\\\\\\", \\\\\\\"Manager\\\\\\\"]]\\\"}}\",\n        ]\n      }]\n    ]})\n\n  def test_dropdown_condition_field_actions(self):\n    self.apply_user_action(['AddTable', 'Table1', [\n      {'id': 'A', 'type': 'Text'},\n      {'id': 'B', 'type': 'Text'},\n      {'id': 'C', 'type': 'Text'},\n    ]])\n\n    # Check that setting dropdownCondition.text automatically sets a parsed version.\n    out_actions = self.apply_user_action(['UpdateRecord', '_grist_Views_section_field', 1, {\n        \"widgetOptions\": json.dumps({\n          \"dropdownCondition\": {\n            \"text\": 'choice.Role == \"Manager\"',\n          },\n        }),\n    }])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 1, {\n        \"widgetOptions\": \"{\\\"dropdownCondition\\\": {\\\"text\\\": \"\n          + \"\\\"choice.Role == \\\\\\\"Manager\\\\\\\"\\\", \\\"parsed\\\": \"\n          + \"\\\"[\\\\\\\"Eq\\\\\\\", [\\\\\\\"Attr\\\\\\\", [\\\\\\\"Name\\\\\\\", \\\\\\\"choice\\\\\\\"], \"\n          + \"\\\\\\\"Role\\\\\\\"], [\\\\\\\"Const\\\\\\\", \\\\\\\"Manager\\\\\\\"]]\\\"}}\"\n      }]\n    ]})\n    out_actions = self.apply_user_action(['BulkUpdateRecord', '_grist_Views_section_field', [2, 3], {\n      \"widgetOptions\": [\n        json.dumps({\n          \"dropdownCondition\": {\n            \"text\": 'choice == \"Manager\"',\n          },\n        }),\n        json.dumps({\n          \"dropdownCondition\": {\n            \"text\": '$Role == \"Manager\"',\n          },\n        }),\n      ],\n    }])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"BulkUpdateRecord\", \"_grist_Views_section_field\", [2, 3], {\n        \"widgetOptions\": [\n          \"{\\\"dropdownCondition\\\": {\\\"text\\\": \\\"choice == \"\n            + \"\\\\\\\"Manager\\\\\\\"\\\", \\\"parsed\\\": \\\"[\\\\\\\"Eq\\\\\\\", \"\n            + \"[\\\\\\\"Name\\\\\\\", \\\\\\\"choice\\\\\\\"], [\\\\\\\"Const\\\\\\\", \\\\\\\"Manager\\\\\\\"]]\\\"}}\",\n          \"{\\\"dropdownCondition\\\": {\\\"text\\\": \\\"$Role == \"\n            + \"\\\\\\\"Manager\\\\\\\"\\\", \\\"parsed\\\": \\\"[\\\\\\\"Eq\\\\\\\", \"\n            + \"[\\\\\\\"Attr\\\\\\\", [\\\\\\\"Name\\\\\\\", \\\\\\\"rec\\\\\\\"], \\\\\\\"Role\\\\\\\"], \"\n            + \"[\\\\\\\"Const\\\\\\\", \\\\\\\"Manager\\\\\\\"]]\\\"}}\",\n        ]\n      }]\n    ]})\n"
  },
  {
    "path": "sandbox/grist/test_dropdown_condition_renames.py",
    "content": "# -*- coding: utf-8 -*-\n# pylint: disable=line-too-long\n\nimport json\n\nimport test_engine\nimport testsamples\nimport useractions\n\n# A sample dropdown condition formula for the column Schools.address and alike, of type Ref/RefList.\ndef build_dc1_text(school_name, address_city):\n  return \"'New' in choice.{address_city} and ${school_name} == rec.{school_name} + rec.choice.city or choice.rec.city != $name2\".format(**locals())\n\n# Another sample formula for a new column of type ChoiceList (or actually, anything other than Ref/RefList).\ndef build_dc2_text(school_name, school_address):\n  # We currently don't support layered attribute access, e.g. rec.address.city, so this is not tested.\n  # choice.city really is nonsense, as choice will not be an object.\n  # Just for testing purposes, to make sure nothing is renamed here.\n  return \"choice + ${school_name} == choice.city or rec.{school_address} > 2\".format(**locals())\n\ndef build_dc1(school_name, address_city):\n  return json.dumps({\n    \"dropdownCondition\": {\n      \"text\": build_dc1_text(school_name, address_city),\n      # The ModifyColumn user action should trigger an auto parse.\n      # \"parsed\" is stored as dumped JSON, so we need to explicitly dump it here as well.\n      \"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\"]]])\n    }\n  })\n\ndef build_dc2(school_name, school_address):\n  return json.dumps({\n    \"dropdownCondition\": {\n      \"text\": build_dc2_text(school_name, school_address),\n      \"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]]])\n    }\n  })\n\nclass TestDCRenames(test_engine.EngineTestCase):\n\n  def setUp(self):\n    super(TestDCRenames, self).setUp()\n\n    self.load_sample(testsamples.sample_students)\n\n    self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (\n      # Add some irrelevant columns to the table Schools. These should never be renamed.\n      [\"AddColumn\", \"Schools\", \"name2\", {\n        \"type\": \"Text\"\n      }],\n      [\"AddColumn\", \"Schools\", \"choice\", {\n        \"type\": \"Ref:Address\"\n      }],\n      [\"AddColumn\", \"Address\", \"rec\", {\n        \"type\": \"Text\"\n      }],\n      # Add a dropdown condition formula to Schools.address (column #12).\n      [\"ModifyColumn\", \"Schools\", \"address\", {\n        \"widgetOptions\": json.dumps({\n          \"dropdownCondition\": {\n            \"text\": build_dc1_text(\"name\", \"city\"),\n          }\n        }),\n      }],\n      # Create a similar column with an invalid dropdown condition formula.\n      # This formula should never be touched.\n      # This column will have the ID 25.\n      [\"AddColumn\", \"Schools\", \"address2\", {\n        \"type\": \"Ref:Address\",\n        \"widgetOptions\": json.dumps({\n          \"dropdownCondition\": {\n            \"text\": \"+ 'New' in choice.city and $name == rec.name\",\n          }\n        }),\n      }],\n      # And another similar column, but of type RefList.\n      # This column will have the ID 26.\n      [\"AddColumn\", \"Schools\", \"addresses\", {\n        \"type\": \"RefList:Address\",\n      }],\n      # AddColumn will not trigger parsing. We emulate a real user's action here by creating it first,\n      # then editing its widgetOptions.\n      [\"ModifyColumn\", \"Schools\", \"addresses\", {\n        \"widgetOptions\": json.dumps({\n          \"dropdownCondition\": {\n            \"text\": build_dc1_text(\"name\", \"city\"),\n          }\n        }),\n      }],\n      # And another similar column, but of type ChoiceList.\n      # widgetOptions stay when the column type changes. We do our best to rename stuff in stray widgetOptions.\n      # This column will have the ID 27.\n      [\"AddColumn\", \"Schools\", \"features\", {\n        \"type\": \"ChoiceList\",\n      }],\n      [\"ModifyColumn\", \"Schools\", \"features\", {\n        \"widgetOptions\": json.dumps({\n          \"dropdownCondition\": {\n            \"text\": build_dc2_text(\"name\", \"address\"),\n          }\n        }),\n      }],\n    )])\n\n    # This is what we'll have at the beginning, for later tests to refer to.\n    # Table Schools is 2.\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"parentId\", \"colId\", \"widgetOptions\"],\n      [12, 2, \"address\", build_dc1(\"name\", \"city\")],\n      [26, 2, \"addresses\", build_dc1(\"name\", \"city\")],\n      [27, 2, \"features\", build_dc2(\"name\", \"address\")],\n    ])\n    self.assert_invalid_formula_untouched()\n\n  def assert_invalid_formula_untouched(self):\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"parentId\", \"colId\", \"widgetOptions\"],\n      [25, 2, \"address2\", json.dumps({\n        \"dropdownCondition\": {\n          \"text\": \"+ 'New' in choice.city and $name == rec.name\",\n        }\n      })]\n    ])\n\n  def test_referred_column_renames(self):\n    self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"area\"])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"parentId\", \"colId\", \"widgetOptions\"],\n      [12, 2, \"address\", build_dc1(\"name\", \"area\")],\n      [26, 2, \"addresses\", build_dc1(\"name\", \"area\")],\n      # Nothing should be renamed here, as only column renames in the table \"Schools\" are relevant.\n      [27, 2, \"features\", build_dc2(\"name\", \"address\")],\n    ])\n    self.assert_invalid_formula_untouched()\n\n  def test_record_column_renames(self):\n    self.apply_user_action([\"RenameColumn\", \"Schools\", \"name\", \"identifier\"])\n    self.apply_user_action([\"RenameColumn\", \"Schools\", \"address\", \"location\"])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"parentId\", \"colId\", \"widgetOptions\"],\n      # Side effect: \"address\" becomes \"location\".\n      [12, 2, \"location\", build_dc1(\"identifier\", \"city\")],\n      [26, 2, \"addresses\", build_dc1(\"identifier\", \"city\")],\n      # Now \"$name\" should become \"$identifier\", just like in Ref/RefList columns. Nothing else should change.\n      [27, 2, \"features\", build_dc2(\"identifier\", \"location\")],\n    ])\n    self.assert_invalid_formula_untouched()\n\n  def test_multiple_renames(self):\n    # Put all renames together.\n    self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"area\"])\n    self.apply_user_action([\"RenameColumn\", \"Schools\", \"name\", \"identifier\"])\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"parentId\", \"colId\", \"widgetOptions\"],\n      [12, 2, \"address\", build_dc1(\"identifier\", \"area\")],\n      [26, 2, \"addresses\", build_dc1(\"identifier\", \"area\")],\n      [27, 2, \"features\", build_dc2(\"identifier\", \"address\")],\n    ])\n    self.assert_invalid_formula_untouched()\n\n  def test_rename_when_null_widget_options(self):\n    # Create a column with None for widget options. Just a presence of such a column was causing\n    # an error at one point.\n    self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (\n      [\"AddColumn\", \"Schools\", \"dummy\", {\n        \"type\": \"Text\",\n        \"widgetOptions\": None,\n      }],\n    )])\n\n    # Check that rename works when it needs to affect a dropdown condition.\n    # First check the dropdown condition before the rename.\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"parentId\", \"colId\", \"widgetOptions\"],\n      [12, 2, \"address\", build_dc1(\"name\", \"city\")],\n    ])\n\n    self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"area\"])\n\n    # Check the condition got updated after the rename.\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"parentId\", \"colId\", \"widgetOptions\"],\n      [12, 2, \"address\", build_dc1(\"name\", \"area\")],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_engine.py",
    "content": "import difflib\nimport functools\nimport json\nimport logging\nimport os\nimport sys\nimport unittest\nfrom collections import namedtuple\nfrom pprint import pprint\nimport actions\nimport column\nimport engine\nimport useractions\nimport testutil\nimport objtypes\n\nlog = logging.getLogger(__name__)\n\n# These are for use in verifying metadata using assertTables/assertViews methods. E.g.\n#   self.assertViews([View(1, sections=[Section(1, parentKey=\"record\", tableRef=1, fields=[\n#         Field(1, colRef=11) ]) ]) ])\nTable = namedtuple('Table', ('id tableId primaryViewId summarySourceTable columns'))\nColumn = namedtuple('Column', ('id colId type isFormula formula summarySourceCol'))\nView = namedtuple('View', 'id sections')\nSection = namedtuple('Section', 'id parentKey tableRef fields')\nField = namedtuple('Field', 'id colRef')\n\nclass EngineTestCase(unittest.TestCase):\n  \"\"\"\n  Provides functionality for verifying engine actions and data, which is general enough to be\n  useful for other tests. It is also used by TestEngine below.\n  \"\"\"\n  @classmethod\n  def setUpClass(cls):\n    cls._orig_log_level = logging.root.level\n    logging.root.setLevel(logging.DEBUG if os.environ.get('VERBOSE') else logging.WARNING)\n\n  @classmethod\n  def tearDownClass(cls):\n    logging.root.setLevel(cls._orig_log_level)\n\n\n  def setUp(self):\n    \"\"\"\n    Initial setup for each test case.\n    \"\"\"\n    self.engine = engine.Engine()\n    self.engine.load_empty()\n\n    # Set up call tracing to count calls (formula evaluations) for each column for each table.\n    self.call_counts = {}\n    def trace_call(col_obj, _rec):\n      # Ignore formulas in metadata tables for simplicity. Such formulas are mostly private, and\n      # it would be annoying to fix tests every time we change them.\n      # Also ignore negative row_ids, used as extra dependency nodes in lookups.\n      if not col_obj.table_id.startswith(\"_grist_\") and _rec._row_id >= 0:\n        tmap = self.call_counts.setdefault(col_obj.table_id, {})\n        tmap[col_obj.col_id] = tmap.get(col_obj.col_id, 0) + 1\n    self.engine.formula_tracer = trace_call\n\n    # This is set when a test case is wrapped by `test_engine.test_undo`.\n    self._undo_state_tracker = None\n\n\n  @classmethod\n  def _getEngineDataLines(cls, engine_data, col_names=[]):\n    \"\"\"\n    Helper for assertEqualEngineData, which returns engine data represented as lines of text\n    suitable for diffing. If col_names is given, it determines the order of columns (columns not\n    found in this list are included in the end and sorted by name).\n    \"\"\"\n    sort_keys = {c: i for i, c in enumerate(col_names)}\n    ret = []\n    for table_id, table_data in sorted(engine_data.items()):\n      ret.append(\"TABLE %s\\n\" % table_id)\n      col_items = sorted(table_data.columns.items(),\n                         key=lambda c: (sort_keys.get(c[0], float('inf')), c))\n      col_items.insert(0, ('id', table_data.row_ids))\n      table_rows = zip(*[[col_id] + values for (col_id, values) in col_items])\n      ret.extend(json.dumps(row) + \"\\n\" for row in table_rows)\n    return ret\n\n  def assertEqualDocData(self, observed, expected, col_names=[]):\n    \"\"\"\n    Compare full engine data, as a mapping of table_ids to TableData objects, and reporting\n    differences with a customized diff (similar to the JSON representation in the test script).\n    \"\"\"\n    enc_observed = actions.encode_objects(observed)\n    enc_expected = actions.encode_objects(expected)\n    if enc_observed != enc_expected:\n      o_lines = self._getEngineDataLines(enc_observed, col_names)\n      e_lines = self._getEngineDataLines(enc_expected, col_names)\n      self.fail(\"Observed data not as expected:\\n\" +\n                \"\".join(difflib.unified_diff(e_lines, o_lines,\n                                             fromfile=\"expected\", tofile=\"observed\")))\n\n  def assertCorrectEngineData(self, expected_data):\n    \"\"\"\n    Verifies that the data engine contains the same data as the given expected data,\n    which should be a dictionary mapping table names to TableData objects.\n    \"\"\"\n    expected_output = actions.decode_objects(expected_data)\n\n    meta_tables = self.engine.fetch_table(\"_grist_Tables\")\n    output = {t: self.engine.fetch_table(t) for t in meta_tables.columns[\"tableId\"]}\n    output = testutil.replace_nans(output)\n\n    self.assertEqualDocData(output, expected_output)\n\n  def getFullEngineData(self):\n    return testutil.replace_nans({t: self.engine.fetch_table(t) for t in self.engine.tables})\n\n  def assertPartialData(self, table_name, col_names, row_data):\n    \"\"\"\n    Verifies that the data engine contains the right data for the given col_names (ignoring any\n    other columns).\n    \"\"\"\n    expected = testutil.table_data_from_rows(table_name, col_names, row_data)\n    observed = self.engine.fetch_table(table_name, private=True)\n    ignore = set(observed.columns) - set(expected.columns)\n    for col_id in ignore:\n      del observed.columns[col_id]\n    self.assertEqualDocData({table_name: observed}, {table_name: expected})\n\n\n  action_group_action_fields = (\"stored\", \"undo\", \"calc\", \"direct\")\n\n  @classmethod\n  def _formatActionGroup(cls, action_group, use_repr=False):\n    \"\"\"\n    Helper for assertEqualActionGroups below.\n    \"\"\"\n    lines = [\"{\"]\n    for (k, action_list) in sorted(action_group.items()):\n      if k in cls.action_group_action_fields:\n        for a in action_list:\n          rep = repr(a) if use_repr else json.dumps(a, sort_keys=True)\n          lines.append(\"%s: %s,\" % (k, rep))\n      else:\n        lines.append(\"%s: %s,\" % (k, json.dumps(action_list)))\n    lines.append(\"}\")\n    return lines\n\n  def assertEqualActionGroups(self, observed, expected):\n    \"\"\"\n    Compare grouped doc actions, reporting differences with a customized diff\n    (a bit more readable than unittest's usual diff).\n    \"\"\"\n    # Do some clean up on the observed data.\n    observed = testutil.replace_nans(observed)\n\n    # Convert observed and expected actions into a comparable form.\n    for k in self.action_group_action_fields:\n      if k in observed:\n        observed[k] = [get_comparable_repr(v) for v in observed[k]]\n      if k in expected:\n        expected[k] = [get_comparable_repr(v) for v in expected[k]]\n\n    if observed != expected:\n      o_lines = self._formatActionGroup(observed)\n      e_lines = self._formatActionGroup(expected)\n      self.fail((\"Observed out actions not as expected:\\n\") +\n                \"\\n\".join(difflib.unified_diff(e_lines, o_lines, n=3, lineterm=\"\",\n                                               fromfile=\"expected\", tofile=\"observed\")))\n\n  def assertOutActions(self, out_action_group, expected_group):\n    \"\"\"\n    Compares action group returned from engine.apply_user_actions() to expected actions as listed\n    in testscript. The array of retValues is only checked if present in expected_group.\n    \"\"\"\n    for k in self.action_group_action_fields:\n      # For comparing full actions, treat omitted groups (e.g. \"calc\") as expected to be empty.\n      expected_group.setdefault(k, [])\n\n    observed = {k: getattr(out_action_group, k) for k in self.action_group_action_fields }\n    if \"retValue\" in expected_group:\n      observed[\"retValue\"] = out_action_group.retValues\n    self.assertEqualActionGroups(observed, expected_group)\n\n  def assertPartialOutActions(self, out_action_group, expected_group):\n    \"\"\"\n    Compares a single action group as returned from engine.apply_user_actions() to expected\n    actions, checking only those fields that are included in the expected_group dict.\n    \"\"\"\n    observed = {k: getattr(out_action_group, k) for k in expected_group}\n    self.assertEqualActionGroups(observed, expected_group)\n\n  def dump_data(self):\n    \"\"\"\n    Prints a dump of all engine data, for help in writing / debugging tests.\n    \"\"\"\n    output = {t: self.engine.fetch_table(t) for t in self.engine.schema}\n    output = testutil.replace_nans(output)\n    output = actions.encode_objects(output)\n    print(''.join(self._getEngineDataLines(output)))\n\n  def dump_actions(self, out_actions):\n    \"\"\"\n    Prints out_actions in human-readable format, for help in writing / debugging tets.\n    \"\"\"\n    pprint({\n      k: [get_comparable_repr(action) for action in getattr(out_actions, k)]\n      for k in self.action_group_action_fields\n    })\n\n  def assertTableData(self, table_name, data=[], cols=\"all\", rows=\"all\", sort=None):\n    \"\"\"\n    Verify some or all of the data in the table named `table_name`.\n    - data: one of\n      (1) an array of rows, with first row containing column names starting with \"id\", and\n          other rows also all starting with row_id.\n      (2) an array of dictionaries, mapping colIds to values\n      (3) an array of namedtuples, e.g. as returned by transpose_bulk_action().\n    - cols: may be \"all\" (default) to match all columns, or \"subset\" to match only those listed.\n    - rows: may be \"all\" (default) to match all rows, or \"subset\" to match only those listed,\n      or a function called with a Record to return whether to include it.\n    - sort: optionally a key function called with a Record, for sorting observed rows.\n    \"\"\"\n    if hasattr(data[0], '_asdict'):   # namedtuple\n      data = [r._asdict() for r in data]\n\n    if isinstance(data[0], dict):\n      expected = testutil.table_data_from_row_dicts(table_name, data)\n      col_names = ['id'] + list(expected.columns)\n    else:\n      assert data[0][0] == 'id', \"assertRecords requires 'id' as the first column\"\n      col_names = data[0]\n      row_data = data[1:]\n      expected = testutil.table_data_from_rows(table_name, col_names, row_data)\n\n    table = self.engine.tables[table_name]\n    columns = [c for c in table.all_columns.values()\n               if c.col_id != \"id\" and not column.is_virtual_column(c.col_id)]\n    if cols == \"all\":\n      pass\n    elif cols == \"subset\":\n      columns = [c for c in columns if c.col_id in col_names]\n    else:\n      raise ValueError(\"assertRecords: invalid value for cols: %s\" % (cols,))\n\n    if rows == \"all\":\n      row_ids = list(table.row_ids)\n    elif rows == \"subset\":\n      row_ids = expected.row_ids\n    elif callable(rows):\n      row_ids = [r.id for r in table.user_table.all if rows(r)]\n    else:\n      raise ValueError(\"assertRecords: invalid value for rows: %s\" % (rows,))\n\n    if sort:\n      row_ids.sort(key=lambda r: sort(table.get_record(r)))\n\n    observed_col_data = {\n      c.col_id: [c.raw_get(r) for r in row_ids]\n      for c in columns if c.col_id != \"id\"\n    }\n    observed = actions.TableData(table_name, row_ids, observed_col_data)\n    self.assertEqualDocData({table_name: observed}, {table_name: expected},\n                            col_names=col_names)\n\n  def assertTables(self, list_of_tables):\n    \"\"\"\n    Verifies that the given Table test-records correspond to the metadata for tables/columns.\n    \"\"\"\n    self.assertPartialData('_grist_Tables',\n                           [\"id\", \"tableId\", \"primaryViewId\", \"summarySourceTable\"],\n                           sorted((tbl.id, tbl.tableId, tbl.primaryViewId, tbl.summarySourceTable)\n                                  for tbl in list_of_tables))\n    self.assertPartialData('_grist_Tables_column',\n                           [\"id\", \"parentId\", \"colId\", \"type\",\n                            \"isFormula\", \"formula\", \"summarySourceCol\"],\n                           sorted((col.id, tbl.id, col.colId, col.type,\n                                   col.isFormula, col.formula, col.summarySourceCol)\n                                  for tbl in list_of_tables\n                                  for col in tbl.columns))\n\n  def assertFormulaError(self, exc, type_, message, tracebackRegexp=None):\n    self.assertIsInstance(exc, objtypes.RaisedException)\n    self.assertIsInstance(exc.error, type_)\n    self.assertEqual(exc._message, message)\n    if tracebackRegexp:\n      traceback_string = exc.details\n      if sys.version_info >= (3, 11) and type_ != SyntaxError:\n        # Python 3.11+ adds lines with only spaces and ^ to indicate the location of the error.\n        # We remove those lines to make the test work with both old and new versions.\n        # This doesn't apply to SyntaxError, which has those lines in all versions.\n        traceback_string = \"\\n\".join(\n          line for line in traceback_string.splitlines()\n          if set(line) != {\" \", \"^\"}\n        )\n      self.assertRegex(traceback_string.strip(), tracebackRegexp.strip())\n\n  def assertViews(self, list_of_views):\n    \"\"\"\n    Verifies that the given View test-records correspond to the metadata for views/sections/fields.\n    \"\"\"\n    self.assertPartialData('_grist_Views', [\"id\"],\n                           [[view.id] for view in list_of_views])\n    self.assertTableData('_grist_Views_section',\n                         rows=lambda r: r.parentId,\n                         cols=\"subset\",\n                         data=[[\"id\", \"parentId\", \"parentKey\", \"tableRef\"]] + sorted(\n                           (sec.id, view.id, sec.parentKey, sec.tableRef)\n                           for view in list_of_views\n                           for sec in view.sections))\n    self.assertTableData('_grist_Views_section_field', sort=(lambda r: r.parentPos),\n                         rows=lambda r: r.parentId.parentId,\n                         cols=\"subset\",\n                         data=[[\"id\", \"parentId\", \"colRef\"]] + sorted(\n                           ((field.id, sec.id, field.colRef)\n                            for view in list_of_views\n                            for sec in view.sections\n                            for field in sec.fields), key=lambda t: t[1])\n                        )\n\n\n  def load_sample(self, sample):\n    \"\"\"\n    Load the data engine with given sample data. The sample is a dict with keys \"SCHEMA\" and\n    \"DATA\", each a dictionary mapping table names to actions.TableData objects. \"SCHEMA\" contains\n    \"_grist_Tables\" and \"_grist_Tables_column\" tables.\n    \"\"\"\n    schema = sample[\"SCHEMA\"]\n    self.engine.load_meta_tables(schema['_grist_Tables'], schema['_grist_Tables_column'])\n    for data in sample[\"DATA\"].values():\n      self.engine.load_table(data)\n    # We used to call load_done() at the end; in practice, Grist's ActiveDoc does not call\n    # load_done, but applies the \"Calculate\" user action. Do that for more realistic tests.\n    self.apply_user_action(['Calculate'])\n\n  # The following are convenience methods for tests deriving from EngineTestCase.\n  def add_column(self, table_name, col_name, **kwargs):\n    return self.apply_user_action(['AddColumn', table_name, col_name, kwargs])\n\n  def modify_column(self, table_name, col_name, **kwargs):\n    return self.apply_user_action(['ModifyColumn', table_name, col_name, kwargs])\n\n  def remove_column(self, table_name, col_name):\n    return self.apply_user_action(['RemoveColumn', table_name, col_name])\n\n  def update_record(self, table_name, row_id, **kwargs):\n    return self.apply_user_action(['UpdateRecord', table_name, row_id, kwargs])\n\n  def add_record(self, table_name, row_id=None, **kwargs):\n    return self.apply_user_action(['AddRecord', table_name, row_id, kwargs])\n\n  def remove_record(self, table_name, row_id):\n    return self.apply_user_action(['RemoveRecord', table_name, row_id])\n\n  def update_records(self, table_name, col_names, row_data):\n    return self.apply_user_action(\n      ('BulkUpdateRecord',) + testutil.table_data_from_rows(table_name, col_names, row_data))\n\n  @classmethod\n  def add_records_action(cls, table_name, data):\n    \"\"\"\n    Creates a BulkAddRecord action; data should be an array of rows, with first row containing\n    column names, with \"id\" column optional.\n    \"\"\"\n    col_names, row_data = data[0], data[1:]\n    if \"id\" not in col_names:\n      col_names = [\"id\"] + col_names\n      row_data = [[None] + r for r in row_data]\n    return ('BulkAddRecord',) + testutil.table_data_from_rows(table_name, col_names, row_data)\n\n  def add_records(self, table_name, col_names, row_data):\n    return self.apply_user_action(self.add_records_action(table_name, [col_names] + row_data))\n\n  def apply_user_action(self, user_action_repr, is_undo=False, user=None):\n    if not is_undo:\n      log.debug(\"Applying user action %r\", user_action_repr)\n      if self._undo_state_tracker is not None:\n        doc_state = self.getFullEngineData()\n\n    self.call_counts.clear()\n    out_actions = self.engine.apply_user_actions([useractions.from_repr(user_action_repr)], user)\n    out_actions.calls = self.call_counts.copy()\n\n    if not is_undo and self._undo_state_tracker is not None:\n      self._undo_state_tracker.append((doc_state, out_actions.undo[:]))\n    return out_actions\n\n  def apply_undo_actions(self, undo_actions):\n    \"\"\"\n    Applies all doc_actions together (as happens e.g. for undo).\n    \"\"\"\n    action = [\"ApplyUndoActions\", [actions.get_action_repr(a) for a in undo_actions]]\n    return self.apply_user_action(action, is_undo=True)\n\n\ndef test_undo(test_method):\n  \"\"\"\n  If a test method is decorated with `@test_engine.test_undo`, then we will store the state before\n  each apply_user_action() call, and at the end of the test, undo each user-action and compare the\n  state. This makes for a fairly comprehensive test of undo.\n  \"\"\"\n  @functools.wraps(test_method)\n  def wrapped(self):\n    self._undo_state_tracker = []\n    test_method(self)\n    for (expected_engine_data, undo_actions) in reversed(self._undo_state_tracker):\n      log.debug(\"Applying undo actions %r\", undo_actions)\n      self.apply_undo_actions(undo_actions)\n      self.assertEqualDocData(self.getFullEngineData(), expected_engine_data)\n  return wrapped\n\ntest_undo.__test__ = False  # tells pytest that this isn't a test\n\n\nclass TestEngine(EngineTestCase):\n  samples = {}\n\n  #----------------------------------------------------------------------\n  # Implementations of the actual script steps.\n  #----------------------------------------------------------------------\n  def process_apply_step(self, data):\n    \"\"\"\n    Processes the \"APPLY\" step of a test script, applying a user action, and checking the\n    resulting action group's return value (if present)\n    \"\"\"\n    if \"USER_ACTION\" in data:\n      user_actions = [useractions.from_repr(data.pop(\"USER_ACTION\"))]\n    else:\n      user_actions = [useractions.from_repr(u) for u in data.pop(\"USER_ACTIONS\")]\n\n    expected_call_counts = data.pop(\"CHECK_CALL_COUNTS\", None)\n    expected_actions = data.pop(\"ACTIONS\", {})\n    expected_actions.setdefault(\"stored\", [])\n    expected_actions.setdefault(\"calc\", [])\n    expected_actions.setdefault(\"undo\", [])\n\n    if data:\n      raise ValueError(\"Unrecognized key %s in APPLY step\" % data.popitem()[0])\n\n    self.call_counts.clear()\n    out_actions = self.engine.apply_user_actions(user_actions)\n\n    self.assertOutActions(out_actions, expected_actions)\n    if expected_call_counts:\n      self.assertEqual(self.call_counts, expected_call_counts)\n    return out_actions\n\n  #----------------------------------------------------------------------\n  # The runner for scripted test cases.\n  #----------------------------------------------------------------------\n  def _run_test_body(self, _name, body):\n    \"\"\"\n    Runs the actual script defined in the JSON test-script file.\n    \"\"\"\n    undo_actions = []\n    loaded_sample = None\n    for line, step, data in body:\n      try:\n        if step == \"LOAD_SAMPLE\":\n          if loaded_sample:\n            # pylint: disable=unsubscriptable-object\n            self._verify_undo_all(undo_actions, loaded_sample[\"DATA\"])\n          loaded_sample = self.samples[data]\n          self.load_sample(loaded_sample)\n        elif step == \"APPLY\":\n          action_group = self.process_apply_step(data)\n          undo_actions.extend(action_group.undo)\n        elif step == \"CHECK_OUTPUT\":\n          expected_data = {}\n          if \"USE_SAMPLE\" in data:\n            sample = self.samples[data.pop(\"USE_SAMPLE\")]\n            expected_data = sample[\"DATA\"].copy()\n          expected_data.update({t: testutil.table_data_from_rows(t, tdata[0], tdata[1:])\n                                for (t, tdata) in data.items()})\n          self.assertCorrectEngineData(expected_data)\n        else:\n          raise ValueError(\"Unrecognized step %s in test script\" % step)\n      except Exception as e:\n        prefix = \"LINE %s: \" % line\n        e.args = (prefix + e.args[0],) + e.args[1:] if e.args else (prefix,)\n        raise\n\n    self._verify_undo_all(undo_actions, loaded_sample[\"DATA\"])\n\n  def _verify_undo_all(self, undo_actions, expected_data):\n    \"\"\"\n    At the end of each test, undo all and verify we get back to the originally loaded sample.\n    \"\"\"\n    self.apply_undo_actions(undo_actions)\n    del undo_actions[:]\n    self.assertCorrectEngineData(expected_data)\n\n    # TODO We need several more tests.\n    # 1. After a bunch of schema actions, create a new engine from the resulting schema, ensure that\n    #    modified engine and new engine produce the same results AND the same dep_graph.\n    # 2. Build up a table by adding one column at a time, in \"good\" order and in \"bad\" order (with\n    #    references to columns that will be added later)\n    # 3. Tear down a table in both of the orders above.\n    # 4. At each intermediate state of 2 and 3, new engine should produce same results as the\n    #    modified engine (and have the same state such as dep_graph).\n\n  sample1 = {\n    \"SCHEMA\": [\n      [1, \"Address\", [\n        [11, \"city\",        \"Text\",       False, \"\", \"\", \"\"],\n        [12, \"state\",       \"Text\",       False, \"\", \"\", \"\"],\n        [13, \"amount\",      \"Numeric\",    False, \"\", \"\", \"\"],\n      ]]\n    ],\n    \"DATA\": {\n      \"Address\": [\n        [\"id\",  \"city\",     \"state\", \"amount\" ],\n        [ 21,   \"New York\", \"NY\"   , 1        ],\n        [ 22,   \"Albany\",   \"NY\"   , 2        ],\n      ]\n    }\n  }\n\n  def test_no_private_fields(self):\n    self.load_sample(testutil.parse_test_sample(self.sample1))\n\n    data = self.engine.fetch_table(\"_grist_Tables\", private=True)\n    self.assertIn('tableId', data.columns)\n    self.assertIn('columns', data.columns)\n    self.assertIn('viewSections', data.columns)\n\n    data = self.engine.fetch_table(\"_grist_Tables\")\n    self.assertIn('tableId', data.columns)\n    self.assertNotIn('columns', data.columns)\n    self.assertNotIn('viewSections', data.columns)\n\n  def test_fetch_table_query(self):\n    self.load_sample(testutil.parse_test_sample(self.sample1))\n\n    col_names = [\"id\",  \"city\",     \"state\", \"amount\" ]\n    data = self.engine.fetch_table('Address', query={'state': ['NY']})\n    self.assertEqualDocData({'Address': data},\n        {'Address': testutil.table_data_from_rows('Address', col_names, [\n          [ 21,   \"New York\", \"NY\"   , 1        ],\n          [ 22,   \"Albany\",   \"NY\"   , 2        ],\n        ])})\n\n    data = self.engine.fetch_table('Address', query={'city': ['New York'], 'state': ['NY']})\n    self.assertEqualDocData({'Address': data},\n        {'Address': testutil.table_data_from_rows('Address', col_names, [\n          [ 21,   \"New York\", \"NY\"   , 1        ],\n        ])})\n\n    data = self.engine.fetch_table('Address', query={'amount': [2.0]})\n    self.assertEqualDocData({'Address': data},\n        {'Address': testutil.table_data_from_rows('Address', col_names, [\n          [ 22,   \"Albany\",   \"NY\"   , 2        ],\n        ])})\n\n    data = self.engine.fetch_table('Address', query={'city': ['New York'], 'amount': [2.0]})\n    self.assertEqualDocData({'Address': data},\n        {'Address': testutil.table_data_from_rows('Address', col_names, [])})\n\n    data = self.engine.fetch_table('Address', query={'city': ['New York'], 'amount': [1.0, 2.0]})\n    self.assertEqualDocData({'Address': data},\n        {'Address': testutil.table_data_from_rows('Address', col_names, [\n          [ 21,   \"New York\", \"NY\"   , 1        ],\n        ])})\n\n    # Ensure empty filter list works too.\n    data = self.engine.fetch_table('Address', query={'city': ['New York'], 'amount': []})\n    self.assertEqualDocData({'Address': data},\n        {'Address': testutil.table_data_from_rows('Address', col_names, [])})\n\n    # Test unhashable values in the column and in the query\n    self.add_column('Address', 'list', type='Any', isFormula=True,\n                    formula='[1] if $id == 21 else 2')\n    col_names.append('list')\n\n    data = self.engine.fetch_table('Address', query={'list': [[1]]})\n    self.assertEqualDocData({'Address': data},\n        {'Address': testutil.table_data_from_rows('Address', col_names, [\n          [ 21,   \"New York\", \"NY\"   , 1, [1]],\n        ])})\n\n    data = self.engine.fetch_table('Address', query={'list': [2]})\n    self.assertEqualDocData({'Address': data},\n        {'Address': testutil.table_data_from_rows('Address', col_names, [\n          [ 22,   \"Albany\",   \"NY\"   , 2, 2],\n        ])})\n\n    data = self.engine.fetch_table('Address', query={'list': [[1], 2]})\n    self.assertEqualDocData({'Address': data},\n        {'Address': testutil.table_data_from_rows('Address', col_names, [\n          [ 21,   \"New York\", \"NY\"   , 1, [1]],\n          [ 22,   \"Albany\",   \"NY\"   , 2, 2],\n        ])})\n\n  def test_schema_restore_on_error(self):\n    # Simulate an error inside a DocAction, and make sure we restore the schema (don't leave it in\n    # inconsistent with metadata).\n    self.load_sample(testutil.parse_test_sample(self.sample1))\n    with self.assertRaisesRegex(AttributeError, r\"'BAD'\"):\n      self.add_column('Address', 'bad', isFormula=False, type=\"BAD\")\n    self.engine.assert_schema_consistent()\n\n\ndef create_tests_from_script(samples, test_cases):\n  \"\"\"\n  Dynamically create tests from a file containing a JSON spec for test cases. The reason for doing\n  it this way is because the same JSON spec is used to test Python and JS code.\n\n  Tests are created as methods to a TestCase. It's done on import, so that python unittest feature\n  to run only particular test cases can apply to these cases too.\n  \"\"\"\n  TestEngine.samples = samples\n  for case in test_cases:\n    create_test_case(\"test_\" + case[\"TEST_CASE\"], case[\"BODY\"])\n\ndef create_test_case(name, body):\n  \"\"\"\n  Helper for create_tests_from_script, which creates a single test case.\n  \"\"\"\n  def run(self):\n    self._run_test_body(name, body)\n  setattr(TestEngine, name, run)\n\n # Convert observed/expected action into a comparable form.\ndef get_comparable_repr(a):\n  if isinstance(a, (list, int)):\n    return a\n  return actions.get_action_repr(a)\n\n# Parse and create test cases on module load. This way the python unittest feature to run only\n# particular test cases can apply to these cases too.\ncreate_tests_from_script(*testutil.parse_testscript())\n\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_find_col.py",
    "content": "import testsamples\nimport test_engine\n\nclass TestFindCol(test_engine.EngineTestCase):\n  def test_find_col_from_values(self):\n    # Test basic functionality.\n    self.load_sample(testsamples.sample_students)\n    self.assertEqual(self.engine.find_col_from_values((\"Columbia\", \"Yale\", \"Eureka\"), 0),\n        [4, 10])\n    self.assertEqual(self.engine.find_col_from_values((\"Columbia\", \"Yale\", \"Eureka\"), 1),\n        [4])\n    self.assertEqual(self.engine.find_col_from_values([\"Yale\"], 2),\n        [10, 4])\n    self.assertEqual(self.engine.find_col_from_values((\"Columbia\", \"Yale\", \"Eureka\"), 0, \"Schools\"),\n        [10])\n\n  def test_find_col_with_nonhashable(self):\n    self.load_sample(testsamples.sample_students)\n    # Add a couple of columns returning list, which is not hashable. There used to be a bug where\n    # non-hashable values would cause an exception.\n    self.add_column(\"Students\", \"foo\", formula=\"list(Schools.lookupRecords(name=$schoolName))\")\n\n    # This column returns a non-hashable value, but is otherwise the best match.\n    self.add_column(\"Students\", \"bar\", formula=\n        \"[1,2,3] if $firstName == 'Bill' else $schoolName.lower()\")\n\n    # Check the columns are added with expected colRefs\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"colId\", \"type\",  \"isFormula\" ],\n      [22,    \"foo\",   \"Any\",   True        ],\n      [23,    \"bar\",   \"Any\",   True        ],\n      ])\n    self.assertTableData(\"Students\", cols=\"subset\", data=[\n      [\"id\",\"firstName\",\"lastName\", \"schoolName\", \"bar\",      ],\n      [1,   \"Barack\",   \"Obama\",    \"Columbia\",   \"columbia\"  ],\n      [2,   \"George W\", \"Bush\",     \"Yale\",       \"yale\"      ],\n      [3,   \"Bill\",     \"Clinton\",  \"Columbia\",   [1,2,3]     ],\n      [4,   \"George H\", \"Bush\",     \"Yale\",       \"yale\"      ],\n      [5,   \"Ronald\",   \"Reagan\",   \"Eureka\",     \"eureka\"    ],\n      [6,   \"Gerald\",   \"Ford\",     \"Yale\",       \"yale\"      ],\n    ])\n\n    self.assertEqual(self.engine.find_col_from_values((\"Columbia\", \"Yale\", \"Eureka\"), 0), [4, 10])\n    self.assertEqual(self.engine.find_col_from_values((\"columbia\", \"yale\", \"Eureka\"), 0), [23, 4])\n\n    # Test that it's safe to include a non-hashable value in the request.\n    self.assertEqual(self.engine.find_col_from_values((\"columbia\", \"yale\", [\"Eureka\"]), 0), [23])\n"
  },
  {
    "path": "sandbox/grist/test_formula_error.py",
    "content": "\"\"\"\nTests that formula error messages (traceback) are correct\n\"\"\"\nimport textwrap\nimport depend\nimport test_engine\nimport testutil\nimport objtypes\n\n\nclass TestErrorMessage(test_engine.EngineTestCase):\n\n  syntax_err = \\\n\"\"\"\nif sum(3, 5) > 6:\n  return 6\nelse:\n  return: 0\n\"\"\"\n\n  indent_err = \\\n\"\"\"\n  if sum(3, 5) > 6:\n    return 6\nreturn 0\n\"\"\"\n\n  other_err = \\\n\"\"\"\n  if sum(3, 5) > 6:\n    return 6\n\"\"\"\n\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Math\", [\n        [11, \"excel_formula\", \"Text\", True, \"SQRT(16, 2)\", \"\", \"\"],\n        [12, \"built_in_formula\", \"Text\", True, \"max(5)\", \"\", \"\"],\n        [13, \"syntax_err\", \"Text\", True, syntax_err, \"\", \"\"],\n        [14, \"indent_err\", \"Text\", True, indent_err, \"\", \"\"],\n        [15, \"other_err\", \"Text\", True, other_err, \"\", \"\"],\n        [15, \"custom_err\", \"Text\", True, \"raise Exception('hello'); return 1\", \"\", \"\"],\n      ]]\n    ],\n    \"DATA\": {\n      \"Math\": [\n        [\"id\"],\n        [3],\n      ]\n    }\n  })\n\n  def test_formula_errors(self):\n    self.load_sample(self.sample)\n\n    self.assertFormulaError(\n        self.engine.get_formula_error('Math', 'excel_formula', 3), TypeError,\n        'SQRT() takes 1 positional argument but 2 were given\\n\\n'\n        'A `TypeError` is usually caused by trying\\n'\n        'to combine two incompatible types of objects,\\n'\n        'by calling a function with the wrong type of object,\\n'\n        'or by trying to do an operation not allowed on a given type of object.\\n\\n'\n        'You apparently have called the function `SQRT` with\\n'\n        '2 positional argument(s) while it requires 1\\n'\n        'such positional argument(s).',\n        r\"TypeError: SQRT\\(\\) takes 1 positional argument but 2 were given\",\n    )\n\n    int_not_iterable_message = \"'int' object is not iterable\"\n    int_not_iterable_message += (\n        '\\n\\n'\n        'A `TypeError` is usually caused by trying\\n'\n        'to combine two incompatible types of objects,\\n'\n        'by calling a function with the wrong type of object,\\n'\n        'or by trying to do an operation not allowed on a given type of object.\\n\\n'\n        'An iterable is an object capable of returning its members one at a time.\\n'\n        'Python containers (`list, tuple, dict`, etc.) are iterables.\\n'\n        'An iterable is required here.'\n    )\n    self.assertFormulaError(self.engine.get_formula_error('Math', 'built_in_formula', 3),\n                            TypeError, int_not_iterable_message,\n                            textwrap.dedent(\n                              r\"\"\"\n                                File \"usercode\", line \\d+, in built_in_formula\n                                  return max\\(5\\)\n                              TypeError: 'int' object is not iterable\n                              \"\"\"\n                            ))\n\n    message = textwrap.dedent(\n        \"\"\"\\\n        invalid syntax\n\n        A `SyntaxError` occurs when Python cannot understand your code.\n\n        I am guessing that you wrote `:` by mistake.\n        Removing it and writing `return 0` seems to fix the error.\n\n         (usercode, line 5)\"\"\")\n    self.assertFormulaError(self.engine.get_formula_error('Math', 'syntax_err', 3),\n                            SyntaxError, message,\n                            textwrap.dedent(\n                              r\"\"\"\n                                File \"usercode\", line 5\n                                  return: 0\n                                        \\^\n                              SyntaxError: invalid syntax\n                              \"\"\"\n                            ))\n\n    traceback_regex = textwrap.dedent(\n        r\"\"\"\n          File \"usercode\", line 2\n            if sum\\(3, 5\\) > 6:\n        IndentationError: unexpected indent\n        \"\"\"\n      )\n    message = textwrap.dedent(\n        \"\"\"\\\n        unexpected indent\n\n        An `IndentationError` occurs when a given line of code is\n        not indented (aligned vertically with other lines) as expected.\n\n        Line `2` identified above is more indented than expected.\n\n         (usercode, line 2)\"\"\")\n    self.assertFormulaError(self.engine.get_formula_error('Math', 'indent_err', 3),\n                            IndentationError, message, traceback_regex)\n\n    self.assertFormulaError(self.engine.get_formula_error('Math', 'other_err', 3),\n                            TypeError, int_not_iterable_message,\n                            textwrap.dedent(\n                              r\"\"\"\n                                File \"usercode\", line \\d+, in other_err\n                                  if sum\\(3, 5\\) > 6:\n                              TypeError: 'int' object is not iterable\n                              \"\"\"\n                            ))\n\n    self.assertFormulaError(self.engine.get_formula_error('Math', 'custom_err', 3),\n                            Exception, \"hello\")\n\n  def test_missing_all_attribute(self):\n    # Test that `Table.Col` raises a helpful AttributeError suggesting to use `Table.all.Col`.\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table\", [\n          [11, \"A\", \"Any\", True, \"Table.id\", \"\", \"\"],\n          [12, \"B\", \"Any\", True, \"Table.id2\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table\": [\n          [\"id\"],\n          [1],\n        ]\n      }\n    })\n\n    self.load_sample(sample)\n\n    # `Table.id` gives a custom message because `id` is an existing column.\n    self.assertFormulaError(\n      self.engine.get_formula_error('Table', 'A', 1),\n      AttributeError,\n        'To retrieve all values in a column, use `Table.all.id`. '\n        \"Tables have no attribute 'id'\"\n        \"\\n\\nAn `AttributeError` occurs when the code contains something like\\n\"\n        \"    `object.x`\\n\"\n        \"and `x` is not a method or attribute (variable) belonging to `object`.\"\n    )\n\n    # `Table.id2` gives a standard message because `id2` is not an existing column.\n    error = self.engine.get_formula_error('Table', 'B', 1).error\n    message = str(error)\n    self.assertNotIn('Table.all', message)\n    self.assertIn(\"'UserTable' object has no attribute 'id2'\", message)\n\n  def test_missing_all_iteration(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"MyTable\", [\n          [11, \"A\", \"Any\", True, \"list(MyTable)\", \"\", \"\"],\n          [12, \"B\", \"Any\", True, \"list(MyTable.all)\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"MyTable\": [\n          [\"id\"],\n          [1],\n        ]\n      }\n    })\n\n    self.load_sample(sample)\n\n    # `list(MyTable)` gives a custom message suggesting `.all`.\n    self.assertFormulaError(\n      self.engine.get_formula_error('MyTable', 'A', 1),\n      TypeError,\n        \"To iterate (loop) over all records in a table, use `MyTable.all`. \"\n        \"Tables are not directly iterable.\"\n        '\\n\\nA `TypeError` is usually caused by trying\\n'\n        'to combine two incompatible types of objects,\\n'\n        'by calling a function with the wrong type of object,\\n'\n        'or by trying to do an operation not allowed on a given type of object.'\n    )\n\n    # `list(MyTable.all)` works correctly.\n    self.assertTableData('MyTable', data=[\n      ['id', 'A', 'B'],\n      [ 1,   objtypes.RaisedException(TypeError()), [objtypes.RecordStub('MyTable', 1)]],\n    ])\n\n  def test_lookup_state(self):\n    # Bug https://phab.getgrist.com/T297 was caused by lookup maps getting corrupted while\n    # re-evaluating a formula for the sake of getting error details. This test case reproduces the\n    # bug in the old code and verifies that it is fixed.\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"LookupTest\", [\n          [11, \"A\", \"Numeric\",  False, \"\", \"\", \"\"],\n          [12, \"B\", \"Text\",     True, \"LookupTest.lookupOne(A=2).x.upper()\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"LookupTest\": [\n          [\"id\", \"A\"],\n          [7,    2],\n        ]\n      }\n    })\n\n    self.load_sample(sample)\n    self.assertTableData('LookupTest', data=[\n      ['id', 'A', 'B'],\n      [ 7,   2.,  objtypes.RaisedException(AttributeError())],\n    ])\n\n    # Updating a dependency shouldn't cause problems.\n    self.update_record('LookupTest', 7, A=3)\n    self.assertTableData('LookupTest', data=[\n      ['id', 'A', 'B'],\n      [ 7,   3.,  objtypes.RaisedException(AttributeError())],\n    ])\n\n    # Fetch the error details.\n    self.assertFormulaError(self.engine.get_formula_error('LookupTest', 'B', 7),\n                            AttributeError, \"Table 'LookupTest' has no column 'x'\")\n\n    # Updating a dependency after the fetch used to cause the error\n    # \"AttributeError: 'Table' object has no attribute 'col_id'\". Check that it's fixed.\n    self.update_record('LookupTest', 7, A=2)    # Should NOT raise an exception.\n    self.assertTableData('LookupTest', data=[\n      ['id', 'A', 'B'],\n      [ 7,   2.,  objtypes.RaisedException(AttributeError())],\n    ])\n\n    # Add the column that will fix the attribute error.\n    self.add_column('LookupTest', 'x', type='Text')\n    self.assertTableData('LookupTest', data=[\n      ['id', 'A', 'x', 'B'],\n      [ 7,   2.,  '',  '' ],\n    ])\n\n    # And check that the dependency still works and is recomputed.\n    self.update_record('LookupTest', 7, x='hello')\n    self.assertTableData('LookupTest', data=[\n      ['id', 'A', 'x',      'B'],\n      [ 7,   2.,  'hello',  'HELLO'],\n    ])\n    self.update_record('LookupTest', 7, A=3)\n    self.assertTableData('LookupTest', data=[\n      ['id', 'A', 'x',      'B'],\n      [ 7,   3.,  'hello',  ''],\n    ])\n\n  def test_undo_side_effects(self):\n    # Ensures that side-effects (i.e. generated doc actions) produced while evaluating\n    # get_formula_errors() get reverted.\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Address\", [\n          [11, \"city\",        \"Text\",       False, \"\", \"\", \"\"],\n          [12, \"state\",       \"Text\",       False, \"\", \"\", \"\"],\n        ]],\n        [2, \"Foo\", [\n          # Note: the formula below is a terrible example of a formula, which intentionally\n          # creates a new record every time it evaluates.\n          [21, \"B\",           \"Any\",        True,\n            \"Address.lookupOrAddDerived(city=str(len(Address.all)))\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Foo\": [[\"id\"], [1]]\n      }\n    })\n\n    self.load_sample(sample)\n    self.assertTableData('Address', data=[\n      ['id',  'city', 'state'],\n      [1,     '0',      ''],\n    ])\n    # Note that evaluating the formula again would add a new record (Address[2]), but when done as\n    # part of get_formula_error(), that action gets undone.\n    self.assertEqual(str(self.engine.get_formula_error('Foo', 'B', 1)), \"Address[2]\")\n    self.assertTableData('Address', data=[\n      ['id',  'city', 'state'],\n      [1,     '0',      ''],\n    ])\n\n  def test_formula_reading_from_an_errored_formula(self):\n    # There was a bug whereby if one formula (call it D) referred to\n    # another (call it T), and that other formula was in error, the\n    # error values of that second formula would not be passed on the\n    # client as a BulkUpdateRecord.  The bug was dependent on order of\n    # evaluation of columns.  D would be evaluated first, and evaluate\n    # T in a nested way.  When evaluating T, a BulkUpdateRecord would\n    # be prepared correctly, and when popping back to evaluate D,\n    # the BulkUpdateRecord for D would be prepared correctly, but since\n    # D was an error, any nested actions would be reverted (this is\n    # logic related to undoing potential side-effects on failure).\n\n    # First, set up a table with a sequence in A, a formula to do cumulative sums in T,\n    # and a formula D to copy T.\n    formula = \"recs = UpdateTest.lookupRecords()\\nsum(r.A for r in recs if r.A <= $A)\"\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"UpdateTest\", [\n          [20, \"A\", \"Numeric\",  False, \"\", \"\", \"\"],\n          [21, \"T\", \"Numeric\",  True, formula, \"\", \"\"],\n          [22, \"D\", \"Numeric\",  True, \"$T\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"UpdateTest\": [\n          [\"id\", \"A\"],\n          [1,    1],\n          [2,    2],\n          [3,    3],\n        ]\n      }\n    })\n\n    # Check the setup is working correctly.\n    self.load_sample(sample)\n    self.assertTableData('UpdateTest', data=[\n      ['id', 'A', 'T', 'D'],\n      [ 1,   1.,  1., 1.],\n      [ 2,   2.,  3., 3.],\n      [ 3,   3.,  6., 6.],\n    ])\n\n    # Now rename the data column.  This rename results in a partial\n    # update to the T formula that leaves it broken (not all the As are caught).\n    out_actions = self.apply_user_action([\"RenameColumn\", \"UpdateTest\", \"A\", \"AA\"])\n\n    # Make sure the we have bulk updates for both T and D, and not just D.\n    err = [\"E\", \"AttributeError\"]\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"UpdateTest\", \"A\", \"AA\"],\n      [\"ModifyColumn\", \"UpdateTest\", \"T\", {\n        \"formula\": \"recs = UpdateTest.lookupRecords()\\nsum(r.A for r in recs if r.A <= $AA)\"}\n      ],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [20, 21], {\n        \"colId\": [\"AA\", \"T\"],\n        \"formula\": [\"\", \"recs = UpdateTest.lookupRecords()\\nsum(r.A for r in recs if r.A <= $AA)\"]}\n      ],\n      [\n        \"BulkUpdateRecord\", \"UpdateTest\", [1, 2, 3], {\n          \"D\": [err, err, err]\n        }\n      ],\n      [\n        \"BulkUpdateRecord\", \"UpdateTest\", [1, 2, 3], {\n          \"T\": [err, err, err]\n        }\n      ],\n    ]})\n\n    # Make sure the table is in the correct state.\n    errVal = objtypes.RaisedException(AttributeError())\n    self.assertTableData('UpdateTest', data=[\n      ['id', 'AA', 'T', 'D'],\n      [ 1,   1., errVal, errVal],\n      [ 2,   2., errVal, errVal],\n      [ 3,   3., errVal, errVal],\n    ])\n\n  def test_undo_side_effects_with_reordering(self):\n    # As for test_undo_side_effects, but now after creating a row in a\n    # formula we try to access a cell that hasn't been recomputed yet.\n    # That will result in the formula evalution being abandoned, the\n    # desired cell being calculated, then the formula being retried.\n    # All going well, we should end up with one row, not two.\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Address\", [\n          [11, \"city\",        \"Text\",       False, \"\", \"\", \"\"],\n          [12, \"state\",       \"Text\",       False, \"\", \"\", \"\"],\n        ]],\n        [2, \"Foo\", [\n          # Note: the formula below is a terrible example of a formula, which intentionally\n          # creates a new record every time it evaluates.\n          [21, \"B\",           \"Any\",        True,\n            \"Address.lookupOrAddDerived(city=str(len(Address.all)))\\nreturn $C\", \"\", \"\"],\n          [22, \"C\",           \"Numeric\",    True, \"42\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Foo\": [[\"id\"], [1]]\n      }\n    })\n\n    self.load_sample(sample)\n    self.assertTableData('Address', data=[\n      ['id',  'city', 'state'],\n      [1,     '0',      ''],\n    ])\n\n  def test_attribute_error(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"AttrTest\", [\n          [30, \"A\", \"Numeric\",  False, \"\", \"\", \"\"],\n          [31, \"B\", \"Numeric\",  True, \"$AA\", \"\", \"\"],\n          [32, \"C\", \"Numeric\",  True, \"$B\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"AttrTest\": [\n          [\"id\", \"A\"],\n          [1,    1],\n          [2,    2],\n        ]\n      }\n    })\n\n    self.load_sample(sample)\n    errVal = objtypes.RaisedException(AttributeError())\n    self.assertTableData('AttrTest', data=[\n      ['id',  'A', 'B', 'C'],\n      [1, 1, errVal, errVal],\n      [2, 2, errVal, errVal],\n    ])\n\n    self.assertFormulaError(self.engine.get_formula_error('AttrTest', 'B', 1),\n                            AttributeError, \"Table 'AttrTest' has no column 'AA'\",\n                            r\"AttributeError: Table 'AttrTest' has no column 'AA'\")\n    cell_error = self.engine.get_formula_error('AttrTest', 'C', 1)\n    self.assertFormulaError(\n      cell_error, objtypes.CellError,\n      \"Table 'AttrTest' has no column 'AA'\\n(in referenced cell AttrTest[1].B)\",\n      r\"CellError: AttributeError in referenced cell AttrTest\\[1\\].B\",\n    )\n    self.assertEqual(\n      objtypes.encode_object(cell_error),\n      ['E',\n       'AttributeError',\n       \"Table 'AttrTest' has no column 'AA'\\n\"\n       \"(in referenced cell AttrTest[1].B)\",\n       cell_error.details]\n    )\n\n  def test_cumulative_formula(self):\n    formula = (\"Table1.lookupOne(A=$A-1).Principal + Table1.lookupOne(A=$A-1).Interest \" +\n               \"if $A > 1 else 1000\")\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [30, \"A\", \"Numeric\",  False, \"\", \"\", \"\"],\n          [31, \"Principal\", \"Numeric\",  True, formula, \"\", \"\"],\n          [32, \"Interest\", \"Numeric\",  True, \"int($Principal * 0.1)\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"A\"],\n          [1,    1],\n          [2,    2],\n          [3,    3],\n          [4,    4],\n          [5,    5],\n        ]\n      }\n    })\n\n    self.load_sample(sample)\n    self.assertTableData('Table1', data=[\n      ['id', 'A', 'Principal', 'Interest'],\n      [ 1,   1,    1000.0, 100.0],\n      [ 2,   2,    1100.0, 110.0],\n      [ 3,   3,    1210.0, 121.0],\n      [ 4,   4,    1331.0, 133.0],\n      [ 5,   5,    1464.0, 146.0],\n    ])\n\n    self.update_records('Table1', ['id', 'A'], [\n      [1, 5], [2, 3], [3, 4], [4, 2], [5, 1]\n    ])\n\n    self.assertTableData('Table1', data=[\n      ['id', 'A', 'Principal', 'Interest'],\n      [ 1,   5,    1464.0, 146.0],\n      [ 2,   3,    1210.0, 121.0],\n      [ 3,   4,    1331.0, 133.0],\n      [ 4,   2,    1100.0, 110.0],\n      [ 5,   1,    1000.0, 100.0],\n    ])\n\n  def test_trivial_cycle(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [31, \"A\", \"Numeric\", False, \"\", \"\", \"\"],\n          [31, \"B\", \"Numeric\",  True, \"$B\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"A\"],\n          [1,    1],\n          [2,    2],\n          [3,    3],\n        ]\n      }\n    })\n\n    self.load_sample(sample)\n    circle = objtypes.RaisedException(depend.CircularRefError())\n    self.assertTableData('Table1', data=[\n      ['id', 'A',  'B'],\n      [ 1,   1,    circle],\n      [ 2,   2,    circle],\n      [ 3,   3,    circle],\n    ])\n\n  def test_cycle(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [30, \"A\", \"Numeric\",  False, \"\", \"\", \"\"],\n          [31, \"Principal\", \"Numeric\",  True, \"$Interest\", \"\", \"\"],\n          [32, \"Interest\", \"Numeric\",  True, \"$Principal\", \"\", \"\"],\n          [33, \"A2\", \"Numeric\",  True, \"$A\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"A\"],\n          [1,    1],\n          [2,    2],\n          [3,    3],\n        ]\n      }\n    })\n\n    self.load_sample(sample)\n    circle = objtypes.RaisedException(depend.CircularRefError())\n    self.assertTableData('Table1', data=[\n      ['id', 'A', 'Principal', 'Interest', 'A2'],\n      [ 1,   1,    circle,      circle,     1],\n      [ 2,   2,    circle,      circle,     2],\n      [ 3,   3,    circle,      circle,     3],\n    ])\n\n  def test_cycle_and_copy(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [31, \"A\", \"Numeric\", False, \"\", \"\", \"\"],\n          [31, \"B\", \"Numeric\",  True, \"$C\", \"\", \"\"],\n          [32, \"C\", \"Numeric\",  True, \"$C\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"A\"],\n          [1,    1],\n          [2,    2],\n          [3,    3],\n        ]\n      }\n    })\n\n    self.load_sample(sample)\n    circle = objtypes.RaisedException(depend.CircularRefError())\n    self.assertTableData('Table1', data=[\n      ['id', 'A',  'B',         'C'],\n      [ 1,   1,    circle,      circle],\n      [ 2,   2,    circle,      circle],\n      [ 3,   3,    circle,      circle],\n    ])\n\n  def test_cycle_and_reference(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [2, \"ATable\", [\n          [32, \"A\", \"Ref:ZTable\", False, \"\", \"\", \"\"],\n          [33, \"B\", \"Numeric\",  True, \"$A.B\", \"\", \"\"],\n        ]],\n        [1, \"ZTable\", [\n          [31, \"A\", \"Numeric\", False, \"\", \"\", \"\"],\n          [31, \"B\", \"Numeric\",  True, \"$B\", \"\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"ATable\": [\n          [\"id\", \"A\"],\n          [1,    1],\n          [2,    2],\n          [3,    3],\n        ],\n        \"ZTable\": [\n          [\"id\", \"A\"],\n          [1,    6],\n          [2,    7],\n          [3,    8],\n        ]\n      }\n    })\n\n    self.load_sample(sample)\n    circle = objtypes.RaisedException(depend.CircularRefError())\n    self.assertTableData('ATable', data=[\n      ['id', 'A',  'B'],\n      [ 1,   1,    circle],\n      [ 2,   2,    circle],\n      [ 3,   3,    circle],\n    ])\n    self.assertTableData('ZTable', data=[\n      ['id', 'A',  'B'],\n      [ 1,   6,    circle],\n      [ 2,   7,    circle],\n      [ 3,   8,    circle],\n    ])\n\n  def test_cumulative_efficiency(self):\n    # Make sure cumulative formula evaluation doesn't fall over after more than a few rows.\n    top = 250\n    # Compute compound interest in ascending order of A\n    formula = (\"Table1.lookupOne(A=$A-1).Principal + Table1.lookupOne(A=$A-1).Interest \" +\n               \"if $A > 1 else 1000\")\n    # Compute compound interest in descending order of A\n    rformula = (\"Table1.lookupOne(A=$A+1).RPrincipal + Table1.lookupOne(A=$A+1).RInterest \" +\n                \"if $A < %d else 1000\" % top)\n\n    rows = [[\"id\", \"A\"]]\n    for i in range(1, top + 1):\n      rows.append([i, i])\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [30, \"A\", \"Numeric\",  False, \"\", \"\", \"\"],\n          [31, \"Principal\", \"Numeric\",  True, formula, \"\", \"\"],\n          [32, \"Interest\", \"Numeric\",  True, \"int($Principal * 0.1)\", \"\", \"\"],\n          [33, \"RPrincipal\", \"Numeric\",  True, rformula, \"\", \"\"],\n          [34, \"RInterest\", \"Numeric\",  True, \"int($RPrincipal * 0.1)\", \"\", \"\"],\n          [35, \"Total\", \"Numeric\", True, \"$Principal + $RPrincipal\", \"\", \"\"],\n        ]],\n        [2, \"Readout\", [\n          [36, \"LastPrincipal\", \"Numeric\", True, \"Table1.lookupOne(A=%d).Principal\" % top, \"\", \"\"],\n          [37, \"LastRPrincipal\", \"Numeric\", True, \"Table1.lookupOne(A=1).RPrincipal\", \"\", \"\"],\n          [38, \"FirstTotal\", \"Numeric\", True, \"Table1.lookupOne(A=1).Total\", \"\", \"\"],\n          [39, \"LastTotal\", \"Numeric\", True, \"Table1.lookupOne(A=%d).Total\" % top, \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table1\": rows,\n        \"Readout\": [[\"id\"], [1]],\n      }\n    })\n\n    self.load_sample(sample)\n    principal = 20213227788876.0\n    self.assertTableData('Readout', data=[\n      ['id', 'LastPrincipal', 'LastRPrincipal', 'FirstTotal', 'LastTotal'],\n      [1, principal, principal, principal + 1000, principal + 1000],\n    ])\n\n  def test_cumulative_formula_with_references(self):\n    top = 100\n    formula = \"max($Prev.Principal + $Prev.Interest, 1000)\"\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [41, \"Prev\", \"Ref:Table1\", True, \"$id - 1\", \"\", \"\"],\n          [42, \"Principal\", \"Numeric\",  True, formula, \"\", \"\"],\n          [43, \"Interest\", \"Numeric\",  True, \"int($Principal * 0.1)\", \"\", \"\"],\n        ]],\n        [2, \"Readout\", [\n          [46, \"LastPrincipal\", \"Numeric\", True, \"Table1.lookupOne(id=%d).Principal\" % top, \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table1\": [[\"id\"]] + [[r] for r in range(1, top + 1)],\n        \"Readout\": [[\"id\"], [1]],\n     }\n    })\n\n    self.load_sample(sample)\n    self.assertTableData('Readout', data=[\n      ['id', 'LastPrincipal'],\n      [1,  12494908.0],\n    ])\n\n    self.modify_column(\"Table1\", \"Prev\", formula=\"$id - 1 if $id > 1 else 100\")\n    self.assertTableData('Readout', data=[\n      ['id', 'LastPrincipal'],\n      [1, objtypes.RaisedException(depend.CircularRefError())],\n    ])\n\n  def test_catch_all_in_formula(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [51, \"A\", \"Numeric\",  False, \"\", \"\", \"\"],\n          [52, \"B1\", \"Numeric\",  True, \"try:\\n  return $A+$C\\nexcept:\\n  return 42\", \"\", \"\"],\n          [53, \"B2\", \"Numeric\",  True, \"try:\\n  return $D+None\\nexcept:\\n  return 42\", \"\", \"\"],\n          [54, \"B3\", \"Numeric\",  True, \"try:\\n  return $A+$B4+$D\\nexcept:\\n  return 42\", \"\", \"\"],\n          [55, \"B4\", \"Numeric\",  True, \"try:\\n  return $A+$B3+$D\\nexcept:\\n  return 42\", \"\", \"\"],\n          [56, \"B5\", \"Numeric\",  True,\n           \"try:\\n  return $E+1\\nexcept:\\n  raise Exception('monkeys!')\", \"\", \"\"],\n          [56, \"B6\", \"Numeric\",  True,\n           \"try:\\n  return $F+1\\nexcept Exception as e:\\n  e.node = e.row_id = 'monkey'\", \"\", \"\"],\n          [57, \"C\", \"Numeric\",  False, \"\", \"\", \"\"],\n          [58, \"D\", \"Numeric\",   True, \"$A\", \"\", \"\"],\n          [59, \"E\", \"Numeric\",   True, \"$A\", \"\", \"\"],\n          [59, \"F\", \"Numeric\",   True, \"$A\", \"\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"Table1\": [[\"id\", \"A\", \"C\"], [1, 1, 2], [2, 20, 10]],\n     }\n    })\n    self.load_sample(sample)\n    circle = objtypes.RaisedException(depend.CircularRefError())\n    # B4 is a subtle case.  B3 and B4 refer to each other.  B3 is recomputed first,\n    # and cells evaluate to a CircularRefError.  Now B3 has a value, so B4 can be\n    # evaluated, and results in 42 when addition of an integer and an exception value\n    # fails.\n    self.assertTableData('Table1', data=[\n      ['id', 'A', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'C', 'D', 'E', 'F'],\n      [1,     1,   3,    42, circle, 42,    2,    2,   2,   1,   1,   1],\n      [2,    20,  30,    42, circle, 42,   21,   21,  10,  20,  20,  20],\n    ])\n\n  def test_reference_column(self):\n    # There was a bug where self-references could result in a column being prematurely\n    # considered complete.\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [40, \"Ident\", \"Text\", False, \"\", \"\", \"\"],\n          [41, \"Prev\", \"Ref:Table1\", False, \"\", \"\", \"\"],\n          [42, \"Calc\", \"Numeric\", True, \"$Prev.Calc * 1.5 if $Prev else 1\", \"\", \"\"]\n        ]]],\n        \"DATA\": {\n          \"Table1\": [\n            ['id', 'Ident', 'Prev'],\n            [1, 'a', 0],\n            [2, 'b', 1],\n            [3, 'c', 4],\n            [4, 'd', 0],\n          ]\n        }\n    })\n    self.load_sample(sample)\n    self.assertTableData('Table1', data=[\n      ['id', 'Ident', 'Prev', 'Calc'],\n      [1, 'a', 0, 1.0],\n      [2, 'b', 1, 1.5],\n      [3, 'c', 4, 1.5],\n      [4, 'd', 0, 1.0]\n    ])\n\n  def test_loop(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [31, \"A\", \"Numeric\", False, \"\", \"\", \"\"],\n          [31, \"B\", \"Numeric\",  True, \"$C\", \"\", \"\"],\n          [32, \"C\", \"Numeric\",  True, \"$B\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"A\"],\n          [1,    1],\n          [2,    2],\n          [3,    3],\n        ]\n      }\n    })\n\n    self.load_sample(sample)\n    circle = objtypes.RaisedException(depend.CircularRefError())\n    self.assertTableData('Table1', data=[\n      ['id', 'A',  'B',         'C'],\n      [ 1,   1,    circle,      circle],\n      [ 2,   2,    circle,      circle],\n      [ 3,   3,    circle,      circle],\n    ])\n\n  def test_peek(self):\n    \"\"\"\n    Test using the PEEK function to avoid circular errors in formulas.\n    \"\"\"\n    col = testutil.col_schema_row\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          col(31, \"A\", \"Numeric\", False, \"$B + 1\", recalcDeps=[31, 32]),\n          col(32, \"B\", \"Numeric\", False, \"$A + 1\", recalcDeps=[31, 32]),\n        ]]\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"A\", \"B\"],\n        ]\n      }\n    })\n    self.load_sample(sample)\n\n    # Normal formulas without PEEK() raise a circular error as expected.\n    self.add_record(\"Table1\", A=1)\n    self.add_record(\"Table1\")\n    error = depend.CircularRefError(\"Circular Reference\")\n    self.assertTableData('Table1', data=[\n      ['id', 'A', 'B'],\n      [1, objtypes.RaisedException(error, user_input=None),\n          objtypes.RaisedException(error, user_input=0)],\n      [2, objtypes.RaisedException(error, user_input=None),\n          objtypes.RaisedException(error, user_input=0)],\n    ])\n    self.remove_record(\"Table1\", 1)\n    self.remove_record(\"Table1\", 2)\n\n    self.modify_column(\"Table1\", \"A\", formula=\"PEEK($B) + 1\")\n    self.add_record(\"Table1\", A=10)\n    self.add_record(\"Table1\", B=20)\n\n    self.modify_column(\"Table1\", \"A\", formula=\"$B + 1\")\n    self.modify_column(\"Table1\", \"B\", formula=\"PEEK($A + 1)\")\n    self.add_record(\"Table1\", A=100)\n    self.add_record(\"Table1\", B=200)\n\n    self.assertTableData('Table1', data=[\n      ['id', 'A', 'B'],\n      # When A peeks at B, A gets evaluated first, so it's always 1 less than B\n      [1, 1,  2],  # Here we set A=10 but it used $B+1 where B=0 (the default value)\n      [2, 21, 22],\n\n      # Now B peeks at A so B is evaluated first\n      [3, 102, 101],\n      [4, 2,   1],\n    ])\n\n    # Test updating records (instead of just adding)\n    self.update_record(\"Table1\", 1, A=30)\n    self.update_record(\"Table1\", 2, B=40)\n    self.update_record(\"Table1\", 3, A=50, B=60)\n\n    self.assertTableData('Table1', rows=\"subset\", data=[\n      ['id', 'A', 'B'],\n      # B is still peeking at A so it's always evaluated first and 1 less than A\n      [1, 32, 31],\n      [2, 23, 22],  # The user input B=40 was overridden by the formula, which saw the old A=21\n      [3, 52, 51],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_formula_prompt.py",
    "content": "import unittest\nfrom asttokens.util import fstring_positions_work\n\nimport test_engine\nimport testutil\n\nfrom formula_prompt import (\n  values_type, column_type, referenced_tables, get_formula_prompt, convert_completion,\n)\nfrom objtypes import RaisedException\nfrom records import Record as BaseRecord, RecordSet as BaseRecordSet\n\n\nclass FakeTable(object):\n\n  def __init__(self):\n    class Record(BaseRecord):\n      _table = self\n    class RecordSet(BaseRecordSet):\n      _table = self\n    self.Record = Record\n    self.RecordSet = RecordSet\n\n  table_id = \"Table1\"\n  _identity_relation = None\n\n\nfake_table = FakeTable()\n\n\nclass TestFormulaPrompt(test_engine.EngineTestCase):\n  def test_values_type(self):\n    self.assertEqual(values_type([1, 2, 3]), \"int\")\n    self.assertEqual(values_type([1.0, 2.0, 3.0]), \"float\")\n    self.assertEqual(values_type([1, 2, 3.0]), \"float\")\n\n    self.assertEqual(values_type([1, 2, None]), \"Optional[int]\")\n    self.assertEqual(values_type([1, 2, 3.0, None]), \"Optional[float]\")\n\n    self.assertEqual(values_type([1, RaisedException(None), 3]), \"int\")\n    self.assertEqual(values_type([1, RaisedException(None), None]), \"Optional[int]\")\n\n    self.assertEqual(values_type([\"1\", \"2\", \"3\"]), \"str\")\n    self.assertEqual(values_type([1, 2, \"3\"]), \"Any\")\n    self.assertEqual(values_type([1, 2, \"3\", None]), \"Any\")\n\n    self.assertEqual(values_type([\n      fake_table.Record(None),\n      fake_table.Record(None),\n    ]), \"Table1\")\n    self.assertEqual(values_type([\n      fake_table.Record(None),\n      fake_table.Record(None),\n      None,\n    ]), \"Optional[Table1]\")\n\n    self.assertEqual(values_type([\n      fake_table.RecordSet(None),\n      fake_table.RecordSet(None),\n    ]), \"list[Table1]\")\n    self.assertEqual(values_type([\n      fake_table.RecordSet(None),\n      fake_table.RecordSet(None),\n      None,\n    ]), \"Optional[list[Table1]]\")\n\n    self.assertEqual(values_type([[1, 2, 3]]), \"list[int]\")\n    self.assertEqual(values_type([[1, 2, 3], None]), \"Optional[list[int]]\")\n    self.assertEqual(values_type([[1, 2, None]]), \"list[Optional[int]]\")\n    self.assertEqual(values_type([[1, 2, None], None]), \"Optional[list[Optional[int]]]\")\n    self.assertEqual(values_type([[1, 2, \"3\"]]), \"list[Any]\")\n\n    self.assertEqual(values_type([{1, 2, 3}]), \"set[int]\")\n    self.assertEqual(values_type([(1, 2, 3)]), \"tuple[int, ...]\")\n    self.assertEqual(values_type([{1: [\"2\"]}]), \"dict[int, list[str]]\")\n\n  def assert_column_type(self, col_id, expected_type):\n    self.assertEqual(column_type(self.engine, \"Table2\", col_id), expected_type)\n\n  def assert_prompt(self, table_name, col_id, expected_prompt, lookups=False):\n    prompt = get_formula_prompt(self.engine, table_name, col_id, include_all_tables=False,\n                                lookups=lookups)\n    # print(prompt)\n    self.assertEqual(prompt, expected_prompt)\n\n  def test_column_type(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table2\", [\n          [1, \"text\", \"Text\", False, \"\", \"\", \"\"],\n          [2, \"numeric\", \"Numeric\", False, \"\", \"\", \"\"],\n          [3, \"int\", \"Int\", False, \"\", \"\", \"\"],\n          [4, \"bool\", \"Bool\", False, \"\", \"\", \"\"],\n          [5, \"date\", \"Date\", False, \"\", \"\", \"\"],\n          [6, \"datetime\", \"DateTime\", False, \"\", \"\", \"\"],\n          [7, \"attachments\", \"Attachments\", False, \"\", \"\", \"\"],\n          [8, \"ref\", \"Ref:Table2\", False, \"\", \"\", \"\"],\n          [9, \"reflist\", \"RefList:Table2\", False, \"\", \"\", \"\"],\n          [10, \"choice\", \"Choice\", False, \"\", \"\", '{\"choices\": [\"a\", \"b\", \"c\"]}'],\n          [11, \"choicelist\", \"ChoiceList\", False, \"\", \"\", '{\"choices\": [\"x\", \"y\", \"z\"]}'],\n          [12, \"ref_formula\", \"Any\", True, \"$ref or None\", \"\", \"\"],\n          [13, \"numeric_formula\", \"Any\", True, \"1 / $numeric\", \"\", \"\"],\n          [14, \"new_formula\", \"Numeric\", True, \"'to be generated...'\", \"\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"Table2\": [\n          [\"id\", \"numeric\", \"ref\"],\n          [1, 0, 0],\n          [2, 1, 1],\n        ],\n      },\n    })\n    self.load_sample(sample)\n\n    self.assert_column_type(\"text\", \"str\")\n    self.assert_column_type(\"numeric\", \"float\")\n    self.assert_column_type(\"int\", \"int\")\n    self.assert_column_type(\"bool\", \"bool\")\n    self.assert_column_type(\"date\", \"datetime.date\")\n    self.assert_column_type(\"datetime\", \"datetime.datetime\")\n    self.assert_column_type(\"attachments\", \"Any\")\n    self.assert_column_type(\"ref\", \"Table2\")\n    self.assert_column_type(\"reflist\", \"list[Table2]\")\n    self.assert_column_type(\"choice\", \"Literal['a', 'b', 'c']\")\n    self.assert_column_type(\"choicelist\", \"tuple[Literal['x', 'y', 'z'], ...]\")\n    self.assert_column_type(\"ref_formula\", \"Optional[Table2]\")\n    self.assert_column_type(\"numeric_formula\", \"float\")\n\n    self.assertEqual(referenced_tables(self.engine, \"Table2\"), set())\n\n    self.assert_prompt(\"Table2\", \"new_formula\",\n      '''\\\nclass Table2:\n    text: str\n    numeric: float\n    int: int\n    bool: bool\n    date: datetime.date\n    datetime: datetime.datetime\n    attachments: Any\n    ref: Table2\n    reflist: list[Table2]\n    choice: Literal['a', 'b', 'c']\n    choicelist: tuple[Literal['x', 'y', 'z'], ...]\n    ref_formula: Optional[Table2]\n    numeric_formula: float\n\ndef new_formula(rec: Table2) -> float:\n''')\n\n  def test_get_formula_prompt(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [1, \"text\", \"Text\", False, \"\", \"\", \"\"],\n        ]],\n        [2, \"Table2\", [\n          [2, \"ref\", \"Ref:Table1\", False, \"\", \"\", \"\"],\n        ]],\n        [3, \"Table3\", [\n          [3, \"reflist\", \"RefList:Table2\", False, \"\", \"\", \"\"],\n        ]],\n      ],\n      \"DATA\": {},\n    })\n    self.load_sample(sample)\n    self.assertEqual(referenced_tables(self.engine, \"Table3\"), {\"Table1\", \"Table2\"})\n    self.assertEqual(referenced_tables(self.engine, \"Table2\"), {\"Table1\"})\n    self.assertEqual(referenced_tables(self.engine, \"Table1\"), set())\n\n    self.assert_prompt(\"Table1\", \"text\", '''\\\nclass Table1:\n\ndef text(rec: Table1) -> str:\n''')\n\n    # Test the same thing but include the lookup methods as in a real case,\n    # just to show that the table class would never actually be empty\n    # (which would be invalid Python and might confuse the model).\n    self.assert_prompt(\"Table1\", \"text\", \"\"\"\\\nclass Table1:\n    def __len__(self):\n        return len(Table1.lookupRecords())\n    @staticmethod\n    def lookupRecords(sort_by=None) -> list[Table1]:\n       ...\n    @staticmethod\n    def lookupOne(sort_by=None) -> Table1:\n       '''\n       Filter for one result matching the keys provided.\n       To control order, use e.g. `sort_by='Key' or `sort_by='-Key'`.\n       '''\n       return Table1.lookupRecords(sort_by=sort_by)[0]\n\n\ndef text(rec: Table1) -> str:\n\"\"\", lookups=True)\n\n    self.assert_prompt(\"Table2\", \"ref\", '''\\\nclass Table1:\n    text: str\n\nclass Table2:\n\ndef ref(rec: Table2) -> Table1:\n''')\n\n    self.assert_prompt(\"Table3\", \"reflist\", '''\\\nclass Table1:\n    text: str\n\nclass Table2:\n    ref: Table1\n\nclass Table3:\n\ndef reflist(rec: Table3) -> list[Table2]:\n''')\n\n  @unittest.skipUnless(fstring_positions_work(), \"Needs Python 3.10+\")\n  def test_convert_completion(self):\n    completion = \"\"\"\nHere's some code:\n\n```python\nimport os\nfrom x import (\n  y,\n  z,\n)\n\nclass Foo:\n    bar: Bar\n\n@property\ndef foo(rec):\n    '''This is a docstring'''\n    x = f\"hello {rec.name} \" + rec.name + \"!\"\n    if rec.bar.spam:\n      return 0\n    return rec.a * rec.b\n```\n\nHope you like it!\n\"\"\"\n    self.assertEqual(convert_completion(completion), \"\"\"\\\nimport os\nfrom x import (\n  y,\n  z,\n)\n\nx = f\"hello {$name} \" + $name + \"!\"\nif $bar.spam:\n  return 0\n$a * $b\"\"\")\n"
  },
  {
    "path": "sandbox/grist/test_formula_undo.py",
    "content": "# pylint: disable=line-too-long\nimport testsamples\nimport test_engine\nfrom objtypes import RecordSetStub\n\n\nclass TestFormulaUndo(test_engine.EngineTestCase):\n  def setUp(self):\n    super(TestFormulaUndo, self).setUp()\n\n  def test_change_and_undo(self):\n    self.load_sample(testsamples.sample_students)\n\n    # Test that regular lookup results behave well on undo.\n    self.apply_user_action(['ModifyColumn', 'Students', 'schoolCities', {\n      \"type\": \"Any\",\n      \"formula\": \"Schools.lookupRecords(name=$schoolName)\"\n    }])\n\n    # Add a formula that produces different results on different invocations. This is\n    # similar to some realistic scenarious (such as returning a time, a rich python object, or a\n    # string like \"<Foo at 0xfe65d350>\"), but is convoluted to keep values deterministic.\n    self.apply_user_action(['AddColumn', 'Students', 'counter', {\n      \"formula\": \"\"\"\ntable.my_counter = getattr(table, 'my_counter', 0) + 1\nreturn '#%s %s' % (table.my_counter, $schoolName)\n\"\"\"\n    }])\n\n    self.assertTableData(\"Students\", cols=\"subset\", data=[\n      [\"id\", \"schoolName\", \"schoolCities\",                   \"counter\"     ],\n      [1,    \"Columbia\",   RecordSetStub(\"Schools\", [1, 2]), \"#1 Columbia\",],\n      [2,    \"Yale\",       RecordSetStub(\"Schools\", [3, 4]), \"#2 Yale\",    ],\n      [3,    \"Columbia\",   RecordSetStub(\"Schools\", [1, 2]), \"#3 Columbia\",],\n      [4,    \"Yale\",       RecordSetStub(\"Schools\", [3, 4]), \"#4 Yale\",    ],\n      [5,    \"Eureka\",     RecordSetStub(\"Schools\", []),     \"#5 Eureka\",  ],\n      [6,    \"Yale\",       RecordSetStub(\"Schools\", [3, 4]), \"#6 Yale\",    ],\n    ])\n\n    # Applying an action produces expected changes to all formula columns, and corresponding undos.\n    out_actions = self.apply_user_action(['UpdateRecord', 'Students', 6, {\"schoolName\": \"Columbia\"}])\n    self.assertOutActions(out_actions, {\n      \"stored\": [\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolName\": \"Columbia\"}],\n        [\"UpdateRecord\", \"Students\", 6, {\"counter\": \"#7 Columbia\"}],\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolCities\": [\"r\", \"Schools\", [1, 2]]}],\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolIds\": \"1:2\"}],\n      ],\n      \"direct\": [True, False, False, False],\n      \"undo\": [\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolName\": \"Yale\"}],\n        [\"UpdateRecord\", \"Students\", 6, {\"counter\": \"#6 Yale\"}],\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolCities\": [\"r\", \"Schools\", [3, 4]]}],\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolIds\": \"3:4\"}],\n      ],\n    })\n\n    # Applying the undo actions (which include calculated values) will trigger recalculations, but\n    # they should not produce extraneous actions even when the calculation results differ.\n    out_actions = self.apply_user_action(['ApplyUndoActions', out_actions.get_repr()[\"undo\"]])\n\n    # TODO Note the double update when applying undo to non-deterministic formula. It would be\n    # nice to fix, but requires further refactoring (perhaps moving towards processing actions\n    # using summaries).\n    self.assertOutActions(out_actions, {\n      \"stored\": [\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolIds\": \"3:4\"}],\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolCities\": [\"r\", \"Schools\", [3, 4]]}],\n        [\"UpdateRecord\", \"Students\", 6, {\"counter\": \"#6 Yale\"}],\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolName\": \"Yale\"}],\n        [\"UpdateRecord\", \"Students\", 6, {\"counter\": \"#8 Yale\"}],\n      ],\n      \"direct\": [True, True, True, True, False],  # undos currently fully direct; formula update is indirect.\n      \"undo\": [\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolIds\": \"1:2\"}],\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolCities\": [\"r\", \"Schools\", [1, 2]]}],\n        [\"UpdateRecord\", \"Students\", 6, {\"counter\": \"#7 Columbia\"}],\n        [\"UpdateRecord\", \"Students\", 6, {\"schoolName\": \"Columbia\"}],\n        [\"UpdateRecord\", \"Students\", 6, {\"counter\": \"#6 Yale\"}],\n      ],\n    })\n\n    self.assertTableData(\"Students\", cols=\"subset\", data=[\n      [\"id\", \"schoolName\", \"schoolCities\",                    \"counter\" ],\n      [1,    \"Columbia\",   RecordSetStub(\"Schools\", [1, 2]),  \"#1 Columbia\"],\n      [2,    \"Yale\",       RecordSetStub(\"Schools\", [3, 4]),  \"#2 Yale\",   ],\n      [3,    \"Columbia\",   RecordSetStub(\"Schools\", [1, 2]),  \"#3 Columbia\"],\n      [4,    \"Yale\",       RecordSetStub(\"Schools\", [3, 4]),  \"#4 Yale\",   ],\n      [5,    \"Eureka\",     RecordSetStub(\"Schools\", []),      \"#5 Eureka\", ],\n\n      # This counter got updated\n      [6,    \"Yale\",       RecordSetStub(\"Schools\", [3, 4]),  \"#8 Yale\",   ],\n    ])\n\n  def test_save_to_empty_column(self):\n    # When we enter data into an empty column, it gets turned from a formula into a data column.\n    # Check that this operation works.\n    self.load_sample(testsamples.sample_students)\n    self.apply_user_action(['AddColumn', 'Students', 'newCol', {\"isFormula\": True}])\n\n    out_actions = self.apply_user_action(['UpdateRecord', 'Students', 6, {\"newCol\": \"Boo!\"}])\n    self.assertTableData(\"Students\", cols=\"subset\", data=[\n      [\"id\", \"schoolName\", \"newCol\" ],\n      [1,    \"Columbia\",   \"\"       ],\n      [2,    \"Yale\",       \"\"       ],\n      [3,    \"Columbia\",   \"\"       ],\n      [4,    \"Yale\",       \"\"       ],\n      [5,    \"Eureka\",     \"\"       ],\n      [6,    \"Yale\",       \"Boo!\"   ],\n    ])\n\n    # Check that the actions look reasonable.\n    self.assertOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Students\", \"newCol\", {\"type\": \"Text\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Text\"}],\n        [\"ModifyColumn\", \"Students\", \"newCol\", {\"isFormula\": False}],\n        [\"BulkUpdateRecord\", \"Students\", [1,2,3,4,5,6], {\"newCol\": [\"\", \"\", \"\", \"\", \"\", \"\"]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"isFormula\": False}],\n        [\"UpdateRecord\", \"Students\", 6, {\"newCol\": \"Boo!\"}],\n      ],\n      \"direct\": [False, False, False, False, False, True],\n      \"undo\": [\n        [\"ModifyColumn\", \"Students\", \"newCol\", {\"type\": \"Any\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Any\"}],\n        [\"BulkUpdateRecord\", \"Students\", [1,2,3,4,5,6], {\"newCol\": [None, None, None, None, None, None]}],\n        [\"ModifyColumn\", \"Students\", \"newCol\", {\"isFormula\": True}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"isFormula\": True}],\n        [\"UpdateRecord\", \"Students\", 6, {\"newCol\": \"\"}],\n      ]\n    })\n\n    out_actions = self.apply_user_action(['ApplyUndoActions', out_actions.get_repr()[\"undo\"]])\n    self.assertTableData(\"Students\", cols=\"subset\", data=[\n      [\"id\", \"schoolName\", \"newCol\" ],\n      [1,    \"Columbia\",   None ],\n      [2,    \"Yale\",       None ],\n      [3,    \"Columbia\",   None ],\n      [4,    \"Yale\",       None ],\n      [5,    \"Eureka\",     None ],\n      [6,    \"Yale\",       None ],\n    ])\n\n    # Check that undo actions are a reversal of the above, without any surprises.\n    self.assertOutActions(out_actions, {\n      \"stored\": [\n        [\"UpdateRecord\", \"Students\", 6, {\"newCol\": \"\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"isFormula\": True}],\n        [\"ModifyColumn\", \"Students\", \"newCol\", {\"isFormula\": True}],\n        [\"BulkUpdateRecord\", \"Students\", [1,2,3,4,5,6], {\"newCol\": [None, None, None, None, None, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Any\"}],\n        [\"ModifyColumn\", \"Students\", \"newCol\", {\"type\": \"Any\"}],\n      ],\n      \"direct\": [True, True, True, True, True, True],  # undos are currently fully direct.\n      \"undo\": [\n        [\"UpdateRecord\", \"Students\", 6, {\"newCol\": \"Boo!\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"isFormula\": False}],\n        [\"ModifyColumn\", \"Students\", \"newCol\", {\"isFormula\": False}],\n        [\"BulkUpdateRecord\", \"Students\", [1,2,3,4,5,6], {\"newCol\": [\"\", \"\", \"\", \"\", \"\", \"\"]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Text\"}],\n        [\"ModifyColumn\", \"Students\", \"newCol\", {\"type\": \"Text\"}],\n      ]\n    })\n"
  },
  {
    "path": "sandbox/grist/test_functions.py",
    "content": "import doctest\nimport os\nimport random\nimport re\nimport unittest\nimport functions\nimport moment\n\n_old_date_get_global_tz = None\n\ndef date_setUp(doc_test):\n  # pylint: disable=unused-argument\n  global _old_date_get_global_tz # pylint: disable=global-statement\n  _old_date_get_global_tz = functions.date._get_global_tz\n  functions.date._get_global_tz = lambda: moment.tzinfo('America/New_York')\n\ndef date_tearDown(doc_test):\n  # pylint: disable=unused-argument\n  functions.date._get_global_tz = _old_date_get_global_tz\n\nclass Py23DocChecker(doctest.OutputChecker):\n  def check_output(self, want, got, optionflags):\n    want = re.sub(r\"^u'(.*?)'$\", r\"'\\1'\", want)\n    want = re.sub(r'^u\"(.*?)\"$', r'\"\\1\"', want)\n    return doctest.OutputChecker.check_output(self, want, got, optionflags)\n\n# This works with the unittest module to turn all the doctests in the functions' doc-comments into\n# unittest test cases.\ndef load_tests(loader, tests, ignore):\n  # Set DOC_URL for SELF_HYPERLINK()\n  os.environ['DOC_URL'] = 'https://docs.getgrist.com/sbaltsirg/Example'\n  tests.addTests(doctest.DocTestSuite(functions.date, setUp = date_setUp, tearDown = date_tearDown))\n  tests.addTests(doctest.DocTestSuite(functions.info, setUp = date_setUp, tearDown = date_tearDown))\n  tests.addTests(doctest.DocTestSuite(functions.logical))\n  tests.addTests(doctest.DocTestSuite(functions.math))\n  tests.addTests(doctest.DocTestSuite(functions.stats))\n  tests.addTests(doctest.DocTestSuite(functions.text, checker=Py23DocChecker()))\n  tests.addTests(doctest.DocTestSuite(functions.schedule,\n                                      setUp = date_setUp, tearDown = date_tearDown))\n  tests.addTests(doctest.DocTestSuite(functions.lookup, checker=Py23DocChecker()))\n  return tests\n\n\nclass TestUuid(unittest.TestCase):\n  def check_uuids(self, expected_unique):\n    uuids = set()\n    for _ in range(100):\n      random.seed(0)  # should make only 'fallback' UUIDs all the same\n      uuids.add(functions.UUID())\n\n    self.assertEqual(len(uuids), expected_unique)\n    for uid in uuids:\n      match = re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', uid)\n      self.assertIsNotNone(match, uid)\n\n  def test_standard_uuid(self):\n    # Test that uuid.uuid4() is used correctly.\n    # uuid.uuid4() shouldn't be affected by random.seed().\n    # Depending on the test environment, uuid.uuid4() may or may not actually be available.\n    try:\n      os.urandom(1)\n    except NotImplementedError:\n      expected_unique = 1\n    else:\n      expected_unique = 100\n\n    self.check_uuids(expected_unique)\n\n  def test_fallback_uuid(self):\n    # Test that our custom implementation with the `random` module works\n    # and is used when uuid.uuid4() is not available.\n    import uuid\n    v4 = uuid.uuid4\n    del uuid.uuid4\n    try:\n      self.check_uuids(1)  # because of the `random.seed(0)` in `check_uuids()`\n    finally:\n      uuid.uuid4 = v4\n\n\nclass TestChain(unittest.TestCase):\n  def test_chain_type_error(self):\n    with self.assertRaises(TypeError):\n      functions.SUM(x / \"2\" for x in [1, 2, 3])\n"
  },
  {
    "path": "sandbox/grist/test_gencode.py",
    "content": "import ast\n\nimport unittest\nimport difflib\nimport re\nimport gencode\nimport identifiers\nimport schema\nimport table\nimport testutil\n\nschema_data = [\n  [1, \"Students\", [\n    [1, \"firstName\",   \"Text\",        False, '', \"firstName\", ''],\n    [2, \"lastName\",    \"Text\",        False, '', \"lastName\", ''],\n    [3, \"fullName\",    \"Any\",         True,\n      \"rec.firstName + ' ' + rec.lastName\", \"fullName\", ''],\n    [4, \"fullNameLen\", \"Any\",         True, \"len(rec.fullName)\", \"fullNameLen\", ''],\n    [5, \"school\",      \"Ref:Schools\", False, '', \"school\", ''],\n    [6, \"schoolShort\",  \"Any\",        True, \"rec.school.name.split(' ')[0]\", \"schoolShort\", ''],\n    [9, \"schoolRegion\", \"Any\",        True,\n      \"addr = $school.address\\naddr.state if addr.country == 'US' else addr.region\",\n      \"schoolRegion\", ''],\n    [8, \"school2\",     \"Ref:Schools\", True, \"Schools.lookupFirst(name=rec.school.name)\", \"\", \"\"]\n  ]],\n  [2, \"Schools\", [\n    [10, \"name\",        \"Text\",       False, '', \"name\", ''],\n    [12, \"address\",     \"Ref:Address\",False, '', \"address\", '']\n  ]],\n  [3, \"Address\", [\n    [21, \"city\",        \"Text\",       False, '', \"city\", ''],\n    [27, \"state\",       \"Text\",       False, '', \"state\", ''],\n    [28, \"country\",     \"Text\",       False, \"'US'\", \"country\", ''],\n    [29, \"region\",      \"Any\",        True,\n          \"{'US': 'North America', 'UK': 'Europe'}.get(rec.country, 'N/A')\", \"region\", ''],\n    [30, \"badSyntax\",   \"Any\",        True, \"for a in\\n10\", \"\", \"\"],\n  ]]\n]\n\nclass TestGenCode(unittest.TestCase):\n  def setUp(self):\n    # Convert the meta tables to appropriate table representations for loading.\n    meta_tables = testutil.table_data_from_rows(\n      '_grist_Tables',\n      (\"id\", \"tableId\"),\n      [(table_row_id, table_id) for (table_row_id, table_id, _) in schema_data])\n\n    meta_columns = testutil.table_data_from_rows(\n      '_grist_Tables_column',\n      (\"parentId\", \"parentPos\", \"id\", \"colId\", \"type\",\n        \"isFormula\", \"formula\", \"label\", \"widgetOptions\"),\n      [[table_row_id, i] + e for (table_row_id, _, entries) in schema_data\n       for (i, e) in enumerate(entries)])\n\n    self.schema = schema.build_schema(meta_tables, meta_columns, include_builtin=False)\n\n  def test_make_module_text(self):\n    \"\"\"\n    Test that make_module_text produces the exact sample output that we have stored\n    in the docstring of usercode.py.\n    \"\"\"\n    import usercode\n    usercode_sample_re = re.compile(r'^==========*\\n', re.M)\n    saved_sample = usercode_sample_re.split(usercode.__doc__)[1]\n\n    gcode = gencode.GenCode()\n    gcode.make_module(self.schema)\n    generated = gcode.get_user_text()\n    saved_sample = saved_sample.replace(\n        \"raise SyntaxError('invalid syntax', ('usercode', 1, 9, u'for a in'))\",\n        \"raise SyntaxError('invalid syntax\\\\n\\\\n\"\n        \"A `SyntaxError` occurs when Python cannot understand your code.\\\\n\\\\n', \"\n        \"('usercode', 1, 9, 'for a in'))\"\n    )\n    self.assertEqual(generated, saved_sample, \"Generated code doesn't match sample:\\n\" +\n                     \"\".join(difflib.unified_diff(generated.splitlines(True),\n                                                  saved_sample.splitlines(True),\n                                                  fromfile=\"generated\",\n                                                  tofile=\"usercode.py\")))\n\n  def test_make_module(self):\n    \"\"\"\n    Test that the generated module has the classes and nested classes we expect.\n    \"\"\"\n    gcode = gencode.GenCode()\n    gcode.make_module(self.schema)\n    module = gcode.usercode\n    # pylint: disable=E1101\n    self.assertTrue(isinstance(module.Students, table.UserTable))\n\n  def test_multiline_string_indent(self):\n    \"\"\"\n    Test that multiline strings don't get affected by formula indentations.\n    \"\"\"\n    def get_built_formula(formula):\n      # We rebuild the entire doc with the given formula (for the \"Students.fullName\" column), and\n      # return the AST node for the formula's function. This goes through the entire module\n      # compilation, so includes all formula transformations including indentation changes.\n      updated_schema = self.schema.copy()\n      updated_columns = updated_schema['Students'].columns.copy()\n      updated_columns['fullName'] = updated_columns['fullName']._replace(formula=formula)\n      updated_schema['Students'] = updated_schema['Students']._replace(columns=updated_columns)\n\n      gcode = gencode.GenCode()\n      gcode.make_module(updated_schema)\n      # Find the ast node for the \"fullName\" function which has our formula.\n      tree = ast.parse(gcode.get_user_text())\n      for node in ast.walk(tree):\n        if isinstance(node, ast.FunctionDef) and node.name == 'fullName':\n          return node\n      return None\n\n    def assert_correct_formula(formula, expected_compiled_formula):\n      actual_formula_body = get_built_formula(formula).body\n      expected_formula_body = ast.parse(expected_compiled_formula).body\n      self.assertEqual(\n          [ast.dump(n, indent=2) for n in actual_formula_body],\n          [ast.dump(n, indent=2) for n in expected_formula_body])\n\n    # The original simple one-line formula.\n    assert_correct_formula(\n        \"rec.firstName + ' ' + rec.lastName\",\n        \"return rec.firstName + ' ' + rec.lastName\")\n\n    # Same, but with $. We are just testing normal behavior, and that test checks work.\n    assert_correct_formula(\n        \"$firstName + ' ' + $lastName\",\n        \"return rec.firstName + ' ' + rec.lastName\")\n\n    # Try a formulas with multiple lines and indents.\n    assert_correct_formula(\n        \"if rec.a:\\n  return 1\\nelse:\\n  return 2\\nreturn unreachable\",\n        \"if rec.a:\\n  return 1\\nelse:\\n  return 2\\nreturn unreachable\")\n\n    # Test multiline strings\n    assert_correct_formula(\n        \"$firstName\\n'''\\nMultiline\\n''' + 'Not\\\\nactually\\\\nmultiline'\",\n        \"rec.firstName\\nreturn '''\\nMultiline\\n''' + 'Not\\\\nactually\\\\nmultiline'\")\n    assert_correct_formula(\n        \"a = '''\\nMultiline\\n'''\\nreturn a\",\n        \"a = '''\\nMultiline\\n'''\\nreturn a\")\n    assert_correct_formula(\n        \"r'''    foo\\nMultiline\\n\\n  bar''' + 'Not\\\\nactually\\\\nmultiline'\",\n        \"return r'''    foo\\nMultiline\\n\\n  bar''' + 'Not\\\\nactually\\\\nmultiline'\")\n    # Multi-line f-string.\n    assert_correct_formula(\n        \"rf'''\\nMulti{\\n1 + $fullNameLen}line\\n''' + 'Not\\\\nactually\\\\nmultiline'\",\n        \"return rf'''\\nMulti{\\n1 + rec.fullNameLen}line\\n''' + 'Not\\\\nactually\\\\nmultiline'\")\n\n\n  def test_ident_combining_chars(self):\n    def check(label, ident):\n      self.assertEqual(ident, identifiers.pick_table_ident(label))\n      self.assertEqual(ident, identifiers.pick_col_ident(label))\n      self.assertEqual(ident.lower(), identifiers.pick_col_ident(label.lower()))\n\n    # Actual example table name from a user\n    # unicodedata.normalize can separate accents but doesn't help with Đ\n    check(\n      u\"Bảng_Đặc_Thù\",\n      u\"Bang__ac_Thu\",\n    )\n\n    check(\n      u\"Noëlle\",\n      u\"Noelle\",\n    )\n    check(\n      u\"Séamus\",\n      u\"Seamus\",\n    )\n    check(\n      u\"Hélène\",\n      u\"Helene\",\n    )\n    check(\n      u\"Dilâçar\",\n      u\"Dilacar\",\n    )\n    check(\n      u\"Erdoğan\",\n      u\"Erdogan\",\n    )\n    check(\n      u\"Ñwalme\",\n      u\"Nwalme\",\n    )\n    check(\n      u\"Árvíztűrő tükörfúrógép\",\n      u\"Arvizturo_tukorfurogep\",\n    )\n\n  def test_pick_col_ident(self):\n    self.assertEqual(identifiers.pick_col_ident(\"asdf\"), \"asdf\")\n    self.assertEqual(identifiers.pick_col_ident(\" a s==d!~@#$%^f\"), \"a_s_d_f\")\n    self.assertEqual(identifiers.pick_col_ident(\"123asdf\"), \"c123asdf\")\n    self.assertEqual(identifiers.pick_col_ident(\"!@#\"), \"A\")\n    self.assertEqual(identifiers.pick_col_ident(\"!@#1\"), \"c1\")\n    self.assertEqual(identifiers.pick_col_ident(\"heLLO world\"), \"heLLO_world\")\n    self.assertEqual(identifiers.pick_col_ident(\"!@#\", avoid={\"A\"}), \"B\")\n\n    self.assertEqual(identifiers.pick_col_ident(\"foo\", avoid={\"bar\"}), \"foo\")\n    self.assertEqual(identifiers.pick_col_ident(\"foo\", avoid={\"foo\"}), \"foo2\")\n    self.assertEqual(identifiers.pick_col_ident(\"foo\", avoid={\"foo\", \"foo2\", \"foo3\"}), \"foo4\")\n    self.assertEqual(identifiers.pick_col_ident(\"foo1\", avoid={\"foo1\", \"foo2\", \"foo1_2\"}), \"foo1_3\")\n    self.assertEqual(identifiers.pick_col_ident(\"\"), \"A\")\n    self.assertEqual(identifiers.pick_table_ident(\"\"), \"Table1\")\n    self.assertEqual(identifiers.pick_col_ident(\"\", avoid={\"A\"}), \"B\")\n    self.assertEqual(identifiers.pick_col_ident(\"\", avoid={\"A\",\"B\"}), \"C\")\n    self.assertEqual(identifiers.pick_col_ident(None, avoid={\"A\",\"B\"}), \"C\")\n    self.assertEqual(identifiers.pick_col_ident(\"\", avoid={'a','b','c','d','E'}), 'F')\n    self.assertEqual(identifiers.pick_col_ident(2, avoid={\"c2\"}), \"c2_2\")\n\n    large_set = set()\n    for i in range(730):\n      large_set.add(identifiers._gen_ident(large_set))\n    self.assertEqual(identifiers.pick_col_ident(\"\", avoid=large_set), \"ABC\")\n\n  def test_pick_table_ident(self):\n    self.assertEqual(identifiers.pick_table_ident(\"123asdf\"), \"T123asdf\")\n    self.assertEqual(identifiers.pick_table_ident(\"!@#\"), \"Table1\")\n    self.assertEqual(identifiers.pick_table_ident(\"!@#1\"), \"T1\")\n\n    self.assertEqual(identifiers.pick_table_ident(\"heLLO world\"), \"HeLLO_world\")\n    self.assertEqual(identifiers.pick_table_ident(\"foo\", avoid={\"Foo\"}), \"Foo2\")\n    self.assertEqual(identifiers.pick_table_ident(\"foo\", avoid={\"Foo\", \"Foo2\"}), \"Foo3\")\n    self.assertEqual(identifiers.pick_table_ident(\"FOO\", avoid={\"foo\", \"foo2\"}), \"FOO3\")\n\n    self.assertEqual(identifiers.pick_table_ident(None, avoid={\"Table\"}), \"Table1\")\n    self.assertEqual(identifiers.pick_table_ident(None, avoid={\"Table1\"}), \"Table2\")\n    self.assertEqual(identifiers.pick_table_ident(\"!@#\", avoid={\"Table1\"}), \"Table2\")\n    self.assertEqual(identifiers.pick_table_ident(None, avoid={\"Table1\", \"Table2\"}), \"Table3\")\n\n    large_set = set()\n    for i in range(730):\n      large_set.add(\"Table%d\" % i)\n    self.assertEqual(identifiers.pick_table_ident(\"\", avoid=large_set), \"Table730\")\n\n  def test_pick_col_ident_list(self):\n    self.assertEqual(identifiers.pick_col_ident_list([\"foo\", \"bar\"], avoid={\"bar\"}),\n                     [\"foo\", \"bar2\"])\n    self.assertEqual(identifiers.pick_col_ident_list([\"bar\", \"bar\"], avoid={\"foo\"}),\n                     [\"bar\", \"bar2\"])\n    self.assertEqual(identifiers.pick_col_ident_list([\"bar\", \"bar\"], avoid={\"bar\"}),\n                     [\"bar2\", \"bar3\"])\n    self.assertEqual(identifiers.pick_col_ident_list([\"bAr\", \"BAR\"], avoid={\"bar\"}),\n                     [\"bAr2\", \"BAR3\"])\n\n  def test_gen_ident(self):\n    self.assertEqual(identifiers._gen_ident(set()), 'A')\n    self.assertEqual(identifiers._gen_ident({'A'}), 'B')\n    self.assertEqual(identifiers._gen_ident({'foo','E','F','H'}), 'A')\n    self.assertEqual(identifiers._gen_ident({'a','b','c','d','E'}), 'F')\n\n  def test_get_grist_type(self):\n    self.assertEqual(gencode.get_grist_type(\"Ref:Foo\"), \"grist.Reference('Foo')\")\n    self.assertEqual(gencode.get_grist_type(\"RefList:Foo\"), \"grist.ReferenceList('Foo')\")\n    self.assertEqual(gencode.get_grist_type(\"Int\"), \"grist.Int()\")\n    self.assertEqual(gencode.get_grist_type(\"DateTime:America/NewYork\"),\n                     \"grist.DateTime('America/NewYork')\")\n    self.assertEqual(gencode.get_grist_type(\"DateTime:\"), \"grist.DateTime()\")\n    self.assertEqual(gencode.get_grist_type(\"DateTime\"), \"grist.DateTime()\")\n    self.assertEqual(gencode.get_grist_type(\"DateTime: foo bar \"), \"grist.DateTime('foo bar')\")\n    self.assertEqual(gencode.get_grist_type(\"DateTime: \"), \"grist.DateTime()\")\n    self.assertEqual(gencode.get_grist_type(\"RefList:\\n ~!@#$%^&*'\\\":;,\\t\"),\n                     \"grist.ReferenceList('~!@#$%^&*\\\\'\\\":;,')\")\n\n  def test_grist_names(self):\n    # Verifies that we can correctly extract the names of Grist objects that occur in formulas.\n    # This is used by automatic formula adjustments when columns or tables get renamed.\n    gcode = gencode.GenCode()\n    gcode.make_module(self.schema)\n    # The output of grist_names is described in codebuilder.py, and copied here:\n    # col_info:   (table_id, col_id) for the formula the name is found in. It is the value passed\n    #             in by gencode.py to codebuilder.make_formula_body().\n    # start_pos:  Index of the start character of the name in the text of the formula.\n    # table_id:   Parsed name when the tuple is for a table name; the name of the column's table\n    #             when the tuple is for a column name.\n    # col_id:     None when tuple is for a table name; col_id when the tuple is for a column name.\n    expected_names = [\n      (('Address', 'region'), 48, 'Address', 'country'),\n      (('Students', 'fullName'), 4, 'Students', 'firstName'),\n      (('Students', 'fullName'), 26, 'Students', 'lastName'),\n      (('Students', 'fullNameLen'), 8, 'Students', 'fullName'),\n      (('Students', 'schoolShort'), 11, 'Schools', 'name'),\n      (('Students', 'schoolShort'), 4, 'Students', 'school'),\n      (('Students', 'schoolRegion'), 15, 'Schools', 'address'),\n      (('Students', 'schoolRegion'), 8, 'Students', 'school'),\n      (('Students', 'schoolRegion'), 42, 'Address', 'country'),\n      (('Students', 'schoolRegion'), 28, 'Address', 'state'),\n      (('Students', 'schoolRegion'), 68, 'Address', 'region'),\n      (('Students', 'school2'), 0, 'Schools', None),\n      (('Students', 'school2'), 36, 'Schools', 'name'),\n      (('Students', 'school2'), 29, 'Students', 'school'),\n    ]\n    self.assertEqual(gcode.grist_names(), expected_names)\n\n    # Test the case of a bare-word function with a keyword argument appearing in a formula. This\n    # case had a bug with code parsing.\n    self.schema['Address'].columns['testcol'] = schema.SchemaColumn(\n      'testcol', 'Any', True, 'foo(bar=$region) or max(Students.all, key=lambda n: -n)', None)\n    gcode.make_module(self.schema)\n    self.assertEqual(gcode.grist_names(), [expected_names[0]] + [\n      (('Address', 'testcol'), 9, 'Address', 'region'),\n      (('Address', 'testcol'), 24, 'Students', None),\n    ] + expected_names[1:])\n\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_import_actions.py",
    "content": "# pylint: disable=line-too-long\nimport logging\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\n\nclass TestImportActions(test_engine.EngineTestCase):\n  def init_state(self):\n    # Add source table\n    self.apply_user_action(['AddTable', 'Source', [{'id': 'Name', 'type': 'Text'},\n                                                {'id': 'City', 'type': 'Text'},\n                                                {'id': 'Zip', 'type': 'Int'}]])\n    self.apply_user_action(['BulkAddRecord', 'Source', [1, 2], {'Name': ['John', 'Alison'],\n                                                                'City': ['New York', 'Boston'],\n                                                                'Zip': [3011, 7003]}])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",           \"isFormula\",  \"formula\"],\n      [1,     \"manualSort\", \"ManualSortPos\",  False,        \"\"],\n      [2,     \"Name\",       \"Text\",           False,        \"\"],\n      [3,     \"City\",       \"Text\",           False,        \"\"],\n      [4,     \"Zip\",        \"Int\",            False,        \"\"],\n    ], rows=lambda r: r.parentId.id == 1)\n\n    # Add destination table which contains columns corresponding to source table\n    self.apply_user_action(['AddTable', 'Destination1', [{'id': 'Name', 'type': 'Text'},\n                                                        {'id': 'City', 'type': 'Text'}]])\n    self.apply_user_action(['BulkAddRecord', 'Destination1', [1, 2], {'Name': ['Bob'],\n                                                                    'City': ['New York']}])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",           \"isFormula\",  \"formula\"],\n      [5,     \"manualSort\", \"ManualSortPos\",  False,        \"\"],\n      [6,     \"Name\",       \"Text\",           False,        \"\"],\n      [7,     \"City\",       \"Text\",           False,        \"\"],\n    ], rows=lambda r: r.parentId.id == 2)\n\n    # Add destination table which has no columns corresponding to source table\n    self.apply_user_action(['AddTable', 'Destination2', [{'id': 'State', 'type': 'Text'}]])\n    self.apply_user_action(['BulkAddRecord', 'Destination2', [1, 2], {'State': ['NY']}])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",           \"isFormula\",  \"formula\"],\n      [8,     \"manualSort\", \"ManualSortPos\",  False,        \"\"],\n      [9,     \"State\",      \"Text\",           False,        \"\"]\n    ], rows=lambda r: r.parentId.id == 3)\n\n    # Verify created tables\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"tableId\"], [\n      [1, \"Source\"],\n      [2, \"Destination1\"],\n      [3, \"Destination2\"],\n    ])\n\n    # Verify created sections\n    self.assertPartialData(\"_grist_Views_section\", [\"id\", \"tableRef\", 'fields'], [\n      [1, 1, [1, 2, 3]],  # section for \"Source\" table\n      [2, 1, [4, 5, 6]],  # section for \"Source\" table\n      [3, 1, [7, 8, 9]],  # section for \"Source\" table\n      [4, 2, [10, 11]],   # section for \"Destination1\" table\n      [5, 2, [12, 13]],   # section for \"Destination1\" table\n      [6, 2, [14, 15]],   # section for \"Destination1\" table\n      [7, 3, [16]],       # section for \"Destination2\" table\n      [8, 3, [17]],       # section for \"Destination2\" table\n      [9, 3, [18]],       # section for \"Destination2\" table\n    ])\n\n  def test_transform(self):\n    # Add source and destination tables\n    self.init_state()\n\n    # Update transform while importing to destination table which have\n    # columns with the same names as source\n    self.apply_user_action(['GenImporterView', 'Source', 'Destination1', None, {}])\n\n    # Verify the new structure of source table and sections\n    # (two columns with special names were added)\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",                    \"type\",           \"isFormula\",  \"formula\"],\n      [1,     \"manualSort\",               \"ManualSortPos\",  False,        \"\"],\n      [2,     \"Name\",                     \"Text\",           False,        \"\"],\n      [3,     \"City\",                     \"Text\",           False,        \"\"],\n      [4,     \"Zip\",                      \"Int\",            False,        \"\"],\n      [10,    \"gristHelper_Import_Name\",  \"Text\",           True,         \"$Name\"],\n      [11,    \"gristHelper_Import_City\",  \"Text\",           True,         \"$City\"],\n    ], rows=lambda r: r.parentId.id == 1)\n\n    self.assertTableData('Source', cols=\"all\", data=[\n      [\"id\",  \"Name\",   \"City\",     \"Zip\",  \"gristHelper_Import_Name\", \"gristHelper_Import_City\", \"manualSort\"],\n      [1,     \"John\",   \"New York\", 3011,  \"John\",                    \"New York\",                1.0],\n      [2,     \"Alison\", \"Boston\",   7003,  \"Alison\",                  \"Boston\",                  2.0],\n    ])\n\n    self.assertPartialData(\"_grist_Views_section\", [\"id\", \"tableRef\", 'fields'], [\n      [1, 1, [1, 2, 3]],\n      [2, 1, [4, 5, 6]],\n      [3, 1, [7, 8, 9]],\n      [4, 2, [10, 11]],\n      [5, 2, [12, 13]],\n      [6, 2, [14, 15]],\n      [7, 3, [16]],\n      [8, 3, [17]],\n      [9, 3, [18]],\n      [10, 1, [19, 20]],  # new section for transform preview\n    ])\n\n    # Apply useraction again to verify that old columns and sections are removing\n    # Update transform while importing to destination table which has no common columns with source\n    self.apply_user_action(['GenImporterView', 'Source', 'Destination2', None, {}])\n\n    # Verify the new structure of source table and sections (old special columns were removed\n    # and one new columns with empty formula were added)\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",                    \"type\",           \"isFormula\",  \"formula\"],\n      [1,     \"manualSort\",               \"ManualSortPos\",  False,        \"\"],\n      [2,     \"Name\",                     \"Text\",           False,        \"\"],\n      [3,     \"City\",                     \"Text\",           False,        \"\"],\n      [4,     \"Zip\",                      \"Int\",            False,        \"\"],\n      [10,    \"gristHelper_Import_State\", \"Text\",           True,         \"\"]\n    ], rows=lambda r: r.parentId.id == 1)\n\n    self.assertTableData('Source', cols=\"all\", data=[\n      [\"id\",  \"Name\",   \"City\",     \"Zip\",  \"gristHelper_Import_State\", \"manualSort\"],\n      [1,     \"John\",   \"New York\", 3011,  \"\",                         1.0],\n      [2,     \"Alison\", \"Boston\",   7003,  \"\",                         2.0],\n    ])\n    self.assertPartialData(\"_grist_Views_section\", [\"id\", \"tableRef\", 'fields'], [\n      [1,  1, [1, 2, 3]],\n      [2,  1, [4, 5, 6]],\n      [3,  1, [7, 8, 9]],\n      [4,  2, [10, 11]],\n      [5,  2, [12, 13]],\n      [6,  2, [14, 15]],\n      [7,  3, [16]],\n      [8,  3, [17]],\n      [9,  3, [18]],\n      [10, 1, [19]], # new section for transform preview\n    ])\n\n\n  def test_regenerate_importer_view(self):\n    # Generate without a destination table, and then with one. Ensure that we don't omit the\n    # actions needed to populate the table in the second call.\n    self.init_state()\n    self.apply_user_action(['GenImporterView', 'Source', None, None, {}])\n    out_actions = self.apply_user_action(['GenImporterView', 'Source', 'Destination1', None, {}])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [19, 20, 21]],\n        [\"RemoveRecord\", \"_grist_Views_section\", 10],\n        [\"BulkRemoveRecord\", \"_grist_Tables_column\", [10, 11, 12]],\n        [\"RemoveColumn\", \"Source\", \"gristHelper_Import_Name\"],\n        [\"RemoveColumn\", \"Source\", \"gristHelper_Import_City\"],\n        [\"RemoveColumn\", \"Source\", \"gristHelper_Import_Zip\"],\n        [\"AddColumn\", \"Source\", \"gristHelper_Import_Name\", {\"formula\": \"$Name\", \"isFormula\": True, \"type\": \"Text\"}],\n        [\"AddRecord\", \"_grist_Tables_column\", 10, {\"colId\": \"gristHelper_Import_Name\", \"formula\": \"$Name\", \"isFormula\": True, \"label\": \"Name\", \"parentId\": 1, \"parentPos\": 10.0, \"type\": \"Text\", \"widgetOptions\": \"\"}],\n        [\"AddColumn\", \"Source\", \"gristHelper_Import_City\", {\"formula\": \"$City\", \"isFormula\": True, \"type\": \"Text\"}],\n        [\"AddRecord\", \"_grist_Tables_column\", 11, {\"colId\": \"gristHelper_Import_City\", \"formula\": \"$City\", \"isFormula\": True, \"label\": \"City\", \"parentId\": 1, \"parentPos\": 11.0, \"type\": \"Text\", \"widgetOptions\": \"\"}],\n        [\"AddRecord\", \"_grist_Views_section\", 10, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"record\", \"sortColRefs\": \"[]\", \"tableRef\": 1}],\n        [\"BulkAddRecord\", \"_grist_Views_section_field\", [19, 20], {\"colRef\": [10, 11], \"parentId\": [10, 10], \"parentPos\": [19.0, 20.0]}],\n        # The actions to populate the removed and re-added columns should be there.\n        [\"BulkUpdateRecord\", \"Source\", [1, 2], {\"gristHelper_Import_City\": [\"New York\", \"Boston\"]}],\n        [\"BulkUpdateRecord\", \"Source\", [1, 2], {\"gristHelper_Import_Name\": [\"John\", \"Alison\"]}],\n      ],\n      \"calc\": []\n    })\n\n\n  def test_transform_destination_new_table(self):\n    # Add source and destination tables\n    self.init_state()\n\n    # Update transform while importing to destination table which is \"New Table\"\n    self.apply_user_action(['GenImporterView', 'Source', None, None, {}])\n\n    # Verify the new structure of source table and sections (old special columns were removed\n    # and three new columns, which are the same as in source table were added)\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",                    \"type\",           \"isFormula\",  \"formula\"],\n      [1,     \"manualSort\",               \"ManualSortPos\",  False,        \"\"],\n      [2,     \"Name\",                     \"Text\",           False,        \"\"],\n      [3,     \"City\",                     \"Text\",           False,        \"\"],\n      [4,     \"Zip\",                      \"Int\",            False,        \"\"],\n      [10,    \"gristHelper_Import_Name\",  \"Text\",           True,         \"$Name\"],\n      [11,    \"gristHelper_Import_City\",  \"Text\",           True,         \"$City\"],\n      [12,    \"gristHelper_Import_Zip\",   \"Int\",            True,         \"$Zip\"],\n    ], rows=lambda r: r.parentId.id == 1)\n\n    self.assertTableData('Source', cols=\"all\", data=[\n      [\"id\",  \"Name\",   \"City\",     \"Zip\",  \"gristHelper_Import_Name\", \"gristHelper_Import_City\", \"gristHelper_Import_Zip\", \"manualSort\"],\n      [1,     \"John\",   \"New York\", 3011,  \"John\",                    \"New York\",                3011,                    1.0],\n      [2,     \"Alison\", \"Boston\",   7003,  \"Alison\",                  \"Boston\",                  7003,                    2.0],\n    ])\n    self.assertPartialData(\"_grist_Views_section\", [\"id\", \"tableRef\", 'fields'], [\n      [1,  1, [1, 2, 3]],\n      [2,  1, [4, 5, 6]],\n      [3,  1, [7, 8, 9]],\n      [4,  2, [10, 11]],\n      [5,  2, [12, 13]],\n      [6,  2, [14, 15]],\n      [7,  3, [16]],\n      [8,  3, [17]],\n      [9,  3, [18]],\n      [10, 1, [19, 20, 21]],  # new section for transform preview\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_lookup_find.py",
    "content": "import datetime\nimport logging\nimport unittest\nimport moment\nimport objtypes\nimport testutil\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\ndef D(year, month, day):\n  return moment.date_to_ts(datetime.date(year, month, day))\n\nclass TestLookupFind(test_engine.EngineTestCase):\n\n  def do_setup(self):\n    self.load_sample(testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Customers\", [\n          [11, \"Name\", \"Text\", False, \"\", \"\", \"\"],\n          [12, \"MyDate\", \"Date\", False, \"\", \"\", \"\"],\n        ]],\n        [2, \"Purchases\", [\n          [20, \"manualSort\", \"PositionNumber\", False, \"\", \"\", \"\"],\n          [21, \"Customer\", \"Ref:Customers\", False, \"\", \"\", \"\"],\n          [22, \"Date\", \"Date\", False, \"\", \"\", \"\"],\n          [24, \"Category\", \"Text\", False, \"\", \"\", \"\"],\n          [25, \"Amount\", \"Numeric\", False, \"\", \"\", \"\"],\n          [26, \"Prev\", \"Ref:Purchases\", True, \"None\", \"\", \"\"],    # To be filled\n          [27, \"Cumul\", \"Numeric\", True, \"$Prev.Cumul + $Amount\", \"\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"Customers\": [\n          [\"id\", \"Name\",    \"MyDate\"],\n          [1,    \"Alice\",   D(2023,12,5)],\n          [2,    \"Bob\",     D(2023,12,10)],\n        ],\n        \"Purchases\": [\n          [ \"id\",   \"manualSort\", \"Customer\", \"Date\",       \"Category\", \"Amount\", ],\n          [1,       1.0,          1,          D(2023,12,1), \"A\",        10],\n          [2,       2.0,          2,          D(2023,12,4), \"A\",        17],\n          [3,       3.0,          1,          D(2023,12,3), \"A\",        20],\n          [4,       4.0,          1,          D(2023,12,9), \"A\",        40],\n          [5,       5.0,          1,          D(2023,12,2), \"B\",        80],\n          [6,       6.0,          1,          D(2023,12,6), \"B\",        160],\n          [7,       7.0,          1,          D(2023,12,7), \"A\",        320],\n          [8,       8.0,          1,          D(2023,12,5), \"A\",        640],\n        ],\n      }\n    }))\n\n  def do_test_lookup_find(self, find=\"find\", ref_type_to_use=None):\n    self.do_setup()\n\n    if ref_type_to_use:\n      self.add_column(\"Customers\", \"PurchasesByDate\", type=ref_type_to_use,\n          formula=\"Purchases.lookupRecords(Customer=$id, sort_by='Date')\")\n      lookup = \"$PurchasesByDate\"\n    else:\n      lookup = \"Purchases.lookupRecords(Customer=$id, sort_by='Date')\"\n\n    self.add_column(\"Customers\", \"LTDate\", type=\"Ref:Purchases\",\n        formula=\"{}.{}.lt($MyDate)\".format(lookup, find))\n    self.add_column(\"Customers\", \"LEDate\", type=\"Ref:Purchases\",\n        formula=\"{}.{}.le($MyDate)\".format(lookup, find))\n    self.add_column(\"Customers\", \"GTDate\", type=\"Ref:Purchases\",\n        formula=\"{}.{}.gt($MyDate)\".format(lookup, find))\n    self.add_column(\"Customers\", \"GEDate\", type=\"Ref:Purchases\",\n        formula=\"{}.{}.ge($MyDate)\".format(lookup, find))\n    self.add_column(\"Customers\", \"EQDate\", type=\"Ref:Purchases\",\n        formula=\"{}.{}.eq($MyDate)\".format(lookup, find))\n\n    # Here's the purchase data sorted by Customer and Date\n    # id      Customer      Date\n    # 1,       1,          D(2023,12,1)\n    # 5,       1,          D(2023,12,2)\n    # 3,       1,          D(2023,12,3)\n    # 8,       1,          D(2023,12,5)\n    # 6,       1,          D(2023,12,6)\n    # 7,       1,          D(2023,12,7)\n    # 4,       1,          D(2023,12,9)\n    # 2,       2,          D(2023,12,4)\n\n    # pylint: disable=line-too-long\n    self.assertTableData('Customers', cols=\"subset\", data=[\n      dict(id=1, Name=\"Alice\", MyDate=D(2023,12,5), LTDate=3, LEDate=8, GTDate=6, GEDate=8, EQDate=8),\n      dict(id=2, Name=\"Bob\", MyDate=D(2023,12,10), LTDate=2, LEDate=2, GTDate=0, GEDate=0, EQDate=0),\n    ])\n\n    # Change Dates for Alice and Bob\n    self.update_record('Customers', 1, MyDate=D(2023,12,4))\n    self.update_record('Customers', 2, MyDate=D(2023,12,4))\n    self.assertTableData('Customers', cols=\"subset\", data=[\n      dict(id=1, Name=\"Alice\", MyDate=D(2023,12,4), LTDate=3, LEDate=3, GTDate=8, GEDate=8, EQDate=0),\n      dict(id=2, Name=\"Bob\", MyDate=D(2023,12,4), LTDate=0, LEDate=2, GTDate=0, GEDate=2, EQDate=2),\n    ])\n\n    # Change a Purchase from Alice to Bob, and remove a purchase for Alice\n    self.update_record('Purchases', 5, Customer=2)\n    self.remove_record('Purchases', 3)\n    self.assertTableData('Customers', cols=\"subset\", data=[\n      dict(id=1, Name=\"Alice\", MyDate=D(2023,12,4), LTDate=1, LEDate=1, GTDate=8, GEDate=8, EQDate=0),\n      dict(id=2, Name=\"Bob\", MyDate=D(2023,12,4), LTDate=5, LEDate=2, GTDate=0, GEDate=2, EQDate=2),\n    ])\n\n    # Another update to the lookup date for Bob.\n    self.update_record('Customers', 2, MyDate=D(2023,1,1))\n    self.assertTableData('Customers', cols=\"subset\", data=[\n      dict(id=1, Name=\"Alice\", MyDate=D(2023,12,4), LTDate=1, LEDate=1, GTDate=8, GEDate=8, EQDate=0),\n      dict(id=2, Name=\"Bob\", MyDate=D(2023,1,1), LTDate=0, LEDate=0, GTDate=5, GEDate=5, EQDate=0),\n    ])\n\n  def test_lookup_find(self):\n    self.do_test_lookup_find()\n\n  def test_lookup_underscore_find(self):\n    # Repeat the previous test case with _find in place of find. Normally, we can use\n    # lookupRecords(...).find.*, but if a column named \"find\" exists, it will shadow this method,\n    # and lookupRecords(...)._find.* may be used instead (with an underscore). Check that it works.\n    self.do_test_lookup_find(find=\"_find\")\n\n  def test_lookup_find_ref_any(self):\n    self.do_test_lookup_find(ref_type_to_use='Any')\n\n  def test_lookup_find_ref_reflist(self):\n    self.do_test_lookup_find(ref_type_to_use='RefList:Purchases')\n\n  def test_lookup_find_empty(self):\n    self.do_setup()\n    self.add_column(\"Customers\", \"P\", type='RefList:Purchases',\n        formula=\"Purchases.lookupRecords(Customer=$id, Category='C', sort_by='Date')\")\n    self.add_column(\"Customers\", \"LTDate\", type=\"Ref:Purchases\", formula=\"$P.find.lt($MyDate)\")\n    self.add_column(\"Customers\", \"LEDate\", type=\"Ref:Purchases\", formula=\"$P.find.le($MyDate)\")\n    self.add_column(\"Customers\", \"GTDate\", type=\"Ref:Purchases\", formula=\"$P.find.gt($MyDate)\")\n    self.add_column(\"Customers\", \"GEDate\", type=\"Ref:Purchases\", formula=\"$P.find.ge($MyDate)\")\n    self.add_column(\"Customers\", \"EQDate\", type=\"Ref:Purchases\", formula=\"$P.find.eq($MyDate)\")\n\n    # pylint: disable=line-too-long\n    self.assertTableData('Customers', cols=\"subset\", data=[\n      dict(id=1, Name=\"Alice\", MyDate=D(2023,12,5), LTDate=0, LEDate=0, GTDate=0, GEDate=0, EQDate=0),\n      dict(id=2, Name=\"Bob\", MyDate=D(2023,12,10), LTDate=0, LEDate=0, GTDate=0, GEDate=0, EQDate=0),\n    ])\n\n    # Check find.* results once the lookup result becomes non-empty.\n    self.update_record('Purchases', 5, Category=\"C\")\n    self.assertTableData('Customers', cols=\"subset\", data=[\n      dict(id=1, Name=\"Alice\", MyDate=D(2023,12,5), LTDate=5, LEDate=5, GTDate=0, GEDate=0, EQDate=0),\n      dict(id=2, Name=\"Bob\", MyDate=D(2023,12,10), LTDate=0, LEDate=0, GTDate=0, GEDate=0, EQDate=0),\n    ])\n\n  def test_lookup_find_unsorted(self):\n    self.do_setup()\n    self.add_column(\"Customers\", \"P\", type='RefList:Purchases',\n        formula=\"[Purchases.lookupOne(Customer=$id)]\")\n    self.add_column(\"Customers\", \"LTDate\", type=\"Ref:Purchases\", formula=\"$P.find.lt($MyDate)\")\n    err = objtypes.RaisedException(ValueError())\n    self.assertTableData('Customers', cols=\"subset\", data=[\n      dict(id=1, Name=\"Alice\", MyDate=D(2023,12,5), LTDate=err),\n      dict(id=2, Name=\"Bob\", MyDate=D(2023,12,10), LTDate=err),\n    ])\n\n  def test_column_named_find(self):\n    # Test that we can add a column named \"find\", use it, and remove it.\n    self.do_setup()\n    self.add_column(\"Customers\", \"find\", type=\"Text\")\n\n    # Check that the column is usable.\n    self.update_record(\"Customers\", 1, find=\"Hello\")\n    self.assertTableData('Customers', cols=\"all\", data=[\n      dict(id=1, Name=\"Alice\", MyDate=D(2023,12,5), find=\"Hello\"),\n      dict(id=2, Name=\"Bob\", MyDate=D(2023,12,10), find=\"\"),\n    ])\n\n    # Check that we can remove the column.\n    self.remove_column(\"Customers\", \"find\")\n    self.assertTableData('Customers', cols=\"all\", data=[\n      dict(id=1, Name=\"Alice\", MyDate=D(2023,12,5)),\n      dict(id=2, Name=\"Bob\", MyDate=D(2023,12,10)),\n    ])\n\n\n  def test_rename_find_attrs(self):\n    \"\"\"\n    Check that in formulas like Table.lookupRecords(...).find.lt(...).ColID, renames of ColID\n    update the formula.\n    \"\"\"\n    # Create a simple table (People) with a couple records.\n    self.apply_user_action([\"AddTable\", \"People\", [\n      dict(id=\"Name\", type=\"Text\")\n    ]])\n    self.add_record(\"People\", Name=\"Alice\")\n    self.add_record(\"People\", Name=\"Bob\")\n\n    # Create a separate table that does a lookup in the People table.\n    self.apply_user_action([\"AddTable\", \"Test\", [\n      dict(id=\"Lookup1\", type=\"Any\", isFormula=True,\n        formula=\"People.lookupRecords(order_by='Name').find.ge('B').Name\"),\n      dict(id=\"Lookup2\", type=\"Any\", isFormula=True,\n        formula=\"People.lookupRecords(order_by='Name')._find.eq('Alice').Name\"),\n      dict(id=\"Lookup3\", type=\"Any\", isFormula=True,\n        formula=\"r = People.lookupRecords(order_by='Name').find.ge('B')\\n\" +\n                \"PREVIOUS(r, order_by=None).Name\"),\n      dict(id=\"Lookup4\", type=\"Any\", isFormula=True,\n        formula=\"r = People.lookupRecords(order_by='Name').find.eq('Alice')\\n\" +\n                \"People.lookupRecords(order_by='Name').find.next(r).Name\")\n    ]])\n    self.add_record(\"Test\")\n\n    # Test that lookups return data as expected.\n    self.assertTableData('Test', cols=\"subset\", data=[\n      dict(id=1, Lookup1=\"Bob\", Lookup2=\"Alice\", Lookup3=\"Alice\", Lookup4=\"Bob\")\n    ])\n\n    # Rename a column used for lookups or order_by. Lookup result shouldn't change.\n    self.apply_user_action([\"RenameColumn\", \"People\", \"Name\", \"FullName\"])\n    self.assertTableData('Test', cols=\"subset\", data=[\n      dict(id=1, Lookup1=\"Bob\", Lookup2=\"Alice\", Lookup3=\"Alice\", Lookup4=\"Bob\")\n    ])\n\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      dict(id=6, colId=\"Lookup3\",\n        formula=\"r = People.lookupRecords(order_by='FullName').find.ge('B')\\n\" +\n                \"PREVIOUS(r, order_by=None).FullName\"),\n      dict(id=7, colId=\"Lookup4\",\n        formula=\"r = People.lookupRecords(order_by='FullName').find.eq('Alice')\\n\" +\n                \"People.lookupRecords(order_by='FullName').find.next(r).FullName\")\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_lookup_perf.py",
    "content": "import math\nimport time\nimport testutil\nimport test_engine\n\nclass TestLookupPerformance(test_engine.EngineTestCase):\n  def test_non_quadratic(self):\n    # This test measures performance which depends on other stuff running on the machine, which\n    # makes it inherently flaky. But if it fails legitimately, it should fail every time. So we\n    # run multiple times (3), and fail only if all of those times fail.\n    for i in range(2):\n      try:\n        return self._do_test_non_quadratic()\n      except Exception as e:\n        print(\"FAIL #%d\" % (i + 1))\n    self._do_test_non_quadratic()\n\n  def _do_test_non_quadratic(self):\n    # If the same lookupRecords is called by many cells, it should reuse calculations, not lead to\n    # quadratic complexity. (Actually making use of the result would often still be O(N) in each\n    # cell, but here we check that just doing the lookup is O(1) amortized.)\n\n    # Table1 has columns: Date and Status, each will have just two distinct values.\n    # We add a bunch of formulas that should take constant time outside of the lookup.\n\n    # The way we test for quadratic complexity is by timing \"BulkAddRecord\" action that causes all\n    # rows to recalculate for a geometrically growing sequence of row counts. Then we\n    # log-transform the data and do linear regression on it. It should produce data that fits\n    # closely a line of slope 1.\n\n    self.setUp()    # Repeat setup because this test case gets called multiple times.\n    self.load_sample(testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [1, \"Date\", \"Date\", False, \"\", \"\", \"\"],\n          [2, \"Status\", \"Text\", False, \"\", \"\", \"\"],\n          [3, \"lookup_1a\", \"Any\", True, \"len(Table1.all)\", \"\", \"\"],\n          [4, \"lookup_2a\", \"Any\", True, \"len(Table1.lookupRecords(order_by='-Date'))\", \"\", \"\"],\n          [5, \"lookup_3a\", \"Any\", True,\n            \"len(Table1.lookupRecords(Status=$Status, order_by=('-Date', '-id')))\", \"\", \"\"],\n          [6, \"lookup_1b\", \"Any\", True, \"Table1.lookupOne().id\", \"\", \"\"],\n          # Keep one legacy sort_by example (it shares implementation, so should work similarly)\n          [7, \"lookup_2b\", \"Any\", True, \"Table1.lookupOne(sort_by='-Date').id\", \"\", \"\"],\n          [8, \"lookup_3b\", \"Any\", True,\n            \"Table1.lookupOne(Status=$Status, order_by=('-Date', '-id')).id\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {}\n    }))\n\n    num_records = 0\n\n    def add_records(count):\n      assert count % 4 == 0, \"Call add_records with multiples of 4 here\"\n      self.add_records(\"Table1\", [\"Date\", \"Status\"], [\n        [ \"2024-01-01\",  \"Green\" ],\n        [ \"2024-01-01\",  \"Green\" ],\n        [ \"2024-02-01\",  \"Blue\" ],\n        [ \"2000-01-01\",  \"Blue\" ],\n      ] * (count // 4))\n\n      N = num_records + count\n      self.assertTableData(\n        \"Table1\", cols=\"subset\", rows=\"subset\", data=[\n          [\"id\", \"lookup_1a\", \"lookup_2a\", \"lookup_3a\", \"lookup_1b\", \"lookup_2b\", \"lookup_3b\"],\n          [1,    N,           N,           N // 2,      1,           3,           N - 2],\n        ])\n      return N\n\n    # Add records in a geometric sequence\n    times = {}\n    start_time = time.time()\n    last_time = start_time\n    count_add = 20\n    while last_time < start_time + 2:       # Stop once we've spent 2 seconds\n      add_time = time.time()\n      num_records = add_records(count_add)\n      last_time = time.time()\n      times[num_records] = last_time - add_time\n      count_add *= 2\n\n    count_array = sorted(times.keys())\n    times_array = [times[r] for r in count_array]\n\n    # Perform linear regression on log-transformed data\n    log_count_array = [math.log(x) for x in count_array]\n    log_times_array = [math.log(x) for x in times_array]\n\n    # Calculate slope and intercept using the least squares method.\n    # Doing this manually so that it works in Python2 too.\n    # Otherwise, we could just use statistics.linear_regression()\n    n = len(log_count_array)\n    sum_x = sum(log_count_array)\n    sum_y = sum(log_times_array)\n    sum_xx = sum(x * x for x in log_count_array)\n    sum_xy = sum(x * y for x, y in zip(log_count_array, log_times_array))\n    slope = (n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x)\n    intercept = (sum_y - slope * sum_x) / n\n\n    # Calculate R-squared\n    mean_y = sum_y / n\n    ss_tot = sum((y - mean_y) ** 2 for y in log_times_array)\n    ss_res = sum((y - (slope * x + intercept)) ** 2\n        for x, y in zip(log_count_array, log_times_array))\n    r_squared = 1 - (ss_res / ss_tot)\n\n    # Check that the slope is close to 1. For log-transformed data, this means a linear\n    # relationship (a quadratic term would make the slope 2).\n    # In practice, we see slope even less 1 (because there is a non-trivial constant term), so we\n    # can assert things a bit lower than 1: 0.86 to 1.04.\n    err_msg = \"Time is non-linear: slope {} R^2 {}\".format(slope, r_squared)\n    self.assertAlmostEqual(slope, 0.95, delta=0.09, msg=err_msg)\n\n    # Check that R^2 is close to 1, meaning that data is very close to that line (of slope ~1).\n    self.assertAlmostEqual(r_squared, 1, delta=0.08, msg=err_msg)\n"
  },
  {
    "path": "sandbox/grist/test_lookup_sort.py",
    "content": "import datetime\nimport logging\nimport moment\nimport testutil\nimport test_engine\nfrom table import make_sort_spec\n\nlog = logging.getLogger(__name__)\n\ndef D(year, month, day):\n  return moment.date_to_ts(datetime.date(year, month, day))\n\nclass TestLookupSort(test_engine.EngineTestCase):\n\n  def do_setup(self, order_by_arg):\n    self.load_sample(testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Customers\", [\n          [11, \"Name\", \"Text\", False, \"\", \"\", \"\"],\n          [12, \"Lookup\", \"RefList:Purchases\", True,\n            \"Purchases.lookupRecords(Customer=$id, %s)\" % order_by_arg, \"\", \"\"],\n          [13, \"LookupAmount\", \"Any\", True,\n            \"Purchases.lookupRecords(Customer=$id, %s).Amount\" % order_by_arg, \"\", \"\"],\n          [14, \"LookupDotAmount\", \"Any\", True, \"$Lookup.Amount\", \"\", \"\"],\n          [15, \"LookupContains\", \"RefList:Purchases\", True,\n            \"Purchases.lookupRecords(Customer=$id, Tags=CONTAINS('foo'), %s)\" % order_by_arg,\n            \"\", \"\"],\n          [16, \"LookupContainsDotAmount\", \"Any\", True, \"$LookupContains.Amount\", \"\", \"\"],\n        ]],\n        [2, \"Purchases\", [\n          [21, \"Customer\", \"Ref:Customers\", False, \"\", \"\", \"\"],\n          [22, \"Date\", \"Date\", False, \"\", \"\", \"\"],\n          [23, \"Tags\", \"ChoiceList\", False, \"\", \"\", \"\"],\n          [24, \"Category\", \"Text\", False, \"\", \"\", \"\"],\n          [25, \"Amount\", \"Numeric\", False, \"\", \"\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"Customers\": [\n          [\"id\", \"Name\"],\n          [1,    \"Alice\"],\n          [2,    \"Bob\"],\n        ],\n        \"Purchases\": [\n          [ \"id\",   \"Customer\", \"Date\",       \"Tags\",   \"Category\", \"Amount\", ],\n          # Note: the tenths digit of Amount corresponds to day, for easier ordering of expected\n          # sort results.\n          [1,       1,          D(2023,12,1), [\"foo\"],  \"A\",        10.1],\n          [2,       2,          D(2023,12,4), [\"foo\"],  \"A\",        17.4],\n          [3,       1,          D(2023,12,3), [\"bar\"],  \"A\",        20.3],\n          [4,       1,          D(2023,12,9), [\"foo\", \"bar\"],  \"A\", 40.9],\n          [5,       1,          D(2023,12,2), [\"foo\", \"bar\"],  \"B\", 80.2],\n          [6,       1,          D(2023,12,6), [\"bar\"],  \"B\",        160.6],\n          [7,       1,          D(2023,12,7), [\"foo\"],  \"A\",        320.7],\n          [8,       1,          D(2023,12,5), [\"bar\", \"foo\"],  \"A\", 640.5],\n        ],\n      }\n    }))\n\n  def test_make_sort_spec(self):\n    \"\"\"\n    Test interpretations of different kinds of order_by and sort_by params.\n    \"\"\"\n    # Test the default for Table.lookupRecords.\n    self.assertEqual(make_sort_spec(('id',), None, True), ())\n    self.assertEqual(make_sort_spec(('id',), None, False), ())\n\n    # Test legacy sort_by\n    self.assertEqual(make_sort_spec(('Doh',), 'Foo', True), ('Foo',))\n    self.assertEqual(make_sort_spec(None, '-Foo', False), ('-Foo',))\n\n    # Test None, string, tuple, without manualSort.\n    self.assertEqual(make_sort_spec(None, None, False), ())\n    self.assertEqual(make_sort_spec('Bar', None, False), ('Bar',))\n    self.assertEqual(make_sort_spec(('Foo', '-Bar'), None, False), ('Foo', '-Bar'))\n\n    # Test None, string, tuple, WITH manualSort.\n    self.assertEqual(make_sort_spec(None, None, True), ('manualSort',))\n    self.assertEqual(make_sort_spec('Bar', None, True), ('Bar', 'manualSort'))\n    self.assertEqual(make_sort_spec(('Foo', '-Bar'), None, True), ('Foo', '-Bar', 'manualSort'))\n\n    # If 'manualSort' is present, should not be added twice.\n    self.assertEqual(make_sort_spec(('Foo', 'manualSort'), None, True), ('Foo', 'manualSort'))\n\n    # If 'id' is present, fields starting with it are dropped.\n    self.assertEqual(make_sort_spec(('Bar', 'id'), None, True), ('Bar',))\n    self.assertEqual(make_sort_spec(('Foo', 'id', 'manualSort', 'X'), None, True), ('Foo',))\n    self.assertEqual(make_sort_spec('id', None, True), ())\n\n  def test_lookup_sort_by_default(self):\n    \"\"\"\n    Tests lookups with default sort (by row_id) using sort_by=None, and how it reacts to changes.\n    \"\"\"\n    self.do_setup('sort_by=None')\n    self._do_test_lookup_sort_by_default()\n\n  def test_lookup_order_by_none(self):\n    # order_by=None means default to manualSort. But this test case should not be affected.\n    self.do_setup('order_by=None')\n    self._do_test_lookup_sort_by_default()\n\n  def _do_test_lookup_sort_by_default(self):\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [1, 3, 4, 5, 6, 7, 8],\n        LookupAmount = [10.1, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],\n        LookupDotAmount = [10.1, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],\n        LookupContains = [1, 4, 5, 7, 8],\n        LookupContainsDotAmount = [10.1, 40.9, 80.2, 320.7, 640.5],\n      )\n    ])\n\n    # Change Customer of Purchase #2 (Bob -> Alice) and check that all got updated.\n    # (The list of purchases for Alice gets the new purchase #2.)\n    out_actions = self.update_record(\"Purchases\", 2, Customer=1)\n    self.assertEqual(out_actions.calls[\"Customers\"], {\n      \"Lookup\": 2, \"LookupAmount\": 2, \"LookupDotAmount\": 2,\n      \"LookupContains\": 2, \"LookupContainsDotAmount\": 2,\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [1, 2, 3, 4, 5, 6, 7, 8],\n        LookupAmount = [10.1, 17.4, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],\n        LookupDotAmount = [10.1, 17.4, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],\n        LookupContains = [1, 2, 4, 5, 7, 8],\n        LookupContainsDotAmount = [10.1, 17.4, 40.9, 80.2, 320.7, 640.5],\n      )\n    ])\n\n    # Change Customer of Purchase #1 (Alice -> Bob) and check that all got updated.\n    # (The list of purchases for Alice loses the purchase #1.)\n    out_actions = self.update_record(\"Purchases\", 1, Customer=2)\n    self.assertEqual(out_actions.calls[\"Customers\"], {\n      \"Lookup\": 2, \"LookupAmount\": 2, \"LookupDotAmount\": 2,\n      \"LookupContains\": 2, \"LookupContainsDotAmount\": 2,\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [2, 3, 4, 5, 6, 7, 8],\n        LookupAmount = [17.4, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],\n        LookupDotAmount = [17.4, 20.3, 40.9, 80.2, 160.6, 320.7, 640.5],\n        LookupContains = [2, 4, 5, 7, 8],\n        LookupContainsDotAmount = [17.4, 40.9, 80.2, 320.7, 640.5],\n      )\n    ])\n\n    # Change Date of Purchase #3 to much earlier, and check that all got updated.\n    out_actions = self.update_record(\"Purchases\", 3, Date=D(2023,8,1))\n    # Nothing to recompute in this case, since it doesn't depend on Date.\n    self.assertEqual(out_actions.calls.get(\"Customers\"), None)\n\n    # Change Amount of Purchase #3 to much larger, and check that just amounts got updated.\n    out_actions = self.update_record(\"Purchases\", 3, Amount=999999)\n    self.assertEqual(out_actions.calls[\"Customers\"], {\n      # Lookups that don't depend on Amount aren't recalculated\n      \"LookupAmount\": 1, \"LookupDotAmount\": 1,\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [2, 3, 4, 5, 6, 7, 8],\n        LookupAmount = [17.4, 999999, 40.9, 80.2, 160.6, 320.7, 640.5],\n        LookupDotAmount = [17.4, 999999, 40.9, 80.2, 160.6, 320.7, 640.5],\n        LookupContains = [2, 4, 5, 7, 8],\n        LookupContainsDotAmount = [17.4, 40.9, 80.2, 320.7, 640.5],\n      )\n    ])\n\n  def test_lookup_sort_by_date(self):\n    \"\"\"\n    Tests lookups with sort by \"-Date\", and how it reacts to changes.\n    \"\"\"\n    self.do_setup('sort_by=\"-Date\"')\n    self._do_test_lookup_sort_by_date()\n\n  def test_lookup_order_by_date(self):\n    # With order_by, we'll fall back to manualSort, but this shouldn't matter here.\n    self.do_setup('order_by=\"-Date\"')\n    self._do_test_lookup_sort_by_date()\n\n  def _do_test_lookup_sort_by_date(self):\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 6, 8, 3, 5, 1],\n        LookupAmount = [40.9, 320.7, 160.6, 640.5, 20.3, 80.2, 10.1],\n        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 20.3, 80.2, 10.1],\n        LookupContains = [4, 7, 8, 5, 1],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 80.2, 10.1],\n      )\n    ])\n\n    # Change Customer of Purchase #2 (Bob -> Alice) and check that all got updated.\n    # (The list of purchases for Alice gets the new purchase #2.)\n    out_actions = self.update_record(\"Purchases\", 2, Customer=1)\n    self.assertEqual(out_actions.calls[\"Customers\"], {\n      \"Lookup\": 2, \"LookupAmount\": 2, \"LookupDotAmount\": 2,\n      \"LookupContains\": 2, \"LookupContainsDotAmount\": 2,\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 6, 8, 2, 3, 5, 1],\n        LookupAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 20.3, 80.2, 10.1],\n        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 20.3, 80.2, 10.1],\n        LookupContains = [4, 7, 8, 2, 5, 1],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2, 10.1],\n      )\n    ])\n\n    # Change Customer of Purchase #1 (Alice -> Bob) and check that all got updated.\n    # (The list of purchases for Alice loses the purchase #1.)\n    out_actions = self.update_record(\"Purchases\", 1, Customer=2)\n    self.assertEqual(out_actions.calls[\"Customers\"], {\n      \"Lookup\": 2, \"LookupAmount\": 2, \"LookupDotAmount\": 2,\n      \"LookupContains\": 2, \"LookupContainsDotAmount\": 2,\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 6, 8, 2, 3, 5],\n        LookupAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 20.3, 80.2],\n        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 20.3, 80.2],\n        LookupContains = [4, 7, 8, 2, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],\n      )\n    ])\n\n    # Change Date of Purchase #3 to much earlier, and check that all got updated.\n    out_actions = self.update_record(\"Purchases\", 3, Date=D(2023,8,1))\n    self.assertEqual(out_actions.calls.get(\"Customers\"), {\n      # Only the affected lookups are affected\n      \"Lookup\": 1, \"LookupAmount\": 1, \"LookupDotAmount\": 1\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 6, 8, 2, 5, 3],\n        LookupAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 80.2, 20.3],\n        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 80.2, 20.3],\n        LookupContains = [4, 7, 8, 2, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],\n      )\n    ])\n\n    # Change Amount of Purchase #3 to much larger, and check that just amounts got updated.\n    out_actions = self.update_record(\"Purchases\", 3, Amount=999999)\n    self.assertEqual(out_actions.calls[\"Customers\"], {\n      # Lookups that don't depend on Amount aren't recalculated\n      \"LookupAmount\": 1, \"LookupDotAmount\": 1,\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 6, 8, 2, 5, 3],\n        LookupAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 80.2, 999999],\n        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 17.4, 80.2, 999999],\n        LookupContains = [4, 7, 8, 2, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],\n      )\n    ])\n\n\n  def test_lookup_order_by_tuple(self):\n    \"\"\"\n    Tests lookups with order by (\"Category\", \"-Date\"), and how it reacts to changes.\n    \"\"\"\n    self.do_setup('order_by=(\"Category\", \"-Date\")')\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 8, 3, 1, 6, 5],\n        LookupAmount = [40.9, 320.7, 640.5, 20.3, 10.1, 160.6, 80.2],\n        LookupDotAmount = [40.9, 320.7, 640.5, 20.3, 10.1, 160.6, 80.2],\n        LookupContains = [4, 7, 8, 1, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 10.1, 80.2],\n      )\n    ])\n\n    # Change Customer of Purchase #2 (Bob -> Alice) and check that all got updated.\n    # (The list of purchases for Alice gets the new purchase #2.)\n    out_actions = self.update_record(\"Purchases\", 2, Customer=1)\n    self.assertEqual(out_actions.calls[\"Customers\"], {\n      \"Lookup\": 2, \"LookupAmount\": 2, \"LookupDotAmount\": 2,\n      \"LookupContains\": 2, \"LookupContainsDotAmount\": 2,\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 8, 2, 3, 1, 6, 5],\n        LookupAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 10.1, 160.6, 80.2],\n        LookupDotAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 10.1, 160.6, 80.2],\n        LookupContains = [4, 7, 8, 2, 1, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 10.1, 80.2],\n      )\n    ])\n\n    # Change Customer of Purchase #1 (Alice -> Bob) and check that all got updated.\n    # (The list of purchases for Alice loses the purchase #1.)\n    out_actions = self.update_record(\"Purchases\", 1, Customer=2)\n    self.assertEqual(out_actions.calls[\"Customers\"], {\n      \"Lookup\": 2, \"LookupAmount\": 2, \"LookupDotAmount\": 2,\n      \"LookupContains\": 2, \"LookupContainsDotAmount\": 2,\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 8, 2, 3, 6, 5],\n        LookupAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 160.6, 80.2],\n        LookupDotAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 160.6, 80.2],\n        LookupContains = [4, 7, 8, 2, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],\n      )\n    ])\n\n    # Change Date of Purchase #3 to much earlier, and check that all got updated.\n    out_actions = self.update_record(\"Purchases\", 3, Date=D(2023,8,1))\n    self.assertEqual(out_actions.calls.get(\"Customers\"), {\n      # Only the affected lookups are affected\n      \"Lookup\": 1, \"LookupAmount\": 1, \"LookupDotAmount\": 1\n    })\n    # Actually this happens to be unchanged, because within the category, the new date is still in\n    # the same position.\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 8, 2, 3, 6, 5],\n        LookupAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 160.6, 80.2],\n        LookupDotAmount = [40.9, 320.7, 640.5, 17.4, 20.3, 160.6, 80.2],\n        LookupContains = [4, 7, 8, 2, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],\n      )\n    ])\n\n    # Change Category of Purchase #3 to \"B\", and check that it got moved.\n    out_actions = self.update_record(\"Purchases\", 3, Category=\"B\")\n    self.assertEqual(out_actions.calls.get(\"Customers\"), {\n      # Only the affected lookups are affected\n      \"Lookup\": 1, \"LookupAmount\": 1, \"LookupDotAmount\": 1\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 8, 2, 6, 5, 3],\n        LookupAmount = [40.9, 320.7, 640.5, 17.4, 160.6, 80.2, 20.3],\n        LookupDotAmount = [40.9, 320.7, 640.5, 17.4, 160.6, 80.2, 20.3],\n        LookupContains = [4, 7, 8, 2, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],\n      )\n    ])\n\n    # Change Amount of Purchase #3 to much larger, and check that just amounts got updated.\n    out_actions = self.update_record(\"Purchases\", 3, Amount=999999)\n    self.assertEqual(out_actions.calls[\"Customers\"], {\n      # Lookups that don't depend on Amount aren't recalculated\n      \"LookupAmount\": 1, \"LookupDotAmount\": 1,\n    })\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 8, 2, 6, 5, 3],\n        LookupAmount = [40.9, 320.7, 640.5, 17.4, 160.6, 80.2, 999999],\n        LookupDotAmount = [40.9, 320.7, 640.5, 17.4, 160.6, 80.2, 999999],\n        LookupContains = [4, 7, 8, 2, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 17.4, 80.2],\n      )\n    ])\n\n  def test_lookup_one(self):\n    self.do_setup('order_by=None')\n\n    # Check that the first value returned by default is the one with the lowest row ID.\n    self.add_column('Customers', 'One', type=\"Ref:Purchases\",\n        formula=\"Purchases.lookupOne(Customer=$id)\")\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(id = 1, Name = \"Alice\", One = 1),\n      dict(id = 2, Name = \"Bob\", One = 2),\n    ])\n\n    # Check that the first value returned with \"-Date\" is the one with the highest Date.\n    self.modify_column('Customers', 'One',\n        formula=\"Purchases.lookupOne(Customer=$id, order_by=('-Date',))\")\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(id = 1, Name = \"Alice\", One = 4),\n      dict(id = 2, Name = \"Bob\", One = 2),\n    ])\n\n    # Check that the first value returned with \"-id\" is the one with the highest row ID.\n    self.modify_column('Customers', 'One',\n        formula=\"Purchases.lookupOne(Customer=$id, order_by='-id')\")\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(id = 1, Name = \"Alice\", One = 8),\n      dict(id = 2, Name = \"Bob\", One = 2),\n    ])\n\n\n  def test_renaming_order_by_str(self):\n    # Given some lookups with order_by, rename a column used in order_by. Check order_by got\n    # adjusted, and the results are correct. Try for order_by as string.\n    self.do_setup(\"order_by='-Date'\")\n    self.apply_user_action(['RenameColumn', 'Purchases', 'Category', 'cat'])\n    self.apply_user_action(['RenameColumn', 'Purchases', 'Date', 'Fecha'])\n\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      dict(id=12, colId=\"Lookup\",\n        formula=\"Purchases.lookupRecords(Customer=$id, order_by='-Fecha')\"),\n      dict(id=13, colId=\"LookupAmount\",\n        formula=\"Purchases.lookupRecords(Customer=$id, order_by='-Fecha').Amount\"),\n    ])\n\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 6, 8, 3, 5, 1],\n        LookupAmount = [40.9, 320.7, 160.6, 640.5, 20.3, 80.2, 10.1],\n        LookupDotAmount = [40.9, 320.7, 160.6, 640.5, 20.3, 80.2, 10.1],\n        LookupContains = [4, 7, 8, 5, 1],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 80.2, 10.1],\n      )\n    ])\n\n    # Change the (renamed) Date of Purchase #1 to much later, and check that all got updated.\n    self.update_record(\"Purchases\", 1, Fecha=D(2024,12,31))\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [1, 4, 7, 6, 8, 3, 5],\n        LookupAmount = [10.1, 40.9, 320.7, 160.6, 640.5, 20.3, 80.2],\n        LookupDotAmount = [10.1, 40.9, 320.7, 160.6, 640.5, 20.3, 80.2],\n        LookupContains = [1, 4, 7, 8, 5],\n        LookupContainsDotAmount = [10.1, 40.9, 320.7, 640.5, 80.2],\n      )\n    ])\n\n\n  def test_renaming_order_by_tuple(self):\n    # Given some lookups with order_by, rename a column used in order_by. Check order_by got\n    # adjusted, and the results are correct. Try for order_by as tuple.\n    self.do_setup(\"order_by=('Category', '-Date')\")\n\n    out_actions = self.apply_user_action(['RenameColumn', 'Purchases', 'Category', 'cat'])\n\n    # Check returned actions to ensure we don't produce actions for any stale lookup helper columns\n    # (this is a way to check that we don't forget to clean up stale lookup helper columns).\n    # pylint: disable=line-too-long\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"RenameColumn\", \"Purchases\", \"Category\", \"cat\"],\n        [\"ModifyColumn\", \"Customers\", \"Lookup\", {\"formula\": \"Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Date'))\"}],\n        [\"ModifyColumn\", \"Customers\", \"LookupAmount\", {\"formula\": \"Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Date')).Amount\"}],\n        [\"ModifyColumn\", \"Customers\", \"LookupContains\", {\"formula\": \"Purchases.lookupRecords(Customer=$id, Tags=CONTAINS('foo'), order_by=('cat', '-Date'))\"}],\n        [\"BulkUpdateRecord\", \"_grist_Tables_column\", [24, 12, 13, 15], {\"colId\": [\"cat\", \"Lookup\", \"LookupAmount\", \"LookupContains\"], \"formula\": [\n          \"\",\n          \"Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Date'))\",\n          \"Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Date')).Amount\",\n          \"Purchases.lookupRecords(Customer=$id, Tags=CONTAINS('foo'), order_by=('cat', '-Date'))\",\n          ]}],\n        ]\n    })\n\n    self.apply_user_action(['RenameColumn', 'Purchases', 'Date', 'Fecha'])\n\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      dict(id=12, colId=\"Lookup\",\n        formula=\"Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Fecha'))\"),\n      dict(id=13, colId=\"LookupAmount\",\n        formula=\"Purchases.lookupRecords(Customer=$id, order_by=('cat', '-Fecha')).Amount\"),\n    ])\n\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 8, 3, 1, 6, 5],\n        LookupAmount = [40.9, 320.7, 640.5, 20.3, 10.1, 160.6, 80.2],\n        LookupDotAmount = [40.9, 320.7, 640.5, 20.3, 10.1, 160.6, 80.2],\n        LookupContains = [4, 7, 8, 1, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 10.1, 80.2],\n      )\n    ])\n\n    # Change the (renamed) Date of Purchase #3 to much earlier, and check that all got updated.\n    self.update_record(\"Purchases\", 3, Fecha=D(2023,8,1))\n    self.assertTableData(\"Customers\", cols=\"subset\", rows=\"subset\", data=[\n      dict(\n        id = 1,\n        Name = \"Alice\",\n        Lookup = [4, 7, 8, 1, 3, 6, 5],\n        LookupAmount = [40.9, 320.7, 640.5, 10.1, 20.3, 160.6, 80.2],\n        LookupDotAmount = [40.9, 320.7, 640.5, 10.1, 20.3, 160.6, 80.2],\n        LookupContains = [4, 7, 8, 1, 5],\n        LookupContainsDotAmount = [40.9, 320.7, 640.5, 10.1, 80.2],\n      )\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_lookups.py",
    "content": "import logging\nimport actions\n\nimport testsamples\nimport testutil\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\ndef _bulk_update(table_name, col_names, row_data):\n  return actions.BulkUpdateRecord(\n    *testutil.table_data_from_rows(table_name, col_names, row_data))\n\nclass TestLookups(test_engine.EngineTestCase):\n\n  def test_verify_sample(self):\n    self.load_sample(testsamples.sample_students)\n    self.assertPartialData(\"Students\", [\"id\", \"schoolIds\", \"schoolCities\" ], [\n      [1,   \"1:2\",  \"New York:Colombia\" ],\n      [2,   \"3:4\",  \"New Haven:West Haven\" ],\n      [3,   \"1:2\",  \"New York:Colombia\" ],\n      [4,   \"3:4\",  \"New Haven:West Haven\" ],\n      [5,   \"\",     \"\"],\n      [6,   \"3:4\",  \"New Haven:West Haven\" ]\n    ])\n\n\n  #----------------------------------------\n  def test_lookup_dependencies(self, pre_loaded=False):\n    \"\"\"\n    Test changes to records accessed via lookup.\n    \"\"\"\n    if not pre_loaded:\n      self.load_sample(testsamples.sample_students)\n\n    out_actions = self.update_record(\"Address\", 14, city=\"Bedford\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Address\", 14, {\"city\": \"Bedford\"}),\n        _bulk_update(\"Students\", [\"id\", \"schoolCities\" ], [\n          [2,   \"New Haven:Bedford\" ],\n          [4,   \"New Haven:Bedford\" ],\n          [6,   \"New Haven:Bedford\" ]]\n        )\n      ],\n      \"calls\": {\"Students\": {\"schoolCities\": 3}}\n    })\n\n    out_actions = self.update_record(\"Schools\", 4, address=13)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Schools\", 4, {\"address\": 13}),\n        _bulk_update(\"Students\", [\"id\", \"schoolCities\" ], [\n          [2,   \"New Haven:New Haven\" ],\n          [4,   \"New Haven:New Haven\" ],\n          [6,   \"New Haven:New Haven\" ]]\n        )\n      ],\n      \"calls\": {\"Students\": {\"schoolCities\": 3}}\n    })\n\n    out_actions = self.update_record(\"Address\", 14, city=\"Hartford\")\n    # No schoolCities need to be recalculatd here, since nothing depends on Address 14 any more.\n    self.assertPartialOutActions(out_actions, {\n      \"calls\": {}\n    })\n\n    # Confirm the final result.\n    self.assertPartialData(\"Students\", [\"id\", \"schoolIds\", \"schoolCities\" ], [\n      [1,   \"1:2\",  \"New York:Colombia\" ],\n      [2,   \"3:4\",  \"New Haven:New Haven\" ],\n      [3,   \"1:2\",  \"New York:Colombia\" ],\n      [4,   \"3:4\",  \"New Haven:New Haven\" ],\n      [5,   \"\",     \"\"],\n      [6,   \"3:4\",  \"New Haven:New Haven\" ]\n    ])\n\n  #----------------------------------------\n  def test_dependency_reset(self, pre_loaded=False):\n    \"\"\"\n    A somewhat tricky case. We know that Student 2 depends on Schools 3,4 and on Address 13,14.\n    If we change Student 2 to depend on nothing, then changing Address 13 should not cause it to\n    recompute.\n    \"\"\"\n    if not pre_loaded:\n      self.load_sample(testsamples.sample_students)\n\n    out_actions = self.update_record(\"Address\", 13, city=\"AAA\")\n    self.assertPartialOutActions(out_actions, {\n      \"calls\": {\"Students\": {\"schoolCities\": 3}}    # Initially 3 students depend on Address 13.\n    })\n\n    out_actions = self.update_record(\"Students\", 2, schoolName=\"Invalid\")\n\n    out_actions = self.update_record(\"Address\", 13, city=\"BBB\")\n    # If the count below is 3, then the engine forgot to reset the dependencies of Students 2.\n    self.assertPartialOutActions(out_actions, {\n      \"calls\": {\"Students\": {\"schoolCities\": 2}}    # Now only 2 Students depend on Address 13.\n    })\n\n  #----------------------------------------\n  def test_lookup_key_changes(self, pre_loaded=False):\n    \"\"\"\n    Test changes to lookup values in the target table. Note that student #3 does not depend on\n    any records, but depends on the value \"Eureka\", so gets updated when this value appears.\n    \"\"\"\n    if not pre_loaded:\n      self.load_sample(testsamples.sample_students)\n\n    out_actions = self.update_record(\"Schools\", 2, name=\"Eureka\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Schools\", 2, {\"name\": \"Eureka\"}),\n        actions.BulkUpdateRecord(\"Students\", [1,3,5], {\n          'schoolCities': [\"New York\", \"New York\", \"Colombia\"]\n        }),\n        actions.BulkUpdateRecord(\"Students\", [1,3,5], {\n          'schoolIds': [\"1\", \"1\",\"2\"]\n        }),\n      ],\n      \"calls\": {\"Students\": { 'schoolCities': 3, 'schoolIds': 3 },\n                \"Schools\": {'#lookup#name': 1} },\n    })\n\n    # Test changes to lookup values in the table doing the lookup.\n    out_actions = self.update_records(\"Students\", [\"id\", \"schoolName\"], [\n      [3, \"\"],\n      [5, \"Yale\"]\n    ])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.BulkUpdateRecord(\"Students\", [3,5], {'schoolName': [\"\", \"Yale\"]}),\n        actions.BulkUpdateRecord(\"Students\", [3,5], {'schoolCities': [\"\", \"New Haven:West Haven\"]}),\n        actions.BulkUpdateRecord(\"Students\", [3,5], {'schoolIds': [\"\", \"3:4\"]}),\n      ],\n      \"calls\": { \"Students\": { 'schoolCities': 2, 'schoolIds': 2 } },\n    })\n\n    # Confirm the final result.\n    self.assertPartialData(\"Students\", [\"id\", \"schoolIds\", \"schoolCities\" ], [\n      [1,   \"1\",    \"New York\" ],\n      [2,   \"3:4\",  \"New Haven:West Haven\" ],\n      [3,   \"\",     \"\" ],\n      [4,   \"3:4\",  \"New Haven:West Haven\" ],\n      [5,   \"3:4\",  \"New Haven:West Haven\" ],\n      [6,   \"3:4\",  \"New Haven:West Haven\" ]\n    ])\n\n\n  #----------------------------------------\n  def test_lookup_formula_after_schema_change(self):\n    self.load_sample(testsamples.sample_students)\n    self.add_column(\"Schools\", \"state\", type=\"Text\")\n\n    # Make a change that causes recomputation of a lookup formula after a schema change.\n    # We should NOT get attribute errors in the values.\n    out_actions = self.update_record(\"Schools\", 4, address=13)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Schools\", 4, {\"address\": 13}),\n        _bulk_update(\"Students\", [\"id\", \"schoolCities\" ], [\n          [2,   \"New Haven:New Haven\" ],\n          [4,   \"New Haven:New Haven\" ],\n          [6,   \"New Haven:New Haven\" ]]\n        )\n      ],\n      \"calls\": { \"Students\": { 'schoolCities': 3 } }\n    })\n\n\n  #----------------------------------------\n  def test_lookup_formula_changes(self):\n    self.load_sample(testsamples.sample_students)\n\n    self.add_column(\"Schools\", \"state\", type=\"Text\")\n    self.update_records(\"Schools\", [\"id\", \"state\"], [\n      [1, \"NY\"],\n      [2, \"MO\"],\n      [3, \"CT\"],\n      [4, \"CT\"]\n    ])\n\n    # Verify that when we change a formula, we get appropriate changes.\n    out_actions = self.modify_column(\"Students\", \"schoolCities\", formula=(\n      \"','.join(Schools.lookupRecords(name=$schoolName).state)\"))\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.ModifyColumn(\"Students\", \"schoolCities\", {\n          \"formula\": \"','.join(Schools.lookupRecords(name=$schoolName).state)\",\n        }),\n        actions.UpdateRecord(\"_grist_Tables_column\", 6, {\n          \"formula\": \"','.join(Schools.lookupRecords(name=$schoolName).state)\",\n        }),\n        _bulk_update(\"Students\", [\"id\", \"schoolCities\" ], [\n          [1,   \"NY,MO\" ],\n          [2,   \"CT,CT\" ],\n          [3,   \"NY,MO\" ],\n          [4,   \"CT,CT\" ],\n          [6,   \"CT,CT\" ]]\n        )\n      ],\n      # Note that it got computed 6 times (once for each record), but one value remained unchanged\n      # (because no schools matched).\n      \"calls\": { \"Students\": { 'schoolCities': 6 } }\n    })\n\n    # Check that we've created new dependencies, and removed old ones.\n    out_actions = self.update_record(\"Schools\", 4, address=13)\n    self.assertPartialOutActions(out_actions, {\n      \"calls\": {}\n    })\n\n    out_actions = self.update_record(\"Schools\", 4, state=\"MA\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Schools\", 4, {\"state\": \"MA\"}),\n        _bulk_update(\"Students\", [\"id\", \"schoolCities\" ], [\n          [2,   \"CT,MA\" ],\n          [4,   \"CT,MA\" ],\n          [6,   \"CT,MA\" ]]\n        )\n      ],\n      \"calls\": { \"Students\": { 'schoolCities': 3 } }\n    })\n\n    # If we change to look up uppercase values, we shouldn't find anything.\n    out_actions = self.modify_column(\"Students\", \"schoolCities\", formula=(\n      \"','.join(Schools.lookupRecords(name=$schoolName.upper()).state)\"))\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.ModifyColumn(\"Students\", \"schoolCities\", {\n          \"formula\": \"','.join(Schools.lookupRecords(name=$schoolName.upper()).state)\"\n        }),\n        actions.UpdateRecord(\"_grist_Tables_column\", 6, {\n          \"formula\": \"','.join(Schools.lookupRecords(name=$schoolName.upper()).state)\"\n        }),\n        actions.BulkUpdateRecord(\"Students\", [1,2,3,4,6],\n                                        {'schoolCities': [\"\",\"\",\"\",\"\",\"\"]})\n      ],\n      \"calls\": { \"Students\": { 'schoolCities': 6 } }\n    })\n\n    # Changes to dependencies should cause appropriate recalculations.\n    out_actions = self.update_record(\"Schools\", 4, state=\"KY\", name=\"EUREKA\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Schools\", 4, {\"state\": \"KY\", \"name\": \"EUREKA\"}),\n        actions.UpdateRecord(\"Students\", 5, {'schoolCities': \"KY\"}),\n        actions.BulkUpdateRecord(\"Students\", [2,4,6], {'schoolIds': [\"3\",\"3\",\"3\"]}),\n      ],\n      \"calls\": {\"Students\": { 'schoolCities': 1, 'schoolIds': 3 },\n                'Schools': {'#lookup#name': 1 } }\n    })\n\n    self.assertPartialData(\"Students\", [\"id\", \"schoolIds\", \"schoolCities\" ], [\n      # schoolCities aren't found here because we changed formula to lookup uppercase names.\n      [1,   \"1:2\",  \"\" ],\n      [2,   \"3\",    \"\" ],\n      [3,   \"1:2\",  \"\" ],\n      [4,   \"3\",    \"\" ],\n      [5,   \"\",     \"KY\" ],\n      [6,   \"3\",    \"\" ]\n    ])\n\n  def test_add_remove_lookup(self):\n    # Verify that when we add or remove a lookup formula, we get appropriate changes.\n    self.load_sample(testsamples.sample_students)\n\n    # Add another lookup formula.\n    out_actions = self.add_column(\"Schools\", \"lastNames\", formula=(\n      \"','.join(Students.lookupRecords(schoolName=$name).lastName)\"))\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.AddColumn(\"Schools\", \"lastNames\", {\n          \"formula\": \"','.join(Students.lookupRecords(schoolName=$name).lastName)\",\n          \"isFormula\": True, \"type\": \"Any\"\n        }),\n        actions.AddRecord(\"_grist_Tables_column\", 22, {\n          \"colId\": \"lastNames\",\n          \"formula\": \"','.join(Students.lookupRecords(schoolName=$name).lastName)\",\n          \"isFormula\": True, \"label\": \"lastNames\", \"parentId\": 2, \"parentPos\": 6.0,\n          \"type\": \"Any\", \"widgetOptions\": \"\"\n        }),\n        _bulk_update(\"Schools\", [\"id\", \"lastNames\"], [\n          [1, \"Obama,Clinton\"],\n          [2, \"Obama,Clinton\"],\n          [3, \"Bush,Bush,Ford\"],\n          [4, \"Bush,Bush,Ford\"]\n        ]),\n      ],\n      \"calls\": {\"Schools\": {\"lastNames\": 4}, \"Students\": {\"#lookup#schoolName\": 6}},\n    })\n\n    # Make sure it responds to changes.\n    out_actions = self.update_record(\"Students\", 5, schoolName=\"Columbia\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Students\", 5, {\"schoolName\": \"Columbia\"}),\n        _bulk_update(\"Schools\", [\"id\", \"lastNames\"], [\n          [1, \"Obama,Clinton,Reagan\"],\n          [2, \"Obama,Clinton,Reagan\"]]\n        ),\n        actions.UpdateRecord(\"Students\", 5, {\"schoolCities\": \"New York:Colombia\"}),\n        actions.UpdateRecord(\"Students\", 5, {\"schoolIds\": \"1:2\"}),\n      ],\n      \"calls\": {\"Students\": {'schoolCities': 1, 'schoolIds': 1, '#lookup#schoolName': 1},\n                \"Schools\": { 'lastNames': 2 }},\n    })\n\n    # Modify the column: in the process, the LookupMapColumn on Students.schoolName becomes unused\n    # while the old formula column is removed, but used again when it's added. It should not have\n    # to be rebuilt (so there should be no calls to recalculate the LookupMapColumn.\n    out_actions = self.modify_column(\"Schools\", \"lastNames\", formula=(\n      \"','.join(Students.lookupRecords(schoolName=$name).firstName)\"))\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.ModifyColumn(\"Schools\", \"lastNames\", {\n          \"formula\": \"','.join(Students.lookupRecords(schoolName=$name).firstName)\"\n        }),\n        actions.UpdateRecord(\"_grist_Tables_column\", 22, {\n          \"formula\": \"','.join(Students.lookupRecords(schoolName=$name).firstName)\"\n        }),\n        _bulk_update(\"Schools\", [\"id\", \"lastNames\"], [\n          [1, \"Barack,Bill,Ronald\"],\n          [2, \"Barack,Bill,Ronald\"],\n          [3, \"George W,George H,Gerald\"],\n          [4, \"George W,George H,Gerald\"]]\n        )\n      ],\n      \"calls\": {\"Schools\": {\"lastNames\": 4}}\n    })\n\n    # Remove the new lookup formula.\n    out_actions = self.remove_column(\"Schools\", \"lastNames\")\n    self.assertPartialOutActions(out_actions, {})    # No calc actions\n\n    # Make sure that changes still work without errors.\n    out_actions = self.update_record(\"Students\", 5, schoolName=\"Eureka\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Students\", 5, {\"schoolName\": \"Eureka\"}),\n        actions.UpdateRecord(\"Students\", 5, {\"schoolCities\": \"\"}),\n        actions.UpdateRecord(\"Students\", 5, {\"schoolIds\": \"\"}),\n      ],\n      # This should NOT have '#lookup#schoolName' recalculation because there are no longer any\n      # formulas which do such a lookup.\n      \"calls\": { \"Students\": {'schoolCities': 1, 'schoolIds': 1}}\n    })\n\n\n  def test_multi_column_lookups(self):\n    \"\"\"\n    Check that we can do lookups by multiple columns.\n    \"\"\"\n    self.load_sample(testsamples.sample_students)\n\n    # Add a lookup formula which looks up a student matching on both first and last names.\n    self.add_column(\"Schools\", \"bestStudent\", type=\"Text\")\n    self.update_record(\"Schools\", 1, bestStudent=\"Bush,George W\")\n    self.add_column(\"Schools\", \"bestStudentId\", formula=(\"\"\"\nif not $bestStudent: return \"\"\nln, fn = $bestStudent.split(\",\")\nreturn \",\".join(str(r.id) for r in Students.lookupRecords(firstName=fn, lastName=ln))\n\"\"\"))\n\n    # Check data so far: only one record is filled.\n    self.assertPartialData(\"Schools\", [\"id\", \"bestStudent\", \"bestStudentId\" ], [\n      [1,   \"Bush,George W\",  \"2\" ],\n      [2,   \"\",  \"\" ],\n      [3,   \"\",  \"\" ],\n      [4,   \"\",  \"\" ],\n    ])\n\n    # Fill a few more records and check that we find records we should, and don't find those we\n    # shouldn't.\n    out_actions = self.update_records(\"Schools\", [\"id\", \"bestStudent\"], [\n      [2, \"Clinton,Bill\"],\n      [3, \"Norris,Chuck\"],\n      [4, \"Bush,George H\"],\n    ])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.BulkUpdateRecord(\"Schools\", [2,3,4], {\n          \"bestStudent\": [\"Clinton,Bill\", \"Norris,Chuck\", \"Bush,George H\"]\n        }),\n        actions.BulkUpdateRecord(\"Schools\", [2, 4], {\"bestStudentId\": [\"3\", \"4\"]})\n      ],\n      \"calls\": {\"Schools\": {\"bestStudentId\": 3}}\n    })\n    self.assertPartialData(\"Schools\", [\"id\", \"bestStudent\", \"bestStudentId\" ], [\n      [1,   \"Bush,George W\",  \"2\" ],\n      [2,   \"Clinton,Bill\",   \"3\" ],\n      [3,   \"Norris,Chuck\",   \"\" ],\n      [4,   \"Bush,George H\",  \"4\" ],\n    ])\n\n    # Now add more records, first matching only some of the lookup fields.\n    out_actions = self.add_record(\"Students\", firstName=\"Chuck\", lastName=\"Morris\")\n    self.assertPartialOutActions(out_actions, {\n      \"calls\": {\n        # No calculations of anything Schools because nothing depends on the incomplete value.\n        \"Students\": {\n          \"#lookup#firstName:lastName\": 1, \"schoolIds\": 1, \"schoolCities\": 1, \"#lookup#\": 1\n        }\n      },\n      \"retValues\": [7],\n    })\n\n    # If we add a matching record, then we get a calculation of a record in Schools\n    out_actions = self.add_record(\"Students\", firstName=\"Chuck\", lastName=\"Norris\")\n    self.assertPartialOutActions(out_actions, {\n      \"calls\": {\n        \"Students\": {\n          \"#lookup#firstName:lastName\": 1, \"schoolIds\": 1, \"schoolCities\": 1, \"#lookup#\": 1\n        },\n        \"Schools\": {\"bestStudentId\": 1}\n      },\n      \"retValues\": [8],\n    })\n\n    # And the data should be correct.\n    self.assertPartialData(\"Schools\", [\"id\", \"bestStudent\", \"bestStudentId\" ], [\n      [1,   \"Bush,George W\",  \"2\" ],\n      [2,   \"Clinton,Bill\",   \"3\" ],\n      [3,   \"Norris,Chuck\",   \"8\" ],\n      [4,   \"Bush,George H\",  \"4\" ],\n    ])\n\n  def test_record_removal(self):\n    # Remove a record, make sure that lookup maps get updated.\n    self.load_sample(testsamples.sample_students)\n\n    out_actions = self.remove_record(\"Schools\", 3)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.RemoveRecord(\"Schools\", 3),\n        actions.BulkUpdateRecord(\"Students\", [2,4,6], {\n          \"schoolCities\": [\"West Haven\",\"West Haven\",\"West Haven\"]}),\n        actions.BulkUpdateRecord(\"Students\", [2,4,6], {\n          \"schoolIds\": [\"4\",\"4\",\"4\"]}),\n      ],\n      \"calls\": {\n        \"Students\": {\"schoolIds\": 3, \"schoolCities\": 3},\n        # LookupMapColumn is also updated but via a different path (unset() vs method() call), so\n        # it's not included in the count of formula calls.\n      }\n    })\n\n    self.assertPartialData(\"Students\", [\"id\", \"schoolIds\", \"schoolCities\" ], [\n      [1,   \"1:2\",  \"New York:Colombia\" ],\n      [2,   \"4\",    \"West Haven\" ],\n      [3,   \"1:2\",  \"New York:Colombia\" ],\n      [4,   \"4\",    \"West Haven\" ],\n      [5,   \"\",     \"\"],\n      [6,   \"4\",    \"West Haven\" ]\n    ])\n\n  def test_empty_relation(self):\n    # Make sure that when a relation becomes empty, it doesn't get messed up.\n    self.load_sample(testsamples.sample_students)\n\n    # Clear out dependencies.\n    self.update_records(\"Students\", [\"id\", \"schoolName\"],\n                        [ [i, \"\"] for i in [1,2,3,4,5,6] ])\n    self.assertPartialData(\"Students\", [\"id\", \"schoolIds\", \"schoolCities\" ],\n                           [ [i, \"\", \"\"] for i in [1,2,3,4,5,6] ])\n\n    # Make a number of changeas, to ensure they reuse rather than re-create _LookupRelations.\n    self.update_record(\"Students\", 2, schoolName=\"Yale\")\n    self.update_record(\"Students\", 2, schoolName=\"Columbia\")\n    self.update_record(\"Students\", 3, schoolName=\"Columbia\")\n    self.assertPartialData(\"Students\", [\"id\", \"schoolIds\", \"schoolCities\" ], [\n      [1,   \"\",     \"\"],\n      [2,   \"1:2\",  \"New York:Colombia\" ],\n      [3,   \"1:2\",  \"New York:Colombia\" ],\n      [4,   \"\",     \"\"],\n      [5,   \"\",     \"\"],\n      [6,   \"\",     \"\"],\n    ])\n\n    # When we messed up the dependencies, this change didn't cause a corresponding update. Check\n    # that it now does.\n    self.remove_record(\"Schools\", 2)\n    self.assertPartialData(\"Students\", [\"id\", \"schoolIds\", \"schoolCities\" ], [\n      [1,   \"\",     \"\"],\n      [2,   \"1\",    \"New York\" ],\n      [3,   \"1\",    \"New York\" ],\n      [4,   \"\",     \"\"],\n      [5,   \"\",     \"\"],\n      [6,   \"\",     \"\"],\n    ])\n\n  def test_lookups_of_computed_values(self):\n    \"\"\"\n    Make sure that lookups get updated when the value getting looked up is a formula result.\n    \"\"\"\n    self.load_sample(testsamples.sample_students)\n\n    # Add a column like Schools.name, but computed, and change schoolIds to use that one instead.\n    self.add_column(\"Schools\", \"cname\", formula=\"$name\")\n    self.modify_column(\"Students\", \"schoolIds\", formula=\n                       \"':'.join(str(id) for id in Schools.lookupRecords(cname=$schoolName).id)\")\n\n    self.assertPartialData(\"Students\", [\"id\", \"schoolIds\" ], [\n      [1,   \"1:2\"   ],\n      [2,   \"3:4\"   ],\n      [3,   \"1:2\"   ],\n      [4,   \"3:4\"   ],\n      [5,   \"\"      ],\n      [6,   \"3:4\"   ],\n    ])\n\n    # Check that a change to School.name, which triggers a change to School.cname, causes a change\n    # to the looked-up ids. The changes here should be the same as in test_lookup_key_changes\n    # test, even though schoolIds depends on name indirectly.\n    out_actions = self.update_record(\"Schools\", 2, name=\"Eureka\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Schools\", 2, {\"name\": \"Eureka\"}),\n        actions.UpdateRecord(\"Schools\", 2, {\"cname\": \"Eureka\"}),\n        actions.BulkUpdateRecord(\"Students\", [1,3,5], {\n          'schoolCities': [\"New York\", \"New York\", \"Colombia\"]\n        }),\n        actions.BulkUpdateRecord(\"Students\", [1,3,5], {\n          'schoolIds': [\"1\", \"1\",\"2\"]\n        }),\n      ],\n      \"calls\": {\"Students\": { 'schoolCities': 3, 'schoolIds': 3 },\n                \"Schools\": {'#lookup#name': 1, '#lookup#cname': 1, \"cname\": 1} },\n    })\n\n  def use_saved_lookup_results(self):\n    \"\"\"\n    This sets up data so that lookupRecord results are stored in a column and used in another. Key\n    tests that check lookup dependencies should work unchanged with this setup.\n    \"\"\"\n    self.load_sample(testsamples.sample_students)\n\n    # Split up Students.schoolCities into Students.schools and Students.schoolCities.\n    self.add_column(\"Students\", \"schools\", formula=\"Schools.lookupRecords(name=$schoolName)\",\n                    type=\"RefList:Schools\")\n    self.modify_column(\"Students\", \"schoolCities\",\n                       formula=\"':'.join(r.address.city for r in $schools)\")\n\n  # The following tests check correctness of dependencies when lookupResults are stored in one\n  # column and used in another. They reuse existing test cases with modified data.\n  def test_lookup_dependencies_reflist(self):\n    self.use_saved_lookup_results()\n    self.test_lookup_dependencies(pre_loaded=True)\n\n    # Confirm the final result including the additional 'schools' column.\n    self.assertPartialData(\"Students\", [\"id\", \"schools\", \"schoolIds\", \"schoolCities\" ], [\n      [1,   [1,2],  \"1:2\",  \"New York:Colombia\" ],\n      [2,   [3,4],  \"3:4\",  \"New Haven:New Haven\" ],\n      [3,   [1,2],  \"1:2\",  \"New York:Colombia\" ],\n      [4,   [3,4],  \"3:4\",  \"New Haven:New Haven\" ],\n      [5,   [],     \"\",     \"\"],\n      [6,   [3,4],  \"3:4\",  \"New Haven:New Haven\" ]\n    ])\n\n  def test_dependency_reset_reflist(self):\n    self.use_saved_lookup_results()\n    self.test_dependency_reset(pre_loaded=True)\n\n  def test_lookup_key_changes_reflist(self):\n    # We can't run this test case unchanged since our new column changes too in this test.\n    self.use_saved_lookup_results()\n    out_actions = self.update_record(\"Schools\", 2, name=\"Eureka\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord('Schools', 2, {'name': \"Eureka\"}),\n        actions.BulkUpdateRecord(\"Students\", [1,3,5], {\n          'schoolCities': [\"New York\", \"New York\", \"Colombia\"]\n        }),\n        actions.BulkUpdateRecord(\"Students\", [1,3,5], {\n          'schoolIds': [\"1\", \"1\",\"2\"]\n        }),\n        actions.BulkUpdateRecord('Students', [1,3,5], {'schools': [[1],[1],[2]]}),\n      ],\n      \"calls\": {\"Students\": { 'schools': 3, 'schoolCities': 3, 'schoolIds': 3 },\n                \"Schools\": {'#lookup#name': 1} },\n    })\n\n    # Test changes to lookup values in the table doing the lookup.\n    out_actions = self.update_records(\"Students\", [\"id\", \"schoolName\"], [\n      [3, \"\"],\n      [5, \"Yale\"]\n    ])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.BulkUpdateRecord(\"Students\", [3,5], {'schoolName': [\"\", \"Yale\"]}),\n        actions.BulkUpdateRecord(\"Students\", [3,5], {'schoolCities': [\"\", \"New Haven:West Haven\"]}),\n        actions.BulkUpdateRecord(\"Students\", [3,5], {'schoolIds': [\"\", \"3:4\"]}),\n        actions.BulkUpdateRecord(\"Students\", [3,5], {'schools': [[], [3,4]]}),\n      ],\n      \"calls\": { \"Students\": { 'schools': 2, 'schoolCities': 2, 'schoolIds': 2 } },\n    })\n\n    # Confirm the final result.\n    self.assertPartialData(\"Students\", [\"id\", \"schools\", \"schoolIds\", \"schoolCities\" ], [\n      [1,   [1],    \"1\",    \"New York\" ],\n      [2,   [3,4],  \"3:4\",  \"New Haven:West Haven\" ],\n      [3,   [],     \"\",     \"\" ],\n      [4,   [3,4],  \"3:4\",  \"New Haven:West Haven\" ],\n      [5,   [3,4],  \"3:4\",  \"New Haven:West Haven\" ],\n      [6,   [3,4],  \"3:4\",  \"New Haven:West Haven\" ]\n    ])\n\n  def test_dependencies_relations_bug(self):\n    # We had a serious bug with dependencies, for which this test verifies a fix. Imagine Table2\n    # has a formula a=Table1.lookupOne(A=$A), and b=$a.foo. When col A changes in Table1, columns\n    # a and b in Table2 get recomputed. Each recompute triggers reset_rows() which is there to\n    # clear lookup relations (it actually triggers reset_dependencies() which resets rows for the\n    # relation on each dependency edge).\n    #\n    # The first recompute (of a) triggers reset_rows() on the LookupRelation, then recomputes the\n    # lookup formula which re-populates the relation correctly. The second recompute (of b) also\n    # triggers reset_rows(). The bug was that it was triggering it in the same LookupRelation, but\n    # since it doesn't get followed with recomputing the lookup formula, the relation remains\n    # incomplete.\n    #\n    # It's important that a formula like \"b=$a.foo\" doesn't reuse the LookupRelation by itself on\n    # the edge between b and $a, but a composition of IdentityRelation and LookupRelation. The\n    # composition will correctly forward reset_rows() to only the first half of the relation.\n\n    # Set up two tables with a situation as described above. Here, the role of column Table2.a\n    # above is taken by \"Students.schools=Schools.lookupRecords(name=$schoolName)\".\n    self.use_saved_lookup_results()\n\n    # We intentionally try behavior with type Any formulas too, without converting to a reference\n    # type, in case that affects relations.\n    self.modify_column(\"Students\", \"schools\", type=\"Any\")\n    self.add_column(\"Students\", \"schoolsCount\", formula=\"len($schools.name)\")\n    self.add_column(\"Students\", \"oneSchool\", formula=\"Schools.lookupOne(name=$schoolName)\")\n    self.add_column(\"Students\", \"oneSchoolName\", formula=\"$oneSchool.name\")\n\n    # A helper for comparing Record objects below.\n    schools_table = self.engine.tables['Schools']\n    def SchoolsRec(row_id):\n      return schools_table.Record(row_id, None)\n\n    # We'll play with schools \"Columbia\" and \"Eureka\", which are rows 1,3,5 in the Students table.\n    self.assertTableData(\"Students\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"schoolName\", \"schoolsCount\", \"oneSchool\",    \"oneSchoolName\"],\n      [1,     \"Columbia\",   2,              SchoolsRec(1),  \"Columbia\"],\n      [3,     \"Columbia\",   2,              SchoolsRec(1),  \"Columbia\"],\n      [5,     \"Eureka\",     0,              SchoolsRec(0),  \"\"],\n    ])\n\n    # Now change Schools.schoolName which should trigger recomputations.\n    self.update_record(\"Schools\", 1, name=\"Eureka\")\n    self.assertTableData(\"Students\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"schoolName\", \"schoolsCount\", \"oneSchool\",    \"oneSchoolName\"],\n      [1,     \"Columbia\",   1,              SchoolsRec(2),  \"Columbia\"],\n      [3,     \"Columbia\",   1,              SchoolsRec(2),  \"Columbia\"],\n      [5,     \"Eureka\",     1,              SchoolsRec(1),  \"Eureka\"],\n    ])\n\n    # The first change is expected to work. The important check is that the relations don't get\n    # corrupted afterwards. So we do a second change to see if that still updates.\n    self.update_record(\"Schools\", 1, name=\"Columbia\")\n    self.assertTableData(\"Students\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"schoolName\", \"schoolsCount\", \"oneSchool\",    \"oneSchoolName\"],\n      [1,     \"Columbia\",   2,              SchoolsRec(1),  \"Columbia\"],\n      [3,     \"Columbia\",   2,              SchoolsRec(1),  \"Columbia\"],\n      [5,     \"Eureka\",     0,              SchoolsRec(0),  \"\"],\n    ])\n\n    # One more time, for good measure.\n    self.update_record(\"Schools\", 1, name=\"Eureka\")\n    self.assertTableData(\"Students\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"schoolName\", \"schoolsCount\", \"oneSchool\",    \"oneSchoolName\"],\n      [1,     \"Columbia\",   1,              SchoolsRec(2),  \"Columbia\"],\n      [3,     \"Columbia\",   1,              SchoolsRec(2),  \"Columbia\"],\n      [5,     \"Eureka\",     1,              SchoolsRec(1),  \"Eureka\"],\n    ])\n\n  def test_vlookup(self):\n    self.load_sample(testsamples.sample_students)\n    self.add_column(\"Students\", \"school\", formula=\"VLOOKUP(Schools, name=$schoolName)\")\n    self.add_column(\"Students\", \"schoolCity\",\n        formula=\"VLOOKUP(Schools, name=$schoolName).address.city\")\n\n    # A helper for comparing Record objects below.\n    schools_table = self.engine.tables['Schools']\n    def SchoolsRec(row_id):\n      return schools_table.Record(row_id, None)\n\n    # We'll play with schools \"Columbia\" and \"Eureka\", which are rows 1,3,5 in the Students table.\n    self.assertTableData(\"Students\", cols=\"subset\", rows=\"all\", data=[\n      [\"id\",  \"schoolName\", \"school\",       \"schoolCity\"],\n      [1,     \"Columbia\",   SchoolsRec(1),  \"New York\"  ],\n      [2,     \"Yale\",       SchoolsRec(3),  \"New Haven\" ],\n      [3,     \"Columbia\",   SchoolsRec(1),  \"New York\"  ],\n      [4,     \"Yale\",       SchoolsRec(3),  \"New Haven\" ],\n      [5,     \"Eureka\",     SchoolsRec(0),  \"\"          ],\n      [6,     \"Yale\",       SchoolsRec(3),  \"New Haven\" ],\n    ])\n\n    # Now change some values which should trigger recomputations.\n    self.update_record(\"Schools\", 1, name=\"Eureka\")\n    self.update_record(\"Students\", 2, schoolName=\"Unknown\")\n\n    self.assertTableData(\"Students\", cols=\"subset\", rows=\"all\", data=[\n      [\"id\",  \"schoolName\", \"school\",       \"schoolCity\"],\n      [1,     \"Columbia\",   SchoolsRec(2),  \"Colombia\"  ],\n      [2,     \"Unknown\",    SchoolsRec(0),  \"\"          ],\n      [3,     \"Columbia\",   SchoolsRec(2),  \"Colombia\"  ],\n      [4,     \"Yale\",       SchoolsRec(3),  \"New Haven\" ],\n      [5,     \"Eureka\",     SchoolsRec(1),  \"New York\"  ],\n      [6,     \"Yale\",       SchoolsRec(3),  \"New Haven\" ],\n    ])\n\n  def test_contains(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Source\", [\n          [11, \"choicelist1\", \"ChoiceList\", False, \"\", \"choicelist1\", \"\"],\n          [12, \"choicelist2\", \"ChoiceList\", False, \"\", \"choicelist2\", \"\"],\n          [13, \"text1\",       \"Text\",       False, \"\", \"text1\",       \"\"],\n          [14, \"text2\",       \"Text\",       False, \"\", \"text1\",       \"\"],\n          [15, \"contains1\", \"RefList:Source\", True,\n           \"Source.lookupRecords(choicelist1=CONTAINS($text1))\",\n           \"contains1\", \"\"],\n          [16, \"contains2\", \"RefList:Source\", True,\n           \"Source.lookupRecords(choicelist2=CONTAINS($text2))\",\n           \"contains2\", \"\"],\n          [17, \"contains_both\", \"RefList:Source\", True,\n           \"Source.lookupRecords(choicelist1=CONTAINS($text1), choicelist2=CONTAINS($text2))\",\n           \"contains_both\", \"\"],\n          [17, \"combined\", \"RefList:Source\", True,\n           \"Source.lookupRecords(choicelist1=CONTAINS($text1), text2='x')\",\n           \"combined\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Source\": [\n          [\"id\", \"choicelist1\", \"text1\", \"choicelist2\", \"text2\"],\n          [101,  [\"a\"],         \"a\",     [\"x\"],         \"y\"],\n          [102,  [\"b\"],         \"b\",     [\"y\"],         \"x\"],\n          [103,  [\"a\", \"b\"],    \"c\",     [\"x\", \"y\"],    \"c\"],\n        ]\n      }\n    })\n    self.load_sample(sample)\n\n    self.assertTableData(\"Source\", cols=\"subset\", data=[\n          [\"id\", \"contains1\", \"contains2\", \"contains_both\", \"combined\"],\n          [101,  [101, 103],  [102, 103],  [103],           []],\n          [102,  [102, 103],  [101, 103],  [103],           [102]],\n          [103,  [],          [],          [],              []],\n    ])\n\n  def test_sort_by(self):\n    self.load_sample(testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [1, \"num\", \"Numeric\", False, \"\", \"\", \"\"],\n          [4, \"is_num\", \"Any\", True,\n           \"isinstance($num, float)\", \"\", \"\"],\n          [2, \"lookup\", \"Any\", True,\n           \"Table1.lookupRecords(sort_by='num').num\", \"\", \"\"],\n          [3, \"lookup_reverse\", \"Any\", True,\n           \"Table1.lookupRecords(sort_by='-num').num\", \"\", \"\"],\n          [5, \"lookup_first\", \"Any\", True,\n           \"Table1.lookupOne().num\", \"\", \"\"],\n          [6, \"lookup_min\", \"Any\", True,\n           \"Table1.lookupOne(sort_by='num').num\", \"\", \"\"],\n          [7, \"lookup_min_num\", \"Any\", True,\n           \"Table1.lookupOne(is_num=True, sort_by='num').num\", \"\", \"\"],\n          [8, \"lookup_max\", \"Any\", True,\n           \"Table1.lookupOne(sort_by='-num').num\", \"\", \"\"],\n          [9, \"lookup_max_num\",\n           \"Any\", True,\n           \"Table1.lookupOne(is_num=True, sort_by='-num').num\", \"\", \"\"],\n\n          [10, \"lookup_2a\", \"Any\", True,\n           \"Table1.lookupRecords(order_by=('is_num', 'num')).num\", \"\", \"\"],\n          [10, \"lookup_2b\", \"Any\", True,\n           \"Table1.lookupRecords(order_by=('is_num', '-num')).num\", \"\", \"\"],\n          [10, \"lookup_2c\", \"Any\", True,\n           \"Table1.lookupRecords(order_by=('-is_num', 'num')).num\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"num\"],\n          [1, 2],\n          [2, 1],\n          [3, 'foo'],\n          [4, 3],\n          [5, None],\n          [6, 0],\n        ]\n      }\n    }))\n\n    self.assertTableData(\n      \"Table1\", cols=\"subset\", rows=\"subset\", data=[\n        [\"id\",\n         \"lookup\",\n         \"lookup_reverse\",\n         \"lookup_first\",\n         \"lookup_min\", \"lookup_min_num\",\n         \"lookup_max\", \"lookup_max_num\",\n         \"lookup_2a\", \"lookup_2b\", \"lookup_2c\"],\n        [1,\n         [None, 0, 1, 2, 3, 'foo'],\n         ['foo', 3, 2, 1, 0, None],\n         2,  # lookup_first: first record (by id)\n         None, 0,  # lookup_min[_num]\n         'foo', 3,  # lookup_max[_num]\n        [None, 'foo', 0, 1, 2, 3],   # lookup_2a ('is_num', 'num')\n        ['foo', None, 3, 2, 1, 0],   # lookup_2b ('is_num', '-num')\n        [0, 1, 2, 3, None, 'foo'],   # lookup_2c ('-is_num', 'num')\n        ]\n      ])\n\n    # Ensure that changes in values used for sorting result in updates,\n    # and produce correctly sorted updates.\n    self.update_record(\"Table1\", 2, num=100)\n    self.assertTableData(\n      \"Table1\", cols=\"subset\", rows=\"subset\", data=[\n        [\"id\",\n         \"lookup\",\n         \"lookup_reverse\",\n         \"lookup_first\",\n         \"lookup_min\", \"lookup_min_num\",\n         \"lookup_max\", \"lookup_max_num\",\n         \"lookup_2a\", \"lookup_2b\", \"lookup_2c\"],\n        [1,\n         [None, 0, 2, 3, 100, 'foo'],\n         ['foo', 100, 3, 2, 0, None],\n         2,  # lookup_first: first record (by id)\n         None, 0,  # lookup_min[_num]\n         'foo', 100,  # lookup_max[_num]\n        [None, 'foo', 0, 2, 3, 100],   # lookup_2a ('is_num', 'num')\n        ['foo', None, 100, 3, 2, 0],   # lookup_2b ('is_num', '-num')\n        [0, 2, 3, 100, None, 'foo'],   # lookup_2c ('-is_num', 'num')\n        ]\n      ])\n\n  def test_conversion(self):\n    # Test that values are converted to the type of the column when looking up\n    # i.e. '123' is converted to 123\n    # and 'foo' is converted to AltText('foo')\n    self.load_sample(testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [1, \"num\", \"Numeric\", False, \"\", \"\", \"\"],\n          [2, \"lookup1\", \"RefList:Table1\", True, \"Table1.lookupRecords(num='123')\", \"\", \"\"],\n          [3, \"lookup2\", \"RefList:Table1\", True, \"Table1.lookupRecords(num='foo')\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"num\"],\n          [1,    123],\n          [2,    'foo'],\n        ]\n      }\n    }))\n\n    self.assertTableData(\n      \"Table1\", data=[\n        [\"id\", \"num\", \"lookup1\", \"lookup2\"],\n        [1,    123,   [1],       [2]],\n        [2,    'foo', [1],       [2]],\n      ])\n"
  },
  {
    "path": "sandbox/grist/test_match_counter.py",
    "content": "import random\nimport string\nimport timeit\nimport unittest\n\nfrom collections.abc import Hashable  # pylint:disable-all\n\nimport match_counter\nfrom testutil import repeat_until_passes\n\n# Here's an alternative implementation. Unlike the simple one, it never constructs a new data\n# structure, or modifies dictionary keys while iterating, but it is still slower.\nclass MatchCounterOther(object):\n  def __init__(self, _sample):\n    self.sample_counts = {v: 0 for v in _sample}\n\n  def count_unique(self, iterable):\n    for v in iterable:\n      try:\n        n = self.sample_counts.get(v)\n        if n is not None:\n          self.sample_counts[v] = n + 1\n      except TypeError:\n        pass\n\n    matches = 0\n    for v, n in self.sample_counts.items():\n      if n > 0:\n        matches += 1\n        self.sample_counts[v] = 0\n    return matches\n\n\n# If not for dealing with unhashable errors, `.intersection(iterable)` would be by far the\n# fastest. But with the extra iteration and especially checking for Hashable, it's super slow.\nclass MatchCounterIntersection(object):\n  def __init__(self, _sample):\n    self.sample = set(_sample)\n\n  def count_unique(self, iterable):\n    return len(self.sample.intersection(v for v in iterable if isinstance(v, Hashable)))\n\n\n# This implementation doesn't measure the intersection, but it's interesting to compare its\n# timings: this is still slower! Presumably because set intersection is native code that's more\n# optimized than checking membership many times from Python.\nclass MatchCounterSimple(object):\n  def __init__(self, _sample):\n    self.sample = set(_sample)\n\n  def count_all(self, iterable):\n    return sum(1 for r in iterable if present(r, self.sample))\n\n# This is much faster than using `isinstance(v, Hashable) and v in value_set`\ndef present(v, value_set):\n  try:\n    return v in value_set\n  except TypeError:\n    return False\n\n\n# Set up a predictable random number generator.\nr = random.Random(17)\n\ndef random_string():\n  length = r.randint(10,20)\n  return ''.join(r.choice(string.ascii_letters) for x in range(length))\n\ndef sample_with_repl(population, n):\n  return [r.choice(population) for x in range(n)]\n\n# Here's some sample generated data.\nsample = [random_string() for x in range(200)]\ndata1 = sample_with_repl([random_string() for x in range(20)] + r.sample(sample, 5), 1000)\ndata2 = sample_with_repl([random_string() for x in range(100)] + r.sample(sample, 15), 500)\n\n# Include an example with an unhashable value, to ensure all implementation can handle it.\ndata3 = sample_with_repl([random_string() for x in range(10)] + sample, 2000) + [[1,2,3]]\n\n\nclass TestMatchCounter(unittest.TestCase):\n  def test_match_counter(self):\n    m = match_counter.MatchCounter(sample)\n    self.assertEqual(m.count_unique(data1), 5)\n    self.assertEqual(m.count_unique(data2), 15)\n    self.assertEqual(m.count_unique(data3), 200)\n\n    m = MatchCounterOther(sample)\n    self.assertEqual(m.count_unique(data1), 5)\n    self.assertEqual(m.count_unique(data2), 15)\n    self.assertEqual(m.count_unique(data3), 200)\n    # Do it again to ensure that we clear out state between counting.\n    self.assertEqual(m.count_unique(data1), 5)\n    self.assertEqual(m.count_unique(data2), 15)\n    self.assertEqual(m.count_unique(data3), 200)\n\n    m = MatchCounterIntersection(sample)\n    self.assertEqual(m.count_unique(data1), 5)\n    self.assertEqual(m.count_unique(data2), 15)\n    self.assertEqual(m.count_unique(data3), 200)\n\n    m = MatchCounterSimple(sample)\n    self.assertGreaterEqual(m.count_all(data1), 5)\n    self.assertGreaterEqual(m.count_all(data2), 15)\n    self.assertGreaterEqual(m.count_all(data3), 200)\n\n  @repeat_until_passes(3)\n  def test_timing(self):\n    setup='''\nimport match_counter\nimport test_match_counter as t\nm1 = match_counter.MatchCounter(t.sample)\nm2 = t.MatchCounterOther(t.sample)\nm3 = t.MatchCounterSimple(t.sample)\nm4 = t.MatchCounterIntersection(t.sample)\n'''\n    N = 100\n\n    t1 = min(timeit.repeat(stmt='m1.count_unique(t.data1)', setup=setup, number=N, repeat=3)) / N\n    t2 = min(timeit.repeat(stmt='m2.count_unique(t.data1)', setup=setup, number=N, repeat=3)) / N\n    t3 = min(timeit.repeat(stmt='m3.count_all(t.data1)', setup=setup, number=N, repeat=3)) / N\n    t4 = min(timeit.repeat(stmt='m4.count_unique(t.data1)', setup=setup, number=N, repeat=3)) / N\n    #print \"Timings/iter data1: %.3fus %.3fus %.3fus %.3fus\" % (t1 * 1e6, t2 * 1e6, t3*1e6, t4*1e6)\n\n    self.assertLess(t1, t2)\n    self.assertLess(t1, t3)\n    self.assertLess(t1, t4)\n\n    t1 = min(timeit.repeat(stmt='m1.count_unique(t.data2)', setup=setup, number=N, repeat=3)) / N\n    t2 = min(timeit.repeat(stmt='m2.count_unique(t.data2)', setup=setup, number=N, repeat=3)) / N\n    t3 = min(timeit.repeat(stmt='m3.count_all(t.data2)', setup=setup, number=N, repeat=3)) / N\n    t4 = min(timeit.repeat(stmt='m4.count_unique(t.data2)', setup=setup, number=N, repeat=3)) / N\n    #print \"Timings/iter data2: %.3fus %.3fus %.3fus %.3fus\" % (t1 * 1e6, t2 * 1e6, t3*1e6, t4*1e6)\n    self.assertLess(t1, t2)\n    self.assertLess(t1, t3)\n    self.assertLess(t1, t4)\n\n    t1 = min(timeit.repeat(stmt='m1.count_unique(t.data3)', setup=setup, number=N, repeat=3)) / N\n    t2 = min(timeit.repeat(stmt='m2.count_unique(t.data3)', setup=setup, number=N, repeat=3)) / N\n    t3 = min(timeit.repeat(stmt='m3.count_all(t.data3)', setup=setup, number=N, repeat=3)) / N\n    t4 = min(timeit.repeat(stmt='m4.count_unique(t.data3)', setup=setup, number=N, repeat=3)) / N\n    #print \"Timings/iter data3: %.3fus %.3fus %.3fus %.3fus\" % (t1 * 1e6, t2 * 1e6, t3*1e6, t4*1e6)\n    self.assertLess(t1, t2)\n    #self.assertLess(t1, t3)    # This fails on occasion, but it's a fairly pointless check.\n    self.assertLess(t1, t4)\n\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_migrations.py",
    "content": "import unittest\nimport actions\nimport schema\nimport table_data_set\nimport migrations\n\nclass TestMigrations(unittest.TestCase):\n  def test_migrations(self):\n    tdset = table_data_set.TableDataSet()\n    tdset.apply_doc_actions(schema_version0())\n    migration_actions = migrations.create_migrations(tdset.all_tables)\n    tdset.apply_doc_actions(migration_actions)\n\n    # Compare schema derived from migrations to the current schema.\n    migrated_schema = tdset.get_schema()\n    current_schema = {a.table_id: {c['id']: c for c in a.columns}\n                      for a in schema.schema_create_actions()}\n    # pylint: disable=too-many-nested-blocks\n    if migrated_schema != current_schema:\n      # Figure out the version of new migration to suggest, and whether to update SCHEMA_VERSION.\n      new_version = max(schema.SCHEMA_VERSION, migrations.get_last_migration_version() + 1)\n\n      # Figure out the missing actions.\n      doc_actions = []\n      for table_id in sorted(current_schema.keys() | migrated_schema.keys()):\n        if table_id not in migrated_schema:\n          doc_actions.append(actions.AddTable(table_id, current_schema[table_id].values()))\n        elif table_id not in current_schema:\n          doc_actions.append(actions.RemoveTable(table_id))\n        else:\n          current_cols = current_schema[table_id]\n          migrated_cols = migrated_schema[table_id]\n          for col_id in sorted(current_cols.keys() | migrated_cols.keys()):\n            if col_id not in migrated_cols:\n              doc_actions.append(actions.AddColumn(table_id, col_id, current_cols[col_id]))\n            elif col_id not in current_cols:\n              doc_actions.append(actions.RemoveColumn(table_id, col_id))\n            else:\n              current_info = current_cols[col_id]\n              migrated_info = migrated_cols[col_id]\n              delta = {k: v for k, v in current_info.items() if v != migrated_info.get(k)}\n              if delta:\n                doc_actions.append(actions.ModifyColumn(table_id, col_id, delta))\n\n      suggested_migration = (\n        \"----------------------------------------------------------------------\\n\" +\n        \"*** migrations.py ***\\n\" +\n        \"----------------------------------------------------------------------\\n\" +\n        \"@migration(schema_version=%s)\\n\" % new_version +\n        \"def migration%s(tdset):\\n\" % new_version +\n        \"  return tdset.apply_doc_actions([\\n\" +\n        \"\".join(stringify(a) + \",\\n\" for a in doc_actions) +\n        \"  ])\\n\"\n      )\n\n      if new_version != schema.SCHEMA_VERSION:\n        suggested_schema_update = (\n          \"----------------------------------------------------------------------\\n\" +\n          \"*** schema.py ***\\n\" +\n          \"----------------------------------------------------------------------\\n\" +\n          \"SCHEMA_VERSION = %s\\n\" % new_version\n        )\n      else:\n        suggested_schema_update = \"\"\n\n      self.fail(\"Migrations are incomplete. Suggested migration to add:\\n\" +\n                suggested_schema_update + suggested_migration)\n\ndef stringify(doc_action):\n  if isinstance(doc_action, actions.AddColumn):\n    return '    add_column(%r, %s)' % (doc_action.table_id, col_info_args(doc_action.col_info))\n  elif isinstance(doc_action, actions.AddTable):\n    return ('    actions.AddTable(%r, [\\n' % doc_action.table_id +\n            ''.join('      schema.make_column(%s),\\n' % col_info_args(c)\n                    for c in doc_action.columns) +\n            '    ])')\n  else:\n    return \"    actions.%s(%s)\" % (doc_action.__class__.__name__, \", \".join(map(repr, doc_action)))\n\ndef col_info_args(col_info):\n  extra = \"\"\n  for k in (\"formula\", \"isFormula\"):\n    v = col_info.get(k)\n    if v:\n      extra += \", %s=%r\" % (k, v)\n  return \"%r, %r%s\" % (col_info['id'], col_info['type'], extra)\n\n\ndef schema_version0():\n  # This is the initial version of the schema before the very first migration. It's a historical\n  # snapshot, and thus should not be edited. The test verifies that starting with this v0,\n  # migrations bring the schema to the current version.\n  def make_column(col_id, col_type, formula='', isFormula=False):\n    return { \"id\": col_id, \"type\": col_type, \"isFormula\": isFormula, \"formula\": formula }\n\n  return [\n    actions.AddTable(\"_grist_DocInfo\", [\n      make_column(\"docId\",        \"Text\"),\n      make_column(\"peers\",        \"Text\"),\n      make_column(\"schemaVersion\", \"Int\"),\n    ]),\n    actions.AddTable(\"_grist_Tables\", [\n      make_column(\"tableId\",      \"Text\"),\n    ]),\n    actions.AddTable(\"_grist_Tables_column\", [\n      make_column(\"parentId\",     \"Ref:_grist_Tables\"),\n      make_column(\"parentPos\",    \"PositionNumber\"),\n      make_column(\"colId\",        \"Text\"),\n      make_column(\"type\",         \"Text\"),\n      make_column(\"widgetOptions\",\"Text\"),\n      make_column(\"isFormula\",    \"Bool\"),\n      make_column(\"formula\",      \"Text\"),\n      make_column(\"label\",        \"Text\")\n    ]),\n    actions.AddTable(\"_grist_Imports\", [\n      make_column(\"tableRef\",     \"Ref:_grist_Tables\"),\n      make_column(\"origFileName\", \"Text\"),\n      make_column(\"parseFormula\", \"Text\", isFormula=True,\n                  formula=\"grist.parseImport(rec, table._engine)\"),\n      make_column(\"delimiter\",    \"Text\",     formula=\"','\"),\n      make_column(\"doublequote\",  \"Bool\",     formula=\"True\"),\n      make_column(\"escapechar\",   \"Text\"),\n      make_column(\"quotechar\",    \"Text\",     formula=\"'\\\"'\"),\n      make_column(\"skipinitialspace\", \"Bool\"),\n      make_column(\"encoding\",     \"Text\",     formula=\"'utf8'\"),\n      make_column(\"hasHeaders\",   \"Bool\"),\n    ]),\n    actions.AddTable(\"_grist_External_database\", [\n      make_column(\"host\",         \"Text\"),\n      make_column(\"port\",         \"Int\"),\n      make_column(\"username\",     \"Text\"),\n      make_column(\"dialect\",      \"Text\"),\n      make_column(\"database\",     \"Text\"),\n      make_column(\"storage\",      \"Text\"),\n    ]),\n    actions.AddTable(\"_grist_External_table\", [\n      make_column(\"tableRef\",     \"Ref:_grist_Tables\"),\n      make_column(\"databaseRef\",  \"Ref:_grist_External_database\"),\n      make_column(\"tableName\",    \"Text\"),\n    ]),\n    actions.AddTable(\"_grist_TabItems\", [\n      make_column(\"tableRef\",     \"Ref:_grist_Tables\"),\n      make_column(\"viewRef\",      \"Ref:_grist_Views\"),\n    ]),\n    actions.AddTable(\"_grist_Views\", [\n      make_column(\"name\",         \"Text\"),\n      make_column(\"type\",         \"Text\"),\n      make_column(\"layoutSpec\",   \"Text\"),\n    ]),\n    actions.AddTable(\"_grist_Views_section\", [\n      make_column(\"tableRef\",           \"Ref:_grist_Tables\"),\n      make_column(\"parentId\",           \"Ref:_grist_Views\"),\n      make_column(\"parentKey\",          \"Text\"),\n      make_column(\"title\",              \"Text\"),\n      make_column(\"defaultWidth\",       \"Int\", formula=\"100\"),\n      make_column(\"borderWidth\",        \"Int\", formula=\"1\"),\n      make_column(\"theme\",              \"Text\"),\n      make_column(\"chartType\",          \"Text\"),\n      make_column(\"layoutSpec\",         \"Text\"),\n      make_column(\"filterSpec\",         \"Text\"),\n      make_column(\"sortColRefs\",        \"Text\"),\n      make_column(\"linkSrcSectionRef\",  \"Ref:_grist_Views_section\"),\n      make_column(\"linkSrcColRef\",      \"Ref:_grist_Tables_column\"),\n      make_column(\"linkTargetColRef\",   \"Ref:_grist_Tables_column\"),\n    ]),\n    actions.AddTable(\"_grist_Views_section_field\", [\n      make_column(\"parentId\",     \"Ref:_grist_Views_section\"),\n      make_column(\"parentPos\",    \"PositionNumber\"),\n      make_column(\"colRef\",       \"Ref:_grist_Tables_column\"),\n      make_column(\"width\",        \"Int\"),\n      make_column(\"widgetOptions\",\"Text\"),\n    ]),\n    actions.AddTable(\"_grist_Validations\", [\n      make_column(\"formula\",      \"Text\"),\n      make_column(\"name\",         \"Text\"),\n      make_column(\"tableRef\",     \"Int\")\n    ]),\n    actions.AddTable(\"_grist_REPL_Hist\", [\n      make_column(\"code\",         \"Text\"),\n      make_column(\"outputText\",   \"Text\"),\n      make_column(\"errorText\",    \"Text\")\n    ]),\n    actions.AddTable(\"_grist_Attachments\", [\n      make_column(\"fileIdent\",    \"Text\"),\n      make_column(\"fileName\",     \"Text\"),\n      make_column(\"fileType\",     \"Text\"),\n      make_column(\"fileSize\",     \"Int\"),\n      make_column(\"timeUploaded\", \"DateTime\")\n    ]),\n    actions.AddRecord(\"_grist_DocInfo\", 1, {})\n  ]\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_moment.py",
    "content": "from datetime import datetime, date, timedelta\nimport unittest\nimport moment\n\n# Helpful strftime() format that imcludes all parts of the date including the time zone.\nfmt = \"%Y-%m-%d %H:%M:%S %Z\"\n\nclass TestMoment(unittest.TestCase):\n\n  new_york = [\n    # - 1918 -\n    [datetime(1918, 3, 31, 6, 59, 59), -1633280401000, \"EST\", 300, 1, 59],\n    [datetime(1918, 3, 31, 7, 0, 0), -1633280400000, \"EDT\", 240, 3, 0],\n    [datetime(1918, 10, 27, 5, 59, 59), -1615140001000, \"EDT\", 240, 1, 59],\n    [datetime(1918, 10, 27, 6, 0, 0), -1615140000000, \"EST\", 300, 1, 0],\n    # - 1979 -\n    [datetime(1979, 4, 29, 6, 59, 59), 294217199000, \"EST\", 300, 1, 59],\n    [datetime(1979, 4, 29, 7, 0, 0), 294217200000, \"EDT\", 240, 3, 0],\n    [datetime(1979, 10, 28, 5, 59, 59), 309938399000, \"EDT\", 240, 1, 59],\n    [datetime(1979, 10, 28, 6, 0, 0), 309938400000, \"EST\", 300, 1, 0],\n    # - 2037 -\n    [datetime(2037, 3, 8, 6, 59, 59), 2120108399000, \"EST\", 300, 1, 59],\n    [datetime(2037, 3, 8, 7, 0, 0), 2120108400000, \"EDT\", 240, 3, 0],\n    [datetime(2037, 11, 1, 5, 59, 59), 2140667999000, \"EDT\", 240, 1, 59]\n  ]\n  new_york_errors = [\n    [\"America/New_York\", \"2037-3-8 6:59:59\", TypeError],\n    [\"America/New_York\", [2037, 3, 8, 6, 59, 59], TypeError],\n    [\"America/new_york\", datetime(1979, 4, 29, 6, 59, 59), KeyError]\n  ]\n\n  los_angeles = [\n    # - 1918 -\n    # Spanning non-existent hour\n    [datetime(1918, 3, 31, 1, 59, 59, 0), -1633269601000, \"PST\", 480, 1, 59],\n    [datetime(1918, 3, 31, 2, 0, 0, 0), -1633273200000, \"PST\", 480, 1, 0],\n    [datetime(1918, 3, 31, 2, 59, 59, 0), -1633269601000, \"PST\", 480, 1, 59],\n    [datetime(1918, 3, 31, 3, 0, 0, 0), -1633269600000, \"PDT\", 420, 3, 0],\n    # Spanning doubly-existent hour\n    [datetime(1918, 10, 27, 0, 59, 59, 0), -1615132801000, \"PDT\", 420, 0, 59],\n    [datetime(1918, 10, 27, 1, 0, 0, 0), -1615132800000, \"PDT\", 420, 1, 0],\n    [datetime(1918, 10, 27, 1, 59, 59, 0), -1615129201000, \"PDT\", 420, 1, 59],\n    [datetime(1918, 10, 27, 2, 0, 0, 0), -1615125600000, \"PST\", 480, 2, 0],\n    # - 2008 -\n    # Spanning non-existent hour\n    [datetime(2008, 3, 9, 1, 59, 59, 0), 1205056799000, \"PST\", 480, 1, 59],\n    [datetime(2008, 3, 9, 2, 0, 0, 0), 1205053200000, \"PST\", 480, 1, 0],\n    [datetime(2008, 3, 9, 2, 59, 59, 0), 1205056799000, \"PST\", 480, 1, 59],\n    [datetime(2008, 3, 9, 3, 0, 0, 0), 1205056800000, \"PDT\", 420, 3, 0],\n    # Spanning doubly-existent hour\n    [datetime(2008, 11, 2, 0, 59, 59, 0), 1225612799000, \"PDT\", 420, 0, 59],\n    [datetime(2008, 11, 2, 1, 0, 0, 0), 1225612800000, \"PDT\", 420, 1, 0],\n    [datetime(2008, 11, 2, 1, 59, 59, 0), 1225616399000, \"PDT\", 420, 1, 59],\n    [datetime(2008, 11, 2, 2, 0, 0, 0), 1225620000000, \"PST\", 480, 2, 0],\n    # - 2037 -\n    [datetime(2037, 3, 8, 1, 59, 59, 0), 2120119199000, \"PST\", 480, 1, 59],\n    [datetime(2037, 3, 8, 2, 0, 0, 0), 2120115600000, \"PST\", 480, 1, 0],\n    [datetime(2037, 11, 1, 0, 59, 59, 0), 2140675199000, \"PDT\", 420, 0, 59],\n    [datetime(2037, 11, 1, 1, 0, 0, 0), 2140675200000, \"PDT\", 420, 1, 0],\n  ]\n\n  def assertMatches(self, data_entry, moment_obj):\n    date, timestamp, abbr, offset, hour, minute = data_entry\n    dt        = moment_obj.datetime()\n    self.assertEqual(moment_obj.timestamp, timestamp)\n    self.assertEqual(moment_obj.zoneAbbr(), abbr)\n    self.assertEqual(moment_obj.zoneOffset(), timedelta(minutes=-offset))\n    self.assertEqual(dt.hour, hour)\n    self.assertEqual(dt.minute, minute)\n\n  # For each UTC date, convert to New York time and compare with expected values\n  def test_standard_entry(self):\n    name = \"America/New_York\"\n    data = self.new_york\n    for entry in data:\n      date      = entry[0]\n      timestamp = entry[1]\n      m   = moment.tz(date).tz(name)\n      mts = moment.tz(timestamp, name)\n      self.assertMatches(entry, m)\n      self.assertMatches(entry, mts)\n    error_data = self.new_york_errors\n    for entry in error_data:\n      name  = entry[0]\n      date  = entry[1]\n      error = entry[2]\n      self.assertRaises(error, moment.tz, date, name)\n\n  # For each Los Angeles date, check that the returned date matches expected values\n  def test_zone_entry(self):\n    name = \"America/Los_Angeles\"\n    data = self.los_angeles\n    for entry in data:\n      date      = entry[0]\n      timestamp = entry[1]\n      m         = moment.tz(date, name)\n      self.assertMatches(entry, m)\n\n\n  def test_zone(self):\n    name = \"America/New_York\"\n    tzinfo = moment.tzinfo(name)\n    data = self.new_york\n    for entry in data:\n      date      = entry[0]\n      ts        = entry[1]\n      abbr      = entry[2]\n      offset    = entry[3]\n      dt = moment.tz(ts, name).datetime()\n      self.assertEqual(dt.tzname(), abbr)\n      self.assertEqual(dt.utcoffset(), timedelta(minutes=-offset))\n\n  def test_ts_to_dt(self):\n    # Verify that ts_to_dt works as expected.\n    value_sec = 1426291200      # 2015-03-14 00:00:00 in UTC\n\n    value_dt_utc = moment.ts_to_dt(value_sec, moment.get_zone('UTC'))\n    value_dt_aware = moment.ts_to_dt(value_sec, moment.get_zone('America/New_York'))\n    self.assertEqual(value_dt_utc.strftime(\"%Y-%m-%d %H:%M:%S %Z\"), '2015-03-14 00:00:00 UTC')\n    self.assertEqual(value_dt_aware.strftime(\"%Y-%m-%d %H:%M:%S %Z\"), '2015-03-13 20:00:00 EDT')\n\n  def test_dst_switches(self):\n    # Verify that conversions around DST switches happen correctly. (This is tested in other tests\n    # as well, but this test case is more focused and easier to debug.)\n    dst_before = -1633280401\n    dst_begin  = -1633280400\n    dst_end    = -1615140001\n    dst_after  = -1615140000\n\n    # Should have no surprises in converting to UTC, since there are not DST dfferences.\n    def ts_to_dt_utc(dt):\n      return moment.ts_to_dt(dt, moment.get_zone('UTC'))\n    self.assertEqual(ts_to_dt_utc(dst_before).strftime(fmt), \"1918-03-31 06:59:59 UTC\")\n    self.assertEqual(ts_to_dt_utc(dst_begin ).strftime(fmt), \"1918-03-31 07:00:00 UTC\")\n    self.assertEqual(ts_to_dt_utc(dst_end   ).strftime(fmt), \"1918-10-27 05:59:59 UTC\")\n    self.assertEqual(ts_to_dt_utc(dst_after ).strftime(fmt), \"1918-10-27 06:00:00 UTC\")\n\n    # Converting to America/New_York should produce correct jumps.\n    def ts_to_dt_nyc(dt):\n      return moment.ts_to_dt(dt, moment.get_zone('America/New_York'))\n    self.assertEqual(ts_to_dt_nyc(dst_before).strftime(fmt), \"1918-03-31 01:59:59 EST\")\n    self.assertEqual(ts_to_dt_nyc(dst_begin ).strftime(fmt), \"1918-03-31 03:00:00 EDT\")\n    self.assertEqual(ts_to_dt_nyc(dst_end   ).strftime(fmt), \"1918-10-27 01:59:59 EDT\")\n    self.assertEqual(ts_to_dt_nyc(dst_after ).strftime(fmt), \"1918-10-27 01:00:00 EST\")\n    self.assertEqual(ts_to_dt_nyc(dst_after + 3599).strftime(fmt), \"1918-10-27 01:59:59 EST\")\n\n\n  def test_tzinfo(self):\n    # Verify that tzinfo works correctly.\n    ts1 = 294217199000      # In EST\n    ts2 = 294217200000      # In EDT (spring forward, we skip ahead by 1 hour)\n    utc_dt1 = datetime(1979, 4, 29, 6, 59, 59)\n    utc_dt2 = datetime(1979, 4, 29, 7, 0, 0)\n    self.assertEqual(moment.tz(ts1).datetime().strftime(fmt), '1979-04-29 06:59:59 UTC')\n    self.assertEqual(moment.tz(ts2).datetime().strftime(fmt), '1979-04-29 07:00:00 UTC')\n\n    # Verify that we get correct time zone variation depending on DST status.\n    nyc_dt1 = moment.tz(ts1, 'America/New_York').datetime()\n    nyc_dt2 = moment.tz(ts2, 'America/New_York').datetime()\n    self.assertEqual(nyc_dt1.strftime(fmt), '1979-04-29 01:59:59 EST')\n    self.assertEqual(nyc_dt2.strftime(fmt), '1979-04-29 03:00:00 EDT')\n\n    # Make sure we can get timestamps back from these datatimes.\n    self.assertEqual(moment.dt_to_ts(nyc_dt1)*1000, ts1)\n    self.assertEqual(moment.dt_to_ts(nyc_dt2)*1000, ts2)\n\n    # Verify that the datetime objects we get produce correct time zones in terms of DST when we\n    # manipulate them. NOTE: it is a bit unexpected that we add 1hr + 1sec rather than just 1sec,\n    # but it seems like that is how Python datetime works. Note that timezone does get switched\n    # correctly between EDT and EST.\n    self.assertEqual(nyc_dt1 + timedelta(seconds=3601), nyc_dt2)\n    self.assertEqual(nyc_dt2 - timedelta(seconds=3601), nyc_dt1)\n    self.assertEqual((nyc_dt1 + timedelta(seconds=3601)).strftime(fmt), '1979-04-29 03:00:00 EDT')\n    self.assertEqual((nyc_dt2 - timedelta(seconds=3601)).strftime(fmt), '1979-04-29 01:59:59 EST')\n\n\n  def test_dt_to_ds(self):\n    # Verify that dt_to_ts works for both naive and aware datetime objects.\n    value_dt = datetime(2015, 3, 14, 0, 0)     # In UTC\n    value_sec = 1426291200\n    tzla = moment.get_zone('America/Los_Angeles')\n    def format_utc(ts):\n      return moment.ts_to_dt(ts, moment.get_zone('UTC')).strftime(fmt)\n\n    # Check that a naive datetime is interpreted in UTC.\n    self.assertEqual(value_dt.strftime(\"%Y-%m-%d %H:%M:%S %Z\"), '2015-03-14 00:00:00 ')\n    self.assertEqual(moment.dt_to_ts(value_dt), value_sec)    # Interpreted in UTC\n\n    # Get an explicit UTC version and make sure that also works.\n    value_dt_utc = value_dt.replace(tzinfo=moment.TZ_UTC)\n    self.assertEqual(value_dt_utc.strftime(fmt), '2015-03-14 00:00:00 UTC')\n    self.assertEqual(moment.dt_to_ts(value_dt_utc), value_sec)\n\n    # Get an aware datetime, and make sure that works too.\n    value_dt_aware = moment.ts_to_dt(value_sec, moment.get_zone('America/New_York'))\n    self.assertEqual(value_dt_aware.strftime(fmt), '2015-03-13 20:00:00 EDT')\n    self.assertEqual(moment.dt_to_ts(value_dt_aware), value_sec)\n\n    # Check that dt_to_ts pays attention to the timezone.\n    # If we interpret midnight in LA time, it's a later timestamp.\n    self.assertEqual(format_utc(moment.dt_to_ts(value_dt, tzla)), '2015-03-14 07:00:00 UTC')\n    # The second argument is ignored if the datetime is aware.\n    self.assertEqual(format_utc(moment.dt_to_ts(value_dt_utc, tzla)), '2015-03-14 00:00:00 UTC')\n    self.assertEqual(format_utc(moment.dt_to_ts(value_dt_aware, tzla)), '2015-03-14 00:00:00 UTC')\n\n    # If we modify an aware datetime, we may get a new timezone abbreviation.\n    value_dt_aware -= timedelta(days=28)\n    self.assertEqual(value_dt_aware.strftime(fmt), '2015-02-13 20:00:00 EST')\n\n  def test_date_to_ts(self):\n    d = date(2015, 3, 14)\n    tzla = moment.get_zone('America/Los_Angeles')\n    def format_utc(ts):\n      return moment.ts_to_dt(ts, moment.get_zone('UTC')).strftime(fmt)\n\n    self.assertEqual(format_utc(moment.date_to_ts(d)), '2015-03-14 00:00:00 UTC')\n    self.assertEqual(format_utc(moment.date_to_ts(d, tzla)), '2015-03-14 07:00:00 UTC')\n    self.assertEqual(moment.ts_to_dt(moment.date_to_ts(d, tzla), tzla).strftime(fmt),\n                     '2015-03-14 00:00:00 PDT')\n\n\n  def test_parse_iso(self):\n    tzny = moment.get_zone('America/New_York')\n    iso = moment.parse_iso\n    self.assertEqual(iso('2011-11-11T11:11:11'), 1321009871.000000)\n    self.assertEqual(iso('2019-01-22T00:47:39.219071-05:00'), 1548136059.219071)\n    self.assertEqual(iso('2019-01-22T00:47:39.219071-0500'), 1548136059.219071)\n    self.assertEqual(iso('2019-01-22T00:47:39.219071', timezone=tzny), 1548136059.219071)\n    self.assertEqual(iso('2019-01-22T00:47:39.219071'), 1548118059.219071)\n    self.assertEqual(iso('2019-01-22T00:47:39.219071Z'), 1548118059.219071)\n    self.assertEqual(iso('2019-01-22T00:47:39.219071Z', timezone=tzny), 1548118059.219071)\n    self.assertEqual(iso('2019-01-22T00:47:39.219'), 1548118059.219)\n    self.assertEqual(iso('2019-01-22T00:47:39'), 1548118059)\n    self.assertEqual(iso('2019-01-22 00:47:39.219071'), 1548118059.219071)\n    self.assertEqual(iso('2019-01-22 00:47:39'), 1548118059)\n    self.assertEqual(iso('2019-01-22'), 1548115200)\n\n  def test_parse_iso_date(self):\n    tzny = moment.get_zone('America/New_York')\n    iso = moment.parse_iso_date\n    # Note that time components and time zone do NOT affect the returned timestamp.\n    self.assertEqual(iso('2019-01-22'), 1548115200)\n    self.assertEqual(iso('2019-01-22T00:47:39.219071'), 1548115200)\n    self.assertEqual(iso('2019-01-22 00:47:39Z'), 1548115200)\n    self.assertEqual(iso('2019-01-22T00:47:39.219071-05:00'), 1548115200)\n    self.assertEqual(iso('2019-01-22T00:47:39.219071+05:00'), 1548115200)\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_objtypes.py",
    "content": "import datetime\nimport enum\nimport marshal\nimport unittest\n\nimport objtypes\n\nclass TestObjTypes(unittest.TestCase):\n  class Int(int):\n    pass\n  class Float(float):\n    pass\n  class Text(str):\n    pass\n  class MyEnum(enum.IntEnum):\n    ONE = 1\n  class FussyFloat(float):\n    def __float__(self):\n      raise TypeError(\"Cannot cast FussyFloat to float\")\n\n\n  # (value, expected encoded value, expected decoded value)\n  values = [\n      (17, 17),\n      (-17, -17),\n      (0, 0),\n      # The following is an unmarshallable value.\n      (12345678901234567890, ['U', '12345678901234567890']),\n      (0.0, 0.0),\n      (1e-20, 1e-20),\n      (1e20, 1e20),\n      (1e40, 1e40),\n      (float('infinity'), float('infinity')),\n      (True, True),\n      (Int(5), 5),\n      (MyEnum.ONE, 1),\n      (Float(3.3), 3.3),\n      (Text(\"Hello\"), u\"Hello\"),\n      (datetime.date(2024, 9, 2), ['d', 1725235200.0]),\n      (datetime.datetime(2024, 9, 2, 3, 8, 21), ['D', 1725246501, 'UTC']),\n      # This is also unmarshallable.\n      (FussyFloat(17.0), ['U', '17.0']),\n      # Various other values are unmarshallable too.\n      (len, ['U', '<built-in function len>']),\n      # List, and list with an unmarshallable value.\n      ([Float(6), \"\", MyEnum.ONE], ['L', 6, \"\", 1]),\n      ([Text(\"foo\"), FussyFloat(-0.5)], ['L', \"foo\", ['U', '-0.5']]),\n  ]\n\n  def test_encode_object(self):\n    for (value, expected_encoded) in self.values:\n      encoded = objtypes.encode_object(value)\n\n      # Check that encoding is as expected.\n      self.assertStrictEqual(encoded, expected_encoded, 'encoding of %r' % value)\n\n      # Check it can be round-tripped through marshalling.\n      marshaled = marshal.dumps(encoded)\n      self.assertStrictEqual(marshal.loads(marshaled), encoded, 'de-marshalling of %r' % value)\n\n      # Check that the decoded value, though it may not be identical, encodes identically.\n      decoded = objtypes.decode_object(encoded)\n      re_encoded = objtypes.encode_object(decoded)\n      self.assertStrictEqual(re_encoded, encoded, 're-encoding of %r' % value)\n\n  def assertStrictEqual(self, a, b, msg=None):\n    self.assertEqual(a, b, '%s: %r != %r' % (msg, a, b))\n    self.assertEqual(type(a), type(b), '%s: %r != %r' % (msg, type(a), type(b)))\n\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_predicate_formula.py",
    "content": "# -*- coding: utf-8 -*-\n# pylint:disable=line-too-long\n\nimport unittest\nimport test_engine    # defines self.assertRaisesRegex pylint:disable=unused-import\nfrom predicate_formula import parse_predicate_formula\n\nclass TestPredicateFormula(unittest.TestCase):\n  def test_basic(self):\n    # Test a few basic formulas and structures, hitting everything we expect to support\n    # in ACL formulas and dropdown conditions.\n    self.assertEqual(parse_predicate_formula(\n      \"user.Email == 'X@'\"),\n      [\"Eq\", [\"Attr\", [\"Name\", \"user\"], \"Email\"],\n        [\"Const\", \"X@\"]])\n\n    self.assertEqual(parse_predicate_formula(\n      \"user.Role in ('editors', 'owners')\"),\n      [\"In\", [\"Attr\", [\"Name\", \"user\"], \"Role\"],\n             [\"List\", [\"Const\", \"editors\"], [\"Const\", \"owners\"]]])\n\n    self.assertEqual(parse_predicate_formula(\n      \"user.Role not in ('editors', 'owners')\"),\n      [\"NotIn\", [\"Attr\", [\"Name\", \"user\"], \"Role\"],\n                [\"List\", [\"Const\", \"editors\"], [\"Const\", \"owners\"]]])\n\n    self.assertEqual(parse_predicate_formula(\n      \"rec.office == 'Seattle' and user.email in ['sally@', 'xie@']\"),\n      ['And',\n        ['Eq', ['Attr', ['Name', 'rec'], 'office'], ['Const', 'Seattle']],\n        ['In',\n         ['Attr', ['Name', 'user'], 'email'],\n         ['List', ['Const', 'sally@'], ['Const', 'xie@']]\n        ]])\n\n    self.assertEqual(parse_predicate_formula(\n      \"$office == 'Seattle' and user.email in ['sally@', 'xie@']\"),\n      ['And',\n        ['Eq', ['Attr', ['Name', 'rec'], 'office'], ['Const', 'Seattle']],\n        ['In',\n         ['Attr', ['Name', 'user'], 'email'],\n         ['List', ['Const', 'sally@'], ['Const', 'xie@']]\n        ]])\n\n    self.assertEqual(parse_predicate_formula(\n      \"user.IsAdmin or rec.assigned is None or (not newRec.HasDuplicates and rec.StatusIndex <= newRec.StatusIndex)\"),\n      ['Or',\n        ['Attr', ['Name', 'user'], 'IsAdmin'],\n        ['Is', ['Attr', ['Name', 'rec'], 'assigned'], ['Const', None]],\n        ['And',\n          ['Not', ['Attr', ['Name', 'newRec'], 'HasDuplicates']],\n          ['LtE', ['Attr', ['Name', 'rec'], 'StatusIndex'], ['Attr', ['Name', 'newRec'], 'StatusIndex']]\n        ]\n      ])\n\n    self.assertEqual(parse_predicate_formula(\n      \"user.IsAdmin or $assigned is None or (not newRec.HasDuplicates and $StatusIndex <= newRec.StatusIndex)\"),\n      ['Or',\n        ['Attr', ['Name', 'user'], 'IsAdmin'],\n        ['Is', ['Attr', ['Name', 'rec'], 'assigned'], ['Const', None]],\n        ['And',\n          ['Not', ['Attr', ['Name', 'newRec'], 'HasDuplicates']],\n          ['LtE', ['Attr', ['Name', 'rec'], 'StatusIndex'], ['Attr', ['Name', 'newRec'], 'StatusIndex']]\n        ]\n      ])\n\n    self.assertEqual(parse_predicate_formula(\n      \"r.A <= n.A + 1 or r.A >= n.A - 1 or r.B < n.B * 2.5 or r.B > n.B / 2.5 or r.C % 2 != 0\"),\n      ['Or',\n        ['LtE',\n          ['Attr', ['Name', 'r'], 'A'],\n          ['Add', ['Attr', ['Name', 'n'], 'A'], ['Const', 1]]],\n        ['GtE',\n          ['Attr', ['Name', 'r'], 'A'],\n          ['Sub', ['Attr', ['Name', 'n'], 'A'], ['Const', 1]]],\n        ['Lt',\n          ['Attr', ['Name', 'r'], 'B'],\n          ['Mult', ['Attr', ['Name', 'n'], 'B'], ['Const', 2.5]]],\n        ['Gt',\n          ['Attr', ['Name', 'r'], 'B'],\n          ['Div', ['Attr', ['Name', 'n'], 'B'], ['Const', 2.5]]],\n        ['NotEq',\n          ['Mod', ['Attr', ['Name', 'r'], 'C'], ['Const', 2]],\n          ['Const', 0]]\n      ])\n\n    self.assertEqual(parse_predicate_formula(\n      \"rec.A is True or rec.A is not False\"),\n      ['Or',\n        ['Is', ['Attr', ['Name', 'rec'], 'A'], ['Const', True]],\n        ['IsNot', ['Attr', ['Name', 'rec'], 'A'], ['Const', False]]\n      ])\n\n    self.assertEqual(parse_predicate_formula(\n      \"$A is True or $A is not False\"),\n      ['Or',\n        ['Is', ['Attr', ['Name', 'rec'], 'A'], ['Const', True]],\n        ['IsNot', ['Attr', ['Name', 'rec'], 'A'], ['Const', False]]\n      ])\n\n    self.assertEqual(parse_predicate_formula(\n      \"user.Office.City == 'Seattle' and user.Status.IsActive\"),\n      ['And',\n        ['Eq',\n          ['Attr', ['Attr', ['Name', 'user'], 'Office'], 'City'],\n          ['Const', 'Seattle']],\n        ['Attr', ['Attr', ['Name', 'user'], 'Status'], 'IsActive']\n      ])\n\n    self.assertEqual(parse_predicate_formula(\n      \"True # Comment!  \"),\n      ['Comment', ['Const', True], 'Comment!'])\n\n    self.assertEqual(parse_predicate_formula(\n      \"\\\"#x\\\" == \\\" # Not a comment \\\"#Comment!\"),\n      ['Comment',\n       ['Eq', ['Const', '#x'], ['Const', ' # Not a comment ']],\n       'Comment!'\n      ])\n\n    self.assertEqual(parse_predicate_formula(\n      \"# Allow owners\\nuser.Access == 'owners' # ignored\\n# comment ignored\"),\n      ['Comment',\n       ['Eq', ['Attr', ['Name', 'user'], 'Access'], ['Const', 'owners']],\n       'Allow owners'\n      ])\n\n    self.assertEqual(parse_predicate_formula(\n      \"choice not in $Categories\"),\n      ['NotIn', ['Name', 'choice'], ['Attr', ['Name', 'rec'], 'Categories']])\n\n    self.assertEqual(parse_predicate_formula(\n      \"choice.role == \\\"Manager\\\"\"),\n      ['Eq', ['Attr', ['Name', 'choice'], 'role'], ['Const', 'Manager']])\n\n  def test_calls(self):\n    self.assertEqual(parse_predicate_formula(\n      \"user.Email.lower() == 'foo'\"),\n      ['Eq', ['Call', ['Attr', ['Attr', ['Name', 'user'], 'Email'], 'lower']],\n        ['Const', 'foo']])\n\n    self.assertEqual(parse_predicate_formula(\n      \"rec.First_Name.upper() == 'FOO'.lower()\"),\n      ['Eq', ['Call', ['Attr', ['Attr', ['Name', 'rec'], 'First_Name'], 'upper']],\n        ['Call', ['Attr', ['Const', 'FOO'], 'lower']]])\n\n    # Calls support arbitrary methods and functions for parsing, though very few are implemented\n    # when interpreted.\n    self.assertEqual(parse_predicate_formula(\n      \"func(a.append(5), bar(b), c=1, d=baz(x=3))\"),\n      ['Call', ['Name', 'func'],\n        ['Call', ['Attr', ['Name', 'a'], 'append'], ['Const', 5]],\n        ['Call', ['Name', 'bar'], ['Name', 'b']],\n        ['keywords',\n          ['c', ['Const', 1]],\n          ['d', ['Call', ['Name', 'baz'], ['keywords', ['x', ['Const', 3]]]]]\n        ]\n      ])\n\n    self.assertEqual(parse_predicate_formula(\"max(rec)\"),\n        ['Call', ['Name', 'max'], ['Name', 'rec']])\n\n  def test_unsupported(self):\n    # Test a few constructs we expect to fail\n    # Not an expression\n    self.assertRaises(SyntaxError, parse_predicate_formula, \"return 1\")\n    self.assertRaises(SyntaxError, parse_predicate_formula, \"def foo(): pass\")\n\n    # Unsupported node type\n    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, \"user.id in {1, 2, 3}\")\n    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, \"1 if user.IsAnon else 2\")\n    # Plain calls are now supported for parsing; strange ones are not.\n    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, \"max(*rec)\")\n\n    # Unsupported operation\n    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, \"1 | 2\")\n    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, \"1 << 2\")\n    self.assertRaisesRegex(SyntaxError, r'Unsupported syntax', parse_predicate_formula, \"~test\")\n\n    # Syntax error\n    self.assertRaises(SyntaxError, parse_predicate_formula, \"[(]\")\n    self.assertRaises(SyntaxError, parse_predicate_formula, \"user.id in (1,2))\")\n    self.assertRaisesRegex(SyntaxError, r'invalid syntax on line 1 col 9', parse_predicate_formula, \"foo and !bar\")\n"
  },
  {
    "path": "sandbox/grist/test_prevnext.py",
    "content": "import datetime\nimport functools\nimport itertools\nimport logging\nimport unittest\n\nimport actions\nfrom column import SafeSortKey\nimport moment\nimport objtypes\nimport testutil\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\ndef D(year, month, day):\n  return moment.date_to_ts(datetime.date(year, month, day))\n\n\nclass TestPrevNext(test_engine.EngineTestCase):\n\n  def do_setup(self):\n    self.load_sample(testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Customers\", [\n          [11, \"Name\", \"Text\", False, \"\", \"\", \"\"],\n        ]],\n        [2, \"Purchases\", [\n          [20, \"manualSort\", \"PositionNumber\", False, \"\", \"\", \"\"],\n          [21, \"Customer\", \"Ref:Customers\", False, \"\", \"\", \"\"],\n          [22, \"Date\", \"Date\", False, \"\", \"\", \"\"],\n          [24, \"Category\", \"Text\", False, \"\", \"\", \"\"],\n          [25, \"Amount\", \"Numeric\", False, \"\", \"\", \"\"],\n          [26, \"Prev\", \"Ref:Purchases\", True, \"None\", \"\", \"\"],    # To be filled\n          [27, \"Cumul\", \"Numeric\", True, \"$Prev.Cumul + $Amount\", \"\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"Customers\": [\n          [\"id\", \"Name\"],\n          [1,    \"Alice\"],\n          [2,    \"Bob\"],\n        ],\n        \"Purchases\": [\n          [ \"id\",   \"manualSort\", \"Customer\", \"Date\",       \"Category\", \"Amount\", ],\n          [1,       1.0,          1,          D(2023,12,1), \"A\",        10],\n          [2,       2.0,          2,          D(2023,12,4), \"A\",        17],\n          [3,       3.0,          1,          D(2023,12,3), \"A\",        20],\n          [4,       4.0,          1,          D(2023,12,9), \"A\",        40],\n          [5,       5.0,          1,          D(2023,12,2), \"B\",        80],\n          [6,       6.0,          1,          D(2023,12,6), \"B\",        160],\n          [7,       7.0,          1,          D(2023,12,7), \"A\",        320],\n          [8,       8.0,          1,          D(2023,12,5), \"A\",        640],\n        ],\n      }\n    }))\n\n  def calc_expected(self, group_key=None, sort_key=None, sort_reverse=False):\n    # Returns expected {id, Prev, Cumul} values from Purchases table calculated according to the\n    # given grouping and sorting parameters.\n    group_key = group_key or (lambda r: 0)\n    data = list(actions.transpose_bulk_action(self.engine.fetch_table('Purchases')))\n    expected = []\n    sorted_data = sorted(data, key=sort_key, reverse=sort_reverse)\n    sorted_data = sorted(sorted_data, key=group_key)\n    for key, group in itertools.groupby(sorted_data, key=group_key):\n      prev = 0\n      cumul = 0.0\n      for r in group:\n        cumul = round(cumul + r.Amount, 2)\n        expected.append({\"id\": r.id, \"Prev\": prev, \"Cumul\": cumul})\n        prev = r.id\n    expected.sort(key=lambda r: r[\"id\"])\n    return expected\n\n  def do_test(self, formula, group_key=None, sort_key=None, sort_reverse=False):\n    calc_expected = lambda: self.calc_expected(\n        group_key=group_key, sort_key=sort_key, sort_reverse=sort_reverse)\n\n    def assertPrevValid():\n      # Check that Prev column is legitimate values, e.g. not errors.\n      prev = self.engine.fetch_table('Purchases').columns[\"Prev\"]\n      self.assertTrue(is_all_ints(prev), \"Prev column contains invalid values: %s\" %\n          [objtypes.encode_object(x) for x in prev])\n\n    # This verification works as follows:\n    # (1) Set \"Prev\" column to the specified formula.\n    # (2) Calculate expected values for \"Prev\" and \"Cumul\" manually, and compare to reality.\n    # (3) Try a few actions that affect the data, and calculate again.\n    self.do_setup()\n    self.modify_column('Purchases', 'Prev', formula=formula)\n\n    # Check the initial data.\n    assertPrevValid()\n    self.assertTableData('Purchases', cols=\"subset\", data=calc_expected())\n\n    # Check the result after removing a record.\n    self.remove_record('Purchases', 6)\n    self.assertTableData('Purchases', cols=\"subset\", data=calc_expected())\n\n    # Check the result after updating a record\n    self.update_record('Purchases', 5, Amount=1080)   # original value +1000\n    self.assertTableData('Purchases', cols=\"subset\", data=calc_expected())\n\n    first_date = D(2023, 8, 1)\n\n    # Update a few other records\n    self.update_record(\"Purchases\", 2, Customer=1)\n    self.update_record(\"Purchases\", 1, Customer=2)\n    self.update_record(\"Purchases\", 3, Date=first_date)   # becomes earliest in date order\n    assertPrevValid()\n    self.assertTableData('Purchases', cols=\"subset\", data=calc_expected())\n\n    # Check the result after re-adding a record\n    # Note that Date here matches new date of record #3. This tests sort fallback to rowId.\n    # Amount is the original amount +1.\n    self.add_record('Purchases', 6, manualSort=6.0, Date=first_date, Amount=161)\n    self.assertTableData('Purchases', cols=\"subset\", data=calc_expected())\n\n    # Update the manualSort value to test how it affects sort results.\n    self.update_record('Purchases', 6, manualSort=0.5)\n    self.assertTableData('Purchases', cols=\"subset\", data=calc_expected())\n    assertPrevValid()\n\n  def do_test_prevnext(self, formula, group_key=None, sort_key=None, sort_reverse=False):\n    # Run do_test() AND also repeat it after replacing PREVIOUS with NEXT in formula, and\n    # reversing the expected results.\n\n    # Note that this is a bit fragile: it relies on do_test() being limited to only the kinds of\n    # changes that would be reset by another call to self.load_sample().\n\n    with self.subTest(formula=formula):   # pylint: disable=no-member\n      self.do_test(formula, group_key=group_key, sort_key=sort_key, sort_reverse=sort_reverse)\n\n    nformula = formula.replace('PREVIOUS', 'NEXT')\n    with self.subTest(formula=nformula):  # pylint: disable=no-member\n      self.do_test(nformula, group_key=group_key, sort_key=sort_key, sort_reverse=not sort_reverse)\n\n  def test_prevnext_none(self):\n    self.do_test_prevnext(\"PREVIOUS(rec, order_by=None)\", group_key=None,\n        sort_key=lambda r: r.manualSort)\n\n    # Check that order_by arg is required (get TypeError without it).\n    with self.assertRaisesRegex(AssertionError, r'Prev column contains invalid values:.*TypeError'):\n      self.do_test(\"PREVIOUS(rec)\", sort_key=lambda r: -r.id)\n\n    # These assertions are just to ensure that do_test() tests do exercise the feature being\n    # tested, i.e. fail when comparisons are NOT correct.\n    with self.assertRaisesRegex(AssertionError, r'Observed data not as expected'):\n      self.do_test(\"PREVIOUS(rec, order_by=None)\", sort_key=lambda r: -r.id)\n    with self.assertRaisesRegex(AssertionError, r'Observed data not as expected'):\n      self.do_test(\"PREVIOUS(rec, order_by=None)\", group_key=(lambda r: r.Customer),\n          sort_key=(lambda r: r.id))\n\n    # Make sure the test case above exercises the disambiguation by 'manualSort' (i.e. fails if\n    # 'manualSort' isn't used to disambiguate).\n    with self.assertRaisesRegex(AssertionError, r'Observed data not as expected'):\n      self.do_test(\"PREVIOUS(rec, order_by=None)\", sort_key=lambda r: r.id)\n\n  def test_prevnext_date(self):\n    self.do_test_prevnext(\"PREVIOUS(rec, order_by='Date')\",\n        group_key=None, sort_key=lambda r: (SafeSortKey(r.Date), r.manualSort))\n\n    # Make sure the test case above exercises the disambiguation by 'manualSort' (i.e. fails if it\n    # isn't used to disambiguate).\n    with self.assertRaisesRegex(AssertionError, r'Observed data not as expected'):\n      self.do_test(\"PREVIOUS(rec, order_by='Date')\",\n          group_key=None, sort_key=lambda r: (SafeSortKey(r.Date), r.id))\n\n  def test_prevnext_date_manualsort(self):\n    # Same as the previous test case (with just 'Date'), but specifies 'manualSort' explicitly.\n    self.do_test_prevnext(\"PREVIOUS(rec, order_by=('Date', 'manualSort'))\",\n        group_key=None, sort_key=lambda r: (SafeSortKey(r.Date), r.manualSort))\n\n  def test_prevnext_rdate(self):\n    self.do_test_prevnext(\"PREVIOUS(rec, order_by='-Date')\",\n        group_key=None, sort_key=lambda r: (SafeSortKey(r.Date), -r.manualSort), sort_reverse=True)\n\n  def test_prevnext_rdate_id(self):\n    self.do_test_prevnext(\"PREVIOUS(rec, order_by=('-Date', 'id'))\",\n        group_key=None, sort_key=lambda r: (SafeSortKey(r.Date), -r.id), sort_reverse=True)\n\n  def test_prevnext_customer_rdate(self):\n    self.do_test_prevnext(\"PREVIOUS(rec, group_by=('Customer',), order_by='-Date')\",\n        group_key=(lambda r: r.Customer), sort_key=lambda r: (SafeSortKey(r.Date), -r.id),\n        sort_reverse=True)\n\n  def test_prevnext_category_date(self):\n    self.do_test_prevnext(\"PREVIOUS(rec, group_by=('Category',), order_by='Date')\",\n        group_key=(lambda r: r.Category), sort_key=lambda r: SafeSortKey(r.Date))\n\n  def test_prevnext_category_date2(self):\n    self.do_test_prevnext(\"PREVIOUS(rec, group_by='Category', order_by='Date')\",\n        group_key=(lambda r: r.Category), sort_key=lambda r: SafeSortKey(r.Date))\n\n  def test_prevnext_n_cat_date(self):\n    self.do_test_prevnext(\"PREVIOUS(rec, order_by=('Category', 'Date'))\",\n        sort_key=lambda r: (SafeSortKey(r.Category), SafeSortKey(r.Date)))\n\n  def do_test_renames(self, formula, renamed_formula, calc_expected_pre, calc_expected_post):\n    self.do_setup()\n    self.modify_column('Purchases', 'Prev', formula=formula)\n\n    # Check the initial data.\n    self.assertTableData('Purchases', cols=\"subset\", data=calc_expected_pre())\n\n    # Do the renames\n    self.apply_user_action(['RenameColumn', 'Purchases', 'Category', 'cat'])\n    self.apply_user_action(['RenameColumn', 'Purchases', 'Date', 'Fecha'])\n    self.apply_user_action(['RenameColumn', 'Purchases', 'Customer', 'person'])\n\n    # Check that rename worked.\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      dict(id=26, colId=\"Prev\", formula=renamed_formula)\n    ])\n\n    # Check that data is as expected, and reacts to changes.\n    self.assertTableData('Purchases', cols=\"subset\", data=calc_expected_post())\n\n    self.update_record(\"Purchases\", 1, cat=\"B\")\n    self.assertTableData('Purchases', cols=\"subset\", data=calc_expected_post())\n\n    self.update_record(\"Purchases\", 3, Fecha=D(2023,8,1))\n    self.assertTableData('Purchases', cols=\"subset\", data=calc_expected_post())\n\n  def test_renaming_prev_str(self):\n    self.do_test_renaming_prevnext_str(\"PREVIOUS\")\n\n  def test_renaming_next_str(self):\n    self.do_test_renaming_prevnext_str(\"NEXT\")\n\n  def do_test_renaming_prevnext_str(self, func):\n    # Given some PREVIOUS/NEXT calls with group_by and order_by, rename columns mentioned there,\n    # and check columns get adjusted and data remains correct.\n    formula = \"{}(rec, group_by='Category', order_by='Date')\".format(func)\n    renamed_formula = \"{}(rec, group_by='cat', order_by='Fecha')\".format(func)\n    self.do_test_renames(formula, renamed_formula,\n        calc_expected_pre = functools.partial(self.calc_expected,\n          group_key=(lambda r: r.Category), sort_key=lambda r: SafeSortKey(r.Date),\n          sort_reverse=(func == 'NEXT')\n        ),\n        calc_expected_post = functools.partial(self.calc_expected,\n          group_key=(lambda r: r.cat), sort_key=lambda r: SafeSortKey(r.Fecha),\n          sort_reverse=(func == 'NEXT')\n        ),\n    )\n\n  def test_renaming_prev_tuple(self):\n    self.do_test_renaming_prevnext_tuple('PREVIOUS')\n\n  def test_renaming_next_tuple(self):\n    self.do_test_renaming_prevnext_tuple('NEXT')\n\n  def do_test_renaming_prevnext_tuple(self, func):\n    formula = \"{}(rec, group_by=('Customer',), order_by=('Category', '-Date'))\".format(func)\n    renamed_formula = \"{}(rec, group_by=('person',), order_by=('cat', '-Fecha'))\".format(func)\n\n    # To handle \"-\" prefix for Date.\n    class Reverse(object):\n      def __init__(self, key):\n        self.key = key\n      def __lt__(self, other):\n        return other.key < self.key\n\n    self.do_test_renames(formula, renamed_formula,\n        calc_expected_pre = functools.partial(self.calc_expected,\n          group_key=(lambda r: r.Customer),\n          sort_key=lambda r: (SafeSortKey(r.Category), Reverse(SafeSortKey(r.Date))),\n          sort_reverse=(func == 'NEXT')\n        ),\n        calc_expected_post = functools.partial(self.calc_expected,\n          group_key=(lambda r: r.person),\n          sort_key=lambda r: (SafeSortKey(r.cat), Reverse(SafeSortKey(r.Fecha))),\n          sort_reverse=(func == 'NEXT')\n        ),\n    )\n\n  def test_rank(self):\n    self.do_setup()\n\n    formula = \"RANK(rec, group_by='Category', order_by='Date')\"\n    self.add_column('Purchases', 'Rank', formula=formula)\n    self.assertTableData('Purchases', cols=\"subset\", data=[\n          [ \"id\",   \"Date\",       \"Category\", \"Rank\"],\n          [1,       D(2023,12,1), \"A\",        1     ],\n          [2,       D(2023,12,4), \"A\",        3     ],\n          [3,       D(2023,12,3), \"A\",        2     ],\n          [4,       D(2023,12,9), \"A\",        6     ],\n          [5,       D(2023,12,2), \"B\",        1     ],\n          [6,       D(2023,12,6), \"B\",        2     ],\n          [7,       D(2023,12,7), \"A\",        5     ],\n          [8,       D(2023,12,5), \"A\",        4     ],\n    ])\n    formula = \"RANK(rec, order_by='Date', order='desc')\"\n    self.modify_column('Purchases', 'Rank', formula=formula)\n    self.assertTableData('Purchases', cols=\"subset\", data=[\n          [ \"id\",   \"Date\",       \"Category\", \"Rank\"],\n          [1,       D(2023,12,1), \"A\",        8     ],\n          [2,       D(2023,12,4), \"A\",        5     ],\n          [3,       D(2023,12,3), \"A\",        6     ],\n          [4,       D(2023,12,9), \"A\",        1     ],\n          [5,       D(2023,12,2), \"B\",        7     ],\n          [6,       D(2023,12,6), \"B\",        3     ],\n          [7,       D(2023,12,7), \"A\",        2     ],\n          [8,       D(2023,12,5), \"A\",        4     ],\n    ])\n\n  def test_rank_rename(self):\n    self.do_setup()\n    self.add_column('Purchases', 'Rank',\n        formula=\"RANK(rec, group_by=\\\"Category\\\", order_by='Date')\")\n    self.assertTableData('Purchases', cols=\"subset\", data=[\n          [ \"id\",   \"Date\",       \"Category\", \"Rank\"],\n          [1,       D(2023,12,1), \"A\",        1     ],\n          [2,       D(2023,12,4), \"A\",        3     ],\n          [3,       D(2023,12,3), \"A\",        2     ],\n          [4,       D(2023,12,9), \"A\",        6     ],\n          [5,       D(2023,12,2), \"B\",        1     ],\n          [6,       D(2023,12,6), \"B\",        2     ],\n          [7,       D(2023,12,7), \"A\",        5     ],\n          [8,       D(2023,12,5), \"A\",        4     ],\n    ])\n\n    self.apply_user_action(['RenameColumn', 'Purchases', 'Category', 'cat'])\n    self.apply_user_action(['RenameColumn', 'Purchases', 'Date', 'when'])\n\n    renamed_formula = \"RANK(rec, group_by=\\\"cat\\\", order_by='when')\"\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      dict(id=28, colId=\"Rank\", formula=renamed_formula)\n    ])\n    self.assertTableData('Purchases', cols=\"subset\", data=[\n          [ \"id\",   \"when\",       \"cat\",    \"Rank\"],\n          [1,       D(2023,12,1), \"A\",        1     ],\n          [2,       D(2023,12,4), \"A\",        3     ],\n          [3,       D(2023,12,3), \"A\",        2     ],\n          [4,       D(2023,12,9), \"A\",        6     ],\n          [5,       D(2023,12,2), \"B\",        1     ],\n          [6,       D(2023,12,6), \"B\",        2     ],\n          [7,       D(2023,12,7), \"A\",        5     ],\n          [8,       D(2023,12,5), \"A\",        4     ],\n    ])\n\n  def test_prevnext_rename_result_attr(self):\n    self.do_setup()\n    self.add_column('Purchases', 'PrevAmount', formula=\"PREVIOUS(rec, order_by=None).Amount\")\n    self.add_column('Purchases', 'NextAmount', formula=\"NEXT(rec, order_by=None).Amount\")\n    self.apply_user_action(['RenameColumn', 'Purchases', 'Amount', 'Dollars'])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      dict(id=28, colId=\"PrevAmount\", formula=\"PREVIOUS(rec, order_by=None).Dollars\"),\n      dict(id=29, colId=\"NextAmount\", formula=\"NEXT(rec, order_by=None).Dollars\"),\n    ])\n\n\ndef is_all_ints(array):\n  return all(isinstance(x, int) for x in array)\n"
  },
  {
    "path": "sandbox/grist/test_record_func.py",
    "content": "# pylint: disable=line-too-long\nimport datetime\nimport logging\nimport actions\nimport moment\nimport objtypes\nfrom objtypes import RecordStub\n\nimport testsamples\nimport testutil\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\ndef _bulk_update(table_name, col_names, row_data):\n  return actions.BulkUpdateRecord(\n    *testutil.table_data_from_rows(table_name, col_names, row_data))\n\nclass TestRecordFunc(test_engine.EngineTestCase):\n\n  def test_record_self(self):\n    self.load_sample(testsamples.sample_students)\n    self.add_column(\"Schools\", \"Foo\", formula='RECORD(rec)')\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1,     {'address': RecordStub('Address', 11), 'id': 1, 'name': 'Columbia'}],\n      [2,     {'address': RecordStub('Address', 12), 'id': 2, 'name': 'Columbia'}],\n      [3,     {'address': RecordStub('Address', 13), 'id': 3, 'name': 'Yale'}],\n      [4,     {'address': RecordStub('Address', 14), 'id': 4, 'name': 'Yale'}],\n    ])\n\n    # A change to data is reflected\n    self.update_record(\"Schools\", 3, name=\"UConn\")\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1,     {'address': RecordStub('Address', 11), 'id': 1, 'name': 'Columbia'}],\n      [2,     {'address': RecordStub('Address', 12), 'id': 2, 'name': 'Columbia'}],\n      [3,     {'address': RecordStub('Address', 13), 'id': 3, 'name': 'UConn'}],\n      [4,     {'address': RecordStub('Address', 14), 'id': 4, 'name': 'Yale'}],\n    ])\n\n    # A column addition is reflected\n    self.add_column(\"Schools\", \"Bar\", formula='len($name)')\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1,     {'address': RecordStub('Address', 11), 'Bar': 8, 'id': 1, 'name': 'Columbia'}],\n      [2,     {'address': RecordStub('Address', 12), 'Bar': 8, 'id': 2, 'name': 'Columbia'}],\n      [3,     {'address': RecordStub('Address', 13), 'Bar': 5, 'id': 3, 'name': 'UConn'}],\n      [4,     {'address': RecordStub('Address', 14), 'Bar': 4, 'id': 4, 'name': 'Yale'}],\n    ])\n\n  def test_reference(self):\n    self.load_sample(testsamples.sample_students)\n    self.add_column(\"Schools\", \"Foo\", formula='RECORD($address)')\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1,     {'city': 'New York', 'id': 11}],\n      [2,     {'city': 'Colombia', 'id': 12}],\n      [3,     {'city': 'New Haven', 'id': 13}],\n      [4,     {'city': 'West Haven', 'id': 14}],\n    ])\n\n    # A change to referenced data is still reflected; try a different kind of change here\n    self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"ciudad\"])\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1,     {'ciudad': 'New York', 'id': 11}],\n      [2,     {'ciudad': 'Colombia', 'id': 12}],\n      [3,     {'ciudad': 'New Haven', 'id': 13}],\n      [4,     {'ciudad': 'West Haven', 'id': 14}],\n    ])\n\n  def test_record_expand_refs(self):\n    self.load_sample(testsamples.sample_students)\n    self.add_column(\"Schools\", \"Foo\", formula='RECORD(rec, expand_refs=1)')\n    self.add_column(\"Address\", \"student\", type=\"Ref:Students\")\n    self.update_record(\"Address\", 12, student=6)\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1, {'address': {'city': 'New York', 'id': 11, 'student': RecordStub(\"Students\", 0)},\n        'id': 1, 'name': 'Columbia'}],\n      [2, {'address': {'city': 'Colombia', 'id': 12, 'student': RecordStub(\"Students\", 6)},\n        'id': 2, 'name': 'Columbia'}],\n      [3, {'address': {'city': 'New Haven', 'id': 13, 'student': RecordStub(\"Students\", 0)},\n        'id': 3, 'name': 'Yale'}],\n      [4, {'address': {'city': 'West Haven', 'id': 14, 'student': RecordStub(\"Students\", 0)},\n        'id': 4, 'name': 'Yale'}],\n    ])\n\n    self.modify_column(\"Schools\", \"Foo\", formula='RECORD(rec, expand_refs=2)')\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1, {'address': {'city': 'New York', 'id': 11, 'student': None},\n        'id': 1, 'name': 'Columbia'}],\n      [2, {'address': {'city': 'Colombia', 'id': 12,\n        'student': {'firstName': 'Gerald', 'schoolName': 'Yale', 'lastName': 'Ford',\n        'schoolCities': 'New Haven:West Haven', 'schoolIds': '3:4', 'id': 6}},\n        'id': 2, 'name': 'Columbia'}],\n      [3, {'address': {'city': 'New Haven', 'id': 13, 'student': None},\n        'id': 3, 'name': 'Yale'}],\n      [4, {'address': {'city': 'West Haven', 'id': 14, 'student': None},\n        'id': 4, 'name': 'Yale'}],\n    ])\n\n  def test_record_date_options(self):\n    self.load_sample(testsamples.sample_students)\n    self.add_column(\"Schools\", \"Foo\", formula='RECORD(rec, expand_refs=1)')\n    self.add_column(\"Address\", \"DT\", type='DateTime')\n    self.add_column(\"Address\", \"D\", type='Date', formula=\"$DT and $DT.date()\")\n    self.update_records(\"Address\", ['id', 'DT'], [\n      [11, 1600000000],\n      [13, 1500000000],\n    ])\n\n    d1 = datetime.datetime(2020, 9, 13, 8, 26, 40, tzinfo=moment.tzinfo('America/New_York'))\n    d2 = datetime.datetime(2017, 7, 13, 22, 40, tzinfo=moment.tzinfo('America/New_York'))\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1, {'address': {'city': 'New York', 'DT': d1, 'id': 11, 'D': d1.date()},\n          'id': 1, 'name': 'Columbia'}],\n      [2, {'address': {'city': 'Colombia', 'DT': None, 'id': 12, 'D': None},\n          'id': 2, 'name': 'Columbia'}],\n      [3, {'address': {'city': 'New Haven', 'DT': d2, 'id': 13, 'D': d2.date()},\n          'id': 3, 'name': 'Yale'}],\n      [4, {'address': {'city': 'West Haven', 'DT': None, 'id': 14, 'D': None},\n          'id': 4, 'name': 'Yale'}],\n    ])\n\n    self.modify_column(\"Schools\", \"Foo\",\n        formula='RECORD(rec, expand_refs=1, dates_as_iso=True)')\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1, {'address': {'city': 'New York', 'DT': d1.isoformat(), 'id': 11, 'D': d1.date().isoformat()},\n          'id': 1, 'name': 'Columbia'}],\n      [2, {'address': {'city': 'Colombia', 'DT': None, 'id': 12, 'D': None},\n          'id': 2, 'name': 'Columbia'}],\n      [3, {'address': {'city': 'New Haven', 'DT': d2.isoformat(), 'id': 13, 'D': d2.date().isoformat()},\n          'id': 3, 'name': 'Yale'}],\n      [4, {'address': {'city': 'West Haven', 'DT': None, 'id': 14, 'D': None},\n          'id': 4, 'name': 'Yale'}],\n    ])\n\n  def test_record_set(self):\n    self.load_sample(testsamples.sample_students)\n    self.add_column(\"Students\", \"schools\", formula='Schools.lookupRecords(name=$schoolName)')\n    self.add_column(\"Students\", \"Foo\", formula='RECORD($schools)')\n    self.assertPartialData(\"Students\", [\"id\", \"Foo\"], [\n      [1, [{'address': RecordStub('Address', 11), 'id': 1, 'name': 'Columbia'},\n           {'address': RecordStub('Address', 12), 'id': 2, 'name': 'Columbia'}]],\n      [2, [{'address': RecordStub('Address', 13), 'id': 3, 'name': 'Yale'},\n           {'address': RecordStub('Address', 14), 'id': 4, 'name': 'Yale'}]],\n      [3, [{'address': RecordStub('Address', 11), 'id': 1, 'name': 'Columbia'},\n           {'address': RecordStub('Address', 12), 'id': 2, 'name': 'Columbia'}]],\n      [4, [{'address': RecordStub('Address', 13), 'id': 3, 'name': 'Yale'},\n           {'address': RecordStub('Address', 14), 'id': 4, 'name': 'Yale'}]],\n      [5, []],\n      [6, [{'address': RecordStub('Address', 13), 'id': 3, 'name': 'Yale'},\n           {'address': RecordStub('Address', 14), 'id': 4, 'name': 'Yale'}]],\n    ])\n\n    # Try a field with filtered lookupRecords result, as an iterable.\n    self.modify_column(\"Students\", \"Foo\",\n        formula='RECORD(s for s in $schools if s.address.city.startswith(\"New\"))')\n    self.assertPartialData(\"Students\", [\"id\", \"Foo\"], [\n      [1, [{'address': RecordStub('Address', 11), 'id': 1, 'name': 'Columbia'}]],\n      [2, [{'address': RecordStub('Address', 13), 'id': 3, 'name': 'Yale'}]],\n      [3, [{'address': RecordStub('Address', 11), 'id': 1, 'name': 'Columbia'}]],\n      [4, [{'address': RecordStub('Address', 13), 'id': 3, 'name': 'Yale'}]],\n      [5, []],\n      [6, [{'address': RecordStub('Address', 13), 'id': 3, 'name': 'Yale'}]],\n    ])\n\n  def test_record_bad_calls(self):\n    self.load_sample(testsamples.sample_students)\n    self.add_column(\"Schools\", \"Foo\", formula='repr(RECORD($name))')\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1, objtypes.RaisedException(ValueError())],\n      [2, objtypes.RaisedException(ValueError())],\n      [3, objtypes.RaisedException(ValueError())],\n      [4, objtypes.RaisedException(ValueError())],\n    ])\n    self.modify_column(\"Schools\", \"Foo\", formula='repr(sorted(RECORD(rec if $id == 2 else $id).items()))')\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1, objtypes.RaisedException(ValueError())],\n      [2, \"[('address', Address[12]), ('id', 2), ('name', 'Columbia')]\"],\n      [3, objtypes.RaisedException(ValueError())],\n      [4, objtypes.RaisedException(ValueError())],\n    ])\n    self.assertEqual(str(self.engine.get_formula_error('Schools', 'Foo', 1).error),\n        'RECORD() requires a Record or an iterable of Records')\n\n  def test_record_error_cells(self):\n    self.load_sample(testsamples.sample_students)\n    self.add_column(\"Schools\", \"Foo\", formula='RECORD($address)')\n    self.add_column(\"Address\", \"Bar\", formula='$id//($id%2)')\n    self.assertPartialData(\"Schools\", [\"id\", \"Foo\"], [\n      [1,     {'city': 'New York', 'Bar': 11, 'id': 11}],\n      [2,     {'city': 'Colombia', 'Bar': None, 'id': 12,\n              '_error_': {'Bar': 'ZeroDivisionError: integer division or modulo by zero'}}],\n      [3,     {'city': 'New Haven', 'Bar': 13, 'id': 13}],\n      [4,     {'city': 'West Haven', 'Bar': None, 'id': 14,\n              '_error_': {'Bar': 'ZeroDivisionError: integer division or modulo by zero'}}],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_recordlist.py",
    "content": "import unittest\nimport testutil\nimport test_engine\nfrom objtypes import RecordSetStub\n\n\nclass TestRecordList(test_engine.EngineTestCase):\n  col = testutil.col_schema_row\n  sample_desc = {\n    \"SCHEMA\": [\n      [1, \"Creatures\", [\n        col(1, \"Name\",   \"Text\", False),\n        col(2, \"Class\",  \"Ref:Class\", False),\n      ]],\n      [2, \"Class\", [\n        col(11, \"Name\",       \"Text\",               False),\n        col(12, \"Creatures\",  \"RefList:Creatures\",  False),\n      ]],\n    ],\n    \"DATA\": {\n      \"Class\": [\n        [\"id\", \"Name\", \"Creatures\"],\n        [1, \"Mammals\",  [1, 3]],\n        [2, \"Reptilia\", [2, 4]],\n      ],\n      \"Creatures\": [\n        [\"id\",\"Name\",    \"Class\"],\n        [1,   \"Cat\",     1],\n        [2,   \"Chicken\", 2],\n        [3,   \"Dolphin\", 1],\n        [4,   \"Turtle\",  2],\n      ],\n    }\n  }\n  sample = testutil.parse_test_sample(sample_desc)\n\n  def test_removals(self):\n    # Removing target rows should remove them from RefList columns.\n    self.load_sample(self.sample)\n    self.assertTableData(\"Class\", data=[\n      [\"id\", \"Name\", \"Creatures\"],\n      [1, \"Mammals\",  [1, 3]],\n      [2, \"Reptilia\", [2, 4]],\n    ])\n\n    self.remove_record(\"Creatures\", 2)\n    self.assertTableData(\"Class\", data=[\n      [\"id\", \"Name\", \"Creatures\"],\n      [1, \"Mammals\",  [1, 3]],\n      [2, \"Reptilia\", [4]],\n    ])\n\n    self.remove_record(\"Creatures\", 4)\n    self.assertTableData(\"Class\", data=[\n      [\"id\", \"Name\", \"Creatures\"],\n      [1, \"Mammals\",  [1, 3]],\n      [2, \"Reptilia\", None]\n    ])\n\n\n  def test_contains(self):\n    self.load_sample(self.sample)\n    self.add_column('Class', 'ContainsInt', type='Any', isFormula=True,\n        formula=\"2 in $Creatures\")\n    self.add_column('Class', 'ContainsRec', type='Any', isFormula=True,\n        formula=\"Creatures.lookupOne(Name='Chicken') in $Creatures\")\n    self.add_column('Class', 'ContainsWrong', type='Any', isFormula=True,\n        formula=\"Class.lookupOne(Name='Reptilia') in $Creatures\")\n\n    self.assertTableData(\"Class\", data=[\n      [\"id\", \"Name\", \"Creatures\", \"ContainsInt\", \"ContainsRec\", \"ContainsWrong\"],\n      [1, \"Mammals\",  [1, 3],     False,          False,        False],\n      [2, \"Reptilia\", [2, 4],     True,           True,         False]\n    ])\n\n\n  def test_equals(self):\n    self.load_sample(self.sample)\n    self.add_column('Class', 'Lookup', type='RefList:Creatures', isFormula=True,\n        formula=\"Creatures.lookupRecords(Class=$id)\")\n    self.add_column('Class', 'Equal', type='Any', isFormula=True,\n        formula=\"$Lookup == $Creatures\")\n\n    self.assertTableData(\"Class\", data=[\n      [\"id\", \"Name\", \"Creatures\", \"Lookup\", \"Equal\"],\n      [1, \"Mammals\",  [1, 3],     [1, 3],   True],\n      [2, \"Reptilia\", [2, 4],     [2, 4],   True],\n    ])\n\n  def test_attribute_chain(self):\n    self.load_sample(self.sample)\n    self.add_column('Class', 'Names', type='Any', isFormula=True,\n        formula=\"$Creatures.Class.Name\")\n    self.add_column('Class', 'Creatures2', type='Any', isFormula=True,\n        formula=\"$Creatures.Class.Creatures\")\n    self.add_column('Class', 'Creatures3', type='RefList:Creatures', isFormula=True,\n        formula=\"$Creatures.Class.Creatures\")\n\n    # Test that it works for empty lookups too.\n    self.add_record('Class', Name=\"Dragons\", Creatures=None)\n\n    mammals = RecordSetStub(\"Creatures\", [1, 3])\n    reptiles = RecordSetStub(\"Creatures\", [2, 4])\n    dragons = RecordSetStub(\"Creatures\", [])\n    self.assertTableData(\"Class\", data=[\n      [\"id\", \"Name\",     \"Creatures\", \"Names\",                  \"Creatures2\", \"Creatures3\"],\n      [1,    \"Mammals\",  [1, 3],      [\"Mammals\", \"Mammals\"],   mammals,      [1, 3]],\n      [2,    \"Reptilia\", [2, 4],      [\"Reptilia\", \"Reptilia\"], reptiles,     [2, 4]],\n      [3,    \"Dragons\",  None,        [],                       dragons,      []],\n    ])\n\n  def test_lookup_attribute_chain(self):\n    self.load_sample(self.sample)\n    self.add_record('Class', Name=\"Dragons\", Creatures=None)\n    self.add_column('Creatures', 'CName', isFormula=True, formula=\"$Class.Name\")\n    self.add_column('Class', 'LookupAttrType', isFormula=True,\n        formula=\"type(Creatures.lookupRecords(CName=$Name).Class).__name__\")\n    self.add_column('Class', 'Lookup', isFormula=True,\n        formula=\"Creatures.lookupRecords(CName=$Name).Class.Name\")\n\n    self.assertTableData(\"Class\", data=[\n      [\"id\", \"Name\",     \"Creatures\", \"LookupAttrType\", \"Lookup\"],\n      [1,    \"Mammals\",  [1, 3],      \"RecordSet\",      [\"Mammals\", \"Mammals\"]],\n      [2,    \"Reptilia\", [2, 4],      \"RecordSet\",      [\"Reptilia\", \"Reptilia\"]],\n      [3,    \"Dragons\",  None,        \"RecordSet\",      []],\n    ])\n\n  def test_reflist_attribute_chain(self):\n    self.load_sample(self.sample)\n    # Include an empty class\n    self.add_record('Class', Name=\"Dragons\", Creatures=None)\n    self.add_column('Creatures', 'AllInClass', type='RefList:Creatures', isFormula=True,\n        formula=\"$Class.Creatures\")\n    self.add_column('Class', 'ListOfRefLists', type='RefList:Creatures', isFormula=True,\n        formula=\"$Creatures.AllInClass\")\n    self.add_column('Class', 'LRLAny', type='Any', isFormula=True,\n        formula=\"$Creatures.AllInClass\")\n    self.add_column('Class', 'Chain', isFormula=True,\n        formula=\"$Creatures.AllInClass.Name\")\n\n    mammals = RecordSetStub(\"Creatures\", [1, 3])\n    reptiles = RecordSetStub(\"Creatures\", [2, 4])\n    dragons = RecordSetStub(\"Creatures\", [])\n    self.assertTableData(\"Class\", data=[\n      [\"id\", \"Name\",     \"Creatures\", \"ListOfRefLists\", \"LRLAny\",   \"Chain\"],\n      [1,    \"Mammals\",  [1, 3],      [1, 3],           mammals,    [\"Cat\", \"Dolphin\"]],\n      [2,    \"Reptilia\", [2, 4],      [2, 4],           reptiles,   [\"Chicken\", \"Turtle\"]],\n      [3,    \"Dragons\",  None,        [],               dragons,    []],\n    ])\n\n  def test_flattens_lookups_in_reflist(self):\n    # Add table Users with column Name\n    self.apply_user_action([\"AddTable\", \"Users\", [\n      {\"id\": \"Name\", \"type\": \"Text\"},\n      # People who liked my posts\n      {\"id\": \"Likes\", \"type\": \"RefList:Users\"},\n    ]])\n\n    # Add table Posts with column Title, Owner and Likes (of type RefList:Users)\n    self.apply_user_action([\"AddTable\", \"Posts\", [\n      {\"id\": \"Title\", \"type\": \"Text\"},\n      {\"id\": \"Owner\", \"type\": \"Ref:Users\"},\n      {\"id\": \"Likes\", \"type\": \"RefList:Users\"},\n      {\"id\": \"ByAuthor\", \"type\": \"RefList:Posts\", \"isFormula\": True,\n        \"formula\": \"Posts.lookupRecords(Owner=$Owner)\"}\n    ]])\n\n    # Add 3 users.\n    self.apply_user_action([\"BulkAddRecord\", \"Users\", [None]*3,\n      {\"Name\": [\"Alice\", \"Bob\", \"Charlie\"]}])\n\n    Alice = 1\n    Bob = 2\n    Charlie = 3\n\n    # Add 2 posts, first one liked by Alice and Bob, second one liked by Bob and Charlie, in\n    # same category\n    self.apply_user_action([\"BulkAddRecord\", \"Posts\", [None]*2,\n      {\n        \"Title\": [\"Post1\", \"Post2\"],\n        \"Owner\": [Alice, Alice],\n        \"Likes\": [[\"L\", Bob, Charlie], [\"L\", Charlie, Bob, Alice]]\n      }\n    ])\n\n    # Make sure data is ok\n    self.assertTableData(\"Posts\", cols=\"subset\", data=[\n      [\"id\", \"Title\", \"Owner\", \"Likes\"],\n      [1, \"Post1\", Alice, [Bob, Charlie]],\n      [2, \"Post2\", Alice, [Charlie, Bob, Alice]],\n    ])\n\n    # Now change Like column in the Users table to formula column that grabs all people who liked\n    # posts of the user.\n    self.apply_user_action([\"ModifyColumn\", \"Users\", \"Likes\", {\n      \"isFormula\": True,\n      \"formula\": \"Posts.lookupRecords(Owner=$id, order_by=\\\"Title\\\").Likes\"\n    }])\n\n    # Check the data, make sure the order is correct and we don't have duplicates.\n    self.assertTableData(\"Users\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Likes\"],\n      [Alice, \"Alice\", [Bob, Charlie, Alice]],\n      [Bob, \"Bob\", []],\n      [Charlie, \"Charlie\", []],\n    ])\n\n    # Now order it in descending order by Name.\n    self.apply_user_action([\"ModifyColumn\", \"Users\", \"Likes\", {\n      \"isFormula\": True,\n      \"formula\": \"Posts.lookupRecords(Owner=$id, order_by=\\\"-Title\\\").Likes\"\n    }])\n\n    # Check the data, make sure the order is correct.\n    self.assertTableData(\"Users\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Likes\"],\n      [Alice, \"Alice\", [Charlie, Bob, Alice]], # First likes from Post2, then from Post1\n      [Bob, \"Bob\", []],\n      [Charlie, \"Charlie\", []],\n    ])\n\n    # Now reorder the lookup by swapping the order of the posts.\n    self.apply_user_action([\"BulkUpdateRecord\", \"Posts\", [1, 2], {\"Title\": [\"Post2\", \"Post1\"]}])\n\n    # Check the data, make sure the order is correct.\n    self.assertTableData(\"Users\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Likes\"],\n      [Alice, \"Alice\", [Bob, Charlie, Alice]], # First likes from Post1, then from Post2\n      [Bob, \"Bob\", []],\n      [Charlie, \"Charlie\", []],\n    ])\n\n    # Now switch back to the original order by setting Post1 to Post3.\n    self.update_record(\"Posts\", 2, Title=\"Post3\")\n\n    # Check the data, make sure the order is correct.\n    self.assertTableData(\"Users\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Likes\"],\n      [Alice, \"Alice\", [Charlie, Bob, Alice]], # First likes from Post2, then from Post3\n      [Bob, \"Bob\", []],\n      [Charlie, \"Charlie\", []],\n    ])\n\n    # Now modify the formula so that it contains other records, not Users.\n    self.apply_user_action([\"ModifyColumn\", \"Users\", \"Likes\", {\n      \"isFormula\": True,\n      \"formula\": \"Posts.lookupRecords(Owner=$id).ByAuthor\"\n    }])\n\n    self.assertTableData(\"Users\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Likes\"],\n      [Alice, \"Alice\", \"Posts[[1, 2]]\"],\n      [Bob, \"Bob\", \"Posts[[]]\"],\n      [Charlie, \"Charlie\", \"Posts[[]]\"],\n    ])\n\n  def test_ref_to_reflist_conversion(self):\n    self.load_sample(self.sample)\n    # If a RefList column is set to a matching Ref value, it should get turned into a list.\n    self.add_column(\"Creatures\", \"ClassList1\", type=\"RefList:Class\",\n        isFormula=True, formula=\"$Class.id\")\n    self.add_column(\"Creatures\", \"ClassList2\", type=\"RefList:Class\",\n        isFormula=True, formula=\"$Class\")\n    # This one has the wrong RefList type, it shouldn't be auto-converted.\n    self.add_column(\"Creatures\", \"ClassList3\", type=\"RefList:Creatures\",\n        isFormula=True, formula=\"$Class\")\n\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Class\", \"ClassList1\", \"ClassList2\", \"ClassList3\" ],\n      [1,   \"Cat\",     1,       [1],          [1],          \"Class[1]\"   ],\n      [2,   \"Chicken\", 2,       [2],          [2],          \"Class[2]\"   ],\n      [3,   \"Dolphin\", 1,       [1],          [1],          \"Class[1]\"   ],\n      [4,   \"Turtle\",  2,       [2],          [2],          \"Class[2]\"   ],\n    ])\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_reflist_rel.py",
    "content": "import json\nimport logging\nimport unittest\nimport test_engine\nfrom test_engine import Table, Column\n\nlog = logging.getLogger(__name__)\n\nclass TestRefListRelation(test_engine.EngineTestCase):\n  def test_ref_list_relation(self):\n    \"\"\"\n    This test replicates a bug involving a column conversion after a table rename in the presence of\n    a RefList. A RefList column type today only appears as a result of detaching a summary table.\n    \"\"\"\n    # Create two tables, the second referring to the first using a RefList and a Ref column.\n    self.apply_user_action([\"AddTable\", \"TableA\", [\n      {\"id\": \"ColA\", \"type\": \"Text\"}\n    ]])\n    self.apply_user_action([\"AddTable\", \"TableB\", [\n      {\"id\": \"ColB\", \"type\": \"Text\"},\n      {\"id\": \"group\", \"type\": \"RefList:TableA\", \"isFormula\": True,\n        \"formula\": \"TableA.lookupRecords(ColA=$ColB)\"},\n      {\"id\": \"ref\", \"type\": \"Ref:TableA\", \"isFormula\": True,\n        \"formula\": \"TableA.lookupOne(ColA=$ColB)\"},\n    ]])\n\n    # Populate the tables with some data.\n    self.apply_user_action([\"BulkAddRecord\", \"TableA\", [None]*4,\n      {\"ColA\": [\"a\", \"b\", \"c\", \"d\"]}])\n    self.apply_user_action([\"BulkAddRecord\", \"TableB\", [None]*3,\n      {\"ColB\": [\"d\", \"b\", \"a\"]}])\n\n    # Rename the second table. This causes some Column objects to be re-created and copied from\n    # the previous table instance. This logic had a bug.\n    self.apply_user_action([\"RenameTable\", \"TableB\", \"TableC\"])\n\n    # Let's see what we've set up here.\n    self.assertTables([\n      Table(1, \"TableA\", 1, 0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(2, \"ColA\",   \"Text\", False, \"\", 0),\n      ]),\n      Table(2, \"TableC\", 2, 0, columns=[\n        Column(3, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(4, \"ColB\",   \"Text\", False, \"\", 0),\n        Column(5, \"group\",  \"RefList:TableA\", True, \"TableA.lookupRecords(ColA=$ColB)\", 0),\n        Column(6, \"ref\",    \"Ref:TableA\", True, \"TableA.lookupOne(ColA=$ColB)\", 0),\n      ]),\n    ])\n    self.assertTableData('TableA', cols=\"subset\", data=[\n      [ \"id\", \"ColA\"],\n      [ 1,    \"a\",  ],\n      [ 2,    \"b\",  ],\n      [ 3,    \"c\",  ],\n      [ 4,    \"d\",  ],\n    ])\n    self.assertTableData('TableC', cols=\"subset\", data=[\n      [ \"id\", \"ColB\", \"group\", \"ref\" ],\n      [ 1,    \"d\",    [4],     4 ],\n      [ 2,    \"b\",    [2],     2 ],\n      [ 3,    \"a\",    [1],     1 ],\n    ])\n\n    # Now when the logic was buggy, this sequence of action, as emitted by a user-initiated column\n    # conversion, triggered an internal exception. Ensure it no longer happens.\n    self.apply_user_action(\n        ['AddColumn', 'TableC', 'gristHelper_Transform', {\n          \"type\": 'Ref:TableA', \"isFormula\": True,\n          \"formula\": \"TableA.lookupOne(ColA=$ColB)\", \"visibleCol\": 2,\n        }])\n    self.apply_user_action(\n        ['SetDisplayFormula', 'TableC', None, 7, '$gristHelper_Transform.ColA'])\n    self.apply_user_action(\n        ['CopyFromColumn', 'TableC', 'gristHelper_Transform', 'ColB', '{\"widget\":\"Reference\"}'])\n    self.apply_user_action(\n        ['RemoveColumn', 'TableC', 'gristHelper_Transform'])\n\n    # Check what we have now.\n    self.assertTables([\n      Table(1, \"TableA\", 1, 0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(2, \"ColA\",   \"Text\", False, \"\", 0),\n      ]),\n      Table(2, \"TableC\", 2, 0, columns=[\n        Column(3, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(4, \"ColB\",   \"Ref:TableA\", False, \"\", 0),\n        Column(5, \"group\",  \"RefList:TableA\", True, \"TableA.lookupRecords(ColA=$ColB)\", 0),\n        Column(6, \"ref\",    \"Ref:TableA\", True, \"TableA.lookupOne(ColA=$ColB)\", 0),\n        Column(9, \"gristHelper_Display2\", \"Any\", True, \"$ColB.ColA\", 0),\n      ]),\n    ])\n    self.assertTableData('TableA', cols=\"subset\", data=[\n      [ \"id\", \"ColA\"],\n      [ 1,    \"a\",  ],\n      [ 2,    \"b\",  ],\n      [ 3,    \"c\",  ],\n      [ 4,    \"d\",  ],\n    ])\n    self.assertTableData('TableC', cols=\"subset\", data=[\n      [ \"id\", \"ColB\", \"gristHelper_Display2\", \"group\", \"ref\" ],\n      [ 1,      4,    \"d\",                    [],     0 ],\n      [ 2,      2,    \"b\",                    [],     0 ],\n      [ 3,      1,    \"a\",                    [],     0 ],\n    ])\n\n\n  def test_ref_list_conversion_from_string(self):\n    \"\"\"\n    RefLists can accept JSON arrays as strings, but only if they look valid.\n    This feature is used by 2 way references, and column renames where type of the column\n    is changed briefly to Int (or other) and the value is converted to string (to represent\n    an error), then when column recovers its type, it should be able to read this string\n    and restore its value\n    \"\"\"\n    self.apply_user_action([\"AddTable\", \"Tree\", [\n      {\"id\": \"Name\", \"type\": \"Text\"},\n      {\"id\": \"Children\", \"type\": \"RefList:Tree\"},\n    ]])\n\n    # Add two records.\n    self.apply_user_action([\"BulkAddRecord\", \"Tree\", [None]*2,\n      {\"Name\": [\"John\", \"Bobby\"]}])\n\n\n    test_literal = lambda x: self.assertTableData('Tree', cols=\"subset\", data=[\n      [ \"id\", \"Name\", \"Children\" ],\n      [ 1,    \"John\", x],\n      [ 2,    \"Bobby\", None ],\n    ])\n\n    invalid_json_arrays = (\n      '[\"Bobby\"]',\n      '[\"2\"]',\n      '[\"2\", \"3\"]',\n      '[-1]',\n      '[\"1\", \"-1\"]',\n      '[0]',\n    )\n\n    for value in invalid_json_arrays:\n      self.apply_user_action(\n        ['UpdateRecord', 'Tree', 1, {'Children': value}]\n      )\n      test_literal(value)\n\n    valid_json_arrays = (\n      '[2]',\n      '[1, 2]',\n      '[100]',\n    )\n\n    for value in valid_json_arrays:\n      # Clear value\n      self.apply_user_action(\n        ['UpdateRecord', 'Tree', 1, {'Children': None}]\n      )\n      self.apply_user_action(\n        ['UpdateRecord', 'Tree', 1, {'Children': value}]\n      )\n      self.assertTableData('Tree', cols=\"subset\", data=[\n        [ \"id\", \"Name\", \"Children\" ],\n        [ 1,    \"John\", json.loads(value) ],\n        [ 2,    \"Bobby\", None ],\n      ])\n\n\n\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_relabeling.py",
    "content": "import unittest\nimport sys\nimport relabeling\n\nfrom sortedcontainers import SortedListWithKey\n\n# Shortcut to keep code more concise.\nr = relabeling\n\ndef skipfloats(x, n):\n  for i in range(n):\n    x = relabeling.nextfloat(x)\n  return x\n\n\nclass Item(object):\n  \"\"\"\n  Tests use Item for items of the sorted lists we maintain.\n  \"\"\"\n  def __init__(self, value, key):\n    self.value = value\n    self.key = key\n\n  def __repr__(self):\n    return \"Item(v=%s,k=%s)\" % (self.value, self.key)\n\n\nclass ItemList(object):\n  def __init__(self, val_key_pairs):\n    self._slist = SortedListWithKey(key=lambda item: item.key)\n    self._slist.update(Item(v, k) for (v, k) in val_key_pairs)\n    self.num_update_events = 0\n    self.num_updated_keys = 0\n\n  def get_values(self):\n    return [item.value for item in self._slist]\n\n  def get_list(self):\n    return self._slist\n\n  def find_value(self, value):\n    return next((item for item in self._slist if item.value == value), None)\n\n  def avg_updated_keys(self):\n    return float(self.num_updated_keys) / len(self._slist)\n\n  def next(self, item):\n    return self._slist[self._slist.index(item) + 1]\n\n  def prev(self, item):\n    return self._slist[self._slist.index(item) - 1]\n\n  def insert_items(self, val_key_pairs, prepare_inserts=r.prepare_inserts):\n    keys = [k for (v, k) in val_key_pairs]\n    adjustments, new_keys = prepare_inserts(self._slist, keys)\n    if adjustments:\n      self.num_update_events += 1\n      self.num_updated_keys += len(adjustments)\n\n    # Updating items is a bit tricky: we have to do it without violating order (just changing\n    # key of an existing item easily might), so we remove items first. And we can only rely on\n    # indices if we scan items in a backwards order.\n    items = [self._slist.pop(index) for (index, key) in reversed(adjustments)]\n    items.reverse()\n    for (index, key), item in zip(adjustments, items):\n      item.key = key\n    self._slist.update(items)\n\n    # Now add the new items.\n    self._slist.update(Item(val, new_key) for (val, _), new_key in zip(val_key_pairs, new_keys))\n\n    # For testing, pass along the return value from prepare_inserts.\n    return adjustments, new_keys\n\n\nclass TestRelabeling(unittest.TestCase):\n\n  def test_nextfloat(self):\n    def verify_nextfloat(x):\n      nx = r.nextfloat(x)\n      self.assertNotEqual(nx, x)\n      self.assertGreater(nx, x)\n      self.assertEqual(r.prevfloat(nx), x)\n      average = (nx + x) / 2\n      self.assertTrue(average == nx or average == x)\n\n    verify_nextfloat(1)\n    verify_nextfloat(-1)\n    verify_nextfloat(417)\n    verify_nextfloat(-417)\n    verify_nextfloat(12312422)\n    verify_nextfloat(-12312422)\n    verify_nextfloat(0.1234)\n    verify_nextfloat(-0.1234)\n    verify_nextfloat(0.00005)\n    verify_nextfloat(-0.00005)\n    verify_nextfloat(0.0)\n    verify_nextfloat(r.nextfloat(0.0))\n    verify_nextfloat(sys.float_info.min)\n    verify_nextfloat(-sys.float_info.min)\n\n  def test_prevfloat(self):\n    def verify_prevfloat(x):\n      nx = r.prevfloat(x)\n      self.assertNotEqual(nx, x)\n      self.assertLess(nx, x)\n      self.assertEqual(r.nextfloat(nx), x)\n      average = (nx + x) / 2\n      self.assertTrue(average == nx or average == x)\n\n    verify_prevfloat(1)\n    verify_prevfloat(-1)\n    verify_prevfloat(417)\n    verify_prevfloat(-417)\n    verify_prevfloat(12312422)\n    verify_prevfloat(-12312422)\n    verify_prevfloat(0.1234)\n    verify_prevfloat(-0.1234)\n    verify_prevfloat(0.00005)\n    verify_prevfloat(-0.00005)\n    verify_prevfloat(r.nextfloat(0.0))\n    verify_prevfloat(sys.float_info.min)\n    verify_prevfloat(-sys.float_info.min)\n\n  def test_range_around_float(self):\n\n    def verify_range(bits, begin, end):\n      self.assertEqual(r.range_around_float(begin, bits), (begin, end))\n      self.assertEqual(r.range_around_float((end + begin) / 2, bits), (begin, end))\n      delta = r.nextfloat(begin) - begin\n      if begin + delta < end:\n        self.assertEqual(r.range_around_float(begin + delta, bits), (begin, end))\n      if end - delta >= begin:\n        self.assertEqual(r.range_around_float(end - delta, bits), (begin, end))\n\n    def verify_small_range_at(begin):\n      verify_range(0, begin, skipfloats(begin, 1))\n      verify_range(1, begin, skipfloats(begin, 2))\n      verify_range(4, begin, skipfloats(begin, 16))\n      verify_range(10, begin, skipfloats(begin, 1024))\n\n    verify_small_range_at(1.0)\n    verify_small_range_at(0.5)\n    verify_small_range_at(0.25)\n    verify_small_range_at(0.75)\n    verify_small_range_at(17.0)\n\n    verify_range(52, 1.0, 2.0)\n    self.assertEqual(r.range_around_float(1.4, 52), (1.0, 2.0))\n\n    verify_range(52, 0.5, 1.0)\n    self.assertEqual(r.range_around_float(0.75, 52), (0.5, 1.0))\n\n    self.assertEqual(r.range_around_float(17, 48), (17.0, 18.0))\n    self.assertEqual(r.range_around_float(17, 49), (16.0, 18.0))\n    self.assertEqual(r.range_around_float(17, 50), (16.0, 20.0))\n    self.assertEqual(r.range_around_float(17, 51), (16.0, 24.0))\n    self.assertEqual(r.range_around_float(17, 52), (16.0, 32.0))\n\n    verify_range(51, 0.25, 0.375)\n    self.assertEqual(r.range_around_float(0.27, 51), (0.25, 0.375))\n    self.assertEqual(r.range_around_float(0.30, 51), (0.25, 0.375))\n    self.assertEqual(r.range_around_float(0.37, 51), (0.25, 0.375))\n\n    verify_range(51, 0.50, 0.75)\n    verify_range(51, 0.75, 1.0)\n    verify_range(52, 0.25, 0.5)\n\n    # Range around 0 isn't quite right, and possibly can't be. But we test that it's at least\n    # something meaningful.\n    self.assertEqual(r.range_around_float(0.00, 52), (0.00, 0.5))\n    self.assertEqual(r.range_around_float(0.25, 52), (0.25, 0.5))\n\n    self.assertEqual(r.range_around_float(0.00, 50), (0.00, 0.125))\n    self.assertEqual(r.range_around_float(0.10, 50), (0.09375, 0.109375))\n\n    self.assertEqual(r.range_around_float(0.0, 53), (0.00, 1))\n    self.assertEqual(r.range_around_float(0.5, 53), (0.00, 1))\n\n    self.assertEqual(r.range_around_float(0, 0), (0.0, skipfloats(0.5, 1) - 0.5))\n    self.assertEqual(r.range_around_float(0, 1), (0.0, skipfloats(0.5, 2) - 0.5))\n    self.assertEqual(r.range_around_float(0, 4), (0.0, skipfloats(0.5, 16) - 0.5))\n    self.assertEqual(r.range_around_float(0, 10), (0.0, skipfloats(0.5, 1024) - 0.5))\n\n  def test_all_distinct(self):\n\n    # Just like r.get_range, but includes endpoints.\n    def full_range(start, end, count):\n      return [start] + r.get_range(start, end, count) + [end]\n\n    self.assertTrue(r.all_distinct(range(1000)))\n    self.assertTrue(r.all_distinct([]))\n    self.assertTrue(r.all_distinct([1.0]))\n    self.assertFalse(r.all_distinct([1.0, 1.0]))\n\n    self.assertTrue(r.all_distinct(full_range(0, 1, 1000)))\n    self.assertFalse(r.all_distinct(full_range(1.0, r.nextfloat(1.0), 1)))\n    self.assertFalse(r.all_distinct(full_range(1.0, skipfloats(1.0, 10), 10)))\n    self.assertTrue(r.all_distinct(full_range(1.0, skipfloats(1.0, 11), 10)))\n    self.assertTrue(r.all_distinct(full_range(0.1, skipfloats(0.1, 100), 99)))\n    self.assertFalse(r.all_distinct(full_range(0.1, skipfloats(0.1, 100), 100)))\n\n  def test_get_range(self):\n    self.assertEqual(r.get_range(0.0, 2.0, 3), [0.5, 1, 1.5])\n    self.assertEqual(r.get_range(1, 17, 7), [3,5,7,9,11,13,15])\n    self.assertEqual(r.get_range(-1, 1.5, 4), [-0.5, 0, 0.5, 1])\n\n  def test_prepare_inserts_simple(self):\n    slist = SortedListWithKey(key=lambda i: i.key)\n    self.assertEqual(r.prepare_inserts(slist, [4.0]), ([], [1.0]))\n    self.assertEqual(r.prepare_inserts(slist, [0.0]), ([], [1.0]))\n    self.assertEqual(r.prepare_inserts(slist, [4.0, 4.0, 5, 6]), ([], [1.0, 2.0, 3.0, 4.0]))\n    self.assertEqual(r.prepare_inserts(slist, [4, 5, 6, 5, 4]), ([], [1,3,5,4,2]))\n    slist.update(Item(v, k) for (v, k) in zip(['a','b','c'], [3.0, 4.0, 5.0]))\n    self.assertEqual(r.prepare_inserts(slist, [0.0]), ([], [1.5]))\n\n    values = 'defgijkl'\n    to_update, to_add = r.prepare_inserts(slist, [3,3,4,5,6,4,6,4])\n    self.assertEqual(to_add, [1., 2., 3.25, 4.5, 6., 3.5, 7., 3.75])\n    self.assertEqual(to_update, [])\n    slist.update(Item(v, k) for (v, k) in zip(values, to_add))\n    self.assertEqual([i.value for i in slist], list('deafjlbgcik'))\n\n  def test_with_invalid(self):\n    slist = SortedListWithKey(key=lambda i: i.key)\n    slist.add(Item('a', 0))\n    self.assertEqual(r.prepare_inserts(slist, [0.0]), ([(0, 2.0)], [1.0]))\n    self.assertEqual(r.prepare_inserts(slist, [1.0]), ([], [1.0]))\n\n    slist = SortedListWithKey(key=lambda i: i.key)\n    slist.update(Item(v, k) for (v, k) in zip('abcdef', [0, 0, 0, 1, 1, 1]))\n    # We expect the whole range to be renumbered.\n    self.assertEqual(r.prepare_inserts(slist, [0.0, 0.0]),\n                     ([(0, 3.0), (1, 4.0), (2, 5.0), (3, 6.0), (4, 7.0), (5, 8.0)],\n                      [1.0, 2.0]))\n\n    # We also expect a renumbering if there are negative or infinite values.\n    slist = SortedListWithKey(key=lambda i: i.key)\n    slist.add(Item('a', float('inf')))\n    self.assertEqual(r.prepare_inserts(slist, [0.0]), ([(0, 2.0)], [1.0]))\n    self.assertEqual(r.prepare_inserts(slist, [float('inf')]), ([(0, 2.0)], [1.0]))\n\n    slist = SortedListWithKey(key=lambda i: i.key)\n    slist.add(Item('a', -17.0))\n    self.assertEqual(r.prepare_inserts(slist, [0.0]), ([(0, 1.0)], [2.0]))\n    self.assertEqual(r.prepare_inserts(slist, [float('-inf')]), ([(0, 2.0)], [1.0]))\n\n  def test_with_dups(self):\n    slist = SortedListWithKey(key=lambda i: i.key)\n    slist.update(Item(v, k) for (v, k) in zip('abcdef', [1, 1, 1, 2, 2, 2]))\n    self.assertEqual(r.prepare_inserts(slist, [0.0]), ([], [0.5]))\n\n  def test_renumber_endpoints1(self):\n    self._do_test_renumber_ends([])\n\n  def test_renumber_endpoints2(self):\n    self._do_test_renumber_ends(list(zip(\"abcd\", [40,50,60,70])))\n\n  def _do_test_renumber_ends(self, initial):\n    # Test insertions that happen together on the left and on the right.\n    slist = ItemList(initial)\n    for i in range(2000):\n      slist.insert_items([(i, float('-inf')), (-i, float('inf'))])\n\n    self.assertEqual(slist.get_values(),\n                     rev_range(2000) + [v for v,k in initial] + list(range(0, -2000, -1)))\n    #print slist.num_update_events, slist.num_updated_keys\n    self.assertLess(slist.avg_updated_keys(), 3)\n    self.assertLess(slist.num_update_events, 80)\n\n  def test_renumber_left(self):\n    slist = ItemList(zip(\"abcd\", [4,5,6,7]))\n    ins_item = slist.find_value('c')\n    for i in range(1000):\n      slist.insert_items([(i, ins_item.key)])\n\n    # Check the end result\n    self.assertEqual(slist.get_values(), ['a', 'b'] + list(range(1000)) + ['c', 'd'])\n    self.assertAlmostEqual(slist.avg_updated_keys(), 3.5, delta=1)\n    self.assertLess(slist.num_update_events, 40)\n\n  def test_renumber_right(self):\n    slist = ItemList(zip(\"abcd\", [4,5,6,7]))\n    ins_item = slist.find_value('b')\n    for i in range(1000):\n      slist.insert_items([(i, r.nextfloat(ins_item.key))])\n\n    # Check the end result\n    self.assertEqual(slist.get_values(), ['a', 'b'] + rev_range(1000) + ['c', 'd'])\n    self.assertAlmostEqual(slist.avg_updated_keys(), 3.5, delta=1)\n    self.assertLess(slist.num_update_events, 40)\n\n  def test_renumber_left_dumb(self):\n    # Here we use the \"dumb\" approach, and see that in our test case it's significantly worse.\n    # (The badness increases with the number of insertions, but we'll keep numbers small to keep\n    # the test fast.)\n    slist = ItemList(zip(\"abcd\", [4,5,6,7]))\n    ins_item = slist.find_value('c')\n    for i in range(1000):\n      slist.insert_items([(i, ins_item.key)], prepare_inserts=r.prepare_inserts_dumb)\n    self.assertEqual(slist.get_values(), ['a', 'b'] + list(range(1000)) + ['c', 'd'])\n    self.assertGreater(slist.avg_updated_keys(), 8)\n\n  def test_renumber_right_dumb(self):\n    slist = ItemList(zip(\"abcd\", [4,5,6,7]))\n    ins_item = slist.find_value('b')\n    for i in range(1000):\n      slist.insert_items([(i, r.nextfloat(ins_item.key))], prepare_inserts=r.prepare_inserts_dumb)\n    self.assertEqual(slist.get_values(), ['a', 'b'] + rev_range(1000) + ['c', 'd'])\n    self.assertGreater(slist.avg_updated_keys(), 8)\n\n  def test_renumber_multiple(self):\n    # In this test, we make multiple difficult insertions at each step: to the left and to the\n    # right of each value. This should involve some adjustments that get affected by subsequent\n    # adjustments during the same prepare_inserts() call.\n    slist = ItemList(zip(\"abcd\", [4,5,6,7]))\n    # We insert items on either side of each of the original items (a, b, c, d).\n    ins_items = list(slist.get_list())\n    N = 250\n    for i in range(N):\n      slist.insert_items([(\"%sr%s\" % (x.value, i), r.nextfloat(x.key)) for x in ins_items] +\n                         [(\"%sl%s\" % (x.value, i), x.key) for x in ins_items] +\n                         # After the first insertion, also insert items next on either side of the\n                         # neighbors of the original a, b, c, d items.\n                         ([(\"%sR%s\" % (x.value, i), r.nextfloat(slist.next(x).key))\n                           for x in ins_items] +\n                          [(\"%sL%s\" % (x.value, i), slist.prev(x).key) for x in ins_items]\n                          if i > 0 else []))\n\n    # The list should grow like this:\n    #  a, b, c, d\n    #  al0, a, ar0, ... (same for b, c, d)\n    #  aL1, al0, al1, a, ar1, ar0, aR1, ...\n    #  aL1, al0, aL2, al1, al2, a, ar2, ar1, aR2, ar0, aR1, ...\n    def left_half(val):\n      half = list(range(2*N - 1))\n      half[0::2] = ['%sL%d' % (val, i) for i in range(1, N + 1)]\n      half[1::2] = ['%sl%d' % (val, i) for i in range(0, N - 1)]\n      half[-1] = '%sl%d' % (val, N - 1)\n      return half\n\n    def right_half(val):\n      # Best described as the reverse of left_half\n      return [v.replace('l', 'r').replace('L', 'R') for v in reversed(left_half(val))]\n\n    # The list we expect to see is of the form [aL1, al1, aL2, al2, ... aL1000, al1000, a,\n    # ar1000, aR1000, ..., aR1],\n    # followed by the same sequence for b, c, and d.\n    self.assertEqual(slist.get_values(), sum([left_half(v) + [v] + right_half(v)\n                                              for v in ('a', 'b', 'c', 'd')], []))\n\n    self.assertAlmostEqual(slist.avg_updated_keys(), 2.5, delta=1)\n    self.assertLess(slist.num_update_events, 40)\n\n\ndef rev_range(n):\n  return list(reversed(list(range(n))))\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_renames.py",
    "content": "# -*- coding: utf-8 -*-\nimport logging\nimport unittest\n\nfrom asttokens.util import fstring_positions_work\n\nimport testutil\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\n\nclass TestRenames(test_engine.EngineTestCase):\n  # Simpler cases of column renames in formulas. Here's the list of cases we support and test.\n\n  # $COLUMN where NAME is a column (formula or non-formula)\n  # $ref.COLUMN when $ref is a non-formula Reference column\n  # $ref.column.COLUMN\n  # $ref.COLUMN when $ref is a function with a Ref type.\n  # $ref.COLUMN when $ref is a function with Any type but clearly returning a Ref.\n  # Table.lookupFunc(COLUMN1=value, COLUMN2=value) and for .lookupRecords\n  # Table.lookupFunc(...).COLUMN and for .lookupRecords\n  # Table.lookupFunc(...).foo.COLUMN and for .lookupRecords\n  # [x.COLUMN for x in Table.lookupRecords(...)] for different kinds of comprehensions\n  # TABLE.lookupFunc(...) where TABLE is a user-defined table.\n\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Address\", [\n        [21, \"city\",        \"Text\",        False, \"\", \"\", \"\"],\n      ]],\n      [2, \"People\", [\n        [22, \"name\",        \"Text\",        False, \"\", \"\", \"\"],\n        [23, \"addr\",        \"Ref:Address\", False, \"\", \"\", \"\"],\n        [24, \"city\",        \"Any\",         True,  \"$addr.city\", \"\", \"\"],\n      ]]\n    ],\n    \"DATA\": {\n      \"Address\": [\n        [\"id\",  \"city\"       ],\n        [11,    \"New York\"   ],\n        [12,    \"Colombia\"   ],\n        [13,    \"New Haven\"  ],\n        [14,    \"West Haven\" ],\n      ],\n      \"People\": [\n        [\"id\",  \"name\"  , \"addr\"  ],\n        [1,     \"Bob\"   , 12      ],\n        [2,     \"Alice\" , 13      ],\n        [3,     \"Doug\"  , 12      ],\n        [4,     \"Sam\"   , 11      ],\n      ],\n    }\n  })\n\n  def test_rename_rec_attribute(self):\n    # Simple case: we are renaming `$COLUMN`.\n    self.load_sample(self.sample)\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"addr\", \"address\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"addr\", \"address\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$address.city\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [23, 24], {\n        \"colId\": [\"address\", \"city\"],\n        \"formula\": [\"\", \"$address.city\"]\n      }],\n    ],\n      # Things should get recomputed, but produce same results, hence no calc actions.\n      \"calc\": []\n    })\n\n    # Make sure renames of formula columns are also recognized.\n    self.add_column(\"People\", \"CityUpper\", formula=\"$city.upper()\")\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"People\", \"CityUpper\", {\"formula\": \"$ciudad.upper()\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [24, 25], {\n        \"colId\": [\"ciudad\", \"CityUpper\"],\n        \"formula\": [\"$address.city\", \"$ciudad.upper()\"]\n      }]\n    ]})\n\n  @unittest.skipUnless(fstring_positions_work(), \"Python 3.10+ only\")\n  def test_rename_inside_fstring(self):\n    self.load_sample(self.sample)\n    self.add_column(\"People\", \"CityUpper\", formula=\"f'{$city.upper()}'\")\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"People\", \"CityUpper\", {\"formula\": \"f'{$ciudad.upper()}'\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [24, 25], {\n        \"colId\": [\"ciudad\", \"CityUpper\"],\n        \"formula\": [\"$addr.city\", \"f'{$ciudad.upper()}'\"]\n      }]\n    ]})\n\n  def test_rename_reference_attribute(self):\n    # Slightly harder: renaming `$ref.COLUMN`\n    self.load_sample(self.sample)\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Address\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$addr.ciudad\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [21, 24], {\n        \"colId\": [\"ciudad\", \"city\"],\n        \"formula\": [\"\", \"$addr.ciudad\"]\n      }],\n    ]})\n\n  def test_rename_ref_ref_attr(self):\n    # Slightly harder still: renaming $ref.column.COLUMN.\n    self.load_sample(self.sample)\n    self.add_column(\"Address\", \"person\", type=\"Ref:People\")\n    self.add_column(\"Address\", \"person_city\", formula=\"$person.addr.city\")\n    self.add_column(\"Address\", \"person_city2\", formula=\"a = $person.addr\\nreturn a.city\")\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Address\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$addr.ciudad\"}],\n      [\"ModifyColumn\", \"Address\", \"person_city\", {\"formula\": \"$person.addr.ciudad\"}],\n      [\"ModifyColumn\", \"Address\", \"person_city2\", {\"formula\":\n                                                   \"a = $person.addr\\nreturn a.ciudad\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [21, 24, 26, 27], {\n        \"colId\": [\"ciudad\", \"city\", \"person_city\", \"person_city2\"],\n        \"formula\": [\"\", \"$addr.ciudad\", \"$person.addr.ciudad\", \"a = $person.addr\\nreturn a.ciudad\"]\n      }],\n    ]})\n\n  def test_rename_typed_ref_func_attr(self):\n    # Renaming `$ref.COLUMN` when $ref is a function with a Ref type.\n    self.load_sample(self.sample)\n    self.add_column(\"People\", \"addr_func\", type=\"Ref:Address\", isFormula=True, formula=\"$addr\")\n    self.add_column(\"People\", \"city2\", formula=\"$addr_func.city\")\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Address\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$addr.ciudad\"}],\n      [\"ModifyColumn\", \"People\", \"city2\", {\"formula\": \"$addr_func.ciudad\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [21, 24, 26], {\n        \"colId\": [\"ciudad\", \"city\", \"city2\"],\n        \"formula\": [\"\", \"$addr.ciudad\", \"$addr_func.ciudad\"]\n      }],\n    ]})\n\n  def test_rename_any_ref_func_attr(self):\n    # Renaming `$ref.COLUMN` when $ref is a function with Any type but clearly returning a Ref.\n    self.load_sample(self.sample)\n    self.add_column(\"People\", \"addr_func\", isFormula=True, formula=\"$addr\")\n    self.add_column(\"People\", \"city3\", formula=\"$addr_func.city\")\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Address\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$addr.ciudad\"}],\n      [\"ModifyColumn\", \"People\", \"city3\", {\"formula\": \"$addr_func.ciudad\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [21, 24, 26], {\n        \"colId\": [\"ciudad\", \"city\", \"city3\"],\n        \"formula\": [\"\", \"$addr.ciudad\", \"$addr_func.ciudad\"]\n      }],\n    ]})\n\n  def test_rename_reflist_attr(self):\n    # Renaming `$ref.COLUMN` where $ref is a data or function with RefList type (most importantly\n    # applies to the $group column of summary tables).\n    self.load_sample(self.sample)\n    self.add_column(\"People\", \"addr_list\", type=\"RefList:Address\", isFormula=False)\n    self.add_column(\"People\", \"addr_func\", type=\"RefList:Address\", isFormula=True, formula=\"[1,2]\")\n    self.add_column(\"People\", \"citysum\", formula=\"sum($addr_func.city) + sum($addr_list.city)\")\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Address\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$addr.ciudad\"}],\n      [\"ModifyColumn\", \"People\", \"citysum\", {\"formula\":\n                                             \"sum($addr_func.ciudad) + sum($addr_list.ciudad)\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [21, 24, 27], {\n        \"colId\": [\"ciudad\", \"city\", \"citysum\"],\n        \"formula\": [\"\", \"$addr.ciudad\", \"sum($addr_func.ciudad) + sum($addr_list.ciudad)\"]\n      }],\n    ]})\n\n\n  def test_rename_lookup_param(self):\n    # Renaming `Table.lookupOne(COLUMN1=value, COLUMN2=value)` and for `.lookupRecords`\n    self.load_sample(self.sample)\n    self.add_column(\"Address\", \"people\", formula=\"People.lookupOne(addr=$id, city=$city)\")\n    self.add_column(\"Address\", \"people2\", formula=\"People.lookupRecords(addr=$id)\")\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"addr\", \"ADDRESS\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"addr\", \"ADDRESS\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$ADDRESS.city\"}],\n      [\"ModifyColumn\", \"Address\", \"people\",\n                   {\"formula\": \"People.lookupOne(ADDRESS=$id, city=$city)\"}],\n      [\"ModifyColumn\", \"Address\", \"people2\",\n                   {\"formula\": \"People.lookupRecords(ADDRESS=$id)\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [23, 24, 25, 26], {\n        \"colId\": [\"ADDRESS\", \"city\", \"people\", \"people2\"],\n        \"formula\": [\"\", \"$ADDRESS.city\",\n                    \"People.lookupOne(ADDRESS=$id, city=$city)\",\n                    \"People.lookupRecords(ADDRESS=$id)\"]\n      }],\n    ]})\n\n    # Another rename that should affect the second parameter.\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"Address\", \"people\",\n                   {\"formula\": \"People.lookupOne(ADDRESS=$id, ciudad=$city)\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [24, 25], {\n        \"colId\": [\"ciudad\", \"people\"],\n        \"formula\": [\"$ADDRESS.city\", \"People.lookupOne(ADDRESS=$id, ciudad=$city)\"]\n      }],\n    ]})\n\n    # This is kind of unnecessary, but checks how the values of params are affected separately.\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"city2\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Address\", \"city\", \"city2\"],\n      [\"ModifyColumn\", \"People\", \"ciudad\", {\"formula\": \"$ADDRESS.city2\"}],\n      [\"ModifyColumn\", \"Address\", \"people\",\n                   {\"formula\": \"People.lookupOne(ADDRESS=$id, ciudad=$city2)\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [21, 24, 25], {\n        \"colId\": [\"city2\", \"ciudad\", \"people\"],\n        \"formula\": [\"\", \"$ADDRESS.city2\", \"People.lookupOne(ADDRESS=$id, ciudad=$city2)\"]\n      }],\n    ]})\n\n  def test_rename_lookup_result_attr(self):\n    # Renaming `Table.lookupOne(...).COLUMN` and for `.lookupRecords`\n    self.load_sample(self.sample)\n    self.add_column(\"Address\", \"people\", formula=\"People.lookupOne(addr=$id, city=$city).name\")\n    self.add_column(\"Address\", \"people2\", formula=\"People.lookupRecords(addr=$id).name\")\n    self.add_column(\"Address\", \"people3\", formula=\"People.all.name\")\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"name\", \"nombre\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"name\", \"nombre\"],\n      [\"ModifyColumn\", \"Address\", \"people\", {\"formula\":\n                                             \"People.lookupOne(addr=$id, city=$city).nombre\"}],\n      [\"ModifyColumn\", \"Address\", \"people2\", {\"formula\":\n                                              \"People.lookupRecords(addr=$id).nombre\"}],\n      [\"ModifyColumn\", \"Address\", \"people3\", {\"formula\":\n                                              \"People.all.nombre\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [22, 25, 26, 27], {\n        \"colId\": [\"nombre\", \"people\", \"people2\", \"people3\"],\n        \"formula\": [\"\",\n                    \"People.lookupOne(addr=$id, city=$city).nombre\",\n                    \"People.lookupRecords(addr=$id).nombre\",\n                    \"People.all.nombre\"]\n      }],\n    ]})\n\n  def test_rename_lookup_ref_attr(self):\n    # Renaming `Table.lookupOne(...).foo.COLUMN` and for `.lookupRecords`\n    self.load_sample(self.sample)\n    self.add_column(\"Address\", \"people\", formula=\"People.lookupOne(addr=$id, city=$city).addr.city\")\n    self.add_column(\"Address\", \"people2\", formula=\"People.lookupRecords(addr=$id).addr.city\")\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Address\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$addr.ciudad\"}],\n      [\"ModifyColumn\", \"Address\", \"people\", {\"formula\":\n                                       \"People.lookupOne(addr=$id, city=$ciudad).addr.ciudad\"}],\n      [\"ModifyColumn\", \"Address\", \"people2\", {\"formula\":\n                                              \"People.lookupRecords(addr=$id).addr.ciudad\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [21, 24, 25, 26], {\n        \"colId\": [\"ciudad\", \"city\", \"people\", \"people2\"],\n        \"formula\": [\"\", \"$addr.ciudad\",\n                    \"People.lookupOne(addr=$id, city=$ciudad).addr.ciudad\",\n                    \"People.lookupRecords(addr=$id).addr.ciudad\"]\n      }]\n    ]})\n\n  def test_rename_lookup_iter_attr(self):\n    # Renaming `[x.COLUMN for x in Table.lookupRecords(...)]`.\n    self.check_comprehension_rename(\"People.lookupRecords(addr=$id)\",\n                                    \"People.lookupRecords(ADDRESS=$id)\")\n\n  def test_rename_all_iter_attr(self):\n    # Renaming `[x.COLUMN for x in Table.all]`.\n    self.check_comprehension_rename(\"People.all\", \"People.all\")\n\n  def check_comprehension_rename(self, iter_expr1, iter_expr2):\n    self.load_sample(self.sample)\n    self.add_column(\"Address\", \"people\",\n                    formula=\"','.join(x.addr.city for x in %s)\" % iter_expr1)\n    self.add_column(\"Address\", \"people2\",\n                    formula=\"','.join([x.addr.city for x in %s])\" % iter_expr1)\n    self.add_column(\"Address\", \"people3\",\n                    formula=\"','.join({x.addr.city for x in %s})\" % iter_expr1)\n    self.add_column(\"Address\", \"people4\",\n                    formula=\"{x.addr.city:x.addr for x in %s}\" % iter_expr1)\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"addr\", \"ADDRESS\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"addr\", \"ADDRESS\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$ADDRESS.city\"}],\n      [\"ModifyColumn\", \"Address\", \"people\",\n           {\"formula\": \"','.join(x.ADDRESS.city for x in %s)\" % iter_expr2}],\n      [\"ModifyColumn\", \"Address\", \"people2\",\n           {\"formula\": \"','.join([x.ADDRESS.city for x in %s])\" % iter_expr2}],\n      [\"ModifyColumn\", \"Address\", \"people3\",\n           {\"formula\": \"','.join({x.ADDRESS.city for x in %s})\" % iter_expr2}],\n      [\"ModifyColumn\", \"Address\", \"people4\",\n           {\"formula\": \"{x.ADDRESS.city:x.ADDRESS for x in %s}\" % iter_expr2}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [23, 24, 25, 26, 27, 28], {\n        \"colId\": [\"ADDRESS\", \"city\", \"people\", \"people2\", \"people3\", \"people4\"],\n        \"formula\": [\"\", \"$ADDRESS.city\",\n           \"','.join(x.ADDRESS.city for x in %s)\" % iter_expr2,\n           \"','.join([x.ADDRESS.city for x in %s])\" % iter_expr2,\n           \"','.join({x.ADDRESS.city for x in %s})\" % iter_expr2,\n           \"{x.ADDRESS.city:x.ADDRESS for x in %s}\" % iter_expr2],\n      }],\n    ]})\n\n  def test_rename_table(self):\n    # Renaming TABLE.lookupFunc(...) where TABLE is a user-defined table.\n    self.load_sample(self.sample)\n    self.add_column(\"Address\", \"people\", formula=\"People.lookupRecords(addr=$id)\")\n    self.add_column(\"Address\", \"people2\", type=\"Ref:People\", formula=\"People.lookupOne(addr=$id)\")\n    out_actions = self.apply_user_action([\"RenameTable\", \"People\", \"Persons\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"ModifyColumn\", \"Address\", \"people2\", {\"type\": \"Int\"}],\n      [\"RenameTable\", \"People\", \"Persons\"],\n      [\"UpdateRecord\", \"_grist_Tables\", 2, {\"tableId\": \"Persons\"}],\n      [\"ModifyColumn\", \"Address\", \"people2\", {\n        \"type\": \"Ref:Persons\", \"formula\": \"Persons.lookupOne(addr=$id)\" }],\n      [\"ModifyColumn\", \"Address\", \"people\", {\"formula\": \"Persons.lookupRecords(addr=$id)\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [26, 25], {\n        \"type\": [\"Ref:Persons\", \"Any\"],\n        \"formula\": [\"Persons.lookupOne(addr=$id)\", \"Persons.lookupRecords(addr=$id)\"]\n      }],\n      [\"BulkUpdateRecord\", \"Address\", [11, 12, 13, 14], {\n        \"people\": [[\"r\", \"Persons\", [4]],\n                   [\"r\", \"Persons\", [1, 3]],\n                   [\"r\", \"Persons\", [2]],\n                   [\"r\", \"Persons\", []]]\n      }],\n    ]})\n\n  def test_rename_table_autocomplete(self):\n    user = {\n      'Name': 'Foo',\n      'UserID': 1,\n      'UserRef': '1',\n      'LinkKey': {},\n      'Origin': None,\n      'Email': 'foo@example.com',\n      'Access': 'owners',\n      'SessionID': 'u1',\n      'IsLoggedIn': True,\n      'ShareRef': None\n    }\n\n    # Renaming a table should not leave the old name available for auto-complete.\n    self.load_sample(self.sample)\n    names = {\"People\", \"Persons\"}\n    autocomplete = self.engine.autocomplete(\"Pe\", \"Address\", \"city\", 1, user)\n    suggestions = {suggestion for suggestion, value in autocomplete}\n    self.assertEqual(\n      names.intersection(suggestions),\n      {\"People\"}\n    )\n\n    # Rename the table and ensure that \"People\" is no longer present among top-level names.\n    self.apply_user_action([\"RenameTable\", \"People\", \"Persons\"])\n    autocomplete = self.engine.autocomplete(\"Pe\", \"Address\", \"city\", 1, user)\n    suggestions = {suggestion for suggestion, value in autocomplete}\n    self.assertEqual(\n      names.intersection(suggestions),\n      {\"Persons\"}\n    )\n\n  def test_rename_to_id(self):\n    # Check that we renaming a column to \"Id\" disambiguates it with a suffix.\n    self.load_sample(self.sample)\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"name\", \"Id\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"name\", \"Id2\"],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"colId\": \"Id2\"}],\n    ]})\n\n  def test_renames_with_non_ascii(self):\n    # Test that presence of unicode does not interfere with formula adjustments for renaming.\n    self.load_sample(self.sample)\n    self.add_column(\"Address\", \"CityUpper\", formula=u\"'Øî'+$city.upper()+'áü'\")\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Address\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$addr.ciudad\"}],\n      [\"ModifyColumn\", \"Address\", \"CityUpper\", {\"formula\": u\"'Øî'+$ciudad.upper()+'áü'\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [21, 24, 25], {\n        \"colId\": [\"ciudad\", \"city\", \"CityUpper\"],\n        \"formula\": [\"\", \"$addr.ciudad\", u\"'Øî'+$ciudad.upper()+'áü'\"],\n      }]\n    ]})\n    self.assertTableData(\"Address\", cols=\"all\", data=[\n      [\"id\",  \"ciudad\",     \"CityUpper\"],\n      [11,    \"New York\",   u\"ØîNEW YORKáü\"],\n      [12,    \"Colombia\",   u\"ØîCOLOMBIAáü\"],\n      [13,    \"New Haven\",  u\"ØîNEW HAVENáü\"],\n      [14,    \"West Haven\", u\"ØîWEST HAVENáü\"],\n    ])\n\n  def test_rename_updates_properties(self):\n    # This tests for the following bug: a column A of type Any with formula Table1.lookupOne(B=$B)\n    # will return a correct reference; when column Table1.X is renamed to Y, $A.X will be changed\n    # to $A.Y correctly. The bug was that the fixed $A.Y formula would fail incorrectly with\n    # \"Table1 has no column 'Y'\".\n    #\n    # The cause was that Record objects created by $A were not affected by the\n    # rename, or recomputed after it, and contained a stale list of allowed column names (the fix\n    # removes reliance on storing column names in the Record class).\n\n    self.load_sample(self.sample)\n    self.add_column(\"Address\", \"person\", formula=\"People.lookupOne(addr=$id)\")\n    self.add_column(\"Address\", \"name\", formula=\"$person.name\")\n    from datetime import date\n    # A helper for comparing Record objects below.\n    people_table = self.engine.tables['People']\n    people_rec = lambda row_id: people_table.Record(row_id, None)\n\n    # Verify the data and calculations are correct.\n    self.assertTableData(\"Address\", cols=\"all\", data=[\n      [\"id\",  \"city\",       \"person\",           \"name\"],\n      [11,    \"New York\",   people_rec(4),      \"Sam\"],\n      [12,    \"Colombia\",   people_rec(1),      \"Bob\"],\n      [13,    \"New Haven\",  people_rec(2),      \"Alice\"],\n      [14,    \"West Haven\", people_rec(0),      \"\"],\n    ])\n\n    # Do the rename.\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"name\", \"name2\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"name\", \"name2\"],\n      [\"ModifyColumn\", \"Address\", \"name\", {\"formula\": \"$person.name2\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [22, 26], {\n        \"colId\": [\"name2\", \"name\"],\n        \"formula\": [\"\", \"$person.name2\"],\n      }]\n    ]})\n\n    # Verify the data and calculations are correct after the rename.\n    self.assertTableData(\"Address\", cols=\"all\", data=[\n      [\"id\",  \"city\",       \"person\",           \"name\"],\n      [11,    \"New York\",   people_rec(4),      \"Sam\"],\n      [12,    \"Colombia\",   people_rec(1),      \"Bob\"],\n      [13,    \"New Haven\",  people_rec(2),      \"Alice\"],\n      [14,    \"West Haven\", people_rec(0),      \"\"],\n    ])\n\n  def test_rename_lookup_kwargs(self):\n    # Renaming causes no errors for `Table.lookupOne(**kwargs)` and for `.lookupRecords`. We can't\n    # rename, but we test that this syntax does not cause errors.\n    self.load_sample(self.sample)\n    self.add_column(\"Address\", \"people\", formula=(\n      \"args={'addr': $id}\\n\" +\n      \"People.lookupOne(city=$city, **args)\"\n    ))\n    self.add_column(\"Address\", \"people2\", formula=\"People.lookupRecords(**{'addr': $id})\")\n\n    # Verify the data, to make sure we got these formulas right, and that they still work later.\n    people_table = self.engine.tables['People']\n    people_rec = people_table.Record\n    people_recset = people_table.RecordSet\n    expected_data = [\n      [\"id\",  \"city\",       \"people\",           \"people2\"],\n      [11,    \"New York\",   people_rec(4),      people_recset([4])],\n      [12,    \"Colombia\",   people_rec(1),      people_recset([1, 3])],\n      [13,    \"New Haven\",  people_rec(2),      people_recset([2])],\n      [14,    \"West Haven\", people_rec(0),      people_recset([])],\n    ]\n\n    self.assertTableData(\"Address\", cols=\"all\", data=expected_data)\n\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"addr\", \"ADDRESS\"])\n    # The new formulas aren't affected but cause no errors on rename.\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"addr\", \"ADDRESS\"],\n      [\"ModifyColumn\", \"People\", \"city\", {\"formula\": \"$ADDRESS.city\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [23, 24], {\n        \"colId\": [\"ADDRESS\", \"city\"],\n        \"formula\": [\"\", \"$ADDRESS.city\"]\n      }],\n      # But since the new formulas aren't affected, we get errors in the cells, as expected.\n      [\"BulkUpdateRecord\", \"Address\", [11, 12, 13, 14],\n        {\"people\": [[\"E\", \"KeyError\"], [\"E\", \"KeyError\"], [\"E\", \"KeyError\"], [\"E\", \"KeyError\"]]}],\n      [\"BulkUpdateRecord\", \"Address\", [11, 12, 13, 14],\n        {\"people2\": [[\"E\", \"KeyError\"], [\"E\", \"KeyError\"], [\"E\", \"KeyError\"], [\"E\", \"KeyError\"]]}],\n    ]})\n\n    # Let's fix the cell errors to make the next check more meaningful.\n    self.modify_column(\"Address\", \"people\", formula=(\n      \"args={'ADDRESS': $id}\\n\" +\n      \"People.lookupOne(city=$city, **args)\"\n    ))\n    self.modify_column(\"Address\", \"people2\", formula=\"People.lookupRecords(**{'ADDRESS': $id})\")\n\n    # Data should again be correct.\n    self.assertTableData(\"Address\", cols=\"all\", data=expected_data)\n\n    # Another rename that should affect the regular keyword argument.\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"city\", \"ciudad\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"city\", \"ciudad\"],\n      [\"ModifyColumn\", \"Address\", \"people\", {\"formula\": (\n        \"args={'ADDRESS': $id}\\n\" +\n        \"People.lookupOne(ciudad=$city, **args)\"\n      )}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [24, 25], {\n        \"colId\": [\"ciudad\", \"people\"],\n        \"formula\": [\"$ADDRESS.city\", (\n          \"args={'ADDRESS': $id}\\n\" +\n          \"People.lookupOne(ciudad=$city, **args)\"\n          )]\n      }],\n    ]})\n\n    # Data should again be correct.\n    self.assertTableData(\"Address\", cols=\"all\", data=expected_data)\n"
  },
  {
    "path": "sandbox/grist/test_renames2.py",
    "content": "import textwrap\nimport unittest\n\nimport logging\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\n\ndef _replace_col_name(data, old_name, new_name):\n  \"\"\"For verifying data, renames a column in the header in-place.\"\"\"\n  data[0] = [(new_name if c == old_name else c) for c in data[0]]\n\n\nclass TestRenames2(test_engine.EngineTestCase):\n  # Another test for column renames, which tests crazier interconnected formulas.\n  # This one includes a bunch of cases where renames fail, marked as TODOs.\n\n  def setUp(self):\n    super(TestRenames2, self).setUp()\n\n    # Create a schema with several tables including some references and lookups.\n    self.apply_user_action([\"AddTable\", \"People\", [\n      {\"id\": \"name\", \"type\": \"Text\"}\n    ]])\n    self.apply_user_action([\"AddTable\", \"Games\", [\n      {\"id\": \"name\", \"type\": \"Text\"},\n      {\"id\": \"winner\", \"type\": \"Ref:People\", \"isFormula\": True,\n       \"formula\": \"Entries.lookupOne(game=$id, rank=1).person\"},\n      {\"id\": \"second\", \"type\": \"Ref:People\", \"isFormula\": True,\n       \"formula\": \"Entries.lookupOne(game=$id, rank=2).person\"},\n    ]])\n    self.apply_user_action([\"AddTable\", \"Entries\", [\n      {\"id\": \"game\", \"type\": \"Ref:Games\"},\n      {\"id\": \"person\", \"type\": \"Ref:People\"},\n      {\"id\": \"rank\", \"type\": \"Int\"},\n    ]])\n\n    # Fill it with some sample data.\n    self.add_records(\"People\", [\"name\"], [\n      [\"Bob\"], [\"Alice\"], [\"Carol\"], [\"Doug\"], [\"Eve\"]])\n    self.add_records(\"Games\", [\"name\"], [\n      [\"ChessA\"], [\"GoA\"], [\"ChessB\"], [\"CheckersA\"]])\n    self.add_records(\"Entries\", [\"game\", \"person\", \"rank\"], [\n      [ 1,  2,  1],\n      [ 1,  4,  2],\n      [ 2,  1,  2],\n      [ 2,  2,  1],\n      [ 3,  4,  1],\n      [ 3,  3,  2],\n      [ 4,  5,  1],\n      [ 4,  1,  2],\n    ])\n\n    # Check the data, to see it, and confirm that lookups work.\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [ \"id\",   \"name\"  ],\n      [ 1,      \"Bob\"   ],\n      [ 2,      \"Alice\" ],\n      [ 3,      \"Carol\" ],\n      [ 4,      \"Doug\"  ],\n      [ 5,      \"Eve\"   ],\n    ])\n    self.assertTableData(\"Games\", cols=\"subset\", data=[\n      [ \"id\",   \"name\"      , \"winner\",   \"second\"  ],\n      [ 1,      \"ChessA\"    , 2,          4,        ],\n      [ 2,      \"GoA\"       , 2,          1,        ],\n      [ 3,      \"ChessB\"    , 4,          3,        ],\n      [ 4,      \"CheckersA\" , 5,          1         ],\n    ])\n\n    # This was just setpu. Now create some crazy formulas that overuse references in crazy ways.\n    self.partner_names = textwrap.dedent(\n      \"\"\"\n      games = Entries.lookupRecords(person=$id).game\n      partners = [e.person for g in games for e in Entries.lookupRecords(game=g)]\n      return ' '.join(p.name for p in partners if p.id != $id)\n      \"\"\")\n    self.partner = textwrap.dedent(\n      \"\"\"\n      game = Entries.lookupOne(person=$id).game\n      next(e.person for e in Entries.lookupRecords(game=game) if e.person != rec)\n      \"\"\").strip()\n\n    self.add_column(\"People\", \"N\", formula=\"$name.upper()\")\n    self.add_column(\"People\", \"Games_Won\", formula=(\n      \"' '.join(e.game.name for e in Entries.lookupRecords(person=$id, rank=1))\"))\n    self.add_column(\"People\", \"PartnerNames\", formula=self.partner_names)\n    self.add_column(\"People\", \"partner\", type=\"Ref:People\", formula=self.partner)\n    self.add_column(\"People\", \"partner4\", type=\"Ref:People\", formula=(\n      \"$partner.partner.partner.partner\"))\n\n    # Make it hard to follow references by using the same names in different tables.\n    self.add_column(\"People\", \"win\", type=\"Ref:Games\",\n                    formula=\"Entries.lookupOne(person=$id, rank=1).game\")\n    self.add_column(\"Games\", \"win\", type=\"Ref:People\", formula=\"$winner\")\n    self.add_column(\"Games\", \"win3_person_name\", formula=\"$win.win.win.name\")\n    self.add_column(\"Games\", \"win4_game_name\", formula=\"$win.win.win.win.name\")\n\n    # This is just for help us know which columns have which rowIds.\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", data=[\n      [ \"id\",   \"parentId\",   \"colId\" ],\n      [   1,    1,            \"manualSort\"       ],\n      [   2,    1,            \"name\"             ],\n      [   3,    2,            \"manualSort\"       ],\n      [   4,    2,            \"name\"             ],\n      [   5,    2,            \"winner\"           ],\n      [   6,    2,            \"second\"           ],\n      [   7,    3,            \"manualSort\"       ],\n      [   8,    3,            \"game\"             ],\n      [   9,    3,            \"person\"           ],\n      [   10,   3,            \"rank\"             ],\n      [   11,   1,            \"N\"                ],\n      [   12,   1,            \"Games_Won\"        ],\n      [   13,   1,            \"PartnerNames\"     ],\n      [   14,   1,            \"partner\"          ],\n      [   15,   1,            \"partner4\"         ],\n      [   16,   1,            \"win\"              ],\n      [   17,   2,            \"win\"              ],\n      [   18,   2,            \"win3_person_name\" ],\n      [   19,   2,            \"win4_game_name\"   ],\n    ])\n\n    # Check the data before we start on the renaming.\n    self.people_data = [\n      [ \"id\",   \"name\" , \"N\",     \"Games_Won\",  \"PartnerNames\", \"partner\",  \"partner4\", \"win\" ],\n      [ 1,      \"Bob\"  , \"BOB\",   \"\",           \"Alice Eve\"   , 2,          4         , 0     ],\n      [ 2,      \"Alice\", \"ALICE\", \"ChessA GoA\", \"Doug Bob\"    , 4,          2         , 1     ],\n      [ 3,      \"Carol\", \"CAROL\", \"\",           \"Doug\"        , 4,          2         , 0     ],\n      [ 4,      \"Doug\" , \"DOUG\",  \"ChessB\",     \"Alice Carol\" , 2,          4         , 3     ],\n      [ 5,      \"Eve\"  , \"EVE\",   \"CheckersA\",  \"Bob\"         , 1,          2         , 4     ],\n    ]\n    self.games_data = [\n      [ \"id\",   \"name\"      , \"winner\",   \"second\", \"win\",  \"win3_person_name\", \"win4_game_name\" ],\n      [ 1,      \"ChessA\"    , 2,          4       , 2     , \"Alice\"           , \"ChessA\"         ],\n      [ 2,      \"GoA\"       , 2,          1       , 2     , \"Alice\"           , \"ChessA\"         ],\n      [ 3,      \"ChessB\"    , 4,          3       , 4     , \"Doug\"            , \"ChessB\"         ],\n      [ 4,      \"CheckersA\" , 5,          1       , 5     , \"Eve\"             , \"CheckersA\"      ],\n    ]\n    self.assertTableData(\"People\", cols=\"subset\", data=self.people_data)\n    self.assertTableData(\"Games\", cols=\"subset\", data=self.games_data)\n\n  def test_renames_a(self):\n    # Rename Entries.game: affects Games.winner, Games.second, People.Games_Won,\n    # People.PartnerNames, People.partner.\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Entries\", \"game\", \"juego\"])\n    self.partner_names = textwrap.dedent(\n      \"\"\"\n      games = Entries.lookupRecords(person=$id).juego\n      partners = [e.person for g in games for e in Entries.lookupRecords(juego=g)]\n      return ' '.join(p.name for p in partners if p.id != $id)\n      \"\"\")\n    self.partner = textwrap.dedent(\n      \"\"\"\n      game = Entries.lookupOne(person=$id).juego\n      next(e.person for e in Entries.lookupRecords(juego=game) if e.person != rec)\n      \"\"\").strip()\n\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Entries\", \"game\", \"juego\"],\n      [\"ModifyColumn\", \"Games\", \"winner\",\n       {\"formula\": \"Entries.lookupOne(juego=$id, rank=1).person\"}],\n      [\"ModifyColumn\", \"Games\", \"second\",\n       {\"formula\": \"Entries.lookupOne(juego=$id, rank=2).person\"}],\n      [\"ModifyColumn\", \"People\", \"Games_Won\", {\n        \"formula\": \"' '.join(e.juego.name for e in Entries.lookupRecords(person=$id, rank=1))\"\n      }],\n      [\"ModifyColumn\", \"People\", \"PartnerNames\", { \"formula\": self.partner_names }],\n      [\"ModifyColumn\", \"People\", \"partner\", {\"formula\": self.partner}],\n      [\"ModifyColumn\", \"People\", \"win\",\n       {\"formula\": \"Entries.lookupOne(person=$id, rank=1).juego\"}],\n\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [8, 5, 6, 12, 13, 14, 16], {\n        \"colId\": [\"juego\", \"winner\", \"second\", \"Games_Won\", \"PartnerNames\", \"partner\", \"win\"],\n        \"formula\": [\"\",\n                    \"Entries.lookupOne(juego=$id, rank=1).person\",\n                    \"Entries.lookupOne(juego=$id, rank=2).person\",\n                    \"' '.join(e.juego.name for e in Entries.lookupRecords(person=$id, rank=1))\",\n                    self.partner_names,\n                    self.partner,\n                    \"Entries.lookupOne(person=$id, rank=1).juego\"\n                   ]\n      }],\n    ]})\n\n    # Verify data to ensure there are no AttributeErrors.\n    self.assertTableData(\"People\", cols=\"subset\", data=self.people_data)\n    self.assertTableData(\"Games\", cols=\"subset\", data=self.games_data)\n\n\n  def test_renames_b(self):\n    # Rename Games.name: affects People.Games_Won, Games.win4_game_name\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Games\", \"name\", \"nombre\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Games\", \"name\", \"nombre\"],\n      [\"ModifyColumn\", \"People\", \"Games_Won\", {\n        \"formula\": \"' '.join(e.game.nombre for e in Entries.lookupRecords(person=$id, rank=1))\"\n      }],\n      [\"ModifyColumn\", \"Games\", \"win4_game_name\", {\"formula\": \"$win.win.win.win.nombre\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [4, 12, 19], {\n        \"colId\": [\"nombre\", \"Games_Won\", \"win4_game_name\"],\n        \"formula\": [\n          \"\",\n          \"' '.join(e.game.nombre for e in Entries.lookupRecords(person=$id, rank=1))\",\n          \"$win.win.win.win.nombre\"\n        ]\n      }]\n    ]})\n\n    # Fix up things missed due to the TODOs above.\n    self.modify_column(\"Games\", \"win4_game_name\", formula=\"$win.win.win.win.nombre\")\n\n    # Verify data to ensure there are no AttributeErrors.\n    _replace_col_name(self.games_data, \"name\", \"nombre\")\n    self.assertTableData(\"People\", cols=\"subset\", data=self.people_data)\n    self.assertTableData(\"Games\", cols=\"subset\", data=self.games_data)\n\n\n  def test_renames_c(self):\n    # Rename Entries.person: affects People.ParnerNames\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Entries\", \"person\", \"persona\"])\n    self.partner_names = textwrap.dedent(\n      \"\"\"\n      games = Entries.lookupRecords(persona=$id).game\n      partners = [e.persona for g in games for e in Entries.lookupRecords(game=g)]\n      return ' '.join(p.name for p in partners if p.id != $id)\n      \"\"\")\n    self.partner = textwrap.dedent(\n      \"\"\"\n      game = Entries.lookupOne(persona=$id).game\n      next(e.persona for e in Entries.lookupRecords(game=game) if e.persona != rec)\n      \"\"\").strip()\n\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Entries\", \"person\", \"persona\"],\n      [\"ModifyColumn\", \"Games\", \"winner\",\n       {\"formula\": \"Entries.lookupOne(game=$id, rank=1).persona\"}],\n      [\"ModifyColumn\", \"Games\", \"second\",\n       {\"formula\": \"Entries.lookupOne(game=$id, rank=2).persona\"}],\n      [\"ModifyColumn\", \"People\", \"Games_Won\", {\n        \"formula\": \"' '.join(e.game.name for e in Entries.lookupRecords(persona=$id, rank=1))\"\n      }],\n      [\"ModifyColumn\", \"People\", \"PartnerNames\", { \"formula\": self.partner_names }],\n      [\"ModifyColumn\", \"People\", \"partner\", {\"formula\": self.partner}],\n      [\"ModifyColumn\", \"People\", \"win\",\n       {\"formula\": \"Entries.lookupOne(persona=$id, rank=1).game\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [9, 5, 6, 12, 13, 14, 16], {\n        \"colId\": [\"persona\", \"winner\", \"second\", \"Games_Won\", \"PartnerNames\", \"partner\", \"win\"],\n        \"formula\": [\"\",\n                    \"Entries.lookupOne(game=$id, rank=1).persona\",\n                    \"Entries.lookupOne(game=$id, rank=2).persona\",\n                    \"' '.join(e.game.name for e in Entries.lookupRecords(persona=$id, rank=1))\",\n                    self.partner_names,\n                    self.partner,\n                    \"Entries.lookupOne(persona=$id, rank=1).game\"\n                   ]\n      }],\n    ]})\n\n    self.assertTableData(\"People\", cols=\"subset\", data=self.people_data)\n    self.assertTableData(\"Games\", cols=\"subset\", data=self.games_data)\n\n\n  def test_renames_d(self):\n    # Rename People.name: affects People.N, People.ParnerNames\n    # TODO: PartnerNames does NOT get updated correctly because astroid doesn't infer meanings of\n    # lists very well.\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"name\", \"nombre\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"name\", \"nombre\"],\n      [\"ModifyColumn\", \"People\", \"N\", {\"formula\": \"$nombre.upper()\"}],\n      [\"ModifyColumn\", \"Games\", \"win3_person_name\", {\"formula\": \"$win.win.win.nombre\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [2, 11, 18], {\n        \"colId\": [\"nombre\", \"N\", \"win3_person_name\"],\n        \"formula\": [\"\", \"$nombre.upper()\", \"$win.win.win.nombre\"]\n      }],\n      [\"BulkUpdateRecord\", \"People\", [1, 2, 3, 4, 5], {\n        \"PartnerNames\": [[\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"],\n          [\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"]]\n      }],\n    ]})\n\n    # Fix up things missed due to the TODO above.\n    self.modify_column(\"People\", \"PartnerNames\",\n                       formula=self.partner_names.replace(\"name\", \"nombre\"))\n\n    _replace_col_name(self.people_data, \"name\", \"nombre\")\n    self.assertTableData(\"People\", cols=\"subset\", data=self.people_data)\n    self.assertTableData(\"Games\", cols=\"subset\", data=self.games_data)\n\n\n  def test_renames_e(self):\n    # Rename People.partner: affects People.partner4\n    # TODO: partner4 ($partner.partner.partner.partner) only gets updated partly because of\n    # astroid's avoidance of looking up the same attr on the same class during inference.\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"partner\", \"companero\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"partner\", \"companero\"],\n      [\"ModifyColumn\", \"People\", \"partner4\", {\n        \"formula\": \"$companero.companero.companero.companero\"\n      }],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [14, 15], {\n        \"colId\": [\"companero\", \"partner4\"],\n        \"formula\": [self.partner, \"$companero.companero.companero.companero\"]\n      }]\n    ]})\n\n    _replace_col_name(self.people_data, \"partner\", \"companero\")\n    self.assertTableData(\"People\", cols=\"subset\", data=self.people_data)\n    self.assertTableData(\"Games\", cols=\"subset\", data=self.games_data)\n\n\n  def test_renames_f(self):\n    # Rename People.win -> People.pwin. Make sure only Game.win is not affected.\n    out_actions = self.apply_user_action([\"RenameColumn\", \"People\", \"win\", \"pwin\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"People\", \"win\", \"pwin\"],\n      [\"ModifyColumn\", \"Games\", \"win3_person_name\", {\"formula\": \"$win.pwin.win.name\"}],\n      [\"ModifyColumn\", \"Games\", \"win4_game_name\", {\"formula\": \"$win.pwin.win.pwin.name\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [16, 18, 19], {\n        \"colId\": [\"pwin\", \"win3_person_name\", \"win4_game_name\"],\n        \"formula\": [\"Entries.lookupOne(person=$id, rank=1).game\",\n                    \"$win.pwin.win.name\", \"$win.pwin.win.pwin.name\"]}],\n    ]})\n\n    _replace_col_name(self.people_data, \"win\", \"pwin\")\n    self.assertTableData(\"People\", cols=\"subset\", data=self.people_data)\n    self.assertTableData(\"Games\", cols=\"subset\", data=self.games_data)\n\n\n  def test_renames_g(self):\n    # Rename Games.win -> Games.gwin.\n    out_actions = self.apply_user_action([\"RenameColumn\", \"Games\", \"win\", \"gwin\"])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameColumn\", \"Games\", \"win\", \"gwin\"],\n      [\"ModifyColumn\", \"Games\", \"win3_person_name\", {\"formula\": \"$gwin.win.gwin.name\"}],\n      [\"ModifyColumn\", \"Games\", \"win4_game_name\", {\"formula\": \"$gwin.win.gwin.win.name\"}],\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [17, 18, 19], {\n        \"colId\": [\"gwin\", \"win3_person_name\", \"win4_game_name\"],\n        \"formula\": [\"$winner\", \"$gwin.win.gwin.name\", \"$gwin.win.gwin.win.name\"]}],\n\n    ]})\n\n    _replace_col_name(self.games_data, \"win\", \"gwin\")\n    self.assertTableData(\"People\", cols=\"subset\", data=self.people_data)\n    self.assertTableData(\"Games\", cols=\"subset\", data=self.games_data)\n\n\n  def test_renames_h(self):\n    # Rename Entries -> Entradas. Affects Games.winner, Games.second, People.Games_Won,\n    # People.PartnerNames, People.partner, People.win.\n    out_actions = self.apply_user_action([\"RenameTable\", \"Entries\", \"Entradas\"])\n    self.partner_names = textwrap.dedent(\n      \"\"\"\n      games = Entradas.lookupRecords(person=$id).game\n      partners = [e.person for g in games for e in Entradas.lookupRecords(game=g)]\n      return ' '.join(p.name for p in partners if p.id != $id)\n      \"\"\")\n    self.partner = textwrap.dedent(\n      \"\"\"\n      game = Entradas.lookupOne(person=$id).game\n      next(e.person for e in Entradas.lookupRecords(game=game) if e.person != rec)\n      \"\"\").strip()\n\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"RenameTable\", \"Entries\", \"Entradas\"],\n      [\"UpdateRecord\", \"_grist_Tables\", 3, {\"tableId\": \"Entradas\"}],\n      [\"ModifyColumn\", \"Games\", \"winner\",\n       {\"formula\": \"Entradas.lookupOne(game=$id, rank=1).person\"}],\n      [\"ModifyColumn\", \"Games\", \"second\",\n       {\"formula\": \"Entradas.lookupOne(game=$id, rank=2).person\"}],\n      [\"ModifyColumn\", \"People\", \"Games_Won\", {\n        \"formula\": \"' '.join(e.game.name for e in Entradas.lookupRecords(person=$id, rank=1))\"\n      }],\n      [\"ModifyColumn\", \"People\", \"PartnerNames\", { \"formula\": self.partner_names }],\n      [\"ModifyColumn\", \"People\", \"partner\", {\"formula\": self.partner}],\n      [\"ModifyColumn\", \"People\", \"win\",\n       {\"formula\": \"Entradas.lookupOne(person=$id, rank=1).game\"}],\n\n      [\"BulkUpdateRecord\", \"_grist_Tables_column\", [5, 6, 12, 13, 14, 16], {\n        \"formula\": [\n          \"Entradas.lookupOne(game=$id, rank=1).person\",\n          \"Entradas.lookupOne(game=$id, rank=2).person\",\n          \"' '.join(e.game.name for e in Entradas.lookupRecords(person=$id, rank=1))\",\n          self.partner_names,\n          self.partner,\n          \"Entradas.lookupOne(person=$id, rank=1).game\"\n        ]}],\n    ]})\n\n    self.assertTableData(\"People\", cols=\"subset\", data=self.people_data)\n    self.assertTableData(\"Games\", cols=\"subset\", data=self.games_data)\n\n  def test_renames_i(self):\n    # Rename when using a local variable referring to a user table.\n    # Test also that a local variable that happens to match a global name is unaffected by renames.\n    self.modify_column(\"Games\", \"winner\", formula=(\n      \"myvar = Entries\\n\"\n      \"People = Entries\\n\"\n      \"myvar.lookupOne(game=$id, rank=1).person\\n\"\n      \"People.lookupOne(game=$id, rank=1).person\\n\"\n    ))\n    self.apply_user_action([\"RenameColumn\", \"Entries\", \"game\", \"juego\"])\n    self.apply_user_action([\"RenameTable\", \"People\", \"Persons\"])\n\n    # Check that renames worked.\n    new_col = self.engine.docmodel.columns.lookupOne(tableId='Games', colId='winner')\n    self.assertEqual(new_col.formula, (\n      \"myvar = Entries\\n\"\n      \"People = Entries\\n\"\n      \"myvar.lookupOne(juego=$id, rank=1).person\\n\"\n      \"People.lookupOne(juego=$id, rank=1).person\\n\"\n    ))\n\n    self.assertTableData(\"Persons\", cols=\"subset\", data=self.people_data)\n    self.assertTableData(\"Games\", cols=\"subset\", data=self.games_data)\n\n  def test_rename_with_blank_line(self):\n    # There was a bug when a blank line is present in some cases.\n    self.add_column(\"Games\", \"test1\", formula=(\n      \"if $winner:\\n\"\n      \"  \\n\"\n      \"  return $winner\\n\"\n    ))\n    # Also try some indents.\n    self.add_column(\"Games\", \"test2\", formula=(\n      \"   $winner\\n\" +\n      \"   if 1:\\n\" +\n      \"    UPPER($winner)\\n\" +\n      \"   None\"\n    ))\n    # Also try with leading/trailing blank/not-quite-blank lines\n    self.add_column(\"Games\", \"test3\", formula=(\n      \"   \\n\"\n      \"\\n\"\n      \"  test = $winner\\n\"\n      \"  \\n\"\n      \"  return $winner + test\\n\"\n      \" \\n\"\n    ))\n\n    self.apply_user_action([\"RenameColumn\", \"Games\", \"winner\", \"winner2\"])\n    self.assertEqual(self.engine.docmodel.columns.lookupOne(tableId='Games', colId='test1').formula,\n      \"if $winner2:\\n\"\n      \"  \\n\"\n      \"  return $winner2\\n\"\n    )\n    self.assertEqual(self.engine.docmodel.columns.lookupOne(tableId='Games', colId='test2').formula,\n      \"   $winner2\\n\" +\n      \"   if 1:\\n\" +\n      \"    UPPER($winner2)\\n\"\n      \"   None\"\n    )\n    self.assertEqual(self.engine.docmodel.columns.lookupOne(tableId='Games', colId='test3').formula,\n      \"   \\n\"\n      \"\\n\"\n      \"  test = $winner2\\n\"\n      \"  \\n\"\n      \"  return $winner2 + test\\n\"\n      \" \\n\"\n    )\n"
  },
  {
    "path": "sandbox/grist/test_replace_table_data.py",
    "content": "import logging\n\nimport test_engine\nfrom test_engine import Table, Column\n\nlog = logging.getLogger(__name__)\n\nclass TestReplaceTableData(test_engine.EngineTestCase):\n\n  @test_engine.test_undo\n  def test_replace_and_add(self):\n    # This tests a fix for a bug where after ReplaceTableData, subsequent adds were causing an\n    # error with \"relabeling\" (updating manualSort column).\n\n    # Add a table with a couple of columns and records.\n    self.apply_user_action([\"AddTable\", \"Vessels\", []])\n    self.apply_user_action([\"AddColumn\", \"Vessels\", \"Type\", {}])\n    self.apply_user_action([\"AddColumn\", \"Vessels\", \"Size\", {}])\n    self.apply_user_action([\"BulkAddRecord\", \"Vessels\", [None, None],\n      {\"Type\": [\"cup\", \"pot\"], \"Size\": [8, 64]}])\n\n    # Check that we guessed correct column types, and the values are there.\n    self.assertTables([\n      Table(1, \"Vessels\", primaryViewId=1, summarySourceTable=0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\",  False, \"\", 0),\n        Column(2, \"Type\",       \"Text\",           False, \"\", 0),\n        Column(3, \"Size\",       \"Numeric\",        False, \"\", 0),\n      ])\n    ])\n    self.assertTableData(\"Vessels\", cols=\"subset\", rows=\"all\", data=[\n      [ \"id\", \"Type\", \"Size\"  ],\n      [ 1,    \"cup\",     8    ],\n      [ 2,    \"pot\",    64    ],\n    ])\n\n    # Now do ReplaceTableData, and add more rows.\n    self.apply_user_action([\"ReplaceTableData\", \"Vessels\", [], {}])\n\n    # The bug used to happen here, manifesting as error\n    # \"docactions.[Bulk]UpdateRecord for non-existent # record #1\"\n    self.apply_user_action([\"BulkAddRecord\", \"Vessels\", [None, None],\n      {\"Type\": [\"shot\", \"bucket\"], \"Size\": [1.5, 640.0]}])\n    self.assertTableData(\"Vessels\", cols=\"subset\", rows=\"all\", data=[\n      [ \"id\", \"Type\",   \"Size\"  ],\n      [ 1,    \"shot\",     1.5   ],\n      [ 2,    \"bucket\",   640   ],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_replay.py",
    "content": "\"\"\"\nReplay binary data sent from JS to reproduce behaviour in the sandbox.\n\nThis isn't really a test and it doesn't run under normal circumstances,\nbut it's convenient to run it alongside other tests to measure total coverage.\n\nThis is a tool to directly run some python code of interest to make it easier to do things like:\n\n- Use a debugger within Python\n- Measure Python code coverage from JS tests\n- Rapidly iterate on Python code without having to repeatedly run the same JS\n    or write a Python test from scratch.\n\nTo use this, first set the environment variable RECORD_SANDBOX_BUFFERS_DIR to a directory path,\nthen run some JS code. For example you could run some tests,\nor run `npm start` and then manually interact with a document in a way that triggers\ndesired behaviour in the sandbox.\n\nThis will store files like $RECORD_SANDBOX_BUFFERS_DIR/<subdirectory>/(input|output)\nEach subdirectory corresponds to a single sandbox process so that replays are isolated.\nJS tests can start many instances of the sandbox and thus create many subdirectories.\n`input` contains the binary data sent from JS to Python, `output` contains the data sent back.\nCurrently, the name of each subdirectory is the time it was created.\n\nNow run this test with the same value of RECORD_SANDBOX_BUFFERS_DIR. For each subdirectory,\nit will read in `input` just as it would read the pipe from JS, and send output to a file\n`new_output` in the same subdirectory. Then it will compare the data in `output` and `new_output`.\nThe outputs will usually match but there are many reasons they might differ:\n\n- Functions registered in JS tests (e.g. via plugins) but not in the python unit tests.\n- File paths in tracebacks.\n- Slight differences between standard and NaCl interpreters.\n- Functions involving randomness or time.\n\nIn any case the point is usually not whether or not the outputs match, but to directly run\njust the python code of interest.\n\"\"\"\n\nfrom __future__ import print_function\n\nimport marshal\nimport os\nimport unittest\n\nfrom main import run\nfrom sandbox import Sandbox\n\ndef marshal_load_all(path):\n  result = []\n  with open(path, \"rb\") as f:\n    while True:\n      try:\n        result.append(marshal.load(f))\n      except EOFError:\n        break\n\n  return result\n\n\nclass TestReplay(unittest.TestCase):\n  maxDiff = None\n\n  def test_replay(self):\n    root = os.environ.get(\"RECORD_SANDBOX_BUFFERS_DIR\")\n    if not root:\n      self.skipTest(\"RECORD_SANDBOX_BUFFERS_DIR not set\")\n\n    for dirpath, dirnames, filenames in os.walk(root):\n      if \"input\" not in filenames:\n        continue\n\n      print(\"Checking \" + dirpath)\n\n      input_path = os.path.join(dirpath, \"input\")\n      output_path = os.path.join(dirpath, \"output\")\n      new_output_path = os.path.join(dirpath, \"new_output\")\n      with open(input_path, \"rb\") as external_input:\n        with open(new_output_path, \"wb\") as external_output:\n          import tracemalloc          # pylint: disable=import-error\n          tracemalloc.reset_peak()\n\n          sandbox = Sandbox(external_input, external_output)\n          run(sandbox)\n\n          # Run with env PYTHONTRACEMALLOC=1 to trace and print peak memory (runs much slower).\n          if tracemalloc.is_tracing():\n            mem_size, mem_peak = tracemalloc.get_traced_memory()\n            print(\"mem_size {}, mem_peak {}\".format(mem_size, mem_peak))\n\n      original_output = marshal_load_all(output_path)\n\n      # _send_to_js does two layers of marshalling,\n      # and NSandbox._onSandboxData parses one of those layers before writing,\n      # hence original_output is 'more parsed' than marshal_load_all(new_output_path)\n      new_output = [marshal.loads(b) for b in marshal_load_all(new_output_path)]\n\n      # It's usually not worth asserting a match, see comments at the top of the file\n      print(\"Match:\", original_output == new_output)\n      # self.assertEqual(original_output, new_output)\n"
  },
  {
    "path": "sandbox/grist/test_requests.py",
    "content": "# coding=utf-8\nimport unittest\n\nimport test_engine\nimport testutil\nfrom functions import CaseInsensitiveDict, Response, HTTPError\nfrom functions.info import _replicate_requests_body_args\n\n\nclass TestCaseInsensitiveDict(unittest.TestCase):\n  def test_case_insensitive_dict(self):\n    d = CaseInsensitiveDict({\"FOO\": 1})\n    for key in [\"foo\", \"FOO\", \"Foo\"]:\n      self.assertEqual(d, {\"foo\": 1})\n      self.assertEqual(list(d), [\"foo\"])\n      self.assertEqual(d, CaseInsensitiveDict({key: 1}))\n      self.assertIn(key, d)\n      self.assertEqual(d[key], 1)\n      self.assertEqual(d.get(key), 1)\n      self.assertEqual(d.get(key, 2), 1)\n      self.assertEqual(d.get(key + \"2\", 2), 2)\n      self.assertEqual(d.pop(key), 1)\n      self.assertEqual(d, {})\n      self.assertEqual(d.setdefault(key, 3), 3)\n      self.assertEqual(d, {\"foo\": 3})\n      self.assertEqual(d.setdefault(key, 4), 3)\n      self.assertEqual(d, {\"foo\": 3})\n      del d[key]\n      self.assertEqual(d, {})\n      d[key] = 1\n\n\nclass TestResponse(unittest.TestCase):\n  def test_ok_response(self):\n    r = Response(b\"foo\", 200, \"OK\", {\"X-header\": \"hi\"}, None)\n    self.assertEqual(r.content, b\"foo\")\n    self.assertEqual(r.text, u\"foo\")\n    self.assertEqual(r.status_code, 200)\n    self.assertEqual(r.ok, True)\n    self.assertEqual(r.reason, \"OK\")\n    self.assertEqual(r.headers, {\"x-header\": \"hi\"})\n    self.assertEqual(r.encoding, \"ascii\")\n    self.assertEqual(r.apparent_encoding, \"ascii\")\n    r.raise_for_status()\n    r.close()\n\n  def test_error_response(self):\n    r = Response(b\"foo\", 500, \"Server error\", {}, None)\n    self.assertEqual(r.status_code, 500)\n    self.assertEqual(r.ok, False)\n    self.assertEqual(r.reason, \"Server error\")\n    with self.assertRaises(HTTPError) as cm:\n      r.raise_for_status()\n    self.assertEqual(str(cm.exception), \"Request failed with status 500\")\n\n  def test_json(self):\n    r = Response(b'{\"foo\": \"bar\"}', 200, \"OK\", {}, None)\n    self.assertEqual(r.json(), {\"foo\": \"bar\"})\n\n  def test_encoding_direct(self):\n    r = Response(b\"foo\", 200, \"OK\", {}, \"some encoding\")\n    self.assertEqual(r.encoding, \"some encoding\")\n    self.assertEqual(r.apparent_encoding, \"ascii\")\n\n  def test_apparent_encoding(self):\n    text = u\"编程\"\n    encoding = \"utf-8\"\n    content = text.encode(encoding)\n    self.assertEqual(content.decode(encoding), text)\n    r = Response(content, 200, \"OK\", {}, \"\")\n    self.assertEqual(r.encoding, encoding)\n    self.assertEqual(r.apparent_encoding, encoding)\n    self.assertEqual(r.content, content)\n    self.assertEqual(r.text, text)\n\n  def test_unknown_undetectable_encoding(self):\n    content = b''\n    r = Response(content, 200, \"OK\", {}, encoding=None)\n\n    # Not knowing the encoding should not break text\n    self.assertEqual(r.text, \"\")\n\n\nclass TestRequestsPostInterface(unittest.TestCase):\n    def test_no_post_args(self):\n        body, headers = _replicate_requests_body_args()\n\n        assert body is None\n        assert headers == {}\n\n    def test_data_as_dict(self):\n        body, headers = _replicate_requests_body_args(data={\"foo\": \"bar\"})\n\n        assert body == \"foo=bar\"\n        assert headers == {\"Content-Type\": \"application/x-www-form-urlencoded\"}\n\n    def test_data_as_string(self):\n        body, headers = _replicate_requests_body_args(data=\"some_content\")\n\n        assert body == \"some_content\"\n        assert headers == {}\n\n    def test_json_as_dict(self):\n        body, headers = _replicate_requests_body_args(json={\"foo\": \"bar\"})\n\n        assert body == '{\"foo\": \"bar\"}'\n        assert headers == {\"Content-Type\": \"application/json\"}\n\n    def test_json_as_string(self):\n        body, headers = _replicate_requests_body_args(json=\"invalid_but_ignored\")\n\n        assert body == \"invalid_but_ignored\"\n        assert headers == {\"Content-Type\": \"application/json\"}\n\n    def test_data_and_json_together(self):\n        with self.assertRaises(ValueError):\n            body, headers = _replicate_requests_body_args(\n                json={\"foo\": \"bar\"},\n                data={\"quux\": \"jazz\"}\n            )\n\n\nclass TestRequestFunction(test_engine.EngineTestCase):\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Table1\", [\n        [2, \"Request\", \"Any\", True, \"$id\", \"\", \"\"],\n        [3, \"Other\", \"Any\", True, \"\", \"\", \"\"],\n      ]],\n    ],\n    \"DATA\": {\n      \"Table1\": [\n        [\"id\"],\n        [1],\n        [2],\n      ],\n    }\n  })\n\n  def test_request_function(self):\n    self.load_sample(self.sample)\n\n    formula = \"\"\"\nr = REQUEST('my_url', headers={'foo': 'bar'}, params={'b': 1, 'a': 2})\nr.__dict__\n\"\"\"\n    out_actions = self.modify_column(\"Table1\", \"Request\", formula=formula)\n    key = 'd7f8cedf177ab538bf7dadf66e77a525486a29a41ce4520b2c89a33e39095fed'\n    deps = {'Table1': {'Request': [1, 2]}}\n    args = {\n      'url': 'my_url',\n      'headers': {'foo': 'bar'},\n      'params': {'a': 2, 'b': 1},\n      'method': 'GET',\n      'body': None,\n      'deps': deps,\n    }\n    self.assertEqual(out_actions.requests, {key: args})\n    self.assertTableData(\"Table1\", cols=\"subset\", data=[\n      [\"id\", \"Request\"],\n      [1, 1],\n      [2, 2],\n    ])\n\n    response = {\n      'status': 200,\n      'statusText': 'OK',\n      'content': b'body',\n      'headers': {'h1': 'h2'},\n      'encoding': 'utf16',\n      'deps': deps,\n    }\n    self.apply_user_action([\"RespondToRequests\", {key: response.copy()}, [key]])\n\n    # Translate names from JS `fetch` API to Python `requests`-style API\n    response[\"status_code\"] = response.pop(\"status\")\n    response[\"reason\"] = response.pop(\"statusText\")\n    # This is sent in the user action but not kept for the response object\n    del response[\"deps\"]\n\n    self.assertTableData(\"Table1\", cols=\"subset\", data=[\n      [\"id\", \"Request\"],\n      [1, response],\n      [2, response],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_rules.py",
    "content": "# -*- coding: utf-8 -*-\nimport json\n\nfrom collections import namedtuple\nfrom summary import skip_rules_update\nimport testutil\nimport test_engine\n\n\nclass TestRules(test_engine.EngineTestCase):\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Inventory\", [\n        [2, \"Label\", \"Text\", False, \"\", \"\", \"\"],\n        [3, \"Stock\", \"Int\", False, \"\", \"\", \"\"],\n      ]],\n    ],\n    \"DATA\": {\n      \"Inventory\": [\n        [\"id\", \"Label\", \"Stock\"],\n        [1, \"A1\", 0],\n        [2, \"A2\", 2],\n        [3, \"A3\", 5],\n        # Duplicate\n        [4, \"A1\", 10]\n      ],\n    }\n  })\n\n  # Helper for rules action\n  def add_empty(self, col_id):\n    return self.apply_user_action(['AddEmptyRule', \"Inventory\", 0, col_id])\n\n  def field_add_empty(self, field_id):\n    return self.apply_user_action(['AddEmptyRule', \"Inventory\", field_id, 0])\n\n  def set_rule(self, col_id, rule_index, formula):\n    rules = self.engine.docmodel.columns.table.get_record(col_id).rules\n    rule = list(rules)[rule_index]\n    return self.apply_user_action(['UpdateRecord', '_grist_Tables_column',\n                                   rule.id, {\"formula\": formula}])\n\n  def field_set_rule(self, field_id, rule_index, formula):\n    rules = self.engine.docmodel.view_fields.table.get_record(field_id).rules\n    rule = list(rules)[rule_index]\n    return self.apply_user_action(['UpdateRecord', '_grist_Tables_column',\n                                   rule.id, {\"formula\": formula}])\n\n  def remove_rule(self, col_id, rule_index):\n    rules = self.engine.docmodel.columns.table.get_record(col_id).rules\n    rule = list(rules)[rule_index]\n    return self.apply_user_action(['RemoveColumn', 'Inventory', rule.colId])\n\n  def field_remove_rule(self, field_id, rule_index):\n    rules = self.engine.docmodel.view_fields.table.get_record(field_id).rules\n    rule = list(rules)[rule_index]\n    return self.apply_user_action(['RemoveColumn', 'Inventory', rule.colId])\n\n  def test_summary_updates(self):\n    Col = namedtuple('Col', 'widgetOptions')\n    col = Col(None)\n    # Should remove rules from update\n    self.assertEqual({}, skip_rules_update(col, {'rules': [15]}))\n    # Should leave col_updates untouched when there are no rules.\n    col_updates = {'type': 'Int'}\n    self.assertEqual(col_updates, skip_rules_update(col, col_updates))\n\n    # Should return same dict when not updating ruleOptions\n    col_updates = {'widgetOptions': '{\"color\": \"red\"}'}\n    self.assertEqual(col_updates, skip_rules_update(col, col_updates))\n    col = Col('{\"color\": \"red\"}')\n    self.assertEqual(col_updates, skip_rules_update(col, col_updates))\n\n    # Should remove ruleOptions from update\n    col_updates = {'widgetOptions': '{\"rulesOptions\": [{\"color\": \"black\"}], \"color\": \"blue\"}'}\n    self.assertEqual({'widgetOptions': '{\"color\": \"blue\"}'},\n                         skip_rules_update(col, col_updates))\n    col_updates = {'widgetOptions': '{\"rulesOptions\": [], \"color\": \"blue\"}'}\n    self.assertEqual({'widgetOptions': '{\"color\": \"blue\"}'},\n                         skip_rules_update(col, col_updates))\n\n    # Should preserve original ruleOptions\n    col = Col('{\"rulesOptions\": [{\"color\":\"red\"}], \"color\": \"blue\"}')\n    col_updates = {'widgetOptions': '{\"rulesOptions\": [{\"color\": \"black\"}], \"color\": \"red\"}'}\n    updated = skip_rules_update(col, col_updates)\n    self.assertEqual({\"rulesOptions\": [{\"color\": \"red\"}], \"color\": \"red\"},\n                         json.loads(updated.get('widgetOptions')))\n    col_updates = {'widgetOptions': '{\"color\": \"red\"}'}\n    updated = skip_rules_update(col, col_updates)\n    self.assertEqual({\"rulesOptions\": [{\"color\": \"red\"}], \"color\": \"red\"},\n                         json.loads(updated.get('widgetOptions')))\n\n\n  def test_simple_rules(self):\n    self.load_sample(self.sample)\n    # Mark all records with Stock = 0\n    out_actions = self.add_empty(3)\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"AddColumn\", \"Inventory\", \"gristHelper_ConditionalRule\",\n       {\"formula\": \"\", \"isFormula\": True, \"type\": \"Any\"}],\n      [\"AddRecord\", \"_grist_Tables_column\", 4,\n       {\"colId\": \"gristHelper_ConditionalRule\", \"formula\": \"\", \"isFormula\": True,\n        \"label\": \"gristHelper_ConditionalRule\", \"parentId\": 1, \"parentPos\": 3.0,\n        \"type\": \"Any\",\n        \"widgetOptions\": \"\"}],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 3, {\"rules\": [\"L\", 4]}],\n    ]})\n    out_actions = self.set_rule(3, 0, \"$Stock == 0\")\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"ModifyColumn\", \"Inventory\", \"gristHelper_ConditionalRule\",\n       {\"formula\": \"$Stock == 0\"}],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 4, {\"formula\": \"$Stock == 0\"}],\n      [\"BulkUpdateRecord\", \"Inventory\", [1, 2, 3, 4],\n       {\"gristHelper_ConditionalRule\": [True, False, False, False]}],\n    ]})\n\n    # Replace this rule with another rule to mark Stock = 2\n    out_actions = self.set_rule(3, 0, \"$Stock == 2\")\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"ModifyColumn\", \"Inventory\", \"gristHelper_ConditionalRule\",\n       {\"formula\": \"$Stock == 2\"}],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 4, {\"formula\": \"$Stock == 2\"}],\n      [\"BulkUpdateRecord\", \"Inventory\", [1, 2],\n       {\"gristHelper_ConditionalRule\": [False, True]}],\n    ]})\n\n    # Add another rule Stock = 10\n    out_actions = self.add_empty(3)\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"AddColumn\", \"Inventory\", \"gristHelper_ConditionalRule2\",\n       {\"formula\": \"\", \"isFormula\": True, \"type\": \"Any\"}],\n      [\"AddRecord\", \"_grist_Tables_column\", 5,\n       {\"colId\": \"gristHelper_ConditionalRule2\", \"formula\": \"\", \"isFormula\": True,\n        \"label\": \"gristHelper_ConditionalRule2\", \"parentId\": 1, \"parentPos\": 4.0,\n        \"type\": \"Any\",\n        \"widgetOptions\": \"\"}],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 3, {\"rules\": [\"L\", 4, 5]}],\n    ]})\n    out_actions = self.set_rule(3, 1, \"$Stock == 10\")\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"ModifyColumn\", \"Inventory\", \"gristHelper_ConditionalRule2\",\n       {\"formula\": \"$Stock == 10\"}],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"formula\": \"$Stock == 10\"}],\n      [\"BulkUpdateRecord\", \"Inventory\", [1, 2, 3, 4],\n       {\"gristHelper_ConditionalRule2\": [False, False, False, True]}],\n    ]})\n\n    # Remove the last rule\n    out_actions = self.remove_rule(3, 1)\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"RemoveRecord\", \"_grist_Tables_column\", 5],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 3, {\"rules\": [\"L\", 4]}],\n      [\"RemoveColumn\", \"Inventory\", \"gristHelper_ConditionalRule2\"]\n    ]})\n\n    # Remove last rule\n    out_actions = self.remove_rule(3, 0)\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"RemoveRecord\", \"_grist_Tables_column\", 4],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 3, {\"rules\": None}],\n      [\"RemoveColumn\", \"Inventory\", \"gristHelper_ConditionalRule\"]\n    ]})\n\n  def test_duplicates(self):\n    self.load_sample(self.sample)\n\n    # Create rule that marks duplicate values\n    formula = \"len(Inventory.lookupRecords(Label=$Label)) > 1\"\n\n    # First add rule on stock column, to test naming - second rule column should have 2 as a suffix\n    self.add_empty(3)\n    self.set_rule(3, 0, \"$Stock == 0\")\n    # Now highlight duplicates on labels\n    self.add_empty(2)\n    out_actions = self.set_rule(2, 0, formula)\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"ModifyColumn\", \"Inventory\", \"gristHelper_ConditionalRule2\",\n       {\"formula\": \"len(Inventory.lookupRecords(Label=$Label)) > 1\"}],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 5,\n       {\"formula\": \"len(Inventory.lookupRecords(Label=$Label)) > 1\"}],\n      [\"BulkUpdateRecord\", \"Inventory\", [1, 2, 3, 4],\n       {\"gristHelper_ConditionalRule2\": [True, False, False, True]}]\n    ]})\n\n  def test_column_removal(self):\n    # Test that rules are removed with a column.\n\n    self.load_sample(self.sample)\n    self.add_empty(3)\n    self.set_rule(3, 0, \"$Stock == 0\")\n    before = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule')\n    self.assertNotEqual(before, 0)\n    out_actions = self.apply_user_action(['RemoveColumn', 'Inventory', 'Stock'])\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"BulkRemoveRecord\", \"_grist_Tables_column\", [3, 4]],\n      [\"RemoveColumn\", \"Inventory\", \"Stock\"],\n      [\"RemoveColumn\", \"Inventory\", \"gristHelper_ConditionalRule\"],\n    ]})\n\n  def test_column_removal_for_a_field(self):\n    # Test that rules are removed with a column when attached to a field.\n\n    self.load_sample(self.sample)\n    self.apply_user_action(['CreateViewSection', 1, 0, 'record', None, None])\n    self.field_add_empty(2)\n    self.field_set_rule(2, 0, \"$Stock == 0\")\n    before = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule')\n    self.assertNotEqual(before, 0)\n    out_actions = self.apply_user_action(['RemoveColumn', 'Inventory', 'Stock'])\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"RemoveRecord\", \"_grist_Views_section_field\", 2],\n      [\"BulkRemoveRecord\", \"_grist_Tables_column\", [3, 4]],\n      [\"RemoveColumn\", \"Inventory\", \"Stock\"],\n      [\"RemoveColumn\", \"Inventory\", \"gristHelper_ConditionalRule\"],\n    ]})\n\n  def test_field_removal(self):\n    # Test that rules are removed with a field.\n\n    self.load_sample(self.sample)\n    self.apply_user_action(['CreateViewSection', 1, 0, 'record', None, None])\n    self.field_add_empty(2)\n    self.field_set_rule(2, 0, \"$Stock == 0\")\n    rule_id = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule').id\n    self.assertNotEqual(rule_id, 0)\n    out_actions = self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 2])\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"RemoveRecord\", \"_grist_Views_section_field\", 2],\n      [\"RemoveRecord\", \"_grist_Tables_column\", rule_id],\n      [\"RemoveColumn\", \"Inventory\", \"gristHelper_ConditionalRule\"]\n    ]})\n"
  },
  {
    "path": "sandbox/grist/test_rules_grid.py",
    "content": "# -*- coding: utf-8 -*-\nimport test_engine\n\n\nclass TestGridRules(test_engine.EngineTestCase):\n  # Helper for rules action\n  def add_empty(self):\n    return self.apply_user_action(['AddEmptyRule', \"Table1\", 0, 0])\n\n\n  def set_rule(self, rule_index, formula):\n    rules = self.engine.docmodel.tables.lookupOne(tableId='Table1').rawViewSectionRef.rules\n    rule = list(rules)[rule_index]\n    return self.apply_user_action(['UpdateRecord', '_grist_Tables_column',\n                                   rule.id, {\"formula\": formula}])\n\n\n  def remove_rule(self, rule_index):\n    rules = self.engine.docmodel.tables.lookupOne(tableId='Table1').rawViewSectionRef.rules\n    rule = list(rules)[rule_index]\n    return self.apply_user_action(['RemoveColumn', 'Table1', rule.colId])\n\n\n  def test_simple_rules(self):\n    self.apply_user_action(['AddEmptyTable', None])\n    self.apply_user_action(['AddRecord', \"Table1\", None, {\"A\": 1}])\n    self.apply_user_action(['AddRecord', \"Table1\", None, {\"A\": 2}])\n    self.apply_user_action(['AddRecord', \"Table1\", None, {\"A\": 3}])\n    out_actions = self.add_empty()\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"AddColumn\", \"Table1\", \"gristHelper_RowConditionalRule\",\n       {\"formula\": \"\", \"isFormula\": True, \"type\": \"Any\"}],\n      [\"AddRecord\", \"_grist_Tables_column\", 5,\n       {\"colId\": \"gristHelper_RowConditionalRule\", \"formula\": \"\", \"isFormula\": True,\n        \"label\": \"gristHelper_RowConditionalRule\", \"parentId\": 1, \"parentPos\": 5.0,\n        \"type\": \"Any\",\n        \"widgetOptions\": \"\"}],\n      [\"UpdateRecord\", \"_grist_Views_section\", 2, {\"rules\": [\"L\", 5]}],\n    ]})\n    out_actions = self.set_rule(0, \"$A == 1\")\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"ModifyColumn\", \"Table1\", \"gristHelper_RowConditionalRule\",\n       {\"formula\": \"$A == 1\"}],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"formula\": \"$A == 1\"}],\n      [\"BulkUpdateRecord\", \"Table1\", [1, 2, 3],\n       {\"gristHelper_RowConditionalRule\": [True, False, False]}],\n    ]})\n\n    # Replace this rule with another rule to mark A = 2\n    out_actions = self.set_rule(0, \"$A == 2\")\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"ModifyColumn\", \"Table1\", \"gristHelper_RowConditionalRule\",\n       {\"formula\": \"$A == 2\"}],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"formula\": \"$A == 2\"}],\n      [\"BulkUpdateRecord\", \"Table1\", [1, 2],\n       {\"gristHelper_RowConditionalRule\": [False, True]}],\n    ]})\n\n    # Add another rule A = 3\n    self.add_empty()\n    out_actions = self.set_rule(1, \"$A == 3\")\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"ModifyColumn\", \"Table1\", \"gristHelper_RowConditionalRule2\",\n       {\"formula\": \"$A == 3\"}],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"formula\": \"$A == 3\"}],\n      [\"BulkUpdateRecord\", \"Table1\", [1, 2, 3],\n       {\"gristHelper_RowConditionalRule2\": [False, False, True]}],\n    ]})\n\n    # Remove the last rule\n    out_actions = self.remove_rule(1)\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"RemoveRecord\", \"_grist_Tables_column\", 6],\n      [\"UpdateRecord\", \"_grist_Views_section\", 2, {\"rules\": [\"L\", 5]}],\n      [\"RemoveColumn\", \"Table1\", \"gristHelper_RowConditionalRule2\"]\n    ]})\n\n    # Remove last rule\n    out_actions = self.remove_rule(0)\n    self.assertPartialOutActions(out_actions, {\"stored\": [\n      [\"RemoveRecord\", \"_grist_Tables_column\", 5],\n      [\"UpdateRecord\", \"_grist_Views_section\", 2, {\"rules\": None}],\n      [\"RemoveColumn\", \"Table1\", \"gristHelper_RowConditionalRule\"]\n    ]})\n"
  },
  {
    "path": "sandbox/grist/test_side_effects.py",
    "content": "# This test verifies behavior when a formula produces side effects. The prime example is\n# lookupOrAddDerived() function, which adds new records (and is the basis for summary tables).\n\nimport objtypes\nimport test_engine\nimport testutil\n\nclass TestSideEffects(test_engine.EngineTestCase):\n  address_table_data = [\n    [\"id\",  \"city\",     \"state\", \"amount\" ],\n    [ 21,   \"New York\", \"NY\"   , 1        ],\n    [ 22,   \"Albany\",   \"NY\"   , 2        ],\n  ]\n\n  schools_table_data = [\n    [\"id\",  \"city\"     , \"name\" ],\n    [1,    \"Boston\"    , \"MIT\"  ],\n    [2,    \"New York\"  , \"NYU\"  ],\n  ]\n\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Address\", [\n        [1, \"city\",        \"Text\",       False, \"\", \"\", \"\"],\n        [2, \"state\",       \"Text\",       False, \"\", \"\", \"\"],\n        [3, \"amount\",      \"Numeric\",    False, \"\", \"\", \"\"],\n      ]],\n      [2, \"Schools\", [\n        [11,   \"name\",        \"Text\",      False, \"\", \"\", \"\"],\n        [12,   \"city\",        \"Text\",      False, \"\", \"\", \"\"],\n      ]],\n    ],\n    \"DATA\": {\n      \"Address\": address_table_data,\n      \"Schools\": schools_table_data,\n    }\n  })\n\n  def test_failure_after_side_effect(self):\n    # Verify that when a formula fails after a side-effect, the effect is reverted.\n    self.load_sample(self.sample)\n\n    formula = 'Schools.lookupOrAddDerived(city=\"TESTCITY\")\\nraise Exception(\"test-error\")\\nNone'\n    out_actions = self.apply_user_action(['AddColumn', 'Address', \"A\", { 'formula': formula }])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"AddColumn\", \"Address\", \"A\", {\"formula\": formula, \"isFormula\": True, \"type\": \"Any\"}],\n      [\"AddRecord\", \"_grist_Tables_column\", 13, {\n        \"colId\": \"A\", \"formula\": formula, \"isFormula\": True, \"label\": \"A\",\n        \"parentId\": 1, \"parentPos\": 4.0, \"type\": \"Any\", \"widgetOptions\": \"\"\n      }],\n      [\"BulkUpdateRecord\", \"Address\", [21, 22], {\"A\": [[\"E\", \"Exception\"], [\"E\", \"Exception\"]]}],\n      # The thing to note  here is that while lookupOrAddDerived() should have added a row to\n      # Schools, the Exception negated it, and there is no action to add that row.\n    ]})\n\n    # Check that data is as expected: no new records in Schools, one new column in Address.\n    self.assertTableData('Schools', cols=\"all\", data=self.schools_table_data)\n    self.assertTableData('Address', cols=\"all\", data=[\n      [\"id\",  \"city\",     \"state\", \"amount\", \"A\"            ],\n      [ 21,   \"New York\", \"NY\"   , 1,        objtypes.RaisedException(Exception())  ],\n      [ 22,   \"Albany\",   \"NY\"   , 2,        objtypes.RaisedException(Exception())  ],\n    ])\n\n\n  def test_calc_actions_in_side_effect_rollback(self):\n    self.load_sample(self.sample)\n\n    # Formula which allows a side effect to be conditionally rolled back.\n    formula = '''\nSchools.lookupOrAddDerived(city=$city)\nif $amount < 0:\n  raise Exception(\"test-error\")\nreturn None\n'''\n    self.add_column('Schools', 'ucity', formula='$city.upper()')\n    self.add_column('Address', 'A', formula=formula)\n\n    self.assertTableData('Schools', cols=\"all\", data=[\n      [\"id\", \"city\", \"name\", \"ucity\"],\n      [1, \"Boston\", \"MIT\", \"BOSTON\"],\n      [2, \"New York\", \"NYU\", \"NEW YORK\"],\n      [3, \"Albany\", \"\", \"ALBANY\"],\n    ])\n\n    # Check that a successful side-effect which adds a row triggers calc actions for that row.\n    out_actions = self.update_record('Address', 22, city=\"aaa\", amount=1000)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"UpdateRecord\", \"Address\", 22, {\"amount\": 1000.0, \"city\": \"aaa\"}],\n        [\"AddRecord\", \"Schools\", 4, {\"city\": \"aaa\"}],\n        [\"UpdateRecord\", \"Schools\", 4, {\"ucity\": \"AAA\"}],\n      ],\n    })\n    self.assertTableData('Schools', cols=\"all\", data=[\n      [\"id\", \"city\", \"name\", \"ucity\"],\n      [1, \"Boston\", \"MIT\", \"BOSTON\"],\n      [2, \"New York\", \"NYU\", \"NEW YORK\"],\n      [3, \"Albany\", \"\", \"ALBANY\"],\n      [4, \"aaa\", \"\", \"AAA\"],\n    ])\n\n    # Check that a side effect that failed and got rolled back does not include calc actions for\n    # the rows that didn't stay.\n    out_actions = self.update_record('Address', 22, city=\"bbb\", amount=-3)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"UpdateRecord\", \"Address\", 22, {\"amount\": -3.0, \"city\": \"bbb\"}],\n        [\"UpdateRecord\", \"Address\", 22, {\"A\": [\"E\", \"Exception\"]}],\n      ],\n    })\n    self.assertTableData('Schools', cols=\"all\", data=[\n      [\"id\", \"city\", \"name\", \"ucity\"],\n      [1, \"Boston\", \"MIT\", \"BOSTON\"],\n      [2, \"New York\", \"NYU\", \"NEW YORK\"],\n      [3, \"Albany\", \"\", \"ALBANY\"],\n      [4, \"aaa\", \"\", \"AAA\"],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_sort_key.py",
    "content": "import test_engine\nimport testutil\nfrom sort_key import make_sort_key\n\nclass TestSortKey(test_engine.EngineTestCase):\n  def test_sort_key(self):\n    # Set up a table with a few rows.\n    self.load_sample(testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Values\", [\n          [1, \"Date\", \"Numeric\", False, \"\", \"\", \"\"],\n          [2, \"Type\", \"Text\", False, \"\", \"\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"Values\": [\n          [\"id\", \"Date\",    \"Type\"],\n          [1,     5,        \"a\"],\n          [2,     4,        \"a\"],\n          [3,     5,        \"b\"],\n        ],\n      }\n    }))\n\n    table = self.engine.tables[\"Values\"]\n    sort_key1 = make_sort_key(table, (\"Date\", \"-Type\"))\n    sort_key2 = make_sort_key(table, (\"-Date\", \"Type\"))\n    self.assertEqual(sorted([1, 2, 3], key=sort_key1), [2, 3, 1])\n    self.assertEqual(sorted([1, 2, 3], key=sort_key2), [1, 3, 2])\n\n    # Change some values\n    self.update_record(\"Values\", 2, Date=6)\n    self.assertEqual(sorted([1, 2, 3], key=sort_key1), [3, 1, 2])\n    self.assertEqual(sorted([1, 2, 3], key=sort_key2), [2, 1, 3])\n\n\n  def test_column_rename(self):\n    \"\"\"\n    Make sure that renaming a column to another name and back does not continue using stale\n    references to the deleted column.\n    \"\"\"\n    # Note that SortedLookupMapColumn does retain references to the columns it uses for sorting,\n    # but lookup columns themselves get deleted and rebuilt in these cases (by mysterious voodoo).\n\n    # Create a simple table (People) with a couple records.\n    self.apply_user_action([\"AddTable\", \"People\", [\n      dict(id=\"Name\", type=\"Text\")\n    ]])\n    self.add_record(\"People\", Name=\"Alice\")\n    self.add_record(\"People\", Name=\"Bob\")\n\n    # Create a separate table that does a lookup in the People table.\n    self.apply_user_action([\"AddTable\", \"Test\", [\n      dict(id=\"Lookup1\", type=\"Any\", isFormula=True,\n        formula=\"People.lookupOne(order_by='-Name').Name\"),\n      dict(id=\"Lookup2\", type=\"Any\", isFormula=True,\n        formula=\"People.lookupOne(order_by='Name').Name\"),\n      dict(id=\"Lookup3\", type=\"Any\", isFormula=True,\n        formula=\"People.lookupOne(Name='Bob').Name\"),\n    ]])\n    self.add_record(\"Test\")\n\n    # Test that lookups return data as expected.\n    self.assertTableData('Test', cols=\"subset\", data=[\n      dict(id=1, Lookup1=\"Bob\", Lookup2=\"Alice\", Lookup3=\"Bob\")\n    ])\n\n    # Rename a column used for lookups or order_by. Lookup result shouldn't change.\n    self.apply_user_action([\"RenameColumn\", \"People\", \"Name\", \"FullName\"])\n    self.assertTableData('Test', cols=\"subset\", data=[\n      dict(id=1, Lookup1=\"Bob\", Lookup2=\"Alice\", Lookup3=\"Bob\")\n    ])\n\n    # Rename the column back. Lookup result shouldn't change.\n    self.apply_user_action([\"RenameColumn\", \"People\", \"FullName\", \"Name\"])\n    self.assertTableData('Test', cols=\"subset\", data=[\n      dict(id=1, Lookup1=\"Bob\", Lookup2=\"Alice\", Lookup3=\"Bob\")\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_sort_spec.py",
    "content": "# coding=utf-8\nimport unittest\n\nimport sort_specs\n\nclass TestSortSpec(unittest.TestCase):\n  def test_direction(self):\n    self.assertEqual(sort_specs.direction(1), 1)\n    self.assertEqual(sort_specs.direction(-1), -1)\n    self.assertEqual(sort_specs.direction('1'), 1)\n    self.assertEqual(sort_specs.direction('-1'), -1)\n    self.assertEqual(sort_specs.direction('1:emptyLast'), 1)\n    self.assertEqual(sort_specs.direction('1:emptyLast;orderByChoice'), 1)\n    self.assertEqual(sort_specs.direction('-1:emptyLast;orderByChoice'), -1)\n\n  def test_col_ref(self):\n    self.assertEqual(sort_specs.col_ref(1), 1)\n    self.assertEqual(sort_specs.col_ref(-1), 1)\n    self.assertEqual(sort_specs.col_ref('1'), 1)\n    self.assertEqual(sort_specs.col_ref('-1'), 1)\n    self.assertEqual(sort_specs.col_ref('1:emptyLast'), 1)\n    self.assertEqual(sort_specs.col_ref('1:emptyLast;orderByChoice'), 1)\n    self.assertEqual(sort_specs.col_ref('-1:emptyLast;orderByChoice'), 1)\n\n  def test_swap_col_ref(self):\n    self.assertEqual(sort_specs.swap_col_ref(1, 2), 2)\n    self.assertEqual(sort_specs.swap_col_ref(-1, 2), -2)\n    self.assertEqual(sort_specs.swap_col_ref('1', 2), '2')\n    self.assertEqual(sort_specs.swap_col_ref('-1', 2), '-2')\n    self.assertEqual(sort_specs.swap_col_ref('1:emptyLast', 2), '2:emptyLast')\n    self.assertEqual(\n      sort_specs.swap_col_ref('1:emptyLast;orderByChoice', 2),\n      '2:emptyLast;orderByChoice')\n    self.assertEqual(\n      sort_specs.swap_col_ref('-1:emptyLast;orderByChoice', 2),\n      '-2:emptyLast;orderByChoice')\n"
  },
  {
    "path": "sandbox/grist/test_summary.py",
    "content": "\"\"\"\nTest of Summary tables. This has many test cases, so to keep files smaller, it's split into two\nfiles: test_summary.py and test_summary2.py.\n\"\"\"\n\nimport logging\nimport unittest\n\nimport actions\nimport summary\nimport test_engine\nimport testutil\nimport useractions\nfrom test_engine import Table, Column, View, Section, Field\nfrom useractions import allowed_summary_change\n\nlog = logging.getLogger(__name__)\n\n\nclass TestSummary(test_engine.EngineTestCase):\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Address\", [\n        [11, \"city\",        \"Text\",       False, \"\", \"City\", \"\"],\n        [12, \"state\",       \"Text\",       False, \"\", \"State\", \"WidgetOptions1\"],\n        [13, \"amount\",      \"Numeric\",    False, \"\", \"Amount\", \"WidgetOptions2\"],\n      ]]\n    ],\n    \"DATA\": {\n      \"Address\": [\n        [\"id\",  \"city\",     \"state\", \"amount\" ],\n        [ 21,   \"New York\", \"NY\"   , 1.       ],\n        [ 22,   \"Albany\",   \"NY\"   , 2.       ],\n        [ 23,   \"Seattle\",  \"WA\"   , 3.       ],\n        [ 24,   \"Chicago\",  \"IL\"   , 4.       ],\n        [ 25,   \"Bedford\",  \"MA\"   , 5.       ],\n        [ 26,   \"New York\", \"NY\"   , 6.       ],\n        [ 27,   \"Buffalo\",  \"NY\"   , 7.       ],\n        [ 28,   \"Bedford\",  \"NY\"   , 8.       ],\n        [ 29,   \"Boston\",   \"MA\"   , 9.       ],\n        [ 30,   \"Yonkers\",  \"NY\"   , 10.      ],\n        [ 31,   \"New York\", \"NY\"   , 11.      ],\n      ]\n    }\n  })\n\n  starting_table = Table(1, \"Address\", primaryViewId=0, summarySourceTable=0, columns=[\n    Column(11, \"city\",  \"Text\", isFormula=False, formula=\"\", summarySourceCol=0),\n    Column(12, \"state\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=0),\n    Column(13, \"amount\", \"Numeric\", isFormula=False, formula=\"\", summarySourceCol=0),\n  ])\n\n  starting_table_data = [\n    [\"id\",  \"city\",     \"state\", \"amount\" ],\n    [ 21,   \"New York\", \"NY\"   , 1        ],\n    [ 22,   \"Albany\",   \"NY\"   , 2        ],\n    [ 23,   \"Seattle\",  \"WA\"   , 3        ],\n    [ 24,   \"Chicago\",  \"IL\"   , 4        ],\n    [ 25,   \"Bedford\",  \"MA\"   , 5        ],\n    [ 26,   \"New York\", \"NY\"   , 6        ],\n    [ 27,   \"Buffalo\",  \"NY\"   , 7        ],\n    [ 28,   \"Bedford\",  \"NY\"   , 8        ],\n    [ 29,   \"Boston\",   \"MA\"   , 9        ],\n    [ 30,   \"Yonkers\",  \"NY\"   , 10       ],\n    [ 31,   \"New York\", \"NY\"   , 11       ],\n  ]\n\n  #----------------------------------------------------------------------\n\n  def test_encode_summary_table_name(self):\n    self.assertEqual(summary.encode_summary_table_name(\"Foo\", []), \"Foo_summary\")\n    self.assertEqual(summary.encode_summary_table_name(\"Foo\", [\"A\"]), \"Foo_summary_A\")\n    self.assertEqual(summary.encode_summary_table_name(\"Foo\", [\"A\", \"B\"]), \"Foo_summary_A_B\")\n    self.assertEqual(summary.encode_summary_table_name(\"Foo\", [\"B\", \"A\"]), \"Foo_summary_A_B\")\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_create_view_section(self):\n    self.load_sample(self.sample)\n\n    # Verify the starting table; there should be no views yet.\n    self.assertTables([self.starting_table])\n    self.assertViews([])\n\n    # Create a view + section for the initial table.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", None, None])\n\n    # Verify that we got a new view, with one section, and three fields.\n    self.assertTables([self.starting_table])\n    basic_view = View(1, sections=[\n      Section(1, parentKey=\"record\", tableRef=1, fields=[\n        Field(1, colRef=11),\n        Field(2, colRef=12),\n        Field(3, colRef=13),\n      ])\n    ])\n    self.assertViews([basic_view])\n\n    self.assertTableData(\"Address\", self.starting_table_data)\n\n    # Create a \"Totals\" section, i.e. a summary with no group-by columns.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n\n    # Verify that a new table gets created, and a new view, with a section for that table,\n    # and some auto-generated summary fields.\n    summary_table1 = Table(2, \"Address_summary\", primaryViewId=0, summarySourceTable=1,\n                           columns=[\n      Column(14, \"group\", \"RefList:Address\", isFormula=True, summarySourceCol=0,\n             formula=\"table.getSummarySourceGroup(rec)\"),\n      Column(15, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n             formula=\"len($group)\"),\n      Column(16, \"amount\", \"Numeric\", isFormula=True, summarySourceCol=0,\n             formula=\"SUM($group.amount)\"),\n    ])\n    summary_view1 = View(2, sections=[\n      Section(3, parentKey=\"record\", tableRef=2, fields=[\n        Field(6, colRef=15),\n        Field(7, colRef=16),\n      ])\n    ])\n    self.assertTables([self.starting_table, summary_table1])\n    self.assertViews([basic_view, summary_view1])\n\n    # Verify the summarized data.\n    self.assertTableData('Address_summary', cols=\"subset\", data=[\n      [ \"id\", \"count\",  \"amount\"],\n      [ 1,    11,       66.0    ],\n    ])\n\n    # Create a summary section, grouped by the \"State\" column.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [12], None])\n\n    # Verify that a new table gets created again, a new view, and a section for that table.\n    # Note that we also check that summarySourceTable and summarySourceCol fields are correct.\n    summary_table2 = Table(3, \"Address_summary_state\", primaryViewId=0, summarySourceTable=1,\n                           columns=[\n      Column(17, \"state\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=12),\n      Column(18, \"group\", \"RefList:Address\", isFormula=True, summarySourceCol=0,\n             formula=\"table.getSummarySourceGroup(rec)\"),\n      Column(19, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n             formula=\"len($group)\"),\n      Column(20, \"amount\", \"Numeric\", isFormula=True, summarySourceCol=0,\n             formula=\"SUM($group.amount)\"),\n    ])\n    summary_view2 = View(3, sections=[\n      Section(5, parentKey=\"record\", tableRef=3, fields=[\n        Field(11, colRef=17),\n        Field(12, colRef=19),\n        Field(13, colRef=20),\n      ])\n    ])\n    self.assertTables([self.starting_table, summary_table1, summary_table2])\n    self.assertViews([basic_view, summary_view1, summary_view2])\n\n    # Verify more fields of the new column objects.\n    self.assertTableData('_grist_Tables_column', rows=\"subset\", cols=\"subset\", data=[\n      ['id', 'colId',  'type',    'formula',            'widgetOptions', 'label'],\n      [17,   'state',  'Text',    '',                   'WidgetOptions1', 'State'],\n      [20,   'amount', 'Numeric', 'SUM($group.amount)', 'WidgetOptions2', 'Amount'],\n    ])\n\n    # Verify the summarized data.\n    self.assertTableData('Address_summary_state', cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\", \"amount\"          ],\n      [ 1,    \"NY\",     7,      1.+2+6+7+8+10+11  ],\n      [ 2,    \"WA\",     1,      3.                ],\n      [ 3,    \"IL\",     1,      4.                ],\n      [ 4,    \"MA\",     2,      5.+9              ],\n    ])\n\n    # Create a summary section grouped by two columns (\"city\" and \"state\").\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n\n    # Verify the new table and views.\n    summary_table3 = Table(4, \"Address_summary_city_state\", primaryViewId=0, summarySourceTable=1,\n                           columns=[\n      Column(21, \"city\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=11),\n      Column(22, \"state\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=12),\n      Column(23, \"group\", \"RefList:Address\", isFormula=True, summarySourceCol=0,\n             formula=\"table.getSummarySourceGroup(rec)\"),\n      Column(24, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n             formula=\"len($group)\"),\n      Column(25, \"amount\", \"Numeric\", isFormula=True, summarySourceCol=0,\n             formula=\"SUM($group.amount)\"),\n    ])\n    summary_view3 = View(4, sections=[\n      Section(7, parentKey=\"record\", tableRef=4, fields=[\n        Field(18, colRef=21),\n        Field(19, colRef=22),\n        Field(20, colRef=24),\n        Field(21, colRef=25),\n      ])\n    ])\n    self.assertTables([self.starting_table, summary_table1, summary_table2, summary_table3])\n    self.assertViews([basic_view, summary_view1, summary_view2, summary_view3])\n\n    # Verify the summarized data.\n    self.assertTableData('Address_summary_city_state', cols=\"subset\", data=[\n      [ \"id\", \"city\",     \"state\", \"count\", \"amount\"  ],\n      [ 1,    \"New York\", \"NY\"   , 3,       1.+6+11   ],\n      [ 2,    \"Albany\",   \"NY\"   , 1,       2.        ],\n      [ 3,    \"Seattle\",  \"WA\"   , 1,       3.        ],\n      [ 4,    \"Chicago\",  \"IL\"   , 1,       4.        ],\n      [ 5,    \"Bedford\",  \"MA\"   , 1,       5.        ],\n      [ 6,    \"Buffalo\",  \"NY\"   , 1,       7.        ],\n      [ 7,    \"Bedford\",  \"NY\"   , 1,       8.        ],\n      [ 8,    \"Boston\",   \"MA\"   , 1,       9.        ],\n      [ 9,    \"Yonkers\",  \"NY\"   , 1,       10.       ],\n    ])\n\n    # The original table's data should not have changed.\n    self.assertTableData(\"Address\", self.starting_table_data)\n\n  #----------------------------------------------------------------------\n\n  def test_summary_gencode(self):\n    self.maxDiff = 1000       # If there is a discrepancy, allow the bigger diff.\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n    self.assertMultiLineEqual(self.engine.fetch_table_schema(),\n\"\"\"import grist\nfrom functions import *       # global uppercase functions\nimport datetime, math, re     # modules commonly needed in formulas\n\n\n@grist.UserTable\nclass Address:\n  city = grist.Text()\n  state = grist.Text()\n  amount = grist.Numeric()\n\n  class _Summary:\n\n    @grist.formulaType(grist.ReferenceList('Address'))\n    def group(rec, table):\n      return table.getSummarySourceGroup(rec)\n\n    @grist.formulaType(grist.Int())\n    def count(rec, table):\n      return len(rec.group)\n\n    @grist.formulaType(grist.Numeric())\n    def amount(rec, table):\n      return SUM(rec.group.amount)\n\"\"\")\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_summary_table_reuse(self):\n    # Test that we'll reuse a suitable summary table when already available.\n\n    self.load_sample(self.sample)\n\n    # Create a summary section grouped by two columns (\"city\" and \"state\").\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n\n    # Verify the new table and views.\n    summary_table = Table(2, \"Address_summary_city_state\", primaryViewId=0, summarySourceTable=1,\n                           columns=[\n      Column(14, \"city\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=11),\n      Column(15, \"state\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=12),\n      Column(16, \"group\", \"RefList:Address\", isFormula=True, summarySourceCol=0,\n             formula=\"table.getSummarySourceGroup(rec)\"),\n      Column(17, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n             formula=\"len($group)\"),\n      Column(18, \"amount\", \"Numeric\", isFormula=True, summarySourceCol=0,\n             formula=\"SUM($group.amount)\"),\n    ])\n    summary_view = View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=2, fields=[\n        Field(5, colRef=14),\n        Field(6, colRef=15),\n        Field(7, colRef=17),\n        Field(8, colRef=18),\n      ])\n    ])\n    self.assertTables([self.starting_table, summary_table])\n    self.assertViews([summary_view])\n\n    # Create twoo other views + view sections with the same breakdown (in different order\n    # of group-by fields, which should still reuse the same table).\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [12,11], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n    summary_view2 = View(2, sections=[\n      Section(3, parentKey=\"record\", tableRef=2, fields=[\n        Field(9, colRef=15),\n        Field(10, colRef=14),\n        Field(11, colRef=17),\n        Field(12, colRef=18),\n      ])\n    ])\n    summary_view3 = View(3, sections=[\n      Section(4, parentKey=\"record\", tableRef=2, fields=[\n        Field(13, colRef=14),\n        Field(14, colRef=15),\n        Field(15, colRef=17),\n        Field(16, colRef=18),\n      ])\n    ])\n    # Verify that we have a new view, but are reusing the table.\n    self.assertTables([self.starting_table, summary_table])\n    self.assertViews([summary_view, summary_view2, summary_view3])\n\n    # Verify the summarized data.\n    self.assertTableData('Address_summary_city_state', cols=\"subset\", data=[\n      [ \"id\", \"city\",     \"state\", \"count\", \"amount\"  ],\n      [ 1,    \"New York\", \"NY\"   , 3,       1.+6+11   ],\n      [ 2,    \"Albany\",   \"NY\"   , 1,       2.        ],\n      [ 3,    \"Seattle\",  \"WA\"   , 1,       3.        ],\n      [ 4,    \"Chicago\",  \"IL\"   , 1,       4.        ],\n      [ 5,    \"Bedford\",  \"MA\"   , 1,       5.        ],\n      [ 6,    \"Buffalo\",  \"NY\"   , 1,       7.        ],\n      [ 7,    \"Bedford\",  \"NY\"   , 1,       8.        ],\n      [ 8,    \"Boston\",   \"MA\"   , 1,       9.        ],\n      [ 9,    \"Yonkers\",  \"NY\"   , 1,       10.       ],\n    ])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_summary_no_invalid_reuse(self):\n    # Verify that if we have some summary tables for one table, they don't mistakenly get used\n    # when we need a summary for another table.\n\n    # Load table and create a couple summary sections, for totals, and grouped by \"state\".\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [12], None])\n\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary\", 0, 1, columns=[\n        Column(14, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(15, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(16, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      Table(3, \"Address_summary_state\", 0, 1, columns=[\n        Column(17, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(18, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(19, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(20, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n\n    # Create another table similar to the first one.\n    self.apply_user_action([\"AddTable\", \"Address2\", [\n      { \"id\": \"city\", \"type\": \"Text\" },\n      { \"id\": \"state\", \"type\": \"Text\" },\n      { \"id\": \"amount\", \"type\": \"Numeric\" },\n    ]])\n    data = self.sample[\"DATA\"][\"Address\"]\n    self.apply_user_action([\"BulkAddRecord\", \"Address2\", data.row_ids, data.columns])\n\n    # Check that we've loaded the right data, and have the new table.\n    self.assertTableData(\"Address\", cols=\"subset\", data=self.starting_table_data)\n    self.assertTableData(\"Address2\", cols=\"subset\", data=self.starting_table_data)\n    self.assertTableData(\"_grist_Tables\", cols=\"subset\", data=[\n      ['id',    'tableId',  'summarySourceTable'],\n      [ 1,      'Address',                  0],\n      [ 2,      'Address_summary',   1],\n      [ 3,      'Address_summary_state',  1],\n      [ 4,      'Address2',                 0],\n    ])\n\n    # Now create similar summary sections for the new table.\n    self.apply_user_action([\"CreateViewSection\", 4, 0, \"record\", [], None])\n    self.apply_user_action([\"CreateViewSection\", 4, 0, \"record\", [23], None])\n\n    # Make sure this creates new section rather than reuses similar ones for the wrong table.\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary\", 0, 1, columns=[\n        Column(14, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(15, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(16, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      Table(3, \"Address_summary_state\", 0, 1, columns=[\n        Column(17, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(18, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(19, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(20, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      Table(4, \"Address2\", primaryViewId=3, summarySourceTable=0, columns=[\n        Column(21, \"manualSort\", \"ManualSortPos\",False, \"\", 0),\n        Column(22, \"city\",    \"Text\",     False, \"\", 0),\n        Column(23, \"state\",   \"Text\",     False, \"\", 0),\n        Column(24, \"amount\",  \"Numeric\",  False, \"\", 0),\n      ]),\n      Table(5, \"Address2_summary\", 0, 4, columns=[\n        Column(25, \"group\",   \"RefList:Address2\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(26, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(27, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      Table(6, \"Address2_summary_state\", 0, 4, columns=[\n        Column(28, \"state\",   \"Text\",     False,  \"\", 23),\n        Column(29, \"group\",   \"RefList:Address2\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(30, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(31, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_summary_updates(self):\n    # Verify that summary tables update automatically when we change a value used in a summary\n    # formula; or a value in a group-by column; or add/remove a record; that records get\n    # auto-added when new group-by combinations appear.\n\n    # Load sample and create a summary section grouped by two columns (\"city\" and \"state\").\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n\n    # Verify that the summary table respects all updates to the source table.\n    self._do_test_updates(\"Address\", \"Address_summary_city_state\")\n\n  def _do_test_updates(self, source_tbl_name, summary_tbl_name):\n    # This is the main part of test_summary_updates(). It's moved to its own method so that\n    # updates can be verified the same way after a table rename.\n\n    # Verify the summarized data.\n    self.assertTableData(summary_tbl_name, cols=\"subset\", data=[\n      [ \"id\", \"city\",     \"state\", \"count\", \"amount\"  ],\n      [ 1,    \"New York\", \"NY\"   , 3,       1.+6+11   ],\n      [ 2,    \"Albany\",   \"NY\"   , 1,       2.        ],\n      [ 3,    \"Seattle\",  \"WA\"   , 1,       3.        ],\n      [ 4,    \"Chicago\",  \"IL\"   , 1,       4.        ],\n      [ 5,    \"Bedford\",  \"MA\"   , 1,       5.        ],\n      [ 6,    \"Buffalo\",  \"NY\"   , 1,       7.        ],\n      [ 7,    \"Bedford\",  \"NY\"   , 1,       8.        ],\n      [ 8,    \"Boston\",   \"MA\"   , 1,       9.        ],\n      [ 9,    \"Yonkers\",  \"NY\"   , 1,       10.       ],\n    ])\n\n    # Change an amount (New York, NY, 6 -> 106), check that the right calc action gets emitted.\n    out_actions = self.update_record(source_tbl_name, 26, amount=106)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(source_tbl_name, 26, {'amount': 106}),\n        actions.UpdateRecord(summary_tbl_name, 1, {'amount': 1.+106+11}),\n      ]\n    })\n\n    # Change a groupby value so that a record moves from one summary group to another.\n    # Bedford, NY, 8.0 -> Bedford, MA, 8.0\n    out_actions = self.update_record(source_tbl_name, 28, state=\"MA\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(source_tbl_name, 28, {'state': 'MA'}),\n        actions.RemoveRecord(summary_tbl_name, 7),\n        actions.UpdateRecord(summary_tbl_name, 5, {'amount': 5.0 + 8.0}),\n        actions.UpdateRecord(summary_tbl_name, 5, {'count': 2}),\n        actions.UpdateRecord(summary_tbl_name, 5, {'group': [25, 28]}),\n      ]\n    })\n\n    # Add a record to an existing group (Bedford, MA, 108.0)\n    out_actions = self.add_record(source_tbl_name, city=\"Bedford\", state=\"MA\", amount=108.0)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.AddRecord(source_tbl_name, 32,\n                          {'city': 'Bedford', 'state': 'MA', 'amount': 108.0}),\n        actions.UpdateRecord(summary_tbl_name, 5, {'amount': 5.0 + 8.0 + 108.0}),\n        actions.UpdateRecord(summary_tbl_name, 5, {'count': 3}),\n        actions.UpdateRecord(summary_tbl_name, 5, {'group': [25, 28, 32]}),\n      ]\n    })\n\n    # Remove a record (rowId=28, Bedford, MA, 8.0)\n    out_actions = self.remove_record(source_tbl_name, 28)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.RemoveRecord(source_tbl_name, 28),\n        actions.UpdateRecord(summary_tbl_name, 5, {'amount': 5.0 + 108.0}),\n        actions.UpdateRecord(summary_tbl_name, 5, {'count': 2}),\n        actions.UpdateRecord(summary_tbl_name, 5, {'group': [25, 32]}),\n      ]\n    })\n\n    # Change groupby value to create a new combination (rowId 25, Bedford, MA, 5.0 -> Salem, MA).\n    # A new summary record should be added.\n    out_actions = self.update_record(source_tbl_name, 25, city=\"Salem\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(source_tbl_name, 25, {'city': 'Salem'}),\n        actions.AddRecord(summary_tbl_name, 10, {'city': 'Salem', 'state': 'MA'}),\n        actions.BulkUpdateRecord(summary_tbl_name, [5,10], {'amount': [108.0, 5.0]}),\n        actions.BulkUpdateRecord(summary_tbl_name, [5,10], {'count': [1, 1]}),\n        actions.BulkUpdateRecord(summary_tbl_name, [5,10], {'group': [[32], [25]]}),\n      ]\n    })\n\n    # Add a record with a new combination (Amherst, MA, 17)\n    out_actions = self.add_record(source_tbl_name, city=\"Amherst\", state=\"MA\", amount=17.0)\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.AddRecord(source_tbl_name, 33, {'city': 'Amherst', 'state': 'MA', 'amount': 17.}),\n        actions.AddRecord(summary_tbl_name, 11, {'city': 'Amherst', 'state': 'MA'}),\n        actions.UpdateRecord(summary_tbl_name, 11, {'amount': 17.0}),\n        actions.UpdateRecord(summary_tbl_name, 11, {'count': 1}),\n        actions.UpdateRecord(summary_tbl_name, 11, {'group': [33]}),\n      ]\n    })\n\n    # Add a new source record which creates a new summary row,\n    # then delete the source row which implicitly deletes the summary row.\n    # Overall this is a no-op, but it tests a specific undo-related bugfix.\n    self.add_record(source_tbl_name, city=\"Nowhere\", state=\"??\", amount=666)\n    out_actions = self.remove_record(source_tbl_name, 34)\n    self.assertOutActions(out_actions, {\n      'calc': [],\n      'direct': [True, False],\n      'stored': [\n        ['RemoveRecord', source_tbl_name, 34],\n        ['RemoveRecord', summary_tbl_name, 12],\n      ],\n      'undo': [\n        ['UpdateRecord', summary_tbl_name, 12, {'group': ['L', 34]}],\n        ['UpdateRecord', summary_tbl_name, 12, {'count': 1}],\n        ['UpdateRecord', summary_tbl_name, 12, {'amount': 666.0}],\n        ['AddRecord', source_tbl_name, 34, {'amount': 666.0, 'city': 'Nowhere', 'state': '??'}],\n        ['AddRecord', summary_tbl_name, 12,\n         {'city': 'Nowhere', 'group': ['L'], 'state': '??'}],\n      ],\n    })\n\n    # Verify the resulting data after all the updates.\n    self.assertTableData(summary_tbl_name, cols=\"subset\", data=[\n      [ \"id\", \"city\",     \"state\", \"count\", \"amount\"  ],\n      [ 1,    \"New York\", \"NY\"   , 3,       1.+106+11 ],\n      [ 2,    \"Albany\",   \"NY\"   , 1,       2.        ],\n      [ 3,    \"Seattle\",  \"WA\"   , 1,       3.        ],\n      [ 4,    \"Chicago\",  \"IL\"   , 1,       4.        ],\n      [ 5,    \"Bedford\",  \"MA\"   , 1,       108.      ],\n      [ 6,    \"Buffalo\",  \"NY\"   , 1,       7.        ],\n      [ 8,    \"Boston\",   \"MA\"   , 1,       9.        ],\n      [ 9,    \"Yonkers\",  \"NY\"   , 1,       10.       ],\n      [ 10,   \"Salem\",    \"MA\"   , 1,       5.0       ],\n      [ 11,   \"Amherst\",  \"MA\"   , 1,       17.0      ],\n    ])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_table_rename(self):\n    # Verify that summary tables keep working and updating when source table is renamed.\n\n    # Load sample and create a couple of summary sections.\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n\n    # Check what tables we have now.\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"tableId\", \"summarySourceTable\"], [\n      [1, \"Address\",                  0],\n      [2, \"Address_summary_city_state\",   1],\n    ])\n\n    # Add a column to the summary table with a name that doesn't exist in the source table,\n    # to test a specific bug fix.\n    self.add_column(\n      \"Address_summary_city_state\", \"lookup\",\n      formula=\"Address.lookupRecords(city=$city)\", isFormula=True\n    )\n\n    # Rename the table: this is what we are really testing in this test case.\n    self.apply_user_action([\"RenameTable\", \"Address\", \"Location\"])\n\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"tableId\", \"summarySourceTable\"], [\n      [1, \"Location\",                  0],\n      [2, \"Location_summary_city_state\",   1],\n    ])\n\n    # Check that the summary table column's formula was updated correctly.\n    self.assertTableData(\"_grist_Tables_column\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"colId\", \"formula\"],\n      [19, \"lookup\", \"Location.lookupRecords(city=$city)\"],\n    ])\n    # This column isn't expected in _do_test_updates().\n    self.remove_column(\"Location_summary_city_state\", \"lookup\")\n\n    # Verify that the bigger summary table respects all updates to the renamed source table.\n    self._do_test_updates(\"Location\", \"Location_summary_city_state\")\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_table_rename_multiple(self):\n    # Similar to the above, verify renames, but now with two summary tables.\n\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"tableId\", \"summarySourceTable\"], [\n      [1, \"Address\",                  0],\n      [2, \"Address_summary_city_state\",   1],\n      [3, \"Address_summary\",  1],\n    ])\n    # Verify the data in the simple totals-only summary table.\n    self.assertTableData('Address_summary', cols=\"subset\", data=[\n      [ \"id\", \"count\",  \"amount\"],\n      [ 1,    11,       66.0    ],\n    ])\n\n    # Do a rename.\n    self.apply_user_action([\"RenameTable\", \"Address\", \"Addresses\"])\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"tableId\", \"summarySourceTable\"], [\n      [1, \"Addresses\",                  0],\n      [2, \"Addresses_summary_city_state\",   1],\n      [3, \"Addresses_summary\",  1],\n    ])\n    self.assertTableData('Addresses_summary', cols=\"subset\", data=[\n      [ \"id\", \"count\",  \"amount\"],\n      [ 1,    11,       66.0    ],\n    ])\n\n    # Remove one of the tables so that we can use _do_test_updates to verify updates still work.\n    self.apply_user_action([\"RemoveTable\", \"Addresses_summary\"])\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"tableId\", \"summarySourceTable\"], [\n      [1, \"Addresses\",                  0],\n      [2, \"Addresses_summary_city_state\",   1],\n    ])\n    self._do_test_updates(\"Addresses\", \"Addresses_summary_city_state\")\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_change_summary_formula(self):\n    # Verify that changing a summary formula affects all group-by variants, and adding a new\n    # summary table gets the changed formula.\n    #\n    # (Recall that all summaries of a single table are *conceptually* variants of a single summary\n    # table, sharing all formulas and differing only in the group-by columns.)\n\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n\n    # These are the tables and columns we automatically get.\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      Table(3, \"Address_summary\", 0, 1, columns=[\n        Column(19, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(20, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(21, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ])\n    ])\n\n    # Now change a formula using one of the summary tables. It should trigger an equivalent\n    # change in the other.\n    self.apply_user_action([\"ModifyColumn\", \"Address_summary_city_state\", \"amount\",\n                            {\"formula\": \"10*sum($group.amount)\"}])\n    self.assertTableData('_grist_Tables_column', rows=\"subset\", cols=\"subset\", data=[\n      ['id', 'colId',  'type',    'formula',               'widgetOptions', 'label'],\n      [18,   'amount', 'Numeric', '10*sum($group.amount)', 'WidgetOptions2', 'Amount'],\n      [21,   'amount', 'Numeric', '10*sum($group.amount)', 'WidgetOptions2', 'Amount'],\n    ])\n\n    # Change a formula and a few other fields in the other table, and verify a change to both.\n    self.apply_user_action([\"ModifyColumn\", \"Address_summary\", \"amount\",\n                            {\"formula\": \"100*sum($group.amount)\",\n                             \"type\": \"Text\",\n                             \"widgetOptions\": \"hello\",\n                             \"label\": \"AMOUNT\",\n                             \"untieColIdFromLabel\": True\n                            }])\n    self.assertTableData('_grist_Tables_column', rows=\"subset\", cols=\"subset\", data=[\n      ['id', 'colId',  'type', 'formula',                 'widgetOptions', 'label'],\n      [18,   'amount', 'Text', '100*sum($group.amount)',  'hello', 'AMOUNT'],\n      [21,   'amount', 'Text', '100*sum($group.amount)',  'hello', 'AMOUNT'],\n    ])\n\n    # Check the values in the summary tables: they should reflect the new formula.\n    self.assertTableData('Address_summary_city_state', cols=\"subset\", data=[\n      [ \"id\", \"city\",     \"state\", \"count\", \"amount\"  ],\n      [ 1,    \"New York\", \"NY\"   , 3,       str(100*(1+6+11))],\n      [ 2,    \"Albany\",   \"NY\"   , 1,       \"200\"        ],\n      [ 3,    \"Seattle\",  \"WA\"   , 1,       \"300\"        ],\n      [ 4,    \"Chicago\",  \"IL\"   , 1,       \"400\"        ],\n      [ 5,    \"Bedford\",  \"MA\"   , 1,       \"500\"        ],\n      [ 6,    \"Buffalo\",  \"NY\"   , 1,       \"700\"        ],\n      [ 7,    \"Bedford\",  \"NY\"   , 1,       \"800\"        ],\n      [ 8,    \"Boston\",   \"MA\"   , 1,       \"900\"        ],\n      [ 9,    \"Yonkers\",  \"NY\"   , 1,       \"1000\"       ],\n    ])\n    self.assertTableData('Address_summary', cols=\"subset\", data=[\n      [ \"id\", \"count\",  \"amount\"],\n      [ 1,    11,       \"6600\"],\n    ])\n\n    # Add a new summary table, and check that it gets the new formula.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [12], None])\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"amount\",  \"Text\",     True,   \"100*sum($group.amount)\", 0),\n      ]),\n      Table(3, \"Address_summary\", 0, 1, columns=[\n        Column(19, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(20, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(21, \"amount\",  \"Text\",     True,   \"100*sum($group.amount)\", 0),\n      ]),\n      Table(4, \"Address_summary_state\", 0, 1, columns=[\n        Column(22, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(23, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(24, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(25, \"amount\",  \"Text\",     True,   \"100*sum($group.amount)\", 0),\n      ])\n    ])\n    self.assertTableData('_grist_Tables_column', rows=\"subset\", cols=\"subset\", data=[\n      ['id', 'colId',  'type', 'formula',                 'widgetOptions', 'label'],\n      [18,   'amount', 'Text', '100*sum($group.amount)',  'hello', 'AMOUNT'],\n      [21,   'amount', 'Text', '100*sum($group.amount)',  'hello', 'AMOUNT'],\n      [25,   'amount', 'Text', '100*sum($group.amount)',  'hello', 'AMOUNT'],\n    ])\n\n    # Verify the summarized data.\n    self.assertTableData('Address_summary_state', cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\", \"amount\"                    ],\n      [ 1,    \"NY\",     7,      str(int(100*(1.+2+6+7+8+10+11))) ],\n      [ 2,    \"WA\",     1,      \"300\"                     ],\n      [ 3,    \"IL\",     1,      \"400\"                     ],\n      [ 4,    \"MA\",     2,      str(500+900)               ],\n    ])\n\n  #----------------------------------------------------------------------\n  @test_engine.test_undo\n  def test_convert_source_column(self):\n    # Verify that we can convert the type of a column when there is a summary table using that\n    # column to group by. Since converting generates extra summary records, this may cause bugs.\n\n    self.apply_user_action([\"AddEmptyTable\", None])\n    self.apply_user_action([\"BulkAddRecord\", \"Table1\", [None]*3, {\"A\": [10,20,10], \"B\": [1,2,3]}])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [2], None])\n\n    # Verify metadata and actual data initially.\n    self.assertTables([\n      Table(1, \"Table1\", summarySourceTable=0, primaryViewId=1, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\",  False,  \"\", 0),\n        Column(2, \"A\",          \"Numeric\",        False,  \"\", 0),\n        Column(3, \"B\",          \"Numeric\",        False,  \"\", 0),\n        Column(4, \"C\",          \"Any\",            True,   \"\", 0),\n      ]),\n      Table(2, \"Table1_summary_A\", summarySourceTable=1, primaryViewId=0, columns=[\n        Column(5, \"A\",          \"Numeric\",        False,  \"\", 2),\n        Column(6, \"group\",      \"RefList:Table1\", True,  \"table.getSummarySourceGroup(rec)\", 0),\n        Column(7, \"count\",      \"Int\",            True,  \"len($group)\", 0),\n        Column(8, \"B\",          \"Numeric\",        True,  \"SUM($group.B)\", 0),\n      ])\n    ])\n    self.assertTableData('Table1', data=[\n      [ \"id\", \"manualSort\", \"A\",  \"B\",  \"C\"   ],\n      [ 1,    1.0,          10,   1.0,    None  ],\n      [ 2,    2.0,          20,   2.0,    None  ],\n      [ 3,    3.0,          10,   3.0,    None  ],\n    ])\n    self.assertTableData('Table1_summary_A', data=[\n      [ \"id\", \"A\",  \"group\",  \"count\",  \"B\" ],\n      [ 1,    10,   [1,3],    2,        4   ],\n      [ 2,    20,   [2],      1,        2   ],\n    ])\n\n\n    # Do a conversion.\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", 2, {\"type\": \"Text\"}])\n\n    # Verify that the conversion's result is as expected.\n    self.assertTables([\n      Table(1, \"Table1\", summarySourceTable=0, primaryViewId=1, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\",  False,  \"\", 0),\n        Column(2, \"A\",          \"Text\",           False,  \"\", 0),\n        Column(3, \"B\",          \"Numeric\",        False,  \"\", 0),\n        Column(4, \"C\",          \"Any\",            True,   \"\", 0),\n      ]),\n      Table(2, \"Table1_summary_A\", summarySourceTable=1, primaryViewId=0, columns=[\n        Column(5, \"A\",          \"Text\",           False,  \"\", 2),\n        Column(6, \"group\",      \"RefList:Table1\", True,  \"table.getSummarySourceGroup(rec)\", 0),\n        Column(7, \"count\",      \"Int\",            True,  \"len($group)\", 0),\n        Column(8, \"B\",          \"Numeric\",        True,  \"SUM($group.B)\", 0),\n      ])\n    ])\n    self.assertTableData('Table1', data=[\n      [ \"id\", \"manualSort\", \"A\",  \"B\",  \"C\"   ],\n      [ 1,    1.0,          \"10\", 1.0,  None  ],\n      [ 2,    2.0,          \"20\", 2.0,  None  ],\n      [ 3,    3.0,          \"10\", 3.0,  None  ],\n    ])\n    self.assertTableData('Table1_summary_A', data=[\n      [ \"id\", \"A\",  \"group\",  \"count\",  \"B\" ],\n      [ 1,    \"10\", [1,3],    2,        4   ],\n      [ 2,    \"20\", [2],      1,        2   ],\n    ])\n\n  #----------------------------------------------------------------------\n  @test_engine.test_undo\n  def test_remove_source_column(self):\n    # Verify that we can remove a column when there is a summary table using that column to group\n    # by. (Bug T188.)\n\n    self.apply_user_action([\"AddEmptyTable\", None])\n    self.apply_user_action([\"BulkAddRecord\", \"Table1\", [None]*3,\n                            {\"A\": ['a','b','c'], \"B\": [1,1,2], \"C\": [4,5,6]}])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [2,3], None])\n\n    # Verify metadata and actual data initially.\n    self.assertTables([\n      Table(1, \"Table1\", summarySourceTable=0, primaryViewId=1, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\",  False,  \"\", 0),\n        Column(2, \"A\",          \"Text\",           False,  \"\", 0),\n        Column(3, \"B\",          \"Numeric\",        False,  \"\", 0),\n        Column(4, \"C\",          \"Numeric\",        False,  \"\", 0),\n      ]),\n      Table(2, \"Table1_summary_A_B\", summarySourceTable=1, primaryViewId=0, columns=[\n        Column(5, \"A\",          \"Text\",           False,  \"\", 2),\n        Column(6, \"B\",          \"Numeric\",        False,  \"\", 3),\n        Column(7, \"group\",      \"RefList:Table1\", True,  \"table.getSummarySourceGroup(rec)\", 0),\n        Column(8, \"count\",      \"Int\",            True,  \"len($group)\", 0),\n        Column(9, \"C\",          \"Numeric\",        True,  \"SUM($group.C)\", 0),\n      ])\n    ])\n    self.assertTableData('Table1', data=[\n      [ \"id\", \"manualSort\", \"A\",  \"B\",  \"C\" ],\n      [ 1,    1.0,          'a',  1.0,  4   ],\n      [ 2,    2.0,          'b',  1.0,  5   ],\n      [ 3,    3.0,          'c',  2.0,  6   ],\n    ])\n    self.assertTableData('Table1_summary_A_B', data=[\n      [ \"id\", \"A\",  \"B\",  \"group\",  \"count\",  \"C\" ],\n      [ 1,    'a',  1.0,  [1],      1,        4   ],\n      [ 2,    'b',  1.0,  [2],      1,        5   ],\n      [ 3,    'c',  2.0,  [3],      1,        6   ],\n    ])\n\n    # Remove column A, used for group-by.\n    self.apply_user_action([\"RemoveColumn\", \"Table1\", \"A\"])\n\n    # Verify that the conversion's result is as expected.\n    self.assertTables([\n      Table(1, \"Table1\", summarySourceTable=0, primaryViewId=1, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\",  False,  \"\", 0),\n        Column(3, \"B\",          \"Numeric\",        False,  \"\", 0),\n        Column(4, \"C\",          \"Numeric\",        False,  \"\", 0),\n      ]),\n      Table(3, \"Table1_summary_B\", summarySourceTable=1, primaryViewId=0, columns=[\n        Column(10, \"B\",          \"Numeric\",        False,  \"\", 3),\n        Column(12, \"count\",      \"Int\",            True,  \"len($group)\", 0),\n        Column(13, \"C\",          \"Numeric\",        True,  \"SUM($group.C)\", 0),\n        Column(11, \"group\",      \"RefList:Table1\", True,  \"table.getSummarySourceGroup(rec)\", 0),\n      ])\n    ])\n    self.assertTableData('Table1', data=[\n      [ \"id\", \"manualSort\", \"B\",  \"C\" ],\n      [ 1,    1.0,          1.0,  4   ],\n      [ 2,    2.0,          1.0,  5   ],\n      [ 3,    3.0,          2.0,  6   ],\n    ])\n    self.assertTableData('Table1_summary_B', data=[\n      [ \"id\", \"B\",  \"group\",  \"count\",  \"C\" ],\n      [ 1,    1.0,  [1,2],    2,        9   ],\n      [ 2,    2.0,  [3],      1,        6   ],\n    ])\n\n  def test_remove_source_columns_with_display_column(self):\n    # Verify a fix for a specific bug: removing multiple groupby source columns\n    # when the summary table contains a display column.\n\n    self.apply_user_action([\"AddEmptyTable\", None])\n    # Group by A and B\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [2,3], None])\n    # Add a display column for the group column\n    self.apply_user_action(['SetDisplayFormula', 'Table1_summary_A_B', None, 7, '$group.C'])\n\n    # Verify metadata and initially.\n    self.assertTables([\n      Table(1, \"Table1\", summarySourceTable=0, primaryViewId=1, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\",  False,  \"\", 0),\n        Column(2, \"A\",          \"Any\",        True,  \"\", 0),\n        Column(3, \"B\",          \"Any\",        True,  \"\", 0),\n        Column(4, \"C\",          \"Any\",        True,  \"\", 0),\n      ]),\n      Table(2, \"Table1_summary_A_B\", summarySourceTable=1, primaryViewId=0, columns=[\n        Column(5, \"A\",          \"Any\",            False, \"\", 2),\n        Column(6, \"B\",          \"Any\",            False, \"\", 3),\n        Column(7, \"group\",      \"RefList:Table1\", True,  \"table.getSummarySourceGroup(rec)\", 0),\n        Column(8, \"count\",      \"Int\",            True,  \"len($group)\", 0),\n        Column(9, \"gristHelper_Display\", \"Any\",   True,  \"$group.C\", 0),\n      ])\n    ])\n\n    user_actions = [\n      useractions.from_repr(ua) for ua in\n      [\n        ['RemoveColumn', 'Table1', 'A'],\n        ['RemoveColumn', 'Table1', 'B'],\n      ]\n    ]\n    self.engine.apply_user_actions(user_actions)\n\n    # Verify that the final structure is as expected.\n    self.assertTables([\n      Table(1, \"Table1\", summarySourceTable=0, primaryViewId=1, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\",  False,  \"\", 0),\n        Column(4, \"C\",          \"Any\",        True,  \"\", 0),\n      ]),\n      # Table1_summary_A_B was removed and recreated as Table1_summary.\n      # This removed Table1_summary_A_B.group which automatically removed gristHelper_Display\n      # which led to an error in the past.\n      Table(4, \"Table1_summary\", summarySourceTable=1, primaryViewId=0, columns=[\n        Column(14, \"group\",      \"RefList:Table1\", True,  \"table.getSummarySourceGroup(rec)\", 0),\n        Column(15, \"count\",      \"Int\",            True,  \"len($group)\", 0),\n      ])\n    ])\n\n  #----------------------------------------------------------------------\n  # pylint: disable=R0915\n  def test_allow_select_by_change(self):\n    def widgetOptions(n, o):\n      return allowed_summary_change('widgetOptions', n, o)\n\n    # Can make no update on widgetOptions.\n    new = None\n    old = None\n    self.assertTrue(widgetOptions(new, old))\n\n    new = ''\n    old = None\n    self.assertTrue(widgetOptions(new, old))\n\n    new = ''\n    old = ''\n    self.assertTrue(widgetOptions(new, old))\n\n    new = None\n    old = ''\n    self.assertTrue(widgetOptions(new, old))\n\n    # Can update when key was not present\n    new = '{\"widget\":\"TextBox\",\"alignment\":\"center\"}'\n    old = ''\n    self.assertTrue(widgetOptions(new, old))\n\n    new = ''\n    old = '{\"widget\":\"TextBox\",\"alignment\":\"center\"}'\n    self.assertTrue(widgetOptions(new, old))\n\n    # Can update when key was present.\n    new = '{\"widget\":\"TextBox\",\"alignment\":\"center\"}'\n    old = '{\"widget\":\"Spinner\",\"alignment\":\"center\"}'\n    self.assertTrue(widgetOptions(new, old))\n\n    # Can update but must leave choices options.\n    new = '{\"widget\":\"TextBox\",\"choices\":\"center\"}'\n    old = '{\"widget\":\"Spinner\",\"choices\":\"center\"}'\n    self.assertTrue(widgetOptions(new, old))\n\n    # Can't add protected property when old was empty.\n    new = '{\"widget\":\"TextBox\",\"choices\":\"new\"}'\n    old = None\n    self.assertFalse(widgetOptions(new, old))\n\n    # Can't remove when there was a protected property.\n    new = None\n    old = '{\"widget\":\"TextBox\",\"choices\":\"old\"}'\n    self.assertFalse(widgetOptions(new, old))\n\n    # Can't update by omitting.\n    new = '{\"widget\":\"TextBox\"}'\n    old = '{\"widget\":\"TextBox\",\"choices\":\"old\"}'\n    self.assertFalse(widgetOptions(new, old))\n\n    # Can't update by changing.\n    new = '{\"widget\":\"TextBox\",\"choices\":\"new\"}'\n    old = '{\"widget\":\"TextBox\",\"choices\":\"old\"}'\n    self.assertFalse(widgetOptions(new, old))\n\n    # Can't update by adding.\n    new = '{\"widget\":\"TextBox\",\"choices\":\"new\"}'\n    old = '{\"widget\":\"TextBox\"}'\n    self.assertFalse(widgetOptions(new, old))\n\n    # Can update objects\n    new = '{\"widget\":\"TextBox\",\"alignment\":{\"prop\":1},\"choices\":{\"prop\":1}}'\n    old = '{\"widget\":\"TextBox\",\"alignment\":{\"prop\":2},\"choices\":{\"prop\":1}}'\n    self.assertTrue(widgetOptions(new, old))\n\n    # Can't update objects\n    new = '{\"widget\":\"TextBox\",\"choices\":{\"prop\":1}}'\n    old = '{\"widget\":\"TextBox\",\"choices\":{\"prop\":2}}'\n    self.assertFalse(widgetOptions(new, old))\n\n    # Can't update lists\n    new = '{\"widget\":\"TextBox\",\"choices\":[1, 2]}'\n    old = '{\"widget\":\"TextBox\",\"choices\":[2, 1]}'\n    self.assertFalse(widgetOptions(new, old))\n\n    # Can update lists\n    new = '{\"widget\":\"TextBox\",\"alignment\":[1, 2]}'\n    old = '{\"widget\":\"TextBox\",\"alignment\":[3, 2]}'\n    self.assertTrue(widgetOptions(new, old))\n\n    # Can update without changing list.\n    new = '{\"widget\":\"TextBox\",\"choices\":[1, 2]}'\n    old = '{\"widget\":\"Spinner\",\"choices\":[1, 2]}'\n    self.assertTrue(widgetOptions(new, old))\n  # pylint: enable=R0915\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_summary2.py",
    "content": "# pylint:disable=too-many-lines\n\"\"\"\nTest of Summary tables. This has many test cases, so to keep files smaller, it's split into two\nfiles: test_summary.py and test_summary2.py.\n\"\"\"\nimport logging\nimport actions\nimport test_engine\nfrom test_engine import Table, Column, View, Section, Field\nimport test_summary\nimport testutil\n\nlog = logging.getLogger(__name__)\n\n\nclass TestSummary2(test_engine.EngineTestCase):\n  sample = test_summary.TestSummary.sample\n  starting_table = test_summary.TestSummary.starting_table\n  starting_table_data = test_summary.TestSummary.starting_table_data\n\n\n  @test_engine.test_undo\n  def test_add_summary_formula(self):\n    # Verify that we can add a summary formula; that new sections automatically get columns\n    # matching the source table, and not other columns. Check that group-by columns override\n    # formula columns (if there are any by the same name).\n\n    # Start as in test_change_summary_formula() test case; see there for what tables and columns\n    # we expect to have at this point.\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n\n    # Check that we cannot add a non-formula column.\n    with self.assertRaisesRegex(ValueError, r'non-formula column'):\n      self.apply_user_action([\"AddColumn\", \"Address_summary_city_state\", \"average\",\n                              {\"type\": \"Text\", \"isFormula\": False}])\n\n    # Add two formula columns: one for 'state' (an existing column name, and a group-by column in\n    # some tables), and one for 'average' (a new column name).\n    self.apply_user_action([\"AddVisibleColumn\", \"Address_summary\", \"state\",\n                            {\"formula\": \"':'.join(sorted(set($group.state)))\"}])\n\n    self.apply_user_action([\"AddVisibleColumn\", \"Address_summary_city_state\", \"average\",\n                            {\"formula\": \"$amount / $count\"}])\n\n    # Add two more summary tables: by 'city', and by 'state', and see what columns they get.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [12], None])\n    # And also a summary table for an existing breakdown.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n\n    # Check the table and columns for all the summary tables.\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n        Column(23, \"average\", \"Any\",      True,   \"$amount / $count\", 0),\n      ]),\n      Table(3, \"Address_summary\", 0, 1, columns=[\n        Column(19, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(20, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(21, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n        Column(22, \"state\",   \"Any\",      True,   \"':'.join(sorted(set($group.state)))\", 0),\n      ]),\n      Table(4, \"Address_summary_city\", 0, 1, columns=[\n        Column(24, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(25, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(26, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(27, \"state\",   \"Any\",      True,   \"':'.join(sorted(set($group.state)))\", 0),\n        Column(28, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      # Note that since 'state' is used as a group-by column here, we skip the 'state' formula.\n      Table(5, \"Address_summary_state\", 0, 1, columns=[\n        Column(29, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(30, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(31, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(32, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n\n\n    # We should now have three sections for table 2 (the one with two group-by fields). One for\n    # the raw summary table view, and two for the non-raw views.\n    self.assertTableData('_grist_Views_section', cols=\"subset\", data=[\n      [\"id\",  \"parentId\", \"tableRef\"],\n      [1,     0,          2],\n      [2,     1,          2],\n      [9,     5,          2],\n    ], rows=lambda r: r.tableRef.id == 2)\n    self.assertTableData('_grist_Views_section_field', cols=\"subset\", data=[\n      [\"id\", \"parentId\", \"colRef\"],\n      [1,     1,          14],\n      [2,     1,          15],\n      [3,     1,          17],\n      [4,     1,          18],\n      [15,    1,          23],\n      [17,    5,          24],\n      [18,    5,          26],\n      [19,    5,          27],\n      [20,    5,          28],  # new section doesn't automatically get 'average' column\n    ], rows=lambda r: r.parentId.id in {1,5})\n\n\n    # Check that the data is as we expect.\n    self.assertTableData('Address_summary_city_state', cols=\"all\", data=[\n      [ \"id\", \"city\",     \"state\", \"group\", \"count\", \"amount\", \"average\"   ],\n      [ 1,    \"New York\", \"NY\"   , [21,26,31],3,     1.+6+11 , (1.+6+11)/3 ],\n      [ 2,    \"Albany\",   \"NY\"   , [22],    1,       2.      , 2.  ],\n      [ 3,    \"Seattle\",  \"WA\"   , [23],    1,       3.      , 3.  ],\n      [ 4,    \"Chicago\",  \"IL\"   , [24],    1,       4.      , 4.  ],\n      [ 5,    \"Bedford\",  \"MA\"   , [25],    1,       5.      , 5.  ],\n      [ 6,    \"Buffalo\",  \"NY\"   , [27],    1,       7.      , 7.  ],\n      [ 7,    \"Bedford\",  \"NY\"   , [28],    1,       8.      , 8.  ],\n      [ 8,    \"Boston\",   \"MA\"   , [29],    1,       9.      , 9.  ],\n      [ 9,    \"Yonkers\",  \"NY\"   , [30],    1,       10.     , 10. ],\n    ])\n    self.assertTableData('Address_summary', cols=\"all\", data=[\n      [ \"id\", \"count\",  \"amount\", \"state\"       , \"group\" ],\n      [ 1,    11,       66.0    , \"IL:MA:NY:WA\" , [21,22,23,24,25,26,27,28,29,30,31]],\n    ])\n    self.assertTableData('Address_summary_city', cols=\"subset\", data=[\n      [ \"id\", \"city\",     \"count\",  \"amount\", \"state\" ],\n      [ 1,    \"New York\",  3,       1.+6+11   , \"NY\"  ],\n      [ 2,    \"Albany\",    1,       2.        , \"NY\"  ],\n      [ 3,    \"Seattle\",   1,       3.        , \"WA\"  ],\n      [ 4,    \"Chicago\",   1,       4.        , \"IL\"  ],\n      [ 5,    \"Bedford\",   2,       5.+8      , \"MA:NY\"],\n      [ 6,    \"Buffalo\",   1,       7.        , \"NY\"  ],\n      [ 7,    \"Boston\",    1,       9.        , \"MA\"  ],\n      [ 8,    \"Yonkers\",   1,       10.       , \"NY\"  ],\n    ])\n    self.assertTableData('Address_summary_state', cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\", \"amount\" ],\n      [ 1,    \"NY\",     7,      1.+2+6+7+8+10+11 ],\n      [ 2,    \"WA\",     1,      3.       ],\n      [ 3,    \"IL\",     1,      4.       ],\n      [ 4,    \"MA\",     2,      5.+9     ],\n    ])\n\n    # Modify a value, and check that various tables got updated correctly.\n    out_actions = self.update_record(\"Address\", 28, state=\"MA\")\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        actions.UpdateRecord(\"Address\", 28, {'state': 'MA'}),\n        actions.RemoveRecord(\"Address_summary_city_state\", 7),\n        actions.UpdateRecord(\"Address_summary_city\", 5,  {'state': \"MA\"}),\n        actions.UpdateRecord(\"Address_summary_city_state\", 5, {'amount': 5.0 + 8.0}),\n        actions.UpdateRecord(\"Address_summary_city_state\", 5, {'average': 6.5}),\n        actions.UpdateRecord(\"Address_summary_city_state\", 5, {'count': 2}),\n        actions.UpdateRecord(\"Address_summary_city_state\", 5, {'group': [25, 28]}),\n        actions.BulkUpdateRecord(\"Address_summary_state\", [1,4],\n                                 {'amount': [1.+2+6+7+10+11, 5.+8+9]}),\n        actions.BulkUpdateRecord(\"Address_summary_state\", [1,4], {'count': [6, 3]}),\n        actions.BulkUpdateRecord(\"Address_summary_state\", [1,4],\n                                 {'group': [[21,22,26,27,30,31], [25,28,29]]}),\n      ]\n    })\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_summary_col_rename(self):\n    # Verify that renaming a column in a source table causes appropriate renames in the summary\n    # tables, and that renames of group-by columns in summary tables are disallowed.\n\n    # Start as in test_change_summary_formula() test case; see there for what tables and columns\n    # we expect to have at this point.\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n\n    # Check that we cannot rename a summary group-by column. (Perhaps it's better to raise an\n    # exception, but currently we translate the invalid request to a no-op.)\n    with self.assertRaisesRegex(ValueError, r'Cannot modify .* group-by'):\n      self.apply_user_action([\"RenameColumn\", \"Address_summary_city_state\", \"state\", \"s\"])\n\n    # Verify all data. We'll repeat this after renamings to make sure there are no errors.\n    self.assertTableData(\"Address\", self.starting_table_data)\n    self.assertTableData('Address_summary_city_state', cols=\"all\", data=[\n      [ \"id\", \"city\",     \"state\", \"group\", \"count\", \"amount\" ],\n      [ 1,    \"New York\", \"NY\"   , [21,26,31],3,     1.+6+11  ],\n      [ 2,    \"Albany\",   \"NY\"   , [22],    1,       2.       ],\n      [ 3,    \"Seattle\",  \"WA\"   , [23],    1,       3.       ],\n      [ 4,    \"Chicago\",  \"IL\"   , [24],    1,       4.       ],\n      [ 5,    \"Bedford\",  \"MA\"   , [25],    1,       5.       ],\n      [ 6,    \"Buffalo\",  \"NY\"   , [27],    1,       7.       ],\n      [ 7,    \"Bedford\",  \"NY\"   , [28],    1,       8.       ],\n      [ 8,    \"Boston\",   \"MA\"   , [29],    1,       9.       ],\n      [ 9,    \"Yonkers\",  \"NY\"   , [30],    1,       10.      ],\n    ])\n    self.assertTableData('Address_summary', cols=\"all\", data=[\n      [ \"id\", \"count\",  \"amount\", \"group\" ],\n      [ 1,    11,       66.0    , [21,22,23,24,25,26,27,28,29,30,31]],\n    ])\n\n    # This should work fine, and should affect sister tables.\n    self.apply_user_action([\"RenameColumn\", \"Address_summary_city_state\", \"count\", \"xcount\"])\n\n    # These are the tables and columns we automatically get.\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"xcount\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      Table(3, \"Address_summary\", 0, 1, columns=[\n        Column(19, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(20, \"xcount\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(21, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ])\n    ])\n\n    # Check that renames in the source table translate to renames in the summary table.\n    self.apply_user_action([\"RenameColumn\", \"Address\", \"state\", \"xstate\"])\n    self.apply_user_action([\"RenameColumn\", \"Address\", \"amount\", \"xamount\"])\n\n    self.assertTables([\n      Table(1, \"Address\", primaryViewId=0, summarySourceTable=0, columns=[\n        Column(11, \"city\",    \"Text\",      False,  \"\", 0),\n        Column(12, \"xstate\",  \"Text\",      False,  \"\", 0),\n        Column(13, \"xamount\", \"Numeric\",   False,  \"\", 0),\n      ]),\n      Table(2, \"Address_summary_city_xstate\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"xstate\",  \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"xcount\",  \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"xamount\", \"Numeric\",  True,   \"SUM($group.xamount)\", 0),\n      ]),\n      Table(3, \"Address_summary\", 0, 1, columns=[\n        Column(19, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(20, \"xcount\",  \"Int\",      True,   \"len($group)\", 0),\n        Column(21, \"xamount\", \"Numeric\",  True,   \"SUM($group.xamount)\", 0),\n      ])\n    ])\n\n    def replace_col_names(data, **col_renames):\n      return [[col_renames.get(c, c) for c in data[0]]] + data[1:]\n\n    # Verify actual data to make sure we don't have formula errors.\n    address_table_data = replace_col_names(\n      self.starting_table_data, state='xstate', amount='xamount')\n    self.assertTableData(\"Address\", address_table_data)\n    self.assertTableData('Address_summary_city_xstate', cols=\"all\", data=[\n      [ \"id\", \"city\",    \"xstate\", \"group\", \"xcount\", \"xamount\" ],\n      [ 1,    \"New York\", \"NY\"   , [21,26,31],3,     1.+6+11  ],\n      [ 2,    \"Albany\",   \"NY\"   , [22],    1,       2.       ],\n      [ 3,    \"Seattle\",  \"WA\"   , [23],    1,       3.       ],\n      [ 4,    \"Chicago\",  \"IL\"   , [24],    1,       4.       ],\n      [ 5,    \"Bedford\",  \"MA\"   , [25],    1,       5.       ],\n      [ 6,    \"Buffalo\",  \"NY\"   , [27],    1,       7.       ],\n      [ 7,    \"Bedford\",  \"NY\"   , [28],    1,       8.       ],\n      [ 8,    \"Boston\",   \"MA\"   , [29],    1,       9.       ],\n      [ 9,    \"Yonkers\",  \"NY\"   , [30],    1,       10.      ],\n    ])\n    self.assertTableData('Address_summary', cols=\"all\", data=[\n      [ \"id\", \"xcount\",  \"xamount\", \"group\" ],\n      [ 1,    11,       66.0      , [21,22,23,24,25,26,27,28,29,30,31]],\n    ])\n\n\n    # Add a conflicting name to a summary table and see how renames behave.\n    self.apply_user_action([\"AddColumn\", \"Address_summary_city_xstate\", \"foo\",\n                            {\"formula\": \"$xamount * 100\"}])\n    self.apply_user_action([\"RenameColumn\", \"Address\", \"xstate\", \"foo\"])\n    self.apply_user_action([\"RenameColumn\", \"Address\", \"xamount\", \"foo\"])\n    self.apply_user_action([\"RenameColumn\", \"Address\", \"city\", \"city\"])\n\n    self.assertTables([\n      Table(1, \"Address\", primaryViewId=0, summarySourceTable=0, columns=[\n        Column(11, \"city\",    \"Text\",      False,  \"\", 0),\n        Column(12, \"foo2\",    \"Text\",      False,  \"\", 0),\n        Column(13, \"foo3\",    \"Numeric\",   False,  \"\", 0),\n      ]),\n      Table(2, \"Address_summary_city_foo2\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"foo2\",    \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"xcount\",  \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"foo3\",    \"Numeric\",  True,   \"SUM($group.foo3)\", 0),\n        Column(22, \"foo\",     \"Any\",      True,   \"$foo3 * 100\", 0),\n      ]),\n      Table(3, \"Address_summary\", 0, 1, columns=[\n        Column(19, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(20, \"xcount\",  \"Int\",      True,   \"len($group)\", 0),\n        Column(21, \"foo3\",    \"Numeric\",  True,   \"SUM($group.foo3)\", 0),\n      ])\n    ])\n\n    # Verify actual data again to make sure we don't have formula errors.\n    address_table_data = replace_col_names(\n      address_table_data, xstate='foo2', xamount='foo3')\n    self.assertTableData(\"Address\", address_table_data)\n    self.assertTableData('Address_summary_city_foo2', cols=\"all\", data=[\n      [ \"id\", \"city\",     \"foo2\" , \"group\", \"xcount\", \"foo3\", \"foo\" ],\n      [ 1,    \"New York\", \"NY\"   , [21,26,31],3,     1.+6+11, 100*(1.+6+11) ],\n      [ 2,    \"Albany\",   \"NY\"   , [22],    1,       2.     , 100*(2.)      ],\n      [ 3,    \"Seattle\",  \"WA\"   , [23],    1,       3.     , 100*(3.)      ],\n      [ 4,    \"Chicago\",  \"IL\"   , [24],    1,       4.     , 100*(4.)      ],\n      [ 5,    \"Bedford\",  \"MA\"   , [25],    1,       5.     , 100*(5.)      ],\n      [ 6,    \"Buffalo\",  \"NY\"   , [27],    1,       7.     , 100*(7.)      ],\n      [ 7,    \"Bedford\",  \"NY\"   , [28],    1,       8.     , 100*(8.)      ],\n      [ 8,    \"Boston\",   \"MA\"   , [29],    1,       9.     , 100*(9.)      ],\n      [ 9,    \"Yonkers\",  \"NY\"   , [30],    1,       10.    , 100*(10.)     ],\n    ])\n    self.assertTableData('Address_summary', cols=\"all\", data=[\n      [ \"id\", \"xcount\",  \"foo3\" , \"group\" ],\n      [ 1,    11,       66.0    , [21,22,23,24,25,26,27,28,29,30,31]],\n    ])\n\n    # Check that update to widgetOptions in source table affects group-by columns and not formula\n    # columns. (Same should be true for type, but not tested here.)\n    self.apply_user_action([\"ModifyColumn\", \"Address\", \"foo2\", {\"widgetOptions\": \"hello\"}])\n    self.apply_user_action([\"ModifyColumn\", \"Address\", \"foo3\", {\"widgetOptions\": \"world\"}])\n\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      ['id', 'colId',   'isFormula',  'widgetOptions'],\n      [12,   'foo2',    False,        'hello'],\n      [13,   'foo3',    False,        'world'],\n      [15,   'foo2',    False,        'hello'],\n      [18,   'foo3',    True,         'WidgetOptions2'],\n      [21,   'foo3',    True,         'WidgetOptions2'],\n    ], rows=lambda r: r.colId in ('foo2', 'foo3'))\n\n  @test_engine.test_undo\n  def test_summary_col_rename_conflict(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [11, \"A\", \"Text\", False, \"\", \"A\", \"\"],\n          [12, \"B\", \"Text\", False, \"\", \"B\", \"\"],\n        ]],\n        [2, \"Table1_summary_A_B\", [\n          [13, \"A\", \"Text\", False, \"\", \"A\", \"\"],\n        ]],\n      ],\n      \"DATA\": {}\n    })\n    self.load_sample(sample)\n\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11, 12], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11], None])\n\n    table1 = Table(\n      1, \"Table1\", primaryViewId=0, summarySourceTable=0, columns=[\n        Column(11, \"A\", \"Text\", False, \"\", 0),\n        Column(12, \"B\", \"Text\", False, \"\", 0),\n      ],\n    )\n\n    # Normal table whose name conflicts with the automatically-generated summary table name below\n    fake_summary = Table(\n      2, \"Table1_summary_A_B\", primaryViewId=0, summarySourceTable=0, columns=[\n        Column(13, \"A\", \"Text\", False, \"\", 0),\n      ],\n    )\n\n    # Auto-generated name has to have a '2' to disambiguate from the normal table.\n    summary_by_a_and_b = Table(\n      3, \"Table1_summary_A_B2\", primaryViewId=0, summarySourceTable=1, columns=[\n        Column(14, \"A\", \"Text\", False, \"\", 11),\n        Column(15, \"B\", \"Text\", False, \"\", 12),\n        Column(16, \"group\", \"RefList:Table1\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"count\", \"Int\", True, \"len($group)\", 0),\n      ],\n    )\n\n    # nothing special here yet\n    summary_by_a = Table(\n      4, \"Table1_summary_A\", primaryViewId=0, summarySourceTable=1, columns=[\n        Column(18, \"A\", \"Text\", False, \"\", 11),\n        Column(19, \"group\", \"RefList:Table1\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(20, \"count\", \"Int\", True, \"len($group)\", 0),\n      ],\n    )\n\n    tables = [table1, fake_summary, summary_by_a_and_b, summary_by_a]\n    self.assertTables(tables)\n\n    # Add some formulas using summary table names that are about to change\n    self.add_column(\"Table1\", \"summary_ref1\",\n                    type=\"RefList:Table1_summary_A_B2\",\n                    formula=\"Table1_summary_A_B2.lookupRecords(A=1)\",\n                    isFormula=True)\n    self.add_column(\"Table1\", \"summary_ref2\",\n                    type=\"Ref:Table1_summary_A\",\n                    formula=\"Table1_summary_A.lookupOne(A=23)\",\n                    isFormula=True)\n\n    # I got the weirdest heisenbug ever when renaming straight from A to A_B.\n    # The order of renaming is not deterministic so it may end up with\n    # 'Table1_summary_A_B3', but asserting that name made it come out as\n    # 'Table1_summary_A_B2' instead. Seems that file contents play a role in\n    # order in sets/dictionaries?\n    self.apply_user_action([\"RenameColumn\", \"Table1\", \"A\", \"A2\"])\n    self.apply_user_action([\"RenameColumn\", \"Table1\", \"A2\", \"A_B\"])\n\n    # Summary tables are automatically renamed to match the new column names.\n    summary_by_a_and_b = summary_by_a_and_b._replace(tableId=\"Table1_summary_A_B_B\")\n    summary_by_a = summary_by_a._replace(tableId=\"Table1_summary_A_B2\")\n\n    table1.columns[0] = table1.columns[0]._replace(colId=\"A_B\")\n    summary_by_a_and_b.columns[0] = summary_by_a_and_b.columns[0]._replace(colId=\"A_B\")\n    summary_by_a.columns[0] = summary_by_a.columns[0]._replace(colId=\"A_B\")\n\n    table1.columns.extend([\n      Column(21, \"summary_ref1\", \"RefList:Table1_summary_A_B_B\", True,\n             \"Table1_summary_A_B_B.lookupRecords(A_B=1)\", 0),\n      Column(22, \"summary_ref2\", \"Ref:Table1_summary_A_B2\", True,\n              \"Table1_summary_A_B2.lookupOne(A_B=23)\", 0),\n    ])\n\n    tables = [table1, fake_summary, summary_by_a_and_b, summary_by_a]\n    self.assertTables(tables)\n\n  @test_engine.test_undo\n  def test_source_table_rename_conflict(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [11, \"A\", \"Text\", False, \"\", \"A\", \"\"],\n        ]],\n        [2, \"Table2_summary\", [\n          [13, \"A\", \"Text\", False, \"\", \"A\", \"\"],\n        ]],\n      ],\n      \"DATA\": {}\n    })\n    self.load_sample(sample)\n\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n\n    table1 = Table(\n      1, \"Table1\", primaryViewId=0, summarySourceTable=0, columns=[\n        Column(11, \"A\", \"Text\", False, \"\", 0),\n      ],\n    )\n\n    fake_summary = Table(\n      2, \"Table2_summary\", primaryViewId=0, summarySourceTable=0, columns=[\n        Column(13, \"A\", \"Text\", False, \"\", 0),\n      ],\n    )\n\n    summary = Table(\n      3, \"Table1_summary\", primaryViewId=0, summarySourceTable=1, columns=[\n        Column(14, \"group\", \"RefList:Table1\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(15, \"count\", \"Int\", True, \"len($group)\", 0),\n      ],\n    )\n\n    tables = [table1, fake_summary, summary]\n    self.assertTables(tables)\n\n    self.apply_user_action([\"RenameTable\", \"Table1\", \"Table2\"])\n\n    table1 = table1._replace(tableId=\"Table2\")\n    # Summary table is automatically renamed to match the new table name.\n    # Needs a '2' to disambiguate from the fake_summary table.\n    summary = summary._replace(tableId=\"Table2_summary2\")\n    summary.columns[0] = summary.columns[0]._replace(type=\"RefList:Table2\")\n\n    tables = [table1, fake_summary, summary]\n    self.assertTables(tables)\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_restrictions(self):\n    # Verify various restrictions on summary tables\n    # (1) no adding/removing/renaming non-formula columns.\n    # (2) no converting between formula/non-formula\n    # (3) no editing values in non-formula columns\n    # (4) no removing rows (this is questionable b/c empty rows might be OK to remove)\n    # (5) no renaming summary tables.\n\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n\n    self.assertTableData('Address_summary_city_state', cols=\"all\", data=[\n      [ \"id\", \"city\",     \"state\", \"group\", \"count\", \"amount\" ],\n      [ 1,    \"New York\", \"NY\"   , [21,26,31],3,     1.+6+11  ],\n      [ 2,    \"Albany\",   \"NY\"   , [22],    1,       2.       ],\n      [ 3,    \"Seattle\",  \"WA\"   , [23],    1,       3.       ],\n      [ 4,    \"Chicago\",  \"IL\"   , [24],    1,       4.       ],\n      [ 5,    \"Bedford\",  \"MA\"   , [25],    1,       5.       ],\n      [ 6,    \"Buffalo\",  \"NY\"   , [27],    1,       7.       ],\n      [ 7,    \"Bedford\",  \"NY\"   , [28],    1,       8.       ],\n      [ 8,    \"Boston\",   \"MA\"   , [29],    1,       9.       ],\n      [ 9,    \"Yonkers\",  \"NY\"   , [30],    1,       10.      ],\n    ])\n\n    # (1) no adding/removing/renaming non-formula columns.\n    with self.assertRaisesRegex(ValueError, r'non-formula column'):\n      self.apply_user_action([\"AddColumn\", \"Address_summary_city_state\", \"foo\",\n                              {\"type\": \"Numeric\", \"isFormula\": False}])\n\n    with self.assertRaisesRegex(ValueError, r'group-by column'):\n      self.apply_user_action([\"RemoveColumn\", \"Address_summary_city_state\", \"state\"])\n\n    with self.assertRaisesRegex(ValueError, r'Cannot modify .* group-by'):\n      self.apply_user_action([\"RenameColumn\", \"Address_summary_city_state\", \"state\", \"st\"])\n\n    # (2) no converting between formula/non-formula\n    with self.assertRaisesRegex(ValueError, r'Cannot change .* formula and data'):\n      self.apply_user_action([\"ModifyColumn\", \"Address_summary_city_state\", \"amount\",\n                              {\"isFormula\": False}])\n\n    with self.assertRaisesRegex(ValueError, r'Cannot change .* formula and data'):\n      self.apply_user_action([\"ModifyColumn\", \"Address_summary_city_state\", \"state\",\n                              {\"isFormula\": True}])\n\n    # (3) no editing values in non-formula columns\n    with self.assertRaisesRegex(ValueError, r'Cannot enter data .* group-by'):\n      self.apply_user_action([\"UpdateRecord\", \"Address_summary_city_state\", 6, {\"state\": \"ny\"}])\n\n    # (4) no removing rows (this is questionable b/c empty rows might be OK to remove)\n    with self.assertRaisesRegex(ValueError, r'Cannot remove record .* summary'):\n      self.apply_user_action([\"RemoveRecord\", \"Address_summary_city_state\", 6])\n\n    # (5) no renaming summary tables.\n    with self.assertRaisesRegex(ValueError, r'cannot rename .* summary'):\n      self.apply_user_action([\"RenameTable\", \"Address_summary_city_state\", \"Address_summary_X\"])\n\n    # Check that we can add an empty column, then set a formula for it.\n    self.apply_user_action([\"AddColumn\", \"Address_summary_city_state\", \"foo\", {}])\n    self.apply_user_action([\"ModifyColumn\", \"Address_summary_city_state\", \"foo\",\n                            {\"formula\": \"1+1\"}])\n    with self.assertRaisesRegex(ValueError, \"Can't save .* to formula\"):\n      self.apply_user_action([\"UpdateRecord\", \"Address_summary_city_state\", 1, {\"foo\": \"hello\"}])\n\n    # But we cannot add an empty column, then add a value to it.\n    self.apply_user_action([\"AddColumn\", \"Address_summary_city_state\", \"foo2\", {}])\n    with self.assertRaisesRegex(ValueError, r'Cannot change .* between formula and data'):\n      self.apply_user_action([\"UpdateRecord\", \"Address_summary_city_state\", 1, {\"foo2\": \"hello\"}])\n\n    self.assertTableData('Address_summary_city_state', cols=\"all\", data=[\n      [ \"id\", \"city\",     \"state\", \"group\", \"count\", \"amount\", \"foo\", \"foo2\" ],\n      [ 1,    \"New York\", \"NY\"   , [21,26,31],3,     1.+6+11 , 2    , None   ],\n      [ 2,    \"Albany\",   \"NY\"   , [22],    1,       2.      , 2    , None   ],\n      [ 3,    \"Seattle\",  \"WA\"   , [23],    1,       3.      , 2    , None   ],\n      [ 4,    \"Chicago\",  \"IL\"   , [24],    1,       4.      , 2    , None   ],\n      [ 5,    \"Bedford\",  \"MA\"   , [25],    1,       5.      , 2    , None   ],\n      [ 6,    \"Buffalo\",  \"NY\"   , [27],    1,       7.      , 2    , None   ],\n      [ 7,    \"Bedford\",  \"NY\"   , [28],    1,       8.      , 2    , None   ],\n      [ 8,    \"Boston\",   \"MA\"   , [29],    1,       9.      , 2    , None   ],\n      [ 9,    \"Yonkers\",  \"NY\"   , [30],    1,       10.     , 2    , None   ],\n    ])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_update_summary_section(self):\n    # Verify that we can change the group-by for a view section, and that unused tables get\n    # removed.\n\n    def get_helper_cols(table_id):\n      return [c for c in self.engine.tables[table_id].all_columns if c.startswith('#summary#')]\n\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n\n    # We should have a single summary table, and a single section referring to it.\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=2, fields=[\n        Field(5, colRef=14),\n        Field(6, colRef=15),\n        Field(7, colRef=17),\n        Field(8, colRef=18),\n      ])\n    ])])\n    self.assertEqual(get_helper_cols('Address'), ['#summary#Address_summary_city_state'])\n\n    # Verify more fields of some of the new column objects.\n    self.assertTableData('_grist_Tables_column', rows=\"subset\", cols=\"subset\", data=[\n      ['id', 'colId',  'type',    'formula',            'widgetOptions',  'label'],\n      [14,   'city',   'Text',    '',                   '',               'City'],\n      [15,   'state',  'Text',    '',                   'WidgetOptions1', 'State'],\n      [18,   'amount', 'Numeric', 'SUM($group.amount)', 'WidgetOptions2', 'Amount'],\n    ])\n\n    # Now change the group-by to just one of the columns ('state')\n    self.apply_user_action([\"UpdateSummaryViewSection\", 2, [12]])\n    self.assertTables([\n      self.starting_table,\n      # Note that Table #2 is gone at this point, since it's unused.\n      Table(3, \"Address_summary_state\", 0, 1, columns=[\n        Column(19, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(20, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(21, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(22, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=3, fields=[\n        Field(6, colRef=19),\n        Field(7, colRef=21),\n        Field(8, colRef=22),\n      ])\n    ])])\n    self.assertTableData('Address_summary_state', cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\", \"amount\"          ],\n      [ 1,    \"NY\",     7,      1.+2+6+7+8+10+11  ],\n      [ 2,    \"WA\",     1,      3.                ],\n      [ 3,    \"IL\",     1,      4.                ],\n      [ 4,    \"MA\",     2,      5.+9              ],\n    ])\n    self.assertEqual(get_helper_cols('Address'), ['#summary#Address_summary_state'])\n\n    # Verify more fields of some of the new column objects.\n    self.assertTableData('_grist_Tables_column', rows=\"subset\", cols=\"subset\", data=[\n      ['id', 'colId',  'type',    'formula',            'widgetOptions',  'label'],\n      [19,   'state',  'Text',    '',                   'WidgetOptions1', 'State'],\n      [22,   'amount', 'Numeric', 'SUM($group.amount)', 'WidgetOptions2', 'Amount'],\n    ])\n\n    # Change group-by to a different single column ('city')\n    self.apply_user_action([\"UpdateSummaryViewSection\", 2, [11]])\n    self.assertTables([\n      self.starting_table,\n      # Note that Table #3 is gone at this point, since it's unused.\n      Table(4, \"Address_summary_city\", 0, 1, columns=[\n        Column(23, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(24, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(25, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(26, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=4, fields=[\n        Field(15, colRef=23),\n        Field(7, colRef=25),\n        Field(8, colRef=26),\n      ])\n    ])])\n    self.assertTableData('Address_summary_city', cols=\"subset\", data=[\n      [ \"id\", \"city\",     \"count\",  \"amount\" ],\n      [ 1,    \"New York\",  3,       1.+6+11  ],\n      [ 2,    \"Albany\",    1,       2.       ],\n      [ 3,    \"Seattle\",   1,       3.       ],\n      [ 4,    \"Chicago\",   1,       4.       ],\n      [ 5,    \"Bedford\",   2,       5.+8     ],\n      [ 6,    \"Buffalo\",   1,       7.       ],\n      [ 7,    \"Boston\",    1,       9.       ],\n      [ 8,    \"Yonkers\",   1,       10.      ],\n    ])\n    self.assertEqual(get_helper_cols('Address'), ['#summary#Address_summary_city'])\n\n    # Verify more fields of some of the new column objects.\n    self.assertTableData('_grist_Tables_column', rows=\"subset\", cols=\"subset\", data=[\n      ['id', 'colId',  'type',    'formula',            'widgetOptions',  'label'],\n      [23,   'city',   'Text',    '',                   '',               'City'],\n      [26,   'amount', 'Numeric', 'SUM($group.amount)', 'WidgetOptions2', 'Amount'],\n    ])\n\n    # Change group-by to no columns (totals)\n    self.apply_user_action([\"UpdateSummaryViewSection\", 2, []])\n    self.assertTables([\n      self.starting_table,\n      # Note that Table #4 is gone at this point, since it's unused.\n      Table(5, \"Address_summary\", 0, 1, columns=[\n        Column(27, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(28, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(29, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=5, fields=[\n        Field(7, colRef=28),\n        Field(8, colRef=29),\n      ])\n    ])])\n    self.assertTableData('Address_summary', cols=\"subset\", data=[\n      [ \"id\", \"count\",  \"amount\"],\n      [ 1,    11,       66.0    ],\n    ])\n    self.assertEqual(get_helper_cols('Address'), ['#summary#Address_summary'])\n\n    # Back to full circle, but with group-by columns differently arranged.\n    self.apply_user_action([\"UpdateSummaryViewSection\", 2, [12,11]])\n    self.assertTables([\n      self.starting_table,\n      # Note that Table #5 is gone at this point, since it's unused.\n      Table(6, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(30, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(31, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(32, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(33, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(34, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=6, fields=[\n        Field(22, colRef=30),\n        Field(23, colRef=31),\n        Field(7, colRef=33),\n        Field(8, colRef=34),\n      ])\n    ])])\n    self.assertTableData('Address_summary_city_state', cols=\"subset\", data=[\n      [ \"id\", \"city\",     \"state\", \"count\", \"amount\"  ],\n      [ 1,    \"New York\", \"NY\"   , 3,       1.+6+11   ],\n      [ 2,    \"Albany\",   \"NY\"   , 1,       2.        ],\n      [ 3,    \"Seattle\",  \"WA\"   , 1,       3.        ],\n      [ 4,    \"Chicago\",  \"IL\"   , 1,       4.        ],\n      [ 5,    \"Bedford\",  \"MA\"   , 1,       5.        ],\n      [ 6,    \"Buffalo\",  \"NY\"   , 1,       7.        ],\n      [ 7,    \"Bedford\",  \"NY\"   , 1,       8.        ],\n      [ 8,    \"Boston\",   \"MA\"   , 1,       9.        ],\n      [ 9,    \"Yonkers\",  \"NY\"   , 1,       10.       ],\n    ])\n    self.assertEqual(get_helper_cols('Address'), ['#summary#Address_summary_city_state'])\n\n    # Now add a different view section with the same group-by columns.\n    self.apply_user_action([\"CreateViewSection\", 1, 1, \"record\", [11,12], None])\n    self.assertTables([\n      self.starting_table,\n      Table(6, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(30, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(31, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(32, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(33, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(34, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=6, fields=[\n        Field(22, colRef=30),\n        Field(23, colRef=31),\n        Field(7, colRef=33),\n        Field(8, colRef=34),\n      ]),\n      Section(7, parentKey=\"record\", tableRef=6, fields=[\n        Field(24,  colRef=31),\n        Field(25,  colRef=30),\n        Field(26,  colRef=33),\n        Field(27, colRef=34),\n      ])\n    ])])\n    self.assertEqual(get_helper_cols('Address'), ['#summary#Address_summary_city_state'])\n\n    # Change one view section, and ensure there are now two summary tables.\n    self.apply_user_action([\"UpdateSummaryViewSection\", 7, []])\n    self.assertTables([\n      self.starting_table,\n      Table(6, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(30, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(31, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(32, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(33, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(34, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      Table(7, \"Address_summary\", 0, 1, columns=[\n        Column(35, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(36, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(37, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=6, fields=[\n        Field(22, colRef=30),\n        Field(23, colRef=31),\n        Field(7, colRef=33),\n        Field(8, colRef=34),\n      ]),\n      Section(7, parentKey=\"record\", tableRef=7, fields=[\n        Field(26,  colRef=36),\n        Field(27, colRef=37),\n      ])\n    ])])\n    self.assertEqual(get_helper_cols('Address'), ['#summary#Address_summary_city_state',\n                                                  '#summary#Address_summary'])\n\n    # Delete one view section, and see that the summary table is gone.\n    self.apply_user_action([\"RemoveViewSection\", 7])\n    self.assertTables([\n      self.starting_table,\n      # Note that Table #7 is gone at this point, since it's now unused.\n      Table(6, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(30, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(31, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(32, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(33, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(34, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ])\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=6, fields=[\n        Field(22, colRef=30),\n        Field(23, colRef=31),\n        Field(7, colRef=33),\n        Field(8, colRef=34),\n      ])\n    ])])\n    self.assertEqual(get_helper_cols('Address'), ['#summary#Address_summary_city_state'])\n\n    # Change the section to add and then remove the \"amount\" to the group-by column; check that\n    # column \"amount\" was correctly restored\n    self.apply_user_action([\"UpdateSummaryViewSection\", 2, [11, 12, 13]])\n    self.assertTables([\n      self.starting_table,\n      Table(7, \"Address_summary_amount_city_state\", 0, 1, columns=[\n        Column(35, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(36, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(37, \"amount\",  \"Numeric\",  False,   \"\", 13),\n        Column(38, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(39, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=7, fields=[\n        Field(23, colRef=35),\n        Field(22, colRef=36),\n        Field(28, colRef=37),\n        Field(7, colRef=39),\n      ])\n    ])])\n    self.apply_user_action([\"UpdateSummaryViewSection\", 2, [11,12]])\n    self.assertTables([\n      self.starting_table,\n      Table(8, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(40, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(41, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(42, \"amount\",  \"Numeric\",  True, \"SUM($group.amount)\", 0),\n        Column(43, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(44, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=8, fields=[\n        Field(23, colRef=40),\n        Field(22, colRef=41),\n        Field(28, colRef=42),\n        Field(7, colRef=44),\n      ])\n    ])])\n\n    # Hide a formula and update group by columns; check that the formula columns had not been\n    # deleted\n    self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 7])\n    self.apply_user_action([\"UpdateSummaryViewSection\", 2, [11]])\n    self.assertTables([\n      self.starting_table,\n      Table(9, \"Address_summary_city\", 0, 1, columns=[\n        Column(45, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(46, \"amount\",  \"Numeric\",  True, \"SUM($group.amount)\", 0),\n        Column(48, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(47, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=9, fields=[\n        Field(23, colRef=45),\n        Field(28, colRef=46),\n      ])\n    ])])\n\n    # Delete source table, and ensure its summary table is also gone.\n    self.apply_user_action([\"RemoveTable\", \"Address\"])\n    self.assertTables([])\n    self.assertViews([])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_update_groupby_override(self):\n    # Verify that if we add a group-by column that conflicts with a formula, group-by column wins.\n\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [12], None])\n    self.apply_user_action([\"AddVisibleColumn\", \"Address_summary_state\", \"city\",\n                            {\"formula\": \"$state.lower()\"}])\n\n    # We should have a single summary table, and a single section referring to it.\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary_state\", 0, 1, columns=[\n        Column(14, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(15, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(16, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(17, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n        Column(18, \"city\",    \"Any\",      True,   \"$state.lower()\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=2, fields=[\n        Field(4, colRef=14),\n        Field(5, colRef=16),\n        Field(6, colRef=17),\n        Field(8, colRef=18),\n      ])\n    ])])\n    self.assertTableData('Address_summary_state', cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\", \"amount\"          , \"city\"],\n      [ 1,    \"NY\",     7,      1.+2+6+7+8+10+11  , \"ny\"  ],\n      [ 2,    \"WA\",     1,      3.                , \"wa\"  ],\n      [ 3,    \"IL\",     1,      4.                , \"il\"  ],\n      [ 4,    \"MA\",     2,      5.+9              , \"ma\"  ],\n    ])\n\n    # Change the section to add \"city\" as a group-by column; check that the formula is gone.\n    self.apply_user_action([\"UpdateSummaryViewSection\", 2, [11,12]])\n    self.assertTables([\n      self.starting_table,\n      Table(3, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(19, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(20, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(21, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(22, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(23, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=3, fields=[\n        # We requested 'city' to come before 'state', check that this is the case.\n        Field(13, colRef=19),\n        Field(4, colRef=20),\n        Field(5, colRef=22),\n        Field(6, colRef=23),\n      ])\n    ])])\n\n    # TODO We should have more tests on UpdateSummaryViewSection that rearranges columns in\n    # interesting ways (e.g. add new column to middle of existing group-by columns; put group-by\n    # columns in the middle of other fields then UpdateSummary to rearrange them).\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_cleanup_on_view_remove(self):\n    # Verify that if we remove a view, that unused summary tables get cleaned up.\n\n    # Create one view with one summary section, and another view with three sections.\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None]) # Creates View #1\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])      # Creates View #2\n    self.apply_user_action([\"CreateViewSection\", 1, 2, \"record\", [11,12], None]) # Refers to View #2\n    self.apply_user_action([\"CreateViewSection\", 1, 2, \"record\", [12], None])    # Refers to View #2\n\n    # We should have a single summary table, and a single section referring to it.\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      Table(3, \"Address_summary\", 0, 1, columns=[\n        Column(19, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(20, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(21, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      Table(4, \"Address_summary_state\", 0, 1, columns=[\n        Column(22, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(23, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(24, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(25, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=2, fields=[\n        Field(5, colRef=14),\n        Field(6, colRef=15),\n        Field(7, colRef=17),\n        Field(8, colRef=18),\n      ])\n    ]), View(2, sections=[\n      Section(4, parentKey=\"record\", tableRef=3, fields=[\n        Field(11, colRef=20),\n        Field(12, colRef=21),\n      ]),\n      Section(5, parentKey=\"record\", tableRef=2, fields=[\n        Field(13, colRef=14),\n        Field(14, colRef=15),\n        Field(15, colRef=17),\n        Field(16, colRef=18),\n      ]),\n      Section(7, parentKey=\"record\", tableRef=4, fields=[\n        Field(20, colRef=22),\n        Field(21, colRef=24),\n        Field(22, colRef=25),\n      ])\n    ])])\n\n    # Now change the group-by to just one of the columns ('state')\n    self.apply_user_action([\"RemoveView\", 2])\n\n    # Verify that unused summary tables are also gone, but the one used remains.\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertViews([View(1, sections=[\n      Section(2, parentKey=\"record\", tableRef=2, fields=[\n        Field(5, colRef=14),\n        Field(6, colRef=15),\n        Field(7, colRef=17),\n        Field(8, colRef=18),\n      ])\n    ])])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_update_sort_spec(self):\n    # Verify that we correctly update sort spec when we update a summary view section.\n\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Views_section\", 2,\n                            {\"sortColRefs\": \"[15,14,-17]\"}])\n\n    # We should have a single summary table, and a single (non-raw) section referring to it.\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertTableData('_grist_Views_section', cols=\"subset\", data=[\n      [\"id\",  \"tableRef\", \"sortColRefs\"],\n      [1,     2,          \"\"], # This is the raw section.\n      [2,     2,          \"[15,14,-17]\"],\n    ])\n\n    # Now change the group-by to just one of the columns ('state')\n    self.apply_user_action([\"UpdateSummaryViewSection\", 2, [12]])\n    self.assertTables([\n      self.starting_table,\n      # Note that Table #2 is gone at this point, since it's unused.\n      Table(3, \"Address_summary_state\", 0, 1, columns=[\n        Column(19, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(20, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(21, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(22, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    # Verify that sortColRefs refers to new columns.\n    self.assertTableData('_grist_Views_section', cols=\"subset\", data=[\n      [\"id\",  \"tableRef\", \"sortColRefs\"],\n      [2,     3,          \"[19,-21]\"],\n      [3,     3,          \"\"], # This is the raw section.\n    ])\n\n  #----------------------------------------------------------------------\n  @test_engine.test_undo\n  def test_detach_summary_section(self):\n    # Verify that \"DetachSummaryViewSection\" useraction works correctly.\n\n    self.load_sample(self.sample)\n    # Add a couple of summary tables.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n    # Add a formula column\n    self.apply_user_action([\"AddVisibleColumn\", \"Address_summary_city_state\", \"average\",\n                            {\"formula\": \"$amount / $count\"}])\n\n    # Check the table and columns for all the summary tables.\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary_city_state\", 0, 1, columns=[\n        Column(14, \"city\",    \"Text\",     False,  \"\", 11),\n        Column(15, \"state\",   \"Text\",     False,  \"\", 12),\n        Column(16, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(17, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(18, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n        Column(22, \"average\", \"Any\",      True,   \"$amount / $count\", 0),\n      ]),\n      Table(3, \"Address_summary\", 0, 1, columns=[\n        Column(19, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(20, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(21, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n    self.assertTableData('_grist_Views_section', cols=\"subset\", data=[\n      [\"id\",  \"parentId\", \"tableRef\"],\n      [1,     0,          2],\n      [2,     1,          2],\n      [3,     0,          3],\n      [4,     2,          3],\n    ])\n    self.assertTableData('_grist_Views_section_field', cols=\"subset\", data=[\n      [\"id\", \"parentId\", \"colRef\"],\n      [1,     1,          14],\n      [2,     1,          15],\n      [3,     1,          17],\n      [4,     1,          18],\n      [13,    1,          22],\n      [5,     2,          14],\n      [6,     2,          15],\n      [7,     2,          17],\n      [8,     2,          18],\n      [14,    2,          22],\n      [9,     3,          20],\n      [10,    3,          21],\n      [11,    4,          20],\n      [12,    4,          21],\n    ], sort=lambda r: (r.parentId, r.id))\n\n    # Now save one section as a separate table, i.e. \"detach\" it from its source.\n    self.apply_user_action([\"DetachSummaryViewSection\", 2])\n\n    # Check the table and columns for all the summary tables.\n    self.assertTables([\n      self.starting_table,\n      Table(3, \"Address_summary\", 0, 1, columns=[\n        Column(19, \"group\",   \"RefList:Address\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(20, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(21, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n      Table(4, \"Table1\", primaryViewId=3, summarySourceTable=0, columns=[\n        Column(23, \"manualSort\", \"ManualSortPos\",  False,  \"\", 0),\n        Column(24, \"city\",    \"Text\",     False,  \"\", 0),\n        Column(25, \"state\",   \"Text\",     False,  \"\", 0),\n        Column(26, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(27, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n        Column(28, \"average\", \"Any\",      True,   \"$amount / $count\", 0),\n        Column(29, \"group\",   \"RefList:Address\", True,\n               \"Address.lookupRecords(city=$city, state=$state)\", 0),\n      ]),\n    ])\n    # We should now have two sections for table 2 (the one with two group-by fields).\n    self.assertTableData('_grist_Views_section', cols=\"subset\", rows=lambda r: r.parentId, data=[\n      [\"id\",  \"parentId\", \"tableRef\"],\n      [2,     1,          4],\n      [4,     2,          3],\n      [5,     3,          4],\n    ])\n    self.assertTableData(\n      '_grist_Views_section_field', cols=\"subset\", rows=lambda r: r.parentId.parentId, data=[\n      [\"id\", \"parentId\", \"colRef\"],\n      [5,     2,         24],\n      [6,     2,         25],\n      [7,     2,         26],\n      [8,     2,         27],\n      [14,    2,         28],\n      [11,    4,         20],\n      [12,    4,         21],\n      [15,    5,         24],\n      [16,    5,         25],\n      [17,    5,         26],\n      [18,    5,         27],\n      [19,    5,         28],\n    ], sort=lambda r: (r.parentId, r.id))\n\n    # Check that the data is as we expect.\n    self.assertTableData('Table1', cols=\"all\", data=[\n      [ \"id\", \"manualSort\", \"city\",     \"state\", \"group\", \"count\", \"amount\", \"average\"   ],\n      [ 1,    1.0,          \"New York\", \"NY\"   , [21,26,31],3,     1.+6+11 , (1.+6+11)/3 ],\n      [ 2,    2.0,          \"Albany\",   \"NY\"   , [22],    1,       2.      , 2.  ],\n      [ 3,    3.0,          \"Seattle\",  \"WA\"   , [23],    1,       3.      , 3.  ],\n      [ 4,    4.0,          \"Chicago\",  \"IL\"   , [24],    1,       4.      , 4.  ],\n      [ 5,    5.0,          \"Bedford\",  \"MA\"   , [25],    1,       5.      , 5.  ],\n      [ 6,    6.0,          \"Buffalo\",  \"NY\"   , [27],    1,       7.      , 7.  ],\n      [ 7,    7.0,          \"Bedford\",  \"NY\"   , [28],    1,       8.      , 8.  ],\n      [ 8,    8.0,          \"Boston\",   \"MA\"   , [29],    1,       9.      , 9.  ],\n      [ 9,    9.0,          \"Yonkers\",  \"NY\"   , [30],    1,       10.     , 10. ],\n    ])\n    self.assertTableData('Address_summary', cols=\"all\", data=[\n      [ \"id\", \"count\",  \"amount\", \"group\" ],\n      [ 1,    11,       66.0    , [21,22,23,24,25,26,27,28,29,30,31]],\n    ])\n\n  #----------------------------------------------------------------------\n  @test_engine.test_undo\n  def test_summary_of_detached(self):\n    # Verify that we can make a summary table of a detached table. This is mainly to ensure that\n    # we handle well the presence of columns like 'group' and 'count' in the source table.\n\n    # Add a summary table and detach it. Then add a summary table of that table.\n    self.load_sample(self.sample)\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11,12], None])\n    self.apply_user_action([\"DetachSummaryViewSection\", 2])\n\n    # Create a summary of the detached table (tableRef 3) by state (colRef 21).\n    self.apply_user_action([\"CreateViewSection\", 3, 0, \"record\", [21], None])\n\n    # Verify the resulting metadata.\n    self.assertTables([\n      self.starting_table,\n      Table(3, \"Table1\", primaryViewId=2, summarySourceTable=0, columns=[\n        Column(19, \"manualSort\", \"ManualSortPos\",  False,  \"\", 0),\n        Column(20, \"city\",    \"Text\",     False,  \"\", 0),\n        Column(21, \"state\",   \"Text\",     False,  \"\", 0),\n        Column(22, \"count\",   \"Int\",      True,   \"len($group)\", 0),\n        Column(23, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n        Column(24, \"group\",   \"RefList:Address\", True,\n               \"Address.lookupRecords(city=$city, state=$state)\", 0),\n      ]),\n      Table(4, \"Table1_summary_state\", primaryViewId=0, summarySourceTable=3, columns=[\n        Column(25, \"state\",   \"Text\",     False,  \"\", 21),\n        Column(26, \"group\",   \"RefList:Table1\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(27, \"count\",   \"Int\",      True,   \"SUM($group.count)\", 0),\n        Column(28, \"amount\",  \"Numeric\",  True,   \"SUM($group.amount)\", 0),\n      ]),\n    ])\n\n    # Check that the data is as we expect. Table1 is the same as in the previous test case.\n    self.assertTableData('Table1', cols=\"all\", data=[\n      [ \"id\", \"manualSort\", \"city\",     \"state\", \"group\", \"count\", \"amount\" ],\n      [ 1,    1.0,          \"New York\", \"NY\"   , [21,26,31],3,     1.+6+11  ],\n      [ 2,    2.0,          \"Albany\",   \"NY\"   , [22],    1,       2.       ],\n      [ 3,    3.0,          \"Seattle\",  \"WA\"   , [23],    1,       3.       ],\n      [ 4,    4.0,          \"Chicago\",  \"IL\"   , [24],    1,       4.       ],\n      [ 5,    5.0,          \"Bedford\",  \"MA\"   , [25],    1,       5.       ],\n      [ 6,    6.0,          \"Buffalo\",  \"NY\"   , [27],    1,       7.       ],\n      [ 7,    7.0,          \"Bedford\",  \"NY\"   , [28],    1,       8.       ],\n      [ 8,    8.0,          \"Boston\",   \"MA\"   , [29],    1,       9.       ],\n      [ 9,    9.0,          \"Yonkers\",  \"NY\"   , [30],    1,       10.      ],\n    ])\n    self.assertTableData('Table1_summary_state', cols=\"all\", data=[\n      [ \"id\", \"state\",  \"group\",      \"count\",  \"amount\"         ],\n      [ 1,    \"NY\",     [1,2,6,7,9],  7,        1.+6+11+2+7+8+10 ],\n      [ 2,    \"WA\",     [3],          1,        3.               ],\n      [ 3,    \"IL\",     [4],          1,        4.               ],\n      [ 4,    \"MA\",     [5,8],        2,        5.+9             ],\n    ])\n\n  #----------------------------------------------------------------------\n  @test_engine.test_undo\n  def test_update_summary_with_suffixed_colId(self):\n    # Verifies that summary update correctly when one of the formula\n    # columns has a suffixed colId\n\n    self.load_sample(self.sample)\n\n    # Let's create two summary table, one with totals (no grouped by columns) and one grouped by\n    # \"city\".\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [], None])\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11], None])\n\n    # Change type of Amount columns to \"Any\" only for table Address_summary_city. User actions keep\n    # types consistent across same-named columns for all summary tables with the same source table,\n    # but here we want to test for the case where types are inconsistent. Hence we bypass user\n    # actions and directly use doc actions.\n    self.engine.apply_doc_action(actions.UpdateRecord(\"_grist_Tables_column\", 20, {'type': 'Any'}))\n    self.engine.apply_doc_action(actions.ModifyColumn(\"Address_summary_city\", \"amount\", {'type':\n                                                                                         'Any'}))\n    self.engine.assert_schema_consistent()\n\n    self.assertTables([\n      self.starting_table,\n      Table(2, \"Address_summary\", primaryViewId=0, summarySourceTable=1, columns=[\n        Column(14, \"group\", \"RefList:Address\", isFormula=True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(15, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n               formula=\"len($group)\"),\n        # This column has type Numeric\n        Column(16, \"amount\", \"Numeric\", isFormula=True, summarySourceCol=0,\n               formula=\"SUM($group.amount)\"),\n      ]),\n      Table(3, \"Address_summary_city\", primaryViewId=0, summarySourceTable=1, columns=[\n        Column(17, \"city\", \"Text\", isFormula=False, summarySourceCol=11,\n               formula=\"\"),\n        Column(18, \"group\", \"RefList:Address\", isFormula=True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(19, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n               formula=\"len($group)\"),\n        # This column has type Any\n        Column(20, \"amount\", \"Any\", isFormula=True, summarySourceCol=0,\n               formula=\"SUM($group.amount)\"),\n      ]),\n    ])\n\n    # Now let's add \"city\" to the summary table with no grouped by column\n    self.apply_user_action([\"UpdateSummaryViewSection\", 2, [11]])\n\n    # Check that summary table now has one column Amount of type Any.\n    self.assertTables([\n      self.starting_table,\n      Table(3, \"Address_summary_city\", primaryViewId=0, summarySourceTable=1, columns=[\n        Column(17, \"city\", \"Text\", isFormula=False, summarySourceCol=11,\n               formula=\"\"),\n        Column(18, \"group\", \"RefList:Address\", isFormula=True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(19, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n               formula=\"len($group)\"),\n        Column(20, \"amount\", \"Any\", isFormula=True, summarySourceCol=0,\n               formula=\"SUM($group.amount)\"),\n      ])\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_summary_choicelist.py",
    "content": "# pylint: disable=line-too-long\n\"\"\"\nTest of Summary tables grouped by ChoiceList columns.\n\"\"\"\nimport logging\nimport column\nimport lookup\nimport testutil\nfrom test_engine import EngineTestCase, Table, Column, test_undo\n\nlog = logging.getLogger(__name__)\n\n\nclass TestSummaryChoiceList(EngineTestCase):\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Source\", [\n        [10, \"other\", \"Text\", False, \"\", \"other\", \"\"],\n        [11, \"choices1\", \"ChoiceList\", False, \"\", \"choices1\", \"\"],\n        [12, \"choices2\", \"ChoiceList\", False, \"\", \"choices2\", \"\"],\n      ]]\n    ],\n    \"DATA\": {\n      \"Source\": [\n        [\"id\", \"choices1\", \"choices2\", \"other\"],\n        [21, [\"a\", \"b\"], [\"c\", \"d\"], \"foo\"],\n      ]\n    }\n  })\n\n  starting_table = Table(1, \"Source\", primaryViewId=0, summarySourceTable=0, columns=[\n    Column(10, \"other\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=0),\n    Column(11, \"choices1\", \"ChoiceList\", isFormula=False, formula=\"\", summarySourceCol=0),\n    Column(12, \"choices2\", \"ChoiceList\", isFormula=False, formula=\"\", summarySourceCol=0),\n  ])\n\n  # ----------------------------------------------------------------------\n\n  @test_undo\n  def test_summary_by_choice_list(self):\n    self.load_sample(self.sample)\n\n    # Verify the starting table; there should be no views yet.\n    self.assertTables([self.starting_table])\n    self.assertViews([])\n\n    # Create a summary section, grouped by the \"choices1\" column.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11], None])\n\n    summary_table1 = Table(\n      2, \"Source_summary_choices1\", primaryViewId=0, summarySourceTable=1,\n      columns=[\n        Column(13, \"choices1\", \"Choice\", isFormula=False, formula=\"\", summarySourceCol=11),\n        Column(14, \"group\", \"RefList:Source\", isFormula=True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(15, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n               formula=\"len($group)\"),\n      ],\n    )\n\n    # Create another summary section, grouped by both choicelist columns.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11, 12], None])\n\n    summary_table2 = Table(\n      3, \"Source_summary_choices1_choices2\", primaryViewId=0, summarySourceTable=1,\n      columns=[\n        Column(16, \"choices1\", \"Choice\", isFormula=False, formula=\"\", summarySourceCol=11),\n        Column(17, \"choices2\", \"Choice\", isFormula=False, formula=\"\", summarySourceCol=12),\n        Column(18, \"group\", \"RefList:Source\", isFormula=True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(19, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n               formula=\"len($group)\"),\n      ],\n    )\n\n    # Create another summary section, grouped by the non-choicelist column\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [10], None])\n\n    summary_table3 = Table(\n      4, \"Source_summary_other\", primaryViewId=0, summarySourceTable=1,\n      columns=[\n        Column(20, \"other\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=10),\n        Column(21, \"group\", \"RefList:Source\", isFormula=True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(22, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n               formula=\"len($group)\"),\n      ],\n    )\n\n    # Create another summary section, grouped by the non-choicelist column and choices1\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [10, 11], None])\n\n    summary_table4 = Table(\n      5, \"Source_summary_choices1_other\", primaryViewId=0, summarySourceTable=1,\n      columns=[\n        Column(23, \"other\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=10),\n        Column(24, \"choices1\", \"Choice\", isFormula=False, formula=\"\", summarySourceCol=11),\n        Column(25, \"group\", \"RefList:Source\", isFormula=True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(26, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n               formula=\"len($group)\"),\n      ],\n    )\n\n    self.assertTables(\n      [self.starting_table, summary_table1, summary_table2, summary_table3, summary_table4]\n    )\n\n    # Verify the summarized data.\n    self.assertTableData('Source_summary_choices1', data=[\n      [\"id\", \"choices1\", \"group\", \"count\"],\n      [1, \"a\", [21], 1],\n      [2, \"b\", [21], 1],\n    ])\n\n    self.assertTableData('Source_summary_choices1_choices2', data=[\n      [\"id\", \"choices1\", \"choices2\", \"group\", \"count\"],\n      [1, \"a\", \"c\", [21], 1],\n      [2, \"a\", \"d\", [21], 1],\n      [3, \"b\", \"c\", [21], 1],\n      [4, \"b\", \"d\", [21], 1],\n    ])\n\n    self.assertTableData('Source_summary_other', data=[\n      [\"id\", \"other\", \"group\", \"count\"],\n      [1, \"foo\", [21], 1],\n    ])\n\n    self.assertTableData('Source_summary_choices1_other', data=[\n      [\"id\", \"other\", \"choices1\", \"group\", \"count\"],\n      [1, \"foo\", \"a\", [21], 1],\n      [2, \"foo\", \"b\", [21], 1],\n    ])\n\n    # Verify the optimisation works for the table without choicelists\n    self.assertIs(self.engine.tables[\"Source\"]._summary_simple, None)\n    self.assertIs(self.engine.tables[\"Source_summary_choices1\"]._summary_simple, False)\n    self.assertIs(self.engine.tables[\"Source_summary_choices1_choices2\"]._summary_simple, False)\n    # simple summary and lookup\n    self.assertIs(self.engine.tables[\"Source_summary_other\"]._summary_simple, True)\n    self.assertIs(self.engine.tables[\"Source_summary_choices1_other\"]._summary_simple, False)\n\n    self.assertEqual(\n      {k: type(v) for k, v in self.engine.tables[\"Source\"]._special_cols.items()},\n      {\n        '#summary#Source_summary_choices1': column.ReferenceListColumn,\n        \"#lookup#_Contains(value='#summary#Source_summary_choices1', match_empty=no_match_empty)\":\n          lookup.LookupMapColumn,\n        '#summary#Source_summary_choices1_choices2': column.ReferenceListColumn,\n        \"#lookup#_Contains(value='#summary#Source_summary_choices1_choices2', \"\n        \"match_empty=no_match_empty)\":\n          lookup.LookupMapColumn,\n\n        # simple summary and lookup\n        '#summary#Source_summary_other': column.ReferenceColumn,\n        '#lookup##summary#Source_summary_other': lookup.LookupMapColumn,\n\n        '#summary#Source_summary_choices1_other': column.ReferenceListColumn,\n        \"#lookup#_Contains(value='#summary#Source_summary_choices1_other', \"\n        \"match_empty=no_match_empty)\":\n          lookup.LookupMapColumn,\n\n        \"#lookup#\": lookup.LookupMapColumn,\n      }\n    )\n\n    # Remove 'b' from choices1\n    self.update_record(\"Source\", 21, choices1=[\"L\", \"a\"])\n\n    self.assertTableData('Source', data=[\n      [\"id\", \"choices1\", \"choices2\", \"other\"],\n      [21, [\"a\"], [\"c\", \"d\"], \"foo\"],\n    ])\n\n    # Verify that the summary table rows containing 'b' are removed\n    self.assertTableData('Source_summary_choices1', data=[\n      [\"id\", \"choices1\", \"group\", \"count\"],\n      [1, \"a\", [21], 1],\n    ])\n\n    self.assertTableData('Source_summary_choices1_choices2', data=[\n      [\"id\", \"choices1\", \"choices2\", \"group\", \"count\"],\n      [1, \"a\", \"c\", [21], 1],\n      [2, \"a\", \"d\", [21], 1],\n    ])\n\n    # Add 'e' to choices2\n    self.update_record(\"Source\", 21, choices2=[\"L\", \"c\", \"d\", \"e\"])\n\n    # First summary table unaffected\n    self.assertTableData('Source_summary_choices1', data=[\n      [\"id\", \"choices1\", \"group\", \"count\"],\n      [1, \"a\", [21], 1],\n    ])\n\n    # New row added for 'e'\n    self.assertTableData('Source_summary_choices1_choices2', data=[\n      [\"id\", \"choices1\", \"choices2\", \"group\", \"count\"],\n      [1, \"a\", \"c\", [21], 1],\n      [2, \"a\", \"d\", [21], 1],\n      [3, \"a\", \"e\", [21], 1],\n    ])\n\n    # Empty choices1\n    self.update_record(\"Source\", 21, choices1=None)\n\n    self.assertTableData('Source', data=[\n      [\"id\", \"choices1\", \"choices2\", \"other\"],\n      [21, None, [\"c\", \"d\", \"e\"], \"foo\"],\n    ])\n\n    self.assertTableData('Source_summary_choices1', data=[\n      [\"id\", \"choices1\", \"group\", \"count\"],\n      [2, \"\", [21], 1],\n    ])\n\n    self.assertTableData('Source_summary_choices1_choices2', data=[\n      [\"id\", \"choices1\", \"choices2\", \"group\", \"count\"],\n      [4, \"\", \"c\", [21], 1],\n      [5, \"\", \"d\", [21], 1],\n      [6, \"\", \"e\", [21], 1],\n    ])\n\n    # Remove record from source\n    self.remove_record(\"Source\", 21)\n\n    # All summary rows are now empty and thus removed\n    self.assertTableData('Source_summary_choices1', data=[\n      [\"id\", \"choices1\", \"group\", \"count\"],\n    ])\n\n    self.assertTableData('Source_summary_choices1_choices2', data=[\n      [\"id\", \"choices1\", \"choices2\", \"group\", \"count\"],\n    ])\n\n    # Make rows with every combination of {a,b,ab} and {c,d,cd}\n    self.add_records(\n      'Source',\n      [\"id\", \"choices1\",       \"choices2\"],\n      [\n        [101, [\"L\", \"a\"],      [\"L\", \"c\"]],\n        [102, [\"L\", \"b\"],      [\"L\", \"c\"]],\n        [103, [\"L\", \"a\", \"b\"], [\"L\", \"c\"]],\n        [104, [\"L\", \"a\"],      [\"L\", \"d\"]],\n        [105, [\"L\", \"b\"],      [\"L\", \"d\"]],\n        [106, [\"L\", \"a\", \"b\"], [\"L\", \"d\"]],\n        [107, [\"L\", \"a\"],      [\"L\", \"c\", \"d\"]],\n        [108, [\"L\", \"b\"],      [\"L\", \"c\", \"d\"]],\n        [109, [\"L\", \"a\", \"b\"], [\"L\", \"c\", \"d\"]],\n        # and one row with empty lists\n        [110, [\"L\"],           [\"L\"]],\n      ]\n    )\n\n    self.assertTableData('Source', cols=\"subset\", data=[\n      [\"id\", \"choices1\", \"choices2\"],\n      [101, [\"a\"],      [\"c\"]],\n      [102, [\"b\"],      [\"c\"]],\n      [103, [\"a\", \"b\"], [\"c\"]],\n      [104, [\"a\"],      [\"d\"]],\n      [105, [\"b\"],      [\"d\"]],\n      [106, [\"a\", \"b\"], [\"d\"]],\n      [107, [\"a\"],      [\"c\", \"d\"]],\n      [108, [\"b\"],      [\"c\", \"d\"]],\n      [109, [\"a\", \"b\"], [\"c\", \"d\"]],\n      [110, None,       None],\n    ])\n\n    # Summary tables now have an even distribution of combinations\n    self.assertTableData('Source_summary_choices1', data=[\n      [\"id\", \"choices1\", \"group\", \"count\"],\n      [1, \"a\", [101, 103, 104, 106, 107, 109], 6],\n      [2, \"b\", [102, 103, 105, 106, 108, 109], 6],\n      [3, \"\",  [110], 1],\n    ])\n\n    summary_data = [\n      [\"id\", \"choices1\", \"choices2\", \"group\", \"count\"],\n      [1, \"a\", \"c\", [101, 103, 107, 109], 4],\n      [2, \"b\", \"c\", [102, 103, 108, 109], 4],\n      [3, \"a\", \"d\", [104, 106, 107, 109], 4],\n      [4, \"b\", \"d\", [105, 106, 108, 109], 4],\n      [5, \"\", \"\", [110], 1],\n    ]\n\n    self.assertTableData('Source_summary_choices1_choices2', data=summary_data)\n\n    # Verify that \"DetachSummaryViewSection\" useraction works correctly.\n    self.apply_user_action([\"DetachSummaryViewSection\", 4])\n\n    self.assertTables([\n      self.starting_table, summary_table1, summary_table3, summary_table4,\n      Table(\n        6, \"Table1\", primaryViewId=5, summarySourceTable=0,\n        columns=[\n          Column(27, \"manualSort\", \"ManualSortPos\", isFormula=False, formula=\"\", summarySourceCol=0),\n          Column(28, \"choices1\", \"Choice\", isFormula=False, formula=\"\", summarySourceCol=0),\n          Column(29, \"choices2\", \"Choice\", isFormula=False, formula=\"\", summarySourceCol=0),\n          Column(30, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n                 formula=\"len($group)\"),\n          Column(31, \"group\", \"RefList:Source\", isFormula=True, summarySourceCol=0,\n                 formula='Source.lookupRecords('\n                         'choices1=CONTAINS($choices1, match_empty=\"\"), '\n                         'choices2=CONTAINS($choices2, match_empty=\"\"))'),\n        ],\n      )\n    ])\n\n    self.assertTableData('Table1', data=summary_data, cols=\"subset\")\n\n  @test_undo\n  def test_change_choice_to_choicelist(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Source\", [\n          [10, \"other\", \"Text\", False, \"\", \"other\", \"\"],\n          [11, \"choices1\", \"Choice\", False, \"\", \"choice\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Source\": [\n          [\"id\", \"choices1\", \"other\"],\n          [21, \"a\", \"foo\"],\n          [22, \"b\", \"bar\"],\n        ]\n      }\n    })\n\n    starting_table = Table(1, \"Source\", primaryViewId=0, summarySourceTable=0, columns=[\n      Column(10, \"other\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=0),\n      Column(11, \"choices1\", \"Choice\", isFormula=False, formula=\"\", summarySourceCol=0),\n    ])\n\n    self.load_sample(sample)\n\n    # Verify the starting table; there should be no views yet.\n    self.assertTables([starting_table])\n    self.assertViews([])\n\n    # Create a summary section, grouped by the \"choices1\" column.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11], None])\n\n    summary_table = Table(\n      2, \"Source_summary_choices1\", primaryViewId=0, summarySourceTable=1,\n      columns=[\n        Column(12, \"choices1\", \"Choice\", isFormula=False, formula=\"\", summarySourceCol=11),\n        Column(13, \"group\", \"RefList:Source\", isFormula=True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(14, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n               formula=\"len($group)\"),\n      ],\n    )\n\n    data = [\n      [\"id\", \"choices1\", \"group\", \"count\"],\n      [1, \"a\", [21], 1],\n      [2, \"b\", [22], 1],\n    ]\n\n    self.assertTables([starting_table, summary_table])\n    self.assertTableData('Source_summary_choices1', data=data)\n\n    # Change the column from Choice to ChoiceList\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", 11, {\"type\": \"ChoiceList\"}])\n\n    # Changing type in reality is a bit more complex than these actions\n    # so we put the correct values in place directly\n    self.apply_user_action([\"BulkUpdateRecord\", \"Source\", [21, 22],\n                            {\"choices1\": [[\"L\", \"a\"], [\"L\", \"b\"]]}])\n\n    starting_table.columns[1] = starting_table.columns[1]._replace(type=\"ChoiceList\")\n    self.assertTables([starting_table, summary_table])\n    self.assertTableData('Source_summary_choices1', data=data)\n\n  @test_undo\n  def test_rename_choices(self):\n    self.load_sample(self.sample)\n\n    # Create a summary section, grouped by both choicelist columns.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [11, 12], None])\n\n    summary_table = Table(\n      2, \"Source_summary_choices1_choices2\", primaryViewId=0, summarySourceTable=1,\n      columns=[\n        Column(13, \"choices1\", \"Choice\", isFormula=False, formula=\"\", summarySourceCol=11),\n        Column(14, \"choices2\", \"Choice\", isFormula=False, formula=\"\", summarySourceCol=12),\n        Column(15, \"group\", \"RefList:Source\", isFormula=True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(16, \"count\", \"Int\", isFormula=True, summarySourceCol=0,\n               formula=\"len($group)\"),\n      ],\n    )\n\n    self.assertTables([self.starting_table, summary_table])\n\n    # Rename all the choices\n    out_actions = self.apply_user_action(\n      [\"RenameChoices\", \"Source\", \"choices1\", {\"a\": \"aa\", \"b\": \"bb\"}])\n    self.apply_user_action(\n      [\"RenameChoices\", \"Source\", \"choices2\", {\"c\": \"cc\", \"d\": \"dd\"}])\n\n    # Actions from renaming choices1 only\n    self.assertPartialOutActions(out_actions, {'stored': [\n      ['UpdateRecord', 'Source', 21, {'choices1': ['L', u'aa', u'bb']}],\n      ['BulkAddRecord',\n       'Source_summary_choices1_choices2',\n       [5, 6, 7, 8],\n       {'choices1': [u'aa', u'aa', u'bb', u'bb'],\n        'choices2': [u'c', u'd', u'c', u'd']}],\n      ['BulkRemoveRecord', 'Source_summary_choices1_choices2', [1, 2, 3, 4]],\n      ['BulkUpdateRecord',\n       'Source_summary_choices1_choices2',\n       [5, 6, 7, 8],\n       {'count': [1, 1, 1, 1]}],\n      ['BulkUpdateRecord',\n       'Source_summary_choices1_choices2',\n       [5, 6, 7, 8],\n       {'group': [['L', 21],\n                  ['L', 21],\n                  ['L', 21],\n                  ['L', 21]]}]\n    ]})\n\n    # Final Source table is essentially the same as before, just with each letter doubled\n    self.assertTableData('Source', data=[\n      [\"id\", \"choices1\", \"choices2\", \"other\"],\n      [21, [\"aa\", \"bb\"], [\"cc\", \"dd\"], \"foo\"],\n    ])\n\n    # Final summary table is very similar to before, but with two empty chunks of 4 rows\n    # left over from each rename\n    self.assertTableData('Source_summary_choices1_choices2', data=[\n      [\"id\", \"choices1\", \"choices2\", \"group\", \"count\"],\n      [9, \"aa\", \"cc\", [21], 1],\n      [10, \"aa\", \"dd\", [21], 1],\n      [11, \"bb\", \"cc\", [21], 1],\n      [12, \"bb\", \"dd\", [21], 1],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_summary_undo.py",
    "content": "\"\"\"\nSome more test cases for summary tables, involving UNDO.\n\"\"\"\nimport logging\nimport testutil\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\nclass TestSummaryUndo(test_engine.EngineTestCase):\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Person\", [\n        [1, \"state\",        \"Text\",       False],\n      ]]\n    ],\n    \"DATA\": {\n      \"Person\": [\n        [\"id\",  \"state\", ],\n        [   1,     \"NY\", ],\n        [   2,     \"IL\", ],\n        [   3,     \"ME\", ],\n        [   4,     \"NY\", ],\n        [   5,     \"IL\", ],\n      ]\n    }\n  })\n\n  def test_summary_undo1(self):\n    # This tests a particular case of a bug when a summary table wasn't fully updated after UNDO.\n    self.load_sample(self.sample)\n    # Create a summary section, grouped by the \"State\" column.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", [1], None])\n    self.assertTableData('Person_summary_state', cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\"],\n      [ 1,    \"NY\",    2],\n      [ 2,    \"IL\",    2],\n      [ 3,    \"ME\",    1],\n    ])\n\n    out_actions = self.update_record('Person', 4, state='ME')\n    self.assertTableData('Person_summary_state', cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\"],\n      [ 1,    \"NY\",    1],\n      [ 2,    \"IL\",    2],\n      [ 3,    \"ME\",    2],\n    ])\n\n    self.apply_undo_actions(out_actions.undo[0:1])\n    self.assertTableData('Person_summary_state', cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\"],\n      [ 1,    \"NY\",    2],\n      [ 2,    \"IL\",    2],\n      [ 3,    \"ME\",    1],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_table_actions.py",
    "content": "import logging\n\nimport testutil\nimport test_engine\nfrom test_engine import Table, Column, View, Section, Field\n\nlog = logging.getLogger(__name__)\n\nclass TestTableActions(test_engine.EngineTestCase):\n\n  address_table_data = [\n    [\"id\",  \"city\",     \"state\", \"amount\" ],\n    [ 21,   \"New York\", \"NY\"   , 1.       ],\n    [ 22,   \"Albany\",   \"NY\"   , 2.       ],\n    [ 23,   \"Seattle\",  \"WA\"   , 3.       ],\n    [ 24,   \"Chicago\",  \"IL\"   , 4.       ],\n    [ 25,   \"Bedford\",  \"MA\"   , 5.       ],\n    [ 26,   \"New York\", \"NY\"   , 6.       ],\n    [ 27,   \"Buffalo\",  \"NY\"   , 7.       ],\n    [ 28,   \"Bedford\",  \"NY\"   , 8.       ],\n    [ 29,   \"Boston\",   \"MA\"   , 9.       ],\n    [ 30,   \"Yonkers\",  \"NY\"   , 10.      ],\n    [ 31,   \"New York\", \"NY\"   , 11.      ],\n  ]\n\n  people_table_data = [\n    [\"id\",  \"name\",   \"address\" ],\n    [ 1,    \"Alice\",  22        ],\n    [ 2,    \"Bob\",    25        ],\n    [ 3,    \"Carol\",  27        ],\n  ]\n\n  def init_sample_data(self):\n    # Add a couple of tables, including references.\n    self.apply_user_action([\"AddTable\", \"Address\", [\n      {\"id\": \"city\",    \"type\": \"Text\"},\n      {\"id\": \"state\",   \"type\": \"Text\"},\n      {\"id\": \"amount\",  \"type\": \"Numeric\"},\n    ]])\n    self.apply_user_action([\"AddTable\", \"People\", [\n      {\"id\": \"name\",    \"type\": \"Text\"},\n      {\"id\": \"address\", \"type\": \"Ref:Address\"},\n      {\"id\": \"city\",    \"type\": \"Any\", \"formula\": \"$address.city\" }\n    ]])\n\n    # Populate some data.\n    d = testutil.table_data_from_rows(\"Address\", self.address_table_data[0],\n                                      self.address_table_data[1:])\n    self.apply_user_action([\"BulkAddRecord\", \"Address\", d.row_ids, d.columns])\n\n    d = testutil.table_data_from_rows(\"People\", self.people_table_data[0],\n                                      self.people_table_data[1:])\n    self.apply_user_action([\"BulkAddRecord\", \"People\", d.row_ids, d.columns])\n\n    # Add a view with several sections, including a summary table.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, 'record', None, None])\n    self.apply_user_action([\"CreateViewSection\", 1, 3, 'record', [3], None])\n    self.apply_user_action([\"CreateViewSection\", 2, 3, 'record', None, None])\n\n    # Verify the new structure of tables and views.\n    self.assertTables([\n      Table(1, \"Address\", primaryViewId=1, summarySourceTable=0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(2, \"city\",       \"Text\", False, \"\", 0),\n        Column(3, \"state\",      \"Text\", False, \"\", 0),\n        Column(4, \"amount\",     \"Numeric\", False, \"\", 0),\n      ]),\n      Table(2, \"People\", primaryViewId=2, summarySourceTable=0, columns=[\n        Column(5, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(6, \"name\",       \"Text\",         False, \"\", 0),\n        Column(7, \"address\",    \"Ref:Address\",  False, \"\", 0),\n        Column(8, \"city\",       \"Any\", True, \"$address.city\", 0),\n      ]),\n      Table(3, \"Address_summary_state\", 0, 1, columns=[\n        Column(9, \"state\", \"Text\", False, \"\", summarySourceCol=3),\n        Column(10, \"group\", \"RefList:Address\", True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(11, \"count\", \"Int\", True, summarySourceCol=0, formula=\"len($group)\"),\n        Column(12, \"amount\", \"Numeric\", True, summarySourceCol=0, formula=\"SUM($group.amount)\"),\n      ]),\n    ])\n    self.assertViews([\n      View(1, sections=[\n        Section(1, parentKey=\"record\", tableRef=1, fields=[\n          Field(1, colRef=2),\n          Field(2, colRef=3),\n          Field(3, colRef=4),\n        ]),\n      ]),\n      View(2, sections=[\n        Section(4, parentKey=\"record\", tableRef=2, fields=[\n          Field(10, colRef=6),\n          Field(11, colRef=7),\n          Field(12, colRef=8),\n        ]),\n      ]),\n      View(3, sections=[\n        Section(7, parentKey=\"record\", tableRef=1, fields=[\n          Field(19, colRef=2),\n          Field(20, colRef=3),\n          Field(21, colRef=4),\n        ]),\n        Section(9, parentKey=\"record\", tableRef=3, fields=[\n          Field(25, colRef=9),\n          Field(26, colRef=11),\n          Field(27, colRef=12),\n        ]),\n        Section(10, parentKey=\"record\", tableRef=2, fields=[\n          Field(28, colRef=6),\n          Field(29, colRef=7),\n          Field(30, colRef=8),\n        ]),\n      ]),\n    ])\n\n    # Verify the data we've loaded.\n    self.assertTableData('Address', cols=\"subset\", data=self.address_table_data)\n    self.assertTableData('People', cols=\"subset\", data=self.people_table_data)\n    self.assertTableData(\"Address_summary_state\", cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\", \"amount\"          ],\n      [ 1,    \"NY\",     7,      1.+2+6+7+8+10+11  ],\n      [ 2,    \"WA\",     1,      3.                ],\n      [ 3,    \"IL\",     1,      4.                ],\n      [ 4,    \"MA\",     2,      5.+9              ],\n    ])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_table_updates(self):\n    # Verify table renames triggered by UpdateRecord actions, and related behavior.\n\n    # Load a sample with a few table and views.\n    self.init_sample_data()\n\n    # Verify that we can rename tables via UpdatRecord actions, including multiple tables.\n    self.apply_user_action([\"BulkUpdateRecord\", \"_grist_Tables\", [1,2],\n                            {\"tableId\": [\"Location\", \"Persons\"]}])\n\n    # Check that requested tables and summary tables got renamed correctly.\n    self.assertTableData('_grist_Tables', cols=\"subset\", data=[\n      [\"id\",  \"tableId\"],\n      [1,     \"Location\"],\n      [2,     \"Persons\"],\n      [3,     \"Location_summary_state\"],\n    ])\n\n    # Check that reference columns to renamed tables get their type modified.\n    self.assertTableData('_grist_Tables_column', rows=\"subset\", cols=\"subset\", data=[\n      [\"id\",  \"colId\",    \"type\"],\n      [7,     \"address\",  \"Ref:Location\"],\n      [10,    \"group\",    \"RefList:Location\"],\n    ])\n\n    # Do a bulk update to rename A and B to conflicting names.\n    self.apply_user_action([\"AddTable\", \"A\", [{\"id\": \"a\", \"type\": \"Text\"}]])\n    out_actions = self.apply_user_action([\"BulkUpdateRecord\", \"_grist_Tables\", [1,2],\n                            {\"tableId\": [\"A\", \"A\"]}])\n\n    # See what doc-actions get generated.\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Persons\", \"address\", {\"type\": \"Int\"}],\n        [\"ModifyColumn\", \"Location_summary_state\", \"group\", {\"type\": \"Int\"}],\n        [\"RenameTable\", \"Location\", \"A2\"],\n        [\"RenameTable\", \"Location_summary_state\", \"A2_summary_state\"],\n        [\"RenameTable\", \"Persons\", \"A3\"],\n        [\"BulkUpdateRecord\", \"_grist_Tables\", [1, 3, 2],\n         {\"tableId\": [\"A2\", \"A2_summary_state\", \"A3\"]}],\n        [\"ModifyColumn\", \"A3\", \"address\", {\"type\": \"Ref:A2\"}],\n        [\"ModifyColumn\", \"A2_summary_state\", \"group\", {\"type\": \"RefList:A2\"}],\n        [\"BulkUpdateRecord\", \"_grist_Tables_column\", [7, 10], {\"type\": [\"Ref:A2\", \"RefList:A2\"]}],\n      ]\n    })\n\n    # Check that requested tables and summary tables got renamed correctly.\n    self.assertTableData('_grist_Tables', cols=\"subset\", data=[\n      [\"id\",  \"tableId\"],\n      [1,     \"A2\"],\n      [2,     \"A3\"],\n      [3,     \"A2_summary_state\"],\n      [4,     \"A\"],\n    ])\n\n    # Check that reference columns to renamed tables get their type modified.\n    self.assertTableData('_grist_Tables_column', rows=\"subset\", cols=\"subset\", data=[\n      [\"id\",  \"colId\",    \"type\"],\n      [7,     \"address\",  \"Ref:A2\"],\n      [10,    \"group\",    \"RefList:A2\"],\n    ])\n\n    # Verify the data we've loaded.\n    self.assertTableData('A2', cols=\"subset\", data=self.address_table_data)\n    self.assertTableData('A3', cols=\"subset\", data=self.people_table_data)\n    self.assertTableData(\"A2_summary_state\", cols=\"subset\", data=[\n      [ \"id\", \"state\", \"count\", \"amount\"          ],\n      [ 1,    \"NY\",     7,      1.+2+6+7+8+10+11  ],\n      [ 2,    \"WA\",     1,      3.                ],\n      [ 3,    \"IL\",     1,      4.                ],\n      [ 4,    \"MA\",     2,      5.+9              ],\n    ])\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_table_renames_summary_by_ref(self):\n    # Verify table renames when there is a group-by column that's a Reference.\n\n    # This tests a potential bug since a table rename needs to modify Reference types, but\n    # group-by columns aren't supposed to be modifiable.\n    self.init_sample_data()\n\n    # Add a table grouped by a reference column (the 'Ref:Address' column named 'address').\n    self.apply_user_action([\"CreateViewSection\", 2, 0, 'record', [7], None])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",    \"type\",           \"isFormula\",    \"formula\" ],\n      [ 13,   \"address\",  \"Ref:Address\",    False,          \"\"        ],\n      [ 14,   \"group\",    \"RefList:People\", True, \"table.getSummarySourceGroup(rec)\" ],\n      [ 15,   \"count\",    \"Int\",            True,           \"len($group)\" ],\n    ], rows=lambda r: (r.parentId.id == 4))\n\n    # Now rename the table Address -> Location.\n    out_actions = self.apply_user_action([\"RenameTable\", \"Address\", \"Location\"])\n\n    # See what doc-actions get generated.\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"People\", \"address\", {\"type\": \"Int\"}],\n        [\"ModifyColumn\", \"Address_summary_state\", \"group\", {\"type\": \"Int\"}],\n        [\"ModifyColumn\", \"People_summary_address\", \"address\", {\"type\": \"Int\"}],\n        [\"RenameTable\", \"Address\", \"Location\"],\n        [\"RenameTable\", \"Address_summary_state\", \"Location_summary_state\"],\n        [\"BulkUpdateRecord\", \"_grist_Tables\", [1, 3],\n         {\"tableId\": [\"Location\", \"Location_summary_state\"]}],\n        [\"ModifyColumn\", \"People\", \"address\", {\"type\": \"Ref:Location\"}],\n        [\"ModifyColumn\", \"Location_summary_state\", \"group\", {\"type\": \"RefList:Location\"}],\n        [\"ModifyColumn\", \"People_summary_address\", \"address\", {\"type\": \"Ref:Location\"}],\n        [\"BulkUpdateRecord\", \"_grist_Tables_column\", [7, 10, 13],\n         {\"type\": [\"Ref:Location\", \"RefList:Location\", \"Ref:Location\"]}],\n      ]\n    })\n\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",    \"type\",           \"isFormula\",    \"formula\" ],\n      [ 13,   \"address\",  \"Ref:Location\",    False,          \"\"        ],\n      [ 14,   \"group\",    \"RefList:People\", True, \"table.getSummarySourceGroup(rec)\" ],\n      [ 15,   \"count\",    \"Int\",            True,           \"len($group)\" ],\n    ], rows=lambda r: (r.parentId.id == 4))\n\n\n  #----------------------------------------------------------------------\n\n  @test_engine.test_undo\n  def test_table_removes(self):\n    # Verify table removals triggered by UpdateRecord actions, and related behavior.\n\n    # Same setup as previous test.\n    self.init_sample_data()\n\n    # Add one more table, and one more view for tables #1 and #4 (those we are about to delete).\n    self.apply_user_action([\"AddEmptyTable\", None])\n    out_actions = self.apply_user_action([\"CreateViewSection\", 1, 0, 'detail', None, None])\n    self.assertEqual(out_actions.retValues[0][\"viewRef\"], 5)\n    self.apply_user_action([\"CreateViewSection\", 4, 5, 'detail', None, None])\n\n    # See what's in TabBar table, to verify after we remove a table.\n    self.assertTableData('_grist_TabBar', cols=\"subset\", data=[\n      [\"id\",  \"viewRef\"],\n      [1,     1],\n      [2,     2],\n      [3,     3],\n      [4,     4],\n      [5,     5],\n    ])\n\n    # Remove two tables, ensure certain views get removed.\n    self.apply_user_action([\"BulkRemoveRecord\", \"_grist_Tables\", [1, 4]])\n\n    # See that some TabBar entries disappear, or tableRef gets unset.\n    self.assertTableData('_grist_TabBar', cols=\"subset\", data=[\n      [\"id\",  \"viewRef\"],\n      [2,     2],\n      [3,     3],\n    ])\n\n    # Check that reference columns to this table get converted\n    self.assertTables([\n      Table(2, \"People\", primaryViewId=2, summarySourceTable=0, columns=[\n        Column(5, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(6, \"name\",       \"Text\",         False, \"\", 0),\n        Column(7, \"address\",    \"Int\", False, \"\", 0),\n        Column(8, \"city\",       \"Any\", True, \"$address.city\", 0),\n      ]),\n      # Note that the summary table is also gone.\n    ])\n    self.assertViews([\n      View(2, sections=[\n        Section(4, parentKey=\"record\", tableRef=2, fields=[\n          Field(10, colRef=6),\n          Field(11, colRef=7),\n          Field(12, colRef=8),\n        ]),\n      ]),\n      View(3, sections=[\n        Section(10, parentKey=\"record\", tableRef=2, fields=[\n          Field(28, colRef=6),\n          Field(29, colRef=7),\n          Field(30, colRef=8),\n        ]),\n      ]),\n    ])\n\n  @test_engine.test_undo\n  def test_remove_table_referenced_by_formula(self):\n    self.init_sample_data()\n\n    # Add a simple formula column of reference type.\n    # Its type will be changed to Any.\n    self.add_column(\n      \"People\", \"address2\", type=\"Ref:Address\", isFormula=True, formula=\"$address\"\n    )\n    # Add a similar reflist column, but change it to a data column.\n    # The formula is just an easy way to populate values for the test.\n    # A data column of type reflist should be changed to text.\n    self.add_column(\n      \"People\", \"addresses\", type=\"RefList:Address\", isFormula=True, formula=\"[$address, $address]\"\n    )\n    self.modify_column(\"People\", \"addresses\", isFormula=False)\n\n    # Now remove the referenced table and see what happens to the ref columns.\n    self.apply_user_action([\"RemoveTable\", \"Address\"])\n    self.assertTables([\n      Table(2, \"People\", primaryViewId=2, summarySourceTable=0, columns=[\n        Column(5, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(6, \"name\",       \"Text\",         False, \"\", 0),\n        # Original data column of type Ref:Address is changed to Int.\n        Column(7, \"address\",    \"Int\", False, \"\", 0),\n        Column(8, \"city\",       \"Any\", True, \"$address.city\", 0),\n        # Formula column of type Ref:Address is changed to Any.\n        Column(13, \"address2\",  \"Any\", True, \"$address\", 0),\n        # Data column of type RefList:Address is changed to Text.\n        Column(14, \"addresses\", \"Text\", False, \"[$address, $address]\", 0),\n      ]),\n    ])\n    self.assertTableData('People', cols=\"subset\", data=[\n    [\"id\",  \"name\",   \"address\", \"address2\", \"addresses\"],\n    [ 1,    \"Alice\",  22,        22,         \"22,22\"],\n    [ 2,    \"Bob\",    25,        25,         \"25,25\"],\n    [ 3,    \"Carol\",  27,        27,         \"27,27\"],\n  ])\n\n  @test_engine.test_undo\n  def test_remove_table_referenced_by_summary_groupby_col_without_visible_col(self):\n    self.init_sample_data()\n    # Create a summary table of People grouped by address (a reference column).\n    self.apply_user_action([\"CreateViewSection\", 2, 0, 'record', [7], None])\n\n    self.assertTables([\n      Table(1, \"Address\", primaryViewId=1, summarySourceTable=0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(2, \"city\",       \"Text\", False, \"\", 0),\n        Column(3, \"state\",      \"Text\", False, \"\", 0),\n        Column(4, \"amount\",     \"Numeric\", False, \"\", 0),\n      ]),\n      Table(2, \"People\", primaryViewId=2, summarySourceTable=0, columns=[\n        Column(5, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(6, \"name\",       \"Text\",         False, \"\", 0),\n        Column(7, \"address\",    \"Ref:Address\",  False, \"\", 0),\n        Column(8, \"city\",       \"Any\", True, \"$address.city\", 0),\n      ]),\n      Table(3, \"Address_summary_state\", 0, 1, columns=[\n        Column(9, \"state\", \"Text\", False, \"\", summarySourceCol=3),\n        Column(10, \"group\", \"RefList:Address\", True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(11, \"count\", \"Int\", True, summarySourceCol=0, formula=\"len($group)\"),\n        Column(12, \"amount\", \"Numeric\", True, summarySourceCol=0, formula=\"SUM($group.amount)\"),\n      ]),\n      Table(4, \"People_summary_address\", 0, 2, columns=[\n        Column(13, \"address\", \"Ref:Address\", False, \"\", summarySourceCol=7),\n        Column(14, \"group\", \"RefList:People\", True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(15, \"count\", \"Int\", True, summarySourceCol=0, formula=\"len($group)\"),\n      ]),\n    ])\n    self.assertTableData('People_summary_address', data=[\n      [\"id\", \"address\", \"count\", \"group\"],\n      [1, 22, 1, [1]],\n      [2, 25, 1, [2]],\n      [3, 27, 1, [3]],\n    ])\n\n    # Now remove the referenced table.\n    self.apply_user_action([\"RemoveTable\", \"Address\"])\n    # In both the People and summary tables, the 'address' reference column\n    # is converted to Int, because it didn't have a visible/display column.\n    self.assertTables([\n      Table(2, \"People\", primaryViewId=2, summarySourceTable=0, columns=[\n        Column(5, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(6, \"name\",       \"Text\",         False, \"\", 0),\n        Column(7, \"address\",    \"Int\",  False, \"\", 0),\n        Column(8, \"city\",       \"Any\", True, \"$address.city\", 0),\n      ]),\n      Table(4, \"People_summary_address\", 0, 2, columns=[\n        Column(13, \"address\", \"Int\", False, \"\", summarySourceCol=7),\n        Column(14, \"group\", \"RefList:People\", True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(15, \"count\", \"Int\", True, summarySourceCol=0, formula=\"len($group)\"),\n      ]),\n    ])\n    self.assertTableData('People', cols=\"subset\", data=self.people_table_data)\n    self.assertTableData('People_summary_address', data=[\n      [\"id\", \"address\", \"count\", \"group\"],\n      [1, 22, 1, [1]],\n      [2, 25, 1, [2]],\n      [3, 27, 1, [3]],\n    ])\n\n  @test_engine.test_undo\n  def test_remove_table_referenced_by_summary_groupby_col_with_visible_col(self):\n    # Similar to the test above, but now the reference column has a visible column.\n    self.init_sample_data()\n    self.modify_column(\"People\", \"address\", visibleCol=2)\n    self.apply_user_action([\"SetDisplayFormula\", \"People\", 0, 7, \"$address.city\"])\n    self.apply_user_action([\"CreateViewSection\", 2, 0, 'record', [7], None])\n\n    self.assertTables([\n      Table(1, \"Address\", primaryViewId=1, summarySourceTable=0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(2, \"city\",       \"Text\", False, \"\", 0),\n        Column(3, \"state\",      \"Text\", False, \"\", 0),\n        Column(4, \"amount\",     \"Numeric\", False, \"\", 0),\n      ]),\n      Table(2, \"People\", primaryViewId=2, summarySourceTable=0, columns=[\n        Column(5, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(6, \"name\",       \"Text\",         False, \"\", 0),\n        Column(7, \"address\",    \"Ref:Address\",  False, \"\", 0),\n        Column(8, \"city\",       \"Any\", True, \"$address.city\", 0),\n        Column(13, \"gristHelper_Display\", \"Any\", True, \"$address.city\", 0),\n      ]),\n      Table(3, \"Address_summary_state\", 0, 1, columns=[\n        Column(9, \"state\", \"Text\", False, \"\", summarySourceCol=3),\n        Column(10, \"group\", \"RefList:Address\", True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(11, \"count\", \"Int\", True, summarySourceCol=0, formula=\"len($group)\"),\n        Column(12, \"amount\", \"Numeric\", True, summarySourceCol=0, formula=\"SUM($group.amount)\"),\n      ]),\n      Table(4, \"People_summary_address\", 0, 2, columns=[\n        Column(14, \"address\", \"Ref:Address\", False, \"\", summarySourceCol=7),\n        Column(15, \"group\", \"RefList:People\", True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(16, \"count\", \"Int\", True, summarySourceCol=0, formula=\"len($group)\"),\n        Column(17, \"gristHelper_Display\", \"Any\", True, \"$address.city\", 0),\n      ]),\n    ])\n    self.assertTableData('People_summary_address', data=[\n      [\"id\", \"address\", \"count\", \"group\", \"gristHelper_Display\"],\n      [1, 22, 1, [1], \"Albany\"],\n      [2, 25, 1, [2], \"Bedford\"],\n      [3, 27, 1, [3], \"Buffalo\"],\n    ])\n\n    self.apply_user_action([\"RemoveTable\", \"Address\"])\n    self.assertTables([\n      Table(2, \"People\", primaryViewId=2, summarySourceTable=0, columns=[\n        Column(5, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(6, \"name\",       \"Text\",         False, \"\", 0),\n        # Reference column is converted to the visible column type, i.e. Text.\n        Column(7, \"address\",    \"Text\",  False, \"\", 0),\n        Column(8, \"city\",       \"Any\", True, \"$address.city\", 0),\n      ]),\n      Table(4, \"People_summary_address\", 0, 2, columns=[\n        # Reference column is converted to the visible column type, i.e. Text.\n        Column(14, \"address\", \"Text\", False, \"\", summarySourceCol=7),\n        Column(15, \"group\", \"RefList:People\", True, summarySourceCol=0,\n               formula=\"table.getSummarySourceGroup(rec)\"),\n        Column(16, \"count\", \"Int\", True, summarySourceCol=0, formula=\"len($group)\"),\n      ]),\n    ])\n    self.assertTableData('People', cols=\"subset\", data=[\n      [\"id\",  \"name\",   \"address\" ],\n      [ 1,    \"Alice\",  \"Albany\"],\n      [ 2,    \"Bob\",    \"Bedford\"],\n      [ 3,    \"Carol\",  \"Buffalo\"],\n    ])\n    self.assertTableData('People_summary_address', data=[\n      [\"id\", \"address\", \"count\", \"group\"],\n      [ 4,   \"Albany\",  1,       [1]],\n      [ 5,   \"Bedford\", 1,       [2]],\n      [ 6,   \"Buffalo\", 1,       [3]],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_table_data_set.py",
    "content": "import actions\nimport schema\nimport table_data_set\nimport testutil\n\nimport difflib\nimport json\nimport unittest\n\nclass TestTableDataSet(unittest.TestCase):\n  \"\"\"\n  Tests functionality of TableDataSet by running through all the test cases in testscript.json.\n  \"\"\"\n  @classmethod\n  def init_test_cases(cls):\n    # Create a test_* method for each case in testscript, which runs `self._run_test_body()`.\n    cls.samples, test_cases = testutil.parse_testscript()\n    for case in test_cases:\n      cls._create_test_case(case[\"TEST_CASE\"], case[\"BODY\"])\n\n  @classmethod\n  def _create_test_case(cls, name, body):\n    setattr(cls, \"test_\" + name, lambda self: self._run_test_body(body))\n\n\n  def setUp(self):\n    self._table_data_set = None\n\n  def load_sample(self, sample):\n    \"\"\"\n    Load _table_data_set with given sample data. The sample is a dict with keys \"SCHEMA\" and\n    \"DATA\", each a dictionary mapping table names to actions.TableData objects. \"SCHEMA\" contains\n    \"_grist_Tables\" and \"_grist_Tables_column\" tables.\n    \"\"\"\n    self._table_data_set = table_data_set.TableDataSet()\n    for a in schema.schema_create_actions():\n      if a.table_id not in self._table_data_set.all_tables:\n        self._table_data_set.apply_doc_action(a)\n\n    for a in sample[\"SCHEMA\"].values():\n      self._table_data_set.BulkAddRecord(*a)\n\n    # Create AddTable actions for each table described in the metadata.\n    meta_tables = self._table_data_set.all_tables['_grist_Tables']\n    meta_columns = self._table_data_set.all_tables['_grist_Tables_column']\n\n    add_tables = {}   # maps the row_id of the table to the schema object for the table.\n    for rec in actions.transpose_bulk_action(meta_tables):\n      add_tables[rec.id] = actions.AddTable(rec.tableId, [])\n\n    # Go through all columns, adding them to the appropriate tables.\n    for rec in actions.transpose_bulk_action(meta_columns):\n      add_tables[rec.parentId].columns.append({\n        \"id\": rec.colId,\n        \"type\": rec.type,\n        \"widgetOptions\": rec.widgetOptions,\n        \"isFormula\": rec.isFormula,\n        \"formula\": rec.formula,\n        \"label\"  : rec.label,\n        \"parentPos\": rec.parentPos,\n      })\n\n    # Sort the columns in the schema according to the parentPos field from the column records.\n    for action in add_tables.values():\n      action.columns.sort(key=lambda r: r[\"parentPos\"])\n      self._table_data_set.AddTable(*action)\n\n    for a in sample[\"DATA\"].values():\n      self._table_data_set.ReplaceTableData(*a)\n\n\n  def _run_test_body(self, body):\n    \"\"\"Runs the actual script defined in the JSON test-script file.\"\"\"\n    undo_actions = []\n    loaded_sample = None\n    for line, step, data in body:\n      try:\n        if step == \"LOAD_SAMPLE\":\n          if loaded_sample:\n            # Pylint's type checking gives a false positive for loaded_sample.\n            # pylint: disable=unsubscriptable-object\n            self._verify_undo_all(undo_actions, loaded_sample[\"DATA\"])\n          loaded_sample = self.samples[data]\n          self.load_sample(loaded_sample)\n        elif step == \"APPLY\":\n          self._apply_stored_actions(data['ACTIONS']['stored'])\n          if 'calc' in data['ACTIONS']:\n            self._apply_stored_actions(data['ACTIONS']['calc'])\n          undo_actions.extend(data['ACTIONS']['undo'])\n        elif step == \"CHECK_OUTPUT\":\n          expected_data = {}\n          if \"USE_SAMPLE\" in data:\n            expected_data = self.samples[data.pop(\"USE_SAMPLE\")][\"DATA\"].copy()\n          expected_data.update({t: testutil.table_data_from_rows(t, tdata[0], tdata[1:])\n                                for (t, tdata) in data.items()})\n          self._verify_data(expected_data)\n        else:\n          raise ValueError(\"Unrecognized step %s in test script\" % step)\n      except Exception as e:\n        new_args0 = \"LINE %s: %s\" % (line, e.args[0])\n        e.args = (new_args0,) + e.args[1:]\n        raise\n\n    self._verify_undo_all(undo_actions, loaded_sample[\"DATA\"])\n\n  def _apply_stored_actions(self, stored_actions):\n    for action in stored_actions:\n      self._table_data_set.apply_doc_action(actions.action_from_repr(action))\n\n  def _verify_undo_all(self, undo_actions, expected_data):\n    \"\"\"\n    At the end of each test, undo all and verify we get back to the originally loaded sample.\n    \"\"\"\n    self._apply_stored_actions(reversed(undo_actions))\n    del undo_actions[:]\n    self._verify_data(expected_data, ignore_formulas=True)\n\n  def _verify_data(self, expected_data, ignore_formulas=False):\n    observed_data = {t: self._prep_data(*data)\n                     for t, data in self._table_data_set.all_tables.items()\n                     if not t.startswith(\"_grist_\")}\n    if ignore_formulas:\n      observed_data = self._strip_formulas(observed_data)\n      expected_data = self._strip_formulas(expected_data)\n\n    if observed_data != expected_data:\n      lines = []\n      for table in sorted(observed_data.keys() | expected_data.keys()):\n        if table not in expected_data:\n          lines.append(\"*** Table %s observed but not expected\\n\" % table)\n        elif table not in observed_data:\n          lines.append(\"*** Table %s not observed but was expected\\n\" % table)\n        else:\n          obs, exp = observed_data[table], expected_data[table]\n          if obs != exp:\n            o_lines = self._get_text_lines(obs)\n            e_lines = self._get_text_lines(exp)\n            lines.append(\"*** Table %s differs\\n\" % table)\n            lines.extend(difflib.unified_diff(e_lines, o_lines,\n                                              fromfile=\"expected\", tofile=\"observed\"))\n      self.fail(\"\\n\" + \"\".join(lines))\n\n  def _strip_formulas(self, all_data):\n    return {t: self._strip_formulas_table(*data) for t, data in all_data.items()}\n\n  def _strip_formulas_table(self, table_id, row_ids, columns):\n    return actions.TableData(table_id, row_ids, {\n      col_id: col for col_id, col in columns.items()\n      if not self._table_data_set.get_col_info(table_id, col_id)[\"isFormula\"]\n    })\n\n  @classmethod\n  def _prep_data(cls, table_id, row_ids, columns):\n    def sort(col):\n      return [v for r, v in sorted(zip(row_ids, col))]\n\n    sorted_data = actions.TableData(table_id, sorted(row_ids),\n                                    {c: sort(col) for c, col in columns.items()})\n    return actions.encode_objects(testutil.replace_nans(sorted_data))\n\n  @classmethod\n  def _get_text_lines(cls, table_data):\n    col_items = sorted(table_data.columns.items())\n    col_items.insert(0, ('id', table_data.row_ids))\n    table_rows = zip(*[[col_id] + values for (col_id, values) in col_items])\n    return [json.dumps(row) + \"\\n\" for row in table_rows]\n\n\n# Parse and create test cases on module load. This way the python unittest feature to run only\n# particular test cases can apply to these cases too.\nTestTableDataSet.init_test_cases()\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_temp_rowids.py",
    "content": "import test_engine\nimport testsamples\nimport useractions\n\nclass TestTempRowIds(test_engine.EngineTestCase):\n\n  def test_temp_row_ids(self):\n    self.load_sample(testsamples.sample_students)\n\n    out_actions = self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (\n      # Add a mix of records with or without temp rowIds.\n      ['AddRecord', 'Address', None, {'city': 'A'}],\n      ['AddRecord', 'Address', -1, {'city': 'B'}],\n      ['BulkAddRecord', 'Address', [-3, None, -7, -10], {'city': ['C', 'D', 'E', 'F']}],\n\n      # -3 translates to C; the new record of -1 applies to a different table, so doesn't affect\n      # its translation to city A.\n      ['AddRecord', 'Schools', -1, {'address': -3, 'name': 'SUNY C'}],\n\n      # Add a mix of records referring to new, existing, or null rows.\n      ['BulkAddRecord', 'Schools', [None, None, None, None, None], {\n        'address': [-1, 11, 0, -3, -7],\n        'name': ['SUNY A', 'NYU', 'Xavier', 'Suny C2', 'Suny E'],\n        }\n      ],\n\n      # Try a few updates too.\n      ['UpdateRecord', 'Schools', 1, {'address': -7}],\n      ['BulkUpdateRecord', 'Schools', [2, 3, 4], {'address': [-3, -1, 11]}],\n\n      # Later temp rowIds override previous one. Here, -3 was already used.\n      ['AddRecord', 'Address', -3, {'city': 'G'}],\n      ['AddRecord', 'Schools', None, {'address': -3, 'name': 'SUNY G'}],\n    )])\n\n    # Test that we get the correct resulting data.\n    self.assertTableData('Address', cols=\"subset\", data=[\n      [\"id\",  \"city\"       ],\n      [11,    \"New York\"   ],\n      [12,    \"Colombia\"   ],\n      [13,    \"New Haven\"  ],\n      [14,    \"West Haven\" ],\n      [15,    \"A\"],\n      [16,    \"B\"],   # was -1\n      [17,    \"C\"],   # was -3\n      [18,    \"D\"],\n      [19,    \"E\"],   # was -7\n      [20,    \"F\"],   # was -10\n      [21,    \"G\"],   # was -3\n    ])\n    self.assertTableData('Schools', cols=\"subset\", data=[\n      [\"id\",  \"name\",     \"address\"],\n      [1, \"Columbia\",     19],\n      [2, \"Columbia\",     17],\n      [3, \"Yale\",         16],\n      [4, \"Yale\",         11],\n      [5, \"SUNY C\",       17],\n      [6, \"SUNY A\",       16],\n      [7, \"NYU\",          11],\n      [8, \"Xavier\",       0],\n      [9, \"Suny C2\",      17],\n      [10, \"Suny E\",      19],\n      [11, \"SUNY G\",      21],\n    ])\n\n    # Test that the actions above got properly translated.\n    # These are same as above, except for the translated rowIds.\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        ['AddRecord', 'Address', 15, {'city': 'A'}],\n        ['AddRecord', 'Address', 16, {'city': 'B'}],\n        ['BulkAddRecord', 'Address', [17, 18, 19, 20], {'city': ['C', 'D', 'E', 'F']}],\n        ['AddRecord', 'Schools', 5, {'address': 17, 'name': 'SUNY C'}],\n        ['BulkAddRecord', 'Schools', [6, 7, 8, 9, 10], {\n          'address': [16, 11, 0, 17, 19],\n          'name': ['SUNY A', 'NYU', 'Xavier', 'Suny C2', 'Suny E'],\n          }\n        ],\n        ['UpdateRecord', 'Schools', 1, {'address': 19}],\n        ['BulkUpdateRecord', 'Schools', [2, 3, 4], {'address': [17, 16, 11]}],\n        ['AddRecord', 'Address', 21, {'city': 'G'}],\n        ['AddRecord', 'Schools', 11, {'address': 21, 'name': 'SUNY G'}],\n\n        # Calculated values (for Students; lookups on schools named \"Columbia\" and \"Yale\")\n        [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 6], {\n          \"schoolCities\": [\"E:C\", \"B:New York\", \"E:C\", \"B:New York\", \"B:New York\"]}],\n      ]\n    })\n\n  def test_update_remove(self):\n    self.load_sample(testsamples.sample_students)\n\n    out_actions = self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (\n      ['AddRecord', 'Students', -1, {'firstName': 'A'}],\n      ['UpdateRecord', 'Students', -1, {'lastName': 'A'}],\n      ['BulkAddRecord', 'Students', [-2, None, -3], {'firstName': ['C', 'D', 'E']}],\n      ['BulkUpdateRecord', 'Students', [-2, -3, -1], {'lastName': ['C', 'E', 'F']}],\n      ['RemoveRecord', 'Students', -2],\n    )])\n\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        ['AddRecord', 'Students', 7, {'firstName': 'A'}],\n        ['UpdateRecord', 'Students', 7, {'lastName': 'A'}],\n        ['BulkAddRecord', 'Students', [8, 9, 10], {'firstName': ['C', 'D', 'E']}],\n        ['BulkUpdateRecord', 'Students', [8, 10, 7], {'lastName': ['C', 'E', 'F']}],\n        ['RemoveRecord', 'Students', 8],\n      ]\n    })\n"
  },
  {
    "path": "sandbox/grist/test_textbuilder.py",
    "content": "import unittest\nimport asttokens\nimport re\n\nimport textbuilder\nfrom textbuilder import make_patch, make_regexp_patches, Patch\n\nclass TestTextBuilder(unittest.TestCase):\n  def test_validate_patch(self):\n    text = \"To be or not to be\"\n    patch = make_patch(text, 3, 8, \"SEE OR\")\n    self.assertEqual(textbuilder.validate_patch(text, patch), None)\n    with self.assertRaises(ValueError):\n      textbuilder.validate_patch('X' + text, patch)\n\n  def test_replacer(self):\n    value = object()\n    t1 = textbuilder.Text(\"To be or not\\n  to be?\\n\", value)\n    patches = make_regexp_patches(t1.get_text(), re.compile(r'be|to', re.I),\n                                  lambda m: (m.group() + m.group()).upper())\n    t2 = textbuilder.Replacer(t1, patches)\n    self.assertEqual(t2.get_text(), \"TOTO BEBE or not\\n  TOTO BEBE?\\n\")\n    self.assertEqual(t2.map_back_patch(make_patch(t2.get_text(), 0, 4, \"xxx\")),\n                      (t1.get_text(), value, Patch(0, 2, \"To\", \"xxx\")))\n    self.assertEqual(t2.map_back_patch(make_patch(t2.get_text(), 5, 9, \"xxx\")),\n                      (t1.get_text(), value, Patch(3, 5, \"be\", \"xxx\")))\n    self.assertEqual(t2.map_back_patch(make_patch(t2.get_text(), 18, 23, \"xxx\")),\n                      (t1.get_text(), value, Patch(14, 17, \" to\", \"xxx\")))\n    # Match the entire second line\n    self.assertEqual(t2.map_back_patch(make_patch(t2.get_text(), 17, 29, \"xxx\")),\n                      (t1.get_text(), value, Patch(13, 21, \"  to be?\", \"xxx\")))\n\n  def test_combiner(self):\n    valueA, valueB = object(), object()\n    t1 = textbuilder.Text(\"To be or not\\n  to be?\\n\", valueA)\n    patches = make_regexp_patches(t1.get_text(), re.compile(r'be|to', re.I),\n                                  lambda m: (m.group() + m.group()).upper())\n    t2 = textbuilder.Replacer(t1, patches)\n    t3 = textbuilder.Text(\"That is the question\", valueB)\n    t4 = textbuilder.Combiner([\"[\", t2, t3, \"]\"])\n    self.assertEqual(t4.get_text(), \"[TOTO BEBE or not\\n  TOTO BEBE?\\nThat is the question]\")\n    self.assertEqual(t4.map_back_patch(make_patch(t4.get_text(), 1, 5, \"xxx\")),\n                     (t1.get_text(), valueA, Patch(0, 2, \"To\", \"xxx\")))\n    self.assertEqual(t4.map_back_patch(make_patch(t4.get_text(), 18, 30, \"xxx\")),\n                     (t1.get_text(), valueA, Patch(13, 21, \"  to be?\", \"xxx\")))\n    self.assertEqual(t4.map_back_patch(make_patch(t4.get_text(), 0, 1, \"xxx\")),\n                     None)\n    self.assertEqual(t4.map_back_patch(make_patch(t4.get_text(), 31, 38, \"xxx\")),\n                     (t3.get_text(), valueB, Patch(0, 7, \"That is\", \"xxx\")))\n\n  def test_linenumbers(self):\n    ln = asttokens.LineNumbers(\"Hello\\nworld\\nThis\\n\\nis\\n\\na test.\\n\")\n    self.assertEqual(ln.line_to_offset(1, 0), 0)\n    self.assertEqual(ln.line_to_offset(1, 5), 5)\n    self.assertEqual(ln.line_to_offset(2, 0), 6)\n    self.assertEqual(ln.line_to_offset(2, 5), 11)\n    self.assertEqual(ln.line_to_offset(3, 0), 12)\n    self.assertEqual(ln.line_to_offset(4, 0), 17)\n    self.assertEqual(ln.line_to_offset(5, 0), 18)\n    self.assertEqual(ln.line_to_offset(6, 0), 21)\n    self.assertEqual(ln.line_to_offset(7, 0), 22)\n    self.assertEqual(ln.line_to_offset(7, 7), 29)\n    self.assertEqual(ln.offset_to_line(0),  (1, 0))\n    self.assertEqual(ln.offset_to_line(5),  (1, 5))\n    self.assertEqual(ln.offset_to_line(6),  (2, 0))\n    self.assertEqual(ln.offset_to_line(11), (2, 5))\n    self.assertEqual(ln.offset_to_line(12), (3, 0))\n    self.assertEqual(ln.offset_to_line(17), (4, 0))\n    self.assertEqual(ln.offset_to_line(18), (5, 0))\n    self.assertEqual(ln.offset_to_line(21), (6, 0))\n    self.assertEqual(ln.offset_to_line(22), (7, 0))\n    self.assertEqual(ln.offset_to_line(29), (7, 7))\n\n    # Test that out-of-bounds inputs still return something sensible.\n    self.assertEqual(ln.line_to_offset(6, 19), 30)\n    self.assertEqual(ln.line_to_offset(100, 99), 30)\n    self.assertEqual(ln.line_to_offset(2, -1), 6)\n    self.assertEqual(ln.line_to_offset(-1, 99), 0)\n    self.assertEqual(ln.offset_to_line(30), (8, 0))\n    self.assertEqual(ln.offset_to_line(100), (8, 0))\n    self.assertEqual(ln.offset_to_line(-100), (1, 0))\n\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_treeview.py",
    "content": "from collections import namedtuple\nimport unittest\n\nfrom treeview import fix_indents\n\nItem = namedtuple('Item', 'id indentation')\n\ndef fix_and_check(items, changes):\n  #  convert from strings to items with ids and indents (e.g. \"A0\" -> {id: \"A\", indent: 0} returns\n  #  the pair (adjustments, resulting items converted to strings) for verification\n  all_items = [Item(i[0], int(i[1:])) for i in items]\n  adjustments = fix_indents(all_items, changes)\n  fix_map = {id: indentation for id, indentation in adjustments}\n  all_items = [i for i in all_items if i.id not in changes]\n  result = ['%s%s' % (i.id, fix_map.get(i.id, i.indentation)) for i in all_items]\n  return (adjustments, result)\n\nclass TestTreeView(unittest.TestCase):\n\n  def test_fix_indents(self):\n    self.assertEqual(fix_and_check([\"A0\", \"B0\", \"C1\", \"D1\"], {\"B\"}), (\n      [(\"C\", 0)],\n      [\"A0\", \"C0\", \"D1\"]))\n\n    self.assertEqual(fix_and_check([\"A0\", \"B1\", \"C1\", \"D1\"], {\"B\"}), (\n      [],\n      [\"A0\", \"C1\", \"D1\"]))\n\n    self.assertEqual(fix_and_check([\"A0\", \"B0\", \"C1\", \"D2\", \"E3\", \"F2\", \"G1\", \"H0\"], {\"B\"}), (\n      [(\"C\", 0), (\"D\", 1), (\"E\", 2)],\n      [\"A0\", \"C0\", \"D1\", \"E2\", \"F2\", \"G1\", \"H0\"]))\n\n    self.assertEqual(fix_and_check([\"A0\", \"B1\", \"C1\", \"D1\"], {\"A\", \"B\"}), (\n      [(\"C\", 0)],\n      [\"C0\", \"D1\"]))\n\n    self.assertEqual(fix_and_check([\"A0\", \"B0\", \"C1\", \"D1\"], {\"A\", \"B\"}), (\n      [(\"C\", 0)],\n      [\"C0\", \"D1\"]))\n\n    self.assertEqual(fix_and_check([\"A0\", \"B1\", \"C2\", \"D0\"], {\"A\", \"B\"}), (\n      [(\"C\", 0)],\n      [\"C0\", \"D0\"]))\n\n    self.assertEqual(fix_and_check([\"A0\", \"B1\", \"C2\", \"D0\"], {\"A\", \"C\"}), (\n      [(\"B\", 0)],\n      [\"B0\", \"D0\"]))\n\n    self.assertEqual(fix_and_check([\"A0\", \"B1\", \"C2\", \"D0\"], {\"B\", \"C\"}), (\n      [],\n      [\"A0\", \"D0\"]))\n\n    self.assertEqual(fix_and_check([\"A0\", \"B1\", \"C2\", \"D0\", \"E0\"], {\"B\", \"D\"}), (\n      [(\"C\", 1)],\n      [\"A0\", \"C1\", \"E0\"]))\n\n    self.assertEqual(fix_and_check([\"A0\", \"B1\", \"C2\", \"D0\", \"E1\"], {\"B\", \"D\"}), (\n      [(\"C\", 1), (\"E\", 0)],\n      [\"A0\", \"C1\", \"E0\"]))\n"
  },
  {
    "path": "sandbox/grist/test_trigger_expression.py",
    "content": "import json\nimport unittest\n\nimport test_engine\nfrom predicate_formula import parse_predicate_formula\nfrom trigger_expression import parse_trigger_condition, parse_conditions_in_triggers\n\n\nclass TestConditionParsing(unittest.TestCase):\n  \"\"\"Unit tests for trigger_expression functions.\"\"\"\n\n  def test_parse_condition_value_with_text_only(self):\n    \"\"\"Test parsing a condition with only text field.\"\"\"\n    condition = json.dumps({'text': '$A == \"Foo\"'})\n    result = parse_trigger_condition(condition)\n    result_data = json.loads(result)\n\n    self.assertEqual(result_data['text'], '$A == \"Foo\"')\n    self.assertEqual(result_data['parsed'], parse_predicate_formula('$A == \"Foo\"'))\n\n  def test_parse_condition_value_already_parsed(self):\n    \"\"\"Test that already parsed conditions are not re-parsed.\"\"\"\n    condition = json.dumps({\n      'text': '$A == \"Foo\"',\n      'parsed': '[\"some\", \"wrong\", \"structure\"]'\n    })\n    result = parse_trigger_condition(condition)\n\n    # Should return unchanged\n    self.assertEqual(result, condition)\n\n  def test_parse_condition_value_with_plain_string(self):\n    \"\"\"Test that a plain string gets converted to object with text field.\"\"\"\n    condition = '$A == \"Foo\"'\n    result = parse_trigger_condition(condition)\n    result_data = json.loads(result)\n\n    self.assertEqual(result_data['text'], '$A == \"Foo\"')\n    self.assertIn('parsed', result_data)\n\n  def test_parse_condition_value_with_empty_text(self):\n    \"\"\"Test that empty text returns original condition.\"\"\"\n    condition = json.dumps({'text': ''})\n    result = parse_trigger_condition(condition)\n\n    self.assertIsNone(result)\n\n  def test_parse_condition_value_with_no_text_field(self):\n    \"\"\"Test that object without text field returns original.\"\"\"\n    condition = json.dumps({'foo': 'bar'})\n    result = parse_trigger_condition(condition)\n\n    self.assertIsNone(result)\n\n  def test_parse_trigger_condition_multiple_values(self):\n    \"\"\"Test parsing multiple condition values in col_values.\"\"\"\n    col_values = {\n      'condition': [\n        json.dumps({'text': '$A == \"Foo\"'}),\n        json.dumps({'text': 'rec.B == \"Director\"'}),\n        json.dumps({'text': '', 'parsed': 'existing'}),  # empty text, will be cleared\n        'rec.C > 10',  # plain string\n      ]\n    }\n\n    parse_conditions_in_triggers(col_values)\n\n    result1 = json.loads(col_values['condition'][0])\n    self.assertIn('parsed', result1)\n    self.assertEqual(result1['text'], '$A == \"Foo\"')\n\n    result2 = json.loads(col_values['condition'][1])\n    self.assertIn('parsed', result2)\n    self.assertEqual(result2['text'], 'rec.B == \"Director\"')\n\n    self.assertIsNone(col_values['condition'][2])\n\n    result4 = json.loads(col_values['condition'][3])\n    self.assertIn('parsed', result4)\n    self.assertEqual(result4['text'], 'rec.C > 10')\n\n  def test_parse_trigger_condition_no_condition_field(self):\n    \"\"\"Test that missing condition field is handled gracefully.\"\"\"\n    col_values = {'other_field': ['value']}\n    parse_conditions_in_triggers(col_values)\n\n    # Should not crash and should not add condition field\n    self.assertNotIn('condition', col_values)\n\n  def test_parse_condition_with_json_non_dict(self):\n    \"\"\"Test that valid JSON that's not a dict is treated as plain string.\"\"\"\n    # JSON string value - the entire JSON-encoded string becomes the text\n\n    wrong_conditions = [\n      json.dumps(\"encoded string\"),\n      json.dumps(42),\n      json.dumps([\"list\", \"of\", \"values\"]),\n      json.dumps(3.14),\n      json.dumps(True),\n      json.dumps(None),\n    ]\n\n    for cond in wrong_conditions:\n      self.assertEqual(parse_trigger_condition(cond), cond)\n\n\nclass TestTriggerActions(test_engine.EngineTestCase):\n  def test_condition_parsed_on_add_and_update(self):\n    # Create a table so the trigger has a valid tableRef.\n    self.apply_user_action(['AddTable', 'Table1', [\n      {'id': 'A', 'type': 'Text'},\n    ]])\n\n    tables = self.engine.fetch_table('_grist_Tables')\n    table_ref = tables.row_ids[tables.columns['tableId'].index('Table1')]\n\n    condition_text = '$A == \"Foo\"'\n    self.apply_user_action(['AddRecord', '_grist_Triggers', None, {\n      'tableRef': table_ref,\n      'condition': json.dumps({'text': condition_text}),\n    }])\n\n    expected_condition = json.dumps({\n      'text': condition_text,\n      'parsed': parse_predicate_formula(condition_text),\n    })\n    trigger_table = self.engine.fetch_table('_grist_Triggers')\n    trigger_id = trigger_table.row_ids[0]\n    self.assertEqual(trigger_table.columns['condition'], [expected_condition])\n\n    # Update the condition text and ensure it is re-parsed.\n    new_condition_text = 'rec.A == \"Bar\"'\n    self.apply_user_action(['UpdateRecord', '_grist_Triggers', trigger_id, {\n      'condition': json.dumps({'text': new_condition_text}),\n    }])\n    updated_condition = json.dumps({\n      'text': new_condition_text,\n      'parsed': parse_predicate_formula(new_condition_text),\n    })\n    trigger_table = self.engine.fetch_table('_grist_Triggers')\n    self.assertEqual(trigger_table.columns['condition'], [updated_condition])\n\n    # Add another one with plain string condition.\n    plain_condition_text = 'rec.A != \"Baz\"'\n    self.apply_user_action(['AddRecord', '_grist_Triggers', None, {\n      'tableRef': table_ref,\n      'condition': plain_condition_text,\n    }])\n\n    expected_plain_condition = json.dumps({\n      'text': plain_condition_text,\n      'parsed': parse_predicate_formula(plain_condition_text),\n    })\n    trigger_table = self.engine.fetch_table('_grist_Triggers')\n    self.assertEqual(trigger_table.columns['condition'][1], expected_plain_condition)\n\n  def test_should_clear_the_condition_when_text_cleared(self):\n    self.apply_user_action(['AddTable', 'Table1', [\n      {'id': 'A', 'type': 'Text'},\n    ]])\n\n    tables = self.engine.fetch_table('_grist_Tables')\n    table_ref = tables.row_ids[tables.columns['tableId'].index('Table1')]\n\n    condition_text = '$A == \"Foo\"'\n    self.apply_user_action(['AddRecord', '_grist_Triggers', None, {\n      'tableRef': table_ref,\n      'condition': condition_text,\n    }])\n\n    values_to_clear = [\n      None,\n      \"\",\n      json.dumps({'text': ''}),\n      json.dumps({'text': None}),\n    ]\n\n    for val in values_to_clear:\n      # First restore the condition to a valid state if it was cleared in a previous iteration\n      self.apply_user_action(['UpdateRecord', '_grist_Triggers', 1, {\n        'condition': json.dumps({'text': condition_text}),\n      }])\n\n      # Sanity check that it worked\n      trigger_table = self.engine.fetch_table('_grist_Triggers')\n      self.assertEqual(trigger_table.columns['condition'], [\n        json.dumps({\n          'text': condition_text,\n          'parsed': parse_predicate_formula(condition_text)\n        })\n      ])\n\n      # Now apply the value that should clear the condition\n      self.apply_user_action(['UpdateRecord', '_grist_Triggers', 1, {\n        'condition': val,\n      }])\n\n      trigger_table = self.engine.fetch_table('_grist_Triggers')\n      self.assertEqual(trigger_table.columns['condition'], [None])\n\n\nclass TestTriggerConditionRenames(test_engine.EngineTestCase):\n  def setUp(self):\n    super().setUp()\n\n    # Create a table with columns for tests\n    self.apply_user_action(['AddTable', 'Table1', [\n      {'id': 'A', 'type': 'Text'},\n      {'id': 'B', 'type': 'Text'},\n    ]])\n\n    self.apply_user_action(['AddTable', 'Table2', [\n      {'id': 'A', 'type': 'Text'},\n      {'id': 'B', 'type': 'Text'},\n    ]])\n\n    tables = self.engine.fetch_table('_grist_Tables')\n    table_1 = tables.row_ids[tables.columns['tableId'].index('Table1')]\n    table_2 = tables.row_ids[tables.columns['tableId'].index('Table2')]\n\n    # Add a trigger with a condition referencing the Status column\n    self.apply_user_action(['AddRecord', '_grist_Triggers', None, {\n      'tableRef': table_1,\n      'condition': '$A == 1',\n    }])\n\n    self.apply_user_action(['AddRecord', '_grist_Triggers', None, {\n      'tableRef': table_2,\n      'condition': '$A == 2',\n    }])\n\n  def get_condition(self, index=0):\n    # Get the condition from the text of the trigger at the given index\n    trigger_table = self.engine.fetch_table('_grist_Triggers')\n    condition_json = json.loads(trigger_table.columns['condition'][index])\n    return condition_json['text']\n\n  def set_condition(self, condition_text):\n    # Update the condition of the first trigger\n    self.apply_user_action(['UpdateRecord', '_grist_Triggers', 1, {\n      'condition': condition_text,\n    }])\n\n  def test_column_rename_updates_trigger_condition(self):\n    \"\"\"Test that renaming a column updates the trigger condition formula.\"\"\"\n    # Rename the Status column\n    self.apply_user_action(['RenameColumn', 'Table1', 'A', 'A2'])\n\n    # Check that the Table1 trigger condition was updated\n    self.assertEqual(self.get_condition(0), '$A2 == 1')\n    # Check that the Table2 trigger condition was not changed\n    self.assertEqual(self.get_condition(1), '$A == 2')\n\n  def test_column_rename_with_oldRec(self):\n    \"\"\"Test that renaming a column updates oldRec references in trigger conditions.\"\"\"\n    # Update the trigger to use oldRec reference\n    self.set_condition('rec.A != oldRec.A and rec.B == \"test\"')\n\n    # Rename both columns\n    self.apply_user_action(['RenameColumn', 'Table1', 'A', 'A2'])\n    self.apply_user_action(['RenameColumn', 'Table1', 'B', 'B2'])\n\n    # Check that both rec and oldRec references were updated in Table1's trigger\n    self.assertEqual(self.get_condition(0), 'rec.A2 != oldRec.A2 and rec.B2 == \"test\"')\n    # Check that the Table2 trigger condition was not changed\n    self.assertEqual(self.get_condition(1), '$A == 2')\n\n  def test_column_rename_updates_both_table_triggers(self):\n    \"\"\"Test that renaming columns in both tables updates both trigger conditions.\"\"\"\n    # Rename columns in Table1\n    self.apply_user_action(['RenameColumn', 'Table1', 'A', 'A2'])\n\n    # Rename columns in Table2\n    self.apply_user_action(['RenameColumn', 'Table2', 'A', 'A3'])\n\n    # Check that both trigger conditions were updated correctly\n    self.assertEqual(self.get_condition(0), '$A2 == 1')\n    self.assertEqual(self.get_condition(1), '$A3 == 2')\n"
  },
  {
    "path": "sandbox/grist/test_trigger_formulas.py",
    "content": "import copy\nimport logging\nimport time\n\nimport objtypes\nimport testutil\nimport test_engine\nfrom schema import RecalcWhen\n\n# pylint: disable=line-too-long\n\nlog = logging.getLogger(__name__)\n\ndef column_error(table, column, user_input):\n  return objtypes.RaisedException(\n    AttributeError(\"Table '%s' has no column '%s'\" % (table, column)),\n    user_input=user_input\n  )\ndiv_error = lambda value: objtypes.RaisedException(ZeroDivisionError(\"float division by zero\"), user_input=value)\n\nclass TestTriggerFormulas(test_engine.EngineTestCase):\n  col = testutil.col_schema_row\n  sample_desc = {\n    \"SCHEMA\": [\n      [1, \"Creatures\", [\n        col(1, \"Name\",       \"Text\",       False),\n        col(2, \"Ocean\",      \"Ref:Oceans\", False),\n        col(3, \"OceanName\",  \"Text\",       True,  \"$Ocean.Name\"),\n        col(4, \"BossDef\",    \"Text\",       False, \"$Ocean.Head\"),\n        col(5, \"BossNvr\",    \"Text\",       False, \"$Ocean.Head\", recalcWhen=RecalcWhen.NEVER),\n        col(6, \"BossUpd\",    \"Text\",       False, \"$Ocean.Head\", recalcDeps=[2]),\n        col(7, \"BossAll\",    \"Text\",       False, \"$Ocean.Head\", recalcWhen=RecalcWhen.MANUAL_UPDATES),\n      ]],\n      [2, \"Oceans\", [\n        col(11, \"Name\",     \"Text\",        False),\n        col(12, \"Head\",     \"Text\",        False)\n      ]],\n    ],\n    \"DATA\": {\n      \"Creatures\": [\n        [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\"],\n        [1,   \"Dolphin\", 2,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\"],\n      ],\n      \"Oceans\": [\n        [\"id\",  \"Name\",     \"Head\"],\n        [1,     \"Pacific\",    \"Watatsumi\"],\n        [2,     \"Atlantic\",   \"Poseidon\"],\n        [3,     \"Indian\",     \"Neptune\"],\n        [4,     \"Arctic\",     \"Poseidon\"],\n      ],\n    }\n  }\n  sample = testutil.parse_test_sample(sample_desc)\n\n  def test_no_recalc_on_load(self):\n    # Trigger formulas don't affect data that's loaded.\n    self.load_sample(self.sample)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\" ],\n    ])\n\n  def test_recalc_on_new_records(self):\n    # Trigger formulas affect new records.\n    self.load_sample(self.sample)\n    self.add_record(\"Creatures\", Name=\"Shark\", Ocean=2)\n    self.add_record(\"Creatures\", Name=\"Squid\", Ocean=1)\n\n    # Check that BossNvr (\"never\") wasn't affected by the default formula, but the rest were.\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",   \"BossAll\",   \"OceanName\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",    \"Arthur\",  \"Arthur\",    \"Arthur\",    \"Atlantic\" ],\n      [2,   \"Shark\",   2,       \"Poseidon\",  \"\",        \"Poseidon\",  \"Poseidon\",  \"Atlantic\" ],\n      [3,   \"Squid\",   1,       \"Watatsumi\", \"\",        \"Watatsumi\", \"Watatsumi\", \"Pacific\"  ],\n    ])\n\n  def test_no_recalc_on_noop_change(self):\n    # A no-op change shouldn't trigger any updates.\n    self.load_sample(self.sample)\n    self.update_record(\"Creatures\", 1, Ocean=2)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\" ],\n    ])\n\n  def test_recalc_on_update(self):\n    # Changes should trigger recalc of certain trigger formulas.\n    self.load_sample(self.sample)\n    self.add_record(\"Creatures\", Name=\"Shark\", Ocean=2)\n    self.add_record(\"Creatures\", Name=\"Squid\", Ocean=1)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",   \"BossAll\",   \"OceanName\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",    \"Arthur\",  \"Arthur\",    \"Arthur\",    \"Atlantic\" ],\n      [2,   \"Shark\",   2,       \"Poseidon\",  \"\",        \"Poseidon\",  \"Poseidon\",  \"Atlantic\" ],\n      [3,   \"Squid\",   1,       \"Watatsumi\", \"\",        \"Watatsumi\", \"Watatsumi\", \"Pacific\"  ],\n    ])\n    self.update_records(\"Creatures\", [\"id\", \"Ocean\"], [\n      [1, 3],   # Ocean for 1: Atlantic -> Indian\n      [3, 4],   # Ocean for 3: Pacific -> Arctic\n    ])\n    # Only BossUpd and BossAll columns should be affected, not BossDef or BossNvr\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",   \"BossAll\",   \"OceanName\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",    \"Arthur\",  \"Neptune\",   \"Neptune\",    \"Indian\"  ],\n      [2,   \"Shark\",   2,       \"Poseidon\",  \"\",        \"Poseidon\",  \"Poseidon\",   \"Atlantic\"],\n      [3,   \"Squid\",   4,       \"Watatsumi\", \"\",        \"Poseidon\",  \"Poseidon\",   \"Arctic\"  ],\n    ])\n\n  def test_recalc_with_direct_update(self):\n    # Check that an update that changes both a dependency and the trigger-formula column itself\n    # respects the latter value.\n    self.load_sample(self.sample)\n\n    out_actions = self.update_record(\"Creatures\", 1, Ocean=3, BossUpd=\"Bob\")\n    self.assertTableData(\"Creatures\", rows=\"subset\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",   \"BossAll\",   \"OceanName\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",    \"Arthur\",  \"Bob\",       \"Neptune\",    \"Indian\"  ],\n    ])\n    # Check that the needed recalcs are the only ones that happened.\n    self.assertPartialOutActions(out_actions, {\n      \"calls\": {\"Creatures\": {\"BossAll\": 1, \"OceanName\": 1}}\n    })\n\n    out_actions = self.update_record(\"Creatures\", 1, Ocean=4, BossUpd=\"\", BossAll=\"Chuck\")\n    self.assertTableData(\"Creatures\", rows=\"subset\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",   \"BossAll\",   \"OceanName\"],\n      [1,   \"Dolphin\", 4,       \"Arthur\",    \"Arthur\",  \"\",          \"Chuck\",     \"Arctic\"  ],\n    ])\n    # Check that the needed recalcs are the only ones that happened.\n    self.assertPartialOutActions(out_actions, {\n      \"calls\": {\"Creatures\": {\"OceanName\": 1}}\n    })\n\n  def test_no_recalc_on_reopen(self):\n    # Change that a reopen does not recalc at all.\n\n    # Load a sample with a few more rows. Only the one true formula should be calculated\n    sample_desc = copy.deepcopy(self.sample_desc)\n    sample_desc[\"DATA\"][\"Creatures\"] = [\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",  \"BossNvr\", \"BossUpd\",  \"BossAll\" ],\n      [1,   \"Dolphin\", 2,       \"Arthur\",   \"Arthur\",  \"Arthur\",   \"Arthur\"  ],\n      [2,   \"Shark\",   2,       \"\",  \"\",               \"Poseidon\", \"Poseidon\"],\n      [3,   \"Squid\",   4,       \"Watatsumi\", \"\",       \"Poseidon\", \"\"        ],\n    ]\n    sample = testutil.parse_test_sample(sample_desc)\n\n    self.assertEqual(self.call_counts, {})\n    self.load_sample(sample)\n    self.assertEqual(self.call_counts, {\n      'Creatures': {'#lookup#': 3, 'OceanName': 3},\n      'Oceans': {'#lookup#': 4},\n    })\n\n\n  def test_recalc_undo(self):\n    self.load_sample(self.sample)\n    data0 = [\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\" ],\n    ]\n    self.assertTableData(\"Creatures\", data=data0)\n\n    # Plain update\n    out_actions1 = self.update_record(\"Creatures\", 1, Ocean=1)\n    data1 = [\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",   \"BossAll\",   \"OceanName\"],\n      [1,   \"Dolphin\", 1,       \"Arthur\",    \"Arthur\",  \"Watatsumi\", \"Watatsumi\", \"Pacific\"  ],\n    ]\n    self.assertTableData(\"Creatures\", data=data1)\n    self.assertEqual(out_actions1.calls, {\"Creatures\": {\"BossUpd\": 1, \"BossAll\": 1, \"OceanName\": 1}})\n\n    # Update with a manual update to one of the trigger columns\n    out_actions2 = self.update_record(\"Creatures\", 1, Ocean=3, BossUpd=\"Bob\")\n    data2 = [\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",   \"BossAll\",   \"OceanName\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",    \"Arthur\",  \"Bob\",       \"Neptune\",   \"Indian\"  ],\n    ]\n    self.assertTableData(\"Creatures\", rows=\"subset\", data=data2)\n    self.assertEqual(out_actions2.calls, {\"Creatures\": {\"BossAll\": 1, \"OceanName\": 1}})\n\n    # Undo, one at a time. It should not cause recalc of trigger columns, because an undo sets\n    # those explicitly.\n    out_actions2_undo = self.apply_undo_actions(out_actions2.undo)\n    self.assertTableData(\"Creatures\", data=data1)\n    self.assertEqual(out_actions2_undo.calls, {\"Creatures\": {\"OceanName\": 1}})\n\n    out_actions1_undo = self.apply_undo_actions(out_actions1.undo)\n    self.assertTableData(\"Creatures\", data=data0)\n    self.assertEqual(out_actions1_undo.calls, {\"Creatures\": {\"OceanName\": 1}})\n\n\n  def test_recalc_triggers(self):\n    # A trigger that depends on some columns should not be triggered by other ones.\n    self.load_sample(self.sample)\n\n    # BossUpd and BossAll both depend on the \"Ocean\" column, so both get updated.\n    out_actions = self.update_record(\"Creatures\", 1, Ocean=3)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",  \"Arthur\",  \"Neptune\", \"Neptune\", \"Indian\" ],\n    ])\n    self.assertEqual(out_actions.calls, {\"Creatures\": {\"BossUpd\": 1, \"BossAll\": 1, \"OceanName\": 1}})\n\n    # Undo, then check that a change that doesn't touch Ocean only triggers BossAll recalc.\n    self.apply_undo_actions(out_actions.undo)\n    out_actions = self.update_record(\"Creatures\", 1, Name=\"Whale\")\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",  \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\",  \"OceanName\"],\n      [1,   \"Whale\", 2,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Poseidon\", \"Atlantic\" ],\n    ])\n    self.assertEqual(out_actions.calls, {\"Creatures\": {\"BossAll\": 1}})\n\n\n  def test_recalc_trigger_changes(self):\n    # After changing a trigger formula dependencies, changes to the old dependency should no\n    # longer cause a recalc.\n    self.load_sample(self.sample)\n\n    # Change column BossUpd to depend on column Name rather than on column Ocean.\n    self.update_record(\"_grist_Tables_column\", 6, recalcDeps=['L', 1])\n\n    # Make a change to Ocean. It should not cause an update to BossUpd, only BossAll.\n    out_actions = self.update_record(\"Creatures\", 1, Ocean=3)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Neptune\", \"Indian\" ],\n    ])\n    self.assertEqual(out_actions.calls, {\"Creatures\": {\"BossAll\": 1, \"OceanName\": 1}})\n\n    # But changes to the new dependency should trigger recalc.\n    out_actions = self.update_record(\"Creatures\", 1, Name=\"Whale\")\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",  \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\"],\n      [1,   \"Whale\", 3,       \"Arthur\",  \"Arthur\",  \"Neptune\", \"Neptune\", \"Indian\" ],\n    ])\n    self.assertEqual(out_actions.calls, {\"Creatures\": {\"BossUpd\": 1, \"BossAll\": 1}})\n\n    # If dependencies are changed to empty, only new records should cause BossUpd recalc.\n    self.update_record(\"_grist_Tables_column\", 6, recalcDeps=['L'])\n    out_actions = self.update_record(\"Creatures\", 1, Name=\"Porpoise\", Ocean=2)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",     \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\",  \"OceanName\"],\n      [1,   \"Porpoise\", 2,       \"Arthur\",  \"Arthur\",  \"Neptune\", \"Poseidon\", \"Atlantic\" ],\n    ])\n    self.assertEqual(out_actions.calls, {\"Creatures\": {\"BossAll\": 1, \"OceanName\": 1}})\n\n    out_actions = self.add_record(\"Creatures\", None, Name=\"Manatee\", Ocean=2)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",     \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\",  \"BossAll\",  \"OceanName\"],\n      [1,   \"Porpoise\", 2,       \"Arthur\",  \"Arthur\",  \"Neptune\",  \"Poseidon\", \"Atlantic\" ],\n      [2,   \"Manatee\",  2,       \"Poseidon\", \"\",       \"Poseidon\", \"Poseidon\", \"Atlantic\" ],\n    ])\n    self.assertEqual(out_actions.calls,\n        {\"Creatures\": {\"BossDef\": 1, \"BossUpd\": 1, \"BossAll\": 1, \"OceanName\": 1, \"#lookup#\": 1}})\n\n\n  def test_recalc_trigger_off(self):\n    # Change BossUpd dependency to never, and check that neither changes nor new records cause\n    # recalc.\n    self.load_sample(self.sample)\n    self.update_record(\"_grist_Tables_column\", 6, recalcWhen=RecalcWhen.NEVER)\n\n    # Check a change\n    out_actions = self.update_record(\"Creatures\", 1, Name=\"Whale\", Ocean=3)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",  \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\"],\n      [1,   \"Whale\", 3,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Neptune\", \"Indian\" ],\n    ])\n    self.assertEqual(out_actions.calls, {\"Creatures\": {\"BossAll\": 1, \"OceanName\": 1}})\n\n    # Check a new record -- doesn't affect BossUpd any more.\n    out_actions = self.add_record(\"Creatures\", None, Name=\"Manatee\", Ocean=2)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",     \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\",  \"OceanName\"],\n      [1,   \"Whale\",    3,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Neptune\",  \"Indian\" ],\n      [2,   \"Manatee\",  2,       \"Poseidon\", \"\",       \"\",        \"Poseidon\", \"Atlantic\" ],\n    ])\n    self.assertEqual(out_actions.calls,\n        {\"Creatures\": {\"BossDef\": 1, \"BossAll\": 1, \"OceanName\": 1, \"#lookup#\": 1}})\n\n\n  def test_renames(self):\n    # After renaming tables or columns, trigger formulas should still be triggered the same way.\n    self.load_sample(self.sample)\n\n    # Do some renamings: they shouldn't trigger updates to trigger formulas.\n    self.apply_user_action([\"RenameColumn\", \"Creatures\", \"Ocean\", \"Sea\"])\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Sea\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 2,     \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\" ],\n    ])\n\n    self.apply_user_action([\"RenameColumn\", \"Creatures\", \"BossUpd\", \"foo\"])\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Sea\", \"BossDef\", \"BossNvr\", \"foo\",     \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 2,     \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\" ],\n    ])\n\n    self.apply_user_action([\"RenameTable\", \"Creatures\", \"Critters\"])\n    self.assertTableData(\"Critters\", data=[\n      [\"id\",\"Name\",    \"Sea\", \"BossDef\", \"BossNvr\", \"foo\",     \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 2,     \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\" ],\n    ])\n\n    self.apply_user_action([\"RenameColumn\", \"Critters\", \"BossAll\", \"bar\"])\n    self.assertTableData(\"Critters\", data=[\n      [\"id\",\"Name\",    \"Sea\", \"BossDef\", \"BossNvr\", \"foo\",     \"bar\",     \"OceanName\"],\n      [1,   \"Dolphin\", 2,     \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\" ],\n    ])\n\n    # After renames, correct trigger formulas continue getting triggered.\n    out_actions = self.update_record(\"Critters\", 1, Sea=3)\n    self.assertTableData(\"Critters\", data=[\n      [\"id\",\"Name\",    \"Sea\",   \"BossDef\", \"BossNvr\", \"foo\",     \"bar\",     \"OceanName\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",  \"Arthur\",  \"Neptune\", \"Neptune\", \"Indian\" ],\n    ])\n    self.assertEqual(out_actions.calls, {\"Critters\": {\"foo\": 1, \"bar\": 1, \"OceanName\": 1}})\n\n    # After renames, changes shouldn't trigger unnecessary recalcs (foo, formerly BossUpd, should\n    # not be triggered by a change to Name).\n    out_actions = self.update_record(\"Critters\", 1, Name=\"Whale\")\n    self.assertTableData(\"Critters\", data=[\n      [\"id\",\"Name\",  \"Sea\",   \"BossDef\", \"BossNvr\", \"foo\",      \"bar\",     \"OceanName\"],\n      [1,   \"Whale\", 3,       \"Arthur\",  \"Arthur\",  \"Neptune\",  \"Neptune\", \"Indian\" ],\n    ])\n    self.assertEqual(out_actions.calls, {\"Critters\": {\"bar\": 1}})\n\n\n  def test_schema_changes(self):\n    # Schema changes like add/modify column should not cause trigger-formulas to recalculate.\n    self.load_sample(self.sample)\n\n    # Adding a column doesn't trigger recalcs.\n    out_actions = self.apply_user_action([\"AddColumn\", \"Creatures\", \"Size\", {\"type\": \"Text\", \"isFormula\": False}])\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\", \"Size\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\",  \"\"],\n    ])\n    self.assertEqual(out_actions.calls, {})\n\n    # Only BossAll should recalc since the record changed.\n    out_actions = self.update_record(\"Creatures\", 1, Size=\"Big\")\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\",  \"OceanName\", \"Size\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Poseidon\", \"Atlantic\",  \"Big\"],\n    ])\n    self.assertEqual(out_actions.calls, {\"Creatures\": {\"BossAll\": 1}})\n\n    # New records trigger recalc as usual.\n    out_actions = self.add_record(\"Creatures\", None, Name=\"Manatee\", Ocean=2)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",  \"BossNvr\", \"BossUpd\",  \"BossAll\",  \"OceanName\", \"Size\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",   \"Arthur\",  \"Arthur\",   \"Poseidon\", \"Atlantic\",  \"Big\"],\n      [2,   \"Manatee\", 2,       \"Poseidon\", \"\",        \"Poseidon\", \"Poseidon\", \"Atlantic\",  \"\"],\n    ])\n\n    # ModifyColumn doesn't trigger recalcs.\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Creatures\", \"Size\", {\"type\": 'Numeric'}])\n    self.assertEqual(out_actions.calls, {})\n\n\n  def test_changing_trigger_formula(self):\n    self.load_sample(self.sample)\n\n    # Modifying trigger formula doesn't trigger recalc.\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Creatures\", \"BossAll\", {\"formula\": 'UPPER($Ocean.Head)'}])\n    self.assertEqual(out_actions.calls, {})\n\n    # But when it runs, recalc uses the new formula.\n    out_actions = self.update_record(\"Creatures\", 1, Name=\"Whale\")\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",  \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\",  \"OceanName\"],\n      [1,   \"Whale\", 2,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"POSEIDON\", \"Atlantic\" ],\n    ])\n\n\n  def test_remove_dependency(self):\n    # Remove a dependency column, and check that recalcDeps list is updated.\n    self.load_sample(self.sample)\n\n    def get_recalc_deps(col_ref):\n      data = self.engine.fetch_table('_grist_Tables_column', col_ref, query={'id': [col_ref]})\n      return data.columns['recalcDeps'][0]\n\n    self.assertEqual(get_recalc_deps(6), [2])\n\n    # Add another dependency, so that we can test partial removal.\n    self.update_record(\"_grist_Tables_column\", 6, recalcDeps=['L', 2, 3])\n    self.assertEqual(get_recalc_deps(6), [2, 3])\n\n    # Remove a column that it's a Dependency of BossUpd\n    self.apply_user_action([\"RemoveColumn\", \"Creatures\", \"Ocean\"])\n    self.assertEqual(get_recalc_deps(6), [3])\n    self.apply_user_action([\"RemoveColumn\", \"Creatures\", \"OceanName\"])\n    self.assertEqual(get_recalc_deps(6), None)\n\n    # None of these operations should have changed trigger-formula columns.\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\"],\n      [1,   \"Dolphin\", \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\" ],\n    ])\n\n    # Check that it still responds to suitable triggers.\n    # Make a change to some other column. BossUpd doesn't get updated.\n    out_actions = self.update_record(\"Creatures\", 1, Name=\"Whale\")\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",  \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\" ],\n      [1,   \"Whale\", \"Arthur\",  \"Arthur\",  \"Arthur\", column_error(\"Creatures\", \"Ocean\", \"Arthur\")],\n    ])\n\n    # Add a record. BossUpd's formula still runs, though with an error.\n    no_column = column_error(\"Creatures\", \"Ocean\", \"\")\n    no_column_value = column_error(\"Creatures\", \"Ocean\", \"Arthur\")\n    out_actions = self.add_record(\"Creatures\", None, Name=\"Manatee\")\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"BossDef\",  \"BossNvr\", \"BossUpd\",  \"BossAll\" ],\n      [1,   \"Whale\",   \"Arthur\",   \"Arthur\",  \"Arthur\",   no_column_value],\n      [2,   \"Manatee\", no_column,   \"\",       no_column,  no_column],\n    ])\n\n\n  def test_no_trigger_by_formulas(self):\n    # A column that depends on any record update (\"allupdates\") should not be affected by formula\n    # recalculations.\n    self.load_sample(self.sample)\n\n    # Name of Ocean affects a formula column; Head affects calculation; neither triggers recalc.\n    self.update_record('Oceans', 2, Head=\"POSEIDON\", Name=\"ATLANTIC\")\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\",  \"ATLANTIC\" ],\n    ])\n    self.add_record(\"Creatures\", None, Name=\"Manatee\", Ocean=2)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Arthur\",  \"ATLANTIC\" ],\n      [2,   \"Manatee\", 2,       \"POSEIDON\",  \"\",  \"POSEIDON\",  \"POSEIDON\",  \"ATLANTIC\" ],\n    ])\n\n    # On the other hand, an explicit dependency on a formula column WILL be triggered.\n    self.update_record(\"_grist_Tables_column\", 6, recalcDeps=['L', 2, 3])\n    self.update_record('Oceans', 2, Name=\"atlantic\")\n\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\",    \"OceanName\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",  \"Arthur\",  \"POSEIDON\",  \"Arthur\",   \"atlantic\" ],\n      [2,   \"Manatee\", 2,       \"POSEIDON\",  \"\",      \"POSEIDON\",  \"POSEIDON\", \"atlantic\" ],\n    ])\n\n\n  def test_no_auto_dependencies(self):\n    # Evaluating a trigger formula should not create dependencies on cells used during\n    # evaluation.\n    self.load_sample(self.sample)\n    self.update_record(\"Creatures\", 1, Ocean=3)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",   \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",    \"Arthur\",  \"Neptune\",   \"Neptune\", \"Indian\"  ],\n    ])\n    # Update a value that trigger-cells used during calculation; it should not cause a recalc.\n    self.update_record('Oceans', 3, Head=\"NEPTUNE\")\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",   \"BossAll\", \"OceanName\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",    \"Arthur\",  \"Neptune\",   \"Neptune\", \"Indian\"  ],\n    ])\n\n\n  def test_self_trigger(self):\n    # A trigger formula may be triggered by changes to the column itself.\n    # Check that it gets recalculated.\n    sample_desc = copy.deepcopy(self.sample_desc)\n    creatures_table = sample_desc[\"SCHEMA\"][0]\n    creatures_columns = creatures_table[-1]\n\n    # Set BossUpd column to depend on Ocean and itself.\n    # Append something to ensure we are testing a case without a fixed point, to ensure\n    # that doesn't cause an infinite update loop.\n    self.assertEqual(creatures_columns[5][1], \"BossUpd\")\n    creatures_columns[5] = testutil.col_schema_row(\n      6, \"BossUpd\", \"Text\", False, \"UPPER(value or $Ocean.Head) + '+'\", recalcDeps=[2, 6]\n    )\n\n    # Previously there were various bugs with trigger formulas in columns involved in lookups:\n    # 1. They did not recalculate their trigger formulas after changes to themselves\n    # 2. They calculated the formula twice for new records\n    # 3. The lookups returned incorrect results\n    creatures_columns.append(testutil.col_schema_row(\n      21, \"Lookup\", \"Any\", True, \"Creatures.lookupRecords(BossUpd=$BossUpd).id\"\n    ))\n\n    sample = testutil.parse_test_sample(sample_desc)\n    self.load_sample(sample)\n\n    self.assertTableData(\"Creatures\", cols=\"subset\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",\"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\", \"Lookup\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\", \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\" , [1]],\n    ])\n\n    self.update_record('Creatures', 1, Ocean=3)\n    self.assertTableData(\"Creatures\", cols=\"subset\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\", \"Lookup\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",  \"Arthur\",  \"ARTHUR+\",  \"Neptune\", \"Indian\"  , [1]],\n    ])\n    self.update_record('Creatures', 1, BossUpd=\"None\")\n    self.assertTableData(\"Creatures\", cols=\"subset\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\", \"Lookup\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",  \"Arthur\",  \"NONE+\",    \"Neptune\", \"Indian\"  , [1]],\n    ])\n    self.update_record('Creatures', 1, BossUpd=\"\")\n    self.assertTableData(\"Creatures\", cols=\"subset\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\", \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\", \"Lookup\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",  \"Arthur\",  \"NEPTUNE+\",\"Neptune\", \"Indian\"  , [1]],\n    ])\n\n    # Ensuring trigger formula isn't called twice for new records\n    self.add_record('Creatures', BossUpd=\"Zeus\")\n    self.assertTableData(\"Creatures\", cols=\"subset\", rows=\"subset\", data=[\n      [\"id\", \"BossUpd\", \"Lookup\"],\n      [2,    \"ZEUS+\"  , [2]],\n    ])\n\n\n  def test_last_update_recipe(self):\n    # Use a formula to store time of last-update. Check that it works as expected.\n    # Check that times don't update on reload.\n    self.load_sample(self.sample)\n    self.add_column('Creatures', 'LastChange',\n      type='DateTime:UTC', isFormula=False, formula=\"NOW()\", recalcWhen=RecalcWhen.MANUAL_UPDATES)\n\n    # To compare times, use actual times after checking approximately.\n    now = time.time()\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\", \"LastChange\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",    \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\",  None],\n    ])\n\n    self.add_record(\"Creatures\", None, Name=\"Manatee\", Ocean=2)\n    self.update_record(\"Creatures\", 1, Ocean=3)\n\n    now = time.time()\n    [time1, time2] = self.engine.fetch_table('Creatures').columns['LastChange']\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",  \"BossAll\",  \"OceanName\", \"LastChange\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",    \"Arthur\",  \"Neptune\",  \"Neptune\",  \"Indian\",    time1],\n      [2,   \"Manatee\", 2,       \"Poseidon\",  \"\",        \"Poseidon\", \"Poseidon\", \"Atlantic\",  time2],\n    ])\n    self.assertLessEqual(abs(time1 - now), 1)\n    self.assertLessEqual(abs(time2 - now), 1)\n\n    # An indirect change doesn't affect the time, but a direct change does.\n    self.update_record(\"Oceans\", 2, Name=\"ATLANTIC\")\n    self.update_record(\"Creatures\", 1, Name=\"Whale\")\n    [time3, time4] = self.engine.fetch_table('Creatures').columns['LastChange']\n    self.assertGreater(time3, time1)\n    self.assertEqual(time4, time2)\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",  \"BossAll\",  \"OceanName\", \"LastChange\"],\n      [1,   \"Whale\",   3,       \"Arthur\",    \"Arthur\",  \"Neptune\",  \"Neptune\",  \"Indian\",    time3],\n      [2,   \"Manatee\", 2,       \"Poseidon\",  \"\",        \"Poseidon\", \"Poseidon\", \"ATLANTIC\",  time2],\n    ])\n\n  def test_last_modified_by_recipe(self):\n    user1 = {\n      'Name': 'Foo Bar',\n      'UserID': 1,\n      'UserRef': '1',\n      'StudentInfo': ['Students', 1],\n      'LinkKey': {},\n      'Origin': None,\n      'Email': 'foo.bar@getgrist.com',\n      'Access': 'owners',\n      'SessionID': 'u1',\n      'IsLoggedIn': True,\n      'ShareRef': None\n    }\n    user2 = {\n      'Name': 'Baz Qux',\n      'UserID': 2,\n      'UserRef': '2',\n      'StudentInfo': ['Students', 1],\n      'LinkKey': {},\n      'Origin': None,\n      'Email': 'baz.qux@getgrist.com',\n      'Access': 'owners',\n      'SessionID': 'u2',\n      'IsLoggedIn': True,\n      'ShareRef': None\n    }\n    # Use formula to store last modified by data (user name and email). Check that it works as expected.\n    self.load_sample(self.sample)\n    self.add_column('Creatures', 'LastModifiedBy', type='Text', isFormula=False,\n      formula=\"user.Name + ' <' + user.Email + '>'\", recalcWhen=RecalcWhen.MANUAL_UPDATES\n    )\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\", \"BossAll\", \"OceanName\", \"LastModifiedBy\"],\n      [1,   \"Dolphin\", 2,       \"Arthur\",    \"Arthur\",  \"Arthur\",  \"Arthur\",  \"Atlantic\",  \"\"],\n    ])\n\n    self.apply_user_action(\n      ['AddRecord', \"Creatures\", None, {\"Name\": \"Manatee\", \"Ocean\": 2}],\n      user=user1\n    )\n    self.apply_user_action(\n      ['UpdateRecord', \"Creatures\", 1, {\"Ocean\": 3}],\n      user=user2\n    )\n\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",  \"BossAll\",  \"OceanName\", \"LastModifiedBy\"],\n      [1,   \"Dolphin\", 3,       \"Arthur\",    \"Arthur\",  \"Neptune\",  \"Neptune\",  \"Indian\",    \"Baz Qux <baz.qux@getgrist.com>\"],\n      [2,   \"Manatee\", 2,       \"Poseidon\",  \"\",        \"Poseidon\", \"Poseidon\", \"Atlantic\",  \"Foo Bar <foo.bar@getgrist.com>\"],\n    ])\n\n    # An indirect change doesn't affect the user, but a direct change does.\n    self.apply_user_action(\n      ['UpdateRecord', \"Oceans\", 2, {\"Name\": \"ATLANTIC\"}],\n      user=user2\n    )\n    self.apply_user_action(\n      ['UpdateRecord', \"Creatures\", 1, {\"Name\": \"Whale\"}],\n      user=user1\n    )\n    self.assertTableData(\"Creatures\", data=[\n      [\"id\",\"Name\",    \"Ocean\", \"BossDef\",   \"BossNvr\", \"BossUpd\",  \"BossAll\",  \"OceanName\", \"LastModifiedBy\"],\n      [1,   \"Whale\",   3,       \"Arthur\",    \"Arthur\",  \"Neptune\",  \"Neptune\",  \"Indian\",    \"Foo Bar <foo.bar@getgrist.com>\"],\n      [2,   \"Manatee\", 2,       \"Poseidon\",  \"\",        \"Poseidon\", \"Poseidon\", \"ATLANTIC\",  \"Foo Bar <foo.bar@getgrist.com>\"],\n    ])\n\n  sample_desc_math = {\n    \"SCHEMA\": [\n      [1, \"Math\", [\n        col(1, \"A\", \"Numeric\", False),\n        col(2, \"B\", \"Numeric\", False),\n        col(3, \"C\", \"Numeric\", False, \"1/$A + 1/$B\", recalcDeps=[1]),\n      ]],\n    ],\n    \"DATA\": {\n    }\n  }\n  sample_math = testutil.parse_test_sample(sample_desc_math)\n\n  def test_triggers_on_error(self):\n    # In case of an error in a trigger formula can be reevaluated when new value is provided\n    self.load_sample(self.sample_math)\n    self.add_record(\"Math\", A=0, B=1)\n    self.assertTableData(\"Math\", data=[\n      [\"id\",  \"A\",  \"B\",  \"C\"],\n      [1,     0,    1,    div_error(0)],\n    ])\n    self.update_record(\"Math\", 1, A=1)\n    self.assertTableData(\"Math\", data=[\n      [\"id\", \"A\",   \"B\",  \"C\"],\n      [1,     1,    1,    2],\n    ])\n    # When the error is cased by external column, formula is not reevaluated\n    self.update_record(\"Math\", 1, A=2, B=0)\n    self.update_record(\"Math\", 1, A=1)\n    self.assertTableData(\"Math\", data=[\n      [\"id\", \"A\", \"B\", \"C\"],\n      [1, 1, 0, div_error(2)],\n    ])\n    self.update_record(\"Math\", 1, B=1)\n    self.assertTableData(\"Math\", data=[\n      [\"id\", \"A\", \"B\", \"C\"],\n      [1, 1, 1, div_error(2)],\n    ])\n\n\n  def test_traceback_available_for_trigger_formula(self):\n    # In case of an error engine is able to retrieve a traceback.\n    self.load_sample(self.sample_math)\n    self.add_record(\"Math\", A=0, B=0)\n    self.assertTableData(\"Math\", data=[\n      [\"id\",  \"A\",  \"B\",  \"C\"],\n      [1,     0,    0,    div_error(0)],\n    ])\n    message = 'float division by zero'\n    message += \"\"\"\n\nA `ZeroDivisionError` occurs when you are attempting to divide a value\nby zero either directly or by using some other mathematical operation.\n\nYou are dividing by the following term\n\n    rec.A\n\nwhich is equal to zero.\"\"\"\n    self.assertFormulaError(self.engine.get_formula_error('Math', 'C', 1),\n                            ZeroDivisionError, message,\n                            r\"1/rec\\.A \\+ 1/rec\\.B\")\n    self.update_record(\"Math\", 1, A=1)\n\n    # Updating B should remove the traceback from an error, but the error should remain.\n    self.update_record(\"Math\", 1, B=1)\n    self.assertTableData(\"Math\", data=[\n      [\"id\",  \"A\",  \"B\",  \"C\"],\n      [1,     1,    1,    div_error(0)],\n    ])\n    error = self.engine.get_formula_error('Math', 'C', 1)\n    self.assertFormulaError(error, ZeroDivisionError, 'float division by zero')\n    self.assertEqual(error.details, objtypes.RaisedException(ZeroDivisionError()).no_traceback().details)\n\n\n  def test_undo_should_restore_dependencies(self):\n    \"\"\"\n    Test case for a bug. Undo wasn't restoring trigger formula dependencies.\n    \"\"\"\n    self.load_sample(self.sample_math)\n    self.add_record(\"Math\", A=1, B=1)\n    self.assertTableData(\"Math\", data=[\n      [\"id\",  \"A\",  \"B\",  \"C\"],\n      [1,     1,    1,    1/1 + 1/1],\n    ])\n\n    # Remove deps from C.\n    out_actions = self.update_record(\"_grist_Tables_column\", 3, recalcDeps=None)\n    # Make sure that trigger is not fired.\n    self.update_record(\"Math\", 1, A=0.5)\n    self.assertTableData(\"Math\", data=[\n      [\"id\", \"A\",   \"B\",  \"C\"],\n      [1,     0.5,  1,    1/1 + 1/1], # C is not recalculated\n    ])\n\n    # Apply undo action.\n    self.apply_undo_actions(out_actions.undo)\n    # Invoke trigger by updating A, and make sure C is updated.\n    self.update_record(\"Math\", 1, A=0.2)\n    self.assertTableData(\"Math\", data=[\n      [\"id\",  \"A\",  \"B\",  \"C\"],\n      [1,     0.2,  1,    1/0.2 + 1/1], # C is recalculated\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_twoway_refs.py",
    "content": "# pylint:disable=too-many-lines\nimport unittest\nimport test_engine\nfrom test_engine import Table, Column\nimport useractions\n\n# Ids for project sample\napps = None\nbackend = None\nalice = None\nbob = None\n\n# Ids for pets sample\nRex = 1\nPluto = 2\nAzor = 3\nEmpty = 0\nAlice = 1\nBob = 2\nPenny = 3\nEmptyList = None\n\ndef uniqueReferences(rec):\n  return rec.reverseCol and rec.reverseCol.type.startswith('Ref:')\n\nclass TestTwoWayReferences(test_engine.EngineTestCase):\n\n  def get_col_rec(self, tableId, colId):\n    # Do simple lookup without creating any dependencies\n    t = self.engine.docmodel.columns.table\n    return t.get_record(t.get(tableId=tableId, colId=colId))\n\n  def loadSample(self):\n    self.apply_user_action([\"AddTable\", \"People\", [\n      {\"id\": \"Name\", \"type\": \"Text\"},\n    ]])\n    people_name_col = self.get_col_rec(tableId=\"People\", colId=\"Name\")\n    self.apply_user_action([\"AddTable\", \"Projects\", [\n      {\"id\": \"Name\",  \"type\": \"Text\"},\n      {\"id\": \"Owner\", \"type\": \"Ref:People\", \"visibleCol\":people_name_col.id},\n    ]])\n    owner_col = self.get_col_rec(tableId=\"Projects\", colId=\"Owner\")\n    self.apply_user_action([\"SetDisplayFormula\", \"Projects\", owner_col.id, None, \"$Owner.Name\"])\n\n    global apps, backend, alice, bob # pylint: disable=global-statement\n    alice = self.add_record(\"People\", Name=\"Alice\").retValues[0]\n    bob = self.add_record(\"People\", Name=\"Bob\").retValues[0]\n    apps = self.add_record(\"Projects\", Name=\"Apps\", Owner=alice).retValues[0]\n    backend = self.add_record(\"Projects\", Name=\"Backend\", Owner=bob).retValues[0]\n\n    self.assertTableData(\"Projects\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [1, \"Apps\", alice],\n      [2, \"Backend\", bob],\n    ])\n\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"Name\"],\n      [1, \"Alice\"],\n      [2, \"Bob\"],\n    ])\n\n\n  def loadReverseSample(self):\n    self.loadSample()\n    self.apply_user_action([\"AddReverseColumn\", 'Projects', 'Owner'])\n\n    self.assertTables([\n      Table(1, \"People\", 1, 0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(2, \"Name\", \"Text\", False, \"\", 0),\n        Column(7, \"Projects\", \"RefList:Projects\", False, \"\", 0),\n        Column(8, \"gristHelper_Display\", \"Any\", True, \"$Projects.Name\", 0),\n      ]),\n      Table(2, \"Projects\", 2, 0, columns=[\n        Column(3, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(4, \"Name\", \"Text\", False, \"\", 0),\n        Column(5, \"Owner\", \"Ref:People\", False, \"\", 0),\n        Column(6, \"gristHelper_Display\", \"Any\", True, \"$Owner.Name\", 0),\n      ]),\n    ])\n\n\n\n    self.assertTableData(\"Projects\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [apps, \"Apps\", alice],\n      [backend, \"Backend\", bob],\n    ])\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Projects\"],\n      [alice, \"Alice\", [apps]],\n      [bob, \"Bob\", [backend]],\n    ])\n\n\n  def add_people_ref(self, name, project_table=\"Projects\"):\n    # Add tester column\n    people_name_col = self.get_col_rec(tableId=\"People\", colId=\"Name\")\n    self.apply_user_action([\n      \"AddColumn\", project_table, name, {\n        \"type\": \"Ref:People\",\n        \"visibleCol\": people_name_col.id,\n        \"isFormula\": False\n      }\n    ])\n    new_col = self.get_col_rec(tableId=project_table, colId=name)\n    self.apply_user_action([\"SetDisplayFormula\",\n                            project_table, None, new_col.id, \"$%s.Name\" % name])\n    self.apply_user_action([\"AddReverseColumn\", project_table, new_col.colId])\n\n  def test_simple_updates_work(self):\n    self.loadReverseSample()\n\n    # Remove Alice as owner of Apps project\n    self.update_record(\"Projects\", apps, Owner=None)\n\n    self.assertTableData(\"Projects\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [apps, \"Apps\", 0],\n      [backend, \"Backend\", bob],\n    ])\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Projects\"],\n      [alice, \"Alice\", None],\n      [bob, \"Bob\", [backend]],\n    ])\n\n    # Now remove Bob as owner of Backend project\n    self.update_record(\"Projects\", backend, Owner=None)\n    self.assertTableData(\"Projects\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [apps, \"Apps\", 0],\n      [backend, \"Backend\", 0],\n    ])\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Projects\"],\n      [alice, \"Alice\", None],\n      [bob, \"Bob\", None],\n    ])\n\n    # Now add Alice as owner of both projects via People table.\n    self.update_record(\"People\", alice, Projects=[\"L\", apps, backend])\n    self.assertTableData(\"Projects\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [apps, \"Apps\", alice],\n      [backend, \"Backend\", alice],\n    ])\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Projects\"],\n      [alice, \"Alice\", [apps, backend]],\n      [bob, \"Bob\", None],\n    ])\n\n\n  def test_failes_to_update_unique_reflist(self):\n    self.loadReverseSample()\n\n    with self.assertRaises(Exception):\n      self.update_record(\"People\", bob, Projects=[\"L\", apps])\n\n  def test_clear_from_single_ref(self):\n    self.loadReverseSample()\n\n    # Remove owner from apps project\n    self.update_record(\"Projects\", apps, Owner=0)\n\n    self.assertTableData(\"Projects\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [apps, \"Apps\", 0],\n      [backend, \"Backend\", bob],\n    ])\n\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Projects\"],\n      [alice, \"Alice\", None],\n      [bob, \"Bob\", [backend]],\n    ])\n\n  def test_clear_ref_list(self):\n    self.loadReverseSample()\n\n    # Remove owner from apps project\n    self.update_record(\"People\", alice, Projects=None)\n\n    self.assertTableData(\"Projects\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [apps, \"Apps\", 0],\n      [backend, \"Backend\", bob],\n    ])\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Projects\"],\n      [alice, \"Alice\", None],\n      [bob, \"Bob\", [backend]],\n    ])\n\n  def test_creates_proper_names(self):\n    self.loadReverseSample()\n    self.add_people_ref(\"Tester\")\n\n    self.assertTables([\n      Table(1, \"People\", 1, 0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(2, \"Name\", \"Text\", False, \"\", 0),\n        Column(7, \"Projects\", \"RefList:Projects\", False, \"\", 0),\n        Column(8, \"gristHelper_Display\", \"Any\", True, \"$Projects.Name\", 0),\n        Column(11, \"Projects_Tester\", \"RefList:Projects\", False, \"\", 0),\n        Column(12, \"gristHelper_Display2\", \"Any\", True, \"$Projects_Tester.Name\", 0),\n      ]),\n      Table(2, \"Projects\", 2, 0, columns=[\n        Column(3, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(4, \"Name\", \"Text\", False, \"\", 0),\n        Column(5, \"Owner\", \"Ref:People\", False, \"\", 0),\n        Column(6, \"gristHelper_Display\", \"Any\", True, \"$Owner.Name\", 0),\n        Column(9, \"Tester\", \"Ref:People\", False, \"\", 0),\n        Column(10, \"gristHelper_Display2\", \"Any\", True, \"$Tester.Name\", 0),\n      ]),\n    ])\n\n    # Now change the name of a table (by adding a title to the rav view section)\n    projects_table = self.engine.docmodel.tables.table.get_record(2)\n    self.engine.docmodel.update([projects_table.rawViewSectionRef], title=\"Tasks\")\n    self.add_people_ref(\"PM\", \"Tasks\")\n\n    self.assertTables([\n      Table(1, \"People\", 1, 0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(2, \"Name\", \"Text\", False, \"\", 0),\n        Column(7, \"Projects\", \"RefList:Tasks\", False, \"\", 0),\n        Column(8, \"gristHelper_Display\", \"Any\", True, \"$Projects.Name\", 0),\n        Column(11, \"Projects_Tester\", \"RefList:Tasks\", False, \"\", 0),\n        Column(12, \"gristHelper_Display2\", \"Any\", True, \"$Projects_Tester.Name\", 0),\n        Column(15, \"Tasks\", \"RefList:Tasks\", False, \"\", 0),\n        Column(16, \"gristHelper_Display3\", \"Any\", True, \"$Tasks.Name\", 0),\n      ]),\n      Table(2, \"Tasks\", 2, 0, columns=[\n        Column(3, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(4, \"Name\", \"Text\", False, \"\", 0),\n        Column(5, \"Owner\", \"Ref:People\", False, \"\", 0),\n        Column(6, \"gristHelper_Display\", \"Any\", True, \"$Owner.Name\", 0),\n        Column(9, \"Tester\", \"Ref:People\", False, \"\", 0),\n        Column(10, \"gristHelper_Display2\", \"Any\", True, \"$Tester.Name\", 0),\n        Column(13, \"PM\", \"Ref:People\", False, \"\", 0),\n        Column(14, \"gristHelper_Display3\", \"Any\", True, \"$PM.Name\", 0),\n      ]),\n    ])\n\n\n  def test_checking_unique_values(self):\n    self.loadReverseSample()\n\n    # Unique values is turned on only on the People table in the Projects column.\n    projects = self.get_col_rec(tableId=\"People\", colId=\"Projects\")\n    owner = self.get_col_rec(tableId=\"Projects\", colId=\"Owner\")\n    self.assertTrue(uniqueReferences(projects))\n    self.assertFalse(uniqueReferences(owner))\n\n    # Owner is of type Ref and Projects of type RefList.\n    self.assertEqual(owner.type, \"Ref:People\")\n    self.assertEqual(projects.type, \"RefList:Projects\")\n\n    # Try moving all projects to Bob\n    with self.assertRaises(Exception):\n      self.update_record(\"People\", bob, Projects=[\"L\", apps, backend])\n\n    # We can't that, we need to first clear projects from Alice.\n    self.update_record(\"People\", alice, Projects=None)\n    self.update_record(\"People\", bob, Projects=[\"L\", apps, backend])\n\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Projects\"],\n      [alice, \"Alice\", None],\n      [bob, \"Bob\", [apps, backend]],\n    ])\n    self.assertTableData(\"Projects\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [apps, \"Apps\", bob],\n      [backend, \"Backend\", bob],\n    ])\n\n    # Now change the type of Projects in People to be Ref, this will destroy data, a little bit\n    # as Bob will only have the frist project.\n    self.apply_user_action([\"ModifyColumn\", \"People\", \"Projects\", {\"type\": \"Ref:Projects\"}])\n\n    projects = self.get_col_rec(tableId=\"People\", colId=\"Projects\")\n    owner = self.get_col_rec(tableId=\"Projects\", colId=\"Owner\")\n    # Make sure type was changed to Ref\n    self.assertEqual(owner.type, \"Ref:People\")\n    self.assertEqual(projects.type, \"Ref:Projects\")\n    self.assertEqual(uniqueReferences(projects), True)\n    self.assertEqual(uniqueReferences(owner), True)\n\n    # And data was updated, bob is no longer owner of Tests project.\n    # and the Projects column in the People table is now of type Ref\n    self.assertTableData(\"Projects\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [apps, \"Apps\", bob],\n      [backend, \"Backend\", 0],\n    ])\n\n    self.assertTableData(\"People\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Projects\"],\n      [alice, \"Alice\", 0],\n      [bob, \"Bob\", apps],\n    ])\n\n  def load_pets(self):\n    self.apply_user_action([\"AddTable\", \"Owners\", [\n      {\"id\": \"Name\", \"type\": \"Text\"},\n    ]])\n\n    # Add owner named Alice\n    self.apply_user_action([\"AddRecord\", \"Owners\", 1, {\"Name\": \"Alice\"}])\n    self.apply_user_action([\"AddRecord\", \"Owners\", 2, {\"Name\": \"Bob\"}])\n\n    # Add pets table with owner ref\n    self.apply_user_action([\"AddTable\", \"Pets\", [\n      {\"id\": \"Name\", \"type\": \"Text\"},\n      {\"id\": \"Owner\", \"type\": \"Ref:Owners\"},\n    ]])\n\n    # Add a pet named Rex with Bob as owner\n    self.apply_user_action([\"AddRecord\", \"Pets\", 1, {\"Name\": \"Rex\", \"Owner\": Bob}])\n\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\"],\n      [Alice, \"Alice\"],\n      [Bob, \"Bob\"],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Bob],\n    ])\n\n\n  def test_uniques(self):\n    self.load_pets()\n\n    # Add another dog\n    self.apply_user_action([\"AddRecord\", \"Pets\", Pluto, {\"Name\": \"Pluto\", \"Owner\": Bob}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Bob],\n      [Pluto, \"Pluto\", Bob],\n    ])\n\n    # Now set unique constraint on Owner column, by adding reverse column of type Ref.\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n    self.apply_user_action([\"ModifyColumn\", \"Owners\", \"Pets\", {\"type\": \"Ref:Pets\"}])\n\n    # Make sure that pluto has no owner now.\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Bob],\n      [Pluto, \"Pluto\", Empty],\n    ])\n\n    # Now try to set Pluto to Bob, it should fail as Rex is in Bob.\n    with self.assertRaises(Exception):\n      self.apply_user_action([\"UpdateRecord\", \"Pets\", Pluto, {\"Owner\": Bob}])\n\n    # So repeat it, but first remove Bob from Rex.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": None}])\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Pluto, {\"Owner\": Bob}])\n\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Empty],\n      [Pluto, \"Pluto\", Bob],\n    ])\n\n    # Convert Owners to RefList (but first remove data).\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Pluto, {\"Owner\": None}])\n    self.apply_user_action([\"ModifyColumn\", \"Pets\", \"Owner\", {\"type\": \"RefList:Owners\"}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", EmptyList],\n      [Pluto, \"Pluto\", EmptyList],\n    ])\n\n    # Now move Alice, Bob to Rex and make sure that works\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": ['L', Alice, Bob]}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", [Alice, Bob]],\n      [Pluto, \"Pluto\", EmptyList],\n    ])\n\n    # Now move Pluto to Bob, it should fail as Bob has Rex.\n    with self.assertRaises(Exception):\n      self.apply_user_action([\"UpdateRecord\", \"Pets\", Pluto, {\"Owner\": ['L', Bob]}])\n\n    # So repeat it, but first remove Bob from Rex.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": ['L', Alice]}])\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Pluto, {\"Owner\": ['L', Bob]}])\n\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\"],\n      [1, \"Alice\"],\n      [2, \"Bob\"],\n    ])\n\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", [Alice]],\n      [Pluto, \"Pluto\", [Bob]],\n    ])\n\n    # Now remove the unique constraint, by removing the reverse column.\n    self.apply_user_action([\"RemoveColumn\", 'Owners', 'Pets'])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", [Alice]],\n      [Pluto, \"Pluto\", [Bob]],\n    ])\n\n    # Both Alice and Bob will own Rex.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": ['L', Alice, Bob]}])\n    # Same for Pluto but in opposite order.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Pluto, {\"Owner\": ['L', Bob, Alice]}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", [Alice, Bob]],\n      [Pluto, \"Pluto\", [Bob, Alice]],\n    ])\n    # Now make it unique again (using reverse column) and see if it will clear the column properly.\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n    self.apply_user_action([\"ModifyColumn\", \"Owners\", \"Pets\", {\"type\": \"Ref:Pets\"}])\n\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", [Alice, Bob]],\n      [Pluto, \"Pluto\", EmptyList],\n    ])\n\n\n  def test_removes(self):\n    self.load_pets()\n    owner_col = self.get_col_rec(tableId=\"Pets\", colId=\"Owner\")\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n    pets_col = self.get_col_rec(tableId=\"Owners\", colId=\"Pets\")\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", [Rex]],\n    ])\n    # Try to remove it\n    self.apply_user_action([\"BulkRemoveRecord\", \"_grist_Tables_column\", [pets_col.id]])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\"],\n      [1, \"Alice\"],\n      [2, \"Bob\"],\n    ])\n\n    # Now add it back\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n    pets_col = self.get_col_rec(tableId=\"Owners\", colId=\"Pets\")\n\n    # And try to remove original column.\n    self.apply_user_action([\"BulkRemoveRecord\", \"_grist_Tables_column\", [owner_col.id]])\n\n\n  def test_designs(self):\n    self.load_pets()\n\n    Rex = 1\n    Empty = 0\n    Alice = 1\n    Bob = 2\n    EmptyList = None\n\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", [Rex]],\n    ])\n\n    # Now move Rex to Bob\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": Alice}])\n\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex]],\n      [2, \"Bob\", EmptyList],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [1, \"Rex\", Alice],\n    ])\n\n    # Now move Rex back to Bob, but by using Owners table, but first remove Alice from Rex.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": None}])\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Bob, {\"Pets\": ['L', Rex]}])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", [Rex]],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [1, \"Rex\", Bob],\n    ])\n\n    # Now remove Rex from Bob using Owners table.\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Bob, {\"Pets\": None}])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", EmptyList],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [1, \"Rex\", Empty],\n    ])\n\n    # Now convert Owners to RefList.\n    self.apply_user_action([\"ModifyColumn\", \"Pets\", \"Owner\", {\"type\": \"RefList:Owners\"}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [1, \"Rex\", EmptyList],\n    ])\n\n    # Set two owners for Rex.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": ['L', Alice, Bob]}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [1, \"Rex\", [Alice, Bob]],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex]],\n      [2, \"Bob\", [Rex]],\n    ])\n\n    # Now clear Rex from Alice.\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Alice, {\"Pets\": None}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [1, \"Rex\", [Bob]],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", [Rex]],\n    ])\n\n\n  def test_reverse_uniqueness(self):\n    self.load_pets()\n\n    # First modify Owner column to RefList\n    self.apply_user_action([\"ModifyColumn\", \"Pets\", \"Owner\", {\"type\": \"RefList:Owners\"}])\n    self.apply_user_action(['RenameColumn', 'Pets', 'Owner', 'Owners'])\n    # Add Pluto with Alice as an owner\n    self.apply_user_action([\"AddRecord\", \"Pets\", Pluto, {\"Name\": \"Pluto\", \"Owners\": ['L', Alice]}])\n    # Move Rex to both Alice and Bob.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owners\": ['L', Alice, Bob]}])\n\n    # Now do the reverse magic\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owners'])\n\n    # Now make sure we see the data\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owners\"],\n      [Rex, \"Rex\", [Alice, Bob]],\n      [Pluto, \"Pluto\", [Alice]],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex, Pluto]],\n      [2, \"Bob\", [Rex]],\n    ])\n\n    # Now make Pets.Owners column unique (Owners is a source column), by setting Owners.Pets to Ref.\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Owners\", \"Pets\", {\"type\": \"Ref:Pets\"}])\n    owner_col = self.get_col_rec(tableId=\"Pets\", colId=\"Owners\")\n    self.assertTrue(uniqueReferences(owner_col))\n\n    # It should clean data nicely.\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owners\"],\n      [Rex, \"Rex\", [Alice, Bob]],\n      [Pluto, \"Pluto\", EmptyList],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", Rex],\n      [2, \"Bob\", Rex],\n    ])\n\n    undo_actions = out_actions.get_repr()[\"undo\"]\n    out_actions = self.apply_user_action(['ApplyUndoActions', undo_actions])\n\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owners\"],\n      [Rex, \"Rex\", [Alice, Bob]],\n      [Pluto, \"Pluto\", [Alice]],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex, Pluto]],\n      [2, \"Bob\", [Rex]],\n    ])\n\n    # Now do the same for the target column, we will convert it to Ref.\n    self.apply_user_action([\"ModifyColumn\", \"Pets\", \"Owners\", {\"type\": \"Ref:Owners\"}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owners\"],\n      [Rex, \"Rex\", Alice],\n      [Pluto, \"Pluto\", Alice],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex, Pluto]],\n      [2, \"Bob\", EmptyList],\n    ])\n\n  def test_unlink_connection(self):\n    \"\"\"\n    This is somehow hidden feature. From the UI we can't unlink a connection, (we can only\n    remove one of the columns). But it is possible to do it via API. It was much easier to implement\n    then to prevent it, so it is allowed.\n    \"\"\"\n    self.load_pets()\n\n    # Create reverse column for Pets.Owner\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n    # Now unlink it immediately and allow duplicates on target column\n    self.apply_user_action(['ModifyColumn', 'Owners', 'Pets', {\n      'reverseCol': 0\n    }])\n    # Now move Rex to both Alice and Bob using Onwers table\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Alice, {\"Pets\": ['L', Rex]}])\n    # Make sure we see the data\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex]],\n      [2, \"Bob\", [Rex]],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Bob],\n    ])\n    # Now change Rex to Alice using Pets table\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": Alice}])\n    # Make sure we see the data\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex]],\n      [2, \"Bob\", [Rex]],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Alice],\n    ])\n\n  def test_reflist_to_ref_conversion(self):\n    self.apply_user_action([\"AddTable\", \"Employees\", [\n      {\"id\": \"Name\",    \"type\": \"Text\"},\n      {\"id\": \"Supervisor\",   \"type\": \"RefList:Employees\"},\n    ]])\n    # Add display column\n    supervisor_col = self.get_col_rec(tableId=\"Employees\", colId=\"Supervisor\")\n    self.apply_user_action([\"SetDisplayFormula\",\n                            \"Employees\", None, supervisor_col.id, \"$Supervisor.Name\"])\n    name_col = self.get_col_rec(tableId=\"Employees\", colId=\"Name\")\n    # Update visibleCol\n    self.engine.docmodel.update([supervisor_col], visibleCol=name_col.id)\n\n    Alice = 1\n    Bob = 2\n    Charlie = 3\n\n    # Add Alice and Bob and then make Bob a supervisor of Alice (one of)\n    self.apply_user_action([\"AddRecord\", \"Employees\", None, {\"Name\": \"Alice\"}])\n    self.apply_user_action([\"AddRecord\", \"Employees\", None, {\"Name\": \"Bob\"}])\n    self.apply_user_action([\"UpdateRecord\", \"Employees\", Alice, {\"Supervisor\": ['L', Bob]}])\n    self.apply_user_action([\"UpdateRecord\", \"Employees\", Bob, {\"Supervisor\": ['L']}])\n    self.apply_user_action([\"AddRecord\", \"Employees\", None, {\"Name\": \"Charlie\",\n      \"Supervisor\": ['L', Alice, Bob]}])\n\n    # Make sure we see the data\n    self.assertTableData(\"Employees\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Supervisor\"],\n      [Alice, \"Alice\", [Bob]],\n      [Bob, \"Bob\", EmptyList],\n      [Charlie, \"Charlie\", [Alice, Bob]],\n    ])\n\n    # Now change Supervisor to Ref, it shouldn't trim anything as we don't have 2-way references\n    self.apply_user_action([\"ModifyColumn\", \"Employees\", \"Supervisor\", {\"type\": \"Ref:Employees\"}])\n    self.assertTableData(\"Employees\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Supervisor\"],\n      [Alice, \"Alice\", Bob],\n      [Bob, \"Bob\", Empty],\n      [Charlie, \"Charlie\", Alice],    # TODO: is this what we want???\n    ])\n\n  def test_reassign_references(self):\n    self.load_pets()\n    # Add pluto\n    self.apply_user_action([\"AddRecord\", \"Pets\", Pluto, {\"Name\": \"Pluto\"}])\n\n    # Make reverse column\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Add pluto to bob, using Owners table\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Bob, {\"Pets\": ['L', Rex, Pluto]}])\n\n\n  def test_renames_of_reverse_cols(self):\n    self.load_pets()\n\n    # Add a reverse column\n    out_actions = self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Rename one column to check there are no errorrs. (Use a UserAction as from the frontend.)\n    pets_col = out_actions.retValues[0]['colRef']\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", pets_col, {\"label\": \"Bots\"}])\n\n    # Rename the other column.\n    owner_col = self.get_col_rec(tableId=\"Pets\", colId=\"Owner\")\n    self.apply_user_action([\"UpdateRecord\", \"_grist_Tables_column\", owner_col.id, {\"label\": \"Man\"}])\n\n    # This is our initial data.\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Man\"],\n      [1, \"Rex\", Bob],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Bots\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", [Rex]],\n    ])\n\n    # Update a reference, and check that the resulting data respected reverse references.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", 1, {\"Name\": \"Rex\", \"Man\": Alice}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Man\"],\n      [1, \"Rex\", Alice],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Bots\"],\n      [1, \"Alice\", [Rex]],\n      [2, \"Bob\", EmptyList],\n    ])\n\n\n  def test_honors_uniqueness(self):\n    self.load_pets()\n    # Add reverse column right away\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", [Rex]],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Bob],\n    ])\n    # Now take Rex away from Bob (using Owners table)\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Bob, {\"Pets\": EmptyList}])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", EmptyList],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Empty],\n    ])\n    # And give Rex to Alice, also using Owners table\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Alice, {\"Pets\": ['L', Rex]}])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex]],\n      [2, \"Bob\", EmptyList],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Alice],\n    ])\n    # Now try to fail, give Rex also to Bob\n    with self.assertRaises(Exception):\n      self.apply_user_action([\"UpdateRecord\", \"Owners\", Bob, {\"Pets\": ['L', Rex]}])\n\n\n  def test_checks_source_column_for_uniqueness(self):\n    self.load_pets()\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Now move Rex to Alice using Owners table but first clear Bob's record, it should work\n    # as engine will check source column not target column for uniqueness.\n    self.engine.apply_user_actions([useractions.from_repr(ua) for ua in [\n      ['UpdateRecord', 'Owners', Bob, {'Pets': None}],\n      ['UpdateRecord', 'Owners', Alice, {'Pets': ['L', Rex]}],\n    ]])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex]],\n      [2, \"Bob\", EmptyList],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Alice],\n    ])\n\n  def test_uses_new_column_after_modify(self):\n    self.load_pets()\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # The reverse column is RefList:Pets, change it to Ref:Pets and back to RefList:Pets\n    self.apply_user_action([\"ModifyColumn\", \"Owners\", \"Pets\", {\"type\": \"Ref:Pets\"}])\n    self.apply_user_action([\"ModifyColumn\", \"Owners\", \"Pets\", {\"type\": \"RefList:Pets\"}])\n\n    # Make sure data is still valid\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", [Rex]],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Bob],\n    ])\n\n    # Now clear Bob from Rex via Pets table.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": None}])\n\n    # Make sure data is still valid\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", EmptyList],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Empty],\n    ])\n\n\n  def test_clears_flags_after_removal(self):\n    self.load_pets()\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # The reverse column is RefList:Owner with a unique flag set on it.\n    pets_col = self.get_col_rec(tableId=\"Owners\", colId=\"Pets\")\n    self.assertTrue(uniqueReferences(pets_col))\n    self.assertEqual(pets_col.type, \"RefList:Pets\")\n\n    # Now remove the original column.\n    self.apply_user_action([\"RemoveColumn\", 'Pets', 'Owner'])\n    pets_col = self.get_col_rec(tableId=\"Owners\", colId=\"Pets\")\n\n    # Unique flag should be cleared.\n    self.assertEqual(pets_col.type, \"RefList:Pets\")\n    self.assertFalse(uniqueReferences(pets_col), \"Flag is not cleared\")\n\n    # And add it back, but through Owners table\n    self.apply_user_action([\"AddReverseColumn\", 'Owners', 'Pets'])\n    owner_col = self.get_col_rec(tableId=\"Pets\", colId=\"Owners\")\n\n    # It should be RefList:Owners and unique flag should be cleared.\n    self.assertEqual(owner_col.type, \"RefList:Owners\")\n    self.assertFalse(uniqueReferences(owner_col))\n\n\n  def test_break_connection_when_type_is_changed_in_source(self):\n    self.load_pets()\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Now change the type of source column to point to a different table.\n    with self.assertRaises(ValueError):\n      self.apply_user_action([\"ModifyColumn\", \"Pets\", \"Owner\", {\"type\": \"Ref:Pets\"}])\n    self.assertEqual(self.get_col_rec(tableId=\"Pets\", colId=\"Owner\").type, \"Ref:Owners\")\n\n    # To change type, have to break the reference explicitly.\n    self.apply_user_action([\"ModifyColumn\", \"Pets\", \"Owner\", {\"type\": \"Ref:Pets\", \"reverseCol\": 0}])\n\n    # And make sure that the connection is broken.\n    owner_col = self.get_col_rec(tableId=\"Pets\", colId=\"Owner\")\n    pets_col = self.get_col_rec(tableId=\"Owners\", colId=\"Pets\")\n\n    self.assertEqual(owner_col.type, \"Ref:Pets\")\n    self.assertFalse(pets_col.reverseCol)\n    self.assertFalse(owner_col.reverseCol)\n\n  def test_break_connection_when_type_is_changed_in_target(self):\n    self.load_pets()\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Now change the type of target column to point to a different table.\n    with self.assertRaises(ValueError):\n      self.apply_user_action([\"ModifyColumn\", \"Owners\", \"Pets\", {\"type\": \"Ref:Owners\"}])\n\n    # To change type, have to break the reference explicitly.\n    self.apply_user_action(\n        [\"ModifyColumn\", \"Owners\", \"Pets\", {\"type\": \"Ref:Owners\", \"reverseCol\": 0}])\n\n    # And make sure that the connection is broken.\n    owner_col = self.get_col_rec(tableId=\"Pets\", colId=\"Owner\")\n    pets_col = self.get_col_rec(tableId=\"Owners\", colId=\"Pets\")\n\n    self.assertEqual(pets_col.type, \"Ref:Owners\")\n    self.assertFalse(pets_col.reverseCol)\n    self.assertFalse(owner_col.reverseCol)\n\n  def test_can_delete_target_table(self):\n    self.load_pets()\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Remove visible column, to avoid convertions\n    self.apply_user_action([\"ModifyColumn\", \"Pets\", \"Owner\",\n                            {\"visibleCol\": None, \"displayCol\": None}])\n    # Same for the reverse\n    self.apply_user_action([\"ModifyColumn\", \"Owners\", \"Pets\",\n                            {\"visibleCol\": None, \"displayCol\": None}])\n\n    # Now remove the Owners table\n    self.apply_user_action([\"RemoveTable\", 'Owners'])\n\n    # And make sure that the reverse column is removed.\n    pets_col = self.get_col_rec(tableId=\"Pets\", colId=\"Owner\")\n    self.assertFalse(pets_col.reverseCol)\n\n  def test_can_delete_source_table(self):\n    self.load_pets()\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Remove visible column, to avoid convertions\n    self.apply_user_action([\"ModifyColumn\", \"Pets\", \"Owner\",\n                            {\"visibleCol\": None, \"displayCol\": None}])\n    # Same for the reverse\n    self.apply_user_action([\"ModifyColumn\", \"Owners\", \"Pets\",\n                            {\"visibleCol\": None, \"displayCol\": None}])\n\n    # Now remove the Pets table\n    self.apply_user_action([\"RemoveTable\", 'Pets'])\n\n    # And make sure that the reverse column is removed.\n    owners_col = self.get_col_rec(tableId=\"Owners\", colId=\"Pets\")\n    self.assertFalse(owners_col.reverseCol)\n    self.assertEqual(owners_col.type, \"Text\")\n\n  def test_keeps_user_order_for_ref_list(self):\n    self.load_pets()\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Add Azor and Pluto\n    self.apply_user_action([\"AddRecord\", \"Pets\", Azor, {\"Name\": \"Azor\"}])\n    self.apply_user_action([\"AddRecord\", \"Pets\", Pluto, {\"Name\": \"Pluto\"}])\n\n    # Remove Rex from Bob, using Pets table\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": None}])\n\n    # Now move all pets to Alice but in different order\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Alice, {\"Pets\": ['L', Pluto, Azor, Rex]}])\n\n    # Make sure we see the data\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Pluto, Azor, Rex]],\n      [2, \"Bob\", EmptyList],\n    ])\n\n  def test_track_changes_after_type_undo(self):\n    self.load_pets()\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # And now move Rex to Alice using Pets table\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": Alice}])\n\n    # And check owners table\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex]],\n      [2, \"Bob\", EmptyList],\n    ])\n\n    # Now change this column to Text and then undo it, this will break the connection\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Owners\", \"Pets\", {\"type\": \"Text\",\n      \"reverseCol\": 0}])\n\n    # Do the undo.\n    undo_actions = out_actions.get_repr()[\"undo\"]\n    self.apply_user_action(['ApplyUndoActions', undo_actions])\n\n    # And now move Rex to Bob using Pets table\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": Bob}])\n\n    # And check owners table\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", [Rex]],\n    ])\n\n  def test_reverse_of_invalid_refs(self):\n    self.load_pets()\n\n    # Check that if we have an invalid value in the Ref column, it's handled on adding a reverse.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": \"invalid\"}])\n    self.apply_user_action([\"AddRecord\", \"Pets\", Pluto, {\"Name\": \"Pluto\", \"Owner\": Bob}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", \"invalid\"],\n      [Pluto, \"Pluto\", Bob],\n    ])\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", [Pluto]],\n    ])\n\n    # Check that setting an invalid value is handled in the presence of a reverse column.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Pluto, {\"Owner\": \"invalid2\"}])\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": Alice}])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Alice],\n      [Pluto, \"Pluto\", \"invalid2\"],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex]],\n      [2, \"Bob\", EmptyList],\n    ])\n\n    # Check that setting an invalid value on a ReferenceList is handled.\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Bob, {\"Pets\": ['L', Pluto]}])\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Alice, {\"Pets\": \"invalid3\"}])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", \"invalid3\"],\n      [2, \"Bob\", [Pluto]],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Empty],\n      [Pluto, \"Pluto\", Bob],\n    ])\n\n  def test_display_cols_for_type_changes(self):\n    self.load_pets()\n\n    # Initially no displayCol.\n    owner_col = self.get_col_rec(tableId=\"Pets\", colId=\"Owner\")\n    self._check_display_col(owner_col, \"Ref:Owners\", None, None)\n\n    # A reverseCol has a displayCol by default.\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n    pets_col = self.get_col_rec(tableId=\"Owners\", colId=\"Pets\")\n    self._check_display_col(pets_col, \"RefList:Pets\", \"Name\", \"$Pets.Name\")\n\n    # Change to single Ref, check that displayCol is unchanged.\n    self.apply_user_action([\"ModifyColumn\", 'Owners', 'Pets', {\"type\": \"Ref:Pets\"}])\n    self._check_display_col(pets_col, \"Ref:Pets\", \"Name\", \"$Pets.Name\")\n\n    # Change to wrong Ref; check that displayCol is unset.\n    out_actions = self.apply_user_action(\n        [\"ModifyColumn\", 'Owners', 'Pets', {\"type\": \"Ref:Owners\", \"reverseCol\": 0}])\n    self._check_display_col(pets_col, \"Ref:Owners\", None, None)\n\n    # Undo.\n    undo_actions = out_actions.get_repr()[\"undo\"]\n    out_actions = self.apply_user_action(['ApplyUndoActions', undo_actions])\n    self._check_display_col(pets_col, \"Ref:Pets\", \"Name\", \"$Pets.Name\")\n\n    # Change to different type; again displayCol should get unset.\n    out_actions = self.apply_user_action(\n        [\"ModifyColumn\", 'Owners', 'Pets', {\"type\": \"Numeric\", \"reverseCol\": 0}])\n    self._check_display_col(pets_col, \"Numeric\", None, None)\n\n\n  def _check_display_col(self, ref_col, expect_type, expect_visible_col_id, expect_display_formula):\n    t = self.engine.docmodel.columns.table\n    self.assertEqual(ref_col.type, expect_type)\n    visible_col_id = t.get_record(ref_col.visibleCol.id).colId if ref_col.visibleCol else None\n    display_formula = t.get_record(ref_col.displayCol.id).formula if ref_col.displayCol else None\n    self.assertEqual(visible_col_id, expect_visible_col_id)\n    self.assertEqual(display_formula, expect_display_formula)\n\n\n\n  def test_convert_column(self):\n    # There was a bug with changing RefList to Ref using the CopyFromColumn action. In case of an\n    # error in column, the reverse column wasn't updated properly.\n    self.load_pets()\n\n    # Rename Bob to Roger and add Penny\n    Roger = Bob\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Roger, {\"Name\": \"Roger\"}])\n    self.apply_user_action([\"AddRecord\", \"Owners\", Penny, {\"Name\": \"Penny\"}])\n\n    # Add Pluto owned by Penny and Azor owned by Alice\n    self.apply_user_action([\"AddRecord\", \"Pets\", Pluto, {\"Name\": \"Pluto\", \"Owner\": Penny}])\n    self.apply_user_action([\"AddRecord\", \"Pets\", Azor, {\"Name\": \"Azor\", \"Owner\": Alice}])\n\n    # Now add reverse column Pets to table Owners (by using Owner column in Pets table)\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Assert table data\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\"],\n      [1, \"Alice\", [Azor]],\n      [2, \"Roger\", [Rex]],\n      [3, \"Penny\", [Pluto]],\n    ])\n\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Roger],\n      [Pluto, \"Pluto\", Penny],\n      [Azor, \"Azor\", Alice],\n    ])\n\n    # Now remove Pluto from Penny and add it to Roger.\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Penny, {\"Pets\": None}])\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Roger, {\"Pets\": ['L', Rex, Pluto]}])\n\n    # Take snapshot of data.\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\"],\n      [1, \"Alice\", [Azor]],\n      [2, \"Roger\", [Rex, Pluto]],\n      [3, \"Penny\", EmptyList],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Roger],\n      [Pluto, \"Pluto\", Roger],\n      [Azor, \"Azor\", Alice],\n    ])\n\n    # Now generate errors using CopyFromColumn action. We won't reproduce the full flow of type\n    # conversion (as it requires node engine), so we will add dummy column and convert it from it.\n\n    # Add dummy column with semi valid values.\n    self.apply_user_action([\"AddColumn\", \"Owners\", \"Dummy\", {\"type\": \"Ref:Pets\"}])\n\n    # Generate almost good data, trim list with single value, and add strings for list with\n    # multiple values.\n    self.apply_user_action([\"BulkUpdateRecord\", \"Owners\", [1, 2, 3], {\n      \"Dummy\": [Azor, 'Rex, Pluto', Empty]\n    }])\n\n    # And than use CopyFromColumn to copy that from Dummy column to Pets column transforming\n    # it in the process. There was a bug here, this action was modifying data directly, bypassing\n    # two way references.\n    pets_col = self.get_col_rec(tableId=\"Pets\", colId=\"Owner\")\n    self.apply_user_action(\n      [\"CopyFromColumn\", \"Owners\", \"Dummy\", \"Pets\", None]\n    )\n\n    # And make sure we see the breakage.\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\"],\n      [1, \"Alice\", [Azor]],\n      [2, \"Roger\", \"Rex, Pluto\"],\n      [3, \"Penny\", EmptyList],\n    ])\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Empty],\n      [Pluto, \"Pluto\", Empty],\n      [Azor, \"Azor\", Alice],\n    ])\n\n  def test_back_update_empty_column(self):\n    \"\"\"\n    There was a bug. When user cretes a reverse column for an empty column, and then updates the\n    reverse column first, the empty column wasn't updated (as it was seen as empty).\n    \"\"\"\n\n    # Load pets sample\n    self.load_pets()\n\n    # Remove owner and add it back as empty column.\n    self.apply_user_action([\"RemoveColumn\", \"Pets\", \"Owner\"])\n    self.apply_user_action([\"AddColumn\", \"Pets\", \"Owner\", {\n      \"type\": \"Ref:Owners\",\n      \"isFormula\": True,\n      \"formula\": '',\n    }])\n\n    # Now add reverse column for Owner\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # And now add Rex with Alice as an owner using Owners table\n    self.apply_user_action([\"UpdateRecord\", \"Owners\", Alice, {\"Pets\": ['L', Rex]}])\n\n    # Make sure we see the data\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Rex]],\n      [2, \"Bob\", EmptyList],\n    ])\n\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Alice],\n    ])\n\n  def test_back_loop(self):\n    \"\"\"\n    Test that updating reverse column doesn't cause infinite loop.\n    \"\"\"\n\n    # Load pets sample.\n    self.load_pets()\n\n    # Add reverse column for Owner.\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Convert Pets to Ref:Owners.\n    self.apply_user_action([\"ModifyColumn\", \"Owners\", \"Pets\", {\"type\": \"Ref:Pets\"}])\n\n    # Check the data.\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [1, \"Rex\", Bob],\n    ])\n\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", Empty],\n      [2, \"Bob\", Rex],\n    ])\n\n    # Now move Rex to Alice using Pets table.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Rex, {\"Owner\": Alice}])\n\n\n  def test_remove_in_bulk(self):\n    \"\"\"\n    Test that we can remove many rows at the same time. PReviously it ended up in an error,\n    as the reverse column was trying to update the removed row.\n    \"\"\"\n\n    # Load pets sample.\n    self.load_pets()\n\n    # Add another dog.\n    self.apply_user_action([\"AddRecord\", \"Pets\", Pluto, {\"Name\": \"Pluto\"}])\n\n    # Add reverse column for Owner.\n    self.apply_user_action([\"AddReverseColumn\", 'Pets', 'Owner'])\n\n    # Add Pluto to Bob.\n    self.apply_user_action([\"UpdateRecord\", \"Pets\", Pluto, {\"Owner\": Alice}])\n\n    # Test the data.\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n      [Rex, \"Rex\", Bob],\n      [Pluto, \"Pluto\", Alice],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", [Pluto]],\n      [2, \"Bob\", [Rex]],\n    ])\n\n    # Now remove both dogs.\n    self.apply_user_action([\"BulkRemoveRecord\", \"Pets\", [Rex, Pluto]])\n\n    # Make sure we see the data.\n    self.assertTableData(\"Pets\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Owner\"],\n    ])\n    self.assertTableData(\"Owners\", cols=\"subset\", data=[\n      [\"id\", \"Name\", \"Pets\"],\n      [1, \"Alice\", EmptyList],\n      [2, \"Bob\", EmptyList],\n    ])\n\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_twowaymap.py",
    "content": "import unittest\nimport twowaymap\n\nclass TestTwoWayMap(unittest.TestCase):\n  def assertTwoWayMap(self, twmap, forward, reverse):\n    map_repr = (\n      { k: twmap.lookup_left(k) for k in twmap.left_all() },\n      { k: twmap.lookup_right(k) for k in twmap.right_all() }\n    )\n    self.assertEqual(map_repr, (forward, reverse))\n\n  def test_set_list(self):\n    tmap = twowaymap.TwoWayMap(left=set, right=list)\n\n    self.assertFalse(tmap)\n    tmap.insert(1, \"a\")\n    self.assertTrue(tmap)\n    self.assertTwoWayMap(tmap, {1: [\"a\"]}, {\"a\": {1}})\n\n    tmap.insert(1, \"a\")   # should be a no-op, since this pair already exists\n    tmap.insert(1, \"b\")\n    tmap.insert(2, \"a\")\n    self.assertTwoWayMap(tmap, {1: [\"a\", \"b\"], 2: [\"a\"]}, {\"a\": {1,2}, \"b\": {1}})\n\n    tmap.insert(1, \"b\")\n    tmap.insert(2, \"b\")\n    self.assertTwoWayMap(tmap, {1: [\"a\", \"b\"], 2: [\"a\", \"b\"]}, {\"a\": {1,2}, \"b\": {1,2}})\n\n    tmap.remove(1, \"b\")\n    tmap.remove(2, \"b\")\n    self.assertTwoWayMap(tmap, {1: [\"a\"], 2: [\"a\"]}, {\"a\": {1,2}})\n\n    tmap.insert(1, \"b\")\n    tmap.insert(2, \"b\")\n    tmap.remove_left(1)\n    self.assertTwoWayMap(tmap, {2: [\"a\", \"b\"]}, {\"a\": {2}, \"b\": {2}})\n\n    tmap.insert(1, \"a\")\n    tmap.insert(2, \"b\")\n    tmap.remove_right(\"b\")\n    self.assertTwoWayMap(tmap, {1: [\"a\"], 2: [\"a\"]}, {\"a\": {1,2}})\n\n    self.assertTrue(tmap)\n    tmap.clear()\n    self.assertTwoWayMap(tmap, {}, {})\n    self.assertFalse(tmap)\n\n  def test_set_single(self):\n    tmap = twowaymap.TwoWayMap(left=set, right=\"single\")\n\n    self.assertFalse(tmap)\n    tmap.insert(1, \"a\")\n    self.assertTrue(tmap)\n    self.assertTwoWayMap(tmap, {1: \"a\"}, {\"a\": {1}})\n\n    tmap.insert(1, \"a\")   # should be a no-op, since this pair already exists\n    tmap.insert(1, \"b\")\n    tmap.insert(2, \"a\")\n    self.assertTwoWayMap(tmap, {1: \"b\", 2: \"a\"}, {\"a\": {2}, \"b\": {1}})\n\n    tmap.insert(1, \"b\")\n    tmap.insert(2, \"b\")\n    self.assertTwoWayMap(tmap, {1: \"b\", 2: \"b\"}, {\"b\": {1,2}})\n\n    tmap.remove(1, \"b\")\n    self.assertTwoWayMap(tmap, {2: \"b\"}, {\"b\": {2}})\n    tmap.remove(2, \"b\")\n    self.assertTwoWayMap(tmap, {}, {})\n\n    tmap.insert(1, \"b\")\n    tmap.insert(2, \"b\")\n    self.assertTwoWayMap(tmap, {1: \"b\", 2: \"b\"}, {\"b\": {1,2}})\n    tmap.remove_left(1)\n    self.assertTwoWayMap(tmap, {2: \"b\"}, {\"b\": {2}})\n\n    tmap.insert(1, \"a\")\n    tmap.insert(2, \"b\")\n    tmap.remove_right(\"b\")\n    self.assertTwoWayMap(tmap, {1: \"a\"}, {\"a\": {1}})\n\n    self.assertTrue(tmap)\n    tmap.clear()\n    self.assertTwoWayMap(tmap, {}, {})\n    self.assertFalse(tmap)\n\n  def test_strict_list(self):\n    tmap = twowaymap.TwoWayMap(left=\"strict\", right=list)\n\n    self.assertFalse(tmap)\n    tmap.insert(1, \"a\")\n    self.assertTrue(tmap)\n    self.assertTwoWayMap(tmap, {1: [\"a\"]}, {\"a\": 1})\n\n    tmap.insert(1, \"a\")   # should be a no-op, since this pair already exists\n    tmap.insert(1, \"b\")\n    with self.assertRaises(ValueError):\n      tmap.insert(2, \"a\")\n    self.assertTwoWayMap(tmap, {1: [\"a\", \"b\"]}, {\"a\": 1, \"b\": 1})\n\n    tmap.insert(1, \"b\")\n    with self.assertRaises(ValueError):\n      tmap.insert(2, \"b\")\n    tmap.insert(2, \"c\")\n    self.assertTwoWayMap(tmap, {1: [\"a\", \"b\"], 2: [\"c\"]}, {\"a\": 1, \"b\": 1, \"c\": 2})\n\n    tmap.remove(1, \"b\")\n    self.assertTwoWayMap(tmap, {1: [\"a\"], 2: [\"c\"]}, {\"a\": 1, \"c\": 2})\n    tmap.remove(2, \"b\")\n    self.assertTwoWayMap(tmap, {1: [\"a\"], 2: [\"c\"]}, {\"a\": 1, \"c\": 2})\n\n    tmap.insert(1, \"b\")\n    with self.assertRaises(ValueError):\n      tmap.insert(2, \"b\")\n    self.assertTwoWayMap(tmap, {1: [\"a\", \"b\"], 2: [\"c\"]}, {\"a\": 1, \"b\": 1, \"c\": 2})\n    tmap.remove_left(1)\n    self.assertTwoWayMap(tmap, {2: [\"c\"]}, {\"c\": 2})\n\n    tmap.insert(1, \"a\")\n    tmap.insert(2, \"b\")\n    tmap.remove_right(\"b\")\n    self.assertTwoWayMap(tmap, {1: [\"a\"], 2: [\"c\"]}, {\"a\": 1, \"c\": 2})\n\n    self.assertTrue(tmap)\n    tmap.clear()\n    self.assertTwoWayMap(tmap, {}, {})\n    self.assertFalse(tmap)\n\n  def test_strict_single(self):\n    tmap = twowaymap.TwoWayMap(left=\"strict\", right=\"single\")\n    tmap.insert(1, \"a\")\n    tmap.insert(2, \"b\")\n    tmap.insert(2, \"c\")\n    self.assertTwoWayMap(tmap, {1: \"a\", 2: \"c\"}, {\"a\": 1, \"c\": 2})\n    with self.assertRaises(ValueError):\n      tmap.insert(2, \"a\")\n    tmap.insert(2, \"c\")   # This pair already exists, so not an error.\n    self.assertTwoWayMap(tmap, {1: \"a\", 2: \"c\"}, {\"a\": 1, \"c\": 2})\n\n  def test_nonhashable(self):\n    # Test that we don't get into an inconsistent state if we attempt to use a non-hashable value.\n    tmap = twowaymap.TwoWayMap(left=list, right=list)\n    tmap.insert(1, \"a\")\n    self.assertTwoWayMap(tmap, {1: [\"a\"]}, {\"a\": [1]})\n\n    with self.assertRaises(TypeError):\n      tmap.insert(1, {})\n    with self.assertRaises(TypeError):\n      tmap.insert({}, \"a\")\n\n    self.assertTwoWayMap(tmap, {1: [\"a\"]}, {\"a\": [1]})\n\n\nif __name__ == \"__main__\":\n  unittest.main()\n"
  },
  {
    "path": "sandbox/grist/test_types.py",
    "content": "# -*- coding: utf-8 -*-\n# pylint: disable=line-too-long\nimport logging\n\nimport testutil\nimport test_engine\n\nlog = logging.getLogger(__name__)\n\nclass TestTypes(test_engine.EngineTestCase):\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Types\", [\n        [21, \"text\",    \"Text\",    False, \"\", \"\", \"\"],\n        [22, \"numeric\", \"Numeric\", False, \"\", \"\", \"\"],\n        [23, \"int\",     \"Int\",     False, \"\", \"\", \"\"],\n        [24, \"bool\",    \"Bool\",    False, \"\", \"\", \"\"],\n        [25, \"date\",    \"Date\",    False, \"\", \"\", \"\"]\n      ]],\n      [2, \"Formulas\", [\n        [30, \"division\", \"Any\",    True,  \"Types.lookupOne(id=18).numeric / 2\", \"\", \"\"]\n      ]]\n    ],\n    \"DATA\": {\n      \"Types\": [\n        [\"id\", \"text\",     \"numeric\",  \"int\",      \"bool\",     \"date\"],\n        [11,   \"New York\", \"New York\", \"New York\", \"New York\", \"New York\"],\n        [12,   u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\"],\n        [13,   False,      False,      False,      False,      False],\n        [14,   True,       True,       True,       True,       True],\n        [15,   1509556595, 1509556595, 1509556595, 1509556595, 1509556595],\n        [16,   8.153,      8.153,      8.153,      8.153,      8.153],\n        [17,   0,          0,          0,          0,          0],\n        [18,   1,          1,          1,          1,          1],\n        [19,   \"\",         \"\",         \"\",         \"\",         \"\"],\n        [20,   None,       None,       None,       None,       None]],\n      \"Formulas\": [\n        [\"id\"],\n        [1]]\n    },\n  })\n  all_row_ids = [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]\n\n  def test_update_typed_cells(self):\n    \"\"\"\n    Tests that updated typed values are set as expected in the sandbox. Types should follow\n    the rules:\n     - After updating a cell with a value of a type compatible to the column type,\n       the cell value should have the column's standard type\n     - Otherwise, the cell value should have the type AltText\n    \"\"\"\n    self.load_sample(self.sample)\n\n    out_actions = self.apply_user_action([\"BulkUpdateRecord\", \"Types\", self.all_row_ids, {\n      \"text\":    [None, \"\", 1, 0, 8.153, 1509556595, True, False, u\"Chîcágö\", \"New York\"],\n      \"numeric\": [None, \"\", 1, 0, 8.153, 1509556595, True, False, u\"Chîcágö\", \"New York\"],\n      \"int\":     [None, \"\", 1, 0, 8.153, 1509556595, True, False, u\"Chîcágö\", \"New York\"],\n      \"bool\":    [None, \"\", 1, 0, 8.153, 1509556595, True, False, u\"Chîcágö\", \"New York\"],\n      \"date\":    [None, \"\", 1, 0, 8.153, 1509556595, True, False, u\"2019-01-22 00:47:39\", \"New York\"]\n    }])\n\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [[\"BulkUpdateRecord\", \"Types\", self.all_row_ids, {\n        \"text\":    [None,\"\",\"1\",\"0\",\"8.153\",\"1509556595\",\"True\",\"False\",u\"Chîcágö\",\"New York\"],\n        \"numeric\": [None, None, 1.0, 0.0, 8.153, 1509556595.0, 1.0, 0.0, u\"Chîcágö\", \"New York\"],\n        \"int\":     [None, None, 1, 0, 8, 1509556595, 1, 0, u\"Chîcágö\", \"New York\"],\n        \"bool\":    [False, False, True, False, True, True, True, False, u\"Chîcágö\", \"New York\"],\n        \"date\":    [None, None, 1.0, 0.0, 8.153, 1509556595.0, 1.0, 0.0, 1548115200.0, \"New York\"]\n        }],\n        [\"UpdateRecord\", \"Formulas\", 1, {\"division\": 0.0}],\n      ],\n      \"undo\": [[\"BulkUpdateRecord\", \"Types\", self.all_row_ids, {\n        \"text\":    [\"New York\", u\"Chîcágö\", False, True, 1509556595, 8.153, 0, 1, \"\", None],\n        \"numeric\": [\"New York\", u\"Chîcágö\", False, True, 1509556595, 8.153, 0, 1, \"\", None],\n        \"int\":     [\"New York\", u\"Chîcágö\", False, True, 1509556595, 8.153, 0, 1, \"\", None],\n        \"bool\":    [\"New York\", u\"Chîcágö\", False, True, 1509556595, 8.153, False, True, \"\", None],\n        \"date\":    [\"New York\", u\"Chîcágö\", False, True, 1509556595, 8.153, 0, 1, \"\", None]\n        }],\n        [\"UpdateRecord\", \"Formulas\", 1, {\"division\": 0.5}],\n      ]\n    })\n\n    self.assertTableData(\"Types\", data=[\n      [\"id\", \"text\",       \"numeric\",  \"int\",      \"bool\",     \"date\"],\n      [11,   None,         None,       None,       False,      None],\n      [12,   \"\",           None,       None,       False,      None],\n      [13,   \"1\",          1.0,        1,          True,       1.0],\n      [14,   \"0\",          0.0,        0,          False,      0.0],\n      [15,   \"8.153\",      8.153,      8,          True,       8.153],\n      [16,   \"1509556595\", 1509556595, 1509556595, True,       1509556595.0],\n      [17,   \"True\",       1.0,        1,          True,       1.0],\n      [18,   \"False\",      0.0,        0,          False,      0.0],\n      [19,   u\"Chîcágö\",    u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  1548115200.0],\n      [20,   \"New York\",   \"New York\", \"New York\", \"New York\", \"New York\"]\n    ])\n\n\n  def test_text_conversions(self):\n    \"\"\"\n    Tests that column type changes occur as expected in the sandbox:\n     - Resulting cell values should all be Text\n     - Only non-compatible values should appear in the resulting BulkUpdateRecord\n    \"\"\"\n    self.load_sample(self.sample)\n\n    # Test Text -> Text conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"text\", { \"type\" : \"Text\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [],\n      \"undo\": []\n    })\n\n    # Test Numeric -> Text conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"numeric\", { \"type\" : \"Text\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Text\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 15, 16, 17, 18],\n          {\"numeric\": [\"False\", \"True\", \"1509556595\", \"8.153\", \"0\", \"1\"]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Text\"}],\n        [\"UpdateRecord\", \"Formulas\", 1, {\"division\": [\"E\", \"TypeError\"]}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 15, 16, 17, 18],\n          {\"numeric\": [False, True, 1509556595, 8.153, 0, 1]}],\n        [\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Numeric\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Numeric\"}],\n        [\"UpdateRecord\", \"Formulas\", 1, {\"division\": 0.5}],\n      ]\n    })\n\n    # Test Int -> Text conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"int\", { \"type\" : \"Text\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"int\", {\"type\": \"Text\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 15, 16, 17, 18],\n          {\"int\": [\"False\", \"True\", \"1509556595\", \"8.153\", \"0\", \"1\"]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 23, {\"type\": \"Text\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 15, 16, 17, 18],\n          {\"int\": [False, True, 1509556595, 8.153, 0, 1]}],\n        [\"ModifyColumn\", \"Types\", \"int\", {\"type\": \"Int\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 23, {\"type\": \"Int\"}],\n      ]\n    })\n\n    # Test Bool -> Text\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"bool\", { \"type\" : \"Text\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"bool\", {\"type\": \"Text\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 15, 16, 17, 18],\n          {\"bool\": [\"False\", \"True\", \"1509556595\", \"8.153\", \"False\", \"True\"]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 24, {\"type\": \"Text\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 15, 16, 17, 18],\n          {\"bool\": [False, True, 1509556595, 8.153, False, True]}],\n        [\"ModifyColumn\", \"Types\", \"bool\", {\"type\": \"Bool\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 24, {\"type\": \"Bool\"}],\n      ]\n    })\n\n    # Test Date -> Text\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"date\", { \"type\" : \"Text\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"date\", {\"type\": \"Text\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 15, 16, 17, 18],\n          {\"date\": [\"False\", \"True\", \"1509556595\", \"8.153\", \"0\", \"1\"]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 25, {\"type\": \"Text\"}]\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 15, 16, 17, 18],\n          {\"date\": [False, True, 1509556595.0, 8.153, 0.0, 1.0]}],\n        [\"ModifyColumn\", \"Types\", \"date\", {\"type\": \"Date\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 25, {\"type\": \"Date\"}]\n      ]\n    })\n\n    # Assert that the final table is as expected\n    self.assertTableData(\"Types\", data=[\n      [\"id\", \"text\",      \"numeric\",   \"int\",       \"bool\",      \"date\"],\n      [11,   \"New York\",  \"New York\",  \"New York\",  \"New York\",  \"New York\"],\n      [12,   u\"Chîcágö\",   u\"Chîcágö\",   u\"Chîcágö\",   u\"Chîcágö\",   u\"Chîcágö\"],\n      [13,   False,       \"False\",     \"False\",     \"False\",     \"False\"],\n      [14,   True,        \"True\",      \"True\",      \"True\",      \"True\"],\n      [15,   1509556595,  \"1509556595\",\"1509556595\",\"1509556595\",\"1509556595\"],\n      [16,   8.153,       \"8.153\",     \"8.153\",     \"8.153\",     \"8.153\"],\n      [17,   0,           \"0\",         \"0\",         \"False\",     \"0\"],\n      [18,   1,           \"1\",         \"1\",         \"True\",      \"1\"],\n      [19,   \"\",          \"\",          \"\",          \"\",          \"\"],\n      [20,   None,        None,        None,        None,        None]\n    ])\n\n\n  def test_numeric_conversions(self):\n    \"\"\"\n    Tests that column type changes occur as expected in the sandbox:\n     - Resulting cell values should all be of type Numeric or AltText\n     - Only non-compatible values should appear in the resulting BulkUpdateRecord\n    \"\"\"\n    self.load_sample(self.sample)\n\n    # Test Text -> Numeric conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"text\", { \"type\" : \"Numeric\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"text\", {\"type\": \"Numeric\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"text\": [0.0, 1.0, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Numeric\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"text\": [False, True, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"text\", {\"type\": \"Text\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Text\"}],\n      ]\n    })\n\n    # Test Numeric -> Numeric conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Numeric\"}])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [],\n      \"undo\": []\n    })\n\n    # Test Int -> Numeric conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"int\", { \"type\" : \"Numeric\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"int\", {\"type\": \"Numeric\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"int\": [0.0, 1.0, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 23, {\"type\": \"Numeric\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"int\": [False, True, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"int\", {\"type\": \"Int\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 23, {\"type\": \"Int\"}],\n      ]\n    })\n\n    # Test Bool -> Numeric conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"bool\", { \"type\" : \"Numeric\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"bool\", {\"type\": \"Numeric\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 17, 18, 19],\n          {\"bool\": [0.0, 1.0, 0.0, 1.0, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 24, {\"type\": \"Numeric\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 17, 18, 19],\n          {\"bool\": [False, True, False, True, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"bool\", {\"type\": \"Bool\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 24, {\"type\": \"Bool\"}],\n      ]\n    })\n\n    # Test Date -> Numeric conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"date\", { \"type\" : \"Numeric\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"date\", {\"type\": \"Numeric\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"date\": [0.0, 1.0, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 25, {\"type\": \"Numeric\"}]\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"date\": [False, True, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"date\", {\"type\": \"Date\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 25, {\"type\": \"Date\"}]\n      ]\n    })\n\n    # Assert that the final table is as expected\n    self.assertTableData(\"Types\", data=[\n      [\"id\", \"text\",     \"numeric\",  \"int\",      \"bool\",     \"date\"],\n      [11,   \"New York\", \"New York\", \"New York\", \"New York\", \"New York\"],\n      [12,   u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\"],\n      [13,   0.0,        False,      0.0,        0.0,        0.0],\n      [14,   1.0,        True,       1.0,        1.0,        1.0],\n      [15,   1509556595, 1509556595, 1509556595, 1509556595, 1509556595],\n      [16,   8.153,      8.153,      8.153,      8.153,      8.153],\n      [17,   0.0,        0.0,        0.0,        0.0,        0.0],\n      [18,   1.0,        1.0,        1.0,        1.0,        1.0],\n      [19,   None,       \"\",         None,       None,       None],\n      [20,   None,       None,       None,       None,       None],\n    ])\n\n  def test_numeric_to_text_conversion(self):\n    \"\"\"\n    Tests text formatting of floats of different sizes.\n    \"\"\"\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Types\", [\n          [22, \"numeric\", \"Numeric\", False, \"\", \"\", \"\"],\n          [23, \"other\", \"Text\", False, \"\", \"\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"Types\": [[\"id\", \"numeric\"]] + [[i+1, 1.23456789 * 10 ** (i-20)] for i in range(40)]\n      },\n    })\n    self.load_sample(sample)\n\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"numeric\", { \"type\" : \"Text\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Text\"}],\n        [\"BulkUpdateRecord\", \"Types\",\n         [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,\n          21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40],\n         {\"numeric\": [\"1.23456789e-20\",\n                      \"1.23456789e-19\",\n                      \"1.23456789e-18\",\n                      \"1.23456789e-17\",\n                      \"1.23456789e-16\",\n                      \"1.23456789e-15\",\n                      \"1.23456789e-14\",\n                      \"1.23456789e-13\",\n                      \"1.23456789e-12\",\n                      \"1.23456789e-11\",\n                      \"1.23456789e-10\",\n                      \"1.23456789e-09\",\n                      \"1.23456789e-08\",\n                      \"1.23456789e-07\",\n                      \"1.23456789e-06\",\n                      \"1.23456789e-05\",\n                      \"0.000123456789\",\n                      \"0.00123456789\",\n                      \"0.0123456789\",\n                      \"0.123456789\",\n                      \"1.23456789\",\n                      \"12.3456789\",\n                      \"123.456789\",\n                      \"1234.56789\",\n                      \"12345.6789\",\n                      \"123456.789\",\n                      \"1234567.89\",\n                      \"12345678.9\",\n                      \"123456789\",\n                      \"1234567890\",\n                      \"12345678900\",\n                      \"123456789000\",\n                      \"1234567890000\",\n                      \"12345678900000\",\n                      \"123456789000000\",\n                      \"1234567890000000\",\n                      \"1.23456789e+16\",\n                      \"1.23456789e+17\",\n                      \"1.23456789e+18\",\n                      \"1.23456789e+19\"]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Text\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\",\n         [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,\n          21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40],\n         {\"numeric\": [1.2345678899999998e-20,\n                      1.2345678899999999e-19,\n                      1.23456789e-18,\n                      1.23456789e-17,\n                      1.2345678899999998e-16,\n                      1.23456789e-15,\n                      1.23456789e-14,\n                      1.23456789e-13,\n                      1.2345678899999998e-12,\n                      1.2345678899999998e-11,\n                      1.2345678899999998e-10,\n                      1.23456789e-09,\n                      1.2345678899999999e-08,\n                      1.23456789e-07,\n                      1.2345678899999998e-06,\n                      1.23456789e-05,\n                      0.000123456789,\n                      0.00123456789,\n                      0.012345678899999999,\n                      0.123456789,\n                      1.23456789,\n                      12.3456789,\n                      123.45678899999999,\n                      1234.5678899999998,\n                      12345.678899999999,\n                      123456.78899999999,\n                      1234567.89,\n                      12345678.899999999,\n                      123456788.99999999,\n                      1234567890.0,\n                      12345678899.999998,\n                      123456788999.99998,\n                      1234567890000.0,\n                      12345678899999.998,\n                      123456788999999.98,\n                      1234567890000000.0,\n                      1.2345678899999998e+16,\n                      1.2345678899999998e+17,\n                      1.23456789e+18,\n                      1.2345678899999998e+19]}],\n        [\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Numeric\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Numeric\"}],\n      ]\n    })\n\n  def test_int_conversions(self):\n    \"\"\"\n    Tests that column type changes occur as expected in the sandbox:\n     - Resulting cell values should all be of type Int or AltText\n     - Only non-compatible values should appear in the resulting BulkUpdateRecord\n    \"\"\"\n    self.load_sample(self.sample)\n\n    # Test Text -> Int conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"text\", { \"type\" : \"Int\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"text\", {\"type\": \"Int\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 16, 19], {\"text\": [0, 1, 8, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Int\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 16, 19],\n          {\"text\": [False, True, 8.153, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"text\", {\"type\": \"Text\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Text\"}],\n      ]\n    })\n\n    # Test Numeric -> Int conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"numeric\", { \"type\" : \"Int\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Int\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 16, 19],\n         {\"numeric\": [0, 1, 8, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Int\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 16, 19],\n          {\"numeric\": [False, True, 8.153, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Numeric\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Numeric\"}],\n      ]\n    })\n\n    # Test Int -> Int conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"int\", { \"type\" : \"Int\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [],\n      \"undo\": []\n    })\n\n    # Test Bool -> Int conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"bool\", { \"type\" : \"Int\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"bool\", {\"type\": \"Int\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 16, 17, 18, 19],\n          {\"bool\": [0, 1, 8, 0, 1, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 24, {\"type\": \"Int\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 16, 17, 18, 19],\n          {\"bool\": [False, True, 8.153, False, True, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"bool\", {\"type\": \"Bool\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 24, {\"type\": \"Bool\"}],\n      ]\n    })\n\n    # Test Date -> Int conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"date\", { \"type\" : \"Int\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"date\", {\"type\": \"Int\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 16, 19],\n          {\"date\": [0, 1, 8, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 25, {\"type\": \"Int\"}]\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 16, 19],\n          {\"date\": [False, True, 8.153, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"date\", {\"type\": \"Date\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 25, {\"type\": \"Date\"}]\n      ]\n    })\n\n    # Assert that the final table is as expected\n    self.assertTableData(\"Types\", data=[\n      [\"id\", \"text\",     \"numeric\",  \"int\",      \"bool\",     \"date\"],\n      [11,   \"New York\", \"New York\", \"New York\", \"New York\", \"New York\"],\n      [12,   u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\"],\n      [13,   0,          0,          False,      0,          0],\n      [14,   1,          1,          True,       1,          1],\n      [15,   1509556595, 1509556595, 1509556595, 1509556595, 1509556595],\n      [16,   8,          8,          8.153,      8,          8],\n      [17,   0,          0,          0,          0,          0],\n      [18,   1,          1,          1,          1,          1],\n      [19,   None,       None,       \"\",         None,       None],\n      [20,   None,       None,       None,       None,       None]\n    ])\n\n\n  def test_bool_conversions(self):\n    \"\"\"\n    Tests that column type changes occur as expected in the sandbox:\n     - Resulting cell values should all be of type Bool or AltText\n     - Only non-compatible values should appear in the resulting BulkUpdateRecord\n    \"\"\"\n    self.load_sample(self.sample)\n\n    # Test Text -> Bool conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"text\", { \"type\" : \"Bool\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"text\", {\"type\": \"Bool\"}],\n        [\"BulkUpdateRecord\", \"Types\", [15, 16, 17, 18, 19, 20],\n          {\"text\": [True, True, False, True, False, False]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Bool\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [15, 16, 17, 18, 19, 20],\n          {\"text\": [1509556595, 8.153, 0, 1, \"\", None]}],\n        [\"ModifyColumn\", \"Types\", \"text\", {\"type\": \"Text\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Text\"}],\n      ]\n    })\n\n    # Test Numeric -> Bool conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"numeric\", { \"type\" : \"Bool\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Bool\"}],\n        [\"BulkUpdateRecord\", \"Types\", [15, 16, 17, 18, 19, 20],\n          {\"numeric\": [True, True, False, True, False, False]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Bool\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [15, 16, 17, 18, 19, 20],\n          {\"numeric\": [1509556595.0, 8.153, 0.0, 1.0, \"\", None]}],\n        [\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Numeric\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Numeric\"}],\n      ]\n    })\n\n    # Test Int -> Bool conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"int\", { \"type\" : \"Bool\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"int\", {\"type\": \"Bool\"}],\n        [\"BulkUpdateRecord\", \"Types\", [15, 16, 17, 18, 19, 20],\n          {\"int\": [True, True, False, True, False, False]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 23, {\"type\": \"Bool\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [15, 16, 17, 18, 19, 20],\n          {\"int\": [1509556595, 8.153, 0, 1, \"\", None]}],\n        [\"ModifyColumn\", \"Types\", \"int\", {\"type\": \"Int\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 23, {\"type\": \"Int\"}],\n      ]\n    })\n\n    # Test Bool -> Bool conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"bool\", { \"type\" : \"Bool\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [],\n      \"undo\": []\n    })\n\n    # Test Date -> Bool conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"date\", { \"type\" : \"Bool\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"date\", {\"type\": \"Bool\"}],\n        [\"BulkUpdateRecord\", \"Types\", [15, 16, 17, 18, 19, 20],\n          {\"date\": [True, True, False, True, False, False]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 25, {\"type\": \"Bool\"}]\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [15, 16, 17, 18, 19, 20],\n          {\"date\": [1509556595, 8.153, 0, 1, \"\", None]}],\n        [\"ModifyColumn\", \"Types\", \"date\", {\"type\": \"Date\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 25, {\"type\": \"Date\"}]\n      ]\n    })\n\n    # Assert that the final table is as expected\n    self.assertTableData(\"Types\", data=[\n      [\"id\", \"text\",     \"numeric\",  \"int\",      \"bool\",     \"date\"],\n      [11,   \"New York\", \"New York\", \"New York\", \"New York\", \"New York\"],\n      [12,   u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\"],\n      [13,   False,      False,      False,      False,      False],\n      [14,   True,       True,       True,       True,       True],\n      [15,   True,       True,       True,       1509556595, True],\n      [16,   True,       True,       True,       8.153,      True],\n      [17,   False,      False,      False,      0,          False],\n      [18,   True,       True,       True,       1,          True],\n      [19,   False,      False,      False,      \"\",         False],\n      [20,   False,      False,      False,      None,       False]\n    ])\n\n\n  def test_date_conversions(self):\n    \"\"\"\n    Tests that column type changes occur as expected in the sandbox:\n     - Resulting cell values should all be of type Date or AltText\n     - Only non-compatible values should appear in the resulting BulkUpdateRecord\n    \"\"\"\n    self.load_sample(self.sample)\n\n    # Test Text -> Date conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"text\", { \"type\" : \"Date\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"text\", {\"type\": \"Date\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"text\": [0.0, 1.0, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Date\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"text\": [False, True, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"text\", {\"type\": \"Text\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Text\"}],\n      ]\n    })\n\n    # Test Numeric -> Date conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"numeric\", { \"type\" : \"Date\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Date\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"numeric\": [0.0, 1.0, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Date\"}],\n        [\"UpdateRecord\", \"Formulas\", 1, {\"division\": [\"E\", \"TypeError\"]}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"numeric\": [False, True, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"numeric\", {\"type\": \"Numeric\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 22, {\"type\": \"Numeric\"}],\n        [\"UpdateRecord\", \"Formulas\", 1, {\"division\": 0.5}],\n      ]\n    })\n\n    # Test Int -> Date conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"int\", { \"type\" : \"Date\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"int\", {\"type\": \"Date\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"int\": [0.0, 1.0, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 23, {\"type\": \"Date\"}],\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 19],\n          {\"int\": [False, True, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"int\", {\"type\": \"Int\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 23, {\"type\": \"Int\"}],\n      ]\n    })\n\n    # Test Bool -> Date conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"bool\", { \"type\" : \"Date\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [\n        [\"ModifyColumn\", \"Types\", \"bool\", {\"type\": \"Date\"}],\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 17, 18, 19],\n          {\"bool\": [0.0, 1.0, 0.0, 1.0, None]}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 24, {\"type\": \"Date\"}]\n      ],\n      \"undo\": [\n        [\"BulkUpdateRecord\", \"Types\", [13, 14, 17, 18, 19],\n          {\"bool\": [False, True, False, True, \"\"]}],\n        [\"ModifyColumn\", \"Types\", \"bool\", {\"type\": \"Bool\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 24, {\"type\": \"Bool\"}]\n      ]\n    })\n\n    # Test Date -> Date conversion\n    out_actions = self.apply_user_action([\"ModifyColumn\", \"Types\", \"date\", { \"type\" : \"Date\" }])\n    self.assertPartialOutActions(out_actions, {\n      \"stored\": [],\n      \"undo\": []\n    })\n\n    # Assert that the final table is as expected\n    self.assertTableData(\"Types\", data=[\n      [\"id\", \"text\",     \"numeric\",  \"int\",      \"bool\",     \"date\"],\n      [11,   \"New York\", \"New York\", \"New York\", \"New York\", \"New York\"],\n      [12,   u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\",  u\"Chîcágö\"],\n      [13,   0.0,        0.0,        0.0,        0.0,        False],\n      [14,   1.0,        1.0,        1.0,        1.0,        True],\n      [15,   1509556595, 1509556595, 1509556595, 1509556595, 1509556595],\n      [16,   8.153,      8.153,      8.153,      8.153,      8.153],\n      [17,   0.0,        0.0,        0.0,        0.0,        0],\n      [18,   1.0,        1.0,        1.0,        1.0,        1],\n      [19,   None,       None,       None,       None,        \"\"],\n      [20,   None,       None,       None,       None,       None]\n    ])\n\n  def test_numerics_are_floats(self):\n    \"\"\"\n    Tests that in formulas, numeric values are floats, not integers.\n    Important to avoid truncation.\n    \"\"\"\n    self.load_sample(self.sample)\n    self.assertTableData('Formulas', data=[\n      ['id', 'division'],\n      [ 1,   0.5],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_undo.py",
    "content": "import re\nimport test_engine\nimport testsamples\nimport testutil\n\n\nclass TestUndo(test_engine.EngineTestCase):\n  def test_bad_undo(self):\n    # Sometimes undo can make metadata inconsistent with schema. Check that we disallow it.\n    self.load_sample(testsamples.sample_students)\n    out_actions1 = self.apply_user_action(['AddEmptyTable', None])\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"tableId\", \"columns\"], [\n      [1,   \"Students\", [1,2,4,5,6]],\n      [2,   \"Schools\", [10,12]],\n      [3,   \"Address\", [21]],\n      [4,   \"Table1\", [22,23,24,25]],\n    ])\n\n    # Add a column, and check that it's present in the metadata.\n    self.add_column('Table1', 'NewCol', type='Text')\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"tableId\", \"columns\"], [\n      [1,   \"Students\", [1,2,4,5,6]],\n      [2,   \"Schools\", [10,12]],\n      [3,   \"Address\", [21]],\n      [4,   \"Table1\", [22,23,24,25,26]],\n    ])\n\n    # Now undo just the first action. The list of undo DocActions for it does not mention the\n    # newly added column, and fails to clean it up. This would leave the doc in an inconsistent\n    # state, and we should not allow it.\n    with self.assertRaisesRegex(AssertionError,\n        re.compile(r\"Internal schema inconsistent.*'NewCol'\", re.S)):\n      self.apply_undo_actions(out_actions1.undo)\n\n    # Check that schema and metadata look OK.\n    self.engine.assert_schema_consistent()\n\n    # Doc state should be unchanged.\n\n    # A little cheating here: assertPartialData() below checks the same thing, but the private\n    # calculated field \"columns\" in _grist_Tables metadata is left out of date by the failed undo.\n    # In practice it's harmless: properly calculated fields get restored correct, and the private\n    # metadata fields get brought up-to-date when used via Record interface, which is what we do\n    # using this assertEqual().\n    self.assertEqual([[r.id, r.tableId, list(map(int, r.columns))]\n                      for r in self.engine.docmodel.tables.table.filter_records()], [\n      [1,   \"Students\", [1,2,4,5,6]],\n      [2,   \"Schools\", [10,12]],\n      [3,   \"Address\", [21]],\n      [4,   \"Table1\", [22,23,24,25,26]],\n    ])\n\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"tableId\", \"columns\"], [\n      [1,   \"Students\", [1,2,4,5,6]],\n      [2,   \"Schools\", [10,12]],\n      [3,   \"Address\", [21]],\n      [4,   \"Table1\", [22,23,24,25,26]],\n    ])\n\n  def test_import_undo(self):\n    # Here we reproduce another bad situation. A more complex example with the same essence arose\n    # during undo of imports when the undo could omit part of the action bundle.\n    self.load_sample(testsamples.sample_students)\n\n    out_actions1 = self.apply_user_action(['AddEmptyTable', None])\n    out_actions2 = self.add_column('Table1', 'D', type='Text')\n    out_actions3 = self.remove_column('Table1', 'D')\n    out_actions4 = self.apply_user_action(['RemoveTable', 'Table1'])\n    out_actions5 = self.apply_user_action(['AddTable', 'Table1', [{'id': 'X'}]])\n\n    undo_actions = [da for out in [out_actions1, out_actions2, out_actions4, out_actions5]\n                       for da in out.undo]\n    with self.assertRaises(AssertionError):\n      self.apply_undo_actions(undo_actions)\n\n    # The undo failed, and data should look as before the undo.\n    self.engine.assert_schema_consistent()\n    self.assertEqual([[r.id, r.tableId, list(map(int, r.columns))]\n                      for r in self.engine.docmodel.tables.table.filter_records()], [\n      [1,   \"Students\", [1,2,4,5,6]],\n      [2,   \"Schools\", [10,12]],\n      [3,   \"Address\", [21]],\n      [4,   \"Table1\", [22, 23]],\n    ])\n\n  @test_engine.test_undo\n  def test_auto_remove_undo(self):\n    \"\"\"\n    Test that a formula using docmodel.setAutoRemove doesn't break when undoing.\n    We don't actually recommend using docmodel.setAutoRemove in formulas,\n    but it'd be nice, and this is really testing that a bugfix about summary tables\n    also helps outside of summary tables.\n    \"\"\"\n    self.load_sample(testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table\", [\n          [11, \"amount\", \"Numeric\", False, \"\", \"\", \"\"],\n          [12, \"amount2\", \"Numeric\", True, \"$amount\", \"\", \"\"],\n          [13, \"remove\", \"Any\", True,\n           \"table.table._engine.docmodel.setAutoRemove(rec, not $amount2)\", \"\", \"\"],\n        ]]\n      ],\n      \"DATA\": {\n        \"Table\": [\n          [\"id\", \"amount\", \"amount2\"],\n          [21, 1, 1],\n          [22, 2, 2],\n        ]\n      }\n    }))\n    out_actions = self.update_record('Table', 21, amount=0)\n    self.assertOutActions(out_actions, {\n      'calc': [],\n      'direct': [True, False],\n      'stored': [['UpdateRecord', 'Table', 21, {'amount': 0.0}],\n                 ['RemoveRecord', 'Table', 21]],\n      'undo': [['UpdateRecord', 'Table', 21, {'amount2': 1.0}],\n               ['UpdateRecord', 'Table', 21, {'amount': 1.0}],\n               ['AddRecord', 'Table', 21, {}]],\n    })\n    self.assertTableData('Table', cols=\"subset\", data=[\n      [\"id\", \"amount\", \"amount2\"],\n      [22, 2, 2],\n    ])\n"
  },
  {
    "path": "sandbox/grist/test_urllib_patch.py",
    "content": "# coding=utf-8\nimport unittest\nimport urllib\n\nfrom urllib_patch import original_quote\n\n\nclass TestUrllibPatch(unittest.TestCase):\n  def test_patched_quote(self):\n    self.assertEqual(urllib.quote( \"a b\"), u\"a%20b\")\n    self.assertEqual(urllib.quote(u\"a b\"), u\"a%20b\")\n    self.assertEqual(urllib.quote(u\"a é\"), u\"a%20%C3%A9\")\n\n    self.assertEqual(original_quote( \"a b\"), u\"a%20b\")\n    self.assertEqual(original_quote(u\"a b\"), u\"a%20b\")\n    self.assertEqual(original_quote(u\"a é\"), u\"a%20%C3%A9\")\n"
  },
  {
    "path": "sandbox/grist/test_user.py",
    "content": "from user import User\nimport test_engine\nimport testsamples\n\nclass TestUser(test_engine.EngineTestCase):\n  # pylint: disable=no-member\n  def setUp(self):\n    super(TestUser, self).setUp()\n    self.load_sample(testsamples.sample_students)\n\n  def test_constructor_sets_user_attributes(self):\n    data = {\n      'Access': 'owners',\n      'Name': 'Foo Bar',\n      'Email': 'email@example.com',\n      'UserID': 1,\n      'UserRef': '1',\n      'LinkKey': {\n        'Param1': 'Param1Value',\n        'Param2': 'Param2Value'\n      },\n      'Origin': 'https://getgrist.com',\n      'StudentInfo': ['Students', 1],\n      'SessionID': 'u1',\n      'IsLoggedIn': True,\n      'ShareRef': None\n    }\n    u = User(data, self.engine.tables)\n    self.assertEqual(u.Name, 'Foo Bar')\n    self.assertEqual(u.Email, 'email@example.com')\n    self.assertEqual(u.UserID, 1)\n    self.assertEqual(u.LinkKey.Param1, 'Param1Value')\n    self.assertEqual(u.LinkKey.Param2, 'Param2Value')\n    self.assertEqual(u.Access, 'owners')\n    self.assertEqual(u.Origin, 'https://getgrist.com')\n    self.assertEqual(u.StudentInfo.id, 1)\n    self.assertEqual(u.StudentInfo.firstName, 'Barack')\n    self.assertEqual(u.StudentInfo.lastName, 'Obama')\n    self.assertEqual(u.StudentInfo.schoolName, 'Columbia')\n\n  def test_setting_is_sample_substitutes_attributes_with_samples(self):\n    data = {\n      'Access': 'owners',\n      'Name': None,\n      'Email': 'email@getgrist.com',\n      'UserID': 1,\n      'UserRef': '1',\n      'LinkKey': {\n        'Param1': 'Param1Value',\n        'Param2': 'Param2Value'\n      },\n      'Origin': 'https://getgrist.com',\n      'StudentInfo': ['Students', 1],\n      'SessionID': 'u1',\n      'IsLoggedIn': True,\n      'ShareRef': None\n    }\n    u = User(data, self.engine.tables, is_sample=True)\n    self.assertEqual(u.StudentInfo.id, 0)\n    self.assertEqual(u.StudentInfo.firstName, '')\n    self.assertEqual(u.StudentInfo.lastName, '')\n    self.assertEqual(u.StudentInfo.schoolName, '')\n"
  },
  {
    "path": "sandbox/grist/test_useractions.py",
    "content": "# pylint:disable=too-many-lines\nimport json\nimport types\nimport logging\nimport useractions\n\nimport testutil\nimport test_engine\nfrom test_engine import Table, Column, View, Section, Field\nfrom schema import RecalcWhen\n\nlog = logging.getLogger(__name__)\n\nclass TestUserActions(test_engine.EngineTestCase):\n  sample = testutil.parse_test_sample({\n    \"SCHEMA\": [\n      [1, \"Address\", [\n        [21, \"city\",        \"Text\",       False, \"\", \"\", \"\"],\n      ]]\n    ],\n    \"DATA\": {\n      \"Address\": [\n        [\"id\",  \"city\"       ],\n        [11,    \"New York\"   ],\n        [12,    \"Colombia\"   ],\n        [13,    \"New Haven\"  ],\n        [14,    \"West Haven\" ]],\n    }\n  })\n\n  starting_table = Table(1, \"Address\", primaryViewId=0, summarySourceTable=0, columns=[\n    Column(21, \"city\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=0)\n  ])\n\n  #----------------------------------------------------------------------\n  def test_conversions(self):\n    # Test the sequence of user actions as used for transform-based conversions. This is actually\n    # not exactly what the client emits, but more like what the client should ideally emit.\n\n    # Our sample has a Schools.city text column; we'll convert it to Ref:Address.\n    self.load_sample(self.sample)\n\n    # Add a new table for Schools so that we get the associated views and fields.\n    self.apply_user_action(['AddTable', 'Schools', [{'id': 'city', 'type': 'Text'}]])\n    self.apply_user_action(['BulkAddRecord', 'Schools', [1,2,3,4], {\n      'city': ['New York', 'Colombia', 'New York', '']\n    }])\n    self.assertPartialData(\"_grist_Tables\", [\"id\", \"tableId\"], [\n      [1, \"Address\"],\n      [2, \"Schools\"],\n    ])\n    self.assertPartialData(\"_grist_Tables_column\",\n                           [\"id\", \"colId\", \"parentId\", \"parentPos\", \"widgetOptions\"], [\n      [21, \"city\",        1,  1.0, \"\"],\n      [22, \"manualSort\",  2,  2.0, \"\"],\n      [23, \"city\",        2,  3.0, \"\"],\n    ])\n    self.assertPartialData(\"_grist_Views_section_field\", [\"id\", \"colRef\", \"widgetOptions\"], [\n      [1,   23,   \"\"],\n      [2,   23,   \"\"],\n      [3,   23,   \"\"],\n    ])\n    self.assertPartialData(\"Schools\", [\"id\", \"city\"], [\n      [1,   \"New York\"  ],\n      [2,   \"Colombia\"  ],\n      [3,   \"New York\"  ],\n      [4,   \"\"          ],\n    ])\n\n    # Our sample has a text column city.\n    out_actions = self.add_column('Schools', 'grist_Transform',\n                                  isFormula=True, formula='return $city', type='Text')\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      ['AddColumn', 'Schools', 'grist_Transform', {\n        'type': 'Text', 'isFormula': True, 'formula': 'return $city',\n      }],\n      ['AddRecord', '_grist_Tables_column', 24, {\n        'widgetOptions': '', 'parentPos': 4.0, 'isFormula': True, 'parentId': 2, 'colId':\n        'grist_Transform', 'formula': 'return $city', 'label': 'grist_Transform',\n        'type': 'Text'\n      }],\n      [\"AddRecord\", \"_grist_Views_section_field\", 4, {\n        \"colRef\": 24, \"parentId\": 2, \"parentPos\": 4.0\n      }],\n      [\"AddRecord\", \"_grist_Views_section_field\", 5, {\n        \"colRef\": 24, \"parentId\": 3, \"parentPos\": 5.0\n      }],\n      [\"BulkUpdateRecord\", \"Schools\", [1, 2, 3],\n        {\"grist_Transform\": [\"New York\", \"Colombia\", \"New York\"]}],\n    ]})\n\n    out_actions = self.update_record('_grist_Tables_column', 24,\n                                     type='Ref:Address',\n                                     formula='return Address.lookupOne(city=$city).id')\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      ['ModifyColumn', 'Schools', 'grist_Transform', {\n        'formula': 'return Address.lookupOne(city=$city).id', 'type': 'Ref:Address'}],\n      ['UpdateRecord', '_grist_Tables_column', 24, {\n        'formula': 'return Address.lookupOne(city=$city).id', 'type': 'Ref:Address'}],\n      [\"BulkUpdateRecord\", \"Schools\", [1, 2, 3, 4], {\"grist_Transform\": [11, 12, 11, 0]}],\n    ]})\n\n    # It seems best if TypeTransform sets widgetOptions on grist_Transform column, so that they\n    # can be copied in CopyFromColumn; rather than updating them after the copy is done.\n    self.update_record('_grist_Views_section_field', 1, widgetOptions=\"hello\")\n    self.update_record('_grist_Tables_column', 24, widgetOptions=\"world\")\n\n    out_actions = self.apply_user_action(\n      ['CopyFromColumn', 'Schools', 'grist_Transform', 'city', None])\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      ['ModifyColumn', 'Schools', 'city', {'type': 'Ref:Address'}],\n      ['UpdateRecord', 'Schools', 4, {'city': 0}],\n      ['UpdateRecord', '_grist_Tables_column', 23, {\n        'type': 'Ref:Address', 'widgetOptions': 'world'\n      }],\n      ['BulkUpdateRecord', 'Schools', [1, 2, 3], {'city': [11, 12, 11]}],\n      [\"BulkUpdateRecord\", \"Schools\", [1, 2, 3], {\"grist_Transform\": [0, 0, 0]}],\n    ]})\n\n    out_actions = self.update_record('_grist_Tables_column', 23,\n                                    widgetOptions='{\"widget\":\"Reference\",\"visibleCol\":\"city\"}')\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      ['UpdateRecord', '_grist_Tables_column', 23, {\n        'widgetOptions': '{\"widget\":\"Reference\",\"visibleCol\":\"city\"}'}],\n    ]})\n\n    out_actions = self.remove_column('Schools', 'grist_Transform')\n    self.assertPartialOutActions(out_actions, { \"stored\": [\n      [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [4, 5]],\n      ['RemoveRecord', '_grist_Tables_column', 24],\n      ['RemoveColumn', 'Schools', 'grist_Transform'],\n    ]})\n\n  #----------------------------------------------------------------------\n  def test_create_section_existing_view(self):\n    # Test that CreateViewSection works for an existing view.\n\n    self.load_sample(self.sample)\n    self.assertTables([self.starting_table])\n\n    # Create a view + section for the initial table.\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", None, None])\n\n    # Verify that we got a new view, with one section, and three fields.\n    self.assertViews([View(1, sections=[\n      Section(1, parentKey=\"record\", tableRef=1, fields=[\n        Field(1, colRef=21),\n      ])\n    ]) ])\n\n    # Create a new section for the same view, check that only a section is added.\n    self.apply_user_action([\"CreateViewSection\", 1, 1, \"record\", None, None])\n    self.assertTables([self.starting_table])\n    self.assertViews([View(1, sections=[\n      Section(1, parentKey=\"record\", tableRef=1, fields=[\n        Field(1, colRef=21),\n      ]),\n      Section(2, parentKey=\"record\", tableRef=1, fields=[\n        Field(2, colRef=21),\n      ])\n    ]) ])\n\n    # Create another section for the same view, this time summarized.\n    self.apply_user_action([\"CreateViewSection\", 1, 1, \"record\", [21], None])\n    summary_table = Table(2, \"Address_summary_city\", 0, summarySourceTable=1, columns=[\n        Column(22, \"city\", \"Text\", isFormula=False, formula=\"\", summarySourceCol=21),\n        Column(23, \"group\", \"RefList:Address\", isFormula=True,\n               formula=\"table.getSummarySourceGroup(rec)\", summarySourceCol=0),\n        Column(24, \"count\", \"Int\", isFormula=True, formula=\"len($group)\", summarySourceCol=0),\n      ])\n    self.assertTables([self.starting_table, summary_table])\n    # Check that we still have one view, with sections for different tables.\n    view = View(1, sections=[\n      Section(1, parentKey=\"record\", tableRef=1, fields=[\n        Field(1, colRef=21),\n      ]),\n      Section(2, parentKey=\"record\", tableRef=1, fields=[\n        Field(2, colRef=21),\n      ]),\n      Section(4, parentKey=\"record\", tableRef=2, fields=[\n        Field(5, colRef=22),\n        Field(6, colRef=24),\n      ]),\n    ])\n    self.assertTables([self.starting_table, summary_table])\n    self.assertViews([view])\n\n    # Try to create a summary table for an invalid column, and check that it fails.\n    with self.assertRaises(ValueError):\n      self.apply_user_action([\"CreateViewSection\", 1, 1, \"record\", [23], None])\n    self.assertTables([self.starting_table, summary_table])\n    self.assertViews([view])\n\n  #----------------------------------------------------------------------\n  def test_creates_section_new_table(self):\n    # Test that CreateViewSection works for adding a new table.\n\n    self.load_sample(self.sample)\n    self.assertTables([self.starting_table])\n    self.assertViews([])\n\n    # When we create a section/view for new table, we got the new view we are creating,\n    # without primary view.\n    self.apply_user_action([\"CreateViewSection\", 0, 0, \"record\", None, None])\n    new_table = Table(2, \"Table1\", primaryViewId=0, summarySourceTable=0, columns=[\n      Column(22, \"manualSort\", \"ManualSortPos\", isFormula=False, formula=\"\", summarySourceCol=0),\n      Column(23, \"A\", \"Any\", isFormula=True, formula=\"\", summarySourceCol=0),\n      Column(24, \"B\", \"Any\", isFormula=True, formula=\"\", summarySourceCol=0),\n      Column(25, \"C\", \"Any\", isFormula=True, formula=\"\", summarySourceCol=0),\n    ])\n    new_view = View(1, sections=[\n      Section(3, parentKey=\"record\", tableRef=2, fields=[\n        Field(7, colRef=23),\n        Field(8, colRef=24),\n        Field(9, colRef=25),\n      ])\n    ])\n    self.assertTables([self.starting_table, new_table])\n    self.assertViews([new_view])\n\n    # Create another section in an existing view for a new table.\n    self.apply_user_action([\"CreateViewSection\", 0, 1, \"record\", None, None])\n    new_table2 = Table(3, \"Table2\", primaryViewId=0, summarySourceTable=0, columns=[\n      Column(26, \"manualSort\", \"ManualSortPos\", isFormula=False, formula=\"\", summarySourceCol=0),\n      Column(27, \"A\", \"Any\", isFormula=True, formula=\"\", summarySourceCol=0),\n      Column(28, \"B\", \"Any\", isFormula=True, formula=\"\", summarySourceCol=0),\n      Column(29, \"C\", \"Any\", isFormula=True, formula=\"\", summarySourceCol=0),\n    ])\n    new_view.sections.append(\n      Section(6, parentKey=\"record\", tableRef=3, fields=[\n        Field(16, colRef=27),\n        Field(17, colRef=28),\n        Field(18, colRef=29),\n      ])\n    )\n    # Check that we have a new table, only the new view; and a new section.\n    self.assertTables([self.starting_table, new_table, new_table2])\n    self.assertViews([new_view])\n\n    # Check that we can't create a summary of a table grouped by a column that doesn't exist yet.\n    with self.assertRaises(ValueError):\n      self.apply_user_action([\"CreateViewSection\", 0, 1, \"record\", [31], None])\n    self.assertTables([self.starting_table, new_table, new_table2])\n    self.assertViews([new_view])\n\n    # But creating a new table and showing totals for it is possible though dumb.\n    self.apply_user_action([\"CreateViewSection\", 0, 1, \"record\", [], None])\n\n    # We expect a new table.\n    new_table3 = Table(4, \"Table3\", primaryViewId=0, summarySourceTable=0, columns=[\n      Column(30, \"manualSort\", \"ManualSortPos\", isFormula=False, formula=\"\", summarySourceCol=0),\n      Column(31, \"A\", \"Any\", isFormula=True, formula=\"\", summarySourceCol=0),\n      Column(32, \"B\", \"Any\", isFormula=True, formula=\"\", summarySourceCol=0),\n      Column(33, \"C\", \"Any\", isFormula=True, formula=\"\", summarySourceCol=0),\n    ])\n    # A summary of it.\n    summary_table = Table(5, \"Table3_summary\", 0, summarySourceTable=4, columns=[\n      Column(34, \"group\", \"RefList:Table3\", isFormula=True,\n             formula=\"table.getSummarySourceGroup(rec)\", summarySourceCol=0),\n      Column(35, \"count\", \"Int\", isFormula=True, formula=\"len($group)\", summarySourceCol=0),\n    ])\n    self.assertTables([self.starting_table, new_table, new_table2, new_table3, summary_table])\n    new_view.sections.append(Section(10, parentKey=\"record\", tableRef=5, fields=[\n      Field(26, colRef=35)\n    ]))\n    self.assertViews([new_view])\n\n  #----------------------------------------------------------------------\n\n  def init_views_sample(self):\n    # Add a new table and a view, to get some Views/Sections/Fields, and TabBar items.\n    self.apply_user_action(['AddTable', 'Schools', [\n      {'id': 'city', 'type': 'Text'},\n      {'id': 'state', 'type': 'Text'},\n      {'id': 'size', 'type': 'Numeric'},\n    ]])\n    self.apply_user_action(['BulkAddRecord', 'Schools', [1,2,3,4], {\n      'city': ['New York', 'Colombia', 'New York', ''],\n      'state': ['NY', 'NY', 'NY', ''],\n      'size': [1000, 2000, 3000, 4000],\n    }])\n    # Add a new view; a second section (summary) to it; and a third view.\n    self.apply_user_action(['CreateViewSection', 1, 0, 'detail', None, None])\n    self.apply_user_action(['CreateViewSection', 1, 2, 'record', [3], None])\n    self.apply_user_action(['CreateViewSection', 1, 0, 'chart', None, None])\n    self.apply_user_action(['CreateViewSection', 0, 2, 'record', None, None])\n\n    # Verify the new structure of tables and views.\n    self.assertTables([\n      Table(1, \"Schools\", 1, 0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(2, \"city\",  \"Text\", False, \"\", 0),\n        Column(3, \"state\", \"Text\", False, \"\", 0),\n        Column(4, \"size\",  \"Numeric\", False, \"\", 0),\n      ]),\n      Table(2, \"Schools_summary_state\", 0, 1, columns=[\n        Column(5, \"state\", \"Text\", False, \"\", 3),\n        Column(6, \"group\", \"RefList:Schools\", True, \"table.getSummarySourceGroup(rec)\", 0),\n        Column(7, \"count\", \"Int\",     True, \"len($group)\", 0),\n        Column(8, \"size\",  \"Numeric\", True, \"SUM($group.size)\", 0),\n      ]),\n      Table(3, 'Table1', 0, 0, columns=[\n        Column(9, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(10, \"A\", \"Any\", True, \"\", 0),\n        Column(11, \"B\", \"Any\", True, \"\", 0),\n        Column(12, \"C\", \"Any\", True, \"\", 0),\n      ]),\n    ])\n    self.assertViews([\n      View(1, sections=[\n        Section(1, parentKey=\"record\", tableRef=1, fields=[\n          Field(1, colRef=2),\n          Field(2, colRef=3),\n          Field(3, colRef=4),\n        ]),\n      ]),\n      View(2, sections=[\n        Section(4, parentKey=\"detail\", tableRef=1, fields=[\n          Field(10, colRef=2),\n          Field(11, colRef=3),\n          Field(12, colRef=4),\n        ]),\n        Section(6, parentKey=\"record\", tableRef=2, fields=[\n          Field(16, colRef=5),\n          Field(17, colRef=7),\n          Field(18, colRef=8),\n        ]),\n        Section(10, parentKey='record', tableRef=3, fields=[\n          Field(27, colRef=10),\n          Field(28, colRef=11),\n          Field(29, colRef=12),\n        ]),\n      ]),\n      View(3, sections=[\n        Section(7, parentKey=\"chart\", tableRef=1, fields=[\n          Field(19, colRef=2),\n          Field(20, colRef=3),\n        ]),\n      ])\n    ])\n    self.assertTableData('_grist_TabBar', cols=\"subset\", data=[\n      [\"id\",  \"viewRef\"],\n      [1,     1],\n      [2,     2],\n      [3,     3],\n    ])\n    self.assertTableData('_grist_Pages', cols=\"subset\", data=[\n      [\"id\", \"viewRef\"],\n      [1,    1],\n      [2,    2],\n      [3,    3]\n    ])\n\n  #----------------------------------------------------------------------\n\n  def test_view_remove(self):\n    # Add a couple of tables and views, to trigger creation of some related items.\n    self.init_views_sample()\n\n    # Remove a view. Ensure related items, sections, fields get removed.\n    self.apply_user_action([\"BulkRemoveRecord\", \"_grist_Views\", [2, 3]])\n\n    # Verify the new structure of tables and views.\n    self.assertTables([\n      Table(1, \"Schools\", 1, 0, columns=[\n        Column(1, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(2, \"city\",  \"Text\", False, \"\", 0),\n        Column(3, \"state\", \"Text\", False, \"\", 0),\n        Column(4, \"size\",  \"Numeric\", False, \"\", 0),\n      ]),\n      # Note that the summary table is gone.\n      Table(3, 'Table1', 0, 0, columns=[\n        Column(9, \"manualSort\", \"ManualSortPos\", False, \"\", 0),\n        Column(10, \"A\", \"Any\", True, \"\", 0),\n        Column(11, \"B\", \"Any\", True, \"\", 0),\n        Column(12, \"C\", \"Any\", True, \"\", 0),\n      ]),\n    ])\n    self.assertViews([\n      View(1, sections=[\n        Section(1, parentKey=\"record\", tableRef=1, fields=[\n          Field(1, colRef=2),\n          Field(2, colRef=3),\n          Field(3, colRef=4),\n        ]),\n      ])\n    ])\n    self.assertTableData('_grist_TabBar', cols=\"subset\", data=[\n      [\"id\",  \"viewRef\"],\n      [1,     1],\n    ])\n    self.assertTableData('_grist_Pages', cols=\"subset\", data=[\n      [\"id\",  \"viewRef\"],\n      [1,     1],\n    ])\n\n  #----------------------------------------------------------------------\n\n  def test_view_rename(self):\n    # Add a couple of tables and views, to trigger creation of some related items.\n    self.init_views_sample()\n\n    # Verify the new structure of tables and views.\n    self.assertTableData('_grist_Tables', cols=\"subset\", data=[\n      [ 'id', 'tableId',  'primaryViewId'  ],\n      [ 1,    'Schools',                1],\n      [ 2,    'Schools_summary_state', 0],\n      [ 3,    'Table1',                 0],\n    ])\n    self.assertTableData('_grist_Views', cols=\"subset\", data=[\n      [ 'id',   'name',   'primaryViewTable'  ],\n      [ 1,      'Schools',    1],\n      [ 2,      'New page',   0],\n      [ 3,      'New page',   0],\n    ])\n\n    # Update the names in a few views, and ensure that primary ones won't cause tables to\n    # get renamed.\n    self.apply_user_action(['BulkUpdateRecord', '_grist_Views', [2, 3],\n                            {'name': ['A', 'B']}])\n\n    self.assertTableData('_grist_Tables', cols=\"subset\", data=[\n      [ 'id', 'tableId',  'primaryViewId'  ],\n      [ 1,    'Schools',                1],\n      [ 2,    'Schools_summary_state', 0],\n      [ 3,    'Table1',                      0],\n    ])\n    self.assertTableData('_grist_Views', cols=\"subset\", data=[\n      [ 'id',   'name',   'primaryViewTable'  ],\n      [ 1,      'Schools',  1],\n      [ 2,      'A',        0],\n      [ 3,      'B',        0]\n    ])\n\n    # Now rename a table (by raw view section) and make sure that a view with the same name\n    # was renamed\n    self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,\n                            {'title': 'Bars'}])\n\n    self.assertTableData('_grist_Tables', cols=\"subset\", data=[\n      ['id', 'tableId'],\n      [1, 'Bars', 1],\n      [2, 'Bars_summary_state', 0],\n      [3, 'Table1', 0],\n    ])\n    self.assertTableData('_grist_Views', cols=\"subset\", data=[\n      ['id', 'name'],\n      [1, 'Bars'],\n      [2, 'A'],\n      [3, 'B']\n    ])\n\n    # Now rename tables so that two tables will have same names, to test if only the view\n    # with a page will be renamed.\n    self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,\n                            {'title': 'A'}])\n\n    self.assertTableData('_grist_Tables', cols=\"subset\", data=[\n      ['id', 'tableId'],\n      [1, 'A', 1],\n      [2, 'A_summary_state', 0],\n      [3, 'Table1', 0],\n    ])\n    self.assertTableData('_grist_Views', cols=\"subset\", data=[\n      ['id', 'name'],\n      [1, 'A'],\n      [2, 'A'],\n      [3, 'B'],\n    ])\n\n    self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,\n                            {'title': 'Z'}])\n\n    self.assertTableData('_grist_Tables', cols=\"subset\", data=[\n      ['id', 'tableId', 'primaryViewId', 'rawViewSectionRef', 'recordCardViewSectionRef'],\n      [1, 'Z', 1, 2, 3],\n      [2, 'Z_summary_state', 0, 5, 0],\n      [3, 'Table1', 0, 8, 9],\n    ])\n    self.assertTableData('_grist_Views', cols=\"subset\", data=[\n      ['id', 'name'],\n      [1, 'Z'],\n      [2, 'Z'],\n      [3, 'B'],\n    ])\n\n    # Add new table, with a view with the same name (Z) and make sure it won't be renamed\n    self.apply_user_action(['AddTable', 'Stations', [\n      {'id': 'city', 'type': 'Text'},\n    ]])\n    self.assertTableData('_grist_Tables', cols=\"subset\", data=[\n      ['id', 'tableId', 'primaryViewId', 'rawViewSectionRef', 'recordCardViewSectionRef'],\n      [1, 'Z', 1, 2, 3],\n      [2, 'Z_summary_state', 0, 5, 0],\n      [3, 'Table1', 0, 8, 9],\n      [4, 'Stations', 4, 12, 13],\n    ])\n    self.assertTableData('_grist_Views', cols=\"subset\", data=[\n      ['id', 'name'],\n      [1, 'Z'],\n      [2, 'Z'],\n      [3, 'B'],\n      [4, 'Stations'],\n    ])\n    # Replacing only a page name (though primary)\n    self.apply_user_action(['UpdateRecord', '_grist_Views', 4, {'name': 'Z'}])\n    self.assertTableData('_grist_Views', cols=\"subset\", data=[\n      ['id', 'name'],\n      [1, 'Z'],\n      [2, 'Z'],\n      [3, 'B'],\n      [4, 'Z']\n    ])\n\n    # Rename table Z to Schools. Primary view for Stations (Z) should not be renamed.\n    self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,\n                            {'title': 'Schools'}])\n\n    self.assertTableData('_grist_Tables', cols=\"subset\", data=[\n      ['id', 'tableId'],\n      [1, 'Schools'],\n      [2, 'Schools_summary_state'],\n      [3, 'Table1'],\n      [4, 'Stations'],\n    ])\n    self.assertTableData('_grist_Views', cols=\"subset\", data=[\n      ['id', 'name'],\n      [1, 'Schools'],\n      [2, 'Schools'],\n      [3, 'B'],\n      [4, 'Z']\n    ])\n\n  #----------------------------------------------------------------------\n\n  def test_section_removes(self):\n    # Add a couple of tables and views, to trigger creation of some related items.\n    self.init_views_sample()\n\n    self.assertViews([\n      View(1, sections=[\n        Section(1, parentKey=\"record\", tableRef=1, fields=[\n          Field(1, colRef=2),\n          Field(2, colRef=3),\n          Field(3, colRef=4),\n        ]),\n      ]),\n      View(2, sections=[\n        Section(4, parentKey=\"detail\", tableRef=1, fields=[\n          Field(10, colRef=2),\n          Field(11, colRef=3),\n          Field(12, colRef=4),\n        ]),\n        Section(6, parentKey=\"record\", tableRef=2, fields=[\n          Field(16, colRef=5),\n          Field(17, colRef=7),\n          Field(18, colRef=8),\n        ]),\n        Section(10, parentKey='record', tableRef=3, fields=[\n          Field(27, colRef=10),\n          Field(28, colRef=11),\n          Field(29, colRef=12),\n        ]),\n      ]),\n      View(3, sections=[\n        Section(7, parentKey=\"chart\", tableRef=1, fields=[\n          Field(19, colRef=2),\n          Field(20, colRef=3),\n        ]),\n      ])\n    ])\n\n    # Remove a couple of sections. Ensure their fields get removed.\n    self.apply_user_action(['BulkRemoveRecord', '_grist_Views_section', [6, 10]])\n\n    self.assertViews([\n      View(1, sections=[\n        Section(1, parentKey=\"record\", tableRef=1, fields=[\n          Field(1, colRef=2),\n          Field(2, colRef=3),\n          Field(3, colRef=4),\n        ]),\n      ]),\n      View(2, sections=[\n        Section(4, parentKey=\"detail\", tableRef=1, fields=[\n          Field(10, colRef=2),\n          Field(11, colRef=3),\n          Field(12, colRef=4),\n        ])\n      ]),\n      View(3, sections=[\n        Section(7, parentKey=\"chart\", tableRef=1, fields=[\n          Field(19, colRef=2),\n          Field(20, colRef=3),\n        ]),\n      ])\n    ])\n\n  #----------------------------------------------------------------------\n\n  def test_schema_consistency_check(self):\n    # Verify that schema consistency check actually runs, but only when schema is affected.\n\n    self.init_views_sample()\n\n    # Replace the engine's assert_schema_consistent() method with a mocked version.\n    orig_method = self.engine.assert_schema_consistent\n    count_calls = [0]\n    def override(self):   # pylint: disable=unused-argument\n      count_calls[0] += 1\n      # pylint: disable=not-callable\n      orig_method()\n    self.engine.assert_schema_consistent = types.MethodType(override, self.engine)\n\n    # Do a non-schema action to ensure it doesn't get called.\n    self.apply_user_action(['UpdateRecord', '_grist_Views', 2, {'name': 'A'}])\n    self.assertEqual(count_calls[0], 0)\n\n    # Do a schema action to ensure it gets called: this causes a table rename.\n    # 8 is id of raw view section for the Table1 table\n    self.apply_user_action(['UpdateRecord', '_grist_Views_section', 8, {'title': 'C'}])\n    self.assertEqual(count_calls[0], 1)\n\n    self.assertTableData('_grist_Tables', cols=\"subset\", data=[\n      [ 'id', 'tableId',  'primaryViewId'  ],\n      [ 1,    'Schools',                1],\n      [ 2,    'Schools_summary_state', 0],\n      [ 3,    'C',                      0],\n    ])\n\n    # Do another schema and non-schema action.\n    self.apply_user_action(['UpdateRecord', 'Schools', 1, {'city': 'Seattle'}])\n    self.assertEqual(count_calls[0], 1)\n\n    self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 2, {'colId': 'city2'}])\n    self.assertEqual(count_calls[0], 2)\n\n  #----------------------------------------------------------------------\n\n  def test_new_column_conversions(self):\n    self.init_views_sample()\n    self.apply_user_action(['AddColumn', 'Schools', None, {}])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",         \"isFormula\",  \"formula\"],\n      [1,     \"manualSort\", \"ManualSortPos\",False,        \"\"],\n      [2,     \"city\",       \"Text\",         False,        \"\"],\n      [3,     \"state\",      \"Text\",         False,        \"\"],\n      [4,     \"size\",       \"Numeric\",      False,        \"\"],\n      [13,    \"A\",          \"Any\",          True,         \"\"],\n    ], rows=lambda r: r.parentId.id == 1)\n    self.assertTableData('Schools', cols=\"subset\", data=[\n      [\"id\",  \"city\",         \"A\"],\n      [1,     \"New York\",     None],\n      [2,     \"Colombia\",     None],\n      [3,     \"New York\",     None],\n      [4,     \"\",             None],\n    ])\n\n    # Check that typing in text into the column produces a text column.\n    out_actions = self.apply_user_action(['UpdateRecord', 'Schools', 3, {\"A\": \"foo\"}])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",         \"isFormula\",  \"formula\"],\n      [13,    \"A\",          \"Text\",         False,        \"\"],\n    ])\n    self.assertTableData('Schools', cols=\"subset\", data=[\n      [\"id\",  \"city\",         \"A\"   ],\n      [1,     \"New York\",     \"\"    ],\n      [2,     \"Colombia\",     \"\"    ],\n      [3,     \"New York\",     \"foo\" ],\n      [4,     \"\",             \"\"    ],\n    ])\n\n    # Undo, and check that typing in a number produces a numeric column.\n    self.apply_undo_actions(out_actions.undo)\n    out_actions = self.apply_user_action(['UpdateRecord', 'Schools', 3, {\"A\": \" -17.6\"}])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",         \"isFormula\",  \"formula\"],\n      [13,    \"A\",          \"Numeric\",      False,        \"\"],\n    ])\n    self.assertTableData('Schools', cols=\"subset\", data=[\n      [\"id\",  \"city\",         \"A\"   ],\n      [1,     \"New York\",     0.0   ],\n      [2,     \"Colombia\",     0.0   ],\n      [3,     \"New York\",     -17.6 ],\n      [4,     \"\",             0.0   ],\n    ])\n\n    # Undo, and set a formula for the new column instead.\n    self.apply_undo_actions(out_actions.undo)\n    self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 13, {'formula': 'len($city)'}])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",     \"isFormula\",  \"formula\"],\n      [13,    \"A\",          \"Any\",      True,         \"len($city)\"],\n    ])\n    self.assertTableData('Schools', cols=\"subset\", data=[\n      [\"id\",  \"city\",         \"A\" ],\n      [1,     \"New York\",     8   ],\n      [2,     \"Colombia\",     8   ],\n      [3,     \"New York\",     8   ],\n      [4,     \"\",             0   ],\n    ])\n\n    # Convert the formula column to non-formula.\n    self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 13, {'isFormula': False}])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", rows=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",     \"isFormula\",  \"formula\"],\n      [13,    \"A\",          \"Numeric\",  False,        \"len($city)\"],\n    ])\n    self.assertTableData('Schools', cols=\"subset\", data=[\n      [\"id\",  \"city\",         \"A\" ],\n      [1,     \"New York\",     8   ],\n      [2,     \"Colombia\",     8   ],\n      [3,     \"New York\",     8   ],\n      [4,     \"\",             0   ],\n    ])\n\n    # Add some more formula columns of type 'Any'.\n    self.apply_user_action(['AddColumn', 'Schools', None, {\"formula\": \"1\"}])\n    self.apply_user_action(['AddColumn', 'Schools', None, {\"formula\": \"'x'\"}])\n    self.apply_user_action(['AddColumn', 'Schools', None, {\"formula\": \"$city == 'New York'\"}])\n    self.apply_user_action(['AddColumn', 'Schools', None, {\"formula\": \"$city=='New York' or '-'\"}])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",         \"isFormula\",  \"formula\"],\n      [1,     \"manualSort\", \"ManualSortPos\",False,        \"\"],\n      [2,     \"city\",       \"Text\",         False,        \"\"],\n      [3,     \"state\",      \"Text\",         False,        \"\"],\n      [4,     \"size\",       \"Numeric\",      False,        \"\"],\n      [13,    \"A\",          \"Numeric\",      False,        \"len($city)\"],\n      [14,    \"B\",          \"Any\",          True,         \"1\"],\n      [15,    \"C\",          \"Any\",          True,         \"'x'\"],\n      [16,    \"D\",          \"Any\",          True,         \"$city == 'New York'\"],\n      [17,    \"E\",          \"Any\",          True,         \"$city=='New York' or '-'\"],\n    ], rows=lambda r: r.parentId.id == 1)\n    self.assertTableData('Schools', cols=\"subset\", data=[\n      [\"id\",  \"city\",         \"A\",  \"B\",  \"C\",  \"D\",    \"E\"],\n      [1,     \"New York\",     8,    1,    \"x\",  True,   True],\n      [2,     \"Colombia\",     8,    1,    \"x\",  False,  '-' ],\n      [3,     \"New York\",     8,    1,    \"x\",  True,   True],\n      [4,     \"\",             0,    1,    \"x\",  False,  '-' ],\n    ])\n\n    # Convert all these formulas to non-formulas, and see that their types get guessed OK.\n    # TODO: We should also guess Int, Bool, Reference, ReferenceList, Date, and DateTime.\n    # TODO: It is possibly better if B became Int, and D became Bool.\n    self.apply_user_action(['BulkUpdateRecord', '_grist_Tables_column', [14,15,16,17],\n                            {'isFormula': [False, False, False, False]}])\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",         \"isFormula\",  \"formula\"],\n      [1,     \"manualSort\", \"ManualSortPos\",False,        \"\"],\n      [2,     \"city\",       \"Text\",         False,        \"\"],\n      [3,     \"state\",      \"Text\",         False,        \"\"],\n      [4,     \"size\",       \"Numeric\",      False,        \"\"],\n      [13,    \"A\",          \"Numeric\",      False,        \"len($city)\"],\n      [14,    \"B\",          \"Numeric\",      False,        \"1\"],\n      [15,    \"C\",          \"Text\",         False,        \"'x'\"],\n      [16,    \"D\",          \"Text\",         False,        \"$city == 'New York'\"],\n      [17,    \"E\",          \"Text\",         False,        \"$city=='New York' or '-'\"],\n    ], rows=lambda r: r.parentId.id == 1)\n    self.assertTableData('Schools', cols=\"subset\", data=[\n      [\"id\",  \"city\",         \"A\",  \"B\",  \"C\",  \"D\",    \"E\"],\n      [1,     \"New York\",     8,    1.0,  \"x\",  \"True\",   'True'],\n      [2,     \"Colombia\",     8,    1.0,  \"x\",  \"False\",  '-'   ],\n      [3,     \"New York\",     8,    1.0,  \"x\",  \"True\",   'True'],\n      [4,     \"\",             0,    1.0,  \"x\",  \"False\",  '-'   ],\n    ])\n\n  #----------------------------------------------------------------------\n\n  def test_useraction_failures(self):\n    # Verify that when a useraction fails, we revert any changes already applied.\n\n    self.load_sample(self.sample)\n\n    # Simple failure: bad action (last argument should be a dict). It shouldn't cause any actions\n    # in the first place, just raise an exception about the argument being an int.\n    with self.assertRaisesRegex(AttributeError, r\"'int'\"):\n      self.apply_user_action(['AddColumn', 'Address', \"A\", 17])\n\n    # Do some successful actions, just to make sure we know what they look like.\n    self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (\n      ['AddColumn', 'Address', \"B\", {\"isFormula\": True}],\n      ['UpdateRecord', 'Address', 11, {\"city\": \"New York2\"}],\n    )])\n\n    # More complicated: here some actions should succeed, but get reverted when a later one fails.\n    with self.assertRaisesRegex(AttributeError, r\"'int'\"):\n      self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (\n        ['UpdateRecord', 'Address', 11, {\"city\": \"New York3\"}],\n        ['AddColumn', 'Address', \"C\", {\"isFormula\": True}],\n        ['AddColumn', 'Address', \"D\", 17]\n      )])\n\n    with self.assertRaisesRegex(Exception, r\"non-existent record #77\"):\n      self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (\n        ['UpdateRecord', 'Address', 11, {\"city\": \"New York4\"}],\n        ['UpdateRecord', 'Address', 77, {\"city\": \"Chicago\"}],\n      )])\n\n    # Make sure that no columns got added except the intentionally successful one.\n    self.assertTableData('_grist_Tables_column', cols=\"subset\", data=[\n      [\"id\",  \"colId\",      \"type\",         \"isFormula\",  \"formula\"],\n      [21,     \"city\",       \"Text\",         False,        \"\"],\n      [22,     \"B\",          \"Any\",          True,         \"\"],\n    ], rows=lambda r: r.parentId.id == 1)\n\n    # Make sure that no columns got added here either, and the only change to \"New York\" is the\n    # one in the successful user-action.\n    self.assertTableData('Address', cols=\"all\", data=[\n      [\"id\",  \"city\"      , \"B\"   ],\n      [11,    \"New York2\" , None  ],\n      [12,    \"Colombia\"  , None  ],\n      [13,    \"New Haven\" , None  ],\n      [14,    \"West Haven\", None  ],\n    ])\n\n  #----------------------------------------------------------------------\n\n  def test_pages_remove(self):\n    # Test that orphan pages get fixed after removing a page\n\n    self.init_views_sample()\n\n    # Moves page 2 to children of page 1.\n    self.apply_user_action(['BulkUpdateRecord', '_grist_Pages', [2], {'indentation': [1]}])\n    self.assertTableData('_grist_Pages', cols='subset', data=[\n      ['id', 'indentation'],\n      [   1,             0],\n      [   2,             1],\n      [   3,             0],\n    ])\n\n    # Verify that removing page 1 fixes page 2 indentation.\n    self.apply_user_action(['RemoveRecord', '_grist_Pages', 1])\n    self.assertTableData('_grist_Pages', cols='subset', data=[\n      ['id', 'indentation'],\n      [   2,             0],\n      [   3,             0],\n    ])\n\n    # Removing last page should not fail\n    # Verify that removing page 1 fixes page 2 indentation.\n    self.apply_user_action(['RemoveRecord', '_grist_Pages', 4])\n    self.assertTableData('_grist_Pages', cols='subset', data=[\n      ['id', 'indentation'],\n      [   2,             0],\n      [   3,             0],\n    ])\n\n    # Removing a page that has no children should do nothing\n    self.apply_user_action(['RemoveRecord', '_grist_Pages', 2])\n    self.assertTableData('_grist_Pages', cols='subset', data=[\n      ['id', 'indentation'],\n      [   3,             0],\n    ])\n\n  #----------------------------------------------------------------------\n\n  def test_rename_choices(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"ChoiceTable\", [\n          [1, \"ChoiceColumn\", \"Choice\", False, \"\", \"ChoiceColumn\", \"\"],\n        ]],\n        [2, \"ChoiceListTable\", [\n          [2, \"ChoiceListColumn\", \"ChoiceList\", False, \"\", \"ChoiceListColumn\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"ChoiceTable\": [\n          [\"id\", \"ChoiceColumn\"],\n          [1, \"a\"],\n          [2, \"b\"],\n          [3, \"c\"],\n          [4, \"d\"],\n          [5, None],\n          [6, 5],\n          [7, [[]]],\n        ],\n        \"ChoiceListTable\": [\n          [\"id\", \"ChoiceListColumn\"],\n          [1, [\"a\"]],\n          [2, [\"b\"]],\n          [3, [\"c\"]],\n          [4, [\"d\"]],\n          [5, None],\n          [7, [\"a\", \"b\"]],\n          [8, [\"b\", \"c\"]],\n          [9, [\"a\", \"c\"]],\n          [10, [\"a\", \"b\", \"c\"]],\n          [11, 5],\n          [12, [[]]],\n        ],\n      }\n    })\n    self.load_sample(sample)\n\n    # Renames go in a loop to make sure that works correctly\n    # a -> b -> c -> a -> b -> ...\n    renames = {\"a\": \"b\", \"b\": \"c\", \"c\": \"a\"}\n    out_actions_choice = self.apply_user_action(\n      [\"RenameChoices\", \"ChoiceTable\", \"ChoiceColumn\", renames])\n    out_actions_choice_list = self.apply_user_action(\n      [\"RenameChoices\", \"ChoiceListTable\", \"ChoiceListColumn\", renames])\n\n    self.assertPartialOutActions(\n      out_actions_choice,\n      {'stored':\n         [['BulkUpdateRecord',\n           'ChoiceTable',\n           [1, 2, 3],\n           {'ChoiceColumn': [u'b', u'c', u'a']}]]})\n\n    self.assertPartialOutActions(\n      out_actions_choice_list,\n      {'stored':\n         [['BulkUpdateRecord',\n           'ChoiceListTable',\n           [1, 2, 3, 7, 8, 9, 10],\n           {'ChoiceListColumn': [['L', u'b'],\n                                 ['L', u'c'],\n                                 ['L', u'a'],\n                                 ['L', u'b', u'c'],\n                                 ['L', u'c', u'a'],\n                                 ['L', u'b', u'a'],\n                                 ['L', u'b', u'c', u'a']]}]]})\n\n    self.assertTableData('ChoiceTable', data=[\n      [\"id\", \"ChoiceColumn\"],\n      [1, \"b\"],\n      [2, \"c\"],\n      [3, \"a\"],\n      [4, \"d\"],\n      [5, None],\n      [6, 5],\n      [7, [[]]],\n    ])\n\n    self.assertTableData('ChoiceListTable', data=[\n      [\"id\", \"ChoiceListColumn\"],\n      [1, [\"b\"]],\n      [2, [\"c\"]],\n      [3, [\"a\"]],\n      [4, [\"d\"]],\n      [5, None],\n      [7, [\"b\", \"c\"]],\n      [8, [\"c\", \"a\"]],\n      [9, [\"b\", \"a\"]],\n      [10, [\"b\", \"c\", \"a\"]],\n      [11, 5],\n      [12, [[]]],\n    ])\n\n    # Test filters rename\n\n    # Create new view section\n    self.apply_user_action([\"CreateViewSection\", 1, 0, \"record\", None, None])\n\n    # Filter it by first column\n    self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], {\n      \"viewSectionRef\": [1],\n      \"colRef\": [1],\n      \"filter\": [json.dumps({\"included\": [\"b\", \"c\"]})],\n      \"pinned\": [True],\n    }])\n\n    # Add the same filter for second column (to make sure it is not renamed)\n    self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], {\n      \"viewSectionRef\": [1],\n      \"colRef\": [2],\n      \"filter\": [json.dumps({\"included\": [\"b\", \"c\"]})],\n      \"pinned\": [False],\n    }])\n\n    # Rename choices\n    renames = {\"b\": \"z\", \"c\": \"b\"}\n    self.apply_user_action(\n      [\"RenameChoices\", \"ChoiceTable\", \"ChoiceColumn\", renames])\n\n    # Test filters\n    self.assertTableData('_grist_Filters', data=[\n      [\"id\", \"colRef\", \"filter\", \"setAutoRemove\", \"viewSectionRef\", \"pinned\"],\n      [1, 1, json.dumps({\"included\": [\"z\", \"b\"]}), None, 1, True],\n      [2, 2, json.dumps({\"included\": [\"b\", \"c\"]}), None, 1, False]\n    ])\n\n  def test_add_or_update(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [1, \"first_name\", \"Text\", False, \"\",     \"first_name\", \"\"],\n          [2, \"last_name\",  \"Text\", False, \"\",     \"last_name\",  \"\"],\n          [3, \"pet\",        \"Text\", False, \"\",     \"pet\",        \"\"],\n          [4, \"color\",      \"Text\", False, \"\",     \"color\",      \"\"],\n          [5, \"formula\",    \"Text\", True,  \"''\",   \"formula\",    \"\"],\n          [6, \"date\",       \"Date\", False, None,   \"date\",       \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"first_name\", \"last_name\"],\n          [1, \"John\", \"Doe\"],\n          [2, \"John\", \"Smith\"],\n        ],\n      }\n    })\n    self.load_sample(sample)\n\n    def check(require, values, options, stored):\n      self.assertPartialOutActions(\n        self.apply_user_action([\"AddOrUpdateRecord\", \"Table1\", require, values, options]),\n        {\"stored\": stored},\n      )\n\n    # Exactly one match, so on_many=none has no effect\n    check(\n      {\"first_name\": \"John\", \"last_name\": \"Smith\"},\n      {\"pet\": \"dog\", \"color\": \"red\"},\n      {\"on_many\": \"none\"},\n      [[\"UpdateRecord\", \"Table1\", 2, {\"color\": \"red\", \"pet\": \"dog\"}]],\n    )\n\n    # Look for a record with pet=dog and change it to pet=cat\n    check(\n      {\"first_name\": \"John\", \"pet\": \"dog\"},\n      {\"pet\": \"cat\"},\n      {},\n      [[\"UpdateRecord\", \"Table1\", 2, {\"pet\": \"cat\"}]],\n    )\n\n    # Two records match first_name=John, by default we only update the first\n    check(\n      {\"first_name\": \"John\"},\n      {\"color\": \"blue\"},\n      {},\n      [[\"UpdateRecord\", \"Table1\", 1, {\"color\": \"blue\"}]],\n    )\n\n    # Update all matching records\n    check(\n      {\"first_name\": \"John\"},\n      {\"color\": \"green\"},\n      {\"on_many\": \"all\"},\n      [\n        [\"BulkUpdateRecord\", \"Table1\", [1, 2], {\"color\": [\"green\", \"green\"]}],\n      ],\n    )\n\n    # Update all records with empty require and allow_empty_require\n    check(\n      {},\n      {\"color\": \"greener\"},\n      {\"on_many\": \"all\", \"allow_empty_require\": True},\n      [\n        [\"BulkUpdateRecord\", \"Table1\", [1, 2], {\"color\": [\"greener\", \"greener\"]}],\n      ],\n    )\n\n    # Missing allow_empty_require\n    with self.assertRaises(ValueError):\n      check(\n        {},\n        {\"color\": \"greenest\"},\n        {},\n        [],\n      )\n\n    # Don't update any records when there's several matches\n    check(\n      {\"first_name\": \"John\"},\n      {\"color\": \"yellow\"},\n      {\"on_many\": \"none\"},\n      [],\n    )\n\n    # Invalid value of on_many\n    with self.assertRaises(ValueError):\n      check(\n        {\"first_name\": \"John\"},\n        {\"color\": \"yellow\"},\n        {\"on_many\": \"other\"},\n        [],\n      )\n\n    # Since there's at least one matching record and update=False, do nothing\n    check(\n      {\"first_name\": \"John\"},\n      {\"color\": \"yellow\"},\n      {\"update\": False},\n      [],\n    )\n\n    # Since there's no matching records and add=False, do nothing\n    check(\n      {\"first_name\": \"John\", \"last_name\": \"Johnson\"},\n      {\"first_name\": \"Jack\", \"color\": \"yellow\"},\n      {\"add\": False},\n      [],\n    )\n\n    # No matching record, make a new one.\n    # first_name=Jack in `values` overrides first_name=John in `require`\n    check(\n      {\"first_name\": \"John\", \"last_name\": \"Johnson\"},\n      {\"first_name\": \"Jack\", \"color\": \"yellow\"},\n      {},\n      [\n        [\"AddRecord\", \"Table1\", 3,\n        {\"color\": \"yellow\", \"first_name\": \"Jack\", \"last_name\": \"Johnson\"}]\n      ],\n    )\n\n    # Specifying a row ID in `require` is allowed\n    check(\n      {\"first_name\": \"Bob\", \"id\": 100},\n      {\"pet\": \"fish\"},\n      {},\n      [[\"AddRecord\", \"Table1\", 100, {\"first_name\": \"Bob\", \"pet\": \"fish\"}]],\n    )\n\n    # Now the row already exists\n    check(\n      {\"first_name\": \"Bob\", \"id\": 100},\n      {\"pet\": \"fish\"},\n      {},\n      [],\n    )\n\n    # Nothing matches this `require`, but the row ID already exists\n    with self.assertRaises(AssertionError):\n      check(\n        {\"first_name\": \"Alice\", \"id\": 100},\n        {\"pet\": \"fish\"},\n        {},\n        [],\n      )\n\n    # Formula columns in `require` can't be used as values when creating records\n    check(\n      {\"formula\": \"anything\"},\n      {\"first_name\": \"Alice\"},\n      {},\n      [[\"AddRecord\", \"Table1\", 101, {\"first_name\": \"Alice\"}]],\n    )\n\n    with self.assertRaises(ValueError):\n      # Row ID too high\n      check(\n        {\"first_name\": \"Alice\", \"id\": 2000000},\n        {\"pet\": \"fish\"},\n        {},\n        [],\n      )\n\n    # Check that encoded objects are decoded correctly\n    check(\n      {\"date\": ['d', 950400]},\n      {},\n      {},\n      [[\"AddRecord\", \"Table1\", 102, {\"date\": 950400}]],\n    )\n    check(\n      {\"date\": ['d', 950400]},\n      {\"date\": ['d', 1900800]},\n      {},\n      [[\"UpdateRecord\", \"Table1\", 102, {\"date\": 1900800}]],\n    )\n\n    # Empty both does nothing\n    check(\n      {},\n      {},\n      {\"allow_empty_require\": True},\n      [],\n    )\n\n  def test_bulk_add_or_update(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [1, \"first_name\", \"Text\", False, \"\",     \"first_name\", \"\"],\n          [2, \"last_name\",  \"Text\", False, \"\",     \"last_name\",  \"\"],\n          [4, \"color\",      \"Text\", False, \"\",     \"color\",      \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"first_name\", \"last_name\"],\n          [1, \"John\", \"Doe\"],\n          [2, \"John\", \"Smith\"],\n        ],\n      }\n    })\n    self.load_sample(sample)\n\n    def check(require, values, options, stored):\n      self.assertPartialOutActions(\n        self.apply_user_action([\"BulkAddOrUpdateRecord\", \"Table1\", require, values, options]),\n        {\"stored\": stored},\n      )\n\n    check(\n      {\n        \"first_name\": [\n        \"John\",\n        \"John\",\n        \"John\",\n        \"Bob\",\n      ],\n      \"last_name\": [\n        \"Doe\",\n        \"Smith\",\n        \"Johnson\",\n        \"Johnson\",\n      ],\n      },\n      {\n        \"color\": [\n          \"red\",\n          \"blue\",\n          \"green\",\n          \"yellow\",\n        ],\n      },\n      {},\n      [\n        [\"BulkAddRecord\", \"Table1\", [3, 4], {\n          \"color\": [\"green\", \"yellow\"],\n          \"first_name\": [\"John\", \"Bob\"],\n          \"last_name\": [\"Johnson\", \"Johnson\"],\n        }],\n        [\"BulkUpdateRecord\", \"Table1\", [1, 2], {\"color\": [\"red\", \"blue\"]}],\n      ],\n    )\n\n    with self.assertRaises(ValueError) as cm:\n      check(\n        {\"color\": [\"yellow\"]},\n        {\"color\": [\"red\", \"blue\", \"green\"]},\n        {},\n        [],\n      )\n    self.assertEqual(\n      str(cm.exception),\n      'Value lists must all have the same length, '\n      'got {\"col_values color\": 3, \"require color\": 1}',\n    )\n\n    with self.assertRaises(ValueError) as cm:\n      check(\n        {\n          \"first_name\": [\n            \"John\",\n            \"John\",\n          ],\n          \"last_name\": [\n            \"Doe\",\n          ],\n        },\n        {},\n        {},\n        [],\n      )\n    self.assertEqual(\n      str(cm.exception),\n      'Value lists must all have the same length, '\n      'got {\"require first_name\": 2, \"require last_name\": 1}',\n    )\n\n    with self.assertRaises(ValueError) as cm:\n      check(\n        {\n          \"first_name\": [\n            \"John\",\n            \"John\",\n          ],\n          \"last_name\": [\n            \"Doe\",\n            \"Doe\",\n          ],\n        },\n        {},\n        {},\n        [],\n      )\n    self.assertEqual(\n      str(cm.exception),\n      \"require values must be unique\",\n    )\n\n  def test_reference_lookup(self):\n    sample = testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Table1\", [\n          [1, \"name\",    \"Text\",           False, \"\", \"name\",    \"\"],\n          [2, \"ref\",     \"Ref:Table1\",     False, \"\", \"ref\",     \"\"],\n          [3, \"reflist\", \"RefList:Table1\", False, \"\", \"reflist\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n        \"Table1\": [\n          [\"id\", \"name\"],\n          [1, \"a\"],\n          [2, \"b\"],\n        ],\n      }\n    })\n    self.load_sample(sample)\n    self.update_record(\"_grist_Tables_column\", 2, visibleCol=1)\n\n    # Normal case\n    out_actions = self.apply_user_action(\n      [\"UpdateRecord\", \"Table1\", 1, {\"ref\": [\"l\", \"b\", {\"column\": \"name\"}]}])\n    self.assertPartialOutActions(out_actions, {'stored': [\n      [\"UpdateRecord\", \"Table1\", 1, {\"ref\": 2}]]})\n\n    # Use ref.visibleCol (name) as default lookup column\n    out_actions = self.apply_user_action(\n      [\"UpdateRecord\", \"Table1\", 2, {\"ref\": [\"l\", \"a\"]}])\n    self.assertPartialOutActions(out_actions, {'stored': [\n      [\"UpdateRecord\", \"Table1\", 2, {\"ref\": 1}]]})\n\n    # No match found, generate alttext from value\n    out_actions = self.apply_user_action(\n      [\"UpdateRecord\", \"Table1\", 2, {\"ref\": [\"l\", \"foo\", {\"column\": \"name\"}]}])\n    self.assertPartialOutActions(out_actions, {'stored': [\n      [\"UpdateRecord\", \"Table1\", 2, {\"ref\": \"foo\"}]]})\n\n    # No match found, use provided alttext\n    out_actions = self.apply_user_action(\n      [\"UpdateRecord\", \"Table1\", 2, {\"ref\": [\"l\", \"foo\", {\"column\": \"name\", \"raw\": \"alt\"}]}])\n    self.assertPartialOutActions(out_actions, {'stored': [\n      [\"UpdateRecord\", \"Table1\", 2, {\"ref\": \"alt\"}]]})\n\n    # Normal case, adding instead of updating\n    out_actions = self.apply_user_action(\n      [\"AddRecord\", \"Table1\", 3,\n       {\"ref\": [\"l\", \"b\", {\"column\": \"name\"}],\n        \"name\": \"c\"}])\n    self.assertPartialOutActions(out_actions, {'stored': [\n      [\"AddRecord\", \"Table1\", 3,\n       {\"ref\": 2,\n        \"name\": \"c\"}]]})\n\n    # Testing reflist and bulk action\n    out_actions = self.apply_user_action(\n      [\"BulkUpdateRecord\", \"Table1\", [1, 2, 3],\n       {\"reflist\": [\n         [\"l\", \"c\", {\"column\": \"name\"}],  # value gets wrapped in list automatically\n         [\"l\", [\"a\", \"b\"], {\"column\": \"name\"}],  # normal case\n         # \"a\" matches but \"foo\" doesn't so the whole thing fails\n         [\"l\", [\"a\", \"foo\"], {\"column\": \"name\", \"raw\": \"alt\"}],\n       ]}])\n    self.assertPartialOutActions(out_actions, {'stored': [\n      [\"BulkUpdateRecord\", \"Table1\", [1, 2, 3],\n       {\"reflist\": [\n         [\"L\", 3],\n         [\"L\", 1, 2],\n         \"alt\",\n       ]}]]})\n\n    self.assertTableData('Table1', data=[\n      [\"id\", \"name\", \"ref\", \"reflist\"],\n      [1,    \"a\",    2,      [3]],\n      [2,    \"b\",    \"alt\",  [1, 2]],\n      [3,    \"c\",    2,      \"alt\"],\n    ])\n\n    # 'id' is used as the default visibleCol\n    out_actions = self.apply_user_action(\n      [\"BulkUpdateRecord\", \"Table1\", [1, 2],\n       {\"reflist\": [\n         [\"l\", 2],\n         [\"l\", 999],  # this row ID doesn't exist\n       ]}])\n    self.assertPartialOutActions(out_actions, {'stored': [\n      [\"BulkUpdateRecord\", \"Table1\", [1, 2],\n       {\"reflist\": [\n         [\"L\", 2],\n         \"999\",\n       ]}]]})\n\n  def test_num_rows(self):\n    self.load_sample(testutil.parse_test_sample({\n      \"SCHEMA\": [\n        [1, \"Address\", [\n          [21, \"city\", \"Text\", False, \"\", \"\", \"\"],\n        ]],\n      ],\n      \"DATA\": {\n      }\n    }))\n\n    table = self.engine.tables[\"Address\"]\n    for i in range(20):\n      self.add_record(\"Address\", None)\n      self.assertEqual(i + 1, table._num_rows())\n      self.assertEqual({1: i + 1, \"total\": i + 1}, self.engine.count_rows())\n\n  def test_raw_view_section_restrictions(self):\n    # load_sample handles loading basic metadata, but doesn't create any view sections\n    self.load_sample(self.sample)\n    # Create a new table which automatically gets a raw view section\n    self.apply_user_action([\"AddEmptyTable\", None])\n\n    # Note the row IDs of the raw view section (2) and fields (4, 5, 6)\n    self.assertTableData('_grist_Views_section', cols=\"subset\", data=[\n      [\"id\",  \"parentId\", \"tableRef\"],\n      [1, 1, 2],\n      [2, 0, 2],  # the raw view section\n      [3, 0, 2],  # the record card view section\n    ])\n    self.assertTableData('_grist_Views_section_field', cols=\"subset\", data=[\n      [\"id\",  \"parentId\"],\n      [1, 1],\n      [2, 1],\n      [3, 1],\n\n      # the raw view section\n      [4, 2],\n      [5, 2],\n      [6, 2],\n\n      # the record card view section\n      [7, 3],\n      [8, 3],\n      [9, 3],\n    ])\n\n    # Test that the records cannot be removed by normal user actions\n    with self.assertRaisesRegex(ValueError, \"Cannot remove raw view section$\"):\n      self.apply_user_action([\"RemoveRecord\", '_grist_Views_section', 2])\n    with self.assertRaisesRegex(ValueError, \"Cannot remove raw view section field$\"):\n      self.apply_user_action([\"RemoveRecord\", '_grist_Views_section_field', 4])\n\n    # and most of their column values can't be changed\n    with self.assertRaisesRegex(ValueError, \"Cannot modify raw view section$\"):\n      self.apply_user_action([\"UpdateRecord\", '_grist_Views_section', 2, {\"parentId\": 1}])\n    with self.assertRaisesRegex(ValueError, \"Cannot modify raw view section fields$\"):\n      self.apply_user_action([\"UpdateRecord\", '_grist_Views_section_field', 5, {\"parentId\": 1}])\n\n    # Confirm that the records are unchanged\n    self.assertTableData('_grist_Views_section', cols=\"subset\", data=[\n      [\"id\",  \"parentId\", \"tableRef\"],\n      [1, 1, 2],\n      [2, 0, 2],  # the raw view section\n      [3, 0, 2],  # the record card view section\n    ])\n    self.assertTableData('_grist_Views_section_field', cols=\"subset\", data=[\n      [\"id\",  \"parentId\"],\n      [1, 1],\n      [2, 1],\n      [3, 1],\n\n      # the raw view section\n      [4, 2],\n      [5, 2],\n      [6, 2],\n\n      # the record card view section\n      [7, 3],\n      [8, 3],\n      [9, 3],\n    ])\n\n  def test_record_card_view_section_restrictions(self):\n    self.load_sample(self.sample)\n    self.apply_user_action([\"AddEmptyTable\", None])\n\n    # Check that record card view sections cannot be removed by normal user actions.\n    with self.assertRaisesRegex(ValueError, \"Cannot remove record card view section$\"):\n      self.apply_user_action([\"RemoveRecord\", '_grist_Views_section', 3])\n\n    # Check that most of their column values can't be changed.\n    with self.assertRaisesRegex(ValueError, \"Cannot modify record card view section$\"):\n      self.apply_user_action([\"UpdateRecord\", '_grist_Views_section', 3, {\"parentId\": 1}])\n    with self.assertRaisesRegex(ValueError, \"Cannot modify record card view section fields$\"):\n      self.apply_user_action([\"UpdateRecord\", '_grist_Views_section_field', 9, {\"parentId\": 1}])\n\n    # Make sure nothing got removed or updated.\n    self.assertTableData('_grist_Views_section', cols=\"subset\", data=[\n      [\"id\", \"parentId\", \"tableRef\"],\n      [1, 1, 2],\n      [2, 0, 2],\n      [3, 0, 2],\n    ])\n    self.assertTableData('_grist_Views_section_field', cols=\"subset\", data=[\n      [\"id\", \"parentId\"],\n      [1, 1],\n      [2, 1],\n      [3, 1],\n      [4, 2],\n      [5, 2],\n      [6, 2],\n      [7, 3],\n      [8, 3],\n      [9, 3],\n    ])\n\n  def test_update_current_time(self):\n    self.load_sample(self.sample)\n    self.apply_user_action([\"AddEmptyTable\", None])\n    self.add_column('Table1', 'now', isFormula=True, formula='NOW()', type='Any')\n\n    # No records with NOW() in a formula yet, so this action should have no effect at all.\n    out_actions = self.apply_user_action([\"UpdateCurrentTime\"])\n    self.assertOutActions(out_actions, {})\n\n    class FakeDatetime(object):\n      counter = 0\n\n      @classmethod\n      def now(cls, *_):\n        cls.counter += 1\n        return cls.counter\n\n    import datetime\n    original = datetime.datetime\n    # This monkeypatch depends on NOW() using `import datetime`\n    # as opposed to `from datetime import datetime`\n    datetime.datetime = FakeDatetime\n\n    def check(expected_now):\n      self.assertEqual(expected_now, FakeDatetime.counter)\n      self.assertTableData('Table1', cols=\"subset\", data=[\n        [\"id\", \"now\"],\n        [1, expected_now],\n      ])\n\n    try:\n      # The counter starts at 0. Adding an initial record calls FakeDatetime.now() for the 1st time.\n      # The call increments the counter to 1 before returning.\n      self.add_record('Table1')\n      check(1)\n\n      # Testing that unrelated actions don't change the time\n      self.apply_user_action([\"AddEmptyTable\", None])\n      self.add_record(\"Table2\")\n      self.apply_user_action([\"Calculate\"])  # only recalculates for fresh docs\n      check(1)\n\n      # Actually testing that the time is updated as requested\n      self.apply_user_action([\"UpdateCurrentTime\"])\n      check(2)\n      out_actions = self.apply_user_action([\"UpdateCurrentTime\"])\n      check(3)\n      self.assertOutActions(out_actions, {\n        \"direct\": [False],\n        \"stored\": [[\"UpdateRecord\", \"Table1\", 1, {\"now\": 3}]],\n        \"undo\": [[\"UpdateRecord\", \"Table1\", 1, {\"now\": 2}]],\n      })\n    finally:\n      # Revert the monkeypatch\n      datetime.datetime = original\n\n  def test_duplicate_table(self):\n    self.load_sample(self.sample)\n\n    # Create a new table, Table1, and populate it with some data.\n    self.apply_user_action(['AddEmptyTable', None])\n    self.apply_user_action(['AddColumn', 'Table1', None, {\n      'formula': '$B * 100 + len(Table1.all)',\n    }])\n    self.add_column('Table1', 'E',\n      type='DateTime:UTC', isFormula=False, formula=\"NOW()\", recalcWhen=RecalcWhen.MANUAL_UPDATES)\n    self.apply_user_action(['AddColumn', 'Table1', None, {\n      'type': 'Ref:Address',\n      'visibleCol': 21,\n    }])\n    self.apply_user_action(['AddColumn', 'Table1', None, {\n      'type': 'Ref:Table1',\n      'visibleCol': 23,\n    }])\n    self.apply_user_action(['AddColumn', 'Table1', None, {\n      'type': 'RefList:Table1',\n      'visibleCol': 23,\n    }])\n    self.apply_user_action(['BulkAddRecord', 'Table1', [1, 2, 3, 4], {\n      'A': ['Foo', 'Bar', 'Baz', ''],\n      'B': [123, 456, 789, 0],\n      'C': ['', '', '', ''],\n      'F': [11, 12, 0, 0],\n      'G': [1, 2, 0, 0],\n      'H': [['L', 1, 2], ['L', 1], None, None],\n    }])\n\n    # Add a row conditional style.\n    self.apply_user_action(['AddEmptyRule', 'Table1', 0, 0])\n    rules = self.engine.docmodel.tables.lookupOne(tableId='Table1').rawViewSectionRef.rules\n    rule = list(rules)[0]\n    self.apply_user_action(['UpdateRecord', '_grist_Tables_column', rule.id, {\n      'formula': 'rec.id % 2 == 0',\n    }])\n\n    # Add a column conditional style.\n    self.apply_user_action(['AddEmptyRule', 'Table1', 0, 23])\n    rules = self.engine.docmodel.columns.table.get_record(23).rules\n    rule = list(rules)[0]\n    self.apply_user_action(['UpdateRecord', '_grist_Tables_column', rule.id, {\n      'formula': '$A == \"Foo\"',\n    }])\n\n    # Add a column and widget description.\n    self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 23, {\n      'description': 'A column description.',\n    }])\n    self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2, {\n      'description': 'A widget description.',\n    }])\n\n    # Duplicate Table1 as Foo without including any of its data.\n    self.apply_user_action(['DuplicateTable', 'Table1', 'Foo', False])\n\n    # Check that the correct table and options were duplicated.\n    existing_table = Table(2, 'Table1', primaryViewId=1, summarySourceTable=0, columns=[\n      Column(22, 'manualSort', 'ManualSortPos', isFormula=False, formula='', summarySourceCol=0),\n      Column(23, 'A', 'Text', isFormula=False, formula='', summarySourceCol=0),\n      Column(24, 'B', 'Numeric', isFormula=False, formula='', summarySourceCol=0),\n      Column(25, 'C', 'Any', isFormula=True, formula='', summarySourceCol=0),\n      Column(26, 'D', 'Any', isFormula=True, formula='$B * 100 + len(Table1.all)',\n        summarySourceCol=0),\n      Column(27, 'E', 'DateTime:UTC', isFormula=False, formula='NOW()',\n        summarySourceCol=0),\n      Column(28, 'F', 'Ref:Address', isFormula=False, formula='', summarySourceCol=0),\n      Column(29, 'G', 'Ref:Table1', isFormula=False, formula='', summarySourceCol=0),\n      Column(30, 'H', 'RefList:Table1', isFormula=False, formula='', summarySourceCol=0),\n      Column(31, 'gristHelper_RowConditionalRule', 'Any', isFormula=True,\n        formula='rec.id % 2 == 0', summarySourceCol=0),\n      Column(32, 'gristHelper_ConditionalRule', 'Any', isFormula=True, formula='$A == \\\"Foo\\\"',\n        summarySourceCol=0),\n    ])\n    duplicated_table = Table(3, 'Foo', primaryViewId=0, summarySourceTable=0, columns=[\n      Column(33, 'manualSort', 'ManualSortPos', isFormula=False, formula='', summarySourceCol=0),\n      Column(34, 'A', 'Text', isFormula=False, formula='', summarySourceCol=0),\n      Column(35, 'B', 'Numeric', isFormula=False, formula='', summarySourceCol=0),\n      Column(36, 'C', 'Any', isFormula=True, formula='', summarySourceCol=0),\n      Column(37, 'D', 'Any', isFormula=True, formula='$B * 100 + len(Foo.all)',\n        summarySourceCol=0),\n      Column(38, 'E', 'DateTime:UTC', isFormula=False, formula='NOW()',\n        summarySourceCol=0),\n      Column(39, 'F', 'Ref:Address', isFormula=False, formula='', summarySourceCol=0),\n      Column(40, 'G', 'Ref:Foo', isFormula=False, formula='', summarySourceCol=0),\n      Column(41, 'H', 'RefList:Foo', isFormula=False, formula='', summarySourceCol=0),\n      Column(42, 'gristHelper_ConditionalRule', 'Any', isFormula=True, formula='$A == \\\"Foo\\\"',\n        summarySourceCol=0),\n      Column(43, 'gristHelper_RowConditionalRule', 'Any', isFormula=True,\n        formula='rec.id % 2 == 0', summarySourceCol=0),\n    ])\n    self.assertTables([self.starting_table, existing_table, duplicated_table])\n    self.assertTableData('Foo', data=[\n      [\"id\", \"A\", \"B\", \"C\", \"D\", \"E\", \"F\", \"G\", \"H\", \"gristHelper_ConditionalRule\",\n        \"gristHelper_RowConditionalRule\", \"manualSort\"],\n    ])\n    self.assertTableData('_grist_Tables_column', rows='subset', cols='subset', data=[\n      ['id', 'description'],\n      [23, 'A column description.'],\n      [34, 'A column description.'],\n    ])\n    self.assertTableData('_grist_Views_section', rows='subset', cols='subset', data=[\n      ['id', 'description'],\n      [2, 'A widget description.'],\n      [4, 'A widget description.'],\n    ])\n\n    # Duplicate Table1 as FooData and include all of its data.\n    self.apply_user_action(['DuplicateTable', 'Table1', 'FooData', True])\n\n    # Check that the correct table, options, and data were duplicated.\n    duplicated_table_with_data = Table(4, 'FooData', primaryViewId=0, summarySourceTable=0,\n      columns=[\n        Column(44, 'manualSort', 'ManualSortPos', isFormula=False, formula='', summarySourceCol=0),\n        Column(45, 'A', 'Text', isFormula=False, formula='', summarySourceCol=0),\n        Column(46, 'B', 'Numeric', isFormula=False, formula='', summarySourceCol=0),\n        Column(47, 'C', 'Any', isFormula=True, formula='', summarySourceCol=0),\n        Column(48, 'D', 'Any', isFormula=True, formula='$B * 100 + len(FooData.all)',\n          summarySourceCol=0),\n        Column(49, 'E', 'DateTime:UTC', isFormula=False, formula='NOW()',\n          summarySourceCol=0),\n        Column(50, 'F', 'Ref:Address', isFormula=False, formula='', summarySourceCol=0),\n        Column(51, 'G', 'Ref:FooData', isFormula=False, formula='', summarySourceCol=0),\n        Column(52, 'H', 'RefList:FooData', isFormula=False, formula='', summarySourceCol=0),\n        Column(53, 'gristHelper_ConditionalRule', 'Any', isFormula=True, formula='$A == \\\"Foo\\\"',\n          summarySourceCol=0),\n        Column(54, 'gristHelper_RowConditionalRule', 'Any', isFormula=True,\n          formula='rec.id % 2 == 0', summarySourceCol=0),\n      ]\n    )\n    self.assertTables([\n      self.starting_table, existing_table, duplicated_table, duplicated_table_with_data])\n    self.assertTableData('Foo', data=[\n      [\"id\", \"A\", \"B\", \"C\", \"D\", \"E\", \"F\", \"G\", \"H\", \"gristHelper_ConditionalRule\",\n        \"gristHelper_RowConditionalRule\", \"manualSort\"],\n    ], rows=\"subset\")\n    self.assertTableData('FooData', data=[\n      [\"id\", \"A\", \"B\", \"C\", \"D\", \"F\", \"G\", \"H\", \"gristHelper_ConditionalRule\",\n        \"gristHelper_RowConditionalRule\", \"manualSort\"],\n      [1, 'Foo', 123, None, 12304.0, 11, 1, [1, 2], True, False, 1.0],\n      [2, 'Bar', 456, None, 45604.0, 12, 2, [1], False, True, 2.0],\n      [3, 'Baz', 789, None, 78904.0, 0, 0, None, False, False, 3.0],\n      [4, '', 0, None, 4.0, 0, 0, None, False, True, 4.0],\n    ], cols=\"subset\")\n\n    # Check that values for the duplicated trigger formula were not re-calculated.\n    existing_times = self.engine.fetch_table('Table1').columns['E']\n    duplicated_times = self.engine.fetch_table('FooData').columns['E']\n    self.assertEqual(existing_times, duplicated_times)\n\n  def test_duplicate_table_untie_col_id_bug(self):\n    # This test case verifies a bug fix: when a column doesn't match its label despite\n    # untieColIdFromLabel being False (which is possible), ensure that duplicating still works.\n\n    self.load_sample(self.sample)\n\n    # This is the problem situation: \"State2\" doesn't match \"State\". It can happen legitimately in\n    # the wild if a second column labeled \"State\" is added, and then the first one removed.\n    self.apply_user_action(['AddTable', 'Table1', [\n      {'id': 'State2', 'type': 'Text', 'label': 'State'}\n    ]])\n    self.apply_user_action(['BulkAddRecord', 'Table1', [1], {\n      'State2': ['NY'],\n    }])\n    self.apply_user_action(['DuplicateTable', 'Table1', 'Foo', True])\n    self.assertTableData('Table1', data=[[\"id\", \"State2\", 'manualSort'], [1, 'NY', 1.0]])\n    self.assertTableData('Foo', data=[[\"id\", \"State2\", 'manualSort'], [1, 'NY', 1.0]])\n\n  def test_duplicate_table_record_card(self):\n    self.load_sample(self.sample)\n    self.apply_user_action(['AddEmptyTable', None])\n    self.apply_user_action(['AddColumn', 'Table1', None, {\n      'type': 'Ref:Table1',\n      'visibleCol': 23,\n    }])\n    self.apply_user_action(['AddColumn', 'Table1', None, {\n      'type': 'RefList:Table1',\n      'visibleCol': 24,\n    }])\n    self.apply_user_action(['BulkUpdateRecord', '_grist_Views_section_field', [11, 13], {\n      'visibleCol': [23, 24],\n    }])\n    self.apply_user_action(['UpdateRecord', '_grist_Views_section', 3, {\n      'layoutSpec': '{\"children\":[{\"children\":[{\"leaf\":7},{\"leaf\":8}]},{\"leaf\":9},{\"leaf\":11}]}',\n      'options': '{\"verticalGridlines\":true,\"horizontalGridlines\":true,\"zebraStripes\":false,' +\n        '\"customView\":\"\",\"numFrozen\":0,\"disabled\":true}',\n      'theme': 'compact',\n    }])\n    self.apply_user_action(['DuplicateTable', 'Table1', 'Foo', False])\n\n    self.assertTableData('_grist_Views_section', rows=\"subset\", cols=\"subset\", data=[\n      [\"id\", \"parentId\", \"tableRef\", \"layoutSpec\", \"options\", \"theme\"],\n      # The original record card section.\n      [3, 0, 2, '{\"children\":[{\"children\":[{\"leaf\":7},{\"leaf\":8}]},{\"leaf\":9},{\"leaf\":11}]}',\n        '{\"verticalGridlines\":true,\"horizontalGridlines\":true,\"zebraStripes\":false,' +\n          '\"customView\":\"\",\"numFrozen\":0,\"disabled\":true}', 'compact'],\n      # The duplicated record card section.\n      [5, 0, 3,\n        '{\"children\": [{\"children\": [{\"leaf\": 19}, {\"leaf\": 20}]}, {\"leaf\": 21}, ' +\n          '{\"leaf\": 22}]}',\n        '{\"verticalGridlines\":true,\"horizontalGridlines\":true,\"zebraStripes\":false,' +\n          '\"customView\":\"\",\"numFrozen\":0,\"disabled\":true}', 'compact'],\n    ])\n    self.assertTableData('_grist_Views_section_field', rows=\"subset\", cols=\"subset\", data=[\n      [\"id\", \"parentId\", \"parentPos\", \"visibleCol\"],\n      # The original record card fields.\n      [7, 3, 7.0, 0],\n      [8, 3, 8.0, 0],\n      [9, 3, 9.0, 0],\n      [11, 3, 11.0, 23],\n      [13, 3, 13.0, 24],\n      [19, 5, 6.5, 0],\n      [20, 5, 7.5, 0],\n      [21, 5, 8.5, 0],\n      [22, 5, 10.5, 29],\n      [23, 5, 12.5, 30],\n    ])\n"
  },
  {
    "path": "sandbox/grist/testsamples.py",
    "content": "import testutil\n\n# pylint: disable=line-too-long\nsample_students = testutil.parse_test_sample({\n  \"SCHEMA\": [\n    [1, \"Students\", [\n      [1, \"firstName\",   \"Text\",        False, \"\", \"\", \"\"],\n      [2, \"lastName\",    \"Text\",        False, \"\", \"\", \"\"],\n      [4, \"schoolName\",  \"Text\",        False, \"\", \"\", \"\"],\n      [5, \"schoolIds\",   \"Text\",        True, \"':'.join(str(id) for id in Schools.lookupRecords(name=$schoolName).id)\", \"\", \"\"],\n      [6, \"schoolCities\",\"Text\",        True, \"':'.join(r.address.city for r in Schools.lookupRecords(name=$schoolName))\", \"\", \"\"],\n    ]],\n    [2, \"Schools\", [\n      [10, \"name\",        \"Text\",       False, \"\", \"\", \"\"],\n      [12, \"address\",     \"Ref:Address\",False, \"\", \"\", \"\"]\n    ]],\n    [3, \"Address\", [\n      [21, \"city\",        \"Text\",       False, \"\", \"\", \"\"],\n    ]]\n  ],\n  \"DATA\": {\n    \"Students\": [\n      [\"id\",\"firstName\",\"lastName\", \"schoolName\" ],\n      [1,   \"Barack\",   \"Obama\",    \"Columbia\"   ],\n      [2,   \"George W\", \"Bush\",     \"Yale\"       ],\n      [3,   \"Bill\",     \"Clinton\",  \"Columbia\"   ],\n      [4,   \"George H\", \"Bush\",     \"Yale\"       ],\n      [5,   \"Ronald\",   \"Reagan\",   \"Eureka\"     ],\n      [6,   \"Gerald\",   \"Ford\",     \"Yale\"       ]],\n    \"Schools\": [\n      [\"id\",  \"name\",     \"address\"],\n      [1,     \"Columbia\", 11],\n      [2,     \"Columbia\", 12],\n      [3,     \"Yale\",     13],\n      [4,     \"Yale\",     14]],\n    \"Address\": [\n      [\"id\",  \"city\"       ],\n      [11,    \"New York\"   ],\n      [12,    \"Colombia\"   ],\n      [13,    \"New Haven\"  ],\n      [14,    \"West Haven\" ]],\n  }\n})\n"
  },
  {
    "path": "sandbox/grist/testscript.json",
    "content": "[{\n  //----------------------------------------------------------------------\n  \"SAMPLE_NAME\": \"basic\",\n  //----------------------------------------------------------------------\n  \"SCHEMA\": [\n    [1, \"Students\", [\n      [1, \"firstName\",   \"Text\",        false, \"\", \"First Name\", \"\"],\n      [2, \"lastName\",    \"Text\",        false, \"\", \"Last Name\", \"\"],\n      [3, \"fullName\",    \"Any\",         true, \"rec.firstName + ' ' + rec.lastName\", \"Full Name\", \"\"],\n      [4, \"fullNameLen\", \"Any\",         true, \"len(rec.fullName)\", \"Full Name Length\", \"\"],\n      [5, \"school\",      \"Ref:Schools\", false, \"\", \"school\", \"\"],\n      [6, \"schoolShort\",  \"Any\",        true, \"rec.school.name.split(' ')[0]\", \"School Short\", \"\"],\n      [9, \"schoolRegion\", \"Any\",        true,\n      \"addr = $school.address\\naddr.state if addr.country == 'US' else addr.region\", \"School Region\", \"\"]\n    ]],\n    [2, \"Schools\", [\n      [10, \"name\",        \"Text\",       false, \"\", \"Name\", \"\"],\n      [12, \"address\",     \"Ref:Address\",false, \"\", \"Address\", \"\"]\n    ]],\n    [3, \"Address\", [\n      [21, \"city\",        \"Text\",       false, \"\", \"City\", \"\"],\n      [27, \"state\",       \"Text\",       false, \"\", \"State\", \"\"],\n      [28, \"country\",     \"Text\",       false, \"'US'\", \"Country\", \"\"],\n      [29, \"region\",      \"Any\",        true,\n            \"{'US': 'North America', 'UK': 'Europe'}.get(rec.country, 'N/A')\", \"region\", \"\"]\n    ]]\n  ],\n  \"DATA\": {\n    \"Students\": [\n      [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",\"@fullNameLen\",\"@schoolShort\",\"@schoolRegion\"],\n      [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\",  12,     \"Columbia\",   \"NY\"],\n      [2,   \"George W\", \"Bush\",    8,       \"George W Bush\", 13,     \"Yale\",       \"CT\"],\n      [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\",  12,     \"Oxford\",     \"Europe\"],\n      [4,   \"George H\", \"Bush\",    8,       \"George H Bush\", 13,     \"Yale\",       \"CT\"],\n      [5,   \"Ronald\",   \"Reagan\",  1,       \"Ronald Reagan\", 13,     \"Eureka\",     \"IL\"],\n      [6,   \"Jimmy\",    \"Carter\",  5,       \"Jimmy Carter\",  12,     \"U.S.\",       \"MD\"],\n      [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",   11,     \"Yale\",       \"CT\"]],\n    \"Schools\": [\n      [\"id\",  \"name\",                 \"address\"],\n      [1,     \"Eureka College\",       2],\n      [2,     \"Columbia University\",  7],\n      [5,     \"U.S. Naval Academy\",   3],\n      [6,     \"Oxford University\",    10],\n      [7,     \"Yale Law School\",      4],\n      [8,     \"Yale University\",      4]],\n    \"Address\": [\n      [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n      [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n      [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n      [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n      [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n      [10,    \"Oxford\",     \"England\",  \"UK\",      \"Europe\"]]\n  }\n}, {\n\n  //----------------------------------------------------------------------\n  \"SAMPLE_NAME\": \"simplest\",\n  //----------------------------------------------------------------------\n  \"SCHEMA\": [\n    [1, \"foo\", [\n      [1, \"bar\", \"Text\", false, \"\", \"bar\", \"\"]\n    ]]\n  ],\n  \"DATA\": {\n    \"foo\": [\n      [\"id\", \"bar\"],\n      [1,    \"apple\"]\n    ]\n  }\n\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"basic_load\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n    [\"CHECK_OUTPUT\", {\"USE_SAMPLE\": \"basic\"}]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"add_record\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n    [\"APPLY\", {\n      // Ensure basic AddRecord works, generates undo, and triggers a formula calculation.\n      \"USER_ACTION\": [\"AddRecord\", \"Address\", 11, {\n        \"city\": \"Washington\",\n        \"state\": \"DC\",\n        \"country\": \"US\"\n      }],\n      \"ACTIONS\": {\n        // Essentially a duplicate of the UserAction, but as a DocAction.\n        \"stored\": [[\"AddRecord\", \"Address\", 11, {\n          \"city\": \"Washington\",\n          \"state\": \"DC\",\n          \"country\": \"US\"\n          }],\n          [\"UpdateRecord\", \"Address\", 11, {\"region\": \"North America\"}]\n        ],\n        \"direct\": [true, false],\n        \"undo\": [[\"RemoveRecord\", \"Address\", 11]],\n        \"retValue\": [ 11 ]\n      },\n     \"CHECK_CALL_COUNTS\": {\n       \"Address\" : {\"region\" : 1, \"#lookup#\": 1}\n     }\n    }],\n    [\"APPLY\", {\n      // Add a Schools record which refers to the just-added Address.\n      \"USER_ACTION\": [\"AddRecord\", \"Schools\", 9, {\n        \"name\": \"Georgetown University\",\n        \"address\": 11\n      }],\n      \"ACTIONS\": {\n        \"stored\": [[\"AddRecord\", \"Schools\", 9, {\n          \"name\": \"Georgetown University\",\n          \"address\": 11\n        }]],\n        \"direct\": [true],\n        \"undo\": [[\"RemoveRecord\", \"Schools\", 9]],\n        \"retValue\": [9]\n      }\n    }],\n    [\"APPLY\", {\n      // Set a student to the new school, and ensure formulas see the correct school and address.\n      \"USER_ACTION\": [\"UpdateRecord\", \"Students\", 3, {\"school\": 9}],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"UpdateRecord\", \"Students\", 3, {\"school\": 9}],\n          [\"UpdateRecord\", \"Students\", 3, {\"schoolRegion\": \"DC\"}],\n          [\"UpdateRecord\", \"Students\", 3, {\"schoolShort\": \"Georgetown\"}]\n        ],\n        \"direct\": [true, false, false],\n        \"undo\": [\n          [\"UpdateRecord\", \"Students\", 3, {\"school\": 6}],\n          [\"UpdateRecord\", \"Students\", 3, {\"schoolRegion\": \"Europe\"}],\n          [\"UpdateRecord\", \"Students\", 3, {\"schoolShort\": \"Oxford\"}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\": {\n        \"Students\" : { \"schoolShort\" : 1, \"schoolRegion\" : 1 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"Students\": [\n        [\"id\", \"firstName\", \"lastName\", \"school\", \"@fullName\", \"@fullNameLen\", \"@schoolShort\",\n         \"@schoolRegion\"],\n        [1,    \"Barack\",    \"Obama\",    2,        \"Barack Obama\",  12,    \"Columbia\",    \"NY\"],\n        [2,    \"George W\",  \"Bush\",     8,        \"George W Bush\", 13,    \"Yale\",        \"CT\"],\n        [3,    \"Bill\",      \"Clinton\",  9,        \"Bill Clinton\",  12,    \"Georgetown\",  \"DC\"],\n        [4,    \"George H\",  \"Bush\",     8,        \"George H Bush\", 13,    \"Yale\",        \"CT\"],\n        [5,    \"Ronald\",    \"Reagan\",   1,        \"Ronald Reagan\", 13,    \"Eureka\",      \"IL\"],\n        [6,    \"Jimmy\",     \"Carter\",   5,        \"Jimmy Carter\",  12,    \"U.S.\",        \"MD\"],\n        [8,    \"Gerald\",    \"Ford\",     7,        \"Gerald Ford\",   11,    \"Yale\",        \"CT\"]],\n      \"Schools\": [\n        [\"id\", \"name\",                  \"address\"],\n        [1,    \"Eureka College\",        2],\n        [2,    \"Columbia University\",   7],\n        [5,    \"U.S. Naval Academy\",    3],\n        [6,    \"Oxford University\",     10],\n        [7,    \"Yale Law School\",       4],\n        [8,    \"Yale University\",       4],\n        [9,    \"Georgetown University\", 11]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"Europe\"],\n        [11,    \"Washington\", \"DC\",       \"US\",      \"North America\"]]\n    }],\n\n    // Check that AddRecord applies default formulas values correctly\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        [\"ModifyColumn\", \"Schools\", \"name\", { \"formula\" : \"'Williams College, ' + $address.city\" }],\n        [\"AddRecord\"   , \"Schools\", 10, { \"address\": 2}],\n        [\"AddRecord\"   , \"Schools\", 11, { \"name\" : \"Amherst College\"}]\n      ],\n\n      \"ACTIONS\": {\n        \"stored\": [\n           [\"ModifyColumn\", \"Schools\", \"name\", {\"formula\": \"'Williams College, ' + $address.city\"}],\n           [\"UpdateRecord\", \"_grist_Tables_column\", 10, {\"formula\": \"'Williams College, ' + $address.city\"}],\n           [\"AddRecord\", \"Schools\", 10, { \"address\" : 2}],\n           [\"AddRecord\", \"Schools\", 11, {\"name\": \"Amherst College\"}],\n           [\"UpdateRecord\", \"Schools\", 10, {\"name\": \"Williams College, Eureka\"}]\n          ],\n        \"direct\": [true, true, true, true, false],\n        \"undo\": [\n           [\"ModifyColumn\", \"Schools\", \"name\", {\"formula\": \"\"}],\n           [\"UpdateRecord\", \"_grist_Tables_column\", 10, {\"formula\": \"\"}],\n           [\"RemoveRecord\", \"Schools\", 10],\n           [\"RemoveRecord\", \"Schools\", 11]\n          ]\n      },\n      \"CHECK_CALL_COUNTS\": {\n        \"Schools\": { \"name\": 1, \"#lookup#\": 2 },\n        \"Students\" : { \"schoolShort\" : 7 }\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTION\": [\n        \"UpdateRecord\", \"Schools\", 10, { \"address\": 3}\n      ],\n      \"ACTIONS\": {\n        // This tests that the formula for 'name' does NOT get recomputed\n        // As it would, if default formulas created dependencies\n        \"stored\" : [[\"UpdateRecord\", \"Schools\", 10, { \"address\": 3}]],\n        \"direct\" : [true],\n        \"undo\"   : [[\"UpdateRecord\", \"Schools\", 10, { \"address\": 2}]]\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        [\"ModifyColumn\", \"Schools\", \"name\", { \"formula\" :  \"str($id) + '$' + '\\\\$' + \\\"$'\\\\\\\\'\\\"\" }],\n        [\"AddRecord\"   , \"Schools\", 12, { \"address\" : 70 }]\n      ],\n\n      \"ACTIONS\": {\n        \"stored\": [\n           [\"ModifyColumn\", \"Schools\", \"name\", {\"formula\": \"str($id) + '$' + '\\\\$' + \\\"$'\\\\\\\\'\\\"\"}],\n           [\"UpdateRecord\", \"_grist_Tables_column\", 10, {\"formula\": \"str($id) + '$' + '\\\\$' + \\\"$'\\\\\\\\'\\\"\"}],\n           [\"AddRecord\", \"Schools\", 12, {\"address\": 70}],\n           [\"UpdateRecord\", \"Schools\", 12, {\"name\": \"12$\\\\$$'\\\\'\"}]\n        ],\n        \"direct\": [true, true, true, false],\n        \"undo\": [\n           [\"ModifyColumn\", \"Schools\", \"name\", {\"formula\": \"'Williams College, ' + $address.city\"}],\n           [\"UpdateRecord\", \"_grist_Tables_column\", 10, {\"formula\": \"'Williams College, ' + $address.city\"}],\n           [\"RemoveRecord\", \"Schools\", 12]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Schools\": { \"name\": 1, \"#lookup#\": 1 },\n        \"Students\" : { \"schoolShort\" : 7 }\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        // Does Adding Records properly set properties?\n        [\"AddColumn\", \"Schools\", \"numStudents\", { \"type\" : \"Int\", \"isFormula\" : false }],\n        [\"AddRecord\", \"Schools\", 13, {\"name\" : \"NYU\", \"numStudents\": \"One-thousand\"}],\n        [\"AddRecord\", \"Schools\", null, {\"numStudents\": \"1000000000000000000000000000\"}],\n        [\"AddRecord\", \"Schools\", null, {\"numStudents\": \"1000000\"}],\n        [\"ModifyColumn\", \"Schools\", \"numStudents\", { \"type\" : \"Numeric\" }],\n        [\"AddRecord\", \"Schools\", null, { \"numStudents\" : \"Infinity\" }]\n      ],\n\n      \"ACTIONS\" : {\n        \"stored\": [\n          [\"AddColumn\", \"Schools\", \"numStudents\",\n            {\"formula\": \"\", \"isFormula\": false, \"type\": \"Int\" }],\n          [\"AddRecord\", \"_grist_Tables_column\", 30,\n            {\"colId\": \"numStudents\", \"widgetOptions\": \"\", \"formula\": \"\", \"isFormula\": false,\n             \"label\": \"numStudents\", \"parentId\": 2, \"parentPos\": 8.0, \"type\": \"Int\"}],\n          [\"AddRecord\", \"Schools\", 13, {\"name\": \"NYU\", \"numStudents\": \"One-thousand\"}],\n          [\"AddRecord\", \"Schools\", 14, {\"numStudents\": \"1000000000000000000000000000\"}],\n          [\"AddRecord\", \"Schools\", 15, {\"numStudents\": 1000000}],\n\n          [\"ModifyColumn\", \"Schools\", \"numStudents\", {\"type\": \"Numeric\"}],\n          // Record 13 is not updated since it can't be properly converted.\n          // Other records aren't updated because the converted value is equivalent for JSON and DB.\n          [\"UpdateRecord\", \"Schools\", 14, {\"numStudents\": 1e+27}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 30, {\"type\": \"Numeric\"}],\n          [\"AddRecord\", \"Schools\", 16, {\"numStudents\": \"@+Infinity\"}],\n          [\"BulkUpdateRecord\", \"Schools\", [14, 15, 16], {\"name\": [\"14$\\\\$$'\\\\'\", \"15$\\\\$$'\\\\'\", \"16$\\\\$$'\\\\'\"]}]\n       ],\n        \"direct\": [true, true, true, true, true,\n                   true, false, true,\n                   true, false],\n        \"undo\"  : [\n          [\"RemoveColumn\", \"Schools\", \"numStudents\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 30],\n          [\"RemoveRecord\", \"Schools\", 13],\n          [\"RemoveRecord\", \"Schools\", 14],\n          [\"RemoveRecord\", \"Schools\", 15],\n          // No updates in undo for Schools.numStudents column, because the undo removes this column.\n          [\"ModifyColumn\", \"Schools\", \"numStudents\", {\"type\": \"Int\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 30, {\"type\": \"Int\"}],\n          [\"RemoveRecord\", \"Schools\", 16]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"Students\": [\n        [\"id\", \"firstName\", \"lastName\", \"school\", \"@fullName\", \"@fullNameLen\", \"@schoolShort\",\n         \"@schoolRegion\"],\n        [1,    \"Barack\",    \"Obama\",    2,        \"Barack Obama\",  12,    \"Columbia\",    \"NY\"],\n        [2,    \"George W\",  \"Bush\",     8,        \"George W Bush\", 13,    \"Yale\",        \"CT\"],\n        [3,    \"Bill\",      \"Clinton\",  9,        \"Bill Clinton\",  12,    \"Georgetown\",  \"DC\"],\n        [4,    \"George H\",  \"Bush\",     8,        \"George H Bush\", 13,    \"Yale\",        \"CT\"],\n        [5,    \"Ronald\",    \"Reagan\",   1,        \"Ronald Reagan\", 13,    \"Eureka\",      \"IL\"],\n        [6,    \"Jimmy\",     \"Carter\",   5,        \"Jimmy Carter\",  12,    \"U.S.\",        \"MD\"],\n        [8,    \"Gerald\",    \"Ford\",     7,        \"Gerald Ford\",   11,    \"Yale\",        \"CT\"]],\n      \"Schools\": [\n        [\"id\", \"name\",          \"address\", \"numStudents\"],\n        [1,    \"Eureka College\",        2,   0.0],\n        [2,    \"Columbia University\",   7,   0.0],\n        [5,    \"U.S. Naval Academy\",    3,   0.0],\n        [6,    \"Oxford University\",     10,  0.0],\n        [7,    \"Yale Law School\",       4,   0.0],\n        [8,    \"Yale University\",       4,   0.0],\n        [9,    \"Georgetown University\", 11,  0.0],\n        [10,   \"Williams College, Eureka\",3, 0.0],\n        [11,   \"Amherst College\" ,      0,   0.0],\n        [12,   \"12$\\\\$$'\\\\'\"     ,      70,  0.0],\n        [13,   \"NYU\"             ,       0,  \"One-thousand\"],\n        [14,   \"14$\\\\$$'\\\\'\"     ,       0, 1e27],\n        [15,   \"15$\\\\$$'\\\\'\"     ,       0,  1000000.0],\n        [16,   \"16$\\\\$$'\\\\'\"     ,       0,  \"@+Infinity\"]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"Europe\"],\n        [11,    \"Washington\", \"DC\",       \"US\",      \"North America\"]]\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"bulk_add_record\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n    [\"APPLY\", {\n      // Ensure basic BulkAddRecord works, generates undo, and triggers a formula calculation.\n      \"USER_ACTION\": [\"BulkAddRecord\", \"Address\", [11, null], {\n        \"city\": [\"Washington\", \"Portland\"],\n        \"state\": [\"DC\", \"OR\"]\n      }],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"BulkAddRecord\", \"Address\", [11, 12], {\n          \"city\": [\"Washington\", \"Portland\"],\n          \"state\": [\"DC\", \"OR\"]\n          }],\n          [\"BulkUpdateRecord\", \"Address\", [11, 12], {\"country\": [\"US\", \"US\"]}],\n          [\"BulkUpdateRecord\", \"Address\", [11, 12], {\n            \"region\": [\"North America\", \"North America\"]\n          }]\n        ],\n        \"direct\": [true, false, false],\n        \"undo\": [[\"BulkRemoveRecord\", \"Address\", [11, 12]]],\n        \"retValue\": [ [11, 12] ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Address\" : { \"country\": 2, \"region\" : 2, \"#lookup#\": 2 }\n      }\n    }],\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"Europe\"],\n        [11,    \"Washington\", \"DC\",       \"US\",      \"North America\"],\n        [12,    \"Portland\",   \"OR\",       \"US\",      \"North America\"]]\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"update_record\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"UpdateRecord\", \"Address\", 10, {\"country\": \"GB\"}],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"UpdateRecord\", \"Address\", 10, {\"country\": \"GB\"}],\n          [\"UpdateRecord\", \"Address\", 10, {\"region\": \"N/A\"}],\n          [\"UpdateRecord\", \"Students\", 3, {\"schoolRegion\": \"N/A\"}]\n        ],\n        \"direct\": [true, false, false],\n        \"undo\": [\n          [\"UpdateRecord\", \"Address\", 10, {\"country\": \"UK\"}],\n          [\"UpdateRecord\", \"Address\", 10, {\"region\": \"Europe\"}],\n          [\"UpdateRecord\", \"Students\", 3, {\"schoolRegion\": \"Europe\"}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Address\"  : { \"region\" : 1 },\n        \"Students\" : { \"schoolRegion\" : 1 }\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"BulkUpdateRecord\", \"Schools\", [1, 2, 8],\n        {\"name\": [\"eureka\", \"columbia\", \"yale\"]}],\n      \"ACTIONS\": {\n        \"stored\": [[\"BulkUpdateRecord\", \"Schools\", [1, 2, 8],\n            {\"name\": [\"eureka\", \"columbia\", \"yale\"]}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 4, 5],\n            {\"schoolShort\": [\"columbia\", \"yale\", \"yale\", \"eureka\"]}]\n        ],\n        \"direct\": [true, false],\n        \"undo\": [[\"BulkUpdateRecord\", \"Schools\", [1, 2, 8],\n          {\"name\": [\"Eureka College\", \"Columbia University\", \"Yale University\"]}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 4, 5],\n            {\"schoolShort\": [\"Columbia\", \"Yale\", \"Yale\", \"Eureka\"]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"schoolShort\" : 4 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"Students\": [\n        [\"id\", \"firstName\", \"lastName\", \"school\", \"@fullName\", \"@fullNameLen\", \"@schoolShort\",\n          \"@schoolRegion\"],\n        [1,    \"Barack\",    \"Obama\",    2,        \"Barack Obama\",  12,   \"columbia\",    \"NY\"],\n        [2,    \"George W\",  \"Bush\",     8,        \"George W Bush\", 13,   \"yale\",        \"CT\"],\n        [3,    \"Bill\",      \"Clinton\",  6,        \"Bill Clinton\",  12,   \"Oxford\",      \"N/A\"],\n        [4,    \"George H\",  \"Bush\",     8,        \"George H Bush\", 13,   \"yale\",        \"CT\"],\n        [5,    \"Ronald\",    \"Reagan\",   1,        \"Ronald Reagan\", 13,   \"eureka\",      \"IL\"],\n        [6,    \"Jimmy\",     \"Carter\",   5,        \"Jimmy Carter\",  12,   \"U.S.\",        \"MD\"],\n        [8,    \"Gerald\",    \"Ford\",     7,        \"Gerald Ford\",   11,   \"Yale\",        \"CT\"]],\n      \"Schools\": [\n        [\"id\",  \"name\",                 \"address\"],\n        [1,     \"eureka\",               2],\n        [2,     \"columbia\",             7],\n        [5,     \"U.S. Naval Academy\",   3],\n        [6,     \"Oxford University\",    10],\n        [7,     \"Yale Law School\",      4],\n        [8,     \"yale\",                 4]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"GB\",      \"N/A\"]]\n    }],\n\n    [\"APPLY\", {\n      // Check that Update Record on _grist_Tables_column properly triggers schema-change actions\n      \"USER_ACTIONS\": [\n        [\"UpdateRecord\", \"_grist_Tables_column\", 3,\n          {\"formula\": \"rec.firstName.upper() + ' ' + rec.lastName\"}],\n        [\"UpdateRecord\", \"_grist_Tables_column\", 6,\n          {\"colId\" : \"shortSchool\"}]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"ModifyColumn\", \"Students\", \"fullName\",\n            {\"formula\": \"rec.firstName.upper() + ' ' + rec.lastName\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 3,\n            {\"formula\": \"rec.firstName.upper() + ' ' + rec.lastName\"}],\n          [\"RenameColumn\", \"Students\", \"schoolShort\", \"shortSchool\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"colId\": \"shortSchool\"}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n            {\"fullName\": [\"BARACK Obama\", \"GEORGE W Bush\", \"BILL Clinton\", \"GEORGE H Bush\", \"RONALD Reagan\", \"JIMMY Carter\", \"GERALD Ford\"]}]\n        ],\n        \"direct\": [true, true, true, true, false],\n        \"undo\": [\n          [\"ModifyColumn\", \"Students\", \"fullName\",\n            {\"formula\": \"rec.firstName + ' ' + rec.lastName\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 3,\n            {\"formula\": \"rec.firstName + ' ' + rec.lastName\"}],\n          [\"RenameColumn\", \"Students\", \"shortSchool\", \"schoolShort\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"colId\": \"schoolShort\"}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n            {\"fullName\": [\"Barack Obama\", \"George W Bush\", \"Bill Clinton\", \"George H Bush\", \"Ronald Reagan\", \"Jimmy Carter\", \"Gerald Ford\"]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"fullName\" : 7, \"fullNameLen\" : 7 , \"shortSchool\" : 7}\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\" : [\n        // Check that noop Update Records do not generate docactions\n        [\"UpdateRecord\", \"Students\", 1, { \"firstName\" : \"Barack\", \"lastName\" : \"Obama\", \"school\": 2}],\n        [\"UpdateRecord\", \"Students\", 2, { \"firstName\" : \"Richard\", \"lastName\" : \"Nixon\", \"school\": 8}]\n      ],\n      \"ACTIONS\" : {\n        \"stored\" : [\n          [\"UpdateRecord\", \"Students\", 2, {\"firstName\" : \"Richard\", \"lastName\" : \"Nixon\"}],\n          [\"UpdateRecord\", \"Students\", 2, {\"fullName\" : \"RICHARD Nixon\"}]\n        ],\n        \"direct\": [true, false],\n        \"undo\" : [\n          [\"UpdateRecord\", \"Students\", 2, {\"firstName\" : \"George W\", \"lastName\" : \"Bush\"}],\n          [\"UpdateRecord\", \"Students\", 2, {\"fullName\": \"GEORGE W Bush\"}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"fullName\" : 1, \"fullNameLen\" : 1 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"Students\": [\n        [\"id\", \"firstName\", \"lastName\", \"school\", \"@fullName\", \"@fullNameLen\", \"@shortSchool\",\n          \"@schoolRegion\"],\n        [1,    \"Barack\",    \"Obama\",    2,        \"BARACK Obama\",  12,   \"columbia\",    \"NY\"],\n        [2,    \"Richard\",   \"Nixon\",     8,       \"RICHARD Nixon\", 13,   \"yale\",        \"CT\"],\n        [3,    \"Bill\",      \"Clinton\",  6,        \"BILL Clinton\",  12,   \"Oxford\",      \"N/A\"],\n        [4,    \"George H\",  \"Bush\",     8,        \"GEORGE H Bush\", 13,   \"yale\",        \"CT\"],\n        [5,    \"Ronald\",    \"Reagan\",   1,        \"RONALD Reagan\", 13,   \"eureka\",      \"IL\"],\n        [6,    \"Jimmy\",     \"Carter\",   5,        \"JIMMY Carter\",  12,   \"U.S.\",        \"MD\"],\n        [8,    \"Gerald\",    \"Ford\",     7,        \"GERALD Ford\",   11,   \"Yale\",        \"CT\"]],\n      \"Schools\": [\n        [\"id\",  \"name\",                 \"address\"],\n        [1,     \"eureka\",               2],\n        [2,     \"columbia\",             7],\n        [5,     \"U.S. Naval Academy\",   3],\n        [6,     \"Oxford University\",    10],\n        [7,     \"Yale Law School\",      4],\n        [8,     \"yale\",                 4]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"GB\",      \"N/A\"]]\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"remove_record\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n\n    // Apply some 'remove' actions. Besides removing the records themselves, they should also\n    // unset references that point to them.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"RemoveRecord\", \"Address\", 4],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RemoveRecord\", \"Address\", 4],\n          [\"BulkUpdateRecord\", \"Schools\", [7, 8], {\"address\": [0, 0]}],\n          [\"BulkUpdateRecord\", \"Students\", [2, 4, 8], {\"schoolRegion\": [null, null, null]}]\n        ],\n        \"direct\": [true, true, false],\n        \"undo\": [\n          [\"AddRecord\", \"Address\", 4, {\"city\": \"New Haven\", \"country\": \"US\", \"region\": \"North America\", \"state\": \"CT\"}],\n          [\"BulkUpdateRecord\", \"Schools\", [7, 8], {\"address\": [4, 4]}],\n          [\"BulkUpdateRecord\", \"Students\", [2, 4, 8], {\"schoolRegion\": [\"CT\", \"CT\", \"CT\"]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"schoolRegion\" : 3 }\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"RemoveRecord\", \"Schools\", 5],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RemoveRecord\", \"Schools\", 5],\n          [\"UpdateRecord\", \"Students\", 6, {\"school\": 0}],\n          [\"UpdateRecord\", \"Students\", 6, {\"schoolRegion\": null}],\n          [\"UpdateRecord\", \"Students\", 6, {\"schoolShort\": \"\"}]\n        ],\n        \"direct\": [true, true, false, false],\n        \"undo\": [\n          [\"AddRecord\", \"Schools\", 5, {\"name\": \"U.S. Naval Academy\", \"address\": 3}],\n          [\"UpdateRecord\", \"Students\", 6, {\"school\": 5}],\n          [\"UpdateRecord\", \"Students\", 6, {\"schoolRegion\": \"MD\"}],\n          [\"UpdateRecord\", \"Students\", 6, {\"schoolShort\": \"U.S.\"}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"schoolShort\" : 1, \"schoolRegion\" : 1 }\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"RemoveRecord\", \"Students\", 1],\n      \"ACTIONS\": {\n        \"stored\": [[\"RemoveRecord\", \"Students\", 1]],\n        \"direct\": [true],\n        \"undo\": [[\"AddRecord\", \"Students\", 1,\n          {\"firstName\": \"Barack\", \"fullName\": \"Barack Obama\", \"fullNameLen\": 12, \"lastName\": \"Obama\", \"school\": 2, \"schoolRegion\": \"NY\", \"schoolShort\": \"Columbia\"}]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",\"@fullNameLen\",\"@schoolShort\",\n          \"@schoolRegion\"],\n        [2,   \"George W\", \"Bush\",    8,       \"George W Bush\",13,      \"Yale\",       null],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\", 12,      \"Oxford\",     \"Europe\"],\n        [4,   \"George H\", \"Bush\",    8,       \"George H Bush\",13,      \"Yale\",       null],\n        [5,   \"Ronald\",   \"Reagan\",  1,       \"Ronald Reagan\",13,      \"Eureka\",     \"IL\"],\n        [6,   \"Jimmy\",    \"Carter\",  0,       \"Jimmy Carter\", 12,      \"\",           null],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",  11,      \"Yale\",       null]],\n      \"Schools\": [\n        [\"id\",  \"name\",                 \"address\"],\n        [1,     \"Eureka College\",       2],\n        [2,     \"Columbia University\",  7],\n        [6,     \"Oxford University\",    10],\n        [7,     \"Yale Law School\",      0],\n        [8,     \"Yale University\",      0]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"Europe\"]]\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"bulk_remove_record\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n    [\"APPLY\", {\n      // Ensure basic BulkRemoveRecord works, generates undo, and triggers a formula calculation.\n      \"USER_ACTION\": [\"BulkRemoveRecord\", \"Students\", [2, 5, 6, 8]],\n      \"ACTIONS\": {\n        \"stored\": [[\"BulkRemoveRecord\", \"Students\", [2, 5, 6, 8]]],\n        \"direct\": [true],\n        \"undo\": [[\"BulkAddRecord\", \"Students\", [2, 5, 6, 8], {\n          \"firstName\": [\"George W\", \"Ronald\", \"Jimmy\", \"Gerald\"],\n          \"lastName\": [\"Bush\", \"Reagan\", \"Carter\", \"Ford\"],\n          \"school\": [8, 1, 5, 7],\n          \"fullName\": [\"George W Bush\", \"Ronald Reagan\", \"Jimmy Carter\", \"Gerald Ford\"],\n          \"fullNameLen\": [13, 13, 12, 11],\n          \"schoolRegion\": [\"CT\", \"IL\", \"MD\", \"CT\"],\n          \"schoolShort\": [\"Yale\", \"Eureka\", \"U.S.\", \"Yale\"]\n        }]]\n      }\n    }],\n\n    [\"APPLY\", {\n      // Ensure that BulkRemoveRecord also unsets linked records.\n      \"USER_ACTION\": [\"BulkRemoveRecord\", \"Schools\", [6, 8]],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"BulkRemoveRecord\", \"Schools\", [6, 8]],\n          [\"BulkUpdateRecord\", \"Students\", [3, 4], {\"school\": [0, 0]}],\n          [\"BulkUpdateRecord\", \"Students\", [3, 4], {\"schoolRegion\": [null, null]}],\n          [\"BulkUpdateRecord\", \"Students\", [3, 4], {\"schoolShort\": [\"\", \"\"]}]\n        ],\n        \"direct\": [true, true, false, false],\n        \"undo\": [\n          [\"BulkAddRecord\", \"Schools\", [6, 8], {\n            \"name\": [\"Oxford University\", \"Yale University\"],\n            \"address\": [10, 4]\n          }],\n          [\"BulkUpdateRecord\", \"Students\", [3, 4], {\"school\": [6, 8]}],\n          [\"BulkUpdateRecord\", \"Students\", [3, 4], {\"schoolRegion\": [\"Europe\", \"CT\"]}],\n          [\"BulkUpdateRecord\", \"Students\", [3, 4], {\"schoolShort\": [\"Oxford\", \"Yale\"]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"schoolRegion\" : 2, \"schoolShort\": 2 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",\"@fullNameLen\",\"@schoolShort\",\"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\",  12,     \"Columbia\",   \"NY\"],\n        [3,   \"Bill\",     \"Clinton\", 0,       \"Bill Clinton\",  12,     \"\",           null],\n        [4,   \"George H\", \"Bush\",    0,       \"George H Bush\", 13,     \"\",           null]],\n      \"Schools\": [\n        [\"id\",  \"name\",                 \"address\"],\n        [1,     \"Eureka College\",       2],\n        [2,     \"Columbia University\",  7],\n        [5,     \"U.S. Naval Academy\",   3],\n        [7,     \"Yale Law School\",      4]]\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"add_column\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n\n    // Test basic addition of a non-formula column.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"AddColumn\", \"Address\", \"zip\", {\n        \"type\": \"Text\",\n        \"isFormula\": false,\n        \"formula\": \"\",\n        \"label\": \"something\",\n        \"widgetOptions\": \"\"\n      }],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"AddColumn\", \"Address\", \"zip\", {\n            \"type\": \"Text\",\n            \"isFormula\": false,\n            \"formula\": \"\"\n          }],\n          // Note that a metadata record outght to be added too.\n          [\"AddRecord\", \"_grist_Tables_column\", 30, {\n            \"parentId\": 3,\n            \"parentPos\": 8.0,\n            \"colId\": \"zip\",\n            \"type\": \"Text\",\n            \"isFormula\": false,\n            \"formula\": \"\",\n            \"label\": \"something\",\n            \"widgetOptions\": \"\"\n          }]\n        ],\n        \"direct\": [true, true],\n        \"undo\": [\n          [\"RemoveColumn\", \"Address\", \"zip\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 30]\n        ],\n        \"retValue\": [\n          {\"colId\": \"zip\", \"colRef\": 30}\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"zip\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"\",    \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"\",    \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"\",    \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"\",    \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"\",    \"Europe\"]]\n    }],\n\n    // Make sure we can modify this new column for existing records.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"BulkUpdateRecord\", \"Address\", [2, 4, 7],\n        {\"zip\": [\"61530-0001\", \"06520-0002\", \"10027-0003\"]}],\n      \"ACTIONS\": {\n        \"stored\": [[\"BulkUpdateRecord\", \"Address\", [2, 4, 7],\n          {\"zip\": [\"61530-0001\", \"06520-0002\", \"10027-0003\"]}]],\n        \"direct\": [true],\n        \"undo\": [[\"BulkUpdateRecord\", \"Address\", [2, 4, 7],\n          {\"zip\": [\"\", \"\", \"\"]}]]\n      }\n    }],\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"zip\",         \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"61530-0001\",  \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"\",            \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"06520-0002\",  \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"10027-0003\",  \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"\",            \"Europe\"]]\n    }],\n\n    // Now try adding a formula column.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\n        \"AddColumn\", \"Address\", \"zip5\", {\n          \"isFormula\": true,\n          \"formula\": \"rec.zip[:5]\"\n        }],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"AddColumn\", \"Address\", \"zip5\",\n            { \"formula\": \"rec.zip[:5]\", \"type\": \"Any\", \"isFormula\": true}],\n          [\"AddRecord\", \"_grist_Tables_column\", 31, {\n            \"parentId\": 3,\n            \"parentPos\": 9.0,\n            \"colId\": \"zip5\",\n            \"type\": \"Any\",\n            \"isFormula\": true,\n            \"formula\": \"rec.zip[:5]\",\n            \"label\": \"zip5\",\n            \"widgetOptions\": \"\"\n          }],\n          // Since it's a formula, it immediately gets evaluated, causing some calc actions.\n          [\"BulkUpdateRecord\", \"Address\", [2, 3, 4, 7, 10],\n            {\"zip5\": [\"61530\", \"\", \"06520\", \"10027\", \"\"]}]\n        ],\n        \"direct\": [true, true, false],\n        \"undo\": [\n          [\"RemoveColumn\", \"Address\", \"zip5\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 31]\n        ],\n        \"retValue\": [\n          {\"colId\": \"zip5\", \"colRef\": 31}\n        ]\n      },\n      \"CHECK_CALL_COUNTS\": {\n        \"Address\" : { \"zip5\" : 5 }\n      }\n    }],\n\n    // And another formula column which refers to another table.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\n        \"AddColumn\", \"Schools\", \"zip\", {\n          \"isFormula\": true,\n          \"formula\": \"rec.address.zip[:5]\",\n          \"label\": \"zippy\",\n          \"widgetOptions\": \"\"\n        }\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"AddColumn\", \"Schools\", \"zip\", {\n            \"type\": \"Any\",\n            \"isFormula\": true,\n            \"formula\": \"rec.address.zip[:5]\"\n          }],\n          [\"AddRecord\", \"_grist_Tables_column\", 32, {\n            \"parentId\": 2,\n            // There are only two other fields in Schools to start with.\n            \"parentPos\": 10.0,\n            \"colId\": \"zip\",\n            \"type\": \"Any\",\n            \"isFormula\": true,\n            \"formula\": \"rec.address.zip[:5]\",\n            \"label\": \"zippy\",\n            \"widgetOptions\": \"\"\n          }],\n          [\"BulkUpdateRecord\", \"Schools\", [1,2,5,6,7,8],\n            {\"zip\": [\"61530\", \"10027\", \"\", \"\", \"06520\", \"06520\"]}]\n        ],\n        \"direct\": [true, true, false],\n        \"undo\": [\n          [\"RemoveColumn\", \"Schools\", \"zip\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 32]\n        ],\n        \"retValue\": [\n          {\"colId\": \"zip\", \"colRef\": 32}\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Schools\" : { \"zip\" : 6 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Schools\": [\n        [\"id\",  \"name\",                 \"address\", \"@zip\"],\n        [1,     \"Eureka College\",       2,         \"61530\"],\n        [2,     \"Columbia University\",  7,         \"10027\"],\n        [5,     \"U.S. Naval Academy\",   3,         \"\"],\n        [6,     \"Oxford University\",    10,        \"\"],\n        [7,     \"Yale Law School\",      4,         \"06520\"],\n        [8,     \"Yale University\",      4,         \"06520\"]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"zip\",         \"@zip5\",  \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"61530-0001\",  \"61530\",  \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"\",            \"\",       \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"06520-0002\",  \"06520\",  \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"10027-0003\",  \"10027\",  \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"\",            \"\",       \"Europe\"]]\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        // Our sample data doesn't include Views metadata, but we can have some generated via\n        // AddTable action. The initial and newly-added column are as in \"add_table\" test case.\n        [\"AddTable\", \"Bar\", [\n          { \"id\": \"hello\", \"type\": \"Text\", \"isFormula\": false }\n        ]],\n        [\"AddColumn\", \"Bar\", \"world\",\n          { \"type\": \"Text\", \"isFormula\": true, \"formula\": \"rec.hello.upper()\"}\n        ]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          // Actions generated from AddTable.\n          [\"AddTable\", \"Bar\", [\n            {\"isFormula\": false, \"formula\": \"\", \"type\": \"ManualSortPos\", \"id\": \"manualSort\"},\n            {\"isFormula\": false, \"formula\": \"\", \"type\": \"Text\", \"id\": \"hello\"}\n          ]],\n          [\"AddRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 0, \"tableId\": \"Bar\"}],\n          [\"BulkAddRecord\", \"_grist_Tables_column\", [33, 34],\n            { \"colId\": [\"manualSort\", \"hello\"],\n              \"formula\": [\"\", \"\"],\n              \"isFormula\": [false, false],\n              \"label\": [\"manualSort\", \"hello\"],\n              \"parentId\": [4, 4],\n              \"parentPos\": [11.0, 12.0],\n              \"type\": [\"ManualSortPos\", \"Text\"],\n              \"widgetOptions\": [\"\", \"\"]\n            }],\n\n          // Raw view\n          [\"AddRecord\", \"_grist_Views\", 1,\n            {\"type\": \"raw_data\", \"name\": \"Bar\"}],\n          [\"AddRecord\", \"_grist_TabBar\", 1, {\"tabPos\": 1.0, \"viewRef\": 1}],\n          [\"AddRecord\", \"_grist_Pages\", 1, {\"indentation\": 0, \"pagePos\": 1.0, \"viewRef\": 1}],\n          [\"AddRecord\", \"_grist_Views_section\", 1,\n            {\"tableRef\": 4, \"defaultWidth\": 100, \"borderWidth\": 1,\n              \"parentId\": 1, \"parentKey\": \"record\", \"sortColRefs\": \"[]\", \"title\": \"\" }],\n          [\"AddRecord\", \"_grist_Views_section_field\", 1,\n            {\"parentId\": 1, \"colRef\": 34, \"parentPos\": 1.0}],\n\n          // Raw data widget\n          [\"AddRecord\", \"_grist_Views_section\", 2, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"record\", \"tableRef\": 4, \"title\": \"\"}],\n          [\"AddRecord\", \"_grist_Views_section_field\", 2, {\"colRef\": 34, \"parentId\": 2, \"parentPos\": 2.0}],\n\n          // Record card widget\n          [\"AddRecord\", \"_grist_Views_section\", 3, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"single\", \"tableRef\": 4, \"title\": \"\"}],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"recordCardViewSectionRef\": 3}],\n          [\"AddRecord\", \"_grist_Views_section_field\", 3, {\"colRef\": 34, \"parentId\": 3, \"parentPos\": 3.0}],\n\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 1, \"rawViewSectionRef\": 2}],\n\n          // Actions generated from AddColumn.\n          [\"AddColumn\", \"Bar\", \"world\",\n            {\"isFormula\": true, \"formula\": \"rec.hello.upper()\", \"type\": \"Text\"}],\n          [\"AddRecord\", \"_grist_Tables_column\", 35,\n            {\"colId\": \"world\", \"parentPos\": 13.0,\n              \"formula\": \"rec.hello.upper()\", \"parentId\": 4, \"type\": \"Text\",\n              \"isFormula\": true, \"label\": \"world\", \"widgetOptions\": \"\"}],\n          [\"AddRecord\", \"_grist_Views_section_field\", 4, {\"colRef\": 35, \"parentId\": 2, \"parentPos\": 4.0}],\n          [\"AddRecord\", \"_grist_Views_section_field\", 5, {\"colRef\": 35, \"parentId\": 3, \"parentPos\": 5.0}]\n        ],\n        \"direct\": [true, true, true, true, true, true, true,\n                   true, true, true, true, true, true, true,\n                   true, true, true, true],\n        \"undo\": [\n          [\"RemoveTable\", \"Bar\"],\n          [\"RemoveRecord\", \"_grist_Tables\", 4],\n          [\"BulkRemoveRecord\", \"_grist_Tables_column\", [33, 34]],\n          [\"RemoveRecord\", \"_grist_Views\", 1],\n          [\"RemoveRecord\", \"_grist_TabBar\", 1],\n          [\"RemoveRecord\", \"_grist_Pages\", 1],\n          [\"RemoveRecord\", \"_grist_Views_section\", 1],\n          [\"RemoveRecord\", \"_grist_Views_section_field\", 1],\n          [\"RemoveRecord\", \"_grist_Views_section\", 2],\n          [\"RemoveRecord\", \"_grist_Views_section_field\", 2],\n          [\"RemoveRecord\", \"_grist_Views_section\", 3],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"recordCardViewSectionRef\": 0}],\n          [\"RemoveRecord\", \"_grist_Views_section_field\", 3],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 0, \"rawViewSectionRef\": 0}],\n          [\"RemoveColumn\", \"Bar\", \"world\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 35],\n          [\"RemoveRecord\", \"_grist_Views_section_field\", 4],\n          [\"RemoveRecord\", \"_grist_Views_section_field\", 5]\n        ],\n        \"retValue\": [\n          {\n            \"table_id\": \"Bar\",\n            \"id\": 4,\n            \"columns\": [\"hello\"],\n            \"views\": [{\"sections\": [1], \"id\": 1}]\n          },\n          {\"colId\": \"world\", \"colRef\": 35}\n        ]\n      }\n    }],\n\n    // Expect same output as before, with one extra table.\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Bar\": [\n        [\"id\", \"manualSort\", \"hello\", \"@world\"]\n      ],\n      \"Schools\": [\n        [\"id\",  \"name\",                 \"address\", \"@zip\"],\n        [1,     \"Eureka College\",       2,         \"61530\"],\n        [2,     \"Columbia University\",  7,         \"10027\"],\n        [5,     \"U.S. Naval Academy\",   3,         \"\"],\n        [6,     \"Oxford University\",    10,        \"\"],\n        [7,     \"Yale Law School\",      4,         \"06520\"],\n        [8,     \"Yale University\",      4,         \"06520\"]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"zip\",         \"@zip5\",  \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"61530-0001\",  \"61530\",  \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"\",            \"\",       \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"06520-0002\",  \"06520\",  \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"10027-0003\",  \"10027\",  \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"\",            \"\",       \"Europe\"]]\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"remove_column\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n\n    // Set some values in column-to-be-removed to defaults, to check they get omitted from undo.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"UpdateRecord\", \"Address\", 3, {\"city\": \"\"}],\n      \"ACTIONS\": {\n        \"stored\": [[\"UpdateRecord\", \"Address\", 3, {\"city\": \"\"}]],\n        \"direct\": [true],\n        \"undo\": [[\"UpdateRecord\", \"Address\", 3, {\"city\": \"Annapolis\"}]]\n      }\n    }],\n\n    // Test removing a regular data column.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"RemoveColumn\", \"Address\", \"city\"],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RemoveRecord\", \"_grist_Tables_column\", 21],\n          [\"RemoveColumn\", \"Address\", \"city\"]],\n        \"direct\": [true, true],\n        \"undo\": [\n          [\"AddRecord\", \"_grist_Tables_column\", 21, {\n            \"parentId\": 3,\n            \"parentPos\": 1.0,\n            \"colId\": \"city\",\n            \"type\": \"Text\",\n            \"label\": \"City\"\n          }],\n          [\"BulkUpdateRecord\", \"Address\", [2, 4, 7, 10],\n            {\"city\": [\"Eureka\", \"New Haven\", \"New York\", \"Oxford\"]}],\n          [\"AddColumn\", \"Address\", \"city\", {\n            \"type\": \"Text\",\n            \"isFormula\": false,\n            \"formula\": \"\"\n          }]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Address\": [\n        [\"id\",  \"state\",    \"country\", \"@region\"],\n        [2,     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"MD\",       \"US\",      \"North America\"],\n        [4,     \"CT\",       \"US\",      \"North America\"],\n        [7,     \"NY\",       \"US\",      \"North America\"],\n        [10,    \"England\",  \"UK\",      \"Europe\"]]\n    }],\n\n    // Test removing a formula column.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"RemoveColumn\", \"Students\", \"fullNameLen\"],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RemoveRecord\", \"_grist_Tables_column\", 4],\n          [\"RemoveColumn\", \"Students\", \"fullNameLen\"]\n        ],\n        \"direct\": [true, true],\n        \"undo\": [\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8], {\"fullNameLen\": [12, 13, 12, 13, 13, 12, 11]}],\n          [\"AddRecord\", \"_grist_Tables_column\", 4, {\n            \"parentId\": 1,\n            \"parentPos\": 4.0,\n            \"colId\": \"fullNameLen\",\n            \"type\": \"Any\",\n            \"isFormula\": true,\n            \"formula\": \"len(rec.fullName)\",\n            \"label\" : \"Full Name Length\"\n          }],\n          [\"AddColumn\", \"Students\", \"fullNameLen\", {\n            \"type\": \"Any\",\n            \"isFormula\": true,\n            \"formula\": \"len(rec.fullName)\"\n          }]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",      \"@schoolShort\",\"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\",  \"Columbia\",   \"NY\"],\n        [2,   \"George W\", \"Bush\",    8,       \"George W Bush\", \"Yale\",       \"CT\"],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\",  \"Oxford\",     \"Europe\"],\n        [4,   \"George H\", \"Bush\",    8,       \"George H Bush\", \"Yale\",       \"CT\"],\n        [5,   \"Ronald\",   \"Reagan\",  1,       \"Ronald Reagan\", \"Eureka\",     \"IL\"],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Jimmy Carter\",  \"U.S.\",       \"MD\"],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",   \"Yale\",       \"CT\"]],\n      \"Address\": [\n        [\"id\",  \"state\",    \"country\", \"@region\"],\n        [2,     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"MD\",       \"US\",      \"North America\"],\n        [4,     \"CT\",       \"US\",      \"North America\"],\n        [7,     \"NY\",       \"US\",      \"North America\"],\n        [10,    \"England\",  \"UK\",      \"Europe\"]]\n    }],\n\n\n    // If we end up with an invalid reference, we should get reference errors in the data, but not die.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"RemoveColumn\", \"Address\", \"state\"],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RemoveRecord\", \"_grist_Tables_column\", 27],\n          [\"RemoveColumn\", \"Address\", \"state\"],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 4, 5, 6, 8],\n            {\"schoolRegion\": [[\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"],\n              [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"],\n              [\"E\",\"AttributeError\"]]\n            }]\n        ],\n        \"direct\": [true, true, false],\n        \"undo\": [\n          [\"AddRecord\", \"_grist_Tables_column\", 27, {\n            \"parentId\": 3,\n            \"parentPos\": 2.0,\n            \"colId\": \"state\",\n            \"type\": \"Text\",\n            \"label\": \"State\"\n          }],\n          [\"BulkUpdateRecord\", \"Address\", [2, 3, 4, 7, 10],\n            {\"state\": [\"IL\", \"MD\", \"CT\", \"NY\", \"England\"]}],\n          [\"AddColumn\", \"Address\", \"state\", {\n            \"type\": \"Text\",\n            \"isFormula\": false,\n            \"formula\": \"\"\n          }],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 4, 5, 6, 8], {\"schoolRegion\": [\"NY\", \"CT\", \"CT\", \"IL\", \"MD\", \"CT\"]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\": {\n        \"Students\" : { \"schoolRegion\" : 7 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\", \"@schoolShort\",\"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\",  \"Columbia\", [\"E\",\"AttributeError\"]],\n        [2,   \"George W\", \"Bush\",    8,       \"George W Bush\", \"Yale\",     [\"E\",\"AttributeError\"]],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\",  \"Oxford\",   \"Europe\"],\n        [4,   \"George H\", \"Bush\",    8,       \"George H Bush\", \"Yale\",     [\"E\",\"AttributeError\"]],\n        [5,   \"Ronald\",   \"Reagan\",  1,       \"Ronald Reagan\", \"Eureka\",   [\"E\",\"AttributeError\"]],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Jimmy Carter\",  \"U.S.\",     [\"E\",\"AttributeError\"]],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",   \"Yale\",     [\"E\",\"AttributeError\"]]],\n      \"Address\": [\n        [\"id\",  \"country\", \"@region\"],\n        [2,     \"US\",      \"North America\"],\n        [3,     \"US\",      \"North America\"],\n        [4,     \"US\",      \"North America\"],\n        [7,     \"US\",      \"North America\"],\n        [10,    \"UK\",      \"Europe\"]]\n    }],\n\n    // Test that when we remove a Reference column, we don't get errors modifying its target\n    // table.\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        [\"RemoveColumn\", \"Students\", \"school\"],\n        [\"RemoveColumn\", \"Students\", \"schoolShort\"],\n        [\"RemoveRecord\", \"Schools\", 1],\n        [\"RemoveRecord\", \"Schools\", 8]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RemoveRecord\", \"_grist_Tables_column\", 5],\n          [\"RemoveColumn\", \"Students\", \"school\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 6],\n          [\"RemoveColumn\", \"Students\", \"schoolShort\"],\n          [\"RemoveRecord\", \"Schools\", 1],\n          [\"RemoveRecord\", \"Schools\", 8],\n          [\"UpdateRecord\", \"Students\", 3,\n            {\"schoolRegion\": [\"E\",\"AttributeError\"]}]\n        ],\n        \"direct\": [true, true, true, true, true, true, false],\n        \"undo\": [\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8], {\"schoolShort\": [\"Columbia\", \"Yale\", \"Oxford\", \"Yale\", \"Eureka\", \"U.S.\", \"Yale\"]}],\n          [\"AddRecord\", \"_grist_Tables_column\", 5, {\"parentPos\": 5.0, \"parentId\": 1,\n            \"colId\": \"school\", \"type\": \"Ref:Schools\", \"label\": \"school\"\n          }],\n          [\"BulkUpdateRecord\", \"Students\", [1,2,3,4,5,6,8], {\"school\": [2,8,6,8,1,5,7]}],\n          [\"AddColumn\", \"Students\", \"school\", {\"isFormula\": false,\n            \"formula\": \"\", \"type\": \"Ref:Schools\"}],\n          [\"AddRecord\", \"_grist_Tables_column\", 6, {\n            \"parentPos\": 6.0, \"isFormula\": 1, \"parentId\": 1,\n            \"colId\": \"schoolShort\", \"formula\": \"rec.school.name.split(' ')[0]\",\n            \"type\": \"Any\", \"label\": \"School Short\"\n          }],\n          [\"AddColumn\", \"Students\", \"schoolShort\", {\"isFormula\": true,\n            \"formula\": \"rec.school.name.split(' ')[0]\", \"type\": \"Any\"}],\n          [\"AddRecord\", \"Schools\", 1, {\"name\": \"Eureka College\", \"address\": 2}],\n          [\"AddRecord\", \"Schools\", 8, {\"name\": \"Yale University\", \"address\": 4}],\n          [\"UpdateRecord\", \"Students\", 3, {\"schoolRegion\": \"Europe\"}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\": {\n        \"Students\" : { \"schoolRegion\" : 7 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"@fullName\",     \"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   \"Barack Obama\",  [\"E\",\"AttributeError\"]],\n        [2,   \"George W\", \"Bush\",    \"George W Bush\", [\"E\",\"AttributeError\"]],\n        [3,   \"Bill\",     \"Clinton\", \"Bill Clinton\",  [\"E\",\"AttributeError\"]],\n        [4,   \"George H\", \"Bush\",    \"George H Bush\", [\"E\",\"AttributeError\"]],\n        [5,   \"Ronald\",   \"Reagan\",  \"Ronald Reagan\", [\"E\",\"AttributeError\"]],\n        [6,   \"Jimmy\",    \"Carter\",  \"Jimmy Carter\",  [\"E\",\"AttributeError\"]],\n        [8,   \"Gerald\",   \"Ford\",    \"Gerald Ford\",   [\"E\",\"AttributeError\"]]],\n      \"Schools\": [\n        [\"id\",  \"name\",                 \"address\"],\n        [2,     \"Columbia University\",  7],\n        [5,     \"U.S. Naval Academy\",   3],\n        [6,     \"Oxford University\",    10],\n        [7,     \"Yale Law School\",      4]],\n      \"Address\": [\n        [\"id\",  \"country\", \"@region\"],\n        [2,     \"US\",      \"North America\"],\n        [3,     \"US\",      \"North America\"],\n        [4,     \"US\",      \"North America\"],\n        [7,     \"US\",      \"North America\"],\n        [10,    \"UK\",      \"Europe\"]]\n    }],\n\n    // To test that columns get removed from the views, use AddTable (which causes a View to be\n    // created) and then RemoveColumn from that.\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        [\"AddTable\", \"ViewTest\", [\n          { \"id\": \"hello\", \"type\": \"Text\", \"isFormula\": false, \"label\": \"\", \"widgetOptions\": \"\"},\n          { \"id\": \"world\", \"type\": \"Text\", \"isFormula\": true, \"formula\": \"rec.hello.upper()\", \"label\": \"\", \"widgetOptions\": \"\"}\n        ]],\n        [\"RemoveColumn\", \"ViewTest\", \"hello\"]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"AddTable\", \"ViewTest\", [\n            {\"isFormula\": false, \"formula\": \"\", \"type\": \"ManualSortPos\", \"id\": \"manualSort\"},\n            {\"isFormula\": false, \"formula\": \"\", \"type\": \"Text\", \"id\": \"hello\"},\n            {\"isFormula\": true, \"formula\": \"rec.hello.upper()\", \"type\": \"Text\", \"id\": \"world\"}\n          ]],\n          [\"AddRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 0, \"tableId\": \"ViewTest\"}],\n          [\"BulkAddRecord\", \"_grist_Tables_column\", [30,31,32], {\n            \"colId\": [\"manualSort\",\"hello\",\"world\"],\n            \"formula\": [\"\",\"\",\"rec.hello.upper()\"],\n            \"parentPos\": [8.0,9.0,10.0],\n            \"isFormula\": [false,false,true],\n            \"label\": [\"manualSort\",\"\",\"\"],\n            \"parentId\": [4,4,4],\n            \"type\": [\"ManualSortPos\",\"Text\",\"Text\"],\n            \"widgetOptions\": [\"\",\"\",\"\"]\n          }],\n          // Raw view\n          [\"AddRecord\", \"_grist_Views\", 1,\n            {\"type\": \"raw_data\", \"name\": \"ViewTest\"}],\n          [\"AddRecord\", \"_grist_TabBar\", 1, {\"tabPos\": 1.0, \"viewRef\": 1}],\n          [\"AddRecord\", \"_grist_Pages\", 1, {\"indentation\": 0, \"pagePos\": 1.0, \"viewRef\": 1}],\n          [\"AddRecord\", \"_grist_Views_section\", 1,\n            {\"tableRef\": 4, \"defaultWidth\": 100, \"borderWidth\": 1,\n              \"parentId\": 1, \"parentKey\": \"record\", \"sortColRefs\": \"[]\", \"title\": \"\"}],\n          [\"BulkAddRecord\", \"_grist_Views_section_field\", [1,2],\n            {\"parentId\": [1,1], \"colRef\": [31,32], \"parentPos\": [1.0,2.0]}],\n          [\"AddRecord\", \"_grist_Views_section\", 2, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"record\", \"tableRef\": 4, \"title\": \"\"}],\n          [\"BulkAddRecord\", \"_grist_Views_section_field\", [3, 4], {\"colRef\": [31, 32], \"parentId\": [2, 2], \"parentPos\": [3.0, 4.0]}],\n          [\"AddRecord\", \"_grist_Views_section\", 3, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"single\", \"tableRef\": 4, \"title\": \"\"}],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"recordCardViewSectionRef\": 3}],\n          [\"BulkAddRecord\", \"_grist_Views_section_field\", [5, 6], {\"colRef\": [31, 32], \"parentId\": [3, 3], \"parentPos\": [5.0, 6.0]}],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 1, \"rawViewSectionRef\": 2}],\n          [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [1, 3, 5]],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 31],\n          [\"RemoveColumn\", \"ViewTest\", \"hello\"]\n\n        ],\n        \"direct\": [true, true, true, true, true, true, true, true, true,\n                   true, true, true, true, true, true, true, true],\n        \"undo\": [\n          [\"RemoveTable\", \"ViewTest\"],\n          [\"RemoveRecord\", \"_grist_Tables\", 4],\n          [\"BulkRemoveRecord\", \"_grist_Tables_column\", [30,31,32]],\n          [\"RemoveRecord\", \"_grist_Views\", 1],\n          [\"RemoveRecord\", \"_grist_TabBar\", 1],\n          [\"RemoveRecord\", \"_grist_Pages\", 1],\n          [\"RemoveRecord\", \"_grist_Views_section\", 1],\n          [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [1,2]],\n          [\"RemoveRecord\", \"_grist_Views_section\", 2],\n          [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [3, 4]],\n          [\"RemoveRecord\", \"_grist_Views_section\", 3],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"recordCardViewSectionRef\": 0}],\n          [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [5, 6]],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 0, \"rawViewSectionRef\": 0}],\n          [\"BulkAddRecord\", \"_grist_Views_section_field\", [1, 3, 5],\n            {\"colRef\": [31, 31, 31], \"parentId\": [1, 2, 3], \"parentPos\": [1.0, 3.0, 5.0]}],\n          [\"AddRecord\", \"_grist_Tables_column\", 31,\n            {\"colId\": \"hello\", \"parentPos\": 9.0,\n              \"parentId\": 4, \"type\": \"Text\"\n            }],\n          [\"AddColumn\", \"ViewTest\", \"hello\",\n            {\"isFormula\": false, \"formula\": \"\", \"type\": \"Text\"}]\n        ]\n      }\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"rename_column\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        [\"RenameColumn\", \"Address\", \"city\", \"town\"],\n        [\"RenameColumn\", \"Students\", \"fullNameLen\", \"nameLen\"],\n        [\"UpdateRecord\", \"Address\", 10, {\"town\": \"Ox-ford\"}]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RenameColumn\", \"Address\", \"city\", \"town\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"colId\": \"town\"}],\n          [\"RenameColumn\", \"Students\", \"fullNameLen\", \"nameLen\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 4, {\"colId\": \"nameLen\"}],\n          [\"UpdateRecord\", \"Address\", 10, {\"town\": \"Ox-ford\"}]\n        ],\n        \"direct\": [true, true, true, true, true],\n        \"undo\": [\n          [\"RenameColumn\", \"Address\", \"town\", \"city\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"colId\": \"city\"}],\n          [\"RenameColumn\", \"Students\", \"nameLen\", \"fullNameLen\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 4, {\"colId\": \"fullNameLen\"}],\n          [\"UpdateRecord\", \"Address\", 10, {\"town\": \"Oxford\"}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : {\"nameLen\" : 7}\n      }\n    }],\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",\"@nameLen\",\"@schoolShort\",\n          \"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\", 12,      \"Columbia\",   \"NY\"],\n        [2,   \"George W\", \"Bush\",    8,       \"George W Bush\",13,      \"Yale\",       \"CT\"],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\", 12,      \"Oxford\",     \"Europe\"],\n        [4,   \"George H\", \"Bush\",    8,       \"George H Bush\",13,      \"Yale\",       \"CT\"],\n        [5,   \"Ronald\",   \"Reagan\",  1,       \"Ronald Reagan\",13,      \"Eureka\",     \"IL\"],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Jimmy Carter\", 12,      \"U.S.\",       \"MD\"],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",  11,      \"Yale\",       \"CT\"]],\n      \"Address\": [\n        [\"id\",  \"town\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Ox-ford\",     \"England\",  \"UK\",      \"Europe\"]]\n    }],\n\n    // Test that when we rename a Reference column, changes to the target table still work.\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        [\"RenameColumn\", \"Students\", \"school\", \"university\"],\n        [\"RemoveRecord\", \"Schools\", 1],\n        [\"RemoveRecord\", \"Schools\", 8]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RenameColumn\", \"Students\", \"school\", \"university\"],\n          [\"ModifyColumn\", \"Students\", \"schoolShort\",\n            {\"formula\": \"rec.university.name.split(' ')[0]\"}],\n          [\"ModifyColumn\", \"Students\", \"schoolRegion\", { \"formula\":\n            \"addr = $university.address\\naddr.state if addr.country == 'US' else addr.region\"\n          }],\n          [\"BulkUpdateRecord\", \"_grist_Tables_column\", [5,6,9], {\n            \"colId\": [\"university\", \"schoolShort\", \"schoolRegion\"],\n            \"formula\": [\n              \"\",\n              \"rec.university.name.split(' ')[0]\",\n              \"addr = $university.address\\naddr.state if addr.country == 'US' else addr.region\"\n            ]\n          }],\n          [\"RemoveRecord\", \"Schools\", 1],\n          [\"UpdateRecord\", \"Students\", 5, {\"university\": 0}],\n          [\"RemoveRecord\", \"Schools\", 8],\n          [\"BulkUpdateRecord\", \"Students\", [2, 4], {\"university\": [0, 0]}],\n          [\"BulkUpdateRecord\", \"Students\", [2, 4, 5], {\"schoolRegion\": [null, null, null]}],\n          [\"BulkUpdateRecord\", \"Students\", [2, 4, 5], {\"schoolShort\": [\"\", \"\", \"\"]}]\n        ],\n        \"direct\": [true, true, true, true, true, true, true, true, false, false],\n        \"undo\": [\n          [\"RenameColumn\", \"Students\", \"university\", \"school\"],\n          [\"ModifyColumn\", \"Students\", \"schoolShort\",\n            {\"formula\": \"rec.school.name.split(' ')[0]\"}],\n          [\"ModifyColumn\", \"Students\", \"schoolRegion\", { \"formula\":\n            \"addr = $school.address\\naddr.state if addr.country == 'US' else addr.region\"\n          }],\n          [\"BulkUpdateRecord\", \"_grist_Tables_column\", [5,6,9], {\n            \"colId\": [\"school\", \"schoolShort\", \"schoolRegion\"],\n            \"formula\": [\n              \"\",\n              \"rec.school.name.split(' ')[0]\",\n              \"addr = $school.address\\naddr.state if addr.country == 'US' else addr.region\"\n            ]\n          }],\n          [\"AddRecord\", \"Schools\", 1, {\"name\": \"Eureka College\", \"address\": 2}],\n          [\"UpdateRecord\", \"Students\", 5, {\"university\": 1}],\n          [\"AddRecord\", \"Schools\", 8, {\"name\": \"Yale University\", \"address\": 4}],\n          [\"BulkUpdateRecord\", \"Students\", [2, 4], {\"university\": [8, 8]}],\n          [\"BulkUpdateRecord\", \"Students\", [2, 4, 5], {\"schoolRegion\": [\"CT\", \"CT\", \"IL\"]}],\n          [\"BulkUpdateRecord\", \"Students\", [2, 4, 5], {\"schoolShort\": [\"Yale\", \"Yale\", \"Eureka\"]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"schoolShort\" : 7, \"schoolRegion\" : 7 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"university\",\"@fullName\",\"@nameLen\", \"@schoolShort\", \"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\", 12,    \"Columbia\", \"NY\" ],\n        [2,   \"George W\", \"Bush\",    0,       \"George W Bush\",13,    \"\",         null ],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\", 12,    \"Oxford\",   \"Europe\"],\n        [4,   \"George H\", \"Bush\",    0,       \"George H Bush\",13,    \"\",         null ],\n        [5,   \"Ronald\",   \"Reagan\",  0,       \"Ronald Reagan\",13,    \"\",         null ],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Jimmy Carter\", 12,    \"U.S.\",     \"MD\" ],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",  11,    \"Yale\",     \"CT\" ]],\n      \"Schools\": [\n        [\"id\",  \"name\",                 \"address\"],\n        [2,     \"Columbia University\",  7],\n        [5,     \"U.S. Naval Academy\",   3],\n        [6,     \"Oxford University\",    10],\n        [7,     \"Yale Law School\",      4]],\n      \"Address\": [\n        [\"id\",  \"town\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Ox-ford\",    \"England\",  \"UK\",      \"Europe\"]]\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        // Check that column renaming plays well with property columns\n        [\"ModifyColumn\", \"Address\", \"state\", { \"type\" : \"Int\" }],\n        [\"UpdateRecord\", \"Address\", 2, { \"state\" : 73 }]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n            [\"ModifyColumn\", \"Address\", \"state\", {\"type\": \"Int\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 27, {\"type\": \"Int\"}],\n            [\"UpdateRecord\", \"Address\", 2, {\"state\": 73}]\n        ],\n        \"direct\": [true, true, true],\n        \"undo\": [\n            [\"ModifyColumn\", \"Address\", \"state\", {\"type\": \"Text\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 27, {\"type\": \"Text\"}],\n            [\"UpdateRecord\", \"Address\", 2, {\"state\": \"IL\"}]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"university\",\"@fullName\",\"@nameLen\", \"@schoolShort\", \"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\", 12,    \"Columbia\",  \"NY\" ],\n        [2,   \"George W\", \"Bush\",    0,       \"George W Bush\",13,    \"\",          null ],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\", 12,    \"Oxford\",    \"Europe\"],\n        [4,   \"George H\", \"Bush\",    0,       \"George H Bush\",13,    \"\",          null ],\n        [5,   \"Ronald\",   \"Reagan\",  0,       \"Ronald Reagan\",13,    \"\",          null ],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Jimmy Carter\", 12,    \"U.S.\",      \"MD\" ],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",  11,    \"Yale\",      \"CT\" ]],\n      \"Schools\": [\n        [\"id\",  \"name\",          \"address\"],\n        [2,     \"Columbia University\",  7],\n        [5,     \"U.S. Naval Academy\",   3],\n        [6,     \"Oxford University\",    10],\n        [7,     \"Yale Law School\",      4]],\n      \"Address\": [\n        [\"id\",  \"town\",     \"state\", \"country\", \"@region\"],\n        [2,     \"Eureka\",     73,       \"US\",   \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",     \"US\",   \"North America\"],\n        [4,     \"New Haven\",  \"CT\",     \"US\",   \"North America\"],\n        [7,     \"New York\",   \"NY\",     \"US\",   \"North America\"],\n        [10,    \"Ox-ford\",    \"England\",\"UK\",   \"Europe\"]]\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        [\"RenameColumn\", \"Address\", \"state\", \"stateName\"],\n        [\"ModifyColumn\", \"Address\", \"stateName\", { \"type\" : \"Numeric\"}]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n            [\"RenameColumn\", \"Address\", \"state\", \"stateName\"],\n            [\"ModifyColumn\", \"Students\", \"schoolRegion\", { \"formula\":\n              \"addr = $university.address\\naddr.stateName if addr.country == 'US' else addr.region\"\n            }],\n            [\"BulkUpdateRecord\", \"_grist_Tables_column\", [27, 9], {\n              \"colId\": [\"stateName\", \"schoolRegion\"],\n              \"formula\": [\"\",\n                \"addr = $university.address\\naddr.stateName if addr.country == 'US' else addr.region\"]\n            }],\n            [\"ModifyColumn\", \"Address\", \"stateName\", {\"type\": \"Numeric\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 27, {\"type\": \"Numeric\"}]\n        ],\n        \"direct\": [true, true, true, true, true],\n        \"undo\": [\n            [\"RenameColumn\", \"Address\", \"stateName\", \"state\"],\n            [\"ModifyColumn\", \"Students\", \"schoolRegion\", { \"formula\":\n              \"addr = $university.address\\naddr.state if addr.country == 'US' else addr.region\"\n            }],\n            [\"BulkUpdateRecord\", \"_grist_Tables_column\", [27, 9], {\n              \"colId\": [\"state\", \"schoolRegion\"],\n              \"formula\": [\"\",\n                \"addr = $university.address\\naddr.state if addr.country == 'US' else addr.region\"]\n            }],\n            [\"ModifyColumn\", \"Address\", \"stateName\", {\"type\": \"Int\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 27, {\"type\": \"Int\"}]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"university\",\"@fullName\",\"@nameLen\", \"@schoolShort\", \"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\", 12,    \"Columbia\",  \"NY\" ],\n        [2,   \"George W\", \"Bush\",    0,       \"George W Bush\",13,    \"\",          null ],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\", 12,    \"Oxford\",    \"Europe\"],\n        [4,   \"George H\", \"Bush\",    0,       \"George H Bush\",13,    \"\",          null ],\n        [5,   \"Ronald\",   \"Reagan\",  0,       \"Ronald Reagan\",13,    \"\",          null ],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Jimmy Carter\", 12,    \"U.S.\",      \"MD\" ],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",  11,    \"Yale\",      \"CT\" ]],\n      \"Schools\": [\n        [\"id\",  \"name\",          \"address\"],\n        [2,     \"Columbia University\",  7],\n        [5,     \"U.S. Naval Academy\",   3],\n        [6,     \"Oxford University\",    10],\n        [7,     \"Yale Law School\",      4]],\n      \"Address\": [\n        [\"id\",  \"town\", \"stateName\", \"country\", \"@region\"],\n        [2,     \"Eureka\",     73,       \"US\",   \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",     \"US\",   \"North America\"],\n        [4,     \"New Haven\",  \"CT\",     \"US\",   \"North America\"],\n        [7,     \"New York\",   \"NY\",     \"US\",   \"North America\"],\n        [10,    \"Ox-ford\",    \"England\",\"UK\",   \"Europe\"]]\n    }],\n\n    // Check that renaming a column to same name without changes is a no-op.\n    [\"APPLY\", {\n      \"USER_ACTION\" : [\"RenameColumn\", \"Students\", \"university\", \"university\"],\n      \"ACTIONS\" : {\n        \"stored\" : [],\n        \"undo\" : []\n      }\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"rename_column2\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n\n    // Check that we can rename a column via a metadata update.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"colId\": \"short\"}],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RenameColumn\", \"Students\", \"schoolShort\", \"short\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"colId\": \"short\"}]\n        ],\n        \"direct\": [true, true],\n        \"undo\": [\n          [\"RenameColumn\", \"Students\", \"short\", \"schoolShort\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"colId\": \"schoolShort\"}]\n        ]\n      }\n    }],\n\n    // Check that we can rename a column to a conflicting name, and a unique name will be chosen.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"colId\": \"school\"}],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RenameColumn\", \"Students\", \"short\", \"school2\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"colId\": \"school2\"}]\n        ],\n        \"direct\": [true, true],\n        \"undo\": [\n          [\"RenameColumn\", \"Students\", \"school2\", \"short\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"colId\": \"short\"}]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",\"@fullNameLen\",\"@school2\",\"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\",  12,     \"Columbia\",   \"NY\"],\n        [2,   \"George W\", \"Bush\",    8,       \"George W Bush\", 13,     \"Yale\",       \"CT\"],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\",  12,     \"Oxford\",     \"Europe\"],\n        [4,   \"George H\", \"Bush\",    8,       \"George H Bush\", 13,     \"Yale\",       \"CT\"],\n        [5,   \"Ronald\",   \"Reagan\",  1,       \"Ronald Reagan\", 13,     \"Eureka\",     \"IL\"],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Jimmy Carter\",  12,     \"U.S.\",       \"MD\"],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",   11,     \"Yale\",       \"CT\"]]\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"modify_column\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n\n    [\"APPLY\", {\n      // Check that Modifying formulas works\n      \"USER_ACTION\": [\"ModifyColumn\", \"Address\", \"city\", {\"formula\": \"'Anytown'\"}],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"ModifyColumn\", \"Address\", \"city\", {\"formula\": \"'Anytown'\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"formula\": \"'Anytown'\"}]\n        ],\n        \"direct\": [true, true],\n        \"undo\": [\n          [\"ModifyColumn\", \"Address\", \"city\", {\"formula\": \"\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"formula\": \"\"}]\n        ]\n      }\n    }],\n\n    [\"APPLY\", {\n      // Modifying formulas and triggering calc actions\n      \"USER_ACTION\": [\"ModifyColumn\", \"Students\", \"fullName\", {\n        \"formula\": \"rec.lastName + ' - ' + rec.firstName\"\n      }],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"ModifyColumn\", \"Students\", \"fullName\",\n            {\"formula\": \"rec.lastName + ' - ' + rec.firstName\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 3,\n            {\"formula\": \"rec.lastName + ' - ' + rec.firstName\"}],\n          [\"BulkUpdateRecord\", \"Students\", [1,2,3,4,5,6,8], {\n            \"fullName\": [\n              \"Obama - Barack\",\n              \"Bush - George W\",\n              \"Clinton - Bill\",\n              \"Bush - George H\",\n              \"Reagan - Ronald\",\n              \"Carter - Jimmy\",\n              \"Ford - Gerald\"\n            ]\n          }],\n          [\"BulkUpdateRecord\", \"Students\", [1,2,3,4,5,6,8], {\n            \"fullNameLen\": [14,15,14,15,15,14,13]\n          }]\n        ],\n        \"direct\": [true, true, false, false],\n        \"undo\": [\n          [\"ModifyColumn\", \"Students\", \"fullName\",\n            {\"formula\": \"rec.firstName + ' ' + rec.lastName\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 3,\n            {\"formula\": \"rec.firstName + ' ' + rec.lastName\"}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n            {\"fullName\": [\"Barack Obama\", \"George W Bush\", \"Bill Clinton\", \"George H Bush\", \"Ronald Reagan\", \"Jimmy Carter\", \"Gerald Ford\"]}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n            {\"fullNameLen\": [12, 13, 12, 13, 13, 12, 11]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"fullName\" : 7, \"fullNameLen\" : 7 }\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        // Check that Default formulas are triggered on add record\n        [\"UpdateRecord\", \"Students\", 2, {\"firstName\": \"G.W.\"}],\n        [\"AddRecord\", \"Address\", 11, {\"country\": \"US\"}]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"UpdateRecord\", \"Students\", 2, {\"firstName\": \"G.W.\"}],\n          [\"AddRecord\", \"Address\", 11, {\"country\": \"US\"}],\n          [\"UpdateRecord\", \"Address\", 11, {\"city\": \"Anytown\"}],\n          [\"UpdateRecord\", \"Address\", 11, {\"region\": \"North America\" }],\n          [\"UpdateRecord\", \"Students\", 2, {\"fullName\": \"Bush - G.W.\"}],\n          [\"UpdateRecord\", \"Students\", 2, {\"fullNameLen\": 11}]\n        ],\n        \"direct\": [true, true, false, false, false, false],\n        \"undo\": [\n          [\"UpdateRecord\", \"Students\", 2, {\"firstName\": \"George W\"}],\n          [\"RemoveRecord\", \"Address\", 11],\n          [\"UpdateRecord\", \"Students\", 2, {\"fullName\": \"Bush - George W\"}],\n          [\"UpdateRecord\", \"Students\", 2, {\"fullNameLen\": 15}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Address\" : { \"city\": 1, \"region\" : 1, \"#lookup#\": 1 },\n        \"Students\": { \"fullName\" : 1, \"fullNameLen\" : 1 }\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        // Check that nothing bad happens when going back and forth between types\n        // in a formula column\n        [\"ModifyColumn\", \"Students\", \"fullNameLen\", {\"type\": \"Text\"}],\n        [\"ModifyColumn\", \"Students\", \"fullNameLen\", {\"type\": \"Int\"}],\n        [\"ModifyColumn\", \"Students\", \"fullNameLen\", {\"type\": \"Any\"}]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\",\n              {\"type\": \"Text\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4,\n              {\"type\": \"Text\"}],\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\",\n              {\"type\": \"Int\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4,\n              {\"type\": \"Int\"}],\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\",\n              {\"type\": \"Any\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4,\n              {\"type\": \"Any\"}]\n        ],\n        \"direct\": [true, true, true, true, true, true],\n        \"undo\": [\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\",\n              {\"type\": \"Any\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4,\n              {\"type\": \"Any\"}],\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\",\n              {\"type\": \"Text\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4,\n              {\"type\": \"Text\"}],\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\",\n              {\"type\": \"Int\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4,\n              {\"type\": \"Int\"}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"fullNameLen\" : 7 }\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        // Check that nothing bad happens when going back and forth between formula\n        // and non-formula\n        [\"ModifyColumn\", \"Students\", \"fullNameLen\", {\"isFormula\": false, \"type\": \"Int\"}],\n        [\"ModifyColumn\", \"Students\", \"fullNameLen\", {\"isFormula\": true, \"formula\" : \"len($fullName) - 1\"}],\n        [\"ModifyColumn\", \"Students\", \"fullNameLen\", {\"isFormula\": false}]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\",\n              {\"isFormula\": false, \"type\": \"Int\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4,\n              {\"isFormula\": false, \"type\": \"Int\"}],\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\",\n              {\"formula\": \"len($fullName) - 1\", \"isFormula\": true}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4,\n              {\"formula\": \"len($fullName) - 1\", \"isFormula\": true}],\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\", {\"isFormula\": false}],\n            [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n              {\"fullNameLen\": [13, 10, 13, 14, 14, 13, 12]}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4, {\"isFormula\": false}]\n        ],\n        \"direct\": [true, true, true, true, true, false, true],\n        \"undo\": [\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\",\n              {\"isFormula\": true, \"type\": \"Any\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4,\n              {\"isFormula\": true, \"type\": \"Any\"}],\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\",\n              {\"isFormula\": false, \"formula\" : \"len(rec.fullName)\"}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4,\n              {\"isFormula\": false, \"formula\" : \"len(rec.fullName)\"}],\n            [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n              {\"fullNameLen\": [14, 11, 14, 15, 15, 14, 13]}],\n            [\"ModifyColumn\", \"Students\", \"fullNameLen\", {\"isFormula\": true}],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 4, {\"isFormula\": true}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"fullNameLen\" : 7 }\n      }\n    }],\n\n    // XXX\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",\"fullNameLen\",\"@schoolShort\",\n          \"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Obama - Barack\", 13,      \"Columbia\",   \"NY\"],\n        [2,   \"G.W.\",     \"Bush\",    8,       \"Bush - G.W.\",    10,      \"Yale\",       \"CT\"],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Clinton - Bill\", 13,      \"Oxford\",     \"Europe\"],\n        [4,   \"George H\", \"Bush\",    8,       \"Bush - George H\",14,      \"Yale\",       \"CT\"],\n        [5,   \"Ronald\",   \"Reagan\",  1,       \"Reagan - Ronald\",14,      \"Eureka\",     \"IL\"],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Carter - Jimmy\", 13,      \"U.S.\",       \"MD\"],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Ford - Gerald\",  12,      \"Yale\",       \"CT\"]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"Europe\"],\n        [11,    \"Anytown\",    \"\",         \"US\",      \"North America\"]]\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        // Modifying types converts some values, leaves others as alttext.\n        [\"UpdateRecord\", \"Address\", 2, {\"city\" : \"567\"}],\n        [\"ModifyColumn\", \"Address\", \"city\", {\"type\" : \"Int\"}]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"UpdateRecord\", \"Address\", 2, {\"city\" : \"567\"}],\n          [\"ModifyColumn\", \"Address\", \"city\", {\"type\" : \"Int\"}],\n          [\"UpdateRecord\", \"Address\", 2, {\"city\" : 567}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\" : \"Int\"}]\n        ],\n        \"direct\": [true, true, false, true],\n        \"undo\" : [\n          [\"UpdateRecord\", \"Address\", 2, {\"city\": \"Eureka\"}],\n          [\"UpdateRecord\", \"Address\", 2, {\"city\" : \"567\"}],\n          [\"ModifyColumn\", \"Address\", \"city\", {\"type\": \"Text\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Text\"}]\n\n        ]\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTION\": [\n        // Updating a record creates alttext strings properly\n        \"UpdateRecord\", \"Address\", 2, {\"city\": \"Eureka\"}\n      ],\n      \"ACTIONS\": {\n        \"stored\": [[\"UpdateRecord\", \"Address\", 2, {\"city\": \"Eureka\"}]],\n        \"direct\": [true],\n        \"undo\" : [[\"UpdateRecord\", \"Address\", 2, {\"city\" : 567}]]\n      }\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        // Modify Column type conversion in a case there the entire column is \"ok\"\n        [\"ModifyColumn\", \"Address\", \"city\", { \"type\" : \"Text\" }],\n        [\"BulkUpdateRecord\", \"Address\", [2,3,4,7,10, 11], {\"city\": [\"7\",\"7\",\"7\",\"7\",\"7\", \"7\"] }],\n        [\"ModifyColumn\", \"Address\", \"city\", { \"type\" : \"Int\" }]\n       ],\n       \"ACTIONS\" : {\n        \"stored\": [\n          [\"ModifyColumn\", \"Address\", \"city\", {\"type\": \"Text\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Text\"}],\n          [\"BulkUpdateRecord\", \"Address\", [2, 3, 4, 7, 10, 11], {\"city\": [\"7\", \"7\", \"7\", \"7\", \"7\", \"7\"]}],\n          [\"ModifyColumn\", \"Address\", \"city\", {\"type\": \"Int\"}],\n          [\"BulkUpdateRecord\", \"Address\", [2, 3, 4, 7, 10, 11], {\"city\": [7, 7, 7, 7, 7, 7]}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Int\"}]\n\n        ],\n        \"direct\": [true, true, true, true, false, true],\n        \"undo\": [\n          [\"ModifyColumn\", \"Address\", \"city\", {\"type\": \"Int\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Int\"}],\n          [\"BulkUpdateRecord\", \"Address\", [2, 3, 4, 7, 10, 11], {\"city\": [\"Eureka\", \"Annapolis\", \"New Haven\", \"New York\", \"Oxford\", \"Anytown\"]}],\n          [\"BulkUpdateRecord\", \"Address\", [2, 3, 4, 7, 10, 11], {\"city\": [\"7\", \"7\", \"7\", \"7\", \"7\", \"7\"]}],\n          [\"ModifyColumn\", \"Address\", \"city\", {\"type\": \"Text\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 21, {\"type\": \"Text\"}]\n       ]\n       }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",\"fullNameLen\",\"@schoolShort\",\n          \"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Obama - Barack\", 13,      \"Columbia\",   \"NY\"],\n        [2,   \"G.W.\",     \"Bush\",    8,       \"Bush - G.W.\",    10,      \"Yale\",       \"CT\"],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Clinton - Bill\", 13,      \"Oxford\",     \"Europe\"],\n        [4,   \"George H\", \"Bush\",    8,       \"Bush - George H\",14,      \"Yale\",       \"CT\"],\n        [5,   \"Ronald\",   \"Reagan\",  1,       \"Reagan - Ronald\",14,      \"Eureka\",     \"IL\"],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Carter - Jimmy\", 13,      \"U.S.\",       \"MD\"],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Ford - Gerald\",  12,      \"Yale\",       \"CT\"]],\n      \"Address\": [\n        [\"id\",  \"city\", \"state\",    \"country\", \"@region\"],\n        [2,     7,         \"IL\",       \"US\",      \"North America\"],\n        [3,     7,         \"MD\",       \"US\",      \"North America\"],\n        [4,     7,         \"CT\",       \"US\",      \"North America\"],\n        [7,     7,         \"NY\",       \"US\",      \"North America\"],\n        [10,    7,    \"England\",       \"UK\",      \"Europe\"],\n        [11,    7,           \"\",       \"US\",      \"North America\"]]\n    }],\n\n   [\"APPLY\", {\n      // Check that turning a non-formula column, with no formula, into a formula\n      // works as expected\n      \"USER_ACTION\" : [\"ModifyColumn\", \"Address\", \"state\", { \"isFormula\" : true, \"type\": \"Any\" }],\n      \"ACTIONS\" : {\n        \"stored\" : [\n          [\"ModifyColumn\", \"Address\", \"state\", {\"isFormula\": true, \"type\": \"Any\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 27, {\"isFormula\": true, \"type\": \"Any\"}],\n          [\"BulkUpdateRecord\", \"Address\", [2, 3, 4, 7, 10, 11], {\"state\": [null, null, null, null, null, null]}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 4, 5, 6, 8], {\"schoolRegion\": [null, null, null, null, null, null]}]\n        ],\n        \"direct\": [true, true, false, false],\n        \"undo\" : [\n          [\"ModifyColumn\", \"Address\", \"state\", {\"isFormula\": false, \"type\": \"Text\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 27, {\"isFormula\": false, \"type\": \"Text\"}],\n          [\"BulkUpdateRecord\", \"Address\", [2, 3, 4, 7, 10, 11],\n            {\"state\": [\"IL\", \"MD\", \"CT\", \"NY\", \"England\", \"\"]}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 4, 5, 6, 8], {\"schoolRegion\": [\"NY\", \"CT\", \"CT\", \"IL\", \"MD\", \"CT\"]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Address\"  : { \"state\" : 6 },\n        \"Students\" : { \"schoolRegion\" : 7 }\n      }\n    }],\n\n   [\"APPLY\", {\n     // Property generation\n     \"USER_ACTION\": [\"UpdateRecord\", \"Students\", 1, {\"fullNameLen\" : \"Fourteen\"}],\n     \"ACTIONS\" : {\n      \"stored\" : [[\"UpdateRecord\", \"Students\", 1, {\"fullNameLen\": \"Fourteen\"}]],\n      \"direct\": [true],\n      \"undo\" : [[\"UpdateRecord\", \"Students\", 1, {\"fullNameLen\": 13}]]\n     }\n   }],\n\n   [\"APPLY\", {\n     // Check formula error handling\n     \"USER_ACTIONS\" : [\n       [\"ModifyColumn\", \"Students\", \"fullName\", {\"formula\" : \"!#@%&T#$UDSAIKVFsdhifzsk\" }],\n       [\"ModifyColumn\", \"Students\", \"schoolRegion\", {\"formula\" : \"5*len($firstName) // $fullNameLen\" }]\n     ],\n     \"ACTIONS\" : {\n       \"stored\" : [\n         [\"ModifyColumn\", \"Students\", \"fullName\", {\"formula\": \"!#@%&T#$UDSAIKVFsdhifzsk\"}],\n         [\"UpdateRecord\", \"_grist_Tables_column\", 3, {\"formula\": \"!#@%&T#$UDSAIKVFsdhifzsk\"}],\n         [\"ModifyColumn\", \"Students\", \"schoolRegion\", {\"formula\": \"5*len($firstName) // $fullNameLen\"}],\n         [\"UpdateRecord\", \"_grist_Tables_column\", 9, {\"formula\": \"5*len($firstName) // $fullNameLen\"}],\n         [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n           {\"fullName\" : [[\"E\",\"SyntaxError\"], [\"E\",\"SyntaxError\"], [\"E\",\"SyntaxError\"],\n             [\"E\",\"SyntaxError\"], [\"E\",\"SyntaxError\"], [\"E\",\"SyntaxError\"], [\"E\",\"SyntaxError\"]]\n           }],\n         // In previous section, we set $fullNameLen to the string \"Fourteen\" for Student #1, so\n         // the calculation for that record fails with TypeError.\n         [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n           {\"schoolRegion\": [[\"E\", \"TypeError\"], 2, 1, 2, 2, 1, 2]}]\n       ],\n       \"direct\": [true, true, true, true, false, false],\n       \"undo\" : [\n         [\"ModifyColumn\", \"Students\", \"fullName\", {\"formula\": \"rec.lastName + ' - ' + rec.firstName\"}],\n         [\"UpdateRecord\", \"_grist_Tables_column\", 3, {\"formula\": \"rec.lastName + ' - ' + rec.firstName\"}],\n         [\"ModifyColumn\", \"Students\", \"schoolRegion\",\n           {\"formula\": \"addr = $school.address\\naddr.state if addr.country == 'US' else addr.region\"}],\n         [\"UpdateRecord\", \"_grist_Tables_column\", 9,\n           {\"formula\": \"addr = $school.address\\naddr.state if addr.country == 'US' else addr.region\"}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n            {\"fullName\": [\"Obama - Barack\", \"Bush - G.W.\", \"Clinton - Bill\", \"Bush - George H\", \"Reagan - Ronald\", \"Carter - Jimmy\", \"Ford - Gerald\"]}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n            {\"schoolRegion\": [null, null, \"Europe\", null, null, null, null]}]\n       ]\n     },\n     \"CHECK_CALL_COUNTS\" : {\n       \"Students\" : { \"schoolRegion\" : 7, \"fullName\" : 7 }\n     }\n   }],\n\n   [\"APPLY\", {\n     // Check that error handling works properly when evaluating a no-longer\n     // error-full formula\n     \"USER_ACTION\" :\n       [\"ModifyColumn\", \"Students\", \"fullName\", {\"formula\" : \"$firstName\"}],\n     \"ACTIONS\" : {\n       \"stored\" : [\n         [\"ModifyColumn\", \"Students\", \"fullName\", {\"formula\": \"$firstName\"}],\n         [\"UpdateRecord\", \"_grist_Tables_column\", 3, {\"formula\": \"$firstName\"}],\n         [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n           {\"fullName\": [\"Barack\", \"G.W.\", \"Bill\", \"George H\", \"Ronald\", \"Jimmy\", \"Gerald\"]}]\n       ],\n       \"direct\": [true, true, false],\n       \"undo\" : [\n         [\"ModifyColumn\", \"Students\", \"fullName\", {\"formula\": \"!#@%&T#$UDSAIKVFsdhifzsk\"}],\n         [\"UpdateRecord\", \"_grist_Tables_column\", 3, {\"formula\": \"!#@%&T#$UDSAIKVFsdhifzsk\"}],\n         [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n           {\"fullName\": [[\"E\", \"SyntaxError\"], [\"E\", \"SyntaxError\"], [\"E\", \"SyntaxError\"], [\"E\", \"SyntaxError\"], [\"E\", \"SyntaxError\"], [\"E\", \"SyntaxError\"], [\"E\", \"SyntaxError\"]]}]\n       ]\n     }\n   }],\n\n\n  [\"CHECK_OUTPUT\", {\n     \"USE_SAMPLE\": \"basic\",\n     \"Students\": [\n       [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",\"fullNameLen\",\"@schoolShort\",\n         \"@schoolRegion\"],\n       [1,   \"Barack\",   \"Obama\",   2, \"Barack\", \"Fourteen\", \"Columbia\", [\"E\",\"TypeError\"]],\n       [2,   \"G.W.\",     \"Bush\",    8, \"G.W.\",      10, \"Yale\",     2],\n       [3,   \"Bill\",     \"Clinton\", 6, \"Bill\",      13, \"Oxford\",   1],\n       [4,   \"George H\", \"Bush\",    8, \"George H\",  14, \"Yale\",     2],\n       [5,   \"Ronald\",   \"Reagan\",  1, \"Ronald\",    14, \"Eureka\",   2],\n       [6,   \"Jimmy\",    \"Carter\",  5, \"Jimmy\",     13, \"U.S.\",     1],\n       [8,   \"Gerald\",   \"Ford\",    7, \"Gerald\",    12, \"Yale\",     2]],\n     \"Address\": [\n       [\"id\",  \"city\", \"@state\",    \"country\", \"@region\"],\n       [2,     7,           null,       \"US\",      \"North America\"],\n       [3,     7,           null,       \"US\",      \"North America\"],\n       [4,     7,           null,       \"US\",      \"North America\"],\n       [7,     7,           null,       \"US\",      \"North America\"],\n       [10,    7,           null,       \"UK\",      \"Europe\"],\n       [11,    7,           null,       \"US\",      \"North America\"]]\n  }],\n\n  [\"APPLY\", {\n    // Check that ModifyColumn properly handles noops on redundant updates\n    \"USER_ACTIONS\" : [\n      [\"ModifyColumn\", \"Students\", \"fullName\", {\"isFormula\" : true }],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 3, { \"formula\" : \"$firstName\" }],\n      [\"ModifyColumn\", \"Students\", \"fullName\",\n        {\"isFormula\" : true, \"formula\" : \"$firstName\", \"label\": \"Entire Name\"}]\n    ],\n    \"ACTIONS\" : {\n      \"stored\" : [\n        [\"RenameColumn\", \"Students\", \"fullName\", \"Entire_Name\"],\n        [\"ModifyColumn\", \"Students\", \"fullNameLen\", {\n          \"formula\": \"len($Entire_Name) - 1\"\n        }],\n        [\"BulkUpdateRecord\", \"_grist_Tables_column\", [3, 4], {\n          \"label\": [\"Entire Name\", \"Full Name Length\"],\n          \"colId\": [\"Entire_Name\", \"fullNameLen\"],\n          \"formula\": [\"$firstName\", \"len($Entire_Name) - 1\"]\n        }]\n      ],\n      \"direct\": [true, true, true],\n      \"undo\" : [\n        [\"RenameColumn\", \"Students\", \"Entire_Name\", \"fullName\"],\n        [\"ModifyColumn\", \"Students\", \"fullNameLen\", {\n          \"formula\": \"len($fullName) - 1\"\n        }],\n        [\"BulkUpdateRecord\", \"_grist_Tables_column\", [3, 4], {\n          \"label\": [\"Full Name\", \"Full Name Length\"],\n          \"colId\": [\"fullName\", \"fullNameLen\"],\n          \"formula\": [\"$firstName\", \"len($fullName) - 1\"]\n        }]\n      ]\n    }\n  }]\n ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"column_conversions\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"simplest\"],\n    [\"CHECK_OUTPUT\", {\"USE_SAMPLE\": \"simplest\"}],\n\n    [\"APPLY\", {\n      // Add a DateTime column\n      \"USER_ACTION\": [\"AddColumn\", \"foo\", \"c_date\", {\"type\": \"DateTime\", \"isFormula\": false}],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"AddColumn\", \"foo\", \"c_date\", {\n            \"type\":          \"DateTime\",\n            \"isFormula\":     false,\n            \"formula\":       \"\"\n          }],\n          [\"AddRecord\", \"_grist_Tables_column\", 2, {\n            \"colId\": \"c_date\", \"formula\": \"\", \"isFormula\": false, \"label\": \"c_date\", \"parentId\": 1,\n            \"parentPos\": 2.0, \"type\": \"DateTime\", \"widgetOptions\": \"\"}\n          ]\n        ],\n        \"direct\": [true, true],\n        \"undo\": [\n          [\"RemoveColumn\", \"foo\", \"c_date\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 2]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"simplest\",\n      \"foo\": [\n        [\"id\", \"bar\",   \"c_date\"],\n        [1,    \"apple\", null]\n      ]\n    }],\n\n    [\"APPLY\", {\n      // Change it to Int\n      \"USER_ACTION\": [\"ModifyColumn\", \"foo\", \"c_date\", {\"type\": \"Int\"}],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"ModifyColumn\", \"foo\", \"c_date\", {\"type\": \"Int\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 2, {\"type\": \"Int\"}]\n        ],\n        \"direct\": [true, true],\n        \"undo\": [\n          [\"ModifyColumn\", \"foo\", \"c_date\", {\"type\": \"DateTime\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 2, {\"type\": \"DateTime\"}]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"simplest\",\n      \"foo\": [\n        [\"id\", \"bar\",   \"c_date\"],\n        [1,    \"apple\", null]\n      ]\n    }],\n\n    [\"APPLY\", {\n      // Change it to Numeric\n      \"USER_ACTION\": [\"ModifyColumn\", \"foo\", \"c_date\", {\"type\": \"Numeric\"}],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"ModifyColumn\", \"foo\", \"c_date\", {\"type\": \"Numeric\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 2, {\"type\": \"Numeric\"}]\n        ],\n        \"direct\": [true, true],\n        \"undo\": [\n          [\"ModifyColumn\", \"foo\", \"c_date\", {\"type\": \"Int\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 2, {\"type\": \"Int\"}]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"simplest\",\n      \"foo\": [\n        [\"id\", \"bar\",   \"c_date\"],\n        [1,    \"apple\", null]\n      ]\n    }],\n\n    [\"APPLY\", {\n      // Change it back to DateTime\n      \"USER_ACTION\": [\"ModifyColumn\", \"foo\", \"c_date\", {\"type\": \"DateTime\"}],\n      \"ACTIONS\": {\n        \"stored\": [\n          // Note absence of UpdateRecord for 0 to null, because 0 is compatible with DateTime\n          [\"ModifyColumn\", \"foo\", \"c_date\", {\"type\": \"DateTime\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 2, {\"type\": \"DateTime\"}]\n        ],\n        \"direct\": [true, true],\n        \"undo\": [\n          [\"ModifyColumn\", \"foo\", \"c_date\", {\"type\": \"Numeric\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 2, {\"type\": \"Numeric\"}]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"simplest\",\n      \"foo\": [\n        [\"id\", \"bar\",   \"c_date\"],\n        [1,    \"apple\", null]\n      ]\n    }]\n  ]\n\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"add_table\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        [\"AddTable\", \"Foo\", []],\n        [\"AddTable\", \"Bar\", [\n          { \"id\": \"hello\", \"label\": \"hello\", \"type\": \"Text\", \"isFormula\": false, \"widgetOptions\": \"\"},\n          { \"id\": \"world\", \"label\": \"world\", \"type\": \"Text\", \"isFormula\": true,  \"formula\": \"rec.hello.upper()\", \"widgetOptions\": \"\"},\n          { \"id\": \"foo\",   \"label\": \"foo\", \"type\": \"Ref:Foo\", \"isFormula\": false, \"widgetOptions\": \"\" }\n        ]],\n        [\"AddRecord\", \"Bar\", 1, {\"hello\": \"a\", \"foo\": 0}],\n        [\"AddRecord\", \"Bar\", 2, {\"hello\": \"b\", \"foo\": 1}],\n        [\"AddRecord\", \"Bar\", 3, {\"hello\": \"c\", \"foo\": 1}]\n      ],\n\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"AddTable\", \"Foo\", [\n            {\"id\": \"manualSort\", \"formula\": \"\", \"isFormula\": false, \"type\": \"ManualSortPos\"}\n          ]],\n          [\"AddRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 0, \"tableId\": \"Foo\"}],\n          [\"AddRecord\", \"_grist_Tables_column\", 30, {\"colId\": \"manualSort\", \"formula\": \"\", \"isFormula\": false,\n           \"label\": \"manualSort\", \"parentId\": 4, \"parentPos\": 8.0, \"type\": \"ManualSortPos\", \"widgetOptions\": \"\"}],\n          // Raw view\n          [\"AddRecord\", \"_grist_Views\", 1,\n            {\"type\": \"raw_data\", \"name\": \"Foo\"}],\n          [\"AddRecord\", \"_grist_TabBar\", 1, {\"tabPos\": 1.0, \"viewRef\": 1}],\n          [\"AddRecord\", \"_grist_Pages\", 1, {\"indentation\": 0, \"pagePos\": 1.0, \"viewRef\": 1}],\n          [\"AddRecord\", \"_grist_Views_section\", 1,\n            {\"tableRef\": 4, \"defaultWidth\": 100, \"borderWidth\": 1,\n              \"parentId\": 1, \"parentKey\": \"record\", \"sortColRefs\": \"[]\", \"title\": \"\"}],\n          [\"AddRecord\", \"_grist_Views_section\", 2, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"record\", \"tableRef\": 4, \"title\": \"\"}],\n          [\"AddRecord\", \"_grist_Views_section\", 3, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"single\", \"tableRef\": 4, \"title\": \"\"}],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"recordCardViewSectionRef\": 3}],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 1, \"rawViewSectionRef\": 2}],\n          [\"AddTable\", \"Bar\", [\n            {\"id\": \"manualSort\", \"formula\": \"\", \"isFormula\": false, \"type\": \"ManualSortPos\"},\n            {\"isFormula\": false, \"formula\": \"\", \"type\": \"Text\", \"id\": \"hello\"},\n            {\"isFormula\": true, \"formula\": \"rec.hello.upper()\", \"type\": \"Text\", \"id\": \"world\"},\n            {\"isFormula\": false, \"formula\": \"\", \"type\": \"Ref:Foo\", \"id\": \"foo\"}]\n          ],\n          [\"AddRecord\", \"_grist_Tables\", 5, {\"primaryViewId\": 0, \"tableId\": \"Bar\"}],\n\n          [\"BulkAddRecord\", \"_grist_Tables_column\", [31,32,33,34], {\n            \"colId\": [\"manualSort\",\"hello\",\"world\",\"foo\"],\n            \"isFormula\": [false,false,true,false],\n            \"formula\": [\"\",\"\",\"rec.hello.upper()\",\"\"],\n            \"type\": [\"ManualSortPos\",\"Text\",\"Text\",\"Ref:Foo\"],\n            \"label\": [\"manualSort\",\"hello\",\"world\",\"foo\"],\n            \"parentId\": [5,5,5,5],\n            \"parentPos\": [9.0,10.0,11.0,12.0],\n            \"widgetOptions\": [\"\",\"\",\"\",\"\"]\n          }],\n\n          [\"AddRecord\", \"_grist_Views\", 2,\n            {\"type\": \"raw_data\", \"name\": \"Bar\"}],\n          [\"AddRecord\", \"_grist_TabBar\", 2, {\"tabPos\": 2.0, \"viewRef\": 2}],\n          [\"AddRecord\", \"_grist_Pages\", 2, {\"pagePos\": 2.0, \"viewRef\": 2, \"indentation\": 0}],\n          [\"AddRecord\", \"_grist_Views_section\", 4,\n            {\"tableRef\": 5, \"defaultWidth\": 100, \"borderWidth\": 1,\n              \"parentId\": 2, \"parentKey\": \"record\", \"sortColRefs\": \"[]\", \"title\": \"\"}],\n          [\"BulkAddRecord\", \"_grist_Views_section_field\", [1,2,3],\n            {\"parentId\": [4,4,4], \"colRef\": [32,33,34], \"parentPos\": [1.0,2.0,3.0]}],\n          [\"AddRecord\", \"_grist_Views_section\", 5, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"record\", \"tableRef\": 5, \"title\": \"\"}],\n          [\"BulkAddRecord\", \"_grist_Views_section_field\", [4, 5, 6], {\"colRef\": [32, 33, 34], \"parentId\": [5, 5, 5], \"parentPos\": [4.0, 5.0, 6.0]}],\n          [\"AddRecord\", \"_grist_Views_section\", 6, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"single\", \"tableRef\": 5, \"title\": \"\"}],\n          [\"UpdateRecord\", \"_grist_Tables\", 5, {\"recordCardViewSectionRef\": 6}],\n          [\"BulkAddRecord\", \"_grist_Views_section_field\", [7, 8, 9], {\"colRef\": [32, 33, 34], \"parentId\": [6, 6, 6], \"parentPos\": [7.0, 8.0, 9.0]}],\n          [\"UpdateRecord\", \"_grist_Tables\", 5, {\"primaryViewId\": 2, \"rawViewSectionRef\": 5}],\n          [\"AddRecord\", \"Bar\", 1, {\"foo\": 0, \"hello\": \"a\", \"manualSort\": 1.0}],\n          [\"AddRecord\", \"Bar\", 2, {\"foo\": 1, \"hello\": \"b\", \"manualSort\": 2.0}],\n          [\"AddRecord\", \"Bar\", 3, {\"foo\": 1, \"hello\": \"c\", \"manualSort\": 3.0}],\n          [\"BulkUpdateRecord\", \"Bar\", [1, 2, 3], {\"world\": [\"A\", \"B\", \"C\"]}]\n        ],\n        \"direct\": [true, true, true, true, true, true, true, true,\n                   true, true, true, true, true, true, true, true,\n                   true, true, true, true, true, true, true, true, true,\n                   true, true, true, false],\n        \"undo\": [\n          [\"RemoveTable\", \"Foo\"],\n          [\"RemoveRecord\", \"_grist_Tables\", 4],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 30],\n          [\"RemoveRecord\", \"_grist_Views\", 1],\n          [\"RemoveRecord\", \"_grist_TabBar\", 1],\n          [\"RemoveRecord\", \"_grist_Pages\", 1],\n          [\"RemoveRecord\", \"_grist_Views_section\", 1],\n          [\"RemoveRecord\", \"_grist_Views_section\", 2],\n          [\"RemoveRecord\", \"_grist_Views_section\", 3],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"recordCardViewSectionRef\": 0}],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 0, \"rawViewSectionRef\": 0}],\n          [\"RemoveTable\", \"Bar\"],\n          [\"RemoveRecord\", \"_grist_Tables\", 5],\n          [\"BulkRemoveRecord\", \"_grist_Tables_column\", [31,32,33,34]],\n          [\"RemoveRecord\", \"_grist_Views\", 2],\n          [\"RemoveRecord\", \"_grist_TabBar\", 2],\n          [\"RemoveRecord\", \"_grist_Pages\", 2],\n          [\"RemoveRecord\", \"_grist_Views_section\", 4],\n          [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [1,2,3]],\n          [\"RemoveRecord\", \"_grist_Views_section\", 5],\n          [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [4, 5, 6]],\n          [\"RemoveRecord\", \"_grist_Views_section\", 6],\n          [\"UpdateRecord\", \"_grist_Tables\", 5, {\"recordCardViewSectionRef\": 0}],\n          [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [7, 8, 9]],\n          [\"UpdateRecord\", \"_grist_Tables\", 5, {\"primaryViewId\": 0, \"rawViewSectionRef\": 0}],\n          [\"RemoveRecord\", \"Bar\", 1],\n          [\"RemoveRecord\", \"Bar\", 2],\n          [\"RemoveRecord\", \"Bar\", 3]\n        ],\n        \"retValue\": [\n          // AddTable \"Foo\" retValue\n          {\n            \"table_id\": \"Foo\",\n            \"id\": 4,\n            \"columns\": [],\n            \"views\": [\n              { \"sections\": [ 1 ], \"id\": 1 }\n            ]\n          },\n          // AddTable \"Bar\" retValue\n          {\n            \"table_id\": \"Bar\",\n            \"id\": 5,\n            \"columns\": [\"hello\", \"world\", \"foo\"],\n            \"views\": [\n              { \"sections\": [ 4 ], \"id\": 2 }\n            ]\n          },\n          // AddRecord retValues\n          1, 2, 3\n       ]\n      },\n      \"CHECK_CALL_COUNTS\": {\n        //\"Bar\" : { \"world\" : 3 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Foo\": [\n        [\"id\", \"manualSort\"]\n      ],\n      \"Bar\": [\n        [\"id\", \"hello\", \"foo\", \"manualSort\", \"@world\"],\n        [1,    \"a\",     0,     1.0,          \"A\"],\n        [2,    \"b\",     1,     2.0,          \"B\"],\n        [3,    \"c\",     1,     3.0,          \"C\"]\n      ]\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"remove_table\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n\n     [\"APPLY\", {\n       \"USER_ACTION\": [\"AddTable\", \"Foo\", []],\n       \"ACTIONS\": {\n         \"stored\": [\n           [\"AddTable\", \"Foo\", [{\"id\": \"manualSort\", \"formula\": \"\",\n            \"isFormula\": false, \"type\": \"ManualSortPos\"}]],\n           [\"AddRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 0, \"tableId\": \"Foo\"}],\n           [\"AddRecord\", \"_grist_Tables_column\", 30, {\"colId\": \"manualSort\", \"formula\": \"\", \"isFormula\": false,\n            \"label\": \"manualSort\", \"parentId\": 4, \"parentPos\": 8.0, \"type\": \"ManualSortPos\", \"widgetOptions\": \"\"}],\n           // Raw view\n           [\"AddRecord\", \"_grist_Views\", 1,\n             {\"type\": \"raw_data\", \"name\": \"Foo\"}],\n           [\"AddRecord\", \"_grist_TabBar\", 1, {\"tabPos\": 1.0, \"viewRef\": 1}],\n           [\"AddRecord\", \"_grist_Pages\", 1, {\"pagePos\": 1.0, \"viewRef\": 1, \"indentation\": 0}],\n           [\"AddRecord\", \"_grist_Views_section\", 1,\n             {\"tableRef\": 4, \"defaultWidth\": 100, \"borderWidth\": 1,\n               \"parentId\": 1, \"parentKey\": \"record\", \"sortColRefs\": \"[]\", \"title\": \"\"}],\n           // Raw data widget\n           [\"AddRecord\", \"_grist_Views_section\", 2, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"record\", \"tableRef\": 4, \"title\": \"\"}],\n           // Record card widget\n           [\"AddRecord\", \"_grist_Views_section\", 3, {\"borderWidth\": 1, \"defaultWidth\": 100, \"parentKey\": \"single\", \"tableRef\": 4, \"title\": \"\"}],\n           // As part of adding a table, we also set the primaryViewId.\n           [\"UpdateRecord\", \"_grist_Tables\", 4, {\"recordCardViewSectionRef\": 3}],\n           [\"UpdateRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 1, \"rawViewSectionRef\": 2}]\n         ],\n         \"direct\": [true, true, true, true, true, true, true, true, true, true, true],\n         \"undo\": [\n           [\"RemoveTable\", \"Foo\"],\n           [\"RemoveRecord\", \"_grist_Tables\", 4],\n           [\"RemoveRecord\", \"_grist_Tables_column\", 30],\n           [\"RemoveRecord\", \"_grist_Views\", 1],\n           [\"RemoveRecord\", \"_grist_TabBar\", 1],\n           [\"RemoveRecord\", \"_grist_Pages\", 1],\n           [\"RemoveRecord\", \"_grist_Views_section\", 1],\n           [\"RemoveRecord\", \"_grist_Views_section\", 2],\n           [\"RemoveRecord\", \"_grist_Views_section\", 3],\n           [\"UpdateRecord\", \"_grist_Tables\", 4, {\"recordCardViewSectionRef\": 0}],\n           [\"UpdateRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 0, \"rawViewSectionRef\": 0}]\n         ]\n       }\n     }],\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"basic\",\n      \"Foo\": [[\"id\", \"manualSort\"]]\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"RemoveTable\", \"Foo\"],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"BulkRemoveRecord\", \"_grist_Views_section\", [1, 2, 3]],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"rawViewSectionRef\": 0}],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"recordCardViewSectionRef\": 0}],\n          [\"RemoveRecord\", \"_grist_TabBar\", 1],\n          [\"RemoveRecord\", \"_grist_Pages\", 1],\n          [\"RemoveRecord\", \"_grist_Views\", 1],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 0}],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 30],\n          [\"RemoveRecord\", \"_grist_Tables\", 4],\n          [\"RemoveTable\", \"Foo\"]\n        ],\n        \"direct\": [true, true, true, true, true, true, true, true, true, true],\n        \"undo\": [\n          [\"BulkAddRecord\", \"_grist_Views_section\", [1, 2, 3],\n            {\"borderWidth\": [1, 1, 1], \"defaultWidth\": [100, 100, 100], \"parentId\": [1, 0, 0],\n              \"parentKey\": [\"record\", \"record\", \"single\"], \"sortColRefs\": [\"[]\", \"\", \"\"], \"tableRef\": [4, 4, 4]}],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"rawViewSectionRef\": 2}],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"recordCardViewSectionRef\": 3}],\n          [\"AddRecord\", \"_grist_TabBar\", 1, {\"tabPos\": 1.0, \"viewRef\": 1}],\n          [\"AddRecord\", \"_grist_Pages\", 1, {\"pagePos\": 1.0, \"viewRef\": 1}],\n          [\"AddRecord\", \"_grist_Views\", 1, {\"name\": \"Foo\", \"type\": \"raw_data\"}],\n          [\"UpdateRecord\", \"_grist_Tables\", 4, {\"primaryViewId\": 1}],\n          [\"AddRecord\", \"_grist_Tables_column\", 30,\n            {\"colId\": \"manualSort\",\n             \"label\": \"manualSort\", \"parentId\": 4, \"parentPos\": 8.0,\n             \"type\": \"ManualSortPos\"}],\n          [\"AddRecord\", \"_grist_Tables\", 4, {\"tableId\": \"Foo\"}],\n          [\"AddTable\", \"Foo\",  [{\"formula\": \"\",\n           \"id\": \"manualSort\", \"isFormula\": false, \"type\": \"ManualSortPos\"}]]\n        ]\n      }\n    }],\n    [\"CHECK_OUTPUT\",\n      {\"USE_SAMPLE\": \"basic\"}\n    ],\n\n    // When there is a Reference column to a deleted table, the reference column is also deleted.\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"RemoveTable\", \"Schools\"],\n      \"ACTIONS\": {\n        \"stored\": [\n          // Students.school is converted from Ref:Schools to Int\n          [\"ModifyColumn\", \"Students\", \"school\", {\"type\": \"Int\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"type\": \"Int\"}],\n\n          [\"BulkRemoveRecord\", \"_grist_Tables_column\", [10, 12]],\n          [\"RemoveRecord\", \"_grist_Tables\", 2],\n          [\"RemoveTable\", \"Schools\"],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n            {\"schoolRegion\": [[\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"],\n              [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"],\n              [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"]]\n            }],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8],\n            {\"schoolShort\": [[\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"],\n              [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"],\n              [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"]]\n            }]\n        ],\n        \"direct\": [true, true, true, true, true, false, false],\n        \"undo\": [\n          [\"ModifyColumn\", \"Students\", \"school\", {\"type\": \"Ref:Schools\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"type\": \"Ref:Schools\"}],\n          [\"BulkAddRecord\", \"_grist_Tables_column\", [10, 12], {\n            \"colId\": [\"name\", \"address\"],\n            \"label\": [\"Name\", \"Address\"], \"parentId\": [2, 2], \"parentPos\": [1.0, 2.0],\n            \"type\": [\"Text\", \"Ref:Address\"]\n          }],\n          [\"AddRecord\", \"_grist_Tables\", 2, {\"tableId\": \"Schools\"}],\n          [\"BulkAddRecord\", \"Schools\", [1, 2, 5, 6, 7, 8],\n            {\"name\": [\n              \"Eureka College\", \"Columbia University\", \"U.S. Naval Academy\",\n              \"Oxford University\", \"Yale Law School\", \"Yale University\"],\n             \"address\": [2, 7, 3, 10, 4, 4]}],\n          [\"AddTable\", \"Schools\", [\n            {\"isFormula\": false, \"formula\": \"\", \"type\": \"Text\", \"id\": \"name\"},\n            {\"isFormula\": false, \"formula\": \"\", \"type\": \"Ref:Address\", \"id\": \"address\"}]\n          ],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8], {\"schoolRegion\": [\"NY\", \"CT\", \"Europe\", \"CT\", \"IL\", \"MD\", \"CT\"]}],\n          [\"BulkUpdateRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8], {\"schoolShort\": [\"Columbia\", \"Yale\", \"Oxford\", \"Yale\", \"Eureka\", \"U.S.\", \"Yale\"]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"Students\" : { \"schoolRegion\" : 7, \"schoolShort\" : 7 }\n      }\n    }],\n    [\"CHECK_OUTPUT\", {\n      \"Students\": [\n        [\"id\",\"firstName\",\"lastName\",\"@fullName\",\"@fullNameLen\",\"school\",\"@schoolShort\",\"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   \"Barack Obama\", 12, 2, [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"]],\n        [2,   \"George W\", \"Bush\",    \"George W Bush\",13, 8, [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"]],\n        [3,   \"Bill\",     \"Clinton\", \"Bill Clinton\", 12, 6, [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"]],\n        [4,   \"George H\", \"Bush\",    \"George H Bush\",13, 8, [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"]],\n        [5,   \"Ronald\",   \"Reagan\",  \"Ronald Reagan\",13, 1, [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"]],\n        [6,   \"Jimmy\",    \"Carter\",  \"Jimmy Carter\", 12, 5, [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"]],\n        [8,   \"Gerald\",   \"Ford\",    \"Gerald Ford\",  11, 7, [\"E\",\"AttributeError\"], [\"E\",\"AttributeError\"]]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"Europe\"]]\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTION\": [\"RemoveTable\", \"Students\"],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"BulkRemoveRecord\", \"_grist_Tables_column\", [1, 2, 3, 4, 5, 6, 9]],\n          [\"RemoveRecord\", \"_grist_Tables\", 1],\n          [\"RemoveTable\", \"Students\"]\n        ],\n        \"direct\": [true, true, true],\n        \"undo\": [\n          [\"BulkAddRecord\", \"_grist_Tables_column\", [1, 2, 3, 4, 5, 6, 9], {\n            \"colId\": [\"firstName\", \"lastName\", \"fullName\", \"fullNameLen\", \"school\", \"schoolShort\",\n              \"schoolRegion\"],\n            \"formula\": [\"\", \"\", \"rec.firstName + ' ' + rec.lastName\", \"len(rec.fullName)\", \"\",\n              \"rec.school.name.split(' ')[0]\",\n                \"addr = $school.address\\naddr.state if addr.country == 'US' else addr.region\"],\n            \"isFormula\": [false, false, true, true, false, true, true],\n            \"label\": [\"First Name\", \"Last Name\", \"Full Name\", \"Full Name Length\", \"school\",\"School Short\",\n              \"School Region\"],\n            \"parentId\": [1, 1, 1, 1, 1, 1, 1], \"parentPos\": [1, 2, 3, 4, 5, 6, 7],\n            \"type\": [\"Text\", \"Text\", \"Any\", \"Any\", \"Int\", \"Any\", \"Any\"]\n          }],\n          [\"AddRecord\", \"_grist_Tables\", 1, {\"tableId\": \"Students\"}],\n          [\"BulkAddRecord\", \"Students\", [1, 2, 3, 4, 5, 6, 8], {\n            \"firstName\": [\"Barack\", \"George W\", \"Bill\", \"George H\", \"Ronald\", \"Jimmy\", \"Gerald\"],\n            \"fullName\": [\"Barack Obama\", \"George W Bush\", \"Bill Clinton\", \"George H Bush\", \"Ronald Reagan\", \"Jimmy Carter\", \"Gerald Ford\"],\n            \"fullNameLen\": [12, 13, 12, 13, 13, 12, 11],\n            \"lastName\": [\"Obama\", \"Bush\", \"Clinton\", \"Bush\", \"Reagan\", \"Carter\", \"Ford\"],\n            \"school\": [2, 8, 6, 8, 1, 5, 7],\n            \"schoolRegion\": [[\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"],\n              [\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"]],\n            \"schoolShort\": [[\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"],\n                [\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"], [\"E\", \"AttributeError\"]]\n          }],\n          [\"AddTable\", \"Students\", [\n            { \"isFormula\": false, \"formula\": \"\", \"type\": \"Text\", \"id\": \"firstName\"},\n            { \"isFormula\": false, \"formula\": \"\", \"type\": \"Text\", \"id\": \"lastName\"},\n            { \"isFormula\": true,\n              \"formula\": \"rec.firstName + ' ' + rec.lastName\", \"type\": \"Any\",\n              \"id\": \"fullName\"},\n            { \"isFormula\": true, \"formula\": \"len(rec.fullName)\", \"type\": \"Any\", \"id\": \"fullNameLen\"},\n            { \"isFormula\": true,\n              \"formula\": \"rec.school.name.split(' ')[0]\", \"type\": \"Any\",\n              \"id\": \"schoolShort\"},\n            { \"isFormula\": true, \"formula\": \"addr = $school.address\\naddr.state if addr.country == 'US' else addr.region\", \"type\": \"Any\" ,\n              \"id\": \"schoolRegion\"},\n            {\"formula\": \"\", \"id\": \"school\", \"isFormula\": false, \"type\": \"Int\"}\n          ]]\n        ]\n      }\n    }],\n    [\"CHECK_OUTPUT\", {\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"Europe\"]]\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"rename_table\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n\n    [\"APPLY\", {\n      // Check that tables can be renamed.\n      \"USER_ACTIONS\": [\n        [\"RenameTable\", \"Students\", \"People\"],\n        [\"RenameTable\", \"Schools\", \"School\"]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RenameTable\", \"Students\", \"People\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 1, {\"tableId\": \"People\"}],\n          [\"ModifyColumn\", \"People\", \"school\", {\"type\": \"Int\"}],\n          [\"RenameTable\", \"Schools\", \"School\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 2, {\"tableId\": \"School\"}],\n          [\"ModifyColumn\", \"People\", \"school\", {\"type\": \"Ref:School\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"type\": \"Ref:School\"}]\n        ],\n        \"direct\": [true, true, true, true, true, true, true],\n        \"undo\": [\n          [\"RenameTable\", \"People\", \"Students\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 1, {\"tableId\": \"Students\"}],\n          [\"ModifyColumn\", \"People\", \"school\", {\"type\": \"Ref:Schools\"}],\n          [\"RenameTable\", \"School\", \"Schools\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 2, {\"tableId\": \"Schools\"}],\n          [\"ModifyColumn\", \"People\", \"school\", {\"type\": \"Int\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"type\": \"Ref:Schools\"}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"People\" : { \"fullName\" : 7, \"schoolRegion\" : 7, \"schoolShort\" : 7, \"fullNameLen\" : 7, \"#lookup#\": 7 },\n        \"School\": {\"#lookup#\": 6}\n      }\n    }],\n\n    [\"APPLY\", {\n      // Check that tables can be renamed to a differently-cased name which SQLite considers\n      // equivalent (see RenameTable in DocStorage.js)\n      \"USER_ACTIONS\": [\n        [\"RenameTable\", \"People\", \"PEOPLE\"],\n        [\"RenameTable\", \"PEOPLE\", \"People\"]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RenameTable\", \"People\", \"PEOPLE\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 1, {\"tableId\": \"PEOPLE\"}],\n          [\"RenameTable\", \"PEOPLE\", \"People\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 1, {\"tableId\": \"People\"}]\n        ],\n        \"direct\": [true, true, true, true],\n        \"undo\": [\n          [\"RenameTable\", \"PEOPLE\", \"People\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 1, {\"tableId\": \"People\"}],\n          [\"RenameTable\", \"People\", \"PEOPLE\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 1, {\"tableId\": \"PEOPLE\"}]\n        ]\n      }\n    }],\n\n    [\"APPLY\", {\n      // Check that references to renamed tables continue to work.\n      \"USER_ACTION\": [\"RemoveRecord\", \"School\", 8],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RemoveRecord\", \"School\", 8],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"school\": [0, 0]}],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"schoolRegion\": [null, null]}],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"schoolShort\": [\"\", \"\"]}]\n        ],\n        \"direct\": [true, true, false ,false],\n        \"undo\": [\n          [\"AddRecord\", \"School\", 8, {\"name\": \"Yale University\", \"address\": 4}],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"school\": [8, 8]}],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"schoolRegion\": [\"CT\", \"CT\"]}],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"schoolShort\": [\"Yale\", \"Yale\"]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"People\" : { \"schoolRegion\" : 2, \"schoolShort\" : 2 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"People\": [\n        [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",\"@fullNameLen\",\"@schoolShort\",\n         \"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\", 12,           \"Columbia\",   \"NY\"],\n        [2,   \"George W\", \"Bush\",    0,       \"George W Bush\",13,           \"\",           null],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\", 12,           \"Oxford\",     \"Europe\"],\n        [4,   \"George H\", \"Bush\",    0,       \"George H Bush\",13,           \"\",           null],\n        [5,   \"Ronald\",   \"Reagan\",  1,       \"Ronald Reagan\",13,           \"Eureka\",     \"IL\"],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Jimmy Carter\", 12,           \"U.S.\",       \"MD\"],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",  11,           \"Yale\",       \"CT\"]],\n      \"School\": [\n        [\"id\",  \"name\",                 \"address\"],\n        [1,     \"Eureka College\",       2],\n        [2,     \"Columbia University\",  7],\n        [5,     \"U.S. Naval Academy\",   3],\n        [6,     \"Oxford University\",    10],\n        [7,     \"Yale Law School\",      4]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"Europe\"]]\n    }]\n  ]\n}, {\n\n  //----------------------------------------------------------------------\n  \"TEST_CASE\": \"user_rename_table\",\n  //----------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"basic\"],\n\n    [\"APPLY\", {\n      // Check that tables can be renamed.\n      \"USER_ACTIONS\": [\n        [\"UpdateRecord\", \"_grist_Tables\", 1, {\"tableId\": \"People\"}],\n        [\"UpdateRecord\", \"_grist_Tables\", 2, {\"tableId\": \"School\"}]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RenameTable\", \"Students\", \"People\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 1, {\"tableId\": \"People\"}],\n          [\"ModifyColumn\", \"People\", \"school\", {\"type\": \"Int\"}],\n          [\"RenameTable\", \"Schools\", \"School\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 2, {\"tableId\": \"School\"}],\n          [\"ModifyColumn\", \"People\", \"school\", {\"type\": \"Ref:School\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"type\": \"Ref:School\"}]\n        ],\n        \"direct\": [true, true, true, true, true, true, true],\n        \"undo\": [\n          [\"RenameTable\", \"People\", \"Students\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 1, {\"tableId\": \"Students\"}],\n          [\"ModifyColumn\", \"People\", \"school\", {\"type\": \"Ref:Schools\"}],\n          [\"RenameTable\", \"School\", \"Schools\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 2, {\"tableId\": \"Schools\"}],\n          [\"ModifyColumn\", \"People\", \"school\", {\"type\": \"Int\"}],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"type\": \"Ref:Schools\"}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"People\" : { \"fullName\" : 7, \"schoolRegion\" : 7, \"schoolShort\" : 7, \"fullNameLen\" : 7, \"#lookup#\": 7 },\n        \"School\": {\"#lookup#\": 6}\n      }\n    }],\n\n    [\"APPLY\", {\n      // Check that references to renamed tables continue to work.\n      \"USER_ACTION\": [\"RemoveRecord\", \"School\", 8],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RemoveRecord\", \"School\", 8],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"school\": [0, 0]}],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"schoolRegion\": [null, null]}],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"schoolShort\": [\"\", \"\"]}]\n        ],\n        \"direct\": [true, true, false, false],\n        \"undo\": [\n          [\"AddRecord\", \"School\", 8, {\"name\": \"Yale University\", \"address\": 4}],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"school\": [8, 8]}],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"schoolRegion\": [\"CT\", \"CT\"]}],\n          [\"BulkUpdateRecord\", \"People\", [2, 4], {\"schoolShort\": [\"Yale\", \"Yale\"]}]\n        ]\n      },\n      \"CHECK_CALL_COUNTS\" : {\n        \"People\" : { \"schoolRegion\" : 2, \"schoolShort\" : 2 }\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"People\": [\n        [\"id\",\"firstName\",\"lastName\",\"school\",\"@fullName\",\"@fullNameLen\",\"@schoolShort\",\n         \"@schoolRegion\"],\n        [1,   \"Barack\",   \"Obama\",   2,       \"Barack Obama\", 12,           \"Columbia\",   \"NY\"],\n        [2,   \"George W\", \"Bush\",    0,       \"George W Bush\",13,           \"\",           null],\n        [3,   \"Bill\",     \"Clinton\", 6,       \"Bill Clinton\", 12,           \"Oxford\",     \"Europe\"],\n        [4,   \"George H\", \"Bush\",    0,       \"George H Bush\",13,           \"\",           null],\n        [5,   \"Ronald\",   \"Reagan\",  1,       \"Ronald Reagan\",13,           \"Eureka\",     \"IL\"],\n        [6,   \"Jimmy\",    \"Carter\",  5,       \"Jimmy Carter\", 12,           \"U.S.\",       \"MD\"],\n        [8,   \"Gerald\",   \"Ford\",    7,       \"Gerald Ford\",  11,           \"Yale\",       \"CT\"]],\n      \"School\": [\n        [\"id\",  \"name\",                 \"address\"],\n        [1,     \"Eureka College\",       2],\n        [2,     \"Columbia University\",  7],\n        [5,     \"U.S. Naval Academy\",   3],\n        [6,     \"Oxford University\",    10],\n        [7,     \"Yale Law School\",      4]],\n      \"Address\": [\n        [\"id\",  \"city\",       \"state\",    \"country\", \"@region\"],\n        [2,     \"Eureka\",     \"IL\",       \"US\",      \"North America\"],\n        [3,     \"Annapolis\",  \"MD\",       \"US\",      \"North America\"],\n        [4,     \"New Haven\",  \"CT\",       \"US\",      \"North America\"],\n        [7,     \"New York\",   \"NY\",       \"US\",      \"North America\"],\n        [10,    \"Oxford\",     \"England\",  \"UK\",      \"Europe\"]]\n    }]\n  ]\n}, {\n  //------------------------------------------------------------------------\n  \"TEST_CASE\" : \"reserved_keywords\",\n  //------------------------------------------------------------------------\n  \"BODY\": [\n    [\"LOAD_SAMPLE\", \"simplest\"],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        [\"AddColumn\", \"foo\", \"on\",          {\"type\": \"Text\", \"isFormula\": false}],\n        [\"AddColumn\", \"foo\", \"alter\",       {\"type\": \"Text\", \"isFormula\": false}],\n        [\"AddColumn\", \"foo\", \"create\",      {\"type\": \"Text\", \"isFormula\": false}],\n        [\"AddColumn\", \"foo\", \"drop\",        {\"type\": \"Text\", \"isFormula\": false}],\n        [\"AddColumn\", \"foo\", \"transaction\", {\"type\": \"Text\", \"isFormula\": false}],\n        [\"AddColumn\", \"foo\", \"table\",       {\"type\": \"Text\", \"isFormula\": false}]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"AddColumn\", \"foo\", \"on\", {\"type\": \"Text\", \"isFormula\": false, \"formula\": \"\"}],\n          [\"AddRecord\", \"_grist_Tables_column\", 2, { \"colId\": \"on\", \"formula\": \"\", \"isFormula\":\n            false, \"label\": \"on\", \"parentId\": 1, \"parentPos\": 2.0, \"type\": \"Text\", \"widgetOptions\":\n            \"\"}],\n          [\"AddColumn\", \"foo\", \"alter\", {\"type\": \"Text\", \"isFormula\": false, \"formula\": \"\"}],\n          [\"AddRecord\", \"_grist_Tables_column\", 3, { \"colId\": \"alter\", \"formula\": \"\", \"isFormula\":\n            false, \"label\": \"alter\", \"parentId\": 1, \"parentPos\": 3.0, \"type\": \"Text\", \"widgetOptions\":\n            \"\"}],\n          [\"AddColumn\", \"foo\", \"create\", {\"type\": \"Text\", \"isFormula\": false, \"formula\": \"\"}],\n          [\"AddRecord\", \"_grist_Tables_column\", 4, { \"colId\": \"create\", \"formula\": \"\", \"isFormula\":\n            false, \"label\": \"create\", \"parentId\": 1, \"parentPos\": 4.0, \"type\": \"Text\", \"widgetOptions\":\n            \"\"}],\n          [\"AddColumn\", \"foo\", \"drop\", {\"type\": \"Text\", \"isFormula\": false, \"formula\": \"\"}],\n          [\"AddRecord\", \"_grist_Tables_column\", 5, { \"colId\": \"drop\", \"formula\": \"\", \"isFormula\":\n            false, \"label\": \"drop\", \"parentId\": 1, \"parentPos\": 5.0, \"type\": \"Text\", \"widgetOptions\":\n            \"\"}],\n          [\"AddColumn\", \"foo\", \"transaction\", {\"type\": \"Text\", \"isFormula\": false, \"formula\": \"\"}],\n          [\"AddRecord\", \"_grist_Tables_column\", 6, { \"colId\": \"transaction\", \"formula\": \"\", \"isFormula\":\n            false, \"label\": \"transaction\", \"parentId\": 1, \"parentPos\": 6.0, \"type\": \"Text\", \"widgetOptions\":\n            \"\"}],\n          [\"AddColumn\", \"foo\", \"table\", {\"type\": \"Text\", \"isFormula\": false, \"formula\": \"\"}],\n          [\"AddRecord\", \"_grist_Tables_column\", 7, { \"colId\": \"table\", \"formula\": \"\", \"isFormula\":\n            false, \"label\": \"table\", \"parentId\": 1, \"parentPos\": 7.0, \"type\": \"Text\", \"widgetOptions\":\n            \"\"}]\n        ],\n        \"direct\": [true, true, true, true, true, true, true, true, true, true, true, true],\n        \"undo\": [\n          [\"RemoveColumn\", \"foo\", \"on\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 2],\n          [\"RemoveColumn\", \"foo\", \"alter\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 3],\n          [\"RemoveColumn\", \"foo\", \"create\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 4],\n          [\"RemoveColumn\", \"foo\", \"drop\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 5],\n          [\"RemoveColumn\", \"foo\", \"transaction\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 6],\n          [\"RemoveColumn\", \"foo\", \"table\"],\n          [\"RemoveRecord\", \"_grist_Tables_column\", 7]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"simplest\",\n      \"foo\": [\n        [\"id\", \"bar\",   \"on\", \"alter\", \"create\", \"drop\", \"transaction\", \"table\"],\n        [1,    \"apple\", \"\",   \"\",      \"\",       \"\",     \"\",            \"\"]\n      ]\n    }],\n\n    [\"APPLY\", {\n      \"USER_ACTIONS\": [\n        [\"RenameColumn\", \"foo\", \"on\", \"select\"],\n        [\"RenameColumn\", \"foo\", \"alter\", \"on\"],\n        [\"RenameColumn\", \"foo\", \"create\", \"alter\"],\n        [\"RenameColumn\", \"foo\", \"drop\", \"create\"],\n        [\"RenameColumn\", \"foo\", \"transaction\", \"drop\"],\n        [\"RenameColumn\", \"foo\", \"table\", \"transaction\"]\n      ],\n      \"ACTIONS\": {\n        \"stored\": [\n          [\"RenameColumn\", \"foo\", \"on\", \"select\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 2, {\"colId\": \"select\"}],\n          [\"RenameColumn\", \"foo\", \"alter\", \"on\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 3, {\"colId\": \"on\"}],\n          [\"RenameColumn\", \"foo\", \"create\", \"alter\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 4, {\"colId\": \"alter\"}],\n          [\"RenameColumn\", \"foo\", \"drop\", \"create\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"colId\": \"create\"}],\n          [\"RenameColumn\", \"foo\", \"transaction\", \"drop\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"colId\": \"drop\"}],\n          [\"RenameColumn\", \"foo\", \"table\", \"transaction\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 7, {\"colId\": \"transaction\"}]\n        ],\n        \"direct\": [true, true, true, true, true, true, true, true, true, true, true, true],\n        \"undo\": [\n          [\"RenameColumn\", \"foo\", \"select\", \"on\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 2, {\"colId\": \"on\"}],\n          [\"RenameColumn\", \"foo\", \"on\", \"alter\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 3, {\"colId\": \"alter\"}],\n          [\"RenameColumn\", \"foo\", \"alter\", \"create\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 4, {\"colId\": \"create\"}],\n          [\"RenameColumn\", \"foo\", \"create\", \"drop\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 5, {\"colId\": \"drop\"}],\n          [\"RenameColumn\", \"foo\", \"drop\", \"transaction\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 6, {\"colId\": \"transaction\"}],\n          [\"RenameColumn\", \"foo\", \"transaction\", \"table\"],\n          [\"UpdateRecord\", \"_grist_Tables_column\", 7, {\"colId\": \"table\"}]\n        ]\n      }\n    }],\n\n    [\"CHECK_OUTPUT\", {\n      \"USE_SAMPLE\": \"simplest\",\n      \"foo\": [\n        [\"id\", \"bar\",   \"select\", \"on\", \"alter\", \"create\", \"drop\", \"transaction\"],\n        [1,    \"apple\", \"\",       \"\",   \"\",      \"\",       \"\",     \"\"]\n      ]\n    }]\n\n  ]\n\n}]\n"
  },
  {
    "path": "sandbox/grist/testutil.py",
    "content": "import json\nimport math\nimport os\nimport re\n\nimport actions\n\ndef table_data_from_rows(table_id, col_names, rows):\n  \"\"\"\n  Returns a TableData object built from a table_id, a list of column names, and corresponding\n  row-oriented data.\n  \"\"\"\n  column_values = {}\n  for i, col in enumerate(col_names):\n    # Strip leading @ from column headers\n    column_values[col.lstrip('@')] = [row[i] for row in rows]\n  return actions.TableData(table_id, column_values.pop('id'), column_values)\n\n\ndef table_data_from_row_dicts(table_id, row_dict_list):\n  \"\"\"\n  Returns a TableData object built from table_id and a list of dictionaries, one per row, mapping\n  column names to cell values.\n  \"\"\"\n  col_ids = {'id': None}    # Collect the set of col_ids. Use a dict for predictable order.\n  for row in row_dict_list:\n    col_ids.update({c: None for c in row})\n  column_values = {col: [row.get(col) for row in row_dict_list] for col in col_ids}\n  return actions.TableData(table_id, column_values.pop('id'), column_values)\n\n\ndef parse_testscript(script_path=None):\n  \"\"\"\n  Parses JSON spec for test cases, and returns a tuple of (samples, test_cases). Lines starting\n  with '//' are comments and are skipped.\n\n  Samples are objects with keys \"SCHEMA\" and \"DATA\", each a dictionary mapping table name to\n  actions.TableData object. \"SCHEMA\" contains \"_grist_Tables\" and \"_grist_Tables_column\" tables.\n\n  Test cases are a list of objects with \"TEST_CASE\" and \"BODY\", and the body is a list of steps of\n  the form [line_number, step_name, data], with line_number being an addition by this parser (or\n  None if not available).\n  \"\"\"\n  if not script_path:\n    script_path = os.path.join(os.path.dirname(__file__), \"testscript.json\")\n\n  comment_re = re.compile(r'^\\s*//')\n  add_line_no_re = re.compile(r'\"(APPLY|CHECK_OUTPUT|LOAD_SAMPLE)\"\\s*,')\n  all_lines = []\n  with open(script_path, \"r\") as testfile:\n    for i, line in enumerate(testfile):\n      if comment_re.match(line):\n        all_lines.append(\"\\n\")\n      else:\n        line = add_line_no_re.sub(r'\"\\1@%s\",' % (i + 1), line)\n        all_lines.append(line)\n  full_text = \"\".join(all_lines)\n\n  script = json.loads(full_text)\n\n  samples = {}\n  test_cases = []\n  for obj in script:\n    if \"TEST_CASE\" in obj:\n      body = []\n      for step, data in obj[\"BODY\"]:\n        step_line = step.split('@', 1)\n        step = step_line[0]\n        line = step_line[1] if len(step_line) > 1 else None\n        body.append([line, step, data])\n      obj[\"BODY\"] = body\n      test_cases.append(obj)\n    elif \"SAMPLE_NAME\" in obj:\n      samples[obj[\"SAMPLE_NAME\"]] = parse_test_sample(obj, samples=samples)\n    else:\n      raise ValueError(\"Unrecognized object in test script: %s\" % obj)\n  return (samples, test_cases)\n\n\ndef parse_test_sample(obj, samples={}):\n  \"\"\"\n  Parses human-readable sample data (with \"SCHEMA\" or \"SCHEMA_FROM\", and \"DATA\" dictionaries; see\n  testscript.json for an example) into a sample containing \"SCHEMA\" and \"DATA\" keys, each a\n  dictionary mapping table name to TableData object.\n  \"\"\"\n  if \"SCHEMA_FROM\" in obj:\n    schema = samples[obj[\"SCHEMA_FROM\"]][\"SCHEMA\"].copy()\n  else:\n    raw_schema = obj[\"SCHEMA\"]\n    # Convert the meta tables to appropriate table representations for loading.\n    schema = {\n      '_grist_Tables': table_data_from_rows(\n        '_grist_Tables',\n        (\"id\", \"tableId\"),\n        [(table_row_id, table_id) for (table_row_id, table_id, _) in raw_schema]),\n      '_grist_Tables_column': table_data_from_rows(\n        '_grist_Tables_column',\n        (\"parentId\", \"parentPos\", \"id\", \"colId\", \"type\", \"isFormula\",\n         \"formula\", \"label\", \"widgetOptions\", \"recalcWhen\", \"recalcDeps\"),\n        [[table_row_id, i+1] + col_schema_row(*e) for (table_row_id, _, entries) in raw_schema\n         for (i, e) in enumerate(entries)])\n    }\n\n  data = {t: table_data_from_rows(t, data[0], data[1:])\n          for t, data in obj[\"DATA\"].items()}\n  return {\"SCHEMA\": schema, \"DATA\": data}\n\n\ndef col_schema_row(id_, colId, type_, isFormula, formula=\"\",\n                   label=\"\", widgetOptions=\"\", recalcWhen=0, recalcDeps=None):\n  \"\"\"\n  Helper to specify columns in test SCHEMA descriptions, to allow omitting some column properties.\n  \"\"\"\n  return [id_, colId, type_, isFormula, formula, label, widgetOptions, recalcWhen, recalcDeps]\n\n\ndef replace_nans(data):\n  \"\"\"\n  Convert all NaNs and Infinities in the data to descriptive strings, since they cannot be\n  serialized to JS-compliant JSON. (But we can serialize them using marshalling, so this\n  workaround is just for the testscript-based tests.)\n  \"\"\"\n  if isinstance(data, float) and (math.isnan(data) or math.isinf(data)):\n    return \"@+Infinity\" if data > 0 else \"@-Infinity\" if data < 0 else \"@NaN\"\n  return actions.convert_recursive_in_action(replace_nans, data)\n\n\ndef repeat_until_passes(count):\n  \"\"\"\n  Use as a decorator on test cases to repeat a failing test case up to count times, until it\n  passes. The resulting test cases will fail only if every repetition failed. This is suitable for\n  flaky timing test when unexpected load spikes could cause spurious failures.\n  \"\"\"\n  def decorator(f):\n    def wrapped(*args):\n      for i in range(0, count):\n        try:\n          f(*args)\n          return\n        except AssertionError as e:\n          pass\n      # Raises the last caught exception, even outside try/except (see\n      # https://stackoverflow.com/questions/25632147/raise-at-the-end-of-a-python-function-outside-try-or-except-block)\n      raise   # pylint: disable=misplaced-bare-raise\n    return wrapped\n  return decorator\n"
  },
  {
    "path": "sandbox/grist/textbuilder.py",
    "content": "\"\"\"\nThis module allows building text with transformations. It is used specifically for transforming\ncode, such as replacing \"$foo\" with \"rec.foo\" in formulas, and composing formulas into a full\nusercode module.\n\nThe importance of this module is in allowing to map back replacements (or patches) to output code,\nsuch as those generated to rename column references, into patches to the original inputs. It\nallows us to deal with the complete valid usercode module text when searching for renames.\n\"\"\"\nimport bisect\nimport re\nfrom collections import namedtuple\n\nPatch = namedtuple('Patch', ('start', 'end', 'old_text', 'new_text'))\n\nline_start_re = re.compile(r'^', re.M)\n\n\ndef make_patch(full_text, start, end, new_text):\n  \"\"\"\n  Returns a patch to `full_text` to replace `full_text[start:end]` with `new_text`.\n  \"\"\"\n  return Patch(start, end, full_text[start:end], new_text)\n\n\ndef make_regexp_patches(full_text, regexp, repl):\n  \"\"\"\n  Returns a list of patches to `full_text` to replace each occurrence of `regexp` with `repl`. If\n  repl is a function, will replace with `repl(match_object)`. If repl is a string, it is used\n  verbatim, without interpreting any special characters.\n  \"\"\"\n  repl_func = repl if callable(repl) else (lambda m: repl)\n  return [make_patch(full_text, m.start(0), m.end(0), repl_func(m))\n          for m in regexp.finditer(full_text)]\n\n\ndef validate_patch(text, patch):\n  \"\"\"\n  Ensures that the given patch fits the given text, raising ValueError if not.\n  \"\"\"\n  found = text[patch.start : patch.end]\n  if found != patch.old_text:\n    before = text[patch.start - 10 : patch.start]\n    after = text[patch.end : patch.end + 10]\n    raise ValueError(\"Invalid patch to '%s[%s]%s' at %s; expected '%s'\" % (\n      before, found, after, patch.start, patch.old_text))\n\n\nclass Builder(object):\n  \"\"\"\n  The base for classes that produce text and can map back a text patch to some useful value. A\n  series of Builders transforms text, and when we know what to change in the result, we use\n  map_back_patch() to get the source of the original `Text` object.\n  \"\"\"\n  def map_back_patch(self, patch):\n    \"\"\"\n    See Text.map_back_patch.\n    \"\"\"\n    raise NotImplementedError()\n\n  def get_text(self):\n    \"\"\"\n    Returns the output text of this Builder.\n    \"\"\"\n    raise NotImplementedError()\n\n\nclass Text(Builder):\n  \"\"\"\n  The lowest Builder that holds a simple string with an optional associated arbitrary value (e.g.\n  which column a formula came from). When we map back a patch of transformed text, we get a tuple\n  (text, value, patch) with text and value from the constructor, and patch that applies to text.\n  \"\"\"\n  def __init__(self, text, value=None):\n    self._text = text\n    self._value = value\n\n  def map_back_patch(self, patch):\n    \"\"\"\n    Returns the tuple (text, value, patch) with text and value from the constructor, and patch\n    that applies to text.\n    \"\"\"\n    assert self._text[patch.start:patch.end] == patch.old_text\n    return (self._text, self._value, patch)\n\n  def get_text(self):\n    return self._text\n\n\nclass Replacer(Builder):\n  \"\"\"\n  Builder that transforms an input Builder with some patches to produce output. It remembers\n  positions of replacements, so it can map patches of its output back to its input.\n  \"\"\"\n  def __init__(self, in_builder, patches):\n    self._in_builder = in_builder\n\n    # Two parallel lists of input and output offsets, with corresponding offsets at the same index\n    # in the two lists. Each list is ordered by offset.\n    self._input_offsets = [0]\n    self._output_offsets = [0]\n\n    out_parts = []\n    in_pos = 0\n    out_pos = 0\n    text = self._in_builder.get_text()\n    # Note that we have to go through patches in sorted order.\n    for in_patch in sorted(patches):\n      validate_patch(text, in_patch)\n      out_parts.append(text[in_pos:in_patch.start])\n      out_parts.append(in_patch.new_text)\n      out_pos += (in_patch.start - in_pos) + len(in_patch.new_text)\n      in_pos = in_patch.end\n      # If the replacement text is shorter or longer than the original, insert a new pair of\n      # offsets corresponding to the patch's end position in the input and output text.\n      if len(in_patch.new_text) != in_patch.end - in_patch.start:\n        self._input_offsets.append(in_pos)\n        self._output_offsets.append(out_pos)\n\n    out_parts.append(text[in_pos:])\n    self._output_text = ''.join(out_parts)\n\n  def get_text(self):\n    return self._output_text\n\n  def map_back_patch(self, patch):\n    validate_patch(self._output_text, patch)\n    in_start = self.get_input_pos(patch.start)\n    in_end = self.get_input_pos(patch.end)\n    in_patch = make_patch(self._in_builder.get_text(), in_start, in_end, patch.new_text)\n    return self._in_builder.map_back_patch(in_patch)\n\n  def get_input_pos(self, out_pos):\n    \"\"\"Returns the position in the input text corresponding to the given position in output.\"\"\"\n    index = bisect.bisect_right(self._output_offsets, out_pos) - 1\n    offset = out_pos - self._output_offsets[index]\n    return self._input_offsets[index] + offset\n\n  def map_back_offset(self, out_pos):\n    \"\"\"\n    Returns the position corresponding to out_pos in the original input, in case it was\n    processed by a series of Replacers.\n    \"\"\"\n    input_pos = self.get_input_pos(out_pos)\n    if isinstance(self._in_builder, Replacer):\n      return self._in_builder.map_back_offset(input_pos)\n    return input_pos\n\n\nclass Combiner(Builder):\n  \"\"\"\n  Combiner allows building output text from a sequence of other Builders. When a patch is mapped\n  back, it gets passed to the Builder it came from, and must not span more than one input Builder.\n  \"\"\"\n  def __init__(self, parts):\n    self._parts = parts\n    self._offsets = []\n    text_parts = [\n      (p if isinstance(p, str) else\n       p.decode('utf8') if isinstance(p, bytes) else\n       p.get_text())\n      for p in self._parts]\n    self._text = ''.join(text_parts)\n\n    offset = 0\n    self._offsets = []\n    for t in text_parts:\n      self._offsets.append(offset)\n      offset += len(t)\n\n  def get_text(self):\n    return self._text\n\n  def map_back_patch(self, patch):\n    validate_patch(self._text, patch)\n    start_index = bisect.bisect_right(self._offsets, patch.start)\n    end_index = bisect.bisect_right(self._offsets, patch.end - 1)\n    if start_index <= 0 or end_index <= 0 or start_index != end_index:\n      raise ValueError(\"Invalid patch to Combiner: %s\" % (patch,))\n    offset = self._offsets[start_index - 1]\n    part = self._parts[start_index - 1]\n    in_patch = Patch(patch.start - offset, patch.end - offset, patch.old_text, patch.new_text)\n    return None if isinstance(part, str) else part.map_back_patch(in_patch)\n"
  },
  {
    "path": "sandbox/grist/timing.py",
    "content": "import contextlib\nimport time\n\n\nclass Timing(object):\n  def __init__(self):\n    self._items = {}\n    self._marks_stack = []\n\n  @contextlib.contextmanager\n  def measure(self, key):\n    start = time.time()\n    stack_start_len = len(self._marks_stack)\n    try:\n      yield\n    finally:\n      end = time.time()\n      self._record_time(key, end - start)\n\n      # Handle the marks added while in this invocation.\n      n = len(self._marks_stack) - stack_start_len\n      if n > 0:\n        next_mark = (\"end\", end)\n        while n > 0:\n          mark = self._marks_stack.pop()\n          self._record_time(\"{}@{}={}:{}\".format(key, n, mark[0], next_mark[0]),\n              next_mark[1] - mark[1])\n          next_mark = mark\n          n -= 1\n        self._record_time(\"{}@{}={}:{}\".format(key, n, \"start\", next_mark[0]), next_mark[1] - start)\n\n  def mark(self, mark_name):\n    self._marks_stack.append((mark_name, time.time()))\n\n  def get(self, clear = True):\n    # Copy it and clear immediately if requested.\n    timing_log = self._items.copy()\n    if clear:\n      self.clear()\n    # Stats will contain a json like structure with table_id, col_id, sum, count, average, max\n    # and optionally a array of marks (in similar format)\n    stats = []\n    for key, t in sorted(timing_log.items(), key=lambda x: str(x[0])):\n      # Key can be either a node (tuple with table_id and col_id) or a string with a mark.\n      # The list is sorted so, we always first get the stats for the node and then the marks.\n      # We will add marks to the last node.\n      if isinstance(key, tuple):\n        stats.append({\"tableId\": key[0], \"colId\": key[1], \"sum\": t.sum, \"count\": t.count,\n                      \"average\": t.average, \"max\": t.max})\n      else:\n        # Create a marks array for the last node or append to the existing one.\n        if stats:\n          prev = stats[-1].get(\"marks\", [])\n          stats[-1][\"marks\"] = prev + [{\n            \"name\": key, \"sum\": t.sum,\n            \"count\": t.count, \"average\": t.average,\n            \"max\": t.max\n          }]\n    return stats\n\n  def dump(self):\n    out = []\n    for key, t in sorted(self._items.items(), key=lambda x: str(x[0])):\n      out.append(\"%6d, %10f, %10f, %10f, %s\" % (t.count, t.average, t.max, t.sum, key))\n    print(\"Timing\\n\" + \"\\n\".join(out))\n    self.clear()\n\n  def _record_time(self, key, time_sec):\n    t = self._items.get(key)\n    if not t:\n      t = self._items[key] = TimingStats()\n    t.add(time_sec)\n\n  def clear(self):\n    self._items.clear()\n\n\n\n# An implementation that adds minimal overhead.\nclass DummyTiming(object):\n  # pylint: disable=no-self-use,unused-argument,no-member\n  def measure(self, key):\n    return contextlib.nullcontext()\n\n  def mark(self, mark_name):\n    pass\n\n  def dump(self):\n    pass\n\n  def get(self, clear = True):\n    return []\n\n  def clear(self):\n    pass\n\n\nclass TimingStats(object):\n  def __init__(self):\n    self.count = 0\n    self.sum = 0\n    self.max = 0\n\n  @property\n  def average(self):\n    return self.sum / self.count if self.count > 0 else 0\n\n  def add(self, value):\n    self.count += 1\n    self.sum += value\n    if value > self.max:\n      self.max = value\n"
  },
  {
    "path": "sandbox/grist/treeview.py",
    "content": "\"\"\"\nGrist supports organizing a list of records as a tree view which allows for grouping records as\nchildren of some other record.\n\nOn the client, the .indentation is used to measure the distance between the left margin of the\ncontainer and where we want the record to be. The variation of .indentation gives the parent-child\nrelationship between consecutive records. For instance in [\"A0\", \"B1\", \"C1\"] (where \"A0\" stands for\nthe record {'id': \"A\", 'indentation': 0}), \"B\" and \"C\" are children of \"A\". In [\"A0\", \"B1\", \"C2\"],\n\"C\" is a child of \"B\", which is a child of \"A\".\n\nThe order for the records is typically handled using a field of type \"PositionNumber\", ie: .pagePos\nin _grist_Pages table.\n\nBecause user can remove records that invalidate the tree, the module exposes fix_indents. For\nexample if user removes \"C\" from [\"A0\", \"B1\", \"C0\", \"D1\"] the resulting table holds [\"A0\", \"B1\",\n\"D1\"] and \"D\" became child of \"A\", which is unfortunate because we'd rather have \"C\" become a\nsibling of \"A\" instead. Using fix_indents helps with keeping the tree consistent by returning [(\"D\",\n0)] which indicate that the indentation of row \"D\" needs to be set to 0.\n\"\"\"\n\n# Items is an array of items with .id and .indentation properties. Returns a list of (item_id,\n# new_indent) pairs.\ndef fix_indents(items, deleted_ids):\n  max_next_indent = 0\n  adjustments = []\n  for item in items:\n    indent = min(max_next_indent, item.indentation)\n    is_deleted = item.id in deleted_ids\n    if indent != item.indentation and not is_deleted:\n      adjustments.append((item.id, indent))\n    max_next_indent = indent if is_deleted else indent + 1\n  return adjustments\n"
  },
  {
    "path": "sandbox/grist/trigger_expression.py",
    "content": "import json\nimport logging\n\nfrom predicate_formula import NamedEntity, parse_predicate_formula, TreeConverter\nimport predicate_formula\n\nlog = logging.getLogger(__name__)\n\nclass _TriggerEntityCollector(TreeConverter):\n  \"\"\"Collects entities (column references) in trigger condition formulas.\"\"\"\n  def __init__(self):\n    self.entities = []\n\n  def visit_Attribute(self, node):\n    parent = self.visit(node.value)\n\n    if parent in ([\"Name\", \"rec\"], [\"Name\", \"oldRec\"]):\n      self.entities.append(NamedEntity(\"recCol\", node.last_token.startpos, node.attr, None))\n\n    return [\"Attr\", parent, node.attr]\n\n\ndef perform_trigger_condition_renames(useractions, renames):\n  \"\"\"\n  Given a dict of column renames of the form {(table_id, col_id): new_col_id}, applies updates\n  to the affected trigger condition formulas.\n  \"\"\"\n  updates = []\n\n  for trigger in useractions.get_docmodel().triggers.all:\n    if not trigger.condition:\n      continue\n\n    # Parse the condition JSON\n    try:\n      condition_data = json.loads(trigger.condition)\n      if not isinstance(condition_data, dict) or 'text' not in condition_data:\n        continue\n      condition_formula = condition_data['text']\n    except (ValueError, KeyError, TypeError):\n      continue\n\n    def renamer(subject, table_id=trigger.tableRef.tableId):\n      # subject.type is recCol, see _TriggerEntityCollector\n      return renames.get((table_id, subject.name))\n\n    new_condition_formula = predicate_formula.process_renames(\n      condition_formula, _TriggerEntityCollector(), renamer)\n\n    # Only update if the formula actually changed\n    if new_condition_formula != condition_formula:\n      condition_data['text'] = new_condition_formula\n      condition_data['parsed'] = parse_predicate_formula(new_condition_formula)\n      updates.append((trigger, {\"condition\": json.dumps(condition_data)}))\n\n  # Update the trigger conditions in the database\n  useractions.doBulkUpdateFromPairs('_grist_Triggers', updates)\n\n\ndef parse_conditions_in_triggers(col_values):\n  \"\"\"\n  Parses any unparsed expressions in trigger `condition` column in `col_values`.\n  \"\"\"\n  if 'condition' not in col_values:\n    return\n\n  col_values['condition'] = [parse_trigger_condition(condition)\n                              for condition in col_values['condition']]\n\n\ndef parse_trigger_condition(condition_str):\n  \"\"\"\n  Parses a trigger condition JSON or text if not already parsed. Returns the updated\n  JSON string. If parsing fails or the input is not a valid JSON object, returns\n  the original string.\n\n  If `parsed` is already set, parsing is skipped (as an optimization). Clients are\n  responsible for including just `text` when creating new (or updating\n  existing) conditions.\n  \"\"\"\n\n  # Quick exit for empty / null conditions\n  if not condition_str:\n    return None\n\n  if not isinstance(condition_str, str):\n    # If it's not a string, we don't know how to handle it, so we return it as is.\n    return condition_str\n\n  (is_json, condition_json) = safe_parse(condition_str)\n\n  if is_json and not isinstance(condition_json, dict):\n    # We have some json but it is not a dict (a json object), we don't know how\n    # to handle it, so we return the original string.\n    return condition_str\n  elif not is_json:\n    # We have a string, but it doesn't look like a json object, we assume that it is\n    # a raw formula string, so we wrap it in the expected JSON format.\n    condition_json = {'text': condition_str}\n\n  assert condition_json\n\n  text = condition_json.get('text', '')\n\n  if not text:\n    # If text is explicitly cleared (e.g. by user action), we are removing the condition.\n    return None\n\n  # As in other cases (in dropdowns and acl), if parsed is set we skip\n  # parsing, as an optimization.\n  if 'parsed' in condition_json:\n    return condition_str\n\n  condition_json['parsed'] = parse_predicate_formula(text)\n  return json.dumps(condition_json)\n\n\ndef safe_parse(json_str):\n  \"\"\"\n  Safely parses a JSON string, returning a tuple (success, result). If parsing is\n  successful, success is True and result is the parsed JSON.\n  \"\"\"\n  try:\n    return (True, json.loads(json_str))\n  except (TypeError, ValueError):\n    return (False, None)\n"
  },
  {
    "path": "sandbox/grist/twowaymap.py",
    "content": "\"\"\"\nTwoWayMap implements mapping from keys to values, and values back to keys. Since keys and values\nare not really different here, they are referred to throughout as 'left' and 'right' values.\n\nTwoWayMap supports different types of containers when one value maps to multiple. You may add\nsupport for additional container types using register_container() module function.\n\nIt's implemented using Python dictionaries, so both 'left' and 'right' values must be hashable.\n\nFor example, to create a dictionary-like structure mapping one key to one value, and which allows\nto quickly tell the set of keys that map to a given value, we can use m=TwoWayMap(left=set,\nright=\"single\"). Then m.insert(key, value) sets the given key to the given value (overwriting the\nvalue previously set, since the \"right\" dataset is \"single\" values), m.lookup_left(key) returns\nthat value, and m.lookup_right(value) returns a `set` of keys that map to the value.\n\"\"\"\n\n# Special sentinel value which can never be legitimately stored in TwoWayMap, to easily tell the\n# difference between a present and absent value.\n_NIL = object()\n\n\nclass TwoWayMap(object):\n  def __init__(self, left=set, right=set):\n    \"\"\"\n    Create a new TwoWayMap. The `left` and `right` parameters determine the type of bin for\n    storing multiple values on the respective side of the map. E.g. if right=set, then\n    lookup_left() will return a set (what's on the right side). Supported values are:\n      set:      a set of values.\n      list:     a list of values, with new items added at the end of the list.\n      \"single\": a single value, new items overwrite previous ones.\n      \"strict\": a single value, new items must not overwrite previous ones.\n\n      To add support for another bin type, use twowaymap.register_container().\n\n    E.g. for TwoWayMap(left=\"single\", right=\"strict\"),\n      after insert(1, \"a\"), insert(1, \"b\") will succeed, but insert(2, \"a\") will fail.\n\n    E.g. for TwoWayMap(left=list, right=\"single\"),\n      after insert(1, \"a\"), insert(1, \"b\"), insert(2, \"a\"),\n      lookup_left(1) will return [\"a\", \"b\"], and lookup_right(\"a\") will return 2.\n  \"\"\"\n    self._left_bin = _mapper_types[left]\n    self._right_bin = _mapper_types[right]\n    self._fwd = {}\n    self._bwd = {}\n\n  def __nonzero__(self):\n    return bool(self._fwd)\n\n  __bool__ = __nonzero__\n\n  def lookup_left(self, left, default=None):\n    \"\"\" Returns the value(s) on the right corresponding to the given value on the left. \"\"\"\n    return self._fwd.get(left, default)\n\n  def lookup_right(self, right, default=None):\n    \"\"\" Returns the value(s) on the left corresponding to the given value on the right. \"\"\"\n    return self._bwd.get(right, default)\n\n  def count_left(self):\n    \"\"\" Returns the count of unique values on the left.\"\"\"\n    return len(self._fwd)\n\n  def count_right(self):\n    \"\"\" Returns the count of unique values on the right.\"\"\"\n    return len(self._bwd)\n\n  def left_all(self):\n    \"\"\" Returns an iterable over all values on the left.\"\"\"\n    return self._fwd.keys()\n\n  def right_all(self):\n    \"\"\" Returns an iterable over all values on the right.\"\"\"\n    return self._bwd.keys()\n\n  def insert(self, left, right):\n    \"\"\" Insert the (left, right) value pair. \"\"\"\n    # The tricky thing here is to keep the two maps consistent if an update to the second one\n    # raises an exception. To handle it, add_item must return what got added and removed, so that\n    # we can restore things after an exception. An exception could be caused by a \"strict\" bin\n    # type, or by using an un-hashable key (on either left or right side), or by using a custom\n    # container that can throw.\n    right_removed, right_added = self._right_bin.add_item(self._fwd, left, right)\n    try:\n      left_removed, _ = self._left_bin.add_item(self._bwd, right, left)\n    except:\n      # _left_bin is responsible to stay unchanged if there was an exception. Now we need to bring\n      # _right_bin back in sync with _left_bin.\n      if right_added is not _NIL:\n        self._right_bin.remove_item(self._fwd, left, right_added)\n      if right_removed is not _NIL:\n        self._right_bin.add_item(self._fwd, left, right_removed)\n      raise\n\n    # It's possible for add_item to overwrite elements, in which case we need to remove the\n    # other side of the mapping for the removed element.\n    if right_removed is not _NIL:\n      self._left_bin.remove_item(self._bwd, right_removed, left)\n    if left_removed is not _NIL:\n      self._right_bin.remove_item(self._fwd, left_removed, right)\n\n  def remove(self, left, right):\n    \"\"\" Remove the (left, right) value pair. \"\"\"\n    self._right_bin.remove_item(self._fwd, left, right)\n    self._left_bin.remove_item(self._bwd, right, left)\n\n  def remove_left(self, left):\n    \"\"\" Remove all values on the right corresponding to the given value on the left. \"\"\"\n    right_removed = self._right_bin.remove_key(self._fwd, left)\n    for x in right_removed:\n      self._left_bin.remove_item(self._bwd, x, left)\n\n  def remove_right(self, right):\n    \"\"\" Remove all values on the left corresponding to the given value on the right. \"\"\"\n    left_removed = self._left_bin.remove_key(self._bwd, right)\n    for x in left_removed:\n      self._right_bin.remove_item(self._fwd, x, right)\n\n  def clear(self):\n    \"\"\" Clear the entire map. \"\"\"\n    self._fwd.clear()\n    self._bwd.clear()\n\n#----------------------------------------------------------------------\n# The private classes below implement the different container types.\n\nclass _BaseBinType(object):\n  \"\"\" Base class for other BinTypes. \"\"\"\n  def add_item(self, mapping, key, value):\n    pass\n  def remove_item(self, mapping, key, value):\n    pass\n  def remove_key(self, mapping, key):\n    pass\n\n\nclass _SingleValueBin(_BaseBinType):\n  \"\"\" Bin that contains a single value, with new values overwriting previous ones.\"\"\"\n  def add_item(self, mapping, key, value):\n    stored = mapping.get(key, _NIL)\n    mapping[key] = value\n    if stored is _NIL:\n      return _NIL, value\n    elif stored == value:\n      return _NIL, _NIL\n    else:\n      return stored, value\n\n  def remove_item(self, mapping, key, value):\n    stored = mapping.get(key, _NIL)\n    if stored == value:\n      del mapping[key]\n\n  def remove_key(self, mapping, key):\n    stored = mapping.pop(key, _NIL)\n    return () if stored is _NIL else (stored,)\n\n\nclass _SingleValueStrictBin(_SingleValueBin):\n  \"\"\" Bin that contains a single value, overwriting which raises ValueError.\"\"\"\n  def add_item(self, mapping, key, value):\n    stored = mapping.get(key, _NIL)\n    if stored is _NIL:\n      mapping[key] = value\n      return _NIL, value\n    elif stored == value:\n      return _NIL, _NIL\n    else:\n      raise ValueError(\"twowaymap: one-to-one map violation for key %s\" % key)\n\n\nclass _ContainerBin(_BaseBinType):\n  \"\"\"\n  Bin that contains a container of values managed by the passed-in functions. See\n  register_container() for documentation of the arguments.\n  \"\"\"\n  def __init__(self, make_func, add_func, remove_func):\n    self.make = make_func\n    self.add = add_func\n    self.remove = remove_func\n\n  def add_item(self, mapping, key, value):\n    stored = mapping.get(key, _NIL)\n    if stored is _NIL:\n      mapping[key] = self.make(value)\n      return _NIL, value\n    else:\n      return _NIL, (value if self.add(stored, value) else _NIL)\n\n  def remove_item(self, mapping, key, value):\n    stored = mapping.get(key, _NIL)\n    if stored is not _NIL:\n      self.remove(stored, value)\n      if not stored:\n        del mapping[key]\n\n  def remove_key(self, mapping, key):\n    return mapping.pop(key, ())\n\n#----------------------------------------------------------------------\n\n_mapper_types = {\n  'single': _SingleValueBin(),\n  'strict': _SingleValueStrictBin(),\n}\n\ndef register_container(cls, make_func, add_func, remove_func):\n  \"\"\"\n  Register another container type. The first argument can be the container's class object, but\n  really can be any hashable value, which you can then give as an argument to left= or right=\n  arguments when constructing a TwoWayMap. The other arguments are:\n\n    make_func(value) - must return a new instance of the container with a single value.\n        This container must support iteration through values, and in boolean context must\n        evaluate to whether it's non-empty.\n\n    add_func(container, value) - must add value to container, only if it's not already there,\n        and return True if the value was added, False if it was already there.\n\n    remove_func(container, value) - must remove value from container if present.\n        This must never raise an exception, since that could leave the map in inconsistent state.\n  \"\"\"\n  _mapper_types[cls] = _ContainerBin(make_func, add_func, remove_func)\n\n\n# Allow `set` to be used as a bin type.\ndef _set_make(value):\n  return {value}\ndef _set_add(container, value):\n  if value not in container:\n    container.add(value)\n    return True\n  return False\ndef _set_remove(container, value):\n  container.discard(value)\n\nregister_container(set, _set_make, _set_add, _set_remove)\n\n# A version of `set` that maintains also sorted versions of the set. Used in lookups, to cache the\n# sorted lookup results.\nclass LookupSet(set):\n  def __init__(self, iterable=[]):\n    super(LookupSet, self).__init__(list(iterable))\n    self.sorted_versions = {}\n\ndef _LookupSet_make(value):\n  return LookupSet([value])\ndef _LookupSet_add(container, value):\n  if value not in container:\n    container.add(value)\n    container.sorted_versions.clear()\n    return True\n  return False\ndef _LookupSet_remove(container, value):\n  if value in container:\n    container.discard(value)\n    container.sorted_versions.clear()\n\nregister_container(LookupSet, _LookupSet_make, _LookupSet_add, _LookupSet_remove)\n\n\n# Allow `list` to be used as a bin type.\ndef _list_make(value):\n  return [value]\ndef _list_add(container, value):\n  if value not in container:\n    container.append(value)\n    return True\n  return False\ndef _list_remove(container, value):\n  try:\n    container.remove(value)\n  except ValueError:\n    pass\n\nregister_container(list, _list_make, _list_add, _list_remove)\n"
  },
  {
    "path": "sandbox/grist/urllib_patch.py",
    "content": "import urllib\nimport urllib.parse\n\noriginal_quote = urllib.parse.quote\n\ndef patched_quote(s, safe='/'):\n  if isinstance(s, str):\n    s = s.encode('utf8')\n  result = original_quote(s, safe=safe)\n  if isinstance(result, bytes):\n    result = result.decode('utf8')\n  return result\n\nurllib.quote = patched_quote\n"
  },
  {
    "path": "sandbox/grist/user.py",
    "content": "\"\"\"\nThis module contains a class for creating a User containing\nbasic user info and optional, user-defined attributes that reference\nuser attribute tables.\n\nA User has the same API as the 'user' variable from\naccess rules. Currently, its primary purpose is to expose\nuser info to trigger formulas, so that they can reference info\nabout the current user.\n\nThe 'data' parameter represents a dictionary containing at least\nthe following fields:\n\n - Access: string or None\n - UserID: integer or None\n - UserRef: string or None\n - Email: string or None\n - Name: string or None\n - Origin: string or None\n - LinkKey: dictionary\n - SessionID: string or None\n - IsLoggedIn: boolean\n - ShareRef: integer or None\n\nAdditional keys may be included, which may have a value that is\neither None or of type tuple with the following shape:\n\n [table_id, row_id]\n\nThe first element is the id (name) of the user attribute table, and the\nsecond element is the id of the row that matched based on the\nuser attribute definition.\n\nSee 'GranularAccess.ts' for the Node equivalent that\nserializes the user information found in 'data'.\n\"\"\"\n\nclass User(object):\n  \"\"\"\n  User containing user info and optional attributes.\n\n  Setting 'is_sample' will substitute user attributes with\n  typed equivalents, for use by autocompletion.\n  \"\"\"\n  def __init__(self, data, tables, is_sample=False):\n    for attr in ('Access', 'UserID', 'Email', 'Name', 'Origin', 'SessionID',\n                 'IsLoggedIn', 'UserRef', 'ShareRef'):\n      setattr(self, attr, data[attr])\n\n    self.LinkKey = LinkKey(data['LinkKey'])\n\n    for name, value in data.items():\n      if hasattr(self, name) or not value:\n        continue\n      table_name, row_id = value\n      table = tables.get(table_name)\n      if not table:\n        continue\n      # TODO: Investigate use of __dir__ in Record for type information\n      record = table.sample_record if is_sample else table.get_record(row_id)\n      setattr(self, name, record)\n\nclass LinkKey(object):\n  def __init__(self, data):\n    for name, value in data.items():\n      setattr(self, name, value)\n"
  },
  {
    "path": "sandbox/grist/useractions.py",
    "content": "# pylint: disable=too-many-lines\nfrom collections import namedtuple, Counter, OrderedDict\nimport re\nimport json\nimport logging\nimport sys\nimport time\nfrom contextlib import contextmanager\n\nimport acl\nfrom acl import parse_acl_formulas\nimport depend\nimport gencode\nfrom dropdown_condition import parse_dropdown_conditions\nimport dropdown_condition\nfrom trigger_expression import parse_conditions_in_triggers\nfrom trigger_expression import perform_trigger_condition_renames\nimport actions\nimport column\nimport sort_specs\nimport identifiers\nfrom objtypes import strict_equal, encode_object\nfrom reverse_references import check_desired_reverse_col, pick_reverse_col_label\nimport schema\nfrom schema import RecalcWhen\nimport summary\nimport import_actions\nimport textbuilder\nimport usertypes\nfrom usertypes import get_referenced_table_id, get_pure_type, is_compatible_ref_type\nimport treeview\n\nfrom table import get_validation_func_name\n\nlog = logging.getLogger(__name__)\n\n\n_current_module = sys.modules[__name__]\n_action_types = {}\n\n# When distinguishing actions directly requested by the user from actions that\n# are indirect consequences of those actions (specifically, adding rows to summary tables)\n# we count levels of indirection. Zero indirection levels implies an action is directly\n# requested.\nDIRECT_ACTION = 0\n\n# Fields of _grist_Tables_column table that can't be modified using ModifyColumn useraction.\n_unmodifiable_col_fields = {'colId', 'id', 'parentId'}\n# Fields of _grist_Tables_column table that are inherited by group-by columns from their source.\n_inherited_groupby_col_fields = {'colId', 'widgetOptions', 'label', 'untieColIdFromLabel'}\n\n# Fields of _grist_Tables_column table that are inherited by summary formula columns from source.\n_inherited_summary_col_fields = {'colId', 'label'}\n\n# Schema properties that can be modified using ModifyColumn docaction.\n_modify_col_schema_props = {'type', 'formula', 'isFormula', 'reverseColId'}\n\n\n# A few generic helpers.\ndef select_keys(dict_obj, keys):\n  \"\"\"Return copy of dict_obj containing only the given keys.\"\"\"\n  return {k: v for k, v in dict_obj.items() if k in keys}\n\ndef has_value(dict_obj, key, value):\n  \"\"\"Returns True if dict_obj contains key, and its value is value.\"\"\"\n  return key in dict_obj and dict_obj[key] == value\n\ndef has_diff_value(dict_obj, key, value):\n  \"\"\"Returns True if dict_obj contains key, and its value is something other than value.\"\"\"\n  return key in dict_obj and dict_obj[key] != value\n\ndef make_bulk_values_dict(record_values_pairs):\n  \"\"\"\n  Given a list of (record, values_dict) pairs, returns a single dict with a union of the keys of\n  all values_dicts, mapping each key to the array of values parallel to records. The output is the\n  kind of dict required for BulkUpdateRecord/BulkAddRecord actions.\n  Missing values are filled in with corresponding attributes from the original records.\n  \"\"\"\n  all_keys = {key for (rec, values) in record_values_pairs for key in values}\n  return {\n    # Whenever we are missing a value, use the original value from the col record.\n    key: [values.get(key, getattr(rec, key)) for (rec, values) in record_values_pairs]\n    for key in all_keys\n  }\n\n\ndef is_hidden_table(table_id):\n  return table_id.startswith('GristHidden_')\n\n\ndef is_user_table(table_id):\n  return not (is_hidden_table(table_id) or gencode._is_special_table(table_id))\n\n\ndef useraction(method):\n  \"\"\"\n  Decorator for a method, which creates an action class with the same name and arguments.\n  \"\"\"\n  code = method.__code__\n  name = method.__name__\n  cls = namedtuple(name, code.co_varnames[1:code.co_argcount])\n  setattr(_current_module, name, cls)\n  _action_types[name] = cls\n  return method\n\n\n# UserActions that require special handling for different tables can have table-specific special\n# implementations defined in methods decorated with `override_action(action_name, table_id)`.\n# These get stored in _action_method_overrides map, with (action_name, table_id) as key.\n_action_method_overrides = {}\ndef override_action(action_name, table_id):\n  def do_wrap(method):\n    _action_method_overrides[(action_name, table_id)] = method\n    return method\n  return do_wrap\n\n\ndef from_repr(user_action):\n  \"\"\"\n  Converts a UserAction array into an object such as UpdateRecord.\n  \"\"\"\n  action_type = _action_types.get(user_action[0])\n  if not action_type:\n    raise ValueError('Unknown action %s' % user_action[0])\n  try:\n    return action_type(*user_action[1:])\n  except TypeError as e:\n    raise TypeError(\"%s: %s\" % (user_action[0], str(e)))\n\ndef _make_clean_col_info(col_info, col_id=None):\n  \"\"\"\n  Fills in missing fields in a col_info object of AddColumn or AddTable user actions.\n  \"\"\"\n  is_formula = col_info.get('isFormula', True)\n  ret = {\n    'isFormula': is_formula,\n    # A formula column should default to type 'Any'.\n    'type': col_info.get('type', 'Any' if is_formula else 'Text'),\n    'formula': col_info.get('formula', '')\n  }\n  if col_id:\n    ret['id'] = col_id\n  return ret\n\n\ndef guess_col_info(values, doc_model):\n  \"\"\"\n  Returns a pair col_info, values\n  where col_info is a dict which may contain a type and widgetOptions\n  and `values` is similar to the given argument but maybe converted to the guessed type.\n  \"\"\"\n  # If the values are all strings/None...\n  if set(map(type, values)) <= {str, type(None)}:\n    # If the values are all blank (None or empty string) leave the column empty\n    if not any(values):\n      return {}, [None] * len(values)\n\n    # Use the exported guessColInfo if we're connected to JS\n    from sandbox import default_sandbox\n    if default_sandbox:\n      doc_info = doc_model.doc_info.lookupOne()\n      try:\n        doc_settings = json.loads(doc_info.documentSettings)\n      except ValueError:\n        doc_settings = {}\n      guess = default_sandbox.call_external(\"guessColInfo\", values, doc_settings, doc_info.timezone)\n      # When the result doesn't contain `values`, that means the guessed type is Text\n      # so there was nothing to convert.\n      values = guess.get(\"values\", values)\n      col_info = guess[\"colInfo\"]\n      if \"widgetOptions\" in col_info:\n        col_info[\"widgetOptions\"] = json.dumps(col_info[\"widgetOptions\"])\n      return col_info, values\n\n  # Fallback to the older guessing method, particularly for pure python tests.\n  return {'type': guess_type(values, convert=True)}, values\n\n\ndef guess_type(values, convert=False):\n  \"\"\"\n  Returns a suitable type for the given iterable of values, optionally attempting conversions.\n  \"\"\"\n  # TODO: this should consider all possible types we support, and pick the most common one.\n  numeric = usertypes.Numeric()\n  counter = Counter(bool(numeric.is_right_type(numeric.convert(v) if convert else v))\n      for v in values if v not in ('', None))\n  total = sum(counter.values())\n  return \"Numeric\" if total and counter[True] >= total * 0.9 else \"Text\"\n\n\ndef allowed_summary_change(key, updated, original):\n  \"\"\"\n  Checks if summary group by column can be modified.\n  \"\"\"\n  allowed_fields_to_change = {'rules', 'description'}\n  if updated == original or key in allowed_fields_to_change:\n    return True\n  elif key == 'widgetOptions':\n    try:\n      updated_options = json.loads(updated or '{}')\n      original_options = json.loads(original or '{}')\n    except ValueError:\n      return False\n    # Can do anything, but those options must be the same\n    must_be_the_same = {'choices', 'choiceOptions'}\n    # Helper function to remove protected keys from dictionary.\n    def protected_options(options):\n      return {k: v for k, v in options.items() if k in must_be_the_same}\n    return protected_options(updated_options) == protected_options(original_options)\n  else:\n    return False\n\n\nclass UserActions(object):\n  def __init__(self, eng):\n    self._engine = eng\n    self._docmodel = eng.docmodel\n    self._summary = summary.SummaryActions(self, self._docmodel)\n    self._import_actions = import_actions.ImportActions(self, self._docmodel, eng)\n    self._allow_changes = False\n    self._indirection_level = DIRECT_ACTION\n\n    # Map of methods implementing particular (action_name, table_id) combinations. It mirrors\n    # global _action_method_overrides, but with methods *bound* to this UserActions instance.\n    self._overrides = {key: method.__get__(self, UserActions)\n                       for key, method in _action_method_overrides.items()}\n\n  def get_docmodel(self):\n    \"\"\"\n    Getter for the docmodel.\n    \"\"\"\n    return self._docmodel\n\n  @contextmanager\n  def indirect_actions(self):\n    \"\"\"\n    Usage:\n\n      with self.indirect_actions():\n        # apply actions here\n\n    This marks those actions as being indirect, for ACL purposes.\n    \"\"\"\n    try:\n      self._indirection_level += 1\n      yield\n    finally:\n      self._indirection_level -= 1\n      assert self._indirection_level >= 0\n\n  def _do_doc_action(self, action):\n    if hasattr(action, 'simplify'):\n      # Convert bulk actions to single actions if possible, or None if it affects no rows.\n      action = action.simplify()\n    if action:\n      self._engine.out_actions.stored.append(action)\n      self._engine.out_actions.direct.append(self._indirection_level == DIRECT_ACTION)\n      self._engine.apply_doc_action(action)\n\n  def _do_extra_doc_action(self, action):\n    # It this is Update, Add (or Bulks), run thouse actions through ensure_column_accepts_data\n    # to ensure that the data is valid.\n\n    converted_action = action\n\n    if isinstance(action, (actions.BulkAddRecord, actions.BulkUpdateRecord)):\n      if isinstance(action, actions.BulkAddRecord):\n        ActionType = actions.BulkAddRecord\n      else:\n        ActionType = actions.BulkUpdateRecord\n\n      # Iterate over every column and make sure it accepts data.\n      table_id, row_ids, column_values = action\n      for col_id, values in column_values.items():\n        column_values[col_id] = self._ensure_column_accepts_data(table_id, col_id, values)\n      converted_action = ActionType(table_id, row_ids, column_values)\n\n    return self._do_doc_action(converted_action)\n\n  def _bulk_action_iter(self, table_id, row_ids, col_values=None):\n    \"\"\"\n    Helper for processing Bulk actions, which generates a list of (i, record, value_dict) tuples,\n    one for each record, where value_dict maps keys to values for that particular record.\n    If col_values is None, generates a list of (i, record) pairs.\n    \"\"\"\n    table = self._engine.tables[table_id]\n    for i, row_id in enumerate(row_ids):\n      rec = table.get_record(row_id)\n      yield ((i, rec) if col_values is None else\n             (i, rec, {k: v[i] for k, v in col_values.items()}))\n\n  def _collect_back_references(self, table_recs):\n    \"\"\"\n    Return a list of columns records for Reference or ReferenceList columns that refer to any of\n    the passed-in tables.\n    \"\"\"\n    cols = []\n    for table_rec in table_recs:\n      table_obj = self._engine.tables[table_rec.tableId]\n      for col in table_obj._back_references:\n        if not col.is_private():\n          cols.extend(self._docmodel.columns.lookupRecords(tableId=col.table_id, colId=col.col_id))\n    cols.sort()\n    return cols\n\n  #----------------------------------------\n  # Special user actions.\n  #----------------------------------------\n\n  @useraction\n  def InitNewDoc(self):\n    creation_actions = schema.schema_create_actions()\n    self._engine.out_actions.stored.extend(creation_actions)\n    self._engine.out_actions.direct += [True] * len(creation_actions)\n    self._do_doc_action(actions.AddRecord(\"_grist_DocInfo\", 1,\n                                          {'schemaVersion': schema.SCHEMA_VERSION}))\n\n    # Set up initial ACL data.\n    # NOTE The special records below are not actually used. They were intended for obsolete ACL\n    # plans, and are kept here to ensure that old versions of Grist can still open newer or\n    # migrated documents. (At least as long as they don't actually include additional new ACL\n    # rules.)\n    self._do_doc_action(actions.BulkAddRecord(\"_grist_ACLPrincipals\", [1,2,3,4], {\n      'type':       ['group', 'group', 'group', 'group'],\n      'groupName':  ['Owners', 'Admins', 'Editors', 'Viewers'],\n    }))\n    self._do_doc_action(actions.AddRecord(\"_grist_ACLResources\", 1, {\n      'tableId': '',\n      'colIds': ''\n    }))\n    self._do_doc_action(actions.AddRecord(\"_grist_ACLRules\", 1, {\n      'resource': 1,\n      'permissions': acl.Permissions.OWNER,\n      'principals': '[1]'\n    }))\n\n  @useraction\n  def ApplyDocActions(self, doc_actions):\n    for doc_action in doc_actions:\n      self._do_doc_action(actions.action_from_repr(doc_action))\n\n  @useraction\n  def ApplyUndoActions(self, undo_actions):\n    for undo_action in reversed(undo_actions):\n      self._do_doc_action(actions.action_from_repr(undo_action))\n\n  @useraction\n  def Calculate(self):\n    \"\"\"\n    This is a dummy action whose only purpose is to trigger calculation\n    of any dirty cells.\n    \"\"\"\n    pass\n\n  @useraction\n  def UpdateCurrentTime(self):\n    \"\"\"\n    Somewhat similar to Calculate, trigger calculation\n    of any cells that depend on the current time.\n    \"\"\"\n    self._engine.update_current_time()\n\n  @useraction\n  def RespondToRequests(self, responses, cached_keys):\n    \"\"\"\n    Reevaluate formulas which called the REQUEST function using the now available responses.\n    \"\"\"\n    engine = self._engine\n\n    # The actual raw responses which will be returned to the REQUEST function\n    engine._request_responses = responses\n    # Keys for older requests which are stored in files and can be retrieved synchronously\n    engine._cached_request_keys = set(cached_keys)\n\n    # Invalidate the exact cells which made the exact requests which are being responded to here.\n    for response in responses.values():\n      for table_id, table_deps in response.pop(\"deps\").items():\n        for col_id, row_ids in table_deps.items():\n          node = depend.Node(table_id, col_id)\n          engine.dep_graph.invalidate_deps(node, row_ids, engine.recompute_map)\n\n  #----------------------------------------\n  # User actions on records.\n  #----------------------------------------\n\n  @useraction\n  def AddRecord(self, table_id, row_id, column_values):\n    return self.BulkAddRecord(\n      table_id, [row_id], {key: [val] for key, val in column_values.items()}\n    )[0]\n\n  @useraction\n  def BulkAddRecord(self, table_id, row_ids, column_values):\n    column_values = actions.decode_bulk_values(column_values)\n    for col_id, values in column_values.items():\n      column_values[col_id] = self._ensure_column_accepts_data(table_id, col_id, values)\n    method = self._overrides.get(('BulkAddRecord', table_id), self.doBulkAddOrReplace)\n    return method(table_id, row_ids, column_values)\n\n  @useraction\n  def ReplaceTableData(self, table_id, row_ids, column_values):\n    column_values = actions.decode_bulk_values(column_values)\n    # There doesn't seem any need to return the big array of ids.\n    self.doBulkAddOrReplace(table_id, row_ids, column_values, replace=True)\n\n  def doBulkAddOrReplace(self, table_id, row_ids, column_values, replace=False):\n    table = self._engine.tables[table_id]\n    next_row_id = 1 if replace else table.next_row_id()\n\n    # Make a copy of row_ids and fill in those set to None.\n    filled_row_ids = row_ids[:]\n    for i, row_id in enumerate(filled_row_ids):\n      if row_id is None or row_id < 0:\n        filled_row_ids[i] = row_id = next_row_id\n      elif row_id > 1000000:\n        raise ValueError(\"Row ID too high\")\n      next_row_id = max(next_row_id, row_id) + 1\n\n    # Whenever we add new rows, remember the mapping from any negative row_ids to their final\n    # values. This allows the negative_row_ids to be used as Reference values in subsequent\n    # actions in the same bundle, and in UpdateRecord/RemoveRecord actions.\n    self._engine.out_actions.summary.update_new_rows_map(table_id, row_ids, filled_row_ids)\n\n    # Convert entered values to the correct types.\n    ActionType = actions.ReplaceTableData if replace else actions.BulkAddRecord\n    action, extra_actions = self._engine.convert_action_values(\n      ActionType(table_id, filled_row_ids, column_values))\n\n    # If any extra actions were generated (e.g. to adjust positions), apply them.\n    for a in extra_actions:\n      self._do_extra_doc_action(a)\n\n    # We could set static default values for omitted data columns, or we can ensure that other\n    # code (JS, DocStorage) is aware of the static defaults. Since other code is already aware,\n    # we'll skip this step. We also don't populate column defaults when adding a new column.\n\n    if table_id == \"_grist_Validations\":\n      for idx, row_id in enumerate(filled_row_ids):\n        self.doAddColumn(\n          self._engine.tables[\"_grist_Tables\"].get_column(\"tableId\").raw_get(\n            column_values[\"tableRef\"][idx]), get_validation_func_name(row_id),\n          { \"isFormula\" : True, \"formula\" : column_values[\"formula\"][idx], \"type\": \"Any\" })\n\n    self._do_doc_action(action)\n\n    # Invalidate new records, including the columns that may have default formulas (trigger\n    # formulas set to recalculate on new records), to get dynamically-computed default values.\n    recalc_cols = set()\n    for col_id in table.all_columns:\n      if col_id in column_values:\n        continue\n      if not table_id.startswith('_grist_'):\n        col_rec = self._docmodel.columns.lookupOne(tableId=table_id, colId=col_id)\n        if col_rec.recalcWhen == RecalcWhen.NEVER:\n          continue\n      recalc_cols.add(col_id)\n\n    self._engine.invalidate_records(table_id, filled_row_ids, data_cols_to_recompute=recalc_cols)\n\n    return filled_row_ids\n\n  @override_action('BulkAddRecord', '_grist_Triggers')\n  def _addTriggers(self, table_id, row_ids, col_values):\n    parse_conditions_in_triggers(col_values)\n    return self.doBulkAddOrReplace(table_id, row_ids, col_values)\n\n  @override_action('BulkAddRecord', '_grist_ACLRules')\n  def _addACLRules(self, table_id, row_ids, col_values):\n    parse_acl_formulas(col_values)\n    return self.doBulkAddOrReplace(table_id, row_ids, col_values)\n\n  @override_action('BulkAddRecord', '_grist_Cells')\n  def _addCells(self, table_id, row_ids, col_values):\n    self._restrict_cells_columns(col_values)\n\n    # Set timeCreated and timeUpdated automatically to current timestamp, this\n    # is a general practice for new records\n    current_time = int(time.time())\n    col_values['timeCreated'] = [current_time] * len(row_ids)\n    col_values['timeUpdated'] = [current_time] * len(row_ids)\n    # Set userRef automatically to current user\n    user_ref = self._engine._user.UserRef if self._engine._user else None\n    col_values['userRef'] = [user_ref] * len(row_ids)\n    return self.doBulkAddOrReplace(table_id, row_ids, col_values)\n\n  @override_action('BulkUpdateRecord', '_grist_Cells')\n  def _updateCells(self, table_id, row_ids, col_values):\n    self._restrict_cells_columns(col_values)\n\n    # Only set timeUpdated if the content field is being updated\n    # This tracks when the comment text was last modified, not\n    # status changes like resolve\n    if 'content' in col_values:\n      # Set timeUpdated automatically to current timestamp\n      current_time = time.time()\n      col_values['timeUpdated'] = [current_time] * len(row_ids)\n    return self.doBulkUpdateRecord(table_id, row_ids, col_values)\n\n  # Helper to prevent direct modification of protected fields in _grist_Cells table.\n  def _restrict_cells_columns(self, col_values):\n    # Prevent users from modifying cell columns that are protected\n    protected_fields = ['timeCreated', 'timeUpdated', 'userRef', 'cellId']\n    for field in protected_fields:\n      if field in col_values:\n        raise ValueError(f\"Cannot modify {field} field directly\")\n\n\n  #----------------------------------------\n  # UpdateRecords & co.\n  # ----------------------------------------\n\n  def doBulkUpdateRecord(self, table_id, row_ids, columns):\n    # Replace negative ids that may refer to rows just added to this table in this bundle.\n    row_ids = self._engine.out_actions.summary.translate_new_row_ids(table_id, row_ids)\n\n    # Convert passed-in values to the column's correct types (or alttext, or errors) and trim any\n    # unchanged values.\n    action, extra_actions = self._engine.convert_action_values(\n      actions.BulkUpdateRecord(table_id, row_ids, columns))\n    action = [_, row_ids, column_values] = self._engine.trim_update_action(action)\n\n    # Prevent modifying raw data widgets and their fields\n    # This is done here so that the trimmed action can be checked,\n    # preventing spurious errors when columns are set to default values\n    if (\n        table_id == \"_grist_Views_section\"\n        and any(rec.isRaw for i, rec in self._bulk_action_iter(table_id, row_ids))\n    ):\n      allowed_fields = {\"title\", \"description\", \"options\", \"sortColRefs\", \"rules\"}\n      has_summary_section = any(rec.tableRef.summarySourceTable\n                                for i, rec in self._bulk_action_iter(table_id, row_ids))\n      if has_summary_section:\n        # When a group-by column is removed from a summary source table, the source table reference\n        # changes; we pre-emptively allow changes to tableRef here to avoid blocking such actions.\n        allowed_fields.add(\"tableRef\")\n\n      if not set(column_values) <= allowed_fields:\n        raise ValueError(\"Cannot modify raw view section\")\n\n    if (\n        table_id == \"_grist_Views_section_field\"\n        and any(rec.parentId.isRaw for i, rec in self._bulk_action_iter(table_id, row_ids))\n        # Only these fields are allowed to be modified\n        and not set(column_values) <= {\"parentPos\", \"width\"}\n    ):\n      raise ValueError(\"Cannot modify raw view section fields\")\n\n    # Prevent modifying record card widgets and their fields.\n    if (\n        table_id == \"_grist_Views_section\"\n        and any(rec.isRecordCard for i, rec in self._bulk_action_iter(table_id, row_ids))\n    ):\n      allowed_fields = {\"layoutSpec\", \"options\", \"theme\"}\n      if not set(column_values) <= allowed_fields:\n        raise ValueError(\"Cannot modify record card view section\")\n\n    if (\n        table_id == \"_grist_Views_section_field\"\n        and any(rec.parentId.isRecordCard for i, rec in self._bulk_action_iter(table_id, row_ids))\n        and not set(column_values) <= {\n          \"displayCol\", \"parentPos\", \"rules\", \"visibleCol\", \"widgetOptions\"\n          }\n    ):\n      raise ValueError(\"Cannot modify record card view section fields\")\n\n    # If any extra actions were generated (e.g. to adjust positions), apply them.\n    for a in extra_actions:\n      self._do_extra_doc_action(a)\n\n    # Finally, update the record\n    self._do_doc_action(action)\n\n    # Invalidate trigger-formula columns affected by this update.\n    table = self._engine.tables[table_id]\n    if column_values:     # Only if this is a non-trivial update.\n      for col_id, col_obj in table.all_columns.items():\n        if col_obj.is_formula() or not col_obj.has_formula():\n          continue\n        col_rec = self._docmodel.columns.lookupOne(tableId=table_id, colId=col_id)\n\n        # Schedule for recalculation those trigger-formulas that depend on any manual update.\n        if col_rec.recalcWhen == RecalcWhen.MANUAL_UPDATES:\n          self._engine.invalidate_column(col_obj, row_ids, recompute_data_col=True)\n\n        # When we have an explicit value for a trigger-formula, the logic in docactions.py\n        # normally prevents recalculation so that the explicit value would stay (it is also\n        # important for undos). For a data-cleaning column (one that depends on itself), a manual\n        # change *should* trigger recalculation, so we un-prevent it here.\n        if col_id in column_values and col_rec.recalcOnChangesToSelf:\n          self._engine.prevent_recalc(col_obj.node, row_ids, should_prevent=False)\n\n\n  # Helper to perform doBulkUpdateRecord using record update value pairs. This saves\n  #  the steps of separating the value pairs into row ids and column values.\n  # The record_values_pairs should be given as a list of tuples, the first element of each\n  #  being a record and the second being an object mapping col_id to the updated value.\n  def doBulkUpdateFromPairs(self, table_id, record_values_pairs):\n    row_ids = [int(r) for (r, _) in record_values_pairs]\n    return self.doBulkUpdateRecord(table_id, row_ids, make_bulk_values_dict(record_values_pairs))\n\n  @useraction\n  def UpdateRecord(self, table_id, row_id, columns):\n    self.BulkUpdateRecord(table_id, [row_id],\n                          {key: [col] for key, col in columns.items()})\n\n  @useraction\n  def BulkUpdateRecord(self, table_id, row_ids, columns):\n    return self._BulkUpdateRecord_decoded(table_id, row_ids, actions.decode_bulk_values(columns))\n\n  def _BulkUpdateRecord_decoded(self, table_id, row_ids, columns):\n    # Handle special tables, updates to which imply metadata actions.\n\n    # Check that the update is valid.\n    for col_id, values in columns.items():\n      columns[col_id] = self._ensure_column_accepts_data(table_id, col_id, values)\n\n      # Additionally check that we are not trying to modify group-by values in a summary column\n      # (this check is only for updating records, not for adding). Note that col_rec will not be\n      # found for metadata tables (since there is no metadata for the metadata tables).\n      col_rec = self._docmodel.columns.lookupOne(tableId=table_id, colId=col_id)\n      if col_rec and col_rec.summarySourceCol:\n        raise ValueError(\"Cannot enter data into summary group-by column %s\" % col_id)\n\n    method = self._overrides.get(('BulkUpdateRecord', table_id), self.doBulkUpdateRecord)\n    method(table_id, row_ids, columns)\n\n  @override_action('BulkUpdateRecord', '_grist_Triggers')\n  def _updateTriggerRecords(self, table_id, row_ids, col_values):\n    parse_conditions_in_triggers(col_values)\n    self.doBulkUpdateRecord(table_id, row_ids, col_values)\n\n  @override_action('BulkUpdateRecord', '_grist_Validations')\n  def _updateValidationRecords(self, table_id, row_ids, col_values):\n    for i, rec, values in self._bulk_action_iter(table_id, row_ids, col_values):\n      vcolid = get_validation_func_name(rec.id)\n      col_rec = self._docmodel.columns.lookupOne(parentId=rec.tableRef, colId=vcolid)\n      # TODO: Validations table's tableRef should be a Reference rather than an Int.\n      if has_diff_value(values, 'tableRef', rec.tableRef):\n        self._docmodel.remove([col_rec])\n        new_table_id = self._docmodel.tables.table.get_record(values['tableRef']).tableId\n        new_col_info = {\"isFormula\": True, \"type\": \"Any\",\n                        \"formula\": values.get('formula', rec.formula)}\n        self.doAddColumn(new_table_id, vcolid, new_col_info)\n      elif has_diff_value(values, 'formula', rec.formula):\n        self._docmodel.update([col_rec], formula=values['formula'])\n\n    self.doBulkUpdateRecord(table_id, row_ids, col_values)\n\n\n  @override_action('BulkUpdateRecord', '_grist_Tables')\n  def _updateTableRecords(self, table_id, row_ids, col_values):\n    avoid_tableid_set = set(self._engine.tables)\n    update_pairs = []\n    for i, rec, values in self._bulk_action_iter(table_id, row_ids, col_values):\n      update_pairs.append((rec, values))\n      if has_diff_value(values, 'tableId', rec.tableId):\n        # Disallow renaming of summary tables.\n        if rec.summarySourceTable and self._indirection_level == DIRECT_ACTION:\n          raise ValueError(\"RenameTable: cannot rename a summary table\")\n\n        # Find a non-conflicting name, except that we don't need to avoid the old name.\n        avoid = avoid_tableid_set - {rec.tableId}\n        new_table_id = identifiers.pick_table_ident(values['tableId'], avoid=avoid)\n        values['tableId'] = new_table_id\n        avoid_tableid_set.add(new_table_id)\n        if new_table_id != rec.tableId:\n          # If there are summary tables based on this table, rename them to appropriate names.\n          for st in rec.summaryTables:\n            groupby_col_ids = [c.colId for c in st.columns if c.summarySourceCol]\n            st_table_id = summary.encode_summary_table_name(new_table_id, groupby_col_ids)\n            st_table_id = identifiers.pick_table_ident(st_table_id, avoid=avoid_tableid_set)\n            avoid_tableid_set.add(st_table_id)\n            update_pairs.append((st, {'tableId': st_table_id}))\n\n    # If other tables have columns referring to this table, generate actions to modify their types\n    # (e.g. from 'Ref:Foo' to 'Ref:Bar'). We change type to 'Int' temporarily, to avoid having\n    # invalid references, then change to correct type. Undo involves a similar sequence of events.\n    backref_cols = self._collect_back_references(table_rec for table_rec, _ in update_pairs)\n    col_updates = OrderedDict()\n    table_renames = {t.tableId: values['tableId'] for t, values in update_pairs\n               if has_diff_value(values, 'tableId', t.tableId)}\n    for col in backref_cols:\n      # Typename will normally be \"Ref\" or \"RefList\".\n      typename, old_target = col.type.split(':')[:2]\n      if old_target in table_renames:\n        col_updates[col] = {'type': typename + ':' + table_renames[old_target]}\n\n    if table_renames:\n      # Build up a dictionary mapping col_ref of each affected formula to the new formula text.\n      formula_updates = self._prepare_formula_renames(\n        {(old, None): new for (old, new) in table_renames.items()})\n      # Add the changes to the dict of col_updates. sort for reproducible order.\n      for col_rec, new_formula in sorted(formula_updates.items()):\n        col_updates.setdefault(col_rec, {})['formula'] = new_formula\n\n    # If a table changes to onDemand, any empty columns (formula columns with no set formula)\n    # should be converted to non-formula text columns to avoid SQL errors when they are updated.\n    on_demand_set = [t for t, values in update_pairs\n      if has_diff_value(values, 'onDemand', t.onDemand) and values['onDemand']]\n    empty_cols = [c for t in on_demand_set for c in t.columns if c.isFormula and not c.formula]\n    for col in empty_cols:\n      col_updates.setdefault(col, {}).update(isFormula=False, type='Text')\n\n    for col, values in col_updates.items():\n      if 'type' in values:\n        self.doModifyColumn(col.tableId, col.colId, {'type': 'Int'})\n\n    make_acl_updates = acl.prepare_acl_table_renames(self, table_renames)\n\n    # Collect all the table renames, and do the actual schema actions to apply them.\n    for tbl, values in update_pairs:\n      if has_diff_value(values, 'tableId', tbl.tableId):\n        self._do_doc_action(actions.RenameTable(tbl.tableId, values['tableId']))\n\n    # Update the metadata to reflect the renamed tables.\n    self.doBulkUpdateFromPairs(table_id, update_pairs)\n\n    # Do the modifications of column types and formulas affected by the renames.\n    # Internal functions are used to prevent unintended additional changes from occurring.\n    # Specifically, this prevents widgetOptions and displayCol from being cleared as a side\n    # effect of the column type change.\n    for col, values in col_updates.items():\n      self.doModifyColumn(col.tableId, col.colId, values)\n    self.doBulkUpdateFromPairs('_grist_Tables_column', col_updates.items())\n    make_acl_updates()\n\n\n  @override_action('BulkUpdateRecord', '_grist_Tables_column')\n  def _updateColumnRecords(self, table_id, row_ids, col_values):\n    # pylint: disable=too-many-statements\n\n    # Does various automatic adjustments required for column updates.\n    # col_values is a dict of arrays, each array containing values for all col_recs. We process\n    # each column individually (to keep code simpler), in _adjust_one_column_update.\n    #\n    # Adjustments made:\n    # (1) colIds are sanitized and disambiguated.\n    # (2) Changes to label cause a change to colId, unless untieColIdFromLabel flag is set.\n    # (3) Turning off untieColIdFromLabel flag also syncs label to colId.\n    #\n    # Additionally, summary tables require some special handling of columns changes.\n    # (1) We disallow converting summary-table columns between formula and non-formula.\n    # (2) We disallow renaming summary-table group-by (non-formula) columns directly (but such\n    #     renames are auto-generated when renaming their source column).\n    # (3) Updates to summary-table formula columns should affect sister columns (same-named\n    #     columns for all summary tables of the same source table).\n    # (4) Updates to the source columns of summary group-by columns (including renaming and type\n    #     changes) should be copied to those group-by columns.\n    parse_dropdown_conditions(col_values)\n\n    # A list of individual (col_rec, values) updates, where values is a per-column dict.\n    col_updates = OrderedDict()\n    avoid_colid_set = set()\n    rebuild_summary_tables = set()\n    for i, col_rec, values in self._bulk_action_iter(table_id, row_ids, col_values):\n      col_updates.update(\n        self._adjust_one_column_update(col_rec, values, avoid_colid_set, rebuild_summary_tables)\n      )\n\n    # Collect all renamings that we are about to apply.\n    renames = {(c.parentId.tableId, c.colId): values['colId']\n               for c, values in col_updates.items()\n               if has_diff_value(values, 'colId', c.colId)}\n\n    if renames:\n      # When a column rename has occurred, we need to update the corresponding references in\n      # formula, ACL rules and dropdown conditions.\n\n      # Build up a dictionary mapping col_ref of each affected formula to the new formula text.\n      formula_updates = self._prepare_formula_renames(renames)\n\n      # For any affected columns, include the formula into the update.\n      for col_rec, new_formula in sorted(formula_updates.items()):\n        col_updates.setdefault(col_rec, {}).setdefault('formula', new_formula)\n\n      # For any renames of columns that have a reverse, tag their reverse column as\n      # changing, so that we can update its schema.\n      for c, values in list(col_updates.items()):\n        reverse_col_ref = values.get('reverseCol', c.reverseCol.id)\n        if has_diff_value(values, 'colId', c.colId) and reverse_col_ref:\n          rcol_rec = self._docmodel.columns.table.get_record(reverse_col_ref)\n          col_updates.setdefault(rcol_rec, {}).setdefault('reverseCol', c.id)\n\n    update_pairs = col_updates.items()\n\n    # Disallow most changes to summary group-by columns, except to match the underlying column.\n    # TODO: This is poor. E.g. renaming a group-by column could rename the underlying column (or\n    # offer the option to), or could be disabled; either would be better than an error.\n    for col, values in update_pairs:\n      if col.summarySourceCol:\n        underlying_updates = col_updates.get(col.summarySourceCol, {})\n        for key, value in values.items():\n          if key == 'summarySourceCol' and not value and not col.summarySourceCol._exists():\n            # We are unsetting summarySourceCol because it no longer exists. That's fine; the\n            # record we are updating is actually also about to be deleted.\n            continue\n          if key in ('displayCol', 'visibleCol'):\n            # These can't always match the underlying column, and can now be changed in the\n            # group-by column. (Perhaps the same should be permitted for all widget options.)\n            continue\n          # Properties like colId and type ought to match those of the underlying column (either\n          # the current ones, or the ones that the underlying column is being changed to).\n          expected = underlying_updates.get(key, getattr(col, key))\n          if key == 'type':\n            # Type sometimes must differ (e.g. ChoiceList -> Choice).\n            expected = summary.summary_groupby_col_type(expected)\n\n          if not allowed_summary_change(key, value, expected):\n            raise ValueError(\"Cannot modify summary group-by column '%s'\" % col.colId)\n\n    rename_summary_tables = set()\n    for c, values in update_pairs:\n      # Trigger ModifyColumn and RenameColumn as necessary\n      schema_colinfo = select_keys(values, _modify_col_schema_props)\n\n      # If we set reverseCol in metadata, that turns into reverseColId in schema (which is used to\n      # generate Python code). Note that _adjust_one_column_update has already done sanity checks.\n      if 'reverseCol' in values:\n        reverse_col_ref = values['reverseCol']\n        if reverse_col_ref:\n          reverse_col = self._docmodel.columns.table.get_record(reverse_col_ref)\n          reverse_updates = col_updates.get(reverse_col, {})\n          schema_colinfo['reverseColId'] = reverse_updates.get('colId', reverse_col.colId)\n        else:\n          schema_colinfo['reverseColId'] = None\n\n      if schema_colinfo:\n        self.doModifyColumn(c.parentId.tableId, c.colId, schema_colinfo)\n      if has_diff_value(values, 'colId', c.colId):\n        self._do_doc_action(actions.RenameColumn(c.parentId.tableId, c.colId, values['colId']))\n        if c.summarySourceCol:\n          rename_summary_tables.add(c.parentId)\n\n    # If we change a column's type, we should ALSO unset each affected field's displayCol.\n    type_changed = [c for c, values in update_pairs if has_diff_value(values, 'type', c.type)\n                      and not is_compatible_ref_type(values.get('type', c.type), c.type)]\n    self._docmodel.update([f for c in type_changed for f in c.viewFields],\n                          displayCol=0, visibleCol=0)\n\n    self.doBulkUpdateFromPairs(table_id, update_pairs)\n\n    if renames:\n      acl.perform_acl_rule_renames(self, renames)\n      dropdown_condition.perform_dropdown_condition_renames(self, renames)\n      perform_trigger_condition_renames(self, renames)\n\n    for table_id in rebuild_summary_tables:\n      table = self._engine.tables[table_id]\n      self._engine._update_table_model(table, table.user_table)\n\n    for table in rename_summary_tables:\n      groupby_col_ids = [c.colId for c in table.columns if c.summarySourceCol]\n      new_table_id = summary.encode_summary_table_name(table.summarySourceTable.tableId,\n                                                       groupby_col_ids)\n      with self.indirect_actions():\n        self.RenameTable(table.tableId, new_table_id)\n\n  @override_action('BulkUpdateRecord', '_grist_Views_section')\n  def _updateViewSections(self, table_id, row_ids, col_values):\n    # If we change a raw section name, rename also the table. Table name is a title of the RAW\n    # section. TableId is derived from the tableName (or is autogenerated if the tableName is blank)\n    if 'title' in col_values:\n      rename_table_recs = []\n      rename_names = []\n      for i, rec, values in self._bulk_action_iter(table_id, row_ids, col_values):\n        if rec.isRaw:\n          rename_table_recs.append(rec.tableRef)\n          rename_names.append(values['title'])\n\n          # Renaming a table may sometimes rename pages: For any pages whose name matches\n          # the table name, rename those page to match (provided it contains a section with this\n          # table).\n\n          # Get all sections with this table\n          sections = self._docmodel.view_sections.lookupRecords(tableRef=rec.tableRef)\n          # Get the views of those sections\n          views = {s.parentId for s in sections if s.parentId is not None and s.parentId.id != 0}\n          # Filter them by the old table name (which may be empty - than by tableId)\n          related_views = [v for v in views if v.name == (rec.title or rec.tableRef.tableId)]\n          # Update the views immediately\n          if related_views:\n            self._docmodel.update(related_views, name=[values['title']] * len(related_views))\n\n      self._docmodel.update(rename_table_recs, tableId=rename_names)\n\n    self.doBulkUpdateRecord(table_id, row_ids, col_values)\n\n  @override_action('BulkUpdateRecord', '_grist_Views_section_field')\n  def _updateViewSectionFields(self, table_id, row_ids, col_values):\n    parse_dropdown_conditions(col_values)\n    return self.doBulkUpdateRecord(table_id, row_ids, col_values)\n\n  @override_action('BulkUpdateRecord', '_grist_ACLRules')\n  def _updateACLRules(self, table_id, row_ids, col_values):\n    parse_acl_formulas(col_values)\n    return self.doBulkUpdateRecord(table_id, row_ids, col_values)\n\n  def _prepare_formula_renames(self, renames):\n    \"\"\"\n    Helper that accepts a dict of {(table_id, col_id): new_name} (where col_id is None when table\n    is being renamed) and returns a dictionary mapping col_recs to updated formulas, for all\n    columns whose formula is affected by the rename.\n    \"\"\"\n    # We'll maintain a list of textbuilder patches for each affected col_rec.\n    patches_map = {}\n\n    for (formula_info, pos, table_id, col_id) in self._engine.gencode.grist_names():\n      # Check if we are seeing a mention of a column that's getting renamed.\n      new_name = renames.get((table_id, col_id))\n      if new_name:\n        # Get the record for the affected formula column.\n        (formula_table, formula_col) = formula_info\n        col_rec = self._docmodel.get_column_rec(formula_table, formula_col)\n        # Create a patch and append to the list for this col_rec.\n        name = col_id or table_id\n        formula = col_rec.formula\n        patch = textbuilder.make_patch(formula, pos, pos + len(name), new_name)\n        patches_map.setdefault(col_rec, []).append(patch)\n\n    # Apply the collected patches to each affected formula\n    result = {}\n    for col_rec, patches in patches_map.items():\n      formula = col_rec.formula\n      replacer = textbuilder.Replacer(textbuilder.Text(formula), patches)\n      result[col_rec] = replacer.get_text()\n    return result\n\n\n  def _get_column_values(self, col_rec):\n    table = self._engine.tables[col_rec.parentId.tableId]\n    col_obj = table.get_column(col_rec.colId)\n    return (col_obj.raw_get(r) for r in table.row_ids)\n\n  def _adjust_one_column_update(self, col, col_values, avoid_colid_set, rebuild_summary_tables):\n    # Adjust an update for a single column, implementing the meat of _updateColumnRecords().\n    # Returns a list of (col, values) pairs (containing the input column but possibly more).\n    # Note that it may modify col_values in-place, and may reuse it for multiple results.\n\n    # If changing label, sync it to colId unless untieColIdFromLabel flag is set.\n    if 'label' in col_values and not col_values.get('untieColIdFromLabel',col.untieColIdFromLabel):\n      col_values.setdefault('colId', col_values['label'])\n\n    # If changing untieColIdFromLabel flag to False, then sync colId to label.\n    if has_value(col_values, 'untieColIdFromLabel', False):\n      col_values.setdefault('colId', col_values.get('label', col.label))\n\n    # If renaming columns, pick unique names for them. In addition to avoiding existing names, we\n    # avoid all the names used while processing _adjust_columns_update(). This is necessary when\n    # multiple updates have conflicting sanitized names.\n    if has_diff_value(col_values, 'colId', col.colId):\n      col_values['colId'] = self._pick_col_name(col.parentId, col_values['colId'],\n                                                old_col_id=col.colId, avoid_extra=avoid_colid_set)\n      avoid_colid_set.add(col_values['colId'])\n\n    # If converting a formula column of type \"Any\" to non-formula, set a reasonable type for it.\n    if (col.isFormula and has_value(col_values, 'isFormula', False) and\n        col.type == 'Any' and 'type' not in col_values):\n      # Look at the actual data for that column (first 1000 values) to decide on the type.\n      col_values['type'] = guess_type(self._get_column_values(col), convert=False)\n\n    # If changing the type of a column, unset its displayCol by default.\n    new_type = col_values.get('type', col.type)\n    if 'type' in col_values and not is_compatible_ref_type(new_type, col.type):\n      col_values.setdefault('displayCol', 0)\n      col_values.setdefault('visibleCol', 0)\n\n    # Collect all updates for dependent summary columns.\n    results = []\n    def add(cols, value_dict):\n      results.extend((c, summary.skip_rules_update(c, value_dict)) for c in cols)\n\n    # If changing reverseCol, do some sanity checks and update its counterpart.\n    if has_diff_value(col_values, 'reverseCol', col.reverseCol):\n      reverse_col_ref = col_values['reverseCol']\n\n      if col.reverseCol and (col.reverseCol.id in self._docmodel.columns.table.row_ids):\n        # If unsetting (or changing) reverseCol, unset the counterpart that was pointing back.\n        # The existence check above is to handle case when reverseCol is what just got deleted.\n        results.append((col.reverseCol, {'reverseCol': 0}))\n\n      if reverse_col_ref:\n        # If setting new reverseCol, set its counterpart pointing back to us.\n        rcol_rec = self._docmodel.columns.table.get_record(reverse_col_ref)\n        check_desired_reverse_col(new_type, rcol_rec)\n        results.append((rcol_rec, {'reverseCol': col.id}))\n\n    # If a column has a reverseCol, we restrict some changes to it while reverseCol is set.\n    if col_values.get('reverseCol', col.reverseCol):\n      if not is_compatible_ref_type(new_type, col.type):\n        raise ValueError(\"invalid change to type of a two-way reference column\")\n      if col_values.get('formula'):\n        raise ValueError(\"cannot set formula on a two-way reference column\")\n\n    source_table = col.parentId.summarySourceTable\n    if source_table:  # This is a summary-table column.\n      # Disallow isFormula changes.\n      if has_diff_value(col_values, 'isFormula', col.isFormula):\n        raise ValueError(\"Cannot change summary column '%s' between formula and data\" % col.colId)\n\n      # Don't update any sister helper columns.\n      if col.isFormula and not col.colId.startswith(\"gristHelper\"):\n        # Get all same-named formula columns from other summary tables for the same source table,\n        # and apply the same changes to them.\n        add(self._get_sister_columns(source_table, col), col_values)\n\n    else:             # A non-summary-table column.\n      # If there are group-by columns based on this, change their properties to match (including\n      # colId, for renaming), except formula/isFormula.\n      changes = select_keys(col_values, _inherited_groupby_col_fields)\n      for field in ['displayCol', 'visibleCol']:\n        if field in col_values and not col_values[field]:\n          # If displayCol or visibleCol is being cleared in this col, it should be cleared\n          # in the groupby columns based on this.\n          changes[field] = col_values[field]\n      if 'type' in col_values:\n        changes['type'] = summary.summary_groupby_col_type(col_values['type'])\n        if col_values['type'] != changes['type']:\n          rebuild_summary_tables.update(t.tableId for t in col.summaryGroupByColumns.parentId)\n      add(col.summaryGroupByColumns, changes)\n\n      # If there are summary tables with a same-named formula column, rename those to match.\n      add(self._get_sister_columns(col.parentId, col),\n          select_keys(col_values, _inherited_summary_col_fields))\n\n    # We keep the original column at the end. This matters for modifying source group-by columns:\n    # adjusting the summary columns first ensures that they have the new (converted) values by\n    # the time lookupOrAddDerived() calls search for converted value.\n    results.append((col, col_values))\n    return results\n\n\n  def _get_sister_columns(self, source_table, col):\n    \"\"\"\n    Returns all summary columns based on the given source_table, with colId matching that of col,\n    and excluding col from the returned list.\n    \"\"\"\n    # The filter removes falsy columns, i.e. results from tables that don't have a match.\n    col_recs = [self._docmodel.columns.lookupOne(parentId=t, colId=col.colId, isFormula=True)\n                for t in source_table.summaryTables]\n    return [c for c in col_recs if c and c != col]\n\n\n  def _ensure_column_accepts_data(self, table_id, col_id, values):\n    \"\"\"\n    When we store values (via Add or Update), check that the column is a data column. If it is an\n    empty column (formula column with an empty formula), convert to data. If it's a real formula\n    column, then fail.\n    Return a list of values which may be the same as the original argument\n    or may have values converted to the newly guessed type of the column.\n    \"\"\"\n    schema_col = self._engine.schema[table_id].columns[col_id]\n    if not schema_col.isFormula:\n      # Plain old data column, OK to enter values.\n      return values\n\n    if schema_col.formula:\n      # This is an error. We can't save individual values to formula columns.\n      raise ValueError(\"Can't save value to formula column %s\" % col_id)\n\n    # An empty column (isFormula=True, formula=\"\"), now is the time to convert it to data.\n    # Since the user is merely adding/updating plain records, they shouldn't be blocked by\n    # ACL rules preventing schema changes, i.e. these column changing actions are not direct.\n    with self.indirect_actions():\n      if schema_col.type == 'Any':\n        # Guess the type when it starts out as Any. We unfortunately need to update the column\n        # separately for type conversion, to recompute type-specific defaults\n        # before they are used in formula->data conversion.\n        col_info, values = guess_col_info(values, self._docmodel)\n        # If the values are all blank (None or empty string) leave the column empty\n        if not col_info:\n          return values\n        col_rec = self._docmodel.get_column_rec(table_id, col_id)\n        self._docmodel.update([col_rec], **col_info)\n      self.ModifyColumn(table_id, col_id, {'isFormula': False})\n      return values\n\n  @useraction\n  def BulkAddOrUpdateRecord(self, table_id, require, col_values, options):\n    \"\"\"\n    Add or Update ('upsert') records depending on `options`\n    and on whether records matching `require` already exist.\n\n    `require` and `col_values` are dictionaries mapping column IDs to lists of cell values.\n    All lists across both dictionaries must have the same length.\n\n    By default, for a single record, if `table.lookupRecords(**require)` returns any records,\n    update the first one with the values in `col_values`.\n    Otherwise create a new record with values `{**require, **col_values}`.\n\n    `options` is a dictionary with optional settings to choose other behaviours:\n    - Set \"on_many\" to \"all\" or \"none\" to change which records are updated when several match.\n    - Set \"update\" or \"add\" to False to disable updating or adding records respectively,\n      i.e. if you only want to add records that don't already exist\n        or if you only want to update records that do already exist.\n    - Set \"allow_empty_require\" to True to allow `require` to be an empty dictionary,\n      which would mean that every record in the table is matched.\n      Otherwise this will raise an error to prevent mistakes like updating an entire column.\n    \"\"\"\n    table = self._engine.tables[table_id]\n\n    update = options.get(\"update\", True)\n    add = options.get(\"add\", True)\n\n    on_many = options.get(\"on_many\", \"first\")\n    if on_many not in (\"first\", \"none\", \"all\"):\n      raise ValueError(\"on_many should be 'first', 'none', or 'all', not %r\" % on_many)\n\n    allow_empty_require = options.get(\"allow_empty_require\", False)\n    if not require and not allow_empty_require:\n      raise ValueError(\"require is empty but allow_empty_require isn't set\")\n\n    if not require and not col_values:\n      return  # nothing to do\n\n    lengths = {}\n    lengths.update({'require ' + k:\n                      len(v) for k, v in require.items()})\n    lengths.update({'col_values ' + k:\n                      len(v) for k, v in col_values.items()})\n    unique_lengths = set(lengths.values())\n    if len(unique_lengths) != 1:\n      raise ValueError(\"Value lists must all have the same length, got %s\" %\n                       json.dumps(lengths, sort_keys=True))\n    [length] = unique_lengths\n\n    decoded_require = actions.decode_bulk_values(require)\n    num_unique_keys = len(set(zip(*decoded_require.values())))\n    if require and num_unique_keys < length:\n      raise ValueError(\"require values must be unique\")\n\n    # Column IDs in `require` that can be used to set values when creating new records,\n    # i.e. not formula columns that don't allow setting values.\n    # `col_values` is not checked for this because setting such a column there should raise an error\n    # This doesn't apply to `require` since it's also used to match existing records.\n    require_add_keys = {\n      key for key in require\n      if not (\n          table.get_column(key).is_formula() and\n          # Check that there actually is a formula and this isn't just an empty column\n          self._engine.docmodel.get_column_rec(table_id, key).formula\n      )\n    }\n    col_keys = set(col_values.keys())\n\n    # Arguments for `BulkAddRecord` and `BulkUpdateRecord` below\n    add_record_ids = []\n    add_record_values = {k: [] for k in col_keys | require_add_keys - {'id'}}\n    update_record_ids = []\n    update_record_values = {k: [] for k in col_keys - {'id'}}\n\n    for i in range(length):\n      current_require = {key: vals[i] for key, vals in decoded_require.items()}\n      records = list(table.lookup_records(**current_require))\n      if not records and add:\n        values = {key: require[key][i] for key in require_add_keys}\n        values.update({key: vals[i] for key, vals in col_values.items()})\n        add_record_ids.append(values.pop(\"id\", None))\n        for key, value in values.items():\n          add_record_values[key].append(value)\n\n      if records and update:\n        if len(records) > 1:\n          if on_many == \"first\":\n            records = records[:1]\n          elif on_many == \"none\":\n            continue\n\n        for record in records:\n          update_record_ids.append(record.id)\n          for key, vals in col_values.items():\n            update_record_values[key].append(vals[i])\n\n    if add_record_ids:\n      self.BulkAddRecord(table_id, add_record_ids, add_record_values)\n\n    if update_record_ids:\n      self.BulkUpdateRecord(table_id, update_record_ids, update_record_values)\n\n  @useraction\n  def AddOrUpdateRecord(self, table_id, require, col_values, options):\n    \"\"\"\n    Add or Update ('upsert') a record depending on `options`\n    and on whether a record matching `require` already exists.\n\n    `require` and `col_values` are dictionaries mapping column IDs to cell values.\n\n    See `BulkAddOrUpdateRecord` for more details.\n    \"\"\"\n    require = {k: [v] for k, v in require.items()}\n    col_values = {k: [v] for k, v in col_values.items()}\n    self.BulkAddOrUpdateRecord(table_id, require, col_values, options)\n\n  #----------------------------------------\n  # RemoveRecords & co.\n  #----------------------------------------\n\n  def doBulkRemoveRecord(self, table_id, row_ids_or_records):\n    table = self._engine.tables[table_id]\n    assert all(isinstance(r, (int, table.Record)) for r in row_ids_or_records)\n    row_ids = [int(r) for r in row_ids_or_records]\n\n    # Replace negative ids that may refer to rows just added to this table in this bundle.\n    row_ids = self._engine.out_actions.summary.translate_new_row_ids(table_id, row_ids)\n\n    self._do_doc_action(actions.BulkRemoveRecord(table_id, row_ids))\n\n    # Also remove any references to this row from other tables.\n    row_id_set = set(row_ids)\n    for ref_col in sorted(table._back_references, key=lambda c: c.node):\n      if ref_col.is_formula() or not isinstance(ref_col, column.BaseReferenceColumn):\n        continue\n      updates = ref_col.get_updates_for_removed_target_rows(row_id_set)\n      if updates:\n        table_id = ref_col.table_id\n        rows = [row_id for (row_id, value) in updates]\n        columns = {ref_col.col_id: [value for (row_id, value) in updates]}\n        if ref_col.table_id.startswith('_grist_'):\n          # Previously we sent this as a docaction. Now we do a proper useraction with all the\n          # processing that involves, e.g. triggering two-way-reference updates, and also all the\n          # metadata checks and updates.\n          self._BulkUpdateRecord_decoded(table_id, rows, columns)\n        else:\n          # But for normal user tables (with two-way references), we must still use the docaction,\n          # otherwise we'd invoke two-way update logic, and the reverse column would try to update\n          # rows that we just deleted.\n          self._do_doc_action(actions.BulkUpdateRecord(table_id, rows, columns))\n\n  @useraction\n  def RemoveRecord(self, table_id, row_id):\n    return self.BulkRemoveRecord(table_id, [row_id])\n\n  @useraction\n  def BulkRemoveRecord(self, table_id, row_ids):\n    # table_rec will not be found for metadata tables, but they are not summary tables anyway.\n    table_rec = self._docmodel.tables.lookupOne(tableId=table_id)\n    # docmodel.setAutoRemove is used for empty summary table rows, but does so 'indirectly'\n    if table_rec and table_rec.summarySourceTable and self._indirection_level == DIRECT_ACTION:\n      raise ValueError(\"Cannot remove record from summary table\")\n\n    method = self._overrides.get(('BulkRemoveRecord', table_id), self.doBulkRemoveRecord)\n    method(table_id, row_ids)\n\n\n  @override_action('BulkRemoveRecord', '_grist_Validations')\n  def _removeValidationRecords(self, table_id, row_ids):\n    # TODO: Validations should be redesigned to use helper columns.\n    col_recs = [\n      self._docmodel.columns.lookupOne(parentId=v.tableRef, colId=get_validation_func_name(v.id))\n      for i, v in self._bulk_action_iter(table_id, row_ids)\n    ]\n    self.doBulkRemoveRecord(table_id, row_ids)\n\n    # Remove the associated validation columns.\n    self._docmodel.remove(col_recs)\n\n\n  @override_action('BulkRemoveRecord', '_grist_Tables')\n  def _removeTableRecords(self, table_id, row_ids):\n    remove_table_recs = [rec for i, rec in self._bulk_action_iter(table_id, row_ids)]\n\n    # If there are any two-way reference columns in this table, break the connection.\n    cols = (c for t in remove_table_recs for c in t.columns if c.reverseCol)\n    if cols:\n      self._docmodel.update(cols, reverseCol=0)\n\n    # If there are summary tables based on this table, remove those too.\n    remove_table_recs.extend(st for t in remove_table_recs for st in t.summaryTables)\n\n    # Handle columns in other tables referring to this table\n    for ref in self._collect_back_references(remove_table_recs):\n      if ref.summarySourceCol:\n        # Skip summary groupby columns, as updating their values is forbidden.\n        # They will be handled automatically when the source column is updated.\n        continue\n      self._convert_reference_col_for_deleted_table(ref)\n\n    # Remove all view sections and fields for all tables being removed.\n    # Bypass the check for raw data view sections.\n    self._doRemoveViewSectionRecords([vs for t in remove_table_recs for vs in t.viewSections])\n\n    # TODO: we need sandbox-side tests for this logic and similar logic elsewhere that deals with\n    # application-level relationships; it is not tested by testscript (nor should be, most likely;\n    # it should have much simpler tests).\n\n    # Remove any views that no longer have view sections.\n    views_to_remove = [view for view in self._docmodel.views.all\n                       if not view.viewSections]\n    self._docmodel.remove(views_to_remove)\n\n    # Save table IDs, which will be inaccessible once we remove the metadata records.\n    remove_table_ids = [t.tableId for t in remove_table_recs]\n\n    # Remove the metadata for the columns and the table itself.\n    col_row_ids = [int(col) for t in remove_table_recs for col in t.columns]\n    table_row_ids = [int(t) for t in remove_table_recs]\n    self.doBulkRemoveRecord('_grist_Tables_column', col_row_ids)\n    self.doBulkRemoveRecord(table_id, table_row_ids)\n\n    # Do the actual RemoveTable docactions. This is done at the end, in reverse order of how\n    # AddTable works, so that 'undo' does schema and metadata actions in the usual order.\n    for table_id in remove_table_ids:\n      self._do_doc_action(actions.RemoveTable(table_id))\n\n  def _convert_reference_col_for_deleted_table(self, col):\n    # col is a column of type Ref:{table_id} or RefList:{table_id}\n    # where table_id is a table that is being deleted.\n    # That type will become invalid. We need to convert it to a type that is valid.\n    table_id = col.parentId.tableId\n    col_id = col.colId\n    visible_col = col.visibleCol\n    display_col = col.displayCol\n    if col.isFormula:\n      # Formula columns are easy, as they allow the Any type.\n      # The contents will probably become some errors which the user should handle.\n      self.ModifyColumn(table_id, col_id, dict(type=\"Any\"))\n      return\n\n    # For data columns, we may also need to update the values.\n    if not (visible_col and display_col):\n      # If there's no visible/display column, we just keep row IDs.\n      if col.type.startswith(\"Ref:\"):\n        self.ModifyColumn(table_id, col_id, {\"type\": \"Int\", \"reverseCol\": 0})\n      else:\n        # Data columns can't be of type Any, and there's no type that can\n        # hold a list of numbers. So we convert the lists of row IDs\n        # to strings containing comma-separated row IDs.\n        # We need to get the values before changing the column type.\n        table = self._engine.tables[table_id]\n        new_values = [\",\".join(map(str, row or [])) for row in self._get_column_values(col)]\n        self.ModifyColumn(table_id, col_id, {\"type\": \"Text\", \"reverseCol\": 0})\n        self.BulkUpdateRecord(table_id, list(table.row_ids), {col_id: new_values})\n      return\n\n    if col.type.startswith(\"Ref:\") and visible_col.type != \"Any\":\n      # This case is easy: we copy the values from the display column directly into\n      # the converted ex-reference column. No need for any complicated conversion.\n      self.CopyFromColumn(table_id, display_col.colId, col_id, visible_col.widgetOptions)\n      self.ModifyColumn(table_id, col_id, dict(type=visible_col.type))\n    else:\n      # Otherwise, we need to do a 'full' type conversion.\n      # Note that this involves `call_external`, i.e. calling JS code.\n      # This is impossible in the Python tests, so this case is tested in DocApi.ts.\n      self.ConvertFromColumn(\n        # widgetOptions and visibleColRef are generally used for parsing values into the new type.\n        # They're not need here since we're just formatting as Text.\n        table_id, col_id, col_id, typ=\"Text\", widgetOptions=\"\", visibleColRef=0\n      )\n\n  @override_action('BulkRemoveRecord', '_grist_Tables_column')\n  def _removeColumnRecords(self, table_id, row_ids):\n    col_recs = [c for i, c in self._bulk_action_iter(table_id, row_ids)]\n\n    # Summary tables disallow removing group-by columns.\n    if any(c.summarySourceCol for c in col_recs):\n      raise ValueError(\"RemoveColumn: cannot remove a group-by column from a summary table\")\n\n    self.doRemoveColumns(col_recs)\n\n  def doRemoveColumns(self, col_recs):\n    # We need to remove group-by columns based on the columns being removed. To ensure we don't end\n    # up with multiple summary tables with the same breakdown, we'll implement this by using\n    # UpdateSummaryViewSection() on all the affected sections.\n    removed_groupby_cols = set(col_recs)\n    summary_tables = {sc.parentId for c in col_recs for sc in c.summaryGroupByColumns}\n    for tbl in sorted(summary_tables):\n      for section in tbl.viewSections:\n        source_cols = [f.colRef.summarySourceCol for f in section.fields]\n        new_groupby_cols = [int(c) for c in source_cols if c and c not in removed_groupby_cols]\n        self.UpdateSummaryViewSection(int(section), new_groupby_cols)\n\n    # At this point, group-by columns based on this should only remain in unused tables\n    # which will get auto-deleted.\n\n    # Remove this column from any sort specs to which it belongs.\n    parent_sections = {section for c in col_recs for section in c.parentId.viewSections}\n    removed_col_refs = set((c.id for c in col_recs))\n    re_sort_sections = []\n    re_sort_specs = []\n    for section in parent_sections:\n      # Only iterates once for each section. Updated sort removes all columns being deleted.\n      sort = json.loads(section.sortColRefs) if section.sortColRefs else []\n      updated_sort = [col_spec for col_spec in sort\n                      if sort_specs.col_ref(col_spec) not in removed_col_refs]\n      if sort != updated_sort:\n        re_sort_sections.append(section)\n        re_sort_specs.append(json.dumps(updated_sort))\n    self._docmodel.update(re_sort_sections, sortColRefs=re_sort_specs)\n\n    more_removals = set()\n    # Remove all rules columns genereted for view fields for all removed columns.\n    # Those columns would be auto-removed but we will remove them immediately to\n    # avoid any recalculations.\n    more_removals.update([rule for col in col_recs\n                               for field in col.viewFields\n                               for rule in field.rules])\n\n    # Remove all view fields for all removed columns.\n    # Bypass the check for raw data view sections.\n    field_ids = [f.id for c in col_recs for f in c.viewFields]\n\n    self.doBulkRemoveRecord(\"_grist_Views_section_field\", field_ids)\n\n    # If there is a displayCol, it may get auto-removed, but may first produce calc actions\n    # triggered by the removal of this column. To avoid those, remove displayCols immediately.\n    # Also remove displayCol for any columns or fields that use this col as their visibleCol.\n    more_removals.update([c.displayCol for c in col_recs],\n                         [vc.displayCol for c in col_recs\n                          for vc in self._docmodel.columns.lookupRecords(visibleCol=c.id)],\n                         [vf.displayCol for c in col_recs\n                          for vf in self._docmodel.view_fields.lookupRecords(visibleCol=c.id)])\n\n    # Remove also all autogenereted formula columns for conditional styles.\n    # But not from transform columns, as those columns borrow rules from original columns\n    more_removals.update([rule\n                          for col in col_recs if not _is_transform_col(col.colId)\n                          for rule in col.rules])\n\n    # Add any extra removals after removing the requested columns in the requested order.\n    orig_removals = set(col_recs)\n    all_removals = col_recs + sorted(c for c in more_removals if c.id and c not in orig_removals)\n\n    # Remove metadata records, but prepare schema actions before the metadata is cleared.\n    removals = [actions.RemoveColumn(c.parentId.tableId, c.colId) for c in all_removals]\n    self.doBulkRemoveRecord('_grist_Tables_column', [int(c) for c in all_removals])\n\n    # Finally do the schema actions to remove the columns.\n    for action in removals:\n      self._do_doc_action(action)\n\n\n  @override_action('BulkRemoveRecord', '_grist_Views')\n  def _removeViewRecords(self, table_id, row_ids):\n    \"\"\"\n    Remove views, including all related items (tab bar, sections, etc.)\n    \"\"\"\n    view_recs = [rec for i, rec in self._bulk_action_iter(table_id, row_ids)]\n\n    # Remove all the tabBar items, and the view sections.\n    self._docmodel.remove(t for v in view_recs for t in v.tabBarItems)\n    self._docmodel.remove(vs for v in view_recs for vs in v.viewSections)\n\n    # Remove all the pages and fixes indentation\n    self._docmodel.remove([p for v in view_recs for p in v.pageItems])\n\n    # Remove the view records themselves.\n    self.doBulkRemoveRecord(table_id, row_ids)\n\n  @override_action('BulkRemoveRecord', '_grist_Pages')\n  def _removePageRecords(self, table_id, row_ids):\n    \"\"\"\n    Remove page records and for the those that have children, update the first child's indentation\n    so that it becomes the new parent. Note that this run a O(n) routine for each page to remove but\n    it's ok considering that the list of _grist_Pages is not meant to grow that big.\n    \"\"\"\n    all_pages = list(self._engine.tables[table_id].filter_records())\n    all_pages.sort(key=lambda p: p.pagePos)\n    fixes = treeview.fix_indents(all_pages, row_ids)\n    if fixes:\n      fixed_row_ids = [f[0] for f in fixes]\n      fixed_indentation = [f[1] for f in fixes]\n      self.doBulkUpdateRecord(table_id, fixed_row_ids, {'indentation': fixed_indentation})\n\n    self.doBulkRemoveRecord(table_id, row_ids)\n\n  @override_action('BulkRemoveRecord', '_grist_Views_section')\n  def _removeViewSectionRecords(self, table_id, row_ids):\n    \"\"\"\n    Remove view sections, including their fields.\n    Raises an error if trying to remove a table's rawViewSectionRef or recordCardViewSectionRef.\n    To bypass that check, call _doRemoveViewSectionRecords.\n    \"\"\"\n    recs = [rec for i, rec in self._bulk_action_iter(table_id, row_ids)]\n    for rec in recs:\n      if rec.isRaw:\n        raise ValueError(\"Cannot remove raw view section\")\n      if rec.isRecordCard:\n        raise ValueError(\"Cannot remove record card view section\")\n    self._doRemoveViewSectionRecords(recs)\n\n  def _doRemoveViewSectionRecords(self, recs):\n    \"\"\"\n    Remove view sections, including their fields, without checking for raw view sections.\n    \"\"\"\n    self.doBulkRemoveRecord('_grist_Views_section_field', [f.id for vs in recs for f in vs.fields])\n    self.doBulkRemoveRecord('_grist_Views_section', [r.id for r in recs])\n\n  @override_action('BulkRemoveRecord', '_grist_Views_section_field')\n  def _removeViewSectionFieldRecords(self, table_id, row_ids):\n    \"\"\"\n    Remove view sections, including their fields.\n    Raises an error if trying to remove a field of a table's rawViewSectionRef,\n    i.e. hiding a column in a raw data widget.\n    \"\"\"\n    recs = [rec for i, rec in self._bulk_action_iter(table_id, row_ids)]\n    for rec in recs:\n      if rec.parentId.isRaw:\n        raise ValueError(\"Cannot remove raw view section field\")\n    self.doBulkRemoveRecord(table_id, row_ids)\n\n  #----------------------------------------\n  # User actions on columns.\n  #----------------------------------------\n\n  @useraction\n  def AddColumn(self, table_id, col_id, col_info):\n    table_rec = self._docmodel.get_table_rec(table_id)\n\n    # New columns by default are empty formula columns, but OnDemand tables require adding\n    # new columns as data columns.\n    if table_rec.onDemand:\n      col_info.setdefault(\"isFormula\", False)\n\n    # Summary tables disallow creating new non-formula columns.\n    if table_rec.summarySourceTable:\n      clean_colinfo = _make_clean_col_info(col_info)\n      if not clean_colinfo[\"isFormula\"]:\n        raise ValueError(\"AddColumn: cannot add a non-formula column to a summary table\")\n\n    transform = (\n        col_id is not None and\n        _is_transform_col(col_id)\n    )\n\n    ret = self.doAddColumn(table_id, col_id, col_info)\n\n    if not transform:\n      if table_rec.rawViewSectionRef:\n        # Add a field for this column to the \"raw_data\" section for this table.\n        # TODO: the position of the inserted field or of the inserted column will often be\n        # bogus, since fields and columns are not the same. This requires better coordination\n        # with the client-side.\n        self._docmodel.insert(\n          table_rec.rawViewSectionRef.fields,\n          col_info.get('_position'),\n          colRef=ret['colRef']\n        )\n\n      if table_rec.recordCardViewSectionRef:\n        # If the record card section or one of its fields hasn't yet been modified,\n        # add a field for this column.\n        section = table_rec.recordCardViewSectionRef\n        modified = (\n          section.layoutSpec or\n          section.options or\n          section.rules or\n          section.theme or\n          any(f.widgetOptions for f in section.fields)\n        )\n        if not modified:\n          self._docmodel.insert(\n            table_rec.recordCardViewSectionRef.fields,\n            col_info.get('_position'),\n            colRef=ret['colRef']\n          )\n\n    return ret\n\n  @useraction\n  def AddHiddenColumn(self, table_id, col_id, col_info):\n    return self.doAddColumn(table_id, col_id, col_info)\n\n\n  @useraction\n  def AddVisibleColumn(self, table_id, col_id, col_info):\n    '''Inserts column and adds it as a field to all 'record' views'''\n\n    ret = self.AddColumn(table_id, col_id, col_info)\n    table_rec = self._docmodel.get_table_rec(table_id)\n\n    transform = (\n        col_id is not None and\n        _is_transform_col(col_id)\n    )\n\n    # Add a field for this column to the view(s) for this table.\n    if not transform:\n      for section in table_rec.viewSections:\n        if section.parentKey == 'record' and section != table_rec.rawViewSectionRef:\n          # TODO: the position of the inserted field or of the inserted column will often be\n          # bogus, since fields and columns are not the same. This requires better coordination\n          # with the client-side.\n          self._docmodel.insert(section.fields, col_info.get('_position'), colRef=ret['colRef'])\n    return ret\n\n  @classmethod\n  def _pick_col_name(cls, table_rec, col_id, old_col_id=None, avoid_extra=None):\n    avoid_set = set(c.colId for c in table_rec.columns)\n    avoid_set.add('id')     # 'id' is already taken although not included among column objects.\n    for t in table_rec.summaryTables:\n      avoid_set.update(c.colId for c in t.columns)\n\n    if avoid_extra:\n      avoid_set.update(avoid_extra)\n\n    # For renaming, don't avoid the old id, e.g. renaming \"a_b\" to \"a*b\" should still give \"a_b\".\n    if old_col_id:\n      avoid_set.discard(old_col_id)\n\n    return identifiers.pick_col_ident(col_id, avoid=avoid_set)\n\n  def doAddColumn(self, table_id, col_id, col_info):\n    table_rec = self._docmodel.get_table_rec(table_id)\n    col_id = self._pick_col_name(table_rec, col_id)\n    clean_colinfo = _make_clean_col_info(col_info)\n    self._do_doc_action(actions.AddColumn(table_id, col_id, clean_colinfo))\n\n    # Update the meta tables.\n    values = clean_colinfo.copy()\n    values.update({\n      'colId': col_id,\n      'widgetOptions': col_info.get('widgetOptions', ''),\n      'label': col_info.get('label', col_id),\n    })\n    if 'rules' in col_info:\n      values['rules'] = col_info['rules']\n    if 'recalcWhen' in col_info:\n      values['recalcWhen'] = col_info['recalcWhen']\n    if 'recalcDeps' in col_info:\n      values['recalcDeps'] = col_info['recalcDeps']\n    visible_col = col_info.get('visibleCol', 0)\n    if visible_col:\n      values['visibleCol'] = visible_col\n    position = col_info.get('_position', None)\n    inserted = self._docmodel.insert(table_rec.columns, position, **values)\n\n    return {\n      'colRef': inserted[0].id,\n      'colId':  col_id\n    }\n\n\n  @useraction\n  def RemoveColumn(self, table_id, col_id):\n    # We can remove a column via either a \"RemoveColumn\" useraction or by removing a column\n    # metadata record. We implement the former interface by forwarding to the latter.\n    col = self._docmodel.get_column_rec(table_id, col_id)\n    self._docmodel.remove([col])\n\n\n  @useraction\n  def RenameColumn(self, table_id, old_col_id, new_col_id):\n    # We can rename a column via either a \"RenameColumn\" useraction or by updating a column\n    # metadata record. We implement the former interface by forwarding to the latter.\n    col = self._docmodel.get_column_rec(table_id, old_col_id)\n    self._docmodel.update([col], colId=new_col_id)\n    return col.colId\n\n  @useraction\n  def SetDisplayFormula(self, table_id, field_ref, col_ref, formula):\n    # Assert user is not setting both field and col formula, since it is likely unintentional.\n    assert not field_ref or not col_ref, \"Should set either field or column display formula\"\n    table_rec = self._docmodel.get_table_rec(table_id)\n\n    if field_ref:\n      field_rec = self._docmodel.view_fields.table.get_record(field_ref)\n      old_display_col_rec = field_rec.displayCol\n      display_col_ref = self._add_or_update_helper_col(table_rec, old_display_col_rec, formula)\n      if display_col_ref is not None:\n        # Update the field's displayCol ref\n        self._docmodel.update([field_rec], displayCol=display_col_ref)\n\n    if col_ref:\n      col_rec = self._docmodel.columns.table.get_record(col_ref)\n      old_display_col_rec = col_rec.displayCol\n      display_col_ref = self._add_or_update_helper_col(table_rec, old_display_col_rec, formula)\n      if display_col_ref is not None:\n        # Update the col's displayCol ref\n        self._docmodel.update([col_rec], displayCol=display_col_ref)\n\n  @useraction\n  def RemoveStaleObjects(self):\n    self._docmodel.remove([\n      col for col in self._docmodel.columns.all if _is_transform_col(col.colId)\n    ])\n    temporary_table_recs = [\n      tab for tab in self._docmodel.tables.all if _is_temporary_table(tab.tableId)\n    ]\n    for table_rec in temporary_table_recs:\n      self.RemoveTable(table_rec.tableId)\n\n  # Helper function to get a helper column with the given formula, or to add one if none\n  # currently exist.\n  def _add_or_update_helper_col(self, table_rec, display_col_rec, formula):\n    if formula:\n      if display_col_rec.numDisplayColUsers == 1:\n        # If this is the only user of the display column, use it as new display column\n        self._docmodel.update([display_col_rec], formula=formula)\n        return None\n      else:\n        formula_cols = self._docmodel.columns.lookupRecords(parentId=table_rec.id, formula=formula)\n        # Get the first display column with the desired formula\n        display_col_ref = next((c.id for c in formula_cols if\n          c.colId.startswith('gristHelper_Display')), 0)\n        # If no appropriate display column exists, add one\n        if not display_col_ref:\n          display_col_info = self.doAddColumn(table_rec.tableId, 'gristHelper_Display', {\n            'type': 'Any',\n            'formula': formula,\n            'isFormula': True\n          })\n          display_col_ref = display_col_info['colRef']\n        return display_col_ref\n    else:\n      return 0\n\n  @useraction\n  def ModifyColumn(self, table_id, col_id, col_info):\n    # We can modify a column via either a \"ModifyColumn\" useraction or by updating a column\n    # metadata record. We implement the former interface by forwarding to the latter.\n    col = self._docmodel.get_column_rec(table_id, col_id)\n\n    update_values = {k: v for k, v in col_info.items() if k not in _unmodifiable_col_fields}\n    if '_position' in col_info:\n      update_values['parentPos'] = col_info['_position']\n    self._docmodel.update([col], **update_values)\n\n  def doModifyColumn(self, table_id, col_id, col_info):\n    \"\"\"\n    ModifyColumn involves a ModifyColumn docaction which changes the column's schema, and creates\n    a new Column object, destroying the old one. Additionally, it may have an effect on the\n    column's data:\n\n    (1) It may change the column's type, which requires a conversion of the data. Note that the\n    action to fill in converted data must come AFTER the ModifyColumn docaction (so that column\n    is already of the right type), including in the \"undo\" direction.\n\n    (2) It may switch a column between \"formula\" and \"data\". Since formula columns are computed\n    on the fly and not stored in DB (at least not always), such a switch requires an action to\n    fill in all values.\n    \"\"\"\n    table = self._engine.tables[table_id]\n    old_column = table.get_column(col_id)\n    from_formula = old_column.is_formula()\n    to_formula = bool(col_info.get('isFormula', from_formula))\n\n    old_col_info = schema.col_to_dict(self._engine.schema[table_id].columns[col_id],\n                                      include_id=False, include_default=True)\n\n    col_info = {k: v for k, v in col_info.items() if old_col_info.get(k, v) != v}\n    if not col_info:\n      log.info(\"useractions.ModifyColumn is a noop\")\n      return\n\n    if from_formula and not to_formula:\n      # Make sure the old column is up to date, in case anything was to be recomputed.\n      self._engine.bring_col_up_to_date(old_column)\n\n    # Get the values from the old column, which is about to be destroyed.\n    all_rows = list(table.row_ids)\n    all_old_values = {r: old_column.raw_get(r) for r in all_rows}\n\n    # Do the actual schema change: this destroys the old column and creates a new one.\n    self._do_doc_action(actions.ModifyColumn(table_id, col_id, col_info))\n\n    old_column = None     # We should no longer refer to this.\n    new_column = table.get_column(col_id)\n    assert to_formula == new_column.is_formula(), \"Wrongly interpreted isFormula conversion\"\n\n    # ModifyColumn has updated the column's values with converted values, but it's up to us to\n    # generate the appropriate BulkUpdateRecord actions for the data changes.\n\n\n    # Fill in the new column by converting the values from the old column. If the type hasn't\n    # changed, or is compatible, the conversion should return the value unchanged.\n    changes = []\n    for row_id in all_rows:\n      orig_value = all_old_values[row_id]\n      new_value = new_column.convert(orig_value)\n      if not strict_equal(orig_value, new_value):\n        new_column.set(row_id, new_value)\n        changes.append((row_id, orig_value, new_column.raw_get(row_id)))\n\n    # Prepare the changes as if for a formula column; they'd get merged at this point with any\n    # previous calc_changes for this column.\n    if changes:\n      self._engine.out_actions.summary.add_changes(table_id, col_id, changes)\n\n    if not to_formula:\n      # If converting to non-formula, any previously prepared calc actions should be removed from\n      # calc summary and actualized now (so that they don't override subsequent changes).\n\n      # The UNDO action needs to be inserted before the one created by ModifyColumn, so that on\n      # undo, we apply ModifyColumn first (getting the correct type), then set the values of\n      # that type. We do it by moving the last (ModifyColumn) action to the end.\n      assert isinstance(self._engine.out_actions.undo[-1], actions.ModifyColumn), \\\n          \"ModifyColumn not where expected in undo list\"\n      mod_action = self._engine.out_actions.undo.pop()\n      try:\n        self._engine.out_actions.flush_calc_changes_for_column(table_id, col_id)\n      finally:\n        self._engine.out_actions.undo.append(mod_action)\n\n    # Give two-way reference columns a chance to get rebuilt after a Ref<>RefList switch.\n    if 'type' in col_info:\n      update_action = new_column.recalc_from_reverse_values()\n      self._do_doc_action(update_action)\n\n  @useraction\n  def ConvertFromColumn(self, table_id, src_col_id, dst_col_id, typ, widgetOptions, visibleColRef):\n    from sandbox import call_external\n    table = self._engine.tables[table_id]\n    src_col = self._docmodel.get_column_rec(table_id, src_col_id)\n    src_column = table.get_column(src_col_id)\n    row_ids = list(table.row_ids)\n    src_values = [encode_object(src_column.raw_get(r)) for r in row_ids]\n    display_values = None\n    if src_col.displayCol:\n      display_col = table.get_column(src_col.displayCol.colId)\n      display_values = [encode_object(display_col.raw_get(r)) for r in row_ids]\n    meta_table_data = {\n      meta_table_id: actions.get_action_repr(\n        self._engine.fetch_table(meta_table_id, formulas=False)\n      )\n      for meta_table_id in [\n        \"_grist_DocInfo\",\n        \"_grist_Tables\",\n        \"_grist_Tables_column\",\n        \"_grist_Views_section_field\"\n      ]\n    }\n    converted_values = call_external(\n      \"convertFromColumn\",\n      meta_table_data,\n      src_col.id,\n      typ,\n      widgetOptions,\n      visibleColRef,\n      src_values,\n      display_values,\n    )\n    self.ModifyColumn(table_id, dst_col_id, {\"type\": typ})\n    self.BulkUpdateRecord(table_id, row_ids, {dst_col_id: converted_values})\n\n  @useraction\n  def CopyFromColumn(self, table_id, src_col_id, dst_col_id, widgetOptions):\n    \"\"\"\n    CopyFromColumn involves a ModifyColumn docaction which changes the destination column's schema,\n    and a BulkUpdateRecord docaction which replaces the destination col's data with the source data.\n    If not None, widgetOptions may contain a JSON-string of widgetOptions to use instead of the\n    source column's.\n    \"\"\"\n    table = self._engine.tables[table_id]\n    src_col = self._docmodel.get_column_rec(table_id, src_col_id)\n    dst_col = self._docmodel.get_column_rec(table_id, dst_col_id)\n    src_column = table.get_column(src_col_id)\n\n    # Make sure the src column is up to date, in case anything was to be recomputed.\n    # If not, bring it up to date now before transferring values.\n    if src_column.is_formula():\n      self._engine.bring_col_up_to_date(src_column)\n\n    # NOTE: This action is invoked only in a single place in the client (during type/column/data\n    # transformation - where user has a chance to adjust some widgetOptions (though\n    # the UI is limited). Those widget options were already cleared (in js) and are either\n    # nullish (default ones) or are truly adjusted. As Grist doesn't know if the widgetOptions\n    # were adjusted or not - it will populate it on UI side and pass it here - so the code below\n    # is not used actually (widgetOptions are always set). But there are set with the things\n    # copied from dst_col or were cleared during typeConversion.\n    if widgetOptions is None:\n      widgetOptions = src_col.widgetOptions\n\n    # If we are changing type, and this column is reverse column, make sure it is compatible.\n    # If not, break the connection first, UI should have already warned the user.\n    existing_type = dst_col.type\n    new_type = src_col.type\n    if not is_compatible_ref_type(new_type, existing_type) and dst_col.reverseCol:\n      self._docmodel.update([dst_col, src_col], reverseCol=0)\n\n    # Update the destination column to match the source's type and options. Also unset displayCol,\n    # except if src_col has a displayCol, then keep it unchanged until SetDisplayFormula below.\n    self._docmodel.update([dst_col], type=src_col.type, widgetOptions=[widgetOptions],\n                          visibleCol=[src_col.visibleCol if src_col.visibleCol else 0],\n    # TypeConversion (in js) has decided if rules should be copied or not. If yes, rules were\n    # copied to transforming column (it borrowed rules from us [us as dst_col]), in that case\n    # here is no-op. But it could also decide to clear rules, in that case here we will clear\n    # rules (as transforming column doesn't have it).\n\n    # RulesOptions (fonts, etc) are copied separately in the widgetOptions with the same\n    # logic (where removed or copied to the transforming column).\n                          rules=[src_col.rules if src_col.rules else None],\n                          displayCol=[dst_col.displayCol if src_col.displayCol else 0])\n\n    # Copy over display column as well, if the source column has one.\n    self.maybe_copy_display_formula(src_col, dst_col)\n\n    # Get the values from the columns and check which have changed.\n    all_row_ids = list(table.row_ids)\n    all_src_values = [src_column.raw_get(r) for r in all_row_ids]\n\n    dst_column = table.get_column(dst_col_id)\n    changed_rows, changed_values = [], []\n    for row_id, src_value in zip(all_row_ids, all_src_values):\n      if src_value != dst_column.raw_get(row_id):\n        changed_rows.append(row_id)\n        changed_values.append(src_value)\n\n    # Produce the BulkUpdateRecord update.\n    self._BulkUpdateRecord_decoded(table_id, changed_rows, {dst_col_id: changed_values})\n\n  @useraction\n  def MaybeCopyDisplayFormula(self, src_col_ref, dst_col_ref):\n    src_col = self._docmodel.columns.table.get_record(src_col_ref)\n    dst_col = self._docmodel.columns.table.get_record(dst_col_ref)\n    self.maybe_copy_display_formula(src_col, dst_col)\n\n  def maybe_copy_display_formula(self, src_col, dst_col):\n    \"\"\"\n    If src_col has a displayCol set, create an equivalent one for dst_col.\n    \"\"\"\n    # TODO: Should use the same formula renaming logic that is used when renaming columns.\n    if src_col.displayCol:\n      self.SetDisplayFormula(dst_col.parentId.tableId, None, dst_col.id,\n        re.sub((r'\\$%s\\b' % src_col.colId), '$' + dst_col.colId, src_col.displayCol.formula))\n\n  @useraction\n  def RenameChoices(self, table_id, col_id, renames):\n    \"\"\"\n    Updates the data in a Choice/ChoiceList column to reflect the new choice names.\n    `renames` should be a dict of {old_choice_name: new_choice_name}.\n    This doesn't touch the choices configuration in widgetOptions, that must be done separately.\n    \"\"\"\n\n    table = self._engine.tables[table_id]\n    col = table.get_column(col_id)\n\n    # We don't set the values of formula columns, they should just recalculate themselves\n    if not col.is_formula():\n      row_ids, values = col.rename_choices(renames)\n      values = [encode_object(v) for v in values]\n      self.BulkUpdateRecord(table_id, row_ids, {col_id: values})\n\n    # Helper to rename only string values\n    def rename(value):\n      return renames.get(value, value) if isinstance(value, str) else value\n\n    # Rename filters\n    filters = self._engine.tables['_grist_Filters']\n    colRef = self._docmodel.get_column_rec(table_id, col_id).id\n    col_filters = filters.filter_records(colRef=colRef)\n    row_ids = []\n    values = []\n    for rec in col_filters:\n      if not rec.filter:\n        continue\n      col_filter = json.loads(rec.filter)\n      new_filter = {\n        include_exclude: [rename(value) for value in values]\n        for include_exclude, values in col_filter.items()\n      }\n      if col_filter != new_filter:\n        row_ids.append(rec.id)\n        values.append(json.dumps(new_filter))\n    if row_ids:\n      self.BulkUpdateRecord('_grist_Filters', row_ids, {\"filter\": values})\n\n\n  @useraction\n  def AddEmptyRule(self, table_id, field_ref, col_ref):\n    \"\"\"\n    Adds an empty conditional style rule to a field, column, or raw view section.\n    \"\"\"\n    self.doAddRule(table_id, field_ref, col_ref)\n\n\n  def doAddRule(self, table_id, field_ref, col_ref, formula=''):\n    \"\"\"\n    Adds a conditional style rule to a field, column, or raw view section.\n    \"\"\"\n    assert table_id, \"table_id is required\"\n\n    col_name = \"gristHelper_ConditionalRule\"\n\n    if field_ref:\n      rule_owner = self._docmodel.view_fields.table.get_record(field_ref)\n    elif col_ref:\n      rule_owner = self._docmodel.columns.table.get_record(col_ref)\n    else:\n      col_name = \"gristHelper_RowConditionalRule\"\n      rule_owner = self._docmodel.get_table_rec(table_id).rawViewSectionRef\n\n    col_info = self.AddHiddenColumn(table_id, col_name, {\n      \"type\": \"Any\",\n      \"isFormula\": True,\n      \"formula\": formula\n    })\n    new_rule = col_info['colRef']\n    existing_rules = rule_owner.rules._get_encodable_row_ids() if rule_owner.rules else []\n    updated_rules = existing_rules + [new_rule]\n    self._docmodel.update([rule_owner], rules=[encode_object(updated_rules)])\n\n  @useraction\n  def AddReverseColumn(self, table_id, col_id):\n    \"\"\"\n    Adds a reverse reference column corresponding to `col_id`. This creates a two-way binding\n    between two Ref/RefList columns. Updating one of them will result in updating the other. To\n    break the binding, one of the columns should be removed (using a regular DocAction).\n\n    If a Foo column (Ref:Table1) has a reverse Bar column (RefList:Table2), then updating the Foo\n    column with a doc action like:\n      ['UpdateRecord', 'Table2', 1, {'Foo': 2}]\n    will result in updating the Bar column with a \"back reference\" like:\n      ['UpdateRecord', 'Table1', 2, {'Bar': ['L', 1]}]\n\n    By default, the type of the reverse column added is RefList, as the column `col_id` might have\n    multiple references (duplicated data). To properly represent it, the reverse column must be of\n    RefList type. The user can change the type of both columns (or either one) to Ref type, but the\n    engine will prevent it if one of the columns has duplicated values (more than one row in Table1\n    points to the same row in Table2).\n\n    The binding is symmetric. There is no \"primary\" or \"secondary\" column. Both columns are equal,\n    and the user can remove either of them and recreate it later from the other one.\n    \"\"\"\n    col_rec = self._docmodel.get_column_rec(table_id, col_id)\n    if col_rec.reverseCol:\n      raise ValueError('reverse reference column already exists')\n    target_table_id = get_referenced_table_id(col_rec.type)\n    if not target_table_id:\n      raise ValueError('reverse column can only be added to a reference column')\n\n    reverse_label = pick_reverse_col_label(self._docmodel, col_rec)\n    ret = self.AddVisibleColumn(target_table_id, reverse_label, {\n      \"isFormula\": False,\n      \"type\": \"RefList:\" + table_id,\n      \"label\": reverse_label,\n    })\n    added_col = self._docmodel.columns.table.get_record(ret['colRef'])\n    self._docmodel.update([col_rec], reverseCol=added_col.id)\n    self._pick_and_set_display_col(added_col)\n\n    # Fill in the new column.\n    col_obj = self._docmodel.get_table(table_id).table.get_column(col_id)\n    update_action = col_obj.recalc_from_reverse_values()\n    self._do_doc_action(update_action)\n\n    return ret\n\n  def _pick_and_set_display_col(self, col_rec):\n    target_table_id = get_referenced_table_id(col_rec.type)\n    target_table_rec = self._docmodel.get_table_rec(target_table_id)\n\n    # Types that could conceivably be identifiers for a record (this is very loose, but at\n    # least excludes types like references and attachments).\n    maybe_ident_types = ['Text', 'Any', 'Numeric', 'Int', 'Date', 'DateTime', 'Choice']\n\n    # Use the first column from target table, if it's a reasonable type.\n    for vcol in target_table_rec.columns:\n      if column.is_visible_column(vcol.colId) and get_pure_type(vcol.type) in maybe_ident_types:\n        self._docmodel.update([col_rec], visibleCol=vcol.id)\n        self.SetDisplayFormula(col_rec.tableId, None, col_rec.id,\n            '$%s.%s' % (col_rec.colId, vcol.colId))\n        break\n\n  #----------------------------------------\n  # User actions on tables.\n  #----------------------------------------\n\n  @useraction\n  def AddEmptyTable(self, table_id):\n    \"\"\"\n    Adds an empty table. Currently it makes up the next available table name (if not provided),\n    and adds three default columns, also picking default names for them (presumably, A, B, and C).\n    \"\"\"\n    columns = [{'id': None, 'isFormula': True} for x in range(3)]\n    return self.AddTable(table_id, columns)\n\n\n  @useraction\n  def AddTable(self, table_id, columns):\n    return self.doAddTable(\n      table_id,\n      columns,\n      manual_sort=True,\n      primary_view=True,\n      raw_section=True,\n      record_card_section=True)\n\n\n  @useraction\n  def AddRawTable(self, table_id):\n    \"\"\"\n    Same as AddEmptyTable but does not create a primary view (and page).\n    \"\"\"\n    columns = [{'id': None, 'isFormula': True} for x in range(3)]\n    return self.doAddTable(\n      table_id,\n      columns,\n      manual_sort=True,\n      primary_view=False,\n      raw_section=True,\n      record_card_section=True\n    )\n\n\n  def doAddTable(self, table_id, columns, manual_sort=False, primary_view=False,\n                 raw_section=False, record_card_section=False,\n                 summarySourceTableRef=0):\n    \"\"\"\n    Add the given table with columns with or without additional views.\n    \"\"\"\n    # For any columns missing 'isFormula' field, default to False when formula is empty. We will\n    # normally default new columns to \"empty\" (isFormula=True), and AddEmptyTable creates empty\n    # columns, but an AddTable action created e.g. by an import will default to data columns.\n    for c in columns:\n      c.setdefault(\"isFormula\", bool(c.get('formula')))\n\n    # Add a manualSort column.\n    if manual_sort:\n      columns.insert(0, column.MANUAL_SORT_COL_INFO.copy())\n\n    # If needed, transform table_id into a valid identifier, and add a suffix to make it unique.\n    table_title = table_id\n    table_id = identifiers.pick_table_ident(table_id, avoid=self._engine.tables.keys())\n    if not table_title:\n      table_title = table_id\n    # Sanitize and de-duplicate column identifiers.\n    col_ids = [c['id'] for c in columns]\n    col_ids = identifiers.pick_col_ident_list(col_ids, avoid={'id'})\n\n    # Clean up col_info objects, including setting certain defaults for omitted fields.\n    clean_colinfo = [_make_clean_col_info(ci, col_id) for (ci, col_id) in zip(columns, col_ids)]\n    self._do_doc_action(actions.AddTable(table_id, clean_colinfo))\n\n    # Update the meta tables.\n    extra = {'summarySourceTable': summarySourceTableRef} if summarySourceTableRef else {}\n    table_rec = self._docmodel.add(self._docmodel.tables, tableId=table_id, primaryViewId=0,\n                                   **extra)[0]\n    self._docmodel.insert(\n      table_rec.columns, None,\n      colId         = col_ids,\n      type          = [c['type'] for c in clean_colinfo],\n      isFormula     = [c['isFormula'] for c in clean_colinfo],\n      formula       = [c['formula'] for c in clean_colinfo],\n      label         = [c.get('label', col_id) for (c, col_id) in zip(columns, col_ids)],\n      widgetOptions = [c.get('widgetOptions', '') for c in columns])\n\n    result = {\n      \"id\": table_rec.id,\n      \"table_id\": table_id,\n      \"columns\": col_ids[1:],   # All the column ids, except the auto-added manualSort.\n    }\n\n    if primary_view:\n      # Create a primary view\n      primary_view = self.doAddView(result[\"table_id\"], 'raw_data', table_title)\n      result[\"views\"] = [primary_view]\n\n    if raw_section:\n      # Create raw view section\n      raw_section = self.create_plain_view_section(\n        result[\"id\"],\n        table_id,\n        self._docmodel.view_sections,\n        \"record\",\n        table_title if not summarySourceTableRef else \"\"\n      )\n\n    if record_card_section:\n      record_card_section = self._create_record_card_view_section(\n        result[\"id\"],\n        table_id,\n        self._docmodel.view_sections\n      )\n\n    if primary_view or raw_section:\n      self.UpdateRecord('_grist_Tables', result[\"id\"], {\n        'primaryViewId': primary_view[\"id\"] if primary_view else 0,\n        'rawViewSectionRef': raw_section.id if raw_section else 0,\n      })\n\n    return result\n\n\n  @useraction\n  def RemoveTable(self, table_id):\n    # We can remove a table via either a \"RemoveTable\" useraction or by removing a table\n    # metadata record. We implement the former interface by forwarding to the latter.\n    table_rec = self._docmodel.get_table_rec(table_id)\n    self._docmodel.remove([table_rec])\n\n\n  @useraction\n  def RenameTable(self, old_table_id, new_table_id):\n    # We can rename a table via either a \"RenameTable\" useraction or by updating a table\n    # metadata record. We implement the former interface by forwarding to the latter.\n    table_rec = self._docmodel.get_table_rec(old_table_id)\n    self._docmodel.update([table_rec], tableId=new_table_id)\n    return table_rec.tableId\n\n\n  @useraction\n  def DuplicateTable(self, existing_table_id, new_table_id, include_data=False):\n    if is_hidden_table(existing_table_id):\n      raise ValueError('Cannot duplicate a hidden table')\n\n    existing_table = self._docmodel.get_table_rec(existing_table_id)\n    if existing_table.summarySourceTable:\n      raise ValueError('Cannot duplicate a summary table')\n\n    # Copy the columns from the raw view section to a new table.\n    raw_section = existing_table.rawViewSectionRef\n    record_card_section = existing_table.recordCardViewSectionRef\n    raw_section_cols = [f.colRef for f in raw_section.fields]\n    col_info = [summary.make_col_info(col=c) for c in raw_section_cols]\n    columns = [summary.get_colinfo_dict(ci, with_id=True) for ci in col_info]\n    result = self.doAddTable(\n      new_table_id,\n      columns,\n      manual_sort=True,\n      primary_view=False,\n      raw_section=True,\n      record_card_section=True,\n    )\n\n    new_table_id = result['table_id']\n    new_table = self._docmodel.get_table_rec(new_table_id)\n    new_raw_section = new_table.rawViewSectionRef\n    new_record_card_section = new_table.recordCardViewSectionRef\n\n    # Copy view section description and options to the new raw view section.\n    self._docmodel.update([new_raw_section],\n      description=raw_section.description,\n      options=raw_section.options,\n    )\n\n    old_to_new_col_refs = {}\n    for existing_field, new_field in zip(raw_section.fields, new_raw_section.fields):\n      old_to_new_col_refs[existing_field.colRef.id] = new_field.colRef\n\n    formula_updates = self._prepare_formula_renames({(existing_table_id, None): new_table_id})\n\n    for existing_field, new_field in zip(raw_section.fields, new_raw_section.fields):\n      existing_column = existing_field.colRef\n      new_column = new_field.colRef\n\n      new_type = existing_column.type\n      new_visible_col = existing_column.visibleCol\n      if new_type.startswith('Ref'):\n        # If this is a self-reference column, point it to the new table.\n        prefix, ref_table_id = new_type.split(':')[:2]\n        if ref_table_id == existing_table_id:\n          new_type = prefix + ':' + new_table_id\n          new_visible_col = old_to_new_col_refs.get(new_visible_col.id, 0)\n\n      new_recalc_deps = existing_column.recalcDeps\n      if new_recalc_deps:\n        new_recalc_deps = [encode_object([old_to_new_col_refs[colRef.id].id\n          for colRef in new_recalc_deps])]\n\n      # Copy column settings to the new columns.\n      self._docmodel.update(\n        [new_column],\n        type=new_type,\n        visibleCol=new_visible_col,\n        untieColIdFromLabel=existing_column.untieColIdFromLabel,\n        colId=new_column.colId,   # To ensure untieColIdFromLabel doesn't rename this column.\n        recalcWhen=existing_column.recalcWhen,\n        recalcDeps=new_recalc_deps,\n        formula=formula_updates.get(new_column, existing_column.formula),\n        description=existing_column.description,\n      )\n      self.maybe_copy_display_formula(existing_column, new_column)\n\n      # Copy field settings to the new fields.\n      self._docmodel.update(\n        [new_field],\n        parentPos=existing_field.parentPos,\n        width=existing_field.width,\n      )\n\n      if existing_column.rules:\n        # Copy all column conditional styles to the new table.\n        for rule in existing_column.rules:\n          self.doAddRule(new_table_id, None, new_column.id, rule.formula)\n\n    self._copy_record_card_settings(record_card_section, new_record_card_section)\n\n    # Copy all row conditional styles to the new table.\n    for rule in raw_section.rules:\n      self.doAddRule(new_table_id, None, None, rule.formula)\n\n    # If requested, copy all data from the original table to the new table.\n    if include_data:\n      data = self._engine.fetch_table(existing_table_id, formulas=False)\n      self.doBulkAddOrReplace(new_table_id, data.row_ids, data.columns, replace=True)\n\n    return {\n      'id': result['id'],\n      'table_id': new_table_id,\n      'raw_section_id': new_raw_section.id,\n    }\n\n  def _copy_record_card_settings(self, src_record_card_section, dst_record_card_section):\n    \"\"\"\n    Helper that copies settings from `src_record_card_section` to `dst_record_card_section`.\n    \"\"\"\n    old_to_new_col_refs = {}\n    old_to_new_field_refs = {}\n    for existing_field, new_field in zip(src_record_card_section.fields,\n                                         dst_record_card_section.fields):\n      old_to_new_col_refs[existing_field.colRef.id] = new_field.colRef\n      old_to_new_field_refs[existing_field.id] = new_field.id\n\n    for existing_field, new_field in zip(src_record_card_section.fields,\n                                         dst_record_card_section.fields):\n      # Copy field settings to the new fields.\n      self._docmodel.update(\n        [new_field],\n        displayCol=old_to_new_col_refs.get(existing_field.displayCol.id, 0),\n        parentPos=existing_field.parentPos,\n        visibleCol=old_to_new_col_refs.get(existing_field.visibleCol.id, 0),\n        widgetOptions=existing_field.widgetOptions,\n      )\n\n      if existing_field.rules:\n        # Copy all field conditional styles to the new section.\n        for rule in existing_field.rules:\n          self.doAddRule(dst_record_card_section.tableRef.tableId, new_field.id, None, rule.formula)\n\n    def patch_layout_spec(layout_spec):\n      if isinstance(layout_spec, (dict, list)):\n        for k, v in (layout_spec.items()\n                     if isinstance(layout_spec, dict)\n                     else enumerate(layout_spec)):\n          if k == 'leaf' and v in old_to_new_field_refs:\n            layout_spec[k] = old_to_new_field_refs[v]\n          patch_layout_spec(v)\n\n    try:\n      new_layout_spec = json.loads(src_record_card_section.layoutSpec)\n      patch_layout_spec(new_layout_spec)\n      new_layout_spec = json.dumps(new_layout_spec)\n    except ValueError:\n      new_layout_spec = ''\n\n    # Copy options, theme, and layout to the new record card view section.\n    self._docmodel.update([dst_record_card_section],\n      options=src_record_card_section.options,\n      layoutSpec=new_layout_spec,\n      theme=src_record_card_section.theme,\n    )\n\n  def _fetch_table_col_recs(self, table_ref, col_refs):\n    \"\"\"Helper that converts col_refs from table table_ref into column Records.\"\"\"\n    try:\n      cols = [self._docmodel.columns.table.get_record(c) for c in col_refs]\n    except KeyError:\n      raise ValueError(\"Invalid column requested\")\n    if not all(c.parentId.id == table_ref for c in cols):\n      raise ValueError(\"Invalid column requested (wrong table)\")\n    return cols\n\n  @useraction\n  def CreateViewSection(self, table_ref, view_ref, section_type, groupby_colrefs, table_id):\n    \"\"\"\n    Create a new view section. If table_ref is 0, also creates a new empty table. If view_ref is\n    0, also creates a new view that will contain the new section. If groupby_colrefs is None,\n    creates a plain section; else creates a summary section grouped by those columns.\n    \"\"\"\n    # If we have groupby_colrefs, ensure they belong to the right table.\n    if groupby_colrefs is not None:\n      groupby_cols = self._fetch_table_col_recs(table_ref, groupby_colrefs)\n\n    if not table_ref:\n      table_ref = self.AddRawTable(table_id)['id']\n    table = self._docmodel.tables.table.get_record(table_ref)\n\n    if not view_ref:\n      view_ref = self.AddView(table.tableId, 'empty', 'New page')['id']\n    view = self._docmodel.views.table.get_record(view_ref)\n\n    if groupby_colrefs is not None:\n      section = self._summary.create_new_summary_section(table, groupby_cols, view, section_type)\n    else:\n      section = self.create_plain_view_section(\n        table.id,\n        table.tableId,\n        view.viewSections,\n        section_type,\n        ''\n      )\n    return {\n      'tableRef': table_ref,\n      'viewRef': view_ref,\n      'sectionRef': section.id\n    }\n\n  def create_plain_view_section(self, tableRef, tableId, view_sections, section_type, title):\n    # If title is the same as tableId leave it empty\n    if title == tableId:\n      title = ''\n    section = self._docmodel.add(view_sections, tableRef=tableRef, parentKey=section_type,\n                                 title=title, borderWidth=1, defaultWidth=100)[0]\n    self._RebuildViewFields(tableId, section.id)\n    return section\n\n  def _create_record_card_view_section(self, tableRef, tableId, view_sections):\n    section = self._docmodel.add(view_sections, tableRef=tableRef, parentKey='single',\n                                 title='', borderWidth=1, defaultWidth=100)[0]\n    self.UpdateRecord('_grist_Tables', tableRef, {\n      'recordCardViewSectionRef': section.id,\n    })\n    self._RebuildViewFields(tableId, section.id)\n    return section\n\n  @useraction\n  def UpdateSummaryViewSection(self, section_ref, groupby_colrefs):\n    \"\"\"\n    Update a summary section to be grouped by a different set of columns. This will update fields\n    of the view section, setting their colRefs to similar columns in a different summary table.\n    \"\"\"\n    section = self._docmodel.view_sections.table.get_record(section_ref)\n    source_table = section.tableRef.summarySourceTable\n    groupby_cols = self._fetch_table_col_recs(source_table.id, groupby_colrefs)\n    self._summary.update_summary_section(section, source_table, groupby_cols)\n\n  @useraction\n  def DetachSummaryViewSection(self, section_ref):\n    \"\"\"\n    Create a real table equivalent to the given summary section, and update the section to show\n    the new table instead of the summary.\n    \"\"\"\n    section = self._docmodel.view_sections.table.get_record(section_ref)\n    if not section.tableRef.summarySourceTable:\n      raise ValueError(\"Can't detach a non-summary section\")\n    self._summary.detach_summary_section(section)\n\n\n  #----------------------------------------\n  # User actions on views.\n  #----------------------------------------\n\n  @useraction\n  def AddView(self, table_id, view_type, name):\n    \"\"\"\n    Creates records for a View\n    \"\"\"\n    result = self.doAddView(table_id, view_type, name)\n    return result\n\n  def doAddView(self, table_id, view_type, name):\n\n    # Create the raw view for the new table, with a field for each column.\n    view_row_id = self.AddRecord('_grist_Views', None, {\n      'name': name,\n      'type': view_type,\n    })\n\n    # Include the new view in the tab bar by default if table isn't hidden\n    if not is_hidden_table(table_id):\n      tab_positions = [r.tabPos for r in self._engine.tables['_grist_TabBar'].filter_records()]\n      max_pos = max(tab_positions) + 1 if tab_positions else 0\n      self.AddRecord('_grist_TabBar', None, {\n        'viewRef': view_row_id,\n        'tabPos': max_pos\n      })\n\n    # Include the new view in the pages tree view\n    self.AddRecord('_grist_Pages', None, {\n      'viewRef': view_row_id,\n      'indentation': 0,\n      'pagePos': None # insert at the end\n    })\n\n    view_sections = []\n    # View type may be 'raw_data' or 'empty'\n    if view_type == 'raw_data':\n      record_section = self.AddViewSection('', 'record', view_row_id, table_id)\n      view_sections.append(record_section['id'])\n\n    return {\n      \"id\": view_row_id,\n      \"sections\": view_sections\n    }\n\n  # TODO: Deprecated; should just use RemoveRecord('_grist_Views', view_id)\n  @useraction\n  def RemoveView(self, view_id):\n    \"\"\"\n    Removes records for view at view_id\n    \"\"\"\n    view_rec = self._docmodel.views.table.get_record(view_id)\n    self._docmodel.remove([view_rec])\n\n  #----------------------------------------\n  # User actions on viewSections.\n  #----------------------------------------\n\n  # TODO: Deprecated; This should no longer be an exposed action; it is superseded by\n  # CreateViewSection.\n  @useraction\n  def AddViewSection(self, title, view_section_type, view_row_id, table_id):\n    \"\"\"\n    Creates records for a viewsection\n    \"\"\"\n    table_rec = self._docmodel.get_table_rec(table_id)\n    view = self._docmodel.views.table.get_record(view_row_id)\n    section = self._docmodel.add(view.viewSections, tableRef=table_rec.id,\n                                 parentKey=view_section_type, title=title,\n                                 borderWidth=1, defaultWidth=100,\n                                 sortColRefs='[]')[0]\n    self._RebuildViewFields(table_id, section.id)\n    return {\"id\": section.id}\n\n  # TODO: Deprecated; should just use RemoveRecord('_grist_Views_section', view_id)\n  @useraction\n  def RemoveViewSection(self, view_section_id):\n    \"\"\"\n    Removes records for viewsection at viewsection_id\n    \"\"\"\n    section = self._docmodel.view_sections.table.get_record(view_section_id)\n    self._docmodel.remove([section])\n\n  #--------------------------------------------------------------------------------\n  # Methods for creating and maintaining default views. This is a work-in-progress.\n  #--------------------------------------------------------------------------------\n\n  def _RebuildViewFields(self, table_id, section_row_id):\n    \"\"\"\n    Does the actual work of rebuilding ViewFields to correspond to the table's columns.\n    \"\"\"\n    section_rec = self._docmodel.view_sections.table.get_record(section_row_id)\n    table_rec = self._docmodel.tables.lookupOne(tableId=table_id)\n\n    # Maybe first remove all view fields\n    if section_rec.fields:\n      self._docmodel.remove(section_rec.fields)\n\n    section_type = section_rec.parentKey\n    is_card = section_type in ('single', 'detail')\n    is_record_card = section_rec == table_rec.recordCardViewSectionRef\n    if is_card and not is_record_card:\n      # Copy settings from the table's record card section to the new section.\n      record_card_section = table_rec.recordCardViewSectionRef\n      self._docmodel.add(section_rec.fields, colRef=[f.colRef for f in record_card_section.fields])\n      self._copy_record_card_settings(record_card_section, section_rec)\n    else :\n      # Include all table columns that are intended to be visible to the user.\n      cols = [c for c in table_rec.columns if column.is_visible_column(c.colId)\n              # TODO: hack to avoid auto-adding the 'group' column when detaching summary tables.\n              and c.colId != 'group']\n      limit = None\n      if section_type == 'chart':\n        # TODO: We should address the automatic selection of fields for charts in a better way.\n        limit = 2\n      elif section_type == 'form':\n        # Attachments and formulas are currently unsupported in forms.\n        cols = [c for c in cols if not (c.type == 'Attachments' or (c.isFormula and c.formula))]\n        limit = 9\n      cols.sort(key=lambda c: c.parentPos)\n      if limit is not None:\n        cols = cols[:limit]\n      self._docmodel.add(section_rec.fields, colRef=[c.id for c in cols])\n\n\n  #----------------------------------------------------------------------\n  # UserActions used for imports    (passthrough to import_actions.py)\n  #----------------------------------------------------------------------\n\n  @useraction\n  def GenImporterView(self, source_table_id, dest_table_id, transform_rule=None, options=None):\n    return self._import_actions.DoGenImporterView(\n        source_table_id, dest_table_id, transform_rule, options or {})\n\n\ndef _is_transform_col(col_id):\n  return col_id.startswith((\n    'gristHelper_Transform',\n    'gristHelper_Converted',\n  ))\n\ndef _is_temporary_table(table_id):\n  \"\"\"\n  Returns True if the table is a a temporary table (for example\n  created just for importing documents).\n  \"\"\"\n  return table_id and table_id.startswith('GristHidden_import')\n"
  },
  {
    "path": "sandbox/grist/usercode.py",
    "content": "\"\"\"\nusercode.py isn't a real module, but an example of a module produced by gencode.py from the\nuser-defined document schema.\n\nIt is the same code that's produced from the test schema in test_gencode.py. In fact, it is used\nas part of that test.\n\nUser-defined Tables (i.e. classes that derive from grist.Table) automatically get some additional\nmembers:\n\n  Record - a class derived from grist.Record, with a property for each table column.\n  RecordSet - a class derived from grist.Record, with a property for each table column.\n  RecordSet.Record - a reference to the Record class above\n\n======================================================================\nimport grist\nfrom functions import *       # global uppercase functions\nimport datetime, math, re     # modules commonly needed in formulas\n\n\n@grist.UserTable\nclass Address:\n  city = grist.Text()\n  state = grist.Text()\n\n  def _default_country(rec, table, value, user):\n    return 'US'\n  country = grist.Text()\n\n  def region(rec, table):\n    return {'US': 'North America', 'UK': 'Europe'}.get(rec.country, 'N/A')\n\n  def badSyntax(rec, table):\n    # for a in\n    # 10\n    raise SyntaxError('invalid syntax', ('usercode', 1, 9, u'for a in'))\n\n\n@grist.UserTable\nclass Schools:\n  name = grist.Text()\n  address = grist.Reference('Address')\n\n\n@grist.UserTable\nclass Students:\n  firstName = grist.Text()\n  lastName = grist.Text()\n  school = grist.Reference('Schools')\n\n  def fullName(rec, table):\n    return rec.firstName + ' ' + rec.lastName\n\n  def fullNameLen(rec, table):\n    return len(rec.fullName)\n\n  def schoolShort(rec, table):\n    return rec.school.name.split(' ')[0]\n\n  def schoolRegion(rec, table):\n    addr = rec.school.address\n    return addr.state if addr.country == 'US' else addr.region\n\n  @grist.formulaType(grist.Reference('Schools'))\n  def school2(rec, table):\n    return Schools.lookupFirst(name=rec.school.name)\n======================================================================\n\"\"\"\n"
  },
  {
    "path": "sandbox/grist/usertypes.py",
    "content": "\"\"\"\nThe basic types in Grist include Numeric, Text, Reference, Date, and others. Each type needs a\nrepresentation in storage (database), in communication messages, and in the memory of JS and\nPython interpreters. Each type also needs a convenient Python representation when used in\nformulas. Any typed column may also contain values of a wrong type, and those also need a\nrepresentation. Finally, every type defines a default value, used when the column is first\ncreated, and for new records.\n\nFor values of type int or bool, It's possible to save some memory by using JS typed arrays or\nPython's array.array. However, at least on the Python side, it means that we need an additional\ndata structure for values of the wrong type, and the memory savings aren't that great to be worth\nthe extra complexity.\n\"\"\"\n# pylint: disable=unidiomatic-typecheck\nimport csv\nimport datetime\nimport io\nimport json\nimport logging\nimport math\nimport os\nfrom collections import OrderedDict\n\nimport depend\nimport objtypes\nfrom objtypes import AltText, is_int_short\nimport moment\nfrom records import Record, RecordSet\n\nlog = logging.getLogger(__name__)\n\nNoneType = type(None)\n\n# Note that this matches the defaults in app/common/gristTypes.js\n_type_defaults = {\n  'Any':          None,\n  'Attachments':  None,\n  'Blob':         None,\n  'Bool':         False,\n  'Choice':       u'',\n  'ChoiceList':   None,\n  'Date':         None,\n  'DateTime':     None,\n  'Id':           0,\n  'Int':          0,\n  'ManualSortPos':  float('inf'),\n  'Numeric':      0.0,\n  'PositionNumber': float('inf'),\n  'Ref':          0,\n  'RefList':      None,\n  'Text':         u'',\n}\n\n# Compute truthy and falsy values for Bool type here so they are not\n# recomputed for every Bool conversion.\n# These are the default values, which can be extended by environment variables.\n_truthy_values = {\"true\", \"yes\", \"1\"}\n_falsy_values = {\"false\", \"no\", \"0\"}\n# If the environment variables are set, extend the truthy and falsy values.\nif extra_truthy_values := os.environ.get('GRIST_TRUTHY_VALUES', ''):\n  _truthy_values |= set(extra_truthy_values.lower().split(','))\nif extra_falsy_values := os.environ.get('GRIST_FALSY_VALUES', ''):\n  _falsy_values |= set(extra_falsy_values.lower().split(','))\n\ndef get_pure_type(col_type):\n  \"\"\"\n  Returns type to the first colon, i.e. strips suffix for Ref:, DateTime:, etc.\n  \"\"\"\n  return col_type.split(':', 1)[0]\n\ndef get_type_default(col_type):\n  return _type_defaults.get(get_pure_type(col_type), None)\n\ndef formulaType(grist_type):\n  \"\"\"\n  formulaType(gristType) is a decorator which saves the type as the 'grist_type' attribute\n  on the decorated formula function. It allows the formula columns to be typed.\n  \"\"\"\n  def wrapper(method):\n    method.grist_type = grist_type\n    return method\n  return wrapper\n\ndef get_referenced_table_id(col_type):\n  if col_type.startswith(\"Ref:\"):\n    return col_type[4:]\n  if col_type.startswith(\"RefList:\"):\n    return col_type[8:]\n  return None\n\ndef is_compatible_ref_type(type1, type2):\n  \"\"\"\n  Returns whether type1 and type2 are Ref or RefList types with the same target table.\n  \"\"\"\n  ref_table1 = get_referenced_table_id(type1)\n  ref_table2 = get_referenced_table_id(type2)\n  return bool(ref_table1 and ref_table1 == ref_table2)\n\ndef ifError(value, value_if_error):\n  \"\"\"\n  Return `value` if it is valid, or `value_if_error` otherwise. Similar to Excel's IFERROR.\n  \"\"\"\n  # TODO: this should ideally handle exception values and values of wrong type returned by\n  # formulas, but it's unclear how to make that work.\n  return value_if_error if isinstance(value, AltText) else value\n\n_numeric_types = (float, int)\n_numeric_or_none = (float, int, NoneType)\n\n# Unique sentinel object to tell BaseColumnType constructor to use get_type_default().\n_use_type_default = object()\n\n\nclass BaseColumnType(object):\n  \"\"\"\n  Base class for all column types.\n  \"\"\"\n  _global_creation_order = 0\n\n  def __init__(self, default=_use_type_default):\n    self.default = get_type_default(self.typename()) if default is _use_type_default else default\n    self.default_func = None\n\n    # Slightly silly, but it allows us to extract the order in which fields are listed in the\n    # model definition, without looking back at the schema.\n    self._creation_order = BaseColumnType._global_creation_order\n    BaseColumnType._global_creation_order += 1\n\n  @classmethod\n  def typename(cls):\n    \"\"\"\n    Returns the name of the type, e.g. \"Int\", \"Ref\", or \"RefList\".\n    \"\"\"\n    return cls.__name__\n\n  @classmethod\n  def is_right_type(cls, _value):\n    \"\"\"\n    Returns whether the given value belongs to this type. A cell may contain a wrong-type value\n    (e.g. alttext, error), but formulas will only see right-type values, defaulting to the\n    column's default.\n\n    If is_right_type returns true, it must be possible to store the value (so with typed arrays,\n    it must fit the type's restrictions).\n    \"\"\"\n    return True\n\n  @classmethod\n  def do_convert(cls, value):\n    \"\"\"\n    Converts a value of any type to one of our type (for which is_right_type is true) and returns\n    it, or throws an exception. This is the method that should be overridden by subclasses.\n    \"\"\"\n    return value\n\n  def convert(self, value_to_convert):\n    \"\"\"\n    Converts a value of any type to this type, returning either a value of the right type, or\n    alttext, or error. It never throws, and should not be overridden by subclasses (override\n    do_convert instead).\n    \"\"\"\n    # Don't try to convert errors, although some day we may want to attempt it (e.g. if an error\n    # contains original text, we may want to try to convert the original text).\n    if isinstance(value_to_convert, objtypes.RaisedException):\n      return value_to_convert\n\n    try:\n      return self.do_convert(value_to_convert)\n    except Exception as e:\n      # If conversion failed, return a string to serve as alttext.\n      try:\n        return str(value_to_convert)\n      except Exception:\n        # If converting to string failed, we should still produce something.\n        return objtypes.safe_repr(value_to_convert)\n\n\nclass Text(BaseColumnType):\n  \"\"\"\n  Text is the type for a field holding string (text) data.\n  \"\"\"\n  @classmethod\n  def do_convert(cls, value):\n    if isinstance(value, bytes):\n      return value.decode('utf8')\n    elif value is None:\n      return None\n    elif isinstance(value, float) and not (math.isinf(value) or math.isnan(value)):\n      # Format as integer if possible to avoid scientific notation\n      # so that strings of digits that aren't meant to represent numbers convert correctly.\n      # https://stackoverflow.com/questions/1848700/biggest-integer-that-can-be-stored-in-a-double\n      # says that 2^53+1 is the first integer that isn't accurately stored in a float,\n      # and it looks like 2^53 so we can't trust that either ;)\n      if abs(value) < 2 ** 53:\n        as_int = int(value)\n        if value == as_int:\n          return str(as_int)\n\n      # More than 15 digits of precision can make large numbers (e.g. 2^53+1) look as if\n      # they're represented exactly when they're not\n      return u\"%.15g\" % value\n    else:\n      return str(value)\n\n  @classmethod\n  def is_right_type(cls, value):\n    return isinstance(value, (str, NoneType))\n\n\nclass Blob(BaseColumnType):\n  \"\"\"\n  Blob hold binary data.\n  \"\"\"\n  @classmethod\n  def do_convert(cls, value):\n    return value\n\n  @classmethod\n  def is_right_type(cls, value):\n    return isinstance(value, (bytes, NoneType))\n\n\nclass Any(BaseColumnType):\n  \"\"\"\n  Any is the type that can hold any kind of value. It's used to hold computed values.\n  \"\"\"\n  @classmethod\n  def do_convert(cls, value):\n    # Convert AltText values to plain text when assigning to type Any.\n    return str(value) if isinstance(value, AltText) else value\n\n\nclass Bool(BaseColumnType):\n  \"\"\"\n  Bool is the type for a field holding boolean data.\n  \"\"\"\n  @classmethod\n  # We'll convert any falsy value to False, non-zero numbers to True, and only strings we\n  # recognize.\n  # The GRIST_TRUTHY_VALUES and GRIST_FALSY_VALUES environment variables\n  # can be set to extend this list.\n  # Everything else will result in alttext.\n  def do_convert(cls, value):\n    if not value:\n      return False\n    if isinstance(value, _numeric_types):\n      return True\n    if isinstance(value, AltText):\n      value = str(value)\n    if isinstance(value, str):\n      if value.lower() in _falsy_values:\n        return False\n      if value.lower() in _truthy_values:\n        return True\n    raise objtypes.ConversionError(\"Bool\")\n\n  @classmethod\n  def is_right_type(cls, value):\n    return isinstance(value, (bool, NoneType))\n\n\nclass Int(BaseColumnType):\n  \"\"\"\n  Int is the type for a field holding integer data.\n  \"\"\"\n  @classmethod\n  def do_convert(cls, value):\n    if value in (\"\", None):\n      return None\n    # Convert to float first, since python does not allow casting strings with decimals to int\n    ret = int(float(value))\n    if not is_int_short(ret):\n      raise OverflowError(\"Integer value too large\")\n    return ret\n\n  @classmethod\n  def is_right_type(cls, value):\n    return value is None or (type(value) is int and is_int_short(value))\n\n\nclass Numeric(BaseColumnType):\n  \"\"\"\n  Numeric is the type for a field holding numerical data.\n  \"\"\"\n  @classmethod\n  def do_convert(cls, value):\n    return float(value) if value not in (\"\", None) else None\n\n  @classmethod\n  def is_right_type(cls, value):\n    # TODO: Python distinguishes ints from floats, while JS only has floats. A value that can be\n    # interpreted as an int will upon being entered have type 'float', but after database reload\n    # will have type 'int'.\n    return type(value) in _numeric_or_none\n\n\nclass Date(Numeric):\n  \"\"\"\n  Date is the type for a field holding date data (no timezone).\n  \"\"\"\n  @classmethod\n  def do_convert(cls, value):\n    if value in (\"\", None):\n      return None\n    elif isinstance(value, datetime.datetime):\n      return moment.date_to_ts(value.date())\n    elif isinstance(value, datetime.date):\n      return moment.date_to_ts(value)\n    elif isinstance(value, _numeric_types):\n      return float(value)\n    elif isinstance(value, str):\n      # We also accept a date in ISO format (YYYY-MM-DD), the time portion is optional and ignored\n      return moment.parse_iso_date(value)\n    else:\n      raise objtypes.ConversionError('Date')\n\n  @classmethod\n  def is_right_type(cls, value):\n    return isinstance(value, _numeric_or_none)\n\n\nclass DateTime(Date):\n  \"\"\"\n  DateTime is the type for a field holding date and time data.\n  \"\"\"\n  def __init__(self, timezone=\"America/New_York\", default=_use_type_default):\n    super(DateTime, self).__init__(default)\n\n    try:\n      self.timezone = moment.Zone(timezone)\n    except KeyError:\n      self.timezone = moment.Zone('UTC')\n\n  def do_convert(self, value):\n    if value in (\"\", None):\n      return None\n    elif isinstance(value, datetime.datetime):\n      return moment.dt_to_ts(value, self.timezone)\n    elif isinstance(value, datetime.date):\n      return moment.date_to_ts(value, self.timezone)\n    elif isinstance(value, _numeric_types):\n      return float(value)\n    elif isinstance(value, str):\n      # We also accept a datetime in ISO format (YYYY-MM-DD[T]HH:mm:ss)\n      return moment.parse_iso(value, self.timezone)\n    else:\n      raise objtypes.ConversionError('DateTime')\n\nclass Choice(Text):\n  \"\"\"\n  Choice is the type for a field holding one of a set of acceptable string (text) values.\n  TODO: Type should possibly be aware of the allowed choices, and be considered invalid\n    when its value isn't one of them\n  \"\"\"\n  pass\n\n\nclass ChoiceList(BaseColumnType):\n  \"\"\"\n  ChoiceList is the type for a field holding a list of strings from a set of acceptable choices.\n  \"\"\"\n  def do_convert(self, value):\n    if not value:\n      return None\n    elif isinstance(value, str):\n      # If it's a string that looks like JSON, try to parse it as such.\n      if value.startswith('['):\n        try:\n          return tuple(str(item) for item in json.loads(value))\n        except Exception:\n          pass\n      return value\n    else:\n      # Accepts other kinds of iterables; if that doesn't work, fail the conversion too.\n      return tuple(str(item) for item in value)\n\n  @classmethod\n  def is_right_type(cls, value):\n    return value is None or (isinstance(value, (tuple, list)) and\n                             all(isinstance(item, str) for item in value))\n\n  @classmethod\n  def toString(cls, value):\n    if isinstance(value, (tuple, list)):\n      try:\n        buf = io.StringIO()\n        csv.writer(buf).writerow(value)\n        return buf.getvalue().strip()\n      except Exception:\n        pass\n    return value\n\n\nclass PositionNumber(BaseColumnType):\n  \"\"\"\n  PositionNumber is the type for a position field used to order records in record lists.\n  \"\"\"\n  # The 'inf' default is used by prepare_new_values() in column.py, which always changes it to\n  # finite numbers, but relies on it to keep newly-added records below existing ones by default.\n  @classmethod\n  def do_convert(cls, value):\n    return float(value) if value not in (\"\", None) else float('inf')\n\n  @classmethod\n  def is_right_type(cls, value):\n    # Same as Numeric, but does not support None.\n    return type(value) in _numeric_types\n\n\nclass ManualSortPos(PositionNumber):\n  pass\n\n\nclass Id(BaseColumnType):\n  \"\"\"\n  Id is the type for the record ID field, present automatically in each table.\n  The default of 0 points to the always-present empty record. Real records start at index 1.\n  \"\"\"\n  @classmethod\n  def do_convert(cls, value):\n    # Just like Int.do_convert, but skips conversion via float. This also makes it work for Record\n    # types, which override int() conversion to yield the row ID. Arbitrary values should not be\n    # cast to ints as it results in false hits when converting numerical values to reference ids.\n    if not value:\n      return 0\n    if not isinstance(value, (int, Record)):\n      raise TypeError(\"Cannot convert to Id type\")\n    ret = int(value)\n    if not is_int_short(ret):\n      raise OverflowError(\"Integer value too large\")\n    return ret\n\n  @classmethod\n  def is_right_type(cls, value):\n    return (type(value) is int and is_int_short(value))\n\n\nclass Reference(Id):\n  \"\"\"\n  Reference is the type for a field holding a reference into another table.\n\n  Note that if `foo` is a Reference('Foo'), then `rec.foo` is of type `Foo.Record`. The ID of that\n  record is available as `rec.foo._row_id`. It is equivalent to `rec.foo.id`, except that\n  accessing `id`, as other public properties, involves a lookup in `Foo` table.\n  \"\"\"\n  def __init__(self, table_id, reverse_of=None):\n    super(Reference, self).__init__()\n    self.table_id = table_id\n    self._reverse_col_id = reverse_of\n\n  @classmethod\n  def typename(cls):\n    return \"Ref\"\n\n  def reverse_source_node(self):\n    \"\"\" Returns the reverse column as depend.Node, if it exists. \"\"\"\n    return depend.Node(self.table_id, self._reverse_col_id) if self._reverse_col_id else None\n\nclass ReferenceList(BaseColumnType):\n  \"\"\"\n  ReferenceList stores a list of references into another table.\n  \"\"\"\n  def __init__(self, table_id, reverse_of=None):\n    super(ReferenceList, self).__init__()\n    self.table_id = table_id\n    self._reverse_col_id = reverse_of\n\n  @classmethod\n  def typename(cls):\n    return \"RefList\"\n\n  def reverse_source_node(self):\n    \"\"\" Returns the reverse column as depend.Node, if it exists. \"\"\"\n    return depend.Node(self.table_id, self._reverse_col_id) if self._reverse_col_id else None\n\n  def do_convert(self, value):\n    if isinstance(value, str):\n      # This is second part of a \"hack\" we have to do when we rename tables. During\n      # the rename, we briefly change all Ref columns to Int columns (to lose the table\n      # part), and then back to Ref columns. The values during this change are stored\n      # as serialized strings, which we expect to understand when the column is back to\n      # being a Ref column. We can either end up with a list of ints, or a RecordList\n      # serialized as a string.\n      # TODO: explain why we need to do this and why we have chosen the Int column\n      try:\n        # If it's a string that looks like JSON, try to parse it as such.\n        if value.startswith('['):\n          parsed = json.loads(value)\n          # It must be list of integers, and all of them must be positive integers.\n          if (isinstance(parsed, list) and\n              all(isinstance(v, int) and v > 0 for v in parsed)):\n            value = parsed\n        else:\n        # Else try to parse it as a RecordList\n          value = objtypes.RecordList.from_repr(value)\n      except Exception:\n        pass\n\n    if isinstance(value, RecordSet):\n      assert value._table.table_id == self.table_id\n      return objtypes.RecordList(value._row_ids, group_by=value._group_by, sort_by=value._sort_by,\n          sort_key=value._sort_key)\n    elif not value:\n      # Represent an empty ReferenceList as None (also its default value). Formulas will see [].\n      return None\n\n    # We can also have a list of RecordSet, in that case just flatten this list. This is used by\n    # formulas like `Table1.lookupRecords().OtherRecords' which returns a list of RecordSets.\n    elif isinstance(value, list) and all(\n         isinstance(rset, RecordSet) and rset._table.table_id == self.table_id for rset in value\n      ):\n      row_ids_flat_list = [rec.id for rset in value for rec in rset]\n      row_ids_unique_list = list(OrderedDict((el, None) for el in row_ids_flat_list).keys())\n      return row_ids_unique_list\n\n    return [Reference.do_convert(val) for val in value]\n\n  @classmethod\n  def is_right_type(cls, value):\n    # TODO: whenever is_right_type isn't trivial, get_cell_value should just remember the result\n    # rather than recompute it on every access. Actually this applies not only to is_right_type\n    # but to everything get_cell_value does. It should use minimal-memory minimal-overhead\n    # translations of raw->rich for valid values, and use what memory it needs but still guarantee\n    # constant time for invalid values.\n    return (value is None or\n        (isinstance(value, objtypes.RecordList)) or\n        (isinstance(value, list) and all(Reference.is_right_type(val) for val in value))\n    )\n\n\nclass Attachments(ReferenceList):\n  \"\"\"\n  Currently attachment type is the field for holding data for attachments.\n  \"\"\"\n  def __init__(self):\n    super(Attachments, self).__init__('_grist_Attachments')\n"
  },
  {
    "path": "sandbox/grist/xmlrunner.py",
    "content": "# -*- coding: utf-8 -*-\n\n\"\"\"\nXML Test Runner for PyUnit\n\"\"\"\n\n# Written by Sebastian Rittau <srittau@jroger.in-berlin.de> and placed in\n# the Public Domain. With contributions by Paolo Borelli and others.\n\nfrom __future__ import unicode_literals\n\n__version__ = \"0.3\"\n\nimport os.path\nimport re\nimport sys\nimport time\nimport traceback\nimport unittest\nimport unittest.util\nfrom xml.sax.saxutils import escape\n\nfrom io import StringIO, BytesIO\n\n\nclass _TestInfo(object):\n\n    \"\"\"Information about a particular test.\n\n    Used by _XMLTestResult.\n\n    \"\"\"\n\n    def __init__(self, test, time):\n        (self._class, self._method) = test.id().rsplit(\".\", 1)\n        self._time = time\n        self._error = None\n        self._failure = None\n\n    @staticmethod\n    def create_success(test, time):\n        \"\"\"Create a _TestInfo instance for a successful test.\"\"\"\n        return _TestInfo(test, time)\n\n    @staticmethod\n    def create_failure(test, time, failure):\n        \"\"\"Create a _TestInfo instance for a failed test.\"\"\"\n        info = _TestInfo(test, time)\n        info._failure = failure\n        return info\n\n    @staticmethod\n    def create_error(test, time, error):\n        \"\"\"Create a _TestInfo instance for an erroneous test.\"\"\"\n        info = _TestInfo(test, time)\n        info._error = error\n        return info\n\n    def print_report(self, stream):\n        \"\"\"Print information about this test case in XML format to the\n        supplied stream.\n\n        \"\"\"\n        tag_template = ('  <testcase classname=\"{class_}\" name=\"{method}\" '\n                        'time=\"{time:.4f}\">')\n        stream.write(tag_template.format(class_=self._class,\n                                         method=self._method,\n                                         time=self._time))\n        if self._failure is not None:\n            self._print_error(stream, 'failure', self._failure)\n        if self._error is not None:\n            self._print_error(stream, 'error', self._error)\n        stream.write('</testcase>\\n')\n\n    @staticmethod\n    def _print_error(stream, tag_name, error):\n        \"\"\"Print information from a failure or error to the supplied stream.\"\"\"\n        str_ = str if sys.version_info[0] >= 3 else unicode\n        io_class = StringIO if sys.version_info[0] >= 3 else BytesIO\n        text = escape(str_(error[1]))\n        class_name = unittest.util.strclass(error[0])\n        stream.write('\\n')\n        stream.write('    <{tag} type=\"{class_}\">{text}\\n'.format(\n            tag=tag_name, class_= class_name, text=text))\n        tb_stream = io_class()\n        traceback.print_tb(error[2], None, tb_stream)\n        tb_string = tb_stream.getvalue()\n        if sys.version_info[0] < 3:\n            tb_string = tb_string.decode(\"utf-8\")\n        stream.write(escape(tb_string))\n        stream.write('    </{tag}>\\n'.format(tag=tag_name))\n        stream.write('  ')\n\n\ndef _clsname(cls):\n    return cls.__module__ + \".\" + cls.__name__\n\n\nclass _XMLTestResult(unittest.TestResult):\n\n    \"\"\"A test result class that stores result as XML.\n\n    Used by XMLTestRunner.\n\n    \"\"\"\n\n    def __init__(self, class_name):\n        unittest.TestResult.__init__(self)\n        self._test_name = class_name\n        self._start_time = None\n        self._tests = []\n        self._error = None\n        self._failure = None\n\n    def startTest(self, test):\n        unittest.TestResult.startTest(self, test)\n        self._error = None\n        self._failure = None\n        self._start_time = time.time()\n\n    def stopTest(self, test):\n        time_taken = time.time() - self._start_time\n        unittest.TestResult.stopTest(self, test)\n        if self._error:\n            info = _TestInfo.create_error(test, time_taken, self._error)\n        elif self._failure:\n            info = _TestInfo.create_failure(test, time_taken, self._failure)\n        else:\n            info = _TestInfo.create_success(test, time_taken)\n        self._tests.append(info)\n\n    def addError(self, test, err):\n        unittest.TestResult.addError(self, test, err)\n        self._error = err\n\n    def addFailure(self, test, err):\n        unittest.TestResult.addFailure(self, test, err)\n        self._failure = err\n\n    def print_report(self, stream, time_taken, out, err):\n        \"\"\"Prints the XML report to the supplied stream.\n\n        The time the tests took to perform as well as the captured standard\n        output and standard error streams must be passed in.a\n\n        \"\"\"\n        tag_template = ('<testsuite errors=\"{errors}\" failures=\"{failures}\" '\n                        'name=\"{name}\" tests=\"{total}\" time=\"{time:.3f}\">\\n')\n        stream.write(tag_template.format(name=self._test_name,\n                                         total=self.testsRun,\n                                         errors=len(self.errors),\n                                         failures=len(self.failures),\n                                         time=time_taken))\n        for info in self._tests:\n            info.print_report(stream)\n        stream.write('  <system-out><![CDATA[{0}]]></system-out>\\n'.format(\n            out))\n        stream.write('  <system-err><![CDATA[{0}]]></system-err>\\n'.format(\n            err))\n        stream.write('</testsuite>\\n')\n\n\nclass XMLTestRunner(object):\n\n    \"\"\"A test runner that stores results in XML format compatible with JUnit.\n\n    XMLTestRunner(stream=None) -> XML test runner\n\n    The XML file is written to the supplied stream. If stream is None, the\n    results are stored in a file called TEST-<module>.<class>.xml in the\n    current working directory (if not overridden with the path property),\n    where <module> and <class> are the module and class name of the test class.\n\n    \"\"\"\n\n    def __init__(self, stream=None):\n        self._stream = stream\n        self._path = \".\"\n\n    def run(self, test):\n        \"\"\"Run the given test case or test suite.\"\"\"\n        class_ = test.__class__\n        class_name = class_.__module__ + \".\" + class_.__name__\n        if self._stream is None:\n            filename = \"TEST-{0}.xml\".format(class_name)\n            stream = open(os.path.join(self._path, filename), \"w\")\n            stream.write('<?xml version=\"1.0\" encoding=\"utf-8\"?>\\n')\n        else:\n            stream = self._stream\n\n        result = _XMLTestResult(class_name)\n        start_time = time.time()\n\n        with _FakeStdStreams():\n            test(result)\n            try:\n                out_s = sys.stdout.getvalue()\n            except AttributeError:\n                out_s = \"\"\n            try:\n                err_s = sys.stderr.getvalue()\n            except AttributeError:\n                err_s = \"\"\n\n        time_taken = time.time() - start_time\n        result.print_report(stream, time_taken, out_s, err_s)\n        if self._stream is None:\n            stream.close()\n\n        return result\n\n    def _set_path(self, path):\n        self._path = path\n\n    path = property(\n        lambda self: self._path, _set_path, None,\n        \"\"\"The path where the XML files are stored.\n\n        This property is ignored when the XML file is written to a file\n        stream.\"\"\")\n\n\nclass _FakeStdStreams(object):\n\n    def __enter__(self):\n        self._orig_stdout = sys.stdout\n        self._orig_stderr = sys.stderr\n        sys.stdout = StringIO()\n        sys.stderr = StringIO()\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        sys.stdout = self._orig_stdout\n        sys.stderr = self._orig_stderr\n\n\nclass XMLTestRunnerTest(unittest.TestCase):\n\n    def setUp(self):\n        self._stream = StringIO()\n\n    def _try_test_run(self, test_class, expected):\n\n        \"\"\"Run the test suite against the supplied test class and compare the\n        XML result against the expected XML string. Fail if the expected\n        string doesn't match the actual string. All time attributes in the\n        expected string should have the value \"0.000\". All error and failure\n        messages are reduced to \"Foobar\".\n\n        \"\"\"\n\n        self._run_test_class(test_class)\n\n        got = self._stream.getvalue()\n        # Replace all time=\"X.YYY\" attributes by time=\"0.000\" to enable a\n        # simple string comparison.\n        got = re.sub(r'time=\"\\d+\\.\\d+\"', 'time=\"0.000\"', got)\n        # Likewise, replace all failure and error messages by a simple \"Foobar\"\n        # string.\n        got = re.sub(r'(?s)<failure (.*?)>.*?</failure>',\n                     r'<failure \\1>Foobar</failure>', got)\n        got = re.sub(r'(?s)<error (.*?)>.*?</error>',\n                     r'<error \\1>Foobar</error>', got)\n        # And finally Python 3 compatibility.\n        got = got.replace('type=\"builtins.', 'type=\"exceptions.')\n\n        self.assertEqual(expected, got)\n\n    def _run_test_class(self, test_class):\n        runner = XMLTestRunner(self._stream)\n        runner.run(unittest.makeSuite(test_class))\n\n    def test_no_tests(self):\n        \"\"\"Regression test: Check whether a test run without any tests\n        matches a previous run.\n\n        \"\"\"\n        class TestTest(unittest.TestCase):\n            pass\n        self._try_test_run(TestTest, \"\"\"<testsuite errors=\"0\" failures=\"0\" name=\"unittest.suite.TestSuite\" tests=\"0\" time=\"0.000\">\n  <system-out><![CDATA[]]></system-out>\n  <system-err><![CDATA[]]></system-err>\n</testsuite>\n\"\"\")\n\n    def test_success(self):\n        \"\"\"Regression test: Check whether a test run with a successful test\n        matches a previous run.\n\n        \"\"\"\n        class TestTest(unittest.TestCase):\n            def test_foo(self):\n                pass\n        self._try_test_run(TestTest, \"\"\"<testsuite errors=\"0\" failures=\"0\" name=\"unittest.suite.TestSuite\" tests=\"1\" time=\"0.000\">\n  <testcase classname=\"__main__.TestTest\" name=\"test_foo\" time=\"0.000\"></testcase>\n  <system-out><![CDATA[]]></system-out>\n  <system-err><![CDATA[]]></system-err>\n</testsuite>\n\"\"\")\n\n    def test_failure(self):\n        \"\"\"Regression test: Check whether a test run with a failing test\n        matches a previous run.\n\n        \"\"\"\n        class TestTest(unittest.TestCase):\n            def test_foo(self):\n                self.assertTrue(False)\n        self._try_test_run(TestTest, \"\"\"<testsuite errors=\"0\" failures=\"1\" name=\"unittest.suite.TestSuite\" tests=\"1\" time=\"0.000\">\n  <testcase classname=\"__main__.TestTest\" name=\"test_foo\" time=\"0.000\">\n    <failure type=\"exceptions.AssertionError\">Foobar</failure>\n  </testcase>\n  <system-out><![CDATA[]]></system-out>\n  <system-err><![CDATA[]]></system-err>\n</testsuite>\n\"\"\")\n\n    def test_error(self):\n        \"\"\"Regression test: Check whether a test run with a erroneous test\n        matches a previous run.\n\n        \"\"\"\n        class TestTest(unittest.TestCase):\n            def test_foo(self):\n                raise IndexError()\n        self._try_test_run(TestTest, \"\"\"<testsuite errors=\"1\" failures=\"0\" name=\"unittest.suite.TestSuite\" tests=\"1\" time=\"0.000\">\n  <testcase classname=\"__main__.TestTest\" name=\"test_foo\" time=\"0.000\">\n    <error type=\"exceptions.IndexError\">Foobar</error>\n  </testcase>\n  <system-out><![CDATA[]]></system-out>\n  <system-err><![CDATA[]]></system-err>\n</testsuite>\n\"\"\")\n\n    def test_non_ascii_characters_in_traceback(self):\n        \"\"\"Test umlauts in traceback exception messages.\"\"\"\n        class TestTest(unittest.TestCase):\n            def test_foo(self):\n                raise Exception(\"Test äöü\")\n        self._run_test_class(TestTest)\n\n    def test_stdout_capture(self):\n        \"\"\"Regression test: Check whether a test run with output to stdout\n        matches a previous run.\n\n        \"\"\"\n        class TestTest(unittest.TestCase):\n            def test_foo(self):\n                sys.stdout.write(\"Test\\n\")\n        self._try_test_run(TestTest, \"\"\"<testsuite errors=\"0\" failures=\"0\" name=\"unittest.suite.TestSuite\" tests=\"1\" time=\"0.000\">\n  <testcase classname=\"__main__.TestTest\" name=\"test_foo\" time=\"0.000\"></testcase>\n  <system-out><![CDATA[Test\n]]></system-out>\n  <system-err><![CDATA[]]></system-err>\n</testsuite>\n\"\"\")\n\n    def test_stderr_capture(self):\n        \"\"\"Regression test: Check whether a test run with output to stderr\n        matches a previous run.\n\n        \"\"\"\n        class TestTest(unittest.TestCase):\n            def test_foo(self):\n                sys.stderr.write(\"Test\\n\")\n        self._try_test_run(TestTest, \"\"\"<testsuite errors=\"0\" failures=\"0\" name=\"unittest.suite.TestSuite\" tests=\"1\" time=\"0.000\">\n  <testcase classname=\"__main__.TestTest\" name=\"test_foo\" time=\"0.000\"></testcase>\n  <system-out><![CDATA[]]></system-out>\n  <system-err><![CDATA[Test\n]]></system-err>\n</testsuite>\n\"\"\")\n\n    class NullStream(object):\n        \"\"\"A file-like object that discards everything written to it.\"\"\"\n        def write(self, buffer):\n            pass\n\n    def test_unittests_changing_stdout(self):\n        \"\"\"Check whether the XMLTestRunner recovers gracefully from unit tests\n        that change stdout, but don't change it back properly.\n\n        \"\"\"\n        class TestTest(unittest.TestCase):\n            def test_foo(self):\n                sys.stdout = XMLTestRunnerTest.NullStream()\n\n        runner = XMLTestRunner(self._stream)\n        runner.run(unittest.makeSuite(TestTest))\n\n    def test_unittests_changing_stderr(self):\n        \"\"\"Check whether the XMLTestRunner recovers gracefully from unit tests\n        that change stderr, but don't change it back properly.\n\n        \"\"\"\n        class TestTest(unittest.TestCase):\n            def test_foo(self):\n                sys.stderr = XMLTestRunnerTest.NullStream()\n\n        runner = XMLTestRunner(self._stream)\n        runner.run(unittest.makeSuite(TestTest))\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "sandbox/gvisor/get_checkpoint_path.sh",
    "content": "#!/usr/bin/env bash\n\n# This defines a GRIST_CHECKPOINT environment variable, where we will store\n# a sandbox checkpoint. The path is in principle arbitrary. In practice,\n# it is helpful if it lies outside of the Grist repo (to avoid permission\n# problems with docker users), but is distinct for each possible location\n# of the Grist repo (to avoid collisions in distinct Jenkins jobs).\n#\n# Checkpointing is currently not supported by gvisor when running in\n# rootless mode. Rootless mode is nevertheless the best way to run\n# \"mainstream\" unpatched gvisor for Grist. If gvisor is unpatched\n# (does not have the --unprivileged flag from\n# https://github.com/google/gvisor/issues/4371#issuecomment-700917549)\n# then we do not define GRIST_CHECKPOINT and checkpoints will not be\n# used. If the host is linux, performance seems just fine; in other\n# configurations we've seen about a second delay in initial load of\n# python due to a relatively sluggish file system.\n#\n# So as part of figuring whether to allow checkpoints, this script\n# determines the best flags to call gvisor with. It tries:\n#   --unprivileged --ignore-cgroups   : for a newer rebased fork of gvisor\n#   --unprivileged                    : for an older fork of gvisor\n#   --rootless                        : unforked gvisor\n# It leaves the flags in a GVISOR_FLAGS environment variable. This\n# variable is respected by the sandbox/gvisor/run.py wrapper for running\n# python in gvisor.\n\nfunction check_gvisor {\n  # If we already have working gvisor flags, return.\n  if [[ -n \"$GVISOR_FLAGS\" ]]; then\n    return\n  fi\n  # Check if a trivial command works under gvisor with the proposed flags.\n  if runsc --network none \"$@\" \"do\" true 2> /dev/null; then\n    export GVISOR_FLAGS=\"$@\"\n    export GVISOR_AVAILABLE=1\n  fi\n}\n\ncheck_gvisor --unprivileged --ignore-cgroups\ncheck_gvisor --unprivileged\n\n# If we can't use --unprivileged, stick with --rootless. We will not make a checkpoint.\ncheck_gvisor --rootless\n\nif [[ \"$GVISOR_FLAGS\" =~ \"-unprivileged\" ]]; then\n  export GRIST_CHECKPOINT=/tmp/engine_$(echo $PWD | sed \"s/[^a-zA-Z0-9]/_/g\")\nfi\n"
  },
  {
    "path": "sandbox/gvisor/run.py",
    "content": "#!/usr/bin/env python3\n\n# Run a command under gvisor, setting environment variables and sharing certain\n# directories in read only mode.  Specialized for running python, and (for testing)\n# bash.  Does not change directory structure, for unprivileged operation.\n\n# Contains plenty of hard-coded paths that assume we are running within\n# a container.\n\nimport argparse\nimport glob\nimport json\nimport os\nimport subprocess\nimport sys\nimport tempfile\nimport shutil\n\n# Separate arguments before and after a -- divider.\nfrom itertools import groupby\nall_args = sys.argv[1:]\nall_args = [list(group) for k, group in groupby(all_args, lambda x: x == \"--\") if not k]\nmain_args = all_args[0]   # args before the -- divider, for this script.\nmore_args = all_args[1] if len(all_args) > 1 else []    # args after the -- divider\n                                                        # to pass on to python/bash.\n\n# Set up options.\nparser = argparse.ArgumentParser(description='Run something in gvisor (runsc).')\nparser.add_argument('command', choices=['bash', 'python3'])\nparser.add_argument('--dry-run', '-d', action='store_true',\n                    help=\"print config\")\nparser.add_argument('--env', '-E', action='append')\nparser.add_argument('--mount', '-m', action='append')\nparser.add_argument('--restore', '-r')\nparser.add_argument('--checkpoint', '-c')\nparser.add_argument('--start', '-s')  # allow overridding the entrypoint\nparser.add_argument('--faketime')\n\n# If CHECK_FOR_TERMINAL is set, just determine whether we will be running bash, and\n# exit with success if so.  This is so if we are being wrapped in docker, it can be\n# started in interactive mode.\nif os.environ.get('CHECK_FOR_TERMINAL') == '1':\n  args = parser.parse_args(main_args)\n  exit(0 if args.command == 'bash' else -1)\n\nargs = parser.parse_args(main_args)\n\nsys.stderr.write('run.py: ' + ' '.join(sys.argv) + \"\\n\")\nsys.stderr.flush()\n\ninclude_bash = args.command == 'bash'\n\n# Basic settings for gvisor's runsc.  This follows the standard OCI specification:\n#   https://github.com/opencontainers/runtime-spec/blob/master/config.md\ncmd_args = []\ntmpfs_mounts = []\nmounts = [             # These will be filled in more fully programmatically below.\n  {\n    \"destination\": \"/proc\",  # gvisor virtualizes /proc\n    \"source\": \"/proc\",\n    \"type\": \"proc\"\n  },\n  {\n    \"destination\": \"/sys\",  # gvisor virtualizes /sys\n    \"source\": \"/sys\",\n    \"type\": \"sysfs\",\n    \"options\": [\n      \"nosuid\",\n      \"noexec\",\n      \"nodev\",\n      \"ro\"\n    ]\n  }\n]\nbinds = []\npreserved = set()\nenv = [\n  \"PATH=/usr/local/bin:/usr/bin:/bin\",\n  \"LD_LIBRARY_PATH=/usr/local/lib\"      # Assumes python version in /usr/local\n] + (args.env or [])\nsettings = {\n  \"ociVersion\": \"1.0.0\",\n  \"process\": {\n    \"terminal\": include_bash,\n    # Match current user id, for convenience with mounts. For some versions of\n    # gvisor, default behavior may be better - if you see \"access denied\" problems\n    # during imports, try commenting this section out. We could make imports work\n    # for any version of gvisor by setting mode when using tmp.dir to allow\n    # others to list directory contents.\n    \"user\": {\n      \"uid\": os.getuid(),\n      \"gid\": 0\n    },\n    \"args\": cmd_args,\n    \"env\": env,\n    \"cwd\": \"/\"\n  },\n  \"root\": {\n    \"path\": \"/\",        # The fork of gvisor we use shares paths with host.\n    \"readonly\": True    # Read-only access by default, and we will blank out most\n    # of the host with empty \"tmpfs\" mounts.\n  },\n  \"hostname\": \"gristland\",\n  \"mounts\": mounts,\n  \"linux\": {\n    \"namespaces\": [\n      {\n        \"type\": \"pid\"\n      },\n      {\n        \"type\": \"network\"\n      },\n      {\n        \"type\": \"ipc\"\n      },\n      {\n        \"type\": \"uts\"\n      },\n      {\n        \"type\": \"mount\"\n      }\n    ]\n  }\n}\n\n# Prevents fork bomb\nsettings['process']['rlimits'] = [{\n  \"type\": \"RLIMIT_NPROC\",\n  \"hard\": int(os.environ.get('GVISOR_LIMIT_NPROC', '8')),\n  \"soft\": int(os.environ.get('GVISOR_LIMIT_NPROC', '8')),\n}]\n\nmemory_limit = os.environ.get('GVISOR_LIMIT_MEMORY')\nif memory_limit:\n  settings['process']['rlimits'].append({\n    \"type\": \"RLIMIT_AS\",\n    \"hard\": int(memory_limit),\n    \"soft\": int(memory_limit)\n  })\n\n# Helper for preparing a mount.\ndef preserve(*locations, short_failure=False):\n  for location in locations:\n    # Check the requested directory is visible on the host, and that there hasn't been a\n    # muddle.  For Grist, this could happen if a parent directory of a temporary import\n    # directory hasn't been made available to the container this code runs in, for example.\n    if not os.path.exists(location):\n      if short_failure:\n        raise Exception('cannot find: ' + location)\n      raise Exception('cannot find: ' + location + ' ' +\n                      '(if tmp path, make sure TMPDIR when running grist and GRIST_TMP line up)')\n    binds.append({\n      \"destination\": location,\n      \"source\": location,\n      \"options\": [\"ro\"],\n      \"type\": \"bind\"\n    })\n    preserved.add(location)\n\n# Prepare the file system - blank out everything that need not be shared.\nexceptions = [\"/lib\", \"/lib64\"]   # to be shared (read-only)\nexceptions += [\"/proc\", \"/sys\"]   # already virtualized\n\n# retain /bin and /usr/bin for utilities\nstart = args.start\nif include_bash or start:\n  exceptions.append(\"/bin\")\n\npreserve(\"/usr/bin\")\npreserve(\"/usr/local/lib\")\n\n# Support user-specific extra directories. This is handy if Python is\n# somewhere weird and there is a maze of soft links to get\n# through. And Python is so often somewhere weird.\nextra_dirs = os.environ.get('GVISOR_EXTRA_DIRS')\nif extra_dirs:\n  preserve(*extra_dirs.split(':'))\n\n# Do not attempt to include symlink directories, they are not supported\n# and will cause obscure failures. On debian bookworm /lib64 is a\n# symlink and we do not appear to need it, relative to debian buster\n# where it is a real directory.\nif os.path.exists('/lib64') and not os.path.islink('/lib64'):\n  preserve(\"/lib64\")\nif os.path.exists('/usr/lib64'):\n  preserve(\"/usr/lib64\")\npreserve(\"/usr/lib\")\n\n# include python3 for bash and python3\nbest_python_executable = None\n# We expect python3 in /usr/bin or /usr/local/bin.\ncandidates = [\n  path\n  # Pick the most generic python if not matching python3.11.\n  # Sorry this is delicate because of restores, mounts, symlinks.\n  for pattern in ['python3.11', 'python3.10', 'python3.9', 'python3', 'python3*']\n  for root in ['/usr/local', '/usr']\n  for path in glob.glob(f'{root}/bin/{pattern}')\n  if os.path.exists(path)\n]\nif not candidates:\n  raise Exception('could not find python3')\nbest_python_executable = os.path.realpath(candidates[0])\n\n# Set up any specific shares requested.\nif args.mount:\n  preserve(*args.mount)\n\nfor directory in os.listdir('/'):\n  directory_realpath = os.path.realpath(\"/\" + directory)\n  if directory_realpath not in exceptions and directory_realpath not in preserved:\n    tmpfs_mounts.append({\n      # This places an empty directory at this destination.\n      # Follow any symlinks since otherwise there is an error.\n      \"destination\": directory_realpath,\n      \"type\": \"tmpfs\"\n    })\n  # To avoid duplicates due to usrmerge pattern symlinks\n  exceptions.append(directory_realpath)\n\nsettings['mounts'] = sorted(tmpfs_mounts, key=lambda mount: mount[\"destination\"]) + mounts + binds\n\n# Set up faketime inside the sandbox if requested.  Can't be set up outside the sandbox,\n# because gvisor is written in Go and doesn't use the standard library that faketime\n# tweaks.\nif args.faketime:\n  preserve('/usr/lib/x86_64-linux-gnu/faketime')\n  cmd_args.append('faketime')\n  cmd_args.append('-f')\n  cmd_args.append('2020-01-01 00:00:00' if args.faketime == 'default' else args.faketime)\n  preserve('/usr/bin/faketime')\n  preserve('/bin/date')\n\n# Pick and set an initial entry point (bash or python).\nif start:\n  cmd_args.append(start)\nelse:\n  cmd_args.append('bash' if include_bash else best_python_executable)\n\n# Add any requested arguments for the program that will be run.\ncmd_args += more_args\n\n# Helper for assembling a runsc command.\n# Takes the directory to work in and a list of arguments to append.\ndef make_command(root_dir, action):\n  flag_string = os.environ.get('GVISOR_FLAGS') or '-rootless'\n  flags = flag_string.split(' ')\n  command = [\"runsc\",\n             \"-root\", \"/tmp/runsc\",   # Place container information somewhere writable.\n            ] + flags + [\n             \"-network\",\n             \"none\"] + action + [\n             root_dir.replace('/', '_')]  # Derive an arbitrary container name.\n  return command\n\n# Either print the OCI spec (if --dry-run), or write it as config.json in a\n# temporary directory and pass it on to gvisor runsc.\nif args.dry_run:\n  print(json.dumps(settings, indent=2))\n  exit(0)\n\nwith tempfile.TemporaryDirectory() as root:  # pylint: disable=no-member\n  config_filename = os.path.join(root, 'config.json')\n  with open(config_filename, 'w') as fout:\n    json.dump(settings, fout, indent=2)\n  if not args.checkpoint:\n    if args.restore:\n      command = make_command(root, [\"restore\", \"--image-path=\" + args.restore])\n      # Overwrite config.json with the one of the checkpoint\n      # Any change to the configuration (such as command-line arguments) would\n      # cause gvisor to reject the restore.\n      shutil.copy(os.path.join(args.restore, 'config.json'), config_filename)\n    else:\n      command = make_command(root, [\"run\"])\n    result = subprocess.run(command, cwd=root)  # pylint: disable=no-member\n    if result.returncode != 0:\n      raise Exception('gvisor runsc problem: ' + json.dumps(command))\n  else:\n    # We've been asked to make a checkpoint.\n    # Start up the sandbox, and wait for it to emit a message on stderr ('Ready').\n    command = make_command(root, [\"run\"])\n    process = subprocess.Popen(command, cwd=root, stderr=subprocess.PIPE)\n    text = process.stderr.readline().decode('utf-8')  # wait for ready\n    if 'Ready' in text:\n      sys.stderr.write('Ready message: ' + text)\n      sys.stderr.flush()\n    else:\n      # Something unexpected has happened, echo the full error and hang.\n      while True:\n        sys.stderr.write('Problem: ' + text)\n        sys.stderr.flush()\n        text = process.stderr.readline().decode('utf-8')\n    # Remove existing checkpoint if present.\n    if os.path.exists(args.checkpoint):\n      shutil.rmtree(args.checkpoint)\n    # Make the directory, so we will later have the right to delete the checkpoint if\n    # we wish to replace it. Otherwise there is a muddle around permissions.\n    os.makedirs(args.checkpoint, exist_ok=True)\n    # Go ahead and run the runsc checkpoint command.\n    # This is destructive, it will kill the sandbox we are checkpointing.\n    command = make_command(root, [\"checkpoint\", \"--image-path=\" + args.checkpoint])\n    result = subprocess.run(command, cwd=root)  # pylint: disable=no-member\n    if result.returncode != 0:\n      raise Exception('gvisor runsc checkpointing problem: ' + json.dumps(command))\n    # Save the configuration of the checkpoint for reuse when restoring.\n    checkpoint_config_json = os.path.join(args.checkpoint, 'config.json')\n    shutil.copy(config_filename, checkpoint_config_json)\n    # We are done!\n"
  },
  {
    "path": "sandbox/gvisor/update_engine_checkpoint.sh",
    "content": "#!/usr/bin/env bash\n\n# Create a checkpoint of a gvisor sandbox. It is best to make the\n# checkpoint in as close to the same circumstances as it will be used,\n# because of some nuances around file descriptor ordering and\n# mapping. So we create the checkpoint in a roundabout way, by opening\n# node and creating an NSandbox, with appropriate flags set.\n#\n# Watch out if you feel tempted to simplify this, I initially had a\n# much simpler solution that worked fine in docker, but on aws\n# would result in a runsc panic related to file descriptor\n# ordering/mapping.\n#\n# Note for mac users: the checkpoint will be made in the docker\n# container running runsc.\n\nset -e\n\nSCRIPT_DIR=$( cd -- \"$( dirname -- \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )\n\nexport NODE_PATH=_build:_build/core:_build/ext:_build/stubs\nsource $SCRIPT_DIR/get_checkpoint_path.sh\n\nif [[ -z \"$GRIST_CHECKPOINT\" ]]; then\n  echo \"Skipping checkpoint generation\"\n  exit 0\nfi\n\nexport GRIST_CHECKPOINT_MAKE=1\nexport GRIST_SANDBOX_FLAVOR=gvisor\nexport PYTHON_VERSION=3\n\nBUILD=$(test -e _build/core && echo \"_build/core\" || echo \"_build\")\nnode $BUILD/app/server/generateCheckpoint.js\n"
  },
  {
    "path": "sandbox/install_tz.js",
    "content": "/**\n * This script converts the timezone data from moment-timezone to marshalled format, for fast\n * loading by Python.\n */\nconst marshal = require(\"app/common/marshal\");\nconst fse = require(\"fs-extra\");\nconst moment = require(\"moment-timezone\");\nconst DEST_FILE = \"sandbox/grist/tzdata.data\";\n\nfunction main() {\n  const zones = moment.tz.names().map((name) => {\n    const z = moment.tz.zone(name);\n    return marshal.wrap(\"TUPLE\", [z.name, z.abbrs, z.offsets, z.untils]);\n  });\n  const marshaller = new marshal.Marshaller({version: 2});\n  marshaller.marshal(zones);\n  const contents = marshaller.dumpAsBuffer();\n\n  return fse.writeFile(DEST_FILE, contents);\n}\n\nif (require.main === module) {\n  main().catch((e) => {\n    console.log(\"ERROR\", e.message);\n    process.exit(1);\n  });\n}\n"
  },
  {
    "path": "sandbox/pyodide/Makefile",
    "content": "# This number should be bumped up if making a non-additive change\n# to python packages.\nGRIST_PYODIDE_VERSION = 3\n\ndefault:\n\techo \"Welcome to the pyodide sandbox\"\n\techo \"make fetch_packages  # gets python packages prepared earlier\"\n\techo \"make build_packages  # build python packages from scratch\"\n\techo \"make save_packages   # upload python packages to fetch later\"\n\techo \"make clean_packages  # remove local cache of python packages\"\n\techo \"setup  # get pyodide node package, and python packages\"\n\nfetch_packages:\n\tnode ./preparePackages.js https://s3.amazonaws.com/grist-pynbox/pyodide/packages/v$(GRIST_PYODIDE_VERSION)/ _build/packages/\n\nbuild_packages:\n\t./build_packages.sh\n\nsave_packages:\n\taws s3 sync _build/packages s3://grist-pynbox/pyodide/packages/v$(GRIST_PYODIDE_VERSION)\n\nclean_packages:\n\trm -rf _build/packages\n\trm -rf _build/pyodide/grist-packages\n\nsetup:\n\t./setup.sh\n\tmake fetch_packages\n"
  },
  {
    "path": "sandbox/pyodide/README.md",
    "content": "This is a collection of scripts for running a pyodide-based \"sandbox\" for\nGrist.\n\nI put \"sandbox\" in quotes since pyodide isn't built with sandboxing\nin mind. It was written to run in a browser, where the browser does\nsandboxing. I don't know how much of node's API ends up being exposed\nto the \"sandbox\" - in previous versions of pyodide it seems the answer is\n\"a lot\". See the back-and-forth between dalcde and hoodmane in:\n  https://github.com/pyodide/pyodide/issues/960\nSee specifically:\n  https://github.com/pyodide/pyodide/issues/960#issuecomment-752305257\nI looked at hiwire and its treatment of js globals has changed a\nlot. On the surface it looks like there is good control of what is\nexposed, but there may be other routes.\n\nStill, some wasm-based solution is likely to be helpful, whether from\npyodide or elsewhere, and this is good practice for that.\n\n***\n\nTo run, we need specific versions of the Python packages that Grist uses\nto be prepared. It should suffice to do:\n\n```\nmake setup\n```\n\nIn this directory. See the `Makefile` for other options.\n"
  },
  {
    "path": "sandbox/pyodide/build_packages.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\necho \"\"\necho \"###############################################################\"\necho \"## Get pyodide repository, for transpiling python packages\"\n\nif [[ ! -e _build/pyodide ]]; then\n  cd _build\n  git clone https://github.com/pyodide/pyodide\n  cd ..\nfi\n\necho \"\"\necho \"###############################################################\"\necho \"## Prepare python packages\"\n\ncd _build/pyodide\ngit checkout 0.23.4 || (git fetch && git checkout 0.23.4)\n./run_docker make\ncp ../../../requirements.txt .\n./run_docker \"source emsdk/emsdk/emsdk_env.sh && pyodide build -r requirements.txt --outdir grist-packages\"\n./run_docker pyodide py-compile grist-packages\ncd ../..\n\necho \"\"\necho \"###############################################################\"\necho \"## Copy out python packages\"\n\nrm -rf _build/packages/\nnode ./preparePackages.js _build/pyodide/grist-packages/ _build/packages/\n"
  },
  {
    "path": "sandbox/pyodide/package.json",
    "content": "{\n  \"type\": \"commonjs\"\n}\n"
  },
  {
    "path": "sandbox/pyodide/package_filenames.json",
    "content": "[\n  \"astroid-2.14.2-cp311-none-any.whl\",\n  \"asttokens-2.4.0-cp311-none-any.whl\",\n  \"chardet-5.1.0-cp311-none-any.whl\",\n  \"et_xmlfile-1.0.1-cp311-none-any.whl\",\n  \"executing-1.1.1-cp311-none-any.whl\",\n  \"friendly_traceback-0.7.48-cp311-none-any.whl\",\n  \"iso8601-0.1.12-cp311-none-any.whl\",\n  \"lazy_object_proxy-1.12.0-cp311-cp311-emscripten_3_1_32_wasm32.whl\",\n  \"openpyxl-3.0.10-cp311-none-any.whl\",\n  \"phonenumberslite-8.12.57-cp311-none-any.whl\",\n  \"pure_eval-0.2.2-cp311-none-any.whl\",\n  \"python_dateutil-2.8.2-cp311-none-any.whl\",\n  \"roman-3.3-cp311-none-any.whl\",\n  \"six-1.17.0-cp311-none-any.whl\",\n  \"sortedcontainers-2.4.0-cp311-none-any.whl\",\n  \"stack_data-0.5.1-cp311-none-any.whl\",\n  \"typing_extensions-4.4.0-cp311-none-any.whl\",\n  \"unittest_xml_reporting-2.0.0-cp311-none-any.whl\",\n  \"wrapt-1.15.0-cp311-none-any.whl\"\n]"
  },
  {
    "path": "sandbox/pyodide/packages.js",
    "content": "const path = require(\"path\");\nconst fs = require(\"fs\");\n\nasync function listLibs(src) {\n  const txt = fs.readFileSync(path.join(__dirname, \"..\", \"requirements.txt\"), \"utf8\");\n  const libs = {};\n  for (const line of txt.split(/\\r?\\n/)) {\n    const raw = line.split(\"#\")[0];\n    if (!raw.includes(\"==\")) { continue; }\n    const [name, version] = line.split(\"==\");\n    libs[name] = version;\n  }\n  const hits = [];\n  const misses = [];\n  const toLoad = [];\n  const material = fs.readdirSync(src);\n  for (const [lib, version] of Object.entries(libs)) {\n    const nlib = lib.replace(/-/g, \"_\");\n    const info = {\n      name: lib,\n      standardName: nlib,\n      version: version,\n    };\n    try {\n      const found = material.filter(m => m.startsWith(`${nlib}-${version}-`));\n      if (found.length !== 1) {\n        throw new Error(\"did not find 1\");\n      }\n      const fname = found[0];\n      info.fullName = path.join(src, fname);\n      info.fileName = fname;\n      toLoad.push(info);\n      hits.push(lib);\n    } catch (e) {\n      misses.push(info);\n    }\n  }\n  return {\n    available: toLoad,\n    misses,\n  };\n}\nexports.listLibs = listLibs;\n"
  },
  {
    "path": "sandbox/pyodide/pipe.js",
    "content": "const path = require(\"path\");\nconst fs = require(\"fs\");\n\nconst { loadPyodide } = require(\"./_build/worker/node_modules/pyodide\");\nconst { listLibs } = require(\"./packages\");\n\nconst isDeno = typeof Deno !== \"undefined\";\n\nconst INCOMING_FD = isDeno ? 0 : 4;\nconst OUTGOING_FD = isDeno ? 1 : 5;\n\nclass GristPipe {\n  constructor() {\n    this.pyodide = null;\n    this.incomingBuffer = Buffer.alloc(65536);\n    this.addedBlob = false;\n    this.adminMode = false;\n  }\n\n  async init() {\n    const self = this;\n    this.setAdminMode(true);\n    this.pyodide = await loadPyodide({\n      jsglobals: {\n        Object: {},\n        setTimeout: function(code, delay) {\n          if (self.adminMode) {\n            setTimeout(code, delay);\n            // Seems to be OK not to return anything, so we don't.\n          } else {\n            throw new Error(\"setTimeout not available\");\n          }\n        },\n        sendFromSandbox: (data) => {\n          return fs.writeSync(OUTGOING_FD, Buffer.from(data.toJs()));\n        }\n      },\n      packageCacheDir: fs.realpathSync(path.join(__dirname, \"_build\", \"cache\")),\n    });\n    this.setAdminMode(false);\n    this.pyodide.setStdin({\n      stdin: () => {\n        const result = fs.readSync(INCOMING_FD, this.incomingBuffer, 0,\n          this.incomingBuffer.byteLength);\n        if (result > 0) {\n          const buf = Buffer.allocUnsafe(result, 0, 0, result);\n          this.incomingBuffer.copy(buf);\n          return buf;\n        }\n        return null;\n      },\n    });\n    this.pyodide.setStderr({\n      batched: (data) => {\n        this.log(\"[py]\", data);\n      }\n    });\n  }\n\n  async loadCode() {\n    // Load python packages.\n    const src = path.join(__dirname, \"_build\", \"packages\");\n    const lsty = (await listLibs(src)).available.map(item => item.fullName);\n    await this.pyodide.loadPackage(lsty, {\n      messageCallback: (msg) => this.log(\"[package]\", msg),\n    });\n\n    // Load Grist data engine code.\n    // We mount it as /grist_src, copy to /grist, then unmount.\n    await this.copyFiles(path.join(__dirname, \"../grist\"), \"/grist_src\", \"/grist\");\n  }\n\n  async mountImportDirIfNeeded() {\n    if (process.env.IMPORTDIR) {\n      this.log(\"Setting up import from\", process.env.IMPORTDIR);\n      // All imports for a given doc live in the same root directory.\n      // Copying import files in and dropping read permissions isn't\n      // workable in that case, since the same sandbox is re-used for\n      // subsequent imports. The files would only be copied once on\n      // startup, meaning the files for these later imports won't be\n      // available to Pyodide, resulting in errors.\n      await this.pyodide.FS.mkdir(\"/import\");\n      await this.pyodide.FS.mount(this.pyodide.FS.filesystems.NODEFS, {\n        root: process.env.IMPORTDIR,\n      }, \"/import\");\n    }\n  }\n\n  async runCode() {\n    await this.pyodide.runPython(`\n  import sys\n  sys.path.append('/')\n  sys.path.append('/grist')\n  import grist\n  import main\n  import os\n  os.environ['PIPE_MODE'] = 'pyodide'\n  os.environ['IMPORTDIR'] = '/import'\n  main.main()\n`);\n  }\n\n  async copyFiles(srcDir, tmpDir, destDir) {\n    // Load file system data.\n    // We mount it as tmpDir, copy to destDir, then unmount.\n    // Note that path to source must be a realpath.\n    const root = fs.realpathSync(srcDir);\n    await this.pyodide.FS.mkdir(tmpDir);\n    // careful, needs to be a realpath\n    await this.pyodide.FS.mount(this.pyodide.FS.filesystems.NODEFS, { root }, tmpDir);\n    // Now want to copy tmpDir to destDir.\n    // For some reason shutil.copytree doesn't work on Windows in this situation, so\n    // we reimplement it crudely.\n    await this.pyodide.runPython(`\nimport os, shutil\ndef copytree(src, dst):\n  os.makedirs(dst, exist_ok=True)\n  for item in os.listdir(src):\n    s = os.path.join(src, item)\n    d = os.path.join(dst, item)\n    if os.path.isdir(s):\n      copytree(s, d)\n    else:\n      shutil.copy2(s, d)\ncopytree('${tmpDir}', '${destDir}')`);\n    await this.pyodide.FS.unmount(tmpDir);\n    await this.pyodide.FS.rmdir(tmpDir);\n  }\n\n  setAdminMode(active) {\n    this.adminMode = active;\n    // Lack of Blob may result in a message on console.log that hurts us.\n    if (active && !globalThis.Blob) {\n      globalThis.Blob = String;\n      this.addedBlob = true;\n    }\n    if (!active && this.addedBlob) {\n      delete globalThis.Blob;\n      this.addedBlob = false;\n    }\n  }\n\n  log(...args) {\n    console.error(\"[pyodide sandbox]\", ...args);\n  }\n}\n\nasync function main() {\n  try {\n    const pipe = new GristPipe();\n    await pipe.init();\n    await pipe.loadCode();\n    await pipe.mountImportDirIfNeeded();\n\n    if (isDeno) {\n      // Revoke write permissions now that packages are loaded.\n      // eslint-disable-next-line no-undef\n      await Deno.permissions.revoke({ name: \"write\" });\n\n      // Read access has been limited quite a lot already.\n      // We need to keep access to the import directory, but can shed\n      // everything else. See --allow-read in SandboxPyodide.ts\n      const readDir = fs.realpathSync(__dirname);\n      const gristDir = fs.realpathSync(path.join(__dirname, \"..\", \"grist\"));\n      const reqFile = fs.realpathSync(path.join(__dirname, \"..\", \"requirements.txt\"));\n      for (const dir of [readDir, gristDir, reqFile]) {\n        // eslint-disable-next-line no-undef\n        await Deno.permissions.revoke({\n          name: \"read\",\n          path: dir\n        });\n      }\n      console.error(\"[pyodide sandbox]\", \"revoked read and write permissions.\");\n    }\n\n    await pipe.runCode();\n  } finally {\n    process.stdin.removeAllListeners();\n  }\n}\n\nmain().catch(err => console.error(\"[pyodide error]\", err));\n"
  },
  {
    "path": "sandbox/pyodide/preparePackages.js",
    "content": "const fs = require(\"fs\");\nconst fetch = require(\"node-fetch\");\nconst path = require(\"path\");\n\nconst {listLibs} = require(\"./packages\");\n\nasync function findOnDisk(src, dest) {\n  console.log(`Organizing packages on disk`, {src, dest});\n  fs.mkdirSync(dest, {recursive: true});\n  let libs = (await listLibs(src));\n  for (const lib of libs.available) {\n    fs.copyFileSync(lib.fullName, path.join(dest, lib.fileName));\n    fs.writeFileSync(path.join(dest, `${lib.name}-${lib.version}.json`),\n      JSON.stringify({\n        name: lib.name,\n        version: lib.version,\n        fileName: lib.fileName,\n      }, null, 2));\n    console.log(\"Copied\", {\n      content: path.join(dest, lib.fileName),\n      meta: path.join(dest, `${lib.name}-${lib.version}.json`),\n    });\n  }\n  libs = await listLibs(dest);\n  fs.writeFileSync(path.join(__dirname, `package_filenames.json`),\n    JSON.stringify(libs.available.map(lib => lib.fileName), null, 2));\n  console.log(`Cached`, {libs: libs.available.map(lib => lib.name)});\n  console.log(`Missing`, {libs: libs.misses.map(lib => lib.name)});\n}\n\nasync function findOnNet(src, dest) {\n  console.log(`Caching packages on disk`, {src, dest});\n  fs.mkdirSync(dest, {recursive: true});\n  let libs = await listLibs(dest);\n  console.log(`Cached`, {libs: libs.available.map(lib => lib.name)});\n  for (const lib of libs.misses) {\n    console.log(\"Fetching\", lib);\n    const url = new URL(src);\n    url.pathname = url.pathname + lib.name + \"-\" + lib.version + \".json\";\n    const result = await fetch(url.href);\n    if (result.status === 200) {\n      const data = await result.json();\n      const url2 = new URL(src);\n      url2.pathname = url2.pathname + data.fileName;\n      const result2 = await fetch(url2.href);\n      if (result2.status === 200) {\n        fs.writeFileSync(path.join(dest, `${lib.name}-${lib.version}.json`),\n          JSON.stringify(data, null, 2));\n        fs.writeFileSync(path.join(dest, data.fileName),\n          await result2.buffer());\n      } else {\n        console.error(\"No payload available\", {lib});\n      }\n    } else {\n      console.error(\"No metadata available\", {lib});\n    }\n  }\n  libs = await listLibs(dest);\n  console.log(`Missing`, {libs: libs.misses.map(lib => lib.name)});\n}\n\nasync function main(src, dest) {\n  if (!src) {\n    console.error(\"please supply a source\");\n    process.exit(1);\n  }\n  if (!dest) {\n    console.error(\"please supply a destination\");\n    process.exit(1);\n  }\n  if (src.startsWith(\"http:\") || src.startsWith(\"https:\")) {\n    await findOnNet(src, dest);\n    return;\n  }\n  await findOnDisk(src, dest);\n}\n\nif (require.main === module) {\n  main(...process.argv.slice(2)).catch(e => console.error(e));\n}\n"
  },
  {
    "path": "sandbox/pyodide/setup.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\necho \"\"\necho \"###############################################################\"\necho \"## Get pyodide node package\"\n\nif [[ ! -e _build/worker ]]; then\n  mkdir -p _build/worker\n  cd _build/worker\n  yarn init --yes\n  touch yarn.lock\n  yarn add pyodide@0.23.4\n  yarn add deno@2.6.3\n  cd ../..\nfi\n\n# Warn if install is old.\nif [[ ! -e _build/worker/node_modules/deno ]]; then\n  echo \"Deno not present in worker packages.\"\n  echo \"Deno is now required.\"\n  echo \"please delete _build/worker and retry.\"\n  exit 1\nfi\n\n# Need an area for pyodide package cache.\nmkdir -p _build/cache\n"
  },
  {
    "path": "sandbox/requirements.in",
    "content": "# python requirements,\n# From this, \"./build python3\" generates requirements.txt by running pip-compile.\n\nfriendly-traceback\nopenpyxl\nastroid\nroman\nchardet\niso8601\nphonenumberslite\npython-dateutil\nsortedcontainers\nunittest-xml-reporting\ntyping-extensions  # used by astroid before Python 3.11\n"
  },
  {
    "path": "sandbox/requirements.txt",
    "content": "#\n# This file is autogenerated by pip-compile with Python 3.9\n# by the following command:\n#\n#    pip-compile --output-file=core/sandbox/requirements.txt core/sandbox/requirements.in\n#\nastroid==2.14.2\n    # via -r core/sandbox/requirements.in\nasttokens==2.4.0\n    # via\n    #   friendly-traceback\n    #   stack-data\nchardet==5.1.0\n    # via -r core/sandbox/requirements.in\net-xmlfile==1.0.1\n    # via openpyxl\nexecuting==1.1.1\n    # via\n    #   friendly-traceback\n    #   stack-data\nfriendly-traceback==0.7.48\n    # via -r core/sandbox/requirements.in\niso8601==0.1.12\n    # via -r core/sandbox/requirements.in\nlazy-object-proxy==1.12.0\n    # via astroid\nopenpyxl==3.0.10\n    # via -r core/sandbox/requirements.in\nphonenumberslite==8.12.57\n    # via -r core/sandbox/requirements.in\npure-eval==0.2.2\n    # via\n    #   friendly-traceback\n    #   stack-data\npython-dateutil==2.8.2\n    # via -r core/sandbox/requirements.in\nroman==3.3\n    # via -r core/sandbox/requirements.in\nsix==1.17.0\n    # via\n    #   asttokens\n    #   friendly-traceback\n    #   python-dateutil\n    #   unittest-xml-reporting\nsortedcontainers==2.4.0\n    # via -r core/sandbox/requirements.in\nstack-data==0.5.1\n    # via friendly-traceback\ntyping-extensions==4.4.0\n    # via\n    #   -r core/sandbox/requirements.in\n    #   astroid\nunittest-xml-reporting==2.0.0\n    # via -r core/sandbox/requirements.in\nwrapt==1.15.0\n    # via astroid\n"
  },
  {
    "path": "sandbox/run.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nif [[ \"$GRIST_SANDBOX_FLAVOR\" = \"gvisor\" ]]; then\n  source ./sandbox/gvisor/get_checkpoint_path.sh\n\n  # Check GVISOR_FLAGS we ended up with. Don't ignore the output, it may be helpful in troubleshooting.\n  if runsc --network none $GVISOR_FLAGS \"do\" true; then\n    echo \"gvisor check ok (flags: ${GVISOR_FLAGS})\"\n  else\n    echo \"gvisor check failed (flags: ${GVISOR_FLAGS}); consider different GVISOR_FLAGS or GRIST_SANDBOX_FLAVOR\"\n    exit 1\n  fi\n\n  ./sandbox/gvisor/update_engine_checkpoint.sh\nfi\n\nexec env NODE_PATH=_build:_build/ext:_build/stubs node _build/stubs/app/server/server.js\n"
  },
  {
    "path": "sandbox/setup.py",
    "content": "# see bundle_as_wheel.sh\n\nfrom setuptools import setup\nimport glob\n\nfiles = glob.glob('grist/*.py') + glob.glob('grist/**/*.py')\nnames = [f.split('.py')[0] for f in files]\n\nsetup(name='grist',\n      version='1.0',\n      include_package_data=True,\n      packages=['grist', 'grist/functions', 'grist/imports'],\n      package_data={\n          'grist': ['grist/tzdata.data'],\n      })\n"
  },
  {
    "path": "sandbox/supervisor.mjs",
    "content": "import {spawn} from \"child_process\";\n\nlet grist;\n\nfunction startGrist(newConfig={}) {\n  // Printing the user helps with setting volume permissions if\n  // using a container.\n  const uid = process.getuid();\n  const gid = process.getgid();\n  console.log(`Running Grist as user ${uid} with primary group ${gid}`);\n\n  // H/T https://stackoverflow.com/a/36995148/11352427\n  grist = spawn(\"./sandbox/run.sh\", {\n    stdio: [\"inherit\", \"inherit\", \"inherit\", \"ipc\"],\n    env: {...process.env, GRIST_RUNNING_UNDER_SUPERVISOR: true}\n  });\n  grist.on(\"message\", function(data) {\n    if (data.action === \"restart\") {\n      console.log(\"Restarting Grist with new configuration\");\n\n      // Note that we only set this event handler here, after we have\n      // a new environment to reload with. Small chance of a race here\n      // in case something else sends a SIGINT before we do it\n      // ourselves further below.\n      grist.on(\"exit\", () => {\n        grist = startGrist(data.newConfig);\n      });\n\n      grist.kill(\"SIGINT\");\n    }\n  });\n  return grist;\n}\n\nstartGrist();\n"
  },
  {
    "path": "sandbox/watch.sh",
    "content": "#!/usr/bin/env bash\n\nset -x\n\nNO_NODEMON=false\nfor arg in $@; do\n  if [[ $arg == \"--no-nodemon\" ]]; then\n    NO_NODEMON=true\n  fi\ndone\n\nPROJECT=\"\"\nif [[ -e ext/app ]]; then\n  PROJECT=\"tsconfig-ext.json\"\nfi\nWEBPACK_CONFIG=buildtools/webpack.config.js\nif [[ -e ext/buildtools/webpack.config.js ]]; then\n  WEBPACK_CONFIG=ext/buildtools/webpack.config.js\nfi\n\nif [ ! -e _build ]; then\n  buildtools/build.sh\nfi\n\ntsc --build -w --preserveWatchOutput $PROJECT &\ncss_files=\"app/client/**/*.css\"\nchokidar \"${css_files}\" -c \"bash -O globstar -c 'cat ${css_files} > static/bundle.css'\" &\nwebpack --config $WEBPACK_CONFIG --mode development --watch &\n! $NO_NODEMON && NODE_PATH=_build:_build/ext:_build/stubs nodemon ${NODE_INSPECT} --delay 1 -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js &\n\nwait\n"
  },
  {
    "path": "static/apiconsole.html",
    "content": "<!doctype html>\n<html>\n<head>\n  <meta charset=\"utf8\">\n  <!-- INSERT META -->\n  <!-- INSERT BASE -->\n  <link rel=\"icon\" type=\"image/x-icon\" href=\"icons/favicon.png\" />\n  <link rel=\"stylesheet\" href=\"icons/icons.css\">\n  <!-- INSERT LOCALE -->\n  <!-- INSERT CONFIG -->\n  <!-- INSERT CUSTOM -->\n  <title>API Console<!-- INSERT TITLE SUFFIX --></title>\n</head>\n<body>\n  <!-- INSERT WARNING -->\n  <script crossorigin=\"anonymous\" src=\"apiconsole.bundle.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "static/app.html",
    "content": "<!doctype html>\n<html>\n<head>\n<meta charset=\"utf8\">\n<!-- INSERT META -->\n\n<!-- INSERT BASE -->\n\n<link rel=\"icon\" type=\"image/x-icon\" href=\"icons/favicon.png\" />\n\n<link rel=\"stylesheet\" href=\"jqueryui/themes/smoothness/jquery-ui.css\">\n<link rel=\"stylesheet\" href=\"hljs.default.css\">\n<link rel=\"stylesheet\" href=\"bootstrap-datepicker/dist/css/bootstrap-datepicker3.min.css\">\n<link rel=\"stylesheet\" href=\"bundle.css\">\n<link rel=\"stylesheet\" href=\"icons/icons.css\">\n<!-- INSERT LOCALE -->\n<!-- INSERT CONFIG -->\n<!-- INSERT CUSTOM -->\n<!-- INSERT CUSTOM SCRIPT -->\n\n<title><!-- INSERT TITLE --><!-- INSERT TITLE SUFFIX --></title>\n</head>\n<body>\n  <!-- INSERT WARNING -->\n  <div id='grist-logo-wrapper'>\n    <div class='grist-logo'>\n      <div class='grist-logo-head'>\n        <div class='grist-logo-grain grain-empty'></div>\n        <div class='grist-logo-grain grain-col grain-flip grain-2'></div>\n        <div class='grist-logo-grain grain-col grain-3'></div>\n      </div>\n      <div class='grist-logo-row'>\n        <div class='grist-logo-grain grain-row grain-flip grain-4'></div>\n        <div class='grist-logo-grain grain-cell grain-flip grain-5'></div>\n        <div class='grist-logo-grain grain-cell grain-6'></div>\n      </div>\n      <div class='grist-logo-row'>\n        <div class='grist-logo-grain grain-row grain-flip grain-7'></div>\n        <div class='grist-logo-grain grain-cell grain-flip grain-8'></div>\n        <div class='grist-logo-grain grain-cell grain-9'></div>\n      </div>\n    </div>\n  </div>\n\n  <div id=\"browser-check-problem\" style=\"display: none;\">\n    <table class=\"browser-check-wrapper\"><tr>\n      <td class=\"browser-check-message\">\n        <span class=\"browser-check-desktop\">\n          Grist works best on modern Firefox or Chrome.\n        </span>\n        <span class=\"browser-check-mobile\">\n          Grist mobile works best on modern Firefox, Chrome, or Safari.\n        </span>\n        <a href=\"https://support.getgrist.com/browser-support\" target=\"_blank\">Learn more</a>\n      </td>\n      <td>\n        <div class=\"browser-check-close\" id=\"browser-check-problem-dismiss\">Dismiss</div>\n      </td>\n    </tr></table>\n  </div>\n\n  <script src=\"jquery/dist/jquery.min.js\" crossorigin=\"anonymous\"></script>\n  <script src=\"jqueryui/jquery-ui.min.js\" crossorigin=\"anonymous\"></script>\n  <script src=\"bootstrap-datepicker/dist/js/bootstrap-datepicker.min.js\" crossorigin=\"anonymous\"></script>\n  <script src=\"main.bundle.js\" crossorigin=\"anonymous\"></script>\n  <script type=\"application/javascript\" src=\"browser-check.js\" crossorigin=\"anonymous\"></script>\n\n</body>\n</html>\n"
  },
  {
    "path": "static/custom-widget.html",
    "content": "<!doctype html>\n<html>\n\n<head>\n  <meta charset=\"utf8\">\n  <title>Custom widget</title>\n  <script src=\"/grist-plugin-api.js\"></script>\n  <script>\n    grist.ready();\n    grist.enableKeyboardShortcuts();\n  </script>\n  <style>\n    body {\n      margin: 0;\n      font-size: 13px;\n      color: var(--grist-theme-text, #262633);\n      font-family: -apple-system, BlinkMacSystemFont, Segoe UI,\n        Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;\n    }\n\n    .title {\n      font-size: 20px;\n      font-weight: 600;\n    }\n\n    .title-light {\n      font-size: 16px;\n      font-weight: 400;\n      color: var(--grist-theme-text-light, #929299);\n    }\n\n    a {\n      color: var(--grist-theme-link, #16B378);\n      text-decoration: none;\n    }\n\n    a:hover, a:focus {\n      text-decoration: underline;\n    }\n  </style>\n</head>\n\n<body>\n  <div style=\"padding: 20px\">\n    <div>\n      <span class=\"title\">Custom widget</span>\n      <span class=\"title-light\">(not configured)</span>\n    </div>\n    <p>\n      The Custom widget allows a user to insert almost anything in their document.\n      Creating a custom widget currently requires knowledge of web development,\n      and access to a public web server (for example, GitHub Pages).\n    </p>\n    <p>\n      To configure this widget, open the right panel by clicking <i>Widget options</i> in the section menu.\n    </p>\n    <p>\n      Learn more about Custom Widgets at:\n      <a target=\"_blank\" href=\"https://support.getgrist.com/widget-custom\">\n        https://support.getgrist.com/widget-custom\n      </a>\n    </p>\n  </div>\n</body>\n\n</html>\n"
  },
  {
    "path": "static/custom.css",
    "content": "/*\n  Are you getting a console warning about deprecated usage? Please see the migration guide at the end of this file.\n\n  Otherwise, you can copy and paste this file and use it as a base for your customization needs.\n*/\n@layer grist-custom {\n  /* theme-agnostic overrides\n   *\n   * These are always applied regardless of the current theme.\n   */\n  :root {\n    /* logo */\n    --icon-GristLogo: url(\"ui-icons/Logo/GristLogo.svg\");\n    --grist-theme-logo-bg: #040404;\n    --grist-theme-logo-size: 22px 22px;\n  }\n\n  /*\n   * theme-dependent overrides\n   *\n   * You can filter your CSS rules with the [data-grist-appearance] and [data-grist-theme] attributes to apply rules conditionally:\n   *  - [data-grist-appearance] is the current appearance (light or dark)\n   *  - [data-grist-theme] is the current theme (GristLight, GristDark, or HighContrastLight)\n   */\n  :root[data-grist-theme=\"GristLight\"] {\n    /* colors */\n    --grist-theme-body: #262633; /* main body text */\n    --grist-theme-emphasis: var(--grist-theme-black); /* pronounced text */\n    --grist-theme-very-light: var(--grist-theme-white); /* text that is always light, whatever the current appearance (light or dark theme) */\n\n    --grist-theme-bg-default: var(--grist-theme-white); /* default body bg color */\n    --grist-theme-bg-secondary: #f7f7f7; /* bg color mostly used on panels */\n    --grist-theme-bg-tertiary: #d9d9d999; /* transparent bg, mostly used on hover effects */\n    --grist-theme-bg-emphasis: #262633; /* pronounced bg color, mostly used on selected items */\n\n    --grist-theme-decoration: #d9d9d9; /* main decoration color, mostly used on borders */\n    --grist-theme-decoration-secondary: #e8e8e8; /* less pronounced decoration color */\n    --grist-theme-decoration-tertiary: #d9d9d9; /* even less pronounced decoration color */\n\n    --grist-theme-primary: #16b378; /* main accent color used mostly on interactive elements */\n    --grist-theme-primary-muted: #009058; /* alternative primary color, mostly used on hover effects */\n    --grist-theme-primary-dim: #007548; /* dimmer primary color, rarely used */\n    --grist-theme-primary-emphasis: #b1ffe2; /* more pronounced primary color variant, rarely used  */\n    /*\n      ⚠ Warning when changing the primary color ⚠\n      Some colors used in themes are not yet defined in css variables.\n      When changing the primary color, please refer to app/common/themes/{GristDark,GristLight}.ts files\n      to see what theme tokens would need specific overrides.\n      Check app/common/ThemePrefs.ts to retrieve the css variable names matching the token names.\n      Contributions to make this process easier are welcome!\n    */\n\n    /* \n      secondary color, used on elements like less visually pronounced text and non-primary or\n      disabled controls\n    */\n    --grist-theme-secondary: #929299;\n    --grist-theme-secondary-muted: #777777; /* alternative secondary color, mostly used on hover effects */\n\n    --grist-theme-token-cursor: var(--grist-theme-primary); /* cursor color in widgets */\n    --grist-theme-token-cursor-inactive: #a2e1c9;\n    --grist-theme-token-selection: #16b37826; /* transparent background of selected cells */\n    --grist-theme-token-selection-opaque: #dcf4eb;\n    --grist-theme-token-selection-darker-opaque: #d6eee5;\n    --grist-theme-token-selection-darker: #16b37840;\n    --grist-theme-token-selection-darkest: #16b37859;\n\n    --grist-theme-token-hover: #bfbfbf; /* non-transparent hover effect color, rarely used */\n    --grist-theme-backdrop: #262633e5; /* transparent modal backdrop bg color */\n\n    --grist-theme-error: #d0021b;\n    --grist-theme-error-light: #f66;\n    --grist-theme-warning: #dd962c;\n    --grist-theme-warning-light: #f9ae41;\n    --grist-theme-info: #3b82f6;\n    --grist-theme-info-light: #87b2f9;\n    --grist-theme-black: #000;\n    --grist-theme-white: #fff;\n\n    /*\n      ℹ There are quite a lot of other css variables available for fine-tuning.\n      See app/common/ThemePrefs.ts and app/common/themes/{Base,GristDark,GristLight}.ts\n      for the exhaustive list and default values.\n    */\n  }\n}\n\n/*\n  Migration guide to new \"--grist-theme-\" prefixed variables.\n\n  If you are using \"--grist-color-\" prefixed variables, you need to upgrade to the new css variable names.\n  To help you migrate, listed below is the exhaustive mapping of the old variables to the new ones:\n\n  --grist-color-light-grey: --grist-theme-bg-secondary\n  --grist-color-medium-grey: --grist-theme-bg-tertiary\n  --grist-color-medium-grey-opaque: --grist-theme-decoration-secondary\n  --grist-color-dark-grey: --grist-theme-decoration\n  --grist-color-light: --grist-theme-white\n  --grist-color-dark: --grist-theme-body\n  --grist-color-dark-bg: --grist-theme-bg-emphasis\n  --grist-color-slate: --grist-theme-secondary\n  --grist-color-lighter-green: --grist-theme-primary-emphasis\n  --grist-color-light-green: --grist-theme-primary\n  --grist-color-dark-green: --grist-theme-primary-muted\n  --grist-color-darker-green: --grist-theme-primary-dim\n  --grist-color-lighter-blue: --grist-theme-info-light\n  --grist-color-light-blue: --grist-theme-info\n  --grist-color-orange: --grist-theme-warning-light\n  --grist-color-cursor: --grist-theme-token-cursor\n  --grist-color-selection: --grist-theme-token-selection\n  --grist-color-selection-opaque: --grist-theme-token-selection-opaque\n  --grist-color-selection-darker-opaque: --grist-theme-token-selection-darker-opaque\n  --grist-color-inactive-cursor: --grist-theme-token-cursor-inactive\n  --grist-color-hover: --grist-theme-token-hover\n  --grist-color-error: --grist-theme-error\n  --grist-color-warning: --grist-theme-warning-light\n  --grist-color-warning-bg: --grist-theme-warning\n  --grist-color-backdrop: --grist-theme-backdrop\n  --grist-font-family: --grist-theme-font-family\n  --grist-font-family-data: --grist-theme-font-family-data\n  --grist-xx-font-size: --grist-theme-xx-small-font-size\n  --grist-x-small-font-size: --grist-theme-x-small-font-size\n  --grist-small-font-size: --grist-theme-small-font-size\n  --grist-medium-font-size: --grist-theme-medium-font-size\n  --grist-intro-font-size: --grist-theme-intro-font-size\n  --grist-large-font-size: --grist-theme-large-font-size\n  --grist-x-large-font-size: --grist-theme-x-large-font-size\n  --grist-xx-large-font-size: --grist-theme-xx-large-font-size\n  --grist-xxx-large-font-size: --grist-theme-xxx-large-font-size\n  --grist-big-control-font-size: --grist-theme-big-control-font-size\n  --grist-header-control-font-size: --grist-theme-header-control-font-size\n  --grist-big-text-weight: --grist-theme-big-control-text-weight\n  --grist-header-text-weight: --grist-theme-header-control-text-weight\n  --grist-border-radius: --grist-theme-control-border-radius\n  --grist-logo-bg: --grist-theme-logo-bg\n  --grist-logo-size: --grist-theme-logo-size\n\n  These variables are not used anymore and can be safely removed from your custom css file:\n\n  --grist-color-dark-text\n  --grist-primary-bg\n  --grist-primary-fg\n  --grist-primary-fg-hover\n  --grist-control-font-size\n  --grist-small-control-font-size\n  --grist-label-text-size\n  --grist-label-text-bg\n  --grist-label-active-bg\n  --grist-normal-margin\n  --grist-normal-padding\n  --grist-tight-padding\n  --grist-loose-padding\n  --grist-control-border\n  --grist-toast-bg\n*/\n"
  },
  {
    "path": "static/error.html",
    "content": "<!doctype html>\n<html>\n<head>\n  <meta charset=\"utf8\">\n  <meta name=\"robots\" content=\"noindex\">\n  <!-- INSERT BASE -->\n  <link rel=\"icon\" type=\"image/x-icon\" href=\"icons/favicon.png\" />\n  <link rel=\"stylesheet\" href=\"icons/icons.css\">\n  <!-- INSERT LOCALE -->\n  <!-- INSERT CONFIG -->\n  <!-- INSERT CUSTOM -->\n  <!-- INSERT CUSTOM SCRIPT -->\n  <title>Loading...<!-- INSERT TITLE SUFFIX --></title>\n</head>\n<body>\n  <!-- INSERT ERROR -->\n  <script crossorigin=\"anonymous\" src=\"errorPages.bundle.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "static/form.html",
    "content": "<!doctype html>\n<html>\n<head>\n  <meta charset=\"utf8\">\n  <meta name=\"robots\" content=\"noindex\">\n  <meta name=\"referrer\" content=\"strict-origin-when-cross-origin\">\n  <!-- INSERT META -->\n  <!-- INSERT BASE -->\n  <link rel=\"icon\" type=\"image/x-icon\" href=\"icons/favicon.png\" />\n  <link rel=\"stylesheet\" href=\"icons/icons.css\">\n  <!-- INSERT CUSTOM -->\n  <title>Grist Form<!-- INSERT TITLE SUFFIX --></title>\n</head>\n<body>\n  <!-- INSERT CONFIG -->\n  <script crossorigin=\"anonymous\" src=\"form.bundle.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "static/icons/icons.css",
    "content": "/* This file is auto-generated by buildtools/genIconCSS.ts */\n/* Do not edit it manually. */\n\n@layer grist-base {\n  :root {\n    --icon-ChartArea: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMy4xOTA5ODMwMSw4IEw1LjA1Mjc4NjQsNC4yNzYzOTMyIEM1LjE4NzI4NTQ0LDQuMDA3Mzk1MTIgNS41MjcxMTE4MSwzLjkxNzE0OTMzIDUuNzc3MzUwMSw0LjA4Mzk3NDg1IEwxMS4zMzE2NDU0LDcuNzg2ODM4MzcgTDE1LjA2NTg3ODQsMS4yNTE5MzA1MyBDMTUuMjAyODgzNCwxLjAxMjE3MTgxIDE1LjUwODMxMDcsMC45Mjg4NzM0NDQgMTUuNzQ4MDY5NSwxLjA2NTg3ODQzIEMxNS45ODc4MjgyLDEuMjAyODgzNDEgMTYuMDcxMTI2NiwxLjUwODMxMDc1IDE1LjkzNDEyMTYsMS43NDgwNjk0NyBMMTEuOTM0MTIxNiw4Ljc0ODA2OTQ3IEMxMS43OTAzOTc0LDguOTk5NTg2NzggMTEuNDYzNjgyNyw5LjA3NjcxMzY2IDExLjIyMjY0OTksOC45MTYwMjUxNSBMNS42OTM5MTU3OSw1LjIzMDIwMjQxIEw0LjEzODI4NDI0LDguMzQxNDY1NTEgTDEwLjQ0NTk1MTMsMTIuODQ2OTQyIEwxNS4xNDY0NDY2LDguMTQ2NDQ2NjEgQzE1LjQ2MTQyOSw3LjgzMTQ2NDE4IDE2LDguMDU0NTQ3NTcgMTYsOC41IEwxNiwxNS4wMDAxMDk1IEMxNiwxNS4yNzYyNTMzIDE1Ljc3NjE0MDEsMTUuNTAwMTExNSAxNS40OTk5OTYzLDE1LjUwMDEwOTUgTDAuNDk5OTk2MzQ5LDE1LjUgQzAuMjIzODU1NCwxNS40OTk5OTggMCwxNS4yNzYxNDA5IDAsMTUgTDAsOC41IEMwLDguMjIzODU3NjMgMC4yMjM4NTc2MjUsOCAwLjUsOCBMMy4xOTA5ODMwMSw4IFoiLz48L3N2Zz4=');\n    --icon-ChartBar: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNSwxNC41IEM1LDE0Ljc3NjE0MjQgNC43NzYxNDIzNywxNSA0LjUsMTUgQzQuMjIzODU3NjMsMTUgNCwxNC43NzYxNDI0IDQsMTQuNSBMNCw1LjUgQzQsNS4yMjM4NTc2MyA0LjIyMzg1NzYzLDUgNC41LDUgQzQuNzc2MTQyMzcsNSA1LDUuMjIzODU3NjMgNSw1LjUgTDUsMTQuNSBaIE04LDE0LjUgQzgsMTQuNzc2MTQyNCA3Ljc3NjE0MjM3LDE1IDcuNSwxNSBDNy4yMjM4NTc2MywxNSA3LDE0Ljc3NjE0MjQgNywxNC41IEw3LDguNSBDNyw4LjIyMzg1NzYzIDcuMjIzODU3NjMsOCA3LjUsOCBDNy43NzYxNDIzNyw4IDgsOC4yMjM4NTc2MyA4LDguNSBMOCwxNC41IFogTTE0LDE0LjUgQzE0LDE0Ljc3NjE0MjQgMTMuNzc2MTQyNCwxNSAxMy41LDE1IEMxMy4yMjM4NTc2LDE1IDEzLDE0Ljc3NjE0MjQgMTMsMTQuNSBMMTMsOC41IEMxMyw4LjIyMzg1NzYzIDEzLjIyMzg1NzYsOCAxMy41LDggQzEzLjc3NjE0MjQsOCAxNCw4LjIyMzg1NzYzIDE0LDguNSBMMTQsMTQuNSBaIE0xMSwxNC41IEMxMSwxNC43NzYxNDI0IDEwLjc3NjE0MjQsMTUgMTAuNSwxNSBDMTAuMjIzODU3NiwxNSAxMCwxNC43NzYxNDI0IDEwLDE0LjUgTDEwLDUuNSBDMTAsNS4yMjM4NTc2MyAxMC4yMjM4NTc2LDUgMTAuNSw1IEMxMC43NzYxNDI0LDUgMTEsNS4yMjM4NTc2MyAxMSw1LjUgTDExLDE0LjUgWiBNMiwxNC41IEMyLDE0Ljc3NjE0MjQgMS43NzYxNDIzNywxNSAxLjUsMTUgQzEuMjIzODU3NjMsMTUgMSwxNC43NzYxNDI0IDEsMTQuNSBMMSwyLjUgQzEsMi4yMjM4NTc2MyAxLjIyMzg1NzYzLDIgMS41LDIgQzEuNzc2MTQyMzcsMiAyLDIuMjIzODU3NjMgMiwyLjUgTDIsMTQuNSBaIi8+PC9zdmc+');\n    --icon-ChartDonut: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48Y2lyY2xlIGN4PSI4IiBjeT0iOCIgcj0iNy41IiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMWMxMzAzO3N0cm9rZS13aWR0aDoxO3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2UtZGFzaGFycmF5Om5vbmUiLz48Y2lyY2xlIGN4PSI4IiBjeT0iOCIgcj0iNSIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6IzFjMTMwMztzdHJva2Utd2lkdGg6MTtzdHJva2Utb3BhY2l0eToxO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lIi8+PHBhdGggZD0iTTcuODg0MDYxOC41OTk4NzQyN1YzLjM0MjE1NjdNMTEuNjExODUyIDExLjUyNjE1NmwxLjg4NTMxOSAxLjg4NTMxOSIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6MXB4O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1vcGFjaXR5OjEiLz48L2c+PC9zdmc+');\n    --icon-ChartKaplan: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNyIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNi4xNzEyODAyOCwxLjQwODMxODAxIEwwLjkxMzQ5NDgxLDEuNDA4MzE4MDEgQzAuNjY4ODg0MjYzLDEuNDA4MzE4MDEgMC40NzA1ODgyMzUsMS4xOTc2Mjg0OSAwLjQ3MDU4ODIzNSwwLjkzNzcyOTc3OSBDMC40NzA1ODgyMzUsMC42Nzc4MzEwNzQgMC42Njg4ODQyNjMsMC40NjcxNDE1NDQgMC45MTM0OTQ4MSwwLjQ2NzE0MTU0NCBMNy4wNTcwOTM0MywwLjQ2NzE0MTU0NCBDNy4zMzMyMzU4LDAuNDY3MTQxNTQ0IDcuNTU3MDkzNDMsMC42OTA5OTkxNjkgNy41NTcwOTM0MywwLjk2NzE0MTU0NCBMNy41NTcwOTM0MywyLjc5MDY3MDk2IEM3LjU1NzA5MzQzLDMuMDY2ODEzMzMgNy43ODA5NTEwNSwzLjI5MDY3MDk2IDguMDU3MDkzNDMsMy4yOTA2NzA5NiBMMTEuNDg2MTU5MiwzLjI5MDY3MDk2IEMxMS43NjIzMDE1LDMuMjkwNjcwOTYgMTEuOTg2MTU5MiwzLjUxNDUyODU4IDExLjk4NjE1OTIsMy43OTA2NzA5NiBMMTEuOTg2MTU5Miw1LjYxNDIwMDM3IEMxMS45ODYxNTkyLDUuODkwMzQyNzQgMTIuMjEwMDE2OCw2LjExNDIwMDM3IDEyLjQ4NjE1OTIsNi4xMTQyMDAzNyBMMTUuMDg2NTA1Miw2LjExNDIwMDM3IEMxNS4zMzExMTU3LDYuMTE0MjAwMzcgMTUuNTI5NDExOCw2LjMyNDg4OTkgMTUuNTI5NDExOCw2LjU4NDc4ODYgQzE1LjUyOTQxMTgsNi44NDQ2ODczMSAxNS4zMzExMTU3LDcuMDU1Mzc2ODQgMTUuMDg2NTA1Miw3LjA1NTM3Njg0IEwxMS42MDAzNDYsNy4wNTUzNzY4NCBDMTEuMzI0MjAzNiw3LjA1NTM3Njg0IDExLjEwMDM0Niw2LjgzMTUxOTIxIDExLjEwMDM0Niw2LjU1NTM3Njg0IEwxMS4xMDAzNDYsNC43MzE4NDc0MyBDMTEuMTAwMzQ2LDQuNDU1NzA1MDUgMTAuODc2NDg4NCw0LjIzMTg0NzQzIDEwLjYwMDM0Niw0LjIzMTg0NzQzIEw3LjE3MTI4MDI4LDQuMjMxODQ3NDMgQzYuODk1MTM3OSw0LjIzMTg0NzQzIDYuNjcxMjgwMjgsNC4wMDc5ODk4IDYuNjcxMjgwMjgsMy43MzE4NDc0MyBMNi42NzEyODAyOCwxLjkwODMxODAxIEM2LjY3MTI4MDI4LDEuNjMyMTc1NjQgNi40NDc0MjI2NSwxLjQwODMxODAxIDYuMTcxMjgwMjgsMS40MDgzMTgwMSBaIE0yLjc5NDExNzY1LDMuMjkwNjcwOTYgTDAuOTQxMTc2NDcxLDMuMjkwNjcwOTYgQzAuNjgxMjc3NzY1LDMuMjkwNjcwOTYgMC40NzA1ODgyMzUsMy4wNzk5ODE0MyAwLjQ3MDU4ODIzNSwyLjgyMDA4MjcyIEMwLjQ3MDU4ODIzNSwyLjU2MDE4NDAxIDAuNjgxMjc3NzY1LDIuMzQ5NDk0NDkgMC45NDExNzY0NzEsMi4zNDk0OTQ0OSBMMy43MzUyOTQxMiwyLjM0OTQ5NDQ5IEM0LjAxMTQzNjQ5LDIuMzQ5NDk0NDkgNC4yMzUyOTQxMiwyLjU3MzM1MjExIDQuMjM1Mjk0MTIsMi44NDk0OTQ0OSBMNC4yMzUyOTQxMiw3LjQ5NjU1MzMxIEM0LjIzNTI5NDEyLDcuNzcyNjk1NjggNC40NTkxNTE3NCw3Ljk5NjU1MzMxIDQuNzM1Mjk0MTIsNy45OTY1NTMzMSBMMTAuMzIzNTI5NCw3Ljk5NjU1MzMxIEMxMC41OTk2NzE4LDcuOTk2NTUzMzEgMTAuODIzNTI5NCw4LjIyMDQxMDkzIDEwLjgyMzUyOTQsOC40OTY1NTMzMSBMMTAuODIzNTI5NCwxMS4yNjEyNTkyIEMxMC44MjM1Mjk0LDExLjUzNzQwMTYgMTEuMDQ3Mzg3LDExLjc2MTI1OTIgMTEuMzIzNTI5NCwxMS43NjEyNTkyIEwxMy4xNDcwNTg4LDExLjc2MTI1OTIgQzEzLjQyMzIwMTIsMTEuNzYxMjU5MiAxMy42NDcwNTg4LDExLjk4NTExNjggMTMuNjQ3MDU4OCwxMi4yNjEyNTkyIEwxMy42NDcwNTg4LDE1LjA1NTM3NjggQzEzLjY0NzA1ODgsMTUuMzE1Mjc1NSAxMy40MzYzNjkzLDE1LjUyNTk2NTEgMTMuMTc2NDcwNiwxNS41MjU5NjUxIEMxMi45MTY1NzE5LDE1LjUyNTk2NTEgMTIuNzA1ODgyNCwxNS4zMTUyNzU1IDEyLjcwNTg4MjQsMTUuMDU1Mzc2OCBMMTIuNzA1ODgyNCwxMy4yMDI0MzU3IEMxMi43MDU4ODI0LDEyLjkyNjI5MzMgMTIuNDgyMDI0NywxMi43MDI0MzU3IDEyLjIwNTg4MjQsMTIuNzAyNDM1NyBMMTAuMzgyMzUyOSwxMi43MDI0MzU3IEMxMC4xMDYyMTA2LDEyLjcwMjQzNTcgOS44ODIzNTI5NCwxMi40Nzg1NzggOS44ODIzNTI5NCwxMi4yMDI0MzU3IEw5Ljg4MjM1Mjk0LDkuNDM3NzI5NzggQzkuODgyMzUyOTQsOS4xNjE1ODc0IDkuNjU4NDk1MzIsOC45Mzc3Mjk3OCA5LjM4MjM1Mjk0LDguOTM3NzI5NzggTDMuNzk0MTE3NjUsOC45Mzc3Mjk3OCBDMy41MTc5NzUyNyw4LjkzNzcyOTc4IDMuMjk0MTE3NjUsOC43MTM4NzIxNSAzLjI5NDExNzY1LDguNDM3NzI5NzggTDMuMjk0MTE3NjUsMy43OTA2NzA5NiBDMy4yOTQxMTc2NSwzLjUxNDUyODU4IDMuMDcwMjYwMDIsMy4yOTA2NzA5NiAyLjc5NDExNzY1LDMuMjkwNjcwOTYgWiIvPjwvc3ZnPg==');\n    --icon-ChartLine: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMy4xOTA5ODMwMSw4IEw1LjA1Mjc4NjQsNC4yNzYzOTMyIEM1LjE4NzI4NTQ0LDQuMDA3Mzk1MTIgNS41MjcxMTE4MSwzLjkxNzE0OTMzIDUuNzc3MzUwMSw0LjA4Mzk3NDg1IEwxMS4zMzE2NDU0LDcuNzg2ODM4MzcgTDE1LjA2NTg3ODQsMS4yNTE5MzA1MyBDMTUuMjAyODgzNCwxLjAxMjE3MTgxIDE1LjUwODMxMDcsMC45Mjg4NzM0NDQgMTUuNzQ4MDY5NSwxLjA2NTg3ODQzIEMxNS45ODc4MjgyLDEuMjAyODgzNDEgMTYuMDcxMTI2NiwxLjUwODMxMDc1IDE1LjkzNDEyMTYsMS43NDgwNjk0NyBMMTEuOTM0MTIxNiw4Ljc0ODA2OTQ3IEMxMS43OTAzOTc0LDguOTk5NTg2NzggMTEuNDYzNjgyNyw5LjA3NjcxMzY2IDExLjIyMjY0OTksOC45MTYwMjUxNSBMNS42OTM5MTU3OSw1LjIzMDIwMjQxIEw0LjEzODI4NDI0LDguMzQxNDY1NTEgTDEwLjQ0NTk1MTMsMTIuODQ2OTQyIEwxNS4xNDY0NDY2LDguMTQ2NDQ2NjEgQzE1LjM0MTcwODgsNy45NTExODQ0NiAxNS42NTgyOTEyLDcuOTUxMTg0NDYgMTUuODUzNTUzNCw4LjE0NjQ0NjYxIEMxNi4wNDg4MTU1LDguMzQxNzA4NzYgMTYuMDQ4ODE1NSw4LjY1ODI5MTI0IDE1Ljg1MzU1MzQsOC44NTM1NTMzOSBMMTAuODUzNTUzNCwxMy44NTM1NTM0IEMxMC42ODA3MjI3LDE0LjAyNjM4NDEgMTAuNDA4MjczMywxNC4wNDg5MzI3IDEwLjIwOTM4MDksMTMuOTA2ODY2NyBMMy4zMzk3Njc0Nyw5IEwwLjUsOSBDMC4yMjM4NTc2MjUsOSAxLjExMDIyMzAyZS0xNCw4Ljc3NjE0MjM3IDEuMTEwMjIzMDJlLTE0LDguNSBDMS4xMTAyMjMwMmUtMTQsOC4yMjM4NTc2MyAwLjIyMzg1NzYyNSw4IDAuNSw4IEwzLjE5MDk4MzAxLDggWiBNMC45NDcyMTM1OTUsMTQuNzIzNjA2OCBDMC44MjM3MTg5NzEsMTQuOTcwNTk2IDAuNTIzMzgyNDUxLDE1LjA3MDcwODIgMC4yNzYzOTMyMDIsMTQuOTQ3MjEzNiBDMC4wMjk0MDM5NTM1LDE0LjgyMzcxOSAtMC4wNzA3MDgyMTk5LDE0LjUyMzM4MjUgMC4wNTI3ODY0MDQ1LDE0LjI3NjM5MzIgTDEuODAyNzg2NCwxMC43NzYzOTMyIEMxLjkyNjI4MTAzLDEwLjUyOTQwNCAyLjIyNjYxNzU1LDEwLjQyOTI5MTggMi40NzM2MDY4LDEwLjU1Mjc4NjQgQzIuNzIwNTk2MDUsMTAuNjc2MjgxIDIuODIwNzA4MjIsMTAuOTc2NjE3NSAyLjY5NzIxMzYsMTEuMjIzNjA2OCBMMC45NDcyMTM1OTUsMTQuNzIzNjA2OCBaIi8+PC9zdmc+');\n    --icon-ChartPie: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTQuNTI4MTI3MywxMy4yODE2NDg0IEMxNC4zNzI2ODgsMTMuNTA5NjQ5NyAxNC4wNjE5MjU5LDEzLjU2ODYyMDQgMTMuODMzNzY4MSwxMy40MTM0MTA5IEw3LjIxODc2ODA3LDguOTEzNDEwOTQgQzcuMDgxOTEyOTcsOC44MjAzMTIyMyA3LDguNjY1NTE5NDUgNyw4LjUgTDcsMC41IEM3LDAuMjIzODU3NjI1IDcuMjIzODU3NjMsMS45NTM5OTI1MmUtMTQgNy41LDEuOTUzOTkyNTJlLTE0IEMxMi4xOTQxNDI0LDEuOTUzOTkyNTJlLTE0IDE2LDMuODA1ODU3NjMgMTYsOC41IEMxNiwxMC4yMjg5OTM4IDE1LjQ4MTY1NDksMTEuODgyOTk1OCAxNC41MjgxMjczLDEzLjI4MTY0ODQgWiBNMTUsOC41IEMxNSw0LjUyNjE1NTkzIDExLjkwODc3NjQsMS4yNzM3MzQyMyA4LDEuMDE2NDA1NjQgTDgsOC4yMzU0MTA4OCBMMTMuOTY5NTEsMTIuMjk2MzAyMSBDMTQuNjM5NTM0MiwxMS4xNTc1ODE1IDE1LDkuODU1Nzc5MTYgMTUsOC41IFogTTEwLjY5MjExNTYsMTMuNzMwMDM2NiBDMTAuOTA5Njk1NiwxMy41NTk5OTY3IDExLjIyMzkyMzYsMTMuNTk4NTM1NiAxMS4zOTM5NjM0LDEzLjgxNjExNTYgQzExLjU2NDAwMzMsMTQuMDMzNjk1NiAxMS41MjU0NjQ0LDE0LjM0NzkyMzYgMTEuMzA3ODg0NCwxNC41MTc5NjM0IEMxMC4wODYyNTk2LDE1LjQ3MjY2OTMgOC41ODI5NDg2MywxNiA3LDE2IEMzLjEzMzg1NzYzLDE2IDguNDM3Njk0OTllLTE0LDEyLjg2NjE0MjQgOC40Mzc2OTQ5OWUtMTQsOSBDOC40Mzc2OTQ5OWUtMTQsNS45NDA4MTAzOSAxLjk4MDQ4NjY5LDMuMjYzNDgwMDUgNC44NDYzNzYxNSwyLjMzODE4NTIxIEM1LjEwOTE2MTQsMi4yNTMzNDExIDUuMzkwOTcwNjgsMi4zOTc1OTA5IDUuNDc1ODE0NzksMi42NjAzNzYxNSBDNS41NjA2NTg5LDIuOTIzMTYxNCA1LjQxNjQwOTEsMy4yMDQ5NzA2OCA1LjE1MzYyMzg1LDMuMjg5ODE0NzkgQzIuNjk3NzUxNzcsNC4wODI3Mjk0NCAxLDYuMzc3ODQzMjQgMSw5IEMxLDEyLjMxMzg1NzYgMy42ODYxNDIzNywxNSA3LDE1IEM4LjM1Nzc0MzQzLDE1IDkuNjQ0NzgxNCwxNC41NDg1MzM1IDEwLjY5MjExNTYsMTMuNzMwMDM2NiBaIi8+PC9zdmc+');\n    --icon-TypeCalendar: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNIDUuMTQzODE4MywzLjIwODk5OTIgSCAxMS4xNDM4MTggViAwLjc5ODE0OTc4IGMgMCwtMC4yOTAzNzE0NSAwLjI0MzY3NSwtMC40OTgxNzEzNCAwLjUsLTAuNSAwLjI1NjMyNSwtMC4wMDE4MjkgMC41LDAuMjEzMDUwNzMgMC41LDAuNSBWIDMuMjA4OTk5MiBoIDIuMTI5OTQxIGMgMC44Mjg0MjcsMCAxLjUsMC42NzE1NzI5IDEuNSwxLjUgdiA5LjU4ODcyOTggYyAwLDAuODI4NDI3IC0wLjY3MTU3MywxLjUgLTEuNSwxLjUgSCAxLjg3MDA1ODkgYyAtMC44Mjg0MjcxLDAgLTEuNDk5OTk5OTYsLTAuNjcxNTczIC0xLjQ5OTk5OTk2LC0xLjUgViA0LjcwODk5OTIgYyAwLC0wLjgyODQyNzEgMC42NzE1NzI4NiwtMS41IDEuNDk5OTk5OTYsLTEuNSBIIDQuMTQzODE4MyBWIDAuNzk4MTQ5NzggYyAwLC0wLjI3NjE0MjM4IDAuMjA0NDYxNSwtMC41MDExNTk5NCAwLjUsLTAuNSAwLjI5NTUzODUsMC4wMDExNiAwLjUsMC4yMjMwMzQ3IDAuNSwwLjUgeiBNIDEuMzcwMDU4OSw3LjExOTg0ODYgSCAxNC43NzM3NTkgViA0LjcwODk5OTIgYyAwLC0wLjI3NjE0MjQgLTAuMjIzODU4LC0wLjUgLTAuNSwtMC41IEggMS44NzAwNTg5IGMgLTAuMjc2MTQyMywwIC0wLjUsMC4yMjM4NTc2IC0wLjUsMC41IHogbSAxMy40MDM3MDAxLDEgSCAxLjM3MDA1ODkgdiA2LjE3Nzg4MDQgYyAwLDAuMjc2MTQyIDAuMjIzODU3NywwLjUgMC41LDAuNSBIIDE0LjI3Mzc1OSBjIDAuMjc2MTQyLDAgMC41LC0wLjIyMzg1OCAwLjUsLTAuNSB6IiBzdHlsZT0iZmlsbDojMDAwO2ZpbGwtcnVsZTpub256ZXJvIi8+PC9zdmc+');\n    --icon-TypeCard: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOS41LDkgQzkuMjIzODU3NjMsOSA5LDguNzc2MTQyMzcgOSw4LjUgQzksOC4yMjM4NTc2MyA5LjIyMzg1NzYzLDggOS41LDggTDEyLjUsOCBDMTIuNzc2MTQyNCw4IDEzLDguMjIzODU3NjMgMTMsOC41IEMxMyw4Ljc3NjE0MjM3IDEyLjc3NjE0MjQsOSAxMi41LDkgTDkuNSw5IFogTTkuNSwxMiBDOS4yMjM4NTc2MywxMiA5LDExLjc3NjE0MjQgOSwxMS41IEM5LDExLjIyMzg1NzYgOS4yMjM4NTc2MywxMSA5LjUsMTEgTDEyLjUsMTEgQzEyLjc3NjE0MjQsMTEgMTMsMTEuMjIzODU3NiAxMywxMS41IEMxMywxMS43NzYxNDI0IDEyLjc3NjE0MjQsMTIgMTIuNSwxMiBMOS41LDEyIFogTTQuNSwzIEM0Ljc3NjE0MjM3LDMgNSwzLjIyMzg1NzYzIDUsMy41IEM1LDMuNzc2MTQyMzcgNC43NzYxNDIzNyw0IDQuNSw0IEwxLjUsNCBDMS4yMjM4NTc2Myw0IDEsNC4yMjM4NTc2MyAxLDQuNSBMMSwxMy41IEMxLDEzLjc3NjE0MjQgMS4yMjM4NTc2MywxNCAxLjUsMTQgTDE0LjUsMTQgQzE0Ljc3NjE0MjQsMTQgMTUsMTMuNzc2MTQyNCAxNSwxMy41IEwxNSw0LjUgQzE1LDQuMjIzODU3NjMgMTQuNzc2MTQyNCw0IDE0LjUsNCBMMTEuNSw0IEMxMS4yMjM4NTc2LDQgMTEsMy43NzYxNDIzNyAxMSwzLjUgQzExLDMuMjIzODU3NjMgMTEuMjIzODU3NiwzIDExLjUsMyBMMTQuNSwzIEMxNS4zMjg0MjcxLDMgMTYsMy42NzE1NzI4OCAxNiw0LjUgTDE2LDEzLjUgQzE2LDE0LjMyODQyNzEgMTUuMzI4NDI3MSwxNSAxNC41LDE1IEwxLjUsMTUgQzAuNjcxNTcyODc1LDE1IDEuNjY1MzM0NTRlLTE2LDE0LjMyODQyNzEgMCwxMy41IEwwLDQuNSBDMS42NjUzMzQ1NGUtMTYsMy42NzE1NzI4OCAwLjY3MTU3Mjg3NSwzIDEuNSwzIEw0LjUsMyBaIE00LDkgTDQsMTEgTDYsMTEgTDYsOSBMNCw5IFogTTMuNSw4IEw2LjUsOCBDNi43NzYxNDIzNyw4IDcsOC4yMjM4NTc2MyA3LDguNSBMNywxMS41IEM3LDExLjc3NjE0MjQgNi43NzYxNDIzNywxMiA2LjUsMTIgTDMuNSwxMiBDMy4yMjM4NTc2MywxMiAzLDExLjc3NjE0MjQgMywxMS41IEwzLDguNSBDMyw4LjIyMzg1NzYzIDMuMjIzODU3NjMsOCAzLjUsOCBaIE05LDUgTDksMiBDOSwxLjQ0NzcxNTI1IDguNTUyMjg0NzUsMSA4LDEgQzcuNDQ3NzE1MjUsMSA3LDEuNDQ3NzE1MjUgNywyIEw3LDUgTDksNSBaIE04LDAgQzkuMTA0NTY5NSwwIDEwLDAuODk1NDMwNSAxMCwyIEwxMCw1LjUgQzEwLDUuNzc2MTQyMzcgOS43NzYxNDIzNyw2IDkuNSw2IEw2LjUsNiBDNi4yMjM4NTc2Myw2IDYsNS43NzYxNDIzNyA2LDUuNSBMNiwyIEM2LDAuODk1NDMwNSA2Ljg5NTQzMDUsMCA4LDAgWiIvPjwvc3ZnPg==');\n    --icon-TypeCardList: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTQuMjMyNDA4MSwxMyBMMTUuNTY1NzQxNSwxMSBMMy43Njc1OTE4OCwxMSBMMi40MzQyNTg1NSwxMyBMMTQuMjMyNDA4MSwxMyBaIE0xNC41LDE0IEwxLjUsMTQgQzEuMTAwNjUyMzEsMTQgMC44NjI0NTY2MSwxMy41NTQ5MjczIDEuMDgzOTc0ODUsMTMuMjIyNjQ5OSBMMy4wODM5NzQ4NSwxMC4yMjI2NDk5IEMzLjE3NjcwNzc0LDEwLjA4MzU1MDYgMy4zMzI4MjM0MSwxMCAzLjUsMTAgTDE2LjUsMTAgQzE2Ljg5OTM0NzcsMTAgMTcuMTM3NTQzNCwxMC40NDUwNzI3IDE2LjkxNjAyNTEsMTAuNzc3MzUwMSBMMTQuOTE2MDI1MSwxMy43NzczNTAxIEMxNC44MjMyOTIzLDEzLjkxNjQ0OTQgMTQuNjY3MTc2NiwxNCAxNC41LDE0IFogTTE0LjIzMjQwODEsMyBMMTUuNTY1NzQxNSwxIEwzLjc2NzU5MTg4LDEgTDIuNDM0MjU4NTUsMyBMMTQuMjMyNDA4MSwzIFogTTE0LjUsNCBMMS41LDQgQzEuMTAwNjUyMzEsNCAwLjg2MjQ1NjYxLDMuNTU0OTI3MjcgMS4wODM5NzQ4NSwzLjIyMjY0OTkgTDMuMDgzOTc0ODUsMC4yMjI2NDk5MDIgQzMuMTc2NzA3NzQsMC4wODM1NTA1Njc3IDMuMzMyODIzNDEsMCAzLjUsMCBMMTYuNSwwIEMxNi44OTkzNDc3LDAgMTcuMTM3NTQzNCwwLjQ0NTA3MjczMyAxNi45MTYwMjUxLDAuNzc3MzUwMDk4IEwxNC45MTYwMjUxLDMuNzc3MzUwMSBDMTQuODIzMjkyMywzLjkxNjQ0OTQzIDE0LjY2NzE3NjYsNCAxNC41LDQgWiBNMTQuMjMyNDA4MSw4IEwxNS41NjU3NDE1LDYgTDMuNzY3NTkxODgsNiBMMi40MzQyNTg1NSw4IEwxNC4yMzI0MDgxLDggWiBNMTQuNSw5IEwxLjUsOSBDMS4xMDA2NTIzMSw5IDAuODYyNDU2NjEsOC41NTQ5MjcyNyAxLjA4Mzk3NDg1LDguMjIyNjQ5OSBMMy4wODM5NzQ4NSw1LjIyMjY0OTkgQzMuMTc2NzA3NzQsNS4wODM1NTA1NyAzLjMzMjgyMzQxLDUgMy41LDUgTDE2LjUsNSBDMTYuODk5MzQ3Nyw1IDE3LjEzNzU0MzQsNS40NDUwNzI3MyAxNi45MTYwMjUxLDUuNzc3MzUwMSBMMTQuOTE2MDI1MSw4Ljc3NzM1MDEgQzE0LjgyMzI5MjMsOC45MTY0NDk0MyAxNC42NjcxNzY2LDkgMTQuNSw5IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xIDEpIi8+PC9zdmc+');\n    --icon-TypeCell: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMSw2IEwxLDEwIEwxMCwxMCBMMTAsNiBMMSw2IFogTTEsNSBMMTAsNSBMMTAsMSBMMS41LDEgQzEuMjI0MTQyMzcsMSAxLDEuMjI0MTQyMzcgMSwxLjUgTDEsNSBaIE0xMSwxIEwxMSwxNSBMMTQuNSwxNSBDMTQuNzc1ODU3NiwxNSAxNSwxNC43NzU4NTc2IDE1LDE0LjUgTDE1LDEuNSBDMTUsMS4yMjQxNDIzNyAxNC43NzU4NTc2LDEgMTQuNSwxIEwxMSwxIFogTTEwLDE1IEwxMCwxMSBMMSwxMSBMMSwxNC41IEMxLDE0Ljc3NTg1NzYgMS4yMjQxNDIzNywxNSAxLjUsMTUgTDEwLDE1IFogTTE0LjUsMTYgTDEuNSwxNiBDMC42NzE4NTc2MjUsMTYgMCwxNS4zMjgxNDI0IDAsMTQuNSBMMCwxLjUgQzAsMC42NzE4NTc2MjUgMC42NzE4NTc2MjUsMCAxLjUsMCBMMTQuNSwwIEMxNS4zMjgxNDI0LDAgMTYsMC42NzE4NTc2MjUgMTYsMS41IEwxNiwxNC41IEMxNiwxNS4zMjgxNDI0IDE1LjMyODE0MjQsMTYgMTQuNSwxNiBaIi8+PC9zdmc+');\n    --icon-TypeChart: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNywxIEw3LDE1IEw5LDE1IEw5LDEgTDcsMSBaIE02LjUsMCBMOS41LDAgQzkuNzc2MTQyMzcsMCAxMCwwLjIyMzg1NzYyNSAxMCwwLjUgTDEwLDE1LjUgQzEwLDE1Ljc3NjE0MjQgOS43NzYxNDIzNywxNiA5LjUsMTYgTDYuNSwxNiBDNi4yMjM4NTc2MywxNiA2LDE1Ljc3NjE0MjQgNiwxNS41IEw2LDAuNSBDNiwwLjIyMzg1NzYyNSA2LjIyMzg1NzYzLDAgNi41LDAgWiBNMSwxMSBMMSwxNSBMMywxNSBMMywxMSBMMSwxMSBaIE0wLjUsMTAgTDMuNSwxMCBDMy43NzYxNDIzNywxMCA0LDEwLjIyMzg1NzYgNCwxMC41IEw0LDE1LjUgQzQsMTUuNzc2MTQyNCAzLjc3NjE0MjM3LDE2IDMuNSwxNiBMMC41LDE2IEMwLjIyMzg1NzYyNSwxNiAwLDE1Ljc3NjE0MjQgMCwxNS41IEwwLDEwLjUgQzAsMTAuMjIzODU3NiAwLjIyMzg1NzYyNSwxMCAwLjUsMTAgWiBNMTMsNiBMMTMsMTUgTDE1LDE1IEwxNSw2IEwxMyw2IFogTTEyLjUsNSBMMTUuNSw1IEMxNS43NzYxNDI0LDUgMTYsNS4yMjM4NTc2MyAxNiw1LjUgTDE2LDE1LjUgQzE2LDE1Ljc3NjE0MjQgMTUuNzc2MTQyNCwxNiAxNS41LDE2IEwxMi41LDE2IEMxMi4yMjM4NTc2LDE2IDEyLDE1Ljc3NjE0MjQgMTIsMTUuNSBMMTIsNS41IEMxMiw1LjIyMzg1NzYzIDEyLjIyMzg1NzYsNSAxMi41LDUgWiIvPjwvc3ZnPg==');\n    --icon-TypeCustom: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTMsNyBDMTQuNjU3MTQyNCw3IDE2LDguMzQyODU3NjMgMTYsMTAgQzE2LDExLjY1NzE0MjQgMTQuNjU3MTQyNCwxMyAxMywxMyBMMTMsMTUuNSBDMTMsMTUuNzc2MTQyNCAxMi43NzYxNDI0LDE2IDEyLjUsMTYgTDkuNDUsMTYgQzkuMjEyMjYwODcsMTYgOS4wMDczOTYyOCwxNS44MzI1OTg2IDguOTYwMDI1OTgsMTUuNTk5NjI2NiBDOC43NzIwMjg4LDE0LjY3NTAzNyA3Ljk1NTU2MjE5LDE0IDcsMTQgQzYuMDQ0NDM3ODEsMTQgNS4yMjc5NzEyLDE0LjY3NTAzNyA1LjAzOTk3NDAyLDE1LjU5OTYyNjYgQzQuOTkyNjAzNzIsMTUuODMyNTk4NiA0Ljc4NzczOTEzLDE2IDQuNTUsMTYgTDEuNSwxNiBDMS4yMjM4NTc2MywxNiAxLDE1Ljc3NjE0MjQgMSwxNS41IEwxLDEyLjQ1IEMxLDEyLjIxMjI2MDkgMS4xNjc0MDEzOSwxMi4wMDczOTYzIDEuNDAwMzczMzgsMTEuOTYwMDI2IEMyLjMyNDk2Mjk4LDExLjc3MjAyODggMywxMC45NTU1NjIyIDMsMTAgQzMsOS4wNDQ0Mzc4MSAyLjMyNDk2Mjk4LDguMjI3OTcxMiAxLjQwMDM3MzM4LDguMDM5OTc0MDIgQzEuMTY3NDAxMzksNy45OTI2MDM3MiAxLDcuNzg3NzM5MTMgMSw3LjU1IEwxLDQuNSBDMSw0LjIyMzg1NzYzIDEuMjIzODU3NjMsNCAxLjUsNCBMNCw0IEM0LDIuMzQyODU3NjMgNS4zNDI4NTc2MywxIDcsMSBDOC42NTcxNDIzNywxIDEwLDIuMzQyODU3NjMgMTAsNCBMMTIuNSw0IEMxMi43NzYxNDI0LDQgMTMsNC4yMjM4NTc2MyAxMyw0LjUgTDEzLDcgWiBNMTIsMTIuNDUgQzEyLDEyLjEzMzc1MDUgMTIuMjg5OTE2OCwxMS44OTY5MzY5IDEyLjU5OTgwMjIsMTEuOTYwMDYxNyBDMTIuNzI5OTg1NCwxMS45ODY1ODA1IDEyLjg2MzU3OTQsMTIgMTMsMTIgQzE0LjEwNDg1NzYsMTIgMTUsMTEuMTA0ODU3NiAxNSwxMCBDMTUsOC44OTUxNDIzNyAxNC4xMDQ4NTc2LDggMTMsOCBDMTIuODY3MDIwNiw4IDEyLjczMjcyMDgsOC4wMTM2OTE3NCAxMi41OTY4OTMyLDguMDQwNTIxODcgQzEyLjI4NzgyNjQsOC4xMDE1NzIxMSAxMiw3Ljg2NTAzODc5IDEyLDcuNTUgTDEyLDUgTDkuNDUsNSBDOS4xMzQ5NjEyMSw1IDguODk4NDI3ODksNC43MTIxNzM2MiA4Ljk1OTQ3ODEzLDQuNDAzMTA2NzkgQzguOTg2MzA4MjYsNC4yNjcyNzkyMyA5LDQuMTMyOTc5NDEgOSw0IEM5LDIuODk1MTQyMzcgOC4xMDQ4NTc2MywyIDcsMiBDNS44OTUxNDIzNywyIDUsMi44OTUxNDIzNyA1LDQgQzUsNC4xMzI5Nzk0MSA1LjAxMzY5MTc0LDQuMjY3Mjc5MjMgNS4wNDA1MjE4Nyw0LjQwMzEwNjc5IEM1LjEwMTU3MjExLDQuNzEyMTczNjIgNC44NjUwMzg3OSw1IDQuNTUsNSBMMiw1IEwyLDcuMTcwNzU2ODUgQzMuMTc4MTk1ODEsNy41ODY1ODI2IDQsOC43MDgzNTc1MSA0LDEwIEM0LDExLjI5MTY0MjUgMy4xNzgxOTU4MSwxMi40MTM0MTc0IDIsMTIuODI5MjQzMiBMMiwxNSBMNC4xNzA3NTY4NSwxNSBDNC41ODY1ODI2LDEzLjgyMTgwNDIgNS43MDgzNTc1MSwxMyA3LDEzIEM4LjI5MTY0MjQ5LDEzIDkuNDEzNDE3NCwxMy44MjE4MDQyIDkuODI5MjQzMTUsMTUgTDEyLDE1IEwxMiwxMi40NSBaIi8+PC9zdmc+');\n    --icon-TypeDetails: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMSwyIEwxLDE0IEwxNSwxNCBMMTUsMiBMMSwyIFogTTAuNSwxIEwxNS41LDEgQzE1Ljc3NjE0MjQsMSAxNiwxLjIyMzg1NzYzIDE2LDEuNSBMMTYsMTQuNSBDMTYsMTQuNzc2MTQyNCAxNS43NzYxNDI0LDE1IDE1LjUsMTUgTDAuNSwxNSBDMC4yMjM4NTc2MjUsMTUgMCwxNC43NzYxNDI0IDAsMTQuNSBMMCwxLjUgQzAsMS4yMjM4NTc2MyAwLjIyMzg1NzYyNSwxIDAuNSwxIFogTTEyLjUsNC41IEw5LjUsNC41IEM5LjIyMzg1NzYzLDQuNSA5LDQuMjc2MTQyMzcgOSw0IEM5LDMuNzIzODU3NjMgOS4yMjM4NTc2MywzLjUgOS41LDMuNSBMMTMsMy41IEMxMy4yNzYxNDI0LDMuNSAxMy41LDMuNzIzODU3NjMgMTMuNSw0IEwxMy41LDcuNSBDMTMuNSw3Ljc3NjE0MjM3IDEzLjI3NjE0MjQsOCAxMyw4IEMxMi43MjM4NTc2LDggMTIuNSw3Ljc3NjE0MjM3IDEyLjUsNy41IEwxMi41LDQuNSBaIE0zLjUsMTEuNSBMNi41LDExLjUgQzYuNzc2MTQyMzcsMTEuNSA3LDExLjcyMzg1NzYgNywxMiBDNywxMi4yNzYxNDI0IDYuNzc2MTQyMzcsMTIuNSA2LjUsMTIuNSBMMywxMi41IEMyLjcyMzg1NzYzLDEyLjUgMi41LDEyLjI3NjE0MjQgMi41LDEyIEwyLjUsOC41IEMyLjUsOC4yMjM4NTc2MyAyLjcyMzg1NzYzLDggMyw4IEMzLjI3NjE0MjM3LDggMy41LDguMjIzODU3NjMgMy41LDguNSBMMy41LDExLjUgWiIvPjwvc3ZnPg==');\n    --icon-TypeTable: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOC41LDEgTDguNSw3LjUgTDE1LDcuNSBMMTUsMSBMOC41LDEgWiBNNy41LDEgTDEsMSBMMSw3LjUgTDcuNSw3LjUgTDcuNSwxIFogTTEsOC41IEwxLDE1IEw3LjUsMTUgTDcuNSw4LjUgTDEsOC41IFogTTguNSwxNSBMMTUsMTUgTDE1LDguNSBMOC41LDguNSBMOC41LDE1IFogTTAuNTMzMzMzMzMzLDAgTDE1LjQ2NjY2NjcsMCBDMTUuNzYxMjE4NSwwIDE2LDAuMjM4NzgxNDY3IDE2LDAuNTMzMzMzMzMzIEwxNiwxNS40NjY2NjY3IEMxNiwxNS43NjEyMTg1IDE1Ljc2MTIxODUsMTYgMTUuNDY2NjY2NywxNiBMMC41MzMzMzMzMzMsMTYgQzAuMjM4NzgxNDY3LDE2IDAsMTUuNzYxMjE4NSAwLDE1LjQ2NjY2NjcgTDAsMC41MzMzMzMzMzMgQzAsMC4yMzg3ODE0NjcgMC4yMzg3ODE0NjcsMCAwLjUzMzMzMzMzMywwIFoiLz48L3N2Zz4=');\n    --icon-FieldAny: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOS4xNDY0NDY2MSw2LjE0NjQ0NjYxIEM5LjM0MTcwODc2LDUuOTUxMTg0NDYgOS42NTgyOTEyNCw1Ljk1MTE4NDQ2IDkuODUzNTUzMzksNi4xNDY0NDY2MSBDMTAuMDQ4ODE1NSw2LjM0MTcwODc2IDEwLjA0ODgxNTUsNi42NTgyOTEyNCA5Ljg1MzU1MzM5LDYuODUzNTUzMzkgTDguMDg2Njg1MTQsOC42MjA0MjE2NCBMOS40Nzc3NTMwOSwxMi4wOTgwOTE1IEwxMi42NjM5NSwzLjMzNjA0OTk5IEwzLjkwMTkwODQ4LDYuNTIyMjQ2OTEgTDcuMzc5NTc4MzYsNy45MTMzMTQ4NiBMOS4xNDY0NDY2MSw2LjE0NjQ0NjYxIFogTTcuMTE1MzQ1MzcsOC44ODQ2NTQ2MyBMMi4zMTQzMDQ2Niw2Ljk2NDIzODM1IEMxLjg4ODAzMDk4LDYuNzkzNzI4ODcgMS44OTc2NTkxNiw2LjE4NzAwMTIyIDIuMzI5MTI4NDcsNi4wMzAxMDMyOSBMMTMuMzI5MTI4NSwyLjAzMDEwMzI5IEMxMy43MjgxNDE2LDEuODg1MDA3NjEgMTQuMTE0OTkyNCwyLjI3MTg1ODQxIDEzLjk2OTg5NjcsMi42NzA4NzE1MyBMOS45Njk4OTY3MSwxMy42NzA4NzE1IEM5LjgxMjk5ODc4LDE0LjEwMjM0MDggOS4yMDYyNzExMywxNC4xMTE5NjkgOS4wMzU3NjE2NSwxMy42ODU2OTUzIEw3LjExNTM0NTM3LDguODg0NjU0NjMgWiIvPjwvc3ZnPg==');\n    --icon-FieldAttachment: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOC4zMDAzNjk1Nyw1LjYyODUyMzY2IEM4LjQ5NTU4OTE3LDUuNDMzMjE4OTcgOC44MTIxNzE2NSw1LjQzMzE0OTk4IDkuMDA3NDc2MzQsNS42MjgzNjk1NyBDOS4yMDI3ODEwMyw1LjgyMzU4OTE3IDkuMjAyODUwMDIsNi4xNDAxNzE2NSA5LjAwNzYzMDQzLDYuMzM1NDc2MzQgTDYuNzEzMzYxMzYsOC42MzA3NDUzMiBDNi41NTY1NzIzMiw4Ljc4NzM2NDE4IDYuNDY4NDc0ODMsOC45OTk4ODcxMSA2LjQ2ODQ3NDgzLDkuMjIxNSBDNi40Njg0NzQ4Myw5LjQ0MzExMjg5IDYuNTU2NTcyMzIsOS42NTU2MzU4MiA2LjcxMzc0NTMyLDkuODEyNjM4NjQgQzYuODcwMzY0MTgsOS45Njk0Mjc2OCA3LjA4Mjg4NzExLDEwLjA1NzUyNTIgNy4zMDQ1LDEwLjA1NzUyNTIgQzcuNTI2MTEyODksMTAuMDU3NTI1MiA3LjczODYzNTgyLDkuOTY5NDI3NjggNy44OTU0NDY2MSw5LjgxMjQ0NjYxIEwxMC45OTUzMzUxLDYuNzEyNTU4MTcgQzExLjg0MzI5ODEsNS44NjQwNTk5MyAxMS44NDMyOTgxLDQuNDg4OTQwMDcgMTAuOTk1MjkzNSwzLjY0MDQwMDI0IEMxMC41ODgwOTgxLDMuMjMyODUxOTggMTAuMDM1NjEwOCwzLjAwMzg2NDA3IDkuNDU5NSwzLjAwMzg2NDA3IEM4Ljg4MzM4OTE4LDMuMDAzODY0MDcgOC4zMzA5MDE4OCwzLjIzMjg1MTk4IDcuOTIzNTUzMzksMy42NDA1NTMzOSBMNC41NTM1ODk2OSw3LjAxMDUxNzA5IEMzLjE4MzU3OTYzLDguMzgwODA4NTIgMy4xODM1Nzk2MywxMC42MDIxOTE1IDQuNTUzNTE3MDksMTEuOTcyNDEwMyBDNS45MjM4MDg1MiwxMy4zNDI0MjA0IDguMTQ1MTkxNDgsMTMuMzQyNDIwNCA5LjUxNTQ0NjYxLDExLjk3MjQ0NjYgTDEyLjYxNTQ0NjYsOC44NzI0NDY2MSBDMTIuODEwNzA4OCw4LjY3NzE4NDQ2IDEzLjEyNzI5MTIsOC42NzcxODQ0NiAxMy4zMjI1NTM0LDguODcyNDQ2NjEgQzEzLjUxNzgxNTUsOS4wNjc3MDg3NiAxMy41MTc4MTU1LDkuMzg0MjkxMjQgMTMuMzIyNTUzNCw5LjU3OTU1MzM5IEwxMC4yMjI1MTcxLDEyLjY3OTU4OTcgQzguNDYxNzE3OTgsMTQuNDQwMDI3MiA1LjYwNzI4MjAyLDE0LjQ0MDAyNzIgMy44NDY0MTAzMSwxMi42Nzk1MTcxIEMyLjA4NTk3Mjc1LDEwLjkxODcxOCAyLjA4NTk3Mjc1LDguMDY0MjgyMDIgMy44NDY0NDY2MSw2LjMwMzQ0NjYxIEw3LjIxNjI5MzUyLDIuOTMzNTk5NzYgQzcuODExMDUwMTQsMi4zMzgzMjc3NiA4LjYxODAyMjYxLDIuMDAzODY0MDcgOS40NTk1LDIuMDAzODY0MDcgQzEwLjMwMDk3NzQsMi4wMDM4NjQwNyAxMS4xMDc5NDk5LDIuMzM4MzI3NzYgMTEuNzAyNjY0OSwyLjkzMzU1ODE3IEMxMi45NDA4NTQ4LDQuMTcyNTI5NjEgMTIuOTQwODU0OCw2LjE4MDQ3MDM5IDExLjcwMjU1MzQsNy40MTk1NTMzOSBMOC42MDI3NDUzMiwxMC41MTkzNjE0IEM4LjI1ODU1ODk2LDEwLjg2MzkyMTcgNy43OTE1MTc1NSwxMS4wNTc1MjUyIDcuMzA0NSwxMS4wNTc1MjUyIEM2LjgxNzQ4MjQ1LDExLjA1NzUyNTIgNi4zNTA0NDEwNCwxMC44NjM5MjE3IDYuMDA2NjM4NjQsMTAuNTE5NzQ1MyBDNS42NjIwNzgyOSwxMC4xNzU1NTkgNS40Njg0NzQ4Myw5LjcwODUxNzU1IDUuNDY4NDc0ODMsOS4yMjE1IEM1LjQ2ODQ3NDgzLDguNzM0NDgyNDUgNS42NjIwNzgyOSw4LjI2NzQ0MTA0IDYuMDA2MzY5NTcsNy45MjM1MjM2NiBMOC4zMDAzNjk1Nyw1LjYyODUyMzY2IFoiLz48L3N2Zz4=');\n    --icon-FieldCheckbox: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMy41LDMgQzMuMjIzODU3NjMsMyAzLDMuMjIzODU3NjMgMywzLjUgTDMsMTIuNSBDMywxMi43NzYxNDI0IDMuMjIzODU3NjMsMTMgMy41LDEzIEwxMi41LDEzIEMxMi43NzYxNDI0LDEzIDEzLDEyLjc3NjE0MjQgMTMsMTIuNSBMMTMsMy41IEMxMywzLjIyMzg1NzYzIDEyLjc3NjE0MjQsMyAxMi41LDMgTDMuNSwzIFogTTMuNSwyIEwxMi41LDIgQzEzLjMyODQyNzEsMiAxNCwyLjY3MTU3Mjg4IDE0LDMuNSBMMTQsMTIuNSBDMTQsMTMuMzI4NDI3MSAxMy4zMjg0MjcxLDE0IDEyLjUsMTQgTDMuNSwxNCBDMi42NzE1NzI4OCwxNCAyLDEzLjMyODQyNzEgMiwxMi41IEwyLDMuNSBDMiwyLjY3MTU3Mjg4IDIuNjcxNTcyODgsMiAzLjUsMiBaIE0xMC42MDk1NjU2LDUuMTg3NjUyNDggQzEwLjc4MjA3MDQsNC45NzIwMjE1MSAxMS4wOTY3MTY2LDQuOTM3MDYwODIgMTEuMzEyMzQ3NSw1LjEwOTU2NTYgQzExLjUyNzk3ODUsNS4yODIwNzAzNyAxMS41NjI5MzkyLDUuNTk2NzE2NTYgMTEuMzkwNDM0NCw1LjgxMjM0NzUyIEw3LjM5MDQzNDQsMTAuODEyMzQ3NSBDNy4yMDQyNzE3NSwxMS4wNDUwNTA4IDYuODU3MTY4NDIsMTEuMDY0Mjc1MiA2LjY0NjQ0NjYxLDEwLjg1MzU1MzQgTDQuNjQ2NDQ2NjEsOC44NTM1NTMzOSBDNC40NTExODQ0Niw4LjY1ODI5MTI0IDQuNDUxMTg0NDYsOC4zNDE3MDg3NiA0LjY0NjQ0NjYxLDguMTQ2NDQ2NjEgQzQuODQxNzA4NzYsNy45NTExODQ0NiA1LjE1ODI5MTI0LDcuOTUxMTg0NDYgNS4zNTM1NTMzOSw4LjE0NjQ0NjYxIEw2Ljk1ODU0MDU2LDkuNzUxNDMzNzggTDEwLjYwOTU2NTYsNS4xODc2NTI0OCBaIi8+PC9zdmc+');\n    --icon-FieldChoice: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTAuNjA5NTY1Niw1LjE4NzY1MjQ4IEMxMC43ODIwNzA0LDQuOTcyMDIxNTEgMTEuMDk2NzE2Niw0LjkzNzA2MDgyIDExLjMxMjM0NzUsNS4xMDk1NjU2IEMxMS41Mjc5Nzg1LDUuMjgyMDcwMzcgMTEuNTYyOTM5Miw1LjU5NjcxNjU2IDExLjM5MDQzNDQsNS44MTIzNDc1MiBMNy4zOTA0MzQ0LDEwLjgxMjM0NzUgQzcuMjA0MjcxNzUsMTEuMDQ1MDUwOCA2Ljg1NzE2ODQyLDExLjA2NDI3NTIgNi42NDY0NDY2MSwxMC44NTM1NTM0IEw0LjY0NjQ0NjYxLDguODUzNTUzMzkgQzQuNDUxMTg0NDYsOC42NTgyOTEyNCA0LjQ1MTE4NDQ2LDguMzQxNzA4NzYgNC42NDY0NDY2MSw4LjE0NjQ0NjYxIEM0Ljg0MTcwODc2LDcuOTUxMTg0NDYgNS4xNTgyOTEyNCw3Ljk1MTE4NDQ2IDUuMzUzNTUzMzksOC4xNDY0NDY2MSBMNi45NTg1NDA1Niw5Ljc1MTQzMzc4IEwxMC42MDk1NjU2LDUuMTg3NjUyNDggWiBNOCwxNCBDNC42ODYyOTE1LDE0IDIsMTEuMzEzNzA4NSAyLDggQzIsNC42ODYyOTE1IDQuNjg2MjkxNSwyIDgsMiBDMTEuMzEzNzA4NSwyIDE0LDQuNjg2MjkxNSAxNCw4IEMxNCwxMS4zMTM3MDg1IDExLjMxMzcwODUsMTQgOCwxNCBaIE04LDEzIEMxMC43NjE0MjM3LDEzIDEzLDEwLjc2MTQyMzcgMTMsOCBDMTMsNS4yMzg1NzYyNSAxMC43NjE0MjM3LDMgOCwzIEM1LjIzODU3NjI1LDMgMyw1LjIzODU3NjI1IDMsOCBDMywxMC43NjE0MjM3IDUuMjM4NTc2MjUsMTMgOCwxMyBaIi8+PC9zdmc+');\n    --icon-FieldColumn: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMyw3IEwzLDkgTDksOSBMOSw3IEwzLDcgWiBNMyw2IEw5LDYgTDksMyBMMy41LDMgQzMuMjIzODU3NjMsMyAzLDMuMjIzODU3NjMgMywzLjUgTDMsNiBaIE0xMCwzIEwxMCwxMyBMMTIuNSwxMyBDMTIuNzc2MTQyNCwxMyAxMywxMi43NzYxNDI0IDEzLDEyLjUgTDEzLDMuNSBDMTMsMy4yMjM4NTc2MyAxMi43NzYxNDI0LDMgMTIuNSwzIEwxMCwzIFogTTksMTMgTDksMTAgTDMsMTAgTDMsMTIuNSBDMywxMi43NzYxNDI0IDMuMjIzODU3NjMsMTMgMy41LDEzIEw5LDEzIFogTTMuNSwyIEwxMi41LDIgQzEzLjMyODQyNzEsMiAxNCwyLjY3MTU3Mjg4IDE0LDMuNSBMMTQsMTIuNSBDMTQsMTMuMzI4NDI3MSAxMy4zMjg0MjcxLDE0IDEyLjUsMTQgTDMuNSwxNCBDMi42NzE1NzI4OCwxNCAyLDEzLjMyODQyNzEgMiwxMi41IEwyLDMuNSBDMiwyLjY3MTU3Mjg4IDIuNjcxNTcyODgsMiAzLjUsMiBaIi8+PC9zdmc+');\n    --icon-FieldDate: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNSw0IEwxMSw0IEwxMSwyLjUgQzExLDIuMjIzODU3NjMgMTEuMjIzODU3NiwyIDExLjUsMiBDMTEuNzc2MTQyNCwyIDEyLDIuMjIzODU3NjMgMTIsMi41IEwxMiw0IEwxMi41LDQgQzEzLjMyODQyNzEsNCAxNCw0LjY3MTU3Mjg4IDE0LDUuNSBMMTQsMTIuNSBDMTQsMTMuMzI4NDI3MSAxMy4zMjg0MjcxLDE0IDEyLjUsMTQgTDMuNSwxNCBDMi42NzE1NzI4OCwxNCAyLDEzLjMyODQyNzEgMiwxMi41IEwyLDUuNSBDMiw0LjY3MTU3Mjg4IDIuNjcxNTcyODgsNCAzLjUsNCBMNCw0IEw0LDIuNSBDNCwyLjIyMzg1NzYzIDQuMjIzODU3NjMsMiA0LjUsMiBDNC43NzYxNDIzNywyIDUsMi4yMjM4NTc2MyA1LDIuNSBMNSw0IFogTTMsNyBMMTMsNyBMMTMsNS41IEMxMyw1LjIyMzg1NzYzIDEyLjc3NjE0MjQsNSAxMi41LDUgTDMuNSw1IEMzLjIyMzg1NzYzLDUgMyw1LjIyMzg1NzYzIDMsNS41IEwzLDcgWiBNMTMsOCBMMyw4IEwzLDEyLjUgQzMsMTIuNzc2MTQyNCAzLjIyMzg1NzYzLDEzIDMuNSwxMyBMMTIuNSwxMyBDMTIuNzc2MTQyNCwxMyAxMywxMi43NzYxNDI0IDEzLDEyLjUgTDEzLDggWiIvPjwvc3ZnPg==');\n    --icon-FieldDateTime: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOC41LDcuNSBMMTEuNSw3LjUgQzExLjc3NjE0MjQsNy41IDEyLDcuNzIzODU3NjMgMTIsOCBDMTIsOC4yNzYxNDIzNyAxMS43NzYxNDI0LDguNSAxMS41LDguNSBMOCw4LjUgQzcuNzIzODU3NjMsOC41IDcuNSw4LjI3NjE0MjM3IDcuNSw4IEw3LjUsNC41IEM3LjUsNC4yMjM4NTc2MyA3LjcyMzg1NzYzLDQgOCw0IEM4LjI3NjE0MjM3LDQgOC41LDQuMjIzODU3NjMgOC41LDQuNSBMOC41LDcuNSBaIE04LDE0IEM0LjY4NjI5MTUsMTQgMiwxMS4zMTM3MDg1IDIsOCBDMiw0LjY4NjI5MTUgNC42ODYyOTE1LDIgOCwyIEMxMS4zMTM3MDg1LDIgMTQsNC42ODYyOTE1IDE0LDggQzE0LDExLjMxMzcwODUgMTEuMzEzNzA4NSwxNCA4LDE0IFogTTgsMTMgQzEwLjc2MTQyMzcsMTMgMTMsMTAuNzYxNDIzNyAxMyw4IEMxMyw1LjIzODU3NjI1IDEwLjc2MTQyMzcsMyA4LDMgQzUuMjM4NTc2MjUsMyAzLDUuMjM4NTc2MjUgMyw4IEMzLDEwLjc2MTQyMzcgNS4yMzg1NzYyNSwxMyA4LDEzIFoiLz48L3N2Zz4=');\n    --icon-FieldFunction: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMy4wNTkwMzI0OSwxMi4zNzMwNDcgTDQuMTA4NDk4OTEsNi40Njk3OTgzNiBMMyw2LjQ2OTc5ODM2IEwzLjE2Mzk3OTEzLDUuNDkyNDgyNzYgTDQuMjg1NTk2MzcsNS40OTI0ODI3NiBMNC4zMzgwNjk2OSw1LjEzODI4Nzg0IEM0LjUyODI4NTQ4LDMuNjk1MjcxNTEgNS4zMjg1MDM2MywzIDYuNzkxMTk3NDYsMyBDNy4wNTM1NjQwNiwzIDcuMzQ4NzI2NDksMy4wMjYyMzY2NiA3LjU5MTQxNTYxLDMuMDcyMTUwODIgTDcuNDE0MzE4MTUsNC4wMTAxMTE0MyBDNy4zMDI4MTIzNCwzLjk4Mzg3NDc3IDcuMDIwNzY4MjQsMy45NjQxOTcyOCA2LjgxNzQzNDEyLDMuOTY0MTk3MjggQzYuMDU2NTcwOTYsMy45NjQxOTcyOCA1LjY4MjY5ODU1LDQuMjkyMTU1NTMgNS41MzE4Mzc3NSw1LjA4NTgxNDUyIEw1LjQ1OTY4NjkzLDUuNDkyNDgyNzYgTDcuMDQwNDQ1NzMsNS40OTI0ODI3NiBMNi44NjMzNDgyNyw2LjQ2OTc5ODM2IEw1LjI4OTE0ODY0LDYuNDY5Nzk4MzYgTDQuMjUyODAwNTQsMTIuMzczMDQ3IEwzLjA1OTAzMjQ5LDEyLjM3MzA0NyBaIE0xMC45OTM0MzU5LDEyLjM3MzA0NyBMOS43Mjc1MTcwNiw5Ljc2MjQ5OTI3IEw5LjY4MTYwMjksOS43NjI0OTkyNyBMNy42MzUxNDMzNywxMi4zNzMwNDcgTDYuMjkwNTE0NTIsMTIuMzczMDQ3IEw5LjExMDk1NTUzLDguODk2Njg5NDcgTDcuMzc5MzM1OTMsNS40NzI4MDUyNiBMOC42ODQ2MDk4LDUuNDcyODA1MjYgTDkuOTMwODUxMTgsOC4xMjI3MDc5OCBMOS45ODk4ODM2Niw4LjEyMjcwNzk4IEwxMi4wNDk0NjE1LDUuNDcyODA1MjYgTDEzLjQyMDMyNyw1LjQ3MjgwNTI2IEwxMC41MzQyOTQ0LDguOTQyNjAzNjMgTDEyLjMwNTI2OSwxMi4zNzMwNDcgTDEwLjk5MzQzNTksMTIuMzczMDQ3IFoiLz48L3N2Zz4=');\n    --icon-FieldFunctionEqual: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNS41LDYgTDEwLjUsNiBDMTAuNzc2MTQyNCw2IDExLDYuMjIzODU3NjMgMTEsNi41IEMxMSw2Ljc3NjE0MjM3IDEwLjc3NjE0MjQsNyAxMC41LDcgTDUuNSw3IEM1LjIyMzg1NzYzLDcgNSw2Ljc3NjE0MjM3IDUsNi41IEM1LDYuMjIzODU3NjMgNS4yMjM4NTc2Myw2IDUuNSw2IFogTTUuNSw5IEwxMC41LDkgQzEwLjc3NjE0MjQsOSAxMSw5LjIyMzg1NzYzIDExLDkuNSBDMTEsOS43NzYxNDIzNyAxMC43NzYxNDI0LDEwIDEwLjUsMTAgTDUuNSwxMCBDNS4yMjM4NTc2MywxMCA1LDkuNzc2MTQyMzcgNSw5LjUgQzUsOS4yMjM4NTc2MyA1LjIyMzg1NzYzLDkgNS41LDkgWiBNNCwzIEMzLjQ0NzcxNTI1LDMgMywzLjQ0NzcxNTI1IDMsNCBMMywxMiBDMywxMi41NTIyODQ3IDMuNDQ3NzE1MjUsMTMgNCwxMyBMMTIsMTMgQzEyLjU1MjI4NDcsMTMgMTMsMTIuNTUyMjg0NyAxMywxMiBMMTMsNCBDMTMsMy40NDc3MTUyNSAxMi41NTIyODQ3LDMgMTIsMyBMNCwzIFogTTQsMiBMMTIsMiBDMTMuMTA0NTY5NSwyIDE0LDIuODk1NDMwNSAxNCw0IEwxNCwxMiBDMTQsMTMuMTA0NTY5NSAxMy4xMDQ1Njk1LDE0IDEyLDE0IEw0LDE0IEMyLjg5NTQzMDUsMTQgMiwxMy4xMDQ1Njk1IDIsMTIgTDIsNCBDMiwyLjg5NTQzMDUgMi44OTU0MzA1LDIgNCwyIFoiLz48L3N2Zz4=');\n    --icon-FieldInteger: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTMsMTMgTDEzLDMgTDEyLDMgTDEyLDIgTDE0LDIgTDE0LDMgTDE0LDEzIEwxNCwxNCBMMTIsMTQgTDEyLDEzIEwxMywxMyBaIE0yLDEzIEwyLDMgTDIsMiBMNCwyIEw0LDMgTDMsMyBMMywxMyBMNCwxMyBMNCwxNCBMMiwxNCBMMiwxMyBaIE04LjkwMjM0Mzc1LDEyIEw3LjgzNTkzNzUsMTIgTDcuODM1OTM3NSw1LjE3MzgyODEyIEw3Ljc4MzIwMzEyLDUuMTczODI4MTIgTDUuOTMxNjQwNjIsNi41MDk3NjU2MiBMNS45MzE2NDA2Miw1LjQwODIwMzEyIEw3LjgzNTkzNzUsNC4wMDE5NTMxMiBMOC45MDIzNDM3NSw0LjAwMTk1MzEyIEw4LjkwMjM0Mzc1LDEyIFoiLz48L3N2Zz4=');\n    --icon-FieldLink: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTIuMjkyODkzMiwzIEw4LjUsMyBDOC4yMjM4NTc2MywzIDgsMi43NzYxNDIzNyA4LDIuNSBDOCwyLjIyMzg1NzYzIDguMjIzODU3NjMsMiA4LjUsMiBMMTMuNSwyIEMxMy43NzYxNDI0LDIgMTQsMi4yMjM4NTc2MyAxNCwyLjUgTDE0LDcuNSBDMTQsNy43NzYxNDIzNyAxMy43NzYxNDI0LDggMTMuNSw4IEMxMy4yMjM4NTc2LDggMTMsNy43NzYxNDIzNyAxMyw3LjUgTDEzLDMuNzA3MTA2NzggTDcuODUzNTUzMzksOC44NTM1NTMzOSBDNy42NTgyOTEyNCw5LjA0ODgxNTU0IDcuMzQxNzA4NzYsOS4wNDg4MTU1NCA3LjE0NjQ0NjYxLDguODUzNTUzMzkgQzYuOTUxMTg0NDYsOC42NTgyOTEyNCA2Ljk1MTE4NDQ2LDguMzQxNzA4NzYgNy4xNDY0NDY2MSw4LjE0NjQ0NjYxIEwxMi4yOTI4OTMyLDMgWiBNMTEsMTAuNSBDMTEsMTAuMjIzODU3NiAxMS4yMjM4NTc2LDEwIDExLjUsMTAgQzExLjc3NjE0MjQsMTAgMTIsMTAuMjIzODU3NiAxMiwxMC41IEwxMiwxMi41IEMxMiwxMy4zMjg0MjcxIDExLjMyODQyNzEsMTQgMTAuNSwxNCBMMy41LDE0IEMyLjY3MTU3Mjg4LDE0IDIsMTMuMzI4NDI3MSAyLDEyLjUgTDIsNS41IEMyLDQuNjcxNTcyODggMi42NzE1NzI4OCw0IDMuNSw0IEw1LjUsNCBDNS43NzYxNDIzNyw0IDYsNC4yMjM4NTc2MyA2LDQuNSBDNiw0Ljc3NjE0MjM3IDUuNzc2MTQyMzcsNSA1LjUsNSBMMy41LDUgQzMuMjIzODU3NjMsNSAzLDUuMjIzODU3NjMgMyw1LjUgTDMsMTIuNSBDMywxMi43NzYxNDI0IDMuMjIzODU3NjMsMTMgMy41LDEzIEwxMC41LDEzIEMxMC43NzYxNDI0LDEzIDExLDEyLjc3NjE0MjQgMTEsMTIuNSBMMTEsMTAuNSBaIi8+PC9zdmc+');\n    --icon-FieldMarkdown: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiPjxwYXRoIGQ9Ik0uNSAxMS41LjUgNC41IDQgOC41IDcuNSA0LjUgNy41IDExLjVNMTUuNSA4LjUgMTMgMTEgMTAuNSA4LjVNMTMgMTEgMTMgNS41Ii8+PC9nPjwvc3ZnPg==');\n    --icon-FieldNumeric: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNC45MDIzNDM3NSwxMiBMMy44MzU5Mzc1LDEyIEwzLjgzNTkzNzUsNS4xNzM4MjgxMiBMMy43ODMyMDMxMiw1LjE3MzgyODEyIEwxLjkzMTY0MDYyLDYuNTA5NzY1NjIgTDEuOTMxNjQwNjIsNS40MDgyMDMxMiBMMy44MzU5Mzc1LDQuMDAxOTUzMTIgTDQuOTAyMzQzNzUsNC4wMDE5NTMxMiBMNC45MDIzNDM3NSwxMiBaIE01LjU1MjczNDM4LDExLjI3MzQzNzUgQzUuNTUyNzM0MzgsMTAuNzY5NTMxMiA1LjkyNzczNDM4LDEwLjQxNzk2ODggNi4zOTA2MjUsMTAuNDE3OTY4OCBDNi44NzY5NTMxMiwxMC40MTc5Njg4IDcuMjQ2MDkzNzUsMTAuNzY5NTMxMiA3LjI0NjA5Mzc1LDExLjI3MzQzNzUgQzcuMjQ2MDkzNzUsMTEuNzUzOTA2MiA2Ljg3Njk1MzEyLDEyLjEwNTQ2ODggNi4zOTA2MjUsMTIuMTA1NDY4OCBDNS45Mjc3MzQzOCwxMi4xMDU0Njg4IDUuNTUyNzM0MzgsMTEuNzUzOTA2MiA1LjU1MjczNDM4LDExLjI3MzQzNzUgWiBNOC4zMDY2NDA2Miw3Ljg2MzI4MTI1IEw4LjMwNjY0MDYyLDguMTQ0NTMxMjUgQzguMzA2NjQwNjIsMTAuMDYwNTQ2OSA4Ljk5ODA0Njg4LDExLjIyMDcwMzEgMTAuMDkzNzUsMTEuMjIwNzAzMSBDMTEuMTk1MzEyNSwxMS4yMjA3MDMxIDExLjg4MDg1OTQsMTAuMDYwNTQ2OSAxMS44ODA4NTk0LDguMTQ0NTMxMjUgTDExLjg4MDg1OTQsNy44NjMyODEyNSBDMTEuODgwODU5NCw1Ljk1MzEyNSAxMS4xOTUzMTI1LDQuNzgxMjUgMTAuMDkzNzUsNC43ODEyNSBDOC45OTgwNDY4OCw0Ljc4MTI1IDguMzA2NjQwNjIsNS45NTMxMjUgOC4zMDY2NDA2Miw3Ljg2MzI4MTI1IFogTTcuMjM0Mzc1LDguMTU2MjUgTDcuMjM0Mzc1LDcuODU3NDIxODggQzcuMjM0Mzc1LDUuNDM3NSA4LjI4MzIwMzEyLDMuODM3ODkwNjIgMTAuMTA1NDY4OCwzLjgzNzg5MDYyIEMxMS45MTYwMTU2LDMuODM3ODkwNjIgMTIuOTUzMTI1LDUuNDI1NzgxMjUgMTIuOTUzMTI1LDcuODU3NDIxODggTDEyLjk1MzEyNSw4LjE1NjI1IEMxMi45NTMxMjUsMTAuNTc2MTcxOSAxMS45MTAxNTYyLDEyLjE2NDA2MjUgMTAuMDkzNzUsMTIuMTY0MDYyNSBDOC4yODMyMDMxMiwxMi4xNjQwNjI1IDcuMjM0Mzc1LDEwLjU4MjAzMTIgNy4yMzQzNzUsOC4xNTYyNSBaIi8+PC9zdmc+');\n    --icon-FieldReference: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNS44NTM1NTMzOSwxMC44NTM1NTM0IEM1LjY1ODI5MTI0LDExLjA0ODgxNTUgNS4zNDE3MDg3NiwxMS4wNDg4MTU1IDUuMTQ2NDQ2NjEsMTAuODUzNTUzNCBDNC45NTExODQ0NiwxMC42NTgyOTEyIDQuOTUxMTg0NDYsMTAuMzQxNzA4OCA1LjE0NjQ0NjYxLDEwLjE0NjQ0NjYgTDEwLjE0NjQ0NjYsNS4xNDY0NDY2MSBDMTAuMzQxNzA4OCw0Ljk1MTE4NDQ2IDEwLjY1ODI5MTIsNC45NTExODQ0NiAxMC44NTM1NTM0LDUuMTQ2NDQ2NjEgQzExLjA0ODgxNTUsNS4zNDE3MDg3NiAxMS4wNDg4MTU1LDUuNjU4MjkxMjQgMTAuODUzNTUzNCw1Ljg1MzU1MzM5IEw1Ljg1MzU1MzM5LDEwLjg1MzU1MzQgWiBNNy4zNTM1NTMzOSw1LjM1MzU1MzM5IEM3LjE1ODI5MTI0LDUuNTQ4ODE1NTQgNi44NDE3MDg3Niw1LjU0ODgxNTU0IDYuNjQ2NDQ2NjEsNS4zNTM1NTMzOSBDNi40NTExODQ0Niw1LjE1ODI5MTI0IDYuNDUxMTg0NDYsNC44NDE3MDg3NiA2LjY0NjQ0NjYxLDQuNjQ2NDQ2NjEgTDguMzE4NTE4MiwyLjk3NDM3NTAzIEM5LjYxODQ2NDQ1LDEuNjc0OTU1MSAxMS43MjU1MzU2LDEuNjc0OTU1MSAxMy4wMjU2MjUsMi45NzQ1MTgyIEMxNC4zMjUwNDQ5LDQuMjc0NDY0NDUgMTQuMzI1MDQ0OSw2LjM4MTUzNTU1IDEzLjAyNTU1MzQsNy42ODE1NTMzOSBMMTEuMzUzNTUzNCw5LjM1MzU1MzM5IEMxMS4xNTgyOTEyLDkuNTQ4ODE1NTQgMTAuODQxNzA4OCw5LjU0ODgxNTU0IDEwLjY0NjQ0NjYsOS4zNTM1NTMzOSBDMTAuNDUxMTg0NSw5LjE1ODI5MTI0IDEwLjQ1MTE4NDUsOC44NDE3MDg3NiAxMC42NDY0NDY2LDguNjQ2NDQ2NjEgTDEyLjMxODM3NSw2Ljk3NDUxODIgQzEzLjIyNzQ2MTUsNi4wNjUwNjM0NyAxMy4yMjc0NjE1LDQuNTkwOTM2NTMgMTIuMzE4NTE4MiwzLjY4MTYyNDk3IEMxMS40MDkwNjM1LDIuNzcyNTM4NDYgOS45MzQ5MzY1MywyLjc3MjUzODQ2IDkuMDI1NTUzMzksMy42ODE1NTMzOSBMNy4zNTM1NTMzOSw1LjM1MzU1MzM5IFogTTQuNjQ2NDQ2NjEsNi42NDY0NDY2MSBDNC44NDE3MDg3Niw2LjQ1MTE4NDQ2IDUuMTU4MjkxMjQsNi40NTExODQ0NiA1LjM1MzU1MzM5LDYuNjQ2NDQ2NjEgQzUuNTQ4ODE1NTQsNi44NDE3MDg3NiA1LjU0ODgxNTU0LDcuMTU4MjkxMjQgNS4zNTM1NTMzOSw3LjM1MzU1MzM5IEwzLjY4MTYyNDk3LDkuMDI1NDgxOCBDMi43NzI1Mzg0Niw5LjkzNDkzNjUzIDIuNzcyNTM4NDYsMTEuNDA5MDYzNSAzLjY4MTQ4MTgsMTIuMzE4Mzc1IEM0LjU5MDkzNjUzLDEzLjIyNzQ2MTUgNi4wNjUwNjM0NywxMy4yMjc0NjE1IDYuOTc0NDQ2NjEsMTIuMzE4NDQ2NiBMOC42NDY0NDY2MSwxMC42NDY0NDY2IEM4Ljg0MTcwODc2LDEwLjQ1MTE4NDUgOS4xNTgyOTEyNCwxMC40NTExODQ1IDkuMzUzNTUzMzksMTAuNjQ2NDQ2NiBDOS41NDg4MTU1NCwxMC44NDE3MDg4IDkuNTQ4ODE1NTQsMTEuMTU4MjkxMiA5LjM1MzU1MzM5LDExLjM1MzU1MzQgTDcuNjgxNDgxOCwxMy4wMjU2MjUgQzYuMzgxNTM1NTUsMTQuMzI1MDQ0OSA0LjI3NDQ2NDQ1LDE0LjMyNTA0NDkgMi45NzQzNzUwMywxMy4wMjU0ODE4IEMxLjY3NDk1NTEsMTEuNzI1NTM1NiAxLjY3NDk1NTEsOS42MTg0NjQ0NSAyLjk3NDQ0NjYxLDguMzE4NDQ2NjEgTDQuNjQ2NDQ2NjEsNi42NDY0NDY2MSBaIi8+PC9zdmc+');\n    --icon-FieldReferenceDisabled: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJtIDcuMzUzNTUzNCw1LjM1MzU1MzQgYyAtMC4xOTUyNjIyLDAuMTk1MjYyMSAtMC41MTE4NDQ2LDAuMTk1MjYyMSAtMC43MDcxMDY4LDAgLTAuMTk1MjYyMSwtMC4xOTUyNjIyIC0wLjE5NTI2MjEsLTAuNTExODQ0NiAwLC0wLjcwNzEwNjggTCA4LjMxODUxODIsMi45NzQzNzUgYyAxLjI5OTk0NjIsLTEuMjk5NDE5OSAzLjQwNzAxNzgsLTEuMjk5NDE5OSA0LjcwNzEwNjgsMS40MzJlLTQgMS4yOTk0MiwxLjI5OTk0NjIgMS4yOTk0MiwzLjQwNzAxNzMgLTcuMmUtNSw0LjcwNzAzNTIgbCAtMS42NzIsMS42NzIgYyAtMC4xOTUyNjIsMC4xOTUyNjIxIC0wLjUxMTg0NCwwLjE5NTI2MjEgLTAuNzA3MTA2LDAgLTAuMTk1MjYyLC0wLjE5NTI2MjIgLTAuMTk1MjYyLC0wLjUxMTg0NDYgMCwtMC43MDcxMDY4IEwgMTIuMzE4Mzc1LDYuOTc0NTE4MiBDIDEzLjIyNzQ2Miw2LjA2NTA2MzUgMTMuMjI3NDYyLDQuNTkwOTM2NSAxMi4zMTg1MTgsMy42ODE2MjUgMTEuNDA5MDY0LDIuNzcyNTM4NSA5LjkzNDkzNjUsMi43NzI1Mzg1IDkuMDI1NTUzNCwzLjY4MTU1MzQgWiBNIDQuNjQ2NDQ2Niw2LjY0NjQ0NjYgYyAwLjE5NTI2MjIsLTAuMTk1MjYyMSAwLjUxMTg0NDYsLTAuMTk1MjYyMSAwLjcwNzEwNjgsMCAwLjE5NTI2MjEsMC4xOTUyNjIyIDAuMTk1MjYyMSwwLjUxMTg0NDYgMCwwLjcwNzEwNjggTCAzLjY4MTYyNSw5LjAyNTQ4MTggYyAtMC45MDkwODY1LDAuOTA5NDU0NyAtMC45MDkwODY1LDIuMzgzNTgyMiAtMS40MzJlLTQsMy4yOTI4OTMyIDAuOTA5NDU0NywwLjkwOTA4NyAyLjM4MzU4MTcsMC45MDkwODcgMy4yOTI5NjQ4LDcuMmUtNSBsIDEuNjcyLC0xLjY3MiBjIDAuMTk1MjYyMiwtMC4xOTUyNjIgMC41MTE4NDQ2LC0wLjE5NTI2MiAwLjcwNzEwNjgsMCAwLjE5NTI2MjEsMC4xOTUyNjIgMC4xOTUyNjIxLDAuNTExODQ0IDAsMC43MDcxMDYgTCA3LjY4MTQ4MTgsMTMuMDI1NjI1IEMgNi4zODE1MzU1LDE0LjMyNTA0NSA0LjI3NDQ2NDQsMTQuMzI1MDQ1IDIuOTc0Mzc1LDEzLjAyNTQ4MiAxLjY3NDk1NTEsMTEuNzI1NTM2IDEuNjc0OTU1MSw5LjYxODQ2NDQgMi45NzQ0NDY2LDguMzE4NDQ2NiBaIi8+PC9zdmc+');\n    --icon-FieldSpinner: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTEuMTc0NjA0MywxMC4xMjAzNzE3IEMxMS4zODQyNjcyLDkuOTQwNjYwNjIgMTEuNjk5OTE3Miw5Ljk2NDk0MTM5IDExLjg3OTYyODMsMTAuMTc0NjA0MyBDMTIuMDU5MzM5NCwxMC4zODQyNjcyIDEyLjAzNTA1ODYsMTAuNjk5OTE3MiAxMS44MjUzOTU3LDEwLjg3OTYyODMgTDguMzI1Mzk1NjksMTMuODc5NjI4MyBDOC4xMzgxNTA4MiwxNC4wNDAxMjM5IDcuODYxODQ5MTgsMTQuMDQwMTIzOSA3LjY3NDYwNDMxLDEzLjg3OTYyODMgTDQuMTc0NjA0MzEsMTAuODc5NjI4MyBDMy45NjQ5NDEzOSwxMC42OTk5MTcyIDMuOTQwNjYwNjIsMTAuMzg0MjY3MiA0LjEyMDM3MTcsMTAuMTc0NjA0MyBDNC4zMDAwODI3Nyw5Ljk2NDk0MTM5IDQuNjE1NzMyNzcsOS45NDA2NjA2MiA0LjgyNTM5NTY5LDEwLjEyMDM3MTcgTDgsMTIuODQxNDYxMSBMMTEuMTc0NjA0MywxMC4xMjAzNzE3IFogTTQuODI1Mzk1NjksNS44Nzk2MjgzIEM0LjYxNTczMjc3LDYuMDU5MzM5MzggNC4zMDAwODI3Nyw2LjAzNTA1ODYxIDQuMTIwMzcxNyw1LjgyNTM5NTY5IEMzLjk0MDY2MDYyLDUuNjE1NzMyNzcgMy45NjQ5NDEzOSw1LjMwMDA4Mjc3IDQuMTc0NjA0MzEsNS4xMjAzNzE3IEw3LjY3NDYwNDMxLDIuMTIwMzcxNyBDNy44NjE4NDkxOCwxLjk1OTg3NjEgOC4xMzgxNTA4MiwxLjk1OTg3NjEgOC4zMjUzOTU2OSwyLjEyMDM3MTcgTDExLjgyNTM5NTcsNS4xMjAzNzE3IEMxMi4wMzUwNTg2LDUuMzAwMDgyNzcgMTIuMDU5MzM5NCw1LjYxNTczMjc3IDExLjg3OTYyODMsNS44MjUzOTU2OSBDMTEuNjk5OTE3Miw2LjAzNTA1ODYxIDExLjM4NDI2NzIsNi4wNTkzMzkzOCAxMS4xNzQ2MDQzLDUuODc5NjI4MyBMOCwzLjE1ODUzODg5IEw0LjgyNTM5NTY5LDUuODc5NjI4MyBaIi8+PC9zdmc+');\n    --icon-FieldSwitcher: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOC42NDU4MjQ0LDExIEwxMCwxMSBDMTEuNjU2ODU0MiwxMSAxMyw5LjY1Njg1NDI1IDEzLDggQzEzLDYuMzQzMTQ1NzUgMTEuNjU2ODU0Miw1IDEwLDUgTDguNjQ1ODI0NCw1IEM5LjQ3NjI0NTExLDUuNzMyOTQ0NDUgMTAsNi44MDUzMDc0NyAxMCw4IEMxMCw5LjE5NDY5MjUzIDkuNDc2MjQ1MTEsMTAuMjY3MDU1NSA4LjY0NTgyNDQsMTEgWiBNNiw0IEwxMCw0IEMxMi4yMDkxMzksNCAxNCw1Ljc5MDg2MSAxNCw4IEMxNCwxMC4yMDkxMzkgMTIuMjA5MTM5LDEyIDEwLDEyIEw2LDEyIEMzLjc5MDg2MSwxMiAyLDEwLjIwOTEzOSAyLDggQzIsNS43OTA4NjEgMy43OTA4NjEsNCA2LDQgWiBNNiwxMSBDNy42NTY4NTQyNSwxMSA5LDkuNjU2ODU0MjUgOSw4IEM5LDYuMzQzMTQ1NzUgNy42NTY4NTQyNSw1IDYsNSBDNC4zNDMxNDU3NSw1IDMsNi4zNDMxNDU3NSAzLDggQzMsOS42NTY4NTQyNSA0LjM0MzE0NTc1LDExIDYsMTEgWiIvPjwvc3ZnPg==');\n    --icon-FieldTable: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOC41LDMgTDguNSw3LjUgTDEzLDcuNSBMMTMsMy41IEMxMywzLjIyMzg1NzYzIDEyLjc3NjE0MjQsMyAxMi41LDMgTDguNSwzIFogTTcuNSwzIEwzLjUsMyBDMy4yMjM4NTc2MywzIDMsMy4yMjM4NTc2MyAzLDMuNSBMMyw3LjUgTDcuNSw3LjUgTDcuNSwzIFogTTMsOC41IEwzLDEyLjUgQzMsMTIuNzc2MTQyNCAzLjIyMzg1NzYzLDEzIDMuNSwxMyBMNy41LDEzIEw3LjUsOC41IEwzLDguNSBaIE04LjUsMTMgTDEyLjUsMTMgQzEyLjc3NjE0MjQsMTMgMTMsMTIuNzc2MTQyNCAxMywxMi41IEwxMyw4LjUgTDguNSw4LjUgTDguNSwxMyBaIE0zLjUsMiBMMTIuNSwyIEMxMy4zMjg0MjcxLDIgMTQsMi42NzE1NzI4OCAxNCwzLjUgTDE0LDEyLjUgQzE0LDEzLjMyODQyNzEgMTMuMzI4NDI3MSwxNCAxMi41LDE0IEwzLjUsMTQgQzIuNjcxNTcyODgsMTQgMiwxMy4zMjg0MjcxIDIsMTIuNSBMMiwzLjUgQzIsMi42NzE1NzI4OCAyLjY3MTU3Mjg4LDIgMy41LDIgWiIvPjwvc3ZnPg==');\n    --icon-FieldText: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNy41LDEzIEw3LjUsMyBMMywzIEwzLDQuNSBDMyw0Ljc3NjE0MjM3IDIuNzc2MTQyMzcsNSAyLjUsNSBDMi4yMjM4NTc2Myw1IDIsNC43NzYxNDIzNyAyLDQuNSBMMiwyLjUgQzIsMi4yMjM4NTc2MyAyLjIyMzg1NzYzLDIgMi41LDIgTDEzLjUsMiBDMTMuNzc2MTQyNCwyIDE0LDIuMjIzODU3NjMgMTQsMi41IEwxNCw0LjUgQzE0LDQuNzc2MTQyMzcgMTMuNzc2MTQyNCw1IDEzLjUsNSBDMTMuMjIzODU3Niw1IDEzLDQuNzc2MTQyMzcgMTMsNC41IEwxMywzIEw4LjUsMyBMOC41LDEzIEwxMC41LDEzIEMxMC43NzYxNDI0LDEzIDExLDEzLjIyMzg1NzYgMTEsMTMuNSBDMTEsMTMuNzc2MTQyNCAxMC43NzYxNDI0LDE0IDEwLjUsMTQgTDUuNSwxNCBDNS4yMjM4NTc2MywxNCA1LDEzLjc3NjE0MjQgNSwxMy41IEM1LDEzLjIyMzg1NzYgNS4yMjM4NTc2MywxMyA1LjUsMTMgTDcuNSwxMyBaIi8+PC9zdmc+');\n    --icon-FieldTextbox: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMi41LDEzIEMyLjIyMzg1NzYzLDEzIDIsMTIuNzc2MTQyNCAyLDEyLjUgQzIsMTIuMjIzODU3NiAyLjIyMzg1NzYzLDEyIDIuNSwxMiBMMTMuNSwxMiBDMTMuNzc2MTQyNCwxMiAxNCwxMi4yMjM4NTc2IDE0LDEyLjUgQzE0LDEyLjc3NjE0MjQgMTMuNzc2MTQyNCwxMyAxMy41LDEzIEwyLjUsMTMgWiBNMi41LDQgQzIuMjIzODU3NjMsNCAyLDMuNzc2MTQyMzcgMiwzLjUgQzIsMy4yMjM4NTc2MyAyLjIyMzg1NzYzLDMgMi41LDMgTDEzLjUsMyBDMTMuNzc2MTQyNCwzIDE0LDMuMjIzODU3NjMgMTQsMy41IEMxNCwzLjc3NjE0MjM3IDEzLjc3NjE0MjQsNCAxMy41LDQgTDIuNSw0IFogTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFoiLz48L3N2Zz4=');\n    --icon-FieldToggle: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOC42NDU4MjQ0LDExIEwxMCwxMSBDMTEuNjU2ODU0MiwxMSAxMyw5LjY1Njg1NDI1IDEzLDggQzEzLDYuMzQzMTQ1NzUgMTEuNjU2ODU0Miw1IDEwLDUgTDguNjQ1ODI0NCw1IEM5LjQ3NjI0NTExLDUuNzMyOTQ0NDUgMTAsNi44MDUzMDc0NyAxMCw4IEMxMCw5LjE5NDY5MjUzIDkuNDc2MjQ1MTEsMTAuMjY3MDU1NSA4LjY0NTgyNDQsMTEgWiBNNiw0IEwxMCw0IEMxMi4yMDkxMzksNCAxNCw1Ljc5MDg2MSAxNCw4IEMxNCwxMC4yMDkxMzkgMTIuMjA5MTM5LDEyIDEwLDEyIEw2LDEyIEMzLjc5MDg2MSwxMiAyLDEwLjIwOTEzOSAyLDggQzIsNS43OTA4NjEgMy43OTA4NjEsNCA2LDQgWiBNNiwxMSBDNy42NTY4NTQyNSwxMSA5LDkuNjU2ODU0MjUgOSw4IEM5LDYuMzQzMTQ1NzUgNy42NTY4NTQyNSw1IDYsNSBDNC4zNDMxNDU3NSw1IDMsNi4zNDMxNDU3NSAzLDggQzMsOS42NTY4NTQyNSA0LjM0MzE0NTc1LDExIDYsMTEgWiIvPjwvc3ZnPg==');\n    --icon-LoginStreamline: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgZmlsbD0ibm9uZSI+PGcgZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXBhdGg9InVybCgjYSkiIGNsaXAtcnVsZT0iZXZlbm9kZCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTM5LjU0MDIgMEgxOC4wMDQ3QzguMDY3NTIgMCAwIDguOTc3NCAwIDIwLjAzNTNWNDBIMjEuNzA1MUMzMS41NDg3IDQwIDM5LjU0MDIgMzEuMTA2OCAzOS41NDAyIDIwLjE1MzFWMFoiLz48cGF0aCBmaWxsPSIjRjlBRTQxIiBkPSJNMjMuNDkxNyAyNy45MzA2TDI5LjQ1MyAxOC44MzA3QzI5LjY1MiAxOC41MjY4IDI5LjU3NzYgMTguMTExOSAyOS4yODY2IDE3LjkwNEMyOS4xODA1IDE3LjgyODEgMjkuMDU0OCAxNy43ODc1IDI4LjkyNjIgMTcuNzg3NUgyNC4yNzlWMTEuOTk0OEMyNC4yNzkgMTEuNjI2NiAyMy45OTMyIDExLjMyODEgMjMuNjQwNyAxMS4zMjgxQzIzLjQzIDExLjMyODEgMjMuMjMyOSAxMS40MzY3IDIzLjExMzkgMTEuNjE4M0wxNy4xNTI2IDIwLjcxODNDMTYuOTUzNSAyMS4wMjIxIDE3LjAyOCAyMS40MzcgMTcuMzE4OSAyMS42NDQ5QzE3LjQyNTEgMjEuNzIwOCAxNy41NTA3IDIxLjc2MTQgMTcuNjc5NCAyMS43NjE0SDIyLjMyNjZWMjcuNTU0MkMyMi4zMjY2IDI3LjkyMjMgMjIuNjEyMyAyOC4yMjA4IDIyLjk2NDkgMjguMjIwOEMyMy4xNzU2IDI4LjIyMDggMjMuMzcyNyAyOC4xMTIyIDIzLjQ5MTcgMjcuOTMwNloiLz48cGF0aCBmaWxsPSIjRjlBRTQxIiBkPSJNMTcuMTQ1OCAyNC43NDE5QzE3Ljg1MDggMjQuNzQxOSAxOC40MjI0IDI1LjMzODggMTguNDIyNCAyNi4wNzUyVjI2LjM4ODlDMTguNDIyNCAyNy4xMjUzIDE3Ljg1MDggMjcuNzIyMyAxNy4xNDU4IDI3LjcyMjNIMTEuODg5MkMxMS4xODQxIDI3LjcyMjMgMTAuNjEyNiAyNy4xMjUzIDEwLjYxMjYgMjYuMzg4OVYyNi4wNzUyQzEwLjYxMjYgMjUuMzM4OCAxMS4xODQxIDI0Ljc0MTkgMTEuODg5MiAyNC43NDE5SDE3LjE0NThaTTE0LjIxNzEgMTguNzgxMUMxNC45MjIxIDE4Ljc4MTEgMTUuNDkzNyAxOS4zNzgxIDE1LjQ5MzcgMjAuMTE0NFYyMC40MjgyQzE1LjQ5MzcgMjEuMTY0NSAxNC45MjIxIDIxLjc2MTUgMTQuMjE3MSAyMS43NjE1SDkuOTM2NzVDOS4yMzE3MSAyMS43NjE1IDguNjYwMTYgMjEuMTY0NSA4LjY2MDE2IDIwLjQyODJWMjAuMTE0NEM4LjY2MDE2IDE5LjM3ODEgOS4yMzE3MSAxOC43ODExIDkuOTM2NzUgMTguNzgxMUgxNC4yMTcxWk0xNy4xNDU4IDEyLjgyMDNDMTcuODUwOCAxMi44MjAzIDE4LjQyMjQgMTMuNDE3MyAxOC40MjI0IDE0LjE1MzZWMTQuNDY3NEMxOC40MjI0IDE1LjIwMzggMTcuODUwOCAxNS44MDA3IDE3LjE0NTggMTUuODAwN0gxMS44ODkyQzExLjE4NDEgMTUuODAwNyAxMC42MTI2IDE1LjIwMzggMTAuNjEyNiAxNC40Njc0VjE0LjE1MzZDMTAuNjEyNiAxMy40MTczIDExLjE4NDEgMTIuODIwMyAxMS44ODkyIDEyLjgyMDNIMTcuMTQ1OFoiIG9wYWNpdHk9Ii40NCIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBINDBWNDBIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');\n    --icon-LoginUnify: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgZmlsbD0ibm9uZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOS41NDAyIDBIMTguMDA0N0M4LjA2NzUyIDAgMCA4Ljk3NzQgMCAyMC4wMzUzVjQwSDIxLjcwNTFDMzEuNTQ4NyA0MCAzOS41NDAyIDMxLjEwNjggMzkuNTQwMiAyMC4xNTMxVjBaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48cGF0aCBmaWxsPSIjNzE0MUY5IiBkPSJNMTMuODMxMiAxNi43Nzg2VjIzLjIyM0MxMy44MzEyIDI1LjAwMjYgMTUuMjEyNSAyNi40NDUyIDE2LjkxNjMgMjYuNDQ1MkgyMy4wODY1VjI3LjMyNEMyMy4wODY1IDI4Ljg0MzEgMjIuMjk3MiAyOS42Njc1IDIwLjg0MjggMjkuNjY3NUgxMi45ODk4QzExLjUzNTQgMjkuNjY3NSAxMC43NDYxIDI4Ljg0MzEgMTAuNzQ2MSAyNy4zMjRWMTkuMTIyQzEwLjc0NjEgMTcuNjAyOSAxMS41MzU0IDE2Ljc3ODYgMTIuOTg5OCAxNi43Nzg2SDEzLjgzMTJaTTI1Ljk4NDcgMTEuNDA4MkMyNy40MzkxIDExLjQwODIgMjguMjI4NCAxMi4yMzI2IDI4LjIyODQgMTMuNzUxNlYyMS45NTM3QzI4LjIyODQgMjMuNDcyNyAyNy40MzkxIDI0LjI5NzEgMjUuOTg0NyAyNC4yOTcxSDI1LjE0MzNWMTcuODUyNkMyNS4xNDMzIDE2LjA3MzEgMjMuNzYyIDE0LjYzMDQgMjIuMDU4MiAxNC42MzA0SDE1Ljg4NzlWMTMuNzUxNkMxNS44ODc5IDEyLjIzMjYgMTYuNjc3MiAxMS40MDgyIDE4LjEzMTcgMTEuNDA4MkgyNS45ODQ3WiIgb3BhY2l0eT0iLjQ0Ii8+PHBhdGggZmlsbD0iIzcxNDFGOSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMTcuMTk3NSAxNi43NzkzSDIxLjc3ODRDMjIuNjI2OCAxNi43NzkzIDIzLjA4NzMgMTcuMjYwMiAyMy4wODczIDE4LjE0NjNWMjIuOTMwOEMyMy4wODczIDIzLjgxNjkgMjIuNjI2OCAyNC4yOTc4IDIxLjc3ODQgMjQuMjk3OEgxNy4xOTc1QzE2LjM0OTEgMjQuMjk3OCAxNS44ODg3IDIzLjgxNjkgMTUuODg4NyAyMi45MzA4VjE4LjE0NjNDMTUuODg4NyAxNy4yNjAyIDE2LjM0OTEgMTYuNzc5MyAxNy4xOTc1IDE2Ljc3OTNaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDQwVjQwSDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+');\n    --icon-LoginVisualize: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgZmlsbD0ibm9uZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBmaWxsPSIjZmZmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOS41NDAyIDBIMTguMDA0N0M4LjA2NzUyIDAgMCA4Ljk3NzQgMCAyMC4wMzUzVjQwSDIxLjcwNTFDMzEuNTQ4NyA0MCAzOS41NDAyIDMxLjEwNjggMzkuNTQwMiAyMC4xNTMxVjBaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48cGF0aCBmaWxsPSIjMTZCMzc4IiBkPSJNMjMuOTcyOSAxMi45OTk2QzIzLjk3MjkgMTIuMjI2NCAyMy4zMDYxIDExLjU5OTYgMjIuNDgzNSAxMS41OTk2QzIxLjY2MSAxMS41OTk2IDIwLjk5NDEgMTIuMjI2NCAyMC45OTQxIDEyLjk5OTZWMjYuMDY2M0MyMC45OTQxIDI2LjgzOTUgMjEuNjYxIDI3LjQ2NjMgMjIuNDgzNSAyNy40NjYzQzIzLjMwNjEgMjcuNDY2MyAyMy45NzI5IDI2LjgzOTUgMjMuOTcyOSAyNi4wNjYzVjEyLjk5OTZaIiBvcGFjaXR5PSIuNDQiLz48cGF0aCBmaWxsPSIjMTZCMzc4IiBkPSJNMTkuMDA4IDE3Ljk1NzZDMTkuMDA4IDE3LjE4NDQgMTguMzQxMiAxNi41NTc2IDE3LjUxODcgMTYuNTU3NiAxNi42OTYxIDE2LjU1NzYgMTYuMDI5MyAxNy4xODQ0IDE2LjAyOTMgMTcuOTU3NlYyNi4wNjZDMTYuMDI5MyAyNi44MzkxIDE2LjY5NjEgMjcuNDY2IDE3LjUxODcgMjcuNDY2IDE4LjM0MTIgMjcuNDY2IDE5LjAwOCAyNi44MzkxIDE5LjAwOCAyNi4wNjZWMTcuOTU3NlpNMjguOTM1OCAxOS45NDFDMjguOTM1OCAxOS4xNjc4IDI4LjI2ODkgMTguNTQxIDI3LjQ0NjQgMTguNTQxIDI2LjYyMzggMTguNTQxIDI1Ljk1NyAxOS4xNjc4IDI1Ljk1NyAxOS45NDFWMjYuMDY2QzI1Ljk1NyAyNi44MzkyIDI2LjYyMzggMjcuNDY2IDI3LjQ0NjQgMjcuNDY2IDI4LjI2ODkgMjcuNDY2IDI4LjkzNTggMjYuODM5MiAyOC45MzU4IDI2LjA2NlYxOS45NDFaTTE0LjA0MzIgMjEuOTI1NEMxNC4wNDMyIDIxLjE1MjIgMTMuMzc2NCAyMC41MjU0IDEyLjU1MzggMjAuNTI1NCAxMS43MzEzIDIwLjUyNTQgMTEuMDY0NSAyMS4xNTIyIDExLjA2NDUgMjEuOTI1NFYyNi4wNjcxQzExLjA2NDUgMjYuODQwMyAxMS43MzEzIDI3LjQ2NzEgMTIuNTUzOCAyNy40NjcxIDEzLjM3NjQgMjcuNDY3MSAxNC4wNDMyIDI2Ljg0MDMgMTQuMDQzMiAyNi4wNjcxVjIxLjkyNTRaIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iYSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMEg0MFY0MEgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');\n    --icon-GoogleLogo: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZmlsbD0iIzQyODVGNCIgZD0iTSAtMy4yNjQgNTEuNTA5IEMgLTMuMjY0IDUwLjcxOSAtMy4zMzQgNDkuOTY5IC0zLjQ1NCA0OS4yMzkgTCAtMTQuNzU0IDQ5LjIzOSBMIC0xNC43NTQgNTMuNzQ5IEwgLTguMjg0IDUzLjc0OSBDIC04LjU3NCA1NS4yMjkgLTkuNDI0IDU2LjQ3OSAtMTAuNjg0IDU3LjMyOSBMIC0xMC42ODQgNjAuMzI5IEwgLTYuODI0IDYwLjMyOSBDIC00LjU2NCA1OC4yMzkgLTMuMjY0IDU1LjE1OSAtMy4yNjQgNTEuNTA5IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI3LjAwOSAtMzkuMjM5KSIvPjxwYXRoIGZpbGw9IiMzNEE4NTMiIGQ9Ik0gLTE0Ljc1NCA2My4yMzkgQyAtMTEuNTE0IDYzLjIzOSAtOC44MDQgNjIuMTU5IC02LjgyNCA2MC4zMjkgTCAtMTAuNjg0IDU3LjMyOSBDIC0xMS43NjQgNTguMDQ5IC0xMy4xMzQgNTguNDg5IC0xNC43NTQgNTguNDg5IEMgLTE3Ljg4NCA1OC40ODkgLTIwLjUzNCA1Ni4zNzkgLTIxLjQ4NCA1My41MjkgTCAtMjUuNDY0IDUzLjUyOSBMIC0yNS40NjQgNTYuNjE5IEMgLTIzLjQ5NCA2MC41MzkgLTE5LjQ0NCA2My4yMzkgLTE0Ljc1NCA2My4yMzkgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjcuMDA5IC0zOS4yMzkpIi8+PHBhdGggZmlsbD0iI0ZCQkMwNSIgZD0iTSAtMjEuNDg0IDUzLjUyOSBDIC0yMS43MzQgNTIuODA5IC0yMS44NjQgNTIuMDM5IC0yMS44NjQgNTEuMjM5IEMgLTIxLjg2NCA1MC40MzkgLTIxLjcyNCA0OS42NjkgLTIxLjQ4NCA0OC45NDkgTCAtMjEuNDg0IDQ1Ljg1OSBMIC0yNS40NjQgNDUuODU5IEMgLTI2LjI4NCA0Ny40NzkgLTI2Ljc1NCA0OS4yOTkgLTI2Ljc1NCA1MS4yMzkgQyAtMjYuNzU0IDUzLjE3OSAtMjYuMjg0IDU0Ljk5OSAtMjUuNDY0IDU2LjYxOSBMIC0yMS40ODQgNTMuNTI5IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI3LjAwOSAtMzkuMjM5KSIvPjxwYXRoIGZpbGw9IiNFQTQzMzUiIGQ9Ik0gLTE0Ljc1NCA0My45ODkgQyAtMTIuOTg0IDQzLjk4OSAtMTEuNDA0IDQ0LjU5OSAtMTAuMTU0IDQ1Ljc4OSBMIC02LjczNCA0Mi4zNjkgQyAtOC44MDQgNDAuNDI5IC0xMS41MTQgMzkuMjM5IC0xNC43NTQgMzkuMjM5IEMgLTE5LjQ0NCAzOS4yMzkgLTIzLjQ5NCA0MS45MzkgLTI1LjQ2NCA0NS44NTkgTCAtMjEuNDg0IDQ4Ljk0OSBDIC0yMC41MzQgNDYuMDk5IC0xNy44ODQgNDMuOTg5IC0xNC43NTQgNDMuOTg5IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI3LjAwOSAtMzkuMjM5KSIvPjwvc3ZnPg==');\n    --icon-GristLogo: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MSIgaGVpZ2h0PSIzOCI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBmaWxsPSIjMkNCMEFGIiBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDI4LjEzNCAxLjE2MikiLz48cGF0aCBmaWxsPSIjMkNCMEFGIiBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDI1LjU3IDEuMTYyKSIvPjxwYXRoIGZpbGw9IiNGOUFFNDEiIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTIuMDE1IDEzLjc0KSIvPjxwYXRoIGZpbGw9IiNGOUFFNDEiIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMTIuMDE1IDI2LjMxOSkiLz48cGF0aCBmaWxsPSIjRDJEMkQyIiBkPSJNMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgTDUuMDE1NTg3NDMsMC4wOTg1OTE3MzQ3IEMyLjMxNzYzNDkxLDAuMDk4NTkxNzM0NyAwLjEyNzMwMTg2LDIuMjg4OTIwMDkgMC4xMjczMDE4Niw0Ljk4Njg0MTgzIEwwLjEyNzMwMTg2LDkuODU3ODg5NzIgTDYuMDIwMjM5OTUsOS44NTc4ODk3MiBDOC42OTI3ODUwMyw5Ljg1Nzg4OTcyIDEwLjg2MjQ3NDUsNy42ODgxMDEzMSAxMC44NjI0NzQ1LDUuMDE1NTk3NzUgTDEwLjg2MjQ3NDUsMC4wOTg1OTE3MzQ3IFoiIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDI1LjU3IDEzLjc0KSIvPjxwYXRoIGZpbGw9IiNEMkQyRDIiIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjguMTM0IDEzLjc0KSIvPjxwYXRoIGZpbGw9IiNEMkQyRDIiIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJtYXRyaXgoLTEgMCAwIDEgMjUuNTcgMjYuMzE5KSIvPjxwYXRoIGZpbGw9IiNEMkQyRDIiIGQ9Ik0xMC44NjI0NzQ1LDAuMDk4NTkxNzM0NyBMNS4wMTU1ODc0MywwLjA5ODU5MTczNDcgQzIuMzE3NjM0OTEsMC4wOTg1OTE3MzQ3IDAuMTI3MzAxODYsMi4yODg5MjAwOSAwLjEyNzMwMTg2LDQuOTg2ODQxODMgTDAuMTI3MzAxODYsOS44NTc4ODk3MiBMNi4wMjAyMzk5NSw5Ljg1Nzg4OTcyIEM4LjY5Mjc4NTAzLDkuODU3ODg5NzIgMTAuODYyNDc0NSw3LjY4ODEwMTMxIDEwLjg2MjQ3NDUsNS4wMTU1OTc3NSBMMTAuODYyNDc0NSwwLjA5ODU5MTczNDcgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjguMTM0IDI2LjMxOSkiLz48L2c+PC9zdmc+');\n    --icon-ThumbPreview: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iNDYiIGhlaWdodD0iNDYiPjxkZWZzPjxwYXRoIGlkPSJhIiBkPSJNNC4wNTc2MTcxOSw2LjM2ODE2NDA2IEw0LjA1NzYxNzE5LDUuMTYyMTA5MzggTDcuMTcyODUxNTYsNS4xNjIxMDkzOCBMNy4xNzI4NTE1Niw4LjAxMzY3MTg4IEM2Ljg3MDExNTY3LDguMzA2NjQyMDkgNi40MzE0ODA3NCw4LjU2NDYxNDc3IDUuODU2OTMzNTksOC43ODc1OTc2NiBDNS4yODIzODY0NSw5LjAxMDU4MDU0IDQuNzAwNTIzNzgsOS4xMjIwNzAzMSA0LjExMTMyODEyLDkuMTIyMDcwMzEgQzMuMzYyNjI2NDYsOS4xMjIwNzAzMSAyLjcwOTk2MzcyLDguOTY1MDA4MDggMi4xNTMzMjAzMSw4LjY1MDg3ODkxIEMxLjU5NjY3NjksOC4zMzY3NDk3MyAxLjE3ODM4NjgyLDcuODg3NTM1NDcgMC44OTg0Mzc1LDcuMzAzMjIyNjYgQzAuNjE4NDg4MTg0LDYuNzE4OTA5ODQgMC40Nzg1MTU2MjUsNi4wODMzMzY3NyAwLjQ3ODUxNTYyNSw1LjM5NjQ4NDM4IEMwLjQ3ODUxNTYyNSw0LjY1MTAzNzk0IDAuNjM0NzY0MDYyLDMuOTg4NjA5NjcgMC45NDcyNjU2MjUsMy40MDkxNzk2OSBDMS4yNTk3NjcxOSwyLjgyOTc0OTcxIDEuNzE3MTE5MzgsMi4zODU0MTgyMSAyLjMxOTMzNTk0LDIuMDc2MTcxODggQzIuNzc4MzIyNjEsMS44Mzg1NDA0OCAzLjM0OTYwNTk2LDEuNzE5NzI2NTYgNC4wMzMyMDMxMiwxLjcxOTcyNjU2IEM0LjkyMTg3OTQ0LDEuNzE5NzI2NTYgNS42MTYwNDU2OCwxLjkwNjA4NTM4IDYuMTE1NzIyNjYsMi4yNzg4MDg1OSBDNi42MTUzOTk2MywyLjY1MTUzMTgxIDYuOTM2ODQ4MjQsMy4xNjY2NjMzOCA3LjA4MDA3ODEyLDMuODI0MjE4NzUgTDUuNjQ0NTMxMjUsNC4wOTI3NzM0NCBDNS41NDM2MTkyOSwzLjc0MTIwOTE4IDUuMzU0MDA1MywzLjQ2MzcwNTQ0IDUuMDc1NjgzNTksMy4yNjAyNTM5MSBDNC43OTczNjE4OSwzLjA1NjgwMjM3IDQuNDQ5ODcxODcsMi45NTUwNzgxMiA0LjAzMzIwMzEyLDIuOTU1MDc4MTIgQzMuNDAxNjg5NTUsMi45NTUwNzgxMiAyLjg5OTU3ODY5LDMuMTU1MjcxNDQgMi41MjY4NTU0NywzLjU1NTY2NDA2IEMyLjE1NDEzMjI1LDMuOTU2MDU2NjkgMS45Njc3NzM0NCw0LjU1MDEyNjI3IDEuOTY3NzczNDQsNS4zMzc4OTA2MiBDMS45Njc3NzM0NCw2LjE4NzUwNDI1IDIuMTU2NTczNjMsNi44MjQ3MDQ5MSAyLjUzNDE3OTY5LDcuMjQ5NTExNzIgQzIuOTExNzg1NzQsNy42NzQzMTg1MyAzLjQwNjU3MjQ2LDcuODg2NzE4NzUgNC4wMTg1NTQ2OSw3Ljg4NjcxODc1IEM0LjMyMTI5MDU4LDcuODg2NzE4NzUgNC42MjQ4MzU3Miw3LjgyNzMxMTc5IDQuOTI5MTk5MjIsNy43MDg0OTYwOSBDNS4yMzM1NjI3Miw3LjU4OTY4MDQgNS40OTQ3OTA1OCw3LjQ0NTYzODg3IDUuNzEyODkwNjIsNy4yNzYzNjcxOSBMNS43MTI4OTA2Miw2LjM2ODE2NDA2IEw0LjA1NzYxNzE5LDYuMzY4MTY0MDYgWiBNOS4yMTA3NDIxOCw5IEw5LjIxMDc0MjE4LDEuODQxNzk2ODggTDEyLjI1MjczNDQsMS44NDE3OTY4OCBDMTMuMDE3NzEyMSwxLjg0MTc5Njg4IDEzLjU3MzUzMzQsMS45MDYwODY2IDEzLjkyMDIxNDgsMi4wMzQ2Njc5NyBDMTQuMjY2ODk2MywyLjE2MzI0OTM0IDE0LjU0NDQsMi4zOTE5MjU0NCAxNC43NTI3MzQ0LDIuNzIwNzAzMTIgQzE0Ljk2MTA2ODcsMy4wNDk0ODA4MSAxNS4wNjUyMzQ0LDMuNDI1NDUzNjEgMTUuMDY1MjM0NCwzLjg0ODYzMjgxIEMxNS4wNjUyMzQ0LDQuMzg1NzQ0ODcgMTQuOTA3MzU4Myw0LjgyOTI2MjU3IDE0LjU5MTYwMTYsNS4xNzkxOTkyMiBDMTQuMjc1ODQ0OCw1LjUyOTEzNTg2IDEzLjgwMzg0NDMsNS43NDk2NzQwMiAxMy4xNzU1ODU5LDUuODQwODIwMzEgQzEzLjQ4ODA4NzUsNi4wMjMxMTI4OSAxMy43NDYwNjAyLDYuMjIzMzA2MiAxMy45NDk1MTE3LDYuNDQxNDA2MjUgQzE0LjE1Mjk2MzIsNi42NTk1MDYzIDE0LjQyNzIxMTgsNy4wNDY4NzIyMiAxNC43NzIyNjU2LDcuNjAzNTE1NjIgTDE1LjY0NjI4OTEsOSBMMTMuOTE3NzczNCw5IEwxMi44NzI4NTE2LDcuNDQyMzgyODEgQzEyLjUwMTc1NTksNi44ODU3Mzk0IDEyLjI0Nzg1MjIsNi41MzQ5OTQyMSAxMi4xMTExMzI4LDYuMzkwMTM2NzIgQzExLjk3NDQxMzQsNi4yNDUyNzkyMiAxMS44Mjk1NTgsNi4xNDU5OTYzNiAxMS42NzY1NjI1LDYuMDkyMjg1MTYgQzExLjUyMzU2NjksNi4wMzg1NzM5NSAxMS4yODEwNTYzLDYuMDExNzE4NzUgMTAuOTQ5MDIzNCw2LjAxMTcxODc1IEwxMC42NTYwNTQ3LDYuMDExNzE4NzUgTDEwLjY1NjA1NDcsOSBMOS4yMTA3NDIxOCw5IFogTTEwLjY1NjA1NDcsNC44NjkxNDA2MiBMMTEuNzI1MzkwNiw0Ljg2OTE0MDYyIEMxMi40MTg3NTM1LDQuODY5MTQwNjIgMTIuODUxNjkxOCw0LjgzOTg0NDA0IDEzLjAyNDIxODcsNC43ODEyNSBDMTMuMTk2NzQ1Niw0LjcyMjY1NTk2IDEzLjMzMTgzNTQsNC42MjE3NDU1MSAxMy40Mjk0OTIyLDQuNDc4NTE1NjIgQzEzLjUyNzE0ODksNC4zMzUyODU3NCAxMy41NzU5NzY2LDQuMTU2MjUxMDcgMTMuNTc1OTc2NiwzLjk0MTQwNjI1IEMxMy41NzU5NzY2LDMuNzAwNTE5NjMgMTMuNTExNjg2OCwzLjUwNjAyMjg4IDEzLjM4MzEwNTUsMy4zNTc5MTAxNiBDMTMuMjU0NTI0MSwzLjIwOTc5NzQ0IDEzLjA3MzA0OCwzLjExNjIxMTEzIDEyLjgzODY3MTksMy4wNzcxNDg0NCBDMTIuNzIxNDgzOCwzLjA2MDg3MjMxIDEyLjM2OTkyNDgsMy4wNTI3MzQzOCAxMS43ODM5ODQ0LDMuMDUyNzM0MzggTDEwLjY1NjA1NDcsMy4wNTI3MzQzOCBMMTAuNjU2MDU0Nyw0Ljg2OTE0MDYyIFogTTE3LjA4MzU5MzcsOSBMMTcuMDgzNTkzNywxLjg0MTc5Njg4IEwxOC41Mjg5MDYyLDEuODQxNzk2ODggTDE4LjUyODkwNjIsOSBMMTcuMDgzNTkzNyw5IFogTTIwLjIzOTY0ODQsNi42NzA4OTg0NCBMMjEuNjQ1ODk4NCw2LjUzNDE3OTY5IEMyMS43MzA1MzQyLDcuMDA2MTg3MjYgMjEuOTAyMjQ0OCw3LjM1Mjg2MzQ4IDIyLjE2MTAzNTEsNy41NzQyMTg3NSBDMjIuNDE5ODI1NSw3Ljc5NTU3NDAyIDIyLjc2ODk0MzEsNy45MDYyNSAyMy4yMDgzOTg0LDcuOTA2MjUgQzIzLjY3Mzg5NTUsNy45MDYyNSAyNC4wMjQ2NDA3LDcuODA3NzgwOTMgMjQuMjYwNjQ0NSw3LjYxMDgzOTg0IEMyNC40OTY2NDgzLDcuNDEzODk4NzUgMjQuNjE0NjQ4NCw3LjE4MzU5NTA3IDI0LjYxNDY0ODQsNi45MTk5MjE4OCBDMjQuNjE0NjQ4NCw2Ljc1MDY1MDIgMjQuNTY1MDA3LDYuNjA2NjA4NjcgMjQuNDY1NzIyNiw2LjQ4Nzc5Mjk3IEMyNC4zNjY0MzgzLDYuMzY4OTc3MjcgMjQuMTkzMTAwMiw2LjI2NTYyNTQ0IDIzLjk0NTcwMzEsNi4xNzc3MzQzOCBDMjMuNzc2NDMxNCw2LjExOTE0MDMzIDIzLjM5MDY5MzEsNi4wMTQ5NzQ3MSAyMi43ODg0NzY1LDUuODY1MjM0MzggQzIyLjAxMzczMzEsNS42NzMxNzYxMiAyMS40NzAxMTg3LDUuNDM3MTc1ODggMjEuMTU3NjE3Miw1LjE1NzIyNjU2IEMyMC43MTgxNjE4LDQuNzYzMzQ0MzggMjAuNDk4NDM3NSw0LjI4MzIwNTk2IDIwLjQ5ODQzNzUsMy43MTY3OTY4OCBDMjAuNDk4NDM3NSwzLjM1MjIxMTcyIDIwLjYwMTc4OTMsMy4wMTEyMzIwNiAyMC44MDg0OTYxLDIuNjkzODQ3NjYgQzIxLjAxNTIwMjgsMi4zNzY0NjMyNiAyMS4zMTMwNTE0LDIuMTM0NzY2NDYgMjEuNzAyMDUwNywxLjk2ODc1IEMyMi4wOTEwNTAxLDEuODAyNzMzNTQgMjIuNTYwNjA5MiwxLjcxOTcyNjU2IDIzLjExMDc0MjIsMS43MTk3MjY1NiBDMjQuMDA5MTg0MSwxLjcxOTcyNjU2IDI0LjY4NTQ0NjksMS45MTY2NjQ3IDI1LjEzOTU1MDcsMi4zMTA1NDY4OCBDMjUuNTkzNjU0NiwyLjcwNDQyOTA1IDI1LjgzMjA5NjIsMy4yMzAxMzk5NCAyNS44NTQ4ODI4LDMuODg3Njk1MzEgTDI0LjQwOTU3MDMsMy45NTExNzE4OCBDMjQuMzQ3NzIxLDMuNTgzMzMxNDkgMjQuMjE1MDcyNiwzLjMxODg0ODQ2IDI0LjAxMTYyMTEsMy4xNTc3MTQ4NCBDMjMuODA4MTY5NSwyLjk5NjU4MTIzIDIzLjUwMjk5NjgsMi45MTYwMTU2MiAyMy4wOTYwOTM3LDIuOTE2MDE1NjIgQzIyLjY3NjE2OTcsMi45MTYwMTU2MiAyMi4zNDczOTcsMy4wMDIyNzc3OCAyMi4xMDk3NjU2LDMuMTc0ODA0NjkgQzIxLjk1Njc3LDMuMjg1NDgyMzIgMjEuODgwMjczNCwzLjQzMzU5MjgyIDIxLjg4MDI3MzQsMy42MTkxNDA2MiBDMjEuODgwMjczNCwzLjc4ODQxMjMgMjEuOTUxODg3MywzLjkzMzI2NzYzIDIyLjA5NTExNzIsNC4wNTM3MTA5NCBDMjIuMjc3NDA5Nyw0LjIwNjcwNjQ5IDIyLjcyMDExMzYsNC4zNjYyMTAxMSAyMy40MjMyNDIyLDQuNTMyMjI2NTYgQzI0LjEyNjM3MDcsNC42OTgyNDMwMiAyNC42NDYzODUsNC44Njk5NTM1NCAyNC45ODMzMDA3LDUuMDQ3MzYzMjggQzI1LjMyMDIxNjUsNS4yMjQ3NzMwMiAyNS41ODM4ODU3LDUuNDY3MjgzNjIgMjUuNzc0MzE2NCw1Ljc3NDkwMjM0IEMyNS45NjQ3NDcsNi4wODI1MjEwNyAyNi4wNTk5NjA5LDYuNDYyNTYyODQgMjYuMDU5OTYwOSw2LjkxNTAzOTA2IEMyNi4wNTk5NjA5LDcuMzI1MTk3MzYgMjUuOTQ2MDI5Nyw3LjcwOTMwODExIDI1LjcxODE2NCw4LjA2NzM4MjgxIEMyNS40OTAyOTgzLDguNDI1NDU3NTIgMjUuMTY4MDM1OSw4LjY5MTU2ODE0IDI0Ljc1MTM2NzIsOC44NjU3MjI2NiBDMjQuMzM0Njk4NCw5LjAzOTg3NzE3IDIzLjgxNTQ5NzksOS4xMjY5NTMxMiAyMy4xOTM3NSw5LjEyNjk1MzEyIEMyMi4yODg3OTc1LDkuMTI2OTUzMTIgMjEuNTkzODE3NSw4LjkxNzgwODA4IDIxLjEwODc4OSw4LjQ5OTUxMTcyIEMyMC42MjM3NjA2LDguMDgxMjE1MzYgMjAuMzM0MDQ5OSw3LjQ3MTY4MzY5IDIwLjIzOTY0ODQsNi42NzA4OTg0NCBaIE0yOS41ODcxMDkzLDkgTDI5LjU4NzEwOTMsMy4wNTI3MzQzOCBMMjcuNDYzMDg1OSwzLjA1MjczNDM4IEwyNy40NjMwODU5LDEuODQxNzk2ODggTDMzLjE1MTU2MjUsMS44NDE3OTY4OCBMMzMuMTUxNTYyNSwzLjA1MjczNDM4IEwzMS4wMzI0MjE4LDMuMDUyNzM0MzggTDMxLjAzMjQyMTgsOSBMMjkuNTg3MTA5Myw5IFoiLz48L2RlZnM+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBvcGFjaXR5PSIuOSI+PHBhdGggZD0iTTAgMEg0OFY0OEgweiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgLTEpIi8+PHBhdGggZmlsbD0iIzBFNTEyQiIgZD0iTTQzIDI2IDMgMjYgMCAyMiA0IDE4IDQyIDE4IDQ2IDIyeiIvPjxwYXRoIGZpbGw9IiNFNkU2RTYiIGQ9Ik00MCw0NiBMNiw0NiBDNC44OTUsNDYgNCw0NS4xMDUgNCw0NCBMNCwyIEM0LDAuODk1IDQuODk1LDAgNiwwIEwzMCwwIEw0MiwxMiBMNDIsNDQgQzQyLDQ1LjEwNSA0MS4xMDUsNDYgNDAsNDYgWiIvPjxwYXRoIGZpbGw9IiNCM0IzQjMiIGQ9Ik0zMCwwIEwzMCwxMCBDMzAsMTEuMTA1IDMwLjg5NSwxMiAzMiwxMiBMNDIsMTIgTDMwLDAgWiIvPjxwYXRoIGZpbGw9IiMxNkIzNzgiIGQ9Ik00NCw0MCBMMiw0MCBDMC44OTUsNDAgMCwzOS4xMDUgMCwzOCBMMCwyMiBMNDYsMjIgTDQ2LDM4IEM0NiwzOS4xMDUgNDUuMTA1LDQwIDQ0LDQwIFoiLz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2IDI1KSI+PHVzZSB4bGluazpocmVmPSIjYSIgZmlsbD0iIzAwMCIvPjx1c2UgeGxpbms6aHJlZj0iI2EiIGZpbGw9IiNGRkYiLz48L2c+PC9nPjwvc3ZnPg==');\n    --icon-Accessibility: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmktdW5pdmVyc2FsLWFjY2Vzcy1jaXJjbGUiPjxwYXRoIGQ9Ik04IDQuMTQzQTEuMDcxIDEuMDcxIDAgMSAwIDggMmExLjA3MSAxLjA3MSAwIDAgMCAwIDIuMTQzbS00LjY2OCAxLjQ3IDMuMjQuMzE2djIuNWwtLjMyMyA0LjU4NUEuMzgzLjM4MyAwIDAgMCA3IDEzLjE0bC44MjYtNC4wMTdjLjA0NS0uMTguMzAxLS4xOC4zNDYgMEw5IDEzLjEzOWEuMzgzLjM4MyAwIDAgMCAuNzUyLS4xMjVMOS40MyA4LjQzdi0yLjVsMy4yMzktLjMxNmEuMzguMzggMCAwIDAtLjA0Ny0uNzU2SDMuMzc5YS4zOC4zOCAwIDAgMC0uMDQ3Ljc1NloiLz48cGF0aCBkPSJNOCAwYTggOCAwIDEgMCAwIDE2QTggOCAwIDAgMCA4IDBNMSA4YTcgNyAwIDEgMSAxNCAwQTcgNyAwIDAgMSAxIDgiLz48L3N2Zz4=');\n    --icon-AddUser: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMjEyMTIxIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiPjxjaXJjbGUgY3g9IjciIGN5PSI0IiByPSIzLjUiIGRhdGEtY2FwPSJidXR0Ii8+PHBhdGggZD0iTTkuODk0IDEwQTUuOTggNS45OCAwIDAgMCA3LjUgOS41aC0xYTYgNiAwIDAgMC02IDZoOU0xMi41IDkuNXY2TTkuNSAxMi41aDYiIGRhdGEtY2FwPSJidXR0Ii8+PC9nPjwvc3ZnPg==');\n    --icon-ArrowLeft: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA4Ljc4NDkzNDYsMTQuNjcwNzUzIDIuNzg0NjI5Niw4LjYyMTMyMjUgYyAtMC4zNjgyMDg0LC0wLjM3MTIzIC0wLjM2ODIwODQsLTAuOTczMSAwLC0xLjM0NDMyIGwgNi4wMDAzMDUsLTYuMDQ5NDI0IGMgMC4zNjgyMSwtMC4zNzEyMjI3NiAwLjk2NTE5LC0wLjM3MTIyMjc2IDEuMzMzNDAwNCwwIDAuMzY4MjEsMC4zNzEyMjMgMC4zNjgyMSwwLjk3MzA5NCAwLDEuMzQ0MzE0IGwgLTQuMzkwNzUwNCw0LjQyNjY5IGggNy43ODA4OTA0IHYgMS45MDExNSBIIDUuNzI3NTg0NiBsIDQuMzkwNzUwNCw0LjQyNjcyMDUgYyAwLjM2ODIxLDAuMzcxMiAwLjM2ODIxLDAuOTczMSAwLDEuMzQ0MyAtMC4zNjgyMTA0LDAuMzcxMiAtMC45NjUxOTA0LDAuMzcxMiAtMS4zMzM0MDA0LDAgeiIgc3R5bGU9ImNsaXAtcnVsZTpldmVub2RkO2ZpbGw6IzAwMDtmaWxsLXJ1bGU6ZXZlbm9kZCIvPjwvc3ZnPg==');\n    --icon-ArrowRight: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA3LjIzMjAxMzcsMTQuNjcwNzUzIDEzLjIzMjMxOSw4LjYyMTMyMjUgYyAwLjM2ODIwOCwtMC4zNzEyMyAwLjM2ODIwOCwtMC45NzMxIDAsLTEuMzQ0MzIgTCA3LjIzMjAxMzcsMS4yMjc1Nzg1IGMgLTAuMzY4MjEsLTAuMzcxMjIyNzYgLTAuOTY1MTksLTAuMzcxMjIyNzYgLTEuMzMzNDAwNCwwIC0wLjM2ODIxLDAuMzcxMjIzIC0wLjM2ODIxLDAuOTczMDk0IDAsMS4zNDQzMTQgbCA0LjM5MDc1MDcsNC40MjY2OSBIIDIuNTA4NDczMyB2IDEuOTAxMTUgSCAxMC4yODkzNjQgTCA1Ljg5ODYxMzMsMTMuMzI2NDUzIGMgLTAuMzY4MjEsMC4zNzEyIC0wLjM2ODIxLDAuOTczMSAwLDEuMzQ0MyAwLjM2ODIxMDQsMC4zNzEyIDAuOTY1MTkwNCwwLjM3MTIgMS4zMzM0MDA0LDAgeiIgc3R5bGU9ImNsaXAtcnVsZTpldmVub2RkO2ZpbGw6IzAwMDtmaWxsLXJ1bGU6ZXZlbm9kZCIvPjwvc3ZnPg==');\n    --icon-ArrowRightOutlined: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggc3Ryb2tlPSIjRDlEOUQ5IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTcuNSAxLjUwMDAxTDE1LjUgOC4wMDAwMUw3LjUgMTQuNVYxMC41SDNDMi4zMzY5NiAxMC41IDEuNzAxMDcgMTAuMjM2NiAxLjIzMjIzIDkuNzY3NzdDMC43NjMzOTIgOS4yOTg5MyAwLjUgOC42NjMwNSAwLjUgOC4wMDAwMUMwLjUgNy4zMzY5NyAwLjc2MzM5MiA2LjcwMTA4IDEuMjMyMjMgNi4yMzIyNEMxLjcwMTA3IDUuNzYzNCAyLjMzNjk2IDUuNTAwMDEgMyA1LjUwMDAxSDcuNVYxLjUwMDAxWiIvPjwvc3ZnPg==');\n    --icon-BarcodeQR: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggc3Ryb2tlPSIjMjEyMTIxIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTYuNS41SC41VjYuNUg2LjVWLjVaTTYuNSA5LjVILjVWMTUuNUg2LjVWOS41Wk0xNS41LjVIOS41VjYuNUgxNS41Vi41Wk0xNS41IDE1LjVIOS41TTExLjUgOS41SDE1LjVWMTIuNSIvPjwvc3ZnPg==');\n    --icon-BarcodeQR2: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzE2QjM3OCIgZD0iTTYgN0gxQy40IDcgMCA2LjYgMCA2VjFDMCAuNC40IDAgMSAwSDZDNi42IDAgNyAuNCA3IDFWNkM3IDYuNiA2LjYgNyA2IDdaTTIgNUg1VjJIMlY1Wk02IDE2SDFDLjQgMTYgMCAxNS42IDAgMTVWMTBDMCA5LjQuNCA5IDEgOUg2QzYuNiA5IDcgOS40IDcgMTBWMTVDNyAxNS42IDYuNiAxNiA2IDE2Wk0yIDE0SDVWMTFIMlYxNFpNMTUgN0gxMEM5LjQgNyA5IDYuNiA5IDZWMUM5IC40IDkuNCAwIDEwIDBIMTVDMTUuNiAwIDE2IC40IDE2IDFWNkMxNiA2LjYgMTUuNiA3IDE1IDdaTTExIDVIMTRWMkgxMVY1WiIvPjxwYXRoIGZpbGw9IiMxNkIzNzgiIGQ9Ik00IDNIM1Y0SDRWM1pNNCAxMkgzVjEzSDRWMTJaTTE2IDE0SDlWMTZIMTZWMTRaTTE2IDEzSDE0VjExSDExVjlIMTVDMTUuNiA5IDE2IDkuNCAxNiAxMFYxM1oiLz48cGF0aCBmaWxsPSIjMTZCMzc4IiBkPSJNMTMgMTJIMTJWMTNIMTNWMTJaTTEzIDNIMTJWNEgxM1YzWiIvPjwvc3ZnPg==');\n    --icon-Board: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiPjxyZWN0IHdpZHRoPSIxNSIgaGVpZ2h0PSIxNSIgeD0iLjUiIHk9Ii41IiByeD0iMSIvPjxwYXRoIGQ9Ik0zLjUgMy41aDN2M2gtM3pNMy41IDkuNWgzdjNoLTN6TTkuNSAzLjVoM3Y5aC0zeiIvPjwvZz48L3N2Zz4=');\n    --icon-Bookmark: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiPjxwYXRoIGQ9Ik04LjUuNXY3TDYgNS41bC0yLjUgMnYtNyIgZGF0YS1jYXA9ImJ1dHQiLz48cGF0aCBkPSJNLjUgMTRWMi41YTIgMiAwIDAgMSAyLTJoMTFhMiAyIDAgMCAxIDIgMlYxNCIgZGF0YS1jYXA9ImJ1dHQiLz48cmVjdCB3aWR0aD0iMTUiIGhlaWdodD0iMyIgeD0iLjUiIHk9IjEyLjUiIGRhdGEtY2FwPSJidXR0IiByeD0iMS41IiByeT0iMS41Ii8+PC9nPjwvc3ZnPg==');\n    --icon-CenterAlign: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNS41LDguNSBDNS4yMjM4NTc2Myw4LjUgNSw4LjI3NjE0MjM3IDUsOCBDNSw3LjcyMzg1NzYzIDUuMjIzODU3NjMsNy41IDUuNSw3LjUgTDEwLjUsNy41IEMxMC43NzYxNDI0LDcuNSAxMSw3LjcyMzg1NzYzIDExLDggQzExLDguMjc2MTQyMzcgMTAuNzc2MTQyNCw4LjUgMTAuNSw4LjUgTDUuNSw4LjUgWiBNMi41LDQgQzIuMjIzODU3NjMsNCAyLDMuNzc2MTQyMzcgMiwzLjUgQzIsMy4yMjM4NTc2MyAyLjIyMzg1NzYzLDMgMi41LDMgTDEzLjUsMyBDMTMuNzc2MTQyNCwzIDE0LDMuMjIzODU3NjMgMTQsMy41IEMxNCwzLjc3NjE0MjM3IDEzLjc3NjE0MjQsNCAxMy41LDQgTDIuNSw0IFogTTQsMTMgQzMuNzIzODU3NjMsMTMgMy41LDEyLjc3NjE0MjQgMy41LDEyLjUgQzMuNSwxMi4yMjM4NTc2IDMuNzIzODU3NjMsMTIgNCwxMiBMMTIsMTIgQzEyLjI3NjE0MjQsMTIgMTIuNSwxMi4yMjM4NTc2IDEyLjUsMTIuNSBDMTIuNSwxMi43NzYxNDI0IDEyLjI3NjE0MjQsMTMgMTIsMTMgTDQsMTMgWiIvPjwvc3ZnPg==');\n    --icon-Chat: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOS41NjIgM2E3LjUgNy41IDAgMCAwLTYuNzk4IDEwLjY3M2wtLjcyNCAyLjg0MmExLjI1IDEuMjUgMCAwIDAgMS41MDQgMS41MjRjLjc1LS4xOCAxLjkwMy0uNDU3IDIuOTMtLjcwMkE3LjUgNy41IDAgMSAwIDkuNTYxIDNabS02IDcuNWE2IDYgMCAxIDEgMy4zMyA1LjM3NWwtLjI0NC0uMTIxLS4yNjQuMDYzYy0uOTIzLjIyLTEuOTkuNDc1LTIuNzg4LjY2N2wuNjktMi43MDguMDctLjI3Ni0uMTMtLjI1M2E1Ljk3MSA1Ljk3MSAwIDAgMS0uNjY0LTIuNzQ3Wm0xMSAxMC41Yy0xLjk3IDAtMy43NjItLjc1OS01LjEtMmguMWMuNzE4IDAgMS40MTUtLjA4OSAyLjA4LS4yNTcuODY1LjQ4MiAxLjg2Ljc1NyAyLjkyLjc1Ny45NiAwIDEuODY2LS4yMjUgMi42Ny0uNjI1bC4yNDMtLjEyMS4yNjQuMDYzYy45MjIuMjIgMS45NjYuNDQ1IDIuNzQuNjEtLjE3NS0uNzUxLS40MTQtMS43NTYtLjY0Mi0yLjY1MWwtLjA3LS4yNzYuMTMtLjI1M2E1Ljk3MSA1Ljk3MSAwIDAgMCAuNjY1LTIuNzQ3IDUuOTk1IDUuOTk1IDAgMCAwLTIuNzQ3LTUuMDQyIDguNDQgOC40NCAwIDAgMC0uOC0yLjA0NyA3LjUwMyA3LjUwMyAwIDAgMSA0LjM0NCAxMC4yNjNjLjI1MyAxLjAwOC41MDkgMi4xLjY3MSAyLjgwM2ExLjI0NCAxLjI0NCAwIDAgMS0xLjQ2NyAxLjUgMTMyLjYyIDEzMi42MiAwIDAgMS0yLjkxMy0uNjQgNy40NzYgNy40NzYgMCAwIDEtMy4wODguNjYzWiIvPjwvc3ZnPg==');\n    --icon-Clock: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzkyOTI5OSIgZD0iTTEwLjAwMDEgMy4zMzI2OEM2LjMxODE4IDMuMzMyNjggMy4zMzM0MSA2LjMxNzQ1IDMuMzMzNDEgOS45OTkzNUMzLjMzMzQxIDEzLjY4MTIgNi4zMTgxOCAxNi42NjYgMTAuMDAwMSAxNi42NjZDMTMuNjgyIDE2LjY2NiAxNi42NjY3IDEzLjY4MTIgMTYuNjY2NyA5Ljk5OTM1QzE2LjY2NjcgNi4zMTc0NSAxMy42ODIgMy4zMzI2OCAxMC4wMDAxIDMuMzMyNjhaTTEuNjY2NzUgOS45OTkzNUMxLjY2Njc1IDUuMzk2OTggNS4zOTc3MSAxLjY2NjAyIDEwLjAwMDEgMS42NjYwMkMxNC42MDI1IDEuNjY2MDIgMTguMzMzNCA1LjM5Njk4IDE4LjMzMzQgOS45OTkzNUMxOC4zMzM0IDE0LjYwMTcgMTQuNjAyNSAxOC4zMzI3IDEwLjAwMDEgMTguMzMyN0M1LjM5NzcxIDE4LjMzMjcgMS42NjY3NSAxNC42MDE3IDEuNjY2NzUgOS45OTkzNVpNMTAuMDAwMSA0Ljk5OTM1QzEwLjQ2MDMgNC45OTkzNSAxMC44MzM0IDUuMzcyNDUgMTAuODMzNCA1LjgzMjY4VjkuNjU0MTdMMTMuMDg5MyAxMS45MTAxQzEzLjQxNDggMTIuMjM1NSAxMy40MTQ4IDEyLjc2MzIgMTMuMDg5MyAxMy4wODg2QzEyLjc2MzkgMTMuNDE0IDEyLjIzNjMgMTMuNDE0IDExLjkxMDggMTMuMDg4Nkw5LjQxMDgzIDEwLjU4ODZDOS4yNTQ1NSAxMC40MzIzIDkuMTY2NzUgMTAuMjIwNCA5LjE2Njc1IDkuOTk5MzVWNS44MzI2OEM5LjE2Njc1IDUuMzcyNDUgOS41Mzk4NCA0Ljk5OTM1IDEwLjAwMDEgNC45OTkzNVoiLz48L3N2Zz4=');\n    --icon-Code: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTAuMDMzMzI3MiwwLjMyMDUxMDQ2IEMxMC4xMzI0NTY1LDAuMDYyNzc0MTg4IDEwLjQyMTc1MzMsLTAuMDY1ODAyMTM4NiAxMC42Nzk0ODk1LDAuMDMzMzI3MTk2OSBDMTAuOTM3MjI1OCwwLjEzMjQ1NjUzMiAxMS4wNjU4MDIxLDAuNDIxNzUzMjY3IDEwLjk2NjY3MjgsMC42Nzk0ODk1NCBMNS45NjY2NzI4LDEzLjY3OTQ4OTUgQzUuODY3NTQzNDcsMTMuOTM3MjI1OCA1LjU3ODI0NjczLDE0LjA2NTgwMjEgNS4zMjA1MTA0NiwxMy45NjY2NzI4IEM1LjA2Mjc3NDE5LDEzLjg2NzU0MzUgNC45MzQxOTc4NiwxMy41NzgyNDY3IDUuMDMzMzI3MiwxMy4zMjA1MTA1IEwxMC4wMzMzMjcyLDAuMzIwNTEwNDYgWiBNMy44NTM1NTMzOSw5LjE0NjQ0NjYxIEM0LjA0ODgxNTU0LDkuMzQxNzA4NzYgNC4wNDg4MTU1NCw5LjY1ODI5MTI0IDMuODUzNTUzMzksOS44NTM1NTMzOSBDMy42NTgyOTEyNCwxMC4wNDg4MTU1IDMuMzQxNzA4NzYsMTAuMDQ4ODE1NSAzLjE0NjQ0NjYxLDkuODUzNTUzMzkgTDAuMTQ2NDQ2NjA5LDYuODUzNTUzMzkgQy0wLjA0ODgxNTUzNjUsNi42NTgyOTEyNCAtMC4wNDg4MTU1MzY1LDYuMzQxNzA4NzYgMC4xNDY0NDY2MDksNi4xNDY0NDY2MSBMMy4xNDY0NDY2MSwzLjE0NjQ0NjYxIEMzLjM0MTcwODc2LDIuOTUxMTg0NDYgMy42NTgyOTEyNCwyLjk1MTE4NDQ2IDMuODUzNTUzMzksMy4xNDY0NDY2MSBDNC4wNDg4MTU1NCwzLjM0MTcwODc2IDQuMDQ4ODE1NTQsMy42NTgyOTEyNCAzLjg1MzU1MzM5LDMuODUzNTUzMzkgTDEuMjA3MTA2NzgsNi41IEwzLjg1MzU1MzM5LDkuMTQ2NDQ2NjEgWiBNMTIuMTQ2NDQ2NiwzLjg1MzU1MzM5IEMxMS45NTExODQ1LDMuNjU4MjkxMjQgMTEuOTUxMTg0NSwzLjM0MTcwODc2IDEyLjE0NjQ0NjYsMy4xNDY0NDY2MSBDMTIuMzQxNzA4OCwyLjk1MTE4NDQ2IDEyLjY1ODI5MTIsMi45NTExODQ0NiAxMi44NTM1NTM0LDMuMTQ2NDQ2NjEgTDE1Ljg1MzU1MzQsNi4xNDY0NDY2MSBDMTYuMDQ4ODE1NSw2LjM0MTcwODc2IDE2LjA0ODgxNTUsNi42NTgyOTEyNCAxNS44NTM1NTM0LDYuODUzNTUzMzkgTDEyLjg1MzU1MzQsOS44NTM1NTMzOSBDMTIuNjU4MjkxMiwxMC4wNDg4MTU1IDEyLjM0MTcwODgsMTAuMDQ4ODE1NSAxMi4xNDY0NDY2LDkuODUzNTUzMzkgQzExLjk1MTE4NDUsOS42NTgyOTEyNCAxMS45NTExODQ1LDkuMzQxNzA4NzYgMTIuMTQ2NDQ2Niw5LjE0NjQ0NjYxIEwxNC43OTI4OTMyLDYuNSBMMTIuMTQ2NDQ2NiwzLjg1MzU1MzM5IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgMSkiLz48L3N2Zz4=');\n    --icon-Collapse: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOCw5LjE3NDYzMDUyIEwxMC45MjE4MjczLDYuMTg5MDIzMiBDMTEuMTY4NDc0Miw1LjkzNjk5MjI3IDExLjU2ODM2NzksNS45MzY5OTIyNyAxMS44MTUwMTQ4LDYuMTg5MDIzMiBDMTIuMDYxNjYxNyw2LjQ0MTA1NDEzIDEyLjA2MTY2MTcsNi44NDk2NzcwMSAxMS44MTUwMTQ4LDcuMTAxNzA3OTQgTDgsMTEgTDQuMTg0OTg1MTksNy4xMDE3MDc5NCBDMy45MzgzMzgyNyw2Ljg0OTY3NzAxIDMuOTM4MzM4MjcsNi40NDEwNTQxMyA0LjE4NDk4NTE5LDYuMTg5MDIzMiBDNC40MzE2MzIxMSw1LjkzNjk5MjI3IDQuODMxNTI1NzgsNS45MzY5OTIyNyA1LjA3ODE3MjcsNi4xODkwMjMyIEw4LDkuMTc0NjMwNTIgWiIvPjwvc3ZnPg==');\n    --icon-Columns: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMCAyVjE0LjIzNTNIMTZWMkgwWk05LjQxMTc2IDMuODgyMzVWMTIuMzUyOUg2LjU4ODI0VjMuODgyMzVIOS40MTE3NlpNMS44ODIzNSAzLjg4MjM1SDQuNzA1ODhWMTIuMzUyOUgxLjg4MjM1VjMuODgyMzVaTTE0LjExNzYgMTIuMzUyOUgxMS4yOTQxVjMuODgyMzVIMTQuMTE3NlYxMi4zNTI5WiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');\n    --icon-Convert: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMy43NTYwMTM0NSwxMi4yNDM5ODY1IEwyLDE0IEwyLDkgTDYuNSw5LjUgTDQuNDYzMTQxOTgsMTEuNTM2ODU4IEM1LjUxNTE0ODk0LDEyLjU4OTk0NjggNi45OTkzMjM4MywxMy4xMzMyOTk4IDguNTE1NTkwMTMsMTIuOTc2MjM1MiBDMTAuMzc5MzUsMTIuNzgzMTc0OCAxMS45Nzg3NDExLDExLjU2NDI3NzQgMTIuNjU5MTI4Niw5LjgxODQ0MDYgQzEyLjc1OTQwMTEsOS41NjExNDY5MiAxMy4wNDkyNjU3LDkuNDMzODU2MDkgMTMuMzA2NTU5NCw5LjUzNDEyODU4IEMxMy41NjM4NTMxLDkuNjM0NDAxMDYgMTMuNjkxMTQzOSw5LjkyNDI2NTcyIDEzLjU5MDg3MTQsMTAuMTgxNTU5NCBDMTIuNzc0NDA2NCwxMi4yNzY1NjM2IDEwLjg1NTEzNzIsMTMuNzM5MjQwNSA4LjYxODYyNTI5LDEzLjk3MDkxMjkgQzYuNzk5MjYyMzIsMTQuMTU5Mzc0MyA1LjAxODQwMjQxLDEzLjUwNzQ3OTEgMy43NTYwMTM0NSwxMi4yNDM5ODY1IFogTTEyLjI0Mzk4NjUsMy43NTYwMTM0NSBMMTQsMiBMMTQsNyBMOS41LDYuNSBMMTEuNTM2ODU4LDQuNDYzMTQxOTggQzEwLjQ4NDg1MTEsMy40MTAwNTMxOCA5LjAwMDY3NjE3LDIuODY2NzAwMTUgNy40ODQ0MDk4NywzLjAyMzc2NDgzIEM1LjYyMDY0OTk2LDMuMjE2ODI1MTYgNC4wMjEyNTg5LDQuNDM1NzIyNTkgMy4zNDA4NzE0Miw2LjE4MTU1OTQgQzMuMjQwNTk4OTQsNi40Mzg4NTMwOCAyLjk1MDczNDI4LDYuNTY2MTQzOTEgMi42OTM0NDA2LDYuNDY1ODcxNDIgQzIuNDM2MTQ2OTIsNi4zNjU1OTg5NCAyLjMwODg1NjA5LDYuMDc1NzM0MjggMi40MDkxMjg1OCw1LjgxODQ0MDYgQzMuMjI1NTkzNTUsMy43MjM0MzY0MyA1LjE0NDg2MjgyLDIuMjYwNzU5NTEgNy4zODEzNzQ3MSwyLjAyOTA4NzEyIEM5LjIwMDczNzY4LDEuODQwNjI1NzIgMTAuOTgxNTk3NiwyLjQ5MjUyMDkyIDEyLjI0Mzk4NjUsMy43NTYwMTM0NSBaIi8+PC9zdmc+');\n    --icon-Copy: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTEsMyBMMTEsMTUgTDIsMTUgTDIsMyBMMTEsMyBaIE0xMCwzLjk5OSBMMywzLjk5OSBMMywxMy45OTkgTDEwLDEzLjk5OSBMMTAsMy45OTkgWiBNMTQsMCBMMTQsMTIgTDExLjUsMTIgTDExLjUsMTEgTDEzLDExIEwxMywxIEw2LDEgTDYsMi41IEw1LDIuNSBMNSwwIEwxNCwwIFoiLz48L3N2Zz4=');\n    --icon-CrossBig: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTMuNDE0MjEzNiwxMiBMMTkuNzA3MTA2OCwxOC4yOTI4OTMyIEMyMC4wOTc2MzExLDE4LjY4MzQxNzUgMjAuMDk3NjMxMSwxOS4zMTY1ODI1IDE5LjcwNzEwNjgsMTkuNzA3MTA2OCBDMTkuMzE2NTgyNSwyMC4wOTc2MzExIDE4LjY4MzQxNzUsMjAuMDk3NjMxMSAxOC4yOTI4OTMyLDE5LjcwNzEwNjggTDEyLDEzLjQxNDIxMzYgTDUuNzA3MTA2NzgsMTkuNzA3MTA2OCBDNS4zMTY1ODI0OSwyMC4wOTc2MzExIDQuNjgzNDE3NTEsMjAuMDk3NjMxMSA0LjI5Mjg5MzIyLDE5LjcwNzEwNjggQzMuOTAyMzY4OTMsMTkuMzE2NTgyNSAzLjkwMjM2ODkzLDE4LjY4MzQxNzUgNC4yOTI4OTMyMiwxOC4yOTI4OTMyIEwxMC41ODU3ODY0LDEyIEw0LjI5Mjg5MzIyLDUuNzA3MTA2NzggQzMuOTAyMzY4OTMsNS4zMTY1ODI0OSAzLjkwMjM2ODkzLDQuNjgzNDE3NTEgNC4yOTI4OTMyMiw0LjI5Mjg5MzIyIEM0LjY4MzQxNzUxLDMuOTAyMzY4OTMgNS4zMTY1ODI0OSwzLjkwMjM2ODkzIDUuNzA3MTA2NzgsNC4yOTI4OTMyMiBMMTIsMTAuNTg1Nzg2NCBMMTguMjkyODkzMiw0LjI5Mjg5MzIyIEMxOC42ODM0MTc1LDMuOTAyMzY4OTMgMTkuMzE2NTgyNSwzLjkwMjM2ODkzIDE5LjcwNzEwNjgsNC4yOTI4OTMyMiBDMjAuMDk3NjMxMSw0LjY4MzQxNzUxIDIwLjA5NzYzMTEsNS4zMTY1ODI0OSAxOS43MDcxMDY4LDUuNzA3MTA2NzggTDEzLjQxNDIxMzYsMTIgWiIvPjwvc3ZnPg==');\n    --icon-CrossSmall: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOC41LDcuNSBMMTIuNSw3LjUgQzEyLjc3NjE0MjQsNy41IDEzLDcuNzIzODU3NjMgMTMsOCBDMTMsOC4yNzYxNDIzNyAxMi43NzYxNDI0LDguNSAxMi41LDguNSBMOC41LDguNSBMOC41LDEyLjUgQzguNSwxMi43NzYxNDI0IDguMjc2MTQyMzcsMTMgOCwxMyBDNy43MjM4NTc2MywxMyA3LjUsMTIuNzc2MTQyNCA3LjUsMTIuNSBMNy41LDguNSBMMy41LDguNSBDMy4yMjM4NTc2Myw4LjUgMyw4LjI3NjE0MjM3IDMsOCBDMyw3LjcyMzg1NzYzIDMuMjIzODU3NjMsNy41IDMuNSw3LjUgTDcuNSw3LjUgTDcuNSwzLjUgQzcuNSwzLjIyMzg1NzYzIDcuNzIzODU3NjMsMyA4LDMgQzguMjc2MTQyMzcsMyA4LjUsMy4yMjM4NTc2MyA4LjUsMy41IEw4LjUsNy41IFoiIHRyYW5zZm9ybT0icm90YXRlKC00NSA4IDgpIi8+PC9zdmc+');\n    --icon-Database: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMjEyMTIxIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY2xhc3M9Im5jLWljb24td3JhcHBlciI+PGVsbGlwc2UgY3g9IjgiIGN5PSIzIiBkYXRhLWNhcD0iYnV0dCIgcng9IjYuNSIgcnk9IjIuNSIvPjxwYXRoIGQ9Ik0xLjUsNi41VjEzIGMwLDEuMzgxLDIuOTEsMi41LDYuNSwyLjVzNi41LTEuMTE5LDYuNS0yLjVWNi41IiBkYXRhLWNhcD0iYnV0dCIvPjwvZz48L3N2Zz4=');\n    --icon-Desktop: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTQuNTQ1NSAxSDEuNDU0NTVDMC42NTQ1NDUgMSAwIDEuNjU0NTUgMCAyLjQ1NDU1VjExLjE4MThDMCAxMS45ODE4IDAuNjU0NTQ1IDEyLjYzNjQgMS40NTQ1NSAxMi42MzY0SDYuNTQ1NDVMNS4wOTA5MSAxNC44MTgyVjE1LjU0NTVIMTAuOTA5MVYxNC44MTgyTDkuNDU0NTUgMTIuNjM2NEgxNC41NDU1QzE1LjM0NTUgMTIuNjM2NCAxNiAxMS45ODE4IDE2IDExLjE4MThWMi40NTQ1NUMxNiAxLjY1NDU1IDE1LjM0NTUgMSAxNC41NDU1IDFaTTE0LjU0NTUgOS43MjcyN0gxLjQ1NDU1VjIuNDU0NTVIMTQuNTQ1NVY5LjcyNzI3WiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');\n    --icon-Dots: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOCw5LjUgQzcuMTcxNTcyODgsOS41IDYuNSw4LjgyODQyNzEyIDYuNSw4IEM2LjUsNy4xNzE1NzI4OCA3LjE3MTU3Mjg4LDYuNSA4LDYuNSBDOC44Mjg0MjcxMiw2LjUgOS41LDcuMTcxNTcyODggOS41LDggQzkuNSw4LjgyODQyNzEyIDguODI4NDI3MTIsOS41IDgsOS41IFogTTEyLjUsOS41IEMxMS42NzE1NzI5LDkuNSAxMSw4LjgyODQyNzEyIDExLDggQzExLDcuMTcxNTcyODggMTEuNjcxNTcyOSw2LjUgMTIuNSw2LjUgQzEzLjMyODQyNzEsNi41IDE0LDcuMTcxNTcyODggMTQsOCBDMTQsOC44Mjg0MjcxMiAxMy4zMjg0MjcxLDkuNSAxMi41LDkuNSBaIE0zLjUsOS41IEMyLjY3MTU3Mjg4LDkuNSAyLDguODI4NDI3MTIgMiw4IEMyLDcuMTcxNTcyODggMi42NzE1NzI4OCw2LjUgMy41LDYuNSBDNC4zMjg0MjcxMiw2LjUgNSw3LjE3MTU3Mjg4IDUsOCBDNSw4LjgyODQyNzEyIDQuMzI4NDI3MTIsOS41IDMuNSw5LjUgWiIvPjwvc3ZnPg==');\n    --icon-Download: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTIsMTMuMjkyODkzMiBMMTQuMTQ2NDQ2NiwxMS4xNDY0NDY2IEMxNC4zNDE3MDg4LDEwLjk1MTE4NDUgMTQuNjU4MjkxMiwxMC45NTExODQ1IDE0Ljg1MzU1MzQsMTEuMTQ2NDQ2NiBDMTUuMDQ4ODE1NSwxMS4zNDE3MDg4IDE1LjA0ODgxNTUsMTEuNjU4MjkxMiAxNC44NTM1NTM0LDExLjg1MzU1MzQgTDExLjg1MzU1MzQsMTQuODUzNTUzNCBDMTEuNjU4MjkxMiwxNS4wNDg4MTU1IDExLjM0MTcwODgsMTUuMDQ4ODE1NSAxMS4xNDY0NDY2LDE0Ljg1MzU1MzQgTDguMTQ2NDQ2NjEsMTEuODUzNTUzNCBDNy45NTExODQ0NiwxMS42NTgyOTEyIDcuOTUxMTg0NDYsMTEuMzQxNzA4OCA4LjE0NjQ0NjYxLDExLjE0NjQ0NjYgQzguMzQxNzA4NzYsMTAuOTUxMTg0NSA4LjY1ODI5MTI0LDEwLjk1MTE4NDUgOC44NTM1NTMzOSwxMS4xNDY0NDY2IEwxMSwxMy4yOTI4OTMyIEwxMSw3LjUgQzExLDcuMjIzODU3NjMgMTEuMjIzODU3Niw3IDExLjUsNyBDMTEuNzc2MTQyNCw3IDEyLDcuMjIzODU3NjMgMTIsNy41IEwxMiwxMy4yOTI4OTMyIFogTTEuMDg1MzUyODUsMTEgQzEuMjkxMjcxMDYsMTEuNTgyNTk2MiAxLjg0Njg5MDU5LDEyIDIuNSwxMiBMNi41LDEyIEM2Ljc3NjE0MjM3LDEyIDcsMTIuMjIzODU3NiA3LDEyLjUgQzcsMTIuNzc2MTQyNCA2Ljc3NjE0MjM3LDEzIDYuNSwxMyBMMi41LDEzIEMxLjExOTI4ODEzLDEzIDEuMzg3Nzc4NzhlLTE2LDExLjg4MDcxMTkgMCwxMC41IEMwLDEwLjIyMzg1NzYgMC4yMjM4NTc2MjUsMTAgMC41LDEwIEw2LjUsMTAgQzYuNzc2MTQyMzcsMTAgNywxMC4yMjM4NTc2IDcsMTAuNSBDNywxMC43NzYxNDI0IDYuNzc2MTQyMzcsMTEgNi41LDExIEwxLjA4NTM1Mjg1LDExIFogTTIsOC41IEMyLDguNzc2MTQyMzcgMS43NzYxNDIzNyw5IDEuNSw5IEMxLjIyMzg1NzYzLDkgMSw4Ljc3NjE0MjM3IDEsOC41IEwxLDIuNSBDMSwxLjY3MTU3Mjg4IDEuNjcxNTcyODgsMSAyLjUsMSBMMTMuNSwxIEMxNC4zMjg0MjcxLDEgMTUsMS42NzE1NzI4OCAxNSwyLjUgTDE1LDguNSBDMTUsOC43NzYxNDIzNyAxNC43NzYxNDI0LDkgMTQuNSw5IEMxNC4yMjM4NTc2LDkgMTQsOC43NzYxNDIzNyAxNCw4LjUgTDE0LDIuNSBDMTQsMi4yMjM4NTc2MyAxMy43NzYxNDI0LDIgMTMuNSwyIEwyLjUsMiBDMi4yMjM4NTc2MywyIDIsMi4yMjM4NTc2MyAyLDIuNSBMMiw4LjUgWiIvPjwvc3ZnPg==');\n    --icon-DragDrop: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNi41LDMgQzYuNzc2MTQyMzcsMyA3LDMuMjIzODU3NjMgNywzLjUgTDcsMTIuNSBDNywxMi43NzYxNDI0IDYuNzc2MTQyMzcsMTMgNi41LDEzIEM2LjIyMzg1NzYzLDEzIDYsMTIuNzc2MTQyNCA2LDEyLjUgTDYsMy41IEM2LDMuMjIzODU3NjMgNi4yMjM4NTc2MywzIDYuNSwzIFogTTkuNSwzIEM5Ljc3NjE0MjM3LDMgMTAsMy4yMjM4NTc2MyAxMCwzLjUgTDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgQzkuMjIzODU3NjMsMTMgOSwxMi43NzYxNDI0IDksMTIuNSBMOSwzLjUgQzksMy4yMjM4NTc2MyA5LjIyMzg1NzYzLDMgOS41LDMgWiIvPjwvc3ZnPg==');\n    --icon-Dropdown: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOCw5LjE3NDYzMDUyIEwxMC45MjE4MjczLDYuMTg5MDIzMiBDMTEuMTY4NDc0Miw1LjkzNjk5MjI3IDExLjU2ODM2NzksNS45MzY5OTIyNyAxMS44MTUwMTQ4LDYuMTg5MDIzMiBDMTIuMDYxNjYxNyw2LjQ0MTA1NDEzIDEyLjA2MTY2MTcsNi44NDk2NzcwMSAxMS44MTUwMTQ4LDcuMTAxNzA3OTQgTDgsMTEgTDQuMTg0OTg1MTksNy4xMDE3MDc5NCBDMy45MzgzMzgyNyw2Ljg0OTY3NzAxIDMuOTM4MzM4MjcsNi40NDEwNTQxMyA0LjE4NDk4NTE5LDYuMTg5MDIzMiBDNC40MzE2MzIxMSw1LjkzNjk5MjI3IDQuODMxNTI1NzgsNS45MzY5OTIyNyA1LjA3ODE3MjcsNi4xODkwMjMyIEw4LDkuMTc0NjMwNTIgWiIvPjwvc3ZnPg==');\n    --icon-DropdownUp: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOCw5LjE3NDYzMDUyIEwxMC45MjE4MjczLDYuMTg5MDIzMiBDMTEuMTY4NDc0Miw1LjkzNjk5MjI3IDExLjU2ODM2NzksNS45MzY5OTIyNyAxMS44MTUwMTQ4LDYuMTg5MDIzMiBDMTIuMDYxNjYxNyw2LjQ0MTA1NDEzIDEyLjA2MTY2MTcsNi44NDk2NzcwMSAxMS44MTUwMTQ4LDcuMTAxNzA3OTQgTDgsMTEgTDQuMTg0OTg1MTksNy4xMDE3MDc5NCBDMy45MzgzMzgyNyw2Ljg0OTY3NzAxIDMuOTM4MzM4MjcsNi40NDEwNTQxMyA0LjE4NDk4NTE5LDYuMTg5MDIzMiBDNC40MzE2MzIxMSw1LjkzNjk5MjI3IDQuODMxNTI1NzgsNS45MzY5OTIyNyA1LjA3ODE3MjcsNi4xODkwMjMyIEw4LDkuMTc0NjMwNTIgWiIgdHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgMCAxNykiLz48L3N2Zz4=');\n    --icon-Empty: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgdmlld0JveD0iMCAwIDUuMjkyIDUuMjkyIj48cGF0aCBkPSJtIDAuMDMyNjIyNjMsMjkxLjcyMjI1IDUuMjMwMDMyMjcsNS4yNzY5IiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDouNTI5MTY2NztzdHJva2UtbGluZWNhcDpidXR0O3N0cm9rZS1saW5lam9pbjptaXRlcjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2Utb3BhY2l0eToxO3BhaW50LW9yZGVyOm5vcm1hbCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMjkxLjcwOCkiLz48L3N2Zz4=');\n    --icon-Exclamation: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTkuNSAxSDYuNUw3IDEwSDlMOS41IDFaTTggMTVDOC44Mjg0MyAxNSA5LjUgMTQuMzI4NCA5LjUgMTMuNSA5LjUgMTIuNjcxNiA4LjgyODQzIDEyIDggMTIgNy4xNzE1NyAxMiA2LjUgMTIuNjcxNiA2LjUgMTMuNSA2LjUgMTQuMzI4NCA3LjE3MTU3IDE1IDggMTVaIi8+PC9zdmc+');\n    --icon-Expand: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOCw5LjE3NDYzMDUyIEwxMC45MjE4MjczLDYuMTg5MDIzMiBDMTEuMTY4NDc0Miw1LjkzNjk5MjI3IDExLjU2ODM2NzksNS45MzY5OTIyNyAxMS44MTUwMTQ4LDYuMTg5MDIzMiBDMTIuMDYxNjYxNyw2LjQ0MTA1NDEzIDEyLjA2MTY2MTcsNi44NDk2NzcwMSAxMS44MTUwMTQ4LDcuMTAxNzA3OTQgTDgsMTEgTDQuMTg0OTg1MTksNy4xMDE3MDc5NCBDMy45MzgzMzgyNyw2Ljg0OTY3NzAxIDMuOTM4MzM4MjcsNi40NDEwNTQxMyA0LjE4NDk4NTE5LDYuMTg5MDIzMiBDNC40MzE2MzIxMSw1LjkzNjk5MjI3IDQuODMxNTI1NzgsNS45MzY5OTIyNyA1LjA3ODE3MjcsNi4xODkwMjMyIEw4LDkuMTc0NjMwNTIgWiIgdHJhbnNmb3JtPSJyb3RhdGUoLTkwIDggOC41KSIvPjwvc3ZnPg==');\n    --icon-EyeHide: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMi43Njc4NTAwNSw4LjM4Nzc2MTg2IEw4LjQ4MjQ3ODY1LDIuOTQ1MjU4NDMgQzcuNjkxMzc4OTEsMi4zMzc3ODQ3OCA2Ljg1OTU2NTI3LDIgNiwyIEM0LjY4NzE1Nzc5LDIgMy40MzkwNTMwNiwyLjc4Nzk2NTkxIDIuMzAxODY4ODMsNC4xMjEyODAyMiBDMS44OTUwOTk5MSw0LjU5ODIwNDU1IDEuNTMyNzI3ODQsNS4xMTA5NzkxMSAxLjIyMDU0MTc0LDUuNjIzNDE5NDQgQzEuMTMzOTQxMDEsNS43NjU1NzA5IDEuMDYwMjYyMTgsNS44OTIzMTU0OCAxLDYgQzEuMDYwMjYyMTgsNi4xMDc2ODQ1MiAxLjEzMzk0MTAxLDYuMjM0NDI5MSAxLjIyMDU0MTc0LDYuMzc2NTgwNTYgQzEuNTMyNzI3ODQsNi44ODkwMjA4OSAxLjg5NTA5OTkxLDcuNDAxNzk1NDUgMi4zMDE4Njg4Myw3Ljg3ODcxOTc4IEMyLjQ1NTIxNjY2LDguMDU4NTE1NDkgMi42MTA1ODE0OCw4LjIyODM5NDQ5IDIuNzY3ODUwMDUsOC4zODc3NjE4NiBaIE0zLjUxNzUyMTM1LDkuMDU0NzQxNTcgQzQuMzA4NjIxMDksOS42NjIyMTUyMiA1LjE0MDQzNDczLDEwIDYsMTAgQzcuMzEyODQyMjEsMTAgOC41NjA5NDY5NCw5LjIxMjAzNDA5IDkuNjk4MTMxMTcsNy44Nzg3MTk3OCBDMTAuMTA0OTAwMSw3LjQwMTc5NTQ1IDEwLjQ2NzI3MjIsNi44ODkwMjA4OSAxMC43Nzk0NTgzLDYuMzc2NTgwNTYgQzEwLjg2NjA1OSw2LjIzNDQyOTEgMTAuOTM5NzM3OCw2LjEwNzY4NDUyIDExLDYgQzEwLjkzOTczNzgsNS44OTIzMTU0OCAxMC44NjYwNTksNS43NjU1NzA5IDEwLjc3OTQ1ODMsNS42MjM0MTk0NCBDMTAuNDY3MjcyMiw1LjExMDk3OTExIDEwLjEwNDkwMDEsNC41OTgyMDQ1NSA5LjY5ODEzMTE3LDQuMTIxMjgwMjIgQzkuNTQ0NzgzMzQsMy45NDE0ODQ1MSA5LjM4OTQxODUyLDMuNzcxNjA1NTEgOS4yMzIxNDk5NSwzLjYxMjIzODE0IEwzLjUxNzUyMTM1LDkuMDU0NzQxNTcgWiBNOS4xOTgwNDkzMSwyLjI2Mzc2MjU3IEwxMC45MDUxNzI0LDAuNjM3OTMxMDM0IEMxMS4xMDUxMzc2LDAuNDQ3NDg4MDE3IDExLjQyMTYyNTksMC40NTUyMDcyNDYgMTEuNjEyMDY5LDAuNjU1MTcyNDE0IEMxMS44MDI1MTIsMC44NTUxMzc1ODIgMTEuNzk0NzkyOCwxLjE3MTYyNTk1IDExLjU5NDgyNzYsMS4zNjIwNjg5NyBMOS45MzU4NzU3MSwyLjk0MjAyMzEzIEMxMC4wNzI2MjI0LDMuMDgyNDQ2NTMgMTAuMjA3NjE5OSwzLjIyOTQzMzkgMTAuMzQwODQyNiwzLjM4MjYzMjA1IEMxMC43ODMyMzI5LDMuODkxMzU0NDcgMTEuMTc0NzIzMSw0LjQzNDY4NzU1IDExLjUxMjQxNTUsNC45NzgzNDQwOSBDMTEuNzE2MzQxLDUuMzA2NjQ3MTggMTEuODU4NTM2Niw1LjU2NTEwODYzIDExLjkzNTIyOCw1LjcxOTQ0MjEyIEMxMi4wMjE1OTA3LDUuODkzMjM3ODIgMTIuMDIxNTkwNyw2LjEwNjc2MjE4IDExLjkzNTIyOCw2LjI4MDU1Nzg4IEMxMS44NTg1MzY2LDYuNDM0ODkxMzcgMTEuNzE2MzQxLDYuNjkzMzUyODIgMTEuNTEyNDE1NSw3LjAyMTY1NTkxIEMxMS4xNzQ3MjMxLDcuNTY1MzEyNDUgMTAuNzgzMjMyOSw4LjEwODY0NTUzIDEwLjM0MDg0MjYsOC42MTczNjc5NSBDOS4wNTIxOTAyNiwxMC4wOTkyNDEyIDcuNTk3NDczMTUsMTEgNiwxMSBDNC44NTgyMTg4NiwxMSAzLjc4OTM2NTUyLDEwLjUzOTg0MTkgMi44MDE5NTA2OSw5LjczNjIzNzQzIEwxLjA5NDgyNzU5LDExLjM2MjA2OSBDMC44OTQ4NjI0MTgsMTEuNTUyNTEyIDAuNTc4Mzc0MDUyLDExLjU0NDc5MjggMC4zODc5MzEwMzQsMTEuMzQ0ODI3NiBDMC4xOTc0ODgwMTcsMTEuMTQ0ODYyNCAwLjIwNTIwNzI0NiwxMC44MjgzNzQxIDAuNDA1MTcyNDE0LDEwLjYzNzkzMSBMMi4wNjQxMjQyOSw5LjA1Nzk3Njg3IEMxLjkyNzM3NzYzLDguOTE3NTUzNDcgMS43OTIzODAwNyw4Ljc3MDU2NjEgMS42NTkxNTczNyw4LjYxNzM2Nzk1IEMxLjIxNjc2NzA3LDguMTA4NjQ1NTMgMC44MjUyNzY5MjgsNy41NjUzMTI0NSAwLjQ4NzU4NDUyMSw3LjAyMTY1NTkxIEMwLjI4MzY1ODk2Niw2LjY5MzM1MjgyIDAuMTQxNDYzMzc4LDYuNDM0ODkxMzcgMC4wNjQ3NzE5NTA0LDYuMjgwNTU3ODggQy0wLjAyMTU5MDY1MDEsNi4xMDY3NjIxOCAtMC4wMjE1OTA2NTAxLDUuODkzMjM3ODIgMC4wNjQ3NzE5NTA0LDUuNzE5NDQyMTIgQzAuMTQxNDYzMzc4LDUuNTY1MTA4NjMgMC4yODM2NTg5NjYsNS4zMDY2NDcxOCAwLjQ4NzU4NDUyMSw0Ljk3ODM0NDA5IEMwLjgyNTI3NjkyOCw0LjQzNDY4NzU1IDEuMjE2NzY3MDcsMy44OTEzNTQ0NyAxLjY1OTE1NzM3LDMuMzgyNjMyMDUgQzIuOTQ3ODA5NzQsMS45MDA3NTg4IDQuNDAyNTI2ODUsMSA2LDEgQzcuMTQxNzgxMTQsMSA4LjIxMDYzNDQ4LDEuNDYwMTU4MTQgOS4xOTgwNDkzMSwyLjI2Mzc2MjU3IFogTTQuMjUsNiBDNC4yNSw2LjI3NjE0MjM3IDQuMDI2MTQyMzcsNi41IDMuNzUsNi41IEMzLjQ3Mzg1NzYzLDYuNSAzLjI1LDYuMjc2MTQyMzcgMy4yNSw2IEMzLjI1LDQuNTM0NjgyNTQgNC40ODY0MTY2NywzLjM1NzE0Mjg2IDYsMy4zNTcxNDI4NiBDNi4yNzYxNDIzNywzLjM1NzE0Mjg2IDYuNSwzLjU4MTAwMDQ4IDYuNSwzLjg1NzE0Mjg2IEM2LjUsNC4xMzMyODUyMyA2LjI3NjE0MjM3LDQuMzU3MTQyODYgNiw0LjM1NzE0Mjg2IEM1LjAyODA4MzMzLDQuMzU3MTQyODYgNC4yNSw1LjA5ODE3NDYgNC4yNSw2IFogTTcuNzUsNiBDNy43NSw1LjcyMzg1NzYzIDcuOTczODU3NjMsNS41IDguMjUsNS41IEM4LjUyNjE0MjM3LDUuNSA4Ljc1LDUuNzIzODU3NjMgOC43NSw2IEM4Ljc1LDcuNDY1MzE3NDYgNy41MTM1ODMzMyw4LjY0Mjg1NzE0IDYsOC42NDI4NTcxNCBDNS43MjM4NTc2Myw4LjY0Mjg1NzE0IDUuNSw4LjQxODk5OTUyIDUuNSw4LjE0Mjg1NzE0IEM1LjUsNy44NjY3MTQ3NyA1LjcyMzg1NzYzLDcuNjQyODU3MTQgNiw3LjY0Mjg1NzE0IEM2Ljk3MTkxNjY3LDcuNjQyODU3MTQgNy43NSw2LjkwMTgyNTQgNy43NSw2IFoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIgMikiLz48L3N2Zz4=');\n    --icon-EyeShow: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMy4yMzUwMDQ3Nyw4LjQwMDQwMDM3IEMzLjU0NzMwNDc5LDguOTEwMzQxODIgMy45MDk3Mzk1LDkuNDIwNTE3OTEgNC4zMTY0Nzk3MSw5Ljg5NDkwOTkzIEM1LjQ1MTQxOTI2LDExLjIxODYyMDQgNi42OTU2MTA0MywxMiA4LDEyIEM5LjMwNDM4OTU3LDEyIDEwLjU0ODU4MDcsMTEuMjE4NjIwNCAxMS42ODM1MjAzLDkuODk0OTA5OTMgQzEyLjA5MDI2MDUsOS40MjA1MTc5MSAxMi40NTI2OTUyLDguOTEwMzQxODIgMTIuNzY0OTk1Miw4LjQwMDQwMDM3IEMxMi44NTg1NzQ3LDguMjQ3NTk4NDQgMTIuOTM3MTE2NCw4LjExMjYxMDY0IDEzLDggQzEyLjkzNzExNjQsNy44ODczODkzNiAxMi44NTg1NzQ3LDcuNzUyNDAxNTYgMTIuNzY0OTk1Miw3LjU5OTU5OTYzIEMxMi40NTI2OTUyLDcuMDg5NjU4MTggMTIuMDkwMjYwNSw2LjU3OTQ4MjA5IDExLjY4MzUyMDMsNi4xMDUwOTAwNyBDMTAuNTQ4NTgwNyw0Ljc4MTM3OTYxIDkuMzA0Mzg5NTcsNCA4LDQgQzYuNjk1NjEwNDMsNCA1LjQ1MTQxOTI2LDQuNzgxMzc5NjEgNC4zMTY0Nzk3MSw2LjEwNTA5MDA3IEMzLjkwOTczOTUsNi41Nzk0ODIwOSAzLjU0NzMwNDc5LDcuMDg5NjU4MTggMy4yMzUwMDQ3Nyw3LjU5OTU5OTYzIEMzLjE0MTQyNTMxLDcuNzUyNDAxNTYgMy4wNjI4ODM1OCw3Ljg4NzM4OTM2IDMsOCBDMy4wNjI4ODM1OCw4LjExMjYxMDY0IDMuMTQxNDI1MzEsOC4yNDc1OTg0NCAzLjIzNTAwNDc3LDguNDAwNDAwMzcgWiBNMi4wNTk4MDYyNSw3Ljc0MTE2MTAxIEMyLjEzNjA0ODExLDcuNTg2OTAyODggMi4yNzc3NTg2Nyw3LjMyNzkzMDcxIDIuNDgxMTU5MDYsNi45OTg3MDMwNyBDMi44MTgyMjQ4OCw2LjQ1MzEyMjA1IDMuMjA5MDU2NDgsNS45MDc3NzA5MiAzLjY1MDgwNTQ1LDUuMzk3MDQwMTQgQzQuOTM5ODA0MDUsMy45MDY3NTY0NiA2LjM5NjMzNDE5LDMgOCwzIEM5LjYwMzY2NTgxLDMgMTEuMDYwMTk1OSwzLjkwNjc1NjQ2IDEyLjM0OTE5NDYsNS4zOTcwNDAxNCBDMTIuNzkwOTQzNSw1LjkwNzc3MDkyIDEzLjE4MTc3NTEsNi40NTMxMjIwNSAxMy41MTg4NDA5LDYuOTk4NzAzMDcgQzEzLjcyMjI0MTMsNy4zMjc5MzA3MSAxMy44NjM5NTE5LDcuNTg2OTAyODggMTMuOTQwMTkzNyw3Ljc0MTE2MTAxIEMxNC4wMTk5MzU0LDcuOTAyNTAwMTkgMTQuMDE5OTM1NCw4LjA5NzQ5OTgxIDEzLjk0MDE5MzcsOC4yNTg4Mzg5OSBDMTMuODYzOTUxOSw4LjQxMzA5NzEyIDEzLjcyMjI0MTMsOC42NzIwNjkyOSAxMy41MTg4NDA5LDkuMDAxMjk2OTMgQzEzLjE4MTc3NTEsOS41NDY4Nzc5NSAxMi43OTA5NDM1LDEwLjA5MjIyOTEgMTIuMzQ5MTk0NiwxMC42MDI5NTk5IEMxMS4wNjAxOTU5LDEyLjA5MzI0MzUgOS42MDM2NjU4MSwxMyA4LDEzIEM2LjM5NjMzNDE5LDEzIDQuOTM5ODA0MDUsMTIuMDkzMjQzNSAzLjY1MDgwNTQ1LDEwLjYwMjk1OTkgQzMuMjA5MDU2NDgsMTAuMDkyMjI5MSAyLjgxODIyNDg4LDkuNTQ2ODc3OTUgMi40ODExNTkwNiw5LjAwMTI5NjkzIEMyLjI3Nzc1ODY3LDguNjcyMDY5MjkgMi4xMzYwNDgxMSw4LjQxMzA5NzEyIDIuMDU5ODA2MjUsOC4yNTg4Mzg5OSBDMS45ODAwNjQ1OCw4LjA5NzQ5OTgxIDEuOTgwMDY0NTgsNy45MDI1MDAxOSAyLjA1OTgwNjI1LDcuNzQxMTYxMDEgWiBNOC4wNjQyMTk2MiwxMC4yODcyNzY2IEM2LjcyNzYwMzYyLDEwLjI4NzI3NjYgNS42NDQwNjIyNiw5LjI2MzIyNzk2IDUuNjQ0MDYyMjYsOCBDNS42NDQwNjIyNiw2LjczNjc3MjA0IDYuNzI3NjAzNjIsNS43MTI3MjM0NCA4LjA2NDIxOTYyLDUuNzEyNzIzNDQgQzkuNDAwODM1NjMsNS43MTI3MjM0NCAxMC40ODQzNzcsNi43MzY3NzIwNCAxMC40ODQzNzcsOCBDMTAuNDg0Mzc3LDkuMjYzMjI3OTYgOS40MDA4MzU2MywxMC4yODcyNzY2IDguMDY0MjE5NjIsMTAuMjg3Mjc2NiBaIE04LjA2NDIxOTYyLDkuMzI0MjEyNzUgQzguODM4MDQ5OTQsOS4zMjQyMTI3NSA5LjQ2NTM2MzM2LDguNzMxMzQyNTEgOS40NjUzNjMzNiw4IEM5LjQ2NTM2MzM2LDcuMjY4NjU3NDkgOC44MzgwNDk5NCw2LjY3NTc4NzI1IDguMDY0MjE5NjIsNi42NzU3ODcyNSBDNy4yOTAzODkzMSw2LjY3NTc4NzI1IDYuNjYzMDc1ODksNy4yNjg2NTc0OSA2LjY2MzA3NTg5LDggQzYuNjYzMDc1ODksOC43MzEzNDI1MSA3LjI5MDM4OTMxLDkuMzI0MjEyNzUgOC4wNjQyMTk2Miw5LjMyNDIxMjc1IFoiLz48L3N2Zz4=');\n    --icon-Feedback: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNy4yMTgyMTMzLDkgQzcuMjI1MTUxOTcsOC45OTk4NTUxNSA3LjIzMjA4MzIxLDguOTk5ODU1NjQgNy4yMzkwMDI2LDkgTDguNTI0LDkgTDguNzEwNDY2NjcsOS4wMzYwNzA5MyBMMTMsMTAuNzYwMTU5OSBMMTMsMy4yMzk4NDAxMSBMOC43MTA0NjY2Nyw0Ljk2MzkyOTA3IEw4LjUyNCw1IEw1LDUgQzMuODk1NDMwNSw1IDMsNS44OTU0MzA1IDMsNyBDMyw3LjUzMDQzMjk4IDMuMjEwNzEzNjgsOC4wMzkxNDA4MSAzLjU4NTc4NjQ0LDguNDE0MjEzNTYgQzMuOTYwODU5MTksOC43ODkyODYzMiA0LjQ2OTU2NzAyLDkgNSw5IEw3LjIxODIxMzA2LDkgWiBNOC4wNDA1MDM3OSwxMCBMOC44NDUwNTE1MywxMS41OTgxMjAzIEM5LjA0NDU1OTMzLDExLjk4Nzc2MTMgOS4wODEwNTY1NCwxMi40NDA3MTI1IDguOTQ2NTA5NCwxMi44NTcyNzA1IEM4LjgxMTk2MjI3LDEzLjI3MzgyODQgOC41MTczOTk1LDEzLjYxOTg0ODEgOC4xMjc0OTI4OSwxMy44MTkyNDk0IEM3LjMxNjIzMTAzLDE0LjIzMzc1MDQgNi4zMjI1NDQ2MiwxMy45MTI3OTIyIDUuOTA3NzcxOTgsMTMuMTAzNDgwOSBMNC4yNTYxNzkwNSw5LjkwNjMzNDc3IEMzLjczOTI3OTU5LDkuNzc0MDI4ODYgMy4yNjIzNjUzMSw5LjUwNTAwNiAyLjg3ODY3OTY2LDkuMTIxMzIwMzQgQzIuMzE2MDcwNTIsOC41NTg3MTEyMSAyLDcuNzk1NjQ5NDcgMiw3IEMyLDUuMzQzMTQ1NzUgMy4zNDMxNDU3NSw0IDUsNCBMOC40MjcyNzc4MSw0IEwxMy4zMTM1MzMzLDIuMDM2MDcwOTMgQzEzLjY0MjA3NjIsMS45MDQwMTk5NCAxNCwyLjE0NTkxMjYyIDE0LDIuNSBMMTQsMTEuNSBDMTQsMTEuODU0MDg3NCAxMy42NDIwNzYyLDEyLjA5NTk4MDEgMTMuMzEzNTMzMywxMS45NjM5MjkxIEw4LjQyNzI3NzgxLDEwIEw4LjA0MDUwMzc5LDEwIFogTTUuNDMwMTEyNCwxMCBMNi43OTY5NjU4NCwxMi42NDU5NTMxIEM2Ljk2MDgwNDMxLDEyLjk2NTYzNTIgNy4zNTI2MjEzMSwxMy4wOTIxOTExIDcuNjcyMzM5NzgsMTIuOTI4ODM2MiBDNy44MjU4NzM0MywxMi44NTAzMTc5IDcuOTQxOTEzMzEsMTIuNzE0MDA3MSA3Ljk5NDkxNjczLDEyLjU0OTkwODUgQzguMDQ3OTIwMTUsMTIuMzg1ODA5OSA4LjAzMzU0MjQ2LDEyLjIwNzM3NDYgNy45NTM0MDI1LDEyLjA1MDgzNDggTDYuOTIwOTMxNTEsMTAgTDUuNDMwMTEyNCwxMCBaIi8+PC9zdmc+');\n    --icon-Filter: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMi41LDQgTDEuNSw0IEMxLjIyNCw0IDEsMy43NzYgMSwzLjUgQzEsMy4yMjQgMS4yMjQsMyAxLjUsMyBMMi41LDMgQzIuNzc2LDMgMywzLjIyNCAzLDMuNSBDMywzLjc3NiAyLjc3Niw0IDIuNSw0IFogTTQuNSw3IEwxLjUsNyBDMS4yMjQsNyAxLDYuNzc2IDEsNi41IEMxLDYuMjI0IDEuMjI0LDYgMS41LDYgTDQuNSw2IEM0Ljc3Niw2IDUsNi4yMjQgNSw2LjUgQzUsNi43NzYgNC43NzYsNyA0LjUsNyBaIE02LjUsMTAgTDEuNSwxMCBDMS4yMjQsMTAgMSw5Ljc3NiAxLDkuNSBDMSw5LjIyNCAxLjIyNCw5IDEuNSw5IEw2LjUsOSBDNi43NzYsOSA3LDkuMjI0IDcsOS41IEM3LDkuNzc2IDYuNzc2LDEwIDYuNSwxMCBaIE04LjUsMTMgTDEuNSwxMyBDMS4yMjQsMTMgMSwxMi43NzYgMSwxMi41IEMxLDEyLjIyNCAxLjIyNCwxMiAxLjUsMTIgTDguNSwxMiBDOC43NzYsMTIgOSwxMi4yMjQgOSwxMi41IEM5LDEyLjc3NiA4Ljc3NiwxMyA4LjUsMTMgWiBNMTQuOTcyODg4LDIuMjg0OTA4ODUgQzE1LjAyNjUzODIsMi40NTczNjE3OCAxNC45OTY4ODY0LDIuNjU1OTY3MjUgMTQuODk3ODkzMiwyLjc4NzgwNjgzIEwxMS42ODEzMDY4LDcuMDc2NjY5NjUgQzExLjYxMTczNDcsNy4xNjk1ODgwOSAxMS41MzgwMDksNy40MDY4MDcwMiAxMS41MzgwMDksNy41MzgxODUwOCBMMTEuNTM4MDA5LDEzLjUzNzg4NTYgQzExLjUzODAwOSwxMy43MjQ2NDU1IDExLjQ1Mzc4NCwxMy44OTI5NDQ4IDExLjMyNDMzMTQsMTMuOTY0MTcyIEMxMS4yODE1MjY2LDEzLjk4ODAxNyAxMS4yMzY1Mjk3LDEzLjk5OTQwMSAxMS4xOTE5OTQzLDEzLjk5OTQwMSBDMTEuMTAxODg1MSwxMy45OTk0MDEgMTEuMDEzMjc1OCwxMy45NTI0ODAzIDEwLjk0NzE2NSwxMy44NjQxNzcgTDkuNTYyNjQ0ODYsMTIuMDE4MTE1MyBDOS40OTc2ODc3OSwxMS45MzE2NTgxIDkuNDYxMjI4NzYsMTEuODE0Mjc5MyA5LjQ2MTIyODc2LDExLjY5MTgyMzkgTDkuNDYxMjI4NzYsNy41MzgxODUwOCBDOS40NjEyMjg3Niw3LjQwNjgwNzAyIDkuMzg3NjE4NDQsNy4xNjk1ODgwOSA5LjMxNzkzMDkyLDcuMDc2OTc3MzMgTDYuMTAxMzQ0NTIsMi43ODc5NjA2NyBDNi4wMDIzNTEzMywyLjY1NTk2NzI1IDUuOTcyODE0OSwyLjQ1NzUxNTYyIDYuMDI2MzQ5NjgsMi4yODQ5MDg4NSBDNi4wNzk4ODQ0NSwyLjExMjMwMjA5IDYuMjA2MTA2NTQsMiA2LjM0NjA1ODQ1LDIgTDE0LjY1MzE3OTMsMiBDMTQuNzkzMjQ2NiwyIDE0LjkxOTQ2ODYsMi4xMTIzMDIwOSAxNC45NzI4ODgsMi4yODQ5MDg4NSBaIi8+PC9zdmc+');\n    --icon-FilterSimple: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOS45NTA4MzMzMywwLjIyIEM5Ljg3ODI4NjY3LDAuMDg0NDk3NzEwOCA5LjczNzAzNCwtNi4xNjk2Nzk1MWUtMDUgOS41ODMzMzMzMywtNC42MjU5MzI4ZS0xNyBMMC40MTY2NjY2NjcsLTQuNjI1OTMyOGUtMTcgQzAuMjYzMTAzNDU0LDcuOTMyNzM0MzVlLTA1IDAuMTIyMDMxNCwwLjA4NDYxODQ4NzUgMC4wNDk1NDk0MDEyLDAuMjE5OTk5NTI5IEMtMC4wMjI5MzI1OTc4LDAuMzU1MzgwNTcxIC0wLjAxNTA3NDA1MTcsMC41MTk2NTYwNjIgMC4wNywwLjY0NzUgTDMuMzMzMzMzMzMsNS41NDI1IEwzLjMzMzMzMzMzLDkuNTgzMzMzMzMgQzMuMzMzMzMzMzMsOS44MTM0NTIgMy41MTk4ODEzNiwxMCAzLjc1LDEwIEw2LjI1LDEwIEM2LjQ4MDExODY0LDEwIDYuNjY2NjY2NjcsOS44MTM0NTIgNi42NjY2NjY2Nyw5LjU4MzMzMzMzIEw2LjY2NjY2NjY3LDUuNTQyNSBMOS45MywwLjY0NzUgQzEwLjAxNTE5NTYsMC41MTk3Mjc5OTEgMTAuMDIzMjAxMywwLjM1NTQ1MDU0NyA5Ljk1MDgzMzMzLDAuMjIgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMyAzKSIvPjwvc3ZnPg==');\n    --icon-Fireworks: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTguNjY2NDcgMTUuMzMzM0g3LjMzMzE0QzcuMzMzMTQgOS44NDc5NyA0LjQ0NzggOC42MzA2MyA0LjQxODQ3IDguNjE5M0wzLjc5OTggOC4zNzEzIDQuMjk1MTQgNy4xMzMzIDQuOTE0NDcgNy4zODA2M0M1LjAyNTE0IDcuNDI1OTcgNi45NTE4IDguMjM1MyA3Ljk5OTggMTEuMTE0IDkuMDQ3OCA4LjIzNTk3IDEwLjk3NDUgNy40MjY2MyAxMS4wODUxIDcuMzgwNjNMMTEuNzA0NSA3LjEzMzMgMTIuMTk5OCA4LjM3MTMgMTEuNTg0NSA4LjYxNzNDMTEuNDM5OCA4LjY3OTk3IDguNjY2NDcgOS45NDE5NyA4LjY2NjQ3IDE1LjMzMzNaTTEwLjQ1MzEgMS43OTA1NCA4Ljc1NzcyIDEuNTQ0NTQgNy45OTk3Mi4wMDg1NDQ5MiA3LjI0MTcyIDEuNTQ0NTQgNS41NDYzOSAxLjc5MDU0IDYuNzczMDUgMi45ODY1NCA2LjQ4MzcyIDQuNjc1MjEgNy45OTk3MiAzLjg3Nzg4IDkuNTE1NzIgNC42NzUyMSA5LjIyNjM5IDIuOTg2NTQgMTAuNDUzMSAxLjc5MDU0Wk01LjI5OTE3IDEyLjM1OCAzLjY5ODUgMTIuMTI1MyAyLjk4MzE3IDEwLjY3NTMgMi4yNjcxNyAxMi4xMjUzLjY2NjUwNCAxMi4zNTggMS44MjQ1IDEzLjQ4NjYgMS41NTExNyAxNS4wODA2IDIuOTgzMTcgMTQuMzI4NiA0LjQxNDUgMTUuMDgwNiA0LjE0MTE3IDEzLjQ4NjYgNS4yOTkxNyAxMi4zNThaTTE1LjMzMzMgMTIuMzU4IDEzLjczMjcgMTIuMTI1MyAxMy4wMTY3IDEwLjY3NTMgMTIuMzAxMyAxMi4xMjUzIDEwLjcwMDcgMTIuMzU4IDExLjg1ODcgMTMuNDg2NiAxMS41ODUzIDE1LjA4MDYgMTMuMDE2NyAxNC4zMjg2IDE0LjQ0ODcgMTUuMDgwNiAxNC4xNzUzIDEzLjQ4NjYgMTUuMzMzMyAxMi4zNThaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTguNjY2MzQgNy41MzMxM1Y1LjM0MThINy4zMzMwMVY3LjUyMDQ2QzcuNTc2MjEgNy43NjgxMSA3LjgwMjUgOC4wMzE4MSA4LjAxMDM0IDguMzA5OCA4LjIxMjIgOC4wMzcxNyA4LjQzMTMyIDcuNzc3NzUgOC42NjYzNCA3LjUzMzEzWk0xLjY2NjUgNy4zMzMyNUMyLjIxODc5IDcuMzMzMjUgMi42NjY1IDYuODg1NTQgMi42NjY1IDYuMzMzMjUgMi42NjY1IDUuNzgwOTcgMi4yMTg3OSA1LjMzMzI1IDEuNjY2NSA1LjMzMzI1IDEuMTE0MjIgNS4zMzMyNS42NjY1MDQgNS43ODA5Ny42NjY1MDQgNi4zMzMyNS42NjY1MDQgNi44ODU1NCAxLjExNDIyIDcuMzMzMjUgMS42NjY1IDcuMzMzMjVaTTE0LjMzMyA3LjMzMzI1QzE0Ljg4NTMgNy4zMzMyNSAxNS4zMzMgNi44ODU1NCAxNS4zMzMgNi4zMzMyNSAxNS4zMzMgNS43ODA5NyAxNC44ODUzIDUuMzMzMjUgMTQuMzMzIDUuMzMzMjUgMTMuNzgwNyA1LjMzMzI1IDEzLjMzMyA1Ljc4MDk3IDEzLjMzMyA2LjMzMzI1IDEzLjMzMyA2Ljg4NTU0IDEzLjc4MDcgNy4zMzMyNSAxNC4zMzMgNy4zMzMyNVoiLz48L3N2Zz4=');\n    --icon-Flag: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgY2xhc3M9ImZlYXRoZXIgZmVhdGhlci1mbGFnIj48cGF0aCBzdHJva2U9Im51bGwiIGQ9Im0wLjQ3MTM2LDEwLjczMjExczAuOTQwNTQsLTAuNzg3ODcgMy43NjIxNiwtMC43ODc4N3M0LjcwMjY5LDEuNTc1NzQgNy41MjQzMiwxLjU3NTc0czMuNzYyMTYsLTAuNzg3ODcgMy43NjIxNiwtMC43ODc4N2wwLC05LjQ1NDQ4cy0wLjk0MDU0LDAuNzg3ODcgLTMuNzYyMTYsMC43ODc4N3MtNC43MDI2OSwtMS41NzU3NCAtNy41MjQzMiwtMS41NzU3NHMtMy43NjIxNiwwLjc4Nzg3IC0zLjc2MjE2LDAuNzg3ODdsMCw5LjQ1NDQ4eiIvPjwvc3ZnPg==');\n    --icon-Folder: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMyw1IEwxMyw1IEMxMy41NTIyODQ3LDUgMTQsNS40NDc3MTUyNSAxNCw2IEwxNCwxMyBDMTQsMTMuNTUyMjg0NyAxMy41NTIyODQ3LDE0IDEzLDE0IEwzLDE0IEMyLjQ0NzcxNTI1LDE0IDIsMTMuNTUyMjg0NyAyLDEzIEwyLDYgQzIsNS40NDc3MTUyNSAyLjQ0NzcxNTI1LDUgMyw1IFogTTQsMiBMMTIsMiBMMTIsNCBMNCw0IEw0LDIgWiIvPjwvc3ZnPg==');\n    --icon-Folder2: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzkyOTI5OSIgZD0iTTI0LjggOS4xODYwMkgxNi4yMjY3TDEzLjcwNjcgNi42NjYwMkg2LjA4QzQuOTMzMzMgNi42NjYwMiA0IDcuNTk5MzUgNCA4Ljc0NjAyVjIzLjMwNkM0IDI0LjQ2NiA0LjkzMzMzIDI1LjM5OTMgNi4wOTMzMyAyNS4zOTkzSDI0LjhDMjUuOTYgMjUuMzk5MyAyNi44OTMzIDI0LjQ2NiAyNi44OTMzIDIzLjMwNlYxMS4yNzkzQzI2Ljg5MzMgMTAuMTE5MyAyNS45NiA5LjE4NjAyIDI0LjggOS4xODYwMloiLz48L3N2Zz4=');\n    --icon-FontBold: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTMuMTk1MDk4OCAxLjgzNTQxOTloNS42OTAzODE4Yy43NTQ1OTUyIDAgMS40NzgyNzI0LjI5OTc1NjcgMi4wMTE4NTM0LjgzMzMzNzUuNTMzNTgxLjUzMzU4MDguODMzMzM3IDEuMjU3MjU4Mi44MzMzMzcgMi4wMTE4NTM1IDAgLjc1NDU5NTItLjI5OTc1NiAxLjQ3ODI3MjctLjgzMzMzNyAyLjAxMTg1MzVDMTAuMzYzNzUzIDcuMjI2MDQ1MSA5LjY0MDA3NTggNy41MjU4MDE4IDguODg1NDgwNiA3LjUyNTgwMThINS4wOTE4OTI3TTUuMDkxODkyNyA3LjUyNTgwMThoNS4yMTYxODMzYy44ODAzNTIgMCAxLjcyNDY1My4zNDk3MTgyIDIuMzQ3MTY5Ljk3MjIyMDcuNjIyNTAyLjYyMjUxNTIuOTcyMjIgMS40NjY4MTYxLjk3MjIyIDIuMzQ3MTY4NXYwYzAgLjg4MDM1My0uMzQ5NzE4IDEuNzI0NjU0LS45NzIyMiAyLjM0NzIxOS0uNjIyNTE2LjYyMjQwMi0xLjQ2NjgxNy45NzIxNy0yLjM0NzE2OS45NzIxN0gzLjE5NTA5ODhNNS4wOTE4OTI3IDEuODM1NDE5OVYxNC4xNjQ1OCIgc3R5bGU9ImZpbGw6bm9uZTtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6ZXZlbm9kZDtzdHJva2U6IzAzMDAwMDtzdHJva2Utd2lkdGg6MS4yNjQ1MjkzNTtzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW9wYWNpdHk6MSIvPjwvc3ZnPg==');\n    --icon-FontItalic: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBkPSJNMCAwSDEyVjEySDB6IiBzdHlsZT0iZmlsbDojZmZmIi8+PC9jbGlwUGF0aD48L2RlZnM+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIiBzdHlsZT0iZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDpub25lIiB0cmFuc2Zvcm09Im1hdHJpeCgxLjEzNTYgMCAwIDEuMTM1NiAuOTgzIDEuMzkpIj48cGF0aCBkPSJtIDQuODc1LDAuMzc1IGggNC41IiBzdHlsZT0ic3Ryb2tlOiMwMDA7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6bm9uZTtzdHJva2Utb3BhY2l0eToxIi8+PHBhdGggZD0ibSAyLjYyNSwxMS42MjUgaCA0LjUiIHN0eWxlPSJzdHJva2U6IzA2MDAwMDtzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDpub25lO3N0cm9rZS1vcGFjaXR5OjEiLz48cGF0aCBkPSJtIDcuMTI1LDAuMzc1IC0yLjI1LDExLjI1IiBzdHlsZT0ic3Ryb2tlOiMwMDA7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6bm9uZTtzdHJva2Utb3BhY2l0eTouOTkwMjQzOTEiLz48L2c+PC9zdmc+');\n    --icon-FontStrikethrough: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSAxLjgwNTY1ODUsMy4zOTcxNzIgViAxLjczNTc0MzEgSCAxNC4xOTQzNDIgViAzLjM5NzE3MiIgc3R5bGU9ImZpbGw6bm9uZTtmaWxsLXJ1bGU6ZXZlbm9kZDtzdHJva2U6IzkyOTI5OTtzdHJva2Utd2lkdGg6MS4xNDMxNzYyO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZCIvPjxwYXRoIGQ9Ik0gNy45OTk5OTk5LDEwLjA0Mjg4NyBWIDE0LjE5NjQ2IiBzdHlsZT0ic3Ryb2tlOiM5MjkyOTk7c3Ryb2tlLXdpZHRoOjEuMTQzMTc2MjtzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQiLz48cGF0aCBkPSJNIDcuOTk5OTk5OSwxLjczNTc0MzEgViA3LjU1MDc0NCIgc3R5bGU9ImZpbGw6bm9uZTtmaWxsLXJ1bGU6ZXZlbm9kZDtzdHJva2U6IzkyOTI5OTtzdHJva2Utd2lkdGg6MS4xNDMxNzYyO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2Utb3BhY2l0eToxIi8+PHBhdGggZD0iTTUuMzQ1MjgyMiAxNC4xOTY0NkgxMC42NTQ3MThNMS44MDU2NTg1IDcuNTUwNzQ0SDE0LjE5NDM0MiIgc3R5bGU9InN0cm9rZTojOTI5Mjk5O3N0cm9rZS13aWR0aDoxLjE0MzE3NjI7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kIi8+PC9zdmc+');\n    --icon-FontUnderline: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBkPSJNMCAwSDEyVjEySDB6IiBzdHlsZT0iZmlsbDojZmZmIi8+PC9jbGlwUGF0aD48L2RlZnM+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIiBzdHlsZT0iZmlsbDpub25lO2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpldmVub2RkO3N0cm9rZTojMDAwO3N0cm9rZS1vcGFjaXR5OjEiIHRyYW5zZm9ybT0ibWF0cml4KDEuMTQyOTUgMCAwIDEuMTQyOTUgMS4xNDIgLjcyMSkiPjxwYXRoIGQ9Ik0uMzc1IDExLjYyNWgxMS4yNU05LjM3NSAxLjEyNVY2QzkuMzc1IDYuODk1MTEgOS4wMTk0MiA3Ljc1MzU1IDguMzg2NDkgOC4zODY0OSA3Ljc1MzU1IDkuMDE5NDIgNi44OTUxMSA5LjM3NSA2IDkuMzc1djBDNS4xMDQ4OSA5LjM3NSA0LjI0NjQ1IDkuMDE5NDIgMy42MTM1MSA4LjM4NjQ5IDIuOTgwNTggNy43NTM1NSAyLjYyNSA2Ljg5NTExIDIuNjI1IDZWMS4xMjVNMS4xMjUgMS4xMjVoM003Ljg3NSAxLjEyNWgzIiBzdHlsZT0iZmlsbDpub25lO2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpldmVub2RkO3N0cm9rZTojMDAwO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utb3BhY2l0eToxIi8+PC9nPjwvc3ZnPg==');\n    --icon-FormConfig: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNTAiIGhlaWdodD0iMTQwIiBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjNDk0OTQ5IiBkPSJNMTUuNTg4MyAxNDAgMTUgMTM5Ljk4OUMxNS4yMjc1IDEzMy41MDggMTUuOTI1NSAxMjcuMDUyIDE3LjA4ODUgMTIwLjY3IDE5LjYzMjYgMTA3LjAwNyAyMy44NjMgOTguMDM0IDI5LjY2MjIgOTRMMzAgOTQuNDc3QzE2LjQ1MyAxMDMuOSAxNS41OTUyIDEzOS42NCAxNS41ODgzIDE0MFpNMjIuNjA4OSAxMzkgMjIgMTM4Ljk4OUMyMi4wMTMxIDEzOC4zNDYgMjIuMzkzNyAxMjMuMTg5IDI4LjY1MDMgMTE5TDI5IDExOS40NzVDMjIuOTk1OCAxMjMuNDk1IDIyLjYxMTkgMTM4Ljg0NSAyMi42MDg5IDEzOVoiLz48cGF0aCBmaWxsPSIjMTZCMzc4IiBkPSJNMzMgOTRDMzQuNjU2OSA5NCAzNiA5Mi42NTY4IDM2IDkxIDM2IDg5LjM0MzEgMzQuNjU2OSA4OCAzMyA4OCAzMS4zNDMxIDg4IDMwIDg5LjM0MzEgMzAgOTEgMzAgOTIuNjU2OCAzMS4zNDMxIDk0IDMzIDk0Wk0zMSAxMTlDMzIuNjU2OSAxMTkgMzQgMTE3LjY1NyAzNCAxMTYgMzQgMTE0LjM0MyAzMi42NTY5IDExMyAzMSAxMTMgMjkuMzQzMSAxMTMgMjggMTE0LjM0MyAyOCAxMTYgMjggMTE3LjY1NyAyOS4zNDMxIDExOSAzMSAxMTlaIi8+PHBhdGggZmlsbD0iIzQ5NDk0OSIgZD0iTTIwLjg3NDYgOTYuMTk5M0MyMS40MjMzIDk5Ljc4OTIgMTkuOTkzNCAxMDMgMTkuOTkzNCAxMDMgMTkuOTkzNCAxMDMgMTcuNjc0IDEwMC4zOTEgMTcuMTI1NCA5Ni44MDA3IDE2LjU3NjcgOTMuMjEwOCAxOC4wMDY2IDkwIDE4LjAwNjYgOTAgMTguMDA2NiA5MCAyMC4zMjYgOTIuNjA5NSAyMC44NzQ2IDk2LjE5OTNaTTMwLjYwODEgMTA0LjM3NUMyNy4yOTQzIDEwNS41NzYgMjQgMTA0LjY3NiAyNCAxMDQuNjc2IDI0IDEwNC42NzYgMjYuMDc4MiAxMDEuODI3IDI5LjM5MTkgMTAwLjYyNSAzMi43MDU2IDk5LjQyMzUgMzYgMTAwLjMyNCAzNiAxMDAuMzI0IDM2IDEwMC4zMjQgMzMuOTIxOCAxMDMuMTczIDMwLjYwODEgMTA0LjM3NVpNMzAuNDA1NCAxMjYuNjI5QzI4Ljk3ODkgMTI3LjA1MyAyNy40NTk4IDEyNy4xMTUgMjYgMTI2LjgwOSAyNi45MjY5IDEyNS42OTUgMjguMTc1MiAxMjQuODU3IDI5LjU5NDYgMTI0LjM5OCAzMS4wMTQgMTIzLjkzOCAzMi41NDM5IDEyMy44NzUgMzQgMTI0LjIxOCAzMy4wNDYxIDEyNS4zMDUgMzEuODA2NSAxMjYuMTM3IDMwLjQwNTQgMTI2LjYyOVoiLz48cGF0aCBmaWxsPSIjRkZGM0RFIiBkPSJNMTA3Ljk5OSAxMzZIMTAzLjkzNEwxMDIgMTIxTDEwOCAxMjFMMTA3Ljk5OSAxMzZaIi8+PHBhdGggZmlsbD0iIzQ5NDk0OSIgZD0iTTEwOCAxNDBMOTYgMTQwVjEzOS44NDJDOTYuMDAwMSAxMzguNTU4IDk2LjQ5MjIgMTM3LjMyNiA5Ny4zNjgxIDEzNi40MThDOTguMjQ0IDEzNS41MSA5OS40MzIgMTM1IDEwMC42NzEgMTM1TDEwOCAxMzVMMTA4IDE0MFoiLz48cGF0aCBmaWxsPSIjRkZGM0RFIiBkPSJNODQuOTk5NCAxMzZIODAuOTMzOUw3OSAxMjFMODUgMTIxTDg0Ljk5OTQgMTM2WiIvPjxwYXRoIGZpbGw9IiM0OTQ5NDkiIGQ9Ik04NC45OTk4IDE0MEw3MyAxNDBWMTM5Ljg0MkM3My4wMDAxIDEzOC41NTggNzMuNDkyMiAxMzcuMzI2IDc0LjM2ODEgMTM2LjQxOEM3NS4yNDQgMTM1LjUxIDc2LjQzMiAxMzUgNzcuNjcwNyAxMzVINzcuNjcxTDg1IDEzNUw4NC45OTk4IDE0MFoiLz48cGF0aCBmaWxsPSIjRkZGM0RFIiBkPSJNNzQgNTMuODQ0OCA2OC43NjA3IDUzIDY3LjA2OCA1NS44NDMxIDQ4Ljg1MjggNjAuNjg4MyA0OC45MDIxIDYwLjk1MDNDNDguMzg2OSA2MC4zODkgNDcuNzEwNiA2MC4wMTgyIDQ2Ljk3NTUgNTkuODkzOCA0Ni4yNDA0IDU5Ljc2OTQgNDUuNDg2NSA1OS44OTgzIDQ0LjgyNzggNjAuMjYxIDQ0LjE2OTEgNjAuNjIzNiA0My42NDE2IDYxLjIwMDMgNDMuMzI1IDYxLjkwMzcgNDMuMDA4NSA2Mi42MDcyIDQyLjkyMDEgNjMuMzk5IDQzLjA3MzMgNjQuMTU5NSA0My4yMjY1IDY0LjkyMDEgNDMuNjEzIDY1LjYwNzggNDQuMTc0MSA2Ni4xMTg3IDQ0LjczNTMgNjYuNjI5NSA0NS40NDA3IDY2LjkzNTcgNDYuMTgzNiA2Ni45OTEgNDYuOTI2NCA2Ny4wNDYyIDQ3LjY2NjMgNjYuODQ3NCA0OC4yOTEyIDY2LjQyNDcgNDguOTE2MSA2Ni4wMDE5IDQ5LjM5MjEgNjUuMzc4MyA0OS42NDcgNjQuNjQ4MUw3MS43MDYyIDU5Ljg0NzMgNzQgNTMuODQ0OFpNMTI3LjU2MSA1MC4wNjE3QzEyNy4xMjkgNTAuMDYyMSAxMjYuNyA1MC4xNDUzIDEyNi4yOTggNTAuMzA2OUwxMjYuNDAzIDUwLjEyMjIgMTAzLjE2OCAzOCAxMDAgNDMuMTY4OSAxMjQuMTU3IDUzLjk5MjRDMTI0LjI0NCA1NC42NTI5IDEyNC41MTkgNTUuMjc0IDEyNC45NDcgNTUuNzgxMiAxMjUuMzc2IDU2LjI4ODUgMTI1Ljk0IDU2LjY2MDQgMTI2LjU3MiA1Ni44NTI2IDEyNy4yMDQgNTcuMDQ0OCAxMjcuODc4IDU3LjA0OTEgMTI4LjUxMyA1Ni44NjUgMTI5LjE0NyA1Ni42ODA4IDEyOS43MTYgNTYuMzE2MSAxMzAuMTUgNTUuODE0MyAxMzAuNTg1IDU1LjMxMjYgMTMwLjg2NyA1NC42OTUxIDEzMC45NjMgNTQuMDM1NyAxMzEuMDYgNTMuMzc2MyAxMzAuOTY1IDUyLjcwMjkgMTMwLjY5MiA1Mi4wOTYgMTMwLjQxOSA1MS40ODkyIDEyOS45NzkgNTAuOTc0NiAxMjkuNDIzIDUwLjYxMzkgMTI4Ljg2OCA1MC4yNTMxIDEyOC4yMjIgNTAuMDYxNCAxMjcuNTYxIDUwLjA2MTdaIi8+PHBhdGggZmlsbD0iIzQ5NDk0OSIgZD0iTTkxLjQwMzUgMTguMDA5NUM5Mi4zMjk0IDEyLjI4NDkgODguNDQwMSA2Ljg4ODY5IDgyLjcxNjUgNS45NTY3OEM3Ni45OTI5IDUuMDI0ODYgNzEuNjAyNCA4LjkxMDExIDcwLjY3NjUgMTQuNjM0N0M2OS43NTA2IDIwLjM1OTMgNzMuNjM5OSAyNS43NTU1IDc5LjM2MzUgMjYuNjg3NEM4NS4wODcyIDI3LjYxOTMgOTAuNDc3NiAyMy43MzQgOTEuNDAzNSAxOC4wMDk1WiIvPjxwYXRoIGZpbGw9IiNGRkYzREUiIGQ9Ik04MC41IDI3Qzg0LjA4OTkgMjcgODcgMjQuMDg5OSA4NyAyMC41Qzg3IDE2LjkxMDEgODQuMDg5OSAxNCA4MC41IDE0Qzc2LjkxMDIgMTQgNzQgMTYuOTEwMSA3NCAyMC41Qzc0IDI0LjA4OTkgNzYuOTEwMiAyNyA4MC41IDI3WiIvPjxwYXRoIGZpbGw9IiM0OTQ5NDkiIGQ9Ik04MCAxOUM4My44NjYgMTkgODcgMTYuOTg1MyA4NyAxNC41Qzg3IDEyLjAxNDcgODMuODY2IDEwIDgwIDEwQzc2LjEzNCAxMCA3MyAxMi4wMTQ3IDczIDE0LjVDNzMgMTYuOTg1MyA3Ni4xMzQgMTkgODAgMTlaIi8+PHBhdGggZmlsbD0iIzQ5NDk0OSIgZD0iTTgxLjUgMTFDODMuOTg1MyAxMSA4NiA4Ljk4NTI4IDg2IDYuNUM4NiA0LjAxNDcyIDgzLjk4NTMgMiA4MS41IDJDNzkuMDE0NyAyIDc3IDQuMDE0NzIgNzcgNi41Qzc3IDguOTg1MjggNzkuMDE0NyAxMSA4MS41IDExWiIvPjxwYXRoIGZpbGw9IiM0OTQ5NDkiIGQ9Ik03NS45MzkxIDQuNDk5OThDNzUuOTM5MiAzLjM4NzY3IDc2LjM1NDIgMi4zMTQ4IDc3LjEwNDEgMS40ODgzMSA3Ny44NTQuNjYxODMgNzguODg1Ni4xNDAzMDEgODAgLjAyNDMzMiA3OS44NDMxLjAwODE2MjMgNzkuNjg1NS4wMDAwNDAxMTU3IDc5LjUyNzggMCA3OC4zMjY1LjAwMTM5MDc4IDc3LjE3NDguNDc2MTA4IDc2LjMyNTkgMS4zMTk4NyA3NS40NzY5IDIuMTYzNjMgNzUgMy4zMDc0MyA3NSA0LjUgNzUgNS42OTI1NyA3NS40NzY5IDYuODM2MzYgNzYuMzI1OSA3LjY4MDEzIDc3LjE3NDggOC41MjM4OSA3OC4zMjY1IDguOTk4NjEgNzkuNTI3OCA5IDc5LjY4NTUgOC45OTk5NiA3OS44NDMxIDguOTkxODQgODAgOC45NzU2NyA3OC44ODU2IDguODU5NyA3Ny44NTQgOC4zMzgxNyA3Ny4xMDQxIDcuNTExNjggNzYuMzU0MiA2LjY4NTE5IDc1LjkzOTIgNS42MTIzIDc1LjkzOTEgNC40OTk5OFpNNzYuNjk0OCAzNi45MzI2IDc3LjIyMTEgMzEuODI4NUM3Ny4yMjExIDMxLjgyODUgODQuMzYxOCAyNy4wODEzIDg2Ljc4ODcgMjkuODg3TDEwMS4yOTQgNTQuOTIyNkMxMDEuMjk0IDU0LjkyMjYgMTEwLjMxIDU4LjE1NjQgMTA5Ljk5MiA3MC41MDQ2TDEwOS41NjEgMTMwLjE5MiA5OS4zMzY0IDEzMS4zMjMgOTMuMTI3NyA4NC43ODcxIDg3LjUxODEgMTMzIDc1LjYxNDggMTMyLjYyNCA3Ni42ODU3IDEwMC44NjIgODIuMzYxNiA3MC4wNzI0IDgyLjMwNzMgNTkuODU1NyA3OS44MDc1IDU1LjczMjVDNzkuODA3NSA1NS43MzI1IDc1LjIyODMgNTMuODc2OCA3NS4xMDAzIDQ4LjYzMjRMNzUgNDEuMjYwNiA3Ni42OTQ4IDM2LjkzMjZaIi8+PHBhdGggZmlsbD0iIzQ5NDk0OSIgZD0iTTg0IDMxLjM1MzYgODQuMTQgMjlDODQuMTQgMjkgMTA1LjYwNyAzNC42NDggMTAzLjkwNCAzOC42ODY0IDEwMi4yMDEgNDIuNzI0NyA5OS4wMDc3IDQ0IDk5LjAwNzcgNDRMODYuNjYwOSAzOS4xMTE1IDg0IDMxLjM1MzZaTTc4LjEyOTQgMzYuNzc2MSA3Ni41NDM3IDM1Qzc2LjU0MzcgMzUgNjMuODk1OCA1My42NDEgNjcuNzIwNSA1NS43MjM4IDcxLjU0NTIgNTcuODA2NiA3NC43NTY5IDU2LjcxMzEgNzQuNzU2OSA1Ni43MTMxTDgxIDQ0LjY4OTcgNzguMTI5NCAzNi43NzYxWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01Mi44NjQyIDk3TDEzNSA4MC41MzQxTDEyOC4xMzYgNDZMNDYgNjIuNDY1OUw1Mi44NjQyIDk3WiIvPjxwYXRoIGZpbGw9IiNEOUQ5RDkiIGQ9Ik0xMzUgODEuNDIxMUw1Mi4wNjA5IDk4TDQ1IDYyLjU3ODlMMTI3LjkzOSA0NkwxMzUgODEuNDIxMVpNNTIuOTU0NCA5Ni42NTg5TDEzMy42NjEgODAuNTI2M0wxMjcuMDQ2IDQ3LjM0MTFMNDYuMzM5MiA2My40NzM3TDUyLjk1NDQgOTYuNjU4OVoiLz48cGF0aCBmaWxsPSIjRDlEOUQ5IiBkPSJNMTIyLjY3NiA1Ny42NDYgNTYgNzEgNTYuMzkwMyA3Mi45NjE2IDEyMy4wNjYgNTkuNjA3NiAxMjIuNjc2IDU3LjY0NlpNMTIzLjY3NiA2My42NDYgNTcgNzcgNTcuMzkwMyA3OC45NjE2IDEyNC4wNjYgNjUuNjA3NiAxMjMuNjc2IDYzLjY0NlpNMTI1LjY3NiA3MC42NDYgNTkgODQgNTkuMzkwMyA4NS45NjE2IDEyNi4wNjYgNzIuNjA3NiAxMjUuNjc2IDcwLjY0NloiLz48cGF0aCBmaWxsPSIjMTZCMzc4IiBkPSJNOTAuNDQ0OSA2Ny41NDE1IDg3Ljk4NzMgNjcuOTg4Qzg3LjgwNzggNjguMDIwNCA4Ny42MjE1IDY3Ljk4NjcgODcuNDY5MiA2Ny44OTQyIDg3LjMxNjkgNjcuODAxOCA4Ny4yMTEyIDY3LjY1ODEgODcuMTc1MSA2Ny40OTQ4TDg2LjAxMzIgNjIuMTk3NkM4NS45Nzc2IDYyLjAzNDIgODYuMDE0NiA2MS44NjQ2IDg2LjExNjIgNjEuNzI2MSA4Ni4yMTc4IDYxLjU4NzUgODYuMzc1NiA2MS40OTEzIDg2LjU1NTEgNjEuNDU4NUw4OS4wMTI3IDYxLjAxMkM4OS4xOTIyIDYwLjk3OTYgODkuMzc4NiA2MS4wMTMzIDg5LjUzMDggNjEuMTA1OCA4OS42ODMxIDYxLjE5ODIgODkuNzg4OCA2MS4zNDE5IDg5LjgyNDkgNjEuNTA1Mkw5MC45ODY4IDY2LjgwMjRDOTEuMDIyNCA2Ni45NjU4IDkwLjk4NTQgNjcuMTM1NCA5MC44ODM4IDY3LjI3MzkgOTAuNzgyMiA2Ny40MTI1IDkwLjYyNDQgNjcuNTA4NyA5MC40NDQ5IDY3LjU0MTVaTTExNy40NDUgNjkuNTQxNSAxMTQuOTg3IDY5Ljk4OEMxMTQuODA4IDcwLjAyMDQgMTE0LjYyMSA2OS45ODY3IDExNC40NjkgNjkuODk0MiAxMTQuMzE3IDY5LjgwMTggMTE0LjIxMSA2OS42NTgxIDExNC4xNzUgNjkuNDk0OEwxMTMuMDEzIDY0LjE5NzZDMTEyLjk3OCA2NC4wMzQyIDExMy4wMTUgNjMuODY0NiAxMTMuMTE2IDYzLjcyNjEgMTEzLjIxOCA2My41ODc1IDExMy4zNzYgNjMuNDkxMyAxMTMuNTU1IDYzLjQ1ODVMMTE2LjAxMyA2My4wMTJDMTE2LjE5MiA2Mi45Nzk2IDExNi4zNzkgNjMuMDEzMyAxMTYuNTMxIDYzLjEwNTggMTE2LjY4MyA2My4xOTgyIDExNi43ODkgNjMuMzQxOSAxMTYuODI1IDYzLjUwNTJMMTE3Ljk4NyA2OC44MDI0QzExOC4wMjIgNjguOTY1OCAxMTcuOTg1IDY5LjEzNTQgMTE3Ljg4NCA2OS4yNzM5IDExNy43ODIgNjkuNDEyNSAxMTcuNjI0IDY5LjUwODcgMTE3LjQ0NSA2OS41NDE1Wk0xMDIuNDQ1IDc5LjU0MTUgOTkuOTg3MyA3OS45ODhDOTkuODA3OCA4MC4wMjA0IDk5LjYyMTUgNzkuOTg2NyA5OS40NjkyIDc5Ljg5NDIgOTkuMzE2OSA3OS44MDE4IDk5LjIxMTIgNzkuNjU4MSA5OS4xNzUyIDc5LjQ5NDhMOTguMDEzMiA3NC4xOTc2Qzk3Ljk3NzYgNzQuMDM0MiA5OC4wMTQ2IDczLjg2NDYgOTguMTE2MiA3My43MjYxIDk4LjIxNzggNzMuNTg3NSA5OC4zNzU2IDczLjQ5MTMgOTguNTU1MSA3My40NTg1TDEwMS4wMTMgNzMuMDEyQzEwMS4xOTIgNzIuOTc5NiAxMDEuMzc5IDczLjAxMzMgMTAxLjUzMSA3My4xMDU4IDEwMS42ODMgNzMuMTk4MiAxMDEuNzg5IDczLjM0MTkgMTAxLjgyNSA3My41MDUyTDEwMi45ODcgNzguODAyNEMxMDMuMDIyIDc4Ljk2NTggMTAyLjk4NSA3OS4xMzU0IDEwMi44ODQgNzkuMjczOSAxMDIuNzgyIDc5LjQxMjUgMTAyLjYyNCA3OS41MDg3IDEwMi40NDUgNzkuNTQxNVoiLz48cGF0aCBmaWxsPSIjQ0FDQUNBIiBkPSJNMTMwLjgyNiAxNDBIMC4xNzM5NzFDMC4xMjc4MzEgMTQwIDAuMDgzNTgwNiAxMzkuOTQ3IDAuMDUwOTU0NyAxMzkuODU0QzAuMDE4MzI4OSAxMzkuNzYgMCAxMzkuNjMzIDAgMTM5LjVDMCAxMzkuMzY3IDAuMDE4MzI4OSAxMzkuMjQgMC4wNTA5NTQ3IDEzOS4xNDZDMC4wODM1ODA2IDEzOS4wNTMgMC4xMjc4MzEgMTM5IDAuMTczOTcxIDEzOUgxMzAuODI2QzEzMC44NzIgMTM5IDEzMC45MTYgMTM5LjA1MyAxMzAuOTQ5IDEzOS4xNDZDMTMwLjk4MiAxMzkuMjQgMTMxIDEzOS4zNjcgMTMxIDEzOS41QzEzMSAxMzkuNjMzIDEzMC45ODIgMTM5Ljc2IDEzMC45NDkgMTM5Ljg1NEMxMzAuOTE2IDEzOS45NDcgMTMwLjg3MiAxNDAgMTMwLjgyNiAxNDBaIi8+PC9zdmc+');\n    --icon-FunctionResult: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNCwyIEwxMiwyIEMxMy4xMDQ1Njk1LDIgMTQsMi44OTU0MzA1IDE0LDQgTDE0LDEyIEMxNCwxMy4xMDQ1Njk1IDEzLjEwNDU2OTUsMTQgMTIsMTQgTDQsMTQgQzIuODk1NDMwNSwxNCAyLDEzLjEwNDU2OTUgMiwxMiBMMiw0IEMyLDIuODk1NDMwNSAyLjg5NTQzMDUsMiA0LDIgWiBNNC41LDYgQzQuMjIzODU3NjMsNiA0LDYuMjIzODU3NjMgNCw2LjUgQzQsNi43NzYxNDIzNyA0LjIyMzg1NzYzLDcgNC41LDcgTDExLjUsNyBDMTEuNzc2MTQyNCw3IDEyLDYuNzc2MTQyMzcgMTIsNi41IEMxMiw2LjIyMzg1NzYzIDExLjc3NjE0MjQsNiAxMS41LDYgTDQuNSw2IFogTTQuNSw5IEM0LjIyMzg1NzYzLDkgNCw5LjIyMzg1NzYzIDQsOS41IEM0LDkuNzc2MTQyMzcgNC4yMjM4NTc2MywxMCA0LjUsMTAgTDExLjUsMTAgQzExLjc3NjE0MjQsMTAgMTIsOS43NzYxNDIzNyAxMiw5LjUgQzEyLDkuMjIzODU3NjMgMTEuNzc2MTQyNCw5IDExLjUsOSBMNC41LDkgWiIvPjwvc3ZnPg==');\n    --icon-GreenArrow: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5NCIgaGVpZ2h0PSIxMiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzE2QjM3OCIgZD0iTTk0IDZMODQgMC4yMjY0OTdWMTEuNzczNUw5NCA2Wk0wIDdINC43VjVIMFY3Wk0xNC4xIDdIMjMuNVY1SDE0LjFWN1pNMzIuOSA3SDQyLjNWNUgzMi45VjdaTTUxLjcgN0g2MS4xVjVINTEuN1Y3Wk03MC41IDdINzkuOVY1SDcwLjVWN1oiLz48L3N2Zz4=');\n    --icon-Grow: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTEuNSAxLjUgNi41IDYuNU05LjUgOS41IDE0LjUgMTQuNU03LjUgMS41SDEuNVY3LjVNMTQuNSA4LjVWMTQuNUg4LjUiLz48L3N2Zz4=');\n    --icon-Headband: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTQgMTZMOCAxMUwyIDE2VjBIMTRWMTZaIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iYSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');\n    --icon-Heart: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNyIgaGVpZ2h0PSIxNyIgZmlsbD0ibm9uZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBmaWxsPSIjMTZCMzc4IiBkPSJNNy42ODU2NCAxNS45OTlDNi44ODU2NCAxNS4yOTkgMC4yODU2NDUgOS43OTkwMSAwLjI4NTY0NSA1LjU5OTAxQzAuMjg1NjQ1IDIuODk5MDEgMi40ODU2NCAwLjc5OTAxMSA1LjA4NTY0IDAuNzk5MDExQzYuMjg1NjQgMC43OTkwMTEgNy4zODU2NCAxLjI5OTAxIDguMjg1NjQgMS45OTkwMUM5LjE4NTY0IDEuMTk5MDEgMTAuMjg1NiAwLjc5OTAxMSAxMS40ODU2IDAuNzk5MDExQzE0LjE4NTYgMC43OTkwMTEgMTYuMjg1NiAyLjk5OTAxIDE2LjI4NTYgNS41OTkwMUMxNi4yODU2IDkuNzk5MDEgOS42ODU2NSAxNS4yOTkgOC44ODU2NCAxNS44OTlDOC41ODU2NCAxNi4yOTkgNy45ODU2NCAxNi4yOTkgNy42ODU2NCAxNS45OTlaTTUuMDg1NjQgMi43OTkwMUMzLjU4NTY0IDIuNzk5MDEgMi4yODU2NCA0LjA5OTAxIDIuMjg1NjQgNS41OTkwMUMyLjI4NTY0IDcuNzk5MDEgNS43ODU2NCAxMS41OTkgOC4yODU2NCAxMy43OTlDMTAuMzg1NiAxMS44OTkgMTQuMjg1NiA3Ljk5OTAxIDE0LjI4NTYgNS41OTkwMUMxNC4yODU2IDMuOTk5MDEgMTIuOTg1NiAyLjc5OTAxIDExLjQ4NTYgMi43OTkwMUMxMC41ODU2IDIuNzk5MDEgOS42ODU2NCAzLjI5OTAxIDkuMTg1NjQgNC4wOTkwMUM4Ljc4NTY0IDQuNjk5MDEgNy44ODU2NCA0LjY5OTAxIDcuNDg1NjQgNC4wOTkwMUM2Ljg4NTY0IDMuMjk5MDEgNi4wODU2NCAyLjc5OTAxIDUuMDg1NjQgMi43OTkwMVoiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSguMjg2IC40OTkpIi8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+');\n    --icon-Help: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMi43MDkyNjQ0MywzLjQxNjM3MTIxIEMxLjY0NDQwNzY5LDQuNjQ0NDMyMjIgMSw2LjI0Njk2MTM3IDEsOCBDMSw5Ljc1MzAzODYzIDEuNjQ0NDA3NjksMTEuMzU1NTY3OCAyLjcwOTI2NDQzLDEyLjU4MzYyODggTDQuODQwMDcxMjksMTAuNDUyODIxOSBDNC4zMTM1NDA4LDkuNzc1NDgwMDcgNCw4LjkyNDM1NTczIDQsOCBDNCw3LjA3NTY0NDI3IDQuMzEzNTQwOCw2LjIyNDUxOTkzIDQuODQwMDcxMjksNS41NDcxNzgwNyBMMi43MDkyNjQ0MywzLjQxNjM3MTIxIFogTTMuNDE2MzcxMjEsMi43MDkyNjQ0MyBMNS41NDcxNzgwNyw0Ljg0MDA3MTI5IEM2LjIyNDUxOTkzLDQuMzEzNTQwOCA3LjA3NTY0NDI3LDQgOCw0IEM4LjkyNDM1NTczLDQgOS43NzU0ODAwNyw0LjMxMzU0MDggMTAuNDUyODIxOSw0Ljg0MDA3MTI5IEwxMi41ODM2Mjg4LDIuNzA5MjY0NDMgQzExLjM1NTU2NzgsMS42NDQ0MDc2OSA5Ljc1MzAzODYzLDEgOCwxIEM2LjI0Njk2MTM3LDEgNC42NDQ0MzIyMiwxLjY0NDQwNzY5IDMuNDE2MzcxMjEsMi43MDkyNjQ0MyBaIE01LjkwMTk4NzMxLDUuODU1NjIzNzYgQzUuODk0ODIwMDEsNS44NjM3ODMwNyA1Ljg4NzM0MjA0LDUuODcxNzY0NzQgNS44Nzk1NTMzOSw1Ljg3OTU1MzM5IEM1Ljg3MTc2NDc0LDUuODg3MzQyMDQgNS44NjM3ODMwNyw1Ljg5NDgyMDAxIDUuODU1NjIzNzYsNS45MDE5ODczMSBDNS4zMjYyOTU1LDYuNDQyOTM5MzIgNSw3LjE4MzM2NDQ2IDUsOCBDNSw4LjgxNjYzNTU0IDUuMzI2Mjk1NSw5LjU1NzA2MDY4IDUuODU1NjIzNzYsMTAuMDk4MDEyNyBDNS44NjM3ODMwNywxMC4xMDUxOCA1Ljg3MTc2NDc0LDEwLjExMjY1OCA1Ljg3OTU1MzM5LDEwLjEyMDQ0NjYgQzUuODg3MzQyMDQsMTAuMTI4MjM1MyA1Ljg5NDgyMDAxLDEwLjEzNjIxNjkgNS45MDE5ODczMSwxMC4xNDQzNzYyIEM2LjQ0MjkzOTMyLDEwLjY3MzcwNDUgNy4xODMzNjQ0NiwxMSA4LDExIEM4LjgxNjYzNTU0LDExIDkuNTU3MDYwNjgsMTAuNjczNzA0NSAxMC4wOTgwMTI3LDEwLjE0NDM3NjIgQzEwLjEwNTE4LDEwLjEzNjIxNjkgMTAuMTEyNjU4LDEwLjEyODIzNTMgMTAuMTIwNDQ2NiwxMC4xMjA0NDY2IEMxMC4xMjgyMzUzLDEwLjExMjY1OCAxMC4xMzYyMTY5LDEwLjEwNTE4IDEwLjE0NDM3NjIsMTAuMDk4MDEyNyBDMTAuNjczNzA0NSw5LjU1NzA2MDY4IDExLDguODE2NjM1NTQgMTEsOCBDMTEsNy4xODMzNjQ0NiAxMC42NzM3MDQ1LDYuNDQyOTM5MzIgMTAuMTQ0Mzc2Miw1LjkwMTk4NzMxIEMxMC4xMzYyMTY5LDUuODk0ODIwMDEgMTAuMTI4MjM1Myw1Ljg4NzM0MjA0IDEwLjEyMDQ0NjYsNS44Nzk1NTMzOSBDMTAuMTEyNjU4LDUuODcxNzY0NzQgMTAuMTA1MTgsNS44NjM3ODMwNyAxMC4wOTgwMTI3LDUuODU1NjIzNzYgQzkuNTU3MDYwNjgsNS4zMjYyOTU1IDguODE2NjM1NTQsNSA4LDUgQzcuMTgzMzY0NDYsNSA2LjQ0MjkzOTMyLDUuMzI2Mjk1NSA1LjkwMTk4NzMxLDUuODU1NjIzNzYgWiBNMy40MTYzNzEyMSwxMy4yOTA3MzU2IEM0LjY0NDQzMjIyLDE0LjM1NTU5MjMgNi4yNDY5NjEzNywxNSA4LDE1IEM5Ljc1MzAzODYzLDE1IDExLjM1NTU2NzgsMTQuMzU1NTkyMyAxMi41ODM2Mjg4LDEzLjI5MDczNTYgTDEwLjQ1MjgyMTksMTEuMTU5OTI4NyBDOS43NzU0ODAwNywxMS42ODY0NTkyIDguOTI0MzU1NzMsMTIgOCwxMiBDNy4wNzU2NDQyNywxMiA2LjIyNDUxOTkzLDExLjY4NjQ1OTIgNS41NDcxNzgwNywxMS4xNTk5Mjg3IEwzLjQxNjM3MTIxLDEzLjI5MDczNTYgWiBNMTMuMjkwNzM1NiwxMi41ODM2Mjg4IEMxNC4zNTU1OTIzLDExLjM1NTU2NzggMTUsOS43NTMwMzg2MyAxNSw4IEMxNSw2LjI0Njk2MTM3IDE0LjM1NTU5MjMsNC42NDQ0MzIyMiAxMy4yOTA3MzU2LDMuNDE2MzcxMjEgTDExLjE1OTkyODcsNS41NDcxNzgwNyBDMTEuNjg2NDU5Miw2LjIyNDUxOTkzIDEyLDcuMDc1NjQ0MjcgMTIsOCBDMTIsOC45MjQzNTU3MyAxMS42ODY0NTkyLDkuNzc1NDgwMDcgMTEuMTU5OTI4NywxMC40NTI4MjE5IEwxMy4yOTA3MzU2LDEyLjU4MzYyODggWiBNOCwxNiBDMy41ODE3MjIsMTYgMCwxMi40MTgyNzggMCw4IEMwLDMuNTgxNzIyIDMuNTgxNzIyLDAgOCwwIEMxMi40MTgyNzgsMCAxNiwzLjU4MTcyMiAxNiw4IEMxNiwxMi40MTgyNzggMTIuNDE4Mjc4LDE2IDgsMTYgWiIvPjwvc3ZnPg==');\n    --icon-Home: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOCwyLjA4MzA5NTE5IEwxLjQ3MTgyNTMyLDYgTDgsOS45MTY5MDQ4MSBMMTQuNTI4MTc0Nyw2IEw4LDIuMDgzMDk1MTkgWiBNOC4yNTcyNDc4OCwxLjA3MTI1MzU0IEwxNS43NTcyNDc5LDUuNTcxMjUzNTQgQzE2LjA4MDkxNzQsNS43NjU0NTUyMyAxNi4wODA5MTc0LDYuMjM0NTQ0NzcgMTUuNzU3MjQ3OSw2LjQyODc0NjQ2IEw4LjI1NzI0Nzg4LDEwLjkyODc0NjUgQzguMDk4OTA2NjgsMTEuMDIzNzUxMiA3LjkwMTA5MzMyLDExLjAyMzc1MTIgNy43NDI3NTIxMiwxMC45Mjg3NDY1IEwwLjI0Mjc1MjEyMiw2LjQyODc0NjQ2IEMtMC4wODA5MTczNzQxLDYuMjM0NTQ0NzcgLTAuMDgwOTE3Mzc0MSw1Ljc2NTQ1NTIzIDAuMjQyNzUyMTIyLDUuNTcxMjUzNTQgTDcuNzQyNzUyMTIsMS4wNzEyNTM1NCBDNy45MDEwOTMzMiwwLjk3NjI0ODgyMSA4LjA5ODkwNjY4LDAuOTc2MjQ4ODIxIDguMjU3MjQ3ODgsMS4wNzEyNTM1NCBaIE0xNC41MjgxNzQ3LDEwIEwxMy43NDI3NTIxLDkuNTI4NzQ2NDYgQzEzLjUwNTk2Miw5LjM4NjY3MjM4IDEzLjQyOTE3OTUsOS4wNzk1NDIyNiAxMy41NzEyNTM1LDguODQyNzUyMTIgQzEzLjcxMzMyNzYsOC42MDU5NjE5OSAxNC4wMjA0NTc3LDguNTI5MTc5NDYgMTQuMjU3MjQ3OSw4LjY3MTI1MzU0IEwxNS43NTcyNDc5LDkuNTcxMjUzNTQgQzE2LjA4MDkxNzQsOS43NjU0NTUyMyAxNi4wODA5MTc0LDEwLjIzNDU0NDggMTUuNzU3MjQ3OSwxMC40Mjg3NDY1IEw4LjI1NzI0Nzg4LDE0LjkyODc0NjUgQzguMDk4OTA2NjgsMTUuMDIzNzUxMiA3LjkwMTA5MzMyLDE1LjAyMzc1MTIgNy43NDI3NTIxMiwxNC45Mjg3NDY1IEwwLjI0Mjc1MjEyMiwxMC40Mjg3NDY1IEMtMC4wODA5MTczNzQxLDEwLjIzNDU0NDggLTAuMDgwOTE3Mzc0MSw5Ljc2NTQ1NTIzIDAuMjQyNzUyMTIyLDkuNTcxMjUzNTQgTDEuNzQyNzUyMTIsOC42NzEyNTM1NCBDMS45Nzk1NDIyNiw4LjUyOTE3OTQ2IDIuMjg2NjcyMzgsOC42MDU5NjE5OSAyLjQyODc0NjQ2LDguODQyNzUyMTIgQzIuNTcwODIwNTQsOS4wNzk1NDIyNiAyLjQ5NDAzODAxLDkuMzg2NjcyMzggMi4yNTcyNDc4OCw5LjUyODc0NjQ2IEwxLjQ3MTgyNTMyLDEwIEw4LDEzLjkxNjkwNDggTDE0LjUyODE3NDcsMTAgWiIvPjwvc3ZnPg==');\n    --icon-Idea: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNywwLjUgQzcsMC4yMjM4NTc2MjUgNy4yMjM4NTc2MywwIDcuNSwwIEM3Ljc3NjE0MjM3LDAgOCwwLjIyMzg1NzYyNSA4LDAuNSBMOCwyLjUgQzgsMi43NzYxNDIzNyA3Ljc3NjE0MjM3LDMgNy41LDMgQzcuMjIzODU3NjMsMyA3LDIuNzc2MTQyMzcgNywyLjUgTDcsMC41IFogTTEuNDg5NDQ2NjEsMy4xOTY1NTMzOSBDMS4yOTQxODQ0NiwzLjAwMTI5MTI0IDEuMjk0MTg0NDYsMi42ODQ3MDg3NiAxLjQ4OTQ0NjYxLDIuNDg5NDQ2NjEgQzEuNjg0NzA4NzYsMi4yOTQxODQ0NiAyLjAwMTI5MTI0LDIuMjk0MTg0NDYgMi4xOTY1NTMzOSwyLjQ4OTQ0NjYxIEwzLjYxMDU1MzM5LDMuOTAzNDQ2NjEgQzMuODA1ODE1NTQsNC4wOTg3MDg3NiAzLjgwNTgxNTU0LDQuNDE1MjkxMjQgMy42MTA1NTMzOSw0LjYxMDU1MzM5IEMzLjQxNTI5MTI0LDQuODA1ODE1NTQgMy4wOTg3MDg3Niw0LjgwNTgxNTU0IDIuOTAzNDQ2NjEsNC42MTA1NTMzOSBMMS40ODk0NDY2MSwzLjE5NjU1MzM5IFogTTEyLjgwMzQ0NjYsMi40ODk0NDY2MSBDMTIuOTk4NzA4OCwyLjI5NDE4NDQ2IDEzLjMxNTI5MTIsMi4yOTQxODQ0NiAxMy41MTA1NTM0LDIuNDg5NDQ2NjEgQzEzLjcwNTgxNTUsMi42ODQ3MDg3NiAxMy43MDU4MTU1LDMuMDAxMjkxMjQgMTMuNTEwNTUzNCwzLjE5NjU1MzM5IEwxMi4wOTY1NTM0LDQuNjEwNTUzMzkgQzExLjkwMTI5MTIsNC44MDU4MTU1NCAxMS41ODQ3MDg4LDQuODA1ODE1NTQgMTEuMzg5NDQ2Niw0LjYxMDU1MzM5IEMxMS4xOTQxODQ1LDQuNDE1MjkxMjQgMTEuMTk0MTg0NSw0LjA5ODcwODc2IDExLjM4OTQ0NjYsMy45MDM0NDY2MSBMMTIuODAzNDQ2NiwyLjQ4OTQ0NjYxIFogTTEwLDEyLjIyMzM2NjYgTDEwLDE0LjUgQzEwLDE0Ljc3NjE0MjQgOS43NzYxNDIzNywxNSA5LjUsMTUgTDUuNSwxNSBDNS4yMjM4NTc2MywxNSA1LDE0Ljc3NjE0MjQgNSwxNC41IEw1LDEyLjIyMjY3MzIgQzMuMjg0MzcxNDYsMTEuMDc3ODQwNCAyLjU1NjY5NzU2LDguODk1ODAxNTggMy4yNzM4OTQxMyw2LjkzNTA3NzA3IEM0LjAyNTAwOTMzLDQuODgxNjIzMzEgNi4xNDM0OSwzLjY2NTI2MjMyIDguMjk1NTY2OTIsNC4wNTE3OTk5NCBDMTAuNDQ3NDMxOSw0LjQzODI5OTQ5IDEyLjAxMDExMzYsNi4zMTU1MjQ3OCAxMS45OTk5OTczLDguNTAxNzM4MzYgQzExLjk5NjQ5ODYsMTAuMDA0NTU2OSAxMS4yNDA3OTE5LDExLjM5ODQ3MjcgMTAsMTIuMjIzMzY2NiBaIE05LDExLjk0NSBDOSwxMS43NjU5MDA4IDkuMDk1NzkzNTYsMTEuNjAwNDgwNSA5LjI1MTEzMTI4LDExLjUxMTMzNjEgQzEwLjMzMDU0NTYsMTAuODkxODg3NiAxMC45OTcyODI0LDkuNzQzNDMzNDYgMTEuMDAwMDAxMiw4LjQ5ODkwNzY5IEMxMS4wMDgxMTU3LDYuNzk3MDEyNCA5Ljc5MjYyMTk4LDUuMzM2NjkwMjQgOC4xMTg3ODQzOCw1LjAzNjA0OTg4IEM2LjQ0NDk0Njc4LDQuNzM1NDA5NTEgNC43OTcyMzk1OSw1LjY4MTQ2ODA1IDQuMjEzMDM4ODgsNy4yNzg1OTg3NSBDMy42Mjg4MzgxNiw4Ljg3NTcyOTQ1IDQuMjc3MjM0NTUsMTAuNjYxNjYgNS43NTAwMDU2OSwxMS41MTE5OTA2IEM1LjkwNDcwMzAyLDExLjYwMTMwNzggNiwxMS43NjYzNjk2IDYsMTEuOTQ1IEw2LDE0IEw5LDE0IEw5LDExLjk0NSBaIE0xMy41LDkgQzEzLjIyMzg1NzYsOSAxMyw4Ljc3NjE0MjM3IDEzLDguNSBDMTMsOC4yMjM4NTc2MyAxMy4yMjM4NTc2LDggMTMuNSw4IEwxNC41LDggQzE0Ljc3NjE0MjQsOCAxNSw4LjIyMzg1NzYzIDE1LDguNSBDMTUsOC43NzYxNDIzNyAxNC43NzYxNDI0LDkgMTQuNSw5IEwxMy41LDkgWiBNMC41LDkgQzAuMjIzODU3NjI1LDkgMCw4Ljc3NjE0MjM3IDAsOC41IEMwLDguMjIzODU3NjMgMC4yMjM4NTc2MjUsOCAwLjUsOCBMMS41LDggQzEuNzc2MTQyMzcsOCAyLDguMjIzODU3NjMgMiw4LjUgQzIsOC43NzYxNDIzNyAxLjc3NjE0MjM3LDkgMS41LDkgTDAuNSw5IFoiLz48L3N2Zz4=');\n    --icon-Import: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTEuNSw3IEMxMS45Mjk1Nzk0LDcgMTIuMTU5MTY4NCw3LjUwNTk2MDg2IDExLjg3NjI4ODMsNy44MjkyNTIzIEw4LjM3NjI4ODM1LDExLjgyOTI1MjMgQzguMTc3MDgyNywxMi4wNTY5MTU5IDcuODIyOTE3MywxMi4wNTY5MTU5IDcuNjIzNzExNjUsMTEuODI5MjUyMyBMNC4xMjM3MTE2NSw3LjgyOTI1MjMgQzMuODQwODMxNjQsNy41MDU5NjA4NiA0LjA3MDQyMDYsNyA0LjUsNyBMNiw3IEw2LDEuNSBDNiwxLjIyMzg1NzYzIDYuMjIzODU3NjMsMSA2LjUsMSBMOS41LDEgQzkuNzc2MTQyMzcsMSAxMCwxLjIyMzg1NzYzIDEwLDEuNSBMMTAsNyBMMTEuNSw3IFogTTgsMTAuNzQwNzAzOSBMMTAuMzk4MTE1OSw4IEw5LjUsOCBDOS4yMjM4NTc2Myw4IDksNy43NzYxNDIzNyA5LDcuNSBMOSwyIEw3LDIgTDcsNy41IEM3LDcuNzc2MTQyMzcgNi43NzYxNDIzNyw4IDYuNSw4IEw1LjYwMTg4NDExLDggTDgsMTAuNzQwNzAzOSBaIE0xMi41LDIgQzEyLjIyMzg1NzYsMiAxMiwxLjc3NjE0MjM3IDEyLDEuNSBDMTIsMS4yMjM4NTc2MyAxMi4yMjM4NTc2LDEgMTIuNSwxIEwxNC41LDEgQzE1LjMyODE0MjQsMSAxNiwxLjY3MTg1NzYzIDE2LDIuNSBMMTYsMTMuNSBDMTYsMTQuMzI4MTQyNCAxNS4zMjgxNDI0LDE1IDE0LjUsMTUgTDEuNSwxNSBDMC42NzE4NTc2MjUsMTUgMCwxNC4zMjgxNDI0IDAsMTMuNSBMMCwyLjUgQzAsMS42NzE4NTc2MyAwLjY3MTg1NzYyNSwxIDEuNSwxIEwzLjUsMSBDMy43NzYxNDIzNywxIDQsMS4yMjM4NTc2MyA0LDEuNSBDNCwxLjc3NjE0MjM3IDMuNzc2MTQyMzcsMiAzLjUsMiBMMS41LDIgQzEuMjI0MTQyMzcsMiAxLDIuMjI0MTQyMzcgMSwyLjUgTDEsMTMuNSBDMSwxMy43NzU4NTc2IDEuMjI0MTQyMzcsMTQgMS41LDE0IEwxNC41LDE0IEMxNC43NzU4NTc2LDE0IDE1LDEzLjc3NTg1NzYgMTUsMTMuNSBMMTUsMi41IEMxNSwyLjIyNDE0MjM3IDE0Ljc3NTg1NzYsMiAxNC41LDIgTDEyLjUsMiBaIi8+PC9zdmc+');\n    --icon-ImportArrow: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTciIGhlaWdodD0iMzEiPjxkZWZzPjxwYXRoIGlkPSJhIiBkPSJNMCAwSDE1VjMySDB6Ii8+PC9kZWZzPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgMSkiPjxtYXNrIGlkPSJiIiBmaWxsPSIjZmZmIj48dXNlIHhsaW5rOmhyZWY9IiNhIi8+PC9tYXNrPjxjaXJjbGUgY3g9IjE1IiBjeT0iMTYiIHI9IjEyIiBzdHJva2U9IiNEOUQ5RDkiIHN0cm9rZS13aWR0aD0iNCIgbWFzaz0idXJsKCNiKSIvPjwvZz48cGF0aCBmaWxsPSIjRDlEOUQ5IiBzdHJva2U9IiNEOUQ5RDkiIGQ9Ik0xNSAxIDE5IDcgMTEgN3oiIHRyYW5zZm9ybT0icm90YXRlKDkwIDE0IDQpIi8+PC9nPjwvc3ZnPg==');\n    --icon-Info: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzRiODA1NiIgZD0iTTgsMGE4LDgsMCwxLDAsOCw4QTguMDI0LDguMDI0LDAsMCwwLDgsMFptLjUsMTNoLTFhLjUuNSwwLDAsMS0uNS0uNXYtNUEuNS41LDAsMCwxLDcuNSw3aDFhLjUuNSwwLDAsMSwuNS41djVBLjUuNSwwLDAsMSw4LjUsMTNaTTgsNUEuOTQ1Ljk0NSwwLDAsMSw3LDQsLjk0NS45NDUsMCwwLDEsOCwzLC45NDUuOTQ1LDAsMCwxLDksNCwuOTQ1Ljk0NSwwLDAsMSw4LDVaIi8+PC9zdmc+');\n    --icon-Layers: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzkyOTI5OSIgZD0iTTkuNTk1MjkgMS43NzA4OEM5Ljg0Njk3IDEuNjMxMDYgMTAuMTUzIDEuNjMxMDYgMTAuNDA0NyAxLjc3MDg4TDE3LjkwNDcgNS45Mzc1NUMxOC4xNjkyIDYuMDg0NTMgMTguMzMzMyA2LjM2MzM4IDE4LjMzMzMgNi42NjYwMkMxOC4zMzMzIDYuOTY4NjYgMTguMTY5MiA3LjI0NzUxIDE3LjkwNDcgNy4zOTQ0OEwxMC40MDQ3IDExLjU2MTFDMTAuMTUzIDExLjcwMSA5Ljg0Njk3IDExLjcwMSA5LjU5NTI5IDExLjU2MTFMMi4wOTUyOSA3LjM5NDQ4QzEuODMwNzMgNy4yNDc1MSAxLjY2NjY2IDYuOTY4NjYgMS42NjY2NiA2LjY2NjAyQzEuNjY2NjYgNi4zNjMzOCAxLjgzMDczIDYuMDg0NTMgMi4wOTUyOSA1LjkzNzU1TDkuNTk1MjkgMS43NzA4OFpNNC4yMTU5MyA2LjY2NjAyTDkuOTk5OTkgOS44NzkzOEwxNS43ODQgNi42NjYwMkw5Ljk5OTk5IDMuNDUyNjVMNC4yMTU5MyA2LjY2NjAyWiIvPjxwYXRoIGZpbGw9IiM5MjkyOTkiIGQ9Ik0xLjc3MTUyIDkuNTk0NjVDMS45OTUwNCA5LjE5MjMzIDIuNTAyMzcgOS4wNDczNyAyLjkwNDY5IDkuMjcwODhMOS45OTk5OSAxMy4yMTI3TDE3LjA5NTMgOS4yNzA4OEMxNy40OTc2IDkuMDQ3MzcgMTguMDA0OSA5LjE5MjMzIDE4LjIyODUgOS41OTQ2NUMxOC40NTIgOS45OTY5NyAxOC4zMDcgMTAuNTA0MyAxNy45MDQ3IDEwLjcyNzhMMTAuNDA0NyAxNC44OTQ1QzEwLjE1MyAxNS4wMzQzIDkuODQ2OTcgMTUuMDM0MyA5LjU5NTI5IDE0Ljg5NDVMMi4wOTUyOSAxMC43Mjc4QzEuNjkyOTcgMTAuNTA0MyAxLjU0ODAxIDkuOTk2OTcgMS43NzE1MiA5LjU5NDY1WiIvPjxwYXRoIGZpbGw9IiM5MjkyOTkiIGQ9Ik0xLjc3MTUyIDEyLjkyOEMxLjk5NTA0IDEyLjUyNTcgMi41MDIzNyAxMi4zODA3IDIuOTA0NjkgMTIuNjA0Mkw5Ljk5OTk5IDE2LjU0NjFMMTcuMDk1MyAxMi42MDQyQzE3LjQ5NzYgMTIuMzgwNyAxOC4wMDQ5IDEyLjUyNTcgMTguMjI4NSAxMi45MjhDMTguNDUyIDEzLjMzMDMgMTguMzA3IDEzLjgzNzYgMTcuOTA0NyAxNC4wNjExTDEwLjQwNDcgMTguMjI3OEMxMC4xNTMgMTguMzY3NiA5Ljg0Njk3IDE4LjM2NzYgOS41OTUyOSAxOC4yMjc4TDIuMDk1MjkgMTQuMDYxMUMxLjY5Mjk3IDEzLjgzNzYgMS41NDgwMSAxMy4zMzAzIDEuNzcxNTIgMTIuOTI4WiIvPjwvc3ZnPg==');\n    --icon-LeftAlign: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMi41LDguNSBDMi4yMjM4NTc2Myw4LjUgMiw4LjI3NjE0MjM3IDIsOCBDMiw3LjcyMzg1NzYzIDIuMjIzODU3NjMsNy41IDIuNSw3LjUgTDEzLjUsNy41IEMxMy43NzYxNDI0LDcuNSAxNCw3LjcyMzg1NzYzIDE0LDggQzE0LDguMjc2MTQyMzcgMTMuNzc2MTQyNCw4LjUgMTMuNSw4LjUgTDIuNSw4LjUgWiBNMi41LDQgQzIuMjIzODU3NjMsNCAyLDMuNzc2MTQyMzcgMiwzLjUgQzIsMy4yMjM4NTc2MyAyLjIyMzg1NzYzLDMgMi41LDMgTDEzLjUsMyBDMTMuNzc2MTQyNCwzIDE0LDMuMjIzODU3NjMgMTQsMy41IEMxNCwzLjc3NjE0MjM3IDEzLjc3NjE0MjQsNCAxMy41LDQgTDIuNSw0IFogTTIuNSwxMyBDMi4yMjM4NTc2MywxMyAyLDEyLjc3NjE0MjQgMiwxMi41IEMyLDEyLjIyMzg1NzYgMi4yMjM4NTc2MywxMiAyLjUsMTIgTDcuNSwxMiBDNy43NzYxNDIzNywxMiA4LDEyLjIyMzg1NzYgOCwxMi41IEM4LDEyLjc3NjE0MjQgNy43NzYxNDIzNywxMyA3LjUsMTMgTDIuNSwxMyBaIi8+PC9zdmc+');\n    --icon-Lighting: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTQuOTk5OSA2LjAwMDAxSDguMzk5OTRMOS45OTk5NCAxLjMwMDAxQzEwLjI5OTkgMC4zMDAwMTEgOS4wOTk5NSAtMC40OTk5ODkgOC4yOTk5NCAwLjMwMDAxMUwwLjI5OTk0NSA4LjMwMDAxQy0wLjMwMDA1NSA4LjkwMDAxIDAuMDk5OTQ0NiAxMCAwLjk5OTk0NSAxMEg3LjU5OTk0TDUuOTk5OTQgMTQuN0M1LjY5OTk0IDE1LjcgNi44OTk5NCAxNi41IDcuNjk5OTQgMTUuN0wxNS42OTk5IDcuNzAwMDFDMTYuMjk5OSA3LjEwMDAxIDE1Ljg5OTkgNi4wMDAwMSAxNC45OTk5IDYuMDAwMDFaIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iYSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');\n    --icon-Lock: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNiAxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZaTTE0IDcgMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3Wk04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTNaIi8+PC9nPjwvc3ZnPg==');\n    --icon-Log: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNy41LDguNSBMNi41LDguNSBDNi4yMjM4NTc2Myw4LjUgNiw4LjI3NjE0MjM3IDYsOCBDNiw3LjcyMzg1NzYzIDYuMjIzODU3NjMsNy41IDYuNSw3LjUgTDcuNSw3LjUgTDcuNSwwLjUgQzcuNSwwLjIyMzg1NzYyNSA3LjcyMzg1NzYzLDAgOCwwIEM4LjI3NjE0MjM3LDAgOC41LDAuMjIzODU3NjI1IDguNSwwLjUgTDguNSwzIEw5LjUsMyBDOS43NzYxNDIzNywzIDEwLDMuMjIzODU3NjMgMTAsMy41IEMxMCwzLjc3NjE0MjM3IDkuNzc2MTQyMzcsNCA5LjUsNCBMOC41LDQgTDguNSwxMiBMOS41LDEyIEM5Ljc3NjE0MjM3LDEyIDEwLDEyLjIyMzg1NzYgMTAsMTIuNSBDMTAsMTIuNzc2MTQyNCA5Ljc3NjE0MjM3LDEzIDkuNSwxMyBMOC41LDEzIEw4LjUsMTUuNSBDOC41LDE1Ljc3NjE0MjQgOC4yNzYxNDIzNywxNiA4LDE2IEM3LjcyMzg1NzYzLDE2IDcuNSwxNS43NzYxNDI0IDcuNSwxNS41IEw3LjUsOC41IFogTTEyLDIgTDEyLDUgTDE1LDUgTDE1LDIgTDEyLDIgWiBNMTEuNSwxIEwxNS41LDEgQzE1Ljc3NjE0MjQsMSAxNiwxLjIyMzg1NzYzIDE2LDEuNSBMMTYsNS41IEMxNiw1Ljc3NjE0MjM3IDE1Ljc3NjE0MjQsNiAxNS41LDYgTDExLjUsNiBDMTEuMjIzODU3Niw2IDExLDUuNzc2MTQyMzcgMTEsNS41IEwxMSwxLjUgQzExLDEuMjIzODU3NjMgMTEuMjIzODU3NiwxIDExLjUsMSBaIE0xMiwxMSBMMTIsMTQgTDE1LDE0IEwxNSwxMSBMMTIsMTEgWiBNMTEuNSwxMCBMMTUuNSwxMCBDMTUuNzc2MTQyNCwxMCAxNiwxMC4yMjM4NTc2IDE2LDEwLjUgTDE2LDE0LjUgQzE2LDE0Ljc3NjE0MjQgMTUuNzc2MTQyNCwxNSAxNS41LDE1IEwxMS41LDE1IEMxMS4yMjM4NTc2LDE1IDExLDE0Ljc3NjE0MjQgMTEsMTQuNSBMMTEsMTAuNSBDMTEsMTAuMjIzODU3NiAxMS4yMjM4NTc2LDEwIDExLjUsMTAgWiBNNCw5LjUgTDQsNi41IEwxLDYuNSBMMSw5LjUgTDQsOS41IFogTTQuNSwxMC41IEwwLjUsMTAuNSBDMC4yMjM4NTc2MjUsMTAuNSAwLDEwLjI3NjE0MjQgMCwxMCBMOC44ODE3ODQyZS0xNiw2IEM4Ljg4MTc4NDJlLTE2LDUuNzIzODU3NjMgMC4yMjM4NTc2MjUsNS41IDAuNSw1LjUgTDQuNSw1LjUgQzQuNzc2MTQyMzcsNS41IDUsNS43MjM4NTc2MyA1LDYgTDUsMTAgQzUsMTAuMjc2MTQyNCA0Ljc3NjE0MjM3LDEwLjUgNC41LDEwLjUgWiIvPjwvc3ZnPg==');\n    --icon-Mail: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNCI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTQuNSwxMyBDMTQuNzc1ODU3NiwxMyAxNSwxMi43NzU4NTc2IDE1LDEyLjUgTDE1LDEuNSBDMTUsMS4yMjQxNDIzNyAxNC43NzU4NTc2LDEgMTQuNSwxIEwxLjUsMSBDMS4yMjQxNDIzNywxIDEsMS4yMjQxNDIzNyAxLDEuNSBMMSwxMi41IEMxLDEyLjc3NTg1NzYgMS4yMjQxNDIzNywxMyAxLjUsMTMgTDE0LjUsMTMgWiBNMTQuNSwxNCBMMS41LDE0IEMwLjY3MTg1NzYyNSwxNCAwLDEzLjMyODE0MjQgMCwxMi41IEwwLDEuNSBDMCwwLjY3MTg1NzYyNSAwLjY3MTg1NzYyNSw4Ljg4MTc4NDJlLTE2IDEuNSw4Ljg4MTc4NDJlLTE2IEwxNC41LDguODgxNzg0MmUtMTYgQzE1LjMyODE0MjQsOC44ODE3ODQyZS0xNiAxNiwwLjY3MTg1NzYyNSAxNiwxLjUgTDE2LDEyLjUgQzE2LDEzLjMyODE0MjQgMTUuMzI4MTQyNCwxNCAxNC41LDE0IFogTTEzLjE4MzM4MTEsMy4xMTMwMjEzNSBDMTMuMzk3MTAzNSwyLjkzODE1NzU2IDEzLjcxMjExNDksMi45Njk2NTg3IDEzLjg4Njk3ODYsMy4xODMzODExIEMxNC4wNjE4NDI0LDMuMzk3MTAzNTEgMTQuMDMwMzQxMywzLjcxMjExNDg2IDEzLjgxNjYxODksMy44ODY5Nzg2NSBMOC4zMTY2MTg5LDguMzg2OTc4NjUgQzguMTMyNDM1OTUsOC41Mzc2NzM3OCA3Ljg2NzU2NDA1LDguNTM3NjczNzggNy42ODMzODExLDguMzg2OTc4NjUgTDIuMTgzMzgxMSwzLjg4Njk3ODY1IEMxLjk2OTY1ODcsMy43MTIxMTQ4NiAxLjkzODE1NzU2LDMuMzk3MTAzNTEgMi4xMTMwMjEzNSwzLjE4MzM4MTEgQzIuMjg3ODg1MTQsMi45Njk2NTg3IDIuNjAyODk2NDksMi45MzgxNTc1NiAyLjgxNjYxODksMy4xMTMwMjEzNSBMOCw3LjM1Mzk2OTUzIEwxMy4xODMzODExLDMuMTEzMDIxMzUgWiIvPjwvc3ZnPg==');\n    --icon-Maximize: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggc3Ryb2tlPSIjZmZmIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTE0LjUgMS41IDkuNSA2LjVNNi41IDkuNSAxLjUgMTQuNU04LjUgMS41SDE0LjVWNy41TTEuNSA4LjVWMTQuNUg3LjUiLz48L3N2Zz4=');\n    --icon-Memo: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgc3Ryb2tlPSIjMDA5MDU4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBkPSJNMTQuNS41SDEuNUMuOTQ4LjUuNS45NDguNSAxLjVWMTEuNUMuNSAxMi4wNTIuOTQ4IDEyLjUgMS41IDEyLjVINS41TDggMTUuNSAxMC41IDEyLjVIMTQuNUMxNS4wNTIgMTIuNSAxNS41IDEyLjA1MiAxNS41IDExLjVWMS41QzE1LjUuOTQ4IDE1LjA1Mi41IDE0LjUuNVpNMy41IDQuNUgxMi41TTMuNSA4LjVIMTIuNSIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');\n    --icon-Message: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTE1LjUgN0MxNS41IDMuNDEgMTIuMTQyIDAuNSA4IDAuNUMzLjg1OCAwLjUgMC41IDMuNDEgMC41IDdDMC41IDEwLjU5IDMuODU4IDEzLjUgOCAxMy41QzguNTI1IDEzLjUgOS4wMzcgMTMuNDUyIDkuNTMyIDEzLjM2M0wxMy41IDE1LjVWMTEuNDA5QzE0LjczOCAxMC4yNSAxNS41IDguNzA0IDE1LjUgN1oiLz48L3N2Zz4=');\n    --icon-Minimize: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsaXAtcGF0aD0idXJsKCNhKSI+PHBhdGggZD0iTTE0LjUgNi41SDkuNVYxLjVNMTUuNS41IDkuNSA2LjVNNi41IDE0LjVWOS41SDEuNU0uNSAxNS41IDYuNSA5LjUiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2VjE2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+');\n    --icon-Minus: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHJlY3Qgd2lkdGg9IjEyIiBoZWlnaHQ9IjEiIHg9IjIiIHk9IjcuNSIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiByeD0iLjUiLz48L3N2Zz4=');\n    --icon-Mobile: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTIgMEg0QzMuNDY5NTcgMCAyLjk2MDg2IDAuMjEwNzE0IDIuNTg1NzkgMC41ODU3ODZDMi4yMTA3MSAwLjk2MDg1OSAyIDEuNDY5NTcgMiAyVjE0QzIgMTQuNTMwNCAyLjIxMDcxIDE1LjAzOTEgMi41ODU3OSAxNS40MTQyQzIuOTYwODYgMTUuNzg5MyAzLjQ2OTU3IDE2IDQgMTZIMTJDMTIuNTMwNCAxNiAxMy4wMzkxIDE1Ljc4OTMgMTMuNDE0MiAxNS40MTQyQzEzLjc4OTMgMTUuMDM5MSAxNCAxNC41MzA0IDE0IDE0VjJDMTQgMS40Njk1NyAxMy43ODkzIDAuOTYwODU5IDEzLjQxNDIgMC41ODU3ODZDMTMuMDM5MSAwLjIxMDcxNCAxMi41MzA0IDAgMTIgMFpNNCAxM1YzSDEyVjEzSDRaIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iYSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTAgMEgxNlYxNkgweiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==');\n    --icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0xMS41IDguNVYxNC41QzExLjUgMTQuNzY1MiAxMS4zOTQ2IDE1LjAxOTYgMTEuMjA3MSAxNS4yMDcxQzExLjAxOTYgMTUuMzk0NiAxMC43NjUyIDE1LjUgMTAuNSAxNS41SDIuNUMyLjIzNDc4IDE1LjUgMS45ODA0MyAxNS4zOTQ2IDEuNzkyODkgMTUuMjA3MUMxLjYwNTM2IDE1LjAxOTYgMS41IDE0Ljc2NTIgMS41IDE0LjVWMi41QzEuNSAyLjIzNDc4IDEuNjA1MzYgMS45ODA0MyAxLjc5Mjg5IDEuNzkyODlDMS45ODA0MyAxLjYwNTM2IDIuMjM0NzggMS41IDIuNSAxLjVINC41Ii8+PHBhdGggc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0xNC41IDYuNUg5LjVMNi41IDguNVYxLjVDNi41IDEuMjM0NzggNi42MDUzNiAwLjk4MDQzIDYuNzkyODkgMC43OTI4OTNDNi45ODA0MyAwLjYwNTM1NyA3LjIzNDc4IDAuNSA3LjUgMC41SDE0LjVDMTQuNzY1MiAwLjUgMTUuMDE5NiAwLjYwNTM1NyAxNS4yMDcxIDAuNzkyODkzQzE1LjM5NDYgMC45ODA0MyAxNS41IDEuMjM0NzggMTUuNSAxLjVWNS41QzE1LjUgNS43NjUyMiAxNS4zOTQ2IDYuMDE5NTcgMTUuMjA3MSA2LjIwNzExQzE1LjAxOTYgNi4zOTQ2NCAxNC43NjUyIDYuNSAxNC41IDYuNVoiLz48L3N2Zz4=');\n    --icon-MobileChat2: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzE2QjM3OCIgZD0iTTggMEgxNUMxNS4yNjUyIDAgMTUuNTE5NiAwLjEwNTM1NyAxNS43MDcxIDAuMjkyODkzQzE1Ljg5NDYgMC40ODA0MyAxNiAwLjczNDc4NCAxNiAxVjVDMTYgNS4yNjUyMiAxNS44OTQ2IDUuNTE5NTcgMTUuNzA3MSA1LjcwNzExQzE1LjUxOTYgNS44OTQ2NCAxNS4yNjUyIDYgMTUgNkgxMEw3IDhWMUM3IDAuNzM0Nzg0IDcuMTA1MzYgMC40ODA0MyA3LjI5Mjg5IDAuMjkyODkzQzcuNDgwNDMgMC4xMDUzNTcgNy43MzQ3OCAwIDggMFYwWiIvPjxwYXRoIGZpbGw9IiMxNkIzNzgiIGQ9Ik0xMCA3VjEzSDNWNEg2VjFIM0MyLjQ2OTU3IDEgMS45NjA4NiAxLjIxMDcxIDEuNTg1NzkgMS41ODU3OUMxLjIxMDcxIDEuOTYwODYgMSAyLjQ2OTU3IDEgM1YxNEMxIDE0LjUzMDQgMS4yMTA3MSAxNS4wMzkxIDEuNTg1NzkgMTUuNDE0MkMxLjk2MDg2IDE1Ljc4OTMgMi40Njk1NyAxNiAzIDE2SDEwQzEwLjUzMDQgMTYgMTEuMDM5MSAxNS43ODkzIDExLjQxNDIgMTUuNDE0MkMxMS43ODkzIDE1LjAzOTEgMTIgMTQuNTMwNCAxMiAxNFY3SDEwWiIvPjwvc3ZnPg==');\n    --icon-NewNotification: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGNpcmNsZSBjeD0iOCIgY3k9IjgiIHI9IjMiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');\n    --icon-Notification: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMS41LDExIEMyLjMyODg1NzYzLDExIDMsMTAuMzI4ODU3NiAzLDkuNSBMMyw1IEMzLDIuMjM4ODU3NjMgNS4yMzg4NTc2MywwIDgsMCBDMTAuNzYxMTQyNCwwIDEzLDIuMjM4ODU3NjMgMTMsNSBMMTMsOS41IEMxMywxMC4yMjAzMzI0IDE0LjA0ODk4MjgsMTEgMTUsMTEgTDE1LjUsMTEgQzE1Ljc3NjE0MjQsMTEgMTYsMTEuMjIzODU3NiAxNiwxMS41IEMxNiwxMS43NzYxNDI0IDE1Ljc3NjE0MjQsMTIgMTUuNSwxMiBMMC41LDEyIEMwLjIyMzg1NzYyNSwxMiAwLDExLjc3NjE0MjQgMCwxMS41IEMwLDExLjIyMzg1NzYgMC4yMjM4NTc2MjUsMTEgMC41LDExIEwxLjUwMDAwMDI2LDExIFogTTMuNTAwNDQ4OTQsMTEgTDEyLjY3NDU2ODksMTEgQzEyLjI2MzM4ODUsMTAuNTc3OTAyOCAxMiwxMC4wNTU1NzE4IDEyLDkuNSBMMTIsNSBDMTIsMi43OTExNDIzNyAxMC4yMDg4NTc2LDEgOCwxIEM1Ljc5MTE0MjM3LDEgNCwyLjc5MTE0MjM3IDQsNSBMNCw5LjUgQzQsMTAuMDYyODk1NSAzLjgxNDE1MzkxLDEwLjU4MjIyNDUgMy41MDA0NDg5NCwxMSBaIE05LjUsMTMuNSBDOS41LDEzLjIyMzg1NzYgOS43MjM4NTc2MywxMyAxMCwxMyBDMTAuMjc2MTQyNCwxMyAxMC41LDEzLjIyMzg1NzYgMTAuNSwxMy41IEMxMC41LDE0Ljg4MTE0MjQgOS4zODExNDIzNywxNiA4LDE2IEM2LjYxODg1NzYzLDE2IDUuNSwxNC44ODExNDI0IDUuNSwxMy41IEM1LjUsMTMuMjIzODU3NiA1LjcyMzg1NzYzLDEzIDYsMTMgQzYuMjc2MTQyMzcsMTMgNi41LDEzLjIyMzg1NzYgNi41LDEzLjUgQzYuNSwxNC4zMjg4NTc2IDcuMTcxMTQyMzcsMTUgOCwxNSBDOC44Mjg4NTc2MywxNSA5LjUsMTQuMzI4ODU3NiA5LjUsMTMuNSBaIi8+PC9zdmc+');\n    --icon-Offline: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOS4xNzUzMjQwOSw3LjExNzU2OTEzIEwxMS41NTk1MTQ3LDQuNzMzMzc4NSBDOC4yNzQ0MTQyMSwzLjMxOTY4MDE0IDQuMzE4ODI3NDUsMy45NTQwODk1NiAxLjYzNTUsNi42MzY2MDY3NyBDMS40NDAyMDgzNyw2LjgzMTgzOTQzIDEuMTIzNjI1ODksNi44MzE3OTE2MyAwLjkyODM5MzIyNyw2LjYzNjUgQzAuNzMzMTYwNTY4LDYuNDQxMjA4MzcgMC43MzMyMDgzNzIsNi4xMjQ2MjU4OSAwLjkyODUsNS45MjkzOTMyMyBDNC4wMDg1OTIyOSwyLjg1MDIzMDk5IDguNTk3MzY4NDcsMi4xOTk3Nzc0NSAxMi4zMTQ4NjA2LDMuOTc4MDMyNjEgTDE0LjE0NjQ0NjYsMi4xNDY0NDY2MSBDMTQuMzQxNzA4OCwxLjk1MTE4NDQ2IDE0LjY1ODI5MTIsMS45NTExODQ0NiAxNC44NTM1NTM0LDIuMTQ2NDQ2NjEgQzE1LjA0ODgxNTUsMi4zNDE3MDg3NiAxNS4wNDg4MTU1LDIuNjU4MjkxMjQgMTQuODUzNTUzNCwyLjg1MzU1MzM5IEwxMy4yMzA0MDgsNC40NzY2OTg3NSBDMTMuODg0Mjc5OSw0Ljg3ODc0ODA0IDE0LjUwMTkxNTIsNS4zNjI5Nzk1MyAxNS4wNjg1LDUuOTI5MzkzMjMgQzE1LjI2Mzc5MTYsNi4xMjQ2MjU4OSAxNS4yNjM4Mzk0LDYuNDQxMjA4MzcgMTUuMDY4NjA2OCw2LjYzNjUgQzE0Ljg3MzM3NDEsNi44MzE3OTE2MyAxNC41NTY3OTE2LDYuODMxODM5NDMgMTQuMzYxNSw2LjYzNjYwNjc3IEMxMy43OTIzNzM5LDYuMDY3NjUyNDggMTMuMTY2MDE0OSw1LjU5MDgzMjc0IDEyLjUwMDk1OTIsNS4yMDYxNDc1NiBMMTAuMjYzMjYyMiw3LjQ0Mzg0NDU1IEMxMC45ODIyMDYyLDcuNzM2NDQ1MjYgMTEuNjU1ODk1Miw4LjE3NDI2NDUzIDEyLjIzOTQwOTEsOC43NTczMDIzNyBDMTIuNDM0NzUwOSw4Ljk1MjQ4NDgyIDEyLjQzNDg4MDEsOS4yNjkwNjcyOCAxMi4yMzk2OTc2LDkuNDY0NDA5MDkgQzEyLjA0NDUxNTIsOS42NTk3NTA5IDExLjcyNzkzMjcsOS42NTk4ODAwOCAxMS41MzI1OTA5LDkuNDY0Njk3NjMgQzEwLjkzODM0NjQsOC44NzA5Mzc4NyAxMC4yMzE4NzUxLDguNDU3OTA4NiA5LjQ4MTQ5Njk2LDguMjI1NjA5ODIgTDIuODUzNTUzMzksMTQuODUzNTUzNCBDMi42NTgyOTEyNCwxNS4wNDg4MTU1IDIuMzQxNzA4NzYsMTUuMDQ4ODE1NSAyLjE0NjQ0NjYxLDE0Ljg1MzU1MzQgQzEuOTUxMTg0NDYsMTQuNjU4MjkxMiAxLjk1MTE4NDQ2LDE0LjM0MTcwODggMi4xNDY0NDY2MSwxNC4xNDY0NDY2IEw4LjI4MzE1OTYzLDguMDA5NzMzNTkgQzYuOTExMDUxMTUsNy45MzE5NjgyIDUuNTEzMDA1OTMsOC40MTY5NTYyMiA0LjQ2NDQwOTA5LDkuNDY0Njk3NjMgQzQuMjY5MDY3MjgsOS42NTk4ODAwOCAzLjk1MjQ4NDgyLDkuNjU5NzUwOSAzLjc1NzMwMjM3LDkuNDY0NDA5MDkgQzMuNTYyMTE5OTIsOS4yNjkwNjcyOCAzLjU2MjI0OTEsOC45NTI0ODQ4MiAzLjc1NzU5MDkxLDguNzU3MzAyMzcgQzUuMjI5OTI3OCw3LjI4NjE2NjU5IDcuMjc2Mzc5OTMsNi43Mzk1ODg4NCA5LjE3NTMyNDA5LDcuMTE3NTY5MTMgWiBNOC42NjY2NjY1NiwxMi4yNTM2Nzc5IEM4LjQ2MDg0MjI0LDEyLjA2OTU4MjkgOC40NDMyMjcxMiwxMS43NTM0OTA5IDguNjI3MzIyMSwxMS41NDc2NjY2IEM4LjgxMTQxNzA3LDExLjM0MTg0MjIgOS4xMjc1MDkxMSwxMS4zMjQyMjcxIDkuMzMzMzMzNDQsMTEuNTA4MzIyMSBDMTAuMDE4MjE4MywxMi4xMjA5MDIxIDEwLjE5NzU4MDcsMTMuMTIxMzQxNiA5Ljc2ODE2MzQ3LDEzLjkzMzY5NzcgQzkuMzM4NzQ2MjIsMTQuNzQ2MDUzOCA4LjQxMTA0NjMyLDE1LjE2MTI5MjMgNy41MTkxMzEyMiwxNC45NDAzNjQ2IEM2LjYyNzIxNjEzLDE0LjcxOTQzNjggNi4wMDA1OTIwMywxMy45MTkxOTE3IDYuMDAwMDAwMSwxMy4wMDAzMjIxIEM1Ljk5OTgyMjIxLDEyLjcyNDE3OTggNi4yMjM1MzU1OSwxMi41MDAxNzggNi40OTk2Nzc5LDEyLjUwMDAwMDEgQzYuNzc1ODIwMjIsMTIuNDk5ODIyMiA2Ljk5OTgyMjAxLDEyLjcyMzUzNTYgNi45OTk5OTk5LDEyLjk5OTY3NzkgQzcuMDAwMjk1ODYsMTMuNDU5MTEyNyA3LjMxMzYwNzkxLDEzLjg1OTIzNTMgNy43NTk1NjU0NiwxMy45Njk2OTkxIEM4LjIwNTUyMywxNC4wODAxNjMgOC42NjkzNzI5NSwxMy44NzI1NDM4IDguODg0MDgxNTgsMTMuNDY2MzY1NyBDOS4wOTg3OTAyLDEzLjA2MDE4NzcgOS4wMDkxMDkwMSwxMi41NTk5Njc5IDguNjY2NjY2NTYsMTIuMjUzNjc3OSBaIi8+PC9zdmc+');\n    --icon-Page: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMiwxIEwyLDE1IEwxNCwxNSBMMTQsMSBMMiwxIFogTTEuNSwwIEwxNC41LDAgQzE0Ljc3NjE0MjQsMCAxNSwwLjIyMzg1NzYyNSAxNSwwLjUgTDE1LDE1LjUgQzE1LDE1Ljc3NjE0MjQgMTQuNzc2MTQyNCwxNiAxNC41LDE2IEwxLjUsMTYgQzEuMjIzODU3NjMsMTYgMSwxNS43NzYxNDI0IDEsMTUuNSBMMSwwLjUgQzEsMC4yMjM4NTc2MjUgMS4yMjM4NTc2MywwIDEuNSwwIFogTTkuNSw0IEM5LjIyMzg1NzYzLDQgOSwzLjc3NjE0MjM3IDksMy41IEM5LDMuMjIzODU3NjMgOS4yMjM4NTc2MywzIDkuNSwzIEwxMS41LDMgQzExLjc3NjE0MjQsMyAxMiwzLjIyMzg1NzYzIDEyLDMuNSBDMTIsMy43NzYxNDIzNyAxMS43NzYxNDI0LDQgMTEuNSw0IEw5LjUsNCBaIE05LjUsNyBDOS4yMjM4NTc2Myw3IDksNi43NzYxNDIzNyA5LDYuNSBDOSw2LjIyMzg1NzYzIDkuMjIzODU3NjMsNiA5LjUsNiBMMTEuNSw2IEMxMS43NzYxNDI0LDYgMTIsNi4yMjM4NTc2MyAxMiw2LjUgQzEyLDYuNzc2MTQyMzcgMTEuNzc2MTQyNCw3IDExLjUsNyBMOS41LDcgWiBNNC41LDEwIEM0LjIyMzg1NzYzLDEwIDQsOS43NzYxNDIzNyA0LDkuNSBDNCw5LjIyMzg1NzYzIDQuMjIzODU3NjMsOSA0LjUsOSBMMTEuNSw5IEMxMS43NzYxNDI0LDkgMTIsOS4yMjM4NTc2MyAxMiw5LjUgQzEyLDkuNzc2MTQyMzcgMTEuNzc2MTQyNCwxMCAxMS41LDEwIEw0LjUsMTAgWiBNNC41LDEzIEM0LjIyMzg1NzYzLDEzIDQsMTIuNzc2MTQyNCA0LDEyLjUgQzQsMTIuMjIzODU3NiA0LjIyMzg1NzYzLDEyIDQuNSwxMiBMMTEuNSwxMiBDMTEuNzc2MTQyNCwxMiAxMiwxMi4yMjM4NTc2IDEyLDEyLjUgQzEyLDEyLjc3NjE0MjQgMTEuNzc2MTQyNCwxMyAxMS41LDEzIEw0LjUsMTMgWiBNNSw0IEw1LDYgTDcsNiBMNyw0IEw1LDQgWiBNNC41LDMgTDcuNSwzIEM3Ljc3NjE0MjM3LDMgOCwzLjIyMzg1NzYzIDgsMy41IEw4LDYuNSBDOCw2Ljc3NjE0MjM3IDcuNzc2MTQyMzcsNyA3LjUsNyBMNC41LDcgQzQuMjIzODU3NjMsNyA0LDYuNzc2MTQyMzcgNCw2LjUgTDQsMy41IEM0LDMuMjIzODU3NjMgNC4yMjM4NTc2MywzIDQuNSwzIFoiLz48L3N2Zz4=');\n    --icon-PanelLeft: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMy43MDcxMDY3OCw4LjUgTDUuODUzNTUzMzksMTAuNjQ2NDQ2NiBDNi4wNDg4MTU1NCwxMC44NDE3MDg4IDYuMDQ4ODE1NTQsMTEuMTU4MjkxMiA1Ljg1MzU1MzM5LDExLjM1MzU1MzQgQzUuNjU4MjkxMjQsMTEuNTQ4ODE1NSA1LjM0MTcwODc2LDExLjU0ODgxNTUgNS4xNDY0NDY2MSwxMS4zNTM1NTM0IEwyLjE0NjQ0NjYxLDguMzUzNTUzMzkgQzEuOTUxMTg0NDYsOC4xNTgyOTEyNCAxLjk1MTE4NDQ2LDcuODQxNzA4NzYgMi4xNDY0NDY2MSw3LjY0NjQ0NjYxIEw1LjE0NjQ0NjYxLDQuNjQ2NDQ2NjEgQzUuMzQxNzA4NzYsNC40NTExODQ0NiA1LjY1ODI5MTI0LDQuNDUxMTg0NDYgNS44NTM1NTMzOSw0LjY0NjQ0NjYxIEM2LjA0ODgxNTU0LDQuODQxNzA4NzYgNi4wNDg4MTU1NCw1LjE1ODI5MTI0IDUuODUzNTUzMzksNS4zNTM1NTMzOSBMMy43MDcxMDY3OCw3LjUgTDguNSw3LjUgQzguNzc2MTQyMzcsNy41IDksNy43MjM4NTc2MyA5LDggQzksOC4yNzYxNDIzNyA4Ljc3NjE0MjM3LDguNSA4LjUsOC41IEwzLjcwNzEwNjc4LDguNSBaIE0xMCwxMy41IEwxMCwyLjUgQzEwLDIuMjIzODU3NjMgMTAuMjIzODU3NiwyIDEwLjUsMiBMMTMuNSwyIEMxMy43NzYxNDI0LDIgMTQsMi4yMjM4NTc2MyAxNCwyLjUgTDE0LDEzLjUgQzE0LDEzLjc3NjE0MjQgMTMuNzc2MTQyNCwxNCAxMy41LDE0IEwxMC41LDE0IEMxMC4yMjM4NTc2LDE0IDEwLDEzLjc3NjE0MjQgMTAsMTMuNSBaIi8+PC9zdmc+');\n    --icon-PanelRight: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMy43MDcxMDY3OCw4LjUgTDUuODUzNTUzMzksMTAuNjQ2NDQ2NiBDNi4wNDg4MTU1NCwxMC44NDE3MDg4IDYuMDQ4ODE1NTQsMTEuMTU4MjkxMiA1Ljg1MzU1MzM5LDExLjM1MzU1MzQgQzUuNjU4MjkxMjQsMTEuNTQ4ODE1NSA1LjM0MTcwODc2LDExLjU0ODgxNTUgNS4xNDY0NDY2MSwxMS4zNTM1NTM0IEwyLjE0NjQ0NjYxLDguMzUzNTUzMzkgQzEuOTUxMTg0NDYsOC4xNTgyOTEyNCAxLjk1MTE4NDQ2LDcuODQxNzA4NzYgMi4xNDY0NDY2MSw3LjY0NjQ0NjYxIEw1LjE0NjQ0NjYxLDQuNjQ2NDQ2NjEgQzUuMzQxNzA4NzYsNC40NTExODQ0NiA1LjY1ODI5MTI0LDQuNDUxMTg0NDYgNS44NTM1NTMzOSw0LjY0NjQ0NjYxIEM2LjA0ODgxNTU0LDQuODQxNzA4NzYgNi4wNDg4MTU1NCw1LjE1ODI5MTI0IDUuODUzNTUzMzksNS4zNTM1NTMzOSBMMy43MDcxMDY3OCw3LjUgTDguNSw3LjUgQzguNzc2MTQyMzcsNy41IDksNy43MjM4NTc2MyA5LDggQzksOC4yNzYxNDIzNyA4Ljc3NjE0MjM3LDguNSA4LjUsOC41IEwzLjcwNzEwNjc4LDguNSBaIE0xMCwxMy41IEwxMCwyLjUgQzEwLDIuMjIzODU3NjMgMTAuMjIzODU3NiwyIDEwLjUsMiBMMTMuNSwyIEMxMy43NzYxNDI0LDIgMTQsMi4yMjM4NTc2MyAxNCwyLjUgTDE0LDEzLjUgQzE0LDEzLjc3NjE0MjQgMTMuNzc2MTQyNCwxNCAxMy41LDE0IEwxMC41LDE0IEMxMC4yMjM4NTc2LDE0IDEwLDEzLjc3NjE0MjQgMTAsMTMuNSBaIiB0cmFuc2Zvcm09Im1hdHJpeCgtMSAwIDAgMSAxNiAwKSIvPjwvc3ZnPg==');\n    --icon-Paragraph: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNS45MjMwOCAyLjQ2MTU0VjcuMzg0NjJDNC41NjkyMyA3LjM4NDYyIDMuNDYxNTQgNi4yNzY5MiAzLjQ2MTU0IDQuOTIzMDhDMy40NjE1NCAzLjU2OTIzIDQuNTY5MjMgMi40NjE1NCA1LjkyMzA4IDIuNDYxNTRaTTE0IDBINS45MjMwOEMzLjIwMzA4IDAgMSAyLjIwMzA4IDEgNC45MjMwOEMxIDcuNjQzMDggMy4yMDMwOCA5Ljg0NjE1IDUuOTIzMDggOS44NDYxNVYxNkg3LjVWMi40NjE1NEg5LjU5NjE1VjE2SDExLjI1TDExLjI1IDIuNDYxNTRIMTRWMFoiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2VjE2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+');\n    --icon-Pencil: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iLTIgLTIgMjAgMjAiPjxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzIxMjEyMSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiPjxwYXRoIGQ9Ik0xMyAuNSAxNS41IDMgNy41IDExIDQgMTIgNSA4LjV6TTExIDIuNSAxMy41IDUiLz48cGF0aCBkPSJNMTMuNSw5LjV2NSBjMCwwLjU1Mi0wLjQ0OCwxLTEsMWgtMTFjLTAuNTUyLDAtMS0wLjQ0OC0xLTF2LTExYzAtMC41NTIsMC40NDgtMSwxLTFoNSIvPjwvZz48L3N2Zz4=');\n    --icon-Pin2: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzkyOTI5OSIgZD0iTTQuOTk5OTIgOC44MzIxNFYzLjMzMjY4QzQuNTM5NjggMy4zMzI2OCA0LjE2NjU5IDIuOTU5NTkgNC4xNjY1OSAyLjQ5OTM1QzQuMTY2NTkgMi4wMzkxMSA0LjUzOTY4IDEuNjY2MDIgNC45OTk5MiAxLjY2NjAySDE0Ljk5OTlDMTUuNDYwMiAxLjY2NjAyIDE1LjgzMzMgMi4wMzkxMSAxNS44MzMzIDIuNDk5MzVDMTUuODMzMyAyLjk1OTU5IDE1LjQ2MDIgMy4zMzI2OCAxNC45OTk5IDMuMzMyNjhWOC44MzIxNEMxNS43NzY0IDkuNjgyOCAxNi4xOTM0IDEwLjUyNzggMTYuNDE1NSAxMS4xOTQyQzE2LjUzNzIgMTEuNTU5MyAxNi41OTk5IDExLjg2OSAxNi42MzIyIDEyLjA5NUMxNi42NDgzIDEyLjIwODEgMTYuNjU2OSAxMi4zMDA2IDE2LjY2MTUgMTIuMzY5QzE2LjY2MzggMTIuNDAzMyAxNi42NjUgMTIuNDMxNSAxNi42NjU3IDEyLjQ1MzRMMTYuNjY2MyAxMi40NzI4TDE2LjY2NjQgMTIuNDgxM0wxNi42NjY2IDEyLjQ5MTZMMTYuNjY2NiAxMi40OTU4TDE2LjY2NjYgMTIuNDk3NkwxNi42NjY2IDEyLjQ5ODVDMTYuNjY2NiAxMi40OTg1IDE2LjY2MjggMTIuMzg5MSAxNi42NjY2IDEyLjQ5OTNDMTYuNjY2NiAxMi45NTk2IDE2LjI5MzUgMTMuMzMyNyAxNS44MzMzIDEzLjMzMjdIMTAuODMzM1YxNy40OTkzQzEwLjgzMzMgMTcuOTU5NiAxMC40NjAyIDE4LjMzMjcgOS45OTk5MiAxOC4zMzI3QzkuNTM5NjggMTguMzMyNyA5LjE2NjU4IDE3Ljk1OTYgOS4xNjY1OCAxNy40OTkzVjEzLjMzMjdINC4xNjY1OUMzLjcwNjM1IDEzLjMzMjcgMy4zMzMyNSAxMi45NTk2IDMuMzMzMjUgMTIuNDk5M0MzLjMzMzI1IDEyLjA4MjcgMy4zMzMyNSAxMi40OTg1IDMuMzMzMjUgMTIuNDk4NUwzLjMzMzI1IDEyLjQ5NzZMMy4zMzMyNiAxMi40OTU4TDMuMzMzMjggMTIuNDkxNkwzLjMzMzQxIDEyLjQ4MTNDMy4zMzM1MyAxMi40NzM2IDMuMzMzNzQgMTIuNDY0MyAzLjMzNDA5IDEyLjQ1MzRDMy4zMzQ4IDEyLjQzMTUgMy4zMzYwNyAxMi40MDMzIDMuMzM4MzUgMTIuMzY5QzMuMzQyOTEgMTIuMzAwNiAzLjM1MTUxIDEyLjIwODEgMy4zNjc2NyAxMi4wOTVDMy4zOTk5NyAxMS44NjkgMy40NjI2NSAxMS41NTkzIDMuNTg0MzUgMTEuMTk0MkMzLjgwNjQ4IDEwLjUyNzggNC4yMjM0NSA5LjY4MjggNC45OTk5MiA4LjgzMjE0Wk0xMy4zMzMzIDMuMzMyNjhINi42NjY1OVY5LjE2NjAyQzYuNjY2NTkgOS4zODcwMyA2LjU3ODc5IDkuNTk4OTkgNi40MjI1MSA5Ljc1NTI3QzUuNzE2NjYgMTAuNDYxMSA1LjM2MzA2IDExLjE1MzkgNS4xODQzMiAxMS42NjZIMTQuODE1NUMxNC42MzY4IDExLjE1MzkgMTQuMjgzMiAxMC40NjExIDEzLjU3NzMgOS43NTUyN0MxMy40MjEgOS41OTg5OSAxMy4zMzMzIDkuMzg3MDMgMTMuMzMzMyA5LjE2NjAyVjMuMzMyNjhaIi8+PC9zdmc+');\n    --icon-PinBig: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTEuODI5NzI4MSwtNy42MDI4MDcyN2UtMTMgTDQuMTkyOTc0OTEsLTcuNTc2MTYxOTJlLTEzIEMzLjYyNzI4OTQ4LC03LjUzMTc1M2UtMTMgMy4yMDMwMjU0MSwwLjQxMDI1NjQxIDMuMjAzMDI1NDEsMC45NTcyNjQ5NTcgQzMuMjAzMDI1NDEsMS41MDQyNzM1IDMuNjI3Mjg5NDgsMS45MTQ1Mjk5MSA0LjE5Mjk3NDkxLDEuOTE0NTI5OTEgTDUuMDQxNTAzMDQsMS45MTQ1Mjk5MSBMMi44NDk0NzIwMiw5LjA5NDAxNzA5IEwxLjQzNTI1ODQ2LDkuMDk0MDE3MDkgQzAuODY5NTczMDM2LDkuMDk0MDE3MDkgMC40NDUzMDg5NjcsOS41MDQyNzM1IDAuNDQ1MzA4OTY3LDEwLjA1MTI4MjEgQzAuNDQ1MzA4OTY3LDEwLjU5ODI5MDYgMC44Njk1NzMwMzYsMTEuMDA4NTQ3IDEuNDM1MjU4NDYsMTEuMDA4NTQ3IEwzLjU1NjU3ODgsMTEuMDA4NTQ3IEw2Ljk1MDY5MTM1LDExLjAwODU0NyBMNy4wMjE0MDIwMywxNiBMOS4wMDEzMDEwMiwxNiBMOS4wMDEzMDEwMiwxMC45NDAxNzA5IEwxNC42NTgxNTUzLDEwLjk0MDE3MDkgQzE1LjM2NTI2MjEsMTAuOTQwMTcwOSAxNS42NDgxMDQ4LDEwLjI1NjQxMDMgMTUuNjQ4MTA0OCw5Ljk4MjkwNTk4IEMxNS42NDgxMDQ4LDkuNDM1ODk3NDQgMTUuMjIzODQwNyw5LjAyNTY0MTAzIDE0LjY1ODE1NTMsOS4wMjU2NDEwMyBMMTMuMjQzOTQxNyw5LjAyNTY0MTAzIEwxMS4xMjI2MjE0LDEuOTE0NTI5OTEgTDExLjk3MTE0OTUsMS45MTQ1Mjk5MSBDMTIuODE5Njc3NiwxLjkxNDUyOTkxIDEyLjk2MTA5OSwxLjIzMDc2OTIzIDEyLjk2MTA5OSwwLjk1NzI2NDk1NyBDMTIuODkwMzg4MywwLjQ3ODYzMjQ3OSAxMi4zOTU0MTM2LC03LjY2NDk3OTc2ZS0xMyAxMS44Mjk3MjgxLC03LjYwMjgwNzI3ZS0xMyBaIE0xMS4wNTE5MTA3LDkuMDk0MDE3MDkgTDQuOTcwNzkyMzcsOS4wOTQwMTcwOSBMNy4wOTIxMTI3MSwxLjk4MjkwNTk4IEw4LjkzMDU5MDM0LDEuOTgyOTA1OTggTDExLjA1MTkxMDcsOS4wOTQwMTcwOSBaIi8+PC9zdmc+');\n    --icon-PinSmall: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNy41LDExIEwyLjUsMTEgQzIuMjIzODU3NjMsMTEgMiwxMC43NzYxNDI0IDIsMTAuNSBDMiwxMC4yMjM4NTc2IDIuMjIzODU3NjMsMTAgMi41LDEwIEw0LjI1LDEwIEw1Ljc1LDQgTDQuNSw0IEM0LjIyMzg1NzYzLDQgNCwzLjc3NjE0MjM3IDQsMy41IEM0LDMuMjIzODU3NjMgNC4yMjM4NTc2MywzIDQuNSwzIEwxMS41LDMgQzExLjc3NjE0MjQsMyAxMiwzLjIyMzg1NzYzIDEyLDMuNSBDMTIsMy43NzYxNDIzNyAxMS43NzYxNDI0LDQgMTEuNSw0IEwxMC4yNSw0IEwxMS43NSwxMCBMMTMuNSwxMCBDMTMuNzc2MTQyNCwxMCAxNCwxMC4yMjM4NTc2IDE0LDEwLjUgQzE0LDEwLjc3NjE0MjQgMTMuNzc2MTQyNCwxMSAxMy41LDExIEw4LjUsMTEgTDguNSwxNCBMNy41LDE0IEw3LjUsMTEgWiBNNS4yODA3NzY0MSwxMCBMMTAuNzE5MjIzNiwxMCBMOS4yMTkyMjM1OSw0IEw2Ljc4MDc3NjQxLDQgTDUuMjgwNzc2NDEsMTAgWiIvPjwvc3ZnPg==');\n    --icon-PinTilted: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0yIDYuMDAwMjQgMTAgMTQuMDAwMk05LjIwMDIgMiAxNC4wMDAyIDYuOE0zLjMxNjg5IDcuMzE2NjEgMTAuNTE0NSAzLjMxNDIxTTguNjgzMTEgMTIuNjgzIDEyLjY4NTUgNS40ODUzNU00LjggMTEuMjAwMiAyIDE0LjAwMDIiLz48L3N2Zz4=');\n    --icon-Pivot: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMywxIEwxMywxIEMxNC4xMDQ1Njk1LDEgMTUsMS44OTU0MzA1IDE1LDMgTDE1LDEzIEMxNSwxNC4xMDQ1Njk1IDE0LjEwNDU2OTUsMTUgMTMsMTUgTDMsMTUgQzEuODk1NDMwNSwxNSAxLDE0LjEwNDU2OTUgMSwxMyBMMSwzIEMxLDEuODk1NDMwNSAxLjg5NTQzMDUsMSAzLDEgWiBNNS4zNzUsNC41IEw1LjM3NSw0Ljc4NTQ2MzQxIEw3LjYzNDY1MDEsOC4wMjI3MDczMiBMNS4zNzUsMTEuMDk0NTEyMiBMNS4zNzUsMTEuNSBMMTAuNjI1LDExLjUgTDEwLjYyNSwxMC4wMzI4MTcxIEw5LjgyNjQ5OTAxLDEwLjcyNjQxNDYgTDYuOTE5NzMxNjEsMTAuNzI2NDE0NiBMNi45MTk3MzE2MSwxMC40MTY5NjM0IEw4LjkwMjgzMyw3LjYxNTg1MzY2IEw2LjkxOTczMTYxLDQuOTkwODUzNjYgTDkuNzc0MzEyMTMsNC45ODczNTM2NiBMMTAuNjI1LDYuMDk1MzE3MDcgTDEwLjYyNSw0LjUgTDUuMzc1LDQuNSBaIi8+PC9zdmc+');\n    --icon-PivotLight: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cmVjdCB3aWR0aD0iMTUiIGhlaWdodD0iMTUiIHg9Ii41IiB5PSIuNSIgc3Ryb2tlPSIjOTc5Nzk3IiByeD0iMiIvPjxwYXRoIGZpbGw9IiM5Nzk3OTciIGQ9Ik01LjM3NSA0LjUgMTAuNjI1IDQuNSAxMC42MjUgNi4wOTUzMTcwNyA5Ljc3NDMxMjEzIDQuOTg3MzUzNjYgNi45MTk3MzE2MSA0Ljk5MDg1MzY2IDguOTAyODMzIDcuNjE1ODUzNjYgNi45MTk3MzE2MSAxMC40MTY5NjM0IDYuOTE5NzMxNjEgMTAuNzI2NDE0NiA5LjgyNjQ5OTAxIDEwLjcyNjQxNDYgMTAuNjI1IDEwLjAzMjgxNzEgMTAuNjI1IDExLjUgNS4zNzUgMTEuNSA1LjM3NSAxMS4wOTQ1MTIyIDcuNjM0NjUwMSA4LjAyMjcwNzMyIDUuMzc1IDQuNzg1NDYzNDF6Ii8+PC9nPjwvc3ZnPg==');\n    --icon-Plus: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOC41LDcuNSBMMTMsNy41IEMxMy4yNzYxNDI0LDcuNSAxMy41LDcuNzIzODU3NjMgMTMuNSw4IEMxMy41LDguMjc2MTQyMzcgMTMuMjc2MTQyNCw4LjUgMTMsOC41IEw4LjUsOC41IEw4LjUsMTMgQzguNSwxMy4yNzYxNDI0IDguMjc2MTQyMzcsMTMuNSA4LDEzLjUgQzcuNzIzODU3NjMsMTMuNSA3LjUsMTMuMjc2MTQyNCA3LjUsMTMgTDcuNSw4LjUgTDMsOC41IEMyLjcyMzg1NzYzLDguNSAyLjUsOC4yNzYxNDIzNyAyLjUsOCBDMi41LDcuNzIzODU3NjMgMi43MjM4NTc2Myw3LjUgMyw3LjUgTDcuNSw3LjUgTDcuNSwzIEM3LjUsMi43MjM4NTc2MyA3LjcyMzg1NzYzLDIuNSA4LDIuNSBDOC4yNzYxNDIzNywyLjUgOC41LDIuNzIzODU3NjMgOC41LDMgTDguNSw3LjUgWiIvPjwvc3ZnPg==');\n    --icon-Popup: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0zLjUgNy41VjMuNUg3LjVNMy41IDMuNSA3LjUgNy41Ii8+PHBhdGggc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0yIDAuNUgxNEMxNC4zOTc4IDAuNSAxNC43Nzk0IDAuNjU4MDM1IDE1LjA2MDcgMC45MzkzNEMxNS4zNDIgMS4yMjA2NCAxNS41IDEuNjAyMTggMTUuNSAyVjE0QzE1LjUgMTQuMzk3OCAxNS4zNDIgMTQuNzc5NCAxNS4wNjA3IDE1LjA2MDdDMTQuNzc5NCAxNS4zNDIgMTQuMzk3OCAxNS41IDE0IDE1LjVIMkMxLjYwMjE4IDE1LjUgMS4yMjA2NCAxNS4zNDIgMC45MzkzNCAxNS4wNjA3QzAuNjU4MDM1IDE0Ljc3OTQgMC41IDE0LjM5NzggMC41IDE0VjJDMC41IDEuNjAyMTggMC42NTgwMzUgMS4yMjA2NCAwLjkzOTM0IDAuOTM5MzRDMS4yMjA2NCAwLjY1ODAzNSAxLjYwMjE4IDAuNSAyIDAuNVYwLjVaIi8+PHBhdGggZmlsbD0iIzkyOTI5OSIgZD0iTTEyLjUgOUg5LjVDOS4yMjM4NiA5IDkgOS4yMjM4NiA5IDkuNVYxMi41QzkgMTIuNzc2MSA5LjIyMzg2IDEzIDkuNSAxM0gxMi41QzEyLjc3NjEgMTMgMTMgMTIuNzc2MSAxMyAxMi41VjkuNUMxMyA5LjIyMzg2IDEyLjc3NjEgOSAxMi41IDlaIi8+PC9zdmc+');\n    --icon-Public: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNSI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6Ii8+PGcgc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05Ljc3NSAxMS4yOTMgNy4zMTIgMTAuNTkzQzcuMTIyMjE2NjEgMTAuNTM4OTUxMSA2Ljk4MTk3MzAyIDEwLjM3ODMzNyA2Ljk1NCAxMC4xODNMNi43NDcgOC43MjZDNy44MTQxMjAyNiA4LjIzODMwNjE1IDguNDk5MDAyMDkgNy4xNzMyODE3IDguNSA2TDguNSA0LjYyNkM4LjUyNTE5NzU2IDIuOTczMDAzMjUgNy4yNDI1MDU0NyAxLjU5NDE1MzM3IDUuNTkyIDEuNSA0Ljc4MDUzMjcgMS40NzUxMDMyIDMuOTkzNjIwMiAxLjc4MDE0ODI3IDMuNDEwOTUzMjMgMi4zNDU0Nzg0NiAyLjgyODI4NjI1IDIuOTEwODA4NjQgMi40OTk2MTgxNiAzLjY4ODE1MDk1IDIuNSA0LjVMMi41IDZDMi41MDA5OTc5MSA3LjE3MzI4MTcgMy4xODU4Nzk3NCA4LjIzODMwNjE1IDQuMjUzIDguNzI2TDQuMDQ2IDEwLjE3OUM0LjAxODAyNjk4IDEwLjM3NDMzNyAzLjg3Nzc4MzM5IDEwLjUzNDk1MTEgMy42ODggMTAuNTg5TDEuMjI1IDExLjI4OUMuNzk1OTk1Njg4IDExLjQxMTcwNzIuNTAwMTk4MjMgMTEuODAzNzkxOC41IDEyLjI1TC41IDE0LjUgMTAuNSAxNC41IDEwLjUgMTIuMjU0QzEwLjQ5OTgwMTggMTEuODA3NzkxOCAxMC4yMDQwMDQzIDExLjQxNTcwNzIgOS43NzUgMTEuMjkzWk0xMi41IDE0LjUgMTUuNSAxNC41IDE1LjUgMTEuMjgxQzE1LjQ5OTk4NzkgMTAuODIyMzIwNiAxNS4xODc5MzExIDEwLjQyMjQ1OTEgMTQuNzQzIDEwLjMxMUwxMS44MjYgOS41ODJDMTEuNjI4NDcxIDkuNTMyNzA4NDEgMTEuNDgwNTUyNCA5LjM2ODU3NDEgMTEuNDUyIDkuMTY3TDExLjI0NyA3LjcyNkMxMi4zMTQxMjAzIDcuMjM4MzA2MTUgMTIuOTk5MDAyMSA2LjE3MzI4MTcgMTMgNUwxMyAzLjYyNkMxMy4wMjUxOTc2IDEuOTczMDAzMjUgMTEuNzQyNTA1NS41OTQxNTMzNjUgMTAuMDkyLjUgOS41MzQ0NjYxNC40ODI3MzcyNzIgOC45ODMxNjAzOS42MjEyNTYzMDYgOC41LjkiLz48L2c+PC9nPjwvc3ZnPg==');\n    --icon-PublicColor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNCI+PGcgc3R5bGU9ImZpbGw6bm9uZTtmaWxsLXJ1bGU6ZXZlbm9kZDtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MSI+PHBhdGggZD0iTTAgMEgxNlYxNkgweiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48ZyBzdHlsZT0ib3BhY2l0eTouNzgyOTk5OTc7ZmlsbDpub25lO2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpldmVub2RkIj48cGF0aCBkPSJNIDE1LjI3NSwxMC4yOTMgMTMuMDg3LDkuNjY4IEMgMTIuNzI2NDc4LDkuNTY0OTE3OCAxMi40NTM5Miw5LjI2ODgxMDUgMTIuMzgxLDguOTAxIEwgMTIuMjQ3LDguMjI2IEMgMTMuMzE0MTIsNy43MzgzMDYxIDEzLjk5OTAwMiw2LjY3MzI4MTcgMTQsNS41IFYgNC4xMjYgQyAxNC4wMjUxOTgsMi40NzMwMDMzIDEyLjc0MjUwNiwxLjA5NDE1MzQgMTEuMDkyLDEgOS44NzkzNzA5LDAuOTYzNDEwODIgOC43NjQwNjU1LDEuNjYwNzcyNyA4LjI2NiwyLjc2NyA4Ljc0MzA0NzgsMy40NjEyNzg1IDguOTk4OTE5OCw0LjI4MzYyNDcgOSw1LjEyNiBWIDYuNSBDIDguOTk4MjA3Nyw2Ljg2Mzc3MDYgOC45NDYwNDU2LDcuMjI1NTQwNCA4Ljg0NSw3LjU3NSA5LjEwNDIzMjUsNy44NDY5NDIzIDkuNDEyMjM5NSw4LjA2Nzc3MTEgOS43NTMsOC4yMjYgTCA5LjYxOSw4LjkgQyA5LjU0NjA3OTYsOS4yNjc4MTA1IDkuMjczNTIxOSw5LjU2MzkxNzggOC45MTMsOS42NjcgTCA4LjA3LDkuOTA4IDkuNTUsMTAuMzMxIGMgMC44NTY5NjQsMC4yNDc1NTQgMS40NDc3MDYsMS4wMzA5OTkgMS40NSwxLjkyMyBWIDE0LjUgYyAtMC4wMDE3LDAuMTcwNzIgLTAuMDMyNzgsMC4zMzk4NzEgLTAuMDkyLDAuNSBIIDE1LjUgYyAwLjI3NjE0MiwwIDAuNSwtMC4yMjM4NTggMC41LC0wLjUgdiAtMy4yNDYgYyAtMS45OGUtNCwtMC40NDYyMDggLTAuMjk1OTk2LC0wLjgzODI5MyAtMC43MjUsLTAuOTYxIHoiIHN0eWxlPSJmaWxsOiNlNmExMTc7ZmlsbC1vcGFjaXR5OjE7ZmlsbC1ydWxlOmV2ZW5vZGQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgLTEpIi8+PHBhdGggZD0iTSA5LjI3NSwxMS4yOTMgNy4wODcsMTAuNjY4IEMgNi43MjYyMDIzLDEwLjU2NDc1NCA2LjQ1MzU4MywxMC4yNjgxOTQgNi4zODEsOS45IEwgNi4yNDcsOS4yMjUgQyA3LjMxMzc5MTQsOC43Mzc0NTY5IDcuOTk4NjExMiw3LjY3MjkxOTUgOCw2LjUgViA1LjEyNiBDIDguMDI1MTk3NiwzLjQ3MzAwMzMgNi43NDI1MDU1LDIuMDk0MTUzNCA1LjA5MiwyIDQuMjgwNTMyNywxLjk3NTEwMzIgMy40OTM2MjAyLDIuMjgwMTQ4MyAyLjkxMDk1MzIsMi44NDU0Nzg1IDIuMzI4Mjg2MywzLjQxMDgwODYgMS45OTk2MTgyLDQuMTg4MTUwOSAyLDUgdiAxLjUgYyA5Ljk3OWUtNCwxLjE3MzI4MTcgMC42ODU4Nzk3LDIuMjM4MzA2MiAxLjc1MywyLjcyNiBMIDMuNjE5LDkuOSBDIDMuNTQ2MDgsMTAuMjY3ODExIDMuMjczNTIxOSwxMC41NjM5MTggMi45MTMsMTAuNjY3IEwgMC43MjUsMTEuMjkyIEMgMC4yOTU2MzkyNiwxMS40MTQ4MDkgLTIuNDgxNzcxNmUtNCwxMS44MDc0MjEgMCwxMi4yNTQgViAxNC41IEMgMCwxNC43NzYxNDIgMC4yMjM4NTc2MywxNSAwLjUsMTUgaCA5IEMgOS43NzYxNDI0LDE1IDEwLDE0Ljc3NjE0MiAxMCwxNC41IFYgMTIuMjU0IEMgOS45OTk4MDE4LDExLjgwNzc5MiA5LjcwNDAwNDMsMTEuNDE1NzA3IDkuMjc1LDExLjI5MyBaIiBzdHlsZT0ib3BhY2l0eToxO2ZpbGw6I2ZmZjtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6ZXZlbm9kZCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48L2c+PC9nPjwvc3ZnPg==');\n    --icon-PublicFilled: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNCI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIGZpbGw9IiMxNkIzNzgiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTE1LjI3NSwxMC4yOTMgTDEzLjA4Nyw5LjY2OCBDMTIuNzI2NDc4MSw5LjU2NDkxNzggMTIuNDUzOTIwNCw5LjI2ODgxMDUgMTIuMzgxLDguOTAxIEwxMi4yNDcsOC4yMjYgQzEzLjMxNDEyMDMsNy43MzgzMDYxNSAxMy45OTkwMDIxLDYuNjczMjgxNyAxNCw1LjUgTDE0LDQuMTI2IEMxNC4wMjUxOTc2LDIuNDczMDAzMjUgMTIuNzQyNTA1NSwxLjA5NDE1MzM3IDExLjA5MiwxIEM5Ljg3OTM3MDksMC45NjM0MTA4MjIgOC43NjQwNjU1LDEuNjYwNzcyNjkgOC4yNjYsMi43NjcgQzguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4NCw0LjI4MzYyNDc0IDksNS4xMjYgTDksNi41IEM4Ljk5ODIwNzc0LDYuODYzNzcwNTcgOC45NDYwNDU1OCw3LjIyNTU0MDM4IDguODQ1LDcuNTc1IEM5LjEwNDIzMjUzLDcuODQ2OTQyMjYgOS40MTIyMzk1Myw4LjA2Nzc3MTA2IDkuNzUzLDguMjI2IEw5LjYxOSw4LjkgQzkuNTQ2MDc5NTcsOS4yNjc4MTA1IDkuMjczNTIxODcsOS41NjM5MTc4IDguOTEzLDkuNjY3IEw4LjA3LDkuOTA4IEw5LjU1LDEwLjMzMSBDMTAuNDA2OTY0MywxMC41Nzg1NTM2IDEwLjk5NzcwNTksMTEuMzYxOTk5MyAxMSwxMi4yNTQgTDExLDE0LjUgQzEwLjk5ODM0MjgsMTQuNjcwNzE5OCAxMC45NjcyMTg5LDE0LjgzOTg3MTUgMTAuOTA4LDE1IEwxNS41LDE1IEMxNS43NzYxNDI0LDE1IDE2LDE0Ljc3NjE0MjQgMTYsMTQuNSBMMTYsMTEuMjU0IEMxNS45OTk4MDE4LDEwLjgwNzc5MTggMTUuNzA0MDA0MywxMC40MTU3MDcyIDE1LjI3NSwxMC4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNOS4yNzUsMTEuMjkzIEw3LjA4NywxMC42NjggQzYuNzI2MjAyMzIsMTAuNTY0NzUzOSA2LjQ1MzU4MywxMC4yNjgxOTM1IDYuMzgxLDkuOSBMNi4yNDcsOS4yMjUgQzcuMzEzNzkxNDEsOC43Mzc0NTY5MyA3Ljk5ODYxMTI1LDcuNjcyOTE5NTQgOCw2LjUgTDgsNS4xMjYgQzguMDI1MTk3NTYsMy40NzMwMDMyNSA2Ljc0MjUwNTQ3LDIuMDk0MTUzMzcgNS4wOTIsMiBDNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgyNyAyLjkxMDk1MzIzLDIuODQ1NDc4NDYgQzIuMzI4Mjg2MjUsMy40MTA4MDg2NCAxLjk5OTYxODE2LDQuMTg4MTUwOTUgMiw1IEwyLDYuNSBDMi4wMDA5OTc5MSw3LjY3MzI4MTcgMi42ODU4Nzk3NCw4LjczODMwNjE1IDMuNzUzLDkuMjI2IEwzLjYxOSw5LjkgQzMuNTQ2MDc5NTcsMTAuMjY3ODEwNSAzLjI3MzUyMTg3LDEwLjU2MzkxNzggMi45MTMsMTAuNjY3IEwwLjcyNSwxMS4yOTIgQzAuMjk1NjM5MjYyLDExLjQxNDgwOTEgLTAuMDAwMjQ4MTc3MTU3LDExLjgwNzQyMTIgMCwxMi4yNTQgTDAsMTQuNSBDMy4zODE3NjU4MWUtMTcsMTQuNzc2MTQyNCAwLjIyMzg1NzYyNSwxNSAwLjUsMTUgTDkuNSwxNSBDOS43NzYxNDIzNywxNSAxMCwxNC43NzYxNDI0IDEwLDE0LjUgTDEwLDEyLjI1NCBDOS45OTk4MDE3NywxMS44MDc3OTE4IDkuNzA0MDA0MzEsMTEuNDE1NzA3MiA5LjI3NSwxMS4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48L2c+PC9nPjwvc3ZnPg==');\n    --icon-Question: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGNpcmNsZSBjeD0iOCIgY3k9IjgiIHI9IjcuNSIgc3Ryb2tlPSIjMTZCMzc4Ii8+PHBhdGggZmlsbD0iIzE2QjM3OCIgZD0iTTcuMTI0NiAxMC4zNDA5VjEwLjI4NTVDNy4xMzA3NiA5LjY5NzY4IDcuMTkyMzEgOS4yMjk4OCA3LjMwOTI2IDguODgyMUM3LjQyNjIxIDguNTM0MzMgNy41OTI0IDguMjUyNzIgNy44MDc4NCA4LjAzNzI5QzguMDIzMjcgNy44MjE4NSA4LjI4MTggNy42MjMzNCA4LjU4MzQxIDcuNDQxNzZDOC43NjQ5OSA3LjMzMDk3IDguOTI4MSA3LjIwMDE3IDkuMDcyNzUgNy4wNDkzNkM5LjIxNzQgNi44OTU0OCA5LjMzMTI4IDYuNzE4NTEgOS40MTQzNyA2LjUxODQ3QzkuNTAwNTUgNi4zMTg0MiA5LjU0MzYzIDYuMDk2ODMgOS41NDM2MyA1Ljg1MzY5QzkuNTQzNjMgNS41NTIwOCA5LjQ3Mjg1IDUuMjkwNDggOS4zMzEyOCA1LjA2ODg5QzkuMTg5NyA0Ljg0NzMgOS4wMDA0MyA0LjY3NjQ5IDguNzYzNDUgNC41NTY0NkM4LjUyNjQ3IDQuNDM2NDMgOC4yNjMzMyA0LjM3NjQyIDcuOTc0MDMgNC4zNzY0MkM3LjcyMTY2IDQuMzc2NDIgNy40Nzg1MyA0LjQyODc0IDcuMjQ0NjMgNC41MzMzOEM3LjAxMDczIDQuNjM4MDIgNi44MTUzIDQuODAyNjggNi42NTgzNCA1LjAyNzM0QzYuNTAxMzggNS4yNTIwMSA2LjQxMDU5IDUuNTQ1OTMgNi4zODU5NiA1LjkwOTA5SDUuMjIyNjFDNS4yNDcyMyA1LjM4NTg5IDUuMzgyNjUgNC45MzgwOSA1LjYyODg2IDQuNTY1N0M1Ljg3ODE1IDQuMTkzMyA2LjIwNTkyIDMuOTA4NjIgNi42MTIxNyAzLjcxMTY1QzcuMDIxNSAzLjUxNDY4IDcuNDc1NDUgMy40MTYxOSA3Ljk3NDAzIDMuNDE2MTlDOC41MTU3IDMuNDE2MTkgOC45ODY1OCAzLjUyMzkxIDkuMzg2NjcgMy43MzkzNUM5Ljc4OTg1IDMuOTU0NzggMTAuMTAwNyA0LjI1MDI0IDEwLjMxOTIgNC42MjU3MUMxMC41NDA4IDUuMDAxMTggMTAuNjUxNiA1LjQyODk4IDEwLjY1MTYgNS45MDkwOUMxMC42NTE2IDYuMjQ3NjMgMTAuNTk5MyA2LjU1Mzg2IDEwLjQ5NDYgNi44Mjc3N0MxMC4zOTMxIDcuMTAxNjggMTAuMjQ1MyA3LjM0NjM1IDEwLjA1MTQgNy41NjE3OUM5Ljg2MDYzIDcuNzc3MjMgOS42Mjk4MSA3Ljk2ODA0IDkuMzU4OTggOC4xMzQyM0M5LjA4ODE0IDguMzAzNSA4Ljg3MTE3IDguNDgyMDEgOC43MDgwNSA4LjY2OTc0QzguNTQ0OTQgOC44NTQ0IDguNDI2NDUgOS4wNzQ0NiA4LjM1MjU4IDkuMzI5OUM4LjI3ODcyIDkuNTg1MzUgOC4yMzg3MSA5LjkwMzg4IDguMjMyNTUgMTAuMjg1NVYxMC4zNDA5SDcuMTI0NlpNNy43MTU1MSAxMy4wNzM5QzcuNDg3NzYgMTMuMDczOSA3LjI5MjMzIDEyLjk5MjMgNy4xMjkyMiAxMi44MjkyQzYuOTY2MSAxMi42NjYxIDYuODg0NTQgMTIuNDcwNiA2Ljg4NDU0IDEyLjI0MjlDNi44ODQ1NCAxMi4wMTUyIDYuOTY2MSAxMS44MTk3IDcuMTI5MjIgMTEuNjU2NkM3LjI5MjMzIDExLjQ5MzUgNy40ODc3NiAxMS40MTE5IDcuNzE1NTEgMTEuNDExOUM3Ljk0MzI2IDExLjQxMTkgOC4xMzg2OSAxMS40OTM1IDguMzAxOCAxMS42NTY2QzguNDY0OTIgMTEuODE5NyA4LjU0NjQ4IDEyLjAxNTIgOC41NDY0OCAxMi4yNDI5QzguNTQ2NDggMTIuMzkzNyA4LjUwODAxIDEyLjUzMjIgOC40MzEwNiAxMi42NTg0QzguMzU3MiAxMi43ODQ2IDguMjU3MTggMTIuODg2MSA4LjEzMDk5IDEyLjk2MzFDOC4wMDc4OSAxMy4wMzY5IDcuODY5MzkgMTMuMDczOSA3LjcxNTUxIDEzLjA3MzlaIi8+PC9zdmc+');\n    --icon-Redo: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMi4zNTUzMjgsNSBMNi41MDAxMDIzOCw1IEM2Ljc3NjI0NDc2LDUgNy4wMDAxMDIzOCw1LjIyMzg1NzYzIDcuMDAwMTAyMzgsNS41IEM3LjAwMDEwMjM4LDUuNzc2MTQyMzcgNi43NzYyNDQ3Niw2IDYuNTAwMTAyMzgsNiBMMS41MTAyMTIzOCw2IEMxLjQ4Njg1NDQ0LDYuMDAwNDkxOTggMS40NjMzNTg5Nyw1Ljk5OTM0NDE0IDEuNDM5ODk2MjcsNS45OTY0OTkyNSBDMS4zNjAzODQ1NCw1Ljk4Njg3MDU4IDEuMjg2NjA3NDMsNS45NTg2NjU2MyAxLjIyMzA3NjA0LDUuOTE2MzA0ODQgQzEuMDk5MzE4MDEsNS44MzQxNzU0NyAxLjAyMTk2NzA5LDUuNzAzNDc4OCAxLjAwNDAxNjU2LDUuNTYyODY2MjkgQzEuMDAxOTEyNzcsNS41NDYwOTYwMiAxLjAwMDYzOTU2LDUuNTI5MDY3NCAxLjAwMDIzOTQyLDUuNTExODIyOTIgQzAuOTk5OTU2MzcxLDUuNTA0MjczMjEgMC45OTk5NDI2ODQsNS40OTY3MDQ2IDEuMDAwMTAyMzgsNS40ODkxMjY5MyBMMS4wMDAxMDIzOCwwLjUgQzEuMDAwMTAyMzgsMC4yMjM4NTc2MjUgMS4yMjM5NjAwMSwwIDEuNTAwMTAyMzgsMCBDMS43NzYyNDQ3NiwwIDIuMDAwMTAyMzgsMC4yMjM4NTc2MjUgMi4wMDAxMDIzOCwwLjUgTDIuMDAwMTAyMzgsMy43NzQ2OTMxNiBDMy4zNjg2NDMyOCwyLjAzOTQwMzQzIDUuMzI5MTc3OTgsMSA3LjUwMDEwMjM4LDEgQzExLjY0MjI0NDgsMSAxNS4wMDAxMDI0LDQuMzU3ODU3NjMgMTUuMDAwMTAyNCw4LjUgQzE1LjAwMDEwMjQsMTIuNjQyMTQyNCAxMS42NDIyNDQ4LDE2IDcuNTAwMTAyMzgsMTYgQzcuMjIzOTYwMDEsMTYgNy4wMDAxMDIzOCwxNS43NzYxNDI0IDcuMDAwMTAyMzgsMTUuNSBDNy4wMDAxMDIzOCwxNS4yMjM4NTc2IDcuMjIzOTYwMDEsMTUgNy41MDAxMDIzOCwxNSBDMTEuMDg5OTYsMTUgMTQuMDAwMTAyNCwxMi4wODk4NTc2IDE0LjAwMDEwMjQsOC41IEMxNC4wMDAxMDI0LDQuOTEwMTQyMzcgMTEuMDg5OTYsMiA3LjUwMDEwMjM4LDIgQzUuNDE1ODQ4OTIsMiAzLjU0NTY1NDAyLDMuMTMwNjQwNyAyLjM1NTMyOCw1IFoiIHRyYW5zZm9ybT0ibWF0cml4KC0xIDAgMCAxIDE2IDApIi8+PC9zdmc+');\n    --icon-Remove: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNiw0IEw2LDIuNSBDNiwyLjIyMzg1NzYzIDYuMjIzODU3NjMsMiA2LjUsMiBMOS41LDIgQzkuNzc2MTQyMzcsMiAxMCwyLjIyMzg1NzYzIDEwLDIuNSBMMTAsNCBMMTMuNSw0IEMxMy43NzYxNDI0LDQgMTQsNC4yMjM4NTc2MyAxNCw0LjUgQzE0LDQuNzc2MTQyMzcgMTMuNzc2MTQyNCw1IDEzLjUsNSBMMi41LDUgQzIuMjIzODU3NjMsNSAyLDQuNzc2MTQyMzcgMiw0LjUgQzIsNC4yMjM4NTc2MyAyLjIyMzg1NzYzLDQgMi41LDQgTDYsNCBaIE03LDQgTDksNCBMOSwzIEw3LDMgTDcsNCBaIE0xMSw2LjUgQzExLDYuMjIzODU3NjMgMTEuMjIzODU3Niw2IDExLjUsNiBDMTEuNzc2MTQyNCw2IDEyLDYuMjIzODU3NjMgMTIsNi41IEwxMiwxMi41IEMxMiwxMy4zMjg0MjcxIDExLjMyODQyNzEsMTQgMTAuNSwxNCBMNS41LDE0IEM0LjY3MTU3Mjg4LDE0IDQsMTMuMzI4NDI3MSA0LDEyLjUgTDQsNi41IEM0LDYuMjIzODU3NjMgNC4yMjM4NTc2Myw2IDQuNSw2IEM0Ljc3NjE0MjM3LDYgNSw2LjIyMzg1NzYzIDUsNi41IEw1LDEyLjUgQzUsMTIuNzc2MTQyNCA1LjIyMzg1NzYzLDEzIDUuNSwxMyBMMTAuNSwxMyBDMTAuNzc2MTQyNCwxMyAxMSwxMi43NzYxNDI0IDExLDEyLjUgTDExLDYuNSBaIi8+PC9zdmc+');\n    --icon-RemoveBig: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNIDUuNDgxNDcxMSw0IDUuNDk4NDIwMywxLjA1OTMyMiBDIDUuNTAwMDExOSwwLjc4MzE4NDIyIDUuNzIxMzI5NCwwLjU1OTMyMjAzIDUuOTk2MzAxNywwLjU1OTMyMjAzIEggMTAuMTk4OTMgYyAwLjI3NDk3MiwwIDAuNDk5NDcyLDAuMjIzODYyMTkgMC40OTc4ODEsMC40OTk5OTk5NyBMIDEwLjY3OTg2Miw0IDE1LjM1Mjk3NywzLjk4MzA1MDggYyAwLjI3NDk3MSwtOS45NzNlLTQgMC40OTc4ODEsMC4yMjM4NTc2IDAuNDk3ODgxLDAuNSAwLDAuMjc2MTQyNCAtMC4yMjI5MDksMC40OTk2ODM3IC0wLjQ5Nzg4MSwwLjUgTCAwLjYxOTc3NzY4LDUgQyAwLjM0NDgwNTU4LDUuMDAwMzE2MyAwLjEyMTg5NjM5LDQuNzc2MTQyNCAwLjEyMTg5NjM5LDQuNSBjIDAsLTAuMjc2MTQyNCAwLjIyMjkwOSwtMC41IDAuNDk3ODgxMjksLTAuNSB6IE0gNi40NzcyMzM4LDQgSCA5LjY4NDA5OSBMIDkuNzAxMDQ4MiwxLjU1OTMyMiBIIDYuNDk0MTgzIFogbSA1LjUyOTg0NzIsMy42MjY5NTE0IGMgLTMuOThlLTQsLTAuMjc2MTQyMSAwLjIyMjkwOSwtMC41IDAuNDk3ODgxLC0wLjUgMC4yNzQ5NzMsMCAwLjQ5NzQ4MiwwLjIyMzg1OCAwLjQ5Nzg4MiwwLjUgbCAwLjAwOTIsNi4zNDYyNDA2IGMgMC4wMDEyLDAuODI4NDI2IC0wLjY2ODcyOCwxLjUgLTEuNDkzNjQ0LDEuNSBIIDQuNDYyNDEyNiBjIC0wLjgyNDkxNjgsMCAtMS40OTI0NDk2LC0wLjY3MTU3NCAtMS40OTM2NDQsLTEuNSBsIC0wLjAwOTE1LC02LjM0NjI0MDYgYyAtMy45ODFlLTQsLTAuMjc2MTQyMSAwLjIyMjkwOTEsLTAuNSAwLjQ5Nzg4MTMsLTAuNSAwLjI3NDk3MjMsMCAwLjQ5NzQ4MzMsMC4yMjM4NTc5IDAuNDk3ODgxNCwwLjUgbCAwLjAwOTE1LDYuMzQ2MjQwNiBjIDMuOTgxZS00LDAuMjc2MTQxIDAuMjIyOTA5MSwwLjUgMC40OTc4ODEzLDAuNSBoIDcuMDU1OTQwNCBjIDAuMjc0OTcyLDAgMC40OTgyNzksLTAuMjIzODU5IDAuNDk3ODgxLC0wLjUgeiIgc3R5bGU9ImZpbGw6IzAwMDtmaWxsLXJ1bGU6bm9uemVybztzdHJva2Utd2lkdGg6Ljk5Nzg3OTA5Ii8+PC9zdmc+');\n    --icon-Repl: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTQuMjkyODkzMiwxMiBMMTIuMTQ2NDQ2Niw5Ljg1MzU1MzM5IEMxMS45NTExODQ1LDkuNjU4MjkxMjQgMTEuOTUxMTg0NSw5LjM0MTcwODc2IDEyLjE0NjQ0NjYsOS4xNDY0NDY2MSBDMTIuMzQxNzA4OCw4Ljk1MTE4NDQ2IDEyLjY1ODI5MTIsOC45NTExODQ0NiAxMi44NTM1NTM0LDkuMTQ2NDQ2NjEgTDE1Ljg1MzU1MzQsMTIuMTQ2NDQ2NiBDMTYuMDQ4ODE1NSwxMi4zNDE3MDg4IDE2LjA0ODgxNTUsMTIuNjU4MjkxMiAxNS44NTM1NTM0LDEyLjg1MzU1MzQgTDEyLjg1MzU1MzQsMTUuODUzNTUzNCBDMTIuNjU4MjkxMiwxNi4wNDg4MTU1IDEyLjM0MTcwODgsMTYuMDQ4ODE1NSAxMi4xNDY0NDY2LDE1Ljg1MzU1MzQgQzExLjk1MTE4NDUsMTUuNjU4MjkxMiAxMS45NTExODQ1LDE1LjM0MTcwODggMTIuMTQ2NDQ2NiwxNS4xNDY0NDY2IEwxNC4yOTI4OTMyLDEzIEwxMi4yOTcsMTMgQzExLjAxMTQwMTQsMTMgOS43ODczMTA0NiwxMi40NTAwODk2IDguOTMzMzg0NDMsMTEuNDg5MjgyMSBMNy45MDQzODQ0MywxMC4zMzIyODIxIEM3LjcyMDg3MDA5LDEwLjEyNTkzOTkgNy43MzkzNzU3LDkuODA5ODk4NzggNy45NDU3MTc4OCw5LjYyNjM4NDQzIEM4LjE1MjA2MDA2LDkuNDQyODcwMDkgOC40NjgxMDEyMiw5LjQ2MTM3NTcgOC42NTE2MTU1Nyw5LjY2NzcxNzg4IEw5LjY4MDcyODgsMTAuODI0ODQ1MiBDMTAuMzQ1MDI1NSwxMS41NzIyODg0IDExLjI5NzEwNCwxMiAxMi4yOTcsMTIgTDE0LjI5Mjg5MzIsMTIgWiBNMC41LDQgQzAuMjIzODU3NjI1LDQgLTUuMTk1ODQzNzZlLTE0LDMuNzc2MTQyMzcgLTUuMTk1ODQzNzZlLTE0LDMuNSBDLTUuMTk1ODQzNzZlLTE0LDMuMjIzODU3NjMgMC4yMjM4NTc2MjUsMyAwLjUsMyBMMC43MDQsMyBDMS45ODk1OTg1NSwzIDMuMjEzNjg5NTQsMy41NDk5MTA0MiA0LjA2Nzc3NTk1LDQuNTEwODk4MyBMNS4wOTU3NzU5NSw1LjY2Nzg5ODMgQzUuMjc5MTkwNjUsNS44NzQzMjkwNSA1LjI2MDUzMjQ2LDYuMTkwMzYxMjQgNS4wNTQxMDE3LDYuMzczNzc1OTUgQzQuODQ3NjcwOTUsNi41NTcxOTA2NSA0LjUzMTYzODc2LDYuNTM4NTMyNDYgNC4zNDgyMjQwNSw2LjMzMjEwMTcgTDMuMzIwMjcxMiw1LjE3NTE1NDc2IEMyLjY1NTk3NDUyLDQuNDI3NzExNTkgMS43MDM4OTU5OSw0IDAuNzA0LDQgTDAuNSw0IFogTTE0LjI5MTg5MzIsMi45OTkgTDEyLjE0NjQ0NjYsMC44NTM1NTMzOTEgQzExLjk1MTE4NDUsMC42NTgyOTEyNDUgMTEuOTUxMTg0NSwwLjM0MTcwODc1NSAxMi4xNDY0NDY2LDAuMTQ2NDQ2NjA5IEMxMi4zNDE3MDg4LC0wLjA0ODgxNTUzNjUgMTIuNjU4MjkxMiwtMC4wNDg4MTU1MzY1IDEyLjg1MzU1MzQsMC4xNDY0NDY2MDkgTDE1LjgzNDE5ODYsMy4xMjcwOTE4MyBDMTUuOTMxMTg5LDMuMjE0MzA1MTcgMTUuOTkzODgwNCwzLjMzODkxMjc4IDE1Ljk5OTU3NTksMy40NzgyMTc3NCBDMTUuOTk5ODM3MSwzLjQ4NTM3NzgzIDE1Ljk5OTk5MjgsMy40OTI0Mzk4MSAxNS45OTk5OTk4LDMuNDk5NTAxOTIgQzE1Ljk5OTk4ODQsMy41MTEwNjExMyAxNS45OTk1ODQ4LDMuNTIyNTI4NDkgMTUuOTk4ODAxNSwzLjUzMzg5MTQzIEMxNS45OTA5OTQ0LDMuNjUwMzMyNTYgMTUuOTQyNTU5OCwzLjc2NDU0NzAyIDE1Ljg1MzU1MzQsMy44NTM1NTMzOSBMMTIuODUzNTUzNCw2Ljg1MzU1MzM5IEMxMi42NTgyOTEyLDcuMDQ4ODE1NTQgMTIuMzQxNzA4OCw3LjA0ODgxNTU0IDEyLjE0NjQ0NjYsNi44NTM1NTMzOSBDMTEuOTUxMTg0NSw2LjY1ODI5MTI0IDExLjk1MTE4NDUsNi4zNDE3MDg3NiAxMi4xNDY0NDY2LDYuMTQ2NDQ2NjEgTDE0LjI5Mzg5MzIsMy45OTkgTDEyLjI5NywzLjk5OSBDMTEuMjk3MTA0LDMuOTk5IDEwLjM0NTAyNTUsNC40MjY3MTE1OSA5LjY4MDcxNDQ1LDUuMTc0MTcwOSBMNC4wNjc3Mjg4LDExLjQ4OTE1NDggQzMuMjEzNjg5NTQsMTIuNDUwMDg5NiAxLjk4OTU5ODU1LDEzIDAuNzA0LDEzIEwwLjUsMTMgQzAuMjIzODU3NjI1LDEzIC0yLjI2NDg1NDk3ZS0xMywxMi43NzYxNDI0IC0yLjI2NDg1NDk3ZS0xMywxMi41IEMtMi4yNjQ4NTQ5N2UtMTMsMTIuMjIzODU3NiAwLjIyMzg1NzYyNSwxMiAwLjUsMTIgTDAuNzA0LDEyIEMxLjcwMzg5NTk5LDEyIDIuNjU1OTc0NTIsMTEuNTcyMjg4NCAzLjMyMDI4NTU1LDEwLjgyNDgyOTEgTDguOTMzMjcxMiw0LjUwOTg0NTI0IEM5Ljc4NzMxMDQ2LDMuNTQ4OTEwNDIgMTEuMDExNDAxNCwyLjk5OSAxMi4yOTcsMi45OTkgTDE0LjI5MTg5MzIsMi45OTkgWiIvPjwvc3ZnPg==');\n    --icon-ResizePanel: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHJlY3Qgd2lkdGg9IjIiIGhlaWdodD0iMTIiIHg9IjQiIHk9IjIiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIgcng9IjEiLz48L3N2Zz4=');\n    --icon-Revert: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0zLjEyMzE3IDMuMDAzM0wzLjA4NzI4IDYuOTExMDNMNi44MTMzNiA1LjY2NDM2Ii8+PHBhdGggc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0xMy42MTU4IDEwLjA3OTlDMTQuMDM2NCA4LjY4NDc4IDEzLjg4NTUgNy4xNzk3NCAxMy4xOTY1IDUuODk1ODYgMTIuNTA3NCA0LjYxMTk4IDExLjMzNjUgMy42NTQ0MyA5Ljk0MTM3IDMuMjMzODUgOC41NDYyNyAyLjgxMzI3IDcuMDQxMjMgMi45NjQxMSA1Ljc1NzM1IDMuNjUzMiA0LjQ3MzQ3IDQuMzQyMyAzLjUxNTkyIDUuNTEzMTggMy4wOTUzNCA2LjkwODI4TTYuNDI5NDMgMTMuNTg3NkM1Ljg0ODggMTMuMzY4MyA1LjMwOTYyIDEzLjA1MiA0LjgzNDg0IDEyLjY1MjJNMTAuMDM5OCAxMy42ODE2QzkuNDk5NTMgMTMuODUzNCA4LjkzNTk1IDEzLjk0MDcgOC4zNjkwMyAxMy45NDAySDguMjQzMDRNMTIuODg2OCAxMS41NzI4QzEyLjU0ODIgMTIuMDYwOCAxMi4xMzMyIDEyLjQ5MTIgMTEuNjU3NyAxMi44NDczTTMuNjYyMTUgMTEuMjgwNEMzLjM1MTQ4IDEwLjc2NjYgMy4xMjc3NiAxMC4yMDUxIDMgOS42MTg0MSIvPjwvc3ZnPg==');\n    --icon-RightAlign: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMi41LDguNSBDMi4yMjM4NTc2Myw4LjUgMiw4LjI3NjE0MjM3IDIsOCBDMiw3LjcyMzg1NzYzIDIuMjIzODU3NjMsNy41IDIuNSw3LjUgTDEzLjUsNy41IEMxMy43NzYxNDI0LDcuNSAxNCw3LjcyMzg1NzYzIDE0LDggQzE0LDguMjc2MTQyMzcgMTMuNzc2MTQyNCw4LjUgMTMuNSw4LjUgTDIuNSw4LjUgWiBNMi41LDQgQzIuMjIzODU3NjMsNCAyLDMuNzc2MTQyMzcgMiwzLjUgQzIsMy4yMjM4NTc2MyAyLjIyMzg1NzYzLDMgMi41LDMgTDEzLjUsMyBDMTMuNzc2MTQyNCwzIDE0LDMuMjIzODU3NjMgMTQsMy41IEMxNCwzLjc3NjE0MjM3IDEzLjc3NjE0MjQsNCAxMy41LDQgTDIuNSw0IFogTTguNSwxMyBDOC4yMjM4NTc2MywxMyA4LDEyLjc3NjE0MjQgOCwxMi41IEM4LDEyLjIyMzg1NzYgOC4yMjM4NTc2MywxMiA4LjUsMTIgTDEzLjUsMTIgQzEzLjc3NjE0MjQsMTIgMTQsMTIuMjIzODU3NiAxNCwxMi41IEMxNCwxMi43NzYxNDI0IDEzLjc3NjE0MjQsMTMgMTMuNSwxMyBMOC41LDEzIFoiLz48L3N2Zz4=');\n    --icon-Robot: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTYgMlYwSDEwVjJIMTJWNEg0VjJINlYwSDBWMkgyVjRIMFYxNkgxNlY0SDE0VjJIMTZaTTEwIDdDMTAuNiA3IDExIDcuNCAxMSA4QzExIDguNiAxMC42IDkgMTAgOUM5LjQgOSA5IDguNiA5IDhDOSA3LjQgOS40IDcgMTAgN1pNNiA3QzYuNiA3IDcgNy40IDcgOEM3IDguNiA2LjYgOSA2IDlDNS40IDkgNSA4LjYgNSA4QzUgNy40IDUuNCA3IDYgN1pNMTIgMTNINFYxMUgxMlYxM1oiLz48L3N2Zz4=');\n    --icon-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeD0iMy43NDUiIHJ5PSI0LjAyNiIgc3R5bGU9ImZpbGw6bm9uZTtmaWxsLW9wYWNpdHk6MTtzdHJva2Utd2lkdGg6MS41O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3BhaW50LW9yZGVyOm5vcm1hbCIvPjxnIHN0eWxlPSJzdHJva2Utd2lkdGg6MS41NDMyOTQ3O3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lIj48cGF0aCBkPSJNMTguNzM0NDQ5IDkuMDIxMjU1NiA1LjI2NTU1MTIgOS4wMDk1MzY4TTE4LjczNDQ0OSAxNC43Mzg1ODYgNS4yNjU1NTEyIDE0LjcyNjg2NyIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6MS41NDMyOTQ3O3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1vcGFjaXR5OjEiIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4=');\n    --icon-Search: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTEuNDM2MjEyNywxMC43MjkxMDU5IEwxNC44NTM1NTM0LDE0LjE0NjQ0NjYgQzE1LjA0ODgxNTUsMTQuMzQxNzA4OCAxNS4wNDg4MTU1LDE0LjY1ODI5MTIgMTQuODUzNTUzNCwxNC44NTM1NTM0IEMxNC42NTgyOTEyLDE1LjA0ODgxNTUgMTQuMzQxNzA4OCwxNS4wNDg4MTU1IDE0LjE0NjQ0NjYsMTQuODUzNTUzNCBMMTAuNzI5MTA1OSwxMS40MzYyMTI3IEM5LjU5MjMzODQ4LDEyLjQxMTA0ODcgOC4xMTQ5NDc3MSwxMyA2LjUsMTMgQzIuOTEwMTQ5MTMsMTMgMCwxMC4wODk4NTA5IDAsNi41IEMwLDIuOTEwMTQ5MTMgMi45MTAxNDkxMywwIDYuNSwwIEMxMC4wODk4NTA5LDAgMTMsMi45MTAxNDkxMyAxMyw2LjUgQzEzLDguMTE0OTQ3NzEgMTIuNDExMDQ4Nyw5LjU5MjMzODQ4IDExLjQzNjIxMjcsMTAuNzI5MTA1OSBaIE0xMC40MDk1NzQ3LDEwLjM2ODQ5MjEgQzExLjM5MjgzMjUsOS4zNzQ4NTc4IDEyLDguMDA4MzM0NjggMTIsNi41IEMxMiwzLjQ2MjQzMzg4IDkuNTM3NTY2MTIsMSA2LjUsMSBDMy40NjI0MzM4OCwxIDEsMy40NjI0MzM4OCAxLDYuNSBDMSw5LjUzNzU2NjEyIDMuNDYyNDMzODgsMTIgNi41LDEyIEM4LjAwODMzNDY4LDEyIDkuMzc0ODU3OCwxMS4zOTI4MzI1IDEwLjM2ODQ5MjEsMTAuNDA5NTc0NyBDMTAuMzc0OTAwMSwxMC40MDIzODc5IDEwLjM4MTU1MTYsMTAuMzk1MzQxNiAxMC4zODg0NDY2LDEwLjM4ODQ0NjYgQzEwLjM5NTM0MTYsMTAuMzgxNTUxNiAxMC40MDIzODc5LDEwLjM3NDkwMDEgMTAuNDA5NTc0NywxMC4zNjg0OTIxIFoiLz48L3N2Zz4=');\n    --icon-Section: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXBhdGg9InVybCgjYSkiIGNsaXAtcnVsZT0iZXZlbm9kZCI+PHBhdGggZD0iTTIuMTA1MjYgNC4yMTA5NEgxMy44OTQ3QzE1LjA1NzQgNC4yMTA5NCAxNiA1LjE1MzUgMTYgNi4zMTYyVjkuNjg0NjJDMTYgMTAuODQ3MyAxNS4wNTc0IDExLjc4OTkgMTMuODk0NyAxMS43ODk5SDIuMTA1MjZDLjk0MjU1OCAxMS43ODk5IDAgMTAuODQ3MyAwIDkuNjg0NjJWNi4zMTYyQzAgNS4xNTM1Ljk0MjU1OCA0LjIxMDk0IDIuMTA1MjYgNC4yMTA5NFpNMi4xMDUyNiA1LjQ3NDFDMS42NDAxOCA1LjQ3NDEgMS4yNjMxNiA1Ljg1MTEyIDEuMjYzMTYgNi4zMTYyVjkuNjg0NjJDMS4yNjMxNiAxMC4xNDk3IDEuNjQwMTggMTAuNTI2NyAyLjEwNTI2IDEwLjUyNjdIMTMuODk0N0MxNC4zNTk4IDEwLjUyNjcgMTQuNzM2OCAxMC4xNDk3IDE0LjczNjggOS42ODQ2MlY2LjMxNjJDMTQuNzM2OCA1Ljg1MTEyIDE0LjM1OTggNS40NzQxIDEzLjg5NDcgNS40NzQxSDIuMTA1MjZaTTEuMjYzMTYgMTYuMDAwMUgwVjE1LjE1OEwuMDAwMDQzMDY1OSAxNS4xNDQ0Qy4wMDczNjcwOCAxMy45ODc5Ljk0NzEgMTMuMDUyNyAyLjEwNTI2IDEzLjA1MjdIMy41Nzg5NVYxNC4zMTU5SDIuMTA1MjZDMS42NDAxOCAxNC4zMTU5IDEuMjYzMTYgMTQuNjkyOSAxLjI2MzE2IDE1LjE1OFYxNi4wMDAxWk05LjQ3MzY4IDE0LjMxNTlINi41MjYzMlYxMy4wNTI3SDkuNDczNjhWMTQuMzE1OVpNMTQuNzM2OCAxNi4wMDAxVjE1LjE1OEMxNC43MzY4IDE0LjY5MjkgMTQuMzU5OCAxNC4zMTU5IDEzLjg5NDcgMTQuMzE1OUgxMi40MjExVjEzLjA1MjdIMTMuODk0N0MxNS4wNTc0IDEzLjA1MjcgMTYgMTMuOTk1MyAxNiAxNS4xNThWMTYuMDAwMUgxNC43MzY4Wk0xNC43MzY4LS4wMDAxMDI4MThIMTZWLjg0MjAwMkwxNiAuODU1NjE4QzE1Ljk5MjYgMi4wMTIwNiAxNS4wNTI5IDIuOTQ3MjcgMTMuODk0NyAyLjk0NzI3SDEyLjQyMTFWMS42ODQxMUwxMy44OTQ3IDEuNjg0MTFDMTQuMzU5OCAxLjY4NDExIDE0LjczNjggMS4zMDcwOCAxNC43MzY4Ljg0MjAwMlYtLjAwMDEwMjgxOFpNNi41MjYzMiAxLjY4NDExIDkuNDczNjggMS42ODQxMVYyLjk0NzI3TDYuNTI2MzIgMi45NDcyN1YxLjY4NDExWk0xLjI2MzE2LS4wMDAxMDI4MThWLjg0MjAwMkMxLjI2MzE2IDEuMzA3MDggMS42NDAxOCAxLjY4NDExIDIuMTA1MjYgMS42ODQxMUwzLjU3ODk1IDEuNjg0MTFWMi45NDcyN0wyLjEwNTI2IDIuOTQ3MjdDLjk0MjU1OCAyLjk0NzI3LTEuMTkyMDllLTcgMi4wMDQ3MS0xLjE5MjA5ZS03Ljg0MjAwMlYtLjAwMDEwMjgxOEgxLjI2MzE2WiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');\n    --icon-Separator: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgZmlsbD0iIzAwMCIgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBkPSJNMS4zMzMgN0gxNC42NjZWOC4zMzNIMS4zMzN6TTAgNUgxLjMzM1YxMC4zMzNIMHpNMTQuNjY3IDVIMTZWMTAuMzMzSDE0LjY2N3oiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2VjE2SDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+');\n    --icon-Settings: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMy45ODA0NzE5LDMuMjczMzY1MTIgQzQuODE0MTQ0NjUsMi41Nzg0MDY4NCA1Ljg1NjYyMjkxLDIuMTI1NDU2NzQgNywyLjAyMjQyMTUxIEw3LDAuNSBDNywwLjIyMzg1NzYyNSA3LjIyMzg1NzYzLDAgNy41LDAgQzcuNzc2MTQyMzcsMCA4LDAuMjIzODU3NjI1IDgsMC41IEw4LDIuMDIyNDIxNTEgQzkuMTQzMzc3MDksMi4xMjU0NTY3NCAxMC4xODU4NTU0LDIuNTc4NDA2ODQgMTEuMDE5NTI4MSwzLjI3MzM2NTEyIEwxMi4wOTY0NDY2LDIuMTk2NDQ2NjEgQzEyLjI5MTcwODgsMi4wMDExODQ0NiAxMi42MDgyOTEyLDIuMDAxMTg0NDYgMTIuODAzNTUzNCwyLjE5NjQ0NjYxIEMxMi45OTg4MTU1LDIuMzkxNzA4NzYgMTIuOTk4ODE1NSwyLjcwODI5MTI0IDEyLjgwMzU1MzQsMi45MDM1NTMzOSBMMTEuNzI2NjM0OSwzLjk4MDQ3MTkgQzEyLjQyMTU5MzIsNC44MTQxNDQ2NSAxMi44NzQ1NDMzLDUuODU2NjIyOTEgMTIuOTc3NTc4NSw3IEwxNC41LDcgQzE0Ljc3NjE0MjQsNyAxNSw3LjIyMzg1NzYzIDE1LDcuNSBDMTUsNy43NzYxNDIzNyAxNC43NzYxNDI0LDggMTQuNSw4IEwxMi45Nzc1Nzg1LDggQzEyLjg3NDU0MzMsOS4xNDMzNzcwOSAxMi40MjE1OTMyLDEwLjE4NTg1NTQgMTEuNzI2NjM0OSwxMS4wMTk1MjgxIEwxMi44MDM1NTM0LDEyLjA5NjQ0NjYgQzEyLjk5ODgxNTUsMTIuMjkxNzA4OCAxMi45OTg4MTU1LDEyLjYwODI5MTIgMTIuODAzNTUzNCwxMi44MDM1NTM0IEMxMi42MDgyOTEyLDEyLjk5ODgxNTUgMTIuMjkxNzA4OCwxMi45OTg4MTU1IDEyLjA5NjQ0NjYsMTIuODAzNTUzNCBMMTEuMDE5NTI4MSwxMS43MjY2MzQ5IEMxMC4xODU4NTU0LDEyLjQyMTU5MzIgOS4xNDMzNzcwOSwxMi44NzQ1NDMzIDgsMTIuOTc3NTc4NSBMOCwxNC41IEM4LDE0Ljc3NjE0MjQgNy43NzYxNDIzNywxNSA3LjUsMTUgQzcuMjIzODU3NjMsMTUgNywxNC43NzYxNDI0IDcsMTQuNSBMNywxMi45Nzc1Nzg1IEM1Ljg1NjYyMjkxLDEyLjg3NDU0MzMgNC44MTQxNDQ2NSwxMi40MjE1OTMyIDMuOTgwNDcxOSwxMS43MjY2MzQ5IEwyLjkwMzU1MzM5LDEyLjgwMzU1MzQgQzIuNzA4MjkxMjQsMTIuOTk4ODE1NSAyLjM5MTcwODc2LDEyLjk5ODgxNTUgMi4xOTY0NDY2MSwxMi44MDM1NTM0IEMyLjAwMTE4NDQ2LDEyLjYwODI5MTIgMi4wMDExODQ0NiwxMi4yOTE3MDg4IDIuMTk2NDQ2NjEsMTIuMDk2NDQ2NiBMMy4yNzMzNjUxMiwxMS4wMTk1MjgxIEMyLjU3ODQwNjg0LDEwLjE4NTg1NTQgMi4xMjU0NTY3NCw5LjE0MzM3NzA5IDIuMDIyNDIxNTEsOCBMMC41LDggQzAuMjIzODU3NjI1LDggMCw3Ljc3NjE0MjM3IDAsNy41IEMwLDcuMjIzODU3NjMgMC4yMjM4NTc2MjUsNyAwLjUsNyBMMi4wMjI0MjE1MSw3IEMyLjEyNTQ1Njc0LDUuODU2NjIyOTEgMi41Nzg0MDY4NCw0LjgxNDE0NDY1IDMuMjczMzY1MTIsMy45ODA0NzE5IEwyLjE5NjQ0NjYxLDIuOTAzNTUzMzkgQzIuMDAxMTg0NDYsMi43MDgyOTEyNCAyLjAwMTE4NDQ2LDIuMzkxNzA4NzYgMi4xOTY0NDY2MSwyLjE5NjQ0NjYxIEMyLjM5MTcwODc2LDIuMDAxMTg0NDYgMi43MDgyOTEyNCwyLjAwMTE4NDQ2IDIuOTAzNTUzMzksMi4xOTY0NDY2MSBMMy45ODA0NzE5LDMuMjczMzY1MTIgWiBNNy41LDEwIEM2LjExOTI4ODEzLDEwIDUsOC44ODA3MTE4NyA1LDcuNSBDNSw2LjExOTI4ODEzIDYuMTE5Mjg4MTMsNSA3LjUsNSBDOC44ODA3MTE4Nyw1IDEwLDYuMTE5Mjg4MTMgMTAsNy41IEMxMCw4Ljg4MDcxMTg3IDguODgwNzExODcsMTAgNy41LDEwIFogTTcuNSw5IEM4LjMyODQyNzEyLDkgOSw4LjMyODQyNzEyIDksNy41IEM5LDYuNjcxNTcyODggOC4zMjg0MjcxMiw2IDcuNSw2IEM2LjY3MTU3Mjg4LDYgNiw2LjY3MTU3Mjg4IDYsNy41IEM2LDguMzI4NDI3MTIgNi42NzE1NzI4OCw5IDcuNSw5IFogTTcuNSwxMiBDOS45ODUyODEzNywxMiAxMiw5Ljk4NTI4MTM3IDEyLDcuNSBDMTIsNS4wMTQ3MTg2MyA5Ljk4NTI4MTM3LDMgNy41LDMgQzUuMDE0NzE4NjMsMyAzLDUuMDE0NzE4NjMgMyw3LjUgQzMsOS45ODUyODEzNyA1LjAxNDcxODYzLDEyIDcuNSwxMiBaIi8+PC9zdmc+');\n    --icon-Share: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNNi43NzQxMTAwNSw5LjE0NDEzMDI5IEw5Ljc1Njg3ODc2LDExLjAwNzg2NTEgQzEwLjMwNjQwNjUsMTAuMzg5NTUyMyAxMS4xMDc3MTA5LDEwIDEyLDEwIEMxMy42NTY4NTQyLDEwIDE1LDExLjM0MzE0NTggMTUsMTMgQzE1LDE0LjY1Njg1NDIgMTMuNjU2ODU0MiwxNiAxMiwxNiBDMTAuMzQzMTQ1OCwxNiA5LDE0LjY1Njg1NDIgOSwxMyBDOSwxMi41OTQ2NTcyIDkuMDgwMzg5NTMsMTIuMjA4MDkwNCA5LjIyNjA5NTA3LDExLjg1NTM3MyBMNi4yNDM0NzgyNCw5Ljk5MTczMzE3IEM1LjY5Mzk0MDU1LDEwLjYxMDI3OSA0Ljg5MjQ4MjM5LDExIDQsMTEgQzIuMzQzMTQ1NzUsMTEgMSw5LjY1Njg1NDI1IDEsOCBDMSw2LjM0MzE0NTc1IDIuMzQzMTQ1NzUsNSA0LDUgQzQuODkyNDgyMzksNSA1LjY5Mzk0MDU1LDUuMzg5NzIxMDMgNi4yNDM0NzgyNCw2LjAwODI2NjgzIEw5LjIyNjA5NTA3LDQuMTQ0NjI2OTYgQzkuMDgwMzg5NTMsMy43OTE5MDk2MyA5LDMuNDA1MzQyOCA5LDMgQzksMS4zNDMxNDU3NSAxMC4zNDMxNDU4LDAgMTIsMCBDMTMuNjU2ODU0MiwwIDE1LDEuMzQzMTQ1NzUgMTUsMyBDMTUsNC42NTY4NTQyNSAxMy42NTY4NTQyLDYgMTIsNiBDMTEuMTA3NzEwOSw2IDEwLjMwNjQwNjUsNS42MTA0NDc3MyA5Ljc1Njg3ODc2LDQuOTkyMTM0OTMgTDYuNzc0MTEwMDUsNi44NTU4Njk3MSBDNi45MTk2ODU5Miw3LjIwODQ1MzI1IDcsNy41OTQ4NDc0NCA3LDggQzcsOC40MDUxNTI1NiA2LjkxOTY4NTkyLDguNzkxNTQ2NzUgNi43NzQxMTAwNSw5LjE0NDEzMDI5IFogTTUuNzA0MTI0ODUsOS4wNDc0MTcxNiBDNS44OTE3NjA2Niw4Ljc0Mjc4NzM0IDYsOC4zODQwMzQgNiw4IEM2LDcuNjE1OTY2IDUuODkxNzYwNjYsNy4yNTcyMTI2NiA1LjcwNDEyNDg1LDYuOTUyNTgyODQgQzUuNzAxMzUzNDYsNi45NDg0MjU3NyA1LjY5ODYzNDQyLDYuOTQ0MjE0MzIgNS42OTU5NjkxOCw2LjkzOTk0ODggQzUuNjkzNDYyNDcsNi45MzU5MzY5OSA1LjY5MTAxOTU5LDYuOTMxOTAzMzggNS42ODg2NDAyOSw2LjkyNzg0OTE1IEM1LjMzMzc0OTkxLDYuMzcwMDYxNjUgNC43MTAwOTEyMiw2IDQsNiBDMi44OTU0MzA1LDYgMiw2Ljg5NTQzMDUgMiw4IEMyLDkuMTA0NTY5NSAyLjg5NTQzMDUsMTAgNCwxMCBDNC43MTAwOTEyMiwxMCA1LjMzMzc0OTkxLDkuNjI5OTM4MzUgNS42ODg2NDAyOSw5LjA3MjE1MDg1IEM1LjY5MTAxOTU5LDkuMDY4MDk2NjIgNS42OTM0NjI0Nyw5LjA2NDA2MzAxIDUuNjk1OTY5MTgsOS4wNjAwNTEyIEM1LjY5ODYzNDQyLDkuMDU1Nzg1NjggNS43MDEzNTM0Niw5LjA1MTU3NDIzIDUuNzA0MTI0ODUsOS4wNDc0MTcxNiBaIE0xMC4zMTk5MDkyLDExLjkxNDUyODMgQzEwLjMxNTIyNjgsMTEuOTIzMDg5NyAxMC4zMTAyNjgxLDExLjkzMTU2NjkgMTAuMzA1MDMwOCwxMS45Mzk5NDg4IEMxMC4yOTk1OTYsMTEuOTQ4NjQ2OCAxMC4yOTM5Mzc2LDExLjk1NzExOTkgMTAuMjg4MDY3NiwxMS45NjUzNjU1IEMxMC4xMDUyNCwxMi4yNjcyMjg2IDEwLDEyLjYyMTMyNDMgMTAsMTMgQzEwLDE0LjEwNDU2OTUgMTAuODk1NDMwNSwxNSAxMiwxNSBDMTMuMTA0NTY5NSwxNSAxNCwxNC4xMDQ1Njk1IDE0LDEzIEMxNCwxMS44OTU0MzA1IDEzLjEwNDU2OTUsMTEgMTIsMTEgQzExLjI5NTU3NjcsMTEgMTAuNjc2MjExNywxMS4zNjQxNzc1IDEwLjMxOTkwOTIsMTEuOTE0NTI4MyBaIE0xMC4yODgwNjc2LDQuMDM0NjM0NTQgQzEwLjI5MzkzNzYsNC4wNDI4ODAwOSAxMC4yOTk1OTYsNC4wNTEzNTMyNSAxMC4zMDUwMzA4LDQuMDYwMDUxMiBDMTAuMzEwMjY4MSw0LjA2ODQzMzE1IDEwLjMxNTIyNjgsNC4wNzY5MTAyNSAxMC4zMTk5MDkyLDQuMDg1NDcxNjggQzEwLjY3NjIxMTcsNC42MzU4MjI0NSAxMS4yOTU1NzY3LDUgMTIsNSBDMTMuMTA0NTY5NSw1IDE0LDQuMTA0NTY5NSAxNCwzIEMxNCwxLjg5NTQzMDUgMTMuMTA0NTY5NSwxIDEyLDEgQzEwLjg5NTQzMDUsMSAxMCwxLjg5NTQzMDUgMTAsMyBDMTAsMy4zNzg2NzU3NCAxMC4xMDUyNCwzLjczMjc3MTM3IDEwLjI4ODA2NzYsNC4wMzQ2MzQ1NCBaIi8+PC9zdmc+');\n    --icon-Skip: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMSIgaGVpZ2h0PSIyMSIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzE2QjM3OCIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMTcuMDYyNSAzLjgyMTA0SDE1LjgxMjVWMTYuMzIxSDE3LjA2MjVWMy44MjEwNFpNMTQuMjY0MyAxMC45NjQ4QzE0LjU3ODUgMTAuNzc2NiAxNC43NzA4IDEwLjQzNzMgMTQuNzcwOCAxMC4wNzFDMTQuNzcwOCA5LjcwNDggMTQuNTc4NSA5LjM2NTQ2IDE0LjI2NDMgOS4xNzczMkw1LjgyNjc3IDQuMTI1NTFDNS41MDQ5MyAzLjkzMjgxIDUuMTA0MzUgMy45MjgwNCA0Ljc3ODAyIDQuMTEzMDFDNC40NTE2OCA0LjI5Nzk4IDQuMjUgNC42NDQxMiA0LjI1IDUuMDE5MjNWMTUuMTIyOUM0LjI1IDE1LjQ5OCA0LjQ1MTY4IDE1Ljg0NDEgNC43NzgwMiAxNi4wMjkxQzUuMTA0MzUgMTYuMjE0IDUuNTA0OTMgMTYuMjA5MyA1LjgyNjc3IDE2LjAxNjZMMTQuMjY0MyAxMC45NjQ4Wk01LjI5MTY3IDUuMDE5MjNMMTMuNzI5MiAxMC4wNzFMNS4yOTE2NyAxNS4xMjI5TDUuMjkxNjcgNS4wMTkyM1oiIGNsaXAtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg==');\n    --icon-Smiley: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0ibm9uZSI+PGcgZmlsbD0iIzBGN0I1MSIgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBkPSJNOS42NzIyOCAyLjA4MzAzQzE2LjE4MTcgMS45ODMyOSAyMC4xODc2IDkuMzgzMzkgMTYuNTIyMyAxNC43ODU2QzEzLjM0MDYgMTkuNDc1NSA2LjMyNzM3IDE5LjUwNTggMy4xMDAzNCAxNC44NDYxQy0wLjU3MzAxMiA5LjU0MTU3IDMuMjQyNCAyLjE4Mjc3IDkuNjcyMjggMi4wODMwM1pNOS4xNjY1MSAzLjQwMDg0QzMuODE2NjcgMy44NjMyOSAxLjAxMTc5IDEwLjI5ODIgNC40NTQ0MiAxNC41MDA1QzcuMjM0MTIgMTcuODkzOCAxMi4yNzA2IDE3LjkyNiAxNS4wODE2IDE0LjU1NDlDMTguOTYzNSA5LjkwMDI0IDE1LjE1MTEgMi44ODE5OCA5LjE2NjUxIDMuNDAwODRaIi8+PHBhdGggZD0iTTEzLjgyMTIgMTAuOTI3N0MxMy41NjIzIDE2LjI0ODQgNi4wMjAxNiAxNi4yNDk0IDUuNzYxMjMgMTAuOTI3N0gxMy44MjEyWk0xMi4wMDc3IDEyLjIzNzVINy4zNzMyM0M4LjQwNTkyIDE0LjEwNTQgMTEuMDAzMyAxNC4xNTE3IDEyLjAwNzcgMTIuMjM3NVpNNi4wOTc4IDcuMjM0ODZDNy4xNTA2NCA2LjE4MjAyIDkuMTI1MzQgNy41NTkyNyA4LjA3OTU1IDguOTY0NzQgNi45Mjc5OCAxMC41MTIzIDQuODE3MjYgOC41MTUzOSA2LjA5NzggNy4yMzQ4NlpNMTEuNTM4MiA3LjIzNDg2QzEyLjU5MTEgNi4xODIwMiAxNC41NjU4IDcuNTU5MjcgMTMuNTIgOC45NjQ3NCAxMi4zNjg0IDEwLjUxMjMgMTAuMjU3NyA4LjUxNTM5IDExLjUzODIgNy4yMzQ4NloiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDE2LjI1VjE2LjI0SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxLjY2NyAyLjA4NCkiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');\n    --icon-Sort: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMy45OTkyNSwzLjcwNjU1Mzc3IEwzLjk5OTI1LDEzLjQ5OTI1IEMzLjk5OTI1LDEzLjc3NTI1IDMuNzc1MjUsMTMuOTk5MjUgMy40OTkyNSwxMy45OTkyNSBDMy4yMjMyNSwxMy45OTkyNSAyLjk5OTI1LDEzLjc3NTI1IDIuOTk5MjUsMTMuNDk5MjUgTDIuOTk5MjUsMy43MDY1NTM3NyBMMS44NTMyNSw0Ljg1MzI1IEMxLjY1ODI1LDUuMDQ4MjUgMS4zNDEyNSw1LjA0ODI1IDEuMTQ2MjUsNC44NTMyNSBDMC45NTEyNSw0LjY1ODI1IDAuOTUxMjUsNC4zNDEyNSAxLjE0NjI1LDQuMTQ2MjUgTDMuMTQ2MjUsMi4xNDYyNSBDMy4zNDEyNSwxLjk1MTI1IDMuNjU4MjUsMS45NTEyNSAzLjg1MzI1LDIuMTQ2MjUgTDUuODUzMjUsNC4xNDYyNSBDNi4wNDgyNSw0LjM0MTI1IDYuMDQ4MjUsNC42NTgyNSA1Ljg1MzI1LDQuODUzMjUgQzUuNzU1MjUsNC45NTAyNSA1LjYyNzI1LDQuOTk5MjUgNS40OTkyNSw0Ljk5OTI1IEM1LjM3MTI1LDQuOTk5MjUgNS4yNDMyNSw0Ljk1MDI1IDUuMTQ1MjUsNC44NTMyNSBMMy45OTkyNSwzLjcwNjU1Mzc3IFogTTguNDk5MjUsMy45OTkyNSBMNy40OTkyNSwzLjk5OTI1IEM3LjIyMzI1LDMuOTk5MjUgNi45OTkyNSwzLjc3NTI1IDYuOTk5MjUsMy40OTkyNSBDNi45OTkyNSwzLjIyMzI1IDcuMjIzMjUsMi45OTkyNSA3LjQ5OTI1LDIuOTk5MjUgTDguNDk5MjUsMi45OTkyNSBDOC43NzUyNSwyLjk5OTI1IDguOTk5MjUsMy4yMjMyNSA4Ljk5OTI1LDMuNDk5MjUgQzguOTk5MjUsMy43NzUyNSA4Ljc3NTI1LDMuOTk5MjUgOC40OTkyNSwzLjk5OTI1IFogTTEwLjQ5OTI1LDYuOTk5MjUgTDcuNDk5MjUsNi45OTkyNSBDNy4yMjMyNSw2Ljk5OTI1IDYuOTk5MjUsNi43NzUyNSA2Ljk5OTI1LDYuNDk5MjUgQzYuOTk5MjUsNi4yMjMyNSA3LjIyMzI1LDUuOTk5MjUgNy40OTkyNSw1Ljk5OTI1IEwxMC40OTkyNSw1Ljk5OTI1IEMxMC43NzUyNSw1Ljk5OTI1IDEwLjk5OTI1LDYuMjIzMjUgMTAuOTk5MjUsNi40OTkyNSBDMTAuOTk5MjUsNi43NzUyNSAxMC43NzUyNSw2Ljk5OTI1IDEwLjQ5OTI1LDYuOTk5MjUgWiBNMTIuNDk5MjUsOS45OTkyNSBMNy40OTkyNSw5Ljk5OTI1IEM3LjIyMzI1LDkuOTk5MjUgNi45OTkyNSw5Ljc3NTI1IDYuOTk5MjUsOS40OTkyNSBDNi45OTkyNSw5LjIyMzI1IDcuMjIzMjUsOC45OTkyNSA3LjQ5OTI1LDguOTk5MjUgTDEyLjQ5OTI1LDguOTk5MjUgQzEyLjc3NTI1LDguOTk5MjUgMTIuOTk5MjUsOS4yMjMyNSAxMi45OTkyNSw5LjQ5OTI1IEMxMi45OTkyNSw5Ljc3NTI1IDEyLjc3NTI1LDkuOTk5MjUgMTIuNDk5MjUsOS45OTkyNSBaIE0xNC40OTkyNSwxMi45OTkyNSBMNy40OTkyNSwxMi45OTkyNSBDNy4yMjMyNSwxMi45OTkyNSA2Ljk5OTI1LDEyLjc3NTI1IDYuOTk5MjUsMTIuNDk5MjUgQzYuOTk5MjUsMTIuMjIzMjUgNy4yMjMyNSwxMS45OTkyNSA3LjQ5OTI1LDExLjk5OTI1IEwxNC40OTkyNSwxMS45OTkyNSBDMTQuNzc1MjUsMTEuOTk5MjUgMTQuOTk5MjUsMTIuMjIzMjUgMTQuOTk5MjUsMTIuNDk5MjUgQzE0Ljk5OTI1LDEyLjc3NTI1IDE0Ljc3NTI1LDEyLjk5OTI1IDE0LjQ5OTI1LDEyLjk5OTI1IFoiIHRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIDAgMTYpIi8+PC9zdmc+');\n    --icon-Sparks: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NiIgaGVpZ2h0PSI0NCIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzI2MjYzMyIgZD0iTTM0LjYyNDggMzAuMjE5QzMzLjU1NzUgMjkuNDYxMiAzMi4yODkzIDI5LjAzNjkgMzAuOTgwOCAyOUMyOS4wNDE4IDI5LjAzNiAyNi4wNzQ4IDI5Ljk5MiAyMy45OTk4IDM0Ljc0N1YxMy45ODdDMjMuOTk5OCAxMy43MjE4IDIzLjg5NDQgMTMuNDY3NCAyMy43MDY5IDEzLjI3OTlDMjMuNTE5NCAxMy4wOTI0IDIzLjI2NSAxMi45ODcgMjIuOTk5OCAxMi45ODdDMjIuNzM0NiAxMi45ODcgMjIuNDgwMiAxMy4wOTI0IDIyLjI5MjcgMTMuMjc5OUMyMi4xMDUyIDEzLjQ2NzQgMjEuOTk5OCAxMy43MjE4IDIxLjk5OTggMTMuOTg3VjM0Ljc0N0MxOS45MjQ4IDI5Ljk5MiAxNi45NTc4IDI5LjAzNiAxNS4wMTg4IDI5SDE0LjkyNDhDMTMuNjQzNyAyOS4wMjkyIDEyLjQwMzIgMjkuNDU1NSAxMS4zNzQ4IDMwLjIyQzExLjI3MjIgMzAuMzAyMSAxMS4xODY4IDMwLjQwMzUgMTEuMTIzNSAzMC41MTg2QzExLjA2MDIgMzAuNjMzNyAxMS4wMjAxIDMwLjc2MDEgMTEuMDA1NiAzMC44OTA3QzEwLjk5MTEgMzEuMDIxMiAxMS4wMDI1IDMxLjE1MzQgMTEuMDM5MSAzMS4yNzk1QzExLjA3NTcgMzEuNDA1NyAxMS4xMzY3IDMxLjUyMzQgMTEuMjE4OCAzMS42MjZDMTEuMzAwOSAzMS43Mjg2IDExLjQwMjQgMzEuODE0IDExLjUxNzQgMzEuODc3M0MxMS42MzI1IDMxLjk0MDYgMTEuNzU4OSAzMS45ODA3IDExLjg4OTUgMzEuOTk1MkMxMi4wMjAxIDMyLjAwOTcgMTIuMTUyMiAzMS45OTgzIDEyLjI3ODQgMzEuOTYxN0MxMi40MDQ1IDMxLjkyNTEgMTIuNTIyMiAzMS44NjQxIDEyLjYyNDggMzEuNzgyQzEzLjI5NjEgMzEuMjk3MyAxNC4wOTcyIDMxLjAyNDkgMTQuOTI0OCAzMUgxNC45ODE4QzE4LjU0ODggMzEuMDY1IDIxLjEwODggMzUuNDgyIDIyLjAwNzggNDMuMTE3QzIyLjAzNjcgNDMuMzYwMSAyMi4xNTM4IDQzLjU4NDEgMjIuMzM2OCA0My43NDY2QzIyLjUxOTggNDMuOTA5MSAyMi43NTYgNDMuOTk4OSAyMy4wMDA4IDQzLjk5ODlDMjMuMjQ1NiA0My45OTg5IDIzLjQ4MTggNDMuOTA5MSAyMy42NjQ4IDQzLjc0NjZDMjMuODQ3OCA0My41ODQxIDIzLjk2NDkgNDMuMzYwMSAyMy45OTM4IDQzLjExN0MyNC44OTM4IDM1LjQ4MiAyNy40NTI4IDMxLjA2NSAzMS4wMTk4IDMxSDMxLjA3NjhDMzEuOTA0MyAzMS4wMjgyIDMyLjcwNDcgMzEuMzAxNCAzMy4zNzY4IDMxLjc4NUMzMy41ODQ3IDMxLjk0NTggMzMuODQ3NiAzMi4wMTgzIDM0LjEwODUgMzEuOTg2OEMzNC4zNjk1IDMxLjk1NTIgMzQuNjA3NSAzMS44MjIyIDM0Ljc3MTIgMzEuNjE2NUMzNC45MzQ4IDMxLjQxMDggMzUuMDEwOSAzMS4xNDkgMzQuOTgyOSAzMC44ODc2QzM0Ljk1NSAzMC42MjYzIDM0LjgyNTIgMzAuMzg2NCAzNC42MjE4IDMwLjIyTDM0LjYyNDggMzAuMjE5WiIvPjxwYXRoIGZpbGw9IiMxNkIzNzgiIGQ9Ik0xMC4yNTMxIDQyQzEwLjA5MSA0MS45OTk5IDkuOTMxNDUgNDEuOTYwNCA5Ljc4ODA2IDQxLjg4NUw3LjAwMDA2IDQwLjQyIDQuMjEyMDYgNDEuODg1QzQuMDQ2OTQgNDEuOTcxNSAzLjg2MDkxIDQyLjAxMDIgMy42NzQ5OCA0MS45OTY2IDMuNDg5MDUgNDEuOTgzMSAzLjMxMDYxIDQxLjkxNzggMy4xNTk4MiA0MS44MDgyIDMuMDA5MDIgNDEuNjk4NiAyLjg5MTg3IDQxLjU0OSAyLjgyMTU4IDQxLjM3NjMgMi43NTEzIDQxLjIwMzYgMi43MzA2OCA0MS4wMTQ3IDIuNzYyMDYgNDAuODMxTDMuMjk0MDYgMzcuNzMxIDEuMDM4MDYgMzUuNTMxQy45MDYwODIgMzUuNDAwNS44MTMwMzUgMzUuMjM1OC43NjkzMyAzNS4wNTU0LjcyNTYyNSAzNC44NzUuNzMyOTg2IDM0LjY4Ni43OTA1ODkgMzQuNTA5Ni44NDgxOTIgMzQuMzMzMi45NTM3NjQgMzQuMTc2MiAxLjA5NTQ5IDM0LjA1NjQgMS4yMzcyMSAzMy45MzY1IDEuNDA5NSAzMy44NTg1IDEuNTkzMDYgMzMuODMxTDQuNzA5MDYgMzMuMzc3IDYuMTA5MDYgMzAuNTUzQzYuMjAwNzQgMzAuMzk2OCA2LjMzMTY2IDMwLjI2NzMgNi40ODg4MyAzMC4xNzczIDYuNjQ2IDMwLjA4NzMgNi44MjM5NSAzMC4wNCA3LjAwNTA2IDMwLjA0IDcuMTg2MTYgMzAuMDQgNy4zNjQxMSAzMC4wODczIDcuNTIxMjggMzAuMTc3MyA3LjY3ODQ1IDMwLjI2NzMgNy44MDkzNyAzMC4zOTY4IDcuOTAxMDYgMzAuNTUzTDkuMjk2MDUgMzMuMzc3IDEyLjQxMjEgMzMuODMxQzEyLjU5NTYgMzMuODU4NSAxMi43Njc5IDMzLjkzNjUgMTIuOTA5NiAzNC4wNTY0IDEzLjA1MTMgMzQuMTc2MiAxMy4xNTY5IDM0LjMzMzIgMTMuMjE0NSAzNC41MDk2IDEzLjI3MjEgMzQuNjg2IDEzLjI3OTUgMzQuODc1IDEzLjIzNTggMzUuMDU1NCAxMy4xOTIxIDM1LjIzNTggMTMuMDk5IDM1LjQwMDUgMTIuOTY3MSAzNS41MzFMMTAuNzExMSAzNy43MzEgMTEuMjQzMSA0MC44MzFDMTEuMjY3NyA0MC45NzQ5IDExLjI2MDYgNDEuMTIyNCAxMS4yMjIyIDQxLjI2MzMgMTEuMTgzNyA0MS40MDQxIDExLjExNDkgNDEuNTM0OCAxMS4wMjA2IDQxLjY0NjMgMTAuOTI2MiA0MS43NTc3IDEwLjgwODYgNDEuODQ3MSAxMC42NzYgNDEuOTA4MiAxMC41NDM0IDQxLjk2OTMgMTAuMzk5MSA0Mi4wMDA2IDEwLjI1MzEgNDJaTTI2LjI1MzEgMTJDMjYuMDkxIDExLjk5OTkgMjUuOTMxNSAxMS45NjA0IDI1Ljc4ODEgMTEuODg1TDIzLjAwMDEgMTAuNDIgMjAuMjEyMSAxMS44ODVDMjAuMDQ2OSAxMS45NzE1IDE5Ljg2MDkgMTIuMDEwMiAxOS42NzUgMTEuOTk2NiAxOS40ODkxIDExLjk4MzEgMTkuMzEwNiAxMS45MTc4IDE5LjE1OTggMTEuODA4MiAxOS4wMDkgMTEuNjk4NiAxOC44OTE5IDExLjU0OSAxOC44MjE2IDExLjM3NjMgMTguNzUxMyAxMS4yMDM2IDE4LjczMDcgMTEuMDE0OCAxOC43NjIxIDEwLjgzMUwxOS4yOTQxIDcuNzMwOTkgMTcuMDM4MSA1LjUzMDk5QzE2LjkwNjEgNS40MDA0OCAxNi44MTMgNS4yMzU4MSAxNi43NjkzIDUuMDU1NDIgMTYuNzI1NiA0Ljg3NTAzIDE2LjczMyA0LjY4NjA0IDE2Ljc5MDYgNC41MDk2IDE2Ljg0ODIgNC4zMzMxNiAxNi45NTM4IDQuMTc2MjMgMTcuMDk1NSA0LjA1NjM4IDE3LjIzNzIgMy45MzY1MyAxNy40MDk1IDMuODU4NDggMTcuNTkzMSAzLjgzMDk5TDIwLjcwOTEgMy4zNzY5OSAyMi4xMDAxLjU0NDk4NkMyMi4xOTE3LjM4ODgwOSAyMi4zMjI3LjI1OTMxMSAyMi40Nzk4LjE2OTMzIDIyLjYzNy4wNzkzNSAyMi44MTUuMDMyMDEyOSAyMi45OTYxLjAzMjAxMjkgMjMuMTc3Mi4wMzIwMTI5IDIzLjM1NTEuMDc5MzUgMjMuNTEyMy4xNjkzMyAyMy42Njk0LjI1OTMxMSAyMy44MDA0LjM4ODgwOSAyMy44OTIxLjU0NDk4NkwyNS4yODcxIDMuMzY4OTkgMjguNDAzMSAzLjgyMjk5QzI4LjU4NjYgMy44NTA0OCAyOC43NTg5IDMuOTI4NTMgMjguOTAwNiA0LjA0ODM4IDI5LjA0MjMgNC4xNjgyMyAyOS4xNDc5IDQuMzI1MTYgMjkuMjA1NSA0LjUwMTYgMjkuMjYzMSA0LjY3ODA0IDI5LjI3MDUgNC44NjcwMyAyOS4yMjY4IDUuMDQ3NDIgMjkuMTgzMSA1LjIyNzgxIDI5LjA5IDUuMzkyNDcgMjguOTU4MSA1LjUyMjk5TDI2LjcwMjEgNy43MjI5OSAyNy4yMzQxIDEwLjgyM0MyNy4yNTk5IDEwLjk2NjcgMjcuMjU0IDExLjExNDQgMjcuMjE2NiAxMS4yNTU2IDI3LjE3OTMgMTEuMzk2OCAyNy4xMTE1IDExLjUyODEgMjcuMDE4IDExLjY0MDIgMjYuOTI0NSAxMS43NTI0IDI2LjgwNzYgMTEuODQyOCAyNi42NzU0IDExLjkwNDkgMjYuNTQzMyAxMS45NjcxIDI2LjM5OTEgMTEuOTk5NSAyNi4yNTMxIDEyWk00Mi4yNTMxIDQyQzQyLjA5MSA0MS45OTk5IDQxLjkzMTQgNDEuOTYwNCA0MS43ODgxIDQxLjg4NUwzOS4wMDAxIDQwLjQyIDM2LjIxMjEgNDEuODg1QzM2LjA0NjkgNDEuOTcxNSAzNS44NjA5IDQyLjAxMDIgMzUuNjc1IDQxLjk5NjYgMzUuNDg5IDQxLjk4MzEgMzUuMzEwNiA0MS45MTc4IDM1LjE1OTggNDEuODA4MiAzNS4wMDkgNDEuNjk4NiAzNC44OTE5IDQxLjU0OSAzNC44MjE2IDQxLjM3NjMgMzQuNzUxMyA0MS4yMDM2IDM0LjczMDcgNDEuMDE0OCAzNC43NjIxIDQwLjgzMUwzNS4yOTQxIDM3LjczMSAzMy4wMzgxIDM1LjUzMUMzMi45MDYxIDM1LjQwMDUgMzIuODEzIDM1LjIzNTggMzIuNzY5MyAzNS4wNTU0IDMyLjcyNTYgMzQuODc1IDMyLjczMyAzNC42ODYgMzIuNzkwNiAzNC41MDk2IDMyLjg0ODIgMzQuMzMzMiAzMi45NTM4IDM0LjE3NjIgMzMuMDk1NSAzNC4wNTY0IDMzLjIzNzIgMzMuOTM2NSAzMy40MDk1IDMzLjg1ODUgMzMuNTkzMSAzMy44MzFMMzYuNzA5MSAzMy4zNzcgMzguMTAwMSAzMC41NDVDMzguMTkxNyAzMC4zODg4IDM4LjMyMjcgMzAuMjU5MyAzOC40Nzk4IDMwLjE2OTMgMzguNjM3IDMwLjA3OTQgMzguODE0OSAzMC4wMzIgMzguOTk2MSAzMC4wMzIgMzkuMTc3MiAzMC4wMzIgMzkuMzU1MSAzMC4wNzk0IDM5LjUxMjMgMzAuMTY5MyAzOS42Njk0IDMwLjI1OTMgMzkuODAwNCAzMC4zODg4IDM5Ljg5MjEgMzAuNTQ1TDQxLjI4NzEgMzMuMzY5IDQ0LjQwMzEgMzMuODIzQzQ0LjU4NjYgMzMuODUwNSA0NC43NTg5IDMzLjkyODUgNDQuOTAwNiAzNC4wNDg0IDQ1LjA0MjMgMzQuMTY4MiA0NS4xNDc5IDM0LjMyNTIgNDUuMjA1NSAzNC41MDE2IDQ1LjI2MzEgMzQuNjc4IDQ1LjI3MDUgMzQuODY3IDQ1LjIyNjggMzUuMDQ3NCA0NS4xODMxIDM1LjIyNzggNDUuMDkgMzUuMzkyNSA0NC45NTgxIDM1LjUyM0w0Mi43MDIxIDM3LjcyMyA0My4yMzQxIDQwLjgyM0M0My4yNTk5IDQwLjk2NjcgNDMuMjU0IDQxLjExNDQgNDMuMjE2NiA0MS4yNTU2IDQzLjE3OTMgNDEuMzk2OCA0My4xMTE1IDQxLjUyODEgNDMuMDE4IDQxLjY0MDIgNDIuOTI0NSA0MS43NTI0IDQyLjgwNzYgNDEuODQyOCA0Mi42NzU0IDQxLjkwNDkgNDIuNTQzMyA0MS45NjcxIDQyLjM5OTEgNDEuOTk5NSA0Mi4yNTMxIDQyWiIvPjxwYXRoIGZpbGw9IiNFNkExMTciIGQ9Ik05IDE0QzguMjA0MzUgMTQgNy40NDEyOSAxMy42ODM5IDYuODc4NjggMTMuMTIxMyA2LjMxNjA3IDEyLjU1ODcgNiAxMS43OTU2IDYgMTEgNiAxMC43MzQ4IDUuODk0NjQgMTAuNDgwNCA1LjcwNzExIDEwLjI5MjkgNS41MTk1NyAxMC4xMDU0IDUuMjY1MjIgMTAgNSAxMCA0LjczNDc4IDEwIDQuNDgwNDMgMTAuMTA1NCA0LjI5Mjg5IDEwLjI5MjkgNC4xMDUzNiAxMC40ODA0IDQgMTAuNzM0OCA0IDExIDQgMTEuNzk1NiAzLjY4MzkzIDEyLjU1ODcgMy4xMjEzMiAxMy4xMjEzIDIuNTU4NzEgMTMuNjgzOSAxLjc5NTY1IDE0IDEgMTQgLjczNDc4NCAxNCAuNDgwNDMgMTQuMTA1NC4yOTI4OTMgMTQuMjkyOS4xMDUzNTcgMTQuNDgwNCAwIDE0LjczNDggMCAxNSAwIDE1LjI2NTIuMTA1MzU3IDE1LjUxOTYuMjkyODkzIDE1LjcwNzEuNDgwNDMgMTUuODk0Ni43MzQ3ODQgMTYgMSAxNiAxLjc5NTY1IDE2IDIuNTU4NzEgMTYuMzE2MSAzLjEyMTMyIDE2Ljg3ODcgMy42ODM5MyAxNy40NDEzIDQgMTguMjA0NCA0IDE5IDQgMTkuMjY1MiA0LjEwNTM2IDE5LjUxOTYgNC4yOTI4OSAxOS43MDcxIDQuNDgwNDMgMTkuODk0NiA0LjczNDc4IDIwIDUgMjAgNS4yNjUyMiAyMCA1LjUxOTU3IDE5Ljg5NDYgNS43MDcxMSAxOS43MDcxIDUuODk0NjQgMTkuNTE5NiA2IDE5LjI2NTIgNiAxOSA2IDE4LjIwNDQgNi4zMTYwNyAxNy40NDEzIDYuODc4NjggMTYuODc4NyA3LjQ0MTI5IDE2LjMxNjEgOC4yMDQzNSAxNiA5IDE2IDkuMjY1MjIgMTYgOS41MTk1NyAxNS44OTQ2IDkuNzA3MTEgMTUuNzA3MSA5Ljg5NDY0IDE1LjUxOTYgMTAgMTUuMjY1MiAxMCAxNSAxMCAxNC43MzQ4IDkuODk0NjQgMTQuNDgwNCA5LjcwNzExIDE0LjI5MjkgOS41MTk1NyAxNC4xMDU0IDkuMjY1MjIgMTQgOSAxNFpNNDUgMTMuOTg3QzQ0LjIwNDQgMTMuOTg3IDQzLjQ0MTMgMTMuNjcwOSA0Mi44Nzg3IDEzLjEwODMgNDIuMzE2MSAxMi41NDU3IDQyIDExLjc4MjYgNDIgMTAuOTg3IDQyIDEwLjcyMTggNDEuODk0NiAxMC40Njc0IDQxLjcwNzEgMTAuMjc5OSA0MS41MTk2IDEwLjA5MjQgNDEuMjY1MiA5Ljk4NyA0MSA5Ljk4NyA0MC43MzQ4IDkuOTg3IDQwLjQ4MDQgMTAuMDkyNCA0MC4yOTI5IDEwLjI3OTkgNDAuMTA1NCAxMC40Njc0IDQwIDEwLjcyMTggNDAgMTAuOTg3IDQwIDExLjc4MjYgMzkuNjgzOSAxMi41NDU3IDM5LjEyMTMgMTMuMTA4MyAzOC41NTg3IDEzLjY3MDkgMzcuNzk1NiAxMy45ODcgMzcgMTMuOTg3IDM2LjczNDggMTMuOTg3IDM2LjQ4MDQgMTQuMDkyNCAzNi4yOTI5IDE0LjI3OTkgMzYuMTA1NCAxNC40Njc0IDM2IDE0LjcyMTggMzYgMTQuOTg3IDM2IDE1LjI1MjIgMzYuMTA1NCAxNS41MDY2IDM2LjI5MjkgMTUuNjk0MSAzNi40ODA0IDE1Ljg4MTYgMzYuNzM0OCAxNS45ODcgMzcgMTUuOTg3IDM3Ljc5NTYgMTUuOTg3IDM4LjU1ODcgMTYuMzAzMSAzOS4xMjEzIDE2Ljg2NTcgMzkuNjgzOSAxNy40MjgzIDQwIDE4LjE5MTMgNDAgMTguOTg3IDQwIDE5LjI1MjIgNDAuMTA1NCAxOS41MDY2IDQwLjI5MjkgMTkuNjk0MSA0MC40ODA0IDE5Ljg4MTYgNDAuNzM0OCAxOS45ODcgNDEgMTkuOTg3IDQxLjI2NTIgMTkuOTg3IDQxLjUxOTYgMTkuODgxNiA0MS43MDcxIDE5LjY5NDEgNDEuODk0NiAxOS41MDY2IDQyIDE5LjI1MjIgNDIgMTguOTg3IDQyIDE4LjE5MTMgNDIuMzE2MSAxNy40MjgzIDQyLjg3ODcgMTYuODY1NyA0My40NDEzIDE2LjMwMzEgNDQuMjA0NCAxNS45ODcgNDUgMTUuOTg3IDQ1LjI2NTIgMTUuOTg3IDQ1LjUxOTYgMTUuODgxNiA0NS43MDcxIDE1LjY5NDEgNDUuODk0NiAxNS41MDY2IDQ2IDE1LjI1MjIgNDYgMTQuOTg3IDQ2IDE0LjcyMTggNDUuODk0NiAxNC40Njc0IDQ1LjcwNzEgMTQuMjc5OSA0NS41MTk2IDE0LjA5MjQgNDUuMjY1MiAxMy45ODcgNDUgMTMuOTg3WiIvPjxwYXRoIGZpbGw9IiMyNjI2MzMiIGQ9Ik0xOS4wMDAyIDI4QzE4LjgxMzYgMjguMDAxIDE4LjYzMDMgMjcuOTQ5NyAxOC40NzEzIDI3Ljg1MiAxOC4zMTIyIDI3Ljc1NDMgMTguMTgzNyAyNy42MTQgMTguMTAwMiAyNy40NDcgMTQuNDUzMiAyMC4xNTMgOS4yMDgyMyAxOS45OSA4Ljk4NjIzIDE5Ljk4NyA4Ljg1NDkxIDE5Ljk4NjQgOC43MjQ5OSAxOS45NTk5IDguNjAzODkgMTkuOTA5MSA4LjQ4Mjc5IDE5Ljg1ODMgOC4zNzI4OCAxOS43ODQyIDguMjgwNDQgMTkuNjkwOSA4LjA5Mzc1IDE5LjUwMjUgNy45ODk1NCAxOS4yNDc3IDcuOTkwNzMgMTguOTgyNSA3Ljk5MTkzIDE4LjcxNzMgOC4wOTg0MyAxOC40NjM0IDguMjg2ODEgMTguMjc2NyA4LjQ3NTE5IDE4LjA5IDguNzMwMDIgMTcuOTg1OCA4Ljk5NTIzIDE3Ljk4NyA5LjI2NTIzIDE3Ljk4NyAxNS42NjAyIDE4LjA5OSAxOS44ODgyIDI2LjU1MyAxOS45NjQyIDI2LjcwNDkgMjAuMDAwMSAyNi44NzM4IDE5Ljk5MjggMjcuMDQzNSAxOS45ODU0IDI3LjIxMzIgMTkuOTM0OSAyNy4zNzgzIDE5Ljg0NiAyNy41MjMgMTkuNzU3MiAyNy42Njc4IDE5LjYzMjkgMjcuNzg3NiAxOS40ODQ5IDI3Ljg3MTEgMTkuMzM2OSAyNy45NTQ1IDE5LjE3MDEgMjcuOTk4OSAxOS4wMDAyIDI4Wk0yNyAyOEMyNi44Mjk2IDI3Ljk5OTkgMjYuNjYyIDI3Ljk1NjMgMjYuNTEzMiAyNy44NzMzIDI2LjM2NDQgMjcuNzkwMiAyNi4yMzkzIDI3LjY3MDYgMjYuMTQ5OCAyNy41MjU2IDI2LjA2MDIgMjcuMzgwNiAyNi4wMDkyIDI3LjIxNTIgMjYuMDAxNSAyNy4wNDUgMjUuOTkzOCAyNi44NzQ4IDI2LjAyOTggMjYuNzA1NCAyNi4xMDYgMjYuNTUzIDMwLjMzMyAxOC4xIDM2LjcyOCAxNy45ODggMzcgMTcuOTg3IDM3LjI2NTIgMTcuOTg3IDM3LjUxOTUgMTguMDkyNCAzNy43MDcxIDE4LjI3OTkgMzcuODk0NiAxOC40Njc0IDM4IDE4LjcyMTggMzggMTguOTg3IDM4IDE5LjI1MjIgMzcuODk0NiAxOS41MDY2IDM3LjcwNzEgMTkuNjk0MSAzNy41MTk1IDE5Ljg4MTYgMzcuMjY1MiAxOS45ODcgMzcgMTkuOTg3IDM2Ljc4NSAxOS45ODcgMzEuNTQgMjAuMTUzIDI3Ljg5MyAyNy40NDcgMjcuODEgMjcuNjEyOSAyNy42ODI2IDI3Ljc1MjUgMjcuNTI0OSAyNy44NTAyIDI3LjM2NzIgMjcuOTQ3OCAyNy4xODU0IDI3Ljk5OTcgMjcgMjhaIi8+PC9zdmc+');\n    --icon-Star: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNSIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzE2QjM3OCIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMi4yNDA4MSA0Ljg0OTIzQzIuNjAwMjMgNC40Mjk5IDMuMjMxNTMgNC4zODEzNCAzLjY1MDg2IDQuNzQwNzZMNy4xNTA4NiA3Ljc0MDc2QzcuNTcwMTggOC4xMDAxOCA3LjYxODc1IDguNzMxNDggNy4yNTkzMiA5LjE1MDgxQzYuODk5OSA5LjU3MDE0IDYuMjY4NiA5LjYxODcgNS44NDkyOCA5LjI1OTI3TDIuMzQ5MjggNi4yNTkyN0MxLjkyOTk1IDUuODk5ODUgMS44ODEzOSA1LjI2ODU1IDIuMjQwODEgNC44NDkyM1pNMTEuNSA3LjAyMDgxQzExLjEzNyA3LjAyMDgxIDEwLjgwNTIgNy4yMjU5IDEwLjY0MjkgNy41NTA1N0w4Ljg3NDU1IDExLjA4NzJMNC45ODcwOSAxMS42NTI3QzQuNjI2MTYgMTEuNzA1MiA0LjMyNjMxIDExLjk1OCA0LjIxMzYgMTIuMzA0OUM0LjEwMDkgMTIuNjUxOCA0LjE5NDg3IDEzLjAzMjYgNC40NTYwMiAxMy4yODcyTDcuMjg2MTkgMTYuMDQ2Nkw2LjY0OTU5IDE5LjkzNjlDNi41OTA3OSAyMC4yOTYyIDYuNzQwNzEgMjAuNjU3OCA3LjAzNjUxIDIwLjg3MDJDNy4zMzIzMSAyMS4wODI1IDcuNzIyODkgMjEuMTA4OSA4LjA0NDUzIDIwLjkzODJMMTEuNSAxOS4xMDQ3TDE0Ljk1NTUgMjAuOTM4MkMxNS4yNzcyIDIxLjEwODkgMTUuNjY3OCAyMS4wODI1IDE1Ljk2MzYgMjAuODcwMkMxNi4yNTk0IDIwLjY1NzggMTYuNDA5MyAyMC4yOTYyIDE2LjM1MDUgMTkuOTM2OUwxNS43MTM5IDE2LjA0NjZMMTguNTQ0IDEzLjI4NzJDMTguODA1MiAxMy4wMzI2IDE4Ljg5OTIgMTIuNjUxOCAxOC43ODY1IDEyLjMwNDlDMTguNjczOCAxMS45NTggMTguMzczOSAxMS43MDUyIDE4LjAxMyAxMS42NTI3TDE0LjEyNTUgMTEuMDg3MkwxMi4zNTcyIDcuNTUwNTdDMTIuMTk0OSA3LjIyNTkgMTEuODYzIDcuMDIwODEgMTEuNSA3LjAyMDgxWk0xMC4zNjUgMTIuMzkyMUwxMS41IDEwLjEyMkwxMi42MzUxIDEyLjM5MjFDMTIuNzc1MyAxMi42NzI2IDEzLjA0MzkgMTIuODY2NyAxMy4zNTQzIDEyLjkxMTlMMTUuODE2MiAxMy4yN0wxNC4wMTg1IDE1LjAyMjdDMTMuNzk0NiAxNS4yNDEgMTMuNjkxMyAxNS41NTQ5IDEzLjc0MTggMTUuODYzNkwxNC4xNDY5IDE4LjMzOTNMMTEuOTQ5MiAxNy4xNzMyQzExLjY2ODMgMTcuMDI0MiAxMS4zMzE4IDE3LjAyNDIgMTEuMDUwOCAxNy4xNzMyTDguODUzMTcgMTguMzM5M0w5LjI1ODI5IDE1Ljg2MzZDOS4zMDg4IDE1LjU1NDkgOS4yMDU1IDE1LjI0MSA4Ljk4MTU1IDE1LjAyMjdMNy4xODM5MSAxMy4yN0w5LjY0NTc5IDEyLjkxMTlDOS45NTYxMyAxMi44NjY3IDEwLjIyNDggMTIuNjcyNiAxMC4zNjUgMTIuMzkyMVpNMTkuMzQ5MiA0Ljc0MDc2QzE5Ljc2ODYgNC4zODEzNCAyMC4zOTk5IDQuNDI5OSAyMC43NTkzIDQuODQ5MjNDMjEuMTE4NyA1LjI2ODU1IDIxLjA3MDEgNS44OTk4NSAyMC42NTA4IDYuMjU5MjdMMTcuMTUwOCA5LjI1OTI3QzE2LjczMTUgOS42MTg3IDE2LjEwMDIgOS41NzAxNCAxNS43NDA4IDkuMTUwODFDMTUuMzgxNCA4LjczMTQ4IDE1LjQyOTkgOC4xMDAxOCAxNS44NDkyIDcuNzQwNzZMMTkuMzQ5MiA0Ljc0MDc2Wk0yMi4yNzUzIDE5LjYzMTZDMjEuOTI2NSAyMC4wNTk4IDIxLjI5NjYgMjAuMTI0MSAyMC44Njg0IDE5Ljc3NTNMMTguMzY4NCAxNy43Mzg2QzE3Ljk0MDMgMTcuMzg5NyAxNy44NzYgMTYuNzU5OCAxOC4yMjQ4IDE2LjMzMTdDMTguNTczNiAxNS45MDM1IDE5LjIwMzUgMTUuODM5MiAxOS42MzE3IDE2LjE4OEwyMi4xMzE3IDE4LjIyNDdDMjIuNTU5OSAxOC41NzM2IDIyLjYyNDIgMTkuMjAzNSAyMi4yNzUzIDE5LjYzMTZaTTIuMTMxNjUgMTkuNzc1M0MxLjcwMzQ3IDIwLjEyNDEgMS4wNzM1OCAyMC4wNTk4IDAuNzI0NzUyIDE5LjYzMTZDMC4zNzU5MTkgMTkuMjAzNSAwLjQ0MDIzOCAxOC41NzM2IDAuODY4NDEzIDE4LjIyNDdMMy4zNjg0MSAxNi4xODhDMy43OTY1OSAxNS44MzkyIDQuNDI2NDggMTUuOTAzNSA0Ljc3NTMxIDE2LjMzMTdDNS4xMjQxNCAxNi43NTk4IDUuMDU5ODIgMTcuMzg5NyA0LjYzMTY1IDE3LjczODZMMi4xMzE2NSAxOS43NzUzWiIgY2xpcC1ydWxlPSJldmVub2RkIi8+PC9zdmc+');\n    --icon-Stop: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzIxMjEyMSIgZD0iTTE0LDFIMkMxLjQsMSwxLDEuNCwxLDJ2MTJjMCwwLjYsMC40LDEsMSwxaDEyYzAuNiwwLDEtMC40LDEtMVYyQzE1LDEuNCwxNC42LDEsMTQsMXoiIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiLz48L3N2Zz4=');\n    --icon-Tick: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTEuNjE4MzA2OSw0LjY3NzAyODQ3IEMxMS43OTY2Nzg5LDQuNDY2MjI1MTcgMTIuMTEyMTY3OCw0LjQzOTkzNDQzIDEyLjMyMjk3MTEsNC42MTgzMDY0NSBDMTIuNTMzNzc0NCw0Ljc5NjY3ODQ4IDEyLjU2MDA2NTIsNS4xMTIxNjc0MSAxMi4zODE2OTMxLDUuMzIyOTcwNzEgTDYuNTMwNjg4MjcsMTIuMjM3Nzk0NiBMMy42NDY0NDY2MSw5LjM1MzU1Mjk4IEMzLjQ1MTE4NDQ2LDkuMTU4MjkwODQgMy40NTExODQ0Niw4Ljg0MTcwODM1IDMuNjQ2NDQ2NjEsOC42NDY0NDYyIEMzLjg0MTcwODc2LDguNDUxMTg0MDYgNC4xNTgyOTEyNCw4LjQ1MTE4NDA2IDQuMzUzNTUzMzksOC42NDY0NDYyIEw2LjQ2OTMxMTczLDEwLjc2MjIwNDUgTDExLjYxODMwNjksNC42NzcwMjg0NyBaIi8+PC9zdmc+');\n    --icon-TickSolid: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0ibSA2MS40NCwwIGMgMzMuOTMzLDAgNjEuNDQxLDI3LjUwNyA2MS40NDEsNjEuNDM5IDAsMzMuOTMzIC0yNy41MDgsNjEuNDQgLTYxLjQ0MSw2MS40NCBDIDI3LjUwOCwxMjIuODggMCw5NS4zNzIgMCw2MS40MzkgMCwyNy41MDcgMjcuNTA4LDAgNjEuNDQsMCBaIE0gMzQuMTA2LDY3LjY3OCAzNC4wOTEsNjcuNjY0IGMgLTAuNzg1LC0wLjcxOCAtMS4yMDcsLTEuNjg1IC0xLjI1NiwtMi42NjkgLTAuMDQ5LC0wLjk4MiAwLjI3NSwtMS45ODUgMC45ODQsLTIuNzc3IDAuMDEsLTAuMDExIDAuMDE5LC0wLjAyMSAwLjAyOSwtMC4wMzEgMC43MTcsLTAuNzg0IDEuNjg0LC0xLjIwNyAyLjY2OCwtMS4yNTYgMC45ODksLTAuMDQ5IDEuOTk4LDAuMjggMi43OTIsMC45OTggTCA1Mi4yNjQsNzMuNjc3IDgzLjM1Myw0MS4xMTggdiAwIGMgMC43NCwtMC43NzYgMS43MjMsLTEuMTggMi43MTksLTEuMjA0IDAuOTkyLC0wLjAyNSAxLjk5NCwwLjMyOSAyLjc3MSwxLjA2NyB2IDEwZS00IGMgMC43NzcsMC43MzkgMS4xOCwxLjcyNCAxLjIwNSwyLjcxOCAwLjAyNSwwLjk5MyAtMC4zMywxLjk5NyAtMS4wNjgsMi43NzMgTCA1NS4yNzksODEuNzY5IGMgLTAuMDIzLDAuMDI0IC0wLjA0OCwwLjA0NyAtMC4wNzMsMC4wNjcgLTAuNzE1LDAuNzE1IC0xLjY0OSwxLjA5NSAtMi41OTgsMS4xMyAtMC45NzQsMC4wMzcgLTEuOTYzLC0wLjI5MyAtMi43NDQsLTEgTCAzNC4xMTgsNjcuNjg4IFoiIHN0eWxlPSJjbGlwLXJ1bGU6ZXZlbm9kZDtmaWxsLXJ1bGU6ZXZlbm9kZCIgdHJhbnNmb3JtPSJtYXRyaXgoLjEzMDIgMCAwIC4xMzAyIDAgMCkiLz48L3N2Zz4=');\n    --icon-TickSwitch: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5IiBoZWlnaHQ9IjgiIGZpbGw9Im5vbmUiPjxwYXRoIGZpbGw9IiMxMjg3NUMiIGQ9Ik04LjQ3MTE3IDAuOTA3NzY4QzguNjYwMzYgMS4wNzU5NCA4LjY3NzQxIDEuMzY1NjQgOC41MDkyNCAxLjU1NDgzTDMuNjIwMzUgNy4wNTQ4M0MzLjUzMzM3IDcuMTUyNjggMy40MDg3IDcuMjA4NjYgMy4yNzc3OCA3LjIwODY2QzMuMTQ2ODcgNy4yMDg2NiAzLjAyMjIgNy4xNTI2OCAyLjkzNTIyIDcuMDU0ODNMMC40OTA3NzYgNC4zMDQ4M0MwLjMyMjYwNSA0LjExNTY0IDAuMzM5NjQ2IDMuODI1OTQgMC41Mjg4MzggMy42NTc3N0MwLjcxODAzIDMuNDg5NiAxLjAwNzczIDMuNTA2NjQgMS4xNzU5IDMuNjk1ODNMMy4yNzc3OCA2LjA2MDQ1TDcuODI0MTEgMC45NDU4M0M3Ljk5MjI4IDAuNzU2NjM4IDguMjgxOTggMC43Mzk1OTcgOC40NzExNyAwLjkwNzc2OFoiLz48L3N2Zz4=');\n    --icon-Undo: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMi4zNTUyMjU2Miw1IEw2LjUsNSBDNi43NzYxNDIzNyw1IDcsNS4yMjM4NTc2MyA3LDUuNSBDNyw1Ljc3NjE0MjM3IDYuNzc2MTQyMzcsNiA2LjUsNiBMMS41MTAxMDk5OSw2IEMxLjQ4Njc1MjA2LDYuMDAwNDkxOTggMS40NjMyNTY1OSw1Ljk5OTM0NDE0IDEuNDM5NzkzODksNS45OTY0OTkyNSBDMS4zNjAyODIxNiw1Ljk4Njg3MDU4IDEuMjg2NTA1MDUsNS45NTg2NjU2MyAxLjIyMjk3MzY1LDUuOTE2MzA0ODQgQzEuMDk5MjE1NjMsNS44MzQxNzU0NyAxLjAyMTg2NDcxLDUuNzAzNDc4OCAxLjAwMzkxNDE3LDUuNTYyODY2MjkgQzEuMDAxODEwMzksNS41NDYwOTYwMiAxLjAwMDUzNzE4LDUuNTI5MDY3NCAxLjAwMDEzNzA0LDUuNTExODIyOTIgQzAuOTk5ODUzOTg3LDUuNTA0MjczMjEgMC45OTk4NDAyOTksNS40OTY3MDQ2IDEsNS40ODkxMjY5MyBMMSwwLjUgQzEsMC4yMjM4NTc2MjUgMS4yMjM4NTc2MywwIDEuNSwwIEMxLjc3NjE0MjM3LDAgMiwwLjIyMzg1NzYyNSAyLDAuNSBMMiwzLjc3NDY5MzE2IEMzLjM2ODU0MDksMi4wMzk0MDM0MyA1LjMyOTA3NTYsMSA3LjUsMSBDMTEuNjQyMTQyNCwxIDE1LDQuMzU3ODU3NjMgMTUsOC41IEMxNSwxMi42NDIxNDI0IDExLjY0MjE0MjQsMTYgNy41LDE2IEM3LjIyMzg1NzYzLDE2IDcsMTUuNzc2MTQyNCA3LDE1LjUgQzcsMTUuMjIzODU3NiA3LjIyMzg1NzYzLDE1IDcuNSwxNSBDMTEuMDg5ODU3NiwxNSAxNCwxMi4wODk4NTc2IDE0LDguNSBDMTQsNC45MTAxNDIzNyAxMS4wODk4NTc2LDIgNy41LDIgQzUuNDE1NzQ2NTQsMiAzLjU0NTU1MTYzLDMuMTMwNjQwNyAyLjM1NTIyNTYyLDUgWiIvPjwvc3ZnPg==');\n    --icon-Validation: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTUsMTAgTDExLDEwIEwxMSwxMi41IEMxMSwxMi43NzYxNDI0IDEwLjc3NjE0MjQsMTMgMTAuNSwxMyBMNS41LDEzIEM1LjIyMzg1NzYzLDEzIDUsMTIuNzc2MTQyNCA1LDEyLjUgTDUsMTAgTDEsMTAgTDEsMTQuNSBDMSwxNC43NzU4NTc2IDEuMjI0MTQyMzcsMTUgMS41LDE1IEwxNC41LDE1IEMxNC43NzU4NTc2LDE1IDE1LDE0Ljc3NTg1NzYgMTUsMTQuNSBMMTUsMTAgWiBNMTUsOSBMMTUsMS41IEMxNSwxLjIyNDE0MjM3IDE0Ljc3NTg1NzYsMSAxNC41LDEgTDEuNSwxIEMxLjIyNDE0MjM3LDEgMSwxLjIyNDE0MjM3IDEsMS41IEwxLDkgTDUuNSw5IEM1Ljc3NjE0MjM3LDkgNiw5LjIyMzg1NzYzIDYsOS41IEw2LDEyIEwxMCwxMiBMMTAsOS41IEMxMCw5LjIyMzg1NzYzIDEwLjIyMzg1NzYsOSAxMC41LDkgTDE1LDkgWiBNMTQuNSwxNiBMMS41LDE2IEMwLjY3MTg1NzYyNSwxNiAwLDE1LjMyODE0MjQgMCwxNC41IEwwLDEuNSBDMCwwLjY3MTg1NzYyNSAwLjY3MTg1NzYyNSwwIDEuNSwwIEwxNC41LDAgQzE1LjMyODE0MjQsMCAxNiwwLjY3MTg1NzYyNSAxNiwxLjUgTDE2LDE0LjUgQzE2LDE1LjMyODE0MjQgMTUuMzI4MTQyNCwxNiAxNC41LDE2IFogTTExLjE0NjQ0NjYsMy4xNDY0NDY2MSBDMTEuMzQxNzA4OCwyLjk1MTE4NDQ2IDExLjY1ODI5MTIsMi45NTExODQ0NiAxMS44NTM1NTM0LDMuMTQ2NDQ2NjEgQzEyLjA0ODgxNTUsMy4zNDE3MDg3NiAxMi4wNDg4MTU1LDMuNjU4MjkxMjQgMTEuODUzNTUzNCwzLjg1MzU1MzM5IEw3Ljg1MzU1MzM5LDcuODUzNTUzMzkgQzcuNjU4MjkxMjQsOC4wNDg4MTU1NCA3LjM0MTcwODc2LDguMDQ4ODE1NTQgNy4xNDY0NDY2MSw3Ljg1MzU1MzM5IEw1LjE0NjQ0NjYxLDUuODUzNTUzMzkgQzQuOTUxMTg0NDYsNS42NTgyOTEyNCA0Ljk1MTE4NDQ2LDUuMzQxNzA4NzYgNS4xNDY0NDY2MSw1LjE0NjQ0NjYxIEM1LjM0MTcwODc2LDQuOTUxMTg0NDYgNS42NTgyOTEyNCw0Ljk1MTE4NDQ2IDUuODUzNTUzMzksNS4xNDY0NDY2MSBMNy41LDYuNzkyODkzMjIgTDExLjE0NjQ0NjYsMy4xNDY0NDY2MSBaIi8+PC9zdmc+');\n    --icon-Video: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTQuNSwxIEMxNS4zMjg0MjcxLDEgMTYsMS42NzE1NzI4OCAxNiwyLjUgTDE2LDIuNSBMMTYsMTMuNSBDMTYsMTQuMzI4NDI3MSAxNS4zMjg0MjcxLDE1IDE0LjUsMTUgTDE0LjUsMTUgTDEuNSwxNSBDMC42NzE1NzI4NzUsMTUgMCwxNC4zMjg0MjcxIDAsMTMuNSBMMCwxMy41IEwwLDIuNSBDMCwxLjY3MTU3Mjg4IDAuNjcxNTcyODc1LDEgMS41LDEgTDEuNSwxIFogTTE0LjUsMiBMMS41LDIgQzEuMjIzODU3NjMsMiAxLDIuMjIzODU3NjMgMSwyLjUgTDEsMi41IEwxLDEzLjUgQzEsMTMuNzc2MTQyNCAxLjIyMzg1NzYzLDE0IDEuNSwxNCBMMS41LDE0IEwxNC41LDE0IEMxNC43NzYxNDI0LDE0IDE1LDEzLjc3NjE0MjQgMTUsMTMuNSBMMTUsMTMuNSBMMTUsMi41IEMxNSwyLjIyMzg1NzYzIDE0Ljc3NjE0MjQsMiAxNC41LDIgTDE0LjUsMiBaIE01LDQuNSBDNSw0LjEwNTQ1NDg2IDUuNDM1NTc1MjEsMy44NjYzNDc2NCA1Ljc2ODQzNzc1LDQuMDc4MTY5MjYgTDUuNzY4NDM3NzUsNC4wNzgxNjkyNiBMMTEuMjY4NDM3Nyw3LjU3ODE2OTI2IEMxMS41NzcxODc0LDcuNzc0NjQ2MzIgMTEuNTc3MTg3NCw4LjIyNTM1MzY4IDExLjI2ODQzNzcsOC40MjE4MzA3NCBMMTEuMjY4NDM3Nyw4LjQyMTgzMDc0IEw1Ljc2ODQzNzc1LDExLjkyMTgzMDcgQzUuNDM1NTc1MjEsMTIuMTMzNjUyNCA1LDExLjg5NDU0NTEgNSwxMS41IEw1LDExLjUgWiBNNiw1LjQxIEw2LDEwLjU4OSBMMTAuMDY5LDggTDYsNS40MSBaIi8+PC9zdmc+');\n    --icon-VideoPlay: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzOSIgaGVpZ2h0PSIzNSIgZmlsbD0ibm9uZSI+PGcgZmlsbD0iI2ZmZiIgZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik00LjA2MjUgMy4yNUMzLjQwNjY2IDMuMjUgMi44NzUgMy43ODE2NiAyLjg3NSA0LjQzNzVWMzAuNTYyNUMyLjg3NSAzMS4yMTgzIDMuNDA2NjYgMzEuNzUgNC4wNjI1IDMxLjc1SDM0LjkzNzVDMzUuNTkzMyAzMS43NSAzNi4xMjUgMzEuMjE4MyAzNi4xMjUgMzAuNTYyNVY0LjQzNzVDMzYuMTI1IDMuNzgxNjYgMzUuNTkzMyAzLjI1IDM0LjkzNzUgMy4yNUg0LjA2MjVaTTAuNSA0LjQzNzVDMC41IDIuNDY5OTkgMi4wOTQ5OSAwLjg3NSA0LjA2MjUgMC44NzVIMzQuOTM3NUMzNi45MDUgMC44NzUgMzguNSAyLjQ2OTk5IDM4LjUgNC40Mzc1VjMwLjU2MjVDMzguNSAzMi41MyAzNi45MDUgMzQuMTI1IDM0LjkzNzUgMzQuMTI1SDQuMDYyNUMyLjA5NDk5IDM0LjEyNSAwLjUgMzIuNTMgMC41IDMwLjU2MjVWNC40Mzc1WiIvPjxwYXRoIGQ9Ik0xMi45OTExIDguMTQ2NTNDMTMuMzcxIDcuOTM3OTcgMTMuODM0NCA3Ljk1Mjk2IDE0LjIgOC4xODU2NUwyNy4yNjI1IDE2LjQ5ODJDMjcuNjA1MSAxNi43MTYxIDI3LjgxMjUgMTcuMDk0IDI3LjgxMjUgMTcuNUMyNy44MTI1IDE3LjkwNiAyNy42MDUxIDE4LjI4MzkgMjcuMjYyNSAxOC41MDE5TDE0LjIgMjYuODE0NEMxMy44MzQ0IDI3LjA0NyAxMy4zNzEgMjcuMDYyIDEyLjk5MTEgMjYuODUzNUMxMi42MTExIDI2LjY0NDkgMTIuMzc1IDI2LjI0NTkgMTIuMzc1IDI1LjgxMjVWOS4xODc1QzEyLjM3NSA4Ljc1NDA5IDEyLjYxMTEgOC4zNTUwOSAxMi45OTExIDguMTQ2NTNaTTE0Ljc1IDExLjM1MDdWMjMuNjQ5M0wyNC40MTMxIDE3LjVMMTQuNzUgMTEuMzUwN1oiLz48L2c+PC9zdmc+');\n    --icon-VideoPlay2: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNSIgaGVpZ2h0PSIyNSIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYuNSA3LjI0MTA1QzYuNSA1LjY5NzQ3IDguMTc0NDMgNC43MzU3MyA5LjUwNzc0IDUuNTEzNDlMMTguNTIzMSAxMC43NzI1QzE5Ljg0NjEgMTEuNTQ0MiAxOS44NDYxIDEzLjQ1NTggMTguNTIzMSAxNC4yMjc2TDkuNTA3NzQgMTkuNDg2NUM4LjE3NDQzIDIwLjI2NDMgNi41IDE5LjMwMjYgNi41IDE3Ljc1OVY3LjI0MTA1Wk0xNy41MTU0IDEyLjVMOC41IDcuMjQxMDVWMTcuNzU5TDE3LjUxNTQgMTIuNVoiLz48L3N2Zz4=');\n    --icon-Warning: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0ibSA2NS40NSwxLjk3MiA1NS41OTQsODcuMzIzIGMgMS42NzMsMi42MyAzLjExNyw4LjAxNiAwLDguMDE2IEggMS44MzcgYyAtMy4xMTgsMCAtMS42NzYsLTUuMzg2IDAsLTguMDE2IEwgNTcuNDMxLDEuOTcyIGMgMS42NzUsLTIuNjMgNi4zNDMsLTIuNjI4IDguMDE5LDAgeiBtIC04Ljg3Miw3Mi4xNzQgaCA5LjY4MiB2IDguNTYxIGggLTkuNjgyIHogbSA5LjY3NiwtNS45MjkgSCA1Ni41OCBDIDU1LjYxNiw1Ni40NjEgNTMuNTk4LDQ5LjAwMSA1My41OTgsMzcuMjYyIGMgMCwtNC4zMzEgMy41MSwtNy44NDIgNy44NDEsLTcuODQyIDQuMzMyLDAgNy44NDIsMy41MTEgNy44NDIsNy44NDIgMTBlLTQsMTEuNzM0IC0yLjA0NSwxOS4yMDkgLTMuMDI3LDMwLjk1NSB6IiBzdHlsZT0iY2xpcC1ydWxlOmV2ZW5vZGQ7ZmlsbC1ydWxlOmV2ZW5vZGQiIHRyYW5zZm9ybT0ibWF0cml4KC4xMzAyIDAgMCAuMTMwMiAwIDEuNjY1KSIvPjwvc3ZnPg==');\n    --icon-Warning2: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iI0ZGMDAwNCIgZD0iTTkuOTk5OTggMy4zMzMzMUM2LjMzMzMxIDMuMzMzMzEgMy4zMzMzMSA2LjMzMzMxIDMuMzMzMzEgOS45OTk5OEMzLjMzMzMxIDEzLjY2NjYgNi4zMzMzMSAxNi42NjY2IDkuOTk5OTggMTYuNjY2NkMxMy42NjY2IDE2LjY2NjYgMTYuNjY2NiAxMy42NjY2IDE2LjY2NjYgOS45OTk5OEMxNi42NjY2IDYuMzMzMzEgMTMuNjY2NiAzLjMzMzMxIDkuOTk5OTggMy4zMzMzMVpNOS45OTk5OCAxMy4zMzMzQzkuNDk5OTggMTMuMzMzMyA5LjE2NjY1IDEzIDkuMTY2NjUgMTIuNUM5LjE2NjY1IDEyIDkuNDk5OTggMTEuNjY2NiA5Ljk5OTk4IDExLjY2NjZDMTAuNSAxMS42NjY2IDEwLjgzMzMgMTIgMTAuODMzMyAxMi41QzEwLjgzMzMgMTMgMTAuNSAxMy4zMzMzIDkuOTk5OTggMTMuMzMzM1pNMTAuODMzMyAxMC44MzMzSDkuMTY2NjVWNi42NjY2NUgxMC44MzMzVjEwLjgzMzNaIi8+PC9zdmc+');\n    --icon-Widget: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMS41LDEgQzEuMjIzODU3NjMsMSAxLDEuMjIzODU3NjMgMSwxLjUgTDEsNS41IEMxLDUuNzc2MTQyMzcgMS4yMjM4NTc2Myw2IDEuNSw2IEw1LjUsNiBDNS43NzYxNDIzNyw2IDYsNS43NzYxNDIzNyA2LDUuNSBMNiwxLjUgQzYsMS4yMjM4NTc2MyA1Ljc3NjE0MjM3LDEgNS41LDEgTDEuNSwxIFogTTEuNSwwIEw1LjUsMCBDNi4zMjg0MjcxMiwtMS42NjUzMzQ1NGUtMTYgNywwLjY3MTU3Mjg3NSA3LDEuNSBMNyw1LjUgQzcsNi4zMjg0MjcxMiA2LjMyODQyNzEyLDcgNS41LDcgTDEuNSw3IEMwLjY3MTU3Mjg3NSw3IDEuNjY1MzM0NTRlLTE2LDYuMzI4NDI3MTIgMCw1LjUgTDAsMS41IEMtOC4zMjY2NzI2OGUtMTcsMC42NzE1NzI4NzUgMC42NzE1NzI4NzUsMS42NjUzMzQ1NGUtMTYgMS41LDAgWiBNMTIuNjk0NDE1NCwxLjExMTgwODkgQzEyLjU4NzAyMTIsMS4wMDQ0MTQ3MiAxMi40MTI5MDA4LDEuMDA0NDE0NzIgMTIuMzA1NTA2NywxLjExMTgwODkgTDEwLjExMjA2MTQsMy4zMDUyNTQxMyBDMTAuMDA0NjY3MywzLjQxMjY0ODMxIDEwLjAwNDY2NzMsMy41ODY3Njg2OCAxMC4xMTIwNjE0LDMuNjk0MTYyODYgTDEyLjMwNTUwNjcsNS44ODc2MDgxIEMxMi40MTI5MDA4LDUuOTk1MDAyMjggMTIuNTg3MDIxMiw1Ljk5NTAwMjI4IDEyLjY5NDQxNTQsNS44ODc2MDgxIEwxNC44ODc4NjA2LDMuNjk0MTYyODYgQzE0Ljk5NTI1NDgsMy41ODY3Njg2OCAxNC45OTUyNTQ4LDMuNDEyNjQ4MzEgMTQuODg3ODYwNiwzLjMwNTI1NDEzIEwxMi42OTQ0MTU0LDEuMTExODA4OSBaIE0xMy40MDE1MjIyLDAuNDA0NzAyMTE4IEwxNS41OTQ5Njc0LDIuNTk4MTQ3MzUgQzE2LjA5Mjg4NTksMy4wOTYwNjU4MiAxNi4wOTI4ODU5LDMuOTAzMzUxMTcgMTUuNTk0OTY3NCw0LjQwMTI2OTY0IEwxMy40MDE1MjIyLDYuNTk0NzE0ODggQzEyLjkwMzYwMzcsNy4wOTI2MzMzNSAxMi4wOTYzMTg0LDcuMDkyNjMzMzUgMTEuNTk4Mzk5OSw2LjU5NDcxNDg4IEw5LjQwNDk1NDY1LDQuNDAxMjY5NjQgQzguOTA3MDM2MTgsMy45MDMzNTExNyA4LjkwNzAzNjE4LDMuMDk2MDY1ODIgOS40MDQ5NTQ2NSwyLjU5ODE0NzM1IEwxMS41OTgzOTk5LDAuNDA0NzAyMTE4IEMxMi4wOTYzMTg0LC0wLjA5MzIxNjM1NDMgMTIuOTAzNjAzNywtMC4wOTMyMTYzNTQzIDEzLjQwMTUyMjIsMC40MDQ3MDIxMTggWiBNMS41LDEwIEMxLjIyMzg1NzYzLDEwIDEsMTAuMjIzODU3NiAxLDEwLjUgTDEsMTQuNSBDMSwxNC43NzYxNDI0IDEuMjIzODU3NjMsMTUgMS41LDE1IEw1LjUsMTUgQzUuNzc2MTQyMzcsMTUgNiwxNC43NzYxNDI0IDYsMTQuNSBMNiwxMC41IEM2LDEwLjIyMzg1NzYgNS43NzYxNDIzNywxMCA1LjUsMTAgTDEuNSwxMCBaIE0xLjUsOSBMNS41LDkgQzYuMzI4NDI3MTIsOSA3LDkuNjcxNTcyODggNywxMC41IEw3LDE0LjUgQzcsMTUuMzI4NDI3MSA2LjMyODQyNzEyLDE2IDUuNSwxNiBMMS41LDE2IEMwLjY3MTU3Mjg3NSwxNiAxLjY2NTMzNDU0ZS0xNiwxNS4zMjg0MjcxIDAsMTQuNSBMMCwxMC41IEMtOC4zMjY2NzI2OGUtMTcsOS42NzE1NzI4OCAwLjY3MTU3Mjg3NSw5IDEuNSw5IFogTTEwLjUsMTAgQzEwLjIyMzg1NzYsMTAgMTAsMTAuMjIzODU3NiAxMCwxMC41IEwxMCwxNC41IEMxMCwxNC43NzYxNDI0IDEwLjIyMzg1NzYsMTUgMTAuNSwxNSBMMTQuNSwxNSBDMTQuNzc2MTQyNCwxNSAxNSwxNC43NzYxNDI0IDE1LDE0LjUgTDE1LDEwLjUgQzE1LDEwLjIyMzg1NzYgMTQuNzc2MTQyNCwxMCAxNC41LDEwIEwxMC41LDEwIFogTTEwLjUsOSBMMTQuNSw5IEMxNS4zMjg0MjcxLDkgMTYsOS42NzE1NzI4OCAxNiwxMC41IEwxNiwxNC41IEMxNiwxNS4zMjg0MjcxIDE1LjMyODQyNzEsMTYgMTQuNSwxNiBMMTAuNSwxNiBDOS42NzE1NzI4OCwxNiA5LDE1LjMyODQyNzEgOSwxNC41IEw5LDEwLjUgQzksOS42NzE1NzI4OCA5LjY3MTU3Mjg4LDkgMTAuNSw5IFoiLz48L3N2Zz4=');\n    --icon-World: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzE2QjM3OCIgZD0iTTEyLjAwNDkgNEM3LjU5MjQzIDQgNCA3LjU5MjQzIDQgMTIuMDA0OUM0IDE2LjQxNzMgNy41OTI0MyAyMC4wMDk4IDEyLjAwNDkgMjAuMDA5OEMxNi40MTczIDIwLjAwOTggMjAuMDA5OCAxNi40MTczIDIwLjAwOTggMTIuMDA0OUMyMC4wMDk4IDcuNTkyNDMgMTYuNDE3MyA0IDEyLjAwNDkgNFpNMTMuNTE4IDUuMDkzMzVDMTUuNzUzNSA1LjU4MTQ1IDE3LjYyNzggNy4xNDMzOCAxOC41MTYyIDkuMjUxOThMMTguNTc0NyA5LjM4ODY1SDE0LjQ2NDlWOS4zMDA3OUMxNC4yNTk5IDcuNTQzNjIgMTMuODk4NyA2LjEzNzg5IDEzLjQxMDYgNS4yMzAwMkwxMy4zMTMgNS4wNDQ1NEwxMy41MTggNS4wOTMzNVpNMTMuNzMyOCAxMi4wNDM5QzEzLjczMjggMTIuNTkwNiAxMy43MTMyIDEzLjEzNzMgMTMuNjg0IDEzLjY3NDJWMTMuNzYySDEwLjM0NTNWMTMuNjc0MkMxMC4zMDYzIDEzLjE0NyAxMC4yODY4IDEyLjYwMDQgMTAuMjg2OCAxMi4wNDM5QzEwLjI4NjggMTEuNDg3NSAxMC4zMDYzIDEwLjkzMTEgMTAuMzQ1MyAxMC4zNjQ5VjEwLjI3N0gxMy42NzQyVjEwLjM2NDlDMTMuNzEzMiAxMC45NDA4IDEzLjczMjggMTEuNDg3NSAxMy43MzI4IDEyLjA0MzlaTTEyLjAxNDYgNC45OTU3M0MxMi41MjIzIDQuOTk1NzMgMTMuMjY0MiA2LjYzNTc1IDEzLjU3NjYgOS4yODEyN1Y5LjM4ODY1SDEwLjQzMzJWOS4yODEyN0MxMC43NTUzIDYuNjM1NzUgMTEuNTA3IDQuOTk1NzMgMTIuMDE0NiA0Ljk5NTczWk01LjQ3NDA3IDkuMjUxOThDNi4wMzA1MSA3LjkyNDM0IDYuOTc3NDMgNi44MjEyMyA4LjE1ODYzIDYuMDU5NzlDOC44NjE1IDUuNjAwOTggOS42NjE5OSA1LjI2OTA3IDEwLjUwMTUgNS4wODM1OUwxMC43MDY1IDUuMDM0NzhMMTAuNjA4OSA1LjIyMDI2QzEwLjExMSA2LjEyODEzIDkuNzQ5ODUgNy41MzM4NiA5LjU1NDYxIDkuMjkxMDNWOS4zNzg4OUg1LjQyNTI2TDUuNDgzODMgOS4yNDIyMkw1LjQ3NDA3IDkuMjUxOThaTTUuMTUxOTIgMTMuNjg0QzUuMDA1NDkgMTMuMDc4NyA0LjkyNzM5IDEyLjUzMiA0LjkyNzM5IDExLjk5NTFDNC45MjczOSAxMS40NTgyIDQuOTk1NzMgMTAuOTMxMSA1LjE0MjE2IDEwLjMzNTZMNS4xNjE2OCAxMC4yNTc1SDkuNDc2NTFWMTAuMzY0OUM5LjQzNzQ2IDEwLjkyMTMgOS40MTc5NCAxMS40ODc1IDkuNDE3OTQgMTIuMDM0MkM5LjQxNzk0IDEyLjU4MDggOS40Mzc0NiAxMy4wOTgyIDkuNDY2NzUgMTMuNjU0N1YxMy43NjJINS4xNzE0NUw1LjE1MTkyIDEzLjY4NFpNMTAuNDUyNyAxOC45MDY3QzguMjM2NzMgMTguNDA4OCA2LjM4MTk0IDE2Ljg2NjQgNS40ODM4MyAxNC43NzczTDUuNDI1MjYgMTQuNjQwNkg5LjUzNTA4VjE0LjcyODVDOS43MzAzMiAxNi40NTY0IDEwLjA4MTggMTcuODUyMyAxMC41NjAxIDE4Ljc3TDEwLjY1NzcgMTguOTU1NUwxMC40NTI3IDE4LjkwNjdaTTEyLjAxNDYgMTkuMDgyNEMxMS40OTczIDE5LjA4MjQgMTAuNzU1MyAxNy40MjI4IDEwLjQ0MyAxNC43NDhWMTQuNjQwNkgxMy41OTYxVjE0Ljc0OEMxMy4yNzM5IDE3LjQyMjggMTIuNTMyIDE5LjA4MjQgMTIuMDE0NiAxOS4wODI0Wk0xOC41MTYyIDE0Ljc3NzNDMTcuNjI3OCAxNi44NTY2IDE1Ljc4MjggMTguMzk5IDEzLjU3NjYgMTguODk2OUwxMy4zNzE2IDE4Ljk0NTdMMTMuNDY5MiAxOC43NjAyQzEzLjk0NzUgMTcuODQyNiAxNC4yOTkgMTYuNDQ2NiAxNC40NzQ3IDE0LjcyODVWMTQuNjQwNkgxOC41NzQ3TDE4LjUxNjIgMTQuNzc3M1pNMTguODQ4MSAxMy43NjJIMTQuNTUyOFYxMy42NTQ3QzE0LjU5MTggMTMuMDk4MiAxNC42MTEzIDEyLjU1MTYgMTQuNjExMyAxMi4wMzQyQzE0LjYxMTMgMTEuNTE2OCAxNC41OTE4IDEwLjk0MDggMTQuNTUyOCAxMC4zNjQ5VjEwLjI1NzVIMTguODQ4MUwxOC44Njc2IDEwLjMzNTZDMTkuMDA0MyAxMC44OTIgMTkuMDcyNiAxMS40NDg0IDE5LjA3MjYgMTEuOTk1MUMxOS4wNzI2IDEyLjU0MTggMTkuMDA0MyAxMy4xMTc4IDE4Ljg1NzggMTMuNjg0TDE4LjgzODMgMTMuNzYySDE4Ljg0ODFaIi8+PC9zdmc+');\n    --icon-Wrap: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNOSwxMiBMOSwxMyBMOC4zMDc4MjM2NiwxMS45NjE3MzU1IEM4LjEyNzA2NDgzLDExLjg4NjQxNDggOCwxMS43MDgwNDYyIDgsMTEuNSBDOCwxMS4yOTE5NTM4IDguMTI3MDY0ODMsMTEuMTEzNTg1MiA4LjMwNzgyMjc0LDExLjAzODI2NTkgTDksMTAgTDksMTEgTDExLjMyNjA4NywxMSBDMTIuMjQ2NjQxNSwxMSAxMywxMC4yMjAyNzM5IDEzLDkuMjUgQzEzLDguMjc5NzI2MDUgMTIuMjQ2NjQxNSw3LjUgMTEuMzI2MDg3LDcuNSBMMi41LDcuNSBDMi4yMjM4NTc2Myw3LjUgMiw3LjI3NjE0MjM3IDIsNyBDMiw2LjcyMzg1NzYzIDIuMjIzODU3NjMsNi41IDIuNSw2LjUgTDExLjMyNjA4Nyw2LjUgQzEyLjgwNjc3MDUsNi41IDE0LDcuNzM0OTkyNTcgMTQsOS4yNSBDMTQsMTAuNzY1MDA3NCAxMi44MDY3NzA1LDEyIDExLjMyNjA4NywxMiBMOSwxMiBaIE0yLjUsMiBMMTMuNSwyIEMxMy43NzYxNDI0LDIgMTQsMi4yMjM4NTc2MyAxNCwyLjUgQzE0LDIuNzc2MTQyMzcgMTMuNzc2MTQyNCwzIDEzLjUsMyBMMi41LDMgQzIuMjIzODU3NjMsMyAyLDIuNzc2MTQyMzcgMiwyLjUgQzIsMi4yMjM4NTc2MyAyLjIyMzg1NzYzLDIgMi41LDIgWiBNMi41LDExIEw1LjUsMTEgQzUuNzc2MTQyMzcsMTEgNiwxMS4yMjM4NTc2IDYsMTEuNSBDNiwxMS43NzYxNDI0IDUuNzc2MTQyMzcsMTIgNS41LDEyIEwyLjUsMTIgQzIuMjIzODU3NjMsMTIgMiwxMS43NzYxNDI0IDIsMTEuNSBDMiwxMS4yMjM4NTc2IDIuMjIzODU3NjMsMTEgMi41LDExIFoiLz48L3N2Zz4=');\n    --icon-Zoom: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBkPSJNMTIsNCBMNy41LDQgQzcuMjIzODU3NjMsNCA3LDMuNzc2MTQyMzcgNywzLjUgQzcsMy4yMjM4NTc2MyA3LjIyMzg1NzYzLDMgNy41LDMgTDEyLjUsMyBDMTIuNzc2MTQyNCwzIDEzLDMuMjIzODU3NjMgMTMsMy41IEwxMyw4LjUgQzEzLDguNzc2MTQyMzcgMTIuNzc2MTQyNCw5IDEyLjUsOSBDMTIuMjIzODU3Niw5IDEyLDguNzc2MTQyMzcgMTIsOC41IEwxMiw0IFogTTQsMTIgTDguNSwxMiBDOC43NzYxNDIzNywxMiA5LDEyLjIyMzg1NzYgOSwxMi41IEM5LDEyLjc3NjE0MjQgOC43NzYxNDIzNywxMyA4LjUsMTMgTDMuNSwxMyBDMy4yMjM4NTc2MywxMyAzLDEyLjc3NjE0MjQgMywxMi41IEwzLDcuNSBDMyw3LjIyMzg1NzYzIDMuMjIzODU3NjMsNyAzLjUsNyBDMy43NzYxNDIzNyw3IDQsNy4yMjM4NTc2MyA0LDcuNSBMNCwxMiBaIi8+PC9zdmc+');\n    --icon-UseChart: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzcxNDFGOSIgZD0iTTEzIDNDMTMgMi40NDc3MiAxMy40NDc3IDIgMTQgMiAxNC41NTIzIDIgMTUgMi40NDc3MiAxNSAzVjE0QzE1IDE0LjU1MjMgMTQuNTUyMyAxNSAxNCAxNSAxMy40NDc3IDE1IDEzIDE0LjU1MjMgMTMgMTRWM1pNOSA4QzkgNy40NDc3MiA5LjQ0NzcyIDcgMTAgNyAxMC41NTIzIDcgMTEgNy40NDc3MiAxMSA4VjE0QzExIDE0LjU1MjMgMTAuNTUyMyAxNSAxMCAxNSA5LjQ0NzcyIDE1IDkgMTQuNTUyMyA5IDE0VjhaTTUgNkM1IDUuNDQ3NzIgNS40NDc3MiA1IDYgNSA2LjU1MjI4IDUgNyA1LjQ0NzcyIDcgNlYxNEM3IDE0LjU1MjMgNi41NTIyOCAxNSA2IDE1IDUuNDQ3NzIgMTUgNSAxNC41NTIzIDUgMTRWNlpNMSAxMUMxIDEwLjQ0NzcgMS40NDc3MiAxMCAyIDEwIDIuNTUyMjggMTAgMyAxMC40NDc3IDMgMTFWMTRDMyAxNC41NTIzIDIuNTUyMjggMTUgMiAxNSAxLjQ0NzcyIDE1IDEgMTQuNTUyMyAxIDE0VjExWiIvPjwvc3ZnPg==');\n    --icon-UseEducate: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PGcgZmlsbD0iIzRBNTg5OSIgY2xpcC1wYXRoPSJ1cmwoI2EpIj48cGF0aCBkPSJNMTUuNTAwMSA0LjEwMDM5TDguNTAwMSAwLjEwMDM5MUM4LjIwMDEgMC4wMDAzOTA2MjYgNy44MDAxIDAuMDAwMzkwNjI2IDcuNTAwMSAwLjEwMDM5MUwwLjUwMDA5OCA0LjEwMDM5Qy0wLjE5OTkwMiA0LjUwMDM5IC0wLjE5OTkwMiA1LjUwMDM5IDAuNTAwMDk4IDUuODAwMzlMNy41MDAxIDkuODAwMzlDNy44MDAxIDEwLjAwMDQgOC4yMDAxIDEwLjAwMDQgOC41MDAxIDkuODAwMzlMMTUuNTAwMSA1LjgwMDM5QzE2LjIwMDEgNS41MDAzOSAxNi4yMDAxIDQuNTAwMzkgMTUuNTAwMSA0LjEwMDM5WiIvPjxwYXRoIGQ9Ik05LjUgMTEuNkM5IDExLjkgOC41IDEyIDggMTJDNy41IDEyIDcgMTEuOSA2LjUgMTEuNkwyIDlWMTNDMiAxNS4xIDUuMSAxNiA4IDE2QzEwLjkgMTYgMTQgMTUuMSAxNCAxM1Y5TDkuNSAxMS42WiIvPjwvZz48ZGVmcz48Y2xpcFBhdGggaWQ9ImEiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBIMTZWMTZIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=');\n    --icon-UseFinance: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzAwNzVBMiIgZD0iTTguMzY3MTkgOS42ODE2NFYxMC45NzgzQzguODM5MTkgMTAuODk4MyA5LjA3NTE5IDEwLjY5MzYgOS4wNzUxOSAxMC4zNjM2IDkuMDc1MTkgMTAuMDEyMyA4LjY4ODUyIDkuODI4OTcgOC4zNjcxOSA5LjY4MTY0Wk02Ljk4MTkzIDcuMjAzMTNDNi45ODE5MyA3LjU3MTEzIDcuMzE0NiA3LjczNTggNy42NTM5MyA3Ljg4NTEzVjYuNjYxMTNDNy4yMDU5MyA2LjcyNzEzIDYuOTgxOTMgNi45MDc4IDYuOTgxOTMgNy4yMDMxM1oiLz48cGF0aCBmaWxsPSIjMDA3NUEyIiBkPSJNMTEuOTk5OSAyTDkuOTk5OTIgMEw3Ljk5OTkyIDJMNS45OTk5MiAwTDMuOTk5OTIgMkwxLjMzMzI1IDBWMTUuMzMzM0MxLjMzMzI1IDE1LjcwMiAxLjYzMTkyIDE2IDEuOTk5OTIgMTZIMTMuOTk5OUMxNC4zNjc5IDE2IDE0LjY2NjYgMTUuNzAyIDE0LjY2NjYgMTUuMzMzM1YwTDExLjk5OTkgMlpNOC4zNjcyNSAxMi4yMTg3VjEzLjI4NjdINy42NTM5MlYxMi4yNEM2LjgwNjU5IDEyLjIyMjcgNi4wNjMyNSAxMi4wNzMzIDUuNDI0NTkgMTEuNzkyVjEwLjQxNzNDNi4wMjQ1OSAxMC43MTQgNi45NzY1OSAxMC45NzQgNy42NTM5MiAxMS4wMTZWOS40MDEzM0M2LjQ2MTI1IDguOTM4NjcgNS40MTQ1OSA4LjQ5IDUuNDE0NTkgNy4yMDMzM0M1LjQxNDU5IDYuMDM0NjcgNi40ODY1OSA1LjQ4NTMzIDcuNjUzOTIgNS4zN1Y0LjU3MjY3SDguMzY3MjVWNS4zNDg2N0M5LjE2MTkyIDUuMzgzMzMgOS44ODEyNSA1LjU0MzMzIDEwLjUyMzMgNS44MjhMMTAuMDMzOSA3LjA0NjY3QzkuNDkyNTkgNi44MjQ2NyA4LjkzNjU5IDYuNjg5MzMgOC4zNjcyNSA2LjY0MDY3VjguMTc3MzNDOS42MzI1OSA4LjY2NCAxMC42NDMzIDkuMTA3MzMgMTAuNjQzMyAxMC4yODY3QzEwLjY0MzMgMTEuNTMgOS42MTA1OSAxMi4xMDQ3IDguMzY3MjUgMTIuMjE4N1oiLz48L3N2Zz4=');\n    --icon-UseHr: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzY4ODA0NyIgZD0iTTE1LjI3NTEgMTAuMjkzNEwxMy4wODcxIDkuNjY4MzlDMTIuOTEwMiA5LjYxNzgzIDEyLjc1MDggOS41MTk0OCAxMi42MjYyIDkuMzg0MTZDMTIuNTAxNyA5LjI0ODg0IDEyLjQxNjggOS4wODE3OSAxMi4zODExIDguOTAxMzlMMTIuMjQ3MSA4LjIyNjM5QzEyLjc2OTYgNy45ODc1NyAxMy4yMTI2IDcuNjAzNzMgMTMuNTIzNCA3LjEyMDQ4QzEzLjgzNDEgNi42MzcyNCAxMy45OTk2IDYuMDc0OTMgMTQuMDAwMSA1LjUwMDM5VjQuMTI2MzlDMTQuMDEyMiAzLjMzMDgzIDEzLjcxNjQgMi41NjEzOCAxMy4xNzQ1IDEuOTc4ODJDMTIuNjMyNSAxLjM5NjI3IDExLjg4NjQgMS4wNDU3IDExLjA5MjEgMS4wMDAzOUMxMC40OTkxIDAuOTgyNDk2IDkuOTE0MTIgMS4xNDA4MiA5LjQxMTExIDEuNDU1MzNDOC45MDgwOSAxLjc2OTg1IDguNTA5NjIgMi4yMjY0NCA4LjI2NjA3IDIuNzY3MzlDOC43NDMxMiAzLjQ2MTY3IDguOTk4OTkgNC4yODQwMSA5LjAwMDA3IDUuMTI2MzlWNi41MDAzOUM4Ljk5ODI4IDYuODY0MTYgOC45NDYxMSA3LjIyNTkzIDguODQ1MDcgNy41NzUzOUM5LjEwNDMgNy44NDczMyA5LjQxMjMxIDguMDY4MTYgOS43NTMwNyA4LjIyNjM5TDkuNjE5MDcgOC45MDAzOUM5LjU4MzMgOS4wODA3OSA5LjQ5ODQ4IDkuMjQ3ODQgOS4zNzM5MiA5LjM4MzE2QzkuMjQ5MzYgOS41MTg0OCA5LjA4OTkgOS42MTY4MyA4LjkxMzA3IDkuNjY3MzlMOC4wNzAwNyA5LjkwODM5TDkuNTUwMDcgMTAuMzMxNEM5Ljk2NzI3IDEwLjQ1MTkgMTAuMzM0MSAxMC43MDQ1IDEwLjU5NTYgMTEuMDUxMkMxMC44NTcgMTEuMzk4IDEwLjk5OSAxMS44MjAxIDExLjAwMDEgMTIuMjU0NFYxNC41MDA0QzEwLjk5ODQgMTQuNjcxMSAxMC45NjczIDE0Ljg0MDMgMTAuOTA4MSAxNS4wMDA0SDE1LjUwMDFDMTUuNjMyNyAxNS4wMDA0IDE1Ljc1OTkgMTQuOTQ3NyAxNS44NTM2IDE0Ljg1MzlDMTUuOTQ3NCAxNC43NjAyIDE2LjAwMDEgMTQuNjMzIDE2LjAwMDEgMTQuNTAwNFYxMS4yNTQ0QzE2IDExLjAzNzIgMTUuOTI5MiAxMC44MjYgMTUuNzk4NCAxMC42NTI2QzE1LjY2NzYgMTAuNDc5MiAxNS40ODM5IDEwLjM1MzEgMTUuMjc1MSAxMC4yOTM0WiIvPjxwYXRoIGZpbGw9IiM2ODgwNDciIGQ9Ik05LjI3NSAxMS4yOTM0TDcuMDg3IDEwLjY2ODRDNi45MTAwNCAxMC42MTc4IDYuNzUwNDkgMTAuNTE5MyA2LjYyNTkyIDEwLjM4MzhDNi41MDEzNSAxMC4yNDgzIDYuNDE2NiAxMC4wODEgNi4zODEgOS45MDA0M0w2LjI0NyA5LjIyNTQzQzYuNzY5NCA4Ljk4NjY5IDcuMjEyMjggOC42MDMgNy41MjMwMyA4LjExOTk1QzcuODMzNzcgNy42MzY5IDcuOTk5MzIgNy4wNzQ4IDggNi41MDA0M1Y1LjEyNjQzQzguMDEyMTMgNC4zMzA4OCA3LjcxNjMyIDMuNTYxNDIgNy4xNzQzOSAyLjk3ODg3QzYuNjMyNDYgMi4zOTYzMSA1Ljg4NjM2IDIuMDQ1NzUgNS4wOTIgMi4wMDA0M0M0LjY5MDM2IDEuOTg4MTEgNC4yOTAzNCAyLjA1NjYgMy45MTU2OCAyLjIwMTg0QzMuNTQxMDEgMi4zNDcwOCAzLjE5OTM1IDIuNTY2MSAyLjkxMDk1IDIuODQ1OTFDMi42MjI1NiAzLjEyNTczIDIuMzkzMzIgMy40NjA2MiAyLjIzNjgzIDMuODMwNzNDMi4wODAzNSA0LjIwMDg0IDEuOTk5ODEgNC41OTg2MSAyIDUuMDAwNDNWNi41MDA0M0MyLjAwMDQ5IDcuMDc0OTcgMi4xNjU5NSA3LjYzNzI5IDIuNDc2NyA4LjEyMDUzQzIuNzg3NDYgOC42MDM3NyAzLjIzMDQ1IDguOTg3NjIgMy43NTMgOS4yMjY0M0wzLjYxOSA5LjkwMDQzQzMuNTgzMjMgMTAuMDgwOCAzLjQ5ODQxIDEwLjI0NzkgMy4zNzM4NSAxMC4zODMyQzMuMjQ5MjkgMTAuNTE4NSAzLjA4OTgzIDEwLjYxNjkgMi45MTMgMTAuNjY3NEwwLjcyNSAxMS4yOTI0QzAuNTE2MDI1IDExLjM1MjIgMC4zMzIyMTQgMTEuNDc4NCAwLjIwMTM5NyAxMS42NTJDMC4wNzA1Nzk2IDExLjgyNTYgLTAuMDAwMTIwNjM2IDEyLjAzNzEgMS41NDUyMWUtMDcgMTIuMjU0NFYxNC41MDA0QzEuNTQ1MjFlLTA3IDE0LjYzMyAwLjA1MjY3ODYgMTQuNzYwMiAwLjE0NjQ0NyAxNC44NTRDMC4yNDAyMTUgMTQuOTQ3OCAwLjM2NzM5MiAxNS4wMDA0IDAuNSAxNS4wMDA0SDkuNUM5LjYzMjYxIDE1LjAwMDQgOS43NTk3OSAxNC45NDc4IDkuODUzNTUgMTQuODU0QzkuOTQ3MzIgMTQuNzYwMiAxMCAxNC42MzMgMTAgMTQuNTAwNFYxMi4yNTQ0QzkuOTk5OSAxMi4wMzczIDkuOTI5MSAxMS44MjYgOS43OTgzIDExLjY1MjZDOS42Njc1IDExLjQ3OTIgOS40ODM4MSAxMS4zNTMyIDkuMjc1IDExLjI5MzRWMTEuMjkzNFoiLz48L3N2Zz4=');\n    --icon-UseMedia: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iI0Y3QjMyQiIgZD0iTTIgMTBWNkgxQzAuNzM0Nzg0IDYgMC40ODA0MyA2LjEwNTM2IDAuMjkyODkzIDYuMjkyODlDMC4xMDUzNTcgNi40ODA0MyAwIDYuNzM0NzggMCA3TDAgMTRDMCAxNC4yNjUyIDAuMTA1MzU3IDE0LjUxOTYgMC4yOTI4OTMgMTQuNzA3MUMwLjQ4MDQzIDE0Ljg5NDYgMC43MzQ3ODQgMTUgMSAxNUgxMUMxMS4yNjUyIDE1IDExLjUxOTYgMTQuODk0NiAxMS43MDcxIDE0LjcwNzFDMTEuODk0NiAxNC41MTk2IDEyIDE0LjI2NTIgMTIgMTRWMTNINUM0LjIwNDM1IDEzIDMuNDQxMjkgMTIuNjgzOSAyLjg3ODY4IDEyLjEyMTNDMi4zMTYwNyAxMS41NTg3IDIgMTAuNzk1NiAyIDEwWiIvPjxwYXRoIGZpbGw9IiNGN0IzMkIiIGQ9Ik0xNSAxSDVDNC43MzQ3OCAxIDQuNDgwNDMgMS4xMDUzNiA0LjI5Mjg5IDEuMjkyODlDNC4xMDUzNiAxLjQ4MDQzIDQgMS43MzQ3OCA0IDJWMTBDNCAxMC4yNjUyIDQuMTA1MzYgMTAuNTE5NiA0LjI5Mjg5IDEwLjcwNzFDNC40ODA0MyAxMC44OTQ2IDQuNzM0NzggMTEgNSAxMUgxNUMxNS4yNjUyIDExIDE1LjUxOTYgMTAuODk0NiAxNS43MDcxIDEwLjcwNzFDMTUuODk0NiAxMC41MTk2IDE2IDEwLjI2NTIgMTYgMTBWMkMxNiAxLjczNDc4IDE1Ljg5NDYgMS40ODA0MyAxNS43MDcxIDEuMjkyODlDMTUuNTE5NiAxLjEwNTM2IDE1LjI2NTIgMSAxNSAxWk0xMS43MjQgNi40NDdMOC43MjQgNy45NDdDOC42NDc3NyA3Ljk4NTIgOC41NjMwNCA4LjAwMzI3IDguNDc3ODYgNy45OTk0OUM4LjM5MjY4IDcuOTk1NzIgOC4zMDk4OCA3Ljk3MDIyIDguMjM3MzMgNy45MjU0M0M4LjE2NDc4IDcuODgwNjQgOC4xMDQ4OSA3LjgxODAzIDguMDYzMzUgNy43NDM1N0M4LjAyMTgxIDcuNjY5MTEgOCA3LjU4NTI2IDggNy41VjQuNUM4IDQuNDE0NzQgOC4wMjE4MSA0LjMzMDg5IDguMDYzMzUgNC4yNTY0M0M4LjEwNDg5IDQuMTgxOTcgOC4xNjQ3OCA0LjExOTM2IDguMjM3MzMgNC4wNzQ1N0M4LjMwOTg4IDQuMDI5NzggOC4zOTI2OCA0LjAwNDI4IDguNDc3ODYgNC4wMDA1MUM4LjU2MzA0IDMuOTk2NzMgOC42NDc3NyA0LjAxNDggOC43MjQgNC4wNTNMMTEuNzI0IDUuNTUzQzExLjgwNjkgNS41OTQ1NyAxMS44NzY3IDUuNjU4NCAxMS45MjU0IDUuNzM3MzRDMTEuOTc0MiA1LjgxNjI4IDEyIDUuOTA3MjMgMTIgNkMxMiA2LjA5Mjc3IDExLjk3NDIgNi4xODM3MiAxMS45MjU0IDYuMjYyNjZDMTEuODc2NyA2LjM0MTYgMTEuODA2OSA2LjQwNTQzIDExLjcyNCA2LjQ0N1oiLz48L3N2Zz4=');\n    --icon-UseMonitor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iI0YyNTQ1QiIgZD0iTTE1IDBIMUMwLjQgMCAwIDAuNCAwIDFWMTJDMCAxMi42IDAuNCAxMyAxIDEzSDZWMTRIM1YxNkgxM1YxNEgxMFYxM0gxNUMxNS42IDEzIDE2IDEyLjYgMTYgMTJWMUMxNiAwLjQgMTUuNiAwIDE1IDBaTTE0IDJWOUgyVjJIMTRaIi8+PC9zdmc+');\n    --icon-UseOther: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzkyOTI5OSIgZD0iTTE1IDBIMUMwLjQgMCAwIDAuNCAwIDFWMTVDMCAxNS42IDAuNCAxNiAxIDE2SDE1QzE1LjYgMTYgMTYgMTUuNiAxNiAxNVYxQzE2IDAuNCAxNS42IDAgMTUgMFpNOCAxM0M3LjQgMTMgNyAxMi42IDcgMTJDNyAxMS40IDcuNCAxMSA4IDExQzguNiAxMSA5IDExLjQgOSAxMkM5IDEyLjYgOC42IDEzIDggMTNaTTkuNSA4LjRDOSA4LjcgOSA4LjggOSA5VjEwSDdWOUM3IDcuNyA3LjggNy4xIDguNCA2LjdDOC45IDYuNCA5IDYuMyA5IDZDOSA1LjQgOC42IDUgOCA1QzcuNiA1IDcuMyA1LjIgNy4xIDUuNUw2LjYgNi40TDQuOSA1LjRMNS40IDQuNUM1LjkgMy42IDYuOSAzIDggM0M5LjcgMyAxMSA0LjMgMTEgNkMxMSA3LjQgMTAuMSA4IDkuNSA4LjRaIi8+PC9zdmc+');\n    --icon-UseProduct: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzE2QjM3OCIgZD0iTTE1LjMzMzMuNjY2OTkySC42NjY2NjdDLjI5ODY2Ny42NjY5OTIgMCAuOTY0OTkyIDAgMS4zMzM2NlYzLjMzMzY2QzAgMy43MDIzMy4yOTg2NjcgNC4wMDAzMy42NjY2NjcgNC4wMDAzM0gxNS4zMzMzQzE1LjcwMTMgNC4wMDAzMyAxNiAzLjcwMjMzIDE2IDMuMzMzNjZWMS4zMzM2NkMxNiAuOTY0OTkyIDE1LjcwMTMuNjY2OTkyIDE1LjMzMzMuNjY2OTkyWk0xNC42NjY2IDUuMzMzOThIOS45OTk5MlYxMC42NjczTDcuOTk5OTIgOS4zMzM5OCA1Ljk5OTkyIDEwLjY2NzNWNS4zMzM5OEgxLjMzMzI1VjE0LjY2NzNDMS4zMzMyNSAxNS4wMzUzIDEuNjMxOTIgMTUuMzM0IDEuOTk5OTIgMTUuMzM0SDEzLjk5OTlDMTQuMzY3OSAxNS4zMzQgMTQuNjY2NiAxNS4wMzUzIDE0LjY2NjYgMTQuNjY3M1Y1LjMzMzk4WiIvPjwvc3ZnPg==');\n    --icon-UseSales: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzg4NUE1QSIgZD0iTTE2LjAwMDEgMS4wMDAwMkMxNi4wMDAxIDAuNzM0ODAxIDE1Ljg5NDcgMC40ODA0NDcgMTUuNzA3MiAwLjI5MjkxMUMxNS41MTk2IDAuMTA1Mzc0IDE1LjI2NTMgMS43NDAxOWUtMDUgMTUuMDAwMSAxLjc0MDE5ZS0wNUgxMC40NTAxQzkuODU4ODYgLTAuMDAxNjMyMTcgOS4yNzMyMSAwLjExNDAwOSA4LjcyNzAxIDAuMzQwMjQ2QzguMTgwODIgMC41NjY0ODMgNy42ODQ5MyAwLjg5ODgyIDcuMjY4MDUgMS4zMTgwMkwzLjI5MzA1IDUuMjkzMDJDMy4xMDU1OCA1LjQ4MDU1IDMuMDAwMjcgNS43MzQ4NSAzLjAwMDI3IDYuMDAwMDJDMy4wMDAyNyA2LjI2NTE4IDMuMTA1NTggNi41MTk0OSAzLjI5MzA1IDYuNzA3MDJDMy43NDEyNiA3LjE1NTc2IDQuMzM4MDMgNy40MjUxOSA0Ljk3MTA0IDcuNDY0NkM1LjYwNDA1IDcuNTA0MDIgNi4yMjk2NCA3LjMxMDY5IDYuNzMwMDUgNi45MjEwMkw5LjQxNzA1IDQuODMxMDJMMTMuNjEwMSA5LjAyNDAyQzEzLjcyNzEgOS4xNDEwNCAxMy44MTg3IDkuMjgxMDIgMTMuODc5IDkuNDM1MTVDMTMuOTM5NCA5LjU4OTI3IDEzLjk2NzIgOS43NTQyMiAxMy45NjA3IDkuOTE5NjFDMTMuOTU0MiAxMC4wODUgMTMuOTEzNiAxMC4yNDczIDEzLjg0MTQgMTAuMzk2MkMxMy43NjkxIDEwLjU0NTEgMTMuNjY2OSAxMC42Nzc1IDEzLjU0MTEgMTAuNzg1TDEyLjk3NTEgMTEuMjY4TDExLjM1NDEgOS42NDYwMkMxMS4zMDc2IDkuNTk5NTMgMTEuMjUyNCA5LjU2MjY1IDExLjE5MTYgOS41Mzc0OUMxMS4xMzA5IDkuNTEyMzMgMTEuMDY1OCA5LjQ5OTM5IDExLjAwMDEgOS40OTkzOUMxMC45MzQzIDkuNDk5MzkgMTAuODY5MiA5LjUxMjMzIDEwLjgwODUgOS41Mzc0OUMxMC43NDc3IDkuNTYyNjUgMTAuNjkyNSA5LjU5OTUzIDEwLjY0NjEgOS42NDYwMkMxMC41OTk2IDkuNjkyNSAxMC41NjI3IDkuNzQ3NjkgMTAuNTM3NSA5LjgwODQzQzEwLjUxMjQgOS44NjkxNyAxMC40OTk0IDkuOTM0MjcgMTAuNDk5NCAxMEMxMC40OTk0IDEwLjA2NTggMTAuNTEyNCAxMC4xMzA5IDEwLjUzNzUgMTAuMTkxNkMxMC41NjI3IDEwLjI1MjMgMTAuNTk5NiAxMC4zMDc1IDEwLjY0NjEgMTAuMzU0TDEyLjIxMjEgMTEuOTE5TDExLjM1NzEgMTIuNjVMOS44NTcwNSAxMS4xNUM5Ljc2MzE3IDExLjA1NjEgOS42MzU4MyAxMS4wMDM0IDkuNTAzMDUgMTEuMDAzNEM5LjM3MDI4IDExLjAwMzQgOS4yNDI5NCAxMS4wNTYxIDkuMTQ5MDUgMTEuMTVDOS4wNTUxNiAxMS4yNDM5IDkuMDAyNDIgMTEuMzcxMiA5LjAwMjQyIDExLjUwNEM5LjAwMjQyIDExLjYzNjggOS4wNTUxNiAxMS43NjQxIDkuMTQ5MDUgMTEuODU4TDEwLjU5NDEgMTMuM0wxMC40NTYxIDEzLjQxOEMxMC4yMDg3IDEzLjYzMDIgOS45MTI5MyAxMy43NzgyIDkuNTk0ODYgMTMuODQ5MkM5LjI3Njc5IDEzLjkyMDIgOC45NDYxNSAxMy45MTE5IDguNjMyMDUgMTMuODI1QzguNTk0OTIgMTMuMzU3MiA4LjM5MjkyIDEyLjkxNzggOC4wNjIwNSAxMi41ODVMNC40NzcwNSA5LjAwMDAyQzQuMjkxMzYgOC44MTQyNiA0LjA3MDkgOC42NjY5IDMuODI4MjYgOC41NjYzNUMzLjU4NTYzIDguNDY1NzkgMy4zMjU1NiA4LjQxNDAxIDMuMDYyOTEgOC40MTM5N0MyLjgwMDI1IDguNDEzOTIgMi41NDAxNyA4LjQ2NTYxIDIuMjk3NDkgOC41NjYwOEMyLjA1NDgyIDguNjY2NTQgMS44MzQzMSA4LjgxMzgzIDEuNjQ4NTUgOC45OTk1MkMxLjQ2MjggOS4xODUyMSAxLjMxNTQ0IDkuNDA1NjYgMS4yMTQ4OCA5LjY0ODNDMS4xMTQzMyA5Ljg5MDk0IDEuMDYyNTUgMTAuMTUxIDEuMDYyNSAxMC40MTM3QzEuMDYyNDEgMTAuOTQ0MSAxLjI3MzA0IDExLjQ1MjkgMS42NDgwNSAxMS44MjhMNS4yMzQwNSAxNS40MTRDNS41NjQxMSAxNS43NDMxIDUuOTk4NzQgMTUuOTQ2NiA2LjQ2Mjg2IDE1Ljk4OTNDNi45MjY5OSAxNi4wMzIgNy4zOTE0NiAxNS45MTEzIDcuNzc2MDUgMTUuNjQ4QzguMjE2MzkgMTUuODEyMiA4LjY4MjEyIDE1Ljg5NzggOS4xNTIwNSAxNS45MDFDMTAuMTA5MiAxNS45MDM3IDExLjAzNTIgMTUuNTYxIDExLjc2MDEgMTQuOTM2TDE0Ljg0MDEgMTIuMzA2QzE1LjE4NjYgMTIuMDI3OSAxNS40Njk0IDExLjY3ODYgMTUuNjY5NiAxMS4yODE5QzE1Ljg2OTcgMTAuODg1MSAxNS45ODI0IDEwLjQ1IDE2LjAwMDEgMTAuMDA2VjEuMDAwMDJaIi8+PHBhdGggZmlsbD0iIzg4NUE1QSIgZD0iTTIgN1YySDVDNS4yNjUyMiAyIDUuNTE5NTcgMS44OTQ2NCA1LjcwNzExIDEuNzA3MTFDNS44OTQ2NCAxLjUxOTU3IDYgMS4yNjUyMiA2IDFDNiAwLjczNDc4NCA1Ljg5NDY0IDAuNDgwNDMgNS43MDcxMSAwLjI5Mjg5M0M1LjUxOTU3IDAuMTA1MzU3IDUuMjY1MjIgMCA1IDBMMSAwQzAuNzM0Nzg0IDAgMC40ODA0MyAwLjEwNTM1NyAwLjI5Mjg5MyAwLjI5Mjg5M0MwLjEwNTM1NyAwLjQ4MDQzIDAgMC43MzQ3ODQgMCAxTDAgN0MwIDcuMjY1MjIgMC4xMDUzNTcgNy41MTk1NyAwLjI5Mjg5MyA3LjcwNzExQzAuNDgwNDMgNy44OTQ2NCAwLjczNDc4NCA4IDEgOEMxLjI2NTIyIDggMS41MTk1NyA3Ljg5NDY0IDEuNzA3MTEgNy43MDcxMUMxLjg5NDY0IDcuNTE5NTcgMiA3LjI2NTIyIDIgN1oiLz48L3N2Zz4=');\n    --icon-UseScience: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzIzMTk0MiIgZD0iTTggMTMuMDAwNEM4IDEyLjIwNDggNy42ODM5MyAxMS40NDE3IDcuMTIxMzIgMTAuODc5MUM2LjU1ODcxIDEwLjMxNjUgNS43OTU2NSAxMC4wMDA0IDUgMTAuMDAwNEM0LjY1Mzk1IDEwLjAwMzUgNC4zMTExMSAxMC4wNjcxIDMuOTg3IDEwLjE4ODRDMy41ODAxNyA5LjgxMzA5IDMuMjg1NTYgOS4zMzIxNyAzLjEzNTk4IDguNzk5MjZDMi45ODY0IDguMjY2MzQgMi45ODc3NCA3LjcwMjM2IDMuMTM5ODQgNy4xNzAxNkMzLjI5MTk0IDYuNjM3OTYgMy41ODg4NCA2LjE1ODQ1IDMuOTk3NDMgNS43ODUwNkM0LjQwNjAzIDUuNDExNjcgNC45MTAyOCA1LjE1OTA3IDUuNDU0IDUuMDU1NDFMNi42NTQgNy4xMjk0MUM2LjcyMDMgNy4yNDQyNSA2LjgyOTUxIDcuMzI4MDQgNi45NTc2IDcuMzYyMzZDNy4wODU2OSA3LjM5NjY4IDcuMjIyMTYgNy4zNzg3MSA3LjMzNyA3LjMxMjQxTDkuMDcxIDYuMzEyNDFDOS4xODU4MyA2LjI0NjEgOS4yNjk2MyA2LjEzNjg5IDkuMzAzOTUgNi4wMDg4MUM5LjMzODI3IDUuODgwNzIgOS4zMjAzIDUuNzQ0MjUgOS4yNTQgNS42Mjk0MUw2LjI1NCAwLjQyOTQwOEM2LjIyMDgzIDAuMzcyMzQ4IDYuMTc2NyAwLjMyMjQxOCA2LjEyNDE0IDAuMjgyNDk4QzYuMDcxNTkgMC4yNDI1NzggNi4wMTE2NSAwLjIxMzQ1OSA1Ljk0Nzc4IDAuMTk2ODE4QzUuODgzOTEgMC4xODAxNzggNS44MTczOSAwLjE3NjM0NiA1Ljc1MjAzIDAuMTg1NTQzQzUuNjg2NjggMC4xOTQ3NDEgNS42MjM3OSAwLjIxNjc4NiA1LjU2NyAwLjI1MDQwOEwzLjgzMyAxLjI1MDQxQzMuNzE4MzMgMS4zMTY4OCAzLjYzNDc1IDEuNDI2MTYgMy42MDA2MiAxLjU1NDI0QzMuNTY2NDkgMS42ODIzMSAzLjU4NDYxIDEuODE4NjkgMy42NTEgMS45MzM0MUw0LjQyOCAzLjI3ODQxQzMuNjIzNDkgMy41NDEzIDIuODk5MzYgNC4wMDUwOSAyLjMyNDEgNC42MjU5MUMxLjc0ODgzIDUuMjQ2NzMgMS4zNDE0NyA2LjAwNDA0IDEuMTQwNTMgNi44MjYyMUMwLjkzOTU5MyA3LjY0ODM4IDAuOTUxNzMgOC41MDgyMiAxLjE3NTc5IDkuMzI0MzlDMS4zOTk4NiAxMC4xNDA2IDEuODI4NDQgMTAuODg2MSAyLjQyMSAxMS40OTA0QzIuMTQ4MSAxMS45NDcgMi4wMDI3MiAxMi40Njg1IDIgMTMuMDAwNFYxNi4wMDA0SDE0VjE0LjAwMDRIOFYxMy4wMDA0WiIvPjxwYXRoIGZpbGw9IiMyMzE5NDIiIGQ9Ik04LjgzMDA1IDExLjkxMTVMMTMuMjg3MSA5LjMzODQ3TDEyLjI4NzEgNy42MDU0N0w3LjgzMTA1IDEwLjE3ODVDOC4zMDYxMiAxMC42NjE3IDguNjQ5OTggMTEuMjU4MiA4LjgzMDA1IDExLjkxMTVaIi8+PC9zdmc+');\n  }\n}\n"
  },
  {
    "path": "static/icons/locales/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Yummygum (https://github.com/Yummygum/flagpack-core)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "static/index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"utf8\">\n  </head>\n  <body>\n    <script src='/main.bundle.js'></script>\n  </body>\n</html>\n"
  },
  {
    "path": "static/locales/ar.client.json",
    "content": "{\n  \"AccessRules\": {\n    \"Permission to access the document in full when needed\": \"الإذن بالنفاذ إلى المستند بالكامل عند الحاجة\",\n    \"Permissions\": \"الأذون\",\n    \"Type message to display when this rule blocks an action…\": \"اكتب رسالة…\",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"السماح للمحررين بتحرير البنية (أي التعديل والحذف في الجداول والأعمدة والترتيبات)، وكتابة الصيغ الرياضية، وهذا يجعلهم نافذين إلى كل البيانات بغض النظر عن قيود القراءة.\",\n    \"Save\": \"حفظ\"\n  },\n  \"AccountPage\": {\n    \"Theme\": \"السمة\",\n    \"Change password\": \"تغيير كلمة السر\",\n    \"Password & security\": \"كلمة السر والأمان\",\n    \"Account settings\": \"إعدادات الحساب\",\n    \"Two-factor authentication\": \"المصادقة ذات العاملين\",\n    \"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.\": \"المصادقة ذات العاملين Two-factor authentication طبقة إضافية من الحماية لحسابك في Grista مصممة للتأكد من أنك الشخص الوحيد القادر على النفاذ إلى حسابك، حتى لو كان شخص آخر يعرف كلمة السر خاصتك.\",\n    \"Language\": \"اللغة\",\n    \"Edit\": \"تحرير\",\n    \"Login method\": \"طريقة الدخول\",\n    \"Allow signing in to this account with Google\": \"السماح بالدخول إلى هذا الحساب باستخدام Google\",\n    \"Save\": \"حفظ\"\n  },\n  \"AccountWidget\": {\n    \"Add account\": \"إضافة حساب\",\n    \"Switch Accounts\": \"تبديل الحساب\",\n    \"Activation\": \"التفعيل\",\n    \"Support Grist\": \"ادعم Grist\",\n    \"Upgrade Plan\": \"ترقية الخطة\",\n    \"Sign out\": \"خروج\",\n    \"Profile settings\": \"إعدادات الملف الشخصي\",\n    \"Sign in\": \"دخول\",\n    \"Pricing\": \"التسعير\",\n    \"Use This Template\": \"استعمل هذا القالب\",\n    \"Billing account\": \"حساب الفوترة\",\n    \"Accounts\": \"الحسابات\",\n    \"Sign up\": \"خروج\"\n  },\n  \"ACUserManager\": {\n    \"Invite new member\": \"دعوة عضو جديد\",\n    \"We'll email an invite to {{email}}\": \"سنرسل دعوة إلى البريد الإلكتروني {{email}}\",\n    \"Enter email address\": \"أدخل عنوان البريد الإلكتروني\"\n  },\n  \"ApiKey\": {\n    \"Click to show\": \"انقر لتعرض\"\n  },\n  \"ChartView\": {\n    \"Pick a column\": \"اختر عمودا\"\n  },\n  \"CellContextMenu\": {\n    \"Insert row below\": \"إدراج صف تحته\",\n    \"Copy\": \"نسخ\",\n    \"Delete {{count}} columns_other\": \"حذف {{count}} من الأعمدة\",\n    \"Insert row above\": \"إدراج صف فوقه\",\n    \"Delete {{count}} rows_other\": \"حذف {{count}} من الصفوف\",\n    \"Comment\": \"تعليق\",\n    \"Insert column to the right\": \"إدراج عمود إلى اليمين\",\n    \"Cut\": \"قص\",\n    \"Insert column to the left\": \"إدراج عمود إلى اليسار\",\n    \"Paste\": \"لصق\"\n  },\n  \"ColumnFilterMenu\": {\n    \"No matching values\": \"لا قيم مطابقة\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"تطبيق\",\n    \"Cancel\": \"إلغاء\"\n  },\n  \"AppHeader\": {\n    \"Personal Site\": \"الموقع الشخصي\",\n    \"Home page\": \"الصفحة الرئيسية\",\n    \"Team Site\": \"موقع الفريق\",\n    \"Grist Templates\": \"قوالب Grist\"\n  },\n  \"CustomSectionConfig\": {\n    \" (optional)\": \" (اختياري)\"\n  },\n  \"DataTables\": {\n    \"Click to copy\": \"انقر لتنسخ\"\n  },\n  \"DocHistory\": {\n    \"Activity\": \"النشاط\"\n  },\n  \"AppModel\": {\n    \"This team site is suspended. Documents can be read, but not modified.\": \"موقع الفريق هذا معلق. يمكن قراءة المستندات لكن لا يمكن تعديلها.\"\n  }\n}\n"
  },
  {
    "path": "static/locales/ar.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"Loading...\": \"تحميل ...\",\n        \"og-description\": \"برنامج حديث لجداول البيانات يتجاوز المخططات التقليدية\",\n        \"og-title\": \"قريست، الجيل القادم من جداول البيانات\"\n    }\n}\n"
  },
  {
    "path": "static/locales/bci.client.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/bci.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/bg.client.json",
    "content": "{\n  \"ACUserManager\": {\n    \"Invite new member\": \"Покани нов член\",\n    \"Enter email address\": \"Въведете e-mail адрес\",\n    \"We'll email an invite to {{email}}\": \"Ще изпратим покана до {{email}}\"\n  },\n  \"AccessRules\": {\n    \"Add column rule\": \"Добави правило за колона\",\n    \"Add user attributes\": \"Добави потребителски атрибути\",\n    \"Add Default Rule\": \"Добави правило по подразбиране\",\n    \"Attribute name\": \"Име на атрибут\",\n    \"Checking...\": \"Проверяне…\",\n    \"Condition\": \"Условие\",\n    \"Default rules\": \"Правила по подразбиране\",\n    \"Delete table rules\": \"Изтрий правилата на таблицата\",\n    \"Enter Condition\": \"Въведи условие\",\n    \"Everyone\": \"Всеки\",\n    \"Everyone Else\": \"Всички останали\",\n    \"Invalid\": \"Невалиден\",\n    \"Attribute to Look Up\": \"Атрибут за търсене\",\n    \"Allow everyone to view Access Rules.\": \"Позволи всички да четат правилата за достъп.\",\n    \"Lookup Column\": \"Колона за търсене\",\n    \"Permission to access the document in full when needed\": \"Разрешение за пълен достъп до документа, когато е необходимо\",\n    \"Remove column {{- colId }} from {{- tableId }} rules\": \"Премахни колона {{- colId }} от правилата на {{- tableId }}\",\n    \"Remove {{- name }} user attribute\": \"Премани потребителски атрибут {{- name }}\",\n    \"Reset\": \"Нулирай\",\n    \"Save\": \"Съхрани\",\n    \"Type message to display when this rule blocks an action…\": \"Напиши съобщение.…\",\n    \"User Attributes\": \"Потреибтелски атрибути\",\n    \"View as\": \"Разгледай като\",\n    \"Permission to edit document structure\": \"Пълномощия за редакция на документната структура\",\n    \"Add table rules\": \"Добави правила за таблица\",\n    \"Lookup Table\": \"Таблица за търсене\",\n    \"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.\": \"Позволи на всички да копират целия документ или да го прегледат изцяло в режим \\\"fiddle\\\".\\nУдобно за примери и шаблони, но не и за чувствители данни.\",\n    \"Permission to view Access Rules\": \"Разрешение за преглед на правилата за достъп\",\n    \"Saved\": \"Съхранено\",\n    \"Permissions\": \"Правомощия\",\n    \"Special rules\": \"Специални правила\",\n    \"Remove {{- tableId }} rules\": \"Премахни правилата на {{- tableId }}\",\n    \"Seed rules\": \"Начални правила\",\n    \"Rules for table \": \"Правила за таблица \",\n    \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"При добавяне на правила за таблица, автоматично добави правило позволяващо пълен достъп на СОБСТВЕНИК.\",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Позволете на редакторите да редактират структура (напр. да променят и изтриват таблици, колони, оформления) и да пишат формули, които дават достъп до всички данни, независимо от ограниченията за четене.\",\n    \"This default should be changed if editors' access is to be limited. \": \"Това по подразбиране трябва да се промени, ако трябва да се ограничи достъпът на редакторите. \",\n    \"Add table-wide rule\": \"Добавете правило за цялата таблица\"\n  },\n  \"AccountPage\": {\n    \"API Key\": \"API ключ\",\n    \"API\": \"API\",\n    \"Account settings\": \"Потребителски настройки\",\n    \"Allow signing in to this account with Google\": \"Позволи вписване чрез Google за този потребител\",\n    \"Change password\": \"Промени парола\",\n    \"Login method\": \"Метод на вписване\",\n    \"Name\": \"Име\",\n    \"Names only allow letters, numbers and certain special characters\": \"Имената съдържат само букви, цифри и специални символи\",\n    \"Language\": \"Език\",\n    \"Edit\": \"Редактирай\",\n    \"Email\": \"E-mail\",\n    \"Password & security\": \"Пароли и сигурност\",\n    \"Save\": \"Съхрани\",\n    \"Theme\": \"Тема\",\n    \"Two-factor authentication\": \"Двуфакторно удостоверяване\",\n    \"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.\": \"Двуфакторното удостоверяване е допълнителен слой на сигурност за вашия акаунт в Grist, предназначен да гарантира, че вие сте единственият човек, който има достъп до вашия акаунт, дори ако някой знае паролата ви.\"\n  },\n  \"AccountWidget\": {\n    \"Access Details\": \"Данни за достъп\",\n    \"Accounts\": \"Потребители\",\n    \"Add account\": \"Добави потребител\",\n    \"Document settings\": \"Документни настройки\",\n    \"Manage team\": \"Управлявай екипа\",\n    \"Pricing\": \"Ценоразпис\",\n    \"Profile settings\": \"Настройки на профила\",\n    \"Sign out\": \"Изход\",\n    \"Sign in\": \"Вход\",\n    \"Switch Accounts\": \"Смени потребител\",\n    \"Toggle Mobile Mode\": \"Превкючи мобилен режим\",\n    \"Activation\": \"Активиране\",\n    \"Billing account\": \"Потребител за таксуване\",\n    \"Support Grist\": \"Поддъжка на Grist\",\n    \"Upgrade Plan\": \"Надгради план\",\n    \"Use This Template\": \"Ползвай този образец\",\n    \"Sign up\": \"Регистрация\"\n  },\n  \"ViewAsDropdown\": {\n    \"Users from table\": \"Потребители от таблица\",\n    \"View as\": \"Разгледай като\",\n    \"Example Users\": \"Примерни потребители\"\n  },\n  \"ActionLog\": {\n    \"Action Log failed to load\": \"Не можа да зареди регистъра с действията\",\n    \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Колона {{colId}} е премахната с действие {{action.actionNum}}\",\n    \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Таблица {{tableId}} е премахната с действие {{actionNum}}\",\n    \"This row was subsequently removed in action {{action.actionNum}}\": \"Този ред е премахнат с действие {{action.actionNum}}\",\n    \"All tables\": \"Всички таблици\"\n  },\n  \"AddNewButton\": {\n    \"Add new\": \"Добави нов\"\n  },\n  \"ApiKey\": {\n    \"Click to show\": \"Цъкнете за показване\",\n    \"Create\": \"Създай\",\n    \"Remove\": \"Премахни\",\n    \"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 ключ. Това ще блокира всички бъдещи заявки, използващи този API ключ. Потвърждавате ли изтриването?\",\n    \"By generating an API key, you will be able to make API calls for your own account.\": \"С генериране на API ключ ще можете да правите API извиквания чрез вашия собствен акаунт.\",\n    \"Remove API Key\": \"Премахни API ключ\",\n    \"This API key can be used to access this account anonymously via the API.\": \"Този API ключ може да се използва за анонимен достъп до този потребител чрез API-то.\",\n    \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Този API ключ може да се използва за достъп до вашия потребител чрез API-то. Не споделяйте вашия API ключ с никого.\"\n  },\n  \"App\": {\n    \"Description\": \"Описание\",\n    \"Key\": \"Ключ\",\n    \"Memory Error\": \"Грешка в паметта\",\n    \"Translators: please translate this only when your language is ready to be offered to users\": \"Преводачи: моля, превеждайте това само когато вашият език е готов да бъде предложен на потребителите\"\n  },\n  \"AppHeader\": {\n    \"Home page\": \"Начална страница\",\n    \"Legacy\": \"Вехто\",\n    \"Team Site\": \"Сайт на екипа\",\n    \"Grist Templates\": \"Grist образци\",\n    \"Personal Site\": \"Личен сайт\"\n  },\n  \"CellContextMenu\": {\n    \"Clear values\": \"Изчисти стойности\",\n    \"Copy anchor link\": \"Копирай връзка към котва\",\n    \"Delete {{count}} columns_one\": \"Изтрий колона\",\n    \"Delete {{count}} columns_other\": \"Изтрий {{count}} колони\",\n    \"Duplicate rows_other\": \"Дублирай редовете\",\n    \"Duplicate rows_one\": \"Дублирай реда\",\n    \"Filter by this value\": \"Отсяване по тази стойност\",\n    \"Insert column to the right\": \"Вмъкни колона вдясно\",\n    \"Insert row above\": \"Вмъкни ред отгоре\",\n    \"Insert row below\": \"Вмъкни ред отдолу\",\n    \"Reset {{count}} entire columns_other\": \"Нулирай {{count}} цели колони\",\n    \"Copy\": \"Копирай\",\n    \"Comment\": \"Коментирай\",\n    \"Cut\": \"Изрежи\",\n    \"Clear cell\": \"Изчисти клетка\",\n    \"Delete {{count}} rows_one\": \"Изтрий ред\",\n    \"Delete {{count}} rows_other\": \"Изтрий {{count}} реда\",\n    \"Insert column to the left\": \"Вмъкни колона отляво\",\n    \"Insert row\": \"Вмъкни ред\",\n    \"Reset {{count}} columns_one\": \"Нулиране на колона\",\n    \"Reset {{count}} columns_other\": \"Нулирай {{count}} колони\",\n    \"Reset {{count}} entire columns_one\": \"Нулирай цялата колона\",\n    \"Paste\": \"Постави\"\n  },\n  \"ChartView\": {\n    \"Each Y series is followed by two series, for top and bottom error bars.\": \"Всяка Y редица е последвана от две редици, за горната и долната лента за грешки.\",\n    \"Pick a column\": \"Избери колона\",\n    \"Toggle chart aggregation\": \"Превключи обединяването на диаграми\",\n    \"Create separate series for each value of the selected column.\": \"Създай отделна редица за всяка стойност на избраната колона.\",\n    \"Each Y series is followed by a series for the length of error bars.\": \"Всяка Y редица е последвана от редица за дължината на лентите за грешки.\",\n    \"selected new group data columns\": \"избрани нови колони с групови данни\"\n  },\n  \"CodeEditorPanel\": {\n    \"Access denied\": \"Отказан достъп\",\n    \"Code View is available only when you have full document access.\": \"Изгледът на код е наличен само когато имате пълен достъп до документа.\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"Приложи\",\n    \"Cancel\": \"Отказ\",\n    \"Default cell style\": \"Стил на клетка по подразбиране\"\n  },\n  \"ColumnFilterMenu\": {\n    \"All\": \"Всички\",\n    \"All shown\": \"Всички показани\",\n    \"Filter by Range\": \"Отсявай по обхват\",\n    \"Future values\": \"Бъдещи стойности\",\n    \"No matching values\": \"Няма съответстващи стойности\",\n    \"None\": \"Нито един\",\n    \"Min\": \"Мин\",\n    \"Max\": \"Макс\",\n    \"End\": \"Край\",\n    \"Other Matching\": \"Друго съпоставяне\",\n    \"Search values\": \"Търси стойности\",\n    \"All except\": \"Всички, освен\",\n    \"Start\": \"Начало\",\n    \"Other Non-Matching\": \"Други несъответстващи\",\n    \"Other values\": \"Други стойности\",\n    \"Search\": \"Търси\",\n    \"Others\": \"Други\"\n  },\n  \"CustomSectionConfig\": {\n    \" (optional)\": \" (по избор)\",\n    \"Add\": \"Добави\",\n    \"Full document access\": \"Пълен достъп до документи\",\n    \"Enter Custom URL\": \"Въведи собствен URL\",\n    \"No document access\": \"Няма достъп до документа\",\n    \"Widget does not require any permissions.\": \"Джаджата не изисква никакви правомощия.\",\n    \"Widget needs {{fullAccess}} to this document.\": \"Джажата се нуждае от {{fullAccess}} до този документ.\",\n    \"Pick a column\": \"Избери колона\",\n    \"Pick a {{columnType}} column\": \"Избери колона от тип {{columnType}}\",\n    \"Learn more about custom widgets\": \"Научи повече за собствените джаджи\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"не се показва {{wrongTypeCount}} колона, които не е от тип {{columnType}}\",\n    \"Clear selection\": \"Изчисти избирането\",\n    \"No {{columnType}} columns in table.\": \"Няма {{columnType}} колони в таблицата.\",\n    \"Open configuration\": \"Отвори конфигурацията\",\n    \"Read selected table\": \"Прочети избраната таблица\",\n    \"Widget needs to {{read}} the current table.\": \"Джажата трябва да {{read}} текущата таблица.\",\n    \"Select Custom Widget\": \"Избери собствена джаджа\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"не се показват {{wrongTypeCount}} колони, които не са от тип {{columnType}}\"\n  },\n  \"DataTables\": {\n    \"Raw Data Tables\": \"Таблици с необработени данни\",\n    \"You do not have edit access to this document\": \"Нямате достъп за редактиране на този документ\",\n    \"Edit record card\": \"Редаткриане на карта със запис\",\n    \"Record Card Disabled\": \"Картата със запис е деактивирана\",\n    \"{{action}} Record Card\": \"{{action}} карта със запис\",\n    \"Table ID copied to clipboard\": \"Обозначението на таблицата е копиран в системния буфер\",\n    \"Click to copy\": \"Цъкнете, за да копирате\",\n    \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Да се изтрият ли данните от {{formattedTableName}} и да се премахнат ли от всички страници?\",\n    \"Duplicate table\": \"Дублирай таблицата\",\n    \"Record Card\": \"Карта със запис\",\n    \"Remove table\": \"Премахни таблица\",\n    \"Rename table\": \"Преименувай таблица\"\n  },\n  \"DocHistory\": {\n    \"Activity\": \"Дейност\",\n    \"Snapshots\": \"Моментни снимки\",\n    \"Compare to previous\": \"Сравнете с предишно\",\n    \"Beta\": \"Бета\",\n    \"Compare to current\": \"Сравнете с текущо\",\n    \"Open snapshot\": \"Отворете моментната снимка\",\n    \"Snapshots are unavailable.\": \"Моментните снимки не са налични.\",\n    \"Only owners have access to snapshots for documents with access rules.\": \"Само собствениците имат достъп до моментни снимки за документи ограничени от правила за достъп.\"\n  },\n  \"DocMenu\": {\n    \"Access Details\": \"Данни за достъп\",\n    \"Delete\": \"Изтрий\",\n    \"Delete Forever\": \"Изтрий завинаги\",\n    \"Delete {{name}}\": \"Изтрий {{name}}\",\n    \"Deleted {{at}}\": \"Изтрито на {{at}}\",\n    \"Document will be permanently deleted.\": \"Документът ще бъде изтрит за постоянно.\",\n    \"Edited {{at}}\": \"Редактиран на {{at}}\",\n    \"Examples & Templates\": \"Примери и образци\",\n    \"Examples and Templates\": \"Примери и образци\",\n    \"Featured\": \"Подбрани\",\n    \"Permanently Delete \\\"{{name}}\\\"?\": \"Изтриване за постоянно на „{{name}}“?\",\n    \"Pin Document\": \"Закачи документ\",\n    \"Pinned Documents\": \"Закачени документи\",\n    \"Remove\": \"Премахни\",\n    \"Rename\": \"Преименувай\",\n    \"Trash\": \"Кошче\",\n    \"Trash is empty.\": \"Кошчето е празно.\",\n    \"Workspace not found\": \"Работното пространство не е намерено\",\n    \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Вие сте на сайта {{siteName}}. Имате достъп и до следните сайтове:\",\n    \"(The organization needs a paid plan)\": \"(Организацията се нуждае от платен абонамент)\",\n    \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Документите остават в кошчето 30 дни, след което се изтриват за постоянно.\",\n    \"Manage users\": \"Управлявай потребители\",\n    \"Move\": \"Премести\",\n    \"More Examples and Templates\": \"Още примери и образци\",\n    \"Move {{name}} to workspace\": \"Премести {{name}} в работното пространство\",\n    \"Other Sites\": \"Други сайтове\",\n    \"Requires edit permissions\": \"Изисква правомощия за редактиране\",\n    \"Restore\": \"Възстанови\",\n    \"This service is not available right now\": \"Тази услуга не е достъпна в момента\",\n    \"To restore this document, restore the workspace first.\": \"За да възстановите този документ, първо възстановете работното пространство.\",\n    \"Unpin Document\": \"Откачи документ\",\n    \"You are on your personal site. You also have access to the following sites:\": \"Вие сте на вашия личен сайт. Имате достъп и до следните сайтове:\",\n    \"You may delete a workspace forever once it has no documents in it.\": \"Можете да изтриете работно пространство завинаги, след като в него няма документи.\",\n    \"All documents\": \"Всички документи\",\n    \"Discover More Templates\": \"Открийте още образци\",\n    \"By Date Modified\": \"по дата на промяна\",\n    \"By Name\": \"по име\",\n    \"Current workspace\": \"Текущо работно пространство\",\n    \"Document will be moved to Trash.\": \"Документът ще бъде преместен в кошчето.\"\n  },\n  \"DocPageModel\": {\n    \"Add empty table\": \"Добави празна таблица\",\n    \"Add page\": \"Добави страница\",\n    \"Add widget to page\": \"Добави джаджа в страницата\",\n    \"Enter recovery mode\": \"Влезте в режим на възстановяване\",\n    \"Sorry, access to this document has been denied. [{{error}}]\": \"За съжаление достъпът до този документ е отказан. [{{error}}]\",\n    \"Document owners can attempt to recover the document. [{{error}}]\": \"Собствениците на документи могат да опитат да възстановят документа. [{{error}}]\",\n    \"You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]\": \"Можете да опитате да презаредите документа или да използвате режим на възстановяване. Режимът за възстановяване отваря документа, за да бъде напълно достъпен за собствениците и недостъпен за други. Режимът също така деактивира и формулите. [{{error}}]\",\n    \"Error accessing document\": \"Грешка при достъпа до документа\",\n    \"Reload\": \"Презареди\",\n    \"You do not have edit access to this document\": \"Нямате достъп за редактиране на този документ\"\n  },\n  \"DocTour\": {\n    \"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.\": \"Не може да изгради наръч от документи от данните в този документ. Уверете се, че има таблица с име GristDocTour с колони Заглавие, Тяло, Разположение и Местоположение.\",\n    \"No valid document tour\": \"Невалиден наръч от документи\"\n  },\n  \"DocumentSettings\": {\n    \"Currency:\": \"Валута:\",\n    \"Document settings\": \"Настройки на документа\",\n    \"Engine (experimental {{span}} change at own risk):\": \"Двигател (експериментално, {{span}} променяй на собствена отговорност):\",\n    \"Local currency ({{currency}})\": \"Местна валута ({{currency}})\",\n    \"Locale:\": \"Регионални настройки:\",\n    \"Save\": \"Съхрани\",\n    \"Save and Reload\": \"Съхрани и презареди\",\n    \"Ok\": \"Добре\",\n    \"Webhooks\": \"Webhooks\",\n    \"API console\": \"API конзола\",\n    \"API documentation.\": \"API документация.\",\n    \"Copy to clipboard\": \"Копирай в системния буфер\",\n    \"Data engine\": \"Двигател обработващ данните\",\n    \"Default for DateTime columns\": \"Начален за колони от тип дати\",\n    \"For number and date formats\": \"За формати на числа и дати\",\n    \"Formula times\": \"Формула пъти\",\n    \"ID for API use\": \"Обозначение в API\",\n    \"Manage webhooks\": \"Управлявай webhooks\",\n    \"Python\": \"Python\",\n    \"Python version used\": \"Ползвана версия на Python\",\n    \"python3 (recommended)\": \"python3 (препоръчан)\",\n    \"API\": \"API\",\n    \"Time Zone:\": \"Времеви пояс:\",\n    \"Document ID copied to clipboard\": \"Обозначението на документа е копиран в системния буфер\",\n    \"Manage Webhooks\": \"Управлявай webhooks\",\n    \"API URL copied to clipboard\": \"URL адресът на API-то е копиран в системния буфер\",\n    \"Base doc URL: {{docApiUrl}}\": \"Основен URL адрес на документа (API): {{docApiUrl}}\",\n    \"Currency\": \"Валута\",\n    \"Coming soon\": \"Очаквайте скоро\",\n    \"Document ID\": \"Обозначение на документ\",\n    \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"Обозначение на документ, което да се ползва за REST API извиквания към {{docId}}. Вижте {{apiURL}}\",\n    \"Find slow formulas\": \"Намери бавни формули\",\n    \"For currency columns\": \"За колони във валута\",\n    \"Hard reset of data engine\": \"Пълно рестартиране на двигателя обработващ данните\",\n    \"This document's ID (for API use):\": \"Обозначението на документа (за ползване чрез API):\",\n    \"Locale\": \"Регионални настройки\",\n    \"Notify other services on doc changes\": \"Уведомете другите услуги за промени в документа\",\n    \"Reload\": \"Презареди\",\n    \"Time zone\": \"Времеви пояс\",\n    \"Try API calls from the browser\": \"Опитайте API извиквания от браузъра\",\n    \"python2 (legacy)\": \"python2 (овехтял)\"\n  },\n  \"DocumentUsage\": {\n    \"Size of attachments\": \"Размер на файловете\",\n    \"Data size\": \"Размер на данните\",\n    \"For higher limits, \": \"За по-високи граници, \",\n    \"Rows\": \"Редове\",\n    \"Usage\": \"Ползване\",\n    \"Usage statistics are only available to users with full access to the document data.\": \"Статистическите данни за използването са достъпни само за потребители с пълен достъп до данните за документите.\",\n    \"Contact the site owner to upgrade the plan to raise limits.\": \"Свържете се със собственика на сайта, за да надстроите абонамента си и да увеличите ограниченията.\",\n    \"start your 30-day free trial of the Pro plan.\": \"започнете 30-дневната си безплатна пробна версия на абонамента Pro.\"\n  },\n  \"Drafts\": {\n    \"Restore last edit\": \"Възстановяване на последната редакция\",\n    \"Undo discard\": \"Отмяна на изхвърлянето\"\n  },\n  \"DuplicateTable\": {\n    \"Copy all data in addition to the table structure.\": \"Копирайте и всички данни в допълнение към структурата на таблицата.\",\n    \"Name for new table\": \"Име на новата таблица\",\n    \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Вместо да дублирате таблици, обикновено е по-добре да сегментирате данните с помощта на свързани изгледи. {{link}}\",\n    \"Only the document default access rules will apply to the copy.\": \"За копието ще се прилагат само правилата за достъп по подразбиране на документа.\"\n  },\n  \"ExampleInfo\": {\n    \"Afterschool Program\": \"Програма за извънкласни занимания\",\n    \"Welcome to the Afterschool Program template\": \"Добре дошли в образеца на програмата за извънкласни занимания\",\n    \"Welcome to the Investment Research template\": \"Добре дошли в образеца за инвестиционни проучвания\",\n    \"Welcome to the Lightweight CRM template\": \"Добре дошли в образец за олекотена CRM система\",\n    \"Tutorial: Analyze & Visualize\": \"Урок: Анализиране и визуализиране\",\n    \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Разгледайте нашия свързан урок за това как да свързвате данни и създавате оформления с висока производителност.\",\n    \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Разгледайте нашето свързано ръководство за моделиране на бизнес данни, използване на формули и управление на сложността.\",\n    \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Разгледайте нашия свързан урок, за да научите как да създавате обобщаващи таблици и диаграми и да свързвате диаграми динамично.\",\n    \"Investment Research\": \"Инвестиционни проучвания\",\n    \"Lightweight CRM\": \"Олекотена CRM система\",\n    \"Tutorial: Create a CRM\": \"Урок: Създаване на CRM система\",\n    \"Tutorial: Manage Business Data\": \"Урок: Управление на бизнес данни\"\n  },\n  \"FieldConfig\": {\n    \"COLUMN BEHAVIOR\": \"ПОВЕДЕНИЕ НА КОЛОНАТА\",\n    \"COLUMN LABEL AND ID\": \"ЕТИКЕТ И ОБОЗНАЧЕНИЕ НА КОЛОНАТА\",\n    \"Convert column to data\": \"Преобразувай колоната в данни\",\n    \"Convert to trigger formula\": \"Преобразувай във формула за задействане\",\n    \"Data columns_other\": \"Колони с данни\",\n    \"Data columns_one\": \"Колона с данни\",\n    \"Empty columns_one\": \"Празна колона\",\n    \"Enter formula\": \"Въведи формула\",\n    \"Formula columns_one\": \"Колона с формула\",\n    \"Make into data column\": \"Превръщане в колона с данни\",\n    \"Mixed Behavior\": \"Смесено поведение\",\n    \"Set formula\": \"Задай формула\",\n    \"Clear and make into formula\": \"Изчисти и превърни във формула\",\n    \"Clear and reset\": \"Изчисти и нулирай\",\n    \"Column options are limited in summary tables.\": \"Опциите за колоните са ограничени в обобщаващите таблици.\",\n    \"Empty columns_other\": \"Празни колони\",\n    \"Formula columns_other\": \"Колони с формули\",\n    \"Set trigger formula\": \"Задай формула за задействане\",\n    \"TRIGGER FORMULA\": \"ФОРМУЛА ЗА ЗАДЕЙСТВАНЕ\",\n    \"DESCRIPTION\": \"ОПИСАНИЕ\"\n  },\n  \"FieldMenus\": {\n    \"Save as common settings\": \"Запази като общи настройки\",\n    \"Use separate settings\": \"Използвай отделни настройки\",\n    \"Using common settings\": \"Използване на общи настройки\",\n    \"Revert to common settings\": \"Върни към общи настройки\",\n    \"Using separate settings\": \"Използване на отделни настройки\"\n  },\n  \"FilterConfig\": {\n    \"Add column\": \"Добавяне на колона\"\n  },\n  \"FilterBar\": {\n    \"SearchColumns\": \"Колони за търсене\",\n    \"Search Columns\": \"Колони за търсене\"\n  },\n  \"GridOptions\": {\n    \"Grid Options\": \"Опции на решетката\",\n    \"Zebra stripes\": \"Зеброви ивици\",\n    \"Horizontal gridlines\": \"Хоризонтални линии на решетката\",\n    \"Vertical gridlines\": \"Вертикални линии на решетката\"\n  },\n  \"GridViewMenus\": {\n    \"Add to sort\": \"Добави към сортирането\",\n    \"Clear values\": \"Изчисти стойностите\",\n    \"Column Options\": \"Настройки на колоната\",\n    \"Convert formula to data\": \"Преобразувай формула в данни\",\n    \"Delete {{count}} columns_one\": \"Изтрий колона\",\n    \"Delete {{count}} columns_other\": \"Изтрий {{count}} колони\",\n    \"Filter Data\": \"Oтсяване на данни\",\n    \"Freeze {{count}} columns_one\": \"Замрази тази колона\",\n    \"Freeze {{count}} columns_other\": \"Замрази {{count}} колони\",\n    \"Freeze {{count}} more columns_one\": \"Замрази една повече колони\",\n    \"Insert column to the {{to}}\": \"Вмъкни колона в{{to}}\",\n    \"More sort options ...\": \"Още опции за сортиране…\",\n    \"Rename column\": \"Преименувай колона\",\n    \"Show column {{- label}}\": \"Покажи колона {{- label}}\",\n    \"Sort\": \"Сортирай\",\n    \"Sorted (#{{count}})_other\": \"Сортирани (#{{count}})\",\n    \"Sorted (#{{count}})_one\": \"Сортирани (#{{count}})\",\n    \"Unfreeze all columns\": \"Размрази всички колони\",\n    \"Unfreeze {{count}} columns_one\": \"Размрази тази колона\",\n    \"Unfreeze {{count}} columns_other\": \"Размрази {{count}} колони\",\n    \"Insert column to the left\": \"Вмъкни колона вляво\",\n    \"Apply to new records\": \"Приложи към новите записи\",\n    \"Authorship\": \"Авторство\",\n    \"Last Updated At\": \"Последно обновено на\",\n    \"Last Updated By\": \"Последно обновено от\",\n    \"Lookups\": \"Прегледи\",\n    \"Shortcuts\": \"Кратки пътища\",\n    \"Show hidden columns\": \"Покажи скрити колони\",\n    \"Timestamp\": \"Клеймо за време\",\n    \"Detect Duplicates in...\": \"Откриване на дубликати в...\",\n    \"Search columns\": \"Колони за търсене\",\n    \"UUID\": \"UUID\",\n    \"Add column with type\": \"Добави колона с тип\",\n    \"Add formula column\": \"Добави колона с формули\",\n    \"Created by\": \"Създадено от\",\n    \"Detect duplicates in...\": \"Откриване на дубликати в...\",\n    \"Last updated at\": \"Последно обновено на\",\n    \"Last updated by\": \"Последно обновено от\",\n    \"Numeric\": \"Числен\",\n    \"Text\": \"Текст\",\n    \"Integer\": \"Целочислен\",\n    \"Toggle\": \"Превключващ\",\n    \"DateTime\": \"Дата и час\",\n    \"Choice List\": \"Списък с избори\",\n    \"Add column\": \"Добави колона\",\n    \"Freeze {{count}} more columns_other\": \"Замрази {{count}} повече колони\",\n    \"Hide {{count}} columns_one\": \"Скрий колона\",\n    \"Hide {{count}} columns_other\": \"Скрий {{count}} колони\",\n    \"Reset {{count}} columns_one\": \"Нулирай колона\",\n    \"Reset {{count}} entire columns_one\": \"Нулирай цяла колона\",\n    \"Reset {{count}} columns_other\": \"Нулирай {{count}} колона\",\n    \"Reset {{count}} entire columns_other\": \"Нулирай {{count}} цели колони\",\n    \"Insert column to the right\": \"Вмъкни колона вдясно\",\n    \"Hidden Columns\": \"Скрити колони\",\n    \"Apply on record changes\": \"Приложи промените в записа\",\n    \"no reference column\": \"няма референтна колона\",\n    \"Adding duplicates column\": \"Добавяне на колона с дубликати\",\n    \"Adding UUID column\": \"Добавяне на UUID колона\",\n    \"Created At\": \"Създадено на\",\n    \"Created By\": \"Създадено от\",\n    \"Duplicate in {{- label}}\": \"Дублиране в {{- label}}\",\n    \"No reference columns.\": \"Няма референтни колони.\",\n    \"Created at\": \"Създадено на\",\n    \"Any\": \"Всякакъв\",\n    \"Date\": \"Дата\",\n    \"Choice\": \"Избор\",\n    \"Reference\": \"Препратка\",\n    \"Reference List\": \"Списък с препратки\",\n    \"Attachment\": \"Закачен файл\"\n  },\n  \"GristDoc\": {\n    \"Import from file\": \"Внасяне от файл\",\n    \"Saved linked section {{title}} in view {{name}}\": \"Запазен свързан раздел {{title}} в изглед {{name}}\",\n    \"go to webhook settings\": \"отидете при настройките на webhook\",\n    \"Added new linked section to view {{viewName}}\": \"Добавена е нова свързана секция за преглед на {{viewName}}\"\n  },\n  \"HomeIntro\": {\n    \"Any documents created in this site will appear here.\": \"Всички документи, създадени в този сайт, ще се показват тук.\",\n    \"Browse Templates\": \"Преглед на образците\",\n    \"Create empty document\": \"Създаване на празен документ\",\n    \"Get started by inviting your team and creating your first Grist document.\": \"Започнете, като поканите своя екип и създадете първия си Grist документ.\",\n    \"Help Center\": \"Център за помощ\",\n    \"Import document\": \"Внасяне на документ\",\n    \"Interested in using Grist outside of your team? Visit your free \": \"Интересувате ли се от използването на Grist извън вашия екип? Посетете вашия безплатен \",\n    \"Sprouts Program\": \"Програма \\\"Покълване\\\"\",\n    \"This workspace is empty.\": \"Това работно пространство е празно.\",\n    \"Visit our {{link}} to learn more.\": \"Посетете нашата {{link}}, за да научите повече.\",\n    \"Welcome to Grist!\": \"Добре дошли в Grist!\",\n    \"Welcome to Grist, {{name}}!\": \"Добре дошли в Grist, {{name}}!\",\n    \"personal site\": \"личен сайт\",\n    \"{{signUp}} to save your work. \": \"{{signUp}}, за да запазите работата си. \",\n    \"Welcome to {{- orgName}}\": \"Добре дошли в {{- orgName}}\",\n    \"Sign in\": \"Вписване\",\n    \"To use Grist, please either sign up or sign in.\": \"За да използвате Grist, моля, регистрирайте се или се впишете.\",\n    \"Visit our {{link}} to learn more about Grist.\": \"Посетете {{link}}, за да научите повече за Grist.\",\n    \"Learn more in our {{helpCenterLink}}.\": \"Научете повече в нашия {{helpCenterLink}}.\",\n    \"Get started by creating your first Grist document.\": \"Започнете, като създадете първия си Grist документ.\",\n    \"Get started by exploring templates, or creating your first Grist document.\": \"Започнете, като проучите образците или създадете първия си Grist документ.\",\n    \"Invite Team Members\": \"Поканете членове на екипа\",\n    \"Sign up\": \"Регистрация\",\n    \"Welcome to {{orgName}}\": \"Добре дошли в {{orgName}}\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Имате достъп само за четене до този сайт. В момента няма документи.\",\n    \"Welcome to Grist, {{- name}}!\": \"Добре дошли в Grist, {{- name}}!\"\n  },\n  \"HomeLeftPane\": {\n    \"Create workspace\": \"Създайте работно пространство\",\n    \"Delete\": \"Изтрий\",\n    \"Delete {{workspace}} and all included documents?\": \"Изтрий {{workspace}} и всичките му документи?\",\n    \"Examples & Templates\": \"Образци\",\n    \"Import document\": \"Внеси документ\",\n    \"Manage users\": \"Управлявай потребители\",\n    \"Access Details\": \"Данни за достъпа\",\n    \"All documents\": \"Всички документи\",\n    \"Create empty document\": \"Създай празен документ\",\n    \"Rename\": \"Преименувай\",\n    \"Trash\": \"Кошче\",\n    \"Workspace will be moved to Trash.\": \"Работното пространство ще бъде преместено в кошчето.\",\n    \"Workspaces\": \"Работни пространства\",\n    \"Tutorial\": \"Урок\"\n  },\n  \"Importer\": {\n    \"Select fields to match on\": \"Изберете полета за съответствие\",\n    \"Update existing records\": \"Обнови съществуващи записи\",\n    \"{{count}} unmatched field in import_one\": \"{{count}} полета без съответствие при внасяне\",\n    \"{{count}} unmatched field in import_other\": \"{{count}} несъответстващи полета при внасяне\",\n    \"{{count}} unmatched field_one\": \"{{count}} несъответстващо поле\",\n    \"Column Mapping\": \"Съпоставяне на колони\",\n    \"Grist column\": \"Grist колона\",\n    \"Import from file\": \"Внеси от файл\",\n    \"New Table\": \"Нова таблица\",\n    \"Revert\": \"Върни\",\n    \"Skip\": \"Прескочи\",\n    \"Skip Import\": \"Прескочи внасяне\",\n    \"Source column\": \"Колона източник\",\n    \"Destination table\": \"Целева таблица\",\n    \"Merge rows that match these fields:\": \"Обединете редове, които съответстват на тези полета:\",\n    \"{{count}} unmatched field_other\": \"{{count}} несъответстващи полета\",\n    \"Column mapping\": \"Съпоставяне на колони\",\n    \"Skip Table on Import\": \"Прескочи таблицата при внасяне\"\n  },\n  \"LeftPanelCommon\": {\n    \"Help Center\": \"Център за помощ\"\n  },\n  \"MakeCopyMenu\": {\n    \"Cancel\": \"Отказ\",\n    \"Enter document name\": \"Въведете име на документа\",\n    \"However, it appears to be already identical.\": \"Но изглежда, че вече е идентичен.\",\n    \"Name\": \"Име\",\n    \"Organization\": \"Организация\",\n    \"Original Has Modifications\": \"Оригиналът има модификации\",\n    \"Original Looks Unrelated\": \"Оригиналът изглежда несвързан\",\n    \"Original Looks Identical\": \"Оригиналът изглежда идентичен\",\n    \"Overwrite\": \"Презапиши\",\n    \"Replacing the original requires editing rights on the original document.\": \"Подмяната на оригинала изисква права за редактиране на оригиналния документ.\",\n    \"Sign up\": \"Регистрация\",\n    \"Update\": \"Обнови\",\n    \"Workspace\": \"Работно пространство\",\n    \"You do not have write access to this site\": \"Нямате достъп за писане до този сайт\",\n    \"Download document and history\": \"Изтеглете пълния документ и хронология\",\n    \"As template\": \"Като образец\",\n    \"Include the structure without any of the data.\": \"Включете структурата без никакви данни.\",\n    \"The original version of this document will be updated.\": \"Оригиналната версия на този документ ще бъде обновена.\",\n    \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Бъдете внимателни, оригиналът има промени, които не са включени в този документ. Тези промени ще бъдат презаписани.\",\n    \"It will be overwritten, losing any content not in this document.\": \"Той ще бъде презаписан, като ще бъде изгубено всяко съдържание, което не е в този документ.\",\n    \"No destination workspace\": \"Няма целево работно пространство\",\n    \"You do not have write access to the selected workspace\": \"Нямате достъп за писане в избраното работно пространство\",\n    \"To save your changes, please sign up, then reload this page.\": \"За да запазите промените си, моля, регистрирайте се, след което презаредете тази страница.\",\n    \"Update Original\": \"Обнови оригинала\",\n    \"Download document structure only (no data, for template use)\": \"Премахни всички данни, но запазете структурата, която да използвате като образец\",\n    \"Download\": \"Изтегли\",\n    \"Download document without history (can significantly reduce file size)\": \"Премахни на хронологията на документа (може значително да намали размера на файла)\",\n    \"Download document\": \"Изтегли документ\"\n  },\n  \"NotifyUI\": {\n    \"Ask for help\": \"Потърси помощ\",\n    \"Give feedback\": \"Дайте обратна връзка\",\n    \"Go to your free personal site\": \"Отиди в безплатния си личен сайт\",\n    \"Report a problem\": \"Докладвай проблем\",\n    \"Manage billing\": \"Управление на таксуването\",\n    \"Cannot find personal site, sorry!\": \"Личният сайт не е намерен, съжалаваме!\",\n    \"No notifications\": \"Няма известия\",\n    \"Notifications\": \"Известия\",\n    \"Renew\": \"Поднови\",\n    \"Upgrade Plan\": \"Надгради абонамент\"\n  },\n  \"OnBoardingPopups\": {\n    \"Finish\": \"Край\",\n    \"Next\": \"Следващ\",\n    \"Previous\": \"Предишен\"\n  },\n  \"OpenVideoTour\": {\n    \"YouTube video player\": \"YouTube видеоплеър\",\n    \"Grist Video Tour\": \"Видеообиколка на Grist\",\n    \"Video Tour\": \"Видеообиколка\"\n  },\n  \"PageWidgetPicker\": {\n    \"Building {{- label}} widget\": \"Изграждане на джаджа {{- label}}\",\n    \"Group by\": \"Групирай по\",\n    \"Select data\": \"Избери данни\",\n    \"Add to page\": \"Добавяне към страницата\",\n    \"Select widget\": \"Избери джаджа\"\n  },\n  \"Pages\": {\n    \"Delete data and this page.\": \"Изтрий данните и тази страница.\",\n    \"The following tables will no longer be visible_one\": \"Следната таблица вече няма да се вижда\",\n    \"The following tables will no longer be visible_other\": \"Следните таблици вече няма да се виждат\",\n    \"Delete\": \"Изтрий\"\n  },\n  \"PermissionsWidget\": {\n    \"Allow all\": \"Разреши всички\",\n    \"Deny all\": \"Забрани всички\",\n    \"Read only\": \"Само за четене\"\n  },\n  \"PluginScreen\": {\n    \"Import failed: \": \"Неуспешно внасяне: \"\n  },\n  \"RecordLayoutEditor\": {\n    \"Add field\": \"Добави поле\",\n    \"Create new field\": \"Създай ново поле\",\n    \"Show field {{- label}}\": \"Покажи поле {{- label}}\",\n    \"Save layout\": \"Съхрани оформлението\",\n    \"Cancel\": \"Отказ\"\n  },\n  \"RightPanel\": {\n    \"CUSTOM\": \"ПО ИЗБОР\",\n    \"Change widget\": \"Промени джаджа\",\n    \"columns_one\": \"Колона\",\n    \"columns_other\": \"Колони\",\n    \"DATA TABLE\": \"ТАБЛИЦА С ДАННИ\",\n    \"DATA TABLE NAME\": \"ИМЕ НА ТАБЛИЦА С ДАННИ\",\n    \"Data\": \"Данни\",\n    \"Detach\": \"Отдели\",\n    \"Edit data selection\": \"Редактиране на избора на данни\",\n    \"fields_one\": \"Поле\",\n    \"fields_other\": \"Полета\",\n    \"Row style\": \"Стил на реда\",\n    \"Sort & filter\": \"Сортиране и отсяване\",\n    \"TRANSFORM\": \"ПРЕОБРАЗУВАЙ\",\n    \"Theme\": \"Тема\",\n    \"WIDGET TITLE\": \"ЗАГЛАВИЕ НА ДЖАДЖА\",\n    \"Widget\": \"Джаджа\",\n    \"You do not have edit access to this document\": \"Нямате достъп за редактиране на този документ\",\n    \"Add referenced columns\": \"Добавяне на колони с препратки\",\n    \"Reset form\": \"Нулирай формулярът\",\n    \"Configuration\": \"Конфигурация\",\n    \"Default field value\": \"Стойност на полето по подразбиране\",\n    \"Display button\": \"Бутон за показване\",\n    \"Field title\": \"Заглавие на полето\",\n    \"Hidden field\": \"Скрито поле\",\n    \"Redirect automatically after submission\": \"Автоматично пренасочване след подаване\",\n    \"Redirection\": \"Пренасочване\",\n    \"Required field\": \"Задължително поле\",\n    \"Submission\": \"Подаване\",\n    \"Submit another response\": \"Подай друг отговор\",\n    \"Submit button label\": \"Етикет на бутона за подаване\",\n    \"Success text\": \"Текст при успех\",\n    \"Table column name\": \"Име на колона в таблицата\",\n    \"Enter redirect URL\": \"Въведете URL за пренасочване\",\n    \"No field selected\": \"Няма избрано поле\",\n    \"COLUMN TYPE\": \"ТИП КОЛОНА\",\n    \"GROUPED BY\": \"ГРУПИРАНИ ПО\",\n    \"SELECT BY\": \"ИЗБЕРИ ПО\",\n    \"CHART TYPE\": \"ТИП ГРАФИКА\",\n    \"Select widget\": \"Избери джаджа\",\n    \"SELECTOR FOR\": \"ИЗБОР ЗА\",\n    \"SOURCE DATA\": \"ИЗХОДНИ ДАННИ\",\n    \"Save\": \"Съхрани\",\n    \"series_one\": \"Редица\",\n    \"series_other\": \"Редици\",\n    \"Enter text\": \"Въведи текст\",\n    \"Layout\": \"Оформление\",\n    \"Field rules\": \"Правила на полето\",\n    \"Select a field in the form widget to configure.\": \"Изберете поле в джаджа на формуляра, което да конфигурирате.\"\n  },\n  \"RowContextMenu\": {\n    \"Copy anchor link\": \"Копиране на връзката с котва\",\n    \"Insert row\": \"Вмъкни ред\",\n    \"Insert row above\": \"Вмъкни ред отгоре\",\n    \"View as card\": \"Преглед като карта\",\n    \"Use as table headers\": \"Използвай като заглавки на таблица\",\n    \"Delete\": \"Изтрий\",\n    \"Duplicate rows_other\": \"Дублирай редовете\",\n    \"Insert row below\": \"Вмъкни ред отдолу\",\n    \"Duplicate rows_one\": \"Дубирай реда\"\n  },\n  \"SelectionSummary\": {\n    \"Copied to clipboard\": \"Копирано в системния буфер\"\n  },\n  \"ShareMenu\": {\n    \"Compare to {{termToUse}}\": \"Сравнете с {{termToUse}}\",\n    \"Current Version\": \"Текуща версия\",\n    \"Download\": \"Изтегли\",\n    \"Duplicate document\": \"Дублирай документ\",\n    \"Edit without affecting the original\": \"Редактирай, без да засягаш оригинала\",\n    \"Export CSV\": \"Изведи CSV\",\n    \"Export XLSX\": \"Изведи XLSX\",\n    \"Manage users\": \"Управление на потребителите\",\n    \"Original\": \"Оригинал\",\n    \"Replace {{termToUse}}...\": \"Замести {{termToUse}}…\",\n    \"Return to {{termToUse}}\": \"Назад към {{termToUse}}\",\n    \"Save copy\": \"Запазване на копие\",\n    \"Save Document\": \"Съхрани документ\",\n    \"Show in folder\": \"Покажи в папката\",\n    \"Unsaved\": \"Незапазени промени\",\n    \"Access Details\": \"Данни за достъп\",\n    \"Back to current\": \"Обратно към текущите\",\n    \"Send to Google Drive\": \"Изпрати до Google Диск\",\n    \"DOO Separated Values (.dsv)\": \"DOO разделени стойности (.dsv)\",\n    \"Export as...\": \"Извеждане като...\",\n    \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n    \"Tab Separated Values (.tsv)\": \"Стойности, разделени с табулации (.tsv)\",\n    \"Work on a copy\": \"Работи върху копие\",\n    \"Share\": \"Сподели\",\n    \"Download...\": \"Изтегли...\",\n    \"Comma Separated Values (.csv)\": \"Стойности, разделени със запетая (.csv)\"\n  },\n  \"SortFilterConfig\": {\n    \"Revert\": \"Върни\",\n    \"Save\": \"Съхрани\",\n    \"Sort\": \"СОРТИРАЙ\",\n    \"Update Sort & Filter settings\": \"Обнови настройките за соритане и отсяване\",\n    \"Filter\": \"СИТО\"\n  },\n  \"ThemeConfig\": {\n    \"Appearance \": \"Външен вид \",\n    \"Switch appearance automatically to match system\": \"Превключете външния вид автоматично, за да съответства на системните настройки\"\n  },\n  \"AppModel\": {\n    \"This team site is suspended. Documents can be read, but not modified.\": \"Сайтът на екипа е спрян. Документите могат да бъдат четени, но не и променяни.\"\n  },\n  \"RecordLayout\": {\n    \"Updating record layout.\": \"Обновяване на оформлението на записа.\"\n  },\n  \"RefSelect\": {\n    \"Add column\": \"Добави колона\",\n    \"No columns to add\": \"Няма колони за добавяне\"\n  },\n  \"SiteSwitcher\": {\n    \"Create new team site\": \"Създай нов екипен сайт\",\n    \"Switch Sites\": \"Превключване на сайтове\"\n  },\n  \"SortConfig\": {\n    \"Update data\": \"Обнови данните\",\n    \"Use choice position\": \"Използвай изборна позиция\",\n    \"Add column\": \"Добави колона\",\n    \"Empty values last\": \"Празните стойности са последни\",\n    \"Natural sort\": \"Естествено подреждане\",\n    \"Search Columns\": \"Колони за търсене\"\n  },\n  \"errorPages\": {\n    \"Account deleted{{suffix}}\": \"Потребителят е изтрит{{suffix}}\",\n    \"Sign up\": \"Регистрация\",\n    \"Your account has been deleted.\": \"Вашият потребител е изтрит.\",\n    \"An unknown error occurred.\": \"Възникна неизвестна грешка.\",\n    \"Build your own form\": \"Изградете свой собствен формуляр\",\n    \"Form not found\": \"Формулярът не е намерен\",\n    \"Powered by\": \"Задвижвано от\",\n    \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Вие сте влезли като {{email}}. Можете да влезете с друг потребител или поистайте достъп от администратора.\",\n    \"You do not have access to this organization's documents.\": \"Нямате достъп до документите на тази организация.\",\n    \"Access denied{{suffix}}\": \"Отказан достъп{{suffix}}\",\n    \"Add account\": \"Добави потребител\",\n    \"Contact support\": \"Свържете се с поддръжката\",\n    \"Error{{suffix}}\": \"Грешка{{suffix}}\",\n    \"Go to main page\": \"Отидете на главната страница\",\n    \"Page not found{{suffix}}\": \"Страницата не е намерена{{suffix}}\",\n    \"Sign in\": \"Вписване\",\n    \"Sign in to access this organization's documents.\": \"Впишете се, за да получите достъп до документите на тази организация.\",\n    \"Sign in again\": \"Впишете се, отново\",\n    \"Signed out{{suffix}}\": \"Отписан{{suffix}}\",\n    \"Something went wrong\": \"Възникна грешка\",\n    \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Търсената страница не може да бъде намерена.{{separator}}Моля, проверете URL адреса и опитайте отново.\",\n    \"There was an error: {{message}}\": \"Възникна грешка: {{message}}\",\n    \"There was an unknown error.\": \"Възникнала е неизвестна грешка.\",\n    \"You are now signed out.\": \"Вече сте отписани.\"\n  },\n  \"menus\": {\n    \"* Workspaces are available on team plans. \": \"* Работните пространства са достъпни в абонаментите с екип. \",\n    \"Select fields\": \"Изберете полета\",\n    \"Upgrade now\": \"Надстройте сега\",\n    \"Any\": \"Всякакъв\",\n    \"Numeric\": \"Числов\",\n    \"Text\": \"Текст\",\n    \"Integer\": \"Целочислен\",\n    \"Toggle\": \"Превключващ\",\n    \"Date\": \"Дата\",\n    \"DateTime\": \"Дата и час\",\n    \"Choice\": \"Избор\",\n    \"Choice List\": \"Списък с избори\",\n    \"Reference\": \"Препратка\",\n    \"Reference List\": \"Списък с препратки\",\n    \"Attachment\": \"Причкачен файл\",\n    \"Search columns\": \"Колони за търсене\"\n  },\n  \"modals\": {\n    \"Undo to restore\": \"Отмени, за да възстановиш\",\n    \"Got it\": \"Разбрах\",\n    \"Don't show again\": \"Не показвай отново\",\n    \"TIP\": \"СЪВЕТ\",\n    \"Save\": \"Съхрани\",\n    \"Cancel\": \"Отказ\",\n    \"Ok\": \"Добре\",\n    \"Are you sure you want to delete these records?\": \"Сигурни ли сте, че искате да изтриете тези записи?\",\n    \"Are you sure you want to delete this record?\": \"Сигурни ли сте, че искате да изтриете този запис?\",\n    \"Delete\": \"Изтрий\",\n    \"Dismiss\": \"Отхвърли\",\n    \"Don't ask again.\": \"Не питай отново.\",\n    \"Don't show again.\": \"Не показвай отново.\",\n    \"Don't show tips\": \"Не показвай съвети\"\n  },\n  \"pages\": {\n    \"Duplicate page\": \"Дублирай страница\",\n    \"Remove\": \"Премахни\",\n    \"Rename\": \"Преименувай\",\n    \"You do not have edit access to this document\": \"Нямате достъп за редактиране на този документ\"\n  },\n  \"search\": {\n    \"Find Previous \": \"Намерете предишния \",\n    \"Find Next \": \"Намери следващия \",\n    \"No results\": \"Няма резултати\",\n    \"Search in document\": \"Търсене в документа\",\n    \"Search\": \"Търсене\"\n  },\n  \"sendToDrive\": {\n    \"Sending file to Google Drive\": \"Файлът се изпраща до Google Диск\"\n  },\n  \"NTextBox\": {\n    \"false\": \"невярно\",\n    \"true\": \"вярно\",\n    \"Field Format\": \"Формат на полето\",\n    \"Lines\": \"Линии\",\n    \"Multi line\": \"Множество линии\",\n    \"Single line\": \"Една линия\"\n  },\n  \"ACLUsers\": {\n    \"Example Users\": \"Примерни потребители\",\n    \"View as\": \"Разгледай като\",\n    \"Users from table\": \"Потребители от таблица\"\n  },\n  \"TypeTransform\": {\n    \"Apply\": \"Приложи\",\n    \"Cancel\": \"Отказ\",\n    \"Preview\": \"Преглед\",\n    \"Revise\": \"Преразглеждане\",\n    \"Update formula (Shift+Enter)\": \"Актуализиране на формула (Shift+Enter)\"\n  },\n  \"CellStyle\": {\n    \"CELL STYLE\": \"СТИЛ НА КЛЕТКА\",\n    \"Cell style\": \"Стил на клетка\",\n    \"Default cell style\": \"Стил на клетка по подразбиране\",\n    \"Mixed style\": \"Смесен стил\",\n    \"Open row styles\": \"Отвори стиловете на реда\",\n    \"Header Style\": \"Стил на заглавките\",\n    \"HEADER STYLE\": \"СТИЛ НА ЗАГЛАВКИТЕ\",\n    \"Default header style\": \"Стил на заглавките по подразбиране\"\n  },\n  \"ColumnEditor\": {\n    \"COLUMN DESCRIPTION\": \"ОПИСАНИЕ НА КОЛОНАТА\",\n    \"COLUMN LABEL\": \"ЕТИКЕТ НА КОЛОНАТА\"\n  },\n  \"ColumnInfo\": {\n    \"COLUMN DESCRIPTION\": \"ОПИСАНИЕ НА КОЛОНАТА\",\n    \"COLUMN ID: \": \"ОБОЗНАЧЕНИЕ НА КОЛОНА: \",\n    \"COLUMN LABEL\": \"ЕТИКЕТ НА КОЛОНАТА\",\n    \"Cancel\": \"Отказ\",\n    \"Save\": \"Съхрани\"\n  },\n  \"ConditionalStyle\": {\n    \"Add another rule\": \"Добави друго правило\",\n    \"Add conditional style\": \"Добавете условен стил\",\n    \"Error in style rule\": \"Грешка в правилото за стил\",\n    \"Row style\": \"Стил на ред\",\n    \"Rule must return True or False\": \"Правилото трябва да връща вярно (True) или невярно (False)\"\n  },\n  \"DiscussionEditor\": {\n    \"Cancel\": \"Отказ\",\n    \"Edit\": \"Редактирай\",\n    \"Marked as resolved\": \"Означи като разрешен\",\n    \"Only current page\": \"Само текущата страница\",\n    \"Only my threads\": \"Само моите нишки\",\n    \"Open\": \"Отвори\",\n    \"Remove\": \"Премахни\",\n    \"Reply\": \"Отговори\",\n    \"Reply to a comment\": \"Отговори на коментар\",\n    \"Resolve\": \"Разреши\",\n    \"Save\": \"Съхрани\",\n    \"Show resolved comments\": \"Показване на разрешените коментари\",\n    \"Showing last {{nb}} comments\": \"Показани са последните {{nb}} коментара\",\n    \"Comment\": \"Коментирай\",\n    \"Started discussion\": \"Започната дискусия\",\n    \"Write a comment\": \"Напиши коментар\"\n  },\n  \"FieldBuilder\": {\n    \"Changing multiple column types\": \"Промяна на множество типове колони\",\n    \"DATA FROM TABLE\": \"ДАННИ ОТ ТАБЛИЦА\",\n    \"Mixed format\": \"Смесен формат\",\n    \"Mixed types\": \"Смесени типове\",\n    \"Revert field settings for {{colId}} to common\": \"Върнете {{colId}} към общи настройки на полето\",\n    \"Save field settings for {{colId}} as common\": \"Запазете настройките на полето {{colId}} като общи\",\n    \"Use separate field settings for {{colId}}\": \"Използвайте отделни настройки на полето {{colId}}\",\n    \"Changing column type\": \"Промяна на типа колона\",\n    \"Apply formula to data\": \"Приложи формула към данни\",\n    \"CELL FORMAT\": \"ФОРМАТ НА КЛЕТКАТА\"\n  },\n  \"FieldEditor\": {\n    \"It should be impossible to save a plain data value into a formula column\": \"Трябва да е невъзможно да се запише стойност на обикновени данни в колона с формула\",\n    \"Unable to finish saving edited cell\": \"Не може да завърши запазването на редактираната клетка\"\n  },\n  \"FormulaEditor\": {\n    \"Error in the cell\": \"Грешка в клетката\",\n    \"Expand Editor\": \"Разгънете редактора\",\n    \"use AI Assistant\": \"използвайте ИИ асистент\",\n    \"Column or field is required\": \"Колона или поле е задължително\",\n    \"Errors in all {{numErrors}} cells\": \"Грешки във всички {{numErrors}} клетки\",\n    \"Errors in {{numErrors}} of {{numCells}} cells\": \"Грешки в {{numErrors}} от {{numCells}} клетки\",\n    \"editingFormula is required\": \"editingFormula е задължително\",\n    \"Enter formula.\": \"Въведете формула.\",\n    \"Enter formula or {{button}}.\": \"Въведете формула или {{button}}.\"\n  },\n  \"HyperLinkEditor\": {\n    \"[link label] url\": \"[етикет на връзката] URL\"\n  },\n  \"NumericTextBox\": {\n    \"Currency\": \"Валута\",\n    \"Decimals\": \"Десетици\",\n    \"Default currency ({{defaultCurrency}})\": \"Валута по подразбиране ({{defaultCurrency}})\",\n    \"Number Format\": \"Числов формат\",\n    \"Field Format\": \"Формат на полето\",\n    \"Spinner\": \"Врътче (при зареждане)\",\n    \"Text\": \"Текст\",\n    \"max\": \"макс\",\n    \"min\": \"мин\"\n  },\n  \"Reference\": {\n    \"CELL FORMAT\": \"ФОРМАТ НА КЛЕТКАТА\",\n    \"Row ID\": \"Обозначение на ред\",\n    \"SHOW COLUMN\": \"ПОКАЖИ КОЛОНА\"\n  },\n  \"WelcomeTour\": {\n    \"Add new\": \"Добави нов\",\n    \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Разгледайте нашата {{templateLibrary}} и се вдъхновете, като откриете възможностите.\",\n    \"Building up\": \"Изграждане\",\n    \"Configuring your document\": \"Конфигуриране на вашия документ\",\n    \"Customizing columns\": \"Нагласяне на колони\",\n    \"Double-click or hit {{enter}} on a cell to edit it. \": \"Цъкнете двукратно или натиснете {{enter}} върху клетка, за да я редактирате. \",\n    \"Make it relational! Use the {{ref}} type to link tables. \": \"Направете го с препратка. Използвайте {{ref}} тип да свържете таблици. \",\n    \"Reference\": \"Препратка\",\n    \"Start with {{equal}} to enter a formula.\": \"Започнете с {{equal}}, за да въведете формула.\",\n    \"Toggle the {{creatorPanel}} to format columns, \": \"Отворете {{creatorPanel}}, за да форматирате колони, \",\n    \"Use the Share button ({{share}}) to share the document or export data.\": \"Използвайте бутона Сподели ({{share}}), за да споделите документа или да изведете данни.\",\n    \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Използвайте {{addNew}}, за да добавите джаджи, страници или да внесете повече данни. \",\n    \"Share\": \"Сподели\",\n    \"Sharing\": \"Споделяне\",\n    \"convert to card view, select data, and more.\": \"преобразуване в изглед на карта, избор на данни и др.\",\n    \"creator panel\": \"панел на създателя\",\n    \"template library\": \"библиотека с образци\",\n    \"Use {{helpCenter}} for documentation or questions.\": \"Използвайте {{helpCenter}} за документация или въпроси.\",\n    \"Welcome to Grist!\": \"Добре дошли в Grist!\",\n    \"Editing Data\": \"Редактиране на данни\",\n    \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Задайте опции за форматиране, формули или типове колони, като дата, избор или прикачен файл. \",\n    \"Enter\": \"Въведете\",\n    \"Flying higher\": \"Полет нагоре\",\n    \"Help Center\": \"Център за помощ\"\n  },\n  \"ChoiceTextBox\": {\n    \"CHOICES\": \"ИЗБОР\"\n  },\n  \"CurrencyPicker\": {\n    \"Invalid currency\": \"Невалидна валута\"\n  },\n  \"EditorTooltip\": {\n    \"Convert column to formula\": \"Преобразувай колона във формула\"\n  },\n  \"Tools\": {\n    \"Access Rules\": \"Правила за достъп\",\n    \"Code view\": \"Изглед на кода\",\n    \"Delete\": \"Изтрий\",\n    \"Delete document tour?\": \"Да се изтрие ли наръча документи?\",\n    \"Document history\": \"Хронология на документа\",\n    \"How-to Tutorial\": \"Урок с инструкции\",\n    \"Raw data\": \"Сурови данни\",\n    \"Return to viewing as yourself\": \"Спрете да гледате като друго лице\",\n    \"TOOLS\": \"ИНСТРУМЕНТИ\",\n    \"Tour of this Document\": \"Наръч на този документ\",\n    \"Validate Data\": \"Валидирайте данните\",\n    \"Settings\": \"Настройки\",\n    \"API console\": \"API конзола\"\n  },\n  \"TopBar\": {\n    \"Manage team\": \"Управление на екипа\"\n  },\n  \"TriggerFormulas\": {\n    \"Any field\": \"Всяко поле\",\n    \"Apply on changes to:\": \"Прилагане на промени в:\",\n    \"Apply on record changes\": \"Прилагане на промените в записа\",\n    \"Apply to new records\": \"Приложи към новите записи\",\n    \"Cancel\": \"Отказ\",\n    \"Close\": \"Затвори\",\n    \"Current field \": \"Текущо поле \",\n    \"OK\": \"Добре\"\n  },\n  \"TypeTransformation\": {\n    \"Apply\": \"Приложи\",\n    \"Cancel\": \"Отказ\",\n    \"Preview\": \"Преглед\",\n    \"Revise\": \"Преразглеждане\",\n    \"Update formula (Shift+Enter)\": \"Обнови формулата (Shift+Enter)\"\n  },\n  \"UserManagerModel\": {\n    \"Editor\": \"Редактор\",\n    \"In full\": \"В цялост\",\n    \"No Default Access\": \"Без достъп по подразбиране\",\n    \"None\": \"Никой\",\n    \"Owner\": \"Собственик\",\n    \"View & edit\": \"Преглед и редактиране\",\n    \"View only\": \"Само преглед\",\n    \"Viewer\": \"Наблюдател\"\n  },\n  \"ValidationPanel\": {\n    \"Rule {{length}}\": \"Правило {{length}}\",\n    \"Update formula (Shift+Enter)\": \"Обнови формулата (Shift+Enter)\"\n  },\n  \"ViewAsBanner\": {\n    \"UnknownUser\": \"Неизвестен потребител\"\n  },\n  \"ViewConfigTab\": {\n    \"Advanced settings\": \"Разширени настройки\",\n    \"Blocks\": \"Блокове\",\n    \"Form\": \"Формуляр\",\n    \"Make On-Demand\": \"Направи при поискване\",\n    \"Plugin: \": \"Приставка: \",\n    \"Unmark On-Demand\": \"Премахване на означение \\\"при поискване\\\"\",\n    \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Големите таблици могат да бъдат отбелязани като „при поискване“, за да се избегне зареждането им в двигателя за данни.\",\n    \"Compact\": \"Компактен\",\n    \"Edit card layout\": \"Редактиране на оформлението на картата\",\n    \"Section: \": \"Раздел: \"\n  },\n  \"ViewLayoutMenu\": {\n    \"Advanced sort & filter\": \"Разширено сортиране и отсяване\",\n    \"Copy anchor link\": \"Copy anchor link\",\n    \"Data selection\": \"Избор на данни\",\n    \"Delete record\": \"Изтрий запис\",\n    \"Delete widget\": \"Изтрий джаджата\",\n    \"Download as CSV\": \"Изтегли като CSV\",\n    \"Download as XLSX\": \"Изтегли като XLSX\",\n    \"Edit card layout\": \"Редактирай оформлението на картата\",\n    \"Open configuration\": \"Отвори конфигурацията\",\n    \"Print widget\": \"Джаджа за печат\",\n    \"Show raw data\": \"Показване на сурови данни\",\n    \"Widget options\": \"Опции за джаджи\",\n    \"Add to page\": \"Добави към страницата\",\n    \"Collapse widget\": \"Свивий джаджата\",\n    \"Create a form\": \"Създай на формуляр\"\n  },\n  \"ViewSectionMenu\": {\n    \"(customized)\": \"(персонализиран)\",\n    \"(empty)\": \"(празен)\",\n    \"(modified)\": \"(променен)\",\n    \"Custom options\": \"Потребителски опции\",\n    \"FILTER\": \"СИТО\",\n    \"Revert\": \"Върни\",\n    \"SORT\": \"СОРТИРАЙ\",\n    \"Save\": \"Съхрани\",\n    \"Update Sort&Filter settings\": \"Обнови настройките за соритане и отсяване\"\n  },\n  \"VisibleFieldsConfig\": {\n    \"Cannot drop items into Hidden Fields\": \"Не можете да пускате елементи в скрити полета\",\n    \"Clear\": \"Изчисти\",\n    \"Hidden Fields cannot be reordered\": \"Скритите полета не могат да се пренареждат\",\n    \"Select all\": \"Избери всички\",\n    \"Visible {{label}}\": \"Видим {{label}}\",\n    \"Hide {{label}}\": \"Скрий {{label}}\",\n    \"Hidden {{label}}\": \"Скрит {{label}}\",\n    \"Show {{label}}\": \"Покажи {{label}}\"\n  },\n  \"WelcomeQuestions\": {\n    \"Education\": \"Образование\",\n    \"Finance & Accounting\": \"Финанси и счетоводство\",\n    \"HR & Management\": \"ТРЗ и управление\",\n    \"IT & Technology\": \"ИТ и технологии\",\n    \"Marketing\": \"Маркетинг\",\n    \"Media Production\": \"Медийно производство\",\n    \"Other\": \"Други\",\n    \"Product Development\": \"Разработване на продукти\",\n    \"Research\": \"Изследвания\",\n    \"Sales\": \"Продажби\",\n    \"Type here\": \"Въведете тук\",\n    \"Welcome to Grist!\": \"Добре дошли в Grist!\",\n    \"What brings you to Grist? Please help us serve you better.\": \"Какво ви доведе в Grist? Моля, помогнете ни да ви обслужаваме по-добре.\"\n  },\n  \"WidgetTitle\": {\n    \"Cancel\": \"Отказ\",\n    \"Override widget title\": \"Замяна на заглавието на джаджа\",\n    \"Provide a table name\": \"Предоставете име на таблица\",\n    \"Save\": \"Съхрани\",\n    \"WIDGET TITLE\": \"ЗАГЛАВИЕ НА ДЖАДЖА\",\n    \"WIDGET DESCRIPTION\": \"ОПИСАНИЕ НА ДЖАДЖАТА\",\n    \"DATA TABLE NAME\": \"ИМЕ НА ТАБЛИЦА С ДАННИ\"\n  },\n  \"breadcrumbs\": {\n    \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Можете да правите редакции, но те ще създадат ново копие и няма\\nда засегнат оригиналния документ.\",\n    \"fiddle\": \"подправям\",\n    \"override\": \"отмени\",\n    \"recovery mode\": \"режим на възстановяване\",\n    \"snapshot\": \"моментна снимка\",\n    \"unsaved\": \"незапазени промени\"\n  },\n  \"duplicatePage\": {\n    \"Duplicate page {{pageName}}\": \"Дублирай страница {{pageName}}\",\n    \"Note that this does not copy data, but creates another view of the same data.\": \"Имайте предвид, че това не копира данни, а създава друг изглед на същите данни.\"\n  },\n  \"LanguageMenu\": {\n    \"Language\": \"Език\"\n  },\n  \"GristTooltips\": {\n    \"Apply conditional formatting to rows based on formulas.\": \"Прилагане на условно форматиране на редове въз основа на формули.\",\n    \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Клетките в референтна колона винаги посочват {{entire}} запис в тази таблица, но вие можете да изберете коя колона от този запис да се покаже.\",\n    \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Цъкнете върху „Стилове на отворени редове“, за да приложите условно форматиране към редове.\",\n    \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Цъкването върху {{EyeHideIcon}} на всяка клетка скрива полето от този изглед, без да го изтрива.\",\n    \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Формули, които се задействат в определени случаи и съхраняват изчислената стойност като данни.\",\n    \"Nested Filtering\": \"Вложено отсяване\",\n    \"Only those rows will appear which match all of the filters.\": \"Ще се появят само онези редове, които отговарят на всички филтри.\",\n    \"Pinning Filters\": \"Закачени филтри\",\n    \"Raw Data page\": \"Страница със сурови данни\",\n    \"Rearrange the fields in your card by dragging and resizing cells.\": \"Пренареждайте полетата в картата, като плъзгате и променяте размера на клетките.\",\n    \"Reference Columns\": \"Колони с препратки\",\n    \"Reference columns are the key to {{relational}} data in Grist.\": \"Колоните с препратка са ключът към {{relational}} данни в Grist.\",\n    \"Select the table containing the data to show.\": \"Изберете таблицата, съдържаща данните, които искате да покажете.\",\n    \"The total size of all data in this document, excluding attachments.\": \"Общият размер на всички данни в този документ, с изключение на прикачените файлове.\",\n    \"They allow for one record to point (or refer) to another.\": \"Те позволяват един запис да сочи (или препраща) към друг.\",\n    \"This is the secret to Grist's dynamic and productive layouts.\": \"Това е тайната на динамичните и продуктивни оформления на Grist.\",\n    \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Изпробвайте промените в копие, след което решете дали да замените оригинала с редакциите си.\",\n    \"Unpin to hide the the button while keeping the filter.\": \"Откачете бутона, за да го скриете, като запазите филтъра.\",\n    \"Updates every 5 minutes.\": \"Актуализира се на всеки 5 минути.\",\n    \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Полезни за съхраненяване на дата и час, или създаване на нов запис, почистване на данни и др.\",\n    \"You can filter by more than one column.\": \"Можете да филтрирате по повече от една колона.\",\n    \"entire\": \"цялата\",\n    \"relational\": \"релационна\",\n    \"Access Rules\": \"Правила за достъп\",\n    \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Правилата за достъп ви дават възможност да създавате детаилни правила, с които да определяте кой може да вижда или редактира кои части от документа ви.\",\n    \"Anchor Links\": \"Анкерни връзки\",\n    \"Custom Widgets\": \"Собствени джаджи\",\n    \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"За да направите връзка за котва, която отвежда потребителя до конкретна клетка, цъкнете върху ред и натиснете {{shortcut}}.\",\n    \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Прилагане на условно форматиране към клетките в тази колона, когато са изпълнени условията на формулата.\",\n    \"Learn more.\": \"Научете повече.\",\n    \"Pinned filters are displayed as buttons above the widget.\": \"Прикачените филтри се показват като бутони над джаджата.\",\n    \"Click the Add new button to create new documents or workspaces, or import data.\": \"Цъкнете върху бутона \\\"Добави нов\\\", за да създадете нови документи или работни пространства, или да внесете данни.\",\n    \"Link your new widget to an existing widget on this page.\": \"Свържете новата си джаджа със съществуваща джаджа на тази страница.\",\n    \"Linking Widgets\": \"Свързване на джаджи\",\n    \"Editing Card Layout\": \"Редактиране на оформлението на картата\",\n    \"Select the table to link to.\": \"Изберете таблицата, към която искате да се свържете.\",\n    \"Selecting Data\": \"Избор на данни\",\n    \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Страницата \\\"Сурови данни\\\" съдържа списък на всички таблици с данни във вашия документ, включително обобщаващи таблици и таблици, които не са включени в оформлението на страницата.\",\n    \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Използвайте иконата \\\\u{1D6BA}, за да създадете обобщаващи (или отправни) таблици за общи суми или междинни суми.\",\n    \"Add new\": \"Добави нов\",\n    \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Използвайте иконата 𝚺, за да създадете обобщаващи (или отправни) таблици за общи суми или междинни суми.\",\n    \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Можете да изберете някоя от нашите готови джаджи или да вградите своя собствена, като предоставите пълния ѝ URL адрес.\",\n    \"Calendar\": \"Календар\",\n    \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Не можете да намерите правилните колони? Цъкнете върху \\\"Промяна на джаджата\\\", за да изберете таблицата с данни за събитията.\",\n    \"Lookups return data from related tables.\": \"Търсенето връща данни от свързани таблици.\",\n    \"Use reference columns to relate data in different tables.\": \"Използвайте референтни колони за свързване на данни в различни таблици.\",\n    \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Изграждайте прости формуляри директно в Grist и споделяйте с едно кликване с новата ни джаджа. {{learnMoreButton}}\",\n    \"Filter displayed dropdown values with a condition.\": \"Отсяване на показаните стойности в падащото меню с условие.\",\n    \"To configure your calendar, select columns for start\": {\n      \"end dates and event titles. Note each column's type.\": \"За да конфигурирате календара си, изберете колони за начални/крайни дати и заглавия на събития. Обърнете внимание на типа на всяка колона.\"\n    },\n    \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID е произволно генериран низ, който е полезен за уникални обозначения и ключове за връзки.\",\n    \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Можете да изберете от наличните джаджи в падащото меню или да вградите свои собствени, като предоставите пълния им URL адрес.\",\n    \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Формулите поддържат много функции на Excel, пълен синтаксис на Python и включват полезен AI Assistant.\",\n    \"Forms are here!\": \"Формулярите са тук!\",\n    \"Learn more\": \"Научете повече\",\n    \"These rules are applied after all column rules have been processed, if applicable.\": \"Тези правила се прилагат след обработката на всички правила за колони, ако е приложимо.\",\n    \"Example: {{example}}\": \"Пример: {{example}}\"\n  },\n  \"FormulaAssistant\": {\n    \"Code view\": \"Изглед на кода\",\n    \"Hi, I'm the Grist Formula AI Assistant.\": \"Здравейте, аз съм ИИ асистентът за формули на Grist.\",\n    \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Мога да помогна само с формули. Не мога да създавам таблици, колони и изгледи или да пиша правила за достъп.\",\n    \"Learn more\": \"Научете повече\",\n    \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Регистрирайте се като безплатен потребител в Grist, за да започнете да използвате Formula AI Assistant.\",\n    \"What do you need help with?\": \"С какво имате нужда от помощ?\",\n    \"Formula AI Assistant is only available for logged in users.\": \"Formula AI Assistant е достъпен само за вписали се в системата потребители.\",\n    \"For higher limits, contact the site owner.\": \"За по-високи лимити се свържете със собственика на сайта.\",\n    \"You have used all available credits.\": \"Използвали сте всички налични кредити.\",\n    \"You have {{numCredits}} remaining credits.\": \"Имате оставащи кредити на {{numCredits}}.\",\n    \"upgrade to the Pro Team plan\": \"надграждане до абонамент Pro Team\",\n    \"upgrade your plan\": \"надграждане на абонамента ви\",\n    \"Press Enter to apply suggested formula.\": \"Натиснете Enter, за да приложите предложената формула.\",\n    \"Sign Up for Free\": \"Регистрирайте се безплатно\",\n    \"There are some things you should know when working with me:\": \"Има някои неща, които трябва да знаете, когато работите с мен:\",\n    \"For higher limits, {{upgradeNudge}}.\": \"За по-високи лимити: {{upgradeNudge}}.\",\n    \"Ask the bot.\": \"Попитайте бота.\",\n    \"Capabilities\": \"Възможности\",\n    \"Data\": \"Данни\",\n    \"Formula Cheat Sheet\": \"Справочник с формули\",\n    \"Formula Help. \": \"Помощ с формулите. \",\n    \"Function List\": \"Списък на функциите\",\n    \"Grist's AI Assistance\": \"Grist ИИ помощник\",\n    \"Grist's AI Formula Assistance. \": \"ИИ помощник за формулите на Grist. \",\n    \"Regenerate\": \"Регенерирай\",\n    \"Save\": \"Съхрани\",\n    \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Вижте нашите {{helpFunction}} и {{formulaCheat}}, или посетете нашия {{community}} за повече помощ.\",\n    \"Tips\": \"Съвети\",\n    \"AI Assistant\": \"ИИ помощник\",\n    \"Apply\": \"Приложи\",\n    \"Cancel\": \"Отказ\",\n    \"Clear conversation\": \"Изчисти разговора\",\n    \"Community\": \"Общност\",\n    \"Need help? Our AI assistant can help.\": \"Имате нужда от помощ? Нашият асистент с изкуствен интелект може да ви помогне.\",\n    \"Preview\": \"Преглед\",\n    \"New Chat\": \"Нов чат\"\n  },\n  \"GridView\": {\n    \"Click to insert\": \"Цъкнете, за да вмъкнете\"\n  },\n  \"WelcomeSitePicker\": {\n    \"You can always switch sites using the account menu.\": \"Винаги можете да превключвате сайтовете чрез менюто на профила.\",\n    \"You have access to the following Grist sites.\": \"Имате достъп до следните сайтове на Grist.\",\n    \"Welcome back\": \"Добре дошъл отново\"\n  },\n  \"DescriptionTextArea\": {\n    \"DESCRIPTION\": \"ОПИСАНИЕ\"\n  },\n  \"UserManager\": {\n    \"Add {{member}} to your team\": \"Добавете {{member}} към екипа си\",\n    \"Allow anyone with the link to open.\": \"Позволете на всеки, който има връзка, да я отвори.\",\n    \"Anyone with link \": \"Всеки с връзка \",\n    \"Confirm\": \"Потвърди\",\n    \"Create a team to share with more people\": \"Създайте екип, за да споделите с повече хора\",\n    \"Grist support\": \"Поддръжка от Grist\",\n    \"Guest\": \"Гост\",\n    \"Invite multiple\": \"Поканете няколко\",\n    \"Invite people to {{resourceType}}\": \"Поканете хора в {{resourceType}}\",\n    \"Manage members of team site\": \"Управление на членовете на сайта на екипа\",\n    \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Липсата на достъп по подразбиране позволява предоставянето на достъп до отделни документи или работни пространства, а не до целия екипен сайт.\",\n    \"Off\": \"Изключено\",\n    \"On\": \"Включено\",\n    \"Cancel\": \"Отказ\",\n    \"Close\": \"Затвори\",\n    \"Collaborator\": \"Сътрудник\",\n    \"Copy link\": \"Копирай на връзката\",\n    \"Link copied to clipboard\": \"Връзка, копирана в системния буфер\",\n    \"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}}.\": \"След като премахнете собствения си достъп, няма да можете да го възстановите без помощта на друг човек с достатъчен достъп до {{name}}.\",\n    \"Open Access Rules\": \"Отвори правилата за достъп\",\n    \"Public access: \": \"Публичен достъп: \",\n    \"Remove my access\": \"Премахване на достъпа ми\",\n    \"Save & \": \"Запази & \",\n    \"Team member\": \"Член на екипа\",\n    \"User may not modify their own access.\": \"Потребителят няма право да променя собствения си достъп.\",\n    \"Your role for this team site\": \"Вашата роля в този екип\",\n    \"free collaborator\": \"безплатен сътрудник\",\n    \"guest\": \"гост\",\n    \"member\": \"член\",\n    \"team site\": \"сайт на екипа\",\n    \"{{collaborator}} limit exceeded\": \"{{collaborator}} превишени ограничения\",\n    \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} на {{limitTop}} {{collaborator}}и\",\n    \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Липсата на достъп по подразбиране позволява предоставянето на достъп до отделни документи или работни пространства, а не до целия екипен сайт.\",\n    \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Потребителят наследява разрешенията от {{parent})}. За да го премахнете, задайте опцията 'Наследяване на достъпа' на 'Няма'.\",\n    \"You are about to remove your own access to this {{resourceType}}\": \"Ще премахнете собствения си достъп до този {{resourceType}}\",\n    \"Outside collaborator\": \"Външен сътрудник\",\n    \"Public access\": \"Публичен достъп\",\n    \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Потребителят наследява разрешенията от {{parent})}. За да го премахнете, задайте опцията 'Наследяване на достъпа' на 'Няма'.\",\n    \"Your role for this {{resourceType}}\": \"Ролята ви в този {{resourceType}}\",\n    \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Публичен достъп, наследен от {{parent}}. За да го премахнете, задайте опцията 'Наследяване на достъпа' на 'Няма'.\",\n    \"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}}.\",\n    \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\": \"Потребителят има достъп за преглед до {{resource}} в резултат на ръчно зададен достъп до ресурсите в него. Ако бъде премахнат тук, този потребител ще загуби достъпа до вътрешните ресурси.\"\n  },\n  \"DescriptionConfig\": {\n    \"DESCRIPTION\": \"ОПИСАНИЕ\"\n  },\n  \"PagePanels\": {\n    \"Close Creator Panel\": \"Затвори панела за създаване\",\n    \"Open creator panel\": \"Отвори панела за създаване\"\n  },\n  \"ColumnTitle\": {\n    \"Add description\": \"Добави описание\",\n    \"COLUMN ID: \": \"ОБОЗНАЧЕНИЕ НА КОЛОНА: \",\n    \"Cancel\": \"Отказ\",\n    \"Column label\": \"Етикет на колоната\",\n    \"Provide a column label\": \"Предоставете етикет на колоната\",\n    \"Save\": \"Съхрани\",\n    \"Column ID copied to clipboard\": \"Обозначението на колоната е копирано в системния буфер\",\n    \"Column description\": \"Описание на колоната\",\n    \"Close\": \"Затвори\"\n  },\n  \"Clipboard\": {\n    \"Got it\": \"Разбрах\",\n    \"Unavailable Command\": \"Недостъпна команда\"\n  },\n  \"FieldContextMenu\": {\n    \"Clear field\": \"Изчисти полето\",\n    \"Copy anchor link\": \"Копирай връзка към котва\",\n    \"Cut\": \"Изрежи\",\n    \"Hide field\": \"Скрий полето\",\n    \"Paste\": \"Постави\",\n    \"Copy\": \"Копирай\"\n  },\n  \"WebhookPage\": {\n    \"Clear queue\": \"Изчисти опашката\",\n    \"Webhook settings\": \"Настройки на Webhook\",\n    \"Cleared webhook queue.\": \"Изчистена webhook опашка.\",\n    \"Enabled\": \"Разрешено\",\n    \"Event Types\": \"Видове събития\",\n    \"Memo\": \"Бележка\",\n    \"Name\": \"Име\",\n    \"Ready Column\": \"Колона за готовност\",\n    \"Removed webhook.\": \"Премахнат webhook.\",\n    \"Sorry, not all fields can be edited.\": \"Съжаляваме, но не всички полета могат да бъдат редактирани.\",\n    \"Status\": \"Състояние\",\n    \"URL\": \"URL\",\n    \"Webhook Id\": \"Обозначение на webhook\",\n    \"Table\": \"Таблица\",\n    \"Filter for changes in these columns (semicolon-separated ids)\": \"Отсяване за промени в тези колони (обозначения, разделени с точка и запетая)\",\n    \"Columns to check when update (separated by ;)\": \"Колони, които да се проверяват при актуализация (разделени с ;)\"\n  },\n  \"SearchModel\": {\n    \"Search all pages\": \"Търси във всички страници\",\n    \"Search all tables\": \"Търси във всички таблици\"\n  },\n  \"searchDropdown\": {\n    \"Search\": \"Търси\"\n  },\n  \"SupportGristNudge\": {\n    \"Close\": \"Затвори\",\n    \"Support Grist\": \"Подкрепете Grist\",\n    \"Support Grist page\": \"Подкрепете страницата на Grist\",\n    \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Благодаря ви! Високо ценим вашето доверие и подкрепа. Можете да се откажете по всяко време от {{link}} в менюто на потребителя.\",\n    \"Admin Panel\": \"Административен панел\",\n    \"Contribute\": \"Допринеси\",\n    \"Help Center\": \"Център за помощ\",\n    \"Opt in to Telemetry\": \"Включете се в телеметрията\",\n    \"Opted In\": \"Включи се\"\n  },\n  \"SupportGristPage\": {\n    \"GitHub\": \"GitHub\",\n    \"GitHub Sponsors page\": \"Страница на спонсорите във GitHub\",\n    \"Help Center\": \"Център за помощ\",\n    \"Home\": \"Начало\",\n    \"Manage Sponsorship\": \"Управление на спонсорството\",\n    \"Opt in to Telemetry\": \"Включете се в телеметрията\",\n    \"Opt out of Telemetry\": \"Отказ от телеметрия\",\n    \"Sponsor Grist Labs on GitHub\": \"Спонсорирай Grist Labs в GitHub\",\n    \"Telemetry\": \"Телеметрия\",\n    \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Телеметрията е включена. Само администраторът на сайта има право да променя това.\",\n    \"Support Grist\": \"Подкрепете Grist\",\n    \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Телеметрия е изключена. Само администраторът на сайта има право да променя това.\",\n    \"You have opted out of telemetry.\": \"Отказали сте се от телеметрията.\",\n    \"Sponsor\": \"Спонсор\",\n    \"You can opt out of telemetry at any time from this page.\": \"Можете да се откажете от телеметрията по всяко време от тази страница.\",\n    \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Събираме само статистически данни за използването, както е описано подробно в нашия {{link}}, никога не събираме съдържанието на документите.\",\n    \"You have opted in to telemetry. Thank you!\": \"Включилите сте телеметрията. Благодарим ви!\"\n  },\n  \"buildViewSectionDom\": {\n    \"No data\": \"Няма данни\",\n    \"No row selected in {{title}}\": \"Няма избран ред в {{title}}\",\n    \"Not all data is shown\": \"Не всички данни са показани\"\n  },\n  \"FloatingEditor\": {\n    \"Collapse Editor\": \"Прибери редактора\"\n  },\n  \"FloatingPopup\": {\n    \"Minimize\": \"Минимизирайте\",\n    \"Maximize\": \"Максимизирайте\"\n  },\n  \"CardContextMenu\": {\n    \"Copy anchor link\": \"Копирай връзка към котва\",\n    \"Insert card\": \"Вкарай карта\",\n    \"Insert card above\": \"Вкарай картата по-горе\",\n    \"Insert card below\": \"Вкарай картата по-долу\",\n    \"Delete card\": \"Изтрий карта\",\n    \"Duplicate card\": \"Дублирай карта\"\n  },\n  \"WelcomeCoachingCall\": {\n    \"Maybe later\": \"Може би по-късно\",\n    \"Schedule call\": \"Насрочете разговор\",\n    \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Насрочете {{freeCoachingCall}} с член на нашия екип.\",\n    \"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.\": \"По време на разговора ще отделим време, за да разберем нуждите ви и да адаптираме към вас. Можем да ви покажем основите на Grist или да започнем работа с вашите данни веднага, за да изградим таблата за управление, от които се нуждаете.\",\n    \"free coaching call\": \"безплатен разговор за напътстване\"\n  },\n  \"FormView\": {\n    \"Link copied to clipboard\": \"Връзката е копирана в системния буфер\",\n    \"Preview\": \"Преглед\",\n    \"Reset\": \"Нулирай\",\n    \"Reset form\": \"Нулирай формуляра\",\n    \"Publish\": \"Публикувайте\",\n    \"Publish your form?\": \"Ще публикувайте ли формуляра си?\",\n    \"Unpublish\": \"Отмени на публикуването\",\n    \"Unpublish your form?\": \"Отмени публикуването на формуляра?\",\n    \"Anyone with the link below can see the empty form and submit a response.\": \"Всеки, който използва връзката по-долу, може да види празния формуляр и да изпрати отговор.\",\n    \"Are you sure you want to reset your form?\": \"Сигурни ли сте, че искате да нулирате формуляра си?\",\n    \"Code copied to clipboard\": \"Кодът е копиран в системния буфер\",\n    \"Copy code\": \"Копирай кода\",\n    \"Copy link\": \"Копирай връзката\",\n    \"Embed this form\": \"Вграждане на този формуляр\",\n    \"Save your document to publish this form.\": \"Запазете документа си, за да публикувате този формуляр.\",\n    \"Share\": \"Сподели\",\n    \"Share this form\": \"Споделu този формуляр\",\n    \"View\": \"Виж\"\n  },\n  \"HiddenQuestionConfig\": {\n    \"Hidden fields\": \"Скрити полета\"\n  },\n  \"Editor\": {\n    \"Delete\": \"Изтрий\"\n  },\n  \"Menu\": {\n    \"Building blocks\": \"Изграждащи блокчета\",\n    \"Columns\": \"Колони\",\n    \"Copy\": \"Копирай\",\n    \"Cut\": \"Изрежи\",\n    \"Insert question above\": \"Вмъкнете въпроса по-горе\",\n    \"Insert question below\": \"Въведете въпроса по-долу\",\n    \"Paragraph\": \"Параграф\",\n    \"Paste\": \"Постави\",\n    \"Separator\": \"Разделител\",\n    \"Unmapped fields\": \"Несъпоставени полета\",\n    \"Header\": \"Заглавие\"\n  },\n  \"UnmappedFieldsConfig\": {\n    \"Clear\": \"Изчисти\",\n    \"Map fields\": \"Съпостави полета\",\n    \"Mapped\": \"Съпоставени\",\n    \"Select all\": \"Избери всички\",\n    \"Unmap fields\": \"Премахни съпоставияния на полета\",\n    \"Unmapped\": \"Несъпоставени\"\n  },\n  \"FormConfig\": {\n    \"Required field\": \"Задължително поле\",\n    \"Ascending\": \"Възходящ\",\n    \"Default\": \"По подразбиране\",\n    \"Descending\": \"Низходящ\",\n    \"Field Format\": \"Формат на полето\",\n    \"Field Rules\": \"Правила на полето\",\n    \"Horizontal\": \"Хоризонтален\",\n    \"Options Alignment\": \"Опции за подравняване\",\n    \"Options Sort Order\": \"Опции за посока на сортиране\",\n    \"Radio\": \"Радио бутон\",\n    \"Select\": \"Избери\",\n    \"Vertical\": \"Вертикален\",\n    \"Field rules\": \"Правила на полето\"\n  },\n  \"CustomView\": {\n    \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"За да използвате тази джаджа, съпоставете всички незадължителни колони от панела на създателя вдясно.\",\n    \"Some required columns aren't mapped\": \"Някои задължителни колони не са съпоставени\"\n  },\n  \"FormContainer\": {\n    \"Powered by\": \"Задвижвано от\",\n    \"Build your own form\": \"Изградете свой собствен формуляр\"\n  },\n  \"FormErrorPage\": {\n    \"Error\": \"Грешка\"\n  },\n  \"FormModel\": {\n    \"Oops! The form you're looking for doesn't exist.\": \"Опа! Търсеният от вас формуляр не съществува.\",\n    \"Oops! This form is no longer published.\": \"Опа! Този формуляр вече не е публикуван.\",\n    \"There was a problem loading the form.\": \"Има проблем със зареждането на формуляра.\",\n    \"You don't have access to this form.\": \"Нямате достъп до този формуляр.\"\n  },\n  \"FormPage\": {\n    \"There was an error submitting your form. Please try again.\": \"Има грешка при подаването на формуляра. Моля, опитайте отново.\"\n  },\n  \"FormSuccessPage\": {\n    \"Form Submitted\": \"Подаден формуляр\",\n    \"Thank you! Your response has been recorded.\": \"Благодаря ви! Вашият отговор е записан.\",\n    \"Submit new response\": \"Подаване на нов отговор\"\n  },\n  \"DateRangeOptions\": {\n    \"Last 30 days\": \"Последните 30 дни\",\n    \"Last 7 days\": \"Последните 7 дни\",\n    \"Last week\": \"Миналата седмица\",\n    \"Next 7 days\": \"Следващите 7 дни\",\n    \"This week\": \"Тази седмица\",\n    \"This year\": \"Тази година\",\n    \"This month\": \"Този месец\",\n    \"Today\": \"Днес\"\n  },\n  \"MappedFieldsConfig\": {\n    \"Clear\": \"Изчисти\",\n    \"Mapped\": \"Съпоставени\",\n    \"Unmapped\": \"Несъпоставени\",\n    \"Map fields\": \"Съпостави полета\",\n    \"Select all\": \"Избери всички\",\n    \"Unmap fields\": \"Премахни съпоставияния на полета\"\n  },\n  \"Section\": {\n    \"Insert section above\": \"Вмъкнете раздел по-горе\",\n    \"Insert section below\": \"Вмъкнете раздел по-долу\"\n  },\n  \"CreateTeamModal\": {\n    \"Cancel\": \"Отказ\",\n    \"Choose a name and url for your team site\": \"Изберете име и URL адрес за сайта на вашия екип\",\n    \"Domain name is invalid\": \"Името на домейна е невалидно\",\n    \"Go to your site\": \"Отидете на вашия сайт\",\n    \"Team name\": \"Име на екипа\",\n    \"Team name is required\": \"Името на екипа е задължително\",\n    \"Work as a Team\": \"Работете в екип\",\n    \"Billing is not supported in grist-core\": \"Фактурирането не се поддържа в grist-core\",\n    \"Create site\": \"Създай сайт\",\n    \"Domain name is required\": \"Името на домейна е задължително\",\n    \"Team site created\": \"Създаване на сайт на екипа\",\n    \"Team url\": \"URL адрес на екипа\"\n  },\n  \"AdminPanel\": {\n    \"Admin Panel\": \"Административен панел\",\n    \"Current\": \"Текущ\",\n    \"Current version of Grist\": \"Текуща версия на Grist\",\n    \"Help us make Grist better\": \"Помогнете ни да направим Grist по-добър\",\n    \"Home\": \"Начало\",\n    \"Sponsor\": \"Спомоществовател\",\n    \"Support Grist\": \"Подкрепете Grist\",\n    \"Support Grist Labs on GitHub\": \"Подкрепете Grist Labs в GitHub\",\n    \"Telemetry\": \"Телеметрия\",\n    \"Auto-check when this page loads\": \"Автоматична проверка при зареждане на тази страница\",\n    \"Check now\": \"Проверете сега\",\n    \"Checking for updates...\": \"Проверяване за актуализации...\",\n    \"Error\": \"Грешка\",\n    \"Error checking for updates\": \"Грешка при проверка за актуализации\",\n    \"Grist is up to date\": \"Grist е актуален\",\n    \"Grist releases are at \": \"Версиите на Grist са в \",\n    \"Last checked {{time}}\": \"Последна проверка {{time}}\",\n    \"Learn more.\": \"Научете повече.\",\n    \"Newer version available\": \"Налична е по-нова версия\",\n    \"No information available\": \"Няма налична информация\",\n    \"OK\": \"Добре\",\n    \"Sandbox settings for data engine\": \"Настройки на пясъчника за двигателя за данни\",\n    \"Sandboxing\": \"Изолация (пясъчник)\",\n    \"Security Settings\": \"Настройки на сигурността\",\n    \"Updates\": \"Актуализации\",\n    \"unconfigured\": \"неконфигуриран\",\n    \"unknown\": \"неизвестен\",\n    \"Version\": \"Версия\",\n    \"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 поддържа много мощни формули, изпълнявайки ги с Python. Препоръчваме да зададете променливата на средата GRIST_SANDBOX_FLAVOR на gvisor, ако вашият хардуер го поддържа (повечето го), за да изпълнявате формули във всеки документ във пясъчник, изолирана от други документи и изолирана от мрежата.\"\n  },\n  \"Columns\": {\n    \"Remove Column\": \"Премахни колона\"\n  },\n  \"Field\": {\n    \"No choices configured\": \"Няма конфигуриран избор\",\n    \"No values in show column of referenced table\": \"Няма стойности в колоната за показване на сочената таблица\"\n  },\n  \"Toggle\": {\n    \"Checkbox\": \"Поле за отметка\",\n    \"Field Format\": \"Формат на полето\",\n    \"Switch\": \"Превключвател\"\n  },\n  \"ChoiceEditor\": {\n    \"No choices matching condition\": \"Няма избор, който да отговаря на условието\",\n    \"No choices to select\": \"Няма възможности за избор\",\n    \"Error in dropdown condition\": \"Грешка в падащото условие\"\n  },\n  \"ChoiceListEditor\": {\n    \"No choices matching condition\": \"Няма избор, който да отговаря на условието\",\n    \"No choices to select\": \"Няма възможности за избор\",\n    \"Error in dropdown condition\": \"Грешка в падащото условие\"\n  },\n  \"DropdownConditionConfig\": {\n    \"Dropdown Condition\": \"Условие за падащо меню\",\n    \"Invalid columns: {{colIds}}\": \"Невалидни колони: {{colIds}}\",\n    \"Set dropdown condition\": \"Задаване на условие за падащо меню\"\n  },\n  \"DropdownConditionEditor\": {\n    \"Enter condition.\": \"Въведете условие.\"\n  },\n  \"ReferenceUtils\": {\n    \"Error in dropdown condition\": \"Грешка в падащото условие\",\n    \"No choices matching condition\": \"Няма избор, който да отговаря на условието\",\n    \"No choices to select\": \"Няма възможности за избор\"\n  },\n  \"FormRenderer\": {\n    \"Reset\": \"Нулирай\",\n    \"Search\": \"Търсене\",\n    \"Submit\": \"Подай\",\n    \"Select...\": \"Изберете...\"\n  },\n  \"widgetTypesMap\": {\n    \"Calendar\": \"Календар\",\n    \"Card\": \"Карта\",\n    \"Card List\": \"Списък на картите\",\n    \"Custom\": \"Собствен\",\n    \"Form\": \"Формуляр\",\n    \"Chart\": \"Графика\",\n    \"Table\": \"Таблица\"\n  }\n}\n"
  },
  {
    "path": "static/locales/bg.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/ca.client.json",
    "content": "{\n  \"ACUserManager\": {\n    \"Invite new member\": \"Convidar nou membre\",\n    \"Enter email address\": \"Posa l'adreça de correu electrònic\",\n    \"We'll email an invite to {{email}}\": \"Enviarem una invitació per correu electrònic a {{email}}\"\n  },\n  \"AccessRules\": {\n    \"Add Default Rule\": \"Afegir regla per defecte\",\n    \"Add table rules\": \"Afegir regles de Taula\",\n    \"Add user attributes\": \"Afegir atributs d'usuari\",\n    \"Allow everyone to view Access Rules.\": \"Permetre a tothom veure les Regles d'Accés.\",\n    \"Attribute name\": \"Nom de l'Atribut\",\n    \"Attribute to Look Up\": \"Atribut a buscar\",\n    \"Checking...\": \"Comprovant…\",\n    \"Condition\": \"Condició\",\n    \"Add column rule\": \"Afegir regla de columna\",\n    \"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.\"\n  }\n}\n"
  },
  {
    "path": "static/locales/ca.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/cs.client.json",
    "content": "{\n  \"AccessRules\": {\n    \"Add column rule\": \"Přidat pravidlo pro sloupec\",\n    \"Lookup Column\": \"Vyhledávací Sloupec\",\n    \"Enter Condition\": \"Napiš Podmínku\",\n    \"Everyone Else\": \"Všichni Ostatní\",\n    \"Allow everyone to view Access Rules.\": \"Umožnit všem zobrazit přístupová práva.\",\n    \"Lookup Table\": \"Vyhledávací Tabulka\",\n    \"Add table rules\": \"Přidat pravidlo pro tabulku\",\n    \"Invalid\": \"Neplatné\",\n    \"Condition\": \"Podmínka\",\n    \"Delete table rules\": \"Vymaž Tabulkové Pravidla\",\n    \"Default rules\": \"Základní Práva\",\n    \"Attribute name\": \"Název atributu\",\n    \"Add user attributes\": \"Přidat atribut pro uživatele\",\n    \"Attribute to Look Up\": \"Atribut k vyhledání\",\n    \"Everyone\": \"Všichni\",\n    \"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.\",\n    \"Add Default Rule\": \"Přidat výchozí pravidlo\",\n    \"Checking...\": \"Kontroluji…\",\n    \"Permissions\": \"Povolení\",\n    \"Permission to view Access Rules\": \"Povolení na zobrazení Přistupových Pravidel\",\n    \"Permission to access the document in full when needed\": \"Povolení plného přístupu k dokumentu pokud je to potřeba\",\n    \"Reset\": \"Resetuj\",\n    \"Remove {{- tableId }} rules\": \"Vymaž pravidla týkající se {{- tableId }}\",\n    \"Remove {{- name }} user attribute\": \"Odstraň uživatelský atribut - {{- name }}\",\n    \"Saved\": \"Uloženo\",\n    \"Remove column {{- colId }} from {{- tableId }} rules\": \"Vymaž sloupec {{- colId }} z {{- tableId }} pravidel\",\n    \"Type message to display when this rule blocks an action…\": \"Napiš zprávu…\",\n    \"View as\": \"Zobraz Jako\",\n    \"User Attributes\": \"Uživatelské Atributy\",\n    \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Když přidáváš nové tabulkové pravidla, automaticky přidej pravidlo - uděl MAJITELI plné práva.\",\n    \"Permission to edit document structure\": \"Povolení k editaci struktury dokumentu\",\n    \"Seed rules\": \"Seed pravidla\",\n    \"Save\": \"Ulož\",\n    \"Rules for table \": \"Pravidla pro tabulku \",\n    \"Special rules\": \"Speciální Pravidla\",\n    \"This default should be changed if editors' access is to be limited. \": \"Toto výchozí nastavení by mělo být změněno, pokud má být přístup editorů omezen. \",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Povolit editorům upravovat strukturu (např. měnit a mazat tabulky, sloupce, rozvržení) a psát vzorce, které umožňují přístup ke všem datům bez ohledu na omezení čtení.\",\n    \"Add table-wide rule\": \"Přidat pravidlo pro celou tabulku\"\n  },\n  \"ACUserManager\": {\n    \"Invite new member\": \"Pozvat nového uživatele\",\n    \"We'll email an invite to {{email}}\": \"Zašleme pozvánku emailem na {{email}}\",\n    \"Enter email address\": \"Vepište e-mailovou adresu\"\n  },\n  \"ApiKey\": {\n    \"Remove API Key\": \"Odeber API klíč\",\n    \"Click to show\": \"Klikni pro zobrazení\",\n    \"Create\": \"Vytvoř\",\n    \"Remove\": \"Odeber\",\n    \"By generating an API key, you will be able to make API calls for your own account.\": \"Tím že vygeneruješ API klíč, budeš schopen používat \\\"API calls\\\" pro svůj vlastní účet.\",\n    \"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?\": \"Chystáte se smazat klíč API. To způsobí, že všechny budoucí požadavky používající tento klíč API budou odmítnuty. Opravdu chcete smazat?\",\n    \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Tento klíč API lze použít k přístupu k vašemu účtu prostřednictvím API. Klíč API s nikým nesdílejte.\",\n    \"This API key can be used to access this account anonymously via the API.\": \"Tento klíč API lze použít k anonymnímu přístupu k tomuto účtu prostřednictvím API.\"\n  },\n  \"AccountPage\": {\n    \"Theme\": \"Motiv\",\n    \"API\": \"API\",\n    \"Change password\": \"Změň heslo\",\n    \"Email\": \"E-mail\",\n    \"Password & security\": \"Heslo a Zabezpečení\",\n    \"Account settings\": \"Uživatelské nastavení\",\n    \"Two-factor authentication\": \"Dvoufázové ověřování\",\n    \"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.\": \"Dvoufázové ověřování je extra vrstva zabezpečení pro tvůj Grist účet. Slouží k tomu aby jsi byl opravdu jediný, kdo se do tvého účtu dostane i-kdyby někdo jiný znal tvé heslo.\",\n    \"Language\": \"Jazyk\",\n    \"Edit\": \"Uprav\",\n    \"Names only allow letters, numbers and certain special characters\": \"Jména povolují pouze písmena, čísla a některé speciální znaky\",\n    \"Login method\": \"Přihlašovací metody\",\n    \"API Key\": \"API klíč\",\n    \"Name\": \"Jméno\",\n    \"Save\": \"Ulož\",\n    \"Allow signing in to this account with Google\": \"Povolit přihlášení k tomuto účtu pomocí Google\"\n  },\n  \"AccountWidget\": {\n    \"Add account\": \"Přidej Účet\",\n    \"Switch Accounts\": \"Přepni Účty\",\n    \"Activation\": \"Aktivace\",\n    \"Toggle Mobile Mode\": \"Přepni Mobilní režim\",\n    \"Support Grist\": \"Podpoř Grist\",\n    \"Access Details\": \"Přístupové detaily\",\n    \"Upgrade Plan\": \"Upgraduj svůj Plán\",\n    \"Sign out\": \"Odhlásit se\",\n    \"Profile settings\": \"Nastavení Profilu\",\n    \"Sign in\": \"Přihlaš se\",\n    \"Pricing\": \"Ceník\",\n    \"Document settings\": \"Nastavení Dokumentu\",\n    \"Use This Template\": \"Použij tuto Šablonu\",\n    \"Manage team\": \"Spravuj tým\",\n    \"Billing account\": \"Fakturační účet\",\n    \"Accounts\": \"Účty\",\n    \"Sign up\": \"Odhlaš se\"\n  },\n  \"FieldConfig\": {\n    \"Set formula\": \"nastav formuli\",\n    \"Data columns_other\": \"Data Sloupce\",\n    \"DESCRIPTION\": \"POPIS\",\n    \"Empty columns_other\": \"Prázdné Sloupce\",\n    \"Empty columns_one\": \"Prázdný Sloupec\",\n    \"Formula columns_other\": \"Formula Sloupce\",\n    \"Formula columns_one\": \"Formula Sloupec\",\n    \"Make into data column\": \"Změň na data sloupec\",\n    \"Enter formula\": \"Zadej formuli\",\n    \"Data columns_one\": \"Data Sloupec\",\n    \"Set trigger formula\": \"Nastav \\\"trigger\\\" formuli\",\n    \"Column options are limited in summary tables.\": \"Možnosti sloupců jsou v souhrnných tabulkách omezené.\",\n    \"Convert column to data\": \"Převést sloupec na data\",\n    \"TRIGGER FORMULA\": \"SPOUŠTĚCÍ VZOREC\",\n    \"Clear and reset\": \"Vymazat a resetovat\",\n    \"COLUMN LABEL AND ID\": \"OZNAČENÍ A ID SLOUPCE\",\n    \"Clear and make into formula\": \"Vymazat a převést na vzorec\",\n    \"COLUMN BEHAVIOR\": \"CHOVÁNÍ SLOUPCE\",\n    \"Convert to trigger formula\": \"Převést na spouštěcí vzorec\",\n    \"Mixed Behavior\": \"Smíšené chování\"\n  },\n  \"ActionLog\": {\n    \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Sloupec {{colId}} byl odstraněn po spuštění akce #{{action.actionNum}}\",\n    \"Action Log failed to load\": \"Historii akcí se nepovedlo načíst\",\n    \"This row was subsequently removed in action {{action.actionNum}}\": \"Tento řádek byl odstraněn po spuštění akce {{action.actionNum}}\",\n    \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Tabulka {{tableId}} byla odstraněna po spuštění akce #{{actionNum}}\",\n    \"All tables\": \"Všechny tabulky\"\n  },\n  \"FieldMenus\": {\n    \"Use separate settings\": \"Použij rozdílné nastavení\",\n    \"Revert to common settings\": \"Převeď na standardní nastavení\",\n    \"Using common settings\": \"Použito standardní nastavení\",\n    \"Using separate settings\": \"Použito rozdílné nastavení\",\n    \"Save as common settings\": \"Ulož jako standardní nastavení\"\n  },\n  \"FilterConfig\": {\n    \"Add column\": \"Přidej Sloupec\"\n  },\n  \"AppHeader\": {\n    \"Personal Site\": \"Osobní stránka\",\n    \"Home page\": \"Domácí stránka\",\n    \"Team Site\": \"Stránka Týmu\",\n    \"Grist Templates\": \"Šablony Grist\",\n    \"Billing account\": \"Fakturační účet\",\n    \"Manage team\": \"Spravovat tým\",\n    \"Legacy\": \"Stará verze\"\n  },\n  \"ViewAsDropdown\": {\n    \"View as\": \"Zobraz Jako\",\n    \"Example Users\": \"\\\"Příkladoví\\\" Uživatelé\",\n    \"Users from table\": \"Uživatelé z tabulky\"\n  },\n  \"App\": {\n    \"Description\": \"Popis\",\n    \"Memory Error\": \"Chyba paměti\",\n    \"Key\": \"Klíč\",\n    \"Translators: please translate this only when your language is ready to be offered to users\": \"Překladatelé: Překládejte, prosím, až když je váš jazyk připraven k nabídce uživatelům\"\n  },\n  \"FilterBar\": {\n    \"SearchColumns\": \"Prohledej sloupce\",\n    \"Search Columns\": \"Hledat sloupce\"\n  },\n  \"AddNewButton\": {\n    \"Add new\": \"Přidej Nový\"\n  },\n  \"CellContextMenu\": {\n    \"Clear cell\": \"Vymazat buňku\",\n    \"Clear values\": \"Vymazat hodnoty\",\n    \"Duplicate rows_other\": \"Duplikovat řádky\",\n    \"Filter by this value\": \"Filtrovat podle této hodnoty\",\n    \"Insert column to the right\": \"Vložit sloupec vpravo\",\n    \"Insert row\": \"Vložit řádek\",\n    \"Insert row below\": \"Vložit řádek pod\",\n    \"Reset {{count}} entire columns_one\": \"Resetovat celý sloupec\",\n    \"Reset {{count}} entire columns_other\": \"Resetovat {{count}} celých sloupců\",\n    \"Copy\": \"Kopírovat\",\n    \"Cut\": \"Vyjmout\",\n    \"Copy with headers\": \"Kopírovat s hlavičkami\",\n    \"Reset {{count}} columns_other\": \"Resetovat {{count}} sloupce\",\n    \"Reset {{count}} columns_one\": \"Resetovat sloupec\",\n    \"Paste\": \"Vložit\",\n    \"Delete {{count}} columns_other\": \"Smazat {{count}} sloupců\",\n    \"Delete {{count}} rows_one\": \"Smazat řádek\",\n    \"Insert column to the left\": \"Vložit sloupec vlevo\",\n    \"Delete {{count}} rows_other\": \"Smazat {{count}} řádků\",\n    \"Duplicate rows_one\": \"Duplikovat řádek\",\n    \"Copy anchor link\": \"Kopírovat odkaz kotvy\",\n    \"Delete {{count}} columns_one\": \"Smazat sloupec\",\n    \"Comment\": \"Komentář\",\n    \"Insert row above\": \"Vložit řádek nad\"\n  },\n  \"ChartView\": {\n    \"Each Y series is followed by a series for the length of error bars.\": \"Každá Y série je následována sérií pro délku chybových pruhů.\",\n    \"Toggle chart aggregation\": \"Přepnout agregaci grafu\",\n    \"Create separate series for each value of the selected column.\": \"Vytvořit samostatné série pro každou hodnotu vybrané sloupce.\",\n    \"selected new group data columns\": \"vybrané nové sloupce skupinových dat\",\n    \"Each Y series is followed by two series, for top and bottom error bars.\": \"Každá Y série je následována dvěma sériemi, pro horní a dolní chybové pruhy.\",\n    \"Pick a column\": \"Vyberte sloupec\"\n  },\n  \"CodeEditorPanel\": {\n    \"Access denied\": \"Přístup odepřen\",\n    \"Code View is available only when you have full document access.\": \"Zobrazení kódu je dostupné pouze při plném přístupu k dokumentu.\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"Použít\",\n    \"Cancel\": \"Zrušit\",\n    \"Default cell style\": \"Výchozí styl buňky\"\n  },\n  \"ColumnFilterMenu\": {\n    \"All shown\": \"Vše zobrazené\",\n    \"Filter by Range\": \"Filtrovat podle rozsahu\",\n    \"No matching values\": \"Žádné odpovídající hodnoty\",\n    \"Future values\": \"Budoucí hodnoty\",\n    \"None\": \"Žádné\",\n    \"Min\": \"Min\",\n    \"All\": \"Vše\",\n    \"All except\": \"Vše kromě\",\n    \"Other values\": \"Ostatní hodnoty\",\n    \"Others\": \"Ostatní\",\n    \"Search\": \"Hledat\",\n    \"Search values\": \"Hledat hodnoty\",\n    \"End\": \"Končí\",\n    \"Other Non-Matching\": \"Ostatní neodpovídající\",\n    \"Start\": \"Začíná\",\n    \"Other Matching\": \"Ostatní odpovídající\",\n    \"Max\": \"Max\"\n  },\n  \"CustomSectionConfig\": {\n    \"Enter Custom URL\": \"Zadat vlastní URL\",\n    \"Learn more about custom widgets\": \"Zjistit více o vlastních widgetech\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} sloupce, které nejsou typu {{columnType}}, nejsou zobrazeny\",\n    \"No {{columnType}} columns in table.\": \"Žádné sloupce typu {{columnType}} v tabulce.\",\n    \"ACCESS LEVEL\": \"ÚROVEŇ PŘÍSTUPU\",\n    \"Accept\": \"Přijmout\",\n    \"Custom URL\": \"Vlastní URL\",\n    \"Developer:\": \"Vývojář:\",\n    \"Last updated:\": \"Poslední aktualizace:\",\n    \"Missing description and author information.\": \"Chybí popis a informace o autorovi.\",\n    \"Reject\": \"Odmítnout\",\n    \"Widget\": \"Widget\",\n    \" (optional)\": \" (volitelné)\",\n    \"Pick a column\": \"Vyberte sloupec\",\n    \"Pick a {{columnType}} column\": \"Vyberte sloupec typu {{columnType}}\",\n    \"Read selected table\": \"Číst vybranou tabulku\",\n    \"Select Custom Widget\": \"Vybrat vlastní widget\",\n    \"Widget does not require any permissions.\": \"Widget nevyžaduje žádná oprávnění.\",\n    \"Widget needs to {{read}} the current table.\": \"Widget potřebuje {{read}} aktuální tabulku.\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} sloupec, který není typu {{columnType}}, není zobrazen\",\n    \"Widget needs {{fullAccess}} to this document.\": \"Widget potřebuje {{fullAccess}} k tomuto dokumentu.\",\n    \"Clear selection\": \"Vymazat výběr\",\n    \"Full document access\": \"Plný přístup k dokumentu\",\n    \"Add\": \"Přidat\",\n    \"No document access\": \"Žádný přístup k dokumentu\",\n    \"Open configuration\": \"Otevřít konfiguraci\"\n  },\n  \"DataTables\": {\n    \"Click to copy\": \"Klikněte pro zkopírování\",\n    \"Raw Data Tables\": \"Tabulky surových dat\",\n    \"Duplicate table\": \"Duplikovat tabulku\",\n    \"Remove table\": \"Odstranit tabulku\",\n    \"Rename table\": \"Přejmenovat tabulku\",\n    \"{{action}} Record Card\": \"{{action}} kartu záznamu\",\n    \"Table ID copied to clipboard\": \"ID tabulky zkopírováno do schránky\",\n    \"Edit record card\": \"Upravit kartu záznamu\",\n    \"Record Card\": \"Karta záznamu\",\n    \"Record Card Disabled\": \"Karta záznamu zakázána\",\n    \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Smazat data {{formattedTableName}} a odstranit je ze všech stránek?\",\n    \"You do not have edit access to this document\": \"Nemáte přístup k úpravám tohoto dokumentu\"\n  },\n  \"DocHistory\": {\n    \"Snapshots\": \"Snímky\",\n    \"Snapshots are unavailable.\": \"Snímky nejsou k dispozici.\",\n    \"Only owners have access to snapshots for documents with access rules.\": \"Pouze vlastníci mají přístup k snímkům pro dokumenty s pravidly přístupu.\",\n    \"Beta\": \"Beta\",\n    \"Activity\": \"Aktivita\",\n    \"Compare to current\": \"Porovnat s aktuálním\",\n    \"Compare to previous\": \"Porovnat s předchozím\",\n    \"Open snapshot\": \"Otevřít snímek\"\n  },\n  \"DocMenu\": {\n    \"Examples & Templates\": \"Příklady & šablony\",\n    \"Pinned Documents\": \"Připnuté dokumenty\",\n    \"Trash\": \"Koš\",\n    \"Delete\": \"Smazat\",\n    \"Delete Forever\": \"Smazat navždy\",\n    \"Delete {{name}}\": \"Smazat {{name}}\",\n    \"Document will be permanently deleted.\": \"Dokument bude trvale smazán.\",\n    \"Edited {{at}}\": \"Upraveno {{at}}\",\n    \"Move\": \"Přesunout\",\n    \"Move {{name}} to workspace\": \"Přesunout {{name}} do pracovního prostoru\",\n    \"Other Sites\": \"Ostatní stránky\",\n    \"Permanently Delete \\\"{{name}}\\\"?\": \"Trvale smazat \\\"{{name}}\\\"?\",\n    \"Pin Document\": \"Připnout dokument\",\n    \"This service is not available right now\": \"Tato služba není momentálně k dispozici\",\n    \"Workspace not found\": \"Pracovní prostor nenalezen\",\n    \"Unpin Document\": \"Odepnout dokument\",\n    \"Access Details\": \"Podrobnosti o přístupu\",\n    \"All documents\": \"Všechny dokumenty\",\n    \"(The organization needs a paid plan)\": \"(Organizace potřebuje placený plán)\",\n    \"Deleted {{at}}\": \"Smazáno {{at}}\",\n    \"By Date Modified\": \"Podle data úpravy\",\n    \"By Name\": \"Podle názvu\",\n    \"Current workspace\": \"Aktuální pracovní prostor\",\n    \"Discover More Templates\": \"Objevte více šablon\",\n    \"Document will be moved to Trash.\": \"Dokument bude přesunut do koše.\",\n    \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Dokumenty zůstanou v koši 30 dní, po kterých budou trvale smazány.\",\n    \"Remove\": \"Odstranit\",\n    \"To restore this document, restore the workspace first.\": \"Pro obnovení tohoto dokumentu nejprve obnovte pracovní prostor.\",\n    \"Trash is empty.\": \"Koš je prázdný.\",\n    \"Any documents created in this site will appear here.\": \"Jakékoliv dokumenty vytvořené na této stránce se objeví zde.\",\n    \"Create my first document\": \"Vytvořte svůj první dokument\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Máte pouze přístup pro čtení k této stránce. Momentálně zde nejsou žádné dokumenty.\",\n    \"personal site\": \"osobní stránka\",\n    \"Restore\": \"Obnovit\",\n    \"Requires edit permissions\": \"Vyžaduje oprávnění k úpravám\",\n    \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Jste na stránce {{siteName}}. Máte také přístup k následujícím stránkám:\",\n    \"You may delete a workspace forever once it has no documents in it.\": \"Pracovní prostor můžete trvale smazat, jakmile v něm nebudou žádné dokumenty.\",\n    \"Examples and Templates\": \"Příklady a šablony\",\n    \"Manage users\": \"Spravovat uživatele\",\n    \"You are on your personal site. You also have access to the following sites:\": \"Jste na své osobní stránce. Máte také přístup k následujícím stránkám:\",\n    \"Featured\": \"Vybrané\",\n    \"More Examples and Templates\": \"Více příkladů a šablon\",\n    \"Rename\": \"Přejmenovat\"\n  },\n  \"DocumentSettings\": {\n    \"Document ID copied to clipboard\": \"ID dokumentu zkopírováno do schránky\",\n    \"API documentation.\": \"Dokumentace k API.\",\n    \"Currency\": \"Měna\",\n    \"Data engine\": \"Datový engine\",\n    \"Document ID\": \"ID dokumentu\",\n    \"Notify other services on doc changes\": \"Oznamovat jiným službám změny v dokumentu\",\n    \"python2 (legacy)\": \"python2 (legacy)\",\n    \"Force reload the document while timing formulas, and show the result.\": \"Nutné znovu načíst dokument při časování vzorců a zobrazit výsledek.\",\n    \"Formula timer\": \"Časovač vzorce\",\n    \"Reload data engine\": \"Obnovit datový engine\",\n    \"Reload data engine?\": \"Obnovit datový engine?\",\n    \"Start timing\": \"Začít měřit čas\",\n    \"Stop timing...\": \"Zastavit měření času...\",\n    \"Time reload\": \"Čas pro obnovení\",\n    \"Timing is on\": \"Měření času je zapnuto\",\n    \"You can make changes to the document, then stop timing to see the results.\": \"Můžete provést změny v dokumentu a poté zastavit měření času, abyste viděli výsledky.\",\n    \"Document settings\": \"Nastavení dokumentu\",\n    \"This document's ID (for API use):\": \"ID tohoto dokumentu (pro použití API):\",\n    \"Ok\": \"OK\",\n    \"Webhooks\": \"Webhooky\",\n    \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Tímto dojde k tvrdému obnovení datového engine. To může pomoci, pokud je datový engine uvízlý v nekonečné smyčce, neustále zpracovává poslední změnu nebo došlo k jeho pádu. Žádná data nebudou ztracena, kromě případně aktuálně čekajících akcí.\",\n    \"Template\": \"Šablona\",\n    \"python3 (recommended)\": \"python3 (doporučeno)\",\n    \"Change document type\": \"Změna typu dokumentu\",\n    \"Regular document\": \"Běžný dokument\",\n    \"Manage webhooks\": \"Spravovat webhooky\",\n    \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Jakmile začnete měřit čas, aplikace bude měřit dobu potřebnou k vyhodnocení každého vzorce. To umožňuje diagnostikovat, které vzorce jsou zodpovědné za pomalý výkon při prvním otevření dokumentu nebo když dokument reaguje na změny.\",\n    \"Hard reset of data engine\": \"Tvrdý reset datového engine\",\n    \"Change nature of document\": \"Změna povahy dokumentu\",\n    \"Cancel\": \"Zrušit\",\n    \"API URL copied to clipboard\": \"API URL zkopírováno do schránky\",\n    \"API console\": \"API konzole\",\n    \"Coming soon\": \"Brzy bude k dispozici\",\n    \"Copy to clipboard\": \"Kopírovat do schránky\",\n    \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"ID dokumentu, které se použije, kdykoliv REST API požaduje {{docId}}. Viz {{apiURL}}\",\n    \"For currency columns\": \"Pro sloupce s měnou\",\n    \"Formula times\": \"Časy vzorců\",\n    \"Find slow formulas\": \"Najít pomalé vzorce\",\n    \"For number and date formats\": \"Pro formáty čísel a dat\",\n    \"Python version used\": \"Použitá verze Pythonu\",\n    \"Python\": \"Python\",\n    \"Reload\": \"Načíst znovu\",\n    \"Time zone\": \"Časové pásmo\",\n    \"Template mode\": \"Režim šablony\",\n    \"Regular\": \"Běžný\",\n    \"fiddle mode\": \"Režim pokusů\",\n    \"Tutorial\": \"Návod\",\n    \"Confirm change\": \"Potvrdit změnu\",\n    \"Engine (experimental {{span}} change at own risk):\": \"Engine (experimentální {{span}} změna na vlastní riziko):\",\n    \"Time Zone:\": \"Časové pásmo:\",\n    \"API\": \"API\",\n    \"Try API calls from the browser\": \"Vyzkoušejte volání API z prohlížeče\",\n    \"Only available to document editors\": \"K dispozici pouze editorům dokumentu\",\n    \"Only available to document owners\": \"K dispozici pouze vlastníkům dokumentu\",\n    \"Locale:\": \"Jazyk:\",\n    \"Save\": \"Uložit\",\n    \"Currency:\": \"Měna:\",\n    \"Edit\": \"Upravit\",\n    \"ID for API use\": \"ID pro použití v API\",\n    \"Normal document behavior. All users work on the same copy of the document.\": \"Normální chování dokumentu. Všichni uživatelé pracují se stejnou kopií dokumentu.\",\n    \"Document automatically opens as a user-specific copy.\": \"Dokument se automaticky otevře jako kopie specifická pro uživatele.\",\n    \"Manage Webhooks\": \"Spravovat webhooky\",\n    \"Save and Reload\": \"Uložit a znovu načíst\",\n    \"Local currency ({{currency}})\": \"Místní měna ({{currency}})\",\n    \"Default for DateTime columns\": \"Výchozí pro sloupce DateTime\",\n    \"Base doc URL: {{docApiUrl}}\": \"Základní URL dokumentu: {{docApiUrl}}\",\n    \"Locale\": \"Jazyk\",\n    \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Dokument se automaticky otevře v {{fiddleModeDocUrl}}. Každý může upravit, což vytvoří novou neuloženou kopii.\",\n    \"Newly uploaded attachments will be placed in External storage.\": \"Nově nahrané přílohy se umístí do externího úložiště.\",\n    \"Newly uploaded attachments will be placed in Internal storage.\": \"Nově nahrané přílohy se umístí do interního úložiště.\",\n    \"No external stores available\": \"Nejsou k dispozici externí úložiště\",\n    \"Preferred storage for this document\": \"Upřednostňované uložení tohoto dokumentu\",\n    \"Start transfer\": \"Zahájit přenos\",\n    \"**Some existing attachments are still external**.\": \"**Některé stávající přílohy jsou stále externí**.\",\n    \"Attachment storage\": \"Uložení příloh\",\n    \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Kliknutím na tlačítko \\\"Začít přenos\\\" je přenesete do interního úložiště (uloženého v souboru SQLite dokumentu).\",\n    \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Kliknutím na tlačítko \\\"Začít přenos\\\" je přenesete do externího úložiště.\",\n    \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Některé stávající přílohy jsou stále interní** (uložené v souboru SQLite).\",\n    \"Being transfer\": \"Přenos započal\"\n  },\n  \"DocumentUsage\": {\n    \"Contact the site owner to upgrade the plan to raise limits.\": \"Kontaktujte vlastníka webu, aby upgradoval plán a zvýšil limity.\",\n    \"Rows\": \"Řádky\",\n    \"Usage statistics are only available to users with full access to the document data.\": \"Statistiky použití jsou k dispozici pouze uživatelům s plným přístupem k datům dokumentu.\",\n    \"For higher limits, \": \"Pro vyšší limity, \",\n    \"Data size\": \"Velikost dat\",\n    \"Usage\": \"Použití\",\n    \"start your 30-day free trial of the Pro plan.\": \"Začněte svou 30denní bezplatnou zkušební verzi plánu Pro.\",\n    \"Size of attachments\": \"Velikost příloh\"\n  },\n  \"GridViewMenus\": {\n    \"Add to sort\": \"Přidat k seřazení\",\n    \"Duplicate in {{- label}}\": \"Duplikovat v {{- label}}\",\n    \"Last updated by\": \"Poslední aktualizace v\",\n    \"Any\": \"Jakékoli\",\n    \"Numeric\": \"Číselné\",\n    \"Hidden Columns\": \"Skryté sloupce\",\n    \"Created By\": \"Vytvořil\",\n    \"Reference List\": \"Seznam referencí\",\n    \"Unfreeze {{count}} columns_other\": \"Odemknout {{count}} sloupců\",\n    \"Date\": \"Datum\",\n    \"Add column with type\": \"Přidat sloupec s typem\",\n    \"DateTime\": \"Datum a čas\",\n    \"Convert formula to data\": \"Převést vzorec na data\",\n    \"Delete {{count}} columns_one\": \"Smazat sloupec\",\n    \"Filter Data\": \"Filtrovat data\",\n    \"Freeze {{count}} columns_one\": \"Zamknout tento sloupec\",\n    \"Insert column to the left\": \"Vložit sloupec vlevo\",\n    \"Created at\": \"Vytvořeno v\",\n    \"Created by\": \"Vytvořil\",\n    \"Freeze {{count}} columns_other\": \"Zamknout {{count}} sloupců\",\n    \"Freeze {{count}} more columns_one\": \"Zamknout jeden další sloupec\",\n    \"Freeze {{count}} more columns_other\": \"Zamknout {{count}} dalších sloupců\",\n    \"Hide {{count}} columns_other\": \"Skrýt {{count}} sloupců\",\n    \"Insert column to the {{to}}\": \"Vložit sloupec do {{to}}\",\n    \"More sort options ...\": \"Více možností seřazení…\",\n    \"Rename column\": \"Přejmenovat sloupec\",\n    \"Reset {{count}} columns_one\": \"Resetovat sloupec\",\n    \"Reset {{count}} columns_other\": \"Resetovat {{count}} sloupce\",\n    \"Show column {{- label}}\": \"Zobrazit sloupec {{- label}}\",\n    \"Sorted (#{{count}})_one\": \"Seřazeno (#{{count}})\",\n    \"Sorted (#{{count}})_other\": \"Seřazeno (#{{count}})\",\n    \"Unfreeze all columns\": \"Odemknout všechny sloupce\",\n    \"Adding UUID column\": \"Přidání sloupce UUID\",\n    \"Adding duplicates column\": \"Přidání sloupce s duplicitami\",\n    \"no reference column\": \"Žádný referenční sloupec\",\n    \"Detect Duplicates in...\": \"Detekce duplicit v...\",\n    \"Search columns\": \"Prohledat sloupce\",\n    \"No reference columns.\": \"Žádné referenční sloupce.\",\n    \"Add column\": \"Přidat sloupec\",\n    \"Detect duplicates in...\": \"Detekce duplicit v...\",\n    \"Column Options\": \"Možnosti sloupce\",\n    \"Hide {{count}} columns_one\": \"Skrýt sloupec\",\n    \"Authorship\": \"Autorství\",\n    \"Last Updated By\": \"Poslední aktualizace v\",\n    \"Add formula column\": \"Přidat sloupec s vzorcem\",\n    \"Integer\": \"Celé číslo\",\n    \"Toggle\": \"Přepínač\",\n    \"Attachment\": \"Příloha\",\n    \"Insert column to the right\": \"Vložit sloupec vpravo\",\n    \"Reset {{count}} entire columns_other\": \"Resetovat {{count}} celých sloupců\",\n    \"Sort\": \"Seřadit\",\n    \"Unfreeze {{count}} columns_one\": \"Odemknout tento sloupec\",\n    \"Apply on record changes\": \"Použít na změny záznamu\",\n    \"Apply to new records\": \"Použít na nové záznamy\",\n    \"Created At\": \"Vytvořeno v\",\n    \"Text\": \"Text\",\n    \"Choice\": \"Výběr\",\n    \"Last Updated At\": \"Poslední aktualizace\",\n    \"Clear values\": \"Vymazat hodnoty\",\n    \"Show hidden columns\": \"Zobrazit skryté sloupce\",\n    \"Timestamp\": \"Časové razítko\",\n    \"UUID\": \"UUID\",\n    \"Last updated at\": \"Poslední aktualizace\",\n    \"Lookups\": \"Vyhledávání\",\n    \"Shortcuts\": \"Zkratky\",\n    \"Choice List\": \"Seznam možností\",\n    \"Reference\": \"Reference\",\n    \"Delete {{count}} columns_other\": \"Smazat {{count}} sloupců\",\n    \"Reset {{count}} entire columns_one\": \"Resetovat celý sloupec\"\n  },\n  \"HomeIntro\": {\n    \"Sign up\": \"Zaregistrovat se\",\n    \"This workspace is empty.\": \"Tento pracovní prostor je prázdný.\",\n    \"Visit our {{link}} to learn more.\": \"Navštivte náš {{link}} a dozvíte se víc.\",\n    \"Welcome to Grist, {{name}}!\": \"Vítejte v Grist, {{name}}!\",\n    \"Welcome to {{orgName}}\": \"Vítejte v {{orgName}}\",\n    \"Welcome to Grist, {{- name}}!\": \"Vítejte v Grist, {{- name}}\",\n    \"Any documents created in this site will appear here.\": \"Jakékoliv dokumenty vytvořené na této stránce se objeví zde.\",\n    \"Welcome to Grist!\": \"Vítejte na Grist!\",\n    \"Browse Templates\": \"Procházet šablony\",\n    \"Create empty document\": \"Vytvořit prázdný dokument\",\n    \"Help Center\": \"Centrum nápovědy\",\n    \"{{signUp}} to save your work. \": \"{{signUp}} pro uložení své práce. \",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Máte pouze přístup pro čtení k této stránce. Momentálně zde nejsou žádné dokumenty.\",\n    \"Get started by exploring templates, or creating your first Grist document.\": \"Začněte prozkoumáváním šablon nebo vytvořením prvního dokumentu v Grist.\",\n    \"Get started by creating your first Grist document.\": \"Začněte vytvořením prvního dokumentu v aplikaci.\",\n    \"Interested in using Grist outside of your team? Visit your free \": \"Máte zájem používat Grist mimo svůj tým? Navštivte svůj bezplatný \",\n    \"Import document\": \"Importovat dokument\",\n    \"Invite Team Members\": \"Pozvat členy týmu\",\n    \"personal site\": \"osobní stránka\",\n    \"Get started by inviting your team and creating your first Grist document.\": \"Začněte pozváním svého týmu a vytvořením prvního dokumentu v Grist.\",\n    \"Sprouts Program\": \"Program Sprouts\",\n    \"Welcome to {{- orgName}}\": \"Vítejte v {{- orgName}}\",\n    \"To use Grist, please either sign up or sign in.\": \"Chcete-li používat službu Grist, zaregistrujte se nebo přihlaste.\",\n    \"Learn more in our {{helpCenterLink}}.\": \"Více informací najdete na našich stránkách {{helpCenterLink}}.\",\n    \"Only show documents\": \"Zobrazit pouze dokumenty\",\n    \"Visit our {{link}} to learn more about Grist.\": \"Navštivte náš {{link}}, abyste se dozvěděli více o Grist.\",\n    \"Sign in\": \"Přihlásit se\"\n  },\n  \"HomeLeftPane\": {\n    \"Terms of service\": \"Podmínky služby\",\n    \"Import document\": \"Importovat dokument\",\n    \"Workspace will be moved to Trash.\": \"Pracovní prostor bude přesunut do koše.\",\n    \"Create workspace\": \"Vytvořit pracovní prostor\",\n    \"Workspaces\": \"Pracovní prostory\",\n    \"Manage users\": \"Spravovat uživatele\",\n    \"Tutorial\": \"Tutoriál\",\n    \"Examples & Templates\": \"Šablony\",\n    \"Trash\": \"Koš\",\n    \"Delete\": \"Smazat\",\n    \"Access Details\": \"Podrobnosti o přístupu\",\n    \"All documents\": \"Všechny dokumenty\",\n    \"Rename\": \"Přejmenovat\",\n    \"Create empty document\": \"Vytvořit prázdný dokument\",\n    \"Delete {{workspace}} and all included documents?\": \"Odstranit {{workspace}} a všechny obsažené dokumenty?\",\n    \"Grist Resources\": \"Zdroje Grist\"\n  },\n  \"PermissionsWidget\": {\n    \"Deny all\": \"Odmítnout vše\",\n    \"Read only\": \"Pouze pro čtení\",\n    \"Allow all\": \"Povolit vše\"\n  },\n  \"RecordLayoutEditor\": {\n    \"Cancel\": \"Zrušit\",\n    \"Save layout\": \"Uložit rozložení\",\n    \"Show field {{- label}}\": \"Zobrazit pole {{- label}}\",\n    \"Add field\": \"Přidat pole\",\n    \"Create new field\": \"Vytvořit nové pole\"\n  },\n  \"RowContextMenu\": {\n    \"Duplicate rows_one\": \"Duplikovat řádek\",\n    \"View as card\": \"Zobrazit jako kartu\",\n    \"Duplicate rows_other\": \"Duplikovat řádky\",\n    \"Copy anchor link\": \"Kopírovat odkaz kotvy\",\n    \"Insert row\": \"Vložit řádek\",\n    \"Delete\": \"Smazat\",\n    \"Insert row above\": \"Vložit řádek nad\",\n    \"Use as table headers\": \"Použít jako záhlaví tabulky\",\n    \"Insert row below\": \"Vložit řádek pod\"\n  },\n  \"Importer\": {\n    \"{{count}} unmatched field_other\": \"{{count}} neshodující se pole\",\n    \"Grist column\": \"Sloupec Grist\",\n    \"{{count}} unmatched field in import_other\": \"{{count}} neshodující se pole při importu\",\n    \"{{count}} unmatched field_one\": \"{{count}} neshodující se pole\",\n    \"Skip Import\": \"Přeskočit import\",\n    \"Merge rows that match these fields:\": \"Sloučit řádky, které odpovídají těmto polím:\",\n    \"Column Mapping\": \"Mapování sloupce\",\n    \"Import from file\": \"Importovat ze souboru\",\n    \"New Table\": \"Nová tabulka\",\n    \"Revert\": \"Vrátit\",\n    \"Destination table\": \"Cílová tabulka\",\n    \"Skip Table on Import\": \"Přeskočit tabulku při importu\",\n    \"Source column\": \"Zdrojový sloupec\",\n    \"Column mapping\": \"Mapování sloupce\",\n    \"Select fields to match on\": \"Vyberte pole, která chcete porovnat\",\n    \"Update existing records\": \"Aktualizovat stávající záznamy\",\n    \"{{count}} unmatched field in import_one\": \"{{count}} neshodující se pole při importu\",\n    \"Skip\": \"Přeskočit\"\n  },\n  \"PageWidgetPicker\": {\n    \"Select widget\": \"Vybrat Widget\",\n    \"Group by\": \"Seskupit podle\",\n    \"Building {{- label}} widget\": \"Vytváření widgetu {{- label}}\",\n    \"Add to page\": \"Přidat na stránku\",\n    \"Select data\": \"Vybrat data\"\n  },\n  \"Pages\": {\n    \"Delete\": \"Smazat\",\n    \"The following tables will no longer be visible_one\": \"Následující tabulka již nebude viditelná.\",\n    \"Delete data and this page.\": \"Smazat data a tuto stránku.\",\n    \"The following tables will no longer be visible_other\": \"Následující tabulky již nebudou viditelné\"\n  },\n  \"RightPanel\": {\n    \"GROUPED BY\": \"SESKUPENO PODLE\",\n    \"CUSTOM\": \"VLASTNÍ\",\n    \"fields_one\": \"Pole\",\n    \"Configuration\": \"Konfigurace\",\n    \"Default field value\": \"Výchozí hodnota pole\",\n    \"Submission\": \"Odeslání\",\n    \"Detach\": \"Odpojení\",\n    \"Edit data selection\": \"Upravit výběr dat\",\n    \"Theme\": \"Motiv\",\n    \"Widget\": \"Widget\",\n    \"WIDGET TITLE\": \"NÁZEV WIDGETU\",\n    \"Layout\": \"Rozložení\",\n    \"Required field\": \"Povinné pole\",\n    \"Select a field in the form widget to configure.\": \"Vyberte pole ve widgetu formuláře, které chcete nakonfigurovat.\",\n    \"Field rules\": \"Pravidla pole\",\n    \"fields_other\": \"Pole\",\n    \"series_one\": \"Série\",\n    \"DATA TABLE\": \"TABULKA DAT\",\n    \"columns_one\": \"Sloupec\",\n    \"Change widget\": \"Změnit widget\",\n    \"DATA TABLE NAME\": \"NÁZEV DATOVÉ TABULKY\",\n    \"COLUMN TYPE\": \"TYP SLOUPCE\",\n    \"Row style\": \"Styl řádku\",\n    \"Save\": \"Uložit\",\n    \"Select widget\": \"Vybrat Widget\",\n    \"series_other\": \"Série\",\n    \"TRANSFORM\": \"TRANSFORMOVAT\",\n    \"You do not have edit access to this document\": \"Nemáte přístup k úpravám tohoto dokumentu\",\n    \"Add referenced columns\": \"Přidat referenční sloupce\",\n    \"Redirect automatically after submission\": \"Automaticky přesměrovat po odeslání\",\n    \"Submit another response\": \"Odeslat další odpověď\",\n    \"Submit button label\": \"Štítek tlačítka Odeslat\",\n    \"Success text\": \"Text úspěchu\",\n    \"Data\": \"Data\",\n    \"Redirection\": \"Přesměrování\",\n    \"Table column name\": \"Název sloupce tabulky\",\n    \"Sort & filter\": \"Třídit & filtrovat\",\n    \"CHART TYPE\": \"TYP GRAFU\",\n    \"Display button\": \"Zobrazit tlačítko\",\n    \"Enter text\": \"Vepsat text\",\n    \"Reset form\": \"Obnovit formulář\",\n    \"Hidden field\": \"Skryté pole\",\n    \"Enter redirect URL\": \"Zadejte adresu URL přesměrování\",\n    \"No field selected\": \"Není vybráno žádné pole\",\n    \"Field title\": \"Název pole\",\n    \"columns_other\": \"Sloupce\",\n    \"SELECTOR FOR\": \"SELEKTOR PRO\",\n    \"SOURCE DATA\": \"ZDROJ DAT\",\n    \"SELECT BY\": \"VYBRAT PODLE\"\n  },\n  \"Tools\": {\n    \"Delete document tour?\": \"Smazat prohlídku dokumentu?\",\n    \"TOOLS\": \"NÁSTROJE\",\n    \"Delete\": \"Smazat\",\n    \"Tour of this Document\": \"Prohlídka tohoto dokumentu\",\n    \"Code view\": \"Zobrazit kód\",\n    \"Document history\": \"Historie dokumentu\",\n    \"Return to viewing as yourself\": \"Vrátit se k zobrazení sám za sebe\",\n    \"API console\": \"API konzole\",\n    \"Validate Data\": \"Ověření dat\",\n    \"Settings\": \"Nastavení\",\n    \"Access Rules\": \"Pravidla přístupu\",\n    \"Raw data\": \"Surová data\",\n    \"How-to Tutorial\": \"Návod, jak na to\"\n  },\n  \"DocPageModel\": {\n    \"Add empty table\": \"Přidat prázdnou tabulku\",\n    \"Add page\": \"Přidat stránku\",\n    \"Add widget to page\": \"Přidat widget na stránku\",\n    \"Error accessing document\": \"Chyba při přístupu k dokumentu\",\n    \"Reload\": \"Načíst znovu\",\n    \"Sorry, access to this document has been denied. [{{error}}]\": \"Omlouváme se, přístup k tomuto dokumentu byl odepřen. [{{error}}]\",\n    \"You do not have edit access to this document\": \"Nemáte přístup k úpravám tohoto dokumentu\",\n    \"Enter recovery mode\": \"Vstoupit do režimu obnovy\",\n    \"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 zkusit dokument znovu načíst nebo použít režim obnovy. Režim obnovy otevře dokument tak, že bude plně přístupný vlastníkům a nepřístupný ostatním. Také deaktivuje vzorce. [{{error}}]\",\n    \"Document owners can attempt to recover the document. [{{error}}]\": \"Vlastníci dokumentu se mohou pokusit dokument obnovit. [{{error}}]\"\n  },\n  \"ExampleInfo\": {\n    \"Welcome to the Investment Research template\": \"Vítejte v šabloně Investiční výzkum\",\n    \"Welcome to the Lightweight CRM template\": \"Vítejte v šabloně Lightweight CRM\",\n    \"Welcome to the Afterschool Program template\": \"Vítejte v šabloně Mimoškolního programu\",\n    \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Podívejte se na náš související návod, jak propojit data a vytvořit rozložení pro vysokou produktivitu.\",\n    \"Lightweight CRM\": \"Odlehčený systém CRM\",\n    \"Investment Research\": \"Investiční výzkum\",\n    \"Tutorial: Analyze & Visualize\": \"Tutoriál: Analýza a vizualizace\",\n    \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Podívejte se na náš související návod, jak modelovat obchodní data, používat vzorce a řídit složitost.\",\n    \"Tutorial: Create a CRM\": \"Výukový program: Vytvoření CRM\",\n    \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Podívejte se na náš související návod, jak vytvořit souhrnné tabulky a grafy a jak dynamicky propojit grafy.\",\n    \"Tutorial: Manage Business Data\": \"Tutoriál: Správa obchodních dat\",\n    \"Afterschool Program\": \"Mimoškolní program\"\n  },\n  \"GristDoc\": {\n    \"go to webhook settings\": \"přejít do nastavení webhooku\",\n    \"Added new linked section to view {{viewName}}\": \"Přidáno nové propojené zobrazení {{viewName}}\",\n    \"Saved linked section {{title}} in view {{name}}\": \"Uložena propojená sekce {{title}} v zobrazení {{name}}\",\n    \"Import from file\": \"Importovat ze souboru\"\n  },\n  \"MakeCopyMenu\": {\n    \"Original Has Modifications\": \"Původní má úpravy\",\n    \"As template\": \"Jako šablona\",\n    \"Include the structure without any of the data.\": \"Zahrnout strukturu bez jakýchkoli dat.\",\n    \"No destination workspace\": \"Žádný cílový pracovní prostor\",\n    \"Organization\": \"Organizace\",\n    \"Sign up\": \"Zaregistrovat se\",\n    \"Update Original\": \"Aktualizovat původní\",\n    \"Workspace\": \"Pracovní prostor\",\n    \"Download document and history\": \"Stáhnout celý dokument a historii\",\n    \"Name\": \"Název\",\n    \"Update\": \"Aktualizovat\",\n    \"Original Looks Identical\": \"Původní vzhled je totožný\",\n    \"Cancel\": \"Zrušit\",\n    \"Download document\": \"Stáhnout dokument\",\n    \"Download\": \"Stáhnout\",\n    \"You do not have write access to this site\": \"Nemáte přístup k zápisu této stránky\",\n    \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Buďte opatrní, původní dokument obsahuje změny, které nejsou v tomto dokumentu. Tyto změny budou přepsány.\",\n    \"However, it appears to be already identical.\": \"Zdá se však, že je již identický.\",\n    \"It will be overwritten, losing any content not in this document.\": \"Bude přepsán, přičemž dojde ke ztrátě jakéhokoli obsahu, který není v tomto dokumentu.\",\n    \"Overwrite\": \"Přepsat\",\n    \"The original version of this document will be updated.\": \"Původní verze tohoto dokumentu bude aktualizována.\",\n    \"Download document structure only (no data, for template use)\": \"Odstranit všechna data, ale zachovat strukturu pro použití jako šablonu\",\n    \"You do not have write access to the selected workspace\": \"Nemáte oprávnění pro zápis do vybraného pracovního prostoru\",\n    \"Download document without history (can significantly reduce file size)\": \"Odstranit historii dokumentu (může výrazně zmenšit velikost souboru)\",\n    \"Enter document name\": \"Zadejte název dokumentu\",\n    \"Original Looks Unrelated\": \"Původní se zdá být nesouvisející\",\n    \"Replacing the original requires editing rights on the original document.\": \"Nahrazení původního vyžaduje oprávnění pro úpravy původního dokumentu.\",\n    \"To save your changes, please sign up, then reload this page.\": \"Pro uložení vašich změn se prosím přihlaste a poté znovu načtěte tuto stránku.\"\n  },\n  \"NotifyUI\": {\n    \"Notifications\": \"Oznámení\",\n    \"Give feedback\": \"Poskytněte zpětnou vazbu\",\n    \"Report a problem\": \"Nahlásit problém\",\n    \"No notifications\": \"Žádná oznámení\",\n    \"Renew\": \"Obnovit\",\n    \"Upgrade Plan\": \"Upgraduj svůj Plán\",\n    \"Manage billing\": \"Správa fakturace\",\n    \"Ask for help\": \"Požádat o pomoc\",\n    \"Cannot find personal site, sorry!\": \"Nelze najít osobní stránky, omlouvám se!\",\n    \"Go to your free personal site\": \"Přejděte na své bezplatné osobní stránky\"\n  },\n  \"RecordLayout\": {\n    \"Updating record layout.\": \"Aktualizace rozložení záznamu.\"\n  },\n  \"Drafts\": {\n    \"Restore last edit\": \"Obnovit poslední úpravu\",\n    \"Undo discard\": \"Zrušit zahození\"\n  },\n  \"TriggerFormulas\": {\n    \"Cancel\": \"Zrušit\",\n    \"Close\": \"Zavřít\",\n    \"Apply on record changes\": \"Použít na změny záznamu\",\n    \"Apply to new records\": \"Použít na nové záznamy\",\n    \"Current field \": \"Aktuální pole \",\n    \"Any field\": \"Jakékoli pole\",\n    \"Apply on changes to:\": \"Použít na změny v:\",\n    \"OK\": \"OK\"\n  },\n  \"WelcomeSitePicker\": {\n    \"You have access to the following Grist sites.\": \"Máte přístup k následujícím stránkám Grist.\",\n    \"Welcome back\": \"Vítejte zpět\",\n    \"You can always switch sites using the account menu.\": \"Můžete vždy přepnout mezi stránkami pomocí nabídky účtu.\"\n  },\n  \"DuplicateTable\": {\n    \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Místo duplikování tabulek je obvykle lepší segmentovat data pomocí propojených zobrazení. {{link}}\",\n    \"Name for new table\": \"Název pro novou tabulku\",\n    \"Copy all data in addition to the table structure.\": \"Zkopírovat všechna data kromě struktury tabulky.\",\n    \"Only the document default access rules will apply to the copy.\": \"Na kopii se budou vztahovat pouze výchozí pravidla přístupu dokumentu.\"\n  },\n  \"GridOptions\": {\n    \"Horizontal gridlines\": \"Vodorovné mřížky\",\n    \"Grid Options\": \"Možnosti mřížky\",\n    \"Zebra stripes\": \"Pruhované řádky\",\n    \"Vertical gridlines\": \"Svislé mřížky\"\n  },\n  \"ShareMenu\": {\n    \"Compare to {{termToUse}}\": \"Porovnat s {{termToUse}}\",\n    \"Download\": \"Stáhnout\",\n    \"Save copy\": \"Uložit kopii\",\n    \"Save Document\": \"Uložit dokument\",\n    \"Access Details\": \"Podrobnosti o přístupu\",\n    \"Edit without affecting the original\": \"Upravit bez ovlivnění originálu\",\n    \"Export CSV\": \"Export do CSV\",\n    \"Export XLSX\": \"Export do XLSX\",\n    \"Manage users\": \"Spravovat uživatele\",\n    \"Original\": \"Originál\",\n    \"Show in folder\": \"Zobrazit ve složce\",\n    \"Back to current\": \"Zpět na aktuální\",\n    \"Return to {{termToUse}}\": \"Návrat na {{termToUse}}\",\n    \"Unsaved\": \"Neuloženo\",\n    \"Comma Separated Values (.csv)\": \"Hodnoty oddělené čárkou (.csv)\",\n    \"DOO Separated Values (.dsv)\": \"Hodnoty oddělené DOO (.dsv)\",\n    \"Export as...\": \"Exportovat jako...\",\n    \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n    \"Tab Separated Values (.tsv)\": \"Hodnoty oddělené tabulátory (.tsv)\",\n    \"Duplicate document\": \"Duplicitní dokument\",\n    \"Replace {{termToUse}}...\": \"Nahradit {{termToUse}}…\",\n    \"Current Version\": \"Aktuální verze\",\n    \"Send to Google Drive\": \"Odeslat na Disk Google\",\n    \"Work on a copy\": \"Pracovat na kopii\",\n    \"Share\": \"Sdílet\",\n    \"Download...\": \"Stáhnout...\"\n  },\n  \"SortFilterConfig\": {\n    \"Save\": \"Uložit\",\n    \"Sort\": \"ŘADIT\",\n    \"Filter\": \"FILTR\",\n    \"Update Sort & Filter settings\": \"Aktualizovat nastavení řazení a filtru\",\n    \"Revert\": \"Vrátit\"\n  },\n  \"TypeTransformation\": {\n    \"Apply\": \"Použít\",\n    \"Preview\": \"Náhled\",\n    \"Cancel\": \"Zrušit\",\n    \"Update formula (Shift+Enter)\": \"Aktualizovat vzorec (Shift+Enter)\",\n    \"Revise\": \"Revize\"\n  },\n  \"WelcomeTour\": {\n    \"convert to card view, select data, and more.\": \"Převést na zobrazení karet, vybrat data a další.\",\n    \"Double-click or hit {{enter}} on a cell to edit it. \": \"Dvojklikněte nebo stiskněte {{enter}} na buňce pro její úpravu. \",\n    \"Help Center\": \"Centrum podpory\",\n    \"Make it relational! Use the {{ref}} type to link tables. \": \"Udělejte to relačně! Použijte typ {{ref}} pro propojení tabulek. \",\n    \"Sharing\": \"Sdílení\",\n    \"Welcome to Grist!\": \"Vítejte na Grist!\",\n    \"template library\": \"knihovna šablon\",\n    \"Building up\": \"Budování\",\n    \"Toggle the {{creatorPanel}} to format columns, \": \"Přepněte {{creatorPanel}} pro formátování sloupců, \",\n    \"Flying higher\": \"Letíme výš\",\n    \"Reference\": \"Reference\",\n    \"Start with {{equal}} to enter a formula.\": \"Začněte s {{equal}} pro zadání vzorce.\",\n    \"Add new\": \"Přidat nový\",\n    \"Configuring your document\": \"Konfigurace vašeho dokumentu\",\n    \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Použijte {{addNew}} pro přidání widgetů, stránek nebo import více dat. \",\n    \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Nastavte možnosti formátování, vzorce nebo typy sloupců, jako jsou data, volby nebo přílohy. \",\n    \"Customizing columns\": \"Přizpůsobení sloupců\",\n    \"Use {{helpCenter}} for documentation or questions.\": \"Použijte {{helpCenter}} pro dokumentaci nebo dotazy.\",\n    \"Use the Share button ({{share}}) to share the document or export data.\": \"Použijte tlačítko Sdílet ({{share}}) pro sdílení dokumentu nebo export dat.\",\n    \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Prozkoumejte naši {{templateLibrary}}, abyste zjistili, co je možné, a získali inspiraci.\",\n    \"Enter\": \"Enter\",\n    \"Editing Data\": \"Úprava dat\",\n    \"Share\": \"Sdílet\",\n    \"creator panel\": \"panel tvůrce\"\n  },\n  \"OnBoardingPopups\": {\n    \"Next\": \"Další\",\n    \"Previous\": \"předchozí\",\n    \"Finish\": \"Dokončit\"\n  },\n  \"OpenVideoTour\": {\n    \"Grist Video Tour\": \"Videoprohlídka Grist\",\n    \"Video Tour\": \"Videoprohlídka\",\n    \"YouTube video player\": \"Přehrávač videí YouTube\"\n  },\n  \"RefSelect\": {\n    \"No columns to add\": \"Žádné sloupce k přidání\",\n    \"Add column\": \"Přidat sloupec\"\n  },\n  \"SortConfig\": {\n    \"Update data\": \"Aktualizovat data\",\n    \"Search Columns\": \"Prohledat sloupce\",\n    \"Empty values last\": \"Prázdné poslední hodnoty\",\n    \"Add column\": \"Přidat sloupec\",\n    \"Natural sort\": \"Přirozené řazení\",\n    \"Use choice position\": \"Použít pozici výběru\"\n  },\n  \"TopBar\": {\n    \"Manage team\": \"Spravovat tým\"\n  },\n  \"DocTour\": {\n    \"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.\": \"Nelze vytvořit prohlídku dokumentu z dat v tomto dokumentu. Ujistěte se, že existuje tabulka s názvem GristDocTour a sloupci Title, Body, Placement a Location.\",\n    \"No valid document tour\": \"Žádná platná prohlídka dokumentu\"\n  },\n  \"errorPages\": {\n    \"Failed to log in.{{separator}}Please try again or contact support.\": \"Přihlášení se nezdařilo.{{separator}}Zkuste to znovu nebo kontaktujte podporu.\",\n    \"An unknown error occurred.\": \"Došlo k neznámé chybě.\",\n    \"There was an error: {{message}}\": \"Došlo k chybě: {{message}}\",\n    \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Jste přihlášeni jako {{email}}. Můžete se přihlásit pomocí jiného účtu nebo požádat správce o přístup.\",\n    \"You do not have access to this organization's documents.\": \"K dokumentům této organizace nemáte přístup.\",\n    \"Form not found\": \"Formulář nebyl nalezen\",\n    \"Powered by\": \"Powered by\",\n    \"Page not found{{suffix}}\": \"Stránka nebyla nalezena{{suffix}}\",\n    \"You are now signed out.\": \"Nyní jste odhlášeni.\",\n    \"Account deleted{{suffix}}\": \"Účet smazán{{suffix}}\",\n    \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Požadovanou stránku se nepodařilo najít.{{separator}}Zkontrolujte prosím adresu URL a zkuste to znovu.\",\n    \"Contact support\": \"Kontaktovat podporu\",\n    \"Sign up\": \"Zaregistrovat se\",\n    \"Build your own form\": \"Vytvořte si vlastní formulář\",\n    \"Sign-in failed{{suffix}}\": \"Přihlášení se nezdařilo{{suffix}}\",\n    \"Access denied{{suffix}}\": \"Přístup odepřen{{suffix}}\",\n    \"Error{{suffix}}\": \"Chyba{{suffix}}\",\n    \"Go to main page\": \"Přejít na hlavní stránku\",\n    \"Your account has been deleted.\": \"Váš účet byl smazán.\",\n    \"Sign in\": \"Přihlásit se\",\n    \"Sign in again\": \"Znovu se přihlaste\",\n    \"Sign in to access this organization's documents.\": \"Pro přístup k dokumentům této organizace se přihlaste.\",\n    \"Signed out{{suffix}}\": \"Odhlášen{{suffix}}\",\n    \"Something went wrong\": \"Něco se pokazilo\",\n    \"Add account\": \"Přidat účet\",\n    \"There was an unknown error.\": \"Došlo k neznámé chybě.\"\n  },\n  \"GristTooltips\": {\n    \"You can filter by more than one column.\": \"Můžete filtrovat podle více než jednoho sloupce.\",\n    \"Apply conditional formatting to rows based on formulas.\": \"Použít podmíněné formátování na řádky na základě vzorců.\",\n    \"Select the table containing the data to show.\": \"Vyberte tabulku obsahující data, která chcete zobrazit.\",\n    \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Můžete si vybrat z widgetů, které jsou vám k dispozici v rozevíracím seznamu, nebo vložit svůj vlastní poskytnutím jeho úplné URL.\",\n    \"Selecting Data\": \"Výběr dat\",\n    \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID je náhodně generovaný řetězec, který je užitečný pro unikátní identifikátory a klíče odkazů.\",\n    \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Vytvářejte jednoduché formuláře přímo v Grist a sdílejte je jedním kliknutím s naším novým widgetem. {{learnMoreButton}}\",\n    \"To configure your calendar, select columns for start\": {\n      \"end dates and event titles. Note each column's type.\": \"Pro konfiguraci vašeho kalendáře vyberte sloupce pro začáteční a koncové datum a názvy událostí. Poznamenejte si typ každého sloupce.\"\n    },\n    \"Editing Card Layout\": \"Úprava rozvržení karty\",\n    \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Použijte ikonu 𝚺 pro vytvoření souhrnných (nebo kontingenčních) tabulek, pro součty nebo mezisoučty.\",\n    \"Pinning Filters\": \"Připnutí filtrů\",\n    \"They allow for one record to point (or refer) to another.\": \"Umožňují jednomu záznamu odkazovat (nebo ukazovat) na jiný.\",\n    \"entire\": \"celý\",\n    \"Calendar\": \"Kalendář\",\n    \"Forms are here!\": \"Formuláře jsou zde!\",\n    \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Použijte ikonu \\\\u{1D6BA} pro vytvoření souhrnných (nebo kontingenčních) tabulek, pro součty nebo mezisoučty.\",\n    \"Add new\": \"Přidat nový\",\n    \"Anchor Links\": \"Odkazy na kotvy\",\n    \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Toto omezení nastává, když má jeden sloupec v obousměrné referenci typ Reference.\",\n    \"Nested Filtering\": \"Vnořené filtrování\",\n    \"Only those rows will appear which match all of the filters.\": \"Zobrazí se pouze ty řádky, které odpovídají všem filtrům.\",\n    \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Nemůžete najít správné sloupce? Klikněte na „Změnit widget“ a vyberte tabulku s daty o událostech.\",\n    \"Custom Widgets\": \"Vlastní widgety\",\n    \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Pro umožnění více přiřazení změňte typ sloupce Reference na Reference List.\",\n    \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Toto omezení nastává, když je jeden konec obousměrné reference nakonfigurován jako jednotlivá reference.\",\n    \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Použijte podmíněné formátování pro buňky v tomto sloupci, když jsou splněny podmínky vzorce.\",\n    \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Buňky ve sloupci reference vždy identifikují {{entire}} záznam v té tabulce, ale můžete vybrat, který sloupec z tohoto záznamu se má zobrazit.\",\n    \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Klikněte na „Otevřít styly řádků“ pro aplikování podmíněného formátování na řádky.\",\n    \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Vzorce podporují mnoho funkcí Excelu, plnou syntaxi Pythonu a zahrnují užitečného asistenta AI.\",\n    \"Click the Add new button to create new documents or workspaces, or import data.\": \"Klikněte na tlačítko Přidat nový pro vytvoření nových dokumentů nebo pracovních prostorů, nebo import dat.\",\n    \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Užitečné pro ukládání časového razítka nebo autora nového záznamu, čištění dat a další.\",\n    \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Vzorce, které se spustí v určitých případech, a uloží vypočítanou hodnotu jako data.\",\n    \"Link your new widget to an existing widget on this page.\": \"Propojte svůj nový widget s existujícím widgetem na této stránce.\",\n    \"Linking Widgets\": \"Propojení widgetů\",\n    \"Rearrange the fields in your card by dragging and resizing cells.\": \"Přeuspořádejte pole ve své kartě tažením a změnou velikosti buněk.\",\n    \"Community widgets are created and maintained by Grist community members.\": \"Community widgety jsou vytvářeny a udržovány členy komunity Grist.\",\n    \"Creates a reverse column in target table that can be edited from either end.\": \"Vytvoří zpětný sloupec v cílové tabulce, který lze upravovat z obou stran.\",\n    \"Reference Columns\": \"Referenční sloupce\",\n    \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Můžete si vybrat jeden z našich předpřipravených widgetů nebo vložit svůj vlastní poskytnutím jeho úplné URL.\",\n    \"Reference columns are the key to {{relational}} data in Grist.\": \"Referenční sloupce jsou klíčem k {{relational}} datům v aplikaci.\",\n    \"Select the table to link to.\": \"Vyberte tabulku, na kterou chcete odkázat.\",\n    \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Stránka s neopracovanými daty zobrazuje všechny datové tabulky ve vašem dokumentu, včetně souhrnných tabulek a tabulek, které nejsou zahrnuty v rozvrženích stránek.\",\n    \"The total size of all data in this document, excluding attachments.\": \"Celková velikost všech dat v tomto dokumentu, kromě příloh.\",\n    \"Raw Data page\": \"Stránka s neopracovanými daty\",\n    \"Updates every 5 minutes.\": \"Aktualizuje se každých 5 minut.\",\n    \"Lookups return data from related tables.\": \"Vyhledávání vrací data z příbuzných tabulek.\",\n    \"Pinned filters are displayed as buttons above the widget.\": \"Připnuté filtry jsou zobrazeny jako tlačítka nad widgetem.\",\n    \"Example: {{example}}\": \"Příklad: {{example}}\",\n    \"Filter displayed dropdown values with a condition.\": \"Filtrujte zobrazené hodnoty v rozevíracím seznamu podle podmínky.\",\n    \"Learn more\": \"Zjistěte více\",\n    \"These rules are applied after all column rules have been processed, if applicable.\": \"Tato pravidla jsou aplikována po zpracování všech pravidel sloupců, pokud je to relevantní.\",\n    \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Kliknutím na {{EyeHideIcon}} v každé buňce skryjete pole z tohoto zobrazení, aniž byste jej smazali.\",\n    \"Learn more.\": \"Další informace.\",\n    \"This is the secret to Grist's dynamic and productive layouts.\": \"To je tajemství dynamických a produktivních rozvržení v Grist.\",\n    \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Vyzkoušejte změny v kopii a poté se rozhodněte, zda nahradíte originál vašimi úpravami.\",\n    \"Unpin to hide the the button while keeping the filter.\": \"Odepnout, aby se tlačítko skrylo, ale filtr zůstal.\",\n    \"relational\": \"relační\",\n    \"Access Rules\": \"Pravidla přístupu\",\n    \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Pravidla přístupu vám dávají možnost vytvářet jemně laděná pravidla, která určují, kdo může vidět nebo upravovat které části vašeho dokumentu.\",\n    \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Pro vytvoření odkazu na kotvu, který přenese uživatele na konkrétní buňku, klikněte na řádek a stiskněte {{shortcut}}.\",\n    \"Use reference columns to relate data in different tables.\": \"Použijte reference sloupce k propojení dat v různých tabulkách.\",\n    \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Pro umožnění více přiřazení změňte typ referencovaného sloupce na Reference List.\",\n    \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Obousměrné odkazy nejsou v současné době podporovány pro sloupce vzorců a spouštěcích vzorců\",\n    \"The preview below this header shows how the selected user will see this document\": \"Náhled pod tímto záhlavím ukazuje, jak vybraný uživatel uvidí tento dokument\"\n  },\n  \"OnboardingPage\": {\n    \"What brings you to Grist (you can select multiple)?\": \"Co vás přivedlo k Grist (můžete vybrat více možností)?\",\n    \"Discover Grist in 3 minutes\": \"Objevte Grist ve 3 minutách\",\n    \"Skip tutorial\": \"Přeskočit výukový program\",\n    \"Welcome\": \"Vítejte\",\n    \"What is your role?\": \"Jaká je vaše role?\",\n    \"Back\": \"Zpět\",\n    \"Next step\": \"Další krok\",\n    \"Go hands-on with the Grist Basics tutorial\": \"Vyzkoušejte si výukový program Grist Basics v praxi\",\n    \"Go to the tutorial!\": \"Přejděte na výukový program!\",\n    \"Skip step\": \"Přeskočit krok\",\n    \"Tell us who you are\": \"Řekněte nám něco o sobě\",\n    \"Type here\": \"Zadejte zde\",\n    \"What organization are you with?\": \"V jaké organizaci pracujete?\",\n    \"Your organization\": \"Vaše organizace\",\n    \"Your role\": \"Vaše role\"\n  },\n  \"SiteSwitcher\": {\n    \"Create new team site\": \"Vytvořit nové stránky týmu\",\n    \"Switch Sites\": \"Přepnout stránky\"\n  },\n  \"ThemeConfig\": {\n    \"Appearance \": \"Vzhled \",\n    \"Switch appearance automatically to match system\": \"Přepnout vzhled automaticky podle systému\"\n  },\n  \"AppModel\": {\n    \"This team site is suspended. Documents can be read, but not modified.\": \"Tato týmová stránka je pozastavena. Dokumenty lze číst, ale nelze je upravovat.\"\n  },\n  \"PluginScreen\": {\n    \"Import failed: \": \"Import se nezdařil: \"\n  },\n  \"CreateTeamModal\": {\n    \"Choose a name and url for your team site\": \"Vyberte název a URL pro svůj týmový web\",\n    \"Domain name is invalid\": \"Název domény je neplatný\",\n    \"Domain name is required\": \"Název domény je vyžadován\",\n    \"Team name is required\": \"Název týmu je povinný\",\n    \"Work as a Team\": \"Pracujte jako tým\",\n    \"Billing is not supported in grist-core\": \"Fakturace není v jádře grist podporována\",\n    \"Go to your site\": \"Přejděte na stránku\",\n    \"Team name\": \"Název týmu\",\n    \"Create site\": \"Vytvořit stránku\",\n    \"Team site created\": \"Týmový web vytvořen\",\n    \"Team url\": \"URL adresa týmu\",\n    \"Cancel\": \"Zrušit\"\n  },\n  \"LeftPanelCommon\": {\n    \"Help Center\": \"Centrum nápovědy\"\n  },\n  \"UserManagerModel\": {\n    \"Editor\": \"Editor\",\n    \"View only\": \"Pouze zobrazení\",\n    \"View & edit\": \"Zobrazit & upravovat\",\n    \"Owner\": \"Majitel\",\n    \"In full\": \"V plném znění\",\n    \"No Default Access\": \"Bez výchozího přístupu\",\n    \"None\": \"Žádné\",\n    \"Viewer\": \"Návštěvník\"\n  },\n  \"SelectionSummary\": {\n    \"Copied to clipboard\": \"Zkopírováno do schránky\"\n  },\n  \"WelcomeQuestions\": {\n    \"Welcome to Grist!\": \"Vítejte na Grist!\",\n    \"Other\": \"Ostatní\",\n    \"Sales\": \"Prodej\",\n    \"Finance & Accounting\": \"Finance a účetnictví\",\n    \"Education\": \"Vzdělávání\",\n    \"Media Production\": \"Mediální produkce\",\n    \"Marketing\": \"Marketing\",\n    \"Research\": \"Výzkum\",\n    \"What brings you to Grist? Please help us serve you better.\": \"Co vás přivedlo ke Grist? Pomozte nám, abychom vám mohli lépe sloužit.\",\n    \"Product Development\": \"Vývoj produktu\",\n    \"Type here\": \"Zadejte zde\",\n    \"HR & Management\": \"Lidské zdroje a řízení\",\n    \"IT & Technology\": \"IT a technologie\"\n  },\n  \"ConditionalStyle\": {\n    \"Row style\": \"Styl řádku\",\n    \"Add another rule\": \"Přidat další pravidlo\",\n    \"Add conditional style\": \"Přidat podmíněný styl\",\n    \"Error in style rule\": \"Chyba v pravidle stylu\",\n    \"Rule must return True or False\": \"Pravidlo musí vracet hodnotu Pravda nebo Nepravda\",\n    \"Conditional Style\": \"Podmíněný styl\",\n    \"IF...\": \"KDYŽ...\"\n  },\n  \"CurrencyPicker\": {\n    \"Invalid currency\": \"Neplatná měna\"\n  },\n  \"DiscussionEditor\": {\n    \"Reply\": \"Odpovědět\",\n    \"Reply to a comment\": \"Odpovědět na komentář\",\n    \"Show resolved comments\": \"Zobrazit vyřešené komentáře\",\n    \"Only current page\": \"Pouze aktuální stránka\",\n    \"Write a comment\": \"Napsat komentář\",\n    \"Comment\": \"Komentář\",\n    \"Edit\": \"Upravit\",\n    \"Marked as resolved\": \"Označit jako vyřešené\",\n    \"Open\": \"Otevřít\",\n    \"Cancel\": \"Zrušit\",\n    \"Only my threads\": \"Pouze moje vlákna\",\n    \"Save\": \"Uložit\",\n    \"Remove\": \"Odstranit\",\n    \"Resolve\": \"Vyřešit\",\n    \"Started discussion\": \"Diskuse zahájena\",\n    \"Showing last {{nb}} comments\": \"Zobrazuje se posledních {{nb}} komentářů\"\n  },\n  \"FieldEditor\": {\n    \"Unable to finish saving edited cell\": \"Nelze dokončit uložení upravené buňky\",\n    \"It should be impossible to save a plain data value into a formula column\": \"Mělo by být nemožné uložit běžnou datovou hodnotu do sloupce s vzorcem\"\n  },\n  \"HyperLinkEditor\": {\n    \"[link label] url\": \"[označení odkazu] URL\"\n  },\n  \"WebhookPage\": {\n    \"Enabled\": \"Povoleno\",\n    \"Status\": \"Stav\",\n    \"Columns to check when update (separated by ;)\": \"Sloupce ke kontrole při aktualizaci (oddělené ; )\",\n    \"URL\": \"URL\",\n    \"Event Types\": \"Typy událostí\",\n    \"Table\": \"Tabulka\",\n    \"Sorry, not all fields can be edited.\": \"Omlouváme se, ne všechna pole lze upravit.\",\n    \"Removed webhook.\": \"Webhook odstraněn.\",\n    \"Ready Column\": \"Připravený sloupec\",\n    \"Name\": \"Název\",\n    \"Clear queue\": \"Vymazat frontu\",\n    \"Cleared webhook queue.\": \"Vymazána fronta webhooků.\",\n    \"Webhook settings\": \"Nastavení webhooku\",\n    \"Memo\": \"Memo\",\n    \"Webhook Id\": \"ID Webhooku\",\n    \"Filter for changes in these columns (semicolon-separated ids)\": \"Filtrujte změny v těchto sloupcích (id oddělená středníkem)\",\n    \"Header Authorization\": \"Hlavička autorizace\"\n  },\n  \"UserManager\": {\n    \"Invite multiple\": \"Pozvat další\",\n    \"{{collaborator}} limit exceeded\": \"{{collaborator}} překročil limit\",\n    \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Žádný výchozí přístup neumožňuje přístup k jednotlivým dokumentům nebo pracovním prostorům, místo k celému týmovému webu.\",\n    \"Your role for this team site\": \"Vaše úloha na tomto týmovém webu\",\n    \"Close\": \"Zavřít\",\n    \"Confirm\": \"Potvrdit\",\n    \"Guest\": \"Host\",\n    \"Invite people to {{resourceType}}\": \"Pozvat lidi do {{resourceType}}\",\n    \"On\": \"Zapnuto\",\n    \"Outside collaborator\": \"Externí spolupracovník\",\n    \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} z {{limitTop}} {{collaborator}}ů\",\n    \"Team member\": \"Člen týmu\",\n    \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Uživatel dědí oprávnění od {{rodiče})}. Chcete-li je odebrat, nastavte možnost 'Zdědit přístup' na hodnotu 'Žádný'.\",\n    \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Žádný výchozí přístup neumožňuje přístup k         jednotlivým dokumentům nebo pracovním prostorům, místo k celému týmovému webu.\",\n    \"Public access\": \"Veřejný přístup\",\n    \"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}}.\": \"Po odebrání vlastního přístupu jej nebudete moci získat zpět bez pomoci někoho jiného, kdo má dostatečný přístup k webu {{name}}.\",\n    \"Public access: \": \"Veřejný přístup: \",\n    \"Save & \": \"Uložit & \",\n    \"free collaborator\": \"volný spolupracovník\",\n    \"guest\": \"host\",\n    \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Uživatel dědí oprávnění z {{parent}}. Chcete-li odebrat, nastavte možnost „Dědit přístup“ na „Žádný“.\",\n    \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Veřejný přístup zděděný z {{parent}}. Chcete-li odebrat, nastavte možnost „Dědit přístup“ na „Žádný“.\",\n    \"Open Access Rules\": \"Pravidla otevřeného přístupu\",\n    \"You are about to remove your own access to this {{resourceType}}\": \"Chystáte se odstranit svůj vlastní přístup k tomuto {{resourceType}}\",\n    \"Create a team to share with more people\": \"Vytvořte tým pro sdílení s více lidmi\",\n    \"Grist support\": \"Podpora Grist\",\n    \"Link copied to clipboard\": \"Odkaz zkopírován do schránky\",\n    \"Manage members of team site\": \"Správa členů týmu\",\n    \"Off\": \"Vypnuto\",\n    \"Copy link\": \"Kopírovat odkaz\",\n    \"Allow anyone with the link to open.\": \"Povolit komukoli s odkazem otevřít.\",\n    \"Anyone with link \": \"Kdokoli s odkazem \",\n    \"Cancel\": \"Zrušit\",\n    \"Remove my access\": \"Odebrat můj přístup\",\n    \"member\": \"člen\",\n    \"team site\": \"Stránka Týmu\",\n    \"Add {{member}} to your team\": \"Přidejte {{member}} do svého týmu\",\n    \"User may not modify their own access.\": \"Uživatel nesmí upravovat svůj vlastní přístup.\",\n    \"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.\": \"Uživatel má přístup k zobrazení {{resource}}, který vyplývá z ručně nastaveného přístupu k vnitřním zdrojům. Pokud bude tento přístup odstraněn, uživatel ztratí přístup k vnitřním zdrojům.\",\n    \"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}}.\": \"Jakmile odstraníte svůj vlastní přístup, nebudete ho moci získat zpět bez pomoci někoho jiného s dostatečným přístupem k {{resourceType}}.\",\n    \"Collaborator\": \"Spolupracovník\",\n    \"Your role for this {{resourceType}}\": \"Vaše úloha v této oblasti {{resourceType}}\",\n    \"Inherit access: \": \"Zděděný přístup: \"\n  },\n  \"SupportGristNudge\": {\n    \"Close\": \"Zavřít\",\n    \"Contribute\": \"Přispět\",\n    \"Support Grist\": \"Podpoř Grist\",\n    \"Admin Panel\": \"Panel správce\",\n    \"Support Grist page\": \"Podpořte stránku Grist\",\n    \"Opt in to Telemetry\": \"Přihlášení k telemetrii\",\n    \"Opted In\": \"Opted In\",\n    \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Děkujeme! Vaše důvěra a podpora jsou velmi ceněny. Kdykoli se můžete odhlásit pomocí {{link}} v nabídce uživatele.\",\n    \"Help Center\": \"Centrum nápovědy\"\n  },\n  \"SupportGristPage\": {\n    \"Support Grist\": \"Podpoř Grist\",\n    \"Telemetry\": \"Telemetrie\",\n    \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Tato instance je odhlášena z telemetrie. Pouze správce webu má oprávnění to změnit.\",\n    \"GitHub\": \"GitHub\",\n    \"Manage Sponsorship\": \"Správa sponzorství\",\n    \"Opt out of Telemetry\": \"Odhlášení od telemetrie\",\n    \"You can opt out of telemetry at any time from this page.\": \"Z telemetrie se můžete odhlásit kdykoli z této stránky.\",\n    \"You have opted in to telemetry. Thank you!\": \"Přihlásili jste se k telemetrii. Děkujeme!\",\n    \"Sponsor\": \"Sponzor\",\n    \"Help Center\": \"Centrum nápovědy\",\n    \"Home\": \"Úvod\",\n    \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Shromažďujeme pouze statistiky používání, jak je podrobně uvedeno v našem {{link}}, nikdy ne obsah dokumentů.\",\n    \"Sponsor Grist Labs on GitHub\": \"Sponzor Grist Labs na GitHubu\",\n    \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Tato instance je přihlášena k telemetrii. Pouze správce webu má oprávnění to změnit.\",\n    \"Opt in to Telemetry\": \"Přihlášení k telemetrii\",\n    \"GitHub Sponsors page\": \"Stránka sponzorů GitHub\",\n    \"You have opted out of telemetry.\": \"Odhlásili jste se z telemetrie.\"\n  },\n  \"FloatingPopup\": {\n    \"Minimize\": \"Minimalizovat\",\n    \"Maximize\": \"Maximalizovat\"\n  },\n  \"MappedFieldsConfig\": {\n    \"Select all\": \"Vybrat vše\",\n    \"Unmapped\": \"Nepřiřazeno\",\n    \"Map fields\": \"Přiřadit pole\",\n    \"Mapped\": \"Přiřazeno\",\n    \"Clear\": \"Vyčistit\",\n    \"Unmap fields\": \"Zrušit přiřazení polí\"\n  },\n  \"Section\": {\n    \"Insert section above\": \"Vložit sekci nad tuto\",\n    \"Insert section below\": \"Vložit sekci pod tuto\",\n    \"Description\": \"Popis\",\n    \"## **Header**\": \"## **Záhlaví**\"\n  },\n  \"ViewLayoutMenu\": {\n    \"Copy anchor link\": \"Kopírovat odkaz kotvy\",\n    \"Edit card layout\": \"Úprava rozvržení karty\",\n    \"Download as CSV\": \"Stáhnout jako CSV\",\n    \"Delete record\": \"Odstranit záznam\",\n    \"Data selection\": \"Výběr dat\",\n    \"Add to page\": \"Přidat na stránku\",\n    \"Open configuration\": \"Otevřít konfiguraci\",\n    \"Advanced sort & filter\": \"Pokročilé třídění a filtrování\",\n    \"Download as XLSX\": \"Stáhnout jako XLSX\",\n    \"Widget options\": \"Možnosti widgetu\",\n    \"Create a form\": \"Vytvořit formulář\",\n    \"Delete widget\": \"Odstranit widget\",\n    \"Show raw data\": \"Zobrazit surová data\",\n    \"Collapse widget\": \"Sbalit widget\",\n    \"Print widget\": \"Tisknout widget\"\n  },\n  \"WidgetTitle\": {\n    \"Cancel\": \"Zrušit\",\n    \"DATA TABLE NAME\": \"NÁZEV DATOVÉ TABULKY\",\n    \"Save\": \"Uložit\",\n    \"WIDGET DESCRIPTION\": \"POPIS WIDGETU\",\n    \"Override widget title\": \"Přepsat název widgetu\",\n    \"Provide a table name\": \"Zadejte název tabulky\",\n    \"WIDGET TITLE\": \"NÁZEV WIDGETU\"\n  },\n  \"menus\": {\n    \"Select fields\": \"Vyberte pole\",\n    \"Any\": \"Jakékoli\",\n    \"Choice List\": \"Seznam možností\",\n    \"Reference\": \"Reference\",\n    \"Toggle\": \"Přepínač\",\n    \"Numeric\": \"Číselné\",\n    \"Search columns\": \"Prohledat sloupce\",\n    \"Reference List\": \"Seznam referencí\",\n    \"Attachment\": \"Příloha\",\n    \"Upgrade now\": \"Upgradujte nyní\",\n    \"Date\": \"Datum\",\n    \"DateTime\": \"Datum a čas\",\n    \"* Workspaces are available on team plans. \": \"* Pracovní prostory jsou k dispozici v týmových plánech. \",\n    \"Light\": \"Světlé\",\n    \"Integer\": \"Celé číslo\",\n    \"By Name\": \"Podle názvu\",\n    \"Custom\": \"Vlastní\",\n    \"Choice\": \"Výběr\",\n    \"Text\": \"Text\",\n    \"By Date Modified\": \"Podle data úpravy\"\n  },\n  \"modals\": {\n    \"Delete\": \"Smazat\",\n    \"Are you sure you want to delete this record?\": \"Opravdu chcete tento záznam odstranit?\",\n    \"Don't ask again.\": \"Neptat se znovu.\",\n    \"Don't show again.\": \"Znovu nezobrazovat.\",\n    \"Undo to restore\": \"Zpět pro obnovení\",\n    \"Don't show tips\": \"Nezobrazovat tipy\",\n    \"Cancel\": \"Zrušit\",\n    \"TIP\": \"TIP\",\n    \"Don't show again\": \"Znovu nezobrazovat\",\n    \"Are you sure you want to delete these records?\": \"Opravdu chcete tyto záznamy odstranit?\",\n    \"Got it\": \"Mám to\",\n    \"Ok\": \"OK\",\n    \"Save\": \"Uložit\",\n    \"Dismiss\": \"Zahodit\"\n  },\n  \"ChoiceTextBox\": {\n    \"CHOICES\": \"MOŽNOSTI\"\n  },\n  \"FieldBuilder\": {\n    \"Changing multiple column types\": \"Změna více typů sloupců\",\n    \"CELL FORMAT\": \"FORMÁT BUŇKY\",\n    \"Save field settings for {{colId}} as common\": \"Uložit nastavení pole pro {{colId}} jako běžné\",\n    \"DATA FROM TABLE\": \"DATA Z TABULKY\",\n    \"Apply formula to data\": \"Použít vzorec na data\",\n    \"Revert field settings for {{colId}} to common\": \"Vrátit nastavení pole pro {{colId}} na běžné\",\n    \"Use separate field settings for {{colId}}\": \"Použít oddělená nastavení pole pro {{colId}}\",\n    \"Mixed format\": \"Smíšený formát\",\n    \"Changing column type\": \"Změna typu sloupce\",\n    \"Mixed types\": \"Smíšené typy\"\n  },\n  \"FormView\": {\n    \"Unpublish your form?\": \"Zrušit zveřejnění formuláře?\",\n    \"Unpublish\": \"Zrušit zveřejnění\",\n    \"Save your document to publish this form.\": \"Uložte svůj dokument pro zveřejnění tohoto formuláře.\",\n    \"Publish\": \"Zveřejnit\",\n    \"Are you sure you want to reset your form?\": \"Jste si jisti, že chcete resetovat svůj formulář?\",\n    \"Copy code\": \"Kopírovat kód\",\n    \"Copy link\": \"Kopírovat odkaz\",\n    \"Embed this form\": \"Vložit tento formulář\",\n    \"Link copied to clipboard\": \"Odkaz zkopírován do schránky\",\n    \"Preview\": \"Náhled\",\n    \"Share\": \"Sdílet\",\n    \"View\": \"Zobrazit\",\n    \"Publish your form?\": \"Zveřejnit formulář?\",\n    \"Reset form\": \"Obnovit formulář\",\n    \"Share this form\": \"Sdílet tento formulář\",\n    \"Reset\": \"Resetuj\",\n    \"Code copied to clipboard\": \"Kód zkopírovaný do schránky\",\n    \"Anyone with the link below can see the empty form and submit a response.\": \"Kdokoli s níže uvedeným odkazem může zobrazit prázdný formulář a odeslat odpověď.\",\n    \"Your form description goes here.\": \"Sem patří popis vašeho formuláře.\",\n    \"# **Form Title**\": \"# **Název formuláře**\"\n  },\n  \"UnmappedFieldsConfig\": {\n    \"Mapped\": \"Přiřazeno\",\n    \"Unmapped\": \"Nepřiřazeno\",\n    \"Select all\": \"Vybrat vše\",\n    \"Unmap fields\": \"Zrušit přiřazení polí\",\n    \"Clear\": \"Vyčistit\",\n    \"Map fields\": \"Přiřadit pole\"\n  },\n  \"VisibleFieldsConfig\": {\n    \"Show {{label}}\": \"Zobrazit {{label}}\",\n    \"Cannot drop items into Hidden Fields\": \"Není možné přetahovat položky do skrytých polí\",\n    \"Hide {{label}}\": \"Skrýt {{label}}\",\n    \"Hidden Fields cannot be reordered\": \"Skrytá pole nelze přeřazovat\",\n    \"Clear\": \"Vyčistit\",\n    \"Hidden {{label}}\": \"Skryté {{label}}\",\n    \"Select all\": \"Vybrat vše\",\n    \"Visible {{label}}\": \"Viditelné {{label}}\"\n  },\n  \"ACLUsers\": {\n    \"Example Users\": \"Příklad uživatelů\",\n    \"View as\": \"Zobraz Jako\",\n    \"Users from table\": \"Uživatelé z tabulky\"\n  },\n  \"CellStyle\": {\n    \"Default header style\": \"Výchozí styl záhlaví\",\n    \"Mixed style\": \"Smíšený styl\",\n    \"Open row styles\": \"Otevřené styly řádku\",\n    \"HEADER STYLE\": \"STYL ZÁHLAVÍ\",\n    \"Cell style\": \"Styl buňky\",\n    \"Header Style\": \"Styl záhlaví\",\n    \"CELL STYLE\": \"STYL BUŇKY\",\n    \"Default cell style\": \"Výchozí styl buňky\"\n  },\n  \"ColumnInfo\": {\n    \"COLUMN LABEL\": \"ŠTÍTEK SLOUPCE\",\n    \"Cancel\": \"Zrušit\",\n    \"COLUMN DESCRIPTION\": \"POPIS SLOUPCE\",\n    \"COLUMN ID: \": \"ID SLOUPCE: \",\n    \"Save\": \"Uložit\"\n  },\n  \"FormulaEditor\": {\n    \"Expand Editor\": \"Rozšířit editor\",\n    \"use AI Assistant\": \"použít AI asistenta\",\n    \"Enter formula or {{button}}.\": \"Zadejte vzorec nebo {{button}}.\",\n    \"editingFormula is required\": \"Úprava vzorce je povinná\",\n    \"Enter formula.\": \"Vložit vzorec.\",\n    \"Column or field is required\": \"Sloupec nebo pole je povinné\",\n    \"Error in the cell\": \"Chyba v buňce\",\n    \"Errors in all {{numErrors}} cells\": \"Chyby ve všech {{numErrors}} buňkách\",\n    \"Errors in {{numErrors}} of {{numCells}} cells\": \"Chyby v {{numErrors}} z {{numCells}} buněk\"\n  },\n  \"ColumnTitle\": {\n    \"Add description\": \"Přidat popis\",\n    \"Column ID copied to clipboard\": \"ID sloupce zkopírováno do schránky\",\n    \"Column description\": \"Popis sloupce\",\n    \"Column label\": \"Štítek sloupce\",\n    \"Provide a column label\": \"Zadejte název sloupce\",\n    \"Save\": \"Uložit\",\n    \"Close\": \"Zavřít\",\n    \"COLUMN ID: \": \"ID SLOUPCE: \",\n    \"Cancel\": \"Zrušit\"\n  },\n  \"FormulaAssistant\": {\n    \"Function List\": \"Seznam funkcí\",\n    \"Clear conversation\": \"Vymazat konverzaci\",\n    \"Data\": \"Data\",\n    \"Cancel\": \"Zrušit\",\n    \"Formula Help. \": \"Nápověda k vzorci. \",\n    \"Save\": \"Uložit\",\n    \"You have {{numCredits}} remaining credits.\": \"Máte {{numCredits}} zbývajících kreditů.\",\n    \"Hi, I'm the Grist Formula AI Assistant.\": \"Ahoj, jsem AI asistent pro Grist vzorce.\",\n    \"Code view\": \"Zobrazit kód\",\n    \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Mohu pomoci pouze s vzorci. Nemohu vytvářet tabulky, sloupce a zobrazení ani psát pravidla přístupu.\",\n    \"Learn more\": \"Zjistěte více\",\n    \"For higher limits, contact the site owner.\": \"Pro vyšší limity se obraťte na majitele webu.\",\n    \"For higher limits, {{upgradeNudge}}.\": \"Pro vyšší limity {{upgradeNudge}}.\",\n    \"Grist's AI Formula Assistance. \": \"Pomoc s umělou inteligencí Grist's AI Formula. \",\n    \"What do you need help with?\": \"S čím potřebujete pomoci?\",\n    \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Podívejte se na naši {{helpFunction}} a {{formulaCheat}}, nebo navštivte naši {{community}} pro více pomoci.\",\n    \"Apply\": \"Použít\",\n    \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Zaregistrujte si bezplatný účet Grist a začněte používat asistenta Formula AI.\",\n    \"Ask the bot.\": \"Zeptejte se bota.\",\n    \"New Chat\": \"Nový chat\",\n    \"Preview\": \"Náhled\",\n    \"Regenerate\": \"Přegenerovat\",\n    \"Tips\": \"Tipy\",\n    \"There are some things you should know when working with me:\": \"Existuje několik věcí, které byste měli vědět, když se mnou pracujete:\",\n    \"Grist's AI Assistance\": \"Pomoc s umělou inteligencí společnosti Grist\",\n    \"Need help? Our AI assistant can help.\": \"Potřebujete pomoc? Náš AI asistent vám pomůže.\",\n    \"Formula AI Assistant is only available for logged in users.\": \"AI Asistent pro vzorce je k dispozici pouze pro přihlášené uživatele.\",\n    \"You have used all available credits.\": \"Využili jste všechny dostupné kredity.\",\n    \"upgrade your plan\": \"aktualizujte svůj plán\",\n    \"Capabilities\": \"Schopnosti\",\n    \"Community\": \"Komunita\",\n    \"Formula Cheat Sheet\": \"Přehled vzorců\",\n    \"AI Assistant\": \"AI Asistent\",\n    \"Press Enter to apply suggested formula.\": \"Stisknutím klávesy Enter použijete navržený vzorec.\",\n    \"Sign Up for Free\": \"Zaregistrujte se zdarma\",\n    \"upgrade to the Pro Team plan\": \"přejít na tarif Pro Team\"\n  },\n  \"SearchModel\": {\n    \"Search all tables\": \"Prohledat všechny tabulky\",\n    \"Search all pages\": \"Prohledat všechny stránky\"\n  },\n  \"AdminPanel\": {\n    \"Checking for updates...\": \"Kontrola aktualizací...\",\n    \"Error\": \"Chyba\",\n    \"Sandbox settings for data engine\": \"Nastavení Sandboxu pro datový engine\",\n    \"Sandboxing\": \"Sandboxing\",\n    \"OK\": \"OK\",\n    \"Updates\": \"Aktualizace\",\n    \"unconfigured\": \"nekonfigurované\",\n    \"Admin Panel\": \"Panel správce\",\n    \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"Nemáte přístup k panelu správce.\\nPřihlaste se jako správce.\",\n    \"New, Enterprise\": \"Nové, Enterprise\",\n    \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} více\",\n    \"Session Secret\": \"Tajný klíč relace (session secret)\",\n    \"Key to sign sessions with\": \"Klíč pro podepisování relací\",\n    \"Home\": \"Úvod\",\n    \"Sponsor\": \"Sponzor\",\n    \"Telemetry\": \"Telemetrie\",\n    \"Error checking for updates\": \"Chyba při kontrole aktualizací\",\n    \"Last checked {{time}}\": \"Poslední kontrola {{time}}\",\n    \"Grist is up to date\": \"Grist je aktuální\",\n    \"Learn more.\": \"Další informace.\",\n    \"Newer version available\": \"K dispozici je novější verze\",\n    \"unknown\": \"neznámý\",\n    \"Security Settings\": \"Nastavení zabezpečení\",\n    \"checking\": \"kontroluji\",\n    \"Administrator Panel Unavailable\": \"Panel správce není k dispozici\",\n    \"Log Streaming\": \"Streamování logů\",\n    \"Check failed.\": \"Kontrola se nezdařila.\",\n    \"Results\": \"Výsledky\",\n    \"Contact us\": \"Kontaktujte nás\",\n    \"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 velmi mocné vzorce s použitím Pythonu. Doporučujeme nastavit proměnnou prostředí GRIST_SANDBOX_FLAVOR na gvisor, pokud to váš hardware podporuje (většinou ano), aby vzorce v každém dokumentu běžely v pískovišti izolovaném od ostatních dokumentů a izolovaném od sítě.\",\n    \"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.\": \"Služba Grist umožňuje konfigurovat různé typy ověřování, včetně SAML a OIDC.     Pokud je systém Grist přístupný po síti nebo je zpřístupněn více osobám, doporučujeme jeden z nich povolit.\",\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 podepisuje cookies uživatelských relací tajným klíčem. Tento klíč nastavte pomocí proměnné prostředí GRIST_SESSION_SECRET. Pokud není nastaven, Grist použije přednastavený tvrdě zakódovaný výchozí klíč. Tuto poznámku můžeme v budoucnu odstranit, protože ID relací generované od verze v1.1.16 jsou inherentně kryptograficky bezpečné.\",\n    \"Enable Grist Enterprise\": \"Povolení Grist Enterprise\",\n    \"Audit Logs\": \"Auditní záznamy\",\n    \"Off\": \"Vypnuto\",\n    \"Current\": \"Aktuální\",\n    \"Current version of Grist\": \"Aktuální verze Grist\",\n    \"Help us make Grist better\": \"Pomozte nám vylepšit Grist\",\n    \"Support Grist\": \"Podpoř Grist\",\n    \"Support Grist Labs on GitHub\": \"Podpořte Grist Labs na GitHubu\",\n    \"Version\": \"Verze\",\n    \"Auto-check when this page loads\": \"Automatická kontrola při načtení této stránky\",\n    \"Check now\": \"Zkontrolovat nyní\",\n    \"Grist releases are at \": \"Vydání Grist jsou na \",\n    \"No information available\": \"Nejsou k dispozici žádné informace\",\n    \"Authentication\": \"Autentizace\",\n    \"Current authentication method\": \"Aktuální metoda ověřování\",\n    \"Check succeeded.\": \"Kontrola byla úspěšná.\",\n    \"Details\": \"Podrobnosti\",\n    \"No fault detected.\": \"Žádná chyba nebyla zjištěna.\",\n    \"Notes\": \"Poznámky\",\n    \"Self Checks\": \"Vlastní kontroly\",\n    \"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.\": \"Služba Grist umožňuje konfigurovat různé typy ověřování, včetně SAML a OIDC. Pokud je systém Grist přístupný po síti nebo je zpřístupněn více osobám, doporučujeme jeden z nich povolit.\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"Grist podepisuje soubory cookie relace uživatele tajným klíčem. Tento klíč nastavte pomocí proměnné prostředí GRIST_SESSION_SECRET. Pokud není nastavena, Grist se vrátí k pevně nastavenému výchozímu klíči. Toto upozornění můžeme v budoucnu odstranit, protože identifikátory relací generované od verze 1.1.16 jsou ze své podstaty kryptograficky bezpečné.\",\n    \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Nebo můžete jako náhradní řešení nastavit: {{bootKey}} v prostředí a navštívit jej: {{url}}\",\n    \"Enterprise\": \"Enterprise\",\n    \"On\": \"Zapnuto\"\n  },\n  \"DropdownConditionConfig\": {\n    \"Set dropdown condition\": \"Nastavení rozbalovací podmínky\",\n    \"Invalid columns: {{colIds}}\": \"Neplatné sloupce: {{colIds}}\",\n    \"Dropdown Condition\": \"Podmínka pro rozevírací seznam\"\n  },\n  \"DropdownConditionEditor\": {\n    \"Enter condition.\": \"Zadejte podmínku.\"\n  },\n  \"duplicatePage\": {\n    \"Duplicate page {{pageName}}\": \"Duplicitní stránka {{pageName}}\",\n    \"Note that this does not copy data, but creates another view of the same data.\": \"Všimněte si, že se nejedná o kopírování dat, ale o vytvoření jiného zobrazení stejných dat.\"\n  },\n  \"breadcrumbs\": {\n    \"unsaved\": \"neuložené\",\n    \"fiddle\": \"Pokusný režim\",\n    \"override\": \"přepsat\",\n    \"recovery mode\": \"režim obnovy\",\n    \"snapshot\": \"snímek\",\n    \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Úpravy můžete provádět, ale vytvoří se nová kopie a\\nneovlivní původní dokument.\"\n  },\n  \"search\": {\n    \"Find Previous \": \"Najít Předchozí \",\n    \"Find Next \": \"Najít další \",\n    \"Search\": \"Hledat\",\n    \"Search in document\": \"Hledat v dokumentu\",\n    \"No results\": \"Žádné výsledky\"\n  },\n  \"NTextBox\": {\n    \"false\": \"nepravda\",\n    \"Field Format\": \"Formát pole\",\n    \"true\": \"pravda\",\n    \"Single line\": \"Jedno řádkový\",\n    \"Lines\": \"Řádků\",\n    \"Multi line\": \"Více řádkový\"\n  },\n  \"TypeTransform\": {\n    \"Apply\": \"Použít\",\n    \"Update formula (Shift+Enter)\": \"Aktualizovat vzorec (Shift+Enter)\",\n    \"Cancel\": \"Zrušit\",\n    \"Preview\": \"Náhled\",\n    \"Revise\": \"Revize\"\n  },\n  \"NumericTextBox\": {\n    \"max\": \"max\",\n    \"Field Format\": \"Formát pole\",\n    \"Text\": \"Text\",\n    \"min\": \"min\",\n    \"Spinner\": \"Spinner\",\n    \"Currency\": \"Měna\",\n    \"Default currency ({{defaultCurrency}})\": \"Výchozí měna ({{defaultCurrency}})\",\n    \"Number Format\": \"Formát čísla\",\n    \"Decimals\": \"Desetinná čísla\"\n  },\n  \"PagePanels\": {\n    \"Open creator panel\": \"Otevřít panel tvůrce\",\n    \"Close Creator Panel\": \"Zavřít panel tvůrce\"\n  },\n  \"CardContextMenu\": {\n    \"Duplicate card\": \"Duplikovat kartu\",\n    \"Insert card above\": \"Vložte kartu výše\",\n    \"Insert card below\": \"Vložte kartu níže\",\n    \"Copy anchor link\": \"Kopírovat odkaz kotvy\",\n    \"Delete card\": \"Smazat kartu\",\n    \"Insert card\": \"Vložit kartu\"\n  },\n  \"FormConfig\": {\n    \"Default\": \"Výchozí\",\n    \"Options Sort Order\": \"Možnosti řazení\",\n    \"Vertical\": \"Vertikální\",\n    \"Field rules\": \"Pravidla pole\",\n    \"Required field\": \"Povinné pole\",\n    \"Descending\": \"Sestupně\",\n    \"Options Alignment\": \"Možnosti zarovnání\",\n    \"Ascending\": \"Vzestupně\",\n    \"Field Format\": \"Formát pole\",\n    \"Horizontal\": \"Horizontální\",\n    \"Field Rules\": \"Pravidla pole\",\n    \"Select\": \"Výběr\",\n    \"Radio\": \"Radio\"\n  },\n  \"FormErrorPage\": {\n    \"Error\": \"Chyba\"\n  },\n  \"DateRangeOptions\": {\n    \"This week\": \"Tento týden\",\n    \"This year\": \"Tento rok\",\n    \"Last 7 days\": \"Posledních 7 dní\",\n    \"This month\": \"Tento měsíc\",\n    \"Next 7 days\": \"Příštích 7 dní\",\n    \"Last 30 days\": \"Posledních 30 dní\",\n    \"Today\": \"Dnes\",\n    \"Last week\": \"Minulý týden\"\n  },\n  \"FormRenderer\": {\n    \"Search\": \"Hledat\",\n    \"Select...\": \"Vybrat...\",\n    \"Submit\": \"Odeslat\",\n    \"Reset\": \"Resetuj\"\n  },\n  \"ViewConfigTab\": {\n    \"Compact\": \"Kompaktní\",\n    \"Plugin: \": \"Plugin: \",\n    \"Edit card layout\": \"Úprava rozvržení karty\",\n    \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Velké tabulky mohou být označeny jako \\\"na požádání\\\", aby se zabránilo jejich načítání do datového engine.\",\n    \"Advanced settings\": \"Pokročilá nastavení\",\n    \"Form\": \"Formulář\",\n    \"Unmark On-Demand\": \"Zrušit označení na vyžádání\",\n    \"Make On-Demand\": \"Vytvářet na vyžádání\",\n    \"Blocks\": \"Bloky\",\n    \"Section: \": \"Sekce: \"\n  },\n  \"pages\": {\n    \"You do not have edit access to this document\": \"Nemáte přístup k úpravám tohoto dokumentu\",\n    \"Duplicate page\": \"Duplicitní stránka\",\n    \"Remove\": \"Odstranit\",\n    \"Rename\": \"Přejmenovat\"\n  },\n  \"ViewSectionMenu\": {\n    \"(modified)\": \"(upraveno)\",\n    \"SORT\": \"ŘADIT\",\n    \"Save\": \"Uložit\",\n    \"FILTER\": \"FILTR\",\n    \"(customized)\": \"(přizpůsobeno)\",\n    \"(empty)\": \"(prázdný)\",\n    \"Revert\": \"Vrátit\",\n    \"Update Sort&Filter settings\": \"Aktualizovat nastavení řazení a filtru\",\n    \"Custom options\": \"Vlastní možnosti\"\n  },\n  \"ColumnEditor\": {\n    \"COLUMN DESCRIPTION\": \"POPIS SLOUPCE\",\n    \"COLUMN LABEL\": \"ŠTÍTEK SLOUPCE\"\n  },\n  \"EditorTooltip\": {\n    \"Convert column to formula\": \"Převést sloupec na vzorec\"\n  },\n  \"widgetTypesMap\": {\n    \"Calendar\": \"Kalendář\",\n    \"Card\": \"Karta\",\n    \"Card List\": \"Seznam karet\",\n    \"Chart\": \"Graf\",\n    \"Custom\": \"Vlastní\",\n    \"Form\": \"Formulář\",\n    \"Table\": \"Tabulka\"\n  },\n  \"LanguageMenu\": {\n    \"Language\": \"Jazyk\"\n  },\n  \"buildViewSectionDom\": {\n    \"No row selected in {{title}}\": \"Žádný řádek není vybrán v {{title}}\",\n    \"Not all data is shown\": \"Nejsou zobrazeny všechny údaje\",\n    \"No data\": \"Žádné údaje\"\n  },\n  \"Editor\": {\n    \"Delete\": \"Smazat\"\n  },\n  \"Menu\": {\n    \"Unmapped fields\": \"Nepřiřazená pole\",\n    \"Building blocks\": \"Stavební bloky\",\n    \"Columns\": \"Sloupce\",\n    \"Copy\": \"Kopírovat\",\n    \"Cut\": \"Vyjmout\",\n    \"Insert question below\": \"Vložit otázku pod tuto\",\n    \"Paragraph\": \"Odstavec\",\n    \"Separator\": \"Oddělovač\",\n    \"Paste\": \"Vložit\",\n    \"Insert question above\": \"Vložit otázku nad tuto\",\n    \"Header\": \"Záhlaví\",\n    \"New question\": \"Nová otázka\",\n    \"More\": \"Více\"\n  },\n  \"FormContainer\": {\n    \"Build your own form\": \"Vytvořte si vlastní formulář\",\n    \"Powered by\": \"Powered by\"\n  },\n  \"FormModel\": {\n    \"Oops! The form you're looking for doesn't exist.\": \"Oops! Formulář, který hledáte, neexistuje.\",\n    \"You don't have access to this form.\": \"K tomuto formuláři nemáte přístup.\",\n    \"There was a problem loading the form.\": \"Při načítání formuláře došlo k problému.\",\n    \"Oops! This form is no longer published.\": \"Oops! Tento formulář již není publikován.\"\n  },\n  \"FormPage\": {\n    \"There was an error submitting your form. Please try again.\": \"Při odesílání formuláře došlo k chybě. Zkuste to prosím znovu.\"\n  },\n  \"TimingPage\": {\n    \"Max Time (s)\": \"Maximální čas (s)\",\n    \"Average Time (s)\": \"Průměrný čas (s)\",\n    \"Table ID\": \"ID tabulky\",\n    \"Number of Calls\": \"Počet volání\",\n    \"Column ID\": \"ID sloupce\",\n    \"Formula timer\": \"Časovač vzorce\",\n    \"Total Time (s)\": \"Celkový čas (s)\",\n    \"Loading timing data. Don't close this tab.\": \"Načítání časových údajů. Nezavírejte tuto kartu.\"\n  },\n  \"ViewLayout\": {\n    \"Delete\": \"Smazat\",\n    \"Table {{tableName}} will no longer be visible\": \"Tabulka {{tableName}} již nebude viditelná\",\n    \"Raw Data page\": \"stránka se surovými daty\",\n    \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Data si ponechte a widget odstraňte. Tabulka zůstane k dispozici v {{rawDataLink}}\",\n    \"Delete data and this widget.\": \"Odstranění dat a tohoto widgetu.\"\n  },\n  \"CustomWidgetGallery\": {\n    \"Change widget\": \"Změnit widget\",\n    \"Last updated:\": \"Poslední aktualizace:\",\n    \"(Missing info)\": \"(Chybějící informace)\",\n    \"Add widget\": \"Přidat widget\",\n    \"Cancel\": \"Zrušit\",\n    \"Add Your Own Widget\": \"Přidejte svůj vlastní widget\",\n    \"Add a widget from outside this gallery.\": \"Přidání widgetu mimo tuto galerii.\",\n    \"Custom URL\": \"Vlastní URL\",\n    \"Search\": \"Hledat\",\n    \"Widget URL\": \"Adresa URL widgetu\",\n    \"Developer:\": \"Vývojář:\",\n    \"No matching widgets\": \"Žádné odpovídající widgety\",\n    \"Grist Widget\": \"Widget Grist\",\n    \"Choose custom widget\": \"Vybrat vlastní widget\",\n    \"Community Widget\": \"Widget komunity\",\n    \"Learn more about custom widgets\": \"Zjistit více o vlastních widgetech\"\n  },\n  \"ReverseReferenceConfig\": {\n    \"Add two-way reference\": \"Přidání obousměrného odkazu\",\n    \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Jedná se o opačnou stranu referenčního sloupce {{column}} v tabulce {{table}}.\",\n    \"Column\": \"Sloupec\",\n    \"Delete\": \"Smazat\",\n    \"Table\": \"Tabulka\",\n    \"Delete two-way reference?\": \"Odstranit obousměrný odkaz?\",\n    \"Target table\": \"Cílová tabulka\",\n    \"Two-way Reference\": \"Dvoucestná reference (odkaz)\",\n    \"Delete column {{column}} in table {{table}}?\": \"Smazání sloupce {{column}} v tabulce {{table}}?\"\n  },\n  \"SupportGristButton\": {\n    \"Opt in to Telemetry\": \"Přihlášení k telemetrii\",\n    \"Close\": \"Zavřít\",\n    \"Help Center\": \"Centrum podpory\",\n    \"Admin Panel\": \"Panel správce\",\n    \"Opted In\": \"Opted In\",\n    \"Support Grist\": \"Podpoř Grist\",\n    \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Děkujeme! Velmi si vážíme vaší důvěry a podpory. Odhlásit se můžete kdykoli na stránce {{link}} v uživatelské nabídce.\"\n  },\n  \"AuditLogStreamingConfig\": {\n    \"Add streaming destination\": \"Přidat cíl streamování\",\n    \"Delete\": \"Smazat\",\n    \"Destination\": \"Cíl\",\n    \"Destinations\": \"Cíle\",\n    \"Edit\": \"Upravit\",\n    \"Edit streaming destination\": \"Upravit cíl streamování\",\n    \"Enter URL\": \"Zadejte adresu URL\",\n    \"Save\": \"Uložit\",\n    \"URL\": \"URL\",\n    \"Delete streaming destination?\": \"Smazat cíl streamování?\",\n    \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Opravdu chcete tento cíl streamování odstranit? Tuto akci nelze vzít zpět.\",\n    \"Start streaming\": \"Spustit streamování\",\n    \"Other\": \"Ostatní\",\n    \"Cancel\": \"Zrušit\",\n    \"Token\": \"Token\",\n    \"Add destination\": \"Přidat cíl\",\n    \"Enter token\": \"Zadejte token\",\n    \"Learn more\": \"Další informace\",\n    \"Splunk\": \"Splunk\"\n  },\n  \"AuditLogsPage\": {\n    \"Contact us\": \"Kontaktujte nás\",\n    \"Audit logs for {{siteName}}\": \"Protokoly auditu pro {{siteName}}\",\n    \"Only site owners may access audit logs.\": \"K protokolům auditu mají přístup pouze vlastníci stránek.\",\n    \"Audit Logs\": \"Auditní záznamy\",\n    \"Log streaming\": \"Streamování logů\",\n    \"Home\": \"Úvod\",\n    \"upgrade your plan\": \"aktualizujte svůj plán\"\n  },\n  \"ChoiceListEditor\": {\n    \"No choices to select\": \"Žádné možnosti výběru\",\n    \"Error in dropdown condition\": \"Chyba v rozbalovací podmínce\",\n    \"No choices matching condition\": \"Žádné možnosti odpovídající podmínce\"\n  },\n  \"Reference\": {\n    \"CELL FORMAT\": \"FORMÁT BUŇKY\",\n    \"Row ID\": \"ID řádku\",\n    \"SHOW COLUMN\": \"ZOBRAZIT SLOUPEC\"\n  },\n  \"sendToDrive\": {\n    \"Sending file to Google Drive\": \"Odeslání souboru na Disk Google\"\n  },\n  \"GridView\": {\n    \"Click to insert\": \"Klikněte pro vložení\"\n  },\n  \"WelcomeCoachingCall\": {\n    \"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.\": \"Během hovoru si uděláme čas na pochopení vašich potřeb a přizpůsobíme hovor vám. Můžeme vám ukázat základy Grist, nebo rovnou začít pracovat s vašimi daty a vytvořit potřebné dashboardy.\",\n    \"free coaching call\": \"Bezplatná konzultace\",\n    \"Maybe later\": \"Možná později\",\n    \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Naplánujte si svou {{freeCoachingCall}} s členem našeho týmu.\",\n    \"Schedule call\": \"Naplánovat hovor\"\n  },\n  \"FormSuccessPage\": {\n    \"Form Submitted\": \"Formulář odeslán\",\n    \"Thank you! Your response has been recorded.\": \"Děkujeme! Vaše odpověď byla zaznamenána.\",\n    \"Submit new response\": \"Odeslat novou odpověď\"\n  },\n  \"Columns\": {\n    \"Remove Column\": \"Odstranit sloupec\"\n  },\n  \"ChoiceEditor\": {\n    \"No choices to select\": \"Žádné možnosti výběru\",\n    \"Error in dropdown condition\": \"Chyba v rozbalovací podmínce\",\n    \"No choices matching condition\": \"Žádné možnosti odpovídající podmínce\"\n  },\n  \"Field\": {\n    \"No choices configured\": \"Žádné možnosti nejsou nakonfigurovány\",\n    \"No values in show column of referenced table\": \"Žádné hodnoty ve sloupci zobrazení odkazované tabulky\",\n    \"Hide\": \"Čeština\"\n  },\n  \"DocTutorial\": {\n    \"End tutorial\": \"Konec výukového programu\",\n    \"Finish\": \"Dokončit\",\n    \"Next\": \"Další\",\n    \"Previous\": \"Předchozí\",\n    \"Restart\": \"Restartovat\",\n    \"Click to expand\": \"Klikněte pro rozšíření\",\n    \"Do you want to restart the tutorial? All progress will be lost.\": \"Chcete znovu spustit výukový program? Veškerý pokrok bude ztracen.\"\n  },\n  \"OnboardingCards\": {\n    \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Naučte se základy referenčních sloupců, propojených widgetů, typů sloupců a karet.\",\n    \"Complete the tutorial\": \"Dokončete výukový program\",\n    \"3 minute video tour\": \"3minutová videoprohlídka\",\n    \"Complete our basics tutorial\": \"Dokončete náš základní tutoriál\",\n    \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Naučte se základy referenčních sloupců, propojených widgetů, typů sloupců a karet.\"\n  },\n  \"ToggleEnterpriseWidget\": {\n    \"Disable Grist Enterprise\": \"Zakázat Grist Enterprise\",\n    \"Enable Grist Enterprise\": \"Povolení Grist Enterprise\",\n    \"Grist Enterprise is **enabled**.\": \"Služba Grist Enterprise je **povolena**.\",\n    \"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}}).\": \"Aktivační klíč slouží ke spuštění aplikace Grist Enterprise po zkušební době.\\npo uplynutí 30denní zkušební doby. Aktivační klíč získáte [přihlášením se k odběru služby Grist\\nEnterprise]({{signupLink}}). Aktivační klíč nepotřebujete k tomu, abyste mohli spustit službu\\nGrist Core.\\n\\nVíce informací najdete v našem [Centru nápovědy]({{helpCenter}}).\",\n    \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Aktivační klíč slouží ke spuštění aplikace Grist Enterprise po zkušební době.\\npo uplynutí 30denní zkušební doby. Aktivační klíč získáte [kontaktujte nás]({{contactLink}}) ještě dnes. Uděláte to\\naktivační klíč nepotřebujete ke spuštění aplikace Grist Core.\\n\\nVíce informací se dozvíte v našem [Centru nápovědy]({{helpCenter}}).\",\n    \"Activation key\": \"Aktivační klíč\",\n    \"Activate\": \"Aktivovat\",\n    \"Copy to clipboard\": \"Kopírovat do schránky\",\n    \"Expiration date\": \"Datum vypršení platnosti\",\n    \"Installation ID copied to clipboard\": \"ID instalace zkopírované do schránky\",\n    \"Installation ID:\": \"ID instalace:\",\n    \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Chcete-li nadále používat službu Grist Enterprise, musíte\\n                  [kontaktujte nás]({{signupLink}}) získat aktivační klíč.\",\n    \"You are currently trialing Grist Enterprise.\": \"Právě testujete Grist Enterprise.\",\n    \"You do not have an active subscription.\": \"Nemáte aktivní předplatné.\",\n    \"Learn more in our [Help Center]({{helpCenter}}).\": \"Více informací najdete v našem [Centru nápovědy]({{helpCenter}}).\",\n    \"Paste your activation key\": \"Vložte aktivační klíč\",\n    \"Plan name\": \"Název plánu\",\n    \"Your activation key has expired due to exceeding limits.\": \"Platnost vašeho aktivačního klíče vypršela z důvodu překročení limitů.\",\n    \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Vaše instance bude v režimu **pouze ke čtení** za **{{days}}** den(y).\",\n    \"Your subscription expired on {{date}}.\": \"Vaše předplatné vypršelo na adrese {{date}}.\",\n    \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Aktivační klíč slouží ke spuštění aplikace Grist Enterprise po zkušební době.\\n        po uplynutí 30denní zkušební doby. Aktivační klíč získáte [přihlášením se k odběru služby Grist\\n        Enterprise]({{signupLink}}). Aktivační klíč nepotřebujete k tomu, abyste mohli spustit službu\\n        Grist Core.\",\n    \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Pro další používání služby Grist Enterprise je nutné aktivní předplatné. Můžete\\npředplatné aktivovat tak, že [se zaregistrujete do služby Grist Enterprise ]({{signupLink}}) a vložíte do ní svůj účet.\\naktivačního klíče níže.\",\n    \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Vaše zkušební období vypršelo **{{expireAt}}**. Chcete-li pokračovat v používání služby Grist Enterprise, musíte se\\n[přihlásit k Grist Enterprise]({{signupLink}}) a vložit svůj aktivační klíč níže.\"\n  },\n  \"AdminPanelName\": {\n    \"Admin Panel\": \"Panel správce\"\n  },\n  \"markdown.d\": {\n    \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n      \"\": {\n        \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nová funkce Markdown\\n *\\n * Můžeme _zapsat_ [obvyklý Markdown](https://markdownguide.org) *vnitř*\\n * prvku Grainjs.\"\n      }\n    },\n    \"The toggle is **on**\": \"Přepínač je **zapnutý**\",\n    \"The toggle is **off**\": \"Přepínač je **vypnutý**\"\n  },\n  \"HomeIntroCards\": {\n    \"Learn more {{webinarsLinks}}\": \"Zjistěte více {{webinarsLinks}}\",\n    \"Templates\": \"Šablony\",\n    \"Start a new document\": \"Začněte novým dokumentem\",\n    \"Blank document\": \"Prázdný dokument\",\n    \"Find solutions and explore more resources {{helpCenterLink}}\": \"Najděte řešení a prozkoumejte další zdroje {{helpCenterLink}}\",\n    \"3 minute video tour\": \"3 minutová videoprohlídka\",\n    \"Finish our basics tutorial\": \"Dokončete náš základní výukový návod\",\n    \"Help center\": \"Centrum podpory\",\n    \"Tutorial\": \"Návod\",\n    \"Webinars\": \"Webináře\",\n    \"Import file\": \"Importovat soubor\"\n  },\n  \"buildReassignModal\": {\n    \"Cancel\": \"Zrušit\",\n    \"Reassign\": \"Nové přiřazení\",\n    \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Každý záznam na {{targetTable}} může být přiřazen pouze jednomu záznamu na {{sourceTable}}.\",\n    \"Reassign to new {{sourceTable}} records.\": \"Přeřazení do nových záznamů {{sourceTable}}.\",\n    \"Record already assigned_one\": \"Záznam je již přiřazen\",\n    \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Přeřazení do tabulky {{sourceTable}} do záznamu {{sourceName}} .\",\n    \"Record already assigned_other\": \"Záznam je již přiřazen\",\n    \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} záznam {{targetName}} je již přiřazen k záznamu {{sourceTable}}           {{oldSourceName}} .\"\n  },\n  \"ValidationPanel\": {\n    \"Update formula (Shift+Enter)\": \"Aktualizovat vzorec (Shift+Enter)\",\n    \"Rule {{length}}\": \"Pravidlo {{length}}\"\n  },\n  \"FloatingEditor\": {\n    \"Collapse Editor\": \"Sbalit editor\"\n  },\n  \"HiddenQuestionConfig\": {\n    \"Hidden fields\": \"Skrytá pole\"\n  },\n  \"CustomView\": {\n    \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Pro použití tohoto widgetu prosím přiřaďte všechna nepovinná pole v panelu tvůrce na pravé straně.\",\n    \"Some required columns aren't mapped\": \"Některá požadovaná pole nejsou přiřazena\"\n  },\n  \"ReferenceUtils\": {\n    \"Error in dropdown condition\": \"Chyba v rozbalovací podmínce\",\n    \"No choices matching condition\": \"Žádné možnosti neodpovídají podmínce\",\n    \"No choices to select\": \"Žádné možnosti výběru\"\n  },\n  \"ViewAsBanner\": {\n    \"UnknownUser\": \"Neznámý uživatel\",\n    \"View as Yourself\": \"Zobrazit za sebe\",\n    \"You are viewing this document as\": \"Tento dokument si prohlížíte jako\"\n  },\n  \"Clipboard\": {\n    \"Unavailable Command\": \"Nedostupný příkaz\",\n    \"Got it\": \"Mám to\"\n  },\n  \"FieldContextMenu\": {\n    \"Clear field\": \"Vymazat pole\",\n    \"Copy anchor link\": \"Kopírovat odkaz kotvy\",\n    \"Cut\": \"Vyjmout\",\n    \"Hide field\": \"Skrýt pole\",\n    \"Paste\": \"Vložit\",\n    \"Copy\": \"Kopírovat\"\n  },\n  \"searchDropdown\": {\n    \"Search\": \"Hledat\"\n  },\n  \"Toggle\": {\n    \"Checkbox\": \"Zaškrtávací tlačítko\",\n    \"Field Format\": \"Formát pole\",\n    \"Switch\": \"Přepínač\"\n  },\n  \"DescriptionConfig\": {\n    \"DESCRIPTION\": \"POPIS\"\n  },\n  \"DescriptionTextArea\": {\n    \"DESCRIPTION\": \"POPIS\"\n  },\n  \"markdown\": {\n    \"The toggle is **on**\": \"Přepínač je **zapnutý**\",\n    \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n      \"\": {\n        \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nová funkce Markdown\\n *\\n * Můžeme _zapsat_ [obvyklý Markdown](https://markdownguide.org) *vnitř*\\n * prvku Grainjs.\"\n      }\n    },\n    \"The toggle is **off**\": \"Přepínač je **vypnut**\"\n  },\n  \"DocList\": {\n    \"Edited {{at}}\": \"Upraveno {{at}}\",\n    \"Recent\": \"Nedávné\",\n    \"Rename and set icon\": \"Přejmenování a nastavení ikony\",\n    \"Requires edit permissions\": \"Vyžaduje oprávnění k úpravám\",\n    \"Pinned\": \"Připnuto\",\n    \"Unpin\": \"Odepnout\",\n    \"Workspace\": \"Pracovní prostor\",\n    \"Access details\": \"Podrobnosti o přístupu\",\n    \"All\": \"Vše\",\n    \"Current workspace\": \"Aktuální pracovní prostor\",\n    \"Delete\": \"Smazat\",\n    \"Delete {{name}}\": \"Smazat {{name}}\",\n    \"Document will be moved to Trash.\": \"Dokument bude přesunut do koše.\",\n    \"Last edited\": \"Naposledy upraveno\",\n    \"Manage users\": \"Spravovat uživatele\",\n    \"Move\": \"Přesunout\",\n    \"Name\": \"Název\",\n    \"No documents to show.\": \"Žádné dokumenty k zobrazení.\",\n    \"Pin\": \"Připnout\",\n    \"Sort by date\": \"Seřadit podle data\",\n    \"Sort by name\": \"Seřadit podle názvu\",\n    \"Move {{name}} to workspace\": \"Přesunout {{name}} do pracovního prostoru\"\n  },\n  \"RenameDocModal\": {\n    \"Choose color\": \"Zvolit barvu\",\n    \"Choose icon\": \"Zvolte ikonu\",\n    \"Enter document name\": \"Zadejte název dokumentu\",\n    \"Icon\": \"Ikona\",\n    \"Name\": \"Název\",\n    \"Rename and set icon\": \"Přejmenování a nastavení ikony\",\n    \"Reset icon\": \"Resetovat ikonu\"\n  }\n}\n"
  },
  {
    "path": "static/locales/cs.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Ověřte prosím svůj e-mail u poskytovatele identit a znovu se přihlaste.\"\n    },\n    \"sendAppPage\": {\n        \"og-description\": \"Moderní tabulkový procesor s otevřeným zdrojovým kódem, který přesahuje rámec mřížky\",\n        \"og-title\": \"Grist, vývoj tabulkových procesorů\",\n        \"Loading...\": \"Načítání...\"\n    },\n    \"access\": {\n        \"docDisabled\": \"Tento dokument byl znepřístupněn.\",\n        \"docNoAccess\": \"Nemáte oprávnění přistupovat k tomuto dokumentu.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"V administrátorské organizaci definované pomocí `GRIST_INSTALL_ADMIN_ORG={{org}}` nebyli nalezeni žádní vlastníci\",\n        \"orgUser\": \"Uživatel je vlastníkem administrátorské organizace definované pomocí `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"noAdminEmail\": \"Chybí administrátorský účet, protože nejsou nastaveny proměnné `GRIST_ADMIN_EMAIL` a `GRIST_DEFAULT_EMAIL`\",\n        \"accountByEmail\": \"Administrátorský účet definovaný pomocí `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Dokument bez názvu\"\n    }\n}\n"
  },
  {
    "path": "static/locales/de.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Enter email address\": \"E-Mail Adresse eingeben\",\n        \"Invite new member\": \"Neues Mitglied einladen\",\n        \"We'll email an invite to {{email}}\": \"Wir schicken eine Einladung zu {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Add column rule\": \"Spaltenregel hinzufügen\",\n        \"Add Default Rule\": \"Standard-Regel hinzufügen\",\n        \"Add table rules\": \"Tabellenregeln hinzufügen\",\n        \"Add user attributes\": \"Benutzerattribute hinzufügen\",\n        \"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.\": \"Erlauben Sie es jedem, das gesamte Dokument zu kopieren oder es im Fiddle-Modus vollständig anzuzeigen.\\nNützlich für Beispiele und Vorlagen, aber nicht für sensible Daten.\",\n        \"Allow everyone to view Access Rules.\": \"Erlauben Sie allen, die Zugangsregeln einzusehen.\",\n        \"Attribute name\": \"Name des Attributs\",\n        \"Attribute to Look Up\": \"Attribut zum Nachschlagen\",\n        \"Checking...\": \"Überprüfung…\",\n        \"Condition\": \"Bedingung\",\n        \"Default rules\": \"Standardregeln\",\n        \"Delete table rules\": \"Tabellenregeln löschen\",\n        \"Enter Condition\": \"Bedingung eingeben\",\n        \"Everyone\": \"Jeder\",\n        \"Everyone Else\": \"Alle Anderen\",\n        \"Invalid\": \"Ungültig\",\n        \"Lookup Column\": \"Nachschlagespalte\",\n        \"Lookup Table\": \"Nachschlagetabelle\",\n        \"Permission to access the document in full when needed\": \"Erlaubnis zum vollständigen Zugriff auf das Dokument bei Bedarf\",\n        \"Permission to view Access Rules\": \"Erlaubnis zum Anzeigen von Zugriffsregeln\",\n        \"Permissions\": \"Berechtigungen\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Spalte {{- colId }} aus {{- tableId }} Regeln entfernen\",\n        \"Remove {{- name }} user attribute\": \"{{- name }} Benutzerattribut entfernen\",\n        \"Remove {{- tableId }} rules\": \"{{- tableId }} Regeln entfernen\",\n        \"Reset\": \"Zurücksetzen\",\n        \"Rules for table \": \"Regeln für die Tabelle \",\n        \"Save\": \"Speichern\",\n        \"Saved\": \"Gespeichert\",\n        \"Special rules\": \"Besondere Regeln\",\n        \"Type message to display when this rule blocks an action…\": \"Geben Sie eine Nachricht ein…\",\n        \"User Attributes\": \"Benutzer-Attribute\",\n        \"Users\": \"Benutzer\",\n        \"View as\": \"Anzeigen als\",\n        \"Seed rules\": \"Saatgut-Regeln\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Beim Hinzufügen von Tabellenregeln wird automatisch eine Regel hinzugefügt, um BESITZER vollen Zugriff zu gewähren.\",\n        \"Permission to edit document structure\": \"Berechtigung zur Bearbeitung der Dokumentenstruktur\",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Erlauben Sie Editoren, Struktur zu bearbeiten (z.B. Tabellen, Spalten, Layouts zu ändern und zu löschen) und Formeln zu schreiben, die unabhängig von Leseeinschränkungen Zugriff auf alle Daten geben.\",\n        \"This default should be changed if editors' access is to be limited. \": \"Diese Standardeinstellung sollte geändert werden, wenn der Zugriff von Editoren eingeschränkt werden soll. \",\n        \"Add table-wide rule\": \"Tabellenweite Regel hinzufügen\"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"API-Schlüssel\",\n        \"Account settings\": \"Kontoeinstellungen\",\n        \"Allow signing in to this account with Google\": \"Anmeldung bei diesem Konto mit Google zulassen\",\n        \"Change password\": \"Passwort ändern\",\n        \"Edit\": \"Bearbeiten\",\n        \"Email\": \"E-mail\",\n        \"Login method\": \"Anmeldemethode\",\n        \"Name\": \"Name\",\n        \"Names only allow letters, numbers and certain special characters\": \"Bei den Namen sind nur Buchstaben, Zahlen und bestimmte Sonderzeichen erlaubt\",\n        \"Password & security\": \"Passwort und Sicherheit\",\n        \"Save\": \"Speichern\",\n        \"Theme\": \"Thema\",\n        \"Two-factor authentication\": \"Zwei-Faktor-Authentifizierung\",\n        \"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.\": \"Die Zwei-Faktor-Authentifizierung ist eine zusätzliche Sicherheitsebene für Ihr Grist-Konto, die sicherstellt, dass Sie die einzige Person sind, die auf Ihr Konto zugreifen kann, selbst wenn jemand Ihr Passwort kennt.\",\n        \"Language\": \"Sprache\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Zugangsdetails\",\n        \"Accounts\": \"Konten\",\n        \"Add account\": \"Konto hinzufügen\",\n        \"Document settings\": \"Dokument-Einstellungen\",\n        \"Manage team\": \"Team verwalten\",\n        \"Pricing\": \"Preisgestaltung\",\n        \"Profile settings\": \"Profil-Einstellungen\",\n        \"Sign out\": \"Abmelden\",\n        \"Sign in\": \"Anmelden\",\n        \"Switch Accounts\": \"Konten wechseln\",\n        \"Toggle Mobile Mode\": \"Mobilmodus umschalten\",\n        \"Activation\": \"Aktivierung\",\n        \"Billing account\": \"Abrechnungskonto\",\n        \"Support Grist\": \"Grist Support\",\n        \"Upgrade Plan\": \"Upgrade-Plan\",\n        \"Sign up\": \"Registrieren Sie sich\",\n        \"Use This Template\": \"Diese Vorlage verwenden\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"Aktionsprotokoll konnte nicht geladen werden\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Spalte {{colId}} wurde nachträglich in Aktion #{{action.actionNum}} entfernt\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Tabelle {{tableId}} wurde anschließend in Aktion #{{actionNum}} entfernt\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Diese Zeile wurde anschließend in Aktion entfernt {{action.actionNum}}\",\n        \"All tables\": \"Alle Tabellen\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"Spalte {{colId}} wurde nachträglich in Aktion {{actionNum}} entfernt\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Diese Zeile wurde nachträglich in Aktion {{actionNum}} entfernt\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Neu hinzufügen\"\n    },\n    \"ApiKey\": {\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Durch das Generieren eines API-Schlüssels können Sie API-Aufrufe für Ihr eigenes Konto durchführen.\",\n        \"Click to show\": \"Zum Anzeigen klicken\",\n        \"Create\": \"Erstellen\",\n        \"Remove\": \"Entfernen\",\n        \"Remove API Key\": \"API-Schlüssel entfernen\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Mit diesem API-Schlüssel kann anonym über die API auf dieses Konto zugegriffen werden.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Dieser API-Schlüssel kann verwendet werden, um über die API auf Ihr Konto zuzugreifen. Geben Sie Ihren API-Schlüssel mit niemandem weiter.\",\n        \"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?\": \"Sie sind dabei, einen API-Schlüssel zu löschen. Dadurch werden alle zukünftigen Anfragen, die diesen API-Schlüssel verwenden, abgelehnt. Möchten Sie trotzdem löschen?\"\n    },\n    \"App\": {\n        \"Description\": \"Beschreibung\",\n        \"Key\": \"Schlüssel\",\n        \"Memory Error\": \"Speicherfehler\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Übersetzer: Bitte übersetzen Sie dies erst, wenn Ihre Sprache bereit ist, Benutzern angeboten zu werden\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Startseite\",\n        \"Legacy\": \"Hinterlassenschaft\",\n        \"Personal Site\": \"Persönliche Seite\",\n        \"Team Site\": \"Teamseite\",\n        \"Grist Templates\": \"Grist Vorlagen\",\n        \"Billing account\": \"Abrechnungskonto\",\n        \"Manage team\": \"Team verwalten\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Zurück nach Hause\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Diese Teamseite ist gesperrt. Die Dokumente können gelesen, aber nicht geändert werden.\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"Zelle löschen\",\n        \"Clear values\": \"Werte auflösen\",\n        \"Copy anchor link\": \"Ankerlink kopieren\",\n        \"Delete column\": \"Spalte löschen\",\n        \"Delete row\": \"Zeile löschen\",\n        \"Delete {{count}} columns\": \"{{count}} Spalten löschen\",\n        \"Delete {{count}} rows\": \"{{count}} Zeilen löschen\",\n        \"Duplicate row\": \"Zeile duplizieren\",\n        \"Duplicate rows\": \"Zeilen duplizieren\",\n        \"Filter by this value\": \"Nach diesem Wert filtern\",\n        \"Insert column to the left\": \"Spalte links einfügen\",\n        \"Insert column to the right\": \"Spalte rechts einfügen\",\n        \"Insert row\": \"Zeile einfügen\",\n        \"Insert row above\": \"Zeile oben einfügen\",\n        \"Insert row below\": \"Zeile unten einfügen\",\n        \"Reset column\": \"Spalte zurücksetzen\",\n        \"Reset entire column\": \"Gesamte Spalte zurücksetzen\",\n        \"Reset {{count}} columns\": \"{{count}} Spalten zurücksetzen\",\n        \"Reset {{count}} entire columns\": \"{{count}} ganze Spalten zurücksetzen\",\n        \"Delete {{count}} columns_one\": \"Spalte löschen\",\n        \"Delete {{count}} columns_other\": \"{{count}} Spalten löschen\",\n        \"Delete {{count}} rows_one\": \"Zeile löschen\",\n        \"Delete {{count}} rows_other\": \"{{count}} Zeilen löschen\",\n        \"Duplicate rows_one\": \"Zeile duplizieren\",\n        \"Duplicate rows_other\": \"Zeilen duplizieren\",\n        \"Reset {{count}} columns_one\": \"Spalte zurücksetzen\",\n        \"Reset {{count}} columns_other\": \"{{count}} Spalten zurücksetzen\",\n        \"Reset {{count}} entire columns_one\": \"Ganze Spalte zurücksetzen\",\n        \"Reset {{count}} entire columns_other\": \"{{count}} ganze Spalten zurücksetzen\",\n        \"Comment\": \"Kommentar\",\n        \"Copy\": \"Kopieren\",\n        \"Cut\": \"Schneiden\",\n        \"Paste\": \"Einfügen\",\n        \"Copy with headers\": \"Kopieren mit Kopfzeilen\"\n    },\n    \"ChartView\": {\n        \"Create separate series for each value of the selected column.\": \"Erstellen Sie separate Datenreihen für jeden Wert der ausgewählten Spalte.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Auf jede Y-Reihe folgt eine Reihe für die Länge der Fehlerbalken.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Auf jede Y-Reihe folgen zwei Reihen für die oberen und unteren Fehlerbalken.\",\n        \"Pick a column\": \"Wählen Sie eine Spalte\",\n        \"Toggle chart aggregation\": \"Diagramm-Aggregation umschalten\",\n        \"selected new group data columns\": \"ausgewählte neue Gruppendaten-Spalten\",\n        \"LABEL\": \"ETIKETT\",\n        \"Bar chart\": \"Balkendiagramm\",\n        \"Pie chart\": \"Tortendiagramm\",\n        \"Donut chart\": \"Donutdiagramm\",\n        \"Line chart\": \"Liniendiagramm\",\n        \"Area chart\": \"Flächediagramm\",\n        \"Kaplan-Meier plot\": \"Kaplan-Meyer Diagramm\",\n        \"Scatter plot\": \"Streudiagramm\",\n        \"Split series\": \"Geteilte Serie\",\n        \"Invert Y-axis\": \"Y-Achse invertieren\",\n        \"Orientation\": \"Orientierung\",\n        \"Vertical\": \"Vertikal\",\n        \"Horizontal\": \"Horizontal\",\n        \"Log scale Y-axis\": \"Logarithmische Skala Y-Achse\",\n        \"Hole size\": \"Lochgröße\",\n        \"Show total\": \"Total anzeigen\",\n        \"Text size\": \"Textgröße\",\n        \"Connect gaps\": \"Lücken verbinden\",\n        \"Show markers\": \"Marker anzeigen\",\n        \"Stack series\": \"Stapel-Serie\",\n        \"Error bars\": \"Fehlerstangen\",\n        \"None\": \"Keine\",\n        \"Symmetric\": \"Symmetrisch\",\n        \"Above+Below\": \"Oben+Unten\",\n        \"Split Series\": \"Geteilte Serie\",\n        \"X-AXIS\": \"X-Achse\",\n        \"Aggregate values\": \"Gesamtwerte\",\n        \"SERIES\": \"SERIEN\",\n        \"non-numeric columns are not shown\": \"nicht-numerische Spalten werden nicht angezeigt\",\n        \"Remove\": \"Entfernen\",\n        \"non-numeric column is not shown\": \"nicht-numerische Spalte wird nicht angezeigt\",\n        \"Add series\": \"Serie hinzufügen\",\n        \"selected new x-axis\": \"ausgewählte neue x-Achse\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Zugriff verweigert\",\n        \"Code View is available only when you have full document access.\": \"Die Codeansicht ist nur verfügbar, wenn Sie über vollständigen Dokumentzugriff verfügen.\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Anwenden\",\n        \"Cancel\": \"Abbrechen\",\n        \"Default cell style\": \"Standard-Zellenstil\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"Alles\",\n        \"All except\": \"Alle außer\",\n        \"All shown\": \"Alle angezeigt\",\n        \"End\": \"Ende\",\n        \"Future values\": \"Zukünftige Werte\",\n        \"Max\": \"Max\",\n        \"Min\": \"Min\",\n        \"No matching values\": \"Keine übereinstimmenden Werte\",\n        \"None\": \"Keine\",\n        \"Other Matching\": \"Andere übereinstimmende\",\n        \"Other Non-Matching\": \"Andere nicht übereinstimmende\",\n        \"Other values\": \"Andere Werte\",\n        \"Others\": \"Andere\",\n        \"Search\": \"Suchen\",\n        \"Search values\": \"Werte suchen\",\n        \"Start\": \"Start\",\n        \"Filter by Range\": \"Nach Bereich filtern\",\n        \"Clear search\": \"Suche löschen\",\n        \"Pin filter\": \"Filter anheften\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Alphabetisch sortieren (aktuell: sortiert nach Häufigkeit)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Nach Häufigkeit sortieren (aktuell: alphabetisch sortiert)\",\n        \"Unpin filter\": \"Filter nicht mehr anheften\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (Optional)\",\n        \"Add\": \"Hinzufügen\",\n        \"Enter Custom URL\": \"Benutzerdefinierte URL eingeben\",\n        \"Full document access\": \"Vollständiger Dokumentenzugriff\",\n        \"Learn more about custom widgets\": \"Erfahren Sie mehr über benutzerdefinierte Widgets\",\n        \"No document access\": \"Kein Dokumentenzugriff\",\n        \"Open configuration\": \"Konfiguration öffnen\",\n        \"Pick a column\": \"Wählen Sie eine Spalte\",\n        \"Pick a {{columnType}} column\": \"Wählen Sie eine {{columnType}} Spalte\",\n        \"Read selected table\": \"Ausgewählte Tabelle lesen\",\n        \"Select Custom Widget\": \"Benutzerdefiniertes Widget auswählen\",\n        \"Widget does not require any permissions.\": \"Das Widget benötigt keine Berechtigungen.\",\n        \"Widget needs to {{read}} the current table.\": \"Das Widget muss {{read}} die aktuelle Tabelle.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Das Widget benötigt {{fullAccess}} zu diesem Dokument.\",\n        \"{{wrongTypeCount}} non-{{columnType}} column is not shown\": \"{{wrongTypeCount}} nicht{{columnType}} Spalte wird nicht angezeigt\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown\": \"{{wrongTypeCount}} nicht{{columnType}} Spalte wird nicht angezeigt\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"Spalte {{wrongTypeCount}} Nicht-{{columnType}} wird nicht angezeigt\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"Spalten {{wrongTypeCount}} Nicht-{{columnType}} werden nicht angezeigt\",\n        \"No {{columnType}} columns in table.\": \"Keine {{columnType}} Spalten in der Tabelle.\",\n        \"Clear selection\": \"Auswahl löschen\",\n        \"ACCESS LEVEL\": \"ZUGRIFFSEBENE\",\n        \"Custom URL\": \"Benutzerdefinierte URL\",\n        \"Last updated:\": \"Letzte Aktualisierung:\",\n        \"Missing description and author information.\": \"Fehlende Beschreibung und Autorenangaben.\",\n        \"Reject\": \"Ablehnen\",\n        \"Widget\": \"Widget\",\n        \"Developer:\": \"Entwickler:\",\n        \"Accept\": \"Akzeptieren\",\n        \"Change custom widget\": \"Benutzerdefiniertes Widget ändern\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Zum Kopieren anklicken\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"{{formattedTableName}} Daten löschen und von allen Seiten entfernen?\",\n        \"Duplicate table\": \"Tabelle duplizieren\",\n        \"Raw Data Tables\": \"Rohdaten-Tabellen\",\n        \"Table ID copied to clipboard\": \"Tabellen-ID in die Zwischenablage kopiert\",\n        \"You do not have edit access to this document\": \"Sie haben keinen Bearbeitungszugriff auf dieses Dokument\",\n        \"Edit record card\": \"Karteikarte bearbeiten\",\n        \"Rename table\": \"Tabelle umbenennen\",\n        \"{{action}} Record Card\": \"{{action}} Karteikarte\",\n        \"Record Card\": \"Karteikarte\",\n        \"Remove table\": \"Tabelle entfernen\",\n        \"Record Card Disabled\": \"Karteikarte Deaktiviert\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Aktivität\",\n        \"Beta\": \"Beta\",\n        \"Compare to current\": \"Mit Aktuell vergleichen\",\n        \"Compare to previous\": \"Mit vorherigem vergleichen\",\n        \"Open snapshot\": \"Schnappschuss öffnen\",\n        \"Snapshots\": \"Schnappschüsse\",\n        \"Snapshots are unavailable.\": \"Schnappschüsse sind nicht verfügbar.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Nur Eigentümer haben Zugriff auf Snapshots für Dokumente mit Zugriffsregeln.\"\n    },\n    \"DocMenu\": {\n        \"(The organization needs a paid plan)\": \"(Die Organisation benötigt einen bezahlten Plan)\",\n        \"Access Details\": \"Zugangsdetails\",\n        \"All documents\": \"Alle Dokumente\",\n        \"By Date Modified\": \"Nach Änderungsdatum\",\n        \"By Name\": \"Nach Name\",\n        \"Current workspace\": \"Aktueller Arbeitsbereich\",\n        \"Delete\": \"Löschen\",\n        \"Delete Forever\": \"Für immer löschen\",\n        \"Delete {{name}}\": \"{{name}} löschen\",\n        \"Deleted {{at}}\": \"Gelöscht {{at}}\",\n        \"Discover More Templates\": \"Mehr Vorlagen entdecken\",\n        \"Document will be moved to Trash.\": \"Das Dokument wird in den Papierkorb verschoben.\",\n        \"Document will be permanently deleted.\": \"Das Dokument wird endgültig gelöscht.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Dokumente bleiben 30 Tage lang im Papierkorb, danach werden sie endgültig gelöscht.\",\n        \"Edited {{at}}\": \"Bearbeitet {{at}}\",\n        \"Examples & Templates\": \"Beispiele & Vorlagen\",\n        \"Examples and Templates\": \"Beispiele und Vorlagen\",\n        \"Featured\": \"Hervorgehoben\",\n        \"Manage users\": \"Benutzer verwalten\",\n        \"More Examples and Templates\": \"Weitere Beispiele und Vorlagen\",\n        \"Move\": \"Verschieben\",\n        \"Move {{name}} to workspace\": \"{{name}} in den Arbeitsbereich verschieben\",\n        \"Other Sites\": \"Andere Seiten\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"\\\"{{name}}\\\" dauerhaft löschen?\",\n        \"Pin Document\": \"Dokument anheften\",\n        \"Pinned Documents\": \"Angeheftete Dokumente\",\n        \"Remove\": \"Entfernen\",\n        \"Rename\": \"Umbenennen\",\n        \"Requires edit permissions\": \"Erfordert Bearbeitungsrechte\",\n        \"Restore\": \"Wiederherstellen\",\n        \"This service is not available right now\": \"Dieser Dienst ist im Moment nicht verfügbar\",\n        \"To restore this document, restore the workspace first.\": \"Um dieses Dokument wiederherzustellen, stellen Sie zuerst den Arbeitsbereich wieder her.\",\n        \"Trash\": \"Papierkorb\",\n        \"Trash is empty.\": \"Der Papierkorb ist leer.\",\n        \"Unpin Document\": \"Dokument lösen\",\n        \"Workspace not found\": \"Arbeitsbereich nicht gefunden\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Sie sind auf der {{siteName}} Seite. Sie haben auch Zugriff auf die folgenden Seiten:\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Sie sind auf Ihrer persönlichen Seite. Sie haben auch Zugriff auf die folgenden Seiten:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Sie können einen Arbeitsbereich für immer löschen, sobald er keine Dokumente enthält.\",\n        \"Any documents created in this site will appear here.\": \"Alle Dokumente, die auf dieser Seite erstellt wurden, werden hier angezeigt.\",\n        \"Create my first document\": \"Mein erstes Dokument erstellen\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Sie haben nur Lesezugriff auf diese Seite. Derzeit sind keine Dokumente vorhanden.\",\n        \"personal site\": \"persönliche Seite\",\n        \"Grid view\": \"Rasteransicht\",\n        \"List view\": \"Listenansicht\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Leere Tabelle hinzufügen\",\n        \"Add page\": \"Seite hinzufügen\",\n        \"Add widget to page\": \"Widget zur Seite hinzufügen\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Dokumentbesitzer können versuchen, das Dokument wiederherzustellen. [{{error}}]\",\n        \"Enter recovery mode\": \"Wiederherstellungsmodus aktivieren\",\n        \"Error accessing document\": \"Fehler beim Zugriff auf das Dokument\",\n        \"Reload\": \"Neu laden\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Leider wurde der Zugriff auf dieses Dokument verweigert. [{{error}}]\",\n        \"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}}]\": \"Sie können versuchen, das Dokument neu zu laden oder den Wiederherstellungsmodus zu verwenden. Im Wiederherstellungsmodus wird das Dokument so geöffnet, dass es für den Besitzer vollständig zugänglich ist, für andere jedoch nicht. Außerdem werden die Formeln deaktiviert. [{{error}}]\",\n        \"You do not have edit access to this document\": \"Sie haben keinen Bearbeitungszugriff auf dieses Dokument\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Bitte laden Sie das Dokument erneut, und wenn der Fehler weiterhin besteht, wenden Sie sich an die Eigentümer des Dokuments, um eine Wiederherstellung des Dokuments zu versuchen. [{{error}}]\"\n    },\n    \"DocTour\": {\n        \"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.\": \"Aus den Daten in diesem Dokument kann keine Dokumenttour erstellt werden. Stellen Sie sicher, dass eine Tabelle mit dem Namen GristDocTour mit den Spalten Title, Body, Placement und Location vorhanden ist.\",\n        \"No valid document tour\": \"Kein gültiges Dokument Tour\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Währung:\",\n        \"Document settings\": \"Dokument-Einstellungen\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Motor (experimental {{span}} auf eigene Gefahr ändern):\",\n        \"Local currency ({{currency}})\": \"Lokale Währung ({{currency}})\",\n        \"Locale:\": \"Region:\",\n        \"Save\": \"Speichern\",\n        \"Save and Reload\": \"Speichern und neu laden\",\n        \"This document's ID (for API use):\": \"Die ID dieses Dokuments (für API-Verwendung):\",\n        \"Time Zone:\": \"Zeitzone:\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"Dokument-ID in die Zwischenablage kopiert\",\n        \"Ok\": \"OK\",\n        \"Webhooks\": \"Webhooks\",\n        \"Manage Webhooks\": \"Webhooks verwalten\",\n        \"API console\": \"API-Konsole\",\n        \"Coming soon\": \"Demnächst verfügbar\",\n        \"For number and date formats\": \"Für Zahlen- und Datumsformate\",\n        \"Formula times\": \"Formelzeiten\",\n        \"Locale\": \"Region\",\n        \"Time zone\": \"Zeitzone\",\n        \"Find slow formulas\": \"Langsame Formeln finden\",\n        \"Manage webhooks\": \"Webhooks verwalten\",\n        \"For currency columns\": \"Für Währungsspalten\",\n        \"Notify other services on doc changes\": \"Andere Services bei Änderungen an Dokumenten benachrichtigen\",\n        \"Hard reset of data engine\": \"Hartes Zurücksetzen der Datenmaschine\",\n        \"Python\": \"Python\",\n        \"ID for API use\": \"ID für API-Verwendung\",\n        \"Python version used\": \"Verwendete Python-Version\",\n        \"Reload\": \"Neu laden\",\n        \"Try API calls from the browser\": \"Versuchen Sie API-Aufrufe über den Browser\",\n        \"python2 (legacy)\": \"python2 (veraltet)\",\n        \"python3 (recommended)\": \"python3 (empfohlen)\",\n        \"API URL copied to clipboard\": \"API-URL in die Zwischenablage kopiert\",\n        \"Base doc URL: {{docApiUrl}}\": \"URL des Basisdokuments: {{docApiUrl}}\",\n        \"API documentation.\": \"API Dokumentation.\",\n        \"Copy to clipboard\": \"In die Zwischenablage kopieren\",\n        \"Currency\": \"Währung\",\n        \"Data engine\": \"Datenmaschine\",\n        \"Default for DateTime columns\": \"Standard für DateTime-Spalten\",\n        \"Document ID\": \"Dokument-ID\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"Dokument-ID, die bei Aufrufen der REST-API für {{docId}} zu verwenden ist. Siehe {{apiURL}}\",\n        \"Reload data engine\": \"Datenmaschine neu laden\",\n        \"Reload data engine?\": \"Datenmaschine neu laden?\",\n        \"Start timing\": \"Startzeitpunkt\",\n        \"Stop timing...\": \"Stoppt die Zeitmessung...\",\n        \"Time reload\": \"Zeit nachladen\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Erzwingen Sie das Neuladen des Dokuments während der Zeitmessung von Formeln, und zeigen Sie das Ergebnis an.\",\n        \"Formula timer\": \"Formel Timer\",\n        \"Cancel\": \"Abbrechen\",\n        \"Timing is on\": \"Das Timing läuft\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Sie können Änderungen an dem Dokument vornehmen und dann die Zeitmessung stoppen, um die Ergebnisse zu sehen.\",\n        \"Only available to document editors\": \"Nur für Redakteure von Dokumenten verfügbar\",\n        \"Only available to document owners\": \"Nur für Eigentümer von Dokumenten verfügbar\",\n        \"Template mode\": \"Vorlage-Modus\",\n        \"Change document type\": \"Dokumenttyp ändern\",\n        \"Edit\": \"Bearbeiten\",\n        \"Change nature of document\": \"Art des Dokuments ändern\",\n        \"Regular document\": \"Regelmäßiges Dokument\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Normales Verhalten des Dokuments. Alle Benutzer arbeiten an der gleichen Kopie des Dokuments.\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Das Dokument wird automatisch in {{fiddleModeDocUrl}} geöffnet. Jeder kann das Dokument bearbeiten, wodurch eine neue, nicht gespeicherte Kopie erstellt wird.\",\n        \"fiddle mode\": \"Fiddle-Modus\",\n        \"Tutorial\": \"Tutorial\",\n        \"Document automatically opens as a user-specific copy.\": \"Das Dokument wird automatisch als benutzerspezifische Kopie geöffnet.\",\n        \"Confirm change\": \"Änderung bestätigen\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Sobald Sie mit der Zeitmessung beginnen, misst Grist die Zeit, die für die Auswertung jeder Formel benötigt wird. Auf diese Weise lässt sich feststellen, welche Formeln für die langsame Leistung beim ersten Öffnen eines Dokuments oder beim Reagieren eines Dokuments auf Änderungen verantwortlich sind.\",\n        \"**Some existing attachments are still external**.\": \"**Einige bestehende Anhänge sind noch extern**.\",\n        \"Attachment storage\": \"Lagerung von Anhängen\",\n        \"Being transfer\": \"Übertragen\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Klicken Sie auf \\\"Übertragung starten\\\", um diese auf den externen Speicher zu übertragen.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Klicken Sie auf \\\"Übertragung starten\\\", um diese in den internen Speicher zu übertragen (gespeichert in der SQLite-Datei des Dokuments).\",\n        \"No external stores available\": \"Keine externen Speicher verfügbar\",\n        \"Template\": \"Vorlage\",\n        \"Preferred storage for this document\": \"Bevorzugte Ablage für dieses Dokument\",\n        \"Start transfer\": \"Übertragung starten\",\n        \"Regular\": \"Regelmäßig\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Neu hochgeladene Anhänge werden im internen Speicher abgelegt.\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Neu hochgeladene Anhänge werden im externen Speicher abgelegt.\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Dadurch wird ein harter Reload der Datenmaschine durchgeführt. Dies kann hilfreich sein, wenn die Datenmaschine in einer Endlosschleife feststeckt, die letzte Änderung unendlich lange verarbeitet oder abgestürzt ist. Es gehen keine Daten verloren, mit Ausnahme möglicherweise noch laufender Aktionen.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Einige vorhandene Anhänge sind noch intern** (in einer SQLite-Datei gespeichert).\",\n        \"Internal\": \"Intern\",\n        \"Transfer in progress\": \"Übertragung im Gange\",\n        \"External\": \"Extern\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Einige vorhandene Anhänge sind noch [extern]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Einige vorhandene Anhänge sind noch [intern]({{internalLink}})** (in einer SQLite-Datei gespeichert).\",\n        \"[Learn more.]({{learnLink}})\": \"[Erfahren Sie mehr.]({{learnLink}})\",\n        \"Upload\": \"Hochladen\",\n        \"Upload missing attachments\": \"Fehlende Anhänge hochladen\",\n        \"Uploading...\": \"Hochladen...\",\n        \"Default\": \"Standard\",\n        \"Default, template, or tutorial\": \"Standard, Vorlage oder Tutorial\",\n        \"Document type\": \"Art des Dokuments\",\n        \"Enable suggestions\": \"Vorschläge aktivieren\",\n        \"Suggestions\": \"Vorschläge\",\n        \"experiment\": \"Experiment\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"Größe der Anhänge\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Wenden Sie sich an den Eigentümer der Seite, um den Plan zu aktualisieren und die Grenzwerte zu erhöhen.\",\n        \"Data size\": \"Datengröße\",\n        \"Document limits {{- link}}.\": \"Dokumentgrenzwerte {{- link}}.\",\n        \"Document limits {{- link}}. In {{gracePeriodDays}} days, this document will be read-only.\": \"Dokumentgrenzen {{- link}}. In {{gracePeriodDays}} Tagen wird dieses Dokument schreibgeschützt sein.\",\n        \"For higher limits, \": \"Für höhere Grenzwerte, \",\n        \"Rows\": \"Zeilen\",\n        \"The total size of all data in this document, excluding attachments.\": \"Die Gesamtgröße aller Daten in diesem Dokument, ohne Anhänge.\",\n        \"This document is {{- link}} free plan limits.\": \"Dieses Dokument ist {{- link}} kostenloser Plan Grenzen.\",\n        \"This document {{- link}} free plan limits and is now read-only, but you can delete rows.\": \"Dieses Dokument {{- link}} die kostenlosen Plan Grenzen und ist jetzt schreibgeschützt, aber Sie können Zeilen löschen.\",\n        \"Updates every 5 minutes.\": \"Aktualisiert alle 5 Minuten.\",\n        \"Usage\": \"Verwendung\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"Nutzungsstatistiken stehen nur Benutzern mit vollem Zugriff auf die Belegdaten zur Verfügung.\",\n        \"start your 30-day free trial of the Pro plan.\": \"Starten Sie Ihre kostenlose 30-Tage-Testversion des Pro-Plans.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Letzte Bearbeitung wiederherstellen\",\n        \"Undo discard\": \"Verwerfen rückgängig machen\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Kopieren Sie alle Daten zusätzlich zur Tabellenstruktur.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Anstatt Tabellen zu duplizieren, ist es in der Regel besser, Daten mithilfe verknüpfter Ansichten zu segmentieren. {{link}}\",\n        \"Name for new table\": \"Name für neue Tabelle\",\n        \"Only the document default access rules will apply to the copy.\": \"Für die Kopie gelten nur die Standardzugriffsregeln des Dokuments.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Nachschulprogramm\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"In unserem zugehörigen Tutorial erfahren Sie, wie Sie Daten verknüpfen und hochproduktive Layouts erstellen können.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"In unserem zugehörigen Tutorial erfahren Sie, wie Sie Geschäftsdaten modellieren, Formeln verwenden und Komplexität bewältigen können.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"In unserem zugehörigen Tutorial erfahren Sie, wie Sie Übersichtstabellen und Diagramme erstellen und Diagramme dynamisch verknüpfen können.\",\n        \"Investment Research\": \"Investitionsforschung\",\n        \"Lightweight CRM\": \"Leichtes CRM\",\n        \"Tutorial: Analyze & Visualize\": \"Tutorial: Analysieren und Visualisieren\",\n        \"Tutorial: Create a CRM\": \"Lernprogramm: Erstellen Sie ein CRM\",\n        \"Tutorial: Manage Business Data\": \"Tutorial: Geschäftsdaten verwalten\",\n        \"Welcome to the Afterschool Program template\": \"Willkommen in der Nachschulprogrammvorlage\",\n        \"Welcome to the Investment Research template\": \"Willkommen bei der Investitionsforschungsvorlage\",\n        \"Welcome to the Lightweight CRM template\": \"Willkommen beim Leichtes CRM-Vorlage\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"SPALTENVERHALTEN\",\n        \"COLUMN LABEL AND ID\": \"SPALTENBEZEICHNUNG UND ID\",\n        \"Clear and make into formula\": \"Löschen und in Formel umwandeln\",\n        \"Clear and reset\": \"Löschen und zurücksetzen\",\n        \"Column options are limited in summary tables.\": \"Spaltenoptionen sind in Übersichtstabellen eingeschränkt.\",\n        \"Convert column to data\": \"Spalte in Daten umwandeln\",\n        \"Convert to trigger formula\": \"In Auslöserformel umwandeln\",\n        \"Data Column\": \"Daten-Spalte\",\n        \"Data Columns\": \"Daten-Spalten\",\n        \"Empty Column\": \"Leere Spalte\",\n        \"Empty Columns\": \"Leere Spalten\",\n        \"Enter formula\": \"Formel eingeben\",\n        \"Formula Column\": \"Formel-Spalte\",\n        \"Formula Columns\": \"Formel-Spalten\",\n        \"Make into data column\": \"In Datenspalte umwandeln\",\n        \"Mixed Behavior\": \"Gemischtes Verhalten\",\n        \"Set formula\": \"Formel festlegen\",\n        \"Set trigger formula\": \"Auslöseformel festlegen\",\n        \"TRIGGER FORMULA\": \"AUSLÖSEFORMEL\",\n        \"Data columns_one\": \"Daten-Spalte\",\n        \"Data columns_other\": \"Daten-Spalten\",\n        \"Empty columns_one\": \"Leere Spalte\",\n        \"Empty columns_other\": \"Leere Spalten\",\n        \"Formula columns_one\": \"Formel Spalte\",\n        \"Formula columns_other\": \"Formel-Spalten\",\n        \"DESCRIPTION\": \"BESCHREIBUNG\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Gemeinsame Einstellungen wiederherstellen\",\n        \"Save as common settings\": \"Als allgemeine Einstellungen speichern\",\n        \"Use separate settings\": \"Verwenden Sie separate Einstellungen\",\n        \"Using common settings\": \"Gemeinsame Einstellungen verwenden\",\n        \"Using separate settings\": \"Separate Einstellungen verwenden\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Spalte hinzufügen\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Filter anheften - {{- columnName}} Spalte (aktuell: nicht angeheftet)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Filter nicht mehr anheften - {{- columnName}} Spalte (aktuell: angeheftet)\",\n        \"remove filter - {{- columnName}} column\": \"Filter entfernen - {{- columnName}} Spalte\",\n        \"{{- columnName }} column filters\": \"{{- columnName }}Spaltenfilter\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Raster-Optionen\",\n        \"Horizontal gridlines\": \"Horizontale Rasterlinien\",\n        \"Vertical gridlines\": \"Vertikale Rasterlinien\",\n        \"Zebra stripes\": \"Zebra-Streifen\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Spalte hinzufügen\",\n        \"Add to sort\": \"Zum Sortieren hinzufügen\",\n        \"Clear values\": \"Werte auflösen\",\n        \"Column Options\": \"Spalten-Optionen\",\n        \"Convert formula to data\": \"Formel in Daten umwandeln\",\n        \"Delete column\": \"Spalte löschen\",\n        \"Delete {{count}} columns\": \"{{count}} Spalten löschen\",\n        \"Filter Data\": \"Daten filtern\",\n        \"Freeze one more columns\": \"Eine weitere Spalte fixieren\",\n        \"Freeze this column\": \"Diese Spalte fixieren\",\n        \"Freeze {{count}} columns\": \"{{count}} Spalten fixieren\",\n        \"Freeze {{count}} more columns\": \"{{count}} weitere Spalten fixieren\",\n        \"Hide column\": \"Spalte ausblenden\",\n        \"Hide {{count}} columns\": \"{{count}} Spalten ausblenden\",\n        \"Insert column to the {{to}}\": \"Spalte in {{to}} einfügen\",\n        \"More sort options ...\": \"Mehr Sortieroptionen…\",\n        \"Rename column\": \"Spalte umbenennen\",\n        \"Reset column\": \"Spalte zurücksetzen\",\n        \"Reset entire column\": \"Gesamte Spalte zurücksetzen\",\n        \"Reset {{count}} columns\": \"{{count}} Spalten zurücksetzen\",\n        \"Reset {{count}} entire columns\": \"{{count}} ganze Spalten zurücksetzen\",\n        \"Show column {{- label}}\": \"Spalte {{- label}} anzeigen\",\n        \"Sort\": \"Sortieren\",\n        \"Sorted (#{{count}})\": \"Sortiert (#{{count}})\",\n        \"Unfreeze all columns\": \"Alle Spalten entsperren\",\n        \"Unfreeze this column\": \"Diese Spalte entsperren\",\n        \"Unfreeze {{count}} columns\": \"{{count}} Spalten entsperren\",\n        \"Delete {{count}} columns_one\": \"Spalte löschen\",\n        \"Delete {{count}} columns_other\": \"{{count}} Spalten löschen\",\n        \"Freeze {{count}} columns_one\": \"Diese Spalte fixieren\",\n        \"Freeze {{count}} columns_other\": \"{{count}} Spalten fixieren\",\n        \"Freeze {{count}} more columns_one\": \"Eine weitere Spalte fixieren\",\n        \"Freeze {{count}} more columns_other\": \"{{count}} weitere Spalten fixieren\",\n        \"Hide {{count}} columns_one\": \"Spalte ausblenden\",\n        \"Hide {{count}} columns_other\": \"{{count}} Spalten ausblenden\",\n        \"Reset {{count}} columns_one\": \"Spalte zurücksetzen\",\n        \"Reset {{count}} columns_other\": \"{{count}} Spalten zurücksetzen\",\n        \"Reset {{count}} entire columns_one\": \"Ganze Spalte zurücksetzen\",\n        \"Reset {{count}} entire columns_other\": \"{{count}} ganze Spalten zurücksetzen\",\n        \"Sorted (#{{count}})_one\": \"Sortiert (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Sortiert (#{{count}})\",\n        \"Unfreeze {{count}} columns_one\": \"Diese Spalte entsperren\",\n        \"Unfreeze {{count}} columns_other\": \"{{count}} Spalten entsperren\",\n        \"Insert column to the right\": \"Spalte rechts einfügen\",\n        \"Insert column to the left\": \"Spalte links einfügen\",\n        \"Shortcuts\": \"Abkürzungen\",\n        \"Show hidden columns\": \"Ausgeblendete Spalten anzeigen\",\n        \"Created At\": \"Erstellt am\",\n        \"Authorship\": \"Urheberschaft\",\n        \"Last Updated By\": \"Zuletzt aktualisiert von\",\n        \"Hidden Columns\": \"Ausgeblendete Spalten\",\n        \"Lookups\": \"Nachschlagen\",\n        \"Apply on record changes\": \"Auf Datensatzänderungen anwenden\",\n        \"Created By\": \"Erstellt von\",\n        \"Last Updated At\": \"Zuletzt aktualisiert am\",\n        \"Apply to new records\": \"Auf neue Datensätze anwenden\",\n        \"Timestamp\": \"Zeitstempel\",\n        \"no reference column\": \"keine Referenzspalte\",\n        \"Detect Duplicates in...\": \"Duplikate erkennen in...\",\n        \"UUID\": \"UUID\",\n        \"No reference columns.\": \"Keine Referenzspalten.\",\n        \"Duplicate in {{- label}}\": \"Duplikate in {{- label}}\",\n        \"Search columns\": \"Spalten suchen\",\n        \"Adding UUID column\": \"Hinzufügen der UUID-Spalte\",\n        \"Adding duplicates column\": \"Hinzufügen einer Duplikatspalte\",\n        \"Add formula column\": \"Formelspalte hinzufügen\",\n        \"Add column with type\": \"Spalte mit Typ hinzufügen\",\n        \"Created by\": \"Erstellt von\",\n        \"Created at\": \"Erstellt am\",\n        \"Last updated by\": \"Zuletzt aktualisiert von\",\n        \"Detect duplicates in...\": \"Erkennen Sie Duplikate in...\",\n        \"Last updated at\": \"Zuletzt aktualisiert am\",\n        \"Reference List\": \"Referenzliste\",\n        \"Text\": \"Text\",\n        \"Date\": \"Datum\",\n        \"DateTime\": \"DatumUhrzeit\",\n        \"Choice\": \"Auswahl\",\n        \"Choice List\": \"Auswahlliste\",\n        \"Reference\": \"Referenz\",\n        \"Attachment\": \"Anhang\",\n        \"Any\": \"Jegliche\",\n        \"Numeric\": \"Numerisch\",\n        \"Integer\": \"Ganze Zahl\",\n        \"Toggle\": \"Umschalten\"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"Neuer verlinkter Abschnitt zur Ansicht hinzugefügt {{viewName}}\",\n        \"Import from file\": \"Aus Datei importieren\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Gespeicherter verlinkter Abschnitt {{title}} in Ansicht {{name}}\",\n        \"go to webhook settings\": \"gehen sie zu webhook Einstellungen\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Neue Änderungen werden vorübergehend ausgesetzt. Die Webhooks-Warteschlange ist übergelaufen. Bitte überprüfen Sie die Webhook-Einstellungen, entfernen Sie ungültige Webhooks und bereinigen Sie die Warteschlange.\"\n    },\n    \"HomeIntro\": {\n        \", or find an expert via our \": \", oder finden Sie einen Experten über unseren\",\n        \"Any documents created in this site will appear here.\": \"Alle Dokumente, die auf dieser Seite erstellt wurden, werden hier angezeigt.\",\n        \"Browse Templates\": \"Vorlagen durchsuchen\",\n        \"Create empty document\": \"Leeres Dokument erstellen\",\n        \"Get started by creating your first Grist document.\": \"Beginnen Sie mit der Erstellung Ihres ersten Grist-Dokuments.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Beginnen Sie mit der Erkundung von Vorlagen oder erstellen Sie Ihr erstes Grist-Dokument.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Beginnen Sie, indem Sie Ihr Team einladen und Ihr erstes Grist-Dokument erstellen.\",\n        \"Help Center\": \"Hilfe-Center\",\n        \"Import document\": \"Dokument importieren\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Sind Sie daran interessiert, Grist auch außerhalb Ihres Teams zu nutzen? Besuchen Sie Ihr kostenloses \",\n        \"Invite Team Members\": \"Teammitglieder einladen\",\n        \"Sign up\": \"Registrieren Sie sich\",\n        \"Sprouts Program\": \"Sprossen-Programm\",\n        \"This workspace is empty.\": \"Dieser Arbeitsbereich ist leer.\",\n        \"Visit our {{link}} to learn more.\": \"Besuchen Sie unser {{link}}, um mehr zu erfahren.\",\n        \"Welcome to Grist!\": \"Willkommen bei Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Willkommen bei Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Willkommen bei {{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Sie haben nur Lesezugriff auf diese Seite. Derzeit sind keine Dokumente vorhanden.\",\n        \"personal site\": \"persönliche Seite\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} um Ihre Arbeit zu speichern. \",\n        \"Welcome to Grist, {{- name}}!\": \"Willkommen bei Grist, {{-name}}!\",\n        \"Welcome to {{- orgName}}\": \"Willkommen bei {{-orgName}}\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Besuchen Sie unsere {{link}}, um mehr über Grist zu erfahren.\",\n        \"Sign in\": \"Anmelden\",\n        \"To use Grist, please either sign up or sign in.\": \"Um Grist zu nutzen, melden Sie sich bitte an oder registrieren Sie sich.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Erfahren Sie mehr in unserem {{helpCenterLink}}.\",\n        \"Only show documents\": \"Nur Dokumente anzeigen\"\n    },\n    \"HomeLeftPane\": {\n        \"Access Details\": \"Zugangsdetails\",\n        \"All documents\": \"Alle Dokumente\",\n        \"Create empty document\": \"Leeres Dokument erstellen\",\n        \"Create workspace\": \"Arbeitsbereich erstellen\",\n        \"Delete\": \"Löschen\",\n        \"Delete {{workspace}} and all included documents?\": \"{{workspace}} und alle enthaltenen Dokumente löschen?\",\n        \"Examples & Templates\": \"Vorlagen\",\n        \"Import document\": \"Dokument importieren\",\n        \"Manage users\": \"Benutzer verwalten\",\n        \"Rename\": \"Umbenennen\",\n        \"Trash\": \"Papierkorb\",\n        \"Workspace will be moved to Trash.\": \"Der Arbeitsbereich wird in den Papierkorb verschoben.\",\n        \"Workspaces\": \"Arbeitsbereiche\",\n        \"Tutorial\": \"Tutorial\",\n        \"Terms of service\": \"Nutzungsbedingungen\",\n        \"Grist Resources\": \"Grist Ressourcen\",\n        \"context menu - {{- workspaceName }}\": \"Kontextmenü - {{- workspaceName }}\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"Zeilen zusammenführen, die mit diesen Feldern übereinstimmen:\",\n        \"Select fields to match on\": \"Wählen Sie Felder zum Abgleichen aus\",\n        \"Update existing records\": \"Vorhandene Datensätze aktualisieren\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} unangepasste Felder im Import\",\n        \"{{count}} unmatched field_one\": \"{{count}} unangepasstes Feld\",\n        \"{{count}} unmatched field_other\": \"{{count}} unangepasste Felder\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} unangepasst Feld im Import\",\n        \"Column mapping\": \"Spaltenzuordnung\",\n        \"Column Mapping\": \"Spaltenzuordnung\",\n        \"Destination table\": \"Zieltabelle\",\n        \"Grist column\": \"Grist Spalte\",\n        \"Import from file\": \"Aus Datei importieren\",\n        \"New Table\": \"Neue Tabelle\",\n        \"Revert\": \"Zurücksetzen\",\n        \"Skip\": \"überspringen\",\n        \"Skip Import\": \"Import überspringen\",\n        \"Skip Table on Import\": \"Tabelle beim Import überspringen\",\n        \"Source column\": \"Quellenspalte\",\n        \"Import options\": \"Importeinstellungen\",\n        \"Cancel\": \"Abbrechen\",\n        \"Import\": \"Importieren\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Hilfe-Center\",\n        \"Accessibility\": \"Barrierefreiheit\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"Als Vorlage\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Seien Sie vorsichtig, das Original hat Änderungen, die nicht in diesem Dokument enthalten sind. Diese Änderungen werden überschrieben.\",\n        \"Cancel\": \"Abbrechen\",\n        \"Enter document name\": \"Geben Sie den Dokumentnamen ein\",\n        \"However, it appears to be already identical.\": \"Es scheint jedoch bereits identisch zu sein.\",\n        \"Include the structure without any of the data.\": \"Nehmen Sie die Struktur ohne die Daten auf.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Es wird überschrieben und verliert alle Inhalte, die nicht in diesem Dokument enthalten sind.\",\n        \"Name\": \"Name\",\n        \"No destination workspace\": \"Kein Zielarbeitsbereich\",\n        \"Organization\": \"Organisation\",\n        \"Original Has Modifications\": \"Original hat Änderungen\",\n        \"Original Looks Identical\": \"Original sieht identisch aus\",\n        \"Original Looks Unrelated\": \"Original sieht nicht verwandt aus\",\n        \"Overwrite\": \"Überschreiben\",\n        \"Replacing the original requires editing rights on the original document.\": \"Das Original zu ersetzen erfordert Bearbeitungsrechte auf dem Originaldokument.\",\n        \"Sign up\": \"Registrieren Sie sich\",\n        \"The original version of this document will be updated.\": \"Die Originalversion dieses Dokuments wird aktualisiert.\",\n        \"To save your changes, please sign up, then reload this page.\": \"Um Ihre Änderungen zu speichern, melden Sie sich bitte an und laden Sie diese Seite neu.\",\n        \"Update\": \"Aktualisieren\",\n        \"Update Original\": \"Original aktualisieren\",\n        \"Workspace\": \"Arbeitsbereich\",\n        \"You do not have write access to the selected workspace\": \"Sie haben keinen Schreibzugriff auf den ausgewählten Arbeitsbereich\",\n        \"You do not have write access to this site\": \"Sie haben keinen Schreibzugriff auf diese Seite\",\n        \"Download document and history\": \"Vollständiges Dokument und Geschichte herunterladen\",\n        \"Download document structure only (no data, for template use)\": \"Entfernen Sie alle Daten, behalten Sie aber die Struktur als Vorlage bei\",\n        \"Download document without history (can significantly reduce file size)\": \"Dokumentverlauf entfernen (kann die Dateigröße deutlich reduzieren)\",\n        \"Download document\": \"Dokument herunterladen\",\n        \"Download\": \"Download\",\n        \".tar (recommended)\": \".tar (empfohlen)\",\n        \"Download an archive of all the attachments present in this document.\": \"Laden Sie ein Archiv mit allen Anhängen dieses Dokuments herunter.\",\n        \"Download attachments\": \"Anhänge herunterladen\",\n        \"Format:\": \"Format:\",\n        \"Learn more\": \"Mehr erfahren\",\n        \"download attachments\": \"Anhänge herunterladen\",\n        \".zip\": \".zip\",\n        \"Download full document and history\": \"Vollständiges Dokument und Geschichte herunterladen\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Anhänge sind extern und nicht in diesem Download enthalten. Wenn Sie das Dokument auf eine separate Grist-Installation hochladen, müssen Sie auch {{downloadLink}} separat aufrufen. \"\n    },\n    \"NTextBox\": {\n        \"false\": \"falsch\",\n        \"true\": \"wahr\",\n        \"Lines\": \"Linien\",\n        \"Field Format\": \"Feldformat\",\n        \"Multi line\": \"Mehrere Linien\",\n        \"Single line\": \"Einzelne Linie\"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"Bitte um Hilfe\",\n        \"Cannot find personal site, sorry!\": \"Ich kann die persönliche Seite nicht finden, tut mir leid!\",\n        \"Give feedback\": \"Feedback geben\",\n        \"Go to your free personal site\": \"Gehen Sie zu Ihrer kostenlosen persönlichen Seite\",\n        \"No notifications\": \"Keine Benachrichtigungen\",\n        \"Notifications\": \"Benachrichtigungen\",\n        \"Renew\": \"Erneuern\",\n        \"Report a problem\": \"Ein Problem melden\",\n        \"Upgrade Plan\": \"Upgrade-Plan\",\n        \"Manage billing\": \"Abrechnung verwalten\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Beenden\",\n        \"Next\": \"Weiter\",\n        \"Previous\": \"Vorherige\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Grist Video Tour\",\n        \"Video Tour\": \"Video-Tour\",\n        \"YouTube video player\": \"YouTube-Video-Player\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Zur Seite hinzufügen\",\n        \"Building {{- label}} widget\": \"{{- label}}-Widget erstellen\",\n        \"Group by\": \"Gruppieren nach\",\n        \"Select data\": \"Daten auswählen\",\n        \"Select widget\": \"Widget auswählen\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Löschen\",\n        \"Delete data and this page.\": \"Daten und diese Seite löschen.\",\n        \"The following table will no longer be visible\": \"Die folgende Tabelle wird nicht mehr angezeigt\",\n        \"The following tables will no longer be visible\": \"Die folgenden Tabellen werden nicht mehr sichtbar sein\",\n        \"The following tables will no longer be visible_one\": \"Die folgende Tabelle wird nicht mehr angezeigt\",\n        \"The following tables will no longer be visible_other\": \"Die folgenden Tabellen werden nicht mehr angezeigt\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Daten behalten und Seite löschen. Die Tabelle bleibt verfügbar in {{rawDataLink}}\",\n        \"raw data page\": \"Rohdaten-Seite\",\n        \"Document pages\": \"Seiten des Dokuments\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Alle zulassen\",\n        \"Deny all\": \"Alle ablehnen\",\n        \"Read only\": \"Schreibgeschützt\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Der Import ist fehlgeschlagen: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Aktualisieren des Datensatzlayouts.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Feld hinzufügen\",\n        \"Cancel\": \"Abbrechen\",\n        \"Create new field\": \"Neues Feld erstellen\",\n        \"Save layout\": \"Layout speichern\",\n        \"Show field {{- label}}\": \"Feld anzeigen {{- label}}\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Spalte hinzufügen\",\n        \"No columns to add\": \"Keine Spalten zum Hinzufügen\"\n    },\n    \"RightPanel\": {\n        \"CHART TYPE\": \"DIAGRAMMTYP\",\n        \"COLUMN TYPE\": \"SPALTENTYP\",\n        \"CUSTOM\": \"ANGEPASST\",\n        \"Change widget\": \"Widget ändern\",\n        \"Column\": \"Spalte\",\n        \"Columns\": \"Spalten\",\n        \"DATA TABLE\": \"DATENTABELLE\",\n        \"DATA TABLE NAME\": \"NAME DER DATENTABELLE\",\n        \"Data\": \"Daten\",\n        \"Detach\": \"Ablösen\",\n        \"Edit data selection\": \"Datenauswahl bearbeiten\",\n        \"FILTER\": \"FILTER\",\n        \"Field\": \"Feld\",\n        \"Fields\": \"Felder\",\n        \"GROUPED BY\": \"GRUPPIERT NACH\",\n        \"Row style\": \"Zeilenstil\",\n        \"SELECT BY\": \"AUSWÄHLEN NACH\",\n        \"SELECTOR FOR\": \"SELEKTOR FÜR\",\n        \"SORT\": \"SORTIEREN\",\n        \"SOURCE DATA\": \"QUELLENDATEN\",\n        \"Save\": \"Speichern\",\n        \"Select widget\": \"Widget auswählen\",\n        \"Series\": \"Serie\",\n        \"Sort & filter\": \"Sortieren & Filtern\",\n        \"TRANSFORM\": \"UMWANDELN\",\n        \"Theme\": \"Thema\",\n        \"WIDGET TITLE\": \"WIDGET-TITEL\",\n        \"Widget\": \"Widget\",\n        \"You do not have edit access to this document\": \"Sie haben keinen Bearbeitungszugriff auf dieses Dokument\",\n        \"series_other\": \"Serien\",\n        \"series_one\": \"Serien\",\n        \"columns_one\": \"Spalte\",\n        \"columns_other\": \"Spalten\",\n        \"fields_one\": \"Feld\",\n        \"fields_other\": \"Felder\",\n        \"Add referenced columns\": \"Referenzspalten hinzufügen\",\n        \"Reset form\": \"Formular zurücksetzen\",\n        \"Enter text\": \"Text eingeben\",\n        \"Layout\": \"Layout\",\n        \"Submission\": \"Einreichung\",\n        \"Redirect automatically after submission\": \"Nach Eingabe automatisch umleiten\",\n        \"Redirection\": \"Umleitung\",\n        \"Submit another response\": \"Eine weitere Antwort einreichen\",\n        \"Required field\": \"Erforderliches Feld\",\n        \"Table column name\": \"Name der Spalte in der Tabelle\",\n        \"Enter redirect URL\": \"Weiterleitungs-URL eingeben\",\n        \"Display button\": \"Anzeigetaste\",\n        \"Field rules\": \"Feldregeln\",\n        \"Success text\": \"Erfolgstext\",\n        \"Configuration\": \"Konfiguration\",\n        \"Default field value\": \"Standard-Feldwert\",\n        \"Field title\": \"Feldtitel\",\n        \"Hidden field\": \"Verborgenes Feld\",\n        \"Submit button label\": \"Beschriftung der Schaltfläche Senden\",\n        \"No field selected\": \"Kein Feld ausgewählt\",\n        \"Select a field in the form widget to configure.\": \"Wählen Sie ein Feld im Formular Widget aus, um es zu konfigurieren.\",\n        \"Submit\": \"Einreichen\",\n        \"Thank you! Your response has been recorded.\": \"Vielen Dank! Ihre Antwort wurde registriert.\",\n        \"Chart options\": \"Tabellenoptionen\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"Ankerlink kopieren\",\n        \"Delete\": \"Löschen\",\n        \"Duplicate row\": \"Zeile duplizieren\",\n        \"Duplicate rows\": \"Zeilen duplizieren\",\n        \"Insert row\": \"Zeile einfügen\",\n        \"Insert row above\": \"Zeile oben einfügen\",\n        \"Insert row below\": \"Zeile unten einfügen\",\n        \"Duplicate rows_one\": \"Zeile duplizieren\",\n        \"Duplicate rows_other\": \"Zeilen duplizieren\",\n        \"View as card\": \"Ansicht als Karte\",\n        \"Use as table headers\": \"Verwendung als Tabellenüberschriften\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"In die Zwischenablage kopiert\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"Zugangsdetails\",\n        \"Back to current\": \"Zurück zu Aktuell\",\n        \"Compare to {{termToUse}}\": \"Vergleichen mit {{termToUse}}\",\n        \"Current Version\": \"Aktuelle Version\",\n        \"Download\": \"Herunterladen\",\n        \"Duplicate document\": \"Dokument duplizieren\",\n        \"Edit without affecting the original\": \"Bearbeiten, ohne das Original zu beeinflussen\",\n        \"Export CSV\": \"CSV exportieren\",\n        \"Export XLSX\": \"XLSX exportieren\",\n        \"Manage users\": \"Benutzer verwalten\",\n        \"Original\": \"Original\",\n        \"Replace {{termToUse}}...\": \"Ersetzen Sie {{termToUse}}…\",\n        \"Return to {{termToUse}}\": \"Zurück zu {{termToUse}}\",\n        \"Save copy\": \"Kopie speichern\",\n        \"Save Document\": \"Dokument speichern\",\n        \"Send to Google Drive\": \"An Google Drive senden\",\n        \"Show in folder\": \"Im Ordner anzeigen\",\n        \"Unsaved\": \"Ungespeichert\",\n        \"Work on a copy\": \"Arbeiten an einer Kopie\",\n        \"Share\": \"Teilen\",\n        \"Download...\": \"Herunterladen...\",\n        \"Export as...\": \"Exportieren als...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Tabulatorgetrennte Werte (.tsv)\",\n        \"Comma Separated Values (.csv)\": \"Kommagetrennte Werte (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"DOO-getrennte Werte (.dsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Das Exportieren ist nur von Dokumentenseiten aus möglich. Bitte wählen Sie eine Dokumentenseite und versuchen Sie es erneut.\",\n        \"Download attachments...\": \"Anhänge herunterladen...\",\n        \"Download document...\": \"Dokument herunterladen...\",\n        \"Suggest changes\": \"Änderungen vorschlagen\",\n        \"current version\": \"aktuelle Version\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Neue Teamseite erstellen\",\n        \"Switch Sites\": \"Seiten wechseln\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Spalte hinzufügen\",\n        \"Empty values last\": \"Leere Werte zuletzt\",\n        \"Natural sort\": \"Natürlich Sortieren\",\n        \"Update data\": \"Daten aktualisieren\",\n        \"Use choice position\": \"Auswahlposition verwenden\",\n        \"Search Columns\": \"Spalten suchen\",\n        \"{{- columnName }} column\": \"{{- columnName }} Spalte\"\n    },\n    \"SortFilterConfig\": {\n        \"FILTER\": \"FILTER\",\n        \"Revert\": \"Zurücksetzen\",\n        \"SORT\": \"SORTIEREN\",\n        \"Save\": \"Speichern\",\n        \"Update Sort & Filter settings\": \"Sortier- und Filtereinstellungen aktualisieren\",\n        \"Sort\": \"SORTIEREN\",\n        \"Filter\": \"FILTER\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Aussehen \",\n        \"Switch appearance automatically to match system\": \"Schalten Sie das Aussehen automatisch auf das System\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Zugriffsregeln\",\n        \"Code view\": \"Code-Ansicht\",\n        \"Delete\": \"Löschen\",\n        \"Delete document tour?\": \"Dokument-Tour löschen?\",\n        \"Document history\": \"Dokumentenverlauf\",\n        \"How-to Tutorial\": \"Anleitungs-Tutorial\",\n        \"Raw data\": \"Rohdaten\",\n        \"Return to viewing as yourself\": \"Zurück zur Selbstdarstellung\",\n        \"TOOLS\": \"WERKZEUGE\",\n        \"Tour of this Document\": \"Tour durch dieses Dokument\",\n        \"Validate Data\": \"Daten validieren\",\n        \"Settings\": \"Einstellungen\",\n        \"API console\": \"API-Konsole\",\n        \"context menu - Access Rules\": \"Kontextmenü - Zugriffsregeln\",\n        \"Delete document tour\": \"Dokumententour löschen\",\n        \"Preview the tutorial\": \"Vorschau des Tutorials\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Team verwalten\"\n    },\n    \"TriggerFormulas\": {\n        \"(data cleaning)\": \"(Datenreinigung)\",\n        \"(except formulas)\": \"(außer Formeln)\",\n        \"Any field\": \"Beliebiges Feld\",\n        \"Apply on changes to:\": \"Übernehmen bei Änderungen an:\",\n        \"Apply on record changes\": \"Auf Datensatzänderungen anwenden\",\n        \"Apply to new records\": \"Auf neue Datensätze anwenden\",\n        \"Cancel\": \"Abbrechen\",\n        \"Close\": \"Schließen\",\n        \"Current field \": \"Aktuelles Feld \",\n        \"OK\": \"OK\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Anwenden\",\n        \"Cancel\": \"Abbrechen\",\n        \"Preview\": \"Vorschau\",\n        \"Update formula (Shift+Enter)\": \"Formel aktualisieren (Umschalttaste+Eingabetaste)\",\n        \"Revise\": \"Überarbeiten\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Redakteur\",\n        \"In full\": \"Vollständig\",\n        \"No Default Access\": \"Kein Standardzugriff\",\n        \"None\": \"Keine\",\n        \"Owner\": \"Eigentümer\",\n        \"View & edit\": \"Anzeigen & Bearbeiten\",\n        \"View only\": \"Nur anzeigen\",\n        \"Viewer\": \"Betrachter\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Regel {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Formel aktualisieren (Umschalttaste+Eingabetaste)\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Erweiterte Einstellungen\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Große Tabellen können als \\\"auf-Befehl\\\" markiert werden, damit sie nicht in die Datenmaschine geladen werden müssen.\",\n        \"Blocks\": \"Blöcke\",\n        \"Compact\": \"Kompakt\",\n        \"Edit card layout\": \"Kartenlayout bearbeiten\",\n        \"Form\": \"Formular\",\n        \"If you make table {{table}} On-Demand, its data will no longer be loaded into the calculation engine and will not be available for use in formulas. It will remain available for viewing and editing.\": \"Wenn Sie die Tabelle {{table}} auf-Befehl machen, werden ihre Daten nicht mehr in die Berechnungsmaschine geladen und können nicht mehr in Formeln verwendet werden. Sie bleibt jedoch für die Anzeige und Bearbeitung verfügbar.\",\n        \"If you unmark table {{- table}}' as On-Demand, its data will be loaded into the calculation engine and will be available for use in formulas. For a big table, this may greatly increase load times.{{- br}}{{-br}}Changing this setting will reload the document for all users.\": \"Wenn Sie die Markierung der Tabelle {{- table}}' als \\\"auf-Befehl\\\" aufheben, werden die Daten in die Berechnungsmaschine geladen und stehen für die Verwendung in Formeln zur Verfügung. Bei einer großen Tabelle kann dies die Ladezeiten erheblich verlängern.{{- br}}{{-br}}Wenn Sie diese Einstellung ändern, wird das Dokument für alle Benutzer neu geladen.\",\n        \"Make On-Demand\": \"Auf-Befehl machen\",\n        \"Make table On-Demand?\": \"Tabelle als auf-Befehl machen?\",\n        \"Plugin: \": \"Plugin: \",\n        \"Section: \": \"Abschnitt: \",\n        \"Unmark On-Demand\": \"Markierung auf-Befehl aufheben\",\n        \"Unmark table On-Demand?\": \"Markierung der Tabelle auf-Befehl aufheben?\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Veraltete Funktion\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"On-Demand-Tabellen wurden aufgrund mangelnder Funktionalität und aus Gründen der Benutzerfreundlichkeit abgeschafft.\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Erweitertes Sortieren & Filtern\",\n        \"Copy anchor link\": \"Ankerlink kopieren\",\n        \"Data selection\": \"Datenauswahl\",\n        \"Delete record\": \"Datensatz löschen\",\n        \"Delete widget\": \"Widget löschen\",\n        \"Download as CSV\": \"Als CSV herunterladen\",\n        \"Download as XLSX\": \"Als XLSX herunterladen\",\n        \"Edit card layout\": \"Kartenlayout bearbeiten\",\n        \"Open configuration\": \"Konfiguration öffnen\",\n        \"Print widget\": \"Widget drucken\",\n        \"Show raw data\": \"Rohdaten anzeigen\",\n        \"Widget options\": \"Widget Optionen\",\n        \"Add to page\": \"Zur Seite hinzufügen\",\n        \"Collapse widget\": \"Widget einklappen\",\n        \"Create a form\": \"Formular erstellen\",\n        \"Duplicate widget\": \"Widget Duplizieren\"\n    },\n    \"ViewSectionMenu\": {\n        \"(customized)\": \"(angepasst)\",\n        \"(empty)\": \"(leer)\",\n        \"(modified)\": \"(modifiziert)\",\n        \"Add Filter\": \"Filter hinzufügen\",\n        \"Custom options\": \"Benutzerdefinierte Optionen\",\n        \"FILTER\": \"FILTER\",\n        \"Filtered by\": \"Gefiltert nach\",\n        \"Revert\": \"Zurücksetzen\",\n        \"SORT\": \"SORTIEREN\",\n        \"Save\": \"Speichern\",\n        \"Sorted by\": \"Sortiert nach\",\n        \"Toggle Filter Bar\": \"Filterleiste ein-/ausblenden\",\n        \"Update Sort&Filter settings\": \"Sortier- und Filtereinstellungen aktualisieren\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"Elemente können nicht in ausgeblendeten Feldern abgelegt werden\",\n        \"Clear\": \"Klären\",\n        \"Hidden Fields cannot be reordered\": \"Ausgeblendete Felder können nicht neu sortiert werden\",\n        \"Select all\": \"Alle auswählen\",\n        \"Hide {{label}}\": \"{{label}} ausblenden\",\n        \"Hidden {{label}}\": \"{{label}} Versteckt\",\n        \"Show {{label}}\": \"{{label}} anzeigen\",\n        \"Visible {{label}}\": \"Sichtbar {{label}}\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Bildung\",\n        \"Finance & Accounting\": \"Finanzbuchhaltung\",\n        \"HR & Management\": \"Personal und Management\",\n        \"IT & Technology\": \"IT und Technologie\",\n        \"Marketing\": \"Vermarktung\",\n        \"Media Production\": \"Medienproduktion\",\n        \"Other\": \"Sonstiges\",\n        \"Product Development\": \"Produktentwicklung\",\n        \"Research\": \"Forschung\",\n        \"Sales\": \"Umsatz\",\n        \"Type here\": \"Hier tippen\",\n        \"Welcome to Grist!\": \"Willkommen bei Grist!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Was bringt Sie zu Grist? Bitte helfen Sie uns, Sie besser zu bedienen.\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"Abbrechen\",\n        \"DATA TABLE NAME\": \"NAME DER DATENTABELLE\",\n        \"Override widget title\": \"Widget-Titel überschreiben\",\n        \"Provide a table name\": \"Geben Sie einen Tabellennamen an\",\n        \"Save\": \"Speichern\",\n        \"WIDGET TITLE\": \"WIDGET TITEL\",\n        \"WIDGET DESCRIPTION\": \"WIDGET-BESCHREIBUNG\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Sie können Änderungen vornehmen, die jedoch eine neue Kopie erstellen und\\ndas Originaldokument nicht beeinflussen.\",\n        \"fiddle\": \"spielen\",\n        \"override\": \"überschreiben\",\n        \"recovery mode\": \"Wiederherstellungsmodus\",\n        \"snapshot\": \"Schnappschuss\",\n        \"unsaved\": \"ungespeichert\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Seite duplizieren {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Beachten Sie, dass dabei keine Daten kopiert werden, sondern eine andere Ansicht der gleichen Daten erstellt wird.\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Zugriff verweigert{{suffix}}\",\n        \"Add account\": \"Konto hinzufügen\",\n        \"Contact support\": \"Kontaktieren Sie Support\",\n        \"Error{{suffix}}\": \"Fehler{{suffix}}\",\n        \"Go to main page\": \"Zur Hauptseite gehen\",\n        \"Page not found{{suffix}}\": \"Seite nicht gefunden{{suffix}}\",\n        \"Sign in\": \"Anmelden\",\n        \"Sign in again\": \"Erneut anmelden\",\n        \"Sign in to access this organization's documents.\": \"Melden Sie sich an, um auf die Dokumente dieser Organisation zuzugreifen.\",\n        \"Signed out{{suffix}}\": \"Abgemeldet{{suffix}}\",\n        \"Something went wrong\": \"Etwas ist schief gelaufen\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Die angeforderte Seite konnte nicht gefunden werden.{{separator}}Bitte überprüfen Sie die URL und versuchen Sie es erneut.\",\n        \"There was an error: {{message}}\": \"Fehler aufgetreten: {{message}}\",\n        \"There was an unknown error.\": \"Es ist ein unbekannter Fehler aufgetreten.\",\n        \"You are now signed out.\": \"Sie sind jetzt abgemeldet.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Sie sind als {{email}} angemeldet. Sie können sich mit einem anderen Konto anmelden oder einen Administrator um Zugriff bitten.\",\n        \"You do not have access to this organization's documents.\": \"Sie haben keinen Zugriff auf die Dokumente dieser Organisation.\",\n        \"Account deleted{{suffix}}\": \"Konto gelöscht{{suffix}}\",\n        \"Your account has been deleted.\": \"Ihr Konto wurde gelöscht.\",\n        \"Sign up\": \"Anmelden\",\n        \"An unknown error occurred.\": \"Ein unbekannter Fehler ist aufgetreten.\",\n        \"Powered by\": \"Angetrieben durch\",\n        \"Build your own form\": \"Erstellen Sie Ihr eigenes Formular\",\n        \"Form not found\": \"Formular nicht gefunden\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Die Anmeldung ist fehlgeschlagen.{{separator}}Bitte versuchen Sie es erneut oder kontaktieren Sie den Support.\",\n        \"Sign-in failed{{suffix}}\": \"Anmelden fehlgeschlagen{{suffix}}\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* Arbeitsbereiche sind in Teamplänen verfügbar. \",\n        \"Select fields\": \"Felder auswählen\",\n        \"Upgrade now\": \"Jetzt aktualisieren\",\n        \"Numeric\": \"Numerisch\",\n        \"DateTime\": \"DatumUhrzeit\",\n        \"Choice List\": \"Auswahlliste\",\n        \"Choice\": \"Auswahl\",\n        \"Reference\": \"Referenz\",\n        \"Reference List\": \"Referenzliste\",\n        \"Attachment\": \"Anhang\",\n        \"Any\": \"Jegliche\",\n        \"Text\": \"Text\",\n        \"Integer\": \"Ganze Zahl\",\n        \"Toggle\": \"Umschalten\",\n        \"Date\": \"Datum\",\n        \"Search columns\": \"Spalten suchen\",\n        \"By Name\": \"Nach Name\",\n        \"Custom\": \"Benutzerdefiniert\",\n        \"Light\": \"Hell\",\n        \"By Date Modified\": \"Nach Änderungsdatum\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Abbrechen\",\n        \"Ok\": \"OK\",\n        \"Save\": \"Speichern\",\n        \"Delete\": \"Löschen\",\n        \"Are you sure you want to delete these records?\": \"Sind Sie sicher, dass Sie diese Datensätze löschen wollen?\",\n        \"Are you sure you want to delete this record?\": \"Sind Sie sicher, dass Sie diesen Datensatz löschen wollen?\",\n        \"Undo to restore\": \"Rückgängig machen zum Wiederherstellen\",\n        \"Don't show again\": \"Nicht mehr anzeigen\",\n        \"Got it\": \"Verstanden\",\n        \"Dismiss\": \"Ablehnen\",\n        \"Don't ask again.\": \"Frag nicht mehr.\",\n        \"Don't show again.\": \"Zeig nicht mehr.\",\n        \"Don't show tips\": \"Keine Tipps anzeigen\",\n        \"TIP\": \"TIPP\",\n        \"Confirm\": \"Bestätigen\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Seite duplizieren\",\n        \"Remove\": \"Entfernen\",\n        \"Rename\": \"Umbenennen\",\n        \"You do not have edit access to this document\": \"Sie haben keinen Bearbeitungszugriff auf dieses Dokument\",\n        \"(default)\": \"(Standard)\",\n        \"Collapse {{maybeDefault}}\": \"Einklappen {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Erweitern {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Standard festlegen: Zusammenklappen\",\n        \"Set default: Expand\": \"Standard festlegen: Erweitern\",\n        \"context menu - {{- pageName }}\": \"Kontextmenü - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Find Next \": \"Nächstes finden \",\n        \"Find Previous \": \"Vorheriges finden \",\n        \"No results\": \"Keine Ergebnisse\",\n        \"Search in document\": \"Suche im Dokument\",\n        \"Search\": \"Suchen\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Datei an Google Drive senden\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Unbekannter Benutzer\",\n        \"You are viewing this document as\": \"Sie sehen dieses Dokument als\",\n        \"View as Yourself\": \"Als Sie selbst sehen\",\n        \"You're seeing what this user would see if given access\": \"Sie sehen, was dieser Benutzer sehen würde, wenn er Zugang hätte\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Anzeigen als\",\n        \"Users from table\": \"Benutzer aus der Tabelle\",\n        \"Example Users\": \"Beispiel Benutzer\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Spalten suchen\",\n        \"Search Columns\": \"Spalten suchen\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"Beispiel Benutzer\",\n        \"Users from table\": \"Benutzer aus der Tabelle\",\n        \"View as\": \"Anzeigen als\",\n        \"Other users from table\": \"Andere Benutzer aus der Tabelle\",\n        \"Shared users\": \"Gemeinsame Nutzer\"\n    },\n    \"TypeTransformation\": {\n        \"Update formula (Shift+Enter)\": \"Formel aktualisieren (Umschalttaste+Eingabetaste)\",\n        \"Cancel\": \"Abbrechen\",\n        \"Revise\": \"Überarbeiten\",\n        \"Preview\": \"Vorschau\",\n        \"Apply\": \"Anwenden\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"ZELLENSTIL\",\n        \"Open row styles\": \"Zeilenstile öffnen\",\n        \"Cell style\": \"Zellenstil\",\n        \"Default cell style\": \"Standard-Zellenstil\",\n        \"Mixed style\": \"Gemischter Stil\",\n        \"Default header style\": \"Standard Kopfzeilenstil\",\n        \"Header Style\": \"Kopfzeilenstil\",\n        \"HEADER STYLE\": \"KOPFSTIL\"\n    },\n    \"DiscussionEditor\": {\n        \"Resolve\": \"Beschließen\",\n        \"Save\": \"Speichern\",\n        \"Show resolved comments\": \"Gelöste Kommentare anzeigen\",\n        \"Only my threads\": \"Nur meine Fäden\",\n        \"Open\": \"Öffnen\",\n        \"Remove\": \"Entfernen\",\n        \"Reply to a comment\": \"Auf einen Kommentar antworten\",\n        \"Reply\": \"Antwort\",\n        \"Comment\": \"Kommentar\",\n        \"Edit\": \"Bearbeiten\",\n        \"Only current page\": \"Nur aktuelle Seite\",\n        \"Started discussion\": \"Begonnene Diskussion\",\n        \"Write a comment\": \"Schreiben Sie einen Kommentar\",\n        \"Marked as resolved\": \"Markiert als gelöst\",\n        \"Cancel\": \"Abbrechen\",\n        \"Showing last {{nb}} comments\": \"Letzte {{nb}} Kommentare anzeigen\",\n        \"Remove thread\": \"Thema entfernen\",\n        \"updated\": \"aktualisiert\",\n        \"Copy link\": \"Link kopieren\",\n        \"{{count}} comments_one\": \"{{count}} Kommentar\",\n        \"{{count}} comments_other\": \"{{count}} Kommentare\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN DESCRIPTION\": \"SPALTENBESCHREIBUNG\",\n        \"COLUMN ID: \": \"SPALTEN-ID: \",\n        \"Save\": \"Speichern\",\n        \"COLUMN LABEL\": \"SPALTENBEZEICHNUNG\",\n        \"Cancel\": \"Abbrechen\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"AUSWAHLMÖGLICHKEITEN\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"SPALTENBESCHREIBUNG\",\n        \"COLUMN LABEL\": \"SPALTENBEZEICHNUNG\"\n    },\n    \"ConditionalStyle\": {\n        \"Add conditional style\": \"Bedingten Stil hinzufügen\",\n        \"Error in style rule\": \"Fehler in der Stilregel\",\n        \"Rule must return True or False\": \"Regel muss wahr oder falsch zurückgeben\",\n        \"Add another rule\": \"Eine weitere Regel hinzufügen\",\n        \"Row style\": \"Zeilenstil\",\n        \"IF...\": \"WENN...\",\n        \"Conditional Style\": \"Bedingter Stil\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Ungültige Währung\"\n    },\n    \"FieldBuilder\": {\n        \"Changing multiple column types\": \"Mehrere Spaltentypen ändern\",\n        \"Mixed format\": \"Gemischtes Format\",\n        \"Mixed types\": \"Gemischte Typen\",\n        \"Revert field settings for {{colId}} to common\": \"Feldeinstellungen für {{colId}} auf \\\"Allgemein\\\" zurücksetzen\",\n        \"Apply formula to data\": \"Formel auf Daten anwenden\",\n        \"CELL FORMAT\": \"ZELLENFORMAT\",\n        \"DATA FROM TABLE\": \"DATEN AUS TABELLE\",\n        \"Save field settings for {{colId}} as common\": \"Feldeinstellungen für {{colId}} als Algemein speichern\",\n        \"Use separate field settings for {{colId}}\": \"Verwenden Sie separate Feldeinstellungen für {{colId}}\",\n        \"Changing column type\": \"Ändern des Spaltentyps\",\n        \"Common\": \"Allgemein\",\n        \"Separate\": \"Getrennt\",\n        \"Field in {{count}} views_one\": \"Feld in einer Ansicht\",\n        \"Field in {{count}} views_other\": \"Feld in {{count}} Ansichten\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Spalte in Formel umwandeln\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Spalte oder Feld ist erforderlich\",\n        \"Error in the cell\": \"Fehler in der Zelle\",\n        \"Errors in all {{numErrors}} cells\": \"Fehler in allen {{numErrors}} Zellen\",\n        \"editingFormula is required\": \"Bearbeitungsformel ist erforderlich\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Fehler in {{numErrors}} von {{numCells}} Zellen\",\n        \"Enter formula or {{button}}.\": \"Formel eingeben oder {{button}}.\",\n        \"Enter formula.\": \"Formel eingeben.\",\n        \"Expand Editor\": \"Editor erweitern\",\n        \"use AI Assistant\": \"Verwenden Sie den KI-Assistenten\"\n    },\n    \"Reference\": {\n        \"SHOW COLUMN\": \"SPALTE ANZEIGEN\",\n        \"CELL FORMAT\": \"ZELLENFORMAT\",\n        \"Row ID\": \"Zeilen-ID\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[Linkbezeichnung] URL\"\n    },\n    \"WelcomeTour\": {\n        \"Add new\": \"Neu hinzufügen\",\n        \"Building up\": \"Zubauend\",\n        \"Configuring your document\": \"Konfigurieren Ihres Dokuments\",\n        \"Help Center\": \"Hilfe-Center\",\n        \"Sharing\": \"Teilen\",\n        \"Welcome to Grist!\": \"Willkommen bei Grist!\",\n        \"Enter\": \"Eingabe\",\n        \"Customizing columns\": \"Anpassen von Spalten\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Durchsuchen Sie unsere {{templateLibrary}}, um zu entdecken, was möglich ist und sich inspirieren lassen.\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Doppelklicken oder {{enter}} auf eine Zelle drücken, um sie zu bearbeiten. \",\n        \"Editing Data\": \"Bearbeiten von Daten\",\n        \"Flying higher\": \"Höher fliegen\",\n        \"Share\": \"Teilen\",\n        \"Reference\": \"Referenz\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Machen Sie es relativ! Verwenden Sie den {{ref}}-Typ, um Tabellen zu verlinken. \",\n        \"Start with {{equal}} to enter a formula.\": \"Beginnen Sie mit {{equal}}, um eine Formel einzugeben.\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Legen Sie Formatierungsoptionen, Formeln oder Spaltentypen fest, z. B. Daten, Auswahlmöglichkeiten oder Anhänge. \",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Schalten Sie die {{creatorPanel}} an, um Spalten zu formatieren, \",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Verwenden Sie die Schaltfläche Teilen ({{share}}), um das Dokument zu teilen oder Daten zu exportieren.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Verwenden Sie {{addNew}}, um Widgets und Seiten hinzuzufügen oder weitere Daten zu importieren. \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Verwenden Sie {{helpCenter}} für Dokumentation oder Fragen.\",\n        \"convert to card view, select data, and more.\": \"in die Kartenansicht konvertieren, Daten auswählen und vieles mehr.\",\n        \"creator panel\": \"Ersteller-Panel\",\n        \"template library\": \"Vorlagenbibliothek\",\n        \"AI Assistant\": \"KI-Assistent\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"Währung\",\n        \"Decimals\": \"Dezimalstellen\",\n        \"Default currency ({{defaultCurrency}})\": \"Standardwährung ({{defaultCurrency}})\",\n        \"Number Format\": \"Zahlenformat\",\n        \"min\": \"min.\",\n        \"Text\": \"Text\",\n        \"max\": \"max.\",\n        \"Field Format\": \"Feldformat\",\n        \"Spinner\": \"Spinner\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"Es sollte unmöglich sein, einen einfachen Datenwert in eine Formelspalte zu speichern\",\n        \"Unable to finish saving edited cell\": \"Speichern der bearbeiteten Zelle kann nicht abgeschlossen werden\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Sprache\"\n    },\n    \"GristTooltips\": {\n        \"Learn more.\": \"Mehr erfahren.\",\n        \"Editing Card Layout\": \"Kartenlayout bearbeiten\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Zellen in einer Referenzspalte identifizieren immer einen {{entire}} Datensatz in dieser Tabelle, aber Sie können auswählen, welche Spalte aus diesem Datensatz angezeigt werden soll.\",\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Wenden Sie bedingte Formatierung auf Zellen in dieser Spalte an, wenn Formelbedingungen erfüllt sind.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Wenden Sie bedingte Formatierung auf Zeilen basierend auf Formeln an.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Klicken Sie auf „Zeilenstile öffnen“, um eine bedingte Formatierung auf Zeilen anzuwenden.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Klicken Sie auf die Schaltfläche Neu hinzufügen, um neue Dokumente oder Arbeitsbereiche zu erstellen oder Daten zu importieren.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Wenn Sie in jeder Zelle auf {{EyeHideIcon}} klicken, wird das Feld aus dieser Ansicht ausgeblendet, ohne es zu löschen.\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Formeln, die in bestimmten Fällen auslösen und den berechneten Wert als Daten speichern.\",\n        \"Link your new widget to an existing widget on this page.\": \"Verknüpfen Sie Ihr neues Widget mit einem bestehenden Widget auf dieser Seite.\",\n        \"Linking Widgets\": \"Widgets verknüpfen\",\n        \"Nested Filtering\": \"Verschachtelte Filterung\",\n        \"Pinning Filters\": \"Anheften von Filtern\",\n        \"Raw Data page\": \"Rohdatenseite\",\n        \"Select the table to link to.\": \"Wählen Sie die zu verknüpfende Tabelle aus.\",\n        \"Selecting Data\": \"Daten auswählen\",\n        \"The total size of all data in this document, excluding attachments.\": \"Die Gesamtgröße aller Daten in diesem Dokument, ohne Anhänge.\",\n        \"Updates every 5 minutes.\": \"Aktualisiert alle 5 Minuten.\",\n        \"relational\": \"relationale\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Lösen Sie die Anheftung, um die Schaltfläche auszublenden und den Filter beizubehalten.\",\n        \"Only those rows will appear which match all of the filters.\": \"Es werden nur die Zeilen angezeigt, die allen Filtern entsprechen.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Angeheftete Filter werden als Schaltflächen über dem Widget angezeigt.\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Ordnen Sie die Felder in Ihrer Karte neu an, indem Sie die Zellen ziehen und ihre Größe ändern.\",\n        \"Reference Columns\": \"Referenzspalten\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Referenzspalten sind der Schlüssel zu {{relational}} Daten in Grist.\",\n        \"Select the table containing the data to show.\": \"Wählen Sie die Tabelle mit den anzuzeigenden Daten aus.\",\n        \"Add new\": \"Neu hinzufügen\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Verwenden Sie das Symbol \\\\u{1D6BA}, um zusammenfassende (oder Pivot-)Tabellen für Summen oder Zwischensummen zu erstellen.\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Verwenden Sie das Symbol 𝚺, um zusammenfassende (oder Pivot-)Tabellen für Summen oder Zwischensummen zu erstellen.\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Auf der Rohdatenseite werden alle Datentabellen in Ihrem Dokument aufgelistet, einschließlich Zusammenfassungstabellen und Tabellen, die nicht in Seitenlayouts enthalten sind.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Nützlich zum Speichern des Zeitstempels oder Autors eines neuen Datensatzes, zur Datenbereinigung und mehr.\",\n        \"They allow for one record to point (or refer) to another.\": \"Sie ermöglichen es, dass ein Datensatz auf einen anderen zeigt (oder verweist).\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"Dies ist das Geheimnis für Grists dynamische und produktive Layouts.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Probieren Sie Änderungen in einer Kopie aus, dann entscheiden Sie, ob Sie das Original durch Ihre Bearbeitungen ersetzen.\",\n        \"You can filter by more than one column.\": \"Sie können nach mehr als einer Spalte filtern.\",\n        \"entire\": \"gesamte\",\n        \"Access Rules\": \"Zugriffsregeln\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Zugriffsregeln geben Ihnen die Möglichkeit, nuancierte Regeln zu erstellen, um festzulegen, wer welche Teile Ihres Dokuments sehen oder bearbeiten kann.\",\n        \"Anchor Links\": \"Anker-Links\",\n        \"Custom Widgets\": \"Benutzerdefinierte Widgets\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Sie können eines unserer vorgefertigten Widgets auswählen oder Ihr eigenes einbetten, indem Sie dessen vollständige URL angeben.\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Um einen Ankerlink zu erstellen, der den Benutzer zu einer bestimmten Zelle führt, klicken Sie auf eine Zeile und drücken Sie {{shortcut}}.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"Um Ihren Kalender zu konfigurieren, wählen Sie Spalten für das Start-/Enddatum und den Titel des Ereignisses. Beachten Sie den Typ jeder Spalte.\"\n        },\n        \"Calendar\": \"Kalender\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Sie können die richtigen Spalten nicht finden? Klicken Sie auf \\\"Widget ändern\\\", um die Tabelle mit den Ereignisdaten auszuwählen.\",\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"Eine UUID ist ein zufällig generierter String, der für eindeutige Kennungen und Linkschlüssel nützlich ist.\",\n        \"Lookups return data from related tables.\": \"Lookups geben Daten aus Bezugstabellen zurück.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Sie können aus Widgets wählen, die Ihnen im Dropdown zur Verfügung stehen, oder Sie selbst einbetten, indem Sie seine volle URL angeben.\",\n        \"Use reference columns to relate data in different tables.\": \"Verwenden Sie Referenzspalten, um Daten in verschiedenen Tabellen zu beziehen.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Die Formeln unterstützen viele Excel-Funktionen, die vollständige Python-Syntax und enthalten einen hilfreichen KI-Assistenten.\",\n        \"Learn more\": \"Mehr erfahren\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Erstellen Sie einfache Formulare direkt in Grist und teilen Sie sie mit einem Klick mit unserem neuen Widget. {{learnMoreButton}}\",\n        \"Forms are here!\": \"Die Formulare sind da!\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Diese Regeln werden angewendet, nachdem alle Spaltenregeln abgearbeitet wurden, falls zutreffend.\",\n        \"Example: {{example}}\": \"Beispiel: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"Filtern angezeigte Dropdown-Werte mit einer Bedingung.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Community Widgets werden von Mitgliedern der Grist Community erstellt und gepflegt.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Erzeugt eine umgekehrte Spalte in der Zieltabelle, die von beiden Seiten bearbeitet werden kann.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Um mehrere Zuordnungen zu ermöglichen, ändern Sie den Typ der Referenzspalte auf Referenzliste.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Um mehrere Zuordnungen zu ermöglichen, ändern Sie den Typ der referenzierten Spalte auf Referenzliste.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Diese Einschränkung tritt auf, wenn ein Ende einer Zwei-Wege-Referenz als Einzelreferenz konfiguriert ist.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Diese Einschränkung tritt auf, wenn eine Spalte in einer zweiseitigen Referenz den Typ Referenz hat.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Zwei-Wege-Referenzen werden derzeit nicht für Formel- oder Trigger-Formelspalten unterstützt\",\n        \"The preview below this header shows how the selected user will see this document\": \"Die Vorschau unter dieser Überschrift zeigt, wie der ausgewählte Benutzer dieses Dokument sehen wird\",\n        \"[Learn more.]({{link}})\": \"[Erfahren Sie mehr.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Übersichtstabellen können nur Formelspalten enthalten.\",\n        \"Manage users and resources in a Grist installation.\": \"Verwalten Sie Benutzer und Ressourcen in einer Grist-Installation.\",\n        \"The new Grist Assistant is here!\": \"Der neue Grist Assistant ist da!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Formeln unterstützen viele Excel-Funktionen und die vollständige Python-Syntax.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Erstellt eine neue Spalte \\\"Referenzliste\\\" in der Zieltabelle, wobei sowohl diese als auch die Zielspalten editierbar und synchronisiert sind.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Interne Speicherung bedeutet, dass alle Anhänge in der SQLite-Datei des Dokuments gespeichert werden, während externe Speicherung bedeutet, dass alle Anhänge in demselben externen Speicher gespeichert werden.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Damit können Sie Anhänge hinzufügen, die im externen Speicher fehlen, z.B. in einem importierten Dokument. Nur .tar-Anhangsarchive, die von Grist heruntergeladen wurden, können hier hochgeladen werden.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Verstehen, verändern und bearbeiten Sie Ihre Daten und Formeln mit Hilfe des neuen KI-Assistenten von Grist!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs. Do not submit passwords through this form, and be careful with links in it. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Dieses Formular wurde von einem Grist-Benutzer erstellt und wird nicht von Grist Labs gebilligt. Übermitteln Sie keine Passwörter über dieses Formular, und seien Sie vorsichtig mit den darin enthaltenen Links. Melden Sie bösartige Formulare an [{{mail}}](mailto:{{mail}}).\",\n        \"Set the maximum number of lines for multi-line text.\": \"Legen Sie die maximale Anzahl von Zeilen für mehrzeiligen Text fest.\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Dieses Formular wurde von einem Grist-Benutzer erstellt und wird nicht von Grist Labs, Inc. oder einer anderen Partei, die diesen Service anbietet, unterstützt. Geben Sie zu Ihrer Sicherheit keine Passwörter über dieses Formular ein, und seien Sie vorsichtig, wenn Sie auf eingebettete Links klicken. Melden Sie bösartige Formulare an [{{mail}}](mailto:{{mail}}).\",\n        \"Comments are here!\": \"Die Kommentare sind da!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Sie können Kommentare zu Zellen hinzufügen, auf Kommentar-Threads antworten und @-Mitarbeiter erwähnen.\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"BESCHREIBUNG\",\n        \"Set description\": \"Beschreibung festlegen\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"Ersteller-Panel schließen\",\n        \"Open creator panel\": \"Ersteller-Panel öffnen\",\n        \"Creator panel (right panel)\": \"Ersteller-Panel (rechtes Panel)\",\n        \"Document header\": \"Kopfzeile des Dokuments\",\n        \"Main content\": \"Hauptinhalt\",\n        \"Main navigation and document settings (left panel)\": \"Hauptnavigation und Dokument-Einstellungen (linker Panel)\",\n        \"Close navigation panel (left panel)\": \"Navigationsfeld schließen (linkes Panel)\",\n        \"Open navigation panel (left panel)\": \"Navigationsfeld öffnen (linkes Panel)\"\n    },\n    \"ColumnTitle\": {\n        \"Add description\": \"Beschreibung hinzufügen\",\n        \"COLUMN ID: \": \"SPALTEN-ID: \",\n        \"Cancel\": \"Abbrechen\",\n        \"Column description\": \"Spaltenbeschreibung\",\n        \"Provide a column label\": \"Geben Sie eine Spaltenbeschriftung an\",\n        \"Save\": \"Speichern\",\n        \"Column label\": \"Spaltenbeschriftung\",\n        \"Column ID copied to clipboard\": \"Spalten-ID in die Zwischenablage kopiert\",\n        \"Close\": \"Schließen\"\n    },\n    \"FieldContextMenu\": {\n        \"Copy anchor link\": \"Ankerlink kopieren\",\n        \"Cut\": \"Schneiden\",\n        \"Clear field\": \"Feld löschen\",\n        \"Hide field\": \"Feld ausblenden\",\n        \"Copy\": \"Kopieren\",\n        \"Paste\": \"Einfügen\",\n        \"Comment\": \"Kommentar\"\n    },\n    \"Clipboard\": {\n        \"Unavailable Command\": \"Nicht verfügbarer Befehl\",\n        \"Got it\": \"Verstanden\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"Der Menübefehl {{action}} ist in diesem Browser nicht verfügbar. Sie können {{action}} weiterhin mit dem Tastaturkürzel {{shortcut}} aufrufen.\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Warteschlange löschen\",\n        \"Webhook settings\": \"Webhook-Einstellungen\",\n        \"Enabled\": \"Aktiviert\",\n        \"Event Types\": \"Ereignisarten\",\n        \"Memo\": \"Memo\",\n        \"Name\": \"Name\",\n        \"Sorry, not all fields can be edited.\": \"Leider können nicht alle Felder bearbeitet werden.\",\n        \"Status\": \"Status\",\n        \"URL\": \"URL\",\n        \"Table\": \"Tabelle\",\n        \"Columns to check when update (separated by ;)\": \"Bei der Aktualisierung zu prüfende Spalten (getrennt durch ;)\",\n        \"Ready Column\": \"Fertige Spalte\",\n        \"Removed webhook.\": \"Der Webhook wurde entfernt.\",\n        \"Webhook Id\": \"Webhook Id\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Filter für Änderungen in diesen Spalten (durch Semikolon getrennte IDs)\",\n        \"Cleared webhook queue.\": \"Webhook-Warteschlange geleert.\",\n        \"Header Authorization\": \"Kopfzeilen-Autorisierung\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooks in nicht gespeicherten Dokumentenkopien nicht verfügbar\"\n    },\n    \"FormulaAssistant\": {\n        \"Ask the bot.\": \"Fragen Sie den Bot.\",\n        \"Capabilities\": \"Fähigkeiten\",\n        \"Community\": \"Gemeinschaft\",\n        \"Data\": \"Daten\",\n        \"Formula Cheat Sheet\": \"Formel-Spickzettel\",\n        \"Formula Help. \": \"Formel-Hilfe. \",\n        \"Function List\": \"Funktionsliste\",\n        \"Grist's AI Assistance\": \"Grists KI-Unterstützung\",\n        \"Grist's AI Formula Assistance. \": \"Grists KI-Formelunterstützung. \",\n        \"Need help? Our AI assistant can help.\": \"Brauchen Sie Hilfe? Unser KI-Assistent kann helfen.\",\n        \"New Chat\": \"Neuer Chat\",\n        \"Preview\": \"Vorschau\",\n        \"Regenerate\": \"Regenerieren\",\n        \"Save\": \"Speichern\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Weitere Informationen finden Sie unter {{helpFunction}} und {{formulaCheat}} oder besuchen Sie unsere {{community}} für weitere Hilfe.\",\n        \"Tips\": \"Tipps\",\n        \"Apply\": \"Anwenden\",\n        \"Cancel\": \"Abbrechen\",\n        \"AI Assistant\": \"KI-Assistent\",\n        \"Clear conversation\": \"Unterhaltung leeren\",\n        \"Code view\": \"Codeansicht\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Hallo, ich bin der KI-Assistent der Grist-Formel.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Ich kann nur bei Formeln helfen. Ich kann keine Tabellen, Spalten und Ansichten erstellen oder Zugriffsregeln schreiben.\",\n        \"Learn more\": \"Mehr erfahren\",\n        \"Press Enter to apply suggested formula.\": \"Drücken Sie die Eingabetaste, um die vorgeschlagene Formel anzuwenden.\",\n        \"Sign Up for Free\": \"Melden Sie sich kostenlos an\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Melden Sie sich für ein kostenloses Grist-Konto an, um den KI Formel Assistenten zu verwenden.\",\n        \"There are some things you should know when working with me:\": \"Es gibt einige Dinge, die Sie wissen sollten, wenn Sie mit mir arbeiten:\",\n        \"What do you need help with?\": \"Womit brauchen Sie Hilfe?\",\n        \"Formula AI Assistant is only available for logged in users.\": \"Der Formel-KI-Assistent ist nur für eingeloggte Benutzer verfügbar.\",\n        \"For higher limits, contact the site owner.\": \"Für höhere Grenzwerte wenden Sie sich bitte an den Eigentümer der Website.\",\n        \"upgrade to the Pro Team plan\": \"Upgrade auf den Pro Team Plan\",\n        \"You have used all available credits.\": \"Sie haben alle verfügbaren Kredite aufgebraucht.\",\n        \"upgrade your plan\": \"aktualisieren Sie Ihren Plan\",\n        \"You have {{numCredits}} remaining credits.\": \"Sie haben {{numCredits}} verbleibende Credits.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Für höhere Grenzwerte: {{upgradeNudge}}.\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Weitere Hilfe zu den Formeln finden Sie unter {{functionList}} und {{formulaCheatSheet}}, oder besuchen Sie unsere Website {{community}} für weitere Hilfe.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Wenn Sie mit mir sprechen, werden Ihre Fragen und die Struktur Ihres Dokuments (sichtbar in {{codeView}}) an OpenAI gesendet. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Sprechen Sie mit mir wie mit einem Menschen. Sie müssen keine Tabellen und Spaltennamen angeben. Sie können z. B. fragen: \\\"Bitte berechnen Sie den Gesamtbetrag der Rechnung\\\".\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Zum Einfügen klicken\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"Willkommen zurück\",\n        \"You can always switch sites using the account menu.\": \"Sie können jederzeit über das Kontomenü zwischen den Websites wechseln.\",\n        \"You have access to the following Grist sites.\": \"Sie haben Zugriff auf die folgenden Grist-Seiten.\"\n    },\n    \"SupportGristNudge\": {\n        \"Support Grist\": \"Grist Support\",\n        \"Close\": \"Schließen\",\n        \"Contribute\": \"Beitragen\",\n        \"Help Center\": \"Hilfe-Center\",\n        \"Opt in to Telemetry\": \"Melden Sie sich für Telemetrie an\",\n        \"Opted In\": \"Angemeldet\",\n        \"Support Grist page\": \"Support Grist-Seite\",\n        \"Admin Panel\": \"Administrations Bereich\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Wir danken Ihnen! Wir wissen Ihr Vertrauen und Ihre Unterstützung sehr zu schätzen. Sie können sich jederzeit unter {{link}} im Benutzermenü abmelden.\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub Sponsors page\": \"GitHub-Sponsorenseite\",\n        \"Help Center\": \"Hilfe-Center\",\n        \"Manage Sponsorship\": \"Sponsoring verwalten\",\n        \"Opt in to Telemetry\": \"Melden Sie sich für Telemetrie an\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Diese Instanz ist für Telemetrie aktiviert. Nur der Site-Administrator hat die Berechtigung, dies zu ändern.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Diese Instanz ist von der Telemetrie deaktiviert. Nur der Site-Administrator hat die Berechtigung, dies zu ändern.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Wir erfassen nur Nutzungsstatistiken, wie in unserem {{link}} beschrieben, jedoch niemals Inhalte der Dokumenten.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Sie können die Telemetrie jederzeit auf dieser Seite deaktivieren.\",\n        \"GitHub\": \"GitHub\",\n        \"Home\": \"Home\",\n        \"Opt out of Telemetry\": \"Deaktivieren Sie die Telemetrie\",\n        \"Sponsor Grist Labs on GitHub\": \"Sponsern Sie Grist Labs auf GitHub\",\n        \"Support Grist\": \"Grist Support\",\n        \"Telemetry\": \"Telemetrie\",\n        \"You have opted in to telemetry. Thank you!\": \"Sie haben sich für die Telemetrie entschieden. Vielen Dank!\",\n        \"You have opted out of telemetry.\": \"Sie haben sich von der Telemetrie abgemeldet.\",\n        \"Sponsor\": \"Sponsor\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Die Grist-Software wird von Grist Labs entwickelt, das kostenlose und kostenpflichtige gehostete Pläne anbietet. Wir stellen den Grist-Code auch unter einer standardmäßigen freien und offenen OSS-Lizenz (Apache 2.0) auf {{link}} zur Verfügung.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Unterstützen Sie Grist, indem Sie sich für die Telemetrie entscheiden, die uns hilft zu verstehen, wie das Produkt genutzt wird, so dass wir zukünftige Verbesserungen priorisieren können.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Wir sind ein kleines und entschlossenes Team. Ihre Unterstützung bedeutet uns sehr viel. Sie zeigt auch anderen, dass eine entschlossene Gemeinschaft hinter diesem Produkt steht.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Sie können die Open-Source-Entwicklung von Grist unterstützen, indem Sie uns auf unserer Website {{link}} sponsern.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No row selected in {{title}}\": \"Keine Zeile in {{title}} ausgewählt\",\n        \"Not all data is shown\": \"Es werden nicht alle Daten angezeigt\",\n        \"No data\": \"Keine Daten\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"BESCHREIBUNG\"\n    },\n    \"UserManager\": {\n        \"Anyone with link \": \"Jeder, der einen Link hat \",\n        \"Add {{member}} to your team\": \"{{member}} zu Ihrem Team hinzufügen\",\n        \"Cancel\": \"Abbrechen\",\n        \"Close\": \"Schließen\",\n        \"Collaborator\": \"Mitarbeiter\",\n        \"On\": \"An\",\n        \"Team member\": \"Teammitglied\",\n        \"member\": \"Mitglied\",\n        \"team site\": \"Teamseite\",\n        \"Allow anyone with the link to open.\": \"Erlauben Sie jedem, der über den Link verfügt, zu öffnen.\",\n        \"Confirm\": \"Bestätigen\",\n        \"Copy link\": \"Link kopieren\",\n        \"Grist support\": \"Grist Unterstützung\",\n        \"Guest\": \"Gast\",\n        \"Invite multiple\": \"Mehrere einladen\",\n        \"Invite people to {{resourceType}}\": \"Personen zu {{resourceType}} einladen\",\n        \"Link copied to clipboard\": \"Link in die Zwischenablage kopiert\",\n        \"Manage members of team site\": \"Mitglieder der Teamsite verwalten\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Kein Standardzugriff ermöglicht den Zugriff auf einzelne Dokumente oder Arbeitsbereiche und nicht auf die gesamte Teamwebsite.\",\n        \"Off\": \"Aus\",\n        \"Open Access Rules\": \"Zugangsregeln öffnen\",\n        \"Outside collaborator\": \"Externer Mitarbeiter\",\n        \"Public access\": \"Öffentlicher Zugang\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Der öffentliche Zugriff wird von {{parent}} vererbt. Um ihn zu entfernen, setzen Sie die Option \\\"Zugriff vererben\\\" auf \\\"Keine\\\".\",\n        \"Public access: \": \"Öffentlicher Zugang: \",\n        \"Remove my access\": \"Meinen Zugang entfernen\",\n        \"Save & \": \"Speichern & \",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Benutzer erbt Berechtigungen von {{Eltern})}. Zum Entfernen setzen Sie die Option \\\"Zugriff vererben\\\" auf \\\"Keine\\\".\",\n        \"Your role for this {{resourceType}}\": \"Ihre Rolle für diese {{resourceType}}\",\n        \"free collaborator\": \"Freier Mitarbeiter\",\n        \"guest\": \"Gast\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} von {{limitTop}} {{collaborator}}s\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Kein Standardzugriff ermöglicht den Zugriff auf einzelne Dokumente oder Arbeitsbereiche und nicht auf die gesamte Teamwebsite.\",\n        \"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.\": \"Der Benutzer hat Ansichtszugriff auf {{resource}}, der sich aus dem manuell festgelegten Zugriff auf die darin enthaltenen Ressourcen ergibt. Wenn dieser Benutzer hier entfernt wird, verliert er den Zugriff auf die darin enthaltenen Ressourcen.\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Der Benutzer erbt Berechtigungen von {{parent}}. Zum Entfernen setzen Sie die Option „Zugriff erben“ auf „Keine“.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Sie sind dabei, Ihren eigenen Zugriff auf dieses {{resourceType}} zu entfernen.\",\n        \"Create a team to share with more people\": \"Erstellen Sie ein Team, um es mit mehr Personen zu teilen\",\n        \"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}}.\": \"Sobald Sie Ihren eigenen Zugriff entfernt haben, können Sie ihn nicht mehr ohne die Hilfe einer anderen Person mit ausreichendem Zugriff auf den {{name}} wiederherstellen.\",\n        \"User may not modify their own access.\": \"Der Benutzer darf seinen eigenen Zugang nicht ändern.\",\n        \"Your role for this team site\": \"Ihre Rolle für diese Teamseite\",\n        \"{{collaborator}} limit exceeded\": \"{{collaborator}} Grenze überschritten\",\n        \"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}}.\": \"Sobald Sie Ihren eigenen Zugriff entfernt haben, können Sie ihn nicht ohne die Hilfe einer anderen Person mit ausreichendem Zugriff auf den {{resourceType}} wiederherstellen.\",\n        \"Inherit access: \": \"Zugang vererben: \",\n        \"Access overview\": \"Zur Übersicht\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Alle Seiten durchsuchen\",\n        \"Search all tables\": \"Alle Tabellen durchsuchen\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Editor ausblenden\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Maximieren\",\n        \"Minimize\": \"Minimieren\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Suchen\"\n    },\n    \"CardContextMenu\": {\n        \"Insert card above\": \"Karte oben einfügen\",\n        \"Duplicate card\": \"Karte duplizieren\",\n        \"Insert card below\": \"Karte unten einfügen\",\n        \"Delete card\": \"Karte löschen\",\n        \"Copy anchor link\": \"Ankerlink kopieren\",\n        \"Insert card\": \"Karte einfügen\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Ausgeblendete Felder\"\n    },\n    \"FormView\": {\n        \"Publish your form?\": \"Ihr Formular veröffentlichen?\",\n        \"Unpublish\": \"Unveröffentlichen\",\n        \"Unpublish your form?\": \"Ihr Formular unveröffentlichen?\",\n        \"Publish\": \"Veröffentlichen\",\n        \"Preview\": \"Vorschau\",\n        \"Reset\": \"Zurücksetzen\",\n        \"Share\": \"Teilen\",\n        \"Are you sure you want to reset your form?\": \"Sind Sie sicher, dass Sie Ihr Formular zurücksetzen möchten?\",\n        \"Reset form\": \"Formular zurücksetzen\",\n        \"Save your document to publish this form.\": \"Speichern Sie Ihr Dokument, um dieses Formular zu veröffentlichen.\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Jeder, der den unten stehenden Link anklickt, kann das leere Formular sehen und eine Antwort einreichen.\",\n        \"Code copied to clipboard\": \"Code in die Zwischenablage kopiert\",\n        \"Copy code\": \"Code kopieren\",\n        \"Embed this form\": \"Dieses Formular einbetten\",\n        \"Copy link\": \"Link kopieren\",\n        \"Link copied to clipboard\": \"Link in die Zwischenablage kopiert\",\n        \"Share this form\": \"Dieses Formular teilen\",\n        \"View\": \"Ansicht\",\n        \"# **Form Title**\": \"# **Formular Titel**\",\n        \"Your form description goes here.\": \"Hier wird Ihr Formular beschrieben.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"Wenn Sie Ihr Formular veröffentlichen, wird ein Link zum Teilen generiert. Jeder, der den Link hat, kann das leere Formular sehen und eine Antwort senden.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Wenn Sie die Veröffentlichung des Formulars aufheben, wird der Freigabelink deaktiviert, so dass Benutzer, die über diesen Link auf Ihr Formular zugreifen, eine Fehlermeldung erhalten.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Die Benutzer sind darauf beschränkt, Einträge (Datensätze in Ihrer Tabelle) zu übermitteln und voreingestellte Werte in bestimmten Feldern zu lesen, z. B. in Referenz- und Auswahlspalten.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Ihr Formular wird veröffentlicht. Jede Änderung ist live und für Benutzer mit Zugriff auf das Formular sichtbar. Wenn Sie Änderungen im Entwurf vornehmen möchten, heben Sie die Veröffentlichung des Formulars auf.\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Löschen\"\n    },\n    \"Menu\": {\n        \"Building blocks\": \"Bausteine\",\n        \"Unmapped fields\": \"Nicht zugeordnete Felder\",\n        \"Separator\": \"Abscheider\",\n        \"Header\": \"Kopfzeile\",\n        \"Cut\": \"Schneiden\",\n        \"Insert question above\": \"Frage oben einfügen\",\n        \"Insert question below\": \"Frage unten einfügen\",\n        \"Paragraph\": \"Absatz\",\n        \"Paste\": \"Einfügen\",\n        \"Columns\": \"Spalten\",\n        \"Copy\": \"Kopieren\",\n        \"New question\": \"Neue Frage\",\n        \"More\": \"Mehr\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Erstellen Sie Ihr eigenes Formular\",\n        \"Powered by\": \"Angetrieben durch\",\n        \"Powered by Grist\": \"Angetrieben von Grist\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Fehler\"\n    },\n    \"FormModel\": {\n        \"Oops! This form is no longer published.\": \"Huch! Dieses Formular wird nicht mehr veröffentlicht.\",\n        \"Oops! The form you're looking for doesn't exist.\": \"Huch! Das Formular, das Sie suchen, existiert nicht.\",\n        \"You don't have access to this form.\": \"Sie haben keinen Zugang zu diesem Formular.\",\n        \"There was a problem loading the form.\": \"Beim Laden des Formulars ist ein Problem aufgetreten.\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"kostenloser Coaching-Anruf\",\n        \"Schedule call\": \"Anruf planen\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Planen Sie Ihre {{freeCoachingCall}} mit einem Mitglied unseres Teams.\",\n        \"Maybe later\": \"Vielleicht später\",\n        \"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.\": \"Während des Gesprächs nehmen wir uns die Zeit, Ihre Bedürfnisse zu verstehen und das Gespräch auf Sie zuzuschneiden. Wir können Ihnen die Grundlagen von Grist zeigen oder sofort mit Ihren Daten arbeiten, um die von Ihnen benötigten Dashboards zu erstellen.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Sie können auch {{ourWeeklyWebinars}} besuchen, um mehr über Grist zu erfahren.\",\n        \"our weekly webinars\": \"unsere wöchentlichen Webinare\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Clear\": \"Leeren\",\n        \"Map fields\": \"Felder zuordnen\",\n        \"Unmap fields\": \"Felder freigeben\",\n        \"Unmapped\": \"Nicht zugeordnet\",\n        \"Mapped\": \"Zugeordnet\",\n        \"Select all\": \"Alle auswählen\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Feldregeln\",\n        \"Required field\": \"Erforderliches Feld\",\n        \"Options Sort Order\": \"Optionen Sortierreihenfolge\",\n        \"Options Alignment\": \"Optionenausrichtung\",\n        \"Select\": \"Auswählen\",\n        \"Vertical\": \"Vertikal\",\n        \"Ascending\": \"Aufsteigend\",\n        \"Default\": \"Standard\",\n        \"Descending\": \"Absteigend\",\n        \"Field Format\": \"Feldformat\",\n        \"Field Rules\": \"Feldregeln\",\n        \"Horizontal\": \"Horizontal\",\n        \"Radio\": \"Radio\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Einige erforderliche Spalten sind nicht zugeordnet\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Um dieses Widget zu verwenden, ordnen Sie bitte alle nicht-optionalen Spalten im Ersteller-Panel auf der rechten Seite zu.\",\n        \"Some required columns are hidden by access rules\": \"Einige erforderliche Spalten werden durch Zugriffsregeln ausgeblendet\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Um dieses Widget zu verwenden, müssen alle zugeordneten Spalten sichtbar sein. Bitte wenden Sie sich an den Eigentümer des Dokuments oder ändern Sie die Zugriffsregeln.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Formular eingereicht\",\n        \"Thank you! Your response has been recorded.\": \"Danke! Ihre Antwort wurde aufgezeichnet.\",\n        \"Submit new response\": \"Neue Antwort absenden\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Beim Absenden Ihres Formulars ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"Letzte 30 Tage\",\n        \"Last 7 days\": \"Letzte 7 Tage\",\n        \"Last week\": \"Letzte Woche\",\n        \"Next 7 days\": \"Nächste 7 Tage\",\n        \"This month\": \"Diesen Monat\",\n        \"This week\": \"Diese Woche\",\n        \"This year\": \"Dieses Jahr\",\n        \"Today\": \"Heute\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Klären\",\n        \"Map fields\": \"Felder zuordnen\",\n        \"Mapped\": \"Zugeordnet\",\n        \"Unmap fields\": \"Felder freigeben\",\n        \"Unmapped\": \"Nicht zugeordnet\",\n        \"Select all\": \"Alle auswählen\"\n    },\n    \"AdminPanel\": {\n        \"Admin Panel\": \"Administrations Bereich\",\n        \"Current\": \"Aktuell\",\n        \"Support Grist\": \"Unterstützen Sie Grist\",\n        \"Telemetry\": \"Telemetrie\",\n        \"Current version of Grist\": \"Aktuelle Version von Grist\",\n        \"Help us make Grist better\": \"Helfen Sie uns, Grist besser zu machen\",\n        \"Home\": \"Home\",\n        \"Sponsor\": \"Sponsor\",\n        \"Support Grist Labs on GitHub\": \"Unterstützen Sie Grist Labs auf GitHub\",\n        \"Version\": \"Version\",\n        \"Auto-check when this page loads\": \"Automatische Überprüfung beim Laden dieser Seite\",\n        \"Checking for updates...\": \"Nach Updates suchen...\",\n        \"Error\": \"Fehler\",\n        \"Error checking for updates\": \"Fehler beim Updates prüfen\",\n        \"Grist is up to date\": \"Grist ist auf dem neuesten Stand\",\n        \"Grist releases are at \": \"Grist-Veröffentlichungen sind unter \",\n        \"Last checked {{time}}\": \"Zuletzt geprüft {{time}}\",\n        \"Learn more.\": \"Mehr erfahren.\",\n        \"Newer version available\": \"Neuere Version verfügbar\",\n        \"No information available\": \"Keine Informationen verfügbar\",\n        \"OK\": \"OK\",\n        \"Sandbox settings for data engine\": \"Sandbox-Einstellungen für Datenmotor\",\n        \"Sandboxing\": \"Sandboxen\",\n        \"Security Settings\": \"Sicherheitseinstellungen\",\n        \"Updates\": \"Aktualisierungen\",\n        \"unconfigured\": \"unkonfiguriert\",\n        \"unknown\": \"unbekannt\",\n        \"Check now\": \"Jetzt prüfen\",\n        \"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 ermöglicht sehr leistungsfähige Formeln, die Python verwenden. Wir empfehlen, die Umgebungsvariable GRIST_SANDBOX_FLAVOR auf gvisor zu setzen, wenn Ihre Hardware dies unterstützt (was bei den meisten der Fall ist), um Formeln in jedem Dokument innerhalb einer Sandbox auszuführen, die von anderen Dokumenten und vom Netzwerk isoliert ist.\",\n        \"Results\": \"Ergebnisse\",\n        \"Self Checks\": \"Selbstkontrolle\",\n        \"Check failed.\": \"Prüfung fehlgeschlagen.\",\n        \"Administrator Panel Unavailable\": \"Administrator-Panel Nicht verfügbar\",\n        \"Authentication\": \"Authentifizierung\",\n        \"Check succeeded.\": \"Prüfung gelungen.\",\n        \"Notes\": \"Anmerkungen\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"Sie haben keinen Zugriff auf das Administrator-Panel.\\nBitte melden Sie sich als Administrator an.\",\n        \"Current authentication method\": \"Aktuelle Authentifizierungsmethode\",\n        \"Details\": \"Details\",\n        \"No fault detected.\": \"Kein Fehler erkannt.\",\n        \"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.\",\n        \"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\",\n        \"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.\",\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 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.\",\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 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.\",\n        \"Key to sign sessions with\": \"Schlüssel zum Anmelden von Sitzungen mit\",\n        \"Session Secret\": \"Sitzungsgeheimnis\",\n        \"Enterprise\": \"Unternehmen\",\n        \"Enable Grist Enterprise\": \"Aktivieren Sie Grist Enterprise\",\n        \"checking\": \"Überprüfung\",\n        \"Audit Logs\": \"Audit-Protokolle\",\n        \"Contact us\": \"Kontakt\",\n        \"New, Enterprise\": \"Neu, Enterprise\",\n        \"Off\": \"Aus\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} mehr\",\n        \"Log Streaming\": \"Log Streaming\",\n        \"On\": \"An\",\n        \"Grist Instance\": \"Grist Instanz\",\n        \"Auto-check weekly\": \"Auto-Check wöchentlich\",\n        \"No record of last version check\": \"Keine Aufzeichnung der letzten Versionsprüfung\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Sie können das Streaming von Audit-Ereignissen von Grist zu einem externen SIEM-System (Security Information and Event Management) einrichten, wenn Sie Grist Enterprise aktivieren. {{contactUsLink}}, um mehr zu erfahren.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Automatische Prüfungen sind deaktiviert. Setzen Sie die Umgebungsvariable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING auf \\\"true\\\", um sie zu aktivieren.\",\n        \"auth error\": \"Authentifizierungsfehler\",\n        \"configured\": \"konfiguriert\",\n        \"default\": \"standard\",\n        \"more...\": \"mehr...\",\n        \"no authentication\": \"keine Authentifizierung\",\n        \"unavailable\": \"nicht verfügbar\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Abschnitt oben einfügen\",\n        \"Insert section below\": \"Abschnitt unten einfügen\",\n        \"## **Header**\": \"## **Kopfzeile**\",\n        \"Description\": \"Beschreibung\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Abbrechen\",\n        \"Create site\": \"Seite erstellen\",\n        \"Domain name is required\": \"Domainname ist erforderlich\",\n        \"Domain name is invalid\": \"Domainname ist ungültig\",\n        \"Work as a Team\": \"Als Team arbeiten\",\n        \"Choose a name and url for your team site\": \"Wählen Sie einen Namen und eine URL für Ihre Team-Site\",\n        \"Go to your site\": \"Gehen Sie zu Ihrer Website\",\n        \"Team name\": \"Teamname\",\n        \"Team name is required\": \"Teamname ist erforderlich\",\n        \"Team site created\": \"Teamseite erstellt\",\n        \"Team url\": \"Team url\",\n        \"Billing is not supported in grist-core\": \"Billing wird in grist-core nicht unterstützt\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Spalte entfernen\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Keine Auswahlmöglichkeiten konfiguriert\",\n        \"No values in show column of referenced table\": \"Keine Werte in der Spalte \\\"Anzeigen\\\" der referenzierten Tabelle\",\n        \"Hide\": \"Ausblenden\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"Kontrollkästchen\",\n        \"Field Format\": \"Feldformat\",\n        \"Switch\": \"Schalter\"\n    },\n    \"ChoiceEditor\": {\n        \"No choices to select\": \"Keine Auswahlmöglichkeiten\",\n        \"Error in dropdown condition\": \"Fehler in Dropdown-Bedingung\",\n        \"No choices matching condition\": \"Keine Auswahlmöglichkeiten für die Bedingung\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"Fehler in Dropdown-Bedingung\",\n        \"No choices matching condition\": \"Keine Auswahlmöglichkeiten für die Bedingung\",\n        \"No choices to select\": \"Keine Auswahlmöglichkeiten\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Invalid columns: {{colIds}}\": \"Ungültige Spalten: {{colIds}}\",\n        \"Set dropdown condition\": \"Dropdown-Bedingung festlegen\",\n        \"Dropdown Condition\": \"Dropdown-Bedingung\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Fehler in Dropdown-Bedingung\",\n        \"No choices to select\": \"Keine Auswahlmöglichkeiten\",\n        \"No choices matching condition\": \"Keine Auswahlmöglichkeiten für die Bedingung\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Bedingung eingeben.\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"Zurücksetzen\",\n        \"Submit\": \"Einreichen\",\n        \"Search\": \"Suchen\",\n        \"Select...\": \"Wählen Sie...\",\n        \"Submitting…\": \"Einreichen…\"\n    },\n    \"widgetTypesMap\": {\n        \"Calendar\": \"Kalender\",\n        \"Card\": \"Karte\",\n        \"Card List\": \"Kartenliste\",\n        \"Form\": \"Formular\",\n        \"Table\": \"Tabelle\",\n        \"Chart\": \"Diagramm\",\n        \"Custom\": \"Benutzerdefiniert\"\n    },\n    \"TimingPage\": {\n        \"Max Time (s)\": \"Max Zeit(en)\",\n        \"Number of Calls\": \"Anzahl der Anrufe\",\n        \"Table ID\": \"Tabelle ID\",\n        \"Total Time (s)\": \"Gesamtzeit(en)\",\n        \"Formula timer\": \"Formel-Timer\",\n        \"Average Time (s)\": \"Durchschnittliche Zeit(en)\",\n        \"Loading timing data. Don't close this tab.\": \"Zeitpunktsdaten laden. Schließen Sie diese Registerkarte nicht.\",\n        \"Column ID\": \"Spalte ID\"\n    },\n    \"DocTutorial\": {\n        \"Click to expand\": \"Klicken Sie zum Erweitern\",\n        \"Finish\": \"Fertig\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Möchten Sie das Tutorial neu starten? Alle Fortschritte gehen dann verloren.\",\n        \"End tutorial\": \"Tutorial beenden\",\n        \"Next\": \"Nächste\",\n        \"Restart\": \"Neustart\",\n        \"Previous\": \"Vorherige\"\n    },\n    \"OnboardingPage\": {\n        \"Back\": \"Zurück\",\n        \"Type here\": \"Hier tippen\",\n        \"Welcome\": \"Willkommen\",\n        \"What brings you to Grist (you can select multiple)?\": \"Was bringt Sie zu Grist (Sie können mehrere auswählen)?\",\n        \"Discover Grist in 3 minutes\": \"Entdecken Sie Grist in 3 Minuten\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Praktische Übungen mit dem Grist Basics-Tutorial\",\n        \"Go to the tutorial!\": \"Gehen Sie zum Tutorial!\",\n        \"Next step\": \"Nächster Schritt\",\n        \"Tell us who you are\": \"Sagen Sie uns, wer Sie sind\",\n        \"What is your role?\": \"Was ist Ihre Aufgabe?\",\n        \"What organization are you with?\": \"In welches Unternehem sind Sie tätig?\",\n        \"Your organization\": \"Ihr Unternehmen\",\n        \"Your role\": \"Ihre Rolle\",\n        \"Skip step\": \"Schritt überspringen\",\n        \"Skip tutorial\": \"Tutorial überspringen\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist mag wie eine Tabellenkalkulation aussehen, aber es verhält sich nicht immer wie eine solche. Entdecken Sie, was Grist anders macht.\"\n    },\n    \"OnboardingCards\": {\n        \"Complete the tutorial\": \"Fertigen Sie das Tutorial\",\n        \"3 minute video tour\": \"3 Minuten Video-Tour\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Lernen Sie die Grundlagen von Referenzspalten, verknüpften Widgets, Spaltentypen und Karten kennen.\",\n        \"Complete our basics tutorial\": \"Vervollständigen Sie unser Grundlagen-Tutorial\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Lernen Sie die Grundlagen der Referenzspalten, verknüpfte Widgets, Spaltentypen, & Karten.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"Disable Grist Enterprise\": \"Grist Enterprise deaktivieren\",\n        \"Enable Grist Enterprise\": \"Grist Enterprise aktivieren\",\n        \"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}}).\",\n        \"Grist Enterprise is **enabled**.\": \"Grist Enterprise ist **aktiviert**.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Ein Aktivierungsschlüssel wird verwendet, um Grist Enterprise nach Ablauf einer Probezeit\\nvon 30 Tagen. Holen Sie sich noch heute einen Aktivierungsschlüssel, indem Sie uns [kontaktieren]({{contactLink}}). Sie benötigen\\nkeinen Aktivierungsschlüssel, um Grist Core auszuführen.\\n\\nErfahren Sie mehr in unserem [Hilfe-Center]({{helpCenter}}).\",\n        \"Activate\": \"Aktivieren\",\n        \"Activation key\": \"Aktivierungsschlüssel\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Um Grist Enterprise weiterhin nutzen zu können, ist ein aktives Abonnement erforderlich. Sie können\\nIhr Abonnement aktivieren, indem Sie [sich für Grist Enterprise anmelden ]({{signupLink}}) und Ihren\\nAktivierungsschlüssel unten einfügen.\",\n        \"Copy to clipboard\": \"In die Zwischenablage kopieren\",\n        \"Expiration date\": \"Verfallsdatum\",\n        \"Installation ID copied to clipboard\": \"Installations-ID in die Zwischenablage kopiert\",\n        \"Installation ID:\": \"Installations-ID:\",\n        \"Installation seats\": \"Einbausitze\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Erfahren Sie mehr in unserem [Hilfezentrum]({{helpCenter}}).\",\n        \"Paste your activation key\": \"Fügen Sie Ihren Aktivierungsschlüssel ein\",\n        \"Plan name\": \"Name des Plans\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Um Grist Enterprise weiterhin nutzen zu können, müssen Sie\\n                  [uns kontaktieren]({{signupLink}}), um Ihren Aktivierungsschlüssel zu erhalten.\",\n        \"You are currently trialing Grist Enterprise.\": \"Sie sind gerade dabei, Grist Enterprise zu testen.\",\n        \"You do not have an active subscription.\": \"Sie haben kein aktives Abonnement.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Ihr Aktivierungsschlüssel ist aufgrund der Überschreitung von Grenzwerten abgelaufen.\",\n        \"Your subscription expired on {{date}}.\": \"Ihr Abonnement ist am {{date}} abgelaufen.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Ihre Instanz wird in **{{days}}** Tagen in den **Nur-Lesen-Modus** versetzt.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Ein Aktivierungsschlüssel wird verwendet, um Grist Enterprise nach Ablauf einer Probezeit\\n        von 30 Tagen weiter zu nutzen. Sie erhalten einen Aktivierungsschlüssel, indem Sie [sich für Grist\\n        Enterprise]({{signupLink}}) anmelden. Sie brauchen keinen Aktivierungsschlüssel, um\\n        Grist Core auszuführen.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Ihr Testzeitraum ist am **{{expireAt}}** abgelaufen. Um Grist Enterprise weiter nutzen zu können, müssen Sie sich\\n[für Grist Enterprise anmelden]({{signupLink}}) und Ihren Aktivierungsschlüssel unten einfügen.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Löschen\",\n        \"Table {{tableName}} will no longer be visible\": \"Die Tabelle {{tableName}} wird nicht mehr sichtbar sein.\",\n        \"Raw Data page\": \"Rohdaten-Seite\",\n        \"Delete data and this widget.\": \"Daten und dieses Widget löschen.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Daten behalten und Widget löschen. Die Tabelle bleibt verfügbar in {{rawDataLink}}\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Administrations Bereich\"\n    },\n    \"CustomWidgetGallery\": {\n        \"(Missing info)\": \"(Fehlende Informationen)\",\n        \"Add widget\": \"Widget hinzufügen\",\n        \"Add Your Own Widget\": \"Eigenes Widget hinzufügen\",\n        \"Cancel\": \"Abbrechen\",\n        \"Change widget\": \"Widget ändern\",\n        \"Choose custom widget\": \"Benutzerdefiniertes Widget auswählen\",\n        \"Developer:\": \"Entwickler:\",\n        \"Grist Widget\": \"Grist Widget\",\n        \"Last updated:\": \"Letzte Aktualisierung:\",\n        \"No matching widgets\": \"Keine passenden Widgets\",\n        \"Search\": \"Suchen\",\n        \"Widget URL\": \"Widget-URL\",\n        \"Learn more about custom widgets\": \"Erfahren Sie mehr über benutzerdefinierte Widgets\",\n        \"Add a widget from outside this gallery.\": \"Fügen Sie ein Widget von außerhalb dieser Galerie hinzu.\",\n        \"Community Widget\": \"Community Widget\",\n        \"Custom URL\": \"Benutzerdefinierte URL\"\n    },\n    \"markdown\": {\n        \"The toggle is **off**\": \"Der Kippschalter ist **aus**\",\n        \"The toggle is **on**\": \"Der Kippschalter ist **an**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Neue Markdown-Funktion\\n *\\n * Wir können [das übliche Markdown](https://markdownguide.org) _innerhalb*\\n * eines Grainjs-Elements schreiben.\"\n            }\n        }\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Neue Markdown-Funktion\\n *\\n * Wir können [das übliche Markdown](https://markdownguide.org) _innerhalb*\\n * eines Grainjs-Elements schreiben.\"\n            }\n        },\n        \"The toggle is **off**\": \"Der Kippschalter ist **aus**\",\n        \"The toggle is **on**\": \"Der Kippschalter ist **an**\"\n    },\n    \"HomeIntroCards\": {\n        \"3 minute video tour\": \"3 Minuten Video-Tour\",\n        \"Blank document\": \"Leeres Dokument\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"Lösungen finden und weitere Ressourcen erkunden {{helpCenterLink}}\",\n        \"Templates\": \"Vorlagen\",\n        \"Tutorial\": \"Tutorial\",\n        \"Import file\": \"Datei importieren\",\n        \"Finish our basics tutorial\": \"Beenden Sie unser Grundlagen-Tutorial\",\n        \"Start a new document\": \"Ein neues Dokument beginnen\",\n        \"Help center\": \"Hilfezentrum\",\n        \"Learn more {{webinarsLinks}}\": \"Mehr erfahren {{webinarsLinks}}\",\n        \"Webinars\": \"Webinare\",\n        \"Find solutions and explore more resources\": \"Lösungen finden und weitere Ressourcen erkunden\",\n        \"Learn more\": \"Mehr erfahren\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Delete column {{column}} in table {{table}}?\": \"Spalte {{column}} in der Tabelle {{table}} löschen?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Es ist die Umkehrung der Referenzspalte {{column}} in Tabelle {{table}}.\",\n        \"Table\": \"Tabelle\",\n        \"Two-way Reference\": \"Zwei-Wege-Referenz\",\n        \"Target table\": \"Zieltabelle\",\n        \"Add two-way reference\": \"Zwei-Wege-Referenz hinzufügen\",\n        \"Column\": \"Spalte\",\n        \"Delete\": \"Löschen\",\n        \"Delete two-way reference?\": \"Zwei-Wege-Referenz löschen?\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Dadurch wird die Referenzspalte {{refCol}} in der Tabelle {{refTable}} gelöscht. Die Referenzspalte {{myName}} bleibt in der aktuellen Tabelle {{myTable}} erhalten.\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"Administrations Bereich\",\n        \"Close\": \"Schließen\",\n        \"Opted In\": \"Angemeldet\",\n        \"Support Grist\": \"Unterstützen Sie Grist\",\n        \"Opt in to Telemetry\": \"Melden Sie sich für Telemetrie an\",\n        \"Help Center\": \"Hilfezentrum\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Danke! Ihr Vertrauen und Ihre Unterstützung ist sehr geschätzt. Sie können sich jederzeit unter {{link}} im Benutzermenü abmelden.\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Melden Sie sich für die Telemetrie an, um zu verstehen, wie das Produkt genutzt wird, damit wir zukünftige Verbesserungen priorisieren können.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Wir sammeln nur Nutzungsstatistiken, wie in unserem {{helpCenterLink}} beschrieben, niemals Dokumentinhalte. Sie können sich jederzeit unter {{supportGristLink}} im Benutzermenü abmelden.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Abbrechen\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Jeder {{targetTable}} Datensatz kann nur einem einzigen {{sourceTable}} Datensatz zugeordnet werden.\",\n        \"Reassign\": \"Neu zuordnen\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Zuweisen auf {{sourceTable}}-Datensatz {{sourceName}}.\",\n        \"Record already assigned_one\": \"Datensatz bereits zugeordnet\",\n        \"Record already assigned_other\": \"Bereits zugeordnete Datensätze\",\n        \"Reassign to new {{sourceTable}} records.\": \"Neuzuordnung zu neuen {{sourceTable}} Datensätzen.\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} Der Datensatz {{targetName}} ist bereits dem Datensatz {{sourceTable}} {{oldSourceName}} zugeordnet.\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Add destination\": \"Ziel hinzufügen\",\n        \"Delete streaming destination?\": \"Streaming-Ziel löschen?\",\n        \"Edit streaming destination\": \"Streaming-Ziel bearbeiten\",\n        \"Enter URL\": \"URL eingeben\",\n        \"Other\": \"Sonstiges\",\n        \"Save\": \"Speichern\",\n        \"Splunk\": \"Splunk\",\n        \"Add streaming destination\": \"Streaming-Ziel hinzufügen\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Sind Sie sicher, dass Sie dieses Streaming-Ziel löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.\",\n        \"Cancel\": \"Abbrechen\",\n        \"Delete\": \"Löschen\",\n        \"Destination\": \"Ziel\",\n        \"Destinations\": \"Ziele\",\n        \"Edit\": \"Bearbeiten\",\n        \"Enter token\": \"Token eingeben\",\n        \"Learn more\": \"Mehr erfahren\",\n        \"Start streaming\": \"Streaming starten\",\n        \"Token\": \"Token\",\n        \"URL\": \"URL\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Richten Sie das Streaming von Audit-Ereignissen von Grist zu einem externen SIEM-System (Security Information and Event Management) wie Splunk ein. {{learnMoreLink}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit Logs\": \"Audit-Protokolle\",\n        \"Contact us\": \"Kontakt\",\n        \"Home\": \"Startseite\",\n        \"Only site owners may access audit logs.\": \"Nur Website-Besitzer können auf Audit-Protokolle zugreifen.\",\n        \"Log streaming\": \"Log Streaming\",\n        \"upgrade your plan\": \"aktualisieren Sie Ihren Plan\",\n        \"Audit logs for {{siteName}}\": \"Audit-Protokolle für {{siteName}}\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Sie können das Streaming von Audit-Ereignissen von Grist zu einem externen SIEM-System (Security Information and Event Management) einrichten, wenn Sie Grist Enterprise aktivieren. {{contactUsLink}}, um mehr zu erfahren.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Sie können das Streaming von Audit-Ereignissen von Grist zu einem externen SIEM-System (Security Information and Event Management) einrichten, wenn Sie {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"Rename and set icon\": \"Symbol umbenennen und einstellen\",\n        \"Pin\": \"Anheften\",\n        \"Pinned\": \"Angeheftet\",\n        \"Recent\": \"Neueste\",\n        \"Requires edit permissions\": \"Erfordert Bearbeitungsrechte\",\n        \"Sort by date\": \"Nach Datum sortieren\",\n        \"Access details\": \"Zugangsdetails\",\n        \"All\": \"Alles\",\n        \"Delete {{name}}\": \"{{name}} löschen\",\n        \"Document will be moved to Trash.\": \"Das Dokument wird in den Papierkorb verschoben.\",\n        \"Last edited\": \"Zuletzt bearbeitet\",\n        \"Move\": \"Verschieben\",\n        \"Move {{name}} to workspace\": \"{{name}} in den Arbeitsbereich verschieben\",\n        \"Name\": \"Name\",\n        \"No documents to show.\": \"Es sind keine Dokumente vorzulegen.\",\n        \"Sort by name\": \"Nach Name sortieren\",\n        \"Unpin\": \"Abheften\",\n        \"Workspace\": \"Arbeitsbereich\",\n        \"Current workspace\": \"Aktueller Arbeitsbereich\",\n        \"Delete\": \"Löschen\",\n        \"Edited {{at}}\": \"Bearbeitet {{at}}\",\n        \"Manage users\": \"Benutzer verwalten\",\n        \"context menu - {{- documentName }}\": \"Kontextmenü - {{- documentName }}\",\n        \"Documents list\": \"Liste der Dokumente\"\n    },\n    \"RenameDocModal\": {\n        \"Enter document name\": \"Geben Sie den Dokumentnamen ein\",\n        \"Icon\": \"Symbol\",\n        \"Name\": \"Name\",\n        \"Rename and set icon\": \"Symbol umbenennen und einstellen\",\n        \"Reset icon\": \"Symbol zurücksetzen\",\n        \"Choose icon\": \"Symbol auswählen\",\n        \"Choose color\": \"Farbe wählen\"\n    },\n    \"RightPanelUtils\": {\n        \"columns_one\": \"Spalte\",\n        \"columns_other\": \"Spalten\",\n        \"fields_one\": \"Feld\",\n        \"series_other\": \"Serien\",\n        \"series_one\": \"Serien\",\n        \"fields_other\": \"Felder\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Seien Sie vorsichtig mit unbekannten benutzerdefinierten Widgets\",\n        \"Please review the following before adding a new custom widget.\": \"Bitte beachten Sie die folgenden Hinweise, bevor Sie ein neues benutzerdefiniertes Widget hinzufügen.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Sind Sie sicher, dass Sie **die Ressourcen** bei dieser URL **vertrauen**?\",\n        \"Do you **trust the person** who shared this link?\": \"**Vertrauen Sie die Person**, die diesen Link geteilt hat?\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Benutzerdefinierte Widgets sind **mächtig**! Sie können Ihre Dokument-Daten lesen und schreiben und sie an andere Stellen senden.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Ich bestätige, dass ich diese Warnhinweise verstanden habe und die Risiken akzeptiere\",\n        \"Have you **reviewed the code** at this URL?\": \"Haben Sie * den Code** bei dieser URL **überprüft**?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Wenn in Zweifel, installieren Sie dieses Widget nicht oder fragen Sie einen Administrator Ihrer Organisation, um es für Sicherheit zu überprüfen.\"\n    },\n    \"AdminLeftPanel\": {\n        \"Users\": \"Benutzer\",\n        \"Learn more\": \"Mehr erfahren\",\n        \"Admin controls\": \"Administrations Steuerung\",\n        \"Admin Controls\": \"Admin-Kontrollen\",\n        \"Settings\": \"Einstellungen\",\n        \"Workspaces\": \"Arbeitsbereiche\",\n        \"Installation\": \"Installation\",\n        \"Orgs\": \"Organizationen\",\n        \"Admin area\": \"Verwaltungsbereich\",\n        \"Docs\": \"Dokumente\"\n    },\n    \"Assistant\": {\n        \"Press Enter to apply suggested formula.\": \"Drücken Sie die Eingabetaste, um die vorgeschlagene Formel anzuwenden.\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Melden Sie sich für ein kostenloses Grist-Konto an, um den KI Assistenten zu verwenden.\",\n        \"What do you need help with?\": \"Womit brauchen Sie Hilfe?\",\n        \"You have {{numCredits}} remaining credits.\": \"Sie haben {{numCredits}} verbleibende Credits.\",\n        \"start a new chat\": \"einen neuen Chat starten\",\n        \"upgrade your plan\": \"aktualisieren Sie Ihren Plan\",\n        \"Apply\": \"Anwenden\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Für höhere Grenzwerte: {{upgradeNudge}}.\",\n        \"Learn more.\": \"Mehr erfahren.\",\n        \"AI Assistant is only available for logged in users.\": \"Der KI-Assistent ist nur für eingeloggte Benutzer verfügbar.\",\n        \"For higher limits, contact the site owner.\": \"Für höhere Grenzwerte wenden Sie sich bitte an den Eigentümer der Website.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Aktualisieren Sie auf Grist Enterprise, um den neuen Grist Assistenten auszuprobieren. {{learnMoreLink}}\",\n        \"Sign Up for Free\": \"Melden Sie sich kostenlos an\",\n        \"upgrade to the Pro Team plan\": \"Upgrade auf den Pro Team Plan\",\n        \"You have used all available credits.\": \"Sie haben alle verfügbaren Kredite aufgebraucht.\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"Das Gespräch ist zu lang geworden und ich kann nicht mehr effektiv antworten. Bitte {{startANewChatButton}}, um weiterhin Unterstützung zu erhalten.\"\n    },\n    \"apiconsole\": {\n        \"Are you sure you want to delete the following?\": \"Sind Sie sicher, dass Sie die folgenden löschen möchten?\",\n        \"Deletion was not confirmed, skipping.\": \"Die Löschung wurde nicht bestätigt, überspringen.\",\n        \"Confirm Deletion\": \"Löschung bestätigen\",\n        \"Delete\": \"Löschen\",\n        \"Type DELETE here if you wish to proceed.\": \"Geben Sie hier DELETE ein, wenn Sie fortfahren möchten.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Geben Sie DELETE ein, wenn Sie sicher sind, dass Sie diese Löschung tatsächlich durchführen möchten.\\nWenn Sie sich nicht sicher sind oder nicht verstehen, was dieser Vorgang bewirkt,\\nist es ratsam, den Vorgang abzubrechen.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"kein Zugang\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Es ist ein kritisches Grist-Update verfügbar.\\nErwägen Sie, so bald wie möglich auf die Version {{version}} zu aktualisieren.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Ihre Grist-Version ist veraltet.\\nErwägen Sie, so bald wie möglich auf die Version {{version}} zu aktualisieren.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Empfehlung: {{storageRecommendation}}\\nWenn Sie große Anhänge oder viele Anhänge speichern, empfehlen wir\\nsie in einem externen Speicher aufzubewahren. Dieses Dokument wird derzeit\\ninterner Speicher für Anhänge verwendet, wodurch es zwar\\nin sich geschlossen ist, aber die Leistung einschränken kann.\",\n        \"Set the document to use external storage.\": \"Legen Sie das Dokument für den externen Speicher ein.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Bitte warten Sie, bis der vorherige Vorgang abgeschlossen ist.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Zeitüberschreitung beim Warten auf den Neustart des Grist-Backends\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Funktion deaktivieren\",\n        \"Don't worry, you can disable it later if needed.\": \"Keine Sorge, Sie können sie später bei Bedarf deaktivieren.\",\n        \"Enable feature\": \"Funktion aktivieren\",\n        \"Experimental feature\": \"Experimentelle Funktion\",\n        \"New record button\": \"Schaltfläche \\\"Neuer Datensatz\",\n        \"Reload the page\": \"Laden Sie die Seite neu\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Sie können diese URL jederzeit aufrufen, um die Nutzung dieser Funktion zu beenden: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Sie sind dabei, diese experimentelle Funktion zu deaktivieren: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Sie sind dabei, diese experimentelle Funktion zu aktivieren: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} deaktiviert.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} aktiviert.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Neue Karte\",\n        \"New record\": \"Neuer Rekord\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Versuchen Sie, auf das Ersteller-Panel zuzugreifen? Verwenden Sie {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Widget Duplizieren\",\n        \"Duplicate widgets\": \"Widgets duplizieren\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Hochladen, bitte warten…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"hinzufügen\",\n        \"Delete\": \"Löschen\",\n        \"Download\": \"Download\",\n        \"Drop files here to attach\": \"Dateien zum Anhängen hier ablegen\",\n        \"Drop files here to attach.\": \"Dateien zum Anhängen hier ablegen.\",\n        \"No attachments\": \"Keine Anhänge\",\n        \"Preview not available.\": \"Vorschau nicht verfügbar.\",\n        \"{{index}} of {{total}}\": \"{{index}} von {{total}}\",\n        \"Uploading…\": \"Hochladen…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Alle Zeilen auf diese Höhe ausdehnen\",\n        \"Max height\": \"Maximale Höhe\",\n        \"Max row height\": \"Maximale Zeilenhöhe\",\n        \"Change\": \"Veränderung\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Zusammenklappen\",\n        \"Expand\": \"Erweitern\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"aktiver Nutzer\",\n        \"active user list\": \"aktive Benutzerliste\",\n        \"open full active user list\": \"vollständige Liste der aktiven Benutzer öffnen\"\n    },\n    \"AdminChecks\": {\n        \"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 ermöglicht sehr leistungsfähige Formeln, die Python verwenden. Wir empfehlen, die Umgebungsvariable GRIST_SANDBOX_FLAVOR auf gvisor zu setzen, wenn Ihre Hardware dies unterstützt (was bei den meisten der Fall ist), um Formeln in jedem Dokument innerhalb einer Sandbox auszuführen, die von anderen Dokumenten und vom Netzwerk isoliert ist.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Grist hat einen kleinen eingebauten Gesundheitscheck, der oft verwendet wird, wenn es als Container läuft.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"Anfragen, die bei Grist ankommen, sollten einen genauen Host-Header haben. Dies ist wichtig, wenn GRIST_SERVE_SAME_ORIGIN eingestellt ist.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"Diese Boot-Seite sollte nicht zu leicht zugänglich sein. Schalten Sie sie entweder aus, wenn die Konfiguration in Ordnung ist (indem Sie GRIST_BOOT_KEY aufheben), oder machen Sie GRIST_BOOT_KEY lang und kryptografisch sicher.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"Websocket-Verbindungen benötigen HTTP 1.1 und die Möglichkeit, ein paar zusätzliche Header zu übergeben, um zu funktionieren. Manchmal kann ein Reverse Proxy diese Anforderungen beeinträchtigen.\",\n        \"It is good practice not to run Grist as the root user.\": \"Es ist eine gute Praxis, Grist nicht als Root-Benutzer auszuführen.\",\n        \"The main page of Grist should be available.\": \"Die Hauptseite von Grist sollte verfügbar sein.\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"+{{count}} mehr\",\n        \"+{{count}} more_other\": \"+{{count}} mehr\",\n        \"Edit\": \"Bearbeiten\",\n        \"No choices configured\": \"Keine Auswahlmöglichkeiten konfiguriert\",\n        \"Reset\": \"Zurücksetzen\",\n        \"Cancel\": \"Abbrechen\",\n        \"Save\": \"Speichern\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Anwenden\",\n        \"Cancel\": \"Abbrechen\",\n        \"Preview\": \"Vorschau\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Schließen\",\n        \"Update preview\": \"Vorschau aktualisieren\",\n        \"Convert quoted fields\": \"Zitate konvertieren\",\n        \"Escape character\": \"Escape-Zeichen\",\n        \"Field separator\": \"Feldtrenner\",\n        \"First row contains headers\": \"Erste Zeile enthält Überschriften\",\n        \"Line terminator\": \"Zeilenterminator\",\n        \"Number of rows\": \"Anzahl der Zeilen\",\n        \"Quote character\": \"Zitat Charakter\",\n        \"Quotes in fields are doubled\": \"Anführungszeichen in Feldern werden verdoppelt\",\n        \"Skip leading whitespace\": \"Führende Whitespace-Zeichen überspringen\",\n        \"Start with row\": \"Beginn mit Zeile\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Zeichenkodierung. Siehe [die unterstützten Codecs]({{link}})\"\n    },\n    \"commandList\": {\n        \"Undo last action\": \"Letzte Aktion rückgängig machen\"\n    },\n    \"GridViewMenusDateHelpers\": {\n        \"Years until\": \"Jahre bis\"\n    }\n}\n"
  },
  {
    "path": "static/locales/de.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Bitte überprüfen Sie Ihre E-Mail mit dem Identitätsanbieter und melden Sie sich erneut an.\"\n    },\n    \"sendAppPage\": {\n        \"og-title\": \"Grist, die Entwicklung von Tabellenkalkulationen\",\n        \"Loading...\": \"Laden...\",\n        \"og-description\": \"Ein modernes, offenes Quell-Spreadsheet, das über das Raster hinausgeht\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"Sie haben keinen Zugang zu diesem Dokument.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/el.client.json",
    "content": "{\n    \"ColumnFilterMenu\": {\n        \"All except\": \"Όλα Εκτός Από\",\n        \"Start\": \"Έναρξη\",\n        \"All\": \"Όλα\",\n        \"All shown\": \"Όλα Όσα Εμφανίζονται\",\n        \"Filter by Range\": \"Φιλτράρισμα κατά εύρος\",\n        \"Future values\": \"Μελλοντικές Τιμές\",\n        \"No matching values\": \"Δεν υπάρχουν αντίστοιχες τιμές\",\n        \"None\": \"Κανένα\",\n        \"Min\": \"Ελάχ\",\n        \"Max\": \"Μεγ\",\n        \"End\": \"Λήξη\",\n        \"Other Matching\": \"Άλλη Αντιστοίχιση\",\n        \"Others\": \"Άλλα\",\n        \"Search\": \"Αναζήτηση\",\n        \"Other values\": \"Άλλες Τιμές\",\n        \"Search values\": \"Αναζήτηση τιμών\",\n        \"Other Non-Matching\": \"Άλλα Μη Αντιστοιχιζόμενα\",\n        \"Clear search\": \"Καθαρισμός αναζήτησης\",\n        \"Pin filter\": \"Καρφίτσωμα φίλτρου\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Ταξινόμηση αλφαβητικά (τρέχουσα: ταξινομημένη κατά αριθμό εμφανίσεων)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Ταξινόμηση κατά αριθμό εμφανίσεων (τρέχουσα: ταξινομημένη αλφαβητικά)\",\n        \"Unpin filter\": \"Ξεκαρφίτσωμα φίλτρου\"\n    },\n    \"AccessRules\": {\n        \"Permission to edit document structure\": \"Άδεια επεξεργασίας δομής εγγράφου\",\n        \"Saved\": \"Αποθηκεύτηκε\",\n        \"Enter Condition\": \"Εισαγωγή συνθήκης\",\n        \"User Attributes\": \"Χαρακτηριστικά χρήστη\",\n        \"Add column rule\": \"Προσθήκη Κανόνα Στήλης\",\n        \"Add Default Rule\": \"Προσθήκη Προεπιλεγμένου Κανόνα\",\n        \"Add user attributes\": \"Προσθήκη Χαρακτηριστικών Χρήστη\",\n        \"Allow everyone to view Access Rules.\": \"Να επιτρέπεται σε όλους η προβολή των Κανόνων Πρόσβασης.\",\n        \"Attribute name\": \"Όνομα χαρακτηριστικού\",\n        \"Attribute to Look Up\": \"Χαρακτηριστικό προς Αναζήτηση\",\n        \"Checking...\": \"Ελεγχος…\",\n        \"Condition\": \"Συνθήκη\",\n        \"Default rules\": \"Προεπιλεγμένοι κανόνες\",\n        \"Delete table rules\": \"Διαγραφή Κανόνων Πίνακα\",\n        \"Everyone\": \"Όλοι\",\n        \"Everyone Else\": \"Όλοι οι άλλοι\",\n        \"Invalid\": \"Άκυρο\",\n        \"Lookup Column\": \"Στήλη αναζήτησης\",\n        \"Lookup Table\": \"Πίνακας Αναζήτησης\",\n        \"Permission to access the document in full when needed\": \"Άδεια πρόσβασης στο έγγραφο πλήρως όταν χρειάζεται\",\n        \"Permissions\": \"Δικαιώματα\",\n        \"Remove {{- tableId }} rules\": \"Κατάργηση κανόνων {{-tableId }}\",\n        \"Remove {{- name }} user attribute\": \"Αφαίρεση του χαρακτηριστικού χρήστη {{- name }}\",\n        \"Rules for table \": \"Κανόνες για τον πίνακα \",\n        \"Save\": \"Αποθήκευση\",\n        \"Special rules\": \"Ειδικοί Κανόνες\",\n        \"View as\": \"Προβολή ως\",\n        \"Seed rules\": \"Κανόνες σποράς\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Κατά την προσθήκη κανόνων πίνακα, αυτόματη προσθήκη κανόνα για να παραχωρείται στον ΚΑΤΟΧΟ πλήρη πρόσβαση.\",\n        \"Add table-wide rule\": \"Προσθήκη κανόνα σε ολόκληρο τον πίνακα\",\n        \"Add table rules\": \"Προσθήκη Κανόνων Πίνακα\",\n        \"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.\": \"Να επιτρέπεται σε όλους να αντιγράψουν ολόκληρο το έγγραφο ή να το δουν ολόκληρο σε λειτουργία βιολιού (fiddle mode).\\nΧρήσιμο για παραδείγματα και πρότυπα, αλλά όχι για ευαίσθητα δεδομένα.\",\n        \"Permission to view Access Rules\": \"Άδεια προβολής Κανόνων Πρόσβασης\",\n        \"Reset\": \"Επαναφορά\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Αφαίρεση στήλης {{- colId }} από τους κανόνες {{- tableId }}\",\n        \"Type message to display when this rule blocks an action…\": \"Πληκτρολογήστε μήνυμα που θα εμφανίζεται όταν αυτός ο κανόνας αποκλείει μια ενέργεια…\",\n        \"This default should be changed if editors' access is to be limited. \": \"Αυτή η προεπιλογή θα πρέπει να αλλάξει εάν πρόκειται να περιοριστεί η πρόσβαση των συντακτών. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Να επιτρέπεται στους συντάκτες να επεξεργάζονται τη δομή (π.χ., να τροποποιούν και να διαγράφουν πίνακες, στήλες και διατάξεις) και να γράφουν τύπους. Ανεξάρτητα από τα δικαιώματα που έχουν οριστεί σε επίπεδο πίνακα και στήλης, οι τύποι εξακολουθούν να μπορούν να υποβάλλονται σε επεξεργασία και να έχουν πρόσβαση σε όλα τα δεδομένα.\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Οι κανόνες πρόσβασης έχουν αλλάξει. Κάντε κλικ στην επιλογή Επαναφορά για να επαναφέρετε τις αλλαγές σας και να ανανεώσετε τους κανόνες.\",\n        \"All\": \"Όλα\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"Η στήλη {{colId}} εμφανίζεται σε πολλούς κανόνες για τον πίνακα {{tableId}} που ενδέχεται να εξαρτώνται από τη σειρά. Θέλετε να δοκιμάσετε να χωρίσετε τους κανόνες διαφορετικά;\",\n        \"Columns\": \"Στήλες\",\n        \"Condition cannot be blank\": \"Η συνθήκη δεν μπορεί να είναι κενή\",\n        \"Default resource missing in resource map\": \"Λείπει ο προεπιλεγμένος πόρος στον χάρτη πόρων\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Μη έγκυρες στήλες στον πίνακα {{tableId}}: {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"Μη έγκυρος πίνακας: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Μη έγκυρος κανόνας χαρακτηριστικού χρήστη: Πρέπει να οριστεί το {{prop}}\",\n        \"Invalid user attribute to look up\": \"Μη έγκυρο χαρακτηριστικό χρήστη για αναζήτηση\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"Δεν υπάρχουν στήλες σε έναν κανόνα στήλης για τον πίνακα {{tableId}}\",\n        \"Not a valid user attribute\": \"Μη έγκυρο χαρακτηριστικό χρήστη\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Λείπει πόρος στον χάρτη πόρων: {{resourceKey}}\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"Προσπάθεια προσθήκης TableRules για τον υπάρχοντα πίνακα {{tableId}}\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"Χρησιμοποιήστε ένα απλό χαρακτηριστικό του user.LinkKey, π.χ. user.LinkKey.something\",\n        \"hidden\": \"κρυφό\",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Κανόνες Πρόσβασης\\n\\nΗ βασική πρόσβαση σε αυτό το έγγραφο ελέγχεται χρησιμοποιώντας την επιλογή «Διαχείριση Χρηστών» στο μενού «Κοινή χρήση», όπου μπορείτε να αναθέσετε ρόλους συνεργατών όπως Κάτοχος, Επεξεργαστής ή Προβολέας.\\n\\nΓια πιο λεπτομερή έλεγχο, μπορείτε να δημιουργήσετε Κανόνες Πρόσβασης για να περιορίσετε ποιος μπορεί να βλέπει ή να επεξεργάζεται συγκεκριμένους πίνακες, στήλες ή γραμμές — χρήσιμο για ευαίσθητα δεδομένα ή δικαιώματα που βασίζονται σε ρόλους.\\n[Μάθετε περισσότερα.]({{helpAccessRules}})\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Κανόνες Πρόσβασης\\n\\nΔεν έχετε άδεια προβολής ή επεξεργασίας κανόνων πρόσβασης για αυτό το έγγραφο.\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Ειδικοί κανόνες** (αναπτύξτε κάθε κανόνα για να προσαρμόσετε σε ποιους ισχύει)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"Μετά την απενεργοποίηση των Κανόνων Πρόσβασης, οι Επεξεργαστές θα μπορούν να αλλάξουν τη δομή του εγγράφου και να επεξεργαστούν τύπους. Οι Επεξεργαστές και οι Αναγνώστες θα μπορούν να δουν όλα τα δεδομένα στο έγγραφο, καθώς και να το αντιγράψουν ή να το κατεβάσουν.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"Μετά την ενεργοποίηση των Κανόνων Πρόσβασης, οι Επεξεργαστές δεν θα μπορούν πλέον να αλλάξουν τη δομή του \\nεγγράφου ή να επεξεργαστούν τύπους. Μόνο οι Κάτοχοι θα μπορούν να αντιγράψουν ή να κατεβάσουν το έγγραφο.\\n\\nΑυτές οι ρυθμίσεις μπορούν να αλλάξουν στην ενότητα \\\"Ειδικοί κανόνες\\\".\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"Επιτρέψτε στους Επεξεργαστές να επεξεργάζονται τη δομή (π.χ. να τροποποιούν και να διαγράφουν πίνακες, στήλες και διατάξεις) και να γράφουν τύπους. Σημαντικό: εάν είναι επιλεγμένο, οι Επεξεργαστές θα μπορούν να επεξεργάζονται τύπους, οι οποίοι θα έχουν πρόσβαση σε όλα τα δεδομένα, ανεξάρτητα από τους κανόνες πρόσβασης σε πίνακες και στήλες!\",\n        \"Allow everyone to view access rules.\": \"Να επιτρέπεται σε όλους η προβολή των Κανόνων Πρόσβασης.\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Παρακάμψτε όλους τους περιορισμούς ανάγνωσης και επιτρέψτε σε όλους να αντιγράψουν ολόκληρο το έγγραφο ή να το δουν ολόκληρο σε λειτουργία βιολιού. Χρησιμοποιήστε μόνο για παραδείγματα και πρότυπα, όχι για έγγραφα με ευαίσθητα δεδομένα.\",\n        \"Continue\": \"Συνέχεια\",\n        \"Disable Access Rules\": \"Απενεργοποίηση Κανόνων Πρόσβασης\",\n        \"Disable and save\": \"Απενεργοποίηση και αποθήκευση\",\n        \"Enable Access Rules\": \"Ενεργοποίηση Κανόνων Πρόσβασης\",\n        \"Permission to access the document in full by all users\": \"Άδεια πρόσβασης στο έγγραφο στο σύνολό του από όλους τους χρήστες\",\n        \"Permission to access the document in full by unrestricted users\": \"Άδεια πρόσβασης στο έγγραφο στο σύνολό του από χρήστες χωρίς περιορισμούς\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Περιορίστε την αντιγραφή ή τη λήψη ολόκληρου του εγγράφου από μη κατόχους. Σημείωση: αυτό επηρεάζει μόνο χρήστες χωρίς περιορισμούς ανάγνωσης, καθώς οι υπόλοιποι θα υπόκεινται σε περιορισμούς ανεξάρτητα από αυτήν τη ρύθμιση.\",\n        \"Special rules for templates\": \"Ειδικοί κανόνες για πρότυπα\",\n        \"This options should be off if Editors' access is to be limited. \": \"Αυτές οι επιλογές θα πρέπει να είναι απενεργοποιημένες εάν πρόκειται να περιοριστεί η πρόσβαση των Συντακτών. \"\n    },\n    \"ACUserManager\": {\n        \"Enter email address\": \"Εισαγάγετε διεύθυνση ηλεκτρονικού ταχυδρομείου\",\n        \"Invite new member\": \"Πρόσκληση νέου μέλους\",\n        \"We'll email an invite to {{email}}\": \"Θα στείλουμε μια πρόσκληση μέσω email στο {{email}}\"\n    },\n    \"AccountPage\": {\n        \"Two-factor authentication\": \"Έλεγχος ταυτότητας δύο παραγόντων\",\n        \"API\": \"API\",\n        \"API Key\": \"Κλειδί API\",\n        \"Account settings\": \"Ρυθμίσεις λογαριασμού\",\n        \"Allow signing in to this account with Google\": \"Να επιτρέπεται η σύνδεση σε αυτόν τον λογαριασμό με την Google\",\n        \"Change password\": \"Αλλαγή κωδικού πρόσβασης\",\n        \"Edit\": \"Επεξεργασία\",\n        \"Login method\": \"Μέθοδος σύνδεσης\",\n        \"Name\": \"Όνομα\",\n        \"Names only allow letters, numbers and certain special characters\": \"Τα ονόματα επιτρέπουν μόνο γράμματα, αριθμούς και ορισμένους ειδικούς χαρακτήρες\",\n        \"Password & security\": \"Κωδικός πρόσβασης και ασφάλεια\",\n        \"Save\": \"Αποθήκευση\",\n        \"Language\": \"Γλώσσα\",\n        \"Theme\": \"Θέμα\",\n        \"Email\": \"E-mail\",\n        \"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.\": \"Ο έλεγχος ταυτότητας δύο παραγόντων είναι ένα επιπλέον επίπεδο ασφάλειας για τον λογαριασμό σας στο Grist, το οποίο έχει σχεδιαστεί για να διασφαλίζει ότι είστε το μόνο άτομο που μπορεί να έχει πρόσβαση στον λογαριασμό σας, ακόμα κι αν κάποιος γνωρίζει τον κωδικό πρόσβασής σας.\"\n    },\n    \"AccountWidget\": {\n        \"Document settings\": \"Ρυθμίσεις Εγγράφου\",\n        \"Access Details\": \"Λεπτομέρειες Πρόσβασης\",\n        \"Accounts\": \"Λογαριασμοί\",\n        \"Add account\": \"Προσθήκη Λογαριασμού\",\n        \"Pricing\": \"Τιμολόγηση\",\n        \"Profile settings\": \"Ρυθμίσεις Προφίλ\",\n        \"Sign in\": \"Σύνδεση\",\n        \"Switch Accounts\": \"Αλλαγή Λογαριασμών\",\n        \"Toggle Mobile Mode\": \"Εναλλαγή Λειτουργίας Κινητού\",\n        \"Activation\": \"Ενεργοποίηση\",\n        \"Billing account\": \"Λογαριασμός Χρέωσης\",\n        \"Upgrade Plan\": \"Αναβάθμιση Προγράμματος\",\n        \"Use This Template\": \"Χρήση Αυτού Του Προτύπου\",\n        \"Sign up\": \"Εγγραφή\",\n        \"Manage team\": \"Διαχείριση Ομάδας\",\n        \"Sign out\": \"Αποσύνδεση\",\n        \"Support Grist\": \"Υποστήριξη Grist\"\n    },\n    \"ApiKey\": {\n        \"Create\": \"Δημιουργία\",\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Δημιουργώντας ένα κλειδί API, θα μπορείτε να πραγματοποιείτε κλήσεις API για τον δικό σας λογαριασμό.\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Αυτό το κλειδί API μπορεί να χρησιμοποιηθεί για ανώνυμη πρόσβαση σε αυτόν τον λογαριασμό μέσω του API.\",\n        \"Remove\": \"Αφαίρεση\",\n        \"Click to show\": \"Κάντε κλικ για εμφάνιση\",\n        \"Remove API Key\": \"Αφαίρεση κλειδιού API\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Αυτό το κλειδί API μπορεί να χρησιμοποιηθεί για πρόσβαση στον λογαριασμό σας μέσω του API. Μην κοινοποιείτε το κλειδί API σας σε κανέναν.\",\n        \"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. Αυτό θα έχει ως αποτέλεσμα την απόρριψη όλων των μελλοντικών αιτημάτων που χρησιμοποιούν αυτό το κλειδί API. Θέλετε ακόμα να το διαγράψετε;\"\n    },\n    \"ViewAsDropdown\": {\n        \"Users from table\": \"Χρήστες από τον πίνακα\",\n        \"Example Users\": \"Παραδείγματα χρηστών\",\n        \"View as\": \"Προβολή Ως\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"Η φόρτωση του αρχείου καταγραφής ενεργειών απέτυχε\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Ο πίνακας {{tableId}} αφαιρέθηκε στη συνέχεια στην ενέργεια #{{actionNum}}\",\n        \"All tables\": \"Όλοι οι πίνακες\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Αυτή η σειρά αφαιρέθηκε στη συνέχεια στην ενέργεια {{action.actionNum}}\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Η στήλη {{colId}} αφαιρέθηκε στη συνέχεια στην ενέργεια #{{action.actionNum}}\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"Η στήλη {{colId}} αφαιρέθηκε στην συνέχεια στην ενέργεια #{{actionNum}}\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Αυτή η γραμμή αφαιρέθηκε στην συνέχεια στην ενέργεια {{actionNum}}\",\n        \"History blocked because of access rules.\": \"Το ιστορικό αποκλείστηκε λόγω κανόνων πρόσβασης.\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Προσθήκη Νέου\"\n    },\n    \"App\": {\n        \"Key\": \"Κλειδί\",\n        \"Memory Error\": \"Σφάλμα Μνήμης\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Μεταφραστές: παρακαλούμε μεταφράστε αυτό μόνο όταν η γλώσσα σας είναι έτοιμη να προσφερθεί στους χρήστες\",\n        \"Description\": \"Περιγραφή\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Αρχική Σελίδα\",\n        \"Legacy\": \"Κληροδότημα (Legacy)\",\n        \"Personal Site\": \"Προσωπική Ιστοσελίδα\",\n        \"Team Site\": \"Ιστότοπος Oμάδας\",\n        \"Grist Templates\": \"Πρότυπα Grist\",\n        \"Billing account\": \"Λογαριασμός Χρέωσης\",\n        \"Manage team\": \"Διαχείριση Ομάδας\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Επιστροφή στην αρχική\"\n    },\n    \"CellContextMenu\": {\n        \"Clear values\": \"Καθαρισμός τιμών\",\n        \"Copy anchor link\": \"Αντιγραφή συνδέσμου αγκύρωσης\",\n        \"Delete {{count}} columns_one\": \"Διαγραφή στήλης\",\n        \"Delete {{count}} columns_other\": \"Διαγραφή {{count}} στηλών\",\n        \"Delete {{count}} rows_one\": \"Διαγραφή γραμμής\",\n        \"Delete {{count}} rows_other\": \"Διαγραφή {{count}} γραμμών\",\n        \"Filter by this value\": \"Φιλτράρισμα με βάση αυτήν την τιμή\",\n        \"Duplicate rows_one\": \"Αντιγραφή σειράς\",\n        \"Duplicate rows_other\": \"Αντιγραφή σειρών\",\n        \"Insert column to the right\": \"Εισαγωγή στήλης στα δεξιά\",\n        \"Insert row\": \"Εισαγωγή γραμμής\",\n        \"Insert row below\": \"Εισαγωγή γραμμής από κάτω\",\n        \"Reset {{count}} columns_one\": \"Επαναφορά στήλης\",\n        \"Reset {{count}} entire columns_one\": \"Επαναφορά ολόκληρης της στήλης\",\n        \"Comment\": \"Σχόλιο\",\n        \"Copy\": \"Αντιγραφή\",\n        \"Cut\": \"Αποκοπή\",\n        \"Paste\": \"Επικόλληση\",\n        \"Copy with headers\": \"Αντιγραφή με κεφαλίδες\",\n        \"Insert column to the left\": \"Εισαγωγή στήλης στα αριστερά\",\n        \"Reset {{count}} columns_other\": \"Επαναφορά {{count}} στηλών\",\n        \"Insert row above\": \"Εισαγωγή γραμμής από πάνω\",\n        \"Clear cell\": \"Καθαρισμός κελιού\",\n        \"Reset {{count}} entire columns_other\": \"Επαναφορά {{count}} ολόκληρων στηλών\"\n    },\n    \"ChartView\": {\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Κάθε σειρά Y ακολουθείται από δύο σειρές, για τις επάνω και κάτω γραμμές σφάλματος.\",\n        \"Pick a column\": \"Επιλέξτε μια στήλη\",\n        \"Toggle chart aggregation\": \"Εναλλαγή συνάθροισης γραφημάτων\",\n        \"selected new group data columns\": \"επιλεγμένες νέες στήλες δεδομένων ομάδας\",\n        \"Create separate series for each value of the selected column.\": \"Δημιουργήστε ξεχωριστές σειρές για κάθε τιμή της επιλεγμένης στήλης.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Κάθε σειρά Y ακολουθείται από μια σειρά για το μήκος των γραμμών σφάλματος.\",\n        \"LABEL\": \"ΕΤΙΚΕΤΑ\",\n        \"Bar chart\": \"Γράφημα με Μπάρες\",\n        \"Pie chart\": \"Γράφημα με Πίτες\",\n        \"Area chart\": \"Χωρικό Γράφημα\",\n        \"Line chart\": \"Γραμμικό Γράφημα\",\n        \"Scatter plot\": \"Γράφημα Διασποράς\",\n        \"Kaplan-Meier plot\": \"Γράφημα Kaplan-Meier\",\n        \"Split series\": \"Διαχωρισμένη σειρά\",\n        \"Invert Y-axis\": \"Αντιστροφή άξονα Y\",\n        \"Orientation\": \"Προσανατολισμός\",\n        \"Vertical\": \"Κάθετα\",\n        \"Horizontal\": \"Οριζόντια\",\n        \"Log scale Y-axis\": \"Λογαριθμική κλίμακα άξονα Y\",\n        \"Hole size\": \"Μέγεθος τρύπας\",\n        \"Show total\": \"Εμφάνιση Συνόλου\",\n        \"Text size\": \"Μέγεθος Κειμένου\",\n        \"Connect gaps\": \"Σύνδεση κενών\",\n        \"Show markers\": \"Εμφάνιση δεικτών\",\n        \"Stack series\": \"Σειρά στοίβας\",\n        \"Error bars\": \"Μπάρες σφάλματος\",\n        \"None\": \"Κανένα\",\n        \"Symmetric\": \"Συμμετρικό\",\n        \"Above+Below\": \"Πάνω+Κάτω\",\n        \"Split Series\": \"Διαίρεση Σειρών\",\n        \"X-AXIS\": \"Άξονας-Χ\",\n        \"Aggregate values\": \"Συνολικές τιμές\",\n        \"non-numeric columns are not shown\": \"Δεν εμγανίζονται μη-αριθμητικές στήλες\",\n        \"non-numeric column is not shown\": \"δεν εμφανίζεται μη-αριθμητική στήλη\",\n        \"selected new x-axis\": \"επιλεγμένος νέος άξονας x\",\n        \"Remove\": \"Αφαίρεση\",\n        \"Donut chart\": \"Γράφημα με Ντόνατ\",\n        \"Add series\": \"Προσθήκη Σειράς\",\n        \"SERIES\": \"ΣΕΙΡΕΣ\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (προαιρετικός)\",\n        \"Add\": \"Προσθήκη\",\n        \"Enter Custom URL\": \"Εισαγάγετε προσαρμοσμένη διεύθυνση URL\",\n        \"Open configuration\": \"Άνοιγμα διαμόρφωσης\",\n        \"Pick a column\": \"Επιλέξτε μια στήλη\",\n        \"Pick a {{columnType}} column\": \"Επιλέξτε μια στήλη {{columnType}}\",\n        \"Read selected table\": \"Ανάγνωση επιλεγμένου πίνακα\",\n        \"Select Custom Widget\": \"Επιλογή Προσαρμοσμένου Γραφικού Στοιχείου (Widget)\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"Η στήλη {{wrongTypeCount}} που δεν είναι {{columnType}} δεν εμφανίζεται\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"Δεν εμφανίζονται {{wrongTypeCount}} στήλες που δεν είναι {{columnType}}\",\n        \"Clear selection\": \"Καθαρισμός επιλογής\",\n        \"ACCESS LEVEL\": \"ΕΠΙΠΕΔΟ ΠΡΟΣΒΑΣΗΣ\",\n        \"Last updated:\": \"Τελευταία ενημέρωση:\",\n        \"Missing description and author information.\": \"Λείπουν η περιγραφή και οι πληροφορίες του συγγραφέα.\",\n        \"Widget\": \"Γραφικό στοιχείο (Widget)\",\n        \"Widget does not require any permissions.\": \"Το γραφικό στοιχείο (Widget) δεν απαιτεί δικαιώματα.\",\n        \"No {{columnType}} columns in table.\": \"Δεν υπάρχουν στήλες {{columnType}} στον πίνακα.\",\n        \"Accept\": \"Αποδοχή\",\n        \"Custom URL\": \"Προσαρμοσμένη διεύθυνση URL\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Το γραφικό στοιχείο (widget) χρειάζεται {{fullAccess}} για αυτό το έγγραφο.\",\n        \"Learn more about custom widgets\": \"Μάθετε περισσότερα σχετικά με τα προσαρμοσμένα γραφικά στοιχεία (widgets)\",\n        \"Reject\": \"Απόρριψη\",\n        \"Full document access\": \"Πλήρης πρόσβαση σε έγγραφα\",\n        \"No document access\": \"Δεν υπάρχει πρόσβαση σε έγγραφα\",\n        \"Widget needs to {{read}} the current table.\": \"Το γραφικό στοιχείο (widget) πρέπει να {{read}} τον τρέχοντα πίνακα.\",\n        \"Developer:\": \"Προγραμματιστής:\",\n        \"Change custom widget\": \"Αλλαγή προσαρμοσμένου γραφικού στοιχείου\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Κάντε κλικ για αντιγραφή\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Διαγραφή δεδομένων {{formattedTableName}} και κατάργησή τους από όλες τις σελίδες;\",\n        \"Duplicate table\": \"Δημιουργία διπλότυπου πίνακα\",\n        \"Table ID copied to clipboard\": \"Το αναγνωριστικό πίνακα αντιγράφηκε στο πρόχειρο\",\n        \"You do not have edit access to this document\": \"Δεν έχετε πρόσβαση επεξεργασίας σε αυτό το έγγραφο\",\n        \"Edit record card\": \"Επεξεργασία Κάρτας Εγγραφής\",\n        \"Record Card\": \"Κάρτα Εγγραφής\",\n        \"Record Card Disabled\": \"Η κάρτα εγγραφής απενεργοποιήθηκε\",\n        \"{{action}} Record Card\": \"{{action}} Κάρτα Εγγραφής\",\n        \"Rename table\": \"Μετονομασία Πίνακα\",\n        \"Remove table\": \"Αφαίρεση Πίνακα\",\n        \"Raw Data Tables\": \"Πίνακες Ακατέργαστων Δεδομένων\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Δραστηριότητα\",\n        \"Beta\": \"Δοκιμαστική (Beta)\",\n        \"Open snapshot\": \"Άνοιγμα Στιγμιότυπου\",\n        \"Snapshots\": \"Στιγμιότυπα\",\n        \"Snapshots are unavailable.\": \"Τα στιγμιότυπα δεν είναι διαθέσιμα.\",\n        \"Compare to current\": \"Σύγκριση με το Τρέχον\",\n        \"Compare to previous\": \"Σύγκριση με το Προηγούμενο\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Μόνο οι κάτοχοι έχουν πρόσβαση σε στιγμιότυπα για έγγραφα με κανόνες πρόσβασης.\"\n    },\n    \"DocMenu\": {\n        \"Access Details\": \"Λεπτομέρειες Πρόσβασης\",\n        \"All documents\": \"Όλα Τα Έγγραφα\",\n        \"By Date Modified\": \"Κατά Ημερομηνία Τροποποίησης\",\n        \"By Name\": \"Κατά Όνομα\",\n        \"Delete\": \"Διαγραφή\",\n        \"Delete Forever\": \"Διαγραφή Για Πάντα\",\n        \"Document will be moved to Trash.\": \"Το έγγραφο θα μετακινηθεί στον Κάδο Απορριμμάτων.\",\n        \"Edited {{at}}\": \"Επεξεργασμένο {{at}}\",\n        \"Examples & Templates\": \"Παραδείγματα & Πρότυπα\",\n        \"Examples and Templates\": \"Παραδείγματα και Πρότυπα\",\n        \"Featured\": \"Προτεινόμενα\",\n        \"Manage users\": \"Διαχείριση Χρηστών\",\n        \"More Examples and Templates\": \"Περισσότερα Παραδείγματα και Πρότυπα\",\n        \"Move\": \"Μετακίνηση\",\n        \"Move {{name}} to workspace\": \"Μετακίνηση του {{name}} στον χώρο εργασίας\",\n        \"Other Sites\": \"Άλλοι Ιστότοποι\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Να διαγραφεί οριστικά το \\\"{{name}}\\\";\",\n        \"Remove\": \"Αφαίρεση\",\n        \"Rename\": \"Μετονομασία\",\n        \"This service is not available right now\": \"Αυτή η υπηρεσία δεν είναι διαθέσιμη αυτήν τη στιγμή\",\n        \"To restore this document, restore the workspace first.\": \"Για να επαναφέρετε αυτό το έγγραφο, επαναφέρετε πρώτα τον χώρο εργασίας.\",\n        \"Unpin Document\": \"Ξεκαρφίτσωμα Εγγράφου\",\n        \"Workspace not found\": \"Δεν βρέθηκε ο χώρος εργασίας\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Βρίσκεστε στον προσωπικό σας ιστότοπο. Έχετε επίσης πρόσβαση στους ακόλουθους ιστότοπους:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Μπορείτε να διαγράψετε έναν χώρο εργασίας οριστικά όταν δεν υπάρχουν πλέον έγγραφα σε αυτόν.\",\n        \"Create my first document\": \"Δημιουργία του πρώτου μου εγγράφου\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Έχετε πρόσβαση μόνο για ανάγνωση σε αυτόν τον ιστότοπο. Προς το παρόν δεν υπάρχουν έγγραφα.\",\n        \"Restore\": \"Ανάκτηση\",\n        \"personal site\": \"προσωπική ιστοσελίδα\",\n        \"Any documents created in this site will appear here.\": \"Οποιαδήποτε έγγραφα δημιουργούνται σε αυτόν τον ιστότοπο θα εμφανίζονται εδώ.\",\n        \"Document will be permanently deleted.\": \"Το έγγραφο θα διαγραφεί οριστικά.\",\n        \"Discover More Templates\": \"Ανακαλύψτε Περισσότερα Πρότυπα\",\n        \"Requires edit permissions\": \"Απαιτούνται δικαιώματα επεξεργασίας\",\n        \"(The organization needs a paid plan)\": \"(Ο οργανισμός χρειάζεται ένα πρόγραμμα επί πληρωμή)\",\n        \"Current workspace\": \"Τρέχων χώρος εργασίας\",\n        \"Trash\": \"Κάδος Απορριμμάτων\",\n        \"Pin Document\": \"Καρφίτσωμα Εγγράφου\",\n        \"Delete {{name}}\": \"Διαγραφή {{name}}\",\n        \"Deleted {{at}}\": \"Διαγράφηκε {{at}}\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Τα έγγραφα παραμένουν στον Κάδο για 30 ημέρες και μετά διαγράφονται οριστικά.\",\n        \"Pinned Documents\": \"Καρφιτσωμένα Έγγραφα\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Βρίσκεστε στον ιστότοπο {{siteName}}. Έχετε επίσης πρόσβαση στους ακόλουθους ιστότοπους:\",\n        \"Trash is empty.\": \"Ο κάδος απορριμμάτων είναι άδειος.\",\n        \"Grid view\": \"Προβολή πλέγματος\",\n        \"List view\": \"Προβολή λίστας\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Προσθήκη Κενού Πίνακα\",\n        \"Enter recovery mode\": \"Εισέλθετε σε λειτουργία ανάκτησης\",\n        \"Error accessing document\": \"Σφάλμα πρόσβασης στο έγγραφο κατά την πρόσβαση σε αυτό\",\n        \"Reload\": \"Επαναφόρτωση\",\n        \"You do not have edit access to this document\": \"Δεν έχετε πρόσβαση επεξεργασίας σε αυτό το έγγραφο\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Λυπούμαστε, η πρόσβαση σε αυτό το έγγραφο έχει απορριφθεί. [{{error}}]\",\n        \"Add page\": \"Προσθήκη Σελίδας\",\n        \"Add widget to page\": \"Προσθήκη Γραφικού Στοιχείου (Widget) στην Σελίδα\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Οι κάτοχοι εγγράφων μπορούν να επιχειρήσουν να ανακτήσουν το έγγραφο. [{{error}}]\",\n        \"You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]\": \"Μπορείτε να δοκιμάσετε να επαναφορτώσετε το έγγραφο ή να χρησιμοποιήσετε τη λειτουργία ανάκτησης. Η λειτουργία ανάκτησης ανοίγει το έγγραφο ώστε να είναι πλήρως προσβάσιμο στους κατόχους και μη προσβάσιμο σε άλλους. Απενεργοποιεί επίσης τους τύπους. [{{error}}]\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Παρακαλούμε επαναφορτώστε το έγγραφο και, εάν το σφάλμα επιμένει, επικοινωνήστε με τους κατόχους του εγγράφου για να επιχειρήσετε την ανάκτησή του. [{{error}}]\"\n    },\n    \"DocTour\": {\n        \"No valid document tour\": \"Δεν υπάρχει έγκυρη περιήγηση για το έγγραφο\",\n        \"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.\": \"Δεν είναι δυνατή η δημιουργία περιήγησης εγγράφου από τα δεδομένα σε αυτό το έγγραφο. Βεβαιωθείτε ότι υπάρχει ένας πίνακας με το όνομα GristDocTour με στήλες Τίτλος, Σώμα, Τοποθέτηση και Τοποθεσία. (Title, Body, Placement, and Location.\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Νόμισμα:\",\n        \"Document settings\": \"Ρυθμίσεις Εγγράφου\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Μηχανή (πειραματική αλλαγή {{span}} με δική σας ευθύνη):\",\n        \"Local currency ({{currency}})\": \"Τοπικό νόμισμα ({{currency}})\",\n        \"Locale:\": \"Τοπικές Ρυθμίσεις:\",\n        \"Save and Reload\": \"Αποθήκευση και Επαναφόρτωση\",\n        \"Time Zone:\": \"Ζώνη Ώρας:\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"Το αναγνωριστικό εγγράφου αντιγράφηκε στο πρόχειρο\",\n        \"Ok\": \"Εντάξει\",\n        \"Manage Webhooks\": \"Διαχείριση Webhooks\",\n        \"Webhooks\": \"Webhooks\",\n        \"API URL copied to clipboard\": \"Η διεύθυνση URL του API αντιγράφηκε στο πρόχειρο\",\n        \"API documentation.\": \"Τεκμηρίωση API.\",\n        \"Coming soon\": \"Σύντομα διαθέσιμο\",\n        \"Copy to clipboard\": \"Αντιγραφή στο πρόχειρο\",\n        \"API console\": \"Κονσόλα API\",\n        \"Default for DateTime columns\": \"Προεπιλογή για στήλες Ημερομηνίας/Ώρας\",\n        \"For currency columns\": \"Για στήλες νομισμάτων\",\n        \"For number and date formats\": \"Για μορφές αριθμών και ημερομηνίας\",\n        \"Formula times\": \"Χρόνοι φόρμουλας\",\n        \"Find slow formulas\": \"Βρείτε αργές φόρμουλες\",\n        \"Hard reset of data engine\": \"Σκληρή επαναφορά της μηχανής δεδομένων\",\n        \"Notify other services on doc changes\": \"Ειδοποίηση άλλων υπηρεσιών σχετικά με αλλαγές εγγράφων\",\n        \"Python\": \"Python\",\n        \"Time zone\": \"Ζώνη Ώρας\",\n        \"python3 (recommended)\": \"python3 (συνιστάται)\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Formula timer\": \"Χρονόμετρο φόρμουλας\",\n        \"Reload data engine\": \"Επαναφόρτωση μηχανής δεδομένων\",\n        \"Reload data engine?\": \"Επαναφόρτωση της μηχανής δεδομένων;\",\n        \"Start timing\": \"Έναρξη χρονομέτρησης\",\n        \"Stop timing...\": \"Τερματισμός χρονομέτρησης...\",\n        \"Only available to document editors\": \"Διαθέσιμο μόνο σε συντάκτες εγγράφων\",\n        \"Only available to document owners\": \"Διαθέσιμο μόνο σε κατόχους εγγράφων\",\n        \"Template mode\": \"Λειτουργία προτύπου\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Κανονική συμπεριφορά εγγράφου. Όλοι οι χρήστες εργάζονται στο ίδιο αντίγραφο του εγγράφου.\",\n        \"Template\": \"Πρότυπο\",\n        \"fiddle mode\": \"λειτουργία βιολιού (fiddle mode)\",\n        \"Confirm change\": \"Επιβεβαίωση αλλαγής\",\n        \"Tutorial\": \"Οδηγός Εκμάθησης\",\n        \"Attachment storage\": \"Αποθήκευση συνημμένων\",\n        \"Being transfer\": \"Μεταφορά\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Τα πρόσφατα μεταφορτωμένα συνημμένα θα τοποθετηθούν στον εξωτερικό χώρο αποθήκευσης.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Τα πρόσφατα μεταφορτωμένα συνημμένα θα τοποθετηθούν στον εσωτερικό χώρο αποθήκευσης.\",\n        \"No external stores available\": \"Δεν υπάρχουν διαθέσιμοι εξωτερικοί χώροι αποθήκευσης\",\n        \"External\": \"Εξωτερικό\",\n        \"Internal\": \"Εσωτερικό\",\n        \"[Learn more.]({{learnLink}})\": \"[Μάθετε περισσότερα.]({{learnLink}})\",\n        \"Upload\": \"Μεταφόρτωση\",\n        \"Upload missing attachments\": \"Μεταφόρτωση συνημμένων που λείπουν\",\n        \"Regular document\": \"Κανονικό έγγραφο\",\n        \"ID for API use\": \"Αναγνωριστικό για χρήση API\",\n        \"Reload\": \"Επαναφόρτωση\",\n        \"Document automatically opens as a user-specific copy.\": \"Το έγγραφο ανοίγει αυτόματα ως αντίγραφο συγκεκριμένου χρήστη.\",\n        \"Base doc URL: {{docApiUrl}}\": \"URL βασικού εγγράφου: {{docApiUrl}}\",\n        \"This document's ID (for API use):\": \"Αναγνωριστικό αυτού του εγγράφου (για χρήση API):\",\n        \"Transfer in progress\": \"Μεταφορά σε εξέλιξη\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Αναγκαστική επαναφόρτωση του εγγράφου κατά τη χρονομέτρηση των τύπων και εμφάνιση του αποτελέσματος.\",\n        \"Document ID\": \"Αναγνωριστικό εγγράφου\",\n        \"Python version used\": \"Χρησιμοποιήθηκε έκδοση Python\",\n        \"Save\": \"Αποθήκευση\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Μπορείτε να κάνετε αλλαγές στο έγγραφο και, στη συνέχεια, να σταματήσετε τη χρονομέτρηση για να δείτε τα αποτελέσματα.\",\n        \"Currency\": \"Νόμισμα\",\n        \"Locale\": \"Τοπική Ρύθμιση\",\n        \"Manage webhooks\": \"Διαχείριση webhooks\",\n        \"Data engine\": \"Μηχανή Δεδομένων\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"Αναγνωριστικό εγγράφου που θα χρησιμοποιείται κάθε φορά που το REST API καλεί το {{docId}}. Δείτε {{apiURL}}\",\n        \"Try API calls from the browser\": \"Δοκιμάστε κλήσεις API από το πρόγραμμα περιήγησης\",\n        \"python2 (legacy)\": \"python2 (παλαιού τύπου)\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Αυτό θα εκτελέσει μια πλήρη επαναφόρτωση της μηχανής δεδομένων. Αυτό μπορεί να βοηθήσει εάν η μηχανή δεδομένων έχει κολλήσει σε έναν ατέρμονα βρόχο, επεξεργάζεται επ' αόριστον την τελευταία αλλαγή ή έχει παρουσιάσει σφάλμα. Δεν θα χαθούν δεδομένα, εκτός από πιθανές ενέργειες που εκκρεμούν αυτήν τη στιγμή.\",\n        \"Time reload\": \"Επαναφόρτωση χρόνου\",\n        \"Timing is on\": \"Η χρονομέτρηση είναι ενεργοποιημένη\",\n        \"Change document type\": \"Αλλαγή τύπου εγγράφου\",\n        \"Change nature of document\": \"Αλλαγή φύσης εγγράφου\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Το έγγραφο ανοίγει αυτόματα στο {{fiddleModeDocUrl}}. Οποιοσδήποτε μπορεί να το επεξεργαστεί, δημιουργώντας ένα νέο μη αποθηκευμένο αντίγραφο.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Μόλις ξεκινήσετε τη χρονομέτρηση, το Grist θα μετρήσει τον χρόνο που απαιτείται για την αξιολόγηση κάθε τύπου. Αυτό επιτρέπει τη διάγνωση των τύπων που ευθύνονται για την αργή απόδοση κατά το πρώτο άνοιγμα ενός εγγράφου ή όταν ένα έγγραφο ανταποκρίνεται στις αλλαγές.\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Κάντε κλικ στην επιλογή \\\"Έναρξη μεταφοράς\\\" για να τα μεταφέρετε σε εξωτερικό χώρο αποθήκευσης.\",\n        \"Edit\": \"Επεξεργασία\",\n        \"Regular\": \"Κανονικό\",\n        \"**Some existing attachments are still external**.\": \"**Ορισμένα υπάρχοντα συνημμένα εξακολουθούν να είναι εξωτερικά**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Ορισμένα υπάρχοντα συνημμένα εξακολουθούν να είναι εσωτερικά** (αποθηκευμένα σε αρχείο SQLite).\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Κάντε κλικ στην επιλογή \\\"Έναρξη μεταφοράς\\\" για να τα μεταφέρετε στον εσωτερικό χώρο αποθήκευσης (αποθηκευμένο στο αρχείο SQLite του εγγράφου).\",\n        \"Preferred storage for this document\": \"Προτιμώμενος χώρος αποθήκευσης για αυτό το έγγραφο\",\n        \"Start transfer\": \"Έναρξη μεταφοράς\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Ορισμένα υπάρχοντα συνημμένα εξακολουθούν να είναι [εξωτερικά]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Ορισμένα υπάρχοντα συνημμένα εξακολουθούν να είναι [εσωτερικά]({{internalLink}})** (αποθηκευμένα σε αρχείο SQLite).\",\n        \"Uploading...\": \"Μεταφόρτωση...\",\n        \"Default\": \"Προκαθορισμένο\",\n        \"Default, template, or tutorial\": \"Προκαθορισμένο, πρότυπο, ή εκπαιδευτικό\",\n        \"Document type\": \"Τύπος αρχείου\",\n        \"Allow others to suggest changes\": \"Επιτρέψτε σε άλλους να προτείνουν αλλαγές\",\n        \"Enable suggestions\": \"Ενεργοποίηση προτάσεων\",\n        \"Suggestions\": \"Προτάσεις\",\n        \"experiment\": \"πείραμα\"\n    },\n    \"DocumentUsage\": {\n        \"Data size\": \"Μέγεθος δεδομένων\",\n        \"For higher limits, \": \"Για υψηλότερα όρια, \",\n        \"Rows\": \"Γραμμές\",\n        \"Usage\": \"Χρήση\",\n        \"start your 30-day free trial of the Pro plan.\": \"Ξεκινήστε τη δωρεάν δοκιμή 30 ημερών του προγράμματος Pro.\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"Τα στατιστικά στοιχεία χρήσης είναι διαθέσιμα μόνο σε χρήστες με πλήρη πρόσβαση στα δεδομένα του εγγράφου.\",\n        \"Size of attachments\": \"Μέγεθος Συνημμένων\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Επικοινωνήστε με τον κάτοχο του ιστότοπου για να αναβαθμίσετε το πρόγραμμα και να αυξήσετε τα όρια.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Επαναφορά τελευταίας επεξεργασίας\",\n        \"Undo discard\": \"Αναίρεση απόρριψης\"\n    },\n    \"DuplicateTable\": {\n        \"Name for new table\": \"Όνομα για τον νέο πίνακα\",\n        \"Only the document default access rules will apply to the copy.\": \"Μόνο οι προεπιλεγμένοι κανόνες πρόσβασης του εγγράφου θα ισχύουν για το αντίγραφο.\",\n        \"Copy all data in addition to the table structure.\": \"Αντιγράψτε όλα τα δεδομένα μαζί με τη δομή του πίνακα.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Αντί να αντιγράφετε πίνακες, είναι συνήθως καλύτερο να τμηματοποιείτε δεδομένα χρησιμοποιώντας συνδεδεμένες προβολές. {{link}}\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Πρόγραμμα Afterschool\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Δείτε το σχετικό μας σεμινάριο για να μάθετε πώς να δημιουργείτε συνοπτικούς πίνακες και γραφήματα και να συνδέετε γραφήματα δυναμικά.\",\n        \"Investment Research\": \"Έρευνα Επενδύσεων\",\n        \"Tutorial: Analyze & Visualize\": \"Εκμάθηση: Ανάλυση και Οπτικοποίηση\",\n        \"Tutorial: Manage Business Data\": \"Εκμάθηση: Διαχείριση επιχειρηματικών δεδομένων\",\n        \"Lightweight CRM\": \"(ελαφρύ) Lightweight CRM\",\n        \"Welcome to the Lightweight CRM template\": \"Καλώς ορίσατε στο πρότυπο Lightweight CRM\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Δείτε το σχετικό μας σεμινάριο για το πώς να συνδέσετε δεδομένα και να δημιουργήσετε διατάξεις υψηλής παραγωγικότητας.\",\n        \"Tutorial: Create a CRM\": \"Εκμάθηση: Δημιουργία ενός CRM\",\n        \"Welcome to the Investment Research template\": \"Καλώς ορίσατε στο πρότυπο Έρευνας Επενδύσεων\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Δείτε το σχετικό μας σεμινάριο για το πώς να μοντελοποιείτε επιχειρηματικά δεδομένα, να χρησιμοποιείτε τύπους και να διαχειρίζεστε την πολυπλοκότητα.\",\n        \"Welcome to the Afterschool Program template\": \"Καλώς ορίσατε στο πρότυπο του προγράμματος Afterschool\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"ΣΥΜΠΕΡΙΦΟΡΑ ΣΤΗΛΗΣ\",\n        \"COLUMN LABEL AND ID\": \"ΕΤΙΚΕΤΑ ΚΑΙ ΑΝΑΓΝΩΡΙΣΤΙΚΟ ΣΤΗΛΗΣ\",\n        \"Convert to trigger formula\": \"Μετατροπή σε φόρμουλα ενεργοποίησης\",\n        \"Data columns_one\": \"Στήλη δεδομένων\",\n        \"Data columns_other\": \"Στήλες δεδομένων\",\n        \"Empty columns_one\": \"Κενή Στήλη\",\n        \"Formula columns_other\": \"Στήλες Φόρμουλας\",\n        \"Make into data column\": \"Μετατροπή σε στήλη δεδομένων\",\n        \"Set formula\": \"Ορισμός Φόρμουλας\",\n        \"Set trigger formula\": \"Ορισμός φόρμουλας ενεργοποίησης\",\n        \"DESCRIPTION\": \"ΠΕΡΙΓΡΑΦΗ\",\n        \"Clear and make into formula\": \"Καθαρίστε και μετατρέψτε το σε φόρμουλα\",\n        \"Convert column to data\": \"Μετατροπή στήλης σε δεδομένα\",\n        \"Formula columns_one\": \"Στήλη Φόρμουλας\",\n        \"TRIGGER FORMULA\": \"ΦΟΡΜΟΥΛΑ ΕΝΕΡΓΟΠΟΙΗΣΗΣ\",\n        \"Clear and reset\": \"Εκκαθάριση και επαναφορά\",\n        \"Enter formula\": \"Εισαγωγή φόρμουλας\",\n        \"Mixed Behavior\": \"Μικτή Συμπεριφορά\",\n        \"Column options are limited in summary tables.\": \"Οι επιλογές στηλών είναι περιορισμένες στους συνοπτικούς πίνακες.\",\n        \"Empty columns_other\": \"Κενές Στήλες\"\n    },\n    \"FieldMenus\": {\n        \"Use separate settings\": \"Χρησιμοποιήστε ξεχωριστές ρυθμίσεις\",\n        \"Using common settings\": \"Χρήση κοινών ρυθμίσεων\",\n        \"Using separate settings\": \"Χρήση ξεχωριστών ρυθμίσεων\",\n        \"Revert to common settings\": \"Επαναφορά στις κοινές ρυθμίσεις\",\n        \"Save as common settings\": \"Αποθήκευση ως κοινές ρυθμίσεις\"\n    },\n    \"FilterBar\": {\n        \"Search Columns\": \"Αναζήτηση Στηλών\",\n        \"SearchColumns\": \"Αναζήτηση στηλών\"\n    },\n    \"GridOptions\": {\n        \"Horizontal gridlines\": \"Οριζόντιες Γραμμές Πλέγματος\",\n        \"Zebra stripes\": \"Ρίγες Ζέβρας\",\n        \"Grid Options\": \"Επιλογές Πλέγματος\",\n        \"Vertical gridlines\": \"Κάθετες Γραμμές Πλέγματος\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Προσθήκη στήλης\",\n        \"Add to sort\": \"Προσθήκη στην ταξινόμηση\",\n        \"Clear values\": \"Καθαρισμός τιμών\",\n        \"Column Options\": \"Επιλογές Στήλης\",\n        \"Convert formula to data\": \"Μετατροπή φόρμουλας σε δεδομένα\",\n        \"Delete {{count}} columns_other\": \"Διαγραφή {{count}} στηλών\",\n        \"Freeze {{count}} columns_one\": \"Πάγωμα αυτής της στήλης\",\n        \"Freeze {{count}} columns_other\": \"Πάγωμα {{count}} στηλών\",\n        \"Freeze {{count}} more columns_other\": \"Πάγωμα {{count}} ακόμη στηλών\",\n        \"Hide {{count}} columns_other\": \"Απόκρυψη {{count}} στηλών\",\n        \"Insert column to the {{to}}\": \"Εισαγωγή στήλης στο {{to}}\",\n        \"Reset {{count}} entire columns_other\": \"Επαναφορά {{count}} ολόκληρων στηλών\",\n        \"Show column {{- label}}\": \"Εμφάνιση στήλης {{- label}}\",\n        \"Sort\": \"Ταξινόμηση\",\n        \"Sorted (#{{count}})_one\": \"Ταξινομημένα (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Ταξινομημένα (#{{count}})\",\n        \"Unfreeze all columns\": \"Ξεπάγωμα όλων των στηλών\",\n        \"Unfreeze {{count}} columns_one\": \"Ξεπάγωμα αυτής της στήλης\",\n        \"Unfreeze {{count}} columns_other\": \"Ξεπάγωμα {{count}} στηλών\",\n        \"Insert column to the left\": \"Εισαγωγή στήλης στα αριστερά\",\n        \"Insert column to the right\": \"Εισαγωγή στήλης στα δεξιά\",\n        \"Apply on record changes\": \"Εφαρμογή σε αλλαγές εγγραφής\",\n        \"Apply to new records\": \"Εφαρμογή σε νέες εγγραφές\",\n        \"Authorship\": \"Συγγραφή\",\n        \"Created By\": \"Δημιουργήθηκε Από\",\n        \"Hidden Columns\": \"Κρυφές στήλες\",\n        \"Last Updated At\": \"Τελευταία Ενημέρωση Στις\",\n        \"Last Updated By\": \"Τελευταία Ενημέρωση Από\",\n        \"Created At\": \"Δημιουργήθηκε Στις\",\n        \"Shortcuts\": \"Συντομεύσεις\",\n        \"Timestamp\": \"Χρονική σήμανση\",\n        \"no reference column\": \"στήλη χωρίς αναφορά\",\n        \"Adding duplicates column\": \"Προσθήκη διπλότυπων στηλών\",\n        \"Duplicate in {{- label}}\": \"Διπλότυπο σε {{- label}}\",\n        \"Search columns\": \"Αναζήτηση στηλών\",\n        \"Add column with type\": \"Προσθήκη στήλης με τύπο\",\n        \"Add formula column\": \"Προσθήκη στήλης φόρμουλας\",\n        \"Created by\": \"Δημιουργήθηκε από\",\n        \"Detect duplicates in...\": \"Εντοπισμός διπλότυπων σε...\",\n        \"Last updated at\": \"Τελευταία ενημέρωση στις\",\n        \"Last updated by\": \"Τελευταία ενημέρωση από\",\n        \"Numeric\": \"Αριθμητικό\",\n        \"Text\": \"Κείμενο\",\n        \"Any\": \"Οτιδήποτε\",\n        \"Integer\": \"Ακέραιος\",\n        \"Toggle\": \"Εναλλαγή\",\n        \"Date\": \"Ημερομηνία\",\n        \"Choice\": \"Επιλογή\",\n        \"Reference List\": \"Λίστα Αναφοράς\",\n        \"Attachment\": \"Συννημένο\",\n        \"More sort options ...\": \"Περισσότερες επιλογές ταξινόμησης…\",\n        \"Rename column\": \"Μετονομασία στήλης\",\n        \"Reset {{count}} columns_one\": \"Επαναφορά στήλης\",\n        \"Reset {{count}} columns_other\": \"Επαναφορά {{count}} στηλών\",\n        \"Reset {{count}} entire columns_one\": \"Επαναφορά ολόκληρης της στήλης\",\n        \"Lookups\": \"Αναζητήσεις (Lookups)\",\n        \"Adding UUID column\": \"Προσθήκη στήλης UUID\",\n        \"UUID\": \"UUID\",\n        \"DateTime\": \"Ημερομηνία/Ώρα\",\n        \"Reference\": \"Αναφορά\",\n        \"Freeze {{count}} more columns_one\": \"Πάγωμα μίας ακόμη στήλης\",\n        \"Choice List\": \"Λίστα Επιλογών\",\n        \"Filter Data\": \"Φιλτράρισμα δεδομένων\",\n        \"Delete {{count}} columns_one\": \"Διαγραφή στήλης\",\n        \"Hide {{count}} columns_one\": \"Απόκρυψη στήλης\",\n        \"Created at\": \"Δημιουργήθηκε στις\",\n        \"No reference columns.\": \"Δεν υπάρχουν στήλες αναφοράς.\",\n        \"Detect Duplicates in...\": \"Εντοπισμός Διπλότυπων σε...\",\n        \"Show hidden columns\": \"Εμφάνιση κρυφών στηλών\"\n    },\n    \"GristDoc\": {\n        \"Import from file\": \"Εισαγωγή από αρχείο\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Αποθηκευμένη συνδεδεμένη ενότητα {{title}} στην προβολή {{name}}\",\n        \"go to webhook settings\": \"μεταβείτε στις ρυθμίσεις του webhook\",\n        \"Added new linked section to view {{viewName}}\": \"Προστέθηκε νέα συνδεδεμένη ενότητα στην προβολή {{viewName}}\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Οι νέες αλλαγές έχουν προσωρινά ανασταλεί. Η ουρά webhooks έχει υπερχειλίσει. Ελέγξτε τις ρυθμίσεις των webhooks, καταργήστε τα μη έγκυρα webhooks και καθαρίστε την ουρά.\",\n        \"Import from Airtable\": \"Εισαγωγή από Airtable\"\n    },\n    \"HomeIntro\": {\n        \"Any documents created in this site will appear here.\": \"Οποιαδήποτε έγγραφα δημιουργούνται σε αυτόν τον ιστότοπο θα εμφανίζονται εδώ.\",\n        \"Create empty document\": \"Δημιουργία Κενού Εγγράφου\",\n        \"Get started by creating your first Grist document.\": \"Ξεκινήστε δημιουργώντας το πρώτο σας έγγραφο Grist.\",\n        \"Help Center\": \"Κέντρο Βοήθειας\",\n        \"Import document\": \"Εισαγωγή Εγγράφου\",\n        \"Invite Team Members\": \"Πρόσκληση Μελών Ομάδας\",\n        \"Sign up\": \"Εγγραφή\",\n        \"Sprouts Program\": \"Πρόγραμμα Sprouts\",\n        \"This workspace is empty.\": \"Αυτός ο χώρος εργασίας είναι κενός.\",\n        \"Visit our {{link}} to learn more.\": \"Επισκεφθείτε τον σύνδεσμο {{link}} μας για να μάθετε περισσότερα.\",\n        \"Welcome to Grist!\": \"Καλώς ήρθατε στο Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Καλώς ήρθατε στο Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Καλώς ήρθατε στο {{orgName}}\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} για να αποθηκεύσετε την εργασία σας. \",\n        \"Welcome to Grist, {{- name}}!\": \"Καλώς ήρθατε στο Grist, {{- name}}!\",\n        \"Welcome to {{- orgName}}\": \"Καλώς ήρθατε στο {{- orgName}}\",\n        \"Sign in\": \"Σύνδεση\",\n        \"personal site\": \"προσωπική ιστοσελίδα\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Μάθετε περισσότερα στο {{helpCenterLink}} μας.\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Ενδιαφέρεστε να χρησιμοποιήσετε το Grist εκτός της ομάδας σας; Επισκεφθείτε το δωρεάν \",\n        \"To use Grist, please either sign up or sign in.\": \"Για να χρησιμοποιήσετε το Grist, εγγραφείτε ή συνδεθείτε.\",\n        \"Browse Templates\": \"Αναζήτηση προτύπων\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Ξεκινήστε εξερευνώντας πρότυπα ή δημιουργώντας το πρώτο σας έγγραφο Grist.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Ξεκινήστε προσκαλώντας την ομάδα σας και δημιουργώντας το πρώτο σας έγγραφο Grist.\",\n        \"Only show documents\": \"Εμφάνιση μόνο εγγράφων\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Έχετε πρόσβαση μόνο για ανάγνωση σε αυτόν τον ιστότοπο. Προς το παρόν δεν υπάρχουν έγγραφα.\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Επισκεφτείτε το {{link}} μας για να μάθετε περισσότερα για το Grist.\"\n    },\n    \"HomeLeftPane\": {\n        \"Access Details\": \"Λεπτομέρειες Πρόσβασης\",\n        \"All documents\": \"Όλα Τα Έγγραφα\",\n        \"Create empty document\": \"Δημιουργία Κενού Εγγράφου\",\n        \"Create workspace\": \"Δημιουργία χώρου εργασίας\",\n        \"Delete\": \"Διαγραφή\",\n        \"Examples & Templates\": \"Πρότυπα\",\n        \"Import document\": \"Εισαγωγή Εγγράφου\",\n        \"Manage users\": \"Διαχείριση Χρηστών\",\n        \"Rename\": \"Μετονομασία\",\n        \"Workspace will be moved to Trash.\": \"Ο χώρος εργασίας θα μετακινηθεί στον Κάδο Απορριμμάτων.\",\n        \"Workspaces\": \"Χώροι εργασίας\",\n        \"Tutorial\": \"Οδηγός Εκμάθησης\",\n        \"Terms of service\": \"Όροι Παροχής Υπηρεσιών\",\n        \"Grist Resources\": \"Πόροι Grist\",\n        \"Delete {{workspace}} and all included documents?\": \"Διαγραφή του {{workspace}} και όλων των εγγράφων που περιλαμβάνονται;\",\n        \"Trash\": \"Κάδος Απορριμμάτων\",\n        \"context menu - {{- workspaceName }}\": \"μενού περιβάλλοντος - {{- workspaceName }}\",\n        \"Import from Airtable\": \"Εισαγωγή από Airtable\"\n    },\n    \"Importer\": {\n        \"{{count}} unmatched field in import_one\": \"{{count}} μη αντιστοιχισμένο πεδίο στην εισαγωγή\",\n        \"{{count}} unmatched field_one\": \"{{count}} μη ταιριασμένο πεδίο\",\n        \"{{count}} unmatched field_other\": \"{{count}} μη ταιριασμένα πεδία\",\n        \"Column Mapping\": \"Αντιστοίχιση Στηλών\",\n        \"Destination table\": \"Πίνακας προορισμού\",\n        \"Grist column\": \"Στήλη Grist\",\n        \"Import from file\": \"Εισαγωγή από αρχείο\",\n        \"New Table\": \"Νέος Πίνακας\",\n        \"Revert\": \"Αναίρεση\",\n        \"Skip\": \"Παράλειψη\",\n        \"Skip Import\": \"Παράλειψη Εισαγωγής\",\n        \"Skip Table on Import\": \"Παράλειψη Πίνακα κατά την Εισαγωγή\",\n        \"Source column\": \"Στήλη πηγής\",\n        \"Column mapping\": \"Αντιστοίχιση στηλών\",\n        \"Select fields to match on\": \"Επιλέξτε πεδία για αντιστοίχιση\",\n        \"Merge rows that match these fields:\": \"Συγχώνευση γραμμών που ταιριάζουν με αυτά τα πεδία:\",\n        \"Update existing records\": \"Ενημέρωση υπαρχόντων εγγραφών\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} μη ταιριασμένα πεδία στην εισαγωγή\",\n        \"Import options\": \"Επιλογές εισαγωγής\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Import\": \"Εισαγωγή\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Κέντρο Βοήθειας\",\n        \"Accessibility\": \"Προσβασιμότητα\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"Ως Πρότυπο\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Enter document name\": \"Εισαγάγετε το όνομα του εγγράφου\",\n        \"Name\": \"Όνομα\",\n        \"No destination workspace\": \"Δεν υπάρχει χώρος εργασίας προορισμού\",\n        \"Organization\": \"Οργανισμός\",\n        \"Original Has Modifications\": \"Το πρωτότυπο έχει τροποποιήσεις\",\n        \"Original Looks Identical\": \"Το πρωτότυπο φαίνεται να είναι πανομοιότυπο\",\n        \"Sign up\": \"Εγγραφή\",\n        \"Update\": \"Ενημέρωση\",\n        \"Update Original\": \"Ενημέρωση πρωτότυπου\",\n        \"Workspace\": \"Χώρος εργασίας\",\n        \"You do not have write access to the selected workspace\": \"Δεν έχετε πρόσβαση εγγραφής στον επιλεγμένο χώρο εργασίας\",\n        \"You do not have write access to this site\": \"Δεν έχετε πρόσβαση εγγραφής σε αυτόν τον ιστότοπο\",\n        \"Download\": \"Λήψη\",\n        \"Download document\": \"Λήψη εγγράφου\",\n        \"Download document and history\": \"Λήψη εγγράφου και ιστορικού\",\n        \"Download document without history (can significantly reduce file size)\": \"Λήψη εγγράφου χωρίς ιστορικό (μπορεί να μειώσει σημαντικά το μέγεθος του αρχείου)\",\n        \"Download document structure only (no data, for template use)\": \"Λήψη μόνο της δομής του εγγράφου (χωρίς δεδομένα, για χρήση προτύπου)\",\n        \".tar (recommended)\": \".tar (συνιστάται)\",\n        \".zip\": \".zip\",\n        \"Download attachments\": \"Λήψη συνημμένων\",\n        \"Download full document and history\": \"Λήψη πλήρους εγγράφου και ιστορικού\",\n        \"Format:\": \"Μορφή:\",\n        \"Learn more\": \"Μάθετε περισσότερα\",\n        \"The original version of this document will be updated.\": \"Η αρχική έκδοση αυτού του εγγράφου θα ενημερωθεί.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Θα αντικατασταθεί, χάνοντας οποιοδήποτε περιεχόμενο δεν υπάρχει σε αυτό το έγγραφο.\",\n        \"However, it appears to be already identical.\": \"Ωστόσο, φαίνεται ότι είναι ήδη το ίδιο.\",\n        \"Include the structure without any of the data.\": \"Συμπεριλάβετε τη δομή χωρίς κανένα από τα δεδομένα.\",\n        \"To save your changes, please sign up, then reload this page.\": \"Για να αποθηκεύσετε τις αλλαγές σας, εγγραφείτε και, στη συνέχεια, επαναφορτώστε αυτήν τη σελίδα.\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Προσέξτε, το πρωτότυπο έχει αλλαγές που δεν βρίσκονται σε αυτό το έγγραφο. Αυτές οι αλλαγές θα αντικατασταθούν.\",\n        \"Original Looks Unrelated\": \"Το πρωτότυπο φαίνεται μη συσχετισμένο\",\n        \"download attachments\": \"λήψη συνημμένων\",\n        \"Overwrite\": \"Αντικατάσταση\",\n        \"Replacing the original requires editing rights on the original document.\": \"Η αντικατάσταση του πρωτοτύπου απαιτεί δικαιώματα επεξεργασίας στο πρωτότυπο έγγραφο.\",\n        \"Download an archive of all the attachments present in this document.\": \"Κατεβάστε ένα αρχείο με όλα τα συνημμένα που υπάρχουν σε αυτό το έγγραφο.\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Τα συνημμένα είναι εξωτερικά και δεν περιλαμβάνονται σε αυτό το στοιχείο λήψης. Εάν ανεβάσετε το έγγραφο σε ξεχωριστή εγκατάσταση του Grist, θα χρειαστεί επίσης να το {{downloadLink}} ξεχωριστά. \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"Αν σκοπεύετε να ανεβάσετε αυτό το έγγραφο σε μια εγκατάσταση του Grist, θα χρειαστείτε το αρχείο σε μορφή \\\".tar\\\" για να επαναφέρετε τα συνημμένα. \"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"Ζητήστε βοήθεια\",\n        \"Notifications\": \"Ειδοποιήσεις\",\n        \"Renew\": \"Ανανέωση\",\n        \"Report a problem\": \"Αναφορά προβλήματος\",\n        \"Manage billing\": \"Διαχείριση χρέωσης\",\n        \"Cannot find personal site, sorry!\": \"Δεν μπορώ να βρω την προσωπική ιστοσελίδα, συγγνώμη!\",\n        \"Give feedback\": \"Υποβολή σχολίων\",\n        \"No notifications\": \"Δεν υπάρχουν ειδοποιήσεις\",\n        \"Go to your free personal site\": \"Μεταβείτε στον δωρεάν προσωπικό σας ιστότοπο\",\n        \"Upgrade Plan\": \"Αναβάθμιση Προγράμματος\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Ολοκλήρωση\",\n        \"Previous\": \"Προηγούμενο\",\n        \"Next\": \"Επόμενο\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Βίντεο περιήγησης στο Grist\",\n        \"Video Tour\": \"Βίντεο Περιήγησης\",\n        \"YouTube video player\": \"Πρόγραμμα αναπαραγωγής βίντεο YouTube\"\n    },\n    \"PageWidgetPicker\": {\n        \"Building {{- label}} widget\": \"Δημιουργία γραφικού στοιχείου (widget) {{- label}}\",\n        \"Group by\": \"Ομαδοποίηση κατά\",\n        \"Select widget\": \"Επιλογή Γραφικού Στοιχείου (widget)\",\n        \"Select data\": \"Επιλογή Δεδομένων\",\n        \"Add to page\": \"Προσθήκη στη Σελίδα\",\n        \"New Table\": \"Νέος Πίνακας\",\n        \"SELECT BY\": \"ΕΠΙΛΟΓΗ ΜΕ\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Διαγραφή\",\n        \"Delete data and this page.\": \"Διαγραφή δεδομένων και αυτής της σελίδας.\",\n        \"The following tables will no longer be visible_other\": \"Οι παρακάτω πίνακες δεν θα είναι πλέον ορατοί\",\n        \"The following tables will no longer be visible_one\": \"Ο παρακάτω πίνακας δεν θα είναι πλέον ορατός\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Διατήρηση δεδομένων και διαγραφή σελίδας. Ο πίνακας θα παραμείνει διαθέσιμος στο {{rawDataLink}}\",\n        \"Raw Data page\": \"σελίδα ακατέργαστων δεδομένων\",\n        \"Document pages\": \"Σελίδες εγγράφων\",\n        \"raw data page\": \"σελίδα ακατέργαστων δεδομένων\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Επιτρέπονται Όλα\",\n        \"Deny all\": \"Απόρριψη όλων\",\n        \"Read only\": \"Μόνο για ανάγνωση\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Η εισαγωγή απέτυχε: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Ενημέρωση διάταξης εγγραφής.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Προσθήκη Πεδίου\",\n        \"Show field {{- label}}\": \"Προβολή πεδίου {{- label}}\",\n        \"Save layout\": \"Αποθήκευση Διάταξης\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Create new field\": \"Δημιουργία Νέου Πεδίου\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Προσθήκη Στήλης\",\n        \"No columns to add\": \"Δεν υπάρχουν στήλες για προσθήκη\"\n    },\n    \"RightPanel\": {\n        \"COLUMN TYPE\": \"ΤΥΠΟΣ ΣΤΗΛΗΣ\",\n        \"CUSTOM\": \"ΠΡΟΣΑΡΜΟΣΜΕΝΟ\",\n        \"Change widget\": \"Αλλαγή Γραφικού Στοιχείου (Widget)\",\n        \"columns_one\": \"Στήλη\",\n        \"columns_other\": \"Στήλες\",\n        \"Data\": \"Δεδομένα\",\n        \"Detach\": \"Απόσπαση\",\n        \"Edit data selection\": \"Επεξεργασία Επιλογής Δεδομένων\",\n        \"fields_one\": \"Πεδίο\",\n        \"fields_other\": \"Πεδία\",\n        \"GROUPED BY\": \"ΟΜΑΔΟΠΟΙΗΜΕΝΟ ΚΑΤΑ\",\n        \"Row style\": \"Στυλ Σειράς\",\n        \"SELECT BY\": \"ΕΠΙΛΟΓΗ ΜΕ\",\n        \"SELECTOR FOR\": \"ΕΠΙΛΟΓΕΑΣ ΓΙΑ\",\n        \"SOURCE DATA\": \"ΠΗΓΗ ΔΕΔΟΜΕΝΩΝ\",\n        \"Save\": \"Αποθήκευση\",\n        \"Select widget\": \"Επιλογή Γραφικού Στοιχείου (widget)\",\n        \"series_one\": \"Σειρές\",\n        \"series_other\": \"Σειρές\",\n        \"TRANSFORM\": \"ΜΕΤΑΣΧΗΜΑΤΙΖΩ\",\n        \"Display button\": \"Κουμπί εμφάνισης\",\n        \"Enter text\": \"Εισαγωγή κειμένου\",\n        \"Field rules\": \"Κανόνες πεδίου\",\n        \"Field title\": \"Τίτλος πεδίου\",\n        \"Hidden field\": \"Κρυφό πεδίο\",\n        \"Layout\": \"Διάταξη\",\n        \"Redirect automatically after submission\": \"Αυτόματη ανακατεύθυνση μετά την υποβολή\",\n        \"Redirection\": \"Ανακατεύθυνση\",\n        \"Submission\": \"Υποβολή\",\n        \"Submit another response\": \"Υποβολή άλλης απάντησης\",\n        \"Submit button label\": \"Ετικέτα κουμπιού υποβολής\",\n        \"Enter redirect URL\": \"Εισαγάγετε URL ανακατεύθυνσης\",\n        \"No field selected\": \"Δεν έχει επιλεγεί πεδίο\",\n        \"DATA TABLE NAME\": \"ΟΝΟΜΑ ΠΙΝΑΚΑ ΔΕΔΟΜΕΝΩΝ\",\n        \"Theme\": \"Θέμα\",\n        \"Sort & filter\": \"Ταξινόμηση & Φιλτράρισμα\",\n        \"Configuration\": \"Διαμόρφωση\",\n        \"Required field\": \"Υποχρεωτικό πεδίο\",\n        \"Success text\": \"Κείμενο επιτυχίας\",\n        \"Reset form\": \"Επαναφορά φόρμας\",\n        \"Thank you! Your response has been recorded.\": \"Ευχαριστούμε! Η απάντησή σας έχει καταγραφεί.\",\n        \"CHART TYPE\": \"ΤΥΠΟΣ ΔΙΑΓΡΑΜΜΑΤΟΣ\",\n        \"WIDGET TITLE\": \"ΤΙΤΛΟΣ ΓΡΑΦΙΚΟΥ ΣΤΟΙΧΕΙΟΥ (WIDGET)\",\n        \"DATA TABLE\": \"ΠΙΝΑΚΑΣ ΔΕΔΟΜΕΝΩΝ\",\n        \"Widget\": \"Γραφικό στοιχείο (Widget)\",\n        \"You do not have edit access to this document\": \"Δεν έχετε πρόσβαση επεξεργασίας σε αυτό το έγγραφο\",\n        \"Table column name\": \"Όνομα στήλης πίνακα\",\n        \"Submit\": \"Υποβολή\",\n        \"Add referenced columns\": \"Προσθήκη στηλών με αναφορά\",\n        \"Default field value\": \"Προεπιλεγμένη τιμή πεδίου\",\n        \"Select a field in the form widget to configure.\": \"Επιλέξτε ένα πεδίο στο γραφικό στοιχείο (widget) φόρμας για να το διαμορφώσετε.\",\n        \"Chart options\": \"Επιλογές γραφήματος\"\n    },\n    \"RowContextMenu\": {\n        \"Duplicate rows_one\": \"Αντιγραφή σειράς\",\n        \"Duplicate rows_other\": \"Αντιγραφή σειρών\",\n        \"Insert row\": \"Εισαγωγή γραμμής\",\n        \"View as card\": \"Προβολή ως κάρτα\",\n        \"Use as table headers\": \"Χρήση ως κεφαλίδες πίνακα\",\n        \"Insert row below\": \"Εισαγωγή γραμμής από κάτω\",\n        \"Insert row above\": \"Εισαγωγή γραμμής από πάνω\",\n        \"Delete\": \"Διαγραφή\",\n        \"Copy anchor link\": \"Αντιγραφή συνδέσμου αγκύρωσης\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Αντιγράφηκε στο πρόχειρο\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"Λεπτομέρειες Πρόσβασης\",\n        \"Back to current\": \"Επιστροφή στο τρέχον\",\n        \"Compare to {{termToUse}}\": \"Σύγκριση με {{termToUse}}\",\n        \"Current Version\": \"Τρέχουσα Εκδοση\",\n        \"Download\": \"Λήψη\",\n        \"Duplicate document\": \"Αντιγραφή Εγγράφου\",\n        \"Edit without affecting the original\": \"Επεξεργασία χωρίς να επηρεαστεί το πρωτότυπο\",\n        \"Export CSV\": \"Εξαγωγή CSV\",\n        \"Export XLSX\": \"Εξαγωγή XLSX\",\n        \"Manage users\": \"Διαχείριση Χρηστών\",\n        \"Original\": \"Πρωτότυπο\",\n        \"Replace {{termToUse}}...\": \"Αντικατάσταση {{termToUse}}…\",\n        \"Save Document\": \"Αποθήκευση Εγγράφου\",\n        \"Send to Google Drive\": \"Αποστολή στο Google Drive\",\n        \"Show in folder\": \"Εμφάνιση σε φάκελο\",\n        \"Export as...\": \"Εξαγωγή ως...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Τιμές διαχωρισμένες με στηλοθέτες/tab (.tsv)\",\n        \"Download attachments...\": \"Λήψη συνημμένων...\",\n        \"Download document...\": \"Λήψη εγγράφου...\",\n        \"Share\": \"Μοιράσου\",\n        \"Comma Separated Values (.csv)\": \"Τιμές διαχωρισμένες με κόμμα (.csv)\",\n        \"Save copy\": \"Αποθήκευση Αντιγράφου\",\n        \"DOO Separated Values (.dsv)\": \"Διαχωρισμένες τιμές DOO (.dsv)\",\n        \"Return to {{termToUse}}\": \"Επιστροφή στο {{termToUse}}\",\n        \"Unsaved\": \"Μη αποθηκευμένο\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Η εξαγωγή είναι διαθέσιμη μόνο από σελίδες εγγράφων. Επιλέξτε μια σελίδα εγγράφου και δοκιμάστε ξανά.\",\n        \"Work on a copy\": \"Εργασία σε ένα Aντίγραφο\",\n        \"Download...\": \"Λήψη...\",\n        \"Suggest changes\": \"Προτείνετε αλλαγές\",\n        \"current version\": \"τρέχουσα έκδοση\",\n        \"original\": \"πρωτότυπο\"\n    },\n    \"SortConfig\": {\n        \"Natural sort\": \"Φυσική ταξινόμηση\",\n        \"Search Columns\": \"Αναζήτηση στηλών\",\n        \"Add column\": \"Προσθήκη Στήλης\",\n        \"Empty values last\": \"Οι κενές τιμές τελευταίες\",\n        \"Use choice position\": \"Χρήση θέσης επιλογής\",\n        \"Update data\": \"Ενημέρωση Δεδομένων\",\n        \"Remove sort setting - {{- columnName }} column\": \"Κατάργηση ρύθμισης ταξινόμησης - στήλη {{- columnName }}\",\n        \"Sort in ascending order (current: descending)\": \"Ταξινόμηση σε αύξουσα σειρά (τρέχουσα: φθίνουσα)\",\n        \"Sort in descending order (current: ascending)\": \"Ταξινόμηση κατά φθίνουσα σειρά (τρέχουσα: αύξουσα)\",\n        \"Sort options - {{- columnName }} column\": \"Επιλογές ταξινόμησης - στήλη {{- columnName }}\",\n        \"{{- columnName }} column\": \"{{- columnName }} στήλη\"\n    },\n    \"SortFilterConfig\": {\n        \"Filter\": \"ΦΙΛΤΡΟ\",\n        \"Revert\": \"Αναίρεση\",\n        \"Save\": \"Αποθήκευση\",\n        \"Sort\": \"ΤΑΞΙΝΟΜΗΣΗ\",\n        \"Update Sort & Filter settings\": \"Ενημέρωση Ρυθμίσεων Ταξινόμησης & Φιλτραρίσματος\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Εμφάνιση \",\n        \"Switch appearance automatically to match system\": \"Αυτόματη αλλαγή εμφάνισης ώστε να ταιριάζει με το σύστημα\"\n    },\n    \"Tools\": {\n        \"API console\": \"Κονσόλα API\",\n        \"How-to Tutorial\": \"Οδηγός Πως-Να\",\n        \"Document history\": \"Ιστορικό Εγγράφου\",\n        \"Return to viewing as yourself\": \"Επιστροφή στην προβολή ως ο εαυτός σας\",\n        \"Raw data\": \"Ακατέργαστα Δεδομένα\",\n        \"Access Rules\": \"Κανόνες Πρόσβασης\",\n        \"Delete\": \"Διαγραφή\",\n        \"TOOLS\": \"ΕΡΓΑΛΕΙΑ\",\n        \"Delete document tour?\": \"Διαγραφή περιήγησης εγγράφου;\",\n        \"Validate Data\": \"Επικύρωση Δεδομένων\",\n        \"Tour of this Document\": \"Περιήγηση σε αυτό το Εγγραφο\",\n        \"Settings\": \"Ρυθμίσεις\",\n        \"Code view\": \"Προβολή Κώδικα\",\n        \"context menu - Access Rules\": \"μενού περιβάλλοντος - Κανόνες πρόσβασης\",\n        \"Delete document tour\": \"Διαγραφή περιήγησης εγγράφου\",\n        \"Preview the tutorial\": \"Προεπισκόπηση του οδηγού εκμάθησης\",\n        \"Proposed changes\": \"Προτεινόμενες αλλαγές\",\n        \"Suggest changes\": \"Προτείνετε αλλαγές\",\n        \"Suggestions\": \"Προτάσεις\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Διαχείριση ομάδας\",\n        \"Redo\": \"Επανάληψη\",\n        \"Undo\": \"Αναίρεση\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"Οποιοδήποτε πεδίο\",\n        \"Apply on changes to:\": \"Εφαρμογή σε αλλαγές σε:\",\n        \"Apply on record changes\": \"Εφαρμογή σε αλλαγές εγγραφής\",\n        \"Apply to new records\": \"Εφαρμογή σε νέες εγγραφές\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Close\": \"Κλείσιμο\",\n        \"Current field \": \"Τρέχον πεδίο \",\n        \"OK\": \"Εντάξει\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"Εφαρμογή\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Preview\": \"Προεπισκόπηση\",\n        \"Revise\": \"Αναθεώρηση\",\n        \"Update formula (Shift+Enter)\": \"Ενημέρωση φόρμουλας (Shift+Enter)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Συντάκτης\",\n        \"In full\": \"Πλήρως\",\n        \"None\": \"Κανένα\",\n        \"Owner\": \"Ιδιοκτήτης\",\n        \"View & edit\": \"Προβολή & Επεξεργασία\",\n        \"View only\": \"Μόνο Προβολή\",\n        \"No Default Access\": \"Δεν Υπάρχει Προεπιλεγμένη Πρόσβαση\",\n        \"Viewer\": \"Θεατής\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Κανόνας {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Ενημέρωση φόρμουλας (Shift+Enter)\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Αγνωστος Χρήστης\",\n        \"View as Yourself\": \"Προβολή ως ο εαυτός σας\",\n        \"You are viewing this document as\": \"Βλέπετε αυτό το έγγραφο ως\",\n        \"You're seeing what this user would see if given access\": \"Βλέπετε τι θα έβλεπε αυτός ο χρήστης αν του δοθεί πρόσβαση\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Προχωρημένες ρυθμίσεις\",\n        \"Blocks\": \"Μπλοκ\",\n        \"Edit card layout\": \"Επεξεργασία Διάταξης Κάρτας\",\n        \"Form\": \"Φόρμα\",\n        \"Make On-Demand\": \"Δημιουργία Κατ' Απαίτηση\",\n        \"Plugin: \": \"Πρόσθετο: \",\n        \"Compact\": \"Συμπαγής\",\n        \"Section: \": \"Τμήμα: \",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Οι μεγάλοι πίνακες ενδέχεται να επισημαίνονται ως \\\"κατ' απαίτηση\\\" για να αποφευχθεί η φόρτωσή τους στη μηχανή δεδομένων.\",\n        \"Unmark On-Demand\": \"Κατάργηση Σήμανσης Κατ' Απαίτηση\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Καταργημένη λειτουργία\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"Οι πίνακες κατ' απαίτηση έχουν καταργηθεί λόγω έλλειψης λειτουργικότητας και ζητημάτων χρηστικότητας.\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Σύνθετη Ταξινόμηση & Φιλτράρισμα\",\n        \"Copy anchor link\": \"Αντιγραφή συνδέσμου αγκύρωσης\",\n        \"Data selection\": \"Επιλογή δεδομένων\",\n        \"Delete record\": \"Διαγραφή εγγραφής\",\n        \"Download as CSV\": \"Λήψη ως CSV\",\n        \"Edit card layout\": \"Επεξεργασία Διάταξης Κάρτας\",\n        \"Print widget\": \"Γραφικό στοιχείο (widget) εκτύπωσης\",\n        \"Show raw data\": \"Εμφάνιση ακατέργαστων δεδομένων\",\n        \"Widget options\": \"Επιλογές γραφικού στοιχείου (widget)\",\n        \"Collapse widget\": \"Σύμπτυξη γραφικού στοιχείου (widget)\",\n        \"Download as XLSX\": \"Λήψη ως XLSX\",\n        \"Add to page\": \"Προσθήκη στη σελίδα\",\n        \"Create a form\": \"Δημιουργήστε μια φόρμα\",\n        \"Delete widget\": \"Διαγραφή γραφικού στοιχείου (widget)\",\n        \"Open configuration\": \"Άνοιγμα διαμόρφωσης\",\n        \"Duplicate widget\": \"Διπλότυπο γραφικό στοιχείο\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Clear\": \"Καθαρισμός\",\n        \"Select all\": \"Επιλογή Ολων\",\n        \"Visible {{label}}\": \"Ορατό {{label}}\",\n        \"Hide {{label}}\": \"Απόκρυψη {{label}}\",\n        \"Hidden {{label}}\": \"Κρυφό {{label}}\",\n        \"Show {{label}}\": \"Προβολή {{label}}\",\n        \"Cannot drop items into Hidden Fields\": \"Δεν είναι δυνατή η απόθεση αντικειμένων σε κρυφά πεδία\",\n        \"Hidden Fields cannot be reordered\": \"Δεν είναι δυνατή η αναδιάταξη των κρυφών πεδίων\",\n        \"Hide {{label}} (batch mode)\": \"Απόκρυψη {{label}} (λειτουργία παρτίδας)\",\n        \"Show {{label}} (batch mode)\": \"Εμφάνιση {{label}} (λειτουργία παρτίδας)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Εκπαίδευση\",\n        \"Finance & Accounting\": \"Χρηματοοικονομικά και Λογιστική\",\n        \"HR & Management\": \"Ανθρώπινο Δυναμικό και Διοίκηση\",\n        \"IT & Technology\": \"Πληροφορική και Τεχνολογία\",\n        \"Marketing\": \"Μάρκετινγκ\",\n        \"Media Production\": \"Παραγωγή Μέσων Ενημέρωσης\",\n        \"Other\": \"Άλλο\",\n        \"Research\": \"Έρευνα\",\n        \"Type here\": \"Πληκτρολογήστε εδώ\",\n        \"Welcome to Grist!\": \"Καλώς ήρθατε στο Grist!\",\n        \"Product Development\": \"Ανάπτυξη Προϊόντων\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Τι σας φέρνει στο Grist; Παρακαλούμε βοηθήστε μας να σας εξυπηρετήσουμε καλύτερα.\",\n        \"Sales\": \"Πωλήσεις\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"Ακύρωση\",\n        \"DATA TABLE NAME\": \"ΟΝΟΜΑ ΠΙΝΑΚΑ ΔΕΔΟΜΕΝΩΝ\",\n        \"Override widget title\": \"Παράκαμψη τίτλου γραφικού στοιχείου (widget)\",\n        \"Provide a table name\": \"Δώστε ένα όνομα πίνακα\",\n        \"Save\": \"Αποθήκευση\",\n        \"WIDGET TITLE\": \"ΤΙΤΛΟΣ ΓΡΑΦΙΚΟΥ ΣΤΟΙΧΕΙΟΥ (WIDGET)\",\n        \"WIDGET DESCRIPTION\": \"ΠΕΡΙΓΡΑΦΗ ΓΡΑΦΙΚΟΥ ΣΤΟΙΧΕΙΟΥ WIDGET\"\n    },\n    \"breadcrumbs\": {\n        \"fiddle\": \"βιολί (fiddle)\",\n        \"override\": \"παράκαμψη\",\n        \"recovery mode\": \"λειτουργία ανάκτησης\",\n        \"snapshot\": \"στιγμιότυπο\",\n        \"unsaved\": \"μη αποθηκευμένο\",\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Μπορείτε να κάνετε αλλαγές, αλλά αυτές θα δημιουργήσουν ένα νέο αντίγραφο και δεν \\nθα επηρεάσουν το αρχικό έγγραφο.\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Μπορείτε να κάνετε αλλαγές,\\nαλλά αυτές δεν θα επηρεάσουν το αρχικό έγγραφο.\\nΜπορείτε να τις προτείνετε ως προτάσεις.\",\n        \"editing\": \"επεξεργασία\",\n        \"suggesting\": \"προτείνοντας\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Δεν επιτρέπεται η πρόσβαση{{suffix}}\",\n        \"Add account\": \"Προσθήκη λογαριασμού\",\n        \"Contact support\": \"Επικοινωνήστε με την υποστήριξη\",\n        \"Error{{suffix}}\": \"Σφάλμα{{suffix}}\",\n        \"Go to main page\": \"Μετάβαση στην κύρια σελίδα\",\n        \"Page not found{{suffix}}\": \"Η σελίδα δεν βρέθηκε{{suffix}}\",\n        \"Sign in again\": \"Συνδεθείτε ξανά\",\n        \"Signed out{{suffix}}\": \"Έχετε αποσυνδεθεί{{suffix}}\",\n        \"You are now signed out.\": \"Έχετε πλέον αποσυνδεθεί.\",\n        \"You do not have access to this organization's documents.\": \"Δεν έχετε πρόσβαση στα έγγραφα αυτού του οργανισμού.\",\n        \"Account deleted{{suffix}}\": \"Ο λογαριασμός διαγράφηκε{{suffix}}\",\n        \"Sign up\": \"Εγγραφή\",\n        \"Your account has been deleted.\": \"Ο λογαριασμός σας έχει διαγραφεί.\",\n        \"Form not found\": \"Η φόρμα δεν βρέθηκε\",\n        \"Powered by\": \"Με την υποστήριξη\",\n        \"Sign-in failed{{suffix}}\": \"Η σύνδεση απέτυχε{{suffix}}\",\n        \"Sign in\": \"Σύνδεση\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Έχετε συνδεθεί ως {{email}}. Μπορείτε να συνδεθείτε με διαφορετικό λογαριασμό ή να ζητήσετε πρόσβαση από έναν διαχειριστή.\",\n        \"There was an unknown error.\": \"Παρουσιάστηκε ένα άγνωστο σφάλμα.\",\n        \"Sign in to access this organization's documents.\": \"Συνδεθείτε για να αποκτήσετε πρόσβαση στα έγγραφα αυτού του οργανισμού.\",\n        \"There was an error: {{message}}\": \"Παρουσιάστηκε ένα σφάλμα: {{message}}\",\n        \"An unknown error occurred.\": \"Συνέβη ένα άγνωστο σφάλμα.\",\n        \"Something went wrong\": \"Κάτι πήγε στραβά\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Αποτυχία σύνδεσης.{{separator}}Δοκιμάστε ξανά ή επικοινωνήστε με την υποστήριξη.\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Δεν ήταν δυνατή η εύρεση της σελίδας που ζητήθηκε.{{separator}}Ελέγξτε τη διεύθυνση URL και προσπαθήστε ξανά.\",\n        \"Build your own form\": \"Δημιουργήστε τη δική σας φόρμα\",\n        \"Manage settings\": \"Διαχείριση ρυθμίσεων\",\n        \"Need Help?\": \"Χρειάζεστε βοήθεια;\",\n        \"There was an error\": \"Παρουσιάστηκε ένα σφάλμα\",\n        \"Unsubscribed{{suffix}}\": \"Μη εγγεγραμμένος {{suffix}}\",\n        \"We could not unsubscribe you\": \"Δεν μπορέσαμε να σας διαγράψουμε από την εγγραφή σας\",\n        \"You are unsubscribed\": \"Έχετε διαγραφεί από την εγγραφή σας\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Μπορείτε ακόμα να διαγραφείτε από αυτό το έγγραφο ενημερώνοντας τις προτιμήσεις σας στις ρυθμίσεις του εγγράφου\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Δεν θα λαμβάνετε πλέον ειδοποιήσεις μέσω email σχετικά με {{changes}} στο {{docName}} στη διεύθυνση {{email}}.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Δεν θα λαμβάνετε πλέον ειδοποιήσεις μέσω email σχετικά με τα {{comments}} στο {{docName}} στη διεύθυνση {{email}}.\",\n        \"changes\": \"αλλαγές\",\n        \"comments\": \"σχόλια\",\n        \"this document\": \"αυτό το έγγραφο\",\n        \"your email\": \"το email σας\",\n        \"You will no longer receive email notifications about {{suggestions}} in {{docName}} at {{email}}.\": \"Δεν θα λαμβάνετε πλέον ειδοποιήσεις μέσω email σχετικά με τις {{suggestions}} στο {{docName}} στη διεύθυνση {{email}}.\",\n        \"suggestions\": \"προτάσεις\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* Οι χώροι εργασίας είναι διαθέσιμοι στα ομαδικά προγράμματα. \",\n        \"Select fields\": \"Επιλογή πεδίων\",\n        \"Upgrade now\": \"Αναβάθμιση τώρα\",\n        \"Any\": \"Οτιδήποτε\",\n        \"Numeric\": \"Αριθμητικό\",\n        \"Text\": \"Κείμενο\",\n        \"Integer\": \"Ακέραιος\",\n        \"Toggle\": \"Εναλλαγή\",\n        \"Choice\": \"Επιλογή\",\n        \"Reference List\": \"Λίστα Αναφοράς\",\n        \"Attachment\": \"Συννημένο\",\n        \"Search columns\": \"Αναζήτηση στηλών\",\n        \"Light\": \"Φωτεινό\",\n        \"Custom\": \"Προσαρμοσμένο\",\n        \"Choice List\": \"Λίστα Επιλογών\",\n        \"By Date Modified\": \"Κατά ημερομηνία Τροποποίησης\",\n        \"Date\": \"Ημερομηνία\",\n        \"By Name\": \"Κατά Όνομα\",\n        \"DateTime\": \"Ημερομηνία/Ώρα\",\n        \"Reference\": \"Αναφορά\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Ακύρωση\",\n        \"Save\": \"Αποθήκευση\",\n        \"Are you sure you want to delete these records?\": \"Είστε σίγουροι ότι θέλετε να διαγράψετε αυτές τις εγγραφές;\",\n        \"Delete\": \"Διαγραφή\",\n        \"Dismiss\": \"Παράβλεψη\",\n        \"Don't ask again.\": \"Να μην ερωτηθώ ξανά.\",\n        \"Don't show again.\": \"Να μην εμφανιστεί ξανά.\",\n        \"Don't show tips\": \"Μην εμφανίζετε συμβουλές\",\n        \"Undo to restore\": \"Αναίρεση για επαναφορά\",\n        \"Got it\": \"Το κατάλαβα\",\n        \"TIP\": \"ΣΥΜΒΟΥΛΗ\",\n        \"Confirm\": \"Επιβεβαίωση\",\n        \"Are you sure you want to delete this record?\": \"Είστε σίγουροι ότι θέλετε να διαγράψετε αυτήν την εγγραφή;\",\n        \"Don't show again\": \"Να μην εμφανιστεί ξανά\",\n        \"Ok\": \"Εντάξει\"\n    },\n    \"pages\": {\n        \"You do not have edit access to this document\": \"Δεν έχετε πρόσβαση επεξεργασίας σε αυτό το έγγραφο\",\n        \"Rename\": \"Μετονομασία\",\n        \"Duplicate page\": \"Διπλότυπη Σελίδα\",\n        \"Remove\": \"Αφαίρεση\",\n        \"(default)\": \"(εξ' ορισμού)\",\n        \"Collapse {{maybeDefault}}\": \"Σύμπτυξη {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Ανάπτυξη {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Ορισμός προεπιλογής: Σύμπτυξη\",\n        \"Set default: Expand\": \"Ορισμός προεπιλογής: Ανάπτυξη\",\n        \"context menu - {{- pageName }}\": \"μενού περιβάλλοντος - {{- pageName }}\"\n    },\n    \"NTextBox\": {\n        \"true\": \"αληθής\",\n        \"Field Format\": \"Μορφή Πεδίου\",\n        \"Lines\": \"Γραμμές\",\n        \"Multi line\": \"Πολλαπλές γραμμές\",\n        \"Single line\": \"Μονή γραμμή\",\n        \"false\": \"ψευδής\"\n    },\n    \"ACLUsers\": {\n        \"Users from table\": \"Χρήστες από τον πίνακα\",\n        \"View as\": \"Προβολή Ως\",\n        \"Example Users\": \"Παραδείγματα χρηστών\",\n        \"Other users from table\": \"Άλλοι χρήστες από τον πίνακα\",\n        \"Shared users\": \"Κοινόχρηστοι χρήστες\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Εφαρμογή\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Preview\": \"Προεπισκόπηση\",\n        \"Revise\": \"Αναθεώρηση\",\n        \"Update formula (Shift+Enter)\": \"Ενημέρωση φόρμουλας (Shift+Enter)\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"ΣΤΥΛ ΚΕΛΙΟΥ\",\n        \"Cell style\": \"Στυλ Κελιού\",\n        \"Mixed style\": \"Μικτό στυλ\",\n        \"Open row styles\": \"Άνοιγμα στυλ γραμμών\",\n        \"Default header style\": \"Προεπιλεγμένο στυλ κεφαλίδας\",\n        \"Header Style\": \"Στυλ Κεφαλίδας\",\n        \"HEADER STYLE\": \"ΣΤΥΛ ΚΕΦΑΛΙΔΑΣ\",\n        \"Default cell style\": \"Προεπιλεγμένο στυλ κελιού\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN LABEL\": \"ΕΤΙΚΕΤΑ ΣΤΗΛΗΣ\",\n        \"COLUMN DESCRIPTION\": \"ΠΕΡΙΓΡΑΦΗ ΣΤΗΛΗΣ\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN DESCRIPTION\": \"ΠΕΡΙΓΡΑΦΗ ΣΤΗΛΗΣ\",\n        \"Save\": \"Αποθήκευση\",\n        \"COLUMN LABEL\": \"ΕΤΙΚΕΤΑ ΣΤΗΛΗΣ\",\n        \"Cancel\": \"Ακύρωση\",\n        \"COLUMN ID: \": \"ΑΝΑΓΝΩΡΙΣΤΙΚΟ ΣΤΗΛΗΣ: \"\n    },\n    \"ConditionalStyle\": {\n        \"Add conditional style\": \"Προσθήκη στυλ υπό όρους\",\n        \"Row style\": \"Στυλ Σειράς\",\n        \"Conditional Style\": \"Στυλ Υπό Προϋποθέσεις\",\n        \"IF...\": \"ΑΝ...\",\n        \"Error in style rule\": \"Σφάλμα στον κανόνα στυλ\",\n        \"Rule must return True or False\": \"Ο κανόνας πρέπει να επιστρέφει True ή False\",\n        \"Add another rule\": \"Προσθήκη άλλου κανόνα\",\n        \"Row Style\": \"Στυλ σειράς\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Μη έγκυρο νόμισμα\"\n    },\n    \"DiscussionEditor\": {\n        \"Comment\": \"Σχόλιο\",\n        \"Edit\": \"Επεξεργασία\",\n        \"Only current page\": \"Μόνο η τρέχουσα σελίδα\",\n        \"Only my threads\": \"Μόνο τα νήματα μου\",\n        \"Remove\": \"Αφαίρεση\",\n        \"Reply\": \"Απάντηση\",\n        \"Reply to a comment\": \"Απάντηση σε ένα σχόλιο\",\n        \"Resolve\": \"Επίλυση\",\n        \"Save\": \"Αποθήκευση\",\n        \"Show resolved comments\": \"Εμφάνιση επιλυμένων σχολίων\",\n        \"Showing last {{nb}} comments\": \"Εμφάνιση των τελευταίων {{nb}} σχολίων\",\n        \"Write a comment\": \"Γράψτε ένα σχόλιο\",\n        \"Remove thread\": \"Αφαίρεση νήματος\",\n        \"updated\": \"ενημερωμένο\",\n        \"Open\": \"Άνοιγμα\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Started discussion\": \"Ξεκίνησε η συζήτηση\",\n        \"Marked as resolved\": \"Επισημάνθηκε ως επιλυμένο\",\n        \"{{count}} comments_one\": \"{{count}} σχόλιο\",\n        \"{{count}} comments_other\": \"{{count}} σχόλια\",\n        \"Copy link\": \"Αντιγραφή συνδέσμου\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Μετατροπή στήλης σε φόρμουλα\"\n    },\n    \"FieldBuilder\": {\n        \"Apply formula to data\": \"Εφαρμογή φόρμουλας σε δεδομένα\",\n        \"CELL FORMAT\": \"ΜΟΡΦΗ ΚΕΛΙΟΥ\",\n        \"Mixed types\": \"Μικτοί τύποι\",\n        \"Revert field settings for {{colId}} to common\": \"Επαναφορά ρυθμίσεων πεδίου για {{colId}} σε κοινές\",\n        \"Save field settings for {{colId}} as common\": \"Αποθήκευση ρυθμίσεων πεδίου για {{colId}} ως κοινές\",\n        \"Changing column type\": \"Αλλαγή τύπου στήλης\",\n        \"Changing multiple column types\": \"Αλλαγή πολλαπλών τύπων στηλών\",\n        \"Mixed format\": \"Μικτή μορφή\",\n        \"Use separate field settings for {{colId}}\": \"Χρησιμοποιήστε ξεχωριστές ρυθμίσεις πεδίου για το {{colId}}\",\n        \"DATA FROM TABLE\": \"ΔΕΔΟΜΕΝΑ ΑΠΟ ΤΟΝ ΠΙΝΑΚΑ\",\n        \"Common\": \"Κοινό\",\n        \"Separate\": \"Ξεχωριστό\",\n        \"Field in {{count}} views_one\": \"Πεδίο σε μία προβολή\",\n        \"Field in {{count}} views_other\": \"Πεδίο σε {{count}} προβολές\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"Θα πρέπει να είναι αδύνατο να αποθηκεύσετε μια απλή τιμή δεδομένων σε μια στήλη τύπου\",\n        \"Unable to finish saving edited cell\": \"Δεν είναι δυνατή η ολοκλήρωση της αποθήκευσης του επεξεργασμένου κελιού\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Απαιτείται στήλη ή πεδίο\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Σφάλματα σε {{numErrors}} από {{numCells}} κελιά\",\n        \"editingFormula is required\": \"Η φόρμουλα είναι απαραίτητη\",\n        \"Expand Editor\": \"Ανάπτυξη Επεξεργαστή\",\n        \"Enter formula.\": \"Εισαγάγετε την φόρμουλα.\",\n        \"Enter formula or {{button}}.\": \"Εισαγάγετε φόρμουλα ή {{button}}.\",\n        \"Errors in all {{numErrors}} cells\": \"Σφάλματα σε όλα τα {{numErrors}} κελιά\",\n        \"Error in the cell\": \"Σφάλμα στο κελί\",\n        \"use AI Assistant\": \"χρησιμοποιήστε τον Βοηθό Τεχνητής Νοημοσύνης\"\n    },\n    \"WelcomeTour\": {\n        \"Configuring your document\": \"Ρύθμιση παραμέτρων του εγγράφου σας\",\n        \"Customizing columns\": \"Προσαρμογή στηλών\",\n        \"Flying higher\": \"Πετώντας ψηλότερα\",\n        \"Help Center\": \"Κέντρο Βοήθειας\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Κάντε το σχεσιακό! Χρησιμοποιήστε τον τύπο {{ref}} για να συνδέσετε πίνακες. \",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Ορίστε επιλογές μορφοποίησης, τύπους ή τύπους στηλών, όπως ημερομηνίες, επιλογές ή συνημμένα. \",\n        \"Sharing\": \"Μοιρασιά\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Κάντε εναλλαγή του {{creatorPanel}} για να μορφοποιήσετε τις στήλες, \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Χρησιμοποιήστε το {{helpCenter}} για τεκμηρίωση ή ερωτήσεις.\",\n        \"convert to card view, select data, and more.\": \"μετατροπή σε προβολή κάρτας, επιλογή δεδομένων και πολλά άλλα.\",\n        \"creator panel\": \"πίνακας δημιουργών\",\n        \"template library\": \"βιβλιοθήκη προτύπων\",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Χρησιμοποιήστε το κουμπί Κοινή χρήση/μοιράσου ({{share}}) για να μοιραστείτε το έγγραφο ή να εξαγάγετε δεδομένα.\",\n        \"Reference\": \"Αναφορά\",\n        \"Start with {{equal}} to enter a formula.\": \"Ξεκινήστε με {{equal}} για να εισαγάγετε μία φόρμουλα.\",\n        \"Add new\": \"Προσθήκη Νέου\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Κάντε διπλό κλικ ή πατήστε {{enter}} σε ένα κελί για να το επεξεργαστείτε. \",\n        \"Editing Data\": \"Επεξεργασία δεδομένων\",\n        \"Share\": \"Μοιράσου\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Περιηγηθείτε στη {{templateLibrary}} μας για να ανακαλύψετε τι είναι δυνατό και να εμπνευστείτε.\",\n        \"Welcome to Grist!\": \"Καλώς ήρθατε στο Grist!\",\n        \"Building up\": \"Χτίζοντας\",\n        \"Enter\": \"Είσοδος\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Χρησιμοποιήστε το {{addNew}} για να προσθέσετε γραφικά στοιχεία, σελίδες ή να εισαγάγετε περισσότερα δεδομένα. \",\n        \"AI Assistant\": \"Βοηθός Τεχνητής Νοημοσύνης\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Γλώσσα\"\n    },\n    \"GristTooltips\": {\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Εφαρμογή μορφοποίησης υπό όρους σε κελιά σε αυτήν τη στήλη όταν πληρούνται οι συνθήκες του τύπου.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Κάντε κλικ στο κουμπί Προσθήκη νέου για να δημιουργήσετε νέα έγγραφα ή χώρους εργασίας ή να εισαγάγετε δεδομένα.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Κάνοντας κλικ στο {{EyeHideIcon}} σε κάθε κελί, το πεδίο αποκρύπτεται από αυτήν την προβολή χωρίς να διαγραφεί.\",\n        \"Editing Card Layout\": \"Επεξεργασία Διάταξης Κάρτας\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Φόρμουλες που ενεργοποιούνται σε ορισμένες περιπτώσεις και αποθηκεύουν την υπολογισμένη τιμή ως δεδομένα.\",\n        \"Learn more.\": \"Μάθετε περισσότερα.\",\n        \"Linking Widgets\": \"Σύνδεση γραφικών στοιχείων (widgets)\",\n        \"Nested Filtering\": \"Ένθετο Φιλτράρισμα\",\n        \"Pinning Filters\": \"Καρφίτσωμα Φίλτρων\",\n        \"Raw Data page\": \"Σελίδα Ακατέργαστων Δεδομένων\",\n        \"Select the table containing the data to show.\": \"Επιλέξτε τον πίνακα που περιέχει τα δεδομένα που θα εμφανιστούν.\",\n        \"Select the table to link to.\": \"Επιλέξτε τον πίνακα στον οποίο θα συνδεθείτε.\",\n        \"The total size of all data in this document, excluding attachments.\": \"Το συνολικό μέγεθος όλων των δεδομένων σε αυτό το έγγραφο, εξαιρουμένων των συνημμένων.\",\n        \"They allow for one record to point (or refer) to another.\": \"Επιτρέπουν σε μια εγγραφή να δείχνει (ή να αναφέρεται) σε μια άλλη.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"Αυτό είναι το μυστικό των δυναμικών και παραγωγικών σχεδίων του Grist.\",\n        \"entire\": \"Ολόκληρο\",\n        \"relational\": \"σχεσιακό\",\n        \"Access Rules\": \"Κανόνες Πρόσβασης\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Οι κανόνες πρόσβασης σάς δίνουν τη δυνατότητα να δημιουργείτε λεπτομερείς κανόνες για να καθορίζετε ποιος μπορεί να δει ή να επεξεργαστεί ποια μέρη του εγγράφου σας.\",\n        \"Add new\": \"Προσθήκη Νέου\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Χρησιμοποιήστε το εικονίδιο 𝚺 για να δημιουργήσετε συνοπτικούς (ή συγκεντρωτικούς) πίνακες, για σύνολα ή μερικά σύνολα.\",\n        \"Anchor Links\": \"Σύνδεσμοι Αγκύρωσης\",\n        \"Calendar\": \"Ημερολόγιο\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Μπορείτε να επιλέξετε από τα γραφικά στοιχεία (widgets) που είναι διαθέσιμα σε εσάς στο αναπτυσσόμενο μενού ή να ενσωματώσετε τα δικά σας παρέχοντας την πλήρη διεύθυνση URL τους.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Οι φόρμουλες υποστηρίζουν πολλές συναρτήσεις του Excel, πλήρη σύνταξη Python και περιλαμβάνουν έναν χρήσιμο Βοηθό Τεχνητής Νοημοσύνης.\",\n        \"Forms are here!\": \"Οι φόρμες είναι εδώ!\",\n        \"Learn more\": \"Μάθετε περισσότερα\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Αυτοί οι κανόνες εφαρμόζονται μετά την επεξεργασία όλων των κανόνων στηλών, εάν υπάρχουν.\",\n        \"Example: {{example}}\": \"Παράδειγμα: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"Φιλτράρετε τις εμφανιζόμενες τιμές από την αναπτυσσόμενη λίστα με μια συνθήκη.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Δημιουργεί μια αντίστροφη στήλη στον πίνακα προορισμού που μπορεί να επεξεργαστεί από οποιοδήποτε άκρο.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Για να επιτρέψετε πολλαπλές αναθέσεις, αλλάξτε τον τύπο της στήλης Αναφοράς σε Λίστα Αναφορών.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Αυτός ο περιορισμός προκύπτει όταν μία στήλη σε μια αμφίδρομη αναφορά έχει τον τύπο Αναφορά.\",\n        \"The preview below this header shows how the selected user will see this document\": \"Η προεπισκόπηση κάτω από αυτήν την κεφαλίδα δείχνει πώς θα βλέπει αυτό το έγγραφο ο επιλεγμένος χρήστης\",\n        \"[Learn more.]({{link}})\": \"[Μάθετε περισσότερα.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Οι πίνακες σύνοψης μπορούν να περιέχουν μόνο στήλες φόρμουλας.\",\n        \"Manage users and resources in a Grist installation.\": \"Διαχειριστείτε χρήστες και πόρους σε μια εγκατάσταση Grist.\",\n        \"The new Grist Assistant is here!\": \"Ο νέος Βοηθός Grist είναι εδώ!\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Οι στήλες αναφοράς είναι το κλειδί για τα {{relational}} δεδομένα στο Grist.\",\n        \"Only those rows will appear which match all of the filters.\": \"Θα εμφανιστούν μόνο οι γραμμές που ταιριάζουν με όλα τα φίλτρα.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Χρήσιμο για την αποθήκευση της χρονικής σήμανσης ή του συντάκτη μιας νέας εγγραφής, τον καθαρισμό δεδομένων και πολλά άλλα.\",\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"Ένα UUID είναι μια τυχαία δημιουργημένη συμβολοσειρά που είναι χρήσιμη για μοναδικά αναγνωριστικά και κλειδιά σύνδεσης.\",\n        \"Custom Widgets\": \"Προσαρμοσμένα Γραφικά Στοιχεία (Widgets)\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Εφαρμογή μορφοποίησης υπό όρους σε γραμμές με βάση φόρμουλες.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Μπορείτε να επιλέξετε ένα από τα έτοιμα γραφικά στοιχεία μας ή να ενσωματώσετε το δικό σας παρέχοντας την πλήρη διεύθυνση URL του.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Αυτός ο περιορισμός προκύπτει όταν το ένα άκρο μιας αμφίδρομης αναφοράς έχει διαμορφωθεί ως μία μονή Αναφορά.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Τα καρφιτσωμένα φίλτρα εμφανίζονται ως κουμπιά πάνω από το γραφικό στοιχείο (widget).\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Η σελίδα \\\"Ακατέργαστα δεδομένα\\\" παραθέτει όλους τους πίνακες δεδομένων στο έγγραφό σας, συμπεριλαμβανομένων των συνοπτικών πινάκων και των πινάκων που δεν περιλαμβάνονται στις διατάξεις σελίδων.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Χρησιμοποιήστε το εικονίδιο \\\\u{1D6BA} για να δημιουργήσετε συνοπτικούς (ή συγκεντρωτικούς) πίνακες, για σύνολα ή μερικά σύνολα.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Τα κελιά σε μια στήλη αναφοράς προσδιορίζουν πάντα μια {{entire}} εγγραφή σε αυτόν τον πίνακα, αλλά μπορείτε να επιλέξετε ποια στήλη από αυτήν την εγγραφή θα εμφανιστεί.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Κάντε κλικ στην επιλογή \\\"Άνοιγμα στυλ γραμμών\\\" για να εφαρμόσετε μορφοποίηση υπό όρους σε γραμμές.\",\n        \"Link your new widget to an existing widget on this page.\": \"Συνδέστε το νέο σας γραφικό στοιχείο (widget) με ένα υπάρχον γραφικό στοιχείο (widget) σε αυτήν τη σελίδα.\",\n        \"Selecting Data\": \"Επιλογή Δεδομένων\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Αναδιατάξτε τα πεδία στην κάρτα σας σύροντας και αλλάζοντας το μέγεθος των κελιών.\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Δεν μπορείτε να βρείτε τις σωστές στήλες; Κάντε κλικ στην επιλογή «Αλλαγή Γραφικού Στοιχείου (Widget)» για να επιλέξετε τον πίνακα με τα δεδομένα συμβάντων.\",\n        \"Reference Columns\": \"Στήλες Αναφοράς\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Για να δημιουργήσετε έναν σύνδεσμο αγκύρωσης που μεταφέρει τον χρήστη σε ένα συγκεκριμένο κελί, κάντε κλικ σε μια γραμμή και πατήστε {{shortcut}}.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Δοκιμάστε τις αλλαγές σε ένα αντίγραφο και, στη συνέχεια, αποφασίστε εάν θα αντικαταστήσετε το πρωτότυπο με τις τροποποιήσεις σας.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Ξεκαρφιτσώστε για να αποκρύψετε το κουμπί διατηρώντας παράλληλα το φίλτρο.\",\n        \"Lookups return data from related tables.\": \"Οι αναζητήσεις (Lookups) επιστρέφουν δεδομένα από σχετικούς πίνακες.\",\n        \"Updates every 5 minutes.\": \"Ενημερώσεις κάθε 5 λεπτά.\",\n        \"Use reference columns to relate data in different tables.\": \"Χρησιμοποιήστε στήλες αναφοράς για να συσχετίσετε δεδομένα σε διαφορετικούς πίνακες.\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Δημιουργήστε απλές φόρμες απευθείας στο Grist και κοινοποιήστε τες με ένα κλικ με το νέο μας γραφικό στοιχείο (widget). {{learnMoreButton}}\",\n        \"You can filter by more than one column.\": \"Μπορείτε να φιλτράρετε με βάση περισσότερες από μία στήλες.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Τα γραφικά στοιχεία (widgets) της κοινότητας δημιουργούνται και συντηρούνται από τα μέλη της κοινότητας Grist.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"Για να διαμορφώσετε το ημερολόγιό σας, επιλέξτε στήλες για ημερομηνίες έναρξης/λήξης και τίτλους συμβάντων. Σημειώστε τον τύπο κάθε στήλης.\"\n        },\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Για να επιτρέψετε πολλαπλές αναθέσεις, αλλάξτε τον τύπο της στήλης στην οποία γίνεται αναφορά σε Λίστα Αναφορών.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Οι αμφίδρομες αναφορές δεν υποστηρίζονται προς το παρόν για στήλες φόρμουλας ή φόρμουλας ενεργοποίησης\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Οι τύποι υποστηρίζουν πολλές συναρτήσεις του Excel και πλήρη σύνταξη Python.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Δημιουργεί μια νέα στήλη Λίστας Αναφορών στον πίνακα προορισμού, με αυτήν και τις στήλες προορισμού επεξεργάσιμες και συγχρονισμένες.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Εσωτερικός χώρος αποθήκευσης σημαίνει ότι όλα τα συνημμένα αποθηκεύονται στο αρχείο SQLite του εγγράφου, ενώ εξωτερικός χώρος αποθήκευσης υποδεικνύει ότι όλα τα συνημμένα αποθηκεύονται στον ίδιο εξωτερικό χώρο αποθήκευσης.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Αυτό σας επιτρέπει να προσθέσετε συνημμένα που λείπουν από εξωτερικό χώρο αποθήκευσης, π.χ. σε ένα εισαγόμενο έγγραφο. Μόνο τα αρχεία συνημμένων .tar που έχουν ληφθεί από το Grist μπορούν να μεταφορτωθούν εδώ.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Κατανοήστε, τροποποιήστε και εργαστείτε με τα δεδομένα και τους τύπους σας με τη βοήθεια του νέου Βοηθού Τεχνητής Νοημοσύνης της Grist!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Αυτή η φόρμα δημιουργήθηκε από έναν χρήστη της Grist και δεν έχει εγκριθεί από την Grist Labs, Inc. ή από οποιοδήποτε μέρος που παρέχει αυτήν την υπηρεσία. Για την ασφάλειά σας, μην υποβάλλετε κωδικούς πρόσβασης μέσω αυτής της φόρμας και να είστε προσεκτικοί όταν κάνετε κλικ σε ενσωματωμένους συνδέσμους. Αναφέρετε κακόβουλες φόρμες στο [{{mail}}](mailto:{{mail}}).\",\n        \"Set the maximum number of lines for multi-line text.\": \"Ορίστε τον μέγιστο αριθμό γραμμών για κείμενο πολλαπλών γραμμών.\",\n        \"Comments are here!\": \"Τα σχόλια είναι εδώ!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Μπορείτε να προσθέσετε σχόλια σε κελιά, να απαντήσετε σε νήματα σχολίων και να κάνετε συνεργάτες με @-αναφορά.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"Όταν είναι επιλεγμένο, η προεπιλεγμένη τιμή αυτού του πεδίου μπορεί να συμπληρωθεί εκ των προτέρων από τη διεύθυνση URL χρησιμοποιώντας παραμέτρους ερωτήματος.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"Με τις προτάσεις, οι χρήστες κάνουν αλλαγές σε ένα προσωπικό αντίγραφο χωρίς να τροποποιήσουν το αρχικό έγγραφο και, στη συνέχεια, υποβάλλουν αυτές τις προτάσεις για έλεγχο από τον κάτοχο του εγγράφου πριν από την ενσωμάτωση.\",\n        \"Unpin to hide the button while keeping the filter.\": \"Ξεκαρφιτσώστε για να αποκρύψετε το κουμπί διατηρώντας παράλληλα το φίλτρο.\"\n    },\n    \"ColumnTitle\": {\n        \"Add description\": \"Προσθήκη περιγραφής\",\n        \"COLUMN ID: \": \"ΑΝΑΓΝΩΡΙΣΤΙΚΟ ΣΤΗΛΗΣ: \",\n        \"Column ID copied to clipboard\": \"Το αναγνωριστικό στήλης αντιγράφηκε στο πρόχειρο\",\n        \"Column description\": \"Περιγραφή στήλης\",\n        \"Save\": \"Αποθήκευση\",\n        \"Column label\": \"Ετικέτα στήλης\",\n        \"Close\": \"Κλείσιμο\",\n        \"Provide a column label\": \"Παρέχετε μια ετικέτα στήλης\",\n        \"Cancel\": \"Ακύρωση\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Το κατάλαβα\",\n        \"Unavailable Command\": \"Μη διαθέσιμη εντολή\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"Η εντολή μενού {{action}} δεν είναι διαθέσιμη σε αυτό το πρόγραμμα περιήγησης. Μπορείτε ακόμα να {{action}} χρησιμοποιώντας τη συντόμευση πληκτρολογίου {{shortcut}}.\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"Καθαρισμός πεδίου\",\n        \"Copy\": \"Αντιγραφή\",\n        \"Cut\": \"Αποκοπή\",\n        \"Hide field\": \"Απόκρυψη πεδίου\",\n        \"Paste\": \"Επικόλληση\",\n        \"Copy anchor link\": \"Αντιγραφή συνδέσμου αγκύρωσης\",\n        \"Comment\": \"Σχόλιο\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Εκκαθάριση ουράς\",\n        \"Webhook settings\": \"Ρυθμίσεις Webhook\",\n        \"Cleared webhook queue.\": \"Η ουρά webhook διαγράφηκε.\",\n        \"Columns to check when update (separated by ;)\": \"Στήλες προς έλεγχο κατά την ενημέρωση (διαχωρισμένες με ;)\",\n        \"Enabled\": \"Ενεργοποιημένο\",\n        \"Event Types\": \"Τύποι συμβάντων\",\n        \"Memo\": \"Σημείωμα\",\n        \"Name\": \"Όνομα\",\n        \"Ready Column\": \"Στήλη Έτοιμο\",\n        \"Removed webhook.\": \"Το webhook αφαιρέθηκε.\",\n        \"Sorry, not all fields can be edited.\": \"Λυπούμαστε, δεν είναι δυνατή η επεξεργασία όλων των πεδίων.\",\n        \"Status\": \"Κατάσταση\",\n        \"URL\": \"URL\",\n        \"Webhook Id\": \"Αναγνωριστικό Webhook\",\n        \"Table\": \"Πίνακας\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Φιλτράρισμα για αλλαγές σε αυτές τις στήλες (αναγνωριστικά διαχωρισμένα με ερωτηματικό)\",\n        \"Header Authorization\": \"Εξουσιοδότηση Κεφαλίδας (Header Authorization)\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Τα Webhooks δεν είναι διαθέσιμα σε μη αποθηκευμένα αντίγραφα εγγράφων\"\n    },\n    \"FormulaAssistant\": {\n        \"Community\": \"Κοινότητα\",\n        \"Ask the bot.\": \"Ρώτα το ρομπότ.\",\n        \"Capabilities\": \"Δυνατότητες\",\n        \"There are some things you should know when working with me:\": \"Υπάρχουν μερικά πράγματα που πρέπει να γνωρίζετε όταν συνεργάζεστε μαζί μου:\",\n        \"What do you need help with?\": \"Με τι χρειάζεσαι βοήθεια;\",\n        \"Formula AI Assistant is only available for logged in users.\": \"Ο Βοηθός Τεχνητής Νοημοσύνης Φόρμουλας είναι διαθέσιμος μόνο για συνδεδεμένους χρήστες.\",\n        \"For higher limits, contact the site owner.\": \"Για υψηλότερα όρια, επικοινωνήστε με τον κάτοχο του ιστότοπου.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Για υψηλότερα όρια, {{upgradeNudge}}.\",\n        \"You have used all available credits.\": \"Έχετε χρησιμοποιήσει όλες τις διαθέσιμες μονάδες.\",\n        \"Data\": \"Δεδομένα\",\n        \"Formula Cheat Sheet\": \"Φύλλο οδηγιών χρήσης Φόρμουλας\",\n        \"Formula Help. \": \"Βοήθεια για Φόρμουλες \",\n        \"Function List\": \"Λίστα Συναρτήσεων\",\n        \"Grist's AI Assistance\": \"Βοήθεια με Τεχνητή Νοημοσύνη του Grist\",\n        \"Grist's AI Formula Assistance. \": \"Βοήθεια Formula AI του Grist. \",\n        \"Regenerate\": \"Αναγέννηση\",\n        \"Save\": \"Αποθήκευση\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Δείτε τα {{helpFunction}} και {{formulaCheat}} ή επισκεφθείτε την {{community}} μας για περισσότερη βοήθεια.\",\n        \"Tips\": \"Συμβουλές\",\n        \"AI Assistant\": \"Βοηθός Τεχνητής Νοημοσύνης\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Clear conversation\": \"Καθαρισμός Συνομιλίας\",\n        \"Code view\": \"Προβολή Κώδικα\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Γεια σας, είμαι ο Βοηθός Τεχνητής Νοημοσύνης της Grist Formula.\",\n        \"Press Enter to apply suggested formula.\": \"Πατήστε Enter για να εφαρμόσετε την προτεινόμενη φόρμουλα.\",\n        \"Sign Up for Free\": \"Εγγραφείτε Δωρεάν\",\n        \"You have {{numCredits}} remaining credits.\": \"Σας απομένουν {{numCredits}} μονάδες.\",\n        \"upgrade to the Pro Team plan\": \"αναβάθμιση στο πρόγραμμα Pro Team\",\n        \"upgrade your plan\": \"αναβάθμιση του προγράμματός σας\",\n        \"Apply\": \"Εφαρμογή\",\n        \"Need help? Our AI assistant can help.\": \"Χρειάζεστε βοήθεια; Ο βοηθός μας με τεχνητή νοημοσύνη μπορεί να σας βοηθήσει.\",\n        \"Preview\": \"Προεπισκόπηση\",\n        \"New Chat\": \"Νέα Συνομιλία\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Μπορώ να βοηθήσω μόνο με τύπους. Δεν μπορώ να δημιουργήσω πίνακες, στήλες και προβολές ή να γράψω κανόνες πρόσβασης.\",\n        \"Learn more\": \"Μάθετε περισσότερα\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Εγγραφείτε για έναν δωρεάν λογαριασμό Grist για να ξεκινήσετε να χρησιμοποιείτε τον Βοηθό Τεχνητής Νοημοσύνης Φόρμουλας.\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Για περισσότερη βοήθεια με τους τύπους, ανατρέξτε στις σελίδες {{functionList}} και {{formulaCheatSheet}} ή επισκεφθείτε την {{community}} μας για περισσότερη βοήθεια.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Όταν μου μιλάτε, οι ερωτήσεις σας και η δομή του εγγράφου σας (ορατή στο {{codeView}}) αποστέλλονται στο OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Μίλησέ μου σαν να ήμουν άνθρωπος. Δεν χρειάζεται να καθορίσεις ονόματα πινάκων και στηλών. Για παράδειγμα, μπορείς να ρωτήσεις \\\"Παρακαλώ υπολογίστε το συνολικό ποσό του τιμολογίου.\\\"\"\n    },\n    \"UserManager\": {\n        \"Guest\": \"Επισκέπτης\",\n        \"Copy link\": \"Αντιγραφή Συνδέσμου\",\n        \"Create a team to share with more people\": \"Δημιουργήστε μια ομάδα για κοινή χρήση με περισσότερα άτομα\",\n        \"Grist support\": \"Υποστήριξη Grist\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Δεν υπάρχει προεπιλεγμένη πρόσβαση που να επιτρέπει την παραχώρηση πρόσβασης σε μεμονωμένα έγγραφα ή χώρους εργασίας, αντί για ολόκληρη την τοποθεσία της ομάδας.\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} από {{limitTop}} {{collaborator}}s\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Ο χρήστης κληρονομεί δικαιώματα από το {{parent}}. Για κατάργηση, ορίστε την επιλογή «Κληρονόμηση πρόσβασης» σε «Καμία».\",\n        \"Add {{member}} to your team\": \"Προσθέστε τον/την {{member}} στην ομάδα σας\",\n        \"Allow anyone with the link to open.\": \"Να επιτρέπεται σε οποιονδήποτε έχει τον σύνδεσμο να το ανοίξει.\",\n        \"Anyone with link \": \"Οποιοσδήποτε έχει σύνδεσμο \",\n        \"Cancel\": \"Ακύρωση\",\n        \"Close\": \"Κλείσιμο\",\n        \"Collaborator\": \"Συνεργάτης\",\n        \"Confirm\": \"Επιβεβαίωση\",\n        \"Invite multiple\": \"Προσκαλέστε πολλαπλούς\",\n        \"Invite people to {{resourceType}}\": \"Προσκαλέστε άτομα στο {{resourceType}}\",\n        \"Link copied to clipboard\": \"Ο σύνδεσμος αντιγράφηκε στο πρόχειρο\",\n        \"Manage members of team site\": \"Διαχείριση μελών της τοποθεσίας ομάδας\",\n        \"Off\": \"Off\",\n        \"On\": \"On\",\n        \"Open Access Rules\": \"Κανόνες Ανοικτής Πρόσβασης\",\n        \"Outside collaborator\": \"Εξωτερικός συνεργάτης\",\n        \"Public access\": \"Δημόσια πρόσβαση\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Δημόσια πρόσβαση που κληρονομήθηκε από {{parent}}. Για κατάργηση, ορίστε την επιλογή «Κληρονόμηση πρόσβασης» σε «Καμία».\",\n        \"Public access: \": \"Δημόσια πρόσβαση: \",\n        \"Remove my access\": \"Κατάργηση της πρόσβασής μου\",\n        \"Save & \": \"Αποθήκευση & \",\n        \"Team member\": \"Μέλος ομάδας\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Ο χρήστης κληρονομεί δικαιώματα από το {{parent})}. Για κατάργηση,            ορίστε την επιλογή «Κληρονόμηση πρόσβασης» σε «Καμία».\",\n        \"User may not modify their own access.\": \"Ο χρήστης δεν μπορεί να τροποποιήσει τα δικά του δικαιώματα πρόσβασης.\",\n        \"Your role for this team site\": \"Ο ρόλος σας για αυτήν την τοποθεσία ομάδας\",\n        \"Your role for this {{resourceType}}\": \"Ο ρόλος σας για αυτό το {{resourceType}}\",\n        \"guest\": \"επισκέπτης\",\n        \"member\": \"μέλος\",\n        \"team site\": \"ιστότοπος ομάδας\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Πρόκειται να καταργήσετε την πρόσβαση που έχετε σε αυτό το {{resourceType}}\",\n        \"Inherit access: \": \"Κληρονομική πρόσβαση: \",\n        \"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}}.\",\n        \"free collaborator\": \"ελεύθερος συνεργάτης\",\n        \"{{collaborator}} limit exceeded\": \"Υπέρβαση ορίου {{collaborator}}\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Δεν υπάρχει προεπιλεγμένη πρόσβαση που να επιτρέπει την          παραχώρηση πρόσβασης σε μεμονωμένα έγγραφα ή χώρους εργασίας, αντί για ολόκληρη την τοποθεσία της ομάδας.\",\n        \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\": \"Ο χρήστης έχει πρόσβαση προβολής στο {{resource}} ως αποτέλεσμα της μη αυτόματης ρύθμισης πρόσβασης στους πόρους που βρίσκονται μέσα. Εάν καταργηθεί από εδώ, αυτός ο χρήστης θα χάσει την πρόσβαση στους πόρους που βρίσκονται μέσα.\",\n        \"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}}.\": \"Αφού καταργήσετε τη δική σας πρόσβαση,              δεν θα μπορείτε να την ανακτήσετε χωρίς βοήθεια              από κάποιον άλλο με επαρκή πρόσβαση στο {{name}}.\",\n        \"Access overview\": \"Επισκόπηση πρόσβασης\",\n        \"Share it publicly\": \"Κοινοποιήστε το δημόσια\",\n        \"Verify your sensitive data before sharing publicly\": \"Επαληθεύστε τα ευαίσθητα δεδομένα σας πριν τα κοινοποιήσετε δημόσια\",\n        \"Your {{resourceType}} will be accessible to anyone with the link, whether shared directly or found through a search engine. \\n Ensure that your {{resourceType}} does not contain sensitive data before sharing.\": \"Το {{resourceType}} σας θα είναι προσβάσιμο σε οποιονδήποτε διαθέτει τον σύνδεσμο, είτε κοινοποιήθηκε απευθείας είτε βρέθηκε μέσω μιας μηχανής αναζήτησης.\\n\\nΒεβαιωθείτε ότι το {{resourceType}} σας δεν περιέχει ευαίσθητα δεδομένα πριν από την κοινοποίηση.\"\n    },\n    \"SupportGristPage\": {\n        \"Help Center\": \"Κέντρο Βοήθειας\",\n        \"You can opt out of telemetry at any time from this page.\": \"Μπορείτε να εξαιρεθείτε από την τηλεμετρία ανά πάσα στιγμή από αυτήν τη σελίδα.\",\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"Σελίδα Χορηγών GitHub\",\n        \"Home\": \"Αρχική\",\n        \"Manage Sponsorship\": \"Διαχείριση Χορηγίας\",\n        \"Opt in to Telemetry\": \"Εγγραφή στην Τηλεμετρία\",\n        \"Opt out of Telemetry\": \"Εξαίρεση από την Τηλεμετρία\",\n        \"Sponsor Grist Labs on GitHub\": \"Χορηγήστε την Grist Labs στο GitHub\",\n        \"Telemetry\": \"Τηλεμετρία\",\n        \"Support Grist\": \"Υποστήριξε το Grist\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Αυτή η εγκατάσταση έχει επιλεγεί για τηλεμετρία. Μόνο ο διαχειριστής του ιστότοπου έχει άδεια να το αλλάξει αυτό.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Αυτή η εγκατάσταση έχει εξαιρεθεί από την τηλεμετρία. Μόνο ο διαχειριστής του ιστότοπου έχει άδεια να το αλλάξει αυτό.\",\n        \"You have opted out of telemetry.\": \"Έχετε εξαιρεθεί από την τηλεμετρία.\",\n        \"Sponsor\": \"Χορηγός\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Συλλέγουμε μόνο στατιστικά στοιχεία χρήσης, όπως περιγράφεται λεπτομερώς στο {{link}} μας, ποτέ δεν καταγράφουμε περιεχόμενο.\",\n        \"You have opted in to telemetry. Thank you!\": \"Έχετε επιλέξει να χρησιμοποιείτε την τηλεμετρία. Ευχαριστούμε!\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Το λογισμικό Grist αναπτύσσεται από την Grist Labs, η οποία προσφέρει δωρεάν και επί πληρωμή προγράμματα φιλοξενίας. Επίσης, διαθέτουμε κώδικα Grist με μια τυπική δωρεάν και ανοιχτή άδεια OSS (Apache 2.0) στο {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Υποστηρίξτε την Grist επιλέγοντας την τηλεμετρία, η οποία μας βοηθά να κατανοήσουμε πώς χρησιμοποιείται το προϊόν, ώστε να μπορούμε να ιεραρχήσουμε τις μελλοντικές βελτιώσεις.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Είμαστε μια μικρή και αποφασισμένη ομάδα. Η υποστήριξή σας έχει μεγάλη σημασία για εμάς. Δείχνει επίσης στους άλλους ότι υπάρχει μια αποφασισμένη κοινότητα πίσω από αυτό το προϊόν.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Μπορείτε να υποστηρίξετε την ανάπτυξη ανοιχτού κώδικα της Grist χρηματοδοτώντας μας στο {{link}} μας.\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Μεγιστοποίηση\",\n        \"Minimize\": \"Ελαχιστοποίηση\"\n    },\n    \"CardContextMenu\": {\n        \"Copy anchor link\": \"Αντιγραφή συνδέσμου αγκύρωσης\",\n        \"Delete card\": \"Διαγραφή κάρτας\",\n        \"Duplicate card\": \"Αντιγραφή κάρτας\",\n        \"Insert card\": \"Εισαγωγή κάρτας\",\n        \"Insert card above\": \"Εισαγωγή κάρτας από πάνω\",\n        \"Insert card below\": \"Εισαγωγή κάρτας από κάτω\"\n    },\n    \"FormView\": {\n        \"Reset\": \"Επαναφορά\",\n        \"Reset form\": \"Επαναφορά φόρμας\",\n        \"Save your document to publish this form.\": \"Αποθηκεύστε το έγγραφό σας για να δημοσιεύσετε αυτήν τη φόρμα.\",\n        \"Share\": \"Μοιράσου\",\n        \"Share this form\": \"Κοινοποίηση αυτής της φόρμας\",\n        \"View\": \"Προβολή\",\n        \"# **Form Title**\": \"# **Form Title**\",\n        \"Your form description goes here.\": \"Η περιγραφή της φόρμας σας πηγαίνει εδώ.\",\n        \"Copy link\": \"Αντιγραφή συνδέσμου\",\n        \"Link copied to clipboard\": \"Ο σύνδεσμος αντιγράφηκε στο πρόχειρο\",\n        \"Publish\": \"Δημοσίευση\",\n        \"Publish your form?\": \"Δημοσίευση της φόρμας σας;\",\n        \"Are you sure you want to reset your form?\": \"Είστε σίγουροι ότι θέλετε να επαναφέρετε τη φόρμα σας;\",\n        \"Copy code\": \"Αντιγραφή κώδικα\",\n        \"Embed this form\": \"Ενσωμάτωση αυτής της φόρμας\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Οποιοσδήποτε διαθέτει τον παρακάτω σύνδεσμο μπορεί να δει την κενή φόρμα και να υποβάλει μια απάντηση.\",\n        \"Preview\": \"Προεπισκόπηση\",\n        \"Unpublish\": \"Κατάργηση δημοσίευσης\",\n        \"Code copied to clipboard\": \"Ο κώδικας αντιγράφηκε στο πρόχειρο\",\n        \"Unpublish your form?\": \"Κατάργηση δημοσίευσης της φόρμας σας;\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"Η δημοσίευση της φόρμας σας θα δημιουργήσει έναν σύνδεσμο κοινής χρήσης. Οποιοσδήποτε διαθέτει τον σύνδεσμο μπορεί να δει την κενή φόρμα και να υποβάλει μια απάντηση.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Η κατάργηση της δημοσίευσης της φόρμας θα απενεργοποιήσει τον σύνδεσμο κοινής χρήσης, έτσι ώστε οι χρήστες που έχουν πρόσβαση στη φόρμα σας μέσω αυτού του συνδέσμου να βλέπουν ένα σφάλμα.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Οι χρήστες περιορίζονται στην υποβολή καταχωρήσεων (εγγραφές στον πίνακά σας) και στην ανάγνωση προκαθορισμένων τιμών σε καθορισμένα πεδία, όπως στήλες αναφοράς και επιλογής.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Η φόρμα σας έχει δημοσιευτεί. Κάθε αλλαγή είναι ενεργή και ορατή στους χρήστες που έχουν πρόσβαση στη φόρμα. Εάν θέλετε να κάνετε αλλαγές στο προσχέδιο, καταργήστε τη δημοσίευση της φόρμας.\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Map fields\": \"Αντιστοίχιση πεδίων\",\n        \"Mapped\": \"Αντιστοιχισμένο\",\n        \"Clear\": \"Καθαρισμός\",\n        \"Select all\": \"Επιλογή Ολων\",\n        \"Unmap fields\": \"Κατάργηση αντιστοίχισης πεδίων\",\n        \"Unmapped\": \"Μη αντιστοιχισμένο\"\n    },\n    \"FormConfig\": {\n        \"Default\": \"Προεπιλεγμένη\",\n        \"Descending\": \"Φθίνουσα\",\n        \"Field rules\": \"Κανόνες πεδίου\",\n        \"Required field\": \"Υποχρεωτικό πεδίο\",\n        \"Ascending\": \"Αύξουσα\",\n        \"Field Format\": \"Μορφή Πεδίου\",\n        \"Field Rules\": \"Κανόνες Πεδίου\",\n        \"Horizontal\": \"Οριζόντια\",\n        \"Options Alignment\": \"Ευθυγράμμιση Επιλογών\",\n        \"Options Sort Order\": \"Σειρά Ταξινόμησης Επιλογών\",\n        \"Select\": \"Επιλογή\",\n        \"Radio\": \"Κουμπί επιλογής (radio)\",\n        \"Vertical\": \"Κάθετη\",\n        \"Accept value from URL\": \"Αποδοχή τιμής από URL\",\n        \"Hidden field\": \"Κρυφό πεδίο\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"Παράμετρος URL:\\n{{colId}}=VALUE\",\n        \"Options limit\": \"Όριο επιλογών\"\n    },\n    \"DateRangeOptions\": {\n        \"Today\": \"Σήμερα\",\n        \"Last 30 days\": \"Τελευταίες 30 ημέρες\",\n        \"Last 7 days\": \"Τελευταίες 7 ημέρες\",\n        \"Last week\": \"Την περασμένη εβδομάδα\",\n        \"Next 7 days\": \"Επόμενες 7 ημέρες\",\n        \"This month\": \"Αυτόν τον μήνα\",\n        \"This week\": \"Αυτή την βδομάδα\",\n        \"This year\": \"Φέτος\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Unmap fields\": \"Κατάργηση αντιστοίχισης πεδίων\",\n        \"Unmapped\": \"Μη αντιστοιχισμένο\",\n        \"Clear\": \"Καθαρισμός\",\n        \"Map fields\": \"Αντιστοίχιση πεδίων\",\n        \"Select all\": \"Επιλογή Ολων\",\n        \"Mapped\": \"Αντιστοιχισμένο\",\n        \"Hide {{label}}\": \"Απόκρυψη {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Απόκρυψη {{label}} (λειτουργία παρτίδας)\",\n        \"Unmap {{label}}\": \"Κατάργηση αντιστοίχισης {{label}}\",\n        \"Unmap {{label}} (batch mode)\": \"Κατάργηση αντιστοίχισης {{label}} (λειτουργία παρτίδας)\"\n    },\n    \"AdminPanel\": {\n        \"Current\": \"Τρέχον\",\n        \"Current version of Grist\": \"Τρέχουσα έκδοση του Grist\",\n        \"Help us make Grist better\": \"Βοηθήστε μας να βελτιώσουμε το Grist\",\n        \"Home\": \"Αρχική\",\n        \"Support Grist Labs on GitHub\": \"Υποστήριξη Grist Labs στο GitHub\",\n        \"Telemetry\": \"Τηλεμετρία\",\n        \"Version\": \"Έκδοση\",\n        \"Check now\": \"Ελέγξτε τώρα\",\n        \"Checking for updates...\": \"Έλεγχος για ενημερώσεις...\",\n        \"Error checking for updates\": \"Σφάλμα κατά τον έλεγχο για ενημερώσεις\",\n        \"Grist is up to date\": \"Το Grist είναι ενημερωμένο\",\n        \"Grist releases are at \": \"Οι κυκλοφορίες του Grist είναι στις \",\n        \"Last checked {{time}}\": \"Τελευταίος έλεγχος {{time}}\",\n        \"Learn more.\": \"Μάθετε περισσότερα.\",\n        \"Newer version available\": \"Διαθέσιμη νεότερη έκδοση\",\n        \"No information available\": \"Δεν υπάρχουν διαθέσιμες πληροφορίες\",\n        \"OK\": \"Εντάξει\",\n        \"Sandbox settings for data engine\": \"Ρυθμίσεις sandbox για μηχανή δεδομένων\",\n        \"Sandboxing\": \"Sandboxing\",\n        \"Security Settings\": \"Ρυθμίσεις Ασφαλείας\",\n        \"Support Grist\": \"Υποστήριξε το Grist\",\n        \"unconfigured\": \"μη διαμορφωμένο\",\n        \"unknown\": \"άγνωστο\",\n        \"Authentication\": \"Έλεγχος ταυτότητας\",\n        \"Check failed.\": \"Ο έλεγχος απέτυχε.\",\n        \"Current authentication method\": \"Τρέχουσα μέθοδος ελέγχου ταυτότητας\",\n        \"Details\": \"Λεπτομέρειες\",\n        \"No fault detected.\": \"Δεν εντοπίστηκε σφάλμα.\",\n        \"Notes\": \"Σημειώσεις\",\n        \"Key to sign sessions with\": \"Κλειδί για την υπογραφή συνεδριών\",\n        \"Session Secret\": \"Μυστικό κλειδί συνεδρίας\",\n        \"Enable Grist Enterprise\": \"Ενεργοποίηση Grist Enterprise\",\n        \"Enterprise\": \"Enterprise\",\n        \"checking\": \"έλεγχος\",\n        \"Audit Logs\": \"Αρχεία καταγραφής ελέγχου\",\n        \"Contact us\": \"Επικοινωνήστε μαζί μας\",\n        \"Log Streaming\": \"Ροή αρχείων καταγραφής\",\n        \"New, Enterprise\": \"Νέο, Enterprise\",\n        \"Off\": \"Off\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} ακόμα\",\n        \"On\": \"On\",\n        \"Grist Instance\": \"Εγκατάσταση Grist\",\n        \"Error\": \"Σφάλμα\",\n        \"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 είναι προσβάσιμο μέσω δικτύου ή διατίθεται σε πολλά άτομα.\",\n        \"Auto-check when this page loads\": \"Αυτόματος έλεγχος κατά τη φόρτωση αυτής της σελίδας\",\n        \"Self Checks\": \"Αυτοέλεγχοι\",\n        \"Updates\": \"Ενημερώσεις\",\n        \"Admin Panel\": \"Πίνακας Διαχείρισης\",\n        \"Check succeeded.\": \"Ο έλεγχος πέτυχε.\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Ή, ως εναλλακτική λύση, μπορείτε να ορίσετε: {{bootKey}} στο περιβάλλον και να επισκεφθείτε: {{url}}\",\n        \"Sponsor\": \"Χορηγός\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"Δεν έχετε πρόσβαση στον πίνακα διαχειριστή.\\nΣυνδεθείτε ως διαχειριστής.\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"Το Grist υπογράφει τα cookies περιόδου σύνδεσης χρήστη με ένα μυστικό κλειδί. Ορίστε αυτό το κλειδί μέσω της μεταβλητής περιβάλλοντος GRIST_SESSION_SECRET. Το Grist επιστρέφει σε μια προεπιλογή που έχει οριστεί από μόνιμα κωδικοποιημένη μορφή όταν δεν έχει οριστεί. Ενδέχεται να καταργήσουμε αυτήν την ειδοποίηση στο μέλλον, καθώς τα αναγνωριστικά περιόδου σύνδεσης που δημιουργούνται από την έκδοση v1.1.16 και μετά είναι εγγενώς κρυπτογραφικά ασφαλή.\",\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 υπογράφει τα cookies περιόδου σύνδεσης χρήστη με ένα μυστικό κλειδί. Ορίστε αυτό το κλειδί μέσω της μεταβλητής περιβάλλοντος GRIST_SESSION_SECRET. Το Grist επιστρέφει σε μια προεπιλογή που έχει οριστεί από μόνιμα κωδικοποιημένη μορφή όταν δεν έχει οριστεί. Ενδέχεται να καταργήσουμε αυτήν την ειδοποίηση στο μέλλον, καθώς τα αναγνωριστικά περιόδου σύνδεσης που δημιουργούνται από την έκδοση v1.1.16 και μετά είναι εγγενώς κρυπτογραφικά ασφαλή.\",\n        \"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 επιτρέπει πολύ ισχυρούς τύπους, χρησιμοποιώντας Python. Συνιστούμε να ορίσετε τη μεταβλητή περιβάλλοντος GRIST_SANDBOX_FLAVOR σε gvisor, εάν το υλικό σας το υποστηρίζει (τα περισσότερα θα το κάνουν), για να εκτελείτε τύπους σε κάθε έγγραφο μέσα σε ένα sandbox απομονωμένο από άλλα έγγραφα και απομονωμένο από το δίκτυο.\",\n        \"Administrator Panel Unavailable\": \"Ο πίνακας διαχειριστή δεν είναι διαθέσιμος\",\n        \"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 είναι προσβάσιμο μέσω δικτύου ή διατίθεται      σε πολλά άτομα.\",\n        \"Results\": \"Αποτελέσματα\",\n        \"Auto-check weekly\": \"Αυτόματος έλεγχος εβδομαδιαίως\",\n        \"No record of last version check\": \"Δεν υπάρχει καταγραφή ελέγχου τελευταίας έκδοσης\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Μπορείτε να ρυθμίσετε τη ροή συμβάντων ελέγχου από το Grist σε ένα εξωτερικό σύστημα πληροφοριών ασφαλείας και διαχείρισης συμβάντων (SIEM), εάν ενεργοποιήσετε το Grist Enterprise. {{contactUsLink}} για να μάθετε περισσότερα.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Οι αυτόματοι έλεγχοι είναι απενεργοποιημένοι. Ορίστε τη μεταβλητή περιβάλλοντος GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING σε \\\"true\\\" για να τους ενεργοποιήσετε.\",\n        \"auth error\": \"σφάλμα ελέγχου ταυτότητας\",\n        \"configured\": \"διαμορφωμένο\",\n        \"default\": \"Προεπιλογή\",\n        \"more...\": \"περισσότερα...\",\n        \"no authentication\": \"χωρίς έλεγχο ταυτότητας\",\n        \"unavailable\": \"μη διαθέσιμο\",\n        \"{{count}} admin accounts_one\": \"{{count}} λογαριασμός διαχειριστή\",\n        \"{{count}} admin accounts_other\": \"{{count}} λογαριασμοί διαχειριστή\",\n        \"Admin account not found\": \"Δεν βρέθηκε λογαριασμός διαχειριστή\",\n        \"Administrative accounts\": \"Διαχειριστικοί λογαριασμοί\",\n        \"The users with administrative accounts\": \"Οι χρήστες με λογαριασμούς διαχειριστή\",\n        \"Version {{versionNumber}}\": \"Έκδοση {{versionNumber}}\",\n        \"no admin accounts\": \"χωρίς λογαριασμούς διαχειριστή\",\n        \"Are you sure you want to restart Grist?\": \"Είστε σίγουροι ότι θέλετε να επανεκκινήσετε το Grist;\",\n        \"Grist is running in an environment that doesn't support restarting from the admin panel.\": \"Το Grist εκτελείται σε ένα περιβάλλον που δεν υποστηρίζει επανεκκίνηση από τον πίνακα διαχείρισης.\",\n        \"Restart\": \"Επανεκκίνηση\",\n        \"Restart Grist\": \"Επανεκκίνηση Grist\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"Επανεκκινήστε το Grist για να εφαρμόσετε τις αλλαγές που εκκρεμούν ή να επιλύσετε προβλήματα.\",\n        \"Restart Grist?\": \"Επανεκκίνηση του Grist;\",\n        \"Restarting Grist...\": \"Επανεκκίνηση του Grist...\",\n        \"This will apply any pending changes and briefly interrupt access for all users.\": \"Αυτό θα εφαρμόσει τυχόν εκκρεμείς αλλαγές και θα διακόψει για λίγο την πρόσβαση για όλους τους χρήστες.\",\n        \"You can still restart Grist manually.\": \"Μπορείτε ακόμα να επανεκκινήσετε το Grist χειροκίνητα.\",\n        \"error in {{provider}}: {{verdict}}\": \"σφάλμα στον {{provider}}: {{verdict}}\",\n        \"Please restart Grist manually.\": \"Παρακαλώ επανεκκινήστε το Grist χειροκίνητα.\",\n        \"Restart Grist to apply pending changes.\": \"Επανεκκινήστε το Grist για να εφαρμόσετε τις αλλαγές που εκκρεμούν.\",\n        \"Restart unavailable\": \"Η επανεκκίνηση δεν είναι διαθέσιμη\"\n    },\n    \"ChoiceListEditor\": {\n        \"No choices to select\": \"Δεν υπάρχουν επιλογές για να διαλέξετε\",\n        \"Error in dropdown condition\": \"Σφάλμα στην αναπτυσσόμενη λίστα\",\n        \"No choices matching condition\": \"Δεν υπάρχουν επιλογές που να αντιστοιχούν στην συνθήκη\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"Συνθήκη αναπτυσσόμενης λίστας\",\n        \"Invalid columns: {{colIds}}\": \"Μη έγκυρες στήλες: {{colIds}}\",\n        \"Set dropdown condition\": \"Ορισμός συνθήκης αναπτυσσόμενης λίστας\"\n    },\n    \"ChoiceEditor\": {\n        \"No choices to select\": \"Δεν υπάρχουν επιλογές για να διαλέξετε\",\n        \"Error in dropdown condition\": \"Σφάλμα στην αναπτυσσόμενη λίστα\",\n        \"No choices matching condition\": \"Δεν υπάρχουν επιλογές που να αντιστοιχούν στην συνθήκη\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Εισαγάγετε συνθήκη.\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Σφάλμα στην αναπτυσσόμενη λίστα\",\n        \"No choices matching condition\": \"Δεν υπάρχουν επιλογές που να αντιστοιχούν στην συνθήκη\",\n        \"No choices to select\": \"Δεν υπάρχουν επιλογές για να διαλέξετε\"\n    },\n    \"widgetTypesMap\": {\n        \"Card\": \"Κάρτα\",\n        \"Card List\": \"Λίστα καρτών\",\n        \"Chart\": \"Διάγραμμα\",\n        \"Custom\": \"Προσαρμοσμένο\",\n        \"Form\": \"Φόρμα\",\n        \"Table\": \"Πίνακας\",\n        \"Calendar\": \"Ημερολόγιο\"\n    },\n    \"DocTutorial\": {\n        \"End tutorial\": \"Τέλος σεμιναρίου\",\n        \"Finish\": \"Ολοκλήρωση\",\n        \"Next\": \"Επόμενο\",\n        \"Click to expand\": \"Κάντε κλικ για ανάπτυξη\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Θέλετε να επανεκκινήσετε το σεμινάριο; Όλη η πρόοδος θα χαθεί.\",\n        \"Previous\": \"Προηγούμενο\",\n        \"Restart\": \"Επανεκκίνηση\"\n    },\n    \"TimingPage\": {\n        \"Total Time (s)\": \"Συνολικός χρόνος (s)\",\n        \"Average Time (s)\": \"Μέσος χρόνος (s)\",\n        \"Column ID\": \"Αναγνωριστικό στήλης\",\n        \"Formula timer\": \"Χρονόμετρο φόρμουλας\",\n        \"Loading timing data. Don't close this tab.\": \"Φόρτωση δεδομένων χρονισμού. Μην κλείσετε αυτήν την καρτέλα.\",\n        \"Max Time (s)\": \"Μέγιστος χρόνος (s)\",\n        \"Number of Calls\": \"Αριθμός κλήσεων\",\n        \"Table ID\": \"Αναγνωριστικό πίνακα\"\n    },\n    \"OnboardingCards\": {\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Μάθετε τα βασικά για τις στήλες αναφοράς, τα συνδεδεμένα γραφικά στοιχεία (widgets), τους τύπους στηλών και τις κάρτες.\",\n        \"Complete our basics tutorial\": \"Ολοκληρώστε το βασικό μας σεμινάριο\",\n        \"Complete the tutorial\": \"Ολοκληρώστε το σεμινάριο\",\n        \"3 minute video tour\": \"Βίντεο περιήγησης 3 λεπτών\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Μάθετε τα βασικά για τις στήλες αναφοράς, τα συνδεδεμένα γραφικά στοιχεία, τους τύπους στηλών και τις κάρτες.\"\n    },\n    \"OnboardingPage\": {\n        \"Back\": \"Πίσω\",\n        \"Go to the tutorial!\": \"Μετάβαση στο σεμινάριο!\",\n        \"Discover Grist in 3 minutes\": \"Ανακαλύψτε το Grist σε 3 λεπτά\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Εξασκηθείτε στο σεμινάριο Grist Basics\",\n        \"Skip tutorial\": \"Παράλειψη σεμιναρίου\",\n        \"Type here\": \"Πληκτρολογήστε εδώ\",\n        \"Welcome\": \"Καλώς ήρθατε\",\n        \"What is your role?\": \"Ποιος είναι ο ρόλος σας;\",\n        \"What organization are you with?\": \"Με ποιον οργανισμό είσαι;\",\n        \"Your role\": \"Ο ρόλος σας\",\n        \"Next step\": \"Επόμενο βήμα\",\n        \"Skip step\": \"Παράλειψη βήματος\",\n        \"Your organization\": \"Ο οργανισμός σας\",\n        \"Tell us who you are\": \"Πες μας ποιος είσαι\",\n        \"What brings you to Grist (you can select multiple)?\": \"Τι σας φέρνει στο Grist (μπορείτε να επιλέξετε πολλαπλά);\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Το Grist μπορεί να μοιάζει με υπολογιστικό φύλλο, αλλά δεν λειτουργεί πάντα έτσι. Ανακαλύψτε τι κάνει το Grist να διαφέρει.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"Enable Grist Enterprise\": \"Ενεργοποίηση Grist Enterprise\",\n        \"Paste your activation key\": \"Επικολλήστε το κλειδί ενεργοποίησης\",\n        \"Disable Grist Enterprise\": \"Απενεργοποίηση του Grist Enterprise\",\n        \"Grist Enterprise is **enabled**.\": \"Το Grist Enterprise είναι **ενεργοποιημένο**.\",\n        \"Activate\": \"Ενεργοποίηση\",\n        \"Activation key\": \"Κλειδί ενεργοποίησης\",\n        \"Copy to clipboard\": \"Αντιγραφή στο πρόχειρο\",\n        \"Expiration date\": \"Ημερομηνία λήξης\",\n        \"Installation ID:\": \"Αναγνωριστικό εγκατάστασης:\",\n        \"Plan name\": \"Όνομα προγράμματος\",\n        \"You are currently trialing Grist Enterprise.\": \"Αυτήν τη στιγμή δοκιμάζετε το Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"Δεν έχετε ενεργή συνδρομή.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Η εγκατάστασή σας θα είναι σε **read-only** κατάσταση σε **{{days}}** μέρες.\",\n        \"Your subscription expired on {{date}}.\": \"Η συνδρομή σας έληξε στις {{date}}.\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Για να συνεχίσετε να χρησιμοποιείτε το Grist Enterprise, πρέπει να \\n                 [επικοινωνήσετε μαζί μας]({{signupLink}}) για να λάβετε το κλειδί ενεργοποίησης.\",\n        \"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 μετά τη λήξη μιας δοκιμαστικής \\nπεριόδου 30 ημερών. Αποκτήστε ένα κλειδί ενεργοποίησης [εγγραφόμενοι στο Grist\\nEnterprise]({{signupLink}}). Δεν χρειάζεστε κλειδί ενεργοποίησης για να εκτελέσετε το\\nGrist Core.\\n\\nΜάθετε περισσότερα στο [Κέντρο Βοήθειας]({{helpCenter}}).\",\n        \"Installation seats\": \"Άδειες χρηστών εγκατάστασεις\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Ένα κλειδί ενεργοποίησης χρησιμοποιείται για την εκτέλεση του Grist Enterprise μετά τη λήξη μιας δοκιμαστικής \\nπεριόδου 30 ημερών. Αποκτήστε ένα κλειδί ενεργοποίησης [επικοινωνώντας μαζί μας]({{contactLink}}) σήμερα. \\nΔεν χρειάζεστε κλειδί ενεργοποίησης για να εκτελέσετε το Grist Core.\\n\\nΜάθετε περισσότερα στο [Κέντρο Βοήθειας]({{helpCenter}}).\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Απαιτείται ενεργή συνδρομή για να συνεχίσετε να χρησιμοποιείτε το Grist Enterprise. \\nΜπορείτε να ενεργοποιήσετε τη συνδρομή σας [εγγραφόμενοι στο Grist Enterprise ]({{signupLink}}) \\nκαι επικολλώντας το κλειδί ενεργοποίησης παρακάτω.\",\n        \"Installation ID copied to clipboard\": \"Το αναγνωριστικό εγκατάστασης αντιγράφηκε στο πρόχειρο\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Μάθετε περισσότερα στο [Κέντρο βοήθειας]({{helpCenter}}).\",\n        \"Your activation key has expired due to exceeding limits.\": \"Το κλειδί ενεργοποίησής σας έχει λήξει λόγω υπέρβασης ορίων.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Η δοκιμαστική σας περίοδος έληξε στις **{{expireAt}}**. Για να συνεχίσετε να χρησιμοποιείτε το Grist Enterprise, πρέπει να\\n[εγγραφείτε στο Grist Enterprise]({{signupLink}}) και να επικολλήσετε το κλειδί ενεργοποίησης παρακάτω.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Ένα κλειδί ενεργοποίησης χρησιμοποιείται για την εκτέλεση του Grist Enterprise μετά τη λήξη \\nμιας δοκιμαστικής περιόδου 30 ημερών. Αποκτήστε ένα κλειδί ενεργοποίησης κάνοντας\\n [εγγραφή στο Grist Enterprise] ({{signupLink}}). Δεν χρειάζεστε κλειδί ενεργοποίησης για να \\nεκτελέσετε το Grist Core.\"\n    },\n    \"ViewLayout\": {\n        \"Raw Data page\": \"σελίδα ακατέργαστων δεδομένων\",\n        \"Delete\": \"Διαγραφή\",\n        \"Delete data and this widget.\": \"Διαγραφή δεδομένων και αυτού του γραφικού στοιχείου (widget).\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Διατήρηση δεδομένων και διαγραφή γραφικού στοιχείου (widget). Ο πίνακας θα παραμείνει διαθέσιμος στο {{rawDataLink}}\",\n        \"Table {{tableName}} will no longer be visible\": \"Ο πίνακας {{tableName}} δεν θα είναι πλέον ορατός\"\n    },\n    \"CustomWidgetGallery\": {\n        \"Custom URL\": \"Προσαρμοσμένη διεύθυνση URL\",\n        \"(Missing info)\": \"(Λείπουν πληροφορίες)\",\n        \"Add widget\": \"Προσθήκη γραφικού στοιχείου (widget)\",\n        \"Add Your Own Widget\": \"Προσθέστε το δικό σας γραφικό στοιχείο (widget)\",\n        \"Add a widget from outside this gallery.\": \"Προσθέστε ένα γραφικό στοιχείο εκτός αυτής της συλλογής (widget).\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Change widget\": \"Αλλαγή Γραφικού Στοιχείου (Widget)\",\n        \"Choose custom widget\": \"Επιλογή Προσαρμοσμένου Γραφικού Συστατικού (Widget)\",\n        \"Developer:\": \"Προγραμματιστής:\",\n        \"Learn more about custom widgets\": \"Μάθετε περισσότερα σχετικά με τα Προσαρμοσμένα Γραφικά Στοιχεία (Widgets)\",\n        \"Widget URL\": \"URL Γραφικού Στοιχείου (Widget)\",\n        \"Community Widget\": \"Γραφικό Στοιχείο της Κοινότητας (Widget)\",\n        \"No matching widgets\": \"Δεν υπάρχουν αντίστοιχα γραφικά στοιχεία (widgets)\",\n        \"Grist Widget\": \"Γραφικό στοιχείο (widget) Grist\",\n        \"Last updated:\": \"Τελευταία ενημέρωση:\",\n        \"Search\": \"Αναζήτηση\"\n    },\n    \"SupportGristButton\": {\n        \"Help Center\": \"Κέντρο Βοήθειας\",\n        \"Opt in to Telemetry\": \"Εγγραφή στην Τηλεμετρία\",\n        \"Opted In\": \"Συμμετοχή\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Σας ευχαριστούμε! Η εμπιστοσύνη και η υποστήριξή σας εκτιμώνται ιδιαίτερα. Μπορείτε να εξαιρεθείτε οποιαδήποτε στιγμή από το {{link}} στο μενού χρήστη.\",\n        \"Support Grist\": \"Υποστήριξε το Grist\",\n        \"Admin Panel\": \"Πίνακας Διαχείρισης\",\n        \"Close\": \"Κλείσιμο\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Επιλέξτε να ενεργοποιήσετε την τηλεμετρία για να κατανοήσουμε καλύτερα τον τρόπο χρήσης του προϊόντος, ώστε να μπορέσουμε να δώσουμε προτεραιότητα στις μελλοντικές βελτιώσεις.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Συλλέγουμε μόνο στατιστικά στοιχεία χρήσης, όπως περιγράφεται λεπτομερώς στο {{helpCenterLink}} μας, ποτέ περιεχόμενο εγγράφων. Μπορείτε να εξαιρεθείτε οποιαδήποτε στιγμή από το {{supportGristLink}} στο μενού χρήστη.\"\n    },\n    \"AuditLogsPage\": {\n        \"upgrade your plan\": \"αναβάθμιση του προγράμματός σας\",\n        \"Audit Logs\": \"Αρχεία καταγραφής ελέγχου\",\n        \"Audit logs for {{siteName}}\": \"Αρχεία καταγραφής ελέγχου για {{siteName}}\",\n        \"Contact us\": \"Επικοινωνήστε μαζί μας\",\n        \"Home\": \"Αρχική\",\n        \"Log streaming\": \"Ροή αρχείων καταγραφής\",\n        \"Only site owners may access audit logs.\": \"Μόνο οι κάτοχοι ιστότοπων έχουν πρόσβαση στα αρχεία καταγραφής ελέγχου.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Μπορείτε να ρυθμίσετε τη ροή συμβάντων ελέγχου από το Grist σε ένα εξωτερικό σύστημα SIEM (πληροφορίες ασφαλείας και διαχείριση συμβάντων), εάν ενεργοποιήσετε το Grist Enterprise. {{contactUsLink}} για να μάθετε περισσότερα.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Μπορείτε να ρυθμίσετε τη ροή συμβάντων ελέγχου από το Grist σε ένα εξωτερικό σύστημα SIEM (πληροφορίες ασφαλείας και διαχείριση συμβάντων) εάν {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"Access details\": \"Λεπτομέρειες πρόσβασης\",\n        \"All\": \"Όλα\",\n        \"Current workspace\": \"Τρέχων χώρος εργασίας\",\n        \"Delete\": \"Διαγραφή\",\n        \"Delete {{name}}\": \"Διαγραφή {{name}}\",\n        \"Document will be moved to Trash.\": \"Το έγγραφο θα μετακινηθεί στον Κάδο Απορριμμάτων.\",\n        \"Edited {{at}}\": \"Επεξεργασμένο {{at}}\",\n        \"Last edited\": \"Τελευταία επεξεργασία\",\n        \"Manage users\": \"Διαχείριση χρηστών\",\n        \"Move\": \"Μετακίνηση\",\n        \"Move {{name}} to workspace\": \"Μετακίνηση του {{name}} στον χώρο εργασίας\",\n        \"Name\": \"Όνομα\",\n        \"No documents to show.\": \"Δεν υπάρχουν έγγραφα προς εμφάνιση.\",\n        \"Pin\": \"Καρφίτσωμα\",\n        \"Pinned\": \"Καρφιτσωμένο\",\n        \"Recent\": \"Πρόσφατα\",\n        \"Rename and set icon\": \"Μετονομασία και ορισμός εικονιδίου\",\n        \"Requires edit permissions\": \"Απαιτούνται δικαιώματα επεξεργασίας\",\n        \"Sort by date\": \"Ταξινόμηση κατά ημερομηνία\",\n        \"Sort by name\": \"Ταξινόμηση κατά όνομα\",\n        \"Unpin\": \"Ξεκαρφίτσωμα\",\n        \"Workspace\": \"Χώρος εργασίας\",\n        \"context menu - {{- documentName }}\": \"μενού περιβάλλοντος - {{- documentName }}\",\n        \"Documents list\": \"Λίστα εγγράφων\",\n        \"Download document...\": \"Λήψη εγγράφου...\",\n        \"Deleted {{at}}\": \"Διαγράφηκε {{at}}\"\n    },\n    \"RightPanelUtils\": {\n        \"series_one\": \"Σειρές\",\n        \"columns_one\": \"Στήλη\",\n        \"columns_other\": \"Στήλες\",\n        \"fields_one\": \"Πεδίο\",\n        \"fields_other\": \"Πεδία\",\n        \"series_other\": \"Σειρές\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Please review the following before adding a new custom widget.\": \"Παρακαλούμε ελέγξτε τα παρακάτω πριν προσθέσετε ένα νέο προσαρμοσμένο γραφικό στοιχείο (widget).\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Είστε βέβαιοι ότι **εμπιστεύεστε τον πόρο** σε αυτήν τη διεύθυνση URL;\",\n        \"Be careful with unknown custom widgets\": \"Να είστε προσεκτικοί με άγνωστα προσαρμοσμένα γραφικά στοιχεία (widgets)\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Τα προσαρμοσμένα γραφικά στοιχεία είναι **ισχυρά**! Ενδέχεται να είναι σε θέση να διαβάσουν και να γράψουν τα δεδομένα του εγγράφου σας και να τα στείλουν αλλού.\",\n        \"Have you **reviewed the code** at this URL?\": \"Έχετε **ελέγξει τον κώδικα** σε αυτήν τη διεύθυνση URL;\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Επιβεβαιώνω ότι κατανοώ αυτές τις προειδοποιήσεις και αποδέχομαι τους κινδύνους\",\n        \"Do you **trust the person** who shared this link?\": \"**Εμπιστεύεστε το άτομο** που κοινοποίησε αυτόν τον σύνδεσμο;\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Σε περίπτωση αμφιβολίας, μην εγκαταστήσετε αυτό το γραφικό στοιχείο ή ζητήστε από έναν διαχειριστή του οργανισμού σας να το ελέγξει για λόγους ασφαλείας.\"\n    },\n    \"Assistant\": {\n        \"Apply\": \"Εφαρμογή\",\n        \"For higher limits, contact the site owner.\": \"Για υψηλότερα όρια, επικοινωνήστε με τον κάτοχο του ιστότοπου.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Για υψηλότερα όρια, {{upgradeNudge}}.\",\n        \"What do you need help with?\": \"Με τι χρειάζεσαι βοήθεια;\",\n        \"You have used all available credits.\": \"Έχετε χρησιμοποιήσει όλες τις διαθέσιμες μονάδες.\",\n        \"You have {{numCredits}} remaining credits.\": \"Σας απομένουν {{numCredits}} μονάδες.\",\n        \"start a new chat\": \"ξεκινήστε μια νέα συνομιλία\",\n        \"AI Assistant is only available for logged in users.\": \"Ο Βοηθός AI είναι διαθέσιμος μόνο για συνδεδεμένους χρήστες.\",\n        \"Learn more.\": \"Μάθετε περισσότερα.\",\n        \"Press Enter to apply suggested formula.\": \"Πατήστε Enter για να εφαρμόσετε την προτεινόμενη φόρμουλα.\",\n        \"Sign Up for Free\": \"Εγγραφείτε Δωρεάν\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Εγγραφείτε για έναν δωρεάν λογαριασμό Grist για να ξεκινήσετε να χρησιμοποιείτε τον Βοηθό Τεχνητής Νοημοσύνης.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Αναβαθμίστε σε Grist Enterprise για να δοκιμάσετε τον νέο Βοηθό Grist. {{learnMoreLink}}\",\n        \"upgrade to the Pro Team plan\": \"αναβάθμιση στο πρόγραμμα Pro Team\",\n        \"upgrade your plan\": \"αναβάθμιση του προγράμματός σας\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"Η συζήτηση έχει γίνει πολύ μεγάλη και δεν μπορώ πλέον να απαντήσω αποτελεσματικά. Παρακαλώ {{startANewChatButton}} για να συνεχίσετε να λαμβάνετε βοήθεια.\"\n    },\n    \"apiconsole\": {\n        \"Deletion was not confirmed, skipping.\": \"Η διαγραφή δεν επιβεβαιώθηκε, παράβλεψη.\",\n        \"Are you sure you want to delete the following?\": \"Είστε σίγουροι ότι θέλετε να διαγράψετε τα παρακάτω;\",\n        \"Confirm Deletion\": \"Επιβεβαίωση Διαγραφής\",\n        \"Delete\": \"Διαγραφή\",\n        \"Type DELETE here if you wish to proceed.\": \"Πληκτρολογήστε DELETE εδώ αν θέλετε να συνεχίσετε.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Πληκτρολογήστε DELETE αν είστε βέβαιοι ότι πράγματι επιθυμείτε να κάνετε αυτήν τη διαγραφή.\\nΕάν δεν είστε σίγουροι ή δεν καταλαβαίνετε τι θα κάνει αυτή η λειτουργία,\\nθα ήταν συνετό να την ακυρώσετε.\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Κάντε κλικ για εισαγωγή\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"Καλώς ήρθες πίσω\",\n        \"You can always switch sites using the account menu.\": \"Μπορείτε πάντα να αλλάζετε ιστότοπους χρησιμοποιώντας το μενού λογαριασμού.\",\n        \"You have access to the following Grist sites.\": \"Έχετε πρόσβαση στις ακόλουθες τοποθεσίες Grist.\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"ΠΕΡΙΓΡΑΦΗ\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Αναζήτηση σε όλες τις σελίδες\",\n        \"Search all tables\": \"Αναζήτηση σε όλoυς τους πίνακες\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Αναζήτηση\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"Εμφάνιση {{displayedCount}} από {{totalCount}} στοιχεία. Αναζήτηση για περισσότερα.\"\n    },\n    \"SupportGristNudge\": {\n        \"Close\": \"Κλείσιμο\",\n        \"Contribute\": \"Συνεισφέρετε\",\n        \"Help Center\": \"Κέντρο Βοήθειας\",\n        \"Opt in to Telemetry\": \"Εγγραφή στην Τηλεμετρία\",\n        \"Opted In\": \"Συμμετοχή\",\n        \"Support Grist page\": \"Σελίδα Υποστήριξης Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Σας ευχαριστούμε! Η εμπιστοσύνη και η υποστήριξή σας εκτιμώνται ιδιαίτερα. Μπορείτε να εξαιρεθείτε οποιαδήποτε στιγμή από το {{link}} στο μενού χρήστη.\",\n        \"Admin Panel\": \"Πίνακας Διαχείρισης\",\n        \"Support Grist\": \"Υποστήριξε το Grist\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Δεν υπάρχουν δεδομένα\",\n        \"No row selected in {{title}}\": \"Δεν έχει επιλεγεί γραμμή στο {{title}}\",\n        \"Not all data is shown\": \"Δεν εμφανίζονται όλα τα δεδομένα\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Σύμπτυξη Επεξεργαστή\"\n    },\n    \"Menu\": {\n        \"Separator\": \"Διαχωριστικό\",\n        \"Unmapped fields\": \"Μη αντιστοιχισμένα πεδία\",\n        \"Header\": \"Κεφαλίδα\",\n        \"New question\": \"Νέα ερώτηση\",\n        \"More\": \"Περισσότερα\",\n        \"Building blocks\": \"Δομικά στοιχεία\",\n        \"Insert question below\": \"Εισαγωγή ερώτησης από κάτω\",\n        \"Columns\": \"Στήλες\",\n        \"Paste\": \"Επικόλληση\",\n        \"Paragraph\": \"Παράγραφος\",\n        \"Copy\": \"Αντιγραφή\",\n        \"Cut\": \"Αποκοπή\",\n        \"Insert question above\": \"Εισαγωγή ερώτησης από πάνω\"\n    },\n    \"CustomView\": {\n        \"Some required columns are hidden by access rules\": \"Ορισμένες απαιτούμενες στήλες είναι κρυφές από τους κανόνες πρόσβασης\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Για να χρησιμοποιήσετε αυτό το γραφικό στοιχείο, αντιστοιχίστε όλες τις μη προαιρετικές στήλες από τον πίνακα δημιουργού στα δεξιά.\",\n        \"Some required columns aren't mapped\": \"Ορισμένες απαιτούμενες στήλες δεν έχουν αντιστοιχιστεί\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Για να χρησιμοποιήσετε αυτό το γραφικό στοιχείο, όλες οι αντιστοιχισμένες στήλες πρέπει να είναι ορατές. Επικοινωνήστε με τον κάτοχο του εγγράφου ή τροποποιήστε τους κανόνες πρόσβασης.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Δημιουργήστε τη δική σας φόρμα\",\n        \"Powered by\": \"Με την υποστήριξη\",\n        \"Powered by Grist\": \"Με την υποστήριξη του Grist\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Σφάλμα\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"Ωχ! Η φόρμα που αναζητάτε δεν υπάρχει.\",\n        \"Oops! This form is no longer published.\": \"Ωχ! Αυτή η φόρμα δεν δημοσιεύεται πλέον.\",\n        \"There was a problem loading the form.\": \"Παρουσιάστηκε πρόβλημα κατά τη φόρτωση της φόρμας.\",\n        \"You don't have access to this form.\": \"Δεν έχετε πρόσβαση σε αυτήν τη φόρμα.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Παρουσιάστηκε σφάλμα κατά την υποβολή της φόρμας σας. Δοκιμάστε ξανά.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Η φόρμα υποβλήθηκε\",\n        \"Submit new response\": \"Υποβολή νέας απάντησης\",\n        \"Thank you! Your response has been recorded.\": \"Ευχαριστούμε! Η απάντησή σας έχει καταγραφεί.\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Εισαγωγή ενότητας από πάνω\",\n        \"Insert section below\": \"Εισαγωγή ενότητας από κάτω\",\n        \"## **Header**\": \"## **Header**\",\n        \"Description\": \"Περιγραφή\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Ακύρωση\",\n        \"Create site\": \"Δημιουργία ιστότοπου\",\n        \"Domain name is invalid\": \"Το όνομα τομέα δεν είναι έγκυρο\",\n        \"Go to your site\": \"Μεταβείτε στον ιστότοπό σας\",\n        \"Team name\": \"Όνομα ομάδας\",\n        \"Team name is required\": \"Το όνομα ομάδας απαιτείται\",\n        \"Team url\": \"URL ομάδας\",\n        \"Work as a Team\": \"Εργαστείτε ως Ομάδα\",\n        \"Billing is not supported in grist-core\": \"Η χρέωση δεν υποστηρίζεται στο grist-core\",\n        \"Choose a name and url for your team site\": \"Επιλέξτε ένα όνομα και μια διεύθυνση URL για τον ιστότοπο της ομάδας σας\",\n        \"Domain name is required\": \"Απαιτείται όνομα τομέα\",\n        \"Team site created\": \"Δημιουργήθηκε ιστότοπος ομάδας\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Αφαίρεση Στήλης\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Δεν έχουν ρυθμιστεί επιλογές\",\n        \"No values in show column of referenced table\": \"Δεν υπάρχουν τιμές στη στήλη εμφάνισης του πίνακα αναφοράς\",\n        \"Hide\": \"Απόκρυψη\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"Πλαίσιο επιλογής (Checkbox)\",\n        \"Field Format\": \"Μορφή Πεδίου\",\n        \"Switch\": \"Διακόπτης\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"Επαναφορά\",\n        \"Select...\": \"Επιλέξτε...\",\n        \"Search\": \"Αναζήτηση\",\n        \"Submit\": \"Υποβολή\",\n        \"Submitting…\": \"Υποβολή…\",\n        \"Clear selection for: {{-inputLabel}}\": \"Εκκαθάριση επιλογής για: {{-inputLabel}}\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Πίνακας Διαχείρισης\"\n    },\n    \"markdown.d\": {\n        \"The toggle is **off**\": \"Η εναλλαγή είναι **απενεργοποιημένη**\",\n        \"The toggle is **on**\": \"Η εναλλαγή είναι **ενεργοποιημένη**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Νέα Συνάρτηση Markdown\\n*\\n* Μπορούμε να _γράψουμε_ [το συνηθισμένο Markdown](https://markdownguide.org) *μέσα*\\n* σε ένα στοιχείο Grainjs.\"\n            }\n        }\n    },\n    \"HomeIntroCards\": {\n        \"3 minute video tour\": \"Βίντεο περιήγησης 3 λεπτών\",\n        \"Blank document\": \"Κενό έγγραφο\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"Βρείτε λύσεις και εξερευνήστε περισσότερους πόρους {{helpCenterLink}}\",\n        \"Finish our basics tutorial\": \"Ολοκληρώστε το βασικό μας σεμινάριο\",\n        \"Help center\": \"Κέντρο βοήθειας\",\n        \"Import file\": \"Εισαγωγή αρχείου\",\n        \"Start a new document\": \"Έναρξη νέου εγγράφου\",\n        \"Webinars\": \"Διαδικτυακά σεμινάρια\",\n        \"Templates\": \"Πρότυπα\",\n        \"Tutorial\": \"Οδηγός Εκμάθησης\",\n        \"Learn more {{webinarsLinks}}\": \"Μάθετε περισσότερα {{webinarsLinks}}\",\n        \"Find solutions and explore more resources\": \"Βρείτε λύσεις και εξερευνήστε περισσότερους πόρους\",\n        \"Learn more\": \"Μάθετε περισσότερα\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Add two-way reference\": \"Προσθήκη αμφίδρομης αναφοράς\",\n        \"Column\": \"Στήλη\",\n        \"Delete\": \"Διαγραφή\",\n        \"Delete column {{column}} in table {{table}}?\": \"Διαγραφή στήλης {{column}} στον πίνακα {{table}};\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Είναι το αντίστροφο της στήλης αναφοράς {{column}} στον πίνακα {{table}}.\",\n        \"Two-way Reference\": \"Αμφίδρομη Αναφορά\",\n        \"Delete two-way reference?\": \"Διαγραφή αμφίδρομης αναφοράς;\",\n        \"Table\": \"Πίνακας\",\n        \"Target table\": \"Πίνακας προορισμού\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Αυτό θα διαγράψει τη στήλη αναφοράς {{refCol}} στον πίνακα {{refTable}}. Η στήλη αναφοράς {{myName}} θα παραμείνει στον τρέχοντα πίνακα {{myTable}}.\"\n    },\n    \"buildReassignModal\": {\n        \"Reassign\": \"Επαναανάθεση\",\n        \"Record already assigned_one\": \"Η εγγραφή έχει ήδη ανατεθεί\",\n        \"Record already assigned_other\": \"Οι εγγραφές έχουν ήδη ανατεθεί\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Επαναανάθεση στην εγγραφή {{sourceTable}} {{sourceName}}.\",\n        \"Reassign to new {{sourceTable}} records.\": \"Επαναανάθεση σε νέες εγγραφές {{sourceTable}}.\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Κάθε εγγραφή {{targetTable}} μπορεί να αντιστοιχιστεί μόνο σε μία εγγραφή {{sourceTable}}.\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"Η εγγραφή {{targetTable}} {{targetName}} έχει ήδη αντιστοιχιστεί στην εγγραφή {{sourceTable}} {{oldSourceName}}.\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Enter URL\": \"Εισαγάγετε διεύθυνση URL\",\n        \"Enter token\": \"Εισαγωγή διακριτικού (token)\",\n        \"Learn more\": \"Μάθετε περισσότερα\",\n        \"Other\": \"Άλλο\",\n        \"Save\": \"Αποθήκευση\",\n        \"Splunk\": \"Σπλανκ\",\n        \"Start streaming\": \"Έναρξη ροής\",\n        \"Token\": \"Token\",\n        \"URL\": \"URL\",\n        \"Edit streaming destination\": \"Επεξεργασία προορισμού ροής\",\n        \"Add streaming destination\": \"Προσθήκη προορισμού ροής\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτόν τον προορισμό ροής; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.\",\n        \"Destinations\": \"Προορισμοί\",\n        \"Delete streaming destination?\": \"Διαγραφή προορισμού ροής;\",\n        \"Delete\": \"Διαγραφή\",\n        \"Add destination\": \"Προσθήκη προορισμού\",\n        \"Destination\": \"Προορισμός\",\n        \"Edit\": \"Επεξεργασία\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Ρυθμίστε τη ροή συμβάντων ελέγχου από το Grist σε ένα εξωτερικό σύστημα πληροφοριών ασφαλείας και διαχείρισης συμβάντων (SIEM) όπως το Splunk. {{learnMoreLink}}.\"\n    },\n    \"RenameDocModal\": {\n        \"Choose color\": \"Επιλέξτε χρώμα\",\n        \"Choose icon\": \"Επιλογή εικονιδίου\",\n        \"Icon\": \"Εικονίδιο\",\n        \"Name\": \"Όνομα\",\n        \"Rename and set icon\": \"Μετονομασία και ορισμός εικονιδίου\",\n        \"Reset icon\": \"Επαναφορά εικονιδίου\",\n        \"Enter document name\": \"Εισαγάγετε το όνομα του εγγράφου\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin area\": \"Περιοχή διαχείρισης\",\n        \"Docs\": \"Έγγραφα\",\n        \"Admin Controls\": \"Χειριστήρια Διαχειριστή\",\n        \"Settings\": \"Ρυθμίσεις\",\n        \"Workspaces\": \"Χώροι εργασίας\",\n        \"Admin controls\": \"Χειριστήρια διαχειριστή\",\n        \"Orgs\": \"Οργανισμοί\",\n        \"Learn more\": \"Μάθετε περισσότερα\",\n        \"Installation\": \"Εγκατάσταση\",\n        \"Users\": \"Χρήστες\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Αυτή η τοποθεσία ομάδας έχει ανασταλεί. Τα έγγραφα μπορούν να διαβαστούν, αλλά όχι να τροποποιηθούν.\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Δεν επιτρέπεται η πρόσβαση\",\n        \"Code View is available only when you have full document access.\": \"Η προβολή κώδικα είναι διαθέσιμη μόνο όταν έχετε πλήρη πρόσβαση στο έγγραφο.\"\n    },\n    \"ColorSelect\": {\n        \"Cancel\": \"Ακύρωση\",\n        \"Apply\": \"Εφαρμογή\",\n        \"Default cell style\": \"Προεπιλεγμένο στυλ κελιού\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Προσθήκη Στήλης\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Φίλτρο καρφιτσώματος - στήλη {{- columnName}} (τρέχουσα: ξεκαρφιτσωμένη)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Ξεκαρφίτσωμα φίλτρου - στήλη {{- columnName}} (τρέχουσα: καρφιτσωμένη)\",\n        \"remove filter - {{- columnName}} column\": \"αφαίρεση φίλτρου - στήλη {{- columnName}}\",\n        \"{{- columnName }} column filters\": \"Φίλτρα στηλών {{- columnName }}\"\n    },\n    \"SiteSwitcher\": {\n        \"Switch Sites\": \"Αλλαγή ιστότοπων\",\n        \"Create new team site\": \"Δημιουργία νέου ιστότοπου ομάδας\"\n    },\n    \"ViewSectionMenu\": {\n        \"Revert\": \"Αναίρεση\",\n        \"Custom options\": \"Προσαρμοσμένες επιλογές\",\n        \"Save\": \"Αποθήκευση\",\n        \"(modified)\": \"(τροποποιημένο)\",\n        \"FILTER\": \"ΦΙΛΤΡΟ\",\n        \"(customized)\": \"(προσαρμοσμένο)\",\n        \"(empty)\": \"(άδειο)\",\n        \"SORT\": \"ΤΑΞΙΝΟΜΗΣΗ\",\n        \"Update Sort&Filter settings\": \"Ενημέρωση ρυθμίσεων Ταξινόμησης&Φιλτραρίσματος\",\n        \"Sort and filter\": \"Ταξινόμηση και φιλτράρισμα\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"ΜΟΡΦΗ ΚΕΛΙΟΥ\",\n        \"Row ID\": \"Αναγνωριστικό Γραμμής\",\n        \"SHOW COLUMN\": \"ΕΜΦΑΝΙΣΗ ΣΤΗΛΗΣ\"\n    },\n    \"NumericTextBox\": {\n        \"Number Format\": \"Μορφή Αριθμών\",\n        \"Decimals\": \"Δεκαδικά\",\n        \"max\": \"μεγ\",\n        \"Field Format\": \"Μορφή Πεδίου\",\n        \"Spinner\": \"Spinner\",\n        \"Default currency ({{defaultCurrency}})\": \"Προεπιλεγμένο νόμισμα ({{defaultCurrency}})\",\n        \"Currency\": \"Νόμισμα\",\n        \"min\": \"ελάχ\",\n        \"Text\": \"Κείμενο\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Διπλότυπη σελίδα {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Σημειώστε ότι αυτό δεν αντιγράφει δεδομένα, αλλά δημιουργεί μια άλλη προβολή των ίδιων δεδομένων.\"\n    },\n    \"search\": {\n        \"Find Previous \": \"Εύρεση Προηγούμενου \",\n        \"No results\": \"Δεν υπάρχουν αποτελέσματα\",\n        \"Search in document\": \"Αναζήτηση στο έγγραφο\",\n        \"Search\": \"Αναζήτηση\",\n        \"Find Next \": \"Εύρεση Επόμενου \",\n        \"Close search bar\": \"Κλείσιμο γραμμής αναζήτησης\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Αποστολή αρχείου στο Google Drive\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"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.\": \"Κατά τη διάρκεια της κλήσης, θα αφιερώσουμε χρόνο για να κατανοήσουμε τις ανάγκες σας και να την προσαρμόσουμε στις ανάγκες σας. Μπορούμε να σας δείξουμε τα βασικά του Grist ή να ξεκινήσουμε αμέσως να εργαζόμαστε με τα δεδομένα σας για να δημιουργήσουμε τους πίνακες ελέγχου που χρειάζεστε.\",\n        \"free coaching call\": \"δωρεάν κλήση καθοδήγησης\",\n        \"Maybe later\": \"Ίσως Αργότερα\",\n        \"Schedule call\": \"Προγραμματισμός Κλήσης\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Κλείστε ραντεβού για το {{freeCoachingCall}} με ένα μέλος της ομάδας μας.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Μπορείτε επίσης να δείτε τα {{ourWeeklyWebinars}} για να μάθετε περισσότερα για τον Grist.\",\n        \"our weekly webinars\": \"τα εβδομαδιαία διαδικτυακά μας σεμινάρια\",\n        \"Free coaching call\": \"Δωρεάν κλήση καθοδήγησης\",\n        \"Grist 101\": \"Grist 101\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"Μπορείτε επίσης να δείτε το εισαγωγικό μας διαδικτυακό σεμινάριο, {{ourWeeklyWebinars}}, το οποίο έχει σχεδιαστεί για να βοηθήσει τους νέους χρήστες να κατανοήσουν τα βασικά του Grist.\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"Μπορείτε επίσης να δείτε το εισαγωγικό μας διαδικτυακό σεμινάριο, {{ourWeeklyWebinars}}, το οποίο έχει σχεδιαστεί για να βοηθήσει τους νέους χρήστες να κατανοήσουν τα βασικά του Grist.\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"ΕΠΙΛΟΓΕΣ\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[link label] URL\"\n    },\n    \"PagePanels\": {\n        \"Open creator panel\": \"Άνοιγμα Πίνακα Δημιουργού\",\n        \"Close Creator Panel\": \"Κλείσιμο Πίνακα Δημιουργού\",\n        \"Creator panel (right panel)\": \"Πίνακας δημιουργού (δεξιό πλαίσιο)\",\n        \"Document header\": \"Κεφαλίδα εγγράφου\",\n        \"Main content\": \"Κύριο περιεχόμενο\",\n        \"Main navigation and document settings (left panel)\": \"Κύριες ρυθμίσεις πλοήγησης και εγγράφου (αριστερό πλαίσιο)\",\n        \"Close navigation panel (left panel)\": \"Κλείσιμο του πίνακα πλοήγησης (αριστερό πλαίσιο)\",\n        \"Open navigation panel (left panel)\": \"Άνοιγμα του πίνακα πλοήγησης (αριστερό πλαίσιο)\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"ΠΕΡΙΓΡΑΦΗ\",\n        \"Set description\": \"Ορισμός περιγραφής\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Κρυφά πεδία\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Διαγραφή\"\n    },\n    \"markdown\": {\n        \"The toggle is **off**\": \"Η εναλλαγή είναι **απενεργοποιημένη**\",\n        \"The toggle is **on**\": \"Η εναλλαγή είναι **ενεργοποιημένη**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Νέα Συνάρτηση Markdown\\n*\\n* Μπορούμε να _γράψουμε_ [το συνηθισμένο Markdown](https://markdownguide.org) *μέσα*\\n* σε ένα στοιχείο Grainjs.\"\n            }\n        }\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"χωρίς πρόσβαση\",\n        \"...loading\": \"...φόρτωση\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Υπάρχει διαθέσιμη μια κρίσιμη ενημέρωση του Grist.\\nΕξετάστε το ενδεχόμενο αναβάθμισης στην έκδοση {{version}} το συντομότερο δυνατό.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Η έκδοση Grist που χρησιμοποιείτε είναι παλιά.\\nΕξετάστε το ενδεχόμενο αναβάθμισης στην έκδοση {{version}} το συντομότερο δυνατό.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Σύσταση: {{storageRecommendation}}\\nΌταν αποθηκεύετε μεγάλα ή πολλά από αυτά συνημμένα, συνιστούμε\\nνα τα διατηρείτε σε εξωτερικό χώρο αποθήκευσης. Αυτό το έγγραφο \\nχρησιμοποιεί αυτήν τη στιγμή εσωτερικό χώρο αποθήκευσης για συνημμένα, γεγονός που το διατηρεί\\nαυτόνομο αλλά ενδέχεται να περιορίσει την απόδοση.\",\n        \"Set the document to use external storage.\": \"Ρυθμίστε το έγγραφο ώστε να χρησιμοποιεί εξωτερικό χώρο αποθήκευσης.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Περιμένετε να ολοκληρωθεί η προηγούμενη λειτουργία.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Λήξη χρονικού ορίου αναμονής για επανεκκίνηση του backend του Grist\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Απενεργοποίηση λειτουργίας\",\n        \"Don't worry, you can disable it later if needed.\": \"Μην ανησυχείτε, μπορείτε να το απενεργοποιήσετε αργότερα, αν χρειαστεί.\",\n        \"Enable feature\": \"Ενεργοποίηση λειτουργίας\",\n        \"Experimental feature\": \"Πειραματική λειτουργία\",\n        \"New record button\": \"Κουμπί νέας εγγραφής\",\n        \"Reload the page\": \"Επαναφόρτωση της σελίδας\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Επισκεφθείτε αυτήν τη διεύθυνση URL ανά πάσα στιγμή για να διακόψετε τη χρήση αυτής της λειτουργίας: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Πρόκειται να απενεργοποιήσετε αυτήν την πειραματική λειτουργία: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Πρόκειται να ενεργοποιήσετε αυτήν την πειραματική λειτουργία: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"Το {{experiment}} απενεργοποιήθηκε.\",\n        \"{{experiment}} enabled.\": \"Το {{experiment}} ενεργοποιήθηκε.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Νέα κάρτα\",\n        \"New record\": \"Νέα εγγραφή\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Προσπαθείτε να αποκτήσετε πρόσβαση στο πλαίσιο δημιουργών; Χρησιμοποιήστε το {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Διπλότυπο γραφικό στοιχείο\",\n        \"Duplicate widgets\": \"Διπλότυπα γραφικά στοιχεία\",\n        \"Active\": \"Ενεργό\",\n        \"Create new page\": \"Δημιουργία νέας σελίδας\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Μεταφόρτωση, παρακαλώ περιμένετε…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Προσθήκη\",\n        \"Delete\": \"Διαγραφή\",\n        \"Download\": \"Λήψη\",\n        \"Drop files here to attach\": \"Αποθέστε αρχεία εδώ για επισύναψη\",\n        \"Drop files here to attach.\": \"Αποθέστε αρχεία εδώ για επισύναψη.\",\n        \"No attachments\": \"Δεν υπάρχουν συνημμένα\",\n        \"Preview not available.\": \"Η προεπισκόπηση δεν είναι διαθέσιμη.\",\n        \"{{index}} of {{total}}\": \"{{index}} από {{total}}\",\n        \"Uploading…\": \"Μεταφόρτωση…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Ανάπτυξη όλων των γραμμών σε αυτό το ύψος\",\n        \"Max height\": \"Μέγιστο ύψος\",\n        \"Max row height\": \"Μέγιστο ύψος σειράς\",\n        \"Change\": \"Αλλαγή\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Σύμπτυξη\",\n        \"Expand\": \"Ανάπτυξη\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"ενεργός χρήστης\",\n        \"active user list\": \"λίστα ενεργών χρηστών\",\n        \"open full active user list\": \"άνοιγμα πλήρους λίστας ενεργών χρηστών\"\n    },\n    \"AdminChecks\": {\n        \"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 επιτρέπει πολύ ισχυρούς τύπους, χρησιμοποιώντας Python. Συνιστούμε να ορίσετε τη μεταβλητή περιβάλλοντος GRIST_SANDBOX_FLAVOR σε gvisor, εάν το υλικό σας το υποστηρίζει (τα περισσότερα θα το κάνουν), για να εκτελείτε τύπους σε κάθε έγγραφο μέσα σε ένα sandbox απομονωμένο από άλλα έγγραφα και απομονωμένο από το δίκτυο.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Το Grist έχει έναν μικρό ενσωματωμένο έλεγχο εύρυθμης λειτουργίας που χρησιμοποιείται συχνά όταν εκτελείται ως κοντέινερ.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"Τα αιτήματα που φτάνουν στο Grist θα πρέπει να έχουν μια ακριβή κεφαλίδα κεντρικού υπολογιστή. Αυτό είναι απαραίτητο όταν έχει οριστεί το GRIST_SERVE_SAME_ORIGIN.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"Αυτή η σελίδα εκκίνησης δεν θα πρέπει να είναι πολύ εύκολη στην πρόσβαση. Είτε απενεργοποιήστε την όταν η διαμόρφωση είναι εντάξει (καταργώντας τη ρύθμιση του GRIST_BOOT_KEY) είτε κάντε το GRIST_BOOT_KEY μακρύ και κρυπτογραφικά ασφαλές.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"Οι συνδέσεις Websocket χρειάζονται HTTP 1.1 και τη δυνατότητα διαβίβασης μερικών επιπλέον κεφαλίδων για να λειτουργήσουν. Μερικές φορές, ένας αντίστροφος διακομιστής μεσολάβησης μπορεί να επηρεάσει αυτές τις απαιτήσεις.\",\n        \"It is good practice not to run Grist as the root user.\": \"Είναι καλή πρακτική να μην εκτελείτε το Grist ως χρήστης root.\",\n        \"The main page of Grist should be available.\": \"Η κύρια σελίδα του Grist θα πρέπει να είναι διαθέσιμη.\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"+{{count}} ακόμη\",\n        \"+{{count}} more_other\": \"+{{count}} ακόμη\",\n        \"Edit\": \"Επεξεργασία\",\n        \"No choices configured\": \"Δεν έχουν ρυθμιστεί επιλογές\",\n        \"Reset\": \"Επαναφορά\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Save\": \"Αποθήκευση\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Εφαρμογή\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Preview\": \"Προεπισκόπηση\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Κλείσιμο\",\n        \"Update preview\": \"Ενημέρωση προεπισκόπησης\",\n        \"Convert quoted fields\": \"Μετατρέψτε τα πεδία που αναφέρονται\",\n        \"Escape character\": \"Χαρακτήρας διαφυγής\",\n        \"Field separator\": \"Διαχωριστής πεδίου\",\n        \"First row contains headers\": \"Η πρώτη σειρά περιέχει κεφαλίδες\",\n        \"Line terminator\": \"Τερματιστής γραμμής\",\n        \"Number of rows\": \"Αριθμός γραμμών\",\n        \"Quote character\": \"Χαρακτήρας παραθέματος (quote)\",\n        \"Quotes in fields are doubled\": \"Τα quotes στα πεδία διπλασιάζονται\",\n        \"Skip leading whitespace\": \"Παράλειψη κενού διαστήματος στην αρχή\",\n        \"Start with row\": \"Ξεκινήστε με τη γραμμή\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Κωδικοποίηση χαρακτήρων. Δείτε [τις υποστηριζόμενες κωδικοποιήσεις]({{link}})\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" ή \",\n        \"\\\"Regions\\\" are what we call the different parts of the user interface:\": \"Οι \\\"περιοχές\\\" είναι αυτά που ονομάζουμε τα διαφορετικά μέρη της διεπαφής χρήστη:\",\n        \"Accessibility\": \"Προσβασιμότητα\",\n        \"Close\": \"Κλείσιμο\",\n        \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\": \"Τέλος, το δεξιό πλαίσιο – ή το πλαίσιο δημιουργού – είναι διαθέσιμο μόνο μέσω της δικής του συντόμευσης και δεν περιλαμβάνεται στον επόμενο και τον προηγούμενο κύκλο περιοχής.\",\n        \"Focus on other parts of the user interface using the following shortcuts:\": \"Εστιάστε σε άλλα μέρη της διεπαφής χρήστη χρησιμοποιώντας τις ακόλουθες συντομεύσεις:\",\n        \"High contrast theme\": \"Θέμα υψηλής αντίθεσης\",\n        \"Keyboard navigation\": \"Πλοήγηση πληκτρολογίου\",\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"Σε μια σελίδα εγγράφου, η πλοήγηση με το πληκτρολόγιο κλειδώνεται πρώτα στο τρέχον γραφικό στοιχείο (widget).\",\n        \"On document pages, each [widget]({{supportPageUrl}}) is a region that can receive focus.\": \"Στις σελίδες εγγράφων, κάθε [widget]({{supportPageUrl}}) είναι μια περιοχή που μπορεί να λάβει εστίαση.\",\n        \"On non-document pages, the main content area is a region.\": \"Σε σελίδες που δεν αποτελούν έγγραφα, η κύρια περιοχή περιεχομένου είναι μια περιοχή.\",\n        \"Other important keyboard shortcuts\": \"Άλλες σημαντικές συντομεύσεις πληκτρολογίου\",\n        \"The left panel, home of the main navigation.\": \"Το αριστερό πλαίσιο, όπου βρίσκεται η κύρια πλοήγηση.\",\n        \"The top panel, or the document header.\": \"Το επάνω πλαίσιο ή η κεφαλίδα του εγγράφου.\",\n        \"To see other available themes, go to your {{profileSettingsLink}}.\": \"Για να δείτε άλλα διαθέσιμα θέματα, μεταβείτε στο {{profileSettingsLink}}.\",\n        \"Use the high contrast theme (light appearance)\": \"Χρησιμοποιήστε το θέμα υψηλής αντίθεσης (ανοιχτόχρωμη εμφάνιση)\",\n        \"You are currently **not using** the high contrast theme.\": \"Αυτήν τη στιγμή **δεν χρησιμοποιείτε** το θέμα υψηλής αντίθεσης.\",\n        \"You are currently using the high contrast theme.\": \"Αυτήν τη στιγμή χρησιμοποιείτε το θέμα υψηλής αντίθεσης.\",\n        \"profile settings\": \"ρυθμίσεις προφίλ\",\n        \"{{accessibilityModal}} Show the accessibility options (this modal)\": \"{{accessibilityModal}} Εμφάνιση των επιλογών προσβασιμότητας (αυτό το modal)\",\n        \"{{creatorPanelShortcut}} Focus to and from the creator panel\": \"{{creatorPanelShortcut}} Εστίαση προς και από το πλαίσιο δημιουργού\",\n        \"{{nextRegionShortcut}} Focus on the next region\": \"{{nextRegionShortcut}} Εστίαση στην επόμενη περιοχή\",\n        \"{{prevRegionShortcut}} Focus on the previous region\": \"{{prevRegionShortcut}} Εστίαση στην προηγούμενη περιοχή\",\n        \"{{shortcutsModal}} Show the complete list of keyboard shortcuts\": \"{{shortcutsModal}} Εμφάνιση της πλήρους λίστας συντομεύσεων πληκτρολογίου\"\n    },\n    \"ProposedChangesPage\": {\n        \"Proposed changes\": \"Προτεινόμενες αλλαγές\",\n        \"Replace original\": \"Αντικατάσταση πρωτοτύπου\",\n        \"This is a list of changes relative to the original document.\": \"Αυτή είναι μια λίστα αλλαγών σε σχέση με το αρχικό έγγραφο.\",\n        \"Accept\": \"Αποδοχή\",\n        \"Accepted {{at}}.\": \"Αποδεκτό {{at}}.\",\n        \"Dismiss\": \"Παράβλεψη\",\n        \"Dismissed {{at}}.\": \"Απορρίφθηκε {{at}}.\",\n        \"Learn more\": \"Μάθετε περισσότερα\",\n        \"No changes found to suggest. Please make some edits.\": \"Δεν βρέθηκαν αλλαγές που να προτείνονται. Παρακαλώ κάντε μερικές αλλαγές.\",\n        \"Retract suggestion\": \"Απόσυρση πρότασης\",\n        \"Retracted {{at}}.\": \"Αποσύρθηκε {{at}}.\",\n        \"Suggest change\": \"Πρόταση αλλαγής\",\n        \"Suggest changes\": \"Προτείνετε αλλαγές\",\n        \"Suggestion made {{at}}.\": \"Πρόταση που έγινε {{at}}.\",\n        \"Suggestions\": \"Προτάσεις\",\n        \"The original document isn't asking for proposed changes.\": \"Το αρχικό έγγραφο δεν ζητά προτεινόμενες αλλαγές.\",\n        \"There are fresh changes that haven't been added to the suggestion yet.\": \"Υπάρχουν νέες αλλαγές που δεν έχουν προστεθεί ακόμη στην πρόταση.\",\n        \"This is a list of changes relative to the {{originalDocument}}.\": \"Αυτή είναι μια λίστα αλλαγών σε σχέση με το {{originalDocument}}.\",\n        \"Update suggestion\": \"Ενημέρωση πρότασης\",\n        \"Work on a copy\": \"Εργασία σε ένα αντίγραφο\",\n        \"Your suggestions\": \"Οι προτάσεις σας\",\n        \"experiment\": \"πείραμα\",\n        \"original document\": \"πρωτότυπο έγγραφο\",\n        \"Undo dismissal\": \"Αναίρεση απόρριψης\"\n    },\n    \"commandList\": {\n        \"showing a behavioral popup\": \"εμφάνιση ενός αναδυόμενου παραθύρου συμπεριφοράς\",\n        \"Show accessibility options\": \"Εμφάνιση επιλογών προσβασιμότητας\",\n        \"Activate assistant\": \"Ενεργοποίηση βοηθού\",\n        \"Add a new viewsection to the currently active view\": \"Προσθήκη νέας ενότητας προβολής στην τρέχουσα ενεργή προβολή\",\n        \"Adds all elements above the cursor to the selected range\": \"Προσθέτει όλα τα στοιχεία πάνω από τον κέρσορα στο επιλεγμένο εύρος\",\n        \"Adds all elements below the cursor to the selected range\": \"Προσθέτει όλα τα στοιχεία κάτω από τον κέρσορα στο επιλεγμένο εύρος\",\n        \"Adds all elements to the left of the cursor to the selected range\": \"Προσθέτει όλα τα στοιχεία αριστερά από τον κέρσορα στην επιλεγμένη περιοχή\",\n        \"Adds all elements to the right of the cursor to the selected range\": \"Προσθέτει όλα τα στοιχεία στα δεξιά του δρομέα στην επιλεγμένη περιοχή\",\n        \"Adds the currently selected column(ascending) to the current view's sort spec\": \"Προσθέτει την τρέχουσα επιλεγμένη στήλη (σε αύξουσα σειρά) στην προδιαγραφή ταξινόμησης της τρέχουσας προβολής\",\n        \"Adds the currently selected column(descending) to the current view's sort spec\": \"Προσθέτει την τρέχουσα επιλεγμένη στήλη (φθίνουσα) στην προδιαγραφή ταξινόμησης της τρέχουσας προβολής\",\n        \"Adds the element above the cursor to the selected range\": \"Προσθέτει το στοιχείο πάνω από τον κέρσορα στο επιλεγμένο εύρος\",\n        \"Adds the element below the cursor to the selected range\": \"Προσθέτει το στοιχείο κάτω από τον κέρσορα στο επιλεγμένο εύρος\",\n        \"Adds the element to the left of the cursor to the selected range\": \"Προσθέτει το στοιχείο αριστερά από τον κέρσορα στο επιλεγμένο εύρος\",\n        \"Adds the element to the right of the cursor to the selected range\": \"Προσθέτει το στοιχείο στα δεξιά του δρομέα στο επιλεγμένο εύρος\",\n        \"Clear the selected columns\": \"Διαγραφή των επιλεγμένων στηλών\",\n        \"Clears the current copy selection, if any\": \"Διαγράφει την τρέχουσα επιλογή αντιγραφής, εάν υπάρχει\",\n        \"Clears the currently selected cells\": \"Διαγράφει τα επιλεγμένα κελιά\",\n        \"Clears the section links in the current view\": \"Διαγράφει τους συνδέσμους ενότητας στην τρέχουσα προβολή\",\n        \"Clears the section links in the current viewsection\": \"Καθαρίζει τους συνδέσμους ενότητας στην τρέχουσα ενότητα προβολής\",\n        \"Collapse the currently active viewsection\": \"Σύμπτυξη της τρέχουσας ενεργής ενότητας προβολής\",\n        \"Convert the selected columns from formula to data\": \"Μετατροπή των επιλεγμένων στηλών από τύπο σε δεδομένα\",\n        \"Copy anchor link\": \"Αντιγραφή συνδέσμου αγκύρωσης\",\n        \"Copy current selection to clipboard\": \"Αντιγραφή τρέχουσας επιλογής στο πρόχειρο\",\n        \"Copy current selection to clipboard including headers\": \"Αντιγραφή της τρέχουσας επιλογής στο πρόχειρο, συμπεριλαμβανομένων των κεφαλίδων\",\n        \"Creates form for active table\": \"Δημιουργεί φόρμα για ενεργό πίνακα\",\n        \"Cut current selection to clipboard\": \"Αποκοπή της τρέχουσας επιλογής στο πρόχειρο\",\n        \"Delete collapsed viewsection\": \"Διαγραφή συμπτυγμένης ενότητας προβολής\",\n        \"Delete the currently active viewsection\": \"Διαγραφή της τρέχουσας ενεργής ενότητας προβολής\",\n        \"Delete the currently selected columns\": \"Διαγραφή των επιλεγμένων στηλών\",\n        \"Delete the currently selected record(s)\": \"Διαγραφή της/των τρέχουσας επιλεγμένης/ων εγγραφής/ών\",\n        \"Detach active editor\": \"Αποσύνδεση ενεργού επεξεργαστή\",\n        \"Discard changes to a cell value\": \"Απόρριψη αλλαγών σε μια τιμή κελιού\",\n        \"Display Grist documentation\": \"Εμφάνιση τεκμηρίωσης Grist\",\n        \"Display shortcuts pane\": \"Εμφάνιση παραθύρου συντομεύσεων\",\n        \"Duplicate the currently active viewsection\": \"Διπλασιάστε την τρέχουσα ενεργή ενότητα προβολής\",\n        \"Duplicate the currently selected record(s)\": \"Αντιγραφή των τρεχόντων επιλεγμένων εγγραφών\",\n        \"Edit label of the currently-selected field\": \"Επεξεργασία ετικέτας του τρέχοντος επιλεγμένου πεδίου\",\n        \"Edit record layout\": \"Επεξεργασία διάταξης εγγραφής\",\n        \"Enter text into currently-selected cell and start editing\": \"Εισαγάγετε κείμενο στο τρέχον επιλεγμένο κελί και ξεκινήστε την επεξεργασία\",\n        \"Enters section linking mode in the current view\": \"Εισέρχεται σε λειτουργία σύνδεσης ενοτήτων στην τρέχουσα προβολή\",\n        \"Exits section linking mode in the current view\": \"Έξοδος από τη λειτουργία σύνδεσης ενότητας στην τρέχουσα προβολή\",\n        \"Expand collapsed viewsection\": \"Ανάπτυξη συμπτυγμένης ενότητας προβολής\",\n        \"Fills current selection with the contents of the top row in the selection\": \"Γεμίζει την τρέχουσα επιλογή με τα περιεχόμενα της πρώτης γραμμής στην επιλογή\",\n        \"Find\": \"Εύρεση\",\n        \"Find next occurrence\": \"Εύρεση επόμενης εμφάνισης\",\n        \"Find previous occurrence\": \"Εύρεση προηγούμενης εμφάνισης\",\n        \"Finish editing a cell and save without moving to next record\": \"Ολοκλήρωση επεξεργασίας ενός κελιού και αποθήκευση χωρίς μετακίνηση στην επόμενη εγγραφή\",\n        \"Finish editing a cell, saving the value\": \"Ολοκλήρωση επεξεργασίας ενός κελιού, αποθήκευση της τιμής\",\n        \"Focus next page panel or widget\": \"Εστίαση σε πάνελ ή γραφικό στοιχείο επόμενης σελίδας\",\n        \"Focus previous page panel or widget\": \"Εστίαση σε προηγούμενο πλαίσιο ή γραφικό στοιχείο σελίδας\",\n        \"Freeze or unfreeze selected columns\": \"Πάγωμα ή κατάργηση παγώματος επιλεγμένων στηλών\",\n        \"Hide the currently selected columns\": \"Απόκρυψη των τρεχόντων επιλεγμένων στηλών\",\n        \"Hide the currently selected fields\": \"Απόκρυψη των επιλεγμένων πεδίων\",\n        \"Insert a new column, after the currently selected one\": \"Εισαγωγή νέας στήλης, μετά την τρέχουσα επιλεγμένη\",\n        \"Insert a new column, before the currently selected one\": \"Εισαγωγή νέας στήλης, πριν από την τρέχουσα επιλεγμένη\",\n        \"Insert a new record, after the currently selected one in an unsorted table\": \"Εισαγωγή νέας εγγραφής, μετά την τρέχουσα επιλεγμένη σε έναν μη ταξινομημένο πίνακα\",\n        \"Insert a new record, before the currently selected one in an unsorted table\": \"Εισαγωγή νέας εγγραφής, πριν από την τρέχουσα επιλεγμένη σε έναν μη ταξινομημένο πίνακα\",\n        \"Insert new column in default location\": \"Εισαγωγή νέας στήλης στην προεπιλεγμένη θέση\",\n        \"Insert the current date\": \"Εισαγάγετε την τρέχουσα ημερομηνία\",\n        \"Insert the current date and time\": \"Εισαγάγετε την τρέχουσα ημερομηνία και ώρα\",\n        \"Maximize the active section\": \"Μεγιστοποίηση της ενεργής ενότητας\",\n        \"Move down one page of records, or to next record in a card list\": \"Μετακίνηση προς τα κάτω κατά μία σελίδα εγγραφών ή στην επόμενη εγγραφή σε μια λίστα καρτών\",\n        \"Move down to the last record\": \"Μετακίνηση προς τα κάτω στην τελευταία εγγραφή\",\n        \"Move downward five records\": \"Μετακίνηση προς τα κάτω κατά πέντε εγγραφές\",\n        \"Move downward to next record or field\": \"Μετακίνηση προς τα κάτω στην επόμενη εγγραφή ή πεδίο\",\n        \"Move left to the previous field\": \"Μετακίνηση αριστερά στο προηγούμενο πεδίο\",\n        \"Move right to the next field\": \"Μετακίνηση δεξιά στο επόμενο πεδίο\",\n        \"Move to the first field or the beginning of a row\": \"Μετακίνηση στο πρώτο πεδίο ή στην αρχή μιας γραμμής\",\n        \"Move to the last field or the end of a row\": \"Μετακίνηση στο τελευταίο πεδίο ή στο τέλος μιας γραμμής\",\n        \"Move to the next field, saving changes if editing a value\": \"Μετακίνηση στο επόμενο πεδίο, αποθήκευση αλλαγών εάν επεξεργάζεστε μια τιμή\",\n        \"Move to the previous field, saving changes if editing a value\": \"Μετακίνηση στο προηγούμενο πεδίο, αποθήκευση αλλαγών σε περίπτωση επεξεργασίας μιας τιμής\",\n        \"Move up one page of records, or to previous record in a card list\": \"Μετακίνηση προς τα πάνω κατά μία σελίδα εγγραφών ή στην προηγούμενη εγγραφή σε μια λίστα καρτών\",\n        \"Move up to the first record\": \"Μετακίνηση προς τα πάνω στην πρώτη εγγραφή\",\n        \"Move upward five records\": \"Μετακίνηση προς τα πάνω κατά πέντε εγγραφές\",\n        \"Move upward to previous record or field\": \"Μετακίνηση προς τα πάνω στην προηγούμενη εγγραφή ή πεδίο\",\n        \"Moves the cursor to the correct location\": \"Μετακινεί τον κέρσορα στη σωστή θέση\",\n        \"Open Custom widget configuration screen\": \"Άνοιγμα οθόνης διαμόρφωσης προσαρμοσμένου γραφικού στοιχείου\",\n        \"Open comment thread\": \"Άνοιγμα νήματος σχολίων\",\n        \"Open next page\": \"Άνοιγμα επόμενης σελίδας\",\n        \"Open previous page\": \"Άνοιγμα προηγούμενης σελίδας\",\n        \"Opens document list\": \"Ανοίγει τη λίστα εγγράφων\",\n        \"Paste clipboard contents at cursor\": \"Επικόλληση περιεχομένων προχείρου στον κέρσορα\",\n        \"Print currently selected page widget\": \"Εκτύπωση γραφικού στοιχείου τρέχουσας επιλεγμένης σελίδας\",\n        \"Push an undo action\": \"Πιέστε μια ενέργεια αναίρεσης\",\n        \"Redo last action\": \"Επανάληψη τελευταίας ενέργειας\",\n        \"Rename the currently selected column\": \"Μετονομασία της τρέχουσας επιλεγμένης στήλης\",\n        \"Reverts the sections links to the saved links the current view\": \"Επαναφέρει τους συνδέσμους ενοτήτων στους αποθηκευμένους συνδέσμους της τρέχουσας προβολής\",\n        \"Saves the sections links in the current view\": \"Αποθηκεύει τους συνδέσμους ενοτήτων στην τρέχουσα προβολή\",\n        \"Selects all currently displayed cells\": \"Επιλέγει όλα τα κελιά που εμφανίζονται αυτήν τη στιγμή\",\n        \"Shortcut to data selection tab\": \"Συντόμευση για την καρτέλα επιλογής δεδομένων\",\n        \"Shortcut to focus view tab if creator panel is open\": \"Συντόμευση για την εστίαση στην καρτέλα προβολής εάν ο πίνακας δημιουργού είναι ανοιχτός\",\n        \"Shortcut to open document tab\": \"Συντόμευση για άνοιγμα καρτέλας εγγράφου\",\n        \"Shortcut to open field tab\": \"Συντόμευση για άνοιγμα καρτέλας πεδίου\",\n        \"Shortcut to open sort & filter menu\": \"Συντόμευση για άνοιγμα του μενού ταξινόμησης και φιλτραρίσματος\",\n        \"Shortcut to open the left panel\": \"Συντόμευση για άνοιγμα του αριστερού πίνακα\",\n        \"Shortcut to open the right panel\": \"Συντόμευση για άνοιγμα του δεξιού πίνακα\",\n        \"Shortcut to open view tab\": \"Συντόμευση για άνοιγμα καρτέλας προβολής\",\n        \"Shortcut to sort & filter tab\": \"Συντόμευση για την καρτέλα ταξινόμησης και φιλτραρίσματος\",\n        \"Show hidden columns\": \"Εμφάνιση κρυφών στηλών\",\n        \"Show raw data widget for table of currently selected page widget\": \"Εμφάνιση γραφικού στοιχείου ακατέργαστων δεδομένων για τον πίνακα του γραφικού στοιχείου σελίδας που έχει επιλεγεί αυτήν τη στιγμή\",\n        \"Show the record card widget of the selected record\": \"Εμφάνιση του γραφικού στοιχείου κάρτας εγγραφής της επιλεγμένης εγγραφής\",\n        \"Sort the view data by the currently selected field in ascending order\": \"Ταξινόμηση των δεδομένων προβολής με βάση το τρέχον επιλεγμένο πεδίο σε αύξουσα σειρά\",\n        \"Sort the view data by the currently selected field in descending order\": \"Ταξινόμηση των δεδομένων προβολής με βάση το τρέχον επιλεγμένο πεδίο σε φθίνουσα σειρά\",\n        \"Start editing the currently-selected cell\": \"Έναρξη επεξεργασίας του τρέχοντος επιλεγμένου κελιού\",\n        \"Toggle creator panel keyboard focus\": \"Εναλλαγή εστίασης πληκτρολογίου πίνακα δημιουργού\",\n        \"Toggle the currently selected checkbox or switch cell\": \"Εναλλαγή του τρέχοντος επιλεγμένου πλαισίου ελέγχου ή εναλλαγή κελιού\",\n        \"Undo last action\": \"Αναίρεση τελευταίας ενέργειας\",\n        \"Use the currently selected row as table headers\": \"Χρήση της τρέχουσας επιλεγμένης γραμμής ως κεφαλίδων πίνακα\",\n        \"When in the search bar, close it and focus the current match\": \"Όταν βρίσκεστε στη γραμμή αναζήτησης, κλείστε την και εστιάστε στην τρέχουσα αντιστοίχιση\",\n        \"When typed at the start of a cell, make this a formula column\": \"Όταν πληκτρολογείται στην αρχή ενός κελιού, μετατρέπεται σε στήλη τύπου\",\n        \"Filter this column by just this cell's value\": \"Φιλτράρισμα αυτής της στήλης μόνο με βάση την τιμή αυτού του κελιού\"\n    },\n    \"startHomeAirtableImport\": {\n        \"Import from Airtable\": \"Εισαγωγή από Airtable\",\n        \"The current workspace can't be imported to.\": \"Δεν είναι δυνατή η εισαγωγή στον τρέχοντα χώρο εργασίας.\"\n    },\n    \"GridViewMenusDateHelpers\": {\n        \"12-hour format\": \"Μορφή 12 ωρών\",\n        \"24-hour format\": \"24ωρη μορφή\",\n        \"AM\": {\n            \"PM\": \"πμ/μμ\"\n        },\n        \"Calendar\": \"Ημερολόγιο\",\n        \"Date helpers…\": \"Βοηθοί ημερομηνιών…\",\n        \"Day\": \"Ημέρα\",\n        \"Day of month\": \"Ημέρα του μήνα\",\n        \"Day of week\": \"Ημέρα της εβδομάδας\",\n        \"Day of week (abbrev)\": \"Ημέρα της εβδομάδας (συντομογραφία)\",\n        \"Day of week (full)\": \"Ημέρα της εβδομάδας (πλήρης)\",\n        \"Day of week (numeric)\": \"Ημέρα της εβδομάδας (αριθμητική)\",\n        \"Days since\": \"Ημέρες από τότε\",\n        \"Days until\": \"Ημέρες μέχρι\",\n        \"Default\": \"Προεπιλεγμένη\",\n        \"End of\": \"Τέλος του\",\n        \"Full date\": \"Πλήρης ημερομηνία\",\n        \"Full name with year\": \"Ονοματεπώνυμο με έτος\",\n        \"Hour\": \"Ωρα\",\n        \"Intervals\": \"Διαστήματα\",\n        \"Is weekend?\": \"Είναι Σαββατοκύριακο;\",\n        \"Minute\": \"Λεπτό\",\n        \"Month\": \"Μήνας\",\n        \"Months since\": \"Μήνες από τότε\",\n        \"Months until\": \"Μήνες μέχρι\",\n        \"Name only\": \"Μόνο όνομα\",\n        \"Number only\": \"Μόνο αριθμός\",\n        \"Quarter\": \"Τέταρτο\",\n        \"Quick Picks\": \"Γρήγορες επιλογές\",\n        \"Relative\": \"Σχετικός\",\n        \"Short with year\": \"Σύντομο με το έτος\",\n        \"Sortable\": \"Ταξινομήσιμο\",\n        \"Start of\": \"Έναρξη του\",\n        \"Time\": \"Ώρα\",\n        \"Time bucket\": \"Χρονικό κάδος\",\n        \"Week\": \"Εβδομάδα\",\n        \"Week of year\": \"Εβδομάδα του έτους\",\n        \"Year\": \"Ετος\",\n        \"Years since\": \"Χρόνια από τότε\",\n        \"Years until\": \"Χρόνια μέχρι\"\n    },\n    \"selectBy\": {\n        \"Select widget\": \"Επιλογή Γραφικού Στοιχείου (widget)\"\n    },\n    \"CoreNewDocMethods\": {\n        \"Untitled document\": \"Έγγραφο χωρίς τίτλο\"\n    },\n    \"AuthenticationSection\": {\n        \"Active\": \"Ενεργό\",\n        \"Active method is controlled by an environment variable. Unset variable to change active method.\": \"Η ενεργή μέθοδος ελέγχεται από μια μεταβλητή περιβάλλοντος. Απορυθμίστε τη μεταβλητή για να αλλάξετε την ενεργή μέθοδο.\",\n        \"Active on restart\": \"Ενεργό κατά την επανεκκίνηση\",\n        \"Are you sure you want to set **{{name}}** as the active authentication method?\": \"Είστε βέβαιοι ότι θέλετε να ορίσετε το **{{name}}** ως την ενεργή μέθοδο ελέγχου ταυτότητας;\",\n        \"Close\": \"Κλείσιμο\",\n        \"Configure\": \"Ρύθμιση παραμέτρων\",\n        \"Configured\": \"Διαμορφωμένο\",\n        \"Confirm\": \"Επιβεβαίωση\",\n        \"Disabled on restart\": \"Απενεργοποιήθηκε κατά την επανεκκίνηση\",\n        \"Error\": \"Σφάλμα\",\n        \"Error details\": \"Λεπτομέρειες σφάλματος\",\n        \"Instructions\": \"Οδηγίες\",\n        \"No authentication method is active.\": \"Δεν υπάρχει ενεργή μέθοδος ελέγχου ταυτότητας.\",\n        \"Set as active method\": \"Ορισμός ως ενεργής μεθόδου\",\n        \"Set as active method?\": \"Ορισμός ως ενεργής μεθόδου\",\n        \"The new method will go into effect after you restart Grist.\": \"Η νέα μέθοδος θα τεθεί σε ισχύ μετά την επανεκκίνηση του Grist.\",\n        \"**Forwarded headers** allows your Grist server to trust authentication performed by an external proxy (e.g. Traefik ForwardAuth).\": \"Οι **προωθημένες κεφαλίδες** επιτρέπουν στον διακομιστή Grist σας να εμπιστεύεται τον έλεγχο ταυτότητας που εκτελείται από εξωτερικό διακομιστή μεσολάβησης (π.χ. Traefik ForwardAuth).\",\n        \"**Grist Connect** is a login solution built and maintained by Grist Labs that integrates seamlessly with your Grist server.\": \"Το **Grist Connect** είναι μια λύση σύνδεσης που έχει κατασκευαστεί και συντηρείται από την Grist Labs και ενσωματώνεται άψογα με τον διακομιστή Grist σας.\",\n        \"**OIDC** allows users on your Grist server to sign in using an external identity provider that supports the OpenID Connect standard.\": \"Το **OIDC** επιτρέπει στους χρήστες στον διακομιστή Grist να συνδέονται χρησιμοποιώντας έναν εξωτερικό πάροχο ταυτότητας που υποστηρίζει το πρότυπο OpenID Connect.\",\n        \"**SAML** allows users on your Grist server to sign in using an external identity provider that supports the SAML 2.0 standard.\": \"Το **SAML** επιτρέπει στους χρήστες στον διακομιστή Grist να συνδέονται χρησιμοποιώντας έναν εξωτερικό πάροχο ταυτότητας που υποστηρίζει το πρότυπο SAML 2.0.\",\n        \"Change admin user\": \"Αλλαγή χρήστη διαχειριστή\",\n        \"If Grist is accessible on your network, or is available to multiple people, configure one of the authentication methods below.\": \"Εάν το Grist είναι προσβάσιμο στο δίκτυό σας ή είναι διαθέσιμο σε πολλά άτομα, διαμορφώστε μία από τις παρακάτω μεθόδους ελέγχου ταυτότητας.\",\n        \"No authentication: unrestricted sign-in as demo user\": \"Χωρίς έλεγχο ταυτότητας: απεριόριστη σύνδεση ως χρήστης επίδειξης\",\n        \"Prepare changes\": \"Προετοιμασία αλλαγών\",\n        \"Restart required. Authentication change may affect your access\": \"Απαιτείται επανεκκίνηση. Η αλλαγή στον έλεγχο ταυτότητας ενδέχεται να επηρεάσει την πρόσβασή σας\",\n        \"Revert change of admin user\": \"Επαναφορά αλλαγής χρήστη διαχειριστή\",\n        \"See \\\"Restart Grist\\\" section on top of this page to restart.\": \"Ανατρέξτε στην ενότητα \\\"Επανεκκίνηση του Grist\\\" στο επάνω μέρος αυτής της σελίδας για επανεκκίνηση.\",\n        \"To set up **Grist Connect**, follow the instructions in [the Grist support article for Grist Connect](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"grist-connect\": {\n                            \").\": \"Για να ρυθμίσετε το **Grist Connect**, ακολουθήστε τις οδηγίες στο [άρθρο υποστήριξης του Grist για το Grist Connect](https://support.getgrist.com/install/grist-connect/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **OIDC**, follow the instructions in [the Grist support article for OIDC](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"oidc).\": \"Για να ρυθμίσετε το **OIDC**, ακολουθήστε τις οδηγίες στο [άρθρο υποστήριξης του Grist για το OIDC](https://support.getgrist.com/install/oidc).\"\n                    }\n                }\n            }\n        },\n        \"To set up **SAML**, follow the instructions in [the Grist support article for SAML](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"saml\": {\n                            \").\": \"Για να ρυθμίσετε το **SAML**, ακολουθήστε τις οδηγίες στο [άρθρο υποστήριξης του Grist για το SAML](https://support.getgrist.com/install/saml/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **forwarded headers**, follow the instructions in [the Grist support article for forwarded headers](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"forwarded-headers\": {\n                            \").\": \"Για να ρυθμίσετε **προωθημένες κεφαλίδες**, ακολουθήστε τις οδηγίες στο [άρθρο υποστήριξης του Grist για προωθημένες κεφαλίδες](https://support.getgrist.com/install/forwarded-headers/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"When a user accesses Grist, the proxy handles authentication and forwards verified user information through HTTP headers. Grist uses these headers to identify the user.\": \"Όταν ένας χρήστης αποκτά πρόσβαση στο Grist, ο διακομιστής μεσολάβησης χειρίζεται τον έλεγχο ταυτότητας και προωθεί επαληθευμένες πληροφορίες χρήστη μέσω κεφαλίδων HTTP. Το Grist χρησιμοποιεί αυτές τις κεφαλίδες για την αναγνώριση του χρήστη.\",\n        \"When signing in, users will be redirected to a Grist Connect login page where they can authenticate using various identity providers. After authentication, they'll be redirected back to your Grist server and signed in.\": \"Κατά τη σύνδεση, οι χρήστες θα ανακατευθύνονται σε μια σελίδα σύνδεσης του Grist Connect όπου μπορούν να επαληθεύσουν τον έλεγχο ταυτότητας χρησιμοποιώντας διάφορους παρόχους ταυτότητας. Μετά τον έλεγχο ταυτότητας, θα ανακατευθύνονται πίσω στον διακομιστή Grist και θα συνδέονται.\",\n        \"When signing in, users will be redirected to your chosen identity provider's login page to authenticate. After successful authentication, they'll be redirected back to your Grist server and signed in as the user verified by the provider.\": \"Κατά τη σύνδεση, οι χρήστες θα ανακατευθύνονται στη σελίδα σύνδεσης του παρόχου ταυτότητας που έχετε επιλέξει για έλεγχο ταυτότητας. Μετά την επιτυχή έλεγχο ταυτότητας, θα ανακατευθύνονται πίσω στον διακομιστή Grist και θα συνδέονται ως ο χρήστης που έχει επαληθευτεί από τον πάροχο.\",\n        \"You are signed in as {{email}}. After restart, the new administrative user will be {{newEmail}}.\": \"Έχετε συνδεθεί ως {{email}}. Μετά την επανεκκίνηση, ο νέος χρήστης διαχειριστή θα είναι {{newEmail}}.\",\n        \"You are signed in as {{email}}. You may lose access to this server if you cannot sign in as this user after switching the authentication system.\": \"Έχετε συνδεθεί ως {{email}}. Ενδέχεται να χάσετε την πρόσβαση σε αυτόν τον διακομιστή εάν δεν μπορείτε να συνδεθείτε ως αυτός ο χρήστης μετά την εναλλαγή του συστήματος ελέγχου ταυτότητας.\"\n    },\n    \"DetailView\": {\n        \"This row is unavailable or does not exist\": \"Αυτή η σειρά δεν είναι διαθέσιμη ή δεν υπάρχει\"\n    },\n    \"GetGristComProvider\": {\n        \"**Sign in with getgrist.com** allows users on your Grist server to sign in using their account on getgrist.com, the cloud version of Grist managed by Grist Labs.\": \"Η σύνδεση με το getgrist.com** επιτρέπει στους χρήστες στον διακομιστή Grist σας να συνδέονται χρησιμοποιώντας τον λογαριασμό τους στο getgrist.com, την έκδοση cloud του Grist που διαχειρίζεται η Grist Labs.\",\n        \"Configure Sign in with getgrist.com\": \"Ρύθμιση παραμέτρων σύνδεσης με το getgrist.com\",\n        \"Home URL is not set; cannot configure Sign in with getgrist.com\": \"Η διεύθυνση URL της αρχικής σελίδας δεν έχει οριστεί. Δεν είναι δυνατή η διαμόρφωση. Σύνδεση με getgrist.com\",\n        \"Instructions\": \"Οδηγίες\",\n        \"Learn more about Sign in with getgrist.com\": \"Μάθετε περισσότερα σχετικά με την Σύνδεση με το getgrist.com\",\n        \"Paste configuration key here\": \"Επικολλήστε το κλειδί διαμόρφωσης εδώ\",\n        \"Register your Grist server\": \"Εγγράψτε τον διακομιστή Grist σας\",\n        \"Sign in with getgrist.com\": \"Συνδεθείτε με το getgrist.com\",\n        \"To set up {{provider}}, you need to register your Grist server on getgrist.com and paste the configuration key you receive below.\": \"Για να ρυθμίσετε τον {{provider}}, πρέπει να καταχωρήσετε τον διακομιστή Grist στο getgrist.com και να επικολλήσετε το κλειδί διαμόρφωσης που λαμβάνετε παρακάτω.\",\n        \"When signing in, users will be redirected to the getgrist.com login page to log in or register. After authenticating on getgrist.com, they'll be redirected back to your Grist server and signed in as the user they authenticated as.\": \"Κατά τη σύνδεση, οι χρήστες θα ανακατευθύνονται στη σελίδα σύνδεσης του getgrist.com για να συνδεθούν ή να εγγραφούν. Μετά τον έλεγχο ταυτότητας στο getgrist.com, θα ανακατευθύνονται πίσω στον διακομιστή Grist και θα συνδέονται ως ο χρήστης με τον οποίο έχουν πιστοποιηθεί.\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Configure\": \"Ρύθμιση παραμέτρων\"\n    },\n    \"AirtableImportUI\": {\n        \"Back\": \"Πίσω\",\n        \"Cancel\": \"Ακύρωση\",\n        \"Choose an Airtable base to import from\": \"Επιλέξτε μια βάση Airtable από την οποία θα κάνετε εισαγωγή\",\n        \"Choose destination\": \"Επιλέξτε προορισμό\",\n        \"Connect\": \"Σύνδεση\",\n        \"Connect with Airtable\": \"Συνδεθείτε με το Airtable\",\n        \"Connect your Airtable account to access your bases.\": \"Συνδέστε τον λογαριασμό σας στο Airtable για να αποκτήσετε πρόσβαση στις βάσεις σας.\",\n        \"Connected via {{method}}\": \"Συνδέθηκε μέσω {{method}}\",\n        \"Connecting...\": \"Γίνεται σύνδεση...\",\n        \"Continue\": \"Συνέχεια\",\n        \"Destination\": \"Προορισμός\",\n        \"Disconnect\": \"Αποσύνδεση\",\n        \"Existing tables\": \"Υπάρχοντες πίνακες\",\n        \"Failed to fetch base schema\": \"Αποτυχία ανάκτησης βασικού σχήματος\",\n        \"Failed to fetch bases\": \"Αποτυχία ανάκτησης βάσεων\",\n        \"Grist configuration required\": \"Απαιτείται διαμόρφωση Grist\",\n        \"Import from {{baseName}} in progress. Do not navigate away from this page.\": \"Εισαγωγή από {{baseName}} σε εξέλιξη. Μην απομακρυνθείτε από αυτήν τη σελίδα.\",\n        \"Import tables\": \"Εισαγωγή πινάκων\",\n        \"Import {{count}} tables_one\": \"Εισαγωγή {{count}} πινάκων\",\n        \"Import {{count}} tables_other\": \"Εισαγωγή {{count}} πινάκων\",\n        \"Make sure your token has the correct permissions.\": \"Βεβαιωθείτε ότι το διακριτικό σας έχει τα σωστά δικαιώματα.\",\n        \"New table\": \"Νέος πίνακας\",\n        \"New table: structure only\": \"Νέος πίνακας: μόνο δομή\",\n        \"No bases found\": \"Δεν βρέθηκαν βάσεις\",\n        \"OAuth\": \"OAuth\",\n        \"OAuth credentials not configured. Please set OAUTH2_AIRTABLE_CLIENT_ID and OAUTH2_AIRTABLE_CLIENT_SECRET, or use personal access token.\": \"Δεν έχουν ρυθμιστεί τα διαπιστευτήρια OAuth. Ορίστε τα OAUTH2_AIRTABLE_CLIENT_ID και OAUTH2_AIRTABLE_CLIENT_SECRET ή χρησιμοποιήστε προσωπικό διακριτικό πρόσβασης.\",\n        \"Personal access token\": \"Προσωπικό διακριτικό πρόσβασης\",\n        \"Please enter a personal access token\": \"Παρακαλώ εισάγετε ένα προσωπικό διακριτικό πρόσβασης\",\n        \"Refresh\": \"Ανανέωση\",\n        \"Select tables to import from {{baseName}}\": \"Επιλέξτε πίνακες για εισαγωγή από το {{baseName}}\",\n        \"Skip\": \"Παράλειψη\",\n        \"Source tables\": \"Πίνακες προέλευσης\",\n        \"Structure only\": \"Μόνο δομή\",\n        \"Use personal access token instead\": \"Χρησιμοποιήστε προσωπικό διακριτικό πρόσβασης αντ' αυτού\",\n        \"[Generate a token]({{url}}) in your Airtable account with scopes that include at least **\\\\`schema.bases:read\\\\`** and **\\\\`data.records:read\\\\`**.\\n\\nYour token is never sent to Grist's servers, and is only used to make API calls to Airtable from your browser.\": \"[Δημιουργήστε ένα διακριτικό]({{url}}) στον λογαριασμό σας στο Airtable με εμβέλειες που περιλαμβάνουν τουλάχιστον **\\\\`schema.bases:read\\\\`** και **\\\\`data.records:read\\\\`**.\\n\\nΤο διακριτικό σας δεν αποστέλλεται ποτέ στους διακομιστές του Grist και χρησιμοποιείται μόνο για την πραγματοποίηση κλήσεων API στο Airtable από το πρόγραμμα περιήγησής σας.\",\n        \"loading your bases...\": \"φορτώνονται οι βάσεις σας...\",\n        \"loading your tables...\": \"φορτώνονται οι πίνακές σας...\",\n        \"or\": \"ή\",\n        \"{{count}} warnings_one\": \"{{count}} προειδοποιήσεις\",\n        \"{{count}} warnings_other\": \"{{count}} προειδοποιήσεις\",\n        \"The more convenient ‘Connect with Airtable’ option can be configured by the installation administrator. [Learn more.]({{url}})\": \"Η πιο βολική επιλογή «Σύνδεση με το Airtable» μπορεί να ρυθμιστεί από τον διαχειριστή εγκατάστασης. [Μάθετε περισσότερα.]({{url}})\",\n        \"Use personal access token\": \"Χρήση προσωπικού διακριτικού πρόσβασης\"\n    },\n    \"AirtableImporter\": {\n        \"Creating a new Grist document...\": \"Δημιουργία νέου εγγράφου Grist...\",\n        \"Preparing to import base from Airtable...\": \"Προετοιμασία εισαγωγής βάσης από το Airtable...\",\n        \"Setting up tables...\": \"Στήσιμο τραπεζιών...\"\n    },\n    \"ChangeAdminModal\": {\n        \"Enter new admin email\": \"Εισαγάγετε νέο email διαχειριστή\",\n        \"Make the new email the installation admin. Orgs, workspaces, and documents will remain owned by {{email}}. These changes will take effect after you restart this Grist server.\": \"Ορίστε το νέο email ως διαχειριστή εγκατάστασης. Οι οργανισμοί, οι χώροι εργασίας και τα έγγραφα θα παραμείνουν ιδιοκτησία του {{email}}. Αυτές οι αλλαγές θα τεθούν σε ισχύ μετά την επανεκκίνηση αυτού του διακομιστή Grist.\",\n        \"New admin\": \"Νέος διαχειριστής\",\n        \"Replace {{email}} with the new email throughout. The new email will become the installation admin, as well as the owner of all materials previously owned by you@example.com.\": \"Αντικαταστήστε το {{email}} με τη νέα διεύθυνση ηλεκτρονικού ταχυδρομείου σε όλο το κείμενο. Η νέα διεύθυνση ηλεκτρονικού ταχυδρομείου θα γίνει του διαχειριστή εγκατάστασης, καθώς και του κατόχου όλων των υλικών που ανήκαν προηγουμένως στο you@example.com.\"\n    },\n    \"startDocAirtableImport\": {\n        \"Import from Airtable\": \"Εισαγωγή από Airtable\"\n    }\n}\n"
  },
  {
    "path": "static/locales/el.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Επαληθεύστε το email σας με τον πάροχο ταυτότητας και συνδεθείτε ξανά.\"\n    },\n    \"sendAppPage\": {\n        \"og-description\": \"Ένα σύγχρονο υπολογιστικό φύλλο ανοιχτού κώδικα που ξεπερνά τα όρια του πλέγματος\",\n        \"Loading...\": \"Φόρτωση...\",\n        \"og-title\": \"Grist, η εξέλιξη των υπολογιστικών φύλλων\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"Δεν έχετε πρόσβαση σε αυτό το έγγραφο.\",\n        \"docDisabled\": \"Αυτό το έγγραφο είναι απενεργοποιημένο.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"Δεν βρέθηκαν κάτοχοι στον οργανισμό διαχείρισης που ορίζεται από το `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"orgUser\": \"Ο χρήστης είναι κάτοχος του οργανισμού διαχείρισης που ορίζεται από το `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"noAdminEmail\": \"Λείπει ο λογαριασμός διαχειριστή επειδή δεν έχουν οριστεί τα `GRIST_ADMIN_EMAIL` και `GRIST_DEFAULT_EMAIL`\",\n        \"accountByEmail\": \"Λογαριασμός διαχειριστή που ορίζεται από το `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Έγγραφο χωρίς τίτλο\"\n    }\n}\n"
  },
  {
    "path": "static/locales/en.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Enter email address\": \"Enter e-mail address\",\n        \"Invite new member\": \"Invite new member\",\n        \"We'll email an invite to {{email}}\": \"We'll email an invite to {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Add column rule\": \"Add column rule\",\n        \"Add Default Rule\": \"Add Default Rule\",\n        \"Add table rules\": \"Add table rules\",\n        \"Add user attributes\": \"Add user attributes\",\n        \"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.\": \"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.\",\n        \"Allow everyone to view Access Rules.\": \"Allow everyone to view Access Rules.\",\n        \"Attribute name\": \"Attribute name\",\n        \"Attribute to Look Up\": \"Attribute to Look Up\",\n        \"Checking...\": \"Checking…\",\n        \"Condition\": \"Condition\",\n        \"Default rules\": \"Default rules\",\n        \"Delete table rules\": \"Delete table rules\",\n        \"Enter Condition\": \"Enter Condition\",\n        \"Everyone\": \"Everyone\",\n        \"Everyone Else\": \"Everyone Else\",\n        \"Invalid\": \"Invalid\",\n        \"Lookup Column\": \"Lookup Column\",\n        \"Lookup Table\": \"Lookup Table\",\n        \"Permission to access the document in full when needed\": \"Permission to access the document in full when needed\",\n        \"Permission to view Access Rules\": \"Permission to view Access Rules\",\n        \"Permissions\": \"Permissions\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Remove column {{- colId }} from {{- tableId }} rules\",\n        \"Remove {{- tableId }} rules\": \"Remove {{- tableId }} rules\",\n        \"Remove {{- name }} user attribute\": \"Remove {{- name }} user attribute\",\n        \"Reset\": \"Reset\",\n        \"Rules for table \": \"Rules for table \",\n        \"Save\": \"Save\",\n        \"Saved\": \"Saved\",\n        \"Special rules\": \"Special rules\",\n        \"Type message to display when this rule blocks an action…\": \"Type message to display when this rule blocks an action…\",\n        \"User Attributes\": \"User Attributes\",\n        \"View as\": \"View as\",\n        \"Seed rules\": \"Seed rules\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"When adding table rules, automatically add a rule to grant OWNER full access.\",\n        \"Permission to edit document structure\": \"Permission to edit document structure\",\n        \"This default should be changed if editors' access is to be limited. \": \"This default should be changed if editors' access is to be limited. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\",\n        \"Add table-wide rule\": \"Add table-wide rule\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\",\n        \"All\": \"All\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\",\n        \"Columns\": \"Columns\",\n        \"Condition cannot be blank\": \"Condition cannot be blank\",\n        \"Default resource missing in resource map\": \"Default resource missing in resource map\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Invalid columns in table {{tableId}}: {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"Invalid table: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Invalid user attribute rule: {{prop}} must be set\",\n        \"Invalid user attribute to look up\": \"Invalid user attribute to look up\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"No columns listed in a column rule for table {{tableId}}\",\n        \"Not a valid user attribute\": \"Not a valid user attribute\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Resource missing in resource map: {{resourceKey}}\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"Trying to add TableRules for existing table {{tableId}}\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\",\n        \"hidden\": \"hidden\",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Special rules** (expand each rule to customize who it applies to)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\",\n        \"Allow everyone to view access rules.\": \"Allow everyone to view access rules.\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\",\n        \"Continue\": \"Continue\",\n        \"Disable Access Rules\": \"Disable Access Rules\",\n        \"Disable and save\": \"Disable and save\",\n        \"Enable Access Rules\": \"Enable Access Rules\",\n        \"Permission to access the document in full by all users\": \"Permission to access the document in full by all users\",\n        \"Permission to access the document in full by unrestricted users\": \"Permission to access the document in full by unrestricted users\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\",\n        \"Special rules for templates\": \"Special rules for templates\",\n        \"This options should be off if Editors' access is to be limited. \": \"This options should be off if Editors' access is to be limited. \"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"API Key\",\n        \"Account settings\": \"Account settings\",\n        \"Allow signing in to this account with Google\": \"Allow signing in to this account with Google\",\n        \"Change password\": \"Change password\",\n        \"Edit\": \"Edit\",\n        \"Email\": \"E-mail\",\n        \"Login method\": \"Login method\",\n        \"Name\": \"Name\",\n        \"Names only allow letters, numbers and certain special characters\": \"Names only allow letters, numbers and certain special characters\",\n        \"Password & security\": \"Password & security\",\n        \"Save\": \"Save\",\n        \"Theme\": \"Theme\",\n        \"Two-factor authentication\": \"Two-factor authentication\",\n        \"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.\": \"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.\",\n        \"Language\": \"Language\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Access Details\",\n        \"Accounts\": \"Accounts\",\n        \"Add account\": \"Add account\",\n        \"Document settings\": \"Document settings\",\n        \"Manage team\": \"Manage team\",\n        \"Pricing\": \"Pricing\",\n        \"Profile settings\": \"Profile settings\",\n        \"Sign out\": \"Sign out\",\n        \"Sign in\": \"Sign in\",\n        \"Switch Accounts\": \"Switch Accounts\",\n        \"Toggle Mobile Mode\": \"Toggle Mobile Mode\",\n        \"Activation\": \"Activation\",\n        \"Billing account\": \"Billing account\",\n        \"Support Grist\": \"Support Grist\",\n        \"Upgrade Plan\": \"Upgrade Plan\",\n        \"Sign up\": \"Sign up\",\n        \"Use This Template\": \"Use This Template\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"View as\",\n        \"Users from table\": \"Users from table\",\n        \"Example Users\": \"Example Users\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"Action Log failed to load\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"This row was subsequently removed in action {{action.actionNum}}\",\n        \"All tables\": \"All tables\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"Column {{colId}} was subsequently removed in action #{{actionNum}}\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"This row was subsequently removed in action {{actionNum}}\",\n        \"History blocked because of access rules.\": \"History blocked because of access rules.\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Add new\"\n    },\n    \"ApiKey\": {\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"By generating an API key, you will be able to make API calls for your own account.\",\n        \"Click to show\": \"Click to show\",\n        \"Create\": \"Create\",\n        \"Remove\": \"Remove\",\n        \"Remove API Key\": \"Remove API Key\",\n        \"This API key can be used to access this account anonymously via the API.\": \"This API key can be used to access this account anonymously via the API.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\",\n        \"You're about to delete an API key. This will cause all future requests using this API key to be rejected. Do you still want to delete?\": \"You're about to delete an API key. This will cause all future requests using this API key to be rejected. Do you still want to delete?\"\n    },\n    \"App\": {\n        \"Description\": \"Description\",\n        \"Key\": \"Key\",\n        \"Memory Error\": \"Memory Error\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Translators: please translate this only when your language is ready to be offered to users\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Home page\",\n        \"Legacy\": \"Legacy\",\n        \"Personal Site\": \"Personal Site\",\n        \"Team Site\": \"Team Site\",\n        \"Grist Templates\": \"Grist Templates\",\n        \"Billing account\": \"Billing account\",\n        \"Manage team\": \"Manage team\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Back to home\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"This team site is suspended. Documents can be read, but not modified.\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"Clear cell\",\n        \"Clear values\": \"Clear values\",\n        \"Copy anchor link\": \"Copy anchor link\",\n        \"Delete {{count}} columns_one\": \"Delete column\",\n        \"Delete {{count}} columns_other\": \"Delete {{count}} columns\",\n        \"Delete {{count}} rows_one\": \"Delete row\",\n        \"Delete {{count}} rows_other\": \"Delete {{count}} rows\",\n        \"Duplicate rows_one\": \"Duplicate row\",\n        \"Duplicate rows_other\": \"Duplicate rows\",\n        \"Filter by this value\": \"Filter by this value\",\n        \"Insert column to the left\": \"Insert column to the left\",\n        \"Insert column to the right\": \"Insert column to the right\",\n        \"Insert row\": \"Insert row\",\n        \"Insert row above\": \"Insert row above\",\n        \"Insert row below\": \"Insert row below\",\n        \"Reset {{count}} columns_one\": \"Reset column\",\n        \"Reset {{count}} columns_other\": \"Reset {{count}} columns\",\n        \"Reset {{count}} entire columns_one\": \"Reset entire column\",\n        \"Reset {{count}} entire columns_other\": \"Reset {{count}} entire columns\",\n        \"Comment\": \"Comment\",\n        \"Copy\": \"Copy\",\n        \"Cut\": \"Cut\",\n        \"Paste\": \"Paste\",\n        \"Copy with headers\": \"Copy with headers\"\n    },\n    \"ChartView\": {\n        \"LABEL\": \"LABEL\",\n        \"Bar chart\": \"Bar chart\",\n        \"Pie chart\": \"Pie chart\",\n        \"Donut chart\": \"Donut chart\",\n        \"Area chart\": \"Area chart\",\n        \"Line chart\": \"Line chart\",\n        \"Scatter plot\": \"Scatter plot\",\n        \"Kaplan-Meier plot\": \"Kaplan-Meier plot\",\n        \"Split series\": \"Split series\",\n        \"Invert Y-axis\": \"Invert Y-axis\",\n        \"Orientation\": \"Orientation\",\n        \"Vertical\": \"Vertical\",\n        \"Horizontal\": \"Horizontal\",\n        \"Log scale Y-axis\": \"Log scale Y-axis\",\n        \"Hole size\": \"Hole size\",\n        \"Show total\": \"Show total\",\n        \"Text size\": \"Text size\",\n        \"Connect gaps\": \"Connect gaps\",\n        \"Show markers\": \"Show markers\",\n        \"Stack series\": \"Stack series\",\n        \"Error bars\": \"Error bars\",\n        \"None\": \"None\",\n        \"Symmetric\": \"Symmetric\",\n        \"Above+Below\": \"Above+Below\",\n        \"Split Series\": \"Split Series\",\n        \"X-AXIS\": \"X-AXIS\",\n        \"Pick a column\": \"Pick a column\",\n        \"Aggregate values\": \"Aggregate values\",\n        \"SERIES\": \"SERIES\",\n        \"Add series\": \"Add series\",\n        \"non-numeric columns are not shown\": \"non-numeric columns are not shown\",\n        \"non-numeric column is not shown\": \"non-numeric column is not shown\",\n        \"selected new x-axis\": \"selected new x-axis\",\n        \"Remove\": \"Remove\",\n        \"Create separate series for each value of the selected column.\": \"Create separate series for each value of the selected column.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Each Y series is followed by a series for the length of error bars.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Each Y series is followed by two series, for top and bottom error bars.\",\n        \"Toggle chart aggregation\": \"Toggle chart aggregation\",\n        \"selected new group data columns\": \"selected new group-data columns\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Access denied\",\n        \"Code View is available only when you have full document access.\": \"Code View is available only when you have full document access.\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Apply\",\n        \"Cancel\": \"Cancel\",\n        \"Default cell style\": \"Default cell style\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"All\",\n        \"All except\": \"All except\",\n        \"All shown\": \"All shown\",\n        \"Filter by Range\": \"Filter by Range\",\n        \"Future values\": \"Future values\",\n        \"No matching values\": \"No matching values\",\n        \"None\": \"None\",\n        \"Min\": \"Min\",\n        \"Max\": \"Max\",\n        \"Start\": \"Start\",\n        \"End\": \"End\",\n        \"Other Matching\": \"Other Matching\",\n        \"Other Non-Matching\": \"Other Non-Matching\",\n        \"Other values\": \"Other values\",\n        \"Others\": \"Others\",\n        \"Search\": \"Search\",\n        \"Search values\": \"Search values\",\n        \"Clear search\": \"Clear search\",\n        \"Pin filter\": \"Pin filter\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Sort alphabetically (current: sorted by number of occurrences)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Sort by number of occurrences (current: sorted alphabetically)\",\n        \"Unpin filter\": \"Unpin filter\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (optional)\",\n        \"Add\": \"Add\",\n        \"Enter Custom URL\": \"Enter Custom URL\",\n        \"Full document access\": \"Full document access\",\n        \"Learn more about custom widgets\": \"Learn more about custom widgets\",\n        \"No document access\": \"No document access\",\n        \"Open configuration\": \"Open configuration\",\n        \"Pick a column\": \"Pick a column\",\n        \"Pick a {{columnType}} column\": \"Pick a {{columnType}} column\",\n        \"Read selected table\": \"Read selected table\",\n        \"Select Custom Widget\": \"Select Custom Widget\",\n        \"Widget does not require any permissions.\": \"Widget does not require any permissions.\",\n        \"Widget needs to {{read}} the current table.\": \"Widget needs to {{read}} the current table.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Widget needs {{fullAccess}} to this document.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} non-{{columnType}} column is not shown\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} non-{{columnType}} columns are not shown\",\n        \"Clear selection\": \"Clear selection\",\n        \"No {{columnType}} columns in table.\": \"No {{columnType}} columns in table.\",\n        \"ACCESS LEVEL\": \"ACCESS LEVEL\",\n        \"Accept\": \"Accept\",\n        \"Custom URL\": \"Custom URL\",\n        \"Developer:\": \"Developer:\",\n        \"Last updated:\": \"Last updated:\",\n        \"Missing description and author information.\": \"Missing description and author information.\",\n        \"Reject\": \"Reject\",\n        \"Widget\": \"Widget\",\n        \"Change custom widget\": \"Change custom widget\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Click to copy\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Delete {{formattedTableName}} data, and remove it from all pages?\",\n        \"Duplicate table\": \"Duplicate table\",\n        \"Raw Data Tables\": \"Raw Data Tables\",\n        \"Table ID copied to clipboard\": \"Table ID copied to clipboard\",\n        \"You do not have edit access to this document\": \"You do not have edit access to this document\",\n        \"Edit record card\": \"Edit record card\",\n        \"Record Card\": \"Record Card\",\n        \"Record Card Disabled\": \"Record Card Disabled\",\n        \"Remove table\": \"Remove table\",\n        \"Rename table\": \"Rename table\",\n        \"{{action}} Record Card\": \"{{action}} Record Card\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Activity\",\n        \"Beta\": \"Beta\",\n        \"Compare to current\": \"Compare to current\",\n        \"Compare to previous\": \"Compare to previous\",\n        \"Open snapshot\": \"Open snapshot\",\n        \"Snapshots\": \"Snapshots\",\n        \"Snapshots are unavailable.\": \"Snapshots are unavailable.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Only owners have access to snapshots for documents with access rules.\"\n    },\n    \"DocMenu\": {\n        \"(The organization needs a paid plan)\": \"(The organization needs a paid plan)\",\n        \"Access Details\": \"Access Details\",\n        \"All documents\": \"All documents\",\n        \"By Date Modified\": \"By Date Modified\",\n        \"By Name\": \"By Name\",\n        \"Current workspace\": \"Current workspace\",\n        \"Delete\": \"Delete\",\n        \"Delete Forever\": \"Delete Forever\",\n        \"Delete {{name}}\": \"Delete {{name}}\",\n        \"Deleted {{at}}\": \"Deleted {{at}}\",\n        \"Discover More Templates\": \"Discover More Templates\",\n        \"Document will be moved to Trash.\": \"Document will be moved to Trash.\",\n        \"Document will be permanently deleted.\": \"Document will be permanently deleted.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Documents stay in Trash for 30 days, after which they get deleted permanently.\",\n        \"Edited {{at}}\": \"Edited {{at}}\",\n        \"Examples & Templates\": \"Examples & Templates\",\n        \"Examples and Templates\": \"Examples and Templates\",\n        \"Featured\": \"Featured\",\n        \"Grid view\": \"Grid view\",\n        \"List view\": \"List view\",\n        \"Manage users\": \"Manage users\",\n        \"More Examples and Templates\": \"More Examples and Templates\",\n        \"Move\": \"Move\",\n        \"Move {{name}} to workspace\": \"Move {{name}} to workspace\",\n        \"Other Sites\": \"Other Sites\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Permanently Delete \\\"{{name}}\\\"?\",\n        \"Pin Document\": \"Pin Document\",\n        \"Pinned Documents\": \"Pinned Documents\",\n        \"Remove\": \"Remove\",\n        \"Rename\": \"Rename\",\n        \"Requires edit permissions\": \"Requires edit permissions\",\n        \"Restore\": \"Restore\",\n        \"This service is not available right now\": \"This service is not available right now\",\n        \"To restore this document, restore the workspace first.\": \"To restore this document, restore the workspace first.\",\n        \"Trash\": \"Trash\",\n        \"Trash is empty.\": \"Trash is empty.\",\n        \"Unpin Document\": \"Unpin Document\",\n        \"Workspace not found\": \"Workspace not found\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"You are on the {{siteName}} site. You also have access to the following sites:\",\n        \"You are on your personal site. You also have access to the following sites:\": \"You are on your personal site. You also have access to the following sites:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"You may delete a workspace forever once it has no documents in it.\",\n        \"Any documents created in this site will appear here.\": \"Any documents created in this site will appear here.\",\n        \"Create my first document\": \"Create my first document\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"You have read-only access to this site. Currently there are no documents.\",\n        \"personal site\": \"personal site\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Add empty table\",\n        \"Add page\": \"Add page\",\n        \"Add widget to page\": \"Add widget to page\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Document owners can attempt to recover the document. [{{error}}]\",\n        \"Enter recovery mode\": \"Enter recovery mode\",\n        \"Error accessing document\": \"Error accessing document\",\n        \"Reload\": \"Reload\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Sorry, access to this document has been denied. [{{error}}]\",\n        \"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}}]\": \"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}}]\",\n        \"You do not have edit access to this document\": \"You do not have edit access to this document\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\"\n    },\n    \"DocTour\": {\n        \"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.\": \"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.\",\n        \"No valid document tour\": \"No valid document tour\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Currency:\",\n        \"Document settings\": \"Document settings\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Engine (experimental {{span}} change at own risk):\",\n        \"Local currency ({{currency}})\": \"Local currency ({{currency}})\",\n        \"Locale:\": \"Locale:\",\n        \"Save\": \"Save\",\n        \"Save and Reload\": \"Save and Reload\",\n        \"This document's ID (for API use):\": \"This document's ID (for API use):\",\n        \"Time Zone:\": \"Time Zone:\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"Document ID copied to clipboard\",\n        \"Ok\": \"OK\",\n        \"Manage Webhooks\": \"Manage Webhooks\",\n        \"Webhooks\": \"Webhooks\",\n        \"API console\": \"API console\",\n        \"API URL copied to clipboard\": \"API URL copied to clipboard\",\n        \"API documentation.\": \"API documentation.\",\n        \"Base doc URL: {{docApiUrl}}\": \"Base doc URL: {{docApiUrl}}\",\n        \"Coming soon\": \"Coming soon\",\n        \"Copy to clipboard\": \"Copy to clipboard\",\n        \"Currency\": \"Currency\",\n        \"Data engine\": \"Data engine\",\n        \"Default for DateTime columns\": \"Default for DateTime columns\",\n        \"Document ID\": \"Document ID\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\",\n        \"Find slow formulas\": \"Find slow formulas\",\n        \"For currency columns\": \"For currency columns\",\n        \"For number and date formats\": \"For number and date formats\",\n        \"Formula times\": \"Formula times\",\n        \"Hard reset of data engine\": \"Hard reset of data engine\",\n        \"ID for API use\": \"ID for API use\",\n        \"Locale\": \"Locale\",\n        \"Manage webhooks\": \"Manage webhooks\",\n        \"Notify other services on doc changes\": \"Notify other services on doc changes\",\n        \"Python\": \"Python\",\n        \"Python version used\": \"Python version used\",\n        \"Reload\": \"Reload\",\n        \"Time zone\": \"Time zone\",\n        \"Try API calls from the browser\": \"Try API calls from the browser\",\n        \"python2 (legacy)\": \"python2 (legacy)\",\n        \"python3 (recommended)\": \"python3 (recommended)\",\n        \"Cancel\": \"Cancel\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Force reload the document while timing formulas, and show the result.\",\n        \"Formula timer\": \"Formula timer\",\n        \"Reload data engine\": \"Reload data engine\",\n        \"Reload data engine?\": \"Reload data engine?\",\n        \"Start timing\": \"Start timing\",\n        \"Stop timing...\": \"Stop timing...\",\n        \"Time reload\": \"Time reload\",\n        \"Timing is on\": \"Timing is on\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"You can make changes to the document, then stop timing to see the results.\",\n        \"Only available to document editors\": \"Only available to document editors\",\n        \"Only available to document owners\": \"Only available to document owners\",\n        \"Template mode\": \"Template mode\",\n        \"Change document type\": \"Change document type\",\n        \"Edit\": \"Edit\",\n        \"Change nature of document\": \"Change nature of document\",\n        \"Regular document\": \"Regular document\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Normal document behavior. All users work on the same copy of the document.\",\n        \"Regular\": \"Regular\",\n        \"Template\": \"Template\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\",\n        \"fiddle mode\": \"fiddle mode\",\n        \"Tutorial\": \"Tutorial\",\n        \"Document automatically opens as a user-specific copy.\": \"Document automatically opens as a user-specific copy.\",\n        \"Confirm change\": \"Confirm change\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\",\n        \"**Some existing attachments are still external**.\": \"**Some existing attachments are still external**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Some existing attachments are still internal** (stored in SQLite file).\",\n        \"Attachment storage\": \"Attachment storage\",\n        \"Being transfer\": \"Being transfer\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Click \\\"Start transfer\\\" to transfer those to External storage.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Newly uploaded attachments will be placed in External storage.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Newly uploaded attachments will be placed in Internal storage.\",\n        \"No external stores available\": \"No external stores available\",\n        \"Preferred storage for this document\": \"Preferred storage for this document\",\n        \"Start transfer\": \"Start transfer\",\n        \"External\": \"External\",\n        \"Internal\": \"Internal\",\n        \"Transfer in progress\": \"Transfer in progress\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Some existing attachments are still [external]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\",\n        \"[Learn more.]({{learnLink}})\": \"[Learn more.]({{learnLink}})\",\n        \"Upload\": \"Upload\",\n        \"Upload missing attachments\": \"Upload missing attachments\",\n        \"Uploading...\": \"Uploading...\",\n        \"Default\": \"Default\",\n        \"Default, template, or tutorial\": \"Default, template, or tutorial\",\n        \"Document type\": \"Document type\",\n        \"Allow others to suggest changes\": \"Allow others to suggest changes\",\n        \"Enable suggestions\": \"Enable suggestions\",\n        \"Suggestions\": \"Suggestions\",\n        \"experiment\": \"experiment\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"Size of attachments\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Contact the site owner to upgrade the plan to raise limits.\",\n        \"Data size\": \"Data size\",\n        \"For higher limits, \": \"For higher limits, \",\n        \"Rows\": \"Rows\",\n        \"Usage\": \"Usage\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"Usage statistics are only available to users with full access to the document data.\",\n        \"start your 30-day free trial of the Pro plan.\": \"start your 30-day free trial of the Pro plan.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Restore last edit\",\n        \"Undo discard\": \"Undo discard\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Copy all data in addition to the table structure.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\",\n        \"Name for new table\": \"Name for new table\",\n        \"Only the document default access rules will apply to the copy.\": \"Only the document default access rules will apply to the copy.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Afterschool Program\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Check out our related tutorial for how to link data, and create high-productivity layouts.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\",\n        \"Investment Research\": \"Investment Research\",\n        \"Lightweight CRM\": \"Lightweight CRM\",\n        \"Tutorial: Analyze & Visualize\": \"Tutorial: Analyze and Visualize\",\n        \"Tutorial: Create a CRM\": \"Tutorial: Create a CRM\",\n        \"Tutorial: Manage Business Data\": \"Tutorial: Manage Business Data\",\n        \"Welcome to the Afterschool Program template\": \"Welcome to the Afterschool Program template\",\n        \"Welcome to the Investment Research template\": \"Welcome to the Investment Research template\",\n        \"Welcome to the Lightweight CRM template\": \"Welcome to the Lightweight CRM template\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"COLUMN BEHAVIOR\",\n        \"COLUMN LABEL AND ID\": \"COLUMN LABEL AND ID\",\n        \"Clear and make into formula\": \"Clear and make into formula\",\n        \"Clear and reset\": \"Clear and reset\",\n        \"Column options are limited in summary tables.\": \"Column options are limited in summary tables.\",\n        \"Convert column to data\": \"Convert column to data\",\n        \"Convert to trigger formula\": \"Convert to trigger formula\",\n        \"Data columns_one\": \"Data column\",\n        \"Data columns_other\": \"Data columns\",\n        \"Empty columns_one\": \"Empty column\",\n        \"Empty columns_other\": \"Empty columns\",\n        \"Enter formula\": \"Enter formula\",\n        \"Formula columns_one\": \"Formula column\",\n        \"Formula columns_other\": \"Formula columns\",\n        \"Make into data column\": \"Make into data column\",\n        \"Mixed Behavior\": \"Mixed Behavior\",\n        \"Set formula\": \"Set formula\",\n        \"Set trigger formula\": \"Set trigger formula\",\n        \"TRIGGER FORMULA\": \"TRIGGER FORMULA\",\n        \"DESCRIPTION\": \"DESCRIPTION\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Revert to common settings\",\n        \"Save as common settings\": \"Save as common settings\",\n        \"Use separate settings\": \"Use separate settings\",\n        \"Using common settings\": \"Using common settings\",\n        \"Using separate settings\": \"Using separate settings\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Add column\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Pin filter - {{- columnName}} column (current: unpinned)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Unpin filter - {{- columnName}} column (current: pinned)\",\n        \"remove filter - {{- columnName}} column\": \"remove filter - {{- columnName}} column\",\n        \"{{- columnName }} column filters\": \"{{- columnName }} column filters\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Search columns\",\n        \"Search Columns\": \"Search Columns\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Grid Options\",\n        \"Horizontal gridlines\": \"Horizontal gridlines\",\n        \"Vertical gridlines\": \"Vertical gridlines\",\n        \"Zebra stripes\": \"Zebra stripes\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Add column\",\n        \"Add to sort\": \"Add to sort\",\n        \"Clear values\": \"Clear values\",\n        \"Column Options\": \"Column Options\",\n        \"Convert formula to data\": \"Convert formula to data\",\n        \"Delete {{count}} columns_one\": \"Delete column\",\n        \"Delete {{count}} columns_other\": \"Delete {{count}} columns\",\n        \"Filter Data\": \"Filter Data\",\n        \"Freeze {{count}} columns_one\": \"Freeze this column\",\n        \"Freeze {{count}} columns_other\": \"Freeze {{count}} columns\",\n        \"Freeze {{count}} more columns_one\": \"Freeze one more column\",\n        \"Freeze {{count}} more columns_other\": \"Freeze {{count}} more columns\",\n        \"Hide {{count}} columns_one\": \"Hide column\",\n        \"Hide {{count}} columns_other\": \"Hide {{count}} columns\",\n        \"Insert column to the {{to}}\": \"Insert column to the {{to}}\",\n        \"More sort options ...\": \"More sort options…\",\n        \"Rename column\": \"Rename column\",\n        \"Reset {{count}} columns_one\": \"Reset column\",\n        \"Reset {{count}} columns_other\": \"Reset {{count}} columns\",\n        \"Reset {{count}} entire columns_one\": \"Reset entire column\",\n        \"Reset {{count}} entire columns_other\": \"Reset {{count}} entire columns\",\n        \"Show column {{- label}}\": \"Show column {{- label}}\",\n        \"Sort\": \"Sort\",\n        \"Sorted (#{{count}})_one\": \"Sorted (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Sorted (#{{count}})\",\n        \"Unfreeze all columns\": \"Unfreeze all columns\",\n        \"Unfreeze {{count}} columns_one\": \"Unfreeze this column\",\n        \"Unfreeze {{count}} columns_other\": \"Unfreeze {{count}} columns\",\n        \"Insert column to the left\": \"Insert column to the left\",\n        \"Insert column to the right\": \"Insert column to the right\",\n        \"Apply on record changes\": \"Apply on record changes\",\n        \"Apply to new records\": \"Apply to new records\",\n        \"Authorship\": \"Authorship\",\n        \"Created At\": \"Created At\",\n        \"Created By\": \"Created By\",\n        \"Hidden Columns\": \"Hidden Columns\",\n        \"Last Updated At\": \"Last Updated At\",\n        \"Last Updated By\": \"Last Updated By\",\n        \"Lookups\": \"Lookups\",\n        \"Shortcuts\": \"Shortcuts\",\n        \"Show hidden columns\": \"Show hidden columns\",\n        \"Timestamp\": \"Timestamp\",\n        \"no reference column\": \"no reference column\",\n        \"Adding UUID column\": \"Adding UUID column\",\n        \"Adding duplicates column\": \"Adding duplicates column\",\n        \"Detect Duplicates in...\": \"Detect Duplicates in...\",\n        \"Duplicate in {{- label}}\": \"Duplicate in {{- label}}\",\n        \"No reference columns.\": \"No reference columns.\",\n        \"Search columns\": \"Search columns\",\n        \"UUID\": \"UUID\",\n        \"Add column with type\": \"Add column with type\",\n        \"Add formula column\": \"Add formula column\",\n        \"Created at\": \"Created at\",\n        \"Created by\": \"Created by\",\n        \"Detect duplicates in...\": \"Detect duplicates in...\",\n        \"Last updated at\": \"Last updated at\",\n        \"Last updated by\": \"Last updated by\",\n        \"Any\": \"Any\",\n        \"Numeric\": \"Numeric\",\n        \"Text\": \"Text\",\n        \"Integer\": \"Integer\",\n        \"Toggle\": \"Toggle\",\n        \"Date\": \"Date\",\n        \"DateTime\": \"DateTime\",\n        \"Choice\": \"Choice\",\n        \"Choice List\": \"Choice List\",\n        \"Reference\": \"Reference\",\n        \"Reference List\": \"Reference List\",\n        \"Attachment\": \"Attachment\"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"Added new linked section to view {{viewName}}\",\n        \"Import from file\": \"Import from file\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Saved linked section {{title}} in view {{name}}\",\n        \"go to webhook settings\": \"go to webhook settings\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\",\n        \"Import from Airtable\": \"Import from Airtable\"\n    },\n    \"HomeIntro\": {\n        \"Any documents created in this site will appear here.\": \"Any documents created in this site will appear here.\",\n        \"Browse Templates\": \"Browse Templates\",\n        \"Create empty document\": \"Create empty document\",\n        \"Get started by creating your first Grist document.\": \"Get started by creating your first Grist document.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Get started by exploring templates, or creating your first Grist document.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Get started by inviting your team and creating your first Grist document.\",\n        \"Help Center\": \"Help Center\",\n        \"Import document\": \"Import document\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Interested in using Grist outside of your team? Visit your free \",\n        \"Invite Team Members\": \"Invite Team Members\",\n        \"Sign up\": \"Sign up\",\n        \"Sprouts Program\": \"Sprouts Program\",\n        \"This workspace is empty.\": \"This workspace is empty.\",\n        \"Visit our {{link}} to learn more.\": \"Visit our {{link}} to learn more.\",\n        \"Welcome to Grist!\": \"Welcome to Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Welcome to Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Welcome to {{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"You have read-only access to this site. Currently there are no documents.\",\n        \"personal site\": \"personal site\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} to save your work. \",\n        \"Welcome to Grist, {{- name}}!\": \"Welcome to Grist, {{- name}}!\",\n        \"Welcome to {{- orgName}}\": \"Welcome to {{- orgName}}\",\n        \"Sign in\": \"Sign in\",\n        \"To use Grist, please either sign up or sign in.\": \"To use Grist, please either sign up or sign in.\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Visit our {{link}} to learn more about Grist.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Learn more in our {{helpCenterLink}}.\",\n        \"Only show documents\": \"Only show documents\"\n    },\n    \"HomeLeftPane\": {\n        \"Access Details\": \"Access Details\",\n        \"All documents\": \"All documents\",\n        \"Create empty document\": \"Create empty document\",\n        \"Create workspace\": \"Create workspace\",\n        \"Delete\": \"Delete\",\n        \"Delete {{workspace}} and all included documents?\": \"Delete {{workspace}} and all included documents?\",\n        \"Examples & Templates\": \"Templates\",\n        \"Import document\": \"Import document\",\n        \"Manage users\": \"Manage users\",\n        \"Rename\": \"Rename\",\n        \"Trash\": \"Trash\",\n        \"Workspace will be moved to Trash.\": \"Workspace will be moved to Trash.\",\n        \"Workspaces\": \"Workspaces\",\n        \"Tutorial\": \"Tutorial\",\n        \"Terms of service\": \"Terms of service\",\n        \"Grist Resources\": \"Grist Resources\",\n        \"context menu - {{- workspaceName }}\": \"context menu - {{- workspaceName }}\",\n        \"Import from Airtable\": \"Import from Airtable\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"Merge rows that match these fields:\",\n        \"Select fields to match on\": \"Select fields to match on\",\n        \"Update existing records\": \"Update existing records\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} unmatched field in import\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} unmatched fields in import\",\n        \"{{count}} unmatched field_one\": \"{{count}} unmatched field\",\n        \"{{count}} unmatched field_other\": \"{{count}} unmatched fields\",\n        \"Column Mapping\": \"Column Mapping\",\n        \"Column mapping\": \"Column mapping\",\n        \"Destination table\": \"Destination table\",\n        \"Grist column\": \"Grist column\",\n        \"Import from file\": \"Import from file\",\n        \"New Table\": \"New Table\",\n        \"Revert\": \"Revert\",\n        \"Skip\": \"Skip\",\n        \"Skip Import\": \"Skip Import\",\n        \"Skip Table on Import\": \"Skip Table on Import\",\n        \"Source column\": \"Source column\",\n        \"Import options\": \"Import options\",\n        \"Cancel\": \"Cancel\",\n        \"Import\": \"Import\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Help Center\",\n        \"Accessibility\": \"Accessibility\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"As template\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Be careful, the original has changes not in this document. Those changes will be overwritten.\",\n        \"Cancel\": \"Cancel\",\n        \"Enter document name\": \"Enter document name\",\n        \"However, it appears to be already identical.\": \"However, it appears to be already identical.\",\n        \"Include the structure without any of the data.\": \"Include the structure without any of the data.\",\n        \"It will be overwritten, losing any content not in this document.\": \"It will be overwritten, losing any content not in this document.\",\n        \"Name\": \"Name\",\n        \"No destination workspace\": \"No destination workspace\",\n        \"Organization\": \"Organization\",\n        \"Original Has Modifications\": \"Original Has Modifications\",\n        \"Original Looks Unrelated\": \"Original Looks Unrelated\",\n        \"Original Looks Identical\": \"Original Looks Identical\",\n        \"Overwrite\": \"Overwrite\",\n        \"Replacing the original requires editing rights on the original document.\": \"Replacing the original requires editing rights on the original document.\",\n        \"Sign up\": \"Sign up\",\n        \"The original version of this document will be updated.\": \"The original version of this document will be updated.\",\n        \"To save your changes, please sign up, then reload this page.\": \"To save your changes, please sign up, then reload this page.\",\n        \"Update\": \"Update\",\n        \"Update Original\": \"Update Original\",\n        \"Workspace\": \"Workspace\",\n        \"You do not have write access to the selected workspace\": \"You do not have write access to the selected workspace\",\n        \"You do not have write access to this site\": \"You do not have write access to this site\",\n        \"Download\": \"Download\",\n        \"Download document\": \"Download document\",\n        \"Download document and history\": \"Download document and history\",\n        \"Download document without history (can significantly reduce file size)\": \"Download document without history (can significantly reduce file size)\",\n        \"Download document structure only (no data, for template use)\": \"Download document structure only (no data, for template use)\",\n        \".tar (recommended)\": \".tar (recommended)\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"Download an archive of all the attachments present in this document.\",\n        \"Download attachments\": \"Download attachments\",\n        \"Download full document and history\": \"Download full document and history\",\n        \"Format:\": \"Format:\",\n        \"Learn more\": \"Learn more\",\n        \"download attachments\": \"download attachments\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"Ask for help\",\n        \"Cannot find personal site, sorry!\": \"Cannot find personal site, sorry!\",\n        \"Give feedback\": \"Give feedback\",\n        \"Go to your free personal site\": \"Go to your free personal site\",\n        \"No notifications\": \"No notifications\",\n        \"Notifications\": \"Notifications\",\n        \"Renew\": \"Renew\",\n        \"Report a problem\": \"Report a problem\",\n        \"Upgrade Plan\": \"Upgrade Plan\",\n        \"Manage billing\": \"Manage billing\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Finish\",\n        \"Next\": \"Next\",\n        \"Previous\": \"Previous\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Grist Video Tour\",\n        \"Video Tour\": \"Video Tour\",\n        \"YouTube video player\": \"YouTube video player\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Add to page\",\n        \"Building {{- label}} widget\": \"Building {{- label}} widget\",\n        \"Group by\": \"Group by\",\n        \"Select data\": \"Select data\",\n        \"Select widget\": \"Select widget\",\n        \"New Table\": \"New Table\",\n        \"SELECT BY\": \"SELECT BY\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Delete\",\n        \"Delete data and this page.\": \"Delete data and this page.\",\n        \"The following tables will no longer be visible_one\": \"The following table will no longer be visible\",\n        \"The following tables will no longer be visible_other\": \"The following tables will no longer be visible\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Keep data and delete page. Table will remain available in {{rawDataLink}}\",\n        \"raw data page\": \"raw data page\",\n        \"Document pages\": \"Document pages\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Allow all\",\n        \"Deny all\": \"Deny all\",\n        \"Read only\": \"Read only\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Import failed: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Updating record layout.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Add field\",\n        \"Create new field\": \"Create new field\",\n        \"Show field {{- label}}\": \"Show field {{- label}}\",\n        \"Save layout\": \"Save layout\",\n        \"Cancel\": \"Cancel\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Add column\",\n        \"No columns to add\": \"No columns to add\"\n    },\n    \"RightPanel\": {\n        \"CHART TYPE\": \"CHART TYPE\",\n        \"COLUMN TYPE\": \"COLUMN TYPE\",\n        \"CUSTOM\": \"CUSTOM\",\n        \"Change widget\": \"Change widget\",\n        \"columns_one\": \"Column\",\n        \"columns_other\": \"Columns\",\n        \"DATA TABLE\": \"DATA TABLE\",\n        \"DATA TABLE NAME\": \"DATA TABLE NAME\",\n        \"Data\": \"Data\",\n        \"Detach\": \"Detach\",\n        \"Edit data selection\": \"Edit data selection\",\n        \"fields_one\": \"Field\",\n        \"fields_other\": \"Fields\",\n        \"GROUPED BY\": \"GROUPED BY\",\n        \"Row style\": \"Row style\",\n        \"SELECT BY\": \"SELECT BY\",\n        \"SELECTOR FOR\": \"SELECTOR FOR\",\n        \"SOURCE DATA\": \"SOURCE DATA\",\n        \"Save\": \"Save\",\n        \"Select widget\": \"Select widget\",\n        \"series_one\": \"Series\",\n        \"series_other\": \"Series\",\n        \"Sort & filter\": \"Sort & filter\",\n        \"TRANSFORM\": \"TRANSFORM\",\n        \"Theme\": \"Theme\",\n        \"WIDGET TITLE\": \"WIDGET TITLE\",\n        \"Widget\": \"Widget\",\n        \"You do not have edit access to this document\": \"You do not have edit access to this document\",\n        \"Add referenced columns\": \"Add referenced columns\",\n        \"Reset form\": \"Reset form\",\n        \"Configuration\": \"Configuration\",\n        \"Default field value\": \"Default field value\",\n        \"Display button\": \"Display button\",\n        \"Enter text\": \"Enter text\",\n        \"Field rules\": \"Field rules\",\n        \"Field title\": \"Field title\",\n        \"Hidden field\": \"Hidden field\",\n        \"Layout\": \"Layout\",\n        \"Redirect automatically after submission\": \"Redirect automatically after submission\",\n        \"Redirection\": \"Redirection\",\n        \"Required field\": \"Required field\",\n        \"Submission\": \"Submission\",\n        \"Submit another response\": \"Submit another response\",\n        \"Submit button label\": \"Submit button label\",\n        \"Success text\": \"Success text\",\n        \"Table column name\": \"Table column name\",\n        \"Enter redirect URL\": \"Enter redirect URL\",\n        \"No field selected\": \"No field selected\",\n        \"Select a field in the form widget to configure.\": \"Select a field in the form widget to configure.\",\n        \"Submit\": \"Submit\",\n        \"Thank you! Your response has been recorded.\": \"Thank you! Your response has been recorded.\",\n        \"Chart options\": \"Chart options\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"Copy anchor link\",\n        \"Delete\": \"Delete\",\n        \"Duplicate rows_one\": \"Duplicate row\",\n        \"Duplicate rows_other\": \"Duplicate rows\",\n        \"Insert row\": \"Insert row\",\n        \"Insert row above\": \"Insert row above\",\n        \"Insert row below\": \"Insert row below\",\n        \"View as card\": \"View as card\",\n        \"Use as table headers\": \"Use as table headers\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Copied to clipboard\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"Access Details\",\n        \"Back to current\": \"Back to current\",\n        \"Compare to {{termToUse}}\": \"Compare to {{termToUse}}\",\n        \"Current Version\": \"Current Version\",\n        \"Download\": \"Download\",\n        \"Duplicate document\": \"Duplicate document\",\n        \"Edit without affecting the original\": \"Edit without affecting the original\",\n        \"Export CSV\": \"Export CSV\",\n        \"Export XLSX\": \"Export XLSX\",\n        \"Manage users\": \"Manage users\",\n        \"Original\": \"Original\",\n        \"Replace {{termToUse}}...\": \"Replace {{termToUse}}…\",\n        \"Return to {{termToUse}}\": \"Return to {{termToUse}}\",\n        \"Save copy\": \"Save copy\",\n        \"Save Document\": \"Save Document\",\n        \"Send to Google Drive\": \"Send to Google Drive\",\n        \"Show in folder\": \"Show in folder\",\n        \"Unsaved\": \"Unsaved\",\n        \"Work on a copy\": \"Work on a copy\",\n        \"Share\": \"Share\",\n        \"Download...\": \"Download...\",\n        \"Comma Separated Values (.csv)\": \"Comma Separated Values (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"DOO Separated Values (.dsv)\",\n        \"Export as...\": \"Export as...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Tab Separated Values (.tsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Exporting is only available from document pages. Please select a document page and try again.\",\n        \"Download attachments...\": \"Download attachments...\",\n        \"Download document...\": \"Download document...\",\n        \"Suggest changes\": \"Suggest changes\",\n        \"current version\": \"current version\",\n        \"original\": \"original\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Create new team site\",\n        \"Switch Sites\": \"Switch Sites\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Add column\",\n        \"Empty values last\": \"Empty values last\",\n        \"Natural sort\": \"Natural sort\",\n        \"Update data\": \"Update data\",\n        \"Use choice position\": \"Use choice position\",\n        \"Search Columns\": \"Search columns\",\n        \"Remove sort setting - {{- columnName }} column\": \"Remove sort setting - {{- columnName }} column\",\n        \"Sort in ascending order (current: descending)\": \"Sort in ascending order (current: descending)\",\n        \"Sort in descending order (current: ascending)\": \"Sort in descending order (current: ascending)\",\n        \"Sort options - {{- columnName }} column\": \"Sort options - {{- columnName }} column\",\n        \"{{- columnName }} column\": \"{{- columnName }} column\"\n    },\n    \"SortFilterConfig\": {\n        \"Filter\": \"FILTER\",\n        \"Revert\": \"Revert\",\n        \"Save\": \"Save\",\n        \"Sort\": \"SORT\",\n        \"Update Sort & Filter settings\": \"Update Sort & Filter settings\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Appearance \",\n        \"Switch appearance automatically to match system\": \"Switch appearance automatically to match system\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Access Rules\",\n        \"Code view\": \"Code view\",\n        \"Delete\": \"Delete\",\n        \"Delete document tour?\": \"Delete document tour?\",\n        \"Document history\": \"Document history\",\n        \"How-to Tutorial\": \"How-to Tutorial\",\n        \"Raw data\": \"Raw data\",\n        \"Return to viewing as yourself\": \"Return to viewing as yourself\",\n        \"TOOLS\": \"TOOLS\",\n        \"Tour of this Document\": \"Tour of this Document\",\n        \"Validate Data\": \"Validate Data\",\n        \"Settings\": \"Settings\",\n        \"API console\": \"API console\",\n        \"context menu - Access Rules\": \"context menu - Access Rules\",\n        \"Delete document tour\": \"Delete document tour\",\n        \"Preview the tutorial\": \"Preview the tutorial\",\n        \"Proposed changes\": \"Proposed changes\",\n        \"Suggest changes\": \"Suggest changes\",\n        \"Suggestions\": \"Suggestions\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Manage team\",\n        \"Redo\": \"Redo\",\n        \"Undo\": \"Undo\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"Any field\",\n        \"Apply on changes to:\": \"Apply on changes to:\",\n        \"Apply on record changes\": \"Apply on record changes\",\n        \"Apply to new records\": \"Apply to new records\",\n        \"Cancel\": \"Cancel\",\n        \"Close\": \"Close\",\n        \"Current field \": \"Current field \",\n        \"OK\": \"OK\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"Apply\",\n        \"Cancel\": \"Cancel\",\n        \"Preview\": \"Preview\",\n        \"Revise\": \"Revise\",\n        \"Update formula (Shift+Enter)\": \"Update formula (Shift+Enter)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Editor\",\n        \"In full\": \"In full\",\n        \"No Default Access\": \"No Default Access\",\n        \"None\": \"None\",\n        \"Owner\": \"Owner\",\n        \"View & edit\": \"View & edit\",\n        \"View only\": \"View only\",\n        \"Viewer\": \"Viewer\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Rule {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Update formula (Shift+Enter)\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Unknown User\",\n        \"View as Yourself\": \"View as Yourself\",\n        \"You are viewing this document as\": \"You are viewing this document as\",\n        \"You're seeing what this user would see if given access\": \"You're seeing what this user would see if given access\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Advanced settings\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\",\n        \"Blocks\": \"Blocks\",\n        \"Compact\": \"Compact\",\n        \"Edit card layout\": \"Edit card layout\",\n        \"Form\": \"Form\",\n        \"Make On-Demand\": \"Make On-Demand\",\n        \"Plugin: \": \"Plugin: \",\n        \"Section: \": \"Section: \",\n        \"Unmark On-Demand\": \"Unmark On-Demand\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Deprecated Feature\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Advanced sort & filter\",\n        \"Copy anchor link\": \"Copy anchor link\",\n        \"Data selection\": \"Data selection\",\n        \"Delete record\": \"Delete record\",\n        \"Delete widget\": \"Delete widget\",\n        \"Download as CSV\": \"Download as CSV\",\n        \"Download as XLSX\": \"Download as XLSX\",\n        \"Edit card layout\": \"Edit card layout\",\n        \"Open configuration\": \"Open configuration\",\n        \"Print widget\": \"Print widget\",\n        \"Show raw data\": \"Show raw data\",\n        \"Widget options\": \"Widget options\",\n        \"Add to page\": \"Add to page\",\n        \"Collapse widget\": \"Collapse widget\",\n        \"Create a form\": \"Create a form\",\n        \"Duplicate widget\": \"Duplicate widget\"\n    },\n    \"ViewSectionMenu\": {\n        \"(customized)\": \"(customized)\",\n        \"(empty)\": \"(empty)\",\n        \"(modified)\": \"(modified)\",\n        \"Custom options\": \"Custom options\",\n        \"FILTER\": \"FILTER\",\n        \"Revert\": \"Revert\",\n        \"SORT\": \"SORT\",\n        \"Save\": \"Save\",\n        \"Update Sort&Filter settings\": \"Update Sort&Filter settings\",\n        \"Sort and filter\": \"Sort and filter\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"Cannot drop items into Hidden Fields\",\n        \"Clear\": \"Clear\",\n        \"Hidden Fields cannot be reordered\": \"Hidden Fields cannot be reordered\",\n        \"Select all\": \"Select all\",\n        \"Visible {{label}}\": \"Visible {{label}}\",\n        \"Hide {{label}}\": \"Hide {{label}}\",\n        \"Hidden {{label}}\": \"Hidden {{label}}\",\n        \"Show {{label}}\": \"Show {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Hide {{label}} (batch mode)\",\n        \"Show {{label}} (batch mode)\": \"Show {{label}} (batch mode)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Education\",\n        \"Finance & Accounting\": \"Finance and Accounting\",\n        \"HR & Management\": \"HR and Management\",\n        \"IT & Technology\": \"IT and Technology\",\n        \"Marketing\": \"Marketing\",\n        \"Media Production\": \"Media Production\",\n        \"Other\": \"Other\",\n        \"Product Development\": \"Product Development\",\n        \"Research\": \"Research\",\n        \"Sales\": \"Sales\",\n        \"Type here\": \"Type here\",\n        \"Welcome to Grist!\": \"Welcome to Grist!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"What brings you to Grist? Please help us serve you better.\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"Cancel\",\n        \"DATA TABLE NAME\": \"DATA TABLE NAME\",\n        \"Override widget title\": \"Override widget title\",\n        \"Provide a table name\": \"Provide a table name\",\n        \"Save\": \"Save\",\n        \"WIDGET TITLE\": \"WIDGET TITLE\",\n        \"WIDGET DESCRIPTION\": \"WIDGET DESCRIPTION\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\",\n        \"fiddle\": \"fiddle\",\n        \"override\": \"override\",\n        \"recovery mode\": \"recovery mode\",\n        \"snapshot\": \"snapshot\",\n        \"unsaved\": \"unsaved\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\",\n        \"editing\": \"editing\",\n        \"suggesting\": \"suggesting\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Duplicate page {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Note that this does not copy data, but creates another view of the same data.\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Access denied{{suffix}}\",\n        \"Add account\": \"Add account\",\n        \"Contact support\": \"Contact support\",\n        \"Error{{suffix}}\": \"Error{{suffix}}\",\n        \"Go to main page\": \"Go to main page\",\n        \"Page not found{{suffix}}\": \"Page not found{{suffix}}\",\n        \"Sign in\": \"Sign in\",\n        \"Sign in again\": \"Sign in again\",\n        \"Sign in to access this organization's documents.\": \"Sign in to access this organization's documents.\",\n        \"Signed out{{suffix}}\": \"Signed out{{suffix}}\",\n        \"Something went wrong\": \"Something went wrong\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Could not find the requested page.{{separator}}Please check the URL and try again.\",\n        \"There was an error: {{message}}\": \"There was an error: {{message}}\",\n        \"There was an unknown error.\": \"There was an unknown error.\",\n        \"You are now signed out.\": \"You are now signed out.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\",\n        \"You do not have access to this organization's documents.\": \"You do not have access to this organization's documents.\",\n        \"Account deleted{{suffix}}\": \"Account deleted{{suffix}}\",\n        \"Sign up\": \"Sign up\",\n        \"Your account has been deleted.\": \"Your account has been deleted.\",\n        \"An unknown error occurred.\": \"An unknown error occurred.\",\n        \"Build your own form\": \"Build your own form\",\n        \"Form not found\": \"Form not found\",\n        \"Powered by\": \"Powered by\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Failed to log in.{{separator}}Please try again or contact support.\",\n        \"Sign-in failed{{suffix}}\": \"Sign-in failed{{suffix}}\",\n        \"Manage settings\": \"Manage settings\",\n        \"Need Help?\": \"Need Help?\",\n        \"There was an error\": \"There was an error\",\n        \"Unsubscribed{{suffix}}\": \"Unsubscribed{{suffix}}\",\n        \"We could not unsubscribe you\": \"We could not unsubscribe you\",\n        \"You are unsubscribed\": \"You are unsubscribed\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"You can still unsubscribe from this document by updating your preferences in the document settings\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\",\n        \"changes\": \"changes\",\n        \"comments\": \"comments\",\n        \"this document\": \"this document\",\n        \"your email\": \"your email\",\n        \"You will no longer receive email notifications about {{suggestions}} in {{docName}} at {{email}}.\": \"You will no longer receive email notifications about {{suggestions}} in {{docName}} at {{email}}.\",\n        \"suggestions\": \"suggestions\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* Workspaces are available on team plans. \",\n        \"Select fields\": \"Select fields\",\n        \"Upgrade now\": \"Upgrade now\",\n        \"Any\": \"Any\",\n        \"Numeric\": \"Numeric\",\n        \"Text\": \"Text\",\n        \"Integer\": \"Integer\",\n        \"Toggle\": \"Toggle\",\n        \"Date\": \"Date\",\n        \"DateTime\": \"DateTime\",\n        \"Choice\": \"Choice\",\n        \"Choice List\": \"Choice List\",\n        \"Reference\": \"Reference\",\n        \"Reference List\": \"Reference List\",\n        \"Attachment\": \"Attachment\",\n        \"Search columns\": \"Search columns\",\n        \"By Name\": \"By Name\",\n        \"By Date Modified\": \"By date Modified\",\n        \"Light\": \"Light\",\n        \"Custom\": \"Custom\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Cancel\",\n        \"Ok\": \"OK\",\n        \"Save\": \"Save\",\n        \"Are you sure you want to delete these records?\": \"Are you sure you want to delete these records?\",\n        \"Are you sure you want to delete this record?\": \"Are you sure you want to delete this record?\",\n        \"Delete\": \"Delete\",\n        \"Dismiss\": \"Dismiss\",\n        \"Don't ask again.\": \"Don't ask again.\",\n        \"Don't show again.\": \"Don't show again.\",\n        \"Don't show tips\": \"Don't show tips\",\n        \"Undo to restore\": \"Undo to restore\",\n        \"Got it\": \"Got it\",\n        \"Don't show again\": \"Don't show again\",\n        \"TIP\": \"TIP\",\n        \"Confirm\": \"Confirm\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Duplicate page\",\n        \"Remove\": \"Remove\",\n        \"Rename\": \"Rename\",\n        \"You do not have edit access to this document\": \"You do not have edit access to this document\",\n        \"(default)\": \"(default)\",\n        \"Collapse {{maybeDefault}}\": \"Collapse {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Expand {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Set default: Collapse\",\n        \"Set default: Expand\": \"Set default: Expand\",\n        \"context menu - {{- pageName }}\": \"context menu - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Find Next \": \"Find Next \",\n        \"Find Previous \": \"Find Previous \",\n        \"No results\": \"No results\",\n        \"Search in document\": \"Search in document\",\n        \"Search\": \"Search\",\n        \"Close search bar\": \"Close search bar\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Sending file to Google Drive\"\n    },\n    \"NTextBox\": {\n        \"false\": \"false\",\n        \"true\": \"true\",\n        \"Field Format\": \"Field Format\",\n        \"Lines\": \"Lines\",\n        \"Multi line\": \"Multi line\",\n        \"Single line\": \"Single line\",\n        \"Maximum characters\": \"Maximum characters\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"Example Users\",\n        \"Users from table\": \"Users from table\",\n        \"View as\": \"View as\",\n        \"Other users from table\": \"Other users from table\",\n        \"Shared users\": \"Shared users\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Apply\",\n        \"Cancel\": \"Cancel\",\n        \"Preview\": \"Preview\",\n        \"Revise\": \"Revise\",\n        \"Update formula (Shift+Enter)\": \"Update formula (Shift+Enter)\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"CELL STYLE\",\n        \"Cell style\": \"Cell style\",\n        \"Default cell style\": \"Default cell style\",\n        \"Mixed style\": \"Mixed style\",\n        \"Open row styles\": \"Open row styles\",\n        \"Default header style\": \"Default header style\",\n        \"Header Style\": \"Header Style\",\n        \"HEADER STYLE\": \"HEADER STYLE\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"CHOICES\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"COLUMN DESCRIPTION\",\n        \"COLUMN LABEL\": \"COLUMN LABEL\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN DESCRIPTION\": \"COLUMN DESCRIPTION\",\n        \"COLUMN ID: \": \"COLUMN ID: \",\n        \"COLUMN LABEL\": \"COLUMN LABEL\",\n        \"Cancel\": \"Cancel\",\n        \"Save\": \"Save\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"Add another rule\",\n        \"Add conditional style\": \"Add conditional style\",\n        \"Error in style rule\": \"Error in style rule\",\n        \"Row style\": \"Row style\",\n        \"Rule must return True or False\": \"Rule must return True or False\",\n        \"Conditional Style\": \"Conditional Style\",\n        \"IF...\": \"IF...\",\n        \"Row Style\": \"Row Style\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Invalid currency\"\n    },\n    \"DiscussionEditor\": {\n        \"{{count}} comments_one\": \"{{count}} comment\",\n        \"{{count}} comments_other\": \"{{count}} comments\",\n        \"Cancel\": \"Cancel\",\n        \"Comment\": \"Comment\",\n        \"Copy link\": \"Copy link\",\n        \"Edit\": \"Edit\",\n        \"Marked as resolved\": \"Marked as resolved\",\n        \"Only current page\": \"Only current page\",\n        \"Only my threads\": \"Only my threads\",\n        \"Open\": \"Open\",\n        \"Remove\": \"Remove\",\n        \"Reply\": \"Reply\",\n        \"Reply to a comment\": \"Reply to a comment\",\n        \"Resolve\": \"Resolve\",\n        \"Save\": \"Save\",\n        \"Show resolved comments\": \"Show resolved comments\",\n        \"Showing last {{nb}} comments\": \"Showing last {{nb}} comments\",\n        \"Started discussion\": \"Started discussion\",\n        \"Write a comment\": \"Write a comment\",\n        \"Remove thread\": \"Remove thread\",\n        \"updated\": \"updated\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Convert column to formula\"\n    },\n    \"FieldBuilder\": {\n        \"Apply formula to data\": \"Apply formula to data\",\n        \"CELL FORMAT\": \"CELL FORMAT\",\n        \"Changing multiple column types\": \"Changing multiple column types\",\n        \"DATA FROM TABLE\": \"DATA FROM TABLE\",\n        \"Mixed format\": \"Mixed format\",\n        \"Mixed types\": \"Mixed types\",\n        \"Revert field settings for {{colId}} to common\": \"Revert field settings for {{colId}} to common\",\n        \"Save field settings for {{colId}} as common\": \"Save field settings for {{colId}} as common\",\n        \"Use separate field settings for {{colId}}\": \"Use separate field settings for {{colId}}\",\n        \"Changing column type\": \"Changing column type\",\n        \"Common\": \"Common\",\n        \"Separate\": \"Separate\",\n        \"Field in {{count}} views_one\": \"Field in one view\",\n        \"Field in {{count}} views_other\": \"Field in {{count}} views\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"It should be impossible to save a plain data value into a formula column\",\n        \"Unable to finish saving edited cell\": \"Unable to finish saving edited cell\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Column or field is required\",\n        \"Error in the cell\": \"Error in the cell\",\n        \"Errors in all {{numErrors}} cells\": \"Errors in all {{numErrors}} cells\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Errors in {{numErrors}} of {{numCells}} cells\",\n        \"editingFormula is required\": \"editingFormula is required\",\n        \"Enter formula or {{button}}.\": \"Enter formula or {{button}}.\",\n        \"Enter formula.\": \"Enter formula.\",\n        \"Expand Editor\": \"Expand Editor\",\n        \"use AI Assistant\": \"use AI Assistant\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[link label] URL\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"Currency\",\n        \"Decimals\": \"Decimals\",\n        \"Default currency ({{defaultCurrency}})\": \"Default currency ({{defaultCurrency}})\",\n        \"Number Format\": \"Number Format\",\n        \"Field Format\": \"Field Format\",\n        \"Spinner\": \"Spinner\",\n        \"Text\": \"Text\",\n        \"max\": \"max\",\n        \"min\": \"min\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"CELL FORMAT\",\n        \"Row ID\": \"Row ID\",\n        \"SHOW COLUMN\": \"SHOW COLUMN\"\n    },\n    \"WelcomeTour\": {\n        \"Add new\": \"Add new\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\",\n        \"Building up\": \"Building up\",\n        \"Configuring your document\": \"Configuring your document\",\n        \"Customizing columns\": \"Customizing columns\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Double-click or hit {{enter}} on a cell to edit it. \",\n        \"Editing Data\": \"Editing Data\",\n        \"Enter\": \"Enter\",\n        \"Flying higher\": \"Flying higher\",\n        \"Help Center\": \"Help Center\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Make it relational! Use the {{ref}} type to link tables. \",\n        \"Reference\": \"Reference\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \",\n        \"Share\": \"Share\",\n        \"Sharing\": \"Sharing\",\n        \"Start with {{equal}} to enter a formula.\": \"Start with {{equal}} to enter a formula.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Toggle the {{creatorPanel}} to format columns, \",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Use the Share button ({{share}}) to share the document or export data.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Use {{addNew}} to add widgets, pages, or import more data. \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Use {{helpCenter}} for documentation or questions.\",\n        \"Welcome to Grist!\": \"Welcome to Grist!\",\n        \"convert to card view, select data, and more.\": \"convert to card view, select data, and more.\",\n        \"creator panel\": \"creator panel\",\n        \"template library\": \"template library\",\n        \"AI Assistant\": \"AI Assistant\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Language\"\n    },\n    \"GristTooltips\": {\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Apply conditional formatting to cells in this column when formula conditions are met.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Apply conditional formatting to rows based on formulas.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Click on “Open row styles” to apply conditional formatting to rows.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Click the Add new button to create new documents or workspaces, or import data.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\",\n        \"Editing Card Layout\": \"Editing Card Layout\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Formulas that trigger in certain cases, and store the calculated value as data.\",\n        \"Learn more.\": \"Learn more.\",\n        \"Link your new widget to an existing widget on this page.\": \"Link your new widget to an existing widget on this page.\",\n        \"Linking Widgets\": \"Linking Widgets\",\n        \"Nested Filtering\": \"Nested Filtering\",\n        \"Only those rows will appear which match all of the filters.\": \"Only those rows will appear which match all of the filters.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Pinned filters are displayed as buttons above the widget.\",\n        \"Pinning Filters\": \"Pinning Filters\",\n        \"Raw Data page\": \"Raw Data page\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Rearrange the fields in your card by dragging and resizing cells.\",\n        \"Reference Columns\": \"Reference Columns\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Reference columns are the key to {{relational}} data in Grist.\",\n        \"Select the table containing the data to show.\": \"Select the table containing the data to show.\",\n        \"Select the table to link to.\": \"Select the table to link to.\",\n        \"Selecting Data\": \"Selecting Data\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\",\n        \"The total size of all data in this document, excluding attachments.\": \"The total size of all data in this document, excluding attachments.\",\n        \"They allow for one record to point (or refer) to another.\": \"They allow for one record to point (or refer) to another.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"This is the secret to Grist's dynamic and productive layouts.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Try out changes in a copy, then decide whether to replace the original with your edits.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Unpin to hide the button while keeping the filter.\",\n        \"Updates every 5 minutes.\": \"Updates every 5 minutes.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\",\n        \"You can filter by more than one column.\": \"You can filter by more than one column.\",\n        \"entire\": \"entire\",\n        \"relational\": \"relational\",\n        \"Access Rules\": \"Access Rules\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\",\n        \"Add new\": \"Add new\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\",\n        \"Anchor Links\": \"Anchor Links\",\n        \"Custom Widgets\": \"Custom Widgets\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\",\n        \"Calendar\": \"Calendar\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"To configure your calendar, select columns for start/end dates and event titles. Note each column's type.\"\n        },\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\",\n        \"Lookups return data from related tables.\": \"Lookups return data from related tables.\",\n        \"Use reference columns to relate data in different tables.\": \"Use reference columns to relate data in different tables.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\",\n        \"Forms are here!\": \"Forms are here!\",\n        \"Learn more\": \"Learn more\",\n        \"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.\",\n        \"Example: {{example}}\": \"Example: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"Filter displayed dropdown values with a condition.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Community widgets are created and maintained by Grist community members.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Creates a reverse column in target table that can be edited from either end.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"To allow multiple assignments, change the type of the Reference column to Reference List.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"This limitation occurs when one column in a two-way reference has the Reference type.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"To allow multiple assignments, change the referenced column's type to Reference List.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Two-way references are not currently supported for Formula or Trigger Formula columns\",\n        \"The preview below this header shows how the selected user will see this document\": \"The preview below this header shows how the selected user will see this document\",\n        \"[Learn more.]({{link}})\": \"[Learn more.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Summary tables can only contain formula columns.\",\n        \"Manage users and resources in a Grist installation.\": \"Manage users and resources in a Grist installation.\",\n        \"The new Grist Assistant is here!\": \"The new Grist Assistant is here!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Formulas support many Excel functions and full Python syntax.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\",\n        \"Set the maximum number of lines for multi-line text.\": \"Set the maximum number of lines for multi-line text.\",\n        \"Comments are here!\": \"Comments are here!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"When checked, this field’s default value can be prefilled from the URL using query parameters.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\",\n        \"Unpin to hide the button while keeping the filter.\": \"Unpin to hide the button while keeping the filter.\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"DESCRIPTION\",\n        \"Set description\": \"Set description\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"Close Creator Panel\",\n        \"Open creator panel\": \"Open creator panel\",\n        \"Creator panel (right panel)\": \"Creator panel (right panel)\",\n        \"Document header\": \"Document header\",\n        \"Main content\": \"Main content\",\n        \"Main navigation and document settings (left panel)\": \"Main navigation and document settings (left panel)\",\n        \"Close navigation panel (left panel)\": \"Close navigation panel (left panel)\",\n        \"Open navigation panel (left panel)\": \"Open navigation panel (left panel)\"\n    },\n    \"ColumnTitle\": {\n        \"Add description\": \"Add description\",\n        \"COLUMN ID: \": \"COLUMN ID: \",\n        \"Cancel\": \"Cancel\",\n        \"Column ID copied to clipboard\": \"Column ID copied to clipboard\",\n        \"Column description\": \"Column description\",\n        \"Column label\": \"Column label\",\n        \"Provide a column label\": \"Provide a column label\",\n        \"Save\": \"Save\",\n        \"Close\": \"Close\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Got it\",\n        \"Unavailable Command\": \"Unavailable Command\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"Clear field\",\n        \"Copy\": \"Copy\",\n        \"Copy anchor link\": \"Copy anchor link\",\n        \"Cut\": \"Cut\",\n        \"Hide field\": \"Hide field\",\n        \"Paste\": \"Paste\",\n        \"Comment\": \"Comment\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Clear queue\",\n        \"Webhook settings\": \"Webhook settings\",\n        \"Cleared webhook queue.\": \"Cleared webhook queue.\",\n        \"Columns to check when update (separated by ;)\": \"Columns to check when update (separated by ;)\",\n        \"Enabled\": \"Enabled\",\n        \"Event Types\": \"Event Types\",\n        \"Memo\": \"Memo\",\n        \"Name\": \"Name\",\n        \"Ready Column\": \"Ready Column\",\n        \"Removed webhook.\": \"Removed webhook.\",\n        \"Sorry, not all fields can be edited.\": \"Sorry, not all fields can be edited.\",\n        \"Status\": \"Status\",\n        \"URL\": \"URL\",\n        \"Webhook Id\": \"Webhook Id\",\n        \"Table\": \"Table\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Filter for changes in these columns (semicolon-separated ids)\",\n        \"Header Authorization\": \"Header Authorization\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooks Unavailable In Unsaved Document Copies\"\n    },\n    \"FormulaAssistant\": {\n        \"Ask the bot.\": \"Ask the bot.\",\n        \"Capabilities\": \"Capabilities\",\n        \"Community\": \"Community\",\n        \"Data\": \"Data\",\n        \"Formula Cheat Sheet\": \"Formula Cheat Sheet\",\n        \"Formula Help. \": \"Formula Help. \",\n        \"Function List\": \"Function List\",\n        \"Grist's AI Assistance\": \"Grist's AI Assistance\",\n        \"Grist's AI Formula Assistance. \": \"Grist's AI Formula Assistance. \",\n        \"Need help? Our AI assistant can help.\": \"Need help? Our AI assistant can help.\",\n        \"New Chat\": \"New Chat\",\n        \"Preview\": \"Preview\",\n        \"Regenerate\": \"Regenerate\",\n        \"Save\": \"Save\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\",\n        \"Tips\": \"Tips\",\n        \"AI Assistant\": \"AI Assistant\",\n        \"Apply\": \"Apply\",\n        \"Cancel\": \"Cancel\",\n        \"Clear conversation\": \"Clear conversation\",\n        \"Code view\": \"Code view\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Hi, I'm the Grist Formula AI Assistant.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\",\n        \"Learn more\": \"Learn more\",\n        \"Press Enter to apply suggested formula.\": \"Press Enter to apply suggested formula.\",\n        \"Sign Up for Free\": \"Sign Up for Free\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Sign up for a free Grist account to start using the Formula AI Assistant.\",\n        \"There are some things you should know when working with me:\": \"There are some things you should know when working with me:\",\n        \"What do you need help with?\": \"What do you need help with?\",\n        \"Formula AI Assistant is only available for logged in users.\": \"Formula AI Assistant is only available for logged in users.\",\n        \"For higher limits, contact the site owner.\": \"For higher limits, contact the site owner.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"For higher limits, {{upgradeNudge}}.\",\n        \"You have used all available credits.\": \"You have used all available credits.\",\n        \"You have {{numCredits}} remaining credits.\": \"You have {{numCredits}} remaining credits.\",\n        \"upgrade to the Pro Team plan\": \"upgrade to the Pro Team plan\",\n        \"upgrade your plan\": \"upgrade your plan\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Click to insert\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"Welcome back\",\n        \"You can always switch sites using the account menu.\": \"You can always switch sites using the account menu.\",\n        \"You have access to the following Grist sites.\": \"You have access to the following Grist sites.\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"DESCRIPTION\"\n    },\n    \"UserManager\": {\n        \"Add {{member}} to your team\": \"Add {{member}} to your team\",\n        \"Allow anyone with the link to open.\": \"Allow anyone with the link to open.\",\n        \"Anyone with link \": \"Anyone with link \",\n        \"Cancel\": \"Cancel\",\n        \"Close\": \"Close\",\n        \"Collaborator\": \"Collaborator\",\n        \"Confirm\": \"Confirm\",\n        \"Copy link\": \"Copy link\",\n        \"Create a team to share with more people\": \"Create a team to share with more people\",\n        \"Grist support\": \"Grist support\",\n        \"Guest\": \"Guest\",\n        \"Invite multiple\": \"Invite multiple\",\n        \"Invite people to {{resourceType}}\": \"Invite people to {{resourceType}}\",\n        \"Link copied to clipboard\": \"Link copied to clipboard\",\n        \"Manage members of team site\": \"Manage members of team site\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\",\n        \"Off\": \"Off\",\n        \"On\": \"On\",\n        \"Once you have removed your own access,             you will not be able to get it back without assistance              from someone else with sufficient access to the {{name}}.\": \"Once you have removed your own access,             you will not be able to get it back without assistance              from someone else with sufficient access to the {{name}}.\",\n        \"Open Access Rules\": \"Open Access Rules\",\n        \"Outside collaborator\": \"Outside collaborator\",\n        \"Public access\": \"Public access\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\",\n        \"Public access: \": \"Public access: \",\n        \"Remove my access\": \"Remove my access\",\n        \"Save & \": \"Save & \",\n        \"Team member\": \"Team member\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\",\n        \"User may not modify their own access.\": \"User may not modify their own access.\",\n        \"Your role for this team site\": \"Your role for this team site\",\n        \"Your role for this {{resourceType}}\": \"Your role for this {{resourceType}}\",\n        \"free collaborator\": \"free collaborator\",\n        \"guest\": \"guest\",\n        \"member\": \"member\",\n        \"team site\": \"team site\",\n        \"{{collaborator}} limit exceeded\": \"{{collaborator}} limit exceeded\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} of {{limitTop}} {{collaborator}}s\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\",\n        \"Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.\": \"Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.\",\n        \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\": \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"You are about to remove your own access to this {{resourceType}}\",\n        \"Inherit access: \": \"Inherit access: \",\n        \"Access overview\": \"Access overview\",\n        \"Share it publicly\": \"Share it publicly\",\n        \"Verify your sensitive data before sharing publicly\": \"Verify your sensitive data before sharing publicly\",\n        \"Your {{resourceType}} will be accessible to anyone with the link, whether shared directly or found through a search engine. \\n Ensure that your {{resourceType}} does not contain sensitive data before sharing.\": \"Your {{resourceType}} will be accessible to anyone with the link, whether shared directly or found through a search engine. \\n Ensure that your {{resourceType}} does not contain sensitive data before sharing.\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Search all pages\",\n        \"Search all tables\": \"Search all tables\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Search\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\"\n    },\n    \"SupportGristNudge\": {\n        \"Close\": \"Close\",\n        \"Contribute\": \"Contribute\",\n        \"Help Center\": \"Help Center\",\n        \"Opt in to Telemetry\": \"Opt in to Telemetry\",\n        \"Opted In\": \"Opted In\",\n        \"Support Grist\": \"Support Grist\",\n        \"Support Grist page\": \"Support Grist page\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\",\n        \"Admin Panel\": \"Admin Panel\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"GitHub Sponsors page\",\n        \"Help Center\": \"Help Center\",\n        \"Home\": \"Home\",\n        \"Manage Sponsorship\": \"Manage Sponsorship\",\n        \"Opt in to Telemetry\": \"Opt in to Telemetry\",\n        \"Opt out of Telemetry\": \"Opt out of Telemetry\",\n        \"Sponsor Grist Labs on GitHub\": \"Sponsor Grist Labs on GitHub\",\n        \"Support Grist\": \"Support Grist\",\n        \"Telemetry\": \"Telemetry\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\",\n        \"You can opt out of telemetry at any time from this page.\": \"You can opt out of telemetry at any time from this page.\",\n        \"You have opted in to telemetry. Thank you!\": \"You have opted in to telemetry. Thank you!\",\n        \"You have opted out of telemetry.\": \"You have opted out of telemetry.\",\n        \"Sponsor\": \"Sponsor\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"You can support Grist open-source development by sponsoring us on our {{link}}.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"No data\",\n        \"No row selected in {{title}}\": \"No row selected in {{title}}\",\n        \"Not all data is shown\": \"Not all data is shown\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Collapse Editor\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Maximize\",\n        \"Minimize\": \"Minimize\"\n    },\n    \"CardContextMenu\": {\n        \"Copy anchor link\": \"Copy anchor link\",\n        \"Delete card\": \"Delete card\",\n        \"Duplicate card\": \"Duplicate card\",\n        \"Insert card\": \"Insert card\",\n        \"Insert card above\": \"Insert card above\",\n        \"Insert card below\": \"Insert card below\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Hidden fields\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"free coaching call\",\n        \"Maybe later\": \"Maybe later\",\n        \"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.\": \"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.\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Schedule your {{freeCoachingCall}} with a member of our team.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\",\n        \"our weekly webinars\": \"our weekly webinars\",\n        \"Free coaching call\": \"Free coaching call\",\n        \"Grist 101\": \"Grist 101\",\n        \"Schedule call\": \"Schedule call\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\"\n    },\n    \"FormView\": {\n        \"Publish\": \"Publish\",\n        \"Publish your form?\": \"Publish your form?\",\n        \"Unpublish\": \"Unpublish\",\n        \"Unpublish your form?\": \"Unpublish your form?\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Anyone with the link below can see the empty form and submit a response.\",\n        \"Are you sure you want to reset your form?\": \"Are you sure you want to reset your form?\",\n        \"Code copied to clipboard\": \"Code copied to clipboard\",\n        \"Copy code\": \"Copy code\",\n        \"Copy link\": \"Copy link\",\n        \"Embed this form\": \"Embed this form\",\n        \"Link copied to clipboard\": \"Link copied to clipboard\",\n        \"Preview\": \"Preview\",\n        \"Reset\": \"Reset\",\n        \"Reset form\": \"Reset form\",\n        \"Save your document to publish this form.\": \"Save your document to publish this form.\",\n        \"Share\": \"Share\",\n        \"Share this form\": \"Share this form\",\n        \"View\": \"View\",\n        \"# **Form Title**\": \"# **Form Title**\",\n        \"Your form description goes here.\": \"Your form description goes here.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Delete\"\n    },\n    \"Menu\": {\n        \"Building blocks\": \"Building blocks\",\n        \"Columns\": \"Columns\",\n        \"Copy\": \"Copy\",\n        \"Cut\": \"Cut\",\n        \"Insert question above\": \"Insert question above\",\n        \"Insert question below\": \"Insert question below\",\n        \"Paragraph\": \"Paragraph\",\n        \"Paste\": \"Paste\",\n        \"Separator\": \"Separator\",\n        \"Unmapped fields\": \"Unmapped fields\",\n        \"Header\": \"Header\",\n        \"New question\": \"New question\",\n        \"More\": \"More\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Clear\": \"Clear\",\n        \"Map fields\": \"Map fields\",\n        \"Mapped\": \"Mapped\",\n        \"Select all\": \"Select all\",\n        \"Unmap fields\": \"Unmap fields\",\n        \"Unmapped\": \"Unmapped\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Field rules\",\n        \"Required field\": \"Required field\",\n        \"Ascending\": \"Ascending\",\n        \"Default\": \"Default\",\n        \"Descending\": \"Descending\",\n        \"Field Format\": \"Field Format\",\n        \"Field Rules\": \"Field Rules\",\n        \"Horizontal\": \"Horizontal\",\n        \"Options Alignment\": \"Options Alignment\",\n        \"Options Sort Order\": \"Options Sort Order\",\n        \"Radio\": \"Radio\",\n        \"Select\": \"Select\",\n        \"Vertical\": \"Vertical\",\n        \"Accept value from URL\": \"Accept value from URL\",\n        \"Hidden field\": \"Hidden field\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"URL parameter:\\n{{colId}}=VALUE\",\n        \"Options limit\": \"Options limit\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Some required columns aren't mapped\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"To use this widget, please map all non-optional columns from the creator panel on the right.\",\n        \"Some required columns are hidden by access rules\": \"Some required columns are hidden by access rules\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Build your own form\",\n        \"Powered by\": \"Powered by\",\n        \"Powered by Grist\": \"Powered by Grist\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Error\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"Oops! The form you're looking for doesn't exist.\",\n        \"Oops! This form is no longer published.\": \"Oops! This form is no longer published.\",\n        \"There was a problem loading the form.\": \"There was a problem loading the form.\",\n        \"You don't have access to this form.\": \"You don't have access to this form.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"There was an error submitting your form. Please try again.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Form Submitted\",\n        \"Thank you! Your response has been recorded.\": \"Thank you! Your response has been recorded.\",\n        \"Submit new response\": \"Submit new response\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"Last 30 days\",\n        \"Last 7 days\": \"Last 7 days\",\n        \"Last week\": \"Last week\",\n        \"Next 7 days\": \"Next 7 days\",\n        \"This month\": \"This month\",\n        \"This week\": \"This week\",\n        \"This year\": \"This year\",\n        \"Today\": \"Today\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Clear\",\n        \"Map fields\": \"Map fields\",\n        \"Mapped\": \"Mapped\",\n        \"Select all\": \"Select all\",\n        \"Unmap fields\": \"Unmap fields\",\n        \"Unmapped\": \"Unmapped\",\n        \"Hide {{label}}\": \"Hide {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Hide {{label}} (batch mode)\",\n        \"Unmap {{label}}\": \"Unmap {{label}}\",\n        \"Unmap {{label}} (batch mode)\": \"Unmap {{label}} (batch mode)\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Insert section above\",\n        \"Insert section below\": \"Insert section below\",\n        \"## **Header**\": \"## **Header**\",\n        \"Description\": \"Description\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Cancel\",\n        \"Choose a name and url for your team site\": \"Choose a name and url for your team site\",\n        \"Create site\": \"Create site\",\n        \"Domain name is invalid\": \"Domain name is invalid\",\n        \"Domain name is required\": \"Domain name is required\",\n        \"Go to your site\": \"Go to your site\",\n        \"Team name\": \"Team name\",\n        \"Team name is required\": \"Team name is required\",\n        \"Team site created\": \"Team site created\",\n        \"Team url\": \"Team url\",\n        \"Work as a Team\": \"Work as a Team\",\n        \"Billing is not supported in grist-core\": \"Billing is not supported in grist-core\"\n    },\n    \"AdminPanel\": {\n        \"Admin Panel\": \"Admin Panel\",\n        \"Current\": \"Current\",\n        \"Current version of Grist\": \"Current version of Grist\",\n        \"Help us make Grist better\": \"Help us make Grist better\",\n        \"Home\": \"Home\",\n        \"Sponsor\": \"Sponsor\",\n        \"Support Grist\": \"Support Grist\",\n        \"Support Grist Labs on GitHub\": \"Support Grist Labs on GitHub\",\n        \"Telemetry\": \"Telemetry\",\n        \"Version\": \"Version\",\n        \"Auto-check when this page loads\": \"Auto-check when this page loads\",\n        \"Check now\": \"Check now\",\n        \"Checking for updates...\": \"Checking for updates...\",\n        \"Error\": \"Error\",\n        \"Error checking for updates\": \"Error checking for updates\",\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 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.\",\n        \"Grist is up to date\": \"Grist is up to date\",\n        \"Grist releases are at \": \"Grist releases are at \",\n        \"Last checked {{time}}\": \"Last checked {{time}}\",\n        \"Learn more.\": \"Learn more.\",\n        \"Newer version available\": \"Newer version available\",\n        \"No information available\": \"No information available\",\n        \"OK\": \"OK\",\n        \"Sandbox settings for data engine\": \"Sandbox settings for data engine\",\n        \"Sandboxing\": \"Sandboxing\",\n        \"Security Settings\": \"Security Settings\",\n        \"Updates\": \"Updates\",\n        \"unconfigured\": \"unconfigured\",\n        \"unknown\": \"unknown\",\n        \"Administrator Panel Unavailable\": \"Administrator Panel Unavailable\",\n        \"Authentication\": \"Authentication\",\n        \"Check failed.\": \"Check failed.\",\n        \"Check succeeded.\": \"Check succeeded.\",\n        \"Current authentication method\": \"Current authentication method\",\n        \"Details\": \"Details\",\n        \"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.\",\n        \"No fault detected.\": \"No fault detected.\",\n        \"Notes\": \"Notes\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\",\n        \"Results\": \"Results\",\n        \"Self Checks\": \"Self Checks\",\n        \"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.\",\n        \"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.\",\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 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.\",\n        \"Key to sign sessions with\": \"Key to sign sessions with\",\n        \"Session Secret\": \"Session Secret\",\n        \"Enable Grist Enterprise\": \"Enable Grist Enterprise\",\n        \"Enterprise\": \"Enterprise\",\n        \"checking\": \"checking\",\n        \"Audit Logs\": \"Audit Logs\",\n        \"Contact us\": \"Contact us\",\n        \"Log Streaming\": \"Log Streaming\",\n        \"New, Enterprise\": \"New, Enterprise\",\n        \"Off\": \"Off\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\",\n        \"{{count}} admin accounts_one\": \"{{count}} admin account\",\n        \"{{count}} admin accounts_other\": \"{{count}} admin accounts\",\n        \"On\": \"On\",\n        \"Grist Instance\": \"Grist Instance\",\n        \"Auto-check weekly\": \"Auto-check weekly\",\n        \"No record of last version check\": \"No record of last version check\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\",\n        \"auth error\": \"auth error\",\n        \"configured\": \"configured\",\n        \"default\": \"default\",\n        \"more...\": \"more...\",\n        \"no authentication\": \"no authentication\",\n        \"unavailable\": \"unavailable\",\n        \"Admin account not found\": \"Admin account not found\",\n        \"Administrative accounts\": \"Administrative accounts\",\n        \"The users with administrative accounts\": \"The users with administrative accounts\",\n        \"Version {{versionNumber}}\": \"Version {{versionNumber}}\",\n        \"no admin accounts\": \"no admin accounts\",\n        \"Are you sure you want to restart Grist?\": \"Are you sure you want to restart Grist?\",\n        \"Grist is running in an environment that doesn't support restarting from the admin panel.\": \"Grist is running in an environment that doesn't support restarting from the admin panel.\",\n        \"Restart\": \"Restart\",\n        \"Restart Grist\": \"Restart Grist\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"Restart Grist to apply pending changes or resolve issues.\",\n        \"Restart Grist?\": \"Restart Grist?\",\n        \"Restarting Grist...\": \"Restarting Grist...\",\n        \"This will apply any pending changes and briefly interrupt access for all users.\": \"This will apply any pending changes and briefly interrupt access for all users.\",\n        \"You can still restart Grist manually.\": \"You can still restart Grist manually.\",\n        \"error in {{provider}}: {{verdict}}\": \"error in {{provider}}: {{verdict}}\",\n        \"Please restart Grist manually.\": \"Please restart Grist manually.\",\n        \"Restart Grist to apply pending changes.\": \"Restart Grist to apply pending changes.\",\n        \"Restart unavailable\": \"Restart unavailable\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Remove Column\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"No choices configured\",\n        \"No values in show column of referenced table\": \"No values in show column of referenced table\",\n        \"Hide\": \"Hide\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"Checkbox\",\n        \"Field Format\": \"Field Format\",\n        \"Switch\": \"Switch\"\n    },\n    \"ChoiceEditor\": {\n        \"Error in dropdown condition\": \"Error in dropdown condition\",\n        \"No choices matching condition\": \"No choices matching condition\",\n        \"No choices to select\": \"No choices to select\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"Error in dropdown condition\",\n        \"No choices matching condition\": \"No choices matching condition\",\n        \"No choices to select\": \"No choices to select\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"Dropdown Condition\",\n        \"Invalid columns: {{colIds}}\": \"Invalid columns: {{colIds}}\",\n        \"Set dropdown condition\": \"Set dropdown condition\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Enter condition.\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Error in dropdown condition\",\n        \"No choices matching condition\": \"No choices matching condition\",\n        \"No choices to select\": \"No choices to select\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"Reset\",\n        \"Search\": \"Search\",\n        \"Select...\": \"Select...\",\n        \"Submit\": \"Submit\",\n        \"Submitting…\": \"Submitting…\",\n        \"Clear selection for: {{-inputLabel}}\": \"Clear selection for: {{-inputLabel}}\"\n    },\n    \"widgetTypesMap\": {\n        \"Calendar\": \"Calendar\",\n        \"Card\": \"Card\",\n        \"Card List\": \"Card List\",\n        \"Chart\": \"Chart\",\n        \"Custom\": \"Custom\",\n        \"Form\": \"Form\",\n        \"Table\": \"Table\"\n    },\n    \"TimingPage\": {\n        \"Average Time (s)\": \"Average Time (s)\",\n        \"Column ID\": \"Column ID\",\n        \"Formula timer\": \"Formula timer\",\n        \"Loading timing data. Don't close this tab.\": \"Loading timing data. Don't close this tab.\",\n        \"Max Time (s)\": \"Max Time (s)\",\n        \"Number of Calls\": \"Number of Calls\",\n        \"Table ID\": \"Table ID\",\n        \"Total Time (s)\": \"Total Time (s)\"\n    },\n    \"DocTutorial\": {\n        \"Click to expand\": \"Click to expand\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Do you want to restart the tutorial? All progress will be lost.\",\n        \"End tutorial\": \"End tutorial\",\n        \"Finish\": \"Finish\",\n        \"Next\": \"Next\",\n        \"Previous\": \"Previous\",\n        \"Restart\": \"Restart\"\n    },\n    \"OnboardingCards\": {\n        \"3 minute video tour\": \"3 minute video tour\",\n        \"Complete our basics tutorial\": \"Complete our basics tutorial\",\n        \"Complete the tutorial\": \"Complete the tutorial\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Learn the basic of reference columns, linked widgets, column types, & cards.\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Learn the basics of reference columns, linked widgets, column types, & cards.\"\n    },\n    \"OnboardingPage\": {\n        \"Back\": \"Back\",\n        \"Discover Grist in 3 minutes\": \"Discover Grist in 3 minutes\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Go hands-on with the Grist Basics tutorial\",\n        \"Go to the tutorial!\": \"Go to the tutorial!\",\n        \"Next step\": \"Next step\",\n        \"Skip step\": \"Skip step\",\n        \"Skip tutorial\": \"Skip tutorial\",\n        \"Tell us who you are\": \"Tell us who you are\",\n        \"Type here\": \"Type here\",\n        \"Welcome\": \"Welcome\",\n        \"What brings you to Grist (you can select multiple)?\": \"What brings you to Grist (you can select multiple)?\",\n        \"What is your role?\": \"What is your role?\",\n        \"What organization are you with?\": \"What organization are you with?\",\n        \"Your organization\": \"Your organization\",\n        \"Your role\": \"Your role\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"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}}).\",\n        \"Disable Grist Enterprise\": \"Disable Grist Enterprise\",\n        \"Enable Grist Enterprise\": \"Enable Grist Enterprise\",\n        \"Grist Enterprise is **enabled**.\": \"Grist Enterprise is **enabled**.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist 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 [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\",\n        \"Activate\": \"Activate\",\n        \"Activation key\": \"Activation key\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\",\n        \"Copy to clipboard\": \"Copy to clipboard\",\n        \"Expiration date\": \"Expiration date\",\n        \"Installation ID copied to clipboard\": \"Installation ID copied to clipboard\",\n        \"Installation ID:\": \"Installation ID:\",\n        \"Installation seats\": \"Installation seats\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Learn more in our [Help Center]({{helpCenter}}).\",\n        \"Paste your activation key\": \"Paste your activation key\",\n        \"Plan name\": \"Plan name\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\",\n        \"You are currently trialing Grist Enterprise.\": \"You are currently trialing Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"You do not have an active subscription.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Your activation key has expired due to exceeding limits.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Your instance will be in **read-only** mode in **{{days}}** day(s).\",\n        \"Your subscription expired on {{date}}.\": \"Your subscription expired on {{date}}.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Delete\",\n        \"Delete data and this widget.\": \"Delete data and this widget.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\",\n        \"Table {{tableName}} will no longer be visible\": \"Table {{tableName}} will no longer be visible\",\n        \"Raw Data page\": \"Raw Data page\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Admin Panel\"\n    },\n    \"CustomWidgetGallery\": {\n        \"(Missing info)\": \"(Missing info)\",\n        \"Add widget\": \"Add widget\",\n        \"Add Your Own Widget\": \"Add Your Own Widget\",\n        \"Add a widget from outside this gallery.\": \"Add a widget from outside this gallery.\",\n        \"Cancel\": \"Cancel\",\n        \"Change widget\": \"Change widget\",\n        \"Choose custom widget\": \"Choose custom widget\",\n        \"Community Widget\": \"Community Widget\",\n        \"Custom URL\": \"Custom URL\",\n        \"Developer:\": \"Developer:\",\n        \"Grist Widget\": \"Grist Widget\",\n        \"Last updated:\": \"Last updated:\",\n        \"Learn more about custom widgets\": \"Learn more about custom widgets\",\n        \"No matching widgets\": \"No matching widgets\",\n        \"Search\": \"Search\",\n        \"Widget URL\": \"Widget URL\"\n    },\n    \"markdown\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"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.\"\n            }\n        },\n        \"The toggle is **off**\": \"The toggle is **off**\",\n        \"The toggle is **on**\": \"The toggle is **on**\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"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.\"\n            }\n        },\n        \"The toggle is **off**\": \"The toggle is **off**\",\n        \"The toggle is **on**\": \"The toggle is **on**\"\n    },\n    \"HomeIntroCards\": {\n        \"3 minute video tour\": \"3 minute video tour\",\n        \"Blank document\": \"Blank document\",\n        \"Find solutions and explore more resources\": \"Find solutions and explore more resources\",\n        \"Finish our basics tutorial\": \"Finish our basics tutorial\",\n        \"Help center\": \"Help center\",\n        \"Import file\": \"Import file\",\n        \"Learn more\": \"Learn more\",\n        \"Start a new document\": \"Start a new document\",\n        \"Templates\": \"Templates\",\n        \"Tutorial\": \"Tutorial\",\n        \"Webinars\": \"Webinars\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Add two-way reference\": \"Add two-way reference\",\n        \"Column\": \"Column\",\n        \"Delete\": \"Delete\",\n        \"Delete column {{column}} in table {{table}}?\": \"Delete column {{column}} in table {{table}}?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"It is the reverse of the reference column {{column}} in table {{table}}.\",\n        \"Table\": \"Table\",\n        \"Two-way Reference\": \"Two-way Reference\",\n        \"Delete two-way reference?\": \"Delete two-way reference?\",\n        \"Target table\": \"Target table\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"Admin Panel\",\n        \"Close\": \"Close\",\n        \"Help Center\": \"Help Center\",\n        \"Opt in to Telemetry\": \"Opt in to Telemetry\",\n        \"Opted In\": \"Opted In\",\n        \"Support Grist\": \"Support Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Cancel\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\",\n        \"Reassign\": \"Reassign\",\n        \"Reassign to new {{sourceTable}} records.\": \"Reassign to new {{sourceTable}} records.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Reassign to {{sourceTable}} record {{sourceName}}.\",\n        \"Record already assigned_one\": \"Record already assigned\",\n        \"Record already assigned_other\": \"Records already assigned\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Add destination\": \"Add destination\",\n        \"Add streaming destination\": \"Add streaming destination\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Are you sure you want to delete this streaming destination? This action cannot be undone.\",\n        \"Cancel\": \"Cancel\",\n        \"Delete\": \"Delete\",\n        \"Delete streaming destination?\": \"Delete streaming destination?\",\n        \"Destination\": \"Destination\",\n        \"Destinations\": \"Destinations\",\n        \"Edit\": \"Edit\",\n        \"Edit streaming destination\": \"Edit streaming destination\",\n        \"Enter URL\": \"Enter URL\",\n        \"Enter token\": \"Enter token\",\n        \"Learn more\": \"Learn more\",\n        \"Other\": \"Other\",\n        \"Save\": \"Save\",\n        \"Splunk\": \"Splunk\",\n        \"Start streaming\": \"Start streaming\",\n        \"Token\": \"Token\",\n        \"URL\": \"URL\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit Logs\": \"Audit Logs\",\n        \"Audit logs for {{siteName}}\": \"Audit logs for {{siteName}}\",\n        \"Contact us\": \"Contact us\",\n        \"Home\": \"Home\",\n        \"Log streaming\": \"Log streaming\",\n        \"Only site owners may access audit logs.\": \"Only site owners may access audit logs.\",\n        \"upgrade your plan\": \"upgrade your plan\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"Access details\": \"Access details\",\n        \"All\": \"All\",\n        \"Current workspace\": \"Current workspace\",\n        \"Delete\": \"Delete\",\n        \"Delete {{name}}\": \"Delete {{name}}\",\n        \"Document will be moved to Trash.\": \"Document will be moved to Trash.\",\n        \"Edited {{at}}\": \"Edited {{at}}\",\n        \"Last edited\": \"Last edited\",\n        \"Manage users\": \"Manage users\",\n        \"Move\": \"Move\",\n        \"Move {{name}} to workspace\": \"Move {{name}} to workspace\",\n        \"Name\": \"Name\",\n        \"No documents to show.\": \"No documents to show.\",\n        \"Pin\": \"Pin\",\n        \"Pinned\": \"Pinned\",\n        \"Recent\": \"Recent\",\n        \"Rename and set icon\": \"Rename and set icon\",\n        \"Requires edit permissions\": \"Requires edit permissions\",\n        \"Sort by date\": \"Sort by date\",\n        \"Sort by name\": \"Sort by name\",\n        \"Unpin\": \"Unpin\",\n        \"Workspace\": \"Workspace\",\n        \"context menu - {{- documentName }}\": \"context menu - {{- documentName }}\",\n        \"Documents list\": \"Documents list\",\n        \"Download document...\": \"Download document...\",\n        \"Deleted {{at}}\": \"Deleted {{at}}\"\n    },\n    \"RenameDocModal\": {\n        \"Choose color\": \"Choose color\",\n        \"Choose icon\": \"Choose icon\",\n        \"Enter document name\": \"Enter document name\",\n        \"Icon\": \"Icon\",\n        \"Name\": \"Name\",\n        \"Rename and set icon\": \"Rename and set icon\",\n        \"Reset icon\": \"Reset icon\"\n    },\n    \"RightPanelUtils\": {\n        \"columns_one\": \"column\",\n        \"columns_other\": \"columns\",\n        \"fields_one\": \"field\",\n        \"fields_other\": \"fields\",\n        \"series_one\": \"series\",\n        \"series_other\": \"series\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Be careful with unknown custom widgets\",\n        \"Please review the following before adding a new custom widget.\": \"Please review the following before adding a new custom widget.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Are you sure you **trust the resource** at this URL?\",\n        \"Do you **trust the person** who shared this link?\": \"Do you **trust the person** who shared this link?\",\n        \"Have you **reviewed the code** at this URL?\": \"Have you **reviewed the code** at this URL?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"I confirm that I understand these warnings and accept the risks\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin area\": \"Admin area\",\n        \"Admin controls\": \"Admin controls\",\n        \"Docs\": \"Docs\",\n        \"Installation\": \"Installation\",\n        \"Learn more\": \"Learn more\",\n        \"Orgs\": \"Orgs\",\n        \"Users\": \"Users\",\n        \"Workspaces\": \"Workspaces\",\n        \"Admin Controls\": \"Admin Controls\",\n        \"Settings\": \"Settings\"\n    },\n    \"Assistant\": {\n        \"AI Assistant is only available for logged in users.\": \"AI Assistant is only available for logged in users.\",\n        \"Apply\": \"Apply\",\n        \"For higher limits, contact the site owner.\": \"For higher limits, contact the site owner.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"For higher limits, {{upgradeNudge}}.\",\n        \"Learn more.\": \"Learn more.\",\n        \"Press Enter to apply suggested formula.\": \"Press Enter to apply suggested formula.\",\n        \"Sign Up for Free\": \"Sign Up for Free\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Sign up for a free Grist account to start using the AI Assistant.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\",\n        \"What do you need help with?\": \"What do you need help with?\",\n        \"You have used all available credits.\": \"You have used all available credits.\",\n        \"You have {{numCredits}} remaining credits.\": \"You have {{numCredits}} remaining credits.\",\n        \"start a new chat\": \"start a new chat\",\n        \"upgrade to the Pro Team plan\": \"upgrade to the Pro Team plan\",\n        \"upgrade your plan\": \"upgrade your plan\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\"\n    },\n    \"apiconsole\": {\n        \"Are you sure you want to delete the following?\": \"Are you sure you want to delete the following?\",\n        \"Confirm Deletion\": \"Confirm Deletion\",\n        \"Delete\": \"Delete\",\n        \"Deletion was not confirmed, skipping.\": \"Deletion was not confirmed, skipping.\",\n        \"Type DELETE here if you wish to proceed.\": \"Type DELETE here if you wish to proceed.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"no access\",\n        \"...loading\": \"...loading\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\",\n        \"Set the document to use external storage.\": \"Set the document to use external storage.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Please wait for the previous operation to complete.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Timed out on waiting for the Grist backend to restart\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Disable feature\",\n        \"Don't worry, you can disable it later if needed.\": \"Don't worry, you can disable it later if needed.\",\n        \"Enable feature\": \"Enable feature\",\n        \"Experimental feature\": \"Experimental feature\",\n        \"New record button\": \"New record button\",\n        \"Reload the page\": \"Reload the page\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Visit this URL at any time to stop using this feature: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"You are about to disable this experimental feature: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"You are about to enable this experimental feature: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} disabled.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} enabled.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"New card\",\n        \"New record\": \"New record\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Trying to access the creator panel? Use {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Duplicate widget\",\n        \"Duplicate widgets\": \"Duplicate widgets\",\n        \"Active\": \"Active\",\n        \"Create new page\": \"Create new page\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Uploading, please wait…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Add\",\n        \"Delete\": \"Delete\",\n        \"Download\": \"Download\",\n        \"Drop files here to attach\": \"Drop files here to attach\",\n        \"Drop files here to attach.\": \"Drop files here to attach.\",\n        \"No attachments\": \"No attachments\",\n        \"Preview not available.\": \"Preview not available.\",\n        \"{{index}} of {{total}}\": \"{{index}} of {{total}}\",\n        \"Uploading…\": \"Uploading…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Expand all rows to this height\",\n        \"Max height\": \"Max height\",\n        \"Max row height\": \"Max row height\",\n        \"Change\": \"Change\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Collapse\",\n        \"Expand\": \"Expand\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"active user\",\n        \"active user list\": \"active user list\",\n        \"open full active user list\": \"open full active user list\"\n    },\n    \"AdminChecks\": {\n        \"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.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Grist has a small built-in health check often used when running it as a container.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\",\n        \"It is good practice not to run Grist as the root user.\": \"It is good practice not to run Grist as the root user.\",\n        \"The main page of Grist should be available.\": \"The main page of Grist should be available.\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"+{{count}} more\",\n        \"+{{count}} more_other\": \"+{{count}} more\",\n        \"Edit\": \"Edit\",\n        \"No choices configured\": \"No choices configured\",\n        \"Reset\": \"Reset\",\n        \"Cancel\": \"Cancel\",\n        \"Save\": \"Save\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Apply\",\n        \"Cancel\": \"Cancel\",\n        \"Preview\": \"Preview\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Close\",\n        \"Update preview\": \"Update preview\",\n        \"Convert quoted fields\": \"Convert quoted fields\",\n        \"Escape character\": \"Escape character\",\n        \"Field separator\": \"Field separator\",\n        \"First row contains headers\": \"First row contains headers\",\n        \"Line terminator\": \"Line terminator\",\n        \"Number of rows\": \"Number of rows\",\n        \"Quote character\": \"Quote character\",\n        \"Quotes in fields are doubled\": \"Quotes in fields are doubled\",\n        \"Skip leading whitespace\": \"Skip leading whitespace\",\n        \"Start with row\": \"Start with row\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Character encoding. See [the supported codecs]({{link}})\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" or \",\n        \"\\\"Regions\\\" are what we call the different parts of the user interface:\": \"\\\"Regions\\\" are what we call the different parts of the user interface:\",\n        \"Accessibility\": \"Accessibility\",\n        \"Close\": \"Close\",\n        \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\": \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\",\n        \"Focus on other parts of the user interface using the following shortcuts:\": \"Focus on other parts of the user interface using the following shortcuts:\",\n        \"High contrast theme\": \"High contrast theme\",\n        \"Keyboard navigation\": \"Keyboard navigation\",\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"On a document page, keyboard navigation is first locked on the current widget.\",\n        \"On document pages, each [widget]({{supportPageUrl}}) is a region that can receive focus.\": \"On document pages, each [widget]({{supportPageUrl}}) is a region that can receive focus.\",\n        \"On non-document pages, the main content area is a region.\": \"On non-document pages, the main content area is a region.\",\n        \"Other important keyboard shortcuts\": \"Other important keyboard shortcuts\",\n        \"The left panel, home of the main navigation.\": \"The left panel, home of the main navigation.\",\n        \"The top panel, or the document header.\": \"The top panel, or the document header.\",\n        \"To see other available themes, go to your {{profileSettingsLink}}.\": \"To see other available themes, go to your {{profileSettingsLink}}.\",\n        \"Use the high contrast theme (light appearance)\": \"Use the high contrast theme (light appearance)\",\n        \"You are currently **not using** the high contrast theme.\": \"You are currently **not using** the high contrast theme.\",\n        \"You are currently using the high contrast theme.\": \"You are currently using the high contrast theme.\",\n        \"profile settings\": \"profile settings\",\n        \"{{accessibilityModal}} Show the accessibility options (this modal)\": \"{{accessibilityModal}} Show the accessibility options (this modal)\",\n        \"{{creatorPanelShortcut}} Focus to and from the creator panel\": \"{{creatorPanelShortcut}} Focus to and from the creator panel\",\n        \"{{nextRegionShortcut}} Focus on the next region\": \"{{nextRegionShortcut}} Focus on the next region\",\n        \"{{prevRegionShortcut}} Focus on the previous region\": \"{{prevRegionShortcut}} Focus on the previous region\",\n        \"{{shortcutsModal}} Show the complete list of keyboard shortcuts\": \"{{shortcutsModal}} Show the complete list of keyboard shortcuts\"\n    },\n    \"ProposedChangesPage\": {\n        \"Proposed changes\": \"Proposed changes\",\n        \"Replace original\": \"Replace original\",\n        \"This is a list of changes relative to the original document.\": \"This is a list of changes relative to the original document.\",\n        \"Accept\": \"Accept\",\n        \"Accepted {{at}}.\": \"Accepted {{at}}.\",\n        \"Dismiss\": \"Dismiss\",\n        \"Dismissed {{at}}.\": \"Dismissed {{at}}.\",\n        \"Learn more\": \"Learn more\",\n        \"No changes found to suggest. Please make some edits.\": \"No changes found to suggest. Please make some edits.\",\n        \"Retract suggestion\": \"Retract suggestion\",\n        \"Retracted {{at}}.\": \"Retracted {{at}}.\",\n        \"Suggest change\": \"Suggest change\",\n        \"Suggest changes\": \"Suggest changes\",\n        \"Suggestion made {{at}}.\": \"Suggestion made {{at}}.\",\n        \"Suggestions\": \"Suggestions\",\n        \"The original document isn't asking for proposed changes.\": \"The original document isn't asking for proposed changes.\",\n        \"There are fresh changes that haven't been added to the suggestion yet.\": \"There are fresh changes that haven't been added to the suggestion yet.\",\n        \"This is a list of changes relative to the {{originalDocument}}.\": \"This is a list of changes relative to the {{originalDocument}}.\",\n        \"Update suggestion\": \"Update suggestion\",\n        \"Work on a copy\": \"Work on a copy\",\n        \"Your suggestions\": \"Your suggestions\",\n        \"experiment\": \"experiment\",\n        \"original document\": \"original document\",\n        \"Undo dismissal\": \"Undo dismissal\"\n    },\n    \"commandList\": {\n        \"Show accessibility options\": \"Show accessibility options\",\n        \"Activate assistant\": \"Activate assistant\",\n        \"Add a new viewsection to the currently active view\": \"Add a new viewsection to the currently active view\",\n        \"Adds all elements above the cursor to the selected range\": \"Adds all elements above the cursor to the selected range\",\n        \"Adds all elements below the cursor to the selected range\": \"Adds all elements below the cursor to the selected range\",\n        \"Adds all elements to the left of the cursor to the selected range\": \"Adds all elements to the left of the cursor to the selected range\",\n        \"Adds all elements to the right of the cursor to the selected range\": \"Adds all elements to the right of the cursor to the selected range\",\n        \"Adds the currently selected column(ascending) to the current view's sort spec\": \"Adds the currently selected column(ascending) to the current view's sort spec\",\n        \"Adds the currently selected column(descending) to the current view's sort spec\": \"Adds the currently selected column(descending) to the current view's sort spec\",\n        \"Adds the element above the cursor to the selected range\": \"Adds the element above the cursor to the selected range\",\n        \"Adds the element below the cursor to the selected range\": \"Adds the element below the cursor to the selected range\",\n        \"Adds the element to the left of the cursor to the selected range\": \"Adds the element to the left of the cursor to the selected range\",\n        \"Adds the element to the right of the cursor to the selected range\": \"Adds the element to the right of the cursor to the selected range\",\n        \"Clear the selected columns\": \"Clear the selected columns\",\n        \"Clears the current copy selection, if any\": \"Clears the current copy selection, if any\",\n        \"Clears the currently selected cells\": \"Clears the currently selected cells\",\n        \"Clears the section links in the current view\": \"Clears the section links in the current view\",\n        \"Clears the section links in the current viewsection\": \"Clears the section links in the current viewsection\",\n        \"Collapse the currently active viewsection\": \"Collapse the currently active viewsection\",\n        \"Convert the selected columns from formula to data\": \"Convert the selected columns from formula to data\",\n        \"Copy anchor link\": \"Copy anchor link\",\n        \"Copy current selection to clipboard\": \"Copy current selection to clipboard\",\n        \"Copy current selection to clipboard including headers\": \"Copy current selection to clipboard including headers\",\n        \"Creates form for active table\": \"Creates form for active table\",\n        \"Cut current selection to clipboard\": \"Cut current selection to clipboard\",\n        \"Delete collapsed viewsection\": \"Delete collapsed viewsection\",\n        \"Delete the currently active viewsection\": \"Delete the currently active viewsection\",\n        \"Delete the currently selected columns\": \"Delete the currently selected columns\",\n        \"Delete the currently selected record(s)\": \"Delete the currently selected record(s)\",\n        \"Detach active editor\": \"Detach active editor\",\n        \"Discard changes to a cell value\": \"Discard changes to a cell value\",\n        \"Display Grist documentation\": \"Display Grist documentation\",\n        \"Display shortcuts pane\": \"Display shortcuts pane\",\n        \"Duplicate the currently active viewsection\": \"Duplicate the currently active viewsection\",\n        \"Duplicate the currently selected record(s)\": \"Duplicate the currently selected record(s)\",\n        \"Edit label of the currently-selected field\": \"Edit label of the currently-selected field\",\n        \"Edit record layout\": \"Edit record layout\",\n        \"Enter text into currently-selected cell and start editing\": \"Enter text into currently-selected cell and start editing\",\n        \"Enters section linking mode in the current view\": \"Enters section linking mode in the current view\",\n        \"Exits section linking mode in the current view\": \"Exits section linking mode in the current view\",\n        \"Expand collapsed viewsection\": \"Expand collapsed viewsection\",\n        \"Fills current selection with the contents of the top row in the selection\": \"Fills current selection with the contents of the top row in the selection\",\n        \"Find\": \"Find\",\n        \"Find next occurrence\": \"Find next occurrence\",\n        \"Find previous occurrence\": \"Find previous occurrence\",\n        \"Finish editing a cell and save without moving to next record\": \"Finish editing a cell and save without moving to next record\",\n        \"Finish editing a cell, saving the value\": \"Finish editing a cell, saving the value\",\n        \"Focus next page panel or widget\": \"Focus next page panel or widget\",\n        \"Focus previous page panel or widget\": \"Focus previous page panel or widget\",\n        \"Freeze or unfreeze selected columns\": \"Freeze or unfreeze selected columns\",\n        \"Hide the currently selected columns\": \"Hide the currently selected columns\",\n        \"Hide the currently selected fields\": \"Hide the currently selected fields\",\n        \"Insert a new column, after the currently selected one\": \"Insert a new column, after the currently selected one\",\n        \"Insert a new column, before the currently selected one\": \"Insert a new column, before the currently selected one\",\n        \"Insert a new record, after the currently selected one in an unsorted table\": \"Insert a new record, after the currently selected one in an unsorted table\",\n        \"Insert a new record, before the currently selected one in an unsorted table\": \"Insert a new record, before the currently selected one in an unsorted table\",\n        \"Insert new column in default location\": \"Insert new column in default location\",\n        \"Insert the current date\": \"Insert the current date\",\n        \"Insert the current date and time\": \"Insert the current date and time\",\n        \"Maximize the active section\": \"Maximize the active section\",\n        \"Move down one page of records, or to next record in a card list\": \"Move down one page of records, or to next record in a card list\",\n        \"Move down to the last record\": \"Move down to the last record\",\n        \"Move downward five records\": \"Move downward five records\",\n        \"Move downward to next record or field\": \"Move downward to next record or field\",\n        \"Move left to the previous field\": \"Move left to the previous field\",\n        \"Move right to the next field\": \"Move right to the next field\",\n        \"Move to the first field or the beginning of a row\": \"Move to the first field or the beginning of a row\",\n        \"Move to the last field or the end of a row\": \"Move to the last field or the end of a row\",\n        \"Move to the next field, saving changes if editing a value\": \"Move to the next field, saving changes if editing a value\",\n        \"Move to the previous field, saving changes if editing a value\": \"Move to the previous field, saving changes if editing a value\",\n        \"Move up one page of records, or to previous record in a card list\": \"Move up one page of records, or to previous record in a card list\",\n        \"Move up to the first record\": \"Move up to the first record\",\n        \"Move upward five records\": \"Move upward five records\",\n        \"Move upward to previous record or field\": \"Move upward to previous record or field\",\n        \"Moves the cursor to the correct location\": \"Moves the cursor to the correct location\",\n        \"Open Custom widget configuration screen\": \"Open Custom widget configuration screen\",\n        \"Open comment thread\": \"Open comment thread\",\n        \"Open next page\": \"Open next page\",\n        \"Open previous page\": \"Open previous page\",\n        \"Opens document list\": \"Opens document list\",\n        \"Paste clipboard contents at cursor\": \"Paste clipboard contents at cursor\",\n        \"Print currently selected page widget\": \"Print currently selected page widget\",\n        \"Push an undo action\": \"Push an undo action\",\n        \"Redo last action\": \"Redo last action\",\n        \"Rename the currently selected column\": \"Rename the currently selected column\",\n        \"Reverts the sections links to the saved links the current view\": \"Reverts the sections links to the saved links the current view\",\n        \"Saves the sections links in the current view\": \"Saves the sections links in the current view\",\n        \"Selects all currently displayed cells\": \"Selects all currently displayed cells\",\n        \"Shortcut to data selection tab\": \"Shortcut to data selection tab\",\n        \"Shortcut to focus view tab if creator panel is open\": \"Shortcut to focus view tab if creator panel is open\",\n        \"Shortcut to open document tab\": \"Shortcut to open document tab\",\n        \"Shortcut to open field tab\": \"Shortcut to open field tab\",\n        \"Shortcut to open sort & filter menu\": \"Shortcut to open sort & filter menu\",\n        \"Shortcut to open the left panel\": \"Shortcut to open the left panel\",\n        \"Shortcut to open the right panel\": \"Shortcut to open the right panel\",\n        \"Shortcut to open view tab\": \"Shortcut to open view tab\",\n        \"Shortcut to sort & filter tab\": \"Shortcut to sort & filter tab\",\n        \"Show hidden columns\": \"Show hidden columns\",\n        \"Show raw data widget for table of currently selected page widget\": \"Show raw data widget for table of currently selected page widget\",\n        \"Show the record card widget of the selected record\": \"Show the record card widget of the selected record\",\n        \"Sort the view data by the currently selected field in ascending order\": \"Sort the view data by the currently selected field in ascending order\",\n        \"Sort the view data by the currently selected field in descending order\": \"Sort the view data by the currently selected field in descending order\",\n        \"Start editing the currently-selected cell\": \"Start editing the currently-selected cell\",\n        \"Toggle creator panel keyboard focus\": \"Toggle creator panel keyboard focus\",\n        \"Toggle the currently selected checkbox or switch cell\": \"Toggle the currently selected checkbox or switch cell\",\n        \"Undo last action\": \"Undo last action\",\n        \"Use the currently selected row as table headers\": \"Use the currently selected row as table headers\",\n        \"When in the search bar, close it and focus the current match\": \"When in the search bar, close it and focus the current match\",\n        \"When typed at the start of a cell, make this a formula column\": \"When typed at the start of a cell, make this a formula column\",\n        \"showing a behavioral popup\": \"showing a behavioral popup\",\n        \"Filter this column by just this cell's value\": \"Filter this column by just this cell's value\"\n    },\n    \"GridViewMenusDateHelpers\": {\n        \"12-hour format\": \"12-hour format\",\n        \"24-hour format\": \"24-hour format\",\n        \"AM\": {\n            \"PM\": \"AM/PM\"\n        },\n        \"Calendar\": \"Calendar\",\n        \"Date helpers…\": \"Date helpers…\",\n        \"Day\": \"Day\",\n        \"Day of month\": \"Day of month\",\n        \"Day of week\": \"Day of week\",\n        \"Day of week (abbrev)\": \"Day of week (abbrev)\",\n        \"Day of week (full)\": \"Day of week (full)\",\n        \"Day of week (numeric)\": \"Day of week (numeric)\",\n        \"Days since\": \"Days since\",\n        \"Days until\": \"Days until\",\n        \"Default\": \"Default\",\n        \"End of\": \"End of\",\n        \"Full date\": \"Full date\",\n        \"Full name with year\": \"Full name with year\",\n        \"Hour\": \"Hour\",\n        \"Intervals\": \"Intervals\",\n        \"Is weekend?\": \"Is weekend?\",\n        \"Minute\": \"Minute\",\n        \"Month\": \"Month\",\n        \"Months since\": \"Months since\",\n        \"Months until\": \"Months until\",\n        \"Name only\": \"Name only\",\n        \"Number only\": \"Number only\",\n        \"Quarter\": \"Quarter\",\n        \"Quick Picks\": \"Quick Picks\",\n        \"Relative\": \"Relative\",\n        \"Short with year\": \"Short with year\",\n        \"Sortable\": \"Sortable\",\n        \"Start of\": \"Start of\",\n        \"Time\": \"Time\",\n        \"Time bucket\": \"Time bucket\",\n        \"Week\": \"Week\",\n        \"Week of year\": \"Week of year\",\n        \"Year\": \"Year\",\n        \"Years since\": \"Years since\",\n        \"Years until\": \"Years until\"\n    },\n    \"selectBy\": {\n        \"Select widget\": \"Select widget\"\n    },\n    \"CoreNewDocMethods\": {\n        \"Untitled document\": \"Untitled document\"\n    },\n    \"AuthenticationSection\": {\n        \"Active\": \"Active\",\n        \"Active method is controlled by an environment variable. Unset variable to change active method.\": \"Active method is controlled by an environment variable. Unset variable to change active method.\",\n        \"Active on restart\": \"Active on restart\",\n        \"Are you sure you want to set **{{name}}** as the active authentication method?\": \"Are you sure you want to set **{{name}}** as the active authentication method?\",\n        \"Close\": \"Close\",\n        \"Configure\": \"Configure\",\n        \"Configured\": \"Configured\",\n        \"Confirm\": \"Confirm\",\n        \"Disabled on restart\": \"Disabled on restart\",\n        \"Error\": \"Error\",\n        \"Error details\": \"Error details\",\n        \"Instructions\": \"Instructions\",\n        \"No authentication method is active.\": \"No authentication method is active.\",\n        \"Set as active method\": \"Set as active method\",\n        \"Set as active method?\": \"Set as active method?\",\n        \"The new method will go into effect after you restart Grist.\": \"The new method will go into effect after you restart Grist.\",\n        \"**Forwarded headers** allows your Grist server to trust authentication performed by an external proxy (e.g. Traefik ForwardAuth).\": \"**Forwarded headers** allows your Grist server to trust authentication performed by an external proxy (e.g. Traefik ForwardAuth).\",\n        \"**Grist Connect** is a login solution built and maintained by Grist Labs that integrates seamlessly with your Grist server.\": \"**Grist Connect** is a login solution built and maintained by Grist Labs that integrates seamlessly with your Grist server.\",\n        \"**OIDC** allows users on your Grist server to sign in using an external identity provider that supports the OpenID Connect standard.\": \"**OIDC** allows users on your Grist server to sign in using an external identity provider that supports the OpenID Connect standard.\",\n        \"**SAML** allows users on your Grist server to sign in using an external identity provider that supports the SAML 2.0 standard.\": \"**SAML** allows users on your Grist server to sign in using an external identity provider that supports the SAML 2.0 standard.\",\n        \"Change admin user\": \"Change admin user\",\n        \"If Grist is accessible on your network, or is available to multiple people, configure one of the authentication methods below.\": \"If Grist is accessible on your network, or is available to multiple people, configure one of the authentication methods below.\",\n        \"No authentication: unrestricted sign-in as demo user\": \"No authentication: unrestricted sign-in as demo user\",\n        \"Prepare changes\": \"Prepare changes\",\n        \"Restart required. Authentication change may affect your access\": \"Restart required. Authentication change may affect your access\",\n        \"Revert change of admin user\": \"Revert change of admin user\",\n        \"See \\\"Restart Grist\\\" section on top of this page to restart.\": \"See \\\"Restart Grist\\\" section on top of this page to restart.\",\n        \"To set up **Grist Connect**, follow the instructions in [the Grist support article for Grist Connect](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"grist-connect\": {\n                            \").\": \"To set up **Grist Connect**, follow the instructions in [the Grist support article for Grist Connect](https://support.getgrist.com/install/grist-connect/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **OIDC**, follow the instructions in [the Grist support article for OIDC](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"oidc).\": \"To set up **OIDC**, follow the instructions in [the Grist support article for OIDC](https://support.getgrist.com/install/oidc).\"\n                    }\n                }\n            }\n        },\n        \"To set up **SAML**, follow the instructions in [the Grist support article for SAML](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"saml\": {\n                            \").\": \"To set up **SAML**, follow the instructions in [the Grist support article for SAML](https://support.getgrist.com/install/saml/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **forwarded headers**, follow the instructions in [the Grist support article for forwarded headers](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"forwarded-headers\": {\n                            \").\": \"To set up **forwarded headers**, follow the instructions in [the Grist support article for forwarded headers](https://support.getgrist.com/install/forwarded-headers/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"When a user accesses Grist, the proxy handles authentication and forwards verified user information through HTTP headers. Grist uses these headers to identify the user.\": \"When a user accesses Grist, the proxy handles authentication and forwards verified user information through HTTP headers. Grist uses these headers to identify the user.\",\n        \"When signing in, users will be redirected to a Grist Connect login page where they can authenticate using various identity providers. After authentication, they'll be redirected back to your Grist server and signed in.\": \"When signing in, users will be redirected to a Grist Connect login page where they can authenticate using various identity providers. After authentication, they'll be redirected back to your Grist server and signed in.\",\n        \"When signing in, users will be redirected to your chosen identity provider's login page to authenticate. After successful authentication, they'll be redirected back to your Grist server and signed in as the user verified by the provider.\": \"When signing in, users will be redirected to your chosen identity provider's login page to authenticate. After successful authentication, they'll be redirected back to your Grist server and signed in as the user verified by the provider.\",\n        \"You are signed in as {{email}}. After restart, the new administrative user will be {{newEmail}}.\": \"You are signed in as {{email}}. After restart, the new administrative user will be {{newEmail}}.\",\n        \"You are signed in as {{email}}. You may lose access to this server if you cannot sign in as this user after switching the authentication system.\": \"You are signed in as {{email}}. You may lose access to this server if you cannot sign in as this user after switching the authentication system.\"\n    },\n    \"DetailView\": {\n        \"This row is unavailable or does not exist\": \"This row is unavailable or does not exist\"\n    },\n    \"GetGristComProvider\": {\n        \"**Sign in with getgrist.com** allows users on your Grist server to sign in using their account on getgrist.com, the cloud version of Grist managed by Grist Labs.\": \"**Sign in with getgrist.com** allows users on your Grist server to sign in using their account on getgrist.com, the cloud version of Grist managed by Grist Labs.\",\n        \"Cancel\": \"Cancel\",\n        \"Configure\": \"Configure\",\n        \"Configure Sign in with getgrist.com\": \"Configure Sign in with getgrist.com\",\n        \"Home URL is not set; cannot configure Sign in with getgrist.com\": \"Home URL is not set; cannot configure Sign in with getgrist.com\",\n        \"Instructions\": \"Instructions\",\n        \"Learn more about Sign in with getgrist.com\": \"Learn more about Sign in with getgrist.com\",\n        \"Paste configuration key here\": \"Paste configuration key here\",\n        \"Register your Grist server\": \"Register your Grist server\",\n        \"Sign in with getgrist.com\": \"Sign in with getgrist.com\",\n        \"To set up {{provider}}, you need to register your Grist server on getgrist.com and paste the configuration key you receive below.\": \"To set up {{provider}}, you need to register your Grist server on getgrist.com and paste the configuration key you receive below.\",\n        \"When signing in, users will be redirected to the getgrist.com login page to log in or register. After authenticating on getgrist.com, they'll be redirected back to your Grist server and signed in as the user they authenticated as.\": \"When signing in, users will be redirected to the getgrist.com login page to log in or register. After authenticating on getgrist.com, they'll be redirected back to your Grist server and signed in as the user they authenticated as.\"\n    },\n    \"AirtableImportUI\": {\n        \"Back\": \"Back\",\n        \"Cancel\": \"Cancel\",\n        \"Choose an Airtable base to import from\": \"Choose an Airtable base to import from\",\n        \"Choose destination\": \"Choose destination\",\n        \"Connect\": \"Connect\",\n        \"Connect with Airtable\": \"Connect with Airtable\",\n        \"Connect your Airtable account to access your bases.\": \"Connect your Airtable account to access your bases.\",\n        \"Connected via {{method}}\": \"Connected via {{method}}\",\n        \"Connecting...\": \"Connecting...\",\n        \"Continue\": \"Continue\",\n        \"Destination\": \"Destination\",\n        \"Disconnect\": \"Disconnect\",\n        \"Existing tables\": \"Existing tables\",\n        \"Failed to fetch base schema\": \"Failed to fetch base schema\",\n        \"Failed to fetch bases\": \"Failed to fetch bases\",\n        \"Grist configuration required\": \"Grist configuration required\",\n        \"Import from {{baseName}} in progress. Do not navigate away from this page.\": \"Import from {{baseName}} in progress. Do not navigate away from this page.\",\n        \"Import tables\": \"Import tables\",\n        \"Import {{count}} tables_one\": \"Import {{count}} tables\",\n        \"Import {{count}} tables_other\": \"Import {{count}} tables\",\n        \"Make sure your token has the correct permissions.\": \"Make sure your token has the correct permissions.\",\n        \"New table\": \"New table\",\n        \"New table: structure only\": \"New table: structure only\",\n        \"No bases found\": \"No bases found\",\n        \"OAuth\": \"OAuth\",\n        \"OAuth credentials not configured. Please set OAUTH2_AIRTABLE_CLIENT_ID and OAUTH2_AIRTABLE_CLIENT_SECRET, or use personal access token.\": \"OAuth credentials not configured. Please set OAUTH2_AIRTABLE_CLIENT_ID and OAUTH2_AIRTABLE_CLIENT_SECRET, or use personal access token.\",\n        \"Personal access token\": \"Personal access token\",\n        \"Please enter a personal access token\": \"Please enter a personal access token\",\n        \"Refresh\": \"Refresh\",\n        \"Select tables to import from {{baseName}}\": \"Select tables to import from {{baseName}}\",\n        \"Skip\": \"Skip\",\n        \"Source tables\": \"Source tables\",\n        \"Structure only\": \"Structure only\",\n        \"Use personal access token instead\": \"Use personal access token instead\",\n        \"[Generate a token]({{url}}) in your Airtable account with scopes that include at least **\\\\`schema.bases:read\\\\`** and **\\\\`data.records:read\\\\`**.\\n\\nYour token is never sent to Grist's servers, and is only used to make API calls to Airtable from your browser.\": \"[Generate a token]({{url}}) in your Airtable account with scopes that include at least **\\\\`schema.bases:read\\\\`** and **\\\\`data.records:read\\\\`**.\\n\\nYour token is never sent to Grist's servers, and is only used to make API calls to Airtable from your browser.\",\n        \"loading your bases...\": \"loading your bases...\",\n        \"loading your tables...\": \"loading your tables...\",\n        \"or\": \"or\",\n        \"{{count}} warnings_one\": \"{{count}} warnings\",\n        \"{{count}} warnings_other\": \"{{count}} warnings\",\n        \"The more convenient ‘Connect with Airtable’ option can be configured by the installation administrator. [Learn more.]({{url}})\": \"The more convenient ‘Connect with Airtable’ option can be configured by the installation administrator. [Learn more.]({{url}})\",\n        \"Use personal access token\": \"Use personal access token\"\n    },\n    \"AirtableImporter\": {\n        \"Creating a new Grist document...\": \"Creating a new Grist document...\",\n        \"Preparing to import base from Airtable...\": \"Preparing to import base from Airtable...\",\n        \"Setting up tables...\": \"Setting up tables...\"\n    },\n    \"ChangeAdminModal\": {\n        \"Enter new admin email\": \"Enter new admin email\",\n        \"Make the new email the installation admin. Orgs, workspaces, and documents will remain owned by {{email}}. These changes will take effect after you restart this Grist server.\": \"Make the new email the installation admin. Orgs, workspaces, and documents will remain owned by {{email}}. These changes will take effect after you restart this Grist server.\",\n        \"New admin\": \"New admin\",\n        \"Replace {{email}} with the new email throughout. The new email will become the installation admin, as well as the owner of all materials previously owned by you@example.com.\": \"Replace {{email}} with the new email throughout. The new email will become the installation admin, as well as the owner of all materials previously owned by you@example.com.\"\n    },\n    \"startDocAirtableImport\": {\n        \"Import from Airtable\": \"Import from Airtable\"\n    },\n    \"startHomeAirtableImport\": {\n        \"Import from Airtable\": \"Import from Airtable\",\n        \"The current workspace can't be imported to.\": \"The current workspace can't be imported to.\"\n    },\n    \"NotifyModel\": {\n        \"Still working...\": \"Still working...\"\n    }\n}\n"
  },
  {
    "path": "static/locales/en.server.json",
    "content": "{\n  \"sendAppPage\": {\n    \"Loading...\": \"Loading...\",\n    \"og-description\": \"A modern, open source spreadsheet that goes beyond the grid\",\n    \"og-title\": \"Grist, the evolution of spreadsheets\"\n  },\n  \"oidc\": {\n    \"emailNotVerifiedError\": \"Please verify your email with the identity provider, and log in again.\"\n  },\n  \"access\": {\n    \"docDisabled\": \"This document is disabled.\",\n    \"docNoAccess\": \"You do not have access to this document.\"\n  },\n  \"admin\": {\n    \"emptyOrg\": \"No owners found in the admin organization defined by `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n    \"orgUser\": \"User is an owner of the admin organization defined by `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n    \"noAdminEmail\": \"Missing admin account because `GRIST_ADMIN_EMAIL` and `GRIST_DEFAULT_EMAIL` are not set\",\n    \"accountByEmail\": \"Admin account defined by `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\"\n  },\n  \"DocApi\": {\n    \"UntitledDocument\": \"Untitled document\"\n  }\n}\n"
  },
  {
    "path": "static/locales/en_GB.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Enter email address\": \"\"\n    }\n}\n"
  },
  {
    "path": "static/locales/en_GB.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/es.client.json",
    "content": "{\n    \"AccessRules\": {\n        \"Add table rules\": \"Agregar reglas de tabla\",\n        \"Add user attributes\": \"Agregar atributos de usuario\",\n        \"Attribute to Look Up\": \"Atributo para buscar\",\n        \"Checking...\": \"Comprobando…\",\n        \"Condition\": \"Condición\",\n        \"Default rules\": \"Reglas predeterminadas\",\n        \"Invalid\": \"Inválido\",\n        \"Lookup Column\": \"Columna de búsqueda\",\n        \"Lookup Table\": \"Tabla de consulta\",\n        \"Reset\": \"Restablecer\",\n        \"Save\": \"Guardar\",\n        \"Saved\": \"Guardado\",\n        \"Type message to display when this rule blocks an action…\": \"Escribe un mensaje…\",\n        \"User Attributes\": \"Atributos de usuario\",\n        \"Users\": \"Usuarios\",\n        \"Add column rule\": \"Agregar Regla de Columna\",\n        \"Add Default Rule\": \"Agregar regla predeterminada\",\n        \"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.\": \"Permitir a todos copiar el documento completo, o verlo en modo abierto.\\nÚtil para ejemplos y plantillas, pero no para datos sensibles.\",\n        \"Allow everyone to view Access Rules.\": \"Permitir a todos ver Reglas de acceso.\",\n        \"Attribute name\": \"Nombre del Atributo\",\n        \"Delete table rules\": \"Borrar reglas de tabla\",\n        \"Enter Condition\": \"Introducir condición\",\n        \"Everyone\": \"Todos\",\n        \"Everyone Else\": \"Todos los demás\",\n        \"Permission to access the document in full when needed\": \"Permiso de acceso completo al documento cuando sea necesario\",\n        \"Permission to view Access Rules\": \"Permiso para ver las Reglas de Acceso\",\n        \"Permissions\": \"Permisos\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Quitar columna {{- colId }} de las reglas de {{- tableId }}\",\n        \"Remove {{- tableId }} rules\": \"Elimine las reglas de {{- tableId }}\",\n        \"Remove {{- name }} user attribute\": \"Eliminar el atributo de usuario {{- name }}\",\n        \"Rules for table \": \"Reglas para la tabla \",\n        \"Special rules\": \"Reglas especiales\",\n        \"View as\": \"Ver como\",\n        \"Seed rules\": \"Reglas de semillas\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Al agregar reglas de tabla, agregue automáticamente una regla para otorgar acceso completo al PROPIETARIO.\",\n        \"Permission to edit document structure\": \"Permiso para editar la estructura del documento\",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Permitir a los editores editar la estructura (por ejemplo, modificar y eliminar tablas, columnas, diseños), y escribir fórmulas, que dan acceso a todos los datos independientemente de las restricciones de lectura.\",\n        \"This default should be changed if editors' access is to be limited. \": \"Este valor predeterminado debe cambiarse si se quiere limitar el acceso de los editores. \",\n        \"Add table-wide rule\": \"Añadir regla a toda la tabla\",\n        \"Allow everyone to view access rules.\": \"Permitir a todos ver Reglas de acceso.\",\n        \"Columns\": \"Columnas\",\n        \"Continue\": \"Continuar\",\n        \"Disable Access Rules\": \"Deshabilitar Reglas de acceso\",\n        \"Disable and save\": \"Deshabilitar y guardar\",\n        \"Enable Access Rules\": \"Habilitar Reglas de acceso\",\n        \"All\": \"Todas\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Las reglas de acceso han cambiado. Haga clic en Restablecer para revertir los cambios y actualizar las reglas.\"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"Clave de API\",\n        \"Account settings\": \"Configuraciones de cuenta\",\n        \"Allow signing in to this account with Google\": \"Permitir iniciar sesión en esta cuenta con Google\",\n        \"Change password\": \"Cambiar contraseña\",\n        \"Edit\": \"Editar\",\n        \"Email\": \"Correo electrónico\",\n        \"Login method\": \"Método de inicio de sesión\",\n        \"Name\": \"Nombre\",\n        \"Names only allow letters, numbers and certain special characters\": \"Los nombres solo permiten letras, números y ciertos caracteres especiales\",\n        \"Password & security\": \"Contraseña y Seguridad\",\n        \"Save\": \"Guardar\",\n        \"Theme\": \"Tema\",\n        \"Two-factor authentication\": \"Autenticación de dos factores\",\n        \"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.\": \"La autenticación de dos factores provee seguridad adicional para su cuenta Grist, diseñada para garantizar que usted sea la única persona que pueda acceder a su cuenta, incluso si alguien conoce su contraseña.\",\n        \"Language\": \"Idioma\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Detalles de Acceso\",\n        \"Accounts\": \"Cuentas\",\n        \"Add account\": \"Agregar Cuenta\",\n        \"Document settings\": \"Configuración del Documento\",\n        \"Manage team\": \"Administrar Equipo\",\n        \"Pricing\": \"Precios\",\n        \"Profile settings\": \"Configuración de Perfil\",\n        \"Sign out\": \"Cerrar Sesión\",\n        \"Sign in\": \"Ingresar\",\n        \"Switch Accounts\": \"Cambiar de Cuenta\",\n        \"Toggle Mobile Mode\": \"Alternar con el modo móvil\",\n        \"Activation\": \"Activación\",\n        \"Billing account\": \"Cuenta de facturación\",\n        \"Support Grist\": \"Soporte Grist\",\n        \"Upgrade Plan\": \"Actualizar el Plan\",\n        \"Sign up\": \"Regístrate\",\n        \"Use This Template\": \"Usa esta plantilla\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Agregar Nuevo\"\n    },\n    \"ApiKey\": {\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Al generar una clave de API, podrás hacer llamadas API para tu propia cuenta.\",\n        \"Click to show\": \"Haga clic para mostrar\",\n        \"Create\": \"Crear\",\n        \"Remove\": \"Quitar\",\n        \"Remove API Key\": \"Quitar clave de API\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Esta clave de API se puede utilizar para acceder a esta cuenta de forma anónima a través de la API.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Esta clave de API se puede utilizar para acceder a su cuenta a través de la API. No compartas tu clave de API con nadie.\",\n        \"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?\": \"Estás a punto de eliminar una clave de API. Esto hará que se rechacen todas las solicitudes futuras que utilicen esta clave. ¿Aún quieres eliminar?\"\n    },\n    \"App\": {\n        \"Description\": \"Descripción\",\n        \"Key\": \"Clave\",\n        \"Memory Error\": \"Error de memoria\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Traductores: por favor, traduzcan esto sólo cuando su idioma esté listo para ser ofrecido a los usuarios\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Portada\",\n        \"Legacy\": \"Legado\",\n        \"Personal Site\": \"Sitio Personal\",\n        \"Team Site\": \"Sitio de equipo\",\n        \"Grist Templates\": \"Plantillas Grist\",\n        \"Manage team\": \"Administrar equipo\",\n        \"Billing account\": \"Cuenta de facturación\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Volver al inicio\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"Borrar celda\",\n        \"Clear values\": \"Borrar valores\",\n        \"Copy anchor link\": \"Copiar enlace de anclaje\",\n        \"Delete column\": \"Eliminar columna\",\n        \"Delete row\": \"Eliminar fila\",\n        \"Delete {{count}} columns\": \"Eliminar {{count}} columnas\",\n        \"Delete {{count}} rows\": \"Eliminar {{count}} filas\",\n        \"Duplicate row\": \"Duplicar fila\",\n        \"Duplicate rows\": \"Duplicar filas\",\n        \"Filter by this value\": \"Filtrar por este valor\",\n        \"Insert column to the left\": \"Insertar columna a la izquierda\",\n        \"Insert column to the right\": \"Insertar columna a la derecha\",\n        \"Insert row\": \"Insertar fila\",\n        \"Insert row above\": \"Insertar fila arriba\",\n        \"Insert row below\": \"Insertar fila debajo\",\n        \"Reset column\": \"Restablecer columna\",\n        \"Reset entire column\": \"Restablecer columna completa\",\n        \"Reset {{count}} columns\": \"Restablecer {{count}} columnas\",\n        \"Reset {{count}} entire columns\": \"Restablecer {{count}} columnas completa\",\n        \"Delete {{count}} columns_one\": \"Eliminar columna\",\n        \"Delete {{count}} columns_other\": \"Eliminar {{count}} columnas\",\n        \"Delete {{count}} rows_one\": \"Eliminar fila\",\n        \"Delete {{count}} rows_other\": \"Eliminar {{count}} filas\",\n        \"Duplicate rows_one\": \"Duplicar fila\",\n        \"Duplicate rows_other\": \"Duplicar filas\",\n        \"Reset {{count}} columns_one\": \"Restablecer columna\",\n        \"Reset {{count}} columns_other\": \"Restablecer {{count}} columnas\",\n        \"Reset {{count}} entire columns_one\": \"Restablecer toda la columna\",\n        \"Reset {{count}} entire columns_other\": \"Restablecer {{count}} columnas enteras\",\n        \"Paste\": \"Pegar\",\n        \"Comment\": \"Comentario\",\n        \"Copy\": \"Copiar\",\n        \"Cut\": \"Cortar\",\n        \"Copy with headers\": \"Copia con cabeceras\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"Todo\",\n        \"All except\": \"Todos Excepto\",\n        \"All shown\": \"Todos Mostrados\",\n        \"End\": \"Fin\",\n        \"Future values\": \"Valores futuros\",\n        \"Max\": \"Max\",\n        \"Min\": \"Min\",\n        \"No matching values\": \"No hay valores coincidentes\",\n        \"None\": \"Ninguno\",\n        \"Other Matching\": \"Otros coincidentes\",\n        \"Other Non-Matching\": \"Otros no coincidentes\",\n        \"Other values\": \"Otros valores\",\n        \"Others\": \"Otros\",\n        \"Search\": \"Buscar\",\n        \"Search values\": \"Buscar valores\",\n        \"Start\": \"Iniciar\",\n        \"Filter by Range\": \"Filtrar por rango\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (opcional)\",\n        \"Add\": \"Agregar\",\n        \"Enter Custom URL\": \"Introduzca su URL personalizada\",\n        \"Full document access\": \"Acceso completo al documento\",\n        \"Learn more about custom widgets\": \"Más información sobre los widgets personalizados\",\n        \"No document access\": \"Sin accesso al documento\",\n        \"Open configuration\": \"Abrir configuración\",\n        \"Pick a column\": \"Elige una columna\",\n        \"Pick a {{columnType}} column\": \"Elige una columna {{columnType}}\",\n        \"Read selected table\": \"Leer tabla seleccionada\",\n        \"Select Custom Widget\": \"Seleccionar widget personalizado\",\n        \"Widget does not require any permissions.\": \"El widget no requiere permisos.\",\n        \"Widget needs to {{read}} the current table.\": \"El widget necesita {{read}} la tabla actual.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"El widget necesita {{fullAccess}} para este documento.\",\n        \"{{wrongTypeCount}} non-{{columnType}} column is not shown\": \"{{wrongTypeCount}} columna no {{columnType}} no se muestra\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown\": \"{{wrongTypeCount}} columnas no {{columnType}} no se muestran\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} no se muestra la columna {{columnType}}\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} no se muestran las columnas {{columnType}}\",\n        \"No {{columnType}} columns in table.\": \"No hay columnas {{columnType}} en la tabla.\",\n        \"Clear selection\": \"Borrar la selección\",\n        \"Widget\": \"Widget\",\n        \"ACCESS LEVEL\": \"NIVEL DE ACCESO\",\n        \"Accept\": \"Aceptar\",\n        \"Custom URL\": \"URL personalizada\",\n        \"Developer:\": \"Desarrollador:\",\n        \"Last updated:\": \"Última actualización:\",\n        \"Missing description and author information.\": \"Falta la descripción y la información del autor.\",\n        \"Reject\": \"Rechazar\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Actividad\",\n        \"Beta\": \"Beta\",\n        \"Compare to current\": \"Comparar con la actual\",\n        \"Compare to previous\": \"Comparar con el anterior\",\n        \"Open snapshot\": \"Abrir instantánea\",\n        \"Snapshots\": \"Instantáneas\",\n        \"Snapshots are unavailable.\": \"Las instantáneas no están disponibles.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Solo los dueños tienen acceso a las instantáneas de los documentos con unas reglas de acceso.\"\n    },\n    \"DocMenu\": {\n        \"(The organization needs a paid plan)\": \"(La organización necesita un plan de pago)\",\n        \"Access Details\": \"Detalles de Acceso\",\n        \"All documents\": \"Todos los documentos\",\n        \"By Date Modified\": \"Por fecha de modificación\",\n        \"By Name\": \"Por nombre\",\n        \"Current workspace\": \"Espacio de trabajo actual\",\n        \"Delete\": \"Borrar\",\n        \"Delete Forever\": \"Eliminar para siempre\",\n        \"Delete {{name}}\": \"Borrar {{name}}\",\n        \"Deleted {{at}}\": \"Eliminado {{at}}\",\n        \"Discover More Templates\": \"Descubra más plantillas\",\n        \"Document will be moved to Trash.\": \"El documento será movido a la papelera.\",\n        \"Document will be permanently deleted.\": \"El documento será eliminado permanentemente.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Los documentos permanecen en la papelera durante 30 días, tras los cuales se eliminan definitivamente.\",\n        \"Edited {{at}}\": \"Editado {{at}}\",\n        \"Examples & Templates\": \"Ejemplos & Plantillas\",\n        \"Examples and Templates\": \"Ejemplos y Plantillas\",\n        \"Featured\": \"Destacados\",\n        \"Manage users\": \"Gestionar usuarios\",\n        \"More Examples and Templates\": \"Más Ejemplos y Plantillas\",\n        \"Move\": \"Mover\",\n        \"Move {{name}} to workspace\": \"Mover {{name}} al espacio de trabajo\",\n        \"Other Sites\": \"Otros sitios\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Eliminar permanentemente \\\"{{name}}\\\"?\",\n        \"Pin Document\": \"Anclar documento\",\n        \"Pinned Documents\": \"Documentos anclados\",\n        \"Remove\": \"Quitar\",\n        \"Rename\": \"Renombrar\",\n        \"Requires edit permissions\": \"Requiere permisos de edición\",\n        \"Restore\": \"Restaurar\",\n        \"This service is not available right now\": \"Este servicio no está disponible ahora mismo\",\n        \"To restore this document, restore the workspace first.\": \"Para restaurar este documento, restaure primero el espacio de trabajo.\",\n        \"Trash\": \"Papelera\",\n        \"Trash is empty.\": \"La papelera está vacía.\",\n        \"Unpin Document\": \"Desanclar documento\",\n        \"Workspace not found\": \"Espacio de trabajo no encontrado\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Usted está en el sitio {{siteName}}. También tiene acceso a los siguientes sitios:\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Estás en tu sitio personal. También tiene acceso a los siguientes sitios:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Puede eliminar un espacio de trabajo para siempre una vez que no tenga documentos en él.\",\n        \"Any documents created in this site will appear here.\": \"Todos los documentos creados en este sitio aparecerán aquí.\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Tienes acceso de solo lectura en esta página. Actualmente no hay documentos.\",\n        \"personal site\": \"sitio personal\",\n        \"Create my first document\": \"Crear mi primer documento\"\n    },\n    \"DocTour\": {\n        \"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.\": \"No se puede construir un recorrido del documento a partir de los datos de este documento. Asegúrese de que existe una tabla denominada GristDocTour con las columnas Title, Body, Placement y Location.\",\n        \"No valid document tour\": \"No hay un recorrido de documento válido\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Moneda:\",\n        \"Document settings\": \"Configuración del Documento\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Motor (experimental {{span}} cambiar bajo su propia responsabilidad):\",\n        \"Local currency ({{currency}})\": \"Moneda local ({{currency}})\",\n        \"Locale:\": \"Localidad:\",\n        \"Save\": \"Guardar\",\n        \"Save and Reload\": \"Guardar y recargar\",\n        \"This document's ID (for API use):\": \"ID de este documento (para uso de API):\",\n        \"Time Zone:\": \"Zona horaria:\",\n        \"Ok\": \"OK\",\n        \"Document ID copied to clipboard\": \"ID de documento copiado al portapapeles\",\n        \"API\": \"API\",\n        \"Webhooks\": \"Ganchos Web\",\n        \"Manage Webhooks\": \"Administrar los ganchos web\",\n        \"API console\": \"Consola de la API\",\n        \"API URL copied to clipboard\": \"URL de API copiada en el portapapeles\",\n        \"API documentation.\": \"Documentación de la API.\",\n        \"Base doc URL: {{docApiUrl}}\": \"URL del doc base: {{docApiUrl}}\",\n        \"Currency\": \"Moneda\",\n        \"Data engine\": \"Motor de datos\",\n        \"Formula times\": \"Tiempo de fórmula\",\n        \"Hard reset of data engine\": \"Reinicio completo del motor de datos\",\n        \"ID for API use\": \"ID para uso de API\",\n        \"Locale\": \"Configuración regional\",\n        \"Coming soon\": \"Próximamente\",\n        \"Copy to clipboard\": \"Copiar al portapapeles\",\n        \"Default for DateTime columns\": \"Predeterminado para las columnas DateTime\",\n        \"Document ID\": \"ID del documento\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"ID de documento para usar cuando la API REST solicite {{docId}}. Véase {{apiURL}}\",\n        \"Find slow formulas\": \"Encontrar fórmulas lentas\",\n        \"For currency columns\": \"Para columnas de moneda\",\n        \"For number and date formats\": \"Para formatos de número y fecha\",\n        \"Manage webhooks\": \"Administrar los ganchos web\",\n        \"Python version used\": \"Versión Python utilizada\",\n        \"Time zone\": \"Huso horario\",\n        \"Try API calls from the browser\": \"Pruebe las llamadas API desde el navegador\",\n        \"Reload\": \"Recargar\",\n        \"python2 (legacy)\": \"python2 (legado)\",\n        \"Notify other services on doc changes\": \"Notificar a otros servicios los cambios de documentos\",\n        \"Python\": \"Python\",\n        \"python3 (recommended)\": \"python3 (recomendado)\",\n        \"Cancel\": \"Cancelar\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Fuerza la recarga del documento mientras sincronizas las fórmulas y muestra el resultado.\",\n        \"Formula timer\": \"Temporizador de formulas\",\n        \"Time reload\": \"Duración de la recarga\",\n        \"Timing is on\": \"El tiempo está activado\",\n        \"Start timing\": \"Iniciar cronometraje\",\n        \"Reload data engine\": \"Recargar el motor de datos\",\n        \"Reload data engine?\": \"¿Recargar motor de datos?\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Puede realizar cambios en el documento y luego detener el cronometraje para ver los resultados.\",\n        \"Stop timing...\": \"Dejando de cronometrar...\",\n        \"Only available to document editors\": \"Sólo disponible para editores de documentos\",\n        \"Only available to document owners\": \"Solo disponible para los propietarios de documentos\",\n        \"Edit\": \"Editar\",\n        \"Template mode\": \"Modo plantilla\",\n        \"Change document type\": \"Cambiar el tipo de documento\",\n        \"Change nature of document\": \"Cambiar la naturaleza del documento\",\n        \"Regular document\": \"Documento regular\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Comportamiento normal del documento. Todos los usuarios trabajan en la misma copia del documento.\",\n        \"Regular\": \"Regular\",\n        \"Template\": \"Plantilla\",\n        \"Document automatically opens as a user-specific copy.\": \"El documento se abre automáticamente como una copia específica del usuario.\",\n        \"Confirm change\": \"Confirmar cambio\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"El documento se abre automáticamente en {{fiddleModeDocUrl}}. Cualquiera puede editarlo, lo que creará una nueva copia sin guardar.\",\n        \"fiddle mode\": \"modo de pruebas\",\n        \"Tutorial\": \"Tutorial\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Esto realizará una recarga dura del motor de datos. Esto puede ayudar si el motor de datos está atascado en un bucle infinito, está procesando indefinidamente el último cambio o se ha bloqueado. No se perderá ningún dato, excepto posiblemente las acciones actualmente pendientes.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Una vez iniciado el cronometraje, Grist medirá el tiempo que tarda en evaluar cada fórmula. Esto permite diagnosticar qué fórmulas son responsables de un rendimiento lento cuando se abre un documento por primera vez, o cuando un documento responde a cambios.\",\n        \"**Some existing attachments are still external**.\": \"**Algunos anexos existentes siguen siendo externos**.\",\n        \"Start transfer\": \"Iniciar transferencia\",\n        \"Being transfer\": \"Siendo traslado\",\n        \"Attachment storage\": \"Almacenamiento de archivos adjuntos\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Algunos archivos adjuntos existentes siguen siendo internos** (almacenados en un archivo SQLite).\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Haz clic en \\\"Iniciar transferencia\\\" para transferirlos al almacenamiento externo.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Haga clic en \\\"Iniciar transferencia\\\" para transferirlos al almacenamiento interno (almacenados en el archivo SQLite del documento).\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Los archivos adjuntos recién cargados se colocarán en el almacenamiento interno.\",\n        \"No external stores available\": \"No hay almacenamientos externos disponibles\",\n        \"Preferred storage for this document\": \"Almacenamiento preferido para este documento\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Los archivos adjuntos recién cargados se colocarán en el almacenamiento externo.\",\n        \"External\": \"Externo\",\n        \"Internal\": \"Interno\",\n        \"Transfer in progress\": \"Transferencia en curso\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Algunos archivos adjuntos existentes siguen siendo [externos]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Algunos archivos adjuntos existentes siguen siendo [internos]({{internalLink}})** (almacenados en un archivo SQLite).\",\n        \"[Learn more.]({{learnLink}})\": \"[Más información.]({{learnLink}})\",\n        \"Upload\": \"Cargar\",\n        \"Upload missing attachments\": \"Cargar los archivos adjuntos que faltan\",\n        \"Uploading...\": \"Cargando...\",\n        \"Default\": \"Por defecto\",\n        \"Default, template, or tutorial\": \"Por defecto, plantilla o tutorial\",\n        \"Document type\": \"Tipo de documento\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Copiar todos los datos además de la estructura de la tabla.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"En lugar de duplicar tablas, generalmente es mejor segmentar datos usando vistas vinculadas. {{link}}\",\n        \"Name for new table\": \"Nombre de la nueva tabla\",\n        \"Only the document default access rules will apply to the copy.\": \"Solo las reglas de acceso predeterminadas del documento se aplicarán a la copia.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Programa extraescolar\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Consulte nuestro tutorial relacionado para saber cómo vincular datos y crear diseños de alta productividad.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Consulte nuestro tutorial relacionado para saber cómo modelar datos empresariales, utilizar fórmulas y gestionar la complejidad.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Consulte nuestro tutorial relacionado para aprender a crear tablas sumarias y gráficos, y a vincular gráficos dinámicamente.\",\n        \"Investment Research\": \"Investigación de inversiones\",\n        \"Lightweight CRM\": \"CRM ligero\",\n        \"Tutorial: Analyze & Visualize\": \"Tutorial: analizar y visualizar\",\n        \"Tutorial: Create a CRM\": \"Tutorial: Crear un CRM\",\n        \"Tutorial: Manage Business Data\": \"Tutorial: Gestionar datos empresariales\",\n        \"Welcome to the Afterschool Program template\": \"Bienvenido a la plantilla del Programa Extraescolar\",\n        \"Welcome to the Investment Research template\": \"Bienvenido a la plantilla de Investigación de Inversiones\",\n        \"Welcome to the Lightweight CRM template\": \"Bienvenido a la plantilla de CRM ligero\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"COMPORTAMIENTO DE LA COLUMNA\",\n        \"COLUMN LABEL AND ID\": \"ETIQUETA DE COLUMNA E ID\",\n        \"Clear and make into formula\": \"Limpiar y convertir en fórmula\",\n        \"Clear and reset\": \"Borrar y restablecer\",\n        \"Column options are limited in summary tables.\": \"Las opciones de columna son limitadas en tablas sumarias.\",\n        \"Convert column to data\": \"Convertir columna en datos\",\n        \"Convert to trigger formula\": \"Convertir en fórmula de activación\",\n        \"Data Column\": \"Columna de datos\",\n        \"Data Columns\": \"Columnas de datos\",\n        \"Empty Column\": \"Columna vacía\",\n        \"Empty Columns\": \"Columnas vacías\",\n        \"Enter formula\": \"Introducir fórmula\",\n        \"Formula Column\": \"Columna de Fórmula\",\n        \"Formula Columns\": \"Columnas de Fórmula\",\n        \"Make into data column\": \"Convertir en columna de datos\",\n        \"Mixed Behavior\": \"Comportamiento mixto\",\n        \"Set formula\": \"Establecer fórmula\",\n        \"Set trigger formula\": \"Establecer fórmula de activación\",\n        \"TRIGGER FORMULA\": \"FÓRMULA DE ACTIVACIÓN\",\n        \"Data columns_one\": \"Columna de datos\",\n        \"Data columns_other\": \"Columnas de datos\",\n        \"Empty columns_one\": \"Columna vacía\",\n        \"Empty columns_other\": \"Columnas vacías\",\n        \"Formula columns_one\": \"Columna de Fórmula\",\n        \"Formula columns_other\": \"Columnas de Fórmula\",\n        \"DESCRIPTION\": \"DESCRIPCIÓN\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Revertir a los ajustes comunes\",\n        \"Save as common settings\": \"Guardar como ajustes comúnes\",\n        \"Use separate settings\": \"Utilizar ajustes separados\",\n        \"Using common settings\": \"Utilizando ajustes comunes\",\n        \"Using separate settings\": \"Utilizando ajustes separados\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Agregar columna\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Opciones de cuadrícula\",\n        \"Horizontal gridlines\": \"Líneas de cuadrícula horizontales\",\n        \"Vertical gridlines\": \"Líneas de cuadrícula verticales\",\n        \"Zebra stripes\": \"Rayas de cebra\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Añadir una columna\",\n        \"Add to sort\": \"Agregar a ordenar\",\n        \"Clear values\": \"Borrar valores\",\n        \"Column Options\": \"Opciones de columna\",\n        \"Convert formula to data\": \"Convertir fórmula en datos\",\n        \"Delete column\": \"Eliminar columna\",\n        \"Delete {{count}} columns\": \"Eliminar {{count}} columnas\",\n        \"Filter Data\": \"Filtrar datos\",\n        \"Freeze one more columns\": \"Inmobilizar una columna más\",\n        \"Freeze this column\": \"Inmovilizar esta columna\",\n        \"Freeze {{count}} columns\": \"Inmovilizar {{count}} columnas\",\n        \"Freeze {{count}} more columns\": \"Inmovilizar {{count}} columnas más\",\n        \"Hide column\": \"Ocultar columna\",\n        \"Hide {{count}} columns\": \"Ocultar {{count}} columnas\",\n        \"Insert column to the {{to}}\": \"Insertar columna en {{to}}\",\n        \"More sort options ...\": \"Más opciones de clasificación…\",\n        \"Rename column\": \"Renombrar columna\",\n        \"Reset column\": \"Restablecer columna\",\n        \"Reset entire column\": \"Restablecer toda la columna\",\n        \"Reset {{count}} columns\": \"Restablecer {{count}} columnas\",\n        \"Reset {{count}} entire columns\": \"Restablecer {{count}} columnas enteras\",\n        \"Show column {{- label}}\": \"Mostrar columna {{- label}}\",\n        \"Sort\": \"Ordenar\",\n        \"Sorted (#{{count}})\": \"Ordenado (#{{count}})\",\n        \"Unfreeze all columns\": \"Descongelar todas las columnas\",\n        \"Unfreeze this column\": \"Descongelar esta columna\",\n        \"Unfreeze {{count}} columns\": \"Descongelar {{count}} columnas\",\n        \"Reset {{count}} entire columns_one\": \"Restablecer toda la columna\",\n        \"Freeze {{count}} columns_one\": \"Inmovilizar esta columna\",\n        \"Reset {{count}} entire columns_other\": \"Restablecer {{count}} columnas enteras\",\n        \"Delete {{count}} columns_one\": \"Eliminar columna\",\n        \"Delete {{count}} columns_other\": \"Eliminar {{count}} columnas\",\n        \"Hide {{count}} columns_one\": \"Ocultar columna\",\n        \"Freeze {{count}} columns_other\": \"Inmovilizar {{count}} columnas\",\n        \"Freeze {{count}} more columns_one\": \"Inmobilizar una columna más\",\n        \"Freeze {{count}} more columns_other\": \"Inmovilizar {{count}} columnas más\",\n        \"Hide {{count}} columns_other\": \"Ocultar {{count}} columnas\",\n        \"Reset {{count}} columns_one\": \"Restablecer columna\",\n        \"Reset {{count}} columns_other\": \"Restablecer {{count}} columnas\",\n        \"Sorted (#{{count}})_one\": \"Ordenado (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Ordenado (#{{count}})\",\n        \"Unfreeze {{count}} columns_one\": \"Descongelar esta columna\",\n        \"Unfreeze {{count}} columns_other\": \"Descongelar {{count}} columnas\",\n        \"Insert column to the right\": \"Insertar columna a la derecha\",\n        \"Insert column to the left\": \"Insertar columna a la izquierda\",\n        \"Show hidden columns\": \"Mostrar las columnas ocultas\",\n        \"Created At\": \"Creado en\",\n        \"Authorship\": \"Autor\",\n        \"Last Updated By\": \"Última actualización por\",\n        \"Hidden Columns\": \"Columnas ocultas\",\n        \"Lookups\": \"Consultas\",\n        \"Apply on record changes\": \"Aplicar los cambios en el registro\",\n        \"Created By\": \"Creado por\",\n        \"Last Updated At\": \"Última actualización en\",\n        \"Apply to new records\": \"Aplicar a los nuevos registros\",\n        \"Timestamp\": \"Marca temporal\",\n        \"no reference column\": \"Sin columna de referencia\",\n        \"Shortcuts\": \"Atajos\",\n        \"Detect Duplicates in...\": \"Detectar duplicados en...\",\n        \"UUID\": \"UUID\",\n        \"No reference columns.\": \"Sin columnas de referencia.\",\n        \"Duplicate in {{- label}}\": \"Duplicado en {{- label}}\",\n        \"Search columns\": \"Buscar columnas\",\n        \"Adding UUID column\": \"Añadiendo una columna UUID\",\n        \"Adding duplicates column\": \"Añadiendo la columna de duplicados\",\n        \"Add formula column\": \"Añadir una columna de fórmulas\",\n        \"Add column with type\": \"Añadir columna con tipo\",\n        \"Created by\": \"Creado por\",\n        \"Created at\": \"Creado en\",\n        \"Last updated by\": \"Última actualización\",\n        \"Detect duplicates in...\": \"Detectar duplicados en...\",\n        \"Last updated at\": \"Última actualización en\",\n        \"Reference\": \"Referencia\",\n        \"Reference List\": \"Lista de referencias\",\n        \"Attachment\": \"Adjuntar\",\n        \"Numeric\": \"Numérico\",\n        \"Any\": \"Cualquiera\",\n        \"Text\": \"Texto\",\n        \"Choice\": \"Seleccione\",\n        \"Integer\": \"Entero\",\n        \"Date\": \"Fecha\",\n        \"Toggle\": \"Alternar\",\n        \"DateTime\": \"Fecha y hora\",\n        \"Choice List\": \"Lista de opciones\"\n    },\n    \"HomeIntro\": {\n        \", or find an expert via our \": \", o encontrar un experto a través de nuestro\",\n        \"Any documents created in this site will appear here.\": \"Cualquier documento creado en este sitio aparecerá aquí.\",\n        \"Browse Templates\": \"Explorar plantillas\",\n        \"Create empty document\": \"Crear un documento vacío\",\n        \"Get started by creating your first Grist document.\": \"Empieza creando tu primer documento Grist.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Empieza explorando plantillas o creando tu primer documento Grist.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Empieza invitando a tu equipo y creando tu primer documento Grist.\",\n        \"Help Center\": \"Centro de ayuda\",\n        \"Import document\": \"Importar documento\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"¿Te interesa usar Grist fuera de tu equipo? Visita gratis tu \",\n        \"Invite Team Members\": \"Invita a los miembros del equipo\",\n        \"Sign up\": \"Inscríbete\",\n        \"Sprouts Program\": \"Programa de Brotes\",\n        \"This workspace is empty.\": \"Este espacio de trabajo está vacío.\",\n        \"Visit our {{link}} to learn more.\": \"Visite nuestro {{link}} para aprender más.\",\n        \"Welcome to Grist!\": \"¡Bienvenido a Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"¡Bienvenido a Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Bienvenido a {{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Tiene acceso sólo lectura a este sitio. Actualmente no hay documentos.\",\n        \"personal site\": \"sitio personal\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} para guardar tu trabajo. \",\n        \"Welcome to Grist, {{- name}}!\": \"¡Bienvenido a Grist, {{- name}}!\",\n        \"Welcome to {{- orgName}}\": \"Bienvenido a {{- orgName}}\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Visita nuestra {{link}} para obtener más información sobre Grist.\",\n        \"Sign in\": \"Iniciar sesión\",\n        \"To use Grist, please either sign up or sign in.\": \"Para utilizar Grist, regístrate o inicia sesión.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Obtenga más información en nuestro {{helpCenterLink}}.\",\n        \"Only show documents\": \"Sólo mostrar documentos\"\n    },\n    \"HomeLeftPane\": {\n        \"Access Details\": \"Detalles de Acceso\",\n        \"All documents\": \"Todos los documentos\",\n        \"Create empty document\": \"Crear un documento vacío\",\n        \"Create workspace\": \"Crear espacio de trabajo\",\n        \"Delete\": \"Borrar\",\n        \"Delete {{workspace}} and all included documents?\": \"Eliminar {{workspace}} y todos los documentos incluidos?\",\n        \"Examples & Templates\": \"Plantillas\",\n        \"Import document\": \"Importar documento\",\n        \"Manage users\": \"Gestionar usuarios\",\n        \"Rename\": \"Renombrar\",\n        \"Trash\": \"Papelera\",\n        \"Workspace will be moved to Trash.\": \"El espacio de trabajo se moverá a la papelera.\",\n        \"Workspaces\": \"Espacios de trabajo\",\n        \"Tutorial\": \"Tutorial\",\n        \"Terms of service\": \"Términos del servicio\",\n        \"Grist Resources\": \"Recursos de Grist\",\n        \"context menu - {{- workspaceName }}\": \"menú contextual - {{- workspaceName }}\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Centro de ayuda\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"Como plantilla\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Tenga cuidado, el original tiene cambios que no están en este documento. Esos cambios serán sobrescritos.\",\n        \"Cancel\": \"Cancelar\",\n        \"Enter document name\": \"Introduzca el nombre del documento\",\n        \"However, it appears to be already identical.\": \"Sin embargo, parece ser ya idéntico.\",\n        \"Include the structure without any of the data.\": \"Incluye la estructura sin ninguno de los datos.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Se sobrescribirá, perdiendo cualquier contenido que no esté en este documento.\",\n        \"Name\": \"Nombre\",\n        \"No destination workspace\": \"No hay espacio de trabajo de destino\",\n        \"Organization\": \"Organización\",\n        \"Original Has Modifications\": \"El original tiene modificaciones\",\n        \"Original Looks Identical\": \"El original parece idéntico\",\n        \"Original Looks Unrelated\": \"El original parece no relacionado\",\n        \"Overwrite\": \"Sobrescribir\",\n        \"Replacing the original requires editing rights on the original document.\": \"Reemplazar el original requiere derechos de edición en el documento original.\",\n        \"Sign up\": \"Inscríbete\",\n        \"The original version of this document will be updated.\": \"La versión original de este documento será actualizada.\",\n        \"To save your changes, please sign up, then reload this page.\": \"Para guardar sus cambios, por favor regístrese, luego vuelva a cargar esta página.\",\n        \"Update\": \"Actualizar\",\n        \"Update Original\": \"Actualizar original\",\n        \"Workspace\": \"Espacio de trabajo\",\n        \"You do not have write access to the selected workspace\": \"No tienes acceso de escritura al espacio de trabajo seleccionado\",\n        \"You do not have write access to this site\": \"No tiene acceso de escritura a este sitio\",\n        \"Download document and history\": \"Descargar documento completo e historial\",\n        \"Download document structure only (no data, for template use)\": \"Elimine todos los datos pero mantenga la estructura para usarla como plantilla\",\n        \"Download document without history (can significantly reduce file size)\": \"Eliminar el historial del documento (puede reducir significativamente el tamaño del archivo)\",\n        \"Download\": \"Descargar\",\n        \"Download document\": \"Descargar el documento\",\n        \"Format:\": \"Formato:\",\n        \"Learn more\": \"Más información\",\n        \"download attachments\": \"descargar archivos adjuntos\",\n        \".tar (recommended)\": \".tar (recomendado)\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"Descargue un archivo con todos los anexos presentes en este documento.\",\n        \"Download full document and history\": \"Descargar documento completo e historial\",\n        \"Download attachments\": \"Descargar archivos adjuntos\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Los archivos adjuntos son externos y no se incluyen en esta descarga. Si carga el documento en otra instalación de Grist, también tendrá que {{downloadLink}} por separado. \"\n    },\n    \"NTextBox\": {\n        \"false\": \"falso\",\n        \"true\": \"verdadero\",\n        \"Field Format\": \"Formato del campo\",\n        \"Lines\": \"Líneas\",\n        \"Multi line\": \"Línea múltiple\",\n        \"Single line\": \"Una sola línea\"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"Solicitar ayuda\",\n        \"Cannot find personal site, sorry!\": \"¡No se puede encontrar el sitio personal, lo siento!\",\n        \"Give feedback\": \"Dar retroalimentación\",\n        \"Go to your free personal site\": \"Vaya a su sitio personal gratuito\",\n        \"No notifications\": \"Sin notificaciones\",\n        \"Notifications\": \"Notificaciones\",\n        \"Renew\": \"Renovar\",\n        \"Report a problem\": \"Reportar un problema\",\n        \"Upgrade Plan\": \"Actualizar el Plan\",\n        \"Manage billing\": \"Administrar la facturación\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Finalizar\",\n        \"Next\": \"Siguiente\",\n        \"Previous\": \"Anterior\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Recorrido en video de Grist\",\n        \"Video Tour\": \"Recorrido en vídeo\",\n        \"YouTube video player\": \"Reproductor de vídeo de YouTube\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Agregar a la página\",\n        \"Building {{- label}} widget\": \"Creación de widget {{- label}}\",\n        \"Group by\": \"Agrupar por\",\n        \"Select data\": \"Seleccionar datos\",\n        \"Select widget\": \"Seleccionar widget\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Borrar\",\n        \"Delete data and this page.\": \"Eliminar datos y esta página.\",\n        \"The following table will no longer be visible\": \"La siguiente tabla ya no será visible\",\n        \"The following tables will no longer be visible\": \"Las siguientes tablas ya no serán visibles\",\n        \"The following tables will no longer be visible_one\": \"La siguiente tabla ya no será visible\",\n        \"The following tables will no longer be visible_other\": \"Las siguientes tablas ya no serán visibles\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Conservar datos y borrar página. La tabla seguirá disponible en {{rawDataLink}}\",\n        \"raw data page\": \"página de datos brutos\",\n        \"Document pages\": \"Páginas de documento\"\n    },\n    \"RightPanel\": {\n        \"CHART TYPE\": \"TIPO DE GRÁFICO\",\n        \"COLUMN TYPE\": \"TIPO DE COLUMNA\",\n        \"CUSTOM\": \"PERSONALIZADO\",\n        \"Change widget\": \"Cambiar widget\",\n        \"Column\": \"Columna\",\n        \"Columns\": \"Columnas\",\n        \"DATA TABLE\": \"TABLA DE DATOS\",\n        \"DATA TABLE NAME\": \"NOMBRE DE LA TABLA DE DATOS\",\n        \"Data\": \"Datos\",\n        \"Detach\": \"Despegar\",\n        \"Edit data selection\": \"Editar la selección de datos\",\n        \"FILTER\": \"FILTRAR\",\n        \"Field\": \"Campo\",\n        \"Fields\": \"Campos\",\n        \"GROUPED BY\": \"AGRUPADO POR\",\n        \"Row style\": \"Estilo de fila\",\n        \"SELECT BY\": \"SELECCIONAR POR\",\n        \"SELECTOR FOR\": \"SELECTOR PARA\",\n        \"SORT\": \"ORDENAR\",\n        \"SOURCE DATA\": \"DATOS DE ORIGEN\",\n        \"Save\": \"Guardar\",\n        \"Select widget\": \"Seleccionar widget\",\n        \"Series\": \"Series\",\n        \"Sort & filter\": \"Ordenar y filtrar\",\n        \"TRANSFORM\": \"TRANSFORMAR\",\n        \"Theme\": \"Tema\",\n        \"WIDGET TITLE\": \"TÍTULO DEL WIDGET\",\n        \"Widget\": \"Widget\",\n        \"You do not have edit access to this document\": \"No tiene acceso de edición a este documento\",\n        \"series_one\": \"Serie\",\n        \"series_other\": \"Serie\",\n        \"columns_one\": \"Columna\",\n        \"columns_other\": \"Columnas\",\n        \"fields_one\": \"Campo\",\n        \"fields_other\": \"Campos\",\n        \"Add referenced columns\": \"Añadir columnas referenciadas\",\n        \"Reset form\": \"Restablecer el formulario\",\n        \"Default field value\": \"Valor predeterminado del campo\",\n        \"Configuration\": \"Configuración\",\n        \"Display button\": \"Botón de visualización\",\n        \"Field rules\": \"Reglas del campo\",\n        \"Submission\": \"Presentación\",\n        \"Submit button label\": \"Etiqueta del botón de enviar\",\n        \"Success text\": \"Texto exitoso\",\n        \"Submit another response\": \"Enviar otra respuesta\",\n        \"Table column name\": \"Nombre de la columna de la tabla\",\n        \"Enter redirect URL\": \"Introduzca la URL de redirección\",\n        \"Enter text\": \"Introduzca el texto\",\n        \"Field title\": \"Título del campo\",\n        \"Layout\": \"Diseño\",\n        \"Redirection\": \"Redirección\",\n        \"Required field\": \"Campo requerido\",\n        \"Hidden field\": \"Campo oculto\",\n        \"Redirect automatically after submission\": \"Redirigir automáticamente después del envío\",\n        \"No field selected\": \"Ningún campo seleccionado\",\n        \"Select a field in the form widget to configure.\": \"Seleccione un campo del widget del formulario para configurarlo.\",\n        \"Submit\": \"Enviar\",\n        \"Thank you! Your response has been recorded.\": \"¡Gracias! Tu respuesta ha sido grabada.\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"Copiar enlace de anclaje\",\n        \"Delete\": \"Borrar\",\n        \"Duplicate row\": \"Duplicar fila\",\n        \"Duplicate rows\": \"Duplicar filas\",\n        \"Insert row\": \"Insertar fila\",\n        \"Insert row above\": \"Insertar fila arriba\",\n        \"Insert row below\": \"Insertar fila debajo\",\n        \"Duplicate rows_one\": \"Duplicar fila\",\n        \"Duplicate rows_other\": \"Duplicar filas\",\n        \"View as card\": \"Ver como tarjeta\",\n        \"Use as table headers\": \"Usar como encabezados de la tabla\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"Detalles de Acceso\",\n        \"Back to current\": \"Volver a Actual\",\n        \"Compare to {{termToUse}}\": \"Comparar con {{termToUse}}\",\n        \"Current Version\": \"Versión actual\",\n        \"Download\": \"Descargar\",\n        \"Duplicate document\": \"Duplicar el documento\",\n        \"Edit without affecting the original\": \"Editar sin afectar el original\",\n        \"Export CSV\": \"Exportar CSV\",\n        \"Export XLSX\": \"Exportar XLSX\",\n        \"Manage users\": \"Gestionar usuarios\",\n        \"Original\": \"Original\",\n        \"Replace {{termToUse}}...\": \"Reemplazar {{termToUse}}…\",\n        \"Return to {{termToUse}}\": \"Volver a {{termToUse}}\",\n        \"Save copy\": \"Guardar copia\",\n        \"Save Document\": \"Guardar el documento\",\n        \"Send to Google Drive\": \"Enviar a Google Drive\",\n        \"Show in folder\": \"Mostrar en la carpeta\",\n        \"Unsaved\": \"No guardado\",\n        \"Work on a copy\": \"Trabajar en una copia\",\n        \"Share\": \"Compartir\",\n        \"Download...\": \"Descargar...\",\n        \"Comma Separated Values (.csv)\": \"Valores separados por comas (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"Valores separados DOO (.dsv)\",\n        \"Tab Separated Values (.tsv)\": \"Valores separados por tabulaciones (.tsv)\",\n        \"Export as...\": \"Exportar como...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Download document...\": \"Descargar documento...\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"La exportación sólo está disponible desde las páginas de documentos. Seleccione una página de documento e inténtelo de nuevo.\",\n        \"Download attachments...\": \"Descargar archivos adjuntos...\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Crear nuevo sitio de equipo\",\n        \"Switch Sites\": \"Cambiar de sitio\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Agregar columna\",\n        \"Empty values last\": \"Valores vacíos en último lugar\",\n        \"Natural sort\": \"Clasificación natural\",\n        \"Update data\": \"Actualizar datos\",\n        \"Use choice position\": \"Usar posición de elección\",\n        \"Search Columns\": \"Buscar en las columnas\"\n    },\n    \"SortFilterConfig\": {\n        \"FILTER\": \"FILTRAR\",\n        \"Revert\": \"Revertir\",\n        \"SORT\": \"ORDENAR\",\n        \"Save\": \"Guardar\",\n        \"Update Sort & Filter settings\": \"Actualizar los ajustes para la clasificación y el filtrado\",\n        \"Filter\": \"FILTRAR\",\n        \"Sort\": \"ORDENAR\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Apariencia \",\n        \"Switch appearance automatically to match system\": \"Cambiar la apariencia automáticamente para que coincida con el sistema\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Reglas de acceso\",\n        \"Code view\": \"Vista de código\",\n        \"Delete\": \"Borrar\",\n        \"Delete document tour?\": \"¿Borrar el recorrido del documento?\",\n        \"Document history\": \"Historial del documento\",\n        \"How-to Tutorial\": \"Tutorial de instrucciones\",\n        \"Raw data\": \"Datos brutos\",\n        \"Return to viewing as yourself\": \"Volver a ver como usted mismo\",\n        \"TOOLS\": \"HERRAMIENTAS\",\n        \"Tour of this Document\": \"Recorrido por este documento\",\n        \"Validate Data\": \"Validar datos\",\n        \"Settings\": \"Ajustes\",\n        \"API console\": \"Consola de la API\",\n        \"context menu - Access Rules\": \"menú contextual - Reglas de acceso\",\n        \"Delete document tour\": \"Borrar el recorrido del documento\",\n        \"Preview the tutorial\": \"Vista previa del tutorial\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Administrar equipo\"\n    },\n    \"TriggerFormulas\": {\n        \"(data cleaning)\": \"(limpieza de datos)\",\n        \"(except formulas)\": \"(excepto fórmulas)\",\n        \"Any field\": \"Cualquier campo\",\n        \"Apply on changes to:\": \"Aplicar en cambios a:\",\n        \"Apply on record changes\": \"Aplicar en cambios de registro\",\n        \"Apply to new records\": \"Aplicar a nuevos registros\",\n        \"Cancel\": \"Cancelar\",\n        \"Close\": \"Cerrar\",\n        \"Current field \": \"Campo actual \",\n        \"OK\": \"OK\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Clasificación y filtrado avanzados\",\n        \"Copy anchor link\": \"Copiar enlace de anclaje\",\n        \"Data selection\": \"Selección de datos\",\n        \"Delete record\": \"Borrar registro\",\n        \"Delete widget\": \"Eliminar el widget\",\n        \"Download as CSV\": \"Descargar como CSV\",\n        \"Download as XLSX\": \"Descargar como XLSX\",\n        \"Edit card layout\": \"Editar el diseño de la tarjeta\",\n        \"Open configuration\": \"Abrir configuración\",\n        \"Print widget\": \"Imprimir widget\",\n        \"Show raw data\": \"Mostrar datos brutos\",\n        \"Widget options\": \"Opciones de Widget\",\n        \"Collapse widget\": \"Ocultar el widget\",\n        \"Add to page\": \"Añadir a la página\",\n        \"Create a form\": \"Crear un formulario\",\n        \"Duplicate widget\": \"Duplicar widget\"\n    },\n    \"ViewSectionMenu\": {\n        \"(customized)\": \"(personalizado)\",\n        \"(empty)\": \"(vacío)\",\n        \"(modified)\": \"(modificado)\",\n        \"Add Filter\": \"Añadir filtro\",\n        \"Custom options\": \"Opciones personalizadas\",\n        \"FILTER\": \"FILTRAR\",\n        \"Filtered by\": \"Filtrado por\",\n        \"Revert\": \"Revertir\",\n        \"SORT\": \"ORDENAR\",\n        \"Save\": \"Guardar\",\n        \"Sorted by\": \"Ordenado por\",\n        \"Toggle Filter Bar\": \"Alternar barra de filtro\",\n        \"Update Sort&Filter settings\": \"Actualizar los ajustes para la clasificación y el filtrado\",\n        \"Sort and filter\": \"Ordenar y filtrar\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"No se pueden colocar elementos en campos ocultos\",\n        \"Clear\": \"Limpiar\",\n        \"Hidden Fields cannot be reordered\": \"Los campos ocultos no pueden ser reordenados\",\n        \"Select all\": \"Seleccionar todo\",\n        \"Hide {{label}}\": \"Ocultar {{label}}\",\n        \"Hidden {{label}}\": \"{{label}} oculta\",\n        \"Show {{label}}\": \"Mostrar {{label}}\",\n        \"Visible {{label}}\": \"{{label}} visible\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Educación\",\n        \"Finance & Accounting\": \"Finanzas y Contabilidad\",\n        \"HR & Management\": \"RRHH y Gestión\",\n        \"IT & Technology\": \"TI y Tecnología\",\n        \"Marketing\": \"Publicidad\",\n        \"Media Production\": \"Producción audiovisual\",\n        \"Other\": \"Otros\",\n        \"Product Development\": \"Desarrollo de productos\",\n        \"Research\": \"Investigación\",\n        \"Sales\": \"Ventas\",\n        \"Type here\": \"Escriba aquí\",\n        \"Welcome to Grist!\": \"¡Bienvenido a Grist!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"¿Qué te trae a Grist? Por favor, ayúdenos a servirle mejor.\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"Cancelar\",\n        \"DATA TABLE NAME\": \"NOMBRE DE LA TABLA DE DATOS\",\n        \"Override widget title\": \"Sobrescribir título del Widget\",\n        \"Provide a table name\": \"Proporcionar un nombre de tabla\",\n        \"Save\": \"Guardar\",\n        \"WIDGET TITLE\": \"TÍTULO DEL WIDGET\",\n        \"WIDGET DESCRIPTION\": \"DESCRIPCIÓN DEL WIDGET\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Acceso negado{{suffix}}\",\n        \"Add account\": \"Agregar cuenta\",\n        \"Contact support\": \"Contactar con el soporte\",\n        \"Error{{suffix}}\": \"Error{{suffix}}\",\n        \"Go to main page\": \"Ir a la página principal\",\n        \"Page not found{{suffix}}\": \"Página no encontrada{{suffix}}\",\n        \"Sign in\": \"Iniciar sesión\",\n        \"Sign in again\": \"Iniciar sesión de nuevo\",\n        \"Sign in to access this organization's documents.\": \"Inicie sesión para acceder a los documentos de esta organización.\",\n        \"Signed out{{suffix}}\": \"Sesión cerrada{{suffix}}\",\n        \"Something went wrong\": \"Algo salió mal\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"La página solicitada no se pudo encontrar.{{separator}}Por favor, compruebe la URL e inténtelo de nuevo.\",\n        \"There was an error: {{message}}\": \"Hubo un error: {{message}}\",\n        \"There was an unknown error.\": \"Hubo un error desconocido.\",\n        \"You are now signed out.\": \"Ya has cerrado la sesión.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Has iniciado sesión como {{email}}. Puede iniciar sesión con otra cuenta o solicitar acceso a un administrador.\",\n        \"You do not have access to this organization's documents.\": \"No tiene acceso a los documentos de esta organización.\",\n        \"Account deleted{{suffix}}\": \"Cuenta borrada{{suffix}}\",\n        \"Your account has been deleted.\": \"Tu cuenta ha sido eliminada.\",\n        \"Sign up\": \"Inscribirse\",\n        \"Form not found\": \"Formulario no encontrado\",\n        \"Powered by\": \"Desarrollado por\",\n        \"An unknown error occurred.\": \"Ha ocurrido un error desconocido.\",\n        \"Build your own form\": \"Cree su propio formulario\",\n        \"Sign-in failed{{suffix}}\": \"Error al iniciar sesión{{suffix}}\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Error al iniciar sesión.{{separator}}Por favor, inténtelo de nuevo o póngase en contacto con el soporte.\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Enviar archivo a Google Drive\"\n    },\n    \"DataTables\": {\n        \"You do not have edit access to this document\": \"No tiene acceso de edición a este documento\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"¿Borrar los datos de {{formattedTableName}} y eliminarlos de todas las páginas?\",\n        \"Duplicate table\": \"Duplicar tabla\",\n        \"Raw Data Tables\": \"Tablas de datos brutos\",\n        \"Table ID copied to clipboard\": \"ID de tabla copiado al portapapeles\",\n        \"Click to copy\": \"Haga clic para copiar\",\n        \"Edit record card\": \"Editar la ficha del registro\",\n        \"Rename table\": \"Cambiar el nombre de la tabla\",\n        \"{{action}} Record Card\": \"{{action}} Ficha\",\n        \"Record Card\": \"Ficha de registro\",\n        \"Remove table\": \"Quitar la tabla\",\n        \"Record Card Disabled\": \"Tarjeta de registro desactivada\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Agregar tabla vacía\",\n        \"Add widget to page\": \"Agregar Widget a la página\",\n        \"Add page\": \"Agregar página\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Los propietarios de documentos pueden intentar recuperar el documento. [{{error}}]\",\n        \"Enter recovery mode\": \"Entrar en modo de recuperación\",\n        \"Error accessing document\": \"Error al acceder al documento\",\n        \"Reload\": \"Recargar\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Lo sentimos, el acceso a este documento ha sido denegado. [{{error}}]\",\n        \"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}}]\": \"Puede intentar volver a cargar el documento o usar el modo de recuperación. El modo de recuperación abre el documento para ser totalmente accesible a los propietarios, e inaccesible a otros. También desactiva fórmulas. [{{error}}]\",\n        \"You do not have edit access to this document\": \"No tiene acceso de edición a este documento\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Vuelva a cargar el documento y, si el error persiste, póngase en contacto con los propietarios del documento para intentar recuperar el documento. [{{error}}]\"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"Añadido nueva sección vinculada a la vista {{viewName}}\",\n        \"Import from file\": \"Importar desde archivo\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Sección vinculada guardada {{title}} a la vista {{name}}\",\n        \"go to webhook settings\": \"ir a la configuración webhook\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Los nuevos cambios se suspenden temporalmente. La cola de webhooks se ha desbordado. Compruebe la configuración de los webhooks, quite los webhooks no válidos y limpie la cola.\"\n    },\n    \"ACUserManager\": {\n        \"Enter email address\": \"Introduzca la dirección de correo electrónico\",\n        \"Invite new member\": \"Invitar nuevo miembro\",\n        \"We'll email an invite to {{email}}\": \"Enviaremos una invitación por correo electrónico a {{email}}\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Ver como\",\n        \"Users from table\": \"Usuarios de la tabla\",\n        \"Example Users\": \"Usuarios de ejemplo\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"No se pudo cargar el registro de acciones\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Columna {{colId}} fue posteriormente removida en acción #{{action.actionNum}}\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"La tabla {{tableId}} fue posteriormente removida en acción #{{actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Esta fila fue removida posteriormente en acción {{action.actionNum}}\",\n        \"All tables\": \"Todas las tablas\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Este sitio de equipo está suspendido. Los documentos pueden ser leídos, pero no modificados.\"\n    },\n    \"ChartView\": {\n        \"Create separate series for each value of the selected column.\": \"Crear series separadas para cada valor de la columna seleccionada.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Cada serie Y es seguida por una serie para la longitud de las barras de error.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Cada serie Y es seguida por dos series, para barras de error superior e inferior.\",\n        \"Pick a column\": \"Elige una columna\",\n        \"Toggle chart aggregation\": \"Alternar la agregación de gráficos\",\n        \"selected new group data columns\": \"nuevas columnas de datos de grupo seleccionadas\",\n        \"LABEL\": \"ETIQUETA\",\n        \"Connect gaps\": \"Conectar vacíos\",\n        \"Show markers\": \"Mostrar marcadores\",\n        \"Stack series\": \"Serie apilada\",\n        \"Error bars\": \"Barras de error\",\n        \"Split Series\": \"Serie dividida\",\n        \"X-AXIS\": \"EJE-X\",\n        \"Aggregate values\": \"Valores agregados\",\n        \"SERIES\": \"SERIE\",\n        \"Add series\": \"Agregar serie\",\n        \"non-numeric columns are not shown\": \"columnas no numéricas no se muestran\",\n        \"non-numeric column is not shown\": \"columna no numérica no se muestra\",\n        \"selected new x-axis\": \"nuevo eje x seleccionado\",\n        \"Remove\": \"Quitar\",\n        \"Vertical\": \"Vertical\",\n        \"Line chart\": \"Gráfico lineal\",\n        \"Area chart\": \"Cuadro de áreas\",\n        \"Split series\": \"Serie dividida\",\n        \"Show total\": \"Mostrar total\",\n        \"Pie chart\": \"Gráfico circular\",\n        \"Kaplan-Meier plot\": \"Gráfico de Kaplan-Meier\",\n        \"Invert Y-axis\": \"Invertir eje Y\",\n        \"Log scale Y-axis\": \"Escala logarítmica Eje Y\",\n        \"Text size\": \"Tamaño del texto\",\n        \"Symmetric\": \"Simétrico\",\n        \"Above+Below\": \"Arriba+Abajo\",\n        \"Donut chart\": \"Gráfico de donuts\",\n        \"None\": \"Ninguno\",\n        \"Scatter plot\": \"Gráfico de dispersión\",\n        \"Horizontal\": \"Horizontal\",\n        \"Bar chart\": \"Gráfico de barras\",\n        \"Orientation\": \"Orientación\",\n        \"Hole size\": \"Tamaño del orificio\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Acceso denegado\",\n        \"Code View is available only when you have full document access.\": \"La Vista de Código sólo está disponible cuando se tiene acceso total al documento.\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Default cell style\": \"Estilo de celda predeterminado\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"Tamaño de los adjuntos\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Póngase en contacto con el propietario del sitio para actualizar el plan para aumentar los límites.\",\n        \"Data size\": \"Tamaño de los datos\",\n        \"For higher limits, \": \"Para límites más altos, \",\n        \"Rows\": \"Filas\",\n        \"Usage\": \"Utilización\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"Las estadísticas de uso sólo están disponibles para los usuarios con acceso completo a los datos del documento.\",\n        \"start your 30-day free trial of the Pro plan.\": \"iniciar su prueba gratuita de 30 días del plan Pro.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Restaurar la última edición\",\n        \"Undo discard\": \"Deshacer descarte\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Regla {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Actualizar fórmula (Mayús+Intro)\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Usuario desconocido\",\n        \"You are viewing this document as\": \"Estás viendo este documento como\",\n        \"View as Yourself\": \"Ver como Tú mismo\",\n        \"You're seeing what this user would see if given access\": \"Estás viendo lo que este usuario vería si se le diera acceso\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Ajustes avanzados\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Las tablas grandes pueden ser marcadas como \\\"a demanda\\\" para evitar cargarlas en el motor de datos.\",\n        \"Blocks\": \"Bloques\",\n        \"Compact\": \"Compacto\",\n        \"Edit card layout\": \"Editar el diseño de la tarjeta\",\n        \"Form\": \"Formulario\",\n        \"Make On-Demand\": \"Hacer a demanda\",\n        \"Plugin: \": \"Plugin: \",\n        \"Section: \": \"Sección: \",\n        \"Unmark On-Demand\": \"Desmarcar bajo demanda\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"Las tablas de demanda han quedado obsoletas por falta de funcionalidad y problemas de usabilidad.\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Función obsoleta\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Buscar en columnas\",\n        \"Search Columns\": \"Buscar en las columnas\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"Combinar filas que coincidan con estos campos:\",\n        \"Select fields to match on\": \"Seleccionar campos para que coincidan\",\n        \"Update existing records\": \"Actualizar los registros existentes\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} campo no coincide con la importación\",\n        \"{{count}} unmatched field_one\": \"{{count}} campo sin equivalente\",\n        \"{{count}} unmatched field_other\": \"{{count}} campos sin equivalentes\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} campos sin equivalente en la importación\",\n        \"Column Mapping\": \"Asignación de columnas\",\n        \"Column mapping\": \"Asignación de las columnas\",\n        \"Import from file\": \"Importar desde un archivo\",\n        \"New Table\": \"Tabla nueva\",\n        \"Revert\": \"Revertir\",\n        \"Skip\": \"Omitir\",\n        \"Skip Import\": \"Omitir la importación\",\n        \"Source column\": \"Columna de origen\",\n        \"Destination table\": \"Tabla de destino\",\n        \"Grist column\": \"Columna Grist\",\n        \"Skip Table on Import\": \"Omitir la tabla en la importación\",\n        \"Import options\": \"Opciones de importación\",\n        \"Cancel\": \"Cancelar\",\n        \"Import\": \"Importar\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Permitir todo\",\n        \"Deny all\": \"Denegar todo\",\n        \"Read only\": \"Sólo lectura\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"La importación falló: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Actualización del diseño del registro.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Agregar campo\",\n        \"Create new field\": \"Crear nuevo campo\",\n        \"Show field {{- label}}\": \"Mostrar campo {{- label}}\",\n        \"Save layout\": \"Guardar el diseño\",\n        \"Cancel\": \"Cancelar\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Agregar columna\",\n        \"No columns to add\": \"No hay columnas que agregar\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Copiado al portapapeles\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Revise\": \"Revisar\",\n        \"Update formula (Shift+Enter)\": \"Actualizar fórmula (Mayús+Intro)\",\n        \"Preview\": \"Vista previa\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Editor\",\n        \"In full\": \"Totalmente\",\n        \"No Default Access\": \"Sin acceso predeterminado\",\n        \"None\": \"Ninguno\",\n        \"Owner\": \"Propietario\",\n        \"View & edit\": \"Ver y editar\",\n        \"View only\": \"Ver sólo\",\n        \"Viewer\": \"Espectador\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Puede realizar ediciones, pero crearán una nueva copia y\\nno afectará al documento original.\",\n        \"fiddle\": \"experimentar\",\n        \"override\": \"sobrescribir\",\n        \"recovery mode\": \"modo de recuperación\",\n        \"snapshot\": \"instantánea\",\n        \"unsaved\": \"no guardado\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Duplicar página {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Tenga en cuenta que esto no copia datos, pero crea otra visión de los mismos datos.\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* Los espacios de trabajo están disponibles en los planes de equipo. \",\n        \"Select fields\": \"Seleccionar campos\",\n        \"Upgrade now\": \"Actualizar ahora\",\n        \"Numeric\": \"Numérico\",\n        \"Text\": \"Texto\",\n        \"Integer\": \"Entero\",\n        \"Date\": \"Fecha\",\n        \"DateTime\": \"Fecha y hora\",\n        \"Choice\": \"Elección\",\n        \"Choice List\": \"Lista de opciones\",\n        \"Reference\": \"Referencia\",\n        \"Reference List\": \"Lista de referencia\",\n        \"Attachment\": \"Adjunto\",\n        \"Any\": \"Cualquiera\",\n        \"Toggle\": \"Alternar\",\n        \"Search columns\": \"Búsqueda por columnas\",\n        \"By Date Modified\": \"Por fecha de modificación\",\n        \"By Name\": \"Por nombre\",\n        \"Light\": \"Claro\",\n        \"Custom\": \"Personalizado\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Cancelar\",\n        \"Ok\": \"OK\",\n        \"Save\": \"Guardar\",\n        \"Are you sure you want to delete this record?\": \"¿Seguro que quieres borrar este registro?\",\n        \"Delete\": \"Borrar\",\n        \"Don't show tips\": \"No muestres consejos\",\n        \"Undo to restore\": \"Deshacer para restaurar\",\n        \"Got it\": \"Entendido\",\n        \"Dismiss\": \"Descartar\",\n        \"Don't ask again.\": \"No preguntes de nuevo.\",\n        \"Don't show again.\": \"No vuelvas a mostrarlo.\",\n        \"Don't show again\": \"No volver a mostrar\",\n        \"Are you sure you want to delete these records?\": \"¿Seguro que quieres borrar estos registros?\",\n        \"TIP\": \"TIP\",\n        \"Confirm\": \"Confirmar\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Duplicar página\",\n        \"Remove\": \"Quitar\",\n        \"Rename\": \"Renombrar\",\n        \"You do not have edit access to this document\": \"No tiene acceso de edición a este documento\",\n        \"(default)\": \"(por defecto)\",\n        \"Collapse {{maybeDefault}}\": \"Contraer {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Expandir {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Establecer predeterminado: Contraer\",\n        \"Set default: Expand\": \"Establecer predeterminado: Expandir\",\n        \"context menu - {{- pageName }}\": \"menú contextual - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Find Next \": \"Buscar siguiente \",\n        \"Find Previous \": \"Buscar anterior \",\n        \"No results\": \"Sin resultados\",\n        \"Search in document\": \"Buscar en el documento\",\n        \"Search\": \"Buscar\"\n    },\n    \"ACLUsers\": {\n        \"Users from table\": \"Usuarios de la tabla\",\n        \"View as\": \"Ver como\",\n        \"Example Users\": \"Usuarios de ejemplo\",\n        \"Other users from table\": \"Otros usuarios de la tabla\",\n        \"Shared users\": \"Usuarios compartidos\"\n    },\n    \"TypeTransformation\": {\n        \"Update formula (Shift+Enter)\": \"Actualizar fórmula (Mayús+Intro)\",\n        \"Cancel\": \"Cancelar\",\n        \"Revise\": \"Revisar\",\n        \"Apply\": \"Aplicar\",\n        \"Preview\": \"Vista previa\"\n    },\n    \"CellStyle\": {\n        \"Cell style\": \"Estilo de celda\",\n        \"Default cell style\": \"Estilo de celda predeterminado\",\n        \"CELL STYLE\": \"ESTILO DE CELDA\",\n        \"Open row styles\": \"Abrir estilos de fila\",\n        \"Mixed style\": \"Estilo mixto\",\n        \"Header Style\": \"Estilo del encabezado\",\n        \"Default header style\": \"Estilo del encabezado por defecto\",\n        \"HEADER STYLE\": \"ESTILO DEL ENCABEZADO\"\n    },\n    \"ColumnInfo\": {\n        \"Cancel\": \"Cancelar\",\n        \"Save\": \"Guardar\",\n        \"COLUMN DESCRIPTION\": \"DESCRIPCIÓN DE LA COLUMNA\",\n        \"COLUMN ID: \": \"ID DE COLUMNA: \",\n        \"COLUMN LABEL\": \"ETIQUETA DE COLUMNA\"\n    },\n    \"FieldBuilder\": {\n        \"Revert field settings for {{colId}} to common\": \"Revertir la configuración de campo de {{colId}} a común\",\n        \"Use separate field settings for {{colId}}\": \"Utilice configuraciones de campo separadas para {{colId}}\",\n        \"Save field settings for {{colId}} as common\": \"Guardar configuración de campo de {{colId}} como común\",\n        \"DATA FROM TABLE\": \"DATOS DE LA TABLA\",\n        \"Mixed types\": \"Tipos mixtos\",\n        \"Apply formula to data\": \"Aplicar Fórmula a Datos\",\n        \"CELL FORMAT\": \"FORMATO DE CELDA\",\n        \"Changing multiple column types\": \"Cambiar varios tipos de columna\",\n        \"Mixed format\": \"Formato mixto\",\n        \"Changing column type\": \"Cambiar el tipo de columna\",\n        \"Common\": \"Común\",\n        \"Separate\": \"Separado\",\n        \"Field in {{count}} views_one\": \"Campo en una vista\",\n        \"Field in {{count}} views_other\": \"Campo en {{count}} vistas\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Moneda inválida\"\n    },\n    \"DiscussionEditor\": {\n        \"Cancel\": \"Cancelar\",\n        \"Edit\": \"Editar\",\n        \"Only current page\": \"Sólo página actual\",\n        \"Remove\": \"Quitar\",\n        \"Resolve\": \"Resolver\",\n        \"Save\": \"Guardar\",\n        \"Started discussion\": \"Discusión iniciada\",\n        \"Show resolved comments\": \"Mostrar comentarios resueltos\",\n        \"Write a comment\": \"Escribe un comentario\",\n        \"Marked as resolved\": \"Marcado como resuelto\",\n        \"Only my threads\": \"Sólo mis hilos\",\n        \"Reply\": \"Respuesta\",\n        \"Comment\": \"Comentario\",\n        \"Open\": \"Abrir\",\n        \"Reply to a comment\": \"Responder a un comentario\",\n        \"Showing last {{nb}} comments\": \"Mostrando los últimos {{nb}} comentarios\",\n        \"Remove thread\": \"Quitar hilo\",\n        \"updated\": \"actualizado\",\n        \"Copy link\": \"Copiar enlace\",\n        \"{{count}} comments_one\": \"{{count}} comentario\",\n        \"{{count}} comments_other\": \"{{count}} comentarios\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Convertir columna en fórmula\"\n    },\n    \"FormulaEditor\": {\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Errores en {{numErrors}} de {{numCells}} celdas\",\n        \"Column or field is required\": \"Se requiere columna o campo\",\n        \"Error in the cell\": \"Error en la celda\",\n        \"Errors in all {{numErrors}} cells\": \"Errores en todas las {{numErrors}} celdas\",\n        \"editingFormula is required\": \"ediciónFórmula es necesaria\",\n        \"use AI Assistant\": \"usar el Asistente de IA\",\n        \"Enter formula or {{button}}.\": \"Introduzca la fórmula o {{button}}.\",\n        \"Enter formula.\": \"Introducir fórmula.\",\n        \"Expand Editor\": \"Expandir Editor\"\n    },\n    \"WelcomeTour\": {\n        \"Add new\": \"Agregar Nuevo\",\n        \"Configuring your document\": \"Configurando tu documento\",\n        \"Enter\": \"Intro\",\n        \"Flying higher\": \"Volando más alto\",\n        \"Reference\": \"Referencia\",\n        \"Start with {{equal}} to enter a formula.\": \"Comience con {{equal}} para introducir una fórmula.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Alternar el {{creatorPanel}} para dar formato a las columnas, \",\n        \"Welcome to Grist!\": \"¡Bienvenido a Grist!\",\n        \"template library\": \"biblioteca de plantillas\",\n        \"creator panel\": \"panel creador\",\n        \"Sharing\": \"Compartiendo\",\n        \"Help Center\": \"Centro de ayuda\",\n        \"Building up\": \"Construyendo\",\n        \"Customizing columns\": \"Personalizando columnas\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Haga doble clic o pulse {{enter}} en una celda para editarla. \",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Explore nuestro {{templateLibrary}} para descubrir lo que es posible y sentirse inspirado.\",\n        \"Editing Data\": \"Editando datos\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"¡Hazlo relacional! Utilice el tipo {{ref}} para vincular tablas. \",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Establezca opciones de formato, fórmulas o tipos de columnas, como fechas, opciones o anexos. \",\n        \"Share\": \"Compartir\",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Utilice el botón Compartir ({{share}}) para compartir el documento o exportar los datos.\",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Utilice {{helpCenter}} para documentación o preguntas.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Utilice {{addNew}} para añadir widgets, páginas o importar más datos. \",\n        \"convert to card view, select data, and more.\": \"convertir a la vista de la tarjeta, seleccionar datos y más.\",\n        \"AI Assistant\": \"Asistente de IA\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[etiqueta de enlace] URL\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"Moneda\",\n        \"Default currency ({{defaultCurrency}})\": \"Moneda predeterminada ({{defaultCurrency}})\",\n        \"Number Format\": \"Formato de número\",\n        \"Decimals\": \"Decimales\",\n        \"Field Format\": \"Formato del campo\",\n        \"Spinner\": \"Girador\",\n        \"Text\": \"Texto\",\n        \"min\": \"mín.\",\n        \"max\": \"Máx.\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"OPCIONES\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"DESCRIPCIÓN DE LA COLUMNA\",\n        \"COLUMN LABEL\": \"ETIQUETA DE COLUMNA\"\n    },\n    \"FieldEditor\": {\n        \"Unable to finish saving edited cell\": \"No se puede terminar de guardar la celda editada\",\n        \"It should be impossible to save a plain data value into a formula column\": \"Debería ser imposible guardar un valor de datos plano en una columna de fórmulas\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"Añadir otra regla\",\n        \"Error in style rule\": \"Error en la regla de estilo\",\n        \"Rule must return True or False\": \"La regla debe regresar Verdadera o Falsa\",\n        \"Add conditional style\": \"Añadir estilo condicional\",\n        \"Row style\": \"Estilo de fila\",\n        \"IF...\": \"SI...\",\n        \"Conditional Style\": \"Estilo condicional\",\n        \"Row Style\": \"Estilo de fila\"\n    },\n    \"Reference\": {\n        \"SHOW COLUMN\": \"MOSTRAR COLUMNA\",\n        \"CELL FORMAT\": \"FORMATO DE CELDA\",\n        \"Row ID\": \"ID de fila\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Idioma\"\n    },\n    \"GristTooltips\": {\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Aplicar formato condicional a las células en esta columna cuando se cumplen las condiciones de la fórmula.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Al hacer clic en {{EyeHideIcon}} en cada celda esconde el campo desde esta vista sin borrarlo.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Los filtros anclados se muestran como botones encima del widget.\",\n        \"Pinning Filters\": \"Anclando filtros\",\n        \"Raw Data page\": \"Página de datos brutos\",\n        \"Reference Columns\": \"Columnas de referencia\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Aplicar formato condicional a filas basado en fórmulas.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Haga clic en \\\"Abrir estilos de filas\\\" para aplicar formato condicional a filas.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Haga clic en el botón Añadir nuevo para crear nuevos documentos o espacios de trabajo, o importar datos.\",\n        \"Editing Card Layout\": \"Editando diseño de la tarjeta\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Las celdas en una columna de referencia siempre identifican un registro {{entire}} en esa tabla, pero puede seleccionar qué columna de ese registro mostrar.\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Fórmulas que se activan en ciertos casos y almacenan el valor calculado como datos.\",\n        \"Link your new widget to an existing widget on this page.\": \"Vincule su nuevo widget a un widget existente en esta página.\",\n        \"Linking Widgets\": \"Vinculando widgets\",\n        \"Learn more.\": \"Más información.\",\n        \"Only those rows will appear which match all of the filters.\": \"Sólo aparecerán las filas que coincidan con todos los filtros.\",\n        \"Nested Filtering\": \"Filtros anidados\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Reorganice los campos de su tarjeta arrastrando y cambiando el tamaño de las celdas.\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Las columnas de referencia son la clave de los datos {{relational}} en Grist.\",\n        \"Selecting Data\": \"Seleccionando datos\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Prueba los cambios en una copia y, a continuación, decide si quieres reemplazar el original con tus ediciones.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Utilice el icono \\\\u{1D6BA} para crear tablas resumen (o dinámicas), para totales o subtotales.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Útil para almacenar la marca de tiempo o el autor de un nuevo registro, limpieza de datos y más.\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Las reglas de acceso le dan el poder de crear reglas matizadas para determinar quién puede ver o editar qué partes de su documento.\",\n        \"Select the table containing the data to show.\": \"Seleccione la tabla que contiene los datos que desea mostrar.\",\n        \"Select the table to link to.\": \"Seleccione la tabla a vincular.\",\n        \"The total size of all data in this document, excluding attachments.\": \"El tamaño total de todos los datos en este documento, excluyendo los adjuntos.\",\n        \"They allow for one record to point (or refer) to another.\": \"Permiten que un registro apunte (o se refiera) a otro.\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"La página Datos brutos enumera todas las tablas de datos del documento, incluidas las tablas de resumen y las tablas no incluidas en los diseños de página.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"Este es el secreto de los diseños dinámicos y productivos de Grist.\",\n        \"Updates every 5 minutes.\": \"Actualiza cada 5 minutos.\",\n        \"You can filter by more than one column.\": \"Puedes filtrar por más de una columna.\",\n        \"Add new\": \"Agregar Nuevo\",\n        \"entire\": \"entero\",\n        \"relational\": \"relacionales\",\n        \"Access Rules\": \"Reglas de acceso\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Utilice el icono 𝚺 para crear tablas resumen (o dinámicas), para totales o subtotales.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Desancla para ocultar el botón mientras mantienes el filtro.\",\n        \"Anchor Links\": \"Enlaces fijados\",\n        \"Custom Widgets\": \"Widgets personalizados\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Para crear un enlace de anclaje que lleve al usuario a una celda específica, haga clic en una fila y pulse {{shortcut}}.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Puede elegir uno de nuestros widgets prediseñados o incrustar el suyo proporcionando su URL completa.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"Para configurar tu calendario, selecciona las columnas para las fechas de inicio y fin y los títulos de los eventos. Ten en cuenta el tipo de cada columna.\"\n        },\n        \"Calendar\": \"Calendario\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"¿No encuentras las columnas adecuadas? Haz clic en \\\"Cambiar widget\\\" para seleccionar la tabla con los datos de los eventos.\",\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"Un UUID es una cadena generada aleatoriamente que resulta útil para identificadores únicos y claves de los enlaces.\",\n        \"Lookups return data from related tables.\": \"Las búsquedas devuelven datos de tablas relacionadas.\",\n        \"Use reference columns to relate data in different tables.\": \"Utilizar las columnas de referencia para relacionar los datos de distintas tablas.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Puedes elegir entre los widgets disponibles en el menú desplegable, o incrustar el suyo propio proporcionando su dirección URL completa.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Las fórmulas admiten muchas funciones de Excel, sintaxis completa de Python e incluyen un útil asistente de inteligencia artificial.\",\n        \"Forms are here!\": \"¡Los formularios están aquí!\",\n        \"Learn more\": \"Más información\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Cree formularios sencillos directamente en Grist y compártalos en un clic con nuestro nuevo widget. {{learnMoreButton}}\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Estas reglas se aplican después de que se hayan procesado todas las reglas de columna, si procede.\",\n        \"Filter displayed dropdown values with a condition.\": \"Filtra los valores desplegables mostrados con una condición.\",\n        \"Example: {{example}}\": \"Ejemplo: {{example}}\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Crea una columna inversa en la tabla de destino que puede editarse desde cualquier extremo.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Para permitir asignaciones múltiples, cambie el tipo de la columna Referencia a Lista de referencias.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Los widgets comunitarios son creados y mantenidos por los miembros de la comunidad Grist.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Esta limitación se produce cuando uno de los extremos de una referencia bidireccional se configura como Referencia única.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Esta limitación ocurre cuando una columna en una referencia bidireccional es de tipo Referencia.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Para permitir asignaciones múltiples, cambie el tipo de la Columna referenciada a Lista de referencias.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Las referencias bidireccionales no son compatibles actualmente con las columnas de fórmula o fórmula de activación\",\n        \"The preview below this header shows how the selected user will see this document\": \"La vista previa debajo de este encabezado muestra cómo verá este documento el usuario seleccionado\",\n        \"[Learn more.]({{link}})\": \"[Aprende más.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Mesas de resumen sólo pueden contener columnas de fórmula.\",\n        \"Manage users and resources in a Grist installation.\": \"Gestionar usuarios y recursos en una instalación de Grist.\",\n        \"The new Grist Assistant is here!\": \"¡El nuevo asistente de Grist está aquí!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Las fórmulas admiten muchas funciones de Excel y la sintaxis completa de Python.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Crea una nueva Columna de Lista de Referencias en la tabla de destino, siendo ésta y las columnas de destino editables y sincronizadas.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Almacenamiento interno significa que todos los archivos adjuntos se almacenan en el archivo SQLite del documento, mientras que almacenamiento externo indica que todos los archivos adjuntos se almacenan en el mismo almacenamiento externo.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Permite agregar archivos adjuntos que faltan en el almacenamiento externo, por ejemplo, en un documento importado. Sólo pueden cargarse aquí archivos adjuntos .tar descargados de Grist.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Comprende, modifica y trabaja con tus datos y fórmulas con la ayuda del nuevo asistente de inteligencia artificial de Grist!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs. Do not submit passwords through this form, and be careful with links in it. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Este formulario ha sido creado por un usuario de Grist y no está avalado por Grist Labs. No envíe contraseñas a través de este formulario y tenga cuidado con los enlaces que contiene. Informe de formularios maliciosos a [{{mail}}](mailto:{{mail}}).\",\n        \"Set the maximum number of lines for multi-line text.\": \"Establezca el número máximo de líneas para el texto multilínea.\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Este formulario ha sido creado por un usuario de Grist, y no está avalado por Grist Labs, Inc. ni por ninguna de las partes que proporcionan este servicio. Por su seguridad, no envíe contraseñas a través de este formulario y tenga cuidado al hacer clic en enlaces incrustados. Informe de formularios maliciosos a [{{mail}}](mailto:{{mail}}).\",\n        \"Comments are here!\": \"Los comentarios están aquí!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Puedes Agregar comentarios a celdas, responder a hilos de comentarios y @mencionar a colaboradores.\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"DESCRIPCIÓN\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"Cerrar el panel de creación\",\n        \"Open creator panel\": \"Abrir el panel de creación\",\n        \"Creator panel (right panel)\": \"Panel de Creador (panel derecho)\",\n        \"Document header\": \"Encabezamiento del documento\",\n        \"Main content\": \"Contenido principal\",\n        \"Main navigation and document settings (left panel)\": \"Navegación principal y configuración de los documentos (panel izquierdo)\",\n        \"Close navigation panel (left panel)\": \"Cerrar el panel de navegación (panel izquierdo)\",\n        \"Open navigation panel (left panel)\": \"Abrir el panel de navegación (panel izquierdo)\"\n    },\n    \"ColumnTitle\": {\n        \"COLUMN ID: \": \"ID DE LA COLUMNA: \",\n        \"Cancel\": \"Cancelar\",\n        \"Column label\": \"Etiqueta de la columna\",\n        \"Save\": \"Guardar\",\n        \"Add description\": \"Agregar una descripción\",\n        \"Column ID copied to clipboard\": \"ID de la columna copiada al portapapeles\",\n        \"Column description\": \"Descripción de la Columna\",\n        \"Provide a column label\": \"Proporciona una etiqueta a la columna\",\n        \"Close\": \"Cerrar\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Entiendo\",\n        \"Unavailable Command\": \"Comando no disponible\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"El comando de menú {{action}} no está disponible en este navegador. Aún puede {{action}} utilizando el atajo de teclado {{shortcut}}.\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"Borrar el campo\",\n        \"Copy\": \"Copiar\",\n        \"Paste\": \"Pegar\",\n        \"Cut\": \"Cortar\",\n        \"Hide field\": \"Ocultar el campo\",\n        \"Copy anchor link\": \"Copiar enlace de anclaje\",\n        \"Comment\": \"Comentario\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Borrar la cola\",\n        \"Webhook settings\": \"Ajustes del gancho web\",\n        \"Cleared webhook queue.\": \"Cola de webhooks vacía.\",\n        \"Columns to check when update (separated by ;)\": \"Columnas a comprobar al actualizar (separadas por ;)\",\n        \"Removed webhook.\": \"Webhook eliminado.\",\n        \"Sorry, not all fields can be edited.\": \"Lo sentimos, no se pueden editar todos los campos.\",\n        \"Status\": \"Situación\",\n        \"URL\": \"URL\",\n        \"Webhook Id\": \"Id. del webhook\",\n        \"Ready Column\": \"Columna lista\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Filtrar los cambios en estas columnas (identificadores separados por punto y coma)\",\n        \"Table\": \"Tabla\",\n        \"Enabled\": \"Activado\",\n        \"Event Types\": \"Tipos de eventos\",\n        \"Memo\": \"Memorándum\",\n        \"Name\": \"Nombre\",\n        \"Header Authorization\": \"Encabezado de autorización\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooks no disponibles en copias de documentos sin guardar\"\n    },\n    \"FormulaAssistant\": {\n        \"Regenerate\": \"Regenerar\",\n        \"Save\": \"Guardar\",\n        \"Preview\": \"Vista previa\",\n        \"Need help? Our AI assistant can help.\": \"¿Necesitas ayuda? Nuestro asistente de IA puede ayudarle.\",\n        \"New Chat\": \"Nuevo chat\",\n        \"Ask the bot.\": \"Pregúntale al bot.\",\n        \"Capabilities\": \"Capacidades\",\n        \"Community\": \"Comunidad\",\n        \"Data\": \"Datos\",\n        \"Formula Cheat Sheet\": \"Hoja de trucos de fórmulas\",\n        \"Formula Help. \": \"Ayuda de fórmula. \",\n        \"Function List\": \"Lista de función\",\n        \"Grist's AI Assistance\": \"Asistencia de IA de Grist\",\n        \"Grist's AI Formula Assistance. \": \"Asistencia de fórmula de IA de Grist. \",\n        \"Tips\": \"Consejos\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Consulte nuestras páginas {{helpFunction}} y {{formulaCheat}}, o visite nuestra página {{community}} para obtener más ayuda.\",\n        \"AI Assistant\": \"Asistente de IA\",\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Clear conversation\": \"Limpiar conversación\",\n        \"Learn more\": \"Más información\",\n        \"Press Enter to apply suggested formula.\": \"Pulse Intro para aplicar la fórmula sugerida.\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Regístrese para obtener una cuenta gratuita en Grist y empezar a utilizar el Asistente de Fórmula de IA.\",\n        \"What do you need help with?\": \"¿Con qué necesitas ayuda?\",\n        \"Code view\": \"Vista de código\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Hola, soy el Asistente de IA de Fórmula Grist.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Sólo puedo ayudar con fórmulas. No puedo construir tablas, columnas y vistas, ni escribir reglas de acceso.\",\n        \"Sign Up for Free\": \"Regístrate gratis\",\n        \"There are some things you should know when working with me:\": \"Hay algunas cosas que debes saber cuando trabajes conmigo:\",\n        \"Formula AI Assistant is only available for logged in users.\": \"Asistente de Fórmula de IA sólo está disponible para usuarios registrados.\",\n        \"For higher limits, contact the site owner.\": \"Para límites superiores, ponte en contacto con el propietario del sitio.\",\n        \"upgrade to the Pro Team plan\": \"actualiza al plan Pro de Team\",\n        \"You have used all available credits.\": \"Has utilizado todos los créditos disponibles.\",\n        \"upgrade your plan\": \"amplía tu plan\",\n        \"You have {{numCredits}} remaining credits.\": \"Te quedan {{numCredits}} créditos.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Para límites superiores, {{upgradeNudge}}.\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Para obtener más ayuda con las fórmulas, consulte nuestras páginas {{functionList}} y {{formulaCheatSheet}}, o visite nuestra página {{community}} para obtener más ayuda.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Cuando hablas conmigo, tus preguntas y la estructura de tu documento (visible en {{codeView}}) se envían a OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Háblame como a una persona. No es necesario especificar tablas ni nombres de columnas. Por ejemplo, puedes pedir \\\"Por favor, calcula el importe total de la factura.\\\"\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Haga clic para insertar\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"Bienvenido de nuevo\",\n        \"You can always switch sites using the account menu.\": \"Siempre puedes cambiar de sitio utilizando el menú de la cuenta.\",\n        \"You have access to the following Grist sites.\": \"Usted tiene acceso a los siguientes sitios de Grist.\"\n    },\n    \"SupportGristNudge\": {\n        \"Help Center\": \"Centro de ayuda\",\n        \"Opted In\": \"Optado por participar\",\n        \"Support Grist\": \"Soporte Grist\",\n        \"Opt in to Telemetry\": \"Participar en Telemetría\",\n        \"Support Grist page\": \"Página de soporte de Grist\",\n        \"Close\": \"Cerrar\",\n        \"Contribute\": \"Contribuir\",\n        \"Admin Panel\": \"Panel de control\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"¡Muchas gracias!. Su confianza y apoyo son muy apreciados. Puedes darte de baja en cualquier momento desde el {{link}} del menú de usuario.\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"Página de patrocinadores de GitHub\",\n        \"Help Center\": \"Centro de ayuda\",\n        \"Manage Sponsorship\": \"Gestionar el patrocinio\",\n        \"Opt in to Telemetry\": \"Optar por la telemetría\",\n        \"Opt out of Telemetry\": \"Darse de baja de la telemetría\",\n        \"Telemetry\": \"Telemetría\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Esta instancia está habilitada para la telemetría. Solo el administrador del sitio tiene permiso para cambiar esto.\",\n        \"Sponsor Grist Labs on GitHub\": \"Patrocinar Grist Labs en GitHub\",\n        \"Support Grist\": \"Soporte Grist\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Esta instancia está inhabilitada para la telemetría. Solo el administrador del sitio tiene permiso para cambiar esto.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Solo recopilamos estadísticas de uso, como se detalla en nuestro {{link}}, nunca el contenido de los documentos.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Puede cancelar la telemetría en cualquier momento desde esta página.\",\n        \"You have opted in to telemetry. Thank you!\": \"Ha optado por la telemetría. ¡Gracias!\",\n        \"You have opted out of telemetry.\": \"Ha optado por no participar en la telemetría.\",\n        \"Home\": \"Inicio\",\n        \"Sponsor\": \"Patrocinador\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"El software Grist está desarrollado por Grist Labs, que ofrece planes de alojamiento gratuitos y de pago. También ponemos a disposición el código de Grist bajo una licencia OSS estándar libre y abierta (Apache 2.0) en {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Apoya a Grist optando por la telemetría, que nos ayuda a entender cómo se utiliza el producto, para que podamos priorizar futuras mejoras.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Somos un equipo pequeño y decidido. Su apoyo es muy importante para nosotros. También demuestra a los demás que hay una comunidad decidida detrás de este producto.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Puedes apoyar el desarrollo de código abierto de Grist patrocinándonos en nuestra página {{link}}.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Sin datos\",\n        \"No row selected in {{title}}\": \"Ninguna fila seleccionada en {{title}}\",\n        \"Not all data is shown\": \"No se muestran todos los datos\"\n    },\n    \"UserManager\": {\n        \"Copy link\": \"Copiar enlace\",\n        \"Grist support\": \"Soporte técnico de Grist\",\n        \"Off\": \"Apagado\",\n        \"Manage members of team site\": \"Administrar miembros del sitio de equipo\",\n        \"Open Access Rules\": \"Abrir Reglas de acceso\",\n        \"Outside collaborator\": \"Colaborador externo\",\n        \"Save & \": \"Guardar & \",\n        \"Team member\": \"Miembro del equipo\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"El usuario hereda los permisos de {{parent})}. Para eliminarlo, establezca la opción \\\"Heredar acceso\\\" en \\\"Ninguno\\\".\",\n        \"member\": \"miembro\",\n        \"team site\": \"sitio de equipo\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Ningún acceso predeterminado permite el acceso a documentos individuales o espacios de trabajo, en lugar de a todo el sitio de equipo.\",\n        \"Anyone with link \": \"Cualquiera con enlace \",\n        \"Cancel\": \"Cancelar\",\n        \"Close\": \"Cerrar\",\n        \"Collaborator\": \"Colaborador\",\n        \"Confirm\": \"Confirmar\",\n        \"Create a team to share with more people\": \"Crear un equipo para compartir con más personas\",\n        \"Invite people to {{resourceType}}\": \"Invita personas a {{resourceType}}\",\n        \"Guest\": \"Invitado\",\n        \"Invite multiple\": \"Invitar a varios\",\n        \"Link copied to clipboard\": \"Enlace copiado al portapapeles\",\n        \"On\": \"Encendido\",\n        \"Public access\": \"Acceso público\",\n        \"Public access: \": \"Acceso público: \",\n        \"User may not modify their own access.\": \"El usuario no puede modificar su propio acceso.\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} de {{limitTop}} {{collaborator}}s\",\n        \"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}}.\": \"Una vez que haya eliminado su propio acceso, no podrá recuperarlo sin la ayuda de otra persona con suficiente acceso al {{resourceType}}.\",\n        \"Add {{member}} to your team\": \"Añadir {{member}} a tu equipo\",\n        \"Allow anyone with the link to open.\": \"Permita que cualquiera que tenga el enlace lo abra.\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Ningún acceso predeterminado permite el         acceso a documentos individuales o espacios de trabajo, en lugar de a todo el sitio de equipo.\",\n        \"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}}.\": \"Una vez que haya eliminado su propio acceso, no podrá recuperarlo sin la ayuda de otra persona con suficiente acceso al {{name}}.\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Acceso público heredado de {{parent}}. Para eliminar, establezca la opción 'Heredar acceso' en 'Ninguno'.\",\n        \"Remove my access\": \"Quitar mi acceso\",\n        \"Your role for this team site\": \"Su rol para este sitio de equipo\",\n        \"Your role for this {{resourceType}}\": \"Su papel para este {{resourceType}}\",\n        \"free collaborator\": \"colaborador libre\",\n        \"guest\": \"invitado\",\n        \"{{collaborator}} limit exceeded\": \"límite de {{collaborator}} excedido\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Usted está a punto de quitar su propio acceso a este {{resourceType}}\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"El usuario hereda los permisos de {{parent}}. Para eliminarlo, establece la opción \\\"Heredar el acceso\\\" en \\\"Ninguno\\\".\",\n        \"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.\": \"El usuario tiene acceso de visualización a {{resource}} como resultado del acceso configurado manualmente a los recursos que contiene. Si se elimina aquí, este usuario perderá el acceso a los recursos que contiene.\",\n        \"Inherit access: \": \"Heredar acceso: \",\n        \"Access overview\": \"Panorama general\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"DESCRIPCIÓN\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Buscar en todas las páginas\",\n        \"Search all tables\": \"Buscar en todas las tablas\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Buscar\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Contraer editor\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Maximizar\",\n        \"Minimize\": \"Minimizar\"\n    },\n    \"CardContextMenu\": {\n        \"Insert card above\": \"Inserte la tarjeta arriba\",\n        \"Duplicate card\": \"Tarjeta duplicada\",\n        \"Insert card below\": \"Inserte la tarjeta a continuación\",\n        \"Delete card\": \"Borrar la tarjeta\",\n        \"Copy anchor link\": \"Copiar enlace fijado\",\n        \"Insert card\": \"Insertar la tarjeta\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Campos ocultos\"\n    },\n    \"Menu\": {\n        \"Building blocks\": \"Partes\",\n        \"Columns\": \"Columnas\",\n        \"Copy\": \"Copiar\",\n        \"Cut\": \"Cortar\",\n        \"Insert question below\": \"Insertar la pregunta siguiente\",\n        \"Paragraph\": \"Párrafo\",\n        \"Insert question above\": \"Insertar la pregunta anterior\",\n        \"Header\": \"Encabezado\",\n        \"Paste\": \"Pegar\",\n        \"Separator\": \"Separador\",\n        \"Unmapped fields\": \"Campos sin asignar\",\n        \"More\": \"Más\",\n        \"New question\": \"Nueva pregunta\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Borrar\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Unmap fields\": \"Anular asignación de campos\",\n        \"Mapped\": \"Asignado\",\n        \"Clear\": \"Limpiar\",\n        \"Map fields\": \"Campos del mapa\",\n        \"Select all\": \"Seleccionar todo\",\n        \"Unmapped\": \"Sin asignar\"\n    },\n    \"FormView\": {\n        \"Publish\": \"Publicar\",\n        \"Publish your form?\": \"¿Publicar su formulario?\",\n        \"Unpublish your form?\": \"¿Anular la publicación de su formulario?\",\n        \"Unpublish\": \"Anular la publicación\",\n        \"Are you sure you want to reset your form?\": \"¿Estás seguro de que quieres restablecer tu formulario?\",\n        \"Copy code\": \"Copiar mi código\",\n        \"Embed this form\": \"Insertar este formulario\",\n        \"Reset\": \"Restablecer\",\n        \"Share\": \"Compartir\",\n        \"Copy link\": \"Copiar enlace\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Cualquiera que acceda al siguiente enlace puede ver el formulario vacío y enviar una respuesta.\",\n        \"Code copied to clipboard\": \"Código copiado al portapapeles\",\n        \"Link copied to clipboard\": \"Enlace copiado al portapapeles\",\n        \"Preview\": \"Vista previa\",\n        \"Reset form\": \"Restablecer el formulario\",\n        \"Save your document to publish this form.\": \"Guarde el documento para publicar este formulario.\",\n        \"Share this form\": \"Compartir este formulario\",\n        \"View\": \"Ver\",\n        \"# **Form Title**\": \"# **Título del formulario**\",\n        \"Your form description goes here.\": \"La descripción de su formulario va aquí.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"Al publicar tu formulario se generará un enlace para compartir. Cualquiera que tenga el enlace podrá ver el formulario vacío y enviar una respuesta.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Al anular la publicación del formulario se desactivará el enlace para compartir, de modo que los usuarios que accedan a su formulario a través de ese enlace verán un error.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Los usuarios se limitan a enviar entradas (registros en su tabla) y a leer valores preestablecidos en campos designados, como las columnas de referencia y elección.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Su formulario está publicado. Todos los cambios son visibles para los usuarios con acceso al formulario. Si quieres hacer cambios en borrador, despublica el formulario.\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"llamada gratuita de asesoramiento\",\n        \"Schedule call\": \"Programar una llamada\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Programe su {{freeCoachingCall}} con un miembro de nuestro equipo.\",\n        \"Maybe later\": \"Quizás más tarde\",\n        \"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.\": \"En la llamada, nos tomaremos el tiempo necesario para entender sus necesidades y adaptar la llamada a usted. Podemos mostrarle los conceptos básicos de Grist o empezar a trabajar con sus datos de inmediato para crear los cuadros de mando que necesita.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"También puede consultar {{ourWeeklyWebinars}} para saber más sobre Grist.\",\n        \"our weekly webinars\": \"nuestros seminarios web semanales\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Reglas del campo\",\n        \"Required field\": \"Campo obligatorio\",\n        \"Ascending\": \"Ascendente\",\n        \"Default\": \"Por defecto\",\n        \"Descending\": \"Descendente\",\n        \"Field Format\": \"Formato del campo\",\n        \"Field Rules\": \"Reglas del campo\",\n        \"Horizontal\": \"Horizontal\",\n        \"Options Alignment\": \"Alineación de las opciones\",\n        \"Options Sort Order\": \"Orden de clasificación de las opciones\",\n        \"Radio\": \"Radio\",\n        \"Select\": \"Seleccionar\",\n        \"Vertical\": \"Vertical\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Algunas columnas obligatorias no están asignadas\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Para utilizar este widget, asigne todas las columnas no opcionales desde el panel del creador de la derecha.\",\n        \"Some required columns are hidden by access rules\": \"Algunas columnas requeridas están ocultas por reglas de acceso\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Para usar este widget, todas las columnas mapeadas deben ser visibles. Por favor contacte al propietario del documento o modifique las reglas de acceso.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Cree su propio formulario\",\n        \"Powered by\": \"Desarrollado por\",\n        \"Powered by Grist\": \"Desarrollado por Grist\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Error\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"¡Vaya! El formulario que busca no existe.\",\n        \"Oops! This form is no longer published.\": \"¡Vaya! Este formulario ya no se publica.\",\n        \"There was a problem loading the form.\": \"Hubo un problema al cargar el formulario.\",\n        \"You don't have access to this form.\": \"No tiene acceso a este formulario.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Se ha producido un error al enviar el formulario. Por favor, inténtelo de nuevo.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Formulario enviado\",\n        \"Thank you! Your response has been recorded.\": \"¡Muchas gracias! Su respuesta ha quedado registrada.\",\n        \"Submit new response\": \"Enviar una nueva respuesta\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"Últimos 30 días\",\n        \"Last 7 days\": \"Últimos 7 días\",\n        \"Last week\": \"Última semana\",\n        \"Next 7 days\": \"Próximos 7 días\",\n        \"This month\": \"Este mes\",\n        \"This week\": \"Esta semana\",\n        \"This year\": \"Este año\",\n        \"Today\": \"Hoy\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Limpiar\",\n        \"Mapped\": \"Mapeado\",\n        \"Select all\": \"Seleccionar todo\",\n        \"Map fields\": \"Campos del mapa\",\n        \"Unmap fields\": \"Anular asignación de campos\",\n        \"Unmapped\": \"Sin mapear\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Insertar la sección anterior\",\n        \"Insert section below\": \"Insertar la sección siguiente\",\n        \"## **Header**\": \"## *Encabezado**\",\n        \"Description\": \"Descripción\"\n    },\n    \"AdminPanel\": {\n        \"Current\": \"Actual\",\n        \"Help us make Grist better\": \"Ayúdanos a mejorar Grist\",\n        \"Home\": \"Inicio\",\n        \"Sponsor\": \"Patrocinador\",\n        \"Support Grist\": \"Soporte Grist\",\n        \"Telemetry\": \"Telemetría\",\n        \"Version\": \"Versión\",\n        \"Current version of Grist\": \"Versión actual de Grist\",\n        \"Admin Panel\": \"Panel de control\",\n        \"Support Grist Labs on GitHub\": \"Apoya a Grist Labs en GitHub\",\n        \"Grist is up to date\": \"Grist está actualizado\",\n        \"Grist releases are at \": \"Los versiones de Grist están en \",\n        \"Last checked {{time}}\": \"Última comprobación {{time}}\",\n        \"Newer version available\": \"Disponible una nueva versión\",\n        \"No information available\": \"No hay información disponible\",\n        \"Sandboxing\": \"Aislamiento de procesos\",\n        \"Security Settings\": \"Configuraciones de seguridad\",\n        \"Updates\": \"Actualizaciones\",\n        \"unknown\": \"desconocido\",\n        \"Auto-check when this page loads\": \"Comprobación automática al cargar esta página\",\n        \"Check now\": \"Comprobar ahora\",\n        \"Checking for updates...\": \"Buscando actualizaciones...\",\n        \"Error\": \"Error\",\n        \"Error checking for updates\": \"Error al buscar actualizaciones\",\n        \"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 permite fórmulas muy potentes, utilizando Python. Recomendamos establecer la variable de entorno GRIST_SANDBOX_FLAVOR a gvisor si su hardware lo soporta (la mayoría lo hará), para ejecutar fórmulas en cada documento dentro de un sandbox aislado de otros documentos y aislado de la red.\",\n        \"OK\": \"De acuerdo\",\n        \"Sandbox settings for data engine\": \"Configuración del entorno de pruebas para el motor de datos\",\n        \"unconfigured\": \"desconfigurado\",\n        \"Learn more.\": \"Más información.\",\n        \"Authentication\": \"Autentificación\",\n        \"Check succeeded.\": \"Verificación exitosa.\",\n        \"Notes\": \"Notas\",\n        \"Administrator Panel Unavailable\": \"Panel de administrador no disponible\",\n        \"Check failed.\": \"La verificación falló.\",\n        \"Current authentication method\": \"Método de autenticación actual\",\n        \"Details\": \"Detalles\",\n        \"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     a varias personas.\",\n        \"No fault detected.\": \"No se detectó ningún error.\",\n        \"Results\": \"Resultados\",\n        \"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}}\",\n        \"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.\",\n        \"Self Checks\": \"Controles automáticos\",\n        \"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.\",\n        \"Session Secret\": \"Secreto de sesión\",\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.\",\n        \"Key to sign sessions with\": \"Clave para firmar sesiones con\",\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 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.\",\n        \"Enterprise\": \"Empresarial\",\n        \"Enable Grist Enterprise\": \"Activar Grist Enterprise\",\n        \"checking\": \"comprobando\",\n        \"Audit Logs\": \"Registros de auditoría\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} más\",\n        \"Log Streaming\": \"Transmisión de registros\",\n        \"New, Enterprise\": \"Nuevo, Empresa\",\n        \"Contact us\": \"Contáctenos\",\n        \"Off\": \"Apagar\",\n        \"On\": \"Encendido\",\n        \"Grist Instance\": \"Instancia Grist\",\n        \"Auto-check weekly\": \"Auto-check semanal\",\n        \"No record of last version check\": \"No hay registro de la última comprobación de la versión\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Puede configurar la transmisión de eventos de auditoría desde Grist a un sistema externo de gestión de eventos e información de seguridad (SIEM) si habilita Grist Enterprise. {{contactUsLink}} para obtener más información.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Las comprobaciones automáticas están desactivadas. Establezca la variable de entorno GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING en \\\"true\\\" para activarlas.\",\n        \"auth error\": \"error de autenticación\",\n        \"configured\": \"configurado\",\n        \"default\": \"por defecto\",\n        \"more...\": \"más...\",\n        \"no authentication\": \"sin autenticación\",\n        \"unavailable\": \"no disponible\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Cancelar\",\n        \"Choose a name and url for your team site\": \"Elija un nombre y una URL para su sitio de equipo\",\n        \"Create site\": \"Crear sitio\",\n        \"Domain name is invalid\": \"El dominio no es válido\",\n        \"Domain name is required\": \"Nombre del dominio obligatorio\",\n        \"Go to your site\": \"Ir a su sitio\",\n        \"Team name\": \"Nombre del equipo\",\n        \"Team name is required\": \"Nombre del equipo obligatorio\",\n        \"Team site created\": \"Sitio de equipo creado\",\n        \"Team url\": \"URL del equipo\",\n        \"Work as a Team\": \"Trabajar en equipo\",\n        \"Billing is not supported in grist-core\": \"La facturación no es compatible con grist-core\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Quitar columna\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Sin opciones configuradas\",\n        \"No values in show column of referenced table\": \"No hay valores en la columna Mostrar de la tabla referenciada\",\n        \"Hide\": \"Ocultar\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"Casilla de verificación\",\n        \"Field Format\": \"Formato del campo\",\n        \"Switch\": \"Interruptor\"\n    },\n    \"ChoiceEditor\": {\n        \"No choices matching condition\": \"No hay opciones que coincidan con la condición\",\n        \"No choices to select\": \"Sin opciones para seleccionar\",\n        \"Error in dropdown condition\": \"Error en la condición desplegable\"\n    },\n    \"ChoiceListEditor\": {\n        \"No choices matching condition\": \"No hay opciones que cumplan la condición\",\n        \"No choices to select\": \"Sin opciones para seleccionar\",\n        \"Error in dropdown condition\": \"Error en la condición desplegable\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Set dropdown condition\": \"Establecer condición desplegable\",\n        \"Dropdown Condition\": \"Condición desplegable\",\n        \"Invalid columns: {{colIds}}\": \"Columnas no válidas: {{colIds}}\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Error en la condición desplegable\",\n        \"No choices to select\": \"Sin opciones para seleccionar\",\n        \"No choices matching condition\": \"No hay opciones que coincidan con la condición\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Introduzca la condición.\"\n    },\n    \"FormRenderer\": {\n        \"Select...\": \"Seleccione...\",\n        \"Reset\": \"Restablecer\",\n        \"Search\": \"Búsqueda\",\n        \"Submit\": \"Enviar\",\n        \"Submitting…\": \"Presentando…\"\n    },\n    \"widgetTypesMap\": {\n        \"Calendar\": \"Calendario\",\n        \"Card\": \"Tarjeta\",\n        \"Card List\": \"Lista de tarjetas\",\n        \"Chart\": \"Gráfico\",\n        \"Custom\": \"Personalizado\",\n        \"Form\": \"Formulario\",\n        \"Table\": \"Tabla\"\n    },\n    \"TimingPage\": {\n        \"Average Time (s)\": \"Tiempo promedio (s)\",\n        \"Formula timer\": \"Temporizador de formulas\",\n        \"Loading timing data. Don't close this tab.\": \"Cargando datos del cronometraje. No cierres esta pestaña.\",\n        \"Number of Calls\": \"Número de llamadas\",\n        \"Table ID\": \"ID de la tabla\",\n        \"Total Time (s)\": \"Tiempo total (s)\",\n        \"Column ID\": \"ID de la columna\",\n        \"Max Time (s)\": \"Tiempo máximo (s)\"\n    },\n    \"DocTutorial\": {\n        \"Click to expand\": \"Haz clic para ampliar\",\n        \"End tutorial\": \"Fin del tutorial\",\n        \"Next\": \"Siguiente\",\n        \"Restart\": \"Reanudar\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"¿Quieres reiniciar el tutorial? Se perderá todo el progreso.\",\n        \"Finish\": \"Finalizar\",\n        \"Previous\": \"Anterior\"\n    },\n    \"OnboardingCards\": {\n        \"Complete our basics tutorial\": \"Completa nuestro tutorial básico\",\n        \"Complete the tutorial\": \"Completa el tutorial\",\n        \"3 minute video tour\": \"La duración del vídeo es de 3 minutos\",\n        \"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.\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Aprende los conceptos básicos de columnas de referencia, widgets vinculados, tipos de columna y tarjetas.\"\n    },\n    \"OnboardingPage\": {\n        \"Discover Grist in 3 minutes\": \"Descubre Grist en 3 minutos\",\n        \"Next step\": \"Paso siguiente\",\n        \"Skip step\": \"Omitir el paso\",\n        \"Skip tutorial\": \"Omitir el tutorial\",\n        \"Tell us who you are\": \"Cuéntanos quién eres\",\n        \"Type here\": \"Escribe aquí\",\n        \"Welcome\": \"Bienvenid@\",\n        \"What brings you to Grist (you can select multiple)?\": \"¿Qué te trae a Grist (puedes seleccionar varias opciones)?\",\n        \"What is your role?\": \"¿Cual es tu papel?\",\n        \"Your role\": \"Tu función\",\n        \"Back\": \"Atrás\",\n        \"Your organization\": \"Tu organización\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Práctica con el tutorial básico de Grist\",\n        \"Go to the tutorial!\": \"¡Ve al tutorial!\",\n        \"What organization are you with?\": \"¿A qué organización perteneces?\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist puede parecer una hoja de cálculo, pero no siempre actúa como tal. Descubre por qué Grist es diferente.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Borrar\",\n        \"Delete data and this widget.\": \"Eliminar datos y este widget.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Mantener datos y eliminar el widget. La tabla permanecerá disponible en {{rawDataLink}}\",\n        \"Table {{tableName}} will no longer be visible\": \"La tabla {{tableName}} ya no será visible\",\n        \"Raw Data page\": \"página de datos en bruto\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"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}}).\",\n        \"Disable Grist Enterprise\": \"Desactivar Grist Enterprise\",\n        \"Enable Grist Enterprise\": \"Activar Grist Enterprise\",\n        \"Grist Enterprise is **enabled**.\": \"Grist Enterprise está **activado**.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist 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. Obtenga una clave de activación [poniéndose en contacto con nosotros]({{contactLink}}) hoy mismo. No\\nnecesita una clave de activación para ejecutar Grist Core.\\n\\nMás información en nuestro [Centro de Ayuda]({{helpCenter}}).\",\n        \"Activate\": \"Activar\",\n        \"Activation key\": \"Clave de activación\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Se utiliza una clave de activación para ejecutar Grist Enterprise después de un período de prueba\\n        de 30 días. Obtenga una clave de activación [registrándose en Grist\\n        Enterprise]({{signupLink}}). No necesita una clave de activación para ejecutar\\n        Grist Core.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Se requiere una suscripción activa para seguir utilizando Grist Enterprise. Puede\\nactivar su suscripción [registrándose en Grist Enterprise ]({{signupLink}}) y pegando su\\nClave de activación.\",\n        \"Copy to clipboard\": \"Copiado para el portapapeles\",\n        \"Expiration date\": \"Fecha de expiración\",\n        \"Installation ID copied to clipboard\": \"ID de instalación copiado en el portapapeles\",\n        \"Installation ID:\": \"ID de instalación:\",\n        \"Installation seats\": \"Asientos de instalación\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Más información en nuestro [Centro de ayuda]({{helpCenter}}).\",\n        \"Paste your activation key\": \"Pega tu clave de activación\",\n        \"Plan name\": \"Nombre de plan\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Para seguir utilizando Grist Enterprise, necesitas\\n[contactarnos]({{signupLink}}) para obtener su llave de activación.\",\n        \"You are currently trialing Grist Enterprise.\": \"Actualmente está probando Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"No tiene una suscripción activa.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Su Clave de activación ha caducado por exceder los límites.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Su instancia estará en modo **sólo lectura** en **{{days}}** día(s).\",\n        \"Your subscription expired on {{date}}.\": \"Su suscripción expiró en {{date}}.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Su periodo de prueba ha expirado en **{{expireAt}}**. Para seguir utilizando Grist Enterprise, debe\\n[Regístrese en Grist Enterprise]({{signupLink}}) y pegue su clave de activación a continuación.\"\n    },\n    \"CustomWidgetGallery\": {\n        \"Add widget\": \"Agregar widget\",\n        \"Add a widget from outside this gallery.\": \"Agregar un widget desde fuera de esta galería.\",\n        \"Last updated:\": \"Última actualización:\",\n        \"(Missing info)\": \"(Falta información)\",\n        \"Add Your Own Widget\": \"Agregar su propio widget\",\n        \"Cancel\": \"Cancelar\",\n        \"Change widget\": \"Cambiar widget\",\n        \"Choose custom widget\": \"Elija Widget personalizado\",\n        \"Community Widget\": \"Widget comunitario\",\n        \"Custom URL\": \"URL personalizada\",\n        \"Developer:\": \"Desarrollador:\",\n        \"Grist Widget\": \"Widget Grist\",\n        \"Widget URL\": \"URL del widget\",\n        \"Learn more about custom widgets\": \"Más información sobre los widgets personalizados\",\n        \"No matching widgets\": \"No hay widgets coincidentes\",\n        \"Search\": \"Búsqueda\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Panel de administración\"\n    },\n    \"markdown\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nueva función Markdown\\n *\\n * Podemos _escribir_ [el Markdown habitual](https://markdownguide.org) *dentro* de\\n * un elemento Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"El conmutador está **desactivado**\",\n        \"The toggle is **on**\": \"El conmutador está **activado**\"\n    },\n    \"HomeIntroCards\": {\n        \"Blank document\": \"Documento en blanco\",\n        \"3 minute video tour\": \"Recorrido en vídeo de 3 minutos\",\n        \"Help center\": \"Centro de ayuda\",\n        \"Finish our basics tutorial\": \"Termina nuestro tutorial básico\",\n        \"Import file\": \"Importar archivo\",\n        \"Learn more {{webinarsLinks}}\": \"Aprende más {{webinarsLinks}}\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"Encontra soluciones y explora más recursos {{helpCenterLink}}\",\n        \"Start a new document\": \"Iniciar un nuevo documento\",\n        \"Templates\": \"Plantillas\",\n        \"Tutorial\": \"Tutorial\",\n        \"Webinars\": \"Webinar\",\n        \"Find solutions and explore more resources\": \"Encuentre soluciones y explore más recursos\",\n        \"Learn more\": \"Más información\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Column\": \"Columna\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Es el reverso de la columna de referencia {{column}} en la tabla {{table}}.\",\n        \"Delete\": \"Borrar\",\n        \"Delete column {{column}} in table {{table}}?\": \"¿Eliminar la columna {{column}} en la tabla {{table}}?\",\n        \"Table\": \"Tabla\",\n        \"Add two-way reference\": \"Añadir referencia de dos vías\",\n        \"Two-way Reference\": \"Referencia de dos vías\",\n        \"Delete two-way reference?\": \"¿Eliminar referencia de dos vías?\",\n        \"Target table\": \"Tabla destino\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Esto borrará la columna de referencia {{refCol}} en la tabla {{refTable}}. La columna de referencia {{myName}} permanecerá en la tabla actual {{myTable}}.\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nueva función Markdown\\n *\\n * Podemos _escribir_ [el Markdown habitual](https://markdownguide.org) *dentro* de\\n * un elemento Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"El conmutador está **desactivado**\",\n        \"The toggle is **on**\": \"El conmutador está **activado**\"\n    },\n    \"SupportGristButton\": {\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"¡Muchas gracias!. Su confianza y apoyo son muy apreciados. Puedes darte de baja en cualquier momento desde {{link}} del menú de usuario.\",\n        \"Help Center\": \"Centro de ayuda\",\n        \"Admin Panel\": \"Panel de administración\",\n        \"Close\": \"Cerrar\",\n        \"Opt in to Telemetry\": \"Optar por la telemetría\",\n        \"Support Grist\": \"Soporte Grist\",\n        \"Opted In\": \"Optó por participar\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Opte por la telemetría para ayudarnos a entender cómo se utiliza el producto, de modo que podamos priorizar futuras mejoras.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Sólo recopilamos estadísticas de uso, como se detalla en nuestro {{helpCenterLink}}, nunca el contenido de los documentos. Puede darse de baja en cualquier momento desde {{supportGristLink}} en el menú de usuario.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Cancelar\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Cada registro {{targetTable}} solo puede asignarse a un único registro {{sourceTable}}.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Reasignar al registro {{sourceTable}} {{sourceName}}.\",\n        \"Reassign\": \"Reasignar\",\n        \"Reassign to new {{sourceTable}} records.\": \"Reasignar a nuevos registros {{sourceTable}}.\",\n        \"Record already assigned_one\": \"Registro ya asignado\",\n        \"Record already assigned_other\": \"Registros ya asignados\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} registro {{targetName}} ya está asignado a {{sourceTable}} registro          {{oldSourceName}}.\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Delete\": \"Borrar\",\n        \"Delete streaming destination?\": \"¿Borrar destino del streaming?\",\n        \"Destinations\": \"Destinos\",\n        \"Edit\": \"Editar\",\n        \"Edit streaming destination\": \"Editar el destino del streaming\",\n        \"Enter URL\": \"Introducir URL\",\n        \"Enter token\": \"Ingresar token\",\n        \"Learn more\": \"Más información\",\n        \"URL\": \"URL\",\n        \"Destination\": \"Destino\",\n        \"Add destination\": \"Añadir destino\",\n        \"Add streaming destination\": \"Agregar destino de transmisión\",\n        \"Cancel\": \"Cancelar\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"¿Está seguro de que desea eliminar este destino de streaming? Esta acción no se puede deshacer.\",\n        \"Other\": \"Otro\",\n        \"Splunk\": \"Splunk\",\n        \"Save\": \"Guardar\",\n        \"Start streaming\": \"Iniciar streaming\",\n        \"Token\": \"Token\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Configure la transmisión de eventos de auditoría desde Grist a un sistema externo de gestión de eventos e información de seguridad (SIEM) como Splunk. {{learnMoreLink}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit Logs\": \"Registros de auditoría\",\n        \"Audit logs for {{siteName}}\": \"Registros de auditoría para {{siteName}}\",\n        \"Contact us\": \"Contáctenos\",\n        \"Home\": \"Inicio\",\n        \"Log streaming\": \"Transmisión de registros\",\n        \"Only site owners may access audit logs.\": \"Solo los propietarios de las páginas pueden acceder a los registros de auditoría.\",\n        \"upgrade your plan\": \"Mejora tu plan\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Puede configurar la transmisión de eventos de auditoría desde Grist a un sistema externo de gestión de eventos e información de seguridad (SIEM) si habilita Grist Enterprise. {{contactUsLink}} para obtener más información.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Puede configurar la transmisión de eventos de auditoría desde Grist a un sistema SIEM (información de seguridad y gestión de eventos) externo si {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"Access details\": \"Detalles de Acceso\",\n        \"Edited {{at}}\": \"Editado {{at}}\",\n        \"Delete\": \"Borrar\",\n        \"Delete {{name}}\": \"Borrar {{name}}\",\n        \"Sort by date\": \"Ordenar por fecha\",\n        \"Recent\": \"Reciente\",\n        \"Rename and set icon\": \"Renombrar y establecer icono\",\n        \"Requires edit permissions\": \"Requiere permisos de edición\",\n        \"All\": \"Todo\",\n        \"Current workspace\": \"Espacio de trabajo actual\",\n        \"Document will be moved to Trash.\": \"El documento será movido a la papelera.\",\n        \"Last edited\": \"Última edición\",\n        \"Move\": \"Mover\",\n        \"Move {{name}} to workspace\": \"Mover {{name}} al espacio de trabajo\",\n        \"Name\": \"Nombre\",\n        \"No documents to show.\": \"No hay documentos que mostrar.\",\n        \"Pin\": \"Fijar\",\n        \"Pinned\": \"Fijado\",\n        \"Sort by name\": \"Ordenar por nombre\",\n        \"Unpin\": \"Liberar\",\n        \"Workspace\": \"Espacios de trabajo\",\n        \"Manage users\": \"Gestionar usuarios\",\n        \"context menu - {{- documentName }}\": \"menú contextual - {{- documentName }}\",\n        \"Documents list\": \"Lista de documentos\"\n    },\n    \"RenameDocModal\": {\n        \"Choose icon\": \"Elegir icono\",\n        \"Rename and set icon\": \"Renombrar y establecer icono\",\n        \"Enter document name\": \"Introduzca el nombre del documento\",\n        \"Icon\": \"Icono\",\n        \"Name\": \"Nombre\",\n        \"Choose color\": \"Elige el color\",\n        \"Reset icon\": \"Restablecer icono\"\n    },\n    \"RightPanelUtils\": {\n        \"fields_one\": \"Campo\",\n        \"fields_other\": \"Campos\",\n        \"columns_other\": \"Columnas\",\n        \"columns_one\": \"Columna\",\n        \"series_one\": \"Series\",\n        \"series_other\": \"Series\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Tenga cuidado con los widgets personalizados desconocidos\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"¡Los widgets personalizados son **poderosos**! Pueden ser capaces de leer y escribir los datos de tu documento, y enviarlos a otra parte.\",\n        \"Do you **trust the person** who shared this link?\": \"¿Confías **en la persona** que compartió este enlace?\",\n        \"Have you **reviewed the code** at this URL?\": \"¿Has **revisado el código** en esta URL?\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Confirmo que entiendo estas advertencias y acepto los riesgos\",\n        \"Please review the following before adding a new custom widget.\": \"Por favor, revise lo siguiente antes de añadir un nuevo widget personalizado.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"¿Está seguro de que **confía en el recurso** de esta URL?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"En caso de duda, no instale este widget, o pida a un administrador de su organización que lo revise por seguridad.\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin area\": \"Área de administración\",\n        \"Admin controls\": \"Controles administrativos\",\n        \"Installation\": \"Instalación\",\n        \"Learn more\": \"Más información\",\n        \"Orgs\": \"Organizaciones\",\n        \"Docs\": \"Documentos\",\n        \"Users\": \"Usuarios\",\n        \"Admin Controls\": \"Controles administrativos\",\n        \"Settings\": \"Ajustes\",\n        \"Workspaces\": \"Espacios de trabajo\"\n    },\n    \"Assistant\": {\n        \"Learn more.\": \"Más información.\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Regístrese para obtener una cuenta gratuita en Grist y empezar a utilizar el Asistente IA.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Actualice a Grist Enterprise para probar el nuevo Asistente de Grist. {{learnMoreLink}}\",\n        \"For higher limits, contact the site owner.\": \"Para límites superiores, ponte en contacto con el propietario del sitio.\",\n        \"Press Enter to apply suggested formula.\": \"Pulse Intro para aplicar la fórmula sugerida.\",\n        \"Sign Up for Free\": \"Regístrate gratis\",\n        \"You have {{numCredits}} remaining credits.\": \"Te quedan {{numCredits}} créditos.\",\n        \"Apply\": \"Aplicar\",\n        \"AI Assistant is only available for logged in users.\": \"El assistente IA sólo está disponible para usuarios conectados.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Para límites más altos, {{upgradeNudge}}.\",\n        \"start a new chat\": \"iniciar un nuevo chat\",\n        \"What do you need help with?\": \"¿Con qué necesitas ayuda?\",\n        \"upgrade to the Pro Team plan\": \"actualiza al plan Pro de Team\",\n        \"You have used all available credits.\": \"Has utilizado todos los créditos disponibles.\",\n        \"upgrade your plan\": \"amplía tu plan\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"La conversación se ha alargado demasiado y ya no puedo responder con eficacia. Por favor, {{startANewChatButton}} para seguir recibiendo asistencia.\"\n    },\n    \"apiconsole\": {\n        \"Deletion was not confirmed, skipping.\": \"La eliminación no fue confirmada, saltando.\",\n        \"Are you sure you want to delete the following?\": \"¿Estás seguro de que quieres borrar lo siguiente?\",\n        \"Confirm Deletion\": \"Confirmar borrado\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Escriba DELETE si está seguro de que desea hacer esta eliminación.\\nSi usted no está seguro, o no entiende lo que esta operación hará,\\nSería prudente cancelarlo.\",\n        \"Delete\": \"Borrar\",\n        \"Type DELETE here if you wish to proceed.\": \"Escriba DELETE aquí si desea continuar.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"sin acceso\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Hay disponible una actualización crítica de Grist.\\nConsidere la posibilidad de actualizar a la versión {{version}} lo antes posible.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Su versión de Grist está obsoleta.\\nConsidere la posibilidad de actualizar a la versión {{version}} lo antes posible.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Recomendación: {{storageRecommendation}}\\nCuando se almacenan archivos adjuntos de gran tamaño, o muchos de ellos, se recomienda\\nguardarlos en un almacenamiento externo. Este documento utiliza\\nalmacenamiento interno para los adjuntos, lo que lo mantiene\\nautocontenido pero puede limitar el rendimiento.\",\n        \"Set the document to use external storage.\": \"Configura el documento para que utilice almacenamiento externo.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Por favor, espere a que la operación anterior se complete.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Se ha agotado el tiempo de espera para que se reinicie el backend de Grist\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Desactivar función\",\n        \"Don't worry, you can disable it later if needed.\": \"No te preocupes, puedes desactivarlo más tarde si es necesario.\",\n        \"Enable feature\": \"Activar función\",\n        \"Experimental feature\": \"Función experimental\",\n        \"New record button\": \"Botón de nuevo registro\",\n        \"Reload the page\": \"Recargar la página\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Visite esta URL en cualquier momento para dejar de utilizar esta función: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Está a punto de desactivar esta función experimental: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Está a punto de activar esta función experimental: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} deshabilitado.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} habilitado.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Nueva tarjeta\",\n        \"New record\": \"Nuevo registro\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"¿Intentando acceder al panel del creador? Utilice {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Duplicar widget\",\n        \"Duplicate widgets\": \"Widgets duplicados\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Cargando, por favor espere…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Agregar\",\n        \"Delete\": \"Borrar\",\n        \"Download\": \"Descargar\",\n        \"Drop files here to attach\": \"Suelte los archivos aquí para adjuntarlos\",\n        \"Drop files here to attach.\": \"Suelte los archivos aquí para adjuntarlos.\",\n        \"No attachments\": \"Sin adjuntos\",\n        \"Preview not available.\": \"Vista previa no disponible.\",\n        \"{{index}} of {{total}}\": \"{{index}} de {{total}}\",\n        \"Uploading…\": \"Cargando…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Ampliar todas las filas a esta altura\",\n        \"Max height\": \"Altura máxima\",\n        \"Max row height\": \"Altura máxima de fila\",\n        \"Change\": \"Cambio\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Contraer\",\n        \"Expand\": \"Expandir\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"Usuario activo\",\n        \"active user list\": \"lista de usuarios activos\",\n        \"open full active user list\": \"abrir la lista completa de usuarios activos\"\n    },\n    \"AdminChecks\": {\n        \"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 permite fórmulas muy potentes, utilizando Python. Recomendamos establecer la variable de entorno GRIST_SANDBOX_FLAVOR a gvisor si su hardware lo soporta (la mayoría lo hará), para ejecutar fórmulas en cada documento dentro de un sandbox aislado de otros documentos y aislado de la red.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Grist tiene una pequeña comprobación de salud incorporada que se utiliza a menudo cuando se ejecuta como contenedor.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"Las solicitudes que llegan a Grist deben tener una cabecera Host precisa. Esto es esencial cuando GRIST_SERVE_SAME_ORIGIN está activado.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"Esta página de arranque no debería ser de fácil acceso. Desactívela cuando la configuración sea correcta (desactivando GRIST_BOOT_KEY) o haga que GRIST_BOOT_KEY sea larga y criptográficamente segura.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"Las conexiones Websocket necesitan HTTP 1.1 y la capacidad de pasar algunas cabeceras adicionales para funcionar. A veces, un proxy inverso puede interferir con estos requisitos.\",\n        \"It is good practice not to run Grist as the root user.\": \"Es una buena práctica no ejecutar Grist como usuario root.\",\n        \"The main page of Grist should be available.\": \"La página principal de Grist debería estar disponible.\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"+{{count}} más\",\n        \"+{{count}} more_other\": \"+{{count}} más\",\n        \"Edit\": \"Editar\",\n        \"No choices configured\": \"No hay opciones configuradas\",\n        \"Reset\": \"Restablecer\",\n        \"Cancel\": \"Cancelar\",\n        \"Save\": \"Guardar\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Preview\": \"Vista previa\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Cerrar\",\n        \"Update preview\": \"Actualizar vista previa\",\n        \"Convert quoted fields\": \"Convertir campos citados\",\n        \"Escape character\": \"Carácter de escape\",\n        \"Field separator\": \"Separador de campo\",\n        \"First row contains headers\": \"La primera fila contiene cabeceras\",\n        \"Line terminator\": \"Terminador de línea\",\n        \"Number of rows\": \"Número de filas\",\n        \"Quote character\": \"Carácter de cita\",\n        \"Quotes in fields are doubled\": \"Las comillas en los campos se duplican\",\n        \"Skip leading whitespace\": \"Omitir el espacio en blanco inicial\",\n        \"Start with row\": \"Empieza con la fila\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Codificación de caracteres. Véase [los códecs admitidos]({{link}})\"\n    },\n    \"commandList\": {\n        \"Show hidden columns\": \"Mostrar columnas ocultas\"\n    }\n}\n"
  },
  {
    "path": "static/locales/es.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Por favor, compruebe su correo electrónico con el proveedor de identidad y vuelva a iniciar sesión.\"\n    },\n    \"sendAppPage\": {\n        \"og-description\": \"Una hoja de cálculo moderna y de código abierto que va más allá de la cuadrícula\",\n        \"Loading...\": \"Cargando...\",\n        \"og-title\": \"Grist, la evolución de las hojas de cálculo\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"No tiene acceso a este documento.\",\n        \"docDisabled\": \"Este documento está desactivado.\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Documento sin título\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"No se han encontrado propietarios en la organización administrativa definida por `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"orgUser\": \"El usuario es propietario de la organización administrativa definida por `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"noAdminEmail\": \"Falta la cuenta de administrador porque `GRIST_ADMIN_EMAIL` y `GRIST_DEFAULT_EMAIL` no están establecidos\",\n        \"accountByEmail\": \"Cuenta de administrador definida por `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\"\n    }\n}\n"
  },
  {
    "path": "static/locales/eu.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Enter email address\": \"Sartu ePosta helbidea\",\n        \"Invite new member\": \"Gonbidatu kide berria\",\n        \"We'll email an invite to {{email}}\": \"Gonbidapena ePostaz bidaliko dugu {{email}}(e)ra\"\n    },\n    \"AccessRules\": {\n        \"Add Default Rule\": \"Gehitu defektuzko araua\",\n        \"Add table rules\": \"Gehitu taularen arauak\",\n        \"Checking...\": \"Egiaztatzen…\",\n        \"Condition\": \"Baldintza\",\n        \"Default rules\": \"Defektuzko arauak\",\n        \"Delete table rules\": \"Ezabatu taularen arauak\",\n        \"Enter Condition\": \"Sartu baldintza\",\n        \"Everyone\": \"Guztiek\",\n        \"Everyone Else\": \"Beste guztiek\",\n        \"Invalid\": \"Ez da baliozkoa\",\n        \"Permissions\": \"Baimenak\",\n        \"Reset\": \"Berrezarri\",\n        \"Save\": \"Gorde\",\n        \"Saved\": \"Gordeta\",\n        \"Special rules\": \"Arau bereziak\",\n        \"Type message to display when this rule blocks an action…\": \"Idatzi mezua…\",\n        \"Permission to edit document structure\": \"Dokumentuaren egitura editatzeko baimena\",\n        \"View as\": \"Ikusi honela\",\n        \"Add column rule\": \"Gehitu Zutabearen araua\",\n        \"Add user attributes\": \"Gehitu erabiltzailearen atributuak\",\n        \"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.\",\n        \"Allow everyone to view Access Rules.\": \"Baimendu guztiei sarbide-arauak ikustea.\",\n        \"Attribute name\": \"Atributu izena\",\n        \"Permission to view Access Rules\": \"Sarbide-arauak ikusteko baimena\",\n        \"Attribute to Look Up\": \"Bilatzeko atributua\",\n        \"Lookup Column\": \"Bilaketa zutabea\",\n        \"Lookup Table\": \"Bilaketa taula\",\n        \"Permission to access the document in full when needed\": \"Dokumentua osorik eskuratzeko baimena, beharrezkoa denean\",\n        \"Rules for table \": \"Taula-arauak \",\n        \"User Attributes\": \"Erabiltzailearen atributuak\",\n        \"Seed rules\": \"-\",\n        \"Add table-wide rule\": \"Gehitu taula osorako araua\",\n        \"This default should be changed if editors' access is to be limited. \": \"Editoreen sarbidea mugatu nahi bada, defektuzko araua aldatu beharko litzateke. \",\n        \"Remove {{- tableId }} rules\": \"Kendu {{- tableId}} arauak\",\n        \"Remove {{- name }} user attribute\": \"Kendu {{- name}} erabiltzailearen atributua\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Kendu {{- colId }} zutabea {{- tableId }} arauetatik\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Taula-arauak gehitzean, automatikoki gehitu arau bat JABEAri sarbide osoa emateko.\",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Baimendu editoreei egitura editatzea (adibidez, taulak, zutabeak, antolaketa aldatu eta ezabatzea), eta datu guztietarako sarbidea ematen duten formulak idaztea, irakurketaren murrizketak kontuan hartu gabe.\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Sarbide-arauak aldatu dira. Sakatu Berrezarri aldaketak desegiteko eta arauak freskatzeko.\",\n        \"All\": \"Guztia\",\n        \"Columns\": \"Zutabeak\",\n        \"Condition cannot be blank\": \"Baldintza ezin da hutsik egon\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Zutabe baliogabea {{tableId}}:{{invalidColIds}} taulan\",\n        \"Invalid table: {{tableId}}\": \"Taula baliogabea: {{tableId}}\",\n        \"hidden\": \"ezkutatuta\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Sarbide-arauak\\n\\nEz duzu baimenik dokumentu hau ikusi edo sarbide-arauak editatzeko.\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Arau bereziak** (hedatu arau bakoitza zehazteko nori eragiten dion)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"Sarbide-arauak ezgaitu ondoren, Editoreek dokumentuaren egitura aldatzeko eta formulak editatzeko gaitasuna izango dute. Editoreek eta Ikusleek dokumentuko datu guztiak ikusi ahal izango dituzte, baita kopiatu edo deskargatu ere.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"Sarbide-arauak gaitu ondoren, Editoreek ezingo dute dokumentuaren egitura aldatu\\nedo formulak editatu. Soilik Jabeek kopiatu edo deskargatu ahal izango dituzte\\ndokumentuko datu guztiak.\\n\\nEzarpen hauek 'Arau bereziak' atalean alda daitezke.\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"Baimendu Editoreei egitura editatzea (adibidez taulak, zutabeak eta antolaketa moldatu eta ezabatzea eta formulak idaztea.  Garrantzitsua: markatuz gero, Editoreek formulak editatzeko aukera izango dute, datu guztietara sarbidea eskainiz, taula eta zutabeen sarbide-arauek diotena diotela!\",\n        \"Allow everyone to view access rules.\": \"Baimendu guztiek ikustea sarbide-arauak.\",\n        \"Continue\": \"Jarraitu\",\n        \"Disable Access Rules\": \"Ezgaitu sarbide-arauak\",\n        \"Disable and save\": \"Ezgaitu eta gorde\",\n        \"Enable Access Rules\": \"Gaitu sarbide-arauak\",\n        \"Special rules for templates\": \"Txantiloien arau bereziak\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"{{colId}} zutabea ordenaren menpekoa izan litekeen {{tableId}} taularako arau anitzetan agertzen da. Saiatu arauak beste modu batera banatzen?\",\n        \"Default resource missing in resource map\": \"Baliabide-mapan lehenetsitako baliabidea falta da\",\n        \"Not a valid user attribute\": \"Ez da baliozko erabiltzaile atributua\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Baliabidea ez dago baliabide-mapan: {{resourceKey}}\",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Sarbide-arauak\\n\\nDokumentu honetarako oinarrizko sarbidea 'Partekatu' menuko 'Kudeatu erabiltzaileak' aukeraren bidez kontrolatzen da. Kolaboratzaile-rolak eslei ditzakezu, hala nola jabea, editorea edo ikuslea.\\n\\nKontrol pikordunagoa lortzeko, Sarbide-arauak sor ditzakezu, taula, zutabe edo errenkada zehatzak nork ikusi\\nedo editatu ditzakeen mugatzeko — erabilgarria datu sentikorretarako edo roletan oinarritutako baimenetarako.\\n[Ikasi gehiago.]({{helpAccessRules}})\",\n        \"Permission to access the document in full by all users\": \"Erabiltzaile guztiek dokumentua osorik eskuratzeko baimena\",\n        \"Permission to access the document in full by unrestricted users\": \"Mugatu gabeko erabiltzaileek dokumentua osorik eskuratzeko baimena\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Eragotzi jabe ez direnei dokumentu osoa kopiatzea edo deskargatzea. Oharra: honek irakurketa-murrizketarik gabeko erabiltzaileei bakarrik eragiten die, beste batzuk murriztuta egongo baitira ezarpen hau kontuan hartu gabe.\",\n        \"This options should be off if Editors' access is to be limited. \": \"Aukera honek desaktibatuta egon behar du Editoreen sarbidea mugatua izango bada. \",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Saihestu irakurtzeko muga guztiak eta utzi guztiei dokumentu osoa kopiatzen, edo osorik ikusteko jolas egiteko moduan. Adibide eta txantiloietarako erabili bakarrik, ez datu sentikorrak dituzten dokumentuetarako.\"\n    },\n    \"AccountPage\": {\n        \"API\": \"APIa\",\n        \"API Key\": \"API gakoa\",\n        \"Account settings\": \"Kontuaren ezarpenak\",\n        \"Allow signing in to this account with Google\": \"Ahalbidetu Google erabiliz kontu honetan saioa hastea\",\n        \"Change password\": \"Aldatu pasahitza\",\n        \"Edit\": \"Editatu\",\n        \"Email\": \"ePosta\",\n        \"Login method\": \"Saioa hasteko modua\",\n        \"Name\": \"Izena\",\n        \"Names only allow letters, numbers and certain special characters\": \"Izenek hizkiak, zenbakiak eta karaktere berezi batzuk bakarrik izan ditzakete\",\n        \"Password & security\": \"Pasahitza eta Segurtasuna\",\n        \"Save\": \"Gorde\",\n        \"Theme\": \"Itxura\",\n        \"Language\": \"Hizkuntza\",\n        \"Two-factor authentication\": \"Bi faktoreren autentifikazioa\",\n        \"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.\"\n    },\n    \"AccountWidget\": {\n        \"Accounts\": \"Kontuak\",\n        \"Add account\": \"Gehitu kontua\",\n        \"Document settings\": \"Dokumentuaren ezarpenak\",\n        \"Manage team\": \"Kudeatu Taldea\",\n        \"Profile settings\": \"Profilaren ezarpenak\",\n        \"Sign out\": \"Amaitu saioa\",\n        \"Sign in\": \"Hasi saioa\",\n        \"Switch Accounts\": \"Aldatu kontua\",\n        \"Support Grist\": \"Eman babesa Grist-i\",\n        \"Sign up\": \"Eman izena\",\n        \"Use This Template\": \"Txantiloi hau erabili\",\n        \"Access Details\": \"Sarbidearen xehetasunak\",\n        \"Pricing\": \"Prezioak\",\n        \"Activation\": \"Aktibazioa\",\n        \"Billing account\": \"Fakturazio-kontua\",\n        \"Toggle Mobile Mode\": \"Sakelako modua bai/ez\",\n        \"Upgrade Plan\": \"Hobetu plana\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Ikusi honela\",\n        \"Users from table\": \"Taulako erabiltzaileak\",\n        \"Example Users\": \"Probako erabiltzaileak\"\n    },\n    \"ActionLog\": {\n        \"All tables\": \"Taula guztiak\",\n        \"Action Log failed to load\": \"Akzio-erregistroak kargatzeak huts egin du\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Errenkada hau {{action.actionNum}} ekintzaren ondorioz ezabatu da\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"{{tableId}} taula #{{actionNum}} ekintza eta gero kendu da\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"{{colId}} zutabea #{{action.actionNum}} ekintza eta gero kendu da\",\n        \"History blocked because of access rules.\": \"Historia blokeatuta dago sarbide-arauak direla-eta.\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Errenkada hau {{actionNum}} ekintzaren ondorioz kendu da\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Gehitu berria\"\n    },\n    \"ApiKey\": {\n        \"Click to show\": \"Egin klik erakusteko\",\n        \"Create\": \"Sortu\",\n        \"Remove\": \"Kendu\",\n        \"Remove API Key\": \"Kendu API gakoa\",\n        \"This API key can be used to access this account anonymously via the API.\": \"API gako hau erabiliz kontu honetara modu anonimoan sartzea dago.\",\n        \"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.\",\n        \"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.\",\n        \"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 atzera botako ditu. Ziur ezabatu nahi duzula?\"\n    },\n    \"App\": {\n        \"Description\": \"Deskribapena\",\n        \"Key\": \"Gakoa\",\n        \"Memory Error\": \"Memoria-errorea\",\n        \"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\"\n    },\n    \"AppHeader\": {\n        \"Personal Site\": \"Gune pertsonala\",\n        \"Team Site\": \"Taldearen gunea\",\n        \"Grist Templates\": \"Grist txantiloiak\",\n        \"Manage team\": \"Kudeatu Taldea\",\n        \"Home page\": \"Hasierako orria\",\n        \"Billing account\": \"Fakturazio-kontua\",\n        \"Legacy\": \"Zaharkitua\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Itzuli hasierara\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Taldearen gunea bertan behera utzi da. Dokumentuak irakur daitezke, baina ez moldatu.\"\n    },\n    \"CellContextMenu\": {\n        \"Clear values\": \"Garbitu balioak\",\n        \"Copy anchor link\": \"Kopiatu esteka\",\n        \"Delete {{count}} columns_one\": \"Ezabatu zutabea\",\n        \"Delete {{count}} columns_other\": \"Ezabatu {{count}} zutabeak\",\n        \"Delete {{count}} rows_one\": \"Ezabatu errenkada\",\n        \"Delete {{count}} rows_other\": \"Ezabatu {{count}} errenkadak\",\n        \"Duplicate rows_one\": \"Bikoiztu errenkada\",\n        \"Duplicate rows_other\": \"Bikoiztu errenkadak\",\n        \"Insert column to the left\": \"Txertatu zutabea ezkerrean\",\n        \"Insert column to the right\": \"Txertatu zutabea eskuman\",\n        \"Insert row\": \"Txertatu errenkada\",\n        \"Insert row above\": \"Txertatu errenkada gainean\",\n        \"Insert row below\": \"Txertatu errenkada azpian\",\n        \"Reset {{count}} columns_one\": \"Berrezarri zutabea\",\n        \"Reset {{count}} columns_other\": \"Berrezarri {{count}} zutabeak\",\n        \"Reset {{count}} entire columns_one\": \"Berrezarri zutabe osoa\",\n        \"Reset {{count}} entire columns_other\": \"Berrezarri {{count}} zutabe osoak\",\n        \"Comment\": \"Iruzkina\",\n        \"Copy\": \"Kopiatu\",\n        \"Cut\": \"Ebaki\",\n        \"Paste\": \"Itsatsi\",\n        \"Clear cell\": \"Garbitu gelaxka\",\n        \"Filter by this value\": \"Iragazi balio honen arabera\",\n        \"Copy with headers\": \"Kopiatu goiburuekin\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Aplikatu\",\n        \"Cancel\": \"Utzi\",\n        \"Default cell style\": \"Gelaxken defektuzko estiloa\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"Guztia\",\n        \"No matching values\": \"Ez dago bat datozen baliorik\",\n        \"Others\": \"Besteak\",\n        \"Search\": \"Bilatu\",\n        \"Search values\": \"Bilatu balioak\",\n        \"None\": \"Bat ere ez\",\n        \"Min\": \"Min.\",\n        \"Filter by Range\": \"Iragazi tartearen arabera\",\n        \"Max\": \"Max.\",\n        \"Start\": \"Hasi\",\n        \"End\": \"Amaiera\",\n        \"Other values\": \"Beste balio batzuk\",\n        \"All except\": \"Denak, hauek izan ezik\",\n        \"Other Non-Matching\": \"Bat ez datorren beste bat\",\n        \"Other Matching\": \"Bat datorren beste bat\",\n        \"All shown\": \"Guztiak erakusten\",\n        \"Future values\": \"Etorkizuneko balioak\",\n        \"Clear search\": \"Garbitu bilaketa\",\n        \"Pin filter\": \"Finkatu iragazkia\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Sailkatu alfabetikoki (unean: agerpen kopuruaren arabera sailkatuta)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Sailkatu agerpen kopuruaren arabera (unean: alfabetikoki sailkatuta)\",\n        \"Unpin filter\": \"Utzi iragazkia finkatzeari\"\n    },\n    \"CustomSectionConfig\": {\n        \"Add\": \"Gehitu\",\n        \"Enter Custom URL\": \"Sartu URL pertsonalizatua\",\n        \"Open configuration\": \"Ireki konfigurazioa\",\n        \"Pick a column\": \"Hautatu zutabea\",\n        \"Read selected table\": \"Irakurri hautatutako taula\",\n        \"Widget does not require any permissions.\": \"Widgetak ez du baimenik behar.\",\n        \"Clear selection\": \"Garbitu hautaketa\",\n        \"Pick a {{columnType}} column\": \"Aukeratu {{columnType}} zutabe bat\",\n        \"Learn more about custom widgets\": \"Ikasi gehiago widget pertsonalizatuei buruz\",\n        \"No document access\": \"Sarbiderik ez dokumentura\",\n        \"Widget needs to {{read}} the current table.\": \"Widgetak uneko taula {{read}} behar du.\",\n        \"Select Custom Widget\": \"Hautatu widget pertsonalizatua\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Widgetak {{fullAccess}} sarbidea behar du dokumentu honetara.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} ez da {{columnType}} zutabea erakusten\",\n        \"No {{columnType}} columns in table.\": \"Ez dago {{columnType}} zutaberik taulan.\",\n        \" (optional)\": \" (aukerakoa)\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} ez dira {{columnType}} zutabeak erakusten\",\n        \"Full document access\": \"Sarbide osoa dokumentura\",\n        \"Accept\": \"Onartu\",\n        \"Developer:\": \"Garatzailea:\",\n        \"Last updated:\": \"Azkenekoz eguneratua:\",\n        \"Missing description and author information.\": \"Ez dago deskribapen ezta egilearen informaziorik ere.\",\n        \"Reject\": \"Bota atzera\",\n        \"ACCESS LEVEL\": \"SARBIDE-MAILA\",\n        \"Custom URL\": \"URL pertsonalizatua\",\n        \"Widget\": \"Widgeta\",\n        \"Change custom widget\": \"Aldatu widget pertsonalizatua\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Egin klik kopiatzeko\",\n        \"Duplicate table\": \"Bikoiztu taula\",\n        \"Table ID copied to clipboard\": \"Taularen IDa arbelera kopiatu da\",\n        \"Remove table\": \"Kendu taula\",\n        \"Rename table\": \"Berrizendatu taula\",\n        \"You do not have edit access to this document\": \"Ez duzu dokumentu hau editatzeko sarbiderik\",\n        \"Raw Data Tables\": \"Datu gordinen taulak\",\n        \"Edit record card\": \"Editatu erregistro-txartela\",\n        \"Record Card\": \"Erregistro-txartela\",\n        \"Record Card Disabled\": \"Erregistro-txartela ezgaituta\",\n        \"{{action}} Record Card\": \"{{action}} erregistro-txartela\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"{{formattedTableName}} datuak ezabatu eta orri guztietatik kendu?\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Jarduera\",\n        \"Beta\": \"Beta\",\n        \"Compare to current\": \"Alderatu unekoarekin\",\n        \"Compare to previous\": \"Alderatu aurrekoarekin\",\n        \"Snapshots are unavailable.\": \"Argazkiak ez daude erabilgarri.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Jabeek bakarrik eskura ditzakete sarbide-arauak dituzten dokumentuen argazkiak.\",\n        \"Snapshots\": \"Argazkiak\",\n        \"Open snapshot\": \"Ireki argazkia\"\n    },\n    \"DocMenu\": {\n        \"Access Details\": \"Sarbidearen xehetasunak\",\n        \"All documents\": \"Dokumentu guztiak\",\n        \"By Date Modified\": \"Moldatu zen dataren arabera\",\n        \"By Name\": \"Izenaren arabera\",\n        \"Delete\": \"Ezabatu\",\n        \"Delete Forever\": \"Betiko ezabatu\",\n        \"Delete {{name}}\": \"Ezabatu {{name}}\",\n        \"Discover More Templates\": \"Arakatu txantiloi gehiago\",\n        \"Document will be moved to Trash.\": \"Dokumentua zakarrontzira eramango da.\",\n        \"Document will be permanently deleted.\": \"Dokumentua betiko ezabatuko da.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Dokumentuek 30 egun ematen dituzte zakarrontzian eta, ondoren, betiko ezabatzen dira.\",\n        \"Examples and Templates\": \"Adibideak eta Txantiloiak\",\n        \"Featured\": \"Ezaugarriak\",\n        \"Manage users\": \"Kudeatu erabiltzaileak\",\n        \"More Examples and Templates\": \"Adibide eta Txantiloi gehiago\",\n        \"Move\": \"Mugitu\",\n        \"Other Sites\": \"Beste guneak\",\n        \"Pin Document\": \"Finkatu dokumentua\",\n        \"Pinned Documents\": \"Finkatutako dokumentuak\",\n        \"Remove\": \"Kendu\",\n        \"Rename\": \"Berrizendatu\",\n        \"Requires edit permissions\": \"Editatzeko baimenak behar ditu\",\n        \"This service is not available right now\": \"Zerbitzua ez dago unean erabilgarri\",\n        \"Trash\": \"Zakarrontzia\",\n        \"Trash is empty.\": \"Zakarrontzia hutsik dago.\",\n        \"Unpin Document\": \"Utzi dokumentua finkatzeari\",\n        \"(The organization needs a paid plan)\": \"(Erakundeak plan ordaindua behar du)\",\n        \"Current workspace\": \"Uneko lan-eremua\",\n        \"Deleted {{at}}\": \"{{at}} ezabatua\",\n        \"Edited {{at}}\": \"{{at}} editatua\",\n        \"Examples & Templates\": \"Adibideak & Txantiloiak\",\n        \"Move {{name}} to workspace\": \"Mugitu {{name}} lan-eremura\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Betiko ezabatu \\\"{{name}}\\\"?\",\n        \"Restore\": \"Leheneratu\",\n        \"To restore this document, restore the workspace first.\": \"Dokumentu hau leheneratzeko, leheneratu lan-eremua aurrenik.\",\n        \"Workspace not found\": \"Ez da lan-eremua aurkitu\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"{{siteName}} gunean zaude. Honako gune hauetara ere sar zaitezke:\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Zure leku pertsonalean zaude. Honako gune hauetara ere sar zaitezke:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Lan-eremu bat betiko ezabatzeko ezin du barruan dokumenturik izan.\",\n        \"Create my first document\": \"Sortu nire lehen dokumentua\",\n        \"Any documents created in this site will appear here.\": \"Gune honetan sortutako dokumentuak hemen agertuko dira.\",\n        \"personal site\": \"gune pertsonala\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Soilik irakurtzeko sarbidea duzu gune honetan. Unean ez dago dokumenturik.\",\n        \"Grid view\": \"Sareta-bista\",\n        \"List view\": \"Zerrenda-bista\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Gehitu taula hutsa\",\n        \"Add page\": \"Gehitu orria\",\n        \"Reload\": \"Birkargatu\",\n        \"You do not have edit access to this document\": \"Ez duzu dokumentu hau editatzeko sarbiderik\",\n        \"Add widget to page\": \"Gehitu widgeta orrira\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Dokumentuen jabeak dokumentua berreskuratzen saia daitezke. [{{error}}]\",\n        \"Enter recovery mode\": \"Sartu berreskuratze moduan\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Barkatu, dokumentu honetarako sarbidea ukatu da. [{{error}}]\",\n        \"Error accessing document\": \"Errorea dokumentura sartzean\",\n        \"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}}]\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Birkargatu dokumentua eta, erroreak badirau, jarri harremanetan dokumentuaren jabearekin dokumentua berreskuratzen saiatzeko. [{{error}}]\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Moneta:\",\n        \"Document settings\": \"Dokumentuaren ezarpenak\",\n        \"Local currency ({{currency}})\": \"Moneta lokala ({{currency}})\",\n        \"Save\": \"Gorde\",\n        \"Save and Reload\": \"Gorde eta birkargatu\",\n        \"This document's ID (for API use):\": \"Dokumentu honen IDa (APIarekin erabiltzeko):\",\n        \"Time Zone:\": \"Ordu-eremua:\",\n        \"API\": \"APIa\",\n        \"Document ID copied to clipboard\": \"Dokumentuaren IDa arbelera kopiatu da\",\n        \"Ok\": \"Ados\",\n        \"API URL copied to clipboard\": \"APIaren URLa arbelera kopiatu da\",\n        \"API documentation.\": \"APIaren dokumentazioa.\",\n        \"Copy to clipboard\": \"Kopiatu arbelera\",\n        \"Currency\": \"Moneta\",\n        \"Document ID\": \"Dokumentuaren IDa\",\n        \"Python\": \"Python\",\n        \"Python version used\": \"Erabiltzen ari den Python bertsioa\",\n        \"Reload\": \"Birkargatu\",\n        \"Time zone\": \"Ordu-eremua\",\n        \"Cancel\": \"Utzi\",\n        \"Locale:\": \"Eskualdeko ezarpenak:\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Motorra ({{span}} esperimentala, aldatu zure kontu):\",\n        \"Manage Webhooks\": \"Kudeatu webhookak\",\n        \"Webhooks\": \"Webhookak\",\n        \"API console\": \"API kontsola\",\n        \"Coming soon\": \"Aurki\",\n        \"Default for DateTime columns\": \"DateTime zutabeetarako defektuzkoa\",\n        \"Base doc URL: {{docApiUrl}}\": \"Oinarrizko dokumentuaren URLa: {{docApiUrl}}\",\n        \"Data engine\": \"Datu-motorra\",\n        \"ID for API use\": \"APIaren erabilerarako Ida\",\n        \"Locale\": \"Eskualdeko ezarpenak\",\n        \"Hard reset of data engine\": \"Datu-motorraren berrasiera\",\n        \"Try API calls from the browser\": \"Probatu APIaren eskaerak nabigatzailetik\",\n        \"Reload data engine\": \"Birkargatu datu-motorra\",\n        \"Formula timer\": \"Formula-kronometroa\",\n        \"Reload data engine?\": \"Datu-motorra birkargatu?\",\n        \"Start timing\": \"Hasi kronometratzen\",\n        \"Stop timing...\": \"Utzi kronometratzeari...\",\n        \"Timing is on\": \"Kronometroa martxan dago\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Dokumentuan aldaketak egin ditzakezu; utzi kronometratzeari emaitzak ikusteko.\",\n        \"Only available to document editors\": \"Soilik dokumentuen editoreentzat eskuragarri\",\n        \"Only available to document owners\": \"Soilik dokumentuen jabeentzat eskuragarri\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"REST APIak {{docId}} eskatzen duen bakoitzean erabili beharreko IDa. Ikus {{apiURL}}\",\n        \"Find slow formulas\": \"Formula motelak bilatu\",\n        \"For currency columns\": \"Moneta zutabeetarako\",\n        \"For number and date formats\": \"Zenbaki- eta data-formatuetarako\",\n        \"Formula times\": \"Formula-denborak\",\n        \"Manage webhooks\": \"Kudeatu webhookak\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Behartu dokumentua berriro kargatzera formulak kronometratu bitartean, eta erakutsi emaitzak.\",\n        \"Notify other services on doc changes\": \"Jakinarazi beste zerbitzu batzuei dokumentuak aldatzerakoan\",\n        \"python2 (legacy)\": \"python2 (legatua)\",\n        \"python3 (recommended)\": \"python3 (gomendatua)\",\n        \"Time reload\": \"Kronometratu birkarga\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Dokumentu arruntaren jokaera. Erabiltzaile guztiek egiten dute lan dokumentuaren kopia berberean.\",\n        \"Template\": \"Txantiloia\",\n        \"Change document type\": \"Aldatu dokumentu mota\",\n        \"Change nature of document\": \"Aldatu dokumentuaren natura\",\n        \"Regular document\": \"Dokumentu arrunta\",\n        \"Regular\": \"Arrunta\",\n        \"Confirm change\": \"Baieztatu aldaketa\",\n        \"Edit\": \"Editatu\",\n        \"Template mode\": \"Txantiloi modua\",\n        \"fiddle mode\": \"jolasteko modua\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Dokumentua automatikoki irekitzen da {{fiddleModeDocUrl}}(e)n. Edonork edita dezake, eta gordeko ez den kopia bat sortuko du.\",\n        \"Tutorial\": \"Tutoriala\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Datu-motorra guztiz birkargatuko du. Lagundu dezake datu-motorra amaigabeko begizta batean trabatuta badago; azken aldaketa prozesatzeari uzten ez badio; edo kraskatu bada. Ez da daturik galduko, unean egin gabe dauden ekintzak izan ezik.\",\n        \"Document automatically opens as a user-specific copy.\": \"Dokumentua automatikoki irekitzen da erabiltzailearentzat kopia gisa.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Kronometratzen hasten zarenean, Gristek formula bakoitza ebaluatzeko zenbat denbora behar duen neurtuko du. Dokumentu bat lehen aldiz irekitzen denean (edo aldaketak egin dienean), errendimendua moteltzen duten formulak diagnostika ditzazke.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Klikatu \\\"Hasi lekualdaketa\\\" barneko biltegiratzera mugitzeko (dokumentuaren SQLite fitxategian gordeta).\",\n        \"**Some existing attachments are still external**.\": \"**Lehendik dauden eranskin batzuk kanpoan biltegiratuta daude**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Lehendik dauden eranskin batzuk kanpoan biltegiratuta daude**. (SQLite fitxategian gordeta).\",\n        \"Attachment storage\": \"Eranskinen biltegiratzea\",\n        \"Being transfer\": \"Lekualdatzen\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Klikatu \\\"Hasi lekualdaketa\\\" kanpoko biltegiratzera mugitzeko.\",\n        \"Start transfer\": \"Hasi lekualdaketa\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Berriki igotako eranskinak kanpoko biltegiratzean gordeko dira.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Berriki igotako eranskinak barneko biltegiratzean gordeko dira.\",\n        \"No external stores available\": \"Ez dago kanpoko biltegiratzerik erabilgarri\",\n        \"Preferred storage for this document\": \"Dokumentu honetarako lehenetsitako biltegiratzea\",\n        \"External\": \"Kanpokoa\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Lehendik dauden fitxategi erantsi batzuk [kanpokoak]({{externalLink}}) dira oraindik**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Lehendik dauden fitxategi erantsi batzuk [barnekoak]({{internalLink}}) dira oraindik** (SQLite fitxategian gordeta).\",\n        \"Internal\": \"Barnekoa\",\n        \"Transfer in progress\": \"Transferentzia abian da\",\n        \"[Learn more.]({{learnLink}})\": \"[Ikasi gehiago.]({{learnLink}})\",\n        \"Upload\": \"Igo\",\n        \"Upload missing attachments\": \"Igo falta diren eranskinak\",\n        \"Uploading...\": \"Igotzen…\",\n        \"Default\": \"Defektuzkoa\",\n        \"Document type\": \"Dokumentu mota\",\n        \"Default, template, or tutorial\": \"Defektuzkoa, txantiloia edo tutoriala\",\n        \"Allow others to suggest changes\": \"Baimendu besteei aldaketak iradokitzea\",\n        \"Enable suggestions\": \"Gaitu iradokizunak\",\n        \"Suggestions\": \"Iradokizunak\",\n        \"experiment\": \"esperimentua\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"Eranskinen tamaina\",\n        \"Usage\": \"Erabilera\",\n        \"Data size\": \"Datuen tamaina\",\n        \"For higher limits, \": \"Muga altuagoetarako, \",\n        \"Rows\": \"Errenkadak\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Jarri gunearen jabearekin harremanetan planaren mugak handitzeko.\",\n        \"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.\",\n        \"start your 30-day free trial of the Pro plan.\": \"Hasi 30 eguneko Pro planaren doako proba.\"\n    },\n    \"DuplicateTable\": {\n        \"Name for new table\": \"Taula berriaren izena\",\n        \"Copy all data in addition to the table structure.\": \"Kopiatu datu guztiak taularen egituraz gain.\",\n        \"Only the document default access rules will apply to the copy.\": \"Soilik kopiari aplikatuko zaizkio dokumenturako defektuzko sarbide-arauak.\",\n        \"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}}\"\n    },\n    \"ExampleInfo\": {\n        \"Lightweight CRM\": \"CRM arina\",\n        \"Afterschool Program\": \"Eskolaz kanpoko programa\",\n        \"Investment Research\": \"Inbertsioen ikerketa\",\n        \"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.\",\n        \"Tutorial: Analyze & Visualize\": \"Tutoriala: aztertu eta bistaratu\",\n        \"Tutorial: Create a CRM\": \"Tutoriala: CRM bat sortu\",\n        \"Tutorial: Manage Business Data\": \"Tutoriala: Negozio-datuak kudeatu\",\n        \"Welcome to the Afterschool Program template\": \"Ongi etorri Eskolaz kanpoko programa txantiloira\",\n        \"Welcome to the Investment Research template\": \"Ongi etorri Inbertsioen ikerketa txantiloira\",\n        \"Welcome to the Lightweight CRM template\": \"Ongi etorri CRM arinaren txantiloira\",\n        \"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.\",\n        \"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.\"\n    },\n    \"FieldConfig\": {\n        \"Clear and reset\": \"Garbitu eta berrezarri\",\n        \"Empty columns_one\": \"Zutabea hutsik dago\",\n        \"Empty columns_other\": \"Zutabeak hutsik daude\",\n        \"Enter formula\": \"Sartu formula\",\n        \"Set formula\": \"Ezarri formula\",\n        \"DESCRIPTION\": \"DESKRIBAPENA\",\n        \"Convert column to data\": \"Bilakatu zutabea datu\",\n        \"Column options are limited in summary tables.\": \"Zutabeen aukerak laburpen-tauletara mugatuta daude.\",\n        \"Data columns_other\": \"Datu-zutabeak\",\n        \"Formula columns_one\": \"Formula-zutabea\",\n        \"Formula columns_other\": \"Formula-zutabeak\",\n        \"Make into data column\": \"Bihurtu datu-zutabean\",\n        \"TRIGGER FORMULA\": \"ABIARAZI FORMULA\",\n        \"COLUMN BEHAVIOR\": \"ZUTABEAREN PORTAERA\",\n        \"COLUMN LABEL AND ID\": \"ZUTABEEN ETIKETA ETA IDa\",\n        \"Clear and make into formula\": \"Garbitu eta bilakatu formula\",\n        \"Convert to trigger formula\": \"Bilakatu formula abiarazteko\",\n        \"Data columns_one\": \"Datu-zutabea\",\n        \"Mixed Behavior\": \"Portaera mistoa\",\n        \"Set trigger formula\": \"Ezarri abiarazlearen formula\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Itzuli ohiko ezarpenetara\",\n        \"Save as common settings\": \"Gorde ohiko ezarpen gisa\",\n        \"Use separate settings\": \"Erabili ezarpen bananduak\",\n        \"Using common settings\": \"Erabili ohiko ezarpenak\",\n        \"Using separate settings\": \"Banandutako ezarpenak erabiltzen\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Gehitu zutabea\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Finkatu iragazkia - {{- columnName}} zutabea (unean: finkatu gabe)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Utzi iragazkia finkatzeari - {{- columnName}} zutabea (unean: finkatuta)\",\n        \"remove filter - {{- columnName}} column\": \"kendu iragazkia - {{- columnName}} zutabea\",\n        \"{{- columnName }} column filters\": \"{{- columnName }} zutabearen iragazkiak\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Saretaren aukerak\",\n        \"Horizontal gridlines\": \"Sareta horizontala\",\n        \"Vertical gridlines\": \"Sareta bertikala\",\n        \"Zebra stripes\": \"Zebra marrak\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Gehitu zutabea\",\n        \"Column Options\": \"Zutabearen aukerak\",\n        \"Freeze {{count}} columns_one\": \"Izoztu zutabe hau\",\n        \"Freeze {{count}} columns_other\": \"Izoztu {{count}} zutabeak\",\n        \"Freeze {{count}} more columns_one\": \"Izoztu zutabe bat gehiago\",\n        \"More sort options ...\": \"Sailkatzeko aukera gehiago…\",\n        \"Rename column\": \"Berrizendatu zutabea\",\n        \"Reset {{count}} columns_one\": \"Berrezarri zutabea\",\n        \"Reset {{count}} columns_other\": \"Berrezarri {{count}} zutabeak\",\n        \"Reset {{count}} entire columns_one\": \"Berrezarri zutabe osoa\",\n        \"Reset {{count}} entire columns_other\": \"Berrezarri {{count}} zutabe osoak\",\n        \"Show column {{- label}}\": \"Erakutsi {{- label}} zutabea\",\n        \"Sort\": \"Sailkatu\",\n        \"Unfreeze all columns\": \"Utzi zutabe guztiak izozteari\",\n        \"Unfreeze {{count}} columns_one\": \"Utzi zutabe hau izozteari\",\n        \"Unfreeze {{count}} columns_other\": \"Utzi {{count}} zutabeak izozteari\",\n        \"Insert column to the left\": \"Txertatu zutabea ezkerrean\",\n        \"Insert column to the right\": \"Txertatu zutabea eskuman\",\n        \"Hidden Columns\": \"Ezkutatutako zutabeak\",\n        \"Shortcuts\": \"Lasterbideak\",\n        \"Show hidden columns\": \"Erakutsi ezkutatutako zutabeak\",\n        \"Any\": \"Edozein\",\n        \"Text\": \"Testua\",\n        \"Toggle\": \"Bai/Ez\",\n        \"Date\": \"Data\",\n        \"Choice List\": \"Aukeren zerrenda\",\n        \"Add to sort\": \"Gehitu sailkatzeko\",\n        \"Filter Data\": \"Iragazi datuak\",\n        \"Clear values\": \"Garbitu balioak\",\n        \"Delete {{count}} columns_one\": \"Ezabatu zutabea\",\n        \"Delete {{count}} columns_other\": \"Ezabatu {{count}} zutabeak\",\n        \"Hide {{count}} columns_other\": \"Ezkutatu {{count}} zutabeak\",\n        \"Insert column to the {{to}}\": \"Txertatu zutabea {{to}}\",\n        \"Freeze {{count}} more columns_other\": \"Izoztu {{count}} zutabe gehiago\",\n        \"Hide {{count}} columns_one\": \"Ezkutatu zutabea\",\n        \"Detect Duplicates in...\": \"Antzeman bikoiztutakoak…\",\n        \"Choice\": \"Aukera\",\n        \"Convert formula to data\": \"Bilakatu formula datu\",\n        \"Sorted (#{{count}})_one\": \"(# {{count}}) sailkatua\",\n        \"Sorted (#{{count}})_other\": \"(# {{count}}) sailkatua\",\n        \"Apply on record changes\": \"Aplikatu erregistro-aldaketetan\",\n        \"Apply to new records\": \"Aplikatu erregistro berriei\",\n        \"Authorship\": \"Egiletza\",\n        \"Last Updated At\": \"Azken eguneraketa\",\n        \"Last Updated By\": \"Azken eguneratzailea\",\n        \"UUID\": \"UUIDa\",\n        \"Timestamp\": \"Data-zigilua\",\n        \"Add formula column\": \"Gehitu formula-zutabea\",\n        \"Created at\": \"Sortze-data\",\n        \"Created by\": \"Sortzailea\",\n        \"Last updated at\": \"Azken eguneraketa\",\n        \"DateTime\": \"Data eta ordua\",\n        \"Reference\": \"Erreferentzia\",\n        \"Attachment\": \"Eranskina\",\n        \"Integer\": \"Zenbaki osoa\",\n        \"Created At\": \"Sortze-data\",\n        \"Created By\": \"Sortzailea\",\n        \"Lookups\": \"Bilaketak\",\n        \"no reference column\": \"Erreferentzia-zutaberik ez\",\n        \"Adding UUID column\": \"UUID zutabea gehitzen\",\n        \"Adding duplicates column\": \"Bikoiztutako zutabea gehitzen\",\n        \"Duplicate in {{- label}}\": \"{{- label}} bikoiztuta\",\n        \"No reference columns.\": \"Ez dago erreferentzia-zutaberik.\",\n        \"Search columns\": \"Bilaketa-zutabeak\",\n        \"Detect duplicates in...\": \"Antzeman bikoizketak…\",\n        \"Add column with type\": \"Gehitu mota honetako zutabea\",\n        \"Last updated by\": \"Azken eguneratzailea\",\n        \"Numeric\": \"Zenbakizkoa\",\n        \"Reference List\": \"Erreferentzia-zerrenda\"\n    },\n    \"HomeIntro\": {\n        \"Browse Templates\": \"Arakatu txantiloiak\",\n        \"Create empty document\": \"Sortu dokumentu hutsa\",\n        \"Invite Team Members\": \"Gonbidatu taldeko kideak\",\n        \"Visit our {{link}} to learn more.\": \"Bisitatu {{link}} gehiago ikasteko.\",\n        \"Welcome to Grist!\": \"Ongi etorri Grist-era!\",\n        \"Welcome to Grist, {{name}}!\": \"Ongi etorri Grist-era, {{name}}!\",\n        \"personal site\": \"gune pertsonala\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} zure lana gordetzeko. \",\n        \"Welcome to Grist, {{- name}}!\": \"Ongi etorri Grist-era, {{- name}}!\",\n        \"Welcome to {{- orgName}}\": \"Ongi etorri {{- orgName}}(e)ra\",\n        \"Sign in\": \"Hasi saioa\",\n        \"To use Grist, please either sign up or sign in.\": \"Grist erabiltzeko eman izena edo hasi saioa.\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Bisitatu {{link}} Grist-i buruz gehiago ikasteko.\",\n        \"Help Center\": \"Laguntza-gunea\",\n        \"Import document\": \"Inportatu dokumentua\",\n        \"Welcome to {{orgName}}\": \"Ongi etorri {{orgName}}(e)ra\",\n        \"Sign up\": \"Eman izena\",\n        \"Any documents created in this site will appear here.\": \"Hemen agertuko dira gune honetan sortzen diren dokumentuak.\",\n        \"Get started by creating your first Grist document.\": \"Has zaitez zure lehen Grist dokumentua sortuz.\",\n        \"This workspace is empty.\": \"Lan-eremu hau hutsik dago.\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Soilik irakurtzeko sarbidea duzu gune honetan. Unean ez dago dokumenturik.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Informazio gehiago gure {{helpCenterLink}}n.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Has zaitez txantiloiak arakatuz edo zure lehen Grist dokumentua sortuz.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Has zaitez zure taldea gonbidatuz eta zure lehen Grist dokumentua sortuz.\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Grist zure taldetik kanpo erabili nahi duzu? Bisitatu zure doako \",\n        \"Sprouts Program\": \"Kimuen programa\",\n        \"Only show documents\": \"Erakutsi dokumentuak bakarrik\"\n    },\n    \"HomeLeftPane\": {\n        \"All documents\": \"Dokumentu guztiak\",\n        \"Delete\": \"Ezabatu\",\n        \"Examples & Templates\": \"Txantiloiak\",\n        \"Import document\": \"Inportatu dokumentua\",\n        \"Rename\": \"Berrizendatu\",\n        \"Trash\": \"Zakarrontzia\",\n        \"Manage users\": \"Kudeatu erabiltzaileak\",\n        \"Terms of service\": \"Zerbitzuaren baldintzak\",\n        \"Access Details\": \"Sarbidearen xehetasunak\",\n        \"Create empty document\": \"Sortu dokumentu hutsa\",\n        \"Create workspace\": \"Sortu lan-eremua\",\n        \"Workspace will be moved to Trash.\": \"Lan-eremua zakarrontzira mugituko da.\",\n        \"Workspaces\": \"Lan-eremuak\",\n        \"Delete {{workspace}} and all included documents?\": \"{{workspace}} eta barne dituen dokumentu guztiak ezabatu?\",\n        \"Tutorial\": \"Tutoriala\",\n        \"Grist Resources\": \"Gristen baliabideak\",\n        \"context menu - {{- workspaceName }}\": \"laster-menua - {{- workspaceName }}\",\n        \"Import from Airtable\": \"Inportatu Airtable-tik\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Laguntza-gunea\",\n        \"Accessibility\": \"Irisgarritasuna\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"Txantiloi gisa\",\n        \"Cancel\": \"Utzi\",\n        \"Name\": \"Izena\",\n        \"Organization\": \"Erakundea\",\n        \"Original Looks Identical\": \"Jatorrizkoak berbera dirudi\",\n        \"Update\": \"Eguneratu\",\n        \"Update Original\": \"Eguneratu jatorrizkoa\",\n        \"Download\": \"Jaitsi\",\n        \"Download document\": \"Jaitsi dokumentua\",\n        \"Original Has Modifications\": \"Jatorrizkoak moldaketak ditu\",\n        \"Enter document name\": \"Sartu dokumentuaren izena\",\n        \"Sign up\": \"Eman izena\",\n        \"No destination workspace\": \"Ez dago helmugako lan-eremurik\",\n        \"Original Looks Unrelated\": \"Badirudi jatorrizkoak ez duela zerikusirik\",\n        \"Overwrite\": \"Gainidatzi\",\n        \"Workspace\": \"Lan-eremua\",\n        \"You do not have write access to this site\": \"Ez duzu gune honetarako idazketa-sarbiderik\",\n        \"You do not have write access to the selected workspace\": \"Ez duzu hautatutako lan-eremurako idazketa-sarbiderik\",\n        \"Download document and history\": \"Jaitsi dokumentu osoa eta historia\",\n        \"However, it appears to be already identical.\": \"Hala ere, badirudi lehendik berdina dela.\",\n        \"Include the structure without any of the data.\": \"Sartu egitura, daturik gabe.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Gainidatziko da, dokumentu honetan ez dagoen edukia galduz.\",\n        \"The original version of this document will be updated.\": \"Dokumentu honen jatorrizko bertsioa eguneratuko da.\",\n        \"To save your changes, please sign up, then reload this page.\": \"Aldaketak gordetzeko, eman izena eta ondoren birkargatu orri hau.\",\n        \"Replacing the original requires editing rights on the original document.\": \"Jatorrizkoa ordezkatzeko, jatorrizko dokumentua editatzeko-eskubidea behar da.\",\n        \"Download document structure only (no data, for template use)\": \"Kendu datu guztiak baina gorde egitura txantiloi gisa erabiltzeko\",\n        \"Download document without history (can significantly reduce file size)\": \"Kendu dokumentuaren historia (fitxategiaren tamaina nabarmen murriztu daiteke)\",\n        \"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.\",\n        \".tar (recommended)\": \".tar (gomendatua)\",\n        \".zip\": \".zip\",\n        \"Download attachments\": \"Deskargatu eranskinak\",\n        \"Download full document and history\": \"Deskargatu dokumentu osoa eta historia\",\n        \"Format:\": \"Formatua:\",\n        \"Learn more\": \"Ikasi gehiago\",\n        \"download attachments\": \"deskargatu eranskinak\",\n        \"Download an archive of all the attachments present in this document.\": \"Deskargatu artxibo bat dokumentu honetan dauden eranskinekin.\",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"Dokumentu hau Grist instantzia batera igotzeko asmoa baduzu, .tar formatuan beharko duzu artxiboa eranskinak leheneratu ahal izateko. \"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"Eskatu laguntza\",\n        \"Cannot find personal site, sorry!\": \"Ezin da gune pertsonala aurkitu!\",\n        \"Go to your free personal site\": \"Joan zure doako gune pertsonalera\",\n        \"No notifications\": \"Jakinarazpenik ez\",\n        \"Notifications\": \"Jakinarazpenak\",\n        \"Give feedback\": \"Eman iritzia\",\n        \"Renew\": \"Berriztu\",\n        \"Report a problem\": \"Eman akats baten berri\",\n        \"Manage billing\": \"Kudeatu fakturazioa\",\n        \"Upgrade Plan\": \"Hobetu plana\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Amaitu\",\n        \"Next\": \"Hurrengoa\",\n        \"Previous\": \"Aurrekoa\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Ezabatu\",\n        \"Delete data and this page.\": \"Ezabatu datuak eta orri hau.\",\n        \"The following tables will no longer be visible_one\": \"Ondorengo taula ez da aurrerantzean ikusgai egongo\",\n        \"The following tables will no longer be visible_other\": \"Ondorengo taulak ez dira aurrerantzean ikusgai egongo\",\n        \"raw data page\": \"datu gordinen orria\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Mantendu datuak eta ezabatu orria. Taulak eskuragai jarraituko du {{rawDataLink}}(e)n\",\n        \"Document pages\": \"Dokumentuaren orriak\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Baimendu guztia\",\n        \"Deny all\": \"Ukatu guztia\",\n        \"Read only\": \"Irakurri bakarrik\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Inportazioak huts egin du: \"\n    },\n    \"RecordLayoutEditor\": {\n        \"Create new field\": \"Sortu eremu berria\",\n        \"Show field {{- label}}\": \"Erakutsi {{- label}} eremua\",\n        \"Save layout\": \"Gorde antolaketa\",\n        \"Cancel\": \"Utzi\",\n        \"Add field\": \"Gehitu eremua\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Gehitu zutabea\",\n        \"No columns to add\": \"Ez dago gehitzeko zutaberik\"\n    },\n    \"RightPanel\": {\n        \"CHART TYPE\": \"GRAFIKO MOTA\",\n        \"COLUMN TYPE\": \"ZUTABE MOTA\",\n        \"fields_one\": \"Eremua\",\n        \"fields_other\": \"Eremuak\",\n        \"Row style\": \"Errenkadaren estiloa\",\n        \"Sort & filter\": \"Sailkatu eta Iragazi\",\n        \"Theme\": \"Gaia\",\n        \"You do not have edit access to this document\": \"Ez duzu dokumentu hau editatzeko sarbiderik\",\n        \"Reset form\": \"Berrezarri formularioa\",\n        \"Field title\": \"Eremuko izena\",\n        \"Hidden field\": \"Ezkutuko eremua\",\n        \"Layout\": \"Antolaketa\",\n        \"Redirection\": \"Birzuzenketa\",\n        \"Enter redirect URL\": \"Sartu birzuzenketaren URLa\",\n        \"No field selected\": \"Ez da eremurik hautatu\",\n        \"columns_one\": \"Zutabea\",\n        \"Default field value\": \"Eremuko defektuzko balioa\",\n        \"columns_other\": \"Zutabeak\",\n        \"Save\": \"Gorde\",\n        \"Configuration\": \"Konfigurazioa\",\n        \"Enter text\": \"Sartu testua\",\n        \"Field rules\": \"Eremuko arauak\",\n        \"Required field\": \"Beharrezko eremua\",\n        \"Change widget\": \"Aldatu widgeta\",\n        \"DATA TABLE\": \"DATU-TAULA\",\n        \"DATA TABLE NAME\": \"DATU-TAULAREN IZENA\",\n        \"Data\": \"Datuak\",\n        \"Detach\": \"Askatu\",\n        \"Edit data selection\": \"Editatu datuen hautaketa\",\n        \"CUSTOM\": \"PERTSONALIZATUA\",\n        \"SOURCE DATA\": \"DATUEN ITURRIA\",\n        \"GROUPED BY\": \"HONELA TALDEKATUTA\",\n        \"SELECT BY\": \"HAUTATU HONELA\",\n        \"Select widget\": \"Hautatu widgeta\",\n        \"TRANSFORM\": \"ERALDATU\",\n        \"SELECTOR FOR\": \"HAUTATZAILEA\",\n        \"series_one\": \"Segida\",\n        \"series_other\": \"Segidak\",\n        \"Redirect automatically after submission\": \"Birbideratu automatikoki bidali ondoren\",\n        \"Submission\": \"Bidalketa\",\n        \"Table column name\": \"Taularen zutabearen izena\",\n        \"Select a field in the form widget to configure.\": \"Hautatu widgetaren formularioko eremu bat konfiguratzeko.\",\n        \"Submit button label\": \"Bidaltzeko botoiaren testua\",\n        \"Success text\": \"Bidali denean erakusteko testua\",\n        \"WIDGET TITLE\": \"WIDGETAREN IZENA\",\n        \"Widget\": \"Widgeta\",\n        \"Add referenced columns\": \"Gehitu erreferentziazko zutabeak\",\n        \"Display button\": \"Erakutsi botoia\",\n        \"Submit another response\": \"Bidali beste erantzun bat\",\n        \"Submit\": \"Bidali\",\n        \"Thank you! Your response has been recorded.\": \"Eskerrik asko! Gorde da zure erantzuna.\",\n        \"Chart options\": \"Grafikoaren aukerak\"\n    },\n    \"RowContextMenu\": {\n        \"Delete\": \"Ezabatu\",\n        \"Duplicate rows_one\": \"Bikoiztu errenkada\",\n        \"Duplicate rows_other\": \"Bikoiztu errenkadak\",\n        \"Insert row\": \"Txertatu errenkada\",\n        \"Insert row above\": \"Txertatu errenkada gainean\",\n        \"Insert row below\": \"Txertatu errenkada azpian\",\n        \"Copy anchor link\": \"Kopiatu aingura-esteka\",\n        \"View as card\": \"Ikusi txartel gisa\",\n        \"Use as table headers\": \"Erabili taulen goiburu gisa\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Arbelera kopiatu da\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"Sarbidearen xehetasunak\",\n        \"Compare to {{termToUse}}\": \"Alderatu {{termToUse}}(r)ekin\",\n        \"Current Version\": \"Uneko bertsioa\",\n        \"Download\": \"Jaitsi\",\n        \"Duplicate document\": \"Bikoiztu dokumentua\",\n        \"Edit without affecting the original\": \"Editatu jatorrizkoari eragin gabe\",\n        \"Export CSV\": \"Esportatu CSVa\",\n        \"Export XLSX\": \"Esportatu XLSXa\",\n        \"Manage users\": \"Kudeatu erabiltzaileak\",\n        \"Original\": \"Jatorrizkoa\",\n        \"Replace {{termToUse}}...\": \"Ordeztu {{termToUse}}…\",\n        \"Save Document\": \"Gorde dokumentua\",\n        \"Send to Google Drive\": \"Bidali Google Drivera\",\n        \"Show in folder\": \"Erakutsi karpetan\",\n        \"Work on a copy\": \"Egin lan kopia batean\",\n        \"Share\": \"Partekatu\",\n        \"Download...\": \"Jaitsi…\",\n        \"Export as...\": \"Esportatu honela…\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Return to {{termToUse}}\": \"Itzuli {{termToUse}}(e)ra\",\n        \"Save copy\": \"Gorde kopia\",\n        \"Back to current\": \"Bueltatu unekora\",\n        \"Unsaved\": \"Gorde gabe\",\n        \"Comma Separated Values (.csv)\": \"Komaz bereizitako balioak (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"DOOz bereizitako balioak (.dsv)\",\n        \"Tab Separated Values (.tsv)\": \"Tabuladorez bereizitako balioak (.tsv)\",\n        \"Download attachments...\": \"Deskargatu eranskinak…\",\n        \"Download document...\": \"Deskargatu dokumentua…\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Esportazioa dokumentuaren orrietatik bakarrik egin daiteke. Hautatu dokumentuko orrialde bat eta saiatu berriro.\",\n        \"Suggest Changes\": \"Iradoki aldaketak\",\n        \"current version\": \"uneko bertsioa\",\n        \"original\": \"jatorrizkoa\",\n        \"Suggest changes\": \"Iradoki aldaketak\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Sortu taldearen gune berria\",\n        \"Switch Sites\": \"Aldatu guneak\"\n    },\n    \"SortConfig\": {\n        \"Update data\": \"Eguneratu datuak\",\n        \"Add column\": \"Gehitu zutabea\",\n        \"Empty values last\": \"Balio hutsak amaieran\",\n        \"Natural sort\": \"Sailkapen naturala\",\n        \"Search Columns\": \"Bilaketa-zutabeak\",\n        \"Use choice position\": \"Erabili hautatutako kokapena\",\n        \"Remove sort setting - {{- columnName }} column\": \"Kendu sailkapen ezarpenak - {{- columnName }} zutabea\",\n        \"Sort in ascending order (current: descending)\": \"Sailkatu goranzko ordenean (unean: beherantz)\",\n        \"Sort in descending order (current: ascending)\": \"Sailkatu beheranzko ordenean (unean: gorantz)\",\n        \"Sort options - {{- columnName }} column\": \"Sailkapen-aukerak - {{- columnName }} zutabea\",\n        \"{{- columnName }} column\": \"{{- columnName }} zutabea\"\n    },\n    \"SortFilterConfig\": {\n        \"Filter\": \"IRAGAZI\",\n        \"Save\": \"Gorde\",\n        \"Sort\": \"SAILKATU\",\n        \"Update Sort & Filter settings\": \"Eguneratu Sailkatu eta Iragazi ezarpenak\",\n        \"Revert\": \"Itzuli\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Itxura \",\n        \"Switch appearance automatically to match system\": \"Aldatu itxura automatikoki sistemarekin bat egiteko\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Sarbide-arauak\",\n        \"Delete\": \"Ezabatu\",\n        \"Document history\": \"Dokumentuaren historia\",\n        \"Settings\": \"Ezarpenak\",\n        \"Code view\": \"Kode-ikustailea\",\n        \"Delete document tour?\": \"Dokumentu-bisitaldia ezabatu?\",\n        \"How-to Tutorial\": \"Tutoriala\",\n        \"Raw data\": \"Datu gordinak\",\n        \"Return to viewing as yourself\": \"Itzuli zuk zeuk bezala ikustera\",\n        \"TOOLS\": \"TRESNAK\",\n        \"Tour of this Document\": \"Dokumentu honen bisitaldia\",\n        \"Validate Data\": \"Balioztatu datuak\",\n        \"API console\": \"API kontsola\",\n        \"Delete document tour\": \"Ezabatu dokumentu-bisitaldia\",\n        \"Preview the tutorial\": \"Aurreikusi tutoriala\",\n        \"Proposed Changes\": \"Proposatutako aldaketak\",\n        \"Suggest Changes\": \"Iradokitutako aldaketak\",\n        \"Suggestions\": \"Iradokizunak\",\n        \"context menu - Access Rules\": \"laster-menua - Sarbide arauak\",\n        \"Proposed changes\": \"Iradokitutako aldaketak\",\n        \"Suggest changes\": \"Iradoki aldaketak\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Kudeatu taldea\"\n    },\n    \"TriggerFormulas\": {\n        \"Cancel\": \"Utzi\",\n        \"Close\": \"Itxi\",\n        \"Current field \": \"Uneko eremua \",\n        \"Any field\": \"Edozein eremu\",\n        \"OK\": \"Ados\",\n        \"Apply on changes to:\": \"Aplikatu aldaketak daudenean honako hauei:\",\n        \"Apply to new records\": \"Aplikatu erregistro berrietan\",\n        \"Apply on record changes\": \"Aplikatu erregistroak aldatzerakoan\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Editorea\",\n        \"None\": \"Bat ere ez\",\n        \"Owner\": \"Jabea\",\n        \"View & edit\": \"Ikusi eta Editatu\",\n        \"View only\": \"Ikusi soilik\",\n        \"Viewer\": \"Ikuslea\",\n        \"No Default Access\": \"Ez dago defektuzko sarbiderik\",\n        \"In full\": \"Osorik\"\n    },\n    \"ViewConfigTab\": {\n        \"Form\": \"Formularioa\",\n        \"Section: \": \"Atala: \",\n        \"Advanced settings\": \"Ezarpen aurreratuak\",\n        \"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.\",\n        \"Compact\": \"Trinkoa\",\n        \"Edit card layout\": \"Editatu txartelen antolaketa\",\n        \"Make On-Demand\": \"Egin nahieran\",\n        \"Plugin: \": \"Plugina: \",\n        \"Unmark On-Demand\": \"Utzi nahieran egiteari\",\n        \"Blocks\": \"Blokeak\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Zaharkitutako ezaugarria\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Sailkatu eta Iragazi aurreratua\",\n        \"Download as CSV\": \"Jaitsi CSV gisa\",\n        \"Download as XLSX\": \"Jaitsi XLSX gisa\",\n        \"Open configuration\": \"Ireki konfigurazioa\",\n        \"Show raw data\": \"Erakutsi datu gordinak\",\n        \"Add to page\": \"Gehitu orrira\",\n        \"Create a form\": \"Sortu formularioa\",\n        \"Print widget\": \"Inprimatu widgeta\",\n        \"Widget options\": \"Widgetaren aukerak\",\n        \"Collapse widget\": \"Tolestu widgeta\",\n        \"Copy anchor link\": \"Kopiatu aingura-esteka\",\n        \"Data selection\": \"Datuen hautaketa\",\n        \"Delete record\": \"Ezabatu erregistroa\",\n        \"Delete widget\": \"Ezabatu widgeta\",\n        \"Edit card layout\": \"Editatu txartelen antolaketa\",\n        \"Duplicate widget\": \"Bikoiztu widgeta\"\n    },\n    \"ViewSectionMenu\": {\n        \"(empty)\": \"(hutsik)\",\n        \"(modified)\": \"(moldatua)\",\n        \"FILTER\": \"IRAGAZI\",\n        \"SORT\": \"SAILKATU\",\n        \"Save\": \"Gorde\",\n        \"(customized)\": \"(pertsonalizatua)\",\n        \"Custom options\": \"Aukera pertsonalizatuak\",\n        \"Revert\": \"Itzuli\",\n        \"Update Sort&Filter settings\": \"Eguneratu Sailkatu eta Iragazi ezarpenak\",\n        \"Sort and filter\": \"Sailkatu eta iragazi\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Clear\": \"Garbitu\",\n        \"Hidden Fields cannot be reordered\": \"Ezkutatutako eremuak ezin dira berrantolatu\",\n        \"Select all\": \"Hautatu guztia\",\n        \"Hide {{label}}\": \"Ezkutatu {{label}}\",\n        \"Show {{label}}\": \"Erakutsi {{label}}\",\n        \"Hidden {{label}}\": \"{{label}} ezkutatuta\",\n        \"Visible {{label}}\": \"{{label}} ikusgai\",\n        \"Cannot drop items into Hidden Fields\": \"Ezin dira elementuak ezkutatutako eremuetan jarri\",\n        \"Hide {{label}} (batch mode)\": \"Ezkutatu {{label}} (batch mode)\",\n        \"Show {{label}} (batch mode)\": \"Erakutsi {{label}} (batch mode)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Hezkuntza\",\n        \"Other\": \"Besteak\",\n        \"Product Development\": \"Produktuen garapena\",\n        \"Research\": \"Ikerketa\",\n        \"Sales\": \"Salmenta\",\n        \"Type here\": \"Idatzi hemen\",\n        \"Welcome to Grist!\": \"Ongi etorri Grist-era!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Zerk zakartza Grist-era? Lagun gaitzazu zu hobeto zerbitzatzen.\",\n        \"Finance & Accounting\": \"Finantzak eta Kontabilitatea\",\n        \"HR & Management\": \"Giza Baliabideak eta Kudeaketa\",\n        \"IT & Technology\": \"Informatika eta Teknologia\",\n        \"Marketing\": \"Marketina\",\n        \"Media Production\": \"Produkzio mediatikoa\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"Utzi\",\n        \"Save\": \"Gorde\",\n        \"DATA TABLE NAME\": \"DATU-TAULAREN IZENA\",\n        \"Override widget title\": \"Idatzi gainean widgetaren izena\",\n        \"Provide a table name\": \"Ezarri taularen izena\",\n        \"WIDGET DESCRIPTION\": \"WIDGETAREN DESKRIBAPENA\",\n        \"WIDGET TITLE\": \"WIDGETAREN IZENA\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Bikoiztu {{pageName}} orria\",\n        \"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.\"\n    },\n    \"errorPages\": {\n        \"Add account\": \"Gehitu kontua\",\n        \"Go to main page\": \"Joan orri nagusira\",\n        \"Sign in\": \"Hasi saioa\",\n        \"Sign in again\": \"Hasi saioa berriro\",\n        \"Sign in to access this organization's documents.\": \"Hasi saioa erakunde honen dokumentuetara sartzeko.\",\n        \"Something went wrong\": \"Zerbaitek huts egin du\",\n        \"There was an error: {{message}}\": \"Errore bat egon da: {{message}}\",\n        \"There was an unknown error.\": \"Errore ezezagun bat egon da.\",\n        \"You do not have access to this organization's documents.\": \"Ez duzu erakunde honen dokumentuetara sarbiderik.\",\n        \"Sign up\": \"Eman izena\",\n        \"Your account has been deleted.\": \"Zure kontua ezabatu da.\",\n        \"An unknown error occurred.\": \"Errore ezezagun bat gertatu da.\",\n        \"Form not found\": \"Ez da formularioa aurkitu\",\n        \"Access denied{{suffix}}\": \"Sarbidea ukatu da{{suffix}}\",\n        \"Error{{suffix}}\": \"Errorea{{suffix}}\",\n        \"Page not found{{suffix}}\": \"Ez da orria aurkitu {{suffix}}\",\n        \"Signed out{{suffix}}\": \"Saioa amaituta{{suffix}}\",\n        \"Contact support\": \"Eskatu laguntza\",\n        \"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.\",\n        \"You are now signed out.\": \"Saioa amaitu duzu.\",\n        \"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.\",\n        \"Build your own form\": \"Sortu zure formularioa\",\n        \"Powered by\": \"Honi esker:\",\n        \"Account deleted{{suffix}}\": \"Kontua ezabatu da{{suffix}}\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Saioaren hasierak huts egin du.{{separator}}Saiatu berriro edo jarri harremanetan laguntza eskatzeko.\",\n        \"Sign-in failed{{suffix}}\": \"Saioaren hasierak huts egin du{{suffix}}\",\n        \"Manage settings\": \"Kudeatu ezarpenak\",\n        \"Need Help?\": \"Laguntza behar duzu?\",\n        \"There was an error\": \"Errorea gertatu da\",\n        \"changes\": \"aldaketak\",\n        \"comments\": \"iruzkinak\",\n        \"this document\": \"dokumentu hau\",\n        \"your email\": \"zure ePosta\",\n        \"You are unsubscribed\": \"Ez zaude harpidetuta\",\n        \"Unsubscribed{{suffix}}\": \"Harpidetza utzi da{{suffix}}\",\n        \"We could not unsubscribe you\": \"Ezin izan dizugu harpidetza utzi\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Dokumentuaren harpidetza utzi dezakezu zure hobespenak eguneratuz dokumentuko ezarpenetan\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Aurrerantzean ez duzu {{email}} helbidean {{docName}}-n egindako {{changes}} jakinarazpenik jasoko.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Aurrerantzean ez duzu {{email}} helbidean {{docName}}-ri buruzko {{comments}} jakinarazpenik jasoko.\",\n        \"suggestions\": \"iradokizunak\",\n        \"You will no longer receive email notifications about {{suggestions}} in {{docName}} at {{email}}.\": \"Ez duzu {{docName}}(e)an egindako {{suggestions}}(e)i buruzko jakinarazpenik gehiago jasoko {{email}}(e)n.\"\n    },\n    \"menus\": {\n        \"Select fields\": \"Hautatu eremuak\",\n        \"Any\": \"Edozein\",\n        \"Text\": \"Testua\",\n        \"Toggle\": \"Bai/Ez\",\n        \"Date\": \"Data\",\n        \"Choice\": \"Aukera\",\n        \"Choice List\": \"Aukeren zerrenda\",\n        \"Attachment\": \"Eranskina\",\n        \"* Workspaces are available on team plans. \": \"* Lan-eremuak TEAM planean daude eskuragarri. \",\n        \"Upgrade now\": \"Hobetu orain\",\n        \"Numeric\": \"Zenbakizkoa\",\n        \"Integer\": \"Osoa\",\n        \"DateTime\": \"Data eta Ordua\",\n        \"Reference\": \"Erreferentzia\",\n        \"Reference List\": \"Erreferentzia-zerrenda\",\n        \"Search columns\": \"Bilaketa-zutabeak\",\n        \"Custom\": \"Norberak ezarritakoa\",\n        \"By Name\": \"Izenaren arabera\",\n        \"By Date Modified\": \"Moldatutako dataren arabera\",\n        \"Light\": \"Argia\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Utzi\",\n        \"Ok\": \"Ados\",\n        \"Save\": \"Gorde\",\n        \"Delete\": \"Ezabatu\",\n        \"Dismiss\": \"Baztertu\",\n        \"Don't ask again.\": \"Ez galdetu berriro.\",\n        \"Don't show tips\": \"Ez erakutsi aholkurik\",\n        \"Got it\": \"Ulertuta\",\n        \"Don't show again\": \"Ez erakutsi berriro\",\n        \"TIP\": \"AHOLKUA\",\n        \"Don't show again.\": \"Ez erakutsi berriro.\",\n        \"Are you sure you want to delete these records?\": \"Ziur zaude erregistro hauek ezabatu nahi dituzula?\",\n        \"Are you sure you want to delete this record?\": \"Ziur zaude erregistro hau ezabatu nahi duzula?\",\n        \"Undo to restore\": \"Desegin leheneratzeko\",\n        \"Confirm\": \"Baieztatu\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Bikoiztu orria\",\n        \"Remove\": \"Kendu\",\n        \"Rename\": \"Berrizendatu\",\n        \"You do not have edit access to this document\": \"Ez duzu dokumentua editatzeko sarbiderik\",\n        \"(default)\": \"(defektuz)\",\n        \"Collapse {{maybeDefault}}\": \"Tolestu {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Hedatu {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Ezarri defektuzkoa: Tolestu\",\n        \"Set default: Expand\": \"Ezarri defektuzkoa: Hedatu\",\n        \"context menu - {{- pageName }}\": \"laster-menua - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Find Previous \": \"Bilatu aurrekoa \",\n        \"No results\": \"Emaitzarik ez\",\n        \"Search in document\": \"Bilatu dokumentuan\",\n        \"Search\": \"Bilatu\",\n        \"Find Next \": \"Bilatu hurrengoa \",\n        \"Close search bar\": \"Itxi bilaketa-barra\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Fitxategia Google Drivera bidaltzen\"\n    },\n    \"NTextBox\": {\n        \"Lines\": \"Lerroak\",\n        \"false\": \"faltsua\",\n        \"true\": \"egia\",\n        \"Field Format\": \"Eremuaren formatua\",\n        \"Multi line\": \"Lerro bat baino gehiago\",\n        \"Single line\": \"Lerro bakarra\"\n    },\n    \"ACLUsers\": {\n        \"View as\": \"Ikusi honela\",\n        \"Example Users\": \"Adibidezko erabiltzaileak\",\n        \"Users from table\": \"Taulako erabiltzaileak\",\n        \"Other users from table\": \"Taulako beste erabiltzaile batzuk\",\n        \"Shared users\": \"Partekatutako erabiltzaileak\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Aplikatu\",\n        \"Cancel\": \"Utzi\",\n        \"Preview\": \"Aurrebista\",\n        \"Revise\": \"Berrikusi\",\n        \"Update formula (Shift+Enter)\": \"Eguneratu formula (Shift+Enter)\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"AUKERAK\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"ZUTABEAREN DESKRIBAPENA\",\n        \"COLUMN LABEL\": \"ZUTABE ETIKETA\"\n    },\n    \"ColumnInfo\": {\n        \"Cancel\": \"Utzi\",\n        \"Save\": \"Gorde\",\n        \"COLUMN DESCRIPTION\": \"ZUTABEAREN DESKRIBAPENA\",\n        \"COLUMN ID: \": \"ZUTABEAREN IDa: \",\n        \"COLUMN LABEL\": \"ZUTABE ETIKETA\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"Gehitu beste arau bat\",\n        \"Row style\": \"Errenkadaren estiloa\",\n        \"IF...\": \"BALDIN ETA...\",\n        \"Add conditional style\": \"Gehitu baldintza-estiloa\",\n        \"Error in style rule\": \"Errorea estilo-arauan\",\n        \"Rule must return True or False\": \"Arauak egia edo faltsua itzuli behar du\",\n        \"Conditional Style\": \"Baldintza-estiloa\",\n        \"Row Style\": \"Errenkadaren estiloa\"\n    },\n    \"DiscussionEditor\": {\n        \"Cancel\": \"Utzi\",\n        \"Comment\": \"Iruzkina\",\n        \"Edit\": \"Editatu\",\n        \"Marked as resolved\": \"Markatu konpondutzat\",\n        \"Only current page\": \"Uneko orria soilik\",\n        \"Remove\": \"Kendu\",\n        \"Showing last {{nb}} comments\": \"Erakutsi azken {{nb}} iruzkinak\",\n        \"Write a comment\": \"Idatzi iruzkina\",\n        \"Open\": \"Ireki\",\n        \"Reply\": \"Erantzun\",\n        \"Resolve\": \"Konpondu\",\n        \"Save\": \"Gorde\",\n        \"Reply to a comment\": \"Erantzun iruzkin bati\",\n        \"Show resolved comments\": \"Erakutsi konpondutako iruzkinak\",\n        \"Only my threads\": \"Soilik nire hariak\",\n        \"Started discussion\": \"Eztabaida hasi du\",\n        \"Remove thread\": \"Kendu haria\",\n        \"updated\": \"eguneratua\",\n        \"{{count}} comments_one\": \"iruzkin {{count}}\",\n        \"{{count}} comments_other\": \"{{count}} iruzkin\",\n        \"Copy link\": \"Kopiatu esteka\"\n    },\n    \"FieldBuilder\": {\n        \"Changing column type\": \"Zutabe mota aldatzen\",\n        \"Apply formula to data\": \"Aplikatu formula datuetan\",\n        \"CELL FORMAT\": \"GELAXKEN FORMATUA\",\n        \"DATA FROM TABLE\": \"TAULAKO DATUAK\",\n        \"Mixed format\": \"Formatu mistoa\",\n        \"Mixed types\": \"Askotariko motak\",\n        \"Changing multiple column types\": \"Hainbat zutabe mota aldatzen\",\n        \"Save field settings for {{colId}} as common\": \"Gorde {{colId}} eremu-ezarpenak arrunt gisa\",\n        \"Use separate field settings for {{colId}}\": \"Erabili {{colId}} eremu-ezarpen bereiziak\",\n        \"Revert field settings for {{colId}} to common\": \"Itzuli {{colId}} eremu-ezarpenak defektuzkora\",\n        \"Common\": \"Komuna\",\n        \"Separate\": \"Banandu\",\n        \"Field in {{count}} views_one\": \"Eremua bista bakarrean\",\n        \"Field in {{count}} views_other\": \"Eremua {{count}} bistatan\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Beharrezkoa da zutabea edo eremua\",\n        \"Enter formula.\": \"Sartu formula.\",\n        \"use AI Assistant\": \"erabili AA laguntzailea\",\n        \"Enter formula or {{button}}.\": \"Sartu formula edo {{button}}.\",\n        \"Error in the cell\": \"Errorea gelaxkan\",\n        \"Expand Editor\": \"Hedatu editorea\",\n        \"Errors in all {{numErrors}} cells\": \"Erroreak {{numErrors}} gelaxka guztietan\",\n        \"editingFormula is required\": \"editingFormula beharrezkoa da\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Erroreak {{numCells}}eko {{numErrors}} gelaxketan-\"\n    },\n    \"NumericTextBox\": {\n        \"Default currency ({{defaultCurrency}})\": \"Defektuzko moneta ({{defaultCurrency}})\",\n        \"Text\": \"Testua\",\n        \"Number Format\": \"Zenbakien formatua\",\n        \"Decimals\": \"Dezimalak\",\n        \"Currency\": \"Moneta\",\n        \"Field Format\": \"Eremuaren formatua\",\n        \"Spinner\": \"Spinnerra\",\n        \"max\": \"max\",\n        \"min\": \"min\"\n    },\n    \"Reference\": {\n        \"Row ID\": \"Errenkadaren IDa\",\n        \"SHOW COLUMN\": \"ERAKUTSI ZUTABEA\",\n        \"CELL FORMAT\": \"GELAXKEN FORMATUA\"\n    },\n    \"WelcomeTour\": {\n        \"Add new\": \"Gehitu berria\",\n        \"Building up\": \"Sortzen\",\n        \"Configuring your document\": \"Dokumentua konfiguratzen\",\n        \"Editing Data\": \"Datuak editatzen\",\n        \"Enter\": \"Sartu\",\n        \"Help Center\": \"Laguntza-gunea\",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Erabili Partekatu botoia ({{share}}) dokumentua partekatu edo datuak esportatzeko.\",\n        \"Welcome to Grist!\": \"Ongi etorri Grist-era!\",\n        \"template library\": \"txantiloi liburutegia\",\n        \"Share\": \"Partekatu\",\n        \"Sharing\": \"Partekatzen\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Arakatu gure {{templateLibrary}} aukerak ikusi eta inspiratzeko.\",\n        \"Customizing columns\": \"Zutabeak pertsonalizatzen\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Egin klik birritan edo sakatu {{enter}} gelaxka batean editatzeko. \",\n        \"Flying higher\": \"Urrunago joateko\",\n        \"Reference\": \"Erreferentzia\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Egizu erlazional! Erabili {{ref}} mota taulak lotzeko. \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Erabili {{helpCenter}} dokumentaziorako edo galderetarako.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Erabili {{addNew}} widgetak edo orriak gehitzeko, edo datu gehiago inportatzeko. \",\n        \"creator panel\": \"sortzailearen mahaigaina\",\n        \"Start with {{equal}} to enter a formula.\": \"Hasi {{equal}}ekin formula bat sartzeko.\",\n        \"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. \",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Erabili {{creatorPanel}} zutabeei formatua emateko, \",\n        \"convert to card view, select data, and more.\": \"txartel-bista bihurtzeko, datuak hautatzeko, eta gehiagorako.\",\n        \"AI Assistant\": \"AA laguntzailea\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Hizkuntza\"\n    },\n    \"GristTooltips\": {\n        \"Learn more.\": \"Ikasi gehiago.\",\n        \"Pinning Filters\": \"Finkatutako iragazkiak\",\n        \"Add new\": \"Gehitu berria\",\n        \"Forms are here!\": \"Hemen dira formularioak!\",\n        \"Updates every 5 minutes.\": \"5 minuturo eguneratzen da.\",\n        \"Calendar\": \"Egutegia\",\n        \"Example: {{example}}\": \"Adibidea: {{example}}\",\n        \"Learn more\": \"Ikasi gehiago\",\n        \"Editing Card Layout\": \"Txartelen antolaketa editatzen\",\n        \"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.\",\n        \"Link your new widget to an existing widget on this page.\": \"Lotu zure widget berria orrialde honetan lehendik dagoen widget batekin.\",\n        \"Raw Data page\": \"Datu gordinen orria\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Berrantolatu zure txarteleko eremuak gelaxkak arrastatuz eta tamaina aldatuz.\",\n        \"Reference Columns\": \"Erreferentzia zutabeak\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Erreferentzia-zutabeak Grist-eko {{relational}} datuen gakoa dira.\",\n        \"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.\",\n        \"Selecting Data\": \"Datuak hautatzen\",\n        \"The total size of all data in this document, excluding attachments.\": \"Dokumentuko datu guztien tamaina osoa, erantsitako fitxategiak alde batera utzita.\",\n        \"They allow for one record to point (or refer) to another.\": \"Erregistro batek beste bati erreferentzi egiteko (edo aipatzeko) aukera ematen dute.\",\n        \"You can filter by more than one column.\": \"Zutabe bat baino gehiago erabiliz iragaz dezakezu.\",\n        \"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.\",\n        \"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}}\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Arau horiek zutabeko arau guztiak prozesatu ondoren aplikatzen dira, badagokio.\",\n        \"Filter displayed dropdown values with a condition.\": \"Iragazi baldintza batekin goitibeherak erakusten dituen balioak.\",\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Aplikatu baldintza-formatua zutabe honetako gelaxkei formulako baldintzak betetzen direnean.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Aplikatu baldintza-formatua formuletan oinarritzen diren errenkadei.\",\n        \"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.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Egin klik \\\"Ireki errenkada-estiloak\\\"-en errenkadei formatu-baldintzak aplikatzeko.\",\n        \"Nested Filtering\": \"iragazki habiratuak\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Finkatutako iragazkiak botoi gisa ageri dira widgetaren gainean.\",\n        \"Select the table containing the data to show.\": \"Hautatu erakutsi beharreko datuak dituen taula.\",\n        \"Only those rows will appear which match all of the filters.\": \"Iragazki guztiekin bat datozen errenkadak baino ez dira agertuko.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"Hau Grist-en antolaketa dinamiko eta produktiboen sekretua da.\",\n        \"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.\",\n        \"relational\": \"erlazionalak\",\n        \"Access Rules\": \"Sarbide-arauak\",\n        \"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.\",\n        \"Anchor Links\": \"Aingura-estekak\",\n        \"Custom Widgets\": \"Widget pertsonalizatuak\",\n        \"entire\": \"osoa\",\n        \"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.\",\n        \"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}}.\",\n        \"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.\",\n        \"To configure your calendar, select columns for start\": {\n            \"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.\"\n        },\n        \"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.\",\n        \"Lookups return data from related tables.\": \"Bilaketek erlazionatutako tauletatik datuak itzultzen dituzte.\",\n        \"Use reference columns to relate data in different tables.\": \"Erabili erreferentzia-zutabeak taula ezberdinetako datuak erlazionatzeko.\",\n        \"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.\",\n        \"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.\",\n        \"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.\",\n        \"Linking Widgets\": \"Widgetak lotzen\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Utzi finkatzeari botoia ezkutatzeko iragazkia mantendu bitartean.\",\n        \"Select the table to link to.\": \"Hautatu lotu beharreko taula.\",\n        \"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.\",\n        \"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.\",\n        \"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.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Helburuko taulan bi muturretatik editatu daitekeen alderantzizko zutabe bat sortzen du.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Esleipen bat baino gehiago baimentzeko, aldatu erreferentzia-zutabearen mota Erreferentzia zerrendara.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Muga hau bi noranzkoko erreferentzia bateko muturretako bat erreferentzia bakar gisa konfiguratuta dagoenean ematen da.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Komunitatearen widgetak Gristen komunitateko kideek sortzen eta mantentzen dituztenak dira.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Esleipen bat baino gehiago baimentzeko, aldatu erreferentzia-zutabearen mota Erreferentzia zerrendara.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Muga hau bi noranzkoko erreferentzia bateko zutabetako bat erreferentzia mota gisa konfiguratuta dagoenean ematen da.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Bi noranzkoko erreferentziak ez dira unean bateragarriak Formula edo formula-abiarazle zutabeekin.\",\n        \"The preview below this header shows how the selected user will see this document\": \"Hautatutako erabiltzaileak dokumentua nola ikusiko duen erakusten du goiburuaren azpiko aurrebistak\",\n        \"[Learn more.]({{link}})\": \"[Ikasi gehiago.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Laburpen-taulek formula-zutabeak besterik ezin dituzte eduki.\",\n        \"The new Grist Assistant is here!\": \"Hemen da Grist Laguntzaile berria!\",\n        \"Manage users and resources in a Grist installation.\": \"Kudeatu Grist instalazio bateko erabiltzaileak eta baliabideak.\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Formulak bateragarriak dira Excelen funtzio askorekin eta Pythonen sintaxi osoarekin.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Ulertu, moldatu eta egin lan zure datu eta formulekin Gristen AA laguntzaile berriari esker!\",\n        \"Comments are here!\": \"Hemen dira iruzkinak!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Gelaxketan iruzkinak gehitu, harietako iruzkinei erantzun eta lankideak @-aipatu ditzakezu.\",\n        \"Set the maximum number of lines for multi-line text.\": \"Ezarri lerroanitzeko testuaren gehienezko lerro-kopurua.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"Hautatuta dagoenean, eremuko defektuzko balioa URLtik aurrez bete daiteke kontsulta-parametroak erabiliz.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"Iradokizunekin, erabiltzaileek aldaketak egiten dituzte kopia pertsonal batean, jatorrizko dokumentua aldatu gabe, eta, ondoren, iradokizun horiek aurkezten dituzte dokumentuaren jabeak berrikusi ditzan, onartu aurretik.\",\n        \"Unpin to hide the button while keeping the filter.\": \"Utzi finkatzeari botoia ezkutatzeko iragazkia mantendu bitartean.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Erreferentzia-zerrenda zutabe berri bat sortzen du helburu-taulan, zutabe editagarri eta sinkronizatuekin.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Barne-biltegiratzeak esan nahi du fitxategi erantsi guztiak SQLite dokumentu-fitxategian gordetzen direla, eta kanpo-biltegiratzeak, berriz, fitxategi erantsi guztiak kanpo-biltegi berean gordetzen direla adierazten du.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Honek kanpoko biltegiratzean falta diren eranskinak gehitzeko aukera ematen du, adibidez inportatutako dokumentu batean. Grist-etik deskargatutako eranskinen .tar fitxategiak bakarrik igo daitezke hemen.\"\n    },\n    \"ColumnTitle\": {\n        \"Column ID copied to clipboard\": \"Zutabearen IDa arbelera kopiatu da\",\n        \"Column description\": \"Zutabearen deskribapena\",\n        \"COLUMN ID: \": \"ZUTABEAREN IDa: \",\n        \"Add description\": \"Gehitu deskribapena\",\n        \"Close\": \"Itxi\",\n        \"Cancel\": \"Utzi\",\n        \"Save\": \"Gorde\",\n        \"Column label\": \"Zutabearen etiketa\",\n        \"Provide a column label\": \"Ezarri zutabearen etiketa\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Ulertuta\",\n        \"Unavailable Command\": \"Komandoa ez dago erabilgarri\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"{{action}} menuko komandoa ez dago erabilgarri nabigatzaile honetan. Hala ere, {{action}} egin dezakezu {{shortcut}} laster-tekla erabiliz.\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"Garbitu eremua\",\n        \"Copy\": \"Kopiatu\",\n        \"Cut\": \"Ebaki\",\n        \"Hide field\": \"Ezkutatu eremua\",\n        \"Paste\": \"Itsatsi\",\n        \"Copy anchor link\": \"Kopiatu aingura-esteka\",\n        \"Comment\": \"Iruzkina\"\n    },\n    \"WebhookPage\": {\n        \"Enabled\": \"Gaituta\",\n        \"Event Types\": \"Gertaera motak\",\n        \"Name\": \"Izena\",\n        \"Sorry, not all fields can be edited.\": \"Eremu guztiak ezin dira editatu.\",\n        \"Status\": \"Egoera\",\n        \"Table\": \"Taula\",\n        \"URL\": \"URLa\",\n        \"Clear queue\": \"Garbitu ilara\",\n        \"Webhook settings\": \"Webhooken ezarpenak\",\n        \"Cleared webhook queue.\": \"Webhooken ilara garbitu da.\",\n        \"Columns to check when update (separated by ;)\": \"Eguneratzerakoan egiaztatuko diren zutabeak (\\\";\\\" bidez bananduta)\",\n        \"Memo\": \"Memorandum\",\n        \"Removed webhook.\": \"Webhooka kendu da.\",\n        \"Webhook Id\": \"Webhook IDa\",\n        \"Ready Column\": \"Zutabe abiarazlea\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Iragazki aldaketetarako zutabe hauetan (\\\";\\\" bidez bareizi IDak)\",\n        \"Header Authorization\": \"Goiburuko baimena\"\n    },\n    \"FormulaAssistant\": {\n        \"Ask the bot.\": \"Galdetu BOTari.\",\n        \"Capabilities\": \"Ahalmenak\",\n        \"Community\": \"Komunitatea\",\n        \"Data\": \"Datuak\",\n        \"Grist's AI Assistance\": \"Grist-en AA laguntzailea\",\n        \"Need help? Our AI assistant can help.\": \"Laguntza behar duzu? Gure AA laguntzaileak lagun zaitzake.\",\n        \"Tips\": \"Aholkuak\",\n        \"AI Assistant\": \"AA laguntzailea\",\n        \"Apply\": \"Aplikatu\",\n        \"Cancel\": \"Utzi\",\n        \"Learn more\": \"Ikasi gehiago\",\n        \"Clear conversation\": \"Garbitu elkarrizketa\",\n        \"Save\": \"Gorde\",\n        \"New Chat\": \"Txat berria\",\n        \"Preview\": \"Aurrebista\",\n        \"Regenerate\": \"Birsortu\",\n        \"Formula Help. \": \"Formulen laguntza \",\n        \"Function List\": \"Funtzioen zerrenda\",\n        \"Grist's AI Formula Assistance. \": \"Gristen AAeko formula-laguntzailea \",\n        \"Formula Cheat Sheet\": \"Formulen eskuliburua\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Kaixo, Gristen AAeko formula-laguntzailea naiz.\",\n        \"Code view\": \"Kode-ikustailea\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Ikusi gure {{helpFunction}} eta {{formulaCheat}}, edo bisitatu gure {{community}} laguntza gehiagorako.\",\n        \"Press Enter to apply suggested formula.\": \"Sakatu Enter iradokitako formula aplikatzeko.\",\n        \"Sign Up for Free\": \"Eman izena doan\",\n        \"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.\",\n        \"There are some things you should know when working with me:\": \"Gauza batzuk jakin beharko zenituzke nirekin lan egitean:\",\n        \"What do you need help with?\": \"Zerrekin behar duzu laguntza?\",\n        \"Formula AI Assistant is only available for logged in users.\": \"AA formula-laguntzailea saioa hasitako erabiltzaileentzat bakarrik dago eskuragarri.\",\n        \"For higher limits, contact the site owner.\": \"Muga altuagoetarako, jarri harremanetan gunearen jabearekin.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Muga altuagoetarako, {{upgradeNudge}}.\",\n        \"upgrade to the Pro Team plan\": \"Pro Team planera pasa zaitez\",\n        \"You have {{numCredits}} remaining credits.\": \"{{numCredits}} kreditu gelditzen zaizkizu.\",\n        \"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.\",\n        \"You have used all available credits.\": \"Kreditu guztiak erabili dituzu.\",\n        \"upgrade your plan\": \"hobetu zure plana\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Formulekin laguntza gehiagorako, begiratu {{functionList}} eta {{formulaCheatSheet}}, edo bisitatu {{community}} laguntza gehiagorako.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Nirekin zabiltzanean, galderak eta dokumentuen egitura ({{codeView}}(e)n ikusgai) OpenAI-ra bidaliko dira. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Hitz egidazu gizakia banintz. Ez da beharrezkoa taulen eta zutabeen izenak zehaztea. Adibidez, eska diezadakezu \\\"Mesedez, kalkulatu fakturaren zenbateko osoa.\\\"\"\n    },\n    \"ChartView\": {\n        \"Pick a column\": \"Hautatu zutabea\",\n        \"Create separate series for each value of the selected column.\": \"Sortu segida bereiziak hautatutako zutabearen balio bakoitzerako.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Y segida bakoitzaren ondoren errore-barren luzerarako segida bat dator.\",\n        \"Toggle chart aggregation\": \"Grafikoaren agregazioa bai/ez\",\n        \"selected new group data columns\": \"hautatutako taldekako datu-zutabe berriak\",\n        \"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.\",\n        \"Orientation\": \"Orientazioa\",\n        \"Vertical\": \"Bertikala\",\n        \"Horizontal\": \"Horizontala\",\n        \"Text size\": \"Testuaren neurria\",\n        \"None\": \"Bat ere ez\",\n        \"Symmetric\": \"Simetrikoa\",\n        \"Above+Below\": \"Gainean+Azpian\",\n        \"Remove\": \"Kendu\",\n        \"Invert Y-axis\": \"Alderantzikatu Y ardatza\",\n        \"Hole size\": \"Zuloaren tamaina\",\n        \"Show total\": \"Erakutsi guztizkoa\",\n        \"Error bars\": \"Errore barrak\",\n        \"X-AXIS\": \"X ARDATZA\",\n        \"Aggregate values\": \"Batu balioak\",\n        \"SERIES\": \"SEGIDAK\",\n        \"Add series\": \"Gehitu segidak\",\n        \"non-numeric columns are not shown\": \"ez dira erakusten zenbakizkoak ez diren zutabeak\",\n        \"non-numeric column is not shown\": \"ez da erakusten zenbakizkoa ez den zutabea\",\n        \"LABEL\": \"ETIKETA\",\n        \"Bar chart\": \"Barra-diagrama\",\n        \"Connect gaps\": \"Konektatu hutsuneak\",\n        \"Show markers\": \"Erakutsi markatzaileak\",\n        \"Stack series\": \"Metatu serieak\",\n        \"Split Series\": \"Banandu serieak\",\n        \"selected new x-axis\": \"hautatutako x-ardatz berria\",\n        \"Pie chart\": \"Zirkularra\",\n        \"Donut chart\": \"Uztaia\",\n        \"Area chart\": \"Gainazala\",\n        \"Line chart\": \"Lerroa\",\n        \"Scatter plot\": \"Barreiadura\",\n        \"Kaplan-Meier plot\": \"Kaplan-Meier\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Bilatu zutabeak\",\n        \"Search Columns\": \"Bilatu zutabea\"\n    },\n    \"Importer\": {\n        \"New Table\": \"Taula berria\",\n        \"Grist column\": \"Grist zutabea\",\n        \"Import from file\": \"Inportatu fitxategitik\",\n        \"Merge rows that match these fields:\": \"Batu ondorengo eremuekin bat datozen errenkadak:\",\n        \"Select fields to match on\": \"Hautatu bat egiteko eremuak\",\n        \"Update existing records\": \"Eguneratu lehendik dauden erregistroak\",\n        \"{{count}} unmatched field_other\": \"Bat ez datozen {{count}} eremu\",\n        \"Column Mapping\": \"Zutabeen mapaketa\",\n        \"Column mapping\": \"Zutabeen esleipena\",\n        \"{{count}} unmatched field in import_one\": \"Bat ez datorren eremu {{count}} inportazioan\",\n        \"{{count}} unmatched field in import_other\": \"Bat ez datozen {{count}} eremu inportazioan\",\n        \"{{count}} unmatched field_one\": \"Bat ez datorren eremu {{count}}\",\n        \"Destination table\": \"Helmuga-taula\",\n        \"Revert\": \"Itzuli\",\n        \"Skip\": \"Egin gabe utzi\",\n        \"Skip Import\": \"Ez inportatu\",\n        \"Skip Table on Import\": \"Ez inportatu taula\",\n        \"Source column\": \"Iturri-zutabea\",\n        \"Import options\": \"Inportazio-aukerak\",\n        \"Cancel\": \"Utzi\",\n        \"Import\": \"Inportatu\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Gehitu orrira\",\n        \"Select data\": \"Hautatu datuak\",\n        \"Building {{- label}} widget\": \"{{- label}} widgeta sortzen\",\n        \"Group by\": \"Taldekatu honela\",\n        \"Select widget\": \"Hautatu widgeta\",\n        \"New Table\": \"Taula berria\",\n        \"SELECT BY\": \"HAUTATU HONEN ARABERA\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Erabiltzaile ezezaguna\",\n        \"View as Yourself\": \"Zeu izango bazina bezala\",\n        \"You are viewing this document as\": \"Dokumentu hau ikusten ari zara\",\n        \"You're seeing what this user would see if given access\": \"Erabiltzaile honi sarbidea emanez gero ikusiko lukeena ikusten ari zara\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"Aplikatu\",\n        \"Cancel\": \"Utzi\",\n        \"Preview\": \"Aurrebista\",\n        \"Revise\": \"Berrikusi\",\n        \"Update formula (Shift+Enter)\": \"Eguneratu formula (Shift+Enter)\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Araua {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Eguneratu formula (Shift+Enter)\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"DESKRIBAPENA\",\n        \"Set description\": \"Ezarri deskribapena\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Sarbidea ukatu da\",\n        \"Code View is available only when you have full document access.\": \"Kode-ikustailea dokumentu guztiak eskura dituzunean bakarrik dago eskuragarri.\"\n    },\n    \"DocTour\": {\n        \"No valid document tour\": \"Ez dago baliozko dokumentu-bisitaldirik\",\n        \"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.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Leheneratu azken edizioa\",\n        \"Undo discard\": \"Desegin bazterketa\"\n    },\n    \"GristDoc\": {\n        \"Import from file\": \"Inportatu fitxategitik\",\n        \"Added new linked section to view {{viewName}}\": \"{{viewName}} ikusteko lotutako atal berria gehitu da\",\n        \"go to webhook settings\": \"Joan webhooken ezarpenetara\",\n        \"Saved linked section {{title}} in view {{name}}\": \"{{title}} lotutako atala {{name}} bistan gorde da\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Aldaketa berriak bertan behera geratu dira behin-behinean. Webhook-en ilarak gainezka egin du. Egiaztatu webhook ezarpenak, kendu baliozkoak ez direnak, eta garbitu ilara.\",\n        \"Import from Airtable\": \"Inportatu Airtable-tik\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Gristen bideo-bisitaldia\",\n        \"Video Tour\": \"Bideo-bisitaldia\",\n        \"YouTube video player\": \"YouTubeko bideo-erreproduzigailua\"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Erregistroen antolaketa eguneratzen.\"\n    },\n    \"breadcrumbs\": {\n        \"override\": \"indargabetu\",\n        \"fiddle\": \"jolas zaitez\",\n        \"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.\",\n        \"recovery mode\": \"Berreskuratze-modua\",\n        \"snapshot\": \"Argazkia\",\n        \"unsaved\": \"Gorde gabe\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Editatu dezakezu,\\nbaina ez du jatorrizko dokumentuan eraginik izango.\\nIradokizun gisa proposatu ditzakezu aldaketak.\",\n        \"editing\": \"editatzen\",\n        \"suggesting\": \"iradokitzen\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"GELAXKA-ESTILOA\",\n        \"Cell style\": \"Gelaxka-estiloa\",\n        \"Mixed style\": \"Estilo mistoa\",\n        \"Default cell style\": \"Gelaxken defektuzko estiloa\",\n        \"Open row styles\": \"Ireki errenkada-estiloak\",\n        \"Default header style\": \"Goiburuen defektuzko estiloa\",\n        \"Header Style\": \"Goiburu-estiloa\",\n        \"HEADER STYLE\": \"GOIBURU-ESTILOA\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Moneta baliogabea\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Bihurtu zutabea formula\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"Ezinezkoa litzateke datu-balio soil bat formula-zutabe batean gordetzea\",\n        \"Unable to finish saving edited cell\": \"Ezin da gelaxka gordetzen amaitu\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[link label] URLa\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"DESKRIBAPENA\"\n    },\n    \"UserManager\": {\n        \"Add {{member}} to your team\": \"Gehitu {{member}} zure taldean\",\n        \"Allow anyone with the link to open.\": \"Baimendu esteka duen edonori irekitzea.\",\n        \"Confirm\": \"Baieztatu\",\n        \"Copy link\": \"Kopiatu esteka\",\n        \"Guest\": \"Gonbidatua\",\n        \"Collaborator\": \"Kolaboratzaile\",\n        \"Invite multiple\": \"Gonbidatu baino gehiago\",\n        \"Invite people to {{resourceType}}\": \"Gonbidatu jendea {{resourceType}}(e)ra\",\n        \"Link copied to clipboard\": \"Esteka arbelera kopiatu da\",\n        \"On\": \"Piztuta\",\n        \"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.\",\n        \"Public access\": \"Sarbide publikoa\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Sarbide publikoak {{parent}}(r)en baimenak jasotzen ditu. Kentzeko, ezarri \\\"Jarauntsi sarbidea\\\" \\\"Bat ere ez\\\" aukerara.\",\n        \"Public access: \": \"Sarbide publikoa: \",\n        \"Remove my access\": \"Kendu nire sarbidea\",\n        \"member\": \"kidea\",\n        \"team site\": \"Taldearen gunea\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitTop}}(e)tik {{limitAt}} {{collaborator}}\",\n        \"{{collaborator}} limit exceeded\": \"{{collaborator}}-muga gainditu da\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Erabiltzaileak {{parent}}(r)en baimenak jasotzen ditu. Kentzeko, ezarri \\\"Jarauntsi sarbidea\\\" \\\"Bat ere ez\\\" aukerara.\",\n        \"Anyone with link \": \"Esteka duen edonor \",\n        \"Cancel\": \"Utzi bertan behera\",\n        \"Close\": \"Itxi\",\n        \"Create a team to share with more people\": \"Sortu talde bat jende gehiagorekin partekatzeko\",\n        \"Grist support\": \"Grist-en laguntza\",\n        \"Manage members of team site\": \"Kudeatu guneko taldeko kideak\",\n        \"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.\",\n        \"Off\": \"Itzalita\",\n        \"Open Access Rules\": \"Ireki sarbide-arauak\",\n        \"Outside collaborator\": \"Kanpoko kolaboratzailea\",\n        \"Save & \": \"Gorde eta \",\n        \"Team member\": \"Taldeko kidea\",\n        \"User may not modify their own access.\": \"Erabiltzaileak ezin du norbere sarbidea aldatu.\",\n        \"Your role for this team site\": \"Talde honen guneko zure rola\",\n        \"Your role for this {{resourceType}}\": \"{{resourceType}} honetarako zure rola\",\n        \"guest\": \"gonbidatua\",\n        \"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.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"{{resourceType}} honetarako zure sarbidea ezabatzear zaude\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Erabiltzaileak {{parent})}(r)en baimenak jasotzen ditu. Kentzeko, ezarri \\\"Jarauntsi sarbidea\\\" aukera \\\"Bat ere ez\\\" aukerara.\",\n        \"free collaborator\": \"kolaboratzaile askea\",\n        \"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.\",\n        \"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.\",\n        \"Inherit access: \": \"Jarauntsi sarbidea: \",\n        \"Access overview\": \"Sarbidearen ikuspegi orokorra\",\n        \"Share it publicly\": \"Partekatu publikoki\",\n        \"Verify your sensitive data before sharing publicly\": \"Ziurtatu datu sentikorrik ez dagoela publikoki partekatu baino lehen\",\n        \"Your {{resourceType}} will be accessible to anyone with the link, whether shared directly or found through a search engine. \\n Ensure that your {{resourceType}} does not contain sensitive data before sharing.\": \"{{resourceType}} lotura duen edonorentzat eskuragarri egongo da, zuzenean partekatuta edo bilatzaile baten bidez aurkituta. \\n Ziurtatu {{resourceType}}(e)k ez duela datu sentikorrik partekatu aurretik.\"\n    },\n    \"SupportGristNudge\": {\n        \"Help Center\": \"Laguntza-gunea\",\n        \"Opt in to Telemetry\": \"Bidali telemetria\",\n        \"Support Grist\": \"Eman babesa Grist-i\",\n        \"Support Grist page\": \"Grist-en laguntza orria\",\n        \"Opted In\": \"Izena emanda\",\n        \"Close\": \"Itxi\",\n        \"Contribute\": \"Hartu parte\",\n        \"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 estimatuak dira. Edozein unetan bidaltzeari utzi diezaiokezu erabiltzailearen menuko {{link}}tik.\",\n        \"Admin Panel\": \"Administratzailearen mahaigaina\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub Sponsors page\": \"GitHub Sponsors orria\",\n        \"Help Center\": \"Laguntza-gunea\",\n        \"Home\": \"Hasiera\",\n        \"Support Grist\": \"Eman babesa Grist-i\",\n        \"Telemetry\": \"Telemetria\",\n        \"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.\",\n        \"Sponsor\": \"Babeslea\",\n        \"GitHub\": \"GitHub\",\n        \"Opt in to Telemetry\": \"Bidali telemetria\",\n        \"Opt out of Telemetry\": \"Ez bidali telemetria\",\n        \"Sponsor Grist Labs on GitHub\": \"Eman babesa Grist Labs-i GitHuben\",\n        \"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.\",\n        \"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.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Orri honetan telemetria ez bidaltzea aukera dezakezu edozein unetan.\",\n        \"You have opted in to telemetry. Thank you!\": \"Telemetria bidaltzea aukeratu duzu. Mila esker!\",\n        \"You have opted out of telemetry.\": \"Telemetria ez bidaltzea aukeratu duzu.\",\n        \"Manage Sponsorship\": \"Kudeatu babesletza\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Grist softwarea Grist Labs-ek garatua da, doako eta ordainpeko ostatatutako planak eskaintzen dituena. Grist kodea ere eskuragarri jartzen dugu doako eta irekitako OSS lizentzia estandar batekin (Apache 2.0) {{link}}-n.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Lagundu Grist-i telemetria bidaltzea aukeratuz, produktua nola erabiltzen den ulertzen laguntzen baitigu, etorkizuneko hobekuntzei lehentasuna eman ahal izateko.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Talde txiki eta nekaezina gara. Zure laguntza oso garrantzitsua da guretzat. Beste batzuei erakusten die produktu honen atzean komunitate bat ere badagoela.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Grist-en kode irekiko garapenaren alde egin dezakezu {{link}}-n babesa emanez.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Daturik ez\",\n        \"Not all data is shown\": \"Ez dira datu guztiak erakusten ari\",\n        \"No row selected in {{title}}\": \"Ez da errenkadarik hautatu {{title}}(e)n\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Tolestu editorea\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Maximizatu\",\n        \"Minimize\": \"Minimizatu\"\n    },\n    \"CardContextMenu\": {\n        \"Copy anchor link\": \"Kopiatu aingura-esteka\",\n        \"Delete card\": \"Ezabatu txartela\",\n        \"Duplicate card\": \"Bikoiztu txartela\",\n        \"Insert card\": \"Txertatu txartela\",\n        \"Insert card above\": \"Txertatu txartela gainean\",\n        \"Insert card below\": \"Txertatu txartela azpian\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"doako coaching deia\",\n        \"Maybe later\": \"Agian geroago\",\n        \"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.\",\n        \"Schedule call\": \"Programatu deia\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Programatu zure {{freeCoachingCall}} gure taldeko kide batekin.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"{{ourWeeklyWebinars}} ere begiratu ditzakezu Grsit-i buruz gehiago ikasteko.\",\n        \"our weekly webinars\": \"gure asteroko web-mintegiak\",\n        \"Grist 101\": \"Grist 101\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"Hastapeneko web-mintegia ere eta {{ourWeeklyWebinars}} begiratu ditzakezu, erabiltzaile berriei Grist-en oinarriak                erakusteko pentsatuak.\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"Hastapeneko web-mintegia ere eta {{ourWeeklyWebinars}} begiratu ditzakezu, erabiltzaile berriei Grist-en oinarriak erakusteko pentsatuak.\"\n    },\n    \"FormView\": {\n        \"Code copied to clipboard\": \"Kodea arbelera kopiatu da\",\n        \"Share this form\": \"Partekatu formulario hau\",\n        \"View\": \"Ikusi\",\n        \"Publish\": \"Argitaratu\",\n        \"Publish your form?\": \"Zure formularioa argitaratu?\",\n        \"Unpublish\": \"Utzi argitaratzeari\",\n        \"Unpublish your form?\": \"Zure formularioa argitaratzeari utzi?\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Beheko esteka duen edonork ikus eta erantzun dezake formularioa.\",\n        \"Are you sure you want to reset your form?\": \"Ziur zaude formularioa berrezarri nahi duzula?\",\n        \"Copy code\": \"Kopiatu kodea\",\n        \"Copy link\": \"Kopiatu esteka\",\n        \"Embed this form\": \"Txertatu formulario hau\",\n        \"Link copied to clipboard\": \"Esteka arbelera kopiatu da\",\n        \"Reset\": \"Berrezarri\",\n        \"Reset form\": \"Berrezarri formularioa\",\n        \"Share\": \"Partekatu\",\n        \"Save your document to publish this form.\": \"Gorde dokumentua formularioa argitaratzeko.\",\n        \"Preview\": \"Aurrebista\",\n        \"# **Form Title**\": \"# **Formularioaren titulua**\",\n        \"Your form description goes here.\": \"Hau formularioaren deskribapena da.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"Formularioa argitaratzean partekatzeko esteka bat sortuko da. Esteka duen edonork ikus dezake formularioa eta erantzun bat bidali.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Formularioa publiko egiteari uzteak partekatzeko esteka desgaituko du, esteka horren bidez zure formulariora sartzen diren erabiltzaileek errore bat ikus dezaten.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Erabiltzaileak sarrerak (taulako erregistroak) aurkeztera eta aurrez ezarritako balioak irakurtzera mugatzen dira zehaztutako eremuetan, hala nola erreferentzia eta aukeraketa zutabeetan.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Formularioa argitaratu da. Aldaketa guztiak egin ahala ikusten dituzte formulariorako sarbidea duten erabiltzaileek. Zirriborroan aldaketak egin nahi badituzu, utzi formularioa argitaratzeari.\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Ezabatu\"\n    },\n    \"Menu\": {\n        \"Building blocks\": \"Blokeak eraikitzen\",\n        \"Columns\": \"Zutabeak\",\n        \"Copy\": \"Kopiatu\",\n        \"Cut\": \"Ebaki\",\n        \"Insert question above\": \"Sartu galdera gainean\",\n        \"Insert question below\": \"Sartu galdera azpian\",\n        \"Paragraph\": \"Paragrafoa\",\n        \"Paste\": \"Itsatsi\",\n        \"Separator\": \"Bereizgailua\",\n        \"Unmapped fields\": \"Esleitu gabeko eremuak\",\n        \"Header\": \"Goiburua\",\n        \"New question\": \"Galdera berria\",\n        \"More\": \"Gehiago\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Clear\": \"Garbitu\",\n        \"Select all\": \"Hautatu guztia\",\n        \"Map fields\": \"Mapaketatu eremuak\",\n        \"Mapped\": \"Mapaketatuta\",\n        \"Unmap fields\": \"Utzi eremuak esleitzeari\",\n        \"Unmapped\": \"Esleitu gabe\"\n    },\n    \"FormConfig\": {\n        \"Default\": \"Defektuzkoa\",\n        \"Field Format\": \"Eremu-formatua\",\n        \"Ascending\": \"Gorantz\",\n        \"Descending\": \"Beherantz\",\n        \"Select\": \"Hautatu\",\n        \"Vertical\": \"Bertikala\",\n        \"Radio\": \"Aukera-botoia\",\n        \"Field rules\": \"Eremu-arauak\",\n        \"Required field\": \"Nahitaezko eremua\",\n        \"Field Rules\": \"Eremu-arauak\",\n        \"Horizontal\": \"Horizontala\",\n        \"Options Alignment\": \"Lerrokatzea-aukerak\",\n        \"Options Sort Order\": \"Sailkatze-aukerak\",\n        \"Hidden field\": \"Ezkutatutako eremua\",\n        \"Accept value from URL\": \"Onartu balioa URLtik\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"URL parametroa:\\n{{colId}}=BALIOA\",\n        \"Options limit\": \"Aukeren muga\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"Hara! Bilatzen ari zaren formularioa ez da existitzen.\",\n        \"There was a problem loading the form.\": \"Arazo bat egon da formularioa kargatzean.\",\n        \"You don't have access to this form.\": \"Ez duzu formulario honetarako sarbiderik.\",\n        \"Oops! This form is no longer published.\": \"Hara! Formularioak argitaratuta egoteari utzi dio.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Errore bat egon da zure formularioa bidaltzean. Saiatu berriro.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Formularioa bidali da\",\n        \"Thank you! Your response has been recorded.\": \"Mila esker! Zure erantzuna erregistratu da.\",\n        \"Submit new response\": \"Bidali erantzun berria\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 7 days\": \"Azken 7 egunak\",\n        \"Last week\": \"Joan den astean\",\n        \"Next 7 days\": \"Hurrengo 7 egunetan\",\n        \"This month\": \"Hilabete honetan\",\n        \"This week\": \"Aste honetan\",\n        \"This year\": \"Aurten\",\n        \"Today\": \"Gaur\",\n        \"Last 30 days\": \"Azken 30 egunak\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Garbitu\",\n        \"Map fields\": \"Mapaketatu eremuak\",\n        \"Mapped\": \"Mapaketatuta\",\n        \"Select all\": \"Hautatu guztia\",\n        \"Unmapped\": \"Esleitu gabe\",\n        \"Unmap fields\": \"Utzi eremuak esleitzeari\",\n        \"Hide {{label}}\": \"Ezkutatu {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Ezkutatu {{label}} (batch mode)\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Utzi\",\n        \"Domain name is required\": \"Domeinuaren izena beharrezkoa da\",\n        \"Go to your site\": \"Joan zure gunera\",\n        \"Team name\": \"Taldearen izena\",\n        \"Team name is required\": \"Taldearen izena beharrezkoa da\",\n        \"Work as a Team\": \"Egin lan taldean\",\n        \"Billing is not supported in grist-core\": \"grist-corek ez du fakturazioa onartzen\",\n        \"Choose a name and url for your team site\": \"Aukeratu zure talderako izen eta URL bat\",\n        \"Create site\": \"Sortu gunea\",\n        \"Domain name is invalid\": \"Domeinuaren izenak ez du balio\",\n        \"Team site created\": \"Taldearen gunea sortu da\",\n        \"Team url\": \"Taldearen URLa\"\n    },\n    \"AdminPanel\": {\n        \"Current version of Grist\": \"Grist-en uneko bertsioa\",\n        \"Admin Panel\": \"Administratzailearen mahaigaina\",\n        \"Current\": \"Unekoa\",\n        \"Help us make Grist better\": \"Lagun gaitzazu Grist hobetzen\",\n        \"Home\": \"Hasiera\",\n        \"Sponsor\": \"Babeslea\",\n        \"Support Grist\": \"Eman babesa Grist-i\",\n        \"Auto-check when this page loads\": \"Egiaztatu automatikoki orri hau kargatzen denean\",\n        \"Check now\": \"Egiaztatu orain\",\n        \"Checking for updates...\": \"Eguneraketak bilatzen...\",\n        \"Error\": \"Errorea\",\n        \"Error checking for updates\": \"Errorea eguneraketak bilatzean\",\n        \"Grist is up to date\": \"Grist egunean dago\",\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-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.\",\n        \"Learn more.\": \"Ikasi gehiago.\",\n        \"Newer version available\": \"Eskuragarri dago bertsio berriago bat\",\n        \"OK\": \"Ados\",\n        \"Sandbox settings for data engine\": \"Datu-motorrarentzako sandboxing konfigurazioa\",\n        \"Sandboxing\": \"Sandboxinga\",\n        \"unknown\": \"ezezaguna\",\n        \"Administrator Panel Unavailable\": \"Administratzailearen mahaigaina ez dago erabilgarri\",\n        \"Authentication\": \"Autentifikazioa\",\n        \"Check failed.\": \"Egiaztaketak huts egin du.\",\n        \"Details\": \"Xehetasunak\",\n        \"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.\",\n        \"Results\": \"Emaitzak\",\n        \"Self Checks\": \"Norbere egiaztatzeak\",\n        \"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\",\n        \"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.\",\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 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.\",\n        \"Support Grist Labs on GitHub\": \"Eman babesa Grist Labs-i GitHuben\",\n        \"Telemetry\": \"Telemetria\",\n        \"Version\": \"Bertsioa\",\n        \"Grist releases are at \": \"Grist-en bertsioak hemen daude \",\n        \"Last checked {{time}}\": \"Azken bilaketa: {{time}}\",\n        \"No information available\": \"Ez dago informaziorik\",\n        \"Security Settings\": \"Segurtasun-ezarpenak\",\n        \"Updates\": \"Eguneraketak\",\n        \"unconfigured\": \"konfiguratu gabe\",\n        \"Check succeeded.\": \"Egiaztaketa arrakastatsua.\",\n        \"Current authentication method\": \"Uneko autentifikazio-metodoa\",\n        \"No fault detected.\": \"Ez da akatsik antzeman.\",\n        \"Notes\": \"Oharrak\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"Ez duzu administratzailearen mahaigainera sarbiderik.\\nHasi saioa administratzaile gisa.\",\n        \"Key to sign sessions with\": \"Saioak sinatzeko gakoa\",\n        \"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).\",\n        \"Session Secret\": \"Saioaren gakoa\",\n        \"Enable Grist Enterprise\": \"Gaitu Grist Enterprise\",\n        \"Enterprise\": \"Enterprise\",\n        \"checking\": \"egiaztatzen\",\n        \"Contact us\": \"Jarri harremanetan\",\n        \"Off\": \"Itzalita\",\n        \"Log Streaming\": \"Erregistro-jarioa\",\n        \"New, Enterprise\": \"Berria, Enpresa\",\n        \"Audit Logs\": \"Erregistro-auditoretza\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} gehiago\",\n        \"On\": \"Piztuta\",\n        \"Grist Instance\": \"Grist instantzia\",\n        \"{{count}} admin accounts_one\": \"administratzaile kontu {{count}}\",\n        \"{{count}} admin accounts_other\": \"{{count}} administratzaile kontu\",\n        \"Auto-check weekly\": \"Egiaztatu automatikoki astero\",\n        \"unavailable\": \"eskuraezin\",\n        \"Admin account not found\": \"Ez da administratzaile-konturik aurkitu\",\n        \"Administrative accounts\": \"Administratzaile-kontuak\",\n        \"The users with administrative accounts\": \"Administratzaile-kontuak dituzten erabiltzaileak\",\n        \"Version {{versionNumber}}\": \"{{versionNumber}} bertsioa\",\n        \"no admin accounts\": \"ez dago administratzaile-konturik\",\n        \"Are you sure you want to restart Grist?\": \"Ziur Grist berrabiarazi nahi duzula?\",\n        \"Restart\": \"Brrabiarazi\",\n        \"Restart Grist\": \"Berrabiarazi Grist\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"Berrabiarazi Grist zain dauden aldaketak aplikatzeko edo arazoak konpontzeko.\",\n        \"Restart Grist?\": \"Grist berrabiarazi?\",\n        \"Restarting Grist...\": \"Grist berrabiarazten…\",\n        \"This will apply any pending changes and briefly interrupt access for all users.\": \"Honek zain dauden aldaketa aplikatuko ditu; erabiltzaileek sarbidea galduko dute laburki.\",\n        \"You can still restart Grist manually.\": \"Grist eskuz berrabiarazi dezakezu hala ere.\",\n        \"error in {{provider}}: {{verdict}}\": \"errorea {{provider}}(e)n: {{verdict}}\",\n        \"No record of last version check\": \"Ez dago azken bertsioaren egiaztapenaren erregistrorik\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Egiaztapen automatikoak desaktibatuta daude. Ezarri GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING \\\"egia\\\" gaitzeko.\",\n        \"auth error\": \"autentifikazio errorea\",\n        \"configured\": \"konfiguratuta\",\n        \"default\": \"defektuzkoa\",\n        \"more...\": \"gehiago…\",\n        \"no authentication\": \"autentifikaziorik ez\",\n        \"Please restart Grist manually.\": \"Berrabiarazi Grist eskuz.\",\n        \"Restart Grist to apply pending changes.\": \"Berrabiarazi Grist zain dauden aldaketak aplikatzeko.\",\n        \"Restart unavailable\": \"Berrabioa ez dago eskuragarri\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Kendu zutabea\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Ez da aukerarik konfiguratu\",\n        \"No values in show column of referenced table\": \"Ez dago baliorik erakusten den zutabean edo erreferentzia-taulan\",\n        \"Hide\": \"Ezkutatu\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"Kontrol-laukia\",\n        \"Field Format\": \"Eremuaren formatua\",\n        \"Switch\": \"Aldatu\"\n    },\n    \"ChoiceEditor\": {\n        \"No choices to select\": \"Ez dago hautatzeko aukeratzerik\",\n        \"Error in dropdown condition\": \"Errorea goitibeherako baldintzan\",\n        \"No choices matching condition\": \"Ez dago baldintzarekin bat datorren aukerarik\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"Errorea goitibeherako baldintzan\",\n        \"No choices matching condition\": \"Ez dago baldintzarekin bat datorren aukerarik\",\n        \"No choices to select\": \"Ez dago hautatzeko aukeratzerik\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"Goitibeherako baldintza\",\n        \"Invalid columns: {{colIds}}\": \"Zutabe baliogabeak: {{colIds}}\",\n        \"Set dropdown condition\": \"Jarri goitibeherako baldintza\"\n    },\n    \"FormRenderer\": {\n        \"Search\": \"Bilatu\",\n        \"Select...\": \"Hautatu...\",\n        \"Reset\": \"Berrezarri\",\n        \"Submit\": \"Bidali\",\n        \"Submitting…\": \"Bidaltzen…\",\n        \"Clear selection for: {{-inputLabel}}\": \"Garbitu hautaketa honentzat: {{-inputLabel}}\"\n    },\n    \"widgetTypesMap\": {\n        \"Chart\": \"Grafikoa\",\n        \"Custom\": \"Pertsonalizatua\",\n        \"Form\": \"Formularioa\",\n        \"Table\": \"Taula\",\n        \"Calendar\": \"Egutegia\",\n        \"Card\": \"Txartela\",\n        \"Card List\": \"Txartel-zerrenda\"\n    },\n    \"TimingPage\": {\n        \"Formula timer\": \"Formula-kronometroa-\",\n        \"Loading timing data. Don't close this tab.\": \"Kronometroaren datuak kargatzen. Ez itxi fitxa hau.\",\n        \"Max Time (s)\": \"Denbora maximoa (s)\",\n        \"Average Time (s)\": \"Batez besteko denbora(k) (s)\",\n        \"Column ID\": \"Zutabearen IDa\",\n        \"Table ID\": \"Taularen IDa\",\n        \"Total Time (s)\": \"Denbora guztira (s)\",\n        \"Number of Calls\": \"Eskaera kopurua\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"Ongi etorri\",\n        \"You have access to the following Grist sites.\": \"Grist gune hauetarako sarbidea duzu.\",\n        \"You can always switch sites using the account menu.\": \"Kontuaren menua erabiliz beti alda ditzakezu guneak.\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Bilatu orri guztiak\",\n        \"Search all tables\": \"Bilatu taula guztiak\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Bilatu\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"{{totalCount}} elementutatik {{displayedCount}} erakusten. Bilatu gehiagorako.\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"Itxi sortzailearen mahaigaina\",\n        \"Open creator panel\": \"Ireki sortzailearen mahaigaina\",\n        \"Document header\": \"Dokumentuaren goiburua\",\n        \"Main content\": \"Eduki nagusia\",\n        \"Creator panel (right panel)\": \"Sortzailearen mahaigaina (eskuman)\",\n        \"Main navigation and document settings (left panel)\": \"Nabigazio nagusia eta dokumentuaren ezarpenak (ezkerrean)\",\n        \"Close navigation panel (left panel)\": \"Itxi nabigazioa (ezkerrean)\",\n        \"Open navigation panel (left panel)\": \"Ireki nabigazioa (ezkerrean)\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Ezkutatutako eremuak\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Nahitaezko zutabe batzuk ez daude esleituta\",\n        \"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.\",\n        \"Some required columns are hidden by access rules\": \"Sarbide-arauak beharrezko zutabe batzuk ezkutatzen ari dira\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Widgeta erabili ahal izateko, esleitutako zutabe guztiak ikusgai egon behar dira. Jarri harremanetan dokumentuaren jabearekin edo aldatu sarbide-arauak.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Sortu zure formularioa\",\n        \"Powered by\": \"Honi esker:\",\n        \"Powered by Grist\": \"Gristi esker\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Errorea\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Sartu atala gainean\",\n        \"Insert section below\": \"Sartu atala azpian\",\n        \"Description\": \"Deskribapena\",\n        \"## **Header**\": \"## **Goiburua**\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Errorea goitibeherako baldintzan\",\n        \"No choices matching condition\": \"Ez dago baldintzarekin bat datorren aukerarik\",\n        \"No choices to select\": \"Ez dago hautatzeko aukeratzerik\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Egin klik txertatzeko\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Sartu baldintza.\"\n    },\n    \"DocTutorial\": {\n        \"Click to expand\": \"Egin klik hedatzeko\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Berriz hasi nahi duzu tutoriala? Orain arte egindakoa galduko da.\",\n        \"Next\": \"Hurrengoa\",\n        \"Previous\": \"Aurrekoa\",\n        \"Restart\": \"Hasi berriz\",\n        \"End tutorial\": \"Tutorialaren amaiera\",\n        \"Finish\": \"Amaitu\"\n    },\n    \"OnboardingCards\": {\n        \"3 minute video tour\": \"3 minutuko bideo-bisitaldia\",\n        \"Complete our basics tutorial\": \"Burutu oinarrizko tutoriala\",\n        \"Complete the tutorial\": \"Burutu tutoriala\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Ikasi erreferentzia-zutabeen, lotutako widgeten, zutabe-moten eta txartelen oinarriak.\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Ikasi erreferentzia-zutabeen, lotutako widgeten, zutabe moten, eta txartelen oinarriak.\"\n    },\n    \"OnboardingPage\": {\n        \"Go hands-on with the Grist Basics tutorial\": \"Murgildu Gristen oinarrizko tutorialean\",\n        \"Next step\": \"Hurrengo urratsa\",\n        \"Skip step\": \"Utzi egin gabe\",\n        \"Skip tutorial\": \"Utzi tutoriala egin gabe\",\n        \"Welcome\": \"Ongi etorri\",\n        \"What brings you to Grist (you can select multiple)?\": \"Zerk zakartza Gristera? (bat baino gehiago hautatu dezakezu)?\",\n        \"Back\": \"Atzera\",\n        \"Tell us who you are\": \"Esaguzu nor zaren\",\n        \"Discover Grist in 3 minutes\": \"Ezagutu Grist 3 minututan\",\n        \"Go to the tutorial!\": \"Joan tutorialera!\",\n        \"Type here\": \"Idatzi hemen\",\n        \"What is your role?\": \"Zein da zure rola?\",\n        \"What organization are you with?\": \"Zein erakunderekin jarduten duzu?\",\n        \"Your role\": \"Zure rola\",\n        \"Your organization\": \"Zure erakundea\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Gristek kalkulu-orri baten itxura izan dezake, baina ez du beti halako batek bezala jokatzen. Ikusi zerk bereizten duen Grist.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"Grist Enterprise is **enabled**.\": \"Grist Enterprise **gaituta** dago.\",\n        \"Disable Grist Enterprise\": \"Ezgaitu Grist Enterprise\",\n        \"Enable Grist Enterprise\": \"Gaitu Grist Enterprise\",\n        \"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.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Aktibazio-gako bat erabili da 30 eguneko epea iraungi eta probaldiaren ondoren Grist Enterprise exekutatzeko.\\nEskuratu aktibazio-gako bat gaur bertan [gurekin harremanetan jarri]({{contactLink}})z.\\nEz duzu aktibazio-gakorik behar Grist Core exekutatzeko.\\n\\nLortu informazio gehiago gure [Laguntza-gunea]({{helpCenter}})n.\",\n        \"Activate\": \"Aktibatu\",\n        \"Copy to clipboard\": \"Kopiatu arlebera\",\n        \"Activation key\": \"Aktibatzeko gakoa\",\n        \"Expiration date\": \"Iraungitze-data\",\n        \"Installation ID copied to clipboard\": \"Instalazio-IDa arbelera kopiatu da\",\n        \"Installation ID:\": \"Instalazio-IDa:\",\n        \"Paste your activation key\": \"Itsatsi aktibazio-gakoa\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Grist Enterprise erabiltzen jarraitzeko harpidetza behar da.\\nZure harpidetza aktiba dezakezu [Grist Enterprise]({{signupLink}})(e)n izena emanda\\neta aktibazio-gakoa azpian itsatsita.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Aktibazio-gako bat erabiltzen da Grist Enterprise exekutatzeko\\n        30 eguneko probaldia amaitu ondoren. Lortu aktibazio-gako bat\\n        [Grist Enterprisen izena emanda]({{signupLink}}).\\n        Ez duzu aktibazio-gakorik behar Grist Core exekutatzeko.\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Informazio gehiago gure [Laguntza-gunea]({{helpCenter}})n.\",\n        \"Plan name\": \"Planaren izena\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Grist Enterprise erabiltzen jarraitzeko,\\n                  [gurekin harremanetan jarri]({{signupLink}}) behar duzu aktibazio-gakoa lortzeko.\",\n        \"You are currently trialing Grist Enterprise.\": \"Une honetan Grist Enterprise probatzen ari zara.\",\n        \"You do not have an active subscription.\": \"Ez duzu harpidetzarik aktibo.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Zure aktibazio-gakoa iraungi da mugak gainditzeagatik.\",\n        \"Your subscription expired on {{date}}.\": \"Zure harpidetza {{date}}(e)an iraungi zen.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Zure instantzia **bakarrik irakurketa** moduan egongo da **{{days}}** egun barru.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Probaldia **{{expireAt}}**(e)an iraungi da. Grist Enterprise erabiltzen jarraitzeko,\\n[kontratatu Grist Enterprise]({{signupLink}}) eta itsatsi aktibazio-gakoa.\",\n        \"Installation seats\": \"Instalazio-idazpenak\"\n    },\n    \"ViewLayout\": {\n        \"Delete data and this widget.\": \"Ezabatu datuak eta widgeta.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Mantendu datuak eta ezabatu widgeta. Taulak erabilgarri egoten jarraituko du {{rawDataLink}}(e)n\",\n        \"Table {{tableName}} will no longer be visible\": \"{{tableName}} taula ez da ikusgai egongo aurrerantzean\",\n        \"Raw Data page\": \"datu gordinen orria\",\n        \"Delete\": \"Ezabatu\"\n    },\n    \"CustomWidgetGallery\": {\n        \"Add Your Own Widget\": \"Gehitu zure widgeta\",\n        \"Cancel\": \"Utzi\",\n        \"Change widget\": \"Aldatu widgeta\",\n        \"Developer:\": \"Garatzailea:\",\n        \"Last updated:\": \"Azkenekoz eguneratua:\",\n        \"Search\": \"Bilatu\",\n        \"Widget URL\": \"Widgetaren URLa\",\n        \"Add widget\": \"Gehitu widgeta\",\n        \"(Missing info)\": \"(Informaziorik gabe)\",\n        \"Grist Widget\": \"Grist widgeta\",\n        \"No matching widgets\": \"Ez dago bat datorren widgetik\",\n        \"Add a widget from outside this gallery.\": \"Gehitu galeria honetatik kanpoko widgetak.\",\n        \"Choose custom widget\": \"Aukeratu widget pertsonalizatua\",\n        \"Community Widget\": \"Komunitatearen widgetak\",\n        \"Custom URL\": \"URL pertsonalizatua\",\n        \"Learn more about custom widgets\": \"Ikasi gehiago widget pertsonalizatuei buruz\"\n    },\n    \"HomeIntroCards\": {\n        \"Help center\": \"Laguntza-gunea\",\n        \"Learn more {{webinarsLinks}}\": \"Ikasi gehiago {{webinarsLinks}}\",\n        \"Start a new document\": \"Hasi dokuemntu berria\",\n        \"Templates\": \"Txantiloiak\",\n        \"Blank document\": \"Dokumentu zuria\",\n        \"Import file\": \"Inportatu fitxategia\",\n        \"3 minute video tour\": \"3 minutuko bideo-bisitaldia\",\n        \"Finish our basics tutorial\": \"Amaitu oinarrizko tutoriala\",\n        \"Tutorial\": \"Tutoriala\",\n        \"Webinars\": \"Web-mintegiak\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"Ikusi adibideak eta arakatu baliabide gehiago {{helpCenterLink}}\",\n        \"Find solutions and explore more resources\": \"Soluzioak bilatu eta baliabide gehiago aztertu\",\n        \"Learn more\": \"Ikasi gehiago\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Delete\": \"Ezabatu\",\n        \"Table\": \"Taula\",\n        \"Target table\": \"Helmugako taula\",\n        \"Delete column {{column}} in table {{table}}?\": \"{{table}} taulako {{column}} zutabea ezabatu nahi duzu?\",\n        \"Column\": \"Zutabea\",\n        \"Add two-way reference\": \"Gehitu bi noranzkoko erreferentzia\",\n        \"Two-way Reference\": \"Bi noranzkoko erreferentzia\",\n        \"Delete two-way reference?\": \"Bi noranzkoko erreferentzia ezabatu?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"{{table}} taulako {{column}} zutabearen alderantzizko erreferentzia da.\"\n    },\n    \"SupportGristButton\": {\n        \"Close\": \"Itxi\",\n        \"Help Center\": \"Laguntza-gunea\",\n        \"Admin Panel\": \"Administratzailearen mahaigaina\",\n        \"Support Grist\": \"Eman babesa Grist-i\",\n        \"Opted In\": \"Telemetria bidaltzen da\",\n        \"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 estimatuak dira. Edozein unetan bidaltzeari utzi diezaiokezu erabiltzailearen menuko {{link}}tik.\",\n        \"Opt in to Telemetry\": \"Bidali telemetria\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Aukeratu telemetria bidaltzea produktua nola erabiltzen den ulertzen laguntzeko, etorkizuneko hobekuntzei lehentasuna eman ahal izateko.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Utzi\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"{{targetTable}} erregistro bakoitzari {{sourceTable}} erregistro bakarra esleitu dakioke.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Berresleitu {{sourceTable}}(e)ko {{sourceName}} erregistroari.\",\n        \"Record already assigned_one\": \"Erregistroa esleituta dago lehendik ere\",\n        \"Record already assigned_other\": \"Erregistroak esleituta daude lehendik ere\",\n        \"Reassign\": \"Berresleitu\",\n        \"Reassign to new {{sourceTable}} records.\": \"Berresleitu {{sourceTable}}(e)ko erregistro berri bati.\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}}(e)ko {{targetName}} erregistroa lehendik dago {{sourceTable}}(e)ko {{oldSourceName}} erregistroari esleituta.\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Administratzailearen mahaigaina\"\n    },\n    \"markdown\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Markdown funtzio berria\\n *\\n *      Grainjs elementu baten *barruan*\\n *      [ohiko Markdown sintaxia](https://markdownguide.org) _idatz_ dezakegu.\"\n            }\n        },\n        \"The toggle is **off**\": \"Ezaugarria **desaktibatuta** dago\",\n        \"The toggle is **on**\": \"Ezaugarria **aktibatuta** dago\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Markdown funtzio berria\\n *\\n *      Grainjs elementu baten *barruan*\\n *      [ohiko Markdown sintaxia](https://markdownguide.org) _idatz_ dezakegu.\"\n            }\n        },\n        \"The toggle is **off**\": \"Ezaugarria **desaktibatuta** dago\",\n        \"The toggle is **on**\": \"Ezaugarria **aktibatuta** dago\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Cancel\": \"Utzi\",\n        \"Edit\": \"Editatu\",\n        \"Delete\": \"Ezabatu\",\n        \"Add destination\": \"Gehitu helburua\",\n        \"Destination\": \"Helburua\",\n        \"Destinations\": \"Helburuak\",\n        \"Enter URL\": \"Sartu URLa\",\n        \"Enter token\": \"Sartu tokena\",\n        \"Learn more\": \"Ikasi gehiago\",\n        \"Save\": \"Gorde\",\n        \"Splunk\": \"Splunk\",\n        \"Token\": \"Tokena\",\n        \"URL\": \"URLa\",\n        \"Delete streaming destination?\": \"Jarioaren helburua ezabatu?\",\n        \"Add streaming destination\": \"Gehitu jariorako helburua\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Ziur jarioaren helmuga ezabatu nahi duzula? Eragiketa ezin da desegin.\",\n        \"Edit streaming destination\": \"Editatu jarioaren helmuga\",\n        \"Other\": \"Besteren bat\",\n        \"Start streaming\": \"Hasi jarioa\"\n    },\n    \"AuditLogsPage\": {\n        \"Contact us\": \"Jarri harremanetan\",\n        \"Audit logs for {{siteName}}\": \"Auditatu {{siteName}} guneko erregistroak\",\n        \"Log streaming\": \"Erregistroen jarioa\",\n        \"Only site owners may access audit logs.\": \"Guneko jabeek bakarrik atzitu ditzakete errgistro-auditoretzak.\",\n        \"Audit Logs\": \"Erregistro-auditoretza\",\n        \"Home\": \"Hasiera\",\n        \"upgrade your plan\": \"hobetu zure plana\"\n    },\n    \"DocList\": {\n        \"Delete {{name}}\": \"Ezabatu {{name}}\",\n        \"Access details\": \"Sabidearen xehetasunak\",\n        \"All\": \"Guztia\",\n        \"Delete\": \"Ezabatu\",\n        \"Document will be moved to Trash.\": \"Dokumentua zakarrontzira mugituko da.\",\n        \"Manage users\": \"Kudeatu erabiltzaileak\",\n        \"Edited {{at}}\": \"Editatua: {{at}}\",\n        \"Last edited\": \"Azkenekoz editatua\",\n        \"Move\": \"Mugitu\",\n        \"Move {{name}} to workspace\": \"Mugitu {{name}} lan-eremura\",\n        \"Current workspace\": \"Uneko lan-eremua\",\n        \"Pin\": \"Finaktu\",\n        \"Pinned\": \"Finkatuta\",\n        \"Rename and set icon\": \"Berrizendatu eta ezarri ikonoa\",\n        \"No documents to show.\": \"Ez dago erakusteko dokumenturik.\",\n        \"Requires edit permissions\": \"Editatzeko baimena behar da\",\n        \"Recent\": \"Duela gutxi\",\n        \"Name\": \"Izena\",\n        \"Sort by date\": \"Antolatu dataren arabera\",\n        \"Sort by name\": \"Antolatu izenaren arabera\",\n        \"Unpin\": \"Utzi finkatzeari\",\n        \"Workspace\": \"Lan-eremua\",\n        \"Documents list\": \"Dokumentuen zerrenda\",\n        \"Download document...\": \"Deskargatu dokumentua…\",\n        \"Deleted {{at}}\": \"{{at}}(e)an ezabatua\",\n        \"context menu - {{- documentName }}\": \"laster-menua - {{- documentName }}\"\n    },\n    \"RenameDocModal\": {\n        \"Enter document name\": \"Sartu dokumentuaren izena\",\n        \"Icon\": \"Ikonoa\",\n        \"Name\": \"Izena\",\n        \"Rename and set icon\": \"Berrizendatu eta ezarri ikonoa\",\n        \"Reset icon\": \"Berrezarri ikonoa\",\n        \"Choose color\": \"Aukeratu kolorea\",\n        \"Choose icon\": \"Aukeratu ikonoa\"\n    },\n    \"RightPanelUtils\": {\n        \"fields_one\": \"Eremuak\",\n        \"columns_one\": \"Zutabeak\",\n        \"columns_other\": \"Zutabeak\",\n        \"fields_other\": \"Eremuak\",\n        \"series_one\": \"Serieak\",\n        \"series_other\": \"Serieak\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Kontuz widget pertsonalizatu ezezagunekin\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Ziur URL honetako **baliabideaz fidatzen** zarela?\",\n        \"Do you **trust the person** who shared this link?\": \"Esteka partekatu duen **pertsonaz fidatzen** zara?\",\n        \"Have you **reviewed the code** at this URL?\": \"URL honetako **kodea aztertu** al duzu?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Zalantza izanez gero, ez instalatu widgeta, edo eskatu erakundeko administratzaileari aztertu dezan segurtasuna bermatzeko.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Baieztatzen dut abisu hauek ulertzen eta arriskuak onartzen ditudala\",\n        \"Please review the following before adding a new custom widget.\": \"Berrikusi honakoa widget pertsonalizatu bat gehitu baino lehen.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Widget pertsonalizatuak **boteretsuak** dira! Zure fitxategiko datuak irakurri eta gainidaztzi litzakete, eta beste norabait bidali ere bai.\"\n    },\n    \"Assistant\": {\n        \"For higher limits, contact the site owner.\": \"Muga altuagoetarako, jarri harremanetan gunearen jabearekin.\",\n        \"AI Assistant is only available for logged in users.\": \"AA laguntzailea saioa hasita duten erabiltzaileentzat dago soilik erabilgarri.\",\n        \"Apply\": \"Aplikatu\",\n        \"Learn more.\": \"Ikasi gehiago.\",\n        \"Press Enter to apply suggested formula.\": \"Sakatu Enter iradokitutako formula aplikatzeko.\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Eman izena Grist doako kontu batean AA laguntzailea erabiltzen hasteko.\",\n        \"Sign Up for Free\": \"Eman izena doan\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Muga altuagoetarako, {{upgradeNudge}}.\",\n        \"What do you need help with?\": \"Zertan behar duzu laguntza?\",\n        \"You have used all available credits.\": \"Kreditu guztiak erabili dituzu.\",\n        \"You have {{numCredits}} remaining credits.\": \"{{numCredits}} kreditu gelditzen zaizkizu.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Grist Enterprise planera pasa zaitez Grist laguntzaile berria probatzeko. {{learnMoreLink}}\",\n        \"start a new chat\": \"hasi txat berria\",\n        \"upgrade your plan\": \"hobetu zure plana\",\n        \"upgrade to the Pro Team plan\": \"Pro Team planera pasa zaitez\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"Elkarrizketa gehiegi luzatu da, eta jada ezin dut modu eraginkorrean erantzun. {{startANewChatButton}} laguntza jasotzen jarraitzeko.\"\n    },\n    \"AdminLeftPanel\": {\n        \"Settings\": \"Ezarpenak\",\n        \"Admin Controls\": \"Administratzailearen kontrolak\",\n        \"Admin area\": \"Administratzailearen gunea\",\n        \"Admin controls\": \"Administratzailearen kontrolak\",\n        \"Installation\": \"Instalazioa\",\n        \"Learn more\": \"Ikasi gehiago\",\n        \"Orgs\": \"Erakundeak\",\n        \"Users\": \"Erabiltzaileak\",\n        \"Docs\": \"Dokumentuak\",\n        \"Workspaces\": \"Lan-eremuak\"\n    },\n    \"apiconsole\": {\n        \"Delete\": \"Ezabatu\",\n        \"Deletion was not confirmed, skipping.\": \"Ezabapena ez da baieztatu, ez da ezabatuko.\",\n        \"Type DELETE here if you wish to proceed.\": \"Idatzi DELETE hemen aurrera egin nahi baduzu.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Idatzi DELETE hemen aurrera egin nahi baduzu.\\nZiur ez bazaude, edo eragiketak zer egingo duen ulertzen ez baduzu,\\nzuhur jokatu eta utzi bertan behera.\",\n        \"Confirm Deletion\": \"Baieztatu ezabapena\",\n        \"Are you sure you want to delete the following?\": \"Ziur honakoa ezabatu nahi duzula?\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Desgaitu ezaugarria\",\n        \"Don't worry, you can disable it later if needed.\": \"Ez kezkatu, geroago desgai desakezu behar izanez gero.\",\n        \"Enable feature\": \"Gaitu ezaugarria\",\n        \"Experimental feature\": \"Ezaugarri esperimentala\",\n        \"Reload the page\": \"Birkargatu orria\",\n        \"{{experiment}} disabled.\": \"{{experiment}} desgaitu da.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} gaitu da.\",\n        \"New record button\": \"Erregistro berriaren botoia\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Ezaugarri esperimental hau ezgaitzear zaude: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Ezaugarri esperimental hau gaitzear zaude: {{experiment}}\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Bisitatu URL hau edozein unetan ezaugarri hau erabiltzeari uzteko: {{url}}\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Bikoiztu widgeta\",\n        \"Duplicate widgets\": \"Bikoiztu widgetak\",\n        \"Active\": \"Aktibo\",\n        \"Create new page\": \"Sortu orri berria\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"sarbiderik ez\",\n        \"...loading\": \"…kargatzen\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Grist-en eguneratze kritiko bat dago eskuragarri.\\nAztertu {{version}}-ra bertsio-berritzen ahalik eta lasterren.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Grist-en bertsioa zaharkitua dago.\\nAztertu {{version}}-ra bertsio-berritzen ahalik eta lasterren.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Set the document to use external storage.\": \"Ezarri dokumentua kanpoko biltegiratzea erabiltzeko.\",\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Gomendioa: {{storageRecommendation}}\\nFitxategi erantsi handiak edo ugari gordetzean,\\nkanpoko biltegian gordetzea gomendatzen dugu.\\nDokumentu hau, une honetan, barne-biltegiratzea erabiltzen ari da\\nfitxategi erantsietarako, eta horrek eduki propioa mantentzen du,\\nbaina errendimendua mugatu dezake.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Itxaron aurreko eragiketa burutu arte.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Txartel berria\",\n        \"New record\": \"Erregistro berria\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Sortzailearen mahaigaina erabili nahi duzu? Erabili {{key}}.\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Igotzen, itxaron…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Gehitu\",\n        \"Delete\": \"Ezabatu\",\n        \"Download\": \"Deskargatu\",\n        \"Drop files here to attach\": \"Jaregin fitxategiak hona atxikitzeko\",\n        \"Drop files here to attach.\": \"Jaregin hona eransteko fitxategiak.\",\n        \"No attachments\": \"Ez dago eranskinik\",\n        \"Preview not available.\": \"Aurrebista ez dago erabilgarri.\",\n        \"{{index}} of {{total}}\": \"{{index}} ({{total}} guztira)\",\n        \"Uploading…\": \"Igotzen…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Hedatu errenkada guztiak garaiera honetara\",\n        \"Max height\": \"Gehieneko garaiera\",\n        \"Max row height\": \"Errenkadaren gehieneko garaiera\",\n        \"Change\": \"Aldatu\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Tolestu\",\n        \"Expand\": \"Hedatu\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"erabiltzaile aktiboa\",\n        \"active user list\": \"erabiltzaile aktiboen zerrenda\",\n        \"open full active user list\": \"ireki erabiltzaile aktiboen zerrenda osoa\"\n    },\n    \"AdminChecks\": {\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Grist-ek osasun-kontrol txiki bat du, askotan edukiontzi gisa exekutatzen denean erabiltzen dena.\",\n        \"It is good practice not to run Grist as the root user.\": \"Praktika ona da ez exekutatzea Grist root erabiltzaile gisa.\",\n        \"The main page of Grist should be available.\": \"Grist-en orri nagusia erabilgarri egon beharko litzateke.\",\n        \"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 oso formula ahaltsuak ahalbidetzen ditu, Python erabiliz. GRIST_SANDBOX_FLAVOR ingurune-aldagaia gvisor-erako ezartzea gomendatzen dugu, hardwareak onartzen badu (gehienetan), dokumentu bakoitzean formulak exekutatzeko beste dokumentu batzuetatik isolatutako eta saretik isolatutako sandbox baten barruan.\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"+{{count}} gehiago\",\n        \"+{{count}} more_other\": \"+{{count}} gehiago\",\n        \"Edit\": \"Editatu\",\n        \"No choices configured\": \"Ez da aukerarik konfiguratu\",\n        \"Reset\": \"Berrezarri\",\n        \"Cancel\": \"Utzi\",\n        \"Save\": \"Gorde\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Aplikatu\",\n        \"Cancel\": \"Utzi\",\n        \"Preview\": \"Aurreikusi\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Itxi\",\n        \"Field separator\": \"Eremu-bereizlea\",\n        \"First row contains headers\": \"Lehenengo errenkadak goiburuak ditu\",\n        \"Number of rows\": \"Errenkaden zenbakia\",\n        \"Quote character\": \"Aipuen karakterea\",\n        \"Skip leading whitespace\": \"Muzin egin hasierako zuriuneari\",\n        \"Update preview\": \"Eguneratu aurrebista\",\n        \"Convert quoted fields\": \"Bihurtu aipatutako eremuak\",\n        \"Escape character\": \"Ihes-karakterea\",\n        \"Line terminator\": \"Banalerroa\",\n        \"Quotes in fields are doubled\": \"Eremuetako kakotxak bikoizten dira\",\n        \"Start with row\": \"Hasi errenkadarekin\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Karaktere-kodeketa. Ikus [kodek bateragarriak]({{link}})\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" edo \",\n        \"Accessibility\": \"Irisgarritasuna\",\n        \"Close\": \"Itxi\",\n        \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\": \"Azkenik, eskumako mahaigaina —edo sortzailearena— lasterbide propioaren bidez bakarrik dago erabilgarri, ez hurrengo eta aurreko eremu-ziklotik kanpo.\",\n        \"Focus on other parts of the user interface using the following shortcuts:\": \"Jarri arreta erabiltzailearen interfazearen beste zati batzuetan, lasterbide hauek erabiliz:\",\n        \"High contrast theme\": \"Kontraste handiko gaia\",\n        \"The left panel, home of the main navigation.\": \"Ezkerreko mahaigaina: nabigazio nagusia.\",\n        \"The top panel, or the document header.\": \"Goiko mahaigaina edo dokumentuaren goiburua.\",\n        \"To see other available themes, go to your {{profileSettingsLink}}.\": \"Erabilgarri dauden beste gai batzuk ikusteko, joan zure {{profileSettingsLink}}.\",\n        \"Use the high contrast theme (light appearance)\": \"Erabili kontraste handiko gaia (itxura argia)\",\n        \"Other important keyboard shortcuts\": \"Beste laster-tekla garrantzitsu batzuk\",\n        \"You are currently **not using** the high contrast theme.\": \"Une honetan **ez** zara kontraste handiko gaia erabiltzen ari.\",\n        \"You are currently using the high contrast theme.\": \"Kontraste handiko gaia erabiltzen ari zara unean.\",\n        \"\\\"Regions\\\" are what we call the different parts of the user interface:\": \"Erabiltzaile-interfazearen atal desberdinak \\\"Eskualde\\\" izenez ezagutzen ditugu:\",\n        \"Keyboard navigation\": \"Teklatu bidezko nabigazioa\",\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"Dokumentuen orrian, teklatuaren bidezko nabigazioa uneko widgetean dago fokatuta hasieran.\",\n        \"profile settings\": \"profilaren ezarpenak\",\n        \"{{accessibilityModal}} Show the accessibility options (this modal)\": \"{{accessibilityModal}} Erakutsi irisgarritasun-aukerak (honako leihoa)\",\n        \"{{nextRegionShortcut}} Focus on the next region\": \"{{nextRegionShortcut}} Fokatu hurrengo eskualdean\",\n        \"{{prevRegionShortcut}} Focus on the previous region\": \"{{prevRegionShortcut}} Fokatu aurreko eskualdean\",\n        \"{{shortcutsModal}} Show the complete list of keyboard shortcuts\": \"{{shortcutsModal}} Erakutsi laster-teklen zerrenda osoa\"\n    },\n    \"AuthenticationSection\": {\n        \"Error\": \"Errorea\",\n        \"Error details\": \"Errorearen xehetasunak\",\n        \"Instructions\": \"Argibideak\",\n        \"Confirm\": \"Baieztatu\",\n        \"Configure\": \"Konfiguratu\",\n        \"Configured\": \"Konfiguratuta\",\n        \"Close\": \"Itxi\",\n        \"Active\": \"Aktibo\",\n        \"Active method is controlled by an environment variable. Unset variable to change active method.\": \"Aktibo dagoen metodoa ingurune-aldagai batek kontrolatzen du. Utzi aldagaia hautatzeari metodo aktiboa aldatzeko.\",\n        \"Active on restart\": \"Aktibo berrabioan\",\n        \"Are you sure you want to set **{{name}}** as the active authentication method?\": \"Ziur **{{name}}** ezarri nahi duzula autentifikazio-metodo aktibo gisa?\",\n        \"Disabled on restart\": \"Ezgaituta berrabioan\",\n        \"No authentication method is active.\": \"Ez dago autentifikazio-metodorik aktibo.\",\n        \"Set as active method\": \"Ezarri metodo aktibo gisa\",\n        \"Set as active method?\": \"Metodo aktibo gisa ezarri?\",\n        \"The new method will go into effect after you restart Grist.\": \"Metodo berria Grist berrabiarazi ondoren sartuko da indarrean.\",\n        \"Change admin user\": \"Aldatu administratzailea\",\n        \"If Grist is accessible on your network, or is available to multiple people, configure one of the authentication methods below.\": \"Grist sarean eskuragarri badago edo jende askoren eskura badago, konfiguratu beheko autentifikazio-metodoetako bat.\",\n        \"No authentication: unrestricted sign-in as demo user\": \"Ez dago autentifikaziorik: mugarik gabeko saioa hasi da demo erabiltzaile gisa\",\n        \"Prepare changes\": \"Prestatu aldaketak\",\n        \"Restart required. Authentication change may affect your access\": \"Berrabiarazi behar da. Autentifikazioa aldatzeak sarbidean eragina izan dezake\",\n        \"See \\\"Restart Grist\\\" section on top of this page to restart.\": \"Ikus orri honen goiko \\\"Berrabiarazi Grist\\\" atala berrabiarazteko.\",\n        \"When a user accesses Grist, the proxy handles authentication and forwards verified user information through HTTP headers. Grist uses these headers to identify the user.\": \"Erabiltzaile bat Grist-era sartzen denean, proxy-ak autentifikazioa kudeatzen du eta egiaztatutako erabiltzailearen informazioa HTTP goiburuen bidez zuzentzen du. Gristek goiburu horiek erabiltzen ditu erabiltzailea identifikatzeko.\",\n        \"When signing in, users will be redirected to a Grist Connect login page where they can authenticate using various identity providers. After authentication, they'll be redirected back to your Grist server and signed in.\": \"Saioa hastean, erabiltzaileak Grist Connect saio-hasierako orri batera bideratuko dira, eta bertan hainbat identitate-hornitzaile erabiliz autentifikatu ahal izango dira. Autentifikatu ondoren, zure Grist zerbitzarira birbideratuko dira, eta saioa hasiko da.\",\n        \"When signing in, users will be redirected to your chosen identity provider's login page to authenticate. After successful authentication, they'll be redirected back to your Grist server and signed in as the user verified by the provider.\": \"Saioa hastean, erabiltzaileak hautatutako identitate-hornitzailearen saio-hasierako orrira birbideratuko dira, autentifikatzeko. Autentifikazioa behar bezala egin ondoren, zure Grist zerbitzarira birbideratuko dira eta hornitzaileak egiaztatutako erabiltzaile gisa hasiko dute saioa.\",\n        \"You are signed in as {{email}}. After restart, the new administrative user will be {{newEmail}}.\": \"{{email}} gisa duzu hasita saioa. Berrabiarazi ondoren, administrazio-erabiltzaile berria {{newEmail}} izango da.\",\n        \"You are signed in as {{email}}. You may lose access to this server if you cannot sign in as this user after switching the authentication system.\": \"{{email}} gisa duzu hasita saioa. Zerbitzari honetarako sarbidea gal dezakezu, autentifikatze-sistema aldatu ondoren erabiltzaile gisa saioa hasi ezin baduzu.\"\n    },\n    \"commandList\": {\n        \"Copy anchor link\": \"Kopiatu esteka\",\n        \"Display Grist documentation\": \"Erakutsi Grist-en dokumentazioa\",\n        \"Display shortcuts pane\": \"Erakutsi lasterbideak\",\n        \"Activate assistant\": \"Aktibatu laguntzailea\",\n        \"Adds all elements above the cursor to the selected range\": \"Kurtsorearen gainean dauden elementu guztiak hautatutako tartean gehitzen ditu\",\n        \"Adds all elements below the cursor to the selected range\": \"Kurtsorearen azpian dauden elementu guztiak hautatutako tartean gehitzen ditu\",\n        \"Adds all elements to the left of the cursor to the selected range\": \"Kurtsorearen ezkerrean dauden elementu guztiak hautatutako tartean gehitzen ditu\",\n        \"Adds all elements to the right of the cursor to the selected range\": \"Kurtsorearen eskuman dauden elementu guztiak hautatutako tartean gehitzen ditu\",\n        \"Adds the element above the cursor to the selected range\": \"Kurtsorearen gainean dagoen elementua hautatutako tarteari gehitzen du\",\n        \"Adds the element below the cursor to the selected range\": \"Kurtsorearen azpian dagoen elementua hautatutako tarteari gehitzen du\",\n        \"Adds the element to the left of the cursor to the selected range\": \"Kurtsorearen ezkerrean dagoen elementua hautatutako tarteari gehitzen du\",\n        \"Adds the element to the right of the cursor to the selected range\": \"Kurtsorearen eskuman dagoen elementua hautatutako tarteari gehitzen du\",\n        \"Clear the selected columns\": \"Garbitu hautatutako zutabeak\",\n        \"Clears the currently selected cells\": \"Hautatutako gelaxkak garbitzen ditu\",\n        \"Convert the selected columns from formula to data\": \"Bihurtu hautatutako zutabeak formuletatik datuetara\",\n        \"Copy current selection to clipboard\": \"Kopiatu hautaketa arbelera\",\n        \"Copy current selection to clipboard including headers\": \"Kopiatu hautaketa arbelera, baita goiburuak ere\",\n        \"Creates form for active table\": \"Formularioa sortzen du aktibo dagoen taularentzat\",\n        \"Cut current selection to clipboard\": \"Ebaki hautaketa arbelera\",\n        \"Delete the currently selected columns\": \"Ezabatu hautatutako zutabeak\",\n        \"Delete the currently selected record(s)\": \"Ezabatu hautatutako erregistroa(k)\",\n        \"Detach active editor\": \"Bereizi aktibo dagoen editorea\",\n        \"Duplicate the currently selected record(s)\": \"Bikoiztu hautatutako erregistroa(k)\",\n        \"Edit label of the currently-selected field\": \"Editatu hautatutako eremuko etiketa\",\n        \"Edit record layout\": \"Editatu erregistroaren antolaketa\",\n        \"Enter text into currently-selected cell and start editing\": \"Sartu testua hautatutako gelaxkan eta hasi editatzen\",\n        \"Fills current selection with the contents of the top row in the selection\": \"Uneko hautapena goiko errenkadan hautatutako edukiekin betetzen du\",\n        \"Find\": \"Bilatu\",\n        \"Finish editing a cell and save without moving to next record\": \"Amaitu gelaxka editatzen eta gorde hurrengo erregistrora pasa gabe\",\n        \"Finish editing a cell, saving the value\": \"Amaitu gelaxka editatzen, eta gorde balioa\",\n        \"Freeze or unfreeze selected columns\": \"Hautatutako zutabeak izoztu edo desizoztu\",\n        \"Hide the currently selected columns\": \"Ezkutatu hautatutako zutabeak\",\n        \"Hide the currently selected fields\": \"Ezkutatu hautatutako eremuak\",\n        \"Insert a new column, after the currently selected one\": \"Sartu zutabe berri bat, hautatutakoaren ondoren\",\n        \"Insert a new column, before the currently selected one\": \"Sartu zutabe berri bat, hautatutakoaren aurretik\",\n        \"Insert a new record, after the currently selected one in an unsorted table\": \"Sartu erregistro berri bat, hautatutakoaren ondoren, ordenatu gabeko taula batean\",\n        \"Insert a new record, before the currently selected one in an unsorted table\": \"Sartu erregistro berri bat, hautatutakoaren aurretik, ordenatu gabeko taula batean\",\n        \"Insert new column in default location\": \"Sartu zutabe berri bat defektuzko tokian\",\n        \"Insert the current date\": \"Sartu uneko data\",\n        \"Insert the current date and time\": \"Sartu uneko data eta ordua\",\n        \"Maximize the active section\": \"Maximizatu aktibo dagoen atala\",\n        \"Move down to the last record\": \"Jaitsi azken erregistrora\",\n        \"Move downward five records\": \"Jaitsi bost erregistro\",\n        \"Move downward to next record or field\": \"Jaitsi hurrengo erregistro edo eremura\",\n        \"Move left to the previous field\": \"Mugitu ezkerrera aurreko eremura\",\n        \"Move right to the next field\": \"Mugitu eskumara hurrengo eremura\",\n        \"Move to the first field or the beginning of a row\": \"Mugitu lehen eremura edo errenkadako hasierara\",\n        \"Move to the last field or the end of a row\": \"Mugitu azken eremura edo errenkadako amaierara\",\n        \"Move to the next field, saving changes if editing a value\": \"Mugitu hurrengo eremura, eta gorde aldaketak balioren bat editatuz gero\",\n        \"Move to the previous field, saving changes if editing a value\": \"Mugitu aurreko eremura, eta gorde aldaketak balioren bat editatuz gero\",\n        \"Move up to the first record\": \"Mugitu lehen erregistrora\",\n        \"Move upward five records\": \"Igo bost erregistro\",\n        \"Move upward to previous record or field\": \"Igo aurreko erregistro edo eremura\",\n        \"Moves the cursor to the correct location\": \"Kurtsorea kokapen egokira darama\",\n        \"Open comment thread\": \"Ireki iruzkinen haria\",\n        \"Open next page\": \"Ireki hurrengo orria\",\n        \"Open previous page\": \"Ireki aurreko orria\",\n        \"Opens document list\": \"Dokumentuen zerrenda irekitzen du\",\n        \"Paste clipboard contents at cursor\": \"Itsatsi arbeleko edukia kurtsorean\",\n        \"Redo last action\": \"Berregin azken ekintza\",\n        \"Rename the currently selected column\": \"Berrizendatu hautatutako zutabea\",\n        \"Selects all currently displayed cells\": \"Unean bistaratzen diren gelaxka guztiak hautatzen ditu\",\n        \"Show accessibility options\": \"Erakutsi irisgarritasun aukerak\",\n        \"Find next occurrence\": \"Bilatu hurrengo agerpena\",\n        \"Find previous occurrence\": \"Bilatu aurreko agerpena\",\n        \"Shortcut to data selection tab\": \"Datuak hautatzeko fitxarako laster-tekla\",\n        \"Shortcut to focus view tab if creator panel is open\": \"Fokatze-ikuspegiaren fitxarako laster-tekla, sortzailearen panela irekita badago\",\n        \"Shortcut to open document tab\": \"Dokumentu-fitxa irekitzeko laster-tekla\",\n        \"Shortcut to open field tab\": \"Eremuaren fitxa irekitzeko laster-tekla\",\n        \"Shortcut to open sort & filter menu\": \"Ordenatzeko eta iragazteko menua irekitzeko laster-tekla\",\n        \"Shortcut to open the left panel\": \"Ezkerreko panela irekitzeko laster-tekla\",\n        \"Shortcut to open the right panel\": \"Eskumako panela irekitzeko laster-tekla\",\n        \"Shortcut to open view tab\": \"Ikuspegiaren fitxa irekitzeko laster-tekla\",\n        \"Shortcut to sort & filter tab\": \"Ordenatzeko eta iragazteko fitxaren laster-tekla\",\n        \"Show hidden columns\": \"Erakutsi ezkutatutako zutabeak\",\n        \"Show the record card widget of the selected record\": \"Erakutsi hautatutako erregistroaren erregistro-txartelaren widgeta\",\n        \"Sort the view data by the currently selected field in ascending order\": \"Ordenatu ikuspegiaren datuak unean hautatutako eremuaren arabera, goranzko ordenan\",\n        \"Sort the view data by the currently selected field in descending order\": \"Ordenatu ikuspegiaren datuak unean hautatutako eremuaren arabera, beheranzko ordenan\",\n        \"Start editing the currently-selected cell\": \"Hasi editatzen unean hautatutako gelaxka\",\n        \"Toggle creator panel keyboard focus\": \"Gaitu/ezgaitu sortzailearen paneleko teklatuaren fokua\",\n        \"Toggle the currently selected checkbox or switch cell\": \"Kommutatu une horretan hautatutako kontrol-laukia edo gelaxka\",\n        \"Undo last action\": \"Desegin azken ekintza\",\n        \"Use the currently selected row as table headers\": \"Erabili unean hautatutako errenkada taulen goiburu gisa\",\n        \"When in the search bar, close it and focus the current match\": \"Bilaketa-barran dagoenean, itxi eta fokatu uneko parekatzean\",\n        \"When typed at the start of a cell, make this a formula column\": \"Gelaxka baten hasieran idazten denean, bihurtu formula-zutabe\",\n        \"Filter this column by just this cell's value\": \"Iragazi zutabe hau gelaxka honen balioaren arabera\"\n    },\n    \"ProposedChangesPage\": {\n        \"Proposed changes\": \"Iradokitutako aldaketak\",\n        \"Replace original\": \"Ordezkatu jatorrizkoa\",\n        \"This is a list of changes relative to the original document.\": \"Honako zerrendan jatorrizko dokumentuarekiko aldaketak ageri dira.\",\n        \"Accept\": \"Onartu\",\n        \"Accepted {{at}}.\": \"{{at}}(e)an onartua.\",\n        \"Dismiss\": \"Baztertu\",\n        \"Dismissed {{at}}.\": \"{{at}}(e)an baztertua.\",\n        \"Learn more\": \"Ikasi gehiago\",\n        \"No changes found to suggest. Please make some edits.\": \"Ez da aurkitu aldaketa proposamenik. Egin edizioren bat.\",\n        \"Retract suggestion\": \"Bota iradokizuna atzera\",\n        \"Retracted {{at}}.\": \"{{at}}(e)an atzera bota da.\",\n        \"Suggest change\": \"Iradoki aldaketa\",\n        \"Suggest changes\": \"Iradoki aldaketak\",\n        \"Suggestion made {{at}}.\": \"{{at}}(e)an iradoki da.\",\n        \"Suggestions\": \"Iradokizunak\",\n        \"The original document isn't asking for proposed changes.\": \"Jatorrizko dokumentuak ez du aldaketa-iradokizunik eskatu.\",\n        \"There are fresh changes that haven't been added to the suggestion yet.\": \"Oraindik iradokizunetara gehitu ez diren aldaketa berriak daude.\",\n        \"Update suggestion\": \"Eguneratu iradokizuna\",\n        \"Work on a copy\": \"Egin lan kopia batean\",\n        \"Your suggestions\": \"Zure iradokizunak\",\n        \"experiment\": \"esperimentua\",\n        \"original document\": \"jatorrizko dokumentua\"\n    },\n    \"GridViewMenusDateHelpers\": {\n        \"Day of month\": \"Hileko eguna\",\n        \"Day of week\": \"Asteko eguna\",\n        \"Day of week (abbrev)\": \"Asteko eguna (labur)\",\n        \"Day of week (full)\": \"Asteko eguna (osoa)\",\n        \"Day of week (numeric)\": \"Asteko eguna (zenbakia)\",\n        \"Default\": \"Defektuz\",\n        \"Full date\": \"Data osoa\",\n        \"Full name with year\": \"Izen osoa urtearekin\",\n        \"Hour\": \"Ordua\",\n        \"Intervals\": \"Bitarteak\",\n        \"Is weekend?\": \"Asteburua da?\",\n        \"Minute\": \"Minutu\",\n        \"Month\": \"Hilabetea\",\n        \"Name only\": \"Izena bakarrik\",\n        \"Number only\": \"Zenbakia bakarrik\",\n        \"Quarter\": \"Hiruhilekoa\",\n        \"Short with year\": \"Labur urtearekin\",\n        \"Time\": \"Denbora\",\n        \"Time bucket\": \"Denbora-poltsa\",\n        \"Week\": \"Astea\",\n        \"Week of year\": \"Urteko astea\",\n        \"Year\": \"Urtea\",\n        \"Sortable\": \"Ordenagarria\",\n        \"Start of\": \"Hasiera\",\n        \"12-hour format\": \"12 orduko formatua\",\n        \"24-hour format\": \"24 orduko formatua\",\n        \"AM\": {\n            \"PM\": \"AM/PM\"\n        },\n        \"Calendar\": \"Egutegia\",\n        \"Day\": \"Eguna\",\n        \"Days since\": \"Egun _tik\",\n        \"Days until\": \"Egun _arte\",\n        \"End of\": \"_ren amaiera\",\n        \"Months since\": \"Hilabete _tik\",\n        \"Months until\": \"Hilabete _arte\",\n        \"Years since\": \"Urte _tik\",\n        \"Years until\": \"Urte _arte\"\n    },\n    \"selectBy\": {\n        \"Select widget\": \"Hautatu widgeta\"\n    },\n    \"CoreNewDocMethods\": {\n        \"Untitled document\": \"Izenik gabeko dokumentua\"\n    },\n    \"DetailView\": {\n        \"This row is unavailable or does not exist\": \"Lerro hau ez dago erabilgarri edo ez da existitzen\"\n    },\n    \"GetGristComProvider\": {\n        \"Cancel\": \"Utzi\",\n        \"Configure\": \"Konfiguratu\",\n        \"Instructions\": \"Jarraibideak\",\n        \"Paste configuration key here\": \"Itsatsi konfigurazio-gakoa hemen\",\n        \"Sign in with getgrist.com\": \"Eman izena getgrist.com-ekin\",\n        \"To set up {{provider}}, you need to register your Grist server on getgrist.com and paste the configuration key you receive below.\": \"{{provider}} ezartzeko, zure Grist zerbitzaria getgrist.com-en erregistratu eta behean ageri den konfigurazio-gakoa itsatsi behar duzu.\",\n        \"When signing in, users will be redirected to the getgrist.com login page to log in or register. After authenticating on getgrist.com, they'll be redirected back to your Grist server and signed in as the user they authenticated as.\": \"Saioa hastean, erabiltzaileak getgrist.com-en saioa hasteko orrira birbideratuko dira, saioa hasteko edo izena emateko. getgrist.com-en autentifikatu ondoren, zure Grist zerbitzarira birbideratuko dira eta autentifikatu diren erabiltzaile gisa hasiko dute saioa.\"\n    },\n    \"AirtableImportUI\": {\n        \"Back\": \"Atzera\",\n        \"Cancel\": \"Utzi\",\n        \"Connect\": \"Konektatu\",\n        \"Connecting...\": \"Konektatzen…\",\n        \"Continue\": \"Jarraitu\",\n        \"Destination\": \"Helburua\",\n        \"Disconnect\": \"Deskonektatu\",\n        \"Import tables\": \"Inportatu taulak\",\n        \"Import {{count}} tables_one\": \"Inportatu taula {{count}}\",\n        \"Import {{count}} tables_other\": \"Inportatu {{count}} taula\",\n        \"Make sure your token has the correct permissions.\": \"Egiaztatu zure tokenak baimen egokiak dituela.\",\n        \"New table\": \"Taula berria\",\n        \"OAuth\": \"OAuth\",\n        \"Personal access token\": \"Sarbide-token pertsonala\",\n        \"Please enter a personal access token\": \"Sartu sarbide-token pertsonala\",\n        \"Refresh\": \"Freskatu\",\n        \"Select tables to import from {{baseName}}\": \"Hautatu inportatzeko taulak {{baseName}}(e)tik\",\n        \"Skip\": \"Egin gabe utzi\",\n        \"Use personal access token instead\": \"Erabili sarbide-token pertsonala haren ordez\",\n        \"loading your bases...\": \"zure datu-baseak kargatzen…\",\n        \"loading your tables...\": \"zure taulak kargatzen…\",\n        \"or\": \"edo\",\n        \"{{count}} warnings_one\": \"abisu {{count}}\",\n        \"{{count}} warnings_other\": \"{{count}} abisu\"\n    },\n    \"AirtableImporter\": {\n        \"Creating a new Grist document...\": \"Grist dokumentu berria sortzen…\",\n        \"Setting up tables...\": \"Taulak ezartzen…\"\n    },\n    \"ChangeAdminModal\": {\n        \"Enter new admin email\": \"Sartu administratzailearen ePosta berria\",\n        \"Make the new email the installation admin. Orgs, workspaces, and documents will remain owned by {{email}}. These changes will take effect after you restart this Grist server.\": \"Egin mezu elektroniko berria instalazio-administratzaile. Orgs, lan-espazio eta dokumentuek {{email}}-en jabetza izaten jarraituko dute. Aldaketa hauek Grist zerbitzari hau berrabiarazi ondoren izango dute eragina.\",\n        \"New admin\": \"Administratzaile berria\"\n    },\n    \"startDocAirtableImport\": {\n        \"Import from Airtable\": \"Inportatu Airtable-tik\"\n    },\n    \"startHomeAirtableImport\": {\n        \"Import from Airtable\": \"Inportatu Airtable-tik\"\n    }\n}\n"
  },
  {
    "path": "static/locales/eu.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"Loading...\": \"Kargatzen…\",\n        \"og-title\": \"Grist, kalkulu-orrien eboluzioa\",\n        \"og-description\": \"Saretatik harago doana kode irekikoa kalkulu-orri modernoa\"\n    },\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Egiaztatu zure posta elektronikoa identitate-hornitzailearekin, eta hasi saioa berriro.\"\n    },\n    \"access\": {\n        \"docDisabled\": \"Dokumentu hau ezgaituta dago.\",\n        \"docNoAccess\": \"Ez duzu dokumentu honetara sarbiderik.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"Ez da `GRIST_INSTALL_ADMIN_ORG={{org}}`administrazio-erakundean zehaztutako jaberik aurkitu\",\n        \"orgUser\": \"Erabiltzailea `GRIST_INSTALL_ADMIN_ORG={{org}}`administrazio-erakundean zehaztutako jabea da\",\n        \"noAdminEmail\": \"Administratzailearen kontua falta da ez direlako `GRIST_ADMIN_EMAIL` eta `GRIST_DEFAULT_EMAIL` ezarri\",\n        \"accountByEmail\": \"Administratzailearen kontua `GRIST_DEFAULT_EMAIL={{defaultEmail}}`k zehazten du\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Izenik gabeko dokumentua\"\n    }\n}\n"
  },
  {
    "path": "static/locales/fa.client.json",
    "content": "{\n  \"ACUserManager\": {\n    \"Invite new member\": \"دعوت از عضو جدید\",\n    \"Enter email address\": \"آدرس رایانامه(ایمیل) را وارد کنید\",\n    \"We'll email an invite to {{email}}\": \"ما دعوت‌نامه‌ای به آدرس {{email}} ارسال خواهیم کرد\"\n  },\n  \"AccessRules\": {\n    \"Add column rule\": \"افزودن قاعده ستون\",\n    \"Add Default Rule\": \"افزودن قاعده پیش‌فرض\",\n    \"Add table rules\": \"افزودن قواعد جدول\",\n    \"Add user attributes\": \"افزودن خصیصه‌های کاربر\",\n    \"Allow everyone to view Access Rules.\": \"به همه اجازه مشاهده قواعد دسترسی را بدهید.\",\n    \"Attribute name\": \"نام خصیصه\",\n    \"Attribute to Look Up\": \"خصیصه برای جستجو\",\n    \"Checking...\": \"در حال بررسی…\",\n    \"Condition\": \"شرط\",\n    \"Default rules\": \"قواعد پیش‌فرض\",\n    \"Delete table rules\": \"حذف قواعد جدول\",\n    \"Enter Condition\": \"وارد کردن شرط\",\n    \"Everyone\": \"همه\",\n    \"Everyone Else\": \"دیگران\",\n    \"Invalid\": \"نامعتبر\",\n    \"Permission to access the document in full when needed\": \"اجازه دسترسی کامل به سند وقتی نیاز شد\",\n    \"Permission to view Access Rules\": \"اجازه مشاهده قواعد دسترسی\",\n    \"Permissions\": \"اجازه‌ها\",\n    \"Remove column {{- colId }} from {{- tableId }} rules\": \"حذف ستون {{- colId }} از قواعد {{- tableId }}\",\n    \"Remove {{- tableId }} rules\": \"حذف قواعد {{- tableId }}\",\n    \"Remove {{- name }} user attribute\": \"حذف خصیصه کاربر {{- name }}\",\n    \"Reset\": \"بازنشانی\",\n    \"Rules for table \": \"قواعد برای جدول \",\n    \"Save\": \"ذخیره\",\n    \"Saved\": \"ذخیره شد\",\n    \"Special rules\": \"قواعد ویژه\",\n    \"Type message to display when this rule blocks an action…\": \"پیامی بنویسید…\",\n    \"User Attributes\": \"خصیصه‌های کاربر\",\n    \"View as\": \"مشاهده به عنوان\",\n    \"Seed rules\": \"قواعد دانه(Seed)\",\n    \"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.\": \"به همه اجازه دهید کل سند را کپی کنند، یا آن را به صورت کامل در حالت ور رفتن مشاهده کنند.\\nمفید برای نمونه‌ها و قالب‌ها، اما نه برای داده‌های حساس.\",\n    \"Lookup Column\": \"جستجوی ستون\",\n    \"Lookup Table\": \"جستجوی جدول\",\n    \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"هنگام افزودن قوانین جدول، به‌طور خودکار یک قانون برای اعطای دسترسی کامل به مالک اضافه کنید.\",\n    \"This default should be changed if editors' access is to be limited. \": \"این پیش‌فرض باید تغییر کند اگر قرار است دسترسی ویرایشگران محدود شود. \",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"اجازه دهید ویرایشگران ساختار را ویرایش کنند (مانند تغییر و حذف جداول، ستون‌ها، چیدمان‌ها) و فرمول‌ها را بنویسند، که به تمام داده‌ها بدون توجه به محدودیت‌های خواندنی دسترسی دارند.\",\n    \"Permission to edit document structure\": \"اجازه‌ی ویرایش ساختار سند\",\n    \"Add table-wide rule\": \"افزودن قانون سراسری جدول\"\n  },\n  \"AccountPage\": {\n    \"API Key\": \"کلید API\",\n    \"Account settings\": \"تنظیمات حساب کاربری\",\n    \"API\": \"API\"\n  }\n}\n"
  },
  {
    "path": "static/locales/fa.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/fi.client.json",
    "content": "{\n  \"ColumnTitle\": {\n    \"Add description\": \"Lisää kuvaus\",\n    \"Cancel\": \"Peruuta\",\n    \"Column ID copied to clipboard\": \"Sarakkeen ID-tunniste kopioitu leikepöydälle\",\n    \"Column description\": \"Sarakkeen kuvaus\",\n    \"Save\": \"Tallenna\",\n    \"Close\": \"Sulje\"\n  },\n  \"Clipboard\": {\n    \"Got it\": \"Selvä\"\n  },\n  \"FieldContextMenu\": {\n    \"Clear field\": \"Tyhjennä kenttä\",\n    \"Copy\": \"Kopioi\",\n    \"Copy anchor link\": \"Kopioi ankkurilinkki\",\n    \"Cut\": \"Leikkaa\",\n    \"Hide field\": \"Piilota kenttä\",\n    \"Paste\": \"Liitä\"\n  },\n  \"WebhookPage\": {\n    \"Clear queue\": \"Tyhjennä jono\",\n    \"Webhook settings\": \"Webhook-asetukset\"\n  },\n  \"FormulaAssistant\": {\n    \"Capabilities\": \"Kyvykkyydet\",\n    \"Community\": \"Yhteisö\",\n    \"Data\": \"Data\",\n    \"Function List\": \"Funktioluettelo\",\n    \"New Chat\": \"Uusi keskustelu\",\n    \"Preview\": \"Esikatselu\",\n    \"Save\": \"Tallenna\",\n    \"Cancel\": \"Peruuta\",\n    \"Clear conversation\": \"Tyhjennä keskustelu\",\n    \"Code view\": \"Koodinäkymä\",\n    \"Learn more\": \"Lue lisää\",\n    \"Sign Up for Free\": \"Rekisteröidy veloituksetta\",\n    \"Tips\": \"Vinkit\",\n    \"AI Assistant\": \"AI-avustaja\",\n    \"Apply\": \"Toteuta\",\n    \"Need help? Our AI assistant can help.\": \"Tarvitsetko apua? AI-avustajamme voi auttaa sinua.\",\n    \"What do you need help with?\": \"Minkä asian kanssa tarvitset apua?\"\n  },\n  \"WelcomeSitePicker\": {\n    \"Welcome back\": \"Tervetuloa takaisin\",\n    \"You can always switch sites using the account menu.\": \"Voit vaihtaa sivustoja tilivalikon kautta.\",\n    \"You have access to the following Grist sites.\": \"Sinulla on pääsy seuraaviin Grist-sivustoihin.\"\n  },\n  \"UserManager\": {\n    \"Add {{member}} to your team\": \"Lisää {{member}} tiimiisi\",\n    \"Allow anyone with the link to open.\": \"Salli kenen tahansa, jolla on linkki, avata.\",\n    \"Cancel\": \"Peruuta\",\n    \"Close\": \"Sulje\",\n    \"Collaborator\": \"Avustaja\",\n    \"Confirm\": \"Vahvista\",\n    \"Copy link\": \"Kopioi linkki\",\n    \"Grist support\": \"Grist-tuki\",\n    \"Link copied to clipboard\": \"Linkki kopioitu leikepöydälle\",\n    \"Manage members of team site\": \"Hallitse tiimisivuston jäseniä\",\n    \"Off\": \"Pois\",\n    \"On\": \"Päällä\",\n    \"Public access\": \"Julkinen pääsy\",\n    \"Public access: \": \"Julkinen pääsy: \",\n    \"Remove my access\": \"Poista oma pääsy\",\n    \"Save & \": \"Tallenna & \",\n    \"Team member\": \"Tiimijäsen\",\n    \"Your role for this team site\": \"Roolisi tälle tiimisivustolle\",\n    \"guest\": \"vieras\",\n    \"member\": \"jäsen\",\n    \"Guest\": \"Vieras\",\n    \"Create a team to share with more people\": \"Luo tiimi jakaaksesi muiden ihmisten kanssa\",\n    \"Invite multiple\": \"Kutsu useita\",\n    \"team site\": \"tiimisivusto\"\n  },\n  \"searchDropdown\": {\n    \"Search\": \"Hae\"\n  },\n  \"SupportGristNudge\": {\n    \"Opt in to Telemetry\": \"Suostu telemetriaan\",\n    \"Support Grist\": \"Tue Gristiä\",\n    \"Close\": \"Sulje\",\n    \"Opted In\": \"Annettu suostumus\",\n    \"Help Center\": \"Ohjekeskus\"\n  },\n  \"SupportGristPage\": {\n    \"GitHub\": \"GitHub\",\n    \"Help Center\": \"Ohjekeskus\",\n    \"Home\": \"Etusivu\",\n    \"Opt in to Telemetry\": \"Suostu telemetriaan\",\n    \"Sponsor Grist Labs on GitHub\": \"Sponsoroi Grist Labsia GitHubissa\",\n    \"You have opted in to telemetry. Thank you!\": \"Olet antanut suostumuksen telemetriaan. Kiitos!\",\n    \"Opt out of Telemetry\": \"Poista telemetriasuostumus\",\n    \"Support Grist\": \"Tue Gristiä\",\n    \"Telemetry\": \"Telemetria\",\n    \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Tällä instanssilla on suostumus telemetriaan. Vain sivuston ylläpitäjällä on oikeus muutaa tätä asetusta.\"\n  },\n  \"CardContextMenu\": {\n    \"Delete card\": \"Poista kortti\",\n    \"Insert card\": \"Lisää kortti\",\n    \"Insert card above\": \"Lisää kortti yläpuolelle\",\n    \"Insert card below\": \"Lisää kortti alapuolelle\",\n    \"Copy anchor link\": \"Kopioi ankkurilinkki\"\n  },\n  \"HiddenQuestionConfig\": {\n    \"Hidden fields\": \"Piilotetut kentät\"\n  },\n  \"WelcomeCoachingCall\": {\n    \"Maybe later\": \"Ehkä myöhemmin\"\n  },\n  \"Menu\": {\n    \"Separator\": \"Erotin\",\n    \"Building blocks\": \"Rakennuspalikat\",\n    \"Columns\": \"Sarakkeet\",\n    \"Copy\": \"Kopioi\",\n    \"Cut\": \"Leikkaa\",\n    \"Insert question above\": \"Lisää kysymys yläpuolelle\",\n    \"Paragraph\": \"Kappale\",\n    \"Insert question below\": \"Lisää kysymys alapuolelle\",\n    \"Paste\": \"Liitä\"\n  },\n  \"ACUserManager\": {\n    \"Enter email address\": \"Kirjoita sähköpostiosoite\",\n    \"Invite new member\": \"Kutsu uusi jäsen\",\n    \"We'll email an invite to {{email}}\": \"Lähetämme kutsun sähköpostitse osoitteeseen {{email}}\"\n  },\n  \"AccessRules\": {\n    \"Add Default Rule\": \"Lisää oletussääntö\",\n    \"Add table rules\": \"Lisää taulusäännöt\",\n    \"Everyone\": \"Kaikki\",\n    \"Add column rule\": \"Lisää sarakesääntö\",\n    \"Add user attributes\": \"Lisää käyttäjäominaisuudet\",\n    \"Attribute name\": \"Ominaisuuden nimi\",\n    \"Checking...\": \"Tarkistetaan…\",\n    \"Condition\": \"Ehto\",\n    \"Default rules\": \"Oletussäännöt\",\n    \"Delete table rules\": \"Poista taulusäännöt\",\n    \"Enter Condition\": \"Kirjoita ehto\",\n    \"Everyone Else\": \"Kaikki muut\",\n    \"Invalid\": \"Virheellinen\",\n    \"Permissions\": \"Oikeudet\",\n    \"Rules for table \": \"Säännöt taululle \",\n    \"Save\": \"Tallenna\",\n    \"Saved\": \"Tallennettu\",\n    \"Special rules\": \"Erikoissäännöt\",\n    \"Type message to display when this rule blocks an action…\": \"Kirjoita viesti…\",\n    \"User Attributes\": \"Käyttäjäominaisuudet\",\n    \"Allow everyone to view Access Rules.\": \"Salli kenen tahansa nähdä pääsysäännöt.\",\n    \"Permission to view Access Rules\": \"Oikeus nähdä pääsysäännöt\",\n    \"Permission to edit document structure\": \"Oikeus muokata dokumentin rakennetta\"\n  },\n  \"AccountPage\": {\n    \"Account settings\": \"Tilin asetukset\",\n    \"Allow signing in to this account with Google\": \"Salli kirjautumien tälle tilille Googlea käyttäen\",\n    \"Name\": \"Nimi\",\n    \"API Key\": \"API-avain\",\n    \"Change password\": \"Vaihda salasana\",\n    \"Edit\": \"Muokkaa\",\n    \"Email\": \"Sähköpostiosoite\",\n    \"Login method\": \"Kirjautumistapa\",\n    \"Password & security\": \"Salasana ja tietoturva\",\n    \"Save\": \"Tallenna\",\n    \"Theme\": \"Teema\",\n    \"Two-factor authentication\": \"Kaksivaiheinen todennus\",\n    \"Language\": \"Kieli\",\n    \"Names only allow letters, numbers and certain special characters\": \"Nimessä on mahdollista käyttää vain kirjaimia, numeroita ja joitain erikoismerkkejä\"\n  },\n  \"AccountWidget\": {\n    \"Pricing\": \"Hinnoittelu\",\n    \"Profile settings\": \"Profiiliasetukset\",\n    \"Sign in\": \"Kirjaudu sisään\",\n    \"Switch Accounts\": \"Vaihda tiliä\",\n    \"Toggle Mobile Mode\": \"Mobiilitila päällä/pois\",\n    \"Activation\": \"Aktivointi\",\n    \"Billing account\": \"Laskutustili\",\n    \"Support Grist\": \"Tue Gristiä\",\n    \"Sign out\": \"Kirjaudu ulos\",\n    \"Upgrade Plan\": \"Päivitä tilaus\",\n    \"Sign up\": \"Rekisteröidy\",\n    \"Use This Template\": \"Käytä tätä mallipohjaa\",\n    \"Add account\": \"Lisää tili\",\n    \"Manage team\": \"Hallitse tiimiä\",\n    \"Accounts\": \"Tilit\",\n    \"Document settings\": \"Dokumentin asetukset\"\n  },\n  \"ViewAsDropdown\": {\n    \"Example Users\": \"Esimerkkikäyttäjät\",\n    \"Users from table\": \"Käyttäjät taulusta\"\n  },\n  \"ActionLog\": {\n    \"All tables\": \"Kaikki taulut\"\n  },\n  \"ApiKey\": {\n    \"Click to show\": \"Napsauta näyttääksesi\",\n    \"Remove\": \"Poista\",\n    \"Remove API Key\": \"Poista API-avain\",\n    \"Create\": \"Luo\",\n    \"By generating an API key, you will be able to make API calls for your own account.\": \"Luomalla API-avaimen voit tehdä API-kutsuja omalle tilillesi.\"\n  },\n  \"AddNewButton\": {\n    \"Add new\": \"Lisää uusi\"\n  },\n  \"App\": {\n    \"Description\": \"Kuvaus\",\n    \"Key\": \"Avain\",\n    \"Memory Error\": \"Muistivirhe\",\n    \"Translators: please translate this only when your language is ready to be offered to users\": \"Valmis testattavaksi\"\n  },\n  \"AppHeader\": {\n    \"Team Site\": \"Tiimin sivu\",\n    \"Home page\": \"Kotisivu\",\n    \"Personal Site\": \"Henkilökohtainen sivu\",\n    \"Grist Templates\": \"Grist-mallipohjat\"\n  },\n  \"CellContextMenu\": {\n    \"Clear cell\": \"Tyhjennä solu\",\n    \"Clear values\": \"Tyhjennä arvot\",\n    \"Copy anchor link\": \"Kopioi ankkurilinkki\",\n    \"Reset {{count}} entire columns_one\": \"Nollaa koko sarake\",\n    \"Reset {{count}} entire columns_other\": \"Nollaa {{count}} saraketta kokonaan\",\n    \"Comment\": \"Kommentti\",\n    \"Copy\": \"Kopioi\",\n    \"Cut\": \"Leikkaa\",\n    \"Delete {{count}} columns_one\": \"Poista sarake\",\n    \"Delete {{count}} columns_other\": \"Poista {{count}} saraketta\",\n    \"Delete {{count}} rows_one\": \"Poista rivi\",\n    \"Delete {{count}} rows_other\": \"Poista {{count}} riviä\",\n    \"Duplicate rows_one\": \"Monista rivi\",\n    \"Duplicate rows_other\": \"Monista rivit\",\n    \"Filter by this value\": \"Suodata tämän arvon perusteella\",\n    \"Insert column to the left\": \"Lisää sarake vasemmalle\",\n    \"Insert column to the right\": \"Lisää sarake oikealle\",\n    \"Insert row\": \"Lisää rivi\",\n    \"Insert row above\": \"Lisää rivi yläpuolelle\",\n    \"Insert row below\": \"Lisää rivi alapuolelle\",\n    \"Reset {{count}} columns_one\": \"Nollaa sarake\",\n    \"Reset {{count}} columns_other\": \"Nollaa {{count}} saraketta\",\n    \"Paste\": \"Liitä\"\n  },\n  \"ColumnFilterMenu\": {\n    \"All\": \"Kaikki\",\n    \"All except\": \"Kaikki paitsi\",\n    \"All shown\": \"Kaikki näytetyt\",\n    \"No matching values\": \"Ei vastaavia arvoja\",\n    \"None\": \"Ei mitään\",\n    \"End\": \"Loppu\",\n    \"Other values\": \"Muut arvot\",\n    \"Search values\": \"Hae arvoja\",\n    \"Others\": \"Muut\",\n    \"Search\": \"Hae\",\n    \"Start\": \"Alku\"\n  },\n  \"CustomSectionConfig\": {\n    \"Select Custom Widget\": \"Valitse mukautettu widgetti\",\n    \"Enter Custom URL\": \"Kirjoita mukautettu URL-osoite\",\n    \" (optional)\": \" (valinnainen)\",\n    \"Add\": \"Lisää\",\n    \"Open configuration\": \"Avaa määritys\",\n    \"Pick a column\": \"Valitse sarake\",\n    \"Read selected table\": \"Lue valittu taulu\",\n    \"Widget does not require any permissions.\": \"Widgetti ei vaadi mitään oikeuksia.\",\n    \"Clear selection\": \"Tyhjennä valinta\",\n    \"Full document access\": \"Täysi pääsy dokumenttiin\",\n    \"Learn more about custom widgets\": \"Lue lisää mukautetuista widgeteistä\"\n  },\n  \"DocMenu\": {\n    \"Delete Forever\": \"Poista pysyvästi\",\n    \"Delete {{name}}\": \"Poista {{name}}\",\n    \"All documents\": \"Kaikki dokumentit\",\n    \"Pin Document\": \"Kiinnitä dokumentti\",\n    \"Pinned Documents\": \"Kiinnitetyt dokumentit\",\n    \"Rename\": \"Nimeä uudelleen\",\n    \"Restore\": \"Palauta\",\n    \"To restore this document, restore the workspace first.\": \"Palauttaaksesi tämän dokumetin, palauta ensin työtila.\",\n    \"(The organization needs a paid plan)\": \"(Organisaatio tarvitsee maksullisen tilauksen)\",\n    \"By Date Modified\": \"Muokkauspäivän mukaan\",\n    \"By Name\": \"Nimen mukaan\",\n    \"You are on your personal site. You also have access to the following sites:\": \"Olet henkilökohtaisella sivustolla. Sinulla on pääsy myös seuraaviin sivustoihin:\",\n    \"Current workspace\": \"Nykyinen työtila\",\n    \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Olet sivustolla {{siteName}}. Sinulla on pääsy myös seuraaviin sivustoihin:\",\n    \"Delete\": \"Poista\",\n    \"Deleted {{at}}\": \"Poistettu {{at}}\",\n    \"Discover More Templates\": \"Löydä lisää mallipohjia\",\n    \"Document will be moved to Trash.\": \"Dokumentti siirretään roskakoriin.\",\n    \"Document will be permanently deleted.\": \"Dokumentti poistetaan pysyvästi.\",\n    \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Dokumentit pysyvät roskakorissa 30 päivän ajan, ja sen jälkeen ne poistetaan pysyvästi.\",\n    \"Edited {{at}}\": \"Muokattu {{at}}\",\n    \"Examples & Templates\": \"Esimerkit & mallipohjat\",\n    \"Examples and Templates\": \"Esimerkit ja mallipohjat\",\n    \"Manage users\": \"Hallitse käyttäjiä\",\n    \"More Examples and Templates\": \"Lisää esimerkkejä ja mallipohjia\",\n    \"Move\": \"Siirrä\",\n    \"Move {{name}} to workspace\": \"Siirrä {{name}} työtilaan\",\n    \"Other Sites\": \"Muut sivustot\",\n    \"Permanently Delete \\\"{{name}}\\\"?\": \"Poistetaanko \\\"{{name}}\\\" pysyvästi?\",\n    \"Remove\": \"Poista\",\n    \"Requires edit permissions\": \"Vaatii muokkausoikeudet\",\n    \"This service is not available right now\": \"Tämä palvelu ei ole juuri nyt saatavilla\",\n    \"Trash\": \"Roskakori\",\n    \"Trash is empty.\": \"Roskakori on tyhjä.\",\n    \"Unpin Document\": \"Poista dokumentin kiinnitys\",\n    \"Workspace not found\": \"Työtilaa ei löydy\",\n    \"You may delete a workspace forever once it has no documents in it.\": \"Voit poistaa työtilan pysyvästi, kun se ei enää sisällä dokumentteja.\"\n  },\n  \"DocHistory\": {\n    \"Open snapshot\": \"Avaa tilannevedos\",\n    \"Snapshots\": \"Tilannevedokset\",\n    \"Snapshots are unavailable.\": \"Tilannevedokset eivät ole saatavilla.\",\n    \"Activity\": \"Aktiviteetti\",\n    \"Compare to current\": \"Vertaa nykyiseen\",\n    \"Compare to previous\": \"Vertaa edelliseen\"\n  },\n  \"DocumentSettings\": {\n    \"API console\": \"API-konsoli\",\n    \"Document settings\": \"Dokumentin asetukset\",\n    \"Engine (experimental {{span}} change at own risk):\": \"Moottori (kokeellinen {{span}} vaihda omalla vastuulla):\",\n    \"Local currency ({{currency}})\": \"Paikallinen valuutta ({{currency}})\",\n    \"Locale:\": \"Maa-asetusto:\",\n    \"Save\": \"Tallenna\",\n    \"Save and Reload\": \"Tallenna ja lataa uudelleen\",\n    \"This document's ID (for API use):\": \"Tämän dokumentin ID-tunniste (API-käyttöä varten):\",\n    \"Time Zone:\": \"Aikavyöhyke:\",\n    \"Document ID copied to clipboard\": \"Dokumentin ID-tunniste kopioitiin leikepöydälle\",\n    \"Ok\": \"OK\",\n    \"Manage Webhooks\": \"Hallitse webhookeja\",\n    \"Webhooks\": \"Webhookit\",\n    \"Currency:\": \"Valuutta:\"\n  },\n  \"DocumentUsage\": {\n    \"Usage\": \"Käyttö\",\n    \"Size of attachments\": \"Liitteiden koko\",\n    \"Rows\": \"Rivit\",\n    \"Data size\": \"Datan koko\"\n  },\n  \"Drafts\": {\n    \"Undo discard\": \"Kumoa hylkäys\",\n    \"Restore last edit\": \"Palauta viimeisin muokkaus\"\n  },\n  \"DuplicateTable\": {\n    \"Copy all data in addition to the table structure.\": \"Kopioi kaikki data taulun rakenteen lisäksi.\",\n    \"Name for new table\": \"Uuden taulun nimi\"\n  },\n  \"ExampleInfo\": {\n    \"Tutorial: Manage Business Data\": \"Opas: hallitse liiketoimintadataa\",\n    \"Tutorial: Analyze & Visualize\": \"Opas: analysoi ja visualisoi\",\n    \"Tutorial: Create a CRM\": \"Opas: luo CRM\"\n  },\n  \"FieldConfig\": {\n    \"Empty columns_other\": \"Tyhjät sarakkeet\",\n    \"Enter formula\": \"Kirjoita kaava\",\n    \"Formula columns_one\": \"Kaavasarake\",\n    \"Formula columns_other\": \"Kaavasarakkeet\",\n    \"Set formula\": \"Aseta kaava\",\n    \"DESCRIPTION\": \"KUVAUS\",\n    \"Data columns_one\": \"Datasarake\",\n    \"Data columns_other\": \"Datasarakkeet\",\n    \"Empty columns_one\": \"Tyhjä sarake\",\n    \"COLUMN BEHAVIOR\": \"SARAKKEEN TOIMINTA\"\n  },\n  \"FieldMenus\": {\n    \"Using separate settings\": \"Käytetään erillisiä asetuksia\",\n    \"Use separate settings\": \"Käytä erillisiä asetuksia\",\n    \"Using common settings\": \"Käytetään yleisiä asetuksia\"\n  },\n  \"GridOptions\": {\n    \"Vertical gridlines\": \"Pystysuuntaiset ruutuviivat\",\n    \"Grid Options\": \"Ruudukkovalinnat\",\n    \"Horizontal gridlines\": \"Vaakasuuntaiset ruutuviivat\",\n    \"Zebra stripes\": \"Seepraraidat\"\n  },\n  \"GridViewMenus\": {\n    \"Freeze {{count}} columns_other\": \"Jäädytä {{count}} saraketta\",\n    \"Hide {{count}} columns_other\": \"Piilota {{count}} saraketta\",\n    \"Show column {{- label}}\": \"Näytä sarake {{- label}}\",\n    \"Add column\": \"Lisää sarake\",\n    \"Clear values\": \"Tyhjennä arvot\",\n    \"Column Options\": \"Sarakevalinnat\",\n    \"Convert formula to data\": \"Muunna kaava dataksi\",\n    \"Delete {{count}} columns_other\": \"Poista {{count}} saraketta\",\n    \"Insert column to the left\": \"Lisää sarake vasemmalle\",\n    \"Insert column to the right\": \"Lisää sarake oikealle\",\n    \"Search columns\": \"Hae sarakkeita\",\n    \"UUID\": \"UUID\",\n    \"Created by\": \"Luonut\",\n    \"Numeric\": \"Numeerinen\",\n    \"Text\": \"Teksti\",\n    \"Integer\": \"Kokonaisluku\",\n    \"Delete {{count}} columns_one\": \"Poista sarake\",\n    \"Filter Data\": \"Suodata data\",\n    \"Freeze {{count}} columns_one\": \"Jäädytä tämä sarake\",\n    \"Hide {{count}} columns_one\": \"Piilota sarake\",\n    \"Rename column\": \"Nimeä sarake uudelleen\",\n    \"Reset {{count}} columns_one\": \"Nollaa sarake\",\n    \"Reset {{count}} columns_other\": \"Nollaa {{count}} saraketta\",\n    \"Reset {{count}} entire columns_one\": \"Nollaa koko sarake\",\n    \"Reset {{count}} entire columns_other\": \"Nollaa {{count}} kokonaista saraketta\",\n    \"Apply on record changes\": \"Toteuta tietueiden muuttuessa\",\n    \"Apply to new records\": \"Toteuta uusiin tietueisiin\",\n    \"Created At\": \"Luotu\",\n    \"Created By\": \"Luonut\",\n    \"Hidden Columns\": \"Piilotetut sarakkeet\",\n    \"Last Updated At\": \"Viimeksi päivitetty\",\n    \"Last Updated By\": \"Viimeksi päivittänyt\",\n    \"Show hidden columns\": \"Näytä piilotetut sarakkeet\",\n    \"Timestamp\": \"Aikaleima\",\n    \"Adding UUID column\": \"Lisätään UUID-saake\",\n    \"No reference columns.\": \"Ei viitesarakkeita.\",\n    \"Sort\": \"Järjestä\",\n    \"Sorted (#{{count}})_one\": \"Järjestetty (#{{count}})\",\n    \"Sorted (#{{count}})_other\": \"Järjestetty (#{{count}})\",\n    \"Add formula column\": \"Lisää kaavasarake\",\n    \"Created at\": \"Luotu\",\n    \"Last updated at\": \"Viimeksi päivitetty\",\n    \"Last updated by\": \"Viimeksi päivittänyt\",\n    \"Date\": \"Päivä\",\n    \"Choice\": \"Valinta\",\n    \"Choice List\": \"Valintaluettelo\",\n    \"Attachment\": \"Liite\",\n    \"Toggle\": \"Kytkin\",\n    \"Reference\": \"Viite\",\n    \"Reference List\": \"Viiteluettelo\",\n    \"DateTime\": \"PäiväAika\"\n  },\n  \"HomeIntro\": {\n    \"This workspace is empty.\": \"Tämä työtila on tyhjä.\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Sinulla on pelkkä lukuoikeus tälle sivustolle. Tällä hetkellä sivustolla ei ole dokumentteja.\",\n    \"Welcome to Grist!\": \"Tervetuloa Gristiin!\",\n    \"Any documents created in this site will appear here.\": \"Tällä sivustolla luodut dokumentit ilmestyvät tänne.\",\n    \"Browse Templates\": \"Selaa mallipohjia\",\n    \"Create empty document\": \"Luo tyhjä dokumentti\",\n    \"Get started by creating your first Grist document.\": \"Aloita luomalla ensimmäinen Grist-dokumentti.\",\n    \"Get started by exploring templates, or creating your first Grist document.\": \"Aloita selaamalla mallipohjia tai luomalla ensimmäinen Grist-dokumentti.\",\n    \"Get started by inviting your team and creating your first Grist document.\": \"Aloita kutsumalla tiimisi ja luomalla ensimmäinen Grist-dokumentti.\",\n    \"Help Center\": \"Ohjekeskus\",\n    \"Import document\": \"Tuo dokumentti\",\n    \"Invite Team Members\": \"Kutsu tiimijäseniä\",\n    \"Sign up\": \"Rekisteröidy\",\n    \"Welcome to Grist, {{name}}!\": \"Tervetuloa Gristiin, {{name}}!\",\n    \"Welcome to {{orgName}}\": \"Tervetuloa, tämä on {{orgName}}\",\n    \"personal site\": \"henkilökohtainen sivusto\",\n    \"{{signUp}} to save your work. \": \"{{signUp}} tallentaaksesi työsi. \",\n    \"Welcome to Grist, {{- name}}!\": \"Tervetuloa Gristiin, {{- name}}!\",\n    \"Welcome to {{- orgName}}\": \"Tervetuloa, tämä on {{- orgName}}\",\n    \"Sign in\": \"Kirjaudu sisään\",\n    \"To use Grist, please either sign up or sign in.\": \"Käytä Gristiä rekisteröitymällä tai kirjautumalla sisään.\"\n  },\n  \"HomeLeftPane\": {\n    \"Delete {{workspace}} and all included documents?\": \"Poistetaanko työtila {{workspace}} ja kaikki siihen sisältyvät dokumentit?\",\n    \"Tutorial\": \"Opas\",\n    \"Trash\": \"Roskakori\",\n    \"Workspace will be moved to Trash.\": \"Työtila siirretään roskakoriin.\",\n    \"Workspaces\": \"Työtilat\",\n    \"All documents\": \"Kaikki dokumentit\",\n    \"Create empty document\": \"Luo tyhjä dokumentti\",\n    \"Create workspace\": \"Luo työtila\",\n    \"Delete\": \"Poista\",\n    \"Examples & Templates\": \"Mallipohjat\",\n    \"Import document\": \"Tuo dokumentti\",\n    \"Manage users\": \"Hallitse käyttäjiä\",\n    \"Rename\": \"Nimeä uudelleen\"\n  },\n  \"errorPages\": {\n    \"Error{{suffix}}\": \"Virhe{{suffix}}\",\n    \"Add account\": \"Lisää tili\",\n    \"Contact support\": \"Ota yhteys tukeen\",\n    \"Go to main page\": \"Siirry etusivulle\",\n    \"Page not found{{suffix}}\": \"Sivua ei löytynyt{{suffix}}\",\n    \"Sign in\": \"Kirjaudu sisään\",\n    \"Sign in again\": \"Kirjaudu sisään uudelleen\",\n    \"Sign in to access this organization's documents.\": \"Kirjaudu sisään käyttääksesi tämän organisaation dokumentteja.\",\n    \"Signed out{{suffix}}\": \"Kirjauduttu ulos{{suffix}}\",\n    \"There was an unknown error.\": \"Tuntematon virhe.\",\n    \"You do not have access to this organization's documents.\": \"Sinulla ei ole pääsyoikeutta tämän organisaation dokumentteihin.\",\n    \"Build your own form\": \"Rakenna oma lomakkeesi\",\n    \"Sign up\": \"Rekisteröidy\",\n    \"Your account has been deleted.\": \"Tilisi on poistettu.\",\n    \"An unknown error occurred.\": \"Tapahtui tuntematon virhe.\",\n    \"Form not found\": \"Lomaketta ei löytynyt\",\n    \"Powered by\": \"Taustavoimana\",\n    \"Something went wrong\": \"Jokin meni pieleen\",\n    \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Pyydettyä sivua ei löydy.{{separator}}Tarkista URL-osoite ja yritä uudelleen.\",\n    \"There was an error: {{message}}\": \"Tapahtui virhe: {{message}}\",\n    \"You are now signed out.\": \"Olet nyt kirjautunut ulos.\",\n    \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Olet kirjautunut käyttäjänä {{email}}. Voit kirjautua sisään toisella tilillä, tai pyytää ylläpitäjältä pääsyä.\",\n    \"Account deleted{{suffix}}\": \"Tili poistettu{{suffix}}\",\n    \"Access denied{{suffix}}\": \"Pääsy estetty{{suffix}}\"\n  },\n  \"Importer\": {\n    \"Merge rows that match these fields:\": \"Yhdistä rivit, jotka vastaavat näitä kenttiä:\",\n    \"Update existing records\": \"Päivitä olemassa olevat tietueet\",\n    \"Destination table\": \"Kohdetaulu\",\n    \"Grist column\": \"Grist-sarake\",\n    \"Import from file\": \"Tuo tiedostosta\",\n    \"New Table\": \"Uusi taulu\",\n    \"Skip\": \"Ohita\",\n    \"Skip Import\": \"Ohita tuonti\",\n    \"Source column\": \"Lähdesarake\"\n  },\n  \"MakeCopyMenu\": {\n    \"Enter document name\": \"Kirjoita dokumentin nimi\",\n    \"Name\": \"Nimi\",\n    \"Include the structure without any of the data.\": \"Sisällytä rakenne ilman mitään dataa.\",\n    \"No destination workspace\": \"Ei kohdetyötilaa\",\n    \"Organization\": \"Organisaatio\",\n    \"Original Has Modifications\": \"Alkuperäisessä on muutoksia\",\n    \"Original Looks Identical\": \"Alkuperäinen vaikuttaa identtiseltä\",\n    \"Overwrite\": \"Ylikirjoita\",\n    \"Sign up\": \"Rekisteröidy\",\n    \"The original version of this document will be updated.\": \"Tämän dokumetin alkuperäinen versio päivitetään.\",\n    \"To save your changes, please sign up, then reload this page.\": \"Tallenna muutokset rekisteröitymällä ja sitten lataamalla tämä sivu uudelleen.\",\n    \"You do not have write access to this site\": \"Sinulla ei ole kirjoitusoikeutta tähän sivustoon\",\n    \"Download document and history\": \"Lataa koko dokumentti ja historia\",\n    \"Cancel\": \"Peruuta\",\n    \"Update\": \"Päivitä\",\n    \"Update Original\": \"Päivitä alkuperäinen\",\n    \"Workspace\": \"Työtila\",\n    \"You do not have write access to the selected workspace\": \"Sinulla ei ole kirjoitusoikeutta valittuun työtilaan\",\n    \"Download document structure only (no data, for template use)\": \"Poista kaikki data, mutta säilytä rakenne mallipohjakäyttöä varten\",\n    \"Download document without history (can significantly reduce file size)\": \"Poista dokumentin historia (voi pienentää tiedostokokoa merkittävästi)\",\n    \"Replacing the original requires editing rights on the original document.\": \"Alkuperäisen korvaaminen vaatii muokkausoikeuksia alkuperäiseen dokumenttiin.\"\n  },\n  \"RightPanel\": {\n    \"Default field value\": \"Kentän oletusarvo\",\n    \"Submit another response\": \"Lähetä toinen vastaus\",\n    \"Field title\": \"Kentän nimi\",\n    \"Enter text\": \"Kirjoita teksti\",\n    \"Hidden field\": \"Piilotettu kenttä\",\n    \"Redirection\": \"Uudelleenohjaus\",\n    \"Field rules\": \"Kenttäsäännöt\",\n    \"Layout\": \"Asettelu\",\n    \"Required field\": \"Pakollinen kenttä\",\n    \"Submission\": \"Lähettäminen\",\n    \"Submit button label\": \"Lähetyspainikkeen nimi\",\n    \"Success text\": \"Onnistumisteksti\",\n    \"Enter redirect URL\": \"Kirjoita uudelleenohjauksen URL-osoite\",\n    \"Change widget\": \"Vaihda widgetti\",\n    \"DATA TABLE NAME\": \"DATATAULUN NIMI\",\n    \"fields_one\": \"Kenttä\",\n    \"fields_other\": \"Kentät\",\n    \"Save\": \"Tallenna\",\n    \"Select widget\": \"Valitse widgetti\",\n    \"Theme\": \"Teema\",\n    \"Widget\": \"Widgetti\",\n    \"You do not have edit access to this document\": \"Sinulla ei ole muokkausoikeutta tähän dokumenttiin\",\n    \"columns_one\": \"Sarake\",\n    \"columns_other\": \"Sarakkeet\",\n    \"DATA TABLE\": \"DATATAULU\",\n    \"COLUMN TYPE\": \"SARAKETYYPPI\",\n    \"Data\": \"Data\",\n    \"TRANSFORM\": \"MUUNNA\",\n    \"Sort & filter\": \"Järjestä & suodata\",\n    \"WIDGET TITLE\": \"WIDGETIN OTSIKKO\",\n    \"Configuration\": \"Määritys\",\n    \"CHART TYPE\": \"KAAVIOTYYPPI\",\n    \"CUSTOM\": \"MUKAUTETTU\",\n    \"GROUPED BY\": \"RYHMITYSPERUSTE\",\n    \"Row style\": \"Rivityyli\",\n    \"SOURCE DATA\": \"LÄHDEDATA\",\n    \"Redirect automatically after submission\": \"Uudelleenohjaa automaattisesti lähetyksen jälkeen\",\n    \"Reset form\": \"Nollaa lomake\"\n  },\n  \"RowContextMenu\": {\n    \"Delete\": \"Poista\",\n    \"Copy anchor link\": \"Kopioi ankkurilinkki\",\n    \"Insert row\": \"Lisää rivi\",\n    \"Insert row above\": \"Lisää rivi yläpuolelle\",\n    \"Insert row below\": \"Lisää rivi alapuolelle\",\n    \"View as card\": \"Näytä korttina\"\n  },\n  \"SelectionSummary\": {\n    \"Copied to clipboard\": \"Kopioitu leikepöydälle\"\n  },\n  \"ShareMenu\": {\n    \"Current Version\": \"Nykyinen versio\",\n    \"Back to current\": \"Takaisin nykyiseen\",\n    \"Download\": \"Lataa\",\n    \"Edit without affecting the original\": \"Muokkaa vaikuttamatta alkuperäiseen\",\n    \"Manage users\": \"Hallitse käyttäjiä\",\n    \"Original\": \"Alkuperäinen\",\n    \"Export CSV\": \"Vie CSV\",\n    \"Export XLSX\": \"Vie XLSX\",\n    \"Replace {{termToUse}}...\": \"Korvaa {{termToUse}}…\",\n    \"Send to Google Drive\": \"Lähetä Google Driveen\",\n    \"Save copy\": \"Tallenna kopio\",\n    \"Save Document\": \"Tallenna dokumentti\",\n    \"Show in folder\": \"Näytä kansiossa\",\n    \"Share\": \"Jaa\",\n    \"Download...\": \"Lataa...\",\n    \"Unsaved\": \"Tallentamaton\"\n  },\n  \"NotifyUI\": {\n    \"No notifications\": \"Ei ilmoituksia\",\n    \"Ask for help\": \"Pyydä apua\",\n    \"Cannot find personal site, sorry!\": \"Pahoittelut, henkilökohtaista sivustoa ei löydy!\",\n    \"Give feedback\": \"Anna palautetta\",\n    \"Go to your free personal site\": \"Siirry henkilökohtaiselle sivustollesi\",\n    \"Notifications\": \"Ilmoitukset\",\n    \"Renew\": \"Uudista\",\n    \"Report a problem\": \"Ilmoita ongelmasta\",\n    \"Upgrade Plan\": \"Päivitä tilaus\",\n    \"Manage billing\": \"Hallitse laskutusta\"\n  },\n  \"PageWidgetPicker\": {\n    \"Select data\": \"Valitse data\",\n    \"Select widget\": \"Valitse widgetti\",\n    \"Add to page\": \"Lisää sivulle\",\n    \"Group by\": \"Ryhmitysperuste\"\n  },\n  \"PermissionsWidget\": {\n    \"Allow all\": \"Salli kaikki\",\n    \"Deny all\": \"Estä kaikki\",\n    \"Read only\": \"Vain luku\"\n  },\n  \"PluginScreen\": {\n    \"Import failed: \": \"Tuonti epäonnistui: \"\n  },\n  \"RecordLayout\": {\n    \"Updating record layout.\": \"Päivitetään tietueasettelua.\"\n  },\n  \"RecordLayoutEditor\": {\n    \"Show field {{- label}}\": \"Näytä kenttä {{- label}}\",\n    \"Save layout\": \"Tallenna asettelu\",\n    \"Add field\": \"Lisää kenttä\",\n    \"Cancel\": \"Peruuta\",\n    \"Create new field\": \"Luo uusi kenttä\"\n  },\n  \"modals\": {\n    \"Don't show again\": \"Älä näytä uudelleen\",\n    \"Cancel\": \"Peruuta\",\n    \"Ok\": \"OK\",\n    \"Save\": \"Tallenna\",\n    \"Are you sure you want to delete these records?\": \"Haluatko varmasti poistaa nämä tietueet?\",\n    \"Are you sure you want to delete this record?\": \"Haluatko varmasti poistaa tämän tietueen?\",\n    \"Delete\": \"Poista\",\n    \"Dismiss\": \"Hylkää\",\n    \"Don't ask again.\": \"Älä kysy uudelleen.\",\n    \"Don't show again.\": \"Älä näytä uudelleen.\",\n    \"Don't show tips\": \"Älä näytä vinkkejä\",\n    \"Got it\": \"Selvä\"\n  },\n  \"pages\": {\n    \"Remove\": \"Poista\",\n    \"You do not have edit access to this document\": \"Sinulla ei ole muokkausoikeutta tähän dokumenttiin\",\n    \"Rename\": \"Nimeä uudelleen\"\n  },\n  \"RefSelect\": {\n    \"Add column\": \"Lisää sarake\",\n    \"No columns to add\": \"Ei sarakkeita lisättäväksi\"\n  },\n  \"SiteSwitcher\": {\n    \"Switch Sites\": \"Vaihda sivustoja\",\n    \"Create new team site\": \"Luo uusi tiimisivusto\"\n  },\n  \"search\": {\n    \"No results\": \"Ei tuloksia\",\n    \"Search in document\": \"Hae dokumentista\",\n    \"Search\": \"Hae\",\n    \"Find Next \": \"Löydä seuraava \",\n    \"Find Previous \": \"Löydä edellinen \"\n  },\n  \"SortConfig\": {\n    \"Add column\": \"Lisää sarake\",\n    \"Search Columns\": \"Hae sarakkeita\",\n    \"Natural sort\": \"Luonnollinen järjestys\",\n    \"Update data\": \"Päivitä data\"\n  },\n  \"SortFilterConfig\": {\n    \"Save\": \"Tallenna\",\n    \"Filter\": \"SUODATA\",\n    \"Sort\": \"JÄRJESTÄ\",\n    \"Update Sort & Filter settings\": \"Päivitä järjestys- & suodatusasetukset\"\n  },\n  \"ThemeConfig\": {\n    \"Appearance \": \"Ulkoasu \"\n  },\n  \"Tools\": {\n    \"Delete\": \"Poista\",\n    \"Validate Data\": \"Validoi data\",\n    \"Settings\": \"Asetukset\",\n    \"API console\": \"API-konsoli\",\n    \"Code view\": \"Koodinäkymä\",\n    \"Access Rules\": \"Pääsysäännöt\",\n    \"Document history\": \"Dokumentin historia\",\n    \"Raw data\": \"Raakadata\",\n    \"TOOLS\": \"TYÖKALUT\"\n  },\n  \"TriggerFormulas\": {\n    \"OK\": \"OK\",\n    \"Any field\": \"Mikä tahansa kenttä\",\n    \"Cancel\": \"Peruuta\",\n    \"Close\": \"Sulje\",\n    \"Current field \": \"Nykyinen kenttä \"\n  },\n  \"TopBar\": {\n    \"Manage team\": \"Hallitse tiimiä\"\n  },\n  \"TypeTransformation\": {\n    \"Apply\": \"Toteuta\",\n    \"Cancel\": \"Peruuta\",\n    \"Preview\": \"Esikatselu\",\n    \"Update formula (Shift+Enter)\": \"Päivitä kaava (Shift+Enter)\"\n  },\n  \"UserManagerModel\": {\n    \"Editor\": \"Muokkain\",\n    \"Owner\": \"Omistaja\",\n    \"View & edit\": \"Näytä & muokkaa\",\n    \"View only\": \"Näytä ainoastaan\"\n  },\n  \"ViewLayoutMenu\": {\n    \"Edit card layout\": \"Muokkaa korttiasettelua\",\n    \"Open configuration\": \"Avaa määritys\",\n    \"Add to page\": \"Lisää sivulle\",\n    \"Show raw data\": \"Näytä raakadata\",\n    \"Widget options\": \"Widgettivalinnat\",\n    \"Copy anchor link\": \"Kopioi ankkurilinkki\",\n    \"Delete record\": \"Poista tietue\",\n    \"Delete widget\": \"Poista widgetti\",\n    \"Download as CSV\": \"Lataa CSV-muodossa\",\n    \"Download as XLSX\": \"Lataa XLSX-muodossa\",\n    \"Collapse widget\": \"Supista widgetti\",\n    \"Create a form\": \"Luo lomake\",\n    \"Advanced sort & filter\": \"Edistynyt järjestys & suodatus\"\n  },\n  \"ViewSectionMenu\": {\n    \"Custom options\": \"Mukautetut valinnat\",\n    \"(customized)\": \"(mukautettu)\",\n    \"(empty)\": \"(tyhjä)\",\n    \"(modified)\": \"(muokattu)\",\n    \"Save\": \"Tallenna\",\n    \"SORT\": \"JÄRJESTÄ\",\n    \"Update Sort&Filter settings\": \"Päivitä järjestys- & suodatusasetukset\",\n    \"FILTER\": \"SUODATA\"\n  },\n  \"WelcomeQuestions\": {\n    \"What brings you to Grist? Please help us serve you better.\": \"Mikä tuo sinut Gristin pariin? Auta meitä palvelemaan sinua entistä paremmin.\",\n    \"Education\": \"Koulutus\",\n    \"Finance & Accounting\": \"Talous ja kirjanpito\",\n    \"HR & Management\": \"Henkilöstöhallinto\",\n    \"IT & Technology\": \"IT ja teknologia\",\n    \"Marketing\": \"Markkinointi\",\n    \"Media Production\": \"Mediatuotanto\",\n    \"Other\": \"Muu\",\n    \"Product Development\": \"Tuotekehitys\",\n    \"Research\": \"Tutkimus\",\n    \"Sales\": \"Myynti\",\n    \"Type here\": \"Kirjoita tähän\",\n    \"Welcome to Grist!\": \"Tervetuloa Gristiin!\"\n  },\n  \"VisibleFieldsConfig\": {\n    \"Clear\": \"Tyhjennä\",\n    \"Show {{label}}\": \"Näytä {{label}}\",\n    \"Select all\": \"Valitse kaikki\",\n    \"Hide {{label}}\": \"Piilota {{label}}\",\n    \"Hidden {{label}}\": \"Piilotettu {{label}}\"\n  },\n  \"ColumnInfo\": {\n    \"Cancel\": \"Peruuta\",\n    \"Save\": \"Tallenna\",\n    \"COLUMN DESCRIPTION\": \"SARAKKEEN KUVAUS\",\n    \"COLUMN LABEL\": \"SARAKKEEN NIMIKE\"\n  },\n  \"sendToDrive\": {\n    \"Sending file to Google Drive\": \"Lähetetään tiedostoa Google Driveen\"\n  },\n  \"ACLUsers\": {\n    \"Example Users\": \"Esimerkkikäyttäjät\",\n    \"Users from table\": \"Käyttäjät taulusta\"\n  },\n  \"TypeTransform\": {\n    \"Cancel\": \"Peruuta\",\n    \"Preview\": \"Esikatselu\",\n    \"Apply\": \"Toteuta\"\n  },\n  \"ConditionalStyle\": {\n    \"Add another rule\": \"Lisää toinen sääntö\",\n    \"Add conditional style\": \"Lisää ehdollinen tyyli\",\n    \"Error in style rule\": \"Vihe tyylisäännössä\",\n    \"Row style\": \"Rivin tyyli\",\n    \"Rule must return True or False\": \"Säännön tulee palauttaa True tai False\"\n  },\n  \"CurrencyPicker\": {\n    \"Invalid currency\": \"Virheellinen valuutta\"\n  },\n  \"DiscussionEditor\": {\n    \"Comment\": \"Kommetti\",\n    \"Edit\": \"Muokkaa\",\n    \"Open\": \"Avaa\",\n    \"Marked as resolved\": \"Merkitty selvitetyksi\",\n    \"Remove\": \"Poista\",\n    \"Reply\": \"Vastaa\",\n    \"Cancel\": \"Peruuta\",\n    \"Reply to a comment\": \"Vastaa kommenttiin\",\n    \"Resolve\": \"Selvitä\",\n    \"Save\": \"Tallenna\",\n    \"Show resolved comments\": \"Näytä selvitetyt kommentit\",\n    \"Showing last {{nb}} comments\": \"Näytetään viimeisimmät {{nb}} kommenttia\",\n    \"Started discussion\": \"Keskustelu aloitettu\",\n    \"Write a comment\": \"Kirjoita kommentti\"\n  },\n  \"WidgetTitle\": {\n    \"Cancel\": \"Peruuta\",\n    \"Provide a table name\": \"Anna taululle nimi\",\n    \"Save\": \"Tallenna\",\n    \"DATA TABLE NAME\": \"DATATAULUN NIMI\",\n    \"WIDGET TITLE\": \"WIDGETIN OTSIKKO\",\n    \"WIDGET DESCRIPTION\": \"WIDGETIN KUVAUS\"\n  },\n  \"FormulaEditor\": {\n    \"Expand Editor\": \"Laajenna muokkain\",\n    \"Column or field is required\": \"Sarake tai kenttä on pakollinen\",\n    \"Error in the cell\": \"Virhe solussa\",\n    \"Errors in all {{numErrors}} cells\": \"Virheitä kaikissa {{numErrors}} solussa\"\n  },\n  \"NumericTextBox\": {\n    \"Currency\": \"Valuutta\",\n    \"Decimals\": \"Desimaalit\",\n    \"Number Format\": \"Numeromuoto\",\n    \"Default currency ({{defaultCurrency}})\": \"OIetusvaluutta ({{defaultCurrency}})\"\n  },\n  \"WelcomeTour\": {\n    \"Add new\": \"Lisää uusi\",\n    \"Configuring your document\": \"Määritetään dokumenttia\",\n    \"Customizing columns\": \"Mukautetaan sarakkeita\",\n    \"Make it relational! Use the {{ref}} type to link tables. \": \"Tee siitä relationaalista! Käytä {{ref}}-tyyppiä linkittääksesi tauluja. \",\n    \"Reference\": \"Viite\",\n    \"Share\": \"Jaa\",\n    \"Sharing\": \"Jakaminen\",\n    \"Welcome to Grist!\": \"Tervetuloa Gristiin!\",\n    \"Double-click or hit {{enter}} on a cell to edit it. \": \"Kaksoisnapsauta tai paina {{enter}} solussa muokataksesi sitä. \",\n    \"Editing Data\": \"Muokataan dataa\",\n    \"Help Center\": \"Ohjekeskus\",\n    \"Use the Share button ({{share}}) to share the document or export data.\": \"Käytä Jaa-painiketta ({{share}}) jakaaksesi dokumentin tai viedäksesi datan.\",\n    \"template library\": \"mallipohjakirjasto\"\n  },\n  \"GristTooltips\": {\n    \"Updates every 5 minutes.\": \"Päivittyy 5 minuutin välein.\",\n    \"Anchor Links\": \"Ankkurilinkit\",\n    \"Custom Widgets\": \"Mukautetut widgetit\",\n    \"Calendar\": \"Kalenteri\",\n    \"Forms are here!\": \"Lomakkeet ovat täällä!\",\n    \"Learn more\": \"Lue lisää\",\n    \"Editing Card Layout\": \"Muokataan korttiasettelua\",\n    \"Reference Columns\": \"Viitesarakkeet\",\n    \"Selecting Data\": \"Datan valinta\",\n    \"Add new\": \"Lisää uusi\",\n    \"Click the Add new button to create new documents or workspaces, or import data.\": \"Napsauta \\\"Lisää uusi\\\"-painiketta luodaksesi uuden dokumentin tai työtilan, tai tuodaksesi dataa.\",\n    \"Linking Widgets\": \"Widgettien linkittäminen\",\n    \"You can filter by more than one column.\": \"Voit suodataa enemmän kuin yhden sarakkeen perusteella.\"\n  },\n  \"ChartView\": {\n    \"Pick a column\": \"Valitse sarake\"\n  },\n  \"CodeEditorPanel\": {\n    \"Access denied\": \"Käyttö estetty\",\n    \"Code View is available only when you have full document access.\": \"Koodinäkymä on käytettävissä vain silloin, kun sinulla täysi pääsy dokumenttiin.\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"Toteuta\",\n    \"Cancel\": \"Peruuta\",\n    \"Default cell style\": \"Solun oletustyyli\"\n  },\n  \"DataTables\": {\n    \"Click to copy\": \"Napsauta kopioidaksesi\",\n    \"Table ID copied to clipboard\": \"Taulun ID-tunniste kopioitu leikepöydälle\",\n    \"Remove table\": \"Poista taulu\",\n    \"Rename table\": \"Nimeä taulu uudelleen\",\n    \"Raw Data Tables\": \"Raakadatataulut\",\n    \"You do not have edit access to this document\": \"Sinulla ei ole muokkausoikeutta tähän dokumenttiin\"\n  },\n  \"DocPageModel\": {\n    \"Add empty table\": \"Lisää tyhjä taulu\",\n    \"Add page\": \"Lisää sivu\",\n    \"Add widget to page\": \"Lisää widgetti sivulle\",\n    \"Document owners can attempt to recover the document. [{{error}}]\": \"Dokumentin omistajat voivat yrittää palauttaa dokumentin. [{{error}}]\",\n    \"Reload\": \"Lataa uudelleen\",\n    \"You do not have edit access to this document\": \"Sinulla ei ole muokkausoikeutta tähän dokumenttiin\",\n    \"Sorry, access to this document has been denied. [{{error}}]\": \"Valitettavasti pääsy tähän dokumenttiin on estetty. [{{error}}]\"\n  },\n  \"FilterConfig\": {\n    \"Add column\": \"Lisää sarake\"\n  },\n  \"FilterBar\": {\n    \"SearchColumns\": \"Hae sarakkeita\",\n    \"Search Columns\": \"Hae sarakkeita\"\n  },\n  \"GristDoc\": {\n    \"Import from file\": \"Tuo tiedostosta\"\n  },\n  \"LeftPanelCommon\": {\n    \"Help Center\": \"Ohjekeskus\"\n  },\n  \"OnBoardingPopups\": {\n    \"Finish\": \"Valmis\",\n    \"Next\": \"Seuraava\"\n  },\n  \"OpenVideoTour\": {\n    \"Grist Video Tour\": \"Gristin videoesittely\",\n    \"Video Tour\": \"Videoesittely\",\n    \"YouTube video player\": \"YouTube-videosoitin\"\n  },\n  \"Pages\": {\n    \"Delete\": \"Poista\",\n    \"Delete data and this page.\": \"Poista data ja tämä sivu.\",\n    \"The following tables will no longer be visible_one\": \"Seuraava taulu ei ole enää näkyvissä\",\n    \"The following tables will no longer be visible_other\": \"Seuraavat taulut eivät enää ole näkyvissä\"\n  },\n  \"ViewAsBanner\": {\n    \"UnknownUser\": \"Tuntematon käyttäjä\"\n  },\n  \"ViewConfigTab\": {\n    \"Advanced settings\": \"Lisäasetukset\",\n    \"Edit card layout\": \"Muokkaa korttiasettelua\",\n    \"Form\": \"Lomake\",\n    \"Plugin: \": \"Liitännäinen: \"\n  },\n  \"breadcrumbs\": {\n    \"snapshot\": \"tilannevedos\",\n    \"unsaved\": \"tallentamaton\",\n    \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Voit tehdä muokkauksia, mutta niistä muodostuu uusi kopio,\\neivätkä muutokset vaikuta alkuperäiseen dokumenttiin.\"\n  },\n  \"menus\": {\n    \"Select fields\": \"Valitse kentät\",\n    \"Upgrade now\": \"Päivitä nyt\",\n    \"Numeric\": \"Numeerinen\",\n    \"Text\": \"Teksti\",\n    \"Integer\": \"Kokonaisluku\",\n    \"Date\": \"Päivä\",\n    \"Choice\": \"Valinta\",\n    \"Choice List\": \"Valintaluettelo\",\n    \"Attachment\": \"Liite\",\n    \"Search columns\": \"Hae sarakkeita\",\n    \"Toggle\": \"Kytkin\",\n    \"Reference\": \"Viite\",\n    \"Reference List\": \"Viiteluettelo\",\n    \"DateTime\": \"PäiväAika\"\n  },\n  \"CellStyle\": {\n    \"Cell style\": \"Solun tyyli\",\n    \"Default cell style\": \"Solun oletustyyli\",\n    \"CELL STYLE\": \"SOLUN TYYLI\"\n  },\n  \"EditorTooltip\": {\n    \"Convert column to formula\": \"Muunna sarake kaavioksi\"\n  },\n  \"FieldBuilder\": {\n    \"DATA FROM TABLE\": \"DATA TAULUSTA\",\n    \"CELL FORMAT\": \"SOLUN MUOTOILU\"\n  },\n  \"Reference\": {\n    \"SHOW COLUMN\": \"NÄYTÄ SARAKE\",\n    \"CELL FORMAT\": \"SOLUN MUOTOILU\"\n  },\n  \"LanguageMenu\": {\n    \"Language\": \"Kieli\"\n  },\n  \"DescriptionConfig\": {\n    \"DESCRIPTION\": \"KUVAUS\"\n  },\n  \"DescriptionTextArea\": {\n    \"DESCRIPTION\": \"KUVAUS\"\n  },\n  \"buildViewSectionDom\": {\n    \"No data\": \"Ei dataa\",\n    \"Not all data is shown\": \"Kaikkea dataa ei näytetä\"\n  },\n  \"FloatingEditor\": {\n    \"Collapse Editor\": \"Supista muokkain\"\n  },\n  \"FloatingPopup\": {\n    \"Maximize\": \"Suurenna\",\n    \"Minimize\": \"Pienennä\"\n  },\n  \"FormView\": {\n    \"Unpublish\": \"Lopeta julkaisu\",\n    \"Unpublish your form?\": \"Lopetetaanko lomakkeen julkaisu?\",\n    \"Publish\": \"Julkaise\",\n    \"Publish your form?\": \"Julkaistaanko lomake?\"\n  },\n  \"Editor\": {\n    \"Delete\": \"Poista\"\n  },\n  \"UnmappedFieldsConfig\": {\n    \"Clear\": \"Tyhjennä\",\n    \"Select all\": \"Valitse kaikki\"\n  },\n  \"FormConfig\": {\n    \"Required field\": \"Pakollinen kenttä\",\n    \"Field rules\": \"Kenttäsäännöt\"\n  },\n  \"ValidationPanel\": {\n    \"Update formula (Shift+Enter)\": \"Päivitä kaava (Shift+Enter)\"\n  },\n  \"ColumnEditor\": {\n    \"COLUMN DESCRIPTION\": \"SARAKKEEN KUVAUS\",\n    \"COLUMN LABEL\": \"SARAKKEEN NIMIKE\"\n  }\n}\n"
  },
  {
    "path": "static/locales/fi.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/fr.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Invite new member\": \"Inviter un nouveau membre\",\n        \"Enter email address\": \"Entrer l'adresse e-mail\",\n        \"We'll email an invite to {{email}}\": \"Nous allons envoyer une invitation à {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Checking...\": \"Vérification…\",\n        \"Saved\": \"Enregistré\",\n        \"Invalid\": \"Invalide\",\n        \"Save\": \"Enregistrer\",\n        \"Reset\": \"Réinitialiser\",\n        \"Add table rules\": \"Ajouter des règles pour la table\",\n        \"Add user attributes\": \"Ajouter des propriétés d'utilisateur\",\n        \"Users\": \"Utilisateurs\",\n        \"User Attributes\": \"Propriétés de l'utilisateur\",\n        \"Attribute to Look Up\": \"Propriété d'appairage\",\n        \"Lookup Table\": \"Table d'appairage\",\n        \"Lookup Column\": \"Colonne cible\",\n        \"Default rules\": \"Règles par défaut\",\n        \"Condition\": \"Condition\",\n        \"Permissions\": \"Droits\",\n        \"Rules for table \": \"Règles pour la table \",\n        \"Add column rule\": \"Ajouter une règle de colonne\",\n        \"Add Default Rule\": \"Ajouter une règle par défaut\",\n        \"Delete table rules\": \"Supprimer les règles de la table\",\n        \"Special rules\": \"Règles avancées\",\n        \"Allow everyone to view Access Rules.\": \"Autoriser tout le monde à voir les permissions avancées.\",\n        \"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.\": \"Permettre à tout le monde de copier le document entier ou de le voir en mode «bac à sable».\\nUtile pour faire des exemples et des modèles, mais pas pour des données sensibles.\",\n        \"Permission to view Access Rules\": \"Permission de voir les règles d'accès\",\n        \"Permission to access the document in full when needed\": \"Permission d'accéder au document dans son intégralité si nécessaire\",\n        \"Attribute name\": \"Nom de l’attribut\",\n        \"Everyone\": \"Tout le monde\",\n        \"Everyone Else\": \"Tous les autres\",\n        \"Type message to display when this rule blocks an action…\": \"Ajouter un message…\",\n        \"Enter Condition\": \"Entrer la condition\",\n        \"Remove {{- name }} user attribute\": \"Supprimer l'attribut utilisateur {{-name}}\",\n        \"Remove {{- tableId }} rules\": \"Supprimer les règles pour la table {{-tableId}}\",\n        \"View as\": \"Voir en tant que\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Supprimer la colonne {{-colId}} des règles de la table {{-tableId}}\",\n        \"Seed rules\": \"Règles préconfigurées\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Pour chaque ajout de règle pour une table, ajouter automatiquement une règle donnant tous les droits au groupe OWNER.\",\n        \"Permission to edit document structure\": \"Droits d'édition de la structure\",\n        \"This default should be changed if editors' access is to be limited. \": \"Cette valeur par défaut doit être modifiée si l'on souhaite limiter l'accès des éditeurs. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Autorise les éditeurs à éditer la structure (modifier/supprimer des tables, colonnes, mises en page) et à écrire des formules. Les formules peuvent être éditées et donner accès à l'ensemble des données, quelles que soient les droits d'accès au niveau des tables ou colonnes.\",\n        \"Add table-wide rule\": \"Ajouter une règle pour l'ensemble du tableau\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Les règles d'accès ont changé. Cliquez sur Annuler pour retirer vos changements et rafraîchir les règles.\",\n        \"All\": \"Toutes\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"La colonne {{colId}} apparaît dans plusieurs règles pour la table {{tableId}} dont l'application peut dépendre de leur ordre. Peut-être devriez-vous essayer de découper les règles autrement ?\",\n        \"Columns\": \"Colonnes\",\n        \"Condition cannot be blank\": \"La condition ne peut pas être vide\",\n        \"Default resource missing in resource map\": \"La ressource par défaut n'est pas présente parmi les ressources disponibles\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Colonnes invalides dans la table {{tableId}} : {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"Table invalide : {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Règle d'attribut utilisateur invalide : {{prop}} doit être défini\",\n        \"Invalid user attribute to look up\": \"L'attribut d'utilisateur à rechercher est invalide\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"Aucune colonne listée dans une règle de colonne pour la table {{tableId}}\",\n        \"Not a valid user attribute\": \"Attribut d'utilisateur non valide\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Ressource absente parmi les ressources disponibles : {{resourceKey}}\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"Tentative d'ajout de règles de table pour la table existante {{tableId}}\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"Utilisez un simple attribut de user.LinkKey, par exemple user.LinkKey.quelquechose\",\n        \"hidden\": \"caché\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Règles d'accès\\n\\nVous n'avez pas la permission de voir ou modifier les règles d'accès pour ce document.\",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Règles d'accès\\n\\nLes accès de base à ce document sont configurés en utilisant l'option 'Gérer les utilisateurs' dans le menu 'Partager', où vous pouvez assigner à vos collaborateurs des rôles tels que Propriétaire, Éditeur, ou Lecture seule.\\n\\nPour un controle plus fin, vous pouvez créer des Permissions Avancées pour limiter qui peut voir ou éditer\\ndes tables, colonnes, or lignes spécifiques — utile pour les données sensibles ou les permissions basées sur des rôles.\\n[En savoir plus.]({{helpAccessRules}})\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Règles spéciales** (développez chaque règle pour personnaliser à qui elle s'applique.)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"Après avoir désactivé les Permissions Avancées, les Éditeurs pourront changer la structure du document et modifier les formules. Les Éditeurs et les Spectateurs pourront voir toutes les données du document et également le copier et le télécharger.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"Après avoir activé les Permissions Avancées, les Éditeurs ne pourront plus modifier la structure du\\ndocument ou modifier les formules. Seuls les Propriétaires pourront copier ou télécharger le document.\\n\\nCes paramètres peuvent être modifier dans les 'Règles spéciales''.\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"Autoriser les Éditeurs à modifier la structure (ex. modifier at supprimer les tables, colonnes et dispositions) et écrire des formules. Important : Si cette option est cochée, les Éditeurs pourront modifier les formules, qui peuvent accéder à toutes les données, malgré les Permissions Avancées sur les tables ou les colonnes !\",\n        \"Allow everyone to view access rules.\": \"Autoriser tout le monde à voir les permissions avancées.\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Contourner toutes les restrictions de lecture et autoriser tout le monde à copier l'entièreté du document, ou le voir en entier en mode bac à sable. À n'utiliser que pour les exemples ou les modèles, pas pour les documents avec des données sensibles.\",\n        \"Continue\": \"Continuer\",\n        \"Disable Access Rules\": \"Désactiver les Permissions Avancées\",\n        \"Disable and save\": \"Désactiver et savegarder\",\n        \"Enable Access Rules\": \"Activer les Permissions Avancées\",\n        \"Permission to access the document in full by all users\": \"Permission d'accéder à l'entièreté du document pour tous les utilisatrices et utilisateurs\",\n        \"Permission to access the document in full by unrestricted users\": \"Permission d'accéder à l'entièreté du document pour les utilisateurs non soumis à une permission avancée\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Empêche les non-Propriétaires de copier ou télécharger l'entièreté du document. N.B. : cela n'affecte que les utilisateurs sans restriction de lecture, comme les autres seront déjà empêchés sans tenir compte de ce réglage.\",\n        \"Special rules for templates\": \"Règle spéciale pour les modèles\",\n        \"This options should be off if Editors' access is to be limited. \": \"Cette option ne devrait pas être activée si les accès des Éditeurs doivent être limités. \"\n    },\n    \"AccountPage\": {\n        \"Account settings\": \"Paramètres du compte\",\n        \"API\": \"API\",\n        \"Edit\": \"Modifier\",\n        \"Email\": \"E-mail\",\n        \"Name\": \"Nom\",\n        \"Save\": \"Enregistrer\",\n        \"Password & security\": \"Mot de passe et sécurité\",\n        \"Login method\": \"Mode de connexion\",\n        \"Change password\": \"Modifier le mot de passe\",\n        \"Allow signing in to this account with Google\": \"Autoriser la connexion à ce compte avec Google\",\n        \"Two-factor authentication\": \"Authentification à deux facteurs\",\n        \"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.\": \"L'authentification à double facteur est une couche supplémentaire de sécurité pour votre compte Grist qui permet de s'assurer que vous êtes la seule personne qui peut accéder à votre compte, même si quelqu'un d'autre connaît votre mot de passe.\",\n        \"Theme\": \"Thème\",\n        \"API Key\": \"Clé d’API\",\n        \"Names only allow letters, numbers and certain special characters\": \"Les noms d'utilisateur ne doivent contenir que des lettres, des chiffres et certains caractères spéciaux\",\n        \"Language\": \"Langue\"\n    },\n    \"AccountWidget\": {\n        \"Sign in\": \"Se connecter\",\n        \"Document settings\": \"Paramètres du document\",\n        \"Toggle Mobile Mode\": \"Activer/Désactiver le mode mobile\",\n        \"Pricing\": \"Tarifs\",\n        \"Profile settings\": \"Paramètres du compte\",\n        \"Manage team\": \"Gestion de l'équipe\",\n        \"Access Details\": \"Informations d’accès\",\n        \"Switch Accounts\": \"Changer de compte\",\n        \"Accounts\": \"Comptes\",\n        \"Add account\": \"Ajouter un compte\",\n        \"Sign out\": \"Se déconnecter\",\n        \"Upgrade Plan\": \"Changer d'offre\",\n        \"Support Grist\": \"Soutenir Grist\",\n        \"Billing account\": \"Facturation\",\n        \"Activation\": \"Activer\",\n        \"Sign up\": \"S'inscrire\",\n        \"Use This Template\": \"Utiliser ce modèle\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"Impossible de charger le journal des actions\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"La table {{tableId}} a été ensuite supprimée dans l'action #{{actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Cette ligne a été ensuite supprimée dans l'action {{action.actionNum}}\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"La colonne {{colId}} a ensuite été supprimée dans l'action #{{action.actionNum}}\",\n        \"All tables\": \"Toutes les tables\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"La colonne {{colId}} a ensuite été supprimée dans l'action #{{actionNum}}\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Cette ligne a été ensuite supprimée dans l'action {{actionNum}}\",\n        \"History blocked because of access rules.\": \"Historique non accessible à cause des permissions avancées.\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Nouveau\"\n    },\n    \"ApiKey\": {\n        \"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?\": \"Vous êtes sur le point de supprimer une clé API. Cela causera le rejet de toutes les requêtes futures utilisant cette clé API. Voulez-vous toujours la supprimer ?\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Cette clé API peut être utilisée pour accéder à ce compte de manière anonyme via l'API.\",\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"En générant une clé API, vous pourrez faire des appels API pour votre propre compte.\",\n        \"Click to show\": \"Cliquer pour afficher\",\n        \"Create\": \"Créer\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Cette clé API peut être utilisée pour accéder à votre compte via l'API. Ne partagez pas votre clé API avec qui que ce soit.\",\n        \"Remove\": \"Supprimer\",\n        \"Remove API Key\": \"Supprimer la clé d'API\"\n    },\n    \"App\": {\n        \"Description\": \"Description\",\n        \"Key\": \"Clé\",\n        \"Memory Error\": \"Erreur mémoire\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Traducteurs : merci de ne traduire ceci que lorsque votre langue sera prête à être proposée aux utilisateurs\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Page d’accueil\",\n        \"Legacy\": \"Ancienne version\",\n        \"Personal Site\": \"Espace personnel\",\n        \"Team Site\": \"Espace d'équipe\",\n        \"Grist Templates\": \"Modèles Grist\",\n        \"Billing account\": \"Compte de facturation\",\n        \"Manage team\": \"Gestion de l'équipe\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Retour à l'accueil\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Le site de cette équipe est suspendu. Les documents peuvent être lus, mais pas modifiés.\"\n    },\n    \"CellContextMenu\": {\n        \"Reset {{count}} entire columns_one\": \"Réinitialiser la colonne entière\",\n        \"Reset {{count}} entire columns_other\": \"Réinitialiser ces {{count}} colonnes entières\",\n        \"Reset {{count}} columns_one\": \"Réinitialiser la colonne\",\n        \"Reset {{count}} columns_other\": \"Réinitialiser {{count}} colonnes\",\n        \"Delete {{count}} columns_one\": \"Supprimer la colonne\",\n        \"Delete {{count}} columns_other\": \"Supprimer {{count}} colonnes\",\n        \"Delete {{count}} rows_one\": \"Supprimer la ligne\",\n        \"Delete {{count}} rows_other\": \"Supprimer {{count}} lignes\",\n        \"Clear values\": \"Effacer les valeurs\",\n        \"Clear cell\": \"Effacer la cellule\",\n        \"Copy anchor link\": \"Copier l'ancre\",\n        \"Filter by this value\": \"Filtrer par cette valeur\",\n        \"Insert row\": \"Insérer une ligne\",\n        \"Insert row above\": \"Insérer une ligne au-dessus\",\n        \"Insert row below\": \"Insérer une ligne au-dessous\",\n        \"Duplicate rows_one\": \"Dupliquer la ligne\",\n        \"Duplicate rows_other\": \"Dupliquer les lignes\",\n        \"Insert column to the right\": \"Insérer une colonne à droite\",\n        \"Insert column to the left\": \"Insérer une colonne à gauche\",\n        \"Copy\": \"Copier\",\n        \"Comment\": \"Commenter\",\n        \"Cut\": \"Couper\",\n        \"Paste\": \"Coller\",\n        \"Copy with headers\": \"Copier avec les entêtes\"\n    },\n    \"ChartView\": {\n        \"Each Y series is followed by a series for the length of error bars.\": \"Chaque série Y est suivi par une série correspondant à la longueur des barres d'erreur.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Chaque série Y est suivie par deux séries, pour les barres d'erreur d'en haut et d'en bas.\",\n        \"Create separate series for each value of the selected column.\": \"Créer une série séparée pour chaque valeur de la colonne sélectionnée.\",\n        \"Pick a column\": \"Choisir une colonne\",\n        \"selected new group data columns\": \"nouveau groupe de colonnes sélectionné\",\n        \"Toggle chart aggregation\": \"Activer/Désactiver l'agrégation des graphiques\",\n        \"Orientation\": \"Orientation\",\n        \"Vertical\": \"Vertical\",\n        \"Horizontal\": \"Horizontal\",\n        \"Split series\": \"Décomposer les séries\",\n        \"None\": \"Aucune\",\n        \"Split Series\": \"Décomposer les séries\",\n        \"Remove\": \"Retirer\",\n        \"Symmetric\": \"Symétrique\",\n        \"Connect gaps\": \"Relier les points manquants\",\n        \"Bar chart\": \"Graphique en barres\",\n        \"Donut chart\": \"Graphique en anneau\",\n        \"Area chart\": \"Graphique en aires\",\n        \"Line chart\": \"Graphique en courbes\",\n        \"Scatter plot\": \"Nuages de points\",\n        \"Invert Y-axis\": \"Valeurs dans l’ordre inverse\",\n        \"Show total\": \"Afficher le total\",\n        \"Text size\": \"Taille du texte\",\n        \"Aggregate values\": \"Regrouper par valeurs identiques\",\n        \"SERIES\": \"SERIES\",\n        \"Add series\": \"Ajouter une série\",\n        \"non-numeric columns are not shown\": \"Les colonnes non numériques ne sont pas affichées\",\n        \"non-numeric column is not shown\": \"La colonne non numérique n’est pas affichée\",\n        \"Error bars\": \"Barres d’erreur\",\n        \"Kaplan-Meier plot\": \"Courbe de survie\",\n        \"Show markers\": \"Afficher les points\",\n        \"X-AXIS\": \"AXE HORIZONTAL\",\n        \"Log scale Y-axis\": \"Échelle logarithmique\",\n        \"Hole size\": \"Taille de l’anneau\",\n        \"Stack series\": \"Empiler les séries\",\n        \"Above+Below\": \"Asymétrique\",\n        \"selected new x-axis\": \"Nouvel axe horizontal sélectionné\",\n        \"Pie chart\": \"Diagramme circulaire\",\n        \"LABEL\": \"LABEL\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Accès refusé\",\n        \"Code View is available only when you have full document access.\": \"La vue code n’est accessible que lorsque vous avez un accès complet au document.\"\n    },\n    \"ColorSelect\": {\n        \"Default cell style\": \"Style de cellule par défaut\",\n        \"Apply\": \"Appliquer\",\n        \"Cancel\": \"Annuler\"\n    },\n    \"ColumnFilterMenu\": {\n        \"Filter by Range\": \"Filtrer par intervalle\",\n        \"Search\": \"Rechercher\",\n        \"Search values\": \"Chercher\",\n        \"All\": \"Tous\",\n        \"All shown\": \"Ces valeurs\",\n        \"All except\": \"Pas ces valeurs\",\n        \"None\": \"Aucun\",\n        \"No matching values\": \"Aucune valeur trouvée\",\n        \"Other Matching\": \"Autres correspondances\",\n        \"Other Non-Matching\": \"Autres non-correspondances\",\n        \"Other values\": \"Autres valeurs\",\n        \"Future values\": \"Futures valeurs\",\n        \"Others\": \"Autres\",\n        \"Min\": \"Min\",\n        \"Max\": \"Max\",\n        \"Start\": \"Début\",\n        \"End\": \"Fin\",\n        \"Clear search\": \"Effacer la recherche\",\n        \"Pin filter\": \"Épingler le filtre\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Trier par ordre alphabétique (actuellement : trié par nombre d'occurrences)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Trier par nombre d'occurrences (actuellement : trié par ordre alphabétique)\",\n        \"Unpin filter\": \"Désépingler le filtre\"\n    },\n    \"CustomSectionConfig\": {\n        \"Add\": \"Ajouter\",\n        \"Enter Custom URL\": \"Entrer une URL personnalisée\",\n        \"Full document access\": \"Accès complet au document\",\n        \"Learn more about custom widgets\": \"En savoir plus sur les vues personnalisées\",\n        \"Pick a column\": \"Choisir une colonne\",\n        \"Pick a {{columnType}} column\": \"Choisir une colonne de type {{columnType}}\",\n        \"No document access\": \"Pas d’accès au document\",\n        \"Open configuration\": \"Ouvrir la configuration\",\n        \" (optional)\": \" (facultatif)\",\n        \"Read selected table\": \"Lire les données source sélectionnées\",\n        \"Select Custom Widget\": \"Sélectionner une vue personnalisée\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Le widget a besoin de {{fullAccess}} à ce document.\",\n        \"Widget needs to {{read}} the current table.\": \"Le widget a besoin de {{read}} la table actuelle.\",\n        \"Widget does not require any permissions.\": \"La vue ne nécessite aucune autorisation.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} colonnes non-{{columnType}} masquées\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} colonnes de type non-{{columnType}} masquées\",\n        \"No {{columnType}} columns in table.\": \"Pas de colonne de type {{columnType}} dans la table.\",\n        \"Clear selection\": \"Tout désélectionner\",\n        \"ACCESS LEVEL\": \"NIVEAU D'ACCÈS\",\n        \"Accept\": \"Accepter\",\n        \"Custom URL\": \"URL personnalisée\",\n        \"Developer:\": \"Développeur/se :\",\n        \"Missing description and author information.\": \"Informations sur la description et sur l'auteur/rice manquantes.\",\n        \"Last updated:\": \"Dernière mise à jour :\",\n        \"Reject\": \"Refuser\",\n        \"Widget\": \"Widget\",\n        \"Change custom widget\": \"Changer la vue personnalisée\"\n    },\n    \"DataTables\": {\n        \"Raw Data Tables\": \"Données sources\",\n        \"Click to copy\": \"Cliquez ici pour copier\",\n        \"Table ID copied to clipboard\": \"Identifiant de table copié\",\n        \"Duplicate table\": \"Dupliquer la table\",\n        \"You do not have edit access to this document\": \"Vous n’avez pas accès en écriture à ce document\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Supprimer les données de {{formattedTableName}} et les supprimer de toutes les pages ?\",\n        \"Edit record card\": \"Modifier la vue fiche\",\n        \"Rename table\": \"Renommer la table\",\n        \"{{action}} Record Card\": \"{{action}} la vue fiche\",\n        \"Record Card\": \"Vue fiche\",\n        \"Remove table\": \"Supprimer la table\",\n        \"Record Card Disabled\": \"Vue fiche désactivée\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Activité\",\n        \"Snapshots\": \"Instantanés\",\n        \"Open snapshot\": \"Ouvrir cet instantané\",\n        \"Compare to current\": \"Comparer au document en cours\",\n        \"Compare to previous\": \"Comparer au précédent\",\n        \"Beta\": \"Bêta\",\n        \"Snapshots are unavailable.\": \"Les sauvegardes ne sont pas disponibles.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Seuls les propriétaires ont accès aux instantanés des documents soumis à des règles d'accès.\"\n    },\n    \"DocMenu\": {\n        \"Other Sites\": \"Autres espaces\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Tu es sur l'espace de {{siteName}}. Tu as aussi accès aux espaces suivants :\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Tu es sur ton espace personnel. Tu as aussi accès aux espaces suivants :\",\n        \"All documents\": \"Tous les documents\",\n        \"Examples and Templates\": \"Exemples et modèles\",\n        \"More Examples and Templates\": \"Plus d’exemples et de modèles\",\n        \"This service is not available right now\": \"Ce service n'est pas disponible pour le moment\",\n        \"(The organization needs a paid plan)\": \"(L'organisation a besoin d'un plan payant)\",\n        \"Pinned Documents\": \"Documents épinglés\",\n        \"Featured\": \"À la une\",\n        \"Trash\": \"Corbeille\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Les documents restent dans la corbeille pendant 30 jours, après quoi ils seront supprimés définitivement.\",\n        \"Trash is empty.\": \"La corbeille est vide.\",\n        \"Workspace not found\": \"Dossier introuvable\",\n        \"Delete\": \"Supprimer\",\n        \"Delete {{name}}\": \"Supprimer « {{name}}»\",\n        \"Deleted {{at}}\": \"Supprimé {{at}}\",\n        \"Edited {{at}}\": \"Modifié {{at}}\",\n        \"Examples & Templates\": \"Exemples et modèles\",\n        \"Discover More Templates\": \"Découvrir plus de modèles\",\n        \"By Name\": \"Par nom\",\n        \"By Date Modified\": \"Par date de modification\",\n        \"Document will be moved to Trash.\": \"Le document sera déplacé vers la corbeille.\",\n        \"Rename\": \"Renommer\",\n        \"Move\": \"Déplacer\",\n        \"Remove\": \"Supprimer\",\n        \"Unpin Document\": \"Désépingler le document\",\n        \"Pin Document\": \"Épingler le document\",\n        \"Access Details\": \"Informations d’accès\",\n        \"Manage users\": \"Gérer les utilisateurs\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Supprimer définitivement « {{name}} » ?\",\n        \"Delete Forever\": \"Supprimer définitivement\",\n        \"Document will be permanently deleted.\": \"Le document sera supprimé définitivement.\",\n        \"Restore\": \"Restaurer\",\n        \"To restore this document, restore the workspace first.\": \"Pour restaurer ce document, il faut restaurer le dossier d'abord.\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Vous pouvez supprimer définitivement un dossier une fois qu’il ne contient plus de documents.\",\n        \"Current workspace\": \"Dossier courant\",\n        \"Requires edit permissions\": \"Nécessite des droits d'édition\",\n        \"Move {{name}} to workspace\": \"Déplacer {{name}} vers le dossier\",\n        \"Any documents created in this site will appear here.\": \"Tout document créé dans cet espace apparaîtra ici.\",\n        \"Create my first document\": \"Créer mon premier document\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Vous avez accès en lecture seule à cet espace. Actuellement il n'y a aucun document.\",\n        \"personal site\": \"espace personnel\",\n        \"Grid view\": \"Vue icônes\",\n        \"List view\": \"Vue liste\"\n    },\n    \"DocPageModel\": {\n        \"Error accessing document\": \"Erreur lors de l'accès au document\",\n        \"Reload\": \"Recharger\",\n        \"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}}]\": \"Vous pouvez essayer de recharger le document ou de le passer mode récupération. Le mode de récupération ouvre le document pour être entièrement accessible aux propriétaires, et inaccessible aux autres. Il désactive également les formules. [{{error}}]\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Désolé, l’accès à ce document a été refusé. [{{error}}]\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Les propriétaires de documents peuvent tenter de récupérer le document. [{{error}}]\",\n        \"Enter recovery mode\": \"Passer en mode récupération\",\n        \"Add page\": \"Ajouter une page\",\n        \"Add widget to page\": \"Ajouter une vue à la page\",\n        \"Add empty table\": \"Ajouter une table vide\",\n        \"You do not have edit access to this document\": \"Vous n’avez pas accès en écriture à ce document\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Veuillez recharger le document et si l'erreur persiste, contactez les propriétaires du document pour tenter une récupération du document. [{{error}}]\"\n    },\n    \"DocTour\": {\n        \"No valid document tour\": \"Visite de document invalide\",\n        \"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.\": \"Impossible de construire un index à partir des données de ce document. Assurez-vous qu'il y ai bien une table nommée GristDocTour, avec des colonnes Title, Body, Placement et Location.\"\n    },\n    \"DocumentSettings\": {\n        \"Document settings\": \"Paramètres du document\",\n        \"This document's ID (for API use):\": \"ID du document (pour l’API seulement) :\",\n        \"Time Zone:\": \"Fuseau horaire :\",\n        \"Locale:\": \"Langue :\",\n        \"Currency:\": \"Devise :\",\n        \"Local currency ({{currency}})\": \"Devise locale ({{currency}})\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Moteur (expérimental {{span}} changez à vos risques et périls) :\",\n        \"Save\": \"Enregistrer\",\n        \"Save and Reload\": \"Enregistrer et recharger\",\n        \"Document ID copied to clipboard\": \"Identifiant de document copié\",\n        \"API\": \"API\",\n        \"Ok\": \"OK\",\n        \"Manage Webhooks\": \"Gérer les points d’ancrage Web\",\n        \"Webhooks\": \"Points d’ancrage Web (Webhooks)\",\n        \"API console\": \"Console de l'API\",\n        \"Reload\": \"Recharger\",\n        \"Python\": \"Python\",\n        \"API URL copied to clipboard\": \"URL de l'API copié\",\n        \"API documentation.\": \"Documentation de l'API.\",\n        \"Coming soon\": \"Prochainement\",\n        \"Copy to clipboard\": \"Copier dans le presse-papier\",\n        \"Currency\": \"Devise\",\n        \"Data engine\": \"Moteur de données\",\n        \"Default for DateTime columns\": \"Valeur par défaut pour les colonnes Date et Heure\",\n        \"Document ID\": \"ID du Document\",\n        \"For currency columns\": \"Pour les colonnes de devises\",\n        \"Hard reset of data engine\": \"Réinitialisation du moteur de données\",\n        \"Locale\": \"Langue\",\n        \"For number and date formats\": \"Pour les colonnes de nombre et date\",\n        \"Base doc URL: {{docApiUrl}}\": \"URL de base pour ce document : {{docApiUrl}}\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"ID du document à utiliser lorsque l'API REST fait appel à {{docId}}. Voir {{apiURL}}\",\n        \"Python version used\": \"Version de Python utilisée\",\n        \"Notify other services on doc changes\": \"Informer d'autres services des changements du document\",\n        \"Manage webhooks\": \"Gérer les points d'ancrage Web\",\n        \"ID for API use\": \"ID pour l'utilisation de l'API\",\n        \"Find slow formulas\": \"Trouver les formules lentes\",\n        \"python2 (legacy)\": \"python2 (ancien)\",\n        \"Try API calls from the browser\": \"Essayer les appels API à partir du navigateur\",\n        \"Time zone\": \"Fuseau horaire\",\n        \"python3 (recommended)\": \"python3 (recommandé)\",\n        \"Start timing\": \"Début du chrono\",\n        \"Time reload\": \"Durée du rechargement\",\n        \"Stop timing...\": \"Arrêter le chrono...\",\n        \"Cancel\": \"Annuler\",\n        \"Reload data engine\": \"Recharger le moteur de données\",\n        \"Reload data engine?\": \"Recharger le moteur de données ?\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Forcer le rechargement du document pendant le chronométrage des formules et afficher le résultat.\",\n        \"Formula timer\": \"Chronomètre de formule\",\n        \"Timing is on\": \"Le chronomètre tourne\",\n        \"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.\",\n        \"Formula times\": \"Minuteur de formule\",\n        \"Only available to document editors\": \"Accessible uniquement aux éditeurs du document\",\n        \"Only available to document owners\": \"Accessible uniquement aux propriétaires du document\",\n        \"Edit\": \"Modifier\",\n        \"Template\": \"Modèle\",\n        \"Tutorial\": \"Tutoriel\",\n        \"Confirm change\": \"Confirmer les modifications\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Une fois le chronométrage lancé, Grist mesure le temps nécessaire à l'évaluation de chaque formule. Cela permet de diagnostiquer les formules responsables du ralentissement des performances lors de la première ouverture d'un document ou lorsqu'un document est modifié.\",\n        \"Template mode\": \"Type de document\",\n        \"Change document type\": \"Changer le type de document\",\n        \"Change nature of document\": \"Changer la nature du document\",\n        \"Regular document\": \"Document classique\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Comportement normal du document. Tous les utilisateurs travaillent sur la même copie du document.\",\n        \"Regular\": \"Classique\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Le document s'ouvre automatiquement dans {{fiddleModeDocUrl}}. N'importe qui peut le modifier, ce qui créera une nouvelle copie non enregistrée.\",\n        \"fiddle mode\": \"mode fiddle\",\n        \"Document automatically opens as a user-specific copy.\": \"Le document s'ouvre automatiquement en tant que copie spécifique à l'utilisateur.\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Cela effectuera un rechargement complet du moteur de données. Cela peut être utile si le moteur de données est bloqué dans une boucle infinie, traite indéfiniment la dernière modification ou en cas de crash. Aucune donnée ne sera perdue, à l'exception possible des actions actuellement en attente.\",\n        \"Attachment storage\": \"Stockage des pièces jointes\",\n        \"**Some existing attachments are still external**.\": \"**Certaines pièces jointes existantes sont toujours externes**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Certaines pièces jointes existantes sont toujours internes** (stockées dans le fichier SQLite).\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Les pièces jointes nouvellement téléchargées seront placées dans le stockage interne.\",\n        \"Being transfer\": \"Transfert en cours\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Cliquez sur « Démarrer le transfert » pour les transférer vers le stockage externe.\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Les pièces jointes nouvellement téléchargées seront placées dans le stockage externe.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Cliquez sur « Démarrer le transfert » pour les transférer vers le stockage interne (stockés dans le fichier SQLite du document).\",\n        \"Start transfer\": \"Démarrer le transfert\",\n        \"Preferred storage for this document\": \"Méthode de stockage pour ce document\",\n        \"No external stores available\": \"Aucun espace de stockage externe disponible\",\n        \"Transfer in progress\": \"Transfert en cours\",\n        \"External\": \"Externe\",\n        \"Internal\": \"Interne\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"Certaines pièces jointes existantes sont toujours [externes]({{externalLink}}).\",\n        \"[Learn more.]({{learnLink}})\": \"[En savoir plus.]({{learnLink}})\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Certaines pièces jointes existantes sont toujours [internes]({{internalLink}})** (stockées dans un fichier SQLite).\",\n        \"Upload missing attachments\": \"Importer les pièces jointes manquantes\",\n        \"Uploading...\": \"Import en cours...\",\n        \"Upload\": \"Importer\",\n        \"Default\": \"Classique\",\n        \"Default, template, or tutorial\": \"Classique, modèle ou tutoriel\",\n        \"Document type\": \"Type de document\",\n        \"Allow others to suggest changes\": \"Permettez aux autres de suggérer des changements\",\n        \"Enable suggestions\": \"Activer les suggestions\",\n        \"Suggestions\": \"Suggestions\",\n        \"experiment\": \"expérimentation\"\n    },\n    \"DocumentUsage\": {\n        \"Usage statistics are only available to users with full access to the document data.\": \"Les statistiques d'utilisation ne sont accessibles qu'aux utilisateurs ayant un accès complet aux données du document.\",\n        \"Size of attachments\": \"Taille des pièces jointes\",\n        \"Data size\": \"Taille des données\",\n        \"Usage\": \"Utilisation\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Contactez l’administrateur pour mettre à niveau le plan afin de relever les limites.\",\n        \"start your 30-day free trial of the Pro plan.\": \"démarrer votre essai gratuit de 30 jours pour le plan Pro.\",\n        \"For higher limits, \": \"Pour des limites plus élevées, \",\n        \"Rows\": \"Lignes\"\n    },\n    \"Drafts\": {\n        \"Undo discard\": \"Annuler la suppression\",\n        \"Restore last edit\": \"Restaurer la dernière modification\"\n    },\n    \"DuplicateTable\": {\n        \"Name for new table\": \"Nom de la nouvelle table\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Au lieu de dupliquer les tables, il est généralement préférable de segmenter les données en utilisant des vues liées. {{link}}\",\n        \"Copy all data in addition to the table structure.\": \"Copier toutes les données en plus de la structure de la table.\",\n        \"Only the document default access rules will apply to the copy.\": \"Seules les règles d'accès par défaut du document s’appliqueront à la copie.\"\n    },\n    \"ExampleInfo\": {\n        \"Lightweight CRM\": \"CRM léger\",\n        \"Welcome to the Lightweight CRM template\": \"Bienvenue dans le modèle de CRM léger\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Consultez le tutoriel associé pour savoir comment lier des données et créer des mises en page de haute productivité.\",\n        \"Tutorial: Create a CRM\": \"Tutoriel : créer un CRM\",\n        \"Investment Research\": \"Recherche d’investissements\",\n        \"Welcome to the Investment Research template\": \"Bienvenue sur le modèle de recherche d'investissements\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Consulter le tutoriel associé pour apprendre à créer des tableaux récapitulatifs et des graphiques, et pour relier les graphiques de façon dynamique.\",\n        \"Tutorial: Analyze & Visualize\": \"Tutoriel : analyser et visualiser\",\n        \"Afterschool Program\": \"Activités périscolaires\",\n        \"Welcome to the Afterschool Program template\": \"Bienvenue sur le modèle d'activités périscolaires\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Consultez e tutoriel associé pour savoir comment modéliser des données d'entreprise, utiliser les formules et gérer la complexité.\",\n        \"Tutorial: Manage Business Data\": \"Tutoriel : Gérer des données d'affaire\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN LABEL AND ID\": \"LABEL ET ID DE LA COLONNE\",\n        \"Column options are limited in summary tables.\": \"Les options des colonnes sont limitées dans les tableaux récapitulatifs.\",\n        \"Formula columns_one\": \"Colonne formule\",\n        \"Data columns_one\": \"Colonne de données\",\n        \"Empty columns_one\": \"Colonne vide\",\n        \"Formula columns_other\": \"Colonnes formule\",\n        \"Data columns_other\": \"Colonnes de données\",\n        \"Empty columns_other\": \"Colonnes vides\",\n        \"Mixed Behavior\": \"Comportement mixte\",\n        \"Clear and make into formula\": \"Effacer et transformer en formule\",\n        \"Convert column to data\": \"Convertir la colonne en données\",\n        \"Convert to trigger formula\": \"Convertir en formule d'initialisation\",\n        \"Clear and reset\": \"Effacer et réinitialiser\",\n        \"Enter formula\": \"Saisir la formule\",\n        \"COLUMN BEHAVIOR\": \"NATURE DE COLONNE\",\n        \"Set formula\": \"Définir la formule\",\n        \"Set trigger formula\": \"Définir une formule d’initialisation\",\n        \"Make into data column\": \"Transformer en colonne de données\",\n        \"TRIGGER FORMULA\": \"FORMULE D'INITIALISATION\",\n        \"DESCRIPTION\": \"DESCRIPTION\"\n    },\n    \"FieldMenus\": {\n        \"Using common settings\": \"Utilisation des paramètres communs\",\n        \"Using separate settings\": \"Utilisation de paramètres distincts\",\n        \"Use separate settings\": \"Utiliser des paramètres distincts\",\n        \"Save as common settings\": \"Sauvegarder comme paramètres communs\",\n        \"Revert to common settings\": \"Revenir aux paramètres communs\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Ajouter une colonne\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Épingler le filtre - colonne {{- columnName}} (actuellement: désépinglé)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Désépingler le filtre - colonne {{- columnName}} (actuellement : épinglé)\",\n        \"remove filter - {{- columnName}} column\": \"supprimer le filtre - colonne {{- columnName}}\",\n        \"{{- columnName }} column filters\": \"filtres de la colonne {{- columnName }}\"\n    },\n    \"FilterBar\": {\n        \"SearchColuns\": \"Rechercher\",\n        \"SearchColumns\": \"Rechercher dans les colonnes\",\n        \"Search Columns\": \"Chercher des colonnes\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Options de la grille\",\n        \"Vertical gridlines\": \"Grille verticale\",\n        \"Horizontal gridlines\": \"Grille horizontale\",\n        \"Zebra stripes\": \"Couleurs alternées\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Ajouter une colonne\",\n        \"Show column {{- label}}\": \"Afficher la colonne {{- label}}\",\n        \"Column Options\": \"Options de la colonne\",\n        \"Filter Data\": \"Filtrer les données\",\n        \"Sort\": \"Trier\",\n        \"More sort options ...\": \"Plus d’options de tri…\",\n        \"Rename column\": \"Renommer la colonne\",\n        \"Reset {{count}} entire columns_one\": \"Réinitialiser la colonne entière\",\n        \"Reset {{count}} entire columns_other\": \"Réinitialiser {{count}} colonnes entières\",\n        \"Reset {{count}} columns_one\": \"Réinitialiser la colonne\",\n        \"Reset {{count}} columns_other\": \"Réinitialiser {{count}} colonnes\",\n        \"Delete {{count}} columns_one\": \"Supprimer la colonne\",\n        \"Delete {{count}} columns_other\": \"Supprimer {{count}} colonnes\",\n        \"Hide {{count}} columns_one\": \"Masquer la colonne\",\n        \"Hide {{count}} columns_other\": \"Masquer {{count}} colonnes\",\n        \"Convert formula to data\": \"Convertir la formule en données\",\n        \"Clear values\": \"Effacer les valeurs\",\n        \"Insert column to the {{to}}\": \"Insérer une colonne {{to}}\",\n        \"Freeze {{count}} columns_one\": \"Figer cette colonne\",\n        \"Freeze {{count}} columns_other\": \"Figer {{count}} colonnes\",\n        \"Freeze {{count}} more columns_one\": \"Figer une colonne de plus\",\n        \"Freeze {{count}} more columns_other\": \"Figer {{count}} colonnes\",\n        \"Unfreeze {{count}} columns_one\": \"Figer cette colonne\",\n        \"Unfreeze {{count}} columns_other\": \"Figer {{count}} colonnes\",\n        \"Unfreeze all columns\": \"Libérer toutes les colonnes\",\n        \"Add to sort\": \"Ajouter au tri\",\n        \"Sorted (#{{count}})_one\": \"Trié (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Triés (#{{count}})\",\n        \"Insert column to the right\": \"Insérer une colonne à droite\",\n        \"Insert column to the left\": \"Insérer une colonne à gauche\",\n        \"Detect Duplicates in...\": \"Détecter les duplicas dans...\",\n        \"UUID\": \"UUID\",\n        \"Shortcuts\": \"Raccourcis\",\n        \"Show hidden columns\": \"Montrer les colonnes cachées\",\n        \"Created At\": \"Créé(e) le\",\n        \"Authorship\": \"Créé par\",\n        \"Last Updated By\": \"Dernière mise à jour par\",\n        \"Hidden Columns\": \"Colonnes cachées\",\n        \"Lookups\": \"Champ rapporté\",\n        \"No reference columns.\": \"Pas de colonne de référence.\",\n        \"Apply on record changes\": \"Appliquer lors des modifications de l'enregistrement\",\n        \"Duplicate in {{- label}}\": \"Duplica dans {{-label}}\",\n        \"Created By\": \"Créé(e) par\",\n        \"Last Updated At\": \"Dernière mise à jour le\",\n        \"Apply to new records\": \"Appliquer aux nouvelles lignes\",\n        \"Search columns\": \"Chercher des colonnes\",\n        \"Timestamp\": \"Horodatage\",\n        \"no reference column\": \"pas de colonne de référence\",\n        \"Adding UUID column\": \"Ajout d'une colonne UUID\",\n        \"Adding duplicates column\": \"Ajouter des colonnes dupliquées\",\n        \"Add formula column\": \"Ajouter une colonne formule\",\n        \"Add column with type\": \"Ajouter une colonne de type\",\n        \"Created by\": \"Créé par\",\n        \"Created at\": \"Créé le\",\n        \"Last updated by\": \"Dernière mise à jour par\",\n        \"Detect duplicates in...\": \"Détecter les duplicats dans...\",\n        \"Last updated at\": \"Dernière mise à jour\",\n        \"Text\": \"Texte\",\n        \"DateTime\": \"Date et Heure\",\n        \"Choice\": \"Choix unique\",\n        \"Choice List\": \"Choix multiple\",\n        \"Date\": \"Date\",\n        \"Any\": \"Non défini\",\n        \"Numeric\": \"Numérique\",\n        \"Integer\": \"Entier\",\n        \"Toggle\": \"Booléen\",\n        \"Reference\": \"Référence\",\n        \"Reference List\": \"Référence multiple\",\n        \"Attachment\": \"Pièce jointe\"\n    },\n    \"GristDoc\": {\n        \"Import from file\": \"Importer depuis un fichier\",\n        \"Added new linked section to view {{viewName}}\": \"Création d'une nouvelle section à la page {{viewName}}\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Section liée {{title}} sauvegardée dans la page {{name}}\",\n        \"go to webhook settings\": \"Aller aux paramétrages des points d’ancrage Web\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Les nouvelles modifications sont temporairement suspendues. La file d'attente des points d'ancrage est saturée. Veuillez vérifier les paramètres des points d'ancrage , supprimer les points d'ancrage non valides et nettoyer la file d'attente.\",\n        \"Import from Airtable\": \"Importer depuis Airtable\"\n    },\n    \"HomeIntro\": {\n        \"Sign up\": \"S'inscrire\",\n        \"This workspace is empty.\": \"Ce dossier est vide.\",\n        \"personal site\": \"espace personnel\",\n        \"Welcome to {{orgName}}\": \"Bienvenue sur {{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Vous avez un accès en lecture seule à ce site. Il n'y a actuellement aucun document.\",\n        \"Any documents created in this site will appear here.\": \"Tous les documents créés dans ce site apparaîtront ici.\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Vous souhaitez utiliser Grist en dehors de votre équipe ? Visitez votre site gratuit \",\n        \"Sprouts Program\": \"programme d'essaimage\",\n        \"Welcome to Grist, {{name}}!\": \"Bienvenue sur Grist, {{name}} !\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Pour commencer, inviter votre équipe et créer votre premier document Grist.\",\n        \"Get started by creating your first Grist document.\": \"Commencez en créant votre premier document Grist.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Commencez par explorer des modèles ou créez votre premier document Grist.\",\n        \"Welcome to Grist!\": \"Bienvenue sur Grist !\",\n        \"Help Center\": \"Centre d'aide\",\n        \"Invite Team Members\": \"Inviter un nouveau membre à l'espace d'équipe\",\n        \"Browse Templates\": \"Parcourir les modèles\",\n        \"Create empty document\": \"Créer un document vide\",\n        \"Import document\": \"Importer un document\",\n        \"Visit our {{link}} to learn more.\": \"Consulter le {{link}} pour en savoir plus.\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} pour enregistrer votre travail. \",\n        \"Welcome to Grist, {{- name}}!\": \"Bienvenue sur Grist, {{- name}} !\",\n        \"Welcome to {{- orgName}}\": \"Bienvenue sur {{- orgName}}\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Visitez notre {{link}} pour en savoir plus sur Grist.\",\n        \"Sign in\": \"Connexion\",\n        \"To use Grist, please either sign up or sign in.\": \"Pour utiliser Grist, connectez-vous ou créez-vous un compte.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Pour en savoir plus, consultez notre {{helpCenterLink}}.\",\n        \"Only show documents\": \"Afficher des documents seulement\"\n    },\n    \"HomeLeftPane\": {\n        \"All documents\": \"Tous les documents\",\n        \"Examples & Templates\": \"Modèles\",\n        \"Create empty document\": \"Créer un document vide\",\n        \"Import document\": \"Importer un document\",\n        \"Create workspace\": \"Créer un nouveau dossier\",\n        \"Trash\": \"Corbeille\",\n        \"Rename\": \"Renommer\",\n        \"Delete\": \"Supprimer\",\n        \"Workspaces\": \"Dossiers\",\n        \"Delete {{workspace}} and all included documents?\": \"Supprimer le dossier {{workspace}} et tous les documents qu'il contient ?\",\n        \"Workspace will be moved to Trash.\": \"Le dossier va être mis à la corbeille.\",\n        \"Manage users\": \"Gérer les utilisateurs\",\n        \"Access Details\": \"Détails d'accès\",\n        \"Tutorial\": \"Tutoriel\",\n        \"Terms of service\": \"CGU\",\n        \"Grist Resources\": \"Ressources Grist\",\n        \"context menu - {{- workspaceName }}\": \"menu contextuel - {{- workspaceName }}\",\n        \"Import from Airtable\": \"Importer depuis Airtable\"\n    },\n    \"Importer\": {\n        \"Update existing records\": \"Mettre à jour les enregistrements existants\",\n        \"Merge rows that match these fields:\": \"Fusionner les lignes si ces champs correspondent :\",\n        \"Select fields to match on\": \"Sélectionner les champs pour l'appairage\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} champ non apparié dans l'importation\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} champs non appariés dans l'importation\",\n        \"{{count}} unmatched field_one\": \"{{count}} champ non apparié\",\n        \"{{count}} unmatched field_other\": \"{{count}} champs non appariés\",\n        \"Column mapping\": \"Cartographie de colonne\",\n        \"Grist column\": \"Colonne Grist\",\n        \"Revert\": \"Annuler\",\n        \"Skip Import\": \"Ignorer l'import\",\n        \"New Table\": \"Nouvelle Table\",\n        \"Skip\": \"Passer\",\n        \"Column Mapping\": \"Cartographie de Colonne\",\n        \"Destination table\": \"Table de destination\",\n        \"Skip Table on Import\": \"Ignorer la table à l'import\",\n        \"Import from file\": \"Importer depuis un fichier\",\n        \"Source column\": \"Colonne source\",\n        \"Import options\": \"Options d'importation\",\n        \"Cancel\": \"Annuler\",\n        \"Import\": \"Importer\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Centre d'aide\",\n        \"Accessibility\": \"Accessibilité\"\n    },\n    \"MakeCopyMenu\": {\n        \"Replacing the original requires editing rights on the original document.\": \"Le remplacement de l'original nécessite des droits d'édition sur le document d'origine.\",\n        \"Cancel\": \"Annuler\",\n        \"Update Original\": \"Mettre à jour l'original\",\n        \"Update\": \"Mettre à jour\",\n        \"The original version of this document will be updated.\": \"La version originale de ce document sera mise à jour.\",\n        \"Original Has Modifications\": \"Modifier la version originale\",\n        \"Overwrite\": \"Remplacer\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Attention, l'original a des modifications qui ne sont pas dans ce document. Ces modifications seront écrasées.\",\n        \"Original Looks Unrelated\": \"L'original ne semble pas relié\",\n        \"It will be overwritten, losing any content not in this document.\": \"Il sera écrasé, perdant tout contenu ne figurant pas dans ce document.\",\n        \"Original Looks Identical\": \"L'original semble être identique\",\n        \"However, it appears to be already identical.\": \"Cependant, il semble être déjà identique.\",\n        \"Sign up\": \"Inscription\",\n        \"To save your changes, please sign up, then reload this page.\": \"Pour enregistrer vos modifications, veuillez vous inscrire, puis recharger cette page.\",\n        \"No destination workspace\": \"Aucun dossier destination\",\n        \"Name\": \"Nom\",\n        \"Enter document name\": \"Saisir le nom du document\",\n        \"As template\": \"Comme modèle\",\n        \"Include the structure without any of the data.\": \"Inclure la structure sans les données.\",\n        \"Organization\": \"Organisation\",\n        \"You do not have write access to this site\": \"Vous n’avez pas d'accès en écriture à cet espace\",\n        \"Workspace\": \"Dossier\",\n        \"You do not have write access to the selected workspace\": \"Vous n’avez pas accès en écriture à ce dossier\",\n        \"Download document structure only (no data, for template use)\": \"Télécharger uniquement la structure du document, sans les données\",\n        \"Download document without history (can significantly reduce file size)\": \"Télécharger le document complet sans l'historique (peut réduire sensiblement la taille du fichier)\",\n        \"Download document and history\": \"Télécharger le document complet avec l'historique des modifications\",\n        \"Download\": \"Télécharger\",\n        \"Download document\": \"Télécharger le document\",\n        \"Learn more\": \"En savoir plus\",\n        \"Download full document and history\": \"Télécharger le document complet avec l'historique des modifications\",\n        \"Download an archive of all the attachments present in this document.\": \"Télécharger une archive de toutes les pièces jointes présentes dans ce document.\",\n        \"download attachments\": \"télécharger les pièces jointes\",\n        \"Download attachments\": \"Télécharger les pièces jointes\",\n        \".zip\": \".zip\",\n        \"Format:\": \"Format :\",\n        \".tar (recommended)\": \".tar (recommandé)\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Les pièces jointes sont externalisées et ne sont pas incluses dans ce téléchargement. Si vous importez le document sur une autre instance de Grist, vous devrez {{downloadLink}} séparément. \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"Si vous prévoyez de téléverser ce document dans une instance de Grist, vous aurez besoin de l'archiver au format \\\".tar\\\" pour pouvoir restaurer les pièces jointes. \"\n    },\n    \"NotifyUI\": {\n        \"Upgrade Plan\": \"Améliorer votre abonnement\",\n        \"Renew\": \"Renouveler\",\n        \"Go to your free personal site\": \"Accéder à votre espace personnel\",\n        \"Cannot find personal site, sorry!\": \"Espace personnel introuvable, désolé !\",\n        \"Report a problem\": \"Signaler un problème\",\n        \"Ask for help\": \"Demander de l’aide\",\n        \"Notifications\": \"Notifications\",\n        \"Give feedback\": \"Donnez votre avis\",\n        \"No notifications\": \"Aucune notification\",\n        \"Manage billing\": \"Gérer la facturation\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Terminer\",\n        \"Next\": \"Suivant\",\n        \"Previous\": \"Précédent\"\n    },\n    \"OpenVideoTour\": {\n        \"YouTube video player\": \"Lecteur vidéo YouTube\",\n        \"Grist Video Tour\": \"Visite guidée de Grist en vidéo\",\n        \"Video Tour\": \"Visite guidée en vidéo\"\n    },\n    \"PageWidgetPicker\": {\n        \"Building {{- label}} widget\": \"Vue {{- label}} en construction\",\n        \"Select widget\": \"Choisir la vue\",\n        \"Select data\": \"Choisir les données source\",\n        \"Group by\": \"Grouper par\",\n        \"Add to page\": \"Ajouter à la Page\",\n        \"New Table\": \"Nouvelle Table\",\n        \"SELECT BY\": \"SÉLECTIONNÉ PAR\"\n    },\n    \"Pages\": {\n        \"The following tables will no longer be visible_one\": \"La donnée source ne sera plus visible\",\n        \"The following tables will no longer be visible_other\": \"Les données source suivantes ne seront plus visibles\",\n        \"Delete data and this page.\": \"Supprimer les données source et la page.\",\n        \"Delete\": \"Supprimer\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Conserver les données et supprimer la page. La table restera disponible dans la {{rawDataLink}}\",\n        \"raw data page\": \"page de données brutes\",\n        \"Document pages\": \"Pages du document\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Tout autoriser\",\n        \"Deny all\": \"Tout refuser\",\n        \"Read only\": \"Lecture seule\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Échec de l'importation : \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Mise à jour de la disposition.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Ajouter un champ\",\n        \"Create new field\": \"Créer un nouveau champ\",\n        \"Show field {{- label}}\": \"Afficher le champ {{- label}}\",\n        \"Save layout\": \"Enregistrer cette disposition\",\n        \"Cancel\": \"Annuler\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Ajouter une colonne\",\n        \"No columns to add\": \"Aucune colonne à ajouter\"\n    },\n    \"RightPanel\": {\n        \"columns_one\": \"Colonne\",\n        \"columns_other\": \"Colonnes\",\n        \"fields_one\": \"Champ\",\n        \"fields_other\": \"Champs\",\n        \"series_one\": \"Série\",\n        \"series_other\": \"Séries\",\n        \"COLUMN TYPE\": \"TYPE DE COLONNE\",\n        \"TRANSFORM\": \"TRANSFORMER\",\n        \"Widget\": \"Vue\",\n        \"Sort & filter\": \"Trier et Filtrer\",\n        \"Data\": \"Données source\",\n        \"DATA TABLE NAME\": \"NOM DE LA TABLE\",\n        \"WIDGET TITLE\": \"TITRE DE LA VUE\",\n        \"Change widget\": \"Modifier la vue\",\n        \"Theme\": \"Thème\",\n        \"Row style\": \"Aspect de la ligne\",\n        \"CHART TYPE\": \"TYPE DE GRAPHIQUE\",\n        \"CUSTOM\": \"PERSONNALISÉ\",\n        \"DATA TABLE\": \"DONNÉES SOURCE\",\n        \"SOURCE DATA\": \"DONNÉES SOURCE\",\n        \"GROUPED BY\": \"GROUPER PAR\",\n        \"Edit data selection\": \"Données source\",\n        \"Detach\": \"Détacher\",\n        \"SELECT BY\": \"SÉLECTIONNER PAR\",\n        \"Select widget\": \"Choisir la vue\",\n        \"SELECTOR FOR\": \"SÉLECTEUR POUR\",\n        \"Save\": \"Enregistrer\",\n        \"You do not have edit access to this document\": \"Vous n’avez pas accès en écriture à ce document\",\n        \"Add referenced columns\": \"Ajouter une colonne référencée\",\n        \"Redirect automatically after submission\": \"Redirection automatique après soumission\",\n        \"Redirection\": \"Redirection\",\n        \"Configuration\": \"Configuration\",\n        \"Default field value\": \"Valeur par défaut du champ\",\n        \"Display button\": \"Afficher le bouton\",\n        \"Enter text\": \"Saisir du texte\",\n        \"Field rules\": \"Règles du champ\",\n        \"Field title\": \"Titre du champ\",\n        \"Hidden field\": \"Champ caché\",\n        \"Layout\": \"Mise en page\",\n        \"Submission\": \"Soumission\",\n        \"Submit button label\": \"Libellé du bouton de validation\",\n        \"Success text\": \"Message de succès\",\n        \"Table column name\": \"Nom de la colonne\",\n        \"Enter redirect URL\": \"Saisir l'URL de redirection\",\n        \"Reset form\": \"Réinitialiser le formulaire\",\n        \"Submit another response\": \"Soumettre une autre réponse\",\n        \"Required field\": \"Champ obligatoire\",\n        \"No field selected\": \"Aucun champ sélectionné\",\n        \"Select a field in the form widget to configure.\": \"Sélectionnez un champ du formulaire à configurer.\",\n        \"Submit\": \"Valider\",\n        \"Thank you! Your response has been recorded.\": \"Merci ! Votre réponse a été enregistrée.\",\n        \"Chart options\": \"Options du graphique\"\n    },\n    \"RowContextMenu\": {\n        \"Insert row\": \"Insérer une ligne\",\n        \"Insert row above\": \"Insérer une ligne au-dessus\",\n        \"Insert row below\": \"Insérer une ligne au-dessous\",\n        \"Duplicate rows_one\": \"Dupliquer la ligne\",\n        \"Duplicate rows_other\": \"Dupliquer les lignes\",\n        \"Delete\": \"Supprimer\",\n        \"Copy anchor link\": \"Copier l'ancre\",\n        \"View as card\": \"Voir en fiche\",\n        \"Use as table headers\": \"Utiliser en tant qu'entêtes de table\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Copié dans le presse-papier\"\n    },\n    \"ShareMenu\": {\n        \"Back to current\": \"Retour à la version active\",\n        \"Save Document\": \"Enregistrer le document\",\n        \"Save copy\": \"Enregistrer une copie\",\n        \"Unsaved\": \"Non enregistré\",\n        \"Duplicate document\": \"Dupliquer le document\",\n        \"Manage users\": \"Gérer les utilisateurs\",\n        \"Access Details\": \"Informations d’accès\",\n        \"Current Version\": \"Version actuelle\",\n        \"Original\": \"Original\",\n        \"Return to {{termToUse}}\": \"Revenir à {{termToUse}}\",\n        \"Replace {{termToUse}}...\": \"Remplacer {{termToUse}}…\",\n        \"Compare to {{termToUse}}\": \"Comparer avec {{termToUse}}\",\n        \"Work on a copy\": \"Travailler sur une copie\",\n        \"Edit without affecting the original\": \"Éditer sans affecter l'original\",\n        \"Show in folder\": \"Afficher dans le répertoire\",\n        \"Download\": \"Télécharger\",\n        \"Export CSV\": \"Exporter en CSV\",\n        \"Export XLSX\": \"Exporter en XLSX\",\n        \"Send to Google Drive\": \"Envoyer vers Google Drive\",\n        \"Share\": \"Partager\",\n        \"Download...\": \"Télécharger...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Export as...\": \"Exporter en tant que...\",\n        \"Comma Separated Values (.csv)\": \"Valeurs séparées par des virgules (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"DOO Separated Values (.dsv)\",\n        \"Tab Separated Values (.tsv)\": \"Valeurs séparées par des tabulations (.tsv)\",\n        \"Download document...\": \"Télécharger le document...\",\n        \"Download attachments...\": \"Télécharger les pièces jointes...\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"L'export n'est possible qu'à partir des pages du document. Veuillez sélectionner une page du document et réessayer.\",\n        \"Suggest changes\": \"Suggérer des modifications\",\n        \"current version\": \"version actuelle\",\n        \"original\": \"original\"\n    },\n    \"SiteSwitcher\": {\n        \"Switch Sites\": \"Changer d’espace\",\n        \"Create new team site\": \"Créer un nouvel espace d'équipe\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Ajouter une colonne\",\n        \"Update data\": \"Mettre à jour les données\",\n        \"Use choice position\": \"Utiliser l'ordre des choix\",\n        \"Natural sort\": \"Trier\",\n        \"Empty values last\": \"Valeurs vides en dernier\",\n        \"Search Columns\": \"Rechercher dans les colonnes\",\n        \"Remove sort setting - {{- columnName }} column\": \"Supprimer l'option de tri - colonne {{- columnName }}\",\n        \"Sort in ascending order (current: descending)\": \"Trier par ordre croissant (actuellement : ordre décroissant)\",\n        \"Sort in descending order (current: ascending)\": \"Trier par ordre décroissant (actuellement : ordre croissant)\",\n        \"Sort options - {{- columnName }} column\": \"Options de tri - colonne {{- columnName }}\",\n        \"{{- columnName }} column\": \"colonne {{- columnName }}\"\n    },\n    \"SortFilterConfig\": {\n        \"Save\": \"Enregistrer\",\n        \"Revert\": \"Retour\",\n        \"Sort\": \"TRI\",\n        \"Filter\": \"FILTRE\",\n        \"Update Sort & Filter settings\": \"Mettre à jour le tri et le filtre\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Apparence \",\n        \"Switch appearance automatically to match system\": \"Adapter l'apparence au système\"\n    },\n    \"Tools\": {\n        \"TOOLS\": \"OUTILS\",\n        \"Access Rules\": \"Permissions avancées\",\n        \"Document history\": \"Historique du document\",\n        \"Validate Data\": \"Valider les données\",\n        \"Code view\": \"Vue du code\",\n        \"How-to Tutorial\": \"Tutoriel pratique\",\n        \"Tour of this Document\": \"Découvrir le document\",\n        \"Delete document tour?\": \"Supprimer la visite guidée du document ?\",\n        \"Delete\": \"Supprimer\",\n        \"Return to viewing as yourself\": \"Revenir à une vue en propre\",\n        \"Raw data\": \"Données source\",\n        \"Settings\": \"Paramètres\",\n        \"API console\": \"Console de l'API\",\n        \"context menu - Access Rules\": \"menu contextuel - Règles d'Accès\",\n        \"Delete document tour\": \"Supprimer la visite de document\",\n        \"Preview the tutorial\": \"Prévisualiser le tutoriel\",\n        \"Proposed changes\": \"Suggestions de modifications\",\n        \"Suggest changes\": \"Suggérer des modifications\",\n        \"Suggestions\": \"Suggestions\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Gérer les équipes\",\n        \"Redo\": \"Rétablir\",\n        \"Undo\": \"Annuler\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"N'importe quel champ\",\n        \"Apply to new records\": \"Appliquer sur les nouvelles lignes uniquement\",\n        \"Apply on changes to:\": \"Appliquer sur les modifications à :\",\n        \"Apply on record changes\": \"Réappliquer en cas de modification de la ligne\",\n        \"Current field \": \"Champ actif \",\n        \"OK\": \"OK\",\n        \"Cancel\": \"Annuler\",\n        \"Close\": \"Fermer\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Appliquer\",\n        \"Cancel\": \"Annuler\",\n        \"Preview\": \"Aperçu\",\n        \"Revise\": \"Réviser\",\n        \"Update formula (Shift+Enter)\": \"Mettre à jour la formule (Shift+Entrée)\"\n    },\n    \"UserManagerModel\": {\n        \"Owner\": \"Propriétaire\",\n        \"Editor\": \"Éditeur\",\n        \"Viewer\": \"Lecture seule\",\n        \"No Default Access\": \"Pas d’accès par défaut\",\n        \"In full\": \"Tous les accès\",\n        \"View & edit\": \"Lecture et écriture\",\n        \"View only\": \"Lecture seule\",\n        \"None\": \"Aucun\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Règle {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Mettre à jour la formule (Maj+Entrée)\"\n    },\n    \"ViewConfigTab\": {\n        \"Unmark On-Demand\": \"Ne plus marquer comme \\\"à la demande\\\"\",\n        \"Make On-Demand\": \"Rendre dynamique\",\n        \"Advanced settings\": \"Paramètres avancés\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Les grosses tables peuvent être marquées comme \\\"à la demande\\\" pour éviter de les charger dans le moteur de calcul.\",\n        \"Form\": \"Formulaire\",\n        \"Compact\": \"Compact\",\n        \"Blocks\": \"Blocs\",\n        \"Edit card layout\": \"Disposition de la fiche\",\n        \"Plugin: \": \"Plugin : \",\n        \"Section: \": \"Section : \",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"Les tables à la demande ne sont plus disponibles en raison d'un manque de fonctionnalités et de problèmes d'expérience utilisateur.\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Fonctionnalité obsolète\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Delete record\": \"Supprimer la ligne\",\n        \"Copy anchor link\": \"Copier l'ancre\",\n        \"Show raw data\": \"Afficher les données source\",\n        \"Print widget\": \"Imprimer la vue\",\n        \"Download as CSV\": \"Télécharger en CSV\",\n        \"Download as XLSX\": \"Télécharger en XLSX\",\n        \"Edit card layout\": \"Disposition de la fiche\",\n        \"Widget options\": \"Options de la vue\",\n        \"Advanced sort & filter\": \"Tri et filtre avancés\",\n        \"Data selection\": \"Sélection des données\",\n        \"Open configuration\": \"Ouvrir la configuration\",\n        \"Delete widget\": \"Supprimer la vue\",\n        \"Collapse widget\": \"Réduire la vue\",\n        \"Add to page\": \"Ajouter à la page\",\n        \"Create a form\": \"Créer un formulaire\",\n        \"Duplicate widget\": \"Dupliquer la vue\"\n    },\n    \"ViewSectionMenu\": {\n        \"Update Sort&Filter settings\": \"Mettre à jour le tri et le filtre\",\n        \"Save\": \"Enregistrer\",\n        \"Revert\": \"Retour\",\n        \"(customized)\": \"(personnalisé)\",\n        \"(modified)\": \"(modifié)\",\n        \"(empty)\": \"(vide)\",\n        \"Custom options\": \"Options personnalisées\",\n        \"SORT\": \"TRI\",\n        \"FILTER\": \"FILTRE\",\n        \"Sort and filter\": \"Trier et filtrer\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Hidden Fields cannot be reordered\": \"Les champs masqués ne peuvent pas être réordonnés\",\n        \"Cannot drop items into Hidden Fields\": \"Impossible de mettre des éléments dans les champs cachés\",\n        \"Select all\": \"Sélectionner tout\",\n        \"Clear\": \"Effacer\",\n        \"Visible {{label}}\": \"{{label}} visible\",\n        \"Hide {{label}}\": \"Cacher {{label}}\",\n        \"Show {{label}}\": \"Montrer {{label}}\",\n        \"Hidden {{label}}\": \"{{label}} caché\",\n        \"Hide {{label}} (batch mode)\": \"Masquer {{label}} (mode groupé)\",\n        \"Show {{label}} (batch mode)\": \"Montrer {{label}} (mode groupé)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Welcome to Grist!\": \"Bienvenue sur Grist !\",\n        \"Product Development\": \"Développement de produit\",\n        \"Finance & Accounting\": \"Finance et comptabilité\",\n        \"Media Production\": \"Production de média\",\n        \"IT & Technology\": \"Technologie informatique\",\n        \"Marketing\": \"Marketing\",\n        \"Research\": \"Recherche\",\n        \"Sales\": \"Ventes\",\n        \"Education\": \"Éducation\",\n        \"HR & Management\": \"RH et Gestion\",\n        \"Other\": \"Autres\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Pourquoi utilisez-vous Grist ? Aidez-nous à l’améliorer.\",\n        \"Type here\": \"Écrire ici\"\n    },\n    \"WidgetTitle\": {\n        \"Override widget title\": \"Renommer la vue\",\n        \"DATA TABLE NAME\": \"NOM DE LA TABLE\",\n        \"Provide a table name\": \"Indiquer un nom de table\",\n        \"WIDGET TITLE\": \"TITRE DE LA VUE\",\n        \"Save\": \"Enregistrer\",\n        \"Cancel\": \"Annuler\",\n        \"WIDGET DESCRIPTION\": \"DESCRIPTION DE LA VUE\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Vous pouvez faire des modifications, mais une nouvelle copie\\n sera créée et ces modifications n’affecteront pas le document original.\",\n        \"snapshot\": \"instantané\",\n        \"unsaved\": \"non enregistré\",\n        \"recovery mode\": \"mode récupération\",\n        \"override\": \"remplacer\",\n        \"fiddle\": \"bac à sable\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Vous pouvez apporter des modifications,\\nmais elles n'affecteront pas le document original.\\nVous pouvez les soumettre à titre de suggestions.\",\n        \"editing\": \"édition\",\n        \"suggesting\": \"suggestion\"\n    },\n    \"duplicatePage\": {\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Notez que cette opération ne duplique pas les données, mais crée une autre page avec les mêmes données.\",\n        \"Duplicate page {{pageName}}\": \"Dupliquer la page {{pageName}}\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Accès refusé{{suffix}}\",\n        \"You do not have access to this organization's documents.\": \"Vous n’avez pas accès aux documents de cette organisation.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Vous êtes connecté en tant que {{email}}. Vous pouvez vous connecter avec un autre compte ou demander un accès à un administrateur.\",\n        \"Sign in to access this organization's documents.\": \"Connectez-vous pour accéder aux documents de cette organisation.\",\n        \"Go to main page\": \"Aller à la page principale\",\n        \"Sign in\": \"Connexion\",\n        \"Add account\": \"Ajouter un compte\",\n        \"Signed out{{suffix}}\": \"Déconnexion{{suffix}}\",\n        \"You are now signed out.\": \"Vous êtes maintenant déconnecté.\",\n        \"Error{{suffix}}\": \"Erreur{{suffix}}\",\n        \"Sign in again\": \"Reconnectez-vous\",\n        \"Page not found{{suffix}}\": \"Page non trouvée{{suffix}}\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"La page demandée n’a pas pu être trouvée.{{separator}}Veuillez vérifier l’URL et réessayer.\",\n        \"Contact support\": \"Contacter le support\",\n        \"Something went wrong\": \"Une erreur s’est produite\",\n        \"There was an error: {{message}}\": \"Une erreur s’est produite : {{message}}\",\n        \"There was an unknown error.\": \"Une erreur inconnue s’est produite.\",\n        \"Account deleted{{suffix}}\": \"Compte supprimé {{suffix}}\",\n        \"Your account has been deleted.\": \"Votre compte a été supprimé.\",\n        \"Sign up\": \"S'inscrire\",\n        \"Build your own form\": \"Créez votre propre formulaire\",\n        \"Form not found\": \"Formulaire non trouvé\",\n        \"Powered by\": \"Créé avec\",\n        \"An unknown error occurred.\": \"Une erreur inconnue s'est produite.\",\n        \"Sign-in failed{{suffix}}\": \"Échec de l'authentification{{suffix}}\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Échec de la connexion.{{separator}}Merci de retenter ou contacter le support.\",\n        \"Manage settings\": \"Gérer les paramètres\",\n        \"Need Help?\": \"Besoin d'aide ?\",\n        \"There was an error\": \"Une erreur s'est produite\",\n        \"We could not unsubscribe you\": \"Nous n'avons pas pu vous désabonner\",\n        \"You are unsubscribed\": \"Vous êtes désabonné·e\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Vous pouvez toujours vous désabonner de ce document en mettant à jour vos préférences dans les paramètres du document\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Vous ne recevrez plus de notifications à l'adresse {{email}} concernant les {{changes}} apportés à {{docName}}.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Vous ne recevrez plus de notifications à l'adresse {{email}} concernant les {{comments}} dans {{docName}}.\",\n        \"changes\": \"modifications\",\n        \"comments\": \"commentaires\",\n        \"this document\": \"ce document\",\n        \"your email\": \"votre email\",\n        \"Unsubscribed{{suffix}}\": \"Désabonné{{suffix}}\",\n        \"You will no longer receive email notifications about {{suggestions}} in {{docName}} at {{email}}.\": \"Vous ne recevrez plus de notifications par email au sujet de {{suggestions}} dans {{docName}} sur {{email}}.\",\n        \"suggestions\": \"suggestions\"\n    },\n    \"menus\": {\n        \"Select fields\": \"Sélectionner les champs\",\n        \"* Workspaces are available on team plans. \": \"* Les dossiers sont disponibles avec une offre équipe. \",\n        \"Upgrade now\": \"Mettre à jour maintenant\",\n        \"Numeric\": \"Numérique\",\n        \"Reference List\": \"Référence multiple\",\n        \"Attachment\": \"Pièce jointe\",\n        \"Text\": \"Texte\",\n        \"Date\": \"Date\",\n        \"DateTime\": \"Date et Heure\",\n        \"Choice\": \"Choix unique\",\n        \"Integer\": \"Entier\",\n        \"Choice List\": \"Choix multiple\",\n        \"Toggle\": \"Booléen\",\n        \"Reference\": \"Référence\",\n        \"Any\": \"Non défini\",\n        \"Search columns\": \"Chercher des colonnes\",\n        \"By Name\": \"Par Nom\",\n        \"By Date Modified\": \"Par Date de modification\",\n        \"Light\": \"Clair\",\n        \"Custom\": \"Personnalisé\"\n    },\n    \"modals\": {\n        \"Save\": \"Enregistrer\",\n        \"Cancel\": \"Annuler\",\n        \"Ok\": \"OK\",\n        \"Don't show tips\": \"Masquer les astuces\",\n        \"Undo to restore\": \"Annuler et rétablir\",\n        \"Don't show again\": \"Ne plus montrer\",\n        \"Delete\": \"Supprimer\",\n        \"Dismiss\": \"Ignorer\",\n        \"Don't ask again.\": \"Ne plus demander.\",\n        \"Don't show again.\": \"Ne plus montrer.\",\n        \"Got it\": \"J'ai compris\",\n        \"Are you sure you want to delete these records?\": \"Êtes-vous sûr de vouloir supprimer ces enregistrements ?\",\n        \"Are you sure you want to delete this record?\": \"Êtes-vous sûr de vouloir supprimer cet enregistrement ?\",\n        \"TIP\": \"CONSEIL\",\n        \"Confirm\": \"Confirmer\"\n    },\n    \"pages\": {\n        \"Rename\": \"Renommer\",\n        \"Remove\": \"Supprimer\",\n        \"Duplicate page\": \"Dupliquer la page\",\n        \"You do not have edit access to this document\": \"Vous n’avez pas accès en écriture à ce document\",\n        \"(default)\": \"(par défaut)\",\n        \"Collapse {{maybeDefault}}\": \"Fermer {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Ouvrir {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Définir par défaut : Fermer\",\n        \"Set default: Expand\": \"Définir par défaut : Ouvrir\",\n        \"context menu - {{- pageName }}\": \"menu contextuel - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Search in document\": \"Rechercher dans le document\",\n        \"No results\": \"Aucun résultat\",\n        \"Find Next \": \"Rechercher suivant \",\n        \"Find Previous \": \"Rechercher le précédent \",\n        \"Search\": \"Chercher\",\n        \"Close search bar\": \"Fermer la barre de recherche\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Envoi en cours vers Google Drive\"\n    },\n    \"NTextBox\": {\n        \"false\": \"faux\",\n        \"true\": \"vrai\",\n        \"Single line\": \"Ligne unique\",\n        \"Multi line\": \"Multi-lignes\",\n        \"Lines\": \"Lignes\",\n        \"Field Format\": \"Format du champ\",\n        \"Maximum characters\": \"Nombre de caractères max\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Voir en tant que\",\n        \"Example Users\": \"Utilisateurs d'exemple\",\n        \"Users from table\": \"Utilisateurs de la table d'appairage\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Utilisateur inconnu\",\n        \"View as Yourself\": \"Voir en tant que vous-même\",\n        \"You are viewing this document as\": \"Vous voyez ce document en tant que\",\n        \"You're seeing what this user would see if given access\": \"Vous voyez ce que cet utilisateur voit si l'accès lui est donné\"\n    },\n    \"CellStyle\": {\n        \"Open row styles\": \"Ouvrir les styles de ligne\",\n        \"Cell style\": \"Style de cellule\",\n        \"CELL STYLE\": \"STYLE de CELLULE\",\n        \"Default cell style\": \"Style par défaut de la cellule\",\n        \"Mixed style\": \"Style composite\",\n        \"Header Style\": \"Style de l'entête\",\n        \"Default header style\": \"Style par défaut\",\n        \"HEADER STYLE\": \"STYLE DE l'EN-TÊTE\"\n    },\n    \"DiscussionEditor\": {\n        \"Comment\": \"Commentaire\",\n        \"Save\": \"Enregistrer\",\n        \"Open\": \"Ouvrir\",\n        \"Write a comment\": \"Écrire un commentaire\",\n        \"Cancel\": \"Annuler\",\n        \"Edit\": \"Modifier\",\n        \"Reply to a comment\": \"Répondre à un commentaire\",\n        \"Only current page\": \"Page actuelle uniquement\",\n        \"Remove\": \"Supprimer\",\n        \"Marked as resolved\": \"Marquer comme résolu\",\n        \"Only my threads\": \"Seulement mes fils\",\n        \"Reply\": \"Répondre\",\n        \"Show resolved comments\": \"Montrer les commentaires résolus\",\n        \"Resolve\": \"Résoudre\",\n        \"Showing last {{nb}} comments\": \"Montrer les {{nb}} derniers commentaires\",\n        \"Started discussion\": \"Discussion commencée\",\n        \"Remove thread\": \"Supprimer le fil\",\n        \"updated\": \"mis à jour\",\n        \"Copy link\": \"Copier le lien\",\n        \"{{count}} comments_one\": \"{{count}} commentaire\",\n        \"{{count}} comments_other\": \"{{count}} commentaires\"\n    },\n    \"FieldBuilder\": {\n        \"Apply formula to data\": \"Appliquer la formule\",\n        \"DATA FROM TABLE\": \"DONNÉES DE LA TABLE\",\n        \"CELL FORMAT\": \"FORMAT DE CELLULE\",\n        \"Mixed types\": \"Types composites\",\n        \"Changing multiple column types\": \"Changer plusieurs types\",\n        \"Mixed format\": \"Format composite\",\n        \"Revert field settings for {{colId}} to common\": \"Réinitialiser les paramètres par défaut pour {{colId}}\",\n        \"Save field settings for {{colId}} as common\": \"Sauvegarder les paramètres pour {{colId}}\",\n        \"Use separate field settings for {{colId}}\": \"Utiliser des paramètres spécifiques pour {{colId}}\",\n        \"Changing column type\": \"Changement du type de colonne\",\n        \"Field in {{count}} views_one\": \"Champ dans une vue\",\n        \"Field in {{count}} views_other\": \"Champ dans {{count}} vues\",\n        \"Common\": \"Commun\",\n        \"Separate\": \"Séparé\"\n    },\n    \"WelcomeTour\": {\n        \"Customizing columns\": \"Personnaliser les colonnes\",\n        \"template library\": \"Bibliothèque de modèles\",\n        \"Share\": \"Partager\",\n        \"Add new\": \"Nouveau\",\n        \"Building up\": \"En construction\",\n        \"Configuring your document\": \"Configuration de votre document\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Double-cliquer ou appuyer sur {{enter}} sur une cellule pour la modifier \",\n        \"Editing Data\": \"Modification des données\",\n        \"Welcome to Grist!\": \"Bienvenue sur Grist !\",\n        \"Start with {{equal}} to enter a formula.\": \"Commencer par {{equal}} pour ajouter une formule.\",\n        \"Sharing\": \"Partager\",\n        \"Reference\": \"Référence\",\n        \"Help Center\": \"Centre d'aide\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Parcourez notre {{templateLibrary}} pour découvrir les possibilités et trouver de l'inspiration.\",\n        \"Flying higher\": \"Pour aller plus loin\",\n        \"Enter\": \"Entrée\",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Utilisez le bouton de partage ({{share}}) pour partager le document ou exporter les données.\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Rendez-le relationnel ! Utilisez le type {{ref}} pour lier les tableaux. \",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Ouvrez le {{creatorPanel}} pour formater les colonnes \",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Cliquez sur {{addNew}} pour ajouter des vues, des pages ou importer plus de données. \",\n        \"convert to card view, select data, and more.\": \"convertir en vue fiche, sélectionner des données et plus.\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Définissez des options de formatage de la colonne, des formules ou bien les types de la colonne (dates, liste à choix unique, pièce jointe etc.) \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Cliquez sur {{helpCenter}} pour voir la documentation et poser vos questions.\",\n        \"creator panel\": \"menu latéral\",\n        \"AI Assistant\": \"Assistant IA\"\n    },\n    \"TypeTransformation\": {\n        \"Cancel\": \"Annuler\",\n        \"Apply\": \"Ok\",\n        \"Revise\": \"Amender\",\n        \"Preview\": \"Aperçu\",\n        \"Update formula (Shift+Enter)\": \"Modifier la formule (Maj+Entrée)\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"DESCRIPTION DE LA COLONNE\",\n        \"COLUMN LABEL\": \"LIBELLÉ\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"Utilisateurs tests\",\n        \"Users from table\": \"Utilisateurs de la table d'appairage\",\n        \"View as\": \"Voir en tant que\",\n        \"Other users from table\": \"Autres utilisateurs de la table\",\n        \"Shared users\": \"Utilisateurs partagés\"\n    },\n    \"ConditionalStyle\": {\n        \"Add conditional style\": \"Ajouter un style conditionnel\",\n        \"Add another rule\": \"Ajouter une autre règle\",\n        \"Error in style rule\": \"Erreur dans la règle de style\",\n        \"Row style\": \"Style de ligne\",\n        \"Rule must return True or False\": \"La règle doit retourner Vrai ou Faux\",\n        \"Conditional Style\": \"Style conditionnel\",\n        \"IF...\": \"SI...\",\n        \"Row Style\": \"Style de ligne\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Devise invalide\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"CHOIX\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN DESCRIPTION\": \"DESCRIPTION DE LA COLONNE\",\n        \"COLUMN ID: \": \"Identifiant de la colonne : \",\n        \"COLUMN LABEL\": \"LIBELLÉ\",\n        \"Cancel\": \"Annuler\",\n        \"Save\": \"Enregistrer\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Convertir en formule\"\n    },\n    \"FieldEditor\": {\n        \"Unable to finish saving edited cell\": \"Impossible de terminer l'enregistrement\",\n        \"It should be impossible to save a plain data value into a formula column\": \"Il devrait être impossible d'enregistrer une valeur de donnée brute dans une colonne de formule\"\n    },\n    \"FormulaEditor\": {\n        \"Errors in all {{numErrors}} cells\": \"Erreur dans les {{numErrors}} cellules\",\n        \"Error in the cell\": \"Erreur dans la cellule\",\n        \"Column or field is required\": \"Colonne ou champ requis\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Erreur dans {{numErrors}} des {{numCells}} cellules\",\n        \"editingFormula is required\": \"editingFormula est nécessaire\",\n        \"Enter formula or {{button}}.\": \"Saisir la formule ou {{button}}.\",\n        \"Enter formula.\": \"Saisir la formule.\",\n        \"Expand Editor\": \"Élargir l'éditeur\",\n        \"use AI Assistant\": \"Utiliser l'assistance IA\"\n    },\n    \"NumericTextBox\": {\n        \"Decimals\": \"Décimales\",\n        \"Currency\": \"Devise\",\n        \"Default currency ({{defaultCurrency}})\": \"Devise par défaut ({{defaultCurrency}})\",\n        \"Number Format\": \"Format de nombre\",\n        \"min\": \"min\",\n        \"Text\": \"Texte\",\n        \"max\": \"max\",\n        \"Field Format\": \"Format du champ\",\n        \"Spinner\": \"Roue\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Langue\"\n    },\n    \"Reference\": {\n        \"Row ID\": \"Id de ligne\",\n        \"CELL FORMAT\": \"FORMAT DE CELLULE\",\n        \"SHOW COLUMN\": \"MONTRER LA COLONNE\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[label du lien] URL\"\n    },\n    \"GristTooltips\": {\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Appliquez un formatage conditionnel aux cellules de cette colonne lorsque les conditions de la formule sont remplies.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Les cellules d'une colonne Référence identifient toujours un enregistrement {{entire}} de cette table, mais vous pouvez sélectionner la colonne de cet enregistrement à afficher.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Cliquez sur “Ouvrir les styles de ligne” pour appliquer le formatage conditionnel aux lignes.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Appliquez un formatage conditionnel aux lignes en fonction de formules.\",\n        \"Updates every 5 minutes.\": \"Mises à jour toutes les 5 minutes.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Utilisez l'icône \\\\u{1D6BA} pour créer des tables récapitulatives (ou tables croisées dynamiques), pour les totaux ou les sous-totaux.\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Les colonnes références sont la clé des données {{relational}} dans Grist.\",\n        \"Select the table containing the data to show.\": \"Sélectionnez la table contenant les données à afficher.\",\n        \"Link your new widget to an existing widget on this page.\": \"Liez votre nouvelle vue à une vue existante sur cette page.\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Formules qui se déclenchent dans certains cas, et qui stockent la valeur calculée comme donnée.\",\n        \"Only those rows will appear which match all of the filters.\": \"Seules les lignes qui correspondent à tous les filtres apparaîtront.\",\n        \"Select the table to link to.\": \"Sélectionnez la table vers laquelle vous souhaitez établir un lien.\",\n        \"Selecting Data\": \"Sélection de données\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Réorganisez les champs de votre vue fiche en faisant glisser et en redimensionnant les cellules.\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"La page des données sources liste toutes les tables de votre document, y compris les tables récapitulatives et les tables non-inclues dans les mises en page.\",\n        \"The total size of all data in this document, excluding attachments.\": \"La taille totale de toutes les données de ce document, à l'exclusion des pièces jointes.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Cliquez sur le bouton Nouveau pour créer de nouveaux documents ou dossiers, ou importer des données.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"En cliquant sur {{EyeHideIcon}} dans chaque cellule, vous masquez le champ de cette vue sans le supprimer.\",\n        \"Editing Card Layout\": \"Modification de la mise en page de la fiche\",\n        \"Linking Widgets\": \"Lier les vues\",\n        \"Nested Filtering\": \"Filtrage imbriqué\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Les filtres épinglés sont affichés sous forme de boutons au-dessus de la vue.\",\n        \"Pinning Filters\": \"Filtres épinglés\",\n        \"Raw Data page\": \"Page des données sources\",\n        \"Reference Columns\": \"Colonnes références\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"C'est le secret des mises en page dynamiques et productives de Grist.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Essayez les changements sur une copie, puis décidez de remplacer l'original par vos modifications.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Utile pour stocker l'horodatage ou l'auteur d'un nouvel enregistrement, le nettoyage des données, et plus encore.\",\n        \"You can filter by more than one column.\": \"Vous pouvez filtrer par plus d'une colonne.\",\n        \"entire\": \"entier\",\n        \"relational\": \"relationnelles\",\n        \"Access Rules\": \"Règles d'accès\",\n        \"Learn more.\": \"En savoir plus.\",\n        \"They allow for one record to point (or refer) to another.\": \"Ils permettent à une ligne de pointer (ou de faire référence) vers une autre.\",\n        \"Add new\": \"Nouveau\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Les règles d'accès vous donnent le pouvoir de créer des règles nuancées pour déterminer qui peut voir ou modifier quelles parties de votre document.\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Utilisez l'icône 𝚺 pour créer des tables récapitulatives (ou tables croisées dynamiques), pour les totaux ou les sous-totaux.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Détachez pour cacher le bouton tout en conservant le filtre.\",\n        \"Anchor Links\": \"Ancres\",\n        \"Custom Widgets\": \"Vues personnalisées\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Pour créer un lien d'ancrage qui amène l'utilisateur à une cellule spécifique, cliquez sur une ligne et appuyez sur {{shortcut}}.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Vous pouvez choisir l'une de nos vues prédéfinies ou intégrer la vôtre en indiquant son URL complète.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"Pour configurer votre calendrier, sélectionnez les colonnes pour les dates de début/fin et le nom de l'évènement. Notez le type de chaque colonne.\"\n        },\n        \"Calendar\": \"Calendrier\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Impossible de trouver les bonnes colonnes ? Cliquez sur \\\"Changer de vue\\\" pour sélectionner la table contenant les évènements.\",\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"Un UUID est un texte généré aléatoirement qui est utile pour les identifiants uniques et les clés de jointures.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Les formules supportent beaucoup de fonctions Excel et la syntaxe Python complète. Un assistant IA est disponible sur certaines instances.\",\n        \"Lookups return data from related tables.\": \"Récupère les données d'une table liée.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Vous pouvez choisir parmi les vues disponibles dans le menu déroulant, ou utilisez le votre en fournissant son URL complète.\",\n        \"Use reference columns to relate data in different tables.\": \"Utilisez les colonnes de type Référence pour lier différentes tables entre elles.\",\n        \"Learn more\": \"En savoir plus\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Créez des formulaires simples directement dans Grist et partagez-les en un clic avec notre nouveau widget. {{learnMoreButton}}\",\n        \"Forms are here!\": \"Les formulaires sont là !\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Ces règles sont appliquées après le traitement de toutes les règles de la colonne, le cas échéant.\",\n        \"Example: {{example}}\": \"Exemple : {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"Filtrer les valeurs affichées dans la liste déroulante en fonction d'une condition.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Les vues communautaires sont créées et maintenues par des membres de la communauté de Grist.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Crée une colonne inversée dans la table cible qui peut être modifiée des deux côtés.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Cette limitation se produit lorsque l'une des colonnes d'une référence bidirectionnelle est de type Référence.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Pour permettre plusieurs assignations, modifiez le type de la colonne référencée en Référence Multiple.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Pour permettre des assignations multiples, modifiez le type de la colonne Référence en Référence Multiple.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Cette limitation se produit lorsque l'une des extrémités d'une référence bidirectionnelle est configurée comme une référence unique.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Les références bidirectionnelles ne peuvent pas contenir des formules ou des formules d'initialisation\",\n        \"The preview below this header shows how the selected user will see this document\": \"L'aperçu sous cette entête montre ce que l'utilisateur sélectionné peut voir\",\n        \"Manage users and resources in a Grist installation.\": \"Gérer les utilisateurs et les ressources de Grist.\",\n        \"[Learn more.]({{link}})\": \"[En savoir plus.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Les tables récapitulatives ne peuvent contenir que des formules.\",\n        \"The new Grist Assistant is here!\": \"Le nouvel assistant Grist est là !\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Les formules prennent en charge de nombreuses fonctions Excel et la syntaxe Python complète.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Crée une nouvelle colonne de type \\\"Référence multiple\\\" dans la table cible. Cette colonne et la colonne cible sont modifiables et synchronisées.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Le stockage interne signifie que toutes les pièces jointes sont stockées dans le fichier SQLite du document, tandis que le stockage externe indique que toutes les pièces jointes sont stockées dans le même stockage externe.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Comprenez, modifiez et travaillez vos données et formules avec l'aide du nouvel assistant IA de Grist !\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs. Do not submit passwords through this form, and be careful with links in it. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Ce formulaire a été créé par une personne utilisant Grist et n’est pas vérifié par Grist Labs. N’utilisez pas ce formulaire pour communiquer vos mots de passe et faites preuve de vigilance concernant les liens qu’il contient. Signalez tout formulaire malveillant à {{mail}}\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Cela vous permet d'ajouter des pièces jointes manquantes, par exemple lorsque vous importez un document dont les pièces jointes étaient externalisées. Seules les archives de pièces jointes au format .tar téléchargées depuis Grist peuvent être chargées ici.\",\n        \"Set the maximum number of lines for multi-line text.\": \"Définir le nombre maximum de lignes pour un texte multi-ligne.\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Ce formulaire a été créé par un utilisateur de Grist. Il n'est pas validé par Grist Labs ni aucune des parties fournissant ce service. Pour votre sécurité, ne fournissez pas vos mots de passe via ce formulaire, et faites attention quand vous cliquez sur des liens. Rapportez les formulaires malveillants à [{{mail}}](mailto:{{mail}}).\",\n        \"Comments are here!\": \"Les commentaires sont ici !\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Vous pouvez-ajouter des commentaires aux cellules, répondre aux fils de commentaires, et @-mentionner les collaborateurs.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"Lorsqu'il est coché, la valeur par défaut de ce champ peut-être préremplie à partir de l'URL en utilisant ses paramètres de requête.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"Avec les suggestions, les utilisateurs font des modifications dans une copie personnelle sans modifier le document original, puis ils soumettent ces suggestions à la revue du propriétaire du document avant leur intégration.\",\n        \"Unpin to hide the button while keeping the filter.\": \"Désépingler pour masquer le bouton tout en conservant le filtre.\"\n    },\n    \"ColumnTitle\": {\n        \"Add description\": \"Ajouter une description\",\n        \"Cancel\": \"Annuler\",\n        \"Column ID copied to clipboard\": \"Identifiant de la colonne copié\",\n        \"COLUMN ID: \": \"Identifiant de la colonne : \",\n        \"Column description\": \"Description de la colonne\",\n        \"Column label\": \"Libellé de la colonne\",\n        \"Provide a column label\": \"Donner un nom à la colonne\",\n        \"Save\": \"Sauvegarder\",\n        \"Close\": \"Fermer\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"DESCRIPTION\",\n        \"Set description\": \"Mettre une description\"\n    },\n    \"PagePanels\": {\n        \"Open creator panel\": \"Ouvrir le panneau de création\",\n        \"Close Creator Panel\": \"Fermer le panneau de création\",\n        \"Creator panel (right panel)\": \"Panneau de création (panneau latéral droit)\",\n        \"Document header\": \"En-tête du document\",\n        \"Main content\": \"Contenu principal\",\n        \"Main navigation and document settings (left panel)\": \"Navigation principale et paramètres du document (panneau de gauche)\",\n        \"Close navigation panel (left panel)\": \"Fermer le panneau de navigation (panneau de gauche)\",\n        \"Open navigation panel (left panel)\": \"Ouvrir le panneau de navigation (panneau de gauche)\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"J'ai compris\",\n        \"Unavailable Command\": \"Commande non disponible\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"La commande {{action}} n'est pas disponible dans ce navigateur. Vous pouvez toujours {{action}} en utilisant le raccourci clavier {{shortcut}}.\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"Effacer le champ\",\n        \"Copy\": \"Copier\",\n        \"Copy anchor link\": \"Copier un lien\",\n        \"Cut\": \"Couper\",\n        \"Hide field\": \"Masquer le champ\",\n        \"Paste\": \"Coller\",\n        \"Comment\": \"Commentaire\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Effacer la file d'attente\",\n        \"Webhook settings\": \"Paramètres des points d’ancrage Web\",\n        \"Cleared webhook queue.\": \"Effacement de la file d'attente du point d'ancrage Web.\",\n        \"Columns to check when update (separated by ;)\": \"Colonnes à vérifier lors de la mise à jour (séparées par des ;)\",\n        \"Event Types\": \"Types d'événements\",\n        \"Memo\": \"Mémo\",\n        \"Ready Column\": \"Colonne de déclenchement\",\n        \"Removed webhook.\": \"Point d'ancrage supprimé.\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Filtrer les changements dans ces colonnes (identifiants séparés par des points-virgules)\",\n        \"Status\": \"Statut\",\n        \"URL\": \"URL\",\n        \"Webhook Id\": \"Id du point d'ancrage Web\",\n        \"Table\": \"Table\",\n        \"Enabled\": \"Activé\",\n        \"Name\": \"Nom\",\n        \"Sorry, not all fields can be edited.\": \"Désolé, tous les champs ne peuvent pas être modifiés.\",\n        \"Header Authorization\": \"Entête de sécurité\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooks indisponibles dans les copies de documents non enregistrées\"\n    },\n    \"FormulaAssistant\": {\n        \"Grist's AI Formula Assistance. \": \"Assistance des formules de l'IA de Grist \",\n        \"Ask the bot.\": \"Demandez au bot.\",\n        \"Function List\": \"Liste des fonctions\",\n        \"Tips\": \"Conseils\",\n        \"Save\": \"Sauvegarder\",\n        \"Data\": \"Données\",\n        \"Formula Cheat Sheet\": \"Aide-mémoire pour les formules\",\n        \"Grist's AI Assistance\": \"Assistance IA de Grist\",\n        \"Need help? Our AI assistant can help.\": \"Besoin d'aide ? Notre assistant IA peut aider.\",\n        \"New Chat\": \"Nouveau Chat\",\n        \"Preview\": \"Aperçu\",\n        \"Regenerate\": \"Régénérer\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Allez voir notre {{helpFunction}}, notre {{formulaCheat}} ou notre {{community}} pour obtenir plus d'aide.\",\n        \"AI Assistant\": \"Assistant IA\",\n        \"Apply\": \"Appliquer\",\n        \"Cancel\": \"Annuler\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Bonjour, je suis l'assistant IA de Grist pour les formules.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Je ne peux aider que pour les formules, je ne peux pas créer de tables, de colonnes, de vues ou gérer les droits d'accès.\",\n        \"Learn more\": \"En apprendre plus\",\n        \"Clear conversation\": \"Effacer la conversation\",\n        \"Code view\": \"Vue du code\",\n        \"Press Enter to apply suggested formula.\": \"Appuyer sur entrer pour appliquer la formule suggérée.\",\n        \"Sign Up for Free\": \"S'inscrire gratuitement\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Créez un compte Grist gratuit pour commencer à utiliser l'assistant IA des formules.\",\n        \"There are some things you should know when working with me:\": \"Il y a certaines choses que vous devez savoir lorsque vous travaillez avec moi :\",\n        \"Capabilities\": \"Capacités\",\n        \"Community\": \"Communauté\",\n        \"Formula Help. \": \"Aide pour les formules. \",\n        \"What do you need help with?\": \"Quel est votre besoin d'aide ?\",\n        \"For higher limits, contact the site owner.\": \"Pour un plafond supérieur, contactez le propriétaire du site.\",\n        \"Formula AI Assistant is only available for logged in users.\": \"L'assistant IA pour les formules n'est disponible que pour les utilisateurs connectés.\",\n        \"upgrade to the Pro Team plan\": \"passer au plan Pro Team\",\n        \"You have used all available credits.\": \"Vous avez utiliser tout vos crédits disponibles.\",\n        \"upgrade your plan\": \"mettez à niveau\",\n        \"You have {{numCredits}} remaining credits.\": \"Vous avez {{numCredits}} crédits restants.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Pour augmenter le plafond, {{upgradeNudge}}.\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Pour plus d'informations sur les formules, consultez notre {{functionList}} et notre {{formulaCheatSheet}}, ou visitez notre {{community}}.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Lorsque vous me parlez, vos questions et la structure de votre document (visible dans {{codeView}}) sont envoyées à OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Parlez-moi comme à une personne. Inutile de préciser les noms de tables et de colonnes. Par exemple, vous pouvez demander : « Veuillez calculer le montant total de la facture. »\"\n    },\n    \"SupportGristNudge\": {\n        \"Help Center\": \"Centre d'aide\",\n        \"Close\": \"Fermer\",\n        \"Contribute\": \"Contribuer\",\n        \"Support Grist\": \"Support Grist\",\n        \"Opt in to Telemetry\": \"S'inscrire à l'envoi de données de télémétrie\",\n        \"Opted In\": \"Accepté\",\n        \"Support Grist page\": \"Soutenir Grist\",\n        \"Admin Panel\": \"Panneau d'administration\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Nous vous remercions ! Votre confiance et votre soutien sont très appréciés. Vous pouvez vous désinscrire à tout moment en cliquant sur le {{link}} dans le menu utilisateur.\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Cliquer pour insérer\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub\": \"GitHub\",\n        \"Help Center\": \"Centre d'aide\",\n        \"Home\": \"Accueil\",\n        \"Sponsor Grist Labs on GitHub\": \"Parrainer Grist Labs sur GitHub\",\n        \"GitHub Sponsors page\": \"Page de parrainage GitHub\",\n        \"Manage Sponsorship\": \"Gérer le parrainage\",\n        \"Opt in to Telemetry\": \"S'inscrire à l'envoi de données de télémétrie\",\n        \"Opt out of Telemetry\": \"Se désinscrire de l'envoi de données de télémétrie\",\n        \"Support Grist\": \"Support Grist\",\n        \"Telemetry\": \"Télémétrie\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Cette instance accepte l'envoi de données de télémétrie. Seul l'administrateur de l'espace est autorisé à modifier ce paramètre.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Cette instance est autorisée à utiliser la télémétrie. Seul l'administrateur de l'espace est autorisé à modifier ce paramètre.\",\n        \"You have opted out of telemetry.\": \"Vous avez choisi de ne pas envoyer de données de télémétrie.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Nous ne collectons que des statistiques d'usage, comme détaillé dans notre {{link}}, jamais le contenu des documents.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Vous pouvez vous désinscrire de la télémétrie à tout moment depuis cette page.\",\n        \"You have opted in to telemetry. Thank you!\": \"Merci de vous être inscrit à la télémétrie !\",\n        \"Sponsor\": \"Parrainage\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Le logiciel Grist est développé par Grist Labs, qui propose des offres hébergées gratuites et payantes. Nous mettons également le code Grist à disposition sous une licence OSS standard, gratuite et ouverte (Apache 2.0) sur {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Soutenez Grist en autorisant la télémétrie, qui nous aide à comprendre comment le produit est utilisé, afin que nous puissions prioriser les améliorations futures.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Nous sommes une petite équipe déterminée. Votre soutien est très important pour nous. Il démontre également qu'une communauté déterminée soutient ce produit.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Vous pouvez soutenir le développement open source de Grist en nous sponsorisant sur notre {{link}}.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Aucune donnée\",\n        \"No row selected in {{title}}\": \"Aucune ligne sélectionnée dans {{title}}\",\n        \"Not all data is shown\": \"Toute la donnée n'est pas visible\"\n    },\n    \"UserManager\": {\n        \"Allow anyone with the link to open.\": \"Permettre à toute personne possédant le lien de l'ouvrir.\",\n        \"Anyone with link \": \"Toute personne possédant le lien \",\n        \"Cancel\": \"Annuler\",\n        \"Close\": \"Fermer\",\n        \"Add {{member}} to your team\": \"Ajouter {{member}} à votre équipe\",\n        \"Confirm\": \"Confirmer\",\n        \"member\": \"membre\",\n        \"{{collaborator}} limit exceeded\": \"la limite de {{collaborator}} a été atteinte\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} sur {{limitTop}} {{collaborator}}s\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"L'utilisateur hérite des autorisations de {{parent}}. Pour les supprimer, réglez l'option \\\"Hériter de l'accès\\\" sur \\\"Aucun\\\".\",\n        \"team site\": \"espace d'équipe\",\n        \"Collaborator\": \"Collaborateur\",\n        \"Copy link\": \"Copier le lien\",\n        \"Create a team to share with more people\": \"Créer une équipe pour partager avec plus de personnes\",\n        \"Grist support\": \"Support utilisateur Grist\",\n        \"Guest\": \"Invité\",\n        \"Invite multiple\": \"Invitation multiple\",\n        \"Invite people to {{resourceType}}\": \"Inviter des personnes à {{resourceType}}\",\n        \"Link copied to clipboard\": \"Lien copié dans le presse-papiers\",\n        \"Manage members of team site\": \"Gérer les membres de l'espace d'équipe\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"L'absence d'accès par défaut permet d'accorder l'accès à des documents ou à des dossiers spécifiques, plutôt qu'à l'ensemble de l'espace d'équipe.\",\n        \"Off\": \"Désactivé\",\n        \"On\": \"Activé\",\n        \"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}}.\": \"Une fois que vous avez supprimé votre propre accès, vous ne pourrez pas le récupérer sans l'aide d'une autre personne disposant d'un accès suffisant à {{name}}.\",\n        \"Open Access Rules\": \"Ouvrir les règles d'accès\",\n        \"Outside collaborator\": \"Collaborateur externe\",\n        \"Public access\": \"Accès public\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"L'accès public est hérité de {{parent}}. Pour le supprimer, changer l'option 'Accès hérité' à 'Aucun'.\",\n        \"Public access: \": \"Accès public : \",\n        \"Remove my access\": \"Supprimer mon accès\",\n        \"Save & \": \"Sauvegarder & \",\n        \"Team member\": \"Membres de l'espace d'équipe\",\n        \"User may not modify their own access.\": \"L'utilisateur ne peut pas modifier ses propres accès.\",\n        \"Your role for this team site\": \"Votre rôle pour cet espace d'équipe\",\n        \"Your role for this {{resourceType}}\": \"Votre rôle pour cet {{resourceType}}\",\n        \"free collaborator\": \"collaborateur gratuit\",\n        \"guest\": \"invité\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"L'absence d'accès par défaut permet d'accorder l'accès à des documents ou à des dossiers spécifiques, plutôt qu'à l'ensemble de l'espace d'équipe.\",\n        \"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}}.\": \"Une fois que vous avez supprimé vos propres accès, vous ne pourrez pas les récupérer sans l'aide d'une autre personne disposant d'un accès suffisant au {{resourceType}}.\",\n        \"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.\": \"L'utilisateur a un accès en lecture seule à {{resource}} résultant d'un accès des ressources à l'intérieur. S'il est supprimé ici, cet utilisateur perdra l'accès aux ressources à l'intérieur.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Vous êtes sur le point de supprimer votre propre accès à {{resourceType}}\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"L'utilisateur hérite ses permissions de {{parent})}. Pour supprimer cela,            paramétrez 'Héritage d'accès à 'Aucun'.\",\n        \"Inherit access: \": \"Hériter des droits d'accès : \",\n        \"Access overview\": \"Aperçu de l'accès\",\n        \"Share it publicly\": \"Partager publiquement\",\n        \"Verify your sensitive data before sharing publicly\": \"Vérifier vos données sensibles avant de partager publiquement\",\n        \"Your {{resourceType}} will be accessible to anyone with the link, whether shared directly or found through a search engine. \\n Ensure that your {{resourceType}} does not contain sensitive data before sharing.\": \"Votre {{resourceType}} sera accessible par toute personne ayant le lien, que ce soit via un partage directe ou à travers un moteur de recherche.\\n Assurez vous que votre {{resourceType}} ne contienne pas de données sensible avant de partager.\"\n    },\n    \"SearchModel\": {\n        \"Search all tables\": \"Rechercher toutes les tables\",\n        \"Search all pages\": \"Rechercher dans toutes les pages\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Chercher\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"Affichage de {{displayedCount}} éléments sur {{totalCount}}. Rechercher pour plus de résultats.\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"DESCRIPTION\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Cacher l'éditeur\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Maximiser\",\n        \"Minimize\": \"Minimiser\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"Bon retour parmi nous\",\n        \"You can always switch sites using the account menu.\": \"Vous pouvez toujours changer d'espace en utilisant le menu du compte.\",\n        \"You have access to the following Grist sites.\": \"Vous avez accès aux espaces Grist suivants.\"\n    },\n    \"CardContextMenu\": {\n        \"Insert card above\": \"Insérer une fiche au dessus\",\n        \"Duplicate card\": \"Dupliquer la fiche\",\n        \"Insert card below\": \"Insérer une fiche en dessous\",\n        \"Delete card\": \"Supprimer la fiche\",\n        \"Copy anchor link\": \"Copier le lien d'ancrage\",\n        \"Insert card\": \"Insérer une fiche\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"Maybe later\": \"Peut-être plus tard\",\n        \"free coaching call\": \"appel d'assistance gratuit\",\n        \"Schedule call\": \"Planifier l'appel\",\n        \"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.\": \"Lors de l'appel, nous prendrons le temps de comprendre vos besoins et d'adapter l'appel à ces derniers. Nous pouvons vous montrer les bases de Grist, ou commencer tout de suite à travailler avec vos données pour construire les tableaux de bord dont vous avez besoin.\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Planifiez votre {{freeCoachingCall}} avec un membre de notre équipe.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Vous pouvez également consulter {{ourWeeklyWebinars}} pour en savoir plus sur Grist.\",\n        \"our weekly webinars\": \"nos webinaires hebdomadaires\",\n        \"Grist 101\": \"Les fondamentaux de Grist\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"Vous pouvez également suivre notre webinaire d'introduction, {{ourWeeklyWebinars}}, qui a été spécialement créé afin d'aider les nouveaux utilisateurs à maitriser les fondamentaux de Grist.\",\n        \"Free coaching call\": \"appel de coaching gratuit\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"Vous pouvez aussi jeter un œil à notre webinaire d'introduction, {{ourWeeklyWebinars}}, conçu pour aider les nouveaux utilisateurs à découvrir les fondamentaux de Grist.\"\n    },\n    \"FormView\": {\n        \"Publish\": \"Publier\",\n        \"Publish your form?\": \"Publier votre formulaire ?\",\n        \"Unpublish\": \"Dépublier\",\n        \"Unpublish your form?\": \"Dépublier votre formulaire ?\",\n        \"Are you sure you want to reset your form?\": \"Êtes-vous sûr de vouloir réinitialiser votre formulaire ?\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Toute personne ayant accès au lien ci-dessous peut voir le formulaire vide et soumettre une réponse.\",\n        \"Code copied to clipboard\": \"Code copié dans le presse-papiers\",\n        \"Copy code\": \"Copier le code\",\n        \"View\": \"Vue\",\n        \"Copy link\": \"Copier le lien\",\n        \"Embed this form\": \"Intégrer ce formulaire\",\n        \"Link copied to clipboard\": \"Lien copié dans le presse-papiers\",\n        \"Preview\": \"Aperçu\",\n        \"Reset form\": \"Réinitialiser le formulaire\",\n        \"Save your document to publish this form.\": \"Enregistrez votre document pour publier ce formulaire.\",\n        \"Share this form\": \"Partager ce formulaire\",\n        \"Reset\": \"Réinitialiser\",\n        \"Share\": \"Partager\",\n        \"Your form description goes here.\": \"Votre description se place ici.\",\n        \"# **Form Title**\": \"# **Titre du formulaire**\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"La publication de votre formulaire générera un lien de partage. Toute personne disposant du lien pourra accéder au formulaire et soumettre une réponse.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"La dépublication du formulaire désactivera le lien de partage. Les utilisateurs accédant au formulaire via ce lien verront une erreur.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Les utilisateurs sont limités à la soumission d'entrées (enregistrements dans votre table) et à la lecture de valeurs prédéfinies dans des champs tels que les colonnes de référence et de choix.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Votre formulaire est publié. Chaque modification est visible par les utilisateurs ayant accès au formulaire. Si vous souhaitez apporter des modifications en \\\"mode brouillon\\\", dépubliez le formulaire.\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Champs cachés\"\n    },\n    \"Menu\": {\n        \"Building blocks\": \"Blocs de construction\",\n        \"Columns\": \"Colonnes\",\n        \"Copy\": \"Copier\",\n        \"Cut\": \"Couper\",\n        \"Insert question above\": \"Insérer une question ci-dessus\",\n        \"Insert question below\": \"Insérer une question ci-dessous\",\n        \"Paragraph\": \"Paragraphe\",\n        \"Paste\": \"Coller\",\n        \"Separator\": \"Séparateur\",\n        \"Unmapped fields\": \"Champs non utilisés\",\n        \"Header\": \"Titre\",\n        \"New question\": \"Nouvelle question\",\n        \"More\": \"Plus\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Mapped\": \"Utilisé\",\n        \"Select all\": \"Tout sélectionner\",\n        \"Unmap fields\": \"Champs non utilisés\",\n        \"Unmapped\": \"Non utilisé\",\n        \"Clear\": \"Effacer\",\n        \"Map fields\": \"Champs utilisés\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Règles du champ\",\n        \"Required field\": \"Champ obligatoire\",\n        \"Ascending\": \"Croissant\",\n        \"Default\": \"Par défaut\",\n        \"Descending\": \"Décroissant\",\n        \"Field Format\": \"Format du champ\",\n        \"Field Rules\": \"Règles du champ\",\n        \"Horizontal\": \"Horizontale\",\n        \"Options Alignment\": \"Option d'alignement\",\n        \"Options Sort Order\": \"Option d'ordonnancement\",\n        \"Vertical\": \"Verticale\",\n        \"Radio\": \"Radio\",\n        \"Select\": \"Sélectionner\",\n        \"Accept value from URL\": \"Accepter une valeur depuis l'URL\",\n        \"Hidden field\": \"Champ caché\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"Paramètre d'URL :\\n{{colId}}=VALEUR\",\n        \"Options limit\": \"Limite du nombre d'options\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Supprimer\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Certaines colonnes obligatoires ne sont pas utilisées\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Pour utiliser cette vue, utilisez toutes les colonnes obligatoires à partir du panneau du créateur sur la droite.\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Pour utiliser ce widget, toutes les colonnes utilisées doivent être visibles. Veuillez contacter le propriétaire du document ou modifier les accès.\",\n        \"Some required columns are hidden by access rules\": \"Certaines colonnes obligatoires sont cachées faute d'accès\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Créez votre propre formulaire\",\n        \"Powered by\": \"Créé avec\",\n        \"Powered by Grist\": \"Créé avec Grist\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Erreur\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"Oups ! Le formulaire que vous recherchez n'existe pas.\",\n        \"Oops! This form is no longer published.\": \"Oups ! Ce formulaire n'est plus publié.\",\n        \"There was a problem loading the form.\": \"Il y a eu un problème de chargement du formulaire.\",\n        \"You don't have access to this form.\": \"Vous n'avez pas accès à ce formulaire.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Une erreur s'est produite lors de l'envoi de votre formulaire. Veuillez réessayer.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Formulaire envoyé\",\n        \"Thank you! Your response has been recorded.\": \"Nous vous remercions. Votre réponse a été enregistrée.\",\n        \"Submit new response\": \"Soumettre une nouvelle réponse\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"30 derniers jours\",\n        \"Next 7 days\": \"7 prochains jours\",\n        \"Last 7 days\": \"7 derniers jours\",\n        \"Last week\": \"Semaine passée\",\n        \"This month\": \"Ce mois-ci\",\n        \"This week\": \"Cette semaine\",\n        \"This year\": \"Cette année\",\n        \"Today\": \"Aujourd'hui\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Mapped\": \"Utilisé\",\n        \"Select all\": \"Sélectionner tout\",\n        \"Unmap fields\": \"Champs non utilisés\",\n        \"Map fields\": \"Champs utilisés\",\n        \"Clear\": \"Effacer\",\n        \"Unmapped\": \"Non utilisé\",\n        \"Hide {{label}}\": \"Masquer {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Masquer {{label}} (mode groupé)\",\n        \"Unmap {{label}}\": \"Désélectionner {{label}}\",\n        \"Unmap {{label}} (batch mode)\": \"Désélectionner {{label}} (mode groupé)\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Ajouter une section ci-dessus\",\n        \"Insert section below\": \"Ajouter une section ci-dessous\",\n        \"## **Header**\": \"## **Titre**\",\n        \"Description\": \"Description\"\n    },\n    \"AdminPanel\": {\n        \"Current\": \"Actuelle\",\n        \"Current version of Grist\": \"Version actuelle de Grist\",\n        \"Help us make Grist better\": \"Aidez-nous à améliorer Grist\",\n        \"Home\": \"Accueil\",\n        \"Telemetry\": \"Télémétrie\",\n        \"Support Grist Labs on GitHub\": \"Soutenir Grist Labs sur GitHub\",\n        \"Admin Panel\": \"Panneau d'administration\",\n        \"Sponsor\": \"Parrainage\",\n        \"Support Grist\": \"Soutenir Grist\",\n        \"Version\": \"Version\",\n        \"Check now\": \"Vérifier\",\n        \"Checking for updates...\": \"Vérifier les mises à jour...\",\n        \"Error\": \"Erreur\",\n        \"Error checking for updates\": \"Erreur lors de la vérification des mises à jour\",\n        \"Grist is up to date\": \"Grist est à jour\",\n        \"Last checked {{time}}\": \"Dernière vérification {{time}}\",\n        \"Learn more.\": \"En savoir plus.\",\n        \"No information available\": \"Aucune information disponible\",\n        \"OK\": \"OK\",\n        \"Security Settings\": \"Paramètres de sécurité\",\n        \"Grist releases are at \": \"Les releases de Grist sont disponibles sur \",\n        \"Newer version available\": \"Nouvelle version disponible\",\n        \"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 permet d'utiliser des formules très puissantes, en utilisant Python. Nous recommandons de définir la variable d'environnement GRIST_SANDBOX_FLAVOR sur gvisor si votre machine le supporte (la plupart le supportent), afin d'exécuter des formules dans chaque document à l'intérieur d'une sandbox isolée des autres documents et du réseau.\",\n        \"Updates\": \"Mises à jour\",\n        \"unconfigured\": \"non configuré\",\n        \"unknown\": \"inconnu\",\n        \"Auto-check when this page loads\": \"Vérification automatique au chargement de cette page\",\n        \"Sandbox settings for data engine\": \"Paramètres de la Sandbox pour le moteur de données\",\n        \"Check failed.\": \"La vérification a échoué.\",\n        \"Check succeeded.\": \"Vérification réussie.\",\n        \"No fault detected.\": \"Aucun défaut n'a été détecté.\",\n        \"Notes\": \"Notes\",\n        \"Authentication\": \"Authentification\",\n        \"Administrator Panel Unavailable\": \"Panneau d'administration indisponible\",\n        \"Current authentication method\": \"Méthode actuelle d'authentification\",\n        \"Details\": \"Détails\",\n        \"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.\",\n        \"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.\",\n        \"Results\": \"Résultats\",\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 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.\",\n        \"Key to sign sessions with\": \"Clé de signature des sessions\",\n        \"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.\",\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 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.\",\n        \"Sandboxing\": \"Bac à sable\",\n        \"Self Checks\": \"Auto contrôles\",\n        \"Session Secret\": \"Secret de session\",\n        \"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}}\",\n        \"Enable Grist Enterprise\": \"Activer Grist Entreprise\",\n        \"Enterprise\": \"Entreprise\",\n        \"checking\": \"vérification\",\n        \"Off\": \"Désactivé\",\n        \"Contact us\": \"Nous contacter\",\n        \"New, Enterprise\": \"Nouveau, Entreprise\",\n        \"Log Streaming\": \"Flux de journalisation\",\n        \"Audit Logs\": \"Journaux d'audit\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} supplémentaires\",\n        \"On\": \"Activé\",\n        \"Grist Instance\": \"Instance de Grist\",\n        \"Auto-check weekly\": \"Vérification automatique hebdomadaire\",\n        \"No record of last version check\": \"Aucune sauvegarde de la dernière vérification de version\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Si vous activez Grist Enterprise, vous pouvez configurer la diffusion en continu des événements d'audit de Grist vers un système externe de gestion des informations et des événements de sécurité (SIEM). Pour en savoir plus : {{contactUsLink}} .\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Les vérifications automatiques sont désactivées. Définissez la variable d'environnement GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING sur « true » pour les activer.\",\n        \"auth error\": \"erreur d'authentification\",\n        \"configured\": \"configuré\",\n        \"more...\": \"plus...\",\n        \"no authentication\": \"pas d'authentification\",\n        \"unavailable\": \"indisponible\",\n        \"default\": \"par défaut\",\n        \"{{count}} admin accounts_one\": \"{{count}} comptes d'administration\",\n        \"{{count}} admin accounts_other\": \"{{count}} comptes d'administration\",\n        \"Admin account not found\": \"Compte d'administration non trouvé\",\n        \"Administrative accounts\": \"Comptes d'administration\",\n        \"The users with administrative accounts\": \"Les utilisateurs avec un compte d'administration\",\n        \"Version {{versionNumber}}\": \"Version {{versionNumber}}\",\n        \"no admin accounts\": \"pas de compte d'administration\",\n        \"Are you sure you want to restart Grist?\": \"Êtes-vous certain de vouloir redémarrer Grist ?\",\n        \"Grist is running in an environment that doesn't support restarting from the admin panel.\": \"Grist est lancé dans un environnement qui ne support pas le redémarrage depuis le panneau d’administration.\",\n        \"Restart\": \"Redémarrer\",\n        \"Restart Grist\": \"Redémarrer Grist\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"Redémarrer Grist pour appliquer les changements en attente ou résoudre les problèmes.\",\n        \"Restart Grist?\": \"Redémarrer Grist ?\",\n        \"Restarting Grist...\": \"Grist en cours de redémarrage...\",\n        \"This will apply any pending changes and briefly interrupt access for all users.\": \"Ceci appliquera tout changement en attente et coupera durant une courte durée l'accès pour tous les utilisateurs.\",\n        \"You can still restart Grist manually.\": \"Vous pouvez toujours redémarrer Grist manuellement.\",\n        \"error in {{provider}}: {{verdict}}\": \"erreur dans {{provider}} : {{verdict}}\",\n        \"Please restart Grist manually.\": \"Redémarrez Grist manuellement s’il vous plaît.\",\n        \"Restart Grist to apply pending changes.\": \"Redémarrez Grist pour appliquer les changements en attente.\",\n        \"Restart unavailable\": \"Redémarrage indisponible\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Aucun choix configuré\",\n        \"No values in show column of referenced table\": \"Pas de valeurs dans la colonne montrée de la table de référence\",\n        \"Hide\": \"Cacher\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Annuler\",\n        \"Choose a name and url for your team site\": \"Choisissez un nom et une adresse URL pour votre espace d'équipe\",\n        \"Create site\": \"Créer un site\",\n        \"Domain name is invalid\": \"Le nom de domaine n'est pas valide\",\n        \"Domain name is required\": \"Le nom de domaine est requis\",\n        \"Go to your site\": \"Aller vers votre espace d'équipe\",\n        \"Team name\": \"Nom de l'espace d'équipe\",\n        \"Team site created\": \"L'espace d'équipe a été créé\",\n        \"Team url\": \"Url de l'espace d'équipe\",\n        \"Work as a Team\": \"Travailler en équipe\",\n        \"Billing is not supported in grist-core\": \"La facturation n'est pas prise en charge dans un Grist auto-hébergé\",\n        \"Team name is required\": \"Le nom de l'espace d'équipe est requis\"\n    },\n    \"Toggle\": {\n        \"Field Format\": \"Format du champ\",\n        \"Checkbox\": \"Case à cocher\",\n        \"Switch\": \"Interrupteur\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Supprimer la colonne\"\n    },\n    \"ChoiceEditor\": {\n        \"Error in dropdown condition\": \"Erreur dans la condition de la liste déroulante\",\n        \"No choices to select\": \"Aucun choix à sélectionner\",\n        \"No choices matching condition\": \"Aucun choix correspondant à la condition\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"Erreur dans la condition de la liste déroulante\",\n        \"No choices to select\": \"Aucun choix à sélectionner\",\n        \"No choices matching condition\": \"Aucun choix correspondant à la condition\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Invalid columns: {{colIds}}\": \"Colonnes invalides : {{colIds}}\",\n        \"Set dropdown condition\": \"Définir la condition de la liste déroulante\",\n        \"Dropdown Condition\": \"Condition de la liste déroulante\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Saisir la condition.\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Erreur dans la condition de la liste déroulant\",\n        \"No choices matching condition\": \"Aucun choix correspondant à la condition\",\n        \"No choices to select\": \"Aucun choix à sélectionner\"\n    },\n    \"FormRenderer\": {\n        \"Submit\": \"Soumettre\",\n        \"Select...\": \"Sélectionner...\",\n        \"Search\": \"Rechercher\",\n        \"Reset\": \"Réinitialiser\",\n        \"Submitting…\": \"En cours de soumission…\",\n        \"Clear selection for: {{-inputLabel}}\": \"Effacer la sélection pour : {{-inputLabel}}\"\n    },\n    \"widgetTypesMap\": {\n        \"Table\": \"Table\",\n        \"Form\": \"Formulaire\",\n        \"Custom\": \"Personnalisée\",\n        \"Chart\": \"Graphique\",\n        \"Card List\": \"Liste de fiches\",\n        \"Card\": \"Fiche\",\n        \"Calendar\": \"Calendrier\"\n    },\n    \"TimingPage\": {\n        \"Max Time (s)\": \"Temps maximum (s)\",\n        \"Average Time (s)\": \"Temps moyen (s)\",\n        \"Column ID\": \"ID de la colonne\",\n        \"Loading timing data. Don't close this tab.\": \"Chargement des données de chronométrage. Ne pas fermer cet onglet.\",\n        \"Formula timer\": \"Chronomètre de formule\",\n        \"Number of Calls\": \"Nombre d'appels\",\n        \"Table ID\": \"ID de la table\",\n        \"Total Time (s)\": \"Temps total\"\n    },\n    \"DocTutorial\": {\n        \"Next\": \"Suivant\",\n        \"Restart\": \"Redémarrer\",\n        \"Click to expand\": \"Cliquez pour agrandir\",\n        \"End tutorial\": \"Fin du tutoriel\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Voulez-vous recommencer le tutoriel ? Tous les progrès seront perdus.\",\n        \"Previous\": \"Précédent\",\n        \"Finish\": \"Finir\"\n    },\n    \"OnboardingCards\": {\n        \"Complete our basics tutorial\": \"Complétez notre tutoriel des bases\",\n        \"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.\",\n        \"3 minute video tour\": \"Visite guidée vidéo de 3 minutes\",\n        \"Complete the tutorial\": \"Compléter le tutoriel\",\n        \"Learn the basics 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.\"\n    },\n    \"OnboardingPage\": {\n        \"Back\": \"Retour\",\n        \"Discover Grist in 3 minutes\": \"Découvrez Grist en 3 minutes\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Mettez la main à la pâte avec le tutoriel des bases Grist\",\n        \"Type here\": \"Tapez ici\",\n        \"Welcome\": \"Bienvenue\",\n        \"What organization are you with?\": \"Quelle est l'organisation à laquelle vous appartenez ?\",\n        \"Your organization\": \"Votre organisation\",\n        \"Your role\": \"Votre rôle\",\n        \"Next step\": \"Prochaine étape\",\n        \"Skip step\": \"Sauter cette étape\",\n        \"Skip tutorial\": \"Sauter le tutoriel\",\n        \"Go to the tutorial!\": \"Allez au tutoriel !\",\n        \"Tell us who you are\": \"Dites-nous qui vous êtes\",\n        \"What brings you to Grist (you can select multiple)?\": \"Qu'est-ce qui vous amène à Grist (vous pouvez en sélectionner plusieurs) ?\",\n        \"What is your role?\": \"Quel est votre rôle ?\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist ressemble à un tableur, mais ne fonctionne pas toujours comme tel. Découvrez ce qui le rend unique.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"Disable Grist Enterprise\": \"Désactiver Grist Entreprise\",\n        \"Enable Grist Enterprise\": \"Activer Grist Entreprise\",\n        \"Grist Enterprise is **enabled**.\": \"Grist Entreprise est **activé**.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Une clef d'activation est nécessaire pour utiliser Grist Enterprise au delà de la période d'évaluation\\nde 30 jours. Obtenez une clef d'activation en [nous contactant]({{contactLink}}) aujourd'hui. Vous\\nn'avez pas besoin de clef d'activation pour utiliser Grist Core.\\n\\nEn savoir plus dans le [Centre d'Aide]({{helpCenter}}).\",\n        \"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}}).\": \"Une clef d'activation est nécessaire pour utiliser Grist Enterprise après la période d'évaluation\\nde 30 jours. Obtenez une clef d'activation en [vous connectant à Grist\\nEnterprise]({{signupLink}}). Vous n'avez pas besoin de clef d'activation pour utiliser\\nGrist Core.\\n\\nEn savoir plus dans notre [Centre d'Aide]({{helpCenter}}).\",\n        \"Activate\": \"Activer\",\n        \"Activation key\": \"Clé d'activation\",\n        \"Copy to clipboard\": \"Copier dans le presse-papier\",\n        \"Expiration date\": \"Date d'expiration\",\n        \"Installation ID copied to clipboard\": \"ID d'installation copié dans le presse-papiers\",\n        \"Installation ID:\": \"ID d'installation :\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Pour en savoir plus, consultez notre [Centre d’aide]({{helpCenter}}).\",\n        \"Paste your activation key\": \"Collez votre clé d'activation\",\n        \"Plan name\": \"Nom de l'offre\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Pour continuer à utiliser Grist Enterprise, vous devez\\n                  [nous contacter]({{signupLink}}) pour obtenir votre clé d'activation.\",\n        \"You are currently trialing Grist Enterprise.\": \"Vous testez actuellement Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"Vous n'avez pas d'abonnement actif.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Votre clé d'activation a expiré en raison du dépassement des limites.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Votre instance sera en mode **lecture seule** dans **{{days}}** jour(s).\",\n        \"Your subscription expired on {{date}}.\": \"Votre abonnement a expiré le {{date}}.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Votre période d'essai a expiré le **{{expireAt}}**. Pour continuer à utiliser Grist Enterprise, vous devez\\n[souscrire à Grist Enterprise]({{signupLink}}) et coller votre clé d'activation ci-dessous.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Un abonnement actif est requis pour continuer à utiliser Grist Enterprise. Vous pouvez\\nactiver votre abonnement en [vous inscrivant à Grist Enterprise ]({{signupLink}}) et en collant votre\\nclé d'activation ci-dessous.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Une clé d'activation est utilisée pour exécuter Grist Enterprise après une période d'essai\\n        de 30 jours a expiré. Obtenez une clé d'activation en vous inscrivant à Grist\\n        Entreprise]({{signupLink}}). Vous n'avez pas besoin d'une clé d'activation pour exécuter\\n        Grist Core.\",\n        \"Installation seats\": \"Places sur l'instance\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Supprimer\",\n        \"Delete data and this widget.\": \"Supprimer les données et la vue.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Garder les données et supprimer la vue. La table restera disponible dans {{rawDataLink}}\",\n        \"Table {{tableName}} will no longer be visible\": \"La table {{tableName}} ne sera plus visible\",\n        \"Raw Data page\": \"la page de données source\"\n    },\n    \"CustomWidgetGallery\": {\n        \"(Missing info)\": \"(Information manquante)\",\n        \"Add widget\": \"Ajouter un widget\",\n        \"Developer:\": \"Développeur/se :\",\n        \"Add a widget from outside this gallery.\": \"Ajouter un widget de votre propre galerie.\",\n        \"Cancel\": \"Annuler\",\n        \"Change widget\": \"Modifier le widget\",\n        \"Choose custom widget\": \"Choisir un widget personnalisé\",\n        \"Community Widget\": \"Widget de la communauté\",\n        \"Custom URL\": \"URL personnalisée\",\n        \"Grist Widget\": \"Widget Grist\",\n        \"Last updated:\": \"Dernière mise à jour :\",\n        \"No matching widgets\": \"Pas de vues correspondantes\",\n        \"Search\": \"Rechercher\",\n        \"Widget URL\": \"URL du widget\",\n        \"Learn more about custom widgets\": \"En savoir plus sur les Vues personnalisées\",\n        \"Add Your Own Widget\": \"Ajouter votre propre widget\"\n    },\n    \"markdown\": {\n        \"The toggle is **off**\": \"L'interrupteur est sur **désactivé**\",\n        \"The toggle is **on**\": \"L'interrupteur est sur **activé**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nouvelle fonction Markdown\\n *\\n *      Nous pouvons _écrire_ du [Markdown hibituel](https://markdownguide.org) *à l'intérieur*\\n *      d'un Élément Grainjs.\"\n            }\n        }\n    },\n    \"markdown.d\": {\n        \"The toggle is **off**\": \"L'interrupteur est sur **désactivé**\",\n        \"The toggle is **on**\": \"L'interrupteur est sur **activé**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nouvelle fonction Markdown\\n *\\n *      Nous pouvons _écrire_ [avec la syntaxe Markdown](https://markdownguide.org) *directement*\\n *      dans un élément Grainjs.\"\n            }\n        }\n    },\n    \"HomeIntroCards\": {\n        \"3 minute video tour\": \"Visite guidée de 3 minutes\",\n        \"Blank document\": \"Document vide\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"Trouver des solutions et parcourir d'autres ressources {{helpCenterLink}}\",\n        \"Finish our basics tutorial\": \"Terminer le tutoriel sur les bases\",\n        \"Help center\": \"Centre d'aide\",\n        \"Import file\": \"Importer un fichier\",\n        \"Learn more {{webinarsLinks}}\": \"En savoir plus {{webinarsLinks}}\",\n        \"Start a new document\": \"Commencer un nouveau document\",\n        \"Templates\": \"Modèles\",\n        \"Tutorial\": \"Tutoriel\",\n        \"Webinars\": \"Webinaires\",\n        \"Learn more\": \"En savoir plus\",\n        \"Find solutions and explore more resources\": \"Trouver des solutions et explorer plus de ressources\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Add two-way reference\": \"Ajouter une référence bi-directionnelle\",\n        \"Delete\": \"Supprimer\",\n        \"Delete column {{column}} in table {{table}}?\": \"Supprimer la colonne {{column}} de la table {{table}} ?\",\n        \"Two-way Reference\": \"Référence bi-directionnelle\",\n        \"Target table\": \"Table cible\",\n        \"Delete two-way reference?\": \"Supprimer la référence bi-directionnelle ?\",\n        \"Column\": \"Colonne\",\n        \"Table\": \"Table\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"C'est l'inverse de la colonne de référence {{column}} dans le tableau {{table}}.\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Cela supprimera la colonne référence {{refCol}} de la table {{refTable}}. La colonne référence {{myName}} restera dans la table actuelle {{myTable}}.\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"Panneau d'administration\",\n        \"Close\": \"Fermer\",\n        \"Help Center\": \"Centre d'aide\",\n        \"Opt in to Telemetry\": \"Activer la télémétrie\",\n        \"Opted In\": \"Activé\",\n        \"Support Grist\": \"Soutenir Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Nous vous remercions ! Votre confiance et votre soutien sont très appréciés. Vous pouvez vous désinscrire à tout moment en cliquant sur le {{link}} dans le menu utilisateur.\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Activez la télémétrie pour nous aider à comprendre comment le produit est utilisé, afin que nous puissions prioriser les améliorations futures.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Nous collectons uniquement des statistiques d'utilisation, comme détaillé dans notre {{helpCenterLink}} - jamais le contenu des documents. Vous pouvez désactivez la fonctionnalité à tout moment via {{supportGristLink}} dans le menu.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Annuler\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Chaque enregistrement de {{targetTable}} ne peut être assigné qu'à un seul enregistrement de {{sourceTable}}.\",\n        \"Reassign\": \"Réassigner\",\n        \"Reassign to new {{sourceTable}} records.\": \"Réassigner à des nouveaux records de {{sourceTable}}.\",\n        \"Record already assigned_one\": \"Enregistrement déjà assigné\",\n        \"Record already assigned_other\": \"Enregistrements déjà assignés\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Réaffecté à {{sourceTable}} de {{sourceName}}.\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"l'enregistrement {{targetName}} de {{targetTable}} est déjà assigné à {{oldSourceName}} de {{sourceTable}}.\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Panneau d'administration\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Splunk\": \"Splunk\",\n        \"Add destination\": \"Ajouter destination\",\n        \"Add streaming destination\": \"Ajouter une destination de streaming\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Êtes-vous certain·e de vouloir supprimer cette destination de streaming ? Cette action ne pourra pas être annulée.\",\n        \"Delete streaming destination?\": \"Effacer la destination de streaming ?\",\n        \"Delete\": \"Supprimer\",\n        \"Destination\": \"Destination\",\n        \"Destinations\": \"Destinations\",\n        \"Edit\": \"Modifier\",\n        \"Enter URL\": \"Entrer une URL\",\n        \"Enter token\": \"Entrer un token\",\n        \"Learn more\": \"En savoir plus\",\n        \"Other\": \"Autre\",\n        \"Save\": \"Enregistrer\",\n        \"Start streaming\": \"Démarrer le streaming\",\n        \"Token\": \"Token\",\n        \"URL\": \"URL\",\n        \"Cancel\": \"Abandonner\",\n        \"Edit streaming destination\": \"Modifier la destination de streaming\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Configurez la diffusion en continu des événements d'audit de Grist vers un système externe de gestion des informations et des événements de sécurité (SIEM) comme Splunk. {{learnMoreLink}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit logs for {{siteName}}\": \"Journaux d'audit pour {{siteName}}\",\n        \"Audit Logs\": \"Journaux d'audit\",\n        \"Home\": \"Accueil\",\n        \"Log streaming\": \"Flux de journalisation\",\n        \"Contact us\": \"Nous contacter\",\n        \"Only site owners may access audit logs.\": \"Seuls les propriétaires de site peuvent accéder aux journaux d'audit.\",\n        \"upgrade your plan\": \"actualiser son contrat\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Si vous activez Grist Enterprise, vous pouvez configurer la diffusion en continu des événements d'audit de Grist vers un système SIEM (gestion des informations et des événements de sécurité) externe. Pour en savoir plus : {{contactUsLink}}.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Vous pouvez configurer la diffusion en continu des événements d'audit de Grist vers un système SIEM (gestion des informations et des événements de sécurité) externe si vous {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"All\": \"Tous\",\n        \"Delete\": \"Supprimer\",\n        \"Delete {{name}}\": \"Supprimer {{name}}\",\n        \"Edited {{at}}\": \"Modifié le {{at}}\",\n        \"Last edited\": \"Dernière modification\",\n        \"Manage users\": \"Gérer les utilisateurs\",\n        \"Move\": \"Déplacer\",\n        \"Name\": \"Nom\",\n        \"Pin\": \"Epingler\",\n        \"Sort by date\": \"Trier par date\",\n        \"Sort by name\": \"Trier par nom\",\n        \"Unpin\": \"Désépingler\",\n        \"No documents to show.\": \"Aucun document à afficher.\",\n        \"Recent\": \"Récent\",\n        \"Pinned\": \"Epinglé\",\n        \"Rename and set icon\": \"Renommer et définir une icône\",\n        \"Current workspace\": \"Dossier actuel\",\n        \"Access details\": \"Détails d'accès\",\n        \"Document will be moved to Trash.\": \"Le document sera déplacé vers la corbeille.\",\n        \"Requires edit permissions\": \"Nécessite des autorisations de modification\",\n        \"Move {{name}} to workspace\": \"Déplacer {{name}} vers le dossier\",\n        \"Workspace\": \"Dossier\",\n        \"context menu - {{- documentName }}\": \"menu contextuel - {{- documentName }}\",\n        \"Documents list\": \"Liste des documents\",\n        \"Download document...\": \"Télécharger le document...\",\n        \"Deleted {{at}}\": \"Supprimé {{at}}\"\n    },\n    \"RenameDocModal\": {\n        \"Icon\": \"Icône\",\n        \"Name\": \"Nom\",\n        \"Choose color\": \"Choisissez la couleur\",\n        \"Choose icon\": \"Choisir l'icône\",\n        \"Enter document name\": \"Entrez le nom du document\",\n        \"Rename and set icon\": \"Renommer et définir l'icône\",\n        \"Reset icon\": \"Réinitialiser l’icône\"\n    },\n    \"RightPanelUtils\": {\n        \"columns_one\": \"Colonne\",\n        \"columns_other\": \"Colonnes\",\n        \"fields_one\": \"Champ\",\n        \"fields_other\": \"Champs\",\n        \"series_one\": \"Séries\",\n        \"series_other\": \"Séries\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Attention aux vues personnalisées de source inconnue\",\n        \"Please review the following before adding a new custom widget.\": \"Merci de lire attentivement les recommandations suivantes avant de confirmer l’ajout du widget.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Les vues personnalisées sont **très puissantes** ! Certaines peuvent **lire et envoyer vos données** sur internet.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Êtes-vous certain de **faire confiance** à la **ressource derrière cette URL** ?\",\n        \"Do you **trust the person** who shared this link?\": \"Faites-vous **confiance à la personne** qui vous a **partagé ce lien** ?\",\n        \"Have you **reviewed the code** at this URL?\": \"Le cas échéant, avez-vous pu **auditer le code derrière ce lien** ?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"En cas de doute, vous pouvez transférer le lien aux équipes techniques responsables de votre instance.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"J’ai bien lu ces recommandations et je confirme vouloir ajouter cette vue\"\n    },\n    \"AdminLeftPanel\": {\n        \"Orgs\": \"Organisations\",\n        \"Users\": \"Utilisateurs\",\n        \"Workspaces\": \"Espace de travail\",\n        \"Admin controls\": \"Contrôles administrateur\",\n        \"Installation\": \"Installation\",\n        \"Learn more\": \"En savoir plus\",\n        \"Admin Controls\": \"Contrôles administrateur\",\n        \"Settings\": \"Paramètres\",\n        \"Admin area\": \"Zone d'administration\",\n        \"Docs\": \"Documentation\"\n    },\n    \"Assistant\": {\n        \"Learn more.\": \"En savoir plus.\",\n        \"You have used all available credits.\": \"Vous avez utilisé tous les crédits disponibles.\",\n        \"upgrade to the Pro Team plan\": \"passer au plan Pro Team\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Créez un compte Grist gratuit pour commencer à utiliser l'assistant IA.\",\n        \"start a new chat\": \"démarrer une nouvelle discussion\",\n        \"upgrade your plan\": \"mettez à niveau votre plan\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Pour des limites plus élevées, {{upgradeNudge}}.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Passez à Grist Enterprise pour essayer le nouvel assistant Grist. {{learnMoreLink}}\",\n        \"What do you need help with?\": \"Pour quelle raison avez-vous besoin d’aide ?\",\n        \"Press Enter to apply suggested formula.\": \"Appuyez sur Entrée pour appliquer la formule suggérée.\",\n        \"AI Assistant is only available for logged in users.\": \"L'assistant AI n'est disponible que pour les utilisateurs connectés.\",\n        \"You have {{numCredits}} remaining credits.\": \"Il vous reste {{numCredits}} crédits.\",\n        \"For higher limits, contact the site owner.\": \"Pour des limites plus élevées, contactez le propriétaire du site.\",\n        \"Sign Up for Free\": \"Inscrivez-vous gratuitement\",\n        \"Apply\": \"Appliquer\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"La conversation est devenue trop longue et je ne peux plus répondre efficacement. Veuillez {{startANewChatButton}} pour continuer à recevoir de l'aide.\"\n    },\n    \"apiconsole\": {\n        \"Type DELETE here if you wish to proceed.\": \"Tapez DELETE si vous souhaitez continuer.\",\n        \"Delete\": \"Supprimer\",\n        \"Deletion was not confirmed, skipping.\": \"Suppression non confirmée, action ignorée.\",\n        \"Confirm Deletion\": \"Confirmer la suppression\",\n        \"Are you sure you want to delete the following?\": \"Êtes-vous sûr·e de vouloir supprimer les éléments suivants ?\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Tapez DELETE si vous êtes sûr·e de vouloir supprimer.\\nEn cas de doute sur l'effet de cette action,\\nnous vous conseillons de ne pas poursuivre.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"pas d'accès\",\n        \"...loading\": \"...chargement en cours\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Une mise à jour critique de Grist est disponible.\\nEnvisagez de passer à la version {{version}} dès que possible.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Votre version de Grist est obsolète.\\nEnvisagez de passer à la version {{version}} dès que possible.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Recommandation : {{storageRecommendation}}\\nLorsque vous stockez des pièces jointes volumineuses, ou en grand nombre,\\n nous vous recommandons de les conserver dans un stockage externe. \\nCe document utilise actuellement un stockage interne pour les pièces jointes, \\nce qui le rend autonome, mais peut limiter les performances.\",\n        \"Set the document to use external storage.\": \"Paramétrez le document pour utiliser un stockage externe.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Veuillez attendre que l'opération précédente soit terminée.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Délai d'attente expiré pour le redémarrage du backend Grist\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Désactiver la fonctionnalité\",\n        \"Don't worry, you can disable it later if needed.\": \"Ne vous inquiétez pas, vous pourrez le désactiver plus tard si nécessaire.\",\n        \"Enable feature\": \"Activer la fonctionnalité\",\n        \"Experimental feature\": \"Fonctionnalité expérimentale\",\n        \"New record button\": \"Bouton d'ajout de ligne\",\n        \"Reload the page\": \"Recharger la page\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Accédez à cette adresse à tout moment pour désactiver cette fonctionnalité : {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Vous êtes sur le point de désactiver cette fonctionnalité expérimentale : {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Vous êtes sur le point d'activer cette fonctionnalité expérimentale : {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} désactivé.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} activé.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Nouvelle fiche\",\n        \"New record\": \"Nouvelle ligne\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Vous essayez d'accéder au panneau de création ? Utilisez {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Dupliquer la vue\",\n        \"Duplicate widgets\": \"Dupliquer les vues\",\n        \"Active\": \"Actif\",\n        \"Create new page\": \"Créer une nouvelle page\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"En cours de téléversement, merci de patienter…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Ajouter\",\n        \"Delete\": \"Supprimer\",\n        \"Download\": \"Télécharger\",\n        \"Drop files here to attach\": \"Déposer les fichiers ici pour les joindre\",\n        \"Drop files here to attach.\": \"Déposer les fichiers ici pour les joindre.\",\n        \"No attachments\": \"Aucune pièce-jointe\",\n        \"Preview not available.\": \"Aperçu non disponible.\",\n        \"{{index}} of {{total}}\": \"{{index}} sur {{total}}\",\n        \"Uploading…\": \"En cours de téléversement…\"\n    },\n    \"RowHeightConfig\": {\n        \"Max height\": \"Hauteur maximale\",\n        \"Max row height\": \"Hauteur de rang maximale\",\n        \"Expand all rows to this height\": \"Étendre tous les rangs jusqu'à cette hauteur\",\n        \"Change\": \"Changer\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Replier\",\n        \"Expand\": \"Déplier\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"utilisateur actif\",\n        \"active user list\": \"liste des utilisateurs actifs\",\n        \"open full active user list\": \"ouvrir la liste complète des utilisateurs actifs\"\n    },\n    \"AdminChecks\": {\n        \"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 permet grâce à Python de faire tourner de puissantes formules. Nous recommandons de définir la variable GRIST_SANDBOX_FLAVOR à gvisor si votre machine le permet (la plupart le permettent), pour faire tourner les formules dans un bac à sable isolé des autres documents et isolé du réseau.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Grist a un bilan de santé intégré souvent utilisé quand il tourne un container.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"Les requêtes qui parviennent à Grist devraient avoir la bonne entête Host. C'est essentiel quand GRIST_SERVE_SAME_ORIGIN est mise.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"Cette page de boot ne devrait pas être facile à atteindre. Soit rendez la indisponible quand la configuration est terminée (en retirant GRIST_BOOT_KEY) ou donnez une valeur suffisamment longue et cryptographiquement sûre à GRIST_BOOT_KEY.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"Les connexions websocket nécessitent HTTP 1.1 et le droit de passer quelques entêtes supplémentaires pour pouvoir fonctionner. Parfois un proxy inverse peut interférer avec ces exigences.\",\n        \"It is good practice not to run Grist as the root user.\": \"C'est une bonne pratique de ne pas faire tourner Grist en tant qu'utilisateur root.\",\n        \"The main page of Grist should be available.\": \"La page principale de Grist devrait être disponible.\"\n    },\n    \"ChoiceListEntry\": {\n        \"Edit\": \"Modifier\",\n        \"No choices configured\": \"Aucun choix n'est configuré\",\n        \"Reset\": \"Réinitialiser\",\n        \"Cancel\": \"Annuler\",\n        \"Save\": \"Sauvegarder\",\n        \"+{{count}} more_other\": \"+{{count}} de plus\",\n        \"+{{count}} more_one\": \"+{{count}} de plus\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Appliquer\",\n        \"Cancel\": \"Annuler\",\n        \"Preview\": \"Aperçu\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Fermer\",\n        \"Update preview\": \"Mettre à jour l'aperçu\",\n        \"Escape character\": \"Échapper le caractère\",\n        \"Field separator\": \"Séparateur de champ\",\n        \"First row contains headers\": \"Le premier rang contient des entêtes\",\n        \"Line terminator\": \"Terminateur de ligne\",\n        \"Number of rows\": \"Nombre de rangs\",\n        \"Quote character\": \"Caractère de citation\",\n        \"Quotes in fields are doubled\": \"Les guillemets dans les champs sont doublés\",\n        \"Skip leading whitespace\": \"Sauter l'espace de début\",\n        \"Start with row\": \"Démarrer avec un rang\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Encodage de caractère. Voir [les encodages supportés]({{link}})\",\n        \"Convert quoted fields\": \"Convertir les champs entre guillemets\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" ou \",\n        \"\\\"Regions\\\" are what we call the different parts of the user interface:\": \"Les \\\"régions\\\" sont les différentes zones de l'interface :\",\n        \"Accessibility\": \"Accessibilité\",\n        \"Close\": \"Fermer\",\n        \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\": \"enfin, le panneau latéral droit – ou panneau de création – est atteignable au clavier uniquement via son propre raccourci, et n'est pas inclus dans le cycle de régions suivantes et précédentes.\",\n        \"Focus on other parts of the user interface using the following shortcuts:\": \"Focalisez d'autres parties de l'interface avec ces raccourcis clavier :\",\n        \"High contrast theme\": \"Thème contraste élevé\",\n        \"Keyboard navigation\": \"Navigation au clavier\",\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"Dans un document, la navigation au clavier est d'abord bloquée dans la vue active.\",\n        \"On document pages, each [widget]({{supportPageUrl}}) is a region that can receive focus.\": \"dans un document, chaque [vue]({{supportPageUrl}}) est une région qui peut être focalisée,\",\n        \"On non-document pages, the main content area is a region.\": \"sur les autres pages, la zone affichant le contenu principal est une région,\",\n        \"Other important keyboard shortcuts\": \"Autres raccourcis clavier importants\",\n        \"The left panel, home of the main navigation.\": \"le panneau latéral gauche, qui contient la navigation principale,\",\n        \"The top panel, or the document header.\": \"le panneau supérieur, qui est l'entête du document,\",\n        \"To see other available themes, go to your {{profileSettingsLink}}.\": \"Pour voir les autres thèmes disponibles, consultez vos {{profileSettingsLink}}.\",\n        \"Use the high contrast theme (light appearance)\": \"Utiliser le thème contraste élevé (apparence claire)\",\n        \"You are currently **not using** the high contrast theme.\": \"Actuellement, vous **n'utilisez pas** le thème contraste élevé.\",\n        \"You are currently using the high contrast theme.\": \"Actuellement, vous utilisez le thème contraste élevé.\",\n        \"profile settings\": \"paramètres de compte\",\n        \"{{accessibilityModal}} Show the accessibility options (this modal)\": \"{{accessibilityModal}} Afficher les paramètres d'accessibilité (cette boîte de dialogue)\",\n        \"{{creatorPanelShortcut}} Focus to and from the creator panel\": \"{{creatorPanelShortcut}} Basculer la focalisation clavier depuis et vers le panneau de création\",\n        \"{{nextRegionShortcut}} Focus on the next region\": \"{{nextRegionShortcut}} Focaliser la région suivante\",\n        \"{{prevRegionShortcut}} Focus on the previous region\": \"{{prevRegionShortcut}} Focaliser la région précédente\",\n        \"{{shortcutsModal}} Show the complete list of keyboard shortcuts\": \"{{shortcutsModal}} Afficher la liste complète des raccourcis claviers\"\n    },\n    \"ProposedChangesPage\": {\n        \"Proposed changes\": \"Suggestions de modifications\",\n        \"Replace Original\": \"Remplacer l'original\",\n        \"This is a list of changes relative to the original document.\": \"Liste des modifications apportées par rapport au document original.\",\n        \"Replace original\": \"Replacer l'original\",\n        \"Accept\": \"Accepter\",\n        \"Accepted {{at}}.\": \"Accepté à {{at}}.\",\n        \"Dismiss\": \"Rejeter\",\n        \"Dismissed {{at}}.\": \"Rejeté à {{at}}.\",\n        \"Learn more\": \"En savoir plus\",\n        \"No changes found to suggest. Please make some edits.\": \"Aucune modification à suggérer. Veuillez apporter quelques modifications.\",\n        \"Retract suggestion\": \"Retirer la suggestion\",\n        \"Retracted {{at}}.\": \"Retirée à {{at}}.\",\n        \"Suggest change\": \"Suggérer une modification\",\n        \"Suggest changes\": \"Suggérer des modifications\",\n        \"Suggestion made {{at}}.\": \"Suggestion faite à {{at}}.\",\n        \"Suggestions\": \"Suggestions\",\n        \"The original document isn't asking for proposed changes.\": \"Le document original ne demande pas de propositions de modifications.\",\n        \"There are fresh changes that haven't been added to the suggestion yet.\": \"De nouvelles modifications n'ont pas encore été ajoutées aux suggestions.\",\n        \"This is a list of changes relative to the {{originalDocument}}.\": \"Voici une liste des modifications relative au {{originalDocument}}.\",\n        \"Update suggestion\": \"Mettre à jour une suggestion\",\n        \"Work on a copy\": \"Travailler sur une copie\",\n        \"Your suggestions\": \"Vos suggestions\",\n        \"experiment\": \"expérimentation\",\n        \"original document\": \"document original\",\n        \"Undo dismissal\": \"Annuler le rejet\"\n    },\n    \"commandList\": {\n        \"Show accessibility options\": \"Montrer les options d'accessibilité\",\n        \"Add a new viewsection to the currently active view\": \"Ajouter une nouvelle section de vue à la vue active\",\n        \"Adds all elements above the cursor to the selected range\": \"Ajoute tous les éléments au dessus du curseur à la sélection\",\n        \"Adds all elements below the cursor to the selected range\": \"Ajoute tous les éléments au dessous du curseur à la sélection\",\n        \"Adds all elements to the left of the cursor to the selected range\": \"Ajoute tous les éléments à gauche du curseur à la sélection\",\n        \"Adds all elements to the right of the cursor to the selected range\": \"Ajoute tous les éléments à droite du curseur à la sélection\",\n        \"Adds the currently selected column(ascending) to the current view's sort spec\": \"Ajoute la colonne sélectionnée(ordre croissant) aux spécifications de tri de la vue actuelle\",\n        \"Adds the currently selected column(descending) to the current view's sort spec\": \"Ajoute la colonne sélectionnée(ordre décroissant) aux spécifications de tri de la vue actuelle\",\n        \"Adds the element above the cursor to the selected range\": \"Ajoute l'élément au dessus du curseur à la sélection\",\n        \"Adds the element below the cursor to the selected range\": \"Ajoute l'élément au dessous du curseur à la sélection\",\n        \"Adds the element to the left of the cursor to the selected range\": \"Ajoute l'élément à gauche du curseur à la sélection\",\n        \"Adds the element to the right of the cursor to the selected range\": \"Ajoute l'élément à droite du curseur à la sélection\",\n        \"Activate assistant\": \"Active l’assistant\",\n        \"Clear the selected columns\": \"Effacer les colonnes sélectionnées\",\n        \"Clears the current copy selection, if any\": \"Efface la sélection copiée, s'il y en a une\",\n        \"Clears the currently selected cells\": \"Efface les cellules sélectionnée\",\n        \"Clears the section links in the current view\": \"Efface les liens de section dans la vue courante\",\n        \"Clears the section links in the current viewsection\": \"Efface les liens de sections dans la section de vue courante\",\n        \"Collapse the currently active viewsection\": \"Replie la section de vue active\",\n        \"Convert the selected columns from formula to data\": \"Convertir les colonnes sélectionnées de formule à donnée\",\n        \"Copy anchor link\": \"Copier le lien d'ancrage\",\n        \"Copy current selection to clipboard\": \"Copie la sélection courante dans le presse-papier\",\n        \"Copy current selection to clipboard including headers\": \"Copie la sélection courante dans le presse-papier en incluant les entêtes\",\n        \"Creates form for active table\": \"Crée un formulaire pour la table active\",\n        \"Cut current selection to clipboard\": \"Couper la sélection courante dans le presse-papier\",\n        \"Delete collapsed viewsection\": \"Supprimer la section de vue repliée\",\n        \"Delete the currently active viewsection\": \"Supprimer la section de vue active\",\n        \"Delete the currently selected columns\": \"Supprimer les colonnes sélectionnées\",\n        \"Delete the currently selected record(s)\": \"Supprimer les enregistrements séléctionnés\",\n        \"Detach active editor\": \"Détacher l'éditeur actif\",\n        \"Discard changes to a cell value\": \"Annuler les modifications de valeur d'une cellule\",\n        \"Display Grist documentation\": \"Afficher la documentation de Grist\",\n        \"Display shortcuts pane\": \"Afficher le panneau des raccourcis\",\n        \"Duplicate the currently active viewsection\": \"Dupliquer la section de vue active\",\n        \"Duplicate the currently selected record(s)\": \"Dupliquer les enregistrements sélectionnés\",\n        \"Edit label of the currently-selected field\": \"Modifier le label du champ sélectionné\",\n        \"Edit record layout\": \"Modifier la mise en page de l'enregistrement\",\n        \"Enter text into currently-selected cell and start editing\": \"Saisir du texte dans la cellule courante et commencer à modifier\",\n        \"Expand collapsed viewsection\": \"Déplier la section de vue\",\n        \"Fills current selection with the contents of the top row in the selection\": \"Rempli la sélection courante avec le contenu du rang le plus haut dans la sélection\",\n        \"Find\": \"Rechercher\",\n        \"Find next occurrence\": \"Rechercher la prochaine occurrence\",\n        \"Find previous occurrence\": \"Rechercher l’occurrence précédente\",\n        \"Finish editing a cell and save without moving to next record\": \"Terminer de modifier une cellule et sauvegarder sans se déplacer sur l'enregistrement suivant\",\n        \"Finish editing a cell, saving the value\": \"Terminer de modifier une cellule, et sauvegarder la valeur\",\n        \"Focus next page panel or widget\": \"Placer le focus sur la prochaine page, panneau ou widget\",\n        \"Focus previous page panel or widget\": \"Placer le focus sur la page, panneau ou widget précédente\",\n        \"Freeze or unfreeze selected columns\": \"Figer ou défiger les colonnes sélectionnées\",\n        \"Hide the currently selected columns\": \"Masquer les colonnes sélectionnées\",\n        \"Hide the currently selected fields\": \"Masquer les champs sélectionnés\",\n        \"Insert a new column, after the currently selected one\": \"Insérer une nouvelle colonne après celle sélectionnée\",\n        \"Insert a new column, before the currently selected one\": \"Insérer une nouvelle colonne avant celle sélectionnée\",\n        \"Insert a new record, after the currently selected one in an unsorted table\": \"Insérer un nouvel enregistrement, après celui sélectionné, dans une table non triée\",\n        \"Insert a new record, before the currently selected one in an unsorted table\": \"Insérer un nouvel enregistrement, avant celui sélectionné, dans une table non triée\",\n        \"Insert new column in default location\": \"Insérer une nouvelle colonne à l'emplacement par défaut\",\n        \"Insert the current date\": \"Insérer la date du jour\",\n        \"Insert the current date and time\": \"Insérer la date du jour et l'heure\",\n        \"Maximize the active section\": \"Agrandir la section active\",\n        \"Move down one page of records, or to next record in a card list\": \"Descendre d'une page d'enregistrements ou vers le prochain enregistrement dans une liste de carte\",\n        \"Move down to the last record\": \"Descendre jusqu'au dernier enregistrement\",\n        \"Move downward five records\": \"Se déplacer de cinq enregistrements vers le bas\",\n        \"Move downward to next record or field\": \"Se déplacer au prochain enregistrement ou champ vers le bas\",\n        \"Move left to the previous field\": \"Se déplacer à gauche sur le champ précédent\",\n        \"Move right to the next field\": \"Se déplacer à droite sur le prochain champ\",\n        \"Move to the first field or the beginning of a row\": \"Se déplacer sur le premier champ ou au début d'une ligne\",\n        \"Move to the last field or the end of a row\": \"Se déplacer sur le dernier champ ou à la fin d'une ligne\",\n        \"Move to the next field, saving changes if editing a value\": \"Se déplacer vers le prochain champ, sauvegarder les changement si vous étiez en train de modifier une valeur\",\n        \"Move to the previous field, saving changes if editing a value\": \"Se déplacer vers le champ précédent, sauvegarder les changement si vous étiez en train de modifier une valeur\",\n        \"Move up one page of records, or to previous record in a card list\": \"Se déplacer vers le haut d'une page d'enregistrements, ou sur l'enregistrement précédent dans une liste de cartes\",\n        \"Enters section linking mode in the current view\": \"Entre dans la section des modes de liens dans la vue courante\",\n        \"Exits section linking mode in the current view\": \"Quitte la section des modes de liens dans la vue courante\",\n        \"Move up to the first record\": \"Se déplacer vers le premier enregistrement\",\n        \"Move upward five records\": \"Se déplacer vers le haut de cinq enregistrements\",\n        \"Move upward to previous record or field\": \"Se déplacer au précédent enregistrement ou champ vers le haut\",\n        \"Moves the cursor to the correct location\": \"Bouge le curseur au bon emplacement\",\n        \"Open Custom widget configuration screen\": \"Ouvrir l'écran de configuration de widget personnalisé\",\n        \"Open comment thread\": \"Ouvrir le fil de commentaires\",\n        \"Open next page\": \"Ouvrir la prochaine page\",\n        \"Open previous page\": \"Ouvrir la page précédente\",\n        \"Opens document list\": \"Ouvre la liste des documents\",\n        \"Paste clipboard contents at cursor\": \"Colle le contenu du presse-papier sous le curseur\",\n        \"Print currently selected page widget\": \"Afficher la page du widget sélectionné\",\n        \"Push an undo action\": \"Appuyer sur une action d'annulation\",\n        \"Redo last action\": \"Refaire la dernière action\",\n        \"Rename the currently selected column\": \"Renommer la colonne sélectionnée\",\n        \"Reverts the sections links to the saved links the current view\": \"Rétablit les liens des sections aux liens enregistrés dans la vue actuelle\",\n        \"Saves the sections links in the current view\": \"Sauve les liens de section dans la vue courante\",\n        \"Selects all currently displayed cells\": \"Sélectionne toutes les cellules visibles\",\n        \"Shortcut to data selection tab\": \"Raccourci vers l'onglet de sélection des données\",\n        \"Shortcut to focus view tab if creator panel is open\": \"Raccourci pour mettre le focus sur l'onglet de vue si le panneau de création est ouvert\",\n        \"Shortcut to open document tab\": \"Raccourci pour ouvrir l'onglet de document\",\n        \"Shortcut to open field tab\": \"Raccourci pour ouvrir l'onglet de champ\",\n        \"Shortcut to open sort & filter menu\": \"Raccourci pour ouvrir le menu \\\"trier et filtrer\\\"\",\n        \"Shortcut to open the left panel\": \"Raccourci pour ouvrir le panneau de gauche\",\n        \"Shortcut to open the right panel\": \"Raccourci pour ouvrir le panneau de droite\",\n        \"Shortcut to open view tab\": \"Raccourci pour ouvrir l'onglet de vue\",\n        \"Shortcut to sort & filter tab\": \"Raccourci pour l'onglet \\\"trier et filtrer\\\"\",\n        \"Show hidden columns\": \"Afficher les colonnes masquées\",\n        \"Show raw data widget for table of currently selected page widget\": \"Afficher le widget de données brutes pour la table de la page actuellement sélectionnée\",\n        \"Show the record card widget of the selected record\": \"Afficher le widget de carte pour l'enregistrement sélectionné\",\n        \"Sort the view data by the currently selected field in ascending order\": \"Trier les données de la vue par le champ sélectionné dans l'ordre croissant\",\n        \"Sort the view data by the currently selected field in descending order\": \"Trier les données de la vue par le champ sélectionné dans l'ordre décroissant\",\n        \"Start editing the currently-selected cell\": \"Commencer à modifier la cellule sélectionnée\",\n        \"Toggle creator panel keyboard focus\": \"Alterner le focus du panneau de création\",\n        \"Toggle the currently selected checkbox or switch cell\": \"Basculer les valeurs de la case à coché ou de l'interrupteur sélectionné\",\n        \"Undo last action\": \"Annuler la dernière action\",\n        \"Use the currently selected row as table headers\": \"Utiliser la ligne sélectionnée comme entêtes de table\",\n        \"When in the search bar, close it and focus the current match\": \"Quand on est dans la barre de recherche, la fermer et mettre le focus sur la correspondance actuelle\",\n        \"When typed at the start of a cell, make this a formula column\": \"Quand tapé au début d'une cellule, transformer celle-ci en colonne de formule\",\n        \"showing a behavioral popup\": \"affichage d'une fenêtre contextuelle comportementale\",\n        \"Filter this column by just this cell's value\": \"Filtrer cette colonne par la valeur de cette cellule uniquement\"\n    },\n    \"GridViewMenusDateHelpers\": {\n        \"12-hour format\": \"format 12-heures\",\n        \"24-hour format\": \"format 24-heures\",\n        \"AM\": {\n            \"PM\": \"Matin/Après-Midi\"\n        },\n        \"Calendar\": \"Calendrier\",\n        \"Date helpers…\": \"Aides de dates…\",\n        \"Day\": \"Jour\",\n        \"Day of month\": \"Jour du mois\",\n        \"Day of week\": \"Jour de la semaine\",\n        \"Day of week (abbrev)\": \"Jour de la semaine (abrégé)\",\n        \"Day of week (full)\": \"Jour de la semaine (plein)\",\n        \"Day of week (numeric)\": \"Jour de la semaine (numérique)\",\n        \"Days since\": \"Jours depuis\",\n        \"Days until\": \"Jours jusque\",\n        \"Default\": \"Par défaut\",\n        \"End of\": \"Fin\",\n        \"Full date\": \"Date complète\",\n        \"Full name with year\": \"Nom complet avec l'année\",\n        \"Hour\": \"Heure\",\n        \"Intervals\": \"Intervalles\",\n        \"Is weekend?\": \"Est-ce le weekend ?\",\n        \"Minute\": \"Minute\",\n        \"Month\": \"Mois\",\n        \"Months since\": \"Mois depuis\",\n        \"Months until\": \"Mois jusque\",\n        \"Name only\": \"Nom seulement\",\n        \"Number only\": \"Nombre seulement\",\n        \"Quarter\": \"Quart\",\n        \"Quick Picks\": \"Sélection rapide\",\n        \"Relative\": \"Relatif\",\n        \"Short with year\": \"Courte avec l'année\",\n        \"Sortable\": \"Triable\",\n        \"Start of\": \"Début\",\n        \"Time\": \"Heure\",\n        \"Time bucket\": \"Période de temps\",\n        \"Week\": \"Semaine\",\n        \"Week of year\": \"Semaine de l'année\",\n        \"Year\": \"Année\",\n        \"Years since\": \"Années depuis\",\n        \"Years until\": \"Années jusque\"\n    },\n    \"selectBy\": {\n        \"Select widget\": \"Choisir une vue\"\n    },\n    \"CoreNewDocMethods\": {\n        \"Untitled document\": \"Document sans titre\"\n    },\n    \"GetGristComProvider\": {\n        \"Sign in with getgrist.com\": \"Se connecter avec getgrist.com\",\n        \"Register your Grist server\": \"Enregistrer votre serveur Grist\",\n        \"**Sign in with getgrist.com** allows users on your Grist server to sign in using their account on getgrist.com, the cloud version of Grist managed by Grist Labs.\": \"**Se connecter avec getgrist.com** permets aux utilisateurs de se connecter à votre instance Grist grâce à leur compte sur getgrist.com, la version cloud de Grist gérée par Grist Labs.\",\n        \"Cancel\": \"Annuler\",\n        \"Configure\": \"Configurer\",\n        \"Configure Sign in with getgrist.com\": \"Configurer Se connecter avec getgrist.com\",\n        \"Home URL is not set; cannot configure Sign in with getgrist.com\": \"L'URL d'accueil n'est pas définie, impossible de configurer Se connecter avec getgrist.com\",\n        \"Instructions\": \"Instructions\",\n        \"Learn more about Sign in with getgrist.com\": \"En savoir plus à propose de Se connecter avec getgrist.com\",\n        \"Paste configuration key here\": \"Collez votre clé de configuration ici\",\n        \"To set up {{provider}}, you need to register your Grist server on getgrist.com and paste the configuration key you receive below.\": \"Pour mettre en place {{provider}}, vous devez enregistrer votre instance Grist sur getgrist.com et coller la clé de configuration que vous avez reçue ci-dessous.\",\n        \"When signing in, users will be redirected to the getgrist.com login page to log in or register. After authenticating on getgrist.com, they'll be redirected back to your Grist server and signed in as the user they authenticated as.\": \"Quand il se connecteront, les utilisateurs seront redirigés sur la page de connexion de getgrist.com pour se connecter ou se créer un compte. Après s'être authentifié sur getgrist.com, ils seront redirigés sur votre instance de Grist et connecté en tant que l'utilisateur qu'ils ont authentifié.\"\n    },\n    \"AuthenticationSection\": {\n        \"Active\": \"Active\",\n        \"Active method is controlled by an environment variable. Unset variable to change active method.\": \"La méthode active est contrôlée par une variable d'environnement. Supprimez la variable pour changer de méthode active.\",\n        \"Are you sure you want to set **{{name}}** as the active authentication method?\": \"Êtes-vous sûr de vouloir définir **{{name}}** comme la méthode d’authentification active ?\",\n        \"Close\": \"Fermer\",\n        \"Configure\": \"Configurer\",\n        \"Configured\": \"Configuré\",\n        \"Confirm\": \"Confirmer\",\n        \"Disabled on restart\": \"Désactiver au redémarrage\",\n        \"Error\": \"Erreur\",\n        \"Error details\": \"Détail d'erreur\",\n        \"Instructions\": \"Instructions\",\n        \"No authentication method is active.\": \"Aucune méthode d’authentification active.\",\n        \"Set as active method\": \"Définir comme méthode active\",\n        \"Set as active method?\": \"Définir comme une méthode active ?\",\n        \"The new method will go into effect after you restart Grist.\": \"Cette nouvelle méthode prendra effet quand vous redémarrerez Grist.\",\n        \"Active on restart\": \"Active au redémarrage\",\n        \"**Forwarded headers** allows your Grist server to trust authentication performed by an external proxy (e.g. Traefik ForwardAuth).\": \"**Forwarded headers** autorise votre serveur Grist à faire confiance à une authentification faites par un proxy externe (ex. Traefik ForwardAuth).\",\n        \"**Grist Connect** is a login solution built and maintained by Grist Labs that integrates seamlessly with your Grist server.\": \"**Grist Connect** est une solution de connexion construite et maintenue par Grist Labs qui s'intègre sans effort avec votre serveur Grist.\",\n        \"**OIDC** allows users on your Grist server to sign in using an external identity provider that supports the OpenID Connect standard.\": \"L'**OIDC** permet aux utilisateurs sur votre serveur Grist de s'identifier en utilisant un fournisseur d'identité externe qui supporte le standard OpenId Connect.\",\n        \"**SAML** allows users on your Grist server to sign in using an external identity provider that supports the SAML 2.0 standard.\": \"Le **SAML** permets aux utilisateurs de votre serveur Grist de s'identifier en utilisant un fournisseur d'identité externe qui support le standard SAML 2.0.\",\n        \"Change admin user\": \"Changer l'utilisateur administrateur\",\n        \"If Grist is accessible on your network, or is available to multiple people, configure one of the authentication methods below.\": \"Si Grist est accessible sur votre réseau, ou s'il est disponible pour plusieurs personnes, configurez l'une des méthodes d'authentification suivantes.\",\n        \"No authentication: unrestricted sign-in as demo user\": \"Aucune authentification : connexion sans restriction en tant qu'utilisateur de démo\",\n        \"Prepare changes\": \"Préparer les changements\",\n        \"Restart required. Authentication change may affect your access\": \"Redémarrage nécessaire. Les changements d'authentification peuvent impacter votre accès\",\n        \"Revert change of admin user\": \"Annuler les changement d'utilisateur d'administration\",\n        \"See \\\"Restart Grist\\\" section on top of this page to restart.\": \"Consultez la section \\\"Redémarrer Grist\\\" en haut de cette page pour redémarrer.\",\n        \"To set up **Grist Connect**, follow the instructions in [the Grist support article for Grist Connect](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"grist-connect\": {\n                            \").\": \"Pour installer **Grist Connect**, suivez les instructions dans [l'article du support Grist pour Grist Connect](https://support.getgrist.com/install/grist-connect/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **OIDC**, follow the instructions in [the Grist support article for OIDC](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"oidc).\": \"Pour installer l'**OIDC**, suivez les instructions dans [l'article du support Grist pour l'OIDC](https://support.getgrist.com/install/oidc).\"\n                    }\n                }\n            }\n        },\n        \"To set up **SAML**, follow the instructions in [the Grist support article for SAML](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"saml\": {\n                            \").\": \"Pour installer le **SAML**, suivez les instructions dans [l'article du support Grist pour le SAML](https://support.getgrist.com/install/saml/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **forwarded headers**, follow the instructions in [the Grist support article for forwarded headers](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"forwarded-headers\": {\n                            \").\": \"Pour installer les **forwarded headers**, suivez les instructions dans [l'article du support Grist pour les forwarded headers](https://support.getgrist.com/install/forwarded-headers/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"When a user accesses Grist, the proxy handles authentication and forwards verified user information through HTTP headers. Grist uses these headers to identify the user.\": \"Quand un utilisateur accède à Grist, le proxy prend en charge l'authentification et transfert les information vérifiées de l'utilisateur au travers des entêtes HTTP. Grist utilise ces entêtes pour identifier l'utilisateur.\",\n        \"When signing in, users will be redirected to a Grist Connect login page where they can authenticate using various identity providers. After authentication, they'll be redirected back to your Grist server and signed in.\": \"Lors de la connexion, les utilisateurs seront redirigés vers une page d'identification Grist Connect où ils pourront s'authentifier en utilisant divers fournisseurs d'identité. Après l'authentification, il seront renvoyé vers votre serveur Grist et connectés.\",\n        \"When signing in, users will be redirected to your chosen identity provider's login page to authenticate. After successful authentication, they'll be redirected back to your Grist server and signed in as the user verified by the provider.\": \"Lorsqu'ils se connectent, les utilisateurs seront redirigés vers la page de connexion de fournisseur d'identité de votre choix afin de s'authentifier. Après une authentification réussi, ils seront renvoyés vers votre serveur Grist et connectés en tant que l'utilisateur vérifié par le fournisseur.\",\n        \"You are signed in as {{email}}. After restart, the new administrative user will be {{newEmail}}.\": \"Vous êtes connecté en tant que {{email}}. Après le redémarrage, le nouvel utilisateur d’administration sera {{newEmail}}.\",\n        \"You are signed in as {{email}}. You may lose access to this server if you cannot sign in as this user after switching the authentication system.\": \"Vous êtes connecté en tant que {{email}}. Vous allez peut-être perdre vos accès à ce serveur si vous ne pouvez plus vous connecter en tant que cet utilisateur après avoir changé le mode d'authentification.\"\n    },\n    \"DetailView\": {\n        \"This row is unavailable or does not exist\": \"Cette ligne est indisponible ou n’existe pas\"\n    },\n    \"AirtableImportUI\": {\n        \"Back\": \"Retour\",\n        \"Cancel\": \"Annuler\",\n        \"Choose an Airtable base to import from\": \"Choisissez une base Airtable depuis laquelle importer\",\n        \"Choose destination\": \"Choisissez une destination\",\n        \"Connect\": \"Se connecter\",\n        \"Connect with Airtable\": \"Se connecter avec Airtable\",\n        \"Connect your Airtable account to access your bases.\": \"Connectez-vous à votre compte Airtable pour accéder à vos bases.\",\n        \"Connected via {{method}}\": \"Connecté via {{method}}\",\n        \"Connecting...\": \"Connexion en cours...\",\n        \"Continue\": \"Continuer\",\n        \"Destination\": \"Destination\",\n        \"Disconnect\": \"Déconnexion\",\n        \"Existing tables\": \"Tables existantes\",\n        \"Failed to fetch base schema\": \"Échec de la récupération du schéma de base\",\n        \"Failed to fetch bases\": \"Échec de la récupération des bases\",\n        \"Grist configuration required\": \"Configuration de Grist nécessaire\",\n        \"Import from {{baseName}} in progress. Do not navigate away from this page.\": \"Import depuis {{baseName}} en cours. Ne quittez pas cette page.\",\n        \"Import tables\": \"Importer les tables\",\n        \"Import {{count}} tables_one\": \"Import de {{count}} table\",\n        \"Import {{count}} tables_other\": \"Import de {{count}} tables\",\n        \"Make sure your token has the correct permissions.\": \"Assurez-vous que votre jeton a les bonnes permissions.\",\n        \"New table\": \"Nouvelle table\",\n        \"New table: structure only\": \"Nouvelle Table : structure uniquement\",\n        \"No bases found\": \"Aucune base trouvée\",\n        \"OAuth\": \"OAuth\",\n        \"OAuth credentials not configured. Please set OAUTH2_AIRTABLE_CLIENT_ID and OAUTH2_AIRTABLE_CLIENT_SECRET, or use personal access token.\": \"Les informations d'identification OAuth ne sont pas configurées. Merci de configurer OAUTH2_AIRTABLE_CLIENT_ID and OAUTH2_AIRTABLE_CLIENT_SECRET, ou d'utiliser un jeton d'accès personnel.\",\n        \"Personal access token\": \"Jeton d'accès personnel\",\n        \"Please enter a personal access token\": \"Merci de saisir un jeton d'accès personnel\",\n        \"Refresh\": \"Rafraîchir\",\n        \"Select tables to import from {{baseName}}\": \"Sélectionner les tables à importer depuis {{baseName}}\",\n        \"Skip\": \"Passer\",\n        \"Source tables\": \"Tables sources\",\n        \"Structure only\": \"Structure uniquement\",\n        \"Use personal access token instead\": \"Utiliser un jeton d'accès à la place\",\n        \"[Generate a token]({{url}}) in your Airtable account with scopes that include at least **\\\\`schema.bases:read\\\\`** and **\\\\`data.records:read\\\\`**.\\n\\nYour token is never sent to Grist's servers, and is only used to make API calls to Airtable from your browser.\": \"[Générer un jeton]({{url}}) dans votre compte Airtable avec une portée qui inclue à minima **\\\\`schema.bases:read\\\\`** et **\\\\`data.records:read\\\\`**.\\n\\nVotre jeton ne sera jamais envoyé aux serveurs de Grist, et il n'est utiliser que pour faire des appels API vers Airtable depuis votre navigateur.\",\n        \"loading your bases...\": \"Chargement de vos bases...\",\n        \"loading your tables...\": \"Chargement de vos tables...\",\n        \"or\": \"ou\",\n        \"{{count}} warnings_one\": \"{{count}} alerte\",\n        \"{{count}} warnings_other\": \"{{count}} alertes\",\n        \"The more convenient ‘Connect with Airtable’ option can be configured by the installation administrator. [Learn more.]({{url}})\": \"L'option plus pratique \\\"Connexion avec Airtable\\\" peut être configurée par l'administrateur système. [En savoir plus.]({{url}})\",\n        \"Use personal access token\": \"Utiliser votre jeton d'accès personnel\"\n    },\n    \"AirtableImporter\": {\n        \"Creating a new Grist document...\": \"Création d'un nouveau document Grist en cours...\",\n        \"Preparing to import base from Airtable...\": \"Préparation de l'import de la base depuis Airtable...\",\n        \"Setting up tables...\": \"Configuration des tables...\"\n    },\n    \"ChangeAdminModal\": {\n        \"Enter new admin email\": \"Entrez le nouvel e-mail d'administration\",\n        \"Make the new email the installation admin. Orgs, workspaces, and documents will remain owned by {{email}}. These changes will take effect after you restart this Grist server.\": \"Faire du nouvel e-mail l'administrateur d'installation. Les Espaces d'équipes, Dossiers, et documents resteront la propriété de {{email}}. Ces changements seront effectifs après le redémarrage du serveur Grist.\",\n        \"New admin\": \"Nouvel administrateur\",\n        \"Replace {{email}} with the new email throughout. The new email will become the installation admin, as well as the owner of all materials previously owned by you@example.com.\": \"Remplacer complètement {{email}} par le nouvel e-mail d'administration. Le nouvel e-mail deviendra l'administrateur d'installation, ainsi que le propriétaire de tout les éléments appartenant précédemment à vous@exemple.fr.\"\n    },\n    \"startDocAirtableImport\": {\n        \"Import from Airtable\": \"Importer depuis Airtable\"\n    },\n    \"startHomeAirtableImport\": {\n        \"Import from Airtable\": \"Importer depuis Airtable\",\n        \"The current workspace can't be imported to.\": \"Le dossier actuel ne peut pas être importé.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/fr.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Merci de vérifier la validité de votre adresse mail auprès de votre fournisseur d'identification, avant de vous reconnecter.\"\n    },\n    \"sendAppPage\": {\n        \"Loading...\": \"Chargement en cours...\",\n        \"og-description\": \"Un tableur moderne et open source qui va au-delà de la feuille de calcul\",\n        \"og-title\": \"Grist, le tableur évolué\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"Vous n'avez pas accès à ce document.\",\n        \"docDisabled\": \"Ce document est désactivé.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"Aucun propriétaire trouvé dans l'organisation admin définie par `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"orgUser\": \"L'utilisateur est un propriétaire de l'organisation admin définie par `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"accountByEmail\": \"Compte d'administration défini par `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\",\n        \"noAdminEmail\": \"Il manque un compte d'administration car `GRIST_ADMIN_EMAIL` et `GRIST_DEFAULT_EMAIL` ne sont pas définies\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Document sans titre\"\n    }\n}\n"
  },
  {
    "path": "static/locales/hu.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"We'll email an invite to {{email}}\": \"Küldünk egy meghívó e-mailt ide: {{email}}\",\n        \"Enter email address\": \"Adja meg az e-mail címet\",\n        \"Invite new member\": \"Új tag meghívása\"\n    },\n    \"AccessRules\": {\n        \"Add user attributes\": \"Új felhasználó attribútum\",\n        \"Everyone\": \"Mindenki\",\n        \"Default rules\": \"Alapértelmezett szabályok\",\n        \"Invalid\": \"Érvénytelen\",\n        \"Add table rules\": \"Új táblázat szabály\",\n        \"Add column rule\": \"Új oszlop szabály\",\n        \"Add Default Rule\": \"Új alapértelmezett szabály\",\n        \"Attribute name\": \"Attribútum neve\",\n        \"Allow everyone to view Access Rules.\": \"Mindeki láthassa a hozzáférési szabályokat.\",\n        \"Attribute to Look Up\": \"Keresendő attribútum\",\n        \"Checking...\": \"Ellenőrzés…\",\n        \"Condition\": \"Feltétel\",\n        \"Delete table rules\": \"Táblázat szabályok törlése\",\n        \"Enter Condition\": \"Írja be a feltételt\",\n        \"Everyone Else\": \"Mindenki más\",\n        \"Lookup Column\": \"Keresési oszlop\",\n        \"Lookup Table\": \"Keresési táblázat\",\n        \"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.\": \"Mindenki számára lehetővé teszi az egész dokumentum másolását vagy a teljes megtekintését játszótér-módban.\\nEz minta-dokumentumoknál vagy sablonoknál hasznos, éles adatoknál ne használd.\",\n        \"Permission to access the document in full when needed\": \"Engedély a teljes dokumentumhoz hozzáféréshez, ha szükséges\",\n        \"Permission to view Access Rules\": \"Engedély a hozzáférési szabályok megtekintésére\",\n        \"Permissions\": \"Engedélyek\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"A(z) {{- colId }} oszlop törlése a(z) {{- tableId }} szabályai közül\",\n        \"Remove {{- tableId }} rules\": \"{{- tableId }} szabályainak törlése\",\n        \"Remove {{- name }} user attribute\": \"{{- name }} felhasználói attribútum törlése\",\n        \"Reset\": \"Visszaállítás\",\n        \"Rules for table \": \"Tábla szabályai \",\n        \"Save\": \"Ment\",\n        \"Saved\": \"Mentve\",\n        \"Special rules\": \"Különleges szabályok\",\n        \"Type message to display when this rule blocks an action…\": \"Írj be egy üzenetet, ami akkor jelenik meg, amikor ez a szabály megakadályoz valamit…\",\n        \"User Attributes\": \"Felhasználói attribútumok\",\n        \"View as\": \"Megjelenítés mint\",\n        \"Seed rules\": \"Kiindulási szabályok\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Táblaszabályok hozzáadásakor automatikusan biztosítsuk a TULAJDONOS teljes hozzáférését.\",\n        \"Permission to edit document structure\": \"Engedély a dokumentumstruktúra módosítására\",\n        \"This default should be changed if editors' access is to be limited. \": \"Ezt az alapértelmezett értéket meg kell változtatni, ha a szerkesztők hozzáférése korlátozott. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Engedélyezzük a szerkesztők számára a struktúra módosítását (pl. táblák, oszlopok és elrendezések módosítását és törlését), valamint képletek használatát. A tábla- vagy oszlopszintű engedélyektől függetlenül módosíthatja a képleteket és hozzáférhet minden adathoz.\",\n        \"Add table-wide rule\": \"Tábla-szintű szabály hozzáadása\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"A hozzáférési szabályok megváltoztak. Kattints a Visszaállítás gombra, ha szeretnéd visszavonni a változtatásaidat és újratölteni a szabályokat!\",\n        \"All\": \"Mind\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"A(z) {{tableId}} tábla szabályai között a(z) {{colId}} oszlop több szabályban is megjelenik, amelyek érvényessége függhet a sorrendtől. Megpróbáljuk másképpen felosztani a szabályokat?\",\n        \"Columns\": \"Oszlopok\",\n        \"Condition cannot be blank\": \"A feltétel nem lehet üres\",\n        \"Default resource missing in resource map\": \"Az alapértelmezett erőforrás hiányzik az erőforrástérképből\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Hibás oszlopok a(z) {{tableId}} táblában: {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"Hibás tábla: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Hibás felhasználóiattribútum-szabály: a(z) {{prop}} tulajdonságot be kell állítani\",\n        \"Invalid user attribute to look up\": \"Hibás felhasználói attribútum a keresésben\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"Nincs oszlop felsorolva a(z) {{tableId}} tábla oszlop-szabályai között\",\n        \"Not a valid user attribute\": \"Érvénytelen felhasználói attribútum\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Hiányzó erőforrás az erőforrástérképben: {{resourceKey}}\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"Adj meg egy egyszerű attribútumot a user.LinkKey-hez, pl. user.LinkKey.valami\",\n        \"hidden\": \"rejtett\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"Megpróbáljuk hozzáadni a TáblaSzabályokat a már létező {{tableId}} táblához\",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Hozzáférési szabályok\\n\\nEnnek a dokumentumnak az alapvető hozzáférési szabályait a 'Megosztás' menü 'Felhasználók kezelése' menüpontjában lehet kezelni, ahol közreműködői szerepköröket rendelhetsz a dokumentumhoz, mint például a Tulajdonos, Szerkesztő, Megtekintő.\\n\\nHa részletesebb szabályozásra van szükséged, akkor létrehozhatsz Hozzáférési Szabályokat, amelyekben pontosan szabályozhatod, hogy ki tekinthet meg vagy szerkeszthet meghatározott\\ntáblákat, oszlopokat vagy sorokat. Ez hasznos érzékeny adatokhoz vagy szerepkör-alapú engedélyekhez.\\n[Bővebben.]({{helpAccessRules}})\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Hozzáférési szabályok\\n\\nNincs jogosultsága a dokumentum hozzáférési szabályait megtekinteni vagy módosítani.\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Speciális szabályok** (bontsd ki az egyes szabályokat, hogy részletesen beállíthasd, kire vonatkozik)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"A Hozzáférési szzbályok letiltása után a Szerkesztő szerepkörű felhasználók megváltoztathatják a dokumentum struktúráját és módosíthatják a képleteket. A Szerkesztő és Megtekintő szerepkörök birtokosai láthatják a dokumentumban lévő minden adatot, valamint lemásolhatják és le is tölthetik azt.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"A Hozzáférési szabályok engedélyezése után a Szerkesztő szerepkörű felhasználók már nem változtathatják meg\\na dokumentum struktúráját és a képleteket, a dokumentumot pedig csak a Tulajdonos felhasználók másolhatják vagy tölthetik le.\\n\\nEzeket a beállításokat a 'Speciális szabályok' alatt változtathatod meg.\",\n        \"Enable Access Rules\": \"Hozzáférési szabályok engedélyezése\",\n        \"Permission to access the document in full by all users\": \"Minden felhasználó számára teljes hozzáférés engedélyezése a dokumentumhoz\",\n        \"Permission to access the document in full by unrestricted users\": \"Minden korlátozás nélküli felhasználó számára teljes hozzáférés engedélyezése a dokumentumhoz\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Megtiltja a nem Tulajdonos szerepkörű felhasználók számára a teljes dokumentum másolását vagy letöltését. Fontos, hogy ez csak az olvasási korlátozás nélküli felhasználókra vonatkozik, mivel a többiek hozzáférése ettől a beállítástól függetlenül korlátozott.\",\n        \"Special rules for templates\": \"Különleges szabályok sablonokhoz\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"A struktúra módosításának (azaz a táblázatok, oszlopok és elrendezések módosításának, törlésének) és képletek szerkesztésének engedélyzése Szerkesztők számára.  Fontos: ha be van pipálva, a Szerkesztők módosíthatják a képleteket, amelyeknek minden adathoz hozzáférésük van, függetlenül a táblázat- vagy oszlopszintű hozzáférési szabályoktól!\",\n        \"Allow everyone to view access rules.\": \"A hozzáférési szabályok megtekintésének engedélyezése mindenki számára.\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Az olvasási korlátozások megkerülése, hogy mindenki képes legyen másolni a teljes dokumentumot, vagy megtekinteni játszó módban. Csak példák és sablonok esetében használd, érzékeny adatokat tartalmazó dokumentumoknál soha.\",\n        \"Continue\": \"Folytatás\",\n        \"Disable Access Rules\": \"Hozzáférési szabályok letiltása\",\n        \"Disable and save\": \"Letiltás és mentés\",\n        \"This options should be off if Editors' access is to be limited. \": \"Ezeknek a beállításoknak kikapcsolt állapotúnak kell lenni, ha a Szerkesztők hozzáférését korlátozni szeretnéd. \"\n    },\n    \"AccountPage\": {\n        \"API Key\": \"API kulcs\",\n        \"Account settings\": \"Fiók beállítások\",\n        \"Edit\": \"Módosít\",\n        \"Email\": \"E-mail\",\n        \"Save\": \"Ment\",\n        \"API\": \"API\",\n        \"Allow signing in to this account with Google\": \"Google-bejelentkezés engedélyezése ebbe a fiókba\",\n        \"Change password\": \"Jelszócsere\",\n        \"Login method\": \"Bejelentkezési mód\",\n        \"Name\": \"Név\",\n        \"Names only allow letters, numbers and certain special characters\": \"A nevekben csak betűk, számok és néhány különleges karakter használható\",\n        \"Password & security\": \"Jelszó és biztonság\",\n        \"Theme\": \"Téma\",\n        \"Two-factor authentication\": \"Kétfaktoros bejelentkezés\",\n        \"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.\": \"A kétfaktoros bejelentkezés egy plusz biztonsági réteg a Grist fiókodban, ami biztosítja, hogy valóban te legyél az egyetlen ember, aki hozzáfér a fiókodhoz, még abban az esetben is, ha valaki más megszerzi a jelszavadat.\",\n        \"Language\": \"Nyelv\"\n    },\n    \"AccountWidget\": {\n        \"Pricing\": \"Árak\",\n        \"Sign in\": \"Bejelentkezés\",\n        \"Sign up\": \"Regisztráció\",\n        \"Use This Template\": \"Használjuk ezt a sablont\",\n        \"Access Details\": \"Hozzáférés részletei\",\n        \"Accounts\": \"Fiókok\",\n        \"Add account\": \"Fiók hozzáadása\",\n        \"Document settings\": \"Dokumentum beállításai\",\n        \"Manage team\": \"Csapat kezelése\",\n        \"Profile settings\": \"Profilbeállítások\",\n        \"Sign out\": \"Kijelentkezés\",\n        \"Switch Accounts\": \"Fiókváltás\",\n        \"Toggle Mobile Mode\": \"Mobil mód kapcsolása\",\n        \"Activation\": \"Aktiválás\",\n        \"Billing account\": \"Számlázási fiók\",\n        \"Support Grist\": \"A Grist támogatása\",\n        \"Upgrade Plan\": \"Előfizetés\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Megjelenítés mint\",\n        \"Users from table\": \"A tábla felhasználói\",\n        \"Example Users\": \"Példa felhasználók\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"A tevékenységnapló betöltése nem sikerült\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"A(z) {{colId}} oszlopot a(z) {{action.actionNum}}. tevékenység eltávolította\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"A(z) {{tableId}} táblát a(z) {{actionNum}}. tevékenység eltávolította\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Ezt a sort a(z) {{action.actionNum}}. tevékenység eltávolította\",\n        \"All tables\": \"Minden tábla\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"A(z) {{colId}} oszlop utóbb törölve lett a(z) #{{actionNum}}. tevékenységnél\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Ez a sor utóbb törölve lett a(z) {{actionNum}}. tevékenységnél\",\n        \"History blocked because of access rules.\": \"A változástörténet a hozzáférési szabályok miatt zárolva.\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Új hozzáadása\"\n    },\n    \"ApiKey\": {\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"API kulcs generálásával API hívásokat indíthatsz.\",\n        \"Click to show\": \"Kattints a megjelenítéshez\",\n        \"Create\": \"Létrehoz\",\n        \"Remove\": \"Eltávolít\",\n        \"Remove API Key\": \"API kulcs törlése\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Ez az API kulcs arra használható, hogy anonim módon is hozzáférj ehhez a fiókhoz.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Ez az API kulcs hozzáférést biztosít a fiókodhoz az API-n keresztül. Ne oszd meg senkivel az API-kulcsot.\",\n        \"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?\": \"Törölni akarsz egy API kulcsot. Ennek következtében minden további, ezen API-kulccsal történő hozzáférést megtagad majd a rendszer. Biztosan törölni szeretnéd?\"\n    },\n    \"App\": {\n        \"Description\": \"Leírás\",\n        \"Key\": \"Kulcs\",\n        \"Memory Error\": \"Memória hiba\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Kezdőlap\",\n        \"Legacy\": \"Régi\",\n        \"Personal Site\": \"Személyes oldal\",\n        \"Team Site\": \"Csapat oldal\",\n        \"Grist Templates\": \"Grist sablonok\",\n        \"Billing account\": \"Számlázási fiók\",\n        \"Manage team\": \"Csapat kezelése\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Vissza a kezdőlapra\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Ez egy szüneteltetett csapat-oldal. A dokumentumok olvashatók, de módosítani nem lehet őket.\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"Cellatartalom törlése\",\n        \"Clear values\": \"Értékek törlése\",\n        \"Copy anchor link\": \"Link másolása\",\n        \"Delete {{count}} columns_one\": \"Oszlop törlése\",\n        \"Delete {{count}} columns_other\": \"{{count}} oszlop törlése\",\n        \"Delete {{count}} rows_one\": \"Sor törlése\",\n        \"Delete {{count}} rows_other\": \"{{count}} sor törlése\",\n        \"Duplicate rows_one\": \"Sor duplikálása\",\n        \"Duplicate rows_other\": \"Sorok duplikálása\",\n        \"Filter by this value\": \"Szűrés ezzel az értékkel\",\n        \"Insert column to the left\": \"Oszlop beszúrása balra\",\n        \"Insert column to the right\": \"Oszlop beszúrása jobbra\",\n        \"Insert row\": \"Sor beszúrása\",\n        \"Insert row above\": \"Sor beszúrása felülre\",\n        \"Insert row below\": \"Sor beszúrása alulra\",\n        \"Reset {{count}} columns_one\": \"Oszlop visszaállítása\",\n        \"Reset {{count}} columns_other\": \"{{count}} oszlop visszaállítása\",\n        \"Reset {{count}} entire columns_one\": \"Egész oszlop visszaállítása\",\n        \"Reset {{count}} entire columns_other\": \"{{count}} egész oszlop visszaállítása\",\n        \"Comment\": \"Megjegyzés\",\n        \"Copy\": \"Másol\",\n        \"Cut\": \"Kivág\",\n        \"Paste\": \"Beilleszt\",\n        \"Copy with headers\": \"Másolás fejlécekkel\"\n    },\n    \"ChartView\": {\n        \"LABEL\": \"CIMKE\",\n        \"Bar chart\": \"Oszlopdiagram\",\n        \"Pie chart\": \"Tortadiagram\",\n        \"Donut chart\": \"Fánkdiagram\",\n        \"Area chart\": \"Területdiagram\",\n        \"Line chart\": \"Vonaldiagram\",\n        \"Scatter plot\": \"Szórásdiagram\",\n        \"Kaplan-Meier plot\": \"Kaplan-Meier diagram\",\n        \"Split series\": \"Adatsorok felosztása\",\n        \"Invert Y-axis\": \"Y-tengely megfordítása\",\n        \"Orientation\": \"Tájolás\",\n        \"Vertical\": \"Függőleges\",\n        \"Horizontal\": \"Vízszintes\",\n        \"Log scale Y-axis\": \"Y-tengely logaritmikus skálával\",\n        \"Hole size\": \"Lyukméret\",\n        \"Show total\": \"Összesítés megjelenítése\",\n        \"Text size\": \"Szövegméret\",\n        \"Connect gaps\": \"Rések összekötése\",\n        \"Show markers\": \"Jelölők megjelenítése\",\n        \"Stack series\": \"Adatsorok halmozása\",\n        \"Error bars\": \"Hibás oszlopok\",\n        \"None\": \"Semmi\",\n        \"Symmetric\": \"Szimmetrikus\",\n        \"Above+Below\": \"Fölötte+alatta\",\n        \"Split Series\": \"Adatsorok felosztása\",\n        \"X-AXIS\": \"X-TENGELY\",\n        \"Pick a column\": \"Válassz oszlopot\",\n        \"Aggregate values\": \"Összesített értékek\",\n        \"SERIES\": \"ADATSOROK\",\n        \"Add series\": \"Adatsor hozzáadása\",\n        \"non-numeric columns are not shown\": \"a nem numerikus oszlopok nem jelennek meg\",\n        \"non-numeric column is not shown\": \"a nem numerikus oszlop nem jelenik meg\",\n        \"selected new x-axis\": \"kiválasztott új x-tengely\",\n        \"Remove\": \"Eltávolít\",\n        \"Create separate series for each value of the selected column.\": \"A kiválasztott oszlop minden értékéhez új adatsort hoz létre.\",\n        \"Toggle chart aggregation\": \"Diagram-összeseítés kapcsolása\",\n        \"selected new group data columns\": \"kiválasztott új csoportosítási oszlopok\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Minden Y adatsort egy másik adatsor követ a hibahatár hosszúságában.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Minden Y adatsort két másik adatsor követ az alsó és felső hibahatár vonatkozásában.\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Hozzáférés megtagadva\",\n        \"Code View is available only when you have full document access.\": \"A kódmegjelentő csak akkor érhető el, ha a teljes dokumentumhoz rendelkezik hozzáféréssel.\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Alkalmaz\",\n        \"Cancel\": \"Mégse\",\n        \"Default cell style\": \"Alapértelmezett cellastílus\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"Mind\",\n        \"All except\": \"Mind, kivéve\",\n        \"All shown\": \"Mindent mutat\",\n        \"Filter by Range\": \"Szűrés tartomány alapján\",\n        \"Future values\": \"Jövőbeli értékek\",\n        \"No matching values\": \"Nincs megfelelő érték\",\n        \"None\": \"Semmi\",\n        \"Min\": \"Min\",\n        \"Max\": \"Max\",\n        \"Start\": \"Kezdet\",\n        \"End\": \"Vég\",\n        \"Other Matching\": \"Más illeszkedő\",\n        \"Other Non-Matching\": \"Más nem illeszkedő\",\n        \"Other values\": \"Más értékek\",\n        \"Others\": \"Egyebek\",\n        \"Search\": \"Keresés\",\n        \"Search values\": \"Értékek keresése\",\n        \"Clear search\": \"Keresés törlése\",\n        \"Pin filter\": \"Szűrő rögzítése\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Rendezés ABC szerint (aktuális: rendezés az előfordulások száma szerint)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Rendezés az előfordulások száma szerint (aktuális: rendezés ABC szerint)\",\n        \"Unpin filter\": \"Szűrő rögzítésének törlése\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (választható)\",\n        \"Add\": \"Hozzáad\",\n        \"Enter Custom URL\": \"Egyedi URL megadása\",\n        \"Full document access\": \"Teljes dokumentum-hozzáférés\",\n        \"Learn more about custom widgets\": \"További tudnivalók az egyedi widget-ekről\",\n        \"No document access\": \"Nincs hozzáférésed a dokumentumhoz\",\n        \"Open configuration\": \"Beállítások megnyitása\",\n        \"Pick a column\": \"Válassz oszlopot\",\n        \"Pick a {{columnType}} column\": \"Válassz egy {{columnType}} oszlopot\",\n        \"Read selected table\": \"Kiválasztott tábla olvasása\",\n        \"Select Custom Widget\": \"Egyedi widget kiválasztása\",\n        \"Widget does not require any permissions.\": \"A widget-nek nincs szüksége engedélyekre.\",\n        \"Widget needs to {{read}} the current table.\": \"A widgetnek szüksége van {{read}} engedélyre a táblához.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"A widget-nek szüksége van {{fullAccess}} engedélyre ehhez a dokumentumhoz.\",\n        \"Clear selection\": \"Kiválasztás törlése\",\n        \"No {{columnType}} columns in table.\": \"Nincs {{columnType}} oszlop a táblában.\",\n        \"ACCESS LEVEL\": \"HOZZÁFÉRÉSI SZINT\",\n        \"Accept\": \"Elfogad\",\n        \"Custom URL\": \"Egyedi URL\",\n        \"Developer:\": \"Fejlesztő:\",\n        \"Last updated:\": \"Utoljára frissítve:\",\n        \"Missing description and author information.\": \"Hiányzik a leírás és az információk a szerzőről.\",\n        \"Reject\": \"Elutasít\",\n        \"Widget\": \"Widget\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} darab nem-{{columnType}} oszlop elrejtve\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} darab nem-{{columnType}} oszlop elrejtve\",\n        \"Change custom widget\": \"Egyedi widget módosítása\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Kattints a másoláshoz\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Töröljük {{formattedTableName}} adatait, egyúttal töröljük minden oldalról?\",\n        \"Duplicate table\": \"Tábla duplikálása\",\n        \"Raw Data Tables\": \"Nyersadat-táblák\",\n        \"Table ID copied to clipboard\": \"A tábla azonosítóját a vágólapra másoltuk\",\n        \"You do not have edit access to this document\": \"Nincs módosítási hozzáférésed ehhez a dokumentumhoz\",\n        \"Edit record card\": \"Rekordkártya módosítása\",\n        \"Record Card\": \"Rekordkártya\",\n        \"Record Card Disabled\": \"Rekordkártya letiltva\",\n        \"Remove table\": \"Tábla eltávolítása\",\n        \"Rename table\": \"Tábla átnevezése\",\n        \"{{action}} Record Card\": \"Rekordkártya {{action}}\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Tevékenység\",\n        \"Beta\": \"Béta\",\n        \"Compare to current\": \"Összevetés a jelenlegivel\",\n        \"Compare to previous\": \"Összevetés az előzővel\",\n        \"Open snapshot\": \"Pillanatfelvétel megnyitása\",\n        \"Snapshots\": \"Pillanatfelvételek\",\n        \"Snapshots are unavailable.\": \"Pillanatfelvételek nem elérhetők.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Csak a tulajdonosok férhetnek hozzá a hozzáférési szabályokkal rendelkező dokumentumok pillanatfelvételeihez.\"\n    },\n    \"DocMenu\": {\n        \"(The organization needs a paid plan)\": \"(A szervezetnek előfizetésre van szüksége)\",\n        \"Access Details\": \"Hozzáférés részletei\",\n        \"All documents\": \"Minden dokumentum\",\n        \"By Date Modified\": \"Módosítás dátuma szerint\",\n        \"By Name\": \"Név szerint\",\n        \"Current workspace\": \"Jelenlegi munkaterület\",\n        \"Delete\": \"Töröl\",\n        \"Delete Forever\": \"Véglegesen töröl\",\n        \"Delete {{name}}\": \"{{name}} törlése\",\n        \"Deleted {{at}}\": \"Törölve {{at}}-kor\",\n        \"Discover More Templates\": \"További sablonok felfedezése\",\n        \"Document will be moved to Trash.\": \"A dokumentum a kukába kerül.\",\n        \"Document will be permanently deleted.\": \"A dokumentumot véglegesen töröljük.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"A dokumentumok 30 napig maradnak a kukában, ezután véglegesen törlődnek.\",\n        \"Edited {{at}}\": \"Szerkesztve {{at}}-kor\",\n        \"Examples & Templates\": \"Példák & sablonok\",\n        \"Examples and Templates\": \"Példák és sablonok\",\n        \"Featured\": \"Kiemelt\",\n        \"Manage users\": \"Felhasználók kezelése\",\n        \"More Examples and Templates\": \"További példák és sablonok\",\n        \"Move\": \"Áthelyez\",\n        \"Move {{name}} to workspace\": \"{{name}} áthelyezése a munkaterületre\",\n        \"Other Sites\": \"További oldalak\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Véglegesen töröljük ezt: \\\"{{name}}\\\"?\",\n        \"Pin Document\": \"Dokumentum rögzítése\",\n        \"Pinned Documents\": \"Rögzített dokumentumok\",\n        \"Remove\": \"Eltávolít\",\n        \"Rename\": \"Átnevez\",\n        \"Requires edit permissions\": \"Módosítási jogosultság szükséges\",\n        \"Restore\": \"Visszaállít\",\n        \"This service is not available right now\": \"Ez a szolgáltatás jelenleg nem elérhető\",\n        \"To restore this document, restore the workspace first.\": \"A dokumentum visszaállításához először a munkaterületet kell visszaállítani.\",\n        \"Trash\": \"Kuka\",\n        \"Trash is empty.\": \"A kuka üres.\",\n        \"Unpin Document\": \"Dokumentum-rögzítés megszüntetése\",\n        \"Workspace not found\": \"Nincs ilyen munkaterület\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"A(z) {{siteName}} oldalon vagy. A következő oldalakhoz is van hozzáférésed:\",\n        \"You are on your personal site. You also have access to the following sites:\": \"A személyes oldaladon vagy. A következő oldalakhoz is van hozzáférésed:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Munkaterület csak akkor törölhető, ha nincs benne dokumentum.\",\n        \"Any documents created in this site will appear here.\": \"Az ezen az oldalon létrehozott dokumentumok itt fognak megjelenni.\",\n        \"Create my first document\": \"Létrehozom az első dokumentumot\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Ehhez az oldalhoz csak olvasási jogosultságod van. Jelenleg nincsenek dokumentumok.\",\n        \"personal site\": \"személyes oldal\",\n        \"Grid view\": \"Rács nézet\",\n        \"List view\": \"Lista nézet\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Üres tábla hozzáadása\",\n        \"Add page\": \"Oldal hozzáadása\",\n        \"Add widget to page\": \"Widget hozzáadása az oldalhoz\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"A dokumentum tulajdonosai megkísérelhetik a dokumentum visszaállítását. [{{error}}]\",\n        \"Enter recovery mode\": \"Visszaállítási mód indítása\",\n        \"Error accessing document\": \"Dokumentum-hozzáférési hiba\",\n        \"Reload\": \"Újratölt\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Elnézést kérünk, ehhez a dokumentumhoz nincs hozzáférése. [{{error}}]\",\n        \"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}}]\": \"Megpróbálhatod a dokumentum újratöltését visszaállítási módban. Ebben a módban a dokumentumot teljeskörűen elérik a tulajdonosai, de mások számára nem hozzáférhetőek, valamint letiltja a képleteket is. [{{error}}]\",\n        \"You do not have edit access to this document\": \"Nincs módosítási jogosultságod ehhez a dokumentumhoz\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Kérlek, töltsd be újra a dokumentumot, és ha a hiba továbbra is fennáll, vedd fel a kapcsolatot a dokumentum tulajdonosával a dokumentum helyreállításával kapcsolatban. [{{error}}]\"\n    },\n    \"DocTour\": {\n        \"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.\": \"A dokumentumban szereplő adatokból nem lehet bemutatót létrehozni. Ellenőrizd, hogy létezik-e GristDocTour tábla Title, Body, Placement és Location oszlopokkal!\",\n        \"No valid document tour\": \"Nincs érvényes dokumentum bemutató\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Pénznem:\",\n        \"Document settings\": \"Dokumentum beállítások\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Motor (kísérleti {{span}} megváltoztatás csak saját felelősségre!):\",\n        \"Local currency ({{currency}})\": \"Helyi pénznem ({{currency}})\",\n        \"Locale:\": \"Környezet:\",\n        \"Save\": \"Ment\",\n        \"Save and Reload\": \"Ment és újratölt\",\n        \"This document's ID (for API use):\": \"Ennek a dokumentumnak az azonosítója (ID-je, API használatához):\",\n        \"Time Zone:\": \"Időzóna:\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"A dokumentum azonosítója (ID-je) vágólapra másolva\",\n        \"Ok\": \"Rendben\",\n        \"Manage Webhooks\": \"Webhook-ok kezelése\",\n        \"Webhooks\": \"Webhook-ok\",\n        \"API console\": \"API konzol\",\n        \"API URL copied to clipboard\": \"API URL vágólapra másolva\",\n        \"API documentation.\": \"API dokumentáció.\",\n        \"Base doc URL: {{docApiUrl}}\": \"Dokumentumok alap URL-je: {{docApiUrl}}\",\n        \"Coming soon\": \"Hamarosan\",\n        \"Copy to clipboard\": \"Másolás vágólapra\",\n        \"Currency\": \"Pénznem\",\n        \"Data engine\": \"Adatbázismotor\",\n        \"Default for DateTime columns\": \"DateTime oszlopok alapértéke\",\n        \"Document ID\": \"Dokumentum azonosító (ID)\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"Dokumentum azonosító (ID), ami a(z) {{docId}}-re vonatkozó REST API hívásoknál használható. Lásd {{apiURL}}\",\n        \"Find slow formulas\": \"Lassú képletek keresése\",\n        \"For currency columns\": \"Pénznem oszlopokhoz\",\n        \"For number and date formats\": \"Szám- és dátumformátumokhoz\",\n        \"Formula times\": \"Képlet idők\",\n        \"Hard reset of data engine\": \"Adatbázismotor kényszerített újraindítása\",\n        \"ID for API use\": \"Azonosító (ID) API használatához\",\n        \"Locale\": \"Környezet\",\n        \"Manage webhooks\": \"Webhook-ok kezelése\",\n        \"Notify other services on doc changes\": \"Más szolgáltatások értesítése a dokumentum változásáról\",\n        \"Python\": \"Python\",\n        \"Python version used\": \"Használt Python verzió\",\n        \"Reload\": \"Újratölt\",\n        \"Time zone\": \"Időzóna\",\n        \"Try API calls from the browser\": \"API hívások kipróbálása böngészőből\",\n        \"python2 (legacy)\": \"python2 (régi)\",\n        \"python3 (recommended)\": \"python3 (javasolt)\",\n        \"Cancel\": \"Mégse\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Dokumentum újratöltése, miközben mérjük a képletek futási idejét, majd az eredmény megjelenítése.\",\n        \"Formula timer\": \"Képlet időmérés\",\n        \"Reload data engine\": \"Adatbázismotor újratöltése\",\n        \"Reload data engine?\": \"Újratöltsük az adatbázismotort?\",\n        \"Start timing\": \"Időmérés indítása\",\n        \"Stop timing...\": \"Időmérés leállítása...\",\n        \"Time reload\": \"Idő újratöltése\",\n        \"Timing is on\": \"Időmérés bekapcsolva\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Változtass a dokumentumon, majd állítsd meg az időmérést az eredmények megjelenítéséhez.\",\n        \"Only available to document editors\": \"Csak a dokumentum szerkesztői számára hozzáférhető\",\n        \"Only available to document owners\": \"Csak a dokumentum tulajdonosai számára hozzáférhető\",\n        \"Template mode\": \"Sablon mód\",\n        \"Change document type\": \"Dokumentum típusának módosítása\",\n        \"Edit\": \"Szerkeszt\",\n        \"Change nature of document\": \"Dokumentum természetének módosítása\",\n        \"Regular document\": \"Általános dokumentum\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Általános dokumentumként viselkedik. Minden felhasználó ugyanazon a dokumentum-példányon dolgozik.\",\n        \"Regular\": \"Általános\",\n        \"Template\": \"Sablon\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"A dokumentum automatikusan {{fiddleModeDocUrl}} nyílik meg. Bárki módosíthat benne, a módosítás egy új, még nem mentett másolatot hoz létre.\",\n        \"fiddle mode\": \"játszó módban\",\n        \"Tutorial\": \"Oktató\",\n        \"Document automatically opens as a user-specific copy.\": \"A dokumentum automatikusan a felhasználóra szabott másolatként nyílik meg.\",\n        \"Confirm change\": \"Módosítás megerősítése\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Ez a funkció az adatbázismotor kényszerített újratöltését végzi. Ez segíthet, ha az adatbázismotor végtelen ciklusba került, határozatlanul végezte el a legutolsó módosítást, vagy összeomlott. Nem okoz adatvesztést, kivéve az esetlegesen még folyamatban lévő (be nem fejezett) tevékenységek adatait.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Ha elindítod az időmérést, a Grist méri, hogy mennyi ideig tart az egyes képletek kiszámítása. Ez lehetővé teszi annak elemzését, hogy melyik képlet felelős a dokumentum első megnyitásakor vagy változtatásoknál tapasztalható lassúságért.\",\n        \"**Some existing attachments are still external**.\": \"**Néhány csatolmány még mindig külsőként van tárolva**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Néhány csatolmány még mindig belsőként van tárolva** (az SQLite fájlban).\",\n        \"Attachment storage\": \"Csatolmány tárolása\",\n        \"Being transfer\": \"Átvitel alatt\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Kattints az \\\"Átvitel indítása\\\" gombra, hogy áttöltsük ezeket a külső tárolóra.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Kattints az \\\"Átvitel indítása\\\" gombra, hogy áttöltsük ezeket a belső tárolóba (az SQLite fájlba).\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Az újonnan feltöltött csatolmányok a külső tárolóba kerülnek.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Az újonnan feltöltött csatolmányok a belső tárolóba kerülnek.\",\n        \"No external stores available\": \"Nincs elérhető külső tároló\",\n        \"Preferred storage for this document\": \"Kívánt tárolási mód ennél a dokumentumnál\",\n        \"Start transfer\": \"Átvitel indítása\",\n        \"External\": \"Külső\",\n        \"Internal\": \"Belső\",\n        \"Transfer in progress\": \"Átvitel folyamatban\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Néhány csatolmány még mindig [külső]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Néhány csatolmány még mindig [belső]({{internalLink}})** (SQLite fájlban tárolva).\",\n        \"[Learn more.]({{learnLink}})\": \"[További információk.]({{learnLink}})\",\n        \"Upload\": \"Feltölt\",\n        \"Upload missing attachments\": \"Hiányzó csatolmányok feltöltése\",\n        \"Uploading...\": \"Feltöltés...\",\n        \"Default\": \"Alapértelmezett\",\n        \"Default, template, or tutorial\": \"Alapértelmezett, sablon, vagy oktató\",\n        \"Document type\": \"Dokumentum típusa\",\n        \"Allow others to suggest changes\": \"Engedélyezzük másoknak, hogy változtatásokat javasoljanak\",\n        \"Enable suggestions\": \"Javaslatok engedélyezése\",\n        \"Suggestions\": \"Javaslatok\",\n        \"experiment\": \"kísérleti\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"Csatolmányok mérete\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Vedd fel a kapcsolatot az oldal tulajdonosával, hogy az előfizetés változtatásával emelje a korlátokat.\",\n        \"Data size\": \"Adatok mérete\",\n        \"For higher limits, \": \"Magasabb korlátokért, \",\n        \"Rows\": \"Sorok\",\n        \"Usage\": \"Használat\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"A dokumentum használati statisztikái csak teljes hozzáféréssel rendelkező felhasználók számára elérhetőek.\",\n        \"start your 30-day free trial of the Pro plan.\": \"kezdd el a Pro előfizetéshez tartozó 30 napos ingyenes próbaidőszakot.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Utolsó módosítás visszaállítása\",\n        \"Undo discard\": \"Elvetés visszavonása\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Minden adat másolása a táblaszerkezettel együtt.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"A tábla duplikálása helyett általában jobb gyakorlat az adatok szegmentálására a kapcsolt nézetek használata. {{link}}\",\n        \"Name for new table\": \"Az új tábla neve\",\n        \"Only the document default access rules will apply to the copy.\": \"Csak a dokumentum alapértelmezett hozzáférési szabályai lesznek érvényesek a másolatra.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Iskola utáni program\",\n        \"Investment Research\": \"Befektetési elemzés\",\n        \"Lightweight CRM\": \"Egyszerű CRM\",\n        \"Tutorial: Manage Business Data\": \"Gyakorlat: Üzleti adatok kezelése\",\n        \"Welcome to the Afterschool Program template\": \"Üdvözöllek az Iskola Utáni Program sablonban\",\n        \"Welcome to the Investment Research template\": \"Üdvözöllek a Befektetései Elemzés sablonban\",\n        \"Welcome to the Lightweight CRM template\": \"Üdvözöllek az Egyszerű CRM sablonban\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Nézd meg a kapcsolódó tananyagunkat, hogy hogyan kapcsolj össze adatokat és hozz létre hasznos elrendezéseket.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Nézd meg a kapcsolódó tananyagunkat, hogy hogyan modellezd az üzleti adatokat, hogyan használj képleteket és kezeld a komplexitást.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Nézd meg a kapcsolódó tananyagunkat, hogy hogyan készíts összegző táblázatokat és grafikonokat, és hogyan kapcsolj össze dinamikusan grafikonokat.\",\n        \"Tutorial: Analyze & Visualize\": \"Tananyag: elemzés és vizualizáció\",\n        \"Tutorial: Create a CRM\": \"Tananyag: készíts egy CRM (ügyfélkapcsolati) rendszert\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"OSZLOP MŰKÖDÉS\",\n        \"COLUMN LABEL AND ID\": \"OSZLOP CÍMKE ÉS AZONOSÍTÓ\",\n        \"Clear and make into formula\": \"Törlés és képletté alakítás\",\n        \"Clear and reset\": \"Törlés és visszaállítás\",\n        \"Column options are limited in summary tables.\": \"Összesítő tábláknál oszlopbeállítások csak korlátozottan állnak rendelkezésre.\",\n        \"Convert column to data\": \"Oszlop átalakítása adattá\",\n        \"Convert to trigger formula\": \"Átalakítás indítóképletté\",\n        \"Data columns_one\": \"Adat oszlop\",\n        \"Data columns_other\": \"Adat oszlopok\",\n        \"Empty columns_one\": \"Üres oszlop\",\n        \"Empty columns_other\": \"Üres oszlopok\",\n        \"Enter formula\": \"Írd be a képletet\",\n        \"Formula columns_one\": \"Képlet oszlop\",\n        \"Formula columns_other\": \"Képlet oszlopok\",\n        \"Make into data column\": \"Átalakítás adat oszloppá\",\n        \"Mixed Behavior\": \"Vegyes működés\",\n        \"Set formula\": \"Képlet beállítása\",\n        \"Set trigger formula\": \"Indítóképlet beállítása\",\n        \"TRIGGER FORMULA\": \"INDÍTÓKÉPLET\",\n        \"DESCRIPTION\": \"LEÍRÁS\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Általános beállítások visszaállítása\",\n        \"Save as common settings\": \"Mentés általános beállításként\",\n        \"Use separate settings\": \"Önálló beállítások használata\",\n        \"Using common settings\": \"Általános beállításokat használ\",\n        \"Using separate settings\": \"Önálló beállításokat használ\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Oszlop hozzáadása\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Szűrő kitűzése - {{- columnName}} oszlop (most: nincs kitűzve)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Szűrő kitűzés törlése - {{- columnName}} oszlop (most: kitűzve)\",\n        \"remove filter - {{- columnName}} column\": \"szűrő törlése - {{- columnName}} oszlop\",\n        \"{{- columnName }} column filters\": \"{{- columnName }} oszlop szűrők\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Keresés az oszlopokban\",\n        \"Search Columns\": \"Keresés az Oszlopokban\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Rács beállítások\",\n        \"Horizontal gridlines\": \"Vízszintes rácsvonalak\",\n        \"Vertical gridlines\": \"Függőleges rácsvonalak\",\n        \"Zebra stripes\": \"Zebracsíkok\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Oszlop hozzáadása\",\n        \"Add to sort\": \"Hozzáadás rendezéshez\",\n        \"Clear values\": \"Értékek törlése\",\n        \"Column Options\": \"Oszlop beállítások\",\n        \"Convert formula to data\": \"Képlet átalakítása adattá\",\n        \"Delete {{count}} columns_one\": \"Oszlop törlése\",\n        \"Delete {{count}} columns_other\": \"{{count}} oszlop törlése\",\n        \"Filter Data\": \"Adatok szűrése\",\n        \"Freeze {{count}} columns_one\": \"Ezen oszlop rögzítése\",\n        \"Freeze {{count}} columns_other\": \"{{count}} oszlop rögzítése\",\n        \"Freeze {{count}} more columns_one\": \"Egy vagy több oszlop rögzítése\",\n        \"Freeze {{count}} more columns_other\": \"További {{count}} oszlop rögzítése\",\n        \"Hide {{count}} columns_one\": \"Oszlop elrejtése\",\n        \"Hide {{count}} columns_other\": \"{{count}} oszlop elrejtése\",\n        \"Insert column to the {{to}}\": \"Oszlop beszúrása {{to}}\",\n        \"More sort options ...\": \"További rendezési beállítások…\",\n        \"Rename column\": \"Oszlop átnevezése\",\n        \"Reset {{count}} columns_one\": \"Oszlop visszaállítása\",\n        \"Reset {{count}} columns_other\": \"{{count}} oszlop visszaállítása\",\n        \"Reset {{count}} entire columns_one\": \"Az egész oszlop visszaállítása\",\n        \"Reset {{count}} entire columns_other\": \"{{count}} egész oszlop visszaállítása\",\n        \"Show column {{- label}}\": \"{{- label}} oszlop megjelenítése\",\n        \"Sort\": \"Rendezés\",\n        \"Sorted (#{{count}})_one\": \"Rendezve (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Rendezve (#{{count}})\",\n        \"Unfreeze all columns\": \"Minden oszlop rögzítésének feloldása\",\n        \"Unfreeze {{count}} columns_one\": \"Ezen oszlop rögzítésének feloldása\",\n        \"Unfreeze {{count}} columns_other\": \"{{count}} oszlop rögzítésének feloldása\",\n        \"Insert column to the left\": \"Oszlop beszúrása balra\",\n        \"Insert column to the right\": \"Oszlop beszúrása jobbra\",\n        \"Apply on record changes\": \"Alkalmazás a rekord változásakor\",\n        \"Apply to new records\": \"Alkalmazás új rekordnál\",\n        \"Authorship\": \"Szerzőség\",\n        \"Created At\": \"Létrehozva\",\n        \"Created By\": \"Létrehozó\",\n        \"Hidden Columns\": \"Rejtett oszlopok\",\n        \"Last Updated At\": \"Utoljára frissítve\",\n        \"Last Updated By\": \"Utolsó frissítő\",\n        \"Lookups\": \"Keresések\",\n        \"Shortcuts\": \"Gyorsítók\",\n        \"Show hidden columns\": \"Rejtett oszlopok mutatása\",\n        \"Timestamp\": \"Időbélyegző\",\n        \"no reference column\": \"nincs kapcsolt oszlop\",\n        \"Adding UUID column\": \"UUID oszlop hozzáadása\",\n        \"Adding duplicates column\": \"Duplikátum-oszlop hozzáadása\",\n        \"Detect Duplicates in...\": \"Duplikátumok keresése...\",\n        \"Duplicate in {{- label}}\": \"Duplikátumok {{- label}} oszlopban\",\n        \"No reference columns.\": \"Nincsenek kapcsolt oszlopok.\",\n        \"Search columns\": \"Keresés az oszlopokban\",\n        \"UUID\": \"UUID\",\n        \"Add column with type\": \"Oszlop hozzáadása típusválasztással\",\n        \"Add formula column\": \"Képlet-oszlop hozzáadása\",\n        \"Created at\": \"Létrehozva\",\n        \"Created by\": \"Létrehozó\",\n        \"Detect duplicates in...\": \"Duplikátumok keresése\",\n        \"Last updated at\": \"Utoljára módosítva\",\n        \"Last updated by\": \"Utolsó módosító\",\n        \"Any\": \"Bármi\",\n        \"Numeric\": \"Szám\",\n        \"Text\": \"Szöveg\",\n        \"Integer\": \"Egész szám\",\n        \"Toggle\": \"Kapcsoló\",\n        \"Date\": \"Dátum\",\n        \"DateTime\": \"Dátum és idő\",\n        \"Choice\": \"Választás\",\n        \"Choice List\": \"Többszörös választás\",\n        \"Reference\": \"Hivatkozás\",\n        \"Reference List\": \"Hivatkozás-lista\",\n        \"Attachment\": \"Csatolmány\"\n    },\n    \"GristDoc\": {\n        \"Import from file\": \"Importálás fájlból\",\n        \"Added new linked section to view {{viewName}}\": \"Új kapcsolt szakasz hozzáadva {{viewName}} nézethez\",\n        \"Saved linked section {{title}} in view {{name}}\": \"{{title}} kapcsolt szakasz mentve {{name}} nézetben\",\n        \"go to webhook settings\": \"menjünk a webhook beállításaihoz\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Az új változtatásokat átmenetileg felfüggesztettük. A webhook várakozási sora megtelt. Ellenőrizd a webhook beállításait, töröld a hibás webhook-okat, és tisztítsd a várakozási sort.\",\n        \"Import from Airtable\": \"Importálás Airtable-ből\"\n    },\n    \"HomeIntro\": {\n        \"Help Center\": \"Súgóközpont\",\n        \"Import document\": \"Dokumentum importálása\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Érdekel a Grist használata a csapatodon kívül is? Látogasd meg ingyenes \",\n        \"Invite Team Members\": \"Csapattagok meghívása\",\n        \"Sign up\": \"Regisztráció\",\n        \"Sprouts Program\": \"Sprouts Program\",\n        \"This workspace is empty.\": \"Ez a munkaterület üres.\",\n        \"Visit our {{link}} to learn more.\": \"További tudnivalókért látogasd meg {{link}} oldalunkat.\",\n        \"Welcome to Grist!\": \"Üdvözöl a Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Üdvözöl a Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Üdvözöl a(z) {{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Ehhez az oldalhoz csak olvasási jogosultságod van. Jelenleg nincsenek dokumentumok.\",\n        \"personal site\": \"személyes oldal\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} a munkád mentéséhez. \",\n        \"Welcome to Grist, {{- name}}!\": \"Üdvözöl a Grist, {{- name}}!\",\n        \"Welcome to {{- orgName}}\": \"Üdvözöl a(z) {{- orgName}}\",\n        \"Sign in\": \"Bejelentkezés\",\n        \"To use Grist, please either sign up or sign in.\": \"Jelentkezz be vagy regisztrálj a Grist használatához!\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Ha többet szeretnél megtudni a Gristről, látogasd meg {{link}} oldalunkat!\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Tudj meg többet a {{helpCenterLink}} oldalunkon.\",\n        \"Only show documents\": \"Csak dokumentumok mutatása\",\n        \"Any documents created in this site will appear here.\": \"Itt jelennek meg az oldalon létrehozott dokumentumok.\",\n        \"Browse Templates\": \"Sablonok böngészése\",\n        \"Create empty document\": \"Üres dokumentum létrehozása\",\n        \"Get started by creating your first Grist document.\": \"Hozd létre az első Grist dokumentumodat!\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Fedezd fel a sablonokat, vagy hozd létre az első Grist dokumentumodat!\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Hívd meg a csapatod tagjait és hozd létre az első Grist dokumentumodat!\"\n    },\n    \"HomeLeftPane\": {\n        \"Access Details\": \"Hozzáférés részletei\",\n        \"All documents\": \"Minden dokumentum\",\n        \"Create empty document\": \"Üres dokumentum létrehozása\",\n        \"Create workspace\": \"Munkaterület létrehozása\",\n        \"Delete\": \"Törlés\",\n        \"Delete {{workspace}} and all included documents?\": \"Töröljük a(z) {{workspace}} munkaterületet és annak minden dokumentumát?\",\n        \"Examples & Templates\": \"Sablonok\",\n        \"Import document\": \"Dokumentum importálása\",\n        \"Manage users\": \"Felhasználók kezelése\",\n        \"Rename\": \"Átnevezés\",\n        \"Trash\": \"Kuka\",\n        \"Workspace will be moved to Trash.\": \"A munkaterületet áthelyezzük a kukába.\",\n        \"Workspaces\": \"Munkaterületek\",\n        \"Tutorial\": \"Gyakorlat\",\n        \"Terms of service\": \"Szolgáltatási feltételek\",\n        \"Grist Resources\": \"Grist eszközök és tudástár\",\n        \"context menu - {{- workspaceName }}\": \"kontextus-menü - {{- workspaceName }}\"\n    },\n    \"Importer\": {\n        \"Column Mapping\": \"Oszlopok megfeleltetése\",\n        \"Column mapping\": \"Oszlopok megfeleltetése\",\n        \"Destination table\": \"Cél tábla\",\n        \"Grist column\": \"Grist oszlop\",\n        \"Import from file\": \"Importálás fájlból\",\n        \"New Table\": \"Új tábla\",\n        \"Revert\": \"Visszaállítás\",\n        \"Skip\": \"Kihagy\",\n        \"Skip Import\": \"Importálás kihagyása\",\n        \"Skip Table on Import\": \"Tábla kihagyása importálásnál\",\n        \"Source column\": \"Forrás oszlop\",\n        \"Import options\": \"Importálás beállításai\",\n        \"Cancel\": \"Mégse\",\n        \"Import\": \"Importálás\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Súgóközpont\",\n        \"Accessibility\": \"Kisegítő lehetőségek\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"Sablonként\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Vigyázz, az eredetiben vannak olyan változtatások, amelyek nincsenek ebben a dokumentumban! Ezeket a változtatásokat felülírjuk!\",\n        \"Cancel\": \"Mégse\",\n        \"Enter document name\": \"Írd be a dokumentum nevét\",\n        \"However, it appears to be already identical.\": \"Bár, úgy tűnik, már megegyezőek.\",\n        \"Include the structure without any of the data.\": \"Csak struktúra adatok nélkül.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Felülír minden tartalmat, ami nincs ebben a dokumentumban.\",\n        \"Name\": \"Név\",\n        \"No destination workspace\": \"Nincs cél munkaterület\",\n        \"Organization\": \"Szervezet\",\n        \"Original Has Modifications\": \"Változott az eredeti\",\n        \"Original Looks Unrelated\": \"Nem látszik kapcsolat az eredetivel\",\n        \"Original Looks Identical\": \"Az eredetivel azonosnak tűnik\",\n        \"Overwrite\": \"Felülírás\",\n        \"Replacing the original requires editing rights on the original document.\": \"Az eredeti felülírásához szerkesztői jogosultság szükséges az eredeti dokumentumhoz.\",\n        \"Sign up\": \"Regisztráció\",\n        \"The original version of this document will be updated.\": \"Ennek a dokumentumnak az eredeti változata frissül.\",\n        \"To save your changes, please sign up, then reload this page.\": \"A változtatásaid mentéséhez regisztrálj, és töltsd be újra ezt a lapot!\",\n        \"Update\": \"Frissítés\",\n        \"Update Original\": \"Eredeti frissítése\",\n        \"Workspace\": \"Munkaterület\",\n        \"You do not have write access to the selected workspace\": \"Nincs írási jogosultságod a kiválasztott munkaterülethez\",\n        \"You do not have write access to this site\": \"Nincs írási jogosultságod ehhez az oldalhoz\",\n        \"Download\": \"Letöltés\",\n        \"Download document\": \"Dokumentum letöltése\",\n        \"Download document and history\": \"Dokumentum és -történet letöltése\",\n        \"Download document without history (can significantly reduce file size)\": \"Dokumentum letöltése történet nélkül (jelentősen csökkentheti a fájlméretet)\",\n        \"Download document structure only (no data, for template use)\": \"Csak dokumentumstruktúra letöltése (adatok nélkül, sablonkénti használatra)\",\n        \".tar (recommended)\": \".tar (javasolt)\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"A dokumentum csatolmányainak letöltése egyetlen archívumként.\",\n        \"Download attachments\": \"Csatolmányok letöltése\",\n        \"Download full document and history\": \"Teljes dokumentum és -történet letöltése\",\n        \"Format:\": \"Formátum:\",\n        \"Learn more\": \"További tudnivalók\",\n        \"download attachments\": \"csatolmányok letöltése\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"A csatolmányok külső tárolásúak, nincsenek benne ebben a letöltésben. Ha feltöltöd a dokumentumot egy másik Grist telepítésre, akkor külön szükséged lesz {{downloadLink}} is. \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"Ha azt tervezed, hogy ezt a dokumentumot feltöltöd egy másik Grist rendszerbe, akkor a \\\".tar\\\" archívum-formátumot válaszd, hogy a csatolmányok is átmenjenek. \"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"Segítség kérése\",\n        \"Cannot find personal site, sorry!\": \"Bocsánat, de nem találom a személyes oldalt!\",\n        \"Give feedback\": \"Visszajelzés küldése\",\n        \"Go to your free personal site\": \"Irány a személyes oldalad\",\n        \"No notifications\": \"Nincsenek értesítések\",\n        \"Notifications\": \"Értesítések\",\n        \"Renew\": \"Megújítás\",\n        \"Report a problem\": \"Hiba jelzése\",\n        \"Upgrade Plan\": \"Előfizetés\",\n        \"Manage billing\": \"Számlázás kezelése\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Befejezés\",\n        \"Next\": \"Következő\",\n        \"Previous\": \"Előző\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Grist videós bemutató\",\n        \"Video Tour\": \"Videós bemutató\",\n        \"YouTube video player\": \"YouTube videólejátszó\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Hozzáadás az oldalhoz\",\n        \"Building {{- label}} widget\": \"{{- label}} widget építése\",\n        \"Group by\": \"Csoportosítás\",\n        \"Select data\": \"Adat kiválasztása\",\n        \"Select widget\": \"Widget kiválasztása\",\n        \"New Table\": \"Új tábla\",\n        \"SELECT BY\": \"KIVÁLASZTÁS\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Törlés\",\n        \"Delete data and this page.\": \"Az adatok és ezen oldal törlése.\",\n        \"The following tables will no longer be visible_one\": \"A következő tábla többé nem lesz látható\",\n        \"The following tables will no longer be visible_other\": \"A következő táblák többé nem lesznek láthatóak\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Oldal törlése az adatok megtartása mellett. A tábla továbbra is elérhető marad a {{rawDataLink}} alatt\",\n        \"raw data page\": \"nyersadat oldal\",\n        \"Document pages\": \"Dokumentum oldalak\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Mindent engedélyez\",\n        \"Deny all\": \"Mindent tilt\",\n        \"Read only\": \"Csak olvasható\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Sikertelen import: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Rekord-elrendezés frissítése.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Mező hozzáadása\",\n        \"Create new field\": \"Mező létrehozása\",\n        \"Show field {{- label}}\": \"{{- label}} mező mutatása\",\n        \"Save layout\": \"Elrendezés mentése\",\n        \"Cancel\": \"Mégse\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Oszlop hozzáadása\",\n        \"No columns to add\": \"Nincs hozzáadható oszlop\"\n    },\n    \"RightPanel\": {\n        \"CHART TYPE\": \"DIAGRAM TÍPUSA\",\n        \"COLUMN TYPE\": \"OSZLOP TÍPUSA\",\n        \"CUSTOM\": \"EGYÉNI\",\n        \"Change widget\": \"Widget cseréje\",\n        \"columns_one\": \"Oszlop\",\n        \"columns_other\": \"Oszlopok\",\n        \"DATA TABLE\": \"ADATTÁBLA\",\n        \"DATA TABLE NAME\": \"ADATTÁBLA NEVE\",\n        \"Data\": \"Adat\",\n        \"Detach\": \"Leválasztás\",\n        \"Edit data selection\": \"Adatkiválasztás módosítása\",\n        \"fields_one\": \"Mező\",\n        \"fields_other\": \"Mezők\",\n        \"GROUPED BY\": \"CSOPORTOSÍTVA\",\n        \"Row style\": \"Sor stílusa\",\n        \"SELECT BY\": \"KIVÁLASZTVA\",\n        \"SELECTOR FOR\": \"KIVÁLASZTÓ\",\n        \"SOURCE DATA\": \"FORRÁSADATOK\",\n        \"Save\": \"Mentés\",\n        \"Select widget\": \"Widget kiválasztása\",\n        \"series_one\": \"Idősor\",\n        \"series_other\": \"Idősor\",\n        \"Sort & filter\": \"Rendezés és szűrés\",\n        \"TRANSFORM\": \"ÁTALAKÍTÁS\",\n        \"Theme\": \"Téma\",\n        \"WIDGET TITLE\": \"WIDGET CÍME\",\n        \"Widget\": \"Widget\",\n        \"You do not have edit access to this document\": \"Nincs módosítási jogosultságod ehhez a dokumentumhoz\",\n        \"Add referenced columns\": \"Hivatkozott oszlopok hozzáadása\",\n        \"Reset form\": \"Űrlap visszaállítása\",\n        \"Configuration\": \"Beállítás\",\n        \"Default field value\": \"Mező alapértelmezett értéke\",\n        \"Display button\": \"Gomb megjelenítése\",\n        \"Enter text\": \"Írj be szöveget\",\n        \"Field rules\": \"Mező szabályok\",\n        \"Field title\": \"Mező címe\",\n        \"Hidden field\": \"Rejtett mező\",\n        \"Layout\": \"Elrendezés\",\n        \"Redirect automatically after submission\": \"Beküldés után automatikus átirányítás\",\n        \"Redirection\": \"Átirányítás\",\n        \"Required field\": \"Kötelező mező\",\n        \"Submission\": \"Beküldés\",\n        \"Submit another response\": \"Újabb válasz beküldése\",\n        \"Submit button label\": \"Beküldés gomb címkéje\",\n        \"Success text\": \"Szöveg sikeres művelet esetén\",\n        \"Table column name\": \"Tábla oszlop neve\",\n        \"Enter redirect URL\": \"Írd be az átirányítás URL-jét\",\n        \"No field selected\": \"Nincs kiválasztott mező\",\n        \"Select a field in the form widget to configure.\": \"Válassz egy mezőt az űrlapon a beállításhoz.\",\n        \"Submit\": \"Beküld\",\n        \"Thank you! Your response has been recorded.\": \"Köszönjük! A válaszát mentettük.\",\n        \"Chart options\": \"Diagram beállítások\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"Horgony link másolása\",\n        \"Delete\": \"Törlés\",\n        \"Duplicate rows_one\": \"Sor duplázása\",\n        \"Duplicate rows_other\": \"Sorok duplázása\",\n        \"Insert row\": \"Sor beszúrása\",\n        \"Insert row above\": \"Sor beszúrása fölé\",\n        \"Insert row below\": \"Sor beszúrása alá\",\n        \"View as card\": \"Megjelenítés kártyaként\",\n        \"Use as table headers\": \"Használat táblafejlécként\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Vágólapra másolva\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"Hozzáférés részletei\",\n        \"Back to current\": \"Vissza a jelenlegihez\",\n        \"Compare to {{termToUse}}\": \"Összehasonlítás {{termToUse}} elemmel\",\n        \"Current Version\": \"Jelenlegi verzió\",\n        \"Download\": \"Letöltés\",\n        \"Duplicate document\": \"Dokumentum duplázása\",\n        \"Edit without affecting the original\": \"Szerkesztés az eredeti módosítása nélkül\",\n        \"Export CSV\": \"CSV exportálás\",\n        \"Export XLSX\": \"XLSX exportálás\",\n        \"Manage users\": \"Felhasználók kezelése\",\n        \"Original\": \"Eredeti\",\n        \"Replace {{termToUse}}...\": \"{{termToUse}} cseréje…\",\n        \"Return to {{termToUse}}\": \"Vissza ide: {{termToUse}}\",\n        \"Save copy\": \"Másolat mentése\",\n        \"Save Document\": \"Dokumentum mentése\",\n        \"Send to Google Drive\": \"Küldés Google Drive-ra\",\n        \"Show in folder\": \"Megjelenítés a mappában\",\n        \"Unsaved\": \"Nincs mentve\",\n        \"Work on a copy\": \"Munkavégzés egy másolaton\",\n        \"Share\": \"Megosztás\",\n        \"Download...\": \"Letöltés...\",\n        \"Comma Separated Values (.csv)\": \"Vesszővel elválasztott értékek (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"Speciális jellel elválasztott értékek (.dsv)\",\n        \"Export as...\": \"Exportálás másként...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Tabulátorral elválasztott értékek (.tsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Csak dokumentum-oldalakról lehet exportálni. Válassz egy dokumentum-oldalt és próbáld újra.\",\n        \"Download attachments...\": \"Csatolmányok letöltése...\",\n        \"Download document...\": \"Dokumentum letöltése...\",\n        \"Suggest changes\": \"Javasolj változtatásokat\",\n        \"current version\": \"jelenlegi verzió\",\n        \"original\": \"eredeti\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Új csapat-munkatér létrehozása\",\n        \"Switch Sites\": \"Munkatérváltás\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Oszlop hozzáadása\",\n        \"Empty values last\": \"Üres értékek a végére\",\n        \"Natural sort\": \"Természetes rendezés\",\n        \"Update data\": \"Adatok frissítése\",\n        \"Use choice position\": \"Kiválasztás pozíciójának használata\",\n        \"Search Columns\": \"Keresés oszlopokban\",\n        \"Remove sort setting - {{- columnName }} column\": \"Rendezési beállítás eltávolítása - {{- columnName }} oszlop\",\n        \"Sort in ascending order (current: descending)\": \"Rendezés növekvő sorrendben (jelenlegi: csökkenő)\",\n        \"Sort in descending order (current: ascending)\": \"Rendezés csökkenő sorrendben (jelenlegi: növekvő)\",\n        \"Sort options - {{- columnName }} column\": \"Rendezési beállítások - {{- columnName }} oszlop\",\n        \"{{- columnName }} column\": \"{{- columnName }} oszlop\"\n    },\n    \"SortFilterConfig\": {\n        \"Filter\": \"SZŰRŐ\",\n        \"Revert\": \"Visszaállítás\",\n        \"Save\": \"Mentés\",\n        \"Sort\": \"RENDEZÉS\",\n        \"Update Sort & Filter settings\": \"Rendezési és szűrési beállítások frissítése\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Megjelenés \",\n        \"Switch appearance automatically to match system\": \"A megjelenés a rendszerbeállításokhoz igazodjon\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Hozzáférési szabályok\",\n        \"Code view\": \"Kód nézet\",\n        \"Delete\": \"Törlés\",\n        \"Delete document tour?\": \"Töröljük a dokumentum-bemutatót?\",\n        \"Document history\": \"Dokumentum története\",\n        \"How-to Tutorial\": \"Bemutató\",\n        \"Raw data\": \"Nyersadatok\",\n        \"Return to viewing as yourself\": \"Vissza a saját nézetedhez\",\n        \"TOOLS\": \"ESZKÖZÖK\",\n        \"Tour of this Document\": \"A jelenlegi dokumentum bemutatója\",\n        \"Validate Data\": \"Adatok validálása\",\n        \"Settings\": \"Beállítások\",\n        \"API console\": \"API konzol\",\n        \"context menu - Access Rules\": \"kontextus-menü - Hozzáférési szabályok\",\n        \"Delete document tour\": \"Dokumentum-bemutató törlése\",\n        \"Preview the tutorial\": \"Bemutató előnézete\",\n        \"Proposed changes\": \"Javasolt változtatások\",\n        \"Suggest changes\": \"Javasolt változtatást\",\n        \"Suggestions\": \"Javaslatok\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Csapat kezelése\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"Bármely mező\",\n        \"Apply on changes to:\": \"Változás esetén alkalmazandó erre:\",\n        \"Apply on record changes\": \"Rekord-változás esetén alkalmazandó\",\n        \"Apply to new records\": \"Új rekord esetén alkalmazandó\",\n        \"Cancel\": \"Mégse\",\n        \"Close\": \"Bezár\",\n        \"Current field \": \"Jelenlegi mező \",\n        \"OK\": \"OK\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"Alkalmaz\",\n        \"Cancel\": \"Mégse\",\n        \"Preview\": \"Előnézet\",\n        \"Revise\": \"Felülvizsgál\",\n        \"Update formula (Shift+Enter)\": \"Képlet frissítése (Shift+Enter)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Szerkesztő\",\n        \"In full\": \"Teljes egészében\",\n        \"No Default Access\": \"Nincs alapértelmezett hozzáférés\",\n        \"None\": \"Nincs\",\n        \"Owner\": \"Tulajdonos\",\n        \"View & edit\": \"Megtekintés és módosítás\",\n        \"View only\": \"Csak megtekintés\",\n        \"Viewer\": \"Megtekintő\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Szabály {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Képlet frissítése (Shift+Enter)\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Ismeretlen felhasználó\",\n        \"View as Yourself\": \"Megtekintés saját magadként\",\n        \"You are viewing this document as\": \"Jelen dokumentum megtekintése mint\",\n        \"You're seeing what this user would see if given access\": \"Azt látod, amit ez a felhasználó látna, ha hozzáférést kapna\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Haladó beállítások\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"A nagy táblázatokat meg lehet jelölni \\\"igény szerinti betöltésre\\\", hogy ne töltse be az egészet az adatbázis-motor.\",\n        \"Blocks\": \"Blokkok\",\n        \"Compact\": \"Kompakt\",\n        \"Edit card layout\": \"Kártya elrendezés szerkesztése\",\n        \"Form\": \"Űrlap\",\n        \"Make On-Demand\": \"Megjelölés igény szerinti betöltésre\",\n        \"Plugin: \": \"Bővítmény: \",\n        \"Section: \": \"Szakasz: \",\n        \"Unmark On-Demand\": \"Igény szerinti betöltés jelölés törlése\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"Az igény szerinti táblázatok elavultak a funkcionalitás hiánya és használhatósági megfontolások miatt.\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Elavult funkció\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Haladó rendezés és szűrés\",\n        \"Copy anchor link\": \"Horgony link másolása\",\n        \"Data selection\": \"Adatok kiválasztása\",\n        \"Delete record\": \"Rekord törlése\",\n        \"Delete widget\": \"Widget törlése\",\n        \"Download as CSV\": \"Letöltés CSV formátumban\",\n        \"Download as XLSX\": \"Letöltés XLSX formátumban\",\n        \"Edit card layout\": \"Kártya elrendezés szerkesztése\",\n        \"Open configuration\": \"Beállítások megnyitása\",\n        \"Print widget\": \"Widget nyomtatása\",\n        \"Show raw data\": \"Nyersadatok mutatása\",\n        \"Widget options\": \"Widget beállításai\",\n        \"Add to page\": \"Hozzáadás az oldalhoz\",\n        \"Collapse widget\": \"Widget összezárása\",\n        \"Create a form\": \"Űrlap létrehozása\",\n        \"Duplicate widget\": \"Widget duplikálása\"\n    },\n    \"ViewSectionMenu\": {\n        \"(customized)\": \"(egyéni)\",\n        \"(empty)\": \"(üres)\",\n        \"(modified)\": \"(módosított)\",\n        \"Custom options\": \"Egyéni beállítások\",\n        \"FILTER\": \"SZŰRŐ\",\n        \"Revert\": \"Visszaállítás\",\n        \"SORT\": \"RENDEZÉS\",\n        \"Save\": \"Mentés\",\n        \"Update Sort&Filter settings\": \"Rendezési és szűrési beállítások frissítése\",\n        \"Sort and filter\": \"Rendezés és szűrés\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"Rejtett mezőbe nem lehet elemeket dobni\",\n        \"Clear\": \"Kijelölések törlése\",\n        \"Hidden Fields cannot be reordered\": \"A rejtett mezőket nem lehet átrendezni\",\n        \"Select all\": \"Mindent kijelöl\",\n        \"Visible {{label}}\": \"{{label}} látható\",\n        \"Hide {{label}}\": \"{{label}} elrejtése\",\n        \"Hidden {{label}}\": \"{{label}} rejtett\",\n        \"Show {{label}}\": \"{{label}} mutatása\",\n        \"Hide {{label}} (batch mode)\": \"{{label}} elrejtése (kötegelt mód)\",\n        \"Show {{label}} (batch mode)\": \"{{label}} mutatása (kötegelt mód)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Végzettség\",\n        \"Finance & Accounting\": \"Pénzügy és számvitel\",\n        \"HR & Management\": \"HR és menedzsment\",\n        \"IT & Technology\": \"IT és technológia\",\n        \"Marketing\": \"Marketing\",\n        \"Media Production\": \"Médiatartalom-gyártás\",\n        \"Other\": \"Egyéb\",\n        \"Product Development\": \"Termékfejlesztés\",\n        \"Research\": \"Kutatás\",\n        \"Sales\": \"Értékesítés\",\n        \"Type here\": \"Írd ide\",\n        \"Welcome to Grist!\": \"Üdvözöl a Grist!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Mi miatt kötöttél ki a Grist-nél? A segítségedet kérjük, hogy jobban ki tudjunk szolgálni.\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"Mégse\",\n        \"DATA TABLE NAME\": \"ADATTÁBLA NEVE\",\n        \"Override widget title\": \"Widget címének felülírása\",\n        \"Provide a table name\": \"Add meg a táblázat nevét\",\n        \"Save\": \"Mentés\",\n        \"WIDGET TITLE\": \"WIDGET CÍME\",\n        \"WIDGET DESCRIPTION\": \"WIDGET LEÍRÁSA\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Módosíthatsz a dokumentumon, de a változtatás új másolatot hoz létre, és\\nnem érinti az eredeti dokumentumot.\",\n        \"fiddle\": \"bütyköl\",\n        \"override\": \"felülír\",\n        \"recovery mode\": \"helyreállítási mód\",\n        \"snapshot\": \"pillanatfelvétel\",\n        \"unsaved\": \"nincs mentve\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Módosíthatsz a dokumentumon,\\nde a változtatás nem érinti az eredeti dokumentumot.\\nJavaslatként előterjesztheted ezeket.\",\n        \"editing\": \"szerkesztés\",\n        \"suggesting\": \"javaslat\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"{{pageName}} oldal duplikálása\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Figyelj rá, hogy ez nem másolja az adatokat, hanem egy másik nézetet készít ugyanazokról az adatokról.\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Hozzáférés megtagadva{{suffix}}\",\n        \"Add account\": \"Fiók hozzáadása\",\n        \"Contact support\": \"Támogatás kérése\",\n        \"Error{{suffix}}\": \"Hiba{{suffix}}\",\n        \"Go to main page\": \"Vissza a főoldalra\",\n        \"Page not found{{suffix}}\": \"Az oldal nem található{{suffix}}\",\n        \"Sign in\": \"Bejelentkezés\",\n        \"Sign in again\": \"Bejelentkezés újra\",\n        \"Sign in to access this organization's documents.\": \"Jelentkezz be, hogy hozzáférj ennek a szervezetnek a dokumentumaihoz.\",\n        \"Signed out{{suffix}}\": \"Kijelentkezve{{suffix}}\",\n        \"Something went wrong\": \"Valami nem sikerült\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"A kért oldal nem található.{{separator}}Ellenőrizd az URL-t és próbáld újra!\",\n        \"There was an error: {{message}}\": \"Hiba történt: {{message}}\",\n        \"There was an unknown error.\": \"Ismeretlen hiba történt.\",\n        \"You are now signed out.\": \"Kijelentkeztél.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"A(z) {{email}} fiókkal jelentkeztél be. Bejelentkezhetsz másik fiókkal, vagy kérhetsz hozzáférést az adminisztrátortól.\",\n        \"You do not have access to this organization's documents.\": \"Nincs hozzáférésed ennek a szervezetnek a dokumentumaihoz.\",\n        \"Account deleted{{suffix}}\": \"Fiók törölve{{suffix}}\",\n        \"Sign up\": \"Regisztráció\",\n        \"Your account has been deleted.\": \"A fiókodat törölték.\",\n        \"An unknown error occurred.\": \"Ismeretlen hiba történt.\",\n        \"Build your own form\": \"Hozd létre a saját űrlapodat\",\n        \"Form not found\": \"Az űrlap nem található\",\n        \"Powered by\": \"Működteti:\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Nem sikerült bejelentkezni.{{separator}}Próbáld meg újra, vagy kérj támogatást.\",\n        \"Sign-in failed{{suffix}}\": \"A bejelentkezés nem sikerült{{suffix}}\",\n        \"Manage settings\": \"Beállítások kezelése\",\n        \"Need Help?\": \"Segítségre van szükséged?\",\n        \"There was an error\": \"Hiba történt\",\n        \"Unsubscribed{{suffix}}\": \"Leiratkozva{{suffix}}\",\n        \"We could not unsubscribe you\": \"Nem tudtunk leiratkoztatni\",\n        \"You are unsubscribed\": \"Leiratkoztál\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Továbbra is leiratkozhatsz erről a dokumentumtól, ha frissíted a beállításaidat a dokumentum beállításai között\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Nem kapsz többé email értesítéseket a(z) {{email}} címre {{docName}} dokumentum {{changes}} változásairól.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Nem kapsz többé email értesítéseket a(z) {{email}} címre {{docName}} dokumentum {{comments}} hozzászólásokról.\",\n        \"changes\": \"változtatások\",\n        \"comments\": \"hozzászólások\",\n        \"this document\": \"ez a dokumentum\",\n        \"your email\": \"e-mail címed\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* A munkaterületek csoport-előfizetésekben érhetők el. \",\n        \"Select fields\": \"Mezők kiválasztása\",\n        \"Upgrade now\": \"Frissíts most\",\n        \"Any\": \"Bármilyen\",\n        \"Numeric\": \"Szám\",\n        \"Text\": \"Szöveg\",\n        \"Integer\": \"Egész szám\",\n        \"Toggle\": \"Kapcsoló\",\n        \"Date\": \"Dátum\",\n        \"DateTime\": \"Dátum és idő\",\n        \"Choice\": \"Választás\",\n        \"Choice List\": \"Választás lista\",\n        \"Reference\": \"Hivatkozás\",\n        \"Reference List\": \"Hivatkozás lista\",\n        \"Attachment\": \"Csatolmány\",\n        \"Search columns\": \"Oszlop keresése\",\n        \"By Name\": \"Név szerint\",\n        \"By Date Modified\": \"Módosítás dátuma szerint\",\n        \"Light\": \"Világos\",\n        \"Custom\": \"Egyéni\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Mégse\",\n        \"Ok\": \"OK\",\n        \"Save\": \"Mentés\",\n        \"Are you sure you want to delete these records?\": \"Biztos, hogy törlöd ezeket a rekordokat?\",\n        \"Are you sure you want to delete this record?\": \"Biztos, hogy törlöd ezt a rekordot?\",\n        \"Delete\": \"Törlés\",\n        \"Dismiss\": \"Bezárás\",\n        \"Don't ask again.\": \"Ne kérdezze újra.\",\n        \"Don't show again.\": \"Ne mutassa újra.\",\n        \"Don't show tips\": \"Ne mutasson tippeket\",\n        \"Undo to restore\": \"Visszavonással helyreállítás\",\n        \"Got it\": \"Értem\",\n        \"Don't show again\": \"Ne mutassa újra\",\n        \"TIP\": \"TIPP\",\n        \"Confirm\": \"Megerősítés\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Oldal duplikálása\",\n        \"Remove\": \"Eltávolítás\",\n        \"Rename\": \"Átnevezés\",\n        \"You do not have edit access to this document\": \"Nincs szerkesztési jogosultságod ehhez a dokumentumhoz\",\n        \"(default)\": \"(alapértelmezett)\",\n        \"Collapse {{maybeDefault}}\": \"Összezár {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Kibont {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Alapértelmezetten összezár\",\n        \"Set default: Expand\": \"Alapértelmezetten kibont\",\n        \"context menu - {{- pageName }}\": \"kontextus-menü - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Find Next \": \"Következő keresése \",\n        \"Find Previous \": \"Előző keresése \",\n        \"No results\": \"Nincs eredmény\",\n        \"Search in document\": \"Keresés a dokumentumban\",\n        \"Search\": \"Keresés\",\n        \"Close search bar\": \"Kereső bezárása\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Fájl küldése a Google Drive-ra\"\n    },\n    \"NTextBox\": {\n        \"false\": \"hamis\",\n        \"true\": \"igaz\",\n        \"Field Format\": \"Mező formátuma\",\n        \"Lines\": \"Sorok\",\n        \"Multi line\": \"Többsoros\",\n        \"Single line\": \"Egysoros\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"Példa felhasználók\",\n        \"Users from table\": \"A táblázat felhasználói\",\n        \"View as\": \"Megtekintés mint\",\n        \"Other users from table\": \"A táblázat más felhasználói\",\n        \"Shared users\": \"Megosztott felhasználók\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Alkalmaz\",\n        \"Cancel\": \"Mégse\",\n        \"Preview\": \"Előnézet\",\n        \"Revise\": \"Felülvizsgál\",\n        \"Update formula (Shift+Enter)\": \"Képlet frissítése (Shift+Enter)\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"CELLA STÍLUSA\",\n        \"Cell style\": \"Cella stílusa\",\n        \"Default cell style\": \"Alapértelmezett cellastílus\",\n        \"Mixed style\": \"Kevert stílus\",\n        \"Open row styles\": \"Sor stílusának megnyitása\",\n        \"Default header style\": \"Alapértelmezett fejléc stílus\",\n        \"Header Style\": \"Fejléc stíluss\",\n        \"HEADER STYLE\": \"FEJLÉC STÍLUSA\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"VÁLASZTÁSOK\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"OSZLOP LEÍRÁSA\",\n        \"COLUMN LABEL\": \"OSZLOP CÍMKÉJE\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN DESCRIPTION\": \"OSZLOP LEÍRÁSA\",\n        \"COLUMN ID: \": \"OSZLOP AZONOSÍTÓ: \",\n        \"COLUMN LABEL\": \"OSZLOP CÍMKÉJE\",\n        \"Cancel\": \"Mégse\",\n        \"Save\": \"Mentés\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"Szabály hozzáadása\",\n        \"Add conditional style\": \"Feltételes stílus hozzáadása\",\n        \"Error in style rule\": \"Hiba a stílus-szabályban\",\n        \"Row style\": \"Sor stílusa\",\n        \"Rule must return True or False\": \"A szabálynak True (igaz) vagy False (hamis) értéket kell visszaadnia\",\n        \"Conditional Style\": \"Feltételes stílus\",\n        \"IF...\": \"HA...\",\n        \"Row Style\": \"Sor stílusa\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Hibás pénznem\"\n    },\n    \"DiscussionEditor\": {\n        \"{{count}} comments_one\": \"{{count}} megjegyzés\",\n        \"{{count}} comments_other\": \"{{count}} megjegyzés\",\n        \"Cancel\": \"Mégse\",\n        \"Comment\": \"Megjegyzés\",\n        \"Copy link\": \"Hivatkozás másolása\",\n        \"Edit\": \"Szerkesztés\",\n        \"Marked as resolved\": \"Megjelölés megoldottként\",\n        \"Only current page\": \"Csak a jelenlegi lap\",\n        \"Only my threads\": \"Csak az én szálaim\",\n        \"Open\": \"Megnyitás\",\n        \"Remove\": \"Eltávolítás\",\n        \"Reply\": \"Válaszol\",\n        \"Reply to a comment\": \"Válaszolás egy megjegyzésre\",\n        \"Resolve\": \"Megold\",\n        \"Save\": \"Mentés\",\n        \"Show resolved comments\": \"Mutasd a megoldott megjegyzéseket\",\n        \"Showing last {{nb}} comments\": \"Mutatom az utolsó {{nb}} megjegyzést\",\n        \"Started discussion\": \"Megbeszélés kezdődött\",\n        \"Write a comment\": \"Megjegyzés írása\",\n        \"Remove thread\": \"Szál eltávolítása\",\n        \"updated\": \"frissítve\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Oszlop átalakítása képletté\"\n    },\n    \"FieldBuilder\": {\n        \"Apply formula to data\": \"Képlet alkalmazása az adatokra\",\n        \"CELL FORMAT\": \"CELLA FORMÁTUMA\",\n        \"Changing multiple column types\": \"Több oszlop típusának változtatása\",\n        \"DATA FROM TABLE\": \"ADATOK A TÁBLÁZATBÓL\",\n        \"Mixed format\": \"Kevert formátum\",\n        \"Mixed types\": \"Kevert típus\",\n        \"Revert field settings for {{colId}} to common\": \"{{colId}} mezőbeállításainak visszaállítása az általánosra\",\n        \"Save field settings for {{colId}} as common\": \"{{colId}} mezőbeállításainak mentése általánosként\",\n        \"Use separate field settings for {{colId}}\": \"Egyedi mezdőbeállítások használata {{colId}} esetében\",\n        \"Changing column type\": \"Oszlop típusának megváltoztatása\",\n        \"Common\": \"Általános\",\n        \"Separate\": \"Egyedi\",\n        \"Field in {{count}} views_one\": \"Mező egy nézetben\",\n        \"Field in {{count}} views_other\": \"Mező {{count}} nézetben\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"Nem lehetséges adatot rögzíteni egy képlet-oszlopba\",\n        \"Unable to finish saving edited cell\": \"Nem tudtam menteni a cella módosításait\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Kötelező oszlop vagy mező\",\n        \"Error in the cell\": \"Hiba a cellában\",\n        \"Errors in all {{numErrors}} cells\": \"Hibák vannak {{numErrors}} cellában\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"{{numErrors}} hiba van {{numCells}} cellában\",\n        \"editingFormula is required\": \"Kötelező képletszerkesztő\",\n        \"Enter formula or {{button}}.\": \"Írd be a képletet vagy {{button}}.\",\n        \"Enter formula.\": \"Írd be a képletet.\",\n        \"Expand Editor\": \"Szerkesztő nagyobbítása\",\n        \"use AI Assistant\": \"MI-segéd használata\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[link label] URL\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"Pénznem\",\n        \"Decimals\": \"Szám\",\n        \"Default currency ({{defaultCurrency}})\": \"Alapértelmezett pénznem ({{defaultCurrency}})\",\n        \"Number Format\": \"Számformátum\",\n        \"Field Format\": \"Mezőformátum\",\n        \"Spinner\": \"Állítógomb\",\n        \"Text\": \"Szöveg\",\n        \"max\": \"max\",\n        \"min\": \"min\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"CELLA FORMÁTUMA\",\n        \"Row ID\": \"Sor ID\",\n        \"SHOW COLUMN\": \"OSZLOP MUTATÁSA\"\n    },\n    \"WelcomeTour\": {\n        \"Add new\": \"Új hozzáadása\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Fedezd fel a(z) {{templateLibrary}}, hogy inspirálódj és meglásd, mennyi minden lehetséges!\",\n        \"Building up\": \"Összeállítás\",\n        \"Configuring your document\": \"A dokumentumod beállítása\",\n        \"Customizing columns\": \"Oszlopok testreszabása\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Kattints duplán vagy nyomd meg az {{enter}} billentyűt egy cellán a szerkesztéshez! \",\n        \"Editing Data\": \"Adat szerkesztése\",\n        \"Enter\": \"Enter\",\n        \"Flying higher\": \"Szállj feljebb\",\n        \"Help Center\": \"Súgóközpont\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Hozz létre kapcsolatokat! Használd a {{ref}} típust táblázatok összekapcsolására. \",\n        \"Reference\": \"Hivatkozás\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Állíts be formázást, képleteket vagy oszloptípusokat, például dátumokat, választási lehetőségeket, vagy csatolmányokat. \",\n        \"Share\": \"Megosztás\",\n        \"Sharing\": \"Megosztás\",\n        \"Start with {{equal}} to enter a formula.\": \"Kezdd {{equal}} jellel a képlet beírását.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Kapcsold ki-be a {{creatorPanel}}-t az oszlopok formázásához, \",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Használt a Megosztás gombot ({{share}}) a dokumentumok megosztásához vagy az adatok exportálásához.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Használd az {{addNew}}-t widgetek vagy oldalak hozzáadásához, vagy további adatok importálásához. \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"A {{helpCenter}}ban találod a dokumentációt és kérdéseket.\",\n        \"Welcome to Grist!\": \"Üdvözöl a Grist!\",\n        \"convert to card view, select data, and more.\": \"átalakítás kártya nézetre, adatok kiválasztása és egyebek.\",\n        \"creator panel\": \"alkotó panel\",\n        \"template library\": \"sablonkönyvtár\",\n        \"AI Assistant\": \"MI-segéd\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Nyelv\"\n    },\n    \"GristTooltips\": {\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Feltételes formázás alkalmazása az ebben az oszlopban lévő olyan cellákra, amelyeknél a képletben leírt feltételek fennállnak.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Feltételes formázás alkalmazása sorokra képletek alapján.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/hu.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Kérem ellenőrizze az e-mail címet az azonosítási szolgáltatónál, és jelentkezzen be újra.\"\n    },\n    \"sendAppPage\": {\n        \"og-description\": \"Egy modern, nyílt forrású táblazatkezelő, ami több, mint egy táblázat\",\n        \"Loading...\": \"Betöltés...\",\n        \"og-title\": \"Grist, a táblázatkezelők evolúciója\"\n    },\n    \"access\": {\n        \"docDisabled\": \"Letiltott dokumentum.\",\n        \"docNoAccess\": \"Ehhez a dokumentumhoz nincs hozzáférése.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"Nem található tulajdonos a `GRIST_INSTALL_ADMIN_ORG={{org}}` által meghatározott admin szervezetben\",\n        \"orgUser\": \"A felhasználó tulajdonos a `GRIST_INSTALL_ADMIN_ORG={{org}}` által meghatározott admin szervezetben\",\n        \"accountByEmail\": \"Admin fiók a `GRIST_DEFAULT_EMAIL={{defaultEmail}}` által beállítva\",\n        \"noAdminEmail\": \"Az admin fiók hiányzik, mert a `GRIST_ADMIN_EMAIL` és a `GRIST_DEFAULT_EMAIL` nem lett beállítva\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Névtelen dokumentum\"\n    }\n}\n"
  },
  {
    "path": "static/locales/id.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Enter email address\": \"Masukkan alamat email\",\n        \"Invite new member\": \"Undang anggota baru\",\n        \"We'll email an invite to {{email}}\": \"Kami akan kirim undangan melalui email ke {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Add column rule\": \"Tambah aturan kolom\",\n        \"Add Default Rule\": \"Tambah Aturan Bawaan\",\n        \"Add table rules\": \"Tambah aturan tabel\",\n        \"Add user attributes\": \"Tambah atribut pengguna\",\n        \"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.\": \"Izinkan semua orang menyalin seluruh dokumen, atau melihatnya secara penuh dalam mode \\\"fiddle\\\".\\nBerguna untuk contoh dan templat, tetapi tidak untuk data sensitif.\",\n        \"Allow everyone to view Access Rules.\": \"Izinkan semua orang melihat Aturan Akses.\",\n        \"Attribute name\": \"Nama atribut\",\n        \"Attribute to Look Up\": \"Atribut untuk Mencari\",\n        \"Checking...\": \"Memeriksa…\",\n        \"Condition\": \"Kondisi\",\n        \"Default rules\": \"Aturan bawaan\",\n        \"Delete table rules\": \"Hapus aturan tabel\",\n        \"Enter Condition\": \"Masukkan Kondisi\",\n        \"Everyone\": \"Setiap orang\",\n        \"Everyone Else\": \"Semua orang lain\",\n        \"Invalid\": \"Tidak sah\",\n        \"Lookup Column\": \"Kolom Pencarian\",\n        \"Lookup Table\": \"Tabel Pencarian\",\n        \"Permission to access the document in full when needed\": \"Izin untuk mengakses dokumen secara penuh bila diperlukan\",\n        \"Permission to view Access Rules\": \"Izin untuk melihat Aturan Akses\",\n        \"Permissions\": \"Izin\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Hapus kolom {{- colId }} dari aturan {{- tableId }}\",\n        \"Remove {{- tableId }} rules\": \"Hapus aturan {{-tableId }}\",\n        \"Remove {{- name }} user attribute\": \"Hapus atribut pengguna {{- name }}\",\n        \"Reset\": \"Atur ulang\",\n        \"Rules for table \": \"Aturan untuk tabel \",\n        \"Save\": \"Simpan\",\n        \"Saved\": \"Tersimpan\",\n        \"Special rules\": \"Aturan khusus\",\n        \"Type message to display when this rule blocks an action…\": \"Ketik pesan yang akan ditampilkan saat aturan ini memblokir suatu tindakan…\",\n        \"User Attributes\": \"Atribut Pengguna\",\n        \"View as\": \"Lihat sebagai\",\n        \"Seed rules\": \"Aturan benih\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Saat menambahkan aturan tabel, tambahkan aturan secara otomatis untuk memberikan akses penuh kepada PEMILIK.\",\n        \"Permission to edit document structure\": \"Izin untuk mengedit struktur dokumen\",\n        \"This default should be changed if editors' access is to be limited. \": \"Default ini harus diubah jika akses editor ingin dibatasi. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Izinkan editor untuk mengedit struktur (misalnya, mengubah dan menghapus tabel, kolom, dan tata letak) serta menulis rumus. Apa pun izin yang ditetapkan di tingkat tabel dan kolom, rumus tetap dapat diedit dan dapat mengakses semua data.\",\n        \"Add table-wide rule\": \"Tambahkan aturan seluruh tabel\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Aturan akses telah berubah. Klik Atur Ulang untuk mengembalikan perubahan dan menyegarkan aturan.\",\n        \"All\": \"Semua\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"Kolom {{colId}} muncul di beberapa aturan untuk tabel {{tableId}} yang mungkin bergantung pada urutan. Coba pisahkan aturan secara berbeda?\",\n        \"Columns\": \"Kolom\",\n        \"Condition cannot be blank\": \"Kondisi tidak boleh kosong\",\n        \"Default resource missing in resource map\": \"Sumber daya default hilang di peta sumber daya\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Kolom tidak valid dalam tabel {{tableId}}: {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"Tabel tidak valid: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Aturan atribut pengguna tidak valid: {{prop}} harus disetel\",\n        \"Invalid user attribute to look up\": \"Atribut pengguna tidak valid untuk dicari\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"Tidak ada kolom yang tercantum dalam aturan kolom untuk tabel {{tableId}}\",\n        \"Not a valid user attribute\": \"Bukan atribut pengguna yang valid\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Sumber daya hilang di peta sumber daya: {{resourceKey}}\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"Mencoba menambahkan TableRules untuk tabel {{tableId}} yang ada\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"Gunakan atribut sederhana user.LinkKey, misalnya user.LinkKey.something\",\n        \"hidden\": \"tersembunyi\",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Aturan Akses\\n\\nAkses dasar ke dokumen ini dikontrol menggunakan opsi 'Kelola Pengguna' di menu 'Bagikan', di mana Anda dapat menetapkan peran kolaborator seperti Pemilik, Editor, atau Penampil.\\n\\nUntuk kontrol yang lebih rinci, Anda dapat membuat Aturan Akses untuk membatasi siapa yang dapat melihat atau mengedit\\ntabel, kolom, atau baris tertentu — berguna untuk data sensitif atau izin berbasis peran.\\n[Pelajari lebih lanjut.]({{helpAccessRules}})\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Aturan Akses\\n\\nAnda tidak memiliki izin untuk melihat atau mengedit aturan akses untuk dokumen ini.\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Aturan khusus** (perluas setiap aturan untuk menyesuaikan berlaku untuk siapa)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"Setelah menonaktifkan Aturan Akses, Editor akan dapat mengubah struktur dokumen dan mengedit rumus. Editor dan Penampil akan dapat melihat semua data dalam dokumen, serta menyalin atau mengunduhnya.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"Setelah mengaktifkan Aturan Akses, Editor tidak akan lagi dapat mengubah struktur\\ndokumen atau mengedit rumus. Hanya Pemilik yang dapat menyalin atau mengunduh dokumen.\\n\\nPengaturan ini dapat diubah di bawah 'Aturan khusus'.\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"Izinkan Editor untuk mengedit struktur (misalnya, memodifikasi dan menghapus tabel, kolom, dan tata letak) serta menulis rumus.  Penting: jika dicentang, Editor akan dapat mengedit rumus, yang dapat mengakses semua data, terlepas dari aturan akses tabel dan kolom!\",\n        \"Allow everyone to view access rules.\": \"Izinkan semua orang untuk melihat aturan akses.\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Lewati semua pembatasan baca dan izinkan semua orang untuk menyalin seluruh dokumen, atau melihatnya secara penuh dalam mode fiddle. Gunakan hanya untuk contoh dan templat, bukan untuk dokumen yang berisi data sensitif.\",\n        \"Continue\": \"Lanjut\",\n        \"Disable Access Rules\": \"Nonaktifkan Aturan Akses\",\n        \"Disable and save\": \"Nonaktifkan dan simpan\",\n        \"Enable Access Rules\": \"Aktifkan Aturan Akses\",\n        \"Permission to access the document in full by all users\": \"Semua pengguna diberikan izin untuk mengakses dokumen secara penuh\",\n        \"Permission to access the document in full by unrestricted users\": \"Akses izin dokumen secara penuh untuk pengguna tidak dibatasi\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Batasi akses pengguna selain pemilik untuk menyalin atau mengunduh dokumen lengkap. Catatan: ini hanya memengaruhi pengguna tanpa batasan baca, karena pengguna lain akan tetap dibatasi terlepas dari pengaturan ini.\",\n        \"Special rules for templates\": \"Aturan khusus untuk templat\",\n        \"This options should be off if Editors' access is to be limited. \": \"Opsi ini harus dimatikan jika akses editor ingin dibatasi. \"\n    },\n    \"CellContextMenu\": {\n        \"Delete {{count}} columns_other\": \"Hapus {{count}} kolom\",\n        \"Delete {{count}} rows_one\": \"Hapus baris\",\n        \"Duplicate rows_other\": \"Duplikat baris\",\n        \"Filter by this value\": \"Filter berdasarkan nilai ini\",\n        \"Insert column to the left\": \"Sisipkan kolom di sebelah kiri\",\n        \"Insert column to the right\": \"Sisipkan kolom di sebelah kanan\",\n        \"Insert row\": \"Sisipkan baris\",\n        \"Insert row above\": \"Sisipkan baris di atas\",\n        \"Insert row below\": \"Sisipkan baris di bawah\",\n        \"Reset {{count}} columns_one\": \"Atur ulang kolom\",\n        \"Reset {{count}} columns_other\": \"Atur ulang {{count}} kolom\",\n        \"Reset {{count}} entire columns_one\": \"Atur ulang seluruh kolom\",\n        \"Reset {{count}} entire columns_other\": \"Atur ulang {{count}} seluruh kolom\",\n        \"Comment\": \"Komentar\",\n        \"Copy\": \"Salin\",\n        \"Cut\": \"Potong\",\n        \"Paste\": \"Tempel\",\n        \"Copy with headers\": \"Salin dengan header\",\n        \"Clear cell\": \"Bersihkan sel\",\n        \"Clear values\": \"Bersihkan nilai\",\n        \"Copy anchor link\": \"Salin tautan jangkar\",\n        \"Delete {{count}} columns_one\": \"Hapus kolom\",\n        \"Delete {{count}} rows_other\": \"Hapus {{count}} baris\",\n        \"Duplicate rows_one\": \"Duplikat baris\"\n    },\n    \"ChartView\": {\n        \"LABEL\": \"LABEL\",\n        \"Bar chart\": \"Diagram batang\",\n        \"Pie chart\": \"Diagram lingkaran\",\n        \"Donut chart\": \"Diagram donat\",\n        \"Area chart\": \"Diagram area\",\n        \"Line chart\": \"Diagram garis\",\n        \"Scatter plot\": \"Diagram sebar\",\n        \"Kaplan-Meier plot\": \"Plot Kaplan-Meier\",\n        \"Split series\": \"Seri terpisah\",\n        \"Invert Y-axis\": \"Sumbu-Y terbalik\",\n        \"Orientation\": \"Orientasi\",\n        \"Vertical\": \"Vertikal\",\n        \"Horizontal\": \"Horisontal\",\n        \"Log scale Y-axis\": \"Skala log Sumbu-Y\",\n        \"Hole size\": \"Ukuran lubang\",\n        \"Show total\": \"Tampilkan total\",\n        \"Text size\": \"Ukuran Teks\",\n        \"Connect gaps\": \"Hubungkan celah\",\n        \"Show markers\": \"Tampilkan penanda\",\n        \"Stack series\": \"Seri tumpukan\",\n        \"Error bars\": \"Batang kesalahan\",\n        \"None\": \"Tidak ada\",\n        \"Symmetric\": \"Simetris\",\n        \"Above+Below\": \"Atas+Bawah\",\n        \"Split Series\": \"Seri Terpisah\",\n        \"X-AXIS\": \"SUMBU-X\",\n        \"Pick a column\": \"Pilih kolom\",\n        \"Aggregate values\": \"Nilai agregat\",\n        \"SERIES\": \"SERI\",\n        \"Add series\": \"Tambah seri\",\n        \"non-numeric columns are not shown\": \"kolom non-numerik tidak ditampilkan\",\n        \"non-numeric column is not shown\": \"kolom non-numerik tidak ditampilkan\",\n        \"selected new x-axis\": \"sumbu-x baru yang dipilih\",\n        \"Remove\": \"Hapus\",\n        \"Create separate series for each value of the selected column.\": \"Buat seri terpisah untuk setiap nilai kolom yang dipilih.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Setiap seri Y diikuti oleh seri lainnya untuk panjang batang kesalahan.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Setiap seri Y diikuti oleh dua seri, untuk batang kesalahan atas dan bawah.\",\n        \"Toggle chart aggregation\": \"Beralih agregasi diagram\",\n        \"selected new group data columns\": \"kolom data grup baru yang dipilih\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Akses ditolak\",\n        \"Code View is available only when you have full document access.\": \"Tampilan Kode hanya tersedia bila Anda memiliki akses dokumen penuh.\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Terapkan\",\n        \"Cancel\": \"Batalkan\",\n        \"Default cell style\": \"Gaya sel default\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"Semua\",\n        \"All except\": \"Semua kecuali\",\n        \"All shown\": \"Semua ditampilkan\",\n        \"Filter by Range\": \"Filter berdasar Rentang\",\n        \"Future values\": \"Nilai ke depan\",\n        \"No matching values\": \"Tidak ada nilai yang cocok\",\n        \"None\": \"Tidak ada\",\n        \"Min\": \"Min\",\n        \"Max\": \"Maks\",\n        \"Start\": \"Mulai\",\n        \"End\": \"Ahir\",\n        \"Other Matching\": \"Pencocokan Lainnya\",\n        \"Other Non-Matching\": \"Lainnya yang Tidak Cocok\",\n        \"Other values\": \"Nilai-nilai lainnya\",\n        \"Others\": \"Yang lain\",\n        \"Search\": \"Cari\",\n        \"Search values\": \"Cari nilai\",\n        \"Clear search\": \"Bersihkan pencarian\",\n        \"Pin filter\": \"Sematkan filter\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Urutkan berdasarkan abjad (saat ini: diurutkan berdasarkan jumlah kemunculan)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Urutkan berdasarkan jumlah kemunculan (saat ini: diurutkan berdasarkan abjad)\",\n        \"Unpin filter\": \"Lepas sematan filter\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (opsional)\",\n        \"Add\": \"Tmbahkan\",\n        \"Enter Custom URL\": \"Masukkan URL Kustom\",\n        \"Full document access\": \"Akses dokumen penuh\",\n        \"Learn more about custom widgets\": \"Pelajari lebih lanjut tentang widget khusus\",\n        \"No document access\": \"Tidak ada akses dokumen\",\n        \"Open configuration\": \"Konfigurasi terbuka\",\n        \"Pick a column\": \"Pilih kolom\",\n        \"Pick a {{columnType}} column\": \"Pilih kolom {{columnType}}\",\n        \"Read selected table\": \"Baca tabel yang dipilih\",\n        \"Select Custom Widget\": \"Pilih Widget Kustom\",\n        \"Widget does not require any permissions.\": \"Widget tidak memerlukan izin apa pun.\",\n        \"Widget needs to {{read}} the current table.\": \"Widget perlu {{read}} tabel saat ini.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Widget memerlukan {{fullAccess}} ke dokumen ini.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"Kolom {{wrongTypeCount}} yang bukan {{columnType}} tidak ditampilkan\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"Kolom {{wrongTypeCount}} yang bukan {{columnType}} tidak ditampilkan\",\n        \"Clear selection\": \"Bersihkan pilihan\",\n        \"No {{columnType}} columns in table.\": \"Tidak ada kolom {{columnType}} dalam tabel.\",\n        \"ACCESS LEVEL\": \"TINGKAT AKSES\",\n        \"Accept\": \"Setuju\",\n        \"Custom URL\": \"URL khusus\",\n        \"Developer:\": \"Pengembang:\",\n        \"Last updated:\": \"Terakhir diperbarui:\",\n        \"Missing description and author information.\": \"Deskripsi dan informasi penulis hilang.\",\n        \"Reject\": \"Tolak\",\n        \"Widget\": \"Widget\",\n        \"Change custom widget\": \"Ubah widget khusus\"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"Kunci API\",\n        \"Account settings\": \"Pengaturan akun\",\n        \"Allow signing in to this account with Google\": \"Izinkan masuk ke akun ini dengan Google\",\n        \"Change password\": \"Ubah kata sandi\",\n        \"Edit\": \"Sunting\",\n        \"Email\": \"Email\",\n        \"Login method\": \"Metode masuk\",\n        \"Name\": \"Nama\",\n        \"Names only allow letters, numbers and certain special characters\": \"Nama hanya memperbolehkan huruf, angka, dan karakter khusus tertentu\",\n        \"Password & security\": \"Kata sandi & keamanan\",\n        \"Save\": \"Simpan\",\n        \"Theme\": \"Tema\",\n        \"Two-factor authentication\": \"Otentikasi dua-faktor\",\n        \"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.\": \"Autentikasi dua faktor adalah lapisan keamanan ekstra untuk akun Grist Anda yang dirancang untuk memastikan bahwa Anda adalah satu-satunya orang yang dapat mengakses akun Anda, bahkan jika seseorang mengetahui kata sandi Anda.\",\n        \"Language\": \"Bahasa\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Detail Akses\",\n        \"Accounts\": \"Akun\",\n        \"Add account\": \"Tambah akun\",\n        \"Document settings\": \"Pengaturan dokumen\",\n        \"Manage team\": \"Kelola tim\",\n        \"Pricing\": \"Harga\",\n        \"Profile settings\": \"Pengaturan profil\",\n        \"Sign out\": \"Keluar\",\n        \"Sign in\": \"Masuk\",\n        \"Switch Accounts\": \"Ganti Akun\",\n        \"Toggle Mobile Mode\": \"Alihkan Mode Seluler\",\n        \"Activation\": \"Pengaktifan\",\n        \"Billing account\": \"Akun penagihan\",\n        \"Support Grist\": \"Dukung Grist\",\n        \"Upgrade Plan\": \"Rencana Peningkatan\",\n        \"Sign up\": \"Mendaftar\",\n        \"Use This Template\": \"Gunakan Templat Ini\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Lihat sebagai\",\n        \"Users from table\": \"Pengguna dari tabel\",\n        \"Example Users\": \"Contoh Pengguna\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"Log Tindakan gagal dimuat\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Kolom {{colId}} kemudian dihapus dalam tindakan #{{action.actionNum}}\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Tabel {{tableId}} kemudian dihapus dalam tindakan #{{actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Baris ini kemudian dihapus dalam tindakan {{action.actionNum}}\",\n        \"All tables\": \"Semua tabel\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"Kolom {{colId}} kemudian dihapus dalam tindakan #{{actionNum}}\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Baris ini kemudian dihapus dalam tindakan {{actionNum}}\",\n        \"History blocked because of access rules.\": \"Riwayat diblokir karena aturan akses.\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Tambah baru\"\n    },\n    \"ApiKey\": {\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Dengan membuat kunci API, Anda akan dapat membuat panggilan API untuk akun Anda sendiri.\",\n        \"Click to show\": \"Klik untuk menampilkan\",\n        \"Create\": \"Buat\",\n        \"Remove\": \"Hapus\",\n        \"Remove API Key\": \"Hapus Kunci API\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Kunci API ini dapat digunakan untuk mengakses akun ini secara anonim melalui API.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Kunci API ini dapat digunakan untuk mengakses akun Anda melalui API. Jangan bagikan kunci API Anda dengan siapa pun.\",\n        \"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?\": \"Anda akan menghapus kunci API. Ini akan menyebabkan semua permintaan mendatang yang menggunakan kunci API ini ditolak. Masih ingin menghapus?\"\n    },\n    \"App\": {\n        \"Description\": \"Keterangan\",\n        \"Key\": \"Kunci\",\n        \"Memory Error\": \"Kesalahan Memori\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Penerjemah: mohon terjemahkan ini hanya ketika bahasa Anda siap ditawarkan kepada pengguna\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Halaman beranda\",\n        \"Legacy\": \"Warisan\",\n        \"Personal Site\": \"Situs Pribadi\",\n        \"Team Site\": \"Situs Tim\",\n        \"Grist Templates\": \"Templat Grist\",\n        \"Billing account\": \"Akun penagihan\",\n        \"Manage team\": \"Kelola tim\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Kembali ke beranda\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Situs tim ini ditangguhkan. Dokumen dapat dibaca, tetapi tidak dapat diubah.\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Klik untuk menyalin\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Hapus data {{formattedTableName}}, dan hapus dari semua halaman?\",\n        \"Duplicate table\": \"Duplikat tabel\",\n        \"Raw Data Tables\": \"Tabel Data Mentah\",\n        \"Table ID copied to clipboard\": \"ID Tabel disalin ke papan klip\",\n        \"You do not have edit access to this document\": \"Anda tidak memiliki akses edit ke dokumen ini\",\n        \"Edit record card\": \"Edit kartu rekaman\",\n        \"Record Card\": \"Kartu Catatan\",\n        \"Record Card Disabled\": \"Kartu Catatan Disabel\",\n        \"Remove table\": \"Hapus tabel\",\n        \"Rename table\": \"Ganti nama tabel\",\n        \"{{action}} Record Card\": \"Kartu Catatan {{action}}\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Aktivitas\",\n        \"Beta\": \"Beta\",\n        \"Compare to current\": \"Bandingkan dengan saat ini\",\n        \"Compare to previous\": \"Bandingkan dengan sebelumnya\",\n        \"Open snapshot\": \"Buka snapshot\",\n        \"Snapshots\": \"Snapshot\",\n        \"Snapshots are unavailable.\": \"Snapshot tidak tersedia.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Hanya pemilik yang memiliki akses ke snapshot dokumen dengan aturan akses.\"\n    },\n    \"DocMenu\": {\n        \"(The organization needs a paid plan)\": \"(Organisasi membutuhkan paket berbayar)\",\n        \"Access Details\": \"Detail Akses\",\n        \"All documents\": \"Semua dokumen\",\n        \"By Date Modified\": \"Berdasar Tanggal Dimodifikasi\",\n        \"By Name\": \"Berdasar Nama\",\n        \"Current workspace\": \"Ruang kerja saat ini\",\n        \"Delete\": \"Hapus\",\n        \"Delete Forever\": \"Hapus Selamanya\",\n        \"Delete {{name}}\": \"Hapus {{name}}\",\n        \"Deleted {{at}}\": \"Dihapus {{at}}\",\n        \"Discover More Templates\": \"Temukan Lebih Banyak Templat\",\n        \"Document will be moved to Trash.\": \"Dokumen akan dipindahkan ke Sampah.\",\n        \"Document will be permanently deleted.\": \"Dokumen akan dihapus secara permanen.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Dokumen tetap berada di Sampah selama 30 hari, setelah itu akan dihapus secara permanen.\",\n        \"Edited {{at}}\": \"Diedit {{at}}\",\n        \"Examples & Templates\": \"Contoh & Templat\",\n        \"Examples and Templates\": \"Contoh dan Templat\",\n        \"Featured\": \"Unggulan\",\n        \"Grid view\": \"Tampilan kisi\",\n        \"List view\": \"Tampilan daftar\",\n        \"Manage users\": \"Kelola pengguna\",\n        \"More Examples and Templates\": \"Lebih Banyak Contoh dan Templat\",\n        \"Move\": \"Pindah\",\n        \"Move {{name}} to workspace\": \"Pindah {{name}} ke ruang kerja\",\n        \"Other Sites\": \"Situs Lain\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Hapus \\\"{{name}}\\\" secara permanen?\",\n        \"Pin Document\": \"Sematkan Dokumen\",\n        \"Pinned Documents\": \"Dokumen Tersemat\",\n        \"Remove\": \"Hapus\",\n        \"Rename\": \"Ganti nama\",\n        \"Requires edit permissions\": \"Memerlukan izin edit\",\n        \"Restore\": \"Pulihkan\",\n        \"This service is not available right now\": \"Layanan ini tidak tersedia saat ini\",\n        \"To restore this document, restore the workspace first.\": \"Untuk memulihkan dokumen ini, pulihkan ruang kerja terlebih dahulu.\",\n        \"Trash\": \"Sampah\",\n        \"Trash is empty.\": \"Sampah kosong.\",\n        \"Unpin Document\": \"Lepas Sematan Dokumen\",\n        \"Workspace not found\": \"Ruang kerja tidak ditemukan\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Anda berada di situs {{siteName}}. Anda juga memiliki akses ke situs-situs berikut:\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Anda berada di situs pribadi Anda. Anda juga memiliki akses ke situs-situs berikut:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Anda dapat menghapus ruang kerja selamanya jika tidak ada dokumen di dalamnya.\",\n        \"Any documents created in this site will appear here.\": \"Dokumen apa pun yang dibuat di situs ini akan muncul di sini.\",\n        \"Create my first document\": \"Buat dokumen pertama saya\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Anda memiliki akses baca-saja ke situs ini. Saat ini tidak ada dokumen.\",\n        \"personal site\": \"situs pribadi\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Tambah tabel kosong\",\n        \"Add page\": \"Tambah halaman\",\n        \"Add widget to page\": \"Tambah widget ke halaman\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Pemilik dokumen dapat mencoba memulihkan dokumen tersebut. [{{error}}]\",\n        \"Enter recovery mode\": \"Masuk ke mode pemulihan\",\n        \"Error accessing document\": \"Terjadi kesalahan saat mengakses dokumen\",\n        \"Reload\": \"Muat ulang\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Maaf, akses ke dokumen ini telah ditolak. [{{error}}]\",\n        \"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}}]\": \"Anda dapat mencoba memuat ulang dokumen, atau menggunakan mode pemulihan. Mode pemulihan akan membuka dokumen agar sepenuhnya dapat diakses oleh pemiliknya, dan tidak dapat diakses oleh orang lain. Mode ini juga menonaktifkan rumus. [{{error}}]\",\n        \"You do not have edit access to this document\": \"Anda tidak memiliki akses edit ke dokumen ini\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Silakan muat ulang dokumen dan jika kesalahan berlanjut, hubungi pemilik dokumen untuk mencoba pemulihan dokumen. [{{error}}]\"\n    },\n    \"DocTour\": {\n        \"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.\": \"Tidak dapat membuat tur dokumen dari data dalam dokumen ini. Pastikan ada tabel bernama GristDocTour dengan kolom Judul, Isi, Penempatan, dan Lokasi.\",\n        \"No valid document tour\": \"Tidak ada tur dokumen yang valid\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Mata uang:\",\n        \"Document settings\": \"Pengaturan dokumen\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Mesin (perubahan eksperimental {{span}} dengan risiko sendiri):\",\n        \"Local currency ({{currency}})\": \"Mata uang lokal ({{currency}})\",\n        \"Locale:\": \"Lokal:\",\n        \"Save\": \"Simpan\",\n        \"Save and Reload\": \"Simpan dan Muat Ulang\",\n        \"This document's ID (for API use):\": \"ID dokumen ini (untuk penggunaan API):\",\n        \"Time Zone:\": \"Zona Waktu:\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"ID dokumen disalin ke papan klip\",\n        \"Ok\": \"OK\",\n        \"Manage Webhooks\": \"Kelola Webhook\",\n        \"Webhooks\": \"Webhook\",\n        \"API console\": \"Konsol API\",\n        \"API URL copied to clipboard\": \"URL API disalin ke papan klip\",\n        \"API documentation.\": \"Dokumentasi API.\",\n        \"Base doc URL: {{docApiUrl}}\": \"URL dokumen dasar: {{docApiUrl}}\",\n        \"Coming soon\": \"Segera hadir\",\n        \"Copy to clipboard\": \"Salin ke papan klip\",\n        \"Currency\": \"Mata uang\",\n        \"Data engine\": \"Mesin data\",\n        \"Default for DateTime columns\": \"Default untuk kolom DateTime\",\n        \"Document ID\": \"ID Dokumen\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"ID Dokumen yang akan digunakan setiap kali REST API memanggil {{docId}}. Lihat {{apiURL}}\",\n        \"Find slow formulas\": \"Temukan rumus lambat\",\n        \"For currency columns\": \"Untuk kolom mata uang\",\n        \"For number and date formats\": \"Untuk format angka dan tanggal\",\n        \"Formula times\": \"Waktu rumus\",\n        \"Hard reset of data engine\": \"Atur ulang paksa mesin data\",\n        \"ID for API use\": \"ID untuk API\",\n        \"Locale\": \"Lokal\",\n        \"Manage webhooks\": \"Kelola webhook\",\n        \"Notify other services on doc changes\": \"Beritahu layanan lain tentang perubahan dokumen\",\n        \"Python\": \"Python\",\n        \"Python version used\": \"Versi Python yang digunakan\",\n        \"Reload\": \"Muat ulang\",\n        \"Time zone\": \"Zona waktu\",\n        \"Try API calls from the browser\": \"Coba panggilan API dari browser\",\n        \"python2 (legacy)\": \"python2 (warisan)\",\n        \"python3 (recommended)\": \"python3 (disarankan)\",\n        \"Cancel\": \"Batal\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Paksa muat ulang dokumen saat menghitung rumus, dan tampilkan hasilnya.\",\n        \"Formula timer\": \"Pengatur waktu rumus\",\n        \"Reload data engine\": \"Muat ulang mesin data\",\n        \"Reload data engine?\": \"Muat ulang mesin data?\",\n        \"Start timing\": \"Mulai penghitung waktu\",\n        \"Stop timing...\": \"Hentikan penghitung waktu...\",\n        \"Time reload\": \"Muat ulang penghitung waktu\",\n        \"Timing is on\": \"Penghitung waktu hidup\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Anda dapat membuat perubahan pada dokumen, lalu menghentikan penghitungan waktu untuk melihat hasilnya.\",\n        \"Only available to document editors\": \"Hanya tersedia untuk editor dokumen\",\n        \"Only available to document owners\": \"Hanya tersedia untuk pemilik dokumen\",\n        \"Template mode\": \"Mode templat\",\n        \"Change document type\": \"Ubah jenis dokumen\",\n        \"Edit\": \"Sunting\",\n        \"Change nature of document\": \"Mengubah sifat dokumen\",\n        \"Regular document\": \"Dokumen biasa\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Perilaku dokumen normal. Semua pengguna bekerja pada salinan dokumen yang sama.\",\n        \"Regular\": \"Biasa\",\n        \"Template\": \"Templat\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Dokumen otomatis terbuka di {{fiddleModeDocUrl}}. Siapa pun dapat mengeditnya, yang akan membuat salinan baru yang belum disimpan.\",\n        \"fiddle mode\": \"mode fiddle\",\n        \"Tutorial\": \"Tutorial\",\n        \"Document automatically opens as a user-specific copy.\": \"Dokumen secara otomatis terbuka sebagai salinan khusus pengguna.\",\n        \"Confirm change\": \"Konfirmasi perubahan\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Ini akan melakukan pemuatan ulang paksa pada mesin data. Ini dapat membantu jika mesin data terjebak dalam putaran tak terbatas, memproses perubahan terbaru tanpa henti, atau mengalami kerusakan. Tidak ada data yang akan hilang, kecuali kemungkinan tindakan yang sedang tertunda.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Setelah Anda mulai menghitung waktu, Grist akan mengukur waktu yang dibutuhkan untuk mengevaluasi setiap rumus. Hal ini memungkinkan Anda mendiagnosis rumus mana yang menyebabkan kinerja lambat saat dokumen pertama kali dibuka, atau saat dokumen merespons perubahan.\",\n        \"**Some existing attachments are still external**.\": \"**Beberapa lampiran yang ada masih bersifat eksternal**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Beberapa lampiran yang ada masih internal** (disimpan dalam file SQLite).\",\n        \"Attachment storage\": \"Penyimpanan lampiran\",\n        \"Being transfer\": \"Sedang transfer\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Klik \\\"Mulai transfer\\\" untuk mentransfernya ke penyimpanan Eksternal.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Klik \\\"Mulai transfer\\\" untuk mentransfernya ke penyimpanan internal (disimpan dalam file SQLite dokumen).\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Lampiran yang baru diunggah akan ditempatkan di penyimpanan Eksternal.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Lampiran yang baru diunggah akan ditempatkan di penyimpanan internal.\",\n        \"No external stores available\": \"Tidak ada toko eksternal yang tersedia\",\n        \"Preferred storage for this document\": \"Penyimpanan pilihan untuk dokumen ini\",\n        \"Start transfer\": \"Mulai transfer\",\n        \"External\": \"Eksternal\",\n        \"Internal\": \"Internal\",\n        \"Transfer in progress\": \"Transfer sedang berlangsung\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Beberapa lampiran yang ada masih [eksternal]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Beberapa lampiran yang ada masih [internal]({{internalLink}})** (disimpan dalam file SQLite).\",\n        \"[Learn more.]({{learnLink}})\": \"[Pelajari lebih lanjut.]({{learnLink}})\",\n        \"Upload\": \"Unggah\",\n        \"Upload missing attachments\": \"Unggah lampiran yang hilang\",\n        \"Uploading...\": \"Mengunggah...\",\n        \"Default\": \"Bawaan\",\n        \"Default, template, or tutorial\": \"Default, templat, atau tutorial\",\n        \"Document type\": \"Jenis dokumen\",\n        \"Allow others to suggest changes\": \"Izinkan orang lain menyarankan perubahan\",\n        \"Enable suggestions\": \"Aktifkan saran\",\n        \"Suggestions\": \"Saran\",\n        \"experiment\": \"percobaan\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"Ukuran lampiran\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Hubungi pemilik situs untuk meningkatkan paket untuk menaikkan batasan.\",\n        \"Data size\": \"Ukuran data\",\n        \"For higher limits, \": \"Untuk batas yang lebih tinggi, \",\n        \"Rows\": \"Baris\",\n        \"Usage\": \"Penggunaan\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"Statistik penggunaan hanya tersedia bagi pengguna dengan akses penuh ke data dokumen.\",\n        \"start your 30-day free trial of the Pro plan.\": \"mulai uji coba gratis 30-hari untuk paket Pro.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Pulihkan suntingan terakhir\",\n        \"Undo discard\": \"Batalkan pembuangan\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Salin semua data selain struktur tabel.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Daripada menduplikasi tabel, biasanya lebih baik mengelompokkan data menggunakan tampilan tertaut. {{link}}\",\n        \"Name for new table\": \"Nama untuk tabel baru\",\n        \"Only the document default access rules will apply to the copy.\": \"Hanya aturan akses default dokumen yang akan berlaku untuk salinannya.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Program Setelah Sekolah\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Lihat tutorial terkait kami tentang cara menautkan data, dan membuat tata letak dengan produktivitas tinggi.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Lihat tutorial terkait kami tentang cara memodelkan data bisnis, menggunakan rumus, dan mengelola kompleksitas.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Lihat tutorial terkait kami untuk mempelajari cara membuat tabel dan diagram secara ringkas, dan menautkan diagram secara dinamis.\",\n        \"Investment Research\": \"Riset Investasi\",\n        \"Lightweight CRM\": \"CRM Ringan\",\n        \"Tutorial: Analyze & Visualize\": \"Tutorial: Menganalisis dan Memvisualisasikan\",\n        \"Tutorial: Create a CRM\": \"Tutorial: Membuat CRM\",\n        \"Tutorial: Manage Business Data\": \"Tutorial: Mengelola Data Bisnis\",\n        \"Welcome to the Afterschool Program template\": \"Selamat datang di templat Program Sepulang Sekolah\",\n        \"Welcome to the Investment Research template\": \"Selamat datang di templat Riset Investasi\",\n        \"Welcome to the Lightweight CRM template\": \"Selamat datang di templat CRM Ringan\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"PERILAKU KOLOM\",\n        \"COLUMN LABEL AND ID\": \"LABEL DAN ID KOLOM\",\n        \"Clear and make into formula\": \"Bersihkan dan buat menjadi formula\",\n        \"Clear and reset\": \"Bersihkan dan atur ulang\",\n        \"Column options are limited in summary tables.\": \"Pilihan kolom terbatas dalam tabel ringkasan.\",\n        \"Convert column to data\": \"Konversi kolom menjadi data\",\n        \"Convert to trigger formula\": \"Ubah ke rumus pemicu\",\n        \"Data columns_one\": \"Kolom data\",\n        \"Data columns_other\": \"Kolom data\",\n        \"Empty columns_one\": \"Kolom kosong\",\n        \"Empty columns_other\": \"Kolom kosong\",\n        \"Enter formula\": \"Masukkan rumus\",\n        \"Formula columns_one\": \"Kolom rumus\",\n        \"Formula columns_other\": \"Kolom rumus\",\n        \"Make into data column\": \"Buat menjadi kolom data\",\n        \"Mixed Behavior\": \"Perilaku Campuran\",\n        \"Set formula\": \"Tetapkan rumus\",\n        \"Set trigger formula\": \"Tetapkan rumus pemicu\",\n        \"TRIGGER FORMULA\": \"RUMUS PEMICU\",\n        \"DESCRIPTION\": \"KETERANGAN\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Kembali ke pengaturan umum\",\n        \"Save as common settings\": \"Simpan sebagai pengaturan umum\",\n        \"Use separate settings\": \"Gunakan pengaturan terpisah\",\n        \"Using common settings\": \"Menggunakan pengaturan umum\",\n        \"Using separate settings\": \"Menggunakan pengaturan terpisah\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Tambah kolom\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Filter semat - kolom {{- columnName}} (saat ini: tidak disematkan)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Filter lepas sematan - kolom {{- columnName}} (saat ini: disematkan)\",\n        \"remove filter - {{- columnName}} column\": \"hapus filter - kolom {{- columnName}}\",\n        \"{{- columnName }} column filters\": \"Filter kolom {{- columnName }}\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Kolom pencarian\",\n        \"Search Columns\": \"Kolom Pencarian\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Opsi Grid\",\n        \"Horizontal gridlines\": \"Garis kisi horisontal\",\n        \"Vertical gridlines\": \"Garis kisi vertikal\",\n        \"Zebra stripes\": \"Garis-garis zebra\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Tambah kolom\",\n        \"Add to sort\": \"Tambah untuk diurutkan\",\n        \"Clear values\": \"Bersihkan nilai\",\n        \"Column Options\": \"Opsi Kolom\",\n        \"Convert formula to data\": \"Ubah rumus menjadi data\",\n        \"Delete {{count}} columns_one\": \"Hapus kolom\",\n        \"Delete {{count}} columns_other\": \"Hapus {{count}} kolom\",\n        \"Filter Data\": \"Saring Data\",\n        \"Freeze {{count}} columns_one\": \"Bekukan kolom ini\",\n        \"Freeze {{count}} columns_other\": \"Bekukan {{count}} kolom\",\n        \"Freeze {{count}} more columns_one\": \"Bekukan satu kolom lagi\",\n        \"Freeze {{count}} more columns_other\": \"Bekukan {{count}} kolom lagi\",\n        \"Hide {{count}} columns_one\": \"Sembunyikan kolom\",\n        \"Hide {{count}} columns_other\": \"Sembunyikan {{count}} kolom\",\n        \"Insert column to the {{to}}\": \"Sisipkan kolom ke {{to}}\",\n        \"More sort options ...\": \"Lebih banyak pilihan sortir…\",\n        \"Rename column\": \"Ganti nama kolom\",\n        \"Reset {{count}} columns_one\": \"Atur ulang kolom\",\n        \"Reset {{count}} columns_other\": \"Atur ulang {{count}} kolom\",\n        \"Reset {{count}} entire columns_one\": \"Atur ulang seluruh kolom\",\n        \"Reset {{count}} entire columns_other\": \"Atur ulang {{count}} seluruh kolom\",\n        \"Show column {{- label}}\": \"Tampilkan kolom {{- label}}\",\n        \"Sort\": \"Sortir\",\n        \"Sorted (#{{count}})_one\": \"Diurutkan (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Diurutkan (#{{count}})\",\n        \"Unfreeze all columns\": \"Buka pembekuan semua kolom\",\n        \"Unfreeze {{count}} columns_one\": \"Buka pembekuan kolom ini\",\n        \"Unfreeze {{count}} columns_other\": \"Buka pembekuan {{count}} kolom\",\n        \"Insert column to the left\": \"Sisipkan kolom di sebelah kiri\",\n        \"Insert column to the right\": \"Sisipkan kolom di sebelah kanan\",\n        \"Apply on record changes\": \"Terapkan ke perubahan rekaman\",\n        \"Apply to new records\": \"Terapkan ke catatan baru\",\n        \"Authorship\": \"Kepengarangan\",\n        \"Created At\": \"Dibuat Pada\",\n        \"Created By\": \"Dibuat Oleh\",\n        \"Hidden Columns\": \"Kolom Tersembunyi\",\n        \"Last Updated At\": \"Diperbarui Pada\",\n        \"Last Updated By\": \"Diperbarui Oleh\",\n        \"Lookups\": \"Pencarian\",\n        \"Shortcuts\": \"Jalan Pintas\",\n        \"Show hidden columns\": \"Tampilkan kolom tersembunyi\",\n        \"Timestamp\": \"Stempel waktu\",\n        \"no reference column\": \"tidak ada kolom referensi\",\n        \"Adding UUID column\": \"Menambahkan kolom UUID\",\n        \"Adding duplicates column\": \"Menambahkan kolom duplikat\",\n        \"Detect Duplicates in...\": \"Deteksi Duplikat di...\",\n        \"Duplicate in {{- label}}\": \"Duplikat di {{- label}}\",\n        \"No reference columns.\": \"Tidak ada kolom referensi.\",\n        \"Search columns\": \"Kolom pencarian\",\n        \"UUID\": \"UUID\",\n        \"Add column with type\": \"Tambahkan kolom dengan tipe\",\n        \"Add formula column\": \"Tambahkan kolom rumus\",\n        \"Created at\": \"Dibuat pada\",\n        \"Created by\": \"Dibuat oleh\",\n        \"Detect duplicates in...\": \"Deteksi duplikat di...\",\n        \"Last updated at\": \"Diperbarui pada\",\n        \"Last updated by\": \"Diperbarui oleh\",\n        \"Any\": \"Semua\",\n        \"Numeric\": \"Numerik\",\n        \"Text\": \"Teks\",\n        \"Integer\": \"Bilangan bulat\",\n        \"Toggle\": \"Beralih\",\n        \"Date\": \"Tanggal\",\n        \"DateTime\": \"TanggalWaktu\",\n        \"Choice\": \"Pilihan\",\n        \"Choice List\": \"Daftar Pilihan\",\n        \"Reference\": \"Referensi\",\n        \"Reference List\": \"Daftar Referensi\",\n        \"Attachment\": \"Lampiran\"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"Menambahkan bagian tertaut baru untuk melihat {{viewName}}\",\n        \"Import from file\": \"Impor dari file\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Bagian tertaut yang disimpan {{title}} dalam tampilan {{name}}\",\n        \"go to webhook settings\": \"buka pengaturan webhook\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Perubahan baru ditangguhkan sementara. Antrean webhook meluap. Harap periksa pengaturan webhook, hapus webhook yang tidak valid, dan bersihkan antrean.\",\n        \"Import from Airtable\": \"Impor dari Airtable\"\n    },\n    \"HomeIntro\": {\n        \"Any documents created in this site will appear here.\": \"Dokumen apa pun yang dibuat di situs ini akan muncul di sini.\",\n        \"Browse Templates\": \"Telusuri Templat\",\n        \"Create empty document\": \"Buat dokumen kosong\",\n        \"Get started by creating your first Grist document.\": \"Mulailah dengan membuat dokumen Grist pertama Anda.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Mulailah dengan menjelajahi templat, atau membuat dokumen Grist pertama Anda.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Mulailah dengan mengundang tim Anda dan membuat dokumen Grist pertama Anda.\",\n        \"Help Center\": \"Pusat Bantuan\",\n        \"Import document\": \"Impor dokumen\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Tertarik menggunakan Grist di luar tim Anda? Kunjungi \",\n        \"Invite Team Members\": \"Undang Anggota Tim\",\n        \"Sign up\": \"Daftar\",\n        \"Sprouts Program\": \"Program Kecambah\",\n        \"This workspace is empty.\": \"Ruang kerja ini kosong.\",\n        \"Visit our {{link}} to learn more.\": \"Kunjungi {{link}} kami untuk mempelajari lebih lanjut.\",\n        \"Welcome to Grist!\": \"Selamat datang di Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Selamat datang di Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Selamat datang di {{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Anda memiliki akses baca-saja ke situs ini. Saat ini tidak ada dokumen.\",\n        \"personal site\": \"situs pribadi\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} untuk menyimpan pekerjaan Anda. \",\n        \"Welcome to Grist, {{- name}}!\": \"Selamat datang di Grist, {{- name}}!\",\n        \"Welcome to {{- orgName}}\": \"Selamat datang di {{- orgName}}\",\n        \"Sign in\": \"Masuk\",\n        \"To use Grist, please either sign up or sign in.\": \"Untuk menggunakan Grist, silakan mendaftar atau masuk.\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Kunjungi {{link}} kami untuk mempelajari lebih lanjut tentang Grist.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Pelajari lebih lanjut di {{helpCenterLink}} kami.\",\n        \"Only show documents\": \"Hanya tampilkan dokumen\"\n    },\n    \"HomeLeftPane\": {\n        \"Access Details\": \"Detail Akses\",\n        \"All documents\": \"Semua dokumen\",\n        \"Create empty document\": \"Buat dokumen kosong\",\n        \"Create workspace\": \"Buat ruang kerja\",\n        \"Delete\": \"Hapus\",\n        \"Delete {{workspace}} and all included documents?\": \"Hapus {{workspace}} dan semua dokumen yang disertakan?\",\n        \"Examples & Templates\": \"Templat\",\n        \"Import document\": \"Impor dokumen\",\n        \"Manage users\": \"Kelola pengguna\",\n        \"Rename\": \"Ganti nama\",\n        \"Trash\": \"Sampah\",\n        \"Workspace will be moved to Trash.\": \"Ruang kerja akan dipindahkan ke Sampah.\",\n        \"Workspaces\": \"Ruang kerja\",\n        \"Tutorial\": \"Tutorial\",\n        \"Terms of service\": \"Ketentuan Layanan\",\n        \"Grist Resources\": \"Sumber Daya Grist\",\n        \"context menu - {{- workspaceName }}\": \"menu konteks - {{- workspaceName }}\",\n        \"Import from Airtable\": \"Impor dari Airtable\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"Gabungkan baris yang cocok dengan bidang ini:\",\n        \"Select fields to match on\": \"Pilih bidang yang akan dicocokkan\",\n        \"Update existing records\": \"Perbarui catatan yang ada\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} bidang yang tidak cocok dalam impor\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} bidang yang tidak cocok dalam impor\",\n        \"{{count}} unmatched field_one\": \"{{count}} bidang yang tidak cocok\",\n        \"{{count}} unmatched field_other\": \"{{count}} bidang yang tidak cocok\",\n        \"Column Mapping\": \"Pemetaan Kolom\",\n        \"Column mapping\": \"Pemetaan kolom\",\n        \"Destination table\": \"Tabel tujuan\",\n        \"Grist column\": \"Kolom Grist\",\n        \"Import from file\": \"Impor dari file\",\n        \"New Table\": \"Tabel Baru\",\n        \"Revert\": \"Kembali\",\n        \"Skip\": \"Lewati\",\n        \"Skip Import\": \"Lewati Impor\",\n        \"Skip Table on Import\": \"Lewati Tabel saat Impor\",\n        \"Source column\": \"Kolom sumber\",\n        \"Import options\": \"Opsi impor\",\n        \"Cancel\": \"Batalkan\",\n        \"Import\": \"Impor\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Pusat Bantuan\",\n        \"Accessibility\": \"Aksesibilitas\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"Sebagai templat\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Hati-hati, dokumen asli memiliki perubahan yang tidak ada dalam dokumen ini. Perubahan tersebut akan ditimpa.\",\n        \"Cancel\": \"Batalkan\",\n        \"Enter document name\": \"Masukkan nama dokumen\",\n        \"However, it appears to be already identical.\": \"Namun, tampaknya sudah identik.\",\n        \"Include the structure without any of the data.\": \"Sertakan struktur tanpa data apa pun.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Ini akan ditimpa, dan kehilangan konten apa pun yang tidak ada dalam dokumen ini.\",\n        \"Name\": \"Nama\",\n        \"No destination workspace\": \"Tidak ada ruang kerja tujuan\",\n        \"Organization\": \"Organisasi\",\n        \"Original Has Modifications\": \"Original Ada Modifikasi\",\n        \"Original Looks Unrelated\": \"Original Sepertinya Tidak Terkait\",\n        \"Original Looks Identical\": \"Original Sepertinya Identik\",\n        \"Overwrite\": \"Timpa\",\n        \"Replacing the original requires editing rights on the original document.\": \"Mengganti dokumen original memerlukan hak penyuntingan pada dokumen asli.\",\n        \"Sign up\": \"Mendaftar\",\n        \"The original version of this document will be updated.\": \"Versi original dokumen ini akan diperbarui.\",\n        \"To save your changes, please sign up, then reload this page.\": \"Untuk menyimpan perubahan Anda, silakan daftar, lalu muat ulang halaman ini.\",\n        \"Update\": \"Perbarui\",\n        \"Update Original\": \"Perbarui Original\",\n        \"Workspace\": \"Ruang kerja\",\n        \"You do not have write access to the selected workspace\": \"Anda tidak memiliki akses menulis ke ruang kerja yang dipilih\",\n        \"You do not have write access to this site\": \"Anda tidak memiliki akses menulis ke situs ini\",\n        \"Download\": \"Unduh\",\n        \"Download document\": \"Unduh dokumen\",\n        \"Download document and history\": \"Unduh dokumen dan riwayat\",\n        \"Download document without history (can significantly reduce file size)\": \"Unduh dokumen tanpa riwayat (dapat mengurangi ukuran file secara signifikan)\",\n        \"Download document structure only (no data, for template use)\": \"Unduh struktur dokumen saja (tanpa data, untuk penggunaan templat)\",\n        \".tar (recommended)\": \".tar (disarankan)\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"Unduh arsip semua lampiran yang ada dalam dokumen ini.\",\n        \"Download attachments\": \"Unduh lampiran\",\n        \"Download full document and history\": \"Unduh dokumen lengkap dan sejarahnya\",\n        \"Format:\": \"Format:\",\n        \"Learn more\": \"Pelajari lebih lanjut\",\n        \"download attachments\": \"unduh lampiran\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Lampiran bersifat eksternal dan tidak disertakan dalam unduhan ini. Jika mengunggah dokumen ke instalasi Grist yang terpisah, Anda juga perlu {{downloadLink}} secara terpisah. \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"Jika Anda berencana mengunggah dokumen ini ke instalasi Grist, Anda memerlukan arsip dalam format \\\".tar\\\" untuk memulihkan lampiran. \"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"Meminta bantuan\",\n        \"Cannot find personal site, sorry!\": \"Tidak dapat menemukan situs pribadi, maaf!\",\n        \"Give feedback\": \"Berikan umpan balik\",\n        \"Go to your free personal site\": \"Kunjungi situs pribadi gratis Anda\",\n        \"No notifications\": \"Tidak ada notifikasi\",\n        \"Notifications\": \"Pemberitahuan\",\n        \"Renew\": \"Memperbarui\",\n        \"Report a problem\": \"Laporkan masalah\",\n        \"Upgrade Plan\": \"Rencana Upgrade\",\n        \"Manage billing\": \"Kelola penagihan\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Selesai\",\n        \"Next\": \"Berikut\",\n        \"Previous\": \"Sebelum\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Tur Video Grist\",\n        \"Video Tour\": \"Tur Video\",\n        \"YouTube video player\": \"Pemutar video YouTube\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Tambah ke halaman\",\n        \"Building {{- label}} widget\": \"Membangun widget {{- label}}\",\n        \"Group by\": \"Kelompokkan berdasar\",\n        \"Select data\": \"Pilih data\",\n        \"Select widget\": \"Pilih widget\",\n        \"New Table\": \"Tabel Baru\",\n        \"SELECT BY\": \"PILIH BERDASARKAN\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Hapus\",\n        \"Delete data and this page.\": \"Hapus data dan halaman ini.\",\n        \"The following tables will no longer be visible_one\": \"Tabel berikut tidak akan terlihat lagi\",\n        \"The following tables will no longer be visible_other\": \"Tabel berikut tidak akan terlihat lagi\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Simpan data dan hapus halaman. Tabel akan tetap tersedia di {{rawDataLink}}\",\n        \"raw data page\": \"halaman data mentah\",\n        \"Document pages\": \"Halaman dokumen\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Izinkan semua\",\n        \"Deny all\": \"Tolak semuanya\",\n        \"Read only\": \"Hanya baca\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Impor gagal: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Memperbarui tata letak rekaman.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Tambah bidang\",\n        \"Create new field\": \"Buat bidang baru\",\n        \"Show field {{- label}}\": \"Tampilkan bidang {{- label}}\",\n        \"Save layout\": \"Simpan tata letak\",\n        \"Cancel\": \"Batal\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Tambah kolom\",\n        \"No columns to add\": \"Tidak ada kolom untuk ditambahkan\"\n    },\n    \"RightPanel\": {\n        \"CHART TYPE\": \"JENIS DIAGRAM\",\n        \"COLUMN TYPE\": \"JENIS KOLOM\",\n        \"CUSTOM\": \"KUSTOM\",\n        \"Change widget\": \"Ubah widget\",\n        \"columns_one\": \"Kolom\",\n        \"columns_other\": \"Kolom\",\n        \"DATA TABLE\": \"TABEL DATA\",\n        \"DATA TABLE NAME\": \"NAMA TABEL DATA\",\n        \"Data\": \"Data\",\n        \"Detach\": \"Lepaskan\",\n        \"Edit data selection\": \"Edit pilihan data\",\n        \"fields_one\": \"Bidang\",\n        \"fields_other\": \"Bidang\",\n        \"GROUPED BY\": \"DIKELOMPOKKAN OLEH\",\n        \"Row style\": \"Gaya baris\",\n        \"SELECT BY\": \"PILIH BERDASARKAN\",\n        \"SELECTOR FOR\": \"PILIH BERDASARKAN\",\n        \"SOURCE DATA\": \"DATA SUMBER\",\n        \"Save\": \"Simpan\",\n        \"Select widget\": \"Pilih widget\",\n        \"series_one\": \"Seri\",\n        \"series_other\": \"Seri\",\n        \"Sort & filter\": \"Sortir & filter\",\n        \"TRANSFORM\": \"MENGUBAH\",\n        \"Theme\": \"Tema\",\n        \"WIDGET TITLE\": \"JUDUL WIDGET\",\n        \"Widget\": \"Widget\",\n        \"You do not have edit access to this document\": \"Anda tidak memiliki akses edit ke dokumen ini\",\n        \"Add referenced columns\": \"Tambahkan kolom yang direferensikan\",\n        \"Reset form\": \"Atur ulang formulir\",\n        \"Configuration\": \"Konfigurasi\",\n        \"Default field value\": \"Nilai bidang default\",\n        \"Display button\": \"Tampilkan tombol\",\n        \"Enter text\": \"Masukkan teks\",\n        \"Field rules\": \"Aturan bidang\",\n        \"Field title\": \"Judul bidang\",\n        \"Hidden field\": \"Bidang tersembunyi\",\n        \"Layout\": \"Tata Letak\",\n        \"Redirect automatically after submission\": \"Dialihkan secara otomatis setelah pengiriman\",\n        \"Redirection\": \"Pengalihan\",\n        \"Required field\": \"Bidang yang dibutuhkan\",\n        \"Submission\": \"Pengajuan\",\n        \"Submit another response\": \"Kirim tanggapan lain\",\n        \"Submit button label\": \"Label tombol kirim\",\n        \"Success text\": \"Teks sukses\",\n        \"Table column name\": \"Nama kolom tabel\",\n        \"Enter redirect URL\": \"Masuk URL pengalihan\",\n        \"No field selected\": \"Tidak ada bidang yang dipilih\",\n        \"Select a field in the form widget to configure.\": \"Pilih bidang di widget formulir untuk dikonfigurasi.\",\n        \"Submit\": \"Kirim\",\n        \"Thank you! Your response has been recorded.\": \"Terima kasih! Tanggapan Anda telah disimpan.\",\n        \"Chart options\": \"Opsi diagram\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"Salin tautan jangkar\",\n        \"Delete\": \"Hapus\",\n        \"Duplicate rows_one\": \"Duplikat baris\",\n        \"Duplicate rows_other\": \"Duplikat baris\",\n        \"Insert row\": \"Sisipkan baris\",\n        \"Insert row above\": \"Sisipkan baris di atas\",\n        \"Insert row below\": \"Sisipkan baris di bawah\",\n        \"View as card\": \"Lihat sebagai kartu\",\n        \"Use as table headers\": \"Gunakan sebagai header tabel\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Disalin ke papan klip\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"Detail Akses\",\n        \"Back to current\": \"Kembali ke saat ini\",\n        \"Compare to {{termToUse}}\": \"Bandingkan dengan {{termToUse}}\",\n        \"Current Version\": \"Versi saat ini\",\n        \"Download\": \"Unduh\",\n        \"Duplicate document\": \"Duplikat dokumen\",\n        \"Edit without affecting the original\": \"Edit tanpa mempengaruhi original\",\n        \"Export CSV\": \"Ekspor CSV\",\n        \"Export XLSX\": \"Ekspor XLSX\",\n        \"Manage users\": \"Kelola pengguna\",\n        \"Original\": \"Original\",\n        \"Replace {{termToUse}}...\": \"Ganti {{termToUse}}…\",\n        \"Return to {{termToUse}}\": \"Kembali ke {{termToUse}}\",\n        \"Save copy\": \"Simpan salinan\",\n        \"Save Document\": \"Simpan Dokumen\",\n        \"Send to Google Drive\": \"Kirim ke Google Drive\",\n        \"Show in folder\": \"Tampilkan di folder\",\n        \"Unsaved\": \"Belum disimpan\",\n        \"Work on a copy\": \"Bekerja pada salinan\",\n        \"Share\": \"Bagikan\",\n        \"Download...\": \"Unduh...\",\n        \"Comma Separated Values (.csv)\": \"Nilai yang Dipisahkan Koma (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"Nilai Terpisah DOO (.dsv)\",\n        \"Export as...\": \"Ekspor sebagai...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Nilai Terpisah Tab (.tsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Ekspor hanya tersedia dari halaman dokumen. Silakan pilih halaman dokumen dan coba lagi.\",\n        \"Download attachments...\": \"Unduh lampiran...\",\n        \"Download document...\": \"Unduh dokumen...\",\n        \"Suggest changes\": \"Sarankan perubahan\",\n        \"current version\": \"versi saat ini\",\n        \"original\": \"original\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Buat situs tim baru\",\n        \"Switch Sites\": \"Beralih Situs\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Tambah kolom\",\n        \"Empty values last\": \"Nilai kosong terakhir\",\n        \"Natural sort\": \"Urutan alami\",\n        \"Update data\": \"Perbarui data\",\n        \"Use choice position\": \"Gunakan posisi pilihan\",\n        \"Search Columns\": \"Kolom pencarian\",\n        \"Remove sort setting - {{- columnName }} column\": \"Hapus pengaturan sortir - kolom {{- columnName }}\",\n        \"Sort in ascending order (current: descending)\": \"Urutkan dalam urutan naik (saat ini: turun)\",\n        \"Sort in descending order (current: ascending)\": \"Urutkan dalam urutan turun (saat ini: naik)\",\n        \"Sort options - {{- columnName }} column\": \"Opsi sortir - kolom {{- columnName }}\",\n        \"{{- columnName }} column\": \"{{- columnName }} kolom\"\n    },\n    \"SortFilterConfig\": {\n        \"Filter\": \"FILTER\",\n        \"Revert\": \"Kembalikan\",\n        \"Save\": \"Simpan\",\n        \"Sort\": \"SORTIR\",\n        \"Update Sort & Filter settings\": \"Perbarui pengaturan Sortir & Filter\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Tampilan \",\n        \"Switch appearance automatically to match system\": \"Ganti tampilan secara otomatis agar sesuai dengan sistem\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Aturan Akses\",\n        \"Code view\": \"Tampilan kode\",\n        \"Delete\": \"Hapus\",\n        \"Delete document tour?\": \"Hapus tur dokumen?\",\n        \"Document history\": \"Riwayat dokumen\",\n        \"How-to Tutorial\": \"Tutorial Cara Menggunakan\",\n        \"Raw data\": \"Data mentah\",\n        \"Return to viewing as yourself\": \"Kembali melihat sebagai diri sendiri\",\n        \"TOOLS\": \"PERALATAN\",\n        \"Tour of this Document\": \"Tur Dokumen Ini\",\n        \"Validate Data\": \"Validasi Data\",\n        \"Settings\": \"Pengaturan\",\n        \"API console\": \"Konsol API\",\n        \"context menu - Access Rules\": \"menu konteks - Aturan Akses\",\n        \"Delete document tour\": \"Hapus tur dokumen\",\n        \"Preview the tutorial\": \"Pratinjau tutorial\",\n        \"Proposed changes\": \"Perubahan yang Diusulkan\",\n        \"Suggest changes\": \"Sarankan perubahan\",\n        \"Suggestions\": \"Saran\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Kelola tim\",\n        \"Redo\": \"Redo\",\n        \"Undo\": \"Undo\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"Bidang apa saja\",\n        \"Apply on changes to:\": \"Terapkan perubahan pada:\",\n        \"Apply on record changes\": \"Terapkan ke perubahan rekaman\",\n        \"Apply to new records\": \"Terapkan ke catatan baru\",\n        \"Cancel\": \"Batalkan\",\n        \"Close\": \"Tutup\",\n        \"Current field \": \"Bidang saat ini \",\n        \"OK\": \"OK\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"Terapkan\",\n        \"Cancel\": \"Batalkan\",\n        \"Preview\": \"Pratinjau\",\n        \"Revise\": \"Revisi\",\n        \"Update formula (Shift+Enter)\": \"Perbarui rumus (Shift+Enter)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Editor\",\n        \"In full\": \"Sepenuhnya\",\n        \"No Default Access\": \"Tidak Ada Akses Default\",\n        \"None\": \"Tidak ada\",\n        \"Owner\": \"Pemilik\",\n        \"View & edit\": \"Lihat & edit\",\n        \"View only\": \"Lihat saja\",\n        \"Viewer\": \"Penonton\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Aturan {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Perbarui rumus (Shift+Enter)\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Pengguna Tidak Dikenal\",\n        \"View as Yourself\": \"Lihat sebagai Diri Sendiri\",\n        \"You are viewing this document as\": \"Anda melihat dokumen ini sebagai\",\n        \"You're seeing what this user would see if given access\": \"Anda melihat apa yang akan dilihat pengguna ini jika diberi akses\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Pengaturan lanjutan\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Tabel besar dapat ditandai sebagai \\\"sesuai permintaan\\\" untuk menghindari pemuatan ke mesin data.\",\n        \"Blocks\": \"Blok\",\n        \"Compact\": \"Kompak\",\n        \"Edit card layout\": \"Edit tata letak kartu\",\n        \"Form\": \"Formulir\",\n        \"Make On-Demand\": \"Buat Sesuai Permintaan\",\n        \"Plugin: \": \"Plugin: \",\n        \"Section: \": \"Seksi: \",\n        \"Unmark On-Demand\": \"Hapus Tanda Sesuai Permintaan\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"Tabel Sesuai Permintaan sudah tidak digunakan lagi karena kurangnya fungsionalitas dan masalah kegunaan.\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Fitur yang Tidak Digunakan Lagi\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Sortir & filter lanjutan\",\n        \"Copy anchor link\": \"Salin tautan jangkar\",\n        \"Data selection\": \"Pemilihan data\",\n        \"Delete record\": \"Hapus rekaman\",\n        \"Delete widget\": \"Hapus widget\",\n        \"Download as CSV\": \"Unduh sebagai CSV\",\n        \"Download as XLSX\": \"Unduh sebagai XLSX\",\n        \"Edit card layout\": \"Edit tata letak kartu\",\n        \"Open configuration\": \"Konfigurasi terbuka\",\n        \"Print widget\": \"Widget cetak\",\n        \"Show raw data\": \"Tampilkan data mentah\",\n        \"Widget options\": \"Opsi widget\",\n        \"Add to page\": \"Tambahkan ke halaman\",\n        \"Collapse widget\": \"Tutup widget\",\n        \"Create a form\": \"Buat formulir\",\n        \"Duplicate widget\": \"Duplikat widget\"\n    },\n    \"ViewSectionMenu\": {\n        \"(customized)\": \"(disesuaikan)\",\n        \"(empty)\": \"(kosong)\",\n        \"(modified)\": \"(dimodifikasi)\",\n        \"Custom options\": \"Opsi khusus\",\n        \"FILTER\": \"FILTER\",\n        \"Revert\": \"Kembali\",\n        \"SORT\": \"SORTIR\",\n        \"Save\": \"Simpan\",\n        \"Update Sort&Filter settings\": \"Perbarui pengaturan Sort&Filter\",\n        \"Sort and filter\": \"Sortir dan filter\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"Tidak dapat menjatuhkan item ke Bidang Tersembunyi\",\n        \"Clear\": \"Bersih\",\n        \"Hidden Fields cannot be reordered\": \"Bidang Tersembunyi tidak dapat diubah urutannya\",\n        \"Select all\": \"Pilih semua\",\n        \"Visible {{label}}\": \"Terlihat {{label}}\",\n        \"Hide {{label}}\": \"Sembunyikan {{label}}\",\n        \"Hidden {{label}}\": \"Tersembunyi {{label}}\",\n        \"Show {{label}}\": \"Tampilkan {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Sembunyikan {{label}} (mode batch)\",\n        \"Show {{label}} (batch mode)\": \"Tampilkan {{label}} (mode batch)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Pendidikan\",\n        \"Finance & Accounting\": \"Keuangan dan Akuntansi\",\n        \"HR & Management\": \"SDM dan Manajemen\",\n        \"IT & Technology\": \"TI dan Teknologi\",\n        \"Marketing\": \"Pemasaran\",\n        \"Media Production\": \"Produksi Media\",\n        \"Other\": \"Lainnya\",\n        \"Product Development\": \"Pengembangan Produk\",\n        \"Research\": \"Riset\",\n        \"Sales\": \"Penjualan\",\n        \"Type here\": \"Ketik di sini\",\n        \"Welcome to Grist!\": \"Selamat datang di Grist!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Apa yang membawa Anda ke Grist? Mohon bantu kami melayani Anda dengan lebih baik.\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"Batal\",\n        \"DATA TABLE NAME\": \"NAMA TABEL DATA\",\n        \"Override widget title\": \"Ganti judul widget\",\n        \"Provide a table name\": \"Berikan nama tabel\",\n        \"Save\": \"Simpan\",\n        \"WIDGET TITLE\": \"JUDUL WIDGET\",\n        \"WIDGET DESCRIPTION\": \"DESKRIPSI WIDGET\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Anda dapat melakukan pengeditan, tetapi pengeditan tersebut akan membuat salinan baru dan\\ntidak akan memengaruhi dokumen asli.\",\n        \"fiddle\": \"fiddle\",\n        \"override\": \"kesampingkan\",\n        \"recovery mode\": \"mode pemulihan\",\n        \"snapshot\": \"snapshot\",\n        \"unsaved\": \"belum disimpan\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Anda dapat melakukan pengeditan,\\ntetapi pengeditan tersebut tidak akan memengaruhi dokumen asli.\\nAnda dapat mengusulkannya sebagai saran.\",\n        \"editing\": \"mengedit\",\n        \"suggesting\": \"menyarankan\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Halaman duplikat {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Perhatikan bahwa ini tidak menyalin data, tetapi membuat tampilan lain dari data yang sama.\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Akses ditolak{{suffix}}\",\n        \"Add account\": \"Tambahkan akun\",\n        \"Contact support\": \"Hubungi dukungan\",\n        \"Error{{suffix}}\": \"Kesalahan{{suffix}}\",\n        \"Go to main page\": \"Buka halaman utama\",\n        \"Page not found{{suffix}}\": \"Halaman tidak ditemukan{{suffix}}\",\n        \"Sign in\": \"Masuk\",\n        \"Sign in again\": \"Masuk lagi\",\n        \"Sign in to access this organization's documents.\": \"Masuk untuk mengakses dokumen organisasi ini.\",\n        \"Signed out{{suffix}}\": \"Keluar{{suffix}}\",\n        \"Something went wrong\": \"Ada yang salah\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Tidak dapat menemukan halaman yang diminta.{{separator}}Silakan periksa URL dan coba lagi.\",\n        \"There was an error: {{message}}\": \"Terjadi kesalahan: {{message}}\",\n        \"There was an unknown error.\": \"Terjadi kesalahan yang tidak diketahui.\",\n        \"You are now signed out.\": \"Anda sekarang telah keluar.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Anda masuk sebagai {{email}}. Anda dapat masuk dengan akun lain, atau meminta akses kepada administrator.\",\n        \"You do not have access to this organization's documents.\": \"Anda tidak memiliki akses ke dokumen organisasi ini.\",\n        \"Account deleted{{suffix}}\": \"Akun dihapus{{suffix}}\",\n        \"Sign up\": \"Daftar\",\n        \"Your account has been deleted.\": \"Akun Anda telah dihapus.\",\n        \"An unknown error occurred.\": \"Terjadi kesalahan yang tidak diketahui.\",\n        \"Build your own form\": \"Bangun formulir Anda sendiri\",\n        \"Form not found\": \"Formulir tidak ditemukan\",\n        \"Powered by\": \"Didukung oleh\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Gagal masuk.{{separator}}Silakan coba lagi atau hubungi dukungan.\",\n        \"Sign-in failed{{suffix}}\": \"Masuk gagal{{suffix}}\",\n        \"Manage settings\": \"Kelola pengaturan\",\n        \"Need Help?\": \"Butuh Bantuan?\",\n        \"There was an error\": \"Terjadi kesalahan\",\n        \"Unsubscribed{{suffix}}\": \"Berhenti berlangganan{{suffix}}\",\n        \"We could not unsubscribe you\": \"Kami tidak dapat berhenti berlangganan Anda\",\n        \"You are unsubscribed\": \"Anda telah berhenti berlangganan\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Anda masih dapat berhenti berlangganan dari dokumen ini dengan memperbarui preferensi Anda di pengaturan dokumen\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Anda tidak akan lagi menerima pemberitahuan email tentang {{changes}} di {{docName}} di {{email}}.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Anda tidak akan lagi menerima pemberitahuan email tentang {{comments}} di {{docName}} di {{email}}.\",\n        \"changes\": \"perubahan\",\n        \"comments\": \"komentar\",\n        \"this document\": \"dokumen ini\",\n        \"your email\": \"email Anda\",\n        \"You will no longer receive email notifications about {{suggestions}} in {{docName}} at {{email}}.\": \"Anda tidak akan lagi menerima pemberitahuan email tentang {{suggestions}} di {{docName}} di {{email}}.\",\n        \"suggestions\": \"saran\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* Ruang kerja tersedia pada paket tim. \",\n        \"Select fields\": \"Pilih bidang\",\n        \"Upgrade now\": \"Tingkatkan sekarang\",\n        \"Any\": \"Apa pun\",\n        \"Numeric\": \"Numerik\",\n        \"Text\": \"Teks\",\n        \"Integer\": \"Bilangan bulat\",\n        \"Toggle\": \"Beralih\",\n        \"Date\": \"Tanggal\",\n        \"DateTime\": \"TanggalWaktu\",\n        \"Choice\": \"Pilihan\",\n        \"Choice List\": \"Daftar Pilihan\",\n        \"Reference\": \"Referensi\",\n        \"Reference List\": \"Daftar Referensi\",\n        \"Attachment\": \"Lampiran\",\n        \"Search columns\": \"Kolom pencarian\",\n        \"By Name\": \"Berdasarkan Nama\",\n        \"By Date Modified\": \"Berdasar tanggal Modifikasi\",\n        \"Light\": \"Terang\",\n        \"Custom\": \"Kustom\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Batal\",\n        \"Ok\": \"OK\",\n        \"Save\": \"Simpan\",\n        \"Are you sure you want to delete these records?\": \"Apakah Anda yakin ingin menghapus rekaman ini?\",\n        \"Are you sure you want to delete this record?\": \"Apakah Anda yakin ingin menghapus rekaman ini?\",\n        \"Delete\": \"Hapus\",\n        \"Dismiss\": \"Berhentikan\",\n        \"Don't ask again.\": \"Jangan tanya lagi.\",\n        \"Don't show again.\": \"Jangan tampilkan lagi.\",\n        \"Don't show tips\": \"Jangan tampilkan tip\",\n        \"Undo to restore\": \"Batal untuk memulihkan\",\n        \"Got it\": \"Mengerti\",\n        \"Don't show again\": \"Jangan tampilkan lagi\",\n        \"TIP\": \"TIP\",\n        \"Confirm\": \"Konfirmasi\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Duplikat halaman\",\n        \"Remove\": \"Hapus\",\n        \"Rename\": \"Ganti nama\",\n        \"You do not have edit access to this document\": \"Anda tidak memiliki akses edit ke dokumen ini\",\n        \"(default)\": \"(bawaan)\",\n        \"Collapse {{maybeDefault}}\": \"Tutup {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Perluas {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Tetapkan default: Ciutkan\",\n        \"Set default: Expand\": \"Tetapkan default: Perluas\",\n        \"context menu - {{- pageName }}\": \"menu konteks - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Find Next \": \"Temukan Berikutnya \",\n        \"Find Previous \": \"Temukan Sebelumnya \",\n        \"No results\": \"Tidak ada hasil\",\n        \"Search in document\": \"Cari dalam dokumen\",\n        \"Search\": \"Cari\",\n        \"Close search bar\": \"Tutup bilah pencarian\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Mengirim file ke Google Drive\"\n    },\n    \"NTextBox\": {\n        \"false\": \"palsu\",\n        \"true\": \"benar\",\n        \"Field Format\": \"Format Bidang\",\n        \"Lines\": \"Garis\",\n        \"Multi line\": \"Multi baris\",\n        \"Single line\": \"Garis tunggal\",\n        \"Maximum characters\": \"Jumlah karakter maksimum\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"Contoh Pengguna\",\n        \"Users from table\": \"Pengguna dari tabel\",\n        \"View as\": \"Lihat sebagai\",\n        \"Other users from table\": \"Pengguna lain dari tabel\",\n        \"Shared users\": \"Pengguna bersama\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Terapkan\",\n        \"Cancel\": \"Batal\",\n        \"Preview\": \"Pratinjau\",\n        \"Revise\": \"Revisi\",\n        \"Update formula (Shift+Enter)\": \"Perbarui rumus (Shift+Enter)\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"GAYA SEL\",\n        \"Cell style\": \"Gaya sel\",\n        \"Default cell style\": \"Gaya sel default\",\n        \"Mixed style\": \"Gaya campuran\",\n        \"Open row styles\": \"Gaya baris terbuka\",\n        \"Default header style\": \"Gaya header default\",\n        \"Header Style\": \"Gaya Header\",\n        \"HEADER STYLE\": \"GAYA HEADER\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"PILIHAN\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"DESKRIPSI KOLOM\",\n        \"COLUMN LABEL\": \"LABEL KOLOM\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN DESCRIPTION\": \"DESKRIPSI KOLOM\",\n        \"COLUMN ID: \": \"ID KOLOM: \",\n        \"COLUMN LABEL\": \"LABEL KOLOM\",\n        \"Cancel\": \"Batal\",\n        \"Save\": \"Simpan\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"Tambahkan aturan lain\",\n        \"Add conditional style\": \"Tambahkan gaya bersyarat\",\n        \"Error in style rule\": \"Kesalahan dalam aturan gaya\",\n        \"Row style\": \"Gaya baris\",\n        \"Rule must return True or False\": \"Aturan harus mengembalikan Benar atau Salah\",\n        \"Conditional Style\": \"Gaya Bersyarat\",\n        \"IF...\": \"JIKA...\",\n        \"Row Style\": \"Gaya Baris\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Mata uang tidak valid\"\n    },\n    \"DiscussionEditor\": {\n        \"{{count}} comments_one\": \"{{count}} komentar\",\n        \"{{count}} comments_other\": \"{{count}} komentar\",\n        \"Cancel\": \"Batal\",\n        \"Comment\": \"Komentar\",\n        \"Copy link\": \"Salin tautan\",\n        \"Edit\": \"Sunting\",\n        \"Marked as resolved\": \"Ditandai sebagai terselesaikan\",\n        \"Only current page\": \"Hanya halaman saat ini\",\n        \"Only my threads\": \"Hanya benangku\",\n        \"Open\": \"Terbuka\",\n        \"Remove\": \"Hapus\",\n        \"Reply\": \"Balas\",\n        \"Reply to a comment\": \"Balas komentar\",\n        \"Resolve\": \"Selesai\",\n        \"Save\": \"Simpan\",\n        \"Show resolved comments\": \"Tampilkan komentar yang terselesaikan\",\n        \"Showing last {{nb}} comments\": \"Menampilkan {{nb}} komentar terakhir\",\n        \"Started discussion\": \"Memulai diskusi\",\n        \"Write a comment\": \"Tulis komentar\",\n        \"Remove thread\": \"Hapus utas\",\n        \"updated\": \"diperbarui\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Ubah kolom menjadi rumus\"\n    },\n    \"FieldBuilder\": {\n        \"Apply formula to data\": \"Terapkan rumus ke data\",\n        \"CELL FORMAT\": \"FORMAT SEL\",\n        \"Changing multiple column types\": \"Mengubah beberapa jenis kolom\",\n        \"DATA FROM TABLE\": \"DATA DARI TABEL\",\n        \"Mixed format\": \"Format campuran\",\n        \"Mixed types\": \"Jenis campuran\",\n        \"Revert field settings for {{colId}} to common\": \"Kembalikan pengaturan bidang untuk {{colId}} ke umum\",\n        \"Save field settings for {{colId}} as common\": \"Simpan pengaturan bidang untuk {{colId}} sebagai umum\",\n        \"Use separate field settings for {{colId}}\": \"Gunakan pengaturan bidang terpisah untuk {{colId}}\",\n        \"Changing column type\": \"Mengubah jenis kolom\",\n        \"Common\": \"Umum\",\n        \"Separate\": \"Memisahkan\",\n        \"Field in {{count}} views_one\": \"Lapangan dalam satu tampilan\",\n        \"Field in {{count}} views_other\": \"Bidang dalam {{count}} tampilan\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"Seharusnya tidak mungkin untuk menyimpan nilai data biasa ke dalam kolom rumus\",\n        \"Unable to finish saving edited cell\": \"Tidak dapat menyelesaikan penyimpanan sel yang diedit\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Kolom atau bidang wajib diisi\",\n        \"Error in the cell\": \"Kesalahan dalam sel\",\n        \"Errors in all {{numErrors}} cells\": \"Kesalahan di semua sel {{numErrors}}\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Kesalahan dalam {{numErrors}} dari {{numCells}} sel\",\n        \"editingFormula is required\": \"pengeditanRumus diperlukan\",\n        \"Enter formula or {{button}}.\": \"Masukkan rumus atau {{button}}.\",\n        \"Enter formula.\": \"Masukkan rumus.\",\n        \"Expand Editor\": \"Perluas Editor\",\n        \"use AI Assistant\": \"gunakan Asisten AI\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[label tautan] URL\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"Mata uang\",\n        \"Decimals\": \"Desimal\",\n        \"Default currency ({{defaultCurrency}})\": \"Mata uang default ({{defaultCurrency}})\",\n        \"Number Format\": \"Format Angka\",\n        \"Field Format\": \"Format Bidang\",\n        \"Spinner\": \"Spiner\",\n        \"Text\": \"Teks\",\n        \"max\": \"maks\",\n        \"min\": \"min\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"FORMAT SEL\",\n        \"Row ID\": \"ID Baris\",\n        \"SHOW COLUMN\": \"TAMPILKAN KOLOM\"\n    },\n    \"WelcomeTour\": {\n        \"Add new\": \"Tambah baru\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Telusuri {{templateLibrary}} kami untuk menemukan apa yang mungkin dan mendapatkan inspirasi.\",\n        \"Building up\": \"Membangun\",\n        \"Configuring your document\": \"Mengonfigurasi dokumen Anda\",\n        \"Customizing columns\": \"Menyesuaikan kolom\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Klik dua kali atau tekan {{enter}} pada sel untuk mengeditnya. \",\n        \"Editing Data\": \"Mengedit Data\",\n        \"Enter\": \"Masuk\",\n        \"Flying higher\": \"Terbang lebih tinggi\",\n        \"Help Center\": \"Pusat Bantuan\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Jadikan relasional! Gunakan tipe {{ref}} untuk menghubungkan tabel. \",\n        \"Reference\": \"Referensi\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Tetapkan opsi pemformatan, rumus, atau jenis kolom, seperti tanggal, pilihan, atau lampiran. \",\n        \"Share\": \"Bagikan\",\n        \"Sharing\": \"Membagikan\",\n        \"Start with {{equal}} to enter a formula.\": \"Mulailah dengan {{equal}} untuk memasukkan rumus.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Alihkan {{creatorPanel}} untuk memformat kolom, \",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Gunakan tombol Bagikan ({{share}}) untuk membagikan dokumen atau mengekspor data.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Gunakan {{addNew}} untuk menambahkan widget, halaman, atau mengimpor lebih banyak data. \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Gunakan {{helpCenter}} untuk dokumentasi atau pertanyaan.\",\n        \"Welcome to Grist!\": \"Selamat datang di Grist!\",\n        \"convert to card view, select data, and more.\": \"ubah ke tampilan kartu, pilih data, dan banyak lagi.\",\n        \"creator panel\": \"panel kreator\",\n        \"template library\": \"perpustakaan templat\",\n        \"AI Assistant\": \"Asisten AI\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Bahasa\"\n    },\n    \"GristTooltips\": {\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Terapkan pemformatan bersyarat ke sel di kolom ini ketika kondisi rumus terpenuhi.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Terapkan pemformatan bersyarat ke baris berdasarkan rumus.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Sel dalam kolom referensi selalu mengidentifikasi {{entire}} rekaman dalam tabel itu, tetapi Anda dapat memilih kolom mana dari rekaman itu yang akan ditampilkan.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Klik “Buka gaya baris” untuk menerapkan pemformatan bersyarat ke baris.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Klik tombol Tambah baru untuk membuat dokumen atau ruang kerja baru, atau mengimpor data.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Mengklik {{EyeHideIcon}} di setiap sel akan menyembunyikan bidang dari tampilan ini tanpa menghapusnya.\",\n        \"Editing Card Layout\": \"Mengedit Tata Letak Kartu\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Rumus yang dipicu dalam kasus tertentu, dan menyimpan nilai terhitung sebagai data.\",\n        \"Learn more.\": \"Pelajari lebih lanjut.\",\n        \"Link your new widget to an existing widget on this page.\": \"Tautkan widget baru Anda ke widget yang ada di halaman ini.\",\n        \"Linking Widgets\": \"Menghubungkan Widget\",\n        \"Nested Filtering\": \"Penyaringan Bersarang\",\n        \"Only those rows will appear which match all of the filters.\": \"Hanya baris-baris yang cocok dengan semua filter yang akan muncul.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Filter yang disematkan ditampilkan sebagai tombol di atas widget.\",\n        \"Pinning Filters\": \"Sematkan Filter\",\n        \"Raw Data page\": \"Halaman Data Mentah\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Atur ulang bidang pada kartu Anda dengan menyeret dan mengubah ukuran sel.\",\n        \"Reference Columns\": \"Kolom Referensi\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Kolom referensi adalah kunci untuk data {{relational}} di Grist.\",\n        \"Select the table containing the data to show.\": \"Pilih tabel berisi data yang akan ditampilkan.\",\n        \"Select the table to link to.\": \"Pilih tabel yang ingin ditautkan.\",\n        \"Selecting Data\": \"Memilih Data\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Halaman Data Mentah mencantumkan semua tabel data dalam dokumen Anda, termasuk tabel ringkasan dan tabel yang tidak disertakan dalam tata letak halaman.\",\n        \"The total size of all data in this document, excluding attachments.\": \"Ukuran total semua data dalam dokumen ini, tidak termasuk lampiran.\",\n        \"They allow for one record to point (or refer) to another.\": \"Mereka memperbolehkan satu catatan menunjuk (atau merujuk) ke catatan lain.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"Inilah rahasia tata letak Grist yang dinamis dan produktif.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Cobalah perubahan dalam salinan, lalu putuskan apakah akan mengganti salinan asli dengan suntingan Anda.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Lepas sematan untuk menyembunyikan tombol sambil tetap mempertahankan filter.\",\n        \"Updates every 5 minutes.\": \"Diperbarui setiap 5 menit.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Gunakan ikon \\\\u{1D6BA} untuk membuat tabel ringkasan (atau pivot), untuk total atau subtotal.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Berguna untuk menyimpan stempel waktu atau pembuat rekaman baru, pembersihan data, dan banyak lagi.\",\n        \"You can filter by more than one column.\": \"Anda dapat memfilter berdasarkan lebih dari satu kolom.\",\n        \"entire\": \"seluruh\",\n        \"relational\": \"relasional\",\n        \"Access Rules\": \"Aturan Akses\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Aturan akses memberi Anda kekuatan untuk membuat aturan bernuansa untuk menentukan siapa yang dapat melihat atau mengedit bagian mana dari dokumen Anda.\",\n        \"Add new\": \"Tambah baru\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Gunakan ikon 𝚺 untuk membuat tabel ringkasan (atau pivot), untuk total atau subtotal.\",\n        \"Anchor Links\": \"Tautan Jangkar\",\n        \"Custom Widgets\": \"Widget Kustom\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Untuk membuat tautan jangkar yang membawa pengguna ke sel tertentu, klik pada baris dan tekan {{shortcut}}.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Anda dapat memilih salah satu widget siap pakai kami atau menyematkan widget Anda sendiri dengan memberikan URL lengkapnya.\",\n        \"Calendar\": \"Kalender\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Tidak menemukan kolom yang tepat? Klik \\\"Ubah Widget\\\" untuk memilih tabel berisi data peristiwa.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"Untuk mengonfigurasi kalender Anda, pilih kolom untuk tanggal mulai/berakhir dan judul acara. Perhatikan jenis setiap kolom.\"\n        },\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID adalah string yang dihasilkan secara acak yang berguna untuk pengenal unik dan kunci tautan.\",\n        \"Lookups return data from related tables.\": \"Pencarian mengembalikan data dari tabel terkait.\",\n        \"Use reference columns to relate data in different tables.\": \"Gunakan kolom referensi untuk menghubungkan data dalam tabel yang berbeda.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Anda dapat memilih dari widget yang tersedia untuk Anda di dropdown, atau menyematkan widget Anda sendiri dengan memberikan URL lengkapnya.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Rumus mendukung banyak fungsi Excel, sintaksis Python lengkap, dan menyertakan Asisten AI yang bermanfaat.\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Buat formulir sederhana langsung di Grist dan bagikan dalam satu klik dengan widget baru kami. {{learnMoreButton}}\",\n        \"Forms are here!\": \"Formulir ada di sini!\",\n        \"Learn more\": \"Pelajari lebih lanjut\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Aturan ini diterapkan setelah semua aturan kolom diproses, jika berlaku.\",\n        \"Example: {{example}}\": \"Contoh: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"Filter nilai dropdown yang ditampilkan dengan suatu kondisi.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Widget komunitas dibuat dan dikelola oleh anggota komunitas Grist.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Membuat kolom terbalik di tabel target yang dapat diedit dari kedua ujungnya.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Batasan ini terjadi ketika salah satu ujung referensi dua arah dikonfigurasikan sebagai Referensi tunggal.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Untuk memperbolehkan beberapa penugasan, ubah jenis kolom Referensi ke Daftar Referensi.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Batasan ini terjadi ketika satu kolom dalam referensi dua arah memiliki tipe Referensi.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Untuk mengizinkan beberapa penugasan, ubah jenis kolom yang dirujuk ke Daftar Referensi.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Referensi dua arah saat ini tidak didukung untuk kolom Rumus atau Rumus Pemicu\",\n        \"The preview below this header shows how the selected user will see this document\": \"Pratinjau di bawah header ini menunjukkan bagaimana pengguna yang dipilih akan melihat dokumen ini\",\n        \"[Learn more.]({{link}})\": \"[Pelajari lebih lanjut.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Tabel ringkasan hanya dapat berisi kolom rumus.\",\n        \"Manage users and resources in a Grist installation.\": \"Kelola pengguna dan sumber daya dalam instalasi Grist.\",\n        \"The new Grist Assistant is here!\": \"Asisten Grist yang baru telah hadir!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Rumus mendukung banyak fungsi Excel dan sintaksis Python lengkap.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Membuat kolom Daftar Referensi baru di tabel target, dengan kolom ini dan kolom target yang dapat diedit dan disinkronkan.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Penyimpanan internal berarti semua lampiran disimpan dalam file SQLite dokumen, sementara penyimpanan eksternal menunjukkan semua lampiran disimpan dalam penyimpanan eksternal yang sama.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Ini memungkinkan Anda menambahkan lampiran yang hilang dari penyimpanan eksternal, misalnya dalam dokumen yang diimpor. Hanya arsip lampiran .tar yang diunduh dari Grist yang dapat diunggah di sini.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Pahami, modifikasi, dan kerjakan data dan rumus Anda dengan bantuan Asisten AI baru dari Grist!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Formulir ini dibuat oleh pengguna Grist, dan tidak didukung oleh Grist Labs, Inc. atau pihak mana pun yang menyediakan layanan ini. Demi keamanan Anda, jangan mengirimkan kata sandi melalui formulir ini, dan berhati-hatilah saat mengeklik tautan yang disematkan. Laporkan formulir berbahaya ke [{{mail}}](mailto:{{mail}}).\",\n        \"Set the maximum number of lines for multi-line text.\": \"Tetapkan jumlah baris maksimum untuk teks multi-baris.\",\n        \"Comments are here!\": \"Komentar ada di sini!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Anda dapat menambahkan komentar ke sel, membalas rangkaian komentar, dan @-menyebut kolaborator.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"Jika dicentang, nilai default bidang ini dapat diisi sebelumnya dari URL menggunakan parameter kueri.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"Dengan saran, pengguna membuat perubahan pada salinan pribadi tanpa memodifikasi dokumen asli, lalu mengirimkan saran ini untuk ditinjau oleh pemilik dokumen sebelum integrasi.\",\n        \"Unpin to hide the button while keeping the filter.\": \"Lepaskan pin untuk menyembunyikan tombol sambil tetap menampilkan filter.\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"KETERANGAN\",\n        \"Set description\": \"Atur deskripsi\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"Tutup Panel Kreator\",\n        \"Open creator panel\": \"Buka panel kreator\",\n        \"Creator panel (right panel)\": \"Panel kreator (panel kanan)\",\n        \"Document header\": \"Header dokumen\",\n        \"Main content\": \"Konten utama\",\n        \"Main navigation and document settings (left panel)\": \"Navigasi utama dan pengaturan dokumen (panel kiri)\",\n        \"Close navigation panel (left panel)\": \"Tutup panel navigasi (panel kiri)\",\n        \"Open navigation panel (left panel)\": \"Buka panel navigasi (panel kiri)\"\n    },\n    \"ColumnTitle\": {\n        \"Add description\": \"Tambahkan deskripsi\",\n        \"COLUMN ID: \": \"ID KOLOM: \",\n        \"Cancel\": \"Batal\",\n        \"Column ID copied to clipboard\": \"ID kolom disalin ke papan klip\",\n        \"Column description\": \"Deskripsi kolom\",\n        \"Column label\": \"Label kolom\",\n        \"Provide a column label\": \"Berikan label kolom\",\n        \"Save\": \"Simpan\",\n        \"Close\": \"Tutup\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Mengerti\",\n        \"Unavailable Command\": \"Perintah Tidak Tersedia\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"Perintah menu {{action}} tidak tersedia di peramban ini. Anda masih dapat {{action}} dengan menggunakan pintasan keyboard {{shortcut}}.\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"Bersihkan bidang\",\n        \"Copy\": \"Salin\",\n        \"Copy anchor link\": \"Salin tautan jangkar\",\n        \"Cut\": \"Potong\",\n        \"Hide field\": \"Sembunyikan bidang\",\n        \"Paste\": \"Tempel\",\n        \"Comment\": \"Komentar\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Bersihkan antrean\",\n        \"Webhook settings\": \"Pengaturan webhook\",\n        \"Cleared webhook queue.\": \"Bersihkan antrean webhook.\",\n        \"Columns to check when update (separated by ;)\": \"Kolom yang perlu diperiksa saat memperbarui (dipisahkan dengan ;)\",\n        \"Enabled\": \"Diaktifkan\",\n        \"Event Types\": \"Jenis Hal\",\n        \"Memo\": \"Memo\",\n        \"Name\": \"Nama\",\n        \"Ready Column\": \"Kolom Siap\",\n        \"Removed webhook.\": \"Webhook dihapus.\",\n        \"Sorry, not all fields can be edited.\": \"Maaf, tidak semua bidang dapat diedit.\",\n        \"Status\": \"Status\",\n        \"URL\": \"URL\",\n        \"Webhook Id\": \"ID Webhook\",\n        \"Table\": \"Tabel\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Filter untuk perubahan pada kolom ini (ID dipisahkan dengan titik koma)\",\n        \"Header Authorization\": \"Otorisasi Header\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhook Tidak Tersedia dalam Salinan Dokumen yang Belum Disimpan\"\n    },\n    \"FormulaAssistant\": {\n        \"Ask the bot.\": \"Tanya pada bot.\",\n        \"Capabilities\": \"Kemampuan\",\n        \"Community\": \"Komunitas\",\n        \"Data\": \"Data\",\n        \"Formula Cheat Sheet\": \"Lembar Contekan Rumus\",\n        \"Formula Help. \": \"Bantuan Rumus. \",\n        \"Function List\": \"Daftar Fungsi\",\n        \"Grist's AI Assistance\": \"Bantuan AI Grist\",\n        \"Grist's AI Formula Assistance. \": \"Bantuan Formula AI Grist. \",\n        \"Need help? Our AI assistant can help.\": \"Butuh bantuan? Asisten AI kami siap membantu.\",\n        \"New Chat\": \"Obrolan Baru\",\n        \"Preview\": \"Pratinjau\",\n        \"Regenerate\": \"Diperbarui\",\n        \"Save\": \"Simpan\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Lihat {{helpFunction}} dan {{formulaCheat}} kami, atau kunjungi {{community}} kami untuk bantuan lebih lanjut.\",\n        \"Tips\": \"Kiat\",\n        \"AI Assistant\": \"Asisten AI\",\n        \"Apply\": \"Asisten AI\",\n        \"Cancel\": \"Batal\",\n        \"Clear conversation\": \"Bersihkan percakapan\",\n        \"Code view\": \"Tampilan kode\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Hai, saya Asisten Formula AI Grist.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Saya hanya bisa membantu dengan rumus. Saya tidak bisa membuat tabel, kolom, dan tampilan, atau menulis aturan akses.\",\n        \"Learn more\": \"Pelajari lebih lanjut\",\n        \"Press Enter to apply suggested formula.\": \"Tekan Enter untuk menerapkan rumus yang disarankan.\",\n        \"Sign Up for Free\": \"Daftar Gratis\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Daftar akun Grist gratis untuk mulai menggunakan Formula AI Assistant.\",\n        \"There are some things you should know when working with me:\": \"Ada beberapa hal yang harus Anda ketahui saat bekerja dengan saya:\",\n        \"What do you need help with?\": \"Anda butuh bantuan dalam hal apa?\",\n        \"Formula AI Assistant is only available for logged in users.\": \"Formula AI Assistant hanya tersedia untuk pengguna yang masuk.\",\n        \"For higher limits, contact the site owner.\": \"Untuk batasan yang lebih tinggi, hubungi pemilik situs.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Untuk batasan yang lebih tinggi, {{upgradeNudge}}.\",\n        \"You have used all available credits.\": \"Anda telah menggunakan semua kredit yang tersedia.\",\n        \"You have {{numCredits}} remaining credits.\": \"Anda memiliki sisa kredit {{numCredits}}.\",\n        \"upgrade to the Pro Team plan\": \"tingkatkan ke paket Tim Pro\",\n        \"upgrade your plan\": \"tingkatkan paket Anda\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Untuk bantuan lebih lanjut dengan rumus, lihat {{functionList}} dan {{formulaCheatSheet}} kami, atau kunjungi {{community}} kami untuk bantuan lebih lanjut.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Saat Anda berbicara dengan saya, pertanyaan dan struktur dokumen Anda (terlihat di {{codeView}}) dikirim ke OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Bicaralah kepada saya seperti manusia. Tidak perlu menyebutkan nama tabel dan kolom. Misalnya, Anda bisa bertanya \\\"Tolong hitung total tagihannya.\\\"\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Klik untuk menyisipkan\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"Selamat Datang kembali\",\n        \"You can always switch sites using the account menu.\": \"Anda selalu dapat berpindah situs menggunakan menu akun.\",\n        \"You have access to the following Grist sites.\": \"Anda memiliki akses ke situs Grist berikut.\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"KETERANGAN\"\n    },\n    \"UserManager\": {\n        \"Add {{member}} to your team\": \"Tambahkan {{member}} ke tim Anda\",\n        \"Allow anyone with the link to open.\": \"Izinkan siapa saja yang memiliki tautan untuk membukanya.\",\n        \"Anyone with link \": \"Siapa pun yang memiliki tautan \",\n        \"Cancel\": \"Batal\",\n        \"Close\": \"Tutup\",\n        \"Collaborator\": \"Kolaborator\",\n        \"Confirm\": \"Konfirmasi\",\n        \"Copy link\": \"Salin tautan\",\n        \"Create a team to share with more people\": \"Buat tim untuk berbagi dengan lebih banyak orang\",\n        \"Grist support\": \"Dukungan Grist\",\n        \"Guest\": \"Tamu\",\n        \"Invite multiple\": \"Undang banyak\",\n        \"Invite people to {{resourceType}}\": \"Undang orang ke {{resourceType}}\",\n        \"Link copied to clipboard\": \"Tautan disalin ke papan klip\",\n        \"Manage members of team site\": \"Kelola anggota situs tim\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Tidak ada akses default yang memperbolehkan akses          diberikan ke dokumen atau ruang kerja individual, dan bukan ke situs tim lengkap.\",\n        \"Off\": \"Mati\",\n        \"On\": \"Aktif\",\n        \"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}}.\": \"Setelah Anda menghapus akses Anda sendiri,             Anda tidak akan bisa mendapatkannya kembali tanpa bantuan              dari orang lain yang memiliki akses memadai ke {{name}}.\",\n        \"Open Access Rules\": \"Aturan Akses Terbuka\",\n        \"Outside collaborator\": \"Kolaborator luar\",\n        \"Public access\": \"Akses publik\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Akses publik diwarisi dari {{parent}}. Untuk menghapus, atur opsi 'Warisi akses' ke 'Tidak Ada'.\",\n        \"Public access: \": \"Akses publik: \",\n        \"Remove my access\": \"Hapus akses saya\",\n        \"Save & \": \"Menyimpan & \",\n        \"Team member\": \"Anggota tim\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Pengguna mewarisi izin dari {{parent})}. Untuk menghapus,           atur opsi 'Warisi akses' ke 'Tidak Ada'.\",\n        \"User may not modify their own access.\": \"Pengguna tidak dapat mengubah aksesnya sendiri.\",\n        \"Your role for this team site\": \"Peran Anda untuk situs tim ini\",\n        \"Your role for this {{resourceType}}\": \"Peran Anda untuk {{resourceType}} ini\",\n        \"free collaborator\": \"kolaborator gratis\",\n        \"guest\": \"tamu\",\n        \"member\": \"anggota\",\n        \"team site\": \"situs tim\",\n        \"{{collaborator}} limit exceeded\": \"Batas {{collaborator}} terlampaui\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} dari {{limitTop}} {{collaborator}}\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Tidak ada akses default yang memperbolehkan akses diberikan ke dokumen atau ruang kerja individual, dan bukan ke situs tim lengkap.\",\n        \"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}}.\": \"Setelah Anda menghapus akses Anda sendiri, Anda tidak akan bisa mendapatkannya kembali tanpa bantuan dari orang lain yang memiliki akses memadai ke {{resourceType}}.\",\n        \"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.\": \"Pengguna memiliki akses tampilan ke {{resource}} yang dihasilkan dari akses yang diatur secara manual ke sumber daya di dalamnya. Jika dihapus di sini, pengguna ini akan kehilangan akses ke sumber daya di dalamnya.\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Pengguna mewarisi izin dari {{parent}}. Untuk menghapus, atur opsi 'Warisi akses' ke 'Tidak Ada'.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Anda akan menghapus akses Anda sendiri ke {{resourceType}} ini\",\n        \"Inherit access: \": \"Mewarisi akses: \",\n        \"Access overview\": \"Ikhtisar akses\",\n        \"Share it publicly\": \"Bagikan secara publik\",\n        \"Verify your sensitive data before sharing publicly\": \"Verifikasi data sensitif Anda sebelum membagikannya secara publik\",\n        \"Your {{resourceType}} will be accessible to anyone with the link, whether shared directly or found through a search engine. \\n Ensure that your {{resourceType}} does not contain sensitive data before sharing.\": \"{{resourceType}} Anda akan dapat diakses oleh siapa pun yang memiliki tautan tersebut, baik yang dibagikan secara langsung maupun yang ditemukan melalui mesin pencari. \\n Pastikan {{resourceType}} Anda tidak berisi data sensitif sebelum dibagikan.\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Cari semua halaman\",\n        \"Search all tables\": \"Cari semua tabel\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Cari\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"Menampilkan {{displayedCount}} dari {{totalCount}} item. Cari lebih banyak.\"\n    },\n    \"SupportGristNudge\": {\n        \"Close\": \"Tutup\",\n        \"Contribute\": \"Berkontribusi\",\n        \"Help Center\": \"Pusat Bantuan\",\n        \"Opt in to Telemetry\": \"Berlangganan Telemetri\",\n        \"Opted In\": \"Ikut serta\",\n        \"Support Grist\": \"Dukung Grist\",\n        \"Support Grist page\": \"Halaman dukungan Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Terima kasih! Kepercayaan dan dukungan Anda sangat kami hargai. Berhenti berlangganan kapan saja melalui {{link}} di menu pengguna.\",\n        \"Admin Panel\": \"Panel Admin\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"Halaman Sponsor GitHub\",\n        \"Help Center\": \"Pusat Bantuan\",\n        \"Home\": \"Beranda\",\n        \"Manage Sponsorship\": \"Kelola Sponsorship\",\n        \"Opt in to Telemetry\": \"Berlangganan Telemetri\",\n        \"Opt out of Telemetry\": \"Berhenti berlangganan Telemetri\",\n        \"Sponsor Grist Labs on GitHub\": \"Sponsor Grist Labs di GitHub\",\n        \"Support Grist\": \"Dukung Grist\",\n        \"Telemetry\": \"Telemetri\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Instans ini telah diaktifkan untuk telemetri. Hanya administrator situs yang memiliki izin untuk mengubahnya.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Instansi ini tidak menerima telemetri. Hanya administrator situs yang memiliki izin untuk mengubahnya.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Kami hanya mengumpulkan statistik penggunaan, seperti yang dirinci dalam {{link}} kami, tidak pernah mendokumentasikan konten.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Anda dapat berhenti berlangganan telemetri kapan saja dari halaman ini.\",\n        \"You have opted in to telemetry. Thank you!\": \"Anda telah memilih telemetri. Terima kasih!\",\n        \"You have opted out of telemetry.\": \"Anda telah memilih keluar dari telemetri.\",\n        \"Sponsor\": \"Sponsor\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Perangkat lunak Grist dikembangkan oleh Grist Labs, yang menawarkan paket hosting gratis dan berbayar. Kami juga menyediakan kode Grist di bawah lisensi OSS standar yang gratis dan terbuka (Apache 2.0) di {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Dukung Grist dengan memilih ikut serta dalam telemetri, yang membantu kami memahami cara penggunaan produk, sehingga kami dapat memprioritaskan peningkatan di masa mendatang.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Kami adalah tim yang kecil dan penuh tekad. Dukungan Anda sangat berarti bagi kami. Dukungan Anda juga menunjukkan kepada orang lain bahwa ada komunitas yang gigih di balik produk ini.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Anda dapat mendukung pengembangan sumber terbuka Grist dengan mensponsori kami di {{link}} kami.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Tidak ada data\",\n        \"No row selected in {{title}}\": \"Tidak ada baris yang dipilih di {{title}}\",\n        \"Not all data is shown\": \"Tidak semua data ditampilkan\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Tutup Editor\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Memaksimalkan\",\n        \"Minimize\": \"Memperkecil\"\n    },\n    \"CardContextMenu\": {\n        \"Copy anchor link\": \"Salin tautan jangkar\",\n        \"Delete card\": \"Hapus kartu\",\n        \"Duplicate card\": \"Duplikat kartu\",\n        \"Insert card\": \"Masukkan kartu\",\n        \"Insert card above\": \"Masukkan kartu di atas\",\n        \"Insert card below\": \"Masukkan kartu di bawah ini\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Bidang tersembunyi\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"panggilan pelatihan gratis\",\n        \"Maybe Later\": \"Mungkin nanti\",\n        \"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.\": \"Dalam panggilan ini, kami akan meluangkan waktu untuk memahami kebutuhan Anda dan menyesuaikannya dengan kebutuhan Anda. Kami dapat menunjukkan dasar-dasar Grist, atau langsung mulai mengolah data Anda untuk membangun dasbor yang Anda butuhkan.\",\n        \"Schedule Call\": \"Jadwalkan Panggilan\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Jadwalkan {{freeCoachingCall}} Anda dengan anggota tim kami.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Anda juga dapat memeriksa {{ourWeeklyWebinars}} untuk mempelajari lebih lanjut tentang Grist.\",\n        \"our weekly webinars\": \"webinar mingguan kami\",\n        \"Maybe later\": \"Mungkin nanti\",\n        \"Free coaching call\": \"Konsultasi gratis\",\n        \"Grist 101\": \"Grist 101\",\n        \"Schedule call\": \"Jadwalkan panggilan\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"Anda juga dapat melihat webinar pengantar kami, {{ourWeeklyWebinars}}, yang dirancang untuk membantu pengguna baru                memahami dasar-dasar Grist.\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"Anda juga dapat melihat webinar pengantar kami, {{ourWeeklyWebinars}}, yang dirancang untuk membantu pengguna baru memahami dasar-dasar Grist.\"\n    },\n    \"FormView\": {\n        \"Publish\": \"Publikasikan\",\n        \"Publish your form?\": \"Publikasikan formulir Anda?\",\n        \"Unpublish\": \"Batalkan publikasi\",\n        \"Unpublish your form?\": \"Batalkan publikasi formulir Anda?\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Siapa pun yang memiliki tautan di bawah dapat melihat formulir kosong dan mengirimkan tanggapan.\",\n        \"Are you sure you want to reset your form?\": \"Apakah Anda yakin ingin mengatur ulang formulir Anda?\",\n        \"Code copied to clipboard\": \"Kode disalin ke papan klip\",\n        \"Copy code\": \"Salin kode\",\n        \"Copy link\": \"Salin tautan\",\n        \"Embed this form\": \"Sematkan formulir ini\",\n        \"Link copied to clipboard\": \"Tautan disalin ke papan klip\",\n        \"Preview\": \"Pratinjau\",\n        \"Reset\": \"Atur ulang\",\n        \"Reset form\": \"Atur ulang formulir\",\n        \"Save your document to publish this form.\": \"Simpan dokumen Anda untuk menerbitkan formulir ini.\",\n        \"Share\": \"Bagikan\",\n        \"Share this form\": \"Bagikan formulir ini\",\n        \"View\": \"Tampilan\",\n        \"# **Form Title**\": \"# **Judul Formulir**\",\n        \"Your form description goes here.\": \"Deskripsi formulir Anda ada di sini.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"Memublikasikan formulir Anda akan menghasilkan tautan berbagi. Siapa pun yang memiliki tautan tersebut dapat melihat formulir kosong dan mengirimkan respons.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Membatalkan penerbitan formulir akan menonaktifkan tautan berbagi sehingga pengguna yang mengakses formulir Anda melalui tautan itu akan melihat kesalahan.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Pengguna dibatasi untuk mengirimkan entri (catatan dalam tabel Anda) dan membaca nilai yang telah ditetapkan di bidang yang ditunjuk, seperti kolom referensi dan pilihan.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Formulir Anda telah dipublikasikan. Setiap perubahan akan aktif dan terlihat oleh pengguna yang memiliki akses ke formulir tersebut. Jika Anda ingin membuat perubahan dalam draf, batalkan publikasi formulir.\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Hapus\"\n    },\n    \"Menu\": {\n        \"Building blocks\": \"Blok bangunan\",\n        \"Columns\": \"Kolom\",\n        \"Copy\": \"Salinan\",\n        \"Cut\": \"Potong\",\n        \"Insert question above\": \"Masukkan pertanyaan di atas\",\n        \"Insert question below\": \"Masukkan pertanyaan di bawah ini\",\n        \"Paragraph\": \"Paragraf\",\n        \"Paste\": \"Tempel\",\n        \"Separator\": \"Pemisah\",\n        \"Unmapped fields\": \"Bidang yang belum dipetakan\",\n        \"Header\": \"Header\",\n        \"New question\": \"Pertanyaan baru\",\n        \"More\": \"Lebih\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Clear\": \"Bersih\",\n        \"Map fields\": \"Peta bidang\",\n        \"Mapped\": \"Dipetakan\",\n        \"Select all\": \"Pilih semua\",\n        \"Unmap fields\": \"Hapus peta bidang\",\n        \"Unmapped\": \"Tidak dipetakan\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Aturan bidang\",\n        \"Required field\": \"Bidang yang wajib diisi\",\n        \"Ascending\": \"Naik\",\n        \"Default\": \"Bawaan\",\n        \"Descending\": \"Turun\",\n        \"Field Format\": \"Format Bidang\",\n        \"Field Rules\": \"Aturan Bidang\",\n        \"Horizontal\": \"Horisontal\",\n        \"Options Alignment\": \"Penyelarasan Opsi\",\n        \"Options Sort Order\": \"Opsi Urutan Sortir\",\n        \"Radio\": \"Radio\",\n        \"Select\": \"Pilih\",\n        \"Vertical\": \"Vertikal\",\n        \"Accept value from URL\": \"Setujui nilai dari URL\",\n        \"Hidden field\": \"Bidang tersembunyi\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"Parameter URL:\\n{{colId}}=NILAI\",\n        \"Options limit\": \"Batasan opsi\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Beberapa kolom yang diperlukan tidak dipetakan\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Untuk menggunakan widget ini, harap petakan semua kolom non-opsional dari panel pembuat di sebelah kanan.\",\n        \"Some required columns are hidden by access rules\": \"Beberapa kolom yang diperlukan disembunyikan oleh aturan akses\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Untuk menggunakan widget ini, semua kolom yang dipetakan harus terlihat. Silakan hubungi pemilik dokumen atau ubah aturan akses.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Bangun formulir Anda sendiri\",\n        \"Powered by\": \"Didukung oleh\",\n        \"Powered by Grist\": \"Didukung oleh Grist\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Kesalahan\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"Ups! Formulir yang Anda cari tidak ada.\",\n        \"Oops! This form is no longer published.\": \"Ups! Formulir ini tidak lagi diterbitkan.\",\n        \"There was a problem loading the form.\": \"Terjadi masalah saat memuat formulir.\",\n        \"You don't have access to this form.\": \"Anda tidak memiliki akses ke formulir ini.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Terjadi kesalahan saat mengirimkan formulir Anda. Silakan coba lagi.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Formulir Telah Dikirim\",\n        \"Thank you! Your response has been recorded.\": \"Terima kasih! Tanggapan Anda telah dicatat.\",\n        \"Submit new response\": \"Kirimkan respons baru\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"30 hari terakhir\",\n        \"Last 7 days\": \"7 hari terakhir\",\n        \"Last week\": \"Pekan lalu\",\n        \"Next 7 days\": \"7 hari ke depan\",\n        \"This month\": \"Bulan ini\",\n        \"This week\": \"Pekan ini\",\n        \"This year\": \"Tahun ini\",\n        \"Today\": \"Hari ini\"\n    },\n    \"AdminPanel\": {\n        \"Auto-check weekly\": \"Pemeriksaan otomatis mingguan\",\n        \"Admin Panel\": \"Panel Admin\",\n        \"Current\": \"Saat ini\",\n        \"Current version of Grist\": \"Versi Grist saat ini\",\n        \"Help us make Grist better\": \"Bantu kami membuat Grist lebih baik\",\n        \"Home\": \"Rumah\",\n        \"Sponsor\": \"Sponsor\",\n        \"Support Grist\": \"Dukung Grist\",\n        \"Support Grist Labs on GitHub\": \"Dukung Grist Labs di GitHub\",\n        \"Telemetry\": \"Telemetri\",\n        \"Version\": \"Versi\",\n        \"Auto-check when this page loads\": \"Periksa otomatis saat halaman ini dimuat\",\n        \"Check now\": \"Periksa sekarang\",\n        \"Checking for updates...\": \"Memeriksa pembaruan...\",\n        \"Error\": \"Kesalahan\",\n        \"Error checking for updates\": \"Kesalahan saat memeriksa pembaruan\",\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 menandatangani kuki sesi pengguna dengan kunci rahasia. Harap tetapkan kunci ini melalui variabel lingkungan GRIST_SESSION_SECRET. Grist akan kembali ke pengaturan default yang dikodekan secara permanen jika tidak ditetapkan. Kami mungkin akan menghapus pemberitahuan ini di masa mendatang karena ID sesi yang dibuat sejak v1.1.16 secara inheren aman secara kriptografis.\",\n        \"Grist is up to date\": \"Grist sudah diperbarui\",\n        \"Grist releases are at \": \"Rilis Grist ada di \",\n        \"Last checked {{time}}\": \"Terakhir diperiksa {{time}}\",\n        \"Learn more.\": \"Pelajari lebih lanjut.\",\n        \"Newer version available\": \"Versi yang lebih baru tersedia\",\n        \"No information available\": \"Tidak ada informasi tersedia\",\n        \"OK\": \"OK\",\n        \"Sandbox settings for data engine\": \"Pengaturan kotak pasir untuk mesin data\",\n        \"Sandboxing\": \"Kotak pasir\",\n        \"Security Settings\": \"Pengaturan Keamanan\",\n        \"Updates\": \"Pembaruan\",\n        \"unconfigured\": \"tidak dikonfigurasi\",\n        \"unknown\": \"tidak diketahui\",\n        \"Administrator Panel Unavailable\": \"Panel Administrator Tidak Tersedia\",\n        \"Authentication\": \"Autentikasi\",\n        \"Check failed.\": \"Pemeriksaan gagal.\",\n        \"Check succeeded.\": \"Pemeriksaan berhasil.\",\n        \"Current authentication method\": \"Metode otentikasi saat ini\",\n        \"Details\": \"Rincian\",\n        \"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 memungkinkan konfigurasi berbagai jenis autentikasi, termasuk SAML dan OIDC.     Kami menyarankan untuk mengaktifkan salah satu opsi ini jika Grist dapat diakses melalui jaringan atau tersedia     untuk banyak orang.\",\n        \"No fault detected.\": \"Tidak ada kesalahan yang terdeteksi.\",\n        \"Notes\": \"Catatan\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Atau, sebagai cadangan, Anda dapat mengatur: {{bootKey}} di lingkungan dan mengunjungi: {{url}}\",\n        \"Results\": \"Hasil\",\n        \"Self Checks\": \"Pemeriksaan Mandiri\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"Anda tidak memiliki akses ke panel administrator.\\nSilakan masuk sebagai administrator.\",\n        \"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 memungkinkan konfigurasi berbagai jenis autentikasi, termasuk SAML dan OIDC. Kami menyarankan untuk mengaktifkan salah satu opsi ini jika Grist dapat diakses melalui jaringan atau tersedia untuk banyak orang.\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"Grist menandatangani kuki sesi pengguna dengan kunci rahasia. Harap tetapkan kunci ini melalui variabel lingkungan GRIST_SESSION_SECRET. Grist akan kembali ke pengaturan default yang dikodekan secara permanen jika tidak ditetapkan. Kami mungkin akan menghapus pemberitahuan ini di masa mendatang karena ID sesi yang dibuat sejak v1.1.16 secara inheren aman secara kriptografis.\",\n        \"Key to sign sessions with\": \"Kunci untuk menandatangani sesi dengan\",\n        \"Session Secret\": \"Rahasia Sesi\",\n        \"Enable Grist Enterprise\": \"Aktifkan Grist Enterprise\",\n        \"Enterprise\": \"Enterprise\",\n        \"checking\": \"memeriksa\",\n        \"Audit Logs\": \"Catatan Audit\",\n        \"Contact us\": \"Hubungi kami\",\n        \"Log Streaming\": \"Streaming Log\",\n        \"New, Enterprise\": \"Baru, Perusahaan\",\n        \"Off\": \"Mati\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} lebih\",\n        \"{{count}} admin accounts_one\": \"{{count}} akun admin\",\n        \"{{count}} admin accounts_other\": \"{{count}} akun admin\",\n        \"On\": \"Di\",\n        \"Grist Instance\": \"Contoh Grist\",\n        \"No record of last version check\": \"Tidak ada catatan pemeriksaan versi terakhir\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Anda dapat mengatur streaming peristiwa audit dari Grist ke sistem manajemen informasi dan peristiwa keamanan (SIEM) eksternal jika Anda mengaktifkan Grist Enterprise. {{contactUsLink}} untuk mempelajari lebih lanjut.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Pemeriksaan otomatis dinonaktifkan. Atur variabel lingkungan GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING ke \\\"true\\\" untuk mengaktifkannya.\",\n        \"auth error\": \"kesalahan autentikasi\",\n        \"configured\": \"dikonfigurasi\",\n        \"default\": \"bawaan\",\n        \"more...\": \"lebih...\",\n        \"no authentication\": \"tidak ada otentikasi\",\n        \"unavailable\": \"tidak tersedia\",\n        \"Admin account not found\": \"Akun admin tidak ditemukan\",\n        \"Administrative accounts\": \"Akun administratif\",\n        \"The users with administrative accounts\": \"Pengguna dengan akun administratif\",\n        \"Version {{versionNumber}}\": \"Versi {{versionNumber}}\",\n        \"no admin accounts\": \"tidak ada akun admin\",\n        \"Are you sure you want to restart Grist?\": \"Apakah Anda yakin ingin memulai ulang Grist?\",\n        \"Grist is running in an environment that doesn't support restarting from the admin panel.\": \"Grist berjalan di lingkungan yang tidak mendukung restart dari panel admin.\",\n        \"Restart\": \"Mulai ulang\",\n        \"Restart Grist\": \"Mulai Ulang Grist\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"Muat ulang Grist untuk menerapkan perubahan yang tertunda atau menyelesaikan masalah.\",\n        \"Restart Grist?\": \"Muat ulang Grist?\",\n        \"Restarting Grist...\": \"Memulai ulang Grist...\",\n        \"This will apply any pending changes and briefly interrupt access for all users.\": \"Ini akan menerapkan semua perubahan yang tertunda dan akan menghentikan akses untuk semua pengguna dalam waktu singkat.\",\n        \"You can still restart Grist manually.\": \"Anda masih dapat memulai ulang Grist secara manual.\",\n        \"error in {{provider}}: {{verdict}}\": \"kesalahan di {{provider}}: {{verdict}}\",\n        \"Please restart Grist manually.\": \"Silakan mulai ulang Grist secara manual.\",\n        \"Restart Grist to apply pending changes.\": \"Restart Grist untuk menerapkan perubahan yang tertunda.\",\n        \"Restart unavailable\": \"Restart tidak tersedia\"\n    },\n    \"GridViewMenusDateHelpers\": {\n        \"Day of week\": \"Hari dalam sepekan\",\n        \"Day of week (abbrev)\": \"Hari dalam sepekan (singkatan)\",\n        \"Day of week (full)\": \"Hari dalam sepekan (penuh)\",\n        \"Day of week (numeric)\": \"Hari dalam sepekan (numerik)\",\n        \"Is weekend?\": \"Apakah akhir pekan?\",\n        \"Week\": \"Pekan\",\n        \"Week of year\": \"Pekan dalam setahun\",\n        \"12-hour format\": \"Format 12 jam\",\n        \"24-hour format\": \"Format 24 jam\",\n        \"AM\": {\n            \"PM\": \"AM/PM\"\n        },\n        \"Calendar\": \"Kalender\",\n        \"Date helpers…\": \"Pembantu tanggal…\",\n        \"Day\": \"Hari\",\n        \"Day of month\": \"Hari dalam bulan\",\n        \"Days since\": \"Hari sejak\",\n        \"Days until\": \"Hari sampai\",\n        \"Default\": \"Bawaan\",\n        \"End of\": \"Akhir dari\",\n        \"Full date\": \"Tanggal lengkap\",\n        \"Full name with year\": \"Nama lengkap dengan tahun\",\n        \"Hour\": \"Jam\",\n        \"Intervals\": \"Interval\",\n        \"Minute\": \"Menit\",\n        \"Month\": \"Bulan\",\n        \"Months since\": \"Bulan sejak\",\n        \"Months until\": \"Bulan sampai\",\n        \"Name only\": \"Nama saja\",\n        \"Number only\": \"Hanya angka\",\n        \"Quarter\": \"Seperempat\",\n        \"Quick Picks\": \"Pilihan Cepat\",\n        \"Relative\": \"Relatif\",\n        \"Short with year\": \"Urut dengan tahun\",\n        \"Sortable\": \"Dapat diurutkan\",\n        \"Start of\": \"Awal dari\",\n        \"Time\": \"Waktu\",\n        \"Time bucket\": \"Ember waktu\",\n        \"Year\": \"Tahun\",\n        \"Years since\": \"Tahun sejak\",\n        \"Years until\": \"Sampai tahun\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Bersih\",\n        \"Map fields\": \"Peta bidang\",\n        \"Mapped\": \"Dipetakan\",\n        \"Select all\": \"Pilih semua\",\n        \"Unmap fields\": \"Hapus peta bidang\",\n        \"Unmapped\": \"Tidak dipetakan\",\n        \"Hide {{label}}\": \"Sembunyikan {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Sembunyikan {{label}} (mode batch)\",\n        \"Unmap {{label}}\": \"Hapus peta {{label}}\",\n        \"Unmap {{label}} (batch mode)\": \"Hapus peta {{label}} (mode batch)\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Sisipkan bagian di atas\",\n        \"Insert section below\": \"Sisipkan bagian di bawah ini\",\n        \"## **Header**\": \"## **Header**\",\n        \"Description\": \"Keterangan\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Batal\",\n        \"Choose a name and url for your team site\": \"Pilih nama dan url untuk situs tim Anda\",\n        \"Create site\": \"Buat situs\",\n        \"Domain name is invalid\": \"Nama domain tidak valid\",\n        \"Domain name is required\": \"Nama domain diperlukan\",\n        \"Go to your site\": \"Kunjungi situs Anda\",\n        \"Team name\": \"Nama tim\",\n        \"Team name is required\": \"Nama tim wajib diisi\",\n        \"Team site created\": \"Situs tim dibuat\",\n        \"Team url\": \"URL tim\",\n        \"Work as a Team\": \"Bekerja sebagai Tim\",\n        \"Billing is not supported in grist-core\": \"Penagihan tidak didukung di grist-core\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Duplikat widget\",\n        \"Duplicate widgets\": \"Duplikat widget\",\n        \"Active\": \"Aktif\",\n        \"Create new page\": \"Buat halaman baru\"\n    },\n    \"commandList\": {\n        \"Duplicate the currently active viewsection\": \"Gandakan bagian tampilan yang sedang aktif\",\n        \"Duplicate the currently selected record(s)\": \"Gandakan rekaman yang dipilih saat ini\",\n        \"When in the search bar, close it and focus the current match\": \"Saat berada di bilah pencarian, tutup dan fokuskan kecocokan saat ini\",\n        \"Show accessibility options\": \"Tampilkan opsi aksesibilitas\",\n        \"Activate assistant\": \"Aktifkan asisten\",\n        \"Add a new viewsection to the currently active view\": \"Tambahkan bagian tampilan baru ke tampilan yang sedang aktif\",\n        \"Adds all elements above the cursor to the selected range\": \"Menambahkan semua elemen di atas kursor ke rentang yang dipilih\",\n        \"Adds all elements below the cursor to the selected range\": \"Menambahkan semua elemen di bawah kursor ke rentang yang dipilih\",\n        \"Adds all elements to the left of the cursor to the selected range\": \"Menambahkan semua elemen di sebelah kiri kursor ke rentang yang dipilih\",\n        \"Adds all elements to the right of the cursor to the selected range\": \"Menambahkan semua elemen di sebelah kanan kursor ke rentang yang dipilih\",\n        \"Adds the currently selected column(ascending) to the current view's sort spec\": \"Menambahkan kolom yang dipilih saat ini (naik) ke spesifikasi pengurutan tampilan saat ini\",\n        \"Adds the currently selected column(descending) to the current view's sort spec\": \"Menambahkan kolom yang dipilih saat ini (turun) ke spesifikasi jenis tampilan saat ini\",\n        \"Adds the element above the cursor to the selected range\": \"Menambahkan elemen di atas kursor ke rentang yang dipilih\",\n        \"Adds the element below the cursor to the selected range\": \"Menambahkan elemen di bawah kursor ke rentang yang dipilih\",\n        \"Adds the element to the left of the cursor to the selected range\": \"Menambahkan elemen di sebelah kiri kursor ke rentang yang dipilih\",\n        \"Adds the element to the right of the cursor to the selected range\": \"Menambahkan elemen di sebelah kanan kursor ke rentang yang dipilih\",\n        \"Clear the selected columns\": \"Bersihkan kolom yang dipilih\",\n        \"Clears the current copy selection, if any\": \"Bersihkan pilihan salinan saat ini, jika ada\",\n        \"Clears the currently selected cells\": \"Membersihkan sel yang sedang dipilih\",\n        \"Clears the section links in the current view\": \"Bersihkan tautan bagian dalam tampilan saat ini\",\n        \"Clears the section links in the current viewsection\": \"Bersihkan tautan bagian dalam tampilan bagian saat ini\",\n        \"Collapse the currently active viewsection\": \"Ciutkan bagian tampilan yang sedang aktif\",\n        \"Convert the selected columns from formula to data\": \"Ubah kolom yang dipilih dari rumus menjadi data\",\n        \"Copy anchor link\": \"Salin tautan jangkar\",\n        \"Copy current selection to clipboard\": \"Salin pilihan saat ini ke papan klip\",\n        \"Copy current selection to clipboard including headers\": \"Salin pilihan saat ini ke papan klip termasuk header\",\n        \"Creates form for active table\": \"Membuat formulir untuk tabel aktif\",\n        \"Cut current selection to clipboard\": \"Potong pilihan saat ini ke papan klip\",\n        \"Delete collapsed viewsection\": \"Hapus tampilan bagian yang diciutkan\",\n        \"Delete the currently active viewsection\": \"Hapus bagian tampilan yang sedang aktif\",\n        \"Delete the currently selected columns\": \"Hapus kolom yang dipilih saat ini\",\n        \"Delete the currently selected record(s)\": \"Hapus rekaman yang dipilih saat ini\",\n        \"Detach active editor\": \"Lepaskan editor aktif\",\n        \"Discard changes to a cell value\": \"Buang perubahan pada nilai sel\",\n        \"Display Grist documentation\": \"Tampilkan dokumentasi Grist\",\n        \"Display shortcuts pane\": \"Tampilkan panel pintasan\",\n        \"Edit label of the currently-selected field\": \"Edit label bidang yang saat ini dipilih\",\n        \"Edit record layout\": \"Edit tata letak rekaman\",\n        \"Enter text into currently-selected cell and start editing\": \"Masukkan teks ke dalam sel yang sedang dipilih dan mulai mengedit\",\n        \"Enters section linking mode in the current view\": \"Memasuki mode penautan bagian dalam tampilan saat ini\",\n        \"Exits section linking mode in the current view\": \"Keluar dari mode penautan bagian dalam tampilan saat ini\",\n        \"Expand collapsed viewsection\": \"Perluas tampilan yang diciutkan\",\n        \"Fills current selection with the contents of the top row in the selection\": \"Mengisi pilihan saat ini dengan isi baris teratas dalam pilihan\",\n        \"Find\": \"Temukan\",\n        \"Find next occurrence\": \"Temukan kejadian berikutnya\",\n        \"Find previous occurrence\": \"Temukan kejadian sebelumnya\",\n        \"Finish editing a cell and save without moving to next record\": \"Selesaikan pengeditan sel dan simpan tanpa pindah ke rekaman berikutnya\",\n        \"Finish editing a cell, saving the value\": \"Selesai mengedit sel, simpan nilainya\",\n        \"Focus next page panel or widget\": \"Fokus panel halaman berikutnya atau widget\",\n        \"Focus previous page panel or widget\": \"Fokus panel halaman sebelumnya atau widget\",\n        \"Freeze or unfreeze selected columns\": \"Bekukan atau cairkan kolom yang dipilih\",\n        \"Hide the currently selected columns\": \"Sembunyikan kolom yang dipilih saat ini\",\n        \"Hide the currently selected fields\": \"Sembunyikan bidang yang dipilih saat ini\",\n        \"Insert a new column, after the currently selected one\": \"Sisipkan kolom baru, setelah kolom yang sedang dipilih\",\n        \"Insert a new column, before the currently selected one\": \"Masukkan kolom baru, sebelum kolom yang sedang dipilih\",\n        \"Insert a new record, after the currently selected one in an unsorted table\": \"Masukkan rekaman baru, setelah rekaman yang sedang dipilih di tabel yang tidak diurutkan\",\n        \"Insert a new record, before the currently selected one in an unsorted table\": \"Masukkan rekaman baru, sebelum rekaman yang sedang dipilih di tabel yang tidak diurutkan\",\n        \"Insert new column in default location\": \"Masukkan kolom baru di lokasi default\",\n        \"Insert the current date\": \"Masukkan tanggal saat ini\",\n        \"Insert the current date and time\": \"Masukkan tanggal dan waktu saat ini\",\n        \"Maximize the active section\": \"Memaksimalkan bagian aktif\",\n        \"Move down one page of records, or to next record in a card list\": \"Pindah ke bawah satu halaman catatan, atau ke catatan berikutnya dalam daftar kartu\",\n        \"Move down to the last record\": \"Pindah ke rekaman terakhir\",\n        \"Move downward five records\": \"Pindah lima catatan ke bawah\",\n        \"Move downward to next record or field\": \"Pindah ke bawah ke rekaman atau bidang berikutnya\",\n        \"Move left to the previous field\": \"Pindah ke kiri ke bidang sebelumnya\",\n        \"Move right to the next field\": \"Pindah ke kanan ke bidang berikutnya\",\n        \"Move to the first field or the beginning of a row\": \"Pindah ke bidang pertama atau awal baris\",\n        \"Move to the last field or the end of a row\": \"Pindah ke bidang terakhir atau akhir baris\",\n        \"Move to the next field, saving changes if editing a value\": \"Pindah ke bidang berikutnya, simpan perubahan jika mengedit nilai\",\n        \"Move to the previous field, saving changes if editing a value\": \"Pindah ke bidang sebelumnya, menyimpan perubahan jika mengedit nilai\",\n        \"Move up one page of records, or to previous record in a card list\": \"Pindah ke atas satu halaman catatan, atau ke catatan sebelumnya dalam daftar kartu\",\n        \"Move up to the first record\": \"Pindah ke rekaman pertama\",\n        \"Move upward five records\": \"Naik ke atas lima rekaman\",\n        \"Move upward to previous record or field\": \"Pindah ke atas ke rekaman atau bidang sebelumnya\",\n        \"Moves the cursor to the correct location\": \"Memindahkan kursor ke lokasi yang benar\",\n        \"Open Custom widget configuration screen\": \"Buka layar konfigurasi widget Kustom\",\n        \"Open comment thread\": \"Buka utas komentar\",\n        \"Open next page\": \"Buka halaman berikutnya\",\n        \"Open previous page\": \"Buka halaman sebelumnya\",\n        \"Opens document list\": \"Membuka daftar dokumen\",\n        \"Paste clipboard contents at cursor\": \"Tempelkan konten papan klip di kursor\",\n        \"Print currently selected page widget\": \"Cetak widget halaman yang dipilih saat ini\",\n        \"Push an undo action\": \"Dorong tindakan undo\",\n        \"Redo last action\": \"Redo tindakan terakhir\",\n        \"Rename the currently selected column\": \"Ubah nama kolom yang dipilih saat ini\",\n        \"Reverts the sections links to the saved links the current view\": \"Mengembalikan tautan bagian ke tautan tersimpan pada tampilan saat ini\",\n        \"Saves the sections links in the current view\": \"Menyimpan tautan bagian dalam tampilan saat ini\",\n        \"Selects all currently displayed cells\": \"Memilih semua sel yang sedang ditampilkan\",\n        \"Shortcut to data selection tab\": \"Pintasan ke tab pemilihan data\",\n        \"Shortcut to focus view tab if creator panel is open\": \"Pintasan untuk memfokuskan tab tampilan jika panel kreator terbuka\",\n        \"Shortcut to open document tab\": \"Pintasan untuk membuka tab dokumen\",\n        \"Shortcut to open field tab\": \"Pintasan untuk membuka tab bidang\",\n        \"Shortcut to open sort & filter menu\": \"Pintasan untuk membuka menu sortir & filter\",\n        \"Shortcut to open the left panel\": \"Pintasan untuk membuka panel kiri\",\n        \"Shortcut to open the right panel\": \"Pintasan untuk membuka panel kanan\",\n        \"Shortcut to open view tab\": \"Pintasan untuk membuka tab tampilan\",\n        \"Shortcut to sort & filter tab\": \"Pintasan untuk mengurutkan & memfilter tab\",\n        \"Show hidden columns\": \"Tampilkan kolom tersembunyi\",\n        \"Show raw data widget for table of currently selected page widget\": \"Tampilkan widget data mentah untuk tabel widget halaman yang sedang dipilih\",\n        \"Show the record card widget of the selected record\": \"Tampilkan widget kartu rekaman dari rekaman yang dipilih\",\n        \"Sort the view data by the currently selected field in ascending order\": \"Urutkan data tampilan berdasarkan bidang yang dipilih saat ini dalam urutan naik\",\n        \"Sort the view data by the currently selected field in descending order\": \"Urutkan data tampilan berdasarkan bidang yang dipilih saat ini dalam urutan menurun\",\n        \"Start editing the currently-selected cell\": \"Mulai mengedit sel yang saat ini dipilih\",\n        \"Toggle creator panel keyboard focus\": \"Alihkan fokus keyboard panel kreator\",\n        \"Toggle the currently selected checkbox or switch cell\": \"Alihkan kotak centang yang saat ini dipilih atau ganti sel\",\n        \"Undo last action\": \"Batalkan tindakan terakhir\",\n        \"Use the currently selected row as table headers\": \"Gunakan baris yang dipilih saat ini sebagai header tabel\",\n        \"When typed at the start of a cell, make this a formula column\": \"Saat diketik di awal sel, jadikan ini kolom rumus\",\n        \"showing a behavioral popup\": \"menampilkan popup perilaku\",\n        \"Filter this column by just this cell's value\": \"Filter kolom ini hanya berdasarkan nilai sel ini\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Hapus Kolom\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Tidak ada pilihan yang dikonfigurasi\",\n        \"No values in show column of referenced table\": \"Tidak ada nilai di kolom pertunjukan tabel yang direferensikan\",\n        \"Hide\": \"Sembunyikan\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"Kotak centang\",\n        \"Field Format\": \"Format Bidang\",\n        \"Switch\": \"Beralih\"\n    },\n    \"ChoiceEditor\": {\n        \"Error in dropdown condition\": \"Kesalahan dalam kondisi dropdown\",\n        \"No choices matching condition\": \"Tidak ada pilihan yang cocok dengan kondisi\",\n        \"No choices to select\": \"Tidak ada pilihan untuk dipilih\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"Kesalahan dalam kondisi dropdown\",\n        \"No choices matching condition\": \"Tidak ada pilihan yang cocok dengan kondisi\",\n        \"No choices to select\": \"Tidak ada pilihan untuk dipilih\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"Kondisi Dropdown\",\n        \"Invalid columns: {{colIds}}\": \"Kolom tidak valid: {{colIds}}\",\n        \"Set dropdown condition\": \"Tetapkan kondisi dropdown\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Masukkan kondisi.\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Kesalahan dalam kondisi dropdown\",\n        \"No choices matching condition\": \"Tidak ada pilihan yang cocok dengan kondisi\",\n        \"No choices to select\": \"Tidak ada pilihan untuk dipilih\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"Atur ulang\",\n        \"Search\": \"Cari\",\n        \"Select...\": \"Pilih...\",\n        \"Submit\": \"Ajukan\",\n        \"Submitting…\": \"Mengajukan…\",\n        \"Clear selection for: {{-inputLabel}}\": \"Bersihkan pilihan untuk: {{-inputLabel}}\"\n    },\n    \"RenameDocModal\": {\n        \"Reset icon\": \"Atur ulang ikon\",\n        \"Choose color\": \"Pilih warna\",\n        \"Choose icon\": \"Pilih ikon\",\n        \"Enter document name\": \"Masukkan nama dokumen\",\n        \"Icon\": \"Ikon\",\n        \"Name\": \"Nama\",\n        \"Rename and set icon\": \"Ganti nama dan atur ikon\"\n    },\n    \"ChoiceListEntry\": {\n        \"Reset\": \"Atur ulang\",\n        \"+{{count}} more_one\": \"+{{count}} lebih\",\n        \"+{{count}} more_other\": \"+{{count}} lebih\",\n        \"Edit\": \"Sunting\",\n        \"No choices configured\": \"Tidak ada pilihan yang dikonfigurasi\",\n        \"Cancel\": \"Batal\",\n        \"Save\": \"Simpan\"\n    },\n    \"CustomWidgetGallery\": {\n        \"Search\": \"Cari\",\n        \"(Missing info)\": \"(Info hilang)\",\n        \"Add widget\": \"Tambah widget\",\n        \"Add Your Own Widget\": \"Tambah Widget Anda Sendiri\",\n        \"Add a widget from outside this gallery.\": \"Tambah widget dari luar galeri ini.\",\n        \"Cancel\": \"Batal\",\n        \"Change widget\": \"Ubah widget\",\n        \"Choose custom widget\": \"Pilih widget khusus\",\n        \"Community Widget\": \"Widget Komunitas\",\n        \"Custom URL\": \"URL khusus\",\n        \"Developer:\": \"Pengembang:\",\n        \"Grist Widget\": \"Widget Grist\",\n        \"Last updated:\": \"Terakhir diperbarui:\",\n        \"Learn more about custom widgets\": \"Pelajari lebih lanjut tentang widget khusus\",\n        \"No matching widgets\": \"Tidak ada widget yang cocok\",\n        \"Widget URL\": \"URL Widget\"\n    },\n    \"widgetTypesMap\": {\n        \"Calendar\": \"Kalender\",\n        \"Card\": \"Kartu\",\n        \"Card List\": \"Daftar Kartu\",\n        \"Chart\": \"Diagram\",\n        \"Custom\": \"Kustom\",\n        \"Form\": \"Formulir\",\n        \"Table\": \"Tabel\"\n    },\n    \"TimingPage\": {\n        \"Average Time (s)\": \"Waktu Rata-rata (s)\",\n        \"Column ID\": \"ID Kolom\",\n        \"Formula timer\": \"Pengatur waktu rumus\",\n        \"Loading timing data. Don't close this tab.\": \"Memuat data waktu. Jangan tutup tab ini.\",\n        \"Max Time (s)\": \"Waktu Maksimum (dtk)\",\n        \"Number of Calls\": \"Jumlah Panggilan\",\n        \"Table ID\": \"ID Tabel\",\n        \"Total Time (s)\": \"Total Waktu (dtk)\"\n    },\n    \"DocTutorial\": {\n        \"Click to expand\": \"Klik untuk memperluas\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Ingin memulai ulang tutorial? Semua progres akan hilang.\",\n        \"End tutorial\": \"Akhiri tutorial\",\n        \"Finish\": \"Selesai\",\n        \"Next\": \"Berikut\",\n        \"Previous\": \"Sebelum\",\n        \"Restart\": \"Mulai ulang\"\n    },\n    \"OnboardingCards\": {\n        \"3 minute video tour\": \"Tur video 3 menit\",\n        \"Complete our basics tutorial\": \"Selesaikan tutorial dasar kami\",\n        \"Complete the tutorial\": \"Selesaikan tutorialnya\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Pelajari dasar-dasar kolom referensi, widget tertaut, jenis kolom, & kartu.\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Pelajari dasar-dasar kolom referensi, widget tertaut, jenis kolom, & kartu.\"\n    },\n    \"OnboardingPage\": {\n        \"Back\": \"Kembali\",\n        \"Discover Grist in 3 minutes\": \"Temukan Grist dalam 3 menit\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Ikuti tutorial Grist Basics secara langsung\",\n        \"Go to the tutorial!\": \"Buka tutorialnya!\",\n        \"Next step\": \"Langkah selanjutnya\",\n        \"Skip step\": \"Lewati langkah\",\n        \"Skip tutorial\": \"Lewati tutorial\",\n        \"Tell us who you are\": \"Beritahu kami siapa Anda\",\n        \"Type here\": \"Ketik di sini\",\n        \"Welcome\": \"Selamat datang\",\n        \"What brings you to Grist (you can select multiple)?\": \"Apa yang membawa Anda ke Grist (Anda dapat memilih beberapa)?\",\n        \"What is your role?\": \"Apa peran Anda?\",\n        \"What organization are you with?\": \"Anda tergabung dalam organisasi apa?\",\n        \"Your organization\": \"Organisasi Anda\",\n        \"Your role\": \"Peran Anda\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist mungkin terlihat seperti spreadsheet, tetapi fungsinya tidak selalu seperti itu. Temukan apa yang membedakan Grist.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"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}}).\": \"Kunci aktivasi digunakan untuk menjalankan Grist Enterprise setelah masa uji coba\\n30 hari berakhir. Dapatkan kunci aktivasi dengan [mendaftar di Grist\\nEnterprise]({{signupLink}}). Anda tidak memerlukan kunci aktivasi untuk menjalankan\\nGrist Core.\\n\\nPelajari lebih lanjut di [Pusat Bantuan]({{helpCenter}}) kami.\",\n        \"Disable Grist Enterprise\": \"Nonaktifkan Grist Enterprise\",\n        \"Enable Grist Enterprise\": \"Aktifkan Grist Enterprise\",\n        \"Grist Enterprise is **enabled**.\": \"Grist Enterprise **diaktifkan**.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Kunci aktivasi digunakan untuk menjalankan Grist Enterprise setelah masa uji coba\\n30 hari berakhir. Dapatkan kunci aktivasi dengan [menghubungi kami]({{contactLink}}) hari ini. Anda\\ntidak memerlukan kunci aktivasi untuk menjalankan Grist Core.\\n\\nPelajari lebih lanjut di [Pusat Bantuan]({{helpCenter}}) kami.\",\n        \"Activate\": \"Aktifkan\",\n        \"Activation key\": \"Kunci aktivasi\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Kunci aktivasi digunakan untuk menjalankan Grist Enterprise setelah masa uji coba\\n        30 hari berakhir. Dapatkan kunci aktivasi dengan [mendaftar di Grist\\n        Enterprise]({{signupLink}}). Anda tidak memerlukan kunci aktivasi untuk menjalankan\\n        Grist Core.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Langganan aktif diperlukan untuk terus menggunakan Grist Enterprise. Anda dapat\\nmengaktifkan langganan Anda dengan [mendaftar ke Grist Enterprise]({{signupLink}}) dan menempelkan\\nkunci aktivasi Anda di bawah ini.\",\n        \"Copy to clipboard\": \"Salin ke papan klip\",\n        \"Expiration date\": \"Kedaluwarsa\",\n        \"Installation ID copied to clipboard\": \"ID Instalasi disalin ke papan klip\",\n        \"Installation ID:\": \"ID Instalasi:\",\n        \"Installation seats\": \"Tempat instalasi\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Pelajari lebih lanjut di [Pusat Bantuan]({{helpCenter}}) kami.\",\n        \"Paste your activation key\": \"Tempel kunci aktivasi Anda\",\n        \"Plan name\": \"Nama rencana\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Untuk terus menggunakan Grist Enterprise, Anda perlu\\n                  [hubungi kami]({{signupLink}}) untuk mendapatkan kunci aktivasi Anda.\",\n        \"You are currently trialing Grist Enterprise.\": \"Anda saat ini sedang mencoba Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"Anda tidak memiliki langganan aktif.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Kunci aktivasi Anda telah kedaluwarsa karena melampaui batas.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Instansi Anda akan berada dalam mode **baca-saja** dalam **{{days}}** hari.\",\n        \"Your subscription expired on {{date}}.\": \"Langganan Anda berakhir pada {{date}}.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Masa uji coba Anda telah berakhir pada **{{expireAt}}**. Untuk terus menggunakan Grist Enterprise, Anda perlu\\n[mendaftar ke Grist Enterprise]({{signupLink}}) dan menempelkan kunci aktivasi Anda di bawah ini.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Hapus\",\n        \"Delete data and this widget.\": \"Hapus data dan widget ini.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Simpan data dan hapus widget. Tabel akan tetap tersedia di {{rawDataLink}}\",\n        \"Table {{tableName}} will no longer be visible\": \"Tabel {{tableName}} tidak akan terlihat lagi\",\n        \"Raw Data page\": \"Halaman Data Mentah\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Panel Admin\"\n    },\n    \"markdown\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Fungsi Markdown Baru\\n *\\n *      Kita bisa _menulis_ [Markdown biasa](https://markdownguide.org) *di dalam*\\n *      elemen Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"Tombolnya **mati**\",\n        \"The toggle is **on**\": \"Tombolnya **aktif**\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Fungsi Markdown Baru\\n *\\n *      Kita bisa _menulis_ [Markdown biasa](https://markdownguide.org) *di dalam*\\n *      elemen Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"Tombolnya **mati**\",\n        \"The toggle is **on**\": \"Tombolnya **aktif**\"\n    },\n    \"HomeIntroCards\": {\n        \"3 minute video tour\": \"Tur video 3 menit\",\n        \"Blank document\": \"Dokumen kosong\",\n        \"Find solutions and explore more resources\": \"Temukan solusi dan jelajahi lebih banyak sumber daya\",\n        \"Finish our basics tutorial\": \"Selesaikan tutorial dasar kami\",\n        \"Help center\": \"Pusat bantuan\",\n        \"Import file\": \"Impor berkas\",\n        \"Learn more\": \"Pelajari lebih lanjut\",\n        \"Start a new document\": \"Mulai dokumen baru\",\n        \"Templates\": \"Templat\",\n        \"Tutorial\": \"Tutorial\",\n        \"Webinars\": \"Seminar Web\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Add two-way reference\": \"Tambahkan referensi dua arah\",\n        \"Column\": \"Kolom\",\n        \"Delete\": \"Hapus\",\n        \"Delete column {{column}} in table {{table}}?\": \"Hapus kolom {{column}} di tabel {{table}}?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Ini adalah kebalikan dari kolom referensi {{column}} dalam tabel {{table}}.\",\n        \"Table\": \"Tabel\",\n        \"Two-way Reference\": \"Referensi Dua Arah\",\n        \"Delete two-way reference?\": \"Hapus referensi dua arah?\",\n        \"Target table\": \"Tabel target\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Ini akan menghapus kolom referensi {{refCol}} di tabel {{refTable}}. Kolom referensi {{myName}} akan tetap berada di tabel {{myTable}} saat ini.\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"Panel Admin\",\n        \"Close\": \"Tutup\",\n        \"Help Center\": \"Pusat Bantuan\",\n        \"Opt in to Telemetry\": \"Berlangganan Telemetri\",\n        \"Opted In\": \"Ikut serta\",\n        \"Support Grist\": \"Dukung Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Terima kasih! Kepercayaan dan dukungan Anda sangat kami hargai. Berhenti berlangganan kapan saja melalui {{link}} di menu pengguna.\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Bergabunglah dengan telemetri untuk membantu kami memahami cara penggunaan produk, sehingga kami dapat memprioritaskan peningkatan di masa mendatang.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Kami hanya mengumpulkan statistik penggunaan, sebagaimana tercantum dalam {{helpCenterLink}}, dan tidak pernah mendokumentasikan konten. Anda dapat berhenti berlangganan kapan saja melalui {{supportGristLink}} di menu pengguna.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Batal\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Setiap rekaman {{targetTable}} hanya dapat ditetapkan ke satu rekaman {{sourceTable}}.\",\n        \"Reassign\": \"Menugaskan kembali\",\n        \"Reassign to new {{sourceTable}} records.\": \"Tetapkan ulang ke rekaman {{sourceTable}} yang baru.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Tetapkan ulang ke rekaman {{sourceTable}} {{sourceName}}.\",\n        \"Record already assigned_one\": \"Rekaman sudah ditetapkan\",\n        \"Record already assigned_other\": \"Rekaman sudah ditetapkan\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"Rekaman {{targetTable}} {{targetName}} sudah ditetapkan ke rekaman {{sourceTable}} {{oldSourceName}}.\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Add destination\": \"Tambah tujuan\",\n        \"Add streaming destination\": \"Tambah tujuan streaming\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Yakin ingin menghapus tujuan streaming ini? Tindakan ini tidak dapat dibatalkan.\",\n        \"Cancel\": \"Batal\",\n        \"Delete\": \"Hapus\",\n        \"Delete streaming destination?\": \"Hapus tujuan streaming?\",\n        \"Destination\": \"Tujuan\",\n        \"Destinations\": \"Tujuan\",\n        \"Edit\": \"Sunting\",\n        \"Edit streaming destination\": \"Edit tujuan streaming\",\n        \"Enter URL\": \"Masukkan URL\",\n        \"Enter token\": \"Masukkan token\",\n        \"Learn more\": \"Pelajari lebih lanjut\",\n        \"Other\": \"Lainnya\",\n        \"Save\": \"Simpan\",\n        \"Splunk\": \"Splunk\",\n        \"Start streaming\": \"Mulai streaming\",\n        \"Token\": \"Token\",\n        \"URL\": \"URL\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Siapkan streaming peristiwa audit dari Grist ke sistem manajemen informasi dan peristiwa keamanan (SIEM) eksternal seperti Splunk. {{learnMoreLink}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit Logs\": \"Catatan Audit\",\n        \"Audit logs for {{siteName}}\": \"Log audit untuk {{siteName}}\",\n        \"Contact us\": \"Hubungi kami\",\n        \"Home\": \"Beranda\",\n        \"Log streaming\": \"Streaming log\",\n        \"Only site owners may access audit logs.\": \"Hanya pemilik situs yang dapat mengakses log audit.\",\n        \"upgrade your plan\": \"tingkatkan paket Anda\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Anda dapat mengatur streaming peristiwa audit dari Grist ke sistem SIEM (manajemen informasi dan peristiwa keamanan) eksternal jika Anda mengaktifkan Grist Enterprise. {{contactUsLink}} untuk mempelajari lebih lanjut.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Anda dapat mengatur streaming peristiwa audit dari Grist ke sistem SIEM (manajemen informasi dan peristiwa keamanan) eksternal jika Anda {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"Access details\": \"Detail akses\",\n        \"All\": \"Semua\",\n        \"Current workspace\": \"Ruang kerja saat ini\",\n        \"Delete\": \"Hapus\",\n        \"Delete {{name}}\": \"Hapus {{name}}\",\n        \"Document will be moved to Trash.\": \"Dokumen akan dipindahkan ke Sampah.\",\n        \"Edited {{at}}\": \"Diedit {{at}}\",\n        \"Last edited\": \"Terakhir diedit\",\n        \"Manage users\": \"Kelola pengguna\",\n        \"Move\": \"Pindah\",\n        \"Move {{name}} to workspace\": \"Pindah {{name}} ke ruang kerja\",\n        \"Name\": \"Nama\",\n        \"No documents to show.\": \"Tidak ada dokumen untuk ditunjukkan.\",\n        \"Pin\": \"Semat\",\n        \"Pinned\": \"Disematkan\",\n        \"Recent\": \"Terkini\",\n        \"Rename and set icon\": \"Ganti nama dan atur ikon\",\n        \"Requires edit permissions\": \"Memerlukan izin edit\",\n        \"Sort by date\": \"Urut berdasarkan tanggal\",\n        \"Sort by name\": \"Urut berdasarkan nama\",\n        \"Unpin\": \"Lepas sematan\",\n        \"Workspace\": \"Ruang kerja\",\n        \"context menu - {{- documentName }}\": \"menu konteks - {{- documentName }}\",\n        \"Documents list\": \"Daftar dokumen\",\n        \"Download document...\": \"Unduh dokumen...\",\n        \"Deleted {{at}}\": \"Dihapus {{at}}\"\n    },\n    \"RightPanelUtils\": {\n        \"columns_one\": \"kolom\",\n        \"columns_other\": \"kolom\",\n        \"fields_one\": \"bidang\",\n        \"fields_other\": \"bidang\",\n        \"series_one\": \"seri\",\n        \"series_other\": \"seri\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Hati-hati dengan widget khusus yang tidak dikenal\",\n        \"Please review the following before adding a new custom widget.\": \"Harap tinjau hal berikut sebelum menambahkan widget khusus baru.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Widget kustom itu **hebat**! Widget ini mungkin bisa membaca dan menulis data dokumen Anda, lalu mengirimkannya ke tempat lain.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Apakah Anda yakin **mempercayai sumber daya** di URL ini?\",\n        \"Do you **trust the person** who shared this link?\": \"Apakah Anda **percaya pada orang** yang membagikan tautan ini?\",\n        \"Have you **reviewed the code** at this URL?\": \"Sudahkah Anda **meninjau kode** di URL ini?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Jika ragu, jangan memasang widget ini, atau mintalah administrator organisasi Anda untuk memeriksanya demi keamanan.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Saya mengonfirmasi bahwa saya memahami peringatan ini dan menyetujui risikonya\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin area\": \"Area admin\",\n        \"Admin controls\": \"Kontrol admin\",\n        \"Docs\": \"Dokumen\",\n        \"Installation\": \"Instalasi\",\n        \"Learn more\": \"Pelajari lebih lanjut\",\n        \"Orgs\": \"Organisasi\",\n        \"Users\": \"Pengguna\",\n        \"Workspaces\": \"Ruang kerja\",\n        \"Admin Controls\": \"Kontrol Admin\",\n        \"Settings\": \"Pengaturan\"\n    },\n    \"Assistant\": {\n        \"AI Assistant is only available for logged in users.\": \"Asisten AI hanya tersedia untuk pengguna yang masuk.\",\n        \"Apply\": \"Terapkan\",\n        \"For higher limits, contact the site owner.\": \"Untuk batasan yang lebih tinggi, hubungi pemilik situs.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Untuk batasan yang lebih tinggi, {{upgradeNudge}}.\",\n        \"Learn more.\": \"Pelajari lebih lanjut.\",\n        \"Press Enter to apply suggested formula.\": \"Tekan Enter untuk menerapkan rumus yang disarankan.\",\n        \"Sign Up for Free\": \"Daftar Gratis\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Daftar akun Grist gratis untuk mulai menggunakan Asisten AI.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Tingkatkan ke Grist Enterprise untuk mencoba Grist Assistant yang baru. {{learnMoreLink}}\",\n        \"What do you need help with?\": \"Anda butuh bantuan dalam hal apa?\",\n        \"You have used all available credits.\": \"Anda telah menggunakan semua kredit yang tersedia.\",\n        \"You have {{numCredits}} remaining credits.\": \"Anda memiliki sisa kredit {{numCredits}}.\",\n        \"start a new chat\": \"mulai obrolan baru\",\n        \"upgrade to the Pro Team plan\": \"tingkatkan ke paket Tim Pro\",\n        \"upgrade your plan\": \"tingkatkan paket Anda\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"Percakapan ini terlalu panjang dan saya tidak bisa lagi merespons dengan efektif. Silakan {{startANewChatButton}} untuk terus menerima bantuan.\"\n    },\n    \"apiconsole\": {\n        \"Are you sure you want to delete the following?\": \"Apakah Anda yakin ingin menghapus yang berikut ini?\",\n        \"Confirm Deletion\": \"Konfirmasi Penghapusan\",\n        \"Delete\": \"Hapus\",\n        \"Deletion was not confirmed, skipping.\": \"Penghapusan tidak dikonfirmasi, dilewati.\",\n        \"Type DELETE here if you wish to proceed.\": \"Ketik DELETE di sini jika Anda ingin melanjutkan.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Ketik DELETE jika Anda yakin ingin menghapusnya.\\nJika Anda tidak yakin, atau tidak mengerti apa yang akan dilakukan oleh operasi ini,\\nsebaiknya batalkan saja.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"tidak ada akses\",\n        \"...loading\": \"...memuat\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Ada pembaruan Grist kritis yang tersedia.\\nPertimbangkan upgrade ke versi {{version}} sesegera mungkin.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Versi Grist Anda sudah kedaluwarsa.\\nPertimbangkan untuk meningkatkan ke versi {{version}} sesegera mungkin.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Rekomendasi: {{storageRecommendation}}\\nSaat menyimpan lampiran besar, atau banyak, kami sarankan\\nmenyimpannya di penyimpanan eksternal. Dokumen ini saat ini\\nmenggunakan penyimpanan internal untuk lampiran, yang membuatnya\\nmandiri tetapi dapat membatasi kinerja.\",\n        \"Set the document to use external storage.\": \"Atur dokumen untuk menggunakan penyimpanan eksternal.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Harap tunggu hingga operasi sebelumnya selesai.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Kehabisan waktu saat menunggu backend Grist dimulai ulang\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Nonaktifkan fitur\",\n        \"Don't worry, you can disable it later if needed.\": \"Jangan khawatir, Anda dapat menonaktifkannya nanti jika diperlukan.\",\n        \"Enable feature\": \"Aktifkan fitur\",\n        \"Experimental feature\": \"Fitur eksperimental\",\n        \"New record button\": \"Tombol rekam baru\",\n        \"Reload the page\": \"Muat ulang halaman\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Kunjungi URL ini kapan saja untuk berhenti menggunakan fitur ini: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Anda akan menonaktifkan fitur eksperimental ini: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Anda akan mengaktifkan fitur eksperimental ini: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} dinonaktifkan.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} diaktifkan.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Kartu baru\",\n        \"New record\": \"Rekam baru\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Ingin mengakses panel kreator? Gunakan {{key}}.\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Sedang mengunggah, harap tunggu…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Tambah\",\n        \"Delete\": \"Hapus\",\n        \"Download\": \"Unduh\",\n        \"Drop files here to attach\": \"Letakkan file di sini untuk dilampirkan\",\n        \"Drop files here to attach.\": \"Letakkan berkas di sini untuk dilampirkan.\",\n        \"No attachments\": \"Tidak ada lampiran\",\n        \"Preview not available.\": \"Pratinjau tidak tersedia.\",\n        \"{{index}} of {{total}}\": \"{{index}} dari {{total}}\",\n        \"Uploading…\": \"Unggah…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Perluas semua baris ke ketinggian ini\",\n        \"Max height\": \"Tinggi maksimal\",\n        \"Max row height\": \"Tinggi maksimum baris\",\n        \"Change\": \"Ubah\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Roboh\",\n        \"Expand\": \"Perluas\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"pengguna aktif\",\n        \"active user list\": \"daftar pengguna aktif\",\n        \"open full active user list\": \"buka daftar pengguna aktif penuh\"\n    },\n    \"AdminChecks\": {\n        \"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 memungkinkan penggunaan rumus yang sangat canggih menggunakan Python. Kami menyarankan untuk mengatur variabel lingkungan GRIST_SANDBOX_FLAVOR ke gvisor jika perangkat keras Anda mendukungnya (sebagian besar mendukungnya), untuk menjalankan rumus di setiap dokumen dalam sandbox yang terisolasi dari dokumen lain dan jaringan.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Grist memiliki pemeriksaan kesehatan bawaan kecil yang sering digunakan saat menjalankannya sebagai kontainer.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"Permintaan yang masuk ke Grist harus memiliki header Host yang akurat. Hal ini penting ketika GRIST_SERVE_SAME_ORIGIN ditetapkan.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"Halaman boot ini seharusnya tidak terlalu mudah diakses. Matikan halaman ini saat konfigurasi sudah benar (dengan menghapus GRIST_BOOT_KEY) atau buat GRIST_BOOT_KEY panjang dan aman secara kriptografi.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"Koneksi Websocket memerlukan HTTP 1.1 dan kemampuan untuk mengirimkan beberapa header tambahan agar dapat berfungsi. Terkadang, proxy terbalik dapat mengganggu persyaratan ini.\",\n        \"It is good practice not to run Grist as the root user.\": \"Merupakan praktik yang baik untuk tidak menjalankan Grist sebagai pengguna root.\",\n        \"The main page of Grist should be available.\": \"Halaman utama Grist seharusnya tersedia.\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Terapkan\",\n        \"Cancel\": \"Batal\",\n        \"Preview\": \"Pratinjau\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Tutup\",\n        \"Update preview\": \"Pratinjau pembaruan\",\n        \"Convert quoted fields\": \"Konversi bidang yang dikutip\",\n        \"Escape character\": \"Karakter escape\",\n        \"Field separator\": \"Pemisah bidang\",\n        \"First row contains headers\": \"Baris pertama berisi header\",\n        \"Line terminator\": \"Terminator garis\",\n        \"Number of rows\": \"Jumlah baris\",\n        \"Quote character\": \"Karakter kutipan\",\n        \"Quotes in fields are doubled\": \"Kutipan di bidang digandakan\",\n        \"Skip leading whitespace\": \"Lewati spasi di depan\",\n        \"Start with row\": \"Mulai dengan baris\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Pengodean karakter. Lihat [codec yang didukung]({{link}})\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" atau \",\n        \"\\\"Regions\\\" are what we call the different parts of the user interface:\": \"\\\"Wilayah\\\" adalah sebutan untuk berbagai bagian antarmuka pengguna:\",\n        \"Accessibility\": \"Aksesibilitas\",\n        \"Close\": \"Tutup\",\n        \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\": \"Akhirnya, panel kanan – atau panel kreator – hanya tersedia melalui pintasannya sendiri dan tidak disertakan dalam siklus wilayah berikutnya dan sebelumnya.\",\n        \"Focus on other parts of the user interface using the following shortcuts:\": \"Fokus pada bagian lain dari antarmuka pengguna menggunakan pintasan berikut:\",\n        \"High contrast theme\": \"Tema kontras tinggi\",\n        \"Keyboard navigation\": \"Navigasi papan ketik\",\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"Pada halaman dokumen, navigasi keyboard pertama-tama dikunci pada widget saat ini.\",\n        \"On document pages, each [widget]({{supportPageUrl}}) is a region that can receive focus.\": \"Pada halaman dokumen, setiap [widget]({{supportPageUrl}}) adalah wilayah yang dapat menerima fokus.\",\n        \"On non-document pages, the main content area is a region.\": \"Pada halaman non-dokumen, area konten utama adalah suatu wilayah.\",\n        \"Other important keyboard shortcuts\": \"Pintasan keyboard penting lainnya\",\n        \"The left panel, home of the main navigation.\": \"Panel kiri, rumah bagi navigasi utama.\",\n        \"The top panel, or the document header.\": \"Panel atas, atau header dokumen.\",\n        \"To see other available themes, go to your {{profileSettingsLink}}.\": \"Untuk melihat tema lain yang tersedia, buka {{profileSettingsLink}} Anda.\",\n        \"Use the high contrast theme (light appearance)\": \"Gunakan tema kontras tinggi (tampilan terang)\",\n        \"You are currently **not using** the high contrast theme.\": \"Anda saat ini **tidak menggunakan** tema kontras tinggi.\",\n        \"You are currently using the high contrast theme.\": \"Anda saat ini menggunakan tema kontras tinggi.\",\n        \"profile settings\": \"pengaturan profil\",\n        \"{{accessibilityModal}} Show the accessibility options (this modal)\": \"{{accessibilityModal}} Menampilkan opsi aksesibilitas (modal ini)\",\n        \"{{creatorPanelShortcut}} Focus to and from the creator panel\": \"{{creatorPanelShortcut}} Fokus ke dan dari panel kreator\",\n        \"{{nextRegionShortcut}} Focus on the next region\": \"{{nextRegionShortcut}} Fokus pada wilayah berikutnya\",\n        \"{{prevRegionShortcut}} Focus on the previous region\": \"{{prevRegionShortcut}} Fokus pada wilayah sebelumnya\",\n        \"{{shortcutsModal}} Show the complete list of keyboard shortcuts\": \"{{shortcutsModal}} Menampilkan daftar lengkap pintasan keyboard\"\n    },\n    \"ProposedChangesPage\": {\n        \"Proposed changes\": \"Perubahan yang diusulkan\",\n        \"Replace original\": \"Ganti original\",\n        \"This is a list of changes relative to the original document.\": \"Ini adalah daftar perubahan yang relatif terhadap dokumen asli.\",\n        \"Accept\": \"Setuju\",\n        \"Accepted {{at}}.\": \"Disetujui {{at}}.\",\n        \"Dismiss\": \"Berhentikan\",\n        \"Dismissed {{at}}.\": \"Diberhentikan {{at}}.\",\n        \"Learn more\": \"Pelajari lebih lanjut\",\n        \"No changes found to suggest. Please make some edits.\": \"Tidak ada perubahan yang disarankan. Mohon lakukan beberapa perubahan.\",\n        \"Retract suggestion\": \"Cabut Saran\",\n        \"Retracted {{at}}.\": \"Ditarik {{at}}.\",\n        \"Suggest change\": \"Sarankan perubahan\",\n        \"Suggest changes\": \"Sarankan perubahan\",\n        \"Suggestion made {{at}}.\": \"Saran dibuat {{at}}.\",\n        \"Suggestions\": \"Saran\",\n        \"The original document isn't asking for proposed changes.\": \"Dokumen original tidak meminta perubahan yang diusulkan.\",\n        \"There are fresh changes that haven't been added to the suggestion yet.\": \"Ada perubahan baru yang belum ditambahkan ke saran.\",\n        \"This is a list of changes relative to the {{originalDocument}}.\": \"Ini adalah daftar perubahan yang relatif terhadap {{originalDocument}}.\",\n        \"Update suggestion\": \"Saran pembaruan\",\n        \"Work on a copy\": \"Bekerja pada salinan\",\n        \"Your suggestions\": \"Saran Anda\",\n        \"experiment\": \"percobaan\",\n        \"original document\": \"dokumen original\",\n        \"Undo dismissal\": \"Batalkan pemecatan\"\n    },\n    \"selectBy\": {\n        \"Select widget\": \"Pilih widget\"\n    },\n    \"CoreNewDocMethods\": {\n        \"Untitled document\": \"Dokumen tanpa judul\"\n    },\n    \"AuthenticationSection\": {\n        \"Active\": \"Aktif\",\n        \"Active method is controlled by an environment variable. Unset variable to change active method.\": \"Metode aktif dikendalikan oleh variabel lingkungan. Hapus variabel untuk mengubah metode aktif.\",\n        \"Active on restart\": \"Aktif saat mulai ulang\",\n        \"Are you sure you want to set **{{name}}** as the active authentication method?\": \"Apakah Anda yakin ingin menetapkan **{{name}}** sebagai metode otentikasi aktif?\",\n        \"Close\": \"Tutup\",\n        \"Configure\": \"Konfigurasi\",\n        \"Configured\": \"Terkonfigurasi\",\n        \"Confirm\": \"Konfirmasi\",\n        \"Disabled on restart\": \"Nonaktif saat mulai ulang\",\n        \"Error\": \"Kesalahan\",\n        \"Error details\": \"Detail kesalahan\",\n        \"Instructions\": \"Instruksi\",\n        \"No authentication method is active.\": \"Tidak ada metode otentikasi yang aktif.\",\n        \"Set as active method\": \"Tetapkan sebagai metode aktif\",\n        \"Set as active method?\": \"Tetapkan sebagai metode aktif?\",\n        \"The new method will go into effect after you restart Grist.\": \"Metode baru ini akan berlaku setelah Anda memulai ulang Grist.\",\n        \"**Forwarded headers** allows your Grist server to trust authentication performed by an external proxy (e.g. Traefik ForwardAuth).\": \"**Header yang diteruskan** memungkinkan server Grist Anda untuk mempercayai autentikasi yang dilakukan oleh proxy eksternal (misalnya, Traefik ForwardAuth).\",\n        \"**Grist Connect** is a login solution built and maintained by Grist Labs that integrates seamlessly with your Grist server.\": \"**Grist Connect** adalah solusi login yang dibangun dan dikelola oleh Grist Labs yang terintegrasi secara mulus dengan server Grist Anda.\",\n        \"**OIDC** allows users on your Grist server to sign in using an external identity provider that supports the OpenID Connect standard.\": \"**OIDC** memungkinkan pengguna di server Grist Anda untuk masuk menggunakan penyedia identitas eksternal yang mendukung standar OpenID Connect.\",\n        \"**SAML** allows users on your Grist server to sign in using an external identity provider that supports the SAML 2.0 standard.\": \"**SAML** memungkinkan pengguna di server Grist Anda untuk masuk menggunakan penyedia identitas eksternal yang mendukung standar SAML 2.0.\",\n        \"Change admin user\": \"Ubah pengguna admin\",\n        \"If Grist is accessible on your network, or is available to multiple people, configure one of the authentication methods below.\": \"Jika Grist dapat diakses di jaringan Anda, atau tersedia untuk beberapa orang, konfigurasikan salah satu metode autentikasi di bawah ini.\",\n        \"No authentication: unrestricted sign-in as demo user\": \"Tidak ada otentikasi: masuk tanpa batasan sebagai pengguna demo\",\n        \"Prepare changes\": \"Siapkan perubahan\",\n        \"Restart required. Authentication change may affect your access\": \"Restart diperlukan. Perubahan otentikasi dapat memengaruhi akses Anda\",\n        \"Revert change of admin user\": \"Batalkan perubahan pengguna admin\",\n        \"See \\\"Restart Grist\\\" section on top of this page to restart.\": \"Lihat bagian \\\"Mulai Ulang Grist\\\" di bagian atas halaman ini untuk memulai ulang.\",\n        \"To set up **Grist Connect**, follow the instructions in [the Grist support article for Grist Connect](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"grist-connect\": {\n                            \").\": \"Untuk mengatur **Grist Connect**, ikuti petunjuk di [artikel dukungan Grist untuk Grist Connect](https://support.getgrist.com/install/grist-connect/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **OIDC**, follow the instructions in [the Grist support article for OIDC](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"oidc).\": \"Untuk mengatur **OIDC**, ikuti petunjuk di [artikel dukungan Grist untuk OIDC](https://support.getgrist.com/install/oidc).\"\n                    }\n                }\n            }\n        },\n        \"To set up **SAML**, follow the instructions in [the Grist support article for SAML](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"saml\": {\n                            \").\": \"Untuk mengatur **SAML**, ikuti petunjuk di [artikel dukungan Grist untuk SAML](https://support.getgrist.com/install/saml/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **forwarded headers**, follow the instructions in [the Grist support article for forwarded headers](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"forwarded-headers\": {\n                            \").\": \"Untuk mengatur **header yang diteruskan**, ikuti petunjuk di [artikel dukungan Grist untuk header yang diteruskan](https://support.getgrist.com/install/forwarded-headers/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"When a user accesses Grist, the proxy handles authentication and forwards verified user information through HTTP headers. Grist uses these headers to identify the user.\": \"Saat pengguna mengakses Grist, proxy menangani otentikasi dan meneruskan informasi pengguna yang terverifikasi melalui header HTTP. Grist menggunakan header ini untuk mengidentifikasi pengguna.\",\n        \"When signing in, users will be redirected to a Grist Connect login page where they can authenticate using various identity providers. After authentication, they'll be redirected back to your Grist server and signed in.\": \"Saat masuk, pengguna akan dialihkan ke halaman login Grist Connect tempat mereka dapat melakukan autentikasi menggunakan berbagai penyedia identitas. Setelah autentikasi, mereka akan dialihkan kembali ke server Grist Anda dan masuk.\",\n        \"When signing in, users will be redirected to your chosen identity provider's login page to authenticate. After successful authentication, they'll be redirected back to your Grist server and signed in as the user verified by the provider.\": \"Saat masuk, pengguna akan dialihkan ke halaman masuk penyedia identitas pilihan Anda untuk melakukan autentikasi. Setelah autentikasi berhasil, mereka akan dialihkan kembali ke server Grist Anda dan masuk sebagai pengguna yang telah diverifikasi oleh penyedia tersebut.\",\n        \"You are signed in as {{email}}. After restart, the new administrative user will be {{newEmail}}.\": \"Anda masuk sebagai {{email}}. Setelah restart, pengguna administratif yang baru adalah {{newEmail}}.\",\n        \"You are signed in as {{email}}. You may lose access to this server if you cannot sign in as this user after switching the authentication system.\": \"Anda masuk sebagai {{email}}. Anda mungkin kehilangan akses ke server ini jika Anda tidak dapat masuk sebagai pengguna ini setelah mengganti sistem otentikasi.\"\n    },\n    \"DetailView\": {\n        \"This row is unavailable or does not exist\": \"Baris ini tidak tersedia atau tidak ada\"\n    },\n    \"GetGristComProvider\": {\n        \"**Sign in with getgrist.com** allows users on your Grist server to sign in using their account on getgrist.com, the cloud version of Grist managed by Grist Labs.\": \"**Masuk dengan getgrist.com** memungkinkan pengguna di server Grist Anda untuk masuk menggunakan akun mereka di getgrist.com, versi cloud dari Grist yang dikelola oleh Grist Labs.\",\n        \"Cancel\": \"Batal\",\n        \"Configure\": \"Konfigurasi\",\n        \"Configure Sign in with getgrist.com\": \"Konfigurasi Masuk dengan getgrist.com\",\n        \"Home URL is not set; cannot configure Sign in with getgrist.com\": \"URL beranda belum diatur; tidak dapat mengkonfigurasi Masuk dengan getgrist.com\",\n        \"Instructions\": \"Instruksi\",\n        \"Learn more about Sign in with getgrist.com\": \"Pelajari selengkapnya tentang Masuk dengan getgrist.com\",\n        \"Paste configuration key here\": \"Tempelkan kunci konfigurasi di sini\",\n        \"Register your Grist server\": \"Daftarkan server Grist Anda\",\n        \"Sign in with getgrist.com\": \"Masuk dengan getgrist.com\",\n        \"To set up {{provider}}, you need to register your Grist server on getgrist.com and paste the configuration key you receive below.\": \"Untuk mengatur {{provider}}, Anda perlu mendaftarkan server Grist Anda di getgrist.com dan menempelkan kunci konfigurasi yang Anda terima di bawah ini.\",\n        \"When signing in, users will be redirected to the getgrist.com login page to log in or register. After authenticating on getgrist.com, they'll be redirected back to your Grist server and signed in as the user they authenticated as.\": \"Saat masuk, pengguna akan dialihkan ke halaman login getgrist.com untuk masuk atau mendaftar. Setelah melakukan autentikasi di getgrist.com, mereka akan dialihkan kembali ke server Grist Anda dan masuk sebagai pengguna yang telah mereka gunakan untuk autentikasi.\"\n    },\n    \"AirtableImportUI\": {\n        \"Back\": \"Kembali\",\n        \"Cancel\": \"Batal\",\n        \"Choose an Airtable base to import from\": \"Pilih basis data Airtable untuk diimpor\",\n        \"Choose destination\": \"Pilih tujuan\",\n        \"Connect\": \"Hubungkan\",\n        \"Connect with Airtable\": \"Terhubung dengan Airtable\",\n        \"Connect your Airtable account to access your bases.\": \"Hubungkan akun Airtable Anda untuk mengakses basis data Anda.\",\n        \"Connected via {{method}}\": \"Terhubung melalui {{method}}\",\n        \"Connecting...\": \"Menghubungkan...\",\n        \"Continue\": \"Lanjutkan\",\n        \"Destination\": \"Tujuan\",\n        \"Disconnect\": \"Putuskan\",\n        \"Existing tables\": \"Tabel yang sudah ada\",\n        \"Failed to fetch base schema\": \"Gagal mengambil skema dasar\",\n        \"Failed to fetch bases\": \"Gagal mengambil basis\",\n        \"Grist configuration required\": \"Konfigurasi Grist diperlukan\",\n        \"Import from {{baseName}} in progress. Do not navigate away from this page.\": \"Impor dari {{baseName}} sedang berlangsung. Jangan tinggalkan halaman ini.\",\n        \"Import tables\": \"Tabel impor\",\n        \"Import {{count}} tables_one\": \"Impor {{count}} tabel\",\n        \"Import {{count}} tables_other\": \"Impor {{count}} tabel\",\n        \"Make sure your token has the correct permissions.\": \"Pastikan token Anda memiliki izin yang benar.\",\n        \"New table\": \"Tabel baru\",\n        \"New table: structure only\": \"Tabel baru: hanya strukturnya saja\",\n        \"No bases found\": \"Basis data tidak ditemukan\",\n        \"OAuth\": \"OAuth\",\n        \"OAuth credentials not configured. Please set OAUTH2_AIRTABLE_CLIENT_ID and OAUTH2_AIRTABLE_CLIENT_SECRET, or use personal access token.\": \"Kredensial OAuth belum dikonfigurasi. Harap atur OAUTH2_AIRTABLE_CLIENT_ID dan OAUTH2_AIRTABLE_CLIENT_SECRET, atau gunakan token akses pribadi.\",\n        \"Personal access token\": \"Token akses pribadi\",\n        \"Please enter a personal access token\": \"Silakan masukkan token akses pribadi\",\n        \"Refresh\": \"Segarkan\",\n        \"Select tables to import from {{baseName}}\": \"Pilih tabel yang akan diimpor dari {{baseName}}\",\n        \"Skip\": \"Lewati\",\n        \"Source tables\": \"Tabel sumber\",\n        \"Structure only\": \"Hanya struktur\",\n        \"Use personal access token instead\": \"Gunakan token akses pribadi sebagai gantinya\",\n        \"[Generate a token]({{url}}) in your Airtable account with scopes that include at least **\\\\`schema.bases:read\\\\`** and **\\\\`data.records:read\\\\`**.\\n\\nYour token is never sent to Grist's servers, and is only used to make API calls to Airtable from your browser.\": \"[Buat token]({{url}}) di akun Airtable Anda dengan cakupan yang mencakup setidaknya **\\\\`schema.bases:read\\\\`** dan **\\\\`data.records:read\\\\`**.\\n\\nToken Anda tidak pernah dikirim ke server Grist, dan hanya digunakan untuk melakukan panggilan API ke Airtable dari browser Anda.\",\n        \"loading your bases...\": \"Memuat basis data anda...\",\n        \"loading your tables...\": \"Memuat tabel Anda...\",\n        \"or\": \"atau\",\n        \"{{count}} warnings_one\": \"{{count}} peringatan\",\n        \"{{count}} warnings_other\": \"{{count}} peringatan\",\n        \"The more convenient ‘Connect with Airtable’ option can be configured by the installation administrator. [Learn more.]({{url}})\": \"Opsi yang lebih praktis, yaitu ‘Hubungkan dengan Airtable’, dapat dikonfigurasi oleh administrator instalasi. [Pelajari selengkapnya.]({{url}})\",\n        \"Use personal access token\": \"Gunakan token akses pribadi\"\n    },\n    \"AirtableImporter\": {\n        \"Creating a new Grist document...\": \"Membuat dokumen Grist baru...\",\n        \"Preparing to import base from Airtable...\": \"Bersiap untuk mengimpor basis data dari Airtable...\",\n        \"Setting up tables...\": \"Menata tabel...\"\n    },\n    \"ChangeAdminModal\": {\n        \"Enter new admin email\": \"Masukkan email admin baru\",\n        \"Make the new email the installation admin. Orgs, workspaces, and documents will remain owned by {{email}}. These changes will take effect after you restart this Grist server.\": \"Jadikan email baru ini sebagai admin instalasi. Organisasi, ruang kerja, dan dokumen akan tetap dimiliki oleh {{email}}. Perubahan ini akan berlaku setelah Anda memulai ulang server Grist ini.\",\n        \"New admin\": \"Admin baru\",\n        \"Replace {{email}} with the new email throughout. The new email will become the installation admin, as well as the owner of all materials previously owned by you@example.com.\": \"Ganti {{email}} dengan email baru di seluruh teks. Email baru ini akan menjadi admin instalasi, serta pemilik semua materi yang sebelumnya dimiliki oleh you@example.com.\"\n    },\n    \"startDocAirtableImport\": {\n        \"Import from Airtable\": \"Impor dari Airtable\"\n    },\n    \"startHomeAirtableImport\": {\n        \"Import from Airtable\": \"Impor dari Airtable\",\n        \"The current workspace can't be imported to.\": \"Ruang kerja saat ini tidak dapat diimpor.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/id.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"Loading...\": \"Memuat...\",\n        \"og-description\": \"Spreadsheet sumber terbuka modern yang melampaui grid\",\n        \"og-title\": \"Grist, evolusi spreadsheet\"\n    },\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Harap verifikasi email Anda dengan penyedia identitas, lalu masuk lagi.\"\n    },\n    \"access\": {\n        \"docDisabled\": \"Dokumen ini dinonaktifkan.\",\n        \"docNoAccess\": \"Anda tidak memiliki akses ke dokumen ini.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"Tidak ada pemilik yang ditemukan dalam organisasi admin yang ditentukan oleh `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"orgUser\": \"Pengguna adalah pemilik organisasi admin yang ditentukan oleh `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"accountByEmail\": \"Akun admin ditentukan oleh `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Dokumen tanpa judul\"\n    }\n}\n"
  },
  {
    "path": "static/locales/ig.client.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/ig.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/it.client.json",
    "content": "{\n  \"HomeIntro\": {\n    \"Invite Team Members\": \"Invita i membri del team\",\n    \"personal site\": \"sito personale\",\n    \"Any documents created in this site will appear here.\": \"Tutti i documenti creati in questo sito appariranno qui.\",\n    \"Browse Templates\": \"Sfoglia template\",\n    \"Create empty document\": \"Crea un documento vuoto\",\n    \"Get started by creating your first Grist document.\": \"Inizia a creare il tuo primo documento Grist.\",\n    \"Get started by exploring templates, or creating your first Grist document.\": \"Inizia esplorando i template o creando il tuo primo documento Grist.\",\n    \"Get started by inviting your team and creating your first Grist document.\": \"Inizia invitando il tuo team e creando il tuo primo documento Grist.\",\n    \"Help Center\": \"Centro assistenza\",\n    \"Import document\": \"Importa documento\",\n    \"Interested in using Grist outside of your team? Visit your free \": \"Ti interessa usare Grist fuori dal tuo team? Prova gratis il tuo \",\n    \"Sign up\": \"Iscriviti\",\n    \"Sprouts Program\": \"Consulenza per iniziare\",\n    \"This workspace is empty.\": \"Questo spazio di lavoro è vuoto.\",\n    \"Visit our {{link}} to learn more.\": \"Visita il {{link}} per saperne di più.\",\n    \"Welcome to Grist!\": \"Benvenuto in Grist!\",\n    \"Welcome to Grist, {{name}}!\": \"Benvenuto in Grist, {{name}}!\",\n    \"Welcome to {{orgName}}\": \"Benvenuto in {{orgName}}\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Hai accesso a questo sito in sola lettura. Attualmente non ci sono documenti.\",\n    \"{{signUp}} to save your work. \": \"{{signUp}} per salvare il tuo lavoro. \",\n    \"Welcome to Grist, {{- name}}!\": \"Benvenuto in Grist, {{- name}}!\",\n    \"Welcome to {{- orgName}}\": \"Benvenuto, {{- orgName}}\",\n    \"Visit our {{link}} to learn more about Grist.\": \"Vai a {{link}} per saperne di più su Grist.\",\n    \"Sign in\": \"Accedi\",\n    \"To use Grist, please either sign up or sign in.\": \"Per usare Grist, iscriviti o accedi.\",\n    \"Learn more in our {{helpCenterLink}}.\": \"Approfondisci nel nostro {{helpCenterLink}}.\",\n    \"Only show documents\": \"Mostra solo i documenti\"\n  },\n  \"HomeLeftPane\": {\n    \"Manage users\": \"Gestisci gli utenti\",\n    \"Access Details\": \"Dettagli di accesso\",\n    \"All documents\": \"Tutti i documenti\",\n    \"Create empty document\": \"Crea un documento vuoto\",\n    \"Create workspace\": \"Crea uno spazio di lavoro\",\n    \"Delete\": \"Elimina\",\n    \"Delete {{workspace}} and all included documents?\": \"Eliminare {{workspace}} e tutti i documenti che contiene?\",\n    \"Examples & Templates\": \"Template\",\n    \"Import document\": \"Importa documento\",\n    \"Rename\": \"Rinomina\",\n    \"Trash\": \"Cestino\",\n    \"Workspace will be moved to Trash.\": \"Lo spazio di lavoro sarà spostato nel cestino.\",\n    \"Workspaces\": \"Spazi di lavoro\",\n    \"Tutorial\": \"Tutorial\",\n    \"Terms of service\": \"Condizioni di servizio\",\n    \"Grist Resources\": \"Risorse Grist\"\n  },\n  \"MakeCopyMenu\": {\n    \"However, it appears to be already identical.\": \"Tuttavia, sembra essere già identico.\",\n    \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Attenzione, l'originale ha delle modifiche che non sono in questo documento. Queste saranno sovrascritte.\",\n    \"It will be overwritten, losing any content not in this document.\": \"Sarà sovrascritto, perdendo qualsiasi contenuto che non è in questo documento.\",\n    \"As template\": \"Come template\",\n    \"Cancel\": \"Annulla\",\n    \"Enter document name\": \"Inserisci il nome del documento\",\n    \"Include the structure without any of the data.\": \"Includi la struttura senza alcun dato.\",\n    \"Name\": \"Nome\",\n    \"No destination workspace\": \"Nessuno spazio di lavoro di destinazione\",\n    \"Organization\": \"Organizzazione\",\n    \"Original Has Modifications\": \"L'originale ha delle modifiche\",\n    \"Original Looks Unrelated\": \"L'originale sembra non essere in relazione\",\n    \"Original Looks Identical\": \"L'originale sembra identico\",\n    \"Overwrite\": \"Sovrascrivi\",\n    \"Replacing the original requires editing rights on the original document.\": \"Rimpiazzare l'originale richiede permessi in scrittura nel documento originale.\",\n    \"Sign up\": \"Iscriviti\",\n    \"The original version of this document will be updated.\": \"La versione originale di questo documento sarà aggiornata.\",\n    \"To save your changes, please sign up, then reload this page.\": \"Per salvare le modifiche, effettua l'accesso e ricarica questa pagina.\",\n    \"Update\": \"Aggiorna\",\n    \"Update Original\": \"Aggiorna l'originale\",\n    \"Workspace\": \"Spazio di lavoro\",\n    \"You do not have write access to the selected workspace\": \"Non hai accesso in scrittura allo spazio di lavoro selezionato\",\n    \"You do not have write access to this site\": \"Non hai accesso in scrittura a questo sito\",\n    \"Download document structure only (no data, for template use)\": \"Rimuovi tutti i dati, mantieni la struttura per usarla come template\",\n    \"Download document without history (can significantly reduce file size)\": \"Rimuovi la storia del documento (può ridurre molto le dimensioni del file)\",\n    \"Download document and history\": \"Scarica tutto il documento e la storia\",\n    \"Download\": \"Scarica\",\n    \"Download document\": \"Scarica documento\"\n  },\n  \"Importer\": {\n    \"Update existing records\": \"Aggiorna i record esistenti\",\n    \"Merge rows that match these fields:\": \"Unisci le righe che corrispondono a questi campi:\",\n    \"Select fields to match on\": \"Seleziona i campi da far corrispondere\",\n    \"Column Mapping\": \"Corrispondenze nelle colonne\",\n    \"Column mapping\": \"Corrispondenze nelle colonne\",\n    \"Destination table\": \"Tabella di destinazione\",\n    \"Grist column\": \"Colonna di Grist\",\n    \"Import from file\": \"Importa da file\",\n    \"New Table\": \"Nuova tabella\",\n    \"Revert\": \"Ripristina\",\n    \"Skip\": \"Salta\",\n    \"{{count}} unmatched field in import_one\": \"{{count}} campi senza equivalente nell'importazione\",\n    \"{{count}} unmatched field in import_other\": \"{{count}} campi senza equivalente nell'importazione\",\n    \"{{count}} unmatched field_one\": \"{{count}} campi senza equivalente\",\n    \"{{count}} unmatched field_other\": \"{{count}} campi senza equivalente\",\n    \"Skip Import\": \"Salta l'importazione\",\n    \"Skip Table on Import\": \"Salta la tabella nell'importazione\",\n    \"Source column\": \"Colonna di origine\"\n  },\n  \"NotifyUI\": {\n    \"Cannot find personal site, sorry!\": \"Spiacente, impossibile trovare il sito personale!\",\n    \"Go to your free personal site\": \"Vai al tuo sito personale gratuito\",\n    \"Notifications\": \"Notifiche\",\n    \"Ask for help\": \"Chiedi aiuto\",\n    \"Give feedback\": \"Lascia un commento\",\n    \"No notifications\": \"Nessuna notifica\",\n    \"Renew\": \"Rinnova\",\n    \"Report a problem\": \"Segnala un problema\",\n    \"Upgrade Plan\": \"Aggiorna il tuo piano\",\n    \"Manage billing\": \"Gestisci modalità di addebito\"\n  },\n  \"Pages\": {\n    \"Delete data and this page.\": \"Elimina i dati e questa pagina.\",\n    \"The following tables will no longer be visible_one\": \"La tabella seguente non sarà più visibile\",\n    \"The following tables will no longer be visible_other\": \"Le tabelle seguenti non saranno più visibili\",\n    \"Delete\": \"Elimina\"\n  },\n  \"RecordLayoutEditor\": {\n    \"Save layout\": \"Salva layout\",\n    \"Add field\": \"Aggiungi campo\",\n    \"Create new field\": \"Crea nuovo campo\",\n    \"Show field {{- label}}\": \"Mostra campo {{- label}}\",\n    \"Cancel\": \"Annulla\"\n  },\n  \"RightPanel\": {\n    \"SOURCE DATA\": \"SORGENTE DATI\",\n    \"WIDGET TITLE\": \"TITOLO WIDGET\",\n    \"fields_other\": \"Campi\",\n    \"Save\": \"Salva\",\n    \"Select widget\": \"Seleziona widget\",\n    \"TRANSFORM\": \"TRASFORMA\",\n    \"You do not have edit access to this document\": \"Non hai accesso in scrittura a questo documento\",\n    \"Theme\": \"Tema\",\n    \"Widget\": \"Widget\",\n    \"COLUMN TYPE\": \"TIPO COLONNA\",\n    \"CHART TYPE\": \"TIPO GRAFICO\",\n    \"CUSTOM\": \"PERSONALIZZATO\",\n    \"Change widget\": \"Cambia widget\",\n    \"columns_one\": \"Colonna\",\n    \"columns_other\": \"Colonne\",\n    \"DATA TABLE\": \"TABELLA DATI\",\n    \"DATA TABLE NAME\": \"NOME TABELLA DATI\",\n    \"Data\": \"Dati\",\n    \"Detach\": \"Scollega\",\n    \"Edit data selection\": \"Modifica selezione dati\",\n    \"fields_one\": \"Campo\",\n    \"GROUPED BY\": \"RAGGRUPPATO PER\",\n    \"Row style\": \"Stile riga\",\n    \"SELECT BY\": \"SELEZIONATO PER\",\n    \"SELECTOR FOR\": \"SELETTORE PER\",\n    \"series_one\": \"Serie\",\n    \"series_other\": \"Serie\",\n    \"Sort & filter\": \"Ordina e filtra\",\n    \"Add referenced columns\": \"Aggiungi colonne referenziate\",\n    \"Reset form\": \"Resetta modulo\",\n    \"Submit button label\": \"Etichetta del pulsante di invio\",\n    \"Submit another response\": \"Invia un'altra risposta\",\n    \"Display button\": \"Mostra pulsante\",\n    \"Enter text\": \"Inserisci testo\",\n    \"Field title\": \"Titolo del campo\",\n    \"Hidden field\": \"Campo nascosto\",\n    \"Layout\": \"Layout\",\n    \"Redirect automatically after submission\": \"Re-indirizza automaticamente dopo l'invio\",\n    \"Redirection\": \"Re-indirizzamento\",\n    \"Required field\": \"Campo richiesto\",\n    \"Submission\": \"Invio\",\n    \"Success text\": \"Messaggio di successo\",\n    \"Table column name\": \"Nome della colonna della tabella\",\n    \"Enter redirect URL\": \"Inserisci URL di re-indirizzamento\",\n    \"Configuration\": \"Configurazione\",\n    \"Default field value\": \"Valore di default del campo\",\n    \"Field rules\": \"Regole per il campo\",\n    \"Select a field in the form widget to configure.\": \"Seleziona un campo nel widget del modulo per configurarlo.\",\n    \"No field selected\": \"Nessun campo selezionato\",\n    \"Submit\": \"Invia\",\n    \"Thank you! Your response has been recorded.\": \"Grazie! La tua risposta è stata registrata.\"\n  },\n  \"RowContextMenu\": {\n    \"Copy anchor link\": \"Copia link\",\n    \"Delete\": \"Elimina\",\n    \"Duplicate rows_one\": \"Duplica riga\",\n    \"Duplicate rows_other\": \"Duplica righe\",\n    \"Insert row\": \"Inserisci riga\",\n    \"Insert row above\": \"Inserisci riga sopra\",\n    \"Insert row below\": \"Inserisci riga sotto\",\n    \"View as card\": \"Vedi come scheda\",\n    \"Use as table headers\": \"Usa come intestazioni di tabella\"\n  },\n  \"SortFilterConfig\": {\n    \"Save\": \"Salva\",\n    \"Filter\": \"FILTRO\",\n    \"Revert\": \"Ripristina\",\n    \"Sort\": \"ORDINA\",\n    \"Update Sort & Filter settings\": \"Aggiorna impostazioni Ordina e Filtra\"\n  },\n  \"SortConfig\": {\n    \"Use choice position\": \"Usa posizione scelta\",\n    \"Add column\": \"Aggiungi colonna\",\n    \"Empty values last\": \"Valori vuoti al fondo\",\n    \"Natural sort\": \"Ordinamento naturale\",\n    \"Update data\": \"Aggiorna dati\",\n    \"Search Columns\": \"Cerca colonne\"\n  },\n  \"TriggerFormulas\": {\n    \"Any field\": \"Qualsiasi campo\",\n    \"Apply on changes to:\": \"Applica su modifica a:\",\n    \"Apply on record changes\": \"Applica su modifica a record\",\n    \"Apply to new records\": \"Applica su nuovo record\",\n    \"Cancel\": \"Annulla\",\n    \"Close\": \"Chudi\",\n    \"Current field \": \"Campo corrente \",\n    \"OK\": \"OK\"\n  },\n  \"Tools\": {\n    \"Validate Data\": \"Valida dati\",\n    \"Settings\": \"Impostazioni\",\n    \"Access Rules\": \"Regole di accesso\",\n    \"Code view\": \"Vista codice\",\n    \"Delete\": \"Elimina\",\n    \"Delete document tour?\": \"Eliminare il tour del documento?\",\n    \"Document history\": \"Storia documento\",\n    \"How-to Tutorial\": \"Tutorial pratico\",\n    \"Raw data\": \"Dati grezzi\",\n    \"Return to viewing as yourself\": \"Torna alla vista come te stesso\",\n    \"TOOLS\": \"STRUMENTI\",\n    \"Tour of this Document\": \"Tour di questo documento\",\n    \"API console\": \"Tavola delle API\"\n  },\n  \"ViewConfigTab\": {\n    \"Form\": \"Modulo\",\n    \"Make On-Demand\": \"Rendi on-demand\",\n    \"Advanced settings\": \"Impostazioni avanzate\",\n    \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Le tabelle grandi possono essere marcate \\\"on-demand\\\" per evitare di caricarle nel sistema.\",\n    \"Blocks\": \"Blocchi\",\n    \"Compact\": \"Compatta\",\n    \"Edit card layout\": \"Modifica layout scheda\",\n    \"Plugin: \": \"Plugin: \",\n    \"Section: \": \"Sezione: \",\n    \"Unmark On-Demand\": \"Non rendere on-demand\"\n  },\n  \"WelcomeQuestions\": {\n    \"What brings you to Grist? Please help us serve you better.\": \"Che cosa ti ha portato a Grist? Aiutaci a servirti meglio.\",\n    \"Sales\": \"Vendite\",\n    \"Product Development\": \"Sviluppo di prodotti\",\n    \"Education\": \"Istruzione\",\n    \"Finance & Accounting\": \"Finanza e contabilità\",\n    \"HR & Management\": \"Risorse umane e amministrazione\",\n    \"IT & Technology\": \"IT e tecnologia\",\n    \"Marketing\": \"Marketing\",\n    \"Media Production\": \"Produzione nei media\",\n    \"Other\": \"Altro\",\n    \"Research\": \"Ricerca\",\n    \"Type here\": \"Scrivi qui\",\n    \"Welcome to Grist!\": \"Benvenuto in Grist!\"\n  },\n  \"errorPages\": {\n    \"Something went wrong\": \"Qualcosa non ha funzionato\",\n    \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Impossibile trovare la pagina richiesta.{{separator}}Controllare la URL e provare ancora.\",\n    \"There was an unknown error.\": \"Si è verificato un errore sconosciuto.\",\n    \"Access denied{{suffix}}\": \"Accesso negato-{{suffix}}\",\n    \"Add account\": \"Aggiungi account\",\n    \"Contact support\": \"Contatta il supporto\",\n    \"Error{{suffix}}\": \"Errore-{{suffix}}\",\n    \"Go to main page\": \"Vai alla pagina principale\",\n    \"Page not found{{suffix}}\": \"Pagina non trovata-{{suffix}}\",\n    \"Sign in\": \"Accedi\",\n    \"Sign in again\": \"Accedi di nuovo\",\n    \"Sign in to access this organization's documents.\": \"Accedi per vedere i documenti di questa organizzazione.\",\n    \"Signed out{{suffix}}\": \"Disconnesso-{{suffix}}\",\n    \"There was an error: {{message}}\": \"Si è verificato un errore: {{message}}\",\n    \"You are now signed out.\": \"Adesso sei scollegato.\",\n    \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Sei collegato come {{email}}. Puoi accedere con un account diverso, o chiedere l'accesso a un amministratore.\",\n    \"You do not have access to this organization's documents.\": \"Non hai accesso ai documenti di questa organizzazione.\",\n    \"Account deleted{{suffix}}\": \"Account eliminato {{suffix}}\",\n    \"Your account has been deleted.\": \"Il tuo account è stato cancellato.\",\n    \"Sign up\": \"Iscriviti\",\n    \"Form not found\": \"Modulo non trovato\",\n    \"Build your own form\": \"Costruisci il tuo modulo\",\n    \"Powered by\": \"Creato con\",\n    \"An unknown error occurred.\": \"Ho incontrato un errore sconosciuto.\",\n    \"Failed to log in.{{separator}}Please try again or contact support.\": \"Accesso non riuscito.{{separator}}Prova ancora o contatta il supporto tecnico.\",\n    \"Sign-in failed{{suffix}}\": \"Accesso fallito{{suffix}}\"\n  },\n  \"duplicatePage\": {\n    \"Note that this does not copy data, but creates another view of the same data.\": \"Notare che questo non copia i dati ma crea un'altra vista dagli stessi dati.\",\n    \"Duplicate page {{pageName}}\": \"Duplica pagina {{pageName}}\"\n  },\n  \"TypeTransform\": {\n    \"Apply\": \"Applica\",\n    \"Cancel\": \"Annulla\",\n    \"Preview\": \"Anteprima\",\n    \"Revise\": \"Rivedi\",\n    \"Update formula (Shift+Enter)\": \"Aggiorna formula (Maiusc + Invio)\"\n  },\n  \"CellStyle\": {\n    \"Cell style\": \"Stile cella\",\n    \"CELL STYLE\": \"STILE CELLA\",\n    \"Mixed style\": \"Stile misto\",\n    \"Open row styles\": \"Apri stili riga\",\n    \"Default cell style\": \"Stile cella di default\",\n    \"Default header style\": \"Stile di default per l'intestazione\",\n    \"Header Style\": \"Stile per l'intestazione\",\n    \"HEADER STYLE\": \"STILE INTESTAZIONE\"\n  },\n  \"ChoiceTextBox\": {\n    \"CHOICES\": \"SCELTE\"\n  },\n  \"ColumnEditor\": {\n    \"COLUMN LABEL\": \"ETICHETTA COLONNA\",\n    \"COLUMN DESCRIPTION\": \"DESCRIZIONE COLONNA\"\n  },\n  \"ColumnInfo\": {\n    \"COLUMN ID: \": \"ID COLONNA: \",\n    \"COLUMN DESCRIPTION\": \"DESCRIZIONE COLONNA\",\n    \"COLUMN LABEL\": \"ETICHETTA COLONNA\",\n    \"Save\": \"Salva\",\n    \"Cancel\": \"Annulla\"\n  },\n  \"DiscussionEditor\": {\n    \"Reply to a comment\": \"Rispondi a commento\",\n    \"Resolve\": \"Risolvi\",\n    \"Started discussion\": \"Discussione avviata\",\n    \"Save\": \"Salva\",\n    \"Open\": \"Apri\",\n    \"Remove\": \"Rimuovi\",\n    \"Reply\": \"Rispondi\",\n    \"Show resolved comments\": \"Mostra commenti risolti\",\n    \"Showing last {{nb}} comments\": \"Mostro gli ultimi {{nb}} commenti\",\n    \"Write a comment\": \"Scrivi un commento\",\n    \"Cancel\": \"Annulla\",\n    \"Comment\": \"Commenta\",\n    \"Edit\": \"Modifica\",\n    \"Marked as resolved\": \"Segnato come risolto\",\n    \"Only current page\": \"Solo pagina corrente\",\n    \"Only my threads\": \"Solo miei thread\"\n  },\n  \"EditorTooltip\": {\n    \"Convert column to formula\": \"Converti colonna in formula\"\n  },\n  \"FieldBuilder\": {\n    \"Apply formula to data\": \"Applica formula ai dati\",\n    \"CELL FORMAT\": \"FORMATO CELLA\",\n    \"Mixed types\": \"Tipi misti\",\n    \"Mixed format\": \"Formato misto\",\n    \"Changing multiple column types\": \"Cambiare i tipi di più colonne\",\n    \"DATA FROM TABLE\": \"DATI DALLA TABELLA\",\n    \"Revert field settings for {{colId}} to common\": \"Ripristina le impostazioni dei campi per {{colId}} a quelli comuni\",\n    \"Use separate field settings for {{colId}}\": \"Usa impostazioni dei campi separate per {{colId}}\",\n    \"Save field settings for {{colId}} as common\": \"Salva le impostazioni dei campi per {{colId}} come quelli comuni\",\n    \"Changing column type\": \"Cambio tipo colonna\"\n  },\n  \"FormulaEditor\": {\n    \"Error in the cell\": \"Errore nella cella\",\n    \"Errors in {{numErrors}} of {{numCells}} cells\": \"Errori in {{numErrors}} su {{numCells}} celle\",\n    \"Column or field is required\": \"Richiesta una colonna o un campo\",\n    \"Errors in all {{numErrors}} cells\": \"Errori in {{numErrors}} celle\",\n    \"editingFormula is required\": \"editingFormula è necessario\",\n    \"Enter formula or {{button}}.\": \"Inserisci una formula o {{button}}.\",\n    \"Enter formula.\": \"Inserisci una formula.\",\n    \"Expand Editor\": \"Espandi l'editor\",\n    \"use AI Assistant\": \"usa l'assistente IA\"\n  },\n  \"FieldEditor\": {\n    \"It should be impossible to save a plain data value into a formula column\": \"Non dovrebbe essere possibile salvare un valore semplice in una colonna di formule\",\n    \"Unable to finish saving edited cell\": \"Impossibile completare il salvataggio della cella modificata\"\n  },\n  \"Reference\": {\n    \"CELL FORMAT\": \"FORMATO CELLA\",\n    \"Row ID\": \"ID riga\",\n    \"SHOW COLUMN\": \"MOSTRA COLONNA\"\n  },\n  \"NumericTextBox\": {\n    \"Decimals\": \"Decimali\",\n    \"Default currency ({{defaultCurrency}})\": \"Valuta di default ({{defaultCurrency}})\",\n    \"Currency\": \"Valuta\",\n    \"Number Format\": \"Formato numerico\",\n    \"Field Format\": \"Formato del campo\",\n    \"Text\": \"Testo\",\n    \"min\": \"min\",\n    \"Spinner\": \"Spinner\",\n    \"max\": \"max\"\n  },\n  \"HyperLinkEditor\": {\n    \"[link label] url\": \"[testo link] URL\"\n  },\n  \"WelcomeTour\": {\n    \"Editing Data\": \"Modificare i dati\",\n    \"Sharing\": \"Condividendo\",\n    \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Sfoglia la nostra {{templateLibrary}} per scoprire che cosa è possibile fare e farti ispirare.\",\n    \"Building up\": \"In costruzione\",\n    \"Configuring your document\": \"Configurare il tuo documento\",\n    \"Customizing columns\": \"Personalizzare le colonne\",\n    \"Double-click or hit {{enter}} on a cell to edit it. \": \"Fare doppio clic o premere {{enter}} su una cella per modificarla. \",\n    \"Flying higher\": \"Volare più alto\",\n    \"Enter\": \"Entrare\",\n    \"Make it relational! Use the {{ref}} type to link tables. \": \"Rendilo relazionale! Usa il tipo {{ref}} per collegare le tabelle. \",\n    \"Reference\": \"Riferimento\",\n    \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Impostare opzioni di formattazione, formule, o tipi di colonna come date, scelte o allegati. \",\n    \"Start with {{equal}} to enter a formula.\": \"Inizia da {{equal}} per inserire una formula.\",\n    \"convert to card view, select data, and more.\": \"convertire alla visualizzazione scheda, selezionare dati e altro ancora.\",\n    \"template library\": \"libreria di template\",\n    \"Use {{helpCenter}} for documentation or questions.\": \"Usa {{helpCenter}} per vedere la documentazione o fare domande.\",\n    \"creator panel\": \"pannello Creatore\",\n    \"Add new\": \"Aggiungi nuovo\",\n    \"Help Center\": \"Centro assistenza\",\n    \"Share\": \"Condividere\",\n    \"Toggle the {{creatorPanel}} to format columns, \": \"Attiva il {{creatorPanel}} per formattare le colonne, \",\n    \"Use the Share button ({{share}}) to share the document or export data.\": \"Usa il pulsante Condividi ({{share}}) per condividere il documento o esportare i dati.\",\n    \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Usa {{addNew}} per aggiungere widget, pagine o importare altri dati. \",\n    \"Welcome to Grist!\": \"Benvenuto in Grist!\"\n  },\n  \"AccountWidget\": {\n    \"Accounts\": \"Account\",\n    \"Document settings\": \"Impostazioni del documento\",\n    \"Sign out\": \"Esci\",\n    \"Toggle Mobile Mode\": \"Modalità mobile\",\n    \"Access Details\": \"Dettagli di accesso\",\n    \"Add account\": \"Aggiungi account\",\n    \"Manage team\": \"Gestisci team\",\n    \"Pricing\": \"Prezzi\",\n    \"Profile settings\": \"Impostazioni utente\",\n    \"Sign in\": \"Accedi\",\n    \"Switch Accounts\": \"Cambia account\",\n    \"Activation\": \"Attivazione\",\n    \"Upgrade Plan\": \"Cambia il tuo piano\",\n    \"Billing account\": \"Conto di addebito\",\n    \"Support Grist\": \"Sostieni Grist\",\n    \"Use This Template\": \"Usa questo template\",\n    \"Sign up\": \"Registrati\"\n  },\n  \"ActionLog\": {\n    \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"La colonna {{colId}} è stata successivamente rimossa nell'azione #{{action.actionNum}}\",\n    \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"La tabella {{tableId}} è stata successivamente rimossa nell'azione #{{actionNum}}\",\n    \"Action Log failed to load\": \"Impossibile caricare il log delle azioni\",\n    \"This row was subsequently removed in action {{action.actionNum}}\": \"Questa riga è stata successivamente rimossa nell'azione {{action.actionNum}}\",\n    \"All tables\": \"Tutte le tabelle\"\n  },\n  \"App\": {\n    \"Memory Error\": \"Errore di memoria\",\n    \"Key\": \"Chiave\",\n    \"Description\": \"Descrizione\",\n    \"Translators: please translate this only when your language is ready to be offered to users\": \"Traduttori: per favore tradurre questo solo quando la tua lingua è pronta per gli utenti\"\n  },\n  \"AppModel\": {\n    \"This team site is suspended. Documents can be read, but not modified.\": \"Il sito del team è sospeso. Si possono leggere i documenti, ma non modificarli.\"\n  },\n  \"CellContextMenu\": {\n    \"Insert column to the left\": \"Inserisci colonna a sinistra\",\n    \"Reset {{count}} entire columns_other\": \"Resetta {{count}} intere colonne\",\n    \"Clear cell\": \"Svuota cella\",\n    \"Clear values\": \"Cancella valori\",\n    \"Copy anchor link\": \"Copia link\",\n    \"Delete {{count}} columns_one\": \"Elimina colonna\",\n    \"Delete {{count}} columns_other\": \"Elimina {{count}} colonne\",\n    \"Delete {{count}} rows_one\": \"Elimina riga\",\n    \"Delete {{count}} rows_other\": \"Elimina {{count}} righe\",\n    \"Duplicate rows_one\": \"Duplica riga\",\n    \"Duplicate rows_other\": \"Duplica righe\",\n    \"Filter by this value\": \"Filtra per questo valore\",\n    \"Insert column to the right\": \"Inserisci colonna a destra\",\n    \"Insert row\": \"Inserisci riga\",\n    \"Insert row above\": \"Inserisci riga sopra\",\n    \"Insert row below\": \"Inserisci riga sotto\",\n    \"Reset {{count}} columns_one\": \"Resetta colonna\",\n    \"Reset {{count}} columns_other\": \"Resetta {{count}} colonne\",\n    \"Reset {{count}} entire columns_one\": \"Resetta l'intera colonna\",\n    \"Comment\": \"Commenta\",\n    \"Copy\": \"Copia\",\n    \"Cut\": \"Taglia\",\n    \"Paste\": \"Incolla\",\n    \"Copy with headers\": \"Copia con le intestazioni\"\n  },\n  \"AccountPage\": {\n    \"API\": \"API\",\n    \"API Key\": \"Chiave per le API\",\n    \"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.\": \"L'autenticazione a due fattori aggiunge un livello di sicurezza, garantendo che tu sia l'unico a poter accedere al tuo Grist, anche se qualcuno scopre la tua password.\",\n    \"Account settings\": \"Impostazioni dell'account\",\n    \"Allow signing in to this account with Google\": \"Permetti di accedere a questo account con Google\",\n    \"Change password\": \"Cambia password\",\n    \"Edit\": \"Modifica\",\n    \"Email\": \"E-mail\",\n    \"Login method\": \"Metodo di accesso\",\n    \"Name\": \"Nome\",\n    \"Names only allow letters, numbers and certain special characters\": \"Per il nome puoi usare solo lettere, numeri e alcuni caratteri speciali\",\n    \"Password & security\": \"Password e sicurezza\",\n    \"Save\": \"Salva\",\n    \"Theme\": \"Tema\",\n    \"Two-factor authentication\": \"Autenticazione a due fattori\",\n    \"Language\": \"Lingua\"\n  },\n  \"AccessRules\": {\n    \"Permission to edit document structure\": \"Permesso di modificare la struttura del documento\",\n    \"Lookup Table\": \"Tabella di ricerca\",\n    \"Add Default Rule\": \"Aggiungi regola di default\",\n    \"Add table rules\": \"Aggiungi regole per la tabella\",\n    \"Delete table rules\": \"Elimina tabella delle regole\",\n    \"Allow everyone to view Access Rules.\": \"Permetti a tutti di vedere le regole di accesso.\",\n    \"Condition\": \"Condizione\",\n    \"Default rules\": \"Regole di default\",\n    \"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.\": \"Permetti a tutti di copiare l'intero documento, o vederlo interamente in modalità sandbox.\\nUtile per esempi e template, ma non per i dati sensibili.\",\n    \"Attribute to Look Up\": \"Attributo da cercare\",\n    \"Permissions\": \"Permessi\",\n    \"Lookup Column\": \"Colonna di ricerca\",\n    \"Remove column {{- colId }} from {{- tableId }} rules\": \"Rimuovi la colonna {{- colId }} dalle regole di {{- tableId }}\",\n    \"View as\": \"Vedi come\",\n    \"Add column rule\": \"Aggiungi regola per la colonna\",\n    \"Add user attributes\": \"Aggiungi attributi degli utenti\",\n    \"Attribute name\": \"Nome dell'attributo\",\n    \"Checking...\": \"Controllo…\",\n    \"Enter Condition\": \"Inserisci la condizione\",\n    \"Everyone\": \"Chiunque\",\n    \"Everyone Else\": \"Chiunque altro\",\n    \"Invalid\": \"Non valido\",\n    \"Permission to access the document in full when needed\": \"Permesso di accedere al documento per intero quando necessario\",\n    \"Permission to view Access Rules\": \"Permesso di vedere le regole di accesso\",\n    \"Remove {{- tableId }} rules\": \"Rimuovi le regole di {{- tableId }}\",\n    \"Remove {{- name }} user attribute\": \"Rimuovi l'attributo utente {{- name }}\",\n    \"Reset\": \"Reset\",\n    \"Rules for table \": \"Regole per la tabella \",\n    \"Save\": \"Salva\",\n    \"Saved\": \"Salvato\",\n    \"Special rules\": \"Regole speciali\",\n    \"Type message to display when this rule blocks an action…\": \"Inserisci un messaggio…\",\n    \"User Attributes\": \"Attributi dell'utente\",\n    \"Seed rules\": \"Regole pre-inserite\",\n    \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Quando si mettono regole per la tabella, inserire sempre una che dà pieno accesso a OWNER.\",\n    \"This default should be changed if editors' access is to be limited. \": \"Questo valore di default può essere cambiato se si vuole limitare l'accesso agli editor. \",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Permette agli editor di modificare la struttura (es., modificare ed eliminare tabelle, colonne, viste) e scrivere formule, cosa che permette l'accesso ai dati indipendentemente dalle restrizioni in lettura.\",\n    \"Add table-wide rule\": \"Aggiungi regola per tutta la tabella\"\n  },\n  \"ACUserManager\": {\n    \"Enter email address\": \"Inserisci indirizzo e-mail\",\n    \"Invite new member\": \"Invita un nuovo membro\",\n    \"We'll email an invite to {{email}}\": \"Invieremo una email a {{email}}\"\n  },\n  \"ViewAsDropdown\": {\n    \"View as\": \"Vedi come\",\n    \"Users from table\": \"Utenti tabella\",\n    \"Example Users\": \"Utenti di esempio\"\n  },\n  \"AddNewButton\": {\n    \"Add new\": \"Nuovo\"\n  },\n  \"ApiKey\": {\n    \"By generating an API key, you will be able to make API calls for your own account.\": \"Genera una chiave API per fare chiamate API per il tuo account.\",\n    \"Click to show\": \"Clicca per vedere\",\n    \"Create\": \"Crea\",\n    \"Remove\": \"Rimuovi\",\n    \"Remove API Key\": \"Rimuovi la chiave API\",\n    \"This API key can be used to access this account anonymously via the API.\": \"Questa chiave API può essere usata per accede anonimamente a questo account tramite le API.\",\n    \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Questa chiave API può essere usata per accedere al tuo account tramite le API. Non dare a nessuno la tua chiave API.\",\n    \"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?\": \"Stai per eliminare una chiave API. Tutte le richieste future che usano questa chiave verranno respinte. Vuoi procedere lo stesso?\"\n  },\n  \"AppHeader\": {\n    \"Home page\": \"Home page\",\n    \"Legacy\": \"Vecchia versione\",\n    \"Personal Site\": \"Sito personale\",\n    \"Team Site\": \"Sito del team\",\n    \"Grist Templates\": \"Template di Grist\",\n    \"Billing account\": \"Dati di fatturazione\",\n    \"Manage team\": \"Gestisci il team\"\n  },\n  \"ChartView\": {\n    \"Create separate series for each value of the selected column.\": \"Creare serie separate per ciascun valore delle colonne selezionate.\",\n    \"Pick a column\": \"Scegli una colonna\",\n    \"Toggle chart aggregation\": \"Grafici aggregati\",\n    \"selected new group data columns\": \"Selezionato un nuovo gruppo-colonne dati\",\n    \"Each Y series is followed by a series for the length of error bars.\": \"Ciascuna serie Y è seguita da una serie per la lunghezza della barra di errore.\",\n    \"Each Y series is followed by two series, for top and bottom error bars.\": \"Ciascuna serie Y è seguita da due serie, per le barre di errore alta e bassa.\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"Applica\",\n    \"Cancel\": \"Annulla\",\n    \"Default cell style\": \"Stile cella di default\"\n  },\n  \"ColumnFilterMenu\": {\n    \"All\": \"Tutto\",\n    \"All except\": \"Tutto tranne\",\n    \"All shown\": \"Tutto è visibile\",\n    \"Filter by Range\": \"Filtra per intervallo\",\n    \"Future values\": \"Valori futuri\",\n    \"No matching values\": \"Nessun valore corrisponde\",\n    \"None\": \"Nessuno\",\n    \"Max\": \"Max\",\n    \"Start\": \"Inizio\",\n    \"End\": \"Fine\",\n    \"Other Matching\": \"Altre corrispondenze\",\n    \"Other Non-Matching\": \"Altre non-corrispondenze\",\n    \"Other values\": \"Altri valori\",\n    \"Others\": \"Altro\",\n    \"Search\": \"Cerca\",\n    \"Search values\": \"Cerca valori\",\n    \"Min\": \"Min\"\n  },\n  \"CustomSectionConfig\": {\n    \" (optional)\": \" (opzionale)\",\n    \"Full document access\": \"Accesso completo al documento\",\n    \"No document access\": \"Nessun accesso al documento\",\n    \"Open configuration\": \"Apri configurazione\",\n    \"Pick a column\": \"Scegli una colonna\",\n    \"Pick a {{columnType}} column\": \"Scegli una colonna {{columnType}}\",\n    \"Read selected table\": \"Leggi la tabella selezionata\",\n    \"Widget does not require any permissions.\": \"Il Widget non richiede alcun permesso.\",\n    \"Widget needs to {{read}} the current table.\": \"Il Widget ha bisogno di {{read}} la tabella corrente.\",\n    \"Widget needs {{fullAccess}} to this document.\": \"Il Widget richiede {{fullAccess}} a questo documento.\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} colonna non-{{columnType}} non è mostrata\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} colonne non-{{columnType}} non sono mostrate\",\n    \"Add\": \"Aggiungi\",\n    \"Enter Custom URL\": \"Inserisci URL personalizzata\",\n    \"Learn more about custom widgets\": \"Scopri di più sui widget personalizzati\",\n    \"Select Custom Widget\": \"Seleziona un Widget personalizzato\",\n    \"No {{columnType}} columns in table.\": \"Nessuna colonna {{columnType}} nella tabella.\",\n    \"Clear selection\": \"Deseleziona tutto\",\n    \"Custom URL\": \"URL personalizzata\",\n    \"ACCESS LEVEL\": \"LIVELLO DI ACCESSO\",\n    \"Accept\": \"Accetta\",\n    \"Developer:\": \"Sviluppatore:\",\n    \"Last updated:\": \"Ultimo aggiornamento:\",\n    \"Missing description and author information.\": \"Mancano descrizione e info sull'autore.\",\n    \"Reject\": \"Rifiuta\",\n    \"Widget\": \"Widget\"\n  },\n  \"DataTables\": {\n    \"Click to copy\": \"Clicca per copiare\",\n    \"Duplicate table\": \"Duplica tabella\",\n    \"Raw Data Tables\": \"Tabelle dati primarie\",\n    \"Table ID copied to clipboard\": \"ID tabella copiato\",\n    \"You do not have edit access to this document\": \"Non hai accesso in scrittura a questo documento\",\n    \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Cancellare i dati di {{formattedTableName}} e rimuoverla da tutte le pagine?\",\n    \"Edit record card\": \"Modifica scheda dei dati\",\n    \"Rename table\": \"Rinomina tabella\",\n    \"{{action}} Record Card\": \"{{action}} Scheda dei dati\",\n    \"Record Card\": \"Scheda dei dati\",\n    \"Remove table\": \"Rimuovi tabella\",\n    \"Record Card Disabled\": \"Scheda dei dati disabilitata\"\n  },\n  \"DocHistory\": {\n    \"Compare to previous\": \"Confronta con il precedente\",\n    \"Compare to current\": \"Confronta con il corrente\",\n    \"Open snapshot\": \"Apri snapshot\",\n    \"Snapshots\": \"Snapshot\",\n    \"Snapshots are unavailable.\": \"Le snapshot non sono disponibili.\",\n    \"Activity\": \"Attvità\",\n    \"Beta\": \"Beta\",\n    \"Only owners have access to snapshots for documents with access rules.\": \"Solo i proprietari hanno accesso agli snapshot per i documenti con regole di accesso.\"\n  },\n  \"DocMenu\": {\n    \"(The organization needs a paid plan)\": \"(L'organizzazione deve avere un piano a pagamento)\",\n    \"Access Details\": \"Dettagli di accesso\",\n    \"All documents\": \"Tutti i documenti\",\n    \"By Date Modified\": \"Per data di modifica\",\n    \"By Name\": \"Per nome\",\n    \"Current workspace\": \"Spazio di lavoro corrente\",\n    \"Delete\": \"Elimina\",\n    \"Delete {{name}}\": \"Elimina {{name}}\",\n    \"Deleted {{at}}\": \"Eliminato {{at}}\",\n    \"Discover More Templates\": \"Scopri altri template\",\n    \"Document will be moved to Trash.\": \"Il documento sarà spostato nel cestino.\",\n    \"Document will be permanently deleted.\": \"Il documento sarà eliminato definitivamente.\",\n    \"Edited {{at}}\": \"Modificato {{at}}\",\n    \"Examples & Templates\": \"Esempi e template\",\n    \"Examples and Templates\": \"Esempi e template\",\n    \"Featured\": \"In evidenza\",\n    \"Manage users\": \"Gestisci gli utenti\",\n    \"Move\": \"Sposta\",\n    \"Move {{name}} to workspace\": \"Sposta {{name}} nello spazio di lavoro\",\n    \"Other Sites\": \"Altri siti\",\n    \"Pin Document\": \"Fissa il documento\",\n    \"Pinned Documents\": \"Documenti fissati\",\n    \"Remove\": \"Rimuovi\",\n    \"Rename\": \"Rinomina\",\n    \"Requires edit permissions\": \"Richiede permessi di modifica\",\n    \"Restore\": \"Ripristina\",\n    \"This service is not available right now\": \"Questo servizio non è attualmente disponibile\",\n    \"Trash\": \"Cestino\",\n    \"Trash is empty.\": \"Il cestino è vuoto.\",\n    \"Workspace not found\": \"Spazio di lavoro non trovato\",\n    \"You are on your personal site. You also have access to the following sites:\": \"Sei nel tuo sito personale. Hai anche accesso a questi siti:\",\n    \"You may delete a workspace forever once it has no documents in it.\": \"Puoi eliminare uno spazio di lavoro definitivamente, quando non contiene più documenti.\",\n    \"Delete Forever\": \"Elimina per sempre\",\n    \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Il documento sarà nel cestino per 30 giorni, quindi verrà eliminato definitivamente.\",\n    \"Permanently Delete \\\"{{name}}\\\"?\": \"Eliminare \\\"{{name}}\\\" definitivamente?\",\n    \"More Examples and Templates\": \"Altri esempi e template\",\n    \"To restore this document, restore the workspace first.\": \"Per ripristinare il documento, ripristina prima lo spazio di lavoro.\",\n    \"Unpin Document\": \"Non fissare il documento\",\n    \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Sei nel sito {{siteName}}. Hai anche accesso a questi siti:\",\n    \"Any documents created in this site will appear here.\": \"Qualsiasi documento creato in questo sito compare qui.\",\n    \"Create my first document\": \"Creare il mio primo documento\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Hai solo accesso in lettura a questo sito. Non ci sono documenti da vedere.\",\n    \"personal site\": \"sito personale\"\n  },\n  \"DocPageModel\": {\n    \"Add empty table\": \"Aggiungi tabella vuota\",\n    \"Add page\": \"Aggiungi pagina\",\n    \"Add widget to page\": \"Aggiungi Widget alla pagina\",\n    \"Enter recovery mode\": \"Entra in modalità ripristino\",\n    \"Reload\": \"Ricarica\",\n    \"Sorry, access to this document has been denied. [{{error}}]\": \"Spiacente, l'accesso a questo documento è stato rifiutato. [{{error}}]\",\n    \"You do not have edit access to this document\": \"Non hai accesso in scrittura a questo documento\",\n    \"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}}]\": \"Puoi provare a ricaricare il documento, o usare la modalità di ricovero. In modalità di ricovero il documento è pienamente accessibile al proprietario e inaccessibile a tutti gli altri. Inoltre le formule sono disabilitate. [{{error}}]\",\n    \"Document owners can attempt to recover the document. [{{error}}]\": \"Il proprietario del documento può tentare di ripristinarlo. [{{error}}]\",\n    \"Error accessing document\": \"Errore nell'accedere al documento\"\n  },\n  \"DocTour\": {\n    \"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.\": \"Non è possibile costruire un tour del documento dai dati contenuti. Verificare che esista una tabella GristDocTour con le colonne Title, Body, Placement e Location.\",\n    \"No valid document tour\": \"Nessun tour del documento valido\"\n  },\n  \"DocumentSettings\": {\n    \"Currency:\": \"Valuta:\",\n    \"Document settings\": \"Impostazioni del documento\",\n    \"Engine (experimental {{span}} change at own risk):\": \"Motore (sperimentale {{span}} modifica a tuo rischio):\",\n    \"Local currency ({{currency}})\": \"Valuta locale ({{currency}})\",\n    \"Locale:\": \"Locale:\",\n    \"Save\": \"Salva\",\n    \"Save and Reload\": \"Salva e ricarica\",\n    \"This document's ID (for API use):\": \"L'ID di questo documento (da usare con le API):\",\n    \"Time Zone:\": \"Zona oraria:\",\n    \"API\": \"API\",\n    \"Document ID copied to clipboard\": \"ID del documento copiato\",\n    \"Ok\": \"OK\",\n    \"Manage Webhooks\": \"Gestisci web hook\",\n    \"Webhooks\": \"Web hook\",\n    \"API console\": \"Console Api\",\n    \"Coming soon\": \"In arrivo\",\n    \"Copy to clipboard\": \"Copia negli appunti\",\n    \"Data engine\": \"Motore dati\",\n    \"Default for DateTime columns\": \"Default per le colonne Data/Ora\",\n    \"Document ID\": \"ID documento\",\n    \"Find slow formulas\": \"Trova le formule lente\",\n    \"For currency columns\": \"Per le colonne di valuta\",\n    \"For number and date formats\": \"Per i formati di numeri e date\",\n    \"Formula times\": \"Tempi delle formule\",\n    \"Hard reset of data engine\": \"Reset forzato del motore dati\",\n    \"ID for API use\": \"ID per l'uso con le Api\",\n    \"Locale\": \"Locale\",\n    \"Manage webhooks\": \"Gestisci i webhooks\",\n    \"Notify other services on doc changes\": \"Notifica altri servizi dei cambiamenti nel documento\",\n    \"Python\": \"Python\",\n    \"Reload\": \"Ricarica\",\n    \"Time zone\": \"Fuso orario\",\n    \"python2 (legacy)\": \"Python 2 (superato)\",\n    \"python3 (recommended)\": \"Python 3 (raccomandato)\",\n    \"API URL copied to clipboard\": \"Url della Api copiata negli appunti\",\n    \"API documentation.\": \"Documentazione Api.\",\n    \"Base doc URL: {{docApiUrl}}\": \"Url documento base: {{docApiUrl}}\",\n    \"Currency\": \"Valuta\",\n    \"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}}\",\n    \"Python version used\": \"Versione di Python in uso\",\n    \"Try API calls from the browser\": \"Prova le chiamate Api nel browser\",\n    \"Stop timing...\": \"Ferma cronometro...\",\n    \"Timing is on\": \"Cronometro attivo\",\n    \"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.\",\n    \"Cancel\": \"Annulla\",\n    \"Reload data engine?\": \"Ricarica il motore dati?\",\n    \"Force reload the document while timing formulas, and show the result.\": \"Ricarica il documenti cronometrando le formule, e mostra il risultato.\",\n    \"Formula timer\": \"Cronometro per le formule\",\n    \"Reload data engine\": \"Ricarica il motore dati\",\n    \"Start timing\": \"Avvia cronometro\",\n    \"Time reload\": \"Ricarica tempo\",\n    \"Only available to document editors\": \"Disponibile solo per gli editor del documento\",\n    \"Only available to document owners\": \"Disponibile solo per i proprietari del documento\",\n    \"Template mode\": \"Modalità template\",\n    \"Edit\": \"Modifica\",\n    \"Template\": \"Template\",\n    \"Tutorial\": \"Tutorial\",\n    \"Confirm change\": \"Conferma la modifica\",\n    \"Regular\": \"Normale\",\n    \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Verrà effettuato un riavvio del motore dati. Questo può aiutare se il motore è bloccato in un ciclo infinito, non riesce a terminare il processamento dell'ultima modifica, o è andato in crash. Nessun dato verrà perduto, tranne potenzialmente quelli dell'azione che viene interrotta.\",\n    \"Change document type\": \"Cambia tipo di documento\",\n    \"Normal document behavior. All users work on the same copy of the document.\": \"Normale comportamento per il documento. Tutti gli utenti lavorano sulla stessa copia del documento.\",\n    \"fiddle mode\": \"modalità \\\"fiddle\\\"\",\n    \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Il documento si apre automaticamente in {{fiddleModeDocUrl}}. Chiunque può modificare, e questo crea una nuova copia non salvata.\",\n    \"Change nature of document\": \"Cambia natura del documento\",\n    \"Regular document\": \"Documento normale\",\n    \"Document automatically opens as a user-specific copy.\": \"Il documento si apre automaticamente in una copia privata dell'utente.\",\n    \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Avviato il timer, Grist misura il tempo occorrente per valutare ogni formula. Questo permette di capire quali formule sono responsabili per un rallentamento del sistema quando il documento viene aperto, o quando si aggiorna dopo un cambiamento.\",\n    \"External\": \"Esterno\",\n    \"Internal\": \"Interno\",\n    \"Transfer in progress\": \"Trasferimento in corso\",\n    \"**Some existing attachments are still [external]({{externalLink}})**.\": \"*Alcuni allegati sono ancora [esterni]({{externalLink}})**.\",\n    \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Alcuni allegati sono ancora [interni]({{internalLink}})** (conservati nel file SQLite).\",\n    \"[Learn more.]({{learnLink}})\": \"[Scopri di più.]({{learnLink}})\",\n    \"**Some existing attachments are still external**.\": \"**Alcuni allegati sono ancora esterni**.\",\n    \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Alcuni allegati sono ancora interni** (conservati dentro il file SQLite).\",\n    \"Attachment storage\": \"Conservazione degli allegati\",\n    \"Being transfer\": \"Trasferimento in corso\",\n    \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Fai click su \\\"Inizia il trasferimento\\\" per trasferire questi a uno storage esterno.\",\n    \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Fai click su \\\"Inizia il trasferimento\\\" per trasferire questi nello storage interno (nel file SQLite del documento).\",\n    \"Newly uploaded attachments will be placed in External storage.\": \"Gli allegati nuovi verranno collocati nello storage esterno.\",\n    \"Newly uploaded attachments will be placed in Internal storage.\": \"Gli allegati nuovi verranno caricati nello storage interno.\",\n    \"No external stores available\": \"Nessuno storage esterno disponibile\",\n    \"Preferred storage for this document\": \"Storage preferito per questo documento\",\n    \"Start transfer\": \"Inizia il trasferimento\"\n  },\n  \"DocumentUsage\": {\n    \"Data size\": \"Dimensione dei dati\",\n    \"For higher limits, \": \"Per limiti più alti, \",\n    \"Rows\": \"Righe\",\n    \"Usage\": \"Utilizzo\",\n    \"Usage statistics are only available to users with full access to the document data.\": \"Le statistiche sull'utilizzo sono disponibili solo per gli utenti con pieno accesso ai dati del documento.\",\n    \"start your 30-day free trial of the Pro plan.\": \"Inizia i tuoi 30 giorni di prova gratuita del piano Pro.\",\n    \"Size of attachments\": \"Dimensione degli allegati\",\n    \"Contact the site owner to upgrade the plan to raise limits.\": \"Contatta il proprietario del sito per migliorare il piano e alzare i limiti.\"\n  },\n  \"Drafts\": {\n    \"Restore last edit\": \"Ripristina l'ultima modifica\",\n    \"Undo discard\": \"Annulla elimina\"\n  },\n  \"DuplicateTable\": {\n    \"Copy all data in addition to the table structure.\": \"Copiare tutti i dati insieme alla struttura della tabella.\",\n    \"Name for new table\": \"Nome della nuova tabella\",\n    \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Invece di duplicare le tabelle, di solito è meglio suddividere i dati con le viste collegate. {{link}}\",\n    \"Only the document default access rules will apply to the copy.\": \"Solo le regole di accesso di default del documento si applicheranno alla copia.\"\n  },\n  \"ExampleInfo\": {\n    \"Afterschool Program\": \"Programma doposcuola\",\n    \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Consulta il tutorial per sapere come collegare i dati e creare layout più efficienti.\",\n    \"Investment Research\": \"Ricerca di investimento\",\n    \"Lightweight CRM\": \"Semplice CRM\",\n    \"Tutorial: Analyze & Visualize\": \"Tutorial: Analizzare e visualizzare\",\n    \"Tutorial: Manage Business Data\": \"Tutorial: Gestire i dati di business\",\n    \"Welcome to the Lightweight CRM template\": \"Benvenuti al template del Semplice CRM\",\n    \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Consulta il tutorial per il design dei dati, l'uso delle formule e la gestione dei casi complessi.\",\n    \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Consulta il tutorial per sapere come creare tabelle riassuntive e grafici, e come collegare dinamicamente i grafici.\",\n    \"Welcome to the Afterschool Program template\": \"Benvenuti al template del Programma doposcuola\",\n    \"Tutorial: Create a CRM\": \"Tutorial: Creare un CRM\",\n    \"Welcome to the Investment Research template\": \"Benvenuti al template della Ricerca d'investimento\"\n  },\n  \"FieldConfig\": {\n    \"COLUMN BEHAVIOR\": \"COMPORTAMENTO DELLA COLONNA\",\n    \"COLUMN LABEL AND ID\": \"ETICHETTA E ID DELLA COLONNA\",\n    \"Clear and make into formula\": \"Cancella e trasforma in formula\",\n    \"Clear and reset\": \"Cancella e resetta\",\n    \"Column options are limited in summary tables.\": \"Le opzioni delle colonne sono limitate nelle tabelle riassuntive.\",\n    \"Convert column to data\": \"Converti la colonna in dati\",\n    \"Convert to trigger formula\": \"Converti in una formula trigger\",\n    \"Data columns_one\": \"Colonna di dati\",\n    \"Data columns_other\": \"Colonne di dati\",\n    \"Empty columns_one\": \"Colonna vuota\",\n    \"Empty columns_other\": \"Colonne vuote\",\n    \"Enter formula\": \"Inserisci formula\",\n    \"Formula columns_one\": \"Colonna di formula\",\n    \"Formula columns_other\": \"Colonne di formula\",\n    \"Mixed Behavior\": \"Comportamento misto\",\n    \"Set formula\": \"Imposta formula\",\n    \"Set trigger formula\": \"Imposta formula trigger\",\n    \"TRIGGER FORMULA\": \"FORMULA TRIGGER\",\n    \"Make into data column\": \"Trasforma in una colonna di dati\",\n    \"DESCRIPTION\": \"DESCRIZIONE\"\n  },\n  \"FieldMenus\": {\n    \"Save as common settings\": \"Salva come impostazioni comuni\",\n    \"Using common settings\": \"Sto usando le impostazioni comuni\",\n    \"Use separate settings\": \"Usa impostazioni separate\",\n    \"Revert to common settings\": \"Riporta alle impostazioni comuni\",\n    \"Using separate settings\": \"Sto usando impostazioni separate\"\n  },\n  \"FilterConfig\": {\n    \"Add column\": \"Aggiungi colonna\"\n  },\n  \"FilterBar\": {\n    \"SearchColumns\": \"Cerca nelle colonne\",\n    \"Search Columns\": \"Cerca nelle colonne\"\n  },\n  \"GridOptions\": {\n    \"Grid Options\": \"Opzioni della griglia\",\n    \"Horizontal gridlines\": \"Linee orizzontali della griglia\",\n    \"Zebra stripes\": \"Strisce alternate\",\n    \"Vertical gridlines\": \"Linee verticali della griglia\"\n  },\n  \"GridViewMenus\": {\n    \"Add column\": \"Aggiungi colonna\",\n    \"Add to sort\": \"Aggiungi all'ordinamento\",\n    \"Clear values\": \"Cancella valori\",\n    \"Column Options\": \"Opzioni colonna\",\n    \"Convert formula to data\": \"Converti la formula in dati\",\n    \"Delete {{count}} columns_one\": \"Elimina colonna\",\n    \"Delete {{count}} columns_other\": \"Elimina {{count}} colonne\",\n    \"Filter Data\": \"Filtra dati\",\n    \"Hide {{count}} columns_one\": \"Nascondi colonna\",\n    \"Freeze {{count}} more columns_one\": \"Blocca ancora una colonna\",\n    \"Freeze {{count}} columns_one\": \"Blocca questa colonna\",\n    \"Freeze {{count}} columns_other\": \"Blocca {{count}} colonne\",\n    \"Freeze {{count}} more columns_other\": \"Blocca ancora {{count}} colonne\",\n    \"Insert column to the {{to}}\": \"Inserisci colonna a {{to}}\",\n    \"More sort options ...\": \"Altre opzioni di ordinamento…\",\n    \"Rename column\": \"Rinomina colonna\",\n    \"Reset {{count}} columns_one\": \"Resetta colonna\",\n    \"Reset {{count}} entire columns_one\": \"Resetta l'intera colonna\",\n    \"Reset {{count}} entire columns_other\": \"Resetta {{count}} intere colonne\",\n    \"Sort\": \"Ordina\",\n    \"Sorted (#{{count}})_one\": \"Ordinato (#{{count}})\",\n    \"Unfreeze all columns\": \"Sblocca tutte le colonne\",\n    \"Unfreeze {{count}} columns_one\": \"Sblocca questa colonna\",\n    \"Hide {{count}} columns_other\": \"Nascondi {{count}} colonne\",\n    \"Reset {{count}} columns_other\": \"Resetta {{count}} colonne\",\n    \"Show column {{- label}}\": \"Mostra colonna {{- label}}\",\n    \"Sorted (#{{count}})_other\": \"Ordinati (#{{count}})\",\n    \"Unfreeze {{count}} columns_other\": \"Sblocca {{count}} colonne\",\n    \"Insert column to the left\": \"Inserisci colonna a sinistra\",\n    \"Insert column to the right\": \"Inserisci colonna a destra\",\n    \"Detect Duplicates in...\": \"Rilevati duplicati in...\",\n    \"UUID\": \"UUID\",\n    \"Shortcuts\": \"Scorciatoie\",\n    \"Show hidden columns\": \"Mostra colonne nascoste\",\n    \"Created At\": \"Creato il\",\n    \"Authorship\": \"Autore\",\n    \"Last Updated By\": \"Ultimo aggiornamento da\",\n    \"Hidden Columns\": \"Colonne nascoste\",\n    \"Lookups\": \"Campi relativi\",\n    \"No reference columns.\": \"Non ci sono colonne referenziate.\",\n    \"Apply on record changes\": \"Applica quando il record è modificato\",\n    \"Duplicate in {{- label}}\": \"Duplicato in {{- label}}\",\n    \"Created By\": \"Creato da\",\n    \"Last Updated At\": \"Ultimo aggiornamento il\",\n    \"Apply to new records\": \"Applica ai nuovi record\",\n    \"Search columns\": \"Cerca colonne\",\n    \"Timestamp\": \"Data e ora\",\n    \"no reference column\": \"nessuna colonna referenziata\",\n    \"Adding UUID column\": \"Aggiungere colonna UUID\",\n    \"Adding duplicates column\": \"Aggiungere colonna duplicati\",\n    \"Add formula column\": \"Aggiungi colonna con formula\",\n    \"Add column with type\": \"Aggiungi colonna con tipo\",\n    \"Created by\": \"Creato da\",\n    \"Created at\": \"Creato a\",\n    \"Last updated by\": \"Ultimo aggiornamento di\",\n    \"Detect duplicates in...\": \"Rileva duplicati in...\",\n    \"Last updated at\": \"Ultimo aggiornamento alle\",\n    \"Any\": \"Qualsiasi\",\n    \"Numeric\": \"Numerico\",\n    \"Text\": \"Testo\",\n    \"Integer\": \"Intero\",\n    \"Toggle\": \"Alterna\",\n    \"Date\": \"Data\",\n    \"DateTime\": \"Data e ora\",\n    \"Choice\": \"Scelta\",\n    \"Choice List\": \"Lista di scelte\",\n    \"Reference\": \"Riferimento\",\n    \"Reference List\": \"Lista di riferimenti\",\n    \"Attachment\": \"Allegato\"\n  },\n  \"GristDoc\": {\n    \"Import from file\": \"Importa da file\",\n    \"Saved linked section {{title}} in view {{name}}\": \"Salvata la sezione collegata {{title}} nella vista {{name}}\",\n    \"Added new linked section to view {{viewName}}\": \"Aggiunta nuova sezione collegata alla vista {{viewName}}\",\n    \"go to webhook settings\": \"vai alle impostazioni dei webhook\"\n  },\n  \"LeftPanelCommon\": {\n    \"Help Center\": \"Centro assistenza\"\n  },\n  \"OnBoardingPopups\": {\n    \"Finish\": \"Termina\",\n    \"Next\": \"Prossimo\",\n    \"Previous\": \"Precedente\"\n  },\n  \"OpenVideoTour\": {\n    \"Grist Video Tour\": \"Video tour di Grist\",\n    \"Video Tour\": \"Video tour\",\n    \"YouTube video player\": \"Player di YouTube\"\n  },\n  \"PageWidgetPicker\": {\n    \"Add to page\": \"Aggiungi a pagina\",\n    \"Building {{- label}} widget\": \"Costruisco il widget {{- label}}\",\n    \"Group by\": \"Raggruppa\",\n    \"Select data\": \"Seleziona dati\",\n    \"Select widget\": \"Seleziona widget\"\n  },\n  \"PermissionsWidget\": {\n    \"Allow all\": \"Autorizza tutto\",\n    \"Deny all\": \"Nega tutto\",\n    \"Read only\": \"Sola lettura\"\n  },\n  \"PluginScreen\": {\n    \"Import failed: \": \"Importazione fallita: \"\n  },\n  \"RecordLayout\": {\n    \"Updating record layout.\": \"Aggiorno il layout del record.\"\n  },\n  \"RefSelect\": {\n    \"Add column\": \"Aggiungi colonna\",\n    \"No columns to add\": \"Nessuna colonna da aggiungere\"\n  },\n  \"SelectionSummary\": {\n    \"Copied to clipboard\": \"Copiato nella clipboard\"\n  },\n  \"ShareMenu\": {\n    \"Access Details\": \"Dettagli di accesso\",\n    \"Back to current\": \"Indietro a corrente\",\n    \"Compare to {{termToUse}}\": \"Confronta con {{termToUse}}\",\n    \"Current Version\": \"Versione attuale\",\n    \"Download\": \"Scarica\",\n    \"Duplicate document\": \"Duplica documento\",\n    \"Edit without affecting the original\": \"Modifica senza toccare l'originale\",\n    \"Export CSV\": \"Esporta CSV\",\n    \"Export XLSX\": \"Esporta XLSX\",\n    \"Manage users\": \"Gestisci gli utenti\",\n    \"Original\": \"Originale\",\n    \"Replace {{termToUse}}...\": \"Sostituisci {{termToUse}}…\",\n    \"Return to {{termToUse}}\": \"Torna a {{termToUse}}\",\n    \"Save copy\": \"Salva copia\",\n    \"Save Document\": \"Salva documento\",\n    \"Send to Google Drive\": \"Invia a Google Drive\",\n    \"Show in folder\": \"Mostra nella cartella\",\n    \"Unsaved\": \"Non salvato\",\n    \"Work on a copy\": \"Lavora su una copia\",\n    \"Share\": \"Condividi\",\n    \"Download...\": \"Scarica...\",\n    \"DOO Separated Values (.dsv)\": \"Valori separati da delimitatore (.dsv)\",\n    \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n    \"Tab Separated Values (.tsv)\": \"Valori separati da tabulazione (.tsv)\",\n    \"Comma Separated Values (.csv)\": \"Valori separati da virgola (.csv)\",\n    \"Export as...\": \"Esporta come...\"\n  },\n  \"SiteSwitcher\": {\n    \"Create new team site\": \"Crea un nuovo sito per il team\",\n    \"Switch Sites\": \"Cambia sito\"\n  },\n  \"ThemeConfig\": {\n    \"Appearance \": \"Aspetto \",\n    \"Switch appearance automatically to match system\": \"Cambia aspetto automaticamente con il sistema\"\n  },\n  \"TopBar\": {\n    \"Manage team\": \"Gestisci il team\"\n  },\n  \"TypeTransformation\": {\n    \"Apply\": \"Applica\",\n    \"Cancel\": \"Annulla\",\n    \"Preview\": \"Anteprima\",\n    \"Revise\": \"Rivedi\",\n    \"Update formula (Shift+Enter)\": \"Aggiorna formula (Maiusc + Invio)\"\n  },\n  \"UserManagerModel\": {\n    \"Editor\": \"Redattore\",\n    \"In full\": \"Per intero\",\n    \"No Default Access\": \"Nessun accesso di default\",\n    \"None\": \"Nessuno\",\n    \"Owner\": \"Proprietario\",\n    \"View & edit\": \"Vedi e modifica\",\n    \"View only\": \"Vedi solo\",\n    \"Viewer\": \"Lettore\"\n  },\n  \"ValidationPanel\": {\n    \"Rule {{length}}\": \"Regola {{length}}\",\n    \"Update formula (Shift+Enter)\": \"Aggiorna formula (Maiusc + Invio)\"\n  },\n  \"ViewAsBanner\": {\n    \"UnknownUser\": \"Utente sconosciuto\",\n    \"You are viewing this document as\": \"Stai vedendo questo documento come\",\n    \"View as Yourself\": \"Vedi come te stesso\"\n  },\n  \"ViewLayoutMenu\": {\n    \"Advanced sort & filter\": \"Ordina e filtra avanzato\",\n    \"Copy anchor link\": \"Copia link\",\n    \"Data selection\": \"Selezione dati\",\n    \"Delete record\": \"Elimina record\",\n    \"Delete widget\": \"Elimina widget\",\n    \"Download as CSV\": \"Scarica come CSV\",\n    \"Download as XLSX\": \"Scarica come XLSX\",\n    \"Edit card layout\": \"Modifica layout scheda\",\n    \"Open configuration\": \"Apri configurazione\",\n    \"Print widget\": \"Widget di stampa\",\n    \"Show raw data\": \"Mostra dati grezzi\",\n    \"Widget options\": \"Opzioni widget\",\n    \"Add to page\": \"Aggiungi a pagina\",\n    \"Collapse widget\": \"Compatta widget\",\n    \"Create a form\": \"Crea un modulo\"\n  },\n  \"ViewSectionMenu\": {\n    \"(customized)\": \"(personalizzato)\",\n    \"(empty)\": \"(vuoto)\",\n    \"(modified)\": \"(modificato)\",\n    \"Custom options\": \"Opzioni personalizzate\",\n    \"FILTER\": \"FILTRO\",\n    \"Revert\": \"Ripristina\",\n    \"SORT\": \"ORDINA\",\n    \"Save\": \"Salva\",\n    \"Update Sort&Filter settings\": \"Aggiorna impostazioni Ordina e Filtra\"\n  },\n  \"VisibleFieldsConfig\": {\n    \"Cannot drop items into Hidden Fields\": \"Impossibile collocare elementi in campi nascosti\",\n    \"Clear\": \"Svuota\",\n    \"Hidden Fields cannot be reordered\": \"Impossibile riordinare i campi nascosti\",\n    \"Select all\": \"Seleziona tutto\",\n    \"Hidden {{label}}\": \"Nascosta {{label}}\",\n    \"Hide {{label}}\": \"Nascondi {{label}}\",\n    \"Visible {{label}}\": \"Visibile {{label}}\",\n    \"Show {{label}}\": \"Mostra {{label}}\"\n  },\n  \"WidgetTitle\": {\n    \"Cancel\": \"Annulla\",\n    \"DATA TABLE NAME\": \"NOME TABELLA DATI\",\n    \"Override widget title\": \"Sovrascrivi titolo widget\",\n    \"Provide a table name\": \"Inserisci un nome per la tabella\",\n    \"Save\": \"Salva\",\n    \"WIDGET TITLE\": \"TITOLO WIDGET\",\n    \"WIDGET DESCRIPTION\": \"DESCRIZIONE WIDGET\"\n  },\n  \"breadcrumbs\": {\n    \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Puoi fare delle modifiche, ma queste generano una nuova copia\\ne l'originale resta immutato.\",\n    \"fiddle\": \"sperimenta\",\n    \"override\": \"sovrascrivi\",\n    \"recovery mode\": \"modalità di ricovero\",\n    \"snapshot\": \"istantanea\",\n    \"unsaved\": \"non salvato\"\n  },\n  \"menus\": {\n    \"* Workspaces are available on team plans. \": \"*Gli spazi di lavoro sono disponibili nel piano per i team. \",\n    \"Select fields\": \"Seleziona campi\",\n    \"Upgrade now\": \"Aggiorna ora\",\n    \"Any\": \"Qualsiasi\",\n    \"Text\": \"Testo\",\n    \"Integer\": \"Intero\",\n    \"Toggle\": \"Interruttore\",\n    \"Date\": \"Data\",\n    \"DateTime\": \"Data/ora\",\n    \"Reference\": \"Riferimento\",\n    \"Reference List\": \"Lista di riferimenti\",\n    \"Choice List\": \"Scelta da lista\",\n    \"Attachment\": \"Allegato\",\n    \"Numeric\": \"Numerico\",\n    \"Choice\": \"Scelta\",\n    \"Search columns\": \"Cerca colonne\",\n    \"By Name\": \"Per nome\",\n    \"Light\": \"Chiaro\",\n    \"By Date Modified\": \"Per data di modifica\",\n    \"Custom\": \"Personalizzato\"\n  },\n  \"modals\": {\n    \"Cancel\": \"Annulla\",\n    \"Ok\": \"OK\",\n    \"Save\": \"Salva\",\n    \"Don't show again\": \"Non mostrare più\",\n    \"Undo to restore\": \"Annulla per ripristinare\",\n    \"Got it\": \"Capito\",\n    \"Are you sure you want to delete this record?\": \"Sei sicuro di voler eliminare questo record?\",\n    \"Are you sure you want to delete these records?\": \"Sei sicuro di voler eliminare questi record?\",\n    \"Delete\": \"Elimina\",\n    \"Dismiss\": \"Ignora\",\n    \"Don't ask again.\": \"Non chiedere più.\",\n    \"Don't show again.\": \"Non mostrare più.\",\n    \"Don't show tips\": \"Non mostrare i suggerimenti\",\n    \"TIP\": \"TIP\",\n    \"Confirm\": \"Conferma\"\n  },\n  \"pages\": {\n    \"Duplicate page\": \"Duplica pagina\",\n    \"Remove\": \"Rimuovi\",\n    \"Rename\": \"Rinomina\",\n    \"You do not have edit access to this document\": \"Non hai accesso in scrittura a questo documento\"\n  },\n  \"search\": {\n    \"Find Next \": \"Trova successivo \",\n    \"Find Previous \": \"Trova precedente \",\n    \"No results\": \"Nessun risultato\",\n    \"Search in document\": \"Cerca nel documento\",\n    \"Search\": \"Cerca\"\n  },\n  \"sendToDrive\": {\n    \"Sending file to Google Drive\": \"Invio i file a Google Drive\"\n  },\n  \"NTextBox\": {\n    \"false\": \"falso\",\n    \"true\": \"vero\",\n    \"Field Format\": \"Formato del campo\",\n    \"Lines\": \"Righe\",\n    \"Multi line\": \"Multiriga\",\n    \"Single line\": \"Riga singola\"\n  },\n  \"ACLUsers\": {\n    \"Example Users\": \"Utenti di esempio\",\n    \"Users from table\": \"Utenti dalla tabella\",\n    \"View as\": \"Vedi come\"\n  },\n  \"ConditionalStyle\": {\n    \"Add another rule\": \"Aggiungi altra regola\",\n    \"Add conditional style\": \"Aggiungi stile condizionale\",\n    \"Error in style rule\": \"Errore nella regola di stile\",\n    \"Row style\": \"Stile riga\",\n    \"Rule must return True or False\": \"La regola deve restituire Vero o Falso\",\n    \"Conditional Style\": \"Stile condizionale\",\n    \"IF...\": \"SE...\"\n  },\n  \"CurrencyPicker\": {\n    \"Invalid currency\": \"Valuta non valida\"\n  },\n  \"LanguageMenu\": {\n    \"Language\": \"Lingua\"\n  },\n  \"CodeEditorPanel\": {\n    \"Code View is available only when you have full document access.\": \"Vista Codice è disponibile solo quando hai pieno accesso al documento.\",\n    \"Access denied\": \"Accesso negato\"\n  },\n  \"GristTooltips\": {\n    \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Clicca su \\\"Apri stile righe\\\" per applicare la formattazione condizionale alle righe.\",\n    \"Editing Card Layout\": \"Modifica Layout Scheda\",\n    \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Applica la formattazione condizionale alle celle in questa colonna quando le condizioni della formula sono soddisfatte.\",\n    \"Apply conditional formatting to rows based on formulas.\": \"Applica la formattazione condizionale alle righe in base alle formule.\",\n    \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Cliccando {{EyeHideIcon}} si nasconde il campo dalla vista senza cancellarlo.\",\n    \"Click the Add new button to create new documents or workspaces, or import data.\": \"Clicca il bottone Aggiungi Nuovo per creare nuovi documenti, workspace, o importare dei dati.\",\n    \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Formule che si attivano in certi casi, e conservano il valore calcolato come dato.\",\n    \"Linking Widgets\": \"Collegare i widget\",\n    \"Nested Filtering\": \"Filtri innestati\",\n    \"Only those rows will appear which match all of the filters.\": \"Saranno mostrate solo le righe che soddisfano tutte le condizioni filtro.\",\n    \"Pinning Filters\": \"Fissare i filtri\",\n    \"Reference Columns\": \"Colonne con riferimenti\",\n    \"Select the table containing the data to show.\": \"Seleziona la tabella che contiene i dati da mostrare.\",\n    \"Selecting Data\": \"Selezionare i dati\",\n    \"This is the secret to Grist's dynamic and productive layouts.\": \"Questo è il segreto dei layout dinamici e produttivi di Grist.\",\n    \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Prova i cambiamenti in una copia, quindi decidi se rimpiazzare l'originale con le tue modifiche.\",\n    \"Updates every 5 minutes.\": \"Si aggiorna ogni 5 minuti.\",\n    \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Utile per conservare data e ora, o l'autore di un nuovo record, per la pulizia dei dati e altro ancora.\",\n    \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Usa il simbolo \\\\u{1D6BA} per creare tabelle riassuntive (pivot), per totali e subtotali.\",\n    \"Add new\": \"Aggiungi nuovo\",\n    \"Unpin to hide the the button while keeping the filter.\": \"Sblocca per nascondere il pulsante, mantenendo comunque il filtro.\",\n    \"Select the table to link to.\": \"Seleziona la tabella da collegare.\",\n    \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Le celle in una colonna referenziata indentificano sempre un record {{entire}} di una tabella, ma puoi scegliere quale colonna mostrare da quel record.\",\n    \"Learn more.\": \"Scopri di più.\",\n    \"Link your new widget to an existing widget on this page.\": \"Collega il tuo widget a un widget già esistente in questa pagina.\",\n    \"Rearrange the fields in your card by dragging and resizing cells.\": \"Sposta e ridimensiona le celle per riconfigurare i campi nella tua scheda.\",\n    \"You can filter by more than one column.\": \"Puoi filtrare secondo più di una colonna.\",\n    \"Pinned filters are displayed as buttons above the widget.\": \"I filtri fissati sono mostrati come pulsanti sopra il widget.\",\n    \"Raw Data page\": \"Pagine di dati grezzi\",\n    \"The total size of all data in this document, excluding attachments.\": \"Lo spazio totale di tutti i dati in questo documento, esclusi gli allegati.\",\n    \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"La pagina dei dati grezzi elenca tutte le tabelle-dati nel tuo documento, incluse le tabelle sommario e le tabelle non comprese nei layout delle pagine.\",\n    \"They allow for one record to point (or refer) to another.\": \"Permettono a un record di puntare (o riferirsi) a un altro.\",\n    \"entire\": \"intero\",\n    \"relational\": \"relazionale\",\n    \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Usa il simbolo 𝚺 per creare tabelle riassuntive (pivot), per totali o subtotali.\",\n    \"Access Rules\": \"Regole di accesso\",\n    \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Le regole di accesso ti danno il potere di creare regole sofisticate per decidere chi può vedere o modificare il tuo documento, e quali parti.\",\n    \"Reference columns are the key to {{relational}} data in Grist.\": \"Le colonne con riferimenti sono il motore {{relational}} dei dati in Grist.\",\n    \"Anchor Links\": \"Link interno\",\n    \"Custom Widgets\": \"Widget personalizzati\",\n    \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Puoi scegliere uno dei nostri widget pronti all'uso, o inserirne uno fatto da te, immettendo la sua URL completa.\",\n    \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Per creare un link che porta l'utente a una cella specifica, fai clic su una riga e premi {{shortcut}}.\",\n    \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"Un UUID è una stringa generata automaticamente, uitle come identificatore univoco e chiave per i link.\",\n    \"To configure your calendar, select columns for start\": {\n      \"end dates and event titles. Note each column's type.\": \"Per configurare il calendario, seleziona le colonne per le date di inizio/fine, e i titoli degli eventi. Nota il tipo di ciascuna colonna.\"\n    },\n    \"Calendar\": \"Calendario\",\n    \"Lookups return data from related tables.\": \"Un lookup restituisce dati dalle tabelle collegate.\",\n    \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Non trovi la colonna giusta? Fai clic su \\\"Cambia widget\\\" per selezionare la tabella con i dati degli eventi.\",\n    \"Use reference columns to relate data in different tables.\": \"Usa colonne di riferimenti per collegare dati da altre tabelle.\",\n    \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Le formule supportano molte funzioni di Excel, la sintassi completa di Python, e includono un utile assistente AI.\",\n    \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Puoi scegliere tra i widget disponibili nella lista, o incorporare il tuo fornendo la sua URL completa.\",\n    \"These rules are applied after all column rules have been processed, if applicable.\": \"Queste regole sono applicate dopo che tutte le regole delle colonne sono state applicate, se possibile.\",\n    \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Costruisci del semplici moduli e condividili rapidamente con il nostro nuovo widget. {{learnMoreButton}}\",\n    \"Forms are here!\": \"Sono arrivati i moduli!\",\n    \"Learn more\": \"Approfondisci\",\n    \"Example: {{example}}\": \"Esempio: {{example}}\",\n    \"Filter displayed dropdown values with a condition.\": \"Il filtro mostrava i valori nella tendina con una condizione.\",\n    \"Creates a reverse column in target table that can be edited from either end.\": \"Crea un colonna inversa nella tabella di destinazione, che può essere usata da entrambi i lati.\",\n    \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Per consentire riferimenti multipli, cambia il tipo della colonna collegata a Lista di Riferimenti.\",\n    \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Questa limitazione si verifica quando un lato del collegamento bidirezionale è configurato come un riferimento unidirezionale.\",\n    \"Community widgets are created and maintained by Grist community members.\": \"I widget della comunità sono creati e mantenuti dai membri della comunità di Grist.\",\n    \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Per consentire riferimenti multipli, cambia il tipo della colonna a Lista di Riferimenti.\",\n    \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Questa limitazione si verifica quando una colonna di un riferimento bidirezionale è di tipo Riferimento.\",\n    \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"I riferimenti bidirezionali non sono supportati al momento per le colonne di formule o formule trigger\",\n    \"Manage users and resources in a Grist installation.\": \"Gestisci utenti e risorse in una installazione di Grist.\",\n    \"[Learn more.]({{link}})\": \"[Scopri di più.]({{link}})\",\n    \"Summary tables can only contain formula columns.\": \"Le tabelle sommario possono contenere solo colonne di formule.\",\n    \"The preview below this header shows how the selected user will see this document\": \"L'anteprima sotto questo titolo mostra come l'utente selezionato vedrà il documento\"\n  },\n  \"DescriptionConfig\": {\n    \"DESCRIPTION\": \"DESCRIZIONE\"\n  },\n  \"PagePanels\": {\n    \"Close Creator Panel\": \"Chiudi il pannello Creatore\",\n    \"Open creator panel\": \"Apri il pannello Creatore\"\n  },\n  \"ColumnTitle\": {\n    \"Column ID copied to clipboard\": \"ID colonna copiato negli appunti\",\n    \"Column description\": \"Descrizione colonna\",\n    \"Save\": \"Salva\",\n    \"Add description\": \"Aggiungi descrizione\",\n    \"COLUMN ID: \": \"ID COLONNA: \",\n    \"Column label\": \"Etichetta colonna\",\n    \"Provide a column label\": \"Dare un'etichetta alla colonna\",\n    \"Cancel\": \"Annulla\",\n    \"Close\": \"Chiudi\"\n  },\n  \"FieldContextMenu\": {\n    \"Copy\": \"Copia\",\n    \"Copy anchor link\": \"Copia link ancora\",\n    \"Clear field\": \"Pulisci campo\",\n    \"Paste\": \"Incolla\",\n    \"Cut\": \"Taglia\",\n    \"Hide field\": \"Nascondi campo\"\n  },\n  \"WebhookPage\": {\n    \"Clear queue\": \"Pulisci la coda\",\n    \"Webhook settings\": \"Impostazioni web hook\",\n    \"Ready Column\": \"Colonna Ready\",\n    \"Removed webhook.\": \"Webhook rimosso.\",\n    \"Cleared webhook queue.\": \"Svuotata la coda dei webhook.\",\n    \"Enabled\": \"Abilitato\",\n    \"Columns to check when update (separated by ;)\": \"Colonne da controllare quando si aggiorna (separate da ;)\",\n    \"Event Types\": \"Tipi di evento\",\n    \"Memo\": \"Memo\",\n    \"Name\": \"Nome\",\n    \"Webhook Id\": \"Id Webhook\",\n    \"Table\": \"Tabella\",\n    \"Sorry, not all fields can be edited.\": \"Spiacente, non tutti i campi possono essere modificati.\",\n    \"Status\": \"Status\",\n    \"URL\": \"URL\",\n    \"Filter for changes in these columns (semicolon-separated ids)\": \"FIltrare i cambiamenti in queste colonne (id separati da ;)\",\n    \"Header Authorization\": \"Header autorizzazione\"\n  },\n  \"Clipboard\": {\n    \"Unavailable Command\": \"Comando non disponibile\",\n    \"Got it\": \"Ricevuto\"\n  },\n  \"FormulaAssistant\": {\n    \"Ask the bot.\": \"Chiedi al bot.\",\n    \"Capabilities\": \"Capacità\",\n    \"Community\": \"Comunità\",\n    \"Data\": \"Dati\",\n    \"Formula Cheat Sheet\": \"Azioni tipiche con le formule\",\n    \"Formula Help. \": \"Aiuto con le formule. \",\n    \"Function List\": \"Lista delle funzioni\",\n    \"Grist's AI Assistance\": \"Assistenza dalla IA di Grist\",\n    \"Need help? Our AI assistant can help.\": \"Bisogno di aiuto? Prova il nostro assistente IA.\",\n    \"New Chat\": \"Nuova chat\",\n    \"Preview\": \"Anteprima\",\n    \"Regenerate\": \"Rigenera\",\n    \"Save\": \"Salva\",\n    \"Tips\": \"Suggerimenti\",\n    \"Grist's AI Formula Assistance. \": \"Assistenza dalla IA di Grist per le formule. \",\n    \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Vedi {{helpFunction}} e {{formulaCheat}}, o visita la {{community}} per ulteriore aiuto.\",\n    \"Cancel\": \"Annulla\",\n    \"Clear conversation\": \"Cancella conversazione\",\n    \"AI Assistant\": \"Assistente IA\",\n    \"Apply\": \"Applica\",\n    \"Code view\": \"Vista codice\",\n    \"Hi, I'm the Grist Formula AI Assistant.\": \"Ciao, sono l'assistente IA per le formule di Grist.\",\n    \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Posso solo aiutare con le formule. Non posso costruire tabelle, colonne e viste, o scrivere regole di accesso.\",\n    \"Learn more\": \"Per saperne di più\",\n    \"Press Enter to apply suggested formula.\": \"Premi Invio per applicare la formula suggerita.\",\n    \"Sign Up for Free\": \"Iscriviti gratis\",\n    \"There are some things you should know when working with me:\": \"Ecco alcune cose da sapere quando lavori con me:\",\n    \"What do you need help with?\": \"In che cosa posso aiutarti?\",\n    \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Iscriviti a un account gratuito di Grist per usare l'Assistente IA per le formule.\",\n    \"Formula AI Assistant is only available for logged in users.\": \"L'assistente IA per le formule è disponibile solo dopo aver effettuato l'accesso.\",\n    \"For higher limits, contact the site owner.\": \"Per limiti più alti, contatta il proprietario del sito.\",\n    \"upgrade to the Pro Team plan\": \"aggiorna al piano Pro Team\",\n    \"You have used all available credits.\": \"Hai usato tutti i crediti disponibili.\",\n    \"upgrade your plan\": \"aggiorna il tuo piano\",\n    \"You have {{numCredits}} remaining credits.\": \"Hai {{numCredits}} crediti rimanenti.\",\n    \"For higher limits, {{upgradeNudge}}.\": \"Per limiti più alti, {{upgradeNudge}}.\"\n  },\n  \"GridView\": {\n    \"Click to insert\": \"Clicca per inserire\"\n  },\n  \"WelcomeSitePicker\": {\n    \"Welcome back\": \"Bentornato\",\n    \"You can always switch sites using the account menu.\": \"Puoi sempre cambiare sito usando il menu del tuo profilo.\",\n    \"You have access to the following Grist sites.\": \"Hai accesso a questi siti di Grist.\"\n  },\n  \"SupportGristNudge\": {\n    \"Support Grist page\": \"Pagina Sostieni Grist\",\n    \"Close\": \"Chiudi\",\n    \"Contribute\": \"Contribuisci\",\n    \"Help Center\": \"Centro Aiuto\",\n    \"Opt in to Telemetry\": \"Accetta la telemetria\",\n    \"Opted In\": \"Accettato\",\n    \"Support Grist\": \"Sostieni Grist\",\n    \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Grazie! Apprezziamo molto la tua fiducia e il tuo supporto. Puoi disdire in qualsiasi momento con {{link}} nel menu utente.\",\n    \"Admin Panel\": \"Pannello di amministrazione\"\n  },\n  \"SupportGristPage\": {\n    \"GitHub\": \"GitHub\",\n    \"Help Center\": \"Centro Aiuto\",\n    \"Home\": \"Pagina iniziale\",\n    \"Manage Sponsorship\": \"Gestisci sponsorizzazione\",\n    \"Opt out of Telemetry\": \"Disattiva la telemetria\",\n    \"Sponsor Grist Labs on GitHub\": \"Sostieni Grist Labs su GitHub\",\n    \"Support Grist\": \"Sostieni Grist\",\n    \"Telemetry\": \"Telemetria\",\n    \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Questa istanza accetta la telemetria. Solo un amministratore può cambiare questa impostazione.\",\n    \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Questa istanza ha disattivato la telemetria. Solo un amministratore può cambiare questa opzione.\",\n    \"You can opt out of telemetry at any time from this page.\": \"Puoi disattivare la telemetria in qualsiasi momento da questa pagina.\",\n    \"You have opted in to telemetry. Thank you!\": \"Hai accettato la telemetria. Grazie!\",\n    \"You have opted out of telemetry.\": \"Hai disattivato la telemetria.\",\n    \"GitHub Sponsors page\": \"Pagina Sponsor GitHub\",\n    \"Opt in to Telemetry\": \"Accetta la telemetria\",\n    \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Raccogliamo solo statistiche di utilizzo, mai contenuti dei documenti, come spiegato in {{link}}.\",\n    \"Sponsor\": \"Sponsor\"\n  },\n  \"buildViewSectionDom\": {\n    \"No row selected in {{title}}\": \"Nessuna riga selezionata in {{title}}\",\n    \"Not all data is shown\": \"Non tutti i dati sono mostrati\",\n    \"No data\": \"Nessun dato\"\n  },\n  \"DescriptionTextArea\": {\n    \"DESCRIPTION\": \"DESCRIZIONE\"\n  },\n  \"UserManager\": {\n    \"Add {{member}} to your team\": \"Aggiungi {{member}} al tuo team\",\n    \"Allow anyone with the link to open.\": \"Consenti l'accesso a chiunque ha il link.\",\n    \"Anyone with link \": \"Chiunque ha il link \",\n    \"Cancel\": \"Annulla\",\n    \"Copy link\": \"Copia link\",\n    \"Create a team to share with more people\": \"Crea un team per condividere con più persone\",\n    \"Grist support\": \"Supporto di Grist\",\n    \"Guest\": \"Ospite\",\n    \"Invite multiple\": \"Invita più persone\",\n    \"Invite people to {{resourceType}}\": \"Invita delle persone a {{resourceType}}\",\n    \"Link copied to clipboard\": \"Link copiato negli Appunti\",\n    \"Off\": \"Spento\",\n    \"Manage members of team site\": \"Gestisci i membri del tuo team\",\n    \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Nessun accesso di default, permette di autorizzare l'accesso a singoli documenti o spazi di lavoro, invece che a tutto il sito del team.\",\n    \"On\": \"Acceso\",\n    \"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}}.\": \"Se togli l'accesso a te stesso, non potrai riaverlo senza l'aiuto di qualcuno con permessi sufficienti a {{name}}.\",\n    \"Open Access Rules\": \"Apri le regole di accesso\",\n    \"Outside collaborator\": \"Collaboratore esterno\",\n    \"Public access\": \"Accesso pubblico\",\n    \"Public access: \": \"Accesso pubblico: \",\n    \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"L'utente eredita i permessi da {{parent})}. Per rimuoverli, imposta l'opzione 'Eredita l'accesso' a 'Nessuno'.\",\n    \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"L'utente eredita i permessi da {{parent}}. Per rimuoverli, imposta l'opzione 'Eredita l'accesso' a 'Nessuno'.\",\n    \"You are about to remove your own access to this {{resourceType}}\": \"Stai per rimuovere il tuo stesso accesso da questa {{resourceType}}\",\n    \"Close\": \"Chiudi\",\n    \"Collaborator\": \"Collaboratore\",\n    \"Remove my access\": \"Rimuovi il mio accesso\",\n    \"Confirm\": \"Conferma\",\n    \"Save & \": \"Salva e \",\n    \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Accesso pubblico ereditato da {{parent}}. Per rimuoverlo, imposta l'opzione 'Eredita l'accesso' a 'Nessuno'.\",\n    \"Team member\": \"Membro del team\",\n    \"User may not modify their own access.\": \"L'utente non può modificare le sue stesse opzioni di accesso.\",\n    \"Your role for this team site\": \"Il tuo ruolo per questo sito del team\",\n    \"Your role for this {{resourceType}}\": \"Il tuo ruolo per {{resourceType}}\",\n    \"free collaborator\": \"collaboratore libero\",\n    \"guest\": \"ospite\",\n    \"member\": \"membro\",\n    \"team site\": \"sito del team\",\n    \"{{collaborator}} limit exceeded\": \"limite superato per {{collaborator}}\",\n    \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} di {{limitTop}} {{collaborator}}\",\n    \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Nessun accesso di default, permette di autorizzare l'accesso a singoli documenti o spazi di lavoro, invece che a tutto il sito del team.\",\n    \"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}}.\": \"Se togli l'accesso a te stesso, non potrai riaverlo senza l'aiuto di qualcuno con permessi sufficienti a {{resourceType}}.\",\n    \"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.\": \"L'utente può visualizzare {{resource}} a causa di regole di accesso alle risorse interne impostate manualmente. Se rimuovi qui, l'utente perderà l'accesso alle risorse interne.\",\n    \"Inherit access: \": \"Eredita accesso: \"\n  },\n  \"SearchModel\": {\n    \"Search all pages\": \"Cerca in tutte le pagine\",\n    \"Search all tables\": \"Cerca in tutte le tabelle\"\n  },\n  \"searchDropdown\": {\n    \"Search\": \"Cerca\"\n  },\n  \"FloatingEditor\": {\n    \"Collapse Editor\": \"Contrai l'editor\"\n  },\n  \"FloatingPopup\": {\n    \"Maximize\": \"Espandi\",\n    \"Minimize\": \"Riduci\"\n  },\n  \"CardContextMenu\": {\n    \"Insert card above\": \"Inserisci scheda sopra\",\n    \"Duplicate card\": \"Duplica scheda\",\n    \"Insert card below\": \"Inserisci scheda sotto\",\n    \"Delete card\": \"Elimina scheda\",\n    \"Copy anchor link\": \"Copia il link\",\n    \"Insert card\": \"Inserisci scheda\"\n  },\n  \"HiddenQuestionConfig\": {\n    \"Hidden fields\": \"Campi nascosti\"\n  },\n  \"Menu\": {\n    \"Insert question below\": \"Inserisci domanda sotto\",\n    \"Building blocks\": \"Componenti\",\n    \"Columns\": \"Colonne\",\n    \"Cut\": \"Taglia\",\n    \"Insert question above\": \"Inserisci domanda sopra\",\n    \"Paragraph\": \"Paragrafo\",\n    \"Paste\": \"Incolla\",\n    \"Separator\": \"Separatore\",\n    \"Unmapped fields\": \"Campi non mappati\",\n    \"Header\": \"Intestazione\",\n    \"Copy\": \"Copia\",\n    \"New question\": \"Nuova domanda\",\n    \"More\": \"Più\"\n  },\n  \"FormView\": {\n    \"Unpublish\": \"Non pubblicare\",\n    \"Publish\": \"Pubblica\",\n    \"Publish your form?\": \"Pubblicare il modulo?\",\n    \"Unpublish your form?\": \"Ritirare la pubblicazione del modulo?\",\n    \"Anyone with the link below can see the empty form and submit a response.\": \"Tutti quelli che hanno il link sottostante possono vedere il modulo vuoto e inviare una risposta.\",\n    \"Preview\": \"Anteprima\",\n    \"Embed this form\": \"Incorpora questo modulo\",\n    \"Copy code\": \"Copia codice\",\n    \"Copy link\": \"Copia link\",\n    \"Reset form\": \"Resetta il modulo\",\n    \"Reset\": \"Reset\",\n    \"Save your document to publish this form.\": \"Salva il tuo documento per pubblicare questo modulo.\",\n    \"Share\": \"Condividi\",\n    \"Share this form\": \"Condividi questo modulo\",\n    \"Are you sure you want to reset your form?\": \"Sei sicuro di voler resettare il tuo modulo?\",\n    \"Code copied to clipboard\": \"Codice copiato negli Appunti\",\n    \"Link copied to clipboard\": \"Link copiato negli Appunti\",\n    \"View\": \"Vedi\",\n    \"Your form description goes here.\": \"La descrizione del form va inserita qui.\",\n    \"## **Form Title**\": \"## **Titolo del form**\",\n    \"# **Form Title**\": \"# **Titolo del modulo**\"\n  },\n  \"UnmappedFieldsConfig\": {\n    \"Clear\": \"Pulisci\",\n    \"Map fields\": \"Mappa i campi\",\n    \"Mapped\": \"Mappato\",\n    \"Select all\": \"Seleziona tutto\",\n    \"Unmap fields\": \"Non mappare il campo\",\n    \"Unmapped\": \"Non mappato\"\n  },\n  \"Editor\": {\n    \"Delete\": \"Elimina\"\n  },\n  \"FormContainer\": {\n    \"Build your own form\": \"Costruisci il tuo modulo\",\n    \"Powered by\": \"Fatto con\"\n  },\n  \"FormModel\": {\n    \"There was a problem loading the form.\": \"C'è stato un problema nel caricamento del modulo.\",\n    \"Oops! The form you're looking for doesn't exist.\": \"Ops! Il modulo che cerchi non esiste.\",\n    \"You don't have access to this form.\": \"Non hai accesso a questo modulo.\",\n    \"Oops! This form is no longer published.\": \"Ops! Questo modulo non è più pubblico.\"\n  },\n  \"FormPage\": {\n    \"There was an error submitting your form. Please try again.\": \"C'è stato un errore nell'invio del modulo. Per favore prova di nuovo.\"\n  },\n  \"AdminPanel\": {\n    \"Admin Panel\": \"Pannello di amministrazione\",\n    \"Current version of Grist\": \"Versione attuale di Grist\",\n    \"Home\": \"Inizio\",\n    \"Help us make Grist better\": \"Aiutaci a migliorare Grist\",\n    \"Support Grist\": \"Supporta Grist\",\n    \"Support Grist Labs on GitHub\": \"Supporta Grist Labs su GitHub\",\n    \"Sponsor\": \"Sponsor\",\n    \"Current\": \"Attuale\",\n    \"Telemetry\": \"Telemetria\",\n    \"Version\": \"Versione\",\n    \"Auto-check when this page loads\": \"Controlla automaticamente quando questa pagina è caricata\",\n    \"Grist is up to date\": \"Grist è aggiornato\",\n    \"Error checking for updates\": \"Errore nel controllo degli aggiornamenti\",\n    \"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 supporta formule molto potenti, grazie a Python. Raccomandiamo di impostare la variabile d'ambiente GRIST_SANDBOX_FLAVOR a gvisor se il vostro hardware lo permette (in genere sì), così che le formule di un documento agiscano in una sandbox isolata dagli altri documenti e dalla rete.\",\n    \"Last checked {{time}}\": \"Ultimo controllo {{time}}\",\n    \"Learn more.\": \"Per saperne di più.\",\n    \"Newer version available\": \"Disponibile una versione più recente\",\n    \"No information available\": \"Nessuna informazione disponibile\",\n    \"OK\": \"OK\",\n    \"Sandboxing\": \"Uso della sandbox\",\n    \"Security Settings\": \"Impostazioni di sicurezza\",\n    \"Updates\": \"Aggiornamenti\",\n    \"unconfigured\": \"non configurato\",\n    \"unknown\": \"sconosciuto\",\n    \"Error\": \"Errore\",\n    \"Check now\": \"Controlla adesso\",\n    \"Checking for updates...\": \"Controllo gli aggiornamenti...\",\n    \"Grist releases are at \": \"Le release di Grist sono a \",\n    \"Sandbox settings for data engine\": \"Impostazione della sandbox per il motore dati\",\n    \"Current authentication method\": \"Metodo di autenticazione attuale\",\n    \"No fault detected.\": \"Nessun problema rilevato.\",\n    \"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}}\",\n    \"Results\": \"Risultati\",\n    \"Self Checks\": \"Auto-diagnostica\",\n    \"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.\",\n    \"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.\",\n    \"Details\": \"Dettagli\",\n    \"Notes\": \"Note\",\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 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.\",\n    \"Administrator Panel Unavailable\": \"Pannello di amministrazione non disponibile\",\n    \"Authentication\": \"Autenticazione\",\n    \"Check failed.\": \"Controllo fallito.\",\n    \"Check succeeded.\": \"Controllo riuscito.\",\n    \"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.\",\n    \"Session Secret\": \"Segreto per la sessione\",\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 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.\",\n    \"Enable Grist Enterprise\": \"Attiva Grist Enterprise\",\n    \"Enterprise\": \"Enterprise\",\n    \"Key to sign sessions with\": \"Chiave per marcare le sessioni\",\n    \"checking\": \"controllo\",\n    \"Off\": \"Disattivo\",\n    \"Audit Logs\": \"Log degli audit\",\n    \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} più\",\n    \"Log Streaming\": \"Flusso del log\",\n    \"Contact us\": \"Contattaci\",\n    \"New, Enterprise\": \"Nuova, Impresa\",\n    \"On\": \"Attivo\"\n  },\n  \"WelcomeCoachingCall\": {\n    \"Maybe later\": \"Forse più tardi\",\n    \"free coaching call\": \"chiamata di assistenza gratuita\",\n    \"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.\": \"Durante la chiamata, ci prendiamo del tempo per capire i tuoi bisogni e adattarci a quelli. Possiamo spiegarti i fondamenti di Grist, o iniziare a lavorare subito con i tuoi dati e creare le dashboard che ti servono.\",\n    \"Schedule call\": \"Prenota una chiamata\",\n    \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Prenota la tua {{freeCoachingCall}} con un membro del nostro team.\"\n  },\n  \"FormConfig\": {\n    \"Field rules\": \"Regole del campo\",\n    \"Ascending\": \"Ascendente\",\n    \"Field Format\": \"Formato campo\",\n    \"Default\": \"Default\",\n    \"Field Rules\": \"Regole campo\",\n    \"Descending\": \"Discendente\",\n    \"Horizontal\": \"Orizzontale\",\n    \"Options Sort Order\": \"Opzioni ordinamento\",\n    \"Select\": \"Seleziona\",\n    \"Options Alignment\": \"Opzioni allineamento\",\n    \"Radio\": \"Radio\",\n    \"Vertical\": \"Verticale\",\n    \"Required field\": \"Campo obbligatorio\"\n  },\n  \"CustomView\": {\n    \"Some required columns aren't mapped\": \"Alcune colonne necessarie non sono mappate\",\n    \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Per usare questo widget, mappare tutte le colonne non opzionali dal pannello di creazione a destra.\"\n  },\n  \"CreateTeamModal\": {\n    \"Domain name is required\": \"Il nome di dominio è obbligatorio\",\n    \"Go to your site\": \"Vai al tuo sito\",\n    \"Team name\": \"Nome del team\",\n    \"Team name is required\": \"Il nome del team è obbligatorio\",\n    \"Team site created\": \"Il sito del team è stato creato\",\n    \"Team url\": \"Url del team\",\n    \"Work as a Team\": \"Lavora come un team\",\n    \"Cancel\": \"Annulla\",\n    \"Choose a name and url for your team site\": \"Scegli un nome e una url per il sito del tuo team\",\n    \"Create site\": \"Crea un sito\",\n    \"Domain name is invalid\": \"Il nome di dominio non è valido\",\n    \"Billing is not supported in grist-core\": \"La fatturazione non è supportata in grist-core\"\n  },\n  \"Toggle\": {\n    \"Checkbox\": \"Checkbox\",\n    \"Switch\": \"Cambia\",\n    \"Field Format\": \"Formato campo\"\n  },\n  \"FormSuccessPage\": {\n    \"Thank you! Your response has been recorded.\": \"Grazie! La tua risposta è stata registrata.\",\n    \"Form Submitted\": \"Modulo inviato\",\n    \"Submit new response\": \"Invia una nuova risposta\"\n  },\n  \"DateRangeOptions\": {\n    \"Next 7 days\": \"Prossimi 7 giorni\",\n    \"This month\": \"Questo mese\",\n    \"This week\": \"Questa settimana\",\n    \"This year\": \"Quest'anno\",\n    \"Today\": \"Oggi\",\n    \"Last 30 days\": \"Ultimi 30 giorni\",\n    \"Last 7 days\": \"Ultimi 7 giorni\",\n    \"Last week\": \"Ultima settimana\"\n  },\n  \"MappedFieldsConfig\": {\n    \"Clear\": \"Svuota\",\n    \"Unmap fields\": \"Rimuovi mappatura campi\",\n    \"Unmapped\": \"Mappatura rimossa\",\n    \"Map fields\": \"Mappa i campi\",\n    \"Mapped\": \"Mappato\",\n    \"Select all\": \"Seleziona tutto\"\n  },\n  \"Section\": {\n    \"Insert section above\": \"Inserisci una sezione sopra\",\n    \"Insert section below\": \"Inserisci una sezione sotto\",\n    \"### **Header**\": \"### **Titolo**\",\n    \"Description\": \"Descrizione\"\n  },\n  \"Columns\": {\n    \"Remove Column\": \"Rimuovi colonna\"\n  },\n  \"Field\": {\n    \"No choices configured\": \"Nessuna scelta configurata\",\n    \"No values in show column of referenced table\": \"Nessun valore nella colonna da mostrare nella tabella collegata\",\n    \"Hide\": \"Nascondi\"\n  },\n  \"FormErrorPage\": {\n    \"Error\": \"Errore\"\n  },\n  \"ChoiceEditor\": {\n    \"Error in dropdown condition\": \"Errore nella condizione della tendina\",\n    \"No choices matching condition\": \"Nessuna scelta soddisfa la condizione\",\n    \"No choices to select\": \"Nessuna scelta da selezionare\"\n  },\n  \"ReferenceUtils\": {\n    \"No choices to select\": \"Nessuna scelta da selezionare\",\n    \"Error in dropdown condition\": \"Errore nella condizione della tendina\",\n    \"No choices matching condition\": \"Nessuna scelta soddisfa la condizione\"\n  },\n  \"DropdownConditionConfig\": {\n    \"Set dropdown condition\": \"Imposta la condizione della tendina\",\n    \"Dropdown Condition\": \"Condizione della tendina\",\n    \"Invalid columns: {{colIds}}\": \"Colonne non valide: {{colIds}}\"\n  },\n  \"DropdownConditionEditor\": {\n    \"Enter condition.\": \"Inserisci la condizione.\"\n  },\n  \"ChoiceListEditor\": {\n    \"Error in dropdown condition\": \"Errore nella condizione della tendina\",\n    \"No choices matching condition\": \"Nessuna scelta soddisfa la condizione\",\n    \"No choices to select\": \"Nessuna scelta da selezionare\"\n  },\n  \"FormRenderer\": {\n    \"Reset\": \"Reset\",\n    \"Search\": \"Cerca\",\n    \"Select...\": \"Seleziona...\",\n    \"Submit\": \"Invia\"\n  },\n  \"widgetTypesMap\": {\n    \"Card\": \"Scheda\",\n    \"Table\": \"Tabella\",\n    \"Calendar\": \"Calendario\",\n    \"Card List\": \"Lista di schede\",\n    \"Chart\": \"Grafico\",\n    \"Custom\": \"Personalizzato\",\n    \"Form\": \"Modulo\"\n  },\n  \"TimingPage\": {\n    \"Average Time (s)\": \"Media tempi (sec)\",\n    \"Total Time (s)\": \"Tempo totale (sec)\",\n    \"Loading timing data. Don't close this tab.\": \"Caricamento dei dati cronometrici. Non chiudere questa scheda.\",\n    \"Max Time (s)\": \"Tempo massimo (sec)\",\n    \"Number of Calls\": \"Numero di chiamate\",\n    \"Table ID\": \"ID Tabella\",\n    \"Column ID\": \"ID colonna\",\n    \"Formula timer\": \"Cronometro formule\"\n  },\n  \"ToggleEnterpriseWidget\": {\n    \"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}}).\",\n    \"Disable Grist Enterprise\": \"Disattiva Grist Enterprise\",\n    \"Enable Grist Enterprise\": \"Attiva Grist Enterprise\",\n    \"Grist Enterprise is **enabled**.\": \"Grist Enterprise è **attivato**.\",\n    \"Activate\": \"Attiva\",\n    \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"La chiave di attivazione serve a usare Grist Enterprise dopo che il periodo di prova\\ndi 30 giorni è finito. Ottieni una chiave di attivazione [contattandoci]({{contactLink}}) oggi. Non serve\\nuna chiave di attivazione per usare Grist Core.\\n\\nScopri di più nel nostro [Help Center]({{helpCenter}}).\",\n    \"Expiration date\": \"Data di scadenza\",\n    \"Installation ID copied to clipboard\": \"ID Installazione copiato negli appunti\",\n    \"Installation ID:\": \"ID Installazione:\",\n    \"Activation key\": \"Chiave di attivazione\",\n    \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"La chiave di attivazione serve a usare Grist Enterprise dopo che il periodo di prova\\n        di 30 giorni è finito. Ottieni una chiave di attivazione [iscrivendoti a Grist\\n        Enterprise]({{signupLink}}). Non serve una chiave di attivazione per usare\\n        Grist Core.\",\n    \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Per continuare a usare Grist Enterprise è richiesta una chiave di attivazione. \\nPuoi ottenere l'attivazione [iscrivendoti a Grist Enterprise ]({{signupLink}}) e copiando\\nla tua chiave di attivazione qui sotto.\",\n    \"Copy to clipboard\": \"Copia negli appunti\"\n  },\n  \"DocTutorial\": {\n    \"Finish\": \"Finisci\",\n    \"Previous\": \"Precedente\",\n    \"Next\": \"Successivo\",\n    \"Restart\": \"Riparti\",\n    \"Click to expand\": \"Clicca per espandere\",\n    \"Do you want to restart the tutorial? All progress will be lost.\": \"Vuoi ricominciare il tutorial? Tutti i progressi fatti saranno azzerati.\",\n    \"End tutorial\": \"Termina il tutorial\"\n  },\n  \"OnboardingCards\": {\n    \"3 minute video tour\": \"Video introduttivo di tre minuti\",\n    \"Complete our basics tutorial\": \"Completa il nostro tutorial di base\",\n    \"Complete the tutorial\": \"Completa il tutorial\",\n    \"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.\",\n    \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Impara le basi sulle colonne di riferimenti, widget collegati, tipi di colonna e schede.\"\n  },\n  \"OnboardingPage\": {\n    \"Welcome\": \"Benvenuto\",\n    \"What brings you to Grist (you can select multiple)?\": \"Che cosa ti ha portato a Grist (puoi scegliere più opzioni)?\",\n    \"Go to the tutorial!\": \"Vai al tutorial!\",\n    \"Next step\": \"Passo successivo\",\n    \"Type here\": \"Scrivi qui\",\n    \"Tell us who you are\": \"Dicci chi sei\",\n    \"What organization are you with?\": \"Di che organizzazione fai parte?\",\n    \"Back\": \"Indietro\",\n    \"Discover Grist in 3 minutes\": \"Scopri Grist in tre minuti\",\n    \"Go hands-on with the Grist Basics tutorial\": \"Inizia a lavorare con il tutorial introduttivo di Grist\",\n    \"Skip step\": \"Salta questo passaggio\",\n    \"Skip tutorial\": \"Salta il tutorial\",\n    \"What is your role?\": \"Qual è il tuo ruolo?\",\n    \"Your organization\": \"La tua organizzazione\",\n    \"Your role\": \"Il tuo ruolo\"\n  },\n  \"ViewLayout\": {\n    \"Delete\": \"Cancella\",\n    \"Delete data and this widget.\": \"Cancella i dati e questo widget.\",\n    \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Mantieni i dati e cancella il widget. La tabella resterà disponibile in {{rawDataLink}}\",\n    \"Table {{tableName}} will no longer be visible\": \"La tabella {{tableName}} non sarà più visibile\",\n    \"Raw Data page\": \"pagina dei dati grezzi\"\n  },\n  \"CustomWidgetGallery\": {\n    \"Learn more about custom widgets\": \"Scopri di più sui widget personalizzati\",\n    \"Widget URL\": \"URL del widget\",\n    \"(Missing info)\": \"(Informazioni mancanti)\",\n    \"Add widget\": \"Aggiungi widget\",\n    \"Add Your Own Widget\": \"Aggiungi il tuo widget\",\n    \"Add a widget from outside this gallery.\": \"Aggiungi un widget non in questa raccolta.\",\n    \"Cancel\": \"Annulla\",\n    \"Change widget\": \"Cambia widget\",\n    \"Choose custom widget\": \"Scegli un widget personalizzato\",\n    \"Community Widget\": \"Widget della comunità\",\n    \"Custom URL\": \"URL personalizzata\",\n    \"Developer:\": \"Sviluppatore:\",\n    \"Grist Widget\": \"Widget di Grist\",\n    \"Last updated:\": \"Ultimo aggiornamento:\",\n    \"No matching widgets\": \"Nessun widget corrispondente\",\n    \"Search\": \"Cerca\"\n  },\n  \"markdown.d\": {\n    \"The toggle is **off**\": \"L'interruttore è **spento**\",\n    \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n      \"\": {\n        \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nuova funzione Markdown\\n *\\n *      Possiamo _scrivere_ [il solito Markdown](https://markdownguide.org) *dentro*\\n *      un elemento Grainjs.\"\n      }\n    },\n    \"The toggle is **on**\": \"L'interruttore è **acceso**\"\n  },\n  \"HomeIntroCards\": {\n    \"3 minute video tour\": \"Tour video di 3 minuti\",\n    \"Blank document\": \"Documento vuoto\",\n    \"Find solutions and explore more resources {{helpCenterLink}}\": \"Trova delle soluzioni e scopri più risorse {{helpCenterLink}}\",\n    \"Finish our basics tutorial\": \"Completa il nostro tutorial di base\",\n    \"Help center\": \"Centro assistenza\",\n    \"Import file\": \"Importa file\",\n    \"Learn more {{webinarsLinks}}\": \"Scopri di più {{webinarsLinks}}\",\n    \"Start a new document\": \"Inizia un documento nuovo\",\n    \"Templates\": \"Template\",\n    \"Tutorial\": \"Tutorial\",\n    \"Webinars\": \"Webinar\"\n  },\n  \"AdminPanelName\": {\n    \"Admin Panel\": \"Pannello di amministrazione\"\n  },\n  \"markdown\": {\n    \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n      \"\": {\n        \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nuova funzione Markdown\\n *\\n *      Possiamo _scrivere_ [il solito Markdown](https://markdownguide.org) *dentro*\\n *      un elemento Grainjs.\"\n      }\n    },\n    \"The toggle is **off**\": \"L'interruttore è **spento**\",\n    \"The toggle is **on**\": \"L'interruttore è **acceso**\"\n  },\n  \"ReverseReferenceConfig\": {\n    \"Add two-way reference\": \"Aggiungi un riferimento bidirezionale\",\n    \"Column\": \"Colonna\",\n    \"Delete\": \"Elimina\",\n    \"Delete column {{column}} in table {{table}}?\": \"Eliminare la colonna {{column}} nella tabella {{table}}?\",\n    \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Questo è il riferimento inverso della colonna {{column}} nella tabella {{table}}.\",\n    \"Table\": \"Tabella\",\n    \"Two-way Reference\": \"Riferimento bidirezionale\",\n    \"Delete two-way reference?\": \"Elimina il riferimento bidirezionale?\",\n    \"Target table\": \"Tabella di destinazione\"\n  },\n  \"SupportGristButton\": {\n    \"Opted In\": \"Accettato\",\n    \"Support Grist\": \"Supporta Grist\",\n    \"Admin Panel\": \"Pannello di amministrazione\",\n    \"Close\": \"Chiudi\",\n    \"Help Center\": \"Centro assistenza\",\n    \"Opt in to Telemetry\": \"Accetta la telemetria\",\n    \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Grazie! Apprezziamo davvero il tuo supporto e la tua fiducia. Puoi ritirarti in qualsiasi momento dal {{link}} nel menu utente.\"\n  },\n  \"buildReassignModal\": {\n    \"Cancel\": \"Annulla\",\n    \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Ogni record di {{targetTable}} può essere collegato a un solo record in {{sourceTable}}.\",\n    \"Reassign\": \"Riassegna\",\n    \"Reassign to new {{sourceTable}} records.\": \"Riassegna ai record di una nuova {{sourceTable}}.\",\n    \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Riassegna al record {{sourceName}} di {{sourceTable}}.\",\n    \"Record already assigned_one\": \"Record già assegnato\",\n    \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"Il record {{targetName}} di {{targetTable}} è già assegnato al record          {{oldSourceName}} di {{sourceTable}}.\",\n    \"Record already assigned_other\": \"Record già assegnato\"\n  }\n}\n"
  },
  {
    "path": "static/locales/it.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"Loading...\": \"Caricamento...\"\n    }\n}\n"
  },
  {
    "path": "static/locales/ja.client.json",
    "content": "{\n  \"ApiKey\": {\n    \"Remove API Key\": \"APIキーの削除\",\n    \"Click to show\": \"クリックして表示\",\n    \"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キーを削除しようとしています。これにより、今後このAPIキーを使用したリクエストはすべて拒否されます。それでも削除しますか？\",\n    \"Create\": \"生成\",\n    \"Remove\": \"削除\",\n    \"By generating an API key, you will be able to make API calls for your own account.\": \"APIキーを生成することで、自分のアカウントでAPIコールを行うことができるようになります。\",\n    \"This API key can be used to access this account anonymously via the API.\": \"この API キーでAPI 経由でこのアカウントに匿名でアクセスできます。\",\n    \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"この API キーはAPI 経由であなたのアカウントにアクセスするために使用できます。 API キーを誰とも共有しないでください。\"\n  },\n  \"breadcrumbs\": {\n    \"override\": \"上書き\",\n    \"unsaved\": \"未保存\",\n    \"fiddle\": \"フィドル\",\n    \"recovery mode\": \"リカバリーモード\",\n    \"snapshot\": \"スナップショット\",\n    \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"編集を行うことはできますが、新しいコピーが作成され、\\n元の文書には影響しません。\"\n  },\n  \"HomeLeftPane\": {\n    \"All documents\": \"すべてのドキュメント\",\n    \"Manage users\": \"ユーザー管理\",\n    \"Tutorial\": \"チュートリアル\",\n    \"Delete {{workspace}} and all included documents?\": \"{{workspace}} と、含まれるすべてのドキュメントを削除しますか？\",\n    \"Create empty document\": \"空のドキュメントを作成\",\n    \"Create workspace\": \"ワークスペースの作成\",\n    \"Import document\": \"ドキュメントをインポート\",\n    \"Access Details\": \"アクセス詳細\",\n    \"Rename\": \"名前の変更\",\n    \"Trash\": \"ゴミ箱\",\n    \"Workspaces\": \"ワークスペース\",\n    \"Workspace will be moved to Trash.\": \"ワークスペースはゴミ箱に移動されます。\",\n    \"Examples & Templates\": \"テンプレート\",\n    \"Delete\": \"削除\"\n  },\n  \"RowContextMenu\": {\n    \"Insert row\": \"行を挿入\",\n    \"Insert row below\": \"下に行を挿入\",\n    \"Delete\": \"削除\",\n    \"Copy anchor link\": \"リンクをコピー\",\n    \"Duplicate rows_one\": \"行を複製\",\n    \"Duplicate rows_other\": \"行を複製\",\n    \"Insert row above\": \"上に行を挿入\"\n  },\n  \"Drafts\": {\n    \"Undo discard\": \"破棄を元に戻す\",\n    \"Restore last edit\": \"最後の編集に戻す\"\n  },\n  \"FormulaAssistant\": {\n    \"Data\": \"データ\",\n    \"Press Enter to apply suggested formula.\": \"Enterキーを押して、提案された数式を適用する。\",\n    \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"詳しくは、{{helpFunction}} および{{formulaCheat}} 、または{{community}} をご覧ください。\",\n    \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"フォーミュラAIアシスタントのご利用を開始するには、無料のGristアカウントにご登録ください。\",\n    \"Clear conversation\": \"会話を消す\",\n    \"New Chat\": \"新しいチャット\",\n    \"Code view\": \"コードビュー\",\n    \"Apply\": \"適用\",\n    \"Learn more\": \"さらに詳しく\",\n    \"Regenerate\": \"再生成\",\n    \"Community\": \"コミュニティ\",\n    \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"私がお手伝いできるのは数式だけです。テーブル、列、ビューの構築やアクセスルールの作成はできません。\",\n    \"Hi, I'm the Grist Formula AI Assistant.\": \"こんにちは、Grist関数AIアシスタントです。\",\n    \"Preview\": \"プレビュー\",\n    \"Ask the bot.\": \"ボットに聞いてみよう。\",\n    \"Function List\": \"関数リスト\",\n    \"Tips\": \"ヒント\",\n    \"Save\": \"保存\",\n    \"Sign Up for Free\": \"無料でサインアップ\",\n    \"Formula Cheat Sheet\": \"数式のチートシート\",\n    \"Grist's AI Assistance\": \"GristのAI支援\",\n    \"Grist's AI Formula Assistance. \": \"GristのAI数式支援 \",\n    \"Formula Help. \": \"数式のヘルプ \",\n    \"Capabilities\": \"能力\",\n    \"Cancel\": \"キャンセル\",\n    \"Need help? Our AI assistant can help.\": \"お困りですか？AIアシスタントがお手伝いします。\",\n    \"AI Assistant\": \"AIアシスタント\"\n  },\n  \"GridViewMenus\": {\n    \"Unfreeze {{count}} columns_one\": \"固定を解除\",\n    \"Sorted (#{{count}})_one\": \"ソート (#{{count}})\",\n    \"Unfreeze all columns\": \"全列の固定を解除\",\n    \"Freeze {{count}} columns_other\": \"{{count}} 列固定する\",\n    \"Show column {{- label}}\": \"{{- label}} 列を表示\",\n    \"Sort\": \"ソート\",\n    \"Column Options\": \"列オプション\",\n    \"Rename column\": \"列名の変更\",\n    \"Filter Data\": \"フィルターデータ\",\n    \"Delete {{count}} columns_one\": \"列の削除\",\n    \"Insert column to the {{to}}\": \"{{to}} に列を挿入\",\n    \"Hide {{count}} columns_other\": \"{{count}} 列非表示\",\n    \"Add column\": \"列を追加\",\n    \"Reset {{count}} columns_one\": \"列をリセット\",\n    \"Freeze {{count}} columns_one\": \"この列を固定する\",\n    \"More sort options ...\": \"その他のソートオプション…\",\n    \"Freeze {{count}} more columns_one\": \"もう1列固定する\",\n    \"Reset {{count}} entire columns_other\": \"{{count}} 列全体をリセット\",\n    \"Reset {{count}} columns_other\": \"{{count}}列リセット\",\n    \"Clear values\": \"値をクリア\",\n    \"Delete {{count}} columns_other\": \"{{count}}列削除\",\n    \"Unfreeze {{count}} columns_other\": \"{{count}} 列固定を解除\",\n    \"Add to sort\": \"ソートに追加\",\n    \"Insert column to the right\": \"右側に列を挿入\",\n    \"Reset {{count}} entire columns_one\": \"列全体をリセット\",\n    \"Convert formula to data\": \"数式をデータに変換する\",\n    \"Freeze {{count}} more columns_other\": \"さらに {{count}} 列固定する\",\n    \"Hide {{count}} columns_one\": \"列を非表示\",\n    \"Insert column to the left\": \"左側に列を挿入\",\n    \"Sorted (#{{count}})_other\": \"ソート (#{{count}})\",\n    \"Attachment\": \"添付ファイル\",\n    \"Adding UUID column\": \"UUID列を追加\",\n    \"Detect Duplicates in...\": \"重複を検出...\",\n    \"UUID\": \"UUID\",\n    \"Add column with type\": \"型を指定して列を追加\",\n    \"Add formula column\": \"数式列を追加\",\n    \"Apply to new records\": \"新しいレコードに適用\",\n    \"Apply on record changes\": \"レコードの変更に適用\",\n    \"Authorship\": \"作成者名\",\n    \"Timestamp\": \"タイムスタンプ\",\n    \"Detect duplicates in...\": \"重複を検出...\",\n    \"Numeric\": \"数値\",\n    \"Text\": \"テキスト\",\n    \"Integer\": \"整数\",\n    \"Toggle\": \"トグル\",\n    \"Date\": \"日付\",\n    \"DateTime\": \"日時\",\n    \"Choice\": \"選択\",\n    \"Choice List\": \"複数選択\",\n    \"Lookups\": \"Lookups\",\n    \"Shortcuts\": \"ショートカット\",\n    \"Show hidden columns\": \"非表示列を再表示\"\n  },\n  \"DocMenu\": {\n    \"This service is not available right now\": \"このサービスは現在ご利用いただけません\",\n    \"Workspace not found\": \"ワークスペースが見つかりません\",\n    \"Discover More Templates\": \"その他のテンプレート\",\n    \"Current workspace\": \"現在のワークスペース\",\n    \"Edited {{at}}\": \"{{at}}に更新\",\n    \"Pin Document\": \"ドキュメントをピン留めする\",\n    \"Remove\": \"削除\",\n    \"By Date Modified\": \"変更日\",\n    \"Rename\": \"名前の変更\",\n    \"Move\": \"移動\",\n    \"Delete Forever\": \"完全に削除\",\n    \"Trash is empty.\": \"ゴミ箱は空です。\",\n    \"Unpin Document\": \"ドキュメントのピン留めを解除\",\n    \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"ドキュメントは30日間ゴミ箱に残り、その後永久に削除されます。\",\n    \"Requires edit permissions\": \"編集権限が必要\",\n    \"More Examples and Templates\": \"その他の事例とテンプレート\",\n    \"Access Details\": \"アクセス詳細\",\n    \"Deleted {{at}}\": \"{{at}}に削除\",\n    \"You are on the {{siteName}} site. You also have access to the following sites:\": \"あなたは {{siteName}} サイトにいます。 次のサイトにもアクセスできます。\",\n    \"Other Sites\": \"他のサイト\",\n    \"All documents\": \"すべてのドキュメント\",\n    \"Pinned Documents\": \"ピン留めされたドキュメント\",\n    \"Delete {{name}}\": \"{{name}}を削除\",\n    \"Manage users\": \"ユーザー管理\",\n    \"Examples and Templates\": \"事例とテンプレート\",\n    \"Delete\": \"削除\",\n    \"Document will be permanently deleted.\": \"文書は永久に削除されます。\",\n    \"(The organization needs a paid plan)\": \"（組織には有料プランが必要です）\",\n    \"To restore this document, restore the workspace first.\": \"このドキュメントを復元するには、まずワークスペースを復元してください。\",\n    \"You may delete a workspace forever once it has no documents in it.\": \"ワークスペースにドキュメントがなくなれば、ワークスペースを永久に削除できます。\",\n    \"By Name\": \"名前\",\n    \"Examples & Templates\": \"事例・テンプレート\",\n    \"Trash\": \"ゴミ箱\",\n    \"You are on your personal site. You also have access to the following sites:\": \"あなたは個人サイトにいます。以下のサイトにもアクセスできます：\",\n    \"Restore\": \"リストア\",\n    \"Move {{name}} to workspace\": \"{{name}} をワークスペースに移動\",\n    \"Document will be moved to Trash.\": \"ドキュメントはゴミ箱に移動されます。\",\n    \"Permanently Delete \\\"{{name}}\\\"?\": \"”{{name}}” を永久に削除しますか？\"\n  },\n  \"RightPanel\": {\n    \"WIDGET TITLE\": \"ウィジェットのタイトル\",\n    \"COLUMN TYPE\": \"列の型\",\n    \"Edit data selection\": \"データ選択の編集\",\n    \"DATA TABLE NAME\": \"データテーブル名\",\n    \"fields_one\": \"フィールド\",\n    \"Save\": \"保存\",\n    \"You do not have edit access to this document\": \"このドキュメントの編集権限がありません\",\n    \"DATA TABLE\": \"データテーブル\",\n    \"Theme\": \"テーマ\",\n    \"columns_other\": \"列\",\n    \"Data\": \"データ\",\n    \"series_one\": \"系列\",\n    \"GROUPED BY\": \"GROUPED BY\",\n    \"SOURCE DATA\": \"元データ\",\n    \"CHART TYPE\": \"グラフの種類\",\n    \"Detach\": \"切り離す\",\n    \"Change widget\": \"ウィジェットの変更\",\n    \"columns_one\": \"列\",\n    \"series_other\": \"系列\",\n    \"fields_other\": \"フィールド\",\n    \"Row style\": \"行書式\",\n    \"CUSTOM\": \"カスタム\",\n    \"Select widget\": \"ウィジェットを選択\",\n    \"Add referenced columns\": \"参照列の追加\",\n    \"TRANSFORM\": \"変換\",\n    \"Sort & filter\": \"ソート＆フィルター\",\n    \"Widget\": \"ウィジェット\",\n    \"Submit button label\": \"送信ボタンのラベル\",\n    \"Submit another response\": \"別の回答を送信\",\n    \"Display button\": \"ボタンを表示\",\n    \"Redirect automatically after submission\": \"送信後に自動的にリダイレクトする\",\n    \"Redirection\": \"リダイレクト\",\n    \"Required field\": \"必須フィールド\",\n    \"Enter redirect URL\": \"リダイレクト先URL\"\n  },\n  \"FloatingPopup\": {\n    \"Maximize\": \"最大化\",\n    \"Minimize\": \"最小化\"\n  },\n  \"MakeCopyMenu\": {\n    \"Include the structure without any of the data.\": \"データのない構造体を含めます。\",\n    \"Original Looks Unrelated\": \"オリジナルは無関係に見えます\",\n    \"Overwrite\": \"上書き\",\n    \"It will be overwritten, losing any content not in this document.\": \"上書きされ、このドキュメントにない内容は失われます。\",\n    \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"オリジナルにはこの文書にはない変更が加えられているので注意してください。それらの変更は上書きされます。\",\n    \"Workspace\": \"ワークスペース\",\n    \"As template\": \"テンプレートとして\",\n    \"Cancel\": \"キャンセル\",\n    \"Sign up\": \"サインアップ\",\n    \"Enter document name\": \"ドキュメント名を入力\",\n    \"Name\": \"名称\",\n    \"Update\": \"更新\",\n    \"Original Has Modifications\": \"オリジナルには変更が加えられています\",\n    \"No destination workspace\": \"宛先ワークスペースがありません\",\n    \"You do not have write access to the selected workspace\": \"選択したワークスペースの書き込み権限がありません\",\n    \"Download document structure only (no data, for template use)\": \"全てのデータを削除し、構造をテンプレートとして使う\",\n    \"Original Looks Identical\": \"オリジナルは同一のようです\",\n    \"Organization\": \"組織\",\n    \"Replacing the original requires editing rights on the original document.\": \"オリジナルを置き換えるには、元のドキュメントに対する編集権限が必要です。\",\n    \"Download document without history (can significantly reduce file size)\": \"ドキュメントの履歴を削除（ファイルサイズを大幅に削減できます）\",\n    \"To save your changes, please sign up, then reload this page.\": \"変更を保存するには、サインアップしてからこのページをリロードしてください。\",\n    \"The original version of this document will be updated.\": \"このドキュメントの元のバージョンは更新されています。\",\n    \"However, it appears to be already identical.\": \"しかし、すでに同一であるようです。\",\n    \"Update Original\": \"オリジナルを更新\",\n    \"You do not have write access to this site\": \"このサイトへのアクセス権限がありません\",\n    \"Download document and history\": \"全てのドキュメントと履歴をダウンロード\"\n  },\n  \"SortConfig\": {\n    \"Add column\": \"列を追加\",\n    \"Natural sort\": \"自然順\",\n    \"Search Columns\": \"列を検索\",\n    \"Update data\": \"データを更新\",\n    \"Use choice position\": \"選択位置を使用\"\n  },\n  \"Clipboard\": {\n    \"Unavailable Command\": \"利用できないコマンド\",\n    \"Got it\": \"了解\"\n  },\n  \"VisibleFieldsConfig\": {\n    \"Show {{label}}\": \"{{label}} を表示\",\n    \"Cannot drop items into Hidden Fields\": \"隠しフィールドにアイテムをドロップできません\",\n    \"Hidden {{label}}\": \"隠し {{label}}\",\n    \"Hidden Fields cannot be reordered\": \"非表示フィールドの並び替えはできません\",\n    \"Visible {{label}}\": \"{{label}} を表示\",\n    \"Select all\": \"すべて選択\",\n    \"Hide {{label}}\": \"{{label}} を非表示\",\n    \"Clear\": \"クリア\"\n  },\n  \"ColumnFilterMenu\": {\n    \"Search values\": \"検索値\",\n    \"All shown\": \"すべて表示\",\n    \"All except\": \"以外\",\n    \"Start\": \"始まり\",\n    \"No matching values\": \"一致する値なし\",\n    \"End\": \"終わり\",\n    \"Search\": \"検索\",\n    \"Max\": \"最大\",\n    \"Others\": \"その他\",\n    \"Other values\": \"その他の値\",\n    \"Min\": \"最小\",\n    \"All\": \"全て\",\n    \"Filter by Range\": \"範囲によるフィルタ\",\n    \"None\": \"なし\"\n  },\n  \"FieldConfig\": {\n    \"Column options are limited in summary tables.\": \"集計表では列のオプションが制限されています。\",\n    \"Set formula\": \"数式を設定\",\n    \"Data columns_other\": \"データ列\",\n    \"DESCRIPTION\": \"説明\",\n    \"Clear and reset\": \"クリア＆リセット\",\n    \"Empty columns_other\": \"空列\",\n    \"COLUMN LABEL AND ID\": \"列ラベルとID\",\n    \"Empty columns_one\": \"空列\",\n    \"Make into data column\": \"データ列の作成\",\n    \"Enter formula\": \"数式を入力\",\n    \"Clear and make into formula\": \"クリアして式にする\",\n    \"Convert to trigger formula\": \"トリガー式に変換する\",\n    \"COLUMN BEHAVIOR\": \"列の動作\",\n    \"Data columns_one\": \"データ列\",\n    \"TRIGGER FORMULA\": \"トリガー式\",\n    \"Set trigger formula\": \"トリガー式の設定\"\n  },\n  \"DocHistory\": {\n    \"Compare to current\": \"現在と比較\",\n    \"Open snapshot\": \"スナップショットを開く\",\n    \"Snapshots are unavailable.\": \"スナップショットは利用できません。\",\n    \"Snapshots\": \"スナップショット\",\n    \"Activity\": \"アクティビティ\",\n    \"Compare to previous\": \"前回と比較\",\n    \"Beta\": \"ベータ\"\n  },\n  \"UserManager\": {\n    \"Anyone with link \": \"リンクのある方 \",\n    \"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}}.\": \"一旦自分のアクセス権を削除してしまうと、{{name}} に十分なアクセス権を持つ他の誰かの援助がない限り、元に戻すことはできません。\",\n    \"Your role for this team site\": \"このチームサイトでのあなたの役割\",\n    \"Copy link\": \"リンクをコピー\",\n    \"User may not modify their own access.\": \"ユーザーは、自身のアクセスを変更することはできません。\",\n    \"member\": \"メンバー\",\n    \"Add {{member}} to your team\": \"{{member}} をチームに追加\",\n    \"Collaborator\": \"コラボレーター\",\n    \"Link copied to clipboard\": \"クリップボードにコピー\",\n    \"team site\": \"チームサイト\",\n    \"Create a team to share with more people\": \"より多くの人と共有するためにチームを作る\",\n    \"guest\": \"ゲスト\",\n    \"Public access: \": \"公開： \",\n    \"Team member\": \"チームメンバー\",\n    \"Manage members of team site\": \"チームサイトのメンバーの管理\",\n    \"Off\": \"Off\",\n    \"Save & \": \"保存 \",\n    \"Outside collaborator\": \"外部コラボレーター\",\n    \"{{collaborator}} limit exceeded\": \"{{collaborator}} 制限超過\",\n    \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"ユーザは{{parent})}からパーミッションを継承します。削除するには、'アクセス権の継承' オプションを 'None' に設定します。\",\n    \"Your role for this {{resourceType}}\": \"この{{resourceType}}のあなたの役割\",\n    \"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}} に十分なアクセス権を持つ他の誰かの援助がない限り、元に戻すことはできません。\",\n    \"Close\": \"閉じる\",\n    \"Allow anyone with the link to open.\": \"誰でもリンクを開くことができるようにする。\",\n    \"Invite people to {{resourceType}}\": \"{{resourceType}} に招待する\",\n    \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"公開設定は{{parent}} から継承されます。 削除するには、'アクセス権の継承' オプションを 'None' に設定します。\",\n    \"Remove my access\": \"アクセスを削除\",\n    \"Public access\": \"公開\",\n    \"Cancel\": \"キャンセル\",\n    \"Grist support\": \"Gristサポート\",\n    \"You are about to remove your own access to this {{resourceType}}\": \"この {{resourceType}} への自分のアクセス権を削除しようとしています\",\n    \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"ユーザは{{parent}} からパーミッションを継承します。 削除するには、'アクセス権の継承' オプションを 'None' に設定します。\",\n    \"Guest\": \"ゲスト\",\n    \"Invite multiple\": \"複数招待\",\n    \"Confirm\": \"確認\",\n    \"On\": \"On\",\n    \"Open Access Rules\": \"アクセスルールを開く\",\n    \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"デフォルトのアクセスでは、チーム サイト全体ではなく、個々のドキュメントまたはワークスペースにアクセスを許可することはできません。\"\n  },\n  \"WelcomeQuestions\": {\n    \"Welcome to Grist!\": \"Gristへようこそ！\",\n    \"HR & Management\": \"人事労務管理\",\n    \"Sales\": \"営業\",\n    \"Marketing\": \"マーケティング\",\n    \"Type here\": \"ここに入力\",\n    \"Other\": \"その他\",\n    \"Product Development\": \"製品開発\",\n    \"Media Production\": \"メディア制作\",\n    \"What brings you to Grist? Please help us serve you better.\": \"グリストをご利用になる理由は何ですか？より良いサービスを提供するためにご協力ください。\",\n    \"Education\": \"教育\",\n    \"IT & Technology\": \"ITとテクノロジー\",\n    \"Finance & Accounting\": \"財務・会計\",\n    \"Research\": \"研究\"\n  },\n  \"PagePanels\": {\n    \"Open creator panel\": \"クリエイターパネルを開く\",\n    \"Close Creator Panel\": \"クリエイターパネルを閉じる\"\n  },\n  \"GristDoc\": {\n    \"go to webhook settings\": \"webhook設定に移動\",\n    \"Saved linked section {{title}} in view {{name}}\": \"ビュー「{{name}}」のリンクセクション「{{title}}」を保存しました\",\n    \"Added new linked section to view {{viewName}}\": \"ビュー[{{viewName}}]にリンクセクションを追加\",\n    \"Import from file\": \"ファイルからインポート\"\n  },\n  \"AccessRules\": {\n    \"Permission to access the document in full when needed\": \"必要な場合にドキュメントにアクセスする許可\",\n    \"Add column rule\": \"列ルールを追加\",\n    \"Reset\": \"リセット\",\n    \"Lookup Column\": \"参照列\",\n    \"Remove {{- tableId }} rules\": \"{{- tableId }} ルールを削除\",\n    \"Remove {{- name }} user attribute\": \"{{- name }} のユーザー属性を削除\",\n    \"Permissions\": \"権限\",\n    \"Enter Condition\": \"条件を入力\",\n    \"Everyone Else\": \"ほかのユーザー\",\n    \"Saved\": \"保存済み\",\n    \"Remove column {{- colId }} from {{- tableId }} rules\": \"{{- tableId }} ルールから列{{- colId }} を削除\",\n    \"Allow everyone to view Access Rules.\": \"誰でもアクセスルールを閲覧できるようにする。\",\n    \"Type message to display when this rule blocks an action…\": \"メッセージを入力してください。\",\n    \"Lookup Table\": \"参照テーブル\",\n    \"Add table rules\": \"テーブルのルールを追加\",\n    \"Invalid\": \"無効\",\n    \"Condition\": \"条件\",\n    \"Delete table rules\": \"テーブルルールの削除\",\n    \"Permission to view Access Rules\": \"アクセスルールの閲覧権限\",\n    \"User Attributes\": \"ユーザー属性\",\n    \"Default rules\": \"既定のルール\",\n    \"Attribute name\": \"属性名\",\n    \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"テーブル・ルールを追加する際、OWNERにフル・アクセス権を与えるルールを自動的に追加する。\",\n    \"Add user attributes\": \"ユーザー属性を追加\",\n    \"Permission to edit document structure\": \"ドキュメント構造の編集権限\",\n    \"Attribute to Look Up\": \"参照する属性\",\n    \"Everyone\": \"全ユーザー\",\n    \"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.\": \"全員がドキュメント全体をコピーしたり、フィドル モードで完全に表示したりできるようにします。\\n例やテンプレートには役立ちますが、機密情報には役立ちません。\",\n    \"Add Default Rule\": \"既定のルールを追加\",\n    \"This default should be changed if editors' access is to be limited. \": \"このデフォルトは、編集者のアクセスが制限される場合に変更する必要があります。 \",\n    \"Save\": \"保存する\",\n    \"Rules for table \": \"テーブルのルール \",\n    \"Checking...\": \"チェック中…\",\n    \"Special rules\": \"特別ルール\",\n    \"View as\": \"役割で表示\",\n    \"Seed rules\": \"シード・ルール\",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"編集者による構造の編集（例：テーブル、列、レイアウトの変更や削除）、および数式の書き込みを許可し、読み取り制限に関係なくすべてのデータにアクセスできるようにする。\",\n    \"Add table-wide rule\": \"テーブル全体のルールを追加\"\n  },\n  \"FieldEditor\": {\n    \"It should be impossible to save a plain data value into a formula column\": \"単純なデータ値を数式列に保存することは不可能なはずです\",\n    \"Unable to finish saving edited cell\": \"編集したセルの保存ができませんでした\"\n  },\n  \"ColumnInfo\": {\n    \"Cancel\": \"キャンセル\",\n    \"COLUMN ID: \": \"列ID： \",\n    \"Save\": \"保存\",\n    \"COLUMN DESCRIPTION\": \"列の説明\",\n    \"COLUMN LABEL\": \"列ラベル\"\n  },\n  \"AccountPage\": {\n    \"Theme\": \"テーマ\",\n    \"API\": \"API\",\n    \"Change password\": \"パスワードを変更\",\n    \"Email\": \"メールアドレス\",\n    \"Password & security\": \"パスワードとセキュリティ\",\n    \"Account settings\": \"アカウント設定\",\n    \"Two-factor authentication\": \"二要素認証\",\n    \"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.\": \"二要素認証は、Gristアカウントのセキュリティを強化するもので、パスワードを知られた場合でも、あなただけがアカウントにアクセスできるように設計されています。\",\n    \"Language\": \"言語\",\n    \"Edit\": \"編集\",\n    \"Names only allow letters, numbers and certain special characters\": \"名前に使用できるのは、文字、数字、および一部の特殊文字のみです\",\n    \"Login method\": \"ログイン方法\",\n    \"API Key\": \"APIキー\",\n    \"Name\": \"名前\",\n    \"Allow signing in to this account with Google\": \"このアカウントへの Google でのサインインを許可する\",\n    \"Save\": \"保存\"\n  },\n  \"DiscussionEditor\": {\n    \"Show resolved comments\": \"解決済みコメントを表示\",\n    \"Save\": \"保存\",\n    \"Reply to a comment\": \"コメントに返信\",\n    \"Comment\": \"コメント\",\n    \"Started discussion\": \"ディスカッションを開始しました\",\n    \"Write a comment\": \"コメントを書く\",\n    \"Cancel\": \"キャンセル\",\n    \"Only current page\": \"現在のページのみ\",\n    \"Reply\": \"返信\",\n    \"Marked as resolved\": \"解決済みとしてマーク\",\n    \"Remove\": \"削除\",\n    \"Open\": \"開く\",\n    \"Only my threads\": \"自分のスレッドのみ\",\n    \"Edit\": \"編集\",\n    \"Resolve\": \"解決する\",\n    \"Showing last {{nb}} comments\": \"最後の{{nb}} コメントを表示\"\n  },\n  \"CellContextMenu\": {\n    \"Copy anchor link\": \"リンクをコピー\",\n    \"Delete {{count}} rows_one\": \"行の削除\",\n    \"Insert row below\": \"下に行を挿入\",\n    \"Reset {{count}} entire columns_other\": \"{{count}} 列全体をリセット\",\n    \"Insert row\": \"行を挿入\",\n    \"Copy\": \"コピー\",\n    \"Delete {{count}} columns_one\": \"列の削除\",\n    \"Delete {{count}} columns_other\": \"{{count}}列削除\",\n    \"Duplicate rows_one\": \"行を複製\",\n    \"Insert row above\": \"上に行を挿入\",\n    \"Delete {{count}} rows_other\": \"{{count}}行削除\",\n    \"Clear values\": \"値をクリア\",\n    \"Clear cell\": \"セルをクリア\",\n    \"Comment\": \"コメント\",\n    \"Duplicate rows_other\": \"行を複製\",\n    \"Reset {{count}} columns_one\": \"列をリセット\",\n    \"Insert column to the right\": \"右側に列を挿入\",\n    \"Filter by this value\": \"この値でフィルタ\",\n    \"Cut\": \"切り取り\",\n    \"Reset {{count}} columns_other\": \"{{count}}列リセット\",\n    \"Reset {{count}} entire columns_one\": \"列全体をリセット\",\n    \"Insert column to the left\": \"左側に列を挿入\",\n    \"Paste\": \"貼り付け\"\n  },\n  \"Importer\": {\n    \"Merge rows that match these fields:\": \"次のフィールドに一致する行をマージする：\",\n    \"Column mapping\": \"列のマッピング\",\n    \"Grist column\": \"Grist列\",\n    \"{{count}} unmatched field_one\": \"{{count}} 個の不一致フィールド\",\n    \"{{count}} unmatched field in import_one\": \"インポート中に {{count}} 個の不一致フィールドがありました\",\n    \"Revert\": \"元に戻す\",\n    \"Skip Import\": \"インポートをスキップ\",\n    \"{{count}} unmatched field_other\": \"{{count}} 個の不一致フィールド\",\n    \"Select fields to match on\": \"フィールドを選択\",\n    \"New Table\": \"新しいテーブル\",\n    \"Skip\": \"スキップ\",\n    \"Column Mapping\": \"列のマッピング\",\n    \"Destination table\": \"宛先テーブル\",\n    \"Skip Table on Import\": \"インポート時にテーブルをスキップ\",\n    \"Import from file\": \"ファイルからインポート\",\n    \"{{count}} unmatched field in import_other\": \"インポート中に {{count}} 個の不一致フィールドがありました\",\n    \"Update existing records\": \"既存のレコードの更新\",\n    \"Source column\": \"元の列\"\n  },\n  \"WelcomeTour\": {\n    \"Make it relational! Use the {{ref}} type to link tables. \": \"関係性を持たせてください！ テーブルをリンクするには、{{ref}} タイプを使用します。 \",\n    \"Enter\": \"入る\",\n    \"Use {{helpCenter}} for documentation or questions.\": \"資料や質問については、{{helpCenter}} を使用してください。\",\n    \"Sharing\": \"共有\",\n    \"Configuring your document\": \"ドキュメントの設定\",\n    \"Reference\": \"参照\",\n    \"Editing Data\": \"データの編集\",\n    \"template library\": \"テンプレートライブラリ\",\n    \"Use {{addNew}} to add widgets, pages, or import more data. \": \"{{addNew}} を使用してウィジェットやページを追加したり、さらにデータをインポートしたりできます。 \",\n    \"Use the Share button ({{share}}) to share the document or export data.\": \"ドキュメントを共有するか、データをエクスポートするには、共有ボタン ({{share}}) を使用します。\",\n    \"Help Center\": \"ヘルプセンター\",\n    \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"{{templateLibrary}} を参照して、何が可能かを発見し、インスピレーションを得てください。\",\n    \"Share\": \"共有\",\n    \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"日付、選択肢、添付ファイルなどの書式設定オプション、数式、または列の種類を設定します。 \",\n    \"Flying higher\": \"より高く飛ぶ\",\n    \"Customizing columns\": \"列のカスタマイズ\",\n    \"Double-click or hit {{enter}} on a cell to edit it. \": \"セルをダブルクリックするか、{{enter}} を押して編集します。 \",\n    \"Welcome to Grist!\": \"Gristへようこそ！\",\n    \"Add new\": \"新規追加\",\n    \"Toggle the {{creatorPanel}} to format columns, \": \"{{creatorPanel}} を切り替えて列をフォーマットします。 \",\n    \"convert to card view, select data, and more.\": \"カード ビューへの変換、データの選択など。\",\n    \"Building up\": \"構築する\",\n    \"Start with {{equal}} to enter a formula.\": \"{{equal}} で始めて数式を入力します。\"\n  },\n  \"buildViewSectionDom\": {\n    \"Not all data is shown\": \"すべてのデータが表示されるわけではありません\",\n    \"No row selected in {{title}}\": \"{{title}} で選択した行なし\",\n    \"No data\": \"データなし\"\n  },\n  \"ViewSectionMenu\": {\n    \"FILTER\": \"フィルター\",\n    \"(customized)\": \"(カスタマイズ)\",\n    \"Revert\": \"元に戻す\",\n    \"Save\": \"保存\",\n    \"Custom options\": \"カスタムオプション\",\n    \"SORT\": \"ソート\",\n    \"Update Sort&Filter settings\": \"ソートとフィルター設定の更新\",\n    \"(empty)\": \"(空)\",\n    \"(modified)\": \"(修正)\"\n  },\n  \"Tools\": {\n    \"Delete document tour?\": \"ドキュメントツアーを削除しますか？\",\n    \"TOOLS\": \"ツール\",\n    \"Delete\": \"削除\",\n    \"Settings\": \"設定\",\n    \"Access Rules\": \"アクセスルール\",\n    \"Validate Data\": \"データ検証\",\n    \"How-to Tutorial\": \"チュートリアルへ\",\n    \"Tour of this Document\": \"このドキュメントのツアー\",\n    \"Code view\": \"コードビュー\",\n    \"Return to viewing as yourself\": \"自分自身のビューに戻る\",\n    \"Raw data\": \"生データ\",\n    \"Document history\": \"ドキュメントの履歴\"\n  },\n  \"menus\": {\n    \"Reference List\": \"参照リスト\",\n    \"Integer\": \"整数\",\n    \"* Workspaces are available on team plans. \": \"* ワークスペースはチームプランでご利用いただけます。 \",\n    \"Text\": \"テキスト\",\n    \"Attachment\": \"添付\",\n    \"Upgrade now\": \"今すぐアップグレード\",\n    \"Toggle\": \"トグル\",\n    \"Choice\": \"選択\",\n    \"Select fields\": \"フィールドを選択\",\n    \"Choice List\": \"リスト選択\",\n    \"Reference\": \"参照\",\n    \"Any\": \"その他\",\n    \"Numeric\": \"数値\",\n    \"DateTime\": \"日時\",\n    \"Date\": \"日付\"\n  },\n  \"DocPageModel\": {\n    \"Sorry, access to this document has been denied. [{{error}}]\": \"申し訳ありません、このドキュメントへのアクセスは拒否されました。[{{error}}]\",\n    \"Add empty table\": \"空のテーブルを追加\",\n    \"You do not have edit access to this document\": \"このドキュメントの編集権限がありません\",\n    \"Add widget to page\": \"ページにウィジェットを追加する\",\n    \"Add page\": \"ページを追加\",\n    \"Document owners can attempt to recover the document. [{{error}}]\": \"ドキュメントのオーナーは、ドキュメントの回復を試みることができます。[{{error}}]\",\n    \"Reload\": \"再読み込み\",\n    \"Error accessing document\": \"ドキュメントへのアクセスエラー\",\n    \"Enter recovery mode\": \"回復モードに入る\"\n  },\n  \"DocumentSettings\": {\n    \"Ok\": \"OK\",\n    \"Manage Webhooks\": \"Webhookを管理\",\n    \"API\": \"API\",\n    \"Save\": \"保存\",\n    \"Document ID copied to clipboard\": \"ドキュメントIDをコピーしました\",\n    \"Local currency ({{currency}})\": \"ローカル通貨({{currency}})\",\n    \"Save and Reload\": \"保存と再読み込み\",\n    \"Time Zone:\": \"タイムゾーン：\",\n    \"Webhooks\": \"Webhook\",\n    \"Currency:\": \"通貨：\",\n    \"Document settings\": \"ドキュメント設定\",\n    \"Locale:\": \"ロケール：\",\n    \"This document's ID (for API use):\": \"このドキュメントのID(API使用の場合)：\"\n  },\n  \"ColumnTitle\": {\n    \"Column ID copied to clipboard\": \"クリップボードに列IDをコピーしました\",\n    \"Add description\": \"説明を追加\",\n    \"Column description\": \"列の説明\",\n    \"COLUMN ID: \": \"列ID： \",\n    \"Provide a column label\": \"列のラベルを提供\",\n    \"Close\": \"閉じる\",\n    \"Cancel\": \"キャンセル\",\n    \"Column label\": \"列ラベル\",\n    \"Save\": \"保存\"\n  },\n  \"ViewConfigTab\": {\n    \"Section: \": \"セクション： \",\n    \"Form\": \"フォーム\",\n    \"Compact\": \"コンパクト\",\n    \"Blocks\": \"ブロック\",\n    \"Advanced settings\": \"詳細設定\",\n    \"Make On-Demand\": \"オンデマンドを作る\",\n    \"Plugin: \": \"プラグイン： \",\n    \"Edit card layout\": \"カードレイアウトの編集\"\n  },\n  \"ValidationPanel\": {\n    \"Update formula (Shift+Enter)\": \"数式を更新 (Shift+Enter)\",\n    \"Rule {{length}}\": \"ルール{{length}}\"\n  },\n  \"AccountWidget\": {\n    \"Add account\": \"アカウントの追加\",\n    \"Switch Accounts\": \"アカウント切り替え\",\n    \"Activation\": \"アクティベーション\",\n    \"Toggle Mobile Mode\": \"モバイルモードを切り替え\",\n    \"Support Grist\": \"サポート リスト\",\n    \"Access Details\": \"アクセス詳細\",\n    \"Upgrade Plan\": \"アップグレードプラン\",\n    \"Sign out\": \"サインアウト\",\n    \"Profile settings\": \"プロフィール設定\",\n    \"Sign in\": \"サインイン\",\n    \"Pricing\": \"課金\",\n    \"Document settings\": \"ドキュメント設定\",\n    \"Use This Template\": \"このテンプレートを使用する\",\n    \"Manage team\": \"チーム管理\",\n    \"Billing account\": \"請求アカウント\",\n    \"Accounts\": \"アカウント\",\n    \"Sign up\": \"サインアップ\"\n  },\n  \"ViewLayoutMenu\": {\n    \"Widget options\": \"ウィジェットオプション\",\n    \"Advanced sort & filter\": \"高度なソートとフィルタ\",\n    \"Print widget\": \"ウィジェットを印刷する\",\n    \"Data selection\": \"データ選択\",\n    \"Download as XLSX\": \"XLSXとしてダウンロード\",\n    \"Open configuration\": \"設定を開く\",\n    \"Edit card layout\": \"カードレイアウトの編集\",\n    \"Add to page\": \"ページに追加\",\n    \"Delete record\": \"レコードの削除\",\n    \"Collapse widget\": \"折りたたみウィジェット\",\n    \"Show raw data\": \"生データを表示する\",\n    \"Delete widget\": \"ウィジェットの削除\",\n    \"Copy anchor link\": \"リンクをコピー\",\n    \"Download as CSV\": \"CSVとしてダウンロード\"\n  },\n  \"ACUserManager\": {\n    \"Invite new member\": \"メンバーを招待\",\n    \"We'll email an invite to {{email}}\": \"{{email}} に招待メールを送信します\",\n    \"Enter email address\": \"メールアドレスを入力\"\n  },\n  \"HomeIntro\": {\n    \"personal site\": \"個人サイト\",\n    \"Any documents created in this site will appear here.\": \"このサイトで作成されたドキュメントはすべてここに表示されます。\",\n    \"Welcome to Grist, {{- name}}!\": \"{{- name}} さん、Gristへようこそ！\",\n    \"Get started by inviting your team and creating your first Grist document.\": \"チームを招待し、最初のGristドキュメントを作成しましょう。\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"このサイトには読み取り専用アクセス権があります。 現在、ドキュメントはありません。\",\n    \"Help Center\": \"ヘルプセンター\",\n    \"Get started by creating your first Grist document.\": \"まずはドキュメントを作成してみましょう。\",\n    \"This workspace is empty.\": \"このワークスペースは空です。\",\n    \"Visit our {{link}} to learn more.\": \"詳しくは {{link}} をご覧ください。\",\n    \"Visit our {{link}} to learn more about Grist.\": \"Gristの詳細については、{{link}} をご覧ください。\",\n    \"{{signUp}} to save your work. \": \"{{signUp}} をクリックして作業を保存します。 \",\n    \"Welcome to {{- orgName}}\": \"{{- orgName}} へようこそ\",\n    \"Welcome to Grist, {{name}}!\": \"{{name}} さん、Gristへようこそ！\",\n    \"Browse Templates\": \"テンプレートを見る\",\n    \"Sign in\": \"サインイン\",\n    \"Welcome to {{orgName}}\": \"{{orgName}} へようこそ\",\n    \"Invite Team Members\": \"チームメンバーを招待\",\n    \"Get started by exploring templates, or creating your first Grist document.\": \"テンプレートを探したり、最初のGristドキュメントを作成することから始めましょう。\",\n    \"Import document\": \"ドキュメントをインポート\",\n    \"Create empty document\": \"空のドキュメントを作成\",\n    \"Sign up\": \"サインアップ\",\n    \"To use Grist, please either sign up or sign in.\": \"Gristを利用するには、サインアップするか、サインインしてください。\",\n    \"Welcome to Grist!\": \"Gristへようこそ！\"\n  },\n  \"CustomSectionConfig\": {\n    \"Open configuration\": \"設定を開く\",\n    \"Pick a {{columnType}} column\": \"{{columnType}} 列を選ぶ\",\n    \"Widget needs {{fullAccess}} to this document.\": \"ウィジェットにはこのドキュメントへの {{fullAccess}} が必要です。\",\n    \"Enter Custom URL\": \"カスタムURLを入力\",\n    \"Select Custom Widget\": \"カスタムウィジェットを選択\",\n    \"Learn more about custom widgets\": \"カスタムウィジェットの詳細\",\n    \"Add\": \"追加\",\n    \" (optional)\": \" (オプション)\",\n    \"Read selected table\": \"選択されたテーブルを読む\",\n    \"Full document access\": \"完全なドキュメントアクセス\",\n    \"Pick a column\": \"列を選択\",\n    \"No document access\": \"ドキュメントアクセスなし\",\n    \"Widget does not require any permissions.\": \"ウィジェットは許可を必要としません。\"\n  },\n  \"WelcomeSitePicker\": {\n    \"You have access to the following Grist sites.\": \"以下のサイトにアクセスすることができます。\",\n    \"Welcome back\": \"おかえりなさい\",\n    \"You can always switch sites using the account menu.\": \"アカウントメニューからいつでもサイトを切り替えることができます。\"\n  },\n  \"TopBar\": {\n    \"Manage team\": \"チーム管理\"\n  },\n  \"GridOptions\": {\n    \"Horizontal gridlines\": \"水平線\",\n    \"Vertical gridlines\": \"垂直線\",\n    \"Grid Options\": \"表オプション\",\n    \"Zebra stripes\": \"ストライプ\"\n  },\n  \"FormulaEditor\": {\n    \"Enter formula or {{button}}.\": \"数式または{{button}}を入力。\",\n    \"Error in the cell\": \"セル内のエラー\",\n    \"Errors in {{numErrors}} of {{numCells}} cells\": \"{{numCells}}セル中{{numErrors}}セルのエラー\",\n    \"Enter formula.\": \"数式を入力。\",\n    \"Column or field is required\": \"列またはフィールドは必須です\",\n    \"Expand Editor\": \"エディターを展開\",\n    \"use AI Assistant\": \"AIアシスタントを使う\",\n    \"Errors in all {{numErrors}} cells\": \"全{{numErrors}}セルのエラー\"\n  },\n  \"ShareMenu\": {\n    \"Current Version\": \"現在のバージョン\",\n    \"Return to {{termToUse}}\": \"{{termToUse}} に戻る\",\n    \"Download...\": \"ダウンロード...\",\n    \"Show in folder\": \"フォルダに表示\",\n    \"Share\": \"共有\",\n    \"Export CSV\": \"CSVエクスポート\",\n    \"Send to Google Drive\": \"Gooogle Driveに送信\",\n    \"Export XLSX\": \"XLSXエクスポート\",\n    \"Access Details\": \"アクセス詳細\",\n    \"Compare to {{termToUse}}\": \"{{termToUse}} と比較\",\n    \"Download\": \"ダウンロード\",\n    \"Replace {{termToUse}}...\": \"{{termToUse}} を置換…\",\n    \"Duplicate document\": \"ドキュメントを複製\",\n    \"Original\": \"オリジナル\",\n    \"Back to current\": \"現在に戻る\",\n    \"Edit without affecting the original\": \"オリジナルに影響を与えずに編集する\",\n    \"Work on a copy\": \"コピーで作業する\",\n    \"Manage users\": \"ユーザー管理\",\n    \"Unsaved\": \"未保存\",\n    \"Save Document\": \"ドキュメントを保存\",\n    \"Save copy\": \"コピーを保存\"\n  },\n  \"UserManagerModel\": {\n    \"View & edit\": \"閲覧と編集\",\n    \"Owner\": \"オーナー\",\n    \"None\": \"なし\",\n    \"View only\": \"閲覧のみ\",\n    \"No Default Access\": \"デフォルトアクセスなし\",\n    \"Viewer\": \"閲覧者\",\n    \"Editor\": \"編集者\"\n  },\n  \"DocumentUsage\": {\n    \"Data size\": \"データサイズ\",\n    \"Usage statistics are only available to users with full access to the document data.\": \"使用状況統計は、ドキュメント データへの完全なアクセス権を持つユーザーのみが利用できます。\",\n    \"Usage\": \"使用方法\",\n    \"Size of attachments\": \"添付ファイルのサイズ\",\n    \"For higher limits, \": \"より高い上限のために、 \",\n    \"start your 30-day free trial of the Pro plan.\": \"プロプランの30日間無料トライアルを開始します。\",\n    \"Rows\": \"行\"\n  },\n  \"NotifyUI\": {\n    \"Go to your free personal site\": \"無料の個人サイトへ\",\n    \"Upgrade Plan\": \"アップグレードプラン\",\n    \"Ask for help\": \"助けを求める\",\n    \"Renew\": \"新着情報\",\n    \"Manage billing\": \"請求管理\",\n    \"Give feedback\": \"フィードバックを送る\",\n    \"Cannot find personal site, sorry!\": \"個人サイトが見つかりません\",\n    \"Notifications\": \"通知\",\n    \"Report a problem\": \"問題を報告する\",\n    \"No notifications\": \"通知なし\"\n  },\n  \"FieldContextMenu\": {\n    \"Copy anchor link\": \"リンクをコピー\",\n    \"Hide field\": \"フィールドを隠す\",\n    \"Copy\": \"コピー\",\n    \"Paste\": \"貼り付け\",\n    \"Clear field\": \"フィールドをクリア\",\n    \"Cut\": \"切り取り\"\n  },\n  \"WidgetTitle\": {\n    \"Override widget title\": \"ウィジェットのタイトルを上書きする\",\n    \"WIDGET TITLE\": \"ウィジェットタイトル\",\n    \"DATA TABLE NAME\": \"データテーブル名\",\n    \"Cancel\": \"キャンセル\",\n    \"WIDGET DESCRIPTION\": \"ウィジェットの説明\",\n    \"Save\": \"保存\",\n    \"Provide a table name\": \"テーブル名を指定する\"\n  },\n  \"ChoiceTextBox\": {\n    \"CHOICES\": \"選択肢\"\n  },\n  \"ChartView\": {\n    \"Pick a column\": \"列を選択\",\n    \"Toggle chart aggregation\": \"チャート集計の切り替え\",\n    \"selected new group data columns\": \"選択した新しいグループデータ列\"\n  },\n  \"ViewAsBanner\": {\n    \"UnknownUser\": \"不明なユーザー\"\n  },\n  \"SupportGristNudge\": {\n    \"Close\": \"閉じる\",\n    \"Help Center\": \"ヘルプセンター\",\n    \"Contribute\": \"貢献する\"\n  },\n  \"PermissionsWidget\": {\n    \"Deny all\": \"すべて拒否\",\n    \"Read only\": \"読み取り専用\",\n    \"Allow all\": \"すべてを許可\"\n  },\n  \"ActionLog\": {\n    \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"列 {{colId}} がアクション #{{action.actionNum}} で削除されました\",\n    \"This row was subsequently removed in action {{action.actionNum}}\": \"この行はアクション #{{action.actionNum}} で削除されました\",\n    \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"テーブル {{tableId}}がアクション #{{actionNum}}で削除されました\",\n    \"All tables\": \"すべてのテーブル\",\n    \"Action Log failed to load\": \"ログファイルの読み込みに失敗しました\"\n  },\n  \"errorPages\": {\n    \"Account deleted{{suffix}}\": \"削除されたアカウント{{suffix}}\",\n    \"Something went wrong\": \"何か問題が発生しました\",\n    \"There was an error: {{message}}\": \"エラーが発生しました：{{message}}\",\n    \"Go to main page\": \"メインページへ\",\n    \"Sign in\": \"サインイン\",\n    \"Access denied{{suffix}}\": \"アクセス拒否{{suffix}}\",\n    \"There was an unknown error.\": \"未知のエラーが発生しました。\",\n    \"Add account\": \"アカウントの追加\",\n    \"You do not have access to this organization's documents.\": \"あなたはこの組織のドキュメントにアクセスすることはできません。\",\n    \"You are now signed out.\": \"サインアウトしました。\",\n    \"Signed out{{suffix}}\": \"サインアウト{{suffix}}\",\n    \"Your account has been deleted.\": \"あなたのアカウントは削除されました。\",\n    \"Page not found{{suffix}}\": \"ページが見つかりません{{suffix}}\",\n    \"Error{{suffix}}\": \"エラー{{suffix}}\",\n    \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"{{email}} としてサインインしています。別のアカウントでサインインするか、管理者にアクセスを依頼することができます。\",\n    \"Sign up\": \"サインアップ\",\n    \"Sign in again\": \"再度サインインする\",\n    \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"リクエストされたページが見つかりません。{{separator}}URLを確認して、もう一度やり直してください。\",\n    \"Sign in to access this organization's documents.\": \"この組織のドキュメントにアクセスするには、サインインしてください。\",\n    \"Contact support\": \"サポートへのお問い合わせ\"\n  },\n  \"RecordLayoutEditor\": {\n    \"Show field {{- label}}\": \"フィールド {{- label}} を表示\",\n    \"Add field\": \"フィールドを追加\",\n    \"Save layout\": \"レイアウトを保存\",\n    \"Cancel\": \"キャンセル\",\n    \"Create new field\": \"新規フィールドの作成\"\n  },\n  \"CellStyle\": {\n    \"HEADER STYLE\": \"ヘッダー書式\",\n    \"Header Style\": \"ヘッダー書式\",\n    \"Default header style\": \"デフォルトのヘッダー書式\",\n    \"Default cell style\": \"既定のセル書式\",\n    \"CELL STYLE\": \"セル書式\",\n    \"Cell style\": \"セル書式\",\n    \"Open row styles\": \"行書式を開く\"\n  },\n  \"ConditionalStyle\": {\n    \"Rule must return True or False\": \"ルールはTrueまたはFalseを返す必要があります\",\n    \"Row style\": \"行書式\",\n    \"Add another rule\": \"別のルールを追加する\",\n    \"Add conditional style\": \"条件付き書式を追加\",\n    \"Error in style rule\": \"書式ルールのエラー\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"適用\",\n    \"Cancel\": \"キャンセル\",\n    \"Default cell style\": \"既定のセル書式\"\n  },\n  \"FieldBuilder\": {\n    \"CELL FORMAT\": \"セル形式\",\n    \"DATA FROM TABLE\": \"テーブルからのデータ\",\n    \"Revert field settings for {{colId}} to common\": \"{{colId}} のフィールド設定を共通に戻します\",\n    \"Save field settings for {{colId}} as common\": \"{{colId}} のフィールド設定を共通として保存\",\n    \"Changing multiple column types\": \"複数の列タイプを変更する\",\n    \"Use separate field settings for {{colId}}\": \"{{colId}} には別のフィールド設定を使用します\",\n    \"Apply formula to data\": \"数式をデータに適用する\"\n  },\n  \"search\": {\n    \"Search in document\": \"ドキュメントを検索\",\n    \"Find Next \": \"次を探す \",\n    \"No results\": \"結果なし\",\n    \"Find Previous \": \"前を探す \",\n    \"Search\": \"検索\"\n  },\n  \"FieldMenus\": {\n    \"Use separate settings\": \"別々の設定を使用する\",\n    \"Revert to common settings\": \"共通設定に戻す\",\n    \"Using common settings\": \"共通設定の使用\",\n    \"Using separate settings\": \"別々の設定を使用する\",\n    \"Save as common settings\": \"共通設定として保存\"\n  },\n  \"FilterConfig\": {\n    \"Add column\": \"列を追加\"\n  },\n  \"EditorTooltip\": {\n    \"Convert column to formula\": \"列を数式に変換\"\n  },\n  \"PageWidgetPicker\": {\n    \"Select widget\": \"ウィジェットを選択\",\n    \"Add to page\": \"ページに追加\",\n    \"Select data\": \"データを選択\",\n    \"Group by\": \"Group by\",\n    \"Building {{- label}} widget\": \"{{- label}} ウィジェットを構築\"\n  },\n  \"Pages\": {\n    \"The following tables will no longer be visible_one\": \"以下のテーブルは表示されなくなります\",\n    \"Delete\": \"削除\",\n    \"The following tables will no longer be visible_other\": \"以下のテーブルは表示されなくなります\",\n    \"Delete data and this page.\": \"データとこのページを削除する。\"\n  },\n  \"DescriptionTextArea\": {\n    \"DESCRIPTION\": \"説明\"\n  },\n  \"WebhookPage\": {\n    \"Webhook settings\": \"Webhook設定\",\n    \"Clear queue\": \"キューをクリア\"\n  },\n  \"TriggerFormulas\": {\n    \"Close\": \"閉じる\",\n    \"Cancel\": \"キャンセル\",\n    \"Apply on changes to:\": \"変更を適用する：\",\n    \"Apply to new records\": \"新しいレコードに適用\",\n    \"OK\": \"OK\",\n    \"Current field \": \"現在のフィールド \",\n    \"Any field\": \"任意のフィールド\",\n    \"Apply on record changes\": \"レコードの変更に適用\"\n  },\n  \"PluginScreen\": {\n    \"Import failed: \": \"インポート失敗： \"\n  },\n  \"GristTooltips\": {\n    \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"数式の条件が満たされた場合、この列のセルに条件付き書式を適用します。\",\n    \"Click the Add new button to create new documents or workspaces, or import data.\": \"「新規追加」ボタンをクリックして、新しいドキュメントまたはワークスペースを作成するか、データをインポートします。\",\n    \"Apply conditional formatting to rows based on formulas.\": \"数式に基づいて条件付き書式を行に適用します。\",\n    \"Click on “Open row styles” to apply conditional formatting to rows.\": \"行に条件付き書式を適用するには、「行書式を開く」をクリックする。\",\n    \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"参照列のセルは常にそのテーブル内の {{entire}} レコードを識別しますが、そのレコードからどの列を表示するかを選択することもできます。\",\n    \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"数式は多くの Excel 関数、完全な Python 構文をサポートし、便利な AI アシスタントが含まれています。\",\n    \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUIDはランダムに生成される文字列で、一意の識別子やキーとして役立ちます。\",\n    \"The total size of all data in this document, excluding attachments.\": \"添付ファイルを除く、このドキュメント内のすべてのデータの合計サイズ。\",\n    \"Lookups return data from related tables.\": \"Lookupは関連テーブルからデータを返します。\",\n    \"Reference columns are the key to {{relational}} data in Grist.\": \"参照列は、Grist の {{relational}} データへのキーです。\",\n    \"These rules are applied after all column rules have been processed, if applicable.\": \"これらのルールは、列ルールの処理が終わった後に適用されます。\"\n  },\n  \"AppHeader\": {\n    \"Personal Site\": \"個人サイト\",\n    \"Home page\": \"ホームページ\",\n    \"Team Site\": \"チームサイト\",\n    \"Legacy\": \"レガシー\",\n    \"Grist Templates\": \"Gristテンプレート\"\n  },\n  \"DataTables\": {\n    \"Raw Data Tables\": \"生データテーブル\",\n    \"Duplicate table\": \"テーブルの複製\",\n    \"You do not have edit access to this document\": \"このドキュメントへのアクセス権がありません\",\n    \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"{{formattedTableName}} データを削除し、すべてのページから削除しますか?\",\n    \"Click to copy\": \"クリックしてコピー\",\n    \"Table ID copied to clipboard\": \"クリップボードにテーブルIDをコピーしました\",\n    \"Edit record card\": \"レコードカードを編集\",\n    \"Record Card\": \"レコードカード\",\n    \"Record Card Disabled\": \"レコードカードを無効化\",\n    \"Remove table\": \"テーブルを削除\",\n    \"Rename table\": \"テーブル名を編集\",\n    \"{{action}} Record Card\": \"レコードカードを{{action}}\"\n  },\n  \"NTextBox\": {\n    \"false\": \"false\",\n    \"true\": \"true\"\n  },\n  \"FilterBar\": {\n    \"Search Columns\": \"列を検索\",\n    \"SearchColumns\": \"列を検索\"\n  },\n  \"TypeTransformation\": {\n    \"Revise\": \"改訂\",\n    \"Update formula (Shift+Enter)\": \"数式を更新 (Shift+Enter)\",\n    \"Preview\": \"プレビュー\",\n    \"Apply\": \"適用\",\n    \"Cancel\": \"キャンセル\"\n  },\n  \"NumericTextBox\": {\n    \"Decimals\": \"小数\",\n    \"Currency\": \"通貨\",\n    \"Default currency ({{defaultCurrency}})\": \"デフォルト通貨 ({{defaultCurrency}})\",\n    \"Number Format\": \"数値書式\"\n  },\n  \"App\": {\n    \"Description\": \"説明\",\n    \"Memory Error\": \"メモリーエラー\",\n    \"Key\": \"キー\",\n    \"Translators: please translate this only when your language is ready to be offered to users\": \"翻訳者へ：あなたの言語がユーザーに提供できる準備が整った場合にのみ翻訳してください\"\n  },\n  \"OnBoardingPopups\": {\n    \"Finish\": \"終了\",\n    \"Next\": \"次へ\"\n  },\n  \"Reference\": {\n    \"CELL FORMAT\": \"セル書式\",\n    \"Row ID\": \"行ID\",\n    \"SHOW COLUMN\": \"列表示\"\n  },\n  \"HyperLinkEditor\": {\n    \"[link label] url\": \"[リンクラベル] URL\"\n  },\n  \"FloatingEditor\": {\n    \"Collapse Editor\": \"折りたたみエディタ\"\n  },\n  \"ThemeConfig\": {\n    \"Switch appearance automatically to match system\": \"システムに合わせて自動的に外観を切り替える\",\n    \"Appearance \": \"外観 \"\n  },\n  \"GridView\": {\n    \"Click to insert\": \"クリックして挿入\"\n  },\n  \"pages\": {\n    \"Duplicate page\": \"ページを複製\",\n    \"You do not have edit access to this document\": \"このドキュメントの編集権限がありません\",\n    \"Remove\": \"削除\",\n    \"Rename\": \"名前の変更\"\n  },\n  \"ColumnEditor\": {\n    \"COLUMN DESCRIPTION\": \"列の説明\",\n    \"COLUMN LABEL\": \"列ラベル\"\n  },\n  \"RefSelect\": {\n    \"No columns to add\": \"追加する列はありません\",\n    \"Add column\": \"列を追加\"\n  },\n  \"SortFilterConfig\": {\n    \"Update Sort & Filter settings\": \"ソートとフィルター設定の更新\",\n    \"Save\": \"保存\",\n    \"Sort\": \"ソート\",\n    \"Filter\": \"フィルター\",\n    \"Revert\": \"元に戻す\"\n  },\n  \"CodeEditorPanel\": {\n    \"Code View is available only when you have full document access.\": \"コードビューは、ドキュメントへのフルアクセス権を持っている場合にのみ利用できます。\",\n    \"Access denied\": \"アクセス拒否\"\n  },\n  \"ViewAsDropdown\": {\n    \"Example Users\": \"ユーザー例\",\n    \"Users from table\": \"テーブルからユーザー\",\n    \"View as\": \"として表示\"\n  },\n  \"DuplicateTable\": {\n    \"Copy all data in addition to the table structure.\": \"テーブル構造に加えてすべてのデータをコピーします。\",\n    \"Name for new table\": \"新しいテーブルの名前\",\n    \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"通常、テーブルを複製する代わりに、リンクされたビューを使用してデータをセグメント化する方が適切です。{{link}}\"\n  },\n  \"TypeTransform\": {\n    \"Cancel\": \"キャンセル\",\n    \"Revise\": \"改訂\",\n    \"Apply\": \"適用\",\n    \"Update formula (Shift+Enter)\": \"数式を更新 (Shift+Enter)\",\n    \"Preview\": \"プレビュー\"\n  },\n  \"LanguageMenu\": {\n    \"Language\": \"言語\"\n  },\n  \"OpenVideoTour\": {\n    \"Video Tour\": \"ビデオツアー\",\n    \"Grist Video Tour\": \"Gristのビデオツアー\"\n  },\n  \"duplicatePage\": {\n    \"Note that this does not copy data, but creates another view of the same data.\": \"これはデータをコピーするのではなく、同じデータの別のビューを作成することに注意してください。\",\n    \"Duplicate page {{pageName}}\": \"重複ページ{{pageName}}\"\n  },\n  \"CurrencyPicker\": {\n    \"Invalid currency\": \"無効な通貨\"\n  },\n  \"LeftPanelCommon\": {\n    \"Help Center\": \"ヘルプセンター\"\n  },\n  \"searchDropdown\": {\n    \"Search\": \"検索\"\n  },\n  \"SearchModel\": {\n    \"Search all tables\": \"すべてのテーブルを検索\",\n    \"Search all pages\": \"すべてのページを検索\"\n  },\n  \"modals\": {\n    \"Save\": \"保存\",\n    \"Cancel\": \"キャンセル\",\n    \"Ok\": \"OK\"\n  },\n  \"SiteSwitcher\": {\n    \"Switch Sites\": \"サイトを切り替え\",\n    \"Create new team site\": \"新しいチームサイトを作成する\"\n  },\n  \"AddNewButton\": {\n    \"Add new\": \"新規追加\"\n  },\n  \"RecordLayout\": {\n    \"Updating record layout.\": \"レコードレイアウトの更新。\"\n  },\n  \"DocTour\": {\n    \"No valid document tour\": \"有効なドキュメントのツアー無し\"\n  },\n  \"AppModel\": {\n    \"This team site is suspended. Documents can be read, but not modified.\": \"このチームサイトは停止中です。ドキュメントの閲覧は可能ですが、編集はできません。\"\n  },\n  \"ACLUsers\": {\n    \"Example Users\": \"ユーザー例\",\n    \"Users from table\": \"テーブルからユーザー\"\n  },\n  \"SelectionSummary\": {\n    \"Copied to clipboard\": \"クリップボードにコピー\"\n  },\n  \"DescriptionConfig\": {\n    \"DESCRIPTION\": \"説明\"\n  },\n  \"sendToDrive\": {\n    \"Sending file to Google Drive\": \"Googleドライブにファイルを送信する\"\n  },\n  \"CardContextMenu\": {\n    \"Insert card\": \"カードを挿入\",\n    \"Duplicate card\": \"カードを複製\",\n    \"Delete card\": \"カードを削除\"\n  },\n  \"FormView\": {\n    \"Publish your form?\": \"このフォームを公開しますか？\",\n    \"Publish\": \"公開\"\n  },\n  \"Menu\": {\n    \"Paragraph\": \"段落\"\n  }\n}\n"
  },
  {
    "path": "static/locales/ja.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/ko.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Invite new member\": \"새 멤버 초대\",\n        \"Enter email address\": \"이메일 주소 입력\",\n        \"We'll email an invite to {{email}}\": \"{{email}} 주소로 초대 메일을 보내드립니다\"\n    },\n    \"AccessRules\": {\n        \"Add column rule\": \"열 규칙 추가\",\n        \"Add Default Rule\": \"기본 규칙 추가\",\n        \"Special rules\": \"특별 규칙\",\n        \"Permission to view Access Rules\": \"접근 규칙 보기 권한\",\n        \"Add table rules\": \"표 규칙 추가\",\n        \"Add user attributes\": \"사용자 속성 추가\",\n        \"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.\": \"모든 사용자가 전체 문서를 복사하거나 탐색 모드에서 전체 내용을 볼 수 있도록 허용합니다.\\n예제 및 템플릿에는 유용하지만 민감한 데이터에는 적합하지 않습니다.\",\n        \"Allow everyone to view Access Rules.\": \"모든 사용자가 접근 규칙을 볼 수 있도록 허용합니다.\",\n        \"Attribute name\": \"속성 이름\",\n        \"Attribute to Look Up\": \"조회할 속성\",\n        \"Checking...\": \"확인 중…\",\n        \"Condition\": \"조건\",\n        \"Default rules\": \"기본 규칙\",\n        \"Delete table rules\": \"표 규칙 삭제\",\n        \"Enter Condition\": \"조건 입력\",\n        \"Everyone\": \"모든 사용자\",\n        \"Everyone Else\": \"그 외 모든 사용자\",\n        \"Invalid\": \"잘못됨\",\n        \"Lookup Column\": \"조회 열\",\n        \"Lookup Table\": \"조회용 테이블\",\n        \"Permission to access the document in full when needed\": \"필요시 문서 전체에 접근할 권한\",\n        \"Permissions\": \"권한\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"{{- tableId }} 규칙에서 {{- colId }} 열 제거\",\n        \"Remove {{- tableId }} rules\": \"{{- tableId }} 규칙 제거\",\n        \"Remove {{- name }} user attribute\": \"{{- name }} 사용자 속성 제거\",\n        \"Reset\": \"초기화\",\n        \"Rules for table \": \"표 규칙: \",\n        \"Save\": \"저장\",\n        \"Saved\": \"저장됨\",\n        \"Type message to display when this rule blocks an action…\": \"메시지 입력…\",\n        \"User Attributes\": \"사용자 속성\",\n        \"View as\": \"다음 사용자로 보기\",\n        \"Seed rules\": \"기본 규칙 생성\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"표 규칙 추가 시, 소유자(OWNER)에게 전체 접근 권한을 부여하는 규칙을 자동으로 추가합니다.\",\n        \"Permission to edit document structure\": \"문서 구조 편집 권한\",\n        \"This default should be changed if editors' access is to be limited. \": \"편집자의 접근을 제한하려면 이 기본 설정을 변경해야 합니다. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"편집자가 구조(예: 표, 열, 레이아웃 수정 및 삭제)를 편집하고, 읽기 제한과 관계없이 모든 데이터에 접근할 수 있는 수식을 작성하도록 허용합니다.\",\n        \"Add table-wide rule\": \"표 전체 규칙 추가\"\n    },\n    \"ViewAsDropdown\": {\n        \"Example Users\": \"예시 사용자\",\n        \"View as\": \"다음 사용자로 보기\",\n        \"Users from table\": \"표에서 불러온 사용자\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"새로 추가\"\n    },\n    \"App\": {\n        \"Description\": \"설명\",\n        \"Key\": \"키\",\n        \"Memory Error\": \"메모리 오류\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"번역가 참고: 이 문구는 해당 언어 지원이 준비되었을 때만 번역하세요\"\n    },\n    \"CustomSectionConfig\": {\n        \"No document access\": \"문서 접근 불가\",\n        \"Widget does not require any permissions.\": \"위젯에 필요한 권한이 없습니다.\",\n        \" (optional)\": \" (선택 사항)\",\n        \"Add\": \"추가\",\n        \"Enter Custom URL\": \"사용자 정의 URL 입력\",\n        \"Full document access\": \"문서 전체 접근\",\n        \"Learn more about custom widgets\": \"사용자 정의 위젯에 대해 자세히 알아보기\",\n        \"Open configuration\": \"구성 열기\",\n        \"Pick a column\": \"열 선택\",\n        \"Pick a {{columnType}} column\": \"{{columnType}} 유형의 열 선택\",\n        \"Read selected table\": \"선택한 표 읽기\",\n        \"Select Custom Widget\": \"사용자 정의 위젯 선택\",\n        \"Widget needs to {{read}} the current table.\": \"위젯은 현재 표를 {{read}}해야 합니다.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"위젯은 이 문서에 대한 {{fullAccess}} 권한이 필요합니다.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}}개의 {{columnType}} 형식이 아닌 열은 표시되지 않습니다\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{columnType}} 유형이 아닌 열 {{wrongTypeCount}}개가 표시되지 않습니다\",\n        \"Clear selection\": \"선택 해제\",\n        \"No {{columnType}} columns in table.\": \"표에 {{columnType}} 유형의 열이 없습니다.\",\n        \"ACCESS LEVEL\": \"접근 수준\",\n        \"Accept\": \"수락\",\n        \"Custom URL\": \"사용자 정의 URL\",\n        \"Developer:\": \"개발자:\",\n        \"Last updated:\": \"마지막 업데이트:\",\n        \"Missing description and author information.\": \"설명 및 작성자 정보가 없습니다.\",\n        \"Reject\": \"거부\",\n        \"Widget\": \"위젯\"\n    },\n    \"DocPageModel\": {\n        \"Error accessing document\": \"문서 접근 오류\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"문서 소유자는 문서 복구를 시도할 수 있습니다. [{{error}}]\",\n        \"Add empty table\": \"빈 표 추가\",\n        \"Add page\": \"페이지 추가\",\n        \"Add widget to page\": \"페이지에 위젯 추가\",\n        \"Enter recovery mode\": \"복구 모드 진입\",\n        \"Reload\": \"새로고침\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"죄송합니다. 이 문서에 대한 접근이 거부되었습니다. [{{error}}]\",\n        \"You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]\": \"문서를 새로고침하거나 복구 모드를 사용할 수 있습니다. 복구 모드는 소유자에게는 문서 전체 접근 권한을 부여하고 다른 사용자에게는 접근을 차단합니다. 또한 수식을 비활성화합니다. [{{error}}]\",\n        \"You do not have edit access to this document\": \"이 문서를 편집할 접근 권한이 없습니다\"\n    },\n    \"DocumentSettings\": {\n        \"Local currency ({{currency}})\": \"현지 통화 ({{currency}})\",\n        \"Save\": \"저장\",\n        \"Base doc URL: {{docApiUrl}}\": \"기본 문서 URL: {{docApiUrl}}\",\n        \"Copy to clipboard\": \"클립보드에 복사\",\n        \"Currency\": \"통화\",\n        \"Data engine\": \"데이터 엔진\",\n        \"Default for DateTime columns\": \"날짜/시간 열의 기본값\",\n        \"Document ID\": \"문서 ID\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"REST API에서 {{docId}}를 요구할 때 사용할 문서 ID입니다. {{apiURL}} 참조\",\n        \"Find slow formulas\": \"느린 수식 찾기\",\n        \"For currency columns\": \"통화 형식 열용\",\n        \"Coming soon\": \"출시 예정\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Engine (실험적 {{span}} 변경 시 책임은 본인에게 있음):\",\n        \"Locale:\": \"언어:\",\n        \"For number and date formats\": \"숫자 및 날짜 형식용\",\n        \"Save and Reload\": \"저장하고 새로고침\",\n        \"Manage webhooks\": \"웹훅 관리\",\n        \"Notify other services on doc changes\": \"문서 변경 시 다른 서비스에 알림\",\n        \"Python\": \"Python\",\n        \"Currency:\": \"통화:\",\n        \"Document settings\": \"문서 설정\",\n        \"Time Zone:\": \"시간대:\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"문서 ID가 클립보드에 복사되었습니다\",\n        \"Ok\": \"확인\",\n        \"Manage Webhooks\": \"웹훅 관리\",\n        \"Webhooks\": \"웹훅\",\n        \"API console\": \"API 콘솔\",\n        \"API URL copied to clipboard\": \"API URL이 클립보드에 복사되었습니다\",\n        \"API documentation.\": \"API 문서.\",\n        \"Hard reset of data engine\": \"데이터 엔진 강제 초기화\",\n        \"Python version used\": \"사용된 Python 버전\",\n        \"Reload\": \"새로고침\",\n        \"Time zone\": \"시간대\",\n        \"python2 (legacy)\": \"python2 (레거시)\",\n        \"python3 (recommended)\": \"python3 (권장)\",\n        \"Cancel\": \"취소\",\n        \"Reload data engine\": \"데이터 엔진 새로고침\",\n        \"Start timing\": \"측정 시작\",\n        \"Stop timing...\": \"측정 중지...\",\n        \"Time reload\": \"새로고침 시간 측정\",\n        \"Timing is on\": \"측정 중\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"문서를 변경한 후 측정을 중지하여 결과를 볼 수 있습니다.\",\n        \"Only available to document editors\": \"문서 편집자만 사용 가능\",\n        \"Only available to document owners\": \"문서 소유자만 사용 가능\",\n        \"Template mode\": \"템플릿 모드\",\n        \"Change document type\": \"문서 유형 변경\",\n        \"Edit\": \"편집\",\n        \"Change nature of document\": \"문서 성격 변경\",\n        \"Regular document\": \"일반 문서\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"일반적인 문서 동작입니다. 모든 사용자가 동일한 문서 사본으로 작업합니다.\",\n        \"Regular\": \"일반\",\n        \"Template\": \"템플릿\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"문서가 자동으로 {{fiddleModeDocUrl}}에서 열립니다. 누구나 편집할 수 있으며, 편집 시 저장되지 않은 새 사본이 생성됩니다.\",\n        \"fiddle mode\": \"탐색 모드\",\n        \"Tutorial\": \"튜토리얼\",\n        \"Document automatically opens as a user-specific copy.\": \"문서가 사용자별 사본으로 자동 열립니다.\",\n        \"Confirm change\": \"변경 확인\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"데이터 엔진을 강제로 새로고침합니다. 데이터 엔진이 무한 루프에 빠지거나, 마지막 변경 사항을 무한정 처리하거나, 충돌한 경우 도움이 될 수 있습니다. 현재 보류 중인 작업을 제외하고는 데이터 손실이 없습니다.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"측정을 시작하면 Grist가 각 수식을 평가하는 데 걸리는 시간을 측정합니다. 이를 통해 문서를 처음 열거나 변경에 응답할 때 성능 저하를 유발하는 수식을 진단할 수 있습니다.\",\n        \"**Some existing attachments are still external**.\": \"**일부 기존 첨부 파일이 여전히 외부에 있습니다**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**일부 기존 첨부 파일이 여전히 내부에 있습니다** (SQLite 파일에 저장됨).\",\n        \"Attachment storage\": \"첨부 파일 저장소\",\n        \"Being transfer\": \"전송 중\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"외부 저장소로 전송하려면 \\\"전송 시작\\\"을 클릭하세요.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"내부 저장소(문서 SQLite 파일에 저장)로 전송하려면 \\\"전송 시작\\\"을 클릭하세요.\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"새로 업로드된 첨부 파일은 외부 저장소에 저장됩니다.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"새로 업로드된 첨부 파일은 내부 저장소에 저장됩니다.\",\n        \"No external stores available\": \"사용 가능한 외부 저장소 없음\",\n        \"Preferred storage for this document\": \"이 문서의 기본 저장소\",\n        \"Start transfer\": \"전송 시작\",\n        \"External\": \"외부\",\n        \"Internal\": \"내부\",\n        \"Transfer in progress\": \"전송 진행 중\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**일부 기존 첨부 파일이 여전히 [외부]({{externalLink}})에 있습니다**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**일부 기존 첨부 파일이 여전히 [내부]({{internalLink}})(SQLite 파일에 저장됨)에 있습니다**.\",\n        \"[Learn more.]({{learnLink}})\": \"[자세히 알아보기.]({{learnLink}})\",\n        \"Reload data engine?\": \"데이터 엔진을 다시 불러오시겠습니까?\",\n        \"Formula times\": \"수식 계산 시점\",\n        \"Locale\": \"언어\",\n        \"Formula timer\": \"수식 타이머\",\n        \"This document's ID (for API use):\": \"이 문서 ID (API용):\",\n        \"ID for API use\": \"API 사용 ID\",\n        \"Try API calls from the browser\": \"브라우저에서 API를 호출하세요\",\n        \"Force reload the document while timing formulas, and show the result.\": \"수식 계산 시간을 측정하는 동안 문서를 강제로 다시 불러오고, 결과를 표시합니다.\",\n        \"Uploading...\": \"업로드 중...\",\n        \"Upload missing attachments\": \"누락 첨부를 업로드\",\n        \"Upload\": \"업로드\"\n    },\n    \"DocumentUsage\": {\n        \"Rows\": \"행\",\n        \"Size of attachments\": \"첨부 파일 크기\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"제한을 높이려면 사이트 소유자에게 연락하여 요금제를 업그레이드하세요.\",\n        \"Data size\": \"데이터 크기\",\n        \"For higher limits, \": \"더 높은 제한을 원하시면, \",\n        \"Usage\": \"사용량\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"사용량 통계는 문서 데이터에 대한 전체 접근 권한이 있는 사용자만 사용할 수 있습니다.\",\n        \"start your 30-day free trial of the Pro plan.\": \"Pro 요금제 30일 무료 체험을 시작하세요.\"\n    },\n    \"GridViewMenus\": {\n        \"Rename column\": \"열 이름 변경\",\n        \"Reset {{count}} columns_one\": \"열 초기화\",\n        \"Reset {{count}} columns_other\": \"열 {{count}}개 초기화\",\n        \"Reset {{count}} entire columns_one\": \"전체 열 초기화\",\n        \"Reset {{count}} entire columns_other\": \"전체 열 {{count}}개 초기화\",\n        \"Show column {{- label}}\": \"{{- label}} 열 표시\",\n        \"Add column\": \"열 추가\",\n        \"Add to sort\": \"정렬에 추가\",\n        \"Clear values\": \"값 지우기\",\n        \"Column Options\": \"열 옵션\",\n        \"Lookups\": \"조회\",\n        \"Convert formula to data\": \"수식을 데이터로 변환\",\n        \"Delete {{count}} columns_one\": \"열 삭제\",\n        \"Delete {{count}} columns_other\": \"열 {{count}}개 삭제\",\n        \"Filter Data\": \"데이터 필터링\",\n        \"Freeze {{count}} columns_one\": \"이 열 고정\",\n        \"Freeze {{count}} columns_other\": \"열 {{count}}개 고정\",\n        \"Freeze {{count}} more columns_one\": \"열 1개 더 고정\",\n        \"Freeze {{count}} more columns_other\": \"열 {{count}}개 더 고정\",\n        \"Hide {{count}} columns_one\": \"열 숨기기\",\n        \"Hide {{count}} columns_other\": \"열 {{count}}개 숨기기\",\n        \"Insert column to the {{to}}\": \"{{to}}에 열 삽입\",\n        \"More sort options ...\": \"더 많은 정렬 옵션…\",\n        \"Sort\": \"정렬\",\n        \"Sorted (#{{count}})_one\": \"정렬됨 (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"정렬됨 (#{{count}})\",\n        \"Unfreeze all columns\": \"모든 열 고정 해제\",\n        \"Unfreeze {{count}} columns_one\": \"이 열 고정 해제\",\n        \"Unfreeze {{count}} columns_other\": \"열 {{count}}개 고정 해제\",\n        \"Insert column to the left\": \"왼쪽에 열 삽입\",\n        \"Insert column to the right\": \"오른쪽에 열 삽입\",\n        \"Apply on record changes\": \"레코드 변경 시 적용\",\n        \"Apply to new records\": \"새 레코드에 적용\",\n        \"Authorship\": \"작성자 정보\",\n        \"Created At\": \"생성 시각\",\n        \"Created By\": \"생성자\",\n        \"Hidden Columns\": \"숨겨진 열\",\n        \"Last Updated At\": \"마지막 업데이트 시각\",\n        \"Last Updated By\": \"마지막 업데이트 사용자\",\n        \"Shortcuts\": \"바로가기\",\n        \"Show hidden columns\": \"숨겨진 열 표시\",\n        \"Timestamp\": \"타임스탬프\",\n        \"no reference column\": \"참조 열 없음\",\n        \"Adding UUID column\": \"UUID 열 추가 중\",\n        \"Adding duplicates column\": \"중복 항목 열 추가 중\",\n        \"Detect Duplicates in...\": \"다음에서 중복 항목 감지...\",\n        \"Duplicate in {{- label}}\": \"{{- label}}에서 중복\",\n        \"No reference columns.\": \"참조 열 없음.\",\n        \"Search columns\": \"열 검색\",\n        \"UUID\": \"UUID\",\n        \"Add column with type\": \"다음 유형의 열 추가\",\n        \"Add formula column\": \"수식 열 추가\",\n        \"Created at\": \"생성 시각\",\n        \"Created by\": \"생성자\",\n        \"Detect duplicates in...\": \"다음에서 중복 항목 감지...\",\n        \"Last updated at\": \"마지막 업데이트 시각\",\n        \"Last updated by\": \"마지막 업데이트 사용자\",\n        \"Any\": \"Any (모든 유형)\",\n        \"Numeric\": \"Numeric (숫자)\",\n        \"Text\": \"Text (텍스트)\",\n        \"Integer\": \"Integer (정수)\",\n        \"Toggle\": \"Toggle (참/거짓)\",\n        \"Date\": \"Date (날짜)\",\n        \"DateTime\": \"DateTime (날짜/시간)\",\n        \"Choice\": \"Choice (선택)\",\n        \"Choice List\": \"Choice List (다중 선택)\",\n        \"Reference\": \"Reference (참조)\",\n        \"Reference List\": \"Reference List (다중 참조)\",\n        \"Attachment\": \"Attachment (첨부 파일)\"\n    },\n    \"HomeIntro\": {\n        \"Any documents created in this site will appear here.\": \"이 사이트에서 생성된 모든 문서가 여기에 표시됩니다.\",\n        \"Help Center\": \"도움말 센터\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"팀 외부에서 Grist를 사용하고 싶으신가요? 무료 \",\n        \"Invite Team Members\": \"팀 멤버 초대\",\n        \"Sign up\": \"가입하기\",\n        \"Sprouts Program\": \"Sprouts 프로그램\",\n        \"Visit our {{link}} to learn more.\": \"자세한 내용은 {{link}}를 방문하세요.\",\n        \"Welcome to Grist!\": \"Grist에 오신 것을 환영합니다!\",\n        \"Welcome to Grist, {{name}}!\": \"{{name}}님, Grist에 오신 것을 환영합니다!\",\n        \"Welcome to {{orgName}}\": \"{{orgName}}에 오신 것을 환영합니다\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"이 사이트에 대한 읽기 전용 접근 권한이 있습니다. 현재 문서가 없습니다.\",\n        \"personal site\": \"개인 사이트\",\n        \"Browse Templates\": \"템플릿 둘러보기\",\n        \"Create empty document\": \"빈 문서 만들기\",\n        \"Get started by creating your first Grist document.\": \"첫 Grist 문서를 만들어 시작하세요.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"템플릿을 둘러보거나 첫 Grist 문서를 만들어 시작하세요.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"팀을 초대하고 첫 Grist 문서를 만들어 시작하세요.\",\n        \"Import document\": \"문서 가져오기\",\n        \"This workspace is empty.\": \"이 워크스페이스는 비어 있습니다.\",\n        \"{{signUp}} to save your work. \": \"작업 내용을 저장하려면 {{signUp}}하세요. \",\n        \"Welcome to Grist, {{- name}}!\": \"{{- name}}님, Grist에 오신 것을 환영합니다!\",\n        \"Welcome to {{- orgName}}\": \"{{- orgName}}에 오신 것을 환영합니다\",\n        \"Sign in\": \"로그인\",\n        \"To use Grist, please either sign up or sign in.\": \"Grist를 사용하려면 가입하거나 로그인하세요.\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Grist에 대해 자세히 알아보려면 {{link}}를 방문하세요.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"{{helpCenterLink}}에서 자세히 알아보세요.\",\n        \"Only show documents\": \"문서만 표시\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"다음 필드와 일치하는 행 병합:\",\n        \"Select fields to match on\": \"일치시킬 필드 선택\",\n        \"Update existing records\": \"기존 레코드 업데이트\",\n        \"{{count}} unmatched field in import_one\": \"가져오기에 일치하지 않는 필드 {{count}}개\",\n        \"{{count}} unmatched field in import_other\": \"가져오기에 일치하지 않는 필드 {{count}}개\",\n        \"{{count}} unmatched field_one\": \"일치하지 않는 필드 {{count}}개\",\n        \"{{count}} unmatched field_other\": \"일치하지 않는 필드 {{count}}개\",\n        \"Column Mapping\": \"열 매핑\",\n        \"Column mapping\": \"열 매핑\",\n        \"Destination table\": \"대상 표\",\n        \"Grist column\": \"Grist 열\",\n        \"Import from file\": \"파일에서 가져오기\",\n        \"New Table\": \"새 표\",\n        \"Revert\": \"되돌리기\",\n        \"Skip\": \"건너뛰기\",\n        \"Skip Import\": \"가져오기 건너뛰기\",\n        \"Skip Table on Import\": \"가져오기 시 표 건너뛰기\",\n        \"Source column\": \"원본 열\"\n    },\n    \"NotifyUI\": {\n        \"Report a problem\": \"문제 신고\",\n        \"Upgrade Plan\": \"요금제 업그레이드\",\n        \"Manage billing\": \"청구 관리\",\n        \"Ask for help\": \"도움 요청하기\",\n        \"Cannot find personal site, sorry!\": \"죄송합니다. 개인 사이트를 찾을 수 없습니다!\",\n        \"Give feedback\": \"피드백 제공\",\n        \"Go to your free personal site\": \"무료 개인 사이트로 이동\",\n        \"No notifications\": \"알림 없음\",\n        \"Notifications\": \"알림\",\n        \"Renew\": \"갱신\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"완료\",\n        \"Next\": \"다음\",\n        \"Previous\": \"이전\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Download as CSV\": \"CSV로 다운로드\",\n        \"Advanced sort & filter\": \"고급 정렬 및 필터\",\n        \"Copy anchor link\": \"바로가기 링크 복사\",\n        \"Data selection\": \"데이터 선택\",\n        \"Delete record\": \"레코드 삭제\",\n        \"Delete widget\": \"위젯 삭제\",\n        \"Download as XLSX\": \"XLSX로 다운로드\",\n        \"Edit card layout\": \"카드 레이아웃 편집\",\n        \"Open configuration\": \"구성 열기\",\n        \"Print widget\": \"위젯 인쇄\",\n        \"Show raw data\": \"원본 데이터 보기\",\n        \"Widget options\": \"위젯 옵션\",\n        \"Add to page\": \"페이지에 추가\",\n        \"Collapse widget\": \"위젯 접기\",\n        \"Create a form\": \"양식 만들기\"\n    },\n    \"ViewSectionMenu\": {\n        \"FILTER\": \"필터\",\n        \"Revert\": \"되돌리기\",\n        \"SORT\": \"정렬\",\n        \"Save\": \"저장\",\n        \"Update Sort&Filter settings\": \"정렬 및 필터 설정 업데이트\",\n        \"(customized)\": \"(사용자 정의됨)\",\n        \"(empty)\": \"(비어 있음)\",\n        \"(modified)\": \"(수정됨)\",\n        \"Custom options\": \"사용자 정의 옵션\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"숨겨진 필드에는 항목을 놓을 수 없습니다\",\n        \"Clear\": \"모두 해제\",\n        \"Hidden Fields cannot be reordered\": \"숨겨진 필드는 순서를 변경할 수 없습니다\",\n        \"Select all\": \"모두 선택\",\n        \"Visible {{label}}\": \"{{label}} 표시됨\",\n        \"Hide {{label}}\": \"{{label}} 숨기기\",\n        \"Hidden {{label}}\": \"{{label}} 숨겨짐\",\n        \"Show {{label}}\": \"{{label}} 표시\"\n    },\n    \"WelcomeQuestions\": {\n        \"Welcome to Grist!\": \"Grist에 오신 것을 환영합니다!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Grist를 사용하게 된 계기는 무엇인가요? 더 나은 서비스를 제공할 수 있도록 도와주세요.\",\n        \"Other\": \"기타\",\n        \"Product Development\": \"제품 개발\",\n        \"Education\": \"교육\",\n        \"Finance & Accounting\": \"재무 및 회계\",\n        \"HR & Management\": \"인사 및 관리\",\n        \"IT & Technology\": \"IT 및 기술\",\n        \"Marketing\": \"마케팅\",\n        \"Media Production\": \"미디어 제작\",\n        \"Research\": \"연구\",\n        \"Sales\": \"영업\",\n        \"Type here\": \"여기에 입력하세요\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"취소\",\n        \"DATA TABLE NAME\": \"데이터 표 이름\",\n        \"Override widget title\": \"위젯 제목 재정의\",\n        \"Provide a table name\": \"표 이름 입력\",\n        \"Save\": \"저장\",\n        \"WIDGET TITLE\": \"위젯 제목\",\n        \"WIDGET DESCRIPTION\": \"위젯 설명\"\n    },\n    \"errorPages\": {\n        \"Sign in\": \"로그인\",\n        \"Sign in to access this organization's documents.\": \"이 조직의 문서에 접근하려면 로그인하세요.\",\n        \"Signed out{{suffix}}\": \"로그아웃됨{{suffix}}\",\n        \"Something went wrong\": \"문제가 발생했습니다\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"요청한 페이지를 찾을 수 없습니다.{{separator}}URL을 확인하고 다시 시도하세요.\",\n        \"Access denied{{suffix}}\": \"접근 거부됨{{suffix}}\",\n        \"Add account\": \"계정 추가\",\n        \"Contact support\": \"지원팀 문의\",\n        \"Error{{suffix}}\": \"오류{{suffix}}\",\n        \"Go to main page\": \"메인 페이지로 이동\",\n        \"Page not found{{suffix}}\": \"페이지를 찾을 수 없음{{suffix}}\",\n        \"Sign in again\": \"다시 로그인\",\n        \"There was an error: {{message}}\": \"오류가 발생했습니다: {{message}}\",\n        \"There was an unknown error.\": \"알 수 없는 오류가 발생했습니다.\",\n        \"You are now signed out.\": \"로그아웃되었습니다.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"현재 {{email}}(으)로 로그인되어 있습니다. 다른 계정으로 로그인하거나 관리자에게 접근 권한을 요청할 수 있습니다.\",\n        \"You do not have access to this organization's documents.\": \"이 조직의 문서에 접근할 권한이 없습니다.\",\n        \"Account deleted{{suffix}}\": \"계정 삭제됨{{suffix}}\",\n        \"Sign up\": \"가입하기\",\n        \"Your account has been deleted.\": \"계정이 삭제되었습니다.\",\n        \"An unknown error occurred.\": \"알 수 없는 오류가 발생했습니다.\",\n        \"Build your own form\": \"나만의 양식 만들기\",\n        \"Form not found\": \"양식을 찾을 수 없음\",\n        \"Powered by\": \"제공:\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"로그인에 실패했습니다.{{separator}}다시 시도하거나 지원팀에 문의하세요.\",\n        \"Sign-in failed{{suffix}}\": \"로그인 실패{{suffix}}\"\n    },\n    \"menus\": {\n        \"Attachment\": \"Attachment (첨부 파일)\",\n        \"Search columns\": \"열 검색\",\n        \"By Name\": \"이름순\",\n        \"By Date Modified\": \"수정 날짜순\",\n        \"Light\": \"라이트\",\n        \"Custom\": \"사용자 정의\",\n        \"* Workspaces are available on team plans. \": \"* 워크스페이스는 팀 요금제에서 사용할 수 있습니다. \",\n        \"Select fields\": \"필드 선택\",\n        \"Upgrade now\": \"지금 업그레이드\",\n        \"Any\": \"Any (모든 유형)\",\n        \"Numeric\": \"Numeric (숫자)\",\n        \"Text\": \"Text (텍스트)\",\n        \"Integer\": \"Integer (정수)\",\n        \"Toggle\": \"Toggle (참/거짓)\",\n        \"Date\": \"Date (날짜)\",\n        \"DateTime\": \"DateTime (날짜/시간)\",\n        \"Choice\": \"Choice (선택)\",\n        \"Choice List\": \"Choice List (다중 선택)\",\n        \"Reference\": \"Reference (참조)\",\n        \"Reference List\": \"Reference List (다중 참조)\"\n    },\n    \"modals\": {\n        \"Cancel\": \"취소\",\n        \"Ok\": \"확인\",\n        \"Save\": \"저장\",\n        \"Are you sure you want to delete these records?\": \"이 레코드들을 삭제하시겠습니까?\",\n        \"Are you sure you want to delete this record?\": \"이 레코드를 삭제하시겠습니까?\",\n        \"Delete\": \"삭제\",\n        \"Dismiss\": \"닫기\",\n        \"Don't ask again.\": \"다시 묻지 않기.\",\n        \"Don't show again.\": \"다시 보지 않기.\",\n        \"Don't show tips\": \"팁 보지 않기\",\n        \"Undo to restore\": \"실행 취소하여 복원\",\n        \"Got it\": \"알겠습니다\",\n        \"Don't show again\": \"다시 보지 않기\",\n        \"TIP\": \"팁\",\n        \"Confirm\": \"확인\"\n    },\n    \"DiscussionEditor\": {\n        \"Open\": \"열기\",\n        \"Write a comment\": \"댓글 작성\",\n        \"Cancel\": \"취소\",\n        \"Comment\": \"댓글\",\n        \"Edit\": \"편집\",\n        \"Marked as resolved\": \"해결됨으로 표시\",\n        \"Only current page\": \"현재 페이지만\",\n        \"Only my threads\": \"내 스레드만\",\n        \"Remove\": \"제거\",\n        \"Reply\": \"답글\",\n        \"Reply to a comment\": \"댓글에 답글 달기\",\n        \"Resolve\": \"해결\",\n        \"Save\": \"저장\",\n        \"Show resolved comments\": \"해결된 댓글 표시\",\n        \"Showing last {{nb}} comments\": \"마지막 댓글 {{nb}}개 표시 중\",\n        \"Started discussion\": \"토론 시작됨\",\n        \"Remove thread\": \"댓글 스레드 삭제\",\n        \"updated\": \"갱신됨\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"열을 수식으로 변환\"\n    },\n    \"FieldBuilder\": {\n        \"Apply formula to data\": \"수식을 데이터에 적용\",\n        \"CELL FORMAT\": \"셀 형식\",\n        \"DATA FROM TABLE\": \"표에서 불러 온 데이터\",\n        \"Revert field settings for {{colId}} to common\": \"{{colId}}의 필드 설정을 공통으로 되돌리기\",\n        \"Changing column type\": \"열 유형 변경 중\",\n        \"Changing multiple column types\": \"여러 열 유형 변경 중\",\n        \"Mixed format\": \"혼합된 형식\",\n        \"Mixed types\": \"혼합된 유형\",\n        \"Save field settings for {{colId}} as common\": \"{{colId}}의 필드 설정을 공통으로 저장\",\n        \"Use separate field settings for {{colId}}\": \"{{colId}}에 개별 필드 설정 사용\"\n    },\n    \"WelcomeTour\": {\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"날짜, 선택 항목, 첨부 파일과 같은 서식 옵션, 수식 또는 열 유형을 설정하세요. \",\n        \"Share\": \"공유\",\n        \"Sharing\": \"공유\",\n        \"Add new\": \"새로 추가\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"{{templateLibrary}}에서 가능한 것을 발견하고 영감을 얻으세요.\",\n        \"Building up\": \"구성하기\",\n        \"Configuring your document\": \"문서 구성하기\",\n        \"Customizing columns\": \"열 사용자 정의하기\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"셀을 더블 클릭하거나 {{enter}} 키를 눌러 편집하세요. \",\n        \"Editing Data\": \"데이터 편집\",\n        \"Enter\": \"Enter\",\n        \"Flying higher\": \"더 높이 날아오르기\",\n        \"Help Center\": \"도움말 센터\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"관계형으로 만드세요! {{ref}} 유형을 사용하여 표를 연결하세요. \",\n        \"Reference\": \"참조\",\n        \"Start with {{equal}} to enter a formula.\": \"{{equal}}로 시작하여 수식을 입력하세요.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"{{creatorPanel}}을(를) 전환하여 열 서식을 지정하고, \",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"공유 버튼({{share}})을 사용하여 문서를 공유하거나 데이터를 내보내세요.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"{{addNew}}을(를) 사용하여 위젯, 페이지를 추가하거나 더 많은 데이터를 가져오세요. \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"문서나 질문은 {{helpCenter}}을(를) 이용하세요.\",\n        \"Welcome to Grist!\": \"Grist에 오신 것을 환영합니다!\",\n        \"convert to card view, select data, and more.\": \"카드 보기로 변환하고, 데이터를 선택하는 등의 작업을 하세요.\",\n        \"creator panel\": \"생성기 패널\",\n        \"template library\": \"템플릿 라이브러리\"\n    },\n    \"GristTooltips\": {\n        \"entire\": \"전체\",\n        \"Add new\": \"새로 추가\",\n        \"Calendar\": \"캘린더\",\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID는 고유 식별자 및 연결 키에 유용한 무작위로 생성된 문자열입니다.\",\n        \"Forms are here!\": \"양식 기능 출시!\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"이 제한은 양방향 참조의 한쪽 끝이 단일 참조(Reference)로 구성된 경우 발생합니다.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"다중 할당을 허용하려면 참조(Reference) 열의 유형을 참조 목록(Reference List)으로 변경하세요.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"이 제한은 양방향 참조의 한 열이 참조(Reference) 유형을 가질 때 발생합니다.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"다중 할당을 허용하려면 참조된 열의 유형을 참조 목록(Reference List)으로 변경하세요.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"양방향 참조는 현재 수식 또는 트리거 수식 열에 대해 지원되지 않습니다\",\n        \"The preview below this header shows how the selected user will see this document\": \"이 헤더 아래의 미리보기는 선택한 사용자가 이 문서를 어떻게 보게 될지 보여줍니다\",\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"수식 조건이 충족되면 이 열의 셀에 조건부 서식을 적용합니다.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"수식을 기반으로 행에 조건부 서식을 적용합니다.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"참조 열의 셀은 항상 해당 표의 {{entire}} 레코드를 식별하지만, 해당 레코드에서 표시할 열을 선택할 수 있습니다.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"행에 조건부 서식을 적용하려면 \\\"행 스타일 열기\\\"를 클릭하세요.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"새 문서나 워크스페이스를 만들거나 데이터를 가져오려면 새로 추가 버튼을 클릭하세요.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"각 셀에서 {{EyeHideIcon}} 아이콘을 클릭하면 필드를 삭제하지 않고 이 보기에서 숨깁니다.\",\n        \"Editing Card Layout\": \"카드 레이아웃 편집\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"특정 경우에 트리거되고 계산된 값을 데이터로 저장하는 수식입니다.\",\n        \"Learn more.\": \"자세히 알아보기.\",\n        \"Link your new widget to an existing widget on this page.\": \"새 위젯을 이 페이지의 기존 위젯에 연결합니다.\",\n        \"Linking Widgets\": \"위젯 연결하기\",\n        \"Nested Filtering\": \"중첩 필터링\",\n        \"Only those rows will appear which match all of the filters.\": \"모든 필터와 일치하는 행만 표시됩니다.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"고정된 필터는 위젯 위에 버튼으로 표시됩니다.\",\n        \"Pinning Filters\": \"필터 고정하기\",\n        \"Raw Data page\": \"원본 데이터 페이지\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"셀을 드래그하고 크기를 조절하여 카드의 필드를 재정렬합니다.\",\n        \"Reference Columns\": \"참조 열\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"참조 열은 Grist에서 {{relational}} 데이터를 다루는 핵심입니다.\",\n        \"Select the table containing the data to show.\": \"표시할 데이터가 포함된 표를 선택합니다.\",\n        \"Select the table to link to.\": \"연결할 표를 선택합니다.\",\n        \"Selecting Data\": \"데이터 선택하기\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"원본 데이터 페이지에는 요약 표 및 페이지 레이아웃에 포함되지 않은 표를 포함하여 문서의 모든 데이터 표가 나열됩니다.\",\n        \"The total size of all data in this document, excluding attachments.\": \"첨부 파일을 제외한 이 문서의 모든 데이터 총 크기입니다.\",\n        \"They allow for one record to point (or refer) to another.\": \"하나의 레코드가 다른 레코드를 가리키거나 참조할 수 있게 합니다.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"이것이 Grist의 동적이고 생산적인 레이아웃의 비결입니다.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"사본에서 변경 사항을 시도해 본 다음, 원본을 편집 내용으로 교체할지 결정하세요.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"필터는 유지하면서 버튼을 숨기려면 고정을 해제하세요.\",\n        \"Updates every 5 minutes.\": \"5분마다 업데이트됩니다.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"𝚺 아이콘을 사용하여 합계 또는 소계를 위한 요약(또는 피벗) 표를 만듭니다.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"새 레코드의 타임스탬프나 작성자를 저장하고, 데이터를 정리하는 등에 유용합니다.\",\n        \"You can filter by more than one column.\": \"둘 이상의 열로 필터링할 수 있습니다.\",\n        \"relational\": \"관계형\",\n        \"Access Rules\": \"접근 규칙\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"캘린더를 구성하려면 시작/종료 날짜 및 이벤트 제목에 대한 열을 선택하세요. 각 열의 유형을 확인하세요.\"\n        },\n        \"Lookups return data from related tables.\": \"조회는 관련된 표의 데이터를 반환합니다.\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"접근 규칙을 사용하면 문서의 어느 부분을 누가 보거나 편집할 수 있는지 결정하는 세분화된 규칙을 만들 수 있습니다.\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"𝚺 아이콘을 사용하여 합계 또는 소계를 위한 요약(또는 피벗) 표를 만듭니다.\",\n        \"Anchor Links\": \"바로가기 링크\",\n        \"Custom Widgets\": \"사용자 정의 위젯\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"사용자를 특정 셀로 이동시키는 바로가기 링크를 만들려면 행을 클릭하고 {{shortcut}} 키를 누릅니다.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"미리 만들어진 위젯 중 하나를 선택하거나 전체 URL을 제공하여 자신만의 위젯을 포함할 수 있습니다.\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"올바른 열을 찾을 수 없나요? '위젯 변경'을 클릭하여 이벤트 데이터가 있는 표를 선택하세요.\",\n        \"Learn more\": \"자세히 알아보기\",\n        \"Use reference columns to relate data in different tables.\": \"참조 열을 사용하여 다른 표의 데이터를 연결합니다.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"드롭다운에서 사용 가능한 위젯 중에서 선택하거나, 전체 URL을 제공하여 자신만의 위젯을 포함할 수 있습니다.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"수식은 많은 Excel 함수, 완전한 Python 구문 및 유용한 AI 어시스턴트를 지원합니다.\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Grist에서 바로 간단한 양식을 만들고 새로운 위젯으로 클릭 한 번으로 공유하세요. {{learnMoreButton}}\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"이 규칙들은 해당되는 경우 모든 열 규칙이 처리된 후에 적용됩니다.\",\n        \"Example: {{example}}\": \"예시: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"조건을 사용하여 표시되는 드롭다운 값을 필터링합니다.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"커뮤니티 위젯은 Grist 커뮤니티 회원들이 만들고 관리합니다.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"대상 표에 역방향 열을 만들어 양측에서 편집할 수 있도록 합니다.\",\n        \"[Learn more.]({{link}})\": \"[자세히 알아보기.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"요약 표에는 수식 열만 포함될 수 있습니다.\",\n        \"The new Grist Assistant is here!\": \"새로운 Grist 도우미가 도착했습니다!\",\n        \"Manage users and resources in a Grist installation.\": \"Grist 설치 환경에서 사용자와 리소스를 관리합니다.\"\n    },\n    \"UserManager\": {\n        \"Invite people to {{resourceType}}\": \"{{resourceType}}에 사용자 초대\",\n        \"guest\": \"게스트\",\n        \"Inherit access: \": \"접근 상속: \",\n        \"Anyone with link \": \"링크가 있는 누구나 \",\n        \"Cancel\": \"취소\",\n        \"Close\": \"닫기\",\n        \"Collaborator\": \"협업자\",\n        \"Confirm\": \"확인\",\n        \"Copy link\": \"링크 복사\",\n        \"Create a team to share with more people\": \"더 많은 사람들과 공유하려면 팀을 만드세요\",\n        \"Grist support\": \"Grist 지원\",\n        \"Guest\": \"게스트\",\n        \"Remove my access\": \"내 접근 권한 제거\",\n        \"Save & \": \"저장하고 \",\n        \"Team member\": \"팀 멤버\",\n        \"User may not modify their own access.\": \"사용자는 자신의 접근 권한을 수정할 수 없습니다.\",\n        \"Add {{member}} to your team\": \"팀에 {{member}} 추가\",\n        \"Allow anyone with the link to open.\": \"링크가 있는 모든 사람이 열 수 있도록 허용합니다.\",\n        \"Invite multiple\": \"여러 명 초대\",\n        \"Link copied to clipboard\": \"링크가 클립보드에 복사되었습니다\",\n        \"Manage members of team site\": \"팀 사이트 멤버 관리\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"기본 접근 권한 없음은 전체 팀 사이트 대신 개별 문서나 워크스페이스에 접근 권한을 부여할 수 있게 합니다.\",\n        \"Off\": \"끄기\",\n        \"On\": \"켜기\",\n        \"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}}.\": \"자신의 접근 권한을 제거하면, {{name}}에 충분한 접근 권한이 있는 다른 사람의 도움 없이는 다시 접근 권한을 얻을 수 없습니다.\",\n        \"Open Access Rules\": \"접근 규칙 열기\",\n        \"Outside collaborator\": \"외부 협업자\",\n        \"Public access\": \"공개 접근\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"{{parent}}에서 상속된 공개 접근입니다. 제거하려면 '접근 상속' 옵션을 '없음'으로 설정하세요.\",\n        \"Public access: \": \"공개 접근: \",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"사용자는 {{parent})}에서 권한을 상속받습니다. 제거하려면 '접근 상속' 옵션을 '없음'으로 설정하세요.\",\n        \"Your role for this team site\": \"이 팀 사이트에서의 역할\",\n        \"Your role for this {{resourceType}}\": \"이 {{resourceType}}에서의 역할\",\n        \"free collaborator\": \"무료 협업자\",\n        \"member\": \"멤버\",\n        \"team site\": \"팀 사이트\",\n        \"{{collaborator}} limit exceeded\": \"{{collaborator}} 한도 초과\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitTop}}명의 {{collaborator}} 중 {{limitAt}}명\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"기본 접근 권한 없음은 전체 팀 사이트 대신 개별 문서나 워크스페이스에 접근 권한을 부여할 수 있게 합니다.\",\n        \"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}}에 충분한 접근 권한이 있는 다른 사람의 도움 없이는 다시 접근 권한을 얻을 수 없습니다.\",\n        \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\": \"사용자는 내부에 있는 리소스에 대한 수동 설정된 접근 권한으로 인해 {{resource}}에 대한 보기 접근 권한을 갖습니다. 여기서 제거하면 이 사용자는 내부 리소스에 대한 접근 권한을 잃게 됩니다.\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"사용자는 {{parent}}에서 권한을 상속받습니다. 제거하려면 '접근 상속' 옵션을 '없음'으로 설정하세요.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"이 {{resourceType}}에 대한 자신의 접근 권한을 제거하려고 합니다\",\n        \"Access overview\": \"접근 권한 개요\"\n    },\n    \"SupportGristPage\": {\n        \"Sponsor\": \"후원\",\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"GitHub Sponsors 페이지\",\n        \"Help Center\": \"도움말 센터\",\n        \"Home\": \"홈\",\n        \"Manage Sponsorship\": \"후원 관리\",\n        \"Opt in to Telemetry\": \"텔레메트리 참여\",\n        \"Opt out of Telemetry\": \"텔레메트리 참여 중단\",\n        \"Sponsor Grist Labs on GitHub\": \"GitHub에서 Grist Labs 후원\",\n        \"Support Grist\": \"Grist 후원\",\n        \"Telemetry\": \"텔레메트리\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"이 인스턴스는 텔레메트리에 참여하고 있습니다. 사이트 관리자만 이 설정을 변경할 권한이 있습니다.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"이 인스턴스는 텔레메트리 참여를 중단했습니다. 사이트 관리자만 이 설정을 변경할 권한이 있습니다.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"{{link}}에 상세히 설명된 대로 사용 통계만 수집하며, 문서 내용은 절대 수집하지 않습니다.\",\n        \"You can opt out of telemetry at any time from this page.\": \"언제든지 이 페이지에서 텔레메트리 참여를 중단할 수 있습니다.\",\n        \"You have opted in to telemetry. Thank you!\": \"텔레메트리에 참여해주셨습니다. 감사합니다!\",\n        \"You have opted out of telemetry.\": \"텔레메트리 참여를 중단하셨습니다.\"\n    },\n    \"FormView\": {\n        \"Copy link\": \"링크 복사\",\n        \"Code copied to clipboard\": \"코드가 클립보드에 복사되었습니다\",\n        \"Copy code\": \"코드 복사\",\n        \"Publish\": \"게시\",\n        \"Publish your form?\": \"양식을 게시하시겠습니까?\",\n        \"Unpublish\": \"게시 취소\",\n        \"Unpublish your form?\": \"양식 게시를 취소하시겠습니까?\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"아래 링크가 있는 누구나 빈 양식을 보고 응답을 제출할 수 있습니다.\",\n        \"Are you sure you want to reset your form?\": \"양식을 초기화하시겠습니까?\",\n        \"Embed this form\": \"이 양식 포함하기\",\n        \"Link copied to clipboard\": \"링크가 클립보드에 복사되었습니다\",\n        \"Preview\": \"미리보기\",\n        \"Reset\": \"초기화\",\n        \"Reset form\": \"양식 초기화\",\n        \"Save your document to publish this form.\": \"이 양식을 게시하려면 문서를 저장하세요.\",\n        \"Share\": \"공유\",\n        \"Share this form\": \"이 양식 공유하기\",\n        \"View\": \"보기\",\n        \"## **Form Title**\": \"## **양식 제목**\",\n        \"Your form description goes here.\": \"여기에 양식 설명을 입력하세요.\",\n        \"# **Form Title**\": \"# **폼 제목**\"\n    },\n    \"FormConfig\": {\n        \"Horizontal\": \"가로\",\n        \"Options Alignment\": \"옵션 정렬\",\n        \"Options Sort Order\": \"옵션 정렬 순서\",\n        \"Radio\": \"라디오 버튼\",\n        \"Select\": \"선택\",\n        \"Vertical\": \"세로\",\n        \"Default\": \"기본\",\n        \"Descending\": \"내림차순\",\n        \"Field Format\": \"필드 형식\",\n        \"Field Rules\": \"필드 규칙\",\n        \"Field rules\": \"필드 규칙\",\n        \"Required field\": \"필수 필드\",\n        \"Ascending\": \"오름차순\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"일부 필수 열이 매핑되지 않았습니다\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"이 위젯을 사용하려면 오른쪽 생성기 패널에서 모든 필수 열을 매핑하세요.\",\n        \"Some required columns are hidden by access rules\": \"일부 필수 열이 접근 규칙에 의해 숨겨짐\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"이 위젯을 사용하려면 모든 매핑된 열이 표시되어야 합니다. 문서 소유자에게 문의하거나 접근 규칙을 수정하세요.\"\n    },\n    \"CreateTeamModal\": {\n        \"Domain name is invalid\": \"도메인 이름이 잘못되었습니다\",\n        \"Go to your site\": \"사이트로 이동\",\n        \"Create site\": \"사이트 만들기\",\n        \"Domain name is required\": \"도메인 이름은 필수입니다\",\n        \"Team name\": \"팀 이름\",\n        \"Cancel\": \"취소\",\n        \"Choose a name and url for your team site\": \"팀 사이트의 이름과 URL을 선택하세요\",\n        \"Team name is required\": \"팀 이름은 필수입니다\",\n        \"Team site created\": \"팀 사이트 생성됨\",\n        \"Team url\": \"팀 URL\",\n        \"Work as a Team\": \"팀으로 작업하기\",\n        \"Billing is not supported in grist-core\": \"grist-core에서는 청구가 지원되지 않습니다\"\n    },\n    \"AdminPanel\": {\n        \"Sandbox settings for data engine\": \"데이터 엔진용 샌드박스 설정\",\n        \"Current authentication method\": \"현재 인증 방식\",\n        \"Grist is up to date\": \"Grist가 최신 버전입니다\",\n        \"Grist releases are at \": \"Grist 릴리스는 다음에 있습니다: \",\n        \"Last checked {{time}}\": \"마지막 확인: {{time}}\",\n        \"Learn more.\": \"자세히 알아보기.\",\n        \"Newer version available\": \"새 버전 사용 가능\",\n        \"Checking for updates...\": \"업데이트 확인 중...\",\n        \"Admin Panel\": \"관리자 패널\",\n        \"Current\": \"현재\",\n        \"Current version of Grist\": \"현재 Grist 버전\",\n        \"Help us make Grist better\": \"Grist 개선에 도움을 주세요\",\n        \"Home\": \"홈\",\n        \"Sponsor\": \"후원\",\n        \"Support Grist\": \"Grist 후원\",\n        \"Support Grist Labs on GitHub\": \"GitHub에서 Grist Labs 후원\",\n        \"Telemetry\": \"텔레메트리\",\n        \"Version\": \"버전\",\n        \"Auto-check when this page loads\": \"이 페이지 로드 시 자동 확인\",\n        \"Check now\": \"지금 확인\",\n        \"Error\": \"오류\",\n        \"Error checking for updates\": \"업데이트 확인 오류\",\n        \"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는 Python을 사용하여 매우 강력한 수식을 허용합니다. 하드웨어가 지원하는 경우(대부분 지원) 환경 변수 GRIST_SANDBOX_FLAVOR를 gvisor로 설정하여 각 문서의 수식을 다른 문서 및 네트워크로부터 격리된 샌드박스 내에서 실행하는 것을 권장합니다.\",\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는 비밀 키로 사용자 세션 쿠키에 서명합니다. 환경 변수 GRIST_SESSION_SECRET를 통해 이 키를 설정하세요. 설정되지 않은 경우 Grist는 하드코딩된 기본값을 사용합니다. v1.1.16 이후 생성된 세션 ID는 암호학적으로 안전하게 업데이트되었으므로 향후 이 알림을 제거할 수 있습니다.\",\n        \"No information available\": \"사용 가능한 정보 없음\",\n        \"OK\": \"확인\",\n        \"Sandboxing\": \"샌드박싱\",\n        \"Security Settings\": \"보안 설정\",\n        \"Updates\": \"업데이트\",\n        \"unconfigured\": \"구성되지 않음\",\n        \"unknown\": \"알 수 없음\",\n        \"Administrator Panel Unavailable\": \"관리자 패널 사용 불가\",\n        \"Authentication\": \"인증\",\n        \"Check failed.\": \"확인 실패.\",\n        \"Check succeeded.\": \"확인 성공.\",\n        \"Details\": \"세부 정보\",\n        \"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가 네트워크를 통해 접근 가능하거나 여러 사람에게 제공되는 경우 이 중 하나를 활성화하는 것을 권장합니다.\",\n        \"No fault detected.\": \"결함 감지되지 않음.\",\n        \"Notes\": \"참고\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"또는 대체 방법으로 환경에서 {{bootKey}}를 설정하고 {{url}}을(를) 방문할 수 있습니다\",\n        \"Results\": \"결과\",\n        \"Self Checks\": \"자체 검사\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"관리자 패널에 접근할 권한이 없습니다.\\n관리자로 로그인하세요.\",\n        \"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가 네트워크를 통해 접근 가능하거나 여러 사람에게 제공되는 경우 이 중 하나를 활성화하는 것을 권장합니다.\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"Grist는 비밀 키로 사용자 세션 쿠키에 서명합니다. 환경 변수 GRIST_SESSION_SECRET를 통해 이 키를 설정하세요. 설정되지 않은 경우 Grist는 하드코딩된 기본값을 사용합니다. v1.1.16 이후 생성된 세션 ID는 암호학적으로 안전하게 업데이트되었으므로 향후 이 알림을 제거할 수 있습니다.\",\n        \"Key to sign sessions with\": \"세션 서명용 키\",\n        \"Session Secret\": \"세션 비밀 키\",\n        \"Enable Grist Enterprise\": \"Grist Enterprise 활성화\",\n        \"Enterprise\": \"엔터프라이즈\",\n        \"checking\": \"확인 중\",\n        \"Audit Logs\": \"감사 로그\",\n        \"Contact us\": \"문의하기\",\n        \"Log Streaming\": \"로그 스트리밍\",\n        \"New, Enterprise\": \"신규, 엔터프라이즈\",\n        \"Off\": \"끄기\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}}개 더보기\",\n        \"On\": \"켜기\",\n        \"Grist Instance\": \"Grist 인스턴스\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"체크박스\",\n        \"Field Format\": \"필드 형식\",\n        \"Switch\": \"스위치\"\n    },\n    \"ChoiceEditor\": {\n        \"Error in dropdown condition\": \"드롭다운 조건 오류\",\n        \"No choices matching condition\": \"조건에 맞는 선택 항목 없음\",\n        \"No choices to select\": \"선택할 항목 없음\"\n    },\n    \"DocTutorial\": {\n        \"Previous\": \"이전\",\n        \"Restart\": \"다시 시작\",\n        \"Click to expand\": \"클릭하여 확장\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"튜토리얼을 다시 시작하시겠습니까? 모든 진행 상황이 손실됩니다.\",\n        \"End tutorial\": \"튜토리얼 종료\",\n        \"Finish\": \"완료\",\n        \"Next\": \"다음\"\n    },\n    \"OnboardingCards\": {\n        \"3 minute video tour\": \"3분 비디오 둘러보기\",\n        \"Complete the tutorial\": \"튜토리얼 완료하기\",\n        \"Complete our basics tutorial\": \"기본 튜토리얼 완료하기\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"참조 열, 연결된 위젯, 열 유형, 카드의 기본 사항 배우기.\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"참조 열, 연결된 위젯, 열 유형, 카드의 기본 사항 배우기.\"\n    },\n    \"OnboardingPage\": {\n        \"Your organization\": \"소속 조직\",\n        \"Welcome\": \"환영합니다\",\n        \"Back\": \"뒤로\",\n        \"Discover Grist in 3 minutes\": \"3분 안에 Grist 알아보기\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Grist 기본 튜토리얼 실습하기\",\n        \"Go to the tutorial!\": \"튜토리얼로 이동!\",\n        \"Next step\": \"다음 단계\",\n        \"Skip step\": \"단계 건너뛰기\",\n        \"Skip tutorial\": \"튜토리얼 건너뛰기\",\n        \"Tell us who you are\": \"자신에 대해 알려주세요\",\n        \"Type here\": \"여기에 입력하세요\",\n        \"What brings you to Grist (you can select multiple)?\": \"Grist를 사용하게 된 계기는 무엇인가요 (여러 개 선택 가능)?\",\n        \"What is your role?\": \"당신의 역할은 무엇인가요?\",\n        \"What organization are you with?\": \"어떤 조직에 속해 있나요?\",\n        \"Your role\": \"역할\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"삭제\",\n        \"Delete data and this widget.\": \"데이터 및 이 위젯을 삭제합니다.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"데이터는 유지하고 위젯만 삭제합니다. 표는 {{rawDataLink}}에서 계속 사용할 수 있습니다\",\n        \"Table {{tableName}} will no longer be visible\": \"{{tableName}} 표는 더 이상 표시되지 않습니다\",\n        \"Raw Data page\": \"원본 데이터 페이지\"\n    },\n    \"HomeIntroCards\": {\n        \"Start a new document\": \"새 문서 시작하기\",\n        \"3 minute video tour\": \"3분 비디오 둘러보기\",\n        \"Blank document\": \"빈 문서\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"해결책을 찾고 더 많은 리소스를 탐색하세요 {{helpCenterLink}}\",\n        \"Finish our basics tutorial\": \"기본 튜토리얼 완료하기\",\n        \"Help center\": \"도움말 센터\",\n        \"Import file\": \"파일 가져오기\",\n        \"Learn more {{webinarsLinks}}\": \"자세히 알아보기 {{webinarsLinks}}\",\n        \"Templates\": \"템플릿\",\n        \"Tutorial\": \"튜토리얼\",\n        \"Webinars\": \"웹 세미나\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Delete\": \"삭제\",\n        \"Delete column {{column}} in table {{table}}?\": \"{{table}} 표의 {{column}} 열을 삭제하시겠습니까?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"이것은 {{table}} 표의 {{column}} 참조 열의 역방향입니다.\",\n        \"Table\": \"표\",\n        \"Two-way Reference\": \"양방향 참조\",\n        \"Delete two-way reference?\": \"양방향 참조를 삭제하시겠습니까?\",\n        \"Target table\": \"대상 표\",\n        \"Add two-way reference\": \"양방향 참조 추가\",\n        \"Column\": \"열\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"관리자 패널\",\n        \"Close\": \"닫기\",\n        \"Help Center\": \"도움말 센터\",\n        \"Opt in to Telemetry\": \"텔레메트리 참여\",\n        \"Opted In\": \"참여함\",\n        \"Support Grist\": \"Grist 후원\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"감사합니다! 신뢰와 지원에 깊이 감사드립니다. 언제든지 사용자 메뉴의 {{link}}에서 참여를 중단할 수 있습니다.\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Delete\": \"삭제\",\n        \"Other\": \"기타\",\n        \"Save\": \"저장\",\n        \"Splunk\": \"스플렁크(Splunk)\",\n        \"Start streaming\": \"스트리밍 시작\",\n        \"Token\": \"토큰\",\n        \"Add destination\": \"대상 추가\",\n        \"Add streaming destination\": \"스트리밍 대상 추가\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"이 스트리밍 대상을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.\",\n        \"Cancel\": \"취소\",\n        \"Delete streaming destination?\": \"스트리밍 대상을 삭제하시겠습니까?\",\n        \"Destination\": \"대상\",\n        \"Destinations\": \"대상\",\n        \"Edit\": \"편집\",\n        \"Edit streaming destination\": \"스트리밍 대상 편집\",\n        \"Enter URL\": \"URL 입력\",\n        \"Enter token\": \"토큰 입력\",\n        \"Learn more\": \"자세히 알아보기\",\n        \"URL\": \"URL\"\n    },\n    \"RightPanelUtils\": {\n        \"fields_one\": \"필드\",\n        \"columns_one\": \"열\",\n        \"columns_other\": \"열\",\n        \"fields_other\": \"필드\",\n        \"series_one\": \"계열\",\n        \"series_other\": \"계열\"\n    },\n    \"ColumnTitle\": {\n        \"Column description\": \"열 설명\",\n        \"Column label\": \"열 레이블\",\n        \"Provide a column label\": \"열 레이블 입력\",\n        \"Save\": \"저장\",\n        \"Close\": \"닫기\",\n        \"Add description\": \"설명 추가\",\n        \"COLUMN ID: \": \"열 ID: \",\n        \"Cancel\": \"취소\",\n        \"Column ID copied to clipboard\": \"열 ID가 클립보드에 복사되었습니다\"\n    },\n    \"SupportGristNudge\": {\n        \"Admin Panel\": \"관리자 패널\",\n        \"Close\": \"닫기\",\n        \"Contribute\": \"기여하기\",\n        \"Help Center\": \"도움말 센터\",\n        \"Opt in to Telemetry\": \"텔레메트리 참여\",\n        \"Opted In\": \"참여함\",\n        \"Support Grist\": \"Grist 후원\",\n        \"Support Grist page\": \"Grist 후원 페이지\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"감사합니다! 신뢰와 지원에 깊이 감사드립니다. 언제든지 사용자 메뉴의 {{link}}에서 참여를 중단할 수 있습니다.\"\n    },\n    \"Menu\": {\n        \"Building blocks\": \"구성 요소\",\n        \"Columns\": \"열\",\n        \"Copy\": \"복사\",\n        \"Cut\": \"잘라내기\",\n        \"Insert question above\": \"위에 질문 삽입\",\n        \"Insert question below\": \"아래에 질문 삽입\",\n        \"Paragraph\": \"단락\",\n        \"Paste\": \"붙여넣기\",\n        \"Separator\": \"구분선\",\n        \"Unmapped fields\": \"매핑되지 않은 필드\",\n        \"Header\": \"헤더\",\n        \"New question\": \"새 질문\",\n        \"More\": \"더보기\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"나만의 양식 만들기\",\n        \"Powered by\": \"제공:\",\n        \"Powered by Grist\": \"Grist에서 제공\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"오류\"\n    },\n    \"DateRangeOptions\": {\n        \"Last week\": \"지난 주\",\n        \"Last 30 days\": \"지난 30일\",\n        \"Last 7 days\": \"지난 7일\",\n        \"Next 7 days\": \"다음 7일\",\n        \"This month\": \"이번 달\",\n        \"This week\": \"이번 주\",\n        \"This year\": \"올해\",\n        \"Today\": \"오늘\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"Paste your activation key\": \"활성화 키 붙여넣기\",\n        \"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}}).\": \"활성화 키는 30일의 평가판 기간이 만료된 후\\nGrist Enterprise를 실행하는 데 사용됩니다.\\n[Grist Enterprise 가입]({{signupLink}})하여 활성화 키를 받으세요.\\nGrist Core를 실행하는 데는 활성화 키가 필요하지 않습니다.\\n\\n자세한 내용은 [도움말 센터]({{helpCenter}})에서 알아보세요.\",\n        \"Disable Grist Enterprise\": \"Grist Enterprise 비활성화\",\n        \"Enable Grist Enterprise\": \"Grist Enterprise 활성화\",\n        \"Grist Enterprise is **enabled**.\": \"Grist Enterprise가 **활성화**되었습니다.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"활성화 키는 30일의 평가판 기간이 만료된 후 Grist Enterprise를 실행하는 데 사용됩니다.\\n오늘 [문의하기]({{contactLink}})를 통해 활성화 키를 받으세요.\\nGrist Core를 실행하는 데는 활성화 키가 필요하지 않습니다.\\n\\n자세한 내용은 [도움말 센터]({{helpCenter}})에서 알아보세요.\",\n        \"Activate\": \"활성화\",\n        \"Activation key\": \"활성화 키\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"활성화 키는 30일의 평가판 기간이 만료된 후 Grist Enterprise를 실행하는 데 사용됩니다.\\n[Grist Enterprise 가입]({{signupLink}})하여 활성화 키를 받으세요.\\nGrist Core를 실행하는 데는\\n활성화 키가 필요하지 않습니다.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Grist Enterprise를 계속 사용하려면 활성 구독이 필요합니다.\\n[Grist Enterprise 가입]({{signupLink}})하고 아래에 활성화 키를 붙여넣어\\n구독을 활성화할 수 있습니다.\",\n        \"Copy to clipboard\": \"클립보드에 복사\",\n        \"Expiration date\": \"만료 날짜\",\n        \"Installation ID copied to clipboard\": \"설치 ID가 클립보드에 복사되었습니다\",\n        \"Installation ID:\": \"설치 ID:\",\n        \"Installation seats\": \"설치 시트 수\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"자세한 내용은 [도움말 센터]({{helpCenter}})에서 알아보세요.\",\n        \"Plan name\": \"요금제 이름\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Grist Enterprise를 계속 사용하려면 활성화 키를 받기 위해\\n[문의하기]({{signupLink}})가 필요합니다.\",\n        \"You are currently trialing Grist Enterprise.\": \"현재 Grist Enterprise 평가판을 사용 중입니다.\",\n        \"You do not have an active subscription.\": \"활성 구독이 없습니다.\",\n        \"Your activation key has expired due to exceeding limits.\": \"한도 초과로 인해 활성화 키가 만료되었습니다.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"**{{days}}**일 후에 인스턴스가 **읽기 전용** 모드가 됩니다.\",\n        \"Your subscription expired on {{date}}.\": \"구독이 {{date}}에 만료되었습니다.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"평가판 기간이 **{{expireAt}}**에 만료되었습니다. Grist Enterprise를 계속 사용하려면\\n[Grist Enterprise 가입]({{signupLink}})하고 아래에 활성화 키를 붙여넣어야 합니다.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit logs for {{siteName}}\": \"{{siteName}}의 감사 로그\",\n        \"Contact us\": \"문의하기\",\n        \"Home\": \"홈\",\n        \"Log streaming\": \"로그 스트리밍\",\n        \"Only site owners may access audit logs.\": \"사이트 소유자만 감사 로그에 접근할 수 있습니다.\",\n        \"Audit Logs\": \"감사 로그\",\n        \"upgrade your plan\": \"요금제 업그레이드\"\n    },\n    \"DocList\": {\n        \"All\": \"전체\",\n        \"Last edited\": \"마지막 수정\",\n        \"Manage users\": \"사용자 관리\",\n        \"Requires edit permissions\": \"편집 권한 필요\",\n        \"Sort by date\": \"날짜순 정렬\",\n        \"Sort by name\": \"이름순 정렬\",\n        \"Unpin\": \"고정 해제\",\n        \"Workspace\": \"워크스페이스\",\n        \"Access details\": \"접근 상세 정보\",\n        \"Current workspace\": \"현재 워크스페이스\",\n        \"Delete\": \"삭제\",\n        \"Delete {{name}}\": \"{{name}} 삭제\",\n        \"Document will be moved to Trash.\": \"문서가 휴지통으로 이동됩니다.\",\n        \"Edited {{at}}\": \"{{at}} 수정됨\",\n        \"Move\": \"이동\",\n        \"Move {{name}} to workspace\": \"{{name}}을(를) 워크스페이스로 이동\",\n        \"Name\": \"이름\",\n        \"No documents to show.\": \"표시할 문서가 없습니다.\",\n        \"Pin\": \"고정\",\n        \"Pinned\": \"고정됨\",\n        \"Recent\": \"최근\",\n        \"Rename and set icon\": \"이름 변경 및 아이콘 설정\"\n    },\n    \"RenameDocModal\": {\n        \"Icon\": \"아이콘\",\n        \"Name\": \"이름\",\n        \"Rename and set icon\": \"이름 변경 및 아이콘 설정\",\n        \"Reset icon\": \"아이콘 초기화\",\n        \"Choose color\": \"색상 선택\",\n        \"Choose icon\": \"아이콘 선택\",\n        \"Enter document name\": \"문서 이름 입력\"\n    },\n    \"DataTables\": {\n        \"Edit record card\": \"레코드 카드 편집\",\n        \"Record Card\": \"레코드 카드\",\n        \"{{action}} Record Card\": \"레코드 카드 {{action}}\",\n        \"Record Card Disabled\": \"레코드 카드 비활성화됨\",\n        \"Click to copy\": \"클릭하여 복사\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"{{formattedTableName}} 데이터를 삭제하고 모든 페이지에서 제거하시겠습니까?\",\n        \"Duplicate table\": \"표 복제\",\n        \"Raw Data Tables\": \"원본 데이터 표\",\n        \"Table ID copied to clipboard\": \"표 ID가 클립보드에 복사되었습니다\",\n        \"You do not have edit access to this document\": \"이 문서를 편집할 접근 권한이 없습니다\",\n        \"Remove table\": \"표 제거\",\n        \"Rename table\": \"표 이름 변경\"\n    },\n    \"ApiKey\": {\n        \"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 키를 삭제하려고 합니다. 삭제하면 이 API 키를 사용하는 이후 모든 요청이 거부됩니다. 삭제하시겠습니까?\",\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"API 키를 생성하면 자신의 계정으로 API 호출을 할 수 있습니다.\",\n        \"Click to show\": \"클릭하여 보기\",\n        \"Create\": \"생성\",\n        \"Remove\": \"제거\",\n        \"Remove API Key\": \"API 키 제거\",\n        \"This API key can be used to access this account anonymously via the API.\": \"이 API 키를 사용하면 API를 통해 익명으로 이 계정에 접근할 수 있습니다.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"이 API 키를 사용하면 API를 통해 계정에 접근할 수 있습니다. API 키를 누구와도 공유하지 마세요.\"\n    },\n    \"DocMenu\": {\n        \"Document will be permanently deleted.\": \"문서를 영구적으로 삭제합니다.\",\n        \"Document will be moved to Trash.\": \"문서를 휴지통으로 보냅니다.\",\n        \"(The organization needs a paid plan)\": \"(조직에 유료 요금제가 필요합니다)\",\n        \"Access Details\": \"접근 상세 정보\",\n        \"All documents\": \"모든 문서\",\n        \"By Date Modified\": \"수정 날짜순\",\n        \"By Name\": \"이름순\",\n        \"Current workspace\": \"현재 워크스페이스\",\n        \"Delete\": \"삭제\",\n        \"Delete Forever\": \"영구 삭제\",\n        \"Delete {{name}}\": \"{{name}} 삭제\",\n        \"Deleted {{at}}\": \"{{at}} 삭제됨\",\n        \"Discover More Templates\": \"더 많은 템플릿 둘러보기\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"문서는 휴지통에 30일 동안 보관된 후 영구적으로 삭제됩니다.\",\n        \"Edited {{at}}\": \"{{at}} 수정됨\",\n        \"Examples & Templates\": \"예제 및 템플릿\",\n        \"Examples and Templates\": \"예제 및 템플릿\",\n        \"Featured\": \"추천\",\n        \"Manage users\": \"사용자 관리\",\n        \"More Examples and Templates\": \"더 많은 예제 및 템플릿\",\n        \"Move\": \"이동\",\n        \"Move {{name}} to workspace\": \"{{name}}을(를) 워크스페이스로 이동\",\n        \"Other Sites\": \"다른 사이트\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"\\\"{{name}}\\\"을(를) 영구적으로 삭제하시겠습니까?\",\n        \"Pin Document\": \"문서 고정\",\n        \"Pinned Documents\": \"고정된 문서\",\n        \"Remove\": \"제거\",\n        \"Rename\": \"이름 변경\",\n        \"Requires edit permissions\": \"편집 권한 필요\",\n        \"Restore\": \"복원\",\n        \"This service is not available right now\": \"현재 이 서비스를 사용할 수 없습니다\",\n        \"To restore this document, restore the workspace first.\": \"이 문서를 복원하려면 먼저 워크스페이스를 복원하세요.\",\n        \"Trash\": \"휴지통\",\n        \"Trash is empty.\": \"휴지통이 비어 있습니다.\",\n        \"Unpin Document\": \"문서 고정 해제\",\n        \"Workspace not found\": \"워크스페이스를 찾을 수 없습니다\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"현재 {{siteName}} 사이트에 있습니다. 다음 사이트에도 접근할 수 있습니다:\",\n        \"You are on your personal site. You also have access to the following sites:\": \"현재 개인 사이트에 있습니다. 다음 사이트에도 접근할 수 있습니다:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"워크스페이스에 문서가 없으면 영구적으로 삭제할 수 있습니다.\",\n        \"Any documents created in this site will appear here.\": \"이 사이트에서 생성된 모든 문서가 여기에 표시됩니다.\",\n        \"Create my first document\": \"첫 문서 만들기\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"이 사이트에 대한 읽기 전용 접근 권한이 있습니다. 현재 문서가 없습니다.\",\n        \"personal site\": \"개인 사이트\"\n    },\n    \"ChartView\": {\n        \"Create separate series for each value of the selected column.\": \"선택한 열의 각 값에 대해 별도의 계열을 생성합니다.\",\n        \"Toggle chart aggregation\": \"차트 집계 전환\",\n        \"Pick a column\": \"열 선택\",\n        \"selected new group data columns\": \"선택된 새 그룹 데이터 열\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"각 Y 계열 뒤에는 위쪽 및 아래쪽 오차 막대를 위한 두 개의 계열이 이어집니다.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"각 Y 계열 뒤에는 오차 막대 길이를 나타내는 계열이 이어집니다.\",\n        \"LABEL\": \"레이블\",\n        \"Bar chart\": \"막대 그래프\",\n        \"Pie chart\": \"파이 그래프\",\n        \"Donut chart\": \"도넛 그래프\",\n        \"Area chart\": \"면적 그래프\",\n        \"Line chart\": \"선 그래프\",\n        \"Kaplan-Meier plot\": \"카플란-마이어 곡선\",\n        \"Invert Y-axis\": \"Y축 반전\",\n        \"Orientation\": \"방향\",\n        \"Vertical\": \"세로\",\n        \"Horizontal\": \"가로\",\n        \"Log scale Y-axis\": \"로그 스케일 Y축\",\n        \"Hole size\": \"중앙 구멍 크기\",\n        \"Show total\": \"합계 보이기\",\n        \"Text size\": \"텍스트 크기\",\n        \"Connect gaps\": \"빈 구간 연결\",\n        \"Show markers\": \"마커 표시\",\n        \"Error bars\": \"오차 막대\",\n        \"None\": \"없음\",\n        \"Symmetric\": \"대칭\",\n        \"Above+Below\": \"위 + 아래\",\n        \"Split Series\": \"계열 분할\",\n        \"X-AXIS\": \"X축\",\n        \"Aggregate values\": \"집계 값\",\n        \"SERIES\": \"계열\",\n        \"Add series\": \"계열 추가\",\n        \"non-numeric columns are not shown\": \"숫자 데이터가 아닌 열은 생략됩니다\",\n        \"non-numeric column is not shown\": \"숫자 데이터가 아닌 열은 생략됩니다\",\n        \"selected new x-axis\": \"새로 선택한 X축\",\n        \"Remove\": \"제거\",\n        \"Stack series\": \"계열 누적\",\n        \"Scatter plot\": \"산점도\",\n        \"Split series\": \"계열 분할\"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"API 키\",\n        \"Account settings\": \"계정 설정\",\n        \"Allow signing in to this account with Google\": \"Google 계정으로 이 계정에 로그인 허용\",\n        \"Change password\": \"비밀번호 변경\",\n        \"Edit\": \"편집\",\n        \"Email\": \"이메일\",\n        \"Login method\": \"로그인 방식\",\n        \"Name\": \"이름\",\n        \"Names only allow letters, numbers and certain special characters\": \"이름에는 문자, 숫자 및 일부 특수 문자만 사용할 수 있습니다\",\n        \"Password & security\": \"비밀번호 및 보안\",\n        \"Save\": \"저장\",\n        \"Theme\": \"테마\",\n        \"Two-factor authentication\": \"2단계 인증\",\n        \"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.\": \"2단계 인증은 Grist 계정 보안을 강화하는 추가 단계입니다. 다른 사람이 비밀번호를 알더라도 본인만 계정에 접근할 수 있도록 설계되었습니다.\",\n        \"Language\": \"언어\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"접근 상세 정보\",\n        \"Accounts\": \"계정\",\n        \"Add account\": \"계정 추가\",\n        \"Document settings\": \"문서 설정\",\n        \"Manage team\": \"팀 관리\",\n        \"Pricing\": \"요금제\",\n        \"Profile settings\": \"프로필 설정\",\n        \"Sign out\": \"로그아웃\",\n        \"Sign in\": \"로그인\",\n        \"Switch Accounts\": \"계정 전환\",\n        \"Toggle Mobile Mode\": \"모바일 모드 전환\",\n        \"Activation\": \"활성화\",\n        \"Billing account\": \"청구 계정\",\n        \"Support Grist\": \"Grist 후원\",\n        \"Upgrade Plan\": \"요금제 업그레이드\",\n        \"Sign up\": \"가입하기\",\n        \"Use This Template\": \"이 템플릿 사용하기\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"활동 로그 로드 실패\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"{{colId}} 열은 이후 작업 #{{action.actionNum}}에서 제거되었습니다\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"{{tableId}} 표는 이후 작업 #{{actionNum}}에서 제거되었습니다\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"이 행은 이후 작업 #{{action.actionNum}}에서 제거되었습니다\",\n        \"All tables\": \"모든 표\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"홈 페이지\",\n        \"Legacy\": \"레거시\",\n        \"Personal Site\": \"개인 사이트\",\n        \"Team Site\": \"팀 사이트\",\n        \"Grist Templates\": \"Grist 템플릿\",\n        \"Billing account\": \"청구 계정\",\n        \"Manage team\": \"팀 관리\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"이 팀 사이트는 일시 중단되었습니다. 문서를 읽을 수는 있지만 수정할 수는 없습니다.\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"셀 내용 지우기\",\n        \"Clear values\": \"값 지우기\",\n        \"Copy anchor link\": \"바로가기 링크 복사\",\n        \"Delete {{count}} columns_one\": \"열 삭제\",\n        \"Delete {{count}} columns_other\": \"열 {{count}}개 삭제\",\n        \"Delete {{count}} rows_one\": \"행 삭제\",\n        \"Delete {{count}} rows_other\": \"행 {{count}}개 삭제\",\n        \"Duplicate rows_one\": \"행 복제\",\n        \"Duplicate rows_other\": \"행 복제\",\n        \"Filter by this value\": \"이 값으로 필터링\",\n        \"Insert column to the left\": \"왼쪽에 열 삽입\",\n        \"Insert column to the right\": \"오른쪽에 열 삽입\",\n        \"Insert row\": \"행 삽입\",\n        \"Insert row above\": \"위에 행 삽입\",\n        \"Insert row below\": \"아래에 행 삽입\",\n        \"Reset {{count}} columns_one\": \"열 초기화\",\n        \"Reset {{count}} columns_other\": \"열 {{count}}개 초기화\",\n        \"Reset {{count}} entire columns_one\": \"전체 열 초기화\",\n        \"Reset {{count}} entire columns_other\": \"전체 열 {{count}}개 초기화\",\n        \"Comment\": \"댓글\",\n        \"Copy\": \"복사\",\n        \"Cut\": \"잘라내기\",\n        \"Paste\": \"붙여넣기\",\n        \"Copy with headers\": \"헤더와 함께 복사\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"접근 거부됨\",\n        \"Code View is available only when you have full document access.\": \"코드 보기는 문서 전체 접근 권한이 있을 때만 사용할 수 있습니다.\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"적용\",\n        \"Cancel\": \"취소\",\n        \"Default cell style\": \"기본 셀 스타일\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"전체\",\n        \"All except\": \"다음을 제외한 전체\",\n        \"All shown\": \"표시된 전체\",\n        \"Filter by Range\": \"범위로 필터링\",\n        \"Future values\": \"미래 값\",\n        \"No matching values\": \"일치하는 값 없음\",\n        \"None\": \"없음\",\n        \"Min\": \"최소\",\n        \"Max\": \"최대\",\n        \"Start\": \"시작\",\n        \"End\": \"끝\",\n        \"Other Matching\": \"기타 일치 항목\",\n        \"Other Non-Matching\": \"기타 불일치 항목\",\n        \"Other values\": \"기타 값\",\n        \"Others\": \"기타\",\n        \"Search\": \"검색\",\n        \"Search values\": \"값 검색\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"활동\",\n        \"Beta\": \"베타\",\n        \"Compare to current\": \"현재 버전과 비교\",\n        \"Compare to previous\": \"이전 버전과 비교\",\n        \"Open snapshot\": \"스냅샷 열기\",\n        \"Snapshots\": \"스냅샷\",\n        \"Snapshots are unavailable.\": \"스냅샷을 사용할 수 없습니다.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"접근 규칙이 있는 문서의 스냅샷은 소유자만 접근할 수 있습니다.\"\n    },\n    \"DocTour\": {\n        \"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.\": \"이 문서의 데이터로 문서 둘러보기를 구성할 수 없습니다. GristDocTour 표에 Title, Body, Placement, Location 열이 있는지 확인하세요.\",\n        \"No valid document tour\": \"유효한 문서 둘러보기 없음\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"마지막 편집 복원\",\n        \"Undo discard\": \"폐기 취소\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"표 구조 뿐 아니라 모든 데이터도 복사합니다.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"표를 복제하는 대신 연결된 보기를 사용하여 데이터를 분할하는 것이 일반적으로 더 좋습니다. {{link}}\",\n        \"Name for new table\": \"새 표 이름\",\n        \"Only the document default access rules will apply to the copy.\": \"문서의 기본 접근 규칙만 사본에 적용됩니다.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"방과 후 프로그램\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"데이터 연결 및 생산성 높은 레이아웃 생성 방법에 대한 관련 튜토리얼을 확인하세요.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"비즈니스 데이터 모델링, 수식 사용, 복잡성 관리 방법에 대한 관련 튜토리얼을 확인하세요.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"요약 표 및 차트 생성, 동적 차트 연결 방법에 대한 관련 튜토리얼을 확인하세요.\",\n        \"Investment Research\": \"투자 리서치\",\n        \"Lightweight CRM\": \"간편 CRM\",\n        \"Tutorial: Analyze & Visualize\": \"튜토리얼: 분석 및 시각화\",\n        \"Tutorial: Create a CRM\": \"튜토리얼: CRM 만들기\",\n        \"Tutorial: Manage Business Data\": \"튜토리얼: 비즈니스 데이터 관리\",\n        \"Welcome to the Afterschool Program template\": \"방과 후 프로그램 템플릿에 오신 것을 환영합니다\",\n        \"Welcome to the Investment Research template\": \"투자 리서치 템플릿에 오신 것을 환영합니다\",\n        \"Welcome to the Lightweight CRM template\": \"간편 CRM 템플릿에 오신 것을 환영합니다\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"열 동작\",\n        \"COLUMN LABEL AND ID\": \"열 레이블 및 ID\",\n        \"Clear and make into formula\": \"내용 지우고 수식으로 만들기\",\n        \"Clear and reset\": \"내용 지우고 초기화\",\n        \"Column options are limited in summary tables.\": \"요약 표에서는 열 옵션이 제한됩니다.\",\n        \"Convert column to data\": \"열을 데이터로 변환\",\n        \"Convert to trigger formula\": \"트리거 수식으로 변환\",\n        \"Data columns_one\": \"데이터 열\",\n        \"Data columns_other\": \"데이터 열\",\n        \"Empty columns_one\": \"빈 열\",\n        \"Empty columns_other\": \"빈 열\",\n        \"Enter formula\": \"수식 입력\",\n        \"Formula columns_one\": \"수식 열\",\n        \"Formula columns_other\": \"수식 열\",\n        \"Make into data column\": \"데이터 열로 만들기\",\n        \"Mixed Behavior\": \"혼합된 동작\",\n        \"Set formula\": \"수식 설정\",\n        \"Set trigger formula\": \"트리거 수식 설정\",\n        \"TRIGGER FORMULA\": \"트리거 수식\",\n        \"DESCRIPTION\": \"설명\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"공통 설정으로 되돌리기\",\n        \"Save as common settings\": \"공통 설정으로 저장\",\n        \"Use separate settings\": \"개별 설정 사용\",\n        \"Using common settings\": \"공통 설정 사용 중\",\n        \"Using separate settings\": \"개별 설정 사용 중\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"열 추가\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"열 검색\",\n        \"Search Columns\": \"열 검색\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"그리드 옵션\",\n        \"Horizontal gridlines\": \"가로 격자선\",\n        \"Vertical gridlines\": \"세로 격자선\",\n        \"Zebra stripes\": \"얼룩말 무늬\"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"{{viewName}} 보기에 새 연결된 섹션 추가됨\",\n        \"Import from file\": \"파일에서 가져오기\",\n        \"Saved linked section {{title}} in view {{name}}\": \"{{name}} 보기에서 연결된 섹션 {{title}} 저장됨\",\n        \"go to webhook settings\": \"웹훅 설정으로 이동\"\n    },\n    \"HomeLeftPane\": {\n        \"Examples & Templates\": \"템플릿\",\n        \"Import document\": \"문서 가져오기\",\n        \"Manage users\": \"사용자 관리\",\n        \"Rename\": \"이름 변경\",\n        \"Trash\": \"휴지통\",\n        \"Access Details\": \"접근 상세 정보\",\n        \"All documents\": \"모든 문서\",\n        \"Create empty document\": \"빈 문서 만들기\",\n        \"Create workspace\": \"워크스페이스 만들기\",\n        \"Delete\": \"삭제\",\n        \"Delete {{workspace}} and all included documents?\": \"{{workspace}} 및 포함된 모든 문서를 삭제하시겠습니까?\",\n        \"Workspace will be moved to Trash.\": \"워크스페이스가 휴지통으로 이동됩니다.\",\n        \"Workspaces\": \"워크스페이스\",\n        \"Tutorial\": \"튜토리얼\",\n        \"Terms of service\": \"서비스 약관\",\n        \"Grist Resources\": \"Grist 리소스\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"도움말 센터\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"템플릿으로\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"주의: 원본에는 이 문서에 없는 변경 사항이 있습니다. 해당 변경 사항은 덮어쓰게 됩니다.\",\n        \"Cancel\": \"취소\",\n        \"Enter document name\": \"문서 이름 입력\",\n        \"However, it appears to be already identical.\": \"하지만 이미 동일한 것으로 보입니다.\",\n        \"Include the structure without any of the data.\": \"데이터 없이 구조만 포함합니다.\",\n        \"It will be overwritten, losing any content not in this document.\": \"이 문서에 없는 내용은 덮어쓰여 손실됩니다.\",\n        \"Name\": \"이름\",\n        \"No destination workspace\": \"대상 워크스페이스 없음\",\n        \"Organization\": \"조직\",\n        \"Original Has Modifications\": \"원본에 수정 사항 있음\",\n        \"Original Looks Unrelated\": \"원본이 관련 없어 보임\",\n        \"Original Looks Identical\": \"원본이 동일해 보임\",\n        \"Overwrite\": \"덮어쓰기\",\n        \"Replacing the original requires editing rights on the original document.\": \"원본을 교체하려면 원본 문서에 대한 편집 권한이 필요합니다.\",\n        \"Sign up\": \"가입하기\",\n        \"The original version of this document will be updated.\": \"이 문서의 원본 버전이 업데이트됩니다.\",\n        \"To save your changes, please sign up, then reload this page.\": \"변경 사항을 저장하려면 가입한 후 이 페이지를 새로고침하세요.\",\n        \"Update\": \"업데이트\",\n        \"Update Original\": \"원본 업데이트\",\n        \"Workspace\": \"워크스페이스\",\n        \"You do not have write access to the selected workspace\": \"선택한 워크스페이스에 쓰기 접근 권한이 없습니다\",\n        \"You do not have write access to this site\": \"이 사이트에 쓰기 접근 권한이 없습니다\",\n        \"Download document and history\": \"전체 문서 및 기록 다운로드\",\n        \"Download document structure only (no data, for template use)\": \"모든 데이터를 제거하되, 템플릿으로 사용할 구조는 유지합니다\",\n        \"Download document without history (can significantly reduce file size)\": \"문서 기록 제거 (파일 크기를 크게 줄일 수 있음)\",\n        \"Download\": \"다운로드\",\n        \"Download document\": \"문서 다운로드\",\n        \"Download attachments\": \"첨부 다운로드\",\n        \"Download full document and history\": \"전체 문서 및 히스토리 다운로드\",\n        \"Format:\": \"형식:\",\n        \"Learn more\": \"더 배우기\",\n        \"download attachments\": \"첨부 다운로드\",\n        \".tar (recommended)\": \"tar파일(권장)\",\n        \".zip\": \"zip파일\",\n        \"Download an archive of all the attachments present in this document.\": \"이 문서에 포함된 모든 첨부 파일을 압축하여 다운로드합니다.\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Grist 비디오 둘러보기\",\n        \"Video Tour\": \"비디오 둘러보기\",\n        \"YouTube video player\": \"YouTube 비디오 플레이어\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"페이지에 추가\",\n        \"Building {{- label}} widget\": \"{{- label}} 위젯 생성 중\",\n        \"Group by\": \"그룹화 기준\",\n        \"Select data\": \"데이터 선택\",\n        \"Select widget\": \"위젯 선택\"\n    },\n    \"Pages\": {\n        \"Delete\": \"삭제\",\n        \"Delete data and this page.\": \"데이터 및 이 페이지를 삭제합니다.\",\n        \"The following tables will no longer be visible_one\": \"다음 표는 더 이상 표시되지 않습니다\",\n        \"The following tables will no longer be visible_other\": \"다음 표들은 더 이상 표시되지 않습니다\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"모두 허용\",\n        \"Deny all\": \"모두 거부\",\n        \"Read only\": \"읽기 전용\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"가져오기 실패: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"레코드 레이아웃 업데이트 중.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"필드 추가\",\n        \"Create new field\": \"새 필드 만들기\",\n        \"Show field {{- label}}\": \"{{- label}} 필드 표시\",\n        \"Save layout\": \"레이아웃 저장\",\n        \"Cancel\": \"취소\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"열 추가\",\n        \"No columns to add\": \"추가할 열 없음\"\n    },\n    \"RightPanel\": {\n        \"CHART TYPE\": \"차트 유형\",\n        \"COLUMN TYPE\": \"열 유형\",\n        \"CUSTOM\": \"사용자 정의\",\n        \"Change widget\": \"위젯 변경\",\n        \"columns_one\": \"열\",\n        \"columns_other\": \"열\",\n        \"DATA TABLE\": \"데이터 표\",\n        \"DATA TABLE NAME\": \"데이터 표 이름\",\n        \"Data\": \"데이터\",\n        \"Detach\": \"분리\",\n        \"Edit data selection\": \"데이터 선택 편집\",\n        \"fields_one\": \"필드\",\n        \"fields_other\": \"필드\",\n        \"GROUPED BY\": \"그룹화 기준\",\n        \"Row style\": \"행 스타일\",\n        \"Select a field in the form widget to configure.\": \"구성할 양식 위젯의 필드를 선택하세요.\",\n        \"Submit\": \"제출\",\n        \"SELECT BY\": \"선택 기준\",\n        \"SELECTOR FOR\": \"선택기 대상\",\n        \"SOURCE DATA\": \"원본 데이터\",\n        \"Save\": \"저장\",\n        \"Select widget\": \"위젯 선택\",\n        \"series_one\": \"계열\",\n        \"series_other\": \"계열\",\n        \"Sort & filter\": \"정렬 및 필터\",\n        \"TRANSFORM\": \"변환\",\n        \"Theme\": \"테마\",\n        \"WIDGET TITLE\": \"위젯 제목\",\n        \"Widget\": \"위젯\",\n        \"You do not have edit access to this document\": \"이 문서를 편집할 접근 권한이 없습니다\",\n        \"Add referenced columns\": \"참조된 열 추가\",\n        \"Reset form\": \"양식 초기화\",\n        \"Configuration\": \"구성\",\n        \"Default field value\": \"기본 필드 값\",\n        \"Display button\": \"버튼 표시\",\n        \"Enter text\": \"텍스트 입력\",\n        \"Field rules\": \"필드 규칙\",\n        \"Field title\": \"필드 제목\",\n        \"Hidden field\": \"숨겨진 필드\",\n        \"Layout\": \"레이아웃\",\n        \"Redirect automatically after submission\": \"제출 후 자동으로 리디렉션\",\n        \"Redirection\": \"리디렉션\",\n        \"Required field\": \"필수 필드\",\n        \"Submission\": \"제출\",\n        \"Submit another response\": \"다른 응답 제출\",\n        \"Submit button label\": \"제출 버튼 레이블\",\n        \"Success text\": \"성공 텍스트\",\n        \"Table column name\": \"표 열 이름\",\n        \"Enter redirect URL\": \"리디렉션 URL 입력\",\n        \"No field selected\": \"선택된 필드 없음\",\n        \"Thank you! Your response has been recorded.\": \"감사합니다! 응답이 기록되었습니다.\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"바로가기 링크 복사\",\n        \"Delete\": \"삭제\",\n        \"Duplicate rows_one\": \"행 복제\",\n        \"Duplicate rows_other\": \"행 복제\",\n        \"Insert row\": \"행 삽입\",\n        \"Insert row above\": \"위에 행 삽입\",\n        \"Insert row below\": \"아래에 행 삽입\",\n        \"View as card\": \"카드로 보기\",\n        \"Use as table headers\": \"표 헤더로 사용\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"클립보드에 복사됨\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"접근 상세 정보\",\n        \"Back to current\": \"현재 버전으로 돌아가기\",\n        \"Compare to {{termToUse}}\": \"{{termToUse}}와(과) 비교\",\n        \"Current Version\": \"현재 버전\",\n        \"Download\": \"다운로드\",\n        \"Duplicate document\": \"문서 복제\",\n        \"Edit without affecting the original\": \"원본에 영향을 주지 않고 편집\",\n        \"Export CSV\": \"CSV로 내보내기\",\n        \"Export XLSX\": \"XLSX로 내보내기\",\n        \"Manage users\": \"사용자 관리\",\n        \"Original\": \"원본\",\n        \"Replace {{termToUse}}...\": \"{{termToUse}} 교체…\",\n        \"Return to {{termToUse}}\": \"{{termToUse}}(으)로 돌아가기\",\n        \"Save copy\": \"사본 저장\",\n        \"Save Document\": \"문서 저장\",\n        \"Send to Google Drive\": \"Google Drive로 보내기\",\n        \"Show in folder\": \"폴더에서 보기\",\n        \"Unsaved\": \"저장되지 않음\",\n        \"Work on a copy\": \"사본으로 작업\",\n        \"Share\": \"공유\",\n        \"Download...\": \"다운로드...\",\n        \"Comma Separated Values (.csv)\": \"쉼표로 구분된 값 (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"DOO로 구분된 값 (.dsv)\",\n        \"Export as...\": \"다음으로 내보내기...\",\n        \"Microsoft Excel (.xlsx)\": \"마이크로소프트 엑셀 (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"탭으로 구분된 값 (.tsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"내보내기는 문서 페이지에서만 사용할 수 있습니다. 문서 페이지를 선택한 후 다시 시도하세요.\",\n        \"Download document...\": \"문서 다운로드...\",\n        \"Download attachments...\": \"첨부 다운로드...\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"새 팀 사이트 만들기\",\n        \"Switch Sites\": \"사이트 전환\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"열 추가\",\n        \"Empty values last\": \"빈 값 마지막에\",\n        \"Natural sort\": \"자연 정렬\",\n        \"Update data\": \"데이터 업데이트\",\n        \"Use choice position\": \"선택 항목 위치 사용\",\n        \"Search Columns\": \"열 검색\"\n    },\n    \"SortFilterConfig\": {\n        \"Filter\": \"필터\",\n        \"Revert\": \"되돌리기\",\n        \"Save\": \"저장\",\n        \"Sort\": \"정렬\",\n        \"Update Sort & Filter settings\": \"정렬 및 필터 설정 업데이트\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"모양 \",\n        \"Switch appearance automatically to match system\": \"시스템 설정에 맞춰 자동으로 모양 전환\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"접근 규칙\",\n        \"Code view\": \"코드 보기\",\n        \"Delete\": \"삭제\",\n        \"Delete document tour?\": \"문서 둘러보기를 삭제하시겠습니까?\",\n        \"Document history\": \"문서 기록\",\n        \"How-to Tutorial\": \"사용법 튜토리얼\",\n        \"Raw data\": \"원본 데이터\",\n        \"Return to viewing as yourself\": \"본인으로 보기로 돌아가기\",\n        \"TOOLS\": \"도구\",\n        \"Tour of this Document\": \"이 문서 둘러보기\",\n        \"Validate Data\": \"데이터 유효성 검사\",\n        \"Settings\": \"설정\",\n        \"API console\": \"API 콘솔\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"팀 관리\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"모든 필드\",\n        \"Apply on changes to:\": \"다음 변경 시 적용:\",\n        \"Apply on record changes\": \"레코드 변경 시 적용\",\n        \"Apply to new records\": \"새 레코드에 적용\",\n        \"Cancel\": \"취소\",\n        \"Close\": \"닫기\",\n        \"Current field \": \"현재 필드 \",\n        \"OK\": \"확인\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"적용\",\n        \"Cancel\": \"취소\",\n        \"Preview\": \"미리보기\",\n        \"Revise\": \"수정\",\n        \"Update formula (Shift+Enter)\": \"수식 업데이트 (Shift+Enter)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"편집자\",\n        \"In full\": \"전체\",\n        \"No Default Access\": \"기본 접근 권한 없음\",\n        \"None\": \"없음\",\n        \"Owner\": \"소유자\",\n        \"View & edit\": \"보기 및 편집\",\n        \"View only\": \"보기 전용\",\n        \"Viewer\": \"뷰어\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"규칙 {{length}}\",\n        \"Update formula (Shift+Enter)\": \"수식 업데이트 (Shift+Enter)\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"알 수 없는 사용자\",\n        \"View as Yourself\": \"본인으로 보기\",\n        \"You are viewing this document as\": \"현재 다음 사용자로 이 문서를 보고 있습니다:\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"고급 설정\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"큰 표는 데이터 엔진으로 로드하는 것을 피하기 위해 \\\"요청 시 로드\\\"로 표시될 수 있습니다.\",\n        \"Blocks\": \"블록\",\n        \"Compact\": \"간결하게\",\n        \"Edit card layout\": \"카드 레이아웃 편집\",\n        \"Form\": \"양식\",\n        \"Make On-Demand\": \"요청 시 로드로 설정\",\n        \"Plugin: \": \"플러그인: \",\n        \"Section: \": \"섹션: \",\n        \"Unmark On-Demand\": \"요청 시 로드 해제\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"기능 부족과 사용성 문제로 인해 On-Demand Table은 더 이상 지원되지 않습니다.\",\n        \"⚠️ Deprecated Feature\": \"⚠️ 지원 중단된 기능\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"편집할 수 있지만, 편집 내용은 새 사본으로 생성되며\\n원본 문서에는 영향을 주지 않습니다.\",\n        \"fiddle\": \"탐색\",\n        \"override\": \"덮어쓰기\",\n        \"recovery mode\": \"복구 모드\",\n        \"snapshot\": \"스냅샷\",\n        \"unsaved\": \"저장되지 않음\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"{{pageName}} 페이지 복제\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"이는 데이터를 복사하는 것이 아니라 동일한 데이터의 다른 보기를 생성합니다.\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"페이지 복제\",\n        \"Remove\": \"제거\",\n        \"Rename\": \"이름 변경\",\n        \"You do not have edit access to this document\": \"이 문서를 편집할 접근 권한이 없습니다\"\n    },\n    \"CellStyle\": {\n        \"Header Style\": \"헤더 스타일\",\n        \"CELL STYLE\": \"셀 스타일\",\n        \"Cell style\": \"셀 스타일\",\n        \"Default cell style\": \"기본 셀 스타일\",\n        \"Mixed style\": \"혼합된 스타일\",\n        \"Open row styles\": \"행 스타일 열기\",\n        \"Default header style\": \"기본 헤더 스타일\",\n        \"HEADER STYLE\": \"헤더 스타일\"\n    },\n    \"search\": {\n        \"Find Next \": \"다음 찾기 \",\n        \"Find Previous \": \"이전 찾기 \",\n        \"No results\": \"결과 없음\",\n        \"Search in document\": \"문서 내 검색\",\n        \"Search\": \"검색\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Google Drive로 파일 전송 중\"\n    },\n    \"NTextBox\": {\n        \"false\": \"false\",\n        \"true\": \"true\",\n        \"Field Format\": \"필드 형식\",\n        \"Lines\": \"줄\",\n        \"Multi line\": \"여러 줄\",\n        \"Single line\": \"한 줄\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"예시 사용자\",\n        \"Users from table\": \"표에서 불러온 사용자\",\n        \"View as\": \"다음 사용자로 보기\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"적용\",\n        \"Cancel\": \"취소\",\n        \"Preview\": \"미리보기\",\n        \"Revise\": \"수정\",\n        \"Update formula (Shift+Enter)\": \"수식 업데이트 (Shift+Enter)\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"선택 항목\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"열 설명\",\n        \"COLUMN LABEL\": \"열 레이블\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN DESCRIPTION\": \"열 설명\",\n        \"COLUMN ID: \": \"열 ID: \",\n        \"COLUMN LABEL\": \"열 레이블\",\n        \"Cancel\": \"취소\",\n        \"Save\": \"저장\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"다른 규칙 추가\",\n        \"Add conditional style\": \"조건부 스타일 추가\",\n        \"Error in style rule\": \"스타일 규칙 오류\",\n        \"Row style\": \"행 스타일\",\n        \"Rule must return True or False\": \"규칙은 True 또는 False를 반환해야 합니다\",\n        \"Conditional Style\": \"조건부 스타일\",\n        \"IF...\": \"만약...\",\n        \"Row Style\": \"행 스타일\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"잘못된 통화\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"수식 열에는 일반 데이터 값을 저장할 수 없어야 합니다\",\n        \"Unable to finish saving edited cell\": \"편집된 셀 저장 완료 불가\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"열 또는 필드가 필요합니다\",\n        \"Error in the cell\": \"셀 오류\",\n        \"Errors in all {{numErrors}} cells\": \"모든 {{numErrors}}개 셀 오류\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"{{numCells}}개 셀 중 {{numErrors}}개 오류\",\n        \"editingFormula is required\": \"editingFormula가 필요합니다\",\n        \"Enter formula or {{button}}.\": \"수식을 입력하거나 {{button}}.\",\n        \"Enter formula.\": \"수식 입력.\",\n        \"Expand Editor\": \"편집기 확장\",\n        \"use AI Assistant\": \"AI 어시스턴트 사용\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[링크 레이블] URL\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"통화\",\n        \"Decimals\": \"소수 자릿수\",\n        \"Default currency ({{defaultCurrency}})\": \"기본 통화 ({{defaultCurrency}})\",\n        \"Number Format\": \"숫자 형식\",\n        \"Field Format\": \"필드 형식\",\n        \"Spinner\": \"스피너\",\n        \"Text\": \"텍스트\",\n        \"max\": \"최대\",\n        \"min\": \"최소\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"셀 형식\",\n        \"Row ID\": \"행 ID\",\n        \"SHOW COLUMN\": \"표시할 열\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"언어\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"설명\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"생성기 패널 닫기\",\n        \"Open creator panel\": \"생성기 패널 열기\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"알겠습니다\",\n        \"Unavailable Command\": \"사용할 수 없는 명령\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"필드 내용 지우기\",\n        \"Copy\": \"복사\",\n        \"Copy anchor link\": \"바로가기 링크 복사\",\n        \"Cut\": \"잘라내기\",\n        \"Hide field\": \"필드 숨기기\",\n        \"Paste\": \"붙여넣기\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"큐 지우기\",\n        \"Webhook settings\": \"웹훅 설정\",\n        \"Cleared webhook queue.\": \"웹훅 큐를 지웠습니다.\",\n        \"Columns to check when update (separated by ;)\": \"업데이트 시 확인할 열 (세미콜론 ; 으로 구분)\",\n        \"Enabled\": \"활성화됨\",\n        \"Event Types\": \"이벤트 유형\",\n        \"Memo\": \"메모\",\n        \"Name\": \"이름\",\n        \"Ready Column\": \"준비 열\",\n        \"Removed webhook.\": \"웹훅을 제거했습니다.\",\n        \"Sorry, not all fields can be edited.\": \"죄송합니다. 모든 필드를 편집할 수는 없습니다.\",\n        \"Status\": \"상태\",\n        \"URL\": \"URL\",\n        \"Webhook Id\": \"웹훅 ID\",\n        \"Table\": \"표\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"이 열의 변경 사항 필터링 (세미콜론으로 구분된 ID)\",\n        \"Header Authorization\": \"헤더 Authorization\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"저장되지 않은 문서 복사본에서는 Webhook 사용 불가\"\n    },\n    \"FormulaAssistant\": {\n        \"Ask the bot.\": \"봇에게 질문하세요.\",\n        \"Capabilities\": \"기능\",\n        \"Community\": \"커뮤니티\",\n        \"Data\": \"데이터\",\n        \"Formula Cheat Sheet\": \"수식 치트 시트\",\n        \"Formula Help. \": \"수식 도움말. \",\n        \"Function List\": \"함수 목록\",\n        \"Grist's AI Assistance\": \"Grist AI 어시스턴스\",\n        \"Grist's AI Formula Assistance. \": \"Grist AI 수식 어시스턴스. \",\n        \"Need help? Our AI assistant can help.\": \"도움이 필요하신가요? AI 어시스턴트가 도와드릴 수 있습니다.\",\n        \"New Chat\": \"새 채팅\",\n        \"Preview\": \"미리보기\",\n        \"Regenerate\": \"다시 생성\",\n        \"Save\": \"저장\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"도움말 {{helpFunction}} 및 {{formulaCheat}}를 참조하거나, 더 많은 도움을 받으려면 {{community}}를 방문하세요.\",\n        \"Tips\": \"팁\",\n        \"AI Assistant\": \"AI 어시스턴트\",\n        \"Apply\": \"적용\",\n        \"Cancel\": \"취소\",\n        \"Clear conversation\": \"대화 지우기\",\n        \"Code view\": \"코드 보기\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"안녕하세요, Grist 수식 AI 어시스턴트입니다.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"수식에 대해서만 도와드릴 수 있습니다. 표, 열, 뷰 생성 또는 접근 규칙 작성은 할 수 없습니다.\",\n        \"Learn more\": \"자세히 알아보기\",\n        \"Press Enter to apply suggested formula.\": \"Enter 키를 눌러 제안된 수식을 적용하세요.\",\n        \"Sign Up for Free\": \"무료로 가입하기\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"수식 AI 어시스턴트를 사용하려면 무료 Grist 계정에 가입하세요.\",\n        \"There are some things you should know when working with me:\": \"저와 작업할 때 알아두어야 할 몇 가지 사항이 있습니다:\",\n        \"What do you need help with?\": \"무엇을 도와드릴까요?\",\n        \"Formula AI Assistant is only available for logged in users.\": \"수식 AI 어시스턴트는 로그인한 사용자만 사용할 수 있습니다.\",\n        \"For higher limits, contact the site owner.\": \"더 높은 한도를 원하시면 사이트 소유자에게 문의하세요.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"더 높은 한도를 원하시면, {{upgradeNudge}}.\",\n        \"You have used all available credits.\": \"사용 가능한 크레딧을 모두 사용했습니다.\",\n        \"You have {{numCredits}} remaining credits.\": \"{{numCredits}}개의 크레딧이 남아 있습니다.\",\n        \"upgrade to the Pro Team plan\": \"Pro 팀 요금제로 업그레이드\",\n        \"upgrade your plan\": \"요금제 업그레이드\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"클릭하여 삽입\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"다시 오신 것을 환영합니다\",\n        \"You can always switch sites using the account menu.\": \"언제든지 계정 메뉴를 사용하여 사이트를 전환할 수 있습니다.\",\n        \"You have access to the following Grist sites.\": \"다음 Grist 사이트에 접근할 수 있습니다.\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"설명\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"모든 페이지 검색\",\n        \"Search all tables\": \"모든 표 검색\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"검색\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"데이터 없음\",\n        \"No row selected in {{title}}\": \"{{title}}에서 선택된 행 없음\",\n        \"Not all data is shown\": \"일부 데이터만 표시됨\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"편집기 축소\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"최대화\",\n        \"Minimize\": \"최소화\"\n    },\n    \"CardContextMenu\": {\n        \"Copy anchor link\": \"바로가기 링크 복사\",\n        \"Delete card\": \"카드 삭제\",\n        \"Duplicate card\": \"카드 복제\",\n        \"Insert card\": \"카드 삽입\",\n        \"Insert card above\": \"위에 카드 삽입\",\n        \"Insert card below\": \"아래에 카드 삽입\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"숨겨진 필드\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"무료 코칭 세션\",\n        \"Maybe later\": \"나중에\",\n        \"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.\": \"세션 동안 고객님의 요구 사항을 이해하고 맞춤형으로 진행합니다. Grist 기본 사항을 보여드리거나, 바로 데이터를 사용하여 필요한 대시보드를 구축할 수 있습니다.\",\n        \"Schedule call\": \"세션 예약\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"팀 멤버와 {{freeCoachingCall}}을(를) 예약하세요.\"\n    },\n    \"Editor\": {\n        \"Delete\": \"삭제\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Clear\": \"모두 해제\",\n        \"Map fields\": \"필드 매핑\",\n        \"Mapped\": \"매핑됨\",\n        \"Select all\": \"모두 선택\",\n        \"Unmap fields\": \"필드 매핑 해제\",\n        \"Unmapped\": \"매핑되지 않음\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"おっと！探しているフォームは存在しません。\",\n        \"Oops! This form is no longer published.\": \"おっと！このフォームは公開されていません。\",\n        \"There was a problem loading the form.\": \"フォームの読み込み中に問題が発生しました。\",\n        \"You don't have access to this form.\": \"このフォームへのアクセス権がありません。\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"양식 제출 중 오류가 발생했습니다. 다시 시도하세요.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"양식 제출됨\",\n        \"Thank you! Your response has been recorded.\": \"감사합니다! 응답이 기록되었습니다.\",\n        \"Submit new response\": \"새 응답 제출\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"모두 해제\",\n        \"Map fields\": \"필드 매핑\",\n        \"Mapped\": \"매핑됨\",\n        \"Select all\": \"모두 선택\",\n        \"Unmap fields\": \"필드 매핑 해제\",\n        \"Unmapped\": \"매핑되지 않음\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"위에 섹션 삽입\",\n        \"Insert section below\": \"아래에 섹션 삽입\",\n        \"### **Header**\": \"### **헤더**\",\n        \"Description\": \"설명\",\n        \"## **Header**\": \"## **제목**\"\n    },\n    \"ReferenceUtils\": {\n        \"No choices to select\": \"선택할 항목 없음\",\n        \"Error in dropdown condition\": \"드롭다운 조건 오류\",\n        \"No choices matching condition\": \"조건에 맞는 선택 항목 없음\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"열 제거\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"구성된 선택 항목 없음\",\n        \"No values in show column of referenced table\": \"참조된 표의 표시 열에 값 없음\",\n        \"Hide\": \"숨기기\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"드롭다운 조건 오류\",\n        \"No choices matching condition\": \"조건에 맞는 선택 항목 없음\",\n        \"No choices to select\": \"선택할 항목 없음\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"드롭다운 조건\",\n        \"Invalid columns: {{colIds}}\": \"잘못된 열: {{colIds}}\",\n        \"Set dropdown condition\": \"드롭다운 조건 설정\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"조건 입력.\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"초기화\",\n        \"Search\": \"검색\",\n        \"Select...\": \"선택...\",\n        \"Submit\": \"제출\"\n    },\n    \"widgetTypesMap\": {\n        \"Calendar\": \"캘린더\",\n        \"Card\": \"카드\",\n        \"Card List\": \"카드 목록\",\n        \"Chart\": \"차트\",\n        \"Custom\": \"사용자 정의\",\n        \"Form\": \"양식\",\n        \"Table\": \"표\"\n    },\n    \"TimingPage\": {\n        \"Average Time (s)\": \"평균 시간 (초)\",\n        \"Column ID\": \"열 ID\",\n        \"Formula timer\": \"수식 타이머\",\n        \"Loading timing data. Don't close this tab.\": \"시간 측정 데이터 로드 중. 이 탭을 닫지 마세요.\",\n        \"Max Time (s)\": \"최대 시간 (초)\",\n        \"Number of Calls\": \"호출 횟수\",\n        \"Table ID\": \"표 ID\",\n        \"Total Time (s)\": \"총 시간 (초)\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"관리자 패널\"\n    },\n    \"CustomWidgetGallery\": {\n        \"(Missing info)\": \"(정보 없음)\",\n        \"Add widget\": \"위젯 추가\",\n        \"Add Your Own Widget\": \"자신만의 위젯 추가\",\n        \"Add a widget from outside this gallery.\": \"이 갤러리 외부의 위젯을 추가합니다.\",\n        \"Cancel\": \"취소\",\n        \"Change widget\": \"위젯 변경\",\n        \"Choose custom widget\": \"사용자 정의 위젯 선택\",\n        \"Community Widget\": \"커뮤니티 위젯\",\n        \"Custom URL\": \"사용자 정의 URL\",\n        \"Developer:\": \"개발자:\",\n        \"Grist Widget\": \"Grist 위젯\",\n        \"Last updated:\": \"마지막 업데이트:\",\n        \"Learn more about custom widgets\": \"사용자 정의 위젯에 대해 자세히 알아보기\",\n        \"No matching widgets\": \"일치하는 위젯 없음\",\n        \"Search\": \"검색\",\n        \"Widget URL\": \"위젯 URL\"\n    },\n    \"markdown\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# 새 마크다운 함수\\n *\\n *      Grainjs 요소 *안에* _[일반적인 마크다운](https://markdownguide.org)_을\\n *      작성할 수 있습니다.\"\n            }\n        },\n        \"The toggle is **off**\": \"토글이 **꺼짐** 상태입니다\",\n        \"The toggle is **on**\": \"토글이 **켜짐** 상태입니다\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# 새 마크다운 함수\\n *\\n *      Grainjs 요소 *안에* _[일반적인 마크다운](https://markdownguide.org)_을\\n *      작성할 수 있습니다.\"\n            }\n        },\n        \"The toggle is **off**\": \"토글이 **꺼짐** 상태입니다\",\n        \"The toggle is **on**\": \"토글이 **켜짐** 상태입니다\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"취소\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"각 {{targetTable}} 레코드는 단일 {{sourceTable}} 레코드에만 할당될 수 있습니다.\",\n        \"Reassign\": \"재할당\",\n        \"Reassign to new {{sourceTable}} records.\": \"새 {{sourceTable}} 레코드에 재할당합니다.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"{{sourceTable}} 레코드 {{sourceName}}에 재할당합니다.\",\n        \"Record already assigned_one\": \"레코드가 이미 할당됨\",\n        \"Record already assigned_other\": \"레코드들이 이미 할당됨\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} 레코드 {{targetName}}은(는) 이미 {{sourceTable}} 레코드 {{oldSourceName}}에 할당되어 있습니다.\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"알 수 없는 사용자 정의 위젯에 주의하세요\",\n        \"Please review the following before adding a new custom widget.\": \"새 사용자 정의 위젯을 추가하기 전에 다음 사항을 검토하세요.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"사용자 정의 위젯은 **강력합니다**! 문서 데이터를 읽고 쓰고 다른 곳으로 전송할 수 있습니다.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"이 URL의 **리소스를 신뢰**하십니까?\",\n        \"Do you **trust the person** who shared this link?\": \"이 링크를 공유한 **사람을 신뢰**하십니까?\",\n        \"Have you **reviewed the code** at this URL?\": \"이 URL의 **코드를 검토**하셨습니까?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"확신이 없다면 이 위젯을 설치하지 마거나, 조직의 관리자에게 안전성 검토를 요청하세요.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"이 경고를 이해했으며 위험을 감수함을 확인합니다\"\n    },\n    \"Assistant\": {\n        \"upgrade your plan\": \"플랜을 업그레이드하기\",\n        \"upgrade to the Pro Team plan\": \"Pro Team 플랜으로 업그레이드\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"AI 도우미를 사용하려면 무료 Grist 계정에 가입하세요.\",\n        \"What do you need help with?\": \"무엇을 도와드릴까요?\",\n        \"Sign Up for Free\": \"무료로 가입하기\",\n        \"AI Assistant is only available for logged in users.\": \"AI 도우미는 로그인한 사용자만 사용할 수 있습니다.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"제한을 높이시려면 업그레이드하세요 {{upgradeNudge}}.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"새로운 Grist 도우미를 사용하려면 Grist Enterprise로 업그레이드하세요. {{learnMoreLink}}\",\n        \"Apply\": \"적용\",\n        \"For higher limits, contact the site owner.\": \"제한을 늘리려면 사이트 관리자에게 문의하십시오.\",\n        \"Learn more.\": \"더 알아보기.\",\n        \"Press Enter to apply suggested formula.\": \"제안 수식을 적용하려면 엔터 키를 누르세요.\",\n        \"You have used all available credits.\": \"사용 가능한 크레딧을 모두 사용하셨습니다.\",\n        \"You have {{numCredits}} remaining credits.\": \"남은 크레딧은 {{numCredits}}입니다.\",\n        \"start a new chat\": \"새로운 대화 시작하기\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin controls\": \"관리자 설정\",\n        \"Admin area\": \"관리자 영역\",\n        \"Docs\": \"문서 관리\",\n        \"Installation\": \"설치\",\n        \"Workspaces\": \"워크스페이스 관리\",\n        \"Admin Controls\": \"관리자 설정\",\n        \"Settings\": \"설정\",\n        \"Users\": \"사용자 관리\",\n        \"Learn more\": \"더 알아보기\",\n        \"Orgs\": \"조직 관리\"\n    },\n    \"apiconsole\": {\n        \"Confirm Deletion\": \"삭제 확인\",\n        \"Are you sure you want to delete the following?\": \"다음 항목을 삭제하시겠습니까?\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"정말로 이 삭제를 원하신다면 DELETE를 입력하세요.\\n확신이 없거나 이 작업이 무엇을 하는지 잘 모르겠다면, \\n취소하는 것이 좋습니다.\",\n        \"Delete\": \"삭제\",\n        \"Deletion was not confirmed, skipping.\": \"삭제가 취소되어 건너뜁니다.\",\n        \"Type DELETE here if you wish to proceed.\": \"진행하려면 DELETE를 입력해 주세요.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/ko.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"Loading...\": \"로딩 중...\",\n        \"og-title\": \"Grist, 스프레드시트의 진화\",\n        \"og-description\": \"표의 한계를 넘어선, 새로운 오픈 소스 스프레드시트\"\n    },\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"이메일 인증을 진행하고 다시 로그인해 주세요.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/nb_NO.client.json",
    "content": "{\n  \"AccountPage\": {\n    \"API\": \"API\",\n    \"API Key\": \"API-nøkkel\",\n    \"Account settings\": \"Kontoinnstillinger\",\n    \"Allow signing in to this account with Google\": \"Tillat innlogging på denne kontoen med Google\",\n    \"Change password\": \"Endre passord\",\n    \"Edit\": \"Rediger\",\n    \"Email\": \"E-post\",\n    \"Login method\": \"Innloggingsmetode\",\n    \"Name\": \"Navn\",\n    \"Password & security\": \"Passord og sikkerhet\",\n    \"Save\": \"Lagre\",\n    \"Theme\": \"Drakt\",\n    \"Two-factor authentication\": \"To-faktoridentitetsbekreftelse\",\n    \"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.\": \"To-faktoridentitetsbekreftelse er et ekstra sikkerhetslag for din Grist-konto, designet for å forsikre at kun du har tilgang til kontoen din, selv om noen tar rede på passordet.\",\n    \"Language\": \"Språk\",\n    \"Names only allow letters, numbers and certain special characters\": \"Navn tillater kun bokstaver, nummer, og noen spesialtegn\"\n  },\n  \"AccountWidget\": {\n    \"Access Details\": \"Tilgangsdetaljer\",\n    \"Accounts\": \"Kontoer\",\n    \"Add account\": \"Legg til konto\",\n    \"Document settings\": \"Dokument-innstillinger\",\n    \"Manage team\": \"Håndter lag\",\n    \"Pricing\": \"Prissetting\",\n    \"Profile settings\": \"Profilinnstillinger\",\n    \"Sign out\": \"Logg ut\",\n    \"Sign in\": \"Logg inn\",\n    \"Switch Accounts\": \"Bytt konto\",\n    \"Toggle Mobile Mode\": \"Slå av/på mobilmodus\",\n    \"Activation\": \"Aktivering\",\n    \"Support Grist\": \"Støtt Grist\",\n    \"Upgrade Plan\": \"Oppgrader plan\",\n    \"Use This Template\": \"Bruk denne malen\",\n    \"Billing account\": \"Faktureringskonto\",\n    \"Sign up\": \"Registrering\"\n  },\n  \"AddNewButton\": {\n    \"Add new\": \"Legg til ny\"\n  },\n  \"ApiKey\": {\n    \"Click to show\": \"Klikk for å vise\",\n    \"Create\": \"Opprett\",\n    \"Remove\": \"Fjern\",\n    \"Remove API Key\": \"Fjern API-nøkkel\",\n    \"By generating an API key, you will be able to make API calls for your own account.\": \"Ved å generere en API-nøkkel vil du kunne gjøre API-kall for din egen konto.\",\n    \"This API key can be used to access this account anonymously via the API.\": \"Denne API-nøkkelen kan brukes til å oppnå tilgang til dette dokumentet anonymt via API-et.\",\n    \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Denne API-nøkkelen kan brukes til å oppnå tilgang til kontoen din via API-et. Ikke del API-nøkkelen din med noen.\",\n    \"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?\": \"Du er i ferd med å slette en API-nøkkel. Dette vil medføre at alle fremtidige forespørsler som bruker denne API-nøkkelen avslås. Vil du fremdeles slette den?\"\n  },\n  \"App\": {\n    \"Description\": \"Beskrivelse\",\n    \"Key\": \"Nøkkel\",\n    \"Memory Error\": \"Minnefeil\",\n    \"Translators: please translate this only when your language is ready to be offered to users\": \"Oversettere: Oversett dette når språket ditt kan tilbys brukere\"\n  },\n  \"AppHeader\": {\n    \"Home page\": \"Hjemmeside\",\n    \"Personal Site\": \"Personlig side\",\n    \"Team Site\": \"Lagside\",\n    \"Legacy\": \"Foreldet\",\n    \"Grist Templates\": \"Grist-maler\"\n  },\n  \"CellContextMenu\": {\n    \"Clear cell\": \"Tøm celle\",\n    \"Clear values\": \"Tøm verdier\",\n    \"Copy anchor link\": \"Kopier ankerlenke\",\n    \"Delete column\": \"Slett kolonne\",\n    \"Delete row\": \"Slett rad\",\n    \"Delete {{count}} columns\": \"Slett {{count}} kolonner\",\n    \"Delete {{count}} rows\": \"Slett {{count}} rader\",\n    \"Duplicate row\": \"Dupliser rad\",\n    \"Duplicate rows\": \"Dupliser rader\",\n    \"Filter by this value\": \"Filtrer etter denne verdien\",\n    \"Insert column to the left\": \"Sett inn kolonne til venstre\",\n    \"Insert column to the right\": \"Sett inn kolonne til høyre\",\n    \"Insert row\": \"Sett inn rad\",\n    \"Insert row above\": \"Sett inn rad ovenfor\",\n    \"Insert row below\": \"Sett inn rad under\",\n    \"Reset column\": \"Tilbakestill kolonne\",\n    \"Reset entire column\": \"Tilbakestill hele kolonnen\",\n    \"Reset {{count}} columns\": \"Tilbakestill {{count}} kolonner\",\n    \"Reset {{count}} entire columns\": \"Tilbakestill {{count}} hele kolonner\",\n    \"Reset {{count}} entire columns_one\": \"Tilbakestill hele kolonnen\",\n    \"Delete {{count}} rows_one\": \"Slett rad\",\n    \"Delete {{count}} columns_one\": \"Slett kolonne\",\n    \"Delete {{count}} columns_other\": \"Slett {{count}} kolonner\",\n    \"Duplicate rows_other\": \"Dupliser rader\",\n    \"Delete {{count}} rows_other\": \"Slett {{count}} rader\",\n    \"Duplicate rows_one\": \"Dupliser rad\",\n    \"Reset {{count}} columns_one\": \"Tilbakestill kolonne\",\n    \"Reset {{count}} entire columns_other\": \"Tilbakestill {{count}} hele kolonner\",\n    \"Reset {{count}} columns_other\": \"Tilbakestill {{count}} kolonner\",\n    \"Copy\": \"Kopier\",\n    \"Comment\": \"Kommentar\",\n    \"Cut\": \"Klipp ut\",\n    \"Paste\": \"Lim inn\"\n  },\n  \"ColumnFilterMenu\": {\n    \"All\": \"Alle\",\n    \"All except\": \"Alle unntatt\",\n    \"All shown\": \"Alle viste\",\n    \"End\": \"Slutt\",\n    \"Future values\": \"Fremtidige verdier\",\n    \"Max\": \"Maks.\",\n    \"Min\": \"Min.\",\n    \"No matching values\": \"Ingen samsvarende verdier\",\n    \"None\": \"Ingen\",\n    \"Other Matching\": \"Andre som samsvarer\",\n    \"Other Non-Matching\": \"Andre som ikke samsvarer\",\n    \"Other values\": \"Andre verdier\",\n    \"Others\": \"Andre\",\n    \"Search\": \"Søk\",\n    \"Search values\": \"Søk etter verdier\",\n    \"Start\": \"Start\",\n    \"Filter by Range\": \"Filtrer etter tallfølge\"\n  },\n  \"CustomSectionConfig\": {\n    \" (optional)\": \" (valgfritt)\",\n    \"Add\": \"Legg til\",\n    \"Enter Custom URL\": \"Skriv inn egendefinert nettadresse\",\n    \"Full document access\": \"Full dokumenttilgang\",\n    \"Learn more about custom widgets\": \"Lær mer om egendefinerte miniprogrammer\",\n    \"No document access\": \"Ingen dokumenttilgang\",\n    \"Open configuration\": \"Åpne oppsett\",\n    \"Pick a column\": \"Velg en kolonne\",\n    \"Pick a {{columnType}} column\": \"Velg en {{columnType}}-kolonne\",\n    \"Read selected table\": \"Les valgt tabell\",\n    \"Select Custom Widget\": \"Velg egendefinert miniprogram\",\n    \"Widget does not require any permissions.\": \"Miniprogrammet krever ingen tilganger.\",\n    \"Widget needs to {{read}} the current table.\": \"Miniprogrammet må {{read}} nåværende tabell.\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} ikke-{{columnType}}-kolonner er ikke vist.\",\n    \"Widget needs {{fullAccess}} to this document.\": \"Miniprogrammet trenger {{fullAccess}} til dette dokumentet.\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} ikke-{{columnType}}-kolonne er ikke vist.\",\n    \"No {{columnType}} columns in table.\": \"Ingen {{columnType}}-kolonner i tabell.\",\n    \"Clear selection\": \"Tøm utvalg\"\n  },\n  \"DocHistory\": {\n    \"Activity\": \"Aktivitet\",\n    \"Beta\": \"Beta\",\n    \"Compare to previous\": \"Sammenlign med forrige\",\n    \"Snapshots\": \"Øyeblikksbilder\",\n    \"Snapshots are unavailable.\": \"Øyeblikksbilder utilgjengelig.\",\n    \"Compare to current\": \"Sammenlign med nåværende\",\n    \"Open snapshot\": \"Åpne øyeblikksbilde\"\n  },\n  \"DocMenu\": {\n    \"All documents\": \"Alle dokumenter\",\n    \"Delete {{name}}\": \"Slett {{name}}\",\n    \"Deleted {{at}}\": \"Slettet {{at}}\",\n    \"Discover More Templates\": \"Oppdag flere maler\",\n    \"Edited {{at}}\": \"Redigert {{at}}\",\n    \"Examples and Templates\": \"Eksempler og maler\",\n    \"More Examples and Templates\": \"Flere eksempler ogmaler\",\n    \"Pinned Documents\": \"Festede dokumenter\",\n    \"This service is not available right now\": \"Denne tjenesten er ikke tilgjengelig akkurat nå\",\n    \"Pin Document\": \"Fest dokument\",\n    \"Remove\": \"Fjern\",\n    \"Trash\": \"Papirkurv\",\n    \"Trash is empty.\": \"Papirkurven er tom.\",\n    \"Unpin Document\": \"Løsne dokument\",\n    \"Workspace not found\": \"Fant ikke arbeidsområdet\",\n    \"To restore this document, restore the workspace first.\": \"Gjenopprett arbeidsområdet for å gjenopprette dette dokumentet.\",\n    \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Du er på {{siteName}}-siden. Du har også tilgang til følgende sider:\",\n    \"You are on your personal site. You also have access to the following sites:\": \"Du er på din personlige side. Du har også tilgang til følgende sider:\",\n    \"You may delete a workspace forever once it has no documents in it.\": \"Du kan slette et arbeidsområde for godt siden det ikke har noen dokumenter i seg.\",\n    \"Delete\": \"Slett\",\n    \"Move\": \"Flytt\",\n    \"(The organization needs a paid plan)\": \"(Organisasjonen trenger en betalt plan)\",\n    \"Access Details\": \"Tilgangsdetaljer\",\n    \"By Date Modified\": \"Etter endringsdato\",\n    \"By Name\": \"Etter navn\",\n    \"Current workspace\": \"Nåværende arbeidsområde\",\n    \"Delete Forever\": \"Slett for godt\",\n    \"Document will be moved to Trash.\": \"Dokumentet vil bli flyttet til papirkurven.\",\n    \"Document will be permanently deleted.\": \"Dokumentet vil bli slettet for godt.\",\n    \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Dokumenter forblir i papirkurven i 30 dager. Etter dette slettes de for godt.\",\n    \"Featured\": \"Framhevet\",\n    \"Rename\": \"Gi nytt navn\",\n    \"Requires edit permissions\": \"Krever redigeringstilgang\",\n    \"Restore\": \"Gjenopprett\",\n    \"Other Sites\": \"Andre sider\",\n    \"Move {{name}} to workspace\": \"Flytt {{name}} til arbeidsområde\",\n    \"Examples & Templates\": \"Eksempler og maler\",\n    \"Manage users\": \"Håndter brukere\",\n    \"Permanently Delete \\\"{{name}}\\\"?\": \"Slett «{{name}}» for godt?\"\n  },\n  \"HomeLeftPane\": {\n    \"Examples & Templates\": \"Eksempler og maler\",\n    \"Create workspace\": \"Opprett arbeidsområde\",\n    \"Delete\": \"Slett\",\n    \"Access Details\": \"Tilgangsdetaljer\",\n    \"All documents\": \"Alle dokumenter\",\n    \"Create empty document\": \"Opprett tomt dokument\",\n    \"Delete {{workspace}} and all included documents?\": \"Slett {{workspace}} og alle inkluderte dokumenter?\",\n    \"Import document\": \"Importer dokument\",\n    \"Manage users\": \"Håndter brukere\",\n    \"Trash\": \"Papirkurv\",\n    \"Workspace will be moved to Trash.\": \"Arbeidsområdet vil bli flyttet til papirkurven.\",\n    \"Workspaces\": \"Arbeidsområder\",\n    \"Rename\": \"Gi nytt navn\",\n    \"Tutorial\": \"Veiledning\"\n  },\n  \"MakeCopyMenu\": {\n    \"It will be overwritten, losing any content not in this document.\": \"Den vil bli overskrevet, noe som forkaster alt innholdet som ikke er i dokumentet.\",\n    \"Sign up\": \"Registrering\",\n    \"Update\": \"Oppdater\",\n    \"Update Original\": \"Oppdater original\",\n    \"Original Looks Identical\": \"Originalen ser identisk ut\",\n    \"Name\": \"Navn\",\n    \"No destination workspace\": \"Inget målarbeidsrom\",\n    \"Include the structure without any of the data.\": \"Inkluder strukturen uten noe av dataen.\",\n    \"Organization\": \"Organisasjon\",\n    \"Original Has Modifications\": \"Originalen har endringer\",\n    \"The original version of this document will be updated.\": \"Den opprinnelige versjonen av dette dokumentet vil bli oppdatert.\",\n    \"To save your changes, please sign up, then reload this page.\": \"Registrer deg her og gjeninnlast siden for å lagre endringene dine.\",\n    \"Workspace\": \"Arbeidsområde\",\n    \"You do not have write access to the selected workspace\": \"Du har ikke skrivetilgang til valgt arbeidsområde\",\n    \"You do not have write access to this site\": \"Du har ikke skrivetilgang til denne siden.\",\n    \"As template\": \"Som mal\",\n    \"Cancel\": \"Avbryt\",\n    \"Enter document name\": \"Skriv inn dokumentnavn\",\n    \"Original Looks Unrelated\": \"Originalen ser urelatert ut\",\n    \"Overwrite\": \"Overskriv\",\n    \"Replacing the original requires editing rights on the original document.\": \"Erstatting av originalen krever redigeringsrettigheter til originaldokumentet.\",\n    \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Vær forsiktig, originalen har endringer som ikke finnes i dette dokumentet. De endringene vil bli overskrevet.\",\n    \"However, it appears to be already identical.\": \"Dog later det til at det allerede er identisk.\",\n    \"Download document structure only (no data, for template use)\": \"Fjern all data, men behold strukturen til bruk som mal\",\n    \"Download document without history (can significantly reduce file size)\": \"Fjern dokumenthistorikk (kan redusere filstørrelse drastisk)\",\n    \"Download document and history\": \"Last ned hele dokumentet og historikken\"\n  },\n  \"NotifyUI\": {\n    \"Cannot find personal site, sorry!\": \"Finner ikke personlig side. Beklager.\",\n    \"Upgrade Plan\": \"Oppgrader plan\",\n    \"Renew\": \"Forny\",\n    \"Give feedback\": \"Gi tilbakemeldinger\",\n    \"Ask for help\": \"Spør om hjelp\",\n    \"Report a problem\": \"Innrapporter et problem\",\n    \"Go to your free personal site\": \"Gå til din kostnadsløse personlige side\",\n    \"No notifications\": \"Ingen merknader\",\n    \"Notifications\": \"Merknader\",\n    \"Manage billing\": \"Håndter fakturering\"\n  },\n  \"RightPanel\": {\n    \"COLUMN TYPE\": \"Kolonnetype\",\n    \"CHART TYPE\": \"Diagramstype\",\n    \"CUSTOM\": \"Egendefinert\",\n    \"Change widget\": \"Endre miniprogram\",\n    \"columns_one\": \"Kolonne\",\n    \"columns_other\": \"Kolonner\",\n    \"DATA TABLE\": \"Datatabell\",\n    \"Data\": \"Data\",\n    \"Edit data selection\": \"Rediger datautvalg\",\n    \"fields_one\": \"Felt\",\n    \"fields_other\": \"Felter\",\n    \"GROUPED BY\": \"Gruppert etter\",\n    \"Row style\": \"Radstil\",\n    \"SELECTOR FOR\": \"Utvelger for\",\n    \"SOURCE DATA\": \"Kildedata\",\n    \"Save\": \"Lagre\",\n    \"Select widget\": \"Velg miniprogram\",\n    \"TRANSFORM\": \"Transformer\",\n    \"Theme\": \"Drakt\",\n    \"DATA TABLE NAME\": \"Datatabellnavn\",\n    \"SELECT BY\": \"Velg etter\",\n    \"Detach\": \"Løsne\",\n    \"series_one\": \"Serie\",\n    \"WIDGET TITLE\": \"Miniprogramsnavn\",\n    \"series_other\": \"Serier\",\n    \"Sort & filter\": \"Sorter og filtrer\",\n    \"You do not have edit access to this document\": \"Du har ikke redigeringstilgang til dette dokumentet\",\n    \"Widget\": \"Miniprogram\",\n    \"Add referenced columns\": \"Legg til kolonner å vise til\"\n  },\n  \"RowContextMenu\": {\n    \"Delete\": \"Slett\",\n    \"Copy anchor link\": \"Kopier avsnitts-lenke\",\n    \"Duplicate rows_one\": \"Dupliser rad\",\n    \"Insert row above\": \"Sett inn rad ovenfor\",\n    \"Insert row below\": \"Sett inn rad nedenfor\",\n    \"Duplicate rows_other\": \"Dupliser rader\",\n    \"Insert row\": \"Sett inn rad\"\n  },\n  \"ShareMenu\": {\n    \"Unsaved\": \"Ulagret\",\n    \"Access Details\": \"Tilgangsdetaljer\",\n    \"Back to current\": \"Tilbake til nåværende\",\n    \"Export XLSX\": \"Eksporter XLSX\",\n    \"Manage users\": \"Håndter brukere\",\n    \"Original\": \"Original\",\n    \"Save copy\": \"Lagre kopi\",\n    \"Replace {{termToUse}}...\": \"Erstatt {{termToUse}} …\",\n    \"Show in folder\": \"Vis i mappe\",\n    \"Edit without affecting the original\": \"Rediger uten innvirkning på originalen\",\n    \"Export CSV\": \"Eksporter CSV\",\n    \"Return to {{termToUse}}\": \"Gå tilbake til {{termToUse}}\",\n    \"Save Document\": \"Lagre dokument\",\n    \"Work on a copy\": \"Endre en kopi\",\n    \"Compare to {{termToUse}}\": \"Sammenlign med{{termToUse}}\",\n    \"Current Version\": \"Nåværende versjon\",\n    \"Download\": \"Last ned\",\n    \"Duplicate document\": \"Dupliser dokument\",\n    \"Send to Google Drive\": \"Send til Google Drive\",\n    \"Download...\": \"Last ned …\",\n    \"Share\": \"Del\"\n  },\n  \"ThemeConfig\": {\n    \"Switch appearance automatically to match system\": \"Bytt utseende automatisk for å samsvare med systemet\",\n    \"Appearance \": \"Utseende \"\n  },\n  \"Tools\": {\n    \"Access Rules\": \"Tilgangsregler\",\n    \"Document history\": \"Dokumenthistorikk\",\n    \"Raw data\": \"Rådata\",\n    \"TOOLS\": \"Verktøy\",\n    \"Return to viewing as yourself\": \"Gå tilbake til visning som deg selv\",\n    \"Settings\": \"Innstillinger\",\n    \"Tour of this Document\": \"Gjennomgang av dette dokumentet\",\n    \"Validate Data\": \"Bekreft data\",\n    \"Delete\": \"Slett\",\n    \"Code view\": \"Kodevising\",\n    \"How-to Tutorial\": \"Funksjonsveiledning\",\n    \"Delete document tour?\": \"Slett dokumentveivisning?\"\n  },\n  \"TriggerFormulas\": {\n    \"Any field\": \"Alle felter\",\n    \"Current field \": \"Nåværende felt \",\n    \"Cancel\": \"Avbryt\",\n    \"Close\": \"Lukk\",\n    \"OK\": \"OK\",\n    \"Apply to new records\": \"Bruk for nye oppføringer\",\n    \"Apply on changes to:\": \"Bruk for endringer av:\",\n    \"Apply on record changes\": \"Bruk for oppføringsendringer\"\n  },\n  \"TypeTransformation\": {\n    \"Revise\": \"Gjennomse\",\n    \"Preview\": \"Forhåndsvis\",\n    \"Update formula (Shift+Enter)\": \"Oppdater formel (Shift+Enter)\",\n    \"Cancel\": \"Avbryt\",\n    \"Apply\": \"Bruk\"\n  },\n  \"ValidationPanel\": {\n    \"Rule {{length}}\": \"Regel {{length}}\",\n    \"Update formula (Shift+Enter)\": \"Oppdater formel (Shift+Enter)\"\n  },\n  \"ViewAsBanner\": {\n    \"UnknownUser\": \"Ukjent bruker\"\n  },\n  \"ViewConfigTab\": {\n    \"Advanced settings\": \"Avanserte innstillinger\",\n    \"Blocks\": \"Blokker\",\n    \"Compact\": \"Kompakt\",\n    \"Edit card layout\": \"Rediger kort-oppsett\",\n    \"Make On-Demand\": \"Gjør til «ved behov»\",\n    \"Unmark On-Demand\": \"Opphev «ved behov»\",\n    \"Plugin: \": \"Programtillegg: \",\n    \"Section: \": \"Avsnitt: \",\n    \"Form\": \"Skjema\",\n    \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Store tabeller kan markeres «ved behov» for å unngå innlasting av dem inn i datamotoren.\"\n  },\n  \"errorPages\": {\n    \"Add account\": \"Legg til konto\",\n    \"Access denied{{suffix}}\": \"Tilgang nektet-{{suffix}}\",\n    \"Signed out{{suffix}}\": \"Utlogget-{{suffix}}\",\n    \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Fant ikke forespurt side.{{separator}}Sjekk nettadressen og prøv igjen.\",\n    \"Sign in\": \"Logg inn\",\n    \"Contact support\": \"Kontakt brukerstøtte\",\n    \"Go to main page\": \"Gå til hovedsiden\",\n    \"Sign in again\": \"Logg inn igjen\",\n    \"Sign in to access this organization's documents.\": \"Logg inn for å få tilgang til organisasjonens dokumenter.\",\n    \"Something went wrong\": \"Noe gikk galt\",\n    \"You are now signed out.\": \"Du er nå utlogget.\",\n    \"There was an error: {{message}}\": \"En feil oppstod: {{message}}\",\n    \"There was an unknown error.\": \"En ukjent feil oppstod.\",\n    \"You do not have access to this organization's documents.\": \"Du har ikke tilgang til denne organisasjonens dokumenter.\",\n    \"Error{{suffix}}\": \"Feil-{{suffix}}\",\n    \"Page not found{{suffix}}\": \"Fant ikke siden-{{suffix}}\",\n    \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Du er innlogget som {{email}}. Du kan logge inn med en annen konto, eller spørre en administrator om tilgang.\",\n    \"Account deleted{{suffix}}\": \"Konto slettet{{suffix}}\",\n    \"Your account has been deleted.\": \"Kontoen din har blitt slettet.\",\n    \"Sign up\": \"Logg inn\"\n  },\n  \"search\": {\n    \"Search in document\": \"Søk i dokumentet\",\n    \"Find Next \": \"Finn neste \",\n    \"Find Previous \": \"Finn forrige \",\n    \"No results\": \"Resultatløst\",\n    \"Search\": \"Søk\"\n  },\n  \"DiscussionEditor\": {\n    \"Cancel\": \"Avbryt\",\n    \"Only my threads\": \"Kun mine tråder\",\n    \"Open\": \"Åpne\",\n    \"Only current page\": \"Kun nåværende side\",\n    \"Remove\": \"Fjern\",\n    \"Reply\": \"Svar\",\n    \"Reply to a comment\": \"Besvar en kommentar\",\n    \"Resolve\": \"Løs\",\n    \"Save\": \"Lagre\",\n    \"Show resolved comments\": \"Vis løste kommentarer\",\n    \"Started discussion\": \"Startet diskusjon\",\n    \"Comment\": \"Kommentar\",\n    \"Showing last {{nb}} comments\": \"Viser de siste {{nb}} kommentarene\",\n    \"Marked as resolved\": \"Markert som løst\",\n    \"Write a comment\": \"Skriv en kommentar\",\n    \"Edit\": \"Rediger\"\n  },\n  \"FieldBuilder\": {\n    \"DATA FROM TABLE\": \"Data fra tabell\",\n    \"Save field settings for {{colId}} as common\": \"Lagre feltinnstillinger for {{colId}} som vanlige\",\n    \"CELL FORMAT\": \"Celleformat\",\n    \"Apply formula to data\": \"Bruk formel for data\",\n    \"Use separate field settings for {{colId}}\": \"Bruk egne feltinnstillinger for {{colId}}\",\n    \"Changing multiple column types\": \"Endring av flere kolonnetyper\",\n    \"Revert field settings for {{colId}} to common\": \"Tilbakestill feltinnstillinger for {{colId}} til de vanlige\",\n    \"Mixed types\": \"Blandede typer\",\n    \"Mixed format\": \"Blandet format\"\n  },\n  \"AccessRules\": {\n    \"Permission to edit document structure\": \"Tilgang til redigering av dokumentstruktur\",\n    \"Seed rules\": \"Delingsregler\",\n    \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Legg til regel som innvilger eier full tilgang når tabellregler legges til.\",\n    \"Add user attributes\": \"Legg til brukerattributter\",\n    \"Add column rule\": \"Legg til kolonneregel\",\n    \"Add Default Rule\": \"Legg til forvalgt regel\",\n    \"Add table rules\": \"Legg tiltabellregler\",\n    \"Condition\": \"Betingelse\",\n    \"Default rules\": \"Forvalgte regler\",\n    \"Delete table rules\": \"Slett tabellregler\",\n    \"Enter Condition\": \"Skriv inn betingelse\",\n    \"Everyone\": \"Alle\",\n    \"Everyone Else\": \"Alle andre\",\n    \"Invalid\": \"Ugyldig\",\n    \"Permissions\": \"Tilganger\",\n    \"Remove column {{- colId }} from {{- tableId }} rules\": \"Fjern {{- colId }}-kolonnen fra {{- tableId }}-regler\",\n    \"Remove {{- tableId }} rules\": \"Fjern {{- tableId }}-regler\",\n    \"Remove {{- name }} user attribute\": \"Fjern {{- name }}-brukerattributt\",\n    \"Reset\": \"Tilbakestill\",\n    \"Type message to display when this rule blocks an action…\": \"Skriv en melding …\",\n    \"User Attributes\": \"Brukerattributter\",\n    \"View as\": \"Vis som\",\n    \"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.\": \"Tillat alle å kopiere hele dokumentet, eller å vise det i sin helhet i fiklingsmodus.\\nNyttig for eksempler og maler, men ikke for sensitiv data.\",\n    \"Allow everyone to view Access Rules.\": \"Tillat alle å utforske tilgangsregler.\",\n    \"Attribute name\": \"Attributtnavn\",\n    \"Attribute to Look Up\": \"Attributt å utforske\",\n    \"Checking...\": \"Sjekker …\",\n    \"Rules for table \": \"Regler for tabell \",\n    \"Special rules\": \"Spesialregler\",\n    \"Saved\": \"Lagret\",\n    \"Permission to view Access Rules\": \"Tilgang til visning av tilgangsregler\",\n    \"Lookup Table\": \"Oppslagstabell\",\n    \"Lookup Column\": \"Oppslagskolonne\",\n    \"Permission to access the document in full when needed\": \"Tilgang til hele dokumentet når det trengs\",\n    \"Save\": \"Lagre\",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Tillat de som redigerer å endre struktur (f.eks. endre og slette tabeller, kolonner, oppsett), og å skrive formler, som gir tilgang til all data uavhengig av begrensninger.\",\n    \"This default should be changed if editors' access is to be limited. \": \"Dette forvalget må endres hvis de som redigerer ikke skal ha tilgang, \"\n  },\n  \"ChartView\": {\n    \"Toggle chart aggregation\": \"Veksle diagramsvisning\",\n    \"Create separate series for each value of the selected column.\": \"Opprett egne serier for hver verdi i de valgte kolonnene.\",\n    \"Each Y series is followed by a series for the length of error bars.\": \"Hver Y-serie følges av en serie for lengden for feilfeltene.\",\n    \"Each Y series is followed by two series, for top and bottom error bars.\": \"Hver Y-serie følges av to serier, for topp- og bunn-feilfelter.\",\n    \"Pick a column\": \"Velg en kolonne\",\n    \"selected new group data columns\": \"valgte nye gruppedatakolonner\"\n  },\n  \"CodeEditorPanel\": {\n    \"Access denied\": \"Tilgang nektet\",\n    \"Code View is available only when you have full document access.\": \"Kodevisning er kun tilgjengelig for deg om du har full dokumenttilgang.\"\n  },\n  \"DataTables\": {\n    \"Click to copy\": \"Klikk for å kopiere\",\n    \"Duplicate table\": \"Dupliser tabell\",\n    \"Raw Data Tables\": \"Rå-datatabeller\",\n    \"Table ID copied to clipboard\": \"Tabell-ID kopiert til utklippstavlen\",\n    \"You do not have edit access to this document\": \"Du har ikke redigeringstilgang til dette dokumentet\",\n    \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Slett {{formattedTableName}}-data,og fjern den fra alle sider?\"\n  },\n  \"DocPageModel\": {\n    \"Reload\": \"Last inn igjen\",\n    \"Document owners can attempt to recover the document. [{{error}}]\": \"Dokumenteiere kan prøve å gjenopprette dokumentet. [{{error}}]\",\n    \"Enter recovery mode\": \"Skriv inn gjenopprettingskode\",\n    \"Add empty table\": \"Legg til tom tabell\",\n    \"Add page\": \"Legg til side\",\n    \"Add widget to page\": \"Legg til miniprogram på siden\",\n    \"Error accessing document\": \"Fikk ikke tilgang til dokumentet\",\n    \"Sorry, access to this document has been denied. [{{error}}]\": \"Du er ikke innvilget tilgang til dette dokumentet. {{error}}\",\n    \"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}}]\": \"Du kan prøve å laste inn dokumentet igjen, eller bruke gjenopprettingsmodus. Gjenopprettingsmodus åpner dokumentet som fullt ut tilgjengelig for eiere, og utilgjengelig for andre. Det skrur også av formler. {{error}}\",\n    \"You do not have edit access to this document\": \"Du har ikke redigeringstilgang til dette dokumentet\"\n  },\n  \"DocumentSettings\": {\n    \"Ok\": \"OK\",\n    \"Engine (experimental {{span}} change at own risk):\": \"Motor (eksperimentell {{span}} endre på egen risiko):\",\n    \"Local currency ({{currency}})\": \"Lokal valuta ({{currency}})\",\n    \"Save and Reload\": \"Lagre og last inn igjen\",\n    \"Time Zone:\": \"Tidssone:\",\n    \"API\": \"API\",\n    \"Save\": \"Lagre\",\n    \"This document's ID (for API use):\": \"Dette dokumentets ID (for API-bruk):\",\n    \"Locale:\": \"Lokalitet:\",\n    \"Document ID copied to clipboard\": \"Dokument-ID kopiert til utklippstavlen\",\n    \"Currency:\": \"Valuta:\",\n    \"Document settings\": \"Dokumentinnstillinger\",\n    \"Manage Webhooks\": \"Håndter vevkroker\",\n    \"Webhooks\": \"Vevkroker\"\n  },\n  \"DocumentUsage\": {\n    \"Usage\": \"Bruk\",\n    \"Size of attachments\": \"Vedleggsstørrelse\",\n    \"Usage statistics are only available to users with full access to the document data.\": \"Bruksstatistikk er kun tilgjengelig for brukere med full tilgang til dokumentdataen.\",\n    \"start your 30-day free trial of the Pro plan.\": \"start din 30-dagers gratisperiode av Pro-planen.\",\n    \"Data size\": \"Datastørrelse\",\n    \"Rows\": \"Rader\",\n    \"Contact the site owner to upgrade the plan to raise limits.\": \"Kontakt eieren av siden for å oppgradere planen eller å øke grensene.\",\n    \"For higher limits, \": \"For høyere grenser, \"\n  },\n  \"FieldConfig\": {\n    \"Formula columns_one\": \"Formelkolonne\",\n    \"Make into data column\": \"Gjør til datakolonne\",\n    \"Set formula\": \"Sett formel\",\n    \"Set trigger formula\": \"Sett utløserformel\",\n    \"COLUMN BEHAVIOR\": \"Kolonneadferd\",\n    \"Empty columns_one\": \"Tom kolonne\",\n    \"Data columns_one\": \"Datakolonne\",\n    \"Data columns_other\": \"Datakolonner\",\n    \"Empty columns_other\": \"Tomme kolonner\",\n    \"Enter formula\": \"Skriv inn formel\",\n    \"Mixed Behavior\": \"Blandet adferd\",\n    \"TRIGGER FORMULA\": \"Utløserformel\",\n    \"Clear and make into formula\": \"Tøm og gjør til formel\",\n    \"Convert column to data\": \"Konverter kolonne til data\",\n    \"Convert to trigger formula\": \"Konverter til utløserformel\",\n    \"COLUMN LABEL AND ID\": \"Kolonneetikett og ID\",\n    \"Column options are limited in summary tables.\": \"Kolonnealternativer er begrenset i sammendragstabeller.\",\n    \"Clear and reset\": \"Tøm og tilbakestill\",\n    \"Formula columns_other\": \"Formelkolonner\",\n    \"DESCRIPTION\": \"Beskrivelse\"\n  },\n  \"DocTour\": {\n    \"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.\": \"Kan ikke konstruere dokumentgjennomgang fra dataen i dette dokumentet. Forsikre deg om at det er en tabell ved navn «GristDocTour» med kolonnene «Title», «Body», «Placement» og «Location».\",\n    \"No valid document tour\": \"Ingen gyldig dokumentgjennomgang\"\n  },\n  \"AppModel\": {\n    \"This team site is suspended. Documents can be read, but not modified.\": \"Denne lagsiden er sperret. Dokumenter kan leses, men ikke endres.\"\n  },\n  \"GristTooltips\": {\n    \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Bruk «𝚺»-ikonet for å opprette sammendrag (eller pivotere) tabeller, for totalsummer, eller delsummer.\",\n    \"Pinned filters are displayed as buttons above the widget.\": \"Festede filtre vises som knapper over miniprogrammet.\",\n    \"Rearrange the fields in your card by dragging and resizing cells.\": \"Endre kortrekkefølgen på siden din ved å dra og endre størrelsen på celler.\",\n    \"Selecting Data\": \"Valg av data\",\n    \"They allow for one record to point (or refer) to another.\": \"De lar én oppføring peke til (eller henvise til) en annen.\",\n    \"This is the secret to Grist's dynamic and productive layouts.\": \"Dette er nøkkelen til Grists dynamiske og produktive oppsett.\",\n    \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Bruk betingelsesmessig formatering for celler i denne kolonnen når formelbetingelser oppfylles.\",\n    \"Access Rules\": \"Tilgangsregler\",\n    \"Raw Data page\": \"Rådata-side\",\n    \"Pinning Filters\": \"Festing av filter\",\n    \"You can filter by more than one column.\": \"Du kan filtrere med mer enn én kolonne.\",\n    \"entire\": \"hele\",\n    \"relational\": \"relasjonsmessig\",\n    \"Nested Filtering\": \"Underfiltrering\",\n    \"Select the table containing the data to show.\": \"Velg tabellen som inneholder dataen å vise.\",\n    \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Å klikke på «{{EyeHideIcon}}» i hver celle skjuler feltet fra denne visningen uten sletting av den.\",\n    \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Tilgangsregler lar deg skape nyanserte regler for å bestemme hvem som kan utforske eller redigere gitte deler av dokumentet ditt.\",\n    \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Celler i en referansekolonne identifiserer alltid en {{entire}}-oppføring i den tabellen, men du kan velge hvilken kolonne fra den oppføringen du vil vise.\",\n    \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Formler som utløses i gitte fall, og lagrer utregnet verdi som data.\",\n    \"Add new\": \"Legg til ny\",\n    \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Bruk «\\\\u{1D6BA}»-ikonet for å opprette sammendrag (eller pivotere) tabeller, for totalsummer, eller delsummer.\",\n    \"Select the table to link to.\": \"Velg tabell å lenke til.\",\n    \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Klikk på «Åpne radstiler» for å bruke betingelsesmessig formatering på rader.\",\n    \"Apply conditional formatting to rows based on formulas.\": \"Bruk betingelsesmessig formatering i rader basert på formler.\",\n    \"Editing Card Layout\": \"Redigering av kort-oppsett\",\n    \"Only those rows will appear which match all of the filters.\": \"Kun rader som samsvarer med alle filtre vises.\",\n    \"Reference Columns\": \"Referansekolonner\",\n    \"Click the Add new button to create new documents or workspaces, or import data.\": \"Klikk på «Legg til ny»-knappen for å opprette nye dokumenter eller arbeidsområder, eller importere data.\",\n    \"Learn more.\": \"Lær mer.\",\n    \"Link your new widget to an existing widget on this page.\": \"Lenk ditt nye miniprogram til et eksisterende miniprogram på denne siden.\",\n    \"Linking Widgets\": \"Lenking av miniprogrammer\",\n    \"Reference columns are the key to {{relational}} data in Grist.\": \"Referansekolonner er nøkkelen til {{relational}}-data i Grist.\",\n    \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Rådatasiden lister opp alle datatabeller i dokumentet ditt, inkludert sammendragstabeller og tabeller som ikke er inkludert i sideoppsett.\",\n    \"Updates every 5 minutes.\": \"Oppdateringer hvert femte minutt.\",\n    \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Prøv ut endringer i en kopi, og avgjør så hvorvidt du vil erstatte originalen med endringene dine.\",\n    \"Unpin to hide the the button while keeping the filter.\": \"Løsne for å skjule knappen og beholde filteret.\",\n    \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Nyttig for lagring av tidsstempel eller forfatter av en ny oppføring, datarensing, med mer.\",\n    \"The total size of all data in this document, excluding attachments.\": \"Samlet størrelse av all data i dette dokumentet, fraregnet vedlegg.\",\n    \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Du kan velge en av miniprogrammene som finnes, eller bygge inn ditt eget ved å angi dets fulle nettadresse.\",\n    \"To configure your calendar, select columns for start\": {\n      \"end dates and event titles. Note each column's type.\": \"Velg kolonner for start-/slutt-dato og begivenhetsnavn. Merk deg hvilken type hver kolonne er.\"\n    },\n    \"Calendar\": \"Kalender\",\n    \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Klikk på en rad og trykk {{shortcut}} for å lage en ankerlenke som tar brukeren til en gitt celle.\",\n    \"Anchor Links\": \"Ankerlenker\",\n    \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Finner du ikke riktig kolonner? Klikk «Endre miniprogram» for å velge tabellen med begivenhetsdata.\",\n    \"Custom Widgets\": \"Egendefinerte miniprogrammer\"\n  },\n  \"ViewAsDropdown\": {\n    \"Example Users\": \"Eksempelbrukere\",\n    \"View as\": \"Vis som\",\n    \"Users from table\": \"Brukere fra tabell\"\n  },\n  \"ActionLog\": {\n    \"Action Log failed to load\": \"Kunne ikke laste inn handlingslogg\",\n    \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Kolonnen {{colId}} ble påfølgende fjernet i handling #{{action.actionNum}}\",\n    \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Tabellen {{tableId}} ble påfølgende fjernet i handling #{{actionNum}}\",\n    \"This row was subsequently removed in action {{action.actionNum}}\": \"Denne raden ble påfølgende fjernet i handling {{action.actionNum}}\",\n    \"All tables\": \"Alle tabeller\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"Bruk\",\n    \"Cancel\": \"Avbryt\",\n    \"Default cell style\": \"Forvalgt cellestil\"\n  },\n  \"Drafts\": {\n    \"Restore last edit\": \"Gjenopprett siste redigering\",\n    \"Undo discard\": \"Angre forkasting\"\n  },\n  \"DuplicateTable\": {\n    \"Copy all data in addition to the table structure.\": \"Kopier all data i tillegg til tabellstrukturen.\",\n    \"Name for new table\": \"Navn på ny tabell\",\n    \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Istedenfor duplisering av tabeller er det vanligvis bedre å dele inn data ved bruk av lenkede visninger. {{link}}\",\n    \"Only the document default access rules will apply to the copy.\": \"Kun forvalgte dokumentregler vil ha innvirkning på kopien.\"\n  },\n  \"ExampleInfo\": {\n    \"Afterschool Program\": \"Nattskoleprogram\",\n    \"Tutorial: Create a CRM\": \"Veiledning: Opprettelse av en CRM\",\n    \"Tutorial: Manage Business Data\": \"Veiledning: Håndtering av bedriftsdata\",\n    \"Investment Research\": \"Investeringsforskning\",\n    \"Lightweight CRM\": \"Lettvektig CRM\",\n    \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Sjekk ut den narrative veiledningen om hvordan man lenker data og skaper høyproduktive oppsett.\",\n    \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Sjekk ut vår relaterte veiledning om hvordan man modellerer bedriftsdata, bruker formler, og håndterer kompleksitet.\",\n    \"Tutorial: Analyze & Visualize\": \"Veiledning: Analys og virtualisering\",\n    \"Welcome to the Investment Research template\": \"Velkommen til investeringsforskningsmalen\",\n    \"Welcome to the Afterschool Program template\": \"Velkommen til nattskoleprogramsmalen\",\n    \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Sjekk vår relaterte veiledning om hvordan man oppretter sammendragstabeller og diagrammer, og også lenker til diagrammer dynamisk.\",\n    \"Welcome to the Lightweight CRM template\": \"Velkommen til malen for lettvektig CRM\"\n  },\n  \"FieldMenus\": {\n    \"Revert to common settings\": \"Gjenopprett til vanlige innstillinger\",\n    \"Use separate settings\": \"Bruk egne innstillinger\",\n    \"Using common settings\": \"Bruker vanlige innstillinger\",\n    \"Save as common settings\": \"Lagre som vanlige innstillinger\",\n    \"Using separate settings\": \"Bruker egne innstillinger\"\n  },\n  \"GridViewMenus\": {\n    \"Clear values\": \"Tøm verdier\",\n    \"Convert formula to data\": \"Konverter formel til data\",\n    \"Column Options\": \"Kolonnealternativer\",\n    \"More sort options ...\": \"Flere sorteringsalternativer …\",\n    \"Insert column to the {{to}}\": \"Sett inn kolonne i {{to}}\",\n    \"Delete {{count}} columns_one\": \"Slett kolonne\",\n    \"Freeze {{count}} more columns_one\": \"Frys én kolonne til\",\n    \"Freeze {{count}} more columns_other\": \"Frys {{count}} kolonner til\",\n    \"Hide {{count}} columns_one\": \"Skjul kolonner\",\n    \"Rename column\": \"Gi kolonnen nytt navn\",\n    \"Reset {{count}} columns_one\": \"Tilbakestill kolonne\",\n    \"Reset {{count}} columns_other\": \"Tilbakestill {{count}} kolonner\",\n    \"Reset {{count}} entire columns_one\": \"Tilbakestill hele kolonnen\",\n    \"Add to sort\": \"Legg til sortering\",\n    \"Sorted (#{{count}})_one\": \"Sortert (#{{count}})\",\n    \"Sorted (#{{count}})_other\": \"Sortert (#{{count}})\",\n    \"Freeze {{count}} columns_one\": \"Frys denne kolonnen\",\n    \"Filter Data\": \"Filtrer data\",\n    \"Show column {{- label}}\": \"Vis {{- label}}-kolonne\",\n    \"Unfreeze {{count}} columns_one\": \"Tin denne kolonnen\",\n    \"Hide {{count}} columns_other\": \"Skjul {{count}} kolonner\",\n    \"Sort\": \"Sortering\",\n    \"Delete {{count}} columns_other\": \"Slett {{count}} kolonner\",\n    \"Freeze {{count}} columns_other\": \"Frys {{count}} kolonner\",\n    \"Unfreeze {{count}} columns_other\": \"Tin {{count}} kolonner\",\n    \"Reset {{count}} entire columns_other\": \"Tilbakestill {{count}} hele kolonner\",\n    \"Unfreeze all columns\": \"Tin alle kolonner\",\n    \"Add column\": \"Legg til kolonne\",\n    \"Insert column to the right\": \"Sett inn kolonne til høyre\",\n    \"Insert column to the left\": \"Sett inn kolonne til venstre\"\n  },\n  \"GristDoc\": {\n    \"Import from file\": \"Importer fra fil\",\n    \"Added new linked section to view {{viewName}}\": \"Ny lenket avsnitt lagt til i visning {{viewName}}\",\n    \"Saved linked section {{title}} in view {{name}}\": \"Lagret lenket {{title}}-avsnitt i {{name}}-visningen\",\n    \"go to webhook settings\": \"gå til vevkroksinnstillinger\"\n  },\n  \"HomeIntro\": {\n    \"Browse Templates\": \"Utforsk maler\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Du har lesetilgang til denne siden. Det finnes for øyeblikket ingen dokumenter.\",\n    \"personal site\": \"personlig side\",\n    \"Visit our {{link}} to learn more.\": \"Besøk vår {{link}} for å lære mer.\",\n    \"Welcome to Grist!\": \"Velkommen til Grist!\",\n    \"Welcome to Grist, {{name}}!\": \"Velkommen til Grist, {{name}}.\",\n    \"Welcome to {{orgName}}\": \"Velkommen til {{orgName}}\",\n    \"{{signUp}} to save your work. \": \"{{signUp}} for å lagre arbeidet ditt. \",\n    \"Create empty document\": \"Opprett tomt dokument\",\n    \"Help Center\": \"Hjelpesenter\",\n    \"Import document\": \"Importer dokument\",\n    \"Invite Team Members\": \"Inviter lagmedlemmer\",\n    \"Sign up\": \"Registrer deg\",\n    \"Sprouts Program\": \"spire-program\",\n    \"Get started by creating your first Grist document.\": \"Begynn ved å opprette ditt første Grist-dokument.\",\n    \"Get started by exploring templates, or creating your first Grist document.\": \"Begynn ved å utforske maler, eller ved å opprette ditt første Grist-dokument.\",\n    \"Get started by inviting your team and creating your first Grist document.\": \"Begynn ved å invitere laget ditt og ved å opprette ditt første Grist-dokument.\",\n    \"Interested in using Grist outside of your team? Visit your free \": \"Interessert i bruk av Grist utenfor laget ditt? Besøk ditt kostnadsløse \",\n    \"This workspace is empty.\": \"Arbeidsområdet er tomt.\",\n    \"Any documents created in this site will appear here.\": \"Alle dokumenter opprettet i denne siden vil vises her.\",\n    \"Welcome to Grist, {{- name}}!\": \"Velkommen til Grist {{- name}}\",\n    \"Visit our {{link}} to learn more about Grist.\": \"Besøk {{link}} for å lære mer om Grist.\",\n    \"Welcome to {{- orgName}}\": \"Velkommen til {{- orgName}}\",\n    \"Sign in\": \"Logg inn\",\n    \"To use Grist, please either sign up or sign in.\": \"Logg inn eller registrer deg for å bruke Grist.\"\n  },\n  \"PageWidgetPicker\": {\n    \"Building {{- label}} widget\": \"Bygger {{- label}}-miniprogram\",\n    \"Group by\": \"Grupper etter\",\n    \"Add to page\": \"Legg til på siden\",\n    \"Select data\": \"Velg data\",\n    \"Select widget\": \"Velg miniprogram\"\n  },\n  \"ViewLayoutMenu\": {\n    \"Advanced sort & filter\": \"Avansert sortering og filtrering\",\n    \"Download as XLSX\": \"Last ned som XLSX\",\n    \"Data selection\": \"Datautvalg\",\n    \"Add to page\": \"Legg til på siden\",\n    \"Collapse widget\": \"Fold sammen miniprogram\",\n    \"Edit card layout\": \"Rediger kort-oppsett\",\n    \"Copy anchor link\": \"Kopier avsnittslenke\",\n    \"Delete record\": \"Slett oppføring\",\n    \"Delete widget\": \"Slett miniprogram\",\n    \"Download as CSV\": \"Last ned som CSV\",\n    \"Open configuration\": \"Åpne oppsett\",\n    \"Show raw data\": \"Vis rådata\",\n    \"Widget options\": \"Miniprogramsalternativer\",\n    \"Print widget\": \"Utskriftsminiprogram\"\n  },\n  \"CellStyle\": {\n    \"Mixed style\": \"Blandet stil\",\n    \"CELL STYLE\": \"Cellestil\",\n    \"Cell style\": \"Cellestil\",\n    \"Default cell style\": \"Forvalgt cellestil\",\n    \"Open row styles\": \"Åpne radstiler\",\n    \"HEADER STYLE\": \"Topptekststil\",\n    \"Header Style\": \"Topptekststil\",\n    \"Default header style\": \"Forvalgt topptekststil\"\n  },\n  \"FormulaEditor\": {\n    \"Errors in all {{numErrors}} cells\": \"Feil i alle {{numErrors}} celler\",\n    \"Column or field is required\": \"Kolonne eller felt kreves angitt\",\n    \"editingFormula is required\": \"«editingFormula» kreves\",\n    \"Errors in {{numErrors}} of {{numCells}} cells\": \"Feil i {{numErrors}} av {{numCells}} celler\",\n    \"Error in the cell\": \"Feil i cellen\",\n    \"Enter formula or {{button}}.\": \"Skriv inn formel eller {{button}}.\",\n    \"Enter formula.\": \"Skriv inn formel.\",\n    \"Expand Editor\": \"Utvid tekstbehandler\",\n    \"use AI Assistant\": \"bruk AI-assistent\"\n  },\n  \"VisibleFieldsConfig\": {\n    \"Cannot drop items into Hidden Fields\": \"Kan ikke plassere elementer i skjulte felter\",\n    \"Hidden {{label}}\": \"Skjult {{label}}\",\n    \"Show {{label}}\": \"Vis {{label}}\",\n    \"Hidden Fields cannot be reordered\": \"Skjulte felter har låst rekkefølge\",\n    \"Clear\": \"Tøm\",\n    \"Visible {{label}}\": \"Synlig {{label}}\",\n    \"Select all\": \"Velg alt\",\n    \"Hide {{label}}\": \"Skjul {{label}}\"\n  },\n  \"OpenVideoTour\": {\n    \"Grist Video Tour\": \"Grist-videogjennomgang\",\n    \"Video Tour\": \"Video-veiledning\",\n    \"YouTube video player\": \"YouTube-videospiller\"\n  },\n  \"OnBoardingPopups\": {\n    \"Finish\": \"Fullfør\",\n    \"Next\": \"Neste\"\n  },\n  \"Pages\": {\n    \"Delete data and this page.\": \"Slett data og denne siden.\",\n    \"Delete\": \"Slett\",\n    \"The following tables will no longer be visible_one\": \"Følgende tabell vil ikke lenger være synlig.\",\n    \"The following tables will no longer be visible_other\": \"Følgende tabeller vil ikke lenger være synlige.\"\n  },\n  \"PermissionsWidget\": {\n    \"Allow all\": \"Tillat alle\",\n    \"Deny all\": \"Nekt alle\",\n    \"Read only\": \"Skrivebeskyttet\"\n  },\n  \"PluginScreen\": {\n    \"Import failed: \": \"Importering mislyktes: \"\n  },\n  \"RecordLayout\": {\n    \"Updating record layout.\": \"Oppdatering av oppføringsoppsett.\"\n  },\n  \"SelectionSummary\": {\n    \"Copied to clipboard\": \"Kopiert til utklippstavlen\"\n  },\n  \"SortConfig\": {\n    \"Empty values last\": \"Tomme verdier sist\",\n    \"Update data\": \"Oppdater data\",\n    \"Natural sort\": \"Naturlig sortering\",\n    \"Use choice position\": \"Bruk valgt posisjon\",\n    \"Search Columns\": \"Søk i kolonner\",\n    \"Add column\": \"Legg til kolonne\"\n  },\n  \"SortFilterConfig\": {\n    \"Filter\": \"Filtrer\",\n    \"Revert\": \"Tilbakestill\",\n    \"Update Sort & Filter settings\": \"Oppdatering sortering- og filtreringsinnstillinger\",\n    \"Save\": \"Lagre\",\n    \"Sort\": \"Sorter\"\n  },\n  \"NumericTextBox\": {\n    \"Default currency ({{defaultCurrency}})\": \"Forvalgt valuta ({{defaultCurrency}})\",\n    \"Currency\": \"Valuta\",\n    \"Decimals\": \"Desimaler\",\n    \"Number Format\": \"Tallformat\"\n  },\n  \"Reference\": {\n    \"CELL FORMAT\": \"Celleformat\",\n    \"Row ID\": \"Rad-ID\",\n    \"SHOW COLUMN\": \"Vis kolonne\"\n  },\n  \"WelcomeTour\": {\n    \"Configuring your document\": \"Oppsett av dokumentet ditt\",\n    \"Customizing columns\": \"Oppsett av kolonner\",\n    \"Building up\": \"Oppbygging\",\n    \"Help Center\": \"Hjelpesenter\",\n    \"Reference\": \"Referanse\",\n    \"Editing Data\": \"Redigering av data\",\n    \"Enter\": \"Begynn\",\n    \"Flying higher\": \"Fly høyere\",\n    \"Add new\": \"Legg til ny\",\n    \"Share\": \"Del\",\n    \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Bruk {{addNew}} for å legge til miniprogrammer, sider, eller importere mer data. \",\n    \"Make it relational! Use the {{ref}} type to link tables. \": \"Gjør ting relasjonsbasert. Bruk {{ref}}-typen for å lenke tabeller. \",\n    \"Start with {{equal}} to enter a formula.\": \"Start med {{equal}} for å skrive inn en formel.\",\n    \"Toggle the {{creatorPanel}} to format columns, \": \"Veksle {{creatorPanel}} for å formatere kolonner, \",\n    \"Welcome to Grist!\": \"Velkommen til Grist.\",\n    \"template library\": \"mal-bibliotek\",\n    \"Use the Share button ({{share}}) to share the document or export data.\": \"Bruk delingsknappen ({{share}}) for å dele dokumentet eller eksportere data.\",\n    \"Use {{helpCenter}} for documentation or questions.\": \"Bruk {{helpCenter}} for dokumentasjon eller spørsmål.\",\n    \"Sharing\": \"Deling\",\n    \"creator panel\": \"skaper-panel\",\n    \"convert to card view, select data, and more.\": \"konverter til kortvisning, velg data, med mer.\",\n    \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Sett formateringsalternativer, formler, eller kolonnetyper. Dette kan være datoer, valg, eller vedlegg. \",\n    \"Double-click or hit {{enter}} on a cell to edit it. \": \"Dobbeltklikk eller trykk {{enter}} på en celle for å redigere den. \",\n    \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Utforsk vårt {{templateLibrary}} for å oppdage hva som er mulig og for å få inspirasjon.\"\n  },\n  \"WelcomeQuestions\": {\n    \"Finance & Accounting\": \"Finans og bokføring\",\n    \"What brings you to Grist? Please help us serve you better.\": \"Hva bringer deg til Grist? Hjelp oss å tjene dine behov.\",\n    \"Other\": \"Annet\",\n    \"Type here\": \"Skriv her\",\n    \"IT & Technology\": \"IT og teknologi\",\n    \"HR & Management\": \"Stabsforvaltning og håndtering\",\n    \"Product Development\": \"Produktutvikling\",\n    \"Research\": \"Forskning\",\n    \"Sales\": \"Salg\",\n    \"Education\": \"Utdanning\",\n    \"Media Production\": \"Mediaproduksjon\",\n    \"Marketing\": \"Markedsføring\",\n    \"Welcome to Grist!\": \"Velkommen til Grist.\"\n  },\n  \"WidgetTitle\": {\n    \"Cancel\": \"Avbryt\",\n    \"DATA TABLE NAME\": \"Datatabellnavn\",\n    \"Save\": \"Lagre\",\n    \"Provide a table name\": \"Angi et tabellnavn\",\n    \"Override widget title\": \"Overstyr miniprogramsnavn\",\n    \"WIDGET TITLE\": \"Miniprogramsnavn\",\n    \"WIDGET DESCRIPTION\": \"Miniprogramsbeskrivelse\"\n  },\n  \"menus\": {\n    \"Select fields\": \"Velg felter\",\n    \"* Workspaces are available on team plans. \": \"* Arbeidsområder er tilgjengelige i lag-planer. \",\n    \"Upgrade now\": \"Oppgrader nå\",\n    \"Numeric\": \"Numerisk\",\n    \"Text\": \"Tekst\",\n    \"Integer\": \"Heltall\",\n    \"Toggle\": \"Veksle\",\n    \"Date\": \"Dato\",\n    \"Attachment\": \"Vedlegg\",\n    \"Any\": \"Alle\",\n    \"Reference\": \"Referanse\",\n    \"Reference List\": \"Referanseliste\",\n    \"Choice List\": \"Liste med valg\",\n    \"Choice\": \"Valg\",\n    \"DateTime\": \"Datotid\"\n  },\n  \"DescriptionConfig\": {\n    \"DESCRIPTION\": \"Beskrivelse\"\n  },\n  \"SiteSwitcher\": {\n    \"Switch Sites\": \"Bytt sider\",\n    \"Create new team site\": \"Opprett ny lagside\"\n  },\n  \"UserManagerModel\": {\n    \"No Default Access\": \"Ingen forvalgt tilgang\",\n    \"None\": \"Ingen\",\n    \"Owner\": \"Eier\",\n    \"View only\": \"Kun visning\",\n    \"Viewer\": \"Viser\",\n    \"View & edit\": \"Vis og rediger\",\n    \"Editor\": \"Redigerer\",\n    \"In full\": \"Fullstendig\"\n  },\n  \"ACLUsers\": {\n    \"Users from table\": \"Brukere fra tabell\",\n    \"View as\": \"Vis som\",\n    \"Example Users\": \"Eksempelbruker\"\n  },\n  \"TypeTransform\": {\n    \"Apply\": \"Bruk\",\n    \"Cancel\": \"Avbryt\",\n    \"Update formula (Shift+Enter)\": \"Oppdater formel (Shift+Enter)\",\n    \"Preview\": \"Forhåndsvisning\",\n    \"Revise\": \"Gjennomse\"\n  },\n  \"ConditionalStyle\": {\n    \"Add another rule\": \"Legg til enda en regel\",\n    \"Add conditional style\": \"Legg til betingelsesmessig stil\",\n    \"Error in style rule\": \"Feil i stilregel\",\n    \"Row style\": \"Radstil\",\n    \"Rule must return True or False\": \"Regelen må returnere enten riktig eller feil\"\n  },\n  \"HyperLinkEditor\": {\n    \"[link label] url\": \"[link label]-nettadresse\"\n  },\n  \"FieldEditor\": {\n    \"Unable to finish saving edited cell\": \"Kunne ikke fullføre lagring av redigert celle\",\n    \"It should be impossible to save a plain data value into a formula column\": \"Det skal være umulig å lagre vanlige dataverdier i en formelkolonne\"\n  },\n  \"RecordLayoutEditor\": {\n    \"Save layout\": \"Lagre oppsett\",\n    \"Add field\": \"Legg til felt\",\n    \"Create new field\": \"Opprett nytt felt\",\n    \"Show field {{- label}}\": \"Vis {{- label}}-felt\",\n    \"Cancel\": \"Avbryt\"\n  },\n  \"breadcrumbs\": {\n    \"recovery mode\": \"gjenopprettingsmodus\",\n    \"unsaved\": \"ulagret\",\n    \"override\": \"overstyringsmodus\",\n    \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Du kan gjøre redigeringer, men de vil opprette en ny kopi og ikke\\nha innvirkning på originaldokumentet.\",\n    \"fiddle\": \"fiklemodus\",\n    \"snapshot\": \"øyeblikksbilde\"\n  },\n  \"duplicatePage\": {\n    \"Note that this does not copy data, but creates another view of the same data.\": \"Merk at dette ikke kopierer dataen, men oppretter en annen visning av samme data.\",\n    \"Duplicate page {{pageName}}\": \"Dupliser {{pageName}}-siden\"\n  },\n  \"ColumnInfo\": {\n    \"COLUMN ID: \": \"Kolonne-ID: \",\n    \"COLUMN DESCRIPTION\": \"Kolonnebeskrivelse\",\n    \"COLUMN LABEL\": \"Kolonneetikett\",\n    \"Cancel\": \"Avbryt\",\n    \"Save\": \"Lagre\"\n  },\n  \"ACUserManager\": {\n    \"Enter email address\": \"Skriv inn e-postadresse\",\n    \"Invite new member\": \"Inviter nytt medlem\",\n    \"We'll email an invite to {{email}}\": \"Invitasjon per e-post vil bli sendt til {{email}}\"\n  },\n  \"Importer\": {\n    \"Merge rows that match these fields:\": \"Flett rader som samsvarer med disse feltene:\",\n    \"Select fields to match on\": \"Velg felter å jamføre\",\n    \"Update existing records\": \"Oppdater eksisterende oppføringer\",\n    \"Column mapping\": \"Kolonnetilknytning\",\n    \"Grist column\": \"Grist-kolonne\",\n    \"{{count}} unmatched field_one\": \"{{count}} usamsvarende felt\",\n    \"{{count}} unmatched field in import_one\": \"{{count}} usamsvarende felt i import\",\n    \"Revert\": \"Angre\",\n    \"Skip Import\": \"Hopp over import\",\n    \"{{count}} unmatched field_other\": \"{{count}} usamsvarende felter\",\n    \"New Table\": \"Ny tabell\",\n    \"Skip\": \"Hopp over\",\n    \"Column Mapping\": \"Kolonnetilknytning\",\n    \"Destination table\": \"Måltabell\",\n    \"Skip Table on Import\": \"Hopp over tabell ved import\",\n    \"Import from file\": \"Importer fra fil\",\n    \"{{count}} unmatched field in import_other\": \"{{count}} usamsvarende felt i import\",\n    \"Source column\": \"Kildekolonne\"\n  },\n  \"LeftPanelCommon\": {\n    \"Help Center\": \"Hjelpesenter\"\n  },\n  \"RefSelect\": {\n    \"Add column\": \"Legg til kolonne\",\n    \"No columns to add\": \"Ingen kolonner å legge til\"\n  },\n  \"TopBar\": {\n    \"Manage team\": \"Håndter lag\"\n  },\n  \"ViewSectionMenu\": {\n    \"(empty)\": \"(tom)\",\n    \"Revert\": \"Tilbakestill\",\n    \"Save\": \"Lagre\",\n    \"SORT\": \"Sorter\",\n    \"Update Sort&Filter settings\": \"Oppdater sortering- og filtreringsinnstillinger\",\n    \"(customized)\": \"(egendefinert)\",\n    \"(modified)\": \"(endret)\",\n    \"Custom options\": \"Egendefinerte alternativer\",\n    \"FILTER\": \"Filtrer\"\n  },\n  \"modals\": {\n    \"Save\": \"Lagre\",\n    \"Cancel\": \"Avbryt\",\n    \"Ok\": \"OK\"\n  },\n  \"pages\": {\n    \"Remove\": \"Fjern\",\n    \"Rename\": \"Gi nytt navn\",\n    \"You do not have edit access to this document\": \"Du har ikke redigeringstilgang til dette dokumentet\",\n    \"Duplicate page\": \"Duplikatside\"\n  },\n  \"NTextBox\": {\n    \"true\": \"riktig\",\n    \"false\": \"feil\"\n  },\n  \"ChoiceTextBox\": {\n    \"CHOICES\": \"Valg\"\n  },\n  \"ColumnEditor\": {\n    \"COLUMN DESCRIPTION\": \"Kolonnebeskrivelse\",\n    \"COLUMN LABEL\": \"Kolonneetikett\"\n  },\n  \"CurrencyPicker\": {\n    \"Invalid currency\": \"Ugyldig valuta\"\n  },\n  \"LanguageMenu\": {\n    \"Language\": \"Språk\"\n  },\n  \"FilterConfig\": {\n    \"Add column\": \"Legg til kolonne\"\n  },\n  \"FilterBar\": {\n    \"SearchColumns\": \"Søk i kolonner\",\n    \"Search Columns\": \"Søk i kolonner\"\n  },\n  \"GridOptions\": {\n    \"Grid Options\": \"Rutenettsvalg\",\n    \"Horizontal gridlines\": \"Vannrette rutenettslinjer\",\n    \"Vertical gridlines\": \"Loddrette rutenettslinjer\",\n    \"Zebra stripes\": \"Zebra-striper\"\n  },\n  \"sendToDrive\": {\n    \"Sending file to Google Drive\": \"Sender filer til Google Drive …\"\n  },\n  \"EditorTooltip\": {\n    \"Convert column to formula\": \"Konverter kolonne til formel\"\n  },\n  \"FormulaAssistant\": {\n    \"Data\": \"Data\",\n    \"Press Enter to apply suggested formula.\": \"Trykk Enter for å bruke foreslått formel.\",\n    \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Sjekk {{helpFunction}} og {{formulaCheat}}, eller besøk {{community}} for mer hjelp.\",\n    \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Registrer deg for en gratis Grist-konto for å bruke AI-formelassistenten.\",\n    \"Clear conversation\": \"Tøm samtale\",\n    \"New Chat\": \"Ny sludring\",\n    \"Code view\": \"Kodevisning\",\n    \"Apply\": \"Bruk\",\n    \"Learn more\": \"Lær mer\",\n    \"Regenerate\": \"Regenerer\",\n    \"Community\": \"Gemenskap\",\n    \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Jeg kan kun hjelpe deg med formler. Jeg kan ikke bygge tabeller, kolonner, visninger, eller skrive tilgangsregler.\",\n    \"Hi, I'm the Grist Formula AI Assistant.\": \"Hei. Jeg er Grists AI-formelassistent.\",\n    \"Preview\": \"Forhåndsvis\",\n    \"Ask the bot.\": \"Spør botten.\",\n    \"Function List\": \"Funksjonsliste\",\n    \"For higher limits, contact the site owner.\": \"Kontakt sidens eier for høyere grenser.\",\n    \"Tips\": \"Tips\",\n    \"Save\": \"Lagre\",\n    \"Sign Up for Free\": \"Registrer deg gratis\",\n    \"Formula Cheat Sheet\": \"Formel-jukseark\",\n    \"Grist's AI Assistance\": \"Grists AI-assistanse\",\n    \"Formula AI Assistant is only available for logged in users.\": \"AI-formelassistenten er kun tilgjengelig for innloggede brukere.\",\n    \"Grist's AI Formula Assistance. \": \"Grists AI-formelassistanse \",\n    \"upgrade to the Pro Team plan\": \"oppgrader til proff lagplan\",\n    \"You have used all available credits.\": \"Du har brukt alle tilgjengelige planpoeng.\",\n    \"upgrade your plan\": \"oppgrader din plan\",\n    \"Formula Help. \": \"Formelhjelp. \",\n    \"You have {{numCredits}} remaining credits.\": \"Du har {{numCredits}} planpoeng igjen.\",\n    \"Capabilities\": \"Ferdigeter\",\n    \"What do you need help with?\": \"Hva trenger du hjelp med?\",\n    \"Cancel\": \"Avbryt\",\n    \"Need help? Our AI assistant can help.\": \"Hjelp? Vår AI-assistent kan hjelpe deg.\",\n    \"AI Assistant\": \"AI-assistant\",\n    \"For higher limits, {{upgradeNudge}}.\": \"{{upgradeNudge}} for høyere grenser.\",\n    \"There are some things you should know when working with me:\": \"Det er noen ting du burde vite når du jobber med meg:\"\n  },\n  \"FloatingPopup\": {\n    \"Maximize\": \"Maksimer\",\n    \"Minimize\": \"Minimer\"\n  },\n  \"Clipboard\": {\n    \"Unavailable Command\": \"Utilgjengelig kommando\",\n    \"Got it\": \"Skjønner\"\n  },\n  \"SupportGristPage\": {\n    \"You have opted out of telemetry.\": \"Du har reservert deg mot datainnsamling.\",\n    \"Support Grist\": \"Støtt Grist\",\n    \"Opt out of Telemetry\": \"Skru av datainnsamling\",\n    \"GitHub Sponsors page\": \"GitHub Sponsor-side\",\n    \"Sponsor Grist Labs on GitHub\": \"Spons Grist Labs på GitHub\",\n    \"Manage Sponsorship\": \"Håndter sponsing\",\n    \"Help Center\": \"Hjelpesenter\",\n    \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Kun bruksstatistikk samles inn og aldri dokumentinnhold. Dette er beskrevet nøye på {{link}}.\",\n    \"You can opt out of telemetry at any time from this page.\": \"Du kan motsette deg datainnsamling når som helst fra denne siden.\",\n    \"Home\": \"Hjem\",\n    \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Denne instansen har reservert seg mot datainnsamling. Kun sidens administrator har tilgang til å endre dette.\",\n    \"Telemetry\": \"Telemetri\",\n    \"Opt in to Telemetry\": \"Tillat datainnsamling\",\n    \"You have opted in to telemetry. Thank you!\": \"Du har reservert deg mot datainnsamling. Takk.\",\n    \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Denne instansen har påslått datainnsamling. Kun sidens administrator har mulighet til å endre dette.\",\n    \"GitHub\": \"GitHub\"\n  },\n  \"UserManager\": {\n    \"Anyone with link \": \"Alle med lenken \",\n    \"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}}.\": \"Når du har fjernet din egen tilgang             vil du ikke kunne logge inn igjen uten hjelp              fra noen andre som har tilstrekkelig tilgang til {{name}}.\",\n    \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} av {{limitTop}} {{collaborator}}s\",\n    \"Your role for this team site\": \"Din rolle på denne lagsiden\",\n    \"Copy link\": \"Kopier lenke\",\n    \"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.\": \"Bruker har visningstilgang for {{resource}}, som følge av manuelt satt tilgang til ressursene som finnes der. Hvis fjernet her vil brukeren miste tilgang til ressursene i {{resource}}.\",\n    \"User may not modify their own access.\": \"Brukere kan ikke endre egen tilgang.\",\n    \"member\": \"medlem\",\n    \"Add {{member}} to your team\": \"Legg til {{member}} på laget ditt\",\n    \"Collaborator\": \"Medarbeider\",\n    \"Link copied to clipboard\": \"Lenke kopiert til utklippstavlen\",\n    \"team site\": \"lagside\",\n    \"Create a team to share with more people\": \"Opprett et lag for å dele med flere\",\n    \"guest\": \"gjest\",\n    \"Public access: \": \"Offentlig tilgang: \",\n    \"Team member\": \"Lagmedlem\",\n    \"Manage members of team site\": \"Håndter medlemmer av lagside\",\n    \"Off\": \"Av\",\n    \"free collaborator\": \"ledig medarbeider\",\n    \"Save & \": \"Lagre og \",\n    \"Outside collaborator\": \"Medarbeider annensteds fra\",\n    \"{{collaborator}} limit exceeded\": \"{{collaborator}}-grense overskredet\",\n    \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Bruker nedarver tilganger fra {{parent})}. Fjern ved å sette           «Nedarv tilgang» til «Ingen».\",\n    \"Your role for this {{resourceType}}\": \"Din rolle i denne {{resourceType}}\",\n    \"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}}.\": \"Når du har fjernet din egen tilgang vil du ikke kunne logge inn igjen uten hjelp fra noen andre som har tilstrekkelig tilgang til {{resourceType}}.\",\n    \"Close\": \"Lukk\",\n    \"Allow anyone with the link to open.\": \"Tillat alle med lenken å åpne.\",\n    \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Ingen forvalgt tilgang tillater tilgang å         innvilges til individuelle dokumenter eller arbeidsoråder, snarere enn hele lagsiden.\",\n    \"Invite people to {{resourceType}}\": \"Inviter noen til {{resourceType}}\",\n    \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Offentlig tilgang nedarvet fra {{parent}}. Fjern den ved å sette «Nedarv tilgang» til «Ingen».\",\n    \"Remove my access\": \"Fjern min tilgang\",\n    \"Public access\": \"Offentlig tilgang\",\n    \"Cancel\": \"Avbryt\",\n    \"Grist support\": \"Grist-støtte\",\n    \"You are about to remove your own access to this {{resourceType}}\": \"Du er i ferd med å fjerne din egen tilgang til denne {{resourceType}}\",\n    \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Bruker nedarver tilganger fra {{parent}}. For å fjerne sett «Nedarv tilgang» til «Ingen».\",\n    \"Guest\": \"Gjest\",\n    \"Invite multiple\": \"Inviter flere\",\n    \"Confirm\": \"Bekreft\",\n    \"On\": \"På\",\n    \"Open Access Rules\": \"Åpne tilgangsregler\",\n    \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Ingen forvalgt tilgang tillater tilgang å innvilges til individuelle dokumenter eller arbeidsoråder, snarere enn hele lagsiden.\"\n  },\n  \"PagePanels\": {\n    \"Open creator panel\": \"Åpne skaperpanel\",\n    \"Close Creator Panel\": \"Lukk skaperpanel\"\n  },\n  \"buildViewSectionDom\": {\n    \"Not all data is shown\": \"Ikke all dataen vises\",\n    \"No row selected in {{title}}\": \"Ingen rader valgt i {{title}}\",\n    \"No data\": \"Ingen data\"\n  },\n  \"ColumnTitle\": {\n    \"Column ID copied to clipboard\": \"Kolonne-ID kopiert til utklippstavle\",\n    \"Add description\": \"Legg til beskrivelse\",\n    \"Column description\": \"Kolonnebeskrivelse\",\n    \"COLUMN ID: \": \"Kolonne-ID: \",\n    \"Provide a column label\": \"Angi en kolonneetikett\",\n    \"Close\": \"Lukk\",\n    \"Cancel\": \"Avbryt\",\n    \"Column label\": \"Kolonneetikett\",\n    \"Save\": \"Lagre\"\n  },\n  \"SupportGristNudge\": {\n    \"Support Grist\": \"Støtt Grist\",\n    \"Close\": \"Lukk\",\n    \"Opt in to Telemetry\": \"Tillat datainnsamling\",\n    \"Help Center\": \"Hjelpesenter\",\n    \"Opted In\": \"Tillatt\",\n    \"Contribute\": \"Bidra\",\n    \"Support Grist page\": \"«Støtt Grist»-siden\"\n  },\n  \"WelcomeSitePicker\": {\n    \"You have access to the following Grist sites.\": \"Du har tilgang til følgende Grist-sider.\",\n    \"Welcome back\": \"Velkommen tilbake\",\n    \"You can always switch sites using the account menu.\": \"Du kan alltid bytte sider fra kontomenyen.\"\n  },\n  \"FieldContextMenu\": {\n    \"Copy anchor link\": \"Kopier ankerlenke\",\n    \"Hide field\": \"Skjul felt\",\n    \"Copy\": \"Kopier\",\n    \"Paste\": \"Lim inn\",\n    \"Clear field\": \"Tøm felt\",\n    \"Cut\": \"Klipp ut\"\n  },\n  \"DescriptionTextArea\": {\n    \"DESCRIPTION\": \"Beskrivelse\"\n  },\n  \"WebhookPage\": {\n    \"Webhook settings\": \"Vevkroksinnstillinger\",\n    \"Clear queue\": \"Tøm kø\"\n  },\n  \"FloatingEditor\": {\n    \"Collapse Editor\": \"Fold sammen tekstbehandler\"\n  },\n  \"GridView\": {\n    \"Click to insert\": \"Klikk for å sette inn\"\n  },\n  \"searchDropdown\": {\n    \"Search\": \"Søk\"\n  },\n  \"SearchModel\": {\n    \"Search all tables\": \"Søk i alle tabeller\",\n    \"Search all pages\": \"Søk på alle sider\"\n  }\n}\n"
  },
  {
    "path": "static/locales/nb_NO.server.json",
    "content": "{\n}\n"
  },
  {
    "path": "static/locales/nl.client.json",
    "content": "{\n  \"ACUserManager\": {\n    \"Invite new member\": \"Nieuw lid uitnodigen\",\n    \"We'll email an invite to {{email}}\": \"We sturen een uitnodiging naar {{email}}\",\n    \"Enter email address\": \"E-mailadres ingeven\"\n  },\n  \"AccessRules\": {\n    \"Add column rule\": \"Kolomregel toevoegen\",\n    \"Add table rules\": \"Tabelregels toevoegen\",\n    \"Add user attributes\": \"Voeg gebruikerskenmerken toe\",\n    \"Add Default Rule\": \"Standaardregel Toevoegen\"\n  },\n  \"RightPanelUtils\": {\n    \"series_one\": \"Serie\",\n    \"series_other\": \"Serie\",\n    \"columns_one\": \"Kolommen\",\n    \"columns_other\": \"Kolommen\",\n    \"fields_one\": \"Velden\",\n    \"fields_other\": \"Velden\"\n  }\n}\n"
  },
  {
    "path": "static/locales/nl.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"og-title\": \"Grist, de evolutie van spreadsheets\",\n        \"Loading...\": \"Aan het laden...\",\n        \"og-description\": \"Een modern, open source spreadsheet dat verder gaat dan de matrix van cellen\"\n    },\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Verifieer uw e-mailadres bij de identiteitsprovider en log opnieuw in.\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"U heeft geen toegang tot dit document.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/pl.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Enter email address\": \"Wpisz adres email\",\n        \"Invite new member\": \"Zaproś nowego użytkownika\",\n        \"We'll email an invite to {{email}}\": \"Zaproszenie wyślemy mailem do {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Add column rule\": \"Dodaj regułę kolumny\",\n        \"Add Default Rule\": \"Dodaj domyślną regułę\",\n        \"Add table rules\": \"Dodaj reguły tabeli\",\n        \"Add user attributes\": \"Dodawanie atrybutów użytkownika\",\n        \"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.\": \"Pozwól innym skopiować lub obejrzeć cały dokument w trybie roboczym.\\nOpcja przydatna dla przykładów i szablonów, ale nie powinna być używana dla wrażliwych danych.\",\n        \"Allow everyone to view Access Rules.\": \"Zezwalaj wszystkim na wyświetlanie reguł dostępu.\",\n        \"Attribute name\": \"Nazwa atrybutu\",\n        \"Attribute to Look Up\": \"Atrybut do wyszukania\",\n        \"Checking...\": \"Sprawdzanie…\",\n        \"Condition\": \"Reguła\",\n        \"Default rules\": \"Reguły domyślne\",\n        \"Delete table rules\": \"Usuń reguły tabeli\",\n        \"Enter Condition\": \"Wprowadź regułę\",\n        \"Everyone\": \"Wszyscy\",\n        \"Everyone Else\": \"Pozostali\",\n        \"Invalid\": \"Nieprawidłowy\",\n        \"Lookup Column\": \"Kolumna wyszukiwania\",\n        \"Lookup Table\": \"Tabela\",\n        \"Permission to access the document in full when needed\": \"Pozwolenie na pełny dostęp do dokumentu w razie potrzeby\",\n        \"Permission to view Access Rules\": \"Uprawnienia do przeglądania reguł dostępu\",\n        \"Permissions\": \"Uprawnienia\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Usuń kolumnę {{- colId }} z reguł {{- tableId }}\",\n        \"Remove {{- tableId }} rules\": \"Usuń reguły {{- tableId }}\",\n        \"Remove {{- name }} user attribute\": \"Usuń atrybut użytkownika {{- name }}\",\n        \"Reset\": \"Reset\",\n        \"Rules for table \": \"Reguły dotyczące tabeli \",\n        \"Save\": \"Zapisz\",\n        \"Saved\": \"Zapisano\",\n        \"Special rules\": \"Reguły specjalne\",\n        \"Type message to display when this rule blocks an action…\": \"Wpisz wiadomość…\",\n        \"User Attributes\": \"Atrybuty użytkownika\",\n        \"View as\": \"Wyświetl jako\",\n        \"Seed rules\": \"Reguły początkowe\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Automatycznie dodawaj regułę przyznającą właścicielowi pełen dostęp.\",\n        \"Permission to edit document structure\": \"Uprawnienie do edycji struktury dokumentu\",\n        \"This default should be changed if editors' access is to be limited. \": \"Tę wartość domyślną należy zmienić, jeśli dostęp edytorów ma być ograniczony. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Zezwalaj edytorom na edycję struktury (np. modyfikowanie i usuwanie tabel, kolumn i układów) oraz pisanie formuł. Niezależnie od uprawnień ustawionych na poziomie tabel i kolumn, formuły nadal mogą być edytowane i mają dostęp do wszystkich danych.\",\n        \"Add table-wide rule\": \"Dodaj regułę dla całej tabeli\",\n        \"All\": \"Wszystkie\",\n        \"Columns\": \"Kolumny\",\n        \"Condition cannot be blank\": \"Warunek nie może być pusty\",\n        \"hidden\": \"ukryty\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Zasady dostępu\\n\\nNie masz uprawnień do dostępu lub edycji tego dokumentu.\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Zasady specjalne** (rozszerz każdą regułę aby sprawdzić kogo ona dotyczy)\",\n        \"Allow everyone to view access rules.\": \"Pozwól wszystkim zobaczyć zasady dostępu.\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Reguły dostępu zmieniły się. Kliknij przycisk Reset, aby odrzucić zmiany i odświeżyć reguły.\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"Kolumna {{colId}} występuje w wielu regułach dla tabeli {{tableId}} która może być zależna od kolejności. Spróbować rozdzielić reguły w inny sposób?\",\n        \"Default resource missing in resource map\": \"Nie znaleziono domyślnego zasobu w mapie zasobów\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Nieprawidłowe kolumny w tabeli {{tableId}}: {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"Nieprawidłowa tabela: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Nieprawidłowa zasada atrybutu użytkownika: należy ustawić {{prop}}\",\n        \"Not a valid user attribute\": \"Nieprawidłowy atrybut użytkownika\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Nie znaleziono zasobu w mapie zasobów: {{resourceKey}}\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"Po wyłączeniu reguł dostępu, edytorzy będą mogli modyfikować strukturę dokumentu i edytować formuły. Edytorzy i osoby wyświetlające będą mogły zobaczyć treść dokumentu, a także skopiować lub pobrać go.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"Po włączeniu reguł dostępu, edytorzy nie będą mogli zmieniać struktury dokumentu lub\\nedytować formuł. Tylko właściciele będą mogli skopiować lub pobrać dokument.\\n\\nTe ustawienia można zmienić pod \\\"Reguły specjalne\\\".\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"Pozwól edytorom na edytowanie struktury (np. modyfikację i usuwanie tabel, kolumn i układów) i pisanie formuł.  Uwaga: po zaznaczeniu, edytorzy będą mogli edytować formuły mające dostęp do wszystkich danych, niezależnie od reguł dostępu tabel i kolumn!\",\n        \"Continue\": \"Kontynuuj\",\n        \"Disable Access Rules\": \"Wyłącz reguły dostępu\",\n        \"Disable and save\": \"Wyłącz i zapisz\",\n        \"Enable Access Rules\": \"Włącz reguły dostępu\",\n        \"Permission to access the document in full by all users\": \"Uprawnienia na dostęp do dokumentu w całości przez wszystkich użytkowników\",\n        \"Permission to access the document in full by unrestricted users\": \"Uprawnienia na dostęp do dokumentu w całości przez nieograniczonych użytkowników\",\n        \"Special rules for templates\": \"Specjalne reguły dla szablonów\",\n        \"This options should be off if Editors' access is to be limited. \": \"Ta opcja powinna być wyłączona, jeśli dostęp dla edytorów ma być ograniczony. \",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Reguły dostępu\\n\\nPodstawowy dostęp do tego dokumentu może być regulowany opcją \\\"Zarządzanie użytkownikami\\\" w menu udostępniania, gdzie możesz przypisać role współpracy, takie jak Właściciel, Edytor, czy Czytelnik.\\n\\n\\nDla bardziej zaawansowanej kontroli, możesz stworzyć reguły dostępu, które określą kto może wyświetlać lub edytować poszczególne tabele, \\nkolumny czy wiersze — przydatne dla wrażliwych danych lub uprawnień opierających się na rolach.\\n\\n[Dowiedz się więcej]({{helpAccessRules}})\",\n        \"Invalid user attribute to look up\": \"Nie można wyszukać tego atrybutu użytkownika\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"Użyj prostego atrybutu user.LinkKey, np. user.LinkKey.something\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Pomiń wszystkie ograniczenia odczytu i pozwól wszystkim na kopiowanie całego dokumentu, lub wyświetlanie go w trybie fiddle. Opcja do użytku tylko dla przykładów i szablonów, nie dokumentów z wrażliwymi danymi.\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Zabroń użytkownikom bez roli właściciela kopiowania lub pobierania całego dokumentu. Uwaga: ta opcja dotyczy wyłącznie użytkowników bez ograniczeń odczytu, ponieważ inni będą ograniczeni niezależnie od tego ustawienia.\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"Brak kolumn w regule kolumn dla tabeli {{tableId}}\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"Próba dodania reguł tabeli dla istniejącej tabeli {{tableId}}\"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"Klucz API\",\n        \"Account settings\": \"Ustawienia konta\",\n        \"Allow signing in to this account with Google\": \"Pozwól na zalogowanie się na to konto za pomocą Google\",\n        \"Change password\": \"Zmiana hasła\",\n        \"Edit\": \"Edytuj\",\n        \"Email\": \"Email\",\n        \"Login method\": \"Metoda logowania\",\n        \"Name\": \"Nazwa\",\n        \"Names only allow letters, numbers and certain special characters\": \"W nazwach dozwolone są tylko litery, cyfry i niektóre znaki specjalne\",\n        \"Password & security\": \"Hasło i bezpieczeństwo\",\n        \"Save\": \"Zapisz\",\n        \"Theme\": \"Motyw\",\n        \"Two-factor authentication\": \"Uwierzytelnianie dwuskładnikowe\",\n        \"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.\": \"Uwierzytelnianie dwuetapowe to dodatkowa warstwa zabezpieczeń konta Grist, która zapewnia, że jesteś jedyną osobą, która ma dostęp do konta, nawet jeśli ktoś zna Twoje hasło.\",\n        \"Language\": \"Język\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Szczegóły dostępu\",\n        \"Accounts\": \"Konta\",\n        \"Add account\": \"Dodaj konto\",\n        \"Document settings\": \"Ustawienia dokumentu\",\n        \"Manage team\": \"Zarządzaj zespołem\",\n        \"Pricing\": \"Cennik\",\n        \"Profile settings\": \"Ustawienia profilu\",\n        \"Sign out\": \"Wyloguj się\",\n        \"Sign in\": \"Zaloguj się\",\n        \"Switch Accounts\": \"Przełącz konta\",\n        \"Toggle Mobile Mode\": \"Przełącz na tryb mobilny\",\n        \"Activation\": \"Aktywacja\",\n        \"Billing account\": \"Konto rozliczeniowe\",\n        \"Support Grist\": \"Wesprzyj Grist\",\n        \"Upgrade Plan\": \"Ulepsz plan\",\n        \"Sign up\": \"Zarejestruj się\",\n        \"Use This Template\": \"Użyj tego szablonu\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Wyświetl jako\",\n        \"Users from table\": \"Użytkownicy z tabeli\",\n        \"Example Users\": \"Przykładowi Użytkownicy\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"Załadowanie dziennika akcji nie powiodło się\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Kolumna {{colId}} została następnie usunięta w akcji #{{action.actionNum}}\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Tabela {{tableId}} została następnie usunięta w akcji #{{actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Ten wiersz został następnie usunięty w akcji {{action.actionNum}}\",\n        \"All tables\": \"Wszystkie tabele\",\n        \"History blocked because of access rules.\": \"Historia zablokowana przez reguły dostępu.\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"Kolumna {{colId}} została usunięta przez akcję #{{actionNum}}\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Ten wiersz został usunięty przez akcję #{{actionNum}}\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Dodaj nowy\"\n    },\n    \"ApiKey\": {\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Generując klucz API, będziesz mógł wykonywać połączenia API dla własnego konta.\",\n        \"Create\": \"Utwórz\",\n        \"Remove\": \"Usuń\",\n        \"Remove API Key\": \"Usuń klucz API\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Ten klucz API może służyć do anonimowego dostępu do tego konta za pośrednictwem interfejsu API.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Ten klucz API może być użyty do uzyskania dostępu do Twojego konta poprzez API. Nie udostępniaj nikomu swojego klucza API.\",\n        \"Click to show\": \"Kliknij, aby pokazać\",\n        \"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?\": \"Zamierzasz usunąć klucz API. Spowoduje to odrzucenie wszystkich przyszłych żądań korzystających z tego klucza API. Czy nadal chcesz usunąć klucz?\"\n    },\n    \"App\": {\n        \"Key\": \"Klucz\",\n        \"Memory Error\": \"Błąd pamięci\",\n        \"Description\": \"Opis\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"[TRANSLATED]\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Strona główna\",\n        \"Legacy\": \"Brak wsparcia\",\n        \"Personal Site\": \"Strona osobista\",\n        \"Team Site\": \"Strona zespołu\",\n        \"Grist Templates\": \"Szablony Grist\",\n        \"Billing account\": \"Konto rozliczeniowe\",\n        \"Manage team\": \"Zarządzaj zespołem\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Powrót na stronę główną\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Ta strona zespołu jest zawieszona. Dokumenty można czytać, ale nie można ich modyfikować.\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"Wyczyść komórkę\",\n        \"Copy anchor link\": \"Kopiuj link\",\n        \"Delete {{count}} columns_one\": \"Usuń kolumnę\",\n        \"Delete {{count}} columns_other\": \"Usuń {{count}} kolumny\",\n        \"Delete {{count}} rows_one\": \"Usuń wiersz\",\n        \"Delete {{count}} rows_other\": \"Usuń {{count}} wierszy\",\n        \"Duplicate rows_one\": \"Zduplikuj wiersz\",\n        \"Duplicate rows_other\": \"Zduplikuj wiersze\",\n        \"Filter by this value\": \"Filtruj według tej wartości\",\n        \"Insert column to the left\": \"Wstaw kolumnę z lewej strony\",\n        \"Insert column to the right\": \"Wstaw kolumnę z prawej strony\",\n        \"Insert row above\": \"Wstaw wiersz powyżej\",\n        \"Reset {{count}} columns_one\": \"Zresetuj kolumnę\",\n        \"Reset {{count}} columns_other\": \"Zresetuj {{count}} kolumn\",\n        \"Reset {{count}} entire columns_one\": \"Zresetuj całą kolumnę\",\n        \"Reset {{count}} entire columns_other\": \"Zresetuj {{count}} kolumn\",\n        \"Clear values\": \"Wyczyść wartości\",\n        \"Insert row below\": \"Wstaw wiersz poniżej\",\n        \"Insert row\": \"Wstaw wiersz\",\n        \"Comment\": \"Komentarz\",\n        \"Copy\": \"Kopiuj\",\n        \"Cut\": \"Wytnij\",\n        \"Paste\": \"Wklej\",\n        \"Copy with headers\": \"Kopiuj z nagłówkami\"\n    },\n    \"ChartView\": {\n        \"Create separate series for each value of the selected column.\": \"Utwórz osobne serie dla każdej wartości z wybranej kolumny.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Po każdej serii Y następują dwie serie, dla górnych i dolnych słupków błędów.\",\n        \"Pick a column\": \"Wybierz kolumnę\",\n        \"Toggle chart aggregation\": \"Zmień agregację wykresu\",\n        \"selected new group data columns\": \"wybrane nowe kolumny danych grupowych\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Po każdej serii Y następuje seria dla długości słupków błędów.\",\n        \"LABEL\": \"ETYKIETA\",\n        \"Bar chart\": \"Wykres słupkowy\",\n        \"Pie chart\": \"Wykres kołowy\",\n        \"Donut chart\": \"Wykres pączkowy\",\n        \"Area chart\": \"Wykres obszarowy\",\n        \"Line chart\": \"Wykres liniowy\",\n        \"Scatter plot\": \"Wykres punktowy\",\n        \"Kaplan-Meier plot\": \"Wykres Kaplana-Meiera\",\n        \"Split series\": \"Podziel serię\",\n        \"Invert Y-axis\": \"Odwróć oś Y\",\n        \"Orientation\": \"Orientacja\",\n        \"Vertical\": \"Pionowa\",\n        \"Horizontal\": \"Pozioma\",\n        \"Log scale Y-axis\": \"Logarytmiczna skala osi Y\",\n        \"Hole size\": \"Rozmiar otworu\",\n        \"Show total\": \"Pokaż sumę\",\n        \"Text size\": \"Rozmiar tekstu\",\n        \"Connect gaps\": \"Połącz luki\",\n        \"Show markers\": \"Pokaż znaczniki\",\n        \"Stack series\": \"Ułóż serie w stos\",\n        \"Error bars\": \"Paski błędów\",\n        \"None\": \"Brak\",\n        \"Symmetric\": \"Symetryczne\",\n        \"Above+Below\": \"Powyżej+Poniżej\",\n        \"Split Series\": \"Podziel serię\",\n        \"X-AXIS\": \"OŚ X\",\n        \"Aggregate values\": \"Wartości zagregowane\",\n        \"SERIES\": \"SERIE\",\n        \"Add series\": \"Dodaj serię\",\n        \"non-numeric columns are not shown\": \"kolumny nieliczbowe nie są pokazywane\",\n        \"non-numeric column is not shown\": \"kolumna nieliczbowa nie jest pokazywana\",\n        \"selected new x-axis\": \"wybrano nową oś x\",\n        \"Remove\": \"Usuń\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Brak dostępu\",\n        \"Code View is available only when you have full document access.\": \"Widok kodu jest dostępny tylko wtedy, gdy masz pełny dostęp do dokumentu.\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All except\": \"Wszystkie z wyjątkiem\",\n        \"All shown\": \"Wszystkie pokazane\",\n        \"Filter by Range\": \"Filtr według zakresu\",\n        \"Future values\": \"Przyszłe wartości\",\n        \"No matching values\": \"Brak pasujących wartości\",\n        \"None\": \"Brak\",\n        \"Min\": \"min\",\n        \"Start\": \"Start\",\n        \"Other Matching\": \"Inne dopasowanie\",\n        \"Other Non-Matching\": \"Inne niepasujące\",\n        \"Other values\": \"Inne wartości\",\n        \"Others\": \"Inne\",\n        \"Search\": \"Szukać\",\n        \"Search values\": \"Wartości wyszukiwania\",\n        \"Max\": \"Max\",\n        \"End\": \"Koniec\",\n        \"All\": \"Wszystkie\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Sortuj według liczby wystąpień (aktualnie: sortowanie alfabetyczne)\",\n        \"Unpin filter\": \"Odepnij filtr\",\n        \"Pin filter\": \"Przypnij filtr\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Sortuj alfabetycznie (aktualnie: sortowanie według liczby wystąpień)\",\n        \"Clear search\": \"Wyczyść wyszukiwanie\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (opcjonalnie)\",\n        \"Add\": \"Dodaj\",\n        \"Enter Custom URL\": \"Wprowadź niestandardowy adres URL\",\n        \"Full document access\": \"Pełny dostęp do dokumentów\",\n        \"Learn more about custom widgets\": \"Dowiedz się więcej o niestandardowych widżetach\",\n        \"No document access\": \"Brak dostępu do dokumentów\",\n        \"Open configuration\": \"Otwórz konfiguracje\",\n        \"Pick a column\": \"Wybieranie kolumny\",\n        \"Read selected table\": \"Odczytywanie zaznaczonej tabeli\",\n        \"Select Custom Widget\": \"Wybierz niestandardowy widżet\",\n        \"Widget does not require any permissions.\": \"Widżet nie wymaga żadnych uprawnień.\",\n        \"Widget needs to {{read}} the current table.\": \"Widżet potrzebuje {{read}} aktualnej tabeli.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Widget wymaga {{fullAccess}} do tego dokumentu.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"Kolumna {{wrongTypeCount}} inna niż {{columnType}} nie jest wyświetlana\",\n        \"Pick a {{columnType}} column\": \"Wybierz kolumnę {{columnType}}\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"Kolumna {{wrongTypeCount}} inna niż {{columnType}} nie jest wyświetlana\",\n        \"Clear selection\": \"Wyczyść zaznaczenie\",\n        \"No {{columnType}} columns in table.\": \"Brak kolumn typu {{columnType}} w tabeli.\",\n        \"ACCESS LEVEL\": \"POZIOM DOSTĘPU\",\n        \"Accept\": \"Akceptuj\",\n        \"Custom URL\": \"Niestandardowy URL\",\n        \"Developer:\": \"Deweloper:\",\n        \"Last updated:\": \"Ostatnia aktualizacja:\",\n        \"Missing description and author information.\": \"Brak opisu i informacji o autorze.\",\n        \"Reject\": \"Odrzuć\",\n        \"Widget\": \"Widget\",\n        \"Change custom widget\": \"Zmień personalizowany widżet\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Kliknij, aby skopiować\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Usunąć dane {{formattedTableName}}, i usunąć je ze wszystkich stron?\",\n        \"Duplicate table\": \"Zduplikuj całą tabele\",\n        \"Raw Data Tables\": \"Tabele danych surowych\",\n        \"Table ID copied to clipboard\": \"ID tabeli skopiowane do schowka\",\n        \"You do not have edit access to this document\": \"Nie masz dostępu do edycji tego dokumentu\",\n        \"Edit record card\": \"Edytuj kartę rekordu\",\n        \"Record Card\": \"Karta rekordu\",\n        \"Record Card Disabled\": \"Karta rekordu wyłączona\",\n        \"Remove table\": \"Usuń tabelę\",\n        \"Rename table\": \"Zmień nazwę tabeli\",\n        \"{{action}} Record Card\": \"{{action}} kartę rekordu\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Aktywność\",\n        \"Beta\": \"Beta\",\n        \"Compare to current\": \"Porównaj z bieżącym\",\n        \"Compare to previous\": \"Porównaj z poprzednim\",\n        \"Snapshots are unavailable.\": \"Migawki są niedostępne.\",\n        \"Open snapshot\": \"Otwórz migawkę\",\n        \"Snapshots\": \"Migawki\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Tylko właściciele mają dostęp do migawek dokumentów z regułami dostępu.\"\n    },\n    \"DocMenu\": {\n        \"(The organization needs a paid plan)\": \"(Organizacja potrzebuje płatnego planu)\",\n        \"Access Details\": \"Szczegóły dostępu\",\n        \"All documents\": \"Wszystkie dokumenty\",\n        \"By Date Modified\": \"Według daty modyfikacji\",\n        \"By Name\": \"Według nazwy\",\n        \"Delete\": \"Usuń\",\n        \"Document will be moved to Trash.\": \"Dokument zostanie przeniesiony do kosza.\",\n        \"Edited {{at}}\": \"Edytowane {{at}}\",\n        \"Examples & Templates\": \"Przykłady i szablony\",\n        \"Examples and Templates\": \"Przykłady i szablony\",\n        \"Featured\": \"Polecane\",\n        \"Manage users\": \"Zarządzanie użytkownikami\",\n        \"More Examples and Templates\": \"Więcej przykładów i szablonów\",\n        \"Move\": \"Przenieś\",\n        \"Move {{name}} to workspace\": \"Przenieś {{name}} do obszaru roboczego\",\n        \"Other Sites\": \"Inne strony\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Trwale usunąć „{{name}}”?\",\n        \"Pin Document\": \"Przypnij dokument\",\n        \"Pinned Documents\": \"Przypięte dokumenty\",\n        \"Current workspace\": \"Aktualna przestrzeń robocza\",\n        \"Delete Forever\": \"Usuń na zawsze\",\n        \"Delete {{name}}\": \"Usuń {{name}}\",\n        \"Deleted {{at}}\": \"Usunięto {{at}}\",\n        \"Discover More Templates\": \"Odkryj więcej szablonów\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Dokumenty pozostają w Koszu przez 30 dni, po czym zostają trwale usunięte.\",\n        \"Document will be permanently deleted.\": \"Dokument zostanie na stałe usunięty.\",\n        \"Unpin Document\": \"Odepnij Dokument\",\n        \"Remove\": \"Usuń\",\n        \"Rename\": \"Zmień nazwę\",\n        \"Requires edit permissions\": \"Wymagane uprawnienia do edycji\",\n        \"Restore\": \"Przywróć\",\n        \"To restore this document, restore the workspace first.\": \"Aby przywrócić ten dokument, najpierw przywróć obszar roboczy.\",\n        \"Trash\": \"Kosz\",\n        \"Trash is empty.\": \"Kosz jest pusty.\",\n        \"Workspace not found\": \"Nie odnaleziono obszaru roboczego\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Jesteś w swojej osobistej przestrzeni. Masz dostęp także do:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Ponieważ ten obszar roboczy nie zawiera żadnych dokumentów, możesz skasować go na stałe.\",\n        \"This service is not available right now\": \"Usługa jest aktualnie niedostępna\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Jesteś w przestrzeni {{siteName}}. Oprócz niej, masz dostęp także do:\",\n        \"Any documents created in this site will appear here.\": \"Wszystkie dokumenty utworzone na tej stronie pojawią się tutaj.\",\n        \"Create my first document\": \"Utwórz mój pierwszy dokument\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Masz dostęp tylko do odczytu do tej strony. Obecnie nie ma żadnych dokumentów.\",\n        \"personal site\": \"strona osobista\",\n        \"Grid view\": \"Widok tabeli\",\n        \"List view\": \"Widok listy\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Zastosuj\",\n        \"Cancel\": \"Anuluj\",\n        \"Default cell style\": \"Domyślny styl komórki\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Dodaj pustą tabele\",\n        \"Add page\": \"Dodaj stronę\",\n        \"Add widget to page\": \"Dodaj widżet do strony\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Właściciel dokumentu może podjąć próbę odzyskania go. {{error}}\",\n        \"Enter recovery mode\": \"Przejdź w tryb odzyskiwania\",\n        \"Error accessing document\": \"Błąd w trakcie pobierania dokumentu\",\n        \"Reload\": \"Przeładuj\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Przepraszamy ale nie masz dostępu do tego dokumentu. [{{error}}]\",\n        \"You do not have edit access to this document\": \"nie masz uprawnień do edytowania tego dokumentu\",\n        \"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}}]\": \"Spróbuj przeładować dokument, albo użyj trybu odzyskiwania. W trybie odzyskiwania dokument jest otwarty z pełnym dostępem dla właściciela i jako niedostępny dla kogokolwiek innego, a formuły są wyłączone. [{{error}}]\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Przeładuj dokument, a jeśli błąd będzie się powtarzał, skontaktuj się z właścicielami dokumentu, aby spróbować odzyskać dokument. [{{error}}]\"\n    },\n    \"HomeIntro\": {\n        \"Get started by creating your first Grist document.\": \"Zacznij od stworzenia swojego pierwszego dokumentu Grist.\",\n        \"Any documents created in this site will appear here.\": \"Wszelkie dokumenty utworzone w tej witrynie pojawią się tutaj.\",\n        \"Browse Templates\": \"Przeglądaj szablony\",\n        \"Create empty document\": \"Tworzenie pustego dokumentu\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Zacznij od zaproszenia swojego zespołu i stworzenia pierwszego dokumentu Grist.\",\n        \"Help Center\": \"Centrum pomocy\",\n        \"Import document\": \"Importuj dokument\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Zacznij od zapoznania się z gotowymi szablonami lub stwórz swój pierwszego dokument Grist.\",\n        \"{{signUp}} to save your work. \": \"{{signUp}}, aby zapisać swoją pracę. \",\n        \"Welcome to {{orgName}}\": \"Witamy w {{orgName}}\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Chcesz używać Grist poza swoim zespołem? Odwiedź swój darmowy \",\n        \"Invite Team Members\": \"Zaproś członków zespołu\",\n        \"Sign up\": \"Zarejestruj się\",\n        \"Sprouts Program\": \"Program Kiełki\",\n        \"This workspace is empty.\": \"Ten obszar roboczy jest pusty.\",\n        \"Visit our {{link}} to learn more.\": \"Odwiedź nasze {{link}}, aby dowiedzieć się więcej.\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Masz dostęp tylko do odczytu do tej witryny. Obecnie brak dokumentów.\",\n        \"personal site\": \"strona osobista\",\n        \"Welcome to Grist!\": \"Witamy w Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Witamy w Grist, {{name}}!\",\n        \"Welcome to Grist, {{- name}}!\": \"Witaj w Grist, {{- name}}!\",\n        \"Welcome to {{- orgName}}\": \"Witaj w {{- orgName}}\",\n        \"Sign in\": \"Zaloguj się\",\n        \"To use Grist, please either sign up or sign in.\": \"Aby korzystać z Grist, zarejestruj się lub zaloguj.\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Odwiedź nasz {{link}}, aby dowiedzieć się więcej o Grist.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Dowiedz się więcej w naszym {{helpCenterLink}}.\",\n        \"Only show documents\": \"Pokaż tylko dokumenty\"\n    },\n    \"DocTour\": {\n        \"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.\": \"Nie można utworzyć przewodnika po dokumencie na podstawie danych w tym dokumencie. Upewnij się, że istnieje tabela o nazwie GristDocTour z kolumnami Tytuł, Treść, Położenie i Lokalizacja.\",\n        \"No valid document tour\": \"Brak przewodnika dla dokumentu\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Waluta:\",\n        \"Document settings\": \"Ustawienia dokumentu\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Silnik (eksperymentalny {{span}} zmiana na własne ryzyko):\",\n        \"Locale:\": \"Ustawienia regionalne:\",\n        \"Save\": \"Zapisz\",\n        \"Save and Reload\": \"Zapisz i ponownie załaduj\",\n        \"This document's ID (for API use):\": \"Identyfikator tego dokumentu (na potrzeby API):\",\n        \"Time Zone:\": \"Strefa czasowa:\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"Identyfikator dokumentu skopiowany do schowka\",\n        \"Ok\": \"OK\",\n        \"Local currency ({{currency}})\": \"Waluta lokalna ({{currency}})\",\n        \"Manage Webhooks\": \"Zarządzaj webhookami\",\n        \"Webhooks\": \"Webhooki\",\n        \"API console\": \"konsola API\",\n        \"API URL copied to clipboard\": \"URL API skopiowany do schowka\",\n        \"API documentation.\": \"Dokumentacja API.\",\n        \"Base doc URL: {{docApiUrl}}\": \"Bazowy URL dokumentu: {{docApiUrl}}\",\n        \"Coming soon\": \"Wkrótce\",\n        \"Copy to clipboard\": \"Kopiuj do schowka\",\n        \"Currency\": \"Waluta\",\n        \"Data engine\": \"Silnik danych\",\n        \"Default for DateTime columns\": \"Domyślne dla kolumn DataCzas\",\n        \"Document ID\": \"ID Dokumentu\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"ID Dokumentu do użycia, gdy REST API wywołuje {{docId}}. Zobacz {{apiURL}}\",\n        \"Find slow formulas\": \"Znajdź wolne formuły\",\n        \"For currency columns\": \"Dla kolumn walutowych\",\n        \"For number and date formats\": \"Dla formatów liczbowych i daty\",\n        \"Formula times\": \"Czasy formuł\",\n        \"Hard reset of data engine\": \"Twardy reset silnika danych\",\n        \"ID for API use\": \"ID do użycia w API\",\n        \"Locale\": \"Ustawienia regionalne\",\n        \"Manage webhooks\": \"Zarządzaj webhookami\",\n        \"Notify other services on doc changes\": \"Powiadamiaj inne usługi o zmianach w dokumencie\",\n        \"Python\": \"Python\",\n        \"Python version used\": \"Używana wersja Pythona\",\n        \"Reload\": \"Przeładuj\",\n        \"Time zone\": \"Strefa czasowa\",\n        \"Try API calls from the browser\": \"Wypróbuj wywołania API z przeglądarki\",\n        \"python2 (legacy)\": \"python2 (starsza wersja)\",\n        \"python3 (recommended)\": \"python3 (zalecane)\",\n        \"Cancel\": \"Anuluj\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Wymuś przeładowanie dokumentu z mierzeniem czasu formuł i pokaż wynik.\",\n        \"Formula timer\": \"Licznik czasu formuł\",\n        \"Reload data engine\": \"Przeładuj silnik danych\",\n        \"Reload data engine?\": \"Przeładować silnik danych?\",\n        \"Start timing\": \"Rozpocznij pomiar czasu\",\n        \"Stop timing...\": \"Zatrzymaj pomiar czasu...\",\n        \"Time reload\": \"Czas przeładowania\",\n        \"Timing is on\": \"Pomiar czasu włączony\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Możesz wprowadzać zmiany w dokumencie, a następnie zatrzymać pomiar czasu, aby zobaczyć wyniki.\",\n        \"Only available to document editors\": \"Dostępne tylko dla edytorów dokumentu\",\n        \"Only available to document owners\": \"Dostępne tylko dla właścicieli dokumentu\",\n        \"Template mode\": \"Tryb szablonu\",\n        \"Change document type\": \"Zmień typ dokumentu\",\n        \"Edit\": \"Edytuj\",\n        \"Change nature of document\": \"Zmień charakter dokumentu\",\n        \"Regular document\": \"Zwykły dokument\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Normalne zachowanie dokumentu. Wszyscy użytkownicy pracują na tej samej kopii dokumentu.\",\n        \"Regular\": \"Zwykły\",\n        \"Template\": \"Szablon\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Dokument automatycznie otwiera się w {{fiddleModeDocUrl}}. Każdy może edytować, co utworzy nową niezapisaną kopię.\",\n        \"fiddle mode\": \"tryb fiddle\",\n        \"Tutorial\": \"Samouczek\",\n        \"Document automatically opens as a user-specific copy.\": \"Dokument automatycznie otwiera się jako kopia specyficzna dla użytkownika.\",\n        \"Confirm change\": \"Potwierdź zmianę\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Spowoduje to twarde przeładowanie silnika danych. Może to pomóc, jeśli silnik danych utknął w nieskończonej pętli, w nieskończoność przetwarza ostatnią zmianę lub uległ awarii. Żadne dane nie zostaną utracone, z wyjątkiem ewentualnie aktualnie oczekujących akcji.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Po rozpoczęciu pomiaru czasu Grist zmierzy czas potrzebny na obliczenie każdej formuły. Pozwala to zdiagnozować, które formuły są odpowiedzialne za wolne działanie przy pierwszym otwarciu dokumentu lub gdy dokument reaguje na zmiany.\",\n        \"**Some existing attachments are still external**.\": \"**Niektóre istniejące załączniki są nadal zewnętrzne**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Niektóre istniejące załączniki są nadal wewnętrzne** (przechowywane w pliku SQLite).\",\n        \"Attachment storage\": \"Przechowywanie załączników\",\n        \"Being transfer\": \"Trwa transfer\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Kliknij \\\"Rozpocznij transfer\\\", aby przenieść je do Pamięci zewnętrznej.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Kliknij \\\"Rozpocznij transfer\\\", aby przenieść je do Pamięci wewnętrznej (przechowywane w pliku SQLite dokumentu).\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Nowo przesłane załączniki będą umieszczane w Pamięci zewnętrznej.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Nowo przesłane załączniki będą umieszczane w Pamięci wewnętrznej.\",\n        \"No external stores available\": \"Brak dostępnych zewnętrznych magazynów\",\n        \"Preferred storage for this document\": \"Preferowane miejsce przechowywania dla tego dokumentu\",\n        \"Start transfer\": \"Rozpocznij transfer\",\n        \"External\": \"Zewnętrzne\",\n        \"Internal\": \"Wewnętrzne\",\n        \"Transfer in progress\": \"Trwa transfer\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Niektóre istniejące załączniki są nadal [zewnętrzne]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Niektóre istniejące załączniki są nadal [wewnętrzne]({{internalLink}})** (przechowywane w pliku SQLite).\",\n        \"[Learn more.]({{learnLink}})\": \"[Dowiedz się więcej.]({{learnLink}})\",\n        \"Upload\": \"Prześlij\",\n        \"Upload missing attachments\": \"Prześlij brakujące załączniki\",\n        \"Uploading...\": \"Przesyłanie...\",\n        \"Default\": \"Domyślne\",\n        \"Default, template, or tutorial\": \"Domyślne, szablon lub samouczek\",\n        \"Document type\": \"Typ dokumentu\",\n        \"Allow others to suggest changes\": \"Pozwól innym na sugerowanie zmian\",\n        \"Enable suggestions\": \"Włącz sugestie\",\n        \"Suggestions\": \"Sugestie\",\n        \"experiment\": \"eksperyment\"\n    },\n    \"DocumentUsage\": {\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Skontaktuj się z właścicielem witryny, aby uaktualnić plan w celu podniesienia limitów.\",\n        \"Data size\": \"Rozmiar danych\",\n        \"Rows\": \"Wiersze\",\n        \"Usage\": \"Zastosowanie\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"Statystyki użytkowania są dostępne tylko dla użytkowników z pełnym dostępem do danych dokumentu.\",\n        \"start your 30-day free trial of the Pro plan.\": \"rozpocznij 30-dniową bezpłatną próbę planu Pro.\",\n        \"Size of attachments\": \"Rozmiar załączników\",\n        \"For higher limits, \": \"W przypadku wyższych limitów, \"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Skopiuj wszystkie dane oprócz struktury tabeli.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Zamiast duplikować tabele, zwykle lepiej jest utworzyć klika połączonych ze sobą widoków. {{link}}\",\n        \"Name for new table\": \"Nowa nazwa tabeli\",\n        \"Only the document default access rules will apply to the copy.\": \"Kopia dokumentu będzie używać tylko domyślnych reguł dostępu.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Program pozaszkolny\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Sprawdź nasz powiązany samouczek, jak łączyć dane i tworzyć układy o wysokiej produktywności.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Zapoznaj się z naszym poradnikiem dotyczącym modelowania danych biznesowych, używania formuł i zarządzania złożonością.\",\n        \"Investment Research\": \"Badania inwestycyjne\",\n        \"Lightweight CRM\": \"Lekki CRM\",\n        \"Tutorial: Analyze & Visualize\": \"Samouczek: Analiza i wizualizacja\",\n        \"Tutorial: Create a CRM\": \"Tutorial: Utwórz CRM\",\n        \"Tutorial: Manage Business Data\": \"Samouczek: Zarządzanie danymi biznesowymi\",\n        \"Welcome to the Afterschool Program template\": \"Witamy w szablonie programu pozaszkolnego\",\n        \"Welcome to the Lightweight CRM template\": \"Witamy w lekkim szablonie CRM\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Sprawdź nasz powiązany tutorial, aby dowiedzieć się, jak tworzyć tabele i wykresy zbiorcze oraz dynamicznie łączyć wykresy.\",\n        \"Welcome to the Investment Research template\": \"Witamy w szablonie badań inwestycyjnych\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"ZACHOWANIE KOLUMNY\",\n        \"COLUMN LABEL AND ID\": \"ETYKIETA I IDENTYFIKATOR KOLUMNY\",\n        \"Clear and make into formula\": \"Wyczyść i przekształć w formułę\",\n        \"Clear and reset\": \"Wyczyść i zresetuj\",\n        \"Column options are limited in summary tables.\": \"Opcje kolumn są ograniczone w tabelach zbiorczych.\",\n        \"Convert column to data\": \"Konwertowanie kolumny na dane\",\n        \"Convert to trigger formula\": \"Konwersja do formuły wyzwalającej\",\n        \"Data columns_one\": \"Kolumna danych\",\n        \"Formula columns_one\": \"Kolumna formuły\",\n        \"Formula columns_other\": \"Kolumny formuł\",\n        \"Set formula\": \"Ustaw formułę\",\n        \"Mixed Behavior\": \"Mieszane zachowanie\",\n        \"TRIGGER FORMULA\": \"FORMUŁA WYZWALACZA\",\n        \"Data columns_other\": \"Kolumny danych\",\n        \"Empty columns_one\": \"Pusta kolumna\",\n        \"Empty columns_other\": \"Puste kolumny\",\n        \"Enter formula\": \"Wprowadź formułę\",\n        \"Make into data column\": \"Zrób z tego kolumnę danych\",\n        \"Set trigger formula\": \"Ustawianie formuły wyzwalacza\",\n        \"DESCRIPTION\": \"OPIS\"\n    },\n    \"GridOptions\": {\n        \"Zebra stripes\": \"Paski zebry\",\n        \"Vertical gridlines\": \"Pionowe linie siatki\",\n        \"Horizontal gridlines\": \"Poziome linie siatki\",\n        \"Grid Options\": \"Opcje siatki\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Dodaj kolumnę\",\n        \"Add to sort\": \"Dodaj do sortowania\",\n        \"Clear values\": \"Wyczyść wartości\",\n        \"Column Options\": \"Opcje kolumn\",\n        \"Convert formula to data\": \"Konwertowanie formuły na dane\",\n        \"Delete {{count}} columns_one\": \"Usuń kolumnę\",\n        \"Delete {{count}} columns_other\": \"Usuń {{count}} kolumny\",\n        \"Filter Data\": \"Filtrowanie danych\",\n        \"Freeze {{count}} more columns_one\": \"Zablokuj jeszcze jedną kolumnę\",\n        \"Freeze {{count}} columns_one\": \"Zablokuj tę kolumnę\",\n        \"Freeze {{count}} columns_other\": \"Blokowanie kolumn {{count}}\",\n        \"Freeze {{count}} more columns_other\": \"Zamrożenie jeszcze {{count}} kolumn\",\n        \"Hide {{count}} columns_one\": \"Ukryj kolumnę\",\n        \"Hide {{count}} columns_other\": \"Ukryj {{count}}kolumn\",\n        \"Insert column to the {{to}}\": \"Wstaw kolumnę do {{to}}\",\n        \"More sort options ...\": \"Pozostałe opcje sortowania…\",\n        \"Rename column\": \"Zmień nazwę kolumny\",\n        \"Reset {{count}} columns_one\": \"Resetuj kolumnę\",\n        \"Reset {{count}} columns_other\": \"Wyzeruj {{count}} kolumn\",\n        \"Reset {{count}} entire columns_one\": \"Zresetuj całą kolumnę\",\n        \"Reset {{count}} entire columns_other\": \"Resetowanie {{count}} całych kolumn\",\n        \"Show column {{- label}}\": \"Pokaż kolumnę {{- label}}\",\n        \"Sort\": \"Sortuj\",\n        \"Sorted (#{{count}})_one\": \"Posortowane (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Posortowane (#{{count}})\",\n        \"Unfreeze all columns\": \"Odblokuj wszystkie kolumny\",\n        \"Unfreeze {{count}} columns_one\": \"Odblokuj tę kolumnę\",\n        \"Unfreeze {{count}} columns_other\": \"Odblokuj kolumny {{count}}\",\n        \"Insert column to the left\": \"Wstaw kolumnę po lewej\",\n        \"Insert column to the right\": \"Wstaw kolumnę po prawej\",\n        \"Apply on record changes\": \"Stosuj przy zmianach rekordu\",\n        \"Apply to new records\": \"Stosuj do nowych rekordów\",\n        \"Authorship\": \"Autorstwo\",\n        \"Created At\": \"Utworzono\",\n        \"Created By\": \"Utworzone przez\",\n        \"Hidden Columns\": \"Ukryte kolumny\",\n        \"Last Updated At\": \"Ostatnia aktualizacja\",\n        \"Last Updated By\": \"Ostatnio zaktualizowane przez\",\n        \"Lookups\": \"Wyszukiwania\",\n        \"Shortcuts\": \"Skróty\",\n        \"Show hidden columns\": \"Pokaż ukryte kolumny\",\n        \"Timestamp\": \"Znacznik czasu\",\n        \"no reference column\": \"brak kolumny referencyjnej\",\n        \"Adding UUID column\": \"Dodawanie kolumny UUID\",\n        \"Adding duplicates column\": \"Dodawanie kolumny duplikatów\",\n        \"Detect Duplicates in...\": \"Wykryj duplikaty w...\",\n        \"Duplicate in {{- label}}\": \"Duplikuj w {{- label}}\",\n        \"No reference columns.\": \"Brak kolumn referencyjnych.\",\n        \"Search columns\": \"Wyszukaj kolumny\",\n        \"UUID\": \"UUID\",\n        \"Add column with type\": \"Dodaj kolumnę z typem\",\n        \"Add formula column\": \"Dodaj kolumnę formuły\",\n        \"Created at\": \"Utworzono\",\n        \"Created by\": \"Utworzone przez\",\n        \"Detect duplicates in...\": \"Wykryj duplikaty w...\",\n        \"Last updated at\": \"Ostatnia aktualizacja\",\n        \"Last updated by\": \"Ostatnio zaktualizowane przez\",\n        \"Any\": \"Dowolny\",\n        \"Numeric\": \"Liczbowe\",\n        \"Text\": \"Tekst\",\n        \"Integer\": \"Liczba całkowita\",\n        \"Toggle\": \"Przełącznik\",\n        \"Date\": \"Data\",\n        \"DateTime\": \"DataCzas\",\n        \"Choice\": \"Wybór\",\n        \"Choice List\": \"Lista wyboru\",\n        \"Reference\": \"Referencja\",\n        \"Reference List\": \"Lista referencji\",\n        \"Attachment\": \"Załącznik\"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"Dodano nową połączoną sekcję do widoku {{viewName}}\",\n        \"Import from file\": \"Import z pliku\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Zapisane połączone sekcje {{title}} w widoku {{name}}\",\n        \"go to webhook settings\": \"przejdź do ustawień webhooków\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Nowe zmiany są tymczasowo wstrzymane. Kolejka webhooków jest przepełniona. Sprawdź ustawienia webhooków, usuń nieprawidłowe webhooki i wyczyść kolejkę.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Przywróć ostatnią zmianę\",\n        \"Undo discard\": \"Cofnij odrzucenie\"\n    },\n    \"FieldMenus\": {\n        \"Use separate settings\": \"Użyj oddzielnych ustawień\",\n        \"Revert to common settings\": \"Przywrócenie zwykłych ustawień\",\n        \"Using common settings\": \"Używanie wspólnych ustawień\",\n        \"Save as common settings\": \"Zapisz jako ustawienia wspólne\",\n        \"Using separate settings\": \"Używanie oddzielnych ustawień\"\n    },\n    \"FilterBar\": {\n        \"Search Columns\": \"Wyszukaj kolumny\",\n        \"SearchColumns\": \"Wyszukaj kolumny\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Dodaj kolumnę\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Przypnij filtr - kolumna {{- columnName}} (aktualnie: nie przypięto)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Odepnij filtr - kolumna {{- columnName}} (aktualnie: przypięto)\",\n        \"remove filter - {{- columnName}} column\": \"usuń filtr - kolumna {{- columnName}}\",\n        \"{{- columnName }} column filters\": \"filtry kolumny {{- columnName }}\"\n    },\n    \"HomeLeftPane\": {\n        \"Delete\": \"Usuń\",\n        \"Delete {{workspace}} and all included documents?\": \"Usunąć {{workspace}} i wszystkie dołączone dokumenty?\",\n        \"Examples & Templates\": \"Szablony\",\n        \"All documents\": \"Wszystkie dokumenty\",\n        \"Create empty document\": \"Utwórz pusty dokument\",\n        \"Create workspace\": \"Utwórz przestrzeń roboczą\",\n        \"Access Details\": \"Szczegóły dostępu\",\n        \"Rename\": \"Zmień nazwę\",\n        \"Trash\": \"Kosz\",\n        \"Workspaces\": \"Obszary robocze\",\n        \"Workspace will be moved to Trash.\": \"Obszar roboczy zostanie przeniesiony do kosza.\",\n        \"Import document\": \"Importuj dokument\",\n        \"Manage users\": \"Zarządzanie użytkownikami\",\n        \"Tutorial\": \"Samouczek\",\n        \"Terms of service\": \"Warunki usługi\",\n        \"Grist Resources\": \"Zasoby Grist\",\n        \"context menu - {{- workspaceName }}\": \"menu kontekstowe - {{- workspaceName }}\"\n    },\n    \"Importer\": {\n        \"Select fields to match on\": \"Wybierz pola do dopasowania\",\n        \"Merge rows that match these fields:\": \"Scal wiersze, które pasują do tych pól:\",\n        \"Update existing records\": \"Aktualizowanie istniejących rekordów\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} niezgodne pole w imporcie\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} niezgodnych pól w imporcie\",\n        \"{{count}} unmatched field_one\": \"{{count}} niezgodne pole\",\n        \"{{count}} unmatched field_other\": \"{{count}} niezgodnych pól\",\n        \"Column Mapping\": \"Mapowanie kolumn\",\n        \"Column mapping\": \"Mapowanie kolumn\",\n        \"Destination table\": \"Tabela docelowa\",\n        \"Grist column\": \"Kolumna Grist\",\n        \"Import from file\": \"Importuj z pliku\",\n        \"New Table\": \"Nowa tabela\",\n        \"Revert\": \"Przywróć\",\n        \"Skip\": \"Pomiń\",\n        \"Skip Import\": \"Pomiń import\",\n        \"Skip Table on Import\": \"Pomiń tabelę przy imporcie\",\n        \"Source column\": \"Kolumna źródłowa\",\n        \"Import options\": \"Importuj opcje\",\n        \"Cancel\": \"Anuluj\",\n        \"Import\": \"Importuj\"\n    },\n    \"MakeCopyMenu\": {\n        \"Update\": \"Aktualizacja\",\n        \"Update Original\": \"Zaktualizuj oryginał\",\n        \"Workspace\": \"Obszar roboczy\",\n        \"You do not have write access to the selected workspace\": \"Nie masz dostępu do zapisu w wybranej przestrzeni roboczej\",\n        \"Cancel\": \"Anuluj\",\n        \"As template\": \"Jako szablon\",\n        \"Include the structure without any of the data.\": \"Dołącz strukturę bez żadnych danych.\",\n        \"Enter document name\": \"Wprowadź nazwę dokumentu\",\n        \"However, it appears to be already identical.\": \"Wydaje się jednak, że jest już identyczny.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Zostanie on nadpisany, tracąc wszelkie treści nie znajdujące się w tym dokumencie.\",\n        \"Name\": \"Nazwa\",\n        \"No destination workspace\": \"Brak docelowej przestrzeni roboczej\",\n        \"Organization\": \"Organizacja\",\n        \"Overwrite\": \"Nadpisz\",\n        \"Original Has Modifications\": \"Oryginał ma modyfikacje\",\n        \"Original Looks Identical\": \"Oryginał Wygląda identycznie\",\n        \"To save your changes, please sign up, then reload this page.\": \"Aby zapisać zmiany, proszę się zarejestrować, a następnie ponownie załadować tę stronę.\",\n        \"Replacing the original requires editing rights on the original document.\": \"Zastąpienie oryginału wymaga uprawnień do edycji oryginalnego dokumentu.\",\n        \"Sign up\": \"Zarejestruj się\",\n        \"The original version of this document will be updated.\": \"Oryginalna wersja tego dokumentu zostanie zaktualizowana.\",\n        \"You do not have write access to this site\": \"Nie masz dostępu do zapisu na tej stronie\",\n        \"Original Looks Unrelated\": \"Oryginalny wygląd niepowiązany\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Uważaj, oryginał ma zmiany, których nie ma w tym dokumencie. Te zmiany zostaną nadpisane.\",\n        \"Download\": \"Pobierz\",\n        \"Download document\": \"Pobierz dokument\",\n        \"Download document and history\": \"Pobierz pełny dokument i historię\",\n        \"Download document without history (can significantly reduce file size)\": \"Usuń historię dokumentu (może znacznie zmniejszyć rozmiar pliku)\",\n        \"Download document structure only (no data, for template use)\": \"Usuń wszystkie dane, ale zachowaj strukturę do użycia jako szablon\",\n        \".tar (recommended)\": \".tar (zalecane)\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"Pobierz archiwum wszystkich załączników obecnych w tym dokumencie.\",\n        \"Download attachments\": \"Pobierz załączniki\",\n        \"Download full document and history\": \"Pobierz pełny dokument i historię\",\n        \"Format:\": \"Format:\",\n        \"Learn more\": \"Dowiedz się więcej\",\n        \"download attachments\": \"pobierz załączniki\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Załączniki są zewnętrzne i nie są uwzględnione w tym pobieraniu. Jeśli przesyłasz dokument do oddzielnej instalacji Grist, będziesz także potrzebować {{downloadLink}} osobno. \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"Aby wgrać ten dokument do instancji Grista, należy spakować go do archiwum z rozszerzeniem \\\".tar\\\" aby przywrócić załączniki. \"\n    },\n    \"NotifyUI\": {\n        \"Go to your free personal site\": \"Przejdź do swojej darmowej strony osobistej\",\n        \"No notifications\": \"Brak powiadomień\",\n        \"Ask for help\": \"Zapytaj o pomoc\",\n        \"Cannot find personal site, sorry!\": \"Nie mogę znaleźć osobistej strony, przepraszam!\",\n        \"Give feedback\": \"Przekaż opinię\",\n        \"Notifications\": \"Powiadomienia\",\n        \"Upgrade Plan\": \"Plan aktualizacji\",\n        \"Renew\": \"Odnów\",\n        \"Report a problem\": \"Zgłoś problem\",\n        \"Manage billing\": \"Zarządzaj rozliczeniami\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Centrum pomocy\",\n        \"Accessibility\": \"Dostępność\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Dodaj do strony\",\n        \"Select widget\": \"Wybierz widżet\",\n        \"Group by\": \"Grupuj według\",\n        \"Building {{- label}} widget\": \"Budowa widżetu {{- label}}\",\n        \"Select data\": \"Wybierz Dane\",\n        \"New Table\": \"Nowa tabela\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Wideo Przewodnik Grist\",\n        \"YouTube video player\": \"Odtwarzacz wideo YouTube\",\n        \"Video Tour\": \"Prezentacja wideo\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Usunąć\",\n        \"Delete data and this page.\": \"Usuń dane i tę stronę.\",\n        \"The following tables will no longer be visible_one\": \"Poniższa tabela nie będzie już widoczna\",\n        \"The following tables will no longer be visible_other\": \"Poniższa tabela nie będzie już widoczna\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Zachowaj dane i usuń stronę. Tabela pozostanie dostępna w {{rawDataLink}}\",\n        \"Raw Data page\": \"strona surowych danych\",\n        \"Document pages\": \"Strony dokumentu\",\n        \"raw data page\": \"strona surowych danych\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Pozwól wszystkim\",\n        \"Deny all\": \"Odmówić wszystkim\",\n        \"Read only\": \"Tylko do odczytu\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Cancel\": \"Anuluj\",\n        \"Add field\": \"Dodaj pole\",\n        \"Show field {{- label}}\": \"Pokaż pole {{- label}}\",\n        \"Save layout\": \"Zapisz układ\",\n        \"Create new field\": \"Utwórz nowe pole\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Dodaj kolumnę\",\n        \"No columns to add\": \"Brak kolumn do dodania\"\n    },\n    \"RightPanel\": {\n        \"Data\": \"Dane\",\n        \"Detach\": \"Odłączyć\",\n        \"fields_other\": \"Pola\",\n        \"SOURCE DATA\": \"DANE ŹRÓDŁOWE\",\n        \"Select widget\": \"Wybierz widżet\",\n        \"CUSTOM\": \"NIESTANDARDOWE\",\n        \"Save\": \"Zapisz\",\n        \"Widget\": \"Widżet\",\n        \"columns_other\": \"Kolumny\",\n        \"columns_one\": \"Kolumna\",\n        \"DATA TABLE\": \"TABELA DANYCH\",\n        \"DATA TABLE NAME\": \"NAZWA TABELI DANYCH\",\n        \"GROUPED BY\": \"POGRUPOWANE WEDŁUG\",\n        \"Row style\": \"Styl wiersza\",\n        \"SELECT BY\": \"WYBIERZ WEDŁUG\",\n        \"TRANSFORM\": \"ZMIANA DANYCH\",\n        \"Theme\": \"Motyw\",\n        \"WIDGET TITLE\": \"TYTUŁ WIDŻETU\",\n        \"series_one\": \"Seria\",\n        \"CHART TYPE\": \"TYP WYKRESU\",\n        \"COLUMN TYPE\": \"TYP KOLUMNY\",\n        \"Change widget\": \"Zmień widżet\",\n        \"Edit data selection\": \"Edytuj zaznaczenie danych\",\n        \"fields_one\": \"Pole\",\n        \"SELECTOR FOR\": \"WYBÓR DLA\",\n        \"Sort & filter\": \"Sortowanie i filtrowanie\",\n        \"series_other\": \"Seria\",\n        \"You do not have edit access to this document\": \"nie masz uprawnień do edytowania tego dokumentu\",\n        \"Add referenced columns\": \"Dodaj kolumny referencyjne\",\n        \"Reset form\": \"Resetuj formularz\",\n        \"Configuration\": \"Konfiguracja\",\n        \"Default field value\": \"Domyślna wartość pola\",\n        \"Display button\": \"Przycisk wyświetlania\",\n        \"Enter text\": \"Wprowadź tekst\",\n        \"Field rules\": \"Zasady pól\",\n        \"Field title\": \"Tytuł pola\",\n        \"Hidden field\": \"Ukryte pole\",\n        \"Layout\": \"Układ\",\n        \"Redirect automatically after submission\": \"Przekieruj automatycznie po przesłaniu\",\n        \"Redirection\": \"Przekierowanie\",\n        \"Required field\": \"Wymagane pole\",\n        \"Submission\": \"Przesyłanie\",\n        \"Submit another response\": \"Prześlij kolejną odpowiedź\",\n        \"Submit button label\": \"Etykieta przycisku przesyłania\",\n        \"Success text\": \"Tekst sukcesu\",\n        \"Table column name\": \"Nazwa kolumny tabeli\",\n        \"Enter redirect URL\": \"Wprowadź URL przekierowania\",\n        \"No field selected\": \"Nie wybrano pola\",\n        \"Select a field in the form widget to configure.\": \"Wybierz pole w widżecie formularza, aby je skonfigurować.\",\n        \"Submit\": \"Prześlij\",\n        \"Thank you! Your response has been recorded.\": \"Dziękujemy! Twoja odpowiedź została zapisana.\",\n        \"Chart options\": \"Opcje wykresu\"\n    },\n    \"RowContextMenu\": {\n        \"Delete\": \"Usuń\",\n        \"Insert row\": \"Dodaj wiersz\",\n        \"Insert row above\": \"Dodaj wiersz powyżej\",\n        \"Insert row below\": \"Dodaj wiersz poniżej\",\n        \"Copy anchor link\": \"Skopiuj link\",\n        \"Duplicate rows_one\": \"Duplikuj wiersz\",\n        \"Duplicate rows_other\": \"Duplikuj wiersze\",\n        \"View as card\": \"Wyświetl jako kartę\",\n        \"Use as table headers\": \"Użyj jako nagłówków tabeli\"\n    },\n    \"ShareMenu\": {\n        \"Back to current\": \"Powrót do aktualnego\",\n        \"Download\": \"Pobierz\",\n        \"Send to Google Drive\": \"Wyślij na Dysk Google\",\n        \"Work on a copy\": \"Wersja robocza\",\n        \"Edit without affecting the original\": \"Edycja bez wpływu na oryginał\",\n        \"Duplicate document\": \"Zduplikuj dokument\",\n        \"Show in folder\": \"Pokaż w folderze\",\n        \"Unsaved\": \"Niezapisane\",\n        \"Access Details\": \"Szczegóły dostępu\",\n        \"Original\": \"Oryginał\",\n        \"Replace {{termToUse}}...\": \"Zastąp {{termToUse}}…\",\n        \"Return to {{termToUse}}\": \"Wróć do {{termToUse}}\",\n        \"Save copy\": \"Zapisz kopię\",\n        \"Save Document\": \"Zapisz dokument\",\n        \"Compare to {{termToUse}}\": \"Porównaj z {{termToUse}}\",\n        \"Export CSV\": \"Eksportuj plik CSV\",\n        \"Current Version\": \"Obecna wersja\",\n        \"Export XLSX\": \"Eksportuj XLSX\",\n        \"Manage users\": \"Zarządzanie użytkownikami\",\n        \"Share\": \"Udostępnij\",\n        \"Download...\": \"Pobierz...\",\n        \"Comma Separated Values (.csv)\": \"Wartości oddzielone przecinkami (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"Wartości oddzielone DOO (.dsv)\",\n        \"Export as...\": \"Eksportuj jako...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Wartości oddzielone tabulatorem (.tsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Eksport jest dostępny tylko ze stron dokumentów. Wybierz stronę dokumentu i spróbuj ponownie.\",\n        \"Download attachments...\": \"Pobierz załączniki...\",\n        \"Download document...\": \"Pobierz dokument...\",\n        \"Suggest changes\": \"Zasugeruj zmiany\",\n        \"current version\": \"obecna wersja\",\n        \"original\": \"oryginalny\"\n    },\n    \"SiteSwitcher\": {\n        \"Switch Sites\": \"Przełącz witrynę\",\n        \"Create new team site\": \"Utwórz nową witrynę zespołu\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Dodaj kolumnę\",\n        \"Empty values last\": \"Puste wartości jako ostatnie\",\n        \"Natural sort\": \"Naturalne sortowanie\",\n        \"Update data\": \"Aktualizowanie danych\",\n        \"Search Columns\": \"Wyszukaj kolumny\",\n        \"Use choice position\": \"Użyj pozycji wyboru\",\n        \"Sort in ascending order (current: descending)\": \"Sortuj rosnąco (aktualnie: malejąco)\",\n        \"Sort in descending order (current: ascending)\": \"Sortuj malejąco (aktualnie: rosnąco)\",\n        \"Sort options - {{- columnName }} column\": \"Opcje sortowania - kolumna {{- columnName }}\",\n        \"{{- columnName }} column\": \"kolumna {{- columnName }}\",\n        \"Remove sort setting - {{- columnName }} column\": \"Usuń opcję sortowania - kolumna {{- columnName }}\"\n    },\n    \"Tools\": {\n        \"Document history\": \"Historia dokumentu\",\n        \"Return to viewing as yourself\": \"Powrót do oglądania jako Ty\",\n        \"Access Rules\": \"Reguły dostępu\",\n        \"Code view\": \"Widok kodu\",\n        \"Delete\": \"Usuń\",\n        \"Delete document tour?\": \"Usunąć przewodnik po dokumencie?\",\n        \"Raw data\": \"Surowe dane\",\n        \"Validate Data\": \"Sprawdzanie poprawności danych\",\n        \"TOOLS\": \"NARZĘDZIA\",\n        \"Settings\": \"Ustawienia\",\n        \"How-to Tutorial\": \"Samouczek\",\n        \"Tour of this Document\": \"Przewodnik po tym dokumencie\",\n        \"API console\": \"Konsola API\",\n        \"context menu - Access Rules\": \"menu kontekstowe - Reguły dostępu\",\n        \"Preview the tutorial\": \"Podejrzyj samouczek\",\n        \"Proposed changes\": \"Zaproponowane zmiany\",\n        \"Suggest changes\": \"Zasugeruj zmiany\",\n        \"Suggestions\": \"Sugestie\",\n        \"Delete document tour\": \"Usuń przewodnik po dokumecie\"\n    },\n    \"TriggerFormulas\": {\n        \"Apply on changes to:\": \"Zastosuj w przypadku zmian do:\",\n        \"Apply on record changes\": \"Uruchom przy zmianach\",\n        \"Cancel\": \"Anuluj\",\n        \"Any field\": \"Dowolne pole\",\n        \"Close\": \"Zamknij\",\n        \"OK\": \"OK\",\n        \"Current field \": \"Bieżące pole \",\n        \"Apply to new records\": \"Uruchom przy dodawaniu\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"Zastosuj\",\n        \"Cancel\": \"Anuluj\",\n        \"Preview\": \"Podgląd\",\n        \"Revise\": \"Popraw\",\n        \"Update formula (Shift+Enter)\": \"Aktualizacja formuły (Shift+Enter)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Edytor\",\n        \"View & edit\": \"Wyświetlanie i edytowanie\",\n        \"None\": \"Żaden\",\n        \"In full\": \"W pełni\",\n        \"No Default Access\": \"Brak dostępu domyślnego\",\n        \"Owner\": \"Właściciel\",\n        \"View only\": \"Tylko widok\",\n        \"Viewer\": \"Czytelnik\"\n    },\n    \"ValidationPanel\": {\n        \"Update formula (Shift+Enter)\": \"Aktualizuj formułę (Shift+Enter)\",\n        \"Rule {{length}}\": \"Zasada {{length}}\"\n    },\n    \"ViewConfigTab\": {\n        \"Make On-Demand\": \"Twórz na żądanie\",\n        \"Plugin: \": \"Wtyczka: \",\n        \"Advanced settings\": \"Ustawienia zaawansowane\",\n        \"Blocks\": \"Bloki\",\n        \"Compact\": \"Kompaktowy\",\n        \"Form\": \"Formularz\",\n        \"Edit card layout\": \"Edytuj układ karty\",\n        \"Section: \": \"Sekcja: \",\n        \"Unmark On-Demand\": \"Odznacz opcję Na żądanie\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Duże tabele mogą być oznaczone jako \\\"na żądanie\\\", aby uniknąć ładowania ich do silnika danych.\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"Tabele na żądanie zostały wycofane z powodu braku funkcjonalności i problemów z użytecznością.\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Przestarzała funkcja\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Copy anchor link\": \"Skopiuj link\",\n        \"Data selection\": \"Wybór danych\",\n        \"Delete record\": \"Usuń rekord\",\n        \"Open configuration\": \"Otwórz konfiguracje\",\n        \"Print widget\": \"Wydrukuj sekcję\",\n        \"Add to page\": \"Dodaj do strony\",\n        \"Collapse widget\": \"Zwiń widżet\",\n        \"Download as XLSX\": \"Pobierz jako XLSX\",\n        \"Edit card layout\": \"Edytuj układ karty\",\n        \"Delete widget\": \"Usuń widżet\",\n        \"Download as CSV\": \"Pobierz jako CSV\",\n        \"Show raw data\": \"Pokaż surowe dane\",\n        \"Widget options\": \"Ustawienia widoku\",\n        \"Advanced sort & filter\": \"Zaawansowane sortowanie i filtrowanie\",\n        \"Create a form\": \"Utwórz formularz\",\n        \"Duplicate widget\": \"Duplikuj widżet\"\n    },\n    \"ViewSectionMenu\": {\n        \"Custom options\": \"Opcje niestandardowe\",\n        \"Revert\": \"Przywrócić\",\n        \"SORT\": \"SORTOWANIE\",\n        \"Save\": \"Zapisz\",\n        \"(customized)\": \"(dostosowane)\",\n        \"(empty)\": \"(pusty)\",\n        \"(modified)\": \"(zmodyfikowany)\",\n        \"FILTER\": \"FILTR\",\n        \"Update Sort&Filter settings\": \"Zaktualizuj ustawienia sortowania i filtrowania\",\n        \"Sort and filter\": \"Sortuj i filtruj\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Clear\": \"Wyczyść\",\n        \"Select all\": \"Wybierz wszystko\",\n        \"Visible {{label}}\": \"Widoczny {{label}}\",\n        \"Hide {{label}}\": \"Ukryj {{label}}\",\n        \"Hidden {{label}}\": \"Ukryty {{label}}\",\n        \"Show {{label}}\": \"Pokaż {{label}}\",\n        \"Cannot drop items into Hidden Fields\": \"Nie można upuszczać elementów do ukrytych pól\",\n        \"Hidden Fields cannot be reordered\": \"Pola ukryte nie mogą być ponownie uporządkowane\",\n        \"Hide {{label}} (batch mode)\": \"Ukryj {{label}} (tryb seryjny)\",\n        \"Show {{label}} (batch mode)\": \"Pokaż {{label}} (tryb seryjny)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Edukacja\",\n        \"Research\": \"Badania\",\n        \"Type here\": \"Wpisz tutaj\",\n        \"Welcome to Grist!\": \"Witamy w Grist!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Co sprowadza cię do Grist? Pomóż nam lepiej Ci służyć.\",\n        \"Product Development\": \"Rozwój produktu\",\n        \"IT & Technology\": \"IT i technologia\",\n        \"Marketing\": \"Marketing\",\n        \"Media Production\": \"Produkcja medialna\",\n        \"Other\": \"Inne\",\n        \"Sales\": \"Sprzedaż\",\n        \"Finance & Accounting\": \"Finanse i Księgowość\",\n        \"HR & Management\": \"HR i zarządzanie\"\n    },\n    \"WidgetTitle\": {\n        \"DATA TABLE NAME\": \"NAZWA TABELI DANYCH\",\n        \"Override widget title\": \"Zastąp tytuł widżetu\",\n        \"WIDGET TITLE\": \"TYTUŁ WIDŻETU\",\n        \"Provide a table name\": \"Podaj nazwę tabeli\",\n        \"Cancel\": \"Anuluj\",\n        \"Save\": \"Zapisz\",\n        \"WIDGET DESCRIPTION\": \"OPIS WIDŻETU\"\n    },\n    \"breadcrumbs\": {\n        \"fiddle\": \"skrzypce\",\n        \"override\": \"nadpisać\",\n        \"snapshot\": \"migawka\",\n        \"unsaved\": \"niezapisany\",\n        \"recovery mode\": \"tryb odzyskiwania\",\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Możesz wprowadzać zmiany, ale one utworzą nową kopię i\\nnie mają wpływu na oryginalny dokument.\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Możesz wprowadzać zmiany,\\nale nie pojawią się one w oryginalnym dokumencie.\\nMożesz je zaproponować jako sugestie.\",\n        \"editing\": \"edytowanie\",\n        \"suggesting\": \"sugerowanie\"\n    },\n    \"duplicatePage\": {\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Zauważ, że to nie kopiuje danych, ale tworzy inny widok tych samych danych.\",\n        \"Duplicate page {{pageName}}\": \"Utwórz kopię stronę {{pageName}}\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Brak dostępu{{suffix}}\",\n        \"Go to main page\": \"Przejdź do strony głównej\",\n        \"Page not found{{suffix}}\": \"Nie znaleziono strony{{suffix}}\",\n        \"Sign in again\": \"Zaloguj się ponownie\",\n        \"Signed out{{suffix}}\": \"Wylogowano{{suffix}}\",\n        \"Something went wrong\": \"Coś poszło nie tak\",\n        \"There was an error: {{message}}\": \"Wystąpił błąd: {{message}}\",\n        \"There was an unknown error.\": \"Wystąpił nieznany błąd.\",\n        \"You are now signed out.\": \"Jesteś teraz wylogowany.\",\n        \"You do not have access to this organization's documents.\": \"Nie ma dostępu do dokumentów tej organizacji.\",\n        \"Error{{suffix}}\": \"Błąd{{suffix}}\",\n        \"Sign in\": \"Zaloguj się\",\n        \"Add account\": \"Dodaj konto\",\n        \"Contact support\": \"Skontaktuj się z pomocą techniczną\",\n        \"Sign in to access this organization's documents.\": \"Zaloguj się, aby uzyskać dostęp do dokumentów tej organizacji.\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Nie można odnaleźć strony. {{separator}} Sprawdź adres URL i spróbuj ponownie.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Jesteś zalogowany jako {{email}}. Możesz zalogować się na inne konto lub poprosić administratora o dostęp.\",\n        \"Account deleted{{suffix}}\": \"Konto usunięte{{suffix}}\",\n        \"Sign up\": \"Zarejestruj się\",\n        \"Your account has been deleted.\": \"Twoje konto zostało usunięte.\",\n        \"An unknown error occurred.\": \"Wystąpił nieznany błąd.\",\n        \"Build your own form\": \"Zbuduj własny formularz\",\n        \"Form not found\": \"Formularz nie znaleziony\",\n        \"Powered by\": \"Napędzane przez\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Logowanie nie powiodło się.{{separator}}Spróbuj ponownie lub skontaktuj się z pomocą techniczną.\",\n        \"Sign-in failed{{suffix}}\": \"Logowanie nie powiodło się{{suffix}}\",\n        \"Manage settings\": \"Zarządzaj ustawieniami\",\n        \"Need Help?\": \"Potrzebujesz pomocy?\",\n        \"There was an error\": \"Wystąpił błąd\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Nie będziesz już otrzymywać powiadomień e-mail o {{changes}} w {{docName}} na {{email}}.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Nie będziesz już otrzymywać powiadomień e-mail o {{comments}} w {{docName}} na {{email}}.\",\n        \"changes\": \"zmiany\",\n        \"comments\": \"komentarze\",\n        \"this document\": \"ten dokument\",\n        \"your email\": \"twój adres e-mail\",\n        \"Unsubscribed{{suffix}}\": \"Odsubskrybowano{{suffix}}\",\n        \"We could not unsubscribe you\": \"Nie mogliśmy Cię odsubskrybować\",\n        \"You are unsubscribed\": \"Jesteś odsubskrybowany\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Wciąż możesz odsubskrybować ten dokument poprzez aktualizację swoich preferencji w ustawieniach dokumentu\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* Obszary robocze są dostępne w planach zespołowych. \",\n        \"Upgrade now\": \"Uaktualnij teraz\",\n        \"Any\": \"Dowolny\",\n        \"Integer\": \"Liczba całkowita\",\n        \"Toggle\": \"Przełącznik\",\n        \"Date\": \"Data\",\n        \"Choice\": \"Wybór\",\n        \"Choice List\": \"Lista wyboru\",\n        \"Reference\": \"Odnośnik\",\n        \"Reference List\": \"Lista odnośników\",\n        \"Select fields\": \"Wybierz pola\",\n        \"Numeric\": \"Liczba\",\n        \"Text\": \"Tekst\",\n        \"DateTime\": \"Data i godzina\",\n        \"Attachment\": \"Załącznik\",\n        \"Search columns\": \"Wyszukaj kolumny\",\n        \"By Name\": \"Według nazwy\",\n        \"By Date Modified\": \"Według daty modyfikacji\",\n        \"Light\": \"Jasny\",\n        \"Custom\": \"Niestandardowy\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Anuluj\",\n        \"Save\": \"Zapisz\",\n        \"Ok\": \"OK\",\n        \"Are you sure you want to delete these records?\": \"Czy na pewno chcesz usunąć te rekordy?\",\n        \"Are you sure you want to delete this record?\": \"Czy na pewno chcesz usunąć ten rekord?\",\n        \"Delete\": \"Usuń\",\n        \"Dismiss\": \"Odrzuć\",\n        \"Don't ask again.\": \"Nie pytaj ponownie.\",\n        \"Don't show again.\": \"Nie pokazuj ponownie.\",\n        \"Don't show tips\": \"Nie pokazuj wskazówek\",\n        \"Undo to restore\": \"Cofnij, aby przywrócić\",\n        \"Got it\": \"Rozumiem\",\n        \"Don't show again\": \"Nie pokazuj ponownie\",\n        \"TIP\": \"WSKAZÓWKA\",\n        \"Confirm\": \"Potwierdź\"\n    },\n    \"pages\": {\n        \"You do not have edit access to this document\": \"Nie masz dostępu do edycji tego dokumentu\",\n        \"Duplicate page\": \"Utwórz kopię strony\",\n        \"Remove\": \"Usuń\",\n        \"Rename\": \"Zmień nazwę\",\n        \"(default)\": \"(domyślne)\",\n        \"Collapse {{maybeDefault}}\": \"Zwiń {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Rozwiń {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Ustaw domyślnie: Zwiń\",\n        \"Set default: Expand\": \"Ustaw domyślnie: Rozwiń\",\n        \"context menu - {{- pageName }}\": \"menu kontekstowe - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Search in document\": \"Szukaj w dokumencie\",\n        \"Find Next \": \"Znajdź następny \",\n        \"Find Previous \": \"Znajdź poprzedni \",\n        \"No results\": \"Brak wyników\",\n        \"Search\": \"Szukaj\",\n        \"Close search bar\": \"Zamknij pasek wyszukiwania\"\n    },\n    \"NTextBox\": {\n        \"true\": \"prawdziwy\",\n        \"false\": \"fałszywy\",\n        \"Field Format\": \"Format pola\",\n        \"Lines\": \"Linie\",\n        \"Multi line\": \"Wielowierszowy\",\n        \"Single line\": \"Jednowierszowy\"\n    },\n    \"ACLUsers\": {\n        \"Users from table\": \"Użytkownicy z tabeli\",\n        \"View as\": \"Wyświetl jako\",\n        \"Example Users\": \"Przykładowi Użytkownicy\",\n        \"Other users from table\": \"Inni użytkownicy z tabeli\",\n        \"Shared users\": \"Współdzieleni użytkownicy\"\n    },\n    \"TypeTransform\": {\n        \"Cancel\": \"Anuluj\",\n        \"Preview\": \"Podgląd\",\n        \"Revise\": \"Popraw\",\n        \"Apply\": \"Zastosuj\",\n        \"Update formula (Shift+Enter)\": \"Aktualizuj formułę (Shift+Enter)\"\n    },\n    \"CellStyle\": {\n        \"Cell style\": \"Styl komórki\",\n        \"Default cell style\": \"Domyślny styl komórki\",\n        \"Mixed style\": \"Styl mieszany\",\n        \"Open row styles\": \"Otwórz style wierszy\",\n        \"CELL STYLE\": \"STYL KOMÓRKI\",\n        \"Default header style\": \"Domyślny styl nagłówka\",\n        \"Header Style\": \"Styl nagłówka\",\n        \"HEADER STYLE\": \"STYL NAGŁÓWKA\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"OPIS KOLUMNY\",\n        \"COLUMN LABEL\": \"ETYKIETA KOLUMNY\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN ID: \": \"ID KOLUMNY: \",\n        \"COLUMN LABEL\": \"ETYKIETA KOLUMNY\",\n        \"Cancel\": \"Anuluj\",\n        \"Save\": \"Zapisz\",\n        \"COLUMN DESCRIPTION\": \"OPIS KOLUMNY\"\n    },\n    \"OnBoardingPopups\": {\n        \"Next\": \"Następny\",\n        \"Finish\": \"Zakończ\",\n        \"Previous\": \"Poprzedni\"\n    },\n    \"SortFilterConfig\": {\n        \"Revert\": \"Przywrócić\",\n        \"Filter\": \"FILTR\",\n        \"Save\": \"Zapisz\",\n        \"Sort\": \"SORTOWANIE\",\n        \"Update Sort & Filter settings\": \"Zaktualizuj ustawienia sortowania i filtrowania\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Nieznany użytkownik\",\n        \"View as Yourself\": \"Wyświetl jako siebie\",\n        \"You are viewing this document as\": \"Przeglądasz ten dokument jako\",\n        \"You're seeing what this user would see if given access\": \"Widzisz to, co zobaczyłby ten użytkownik, gdyby miał dostęp\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Skopiowane do schowka\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Importowanie nie powiodło się: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Aktualizowanie układu rekordu.\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Wygląd \",\n        \"Switch appearance automatically to match system\": \"Automatyczne przełączanie wyglądu w celu dopasowania do systemu\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Zarządzaj zespołem\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Wysyłanie pliku do Google Drive\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"WYBORY\"\n    },\n    \"FormulaEditor\": {\n        \"Error in the cell\": \"Błąd w komórce\",\n        \"editingFormula is required\": \"edycja Formuły jest wymagana\",\n        \"Column or field is required\": \"Kolumna lub pole jest wymagane\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Błędy w {{numErrors}} komórek {{numCells}}\",\n        \"Errors in all {{numErrors}} cells\": \"Błędy we wszystkich komórkach {{numErrors}}\",\n        \"Enter formula or {{button}}.\": \"Wprowadź formułę lub {{button}}.\",\n        \"Enter formula.\": \"Wprowadź formułę.\",\n        \"Expand Editor\": \"Rozwiń edytor\",\n        \"use AI Assistant\": \"użyj Asystenta AI\"\n    },\n    \"FieldEditor\": {\n        \"Unable to finish saving edited cell\": \"Nie można zakończyć zapisywania edytowanej komórki\",\n        \"It should be impossible to save a plain data value into a formula column\": \"Zapisanie zwykłej wartości danych w kolumnie formuły powinno być niemożliwe\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[etykieta linku] URL\"\n    },\n    \"NumericTextBox\": {\n        \"Decimals\": \"Miejsc dziesiętnych\",\n        \"Default currency ({{defaultCurrency}})\": \"Waluta domyślna ({{defaultCurrency}})\",\n        \"Number Format\": \"Format liczb\",\n        \"Currency\": \"Waluta\",\n        \"Field Format\": \"Format pola\",\n        \"Spinner\": \"Pokrętło\",\n        \"Text\": \"Tekst\",\n        \"max\": \"maks.\",\n        \"min\": \"min\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"Dodaj kolejną regułę\",\n        \"Add conditional style\": \"Dodaj styl warunkowy\",\n        \"Error in style rule\": \"Błąd w regule stylu\",\n        \"Row style\": \"Styl wiersza\",\n        \"Rule must return True or False\": \"Reguła musi zwracać wartość Prawda lub Fałsz\",\n        \"Conditional Style\": \"Styl warunkowy\",\n        \"IF...\": \"JEŚLI...\",\n        \"Row Style\": \"Styl wiersza\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Nieprawidłowa waluta\"\n    },\n    \"DiscussionEditor\": {\n        \"Only current page\": \"Tylko bieżąca strona\",\n        \"Remove\": \"Usuń\",\n        \"Resolve\": \"Rozwiązać\",\n        \"Save\": \"Zapisać\",\n        \"Showing last {{nb}} comments\": \"Wyświetlanie ostatnich {{nb}} komentarzy\",\n        \"Started discussion\": \"Rozpoczęto dyskusję\",\n        \"Write a comment\": \"Napisz komentarz\",\n        \"Only my threads\": \"Tylko moje wątki\",\n        \"Open\": \"Otwórz\",\n        \"Reply to a comment\": \"Odpowiedz na komentarz\",\n        \"Show resolved comments\": \"Pokaż rozwiązane komentarze\",\n        \"Cancel\": \"Anuluj\",\n        \"Comment\": \"Komentarz\",\n        \"Edit\": \"Edytuj\",\n        \"Marked as resolved\": \"Oznaczone jako rozwiązane\",\n        \"Reply\": \"Odpowiedź\",\n        \"Remove thread\": \"Usuń wątek\",\n        \"updated\": \"zaktualizowano\",\n        \"{{count}} comments_one\": \"{{count}}. komentarz\",\n        \"{{count}} comments_other\": \"{{count}} komentarzy\",\n        \"Copy link\": \"Skopiuj link\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Konwertuj kolumnę na formułę\"\n    },\n    \"FieldBuilder\": {\n        \"Apply formula to data\": \"Zastosuj formułę do danych\",\n        \"CELL FORMAT\": \"FORMAT KOMÓRKI\",\n        \"DATA FROM TABLE\": \"DANE Z TABELI\",\n        \"Mixed format\": \"Format mieszany\",\n        \"Mixed types\": \"Rodzaje mieszane\",\n        \"Save field settings for {{colId}} as common\": \"Zapisz ustawienia pola dla {{colId}} jako typowe\",\n        \"Use separate field settings for {{colId}}\": \"Użyj oddzielnych ustawień pól dla {{colId}}\",\n        \"Revert field settings for {{colId}} to common\": \"Przywróć ustawienia pola dla {{colId}} do wspólnych\",\n        \"Changing multiple column types\": \"Zmienianie wielu typów kolumn\",\n        \"Changing column type\": \"Zmiana typu kolumny\",\n        \"Common\": \"Wspólny\",\n        \"Separate\": \"Oddzielny\",\n        \"Field in {{count}} views_one\": \"Pola w jednym widoku\",\n        \"Field in {{count}} views_other\": \"Pole w {{count}} widokach\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"FORMAT KOMÓRKI\",\n        \"Row ID\": \"Identyfikator wiersza\",\n        \"SHOW COLUMN\": \"POKAŻ KOLUMNĘ\"\n    },\n    \"WelcomeTour\": {\n        \"Add new\": \"Dodaj nowy\",\n        \"Building up\": \"Budowanie\",\n        \"Flying higher\": \"Latanie wyżej\",\n        \"Help Center\": \"Centrum pomocy\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Spraw, aby był relacyjny! Użyj typu {{ref}}, aby połączyć tabele. \",\n        \"Reference\": \"Odnośnik\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Ustaw opcje formatowania, formuły lub typy kolumn, takie jak daty, wybory lub załączniki. \",\n        \"Sharing\": \"Udostępnianie\",\n        \"Start with {{equal}} to enter a formula.\": \"Zacznij od {{equal}}, aby wprowadzić formułę.\",\n        \"convert to card view, select data, and more.\": \"Konwertuj na widok karty, wybierz Dane i nie tylko.\",\n        \"creator panel\": \"Panel tworzenia\",\n        \"template library\": \"Biblioteka szablonów\",\n        \"Configuring your document\": \"Konfigurowanie dokumentu\",\n        \"Customizing columns\": \"Dostosowywanie kolumn\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Kliknij dwukrotnie lub naciśnij {{enter}} na komórkę, aby ją edytować. \",\n        \"Enter\": \"Wejść\",\n        \"Editing Data\": \"Edycja danych\",\n        \"Welcome to Grist!\": \"Witamy w Grist!\",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Użyj {{helpCenter}} do dokumentacji lub pytań.\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Przeglądaj nasze {{templateLibrary}}, aby odkryć, co jest możliwe i zainspirować się.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Przełącz {{creatorPanel}}, aby sformatować kolumny, \",\n        \"Share\": \"Udostępnij\",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Użyj przycisku Udostępnij ({{share}}), aby udostępnić dokument lub wyeksportować dane.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Użyj {{addNew}}, aby dodać widżety, strony lub zaimportować więcej danych. \",\n        \"AI Assistant\": \"Asystent AI\"\n    },\n    \"GristTooltips\": {\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Kliknij na \\\"Otwórz style wierszy\\\", aby zastosować formatowanie warunkowe do wierszy.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Kliknij przycisk Dodaj nowy, aby utworzyć nowe dokumenty lub obszary robocze albo zaimportować dane.\",\n        \"Learn more.\": \"Dowiedz się więcej.\",\n        \"Link your new widget to an existing widget on this page.\": \"Połącz swój nowy widget z istniejącym widgetem na tej stronie.\",\n        \"Nested Filtering\": \"Filtrowanie zagnieżdżone\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Przypięte filtry są wyświetlane jako przyciski nad widżetem.\",\n        \"Pinning Filters\": \"Przypinanie filtrów\",\n        \"Raw Data page\": \"Strona z danymi surowymi\",\n        \"Reference Columns\": \"Kolumny referencyjne\",\n        \"Select the table containing the data to show.\": \"Wybierz tabelę zawierającą dane do wyświetlenia.\",\n        \"Select the table to link to.\": \"Wybierz tabelę, do której chcesz utworzyć łącze.\",\n        \"Selecting Data\": \"Wybieranie danych\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Strona Dane surowe zawiera listę wszystkich tabel danych w dokumencie, w tym tabel podsumowania i tabel nieuwzględnionych w układach stron.\",\n        \"The total size of all data in this document, excluding attachments.\": \"Całkowity rozmiar wszystkich danych w tym dokumencie, z wyłączeniem załączników.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Wypróbuj zmiany w kopii, a następnie zdecyduj, czy zastąpić oryginał zmianami.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Odepnij, aby ukryć przycisk, zachowując filtr.\",\n        \"You can filter by more than one column.\": \"Możesz filtrować według więcej niż jednej kolumny.\",\n        \"entire\": \"cały\",\n        \"Editing Card Layout\": \"Edytuj układ karty\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Formuły, które wyzwalają się w określonych przypadkach i przechowują obliczoną wartość jako dane.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Przydatne do przechowywania znacznika czasu lub autora nowego rekordu, czyszczenia danych i nie tylko.\",\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Zastosuj formatowanie warunkowe do komórek w tej kolumnie, gdy spełnione są warunki formuły.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Zastosuj formatowanie warunkowe do wierszy na podstawie formuł.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Komórki w kolumnie odwołania zawsze identyfikują rekord {{entire}} w tej tabeli, ale można wybrać kolumnę z tego rekordu do wyświetlenia.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Kliknięcie {{EyeHideIcon}} w każdej komórce powoduje ukrycie pola w tym widoku bez jego usuwania.\",\n        \"Only those rows will appear which match all of the filters.\": \"Pojawią się tylko te wiersze, które pasują do wszystkich filtrów.\",\n        \"Linking Widgets\": \"Połączenie widżetów\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Zmień kolejność pól na karcie, przeciągając komórki i zmieniając ich rozmiar.\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Kolumny referencyjne są kluczem do {{relational}} danych w Grist.\",\n        \"They allow for one record to point (or refer) to another.\": \"Pozwalają one na to, aby jeden rekord wskazywał (lub odnosił się) do innego.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"To jest sekret dynamicznych i produktywnych układów Grist.\",\n        \"Updates every 5 minutes.\": \"Aktualizacje co 5 minut.\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Użyj ikony Σ, aby utworzyć tabele podsumowujące (lub przestawne) dla sum lub sum częściowych.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Użyj ikony \\\\u{1D6BA}, aby utworzyć tabele podsumowujące (przestawne) dla sum lub sum częściowych.\",\n        \"relational\": \"relacyjny\",\n        \"Access Rules\": \"Reguły dostępu\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Reguły dostępu umożliwiają tworzenie reguł szczegółowych w celu określenia, kto może wyświetlać lub edytować poszczególne części dokumentu.\",\n        \"Add new\": \"Dodaj nowy\",\n        \"Anchor Links\": \"Linki kotwiczące\",\n        \"Custom Widgets\": \"Niestandardowe widżety\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Aby utworzyć link kotwiczący, który przeniesie użytkownika do konkretnej komórki, kliknij wiersz i naciśnij {{shortcut}}.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Możesz wybrać jeden z naszych gotowych widżetów lub osadzić własny, podając jego pełny adres URL.\",\n        \"Calendar\": \"Kalendarz\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Nie możesz znaleźć odpowiednich kolumn? Kliknij 'Zmień widżet', aby wybrać tabelę z danymi wydarzeń.\",\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID to losowo generowany ciąg znaków przydatny do unikalnych identyfikatorów i kluczy łączy.\",\n        \"Lookups return data from related tables.\": \"Wyszukiwania zwracają dane z powiązanych tabel.\",\n        \"Use reference columns to relate data in different tables.\": \"Używaj kolumn referencyjnych do powiązania danych w różnych tabelach.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Możesz wybrać z widżetów dostępnych w rozwijanym menu lub osadzić własny, podając jego pełny adres URL.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Formuły obsługują wiele funkcji Excela, pełną składnię Pythona i zawierają pomocnego Asystenta AI.\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Buduj proste formularze bezpośrednio w Grist i udostępniaj je jednym kliknięciem za pomocą naszego nowego widżetu. {{learnMoreButton}}\",\n        \"Forms are here!\": \"Formularze są tutaj!\",\n        \"Learn more\": \"Dowiedz się więcej\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Te zasady są stosowane po przetworzeniu wszystkich zasad kolumn, jeśli ma to zastosowanie.\",\n        \"Example: {{example}}\": \"Przykład: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"Filtruj wyświetlane wartości rozwijane według warunku.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Widżety społeczności są tworzone i utrzymywane przez członków społeczności Grist.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Tworzy odwrotną kolumnę w tabeli docelowej, którą można edytować z obu stron.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"To ograniczenie występuje, gdy jeden koniec dwukierunkowego odniesienia jest skonfigurowany jako pojedyncza Referencja.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Aby zezwolić na wiele przypisań, zmień typ kolumny Referencja na Lista referencji.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"To ograniczenie występuje, gdy jedna kolumna w dwukierunkowym odniesieniu ma typ Referencja.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Aby zezwolić na wiele przypisań, zmień typ przywoływanej kolumny na Lista referencji.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Odnośniki dwukierunkowe nie są obecnie obsługiwane dla kolumn Formuła lub Formuła wyzwalacza\",\n        \"The preview below this header shows how the selected user will see this document\": \"Podgląd pod tym nagłówkiem pokazuje, jak wybrany użytkownik zobaczy ten dokument\",\n        \"[Learn more.]({{link}})\": \"[Dowiedz się więcej.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Tabele podsumowujące mogą zawierać tylko kolumny formuł.\",\n        \"Manage users and resources in a Grist installation.\": \"Zarządzaj użytkownikami i zasobami w instalacji Grist.\",\n        \"The new Grist Assistant is here!\": \"Nowy Asystent Grist jest tutaj!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Formuły obsługują wiele funkcji Excela i pełną składnię Pythona.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Tworzy nową kolumnę Lista referencji w tabeli docelowej, przy czym zarówno ta, jak i kolumny docelowe są edytowalne i synchronizowane.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Przechowywanie wewnętrzne oznacza, że wszystkie załączniki są przechowywane w pliku SQLite dokumentu, podczas gdy przechowywanie zewnętrzne oznacza, że wszystkie załączniki są przechowywane w tym samym zewnętrznym magazynie.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Pozwala to dodać załączniki, których brakuje w magazynie zewnętrznym, np. w zaimportowanym dokumencie. Tylko archiwa załączników .tar pobrane z Grist mogą być tutaj przesyłane.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Zrozum, modyfikuj i pracuj ze swoimi danymi i formułami przy pomocy nowego Asystenta AI Grist!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs. Do not submit passwords through this form, and be careful with links in it. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Ten formularz został utworzony przez użytkownika Grist i nie jest popierany przez Grist Labs. Nie przesyłaj haseł przez ten formularz i zachowaj ostrożność wobec linków w nim zawartych. Zgłoś złośliwe formularze na adres [{{mail}}](mailto:{{mail}}).\",\n        \"Set the maximum number of lines for multi-line text.\": \"Ustaw maksymalną liczbę wierszy dla tekstu wielowierszowego.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"Aby skonfigurować kalendarz, wybierz kolumny dat początkowych/końcowych i nazw wydarzeń. Zwróć uwagę na tyup każdej kolumny.\"\n        },\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Ten formularz został stworzony przez użytkownika Grista i nie jest polecany przez Grist Labs, Inc. lub inne strony zapewniające tę usługę. Dla bezpieczeństwa, nie wpisuj haseł do tego formularza i zachowaj ostrożność przy klikaniu wbudowanych linków. Podejrzane formularze możesz zgłaszać na adres [{{mail}}](mailto:{{mail}}).\",\n        \"Comments are here!\": \"Tu są komentarze!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Możesz dodawać komentarze do komórek, odpowiadać na wątki i @oznaczać współpracowników.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"Po zaznaczeniu, domyślna wartość tego pola będzie mogła być wypełniona przez URL za pomocą parametrów zapytania.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"Dzięki sugestiom, użytkownicy mogą tworzyć zmiany w osobistych kopiach dokumentu bez modyfikacji oryginału, a następnie publikować sugestie do recenzji przez właściciela przed integracją.\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"OPIS\",\n        \"Set description\": \"Ustaw opis\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Język\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"Zamknij panel twórcy\",\n        \"Open creator panel\": \"Otwórz panel twórcy\",\n        \"Creator panel (right panel)\": \"Panel twórcy (prawy panel)\",\n        \"Document header\": \"Nagłówek dokumentu\",\n        \"Main content\": \"Główna zawartość\",\n        \"Main navigation and document settings (left panel)\": \"Główna nawigacja i ustawienia dokumentu (lewy panel)\",\n        \"Close navigation panel (left panel)\": \"Zamknij panel nawigacji (lewy panel)\",\n        \"Open navigation panel (left panel)\": \"Otwórz panel nawigacji (lewy panel)\"\n    },\n    \"ColumnTitle\": {\n        \"Add description\": \"Dodaj opis\",\n        \"COLUMN ID: \": \"ID KOLUMNY: \",\n        \"Cancel\": \"Anuluj\",\n        \"Column ID copied to clipboard\": \"ID kolumny skopiowano do schowka\",\n        \"Column description\": \"Opis kolumny\",\n        \"Column label\": \"Etykieta kolumny\",\n        \"Provide a column label\": \"Podaj etykietę kolumny\",\n        \"Save\": \"Zapisz\",\n        \"Close\": \"Zamknij\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Rozumiem\",\n        \"Unavailable Command\": \"Niedostępna komenda\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"Polecenie menu {{action}} jest niedostępne w tej przeglądarce. Nadal możesz {{action}} za pomocą skrótu klawiaturowego {{shortcut}}.\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"Wyczyść pole\",\n        \"Copy\": \"Kopiuj\",\n        \"Copy anchor link\": \"Kopiuj link kotwiczący\",\n        \"Cut\": \"Wytnij\",\n        \"Hide field\": \"Ukryj pole\",\n        \"Paste\": \"Wklej\",\n        \"Comment\": \"Komentarz\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Wyczyść kolejkę\",\n        \"Webhook settings\": \"Ustawienia webhooka\",\n        \"Cleared webhook queue.\": \"Wyczyszczono kolejkę webhooków.\",\n        \"Columns to check when update (separated by ;)\": \"Kolumny do sprawdzenia przy aktualizacji (oddzielone ;)\",\n        \"Enabled\": \"Włączone\",\n        \"Event Types\": \"Typy zdarzeń\",\n        \"Memo\": \"Notatka\",\n        \"Name\": \"Nazwa\",\n        \"Ready Column\": \"Kolumna gotowości\",\n        \"Removed webhook.\": \"Usunięto webhook.\",\n        \"Sorry, not all fields can be edited.\": \"Przepraszamy, nie wszystkie pola można edytować.\",\n        \"Status\": \"Status\",\n        \"URL\": \"URL\",\n        \"Webhook Id\": \"Id webhooka\",\n        \"Table\": \"Tabela\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Filtruj zmiany w tych kolumnach (identyfikatory oddzielone średnikiem)\",\n        \"Header Authorization\": \"Autoryzacja nagłówka\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooki niedostępne w niezapisanych kopiach dokumentów\"\n    },\n    \"FormulaAssistant\": {\n        \"Ask the bot.\": \"Zapytaj bota.\",\n        \"Capabilities\": \"Możliwości\",\n        \"Community\": \"Społeczność\",\n        \"Data\": \"Dane\",\n        \"Formula Cheat Sheet\": \"Ściąga formuł\",\n        \"Formula Help. \": \"Pomoc formuł. \",\n        \"Function List\": \"Lista funkcji\",\n        \"Grist's AI Assistance\": \"Asystent AI Grist\",\n        \"Grist's AI Formula Assistance. \": \"Asystent formuł AI Grist \",\n        \"Need help? Our AI assistant can help.\": \"Potrzebujesz pomocy? Nasz asystent AI może pomóc.\",\n        \"New Chat\": \"Nowa rozmowa\",\n        \"Preview\": \"Podgląd\",\n        \"Regenerate\": \"Wygeneruj ponownie\",\n        \"Save\": \"Zapisz\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Zobacz nasz {{helpFunction}} i {{formulaCheat}} lub odwiedź naszą {{community}}, aby uzyskać więcej pomocy.\",\n        \"Tips\": \"Wskazówki\",\n        \"AI Assistant\": \"Asystent AI\",\n        \"Apply\": \"Zastosuj\",\n        \"Cancel\": \"Anuluj\",\n        \"Clear conversation\": \"Wyczyść rozmowę\",\n        \"Code view\": \"Widok kodu\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Cześć, jestem Asystentem AI Formuł Grist.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Mogę pomóc tylko z formułami. Nie mogę budować tabel, kolumn i widoków ani pisać reguł dostępu.\",\n        \"Learn more\": \"Dowiedz się więcej\",\n        \"Press Enter to apply suggested formula.\": \"Naciśnij Enter, aby zastosować sugerowaną formułę.\",\n        \"Sign Up for Free\": \"Zarejestruj się za darmo\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Zarejestruj bezpłatne konto Grist, aby rozpocząć korzystanie z Asystenta Formuł AI.\",\n        \"There are some things you should know when working with me:\": \"Jest kilka rzeczy, które powinieneś wiedzieć, pracując ze mną:\",\n        \"What do you need help with?\": \"Z czym potrzebujesz pomocy?\",\n        \"Formula AI Assistant is only available for logged in users.\": \"Asystent Formuł AI jest dostępny tylko dla zalogowanych użytkowników.\",\n        \"For higher limits, contact the site owner.\": \"Aby uzyskać wyższe limity, skontaktuj się z właścicielem strony.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Aby uzyskać wyższe limity, {{upgradeNudge}}.\",\n        \"You have used all available credits.\": \"Wykorzystałeś wszystkie dostępne kredyty.\",\n        \"You have {{numCredits}} remaining credits.\": \"Pozostało Ci {{numCredits}} kredytów.\",\n        \"upgrade to the Pro Team plan\": \"przejdź na plan Pro Team\",\n        \"upgrade your plan\": \"ulepsz swój plan\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Aby uzyskać więcej pomocy dotyczącej formuł, sprawdź naszą {{functionList}} i {{formulaCheatSheet}} lub odwiedź naszą {{community}}.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Kiedy ze mną rozmawiasz, Twoje pytania i struktura dokumentu (widoczna w {{codeView}}) są wysyłane do OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Rozmawiaj ze mną jak z człowiekiem. Nie musisz określać nazw tabel i kolumn. Na przykład, możesz zapytać \\\"Proszę obliczyć całkowitą kwotę faktury.\\\"\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Kliknij, aby wstawić\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"Witaj z powrotem\",\n        \"You can always switch sites using the account menu.\": \"Zawsze możesz przełączyć strony za pomocą menu konta.\",\n        \"You have access to the following Grist sites.\": \"Masz dostęp do następujących stron Grist.\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"OPIS\"\n    },\n    \"UserManager\": {\n        \"Add {{member}} to your team\": \"Dodaj {{member}} do swojego zespołu\",\n        \"Allow anyone with the link to open.\": \"Zezwól każdemu z linkiem na otwarcie.\",\n        \"Anyone with link \": \"Każdy z linkiem \",\n        \"Cancel\": \"Anuluj\",\n        \"Close\": \"Zamknij\",\n        \"Collaborator\": \"Współpracownik\",\n        \"Confirm\": \"Potwierdź\",\n        \"Copy link\": \"Kopiuj link\",\n        \"Create a team to share with more people\": \"Utwórz zespół, aby udostępniać większej liczbie osób\",\n        \"Grist support\": \"wsparcie Grist\",\n        \"Guest\": \"Gość\",\n        \"Invite multiple\": \"Zaproś wielu\",\n        \"Invite people to {{resourceType}}\": \"Zaproś ludzi do {{resourceType}}\",\n        \"Link copied to clipboard\": \"Link skopiowany do schowka\",\n        \"Manage members of team site\": \"Zarządzaj członkami strony zespołowej\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Brak domyślnego dostępu umożliwia przyznanie dostępu do pojedynczych dokumentów lub obszarów roboczych zamiast do całej witryny zespołu.\",\n        \"Off\": \"Wył.\",\n        \"On\": \"Wł.\",\n        \"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}}.\": \"Po usunięciu własnego dostępu nie będziesz mógł go odzyskać bez pomocy kogoś innego z wystarczającym dostępem do {{name}}.\",\n        \"Open Access Rules\": \"Otwórz zasady dostępu\",\n        \"Outside collaborator\": \"Zewnętrzny współpracownik\",\n        \"Public access\": \"dostęp publiczny\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Dostęp publiczny odziedziczony po {{parent}}. Aby usunąć, ustaw opcję 'Dziedzicz dostęp' na 'Brak'.\",\n        \"Public access: \": \"Dostęp publiczny: \",\n        \"Remove my access\": \"Usuń mój dostęp\",\n        \"Save & \": \"Zapisz & \",\n        \"Team member\": \"Członek zespołu\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Użytkownik dziedziczy uprawnienia od {{parent})}. Aby usunąć, ustaw opcję 'Dziedzicz dostęp' na 'Brak'.\",\n        \"User may not modify their own access.\": \"Użytkownik nie może modyfikować własnego dostępu.\",\n        \"Your role for this team site\": \"Twoja rola dla tej strony zespołowej\",\n        \"Your role for this {{resourceType}}\": \"Twoja rola dla tego {{resourceType}}\",\n        \"free collaborator\": \"darmowy współpracownik\",\n        \"guest\": \"gość\",\n        \"member\": \"członek\",\n        \"team site\": \"strona zespołowa\",\n        \"{{collaborator}} limit exceeded\": \"przekroczono limit {{collaborator}}\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} z {{limitTop}} {{collaborator}}\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Brak domyślnego dostępu pozwala na przyznanie dostępu do poszczególnych dokumentów lub obszarów roboczych, a nie do całej strony zespołowej.\",\n        \"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}}.\": \"Po usunięciu własnego dostępu nie będziesz mógł go odzyskać bez pomocy kogoś innego z wystarczającym dostępem do {{resourceType}}.\",\n        \"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.\": \"Użytkownik ma dostęp do przeglądania {{resource}} wynikający z ręcznie ustawionego dostępu do zasobów wewnątrz. Jeśli zostanie usunięty tutaj, ten użytkownik straci dostęp do zasobów wewnątrz.\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Użytkownik dziedziczy uprawnienia od {{parent}}. Aby usunąć, ustaw opcję 'Dziedzicz dostęp' na 'Brak'.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Zamierzasz usunąć własny dostęp do tego {{resourceType}}\",\n        \"Inherit access: \": \"Dziedzicz dostęp: \",\n        \"Access overview\": \"Przegląd dostępu\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Szukaj na wszystkich stronach\",\n        \"Search all tables\": \"Szukaj we wszystkich tabelach\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Szukaj\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"Pokazano {{displayedCount}} z {{totalCount}} elementów. Wyszukaj aby znaleźć więcej.\"\n    },\n    \"SupportGristNudge\": {\n        \"Close\": \"Zamknij\",\n        \"Contribute\": \"Wspieraj\",\n        \"Help Center\": \"Centrum pomocy\",\n        \"Opt in to Telemetry\": \"Zgódź się na telemetrię\",\n        \"Opted In\": \"Zgoda udzielona\",\n        \"Support Grist\": \"Wesprzyj Grist\",\n        \"Support Grist page\": \"Strona wsparcia Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Dziękujemy! Twoje zaufanie i wsparcie są bardzo doceniane. Możesz zrezygnować w dowolnym momencie za pomocą {{link}} w menu użytkownika.\",\n        \"Admin Panel\": \"Panel administratora\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"Strona sponsorów GitHub\",\n        \"Help Center\": \"Centrum pomocy\",\n        \"Home\": \"Strona główna\",\n        \"Manage Sponsorship\": \"Zarządzaj sponsoringiem\",\n        \"Opt in to Telemetry\": \"Zgódź się na telemetrię\",\n        \"Opt out of Telemetry\": \"Zrezygnuj z telemetrii\",\n        \"Sponsor Grist Labs on GitHub\": \"Sponsoruj Grist Labs na GitHub\",\n        \"Support Grist\": \"Wesprzyj Grist\",\n        \"Telemetry\": \"Telemetria\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Ta instancja wyraziła zgodę na telemetrię. Tylko administrator strony ma uprawnienia aby to zmienić.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Ta instancja zrezygnowała z telemetrii. Tylko administrator strony ma uprawnienia aby to zmienić.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Zbieramy tylko statystyki użycia, jak szczegółowo opisano w naszym {{link}}, nigdy zawartość dokumentów.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Możesz zrezygnować z telemetrii w dowolnym momencie na tej stronie.\",\n        \"You have opted in to telemetry. Thank you!\": \"Wyraziłeś zgodę na telemetrię. Dziękujemy!\",\n        \"You have opted out of telemetry.\": \"Zrezygnowałeś z telemetrii.\",\n        \"Sponsor\": \"Sponsor\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Oprogramowanie Grist jest rozwijane przez Grist Labs, które oferuje darmowe i płatne plany hostowane. Udostępniamy również kod Grist na standardowej, darmowej i otwartej licencji OSS (Apache 2.0) na {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Wesprzyj Grist, wyrażając zgodę na telemetrię, która pomaga nam zrozumieć, jak produkt jest używany, dzięki czemu możemy ustalać priorytety przyszłych ulepszeń.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Jesteśmy małym i zdeterminowanym zespołem. Twoje wsparcie bardzo się dla nas liczy. Pokazuje to również innym, że za tym produktem stoi zdeterminowana społeczność.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Możesz wesprzeć rozwój Grist o otwartym kodzie źródłowym, sponsorując nas na naszym {{link}}.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Brak danych\",\n        \"No row selected in {{title}}\": \"Nie wybrano wiersza w {{title}}\",\n        \"Not all data is shown\": \"Nie wszystkie dane są pokazane\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Zwiń edytor\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Maksymalizuj\",\n        \"Minimize\": \"Minimalizuj\"\n    },\n    \"CardContextMenu\": {\n        \"Copy anchor link\": \"Kopiuj link kotwiczący\",\n        \"Delete card\": \"Usuń kartę\",\n        \"Duplicate card\": \"Duplikuj kartę\",\n        \"Insert card\": \"Wstaw kartę\",\n        \"Insert card above\": \"Wstaw kartę powyżej\",\n        \"Insert card below\": \"Wstaw kartę poniżej\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Ukryte pola\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"darmowa rozmowa coachingowa\",\n        \"Maybe later\": \"Może później\",\n        \"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.\": \"Podczas rozmowy poświęcimy czas na zrozumienie Twoich potrzeb i dostosowanie rozmowy do Ciebie. Możemy pokazać Ci podstawy Grist lub od razu zacząć pracę z Twoimi danymi, aby zbudować potrzebne Ci pulpity nawigacyjne.\",\n        \"Schedule call\": \"Zaplanuj rozmowę\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Zaplanuj swoją {{freeCoachingCall}} z członkiem naszego zespołu.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Możesz także sprawdzić {{ourWeeklyWebinars}}, aby dowiedzieć się więcej o Grist.\",\n        \"our weekly webinars\": \"nasze cotygodniowe webinary\",\n        \"Free coaching call\": \"Darmowe połączenie telefoniczne z konsultacją\",\n        \"Grist 101\": \"Podstawy Grista\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"Sprawdź także nasz wprowadzający webinar, {{ourWeeklyWebinars}}, stworzony do pomocy nowym użytkownikom w używaniu Grista.\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"Sprawdź także nasz wprowadzający webinar {{ourWeeklyWebinars}}, stworzony do pomocy nowym użytkownikom Grista.\"\n    },\n    \"FormView\": {\n        \"Publish\": \"Opublikuj\",\n        \"Publish your form?\": \"Opublikować formularz?\",\n        \"Unpublish\": \"Cofnij publikację\",\n        \"Unpublish your form?\": \"Cofnąć publikację formularza?\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Każdy, kto ma poniższy link, może zobaczyć pusty formularz i przesłać odpowiedź.\",\n        \"Are you sure you want to reset your form?\": \"Czy na pewno chcesz zresetować formularz?\",\n        \"Code copied to clipboard\": \"Kod skopiowany do schowka\",\n        \"Copy code\": \"Kopiuj kod\",\n        \"Copy link\": \"Kopiuj link\",\n        \"Embed this form\": \"Osadź ten formularz\",\n        \"Link copied to clipboard\": \"Link skopiowany do schowka\",\n        \"Preview\": \"Podgląd\",\n        \"Reset\": \"Resetuj\",\n        \"Reset form\": \"Resetuj formularz\",\n        \"Save your document to publish this form.\": \"Zapisz dokument, aby opublikować ten formularz.\",\n        \"Share\": \"Udostępnij\",\n        \"Share this form\": \"Udostępnij ten formularz\",\n        \"View\": \"Widok\",\n        \"# **Form Title**\": \"# **Tytuł formularza**\",\n        \"Your form description goes here.\": \"Tutaj umieść opis swojego formularza.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"Opublikowanie formularza wygeneruje link do udostępnienia. Każdy, kto ma link, może zobaczyć pusty formularz i przesłać odpowiedź.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Cofnięcie publikacji formularza spowoduje wyłączenie linku udostępniania, więc użytkownicy uzyskujący dostęp do formularza za pomocą tego linku zobaczą błąd.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Użytkownicy są ograniczeni do przesyłania wpisów (rekordów w Twojej tabeli) i odczytywania wstępnie ustawionych wartości w wyznaczonych polach, takich jak kolumny referencyjne i wyboru.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Twój formularz jest opublikowany. Każda zmiana jest na żywo i widoczna dla użytkowników z dostępem do formularza. Jeśli chcesz wprowadzać zmiany w wersji roboczej, cofnij publikację formularza.\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Usuń\"\n    },\n    \"Menu\": {\n        \"Building blocks\": \"Bloki konstrukcyjne\",\n        \"Columns\": \"Kolumny\",\n        \"Copy\": \"Kopiuj\",\n        \"Cut\": \"Wytnij\",\n        \"Insert question above\": \"Wstaw pytanie powyżej\",\n        \"Insert question below\": \"Wstaw pytanie poniżej\",\n        \"Paragraph\": \"Akapit\",\n        \"Paste\": \"Wklej\",\n        \"Separator\": \"Separator\",\n        \"Unmapped fields\": \"Niezmapowane pola\",\n        \"Header\": \"Nagłówek\",\n        \"New question\": \"Nowe pytanie\",\n        \"More\": \"Więcej\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Clear\": \"Wyczyść\",\n        \"Map fields\": \"Mapuj pola\",\n        \"Mapped\": \"Zmapowane\",\n        \"Select all\": \"Zaznacz wszystkie\",\n        \"Unmap fields\": \"Cofnij mapowanie pól\",\n        \"Unmapped\": \"Niezmapowane\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Zasady pól\",\n        \"Required field\": \"Wymagane pole\",\n        \"Ascending\": \"Rosnąco\",\n        \"Default\": \"Domyślne\",\n        \"Descending\": \"Malejąco\",\n        \"Field Format\": \"Format pola\",\n        \"Field Rules\": \"Zasady pola\",\n        \"Horizontal\": \"Poziome\",\n        \"Options Alignment\": \"Wyrównanie opcji\",\n        \"Options Sort Order\": \"Kolejność sortowania opcji\",\n        \"Radio\": \"Radio\",\n        \"Select\": \"Wybierz\",\n        \"Vertical\": \"Pionowe\",\n        \"Accept value from URL\": \"Zaakceptuj wartość z URL\",\n        \"Hidden field\": \"Ukryte pole\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"Parametr URL:\\n{{colId}}=VALUE\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Niektóre wymagane kolumny nie są zmapowane\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Aby użyć tego widżetu, zamapuj wszystkie nieopcjonalne kolumny z panelu twórcy po prawej stronie.\",\n        \"Some required columns are hidden by access rules\": \"Niektóre wymagane kolumny są ukryte przez reguły dostępu\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Aby użyć tego widżetu, wszystkie zmapowane kolumny muszą być widoczne. Skontaktuj się z właścicielem dokumentu lub zmodyfikuj reguły dostępu.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Zbuduj własny formularz\",\n        \"Powered by\": \"Napędzane przez\",\n        \"Powered by Grist\": \"Napędzane przez Grist\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Błąd\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"Ups! Formularz, którego szukasz, nie istnieje.\",\n        \"Oops! This form is no longer published.\": \"Ups! Ten formularz nie jest już publikowany.\",\n        \"There was a problem loading the form.\": \"Wystąpił problem z załadowaniem formularza.\",\n        \"You don't have access to this form.\": \"Nie masz dostępu do tego formularza.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Wystąpił błąd podczas przesyłania formularza. Spróbuj ponownie.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Formularz przesłany\",\n        \"Thank you! Your response has been recorded.\": \"Dziękujemy! Twoja odpowiedź została zapisana.\",\n        \"Submit new response\": \"Prześlij nową odpowiedź\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"Ostatnie 30 dni\",\n        \"Last 7 days\": \"Ostatnie 7 dni\",\n        \"Last week\": \"Zeszły tydzień\",\n        \"Next 7 days\": \"Następne 7 dni\",\n        \"This month\": \"W tym miesiącu\",\n        \"This week\": \"W tym tygodniu\",\n        \"This year\": \"W tym roku\",\n        \"Today\": \"Dziś\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Wyczyść\",\n        \"Map fields\": \"Mapuj pola\",\n        \"Mapped\": \"Zmapowane\",\n        \"Select all\": \"Zaznacz wszystkie\",\n        \"Unmap fields\": \"Cofnij mapowanie pól\",\n        \"Unmapped\": \"Niezmapowane\",\n        \"Hide {{label}}\": \"Ukryj {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Ukryj {{label}} (tryb seryjny)\",\n        \"Unmap {{label}}\": \"Odepnij {{label}}\",\n        \"Unmap {{label}} (batch mode)\": \"Odepnij {{label}} (tryb zbiorowy)\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Wstaw sekcję powyżej\",\n        \"Insert section below\": \"Wstaw sekcję poniżej\",\n        \"## **Header**\": \"## **Nagłówek**\",\n        \"Description\": \"Opis\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Anuluj\",\n        \"Choose a name and url for your team site\": \"Wybierz nazwę i adres URL dla swojej strony zespołowej\",\n        \"Create site\": \"Utwórz stronę\",\n        \"Domain name is invalid\": \"Nazwa domeny jest nieprawidłowa\",\n        \"Domain name is required\": \"Nazwa domeny jest wymagana\",\n        \"Go to your site\": \"Przejdź do swojej strony\",\n        \"Team name\": \"Nazwa zespołu\",\n        \"Team name is required\": \"Nazwa zespołu jest wymagana\",\n        \"Team site created\": \"Strona zespołowa utworzona\",\n        \"Team url\": \"URL zespołu\",\n        \"Work as a Team\": \"Pracuj jako zespół\",\n        \"Billing is not supported in grist-core\": \"Rozliczenia nie są obsługiwane w grist-core\"\n    },\n    \"AdminPanel\": {\n        \"Admin Panel\": \"Panel administratora\",\n        \"Current\": \"Bieżąca\",\n        \"Current version of Grist\": \"Bieżąca wersja Grist\",\n        \"Help us make Grist better\": \"Pomóż nam ulepszyć Grist\",\n        \"Home\": \"Strona główna\",\n        \"Sponsor\": \"Sponsor\",\n        \"Support Grist\": \"Wesprzyj Grist\",\n        \"Support Grist Labs on GitHub\": \"Wesprzyj Grist Labs na GitHub\",\n        \"Telemetry\": \"Telemetria\",\n        \"Version\": \"Wersja\",\n        \"Auto-check when this page loads\": \"Automatyczna kontrola przy ładowaniu tej strony\",\n        \"Check now\": \"Sprawdź teraz\",\n        \"Checking for updates...\": \"Sprawdzanie aktualizacji...\",\n        \"Error\": \"Błąd\",\n        \"Error checking for updates\": \"Błąd podczas sprawdzania aktualizacji\",\n        \"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żliwia tworzenie bardzo zaawansowanych formuł przy użyciu Pythona. Zalecamy ustawienie zmiennej środowiskowej GRIST_SANDBOX_FLAVOR na gvisor, jeśli twój sprzęt to obsługuje (większość będzie), aby uruchamiać formuły w każdym dokumencie w piaskownicy izolowanej od innych dokumentów i od sieci.\",\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 podpisuje ciasteczka sesji użytkownika kluczem tajnym. Proszę ustawić ten klucz za pomocą zmiennej środowiskowej GRIST_SESSION_SECRET. Grist powraca do zakodowanego domyślnego wartości, gdy nie jest ustawiony. Możemy usunąć to powiadomienie w przyszłości, ponieważ identyfikatory sesji od wersji v1.1.16 są z natury kryptograficznie bezpieczne.\",\n        \"Grist is up to date\": \"Grist jest aktualny\",\n        \"Grist releases are at \": \"Wydania Grist znajdują się na \",\n        \"Last checked {{time}}\": \"Ostatnio sprawdzono {{time}}\",\n        \"Learn more.\": \"Dowiedz się więcej.\",\n        \"Newer version available\": \"Dostępna nowsza wersja\",\n        \"No information available\": \"Brak dostępnych informacji\",\n        \"OK\": \"OK\",\n        \"Sandbox settings for data engine\": \"Ustawienia piaskownicy dla silnika danych\",\n        \"Sandboxing\": \"Piaskownica\",\n        \"Security Settings\": \"Ustawienia bezpieczeństwa\",\n        \"Updates\": \"Aktualizacje\",\n        \"unconfigured\": \"nieskonfigurowany\",\n        \"unknown\": \"nieznany\",\n        \"Administrator Panel Unavailable\": \"Panel administratora niedostępny\",\n        \"Authentication\": \"Uwierzytelnianie\",\n        \"Check failed.\": \"Kontrola nie powiodła się.\",\n        \"Check succeeded.\": \"Kontrola powiodła się.\",\n        \"Current authentication method\": \"Bieżąca metoda uwierzytelniania\",\n        \"Details\": \"Szczegóły\",\n        \"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żliwia konfigurowanie różnych typów uwierzytelniania, w tym SAML i OIDC. Zalecamy włączenie jednego z nich, jeśli Grist jest dostępny przez sieć lub jest udostępniany wielu osobom.\",\n        \"No fault detected.\": \"Nie wykryto usterek.\",\n        \"Notes\": \"Notatki\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Lub, jako rezerwę, możesz ustawić: {{bootKey}} w środowisku i odwiedzić: {{url}}\",\n        \"Results\": \"Wyniki\",\n        \"Self Checks\": \"Autokontrole\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"Nie masz dostępu do panelu administratora.\\nProszę zalogować się jako administrator.\",\n        \"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żliwia konfigurowanie różnych typów uwierzytelniania, w tym SAML i OIDC. Zalecamy włączenie jednego z nich, jeśli Grist jest dostępny przez sieć lub jest udostępniany wielu osobom.\",\n        \"Key to sign sessions with\": \"Klucz do podpisywania sesji\",\n        \"Session Secret\": \"Tajemnica sesji\",\n        \"Enable Grist Enterprise\": \"Włącz Grist Enterprise\",\n        \"Enterprise\": \"Przedsiębiorstwo\",\n        \"checking\": \"sprawdzanie\",\n        \"Audit Logs\": \"Dzienniki audytu\",\n        \"Contact us\": \"Skontaktuj się z nami\",\n        \"Log Streaming\": \"Strumieniowanie dzienników\",\n        \"New, Enterprise\": \"Nowy, Enterprise\",\n        \"Off\": \"Wył.\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} więcej\",\n        \"On\": \"Wł.\",\n        \"Grist Instance\": \"Instancja Grist\",\n        \"Auto-check weekly\": \"Automatyczne cotygodniowe sprawdzanie\",\n        \"No record of last version check\": \"Brak rekordu ostatniego sprawdzenia wersji\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Możesz skonfigurować strumieniowanie zdarzeń audytu z Grist do zewnętrznego systemu zarządzania informacjami i zdarzeniami bezpieczeństwa (SIEM), jeśli włączysz Grist Enterprise. {{contactUsLink}}, aby dowiedzieć się więcej.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Automatyczne sprawdzanie jest wyłączone. Ustaw zmienną środowiskową GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING na \\\"true\\\", aby je włączyć.\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"Grist podpisuje ciasteczka sesji użytkownika za pomocą sekretnego klucza. Ten klucz należy ustawić zmienną środowiskową GRIST_SESSION_SECRET. Grist użyje domyślnej wartości jeśli ta zmienna nie jest ustawiona. Ta informacja może zostać usunięta w przyszłości, ponieważ od wersji v1.1.16 ID sesji są kryptograficznie bezpieczne.\",\n        \"{{count}} admin accounts_one\": \"{{count}} konto administratora\",\n        \"{{count}} admin accounts_other\": \"{{count}} konta administratora\",\n        \"auth error\": \"błąd autoryzacji\",\n        \"configured\": \"skonfigurowano\",\n        \"default\": \"domyślny\",\n        \"more...\": \"więcej...\",\n        \"no authentication\": \"brak autoryzacji\",\n        \"unavailable\": \"niedostępny\",\n        \"Admin account not found\": \"Nie znaleziono konta administratora\",\n        \"Administrative accounts\": \"Konta administratorów\",\n        \"The users with administrative accounts\": \"Użytkownicy z kontami administratorów\",\n        \"Version {{versionNumber}}\": \"Wersja {{versionNumber}}\",\n        \"no admin accounts\": \"brak kont administratorów\",\n        \"Are you sure you want to restart Grist?\": \"Czy na pewno chcesz zrestartować Grista?\",\n        \"Grist is running in an environment that doesn't support restarting from the admin panel.\": \"Grist działa w środowisku które nie wspiera restartu z panelu administratora.\",\n        \"Restart\": \"Restart\",\n        \"Restart Grist\": \"Zrestartuj Grista\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"Zrestartuj Grista aby zastosować oczekujące zmiany lub rozwiązać problemy.\",\n        \"Restart Grist?\": \"Czy chcesz zrestartować Grista?\",\n        \"Restarting Grist...\": \"Restartowanie Grista...\",\n        \"This will apply any pending changes and briefly interrupt access for all users.\": \"To zastosuje wszelkie oczekujące zmiany i na chwilę przerwie dostęp dla wszystkich użytkowników.\",\n        \"You can still restart Grist manually.\": \"Wciąż możesz zrestartować Grista manualnie.\",\n        \"error in {{provider}}: {{verdict}}\": \"błąd w {{provider}}: {{verdict}}\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Usuń kolumnę\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Brak skonfigurowanych opcji\",\n        \"No values in show column of referenced table\": \"Brak wartości w kolumnie pokazowej tabeli referencyjnej\",\n        \"Hide\": \"Ukryj\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"Pole wyboru\",\n        \"Field Format\": \"Format pola\",\n        \"Switch\": \"Przełącznik\"\n    },\n    \"ChoiceEditor\": {\n        \"Error in dropdown condition\": \"Błąd w warunku listy rozwijanej\",\n        \"No choices matching condition\": \"Brak opcji spełniających warunek\",\n        \"No choices to select\": \"Brak opcji do wyboru\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"Błąd w warunku listy rozwijanej\",\n        \"No choices matching condition\": \"Brak opcji spełniających warunek\",\n        \"No choices to select\": \"Brak opcji do wyboru\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"Warunek listy rozwijanej\",\n        \"Invalid columns: {{colIds}}\": \"Nieprawidłowe kolumny: {{colIds}}\",\n        \"Set dropdown condition\": \"Ustaw warunek listy rozwijanej\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Wprowadź warunek.\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Błąd w warunku listy rozwijanej\",\n        \"No choices matching condition\": \"Brak opcji spełniających warunek\",\n        \"No choices to select\": \"Brak opcji do wyboru\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"Resetuj\",\n        \"Search\": \"Szukaj\",\n        \"Select...\": \"Wybierz...\",\n        \"Submit\": \"Wyślij\",\n        \"Submitting…\": \"Wysyłanie…\",\n        \"Clear selection for: {{-inputLabel}}\": \"Wyczyść wybór dla: {{-inputLabel}}\"\n    },\n    \"widgetTypesMap\": {\n        \"Calendar\": \"Kalendarz\",\n        \"Card\": \"Karta\",\n        \"Card List\": \"Lista kart\",\n        \"Chart\": \"Wykres\",\n        \"Custom\": \"Niestandardowy\",\n        \"Form\": \"Formularz\",\n        \"Table\": \"Tabela\"\n    },\n    \"TimingPage\": {\n        \"Average Time (s)\": \"Średni czas (s)\",\n        \"Column ID\": \"ID kolumny\",\n        \"Formula timer\": \"Czasomierz formuły\",\n        \"Loading timing data. Don't close this tab.\": \"Ładowanie danych czasowych. Nie zamykaj tej karty.\",\n        \"Max Time (s)\": \"Maks. czas (s)\",\n        \"Number of Calls\": \"Liczba wywołań\",\n        \"Table ID\": \"ID tabeli\",\n        \"Total Time (s)\": \"Całkowity czas (s)\"\n    },\n    \"DocTutorial\": {\n        \"Click to expand\": \"Kliknij, aby rozwinąć\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Czy chcesz ponownie uruchomić samouczek? Cały postęp zostanie utracony.\",\n        \"End tutorial\": \"Zakończ samouczek\",\n        \"Finish\": \"Zakończ\",\n        \"Next\": \"Dalej\",\n        \"Previous\": \"Poprzedni\",\n        \"Restart\": \"Uruchom ponownie\"\n    },\n    \"OnboardingCards\": {\n        \"3 minute video tour\": \"3-minutowe wprowadzenie\",\n        \"Complete our basics tutorial\": \"Ukończ nasz podstawowy samouczek\",\n        \"Complete the tutorial\": \"Ukończ samouczek\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Poznaj podstawy kolumn referencyjnych, połączonych widżetów, typów kolumn i kart.\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Poznaj podstawy kolumn referencyjnych, połączonych widżetów, typów kolumn i kart.\"\n    },\n    \"OnboardingPage\": {\n        \"Back\": \"Wstecz\",\n        \"Discover Grist in 3 minutes\": \"Odkryj Grista w 3 minuty\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Praktycznie poznaj samouczek podstaw Grista\",\n        \"Go to the tutorial!\": \"Przejdź do samouczka!\",\n        \"Next step\": \"Następny krok\",\n        \"Skip step\": \"Pomiń krok\",\n        \"Skip tutorial\": \"Pomiń samouczek\",\n        \"Tell us who you are\": \"Powiedz nam, kim jesteś\",\n        \"Type here\": \"Wpisz tutaj\",\n        \"Welcome\": \"Witamy\",\n        \"What brings you to Grist (you can select multiple)?\": \"Co sprowadza Cię do Grista (możesz wybrać wiele opcji)?\",\n        \"What is your role?\": \"Jaka jest Twoja rola?\",\n        \"What organization are you with?\": \"Z jaką organizacją jesteś związany?\",\n        \"Your organization\": \"Twoja organizacja\",\n        \"Your role\": \"Twoja rola\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist może wyglądać jak arkusz kalkulacyjny, ale nie zawsze tak działa. Odkryj, co wyróżnia Grista.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"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}}).\": \"Klucz aktywacyjny służy do uruchomienia Grist Enterprise po wygaśnięciu 30-dniowego okresu próbnego. Uzyskaj klucz aktywacyjny, [rejestrując się w Grist Enterprise]({{signupLink}}). Do uruchomienia Grist Core nie potrzebujesz klucza aktywacyjnego.\\n\\nDowiedz się więcej w naszym [Centrum pomocy]({{helpCenter}}).\",\n        \"Disable Grist Enterprise\": \"Wyłącz Grist Enterprise\",\n        \"Enable Grist Enterprise\": \"Włącz Grist Enterprise\",\n        \"Grist Enterprise is **enabled**.\": \"Grist Enterprise jest **włączony**.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Klucz aktywacyjny służy do uruchomienia Grist Enterprise po wygaśnięciu 30-dniowego okresu próbnego. Uzyskaj klucz aktywacyjny, [kontaktując się z nami]({{contactLink}}) jeszcze dziś. Do uruchomienia Grist Core nie potrzebujesz klucza aktywacyjnego.\\n\\nDowiedz się więcej w naszym [Centrum pomocy]({{helpCenter}}).\",\n        \"Activate\": \"Aktywuj\",\n        \"Activation key\": \"Klucz aktywacyjny\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Klucz aktywacyjny służy do uruchomienia Grist Enterprise po wygaśnięciu 30-dniowego okresu próbnego. Uzyskaj klucz aktywacyjny, [rejestrując się w Grist Enterprise]({{signupLink}}). Do uruchomienia Grist Core nie potrzebujesz klucza aktywacyjnego.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Aby kontynuować korzystanie z Grist Enterprise, wymagana jest aktywna subskrypcja. Możesz aktywować swoją subskrypcję, [rejestrując się w Grist Enterprise]({{signupLink}}) i wklejając poniżej swój klucz aktywacyjny.\",\n        \"Copy to clipboard\": \"Kopiuj do schowka\",\n        \"Expiration date\": \"Data wygaśnięcia\",\n        \"Installation ID copied to clipboard\": \"ID instalacji skopiowane do schowka\",\n        \"Installation ID:\": \"ID instalacji:\",\n        \"Installation seats\": \"Liczba stanowisk instalacji\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Dowiedz się więcej w naszym [Centrum pomocy]({{helpCenter}}).\",\n        \"Paste your activation key\": \"Wklej swój klucz aktywacyjny\",\n        \"Plan name\": \"Nazwa planu\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Aby kontynuować korzystanie z Grist Enterprise, musisz\\n                  [skontaktować się z nami]({{signupLink}}), aby uzyskać klucz aktywacyjny.\",\n        \"You are currently trialing Grist Enterprise.\": \"Obecnie testujesz Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"Nie masz aktywnej subskrypcji.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Twój klucz aktywacyjny wygasł z powodu przekroczenia limitów.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Twoja instancja będzie w trybie **tylko do odczytu** za **{{days}}** dni.\",\n        \"Your subscription expired on {{date}}.\": \"Twoja subskrypcja wygasła {{date}}.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Twój okres próbny wygasł **{{expireAt}}**. Aby kontynuować korzystanie z Grist Enterprise, musisz\\n[zarejestrować się w Grist Enterprise]({{signupLink}}) i wkleić poniżej swój klucz aktywacyjny.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Usuń\",\n        \"Delete data and this widget.\": \"Usuń dane i ten widżet.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Zachowaj dane i usuń widżet. Tabela pozostanie dostępna w {{rawDataLink}}\",\n        \"Table {{tableName}} will no longer be visible\": \"Tabela {{tableName}} nie będzie już widoczna\",\n        \"Raw Data page\": \"strona surowych danych\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Panel administratora\"\n    },\n    \"CustomWidgetGallery\": {\n        \"(Missing info)\": \"(Brakujące informacje)\",\n        \"Add widget\": \"Dodaj widżet\",\n        \"Add Your Own Widget\": \"Dodaj własny widżet\",\n        \"Add a widget from outside this gallery.\": \"Dodaj widżet spoza tej galerii.\",\n        \"Cancel\": \"Anuluj\",\n        \"Change widget\": \"Zmień widżet\",\n        \"Choose custom widget\": \"Wybierz niestandardowy widżet\",\n        \"Community Widget\": \"Widżet społeczności\",\n        \"Custom URL\": \"Niestandardowy URL\",\n        \"Developer:\": \"Deweloper:\",\n        \"Grist Widget\": \"Widżet Grist\",\n        \"Last updated:\": \"Ostatnia aktualizacja:\",\n        \"Learn more about custom widgets\": \"Dowiedz się więcej o niestandardowych widżetach\",\n        \"No matching widgets\": \"Brak pasujących widżetów\",\n        \"Search\": \"Szukaj\",\n        \"Widget URL\": \"URL widżetu\"\n    },\n    \"markdown\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nowa funkcja Markdown\\n *\\n *      Możemy _pisać_ [zwykły Markdown](https://markdownguide.org) *wewnątrz*\\n *      elementu Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"Przełącznik jest **wyłączony**\",\n        \"The toggle is **on**\": \"Przełącznik jest **włączony**\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nowa funkcja Markdown\\n *\\n *      Możemy _pisać_ [zwykły Markdown](https://markdownguide.org) *wewnątrz*\\n *      elementu Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"Przełącznik jest **wyłączony**\",\n        \"The toggle is **on**\": \"Przełącznik jest **włączony**\"\n    },\n    \"HomeIntroCards\": {\n        \"3 minute video tour\": \"3-minutowe wprowadzenie\",\n        \"Blank document\": \"Pusty dokument\",\n        \"Find solutions and explore more resources\": \"Znajdź rozwiązania i poznaj więcej zasobów\",\n        \"Finish our basics tutorial\": \"Ukończ nasz samouczek podstaw\",\n        \"Help center\": \"Centrum pomocy\",\n        \"Import file\": \"Importuj plik\",\n        \"Learn more\": \"Dowiedz się więcej\",\n        \"Start a new document\": \"Stwórz nowy dokument\",\n        \"Templates\": \"Szablony\",\n        \"Tutorial\": \"Samouczek\",\n        \"Webinars\": \"Webinary\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Add two-way reference\": \"Dodaj referencję dwukierunkową\",\n        \"Column\": \"Kolumna\",\n        \"Delete\": \"Usuń\",\n        \"Delete column {{column}} in table {{table}}?\": \"Usunąć kolumnę {{column}} w tabeli {{table}}?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Jest to odwrotność kolumny referencyjnej {{column}} w tabeli {{table}}.\",\n        \"Table\": \"Tabela\",\n        \"Two-way Reference\": \"Referencja dwukierunkowa\",\n        \"Delete two-way reference?\": \"Usunąć referencję dwukierunkową?\",\n        \"Target table\": \"Tabela docelowa\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Spowoduje to usunięcie kolumny referencyjnej {{refCol}} w tabeli {{refTable}}. Kolumna referencyjna {{myName}} pozostanie w bieżącej tabeli {{myTable}}.\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"Panel administratora\",\n        \"Close\": \"Zamknij\",\n        \"Help Center\": \"Centrum pomocy\",\n        \"Opt in to Telemetry\": \"Zgódź się na telemetrię\",\n        \"Opted In\": \"Wyrażono zgodę\",\n        \"Support Grist\": \"Wesprzyj Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Dziękujemy! Twoje zaufanie i wsparcie są bardzo doceniane. Możesz zrezygnować w dowolnym momencie za pomocą {{link}} w menu użytkownika.\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Zgódź się na telemetrię, aby pomóc nam zrozumieć, jak produkt jest używany, dzięki czemu możemy ustalić priorytety przyszłych ulepszeń.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Zbieramy tylko statystyki użycia, jak szczegółowo opisano w naszym {{helpCenterLink}}, nigdy treści dokumentów. Możesz zrezygnować w dowolnym momencie za pomocą {{supportGristLink}} w menu użytkownika.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Anuluj\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Każdy rekord {{targetTable}} może być przypisany tylko do jednego rekordu {{sourceTable}}.\",\n        \"Reassign\": \"Przypisz ponownie\",\n        \"Reassign to new {{sourceTable}} records.\": \"Przypisz ponownie do nowych rekordów {{sourceTable}}.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Przypisz ponownie do rekordu {{sourceTable}} {{sourceName}}.\",\n        \"Record already assigned_one\": \"Rekord już przypisany\",\n        \"Record already assigned_other\": \"Rekord już przypisany\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"Rekord {{targetTable}} {{targetName}} jest już przypisany do rekordu {{sourceTable}} {{oldSourceName}}.\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Add destination\": \"Dodaj miejsce docelowe\",\n        \"Add streaming destination\": \"Dodaj miejsce docelowe strumieniowania\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Czy na pewno chcesz usunąć to miejsce docelowe strumieniowania? Tej czynności nie można cofnąć.\",\n        \"Cancel\": \"Anuluj\",\n        \"Delete\": \"Usuń\",\n        \"Delete streaming destination?\": \"Usunąć miejsce docelowe strumieniowania?\",\n        \"Destination\": \"Miejsce docelowe\",\n        \"Destinations\": \"Miejsca docelowe\",\n        \"Edit\": \"Edytuj\",\n        \"Edit streaming destination\": \"Edytuj miejsce docelowe strumieniowania\",\n        \"Enter URL\": \"Wprowadź URL\",\n        \"Enter token\": \"Wprowadź token\",\n        \"Learn more\": \"Dowiedz się więcej\",\n        \"Other\": \"Inne\",\n        \"Save\": \"Zapisz\",\n        \"Splunk\": \"Splunk\",\n        \"Start streaming\": \"Rozpocznij strumieniowanie\",\n        \"Token\": \"Token\",\n        \"URL\": \"URL\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Skonfiguruj strumieniowanie zdarzeń audytu z Grist do zewnętrznego systemu zarządzania informacjami i zdarzeniami bezpieczeństwa (SIEM), takiego jak Splunk. {{learnMoreLink}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit Logs\": \"Dzienniki zdarzeń\",\n        \"Audit logs for {{siteName}}\": \"Dzienniki zdarzeń dla {{siteName}}\",\n        \"Contact us\": \"Skontaktuj się z nami\",\n        \"Home\": \"Strona główna\",\n        \"Log streaming\": \"Strumieniowanie dzienników\",\n        \"Only site owners may access audit logs.\": \"Tylko właściciele witryny mogą uzyskać dostęp do dziennika zdarzeń.\",\n        \"upgrade your plan\": \"zaktualizuj swój plan\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Możesz skonfigurować strumieniowanie zdarzeń audytu z Grist do zewnętrznego systemu SIEM (zarządzanie informacjami i zdarzeniami bezpieczeństwa), jeśli włączysz Grist Enterprise. {{contactUsLink}}, aby dowiedzieć się więcej.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Możesz skonfigurować strumieniowanie zdarzeń audytu z Grist do zewnętrznego systemu SIEM (zarządzanie informacjami i zdarzeniami bezpieczeństwa), jeśli {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"Access details\": \"Szczegóły dostępu\",\n        \"All\": \"Wszystko\",\n        \"Current workspace\": \"Bieżący obszar roboczy\",\n        \"Delete\": \"Usuń\",\n        \"Delete {{name}}\": \"Usuń {{name}}\",\n        \"Document will be moved to Trash.\": \"Dokument zostanie przeniesiony do Kosza.\",\n        \"Edited {{at}}\": \"Edytowano {{at}}\",\n        \"Last edited\": \"Ostatnio edytowano\",\n        \"Manage users\": \"Zarządzaj użytkownikami\",\n        \"Move\": \"Przenieś\",\n        \"Move {{name}} to workspace\": \"Przenieś {{name}} do obszaru roboczego\",\n        \"Name\": \"Nazwa\",\n        \"No documents to show.\": \"Brak dokumentów do wyświetlenia.\",\n        \"Pin\": \"Przypnij\",\n        \"Pinned\": \"Przypięte\",\n        \"Recent\": \"Ostatnie\",\n        \"Rename and set icon\": \"Zmień nazwę i ustaw ikonę\",\n        \"Requires edit permissions\": \"Wymaga uprawnień do edycji\",\n        \"Sort by date\": \"Sortuj według daty\",\n        \"Sort by name\": \"Sortuj według nazwy\",\n        \"Unpin\": \"Odepnij\",\n        \"Workspace\": \"Obszar roboczy\",\n        \"context menu - {{- documentName }}\": \"menu kontekstowe - {{- documentName }}\",\n        \"Documents list\": \"Lista dokumentów\",\n        \"Download document...\": \"Pobierz dokument...\",\n        \"Deleted {{at}}\": \"Usunięto {{at}}\"\n    },\n    \"RenameDocModal\": {\n        \"Choose color\": \"Wybierz kolor\",\n        \"Choose icon\": \"Wybierz ikonę\",\n        \"Enter document name\": \"Wprowadź nazwę dokumentu\",\n        \"Icon\": \"Ikona\",\n        \"Name\": \"Nazwa\",\n        \"Rename and set icon\": \"Zmień nazwę i ustaw ikonę\",\n        \"Reset icon\": \"Resetuj ikonę\"\n    },\n    \"RightPanelUtils\": {\n        \"columns_one\": \"Kolumny\",\n        \"columns_other\": \"Kolumny\",\n        \"fields_one\": \"Pola\",\n        \"fields_other\": \"Pola\",\n        \"series_one\": \"Seria\",\n        \"series_other\": \"Serie\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Bądź ostrożny z nieznanymi niestandardowymi widżetami\",\n        \"Please review the following before adding a new custom widget.\": \"Przed dodaniem nowego niestandardowego widżetu zapoznaj się z poniższymi informacjami.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Niestandardowe widżety są **potężne**! Mogą odczytywać i zapisywać dane Twojego dokumentu oraz wysyłać je gdzie indziej.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Czy na pewno **ufasz zasobowi** pod tym adresem URL?\",\n        \"Do you **trust the person** who shared this link?\": \"Czy **ufasz osobie**, która udostępniła ten link?\",\n        \"Have you **reviewed the code** at this URL?\": \"Czy **przejrzałeś kod** pod tym adresem URL?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Jeśli masz wątpliwości, nie instaluj tego widżetu lub poproś administratora swojej organizacji o sprawdzenie go pod kątem bezpieczeństwa.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Potwierdzam, że rozumiem te ostrzeżenia i akceptuję ryzyko\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin area\": \"Obszar administracyjny\",\n        \"Admin controls\": \"Kontrolki administracyjne\",\n        \"Docs\": \"Dokumenty\",\n        \"Installation\": \"Instalacja\",\n        \"Learn more\": \"Dowiedz się więcej\",\n        \"Orgs\": \"Organizacje\",\n        \"Users\": \"Użytkownicy\",\n        \"Workspaces\": \"Obszary robocze\",\n        \"Admin Controls\": \"Kontrolki administracyjne\",\n        \"Settings\": \"Ustawienia\"\n    },\n    \"Assistant\": {\n        \"AI Assistant is only available for logged in users.\": \"Asystent AI jest dostępny tylko dla zalogowanych użytkowników.\",\n        \"Apply\": \"Zastosuj\",\n        \"For higher limits, contact the site owner.\": \"Aby uzyskać wyższe limity, skontaktuj się z właścicielem witryny.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Aby uzyskać wyższe limity, {{upgradeNudge}}.\",\n        \"Learn more.\": \"Dowiedz się więcej.\",\n        \"Press Enter to apply suggested formula.\": \"Naciśnij Enter, aby zastosować sugerowaną formułę.\",\n        \"Sign Up for Free\": \"Zarejestruj się za darmo\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Zarejestruj się, aby uzyskać bezpłatne konto Grist i zacząć korzystać z Asystenta AI.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Przejdź na Grist Enterprise, aby wypróbować nowego Asystenta Grist. {{learnMoreLink}}\",\n        \"What do you need help with?\": \"Z czym potrzebujesz pomocy?\",\n        \"You have used all available credits.\": \"Wykorzystałeś wszystkie dostępne kredyty.\",\n        \"You have {{numCredits}} remaining credits.\": \"Pozostało Ci {{numCredits}} kredytów.\",\n        \"start a new chat\": \"rozpocznij nową rozmowę\",\n        \"upgrade to the Pro Team plan\": \"przejdź na plan Pro Team\",\n        \"upgrade your plan\": \"zaktualizuj swój plan\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"Rozmowa stała się zbyt długa i nie mogę już skutecznie odpowiadać. Aby nadal otrzymywać pomoc, {{startANewChatButton}}.\"\n    },\n    \"apiconsole\": {\n        \"Are you sure you want to delete the following?\": \"Czy na pewno chcesz usunąć następujące elementy?\",\n        \"Confirm Deletion\": \"Potwierdź usunięcie\",\n        \"Delete\": \"Usuń\",\n        \"Deletion was not confirmed, skipping.\": \"Usunięcie nie zostało potwierdzone, pomijam.\",\n        \"Type DELETE here if you wish to proceed.\": \"Wpisz tutaj DELETE, jeśli chcesz kontynuować.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Wpisz DELETE, jeśli na pewno chcesz wykonać to usunięcie.\\nJeśli nie jesteś pewien lub nie rozumiesz, co ta operacja zrobi,\\nrozsądnie będzie ją anulować.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"brak dostępu\",\n        \"...loading\": \"...ładowanie\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Dostępna jest krytyczna aktualizacja Grist.\\nRozważ jak najszybsze uaktualnienie do wersji {{version}}.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Twoja wersja Grist jest nieaktualna.\\nRozważ jak najszybsze uaktualnienie do wersji {{version}}.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Rekomendacja: {{storageRecommendation}}\\nPrzechowując duże załączniki lub wiele z nich, zalecamy\\nprzechowywanie ich w pamięci zewnętrznej. Ten dokument obecnie\\nużywa wewnętrznej pamięci na załączniki, co sprawia, że jest\\nsamodzielny, ale może ograniczać wydajność.\",\n        \"Set the document to use external storage.\": \"Skonfiguruj dokument do używania zewnętrznej pamięci.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Poczekaj na zakończenie poprzedniej operacji.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Przekroczono czas oczekiwania na restart zaplecza Grist\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Wyłącz funkcję\",\n        \"Don't worry, you can disable it later if needed.\": \"Nie martw się, możesz ją później wyłączyć, jeśli zajdzie taka potrzeba.\",\n        \"Enable feature\": \"Włącz funkcję\",\n        \"Experimental feature\": \"Funkcja eksperymentalna\",\n        \"New record button\": \"Przycisk nowego rekordu\",\n        \"Reload the page\": \"Przeładuj stronę\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Odwiedź ten adres URL w dowolnym momencie, aby przestać używać tej funkcji: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Zamierzasz wyłączyć tę funkcję eksperymentalną: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Zamierzasz włączyć tę funkcję eksperymentalną: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} wyłączone.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} włączone.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Nowa karta\",\n        \"New record\": \"Nowy rekord\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Próbujesz uzyskać dostęp do panelu twórcy? Użyj {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Powiel widżet\",\n        \"Duplicate widgets\": \"Powiel widżety\",\n        \"Active\": \"Aktywny\",\n        \"Create new page\": \"Stwórz nową stronę\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Przesyłanie, proszę czekać…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Dodaj\",\n        \"Delete\": \"Usuń\",\n        \"Download\": \"Pobierz\",\n        \"Drop files here to attach\": \"Upuść pliki tutaj, aby je dołączyć\",\n        \"Drop files here to attach.\": \"Upuść pliki tutaj, aby je dołączyć.\",\n        \"No attachments\": \"Brak załączników\",\n        \"Preview not available.\": \"Podgląd niedostępny.\",\n        \"{{index}} of {{total}}\": \"{{index}} z {{total}}\",\n        \"Uploading…\": \"Przesyłanie…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Rozszerz wszystkie wiersze do tej wysokości\",\n        \"Max height\": \"Maksymalna wysokość\",\n        \"Max row height\": \"Maksymalna wysokość wiersza\",\n        \"Change\": \"Zmień\"\n    },\n    \"AdminChecks\": {\n        \"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 pozwala na wprowadzanie bardzo zaawansowanych formuł za pomocą Pythona. Zaleca się ustawienie zmiennej środowiskowej GRIST_SANDBOX_FLAVOR w gvisor jeśli twój sprzęt na to pozwala (prawdopodobnie tak), aby załączać formuły każdego dokumentu w osobnej piaskownicy izolowanej od innych dokumentów i sieci.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"Do działania, połączenia websocketem wymagają HTTP 1.1 i możliwość przesłania kilku dodatkowych nagłówków. Odwrotny serwer proxy może zakłócać te wymogi.\",\n        \"It is good practice not to run Grist as the root user.\": \"Najlepiej nie uruchamiać Grista jako root.\",\n        \"The main page of Grist should be available.\": \"Strona główna Grista powinna być dostępna.\"\n    },\n    \"AuthenticationSection\": {\n        \"No authentication method is active.\": \"Żadna metoda autoryzacyjna nie jest aktywna.\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Zwiń\",\n        \"Expand\": \"Rozszerz\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"aktywny użytkownik\",\n        \"active user list\": \"lista aktywnych użytkowników\",\n        \"open full active user list\": \"otwórz pełną listę aktywnych użytkowników\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"+{{count}} więcej\",\n        \"+{{count}} more_other\": \"+{{count}} więcej\",\n        \"Edit\": \"Edytuj\",\n        \"Reset\": \"Zresetuj\",\n        \"Cancel\": \"Anuluj\",\n        \"Save\": \"Zapisz\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Zastosuj\",\n        \"Cancel\": \"Anuluj\",\n        \"Preview\": \"Podgląd\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Zamknij\",\n        \"Update preview\": \"Odśwież podgląd\",\n        \"Escape character\": \"Znak Escape\",\n        \"Field separator\": \"Separator pól\",\n        \"First row contains headers\": \"Pierwszy wiersz zawiera nagłówki\",\n        \"Number of rows\": \"Liczba wierszy\",\n        \"Start with row\": \"Zacznij wierszem\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" lub \"\n    }\n}\n"
  },
  {
    "path": "static/locales/pl.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"Loading...\": \"Ładowanie...\",\n        \"og-description\": \"Nowoczesny, otwartoźródłowy arkusz kalkulacyjny, który znaczy więcej niż tabela\",\n        \"og-title\": \"Grist - ewolucja arkusza kalkulacyjnego\"\n    },\n    \"access\": {\n        \"docDisabled\": \"Ten dokument jest wyłączony.\",\n        \"docNoAccess\": \"Nie masz dostępu do tego dokumentu.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"Nie znaleziono właścicieli w organizacji zdefiniowanej przez `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"orgUser\": \"Użytkownik jest właścicielem organizacji zdefiniowanej przez `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"accountByEmail\": \"Konto administratora zdefiniowane przez `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Dokument bez tytułu\"\n    },\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Zweryfikuj swój adres e-mail z dostawcą tożsamości i zaloguj się ponownie.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/pt.client.json",
    "content": "{\n    \"AccountPage\": {\n        \"API Key\": \"Chave API\",\n        \"API\": \"API\",\n        \"Account settings\": \"Configurações de conta\",\n        \"Allow signing in to this account with Google\": \"Permitir o acesso a esta conta com o Google\",\n        \"Change password\": \"Alterar Palavra-passe\",\n        \"Edit\": \"Editar\",\n        \"Email\": \"E-mail\",\n        \"Login method\": \"Método de Login\",\n        \"Name\": \"Nome\",\n        \"Names only allow letters, numbers and certain special characters\": \"Nomes só permitem letras, números e certos caracteres especiais\",\n        \"Password & security\": \"Palavra-passe e Segurança\",\n        \"Save\": \"Gravar\",\n        \"Theme\": \"Tema\",\n        \"Two-factor authentication\": \"Autenticação de dois fatores\",\n        \"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.\": \"A autenticação de dois fatores é uma camada extra de segurança para a sua conta Grist projetada para garantir que seja a única pessoa que pode aceder a sua conta, mesmo que alguém saiba a sua palavra-passe.\",\n        \"Language\": \"Idioma\"\n    },\n    \"ColumnFilterMenu\": {\n        \"Others\": \"Outros\",\n        \"Search\": \"Pesquisar\",\n        \"Search values\": \"Pesquisar valores\",\n        \"All\": \"Todos\",\n        \"All except\": \"Todos, Exceto\",\n        \"All shown\": \"Todos Mostrados\",\n        \"Filter by Range\": \"Filtrar por intervalo\",\n        \"Future values\": \"Valores Futuros\",\n        \"No matching values\": \"Nenhum valor coincidente\",\n        \"None\": \"Nenhum\",\n        \"Min\": \"Mín\",\n        \"Max\": \"Máx\",\n        \"Start\": \"Começo\",\n        \"End\": \"Fim\",\n        \"Other Matching\": \"Outros Coincidentes\",\n        \"Other Non-Matching\": \"Outros Não-Coincidentes\",\n        \"Other values\": \"Outros Valores\"\n    },\n    \"CustomSectionConfig\": {\n        \"Read selected table\": \"Ler a tabela selecionada\",\n        \" (optional)\": \" (opcional)\",\n        \"Add\": \"Adicionar\",\n        \"Enter Custom URL\": \"Digite a URL personalizada\",\n        \"Full document access\": \"Acesso total ao documento\",\n        \"Learn more about custom widgets\": \"Saiba mais sobre Widgets personalizados\",\n        \"No document access\": \"Sem acesso ao documento\",\n        \"Open configuration\": \"Abrir configuração\",\n        \"Pick a column\": \"Escolha uma coluna\",\n        \"Pick a {{columnType}} column\": \"Escolha uma coluna {{columnType}}\",\n        \"Select Custom Widget\": \"Selecione o Widget personalizado\",\n        \"Widget does not require any permissions.\": \"O Widget não requer nenhuma permissão.\",\n        \"Widget needs to {{read}} the current table.\": \"O Widget necessita {{read}} a tabela atual.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"O Widget necessita {{fullAccess}} ao documento.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} a não-{{columnType}} coluna não é mostrada\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} as não-{{columnType}} colunas não são mostradas\",\n        \"No {{columnType}} columns in table.\": \"Não há colunas {{columnType}} na tabela.\",\n        \"Clear selection\": \"Limpar seleção\",\n        \"Reject\": \"Rejeitar\",\n        \"Widget\": \"Widget\",\n        \"ACCESS LEVEL\": \"NÍVEL DE ACESSO\",\n        \"Accept\": \"Aceitar\",\n        \"Custom URL\": \"URL personalizado\",\n        \"Developer:\": \"Programador:\",\n        \"Last updated:\": \"Última atualização:\",\n        \"Missing description and author information.\": \"Faltam a descrição e as informações do autor.\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Atividade\",\n        \"Snapshots\": \"Instantâneos\",\n        \"Beta\": \"Beta\",\n        \"Compare to current\": \"Comparar ao atual\",\n        \"Compare to previous\": \"Comparar ao anterior\",\n        \"Open snapshot\": \"Abrir Instantâneo\",\n        \"Snapshots are unavailable.\": \"Os instantâneos não estão disponíveis.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Apenas os proprietários têm acesso a instantâneos para documentos com regras de acesso.\"\n    },\n    \"DocMenu\": {\n        \"By Date Modified\": \"Por Data de Modificação\",\n        \"Deleted {{at}}\": \"{{at}} excluído\",\n        \"Discover More Templates\": \"Descubra mais Modelos\",\n        \"Document will be moved to Trash.\": \"O documento será movido para Lixo.\",\n        \"Document will be permanently deleted.\": \"O documento será permanentemente excluído.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Os documentos ficam na Lixo por 30 dias, após os quais são excluídos permanentemente.\",\n        \"Edited {{at}}\": \"{{at}} editado\",\n        \"Examples & Templates\": \"Exemplos & Modelos\",\n        \"Examples and Templates\": \"Exemplos e Modelos\",\n        \"Featured\": \"Destaques\",\n        \"Manage users\": \"Gerir Utilizadores\",\n        \"More Examples and Templates\": \"Mais Exemplos e Modelos\",\n        \"Move\": \"Mover\",\n        \"Move {{name}} to workspace\": \"Mover {{name}} para a área de trabalho\",\n        \"Other Sites\": \"Outros Sites\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Apagar \\\"{{name}}\\\" permanentemente?\",\n        \"Pin Document\": \"Fixar documento\",\n        \"Pinned Documents\": \"Documentos Fixados\",\n        \"Remove\": \"Remover\",\n        \"Rename\": \"Renomear\",\n        \"Requires edit permissions\": \"Requer permissões de edição\",\n        \"Restore\": \"Restaurar\",\n        \"This service is not available right now\": \"Este serviço não está disponível no momento\",\n        \"To restore this document, restore the workspace first.\": \"Para restaurar esse documento, restaure a área de trabalho primeiro.\",\n        \"Trash\": \"Lixo\",\n        \"Trash is empty.\": \"A lixo está vazia.\",\n        \"Unpin Document\": \"Desafixar o Documento\",\n        \"Workspace not found\": \"Área de trabalho não encontrada\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Está no site {{siteName}}. Também tem acesso aos seguintes sites:\",\n        \"(The organization needs a paid plan)\": \"(A organização precisa de um plano pago)\",\n        \"All documents\": \"Todos os Documentos\",\n        \"By Name\": \"Por Nome\",\n        \"Access Details\": \"Detalhes de Acesso\",\n        \"Current workspace\": \"Área de trabalho atual\",\n        \"Delete\": \"Apagar\",\n        \"Delete Forever\": \"Apagar para sempre\",\n        \"Delete {{name}}\": \"Apagar {{name}}\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Está na sua página pessoal. Também tem acesso às seguintes páginas:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Pode apagar uma área de trabalho para sempre uma vez que ela não contenha documentos.\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Só tem acesso de leitura a este site. Atualmente não há documentos.\",\n        \"Any documents created in this site will appear here.\": \"Todos os documentos criados neste site aparecerão aqui.\",\n        \"Create my first document\": \"Criar o meu primeiro documento\",\n        \"personal site\": \"site pessoal\"\n    },\n    \"GristDoc\": {\n        \"Import from file\": \"Importação de ficheiro\",\n        \"Added new linked section to view {{viewName}}\": \"Adicionada nova secção vinculada para visualizar {{viewName}}}\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Secção vinculada gravada {{title}} em exibição {{name}}\",\n        \"go to webhook settings\": \"ir para configurações webhook\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Novas alterações estão temporariamente suspensas. A fila de webhooks transbordou. Verifique as configurações dos webhooks, remova os webhooks inválidos e limpe a fila.\"\n    },\n    \"FormulaAssistant\": {\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Consulte o nosso {{helpFunction}} e {{formulaCheat}} ou visite o nosso {{community}} para obter mais ajuda.\",\n        \"Formula Help. \": \"Ajuda de fórmulas. \",\n        \"Preview\": \"Pré-visualização\",\n        \"Regenerate\": \"Regenerar\",\n        \"Save\": \"Gravar\",\n        \"Grist's AI Formula Assistance. \": \"Assistência de fórmula de IA de Grist. \",\n        \"Ask the bot.\": \"Pergunte o robô.\",\n        \"Data\": \"Dados\",\n        \"New Chat\": \"Novo chat\",\n        \"Formula Cheat Sheet\": \"Folha de consulta de fórmulas\",\n        \"Tips\": \"Dicas\",\n        \"Capabilities\": \"Capacidades\",\n        \"Need help? Our AI assistant can help.\": \"Precisa de ajuda? O nosso assistente de IA pode ajudar.\",\n        \"Community\": \"Comunidade\",\n        \"Function List\": \"Lista de funções\",\n        \"Grist's AI Assistance\": \"Assistência de IA da Grist\",\n        \"AI Assistant\": \"Assistente de IA\",\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Clear conversation\": \"Limpar conversa\",\n        \"Code view\": \"Vista do Código\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Olá, sou o Assistente de IA de Fórmula Grist.\",\n        \"Learn more\": \"Saiba mais\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Só posso ajudar com fórmulas. Não posso criar tabelas, colunas e exibições, nem escrever regras de acesso.\",\n        \"Press Enter to apply suggested formula.\": \"Pressione Enter para aplicar a fórmula sugerida.\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Inscreva-se para uma conta gratuita do Grist para começar a usar o Assistente de Fórmula AI.\",\n        \"There are some things you should know when working with me:\": \"Há algumas coisas que deve saber ao trabalhar comigo:\",\n        \"What do you need help with?\": \"Em que precisa de ajuda?\",\n        \"Sign Up for Free\": \"Inscreva-se gratuitamente\",\n        \"For higher limits, contact the site owner.\": \"Para limites maiores, entre em contato com o proprietário do site.\",\n        \"Formula AI Assistant is only available for logged in users.\": \"O Assistente de Fórmula de IA só está disponível para utilizadores registados.\",\n        \"upgrade to the Pro Team plan\": \"atualize para o plano Para o Team\",\n        \"You have used all available credits.\": \"Você utilizou todos os créditos disponíveis.\",\n        \"upgrade your plan\": \"Atualize o seu plano\",\n        \"You have {{numCredits}} remaining credits.\": \"Você tem {{numCredits}} créditos restantes.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Para limites maiores, {{upgradeNudge}}.\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Para obter mais ajuda com fórmulas, consulte os nossos sites {{functionList}} e {{formulaCheatSheet}}, ou visite o nosso site {{community}} para obter mais ajuda.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Quando fala comigo, as suas perguntas e a estrutura do documento (visível em {{codeView}}) são enviadas ao OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Fale comigo como uma pessoa. Não há necessidade de especificar tabelas e nomes de colunas. Por exemplo, pode pedir \\\"Por favor, calcule o valor total da fatura.\\\"\"\n    },\n    \"WelcomeSitePicker\": {\n        \"You can always switch sites using the account menu.\": \"Sempre pode alternar entre sites através do menu da conta.\",\n        \"Welcome back\": \"Bem-vindo de volta\",\n        \"You have access to the following Grist sites.\": \"Tens acesso aos seguintes sítios Grist.\"\n    },\n    \"MakeCopyMenu\": {\n        \"Cancel\": \"Cancelar\",\n        \"However, it appears to be already identical.\": \"No entanto, parece já ser idêntico.\",\n        \"Include the structure without any of the data.\": \"Incluir a estrutura sem os dados.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Será substituído, perdendo qualquer conteúdo que não esteja neste documento.\",\n        \"Name\": \"Nome\",\n        \"You do not have write access to the selected workspace\": \"Não tem acesso de gravação na área de trabalho selecionada\",\n        \"No destination workspace\": \"Nenhuma área de trabalho de destino\",\n        \"Organization\": \"Organização\",\n        \"Original Has Modifications\": \"Original Tem Modificações\",\n        \"Original Looks Unrelated\": \"Original Parece não Relacionado\",\n        \"Original Looks Identical\": \"Original Parece Identêntico\",\n        \"Overwrite\": \"Sobreescrever\",\n        \"Replacing the original requires editing rights on the original document.\": \"A substituição do original requer direitos de edição sobre o documento original.\",\n        \"Sign up\": \"Cadastre-se\",\n        \"The original version of this document will be updated.\": \"A versão original deste documento será atualizada.\",\n        \"To save your changes, please sign up, then reload this page.\": \"Para gravar as suas alterações, cadastre-se e recarregue esta página.\",\n        \"Update\": \"Atualizar\",\n        \"Update Original\": \"Atualizar o Original\",\n        \"Workspace\": \"Área de Trabalho\",\n        \"You do not have write access to this site\": \"Não tem acesso de gravação a este site\",\n        \"As template\": \"Como Modelo\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Tenha cuidado, o original tem mudanças que não estão neste documento. Essas mudanças serão sobrescritas.\",\n        \"Enter document name\": \"Digite o nome do documento\",\n        \"Download document structure only (no data, for template use)\": \"Remova todos os dados, mas mantenha a estrutura para usar como um modelo\",\n        \"Download document without history (can significantly reduce file size)\": \"Remova o histórico do documento (pode reduzir significativamente o tamanho do ficheiro)\",\n        \"Download document and history\": \"Descarregue documento completo e histórico\",\n        \"Download\": \"Descarregar\",\n        \"Download document\": \"Descarregar documento\",\n        \".tar (recommended)\": \".tar (recomendado)\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"Descarregue um arquivo de todos os anexos presentes neste documento.\",\n        \"Download attachments\": \"Descarregar anexos\",\n        \"Download full document and history\": \"Descarregue documento completo e histórico\",\n        \"Format:\": \"Formato:\",\n        \"Learn more\": \"Saiba mais\",\n        \"download attachments\": \"descarregar anexos\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Os anexos são externos e não estão incluídos nesta descarga. Se enviaro documento numa instalação separada do Grist, também será necessário aceder {{downloadLink}} separadamente. \"\n    },\n    \"Pages\": {\n        \"Delete\": \"Apagar\",\n        \"Delete data and this page.\": \"Apagar os dados desta página.\",\n        \"The following tables will no longer be visible_one\": \"A seguinte tabela não será mais visível\",\n        \"The following tables will no longer be visible_other\": \"As seguintes tabelas não serão mais visíveis\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Mantenha os dados e apague a página. A tabela permanecerá disponível em {{rawDataLink}}\",\n        \"raw data page\": \"página de dados brutos\"\n    },\n    \"RightPanel\": {\n        \"Theme\": \"Tema\",\n        \"CHART TYPE\": \"TIPO DE GRÁFICO\",\n        \"COLUMN TYPE\": \"TIPO DE COLUNA\",\n        \"CUSTOM\": \"PERSONALIZADO\",\n        \"Change widget\": \"Alterar widget\",\n        \"columns_one\": \"Coluna\",\n        \"columns_other\": \"Colunas\",\n        \"DATA TABLE\": \"TABELA DE DADOS\",\n        \"DATA TABLE NAME\": \"NOME DA TABELA DE DADOS\",\n        \"Data\": \"Dados\",\n        \"Detach\": \"Separar\",\n        \"Edit data selection\": \"Editar Seleção de Dados\",\n        \"fields_one\": \"Campo\",\n        \"fields_other\": \"Campos\",\n        \"SELECT BY\": \"SELECIONADO POR\",\n        \"GROUPED BY\": \"AGRUPADO POR\",\n        \"SELECTOR FOR\": \"SELETOR PARA\",\n        \"Row style\": \"Estilo de Linha\",\n        \"SOURCE DATA\": \"DADOS DE ORIGEM\",\n        \"Save\": \"Gravar\",\n        \"Select widget\": \"Selecionar Widget\",\n        \"series_one\": \"Séries\",\n        \"series_other\": \"Séries\",\n        \"Sort & filter\": \"Ordenar & Filtrar\",\n        \"TRANSFORM\": \"TRANSFORMAR\",\n        \"WIDGET TITLE\": \"TÍTULO DO WIDGET\",\n        \"Widget\": \"Widget\",\n        \"You do not have edit access to this document\": \"Não tem permissão de edição desse documento\",\n        \"Add referenced columns\": \"Adicionar colunas referenciadas\",\n        \"Configuration\": \"Configuração\",\n        \"Default field value\": \"Valor padrão do campo\",\n        \"Display button\": \"Botão de exibição\",\n        \"Enter text\": \"Digite texto\",\n        \"Field title\": \"Título do campo\",\n        \"Layout\": \"Leiaute\",\n        \"Submission\": \"Envio\",\n        \"Submit another response\": \"Enviar outra resposta\",\n        \"Submit button label\": \"Etiqueta do botão de envio\",\n        \"Success text\": \"Texto de sucesso\",\n        \"Table column name\": \"Nome da coluna da tabela\",\n        \"Enter redirect URL\": \"Insira URL de redirecionamento\",\n        \"Reset form\": \"Restaurar formulário\",\n        \"Hidden field\": \"Campo escondido\",\n        \"Redirect automatically after submission\": \"Redirecionar automaticamente após o envio\",\n        \"No field selected\": \"Nenhum campo selecionado\",\n        \"Redirection\": \"Redirecionamento\",\n        \"Select a field in the form widget to configure.\": \"Selecione um campo no widget do formulário para configurar.\",\n        \"Field rules\": \"Regras de campo\",\n        \"Required field\": \"Campo necessário\",\n        \"Submit\": \"Enviar\",\n        \"Thank you! Your response has been recorded.\": \"Obrigado! A sua resposta foi registada.\"\n    },\n    \"ShareMenu\": {\n        \"Return to {{termToUse}}\": \"Retornar ao {{termToUse}}\",\n        \"Save copy\": \"Gravar Cópia\",\n        \"Save Document\": \"Gravar Documento\",\n        \"Send to Google Drive\": \"Enviar ao Google Drive\",\n        \"Show in folder\": \"Mostrar na pasta\",\n        \"Unsaved\": \"Não gravado\",\n        \"Work on a copy\": \"Trabalho numa cópia\",\n        \"Access Details\": \"Detalhes de Acesso\",\n        \"Back to current\": \"Voltar ao Atual\",\n        \"Compare to {{termToUse}}\": \"Comparar ao {{termToUse}}\",\n        \"Current Version\": \"Versão Atual\",\n        \"Download\": \"Descarregar\",\n        \"Duplicate document\": \"Duplicar o Documento\",\n        \"Edit without affecting the original\": \"Editar sem afetar o original\",\n        \"Export CSV\": \"Exportar CSV\",\n        \"Export XLSX\": \"Exportar XLSX\",\n        \"Manage users\": \"Gerir Utilizadores\",\n        \"Original\": \"Original\",\n        \"Replace {{termToUse}}...\": \"Substituir {{termToUse}}…\",\n        \"Share\": \"Partilhar\",\n        \"Download...\": \"Descarregar...\",\n        \"Comma Separated Values (.csv)\": \"Valores separados por vírgula (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"Valores separados por DOO (.dsv)\",\n        \"Export as...\": \"Exportar como...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Valores separados por tabulação (.tsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"A exportação só está disponível a partir de páginas de documentos. Por favor, selecione uma página de documento e tente novamente.\",\n        \"Download attachments...\": \"Descarregar anexos...\",\n        \"Download document...\": \"Descarregar documento...\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Criar site de equipa\",\n        \"Switch Sites\": \"Alternar sites\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Utilizador desconhecido\",\n        \"View as Yourself\": \"Ver como você mesmo\",\n        \"You are viewing this document as\": \"Está a visualizar este documento como\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN LABEL\": \"RÓTULO DA COLUNA\",\n        \"COLUMN DESCRIPTION\": \"DESCRIÇÃO DA COLUNA\",\n        \"COLUMN ID: \": \"ID DA COLUNA: \",\n        \"Cancel\": \"Cancelar\",\n        \"Save\": \"Gravar\"\n    },\n    \"FieldBuilder\": {\n        \"DATA FROM TABLE\": \"DADOS DA TABELA\",\n        \"Mixed format\": \"Formato misto\",\n        \"Mixed types\": \"Tipos mistos\",\n        \"Apply formula to data\": \"Aplicar fórmula aos dados\",\n        \"CELL FORMAT\": \"FORMATO DA CÉLULA\",\n        \"Changing multiple column types\": \"Alterar vários tipos de colunas\",\n        \"Revert field settings for {{colId}} to common\": \"Reverter configurações de campo da {{colId}} para comum\",\n        \"Save field settings for {{colId}} as common\": \"Gravar configurações de campo da {{colId}} como comum\",\n        \"Use separate field settings for {{colId}}\": \"Use configurações de campo separadas para {{colId}}\",\n        \"Changing column type\": \"A alterar o tipo de coluna\"\n    },\n    \"NumericTextBox\": {\n        \"Number Format\": \"Formato de número\",\n        \"Currency\": \"Moeda\",\n        \"Decimals\": \"Decimais\",\n        \"Default currency ({{defaultCurrency}})\": \"Moeda padrão ({{defaultCurrency}})\",\n        \"Spinner\": \"Girador\",\n        \"Text\": \"Texto\",\n        \"Field Format\": \"Formato do campo\",\n        \"max\": \"máximo\",\n        \"min\": \"mínimo\"\n    },\n    \"ACUserManager\": {\n        \"Enter email address\": \"Digite o endereço de e-mail\",\n        \"Invite new member\": \"Convidar novo membro\",\n        \"We'll email an invite to {{email}}\": \"Enviaremos um convite por e-mail para {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Add column rule\": \"Adicionar Regra de Coluna\",\n        \"Add Default Rule\": \"Adicionar Regra Padrão\",\n        \"Add table rules\": \"Adicionar Regras de Tabela\",\n        \"Add user attributes\": \"Adicionar Atibutos de Utilizador\",\n        \"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.\": \"Permitir que todos possam copiar, ver e mexer no documento todo.\\nÚtil para exemplos e modelos, mas não para dados sensíveis.\",\n        \"Allow everyone to view Access Rules.\": \"Permitir que todos visualizem as Regras de Acesso.\",\n        \"Attribute name\": \"Nome do atributo\",\n        \"Attribute to Look Up\": \"Atributo para Procurar\",\n        \"Checking...\": \"A verificar…\",\n        \"Condition\": \"Condição\",\n        \"Default rules\": \"Regras Padrão\",\n        \"Delete table rules\": \"Apagar Regras de Tabela\",\n        \"Enter Condition\": \"Insira a condição\",\n        \"Everyone\": \"Todos\",\n        \"Everyone Else\": \"Todos os outros\",\n        \"Invalid\": \"Inválido\",\n        \"Lookup Column\": \"Coluna de pesquisa\",\n        \"Lookup Table\": \"Tabela de Pesquisa\",\n        \"Permission to access the document in full when needed\": \"Permissão para aceder o documento completo quando necessário\",\n        \"Permission to view Access Rules\": \"Permissão para visualizar as Regras de Acesso\",\n        \"Permissions\": \"Permissões\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Remover a coluna {{- colId }} das regras de {{- tableId }}\",\n        \"Remove {{- tableId }} rules\": \"Remover regras de {{- tableId }}\",\n        \"Remove {{- name }} user attribute\": \"Remover o atributo do utilizador {{- name }}\",\n        \"Reset\": \"Redefinir\",\n        \"Rules for table \": \"Regras para a tabela \",\n        \"Save\": \"Gravar\",\n        \"Saved\": \"Gravado\",\n        \"Special rules\": \"Regras Especiais\",\n        \"Type message to display when this rule blocks an action…\": \"Escreva uma mensagem…\",\n        \"User Attributes\": \"Atributos de Utilizador\",\n        \"View as\": \"Ver como\",\n        \"Seed rules\": \"Regras de propagação\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total.\",\n        \"Permission to edit document structure\": \"Permissão para editar a estrutura do documento\",\n        \"This default should be changed if editors' access is to be limited. \": \"Esse padrão deve ser alterado se o acesso dos editores for limitado. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura.\",\n        \"Add table-wide rule\": \"Adicionar regra para toda a tabela\"\n    },\n    \"ChartView\": {\n        \"Toggle chart aggregation\": \"Alternar a agregação de gráficos\",\n        \"selected new group data columns\": \"novas colunas de dados de grupo selecionadas\",\n        \"Create separate series for each value of the selected column.\": \"Crie séries separadas para cada valor da coluna selecionada.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Cada série Y é seguida por uma série para o comprimento das barras de erro.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Cada série Y é seguida por duas séries, para as barras de erro superior e inferior.\",\n        \"Pick a column\": \"Escolha uma coluna\",\n        \"LABEL\": \"ETIQUETA\",\n        \"Bar chart\": \"Gráfico de barras\",\n        \"Pie chart\": \"Gráfico de tarte\",\n        \"Donut chart\": \"Gráfico de Donut\",\n        \"Area chart\": \"Gráfico de área\",\n        \"Line chart\": \"Gráfico de linha\",\n        \"Scatter plot\": \"Gráfico de dispersão\",\n        \"Kaplan-Meier plot\": \"Gráfico Kaplan-Meier\",\n        \"Split series\": \"Série de divisão\",\n        \"Invert Y-axis\": \"Inverter eixo Y\",\n        \"Orientation\": \"Orientação\",\n        \"Vertical\": \"Vertical\",\n        \"Horizontal\": \"Horizontal\",\n        \"Log scale Y-axis\": \"Escala logarítmica no eixo Y\",\n        \"Hole size\": \"Tamanho do furo\",\n        \"Show total\": \"Mostrar Total\",\n        \"Text size\": \"Tamanho do texto\",\n        \"Connect gaps\": \"Conecte lacunas\",\n        \"Show markers\": \"Mostrar marcadores\",\n        \"Stack series\": \"Série de pilha\",\n        \"Error bars\": \"Barras de erro\",\n        \"None\": \"Nenhum\",\n        \"Symmetric\": \"Simétrico\",\n        \"Above+Below\": \"Acima+Abaixo\",\n        \"Split Series\": \"Série de divisão\",\n        \"X-AXIS\": \"EIXO-X\",\n        \"Aggregate values\": \"Valores agregados\",\n        \"SERIES\": \"SÉRIES\",\n        \"Add series\": \"Adicionar série\",\n        \"non-numeric columns are not shown\": \"colunas não numéricas não são mostradas\",\n        \"non-numeric column is not shown\": \"coluna não numérica não é mostrada\",\n        \"selected new x-axis\": \"novo eixo x selecionado\",\n        \"Remove\": \"Remover\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Acesso negado\",\n        \"Code View is available only when you have full document access.\": \"A Vista de Código só está disponível quando tem acesso total aos documentos.\"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"Peça ajuda\",\n        \"Cannot find personal site, sorry!\": \"Não encontro site pessoal, desculpe!\",\n        \"Give feedback\": \"Dar feedback\",\n        \"Go to your free personal site\": \"Acede o seu site pessoal gratuito\",\n        \"No notifications\": \"Nenhuma notificação\",\n        \"Notifications\": \"Notificações\",\n        \"Renew\": \"Renovar\",\n        \"Report a problem\": \"Reportar um problema\",\n        \"Upgrade Plan\": \"Atualizar o Plano\",\n        \"Manage billing\": \"Gerir faturamento\"\n    },\n    \"OpenVideoTour\": {\n        \"Video Tour\": \"Tour de Vídeo\",\n        \"Grist Video Tour\": \"Tour de Vídeo Grist\",\n        \"YouTube video player\": \"Reprodutor de vídeo YouTube\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Terminar\",\n        \"Next\": \"Próximo\",\n        \"Previous\": \"Anterior\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Adicionar à Página\",\n        \"Building {{- label}} widget\": \"Construír o {{- label}} widget\",\n        \"Group by\": \"Agrupar por\",\n        \"Select data\": \"Selecionar dados\",\n        \"Select widget\": \"Selcione o Widget\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Permitir Todos\",\n        \"Deny all\": \"Recusar Todos\",\n        \"Read only\": \"Somente leitura\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Falha na importação: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Atualizar o layout dos registos.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Adicionar Campo\",\n        \"Create new field\": \"Criar um Novo Campo\",\n        \"Show field {{- label}}\": \"Mostrar campo {{- label}}\",\n        \"Save layout\": \"Guardar Layout\",\n        \"Cancel\": \"Cancelar\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Adicionar Coluna\",\n        \"No columns to add\": \"Não há colunas para adicionar\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"Copiar a ligação de ancoragem\",\n        \"Delete\": \"Apagar\",\n        \"Duplicate rows_one\": \"Duplicar linha\",\n        \"Duplicate rows_other\": \"Duplicar linhas\",\n        \"Insert row\": \"Inserir linha\",\n        \"Insert row above\": \"Inserir linha acima\",\n        \"Insert row below\": \"Inserir linha abaixo\",\n        \"View as card\": \"Ver como cartão\",\n        \"Use as table headers\": \"Usar como cabeçalhos de tabela\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Copiado para a área de transferência\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Adicionar Coluna\",\n        \"Empty values last\": \"Valores vazios por último\",\n        \"Natural sort\": \"Classificação natural\",\n        \"Update data\": \"Atualizar Dados\",\n        \"Use choice position\": \"Usar posição de escolha\",\n        \"Search Columns\": \"Procurar colunas\"\n    },\n    \"SortFilterConfig\": {\n        \"Filter\": \"FILTRAR\",\n        \"Revert\": \"Reverter\",\n        \"Save\": \"Gravar\",\n        \"Sort\": \"ORDENAR\",\n        \"Update Sort & Filter settings\": \"Atualizar configurações de Classificação e Filtro\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Aparência \",\n        \"Switch appearance automatically to match system\": \"Alternar a aparência automaticamente para corresponder ao sistema\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Regras de Acesso\",\n        \"Code view\": \"Vista do Código\",\n        \"Delete\": \"Apagar\",\n        \"Delete document tour?\": \"Apagar tour do documento?\",\n        \"Document history\": \"Histórico do Documento\",\n        \"How-to Tutorial\": \"Tutorial de Como Fazer\",\n        \"Raw data\": \"Dados Primários\",\n        \"Return to viewing as yourself\": \"Voltar a ver como você mesmo\",\n        \"TOOLS\": \"FERRAMENTAS\",\n        \"Tour of this Document\": \"Tour desse Documento\",\n        \"Validate Data\": \"Validar dados\",\n        \"Settings\": \"Configurações\",\n        \"API console\": \"Consola API\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Gerir a equipa\"\n    },\n    \"TriggerFormulas\": {\n        \"Cancel\": \"Cancelar\",\n        \"Close\": \"Fechar\",\n        \"Current field \": \"Campo atual \",\n        \"OK\": \"OK\",\n        \"Any field\": \"Qualquer campo\",\n        \"Apply on changes to:\": \"Aplicar em alterações para:\",\n        \"Apply on record changes\": \"Aplicar em alterações de registo\",\n        \"Apply to new records\": \"Aplicar a novos registos\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Preview\": \"Prévisualizar\",\n        \"Revise\": \"Revisar\",\n        \"Update formula (Shift+Enter)\": \"Atualizar a fórmula (Shift+Enter)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Editor\",\n        \"In full\": \"Na íntegra\",\n        \"No Default Access\": \"Sem Acesso Padrão\",\n        \"None\": \"Nenhum\",\n        \"Owner\": \"Proprietário\",\n        \"View & edit\": \"Ver & Editar\",\n        \"View only\": \"Somente Ver\",\n        \"Viewer\": \"Observador\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Regra {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Atualizar a fórmula (Shift+Enter)\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Configurações avançadas\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"As tabelas grandes podem ser marcadas como \\\"sob-necessidade\\\" para evitar o seu carregamento no mecanismo de dados.\",\n        \"Blocks\": \"Blocos\",\n        \"Compact\": \"Compactar\",\n        \"Edit card layout\": \"Editar Layout do Cartão\",\n        \"Form\": \"Formulário\",\n        \"Make On-Demand\": \"Fazer Sob-Necessidade\",\n        \"Plugin: \": \"Plugin: \",\n        \"Section: \": \"Secção: \",\n        \"Unmark On-Demand\": \"Desmarcar Sob-Necessidade\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"As Tabelas On-Demand foram depreciadas devido à falta de funcionalidades e preocupações de usabilidade.\",\n        \"⚠️ Deprecated Feature\": \"⚠ Recurso Depreciado\"\n    },\n    \"FieldConfig\": {\n        \"Make into data column\": \"Transformar em coluna de dados\",\n        \"Mixed Behavior\": \"Comportamento Misto\",\n        \"Set formula\": \"Definir fórmula\",\n        \"Set trigger formula\": \"Definir fórmula de disparo\",\n        \"TRIGGER FORMULA\": \"FÓRMULA DE DISPARO\",\n        \"DESCRIPTION\": \"DESCRIÇÃO\",\n        \"COLUMN BEHAVIOR\": \"COMPORTAMENTO DE COLUNA\",\n        \"COLUMN LABEL AND ID\": \"RÓTULO E IDENTIFICAÇÃO DA COLUNA\",\n        \"Clear and make into formula\": \"Limpar e transformar em fórmula\",\n        \"Clear and reset\": \"Limpar e redefinir\",\n        \"Column options are limited in summary tables.\": \"As opções de coluna são limitadas nas tabelas de resumo.\",\n        \"Convert column to data\": \"Converter coluna para dados\",\n        \"Convert to trigger formula\": \"Converter em fórmula de disparo\",\n        \"Data columns_one\": \"Coluna de Dados\",\n        \"Data columns_other\": \"Colunas de Dados\",\n        \"Empty columns_one\": \"Coluna vazia\",\n        \"Empty columns_other\": \"Colunas Vazias\",\n        \"Enter formula\": \"Insira a fórmula\",\n        \"Formula columns_one\": \"Coluna de Fórmula\",\n        \"Formula columns_other\": \"Colunas de Fórmula\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Reverter para configurações comuns\",\n        \"Save as common settings\": \"Gravar como configuraçes comuns\",\n        \"Use separate settings\": \"Use configurações separadas\",\n        \"Using common settings\": \"Utilizar configurações comuns\",\n        \"Using separate settings\": \"Utilizar configurações separadas\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Adicionar Coluna\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Procurar colunas\",\n        \"Search Columns\": \"Procurar colunas\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Opções de Grade\",\n        \"Horizontal gridlines\": \"Linhas de Grade Horizontais\",\n        \"Vertical gridlines\": \"Linhas de Grade Verticais\",\n        \"Zebra stripes\": \"Listras de Zebra\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Adicionar coluna\",\n        \"Add to sort\": \"Adicionar à classificação\",\n        \"Clear values\": \"Limpar valores\",\n        \"Column Options\": \"Opções de Coluna\",\n        \"Convert formula to data\": \"Converter fórmula em dados\",\n        \"Delete {{count}} columns_one\": \"Apagar coluna\",\n        \"Delete {{count}} columns_other\": \"Apagar {{count}} colunas\",\n        \"Filter Data\": \"Filtrar Dados\",\n        \"Freeze {{count}} more columns_one\": \"Congelar mais uma coluna\",\n        \"Freeze {{count}} more columns_other\": \"Congelar {{count}} colunas mais\",\n        \"Freeze {{count}} columns_one\": \"Congelar esta coluna\",\n        \"Freeze {{count}} columns_other\": \"Congelar {{count}} colunas\",\n        \"Hide {{count}} columns_one\": \"Ocultar coluna\",\n        \"Hide {{count}} columns_other\": \"Ocultar {{count}} colunas\",\n        \"Insert column to the {{to}}\": \"Inserir coluna para a {{to}}\",\n        \"More sort options ...\": \"Mais opções de ordenação…\",\n        \"Rename column\": \"Renomear coluna\",\n        \"Reset {{count}} columns_one\": \"Reinicializar coluna\",\n        \"Reset {{count}} columns_other\": \"Reinicializar {{count}} colunas\",\n        \"Reset {{count}} entire columns_one\": \"Reinicializar toda a coluna\",\n        \"Reset {{count}} entire columns_other\": \"Reinicializar {{count}} colunas inteiras\",\n        \"Sort\": \"Ordenar\",\n        \"Show column {{- label}}\": \"Mostrar coluna {{- label}}\",\n        \"Sorted (#{{count}})_one\": \"Ordenado (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Ordenado (#{{count}})\",\n        \"Unfreeze all columns\": \"Descongelar todas as colunas\",\n        \"Unfreeze {{count}} columns_one\": \"Descongelar esta coluna\",\n        \"Unfreeze {{count}} columns_other\": \"Descongelar {{count}} colunas\",\n        \"Insert column to the left\": \"Inserir coluna à esquerda\",\n        \"Insert column to the right\": \"Inserir coluna à direita\",\n        \"Created by\": \"Criado por\",\n        \"Detect Duplicates in...\": \"Detetar duplicatas em...\",\n        \"UUID\": \"UUID\",\n        \"Shortcuts\": \"Atalhos\",\n        \"Show hidden columns\": \"Mostrar colunas ocultas\",\n        \"Created at\": \"Criado em\",\n        \"Created At\": \"Criado em\",\n        \"Authorship\": \"Autoria\",\n        \"Add formula column\": \"Añadir una columna de fórmulas\",\n        \"Last Updated By\": \"Última atualização por\",\n        \"Hidden Columns\": \"Colunas ocultas\",\n        \"Lookups\": \"Pesquisas\",\n        \"No reference columns.\": \"Não há colunas de referência.\",\n        \"Apply on record changes\": \"Aplicar em alterações de registro\",\n        \"Last updated by\": \"Última atualização por\",\n        \"Duplicate in {{- label}}\": \"Duplicar em {{- label}}\",\n        \"Created By\": \"Criado por\",\n        \"Last Updated At\": \"Última atualização em\",\n        \"Add column with type\": \"Adicionar coluna com tipo\",\n        \"Apply to new records\": \"Aplicar a novos registros\",\n        \"Search columns\": \"Procurar colunas\",\n        \"Timestamp\": \"Carimbo de data/hora\",\n        \"no reference column\": \"nenhuma coluna de referência\",\n        \"Adding UUID column\": \"A adicionar coluna UUID\",\n        \"Adding duplicates column\": \"Adicionar coluna duplicatas\",\n        \"Detect duplicates in...\": \"Detetar duplicados em...\",\n        \"Last updated at\": \"Última atualização em\",\n        \"Any\": \"Qualquer\",\n        \"Numeric\": \"Numérico\",\n        \"Integer\": \"Inteiro\",\n        \"Toggle\": \"Alternar\",\n        \"Choice\": \"Opção\",\n        \"DateTime\": \"DataHora\",\n        \"Choice List\": \"Lista de opções\",\n        \"Text\": \"Texto\",\n        \"Date\": \"Data\",\n        \"Reference\": \"Referência\",\n        \"Reference List\": \"Lista de referências\",\n        \"Attachment\": \"Anexo\"\n    },\n    \"HomeIntro\": {\n        \"Any documents created in this site will appear here.\": \"Qualquer documento criado neste site aparecerá aqui.\",\n        \"Browse Templates\": \"Explore os Modelos\",\n        \"Create empty document\": \"Criar um Documento Vazio\",\n        \"Get started by creating your first Grist document.\": \"Comece a criar o seu primeiro documento Grist.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Comece a explorar os modelos, ou criar o seu primeiro documento Grist.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Comece a convidar a sua equipa e criar o seu primeiro documento Grist.\",\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Import document\": \"Importar Documento\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Interessado em usar Grist além da sua equipa? Visite gratuitamente o seu \",\n        \"Invite Team Members\": \"Convide membros da equipa\",\n        \"Sign up\": \"Cadastre-se\",\n        \"Sprouts Program\": \"Programa Brotos\",\n        \"This workspace is empty.\": \"Essa área de trabalho está vazia.\",\n        \"Visit our {{link}} to learn more.\": \"Visite o nosso {{link}} para saber mais.\",\n        \"Welcome to Grist!\": \"Bem-vindo ao Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Bem-vindo ao Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Bem-vindo ao {{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Só tem acesso de leitura a este site. Atualmente não há documentos.\",\n        \"personal site\": \"Site pessoal\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} para gravar o seu trabalho. \",\n        \"Welcome to Grist, {{- name}}!\": \"Bem-vindo ao Grist, {{-name}}!\",\n        \"Welcome to {{- orgName}}\": \"Bem-vindo a {{-orgName}}\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Visite o nosso site {{link}} para saber mais sobre o Grist.\",\n        \"Sign in\": \"Entrar\",\n        \"To use Grist, please either sign up or sign in.\": \"Para usar o Grist, inscreva-se ou faça login.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Saiba mais no nosso {{helpCenterLink}}.\",\n        \"Only show documents\": \"Mostrar apenas documentos\"\n    },\n    \"GristTooltips\": {\n        \"Apply conditional formatting to rows based on formulas.\": \"Aplicar formatação condicional em linhas com base em fórmulas.\",\n        \"Updates every 5 minutes.\": \"Atualiza a cada 5 minutos.\",\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Aplicar formatação condicional às células nesta coluna quando as condições da fórmula forem atendidas.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"As células numa coluna de referência sempre identificam um registo {{entire}} nessa tabela, mas pode selecionar qual coluna desse registo deve ser mostrada.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Clique em \\\"Abrir estilos de linhas\\\" para aplicar a formatação condicional às linhas.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Clique no botão Adicionar Novo para criar documentos ou espaços de trabalho ou importar dados.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Clicar {{EyeHideIcon}} em cada célula esconde o campo desta visualização sem apagá-lo.\",\n        \"Editing Card Layout\": \"A editar o layout do cartão\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Fórmulas que acionam em certos casos e armazenam o valor calculado como dados.\",\n        \"Learn more.\": \"Saiba mais.\",\n        \"Link your new widget to an existing widget on this page.\": \"Vincule o seu novo widget a um widget existente nesta página.\",\n        \"Linking Widgets\": \"A vincular widgets\",\n        \"Nested Filtering\": \"Filtragem aninhada\",\n        \"Only those rows will appear which match all of the filters.\": \"Somente serão exibidas as linhas que correspondem a todos os filtros.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Os filtros fixados são exibidos como botões acima do widget.\",\n        \"Pinning Filters\": \"A fixar filtros\",\n        \"Raw Data page\": \"Página de dados brutos\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Organize os campos no seu cartão arrastando e redimensionando células.\",\n        \"Reference Columns\": \"Colunas de referência\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"As colunas de referência são a chave para os dados {{relational}} no Grist.\",\n        \"Select the table containing the data to show.\": \"Selecione a tabela que contém os dados a serem exibidos.\",\n        \"Select the table to link to.\": \"Selecione a tabela à qual se vincular.\",\n        \"Selecting Data\": \"A selecionar dados\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"A página de dados brutos lista todas as tabelas de dados no seu documento, incluindo tabelas de resumo e tabelas não incluídas nos layouts de página.\",\n        \"The total size of all data in this document, excluding attachments.\": \"O tamanho total de todos os dados deste documento, excluindo os anexos.\",\n        \"They allow for one record to point (or refer) to another.\": \"Eles permitem que um registo aponte (ou se refira) a outro.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"Este é o segredo dos layouts dinâmicos e produtivos do Grist.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Experimente mudanças numa cópia, depois decida se deseja substituir o original pelas suas edições.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Desfixe para ocultar o botão enquanto mantém o filtro.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Use o ícone \\\\u{1D6BA} para criar tabelas de resumo (ou dinâmicas) para totais ou subtotais.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Útil para armazenar o carimbo da hora ou autor de um novo registo, limpeza de dados e muito mais.\",\n        \"You can filter by more than one column.\": \"Pode filtrar por mais que uma coluna.\",\n        \"entire\": \"inteiro\",\n        \"relational\": \"relacionais\",\n        \"Access Rules\": \"Regras de Acesso\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"As regras de acesso lhe dão o poder de criar regras diferenciadas para determinar quem pode ver ou editar quais partes do seu documento.\",\n        \"Add new\": \"Adicionar Novo\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Use o ícone 𝚺 para criar tabelas resumidas (ou dinâmicas), para totais ou subtotais.\",\n        \"Anchor Links\": \"Ligações de âncora\",\n        \"Custom Widgets\": \"Widgets personalizados\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Para criar uma ligação de âncora que leve o utilizador a uma célula específica, clique numa linha e pressione {{shortcut}}.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Pode escolher um dos nossos widgets pré-feitos ou incorporar o seu próprio, fornecendo a sua URL completa.\",\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"Um UUID é uma cadeia de caracteres gerada aleatoriamente que é útil para identificadores exclusivos e chaves de ligação.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"Para configurar o seu calendário, selecione colunas para datas de início/fim e títulos de eventos. Observe o tipo de cada coluna.\"\n        },\n        \"Calendar\": \"Calendário\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"As fórmulas suportam muitas funções do Excel, sintaxe Python completa e incluem um assistente de IA útil.\",\n        \"Lookups return data from related tables.\": \"As pesquisas retornam dados de tabelas relacionadas.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Pode escolher entre os widgets disponíveis no menu suspenso ou incorporar o seu próprio widget fornecendo o URL completo.\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Não consegue encontrar as colunas certas? Clique em \\\"Change Widget\\\" (Alterar widget) para selecionar a tabela com os dados dos eventos.\",\n        \"Use reference columns to relate data in different tables.\": \"Use colunas de referência para relacionar dados em diferentes tabelas.\",\n        \"Forms are here!\": \"Os formulários chegaram!\",\n        \"Learn more\": \"Saiba mais\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Crie formulários simples diretamente no Grist e partilhe-os com um clique com o nosso novo widget. {{learnMoreButton}}\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Estas regras são aplicadas após todas as regras da coluna terem sido processadas, se aplicável.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Esta limitação ocorre quando uma extremidade de uma referência bidirecional é configurada como uma única referência.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Esta limitação ocorre quando uma coluna numa referência bidirecional tem o tipo Referência.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Atualmente, não há suporte para referências bidirecionais em Colunas de Fórmula ou Fórmula de disparo\",\n        \"The preview below this header shows how the selected user will see this document\": \"A visualização abaixo desse cabeçalho mostra como o utilizador selecionado verá este documento\",\n        \"Manage users and resources in a Grist installation.\": \"Gerir utilizadores e recursos numa instalação do Grist.\",\n        \"Example: {{example}}\": \"Exemplo: {{example}}\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Cria uma coluna reversa na tabela de destino que pode ser editada de qualquer lado.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Para permitir várias atribuições, altere o tipo da coluna Referência para Lista de Referência.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Para permitir várias atribuições, altere o tipo da coluna referenciada para Lista de referência.\",\n        \"Filter displayed dropdown values with a condition.\": \"Filtre os valores exibidos no menu suspenso com uma condição.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Os widgets comunitários são criados e mantidos pelos membros da comunidade Grist.\",\n        \"[Learn more.]({{link}})\": \"[Saiba mais]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"As tabelas de resumo só podem conter colunas de fórmula.\",\n        \"The new Grist Assistant is here!\": \"O novo assistente Grist está aqui!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"As fórmulas suportam muitas funções do Excel e a sintaxe completa do Python.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Cria uma nova coluna Lista de Referência na tabela de destino, com ambas as colunas de destino editáveis e sincronizadas.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Armazenamento interno significa que todos os anexos são armazenados no ficheiro SQLite do documento, enquanto o armazenamento externo indica que todos os anexos são armazenados no mesmo armazenamento externo.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Isto permite adicionar anexos que estão faltando no armazenamento externo, por exemplo, num documento importado. Somente arquivos de anexos .tar descarregados do Grist podem ser carregados aqui.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Perceba, modifique e trabalhe com os seus dados e fórmulas com a ajuda do novo Assistente de IA do Grist!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs. Do not submit passwords through this form, and be careful with links in it. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Este formulário foi criado por um utilizador do Grist e não é endossado pelo Grist Labs. Não envie palavras-passe por meio desse formulário e tenha cuidado com as ligações contidas. Denuncie formulários maliciosos para [{{mail}}](mailto:{{mail}}).\"\n    },\n    \"WelcomeQuestions\": {\n        \"IT & Technology\": \"TI e Tecnologia\",\n        \"Marketing\": \"Publicidade\",\n        \"Media Production\": \"Produção de Mídia\",\n        \"Other\": \"Outros\",\n        \"Product Development\": \"Desenvolvimento de Produto\",\n        \"Research\": \"Investigação\",\n        \"Sales\": \"Vendas\",\n        \"Type here\": \"Digite aqui\",\n        \"Welcome to Grist!\": \"Bem-vindo ao Grist!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"O que te traz ao Grist? Por favor, ajude-nos a atendê-lo melhor.\",\n        \"Education\": \"Educação\",\n        \"Finance & Accounting\": \"Finanças e Contabilidade\",\n        \"HR & Management\": \"RH e Gestão\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"Cancelar\",\n        \"DATA TABLE NAME\": \"NOME DA TABELA DE DADOS\",\n        \"Override widget title\": \"Substituir o título do Widget\",\n        \"Provide a table name\": \"Forneça um nome de tabela\",\n        \"Save\": \"Gravar\",\n        \"WIDGET TITLE\": \"TÍTULO DO WIDGET\",\n        \"WIDGET DESCRIPTION\": \"DESCRIÇÃO DO WIDGET\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Pode fazer edições, mas elas criarão uma nova cópia e\\nnão afetarão o documento original.\",\n        \"override\": \"sobreescrever\",\n        \"fiddle\": \"mexer\",\n        \"recovery mode\": \"modo de recuperação\",\n        \"snapshot\": \"instantâneo\",\n        \"unsaved\": \"não-gravado\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Duplicar página {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Observe que isto não copia dados, mas cria uma outra visão dos mesmos dados.\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Acesso negado{{suffix}}\",\n        \"Add account\": \"Adicionar conta\",\n        \"Contact support\": \"Entre em contato com o suporte\",\n        \"Error{{suffix}}\": \"Erro {{suffix}}\",\n        \"Go to main page\": \"Ir para a página principal\",\n        \"Page not found{{suffix}}\": \"Página não encontrada {{suffix}}\",\n        \"Sign in\": \"Entrar\",\n        \"Sign in again\": \"Iniciar sessão novamente\",\n        \"Sign in to access this organization's documents.\": \"Faça o login para aceder os documentos desta organização.\",\n        \"Signed out{{suffix}}\": \"Sessão finalizada{{suffix}}\",\n        \"Something went wrong\": \"Algo deu errado\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"A página solicitada não pôde ser encontrada.{{separator}}Por favor, verifique a URL e tente novamente.\",\n        \"There was an error: {{message}}\": \"Houve um erro: {{message}}\",\n        \"There was an unknown error.\": \"Houve um erro desconhecido.\",\n        \"You are now signed out.\": \"Agora está fora da sessão.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Está inscrito como {{email}}. Pode entrar com uma conta diferente, ou pedir acesso a um administrador.\",\n        \"You do not have access to this organization's documents.\": \"Não tem acesso aos documentos desta organização.\",\n        \"Account deleted{{suffix}}\": \"Conta excluída{{suffix}}\",\n        \"Your account has been deleted.\": \"A sua conta foi apagada.\",\n        \"Sign up\": \"Cadastre-se\",\n        \"An unknown error occurred.\": \"Ocorreu um erro desconhecido.\",\n        \"Build your own form\": \"Construa o seu próprio formulário\",\n        \"Form not found\": \"Formulário não encontrado\",\n        \"Powered by\": \"Desenvolvido por\",\n        \"Sign-in failed{{suffix}}\": \"Falha no login{{suffix}}\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Falha ao fazer login.{{separator}}Tente novamente ou entre em contato com o suporte.\"\n    },\n    \"CellStyle\": {\n        \"Cell style\": \"Estilo de célula\",\n        \"Default cell style\": \"Estilo de célula padrão\",\n        \"Mixed style\": \"Estilo misto\",\n        \"Open row styles\": \"Estilos de linha aberta\",\n        \"CELL STYLE\": \"ESTILO DE CÉLULA\",\n        \"HEADER STYLE\": \"ESTILO DE CABEÇALHO\",\n        \"Header Style\": \"Estilo de cabeçalho\",\n        \"Default header style\": \"Estilo de cabeçalho padrão\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"ESCOLHAS\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"DESCRIÇÃO DA COLUNA\",\n        \"COLUMN LABEL\": \"RÓTULO DA COLUNA\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"Adicionar outra regra\",\n        \"Add conditional style\": \"Adicionar estilo condicional\",\n        \"Error in style rule\": \"Erro na regra de estilo\",\n        \"Row style\": \"Estilo de Linha\",\n        \"Rule must return True or False\": \"A regra deve retornar Verdadeiro ou Falso\",\n        \"Conditional Style\": \"Estilo condicional\",\n        \"IF...\": \"SE...\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Moeda inválida\"\n    },\n    \"DiscussionEditor\": {\n        \"Cancel\": \"Cancelar\",\n        \"Comment\": \"Comentário\",\n        \"Edit\": \"Editar\",\n        \"Marked as resolved\": \"Marcado como resolvido\",\n        \"Only current page\": \"Somente a página atual\",\n        \"Only my threads\": \"Somente os meus tópicos\",\n        \"Open\": \"Abrir\",\n        \"Remove\": \"Remover\",\n        \"Reply\": \"Responder\",\n        \"Resolve\": \"Resolver\",\n        \"Reply to a comment\": \"Responder a um comentário\",\n        \"Show resolved comments\": \"Mostrar comentários resolvidos\",\n        \"Showing last {{nb}} comments\": \"Mostrar os últimos {{nb}} comentários\",\n        \"Started discussion\": \"Discussão iniciada\",\n        \"Write a comment\": \"Escreva um comentário\",\n        \"Save\": \"Gravar\",\n        \"Remove thread\": \"Remover tópico\",\n        \"updated\": \"atualizado\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Converter coluna em fórmula\"\n    },\n    \"WelcomeTour\": {\n        \"convert to card view, select data, and more.\": \"converta para a visualização de cartão, selecione dados e muito mais.\",\n        \"creator panel\": \"painel do criador\",\n        \"template library\": \"biblioteca de modelos\",\n        \"Add new\": \"Adicionar Novo\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Procure o nosso {{templateLibrary}} para descobrir o que é possível e se inspirar.\",\n        \"Building up\": \"A construir\",\n        \"Configuring your document\": \"A configurar o seu documento\",\n        \"Customizing columns\": \"A personalizar colunas\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Clique duas vezes ou pressione {{enter}} numa célula para editá-la. \",\n        \"Editing Data\": \"A editar dados\",\n        \"Enter\": \"Entra\",\n        \"Flying higher\": \"A voar mais alto\",\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Faça-o relacional! Use o tipo {{ref}} para vincular tabelas. \",\n        \"Reference\": \"Referência\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Defina opções de formatação, fórmulas ou tipos de coluna, como datas, escolhas ou anexos. \",\n        \"Share\": \"Partilhar\",\n        \"Sharing\": \"A partilhar\",\n        \"Start with {{equal}} to enter a formula.\": \"Comece com {{equal}} para inserir uma fórmula.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Alternar o {{creatorPanel}} para formatar colunas, \",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Use o botão Partilhar ({{share}}) para partilhar o documento ou exportar dados.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Use {{addNew}} para adicionar widgets, páginas ou importar mais dados. \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Use {{helpCenter}} para documentação ou perguntas.\",\n        \"Welcome to Grist!\": \"Bem-vindo ao Grist!\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Idioma\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Clique para inserir\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Detalhes de Acesso\",\n        \"Accounts\": \"Contas\",\n        \"Add account\": \"Adicionar Conta\",\n        \"Document settings\": \"Configurações do documento\",\n        \"Manage team\": \"Gerir Equipa\",\n        \"Pricing\": \"Preços\",\n        \"Profile settings\": \"Configurações de Perfil\",\n        \"Sign out\": \"Sair\",\n        \"Sign in\": \"Entrar\",\n        \"Switch Accounts\": \"Alternar Contas\",\n        \"Toggle Mobile Mode\": \"Alternar Modo Móvel\",\n        \"Support Grist\": \"Suporte Grist\",\n        \"Activation\": \"Ativação\",\n        \"Billing account\": \"Conta de faturação\",\n        \"Upgrade Plan\": \"Atualizar o Plano\",\n        \"Sign up\": \"Inscrever-se\",\n        \"Use This Template\": \"Use este modelo\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Ver como\",\n        \"Users from table\": \"Utilizadores da tabela\",\n        \"Example Users\": \"Utilizadores de exemplo\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"Falha ao carregar o Log de Ações\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"A Coluna {{colId}} foi posteriormente removida em ação #{{action.actionNum}}\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"A Tabela {{tableId}} foi posteriormente removida em ação #{{actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Essa linha foi posteriormente removida em ação {{action.actionNum}}\",\n        \"All tables\": \"Todas as tabelas\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Adicionar Novo\"\n    },\n    \"ApiKey\": {\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Ao gerar uma chave API, será capaz de fazer chamadas API para a sua própria conta.\",\n        \"Click to show\": \"Clique para mostrar\",\n        \"Create\": \"Criar\",\n        \"Remove\": \"Remover\",\n        \"Remove API Key\": \"Remover a Chave API\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Esta chave API pode ser usada para aceder esta conta anonimamente através da API.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Esta chave API pode ser usada para aceder a sua conta através da API. Não partilhe a sua chave API com ninguém.\",\n        \"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?\": \"Está prestes a apagar uma chave API. Isto fará com que todas as solicitações futuras usando esta chave API sejam rejeitadas. Realmente quer apagar?\"\n    },\n    \"App\": {\n        \"Description\": \"Descrição\",\n        \"Key\": \"Chave\",\n        \"Memory Error\": \"Erro de Memória\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Tradutores: por favor, traduzam isto apenas quando o seu idioma estiver pronto para ser oferecido aos utilizadores\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Página inicial\",\n        \"Legacy\": \"Legado\",\n        \"Personal Site\": \"Site pessoal\",\n        \"Team Site\": \"Site da Equipa\",\n        \"Grist Templates\": \"Modelos Grist\",\n        \"Billing account\": \"Conta de faturação\",\n        \"Manage team\": \"Gerir Equipa\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Este site da equipa está suspenso. Os documentos podem ser lidos, mas não modificados.\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"Limpar célula\",\n        \"Clear values\": \"Limpar valores\",\n        \"Copy anchor link\": \"Copiar a ligação de ancoragem\",\n        \"Delete {{count}} columns_one\": \"Apagar coluna\",\n        \"Delete {{count}} columns_other\": \"Apagar {{count}} colunas\",\n        \"Delete {{count}} rows_one\": \"Apagar linha\",\n        \"Delete {{count}} rows_other\": \"Apagar {{count}} linhas\",\n        \"Duplicate rows_one\": \"Duplicar linha\",\n        \"Duplicate rows_other\": \"Duplicar linhas\",\n        \"Filter by this value\": \"Filtre por este valor\",\n        \"Insert column to the left\": \"Inserir coluna à esquerda\",\n        \"Insert column to the right\": \"Inserir coluna à direita\",\n        \"Insert row\": \"Inserir linha\",\n        \"Insert row above\": \"Inserir linha acima\",\n        \"Insert row below\": \"Inserir linha abaixo\",\n        \"Reset {{count}} columns_one\": \"Reinicializar coluna\",\n        \"Reset {{count}} columns_other\": \"Reinicializar {{count}} colunas\",\n        \"Reset {{count}} entire columns_one\": \"Reinicializar toda a coluna\",\n        \"Reset {{count}} entire columns_other\": \"Reinicializar {{count}} colunas inteiras\",\n        \"Comment\": \"Comentário\",\n        \"Copy\": \"Copiar\",\n        \"Cut\": \"Cortar\",\n        \"Paste\": \"Colar\",\n        \"Copy with headers\": \"Copiar com cabeçalhos\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Default cell style\": \"Estilo de célula padrão\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Clique para copiar\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Apagar os dados da {{formattedTableName}} e removê-la de todas as páginas?\",\n        \"Duplicate table\": \"Duplicar a Tabela\",\n        \"Raw Data Tables\": \"Tabelas de Dados Primários\",\n        \"Table ID copied to clipboard\": \"ID da Tabela copiada para a área de transferência\",\n        \"You do not have edit access to this document\": \"Não tem permissão de edição desse documento\",\n        \"Edit record card\": \"Editar cartão de registro\",\n        \"Rename table\": \"Renomear tabela\",\n        \"{{action}} Record Card\": \"{{action}} Cartão de registro\",\n        \"Record Card\": \"Cartão de registro\",\n        \"Remove table\": \"Remover tabela\",\n        \"Record Card Disabled\": \"Cartão de registro desativado\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Adicionar Tabela Vazia\",\n        \"Add page\": \"Adicionar Página\",\n        \"Add widget to page\": \"Adicionar Widget à Página\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Proprietários do documento podem tentar recuperar o documento. [{{error}}]\",\n        \"Enter recovery mode\": \"Entrar em modo de recuperação\",\n        \"Error accessing document\": \"Erro ao aceder o documento\",\n        \"Reload\": \"Recarregar\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Desculpe, o acesso a esse documento foi negado. [{{error}}]\",\n        \"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}}]\": \"Pode tentar recarregar o documento ou usar o modo de recuperação. O modo de recuperação abre o documento para ser totalmente acessível aos proprietários e inacessível a outras pessoas. Ele também desativa as fórmulas. [{{error}}]\",\n        \"You do not have edit access to this document\": \"Não tem permissão de edição desse documento\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Por favor, recarregue o documento e se o erro persistir, entre em contato com o proprietário do documento para tentar uma recuperação de documentos. [{{error}}]\"\n    },\n    \"DocTour\": {\n        \"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.\": \"Não é possível construir um Tour a partir dos dados contidos neste documento. Certifique-se de que haja uma tabela chamada GristDocTour com colunas Title, Body, Placement e Location.\",\n        \"No valid document tour\": \"Tour de documento inválido\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Moeda:\",\n        \"Document settings\": \"Configurações do documento\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Motor (experimental {{span}} mudança por conta e risco próprios):\",\n        \"Local currency ({{currency}})\": \"Moeda local ({{currency}})\",\n        \"Locale:\": \"Localização:\",\n        \"Save\": \"Gravar\",\n        \"Save and Reload\": \"Gravar e Recarregar\",\n        \"This document's ID (for API use):\": \"O ID deste documento (para uso em API):\",\n        \"Time Zone:\": \"Fuso horário:\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"ID do documento copiado para a área de transferência\",\n        \"Ok\": \"OK\",\n        \"Manage Webhooks\": \"Gerir ganchos web\",\n        \"Webhooks\": \"Ganchos Web\",\n        \"API console\": \"Consola API\",\n        \"Time reload\": \"Recarga de tempo\",\n        \"Document ID\": \"ID do documento\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"ID do documento a ser usado sempre que a API REST fizer uma chamada para {{docId}}. Veja {{apiURL}}\",\n        \"For currency columns\": \"Para colunas de moeda\",\n        \"Hard reset of data engine\": \"Reinicialização total do motor de dados\",\n        \"ID for API use\": \"ID para uso da API\",\n        \"Notify other services on doc changes\": \"Notifique outros serviços em alterações doc\",\n        \"Python\": \"Python\",\n        \"Python version used\": \"Versão Python usada\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Forçar o recarregamento do documento durante a cronometragem das fórmulas e mostrar o resultado.\",\n        \"Reload data engine\": \"Recarregar o motor de dados\",\n        \"Reload data engine?\": \"Recarregar o motor de dados?\",\n        \"Start timing\": \"Iniciar cronometragem\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Pode fazer alterações no documento e, em seguida, interromper a cronometragem para ver os resultados.\",\n        \"Only available to document owners\": \"Disponível apenas para proprietários de documentos\",\n        \"Change document type\": \"Alterar o tipo de documento\",\n        \"Change nature of document\": \"Alterar a natureza do documento\",\n        \"Regular document\": \"Documento regular\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Comportamento normal do documento. Todos os utilizadores trabalham na mesma cópia do documento.\",\n        \"Regular\": \"Regular\",\n        \"Template\": \"Modelo\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"O documento abre automaticamente em {{fiddleModeDocUrl}}. Qualquer pessoa pode editar, o que criará uma nova cópia não gravada.\",\n        \"fiddle mode\": \"modo para experimentar\",\n        \"Tutorial\": \"Tutorial\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Isto executará um recarregamento rígido do mecanismo de dados. Isto pode ajudar se o mecanismo de dados estiver preso num loop infinito, estiver a processar indefinidamente a última alteração ou tiver travado. Nenhum dado será perdido, exceto possivelmente as ações pendentes no momento.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Quando iniciar a cronometragem, o Grist medirá o tempo necessário para avaliar cada fórmula. Isto permite diagnosticar quais fórmulas são responsáveis pelo desempenho lento quando um documento é aberto pela primeira vez ou quando um documento responde a alterações.\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Os anexos recém-carregados serão postos no armazenamento externo.\",\n        \"Preferred storage for this document\": \"Armazenamento preferido para este documento\",\n        \"Start transfer\": \"Iniciar transferência\",\n        \"Template mode\": \"Modo de modelo\",\n        \"Edit\": \"Editar\",\n        \"Document automatically opens as a user-specific copy.\": \"O documento abre automaticamente como uma cópia específica do utilizador.\",\n        \"Confirm change\": \"Confirmar alteração\",\n        \"API URL copied to clipboard\": \"URL da API copiado para a área de transferência\",\n        \"API documentation.\": \"Documentação de API.\",\n        \"Base doc URL: {{docApiUrl}}\": \"URL do documento base: {{docApiUrl}}\",\n        \"For number and date formats\": \"Para formatos de número e data\",\n        \"Formula times\": \"Tempos de fórmula\",\n        \"Manage webhooks\": \"Gerir webhooks\",\n        \"Reload\": \"Recarregar\",\n        \"Time zone\": \"Fuso horário\",\n        \"Try API calls from the browser\": \"Experimente chamadas de API do navegador\",\n        \"Coming soon\": \"Em breve\",\n        \"Copy to clipboard\": \"Copiar para a área de transferência\",\n        \"Currency\": \"Moeda\",\n        \"Data engine\": \"Motor de dados\",\n        \"Default for DateTime columns\": \"Padrão para colunas DataHorário\",\n        \"Find slow formulas\": \"Encontrar fórmulas lentas\",\n        \"Locale\": \"Localização\",\n        \"python2 (legacy)\": \"python2 (legado)\",\n        \"python3 (recommended)\": \"python3 (recomendado)\",\n        \"Cancel\": \"Cancelar\",\n        \"Formula timer\": \"Temporizador de Fórmula\",\n        \"Stop timing...\": \"Pare de cronometrar...\",\n        \"Timing is on\": \"O tempo está ligado\",\n        \"External\": \"Externo\",\n        \"Internal\": \"Interno\",\n        \"Transfer in progress\": \"Transferência em andamento\",\n        \"[Learn more.]({{learnLink}})\": \"[Saiba mais]({{learnLink}})\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Alguns anexos existentes ainda são [externos]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Alguns anexos existentes ainda são [internos]({{internalLink}})** (armazenados no ficheiro SQLite).\",\n        \"Only available to document editors\": \"Disponível apenas para editores de documentos\",\n        \"**Some existing attachments are still external**.\": \"**Alguns anexos existentes ainda são externos**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Alguns anexos existentes ainda são internos** (armazenados num ficheiro SQLite).\",\n        \"Attachment storage\": \"Armazenamento de anexos\",\n        \"Being transfer\": \"Transferência\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Clique em \\\"Iniciar transferência\\\" para transferir aqueles para armazenamento externo.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Clique em \\\"Iniciar transferência\\\" para transferir aqueles para armazenamento interno (armazenado no ficheiro SQLite documento).\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Os anexos recém-carregados serão postos no armazenamento interno.\",\n        \"No external stores available\": \"Não há armazenamento externo disponível\",\n        \"Upload\": \"Carregar\",\n        \"Upload missing attachments\": \"Carregar anexos ausentes\",\n        \"Uploading...\": \"A carregar...\",\n        \"Default\": \"Padrão\",\n        \"Default, template, or tutorial\": \"Padrão, modelo ou tutorial\",\n        \"Document type\": \"Tipo de documento\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"Tamanho dos Anexos\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Entre em contato com o proprietário do site para atualizar o plano para aumentar os limites.\",\n        \"Data size\": \"Tamanho dos Dados\",\n        \"For higher limits, \": \"Para limites maiores, \",\n        \"Rows\": \"Linhas\",\n        \"Usage\": \"Uso\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"As estatísticas de uso só estão disponíveis para utilizadores com acesso total aos dados do documento.\",\n        \"start your 30-day free trial of the Pro plan.\": \"comece a sua avaliação gratuita de 30 dias do plano Pro.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Restaurar a última edição\",\n        \"Undo discard\": \"Desfazer descarte\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Copiar todos os dados, além da estrutura da tabela.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Em vez de dplicar tabelas, geralmente é melhor segmentar os dados usando vistas vinculadas. {{link}}\",\n        \"Name for new table\": \"Nome para a nova tabela\",\n        \"Only the document default access rules will apply to the copy.\": \"Somente as regras de acesso padrão do documento serão aplicadas à cópia.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Programa Pós-Escolar\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Confira o nosso tutorial relacionado para saber como vincular dados e criar leiautes de alta produtividade.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Consulte o nosso tutorial relacionado para saber como modelar dados corporativos, usar fórmulas e gerir a complexidade.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Confira o nosso tutorial relacionado para aprender como criar tabelas e gráficos resumidos e para vincular os gráficos dinamicamente.\",\n        \"Investment Research\": \"Pesquisa de Investimento\",\n        \"Lightweight CRM\": \"CRM Simples (Gestão de Relações com o Cliente)\",\n        \"Tutorial: Analyze & Visualize\": \"Tutorial: Analisar e Visualizar\",\n        \"Tutorial: Create a CRM\": \"Tutorial: Criar um CRM\",\n        \"Tutorial: Manage Business Data\": \"Tutorial: Gerir dados corporativos\",\n        \"Welcome to the Afterschool Program template\": \"Bem vindo ao modelo do Programa Pós-Escolar\",\n        \"Welcome to the Investment Research template\": \"Bem vindo ao modelo de Pesquisa de Investimento\",\n        \"Welcome to the Lightweight CRM template\": \"Bem vindo ao modelo de CRM Simples (gestão de relações com o cliente)\"\n    },\n    \"HomeLeftPane\": {\n        \"Access Details\": \"Detalhes de Acesso\",\n        \"All documents\": \"Todos os Documentos\",\n        \"Create empty document\": \"Criar um Documento Vazio\",\n        \"Create workspace\": \"Criar Área de Trabalho\",\n        \"Delete\": \"Apagar\",\n        \"Delete {{workspace}} and all included documents?\": \"Apagar {{workspace}} e todos os documentos inclusos?\",\n        \"Examples & Templates\": \"Modelos\",\n        \"Import document\": \"Importar Documento\",\n        \"Manage users\": \"Gerir Utilizadores\",\n        \"Rename\": \"Renomear\",\n        \"Trash\": \"Lixo\",\n        \"Workspace will be moved to Trash.\": \"A Área de Trabalho será movida para Lixo.\",\n        \"Workspaces\": \"Áreas de Trabalho\",\n        \"Tutorial\": \"Tutorial\",\n        \"Terms of service\": \"Termos de serviço\",\n        \"Grist Resources\": \"Recursos do Grist\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"Mesclar linhas que correspondem a estes campos:\",\n        \"Select fields to match on\": \"Selecione os campos a serem correspondidos em\",\n        \"Update existing records\": \"Atualizar os registos existentes\",\n        \"Column mapping\": \"Mapeamento de coluna\",\n        \"Grist column\": \"Coluna de Grist\",\n        \"{{count}} unmatched field_one\": \"{{count}} campo sem equivalente\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} campo sem equivalente na importação\",\n        \"Revert\": \"Reverter\",\n        \"Skip Import\": \"Pular importação\",\n        \"{{count}} unmatched field_other\": \"{{count}} campos sem equivalente\",\n        \"New Table\": \"Nova tabela\",\n        \"Skip\": \"Pular\",\n        \"Column Mapping\": \"Mapeamento de coluna\",\n        \"Destination table\": \"Tabela de destino\",\n        \"Skip Table on Import\": \"Pular tabela na importação\",\n        \"Import from file\": \"Importar de ficheiro\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} campos sem equivalente na importação\",\n        \"Source column\": \"Coluna de origem\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Centro de Ajuda\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Ordenar & filtrar avançados\",\n        \"Copy anchor link\": \"Copiar a ligação de ancoragem\",\n        \"Data selection\": \"Seleção de dados\",\n        \"Delete record\": \"Apagar registo\",\n        \"Delete widget\": \"Apagar Widget\",\n        \"Download as CSV\": \"Descarregar como CSV\",\n        \"Download as XLSX\": \"Descarregar como XLSX\",\n        \"Edit card layout\": \"Editar Layout de cartão\",\n        \"Open configuration\": \"Abrir configuração\",\n        \"Print widget\": \"Imprimir Widget\",\n        \"Show raw data\": \"Mostrar dados primários\",\n        \"Widget options\": \"Opções do Widget\",\n        \"Add to page\": \"Adicionar à página\",\n        \"Collapse widget\": \"Colapsar widget\",\n        \"Create a form\": \"Criar um formulário\",\n        \"Duplicate widget\": \"Duplicar widget\"\n    },\n    \"ViewSectionMenu\": {\n        \"(customized)\": \"(personalizado)\",\n        \"(empty)\": \"(vazio)\",\n        \"(modified)\": \"(modificado)\",\n        \"Custom options\": \"Opções personalizadas\",\n        \"FILTER\": \"FILTRAR\",\n        \"Revert\": \"Reverter\",\n        \"SORT\": \"ORDENAR\",\n        \"Save\": \"Gravar\",\n        \"Update Sort&Filter settings\": \"Atualizar configurações de Ordenar e Filtrar\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"Não é possível lançar itens em Campos Ocultos\",\n        \"Clear\": \"Limpar\",\n        \"Hidden Fields cannot be reordered\": \"Campos ocultos não podem ser reordenados\",\n        \"Select all\": \"Selecionar Todos\",\n        \"Visible {{label}}\": \"{{label}} visível\",\n        \"Hide {{label}}\": \"Ocultar {{label}}\",\n        \"Hidden {{label}}\": \"{{label}} escondido\",\n        \"Show {{label}}\": \"Mostrar {{label}}\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* As áreas de trabalho estão disponíveis nos planos de equipa. \",\n        \"Select fields\": \"Selecionar campos\",\n        \"Upgrade now\": \"Atualizar agora\",\n        \"Any\": \"Qualquer\",\n        \"Numeric\": \"Numérico\",\n        \"Text\": \"Texto\",\n        \"Integer\": \"Inteiro\",\n        \"Toggle\": \"Alternar\",\n        \"Date\": \"Data\",\n        \"DateTime\": \"DataHora\",\n        \"Choice\": \"Opção\",\n        \"Choice List\": \"Lista de opções\",\n        \"Reference\": \"Referência\",\n        \"Reference List\": \"Lista de Referências\",\n        \"Attachment\": \"Anexo\",\n        \"Search columns\": \"Procurar colunas\",\n        \"Custom\": \"Personalizado\",\n        \"By Date Modified\": \"Por Data de Modificação\",\n        \"By Name\": \"Por Nome\",\n        \"Light\": \"Claro\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Cancelar\",\n        \"Ok\": \"OK\",\n        \"Save\": \"Gravar\",\n        \"Are you sure you want to delete this record?\": \"Tem certeza de que deseja apagar este registo?\",\n        \"Don't show tips\": \"Não mostrar dicas\",\n        \"Undo to restore\": \"Desfazer para restaurar\",\n        \"Don't show again.\": \"Não mostrar novamente.\",\n        \"Don't ask again.\": \"Não perguntar novamente.\",\n        \"Are you sure you want to delete these records?\": \"Tem a certeza de que deseja apagar esses registos?\",\n        \"Delete\": \"Eliminar\",\n        \"Dismiss\": \"Descartar\",\n        \"Got it\": \"Percebido\",\n        \"Don't show again\": \"Não mostrar novamente\",\n        \"Confirm\": \"Confirmar\",\n        \"TIP\": \"DICA\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Duplicar a Página\",\n        \"Remove\": \"Remover\",\n        \"Rename\": \"Renomear\",\n        \"You do not have edit access to this document\": \"Não tem permissão de edição desse documento\",\n        \"(default)\": \"(predefinição)\",\n        \"Expand {{maybeDefault}}\": \"Expandir {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Definir predefinição: colapso\",\n        \"Set default: Expand\": \"Definir predefinição: Expandir\"\n    },\n    \"search\": {\n        \"Find Next \": \"Encontrar Próximo \",\n        \"Find Previous \": \"Encontrar Anterior \",\n        \"No results\": \"Sem resultados\",\n        \"Search in document\": \"Procurar no documento\",\n        \"Search\": \"Procurar\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"A enviar ficheiro ao Google Drive\"\n    },\n    \"NTextBox\": {\n        \"false\": \"falso\",\n        \"true\": \"verdadeiro\",\n        \"Field Format\": \"Formato do campo\",\n        \"Lines\": \"Linhas\",\n        \"Multi line\": \"Multilinha\",\n        \"Single line\": \"Linha única\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"Utilizadores de exemplo\",\n        \"Users from table\": \"Utilizadores da tabela\",\n        \"View as\": \"Ver como\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Preview\": \"Pré-visualização\",\n        \"Revise\": \"Revisar\",\n        \"Update formula (Shift+Enter)\": \"Atualizar a fórmula (Shift+Enter)\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"Deveria ser impossível de gravar um valor de dados simples numa coluna de fórmula\",\n        \"Unable to finish saving edited cell\": \"Não é possível concluir gravar a célula editada\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Coluna ou campo é obrigatório\",\n        \"Error in the cell\": \"Erro na célula\",\n        \"Errors in all {{numErrors}} cells\": \"Erro em todas as {{numErrors}} células\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Erros em {{numErrors}} de {{numCells}} células\",\n        \"editingFormula is required\": \"ediçãoFórmula é obrigatório\",\n        \"Enter formula or {{button}}.\": \"Digite a fórmula ou {{button}}.\",\n        \"Enter formula.\": \"Digite a fórmula.\",\n        \"Expand Editor\": \"Expandir editor\",\n        \"use AI Assistant\": \"usar o Assistente de IA\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[rótulo da ligação] URL\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"FORMATO DA CÉLULA\",\n        \"Row ID\": \"ID da linha\",\n        \"SHOW COLUMN\": \"MOSTRAR COLUNA\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"DESCRIÇÃO\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"Fechar Painel do Criador\",\n        \"Open creator panel\": \"Abrir o Painel do Criador\",\n        \"Creator panel (right panel)\": \"Painel do criador (painel direito)\",\n        \"Document header\": \"Cabeçalho do documento\",\n        \"Main content\": \"Conteúdo principal\",\n        \"Main navigation and document settings (left panel)\": \"Navegação principal e configurações do documento (painel esquerdo)\"\n    },\n    \"ColumnTitle\": {\n        \"Add description\": \"Adicionar descrição\",\n        \"COLUMN ID: \": \"ID DA COLUNA: \",\n        \"Cancel\": \"Cancelar\",\n        \"Column ID copied to clipboard\": \"ID da coluna copiada para a área de transferência\",\n        \"Column description\": \"Descrição da coluna\",\n        \"Column label\": \"Rótulo da coluna\",\n        \"Provide a column label\": \"Forneça um rótulo de coluna\",\n        \"Save\": \"Gravar\",\n        \"Close\": \"Fechar\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Entendido\",\n        \"Unavailable Command\": \"Comando indisponível\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"O comando de menu {{action}} não está disponível neste navegador. Mas pode aceder {{action}} usando o atalho de teclado {{shortcut}}.\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"Limpar campo\",\n        \"Copy\": \"Copiar\",\n        \"Copy anchor link\": \"Copiar ligação de âncora\",\n        \"Cut\": \"Cortar\",\n        \"Hide field\": \"Ocultar campo\",\n        \"Paste\": \"Colar\",\n        \"Comment\": \"Comentário\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Limpar fila\",\n        \"Webhook settings\": \"Configurações do gancho web\",\n        \"Ready Column\": \"Coluna pronta\",\n        \"Webhook Id\": \"Id do webhook\",\n        \"Sorry, not all fields can be edited.\": \"Desculpe, nem todos os campos podem ser editados.\",\n        \"Cleared webhook queue.\": \"Fila de webhooks limpa.\",\n        \"Header Authorization\": \"Autorização de cabeçalho\",\n        \"Columns to check when update (separated by ;)\": \"Colunas a serem verificadas na atualização (separadas por ;)\",\n        \"Enabled\": \"Ativado\",\n        \"Memo\": \"Memorando\",\n        \"Name\": \"Nome\",\n        \"URL\": \"URL\",\n        \"Status\": \"Estado\",\n        \"Table\": \"Tabela\",\n        \"Removed webhook.\": \"Webhook removido.\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Filtrar as alterações nessas Colunas (ids separados por ponto e vírgula)\",\n        \"Event Types\": \"Tipos de eventos\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooks Indisponível Em cópias de documentos não gravadas\"\n    },\n    \"UserManager\": {\n        \"Cancel\": \"Cancelar\",\n        \"Outside collaborator\": \"Colaborador externo\",\n        \"Public access\": \"Acesso público\",\n        \"Public access: \": \"Acesso público: \",\n        \"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}}.\": \"Depois de remover o seu próprio acesso, não poderá recuperá-lo sem a ajuda de outra pessoa com acesso suficiente ao {{resourceType}}.\",\n        \"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.\": \"O utilizador tem acesso de visualização a {{resource}} resultante do acesso definido manualmente aos recursos internos. Se removido aqui, esse utilizador perderá o acesso aos recursos internos.\",\n        \"Anyone with link \": \"Qualquer pessoa com ligação \",\n        \"Add {{member}} to your team\": \"Adicionar {{member}} à sua equipa\",\n        \"Allow anyone with the link to open.\": \"Permita que qualquer pessoa com a ligação abra.\",\n        \"Close\": \"Fechar\",\n        \"Collaborator\": \"Colaborador\",\n        \"Confirm\": \"Confirmar\",\n        \"Copy link\": \"Copiar ligação\",\n        \"Create a team to share with more people\": \"Crie uma equipa para partilhar com mais pessoas\",\n        \"Grist support\": \"Suporte Grist\",\n        \"Guest\": \"Convidado\",\n        \"Invite multiple\": \"Convidar vários\",\n        \"Invite people to {{resourceType}}\": \"Convidar pessoas para {{resourceType}}\",\n        \"Link copied to clipboard\": \"Ligação copiada para a área de transferência\",\n        \"Manage members of team site\": \"Gerir membros do site da equipa\",\n        \"Off\": \"Desligado\",\n        \"On\": \"Ligado\",\n        \"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}}.\": \"Depois de remover o seu próprio acesso, não poderá recuperá-lo sem a ajuda de outra pessoa com acesso suficiente ao {{name}}.\",\n        \"Open Access Rules\": \"Regras de acesso aberto\",\n        \"Save & \": \"Gravar & \",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"O utilizador herda as permissões de {{parent})}. Para remover, defina a opção 'Herdar acesso' para 'Nenhum'.\",\n        \"User may not modify their own access.\": \"O utilizador não pode modificar o seu próprio acesso.\",\n        \"member\": \"membro\",\n        \"team site\": \"site da equipa\",\n        \"{{collaborator}} limit exceeded\": \"Limite de {{collaborator}} excedido\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} de {{limitTop}} {{collaborator}}s\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Nenhum acesso padrão permite que o acesso seja concedido a documentos ou espaços de trabalho individuais, em vez do site de equipa completo.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Está prestes a remover o seu próprio acesso a este {{resourceType}}\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Nenhum acesso padrão permite que o acesso seja concedido a documentos ou espaços de trabalho individuais, em vez do site de equipa completo.\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Acesso público herdado de {{parent}}. Para remover, defina a opção 'Herdar acesso' para 'Nenhum'.\",\n        \"Remove my access\": \"Remover o meu acesso\",\n        \"Team member\": \"Membro da equipa\",\n        \"Your role for this team site\": \"O seu papel para este site de equipa\",\n        \"Your role for this {{resourceType}}\": \"O seu papel para este {{resourceType}}\",\n        \"free collaborator\": \"colaborador livre\",\n        \"guest\": \"convidado\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"O utilizador herda as permissões de {{parent}}. Para remover, defina a opção 'Herdar acesso' para 'Nenhum'.\",\n        \"Inherit access: \": \"Acesso herdado: \",\n        \"Access overview\": \"Visão geral do acesso\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"DESCRIÇÃO\"\n    },\n    \"SearchModel\": {\n        \"Search all tables\": \"Procurar todas as tabelas\",\n        \"Search all pages\": \"Procurar todas as páginas\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Procurar\"\n    },\n    \"SupportGristNudge\": {\n        \"Close\": \"Fechar\",\n        \"Contribute\": \"Contribuir\",\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Opt in to Telemetry\": \"Aceitar a Telemetria\",\n        \"Opted In\": \"Optou por participar\",\n        \"Support Grist\": \"Suporte Grist\",\n        \"Support Grist page\": \"Página de Suporte Grist\",\n        \"Admin Panel\": \"Painel do administrador\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Obrigado! A sua confiança e o seu apoio são muito apreciados. Cancele a qualquer momento no {{link}} no menu do utilizador.\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"Página de patrocinadores do GitHub\",\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Home\": \"Início\",\n        \"Manage Sponsorship\": \"Gerir patrocínio\",\n        \"Opt in to Telemetry\": \"Aceitar a Telemetria\",\n        \"Opt out of Telemetry\": \"Desativar a Telemetria\",\n        \"Sponsor Grist Labs on GitHub\": \"Patrocine Grist Labs no GitHub\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Esta instância está incluída na telemetria. Somente o administrador do site tem permissão para alterar isso.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Esta instância foi desativada da telemetria. Somente o administrador do site tem permissão para alterar isso.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Pode desativar a telemetria a qualquer momento nesta página.\",\n        \"You have opted in to telemetry. Thank you!\": \"Optou pela telemetria. Obrigado!\",\n        \"You have opted out of telemetry.\": \"Decidiu em não participar da telemetria.\",\n        \"Support Grist\": \"Suporte Grist\",\n        \"Telemetry\": \"Telemetria\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Coletamos apenas estatísticas de uso, conforme detalhado no nosso {{link}}, nunca o conteúdo dos documentos.\",\n        \"Sponsor\": \"Patrocinador\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"O software Grist é desenvolvido pela Grist Labs, que oferece planos hospedados gratuitos e pagos. Também disponibilizamos o código do Grist sob uma licença OSS padrão gratuita e aberta (Apache 2.0) em {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Apoie o Grist optando pela telemetria, que nos ajuda a perceber como o produto é usado, para que possamos priorizar melhorias futuras.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Somos uma equipa pequena e determinada. O seu apoio é muito importante para nós. Ele também mostra aos outros que há uma comunidade determinada por trás desse produto.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Pode apoiar o desenvolvimento de código aberto do Grist patrocinando-nos no nosso site {{link}}.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Sem dados\",\n        \"No row selected in {{title}}\": \"Nenhuma linha selecionada em {{title}}\",\n        \"Not all data is shown\": \"Nem todos os dados são mostrados\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Recolher editor\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Maximizar\",\n        \"Minimize\": \"Minimizar\"\n    },\n    \"CardContextMenu\": {\n        \"Insert card above\": \"Inserir cartão acima\",\n        \"Duplicate card\": \"Duplicar o cartão\",\n        \"Insert card below\": \"Inserir cartão abaixo\",\n        \"Delete card\": \"Apagar cartão\",\n        \"Copy anchor link\": \"Copiar ligação de ancoragem\",\n        \"Insert card\": \"Inserir cartão\"\n    },\n    \"Menu\": {\n        \"Insert question below\": \"Inserir questão abaixo\",\n        \"Paragraph\": \"Parágrafo\",\n        \"Columns\": \"Colunas\",\n        \"Paste\": \"Colar\",\n        \"Insert question above\": \"Insira a questão acima\",\n        \"Header\": \"Cabeçalho\",\n        \"Copy\": \"Copiar\",\n        \"Cut\": \"Cortar\",\n        \"Building blocks\": \"Blocos de construção\",\n        \"Separator\": \"Separador\",\n        \"Unmapped fields\": \"Campos não mapeados\",\n        \"New question\": \"Nova pergunta\",\n        \"More\": \"Mais\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Map fields\": \"Mapear campos\",\n        \"Mapped\": \"Mapeado\",\n        \"Clear\": \"Limpar\",\n        \"Select all\": \"Selecionar Todos\",\n        \"Unmapped\": \"Desmapeado\",\n        \"Unmap fields\": \"Desmapear campos\"\n    },\n    \"FormView\": {\n        \"Are you sure you want to reset your form?\": \"Tem certeza de que deseja redefinir o formulário?\",\n        \"Preview\": \"Pré-visualização\",\n        \"Save your document to publish this form.\": \"Grave o seu documento para publicar este formulário.\",\n        \"Publish your form?\": \"Publicar o seu formulário?\",\n        \"Code copied to clipboard\": \"Código copiado para a área de transferência\",\n        \"Copy code\": \"Copiar código\",\n        \"Copy link\": \"Copiar ligação\",\n        \"Embed this form\": \"Incorporar este formulário\",\n        \"Link copied to clipboard\": \"Ligação copiada para a área de transferência\",\n        \"Reset form\": \"Redefinir formulário\",\n        \"Share\": \"Partilhar\",\n        \"Share this form\": \"Partilhe este formulário\",\n        \"View\": \"Ver\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Qualquer pessoa com a ligação abaixo pode ver o formulário vazio e enviar uma resposta.\",\n        \"Reset\": \"Redefinir\",\n        \"Unpublish\": \"Cancelar publicação\",\n        \"Unpublish your form?\": \"Despublicar o seu formulário?\",\n        \"Publish\": \"Publicar\",\n        \"Your form description goes here.\": \"A descrição do seu formulário vai aqui.\",\n        \"## **Form Title**\": \"## **Título do formulário**\",\n        \"# **Form Title**\": \"# **Título do formulário**\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"A publicação do formulário gerará uma ligação de compartilhamento. Qualquer pessoa com a ligação poderá ver o formulário vazio e enviar uma resposta.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Ao cancelar a publicação do formulário, a ligação de compartilhamento será desativado, de modo que os utilizadores que acederem o formulário por meio dessa ligação verão um erro.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Os utilizadores estão limitados a enviar entradas (registos na sua tabela) e ler valores predefinidos em campos designados, como colunas de referência e de escolha.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"O seu formulário está publicado. Todas as alterações estão ativas e visíveis para os utilizadores com acesso ao formulário. Se quiser fazer alterações no rascunho, cancele a publicação do formulário.\"\n    },\n    \"AdminPanel\": {\n        \"Home\": \"Início\",\n        \"Sponsor\": \"Patrocinador\",\n        \"Support Grist\": \"Apoiar o Grist\",\n        \"Telemetry\": \"Telemetria\",\n        \"Admin Panel\": \"Painel do administrador\",\n        \"Current\": \"Atual\",\n        \"Current version of Grist\": \"Versão atual do Grist\",\n        \"Help us make Grist better\": \"Ajude-nos a melhorar o Grist\",\n        \"Support Grist Labs on GitHub\": \"Apoie a Grist Labs no GitHub\",\n        \"Version\": \"Versão\",\n        \"Checking for updates...\": \"A verificar atualizações...\",\n        \"Error checking for updates\": \"Erro ao verificar atualizações\",\n        \"Grist is up to date\": \"Grist está atualizado\",\n        \"Check failed.\": \"A verificação falhou.\",\n        \"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 a ser disponibilizado para várias pessoas.\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Ou, como alternativa, pode definir: {{bootKey}} no ambiente e visitar: {{url}}\",\n        \"Results\": \"Resultados\",\n        \"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 a ser disponibilizado para várias pessoas.\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"O Grist assina os cookies de sessão do utilizador com uma chave secreta. Defina esta 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 este 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.\",\n        \"Sandbox settings for data engine\": \"Configurações da caixa de areia para o motor de dados\",\n        \"unconfigured\": \"não configurado\",\n        \"Administrator Panel Unavailable\": \"Painel do administrador indisponível\",\n        \"Check succeeded.\": \"A verificação foi bem-sucedida.\",\n        \"No fault detected.\": \"Nenhuma falha detetada.\",\n        \"Notes\": \"Notas\",\n        \"Self Checks\": \"Autoverificações\",\n        \"Enable Grist Enterprise\": \"Ativar a Grist Empresarial\",\n        \"Enterprise\": \"Empresarial\",\n        \"OK\": \"OK\",\n        \"Updates\": \"Atualizações\",\n        \"Authentication\": \"Autenticação\",\n        \"Newer version available\": \"Versão mais recente disponível\",\n        \"Log Streaming\": \"Fluxo de registos\",\n        \"Session Secret\": \"Segredo da sessão\",\n        \"Auto-check when this page loads\": \"Verificar automaticamente quando esta página carregar\",\n        \"Check now\": \"Verificar agora\",\n        \"No information available\": \"Não há informações disponíveis\",\n        \"unknown\": \"desconhecido\",\n        \"Current authentication method\": \"Método de autenticação atual\",\n        \"checking\": \"a verificar\",\n        \"Error\": \"Erro\",\n        \"Audit Logs\": \"Registos de auditoria\",\n        \"Contact us\": \"Entre em contato conosco\",\n        \"New, Enterprise\": \"Novo, Enterprise\",\n        \"Off\": \"Desligado\",\n        \"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 permite fórmulas muito poderosas, usando Python. Recomendamos definir a variável de ambiente GRIST_SANDBOX_FLAVOR para gvisor se o seu hardware o suporta (a maioria suportará), para executar fórmulas em cada documento dentro de uma caixa de areia isolada de outros documentos e isolada da rede.\",\n        \"Grist releases are at \": \"Os lançamentos do Grist estão em \",\n        \"Last checked {{time}}\": \"Última verificação {{time}}\",\n        \"Learn more.\": \"Saiba mais.\",\n        \"Sandboxing\": \"Caixa de areia\",\n        \"Security Settings\": \"Configurações de segurança\",\n        \"Details\": \"Pormenores\",\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.\": \"O Grist assina os cookies de sessão do utilizador com uma chave secreta. Define esta chave por meio da variável de ambiente GRIST_SESSION_SECRET. O Grist retorna a um padrão codificado quando 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.\",\n        \"Key to sign sessions with\": \"Chave para assinar sessões com\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} mais\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"Não tem acesso ao painel do administrador.\\nFaça login como administrador.\",\n        \"On\": \"Ligado\",\n        \"Grist Instance\": \"Instância do Grist\",\n        \"Auto-check weekly\": \"Verificação automática semanal\",\n        \"No record of last version check\": \"Nenhum registo da última verificação de versão\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Pode configurar a transmissão de eventos de auditoria do Grist para um sistema externo de gestão de eventos e informações de segurança (SIEM) se ativar o Grist Enterprise. {{contactUsLink}} para saber mais.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"As verificações automáticas estão desativadas. Defina a variável de ambiente GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING como \\\"true\\\" para ativá-las.\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Eliminar\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Regras de campo\",\n        \"Required field\": \"Campo obrigatório\",\n        \"Field Rules\": \"Regras de campo\",\n        \"Ascending\": \"Ascendente\",\n        \"Field Format\": \"Formato do campo\",\n        \"Descending\": \"Descendente\",\n        \"Options Sort Order\": \"Opções Ordem de classificação\",\n        \"Vertical\": \"Vertical\",\n        \"Default\": \"Padrão\",\n        \"Horizontal\": \"Horizontal\",\n        \"Options Alignment\": \"Alinhamento de opções\",\n        \"Radio\": \"Rádio\",\n        \"Select\": \"Selecione\"\n    },\n    \"CustomView\": {\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Para usar este widget, mapeie todas as colunas não opcionais do painel criador à direita.\",\n        \"Some required columns aren't mapped\": \"Algumas colunas obrigatórias não estão mapeadas\",\n        \"Some required columns are hidden by access rules\": \"Algumas colunas necessárias estão ocultas por regras de acesso\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Para utilizar este widget, todas as colunas mapeadas devem estar visíveis. Contacte o proprietário do documento ou modifique as regras de acesso.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Crie o seu próprio formulário\",\n        \"Powered by\": \"Desenvolvido por\",\n        \"Powered by Grist\": \"Desenvolvido por Grist\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"Epá! O formulário que procura não existe.\",\n        \"Oops! This form is no longer published.\": \"Ops! Este formulário não está mais publicado.\",\n        \"There was a problem loading the form.\": \"Houve um problema ao carregar o formulário.\",\n        \"You don't have access to this form.\": \"Não tem acesso a este formulário.\"\n    },\n    \"FormSuccessPage\": {\n        \"Thank you! Your response has been recorded.\": \"Obrigado! A sua resposta foi registada.\",\n        \"Form Submitted\": \"Formulário enviado\",\n        \"Submit new response\": \"Enviar nova resposta\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"Últimos 30 dias\",\n        \"This month\": \"Este mês\",\n        \"This week\": \"Esta semana\",\n        \"Last 7 days\": \"Últimos 7 dias\",\n        \"Last week\": \"Semana passada\",\n        \"Next 7 days\": \"Próximo 7 dias\",\n        \"This year\": \"Este ano\",\n        \"Today\": \"Hoje\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Erro\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Houve um erro ao enviar o seu formulário. Por favor, tente novamente.\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Limpar\",\n        \"Map fields\": \"Mapear campos\",\n        \"Mapped\": \"Mapeado\",\n        \"Select all\": \"Selecionar tudo\",\n        \"Unmapped\": \"Desmapeado\",\n        \"Unmap fields\": \"Desmapear campos\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Inserir secção acima\",\n        \"Insert section below\": \"Inserir secção abaixo\",\n        \"### **Header**\": \"### **Cabeçalho**\",\n        \"Description\": \"Descrição\",\n        \"## **Header**\": \"## **Cabeçalho**\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"chamada gratuita de treino\",\n        \"Schedule call\": \"Agendar chamada\",\n        \"Maybe later\": \"Talvez mais tarde\",\n        \"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.\": \"Na chamada, vamos ter tempo para perceber as suas necessidades e adaptar a chamada para si. Podemos mostrar-lhe os princípios básicos do Grist ou começar a trabalhar com os seus dados imediatamente para construir os painéis que precisa.\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Programe o seu {{freeCoachingCall}} com um membro da nossa equipa.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Também pode conferir {{ourWeeklyWebinars}} para saber mais sobre Grist.\",\n        \"our weekly webinars\": \"os nossos webinars semanais\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Campos ocultos\"\n    },\n    \"CreateTeamModal\": {\n        \"Choose a name and url for your team site\": \"Escolha um nome e uma url para o site da sua equipa\",\n        \"Team url\": \"URL da equipa\",\n        \"Billing is not supported in grist-core\": \"O faturamento não é compatível com o grist-core\",\n        \"Create site\": \"Criar site\",\n        \"Domain name is invalid\": \"Nome do domínio é inválido\",\n        \"Domain name is required\": \"Nome de domínio é necessário\",\n        \"Team name\": \"Nome da equipa\",\n        \"Team name is required\": \"O Nome da equipa é obrigatório\",\n        \"Cancel\": \"Cancelar\",\n        \"Team site created\": \"Site da equipa criado\",\n        \"Work as a Team\": \"Trabalhe em equipa\",\n        \"Go to your site\": \"Ir para o seu site\"\n    },\n    \"ChoiceListEditor\": {\n        \"No choices to select\": \"Não há opções para selecionar\",\n        \"No choices matching condition\": \"Nenhuma opção que corresponda à condição\",\n        \"Error in dropdown condition\": \"Erro na condição do menu suspenso\"\n    },\n    \"ReferenceUtils\": {\n        \"No choices to select\": \"Não há opções para selecionar\",\n        \"Error in dropdown condition\": \"Erro na condição do menu suspenso\",\n        \"No choices matching condition\": \"Nenhuma opção que corresponda à condição\"\n    },\n    \"TimingPage\": {\n        \"Column ID\": \"ID da Coluna\",\n        \"Number of Calls\": \"Número de chamadas\",\n        \"Total Time (s)\": \"Tempo(s) total(s)\",\n        \"Average Time (s)\": \"Tempo(s) médio(s)\",\n        \"Loading timing data. Don't close this tab.\": \"A carregar dados de tempo. Não feche esta guia.\",\n        \"Table ID\": \"ID da tabela\",\n        \"Max Time (s)\": \"Tempo(s) máximo(s)\",\n        \"Formula timer\": \"Temporizador de Fórmula\"\n    },\n    \"DocTutorial\": {\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Quer reiniciar o tutorial? Todo o progresso será perdido.\",\n        \"Previous\": \"Anterior\",\n        \"End tutorial\": \"Finalizar tutorial\",\n        \"Restart\": \"Reiniciar\",\n        \"Click to expand\": \"Clique para expandir\",\n        \"Finish\": \"Terminar\",\n        \"Next\": \"Próximo\"\n    },\n    \"OnboardingCards\": {\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Aprenda os conceitos básicos de Colunas de referência, widgets vinculados, tipos de colunas e cartões.\",\n        \"Complete our basics tutorial\": \"Conclua o nosso tutorial básico\",\n        \"Complete the tutorial\": \"Concluir o tutorial\",\n        \"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.\",\n        \"3 minute video tour\": \"Vídeo tour de 3 minutos\"\n    },\n    \"OnboardingPage\": {\n        \"Skip step\": \"Pular passo\",\n        \"Next step\": \"Próximo passo\",\n        \"Skip tutorial\": \"Pular o tutorial\",\n        \"What is your role?\": \"Qual é a sua função?\",\n        \"What organization are you with?\": \"Em que organização está?\",\n        \"Your organization\": \"A sua organização\",\n        \"Your role\": \"A sua função\",\n        \"Type here\": \"Digite aqui\",\n        \"Welcome\": \"Bem-vindo\",\n        \"Back\": \"Voltar\",\n        \"Tell us who you are\": \"Diga-nos quem é\",\n        \"What brings you to Grist (you can select multiple)?\": \"O que o traz ao Grist (pode selecionar várias opções)?\",\n        \"Discover Grist in 3 minutes\": \"Descubra Grist em 3 minutos\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Pratique com o tutorial Conceitos Básicos do Grist\",\n        \"Go to the tutorial!\": \"Vá para o tutorial!\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"O Grist pode parecer uma planilha, mas nem sempre age como uma. Descubra o que torna o Grist diferente.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"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}}). Não precisa de uma chave de ativação para executar o\\nGrist Core.\\n\\nSaiba mais na nossa [Central de Ajuda]({{helpCenter}}).\",\n        \"Enable Grist Enterprise\": \"Ativar a Grist Empresarial\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Uma chave de ativação é usada para executar o Grist Enterprise após o período de avaliação\\nde 30 dias tenha expirado. Obtenha uma chave de ativação [entrando em contato conosco]({{contactLink}}) hoje mesmo. Não\\nprecisa de uma chave de ativação para executar o Grist Core.\\n\\nSaiba mais na nossa [Central de Ajuda]({{helpCenter}}).\",\n        \"Activate\": \"Ativar\",\n        \"Activation key\": \"Chave de ativação\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Uma chave de ativação é usada para executar o Grist Enterprise após o período de avaliação\\n        de 30 dias tenha expirado. Obtenha uma chave de ativação [inscrevendo-se no Grist\\n        Enterprise]({{signupLink}}). Não precisa de uma chave de ativação para executar o\\n        Grist Core.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"É necessário ter uma assinatura ativa para continuar usando o Grist Enterprise. Pode\\nativar a sua assinatura [inscrevendo-se no Grist Enterprise ]({{signupLink}}) e colar a sua\\nchave de ativação abaixo.\",\n        \"Copy to clipboard\": \"Copiar para a área de transferência\",\n        \"Expiration date\": \"Data de expiração\",\n        \"Installation ID copied to clipboard\": \"ID de instalação copiado para a área de transferência\",\n        \"Installation ID:\": \"ID de instalação:\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Saiba mais na nossa [Central de Ajuda]({{helpCenter}}).\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"A sua instância estará no modo **somente-leitura** em **{{days}}** dia(s).\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"O seu período de avaliação expirou em **{{expireAt}}**. Para continuar a usar o Grist Enterprise, precisa\\n[registar-se no Grist Enterprise]({{signupLink}}) e colar a sua chave de ativação abaixo.\",\n        \"Grist Enterprise is **enabled**.\": \"O Grist Empresarial está **ativado**.\",\n        \"Installation seats\": \"Assentos de instalação\",\n        \"Your subscription expired on {{date}}.\": \"A sua assinatura expirou em {{date}}.\",\n        \"Disable Grist Enterprise\": \"Desativar o Grist Empresarial\",\n        \"Your activation key has expired due to exceeding limits.\": \"A sua chave de ativação expirou devido ao excesso de limites.\",\n        \"Paste your activation key\": \"Cole a sua chave de ativação\",\n        \"Plan name\": \"Nome do plano\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Para continuar usando o Grist Enterprise, precisa\\n                  [entrar em contato conosco]({{signupLink}}) para obter a sua chave de ativação.\",\n        \"You are currently trialing Grist Enterprise.\": \"No momento, está a testar o Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"Não tem uma assinatura ativa.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Apagar\",\n        \"Delete data and this widget.\": \"Apagar dados e este widget.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Mantenha os dados e apague o widget. A tabela permanecerá disponível em {{rawDataLink}}\",\n        \"Table {{tableName}} will no longer be visible\": \"A tabela {{tableName}} não estará mais visível\",\n        \"Raw Data page\": \"página de dados brutos\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Painel de administração\"\n    },\n    \"CustomWidgetGallery\": {\n        \"Add a widget from outside this gallery.\": \"Adicione um widget de fora dessa galeria.\",\n        \"Cancel\": \"Cancelar\",\n        \"Choose custom widget\": \"Escolha o widget personalizado\",\n        \"Custom URL\": \"URL personalizado\",\n        \"Grist Widget\": \"Widget Grist\",\n        \"Change widget\": \"Alterar widget\",\n        \"Community Widget\": \"Widget da comunidade\",\n        \"Developer:\": \"Programador:\",\n        \"Add Your Own Widget\": \"Adicione o seu próprio widget\",\n        \"(Missing info)\": \"(Informação ausente)\",\n        \"Add widget\": \"Adicionar Widget\",\n        \"Last updated:\": \"Última atualização:\",\n        \"No matching widgets\": \"Nenhum widget correspondente\",\n        \"Search\": \"Pesquisar\",\n        \"Learn more about custom widgets\": \"Saiba mais sobre Widgets personalizados\",\n        \"Widget URL\": \"URL do widget\"\n    },\n    \"markdown\": {\n        \"The toggle is **off**\": \"O interruptor está **desligado**\",\n        \"The toggle is **on**\": \"O interruptor está **ligado**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nova função Markdown\\n *\\n *      Podemos _escrever_ [o Markdown usual] (https://markdownguide.org) *dentro de*\\n *      um elemento Grainjs.\"\n            }\n        }\n    },\n    \"HomeIntroCards\": {\n        \"3 minute video tour\": \"Vídeo tour de 3 minutos\",\n        \"Finish our basics tutorial\": \"Termine o nosso tutorial básico\",\n        \"Learn more {{webinarsLinks}}\": \"Saiba mais {{webinarsLinks}}\",\n        \"Webinars\": \"Webinars\",\n        \"Tutorial\": \"Tutorial\",\n        \"Templates\": \"Modelos\",\n        \"Blank document\": \"Documento em branco\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"Encontre soluções e explore mais recursos {{helpCenterLink}}\",\n        \"Help center\": \"Centro de Ajuda\",\n        \"Import file\": \"Importar ficheiro\",\n        \"Start a new document\": \"Inicie um novo documento\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Column\": \"Coluna\",\n        \"Delete column {{column}} in table {{table}}?\": \"Apagar a Coluna {{column}} na tabela {{table}}?\",\n        \"Table\": \"Tabela\",\n        \"Delete\": \"Apagar\",\n        \"Add two-way reference\": \"Adicionar referência bidirecional\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"É o inverso da coluna de referência {{column}} na tabela {{table}}.\",\n        \"Delete two-way reference?\": \"Apagar referência bidirecional?\",\n        \"Target table\": \"Tabela de destino\",\n        \"Two-way Reference\": \"Referência bidirecional\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Isto apagará a coluna de referência {{refCol}} na tabela {{refTable}}. A coluna de referência {{myName}} permanecerá na tabela atual {{myTable}}.\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"Painel de administração\",\n        \"Close\": \"Fechar\",\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Opt in to Telemetry\": \"Aceitar a Telemetria\",\n        \"Opted In\": \"Optou por participar\",\n        \"Support Grist\": \"Apoiar o Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Obrigado! A sua confiança e o seu apoio são muito apreciados. Cancele a qualquer momento no {{link}} no menu do utilizador.\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Aceite a telemetria para ajudar-nos a perceber como o produto é usado, para que possamos priorizar melhorias futuras.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Coletamos apenas estatísticas de uso, conforme detalhado no nosso {{helpCenterLink}}, nunca o conteúdo dos documentos. Desative a qualquer momento em {{supportGristLink}} no menu do utilizador.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Cancelar\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Cada registo {{targetTable}} só pode ser atribuído a um único registo {{sourceTable}}.\",\n        \"Reassign to new {{sourceTable}} records.\": \"Reatribuir a novos registos {{sourceTable}}.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Reatribuir para o registo {{sourceTable}} {{sourceName}} .\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} O registo {{targetName}} já está atribuído ao registo {{sourceTable}} {{oldSourceName}} .\",\n        \"Record already assigned_other\": \"Registo já atribuído\",\n        \"Record already assigned_one\": \"Registo já atribuído\",\n        \"Reassign\": \"Reatribuir\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Add destination\": \"Adicionar destino\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Tem certeza que deseja apagar este destino de streaming? Esta ação não pode ser desfeita.\",\n        \"Destination\": \"Destino\",\n        \"Destinations\": \"Destinos\",\n        \"Splunk\": \"Splunk\",\n        \"Start streaming\": \"Iniciar o streaming\",\n        \"URL\": \"URL\",\n        \"Enter URL\": \"Inserir URL\",\n        \"Enter token\": \"Inserir token\",\n        \"Add streaming destination\": \"Adicionar destino de streaming\",\n        \"Cancel\": \"Cancelar\",\n        \"Learn more\": \"Saiba mais\",\n        \"Delete\": \"Eliminar\",\n        \"Delete streaming destination?\": \"Apagar destino de streaming?\",\n        \"Other\": \"Outros\",\n        \"Token\": \"Token\",\n        \"Edit\": \"Editar\",\n        \"Edit streaming destination\": \"Editar destino de streaming\",\n        \"Save\": \"Gravar\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Configure a transmissão de eventos de auditoria do Grist para um sistema externo de gestão de eventos e informações de segurança (SIEM), como o Splunk. {{learnMoreLink}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit Logs\": \"Registos de auditoria\",\n        \"Audit logs for {{siteName}}\": \"Registos de auditoria para {{siteName}}\",\n        \"Contact us\": \"Entre em contato conosco\",\n        \"Home\": \"Início\",\n        \"Only site owners may access audit logs.\": \"Somente os proprietários do site podem aceder os registos de auditoria.\",\n        \"Log streaming\": \"Fluxo de registos\",\n        \"upgrade your plan\": \"atualize o seu plano\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Pode configurar a transmissão de eventos de auditoria do Grist para um sistema externo de gestão de eventos e informações de segurança (SIEM) se ativar o Grist Enterprise. {{contactUsLink}} para saber mais.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Pode configurar a transmissão de eventos de auditoria do Grist para um sistema SIEM (gestão de eventos e informações de segurança) externo se {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"Delete\": \"Eliminar\",\n        \"Delete {{name}}\": \"Apagar {{name}}\",\n        \"Document will be moved to Trash.\": \"O documento será movido para o Lixo.\",\n        \"Manage users\": \"Gerir Usuarios\",\n        \"Pinned\": \"Fixado\",\n        \"Recent\": \"Recente\",\n        \"Current workspace\": \"Área de trabalho atual\",\n        \"Rename and set icon\": \"Renomear e definir o ícone\",\n        \"Access details\": \"Pormenores de Acesso\",\n        \"Move\": \"Mover\",\n        \"Sort by date\": \"Ordenar por data\",\n        \"Requires edit permissions\": \"Requer permissões de edição\",\n        \"Sort by name\": \"Ordenar por Nome\",\n        \"Unpin\": \"Liberar\",\n        \"All\": \"Todo\",\n        \"Edited {{at}}\": \"{{at}} editado\",\n        \"Last edited\": \"Última edição\",\n        \"Move {{name}} to workspace\": \"Mover {{name}} para a área de trabalho\",\n        \"Name\": \"Nome\",\n        \"No documents to show.\": \"Não há documentos para mostrar.\",\n        \"Workspace\": \"Área de Trabalho\",\n        \"Pin\": \"Fixar\"\n    },\n    \"RightPanelUtils\": {\n        \"fields_one\": \"Campos\",\n        \"fields_other\": \"Campos\",\n        \"series_one\": \"Séries\",\n        \"series_other\": \"Séries\",\n        \"columns_one\": \"Colunas\",\n        \"columns_other\": \"Colunas\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Are you sure you **trust the resource** at this URL?\": \"Tem certeza de que **confia no recurso** desse URL?\",\n        \"Have you **reviewed the code** at this URL?\": \"Você **revisou o código** neste URL?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Em caso de dúvida, não instale este widget ou peça a um administrador da sua organização para revisá-lo quanto à segurança.\",\n        \"Do you **trust the person** who shared this link?\": \"**Confia na pessoa** que partilhou esta ligação?\",\n        \"Please review the following before adding a new custom widget.\": \"Por favor, reveja o seguinte antes de adicionar um novo widget personalizado.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Confirmo que percebi estes avisos e aceito os riscos\",\n        \"Be careful with unknown custom widgets\": \"Tenha cuidado com widgets personalizados desconhecidos\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Os widgets personalizados são **poderosos**! Eles podem ler e gravar os dados do seu documento e enviá-los para outro lugar.\"\n    },\n    \"AdminLeftPanel\": {\n        \"Workspaces\": \"Áreas de Trabalho\",\n        \"Admin area\": \"Área de administração\",\n        \"Installation\": \"Instalação\",\n        \"Learn more\": \"Aprender mais\",\n        \"Orgs\": \"Organizações\",\n        \"Users\": \"Utilizadores\",\n        \"Admin controls\": \"Controlos administrativos\",\n        \"Docs\": \"Docs\",\n        \"Admin Controls\": \"Controles administrativos\",\n        \"Settings\": \"Configurações\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"Condição de menu suspenso\",\n        \"Set dropdown condition\": \"Definir condição do menu suspenso\",\n        \"Invalid columns: {{colIds}}\": \"Colunas inválidas: {{colIds}}\"\n    },\n    \"widgetTypesMap\": {\n        \"Table\": \"Tabela\",\n        \"Custom\": \"Personalizado\",\n        \"Form\": \"Formulário\",\n        \"Card\": \"Cartão\",\n        \"Calendar\": \"Calendário\",\n        \"Card List\": \"Lista de cartões\",\n        \"Chart\": \"Gráfico\"\n    },\n    \"FormRenderer\": {\n        \"Submit\": \"Enviar\",\n        \"Reset\": \"Redefinir\",\n        \"Search\": \"Pesquisar\",\n        \"Select...\": \"Selecionar...\",\n        \"Submitting…\": \"A submeter…\"\n    },\n    \"ChoiceEditor\": {\n        \"No choices to select\": \"Não há opções para selecionar\",\n        \"Error in dropdown condition\": \"Erro na condição do menu suspenso\",\n        \"No choices matching condition\": \"Nenhuma opção que corresponda à condição\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Remover Coluna\"\n    },\n    \"RenameDocModal\": {\n        \"Name\": \"Nome\",\n        \"Rename and set icon\": \"Renomear e definir o ícone\",\n        \"Choose color\": \"Escolha a cor\",\n        \"Choose icon\": \"Escolha o ícone\",\n        \"Enter document name\": \"Digite o nome do documento\",\n        \"Icon\": \"Ícone\",\n        \"Reset icon\": \"Repor ícone\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Nenhuma opção configurada\",\n        \"No values in show column of referenced table\": \"Nenhum valor na coluna de exibição da tabela referenciada\",\n        \"Hide\": \"Ocultar\"\n    },\n    \"Toggle\": {\n        \"Field Format\": \"Formato do campo\",\n        \"Checkbox\": \"Caixa de seleção\",\n        \"Switch\": \"Interruptor\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Digite a condição.\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nova função Markdown\\n *\\n * Podemos _escrever_ [o Markdown usual] (https://markdownguide.org) *dentro*\\n * um elemento Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"O interruptor está **desligado**\",\n        \"The toggle is **on**\": \"O interruptor está **ligado**\"\n    },\n    \"Assistant\": {\n        \"AI Assistant is only available for logged in users.\": \"O Assistente de IA só está disponível para utilizadores conectados.\",\n        \"Apply\": \"Aplicar\",\n        \"For higher limits, contact the site owner.\": \"Para limites maiores, entre em contato com o proprietário do site.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Para limites maiores, {{upgradeNudge}}.\",\n        \"Learn more.\": \"Saiba mais.\",\n        \"Press Enter to apply suggested formula.\": \"Pressione Enter para aplicar a fórmula sugerida.\",\n        \"Sign Up for Free\": \"Cadastre-se gratuitamente\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Registe-se gratuitamente no Grist para começar a usar o Assistente IA.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Atualize para a Grist Enterprise para experimentar o novo assistente de Grist. {{learnMoreLink}}\",\n        \"What do you need help with?\": \"Para quê precisa de ajuda?\",\n        \"You have used all available credits.\": \"Utilizou todos os créditos disponíveis.\",\n        \"You have {{numCredits}} remaining credits.\": \"Tem {{numCredits}} créditos restantes.\",\n        \"start a new chat\": \"iniciar um novo chat\",\n        \"upgrade to the Pro Team plan\": \"atualize para o plano Pro Team\",\n        \"upgrade your plan\": \"Atualize o seu plano\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"A conversa ficou muito longa e não consigo mais responder com eficiência. Aceda {{startANewChatButton}} para continuar recebendo assistência.\"\n    },\n    \"apiconsole\": {\n        \"Are you sure you want to delete the following?\": \"Tem certeza de que deseja apagar o seguinte?\",\n        \"Confirm Deletion\": \"Confirmar apagar\",\n        \"Delete\": \"Apagar\",\n        \"Deletion was not confirmed, skipping.\": \"O apagar não foi confirmado, a pular.\",\n        \"Type DELETE here if you wish to proceed.\": \"Digite DELETE aqui se deseja prosseguir.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Digite DELETE se tem certeza que realmente deseja apagar.\\nSe não tem certeza, ou não percebe o que esta operação vai fazer,\\nseria sensato cancelar.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"sem acesso\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Há uma atualização crítica do Grist disponível.\\nConsidere fazer o upgrade para a versão {{version}} assim que possível.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"A sua versão Grist está desatualizada.\\nConsidere a atualização para a versão {{version}} o mais rápido possível.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Recomendação: {{storageRecommendation}}\\nAo armazenar anexos grandes, ou muitos deles, recomendamos\\nmantê-los num armazenamento externo. Atualmente, este documento está\\nusando o armazenamento interno para anexos, o que o mantém\\nautônomo, mas pode limitar o desempenho.\",\n        \"Set the document to use external storage.\": \"Defina o documento para usar o armazenamento externo.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Por favor, espere que a operação anterior seja concluída.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Tempo limite de espera para que o back-end do Grist seja reiniciado\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Desativar recurso\",\n        \"Don't worry, you can disable it later if needed.\": \"Não se preocupe, pode desabilitá-lo mais tarde, se necessário.\",\n        \"Enable feature\": \"Ativar recurso\",\n        \"Experimental feature\": \"Recurso experimental\",\n        \"New record button\": \"Botão Novo registo\",\n        \"Reload the page\": \"Recarregar a página\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Visite este URL a qualquer momento para parar de usar este recurso: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Está prestes a desativar este recurso experimental: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Está prestes a ativar este recurso experimental: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} desativado.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} ativado.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Novo cartão\",\n        \"New record\": \"Novo registo\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Está a tentar aceder o painel do criador? Use {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Duplicar widget\",\n        \"Duplicate widgets\": \"Widgets duplicados\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Adicionar\",\n        \"Delete\": \"Apagar\",\n        \"Download\": \"Descarregar\",\n        \"Uploading…\": \"A enviar…\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"A enviar, por favor aguarde…\"\n    }\n}\n"
  },
  {
    "path": "static/locales/pt.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"Loading...\": \"A carregar...\",\n        \"og-description\": \"Uma folha de cálculo moderna e de código aberto que ultrapassa os limites\",\n        \"og-title\": \"Grist, a evolução das folhas de cálculo\"\n    },\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Verifique o seu e-mail com o provedor de identidade e faça login novamente.\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"Não tem acesso a este documento.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/pt_BR.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Enter email address\": \"Digite o endereço de e-mail\",\n        \"Invite new member\": \"Convidar novo membro\",\n        \"We'll email an invite to {{email}}\": \"Enviaremos um convite por e-mail para {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Add column rule\": \"Adicionar Regra de Coluna\",\n        \"Add Default Rule\": \"Adicionar Regra Padrão\",\n        \"Add table rules\": \"Adicionar Regras de Tabela\",\n        \"Add user attributes\": \"Adicionar Atibutos de Usuário\",\n        \"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.\": \"Permitir que todos possam copiar, ver e mexer no documento todo.\\nÚtil para exemplos e modelos, mas não para dados sensíveis.\",\n        \"Allow everyone to view Access Rules.\": \"Permitir que todos visualizem as Regras de Acesso.\",\n        \"Attribute name\": \"Nome do atributo\",\n        \"Attribute to Look Up\": \"Atributo para Procurar\",\n        \"Checking...\": \"Verificando…\",\n        \"Condition\": \"Condição\",\n        \"Default rules\": \"Regras Padrão\",\n        \"Delete table rules\": \"Excluir Regras de Tabela\",\n        \"Enter Condition\": \"Insira a condição\",\n        \"Everyone\": \"Todos\",\n        \"Everyone Else\": \"Todos os outros\",\n        \"Invalid\": \"Inválido\",\n        \"Lookup Column\": \"Coluna de pesquisa\",\n        \"Lookup Table\": \"Tabela de Pesquisa\",\n        \"Permission to access the document in full when needed\": \"Permissão para acessar o documento completo quando necessário\",\n        \"Permission to view Access Rules\": \"Permissão para visualizar as Regras de Acesso\",\n        \"Permissions\": \"Permissões\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Remover a coluna {{- colId }} das regras de {{- tableId }}\",\n        \"Remove {{- name }} user attribute\": \"Remover o atributo do usuário {{- name }}\",\n        \"Remove {{- tableId }} rules\": \"Remover regras de {{- tableId }}\",\n        \"Reset\": \"Redefinir\",\n        \"Rules for table \": \"Regras para a tabela \",\n        \"Save\": \"Salvar\",\n        \"Saved\": \"Salvo\",\n        \"Special rules\": \"Regras Especiais\",\n        \"Type message to display when this rule blocks an action…\": \"Escreva uma mensagem…\",\n        \"User Attributes\": \"Atributos de Usuário\",\n        \"Users\": \"Usuários\",\n        \"View as\": \"Ver como\",\n        \"Seed rules\": \"Regras de propagação\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total.\",\n        \"Permission to edit document structure\": \"Permissão para editar a estrutura do documento\",\n        \"This default should be changed if editors' access is to be limited. \": \"Esse padrão deve ser alterado se o acesso dos editores for limitado. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura.\",\n        \"Add table-wide rule\": \"Adicionar regra para toda a tabela\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"As regras de acesso foram alteradas. Clique em Reiniciar para reverter suas alterações e atualizar as regras.\",\n        \"All\": \"Todo\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"A Coluna {{colId}} aparece em várias regras da tabela {{tableId}} que podem ser dependentes da ordem. Tentar dividir as regras de forma diferente?\",\n        \"Columns\": \"Colunas\",\n        \"Condition cannot be blank\": \"A condição não pode ser em branco\",\n        \"Default resource missing in resource map\": \"Recurso padrão faltando no mapa de recursos\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Colunas inválidas na tabela {{tableId}}: {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"Tabela inválida: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Regra de atributo de usuário inválida: {{prop}} deve ser definido\",\n        \"Invalid user attribute to look up\": \"Atributo de usuário inválido a ser pesquisado\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"Nenhuma coluna listada em uma regra de coluna para a tabela {{tableId}}\",\n        \"Not a valid user attribute\": \"Não é um atributo de usuário válido\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Recurso ausente no mapa de recursos: {{resourceKey}}\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"Tentando adicionar TableRules a uma tabela existente {{tableId}}\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"Use um atributo simples de user.LinkKey, por exemplo, user.LinkKey.algo\",\n        \"hidden\": \"oculto\",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Regras de Acesso\\n\\nO acesso básico a este documento é controlado pela opção \\\"Gerenciar Usuários\\\" no menu \\\"Compartilhar\\\", onde você pode atribuir funções de colaborador, como Proprietário, Editor ou Visualizador.\\n\\nPara um controle mais granular, você pode criar Regras de Acesso para limitar quem pode visualizar ou editar tabelas, colunas ou linhas específicas — útil para dados confidenciais ou permissões baseadas em funções.\\n\\n[Saiba mais.]({{helpAccessRules}})\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Regras de Acesso\\n\\nVocê não tem permissão para visualizar ou editar as regras de acesso para este documento .\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Regras especiais** (expanda cada regra para personalizar a quem ela se aplica)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"Após desativar as Regras de Acesso, os Editores poderão alterar a estrutura do documento e editar fórmulas. Editores e Visualizadores poderão ver todos os dados do documento , bem como copiá-los ou baixá-los.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"Após ativar as Regras de Acesso, os Editores não poderão mais alterar a estrutura do\\nDocumentar ou editar fórmulas. Somente os proprietários poderão copiar ou baixar o documento .\\n\\nEssas configurações podem ser alteradas em 'Regras especiais'.\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"Permitir que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas e layouts) e escrevam fórmulas.   Importante: se selecionada, esta opção permitirá que os editores editem fórmulas, que podem acessar todos os dados, independentemente das regras de acesso à tabela e à coluna !\",\n        \"Allow everyone to view access rules.\": \"Permitir que todos visualizem as regras de acesso.\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Contorne todas as restrições de leitura e permita que todos copiem o documento inteiro ou o visualizem na íntegra no modo de edição. Use apenas para exemplos e modelos, não para documentos com dados confidenciais.\",\n        \"Continue\": \"Continuar\",\n        \"Disable Access Rules\": \"Regras de desativação de acesso\",\n        \"Disable and save\": \"Desativar e salvar\",\n        \"Enable Access Rules\": \"Ativar regras de acesso\",\n        \"Permission to access the document in full by all users\": \"Permissão para acesso integral ao documento por todos os usuários\",\n        \"Permission to access the document in full by unrestricted users\": \"Permissão para acesso integral ao documento por usuários sem restrições\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Impeça usuários que não sejam proprietários de copiar ou baixar o documento completo . Observação: isso afeta apenas usuários sem restrições de leitura, pois os demais serão restringidos independentemente dessa configuração.\",\n        \"Special rules for templates\": \"Regras especiais para modelos\",\n        \"This options should be off if Editors' access is to be limited. \": \"Esta opção deve estar desativada se o acesso dos editores for limitado. \"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"Chave API\",\n        \"Account settings\": \"Configurações de conta\",\n        \"Allow signing in to this account with Google\": \"Permitir o acesso a esta conta com o Google\",\n        \"Change password\": \"Alterar Senha\",\n        \"Edit\": \"Editar\",\n        \"Email\": \"E-mail\",\n        \"Login method\": \"Método de Login\",\n        \"Name\": \"Nome\",\n        \"Names only allow letters, numbers and certain special characters\": \"Nomes só permitem letras, números e certos caracteres especiais\",\n        \"Password & security\": \"Senha e Segurança\",\n        \"Save\": \"Salvar\",\n        \"Theme\": \"Tema\",\n        \"Two-factor authentication\": \"Autenticação de dois fatores\",\n        \"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.\": \"A autenticação de dois fatores é uma camada extra de segurança para sua conta Grist projetada para garantir que você seja a única pessoa que pode acessar sua conta, mesmo que alguém saiba sua senha.\",\n        \"Language\": \"Idioma\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Detalhes de Acesso\",\n        \"Accounts\": \"Contas\",\n        \"Add account\": \"Adicionar Conta\",\n        \"Document settings\": \"Configurações do documento\",\n        \"Manage team\": \"Gerenciar Equipe\",\n        \"Pricing\": \"Preços\",\n        \"Profile settings\": \"Configurações de Perfil\",\n        \"Sign out\": \"Sair\",\n        \"Sign in\": \"Ingressar\",\n        \"Switch Accounts\": \"Alternar Contas\",\n        \"Toggle Mobile Mode\": \"Alternar Modo Móvel\",\n        \"Activation\": \"Ativação\",\n        \"Billing account\": \"Conta de faturamento\",\n        \"Support Grist\": \"Suporte Grist\",\n        \"Upgrade Plan\": \"Atualizar o Plano\",\n        \"Sign up\": \"Cadastre-se\",\n        \"Use This Template\": \"Use este modelo\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"Falha ao carregar o Log de Ações\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"A Coluna {{colId}} foi posteriormente removida em ação #{{action.actionNum}}\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"A Tabela {{tableId}} foi posteriormente removida em ação #{{actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Essa linha foi posteriormente removida em ação {{action.actionNum}}\",\n        \"All tables\": \"Todas as tabelas\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"A Coluna {{colId}} foi posteriormente removida em ação #{{actionNum}}\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Essa linha foi posteriormente removida em ação {{actionNum}}\",\n        \"History blocked because of access rules.\": \"Histórico bloqueado devido a regras de acesso.\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Adicionar Novo\"\n    },\n    \"ApiKey\": {\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Ao gerar uma chave API, você será capaz de fazer chamadas API para sua própria conta.\",\n        \"Click to show\": \"Clique para mostrar\",\n        \"Create\": \"Criar\",\n        \"Remove\": \"Remover\",\n        \"Remove API Key\": \"Remover a Chave API\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Esta chave API pode ser usada para acessar esta conta anonimamente através da API.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Esta chave API pode ser usada para acessar sua conta através da API. Não compartilhe sua chave API com ninguém.\",\n        \"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?\": \"Você está prestes a excluir uma chave API. Isto fará com que todas as solicitações futuras usando esta chave API sejam rejeitadas. Você realmente quer excluir?\"\n    },\n    \"App\": {\n        \"Description\": \"Descrição\",\n        \"Key\": \"Chave\",\n        \"Memory Error\": \"Erro de Memória\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Tradutores: por favor, traduzam isso apenas quando seu idioma estiver pronto para ser oferecido aos usuários\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Página inicial\",\n        \"Legacy\": \"Legado\",\n        \"Personal Site\": \"Site pessoal\",\n        \"Team Site\": \"Site da Equipe\",\n        \"Grist Templates\": \"Modelos de Grist\",\n        \"Billing account\": \"Conta de faturamento\",\n        \"Manage team\": \"Gerenciar Equipe\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Voltar para a página inicial\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Este site da equipe está suspenso. Os documentos podem ser lidos, mas não modificados.\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"Limpar célula\",\n        \"Clear values\": \"Limpar valores\",\n        \"Copy anchor link\": \"Copiar o link de ancoragem\",\n        \"Delete column\": \"Eliminar coluna\",\n        \"Delete row\": \"Excluir linha\",\n        \"Delete {{count}} columns\": \"Eliminiar {{count}} colunas\",\n        \"Delete {{count}} rows\": \"Excluir {{count}} linhas\",\n        \"Duplicate row\": \"Duplicar linha\",\n        \"Duplicate rows\": \"Duplicar linhas\",\n        \"Filter by this value\": \"Filtre por este valor\",\n        \"Insert column to the left\": \"Inserir coluna à esquerda\",\n        \"Insert column to the right\": \"Inserir coluna à direita\",\n        \"Insert row\": \"Inserir linha\",\n        \"Insert row above\": \"Inserir linha acima\",\n        \"Insert row below\": \"Inserir linha abaixo\",\n        \"Reset column\": \"Reinicializar coluna\",\n        \"Reset entire column\": \"Redefinir coluna inteira\",\n        \"Reset {{count}} columns\": \"Reinicializar {{count}} colunas\",\n        \"Reset {{count}} entire columns\": \"Reinicializar {{count}} colunas inteiras\",\n        \"Delete {{count}} columns_other\": \"Excluir {{count}} colunas\",\n        \"Delete {{count}} rows_one\": \"Excluir linha\",\n        \"Delete {{count}} rows_other\": \"Excluir {{count}} linhas\",\n        \"Duplicate rows_one\": \"Duplicar linha\",\n        \"Duplicate rows_other\": \"Duplicar linhas\",\n        \"Reset {{count}} columns_one\": \"Reinicializar coluna\",\n        \"Reset {{count}} columns_other\": \"Reinicializar {{count}} colunas\",\n        \"Reset {{count}} entire columns_one\": \"Reinicializar toda a coluna\",\n        \"Reset {{count}} entire columns_other\": \"Reinicializar {{count}} colunas inteiras\",\n        \"Delete {{count}} columns_one\": \"Excluir coluna\",\n        \"Comment\": \"Comentário\",\n        \"Copy\": \"Copiar\",\n        \"Cut\": \"Cortar\",\n        \"Paste\": \"Colar\",\n        \"Copy with headers\": \"Copiar com cabeçalhos\"\n    },\n    \"ChartView\": {\n        \"Create separate series for each value of the selected column.\": \"Crie séries separadas para cada valor da coluna selecionada.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Cada série Y é seguida por uma série para o comprimento das barras de erro.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Cada série Y é seguida por duas séries, para as barras de erro superior e inferior.\",\n        \"Pick a column\": \"Escolha uma coluna\",\n        \"Toggle chart aggregation\": \"Alternar a agregação de gráficos\",\n        \"selected new group data columns\": \"novas colunas de dados de grupo selecionadas\",\n        \"LABEL\": \"ETIQUETA\",\n        \"Bar chart\": \"Gráfico de barras\",\n        \"Pie chart\": \"Gráfico de torta\",\n        \"Donut chart\": \"Gráfico de Donut\",\n        \"Area chart\": \"Gráfico de área\",\n        \"Line chart\": \"Gráfico de linha\",\n        \"Scatter plot\": \"Gráfico de dispersão\",\n        \"Kaplan-Meier plot\": \"Gráfico Kaplan-Meier\",\n        \"Split series\": \"Série de divisão\",\n        \"Orientation\": \"Orientação\",\n        \"Vertical\": \"Vertical\",\n        \"Log scale Y-axis\": \"Escala logarítmica no eixo Y\",\n        \"Hole size\": \"Tamanho do furo\",\n        \"Text size\": \"Tamanho do texto\",\n        \"Connect gaps\": \"Conecte lacunas\",\n        \"Show markers\": \"Mostrar marcadores\",\n        \"Stack series\": \"Série de pilha\",\n        \"Error bars\": \"Barras de erro\",\n        \"None\": \"Nenhum\",\n        \"Symmetric\": \"Simétrico\",\n        \"Above+Below\": \"Acima+Abaixo\",\n        \"X-AXIS\": \"EIXO-X\",\n        \"SERIES\": \"SÉRIES\",\n        \"Add series\": \"Adicionar série\",\n        \"non-numeric columns are not shown\": \"colunas não numéricas não são mostradas\",\n        \"non-numeric column is not shown\": \"coluna não numérica não é mostrada\",\n        \"Remove\": \"Remover\",\n        \"Aggregate values\": \"Valores agregados\",\n        \"Show total\": \"Mostrar Total\",\n        \"Split Series\": \"Série de divisão\",\n        \"Invert Y-axis\": \"Inverter eixo Y\",\n        \"Horizontal\": \"Horizontal\",\n        \"selected new x-axis\": \"novo eixo x selecionado\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Acesso negado\",\n        \"Code View is available only when you have full document access.\": \"A Vista de Código só está disponível quando você tem acesso total aos documentos.\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Default cell style\": \"Estilo de célula padrão\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"Todos\",\n        \"All except\": \"Todos, Exceto\",\n        \"All shown\": \"Todos Mostrados\",\n        \"End\": \"Fim\",\n        \"Future values\": \"Valores Futuros\",\n        \"Max\": \"Máx\",\n        \"Min\": \"Mín\",\n        \"No matching values\": \"Nenhum valor coincidente\",\n        \"None\": \"Nenhum\",\n        \"Other Matching\": \"Outros Coincidentes\",\n        \"Other Non-Matching\": \"Outros Não-Coincidentes\",\n        \"Other values\": \"Outros Valores\",\n        \"Others\": \"Outros\",\n        \"Search\": \"Pesquisar\",\n        \"Search values\": \"Pesquisar valores\",\n        \"Start\": \"Começo\",\n        \"Filter by Range\": \"Filtrar por intervalo\",\n        \"Clear search\": \"Limpar pesquisa\",\n        \"Pin filter\": \"Fixar filtro\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Classificar alfabeticamente (atual: classificado por número de ocorrências)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Classificar por número de ocorrências (atual: classificado em ordem alfabética)\",\n        \"Unpin filter\": \"Soltar o filtro\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (opcional)\",\n        \"Add\": \"Adicionar\",\n        \"Enter Custom URL\": \"Digite a URL personalizada\",\n        \"Full document access\": \"Acesso total ao documento\",\n        \"Learn more about custom widgets\": \"Saiba mais sobre Widgets personalizados\",\n        \"No document access\": \"Sem acesso ao documento\",\n        \"Open configuration\": \"Abrir configuração\",\n        \"Pick a column\": \"Escolha uma coluna\",\n        \"Pick a {{columnType}} column\": \"Escolha uma coluna {{columnType}}\",\n        \"Read selected table\": \"Ler a tabela selecionada\",\n        \"Select Custom Widget\": \"Selecione o Widget personalizado\",\n        \"Widget does not require any permissions.\": \"O Widget não requer nenhuma permissão.\",\n        \"Widget needs to {{read}} the current table.\": \"O Widget necessita {{read}} a tabela atual.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"O Widget necessita {{fullAccess}} ao documento.\",\n        \"{{wrongTypeCount}} non-{{columnType}} column is not shown\": \"{{wrongTypeCount}} a não-{{columnType}} coluna não é mostrada\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown\": \"{{wrongTypeCount}} as não-{{columnType}} colunas não são mostradas\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} a não-{{columnType}} coluna não é mostrada\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} as não-{{columnType}} colunas não são mostradas\",\n        \"No {{columnType}} columns in table.\": \"Não há colunas {{columnType}} na tabela.\",\n        \"Clear selection\": \"Limpar seleção\",\n        \"Developer:\": \"Desenvolvedor:\",\n        \"Last updated:\": \"Última atualização:\",\n        \"Missing description and author information.\": \"Faltam a descrição e as informações do autor.\",\n        \"ACCESS LEVEL\": \"NÍVEL DE ACESSO\",\n        \"Custom URL\": \"URL personalizado\",\n        \"Accept\": \"Aceitar\",\n        \"Reject\": \"Rejeitar\",\n        \"Widget\": \"Widget\",\n        \"Change custom widget\": \"Alterar widget personalizado\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Clique para copiar\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Excluir os dados da {{formattedTableName}}, e removê-la de todas as páginas?\",\n        \"Duplicate table\": \"Duplicar a Tabela\",\n        \"Raw Data Tables\": \"Tabelas de Dados Primários\",\n        \"Table ID copied to clipboard\": \"ID da Tabela copiada para a área de transferência\",\n        \"You do not have edit access to this document\": \"Você não tem permissão de edição desse documento\",\n        \"Edit record card\": \"Editar cartão de registro\",\n        \"Rename table\": \"Renomear tabela\",\n        \"{{action}} Record Card\": \"{{action}} Cartão de registro\",\n        \"Record Card\": \"Cartão de registro\",\n        \"Remove table\": \"Remover tabela\",\n        \"Record Card Disabled\": \"Cartão de registro desabilitado\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Atividade\",\n        \"Beta\": \"Beta\",\n        \"Compare to current\": \"Comparar ao atual\",\n        \"Compare to previous\": \"Comparar ao anterior\",\n        \"Open snapshot\": \"Abrir Instantâneo\",\n        \"Snapshots\": \"Instantâneos\",\n        \"Snapshots are unavailable.\": \"Os instantâneos não estão disponíveis.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Apenas os proprietários têm acesso a instantâneos para documentos com regras de acesso.\"\n    },\n    \"DocMenu\": {\n        \"(The organization needs a paid plan)\": \"(A organização precisa de um plano pago)\",\n        \"Access Details\": \"Detalhes de Acesso\",\n        \"All documents\": \"Todos os Documentos\",\n        \"By Date Modified\": \"Por Data de Modificação\",\n        \"By Name\": \"Por Nome\",\n        \"Current workspace\": \"Área de trabalho atual\",\n        \"Delete\": \"Excluir\",\n        \"Delete Forever\": \"Excluir para sempre\",\n        \"Delete {{name}}\": \"Excluir {{name}}\",\n        \"Deleted {{at}}\": \"{{at}} excluído\",\n        \"Discover More Templates\": \"Descubra mais Modelos\",\n        \"Document will be moved to Trash.\": \"O documento será movido pra Lixeira.\",\n        \"Document will be permanently deleted.\": \"O documento será permanentemente excluído.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Os documentos ficam na Lixeira por 30 dias, após os quais são excluídos permanentemente.\",\n        \"Edited {{at}}\": \"{{at}} editado\",\n        \"Examples & Templates\": \"Exemplos & Modelos\",\n        \"Examples and Templates\": \"Exemplos e Modelos\",\n        \"Featured\": \"Destaques\",\n        \"Manage users\": \"Gerenciar Usuários\",\n        \"More Examples and Templates\": \"Mais Exemplos e Modelos\",\n        \"Move\": \"Mover\",\n        \"Move {{name}} to workspace\": \"Mover {{name}} para a área de trabalho\",\n        \"Other Sites\": \"Outros Sites\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Excluir \\\"{{name}}\\\" permanentemente?\",\n        \"Pin Document\": \"Fixar documento\",\n        \"Pinned Documents\": \"Documentos Fixados\",\n        \"Remove\": \"Remover\",\n        \"Rename\": \"Renomear\",\n        \"Requires edit permissions\": \"Requer permissões de edição\",\n        \"Restore\": \"Restaurar\",\n        \"This service is not available right now\": \"Este serviço não está disponível no momento\",\n        \"To restore this document, restore the workspace first.\": \"Para restaurar esse documento, restaure a área de trabalho primeiro.\",\n        \"Trash\": \"Lixeira\",\n        \"Trash is empty.\": \"A lixeira está vazia.\",\n        \"Unpin Document\": \"Desafixar o Documento\",\n        \"Workspace not found\": \"Área de trabalho não encontrada\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Você está no site {{siteName}}. Você também tem acesso aos seguintes sites:\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Você está na sua página pessoal. Você também tem acesso às seguintes páginas:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Você pode excluir uma área de trabalho para sempre uma vez que ela não contenha documentos.\",\n        \"Any documents created in this site will appear here.\": \"Todos os documentos criados neste site aparecerão aqui.\",\n        \"Create my first document\": \"Criar meu primeiro documento\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Você tem acesso somente de leitura a este site. No momento, não há documentos.\",\n        \"personal site\": \"site pessoal\",\n        \"Grid view\": \"Visualização de grade\",\n        \"List view\": \"Visualização da lista\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Adicionar Tabela Vazia\",\n        \"Add page\": \"Adicionar Página\",\n        \"Add widget to page\": \"Adicionar Widget à Página\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Proprietários do documento podem tentar recuperar o documento. [{{error}}]\",\n        \"Enter recovery mode\": \"Entrar em modo de recuperação\",\n        \"Error accessing document\": \"Erro ao acessar o documento\",\n        \"Reload\": \"Recarregar\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Desculpe, o acesso a esse documento foi negado. [{{error}}]\",\n        \"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}}]\": \"Você pode tentar recarregar o documento ou usar o modo de recuperação. O modo de recuperação abre o documento para ser totalmente acessível aos proprietários e inacessível a outras pessoas. Ele também desativa as fórmulas. [{{error}}]\",\n        \"You do not have edit access to this document\": \"Você não tem permissão de edição desse documento\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Por favor, recarregue o documento e se o erro persistir, entre em contato com o proprietário do documento para tentar uma recuperação de documentos. [{{error}}]\"\n    },\n    \"DocTour\": {\n        \"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.\": \"Não é possível construir um Tour a partir dos dados contidos neste documento. Certifique-se de que haja uma tabela chamada GristDocTour com colunas Title, Body, Placement, e Location.\",\n        \"No valid document tour\": \"Tour de documento inválido\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Moeda:\",\n        \"Document settings\": \"Configurações do documento\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Motor (experimental {{span}} mudança por conta e risco próprios):\",\n        \"Local currency ({{currency}})\": \"Moeda local ({{currency}})\",\n        \"Locale:\": \"Localização:\",\n        \"Save\": \"Salvar\",\n        \"Save and Reload\": \"Salvar e Recarregar\",\n        \"This document's ID (for API use):\": \"O ID deste documento (para uso em API):\",\n        \"Time Zone:\": \"Fuso horário:\",\n        \"Ok\": \"OK\",\n        \"Document ID copied to clipboard\": \"ID do documento copiado para a área de transferência\",\n        \"API\": \"API\",\n        \"Manage Webhooks\": \"Gerenciar ganchos web\",\n        \"Webhooks\": \"Ganchos Web\",\n        \"API console\": \"Consola API\",\n        \"API documentation.\": \"Documentação de API.\",\n        \"Currency\": \"Moeda\",\n        \"Data engine\": \"Motor de dados\",\n        \"Find slow formulas\": \"Encontrar fórmulas lentas\",\n        \"For currency columns\": \"Para colunas de moeda\",\n        \"Hard reset of data engine\": \"Reinicialização total do motor de dados\",\n        \"Manage webhooks\": \"Gerenciar webhooks\",\n        \"Python\": \"Python\",\n        \"Python version used\": \"Versão Python usada\",\n        \"Try API calls from the browser\": \"Experimente chamadas de API do navegador\",\n        \"python2 (legacy)\": \"python2 (legado)\",\n        \"API URL copied to clipboard\": \"URL da API copiado para a área de transferência\",\n        \"Coming soon\": \"Em breve\",\n        \"Base doc URL: {{docApiUrl}}\": \"URL do documento base: {{docApiUrl}}\",\n        \"Copy to clipboard\": \"Copiar para a área de transferência\",\n        \"Default for DateTime columns\": \"Padrão para colunas DataHorário\",\n        \"Document ID\": \"ID do documento\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"ID do documento a ser usado sempre que a API REST fizer uma chamada para {{docId}}. Veja {{apiURL}}\",\n        \"For number and date formats\": \"Para formatos de número e data\",\n        \"Formula times\": \"Tempos de fórmula\",\n        \"Locale\": \"Localização\",\n        \"ID for API use\": \"ID para uso da API\",\n        \"Notify other services on doc changes\": \"Notifique outros serviços em alterações doc\",\n        \"Reload\": \"Recarregar\",\n        \"Time zone\": \"Fuso horário\",\n        \"python3 (recommended)\": \"python3 (recomendado)\",\n        \"Time reload\": \"Recarga de tempo\",\n        \"Timing is on\": \"O tempo está ligado\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Você pode fazer alterações no documento e, em seguida, interromper a cronometragem para ver os resultados.\",\n        \"Cancel\": \"Cancelar\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Forçar o recarregamento do documento durante a cronometragem das fórmulas e mostrar o resultado.\",\n        \"Formula timer\": \"Temporizador de Fórmula\",\n        \"Reload data engine\": \"Recarregar o motor de dados\",\n        \"Reload data engine?\": \"Recarregar o motor de dados?\",\n        \"Start timing\": \"Iniciar cronometragem\",\n        \"Stop timing...\": \"Pare de cronometrar...\",\n        \"Only available to document editors\": \"Disponível apenas para editores de documentos\",\n        \"Only available to document owners\": \"Disponível apenas para proprietários de documentos\",\n        \"Template mode\": \"Modo de modelo\",\n        \"Change document type\": \"Alterar o tipo de documento\",\n        \"Edit\": \"Editar\",\n        \"Change nature of document\": \"Alterar a natureza do documento\",\n        \"Regular document\": \"Documento regular\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Comportamento normal do documento. Todos os usuários trabalham na mesma cópia do documento.\",\n        \"Regular\": \"Regular\",\n        \"Template\": \"Modelo\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"O documento abre automaticamente em {{fiddleModeDocUrl}}. Qualquer pessoa pode editar, o que criará uma nova cópia não salva.\",\n        \"fiddle mode\": \"modo para experimentar\",\n        \"Tutorial\": \"Tutorial\",\n        \"Document automatically opens as a user-specific copy.\": \"O documento abre automaticamente como uma cópia específica do usuário.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Quando você iniciar a cronometragem, o Grist medirá o tempo necessário para avaliar cada fórmula. Isso permite diagnosticar quais fórmulas são responsáveis pelo desempenho lento quando um documento é aberto pela primeira vez ou quando um documento responde a alterações.\",\n        \"Confirm change\": \"Confirmar alteração\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Isso executará um recarregamento rígido do mecanismo de dados. Isso pode ajudar se o mecanismo de dados estiver preso em um loop infinito, estiver processando indefinidamente a última alteração ou tiver travado. Nenhum dado será perdido, exceto possivelmente as ações pendentes no momento.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Clique em \\\"Iniciar transferência\\\" para transferir aqueles para armazenamento interno (armazenado no arquivo SQLite documento).\",\n        \"**Some existing attachments are still external**.\": \"**Alguns anexos existentes ainda são externos**.\",\n        \"Attachment storage\": \"Armazenamento de anexos\",\n        \"Being transfer\": \"Transferência\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Os anexos recém-carregados serão colocados no armazenamento externo.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Os anexos recém-carregados serão colocados no armazenamento interno.\",\n        \"No external stores available\": \"Não há armazenamento externo disponível\",\n        \"Preferred storage for this document\": \"Armazenamento preferido para este documento\",\n        \"Start transfer\": \"Iniciar transferência\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Alguns anexos existentes ainda são internos** (armazenados em um arquivo SQLite).\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Clique em \\\"Iniciar transferência\\\" para transferir aqueles para armazenamento externo.\",\n        \"External\": \"Externo\",\n        \"Internal\": \"Interno\",\n        \"Transfer in progress\": \"Transferência em andamento\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Alguns anexos existentes ainda são [externos]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Alguns anexos existentes ainda são [internos]({{internalLink}})** (armazenados no arquivo SQLite).\",\n        \"[Learn more.]({{learnLink}})\": \"[Saiba mais]({{learnLink}})\",\n        \"Upload\": \"Carregar\",\n        \"Upload missing attachments\": \"Carregar anexos ausentes\",\n        \"Uploading...\": \"Carregando...\",\n        \"Default\": \"Padrão\",\n        \"Default, template, or tutorial\": \"Padrão, modelo ou tutorial\",\n        \"Document type\": \"Tipo de documento\",\n        \"Allow others to suggest changes\": \"Permitir que outras pessoas sugiram mudanças\",\n        \"Enable suggestions\": \"Habilitar sugestões\",\n        \"Suggestions\": \"Sugestões\",\n        \"experiment\": \"experimento\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"Tamanho dos Anexos\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Entre em contato com o proprietário do site para atualizar o plano para aumentar os limites.\",\n        \"Data size\": \"Tamanho dos Dados\",\n        \"Document limits {{- link}}.\": \"Limite do documento {{- link}}.\",\n        \"Document limits {{- link}}. In {{gracePeriodDays}} days, this document will be read-only.\": \"Limite do documento {{- link}}. Dentro de {{gracePeriodDays}} dias, este documento será somente de leitura.\",\n        \"For higher limits, \": \"Para limites maiores, \",\n        \"Rows\": \"Linhas\",\n        \"The total size of all data in this document, excluding attachments.\": \"O tamanho total de todos os dados deste documento, excluindo os anexos.\",\n        \"This document is {{- link}} free plan limits.\": \"Este documento está {{- link}} o limite do plano gratuito.\",\n        \"This document {{- link}} free plan limits and is now read-only, but you can delete rows.\": \"Este documento {{- link}} o limite do plano livre e agora é somente leitura, mas você pode excluir linhas.\",\n        \"Updates every 5 minutes.\": \"Atualiza a cada 5 minutos.\",\n        \"Usage\": \"Uso\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"As estatísticas de uso só estão disponíveis para usuários com acesso total aos dados do documento.\",\n        \"start your 30-day free trial of the Pro plan.\": \"comece sua avaliação gratuita de 30 dias do plano Pro.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Restaurar a última edição\",\n        \"Undo discard\": \"Desfazer descarte\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Copiar todos os dados, além da estrutura da tabela.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Em vez de duplicar tabelas, geralmente é melhor segmentar os dados usando vistas vinculadas. {{link}}\",\n        \"Name for new table\": \"Nome para a nova tabela\",\n        \"Only the document default access rules will apply to the copy.\": \"Somente as regras de acesso padrão do documento serão aplicadas à cópia.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Programa Pós-Escolar\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Confira nosso tutorial relacionado para saber como vincular dados e criar leiautes de alta produtividade.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Consulte nosso tutorial relacionado para saber como modelar dados corporativos, usar fórmulas e gerenciar a complexidade.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Confira nosso tutorial relacionado para aprender como criar tabelas e gráficos resumidos, e para vincular os gráficos dinamicamente.\",\n        \"Investment Research\": \"Pesquisa de Investimento\",\n        \"Lightweight CRM\": \"CRM Simples (Gestão de Relações com o Cliente)\",\n        \"Tutorial: Analyze & Visualize\": \"Tutorial: Analisar e Visualizar\",\n        \"Tutorial: Create a CRM\": \"Tutorial: Criar um CRM\",\n        \"Tutorial: Manage Business Data\": \"Tutorial: Gerenciar dados corporativos\",\n        \"Welcome to the Afterschool Program template\": \"Bem vindo ao modelo do Programa Pós-Escolar\",\n        \"Welcome to the Investment Research template\": \"Bem vindo ao modelo de Pesquisa de Investimento\",\n        \"Welcome to the Lightweight CRM template\": \"Bem vindo ao modelo de CRM Simples (gestão de relações com o cliente)\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"COMPORTAMENTO DE COLUNA\",\n        \"COLUMN LABEL AND ID\": \"RÓTULO E IDENTIFICAÇÃO DA COLUNA\",\n        \"Clear and make into formula\": \"Limpar e transformar em fórmula\",\n        \"Clear and reset\": \"Limpar e redefinir\",\n        \"Column options are limited in summary tables.\": \"As opções de coluna são limitadas nas tabelas de resumo.\",\n        \"Convert column to data\": \"Converter coluna para dados\",\n        \"Convert to trigger formula\": \"Converter em fórmula de disparo\",\n        \"Data Column\": \"Coluna de Dados\",\n        \"Data Columns\": \"Colunas de Dados\",\n        \"Empty Column\": \"Coluna Vazia\",\n        \"Empty Columns\": \"Colunas Vazias\",\n        \"Enter formula\": \"Insira a fórmula\",\n        \"Formula Column\": \"Coluna de Fórmula\",\n        \"Formula Columns\": \"Colunas de Fórmula\",\n        \"Make into data column\": \"Transformar em coluna de dados\",\n        \"Mixed Behavior\": \"Comportamento Misto\",\n        \"Set formula\": \"Definir fórmula\",\n        \"Set trigger formula\": \"Definir fórmula de disparo\",\n        \"TRIGGER FORMULA\": \"FÓRMULA DE DISPARO\",\n        \"Data columns_one\": \"Coluna de Dados\",\n        \"Data columns_other\": \"Colunas de Dados\",\n        \"Empty columns_one\": \"Coluna vazia\",\n        \"Empty columns_other\": \"Colunas Vazias\",\n        \"Formula columns_one\": \"Coluna de Fórmula\",\n        \"Formula columns_other\": \"Colunas de Fórmula\",\n        \"DESCRIPTION\": \"DESCRIÇÃO\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Reverter para configurações comuns\",\n        \"Save as common settings\": \"Salvar como configuraçes comuns\",\n        \"Use separate settings\": \"Use configurações separadas\",\n        \"Using common settings\": \"Usando configurações comuns\",\n        \"Using separate settings\": \"Usando configurações separadas\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Adicionar Coluna\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Fixar filtro - {{- columnName}} Coluna (atual: livre)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Soltar filtro - {{- columnName}} Coluna (atual: fixado)\",\n        \"remove filter - {{- columnName}} column\": \"remover filtro - {{- columnName}} coluna\",\n        \"{{- columnName }} column filters\": \"{{- columnName }} filtros de coluna\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Opções de Grade\",\n        \"Horizontal gridlines\": \"Linhas de Grade Horizontais\",\n        \"Vertical gridlines\": \"Linhas de Grade Verticais\",\n        \"Zebra stripes\": \"Listras de Zebra\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Adicionar coluna\",\n        \"Add to sort\": \"Adicionar à classificação\",\n        \"Clear values\": \"Limpar valores\",\n        \"Column Options\": \"Opções de Coluna\",\n        \"Convert formula to data\": \"Converter fórmula em dados\",\n        \"Delete column\": \"Excluir coluna\",\n        \"Delete {{count}} columns\": \"Excluir {{count}} colunas\",\n        \"Filter Data\": \"Filtrar Dados\",\n        \"Freeze one more columns\": \"Congelar mais uma coluna\",\n        \"Freeze this column\": \"Congelar essa coluna\",\n        \"Freeze {{count}} columns\": \"Congelar {{count}} colunas\",\n        \"Freeze {{count}} more columns\": \"Congelar {{count}} colunas mais\",\n        \"Hide column\": \"Ocultar coluna\",\n        \"Hide {{count}} columns\": \"Ocultar {{count}} colunas\",\n        \"Insert column to the {{to}}\": \"Inserir coluna para a {{to}}\",\n        \"More sort options ...\": \"Mais opções de ordenação…\",\n        \"Rename column\": \"Renomear coluna\",\n        \"Reset column\": \"Redefinir coluna\",\n        \"Reset entire column\": \"Redefinir coluna inteira\",\n        \"Reset {{count}} columns\": \"Redefinir {{count}} colunas\",\n        \"Reset {{count}} entire columns\": \"Redefinir {{count}} colunas inteiras\",\n        \"Show column {{- label}}\": \"Mostrar coluna {{- label}}\",\n        \"Sort\": \"Ordenar\",\n        \"Sorted (#{{count}})\": \"Ordenado (#{{count}})\",\n        \"Unfreeze all columns\": \"Descongelar todas as colunas\",\n        \"Unfreeze this column\": \"Descongelar essa coluna\",\n        \"Unfreeze {{count}} columns\": \"Descongelar {{count}} colunas\",\n        \"Delete {{count}} columns_one\": \"Excluir coluna\",\n        \"Delete {{count}} columns_other\": \"Excluir {{count}} colunas\",\n        \"Freeze {{count}} columns_one\": \"Congelar esta coluna\",\n        \"Freeze {{count}} columns_other\": \"Congelar {{count}} colunas\",\n        \"Freeze {{count}} more columns_one\": \"Congelar mais uma coluna\",\n        \"Freeze {{count}} more columns_other\": \"Congelar {{count}} colunas mais\",\n        \"Hide {{count}} columns_one\": \"Ocultar coluna\",\n        \"Hide {{count}} columns_other\": \"Ocultar {{count}} colunas\",\n        \"Reset {{count}} columns_one\": \"Reinicializar coluna\",\n        \"Reset {{count}} columns_other\": \"Reinicializar {{count}} colunas\",\n        \"Reset {{count}} entire columns_one\": \"Reinicializar toda a coluna\",\n        \"Reset {{count}} entire columns_other\": \"Reinicializar {{count}} colunas inteiras\",\n        \"Sorted (#{{count}})_one\": \"Ordenado (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Ordenado (#{{count}})\",\n        \"Unfreeze {{count}} columns_one\": \"Descongelar esta coluna\",\n        \"Unfreeze {{count}} columns_other\": \"Descongelar {{count}} colunas\",\n        \"Insert column to the left\": \"Inserir coluna à esquerda\",\n        \"Insert column to the right\": \"Inserir coluna à direita\",\n        \"Shortcuts\": \"Atalhos\",\n        \"Show hidden columns\": \"Mostrar colunas ocultas\",\n        \"Created At\": \"Criado em\",\n        \"Authorship\": \"Autoria\",\n        \"Last Updated By\": \"Última atualização por\",\n        \"Hidden Columns\": \"Colunas ocultas\",\n        \"Lookups\": \"Pesquisas\",\n        \"Apply on record changes\": \"Aplicar em alterações de registro\",\n        \"Created By\": \"Criado por\",\n        \"Last Updated At\": \"Última atualização em\",\n        \"Apply to new records\": \"Aplicar a novos registros\",\n        \"Timestamp\": \"Carimbo de data/hora\",\n        \"no reference column\": \"nenhuma coluna de referência\",\n        \"Detect Duplicates in...\": \"Detectar duplicatas em...\",\n        \"UUID\": \"UUID\",\n        \"No reference columns.\": \"Não há colunas de referência.\",\n        \"Duplicate in {{- label}}\": \"Duplicar em {{- label}}\",\n        \"Search columns\": \"Procurar colunas\",\n        \"Adding UUID column\": \"Adicionando coluna UUID\",\n        \"Adding duplicates column\": \"Adicionar coluna duplicatas\",\n        \"Add formula column\": \"Añadir una columna de fórmulas\",\n        \"Add column with type\": \"Adicionar coluna com tipo\",\n        \"Created by\": \"Criado por\",\n        \"Created at\": \"Criado em\",\n        \"Last updated by\": \"Última atualização por\",\n        \"Detect duplicates in...\": \"Detectar duplicações em...\",\n        \"Last updated at\": \"Última atualização em\",\n        \"Any\": \"Qualquer\",\n        \"Numeric\": \"Numérico\",\n        \"Text\": \"Texto\",\n        \"Date\": \"Data\",\n        \"DateTime\": \"DataHora\",\n        \"Choice List\": \"Lista de opções\",\n        \"Reference\": \"Referência\",\n        \"Reference List\": \"Lista de referências\",\n        \"Attachment\": \"Anexo\",\n        \"Integer\": \"Inteiro\",\n        \"Toggle\": \"Alternar\",\n        \"Choice\": \"Opção\"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"Adicionada nova seção vinculada para visualizar {{viewName}}}\",\n        \"Import from file\": \"Importação de arquivo\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Seção vinculada salva {{title}} em exibição {{name}}\",\n        \"go to webhook settings\": \"ir para configurações webhook\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Novas alterações estão temporariamente suspensas. A fila de webhooks transbordou. Verifique as configurações dos webhooks, remova os webhooks inválidos e limpe a fila.\"\n    },\n    \"HomeIntro\": {\n        \", or find an expert via our \": \", ou encontre um especialista através do nosso\",\n        \"Any documents created in this site will appear here.\": \"Qualquer documento criado neste site aparecerá aqui.\",\n        \"Browse Templates\": \"Explore os Modelos\",\n        \"Create empty document\": \"Criar um Documento Vazio\",\n        \"Get started by creating your first Grist document.\": \"Comece criando seu primeiro documento Grist.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Comece explorando os modelos, ou criando seu primeiro documento Grist.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Comece convidando sua equipe e criando seu primeiro documento Grist.\",\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Import document\": \"Importar Documento\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Interessado em usar Grist fora de sua equipe? Visite gratuitamente seu \",\n        \"Invite Team Members\": \"Convide membros da equipe\",\n        \"Sign up\": \"Cadastre-se\",\n        \"Sprouts Program\": \"Programa Brotos\",\n        \"This workspace is empty.\": \"Essa área de trabalho está vazia.\",\n        \"Visit our {{link}} to learn more.\": \"Visite nosso {{link}} para saber mais.\",\n        \"Welcome to Grist!\": \"Bem-vindo ao Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Bem-vindo ao Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Bem-vindo ao {{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Você só tem acesso de leitura a este site. Atualmente não há documentos.\",\n        \"personal site\": \"Site pessoal\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} para salvar seu trabalho. \",\n        \"Welcome to Grist, {{- name}}!\": \"Bem-vindo ao Grist, {{-name}}!\",\n        \"Welcome to {{- orgName}}\": \"Bem-vindo a {{-orgName}}\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Visite nosso site {{link}} para saber mais sobre o Grist.\",\n        \"Sign in\": \"Entrar\",\n        \"To use Grist, please either sign up or sign in.\": \"Para usar o Grist, inscreva-se ou faça login.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Saiba mais em nosso {{helpCenterLink}}.\",\n        \"Only show documents\": \"Mostrar apenas documentos\"\n    },\n    \"HomeLeftPane\": {\n        \"Access Details\": \"Detalhes de Acesso\",\n        \"All documents\": \"Todos os Documentos\",\n        \"Create empty document\": \"Criar um Documento Vazio\",\n        \"Create workspace\": \"Criar Área de Trabalho\",\n        \"Delete\": \"Excluir\",\n        \"Delete {{workspace}} and all included documents?\": \"Excluir {{workspace}} e todos os documentos inclusos?\",\n        \"Examples & Templates\": \"Modelos\",\n        \"Import document\": \"Importar Documento\",\n        \"Manage users\": \"Gerenciar Usuários\",\n        \"Rename\": \"Renomear\",\n        \"Trash\": \"Lixeira\",\n        \"Workspace will be moved to Trash.\": \"A Área de Trabalho será movida pra Lixeira.\",\n        \"Workspaces\": \"Áreas de Trabalho\",\n        \"Tutorial\": \"Tutorial\",\n        \"Terms of service\": \"Termos de serviço\",\n        \"Grist Resources\": \"Recursos do Grist\",\n        \"context menu - {{- workspaceName }}\": \"menu de contexto - {{- workspaceName }}\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"Mesclar linhas que correspondem a estes campos:\",\n        \"Select fields to match on\": \"Selecione os campos a serem correspondidos em\",\n        \"Update existing records\": \"Atualizar os registros existentes\",\n        \"{{count}} unmatched field_other\": \"{{count}} campos sem equivalente\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} campos sem equivalente na importação\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} campo sem equivalente na importação\",\n        \"{{count}} unmatched field_one\": \"{{count}} campo sem equivalente\",\n        \"Column Mapping\": \"Mapeamento de coluna\",\n        \"Column mapping\": \"Mapeamento de coluna\",\n        \"Destination table\": \"Tabela de destino\",\n        \"Grist column\": \"Coluna de Grist\",\n        \"New Table\": \"Nova tabela\",\n        \"Revert\": \"Reverter\",\n        \"Skip\": \"Pular\",\n        \"Skip Import\": \"Pular importação\",\n        \"Source column\": \"Coluna de origem\",\n        \"Import from file\": \"Importar de arquivo\",\n        \"Skip Table on Import\": \"Pular tabela na importação\",\n        \"Import options\": \"Opções de importação\",\n        \"Cancel\": \"Cancelar\",\n        \"Import\": \"Importação\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Accessibility\": \"Acessibilidade\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"Como Modelo\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Tenha cuidado, o original tem mudanças que não estão neste documento. Essas mudanças serão sobrescritas.\",\n        \"Cancel\": \"Cancelar\",\n        \"Enter document name\": \"Digite o nome do documento\",\n        \"However, it appears to be already identical.\": \"No entanto, parece já ser idêntico.\",\n        \"Include the structure without any of the data.\": \"Incluir a estrutura sem os dados.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Será substituído, perdendo qualquer conteúdo que não esteja neste documento.\",\n        \"Name\": \"Nome\",\n        \"No destination workspace\": \"Nenhuma área de trabalho de destino\",\n        \"Organization\": \"Organização\",\n        \"Original Has Modifications\": \"Original Tem Modificações\",\n        \"Original Looks Identical\": \"Original Parece Identêntico\",\n        \"Original Looks Unrelated\": \"Original Parece não Relacionado\",\n        \"Overwrite\": \"Sobreescrever\",\n        \"Replacing the original requires editing rights on the original document.\": \"A substituição do original requer direitos de edição sobre o documento original.\",\n        \"Sign up\": \"Cadastre-se\",\n        \"The original version of this document will be updated.\": \"A versão original deste documento será atualizada.\",\n        \"To save your changes, please sign up, then reload this page.\": \"Para salvar suas alterações, cadastre-se e recarregue esta página.\",\n        \"Update\": \"Atualizar\",\n        \"Update Original\": \"Atualizar o Original\",\n        \"Workspace\": \"Área de Trabalho\",\n        \"You do not have write access to the selected workspace\": \"Você não tem acesso de gravação na área de trabalho selecionada\",\n        \"You do not have write access to this site\": \"Você não tem acesso de gravação a este site\",\n        \"Download document and history\": \"Baixe documento completo e histórico\",\n        \"Download document structure only (no data, for template use)\": \"Remova todos os dados, mas mantenha a estrutura para usar como um modelo\",\n        \"Download document without history (can significantly reduce file size)\": \"Remova o histórico do documento (pode reduzir significativamente o tamanho do arquivo)\",\n        \"Download\": \"Baixar\",\n        \"Download document\": \"Baixar documento\",\n        \"Download attachments\": \"Baixar anexos\",\n        \"Download full document and history\": \"Baixe documento completo e histórico\",\n        \"Format:\": \"Formato:\",\n        \"Learn more\": \"Saiba mais\",\n        \"download attachments\": \"baixar anexos\",\n        \".zip\": \".zip\",\n        \".tar (recommended)\": \".tar (recomendado)\",\n        \"Download an archive of all the attachments present in this document.\": \"Baixe um arquivo de todos os anexos presentes neste documento.\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Os anexos são externos e não estão incluídos neste download. Se fizer o upload do documento em uma instalação separada do Grist, também será necessário acessar {{downloadLink}} separadamente. \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"Se você pretende enviar este documento para uma instalação do Grist, precisará do arquivo no formato \\\".tar\\\" para restaurar os anexos. \"\n    },\n    \"NTextBox\": {\n        \"false\": \"falso\",\n        \"true\": \"verdadeiro\",\n        \"Field Format\": \"Formato do campo\",\n        \"Lines\": \"Linhas\",\n        \"Multi line\": \"Multilinha\",\n        \"Single line\": \"Linha única\"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"Peça ajuda\",\n        \"Cannot find personal site, sorry!\": \"Não encontro site pessoal, desculpe!\",\n        \"Give feedback\": \"Dar feedback\",\n        \"Go to your free personal site\": \"Acesse seu site pessoal gratuito\",\n        \"No notifications\": \"Nenhuma notificação\",\n        \"Notifications\": \"Notificações\",\n        \"Renew\": \"Renovar\",\n        \"Report a problem\": \"Reportar um problema\",\n        \"Upgrade Plan\": \"Atualizar o Plano\",\n        \"Manage billing\": \"Gerenciar faturamento\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Terminar\",\n        \"Next\": \"Próximo\",\n        \"Previous\": \"Anterior\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Tour de Vídeo Grist\",\n        \"Video Tour\": \"Tour de Vídeo\",\n        \"YouTube video player\": \"Reprodutor de vídeo YouTube\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Adicionar à Página\",\n        \"Building {{- label}} widget\": \"Construíndo o {{- label}} widget\",\n        \"Group by\": \"Agrupar por\",\n        \"Select data\": \"Selecionar dados\",\n        \"Select widget\": \"Selcione o Widget\",\n        \"New Table\": \"Nova tabela\",\n        \"SELECT BY\": \"SELECIONAR POR\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Excluir\",\n        \"Delete data and this page.\": \"Excluir os dados desta página.\",\n        \"The following table will no longer be visible\": \"A tabela a seguir não será mais visível\",\n        \"The following tables will no longer be visible\": \"As seguintes tabelas não serão mais visíveis\",\n        \"The following tables will no longer be visible_one\": \"A seguinte tabela não será mais visível\",\n        \"The following tables will no longer be visible_other\": \"As seguintes tabelas não serão mais visíveis\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Mantenha os dados e exclua a página. A tabela permanecerá disponível em {{rawDataLink}}\",\n        \"Raw Data page\": \"página de dados brutos\",\n        \"Document pages\": \"Páginas do documento\",\n        \"raw data page\": \"página de dados brutos\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Permitir Todos\",\n        \"Deny all\": \"Recusar Todos\",\n        \"Read only\": \"Somente leitura\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Falha na importação: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Atualizando o layout dos registros.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Adicionar Campo\",\n        \"Cancel\": \"Cancelar\",\n        \"Create new field\": \"Criar um Novo Campo\",\n        \"Save layout\": \"Guardar Layout\",\n        \"Show field {{- label}}\": \"Mostrar campo {{- label}}\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Adicionar Coluna\",\n        \"No columns to add\": \"Não há colunas para adicionar\"\n    },\n    \"RightPanel\": {\n        \"CHART TYPE\": \"TIPO DE GRÁFICO\",\n        \"COLUMN TYPE\": \"TIPO DE COLUNA\",\n        \"CUSTOM\": \"PERSONALIZADO\",\n        \"Change widget\": \"Alterar widget\",\n        \"Column\": \"Coluna\",\n        \"Columns\": \"Colunas\",\n        \"DATA TABLE\": \"TABELA DE DADOS\",\n        \"DATA TABLE NAME\": \"NOME DA TABELA DE DADOS\",\n        \"Data\": \"Dados\",\n        \"Detach\": \"Separar\",\n        \"Edit data selection\": \"Editar Seleção de Dados\",\n        \"FILTER\": \"FILTRAR\",\n        \"Field\": \"Campo\",\n        \"Fields\": \"Campos\",\n        \"GROUPED BY\": \"AGRUPADO POR\",\n        \"Row style\": \"Estilo de Linha\",\n        \"SELECT BY\": \"SELECIONADO POR\",\n        \"SELECTOR FOR\": \"SELETOR PARA\",\n        \"SORT\": \"ORDENAR\",\n        \"SOURCE DATA\": \"DADOS DE ORIGEM\",\n        \"Save\": \"Salvar\",\n        \"Select widget\": \"Selecionar Widget\",\n        \"Series\": \"Série\",\n        \"Sort & filter\": \"Ordenar & Filtrar\",\n        \"TRANSFORM\": \"TRANSFORMAR\",\n        \"Theme\": \"Tema\",\n        \"WIDGET TITLE\": \"TÍTULO DO WIDGET\",\n        \"Widget\": \"Widget\",\n        \"You do not have edit access to this document\": \"Você não tem permissão de edição desse documento\",\n        \"series_one\": \"Séries\",\n        \"series_other\": \"Séries\",\n        \"columns_one\": \"Coluna\",\n        \"columns_other\": \"Colunas\",\n        \"fields_one\": \"Campo\",\n        \"fields_other\": \"Campos\",\n        \"Add referenced columns\": \"Adicionar colunas referenciadas\",\n        \"Reset form\": \"Restaurar formulário\",\n        \"Default field value\": \"Valor padrão do campo\",\n        \"Display button\": \"Botão de exibição\",\n        \"Enter text\": \"Digite texto\",\n        \"Field rules\": \"Regras de campo\",\n        \"Field title\": \"Título do campo\",\n        \"Hidden field\": \"Campo escondido\",\n        \"Redirection\": \"Redirecionamento\",\n        \"Submission\": \"Envio\",\n        \"Required field\": \"Campo necessário\",\n        \"Submit another response\": \"Enviar outra resposta\",\n        \"Submit button label\": \"Etiqueta do botão de envio\",\n        \"Table column name\": \"Nome da coluna da tabela\",\n        \"Enter redirect URL\": \"Insira URL de redirecionamento\",\n        \"Redirect automatically after submission\": \"Redirecionar automaticamente após o envio\",\n        \"Configuration\": \"Configuração\",\n        \"Success text\": \"Texto de sucesso\",\n        \"Layout\": \"Leiaute\",\n        \"No field selected\": \"Nenhum campo selecionado\",\n        \"Select a field in the form widget to configure.\": \"Selecione um campo no widget do formulário para configurar.\",\n        \"Submit\": \"Enviar\",\n        \"Thank you! Your response has been recorded.\": \"Obrigado! Sua resposta foi registrada.\",\n        \"Chart options\": \"Opções de gráfico\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"Copiar o link de ancoragem\",\n        \"Delete\": \"Excluir\",\n        \"Duplicate row\": \"Duplicar linha\",\n        \"Duplicate rows\": \"Duplicar linhas\",\n        \"Insert row\": \"Inserir linha\",\n        \"Insert row above\": \"Inserir linha acima\",\n        \"Insert row below\": \"Inserir linha abaixo\",\n        \"Duplicate rows_one\": \"Duplicar linha\",\n        \"Duplicate rows_other\": \"Duplicar linhas\",\n        \"View as card\": \"Ver como cartão\",\n        \"Use as table headers\": \"Usar como cabeçalhos de tabela\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Copiado para a área de transferência\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"Detalhes de Acesso\",\n        \"Back to current\": \"Voltar ao Atual\",\n        \"Compare to {{termToUse}}\": \"Comparar ao {{termToUse}}\",\n        \"Current Version\": \"Versão Atual\",\n        \"Download\": \"Baixar\",\n        \"Duplicate document\": \"Duplicar o Documento\",\n        \"Edit without affecting the original\": \"Editar sem afetar o original\",\n        \"Export CSV\": \"Exportar CSV\",\n        \"Export XLSX\": \"Exportar XLSX\",\n        \"Manage users\": \"Gerenciar Usuários\",\n        \"Original\": \"Original\",\n        \"Replace {{termToUse}}...\": \"Substituir {{termToUse}}…\",\n        \"Return to {{termToUse}}\": \"Retornar ao {{termToUse}}\",\n        \"Save copy\": \"Salvar Cópia\",\n        \"Save Document\": \"Salvar Documento\",\n        \"Send to Google Drive\": \"Enviar ao Google Drive\",\n        \"Show in folder\": \"Mostrar na pasta\",\n        \"Unsaved\": \"Não Salvo\",\n        \"Work on a copy\": \"Trabalho em uma cópia\",\n        \"Share\": \"Compartilhar\",\n        \"Download...\": \"Baixar...\",\n        \"Tab Separated Values (.tsv)\": \"Valores separados por tabulação (.tsv)\",\n        \"Export as...\": \"Exportar como...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Comma Separated Values (.csv)\": \"Valores separados por vírgula (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"Valores separados por DOO (.dsv)\",\n        \"Download attachments...\": \"Baixar anexos...\",\n        \"Download document...\": \"Baixar documento...\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"A exportação só está disponível a partir de páginas de documentos. Por favor, selecione uma página de documento e tente novamente.\",\n        \"Suggest changes\": \"Sugerir alterações\",\n        \"current version\": \"versão atual\",\n        \"original\": \"original\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Criar novo site de equipe\",\n        \"Switch Sites\": \"Alternar sites\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Adicionar Coluna\",\n        \"Empty values last\": \"Valores vazios por último\",\n        \"Natural sort\": \"Classificação natural\",\n        \"Update data\": \"Atualizar Dados\",\n        \"Use choice position\": \"Usar posição de escolha\",\n        \"Search Columns\": \"Procurar colunas\",\n        \"Remove sort setting - {{- columnName }} column\": \"Remover configuração de classificação - {{- columnName }} coluna\",\n        \"Sort in ascending order (current: descending)\": \"Classificar em ordem crescente (atual: decrescente)\",\n        \"Sort in descending order (current: ascending)\": \"Classificar em ordem decrescente (atual: crescente)\",\n        \"Sort options - {{- columnName }} column\": \"Opções de classificação - {{- columnName }} coluna\",\n        \"{{- columnName }} column\": \"{{- columnName }} coluna\"\n    },\n    \"SortFilterConfig\": {\n        \"FILTER\": \"FILTRAR\",\n        \"Revert\": \"Reverter\",\n        \"SORT\": \"ORDENAR\",\n        \"Save\": \"Salvar\",\n        \"Update Sort & Filter settings\": \"Atualizar configurações de Classificação e Filtro\",\n        \"Filter\": \"FILTRAR\",\n        \"Sort\": \"ORDENAR\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Aparência \",\n        \"Switch appearance automatically to match system\": \"Alternar a aparência automaticamente para corresponder ao sistema\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Regras de Acesso\",\n        \"Code view\": \"Vista do Código\",\n        \"Delete\": \"Excluir\",\n        \"Delete document tour?\": \"Excluir tour do documento?\",\n        \"Document history\": \"Histórico do Documento\",\n        \"How-to Tutorial\": \"Tutorial de Como Fazer\",\n        \"Raw data\": \"Dados Primários\",\n        \"Return to viewing as yourself\": \"Voltar a ver como você mesmo\",\n        \"TOOLS\": \"FERRAMENTAS\",\n        \"Tour of this Document\": \"Tour desse Documento\",\n        \"Validate Data\": \"Validar dados\",\n        \"Settings\": \"Configurações\",\n        \"API console\": \"Consola API\",\n        \"context menu - Access Rules\": \"Menu de contexto - Regras de acesso\",\n        \"Delete document tour\": \"Excluir o tour do documento\",\n        \"Preview the tutorial\": \"Visualizar o tutorial\",\n        \"Proposed changes\": \"Alterações propostas\",\n        \"Suggest changes\": \"Sugerir alterações\",\n        \"Suggestions\": \"Sugestões\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Gerenciar a equipe\"\n    },\n    \"TriggerFormulas\": {\n        \"(data cleaning)\": \"(limpeza de dados)\",\n        \"(except formulas)\": \"(exceto fórmulas)\",\n        \"Any field\": \"Qualquer campo\",\n        \"Apply on changes to:\": \"Aplicar em alterações para:\",\n        \"Apply on record changes\": \"Aplicar em alterações de registro\",\n        \"Apply to new records\": \"Aplicar a novos registros\",\n        \"Cancel\": \"Cancelar\",\n        \"Close\": \"Fechar\",\n        \"Current field \": \"Campo atual \",\n        \"OK\": \"OK\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Preview\": \"Pré-visualização\",\n        \"Revise\": \"Revisar\",\n        \"Update formula (Shift+Enter)\": \"Atualizar a fórmula (Shift+Enter)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Editor\",\n        \"In full\": \"Na íntegra\",\n        \"No Default Access\": \"Sem Acesso Padrão\",\n        \"None\": \"Nenhum\",\n        \"Owner\": \"Proprietário\",\n        \"View & edit\": \"Ver & Editar\",\n        \"View only\": \"Somente Ver\",\n        \"Viewer\": \"Observador\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Regra {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Atualizar a fórmula (Shift+Enter)\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Configurações avançadas\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"As tabelas grandes podem ser marcadas como \\\"sob-demanda\\\" para evitar o seu carregamento no mecanismo de dados.\",\n        \"Blocks\": \"Blocos\",\n        \"Compact\": \"Compactar\",\n        \"Edit card layout\": \"Editar Layout do Cartão\",\n        \"Form\": \"Formulário\",\n        \"If you make table {{table}} On-Demand, its data will no longer be loaded into the calculation engine and will not be available for use in formulas. It will remain available for viewing and editing.\": \"Se você fizer a tabela {{table}} Sob-Demanda, seus dados não serão mais carregados no mecanismo de cálculo e não estarão disponíveis para uso em fórmulas. Ela permanecerá disponível para visualização e edição.\",\n        \"If you unmark table {{- table}}' as On-Demand, its data will be loaded into the calculation engine and will be available for use in formulas. For a big table, this may greatly increase load times.{{- br}}{{-br}}Changing this setting will reload the document for all users.\": \"Se você desmarcar a tabela {{- table}} como Sob-Demanda, seus dados serão carregados no mecanismo de cálculo e ficarão disponíveis para uso em fórmulas. Para uma tabela grande, isto pode aumentar muito os tempos de carregamento.{{- br}}{{-br}}A modificação desta configuração recarregará o documento para todos os usuários.\",\n        \"Make On-Demand\": \"Fazer Sob-Demanda\",\n        \"Make table On-Demand?\": \"Fazer tabela Sob-Demanda?\",\n        \"Plugin: \": \"Plugin: \",\n        \"Section: \": \"Secção: \",\n        \"Unmark On-Demand\": \"Desmarcar Sob-Demanda\",\n        \"Unmark table On-Demand?\": \"Desmarcar a tabela Sob-Demanda?\",\n        \"⚠️ Deprecated Feature\": \"⚠ Recurso Depreciado\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"As Tabelas On-Demand foram depreciadas devido à falta de funcionalidades e preocupações de usabilidade.\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Ordenar & filtrar avançados\",\n        \"Copy anchor link\": \"Copiar o link de ancoragem\",\n        \"Data selection\": \"Seleção de dados\",\n        \"Delete record\": \"Excluir registro\",\n        \"Delete widget\": \"Excluir Widget\",\n        \"Download as CSV\": \"Baixar como CSV\",\n        \"Download as XLSX\": \"Baixar como XLSX\",\n        \"Edit card layout\": \"Editar Layout de cartão\",\n        \"Open configuration\": \"Abrir configuração\",\n        \"Print widget\": \"Imprimir Widget\",\n        \"Show raw data\": \"Mostrar dados primários\",\n        \"Widget options\": \"Opções do Widget\",\n        \"Collapse widget\": \"Colapsar widget\",\n        \"Add to page\": \"Adicionar à página\",\n        \"Create a form\": \"Criar um formulário\",\n        \"Duplicate widget\": \"Duplicar widget\"\n    },\n    \"ViewSectionMenu\": {\n        \"(customized)\": \"(personalizado)\",\n        \"(empty)\": \"(vazio)\",\n        \"(modified)\": \"(modificado)\",\n        \"Add Filter\": \"Adicionar Filtro\",\n        \"Custom options\": \"Opções personalizadas\",\n        \"FILTER\": \"FILTRAR\",\n        \"Filtered by\": \"Filtrado por\",\n        \"Revert\": \"Reverter\",\n        \"SORT\": \"ORDENAR\",\n        \"Save\": \"Salvar\",\n        \"Sorted by\": \"Ordenado por\",\n        \"Toggle Filter Bar\": \"Alternar Barra de Filtro\",\n        \"Update Sort&Filter settings\": \"Atualizar configurações de Ordenar e Filtrar\",\n        \"Sort and filter\": \"Classificar e filtrar\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"Não é possível lançar itens em Campos Ocultos\",\n        \"Clear\": \"Limpar\",\n        \"Hidden Fields cannot be reordered\": \"Campos ocultos não podem ser reordenados\",\n        \"Select all\": \"Selecionar Todos\",\n        \"Hidden {{label}}\": \"{{label}} escondido\",\n        \"Show {{label}}\": \"Mostrar {{label}}\",\n        \"Visible {{label}}\": \"{{label}} visível\",\n        \"Hide {{label}}\": \"Ocultar {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Ocultar {{label}} (modo em lote)\",\n        \"Show {{label}} (batch mode)\": \"Mostrar {{label}} (modo em lote)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Educação\",\n        \"Finance & Accounting\": \"Finanças e Contabilidade\",\n        \"HR & Management\": \"RH e Gestão\",\n        \"IT & Technology\": \"TI e Tecnologia\",\n        \"Marketing\": \"Publicidade\",\n        \"Media Production\": \"Produção de Mídia\",\n        \"Other\": \"Outros\",\n        \"Product Development\": \"Desenvolvimento de Produto\",\n        \"Research\": \"Investigação\",\n        \"Sales\": \"Vendas\",\n        \"Type here\": \"Digite aqui\",\n        \"Welcome to Grist!\": \"Bem-vindo ao Grist!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"O que te traz ao Grist? Por favor, ajude-nos a atendê-lo melhor.\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"Cancelar\",\n        \"DATA TABLE NAME\": \"NOME DA TABELA DE DADOS\",\n        \"Override widget title\": \"Substituir o título do Widget\",\n        \"Provide a table name\": \"Forneça um nome de tabela\",\n        \"Save\": \"Salvar\",\n        \"WIDGET TITLE\": \"TÍTULO DO WIDGET\",\n        \"WIDGET DESCRIPTION\": \"DESCRIÇÃO DO WIDGET\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Você pode fazer edições, mas elas criarão uma nova cópia e\\nnão afetarão o documento original.\",\n        \"fiddle\": \"mexer\",\n        \"override\": \"sobreescrever\",\n        \"recovery mode\": \"modo de recuperação\",\n        \"snapshot\": \"instantâneo\",\n        \"unsaved\": \"não-salvo\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Você pode fazer edições,\\nmas elas não afetarão o documento original.\\nVocê pode propô-las como sugestões.\",\n        \"editing\": \"editando\",\n        \"suggesting\": \"sugerindo\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Duplicar página {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Observe que isto não copia dados, mas cria uma outra visão dos mesmos dados.\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Acesso negado{{suffix}}\",\n        \"Add account\": \"Adicionar conta\",\n        \"Contact support\": \"Entre em contato com o suporte\",\n        \"Error{{suffix}}\": \"Erro {{suffix}}\",\n        \"Go to main page\": \"Ir para a página principal\",\n        \"Page not found{{suffix}}\": \"Página não encontrada {{suffix}}\",\n        \"Sign in\": \"Entrar\",\n        \"Sign in again\": \"Iniciar sessão novamente\",\n        \"Sign in to access this organization's documents.\": \"Faça o login para acessar os documentos desta organização.\",\n        \"Signed out{{suffix}}\": \"Sessão finalizada{{suffix}}\",\n        \"Something went wrong\": \"Algo deu errado\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"A página solicitada não pôde ser encontrada.{{separator}}Por favor, verifique a URL e tente novamente.\",\n        \"There was an error: {{message}}\": \"Houve um erro: {{message}}\",\n        \"There was an unknown error.\": \"Houve um erro desconhecido.\",\n        \"You are now signed out.\": \"Agora você está fora da sessão.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Você está inscrito como {{email}}. Você pode entrar com uma conta diferente, ou pedir acesso a um administrador.\",\n        \"You do not have access to this organization's documents.\": \"Você não tem acesso aos documentos desta organização.\",\n        \"Account deleted{{suffix}}\": \"Conta excluída{{suffix}}\",\n        \"Your account has been deleted.\": \"Sua conta foi excluída.\",\n        \"Sign up\": \"Cadastre-se\",\n        \"An unknown error occurred.\": \"Ocorreu um erro desconhecido.\",\n        \"Form not found\": \"Formulário não encontrado\",\n        \"Powered by\": \"Desenvolvido por\",\n        \"Build your own form\": \"Construa seu próprio formulário\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Falha ao fazer login.{{separator}}Tente novamente ou entre em contato com o suporte.\",\n        \"Sign-in failed{{suffix}}\": \"Falha no login{{suffix}}\",\n        \"Manage settings\": \"Gerenciar configurações\",\n        \"Need Help?\": \"Precisa de ajuda?\",\n        \"There was an error\": \"Houve um erro\",\n        \"Unsubscribed{{suffix}}\": \"Não inscrito {{suffix}}\",\n        \"We could not unsubscribe you\": \"Não foi possível cancelar sua inscrição\",\n        \"You are unsubscribed\": \"Sua inscrição foi cancelada\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Você ainda pode cancelar a assinatura deste documento atualizando suas preferências nas configurações do documento\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Você não receberá mais notificações por e-mail sobre {{changes}} em {{docName}} em {{email}}.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Você não receberá mais notificações por e-mail sobre {{comments}} em {{docName}} em {{email}}.\",\n        \"changes\": \"alterações\",\n        \"comments\": \"comentários\",\n        \"this document\": \"este documento\",\n        \"your email\": \"seu e-mail\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* As áreas de trabalho estão disponíveis nos planos de equipe. \",\n        \"Select fields\": \"Selecionar campos\",\n        \"Upgrade now\": \"Atualizar agora\",\n        \"Numeric\": \"Numérico\",\n        \"Text\": \"Texto\",\n        \"Integer\": \"Inteiro\",\n        \"Toggle\": \"Alternar\",\n        \"Date\": \"Data\",\n        \"DateTime\": \"DataHora\",\n        \"Choice List\": \"Lista de opções\",\n        \"Reference List\": \"Lista de Referências\",\n        \"Attachment\": \"Anexo\",\n        \"Any\": \"Qualquer\",\n        \"Choice\": \"Opção\",\n        \"Reference\": \"Referência\",\n        \"Search columns\": \"Procurar colunas\",\n        \"Light\": \"Claro\",\n        \"Custom\": \"Personalizado\",\n        \"By Name\": \"Por Nome\",\n        \"By Date Modified\": \"Por Data de Modificação\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Cancelar\",\n        \"Ok\": \"OK\",\n        \"Save\": \"Salvar\",\n        \"Are you sure you want to delete these records?\": \"Tem a certeza de que deseja apagar esses registros?\",\n        \"Undo to restore\": \"Desfazer para restaurar\",\n        \"Delete\": \"Eliminar\",\n        \"Are you sure you want to delete this record?\": \"Tem certeza de que deseja apagar este registro?\",\n        \"Dismiss\": \"Descartar\",\n        \"Don't ask again.\": \"Não perguntar novamente\",\n        \"Got it\": \"Entendido\",\n        \"Don't show again\": \"Não mostrar novamente\",\n        \"Don't show again.\": \"Não mostrar novamente.\",\n        \"Don't show tips\": \"Não mostrar dicas\",\n        \"TIP\": \"DICA\",\n        \"Confirm\": \"Confirmar\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Duplicar a Página\",\n        \"Remove\": \"Remover\",\n        \"Rename\": \"Renomear\",\n        \"You do not have edit access to this document\": \"Você não tem permissão de edição desse documento\",\n        \"(default)\": \"(padrão)\",\n        \"Collapse {{maybeDefault}}\": \"Recolher {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Expandir {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Definir padrão: Recolher\",\n        \"Set default: Expand\": \"Definir padrão: Expandir\",\n        \"context menu - {{- pageName }}\": \"menu de contexto - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Find Next \": \"Encontrar Próximo \",\n        \"Find Previous \": \"Encontrar Anterior \",\n        \"No results\": \"Sem resultados\",\n        \"Search in document\": \"Procurar no documento\",\n        \"Search\": \"Procurar\",\n        \"Close search bar\": \"Fechar barra de pesquisa\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Enviando arquivo ao Google Drive\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Usuário desconhecido\",\n        \"View as Yourself\": \"Ver como você mesmo\",\n        \"You are viewing this document as\": \"Você está visualizando este documento como\",\n        \"You're seeing what this user would see if given access\": \"Você está vendo o que esse usuário veria se tivesse acesso\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Procurar colunas\",\n        \"Search Columns\": \"Procurar colunas\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Ver como\",\n        \"Users from table\": \"Usuários da tabela\",\n        \"Example Users\": \"Usuários de exemplo\"\n    },\n    \"ACLUsers\": {\n        \"Users from table\": \"Usuários da tabela\",\n        \"View as\": \"Ver como\",\n        \"Example Users\": \"Usuários de exemplo\",\n        \"Other users from table\": \"Outros usuários da tabela\",\n        \"Shared users\": \"Usuários compartilhados\"\n    },\n    \"WelcomeTour\": {\n        \"Use {{helpCenter}} for documentation or questions.\": \"Use {{helpCenter}} para documentação ou perguntas.\",\n        \"convert to card view, select data, and more.\": \"converta para a visualização de cartão, selecione dados e muito mais.\",\n        \"Start with {{equal}} to enter a formula.\": \"Comece com {{equal}} para inserir uma fórmula.\",\n        \"Welcome to Grist!\": \"Bem-vindo ao Grist!\",\n        \"template library\": \"biblioteca de modelos\",\n        \"Flying higher\": \"Voando mais alto\",\n        \"Add new\": \"Adicionar Novo\",\n        \"Building up\": \"Construindo\",\n        \"Customizing columns\": \"Personalizando colunas\",\n        \"Configuring your document\": \"Configurando seu documento\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Faça-o relacional! Use o tipo {{ref}} para vincular tabelas. \",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Procure nosso {{templateLibrary}} para descobrir o que é possível e se inspirar.\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Clique duas vezes ou pressione {{enter}} em uma célula para editá-la. \",\n        \"Editing Data\": \"Editando dados\",\n        \"Enter\": \"Entra\",\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Reference\": \"Referência\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Defina opções de formatação, fórmulas ou tipos de coluna, como datas, escolhas ou anexos. \",\n        \"Share\": \"Compartilhar\",\n        \"Sharing\": \"Compartilhando\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Alternar o {{creatorPanel}} para formatar colunas, \",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Use {{addNew}} para adicionar widgets, páginas ou importar mais dados. \",\n        \"creator panel\": \"painel do criador\",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Use o botão Compartilhar ({{share}}) para compartilhar o documento ou exportar dados.\",\n        \"AI Assistant\": \"Assistente de IA\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN LABEL\": \"RÓTULO DA COLUNA\",\n        \"Cancel\": \"Cancelar\",\n        \"Save\": \"Salvar\",\n        \"COLUMN DESCRIPTION\": \"DESCRIÇÃO DA COLUNA\",\n        \"COLUMN ID: \": \"ID DA COLUNA: \"\n    },\n    \"ConditionalStyle\": {\n        \"Row style\": \"Estilo de Linha\",\n        \"Rule must return True or False\": \"A regra deve retornar Verdadeiro ou Falso\",\n        \"Error in style rule\": \"Erro na regra de estilo\",\n        \"Add another rule\": \"Adicionar outra regra\",\n        \"Add conditional style\": \"Adicionar estilo condicional\",\n        \"Conditional Style\": \"Estilo condicional\",\n        \"IF...\": \"SE...\",\n        \"Row Style\": \"Estilo de Linha\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Moeda inválida\"\n    },\n    \"DiscussionEditor\": {\n        \"Cancel\": \"Cancelar\",\n        \"Marked as resolved\": \"Marcado como resolvido\",\n        \"Reply\": \"Responder\",\n        \"Reply to a comment\": \"Responder a um comentário\",\n        \"Write a comment\": \"Escreva um comentário\",\n        \"Comment\": \"Comentário\",\n        \"Resolve\": \"Resolver\",\n        \"Started discussion\": \"Discussão iniciada\",\n        \"Edit\": \"Editar\",\n        \"Only current page\": \"Somente a página atual\",\n        \"Only my threads\": \"Somente meus tópicos\",\n        \"Showing last {{nb}} comments\": \"Mostrar os últimos {{nb}} comentários\",\n        \"Open\": \"Abrir\",\n        \"Remove\": \"Remover\",\n        \"Save\": \"Salvar\",\n        \"Show resolved comments\": \"Mostrar comentários resolvidos\",\n        \"Remove thread\": \"Remover tópico\",\n        \"updated\": \"atualizado\",\n        \"Copy link\": \"Copiar link\",\n        \"{{count}} comments_one\": \"{{count}} comentário\",\n        \"{{count}} comments_other\": \"{{count}} comentários\"\n    },\n    \"FieldBuilder\": {\n        \"CELL FORMAT\": \"FORMATO DA CÉLULA\",\n        \"DATA FROM TABLE\": \"DADOS DA TABELA\",\n        \"Apply formula to data\": \"Aplicar fórmula aos dados\",\n        \"Changing multiple column types\": \"Alterar vários tipos de colunas\",\n        \"Mixed format\": \"Formato misto\",\n        \"Save field settings for {{colId}} as common\": \"Salvar configurações de campo da {{colId}} como comum\",\n        \"Revert field settings for {{colId}} to common\": \"Reverter configurações de campo da {{colId}} para comum\",\n        \"Mixed types\": \"Tipos mistos\",\n        \"Use separate field settings for {{colId}}\": \"Use configurações de campo separadas para {{colId}}\",\n        \"Changing column type\": \"Alterando o tipo de coluna\",\n        \"Common\": \"Comum\",\n        \"Separate\": \"Separado\",\n        \"Field in {{count}} views_one\": \"Campo em uma vista\",\n        \"Field in {{count}} views_other\": \"Campo em {{count}} vistas\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Coluna ou campo é obrigatório\",\n        \"editingFormula is required\": \"ediçãoFórmula é obrigatório\",\n        \"Errors in all {{numErrors}} cells\": \"Erro em todas as {{numErrors}} células\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Erros em {{numErrors}} de {{numCells}} células\",\n        \"Error in the cell\": \"Erro na célula\",\n        \"Enter formula or {{button}}.\": \"Digite a fórmula ou {{button}}.\",\n        \"Enter formula.\": \"Digite a fórmula.\",\n        \"Expand Editor\": \"Expandir editor\",\n        \"use AI Assistant\": \"usar o Assistente de IA\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[rótulo do link] URL\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"Moeda\",\n        \"Decimals\": \"Decimais\",\n        \"Default currency ({{defaultCurrency}})\": \"Moeda padrão ({{defaultCurrency}})\",\n        \"Number Format\": \"Formato de número\",\n        \"Field Format\": \"Formato do campo\",\n        \"Spinner\": \"Girador\",\n        \"Text\": \"Texto\",\n        \"max\": \"máximo\",\n        \"min\": \"mínimo\"\n    },\n    \"TypeTransformation\": {\n        \"Preview\": \"Prévisualizar\",\n        \"Apply\": \"Aplicar\",\n        \"Revise\": \"Revisar\",\n        \"Cancel\": \"Cancelar\",\n        \"Update formula (Shift+Enter)\": \"Atualizar a fórmula (Shift+Enter)\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"ESTILO DE CÉLULA\",\n        \"Cell style\": \"Estilo de célula\",\n        \"Default cell style\": \"Estilo de célula padrão\",\n        \"Mixed style\": \"Estilo misto\",\n        \"Open row styles\": \"Estilos de linha aberta\",\n        \"Default header style\": \"Estilo de cabeçalho padrão\",\n        \"Header Style\": \"Estilo de cabeçalho\",\n        \"HEADER STYLE\": \"ESTILO DE CABEÇALHO\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"DESCRIÇÃO DA COLUNA\",\n        \"COLUMN LABEL\": \"RÓTULO DA COLUNA\"\n    },\n    \"FieldEditor\": {\n        \"Unable to finish saving edited cell\": \"Não é possível concluir o salvamento da célula editada\",\n        \"It should be impossible to save a plain data value into a formula column\": \"Deveria ser impossível salvar um valor de dados simples em uma coluna de fórmula\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Converter coluna em fórmula\"\n    },\n    \"Reference\": {\n        \"Row ID\": \"ID da linha\",\n        \"CELL FORMAT\": \"FORMATO DA CÉLULA\",\n        \"SHOW COLUMN\": \"MOSTRAR COLUNA\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"ESCOLHAS\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Idioma\"\n    },\n    \"GristTooltips\": {\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"As células em uma coluna de referência sempre identificam um registro {{entire}} nessa tabela, mas você pode selecionar qual coluna desse registro deve ser mostrada.\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Fórmulas que acionam em certos casos, e armazenam o valor calculado como dados.\",\n        \"Link your new widget to an existing widget on this page.\": \"Vincule seu novo widget a um widget existente nesta página.\",\n        \"Linking Widgets\": \"Vinculando widgets\",\n        \"Nested Filtering\": \"Filtragem aninhada\",\n        \"Only those rows will appear which match all of the filters.\": \"Somente serão exibidas as linhas que correspondem a todos os filtros.\",\n        \"Reference Columns\": \"Colunas de referência\",\n        \"Select the table to link to.\": \"Selecione a tabela à qual se vincular.\",\n        \"Selecting Data\": \"Selecionando dados\",\n        \"The total size of all data in this document, excluding attachments.\": \"O tamanho total de todos os dados deste documento, excluindo os anexos.\",\n        \"Updates every 5 minutes.\": \"Atualiza a cada 5 minutos.\",\n        \"You can filter by more than one column.\": \"Você pode filtrar por mais de uma coluna.\",\n        \"Access Rules\": \"Regras de Acesso\",\n        \"Add new\": \"Adicionar Novo\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Clique no botão Adicionar Novo para criar novos documentos ou espaços de trabalho ou importar dados.\",\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Aplicar formatação condicional às células nesta coluna quando as condições da fórmula forem atendidas.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Aplicar formatação condicional em linhas com base em fórmulas.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Clique em \\\"Abrir estilos de linhas\\\" para aplicar a formatação condicional às linhas.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Clicar {{EyeHideIcon}} em cada célula esconde o campo desta visualização sem apagá-lo.\",\n        \"Editing Card Layout\": \"Editando o layout do cartão\",\n        \"Learn more.\": \"Saiba mais.\",\n        \"Raw Data page\": \"Página de dados brutos\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"As colunas de referência são a chave para os dados {{relational}} no Grist.\",\n        \"Pinning Filters\": \"Fixando filtros\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Organize os campos em seu cartão arrastando e redimensionando células.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Os filtros fixados são exibidos como botões acima do widget.\",\n        \"Select the table containing the data to show.\": \"Selecione a tabela que contém os dados a serem exibidos.\",\n        \"They allow for one record to point (or refer) to another.\": \"Eles permitem que um registro aponte (ou se refira) a outro.\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"A página de dados brutos lista todas as tabelas de dados em seu documento, incluindo tabelas de resumo e tabelas não incluídas nos layouts de página.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"Este é o segredo dos layouts dinâmicos e produtivos do Grist.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Experimente mudanças em uma cópia, depois decida se deseja substituir o original por suas edições.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Use o ícone \\\\u{1D6BA} para criar tabelas de resumo (ou dinâmicas) para totais ou subtotais.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Útil para armazenar o carimbo da hora ou autor de um novo registro, limpeza de dados, e muito mais.\",\n        \"entire\": \"inteiro\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"As regras de acesso lhe dão o poder de criar regras diferenciadas para determinar quem pode ver ou editar quais partes de seu documento.\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Use o ícone 𝚺 para criar tabelas resumidas (ou dinâmicas), para totais ou subtotais.\",\n        \"relational\": \"relacionais\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Desfixe para ocultar o botão enquanto mantém o filtro.\",\n        \"Anchor Links\": \"Links de âncora\",\n        \"Custom Widgets\": \"Widgets personalizados\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Para criar um link âncora que leve o usuário a uma célula específica, clique em uma linha e pressione {{shortcut}}.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Você pode escolher um dos nossos widgets pré-feitos ou incorporar seu próprio, fornecendo sua URL completa.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"Para configurar seu calendário, selecione colunas para datas de início/fim e títulos de eventos. Observe o tipo de cada coluna.\"\n        },\n        \"Calendar\": \"Calendário\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Não consegue encontrar as colunas certas? Clique em \\\"Change Widget\\\" (Alterar widget) para selecionar a tabela com os dados dos eventos.\",\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"Um UUID é uma cadeia de caracteres gerada aleatoriamente que é útil para identificadores exclusivos e chaves de link.\",\n        \"Lookups return data from related tables.\": \"As pesquisas retornam dados de tabelas relacionadas.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Você pode escolher entre os widgets disponíveis no menu suspenso ou incorporar o seu próprio widget fornecendo o URL completo.\",\n        \"Use reference columns to relate data in different tables.\": \"Use colunas de referência para relacionar dados em diferentes tabelas.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"As fórmulas suportam muitas funções do Excel, sintaxe Python completa e incluem um assistente de IA útil.\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\",\n        \"Forms are here!\": \"Os formulários chegaram!\",\n        \"Learn more\": \"Saiba mais\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Estas regras são aplicadas após todas as regras da coluna terem sido processadas, se aplicável.\",\n        \"Example: {{example}}\": \"Exemplo: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"Filtre os valores exibidos no menu suspenso com uma condição.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Cria uma coluna reversa na tabela de destino que pode ser editada de qualquer lado.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Esta limitação ocorre quando uma coluna em uma referência bidirecional tem o tipo Referência.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Para permitir várias atribuições, altere o tipo da coluna Referência para Lista de Referência.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Para permitir várias atribuições, altere o tipo da coluna referenciada para Lista de referência.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Atualmente, não há suporte para referências bidirecionais em Colunas de Fórmula ou Fórmula de disparo\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Os widgets comunitários são criados e mantidos pelos membros da comunidade Grist.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Essa limitação ocorre quando uma extremidade de uma referência bidirecional é configurada como uma única referência.\",\n        \"The preview below this header shows how the selected user will see this document\": \"A visualização abaixo desse cabeçalho mostra como o usuário selecionado verá esse documento\",\n        \"[Learn more.]({{link}})\": \"[Saiba mais]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"As tabelas de resumo só podem conter colunas de fórmula.\",\n        \"Manage users and resources in a Grist installation.\": \"Gerencie usuários e recursos em uma instalação do Grist.\",\n        \"The new Grist Assistant is here!\": \"O novo assistente Grist está aqui!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"As fórmulas suportam muitas funções do Excel e a sintaxe completa do Python.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Cria uma nova coluna Lista de Referência na tabela de destino, com ambas as colunas de destino editáveis e sincronizadas.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Armazenamento interno significa que todos os anexos são armazenados no arquivo SQLite do documento, enquanto o armazenamento externo indica que todos os anexos são armazenados no mesmo armazenamento externo.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Isso permite adicionar anexos que estão faltando no armazenamento externo, por exemplo, em um documento importado. Somente arquivos de anexos .tar baixados do Grist podem ser carregados aqui.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Entenda, modifique e trabalhe com seus dados e fórmulas com a ajuda do novo Assistente de IA do Grist!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs. Do not submit passwords through this form, and be careful with links in it. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Este formulário foi criado por um usuário do Grist e não é endossado pelo Grist Labs. Não envie senhas por meio desse formulário e tenha cuidado com os links nele contidos. Denuncie formulários maliciosos para [{{mail}}](mailto:{{mail}}).\",\n        \"Set the maximum number of lines for multi-line text.\": \"Defina o número máximo de linhas para texto com várias linhas.\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Este formulário foi criado por um usuário do Grist e não é endossado pelo Grist Labs, Inc. ou por qualquer parte que forneça esse serviço. Para sua segurança, não envie senhas por meio deste formulário e tenha cuidado ao clicar em links incorporados. Denuncie formulários maliciosos para [{{mail}}](mailto:{{mail}}).\",\n        \"Comments are here!\": \"Os comentários estão aqui!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Você pode adicionar comentários às células, responder a tópicos de comentários e @mencionar colaboradores.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"Quando selecionado, o valor padrão deste campo pode ser preenchido previamente a partir da URL usando parâmetros de consulta.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"Com a função de sugestões, os usuários fazem alterações em uma cópia pessoal sem modificar o documento original e, em seguida, enviam essas sugestões para serem revisadas pelo proprietário do documento antes da integração.\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"DESCRIÇÃO\",\n        \"Set description\": \"Descrição do conjunto\"\n    },\n    \"PagePanels\": {\n        \"Open creator panel\": \"Abrir o Painel do Criador\",\n        \"Close Creator Panel\": \"Fechar Painel do Criador\",\n        \"Creator panel (right panel)\": \"Painel do criador (painel direito)\",\n        \"Document header\": \"Cabeçalho do documento\",\n        \"Main content\": \"Conteúdo principal\",\n        \"Main navigation and document settings (left panel)\": \"Navegação principal e configurações do documento (painel esquerdo)\",\n        \"Close navigation panel (left panel)\": \"Fechar o painel de navegação (painel esquerdo)\",\n        \"Open navigation panel (left panel)\": \"Abrir o painel de navegação (painel esquerdo)\"\n    },\n    \"ColumnTitle\": {\n        \"Cancel\": \"Cancelar\",\n        \"Column description\": \"Descrição da coluna\",\n        \"Column label\": \"Rótulo da coluna\",\n        \"Save\": \"Salvar\",\n        \"Add description\": \"Adicionar descrição\",\n        \"Column ID copied to clipboard\": \"ID da coluna copiada para a área de transferência\",\n        \"COLUMN ID: \": \"ID DA COLUNA: \",\n        \"Provide a column label\": \"Forneça um rótulo de coluna\",\n        \"Close\": \"Fechar\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Entendido\",\n        \"Unavailable Command\": \"Comando indisponível\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"O comando de menu {{action}} não está disponível neste navegador. Mas você pode acessar {{action}} usando o atalho de teclado {{shortcut}}.\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Limpar fila\",\n        \"Webhook settings\": \"Configurações do gancho web\",\n        \"Enabled\": \"Ativado\",\n        \"Cleared webhook queue.\": \"Fila de webhooks limpa.\",\n        \"Columns to check when update (separated by ;)\": \"Colunas a serem verificadas na atualização (separadas por ;)\",\n        \"Event Types\": \"Tipos de eventos\",\n        \"Status\": \"Estado\",\n        \"Sorry, not all fields can be edited.\": \"Desculpe, nem todos os campos podem ser editados.\",\n        \"URL\": \"URL\",\n        \"Memo\": \"Memorando\",\n        \"Name\": \"Nome\",\n        \"Ready Column\": \"Coluna pronta\",\n        \"Removed webhook.\": \"Webhook removido.\",\n        \"Webhook Id\": \"Id do webhook\",\n        \"Table\": \"Tabela\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Filtrar as alterações nessas Colunas (ids separados por ponto e vírgula)\",\n        \"Header Authorization\": \"Autorização de cabeçalho\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooks Indisponível Em cópias de documentos não salvas\"\n    },\n    \"FieldContextMenu\": {\n        \"Copy anchor link\": \"Copiar link de âncora\",\n        \"Hide field\": \"Ocultar campo\",\n        \"Paste\": \"Colar\",\n        \"Clear field\": \"Limpar campo\",\n        \"Copy\": \"Copiar\",\n        \"Cut\": \"Cortar\",\n        \"Comment\": \"Comentário\"\n    },\n    \"FormulaAssistant\": {\n        \"Capabilities\": \"Capacidades\",\n        \"Formula Help. \": \"Ajuda de Fórmula. \",\n        \"Function List\": \"Lista de funções\",\n        \"Grist's AI Assistance\": \"Assistência de IA do Grist\",\n        \"Grist's AI Formula Assistance. \": \"Assistência à Fórmula IA do Grist. \",\n        \"Need help? Our AI assistant can help.\": \"Precisa de ajuda? Nosso assistente de IA pode ajudar.\",\n        \"New Chat\": \"Novo chat\",\n        \"Preview\": \"Prévisualizar\",\n        \"Regenerate\": \"Regenerar\",\n        \"Save\": \"Salvar\",\n        \"Tips\": \"Dicas\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Consulte nossos sites {{helpFunction}} e {{formulaCheat}}, ou visite nosso site {{community}} para obter mais ajuda.\",\n        \"Ask the bot.\": \"Pergunte ao bot.\",\n        \"Data\": \"Dados\",\n        \"Formula Cheat Sheet\": \"Folha de Consulta da Fórmula\",\n        \"Community\": \"Comunidade\",\n        \"AI Assistant\": \"Assistente de IA\",\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Clear conversation\": \"Limpar conversa\",\n        \"Code view\": \"Vista do Código\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Olá, sou o Assistente de IA de Fórmula Grist.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Só posso ajudar com fórmulas. Não posso criar tabelas, colunas e exibições, nem escrever regras de acesso.\",\n        \"Press Enter to apply suggested formula.\": \"Pressione Enter para aplicar a fórmula sugerida.\",\n        \"Sign Up for Free\": \"Cadastre-se gratuitamente\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Cadastre-se para uma conta gratuita do Grist para começar a usar o Assistente de Fórmula AI.\",\n        \"What do you need help with?\": \"Em que você precisa de ajuda?\",\n        \"There are some things you should know when working with me:\": \"Há algumas coisas que você deve saber ao trabalhar comigo:\",\n        \"Learn more\": \"Saiba mais\",\n        \"Formula AI Assistant is only available for logged in users.\": \"O Assistente de Fórmula de IA só está disponível para usuários registrados.\",\n        \"For higher limits, contact the site owner.\": \"Para limites maiores, entre em contato com o proprietário do site.\",\n        \"upgrade to the Pro Team plan\": \"atualize para o plano Pro Team\",\n        \"You have used all available credits.\": \"Você utilizou todos os créditos disponíveis.\",\n        \"upgrade your plan\": \"Atualize seu plano\",\n        \"You have {{numCredits}} remaining credits.\": \"Você tem {{numCredits}} créditos restantes.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Para limites maiores, {{upgradeNudge}}.\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Para obter mais ajuda com fórmulas, consulte nossos sites {{functionList}} e {{formulaCheatSheet}}, ou visite nosso site {{community}} para obter mais ajuda.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Quando você fala comigo, suas perguntas e a estrutura do documento (visível em {{codeView}}) são enviadas para a OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Fale comigo como uma pessoa. Não há necessidade de especificar tabelas e nomes de colunas. Por exemplo, você pode pedir \\\"Por favor, calcule o valor total da fatura.\\\"\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Clique para inserir\"\n    },\n    \"WelcomeSitePicker\": {\n        \"You have access to the following Grist sites.\": \"Você tem acesso aos seguintes sites do Grist.\",\n        \"Welcome back\": \"Bem-vindo de volta\",\n        \"You can always switch sites using the account menu.\": \"Você sempre pode alternar entre sites usando o menu da conta.\"\n    },\n    \"SupportGristNudge\": {\n        \"Close\": \"Fechar\",\n        \"Opt in to Telemetry\": \"Aceitar a Telemetria\",\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Support Grist\": \"Suporte Grist\",\n        \"Contribute\": \"Contribuir\",\n        \"Opted In\": \"Optou por participar\",\n        \"Support Grist page\": \"Página de Suporte Grist\",\n        \"Admin Panel\": \"Painel do administrador\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Obrigado! Sua confiança e seu apoio são muito apreciados. Cancele a qualquer momento no {{link}} no menu do usuário.\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"Página de patrocinadores do GitHub\",\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Home\": \"Início\",\n        \"Manage Sponsorship\": \"Gerenciar patrocínio\",\n        \"Opt in to Telemetry\": \"Aceitar a Telemetria\",\n        \"Opt out of Telemetry\": \"Desativar a Telemetria\",\n        \"Sponsor Grist Labs on GitHub\": \"Patrocine Grist Labs no GitHub\",\n        \"Support Grist\": \"Suporte Grist\",\n        \"Telemetry\": \"Telemetria\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Esta instância está incluída na telemetria. Somente o administrador do site tem permissão para alterar isso.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Você pode desativar a telemetria a qualquer momento nesta página.\",\n        \"You have opted out of telemetry.\": \"Você decidiu em não participar da telemetria.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Coletamos apenas estatísticas de uso, conforme detalhado em nosso {{link}}, nunca o conteúdo dos documentos.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Esta instância foi desativada da telemetria. Somente o administrador do site tem permissão para alterar isso.\",\n        \"You have opted in to telemetry. Thank you!\": \"Você optou pela telemetria. Obrigado!\",\n        \"Sponsor\": \"Patrocinador\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"O software Grist é desenvolvido pela Grist Labs, que oferece planos hospedados gratuitos e pagos. Também disponibilizamos o código do Grist sob uma licença OSS padrão gratuita e aberta (Apache 2.0) em {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Apoie o Grist optando pela telemetria, que nos ajuda a entender como o produto é usado, para que possamos priorizar melhorias futuras.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Somos uma equipe pequena e determinada. Seu apoio é muito importante para nós. Ele também mostra aos outros que há uma comunidade determinada por trás desse produto.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Você pode apoiar o desenvolvimento de código aberto do Grist patrocinando-nos em nosso site {{link}}.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Sem dados\",\n        \"No row selected in {{title}}\": \"Nenhuma linha selecionada em {{title}}\",\n        \"Not all data is shown\": \"Nem todos os dados são mostrados\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"DESCRIÇÃO\"\n    },\n    \"UserManager\": {\n        \"Add {{member}} to your team\": \"Adicionar {{member}} à sua equipe\",\n        \"Allow anyone with the link to open.\": \"Permita que qualquer pessoa com o link abra.\",\n        \"Anyone with link \": \"Qualquer pessoa com link \",\n        \"Cancel\": \"Cancelar\",\n        \"Close\": \"Fechar\",\n        \"Collaborator\": \"Colaborador\",\n        \"Confirm\": \"Confirmar\",\n        \"Copy link\": \"Copiar link\",\n        \"Create a team to share with more people\": \"Crie uma equipe para compartilhar com mais pessoas\",\n        \"Guest\": \"Convidado\",\n        \"Invite multiple\": \"Convidar vários\",\n        \"Invite people to {{resourceType}}\": \"Convidar pessoas para {{resourceType}}\",\n        \"Manage members of team site\": \"Gerenciar membros do site da equipe\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Nenhum acesso padrão permite que o acesso seja concedido a documentos ou espaços de trabalho individuais, em vez do site de equipe completo.\",\n        \"On\": \"Ligado\",\n        \"Open Access Rules\": \"Regras de acesso aberto\",\n        \"Outside collaborator\": \"Colaborador externo\",\n        \"Public access\": \"Acesso Público\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Acesso público herdado de {{parent}}. Para remover, defina a opção 'Herdar acesso' para 'Nenhum'.\",\n        \"Public access: \": \"Acesso público: \",\n        \"Remove my access\": \"Remover meu acesso\",\n        \"Save & \": \"Salvar & \",\n        \"Team member\": \"Membro da equipe\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"O usuário herda as permissões de {{parent})}. Para remover, defina a opção 'Herdar acesso' para 'Nenhum'.\",\n        \"User may not modify their own access.\": \"O usuário não pode modificar seu próprio acesso.\",\n        \"Your role for this team site\": \"Seu papel para este site de equipe\",\n        \"Your role for this {{resourceType}}\": \"Seu papel para este {{resourceType}}\",\n        \"free collaborator\": \"colaborador livre\",\n        \"member\": \"membro\",\n        \"team site\": \"site da equipe\",\n        \"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}}.\": \"Depois de remover seu próprio acesso, você não poderá recuperá-lo sem a ajuda de outra pessoa com acesso suficiente ao {{resourceType}}.\",\n        \"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.\": \"O usuário tem acesso de visualização a {{resource}} resultante do acesso definido manualmente aos recursos internos. Se removido aqui, esse usuário perderá o acesso aos recursos internos.\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"O usuário herda as permissões de {{parent}}. Para remover, defina a opção 'Herdar acesso' para 'Nenhum'.\",\n        \"Grist support\": \"Suporte Grist\",\n        \"Link copied to clipboard\": \"Link copiado para a área de transferência\",\n        \"guest\": \"convidado\",\n        \"Off\": \"Desligado\",\n        \"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}}.\": \"Depois de remover seu próprio acesso, você não poderá recuperá-lo sem a ajuda de outra pessoa com acesso suficiente ao {{name}}.\",\n        \"{{collaborator}} limit exceeded\": \"Limite de {{collaborator}} excedido\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} de {{limitTop}} {{collaborator}}s\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Nenhum acesso padrão permite que o acesso seja concedido a documentos ou espaços de trabalho individuais, em vez do site de equipe completo.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Você está prestes a remover seu próprio acesso a este {{resourceType}}\",\n        \"Inherit access: \": \"Acesso herdado: \",\n        \"Access overview\": \"Visão geral do acesso\"\n    },\n    \"SearchModel\": {\n        \"Search all tables\": \"Procurar todas as tabelas\",\n        \"Search all pages\": \"Procurar todas as páginas\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Procurar\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"Exibindo {{displayedCount}} de {{totalCount}} itens. Pesquise para ver mais.\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Recolher editor\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Maximizar\",\n        \"Minimize\": \"Minimizar\"\n    },\n    \"CardContextMenu\": {\n        \"Insert card above\": \"Inserir cartão acima\",\n        \"Duplicate card\": \"Duplicar o cartão\",\n        \"Insert card below\": \"Inserir cartão abaixo\",\n        \"Delete card\": \"Excluir cartão\",\n        \"Copy anchor link\": \"Copiar link de ancoragem\",\n        \"Insert card\": \"Inserir cartão\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Campos ocultos\"\n    },\n    \"FormView\": {\n        \"Publish\": \"Publicar\",\n        \"Publish your form?\": \"Publicar o seu formulário?\",\n        \"Unpublish your form?\": \"Despublicar seu formulário?\",\n        \"Unpublish\": \"Cancelar publicação\",\n        \"Are you sure you want to reset your form?\": \"Tem certeza de que deseja redefinir o formulário?\",\n        \"Embed this form\": \"Incorporar este formulário\",\n        \"Link copied to clipboard\": \"Link copiado para a área de transferência\",\n        \"Reset form\": \"Redefinir formulário\",\n        \"Share this form\": \"Compartilhe este formulário\",\n        \"View\": \"Ver\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Qualquer pessoa com o link abaixo pode ver o formulário vazio e enviar uma resposta.\",\n        \"Copy link\": \"Copiar link\",\n        \"Reset\": \"Redefinir\",\n        \"Save your document to publish this form.\": \"Salve seu documento para publicar esse formulário.\",\n        \"Share\": \"Compartilhar\",\n        \"Code copied to clipboard\": \"Código copiado para a área de transferência\",\n        \"Copy code\": \"Copiar código\",\n        \"Preview\": \"Pré-visualização\",\n        \"# **Form Title**\": \"# **Título do formulário**\",\n        \"Your form description goes here.\": \"A descrição do seu formulário vai aqui.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"A publicação do formulário gerará um link de compartilhamento. Qualquer pessoa com o link poderá ver o formulário vazio e enviar uma resposta.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Ao cancelar a publicação do formulário, o link de compartilhamento será desativado, de modo que os usuários que acessarem o formulário por meio desse link verão um erro.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Os usuários estão limitados a enviar entradas (registros em sua tabela) e ler valores predefinidos em campos designados, como colunas de referência e de escolha.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Seu formulário está publicado. Todas as alterações estão ativas e visíveis para os usuários com acesso ao formulário. Se você quiser fazer alterações no rascunho, cancele a publicação do formulário.\"\n    },\n    \"Menu\": {\n        \"Columns\": \"Colunas\",\n        \"Cut\": \"Cortar\",\n        \"Building blocks\": \"Blocos de construção\",\n        \"Unmapped fields\": \"Campos não mapeados\",\n        \"Insert question above\": \"Insira a questão acima\",\n        \"Insert question below\": \"Inserir questão abaixo\",\n        \"Paragraph\": \"Parágrafo\",\n        \"Paste\": \"Colar\",\n        \"Separator\": \"Separador\",\n        \"Copy\": \"Copiar\",\n        \"Header\": \"Cabeçalho\",\n        \"New question\": \"Nova pergunta\",\n        \"More\": \"Mais\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Unmap fields\": \"Desmapear campos\",\n        \"Unmapped\": \"Desmapeado\",\n        \"Mapped\": \"Mapeado\",\n        \"Select all\": \"Selecionar Todos\",\n        \"Map fields\": \"Mapear campos\",\n        \"Clear\": \"Limpar\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Eliminar\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Programe seu {{freeCoachingCall}} com um membro da nossa equipe.\",\n        \"free coaching call\": \"chamada gratuita de treinamento\",\n        \"Maybe later\": \"Talvez mais tarde\",\n        \"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.\": \"Na chamada, vamos ter tempo para entender suas necessidades e adaptar a chamada para você. Podemos mostrar-lhe os princípios básicos do Grist ou começar a trabalhar com os seus dados imediatamente para construir os painéis que você precisa.\",\n        \"Schedule call\": \"Agendar chamada\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Você também pode conferir {{ourWeeklyWebinars}} para saber mais sobre Grist.\",\n        \"our weekly webinars\": \"nossos webinars semanais\",\n        \"Free coaching call\": \"Sessão de coaching gratuita\",\n        \"Grist 101\": \"Grist 101\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"Você também pode conferir nosso webinar introdutório, {{ourWeeklyWebinars}} , desenvolvido para ajudar novos usuários.                Aprenda os fundamentos do Grist.\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"Você também pode conferir nosso webinar introdutório, {{ourWeeklyWebinars}} , desenvolvido para ajudar novos usuários a se familiarizarem com os fundamentos do Grist.\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Regras de campo\",\n        \"Required field\": \"Campo obrigatório\",\n        \"Ascending\": \"Ascendente\",\n        \"Default\": \"Padrão\",\n        \"Descending\": \"Descendente\",\n        \"Field Format\": \"Formato do campo\",\n        \"Field Rules\": \"Regras de campo\",\n        \"Horizontal\": \"Horizontal\",\n        \"Options Alignment\": \"Alinhamento de opções\",\n        \"Options Sort Order\": \"Opções Ordem de classificação\",\n        \"Radio\": \"Rádio\",\n        \"Select\": \"Selecione\",\n        \"Vertical\": \"Vertical\",\n        \"Accept value from URL\": \"Aceitar valor da URL\",\n        \"Hidden field\": \"Campo oculto\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"Parâmetro da URL: \\n{{colId}} =VALOR\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Algumas colunas obrigatórias não estão mapeadas\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Para usar este widget, mapeie todas as colunas não opcionais do painel criador à direita.\",\n        \"Some required columns are hidden by access rules\": \"Algumas colunas necessárias estão ocultas por regras de acesso\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Para usar este widget, todas as colunas mapeadas devem ser visíveis. Entre em contato com o proprietário do documento ou modifique as regras de acesso.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Crie seu próprio formulário\",\n        \"Powered by\": \"Desenvolvido por\",\n        \"Powered by Grist\": \"Desenvolvido por Grist\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Erro\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"Ops! O formulário que você está procurando não existe.\",\n        \"Oops! This form is no longer published.\": \"Ops! Este formulário não está mais publicado.\",\n        \"There was a problem loading the form.\": \"Houve um problema ao carregar o formulário.\",\n        \"You don't have access to this form.\": \"Você não tem acesso a este formulário.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Houve um erro ao enviar seu formulário. Por favor, tente novamente.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Formulário enviado\",\n        \"Thank you! Your response has been recorded.\": \"Obrigado! Sua resposta foi registrada.\",\n        \"Submit new response\": \"Enviar nova resposta\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"Últimos 30 dias\",\n        \"Last 7 days\": \"Últimos 7 dias\",\n        \"Last week\": \"Semana passada\",\n        \"Next 7 days\": \"Próximo 7 dias\",\n        \"This month\": \"Este mês\",\n        \"This week\": \"Esta semana\",\n        \"This year\": \"Este ano\",\n        \"Today\": \"Hoje\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Select all\": \"Selecionar tudo\",\n        \"Unmap fields\": \"Desmapear campos\",\n        \"Unmapped\": \"Desmapeado\",\n        \"Clear\": \"Limpar\",\n        \"Map fields\": \"Mapear campos\",\n        \"Mapped\": \"Mapeado\",\n        \"Hide {{label}}\": \"Ocultar {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Ocultar {{label}} (modo em lote)\",\n        \"Unmap {{label}}\": \"Desmapear {{label}}\",\n        \"Unmap {{label}} (batch mode)\": \"Desmapear {{label}} (modo em lote)\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Inserir seção acima\",\n        \"Insert section below\": \"Inserir seção abaixo\",\n        \"## **Header**\": \"## **Cabeçalho**\",\n        \"Description\": \"Descrição\"\n    },\n    \"AdminPanel\": {\n        \"Current\": \"Atual\",\n        \"Current version of Grist\": \"Versão atual do Grist\",\n        \"Help us make Grist better\": \"Ajude-nos a melhorar o Grist\",\n        \"Support Grist Labs on GitHub\": \"Apoie a Grist Labs no GitHub\",\n        \"Admin Panel\": \"Painel do administrador\",\n        \"Home\": \"Início\",\n        \"Sponsor\": \"Patrocinador\",\n        \"Support Grist\": \"Apoiar o Grist\",\n        \"Telemetry\": \"Telemetria\",\n        \"Version\": \"Versão\",\n        \"Check now\": \"Verificar agora\",\n        \"Error\": \"Erro\",\n        \"Grist is up to date\": \"Grist está atualizado\",\n        \"Grist releases are at \": \"Os lançamentos do Grist estão em \",\n        \"Last checked {{time}}\": \"Última verificação {{time}}\",\n        \"Learn more.\": \"Saiba mais.\",\n        \"Newer version available\": \"Versão mais recente disponível\",\n        \"No information available\": \"Não há informações disponíveis\",\n        \"OK\": \"OK\",\n        \"Sandbox settings for data engine\": \"Configurações da caixa de areia para o motor de dados\",\n        \"Sandboxing\": \"Caixa de areia\",\n        \"Security Settings\": \"Configurações de segurança\",\n        \"Updates\": \"Atualizações\",\n        \"unconfigured\": \"não configurado\",\n        \"unknown\": \"desconhecido\",\n        \"Auto-check when this page loads\": \"Verificar automaticamente quando esta página carregar\",\n        \"Checking for updates...\": \"Verificando atualizações...\",\n        \"Error checking for updates\": \"Erro ao verificar atualizações\",\n        \"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 permite fórmulas muito poderosas, usando Python. Recomendamos definir a variável de ambiente GRIST_SANDBOX_FLAVOR para gvisor se o seu hardware o suporta (a maioria suportará), para executar fórmulas em cada documento dentro de uma caixa de areia isolada de outros documentos e isolada da rede.\",\n        \"Details\": \"Detalhes\",\n        \"No fault detected.\": \"Nenhuma falha detectada.\",\n        \"Notes\": \"Notas\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Ou, como alternativa, você pode definir: {{bootKey}} no ambiente e visitar: {{url}}\",\n        \"Results\": \"Resultados\",\n        \"Self Checks\": \"Autoverificações\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"Você não tem acesso ao painel do administrador.\\nFaça login como administrador.\",\n        \"Administrator Panel Unavailable\": \"Painel do administrador indisponível\",\n        \"Check succeeded.\": \"A verificação foi bem-sucedida.\",\n        \"Authentication\": \"Autenticação\",\n        \"Check failed.\": \"A verificação falhou.\",\n        \"Current authentication method\": \"Método de autenticação atual\",\n        \"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.\",\n        \"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.\",\n        \"Key to sign sessions with\": \"Chave para assinar sessões com\",\n        \"Session Secret\": \"Segredo da sessão\",\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.\": \"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.\",\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 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.\",\n        \"Enterprise\": \"Empresarial\",\n        \"Enable Grist Enterprise\": \"Habilitar a Grist Empresarial\",\n        \"checking\": \"verificando\",\n        \"Log Streaming\": \"Fluxo de registros\",\n        \"Contact us\": \"Entre em contato conosco\",\n        \"New, Enterprise\": \"Novo, Enterprise\",\n        \"Off\": \"Desligado\",\n        \"Audit Logs\": \"Registros de auditoria\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} mais\",\n        \"On\": \"Ligado\",\n        \"Grist Instance\": \"Instância do Grist\",\n        \"Auto-check weekly\": \"Verificação automática semanal\",\n        \"No record of last version check\": \"Nenhum registro da última verificação de versão\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Você pode configurar a transmissão de eventos de auditoria do Grist para um sistema externo de gerenciamento de eventos e informações de segurança (SIEM) se ativar o Grist Enterprise. {{contactUsLink}} para saber mais.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"As verificações automáticas estão desativadas. Defina a variável de ambiente GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING como \\\"true\\\" para ativá-las.\",\n        \"auth error\": \"erro de autenticação\",\n        \"configured\": \"configurado\",\n        \"default\": \"padrão\",\n        \"more...\": \"mais...\",\n        \"no authentication\": \"sem autenticação\",\n        \"unavailable\": \"indisponível\",\n        \"{{count}} admin accounts_one\": \"{{count}} conta de administrador\",\n        \"{{count}} admin accounts_other\": \"{{count}} contas de administrador\",\n        \"Admin account not found\": \"Conta de administrador não encontrada\",\n        \"Administrative accounts\": \"Contas administrativas\",\n        \"The users with administrative accounts\": \"Os usuários com contas administrativas\",\n        \"Version {{versionNumber}}\": \"Versão {{versionNumber}}\",\n        \"no admin accounts\": \"não há contas de administrador\",\n        \"Are you sure you want to restart Grist?\": \"Tem certeza de que deseja reiniciar o Grist?\",\n        \"Grist is running in an environment that doesn't support restarting from the admin panel.\": \"O Grist está sendo executado em um ambiente que não suporta reinicialização a partir do painel de administração.\",\n        \"Restart\": \"Reiniciar\",\n        \"Restart Grist\": \"Reiniciar Grist\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"Reinicie o Grist para aplicar as alterações pendentes ou resolver problemas.\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Nenhuma opção configurada\",\n        \"No values in show column of referenced table\": \"Nenhum valor na coluna de exibição da tabela referenciada\",\n        \"Hide\": \"Ocultar\"\n    },\n    \"CreateTeamModal\": {\n        \"Billing is not supported in grist-core\": \"O faturamento não é compatível com o grist-core\",\n        \"Cancel\": \"Cancelar\",\n        \"Choose a name and url for your team site\": \"Escolha um nome e uma url para o site da sua equipe\",\n        \"Create site\": \"Criar site\",\n        \"Domain name is invalid\": \"Nome do domínio é inválido\",\n        \"Domain name is required\": \"Nome de domínio é necessário\",\n        \"Go to your site\": \"Ir para o seu site\",\n        \"Team name\": \"Nome da equipe\",\n        \"Team name is required\": \"O Nome da equipe é obrigatório\",\n        \"Team site created\": \"Site da equipe criado\",\n        \"Team url\": \"URL da equipe\",\n        \"Work as a Team\": \"Trabalhe em equipe\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Remover Coluna\"\n    },\n    \"Toggle\": {\n        \"Field Format\": \"Formato do campo\",\n        \"Switch\": \"Interruptor\",\n        \"Checkbox\": \"Caixa de seleção\"\n    },\n    \"ChoiceEditor\": {\n        \"Error in dropdown condition\": \"Erro na condição do menu suspenso\",\n        \"No choices matching condition\": \"Nenhuma opção que corresponda à condição\",\n        \"No choices to select\": \"Não há opções para selecionar\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"Erro na condição do menu suspenso\",\n        \"No choices matching condition\": \"Nenhuma opção que corresponda à condição\",\n        \"No choices to select\": \"Não há opções para selecionar\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Invalid columns: {{colIds}}\": \"Colunas inválidas: {{colIds}}\",\n        \"Dropdown Condition\": \"Condição de menu suspenso\",\n        \"Set dropdown condition\": \"Definir condição do menu suspenso\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Digite a condição.\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Erro na condição do menu suspenso\",\n        \"No choices matching condition\": \"Nenhuma opção que corresponda à condição\",\n        \"No choices to select\": \"Não há opções para selecionar\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"Redefinir\",\n        \"Search\": \"Pesquisar\",\n        \"Select...\": \"Selecionar...\",\n        \"Submit\": \"Enviar\",\n        \"Submitting…\": \"Enviando…\"\n    },\n    \"widgetTypesMap\": {\n        \"Calendar\": \"Calendário\",\n        \"Card\": \"Cartão\",\n        \"Card List\": \"Lista de cartões\",\n        \"Chart\": \"Gráfico\",\n        \"Custom\": \"Personalizado\",\n        \"Table\": \"Tabela\",\n        \"Form\": \"Formulário\"\n    },\n    \"TimingPage\": {\n        \"Average Time (s)\": \"Tempo(s) médio(s)\",\n        \"Max Time (s)\": \"Tempo(s) máximo(s)\",\n        \"Total Time (s)\": \"Tempo(s) total(s)\",\n        \"Formula timer\": \"Temporizador de Fórmula\",\n        \"Column ID\": \"ID da Coluna\",\n        \"Loading timing data. Don't close this tab.\": \"Carregando dados de tempo. Não feche essa guia.\",\n        \"Number of Calls\": \"Número de chamadas\",\n        \"Table ID\": \"ID da tabela\"\n    },\n    \"DocTutorial\": {\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Você quer reiniciar o tutorial? Todo o progresso será perdido.\",\n        \"Finish\": \"Terminar\",\n        \"Restart\": \"Reiniciar\",\n        \"Click to expand\": \"Clique para expandir\",\n        \"Previous\": \"Anterior\",\n        \"End tutorial\": \"Finalizar tutorial\",\n        \"Next\": \"Próximo\"\n    },\n    \"OnboardingCards\": {\n        \"3 minute video tour\": \"Vídeo tour de 3 minutos\",\n        \"Complete our basics tutorial\": \"Conclua nosso tutorial básico\",\n        \"Complete the tutorial\": \"Concluir o tutorial\",\n        \"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.\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Aprenda os conceitos básicos de Colunas de referência, widgets vinculados, tipos de colunas e cartões.\"\n    },\n    \"OnboardingPage\": {\n        \"Discover Grist in 3 minutes\": \"Descubra Grist em 3 minutos\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Pratique com o tutorial Conceitos Básicos do Grist\",\n        \"Go to the tutorial!\": \"Vá para o tutorial!\",\n        \"Next step\": \"Próximo passo\",\n        \"Skip tutorial\": \"Pular o tutorial\",\n        \"Tell us who you are\": \"Diga-nos quem você é\",\n        \"Type here\": \"Digite aqui\",\n        \"What brings you to Grist (you can select multiple)?\": \"O que o traz ao Grist (você pode selecionar várias opções)?\",\n        \"What is your role?\": \"Qual é a sua função?\",\n        \"What organization are you with?\": \"Em que organização você está?\",\n        \"Your organization\": \"Sua organização\",\n        \"Your role\": \"Sua função\",\n        \"Skip step\": \"Pular passo\",\n        \"Back\": \"Voltar\",\n        \"Welcome\": \"Bem-vindo\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"O Grist pode parecer uma planilha, mas nem sempre age como uma. Descubra o que torna o Grist diferente.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"Disable Grist Enterprise\": \"Desativar o Grist Empresarial\",\n        \"Enable Grist Enterprise\": \"Habilitar a Grist Empresarial\",\n        \"Grist Enterprise is **enabled**.\": \"O Grist Empresarial está **habilitado**.\",\n        \"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}}).\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Uma chave de ativação é usada para executar o Grist Enterprise após o período de avaliação\\nde 30 dias tenha expirado. Obtenha uma chave de ativação [entrando em contato conosco]({{contactLink}}) hoje mesmo. Você não\\nprecisa de uma chave de ativação para executar o Grist Core.\\n\\nSaiba mais em nossa [Central de Ajuda]({{helpCenter}}).\",\n        \"Activate\": \"Ativar\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"É necessário ter uma assinatura ativa para continuar usando o Grist Enterprise. Você pode\\nativar sua assinatura [inscrevendo-se no Grist Enterprise ]({{signupLink}}) e colando sua\\nchave de ativação abaixo.\",\n        \"Copy to clipboard\": \"Copiar para a área de transferência\",\n        \"Expiration date\": \"Data de expiração\",\n        \"Installation ID copied to clipboard\": \"ID de instalação copiado para a área de transferência\",\n        \"Installation ID:\": \"ID de instalação:\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Saiba mais em nossa [Central de Ajuda]({{helpCenter}}).\",\n        \"Paste your activation key\": \"Cole sua chave de ativação\",\n        \"Plan name\": \"Nome do plano\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Para continuar usando o Grist Enterprise, você precisa\\n                  [entre em contato conosco]({{signupLink}}) para obter sua chave de ativação.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Sua chave de ativação expirou devido ao excesso de limites.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Sua instância estará no modo **somente-leitura** em **{{days}}** dia(s).\",\n        \"Your subscription expired on {{date}}.\": \"Sua assinatura expirou em {{date}}.\",\n        \"Activation key\": \"Chave de ativação\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Uma chave de ativação é usada para executar o Grist Enterprise após o período de avaliação\\n        de 30 dias tenha expirado. Obtenha uma chave de ativação [inscrevendo-se no Grist\\n        Enterprise]({{signupLink}}). Você não precisa de uma chave de ativação para executar o\\n        Grist Core.\",\n        \"Installation seats\": \"Assentos de instalação\",\n        \"You are currently trialing Grist Enterprise.\": \"No momento, você está testando o Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"Você não tem uma assinatura ativa.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Seu período de avaliação expirou em **{{expireAt}}**. Para continuar usando o Grist Enterprise, você precisa\\n[registrar-se no Grist Enterprise]({{signupLink}}) e colar sua chave de ativação abaixo.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Excluir\",\n        \"Delete data and this widget.\": \"Excluir dados e este widget.\",\n        \"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}}\",\n        \"Table {{tableName}} will no longer be visible\": \"A tabela {{tableName}} não estará mais visível\",\n        \"Raw Data page\": \"página de dados brutos\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Painel de administração\"\n    },\n    \"CustomWidgetGallery\": {\n        \"(Missing info)\": \"(Informação ausente)\",\n        \"Add widget\": \"Adicionar Widget\",\n        \"Community Widget\": \"Widget da comunidade\",\n        \"Custom URL\": \"URL personalizado\",\n        \"Learn more about custom widgets\": \"Saiba mais sobre Widgets personalizados\",\n        \"Widget URL\": \"URL do widget\",\n        \"Add Your Own Widget\": \"Adicione seu próprio widget\",\n        \"Cancel\": \"Cancelar\",\n        \"Change widget\": \"Alterar widget\",\n        \"Choose custom widget\": \"Escolha o widget personalizado\",\n        \"Add a widget from outside this gallery.\": \"Adicione um widget de fora dessa galeria.\",\n        \"Developer:\": \"Desenvolvedor:\",\n        \"Grist Widget\": \"Widget Grist\",\n        \"Last updated:\": \"Última atualização:\",\n        \"No matching widgets\": \"Nenhum widget correspondente\",\n        \"Search\": \"Pesquisar\"\n    },\n    \"markdown\": {\n        \"The toggle is **off**\": \"O interruptor está **desligado**\",\n        \"The toggle is **on**\": \"O interruptor está **ligado**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nova função Markdown\\n *\\n * Podemos _escrever_ [o Markdown usual] (https://markdownguide.org) *dentro*\\n * um elemento Grainjs.\"\n            }\n        }\n    },\n    \"markdown.d\": {\n        \"The toggle is **off**\": \"O interruptor está **desligado**\",\n        \"The toggle is **on**\": \"O interruptor está **ligado**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nova função Markdown\\n *\\n * Podemos _escrever_ [o Markdown usual] (https://markdownguide.org) *dentro*\\n * um elemento Grainjs.\"\n            }\n        }\n    },\n    \"HomeIntroCards\": {\n        \"3 minute video tour\": \"Vídeo tour de 3 minutos\",\n        \"Help center\": \"Centro de Ajuda\",\n        \"Import file\": \"Importar arquivo\",\n        \"Learn more {{webinarsLinks}}\": \"Saiba mais {{webinarsLinks}}\",\n        \"Tutorial\": \"Tutorial\",\n        \"Webinars\": \"Webinars\",\n        \"Blank document\": \"Documento em branco\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"Encontre soluções e explore mais recursos {{helpCenterLink}}\",\n        \"Finish our basics tutorial\": \"Termine nosso tutorial básico\",\n        \"Start a new document\": \"Inicie um novo documento\",\n        \"Templates\": \"Modelos\",\n        \"Find solutions and explore more resources\": \"Encontre soluções e explore mais recursos\",\n        \"Learn more\": \"Saiba mais\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Add two-way reference\": \"Adicionar referência bidirecional\",\n        \"Column\": \"Coluna\",\n        \"Delete\": \"Excluir\",\n        \"Delete column {{column}} in table {{table}}?\": \"Excluir a Coluna {{column}} na tabela {{table}}?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"É o inverso da coluna de referência {{column}} na tabela {{table}}.\",\n        \"Table\": \"Tabela\",\n        \"Two-way Reference\": \"Referência bidirecional\",\n        \"Delete two-way reference?\": \"Excluir referência bidirecional?\",\n        \"Target table\": \"Tabela de destino\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Isso excluirá a coluna de referência {{refCol}} na tabela {{refTable}}. A coluna de referência {{myName}} permanecerá na tabela atual {{myTable}}.\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"Painel de administração\",\n        \"Close\": \"Fechar\",\n        \"Help Center\": \"Centro de Ajuda\",\n        \"Opt in to Telemetry\": \"Aceitar a Telemetria\",\n        \"Opted In\": \"Optou por participar\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Obrigado! Sua confiança e seu apoio são muito apreciados. Cancele a qualquer momento no {{link}} no menu do usuário.\",\n        \"Support Grist\": \"Apoiar o Grist\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Aceite a telemetria para nos ajudar a entender como o produto é usado, para que possamos priorizar melhorias futuras.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Coletamos apenas estatísticas de uso, conforme detalhado em nosso {{helpCenterLink}}, nunca o conteúdo dos documentos. Desative a qualquer momento em {{supportGristLink}} no menu do usuário.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Cancelar\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Cada registro {{targetTable}} só pode ser atribuído a um único registro {{sourceTable}}.\",\n        \"Reassign\": \"Reatribuir\",\n        \"Record already assigned_one\": \"Registro já atribuído\",\n        \"Reassign to new {{sourceTable}} records.\": \"Reatribuir a novos registros {{sourceTable}}.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Reatribuir para o registro {{sourceTable}} {{sourceName}} .\",\n        \"Record already assigned_other\": \"Registros já atribuídos\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} O registro {{targetName}} já está atribuído ao registro {{sourceTable}} {{oldSourceName}} .\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Add destination\": \"Adicionar destino\",\n        \"Add streaming destination\": \"Adicionar destino de streaming\",\n        \"Delete streaming destination?\": \"Excluir destino de streaming?\",\n        \"Destinations\": \"Destinos\",\n        \"Edit\": \"Editar\",\n        \"Edit streaming destination\": \"Editar destino de streaming\",\n        \"Enter URL\": \"Inserir URL\",\n        \"Enter token\": \"Inserir token\",\n        \"Learn more\": \"Saiba mais\",\n        \"Other\": \"Outros\",\n        \"Save\": \"Salvar\",\n        \"Splunk\": \"Splunk\",\n        \"URL\": \"URL\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Tem certeza de que deseja Excluir esse destino de streaming? Essa ação não pode ser desfeita.\",\n        \"Cancel\": \"Cancelar\",\n        \"Delete\": \"Eliminar\",\n        \"Destination\": \"Destino\",\n        \"Start streaming\": \"Iniciar o streaming\",\n        \"Token\": \"Token\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Configure a transmissão de eventos de auditoria do Grist para um sistema externo de gerenciamento de eventos e informações de segurança (SIEM), como o Splunk. {{learnMoreLink}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit Logs\": \"Registros de auditoria\",\n        \"Log streaming\": \"Fluxo de registros\",\n        \"Only site owners may access audit logs.\": \"Somente os proprietários do site podem acessar os registros de auditoria.\",\n        \"Contact us\": \"Entre em contato conosco\",\n        \"Home\": \"Início\",\n        \"Audit logs for {{siteName}}\": \"Registros de auditoria para {{siteName}}\",\n        \"upgrade your plan\": \"atualize seu plano\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Você pode configurar a transmissão de eventos de auditoria do Grist para um sistema externo de gerenciamento de eventos e informações de segurança (SIEM) se ativar o Grist Enterprise. {{contactUsLink}} para saber mais.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Você pode configurar a transmissão de eventos de auditoria do Grist para um sistema SIEM (gerenciamento de eventos e informações de segurança) externo se {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"All\": \"Todo\",\n        \"Access details\": \"Detalhes de Acesso\",\n        \"Current workspace\": \"Área de trabalho atual\",\n        \"Delete\": \"Eliminar\",\n        \"Delete {{name}}\": \"Excluir {{name}}\",\n        \"Document will be moved to Trash.\": \"O documento será movido pra Lixeira.\",\n        \"Name\": \"Nome\",\n        \"Pin\": \"Fixar\",\n        \"Move {{name}} to workspace\": \"Mover {{name}} para a área de trabalho\",\n        \"No documents to show.\": \"Não há documentos para mostrar.\",\n        \"Sort by date\": \"Ordenar por data\",\n        \"Recent\": \"Recente\",\n        \"Rename and set icon\": \"Renomear e definir o ícone\",\n        \"Workspace\": \"Área de Trabalho\",\n        \"Edited {{at}}\": \"{{at}} editado\",\n        \"Last edited\": \"Última edição\",\n        \"Pinned\": \"Fixado\",\n        \"Requires edit permissions\": \"Requer permissões de edição\",\n        \"Sort by name\": \"Ordenar por Nome\",\n        \"Unpin\": \"Liberar\",\n        \"Manage users\": \"Gerenciar Usuarios\",\n        \"Move\": \"Mover\",\n        \"context menu - {{- documentName }}\": \"menu de contexto - {{- documentName }}\",\n        \"Documents list\": \"Lista de documentos\"\n    },\n    \"RenameDocModal\": {\n        \"Choose icon\": \"Escolha o ícone\",\n        \"Enter document name\": \"Digite o nome do documento\",\n        \"Choose color\": \"Escolha a cor\",\n        \"Rename and set icon\": \"Renomear e definir o ícone\",\n        \"Reset icon\": \"Repor ícone\",\n        \"Icon\": \"Ícone\",\n        \"Name\": \"Nome\"\n    },\n    \"RightPanelUtils\": {\n        \"columns_one\": \"Coluna\",\n        \"columns_other\": \"Colunas\",\n        \"fields_one\": \"Campo\",\n        \"fields_other\": \"campos\",\n        \"series_one\": \"Séries\",\n        \"series_other\": \"Séries\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Tenha cuidado com widgets personalizados desconhecidos\",\n        \"Have you **reviewed the code** at this URL?\": \"Você **revisou o código** neste URL?\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Confirmo que entendi esses avisos e aceito os riscos\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Você tem certeza de que **confia no recurso** desse URL?\",\n        \"Please review the following before adding a new custom widget.\": \"Por favor, reveja o seguinte antes de adicionar um novo widget personalizado.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Os widgets personalizados são **poderosos**! Eles podem ler e gravar os dados de seu documento e enviá-los para outro lugar.\",\n        \"Do you **trust the person** who shared this link?\": \"Você **confia na pessoa** que compartilhou esse link?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Em caso de dúvida, não instale esse widget ou peça a um administrador de sua organização para revisá-lo quanto à segurança.\"\n    },\n    \"AdminLeftPanel\": {\n        \"Installation\": \"Instalação\",\n        \"Learn more\": \"Saiba mais\",\n        \"Admin Controls\": \"Controles administrativos\",\n        \"Settings\": \"Configurações\",\n        \"Docs\": \"Documentos\",\n        \"Admin controls\": \"Controles administrativos\",\n        \"Admin area\": \"Área administrativa\",\n        \"Orgs\": \"Organizações\",\n        \"Users\": \"Usuários\",\n        \"Workspaces\": \"Áreas de Trabalho\"\n    },\n    \"Assistant\": {\n        \"AI Assistant is only available for logged in users.\": \"O Assistente de IA só está disponível para usuários conectados.\",\n        \"Apply\": \"Aplicar\",\n        \"Press Enter to apply suggested formula.\": \"Pressione Enter para aplicar a fórmula sugerida.\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Registre-se gratuitamente no Grist para começar a usar o Assistente IA.\",\n        \"What do you need help with?\": \"Em que você precisa de ajuda?\",\n        \"You have {{numCredits}} remaining credits.\": \"Você tem {{numCredits}} créditos restantes.\",\n        \"start a new chat\": \"iniciar um novo chat\",\n        \"upgrade to the Pro Team plan\": \"atualize para o plano Pro Team\",\n        \"upgrade your plan\": \"Atualize seu plano\",\n        \"For higher limits, contact the site owner.\": \"Para limites maiores, entre em contato com o proprietário do site.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Para limites maiores, {{upgradeNudge}}.\",\n        \"Learn more.\": \"Saiba mais.\",\n        \"Sign Up for Free\": \"Cadastre-se gratuitamente\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Atualize para a Grist Enterprise para experimentar o novo assistente de Grist. {{learnMoreLink}}\",\n        \"You have used all available credits.\": \"Você utilizou todos os créditos disponíveis.\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"A conversa ficou muito longa e não consigo mais responder com eficiência. Acesse {{startANewChatButton}} para continuar recebendo assistência.\"\n    },\n    \"apiconsole\": {\n        \"Type DELETE here if you wish to proceed.\": \"Digite DELETE aqui se você deseja prosseguir.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Digite DELETE se você tem certeza de que você realmente deseja fazer esta exclusão.\\nSe você não tem certeza, ou não entende o que esta operação vai fazer,\\nseria sensato cancelar.\",\n        \"Confirm Deletion\": \"Confirmar exclusão\",\n        \"Are you sure you want to delete the following?\": \"Tem certeza de que deseja excluir o seguinte?\",\n        \"Delete\": \"Eliminar\",\n        \"Deletion was not confirmed, skipping.\": \"A exclusão não foi confirmada, pulando.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"sem acesso\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Há uma atualização crítica do Grist disponível.\\nConsidere fazer o upgrade para a versão {{version}} assim que possível.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Sua versão Grist está desatualizada.\\nConsidere a atualização para a versão {{version}} o mais rápido possível.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Recomendação: {{storageRecommendation}}\\nAo armazenar anexos grandes, ou muitos deles, recomendamos\\nmantê-los em um armazenamento externo. Atualmente, este documento está\\nusando o armazenamento interno para anexos, o que o mantém\\nautônomo, mas pode limitar o desempenho.\",\n        \"Set the document to use external storage.\": \"Defina o documento para usar o armazenamento externo.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Por favor, espere que a operação anterior seja concluída.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Tempo limite de espera para que o back-end do Grist seja reiniciado\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Desativar recurso\",\n        \"Don't worry, you can disable it later if needed.\": \"Não se preocupe, você pode desabilitá-lo mais tarde, se necessário.\",\n        \"Enable feature\": \"Ativar recurso\",\n        \"Experimental feature\": \"Recurso experimental\",\n        \"New record button\": \"Botão Novo registro\",\n        \"Reload the page\": \"Recarregar a página\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Visite este URL a qualquer momento para parar de usar este recurso: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Você está prestes a desativar este recurso experimental: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Você está prestes a ativar este recurso experimental: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} desativado.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} activado.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Novo cartão\",\n        \"New record\": \"Novo registro\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Está tentando acessar o painel do criador? Use {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Duplicar widget\",\n        \"Duplicate widgets\": \"Widgets duplicados\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Carregando, aguarde…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Adicionar\",\n        \"Delete\": \"Excluir\",\n        \"Download\": \"Baixar\",\n        \"Drop files here to attach\": \"Solte os arquivos aqui para anexá-los\",\n        \"Drop files here to attach.\": \"Solte arquivos aqui para anexar.\",\n        \"No attachments\": \"Sem anexos\",\n        \"Preview not available.\": \"Pré-visualização não disponível.\",\n        \"{{index}} of {{total}}\": \"{{index}} de {{total}}\",\n        \"Uploading…\": \"Carregando…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Expandir todas as linhas para essa altura\",\n        \"Max height\": \"Altura máxima\",\n        \"Max row height\": \"Altura máxima da linha\",\n        \"Change\": \"Mudança\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Recolher\",\n        \"Expand\": \"Expandir\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"usuário ativo\",\n        \"active user list\": \"lista de usuários ativos\",\n        \"open full active user list\": \"abrir a lista completa de usuários ativos\"\n    },\n    \"AdminChecks\": {\n        \"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 permite fórmulas muito poderosas, usando Python. Recomendamos definir a variável de ambiente GRIST_SANDBOX_FLAVOR para gvisor se o seu hardware o suporta (a maioria suportará), para executar fórmulas em cada documento dentro de uma caixa de areia isolada de outros documentos e isolada da rede.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"O Grist tem uma pequena verificação de integridade embutida, geralmente usada ao executá-lo como um recipiente.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"As solicitações que chegam ao Grist devem ter um cabeçalho Host preciso. Isso é essencial quando GRIST_SERVE_SAME_ORIGIN está definido.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"Essa página de inicialização não deve ser muito fácil de acessar. Desative-a quando a configuração estiver correta (desmarcando GRIST_BOOT_KEY) ou torne GRIST_BOOT_KEY longa e criptograficamente segura.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"As conexões Websocket precisam do HTTP 1.1 e da capacidade de passar alguns cabeçalhos extras para funcionar. Às vezes, um proxy reverso pode interferir nesses requisitos.\",\n        \"It is good practice not to run Grist as the root user.\": \"É uma boa prática não executar o Grist como usuário root.\",\n        \"The main page of Grist should be available.\": \"A página principal do Grist deve estar disponível.\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"+{{count}} mais\",\n        \"+{{count}} more_other\": \"+{{count}} mais\",\n        \"Edit\": \"Editar\",\n        \"No choices configured\": \"Nenhuma opção configurada\",\n        \"Reset\": \"Restabelecer\",\n        \"Cancel\": \"Cancelar\",\n        \"Save\": \"Guardar\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Aplicar\",\n        \"Cancel\": \"Cancelar\",\n        \"Preview\": \"Prévisualizar\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Fechar\",\n        \"Update preview\": \"Atualizar a prévisualização\",\n        \"Convert quoted fields\": \"Converta campos citados\",\n        \"Escape character\": \"Caractere de escape\",\n        \"Field separator\": \"Separador de campo\",\n        \"First row contains headers\": \"A primeira linha contém cabeçalhos\",\n        \"Line terminator\": \"Terminador de linha\",\n        \"Number of rows\": \"Número de linhas\",\n        \"Quote character\": \"Caracterer de citação\",\n        \"Quotes in fields are doubled\": \"As citações em campos são duplicadas\",\n        \"Skip leading whitespace\": \"Ignorar espaços em branco à esquerda\",\n        \"Start with row\": \"Comece com a linha\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Codificação de caracteres. Consulte [os codecs compatíveis]({{link}})\"\n    },\n    \"GetGristComProvider\": {\n        \"When signing in, users will be redirected to the getgrist.com login page to log in or register. After authenticating on getgrist.com, they'll be redirected back to your Grist server and signed in as the user they authenticated as.\": \"Ao fazer login, os usuários serão redirecionados para a página de login do getgrist.com para entrar ou se cadastrar. Após a autenticação no getgrist.com, eles serão redirecionados de volta para o seu servidor Grist e farão login com a conta do usuário que utilizaram para autenticação.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/pt_BR.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Verifique seu e-mail com o provedor de identidade e faça login novamente.\"\n    },\n    \"sendAppPage\": {\n        \"Loading...\": \"Carregando...\",\n        \"og-description\": \"Uma planilha moderna e de código aberto que vai além da grade\",\n        \"og-title\": \"Grist, a evolução das planilhas eletrônicas\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"Você não tem acesso a este documento.\",\n        \"docDisabled\": \"Este documento está desativado.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"Nenhum proprietário encontrado na organização administrativa definida por `GRIST_INSTALL_ADMIN_ORG={org}} \\\"\",\n        \"orgUser\": \"O usuário é proprietário da organização de administração definida por `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"accountByEmail\": \"Conta de administrador definida por `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Documento sem título\"\n    }\n}\n"
  },
  {
    "path": "static/locales/ro.client.json",
    "content": "{\n  \"ApiKey\": {\n    \"Remove API Key\": \"Eliminați cheia API\",\n    \"Click to show\": \"Faceți clic pentru a afișa\",\n    \"This API key can be used to access this account anonymously via the API.\": \"Această cheie API poate fi utilizată pentru a accesa acest cont în mod anonim prin intermediul API-ului.\",\n    \"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?\": \"Sunteți pe cale să ștergeți o cheie API. Acest lucru va face ca toate solicitările viitoare care utilizează această cheie API să fie respinse. Mai vreți să ștergeți?\",\n    \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Această cheie API poate fi utilizată pentru a vă accesa contul prin intermediul API-ului. Nu partajați cheia dvs. API cu nimeni.\",\n    \"Create\": \"Creează\",\n    \"Remove\": \"Elimină\",\n    \"By generating an API key, you will be able to make API calls for your own account.\": \"Prin generarea unei chei API, veți putea efectua apeluri API pentru contul dvs.\"\n  },\n  \"ChartView\": {\n    \"Each Y series is followed by a series for the length of error bars.\": \"Fiecare serie Y este urmată de o serie pentru lungimea barelor de eroare.\",\n    \"Pick a column\": \"Alegeți o coloană\",\n    \"Toggle chart aggregation\": \"Comutați agregarea diagramelor\",\n    \"Each Y series is followed by two series, for top and bottom error bars.\": \"Fiecare serie Y este urmată de două serii, pentru barele de eroare de sus și de jos.\",\n    \"Create separate series for each value of the selected column.\": \"Creați serii separate pentru fiecare valoare a coloanei selectate.\",\n    \"selected new group data columns\": \"selectate noi coloane de date de grup\",\n    \"Bar chart\": \"Diagramă cu bare\",\n    \"Pie chart\": \"Diagramă circulară\",\n    \"Area chart\": \"Diagramă de suprafață\",\n    \"Line chart\": \"Diagramă cu linii\",\n    \"Kaplan-Meier plot\": \"Diagrama Kaplan-Meier\",\n    \"Orientation\": \"Orientare\",\n    \"Show total\": \"Afișați totalul\",\n    \"Text size\": \"Mărimea textului\",\n    \"Split series\": \"Serie separată\",\n    \"Error bars\": \"Bare de eroare\",\n    \"Donut chart\": \"Diagramă cu gogoși\",\n    \"Symmetric\": \"Simetric\",\n    \"Scatter plot\": \"Diagramă de dispersie\",\n    \"Remove\": \"Elimină\",\n    \"LABEL\": \"ETICHETA\",\n    \"Vertical\": \"Vertical\",\n    \"Horizontal\": \"Orizontal\",\n    \"Show markers\": \"Afișați marcajele\",\n    \"Invert Y-axis\": \"Inversați axa Y\",\n    \"Hole size\": \"Dimensiunea găurii\",\n    \"Connect gaps\": \"Conectați golurile\",\n    \"X-AXIS\": \"Axa X\",\n    \"SERIES\": \"SERIE\",\n    \"None\": \"Nici unul\",\n    \"Above+Below\": \"Deasupra+Jos\",\n    \"Aggregate values\": \"Valori agregate\",\n    \"selected new x-axis\": \"noua axă x selectată\",\n    \"Add series\": \"Adăugați o serie\",\n    \"non-numeric columns are not shown\": \"Coloanele non-numerice nu sunt afișate\",\n    \"non-numeric column is not shown\": \"coloana non-numerică nu este afișată\"\n  },\n  \"ColumnFilterMenu\": {\n    \"Search values\": \"Căutați valori\",\n    \"All shown\": \"Toate sunt afișate\",\n    \"Other Matching\": \"Alte potriviri\",\n    \"All except\": \"Toate, cu excepția\",\n    \"Start\": \"Început\",\n    \"Other Non-Matching\": \"Alte care nu se potrivesc\",\n    \"No matching values\": \"Nu există valori care se potrivesc\",\n    \"End\": \"Sfârşit\",\n    \"Search\": \"Căutare\",\n    \"Max\": \"Max\",\n    \"Others\": \"Altele\",\n    \"Other values\": \"Alte valori\",\n    \"Min\": \"Min\",\n    \"All\": \"Toate\",\n    \"Future values\": \"Valori viitoare\",\n    \"Filter by Range\": \"Filtrați după interval\",\n    \"None\": \"Nici unul\"\n  },\n  \"DocHistory\": {\n    \"Compare to current\": \"Comparați cu documentul actual\",\n    \"Open snapshot\": \"Deschideți instantaneu\",\n    \"Snapshots are unavailable.\": \"Instantaneele nu sunt disponibile.\",\n    \"Snapshots\": \"Instantanee\",\n    \"Activity\": \"Activitate\",\n    \"Compare to previous\": \"Comparați cu precedentul\",\n    \"Beta\": \"Beta\",\n    \"Only owners have access to snapshots for documents with access rules.\": \"Doar proprietarii au acces la instantanee pentru documentele cu reguli de acces.\"\n  },\n  \"AccessRules\": {\n    \"Permission to access the document in full when needed\": \"Permisiune de a accesa documentul în întregime atunci când este necesar\",\n    \"Add column rule\": \"Adăugați o regulă de coloană\",\n    \"Reset\": \"Resetați\",\n    \"Lookup Column\": \"Coloana de căutare\",\n    \"Remove {{- tableId }} rules\": \"Înlocuiește regulile {{- tableId }}\",\n    \"Remove {{- name }} user attribute\": \"Eliminați atributul de utilizator {{- name }}\",\n    \"Permissions\": \"Permisiuni\",\n    \"Enter Condition\": \"Introduceți o condiție\",\n    \"Everyone Else\": \"Toți ceilalți\",\n    \"Saved\": \"Salvat\",\n    \"Remove column {{- colId }} from {{- tableId }} rules\": \"Eliminați coloana {{- colId }} din regulile {{- tableId }}\",\n    \"Allow everyone to view Access Rules.\": \"Permiteți tuturor să vadă Regulile de acces.\",\n    \"Type message to display when this rule blocks an action…\": \"Scrie un mesaj…\",\n    \"Lookup Table\": \"Tabel de căutare\",\n    \"View as\": \"Vizualizare ca\",\n    \"Add table rules\": \"Adăugați reguli pentru tabel\",\n    \"Invalid\": \"Nu este valid\",\n    \"Condition\": \"Condiție\",\n    \"Delete table rules\": \"Ștergeți regulile tabelului\",\n    \"Permission to view Access Rules\": \"Permisiune de a vizualiza regulile de acces\",\n    \"User Attributes\": \"Atributele utilizatorului\",\n    \"Default rules\": \"Reguli implicite\",\n    \"Attribute name\": \"Numele atributului\",\n    \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Când adăugați reguli de tabel, adăugați automat o regulă pentru a acorda PROPRIETARULUI acces complet.\",\n    \"Add user attributes\": \"Adăugați atribute de utilizator\",\n    \"Permission to edit document structure\": \"Permisiune de a edita structura documentului\",\n    \"Attribute to Look Up\": \"Atribut pentru căutare\",\n    \"Seed rules\": \"Reguli predefinite\",\n    \"Everyone\": \"Toată lumea\",\n    \"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.\": \"Permiteți tuturor să copieze întregul document sau să-l vizualizeze integral în modul lăutăresc.\\nUtil pentru exemple și șabloane, dar nu pentru date sensibile.\",\n    \"Add Default Rule\": \"Adăugați o regulă implicită\",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Permite editorilor să editeze structura (de exemplu, să modifice și să șteargă tabele, coloane, machete) și să scrie formule, care oferă acces la toate datele, indiferent de restricțiile de citire.\",\n    \"This default should be changed if editors' access is to be limited. \": \"Această valoare implicită ar trebui schimbată dacă accesul editorilor trebuie să fie limitat. \",\n    \"Save\": \"Salvați\",\n    \"Rules for table \": \"Reguli pentru tabel \",\n    \"Checking...\": \"Control…\",\n    \"Special rules\": \"Reguli speciale\"\n  },\n  \"AccountPage\": {\n    \"Theme\": \"Temă\",\n    \"API\": \"API\",\n    \"Change password\": \"Schimbaţi parola\",\n    \"Email\": \"E-mail\",\n    \"Password & security\": \"Parolă și securitate\",\n    \"Account settings\": \"Setările contului\",\n    \"Two-factor authentication\": \"Autentificare cu doi factori\",\n    \"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.\": \"Autentificarea cu doi factori este un nivel suplimentar de securitate pentru contul dvs. Grist, conceput pentru a vă asigura că sunteți singura persoană care vă poate accesa contul, chiar dacă cineva vă cunoaște parola.\",\n    \"Language\": \"Limba\",\n    \"Edit\": \"Editați\",\n    \"Names only allow letters, numbers and certain special characters\": \"Numele permit doar litere, cifre și anumite caractere speciale\",\n    \"Login method\": \"Metoda de conectare\",\n    \"API Key\": \"Cheia API\",\n    \"Name\": \"Nume\",\n    \"Allow signing in to this account with Google\": \"Permiteți conectarea la acest cont cu Google\",\n    \"Save\": \"Salvați\"\n  },\n  \"CellContextMenu\": {\n    \"Copy anchor link\": \"Copiați linkul de ancorare\",\n    \"Delete {{count}} rows_one\": \"Ștergeți rândul\",\n    \"Insert row below\": \"Inserează rând mai jos\",\n    \"Reset {{count}} entire columns_other\": \"Resetați {{count}} coloane întregi\",\n    \"Insert row\": \"Inserați rând\",\n    \"Copy\": \"Copiază\",\n    \"Delete {{count}} columns_one\": \"Ștergeți coloana\",\n    \"Delete {{count}} columns_other\": \"Ștergeți {{count}} coloane\",\n    \"Duplicate rows_one\": \"Rând duplicat\",\n    \"Insert row above\": \"Inserează rând deasupra\",\n    \"Delete {{count}} rows_other\": \"Ștergeți {{count}} rânduri\",\n    \"Clear values\": \"Șterge valorile\",\n    \"Clear cell\": \"Șterge celula\",\n    \"Comment\": \"Comentariu\",\n    \"Duplicate rows_other\": \"Rânduri duplicate\",\n    \"Reset {{count}} columns_one\": \"Resetează coloana\",\n    \"Insert column to the right\": \"Inserați coloana la dreapta\",\n    \"Filter by this value\": \"Filtrați după această valoare\",\n    \"Cut\": \"Taie\",\n    \"Reset {{count}} columns_other\": \"Resetați {{count}} coloane\",\n    \"Reset {{count}} entire columns_one\": \"Resetați întreaga coloană\",\n    \"Insert column to the left\": \"Inserați coloana la stânga\",\n    \"Paste\": \"Lipește\",\n    \"Copy with headers\": \"Copiați cu anteturi\"\n  },\n  \"AccountWidget\": {\n    \"Add account\": \"Adaugă cont\",\n    \"Switch Accounts\": \"Schimbă conturile\",\n    \"Activation\": \"Activare\",\n    \"Toggle Mobile Mode\": \"Comutați modul mobil\",\n    \"Support Grist\": \"Sprijină Grist\",\n    \"Access Details\": \"Detalii de acces\",\n    \"Upgrade Plan\": \"Plan de upgrade\",\n    \"Sign out\": \"Deconectare\",\n    \"Profile settings\": \"Setările profilului\",\n    \"Sign in\": \"Acces\",\n    \"Pricing\": \"Prețuri\",\n    \"Document settings\": \"Setări document\",\n    \"Use This Template\": \"Utilizați acest șablon\",\n    \"Manage team\": \"Gestionează echipa\",\n    \"Billing account\": \"Cont de facturare\",\n    \"Accounts\": \"Conturi\",\n    \"Sign up\": \"Înscriere\"\n  },\n  \"ACUserManager\": {\n    \"Invite new member\": \"Invitați un membru nou\",\n    \"We'll email an invite to {{email}}\": \"Vom trimite prin e-mail o invitație către {{email}}\",\n    \"Enter email address\": \"Introduceți adresa de e-mail\"\n  },\n  \"CustomSectionConfig\": {\n    \"Open configuration\": \"Deschideți configurația\",\n    \"Pick a {{columnType}} column\": \"Alegeți o coloană {{columnType}}\",\n    \"Widget needs {{fullAccess}} to this document.\": \"Widgetul are nevoie de {{fullAccess}} pentru acest document.\",\n    \"Enter Custom URL\": \"Introduceți adresa URL personalizată\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"Coloana {{wrongTypeCount}} non-{{columnType}} nu este afișată\",\n    \"Select Custom Widget\": \"Selectați Widget personalizat\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} coloane non-{{columnType}} nu sunt afișate\",\n    \"Learn more about custom widgets\": \"Aflați mai multe despre widget-urile personalizate\",\n    \"Add\": \"Adaugă\",\n    \" (optional)\": \" (opțional)\",\n    \"No {{columnType}} columns in table.\": \"Nu există {{columnType}} coloane în tabel.\",\n    \"Read selected table\": \"Citiți tabelul selectat\",\n    \"Full document access\": \"Acces complet la documente\",\n    \"Widget needs to {{read}} the current table.\": \"Widgetul trebuie să {{read}} tabelul curent.\",\n    \"Pick a column\": \"Alegeți o coloană\",\n    \"Clear selection\": \"Anulează selecția\",\n    \"No document access\": \"Fără acces la documente\",\n    \"Widget does not require any permissions.\": \"Widgetul nu necesită nicio permisiune.\",\n    \"Accept\": \"Accepta\",\n    \"Custom URL\": \"URL personalizat\",\n    \"Developer:\": \"Dezvoltator:\",\n    \"Last updated:\": \"Ultima actualizare:\",\n    \"Missing description and author information.\": \"Lipsesc descrierea și informațiile despre autor.\",\n    \"Reject\": \"Respinge\",\n    \"ACCESS LEVEL\": \"NIVEL DE ACCES\"\n  },\n  \"DocMenu\": {\n    \"Discover More Templates\": \"Descoperiți mai multe șabloane\",\n    \"Current workspace\": \"Spațiul de lucru actual\",\n    \"Edited {{at}}\": \"Editat {{at}}\",\n    \"By Date Modified\": \"După data modificării\",\n    \"Delete Forever\": \"Șterge pentru totdeauna\",\n    \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Documentele rămân în Coșul de gunoi timp de 30 de zile, după care sunt șterse definitiv.\",\n    \"Access Details\": \"Detalii de acces\",\n    \"Deleted {{at}}\": \"S-a șters {{at}}\",\n    \"All documents\": \"Toate documentele\",\n    \"Delete {{name}}\": \"Ștergeți {{name}}\",\n    \"Examples and Templates\": \"Exemple și Șabloane\",\n    \"Delete\": \"Șterge\",\n    \"Document will be permanently deleted.\": \"Documentul va fi șters definitiv.\",\n    \"(The organization needs a paid plan)\": \"(Organizația are nevoie de un plan plătit)\",\n    \"By Name\": \"După nume\",\n    \"Examples & Templates\": \"Exemple & Șabloane\",\n    \"Document will be moved to Trash.\": \"Documentul va fi mutat în Coșul de gunoi.\",\n    \"This service is not available right now\": \"Acest serviciu nu este disponibil momentan\",\n    \"Workspace not found\": \"Spațiul de lucru nu a fost găsit\",\n    \"Pin Document\": \"Fixați documentul\",\n    \"Remove\": \"Elimină\",\n    \"Rename\": \"Redenumiți\",\n    \"Move\": \"Mută\",\n    \"Trash is empty.\": \"Coșul de gunoi este gol.\",\n    \"Unpin Document\": \"Anulați fixarea documentului\",\n    \"Requires edit permissions\": \"Necesită permisiuni de editare\",\n    \"More Examples and Templates\": \"Mai multe exemple și șabloane\",\n    \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Sunteți în spaţiul {{siteName}}. De asemenea, aveți acces la următoarele spaţii:\",\n    \"Other Sites\": \"Alte spaţii\",\n    \"Pinned Documents\": \"Documente fixate\",\n    \"Featured\": \"Recomandat\",\n    \"Manage users\": \"Gestionare utilizatori\",\n    \"To restore this document, restore the workspace first.\": \"Pentru a restaura acest document, mai întâi restaurați spațiul de lucru.\",\n    \"You may delete a workspace forever once it has no documents in it.\": \"Puteți șterge pentru totdeauna un spațiu de lucru odată ce nu are documente în el.\",\n    \"Trash\": \"Gunoi\",\n    \"You are on your personal site. You also have access to the following sites:\": \"Ești în spaţiul tău personal. De asemenea, aveți acces la următoarele spaţii:\",\n    \"Restore\": \"Restaurează\",\n    \"Move {{name}} to workspace\": \"Mutați {{name}} în spațiul de lucru\",\n    \"Permanently Delete \\\"{{name}}\\\"?\": \"Ștergeți definitiv „{{name}}”?\",\n    \"Create my first document\": \"Creează primul meu document\",\n    \"Any documents created in this site will appear here.\": \"Orice documente create pe acest site vor apărea aici.\"\n  },\n  \"ActionLog\": {\n    \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Coloana {{colId}} a fost eliminată ulterior în acțiunea #{{action.actionNum}}\",\n    \"Action Log failed to load\": \"Jurnalul de acțiuni nu s-a încărcat\",\n    \"This row was subsequently removed in action {{action.actionNum}}\": \"Acest rând a fost eliminat ulterior în acțiunea {{action.actionNum}}\",\n    \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Tabelul {{tableId}} a fost ulterior eliminat în acțiunea #{{actionNum}}\",\n    \"All tables\": \"Toate tabelele\"\n  },\n  \"DataTables\": {\n    \"Edit record card\": \"Editați cardul de înregistrare\",\n    \"Rename table\": \"Redenumiți tabelul\",\n    \"{{action}} Record Card\": \"{{action}} Card de înregistrare\",\n    \"Raw Data Tables\": \"Tabele cu date brute\",\n    \"Duplicate table\": \"Tabel duplicat\",\n    \"You do not have edit access to this document\": \"Nu aveți acces de editare la acest document\",\n    \"Record Card\": \"Card de înregistrare\",\n    \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Ștergeți datele {{formattedTableName}} și le eliminați din toate paginile?\",\n    \"Click to copy\": \"Faceți clic pentru a copia\",\n    \"Remove table\": \"Eliminați tabelul\",\n    \"Record Card Disabled\": \"Card de înregistrare dezactivat\",\n    \"Table ID copied to clipboard\": \"ID-ul tabelului a fost copiat în clipboard\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"Aplică\",\n    \"Cancel\": \"Anulează\",\n    \"Default cell style\": \"Stilul de celulă implicit\"\n  },\n  \"AppHeader\": {\n    \"Personal Site\": \"Site personal\",\n    \"Home page\": \"Pagina principală\",\n    \"Team Site\": \"Spaţiul echipei\",\n    \"Legacy\": \"Versiune veche\",\n    \"Grist Templates\": \"Șabloane Grist\",\n    \"Manage team\": \"Gestionează echipa\",\n    \"Billing account\": \"Cont de facturare\"\n  },\n  \"ViewAsDropdown\": {\n    \"View as\": \"Vizualizare ca\",\n    \"Example Users\": \"Exemplu de utilizatori\",\n    \"Users from table\": \"Utilizatori din acest tabel\"\n  },\n  \"App\": {\n    \"Description\": \"Descriere\",\n    \"Memory Error\": \"Eroare de memorie\",\n    \"Key\": \"Cheie\",\n    \"Translators: please translate this only when your language is ready to be offered to users\": \"Traducători: vă rugăm să traduceți acest text numai atunci când limba dvs. este pregătită pentru a fi oferită utilizatorilor\"\n  },\n  \"CodeEditorPanel\": {\n    \"Code View is available only when you have full document access.\": \"Vizualizarea codului este disponibilă numai atunci când aveți acces complet la documente.\",\n    \"Access denied\": \"Acces interzis\"\n  },\n  \"AddNewButton\": {\n    \"Add new\": \"Adăugare\"\n  },\n  \"AppModel\": {\n    \"This team site is suspended. Documents can be read, but not modified.\": \"Acest spaţiu al echipei este suspendat. Documentele pot fi citite, dar nu modificate.\"\n  },\n  \"DocPageModel\": {\n    \"Add empty table\": \"Adăugați un tabel gol\",\n    \"Add widget to page\": \"Adăugați widget pe pagină\",\n    \"Add page\": \"Adăugați pagina\",\n    \"Document owners can attempt to recover the document. [{{error}}]\": \"Proprietarii de documente pot încerca să recupereze documentul. [{{error}}]\",\n    \"Enter recovery mode\": \"Intrați în modul de recuperare\",\n    \"Error accessing document\": \"Eroare la accesarea documentului\",\n    \"Sorry, access to this document has been denied. [{{error}}]\": \"Ne pare rău, accesul la acest document a fost refuzat. [{{error}}]\",\n    \"You do not have edit access to this document\": \"Nu aveți acces de editare la acest document\",\n    \"Reload\": \"Reîncărcați\",\n    \"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}}]\": \"Puteți încerca să reîncărcați documentul sau să utilizați modul de recuperare. Modul de recuperare deschide documentul pentru a fi pe deplin accesibil proprietarilor și inaccesibil pentru alții. De asemenea, dezactivează formulele. [{{error}}]\"\n  },\n  \"breadcrumbs\": {\n    \"override\": \"suprascrie\",\n    \"unsaved\": \"nesalvat\",\n    \"fiddle\": \"experimentează\",\n    \"recovery mode\": \"mod de recuperare\",\n    \"snapshot\": \"instantaneu\",\n    \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Puteți face modificări, dar va fi creată o nouă copie și \\naceste modificări nu vor afecta documentul original.\"\n  },\n  \"HomeLeftPane\": {\n    \"All documents\": \"Toate documentele\",\n    \"Manage users\": \"Gestionare utilizatori\",\n    \"Tutorial\": \"Tutorial\",\n    \"Delete {{workspace}} and all included documents?\": \"Ștergeți {{workspace}} și toate documentele incluse?\",\n    \"Create empty document\": \"Creați un document gol\",\n    \"Create workspace\": \"Creați spațiu de lucru\",\n    \"Import document\": \"Import document\",\n    \"Access Details\": \"Detalii de acces\",\n    \"Rename\": \"Redenumiți\",\n    \"Trash\": \"Gunoi\",\n    \"Workspaces\": \"Spații de lucru\",\n    \"Workspace will be moved to Trash.\": \"Spațiul de lucru va fi mutat în Coșul de gunoi.\",\n    \"Examples & Templates\": \"Șabloane\",\n    \"Delete\": \"Șterge\"\n  },\n  \"RowContextMenu\": {\n    \"Insert row\": \"Inserați rând\",\n    \"Insert row below\": \"Introduceți rândul de mai jos\",\n    \"Delete\": \"Șterge\",\n    \"View as card\": \"Vedeți ca un card\",\n    \"Copy anchor link\": \"Copiați linkul de ancorare\",\n    \"Duplicate rows_one\": \"Rând duplicat\",\n    \"Duplicate rows_other\": \"Rânduri duplicate\",\n    \"Insert row above\": \"Introduceți rândul de deasupra\"\n  },\n  \"Drafts\": {\n    \"Undo discard\": \"Anulați eliminarea\",\n    \"Restore last edit\": \"Restaurați ultima editare\"\n  },\n  \"FormulaAssistant\": {\n    \"Data\": \"Date\",\n    \"Press Enter to apply suggested formula.\": \"Apăsați Enter pentru a aplica formula sugerată.\",\n    \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Consultați {{helpFunction}} și {{formulaCheat}} sau vizitați {{community}} pentru mai mult ajutor.\",\n    \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Înscrieți-vă pentru un cont Grist gratuit pentru a începe să utilizați Asistentul AI pentru formule.\",\n    \"Clear conversation\": \"Anulează conversația\",\n    \"New Chat\": \"Chat nou\",\n    \"Code view\": \"Vizualizare cod\",\n    \"Apply\": \"Aplică\",\n    \"Learn more\": \"Află mai multe\",\n    \"Regenerate\": \"Regenerează\",\n    \"Community\": \"Comunitate\",\n    \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Pot să ajut doar cu formule. Nu pot crea tabele, coloane și vizualizări sau nu pot scrie reguli de acces.\",\n    \"Hi, I'm the Grist Formula AI Assistant.\": \"Bună, sunt asistentul AI Grist pentru formule.\",\n    \"Preview\": \"Previzualizare\",\n    \"Ask the bot.\": \"Întrebați robotul.\",\n    \"Function List\": \"Lista de funcții\",\n    \"For higher limits, contact the site owner.\": \"Pentru limite mai mari, contactați proprietarul spaţiului.\",\n    \"Tips\": \"Sfaturi\",\n    \"Save\": \"Salvați\",\n    \"Sign Up for Free\": \"Înregistrează-te gratuit\",\n    \"Formula Cheat Sheet\": \"Cheat sheet pentru formule\",\n    \"Grist's AI Assistance\": \"Asistența AI Grist\",\n    \"Formula AI Assistant is only available for logged in users.\": \"Asistentul AI pentru formule este disponibil numai pentru utilizatorii conectați.\",\n    \"Grist's AI Formula Assistance. \": \"Asistență AI pentru formulele Grist. \",\n    \"upgrade to the Pro Team plan\": \"actualizare la planul Pro Team\",\n    \"You have used all available credits.\": \"Ați folosit toate creditele disponibile.\",\n    \"upgrade your plan\": \"îmbunătățiți-vă planul\",\n    \"Formula Help. \": \"Ajutor la formule. \",\n    \"You have {{numCredits}} remaining credits.\": \"Mai aveți {{numCredits}} credite rămase.\",\n    \"Capabilities\": \"Capabilități\",\n    \"What do you need help with?\": \"La ce ai nevoie de ajutor?\",\n    \"Cancel\": \"Anulare\",\n    \"Need help? Our AI assistant can help.\": \"Nevoie de ajutor? Asistentul nostru AI poate ajuta.\",\n    \"AI Assistant\": \"Asistent AI\",\n    \"For higher limits, {{upgradeNudge}}.\": \"Pentru limite mai mari, {{upgradeNudge}}.\",\n    \"There are some things you should know when working with me:\": \"Sunt câteva lucruri pe care ar trebui să le știi când lucrezi cu mine:\"\n  },\n  \"GridViewMenus\": {\n    \"Unfreeze {{count}} columns_one\": \"Deblocați această coloană\",\n    \"Created by\": \"Creat de\",\n    \"Detect Duplicates in...\": \"Detectează duplicatele în...\",\n    \"UUID\": \"UUID\",\n    \"Shortcuts\": \"Comenzi rapide\",\n    \"Sorted (#{{count}})_one\": \"Sortat (#{{count}})\",\n    \"Unfreeze all columns\": \"Deblocați toate coloanele\",\n    \"Show hidden columns\": \"Afișează coloanele ascunse\",\n    \"Freeze {{count}} columns_other\": \"Înghețați {{count}} coloane\",\n    \"Show column {{- label}}\": \"Afișați coloana {{- label}}\",\n    \"Sort\": \"Ordonează\",\n    \"Column Options\": \"Opțiuni de coloană\",\n    \"Rename column\": \"Redenumiți coloana\",\n    \"Created at\": \"Creat la\",\n    \"Filter Data\": \"Filtrați datele\",\n    \"Delete {{count}} columns_one\": \"Ștergeți coloana\",\n    \"Created At\": \"Creat la\",\n    \"Insert column to the {{to}}\": \"Inserați coloana în {{to}}\",\n    \"Authorship\": \"Autor\",\n    \"Hide {{count}} columns_other\": \"Ascundeți {{count}} coloane\",\n    \"Add formula column\": \"Adăugați coloana cu formule\",\n    \"Add column\": \"Adăugați coloană\",\n    \"Last Updated By\": \"Ultima actualizare de către\",\n    \"Hidden Columns\": \"Coloane ascunse\",\n    \"Lookups\": \"Câmp conex\",\n    \"Reset {{count}} columns_one\": \"Resetează coloana\",\n    \"No reference columns.\": \"Fără coloane de referință.\",\n    \"Freeze {{count}} columns_one\": \"Înghețați această coloană\",\n    \"More sort options ...\": \"Mai multe opțiuni de sortare…\",\n    \"Freeze {{count}} more columns_one\": \"Înghețați încă o coloană\",\n    \"Reset {{count}} entire columns_other\": \"Resetați {{count}} coloane întregi\",\n    \"Apply on record changes\": \"Aplicați modificările înregistrate\",\n    \"Last updated by\": \"Ultima actualizare de către\",\n    \"Reset {{count}} columns_other\": \"Resetați {{count}} coloane\",\n    \"Clear values\": \"Ștergeți valorile\",\n    \"Delete {{count}} columns_other\": \"Ștergeți {{count}} coloane\",\n    \"Duplicate in {{- label}}\": \"Duplicați în {{- label}}\",\n    \"Created By\": \"Creat de\",\n    \"Unfreeze {{count}} columns_other\": \"Deblocați {{count}} coloane\",\n    \"Last Updated At\": \"Ultima actualizare la\",\n    \"Add column with type\": \"Adăugați coloană cu tip\",\n    \"Apply to new records\": \"Aplicați la noi înregistrări\",\n    \"Add to sort\": \"Adăugați pentru a sorta\",\n    \"Insert column to the right\": \"Inserați coloana la dreapta\",\n    \"Search columns\": \"Căutați coloane\",\n    \"Timestamp\": \"Data și ora\",\n    \"no reference column\": \"nicio coloană de referință\",\n    \"Reset {{count}} entire columns_one\": \"Resetați întreaga coloană\",\n    \"Adding UUID column\": \"Adăugarea coloanei UUID\",\n    \"Convert formula to data\": \"Convertiți formula în date\",\n    \"Freeze {{count}} more columns_other\": \"Înghețați încă {{count}} coloane\",\n    \"Adding duplicates column\": \"Adăugarea coloanei duplicate\",\n    \"Hide {{count}} columns_one\": \"Ascundeți coloana\",\n    \"Insert column to the left\": \"Inserați coloana la stânga\",\n    \"Sorted (#{{count}})_other\": \"Sortat (#{{count}})\",\n    \"Detect duplicates in...\": \"Detectează duplicatele în...\",\n    \"Last updated at\": \"Ultima actualizare la\",\n    \"Toggle\": \"Comută\",\n    \"Date\": \"Dată\",\n    \"Numeric\": \"Numeric\",\n    \"Text\": \"Text\",\n    \"Integer\": \"Întreg\",\n    \"Choice\": \"Alege\",\n    \"Reference\": \"Referinţă\",\n    \"Any\": \"Oricare\"\n  },\n  \"RightPanel\": {\n    \"WIDGET TITLE\": \"TITLUL WIDGET-ULUI\",\n    \"COLUMN TYPE\": \"TIP COLOANĂ\",\n    \"SELECT BY\": \"SELECTARE PRIN\",\n    \"Edit data selection\": \"Editați selecția datelor\",\n    \"DATA TABLE NAME\": \"NUME TABEL DE DATE\",\n    \"fields_one\": \"Câmp\",\n    \"Save\": \"Salvați\",\n    \"You do not have edit access to this document\": \"Nu aveți acces de editare la acest document\",\n    \"DATA TABLE\": \"TABEL DE DATE\",\n    \"Theme\": \"Temă\",\n    \"columns_other\": \"Coloane\",\n    \"Data\": \"Date\",\n    \"series_one\": \"Serie\",\n    \"GROUPED BY\": \"GRUPAT DUPĂ\",\n    \"SOURCE DATA\": \"DATE SURSĂ\",\n    \"CHART TYPE\": \"TIP GRAFIC\",\n    \"Detach\": \"Desprinde\",\n    \"Change widget\": \"Schimbați widgetul\",\n    \"columns_one\": \"Coloană\",\n    \"series_other\": \"Serii\",\n    \"fields_other\": \"Câmpuri\",\n    \"Row style\": \"Stil de rând\",\n    \"CUSTOM\": \"PERSONALIZAT\",\n    \"Select widget\": \"Selectați Widget\",\n    \"Add referenced columns\": \"Adăugați coloane la care se face referire\",\n    \"TRANSFORM\": \"TRANSFORMĂ\",\n    \"SELECTOR FOR\": \"SELECTOR PENTRU\",\n    \"Sort & filter\": \"Sortare și filtrare\",\n    \"Widget\": \"Widget\",\n    \"Reset form\": \"Resetare formular\",\n    \"Enter text\": \"Introdu text\",\n    \"Configuration\": \"Configurare\"\n  },\n  \"FloatingPopup\": {\n    \"Maximize\": \"Maximizați\",\n    \"Minimize\": \"Minimizați\"\n  },\n  \"MakeCopyMenu\": {\n    \"Include the structure without any of the data.\": \"Includeți structura fără niciuna dintre date.\",\n    \"Original Looks Unrelated\": \"Originalul nu pare a fi legat\",\n    \"Overwrite\": \"Suprascrie\",\n    \"It will be overwritten, losing any content not in this document.\": \"Acesta va fi suprascris, pierzând orice conținut care nu este în acest document.\",\n    \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Atenție, originalul are modificări nu în acest document. Aceste modificări vor fi suprascrise.\",\n    \"Workspace\": \"Spațiul de lucru\",\n    \"As template\": \"Ca șablon\",\n    \"Cancel\": \"Anulare\",\n    \"Sign up\": \"Înscrie-te\",\n    \"Enter document name\": \"Introduceți numele documentului\",\n    \"Name\": \"Nume\",\n    \"Update\": \"Actualizați\",\n    \"Original Has Modifications\": \"Originalul are modificări\",\n    \"No destination workspace\": \"Fără spațiu de lucru destinație\",\n    \"You do not have write access to the selected workspace\": \"Nu aveți acces de scriere la spațiul de lucru selectat\",\n    \"Download document structure only (no data, for template use)\": \"Eliminați toate datele, dar păstrați structura pentru a o folosi ca șablon\",\n    \"Original Looks Identical\": \"Originalul arată identic\",\n    \"Organization\": \"Organizația\",\n    \"Replacing the original requires editing rights on the original document.\": \"Înlocuirea originalului necesită drepturi de editare asupra documentului original.\",\n    \"Download document without history (can significantly reduce file size)\": \"Eliminați istoricul documentelor (poate reduce semnificativ dimensiunea fișierului)\",\n    \"To save your changes, please sign up, then reload this page.\": \"Pentru a salva modificările, vă rugăm să vă înscrieți, apoi să reîncărcați această pagină.\",\n    \"The original version of this document will be updated.\": \"Versiunea originală a acestui document va fi actualizată.\",\n    \"However, it appears to be already identical.\": \"Cu toate acestea, pare să fie deja identic.\",\n    \"Update Original\": \"Actualizați originalul\",\n    \"You do not have write access to this site\": \"Nu aveți acces de scriere la acest spaţiu\",\n    \"Download document and history\": \"Descărcați documentul complet și istoricul\"\n  },\n  \"GristTooltips\": {\n    \"They allow for one record to point (or refer) to another.\": \"Acestea permit ca o înregistrare să indice (sau să se refere) la alta.\",\n    \"Updates every 5 minutes.\": \"Actualizări la fiecare 5 minute.\",\n    \"entire\": \"întreg\",\n    \"Select the table to link to.\": \"Selectați tabelul la care să faceți legătura.\",\n    \"The total size of all data in this document, excluding attachments.\": \"Dimensiunea totală a tuturor datelor din acest document, cu excepția atașamentelor.\",\n    \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"Un UUID este un șir generat aleatoriu care este util pentru identificatori unici și chei de legătură.\",\n    \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Puteți alege unul dintre widget-urile noastre prefabricate sau puteți încorpora pe al dvs., furnizând adresa URL completă.\",\n    \"Reference Columns\": \"Coloane de referință\",\n    \"To configure your calendar, select columns for start\": {\n      \"end dates and event titles. Note each column's type.\": \"Pentru a vă configura calendarul, selectați coloanele pentru datele de început/sfârșit și titlurile evenimentelor. Notați tipul fiecărei coloane.\"\n    },\n    \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Regulile de acces vă oferă puterea de a crea reguli nuanțate pentru a determina cine poate vedea sau edita ce părți ale documentului dvs.\",\n    \"Rearrange the fields in your card by dragging and resizing cells.\": \"Rearanjați câmpurile din cardul dvs. trăgând și redimensionând celulele.\",\n    \"Calendar\": \"Calendar\",\n    \"You can filter by more than one column.\": \"Puteți filtra după mai multe coloane.\",\n    \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Aplicați formatare condiționată celulelor din această coloană când sunt îndeplinite condițiile formulei.\",\n    \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Pentru a crea un link de ancorare care duce utilizatorul la o anumită celulă, faceți clic pe un rând și apăsați pe {{shortcut}}.\",\n    \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Formulele acceptă multe funcții Excel, sintaxă Python completă și includ un asistent AI util.\",\n    \"Unpin to hide the the button while keeping the filter.\": \"Anulați fixarea pentru a ascunde butonul în timp ce păstrați filtrul.\",\n    \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Util pentru stocarea marcajului temporal sau a autorului unei noi înregistrări, curățarea datelor și multe altele.\",\n    \"Anchor Links\": \"Legături de ancorare\",\n    \"Click the Add new button to create new documents or workspaces, or import data.\": \"Faceți clic pe butonul Adăugare pentru a crea noi documente sau spații de lucru sau pentru a importa date.\",\n    \"Nested Filtering\": \"Filtrare imbricată\",\n    \"relational\": \"relaționale\",\n    \"Apply conditional formatting to rows based on formulas.\": \"Aplicați formatare condiționată la rânduri bazate pe formule.\",\n    \"Add new\": \"Adăugare\",\n    \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Faceți clic pe „Deschideți stiluri de rând” pentru a aplica formatarea condiționată rândurilor.\",\n    \"Pinned filters are displayed as buttons above the widget.\": \"Filtrele fixate sunt afișate ca butoane deasupra widgetului.\",\n    \"Link your new widget to an existing widget on this page.\": \"Conectați-vă noul widget la un widget existent pe această pagină.\",\n    \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Utilizați pictograma \\\\u{1D6BA} pentru a crea tabele rezumative (sau pivot), pentru totaluri sau subtotaluri.\",\n    \"Lookups return data from related tables.\": \"Căutările returnează date din tabelele asociate.\",\n    \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Utilizați pictograma 𝚺 pentru a crea tabele rezumative (sau pivot), pentru totaluri sau subtotaluri.\",\n    \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Puteți alege dintre widget-urile disponibile în meniul drop-down sau puteți să le încorporați pe ale dvs. furnizând adresa URL completă.\",\n    \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Făcând clic pe {{EyeHideIcon}} în fiecare celulă, câmpul ascunde din această vizualizare fără a-l șterge.\",\n    \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Nu găsiți coloanele potrivite? Faceți clic pe „Modificați widgetul” pentru a selecta tabelul cu date despre evenimente.\",\n    \"Only those rows will appear which match all of the filters.\": \"Vor apărea doar acele rânduri care se potrivesc cu toate filtrele.\",\n    \"Custom Widgets\": \"Widgeturi personalizate\",\n    \"This is the secret to Grist's dynamic and productive layouts.\": \"Acesta este secretul layout-urilor dinamice și productive ale lui Grist.\",\n    \"Editing Card Layout\": \"Editarea aspectului cardului\",\n    \"Linking Widgets\": \"Conectarea widgeturilor\",\n    \"Raw Data page\": \"Pagina de date brute\",\n    \"Selecting Data\": \"Selectarea Datelor\",\n    \"Access Rules\": \"Reguli de acces\",\n    \"Learn more.\": \"Află mai multe.\",\n    \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Încercați modificările într-o copie, apoi decideți dacă înlocuiți originalul cu editările dvs.\",\n    \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Pagina Date brute listează toate tabelele de date din documentul dvs., inclusiv tabelele rezumative și tabelele care nu sunt incluse în aspectul paginii.\",\n    \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Formule care se declanșează în anumite cazuri și stochează valoarea calculată ca date.\",\n    \"Select the table containing the data to show.\": \"Selectați tabelul care conține datele de afișat.\",\n    \"Pinning Filters\": \"Fixarea filtrelor\",\n    \"Reference columns are the key to {{relational}} data in Grist.\": \"Coloanele de referință sunt cheia pentru datele {{relational}} din Grist.\",\n    \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Celulele dintr-o coloană de referință identifică întotdeauna o înregistrare {{entire}} în acel tabel, dar puteți selecta ce coloană din înregistrarea respectivă să afișați.\",\n    \"Use reference columns to relate data in different tables.\": \"Utilizați coloanele de referință pentru a lega datele din diferite tabele.\"\n  },\n  \"SortConfig\": {\n    \"Add column\": \"Adăugați o coloană\",\n    \"Natural sort\": \"Ordonare naturală\",\n    \"Search Columns\": \"Căutați coloane\",\n    \"Empty values last\": \"Valorile necompletate ultimele\",\n    \"Update data\": \"Actualizați datele\",\n    \"Use choice position\": \"Utilizați poziția de alegere\"\n  },\n  \"Clipboard\": {\n    \"Unavailable Command\": \"Comanda indisponibilă\",\n    \"Got it\": \"Am înţeles\"\n  },\n  \"SupportGristPage\": {\n    \"You have opted out of telemetry.\": \"Ați renunțat la telemetrie.\",\n    \"Support Grist\": \"Sprijină Grist\",\n    \"Opt out of Telemetry\": \"Renunțați la Telemetrie\",\n    \"GitHub Sponsors page\": \"Pagina de sponsori GitHub\",\n    \"Sponsor Grist Labs on GitHub\": \"Sponsorizează Grist Labs pe GitHub\",\n    \"Manage Sponsorship\": \"Gestionați sponsorizarea\",\n    \"Help Center\": \"Centru de ajutor\",\n    \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Colectăm doar statistici de utilizare, așa cum este detaliat în {{link}}, nu documentăm niciodată conținutul.\",\n    \"You can opt out of telemetry at any time from this page.\": \"Puteți renunța oricând la telemetrie din această pagină.\",\n    \"Home\": \"Acasă\",\n    \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Această instanță este renunțată la telemetrie. Doar administratorul spaţiului are permisiunea de a schimba acest lucru.\",\n    \"Telemetry\": \"Telemetrie\",\n    \"Opt in to Telemetry\": \"Înscrieți-vă la Telemetrie\",\n    \"You have opted in to telemetry. Thank you!\": \"Ați optat pentru telemetrie. Mulțumesc!\",\n    \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Această instanță este înscrisă la telemetrie. Doar administratorul spaţiului are permisiunea de a schimba acest lucru.\",\n    \"GitHub\": \"GitHub\"\n  },\n  \"VisibleFieldsConfig\": {\n    \"Show {{label}}\": \"Afișați {{label}}\",\n    \"Cannot drop items into Hidden Fields\": \"Nu se pot plasa articole în Câmpuri ascunse\",\n    \"Hidden {{label}}\": \"{{label}} ascuns\",\n    \"Hidden Fields cannot be reordered\": \"Câmpurile ascunse nu pot fi reordonate\",\n    \"Visible {{label}}\": \"Vizibil {{label}}\",\n    \"Select all\": \"Selectează tot\",\n    \"Hide {{label}}\": \"Ascunde {{label}}\",\n    \"Clear\": \"Ștergeți\"\n  },\n  \"FieldConfig\": {\n    \"Column options are limited in summary tables.\": \"Opțiunile coloanelor sunt limitate în tabelele sintetice.\",\n    \"Set formula\": \"Setați formula\",\n    \"Data columns_other\": \"Coloane de date\",\n    \"DESCRIPTION\": \"DESCRIERE\",\n    \"Clear and reset\": \"Ștergeți și resetați\",\n    \"Convert column to data\": \"Convertiți coloana în date\",\n    \"Empty columns_other\": \"Coloane goale\",\n    \"COLUMN LABEL AND ID\": \"Eticheta coloanei și ID\",\n    \"Empty columns_one\": \"Coloană goală\",\n    \"Formula columns_other\": \"Coloane cu formule\",\n    \"Formula columns_one\": \"Coloana cu formule\",\n    \"Make into data column\": \"Faceți în coloana de date\",\n    \"Enter formula\": \"Introduceți formula\",\n    \"Clear and make into formula\": \"Limpeziți și transformați în formulă\",\n    \"Mixed Behavior\": \"Comportament mixt\",\n    \"Convert to trigger formula\": \"Convertiți în formula de declanșare\",\n    \"COLUMN BEHAVIOR\": \"COMPORTAMENTUL COLOANEI\",\n    \"Data columns_one\": \"Coloana de date\",\n    \"TRIGGER FORMULA\": \"FORMULĂ DE DECLANȘARE\",\n    \"Set trigger formula\": \"Setați formula de declanșare\"\n  },\n  \"UserManager\": {\n    \"Anyone with link \": \"Oricine are legătura \",\n    \"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}}.\": \"După ce v-ați eliminat propriul acces, nu îl veți putea recupera fără asistență de la altcineva cu acces suficient la {{name}}.\",\n    \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} de {{limitTop}} {{collaborator}}i\",\n    \"Your role for this team site\": \"Rolul tău pentru acest spaţiu de echipă\",\n    \"Copy link\": \"Copiază legătura\",\n    \"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.\": \"Utilizatorul are acces de vizualizare la {{resource}} rezultat din accesul setat manual la resursele din interior. Dacă este eliminat aici, acest utilizator va pierde accesul la resursele din interior.\",\n    \"User may not modify their own access.\": \"Utilizatorul nu poate modifica propriul acces.\",\n    \"member\": \"membru\",\n    \"Add {{member}} to your team\": \"Adaugă {{member}} în echipa ta\",\n    \"Collaborator\": \"Colaborator\",\n    \"Link copied to clipboard\": \"Link copiat în clipboard\",\n    \"team site\": \"spaţiul echipei\",\n    \"Create a team to share with more people\": \"Creați o echipă pe care să o împărtășiți cu mai mulți oameni\",\n    \"guest\": \"oaspete\",\n    \"Public access: \": \"Acces public: \",\n    \"Team member\": \"Membru al echipei\",\n    \"Manage members of team site\": \"Gestionați membrii spaţiului echipei\",\n    \"Off\": \"Oprit\",\n    \"free collaborator\": \"colaborator gratuit\",\n    \"Save & \": \"Salvează & \",\n    \"Outside collaborator\": \"Colaborator extern\",\n    \"{{collaborator}} limit exceeded\": \"Limita {{collaborator}} a fost depășită\",\n    \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Utilizatorul moștenește permisiunile de la {{părinte})}. Pentru a elimina, setați opțiunea „Moștenire acces” la „Niciuna”.\",\n    \"Your role for this {{resourceType}}\": \"Rolul dvs. pentru acest {{resourceType}}\",\n    \"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}}.\": \"După ce v-ați eliminat propriul acces, nu îl veți putea recupera fără asistență de la altcineva cu acces suficient la {{resourceType}}.\",\n    \"Close\": \"Închide\",\n    \"Allow anyone with the link to open.\": \"Permiteți oricui are legătura să o deschidă.\",\n    \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Niciun acces implicit nu permite accesul la documente sau spații de lucru individuale, mai degrabă decât la spaţiul complet al echipei.\",\n    \"Invite people to {{resourceType}}\": \"Invită persoane la {{resourceType}}\",\n    \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Acces public moștenit de la {{parent}}. Pentru a elimina, setați opțiunea „Moștenire acces” la „Niciuna”.\",\n    \"Remove my access\": \"Elimină accesul meu\",\n    \"Public access\": \"Acces Public\",\n    \"Cancel\": \"Anulare\",\n    \"Grist support\": \"Suport Grist\",\n    \"You are about to remove your own access to this {{resourceType}}\": \"Sunteți pe cale să vă eliminați propriul acces la acest {{resourceType}}\",\n    \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Utilizatorul moștenește permisiunile de la {{parent}}. Pentru a elimina, setați opțiunea „Moștenire acces” la „Niciuna”.\",\n    \"Guest\": \"Oaspete\",\n    \"Invite multiple\": \"Invitați mai mulți\",\n    \"Confirm\": \"Confirmă\",\n    \"On\": \"Activat\",\n    \"Open Access Rules\": \"Reguli de acces deschis\",\n    \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Niciun acces implicit nu permite accesul la documente sau spații de lucru individuale, mai degrabă decât la spaţiul complet al echipei.\",\n    \"Access overview\": \"Prezentare generală a accesului\",\n    \"Inherit access: \": \"Moşteneşte acess: \"\n  },\n  \"WelcomeQuestions\": {\n    \"Welcome to Grist!\": \"Bun venit la Grist!\",\n    \"HR & Management\": \"Resurse Umane și Management\",\n    \"Sales\": \"Vânzări\",\n    \"Marketing\": \"Marketing\",\n    \"Type here\": \"Scrie aici\",\n    \"Other\": \"Altele\",\n    \"Product Development\": \"Dezvoltare de produs\",\n    \"Media Production\": \"Producție media\",\n    \"What brings you to Grist? Please help us serve you better.\": \"Ce vă aduce la Grist? Vă rugăm să ne ajutați să vă servim mai bine.\",\n    \"Education\": \"Educaţie\",\n    \"IT & Technology\": \"IT și Tehnologie\",\n    \"Finance & Accounting\": \"Finanțe și Contabilitate\",\n    \"Research\": \"Cercetare\"\n  },\n  \"PagePanels\": {\n    \"Open creator panel\": \"Deschide Panoul creatorului\",\n    \"Close Creator Panel\": \"Închide Panoul creatorului\"\n  },\n  \"GristDoc\": {\n    \"go to webhook settings\": \"accesați setările ancorei Web\",\n    \"Saved linked section {{title}} in view {{name}}\": \"Secțiunea legată salvată {{title}} în vizualizarea {{name}}\",\n    \"Added new linked section to view {{viewName}}\": \"S-a adăugat o nouă secțiune conectată pentru a vizualiza {{viewName}}\",\n    \"Import from file\": \"Import din fișier\"\n  },\n  \"CardContextMenu\": {\n    \"Insert card above\": \"Introduceți cardul deasupra\",\n    \"Duplicate card\": \"Duplicați cardul\",\n    \"Insert card below\": \"Introduceți cardul mai jos\",\n    \"Delete card\": \"Ștergeți cardul\",\n    \"Copy anchor link\": \"Copiați linkul de ancorare\",\n    \"Insert card\": \"Introduceți cardul\"\n  },\n  \"FieldEditor\": {\n    \"It should be impossible to save a plain data value into a formula column\": \"Ar trebui să fie imposibil să salvați o valoare de date simplă într-o coloană de formulă\",\n    \"Unable to finish saving edited cell\": \"Nu se poate termina salvarea celulei editate\"\n  },\n  \"DuplicateTable\": {\n    \"Only the document default access rules will apply to the copy.\": \"Numai regulile de acces implicite la document se vor aplica copiei.\",\n    \"Copy all data in addition to the table structure.\": \"Copiați toate datele în plus față de structura tabelului.\",\n    \"Name for new table\": \"Nume pentru tabelul nou\",\n    \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"În loc să duplicați tabelele, este de obicei mai bine să segmentați datele folosind vizualizări legate. {{link}}\"\n  },\n  \"ColumnInfo\": {\n    \"Cancel\": \"Anulare\",\n    \"COLUMN ID: \": \"ID COLOANĂ: \",\n    \"Save\": \"Salvați\",\n    \"COLUMN DESCRIPTION\": \"DESCRIEREA COLOANEI\",\n    \"COLUMN LABEL\": \"ETICHETA COLOANEI\"\n  },\n  \"DiscussionEditor\": {\n    \"Show resolved comments\": \"Afișați comentariile rezolvate\",\n    \"Save\": \"Salvați\",\n    \"Reply to a comment\": \"Răspunde la un comentariu\",\n    \"Comment\": \"Comentariu\",\n    \"Started discussion\": \"Discuție începută\",\n    \"Write a comment\": \"Scrie un comentariu\",\n    \"Cancel\": \"Anulare\",\n    \"Only current page\": \"Doar pagina curentă\",\n    \"Reply\": \"Răspunde\",\n    \"Marked as resolved\": \"Marcat ca rezolvat\",\n    \"Remove\": \"Elimină\",\n    \"Open\": \"Deschide\",\n    \"Only my threads\": \"Doar firele mele\",\n    \"Edit\": \"Editați\",\n    \"Resolve\": \"Rezolvă\",\n    \"Showing last {{nb}} comments\": \"Se afișează ultimele {{nb}} comentarii\"\n  },\n  \"Importer\": {\n    \"Merge rows that match these fields:\": \"Îmbinați rândurile care se potrivesc cu aceste câmpuri:\",\n    \"Column mapping\": \"Maparea coloanei\",\n    \"Grist column\": \"Coloana Grist\",\n    \"{{count}} unmatched field_one\": \"{{count}} câmp nepotrivit\",\n    \"{{count}} unmatched field in import_one\": \"{{count}} câmp nepotrivit în import\",\n    \"Revert\": \"Restabilește\",\n    \"Skip Import\": \"Omite importul\",\n    \"{{count}} unmatched field_other\": \"{{count}} câmpuri nepotrivite\",\n    \"Select fields to match on\": \"Selectați câmpurile pentru a se potrivi\",\n    \"New Table\": \"Tabel nou\",\n    \"Skip\": \"Omite\",\n    \"Column Mapping\": \"Maparea Coloanelor\",\n    \"Destination table\": \"Tabel de destinație\",\n    \"Skip Table on Import\": \"Omiteți tabelul la import\",\n    \"Import from file\": \"Import din fișier\",\n    \"{{count}} unmatched field in import_other\": \"{{count}} câmpuri nepotrivite în import\",\n    \"Update existing records\": \"Actualizați înregistrările existente\",\n    \"Source column\": \"Coloana sursă\"\n  },\n  \"WelcomeTour\": {\n    \"Make it relational! Use the {{ref}} type to link tables. \": \"Fă-o relațională! Utilizați tipul {{ref}} pentru a lega tabele. \",\n    \"Enter\": \"Intrare\",\n    \"Use {{helpCenter}} for documentation or questions.\": \"Utilizați {{helpCenter}} pentru documentație sau întrebări.\",\n    \"creator panel\": \"panoul creatorilor\",\n    \"Sharing\": \"Partajare\",\n    \"Configuring your document\": \"Configurarea documentului dvs\",\n    \"Reference\": \"Referinţă\",\n    \"Editing Data\": \"Editarea datelor\",\n    \"template library\": \"biblioteca de șabloane\",\n    \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Utilizați {{addNew}} pentru a adăuga widget-uri, pagini sau pentru a importa mai multe date. \",\n    \"Use the Share button ({{share}}) to share the document or export data.\": \"Folosiți butonul Partajare ({{share}}) pentru a partaja documentul sau pentru a exporta datele.\",\n    \"Help Center\": \"Centru de ajutor\",\n    \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Răsfoiți {{templateLibrary}} pentru a descoperi ce este posibil și inspirați-vă.\",\n    \"Share\": \"Partajează\",\n    \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Setați opțiuni de formatare, formule sau tipuri de coloane, cum ar fi datele, opțiunile sau atașamentele. \",\n    \"Flying higher\": \"Pentru mai multe\",\n    \"Customizing columns\": \"Personalizarea coloanelor\",\n    \"Double-click or hit {{enter}} on a cell to edit it. \": \"Faceți dublu clic sau apăsați pe {{enter}} pe o celulă pentru a o edita. \",\n    \"Welcome to Grist!\": \"Bun venit la Grist!\",\n    \"Add new\": \"Adăugare\",\n    \"Toggle the {{creatorPanel}} to format columns, \": \"Comutați {{creatorPanel}} pentru a formata coloanele, \",\n    \"convert to card view, select data, and more.\": \"convertiți în vizualizarea cardului, selectați date și multe altele.\",\n    \"Building up\": \"În construcție\",\n    \"Start with {{equal}} to enter a formula.\": \"Începeți cu {{equal}} pentru a introduce o formulă.\"\n  },\n  \"buildViewSectionDom\": {\n    \"Not all data is shown\": \"Nu sunt afișate toate datele\",\n    \"No row selected in {{title}}\": \"Niciun rând selectat în {{title}}\",\n    \"No data\": \"Nu există date\"\n  },\n  \"ViewSectionMenu\": {\n    \"FILTER\": \"FILTRU\",\n    \"(customized)\": \"(personalizat)\",\n    \"Revert\": \"Refacere\",\n    \"Save\": \"Salvați\",\n    \"Custom options\": \"Opțiuni personalizate\",\n    \"SORT\": \"ORDONEAZĂ\",\n    \"Update Sort&Filter settings\": \"Actualizați setările de sortare și filtrare\",\n    \"(empty)\": \"(gol)\",\n    \"(modified)\": \"(modificat)\"\n  },\n  \"Tools\": {\n    \"Delete document tour?\": \"Ștergeți turul documentului?\",\n    \"TOOLS\": \"INSTRUMENTE\",\n    \"Delete\": \"Șterge\",\n    \"Settings\": \"Setări\",\n    \"Access Rules\": \"Reguli de acces\",\n    \"Validate Data\": \"Validați datele\",\n    \"How-to Tutorial\": \"Tutorial practic\",\n    \"Tour of this Document\": \"Tur al acestui document\",\n    \"Code view\": \"Vizualizare cod\",\n    \"Return to viewing as yourself\": \"Reveniți la vizualizarea ca dvs\",\n    \"Raw data\": \"Date neprelucrate\",\n    \"Document history\": \"Istoricul documentelor\",\n    \"API console\": \"Consola API\"\n  },\n  \"menus\": {\n    \"Reference List\": \"Referință multiplă\",\n    \"Integer\": \"Întreg\",\n    \"* Workspaces are available on team plans. \": \"* Spațiile de lucru sunt disponibile pentru planuri de echipă. \",\n    \"Text\": \"Text\",\n    \"Attachment\": \"Atașament\",\n    \"Upgrade now\": \"Actualizare acum\",\n    \"Toggle\": \"Comutare\",\n    \"Choice\": \"Alegere\",\n    \"Select fields\": \"Selectați câmpuri\",\n    \"Choice List\": \"Alegere multiplă\",\n    \"Reference\": \"Referinţă\",\n    \"Search columns\": \"Căutați coloane\",\n    \"Any\": \"Oricare\",\n    \"Numeric\": \"Numeric\",\n    \"DateTime\": \"Data și ora\",\n    \"Date\": \"Data\"\n  },\n  \"DocumentSettings\": {\n    \"Ok\": \"OK\",\n    \"Manage Webhooks\": \"Gestionați ancore Web\",\n    \"API\": \"API\",\n    \"Save\": \"Salvați\",\n    \"Document ID copied to clipboard\": \"ID-ul documentului a fost copiat în clipboard\",\n    \"Local currency ({{currency}})\": \"Moneda locală ({{currency}})\",\n    \"Save and Reload\": \"Salvați și reîncărcați\",\n    \"Time Zone:\": \"Fus orar:\",\n    \"Webhooks\": \"Ancore Web\",\n    \"Currency:\": \"Valută:\",\n    \"Engine (experimental {{span}} change at own risk):\": \"Motor (modificare experimentală de {{span}} pe propriul risc):\",\n    \"Document settings\": \"Setări document\",\n    \"Locale:\": \"Limba:\",\n    \"This document's ID (for API use):\": \"ID-ul acestui document (pentru utilizarea API):\",\n    \"API URL copied to clipboard\": \"API URL a fost copiat in clipboard\",\n    \"API documentation.\": \"Documentatia pentru API.\",\n    \"Coming soon\": \"În curând\",\n    \"Copy to clipboard\": \"Copiază în clipboard\",\n    \"Currency\": \"Monedă\",\n    \"Find slow formulas\": \"Găsiți formule lente\",\n    \"For currency columns\": \"Pentru coloanele valutare\",\n    \"Formula times\": \"Formule de timp\",\n    \"Notify other services on doc changes\": \"Notificați alte servicii cu privire la modificările documentului\",\n    \"Python\": \"Python\",\n    \"Python version used\": \"Versiunea Python folosită\",\n    \"Reload\": \"Reîncarcă\",\n    \"For number and date formats\": \"Pentru formatele de număr și dată\",\n    \"Cancel\": \"Anulează\",\n    \"Start timing\": \"Începe cronometrarea\",\n    \"Manage webhooks\": \"Gestionează ancore Web\",\n    \"Stop timing...\": \"Oprește cronometrarea...\",\n    \"python3 (recommended)\": \"python3 (recomandat)\",\n    \"Document type\": \"Tipul documentului\"\n  },\n  \"ColumnTitle\": {\n    \"Column ID copied to clipboard\": \"ID-ul coloanei a fost copiat în clipboard\",\n    \"Add description\": \"Adaugă descriere\",\n    \"Column description\": \"Descrierea coloanei\",\n    \"COLUMN ID: \": \"ID COLOANĂ: \",\n    \"Provide a column label\": \"Furnizați o etichetă a coloanei\",\n    \"Close\": \"Închide\",\n    \"Cancel\": \"Anulare\",\n    \"Column label\": \"Eticheta coloanei\",\n    \"Save\": \"Salvați\"\n  },\n  \"ViewConfigTab\": {\n    \"Section: \": \"Secțiune: \",\n    \"Form\": \"Formular\",\n    \"Unmark On-Demand\": \"Nu faceți la cerere\",\n    \"Compact\": \"Compactează\",\n    \"Blocks\": \"Blocuri\",\n    \"Advanced settings\": \"Setări avansate\",\n    \"Make On-Demand\": \"Creare la cerere\",\n    \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Tabelele mari pot fi marcate ca „la cerere” pentru a evita încărcarea lor în motorul de date.\",\n    \"Plugin: \": \"Plugin: \",\n    \"Edit card layout\": \"Editați aspectul cardului\"\n  },\n  \"FieldBuilder\": {\n    \"Mixed format\": \"Format mixt\",\n    \"CELL FORMAT\": \"FORMATARE DE CELULĂ\",\n    \"Mixed types\": \"Tipuri mixte\",\n    \"DATA FROM TABLE\": \"DATE DIN TABEL\",\n    \"Revert field settings for {{colId}} to common\": \"Reveniți setările de câmp pentru {{colId}} la obișnuit\",\n    \"Save field settings for {{colId}} as common\": \"Salvați setările de câmp pentru {{colId}} ca obișnuite\",\n    \"Changing multiple column types\": \"Modificarea mai multor tipuri de coloane\",\n    \"Use separate field settings for {{colId}}\": \"Utilizați setări de câmp separate pentru {{colId}}\",\n    \"Changing column type\": \"Schimbarea tipului de coloană\",\n    \"Apply formula to data\": \"Aplicați formula datelor\"\n  },\n  \"SupportGristNudge\": {\n    \"Support Grist\": \"Sprijină Grist\",\n    \"Close\": \"Închide\",\n    \"Opt in to Telemetry\": \"Înscrieți-vă la Telemetrie\",\n    \"Help Center\": \"Centru de ajutor\",\n    \"Opted In\": \"A optat pentru a participa\",\n    \"Contribute\": \"Contribuie\",\n    \"Support Grist page\": \"Pagina Susține Grist\",\n    \"Admin Panel\": \"Panou administrator\",\n    \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Vă mulțumim! Încrederea și sprijinul dumneavoastră sunt foarte apreciate. Vă puteți dezabona oricând accesând {{link}} din meniul utilizatorului.\"\n  },\n  \"ValidationPanel\": {\n    \"Update formula (Shift+Enter)\": \"Actualizați formula (Shift+Enter)\",\n    \"Rule {{length}}\": \"Regula {{length}}\"\n  },\n  \"ViewLayoutMenu\": {\n    \"Widget options\": \"Opțiuni widget\",\n    \"Advanced sort & filter\": \"Sortare și filtrare avansată\",\n    \"Print widget\": \"Imprimare widget\",\n    \"Data selection\": \"Selectarea datelor\",\n    \"Download as XLSX\": \"Descărcați ca XLSX\",\n    \"Open configuration\": \"Deschideți configurația\",\n    \"Edit card layout\": \"Editați aspectul cardului\",\n    \"Add to page\": \"Adăugați la pagină\",\n    \"Delete record\": \"Șterge înregistrarea\",\n    \"Collapse widget\": \"Restrângeți widgetul\",\n    \"Show raw data\": \"Afișați datele brute\",\n    \"Delete widget\": \"Ștergeți widgetul\",\n    \"Copy anchor link\": \"Copiați legătura de ancorare\",\n    \"Download as CSV\": \"Descărcați ca CSV\"\n  },\n  \"HomeIntro\": {\n    \"personal site\": \"spaţiu personal\",\n    \"Any documents created in this site will appear here.\": \"Orice documente create în acest spaţiu vor apărea aici.\",\n    \"Welcome to Grist, {{- name}}!\": \"Bun venit la Grist, {{- name}}!\",\n    \"Get started by inviting your team and creating your first Grist document.\": \"Începeți prin a vă invita echipa și a crea primul document Grist.\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Aveți acces numai pentru citire în acest spaţiu. Momentan nu există documente.\",\n    \"Help Center\": \"Centru de Ajutor\",\n    \"Interested in using Grist outside of your team? Visit your free \": \"Sunteți interesat să folosești Grist în afara echipei tale? Vizitați gratuit \",\n    \"Get started by creating your first Grist document.\": \"Începeți prin a crea primul dvs. document Grist.\",\n    \"This workspace is empty.\": \"Acest spațiu de lucru este gol.\",\n    \"Visit our {{link}} to learn more.\": \"Vizitați {{link}} pentru a afla mai multe.\",\n    \"Visit our {{link}} to learn more about Grist.\": \"Vizitați {{link}} pentru a afla mai multe despre Grist.\",\n    \"{{signUp}} to save your work. \": \"{{signUp}} pentru a vă salva munca. \",\n    \"Welcome to {{- orgName}}\": \"Bun venit la {{- orgName}}\",\n    \"Welcome to Grist, {{name}}!\": \"Bun venit la Grist, {{name}}!\",\n    \"Browse Templates\": \"Răsfoiți șabloane\",\n    \"Sign in\": \"Conectare\",\n    \"Welcome to {{orgName}}\": \"Bun venit la {{orgName}}\",\n    \"Invite Team Members\": \"Invitați membrii echipei\",\n    \"Get started by exploring templates, or creating your first Grist document.\": \"Începeți prin a explora șabloane sau prin a crea primul document Grist.\",\n    \"Import document\": \"Importă Document\",\n    \"Create empty document\": \"Creați un document gol\",\n    \"Sign up\": \"Înscrie-te\",\n    \"Sprouts Program\": \"Consultanță pentru a începe\",\n    \"To use Grist, please either sign up or sign in.\": \"Pentru a utiliza Grist, vă rugăm fie să vă înregistrați, fie să vă conectați.\",\n    \"Welcome to Grist!\": \"Bun venit la Grist!\"\n  },\n  \"WelcomeSitePicker\": {\n    \"You have access to the following Grist sites.\": \"Aveți acces la următoarele spaţii Grist.\",\n    \"Welcome back\": \"Bine ați revenit\",\n    \"You can always switch sites using the account menu.\": \"Puteți schimba oricând spaţiul folosind meniul contului.\"\n  },\n  \"TopBar\": {\n    \"Manage team\": \"Gestionează echipa\"\n  },\n  \"GridOptions\": {\n    \"Horizontal gridlines\": \"Grile Orizontale\",\n    \"Vertical gridlines\": \"Grile Verticale\",\n    \"Grid Options\": \"Opțiuni Grilă\",\n    \"Zebra stripes\": \"Dungi Zebra\"\n  },\n  \"FormulaEditor\": {\n    \"Enter formula or {{button}}.\": \"Introduceți formula sau {{button}}.\",\n    \"Error in the cell\": \"Eroare în celulă\",\n    \"Errors in {{numErrors}} of {{numCells}} cells\": \"Erori în {{numErrors}} din {{numCells}} celule\",\n    \"Enter formula.\": \"Introduceți formula.\",\n    \"Column or field is required\": \"Coloana sau câmpul este obligatoriu\",\n    \"Expand Editor\": \"Extinde Editorul\",\n    \"use AI Assistant\": \"utilizați asistentul AI\",\n    \"Errors in all {{numErrors}} cells\": \"Erori în toate celulele {{numErrors}}\",\n    \"editingFormula is required\": \"EditingFormula este necesară\"\n  },\n  \"ShareMenu\": {\n    \"Current Version\": \"Versiune curentă\",\n    \"Return to {{termToUse}}\": \"Reveniți la {{termToUse}}\",\n    \"Download...\": \"Descarcă...\",\n    \"Show in folder\": \"Arată în dosar\",\n    \"Share\": \"Partajează\",\n    \"Export CSV\": \"Exportați CSV\",\n    \"Send to Google Drive\": \"Trimiteți pe Google Drive\",\n    \"Export XLSX\": \"Exportați XLSX\",\n    \"Access Details\": \"Detalii de acces\",\n    \"Compare to {{termToUse}}\": \"Comparați cu {{termToUse}}\",\n    \"Download\": \"Descarcă\",\n    \"Replace {{termToUse}}...\": \"Înlocuiți {{termToUse}}…\",\n    \"Duplicate document\": \"Duplicare document\",\n    \"Original\": \"Original\",\n    \"Back to current\": \"Înapoi la curent\",\n    \"Edit without affecting the original\": \"Editați fără a afecta originalul\",\n    \"Work on a copy\": \"Lucrați la o copie\",\n    \"Manage users\": \"Gestionare utilizatori\",\n    \"Unsaved\": \"Nesalvat\",\n    \"Save Document\": \"Salvați documentul\",\n    \"Save copy\": \"Salvare copie\"\n  },\n  \"UserManagerModel\": {\n    \"View & edit\": \"Vizualizați și editați\",\n    \"Owner\": \"Proprietar\",\n    \"None\": \"Nici unul\",\n    \"View only\": \"Doar vizualizare\",\n    \"No Default Access\": \"Fără acces implicit\",\n    \"In full\": \"În întregime\",\n    \"Viewer\": \"Vizualizator\",\n    \"Editor\": \"Editor\"\n  },\n  \"DocumentUsage\": {\n    \"Data size\": \"Dimensiunea datelor\",\n    \"Usage statistics are only available to users with full access to the document data.\": \"Statisticile de utilizare sunt disponibile numai pentru utilizatorii cu acces complet la datele documentului.\",\n    \"Usage\": \"Utilizare\",\n    \"Size of attachments\": \"Dimensiunea atașamentelor\",\n    \"For higher limits, \": \"Pentru limite mai mari, \",\n    \"Contact the site owner to upgrade the plan to raise limits.\": \"Contactați proprietarul pentru a actualiza planul ca să crească limitele.\",\n    \"start your 30-day free trial of the Pro plan.\": \"începeți încercarea gratuită de 30 de zile a planului Pro.\",\n    \"Rows\": \"Rânduri\"\n  },\n  \"NotifyUI\": {\n    \"Go to your free personal site\": \"Accesați spaţiul dvs. personal gratuit\",\n    \"Upgrade Plan\": \"Îmbunătățiți-vă abonamentul\",\n    \"Ask for help\": \"Întreabă pentru ajutor\",\n    \"Renew\": \"Reînnoiește\",\n    \"Manage billing\": \"Gestionați facturarea\",\n    \"Give feedback\": \"Oferă feedback\",\n    \"Cannot find personal site, sorry!\": \"Spaţiul personal nu poate fi găsit, scuze!\",\n    \"Notifications\": \"Notificări\",\n    \"Report a problem\": \"Raportați o problemă\",\n    \"No notifications\": \"Nicio notificare\"\n  },\n  \"FieldContextMenu\": {\n    \"Copy anchor link\": \"Copiați linkul de ancorare\",\n    \"Hide field\": \"Ascunde câmpul\",\n    \"Copy\": \"Copiază\",\n    \"Paste\": \"Lipește\",\n    \"Clear field\": \"Şterge câmpul\",\n    \"Cut\": \"Taie\"\n  },\n  \"WidgetTitle\": {\n    \"Override widget title\": \"Înlocuiți titlul widgetului\",\n    \"WIDGET TITLE\": \"TITLU WIDGET\",\n    \"DATA TABLE NAME\": \"NUME TABEL DE DATE\",\n    \"Cancel\": \"Anulare\",\n    \"WIDGET DESCRIPTION\": \"DESCRIERE WIDGET\",\n    \"Save\": \"Salvați\",\n    \"Provide a table name\": \"Indicați un nume de tabel\"\n  },\n  \"ChoiceTextBox\": {\n    \"CHOICES\": \"ALEGERILE\"\n  },\n  \"ExampleInfo\": {\n    \"Afterschool Program\": \"Program după școală\",\n    \"Welcome to the Investment Research template\": \"Bun venit la șablonul Cercetare de investiții\",\n    \"Welcome to the Afterschool Program template\": \"Bun venit la șablonul Program după școală\",\n    \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Consultați tutorialul nostru asociat pentru a afla cum să creați tabele și diagrame rezumative și cum să conectați diagramele în mod dinamic.\",\n    \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Consultați tutorialul nostru legat pentru cum să conectați datele și să creați machete de înaltă productivitate.\",\n    \"Investment Research\": \"Cercetare de investiții\",\n    \"Tutorial: Create a CRM\": \"Tutorial: Creați un CRM\",\n    \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Consultați tutorialul nostru legat pentru cum să modelați datele de afaceri, să utilizați formule și să gestionați complexitatea.\",\n    \"Welcome to the Lightweight CRM template\": \"Bun venit la șablonul CRM Simplu\",\n    \"Lightweight CRM\": \"CRM simplu\",\n    \"Tutorial: Manage Business Data\": \"Tutorial: Gestionați datele de afaceri\",\n    \"Tutorial: Analyze & Visualize\": \"Tutorial: Analizați și vizualizați\"\n  },\n  \"ViewAsBanner\": {\n    \"UnknownUser\": \"Utilizator necunoscut\"\n  },\n  \"PermissionsWidget\": {\n    \"Deny all\": \"Negați tot\",\n    \"Read only\": \"Numai citire\",\n    \"Allow all\": \"Permite Toate\"\n  },\n  \"errorPages\": {\n    \"Account deleted{{suffix}}\": \"Contul a fost șters{{suffix}}\",\n    \"Something went wrong\": \"Ceva nu a mers bine\",\n    \"There was an error: {{message}}\": \"A apărut o eroare: {{message}}\",\n    \"Go to main page\": \"Accesați pagina principală\",\n    \"Sign in\": \"Conectare\",\n    \"Access denied{{suffix}}\": \"Acces refuzat{{suffix}}\",\n    \"There was an unknown error.\": \"A apărut o eroare necunoscută.\",\n    \"Add account\": \"Adaugă cont\",\n    \"You do not have access to this organization's documents.\": \"Nu aveți acces la documentele acestei organizații.\",\n    \"You are now signed out.\": \"Acum sunteți deconectat.\",\n    \"Signed out{{suffix}}\": \"Deconectat{{suffix}}\",\n    \"Your account has been deleted.\": \"Contul dvs. a fost șters.\",\n    \"Page not found{{suffix}}\": \"Pagina nu a fost găsită {{suffix}}\",\n    \"Error{{suffix}}\": \"Eroare {{suffix}}\",\n    \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Sunteți conectat ca {{email}}. Vă puteți conecta cu un alt cont sau puteți solicita accesul unui administrator.\",\n    \"Sign up\": \"Înscriere\",\n    \"Sign in again\": \"Conectați-vă din nou\",\n    \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Pagina solicitată nu a putut fi găsită.{{separator}}Verificați adresa URL și încercați din nou.\",\n    \"Sign in to access this organization's documents.\": \"Conectați-vă pentru a accesa documentele acestei organizații.\",\n    \"Contact support\": \"Contactați asistența\"\n  },\n  \"RecordLayoutEditor\": {\n    \"Show field {{- label}}\": \"Afișați câmpul {{- label}}\",\n    \"Add field\": \"Adăugați câmp\",\n    \"Save layout\": \"Salvați aspectul\",\n    \"Cancel\": \"Anulare\",\n    \"Create new field\": \"Creați un câmp nou\"\n  },\n  \"CellStyle\": {\n    \"HEADER STYLE\": \"STIL ANTET\",\n    \"Header Style\": \"Stil antet\",\n    \"Default header style\": \"Stil implicit pentru antet\",\n    \"Mixed style\": \"Stil mixt\",\n    \"Default cell style\": \"Stilul de celulă implicit\",\n    \"CELL STYLE\": \"STILUL CELULEI\",\n    \"Cell style\": \"Stilul Celulei\",\n    \"Open row styles\": \"Deschide stilurile pentru rând\"\n  },\n  \"ConditionalStyle\": {\n    \"Rule must return True or False\": \"Regula trebuie să returneze Adevărat sau Fals\",\n    \"Row style\": \"Stil de rând\",\n    \"Add another rule\": \"Adăugați o altă regulă\",\n    \"Add conditional style\": \"Adăugați stil condiționat\",\n    \"Error in style rule\": \"Eroare în regula de stil\"\n  },\n  \"search\": {\n    \"Search in document\": \"Caută în document\",\n    \"Find Next \": \"Găsește următorul \",\n    \"No results\": \"Fără rezultate\",\n    \"Find Previous \": \"Găsește precedentul \",\n    \"Search\": \"Căutare\"\n  },\n  \"FieldMenus\": {\n    \"Use separate settings\": \"Utilizați setări separate\",\n    \"Revert to common settings\": \"Reveniți la setările comune\",\n    \"Using common settings\": \"Utilizarea setărilor comune\",\n    \"Using separate settings\": \"Folosiți setări separate\",\n    \"Save as common settings\": \"Salvați ca setări comune\"\n  },\n  \"FilterConfig\": {\n    \"Add column\": \"Adăugați o coloană\"\n  },\n  \"EditorTooltip\": {\n    \"Convert column to formula\": \"Convertiți coloana în formulă\"\n  },\n  \"PageWidgetPicker\": {\n    \"Select widget\": \"Selectați Widget\",\n    \"Add to page\": \"Adăugați la pagină\",\n    \"Select data\": \"Selectați Date\",\n    \"Group by\": \"Grupează după\",\n    \"Building {{- label}} widget\": \"Crearea widgetului {{- label}}\"\n  },\n  \"Pages\": {\n    \"The following tables will no longer be visible_one\": \"Următorul tabel nu va mai fi vizibil\",\n    \"Delete\": \"Șterge\",\n    \"The following tables will no longer be visible_other\": \"Următoarele tabele nu vor mai fi vizibile\",\n    \"Delete data and this page.\": \"Ștergeți datele și această pagină.\"\n  },\n  \"DescriptionTextArea\": {\n    \"DESCRIPTION\": \"DESCRIERE\"\n  },\n  \"WebhookPage\": {\n    \"Webhook settings\": \"Setări ancoră Web\",\n    \"Clear queue\": \"Ștergeți coada\"\n  },\n  \"TriggerFormulas\": {\n    \"Close\": \"Închide\",\n    \"Cancel\": \"Anulare\",\n    \"Apply on changes to:\": \"Aplicați la modificări la:\",\n    \"Apply to new records\": \"Aplicați la noi înregistrări\",\n    \"OK\": \"OK\",\n    \"Current field \": \"Câmp curent \",\n    \"Any field\": \"Orice domeniu\",\n    \"Apply on record changes\": \"Aplicați modificările înregistrate\"\n  },\n  \"PluginScreen\": {\n    \"Import failed: \": \"Importul nu a reușit: \"\n  },\n  \"NTextBox\": {\n    \"false\": \"fals\",\n    \"true\": \"adevărat\",\n    \"Lines\": \"Linii\"\n  },\n  \"FilterBar\": {\n    \"Search Columns\": \"Căutați Coloane\",\n    \"SearchColumns\": \"Căutați coloane\"\n  },\n  \"TypeTransformation\": {\n    \"Revise\": \"Revizuire\",\n    \"Update formula (Shift+Enter)\": \"Actualizați formula (Shift+Enter)\",\n    \"Preview\": \"Previzualizare\",\n    \"Apply\": \"Aplică\",\n    \"Cancel\": \"Anulare\"\n  },\n  \"NumericTextBox\": {\n    \"Decimals\": \"Zecimale\",\n    \"Currency\": \"Moneda\",\n    \"Default currency ({{defaultCurrency}})\": \"Moneda implicită ({{defaultCurrency}})\",\n    \"Number Format\": \"Format de număr\"\n  },\n  \"OnBoardingPopups\": {\n    \"Finish\": \"Terminați\",\n    \"Next\": \"Următorul\"\n  },\n  \"Reference\": {\n    \"CELL FORMAT\": \"FORMAT DE CELULA\",\n    \"Row ID\": \"ID rând\",\n    \"SHOW COLUMN\": \"ARATĂ COLOANA\"\n  },\n  \"HyperLinkEditor\": {\n    \"[link label] url\": \"[eticheta legăturii] URL\"\n  },\n  \"FloatingEditor\": {\n    \"Collapse Editor\": \"Restrângeți editorul\"\n  },\n  \"ThemeConfig\": {\n    \"Switch appearance automatically to match system\": \"Schimbați automat aspectul pentru a se potrivi cu sistemul\",\n    \"Appearance \": \"Aspect \"\n  },\n  \"GridView\": {\n    \"Click to insert\": \"Faceți clic pentru a insera\"\n  },\n  \"pages\": {\n    \"Duplicate page\": \"Duplică pagina\",\n    \"You do not have edit access to this document\": \"Nu aveți acces de editare la acest document\",\n    \"Remove\": \"Elimină\",\n    \"Rename\": \"Redenumiți\"\n  },\n  \"ColumnEditor\": {\n    \"COLUMN DESCRIPTION\": \"DESCRIEREA COLOANEI\",\n    \"COLUMN LABEL\": \"ETICHETA COLOANEI\"\n  },\n  \"RefSelect\": {\n    \"No columns to add\": \"Nu există coloane de adăugat\",\n    \"Add column\": \"Adăugați o coloană\"\n  },\n  \"SortFilterConfig\": {\n    \"Update Sort & Filter settings\": \"Actualizați setările de sortare și filtrare\",\n    \"Save\": \"Salvați\",\n    \"Sort\": \"ORDONARE\",\n    \"Filter\": \"FILTRU\",\n    \"Revert\": \"Refacere\"\n  },\n  \"ACLUsers\": {\n    \"View as\": \"Vizualizare ca\",\n    \"Example Users\": \"Exemplu de utilizatori\",\n    \"Users from table\": \"Utilizatori de la masă\"\n  },\n  \"TypeTransform\": {\n    \"Cancel\": \"Anulare\",\n    \"Revise\": \"Revizuire\",\n    \"Apply\": \"Aplică\",\n    \"Update formula (Shift+Enter)\": \"Actualizați formula (Shift+Enter)\",\n    \"Preview\": \"Previzualizare\"\n  },\n  \"LanguageMenu\": {\n    \"Language\": \"Limba\"\n  },\n  \"OpenVideoTour\": {\n    \"Video Tour\": \"Tur video\",\n    \"YouTube video player\": \"Player video YouTube\",\n    \"Grist Video Tour\": \"Tur video Grist\"\n  },\n  \"duplicatePage\": {\n    \"Note that this does not copy data, but creates another view of the same data.\": \"Rețineți că aceasta nu copiază datele, ci creează o altă vizualizare a acelorași date.\",\n    \"Duplicate page {{pageName}}\": \"Dublați pagina {{pageName}}\"\n  },\n  \"CurrencyPicker\": {\n    \"Invalid currency\": \"Moneda nevalidă\"\n  },\n  \"LeftPanelCommon\": {\n    \"Help Center\": \"Centru de ajutor\"\n  },\n  \"searchDropdown\": {\n    \"Search\": \"Căutare\"\n  },\n  \"SearchModel\": {\n    \"Search all tables\": \"Căutați în toate tabelele\",\n    \"Search all pages\": \"Căutați în toate paginile\"\n  },\n  \"modals\": {\n    \"Save\": \"Salvează\",\n    \"Cancel\": \"Anulare\",\n    \"Ok\": \"OK\",\n    \"Delete\": \"șterge\",\n    \"Are you sure you want to delete these records?\": \"Ești sigur că vrei să ștergi aceste înregistrări?\",\n    \"TIP\": \"SFAT\",\n    \"Don't show tips\": \"Nu mai arăta sfaturi\",\n    \"Are you sure you want to delete this record?\": \"Ești sigur că vrei să ștergi această înregistrare?\",\n    \"Don't ask again.\": \"Nu mai întreba.\",\n    \"Got it\": \"Am înțeles\",\n    \"Don't show again\": \"Nu mai afișa\",\n    \"Don't show again.\": \"Nu mai afișa.\"\n  },\n  \"SiteSwitcher\": {\n    \"Switch Sites\": \"Schimbați spaţiul\",\n    \"Create new team site\": \"Creați un nou spaţiu de echipă\"\n  },\n  \"RecordLayout\": {\n    \"Updating record layout.\": \"Actualizarea aspectului înregistrării.\"\n  },\n  \"DocTour\": {\n    \"No valid document tour\": \"Nici un tur de document valid\",\n    \"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.\": \"Nu se poate construi un tur al documentului din datele din acest document. Asigurați-vă că există un tabel numit GristDocTour cu coloanele Titlu, Corp, Plasare și Locație.\"\n  },\n  \"SelectionSummary\": {\n    \"Copied to clipboard\": \"Copiat în clipboard\"\n  },\n  \"DescriptionConfig\": {\n    \"DESCRIPTION\": \"DESCRIERE\"\n  },\n  \"sendToDrive\": {\n    \"Sending file to Google Drive\": \"Se trimite fișierul la Google Drive\"\n  },\n  \"HiddenQuestionConfig\": {\n    \"Hidden fields\": \"Câmpuri ascunse\"\n  },\n  \"AdminPanel\": {\n    \"Support Grist Labs on GitHub\": \"Sponsor Grist Labs pe GitHub\",\n    \"Telemetry\": \"Telemetry\",\n    \"Admin Panel\": \"Panou administrator\",\n    \"Home\": \"Acasă\",\n    \"Sponsor\": \"Sponsor\",\n    \"Support Grist\": \"Sponsor Grist\"\n  }\n}\n"
  },
  {
    "path": "static/locales/ro.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/ru.client.json",
    "content": "{\n    \"AccessRules\": {\n        \"Allow everyone to view Access Rules.\": \"Разрешить всем просматривать правила доступа.\",\n        \"Attribute name\": \"Имя атрибута\",\n        \"Add Default Rule\": \"Добавить правило по умолчанию\",\n        \"Add column rule\": \"Добавить правило столбца\",\n        \"View as\": \"Смотреть как\",\n        \"Seed rules\": \"Наследуемые правила\",\n        \"Add user attributes\": \"Добавить атрибуты пользователя\",\n        \"Add table rules\": \"Добавить правила таблицы\",\n        \"Everyone\": \"Остальные\",\n        \"Delete table rules\": \"Удалить правила таблицы\",\n        \"Enter Condition\": \"Введите условие\",\n        \"Lookup Column\": \"Столбец поиска\",\n        \"Everyone Else\": \"Все остальные\",\n        \"Remove {{- name }} user attribute\": \"Удалить атрибут пользователя {{- name }}\",\n        \"Invalid\": \"Недействительный\",\n        \"Permission to access the document in full when needed\": \"Разрешение на полный доступ к документу при необходимости\",\n        \"Remove {{- tableId }} rules\": \"Удалить правила {{- tableId }}\",\n        \"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.\": \"Разрешить всем копировать весь документ, или посмотреть его полностью в режиме Ответвления (fiddle).\\nПолезно для примеров и шаблонов, но не для конфиденциальных данных.\",\n        \"Attribute to Look Up\": \"Атрибут для поиска (Look Up)\",\n        \"Condition\": \"Условие\",\n        \"Checking...\": \"Проверка…\",\n        \"Default rules\": \"Правила по умолчанию\",\n        \"Lookup Table\": \"Таблица поиска\",\n        \"Permission to view Access Rules\": \"Разрешение на просмотр правил доступа\",\n        \"Permissions\": \"Разрешения\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Удалить столбец {{- colId }} из правил {{- tableId }}\",\n        \"Reset\": \"Сброс\",\n        \"Rules for table \": \"Правила для таблицы \",\n        \"Save\": \"Сохранить\",\n        \"Special rules\": \"Специальные правила\",\n        \"User Attributes\": \"Пользовательские атрибуты\",\n        \"Type message to display when this rule blocks an action…\": \"Введите сообщение…\",\n        \"Saved\": \"Сохранено\",\n        \"Permission to edit document structure\": \"Разрешение на редактирование структуры документа\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"При добавлении правил таблицы, автоматически добавить правило для предоставления ВЛАДЕЛЬЦУ полного доступа.\",\n        \"This default should be changed if editors' access is to be limited. \": \"Это значение по умолчанию следует изменить, если требуется ограничить доступ редакторов. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Позволяет редакторам редактировать структуру (например, изменять и удалять таблицы, столбцы, макеты) и писать формулы, которые предоставляют доступ ко всем данным независимо от ограничений на чтение.\",\n        \"Add table-wide rule\": \"Добавить обще-табличное правило\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Правила доступа изменились. Нажмите «Сброс», чтобы отменить изменения и обновить правила.\",\n        \"All\": \"Все\",\n        \"Columns\": \"Столбцы\",\n        \"Condition cannot be blank\": \"Условие не может быть пустым\",\n        \"Default resource missing in resource map\": \"Ресурс по умолчанию отсутствует на карте ресурсов\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Недопустимые столбцы в таблице {{tableId}}: {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"Недопустимая таблица: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Недопустимое правило атрибута пользователя: {{prop}} должно быть задано\",\n        \"Invalid user attribute to look up\": \"Неверный атрибут пользователя для поиска\",\n        \"Not a valid user attribute\": \"Недопустимый атрибут пользователя\",\n        \"hidden\": \"скрытый\"\n    },\n    \"ACUserManager\": {\n        \"Enter email address\": \"Введите адрес e-mail\",\n        \"Invite new member\": \"Пригласить нового участника\",\n        \"We'll email an invite to {{email}}\": \"Мы вышлем приглашение на {{email}}\"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"Allow signing in to this account with Google\": \"Разрешить вход в этот аккаунт с помощью Google\",\n        \"Theme\": \"Тема\",\n        \"Change password\": \"Изменить пароль\",\n        \"API Key\": \"API Ключ\",\n        \"Account settings\": \"Настройки аккаунта\",\n        \"Edit\": \"Редактировать\",\n        \"Name\": \"Имя\",\n        \"Email\": \"E-mail\",\n        \"Login method\": \"Способ входа в систему\",\n        \"Names only allow letters, numbers and certain special characters\": \"В именах допускаются только буквы, цифры и определенные специальные символы\",\n        \"Save\": \"Сохранить\",\n        \"Password & security\": \"Пароль & Безопасность\",\n        \"Two-factor authentication\": \"Двухфакторная аутентификация\",\n        \"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.\": \"Двухфакторная аутентификация - это дополнительный уровень безопасности для вашего аккаунта Grist, предназначенный для идентификации вас как единственного человека, который может получить доступ к вашему аккаунту, даже если кто-то знает ваш пароль.\",\n        \"Language\": \"Язык\"\n    },\n    \"App\": {\n        \"Memory Error\": \"Ошибка памяти\",\n        \"Description\": \"Описание\",\n        \"Key\": \"Ключ\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Переводчики: пожалуйста, переведите это только тогда, когда ваш язык будет готов для использования пользователями\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Применить\",\n        \"Cancel\": \"Отменить\",\n        \"Default cell style\": \"Стиль ячейки по умолчанию\"\n    },\n    \"ColumnFilterMenu\": {\n        \"Max\": \"Max\",\n        \"Start\": \"Начинается\",\n        \"End\": \"Оканчивается\",\n        \"Other Non-Matching\": \"Другие несоответствующие\",\n        \"None\": \"Нет\",\n        \"All except\": \"Все, кроме\",\n        \"All\": \"Все\",\n        \"All shown\": \"Все отображенное\",\n        \"Filter by Range\": \"Фильтровать по диапазону\",\n        \"Future values\": \"Будущие значения\",\n        \"No matching values\": \"Нет совпадающих значений\",\n        \"Min\": \"Min\",\n        \"Other values\": \"Другие значения\",\n        \"Other Matching\": \"Другие соответствующие\",\n        \"Others\": \"Другие\",\n        \"Search\": \"Поиск\",\n        \"Search values\": \"Поиск значений\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Сортировка по алфавиту (текущий: отсортирован по количеству появлений)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Сортировка по количеству вхождений (текущее: отсортировано по алфавиту)\",\n        \"Unpin filter\": \"Открепить фильтр\",\n        \"Pin filter\": \"Закрепить фильтр\",\n        \"Clear search\": \"Очистить поиск\"\n    },\n    \"AppHeader\": {\n        \"Personal Site\": \"Личный сайт\",\n        \"Home page\": \"Домашняя страница\",\n        \"Legacy\": \"Устаревший\",\n        \"Team Site\": \"Сайт группы\",\n        \"Grist Templates\": \"Шаблоны Grist\",\n        \"Billing account\": \"Платежный аккаунт\",\n        \"Manage team\": \"Управление командой\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Возврат домой\"\n    },\n    \"ApiKey\": {\n        \"Remove API Key\": \"Удалить ключ API\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Этот ключ API можно использовать для анонимного доступа к этой учетной записи через API.\",\n        \"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. Это приведет к отклонению всех будущих запросов, использующих этот ключ API. Вы все еще хотите удалить?\",\n        \"Create\": \"Создать\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Этот ключ API можно использовать для доступа к вашей учетной записи через API. Никому не сообщайте свой ключ API.\",\n        \"Remove\": \"Удалить\",\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Сгенерировав ключ API, вы сможете выполнять вызовы API для своей собственной учетной записи.\",\n        \"Click to show\": \"Нажмите, чтобы показать\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"Очистить ячейку\",\n        \"Delete {{count}} rows_one\": \"Удалить строку\",\n        \"Clear values\": \"Очистить значения\",\n        \"Copy anchor link\": \"Скопировать якорную ссылку\",\n        \"Delete {{count}} rows_other\": \"Удалить {{count}} строки\",\n        \"Delete {{count}} columns_one\": \"Удалить столбец\",\n        \"Delete {{count}} columns_other\": \"Удалить {{count}} столбцы\",\n        \"Duplicate rows_one\": \"Дублировать строку\",\n        \"Insert column to the left\": \"Вставить столбец слева\",\n        \"Duplicate rows_other\": \"Дублировать строки\",\n        \"Filter by this value\": \"Фильтровать по этому значению\",\n        \"Insert column to the right\": \"Вставить столбец справа\",\n        \"Insert row\": \"Вставить строку\",\n        \"Insert row above\": \"Вставить строку выше\",\n        \"Insert row below\": \"Вставить строку ниже\",\n        \"Reset {{count}} columns_one\": \"Сбросить столбец\",\n        \"Reset {{count}} columns_other\": \"Сброс {{count}} столбцов\",\n        \"Reset {{count}} entire columns_one\": \"Сбросить весь столбец\",\n        \"Reset {{count}} entire columns_other\": \"Сброс всех {{count}} столбцов\",\n        \"Comment\": \"Комментарий\",\n        \"Copy\": \"Копировать\",\n        \"Cut\": \"Вырезать\",\n        \"Paste\": \"Вставить\",\n        \"Copy with headers\": \"Копировать с заголовком\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Этот сайт группы приостановлен. Документы можно читать, но не изменять.\"\n    },\n    \"ChartView\": {\n        \"Each Y series is followed by a series for the length of error bars.\": \"После каждого ряда У следует ряд, описывающий ошибки.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Каждая ряд У сопровождается двумя рядами  ошибок вверху и внизу.\",\n        \"Create separate series for each value of the selected column.\": \"Создайте отдельные ряды для каждого значения выбранного столбца.\",\n        \"selected new group data columns\": \"выбранные новые группы столбцов данных\",\n        \"Pick a column\": \"Выберите столбец\",\n        \"Toggle chart aggregation\": \"Переключение агрегации диаграмм\",\n        \"LABEL\": \"ЯРЛЫК\",\n        \"Bar chart\": \"Столбчатая диаграмма\",\n        \"Pie chart\": \"Круговая диаграмма\",\n        \"Donut chart\": \"Диаграмма пончик\",\n        \"Area chart\": \"Диаграмма площади\",\n        \"Line chart\": \"Линейный график\",\n        \"Scatter plot\": \"Точечный график\",\n        \"Kaplan-Meier plot\": \"Участок Каплана-Мейера\",\n        \"Split series\": \"Разделенная серия\",\n        \"Invert Y-axis\": \"Инвертировать ось Y\",\n        \"Orientation\": \"Ориентация\",\n        \"Horizontal\": \"Горизонтальная\",\n        \"Vertical\": \"Вертикальная\",\n        \"Hole size\": \"Размер отверстия\",\n        \"Text size\": \"Размер текста\",\n        \"Connect gaps\": \"Соедините зазоры\",\n        \"Show markers\": \"Показывать маркеры\",\n        \"Symmetric\": \"Симметрично\",\n        \"Remove\": \"Удалить\",\n        \"non-numeric column is not shown\": \"нечисловой столбец не отображается\",\n        \"Log scale Y-axis\": \"Логарифмическая шкала по оси Y\",\n        \"non-numeric columns are not shown\": \"нечисловые столбцы не отображаются\",\n        \"Show total\": \"Показать общее количество\",\n        \"Add series\": \"Добавить серию\",\n        \"Split Series\": \"Разделенная серия\",\n        \"X-AXIS\": \"X-ось\",\n        \"SERIES\": \"СЕРИИ\",\n        \"Error bars\": \"Ошибка панелей\",\n        \"None\": \"Нет\",\n        \"Above+Below\": \"Выше+Ниже\",\n        \"Aggregate values\": \"Совокупные значения\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Доступ запрещен\",\n        \"Code View is available only when you have full document access.\": \"Просмотр кода доступен, только если у вас есть полный доступ к документу.\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (опционально)\",\n        \"Add\": \"Добавить\",\n        \"Enter Custom URL\": \"Введите пользовательский URL\",\n        \"Full document access\": \"Полный доступ к документу\",\n        \"Widget needs to {{read}} the current table.\": \"Виджет должен {{read}} текущую таблицу.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Виджет должен {{fullAccess}} для этого документа.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} не-{{columnType}} столбец не отображается\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} не-{{columnType}} столбец не отображается\",\n        \"Learn more about custom widgets\": \"Узнайте больше о пользовательских виджетах\",\n        \"Pick a {{columnType}} column\": \"Выберать {{columnType}} столбец\",\n        \"No document access\": \"Нет доступа к документу\",\n        \"Open configuration\": \"Открыть конфигурацию\",\n        \"Select Custom Widget\": \"Выбор пользовательского виджета\",\n        \"Pick a column\": \"Выберать столбец\",\n        \"Read selected table\": \"Просмотр выбранной таблицы\",\n        \"Widget does not require any permissions.\": \"Виджет не требует никаких разрешений.\",\n        \"No {{columnType}} columns in table.\": \"Нет {{columnType}} столбцов в таблице.\",\n        \"Clear selection\": \"Очистить выбор\",\n        \"Custom URL\": \"URL пользователя\",\n        \"Developer:\": \"Разраб:\",\n        \"Reject\": \"Отклонено\",\n        \"Widget\": \"Виджет\",\n        \"ACCESS LEVEL\": \"УРОВЕНЬ ДОСТУПА\",\n        \"Accept\": \"Принять\",\n        \"Last updated:\": \"Последнее обновление:\",\n        \"Missing description and author information.\": \"Отсутствует описание и информация об авторе.\",\n        \"Change custom widget\": \"Изменить кастомный виджет\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Сведения о доступе\",\n        \"Accounts\": \"Аккаунты\",\n        \"Document settings\": \"Настройки документа\",\n        \"Profile settings\": \"Настройки профиля\",\n        \"Switch Accounts\": \"Переключить аккаунты\",\n        \"Add account\": \"Добавить аккаунт\",\n        \"Manage team\": \"Управление командой\",\n        \"Sign in\": \"Войти\",\n        \"Toggle Mobile Mode\": \"Переключить мобильный режим\",\n        \"Pricing\": \"Цены\",\n        \"Sign out\": \"Выход\",\n        \"Support Grist\": \"Поддержка Grist\",\n        \"Upgrade Plan\": \"Обновить Подписку\",\n        \"Activation\": \"Активация\",\n        \"Billing account\": \"Расчетный счет\",\n        \"Sign up\": \"Зарегистрироваться\",\n        \"Use This Template\": \"Использовать этот шаблон\"\n    },\n    \"ActionLog\": {\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Таблица {{tableId}} впоследствии была удалена в действии #{{actionNum}}\",\n        \"Action Log failed to load\": \"Не удалось загрузить журнал действий\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Столбец {{colId}} впоследствии был удален в действии #{{action.actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Эта строка впоследствии была удалена в действии {{action.actionNum}}\",\n        \"All tables\": \"Все таблицы\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"Столбец {{colId}} впоследствии был удален в действии #{{actionNum}}\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Впоследствии эта строка была удалена в действии {{actionNum}}\",\n        \"History blocked because of access rules.\": \"История заблокирована из-за правил доступа.\"\n    },\n    \"ViewAsDropdown\": {\n        \"Example Users\": \"Пользователи для примеров\",\n        \"View as\": \"Посмотреть как\",\n        \"Users from table\": \"Пользователи из таблицы\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Добавить\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Правило {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Обновить формулу (Shift+Enter)\"\n    },\n    \"FieldBuilder\": {\n        \"Apply formula to data\": \"Применить формулу к данным\",\n        \"DATA FROM TABLE\": \"ДАННЫЕ ИЗ ТАБЛИЦЫ\",\n        \"CELL FORMAT\": \"ФОРМАТ ЯЧЕЙКИ\",\n        \"Changing multiple column types\": \"Изменение нескольких типов столбцов\",\n        \"Mixed format\": \"Смешанный формат\",\n        \"Mixed types\": \"Смешанные типы\",\n        \"Revert field settings for {{colId}} to common\": \"Вернуть настройки полей для {{colId}} к общим\",\n        \"Save field settings for {{colId}} as common\": \"Сохранить настройки полей для {{colId}} как общие\",\n        \"Use separate field settings for {{colId}}\": \"Использовать отдельные настройки полей для {{colId}}\",\n        \"Changing column type\": \"Изменение типа столбца\",\n        \"Common\": \"Общее\",\n        \"Separate\": \"Отдельное\",\n        \"Field in {{count}} views_one\": \"Поле в одном представлении\",\n        \"Field in {{count}} views_other\": \"Поле в {{count}} представлениях\"\n    },\n    \"FieldConfig\": {\n        \"TRIGGER FORMULA\": \"ТРИГГЕРНАЯ ФОРМУЛА\",\n        \"Column options are limited in summary tables.\": \"Параметры столбцов в сводных таблицах ограничены.\",\n        \"Clear and reset\": \"Очистка и сброс\",\n        \"COLUMN BEHAVIOR\": \"ПОВЕДЕНИЕ СТОЛБЦА\",\n        \"COLUMN LABEL AND ID\": \"ЯРЛЫК И ID СТОЛБЦА\",\n        \"Clear and make into formula\": \"Очистить и преобразовать в формулу\",\n        \"Convert column to data\": \"Преобразовать столбец в данные\",\n        \"Data columns_one\": \"Столбец данных\",\n        \"Convert to trigger formula\": \"Преобразовать в триггерную формулу\",\n        \"Set trigger formula\": \"Задать триггерную формулу\",\n        \"Formula columns_other\": \"Столбцы формул\",\n        \"Data columns_other\": \"Столбцы данных\",\n        \"Empty columns_one\": \"Пустой столбец\",\n        \"Empty columns_other\": \"Пустые столбцы\",\n        \"Formula columns_one\": \"Столбец формулы\",\n        \"Make into data column\": \"Преобразовать в столбец данных\",\n        \"Enter formula\": \"Введите формулу\",\n        \"Mixed Behavior\": \"Смешанное поведение\",\n        \"Set formula\": \"Задать формулу\",\n        \"DESCRIPTION\": \"ОПИСАНИЕ\"\n    },\n    \"MakeCopyMenu\": {\n        \"Update Original\": \"Обновить оригинал\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Будьте осторожны, в оригинале есть изменения, которых нет в этом документе. Эти изменения будут перезаписаны.\",\n        \"Enter document name\": \"Введите название документа\",\n        \"Name\": \"Наименование\",\n        \"As template\": \"Как шаблон\",\n        \"Cancel\": \"Отмена\",\n        \"However, it appears to be already identical.\": \"Однако, похоже он уже идентичен.\",\n        \"Organization\": \"Организация\",\n        \"Original Has Modifications\": \"Оригинал Имеет Модификации\",\n        \"Include the structure without any of the data.\": \"Включите структуру без каких-либо данных.\",\n        \"Sign up\": \"Регистрация\",\n        \"Original Looks Identical\": \"Оригинал выглядит идентично\",\n        \"Workspace\": \"Рабочее пространство\",\n        \"It will be overwritten, losing any content not in this document.\": \"Он будет перезаписан, потеряв все содержимое, не входящее в этот документ.\",\n        \"Original Looks Unrelated\": \"Оригинал выглядит несвязанным\",\n        \"Update\": \"Обновить\",\n        \"No destination workspace\": \"Нет целевого рабочего пространства\",\n        \"The original version of this document will be updated.\": \"Первоначальная версия этого документа будет обновлена.\",\n        \"Overwrite\": \"Перезаписать\",\n        \"Replacing the original requires editing rights on the original document.\": \"Для замены оригинала требуются права на редактирование исходного документа.\",\n        \"To save your changes, please sign up, then reload this page.\": \"Чтобы сохранить изменения, пожалуйста зарегистрируйтесь, а затем перезагрузите эту страницу.\",\n        \"You do not have write access to the selected workspace\": \"У вас нет прав на запись в выбранное рабочее пространство\",\n        \"You do not have write access to this site\": \"У вас нет права записи для этого сайта\",\n        \"Download document structure only (no data, for template use)\": \"Удалить все данные, но сохранить структуру для использования в качестве шаблона.\",\n        \"Download document and history\": \"Скачать полный документ и историю\",\n        \"Download document without history (can significantly reduce file size)\": \"Удалить историю документа (может значительно уменьшить размер файла)\",\n        \"Download\": \"Скачать\",\n        \"Download document\": \"Скачать документ\",\n        \".tar (recommended)\": \".tar (рекомендуемый)\",\n        \".zip\": \".zip\",\n        \"Download attachments\": \"Скачать вложения\",\n        \"Format:\": \"Формат:\",\n        \"Learn more\": \"Узнать больше\",\n        \"download attachments\": \"скачать вложения\",\n        \"Download full document and history\": \"Скачать полный документ и историю\",\n        \"Download an archive of all the attachments present in this document.\": \"Загрузите архив всех вложений, присутствующих в этом документе.\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Вложения являются внешними и не включены в эту загрузку. При загрузке документа в другую инсталляцию Grist вам также потребуется {{downloadLink}} отдельно. \"\n    },\n    \"ShareMenu\": {\n        \"Back to current\": \"Вернуться к текущему\",\n        \"Show in folder\": \"Показать в папке\",\n        \"Return to {{termToUse}}\": \"Вернуться к {{termToUse}}\",\n        \"Unsaved\": \"Несохраненный\",\n        \"Export XLSX\": \"Экспорт XLSX\",\n        \"Access Details\": \"Детали доступа\",\n        \"Current Version\": \"Текущая версия\",\n        \"Replace {{termToUse}}...\": \"Заменить {{termToUse}}…\",\n        \"Duplicate document\": \"Дублировать документ\",\n        \"Original\": \"Оригинал\",\n        \"Compare to {{termToUse}}\": \"Сравнить с {{termToUse}}\",\n        \"Download\": \"Скачать\",\n        \"Edit without affecting the original\": \"Редактировать, не затрагивая оригинал\",\n        \"Export CSV\": \"Экспорт CSV\",\n        \"Manage users\": \"Управление пользователями\",\n        \"Save copy\": \"Сохранить копию\",\n        \"Send to Google Drive\": \"Отправить в Google Диск\",\n        \"Save Document\": \"Сохранить документ\",\n        \"Work on a copy\": \"Работа над копией\",\n        \"Share\": \"Поделиться\",\n        \"Download...\": \"Скачать...\",\n        \"Comma Separated Values (.csv)\": \"Значения, разделенные запятыми (.csv)\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Значения, разделенные табуляцией (.tsv)\",\n        \"DOO Separated Values (.dsv)\": \"DOO Разделенные значения (.dsv)\",\n        \"Export as...\": \"Экспортировать как...\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Экспорт доступен только на страницах документов. Пожалуйста, выберите страницу документа и попробуйте еще раз.\",\n        \"Download attachments...\": \"Загрузка вложений...\",\n        \"Download document...\": \"Загрузка документа...\",\n        \"Suggest changes\": \"Предложить изменения\",\n        \"current version\": \"текущая версия\",\n        \"original\": \"оригинал\"\n    },\n    \"SortConfig\": {\n        \"Search Columns\": \"Поиск по столбцам\",\n        \"Add column\": \"Добавить столбец\",\n        \"Empty values last\": \"Последние пустые значения\",\n        \"Natural sort\": \"Естественная сортировка\",\n        \"Update data\": \"Обновить данные\",\n        \"Use choice position\": \"Use choice position\",\n        \"Remove sort setting - {{- columnName }} column\": \"Удалить настройку сортировки - {{- columnName }} столбца\",\n        \"Sort in ascending order (current: descending)\": \"Сортировка по возрастанию (текущая: по убыванию)\",\n        \"Sort in descending order (current: ascending)\": \"Сортировка по убыванию (текущая: по возрастанию)\",\n        \"Sort options - {{- columnName }} column\": \"Параметры сортировки - {{- columnName }} столбец\",\n        \"{{- columnName }} column\": \"{{- columnName }} столбец\"\n    },\n    \"SortFilterConfig\": {\n        \"Filter\": \"ФИЛЬТР\",\n        \"Revert\": \"Возврат\",\n        \"Save\": \"Сохранить\",\n        \"Sort\": \"СОРТИРОВКА\",\n        \"Update Sort & Filter settings\": \"Обновить настройки Сортировки & Фильтра\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Правила доступа\",\n        \"Delete\": \"Удалить\",\n        \"Delete document tour?\": \"Удалить тур по документу?\",\n        \"Code view\": \"Просмотр кода\",\n        \"Document history\": \"История документа\",\n        \"How-to Tutorial\": \"Учебное пособие\",\n        \"Raw data\": \"Исходные данные\",\n        \"Return to viewing as yourself\": \"Возврат к просмотру от своего имени\",\n        \"TOOLS\": \"ИНСТРУМЕНТЫ\",\n        \"Tour of this Document\": \"Тур по этому документу\",\n        \"Validate Data\": \"Проверка данных\",\n        \"Settings\": \"Настройки\",\n        \"API console\": \"API-консоль\",\n        \"context menu - Access Rules\": \"контекстное меню - Правила доступа\",\n        \"Delete document tour\": \"Удалить тур по документу\",\n        \"Preview the tutorial\": \"Предварительный просмотр учебника\",\n        \"Proposed changes\": \"Предлагаемые изменения\",\n        \"Suggest changes\": \"Предложить изменения\",\n        \"Suggestions\": \"Предложения\"\n    },\n    \"TypeTransformation\": {\n        \"Cancel\": \"Отмена\",\n        \"Revise\": \"Ревизировать\",\n        \"Apply\": \"Применить\",\n        \"Preview\": \"Предпросмотр\",\n        \"Update formula (Shift+Enter)\": \"Обновить формулу (Shift+Enter)\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Нажмите для копирования\",\n        \"Raw Data Tables\": \"Необработанные данные таблиц\",\n        \"Duplicate table\": \"Дублировать таблицу\",\n        \"Table ID copied to clipboard\": \"Идентификатор таблицы скопирован в буфер обмена\",\n        \"You do not have edit access to this document\": \"У вас нет доступа к редактированию этого документа\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Удалить {{formattedTableName}} данные, и удалить их со всех страниц?\",\n        \"Edit record card\": \"Редактировать карточку записи\",\n        \"Rename table\": \"Переименовать Таблицу\",\n        \"{{action}} Record Card\": \"{{action}} Карточка записи\",\n        \"Record Card\": \"Карточка записи\",\n        \"Remove table\": \"Удалить Таблицу\",\n        \"Record Card Disabled\": \"Карточка записи отключена\"\n    },\n    \"DocHistory\": {\n        \"Snapshots\": \"Снимки\",\n        \"Activity\": \"Активность\",\n        \"Compare to previous\": \"Сравните с предыдущим\",\n        \"Beta\": \"Beta\",\n        \"Compare to current\": \"Сравните с текущим\",\n        \"Snapshots are unavailable.\": \"Снимки недоступны.\",\n        \"Open snapshot\": \"Открыть Снимок\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Только владельцы имеют доступ к снимкам документов с правилами доступа.\"\n    },\n    \"DocMenu\": {\n        \"By Name\": \"По имени\",\n        \"(The organization needs a paid plan)\": \"(Организации необходим платный тариф)\",\n        \"Access Details\": \"Детали доступа\",\n        \"All documents\": \"Все документы\",\n        \"By Date Modified\": \"По дате изменения\",\n        \"Delete Forever\": \"Удалить навсегда\",\n        \"Move {{name}} to workspace\": \"Переместить {{name}} в рабочее пространство\",\n        \"Delete\": \"Удалить\",\n        \"Current workspace\": \"Текущее рабочее пространство\",\n        \"Delete {{name}}\": \"Удалить {{name}}\",\n        \"Deleted {{at}}\": \"Удалено {{at}}\",\n        \"Examples and Templates\": \"Примеры и Шаблоны\",\n        \"Discover More Templates\": \"Поиск большего числа шаблонов\",\n        \"Edited {{at}}\": \"Отредактировано {{at}}\",\n        \"Document will be permanently deleted.\": \"Документ будет удален навсегда.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Документы остаются в Корзине в течение 30 дней, после чего удаляются навсегда.\",\n        \"Examples & Templates\": \"Примеры и Шаблоны\",\n        \"Featured\": \"Рекомендуемые\",\n        \"Manage users\": \"Управление пользователями\",\n        \"Move\": \"Переместить\",\n        \"Other Sites\": \"Другие сайты\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Удалить навсегда \\\"{{name}}\\\"?\",\n        \"More Examples and Templates\": \"Больше Примеров и Шаблонов\",\n        \"Pinned Documents\": \"Закрепленные документы\",\n        \"Pin Document\": \"Закрепить документ\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Вы находитесь на {{siteName}} сайте. У вас также есть доступ к следующим сайтам:\",\n        \"Remove\": \"Удалить\",\n        \"Rename\": \"Переименовать\",\n        \"Requires edit permissions\": \"Требуются разрешения на редактирование\",\n        \"This service is not available right now\": \"Эта услуга сейчас недоступна\",\n        \"Restore\": \"Восстановить\",\n        \"To restore this document, restore the workspace first.\": \"Чтобы восстановить этот документ, сначала восстановите рабочую область.\",\n        \"Trash is empty.\": \"Корзина пуста.\",\n        \"Unpin Document\": \"Открепить документ\",\n        \"Workspace not found\": \"Рабочее пространство не найдено\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Вы находитесь на своем личном сайте. У вас также есть доступ к следующим сайтам:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Вы можете навсегда удалить рабочую область, если в ней нет документов.\",\n        \"Trash\": \"Корзина\",\n        \"Document will be moved to Trash.\": \"Документ будет перемещен в Корзину.\",\n        \"Any documents created in this site will appear here.\": \"Все документы, созданные на этом сайте, будут отображаться здесь.\",\n        \"Create my first document\": \"Создаю свой первый документ\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"У вас есть доступ к этому сайту только для чтения. На данный момент документов нет.\",\n        \"personal site\": \"персональный сайт\"\n    },\n    \"DocTour\": {\n        \"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.\": \"Не удается создать тур по документу на основе данных в этом документе. Убедитесь, что существует таблица с именем GristDocTour со столбцами Title, Body, Placement и Location.\",\n        \"No valid document tour\": \"Нет действительного тура по документу\"\n    },\n    \"DocumentSettings\": {\n        \"Document settings\": \"Настройки документа\",\n        \"Currency:\": \"Валюта:\",\n        \"Ok\": \"OK\",\n        \"Locale:\": \"Регион:\",\n        \"Save and Reload\": \"Сохранить и Перезагрузить\",\n        \"Save\": \"Сохранить\",\n        \"Time Zone:\": \"Часовой пояс:\",\n        \"API\": \"API\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Engine (экспериментальный {{span}} менять на свой страх и риск):\",\n        \"Local currency ({{currency}})\": \"Местная валюта ({{currency}})\",\n        \"This document's ID (for API use):\": \"Идентификатор этого документа (для использования API):\",\n        \"Document ID copied to clipboard\": \"Идентификатор документа скопирован в буфер обмена\",\n        \"Manage Webhooks\": \"Управление вебхуками\",\n        \"Webhooks\": \"Вебхуки\",\n        \"API console\": \"Консоль API\",\n        \"Default for DateTime columns\": \"По умолчанию для столбцов ДатаВремя\",\n        \"Document ID\": \"ID Документа\",\n        \"Notify other services on doc changes\": \"Уведомлять другие службы об изменениях в документе\",\n        \"python2 (legacy)\": \"python2 (устаревший)\",\n        \"Formula timer\": \"Таймер формулы\",\n        \"Reload data engine\": \"Перезагрузка механизма обработки данных\",\n        \"Start timing\": \"Старт тайминга\",\n        \"Stop timing...\": \"Остановка тайминга...\",\n        \"Currency\": \"Валюта\",\n        \"Data engine\": \"Механизм обработки данных\",\n        \"Hard reset of data engine\": \"Жесткий сброс системы обработки данных\",\n        \"python3 (recommended)\": \"python3 (рекомендуется)\",\n        \"Cancel\": \"Отмена\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Принудительно перезагрузите документ, синхронизируя формулы, и покажите результат.\",\n        \"Time reload\": \"Время перезагрузки\",\n        \"Reload data engine?\": \"Перезагрузить механизм обработки данных?\",\n        \"Timing is on\": \"Тайминг включен\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Вы можете внести изменения в документ, а затем остановить отсчет времени, чтобы увидеть результаты.\",\n        \"Coming soon\": \"Скоро будет\",\n        \"Copy to clipboard\": \"Скопировать в буфер обмена\",\n        \"API URL copied to clipboard\": \"API URL скопирован в буфер обмена\",\n        \"API documentation.\": \"Документация по API.\",\n        \"Base doc URL: {{docApiUrl}}\": \"Базовый URL документа: {{docApiUrl}}\",\n        \"Find slow formulas\": \"Найти медленные формулы\",\n        \"For currency columns\": \"Для столбцов валют\",\n        \"For number and date formats\": \"Для форматов чисел и дат\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"ID Документа, используется всегда при запросе REST API {{docId}}. Смотреть {{apiURL}}\",\n        \"ID for API use\": \"ID для использования API\",\n        \"Manage webhooks\": \"Управление webhook-ами\",\n        \"Locale\": \"Локализация\",\n        \"Reload\": \"Перезагрузить\",\n        \"Python\": \"Python\",\n        \"Python version used\": \"Python используемая версия\",\n        \"Time zone\": \"Часовой пояс\",\n        \"Try API calls from the browser\": \"Попробуйте вызовы API из браузера\",\n        \"Formula times\": \"Время вычисления формулы\",\n        \"Only available to document editors\": \"Доступно только редакторам документов\",\n        \"Only available to document owners\": \"Доступно только владельцам документа\",\n        \"Template mode\": \"Режим шаблона\",\n        \"Change document type\": \"Изменить тип документа\",\n        \"Change nature of document\": \"Изменить характер документа\",\n        \"Regular\": \"Обычный\",\n        \"Template\": \"Шаблон\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Документ автоматически открывается в {{fiddleModeDocUrl}}. Любой может отредактировать, что создаст новую несохраненную копию.\",\n        \"Tutorial\": \"Учебник\",\n        \"Regular document\": \"Обычный документ\",\n        \"Confirm change\": \"Подтвердите изменение\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Это приведет к полной перезагрузке механизма обработки данных. Это может помочь, если обработчик данных застрял в бесконечном цикле, бесконечно обрабатывает последние изменения или произошел сбой. Никакие данные не будут потеряны, за исключением, возможно, ожидающих выполнения действий в данный момент.\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Нормальное поведение документа. Все пользователи работают над одной и той же копией документа.\",\n        \"Edit\": \"Редактировать\",\n        \"Document automatically opens as a user-specific copy.\": \"Документ автоматически открывается как пользовательская копия.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Как только вы начнете отсчитывать время, Grist измерит время, необходимое для оценки каждой формулы. Это позволяет определить, какие формулы ответственны за низкую производительность при первом открытии документа или когда документ реагирует на изменения.\",\n        \"fiddle mode\": \"режим ответвления (fiddle)\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Нажмите «Начать перенос», чтобы перенести их во Внутреннее хранилище (хранится в файле документа SQLite).\",\n        \"External\": \"Внешний\",\n        \"Internal\": \"Внутренний\",\n        \"Transfer in progress\": \"Выполняется передача данных\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Некоторые существующие вложения все еще [внешние]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Некоторые существующие приложения все еще являются [внутренними]({{internalLink}})** (хранится в файле SQLite).\",\n        \"[Learn more.]({{learnLink}})\": \"[Подробнее.]({{learnLink}})\",\n        \"**Some existing attachments are still external**.\": \"**Некоторые существующие вложения все еще являются внешними**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Некоторые существующие вложения все еще являются внутренними** (хранится в файле SQLite).\",\n        \"Attachment storage\": \"Храненилище вложений\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Нажмите «Начать перенос», чтобы перенести их во Внешнее хранилище.\",\n        \"Being transfer\": \"Начать перенос\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Новые загруженные вложения будут помещены во Внешнее хранилище.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Новые загруженные вложения будут помещены во Внутреннее хранилище.\",\n        \"No external stores available\": \"Внешние хранилища недоступны\",\n        \"Preferred storage for this document\": \"Предпочтительное место хранения для этого документа\",\n        \"Start transfer\": \"Начать передачу\",\n        \"Upload\": \"Загрузить\",\n        \"Upload missing attachments\": \"Загрузите недостающие вложения\",\n        \"Uploading...\": \"Загрузка...\",\n        \"Document type\": \"Тип документа\",\n        \"Default\": \"По умолчанию\",\n        \"Default, template, or tutorial\": \"По умолчанию, шаблон или учебник\",\n        \"Allow others to suggest changes\": \"Разрешить другим предлагать изменения\",\n        \"Enable suggestions\": \"Включить предложения\",\n        \"Suggestions\": \"Предложения\",\n        \"experiment\": \"эксперимент\"\n    },\n    \"DocPageModel\": {\n        \"Add widget to page\": \"Добавить виджет на страницу\",\n        \"You do not have edit access to this document\": \"У вас нет доступа к редактированию этого документа\",\n        \"Add empty table\": \"Добавить пустую таблицу\",\n        \"Reload\": \"Перезагрузить\",\n        \"Add page\": \"Добавить страницу\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Владельцы документов могут попытаться восстановить документ. [{{error}}]\",\n        \"Enter recovery mode\": \"Войти в режим восстановления\",\n        \"Error accessing document\": \"Ошибка доступа к документу\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Извините, в доступе к этому документу было отказано. [{{error}}]\",\n        \"You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]\": \"Вы можете попробовать перезагрузить документ или использовать режим восстановления. Режим восстановления открывает документ, чтобы он был полностью доступен для владельцев и недоступен для других. Это также отключает формулы. [{{error}}]\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Пожалуйста, перезагрузите документ, и если ошибка сохраняется, свяжитесь с владельцами документов, чтобы попытаться восстановить документ. [{{error}}]\"\n    },\n    \"DocumentUsage\": {\n        \"Data size\": \"Размер данных\",\n        \"Size of attachments\": \"Размер Вложений\",\n        \"For higher limits, \": \"Для более высоких пределов, \",\n        \"Rows\": \"Строки\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Свяжитесь с владельцем сайта для обновления тарифа и увеличения лимитов.\",\n        \"Usage\": \"Использование\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"Статистика использования доступна только пользователям, имеющим полный доступ к данным документа.\",\n        \"start your 30-day free trial of the Pro plan.\": \"начните свою 30-дневную бесплатную пробную версию тарифа Pro.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Восстановить последнее редактирование\",\n        \"Undo discard\": \"Отменить сброс\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Скопируйте все данные в дополнение к структуре таблицы.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Вместо дублирования таблиц обычно лучше сегментировать данные, используя связанные представления. {{link}}\",\n        \"Name for new table\": \"Имя для новой таблицы\",\n        \"Only the document default access rules will apply to the copy.\": \"К копии будут применяться только правила доступа к документу по умолчанию.\"\n    },\n    \"ExampleInfo\": {\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Ознакомьтесь с нашим соответствующим учебником, чтобы узнать, как моделировать бизнес-данные, использовать формулы и управлять сложными представлениями данных.\",\n        \"Investment Research\": \"Инвестиционные исследования\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Ознакомьтесь с нашим соответствующим учебником, чтобы узнать, как связывать данные и создавать высокопроизводительные макеты.\",\n        \"Lightweight CRM\": \"Легкая CRM\",\n        \"Tutorial: Analyze & Visualize\": \"Учебник: Анализ и Визуализация\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Ознакомьтесь с нашим соответствующим учебником, чтобы узнать, как создавать сводные таблицы и графики, а также динамически связывать графики.\",\n        \"Tutorial: Create a CRM\": \"Учебник: Создание CRM\",\n        \"Tutorial: Manage Business Data\": \"Учебник: Управление бизнес-данными\",\n        \"Welcome to the Investment Research template\": \"Добро пожаловать в шаблон \\\"Инвестиционные исследования\\\"\",\n        \"Welcome to the Afterschool Program template\": \"Добро пожаловать в шаблон \\\"Внеклассная работа\\\"\",\n        \"Welcome to the Lightweight CRM template\": \"Добро пожаловать в шаблон \\\"Легкая CRM\\\"\",\n        \"Afterschool Program\": \"Внеклассная работа\"\n    },\n    \"FieldMenus\": {\n        \"Save as common settings\": \"Сохранить как общие настройки\",\n        \"Using separate settings\": \"Использование раздельных настроек\",\n        \"Revert to common settings\": \"Возврат к общим настройкам\",\n        \"Using common settings\": \"Использование общих настроек\",\n        \"Use separate settings\": \"Использовать раздельные настройки\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Параметры сетки\",\n        \"Vertical gridlines\": \"Вертикальные линии сетки\",\n        \"Horizontal gridlines\": \"Горизонтальные линии сетки\",\n        \"Zebra stripes\": \"Чересстрочная заливка\"\n    },\n    \"HomeIntro\": {\n        \"Interested in using Grist outside of your team? Visit your free \": \"Заинтересованы в использовании Grist вне вашей команды? Попробуйте бесплатно \",\n        \"Any documents created in this site will appear here.\": \"Любые документы, созданные на этом сайте, появятся здесь.\",\n        \"Browse Templates\": \"Просмотр шаблонов\",\n        \"Create empty document\": \"Создать пустой документ\",\n        \"Import document\": \"Импорт документа\",\n        \"Get started by creating your first Grist document.\": \"Начните с создания вашего первого документа Grist.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Начните с изучения шаблонов или создания своего первого документа Grist.\",\n        \"Invite Team Members\": \"Пригласить членов команды\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Начните с приглашения своей команды и создания вашего первого документа Grist.\",\n        \"Help Center\": \"Справочный центр\",\n        \"Welcome to Grist, {{name}}!\": \"Добро пожаловать в Grist, {{name}}!\",\n        \"Sign up\": \"Регистрация\",\n        \"Sprouts Program\": \"Программа Внедрения\",\n        \"This workspace is empty.\": \"Эта рабочая область пуста.\",\n        \"Visit our {{link}} to learn more.\": \"Посетите наш {{link}} чтобы узнать больше.\",\n        \"Welcome to Grist!\": \"Добро пожаловать в Grist!\",\n        \"Welcome to {{orgName}}\": \"Добро пожаловать в {{orgName}}\",\n        \"personal site\": \"личный сайт\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Вы имеете доступ к этому сайту только для просмотра. В настоящее время документов нет.\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} для сохранения своих данных. \",\n        \"Welcome to Grist, {{- name}}!\": \"Добро пожаловать в Grist, {{- name}}!\",\n        \"Welcome to {{- orgName}}\": \"Добро пожаловать в {{- orgName}}\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Посетите наш {{link}} чтобы узнать больше о Grist.\",\n        \"Sign in\": \"Вход\",\n        \"To use Grist, please either sign up or sign in.\": \"Для использования Grist, зарегистрируйтесь или войдите в систему.\",\n        \"Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.\": \"Узнайте больше в {{helpCenterLink}}, или найдите специалиста с {{sproutsProgram}}.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Узнайте больше в нашем {{helpCenterLink}}.\",\n        \"Only show documents\": \"Показывать только документы\"\n    },\n    \"HomeLeftPane\": {\n        \"Import document\": \"Импорт документа\",\n        \"All documents\": \"Все документы\",\n        \"Manage users\": \"Управление пользователями\",\n        \"Create workspace\": \"Создать рабочее пространство\",\n        \"Access Details\": \"Детали доступа\",\n        \"Create empty document\": \"Создать пустой документ\",\n        \"Delete\": \"Удалить\",\n        \"Examples & Templates\": \"Шаблоны\",\n        \"Rename\": \"Переименовать\",\n        \"Delete {{workspace}} and all included documents?\": \"Удалить {{workspace}} и все прилагаемые документы?\",\n        \"Trash\": \"Корзина\",\n        \"Workspaces\": \"Рабочие пространства\",\n        \"Workspace will be moved to Trash.\": \"Рабочее пространство будет перемещено в корзину.\",\n        \"Tutorial\": \"Обучение\",\n        \"Terms of service\": \"Условия использования\",\n        \"Grist Resources\": \"Ресурсы Grist\",\n        \"context menu - {{- workspaceName }}\": \"контекстное меню - {{- workspaceName }}\"\n    },\n    \"GridViewMenus\": {\n        \"Add to sort\": \"Добавить в сортировку\",\n        \"Column Options\": \"Параметры столбца\",\n        \"Add column\": \"Добавить столбец\",\n        \"Clear values\": \"Очистить значения\",\n        \"Delete {{count}} columns_one\": \"Удалить столбец\",\n        \"Filter Data\": \"Фильтровать данные\",\n        \"Convert formula to data\": \"Преобразование формулы в данные\",\n        \"Freeze {{count}} columns_one\": \"Закрепить этот столбец\",\n        \"Freeze {{count}} columns_other\": \"Закрепить {{count}} столбцы\",\n        \"Freeze {{count}} more columns_one\": \"Закрепить еще один столбец\",\n        \"Delete {{count}} columns_other\": \"Удалить {{count}} столбцы\",\n        \"Hide {{count}} columns_one\": \"Скрыть столбец\",\n        \"Freeze {{count}} more columns_other\": \"Закрепить ещё {{count}} столбцов\",\n        \"Insert column to the {{to}}\": \"Вставьте столбец в {{to}}\",\n        \"More sort options ...\": \"Больше параметров сортировки…\",\n        \"Hide {{count}} columns_other\": \"Скрыть {{count}} столбцы\",\n        \"Rename column\": \"Переименовать столбец\",\n        \"Reset {{count}} entire columns_one\": \"Сбросить весь столбец целиком\",\n        \"Reset {{count}} columns_other\": \"Сбросить {{count}} столбцов\",\n        \"Reset {{count}} columns_one\": \"Сброс столбца\",\n        \"Sorted (#{{count}})_one\": \"Отсортировано (#{{count}})\",\n        \"Reset {{count}} entire columns_other\": \"Сбросить {{count}} столбцов целиком\",\n        \"Sorted (#{{count}})_other\": \"Отсортировано (#{{count}})\",\n        \"Unfreeze all columns\": \"Открепить все столбцы\",\n        \"Show column {{- label}}\": \"Показать столбец {{- label}}\",\n        \"Sort\": \"Сортировать\",\n        \"Unfreeze {{count}} columns_other\": \"Открепить {{count}} столбцов\",\n        \"Unfreeze {{count}} columns_one\": \"Открепить этот столбец\",\n        \"Insert column to the left\": \"Вставить столбец слева\",\n        \"Insert column to the right\": \"Вставить столбец справа\",\n        \"Shortcuts\": \"Ярлыки\",\n        \"Show hidden columns\": \"Показывать скрытые столбцы\",\n        \"Created At\": \"Создан в\",\n        \"Authorship\": \"Авторство\",\n        \"Last Updated By\": \"Последний обновивший\",\n        \"Hidden Columns\": \"Скрытые столбцы\",\n        \"Apply on record changes\": \"Применять при изменениях записи\",\n        \"Created By\": \"Создатель\",\n        \"Last Updated At\": \"Последнее обновление в\",\n        \"Apply to new records\": \"Применять к новым записям\",\n        \"no reference column\": \"нет ссылочного столбца\",\n        \"Detect Duplicates in...\": \"Обнаружение дубликатов в...\",\n        \"UUID\": \"UUID\",\n        \"No reference columns.\": \"Нет ссылочных столбцов.\",\n        \"Duplicate in {{- label}}\": \"Дублирование в {{- label}}\",\n        \"Search columns\": \"Поисковые столбцы\",\n        \"Timestamp\": \"Метка времени\",\n        \"Adding UUID column\": \"Добавление столбца UUID\",\n        \"Adding duplicates column\": \"Добавление столбца дубликатов\",\n        \"Lookups\": \"Lookups\",\n        \"Add formula column\": \"Добавить вычисляемый столбец\",\n        \"Add column with type\": \"Добавить столбец с типом\",\n        \"Created at\": \"Создан в\",\n        \"Created by\": \"Создано\",\n        \"Detect duplicates in...\": \"Обнаружение дубликатов в...\",\n        \"Last updated at\": \"Последнее обновление\",\n        \"Last updated by\": \"Последнее обновление\",\n        \"Any\": \"Любые\",\n        \"Text\": \"Текст\",\n        \"Integer\": \"Целочисленное\",\n        \"Toggle\": \"Переключатель\",\n        \"Date\": \"Дата\",\n        \"Numeric\": \"Число\",\n        \"DateTime\": \"ДатаВремя\",\n        \"Choice\": \"Выбор\",\n        \"Choice List\": \"Выбор из списка\",\n        \"Reference\": \"Ссылка\",\n        \"Reference List\": \"Ссылки списком\",\n        \"Attachment\": \"Вложение\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Столбцы поиска\",\n        \"Search Columns\": \"Столбцы поиска\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Добавить столбец\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Закрепить фильтр - {{- columnName}} столбец (текущий: открепленный)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Открепить фильтр - {{- columnName}} столбец (текущий: закреплен)\",\n        \"remove filter - {{- columnName}} column\": \"удалить фильтр - {{- columnName}} столбец\",\n        \"{{- columnName }} column filters\": \"{{- columnName }} фильтры столбцов\"\n    },\n    \"GristDoc\": {\n        \"Import from file\": \"Импорт из файла\",\n        \"Added new linked section to view {{viewName}}\": \"Добавлен новый связанный раздел в представление {{viewName}}\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Сохраненный связанный раздел {{title}} в представлении {{name}}\",\n        \"go to webhook settings\": \"перейти к настройкам webhook\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Новые изменения временно приостановлены. Очередь Webhooks переполнена. Пожалуйста, проверьте настройки webhooks, удалите недопустимые webhooks и очистите очередь.\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"Объедините строки, соответствующие этим полям:\",\n        \"Select fields to match on\": \"Выберите поля для сопоставления\",\n        \"Update existing records\": \"Обновите существующие записи\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} несовпадающие поля при импорте\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} несовпадающее поле при импорте\",\n        \"{{count}} unmatched field_one\": \"{{count}} несовпадающее поле\",\n        \"{{count}} unmatched field_other\": \"{{count}} несовпадающие поля\",\n        \"Column Mapping\": \"Сопоставление столбцов\",\n        \"Column mapping\": \"Сопоставление столбцов\",\n        \"Destination table\": \"Таблица назначения\",\n        \"Grist column\": \"Grist столбец\",\n        \"Import from file\": \"Импорт из файла\",\n        \"New Table\": \"Новая таблица\",\n        \"Revert\": \"Восстановить\",\n        \"Skip\": \"Пропустить\",\n        \"Skip Table on Import\": \"Пропустить Таблицу при импорте\",\n        \"Source column\": \"Столбец Источника\",\n        \"Skip Import\": \"Пропустить импорт\",\n        \"Import options\": \"Параметры импорта\",\n        \"Cancel\": \"Отмена\",\n        \"Import\": \"Импорт\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Справочный центр\",\n        \"Accessibility\": \"Доступность\"\n    },\n    \"RowContextMenu\": {\n        \"Duplicate rows_other\": \"Дублировать строки\",\n        \"Duplicate rows_one\": \"Дублировать строку\",\n        \"Copy anchor link\": \"Скопировать якорную ссылку\",\n        \"Insert row below\": \"Вставить строку ниже\",\n        \"Insert row\": \"Вставить строку\",\n        \"Insert row above\": \"Вставить строку выше\",\n        \"Delete\": \"Удалить\",\n        \"View as card\": \"Посмотреть как карточку\",\n        \"Use as table headers\": \"Использовать в качестве заголовков таблицы\"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Обновление макета записи.\"\n    },\n    \"NotifyUI\": {\n        \"Cannot find personal site, sorry!\": \"Не могу найти личный сайт, извините!\",\n        \"Report a problem\": \"Сообщить о проблеме\",\n        \"Give feedback\": \"Дайте отзыв\",\n        \"Ask for help\": \"Попросить помощи\",\n        \"No notifications\": \"Нет уведомлений\",\n        \"Notifications\": \"Уведомления\",\n        \"Renew\": \"Продлить\",\n        \"Go to your free personal site\": \"Перейдите на свой бесплатный личный сайт\",\n        \"Upgrade Plan\": \"Обновить тариф\",\n        \"Manage billing\": \"Управление платежами\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Видео-тур по Grist\",\n        \"YouTube video player\": \"Видеоплеер YouTube\",\n        \"Video Tour\": \"Видео-тур\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Закончить\",\n        \"Next\": \"Дальше\",\n        \"Previous\": \"Предыдущий\"\n    },\n    \"PageWidgetPicker\": {\n        \"Group by\": \"Группировать по\",\n        \"Building {{- label}} widget\": \"Создание {{- label}} виджета\",\n        \"Add to page\": \"Добавить на страницу\",\n        \"Select data\": \"Выбор данных\",\n        \"Select widget\": \"Выбор виджета\",\n        \"New Table\": \"Новая таблица\",\n        \"SELECT BY\": \"SELECT BY\"\n    },\n    \"RightPanel\": {\n        \"SOURCE DATA\": \"ИСТОЧНИК ДАННЫХ\",\n        \"Theme\": \"Тема\",\n        \"Data\": \"Данные\",\n        \"fields_other\": \"Поля\",\n        \"Detach\": \"Отсоединить\",\n        \"GROUPED BY\": \"СГРУППИРОВАННЫЕ ПО\",\n        \"CHART TYPE\": \"ТИП ДИАГРАММЫ\",\n        \"COLUMN TYPE\": \"ТИП СТОЛБЦА\",\n        \"columns_one\": \"Столбец\",\n        \"Change widget\": \"Изменить виджет\",\n        \"CUSTOM\": \"ПЕРСОНАЛЬНО\",\n        \"DATA TABLE\": \"ТАБЛИЦА ДАННЫХ\",\n        \"fields_one\": \"Поле\",\n        \"columns_other\": \"Столбцы\",\n        \"DATA TABLE NAME\": \"НАЗВАНИЕ ТАБЛИЦЫ ДАННЫХ\",\n        \"Edit data selection\": \"Редактировать выборку данных\",\n        \"Row style\": \"Стиль строки\",\n        \"SELECT BY\": \"ВЫБОР ПО\",\n        \"series_other\": \"Ряды\",\n        \"SELECTOR FOR\": \"СЕЛЕКТОР ДЛЯ\",\n        \"Save\": \"Сохранить\",\n        \"Select widget\": \"Выберите виджет\",\n        \"series_one\": \"Ряд\",\n        \"Sort & filter\": \"Сортировка & Фильтрация\",\n        \"TRANSFORM\": \"ПРЕОБРАЗОВАНИЕ\",\n        \"WIDGET TITLE\": \"ЗАГОЛОВОК ВИДЖЕТА\",\n        \"You do not have edit access to this document\": \"У вас нет прав на редактирование этого документа\",\n        \"Widget\": \"Виджет\",\n        \"Add referenced columns\": \"Добавить ссылочные столбцы\",\n        \"Reset form\": \"Сброс формы\",\n        \"Field rules\": \"Правила полей\",\n        \"Field title\": \"Название поля\",\n        \"Display button\": \"Отобразить кнопку\",\n        \"Enter text\": \"Ввести текст\",\n        \"Layout\": \"Шаблон\",\n        \"Table column name\": \"Имя столбца таблицы\",\n        \"Enter redirect URL\": \"Введите URL перенаправления\",\n        \"Configuration\": \"Конфигурация\",\n        \"Default field value\": \"Значение поля по умолчанию\",\n        \"Hidden field\": \"Скрытое поле\",\n        \"Redirect automatically after submission\": \"Автоматическое перенаправление после отправки\",\n        \"Redirection\": \"Перенаправление\",\n        \"Required field\": \"Обязательное поле\",\n        \"Submission\": \"Отправка\",\n        \"Submit another response\": \"Отправить другой ответ\",\n        \"Submit button label\": \"Название кнопки отправки\",\n        \"Success text\": \"Текст успешной отправки\",\n        \"No field selected\": \"Нет выбранных полей\",\n        \"Select a field in the form widget to configure.\": \"Выберите поле в виджете формы для настройки.\",\n        \"Submit\": \"Отправить\",\n        \"Thank you! Your response has been recorded.\": \"Спасибо! Ваш ответ был учтён.\",\n        \"Chart options\": \"Параметры диаграммы\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Разрешить все\",\n        \"Deny all\": \"Запретить все\",\n        \"Read only\": \"Только чтение\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Удалить\",\n        \"Delete data and this page.\": \"Удалить данные и эту страницу.\",\n        \"The following tables will no longer be visible_other\": \"Следующие таблицы больше не будут видны\",\n        \"The following tables will no longer be visible_one\": \"Следующая таблица больше не будет видна\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Сохраните данные и удалите страницу. Таблица останется доступной в {{rawDataLink}}\",\n        \"raw data page\": \"страница с необработанными данными\",\n        \"Document pages\": \"Страницы документа\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Save layout\": \"Сохранить макет\",\n        \"Add field\": \"Добавить поле\",\n        \"Cancel\": \"Отмена\",\n        \"Create new field\": \"Создать новое поле\",\n        \"Show field {{- label}}\": \"Показать поле {{- label}}\"\n    },\n    \"RefSelect\": {\n        \"No columns to add\": \"Нет столбцов для добавления\",\n        \"Add column\": \"Добавить столбец\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Сбой импорта: \"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Скопировано в буфер обмена\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Создать новый сайт группы\",\n        \"Switch Sites\": \"Переключить сайты\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Управлять командой\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"Любое поле\",\n        \"Apply on changes to:\": \"Вычислять при изменениях в:\",\n        \"Current field \": \"Текущее поле \",\n        \"Apply on record changes\": \"Вычислять при изменениях записи\",\n        \"Apply to new records\": \"Вычислять при создании записей\",\n        \"Cancel\": \"Отмена\",\n        \"OK\": \"OK\",\n        \"Close\": \"Закрыть\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Оформление \",\n        \"Switch appearance automatically to match system\": \"Автоматическое переключение оформления в соответствии с системной настройкой\"\n    },\n    \"UserManagerModel\": {\n        \"In full\": \"Полный\",\n        \"None\": \"Без доступа\",\n        \"View & edit\": \"Просмотр & Редактирование\",\n        \"Viewer\": \"Наблюдатель\",\n        \"Owner\": \"Владелец\",\n        \"No Default Access\": \"Нет доступа по умолчанию\",\n        \"Editor\": \"Редактор\",\n        \"View only\": \"Только просмотр\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Неизвестный пользователь\",\n        \"You are viewing this document as\": \"Вы просматриваете этот документ от имени\",\n        \"View as Yourself\": \"Смотреть от Своего имени\",\n        \"You're seeing what this user would see if given access\": \"Вы видите то, что увидел бы этот пользователь, если бы ему был предоставлен доступ\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Расширенные настройки\",\n        \"Make On-Demand\": \"Преобразовать в Хранилище\",\n        \"Unmark On-Demand\": \"Убрать Хранилище\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Большие таблицы могут быть помечены как \\\"Хранилище\\\", чтобы избежать перегрузки сервера при вычислениях.\",\n        \"Section: \": \"Раздел: \",\n        \"Form\": \"Форма\",\n        \"Plugin: \": \"Плагин: \",\n        \"Edit card layout\": \"Редактирование шаблона карточки\",\n        \"Blocks\": \"Блоками\",\n        \"Compact\": \"Компактная\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Устаревшая функция\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"Таблицы по запросу устарели из-за отсутствия функциональности и удобства использования.\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Расширенная Сортировка & Фильтрация\",\n        \"Delete record\": \"Удалить запись\",\n        \"Delete widget\": \"Удалить виджет\",\n        \"Download as XLSX\": \"Скачать как XLSX\",\n        \"Download as CSV\": \"Скачать как CSV\",\n        \"Copy anchor link\": \"Копировать якорную ссылку\",\n        \"Edit card layout\": \"Редактирование шаблона карточки\",\n        \"Show raw data\": \"Показать необработанные данные\",\n        \"Open configuration\": \"Открыть конфигурацию\",\n        \"Print widget\": \"Печать виджета\",\n        \"Data selection\": \"Выбор данных\",\n        \"Widget options\": \"Параметры виджета\",\n        \"Add to page\": \"Добавить на страницу\",\n        \"Collapse widget\": \"Свернуть виджет\",\n        \"Create a form\": \"Создать форму\",\n        \"Duplicate widget\": \"Дублировать виджет\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"Должно быть невозможно сохранить значение простых данных в столбце формулы\",\n        \"Unable to finish saving edited cell\": \"Не удается завершить сохранение отредактированной ячейки\"\n    },\n    \"WidgetTitle\": {\n        \"Save\": \"Сохранить\",\n        \"Cancel\": \"Отмена\",\n        \"DATA TABLE NAME\": \"НАЗВАНИЕ ТАБЛИЦЫ ДАННЫХ\",\n        \"Override widget title\": \"Переопределить заголовок виджета\",\n        \"Provide a table name\": \"Укажите имя таблицы\",\n        \"WIDGET TITLE\": \"ЗАГОЛОВОК ВИДЖЕТА\",\n        \"WIDGET DESCRIPTION\": \"ОПИСАНИЕ ВИДЖЕТА\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Дублировать страницу {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Обратите внимание, что это не копирует данные, а создает другое представление тех же данных.\"\n    },\n    \"errorPages\": {\n        \"There was an error: {{message}}\": \"Была допущена ошибка: {{message}}\",\n        \"Access denied{{suffix}}\": \"Доступ запрещен{{suffix}}\",\n        \"Add account\": \"Добавить учетную запись\",\n        \"Error{{suffix}}\": \"Ошибка{{suffix}}\",\n        \"Go to main page\": \"Перейти на главную страницу\",\n        \"Page not found{{suffix}}\": \"Страница не найдена{{suffix}}\",\n        \"Signed out{{suffix}}\": \"Вышел{{suffix}}\",\n        \"Sign in\": \"Войти\",\n        \"Sign in again\": \"Войти снова\",\n        \"You are now signed out.\": \"Вы вышли из аккаунта.\",\n        \"Something went wrong\": \"Что-то пошло не так\",\n        \"There was an unknown error.\": \"Произошла неизвестная ошибка.\",\n        \"You do not have access to this organization's documents.\": \"У вас нет доступа к документам этой организации.\",\n        \"Contact support\": \"Обратитесь в службу поддержки\",\n        \"Sign in to access this organization's documents.\": \"Войдите, чтобы получить доступ к документам этой организации.\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Запрашиваемая страница не может быть найдена.{{separator}}Пожалуйста, проверьте URL-адрес и повторите попытку.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Вы вошли как {{email}}. Вы можете войти с другой учетной записью или запросить доступ у администратора.\",\n        \"Account deleted{{suffix}}\": \"Аккаунт удален{{suffix}}\",\n        \"Your account has been deleted.\": \"Ваш аккаунт был удален.\",\n        \"Sign up\": \"Регистрация\",\n        \"An unknown error occurred.\": \"Произошла неизвестная ошибка.\",\n        \"Build your own form\": \"Создайте свою собственную форму\",\n        \"Form not found\": \"Форма не найдена\",\n        \"Powered by\": \"Разработано\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Не удалось войти в систему.{{separator}}Пожалуйста, повторите попытку или обратитесь в службу поддержки.\",\n        \"Sign-in failed{{suffix}}\": \"Не удалось выполнить вход в систему{{suffix}}\",\n        \"Manage settings\": \"Управление настройками\",\n        \"Need Help?\": \"Нужна помощь?\",\n        \"There was an error\": \"Произошла ошибка\",\n        \"changes\": \"изменения\",\n        \"comments\": \"комментарии\",\n        \"this document\": \"этот документ\",\n        \"your email\": \"ваш email\",\n        \"We could not unsubscribe you\": \"Мы не смогли отписать вас\",\n        \"You are unsubscribed\": \"Вы отписались\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Вы всё ещё можете отписаться от этого документа, обновив свои предпочтения в настройках документа\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Вы больше не будете получать уведомления по электронной почте о {{changes}} в {{docName}} на {{email}}.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Вы больше не будете получать уведомления по электронной почте о {{comments}} в {{docName}} на адрес {{email}}.\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"СТИЛЬ ЯЧЕЙКИ\",\n        \"Default cell style\": \"Стиль ячейки по умолчанию\",\n        \"Open row styles\": \"Открыть форматы строк\",\n        \"Cell style\": \"Стиль ячейки\",\n        \"Mixed style\": \"Смешанный стиль\",\n        \"Default header style\": \"Стиль заголовка по умолчанию\",\n        \"HEADER STYLE\": \"СТИЛЬ ЗАГОЛОВКА\",\n        \"Header Style\": \"Стиль заголовка\"\n    },\n    \"TypeTransform\": {\n        \"Cancel\": \"Отмена\",\n        \"Preview\": \"Предпросмотр\",\n        \"Apply\": \"Применить\",\n        \"Update formula (Shift+Enter)\": \"Обновить формулу (Shift+Enter)\",\n        \"Revise\": \"Ревизировать\"\n    },\n    \"DiscussionEditor\": {\n        \"Resolve\": \"Решить\",\n        \"Cancel\": \"Отмена\",\n        \"Comment\": \"Комментарий\",\n        \"Edit\": \"Редактировать\",\n        \"Marked as resolved\": \"Помечено как решенное\",\n        \"Only current page\": \"Только текущая страница\",\n        \"Only my threads\": \"Только мои темы\",\n        \"Open\": \"Открыть\",\n        \"Remove\": \"Удалить\",\n        \"Reply\": \"Ответить\",\n        \"Save\": \"Сохранить\",\n        \"Showing last {{nb}} comments\": \"Показать последние {{nb}} комментарии\",\n        \"Show resolved comments\": \"Показать решенные комментарии\",\n        \"Started discussion\": \"Начатое обсуждение\",\n        \"Write a comment\": \"Написать комментарий\",\n        \"Reply to a comment\": \"Ответить на комментарий\",\n        \"updated\": \"обновлено\",\n        \"{{count}} comments_one\": \"{{count}} комментарий\",\n        \"{{count}} comments_other\": \"{{count}} комментариев\",\n        \"Copy link\": \"Скопировать ссылку\",\n        \"Remove thread\": \"Удалить тему\"\n    },\n    \"NumericTextBox\": {\n        \"Number Format\": \"Числовой формат\",\n        \"Currency\": \"Валюта\",\n        \"Default currency ({{defaultCurrency}})\": \"Валюта по умолчанию ({{defaultCurrency}})\",\n        \"Decimals\": \"Десятичные\",\n        \"Text\": \"Текст\",\n        \"max\": \"максимум\",\n        \"min\": \"минимум\",\n        \"Spinner\": \"Спиннер\",\n        \"Field Format\": \"Формат поля\"\n    },\n    \"WelcomeTour\": {\n        \"convert to card view, select data, and more.\": \"преобразовать в представление карточки, выбрать данные, и более.\",\n        \"creator panel\": \"панель создателя\",\n        \"template library\": \"библиотека шаблонов\",\n        \"Configuring your document\": \"Настройка вашего документа\",\n        \"Add new\": \"Добавить новый\",\n        \"Building up\": \"Сборка\",\n        \"Customizing columns\": \"Настройка столбцов\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Дважды щелкните или нажмите {{enter}} в ячейке, чтобы отредактировать ее. \",\n        \"Editing Data\": \"Редактирование данных\",\n        \"Enter\": \"Ввод\",\n        \"Flying higher\": \"Flying higher\",\n        \"Help Center\": \"Центр помощи\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Просмотрите наши {{templateLibrary}} чтобы узнать возможности и получить вдохновение.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Переключите {{creatorPanel}} для форматирования столбцов, \",\n        \"Welcome to Grist!\": \"Добро пожаловать в Grist!\",\n        \"Reference\": \"Ссылка\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Задайте параметры форматирования, формулы или типов столбца, такие как даты, варианты выбора или вложений. \",\n        \"Share\": \"Поделиться\",\n        \"Sharing\": \"Совместное использование\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Сделайте его реляционным! Используйте {{ref}} тип для связи таблиц. \",\n        \"Start with {{equal}} to enter a formula.\": \"Начните с {{equal}} чтобы ввести формулу.\",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Используйте кнопку \\\"Поделиться\\\" ({{share}}) чтобы поделиться документом или экспортировать данные.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Используйте {{addNew}} для добавления виджетов, страниц или импорта дополнительных данных. \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Используйте {{helpCenter}} для получения документации или вопросов.\",\n        \"AI Assistant\": \"AI Ассистент\"\n    },\n    \"ViewSectionMenu\": {\n        \"(empty)\": \"(пустой)\",\n        \"(customized)\": \"(индивидуальный)\",\n        \"FILTER\": \"ФИЛЬТР\",\n        \"Custom options\": \"Пользовательские параметры\",\n        \"(modified)\": \"(модифицированный)\",\n        \"Save\": \"Сохранить\",\n        \"Update Sort&Filter settings\": \"Обновить настройки Cортировки&Фильтра\",\n        \"SORT\": \"СОРТИРОВКА\",\n        \"Revert\": \"Вернуть\",\n        \"Sort and filter\": \"Сортировка и фильтрация\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Clear\": \"Очистить\",\n        \"Hidden Fields cannot be reordered\": \"Скрытые поля не могут быть переупорядочены\",\n        \"Cannot drop items into Hidden Fields\": \"Невозможно поместить элементы в скрытые поля\",\n        \"Select all\": \"Выбрать все\",\n        \"Hide {{label}}\": \"Скрыть {{label}}\",\n        \"Hidden {{label}}\": \"Скрытый {{label}}\",\n        \"Show {{label}}\": \"Показать {{label}}\",\n        \"Visible {{label}}\": \"Видимый {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Скрыть {{label}} (пакетный режим)\",\n        \"Show {{label}} (batch mode)\": \"Показать {{label}} (пакетный режим)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Marketing\": \"Маркетинг\",\n        \"Research\": \"Исследование\",\n        \"Sales\": \"Продажи\",\n        \"Welcome to Grist!\": \"Добро пожаловать в Grist!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Как вы пришли к Grist? Пожалуйста, помогите сделать наш сервис лучше.\",\n        \"Education\": \"Образование\",\n        \"HR & Management\": \"Управление персоналом и Менеджмент\",\n        \"Finance & Accounting\": \"Финансы и Бухгалтерия\",\n        \"Media Production\": \"Медиапроизводство\",\n        \"Type here\": \"Введите здесь\",\n        \"Product Development\": \"Разработка продукта\",\n        \"Other\": \"Прочее\",\n        \"IT & Technology\": \"IT и Технология\"\n    },\n    \"breadcrumbs\": {\n        \"fiddle\": \"ветка\",\n        \"override\": \"переопределение\",\n        \"recovery mode\": \"режим восстановления\",\n        \"snapshot\": \"снимок\",\n        \"unsaved\": \"несохраненный\",\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Вы можете вносить изменения, но они создадут новую копию\\nи не повлияют на исходный документ.\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Вы можете вносить изменения,\\nно они не повлияют на исходный документ.\\nВы можете оставить их в качестве предложений.\",\n        \"editing\": \"изменение\",\n        \"suggesting\": \"предложение\"\n    },\n    \"menus\": {\n        \"Select fields\": \"Выберите поля\",\n        \"Upgrade now\": \"Обновитесь сейчас\",\n        \"* Workspaces are available on team plans. \": \"* Рабочие пространства доступны в командных тарифах. \",\n        \"Any\": \"Любые\",\n        \"Numeric\": \"Численный\",\n        \"Text\": \"Текст\",\n        \"Toggle\": \"Переключатель\",\n        \"Date\": \"Дата\",\n        \"Choice\": \"Выбор\",\n        \"Reference List\": \"Ссылки списком\",\n        \"Choice List\": \"Выбор списком\",\n        \"Attachment\": \"Вложения\",\n        \"DateTime\": \"Дата и Время\",\n        \"Integer\": \"Целочисленный\",\n        \"Reference\": \"Ссылка\",\n        \"Search columns\": \"Поиск столбцов\",\n        \"By Name\": \"По Имени\",\n        \"By Date Modified\": \"По дате Изменения\",\n        \"Light\": \"Светлая\",\n        \"Custom\": \"Своя\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Отмена\",\n        \"Ok\": \"OK\",\n        \"Save\": \"Сохранить\",\n        \"Are you sure you want to delete these records?\": \"Вы уверены, что хотите удалить эти записи?\",\n        \"Are you sure you want to delete this record?\": \"Вы уверены, что хотите удалить эту запись?\",\n        \"Delete\": \"Удалить\",\n        \"Dismiss\": \"Отклонить\",\n        \"Don't ask again.\": \"Не спрашивать больше.\",\n        \"Don't show again.\": \"Больше не показывать.\",\n        \"Don't show tips\": \"Не показывать советы\",\n        \"Undo to restore\": \"Отменить для восстановления\",\n        \"Got it\": \"Принято\",\n        \"Don't show again\": \"Больше не показывать\",\n        \"TIP\": \"Совет\",\n        \"Confirm\": \"Подтвердить\"\n    },\n    \"search\": {\n        \"Find Next \": \"Найти далее \",\n        \"No results\": \"Нет результатов\",\n        \"Find Previous \": \"Найти предыдущий \",\n        \"Search in document\": \"Поиск в документе\",\n        \"Search\": \"Поиск\",\n        \"Close search bar\": \"Закрыть панель поиска\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Отправка файла в Google Drive\"\n    },\n    \"NTextBox\": {\n        \"false\": \"ложь\",\n        \"true\": \"истина\",\n        \"Field Format\": \"Формат поля\",\n        \"Lines\": \"Линейность\",\n        \"Multi line\": \"Многострочный\",\n        \"Single line\": \"Однострочный\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Дублировать страницу\",\n        \"Remove\": \"Удалить\",\n        \"Rename\": \"Переименовать\",\n        \"You do not have edit access to this document\": \"У вас нет доступа к редактированию этого документа\",\n        \"(default)\": \"(по умолчанию)\",\n        \"Collapse {{maybeDefault}}\": \"Свернуть {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Развернуть {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Установить по умолчанию: Свернуть\",\n        \"Set default: Expand\": \"Установить по умолчанию: Развернуть\",\n        \"context menu - {{- pageName }}\": \"контекстное меню - {{- pageName }}\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"Пользователи для примера\",\n        \"View as\": \"Посмотреть как\",\n        \"Users from table\": \"Пользователи из таблицы\",\n        \"Other users from table\": \"Другие пользователи из таблицы\",\n        \"Shared users\": \"Коллективные пользователи\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"ВАРИАНТЫ\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"ОПИСАНИЕ СТОЛБЦА\",\n        \"COLUMN LABEL\": \"ЯРЛЫК СТОЛБЦА\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN ID: \": \"ID СТОЛБЦА: \",\n        \"Cancel\": \"Отменить\",\n        \"COLUMN LABEL\": \"ЯРЛЫК СТОЛБЦА\",\n        \"Save\": \"Сохранить\",\n        \"COLUMN DESCRIPTION\": \"ОПИСАНИЕ СТОЛБЦА\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"Добавить другое правило\",\n        \"Add conditional style\": \"Добавить условное форматирование\",\n        \"Error in style rule\": \"Ошибка в правиле форматирования\",\n        \"Row style\": \"Форматирование строки\",\n        \"Rule must return True or False\": \"Правило должно возвращать значение Истина или Ложь\",\n        \"IF...\": \"ЕСЛИ...\",\n        \"Conditional Style\": \"Условный стиль\",\n        \"Row Style\": \"Форматирование строки\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Неверная валюта\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Преобразовать столбец в формулу\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"ФОРМАТ ЯЧЕЙКИ\",\n        \"Row ID\": \"ID строки\",\n        \"SHOW COLUMN\": \"ПОКАЗАТЬ СТОЛБЕЦ\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Язык\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Столбец или поле обязательны для заполнения\",\n        \"Errors in all {{numErrors}} cells\": \"Ошибки во всех {{numErrors}} ячейках\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Ошибки в {{numErrors}} из {{numCells}} ячеек\",\n        \"Error in the cell\": \"Ошибка в ячейке\",\n        \"editingFormula is required\": \"editingFormula обязательно\",\n        \"Enter formula or {{button}}.\": \"Введите формулу или {{button}}.\",\n        \"Enter formula.\": \"Введите формулу.\",\n        \"Expand Editor\": \"Развернуть редактор\",\n        \"use AI Assistant\": \"использовать AI Помощника\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[link label] URL\"\n    },\n    \"GristTooltips\": {\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Нажатие {{EyeHideIcon}} в каждой ячейке скрывает поле из этого представления, не удаляя его.\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Формулы, которые срабатывают в определенных случаях и сохраняют вычисленное значение в виде данных.\",\n        \"Learn more.\": \"Узнать больше.\",\n        \"Linking Widgets\": \"Связывание виджетов\",\n        \"Nested Filtering\": \"Вложенная фильтрация\",\n        \"Editing Card Layout\": \"Редактирование макета карточки\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Закрепленные фильтры отображаются в виде кнопок над виджетом.\",\n        \"Pinning Filters\": \"Закрепление фильтров\",\n        \"Raw Data page\": \"Страница необработанных данных\",\n        \"Reference Columns\": \"Ссылочные столбцы\",\n        \"Select the table containing the data to show.\": \"Выберите таблицу, содержащую данные для отображения.\",\n        \"Select the table to link to.\": \"Выберите таблицу для ссылки.\",\n        \"Selecting Data\": \"Выбор данных\",\n        \"The total size of all data in this document, excluding attachments.\": \"Общий размер всех данных в этом документе, за исключением вложений.\",\n        \"They allow for one record to point (or refer) to another.\": \"Они позволяют одной записи указывать (или ссылаться) на другую.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"В этом секрет динамичных и продуктивных макетов Grist.\",\n        \"Updates every 5 minutes.\": \"Обновляется каждые 5 минут.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Используйте \\\\u{1D6BA} значок для создания сводных таблиц для итогов или промежуточных итогов.\",\n        \"You can filter by more than one column.\": \"Вы можете выполнить фильтрацию более чем по одному столбцу.\",\n        \"entire\": \"весь\",\n        \"Only those rows will appear which match all of the filters.\": \"Появятся только те строки, которые соответствуют всем фильтрам.\",\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Примените условное форматирование к ячейкам в этом столбце при выполнении условий формулы.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Примените условное форматирование к строкам на основе формул.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Ячейки в ссылочном столбце всегда идентифицируют {{entire}} запись в этой таблице, но вы можете выбрать, какой столбец из этой записи показывать.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Нажмите на “Открыть стили строк”, чтобы применить условное форматирование к строкам.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Нажмите кнопку Добавить, чтобы создать новые документы или рабочие области или импортировать данные.\",\n        \"Link your new widget to an existing widget on this page.\": \"Свяжите новый виджет с существующим виджетом на этой странице.\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Переставляйте поля в карточке, перетаскивая и изменяя размер ячеек.\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Ссылочные столбцы являются ключом к {{relational}} данным в Grist.\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"На странице необработанных данных перечислены все таблицы данных в вашем документе, включая сводные таблицы и таблицы, не включенные в макеты страниц.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Попробуйте внести изменения в копию, затем решите, следует ли заменить оригинал вашими правками.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Открепите, чтобы скрыть кнопку, сохранив фильтр.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Полезно для хранения метки времени или автора новой записи, очистки данных и многого другого.\",\n        \"Access Rules\": \"Правила доступа\",\n        \"Add new\": \"Добавить новое\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Правила доступа дают вам возможность создавать детальные правила, определяющие, кто может просматривать или редактировать части вашего документа.\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Используйте 𝚺 значок для создания сводных таблиц для итогов или промежуточных итогов.\",\n        \"relational\": \"реляционный\",\n        \"Anchor Links\": \"Якорные ссылки\",\n        \"Custom Widgets\": \"Пользовательские виджеты\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Чтобы создать якорную ссылку, которая приведет пользователя к определенной ячейке, щелкните по строке и нажмите {{shortcut}}.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Вы можете выбрать один из наших готовых виджетов или встроить свой собственный, указав его полный URL-адрес.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"Чтобы настроить календарь, выберите столбцы для дат начала/окончания и названий событий. Обратите внимание на тип каждого столбца.\"\n        },\n        \"Calendar\": \"Календарь\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Не можете найти нужные столбцы? Нажмите «Изменить виджет», чтобы выбрать таблицу с данными о событиях.\",\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID - это случайно сгенерированная строка, которая полезна для уникальных идентификаторов и ключевых ссылок.\",\n        \"Lookups return data from related tables.\": \"Lookups возвращают данные из связанных таблиц.\",\n        \"Use reference columns to relate data in different tables.\": \"Используйте ссылочные столбцы для сопоставления данных в разных таблицах.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Вы можете выбрать виджеты, доступные вам в раскрывающемся списке, или встроить свой собственный, указав его полный URL-адрес.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Формулы поддерживают множество функций Excel, полный синтаксис Python и включает полезного помощника AI.\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Создавайте простые формы прямо в Grist и делитесь ими одним щелчком мыши с помощью нашего нового виджета.. {{learnMoreButton}}\",\n        \"Forms are here!\": \"Формы уже здесь!\",\n        \"Learn more\": \"Узнать больше\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Эти правила применяются после обработки всех правил столбцов, если это применимо.\",\n        \"Example: {{example}}\": \"Пример: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"Отфильтровать выпадающие значения по условиям.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Двусторонние ссылки в настоящее время не поддерживаются для столбцов формулы или триггерных формул\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Виджеты сообщества создаются и поддерживаются участниками сообщества Grist.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Создает обратный столбец в целевой таблице, который можно редактировать с любого конца.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Это ограничение возникает, когда один конец двусторонней ссылки сконфигурирован как одиночная ссылка.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Это ограничение возникает, когда один столбец в двусторонней ссылке имеет ссылочный тип.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Чтобы разрешить несколько значений, измените тип столбца с типа «Ссылка» на «Ссылки списком».\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Чтобы разрешить несколько назначений, измените тип указанного столбца на Ссылки списком.\",\n        \"[Learn more.]({{link}})\": \"[Подробнее.]({{link}})\",\n        \"The preview below this header shows how the selected user will see this document\": \"Предварительный просмотр под этим заголовком показывает, как выбранный пользователь увидит этот документ\",\n        \"Manage users and resources in a Grist installation.\": \"Управляйте пользователями и ресурсами напрямую в Grist.\",\n        \"Summary tables can only contain formula columns.\": \"Сводные таблицы могут содержать только столбцы формул.\",\n        \"The new Grist Assistant is here!\": \"Новый Ассистент Grist здесь!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Формулы поддерживают множество функций Excel и полный синтаксис Python.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Внутреннее хранилище означает, что все вложения хранятся в файле документа SQLite, в то время как внешнее хранилище означает, что все вложения хранятся в одном и том же внешнем хранилище.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Это позволяет добавлять вложения, отсутствующие во внешнем хранилище, например, в импортированном документе. Здесь можно загрузить только архивы вложений .tar, загруженные из Grist.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Разбирайтесь в своих данных и формулах, изменяйте их и работайте с ними с помощью нового AI Ассистента Grist!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs. Do not submit passwords through this form, and be careful with links in it. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Эта форма создана пользователем Grist и не одобрена Grist Labs. Не отправляйте пароли через эту форму и будьте осторожны со ссылками в ней. Сообщите о вредоносных формах в [{{mail}}](mailto:{{mail}}).\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Эта форма создана пользователем Grist и не подтверждена компанией Grist Labs, Inc. или любой стороной, предоставляющей эту услугу. В целях вашей безопасности не вводите пароли через эту форму и будьте осторожны при переходе по встроенным ссылкам. Сообщить о вредоносной форме по [{{mail}}](mailto:{{mail}}).\",\n        \"Set the maximum number of lines for multi-line text.\": \"Установите максимальное количество строк для многострочного текста.\",\n        \"Comments are here!\": \"Комментарии здесь!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Вы можете добавлять комментарии к ячейкам, отвечать на темы комментариев и создать @-упоминание сотрудникам.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Создаёт новый столбец Списка ссылок в целевой таблице, причём как этот, так и целевые столбцы можно редактировать и синхронизировать.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"Если этот флажок установлен, значение этого поля по умолчанию может быть предварительно заполнено из URL-адреса с использованием параметров запроса.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"С помощью предложений пользователи вносят изменения в личную копию, не изменяя исходный документ, а затем отправляют эти предложения на рассмотрение владельцу документа для принятия решения об их интеграции.\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"ОПИСАНИЕ\",\n        \"Set description\": \"Задать описание\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"Закрыть Панель Создателя\",\n        \"Open creator panel\": \"Открыть Панель Создателя\",\n        \"Document header\": \"Заголовок документа\",\n        \"Main content\": \"Основной контент\",\n        \"Main navigation and document settings (left panel)\": \"Основные настройки навигации и документа (левая панель)\",\n        \"Close navigation panel (left panel)\": \"Закрыть панель навигации (левая панель)\",\n        \"Open navigation panel (left panel)\": \"Открыть панель навигации (левая панель)\",\n        \"Creator panel (right panel)\": \"Панель создателя (правая панель)\"\n    },\n    \"ColumnTitle\": {\n        \"COLUMN ID: \": \"ID СТОЛБЦА: \",\n        \"Cancel\": \"Отмена\",\n        \"Column label\": \"Метка столбца\",\n        \"Column ID copied to clipboard\": \"ID столбца скопирован в буфер обмена\",\n        \"Add description\": \"Добавить описание\",\n        \"Column description\": \"Описание столбца\",\n        \"Save\": \"Сохранить\",\n        \"Provide a column label\": \"Укажите метку столбца\",\n        \"Close\": \"Закрыть\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Понятно\",\n        \"Unavailable Command\": \"Недоступная команда\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"Очистить поле\",\n        \"Copy\": \"Копировать\",\n        \"Copy anchor link\": \"Скопировать якорную ссылку\",\n        \"Cut\": \"Вырезать\",\n        \"Paste\": \"Вставить\",\n        \"Hide field\": \"Скрыть поле\",\n        \"Comment\": \"Комментарий\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Очистить очередь\",\n        \"Webhook settings\": \"Настройки вебхука\",\n        \"Cleared webhook queue.\": \"Очистка очереди вебхуков.\",\n        \"Enabled\": \"Активировано\",\n        \"Columns to check when update (separated by ;)\": \"Столбцы для проверки при обновлении (разделитель ;)\",\n        \"Event Types\": \"Типы событий\",\n        \"Ready Column\": \"Триггерный столбец\",\n        \"Memo\": \"Заметка\",\n        \"Name\": \"Название\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Фильтрация изменений в этих столбцах (идентификаторы, разделенные точкой с запятой)\",\n        \"Removed webhook.\": \"Удаленный вебхук.\",\n        \"Sorry, not all fields can be edited.\": \"К сожалению, не все поля можно редактировать.\",\n        \"Status\": \"Статус\",\n        \"URL\": \"URL\",\n        \"Webhook Id\": \"Вебхук Id\",\n        \"Table\": \"Таблица\",\n        \"Header Authorization\": \"Заголовок для авторизации\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooks Недоступно в Несохраненных Копиях Документов\"\n    },\n    \"FormulaAssistant\": {\n        \"Ask the bot.\": \"Спроси у бота.\",\n        \"Capabilities\": \"Возможности\",\n        \"Community\": \"Сообщество\",\n        \"Data\": \"Данные\",\n        \"Formula Cheat Sheet\": \"Шпаргалка по формуле\",\n        \"Formula Help. \": \"Справка по формуле. \",\n        \"Function List\": \"Список функций\",\n        \"Grist's AI Assistance\": \"Помощь AI Grist'а\",\n        \"Grist's AI Formula Assistance. \": \"Помощник по формуле AI Grist'a. \",\n        \"New Chat\": \"Новый чат\",\n        \"Preview\": \"Предпросмотр\",\n        \"Regenerate\": \"Регенерировать\",\n        \"Save\": \"Сохранить\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Посмотрите наш {{helpFunction}} и {{formulaCheat}}, или поситите наше {{community}} для получения дополнительной помощи.\",\n        \"Tips\": \"Советы\",\n        \"Need help? Our AI assistant can help.\": \"Нужна помощь? Наш AI помощник может помочь.\",\n        \"AI Assistant\": \"AI Помощник\",\n        \"Apply\": \"Применить\",\n        \"Cancel\": \"Отмена\",\n        \"Clear conversation\": \"Очистить беседу\",\n        \"Code view\": \"Просмотр кода\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Я могу помочь только с формулами. Я не могу создавать таблицы, столбцы и представления или устанавливать правила доступа.\",\n        \"Learn more\": \"Узнать больше\",\n        \"Press Enter to apply suggested formula.\": \"Нажмите Enter, чтобы применить предложенную формулу.\",\n        \"Sign Up for Free\": \"Бесплатно зарегестрироваться\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Зарегистрируйте бесплатную учетную запись Grist, чтобы начать использовать AI Помощника по формулам.\",\n        \"There are some things you should know when working with me:\": \"Есть некоторые вещи, которые вы должны знать, работая со мной:\",\n        \"What do you need help with?\": \"Как я могу вам помочь?\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Привет, я AI Помощник по формулам Grist.\",\n        \"Formula AI Assistant is only available for logged in users.\": \"AI Ассистент для формул доступен только для зарегистрированных пользователей.\",\n        \"For higher limits, contact the site owner.\": \"Для увеличения лимитов, обратитесь к владельцу сайта.\",\n        \"upgrade to the Pro Team plan\": \"перейти на план Pro Team\",\n        \"You have used all available credits.\": \"Вы использовали все доступные кредиты.\",\n        \"upgrade your plan\": \"обновите свой тавиф\",\n        \"You have {{numCredits}} remaining credits.\": \"У вас осталось {{numCredits}} кредитов.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Для увеличения лимитов, {{upgradeNudge}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Общайтесь со мной как с человеком. Не нужно указывать названия таблиц и столбцов. Например, вы можете попросить \\\"Пожалуйста, подсчитайте общую сумму счета.\\\"\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Нажмите для вставки\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"С возвращением\",\n        \"You can always switch sites using the account menu.\": \"Вы всегда можете переключиться с одного сайта на другой, используя меню учетной записи.\",\n        \"You have access to the following Grist sites.\": \"У вас есть доступ к следующим сайтам Grist.\"\n    },\n    \"UserManager\": {\n        \"Add {{member}} to your team\": \"Добавить {{member}} в вашу команду\",\n        \"Anyone with link \": \"Любой по ссылке \",\n        \"Cancel\": \"Отмена\",\n        \"Close\": \"Закрыть\",\n        \"Collaborator\": \"Соавтор\",\n        \"Confirm\": \"Подтвердить\",\n        \"Copy link\": \"Скопировать Ссылку\",\n        \"Guest\": \"Гость\",\n        \"Invite multiple\": \"Пригласить несколько\",\n        \"Manage members of team site\": \"Управление участниками группового сайта\",\n        \"On\": \"Включено\",\n        \"Open Access Rules\": \"Открыть Правила Доступа\",\n        \"Outside collaborator\": \"Сторонний соавтор\",\n        \"Public access\": \"Публичный доступ\",\n        \"Public access: \": \"Публичный доступ: \",\n        \"Remove my access\": \"Удалить мой доступ\",\n        \"Save & \": \"Сохранить & \",\n        \"Team member\": \"Участник команды\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Пользователь наследует разрешения от {{parent})}. Для удаления,           установите параметр 'Наследовать доступ' в 'None'.\",\n        \"User may not modify their own access.\": \"Пользователь не может изменять свой собственный доступ.\",\n        \"Your role for this team site\": \"Ваша роль для этого группового сайта\",\n        \"Your role for this {{resourceType}}\": \"Ваша роль для этого {{resourceType}}\",\n        \"free collaborator\": \"свободный соавтор\",\n        \"member\": \"участник\",\n        \"team site\": \"групповой сайт\",\n        \"{{collaborator}} limit exceeded\": \"{{collaborator}} лимит превышен\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} из {{limitTop}} {{collaborator}}s\",\n        \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\": \"У пользователя есть доступ к просмотру {{resource}} в результате установленного вручную доступа к внутренним ресурсам. Если удалить его здесь, этот пользователь потеряет доступ к внутренним ресурсам.\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Пользователь наследует разрешения от {{parent}}. Чтобы удалить, установите для параметра 'Наследовать доступ' значение 'None'.\",\n        \"Allow anyone with the link to open.\": \"Разрешить открывать всем, у кого есть ссылка.\",\n        \"Grist support\": \"Grist поддержка\",\n        \"Invite people to {{resourceType}}\": \"Пригласите людей к {{resourceType}}\",\n        \"Create a team to share with more people\": \"Создайте команду, чтобы поделиться с большим количеством людей\",\n        \"Link copied to clipboard\": \"Ссылка скопирована в буфер обмена\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Отсутствие доступа по умолчанию позволяет         предоставлять доступ к отдельным документам или рабочим областям, а не ко всему групповому сайту.\",\n        \"Off\": \"Выключено\",\n        \"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}}.\": \"После того, как вы удалили свой собственный доступ,             вы не сможете вернуть его без посторонней помощи              от кого-то другого, имеющего достаточный доступ к {{name}}.\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Публичный доступ унаследован от {{parent}}. Чтобы удалить, установите для параметра «Наследовать доступ» значение 'None'.\",\n        \"guest\": \"гость\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Отсутствие доступа по умолчанию позволяет предоставлять доступ к отдельным документам или рабочим областям, а не ко всему групповому сайту.\",\n        \"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}}.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Вы собираетесь лишить себя доступа к {{resourceType}}\",\n        \"Inherit access: \": \"Наследование доступа: \",\n        \"Access overview\": \"Обзор прав доступа\"\n    },\n    \"SupportGristNudge\": {\n        \"Close\": \"Закрыть\",\n        \"Help Center\": \"Центр помощи\",\n        \"Opt in to Telemetry\": \"Включить телеметрию\",\n        \"Support Grist\": \"Поддержка Grist\",\n        \"Support Grist page\": \"Страница поддержки Grist\",\n        \"Contribute\": \"Участвовать\",\n        \"Opted In\": \"Подключено\",\n        \"Admin Panel\": \"Панель администратора\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Спасибо! Ваше доверие и поддержка очень ценны. Откажитесь в любой момент с помощью {{link}} в меню пользователя.\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"GitHub Спонсорская страница\",\n        \"Help Center\": \"Центр помощи\",\n        \"Home\": \"Домой\",\n        \"Manage Sponsorship\": \"Управление спонсорством\",\n        \"Support Grist\": \"Поддержать Grist\",\n        \"Telemetry\": \"Телеметрия\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Этот экземпляр отключен от телеметрии. Только администратор сайта имеет право изменить это.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Вы можете отказаться от телеметрии в любое время на этой странице.\",\n        \"You have opted in to telemetry. Thank you!\": \"Вы включили телеметрию. Спасибо!\",\n        \"You have opted out of telemetry.\": \"Вы отказались от телеметрии.\",\n        \"Opt in to Telemetry\": \"Включить телеметрию\",\n        \"Opt out of Telemetry\": \"Отказаться от телеметрии\",\n        \"Sponsor Grist Labs on GitHub\": \"Спонсор Grist Labs на GitHub\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"В этом экземпляре включена телеметрия. Только администратор сайта может изменить это.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Мы собираем только статистику использования, как описано в {{link}} и никогда не собираем содержимое документов.\",\n        \"Sponsor\": \"Спонсор\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Программное обеспечение Grist разработано компанией Grist Labs, которая предлагает бесплатные и платные хостинговые планы. Мы также предоставляем код Grist под стандартной бесплатной лицензией open OSS (Apache 2.0) на {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Поддержите Grist, выбрав телеметрию, которая помогает нам понять, как используется продукт, и определить приоритеты будущих улучшений.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Мы - небольшая и целеустремленная команда. Ваша поддержка очень важна для нас. Это также показывает другим, что за этим продуктом стоит сплоченное сообщество.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Вы можете поддержать разработку Grist с открытым исходным кодом, спонсируя нас на нашем сайте. {{link}}.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Нет данных\",\n        \"No row selected in {{title}}\": \"Нет выбранных строк в {{title}}\",\n        \"Not all data is shown\": \"Не все данные отображаются\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"ОПИСАНИЕ\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Поиск\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Искать на всех страницах\",\n        \"Search all tables\": \"Поиск по всем таблицам\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Свернуть редактор\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Максимизировать\",\n        \"Minimize\": \"Минимизировать\"\n    },\n    \"CardContextMenu\": {\n        \"Insert card above\": \"Вставить карточку выше\",\n        \"Duplicate card\": \"Дублировать карточку\",\n        \"Insert card below\": \"Вставить карточку ниже\",\n        \"Delete card\": \"Удалить карточку\",\n        \"Copy anchor link\": \"Скопировать якорную ссылку\",\n        \"Insert card\": \"Вставить карточку\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Скрытые поля\"\n    },\n    \"Menu\": {\n        \"Cut\": \"Вырезать\",\n        \"Building blocks\": \"Строительные блоки\",\n        \"Columns\": \"Столбцы\",\n        \"Copy\": \"Копировать\",\n        \"Insert question above\": \"Вставить вопрос выше\",\n        \"Insert question below\": \"Вставить вопрос ниже\",\n        \"Paragraph\": \"Параграф\",\n        \"Paste\": \"Вставить\",\n        \"Separator\": \"Разделитель\",\n        \"Unmapped fields\": \"Несопоставленные поля\",\n        \"Header\": \"Заголовок\",\n        \"New question\": \"Новый вопрос\",\n        \"More\": \"Больше\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Unmapped\": \"Несопоставленные\",\n        \"Clear\": \"Очистить\",\n        \"Map fields\": \"Сопоставить поля\",\n        \"Mapped\": \"Сопоставленные\",\n        \"Select all\": \"Выбрать все\",\n        \"Unmap fields\": \"Снять сопоставление полей\"\n    },\n    \"FormView\": {\n        \"Publish\": \"Публиковать\",\n        \"Publish your form?\": \"Опубликовать форму?\",\n        \"Unpublish\": \"Отменить публикацию\",\n        \"Unpublish your form?\": \"Отменить публикацию формы?\",\n        \"Code copied to clipboard\": \"Код скопирован в буфер обмена\",\n        \"Copy code\": \"Скопировать код\",\n        \"Embed this form\": \"Встроить эту форму\",\n        \"Link copied to clipboard\": \"Ссылка скопирована в буфер обмена\",\n        \"Preview\": \"Предпросмотр\",\n        \"Save your document to publish this form.\": \"Сохраните документ, для публикации этой формы.\",\n        \"Share\": \"Поделиться\",\n        \"Share this form\": \"Поделиться этой формой\",\n        \"View\": \"Просмотр\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Любой, у кого есть ссылка ниже, может увидеть пустую форму и отправить ответ.\",\n        \"Are you sure you want to reset your form?\": \"Вы уверены, что хотите сбросить форму?\",\n        \"Copy link\": \"Копировать ссылку\",\n        \"Reset\": \"Сброс\",\n        \"Reset form\": \"Сброс формы\",\n        \"# **Form Title**\": \"# **Заголовок формы**\",\n        \"Your form description goes here.\": \"Описание формы.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"После публикации вашей формы будет создана ссылка для обмена. Любой, у кого есть ссылка, может просмотреть пустую форму и отправить ответ.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Отмена публикации формы приведет к отключению ссылки \\\"Поделиться\\\", и пользователи, имеющие доступ к вашей форме по этой ссылке, увидят сообщение об ошибке.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Ваша форма опубликована. Все изменения доступны пользователям, имеющим доступ к форме. Если вы хотите внести комплексные изменения, отмените публикацию формы.\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"бесплатный коуч-звонок\",\n    \"Maybe later\": \"Возможно позже\",\n        \"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.\": \"Во время звонка мы уделяем время тому, чтобы понять ваши потребности и адаптировать звонок под ваши нужды. Мы можем показать вам основы Grist или сразу начать работать с вашими данными, чтобы создать нужные вам информационные панели.\",\n    \"Schedule call\": \"Запланировать звонок\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Запланируйте свой {{freeCoachingCall}} с членом нашей команды.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Вы также можете проверить {{ourWeeklyWebinars}} чтобы узнать больше о Grist.\",\n        \"our weekly webinars\": \"наши еженедельные вебинары\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Удалить\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Правила полей\",\n        \"Required field\": \"Обязательное поле\",\n        \"Ascending\": \"Восходящий\",\n        \"Default\": \"По умолчанию\",\n        \"Descending\": \"Нисходящий\",\n        \"Field Format\": \"Формат поля\",\n        \"Field Rules\": \"Правила поля\",\n        \"Horizontal\": \"Горизонтальный\",\n        \"Options Alignment\": \"Выравнивание параметров\",\n        \"Options Sort Order\": \"Параметры Порядка Сортировки\",\n        \"Radio\": \"Радио\",\n        \"Select\": \"Выбрать\",\n        \"Vertical\": \"Вертикальный\",\n        \"Accept value from URL\": \"Принять значение из URL\",\n        \"Hidden field\": \"Скрытое поле\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"Параметр из URL:\\n{{colId}}=VALUE\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Некоторые обязательные столбцы не сопоставлены\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Чтобы использовать этот виджет, сопоставьте все необязательные столбцы с панели создателя справа.\",\n        \"Some required columns are hidden by access rules\": \"Некоторые необходимые столбцы скрыты в соответствии с правилами доступа\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Чтобы использовать этот виджет, все сопоставленные столбцы должны быть видны. Пожалуйста, свяжитесь с владельцем документа или измените правила доступа.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Создайте свою собственную форму\",\n        \"Powered by\": \"Разработано\",\n        \"Powered by Grist\": \"Разработано в Grist\"\n    },\n    \"FormModel\": {\n        \"There was a problem loading the form.\": \"Возникла проблема с загрузкой формы.\",\n        \"You don't have access to this form.\": \"У вас нет доступа к этой форме.\",\n        \"Oops! The form you're looking for doesn't exist.\": \"Ой! Форма, которую вы ищете, не существует.\",\n        \"Oops! This form is no longer published.\": \"Ой! Эта форма больше не публикуется.\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Ошибка\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"При отправке формы произошла ошибка. Пожалуйста, попробуйте еще раз.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Форма отправлена\",\n        \"Thank you! Your response has been recorded.\": \"Спасибо! Ваш ответ учтен.\",\n        \"Submit new response\": \"Отправить новый ответ\"\n    },\n    \"DateRangeOptions\": {\n        \"Today\": \"Сегодня\",\n        \"Last 30 days\": \"Последние 30 дней\",\n        \"Last 7 days\": \"Последние 7 дней\",\n        \"Last week\": \"Последняя неделя\",\n        \"Next 7 days\": \"Следующие 7 дней\",\n        \"This month\": \"Этот месяц\",\n        \"This week\": \"Эта неделя\",\n        \"This year\": \"Текущий год\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Очистить\",\n        \"Map fields\": \"Сопоставить поля\",\n        \"Mapped\": \"Сопоставлено\",\n        \"Select all\": \"Выбрать все\",\n        \"Unmap fields\": \"Отменить сопоставление полей\",\n        \"Unmapped\": \"Несопоставленные\",\n        \"Hide {{label}}\": \"Скрыть {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Скрыть {{label}} (пакетный режим)\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Вставить секцию выше\",\n        \"Insert section below\": \"Вставить секцию ниже\",\n        \"Description\": \"Описание\",\n        \"## **Header**\": \"## **Заголовок**\"\n    },\n    \"AdminPanel\": {\n        \"Current\": \"Текущий\",\n        \"Current version of Grist\": \"Текущая версия Grist\",\n        \"Help us make Grist better\": \"Помогите нам сделать Grist лучше\",\n        \"Home\": \"Домой\",\n        \"Sponsor\": \"Спонсор\",\n        \"Support Grist\": \"Поддержать Grist\",\n        \"Support Grist Labs on GitHub\": \"Поддержите Grist Labs на GitHub\",\n        \"Version\": \"Версия\",\n        \"Admin Panel\": \"Панель администратора\",\n        \"Telemetry\": \"Телеметрия\",\n        \"Check now\": \"Проверь сейчас\",\n        \"Checking for updates...\": \"Проверка обновлений...\",\n        \"Error\": \"Ошибка\",\n        \"Grist is up to date\": \"Grist актуален\",\n        \"Grist releases are at \": \"Релизы Grist находятся в \",\n        \"Last checked {{time}}\": \"Последняя проверка {{time}}\",\n        \"Learn more.\": \"Узнать больше.\",\n        \"No information available\": \"Информация отсутствует\",\n        \"Sandbox settings for data engine\": \"Настройки песочницы для механизма обработки данных\",\n        \"Sandboxing\": \"Песочница\",\n        \"Updates\": \"Обновления\",\n        \"unknown\": \"неизвестный\",\n        \"Auto-check when this page loads\": \"Автоматическая проверка при загрузке этой страницы\",\n        \"Error checking for updates\": \"Ошибка проверки обновлений\",\n        \"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 позволяет создавать очень мощные формулы с использованием Python.. Мы рекомендуем установить переменную среды GRIST_SANDBOX_FLAVOR к gvisor если ваше оборудование поддерживает это (большинство поддерживает), позволит запускать формулы в каждом документе в изолированной от других документов и сети программной среде.\",\n        \"Newer version available\": \"Доступна более новая версия\",\n        \"OK\": \"OK\",\n        \"unconfigured\": \"несконфигурированно\",\n        \"Security Settings\": \"Настройки безопасности\",\n        \"Authentication\": \"Аутентификация\",\n        \"Check failed.\": \"Проверка не удалась.\",\n        \"Check succeeded.\": \"Проверка прошла успешно.\",\n        \"Details\": \"Подробности\",\n        \"No fault detected.\": \"Неисправностей не обнаружено.\",\n        \"Notes\": \"Примечания\",\n        \"Results\": \"Результаты\",\n        \"Self Checks\": \"Самопроверки\",\n        \"Administrator Panel Unavailable\": \"Панель администратора недоступна\",\n        \"Current authentication method\": \"Текущий метод аутентификации\",\n        \"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 доступен по сети или доступен     нескольким пользователям.\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Или, в качестве запасного варианта, вы можете установить: {{bootKey}} в окружающей среде и посетить: {{url}}\",\n        \"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 доступен по сети или доступен нескольким пользователям.\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"У вас нет доступа к панели администратора.\\nПожалуйста, войдите в систему как администратор.\",\n        \"Key to sign sessions with\": \"Ключ для подписи сеансов с\",\n        \"Session Secret\": \"Секрет сессии\",\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 подписывает файлы cookie сеанса пользователя секретным ключом. Установите этот ключ через переменную среды GRIST_SESSION_SECRET. Grist возвращается к жестко запрограммированному значению по умолчанию, если оно не установлено. Мы можем удалить это уведомление в будущем, поскольку идентификаторы сеансов, созданные начиная с версии 1.1.16, по своей сути криптографически безопасны.\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"Grist подписывает файлы cookie сеанса пользователя секретным ключом. Установите этот ключ через переменную среды GRIST_SESSION_SECRET. Grist возвращается к жестко запрограммированному значению по умолчанию, если оно не установлено. Мы можем удалить это уведомление в будущем, поскольку идентификаторы сеансов, созданные начиная с версии 1.1.16, по своей сути криптографически безопасны.\",\n        \"Enable Grist Enterprise\": \"Включить Grist Enterprise\",\n        \"checking\": \"проверка\",\n        \"Enterprise\": \"Корпоративный\",\n        \"Audit Logs\": \"Журналы аудита\",\n        \"Contact us\": \"Свяжитесь с нами\",\n        \"Log Streaming\": \"Потоковая передача журналов\",\n        \"Off\": \"Выкл\",\n        \"New, Enterprise\": \"Новый, Корпоративный\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} больше\",\n        \"On\": \"На\",\n        \"Grist Instance\": \"Экземпляр Grist\",\n        \"Auto-check weekly\": \"Еженедельная автоматическая проверка\",\n        \"No record of last version check\": \"Нет записей о последней проверке версии\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Вы можете настроить потоковую передачу событий аудита из Grist во внешнюю систему управления информацией о безопасности и событиями (SIEM), если вы включили Grist Enterprise. {{contactUsLink}} чтобы узнать больше.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Автоматические проверки отключены. Чтобы включить их, установите environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING значение \\\"true\\\".\",\n        \"auth error\": \"ошибка авторизации\",\n        \"default\": \"по умолчанию\",\n        \"more...\": \"подробнее...\",\n        \"no authentication\": \"нет аутентификации\",\n        \"unavailable\": \"недоступно\",\n        \"Admin account not found\": \"Учётная запись администратора не найдена\",\n        \"Administrative accounts\": \"Административные учётные записи\",\n        \"The users with administrative accounts\": \"Пользователи с административными учётными записями\",\n        \"Version {{versionNumber}}\": \"Версия {{versionNumber}}\",\n        \"no admin accounts\": \"нет учётных записей администратора\"\n    },\n    \"CreateTeamModal\": {\n        \"Billing is not supported in grist-core\": \"Выставление счетов в grist-core не поддерживается\",\n        \"Cancel\": \"Отменить\",\n        \"Choose a name and url for your team site\": \"Выберите название и URL-адрес для сайта вашей команды\",\n        \"Create site\": \"Создать сайт\",\n        \"Domain name is invalid\": \"Доменное имя недействительно\",\n        \"Domain name is required\": \"Требуется доменное имя\",\n        \"Go to your site\": \"Перейдите на ваш сайт\",\n        \"Team name\": \"Название команды\",\n        \"Team name is required\": \"Требуется указать название команды\",\n        \"Team site created\": \"Создан сайт команды\",\n        \"Team url\": \"URL-адрес команды\",\n        \"Work as a Team\": \"Работайте в команде\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Опции отсутствуют\",\n        \"No values in show column of referenced table\": \"Нет значений в столбце отображения ссылочной таблицы.\",\n        \"Hide\": \"Скрыть\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Удалить столбец\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"Флажок\",\n        \"Field Format\": \"Формат поля\",\n        \"Switch\": \"Тумблер\"\n    },\n    \"ChoiceEditor\": {\n        \"No choices to select\": \"Нет вариантов для выбора\",\n        \"Error in dropdown condition\": \"Ошибка в условиях выпадающего списка\",\n        \"No choices matching condition\": \"Нет вариантов, соответствующих условию\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"Ошибка в условиях выпадающего списка\",\n        \"No choices matching condition\": \"Нет вариантов, соответствующих условию\",\n        \"No choices to select\": \"Нет вариантов для выбора\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Set dropdown condition\": \"Установить условие выпадающего списка\",\n        \"Dropdown Condition\": \"Условие выпадающего списка\",\n        \"Invalid columns: {{colIds}}\": \"Неверные столбцы: {{colIds}}\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Введите условие.\"\n    },\n    \"ReferenceUtils\": {\n        \"No choices matching condition\": \"Нет вариантов, соответствующих условию\",\n        \"No choices to select\": \"Нет вариантов для выбора\",\n        \"Error in dropdown condition\": \"Ошибка в условиях выпадающего списка\"\n    },\n    \"widgetTypesMap\": {\n        \"Custom\": \"Кастомный\",\n        \"Form\": \"Форма\",\n        \"Table\": \"Таблица\",\n        \"Calendar\": \"Календарь\",\n        \"Card\": \"Карточка\",\n        \"Card List\": \"Список карточек\",\n        \"Chart\": \"Диаграмма\"\n    },\n    \"TimingPage\": {\n        \"Table ID\": \"ID таблицы\",\n        \"Formula timer\": \"Таймер формулы\",\n        \"Average Time (s)\": \"Среднее время (с)\",\n        \"Loading timing data. Don't close this tab.\": \"Загрузка данных о времени. Не закрывайте эту вкладку.\",\n        \"Column ID\": \"ID столбца\",\n        \"Max Time (s)\": \"Макс. время (с)\",\n        \"Number of Calls\": \"Количество Вызовов\",\n        \"Total Time (s)\": \"Общее время (с)\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"Сброс\",\n        \"Search\": \"Поиск\",\n        \"Select...\": \"Выбрать...\",\n        \"Submit\": \"Отправить\",\n        \"Submitting…\": \"Отправка…\",\n        \"Clear selection for: {{-inputLabel}}\": \"Очистить выбор для: {{-inputLabel}}\"\n    },\n    \"DocTutorial\": {\n        \"Click to expand\": \"Нажмите, чтобы развернуть\",\n        \"Finish\": \"Закончить\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Хотите перезапустить обучение? Весь прогресс будет потерян.\",\n        \"End tutorial\": \"Завершить обучение\",\n        \"Previous\": \"Предыдущий\",\n        \"Restart\": \"Перезапуск\",\n        \"Next\": \"Следующий\"\n    },\n    \"OnboardingCards\": {\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Изучите основы ссылочных столбцов, связанных виджетов, типов столбцов и карточек.\",\n        \"3 minute video tour\": \"3-минутный видеотур\",\n        \"Complete the tutorial\": \"Завершить обучение\",\n        \"Complete our basics tutorial\": \"Завершите наше базовое обучение\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Изучите основы ссылочных столбцов, связанные виджеты, типы столбцов, & карточки.\"\n    },\n    \"OnboardingPage\": {\n        \"Go hands-on with the Grist Basics tutorial\": \"Познакомьтесь с учебным пособием по основам Grist\",\n        \"Tell us who you are\": \"Расскажите нам, кто вы\",\n        \"Type here\": \"Введите здесь\",\n        \"Welcome\": \"Добро пожаловать\",\n        \"What brings you to Grist (you can select multiple)?\": \"Что привело вас в Grist (вы можете выбрать несколько)?\",\n        \"What is your role?\": \"Какова ваша роль?\",\n        \"What organization are you with?\": \"В какой организации вы работаете?\",\n        \"Your organization\": \"Ваша организация\",\n        \"Your role\": \"Ваша роль\",\n        \"Back\": \"Назад\",\n        \"Discover Grist in 3 minutes\": \"Откройте для себя Grist за 3 минуты\",\n        \"Go to the tutorial!\": \"Перейти к обучению!\",\n        \"Next step\": \"Следующий шаг\",\n        \"Skip step\": \"Пропустить шаг\",\n        \"Skip tutorial\": \"Пропустить обучение\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist может выглядеть как электронная таблица, но это не всегда так. Узнайте, чем отличается Grist от других приложений.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"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}}).\",\n        \"Disable Grist Enterprise\": \"Отключить Grist Enterprise\",\n        \"Enable Grist Enterprise\": \"Включить Grist Enterprise\",\n        \"Grist Enterprise is **enabled**.\": \"Grist Enterprise **включен**.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Ключ активации используется для запуска Grist Enterprise после истечения пробного периода\\nсроком в 30 дней. Получите ключ активации, [связавшись с нами]({{contactLink}}) сегодня. Вам\\nне нужен ключ активации для запуска Grist Core.\\n\\nУзнайте больше в нашем [Справочном центре]({{helpCenter}}).\",\n        \"Activate\": \"Активировать\",\n        \"Activation key\": \"Ключ активации\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Для продолжения использования Grist Enterprise требуется активная подписка. Вы можете\\nактивировать свою подписку с помощью [подписка Grist Enterprise ]({{signupLink}}) и вставте свой\\nключ активации ниже.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Ключ активации используется для запуска Grist Enterprise после пробного периода\\n        30 дней истекли. Получите ключ активации, [подписка Grist\\n        Enterprise]({{signupLink}}). Вам не нужен ключ активации для запуска\\n        Grist Core.\",\n        \"Installation ID:\": \"Идентификатор установки:\",\n        \"Installation ID copied to clipboard\": \"Идентификатор установки скопирован в буфер обмена\",\n        \"Expiration date\": \"Дата окончания срока\",\n        \"Installation seats\": \"Установочные места\",\n        \"Copy to clipboard\": \"Скопировать в буфер обмена\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Узнайте больше в нашем [Справочный центр]({{helpCenter}}).\",\n        \"Paste your activation key\": \"Вставьте свой ключ активации\",\n        \"Plan name\": \"Название плана\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Чтобы продолжить использование Grist Enterprise, вам необходимо\\n                  [связаться с нами]({{signupLink}}) чтобы получить свой ключ активации.\",\n        \"You are currently trialing Grist Enterprise.\": \"В настоящее время вы проводите тестирование Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"У вас нет активной подписки.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Срок действия вашего ключа активации истек из-за превышения лимитов.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Ваш экземпляр будет находиться в **только для чтения** режиме в **{{days}}** дней.\",\n        \"Your subscription expired on {{date}}.\": \"Срок действия вашей подписки истек в {{date}}.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Ваш пробный период истек в **{{expireAt}}**. Чтобы продолжить использование Grist Enterprise, вам необходимо\\n[зарегистрироваться в Grist Enterprise]({{signupLink}}) и вставьте свой ключ активации ниже.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Удалить\",\n        \"Delete data and this widget.\": \"Удалить данные и этот виджет.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Сохранить данные и удалить виджет. Таблица останется доступной в {{rawDataLink}}\",\n        \"Table {{tableName}} will no longer be visible\": \"Таблица {{tableName}} больше не будет видима\",\n        \"Raw Data page\": \"Страница исходных данных\"\n    },\n    \"CustomWidgetGallery\": {\n        \"Cancel\": \"Отменить\",\n        \"Change widget\": \"Изменить виджет\",\n        \"Choose custom widget\": \"Выберите Пользовательский виджет\",\n        \"Search\": \"Поиск\",\n        \"(Missing info)\": \"(Недостающая информация)\",\n        \"Add widget\": \"Добавить виджет\",\n        \"Add Your Own Widget\": \"Добавьте Свой собственный виджет\",\n        \"Add a widget from outside this gallery.\": \"Добавьте виджет из-за пределов этой галереи.\",\n        \"Community Widget\": \"Виджет от сообщества\",\n        \"Custom URL\": \"Пользовательский URL-адрес\",\n        \"Developer:\": \"Разработчик:\",\n        \"Grist Widget\": \"Grist Виджет\",\n        \"Last updated:\": \"Последнее обновление:\",\n        \"Learn more about custom widgets\": \"Узнайте больше о пользовательских виджетах\",\n        \"No matching widgets\": \"Нет подходящих виджетов\",\n        \"Widget URL\": \"URL виджета\"\n    },\n    \"HomeIntroCards\": {\n        \"Blank document\": \"Пустой документ\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"Находите решения и изучайте больше ресурсов {{helpCenterLink}}\",\n        \"Tutorial\": \"Учебное пособие\",\n        \"3 minute video tour\": \"3-минутный видео-тур\",\n        \"Finish our basics tutorial\": \"Завершите наше учебное пособие по основам\",\n        \"Help center\": \"Справочный центр\",\n        \"Import file\": \"Импорт файла\",\n        \"Learn more {{webinarsLinks}}\": \"Изучить больше {{webinarsLinks}}\",\n        \"Start a new document\": \"Начать новый документ\",\n        \"Templates\": \"Шаблоны\",\n        \"Webinars\": \"Вебинары\",\n        \"Find solutions and explore more resources\": \"Найдите решения и изучите дополнительные ресурсы\",\n        \"Learn more\": \"Подробнее\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Delete two-way reference?\": \"Удалить двустороннюю ссылку?\",\n        \"Target table\": \"Целевая таблица\",\n        \"Column\": \"Столбец\",\n        \"Delete\": \"Удалить\",\n        \"Delete column {{column}} in table {{table}}?\": \"Удалить столбец {{column}} в таблице {{table}}?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Это обратная сторона ссылочного столбца. {{column}} в таблице {{table}}.\",\n        \"Table\": \"Таблица\",\n        \"Two-way Reference\": \"Двусторонняя Ссылка\",\n        \"Add two-way reference\": \"Добавить двусторонную ссылку\"\n    },\n    \"SupportGristButton\": {\n        \"Help Center\": \"Справочный центр\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Спасибо! Ваше доверие и поддержка очень ценны. Отказаться в любое время от {{link}} в меню пользователя.\",\n        \"Admin Panel\": \"Панель администратора\",\n        \"Close\": \"Закрыть\",\n        \"Opt in to Telemetry\": \"Включите телеметрию\",\n        \"Opted In\": \"Включено\",\n        \"Support Grist\": \"Поддержать Grist\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Подключитесь к телеметрии, чтобы помочь нам понять, как используется продукт, и определить приоритеты будущих улучшений.\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Панель администратора\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Отмена\",\n        \"Record already assigned_other\": \"Запись уже назначена\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Каждая {{targetTable}} запись может быть назначен только одной {{sourceTable}} записи.\",\n        \"Reassign\": \"Переназначить\",\n        \"Reassign to new {{sourceTable}} records.\": \"Переназначить на новые {{sourceTable}} записи.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Переназначить на {{sourceTable}} запись {{sourceName}}.\",\n        \"Record already assigned_one\": \"Запись уже назначена\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} запись {{targetName}} уже назначена {{sourceTable}} запись          {{oldSourceName}}.\"\n    },\n    \"markdown\": {\n        \"The toggle is **off**\": \"Переключатель **Выключен**\",\n        \"The toggle is **on**\": \"Переключатель **Включен**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Новая Markdown функция\\n *\\n *      Мы можем _писать_ [обычный Markdown](https://markdownguide.org) *внутри*\\n *      Grainjs элемента.\"\n            }\n        }\n    },\n    \"markdown.d\": {\n        \"The toggle is **off**\": \"Переключатель **Выключен**\",\n        \"The toggle is **on**\": \"Переключатель **Включен**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Новая Markdown функция\\n *\\n *      Мы можем _писать_ [обычный Markdown](https://markdownguide.org) *внутри*\\n *      Grainjs элемента.\"\n            }\n        }\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Add streaming destination\": \"Добавить пункт назначения потоковой передачи\",\n        \"URL\": \"URL\",\n        \"Cancel\": \"Отменить\",\n        \"Delete\": \"Удалить\",\n        \"Add destination\": \"Добавить пункт назначения\",\n        \"Delete streaming destination?\": \"Удалить пункт назначения потоковой передачи?\",\n        \"Enter URL\": \"Введите URL\",\n        \"Enter token\": \"Введите токен\",\n        \"Other\": \"Другое\",\n        \"Save\": \"Сохранить\",\n        \"Destinations\": \"Назначения\",\n        \"Edit\": \"Редактировать\",\n        \"Start streaming\": \"Начать потоковую передачу\",\n        \"Edit streaming destination\": \"Редактировать пункт назначения потоковой передачи\",\n        \"Learn more\": \"Учить больше\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Вы уверены, что хотите удалить этот пункт назначения потоковой передачи? Это действие невозможно отменить.\",\n        \"Destination\": \"Место назначения\"\n    },\n    \"DocList\": {\n        \"Last edited\": \"Последнее редактирование\",\n        \"All\": \"Все\",\n        \"Current workspace\": \"Текущее рабочее пространство\",\n        \"Delete\": \"Удалить\",\n        \"Manage users\": \"Управление пользователями\",\n        \"Move\": \"Переместить\",\n        \"Move {{name}} to workspace\": \"Переместить {{name}} в рабочее пространство\",\n        \"Pin\": \"Закрепить\",\n        \"Pinned\": \"Закреплён\",\n        \"Recent\": \"Недавний\",\n        \"Rename and set icon\": \"Переименовать и установить иконку\",\n        \"Sort by date\": \"Сортировка по дате\",\n        \"Edited {{at}}\": \"Отредактирован {{at}}\",\n        \"Requires edit permissions\": \"Требуются разрешения на редактирование\",\n        \"Unpin\": \"Открепить\",\n        \"Workspace\": \"Рабочее место\",\n        \"Access details\": \"Информация о доступе\",\n        \"Sort by name\": \"Сортировать по наименованию\",\n        \"Delete {{name}}\": \"Удалить {{name}}\",\n        \"Document will be moved to Trash.\": \"Документ будет перемещен в корзину.\",\n        \"Name\": \"Наименование\",\n        \"No documents to show.\": \"Нет документов для отображения.\",\n        \"context menu - {{- documentName }}\": \"контекстное меню - {{- documentName }}\",\n        \"Documents list\": \"Список документов\",\n        \"Download document...\": \"Загрузка документа...\",\n        \"Deleted {{at}}\": \"Удалено {{at}}\"\n    },\n    \"RightPanelUtils\": {\n        \"fields_one\": \"Поля\",\n        \"columns_one\": \"Столбцы\",\n        \"columns_other\": \"Столбцы\",\n        \"series_other\": \"Серии\",\n        \"fields_other\": \"Поля\",\n        \"series_one\": \"Серии\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Please review the following before adding a new custom widget.\": \"Пожалуйста, ознакомьтесь со следующим, прежде чем добавлять новый пользовательский виджет.\",\n        \"Be careful with unknown custom widgets\": \"Будьте осторожны с неизвестными пользовательскими виджетами\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Пользовательские виджеты - это **мощность**! Они могут считывать и записывать данные вашего документа, а также отправлять их в другое место.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Вы уверены, что вы **доверяете ресурсу** по этому URL?\",\n        \"Have you **reviewed the code** at this URL?\": \"Вы **ознакомлены с кодом** по этому URL?\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Я подтверждаю, что понимаю эти предупреждения и принимаю риски\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Если вы сомневаетесь, не устанавливайте этот виджет, или попросите администратора вашей организации просмотреть его в целях безопасности.\",\n        \"Do you **trust the person** who shared this link?\": \"Вы **доверяете человеку** кто поделился этой ссылкой?\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit logs for {{siteName}}\": \"Журналы аудита для {{siteName}}\",\n        \"Audit Logs\": \"Журналы аудита\",\n        \"Log streaming\": \"Потоковая передача логов\",\n        \"Contact us\": \"Связаться с нами\",\n        \"Home\": \"Домой\",\n        \"Only site owners may access audit logs.\": \"Только владельцы сайтов могут получить доступ к журналам аудита.\",\n        \"upgrade your plan\": \"обновите свой план\"\n    },\n    \"RenameDocModal\": {\n        \"Choose color\": \"Выберите цвет\",\n        \"Choose icon\": \"Выберите иконку\",\n        \"Enter document name\": \"Введите название документа\",\n        \"Icon\": \"Иконка\",\n        \"Name\": \"Наименование\",\n        \"Reset icon\": \"Сброс иконки\",\n        \"Rename and set icon\": \"Переименовать и установить иконку\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin area\": \"Административная зона\",\n        \"Admin controls\": \"Административные элементы управления\",\n        \"Docs\": \"Документы\",\n        \"Installation\": \"Установка\",\n        \"Learn more\": \"Изучить больше\",\n        \"Orgs\": \"Организации\",\n        \"Users\": \"Пользователи\",\n        \"Workspaces\": \"Рабочие пространства\",\n        \"Settings\": \"Настройки\",\n        \"Admin Controls\": \"Элементы управления администратора\"\n    },\n    \"Assistant\": {\n        \"What do you need help with?\": \"В чем вам нужна помощь?\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Зарегистрируйте бесплатную учетную запись Grist, чтобы начать использовать AI Ассистент.\",\n        \"You have {{numCredits}} remaining credits.\": \"У вас есть {{numCredits}} оставшиеся кредиты.\",\n        \"You have used all available credits.\": \"Вы использовали все доступные кредиты.\",\n        \"upgrade to the Pro Team plan\": \"обновить на Pro Team план\",\n        \"upgrade your plan\": \"Обновите свой план\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Для увеличения лимитов, {{upgradeNudge}}.\",\n        \"Learn more.\": \"Изучить больше.\",\n        \"Apply\": \"Применить\",\n        \"Sign Up for Free\": \"Зарегистрироваться бесплатно\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Обновитесь до Grist Enterprise, чтобы опробовать новый инструмент Grist Ассистент. {{learnMoreLink}}\",\n        \"start a new chat\": \"начать новый чат\",\n        \"AI Assistant is only available for logged in users.\": \"AI Ассистент доступен только для зарегистрированных пользователей.\",\n        \"Press Enter to apply suggested formula.\": \"Нажмите Enter, чтобы применить предложенную формулу.\",\n        \"For higher limits, contact the site owner.\": \"Для увеличения лимитов, обратитесь к владельцу сайта.\"\n    },\n    \"apiconsole\": {\n        \"Confirm Deletion\": \"Подтвердите удаление\",\n        \"Delete\": \"Удалить\",\n        \"Deletion was not confirmed, skipping.\": \"Удаление не было подтверждено, пропуск.\",\n        \"Type DELETE here if you wish to proceed.\": \"Введите DELETE здесь если вы хотите продолжить.\",\n        \"Are you sure you want to delete the following?\": \"Вы уверены, что хотите удалить следующее?\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Введите DELETE если вы уверены, что действительно хотите сделать это удаление.\\nЕсли вы не уверены или не понимаете, что будет делать эта операция,\\nбыло бы разумно отменить это.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"нет доступа\",\n        \"...loading\": \"...загрузка\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Пожалуйста, дождитесь завершения предыдущей операции.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Истек срок ожидания перезапуска серверной части Grist\"\n    },\n    \"Experiments\": {\n        \"Don't worry, you can disable it later if needed.\": \"Не волнуйтесь, вы можете отключить его позже, если понадобится.\",\n        \"Reload the page\": \"Перезагрузите страницу\",\n        \"Disable feature\": \"Отключить функцию\",\n        \"Enable feature\": \"Включить функцию\",\n        \"Experimental feature\": \"Экспериментальная функция\",\n        \"New record button\": \"Кнопка новой записи\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Чтобы прекратить использование этой функции, посетите этот URL-адрес в любое время: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Вы собираетесь отключить эту экспериментальную функцию: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Вы собираетесь включить эту экспериментальную функцию: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} выключена.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} включена.\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Загрузка, пожалуйста, подождите…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Добавить\",\n        \"Uploading…\": \"Загрузка…\",\n        \"Delete\": \"Удалить\",\n        \"Download\": \"Загрузить\",\n        \"Drop files here to attach\": \"Перетащите сюда файлы для прикрепления\",\n        \"Drop files here to attach.\": \"Перетащите сюда файлы для прикрепления.\",\n        \"No attachments\": \"Нет вложений\",\n        \"Preview not available.\": \"Предпросмотр недоступен.\",\n        \"{{index}} of {{total}}\": \"{{index}} из {{total}}\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Доступно критическое обновление Grist.\\nРассмотрите возможность скорейшего обновления до версии {{version}}.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Ваша версия Grist устарела.\\nРассмотрите возможность скорейшего обновления до версии {{version}}.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Рекомендуется: {{storageRecommendation}}\\nПри хранении больших вложений (или большого их количества) мы рекомендуем\\nсохранять их во внешнем хранилище. В настоящее время этот документ\\nиспользует внутреннее хранилище для вложений, что обеспечивает\\nего автономность, но может ограничивать производительность.\",\n        \"Set the document to use external storage.\": \"Настройте документ на использование внешнего хранилища.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Новая карточка\",\n        \"New record\": \"Новая запись\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Хотите получить доступ к панели создателя? Используйте {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Дублировать виджет\",\n        \"Duplicate widgets\": \"Дублировать виджеты\",\n        \"Active\": \"Активен\",\n        \"Create new page\": \"Создать новую страницу\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Развернуть все строки до этой высоты\",\n        \"Max height\": \"Макс высота\",\n        \"Max row height\": \"Макс высота строки\",\n        \"Change\": \"Изменить\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Свернуть\",\n        \"Expand\": \"Развернуть\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"активный пользователь\",\n        \"active user list\": \"активный список пользователей\",\n        \"open full active user list\": \"открыть полный список активных пользователей\"\n    },\n    \"AdminChecks\": {\n        \"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 позволяет создавать очень мощные формулы на Python. Мы рекомендуем установить переменную окружения GRIST_SANDBOX_FLAVOR в значение gvisor, если ваше оборудование поддерживает это (большинство поддерживает), чтобы формулы в каждом документе запускались в песочнице, изолированной от других документов и от сети.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Grist имеет небольшую встроенную проверку работоспособности, которая часто используется при запуске его в качестве контейнера.\",\n        \"It is good practice not to run Grist as the root user.\": \"Лучше не запускать Grist от имени пользователя root.\",\n        \"The main page of Grist should be available.\": \"Главная страница Grist должна быть доступна.\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"ещё +{{count}}\",\n        \"+{{count}} more_other\": \"ещё +{{count}}\",\n        \"Edit\": \"Правка\",\n        \"No choices configured\": \"Варианты не настроены\",\n        \"Reset\": \"Сброс\",\n        \"Cancel\": \"Отмена\",\n        \"Save\": \"Сохранить\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Применить\",\n        \"Cancel\": \"Отмена\",\n        \"Preview\": \"Предпросмотр\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Закрыть\",\n        \"Update preview\": \"Обновить предпросмотр\",\n        \"Convert quoted fields\": \"Преобразовать цитируемые поля\",\n        \"Escape character\": \"Escape-символ\",\n        \"Field separator\": \"Разделитель полей\",\n        \"First row contains headers\": \"Первая строка содержит заголовки\",\n        \"Line terminator\": \"Символ конца строки\",\n        \"Number of rows\": \"Количество строк\",\n        \"Quote character\": \"Символ кавычки\",\n        \"Quotes in fields are doubled\": \"Кавычки в полях удваиваются\",\n        \"Skip leading whitespace\": \"Пропустить начальные пробелы\",\n        \"Start with row\": \"Начать со строки\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Кодировка символа. См. [поддерживаемые кодировщики]({{link}})\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" или \",\n        \"\\\"Regions\\\" are what we call the different parts of the user interface:\": \"«Регионы» — это то, как мы называем различные части пользовательского интерфейса:\",\n        \"Accessibility\": \"Доступность\",\n        \"Close\": \"Закрыть\",\n        \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\": \"Наконец, правая панель — или панель создателя — доступна только через её собственный ярлык и не включена в цикл следующего и предыдущего региона.\",\n        \"Focus on other parts of the user interface using the following shortcuts:\": \"Сосредоточьтесь на других частях пользовательского интерфейса, используя следующие сочетания клавиш:\",\n        \"High contrast theme\": \"Тема высокой контрастности\",\n        \"Keyboard navigation\": \"Навигация с помощью клавиатуры\",\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"На странице документа навигация с помощью клавиатуры сначала блокируется на текущем виджете.\",\n        \"Other important keyboard shortcuts\": \"Другие важные сочетания клавиш\",\n        \"The left panel, home of the main navigation.\": \"Левая панель, место основной навигации.\",\n        \"The top panel, or the document header.\": \"Верхняя панель или заголовок документа.\",\n        \"To see other available themes, go to your {{profileSettingsLink}}.\": \"Чтобы увидеть другие доступные темы, перейдите в свои {{profileSettingsLink}}.\",\n        \"Use the high contrast theme (light appearance)\": \"Используйте тему высокой контрастности (светлая)\",\n        \"You are currently **not using** the high contrast theme.\": \"В настоящее время вы **не используете** тему высокой контрастности.\",\n        \"You are currently using the high contrast theme.\": \"В настоящее время вы используете тему высокой контрастности.\",\n        \"profile settings\": \"настройки профиля\"\n    }\n}\n"
  },
  {
    "path": "static/locales/ru.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"og-description\": \"Современный табличный процессор с открытым исходным кодом, который выходит за рамки простой таблицы\",\n        \"Loading...\": \"Загрузка ...\",\n        \"og-title\": \"Grist, эволюция электронных таблиц\"\n    },\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Пожалуйста, подтвердите свой e-mail в службе идентификации и войдите в систему еще раз.\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"У вас нет доступа к этому документу.\",\n        \"docDisabled\": \"Этот документ отключен.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"В организации администратора, определенной `GRIST_INSTALL_ADMIN_ORG={{org}}` не найдены владельцы\",\n        \"accountByEmail\": \"Учётная запись администратора определяется `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Безымянный документ\"\n    }\n}\n"
  },
  {
    "path": "static/locales/sk.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Enter email address\": \"Vložiť emailovú adresu\",\n        \"Invite new member\": \"Pozvať nového člena\",\n        \"We'll email an invite to {{email}}\": \"Pozvánku poslať e-mailom na adresu {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Add Default Rule\": \"Pridať predvolené pravidlo\",\n        \"Add column rule\": \"Pridať pravidlo stĺpca\",\n        \"Add table rules\": \"Pridať pravidlá tabuľky\",\n        \"Add user attributes\": \"Pridať používateľské atribúty\",\n        \"Allow everyone to view Access Rules.\": \"Umožniť každému zobraziť Prístupové Pravidlá.\",\n        \"Attribute name\": \"Názov atribútu\",\n        \"Checking...\": \"Kontroluje sa…\",\n        \"Condition\": \"Podmienka\",\n        \"Default rules\": \"Predvolené Pravidlá\",\n        \"Delete table rules\": \"Odstrániť Pravidlá Tabuľky\",\n        \"Enter Condition\": \"Zadať Podmienku\",\n        \"Everyone\": \"Každý\",\n        \"Everyone Else\": \"Hocikto Iný\",\n        \"Invalid\": \"Neplatné\",\n        \"Attribute to Look Up\": \"Vyhľadávaný Atribút\",\n        \"Lookup Column\": \"Vyhľadávací stĺpec\",\n        \"Lookup Table\": \"Vyhľadávacia Tabuľka\",\n        \"Permission to access the document in full when needed\": \"Povolenie na úplný prístup k dokumentu v prípade potreby\",\n        \"Permission to view Access Rules\": \"Povolenie na zobrazenie Prístupových Pravidiel\",\n        \"Permissions\": \"Povolenia\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Odstrániť stĺpec {{- colId }} z pravidiel {{- tableId }}\",\n        \"Remove {{- tableId }} rules\": \"Odstrániť pravidlá {{- tableId }}\",\n        \"Reset\": \"Reset\",\n        \"Rules for table \": \"Pravidlá pre tabuľku \",\n        \"Save\": \"Uložiť\",\n        \"Special rules\": \"Špeciálné pravidlá\",\n        \"Type message to display when this rule blocks an action…\": \"Napísať správu…\",\n        \"User Attributes\": \"Používateľské Atribúty\",\n        \"View as\": \"Zobraziť Ako\",\n        \"Seed rules\": \"Seed Pravidlá\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Pri pridávaní pravidiel tabuľky automaticky pridať pravidlo na udelenie úplného prístupu VLASTNÍKOVI.\",\n        \"Permission to edit document structure\": \"Povolenie upravovať štruktúru dokumentu\",\n        \"This default should be changed if editors' access is to be limited. \": \"Toto predvolené nastavenie by sa malo zmeniť, ak má byť prístup redaktorov obmedzený. \",\n        \"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žnite každému skopírovať celý dokument alebo ho zobraziť celý v pokusnom móde.\\n Užitočné pre príklady a šablóny, ale nie pre citlivé údaje.\",\n        \"Saved\": \"Uložené\",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Umožniť redaktorom upravovať štruktúru (napr. upravovať a mazať tabuľky, stĺpce, rozloženia) a písať vzorce, ktoré umožňujú prístup ku všetkým údajom bez ohľadu na obmedzenia čítania.\",\n        \"Remove {{- name }} user attribute\": \"Odstrániť používateľský atribút {{- name }}\",\n        \"Add table-wide rule\": \"Pridať Pravidlo pre celú tabuľku\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Pravidlá prístupu sa zmenili. Kliknite na Reset, aby sa vaše zmeny odstránili a obnovili sa pravidlá.\",\n        \"All\": \"Všetko\",\n        \"Columns\": \"Stĺpce\",\n        \"Condition cannot be blank\": \"Podmienka nemôže byť prázdna\",\n        \"Default resource missing in resource map\": \"Chýba predvolený zdroj v mape zdrojov\",\n        \"Invalid table: {{tableId}}\": \"Neplatná tabuľka: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Neplatné pravidlo užívateľského atribútu: {{prop}} musí byť nastavené\",\n        \"Invalid user attribute to look up\": \"Neplatný atribút používateľa na vyhľadanie\",\n        \"Not a valid user attribute\": \"Neplatný užívateľský atribút\",\n        \"hidden\": \"skrytý\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"Stĺpec {{colId}} sa objavuje vo viacerých pravidlách tabuľky {{tableId}}, ktoré môžu byť závislé od poradia. Skúste pravidlá rozdeliť inak?\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Neplatné stĺpce v tabuľke {{tableId}}: {{invalidColIds}}\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"Niesu uvedené žiadne stĺpce v pravidle stĺpcov pre tabuľku {{tableId}}\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Chýba zdroj v mape zdrojov: {{resourceKey}}\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"Pokus pridať Pravidlá Tabuľky pre existujúcu tabuľku {{tableId}}\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"Použite jednoduchý atribút user.LinkKey, napr. user.LinkKey.niečo\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Prístupové pravidlá\\n\\nNemáte právo na zobrazenie alebo úpravu prístupových pravidiel tohoto dokumentu.\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Špeciálne pravidlá** (otvorte jednotlivé pravidlá a prispôsobte na koho sa aplikujú)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"Po vypnutí prístupových pravidiel, redaktori budú schopní meniť štruktúru dokumentu a upravovať vzorce. Redaktori a Čitatelia budú môcť vidieť všetky údaje v dokumente, ako ho aj kopírovať alebo stiahnuť.\",\n        \"Allow everyone to view access rules.\": \"Povoliť všetkým vidieť prístupové pravidlá.\",\n        \"Continue\": \"Pokračovať\",\n        \"Disable Access Rules\": \"Vypnúť prístupové pravidlá\",\n        \"Disable and save\": \"Vypnúť a uložiť\",\n        \"Enable Access Rules\": \"Zapnúť prístupové pravidlá\",\n        \"Permission to access the document in full by all users\": \"Právo na plný prístup k dokumentu pre všetkých používateľov\",\n        \"Permission to access the document in full by unrestricted users\": \"Právo na plný prístup k dokumentu pre neobmedzených používateľov\",\n        \"Special rules for templates\": \"Špeciálne previdlá pre šablóny\",\n        \"This options should be off if Editors' access is to be limited. \": \"Táto voľba by mala byť vypnutá ak redaktori majú mať obmedzený prístup. \",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Prístupové pravidlá\\n\\nZákladný prístup k tomuto dokumentu sa kontroluje použití voľby 'Správa používateľov' v menu 'Zdieľať', kde môžete priradiť spolupracovníkom role ako napr. Vlastník, Redaktor, Čitateľ\\n\\nViac granulárnu kontrolu môžete nastaviť vytvorením Prístupových pravidiel, ktoré obmedzia kto môže vidieť a upravovať\\nkonkrétne tabuľky, stĺpce alebo riadky — užitočné pre citlivé údaje alebo práva podľa rolí.\\n[Zistiť viac.]({{helpAccessRules}})\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"Po zapnutí Prístupových pravidiel, Redaktori už viac nebudú môcť meniť štruktúru\\ndokumentu a upravovať vzorce. Len vlastníci budú môcť kopírovať a sťahovať dokument.\\n\\nTieto nastavenia sa dajú zmeniť pod 'Špeciálnymi pravidlami'.\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"Povolí redaktorom upravovať štruktúru (napr. meniť a mazať tabuľky, stĺpce a rozloženia) a písať vzorce.  Dôležité: ak je zapnuté, Redaktori budú môcť meniť vzorce, ktoré môžu pristupovať ku všetkým údajom, bez ohľadu na práva tabuliek a stĺpcov!\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Obíde všetky práva na čítanie a dovoli všetkým kopírovať celý dokument, alebo pozrieť siho celý vo fiddle režime. Používajte len v príkladoch a šablónach, nie pre dokumentu s citlivými údajmi.\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Obmedzí nevlastníkov kopírovanie a sťahovanie celého dokumentu. Poznámka: Ovplyvní to len používateľov bez obmedzeného čítania, keďže ostatní sú obmedzený bez ohľadu na toto nastavenie.\"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"API Kľúč\",\n        \"Account settings\": \"Nastavenia účtu\",\n        \"Allow signing in to this account with Google\": \"Povoliť prihlásenie do tohto účtu pomocou Google\",\n        \"Change password\": \"Zmeniť Heslo\",\n        \"Login method\": \"Metóda Prihlásenia\",\n        \"Password & security\": \"Heslo a Zabezpečenie\",\n        \"Save\": \"Uložiť\",\n        \"Theme\": \"Téma\",\n        \"Two-factor authentication\": \"Dvojvázové overenie\",\n        \"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.\": \"Dvojfázová autentifikácia je ďalšou vrstvou zabezpečenia vášho účtu Grist, ktorá je navrhnutá tak, aby zaistila, že ste jedinou osobou, ktorá môže pristupovať k vášmu účtu, aj keď niekto pozná vaše heslo.\",\n        \"Language\": \"Jazyk\",\n        \"Edit\": \"Upraviť\",\n        \"Email\": \"E-mail\",\n        \"Names only allow letters, numbers and certain special characters\": \"Názvy povoľujú iba písmená, čísla a určité špeciálne znaky\",\n        \"Name\": \"Meno\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Prístupové podrobnosti\",\n        \"Accounts\": \"Účty\",\n        \"Add account\": \"Pridať účet\",\n        \"Document settings\": \"Nastavenie Dokumentu\",\n        \"Manage team\": \"Riadiť Tím\",\n        \"Pricing\": \"Cenník\",\n        \"Profile settings\": \"Nastavenie Profilu\",\n        \"Sign out\": \"Odhlásiť sa\",\n        \"Sign in\": \"Prihlásiť sa\",\n        \"Switch Accounts\": \"Prepnúť Účty\",\n        \"Toggle Mobile Mode\": \"Prepnúť Mobilný Režim\",\n        \"Activation\": \"Aktivácia\",\n        \"Billing account\": \"Fakturačný účet\",\n        \"Support Grist\": \"Podpora Grist\",\n        \"Upgrade Plan\": \"Plán Inovácie\",\n        \"Use This Template\": \"Použiť túto Šablónu\",\n        \"Sign up\": \"Prihlásiť sa\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Zobraziť Ako\",\n        \"Users from table\": \"Používatelia z tabuľky\",\n        \"Example Users\": \"Príklady používateľov\"\n    },\n    \"ActionLog\": {\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Tabuľka {{tableId}} bola následne odstránená v akcii #{{actionNum}}\",\n        \"Action Log failed to load\": \"Nepodarilo sa načítať denník akcií\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Stĺpec {{colId}} bol následne odstránený v akcii #{{action.actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Tento riadok bol následne odstránený v akcii {{action.actionNum}}\",\n        \"All tables\": \"Všetky tabuľky\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"Stĺpec {{colId}} bol následne odstránený v akcii #{{actionNum}}\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Tento riadok bol následne odstránený v akcii {{actionNum}}\",\n        \"History blocked because of access rules.\": \"História zablokovaná kvôli pravidlám prístupu.\"\n    },\n    \"ApiKey\": {\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Vygenerovaním kľúča API budete môcť uskutočňovať volania API pre svoj vlastný účet.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Tento kľúč API je možné použiť na prístup k vášmu účtu prostredníctvom rozhrania API. Nezdieľajte svoj API kľúč s nikým.\",\n        \"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?\": \"Chystáte sa odstrániť kľúč API. To spôsobí, že všetky budúce požiadavky používajúce tento kľúč API budú odmietnuté. Stále chcete odstrániť?\",\n        \"Click to show\": \"Kliknutím zobraziť\",\n        \"Create\": \"Vytvoriť\",\n        \"Remove\": \"Odobrať\",\n        \"Remove API Key\": \"Odobrať kľúč API\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Tento kľúč API možno použiť na anonymný prístup k tomuto účtu prostredníctvom rozhrania API.\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Pridať Nový\"\n    },\n    \"App\": {\n        \"Description\": \"Popis\",\n        \"Key\": \"Kľúč\",\n        \"Memory Error\": \"Chyba pamäte\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Prekladatelia: preložte to, prosím, len vtedy, keď je váš jazyk pripravený na poskytovanie používateľom\"\n    },\n    \"AppHeader\": {\n        \"Manage team\": \"Riadiť Tím\",\n        \"Billing account\": \"Fakturačný účet\",\n        \"Home page\": \"Domovská stránka\",\n        \"Legacy\": \"Dedičstvo\",\n        \"Personal Site\": \"Osobná stránka\",\n        \"Team Site\": \"Tímová stránka\",\n        \"Grist Templates\": \"Grist Šablóny\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Návrat domov\"\n    },\n    \"CellContextMenu\": {\n        \"Delete {{count}} columns_one\": \"Odstrániť stĺpec\",\n        \"Delete {{count}} columns_other\": \"Odstrániť {{count}} stĺpcov\",\n        \"Delete {{count}} rows_other\": \"Odstrániť {{count}} riadkov\",\n        \"Duplicate rows_one\": \"Duplikovať riadok\",\n        \"Insert column to the right\": \"Vložiť stĺpec doprava\",\n        \"Reset {{count}} columns_one\": \"Resetovať stĺpec\",\n        \"Reset {{count}} columns_other\": \"Resetovanie {{count}} stĺpcov\",\n        \"Reset {{count}} entire columns_one\": \"Resetovať celý stĺpec\",\n        \"Comment\": \"Komentár\",\n        \"Reset {{count}} entire columns_other\": \"Resetovať {{count}} celé stĺpce\",\n        \"Cut\": \"Vystrihnúť\",\n        \"Duplicate rows_other\": \"Duplikovať riadky\",\n        \"Filter by this value\": \"Filtrovať podľa tejto hodnoty\",\n        \"Insert row\": \"Vložiť riadok\",\n        \"Insert row above\": \"Vložiť riadok vyššie\",\n        \"Insert row below\": \"Vložiť riadok nižšie\",\n        \"Insert column to the left\": \"Vložiť stĺpec doľava\",\n        \"Copy\": \"Kopírovať\",\n        \"Paste\": \"Vložiť\",\n        \"Clear cell\": \"Vymazať bunku\",\n        \"Clear values\": \"Vyčistiť hodnoty\",\n        \"Copy anchor link\": \"Kopírovať odkaz na kotvu\",\n        \"Delete {{count}} rows_one\": \"Odstrániť riadok\",\n        \"Copy with headers\": \"Kopírovať s hlavičkami\"\n    },\n    \"ChartView\": {\n        \"Create separate series for each value of the selected column.\": \"Vytvoriť samostatné série pre každú hodnotu vybratého stĺpca.\",\n        \"Pick a column\": \"Vybrať stĺpec\",\n        \"Toggle chart aggregation\": \"Prepnúť združovanie grafu\",\n        \"selected new group data columns\": \"vybrať nové stĺpce skupiny dát\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Po každej sérii Y nasleduje séria dlhých chybových pruhov.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Po každej sérii Y nasledujú dve série pre horný a dolný chybový pruh.\",\n        \"LABEL\": \"MENOVKA\",\n        \"Bar chart\": \"Stĺpcový Graf\",\n        \"Pie chart\": \"Kruhový Graf\",\n        \"Donut chart\": \"Vencový Graf\",\n        \"Area chart\": \"Plošný Graf\",\n        \"Line chart\": \"Línyiový Graf\",\n        \"Scatter plot\": \"Diagram Rozptylu\",\n        \"Kaplan-Meier plot\": \"Kaplan-Meier Diagram\",\n        \"Split series\": \"Rozdeliť sériu\",\n        \"Invert Y-axis\": \"Invertovať osu Y\",\n        \"Orientation\": \"Orientácia\",\n        \"Vertical\": \"Vertikálne\",\n        \"Horizontal\": \"Horizontálne\",\n        \"Hole size\": \"Veľkosť otvoru\",\n        \"Show total\": \"Zobraziť Celok\",\n        \"Text size\": \"Veľkosť Textu\",\n        \"Connect gaps\": \"Pripojiť medzery\",\n        \"Show markers\": \"Zobraziť značky\",\n        \"Stack series\": \"Séria Zásobníkov\",\n        \"Error bars\": \"Chybové úsečky\",\n        \"None\": \"Žiadne\",\n        \"Symmetric\": \"Symetrické\",\n        \"Above+Below\": \"Hore+Dole\",\n        \"Split Series\": \"Rozdeliť Série\",\n        \"X-AXIS\": \"OS-X\",\n        \"Aggregate values\": \"Súhrnné hodnoty\",\n        \"SERIES\": \"SÉRIE\",\n        \"Add series\": \"Pridať Sériu\",\n        \"non-numeric columns are not shown\": \"nečíselné stĺpce sa nezobrazujú\",\n        \"non-numeric column is not shown\": \"nečíselný stĺpec sa nezobrazuje\",\n        \"selected new x-axis\": \"vybraná nová os-x\",\n        \"Remove\": \"Odstrániť\",\n        \"Log scale Y-axis\": \"Logaritmická stupnica osi Y\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All shown\": \"Všetko zobrazené\",\n        \"Filter by Range\": \"Filtrovať podľa rozsahu\",\n        \"No matching values\": \"Žiadne zodpovedajúce hodnoty\",\n        \"Max\": \"Max\",\n        \"Start\": \"Štart\",\n        \"End\": \"Koniec\",\n        \"Other Matching\": \"Iné zhodné\",\n        \"Other Non-Matching\": \"Iné nezhodné\",\n        \"Other values\": \"Iné hodnoty\",\n        \"Future values\": \"Budúce hodnoty\",\n        \"Others\": \"Iné\",\n        \"None\": \"Žiadne\",\n        \"Min\": \"Min\",\n        \"Search\": \"Vyhľadať\",\n        \"Search values\": \"Hľadať hodnoty\",\n        \"All\": \"Všetko\",\n        \"All except\": \"Všetko Okrem\",\n        \"Clear search\": \"Vyčistiť vyhľadávanie\",\n        \"Pin filter\": \"Pripnúť filter\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Zoradiť abecedne (aktuálne: zoradené podľa počtu výskytov)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Zoradiť podľa počtu výskytov (aktuálne: zoradené abecedne)\",\n        \"Unpin filter\": \"Odopnúť filter\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (voliteľné)\",\n        \"Add\": \"Pridať\",\n        \"Enter Custom URL\": \"Zadať vlastnú adresu URL\",\n        \"Full document access\": \"Úplný prístup k dokumentu\",\n        \"Learn more about custom widgets\": \"Zistite viac o vlastných widgetoch\",\n        \"Open configuration\": \"Otvoriť konfiguráciu\",\n        \"Pick a {{columnType}} column\": \"Vybrať stĺpec {{columnType}}\",\n        \"Select Custom Widget\": \"Vybrať Vlastný widget\",\n        \"Pick a column\": \"Vybrať stĺpec\",\n        \"Read selected table\": \"Prečítať vybranú tabuľku\",\n        \"Widget does not require any permissions.\": \"Widget nevyžaduje žiadne práva.\",\n        \"Widget needs to {{read}} the current table.\": \"Widget vyžaduje {{read}} aktuálnu tabuľku.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Widget potrebuje {{fullAccess}} k tomuto dokumentu.\",\n        \"No document access\": \"Bez prístupu k dokumentu\",\n        \"Clear selection\": \"Vyčistiť výber\",\n        \"No {{columnType}} columns in table.\": \"V tabuľke nie sú žiadne stĺpce {{columnType}}.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"stĺpce {{wrongTypeCount}} iné ako {{columnType}} sa nezobrazujú\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"stĺpec {{wrongTypeCount}} iný ako {{columnType}} sa nezobrazuje\",\n        \"Custom URL\": \"Vlastná URL\",\n        \"Developer:\": \"Vývojár:\",\n        \"Last updated:\": \"Posledná aktualizácia:\",\n        \"Missing description and author information.\": \"Chýba popis a informácie o autorovi.\",\n        \"ACCESS LEVEL\": \"ÚROVEŇ PRÍSTUPU\",\n        \"Accept\": \"Prijať\",\n        \"Reject\": \"Odmietnuť\",\n        \"Widget\": \"Widget\",\n        \"Change custom widget\": \"Zmeniť vlastný widget\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Táto tímová stránka je pozastavená. Dokumenty je možné čítať, ale nie upravovať.\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Prístup zamietnutý\",\n        \"Code View is available only when you have full document access.\": \"Zobrazenie kódu je dostupné iba vtedy, keď máte úplný prístup k dokumentu.\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Použiť\",\n        \"Cancel\": \"Zrušiť\",\n        \"Default cell style\": \"Predvolený štýl bunky\"\n    },\n    \"DataTables\": {\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Odstrániť údaje {{formattedTableName}} a odobrať ich zo všetkých stránok?\",\n        \"Duplicate table\": \"Duplikovať Tabuľku\",\n        \"Raw Data Tables\": \"Tabuľky so surovými údajmi\",\n        \"Table ID copied to clipboard\": \"ID tabuľky bolo skopírované do schránky\",\n        \"Record Card\": \"Karta Záznamu\",\n        \"Edit record card\": \"Úprava Karty Záznamu\",\n        \"Click to copy\": \"Kliknutím skopírovať\",\n        \"You do not have edit access to this document\": \"Nemáte prístup k úprave tohto dokumentu\",\n        \"Record Card Disabled\": \"Zakázaná Karta Záznamu\",\n        \"Rename table\": \"Premenovať tabuľku\",\n        \"{{action}} Record Card\": \"{{action}} Kartu Záznamu\",\n        \"Remove table\": \"Odstrániť tabuľku\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Aktivita\",\n        \"Beta\": \"Beta\",\n        \"Compare to previous\": \"Porovnať s Predchádzajúcim\",\n        \"Compare to current\": \"Porovnať s Aktuálnym\",\n        \"Open snapshot\": \"Otvoriť Snímok\",\n        \"Snapshots\": \"Snímok\",\n        \"Snapshots are unavailable.\": \"Snímky nie sú k dispozícii.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Prístup k snímkom dokumentov s pravidlami prístupu majú iba vlastníci.\"\n    },\n    \"DocMenu\": {\n        \"(The organization needs a paid plan)\": \"(Organizácia potrebuje platený plán)\",\n        \"Access Details\": \"Podrobnosti Prístupu\",\n        \"By Date Modified\": \"Podľa Dátumu Zmeny\",\n        \"Document will be moved to Trash.\": \"Dokument bude presunutý do Koša.\",\n        \"Edited {{at}}\": \"Upravené {{at}}\",\n        \"Examples and Templates\": \"Príklady a Šablóny\",\n        \"Examples & Templates\": \"Príklady & Šablóny\",\n        \"Manage users\": \"Spravovať Používateľov\",\n        \"More Examples and Templates\": \"Ďalšie Príklady a Šablóny\",\n        \"Move\": \"Presunúť\",\n        \"Other Sites\": \"Iné Stránky\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Natrvalo Odstrániť „{{name}}“?\",\n        \"Pin Document\": \"Pripnúť Dokument\",\n        \"Pinned Documents\": \"Pripnuté Dokumenty\",\n        \"Remove\": \"Odobrať\",\n        \"Rename\": \"Premenovať\",\n        \"Requires edit permissions\": \"Vyžaduje povolenia na úpravy\",\n        \"To restore this document, restore the workspace first.\": \"Ak chcete tento dokument obnoviť, najskôr obnovte pracovný priestor.\",\n        \"Trash\": \"Kôš\",\n        \"Trash is empty.\": \"Kôš je prázdny.\",\n        \"Unpin Document\": \"Odopnúť Dokument\",\n        \"Workspace not found\": \"Pracovný priestor sa nenašiel\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Nachádzate sa na svojej osobnej stránke. Máte tiež prístup k nasledujúcim stránkam:\",\n        \"All documents\": \"Všetky dokumenty\",\n        \"Current workspace\": \"Aktuálny pracovný priestor\",\n        \"Deleted {{at}}\": \"Odstránené {{at}}\",\n        \"By Name\": \"Podľa Názvu\",\n        \"Delete\": \"Odstrániť\",\n        \"Delete Forever\": \"Odstrániť Navždy\",\n        \"Discover More Templates\": \"Objaviť Ďalšie Šablóny\",\n        \"Document will be permanently deleted.\": \"Dokument bude natrvalo odstránený.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Dokumenty zostanú v koši 30 dní, potom sa natrvalo odstránia.\",\n        \"Featured\": \"Odporúčané\",\n        \"Move {{name}} to workspace\": \"Presunúť {{name}} do pracovného priestoru\",\n        \"This service is not available right now\": \"Táto služba nie je momentálne dostupná\",\n        \"Restore\": \"Obnoviť\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Nachádzate sa na stránke {{siteName}}. Máte tiež prístup k nasledujúcim stránkam:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Keď pracovný priestor neobsahuje žiadne dokumenty, môžete ho natrvalo odstrániť.\",\n        \"Delete {{name}}\": \"Odstrániť {{name}}\",\n        \"Create my first document\": \"Vytvoriť môj prvý dokument\",\n        \"personal site\": \"osobná stránka\",\n        \"Any documents created in this site will appear here.\": \"Všetky dokumenty vytvorené na tejto stránke sa zobrazia tu.\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Máte prístup len na čítanie tejto stránky. V súčasnosti neexistujú žiadne dokumenty.\",\n        \"Grid view\": \"Zobraziť v mriežke\",\n        \"List view\": \"Zobraziť v zozname\"\n    },\n    \"DocPageModel\": {\n        \"Enter recovery mode\": \"Spustiť režim obnovenia\",\n        \"Error accessing document\": \"Chyba pri prístupe k dokumentu\",\n        \"Reload\": \"Znovu načítať\",\n        \"Add empty table\": \"Pridať prázdnu tabuľku\",\n        \"Add page\": \"Pridať stránku\",\n        \"Add widget to page\": \"Pridať widget na stránku\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Vlastníci dokumentu sa môžu pokúsiť dokument obnoviť. [{{error}}]\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Ľutujeme, prístup k tomuto dokumentu bol odmietnutý. [{{error}}]\",\n        \"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}}]\",\n        \"You do not have edit access to this document\": \"Nemáte prístup k úpravám tohto dokumentu\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Dokument znova načítajte a ak chyba pretrváva, obráťte sa na vlastníkov dokumentu a pokúste sa o obnovenie dokumentu. [{{error}}]\"\n    },\n    \"DocTour\": {\n        \"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.\": \"Nie je možné vytvoriť prehliadku dokumentu z údajov v tomto dokumente. Uistite sa, že existuje tabuľka s názvom GristDocTour so stĺpcami Title, Body, Placement a Location.\",\n        \"No valid document tour\": \"Neplatná prehliadka dokumentov\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Mena:\",\n        \"Document settings\": \"Nastavenia Dokumentu\",\n        \"Save and Reload\": \"Uložiť a znova Načítať\",\n        \"This document's ID (for API use):\": \"ID tohto dokumentu (na použitie API):\",\n        \"Time Zone:\": \"Časové Pásmo:\",\n        \"Manage Webhooks\": \"Spravovať Webhooks\",\n        \"Webhooks\": \"Webhooky\",\n        \"API console\": \"API konzola\",\n        \"API URL copied to clipboard\": \"Adresa URL rozhrania API bola skopírovaná do schránky\",\n        \"API documentation.\": \"API dokumentácia.\",\n        \"Base doc URL: {{docApiUrl}}\": \"Základná URL dokumentu: {{docApiUrl}}\",\n        \"Coming soon\": \"Už čoskoro\",\n        \"Copy to clipboard\": \"Skopírovať do schránky\",\n        \"Currency\": \"Mena\",\n        \"Data engine\": \"Dátový Stroj\",\n        \"Default for DateTime columns\": \"Predvoľba pre stĺpce DateTime\",\n        \"Document ID\": \"ID Dokumentu\",\n        \"Find slow formulas\": \"Vyhľadať pomalé vzorce\",\n        \"For number and date formats\": \"Pre čísla a formáty dátumu\",\n        \"Formula times\": \"Vzorec časov\",\n        \"Hard reset of data engine\": \"Tvrdý reset dátového stroja\",\n        \"ID for API use\": \"ID pre použitie API\",\n        \"Locale\": \"Miestne\",\n        \"Manage webhooks\": \"Spravovať webhooks\",\n        \"Python version used\": \"Použitá verzia Pythonu\",\n        \"Reload\": \"Znovu načítať\",\n        \"Time zone\": \"Časové Pásmo\",\n        \"Try API calls from the browser\": \"Skúsiť volania API z prehliadača\",\n        \"python2 (legacy)\": \"python2 (zastaralé)\",\n        \"python3 (recommended)\": \"python3 (odporúčané)\",\n        \"Cancel\": \"Zrušiť\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Vynútiť opätovné načítanie dokumentu pri časovaní vzorcov a zobraziť výsledok.\",\n        \"Formula timer\": \"Časovač Vzorca\",\n        \"Reload data engine\": \"Znovu načítať dátový stroj\",\n        \"Reload data engine?\": \"Znovu načítať dátový stroj?\",\n        \"Start timing\": \"Spustiť časovanie\",\n        \"Stop timing...\": \"Zastaviť časovač...\",\n        \"Time reload\": \"Znova načítať čas\",\n        \"Timing is on\": \"Časovanie je zapnuté\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Môžete vykonať zmeny v dokumente a potom zastaviť časovanie, aby ste videli výsledky.\",\n        \"Local currency ({{currency}})\": \"Miestna mena ({{currency}})\",\n        \"Save\": \"Uložiť\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Motor (experimentálna {{span}} zmena na vlastné riziko):\",\n        \"Locale:\": \"Miestne:\",\n        \"Ok\": \"OK\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"ID dokumentu bolo skopírované do schránky\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"ID dokumentu, ktoré sa má použiť vždy, keď REST API požaduje {{docId}}. Pozrieť {{apiURL}}\",\n        \"For currency columns\": \"Pre stĺpce meny\",\n        \"Python\": \"Python\",\n        \"Notify other services on doc changes\": \"Upozorniť ostatné služby na zmeny dokumentu\",\n        \"Only available to document editors\": \"Dostupné iba pre redaktorov dokumentov\",\n        \"Only available to document owners\": \"Dostupné iba pre vlastníkov dokumentov\",\n        \"Change document type\": \"Zmena typu dokumentu\",\n        \"Template mode\": \"Režim šablóny\",\n        \"Edit\": \"Upraviť\",\n        \"Change nature of document\": \"Zmena charakteru dokumentu\",\n        \"Regular document\": \"Bežný dokument\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Normálne správanie dokumentu. Všetci používatelia pracujú na rovnakej kópii dokumentu.\",\n        \"Regular\": \"Bežný\",\n        \"Template\": \"Šablóna\",\n        \"fiddle mode\": \"pokusný mód\",\n        \"Tutorial\": \"Návod\",\n        \"Document automatically opens as a user-specific copy.\": \"Dokument sa automaticky otvorí ako kópia špecifická pre používateľa.\",\n        \"Confirm change\": \"Potvrdiť zmenu\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Akonáhle začnete s časovaním, Grist zmeria čas potrebný na vyhodnotenie každého vzorca. To umožňuje diagnostikovať, ktoré vzorce sú zodpovedné za pomalý výkon pri prvom otvorení dokumentu alebo keď dokument reaguje na zmeny.\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Tým sa vykoná tvrdé opätovné načítanie dátového stroja. To môže pomôcť, ak je dátový stroj uviaznutý v nekonečnej slučke, na neurčito spracováva najnovšiu zmenu alebo zlyhal. Nebudú stratené žiadne údaje, s výnimkou prípadných aktuálne prebiehajúcich akcií.\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Dokument sa automaticky otvorí vo {{fiddleModeDocUrl}}. Každý ho môže upraviť, čo vytvorí novú neuloženú kópiu.\",\n        \"External\": \"Externé\",\n        \"Internal\": \"Interné\",\n        \"Transfer in progress\": \"Prebieha presun\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Kliknutím na „Spustiť prenos“ ich prenesiete do Externého úložiska.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Kliknutím na „Spustiť prenos“ ich prenesiete do Interného úložiska (uloženého v súbore dokumentu SQLite).\",\n        \"**Some existing attachments are still external**.\": \"**Niektoré existujúce prílohy sú stále externé**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Niektoré existujúce prílohy sú stále interné** (uložené v súbore SQLite).\",\n        \"Attachment storage\": \"Uloženie príloh\",\n        \"Being transfer\": \"Prebieha prenos\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Novo nahrané prílohy budú umiestnené v Externom úložisku.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Novo nahrané prílohy budú umiestnené do Interného úložiska.\",\n        \"Preferred storage for this document\": \"Preferované úložisko pre tento dokument\",\n        \"No external stores available\": \"Nie je k dispozícii žiadne externé úložisko\",\n        \"Start transfer\": \"Začať prenos\",\n        \"Upload\": \"Nahrať\",\n        \"Upload missing attachments\": \"Nahrať chýbajúce prílohy\",\n        \"Uploading...\": \"Nahrávanie...\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Niektoré existujúce prílohy sú stále [external]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Niektoré existujúce prílohy sú stále [internal]({{internalLink}})** (uložené v súbore SQLite).\",\n        \"Default\": \"Predvolené\",\n        \"Document type\": \"Typ dokumentu\",\n        \"[Learn more.]({{learnLink}})\": \"[Naučiť sa viac.]({{learnLink}})\",\n        \"Default, template, or tutorial\": \"Predvolené, šablóna alebo návod\",\n        \"Allow others to suggest changes\": \"Dovoľte ostatným navrhnúť zmeny\",\n        \"Enable suggestions\": \"Povoliť návrhy\",\n        \"Suggestions\": \"Návrhy\",\n        \"experiment\": \"experiment\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"Veľkosť Príloh\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Kontaktujte vlastníka lokality, aby inovoval plán a zvýšil limity.\",\n        \"For higher limits, \": \"Pre vyššie limity, \",\n        \"Rows\": \"Riadky\",\n        \"Usage\": \"Použitie\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"Štatistiky používania sú dostupné len pre používateľov s úplným prístupom k údajom dokumentu.\",\n        \"start your 30-day free trial of the Pro plan.\": \"začnite svoju 30-dňovú bezplatnú skúšobnú verziu plánu Pro.\",\n        \"Data size\": \"Veľkosť Údajov\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Skopírujte všetky údaje okrem štruktúry tabuľky.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Namiesto duplikovania tabuliek je zvyčajne lepšie segmentovať údaje pomocou prepojených zobrazení. {{link}}\",\n        \"Name for new table\": \"Názov novej tabuľky\",\n        \"Only the document default access rules will apply to the copy.\": \"Na kópiu sa budú vzťahovať iba predvolené pravidlá prístupu k dokumentu.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Mimoškolský Program\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Pozrite si náš súvisiaci návod, ako prepojiť údaje a vytvoriť vysoko produktívne rozloženia.\",\n        \"Investment Research\": \"Investičný Výskum\",\n        \"Lightweight CRM\": \"Ľahké CRM\",\n        \"Tutorial: Analyze & Visualize\": \"Návod: Analyzujte a Vizualizujte\",\n        \"Tutorial: Create a CRM\": \"Návod: Vytvorte CRM\",\n        \"Tutorial: Manage Business Data\": \"Návod: Správa obchodných údajov\",\n        \"Welcome to the Afterschool Program template\": \"Vitajte v šablóne Mimoškolský Program\",\n        \"Welcome to the Investment Research template\": \"Vitajte v šablóne Investičný Prieskum\",\n        \"Welcome to the Lightweight CRM template\": \"Vitajte v šablóne Ľahké CRM\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Pozrite si náš súvisiaci návod, ako modelovať obchodné údaje, používať vzorce a spravovať zložitosť.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Pozrite si náš súvisiaci návod, v ktorom sa dozviete, ako vytvoriť súhrnné tabuľky a grafy a ako grafy dynamicky prepojiť.\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"SPRÁVANIE STĹPCA\",\n        \"COLUMN LABEL AND ID\": \"OZNAČENIE A ID STĹPCA\",\n        \"Clear and make into formula\": \"Vyčistiť a vytvoriť vzorec\",\n        \"DESCRIPTION\": \"POPIS\",\n        \"Clear and reset\": \"Vymazať a resetovať\",\n        \"Column options are limited in summary tables.\": \"Možnosti stĺpcov sú v súhrnných tabuľkách obmedzené.\",\n        \"Mixed Behavior\": \"Zmiešané Správanie\",\n        \"Empty columns_other\": \"Prázdne Stĺpce\",\n        \"Convert column to data\": \"Previesť stĺpec na údaje\",\n        \"Convert to trigger formula\": \"Konvertovať na spúšťací vzorec\",\n        \"Data columns_one\": \"Stĺpec Údajov\",\n        \"Data columns_other\": \"Stĺpce Údajov\",\n        \"Empty columns_one\": \"Prázdny Stĺpec\",\n        \"Enter formula\": \"Zadať vzorec\",\n        \"Formula columns_one\": \"Stĺpec Vzorca\",\n        \"Formula columns_other\": \"Stĺpce Vzorca\",\n        \"Make into data column\": \"Prerobiť na stĺpec údajov\",\n        \"Set trigger formula\": \"Nastaviť spúšťací vzorec\",\n        \"TRIGGER FORMULA\": \"SPÚŠŤACÍ VZOREC\",\n        \"Set formula\": \"Nastaviť vzorec\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Obnoviť poslednú úpravu\",\n        \"Undo discard\": \"Zrušiť zahodenie\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Vrátiť sa k spoločným nastaveniam\",\n        \"Save as common settings\": \"Uložiť ako spoločné nastavenia\",\n        \"Use separate settings\": \"Použiť samostatné nastavenia\",\n        \"Using separate settings\": \"Použitie samostatných nastavení\",\n        \"Using common settings\": \"Použiť spoločné nastavenia\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Prehľadať stĺpce\",\n        \"Search Columns\": \"Prehľadať Stĺpce\"\n    },\n    \"GridViewMenus\": {\n        \"Add to sort\": \"Pridať do triedenia\",\n        \"Clear values\": \"Vyčistiť hodnoty\",\n        \"Delete {{count}} columns_one\": \"Odstrániť stĺpec\",\n        \"Freeze {{count}} columns_other\": \"Zmraziť {{count}} stĺpcov\",\n        \"Freeze {{count}} more columns_other\": \"Zmraziť {{count}} ďalšie stĺpce\",\n        \"Add column\": \"Pridať stĺpec\",\n        \"Column Options\": \"Možnosti Stĺpca\",\n        \"Convert formula to data\": \"Previesť vzorec na údaje\",\n        \"Delete {{count}} columns_other\": \"Odstrániť {{count}} stĺpcov\",\n        \"Filter Data\": \"Filtrovať Údaje\",\n        \"Freeze {{count}} columns_one\": \"Zmraziť tento stĺpec\",\n        \"Freeze {{count}} more columns_one\": \"Zmraziť ešte jeden stĺpec\",\n        \"Hide {{count}} columns_one\": \"Skryť stĺpec\",\n        \"Hide {{count}} columns_other\": \"Skryť {{count}} stĺpce\",\n        \"Reset {{count}} columns_one\": \"Resetovať stĺpec\",\n        \"Reset {{count}} columns_other\": \"Resetovať {{count}} stĺpcov\",\n        \"Reset {{count}} entire columns_one\": \"Resetovať celý stĺpec\",\n        \"Reset {{count}} entire columns_other\": \"Resetovať {{count}} celé stĺpce\",\n        \"Apply to new records\": \"Použiť na nové záznamy\",\n        \"Authorship\": \"Autorstvo\",\n        \"Lookups\": \"Vyhľadávania\",\n        \"Show column {{- label}}\": \"Zobraziť stĺpec {{- label}}\",\n        \"Sort\": \"Triediť\",\n        \"Sorted (#{{count}})_one\": \"Zoradené (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Zoradené (#{{count}})\",\n        \"Unfreeze {{count}} columns_one\": \"Zrušiť zmrazenie tohto stĺpca\",\n        \"Unfreeze all columns\": \"Zrušiť zmrazenie všetkých stĺpcov\",\n        \"Show hidden columns\": \"Zobraziť skryté stĺpce\",\n        \"Timestamp\": \"Časové razítko\",\n        \"no reference column\": \"žiadny referenčný stĺpec\",\n        \"Unfreeze {{count}} columns_other\": \"Zrušiť zmrazenie {{count}} stĺpcov\",\n        \"Insert column to the left\": \"Vložiť stĺpec doľava\",\n        \"Insert column to the right\": \"Vložiť stĺpec doprava\",\n        \"Apply on record changes\": \"Použiť zmeny záznamu\",\n        \"Created By\": \"Vytvoril\",\n        \"Hidden Columns\": \"Skryté stĺpce\",\n        \"Last Updated At\": \"Naposledy aktualizované v\",\n        \"Last Updated By\": \"Naposledy aktualizované používateľom\",\n        \"Insert column to the {{to}}\": \"Vložiť stĺpec do {{to}}\",\n        \"More sort options ...\": \"Ďalšie možnosti zoradenia…\",\n        \"Rename column\": \"Premenovať stĺpec\",\n        \"Created At\": \"Vytvorené v\",\n        \"Detect Duplicates in...\": \"Zistiť duplikáty v...\",\n        \"Duplicate in {{- label}}\": \"Duplikovať v {{- label}}\",\n        \"Shortcuts\": \"Skratky\",\n        \"Adding UUID column\": \"Pridávať stĺpec UUID\",\n        \"Adding duplicates column\": \"Pridávať stĺpec duplikátov\",\n        \"Search columns\": \"Prehľadať stĺpce\",\n        \"UUID\": \"UUID\",\n        \"No reference columns.\": \"Žiadne referenčné stĺpce.\",\n        \"Add formula column\": \"Pridať stĺpec vzorca\",\n        \"Add column with type\": \"Pridať stĺpec s typom\",\n        \"Created at\": \"Vytvorené v\",\n        \"Created by\": \"Vytvoril\",\n        \"Detect duplicates in...\": \"Zistiť duplikáty v...\",\n        \"DateTime\": \"Dátum a Čas\",\n        \"Choice\": \"Voľba\",\n        \"Reference\": \"Referencia\",\n        \"Last updated at\": \"Naposledy aktualizované v\",\n        \"Last updated by\": \"Naposledy aktualizoval\",\n        \"Date\": \"Dátum\",\n        \"Any\": \"Akýkoľvek\",\n        \"Text\": \"Text\",\n        \"Integer\": \"Celé číslo\",\n        \"Numeric\": \"Desatinné číslo\",\n        \"Toggle\": \"Prepínať\",\n        \"Choice List\": \"Výberový zoznam\",\n        \"Reference List\": \"Zoznam referencií\",\n        \"Attachment\": \"Príloha\"\n    },\n    \"GridOptions\": {\n        \"Horizontal gridlines\": \"Horizontálna línia Mriežky\",\n        \"Grid Options\": \"Možnosti Mriežky\",\n        \"Vertical gridlines\": \"Vertikálna línia Mriežky\",\n        \"Zebra stripes\": \"Zebra Pruhy\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Pridať Stĺpec\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Pripnúť filter - stĺpec {{- columnName}} (aktuálne: nepripnuté)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Odopnúť filter - {{- columnName}} stĺpec (aktuálne: pripnutý)\",\n        \"remove filter - {{- columnName}} column\": \"odstrániť filter - {{- columnName}} stĺpec\",\n        \"{{- columnName }} column filters\": \"{{- columnName }} filtre stĺpca\"\n    },\n    \"HomeIntro\": {\n        \"Welcome to {{- orgName}}\": \"Vitajte v {{- orgName}}\",\n        \"{{signUp}} to save your work. \": \"Ak chcete uložiť svoju prácu, {{signUp}}. \",\n        \"Welcome to Grist, {{- name}}!\": \"Vitajte v Grist, {{- name}}!\",\n        \"Any documents created in this site will appear here.\": \"Všetky dokumenty vytvorené na tejto stránke sa zobrazia tu.\",\n        \"Browse Templates\": \"Prechádzať Šablóny\",\n        \"Create empty document\": \"Vytvoriť Prázdny Dokument\",\n        \"Get started by creating your first Grist document.\": \"Začať vytvorením svojho prvého dokumentu Grist.\",\n        \"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.\",\n        \"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.\",\n        \"Help Center\": \"Centrum Pomoci\",\n        \"Import document\": \"Importovať Dokument\",\n        \"Invite Team Members\": \"Pozvať Členov Tímu\",\n        \"This workspace is empty.\": \"Tento pracovný priestor je prázdny.\",\n        \"Sprouts Program\": \"Program Klíčenia\",\n        \"Visit our {{link}} to learn more.\": \"Viac informácií na našej stránke {{link}}.\",\n        \"Welcome to Grist!\": \"Vitajte v Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Vitajte v Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Vitajte v {{orgName}}\",\n        \"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.\",\n        \"personal site\": \"osobná stránka\",\n        \"Sign in\": \"Prihlásiť sa\",\n        \"To use Grist, please either sign up or sign in.\": \"Ak chcete používať Grist, zaregistrujte sa alebo sa prihláste.\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Navštíviť náš {{link}} a dozvedieť sa viac o Grist.\",\n        \"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}}.\",\n        \"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é \",\n        \"Sign up\": \"Prihlásiť sa\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Zistiť viac v našom {{helpCenterLink}}.\",\n        \"Only show documents\": \"Dokumenty iba ukázať\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"Zlúčiť riadky, ktoré zodpovedajú týmto poliam:\",\n        \"Select fields to match on\": \"Vyberte polia podľa obsahu\",\n        \"Update existing records\": \"Aktualizovať existujúce záznamy\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} nezhodujúce sa pole v importe\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} nezhodujúcich sa polí v importe\",\n        \"{{count}} unmatched field_one\": \"{{count}} nezhodné pole\",\n        \"{{count}} unmatched field_other\": \"{{count}} nezhodné polia\",\n        \"Column Mapping\": \"Mapovanie Stĺpcov\",\n        \"Column mapping\": \"Mapovanie stĺpcov\",\n        \"Destination table\": \"Cieľová tabuľka\",\n        \"Grist column\": \"Grist stĺpec\",\n        \"Import from file\": \"Importovať zo súboru\",\n        \"New Table\": \"Nová Tabuľka\",\n        \"Revert\": \"Návrat\",\n        \"Skip\": \"Preskočiť\",\n        \"Skip Import\": \"Preskočiť import\",\n        \"Skip Table on Import\": \"Preskočiť Tabuľku pri Importe\",\n        \"Source column\": \"Zdrojový stĺpec\",\n        \"Import options\": \"Možnosti importu\",\n        \"Cancel\": \"Zrušiť\",\n        \"Import\": \"Import\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"Ako Šablóna\",\n        \"Enter document name\": \"Zadajte názov dokumentu\",\n        \"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é.\",\n        \"However, it appears to be already identical.\": \"Zdá sa však, že je už identický.\",\n        \"Include the structure without any of the data.\": \"Zahrňte štruktúru bez akýchkoľvek údajov.\",\n        \"Cancel\": \"Zrušiť\",\n        \"You do not have write access to the selected workspace\": \"Nemáte prístup na zápis do vybratého pracovného priestoru\",\n        \"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.\",\n        \"Name\": \"Meno\",\n        \"You do not have write access to this site\": \"Nemáte prístup k zápisu na túto stránku\",\n        \"No destination workspace\": \"Žiadny cieľový pracovný priestor\",\n        \"Organization\": \"Organizácia\",\n        \"Overwrite\": \"Prepísať\",\n        \"Sign up\": \"Prihlásiť sa\",\n        \"Download document and history\": \"Stiahnite si celý dokument a jeho históriu\",\n        \"Download document structure only (no data, for template use)\": \"Odstráňte všetky údaje, ale ponechajte štruktúru na použitie ako šablónu\",\n        \"Download document without history (can significantly reduce file size)\": \"Odstrániť históriu dokumentov (môže výrazne znížiť veľkosť súboru)\",\n        \"Download\": \"Stiahnuť\",\n        \"Download document\": \"Stiahnuť dokument\",\n        \"Original Has Modifications\": \"Originál Má Úpravy\",\n        \"Original Looks Unrelated\": \"Pôvodný Pohľad Nesúvisí\",\n        \"Original Looks Identical\": \"Pôvodný Pohľad Identický\",\n        \"Replacing the original requires editing rights on the original document.\": \"Nahradenie originálu vyžaduje práva na úpravu pôvodného dokumentu.\",\n        \"The original version of this document will be updated.\": \"Pôvodná verzia tohto dokumentu bude aktualizovaná.\",\n        \"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.\",\n        \"Update\": \"Aktualizovať\",\n        \"Update Original\": \"Aktualizovať Originál\",\n        \"Workspace\": \"Pracovný priestor\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"Stiahnuť si archív všetkých príloh prítomných v tomto dokumente.\",\n        \".tar (recommended)\": \".tar (odporúča sa)\",\n        \"Download attachments\": \"Stiahnuť prílohy\",\n        \"Download full document and history\": \"Stiahnuť si celý dokument a históriu\",\n        \"Format:\": \"Formát:\",\n        \"Learn more\": \"Zistiť viac\",\n        \"download attachments\": \"stiahnuť prílohy\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Prílohy sú externé a nie sú súčasťou tohto sťahovania. Ak dokument nahráte do samostatnej inštalácie Grist, budete musieť tiež samostatne stiahnuť {{downloadLink}}. \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"Ak plánujete nahrať tento dokument do Gristu, na obnovenie príloh budete potrebovať archív v \\\".tar\\\" formáte. \"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"Pridaná nová prepojená sekcia na zobrazenie {{viewName}}\",\n        \"Import from file\": \"Importovať zo súboru\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Uložená prepojená sekcia {{title}} v zobrazení {{name}}\",\n        \"go to webhook settings\": \"prejsť na nastavenia webhooku\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Nové zmeny sú dočasne pozastavené. Front webhookov je preplnený. Skontrolujte nastavenia webhookov, odstráňte neplatné webhooky a vyčistite front.\"\n    },\n    \"HomeLeftPane\": {\n        \"Access Details\": \"Podrobnosti Prístupu\",\n        \"All documents\": \"Všetky Dokumenty\",\n        \"Create empty document\": \"Vytvoriť Prázdny Dokument\",\n        \"Create workspace\": \"Vytvoriť Pracovný priestor\",\n        \"Delete\": \"Odstrániť\",\n        \"Delete {{workspace}} and all included documents?\": \"Odstrániť {{workspace}} a všetky zahrnuté dokumenty?\",\n        \"Examples & Templates\": \"Šablóny\",\n        \"Import document\": \"Importovať Dokument\",\n        \"Manage users\": \"Spravovať Používateľov\",\n        \"Rename\": \"Premenovať\",\n        \"Trash\": \"Odpad\",\n        \"Workspace will be moved to Trash.\": \"Pracovný priestor sa presunie do Koša.\",\n        \"Workspaces\": \"Pracovné priestory\",\n        \"Terms of service\": \"Podmienky služby\",\n        \"Tutorial\": \"Návod\",\n        \"Grist Resources\": \"Zdroje Grist\",\n        \"context menu - {{- workspaceName }}\": \"kontextové menu - {{- workspaceName }}\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Centrum Pomoci\",\n        \"Accessibility\": \"Prístupnosť\"\n    },\n    \"NotifyUI\": {\n        \"Go to your free personal site\": \"Prejdite na svoju bezplatnú osobnú stránku\",\n        \"No notifications\": \"Žiadne upozornenia\",\n        \"Notifications\": \"Upozornenia\",\n        \"Manage billing\": \"Spravovať fakturáciu\",\n        \"Ask for help\": \"Požiadať o pomoc\",\n        \"Cannot find personal site, sorry!\": \"Nie je možné nájsť osobnú stránku, prepáčte!\",\n        \"Give feedback\": \"Dať spätnú väzbu\",\n        \"Renew\": \"Obnoviť\",\n        \"Report a problem\": \"Nahlásiť problém\",\n        \"Upgrade Plan\": \"Plán Inovácie\"\n    },\n    \"OpenVideoTour\": {\n        \"YouTube video player\": \"Prehrávač videa YouTube\",\n        \"Grist Video Tour\": \"Grist Video Prehliadka\",\n        \"Video Tour\": \"Video Prehliadka\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Pridať na stránku\",\n        \"Group by\": \"Zoskupiť podľa\",\n        \"Building {{- label}} widget\": \"Buduje sa {{- label}} widget\",\n        \"Select data\": \"Vybrať Údaje\",\n        \"Select widget\": \"Vybrať widget\",\n        \"New Table\": \"Nová Tabuľka\",\n        \"SELECT BY\": \"VYBRAŤ PODĽA\"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Aktualizuje sa schéma záznamov.\"\n    },\n    \"Pages\": {\n        \"The following tables will no longer be visible_one\": \"Nasledujúca tabuľka už nebude zobraziteľná\",\n        \"The following tables will no longer be visible_other\": \"Nasledujúce tabuľky už nebudú viditeľné\",\n        \"Delete\": \"Odstrániť\",\n        \"Delete data and this page.\": \"Odstrániť údaje a túto stránku.\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Uložiť údaje a odstrániť stranu. Tabuľka zostane k dispozícii v {{rawDataLink}}\",\n        \"raw data page\": \"strana surových údajov\",\n        \"Document pages\": \"Stránky dokumentu\"\n    },\n    \"PermissionsWidget\": {\n        \"Deny all\": \"Odmietnuť Všetko\",\n        \"Read only\": \"Iba na čítanie\",\n        \"Allow all\": \"Povoliť Všetko\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Import zlyhal: \"\n    },\n    \"RecordLayoutEditor\": {\n        \"Save layout\": \"Uložiť rozloženie\",\n        \"Cancel\": \"Zrušiť\",\n        \"Add field\": \"Pridať pole\",\n        \"Create new field\": \"Vytvoriť nové pole\",\n        \"Show field {{- label}}\": \"Zobraziť pole {{- label}}\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Pridať stĺpec\",\n        \"No columns to add\": \"Žiadne stĺpce na pridanie\"\n    },\n    \"RightPanel\": {\n        \"CHART TYPE\": \"TYP GRAF\",\n        \"Data\": \"Údaje\",\n        \"fields_one\": \"Pole\",\n        \"fields_other\": \"Polia\",\n        \"Sort & filter\": \"Triediť a Filtrovať\",\n        \"TRANSFORM\": \"TRANSFORMÁCIA\",\n        \"Theme\": \"Téma\",\n        \"WIDGET TITLE\": \"NÁZOV WIDGETU\",\n        \"Enter redirect URL\": \"Zadať URL presmerovania\",\n        \"No field selected\": \"Nie je vybraté žiadne pole\",\n        \"COLUMN TYPE\": \"TYP STĹPEC\",\n        \"CUSTOM\": \"VOLITELNÝ\",\n        \"Change widget\": \"Zmeniť widget\",\n        \"columns_one\": \"Stĺpec\",\n        \"columns_other\": \"Stĺpce\",\n        \"DATA TABLE\": \"TABUĽKA ÚDAJOV\",\n        \"DATA TABLE NAME\": \"NÁZOV TABUĽKY ÚDAJOV\",\n        \"Detach\": \"Oddeliť\",\n        \"Edit data selection\": \"Upraviť Výber Údajov\",\n        \"GROUPED BY\": \"ZOSKUPIŤ PODĽA\",\n        \"Row style\": \"Štýl riadku\",\n        \"SELECT BY\": \"VYBRAŤ PODĽA\",\n        \"SELECTOR FOR\": \"VOLIČ PRE\",\n        \"SOURCE DATA\": \"ZDROJOVÉ ÚDAJE\",\n        \"Save\": \"Uložiť\",\n        \"Select widget\": \"Vybrať widget\",\n        \"series_one\": \"Séria\",\n        \"series_other\": \"Série\",\n        \"Widget\": \"Widget\",\n        \"You do not have edit access to this document\": \"Nemáte prístup k úpravám tohto dokumentu\",\n        \"Add referenced columns\": \"Pridať referenčné stĺpce\",\n        \"Reset form\": \"Obnoviť formulár\",\n        \"Configuration\": \"Konfigurácia\",\n        \"Default field value\": \"Predvolená hodnota poľa\",\n        \"Display button\": \"Tlačidlo Zobrazenia\",\n        \"Enter text\": \"Zadať text\",\n        \"Required field\": \"Vyžadované pole\",\n        \"Field rules\": \"Pravidlá poľa\",\n        \"Field title\": \"Názov Poľa\",\n        \"Hidden field\": \"Skryté pole\",\n        \"Layout\": \"Rozloženie\",\n        \"Redirect automatically after submission\": \"Po odoslaní automaticky presmerovať\",\n        \"Redirection\": \"Presmerovať\",\n        \"Submission\": \"Návrh\",\n        \"Submit another response\": \"Odoslať inú odpoveď\",\n        \"Success text\": \"Text Dokončenia\",\n        \"Table column name\": \"Názov stĺpca tabuľky\",\n        \"Select a field in the form widget to configure.\": \"Vyberte pole vo formulárovom widgete, ktoré chcete nakonfigurovať.\",\n        \"Submit button label\": \"Označenie tlačidla Odoslať\",\n        \"Submit\": \"Odoslať\",\n        \"Thank you! Your response has been recorded.\": \"Ďakujem! Vaša odpoveď bola zaznamenaná.\",\n        \"Chart options\": \"Možnosti grafu\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"Kopírovať odkaz na kotvu\",\n        \"Insert row\": \"Vložiť riadok\",\n        \"Insert row above\": \"Vložiť riadok vyššie\",\n        \"Duplicate rows_other\": \"Duplikovať riadky\",\n        \"Insert row below\": \"Vložiť riadok nižšie\",\n        \"Delete\": \"Odstrániť\",\n        \"Duplicate rows_one\": \"Duplikovať riadok\",\n        \"View as card\": \"Zobraziť ako kartu\",\n        \"Use as table headers\": \"Použiť ako hlavičky tabuľky\"\n    },\n    \"ShareMenu\": {\n        \"Edit without affecting the original\": \"Upraviť bez ovplyvnenia originálu\",\n        \"Duplicate document\": \"Duplikovať Dokument\",\n        \"Back to current\": \"Späť na Aktuálne\",\n        \"Compare to {{termToUse}}\": \"Porovnať s {{termToUse}}\",\n        \"Access Details\": \"Podrobnosti Prístupu\",\n        \"Current Version\": \"Aktuálna verzia\",\n        \"Download\": \"Stiahnuť\",\n        \"Export CSV\": \"Exportovať CSV\",\n        \"Export XLSX\": \"Exportovať XLSX\",\n        \"Send to Google Drive\": \"Odoslať na Disk Google\",\n        \"Show in folder\": \"Zobraziť v priečinku\",\n        \"Unsaved\": \"Neuložené\",\n        \"Work on a copy\": \"Pracovať na kópii\",\n        \"Share\": \"Zdieľať\",\n        \"Download...\": \"Stiahnuť ...\",\n        \"Comma Separated Values (.csv)\": \"Hodnoty oddelené čiarkou (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"DOO odelené hodnoty (.dsv)\",\n        \"Export as...\": \"Exportovať ako...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Hodnoty oddelené tabulátorom (.tsv)\",\n        \"Manage users\": \"Spravovať Používateľov\",\n        \"Replace {{termToUse}}...\": \"Nahradiť {{termToUse}}…\",\n        \"Original\": \"Originál\",\n        \"Return to {{termToUse}}\": \"Späť na {{termToUse}}\",\n        \"Save copy\": \"Uložiť kópiu\",\n        \"Save Document\": \"Uložiť Dokument\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Export je dostupný iba zo stránok dokumentov. Vyberte stránku dokumentu a skúste to znova.\",\n        \"Download attachments...\": \"Stiahnite si prílohy...\",\n        \"Download document...\": \"Stiahnuť dokument...\",\n        \"Suggest changes\": \"Navrhnúť Zmeny\",\n        \"current version\": \"aktuálna verzia\",\n        \"original\": \"originál\"\n    },\n    \"Tools\": {\n        \"Delete document tour?\": \"Chcete odstrániť Prehliadku?\",\n        \"How-to Tutorial\": \"Návod Ako na to\",\n        \"Code view\": \"Zobrazenie Kódu\",\n        \"Delete\": \"Odstrániť\",\n        \"Document history\": \"História dokumentu\",\n        \"Settings\": \"Nastavenie\",\n        \"API console\": \"API Konzola\",\n        \"Tour of this Document\": \"Prehliadka tohto Dokumentu\",\n        \"Validate Data\": \"Overiť Údaje\",\n        \"Access Rules\": \"Pravidlá prístupu\",\n        \"Raw data\": \"Surové údaje\",\n        \"Return to viewing as yourself\": \"Vráťiť sa ku svojmu zobrazeniu\",\n        \"TOOLS\": \"NÁSTROJE\",\n        \"context menu - Access Rules\": \"kontextové menu - Pravidlá prístupu\",\n        \"Delete document tour\": \"Vymazať prehliadku dokumentov\",\n        \"Preview the tutorial\": \"Náhľad tutoriálu\",\n        \"Proposed changes\": \"Navrhované Zmeny\",\n        \"Suggest changes\": \"Navrhnúť Zmeny\",\n        \"Suggestions\": \"Návrhy\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Riadiť tím\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"Akékoľvek pole\",\n        \"Apply to new records\": \"Použiť na nové záznamy\",\n        \"Apply on record changes\": \"Použiť na zmeny záznamu\",\n        \"OK\": \"OK\",\n        \"Current field \": \"Aktuálne pole \",\n        \"Apply on changes to:\": \"Použiť pri zmenách na:\",\n        \"Cancel\": \"Zrušiť\",\n        \"Close\": \"Zavrieť\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"Použiť\",\n        \"Cancel\": \"Zrušiť\",\n        \"Preview\": \"Náhľad\",\n        \"Revise\": \"Revidovať\",\n        \"Update formula (Shift+Enter)\": \"Aktualizovať vzorec (Shift+Enter)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Redaktor\",\n        \"In full\": \"Plne\",\n        \"No Default Access\": \"Žiadny Predvolený Prístup\",\n        \"None\": \"Žiadne\",\n        \"Owner\": \"Vlastník\",\n        \"View & edit\": \"Zobraziť a Upraviť\",\n        \"View only\": \"Iba Zobraziť\",\n        \"Viewer\": \"Čitateľ\"\n    },\n    \"ViewConfigTab\": {\n        \"Plugin: \": \"Plugin: \",\n        \"Make On-Demand\": \"Urobiť Na Požiadanie (On-Demand)\",\n        \"Advanced settings\": \"Pokročilé nastavenia\",\n        \"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.\",\n        \"Blocks\": \"Bloky\",\n        \"Compact\": \"Kompaktný\",\n        \"Edit card layout\": \"Upraviť Rozloženie Karty\",\n        \"Form\": \"Formulár\",\n        \"Section: \": \"Sekcia: \",\n        \"Unmark On-Demand\": \"Odobrať označenie On-Demand\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"Tabuľky na požiadanie boli zastarané z dôvodu nedostatku funkčnosti a problémov s použiteľnosťou.\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Zastaraná funkcia\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Widget options\": \"Voľby widgetu\",\n        \"Advanced sort & filter\": \"Pokročilé Triedenie a Filtrovanie\",\n        \"Copy anchor link\": \"Kopírovať odkaz na kotvu\",\n        \"Data selection\": \"Výber údajov\",\n        \"Delete record\": \"Odstrániť záznam\",\n        \"Delete widget\": \"Odstrániť widget\",\n        \"Download as CSV\": \"Stiahnuť ako CSV\",\n        \"Download as XLSX\": \"Stiahnuť ako XLSX\",\n        \"Edit card layout\": \"Upraviť Rozloženie Karty\",\n        \"Open configuration\": \"Otvorte konfiguráciu\",\n        \"Print widget\": \"Tlačiť widget\",\n        \"Show raw data\": \"Zobraziť surové údaje\",\n        \"Collapse widget\": \"Zbaliť widget\",\n        \"Create a form\": \"Vytvoriť formulár\",\n        \"Add to page\": \"Pridať na stránku\",\n        \"Duplicate widget\": \"Duplikovať widget\"\n    },\n    \"ViewSectionMenu\": {\n        \"SORT\": \"TRIEDIŤ\",\n        \"(customized)\": \"(prispôsobené)\",\n        \"(empty)\": \"(prázdne)\",\n        \"(modified)\": \"(zmenené)\",\n        \"Custom options\": \"Vlastné možnosti\",\n        \"FILTER\": \"FILTER\",\n        \"Revert\": \"Návrat\",\n        \"Save\": \"Uložiť\",\n        \"Update Sort&Filter settings\": \"Aktualizovať nastavenie triedenia a filtrovania\",\n        \"Sort and filter\": \"Triedenie a filter\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Vzdelávanie\",\n        \"Finance & Accounting\": \"Financie a Účtovníctvo\",\n        \"HR & Management\": \"HR a Manažment\",\n        \"Marketing\": \"Marketing\",\n        \"Media Production\": \"Mediálna Produkcia\",\n        \"Other\": \"Ostatné\",\n        \"Product Development\": \"Vývoj Produktov\",\n        \"Research\": \"Výskum\",\n        \"Sales\": \"Predaj\",\n        \"Type here\": \"Písať sem\",\n        \"Welcome to Grist!\": \"Vitajte v Grist!\",\n        \"IT & Technology\": \"IT a Technológie\",\n        \"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.\"\n    },\n    \"WidgetTitle\": {\n        \"Save\": \"Uložiť\",\n        \"Cancel\": \"Zrušiť\",\n        \"DATA TABLE NAME\": \"NÁZOV TABUĽKY ÚDAJOV\",\n        \"Override widget title\": \"Prepísať názov widgetu\",\n        \"Provide a table name\": \"Zadajte názov tabuľky\",\n        \"WIDGET TITLE\": \"NÁZOV WIDGETU\",\n        \"WIDGET DESCRIPTION\": \"POPIS WIDGETU\"\n    },\n    \"duplicatePage\": {\n        \"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.\",\n        \"Duplicate page {{pageName}}\": \"Duplikovať stránku {{pageName}}\"\n    },\n    \"errorPages\": {\n        \"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.\",\n        \"Sign up\": \"Prihlásiť sa\",\n        \"Powered by\": \"Poháňaný\",\n        \"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.\",\n        \"Form not found\": \"Formulár sa nenašiel\",\n        \"Access denied{{suffix}}\": \"Prístup odmietnutý {{suffix}}\",\n        \"Add account\": \"Pridať účet\",\n        \"Contact support\": \"Kontaktovať podporu\",\n        \"Error{{suffix}}\": \"Chyba {{suffix}}\",\n        \"Go to main page\": \"Prejdite na hlavnú stránku\",\n        \"Page not found{{suffix}}\": \"Stránka sa nenašla {{suffix}}\",\n        \"Sign in\": \"Prihlásiť sa\",\n        \"Sign in again\": \"Znovu sa prihlásiť\",\n        \"Sign in to access this organization's documents.\": \"Prihláste sa, ak chcete získať prístup k dokumentom tejto organizácie.\",\n        \"Signed out{{suffix}}\": \"Odhlásený {{suffix}}\",\n        \"Something went wrong\": \"Niečo sa pokazilo\",\n        \"There was an error: {{message}}\": \"Vyskytla sa chyba: {{message}}\",\n        \"There was an unknown error.\": \"Vyskytla sa neznáma chyba.\",\n        \"You are now signed out.\": \"Teraz ste odhlásení.\",\n        \"You do not have access to this organization's documents.\": \"Nemáte prístup k dokumentom tejto organizácie.\",\n        \"Account deleted{{suffix}}\": \"Účet odstránený {{suffix}}\",\n        \"Your account has been deleted.\": \"Váš účet bol odstránený.\",\n        \"An unknown error occurred.\": \"Vyskytla sa neznáma chyba.\",\n        \"Build your own form\": \"Vytvorte si vlastný formulár\",\n        \"Sign-in failed{{suffix}}\": \"Prihlásenie zlyhalo{{suffix}}\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Nepodarilo sa prihlásiť.{{separator}}Prosím skúste to znova alebo kontaktujte podporu.\",\n        \"Manage settings\": \"Spravovať nastavenia\",\n        \"Need Help?\": \"Potreba Pomoci?\",\n        \"There was an error\": \"Vyskytla sa chyba\",\n        \"Unsubscribed{{suffix}}\": \"Odhlásenie {{suffix}}\",\n        \"We could not unsubscribe you\": \"Nemohli sme vás odhlásiť\",\n        \"You are unsubscribed\": \"Ste odhlásený\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Stále sa môžete odhlásiť z tohto dokumentu aktualizáciou svojich preferencií v nastaveniach dokumentu\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Už nebudete dostávať e-mailové upozornenia o {{changes}} v {{docName}} na {{email}}.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Už nebudete dostávať e-mailové upozornenia o {{comments}} v {{docName}} na {{email}}.\",\n        \"changes\": \"zmeny\",\n        \"comments\": \"komentáre\",\n        \"this document\": \"tento dokument\",\n        \"your email\": \"tvoj email\"\n    },\n    \"menus\": {\n        \"Select fields\": \"Vybrať polia\",\n        \"Any\": \"Akýkoľvek\",\n        \"Numeric\": \"Desatinné číslo\",\n        \"* Workspaces are available on team plans. \": \"* Pracovné priestory sú k dispozícii v tímových plánoch. \",\n        \"Upgrade now\": \"Vylepšiť teraz\",\n        \"Text\": \"Text\",\n        \"Integer\": \"Celé číslo\",\n        \"Toggle\": \"Prepínač\",\n        \"Date\": \"Dátum\",\n        \"DateTime\": \"Dátum a Čas\",\n        \"Choice\": \"Voľba\",\n        \"Choice List\": \"Zoznam Volieb\",\n        \"Reference\": \"Referencia\",\n        \"Attachment\": \"Príloha\",\n        \"Reference List\": \"Zoznam Referencií\",\n        \"Search columns\": \"Hľadať stĺpce\",\n        \"By Name\": \"Podľa Názvu\",\n        \"By Date Modified\": \"Podľa dátumu úpravy\",\n        \"Light\": \"Svetlé\",\n        \"Custom\": \"Vlastné\"\n    },\n    \"modals\": {\n        \"Save\": \"Uložiť\",\n        \"Delete\": \"Odstrániť\",\n        \"Dismiss\": \"Odmietnuť\",\n        \"Undo to restore\": \"Späť na obnovenie\",\n        \"Cancel\": \"Zrušiť\",\n        \"Got it\": \"Mám to\",\n        \"Ok\": \"OK\",\n        \"Don't show again\": \"Už nezobrazovať\",\n        \"TIP\": \"TIP\",\n        \"Are you sure you want to delete these records?\": \"Naozaj chcete odstrániť tieto záznamy?\",\n        \"Are you sure you want to delete this record?\": \"Naozaj chcete odstrániť tento záznam?\",\n        \"Don't ask again.\": \"Už sa nepýtať.\",\n        \"Don't show again.\": \"Už nezobrazovať.\",\n        \"Don't show tips\": \"Nezobrazovať tipy\",\n        \"Confirm\": \"Potvrdiť\"\n    },\n    \"NTextBox\": {\n        \"Lines\": \"Linie\",\n        \"false\": \"nepravda\",\n        \"true\": \"pravda\",\n        \"Field Format\": \"Formát Poľa\",\n        \"Multi line\": \"Viac riadok\",\n        \"Single line\": \"Jeden riadok\"\n    },\n    \"TypeTransform\": {\n        \"Preview\": \"Náhľad\",\n        \"Revise\": \"Revidovať\",\n        \"Update formula (Shift+Enter)\": \"Aktualizovať vzorec (Shift+Enter)\",\n        \"Apply\": \"Použiť\",\n        \"Cancel\": \"Zrušiť\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN ID: \": \"ID STĹPCA: \",\n        \"Cancel\": \"Zrušiť\",\n        \"Save\": \"Uložiť\",\n        \"COLUMN DESCRIPTION\": \"POPIS STĹPCA\",\n        \"COLUMN LABEL\": \"OZNAČENIE STĹPCA\"\n    },\n    \"ConditionalStyle\": {\n        \"Row style\": \"Štýl riadku\",\n        \"Rule must return True or False\": \"Pravidlo musí vrátiť hodnotu Pravda alebo Nepravda\",\n        \"Conditional Style\": \"Podmienený Štýl\",\n        \"Add another rule\": \"Pridať ďalšie pravidlo\",\n        \"Add conditional style\": \"Pridať podmienený štýl\",\n        \"Error in style rule\": \"Chyba v pravidle štýlu\",\n        \"IF...\": \"AK...\",\n        \"Row Style\": \"Štýl Riadku\"\n    },\n    \"DiscussionEditor\": {\n        \"Resolve\": \"Vyriešiť\",\n        \"Cancel\": \"Zrušiť\",\n        \"Comment\": \"Komentovať\",\n        \"Edit\": \"Upraviť\",\n        \"Marked as resolved\": \"Označené ako vyriešené\",\n        \"Only current page\": \"Iba aktuálna stránka\",\n        \"Only my threads\": \"Iba moje vlákna\",\n        \"Open\": \"Otvoriť\",\n        \"Remove\": \"Odobrať\",\n        \"Reply\": \"Odpovedať\",\n        \"Reply to a comment\": \"Odpovedať na komentár\",\n        \"Save\": \"Uložiť\",\n        \"Show resolved comments\": \"Zobraziť vyriešené komentáre\",\n        \"Showing last {{nb}} comments\": \"Zobrazujú sa posledné {{nb}} komentáre\",\n        \"Started discussion\": \"Začal diskusiu\",\n        \"Write a comment\": \"Napísať komentár\",\n        \"Remove thread\": \"Odstrániť vlákno\",\n        \"updated\": \"aktualizované\",\n        \"{{count}} comments_one\": \"{{count}} komentár\",\n        \"{{count}} comments_other\": \"{{count}} komentárov\",\n        \"Copy link\": \"Kopírovať odkaz\"\n    },\n    \"FieldBuilder\": {\n        \"Changing column type\": \"Zmena typu stĺpca\",\n        \"DATA FROM TABLE\": \"ÚDAJE Z TABUĽKY\",\n        \"Mixed format\": \"Zmiešaný formát\",\n        \"Mixed types\": \"Zmiešané typy\",\n        \"Revert field settings for {{colId}} to common\": \"Vráti nastavenie poľa pre {{colId}} na spoločné\",\n        \"Save field settings for {{colId}} as common\": \"Uložiť nastavenie poľa pre {{colId}} ako spoločné\",\n        \"Use separate field settings for {{colId}}\": \"Použiť samostatné nastavenie poľa pre {{colId}}\",\n        \"Apply formula to data\": \"Použiť Vzorec na Údaje\",\n        \"CELL FORMAT\": \"FORMÁT BUNIEK\",\n        \"Changing multiple column types\": \"Zmena viacerých typov stĺpcov\",\n        \"Common\": \"Bežný\",\n        \"Separate\": \"Oddelené\",\n        \"Field in {{count}} views_one\": \"Pole v jednom zobrazení\",\n        \"Field in {{count}} views_other\": \"Pole v {{count}} zobrazeniach\"\n    },\n    \"FieldEditor\": {\n        \"Unable to finish saving edited cell\": \"Ukladanie upravenej bunky nie je možné dokončiť\",\n        \"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\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"Mena\",\n        \"Decimals\": \"Desatinné čísla\",\n        \"Number Format\": \"Formát Čísla\",\n        \"Default currency ({{defaultCurrency}})\": \"Predvolená mena ({{defaultCurrency}})\",\n        \"Field Format\": \"Formát Poľa\",\n        \"Spinner\": \"\",\n        \"Text\": \"Text\",\n        \"max\": \"max\",\n        \"min\": \"min\"\n    },\n    \"WelcomeTour\": {\n        \"Add new\": \"Pridať nové\",\n        \"Configuring your document\": \"Konfigurácia dokumentu\",\n        \"Editing Data\": \"Úprava údajov\",\n        \"Reference\": \"Referencia\",\n        \"Share\": \"Zdieľať\",\n        \"Start with {{equal}} to enter a formula.\": \"Ak chcete zadať vzorec, začnite s {{equal}}.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Prepnite {{creatorPanel}} na formátovanie stĺpcov, \",\n        \"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ť.\",\n        \"Enter\": \"Enter\",\n        \"Flying higher\": \"Letieť vyššie\",\n        \"Help Center\": \"Centrum pomoci\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Urobte z toho vzťah! Na prepojenie tabuliek použite typ {{ref}}. \",\n        \"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. \",\n        \"Sharing\": \"Zdieľanie\",\n        \"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}}).\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Na pridanie widgeto, stránky alebo importovať ďalšie údaje, použite {{addNew}}. \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"V prípade otázok, alebo potreby dokumentácie použite {{helpCenter}}.\",\n        \"Welcome to Grist!\": \"Vitajte v Grist!\",\n        \"convert to card view, select data, and more.\": \"previesť na zobrazenie kariet, výber údajov a ďalšie.\",\n        \"creator panel\": \"panel tvorcu\",\n        \"template library\": \"knižnica šablón\",\n        \"Building up\": \"Vybudovanie\",\n        \"Customizing columns\": \"Prispôsobenie stĺpcov\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Ak chcete bunku upraviť, dvakrát kliknite na bunku, alebo stlačte {{enter}}. \",\n        \"AI Assistant\": \"UI Asistent\"\n    },\n    \"Reference\": {\n        \"SHOW COLUMN\": \"ZOBRAZIŤ STĹPEC\",\n        \"CELL FORMAT\": \"FORMÁT BUNKY\",\n        \"Row ID\": \"ID Riadku\"\n    },\n    \"GristTooltips\": {\n        \"Learn more.\": \"Naučiť sa viac.\",\n        \"Editing Card Layout\": \"Úprava Rozloženia Karty\",\n        \"Nested Filtering\": \"Vnorené Filtrovanie\",\n        \"Updates every 5 minutes.\": \"Aktualizácie každých 5 minút.\",\n        \"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.\",\n        \"Custom Widgets\": \"Vlastné Widgety\",\n        \"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.\",\n        \"Anchor Links\": \"Odkaz na Kotvu\",\n        \"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ť Widget“ a vyberte tabuľku s údajmi udalostí.\",\n        \"Example: {{example}}\": \"Príklad: {{example}}\",\n        \"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 vášho nového widgetu. {{learnMoreButton}}\",\n        \"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.\",\n        \"Link your new widget to an existing widget on this page.\": \"Prepojí váš nový widget s existujúcim widgetom na tejto stránke.\",\n        \"Select the table containing the data to show.\": \"Vyberte tabuľku obsahujúcu údaje, ktoré chcete zobraziť.\",\n        \"Selecting Data\": \"Výber Údajov\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Na stránke surové ú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.\",\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}}.\",\n        \"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 widgetov dostupných v ponuke, alebo môžete vložiť svoj vlastný zadaním jeho celej URL.\",\n        \"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.\",\n        \"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.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Použite podmienené formátovanie na riadky založené na vzorcoch.\",\n        \"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ť.\",\n        \"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.\",\n        \"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.\",\n        \"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.\",\n        \"Only those rows will appear which match all of the filters.\": \"Zobrazia sa len tie riadky, ktoré zodpovedajú všetkým filtrom.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Pripnuté filtre sa zobrazujú ako tlačidlá nad widgetom.\",\n        \"Pinning Filters\": \"Pripnúť Filtre\",\n        \"Raw Data page\": \"Strana surových údajov\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Usporiadajte polia na karte presunutím buniek a zmenou ich veľkosti.\",\n        \"Reference Columns\": \"Referenčné Stĺpce\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Referenčné stĺpce sú kľúčom k údajom {{relational}} v Grist.\",\n        \"Select the table to link to.\": \"Vyberte tabuľku, na ktorú chcete odkazovať.\",\n        \"They allow for one record to point (or refer) to another.\": \"Umožňujú, aby jeden záznam ukazoval (alebo odkazoval) na iný.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"Toto je tajomstvo dynamických a produktívnych rozložení Grist.\",\n        \"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.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Odopnutím skryjete tlačidlo pri zachovaní filtra.\",\n        \"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.\",\n        \"You can filter by more than one column.\": \"Môžete filtrovať podľa viac ako jedného stĺpca.\",\n        \"entire\": \"celý\",\n        \"relational\": \"vo vzťahu\",\n        \"Access Rules\": \"Pravidlá prístupu\",\n        \"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ť detailné pravidlá určujúce, kto môže vidieť alebo upravovať časti vášho dokumentu.\",\n        \"Add new\": \"Pridať nové\",\n        \"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 predpripravených widgetov alebo vložiť svoj vlastný poskytnutím jeho celej URL.\",\n        \"Calendar\": \"Kalendár\",\n        \"To configure your calendar, select columns for start\": {\n            \"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.\"\n        },\n        \"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.\",\n        \"Lookups return data from related tables.\": \"Vyhľadávania vracajú údaje zo súvisiacich tabuliek.\",\n        \"Use reference columns to relate data in different tables.\": \"Použite referenčné stĺpce na prepojenie údajov v rôznych tabuľkách.\",\n        \"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é.\",\n        \"Filter displayed dropdown values with a condition.\": \"Filtrujte zobrazené rozbaľovacie hodnoty s podmienkou.\",\n        \"Forms are here!\": \"Formuláre sú tu!\",\n        \"Learn more\": \"Naučiť sa viac\",\n        \"Linking Widgets\": \"Prepojenie widgetov\",\n        \"The total size of all data in this document, excluding attachments.\": \"Celková veľkosť všetkých údajov v tomto dokumente okrem príloh.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Komunitné widgety vytvárajú a spravujú členovia komunity Grist.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Ak chcete povoliť viacero priradení, zmeňte typ stĺpca Referencie na Zoznam referencií.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Obojsmerné referencie nie sú v súčasnosti podporované pre stĺpce Formula alebo Trigger Formula\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Vytvorí náprotivný stĺpec v cieľovej tabuľke, ktorý možno upravovať z oboch strán.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Toto obmedzenie nastáva, keď je jedna strana obojsmernej referencie nakonfigurovaná ako jedna referencia.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Toto obmedzenie nastáva, keď jeden stĺpec v obojsmernej referencii má typ referencie.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Ak chcete povoliť viacero priradení, zmeňte typ odkazovaného stĺpca na Referenčný zoznam.\",\n        \"The preview below this header shows how the selected user will see this document\": \"Náhľad pod touto hlavičkou ukazuje, ako bude vybraný používateľ vidieť tento dokument\",\n        \"Manage users and resources in a Grist installation.\": \"Spravujte používateľov a zdroje v inštalácii Gristu.\",\n        \"[Learn more.]({{link}})\": \"[Zistiť viac.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Súhrnné tabuľky môžu obsahovať len stĺpce vzorcov.\",\n        \"The new Grist Assistant is here!\": \"Nový Grist Asistent je tu!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Vzorce podporujú mnoho funkcií Excel-u a úplnú syntax jazyka Python.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Vytvorí nový stĺpec Referenčný zoznam v cieľovej tabuľke, pričom tento stĺpec aj cieľové stĺpce sú upraviteľné a synchronizované.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Interné úložisko znamená, že všetky prílohy sú uložené v súbore SQLite dokumentu, zatiaľ čo externé úložisko znamená, že všetky prílohy sú uložené v rovnakom externom úložisku.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Toto umožňuje pridávať prílohy, ktoré chýbajú v externom úložisku, napríklad v importovanom dokumente. Sem je možné nahrať len .tar archívy príloh stiahnuté z Grist.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Porozumejte, upravujte a pracujte so svojimi údajmi a vzorcami s pomocou nového Grist AI asistenta!\",\n        \"Set the maximum number of lines for multi-line text.\": \"Nastaví maximálny počet riadkov pre viacriadkový text.\",\n        \"Comments are here!\": \"Komentáre sú tu!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Môžete komentovať svoje bunky, odpovedať vo vláknach, a @spomínať spolupracovníkov.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"Keď je zaškrtnuté, predvolená hodnota tohoto poľa sa môže predvyplniť z URL použitím query parametrov.\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Tento formulár vytvoril používateľ Gristu, a nebol schvaľovaný Grist Labs, Inc. alebo žiadnou stranou poskytujúcou túto službu. Pre vašu bezpečnosť neodosielajte svoje heslá cez tento formulár, a buďte opatrní pri klikaní na odkazy. Nahláste škodlivé formuláre na [{{mail}}](mailto:{{mail}}).\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"S návrhmi, používatelia robia zmeny do svojej osobnej kópie bez modifikácie pôvodného dokumentu, a potom sú tieto návrhy odoslané na kontrolu vlastníkom dokumentu pred ich integrovaním.\"\n    },\n    \"ColumnTitle\": {\n        \"Add description\": \"Pridať popis\",\n        \"COLUMN ID: \": \"ID STĹPCA: \",\n        \"Column label\": \"Označenie stĺpca\",\n        \"Provide a column label\": \"Poskytnúť označenie stĺpca\",\n        \"Cancel\": \"Zrušiť\",\n        \"Column ID copied to clipboard\": \"ID stĺpca bolo skopírované do schránky\",\n        \"Column description\": \"Popis stĺpca\",\n        \"Save\": \"Uložiť\",\n        \"Close\": \"Zavrieť\"\n    },\n    \"PagePanels\": {\n        \"Open creator panel\": \"Otvoriť Panel Tvorcu\",\n        \"Close Creator Panel\": \"Zatvoriť Panel Tvorcu\",\n        \"Creator panel (right panel)\": \"Panel tvorcu (pravý panel)\",\n        \"Document header\": \"Hlavička dokumentu\",\n        \"Main content\": \"Hlavný obsah\",\n        \"Main navigation and document settings (left panel)\": \"Hlavná navigácia a nastavenia dokumentu (ľavý panel)\",\n        \"Close navigation panel (left panel)\": \"Zavrie navigačný panel (ľavý panel)\",\n        \"Open navigation panel (left panel)\": \"Otvorí navigačný panel (ľavý panel)\"\n    },\n    \"FieldContextMenu\": {\n        \"Cut\": \"Vystrihnúť\",\n        \"Hide field\": \"Skryť pole\",\n        \"Paste\": \"Prilepiť\",\n        \"Clear field\": \"Vyčistiť pole\",\n        \"Copy\": \"Kopírovať\",\n        \"Copy anchor link\": \"Kopírovať odkaz na kotvu\",\n        \"Comment\": \"Komentár\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Vymazať Front\",\n        \"Webhook settings\": \"Nastavenie Webhook\",\n        \"Removed webhook.\": \"Webhook bol odstránený.\",\n        \"Sorry, not all fields can be edited.\": \"Ľutujeme, nie všetky polia je možné upraviť.\",\n        \"Status\": \"Postavenie\",\n        \"Columns to check when update (separated by ;)\": \"Stĺpce na kontrolu pri aktualizácii (oddelené ;)\",\n        \"Cleared webhook queue.\": \"Vymazaný front webhook.\",\n        \"Event Types\": \"Typy Udalostí\",\n        \"Memo\": \"Poznámka\",\n        \"Name\": \"Meno\",\n        \"Enabled\": \"Povolené\",\n        \"Ready Column\": \"Pripravený Stĺpec\",\n        \"URL\": \"URL\",\n        \"Webhook Id\": \"Webhook Id\",\n        \"Table\": \"Tabuľka\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Filtrovať zmeny v týchto stĺpcoch (identifikátory oddelené bodkočiarkou)\",\n        \"Header Authorization\": \"Autorizácia Hlavičky\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooky sú nedostupné v Neuložených Kópiách Dokumentov\"\n    },\n    \"FormulaAssistant\": {\n        \"Capabilities\": \"Schopnosti\",\n        \"Community\": \"Spoločenstvo\",\n        \"Preview\": \"Náhľad\",\n        \"Need help? Our AI assistant can help.\": \"Potrebujete pomoc? Náš asistent UI vám môže pomôcť.\",\n        \"New Chat\": \"Nový Rozhovor\",\n        \"Tips\": \"Tipy\",\n        \"AI Assistant\": \"UI Asistent\",\n        \"Apply\": \"Použiť\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Pre vyššie limity, {{upgradeNudge}}.\",\n        \"You have used all available credits.\": \"Využili ste všetky dostupné kredity.\",\n        \"You have {{numCredits}} remaining credits.\": \"Zostávajúce kredity máte na {{numCredits}}.\",\n        \"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.\",\n        \"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.\",\n        \"Sign Up for Free\": \"Prihláste sa Zdarma\",\n        \"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.\",\n        \"Ask the bot.\": \"Opýtajte sa robota.\",\n        \"Data\": \"Údaje\",\n        \"Formula Help. \": \"Pomocník Vzorca. \",\n        \"Function List\": \"Zoznam Funkcií\",\n        \"Grist's AI Assistance\": \"Grist's UI Asistent\",\n        \"Grist's AI Formula Assistance. \": \"Grist UI Asistent Vzorcov \",\n        \"Formula Cheat Sheet\": \"Ťahák Vzorca\",\n        \"Regenerate\": \"Regenerovať\",\n        \"Save\": \"Uložiť\",\n        \"Cancel\": \"Zrušiť\",\n        \"Clear conversation\": \"Vymazať Konverzáciu\",\n        \"Code view\": \"Zobrazenie Kódu\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Ahoj, som Grist UI asistent Vzorcov.\",\n        \"Learn more\": \"Naučiť sa viac\",\n        \"Press Enter to apply suggested formula.\": \"Stlačením klávesy Enter použijete navrhovaný vzorec.\",\n        \"There are some things you should know when working with me:\": \"Pri spolupráci so mnou by ste mali vedieť niekoľko vecí:\",\n        \"What do you need help with?\": \"S čím potrebujete pomôcť?\",\n        \"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.\",\n        \"For higher limits, contact the site owner.\": \"Pre vyššie limity kontaktujte vlastníka stránky.\",\n        \"upgrade to the Pro Team plan\": \"povýsiť na plán Pro Team\",\n        \"upgrade your plan\": \"povýšiť svoj plán\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Pomoc so vzorcami nájdete na {{functionList}} a {{formulaCheatSheet}}, alebo navštívte našu {{community}} kde nájdete viac pomoci.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Pri komunikácii so mnou sa vaše otázky, a štruktúra dokumentu (viditeľná v {{codeView}}) odošle do OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Píš so mnou ako s osobou. Nemusíš opisovať tabuľky a stĺpce. Napríklad môžeš napísať \\\"Prosím vypočítaj celkovú sumu faktúr.\\\"\"\n    },\n    \"WelcomeSitePicker\": {\n        \"You have access to the following Grist sites.\": \"Máte prístup k nasledovným stránkam Grist.\",\n        \"Welcome back\": \"Vitajte späť\",\n        \"You can always switch sites using the account menu.\": \"Stránky môžete vždy prepínať pomocou ponuky konta.\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"POPIS\"\n    },\n    \"UserManager\": {\n        \"Guest\": \"Hosť\",\n        \"Grist support\": \"Podpora Grist\",\n        \"On\": \"Zapnuté\",\n        \"Open Access Rules\": \"Otvoriť Pravidlá prístupu\",\n        \"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}}.\",\n        \"Allow anyone with the link to open.\": \"Povoliť otvorenie komukoľvek s odkazom.\",\n        \"Add {{member}} to your team\": \"Pridať {{member}} do svojho tímu\",\n        \"Anyone with link \": \"Ktokoľvek s odkazom \",\n        \"Cancel\": \"Zrušiť\",\n        \"Close\": \"Zavrieť\",\n        \"Collaborator\": \"Spolupracovník\",\n        \"Confirm\": \"Potvrdiť\",\n        \"Create a team to share with more people\": \"Vytvorte tím na zdieľanie s viacerými ľuďmi\",\n        \"Manage members of team site\": \"Spravovať členov tímovej lokality\",\n        \"Copy link\": \"Kopírovať Link\",\n        \"Invite multiple\": \"Pozvať viacerých\",\n        \"Invite people to {{resourceType}}\": \"Pozvite ľudí do {{resourceType}}\",\n        \"Link copied to clipboard\": \"Odkaz bol skopírovaný do schránky\",\n        \"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.\",\n        \"Off\": \"Vypnuté\",\n        \"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}}.\",\n        \"Outside collaborator\": \"Externý spolupracovník\",\n        \"Your role for this team site\": \"Vaša rola pre túto tímovú lokalitu\",\n        \"Your role for this {{resourceType}}\": \"Vaša rola pre tento {{resourceType}}\",\n        \"free collaborator\": \"slobodný spolupracovník\",\n        \"guest\": \"hosť\",\n        \"member\": \"člen\",\n        \"team site\": \"tímová stránka\",\n        \"{{collaborator}} limit exceeded\": \"{{collaborator}} prekročený limit\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} z {{limitTop}} {{collaborator}}\",\n        \"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.\",\n        \"User may not modify their own access.\": \"Používateľ nesmie meniť svoj vlastný prístup.\",\n        \"Public access\": \"Verejný prístup\",\n        \"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“.\",\n        \"Public access: \": \"Verejný prístup: \",\n        \"Remove my access\": \"Odstrániť môj prístup\",\n        \"Save & \": \"Uložiť & \",\n        \"Team member\": \"Člen tímu\",\n        \"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“.\",\n        \"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.\",\n        \"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“.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Chystáte sa odstrániť svoj vlastný prístup k {{resourceType}}\",\n        \"Inherit access: \": \"Zdedený prístup \",\n        \"Access overview\": \"Prehľad prístupov\"\n    },\n    \"SupportGristNudge\": {\n        \"Admin Panel\": \"Panel Správcu\",\n        \"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.\",\n        \"Opt in to Telemetry\": \"Prihláste sa do Telemetrie\",\n        \"Opted In\": \"Prihlásené\",\n        \"Support Grist\": \"Podpora Grist\",\n        \"Support Grist page\": \"Stránka Podpory Grist\",\n        \"Help Center\": \"Centrum Pomoci\",\n        \"Close\": \"Zavrieť\",\n        \"Contribute\": \"Prispieť\"\n    },\n    \"SupportGristPage\": {\n        \"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.\",\n        \"Sponsor\": \"Sponzor\",\n        \"Help Center\": \"Centrum Pomoci\",\n        \"Manage Sponsorship\": \"Spravovať Sponzorovanie\",\n        \"Telemetry\": \"Telemetria\",\n        \"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.\",\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"GitHub Stránka sponzorov\",\n        \"Home\": \"Domov\",\n        \"Opt in to Telemetry\": \"Prihláste sa do Telemetrie\",\n        \"Opt out of Telemetry\": \"Odhlásiť sa z Telemetrie\",\n        \"Sponsor Grist Labs on GitHub\": \"Sponzorujte Grist Labs na GitHub\",\n        \"Support Grist\": \"Podpora Grist\",\n        \"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.\",\n        \"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.\",\n        \"You have opted in to telemetry. Thank you!\": \"Prihlásili ste sa do telemetrie. Ďakujeme!\",\n        \"You have opted out of telemetry.\": \"Deaktivovali ste telemetriu.\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Grist softvér vývíja Grist labs, ktorá poskytuje zadarmo aj platené hostingové ponuky. Taktiež zverejňujeme Grist kód pod štandardnou slobodnou a otvorenou licenciou OSS (Apache 2.0) na {{link}}.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Sme malý a zanietený tím. Vaša podpora pre nás veľa znamená. Tiež to ukazuje ostatným, že za týmto produkto stojí zanietená komunita.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Vývoj Grist open-source môžete podporiť spozorstvom na {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Podporte Grist zapnutím telemetrie, ktorá nám pomáha porozumieť ako sa reálne produkt používa, aby sme vedeli lepšie prioritizovať budúci vývoj.\"\n    },\n    \"FormView\": {\n        \"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ď.\",\n        \"Are you sure you want to reset your form?\": \"Naozaj chcete resetovať svoj formulár?\",\n        \"View\": \"Zobrazenie\",\n        \"Copy code\": \"Kopírovať kód\",\n        \"Embed this form\": \"Vložiť tento formulár\",\n        \"Reset form\": \"Resetovať formulár\",\n        \"Preview\": \"Náhľad\",\n        \"Reset\": \"Resetovať\",\n        \"Save your document to publish this form.\": \"Ak chcete zverejniť tento formulár, uložte dokument.\",\n        \"Unpublish your form?\": \"Chcete zrušiť zverejnenie formuláru?\",\n        \"Share this form\": \"Zdieľať tento formulár\",\n        \"Publish\": \"Publikovať\",\n        \"Publish your form?\": \"Zverejniť svoj formulár?\",\n        \"Unpublish\": \"Nepublikovať\",\n        \"Code copied to clipboard\": \"Kód bol skopírovaný do schránky\",\n        \"Copy link\": \"Skopírovať odkaz\",\n        \"Link copied to clipboard\": \"Odkaz bol skopírovaný do schránky\",\n        \"Share\": \"Zdieľať\",\n        \"# **Form Title**\": \"# **Názov Formulára**\",\n        \"Your form description goes here.\": \"Tu zadať popis vášho formulára.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"Zverejnení formuláru sa vygeneruje odkaz na zdieľanie. Ktokoľvek s týmto odkazom si môže zobraziť prázdny formulár a vyplniť ho.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Zrušenie zverejnenia formuláru deaktivuje odkaz na zdieľanie, takže používatelia, ktorí kliknú na odkaz uvidia chybu.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Používatelia sú obmedzení na odosielanie záznamov (položiek vo vašej tabuľke) a čítanie prednastavených hodnôt v poliach, ako napríklad referencie a stĺpec s výberom.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Váš formulár je zverejnený. Každá zmena je živí a viditeľná používateľom bez prístupu k formuláru. Ak chcete robiť zmeny v koncepte, zrušte zverejnenie formuláru.\"\n    },\n    \"AdminPanel\": {\n        \"Current version of Grist\": \"Aktuálna verzia Grist\",\n        \"Help us make Grist better\": \"Pomôžte nám zlepšiť Grist\",\n        \"Home\": \"Domov\",\n        \"Error checking for updates\": \"Kontrola chýb pri aktualizáciách\",\n        \"Grist is up to date\": \"Grist je aktuálny\",\n        \"Current authentication method\": \"Aktuálna metóda overovania\",\n        \"Details\": \"Podrobnosti\",\n        \"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.\",\n        \"No fault detected.\": \"Nebola zistená žiadna porucha.\",\n        \"Authentication\": \"Overenie\",\n        \"Check failed.\": \"Kontrola zlyhala.\",\n        \"Check succeeded.\": \"Kontrola sa podarila.\",\n        \"Notes\": \"Poznámky\",\n        \"Self Checks\": \"Sebakontroly\",\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 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é.\",\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}}\",\n        \"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.\",\n        \"Admin Panel\": \"Panel Správcu\",\n        \"Current\": \"Aktuálne\",\n        \"Sponsor\": \"Sponzor\",\n        \"Support Grist\": \"Podpora Grist\",\n        \"Support Grist Labs on GitHub\": \"Podpora Grist Labs na GitHub\",\n        \"Telemetry\": \"Telemetria\",\n        \"Version\": \"Verzia\",\n        \"Auto-check when this page loads\": \"Automatická kontrola pri načítaní tejto stránky\",\n        \"Check now\": \"Skontrolovať teraz\",\n        \"Checking for updates...\": \"Kontrola aktualizácií...\",\n        \"Error\": \"Chyba\",\n        \"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.\",\n        \"Grist releases are at \": \"Vydania Grist sú na adrese \",\n        \"Last checked {{time}}\": \"Posledná kontrola {{time}}\",\n        \"Learn more.\": \"Naučiť sa viac.\",\n        \"No information available\": \"Nie sú dostupné žiadne informácie\",\n        \"Newer version available\": \"K dispozícii je nová verzia\",\n        \"OK\": \"OK\",\n        \"Administrator Panel Unavailable\": \"Panel Správcu je Nedostupný\",\n        \"Results\": \"Výsledky\",\n        \"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.\",\n        \"Sandboxing\": \"Pieskovisko\",\n        \"Sandbox settings for data engine\": \"Nastavenia sandboxu pre dátový stroj\",\n        \"Security Settings\": \"Bezpečnostné nastavenia\",\n        \"Updates\": \"Aktualizácie\",\n        \"unconfigured\": \"nekonfigurované\",\n        \"unknown\": \"neznáme\",\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 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é.\",\n        \"Key to sign sessions with\": \"Kľúč na podpisovanie relácií\",\n        \"Session Secret\": \"Tajomstvo Relácie\",\n        \"Enable Grist Enterprise\": \"Povoliť Grist Enterprise\",\n        \"Enterprise\": \"Enterprise\",\n        \"checking\": \"kontrola\",\n        \"Contact us\": \"Kontaktujte nás\",\n        \"Log Streaming\": \"Streamovanie denníka\",\n        \"Audit Logs\": \"Denníky auditu\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} viac\",\n        \"New, Enterprise\": \"Nové, Enterprise\",\n        \"Off\": \"Vypnúť\",\n        \"On\": \"Zapnúť\",\n        \"Grist Instance\": \"Inštancia Grist\",\n        \"{{count}} admin accounts_one\": \"{{count}} admin účet\",\n        \"{{count}} admin accounts_other\": \"{{count}} admin účtov\",\n        \"Auto-check weekly\": \"Kontrolovať týždenné\",\n        \"No record of last version check\": \"Žiadny záznam o poslednej kontrole verzie\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Automatické kontroly sú vypnuté. Zapnete ich nastavením environment premennej GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING=\\\"true\\\".\",\n        \"auth error\": \"auth chyba\",\n        \"configured\": \"nastavené\",\n        \"default\": \"predvolené\",\n        \"more...\": \"viac...\",\n        \"no authentication\": \"bez prihlásenia\",\n        \"unavailable\": \"nedostupné\",\n        \"Admin account not found\": \"Nenašiel sa admin účet\",\n        \"Administrative accounts\": \"Administrátorské účty\",\n        \"The users with administrative accounts\": \"Používatelia s administrátorskými účtami\",\n        \"Version {{versionNumber}}\": \"Verzia {{versionNumber}}\",\n        \"no admin accounts\": \"žiadny admin účet\",\n        \"Are you sure you want to restart Grist?\": \"Naozaj chcete reštartovať Grist?\",\n        \"Grist is running in an environment that doesn't support restarting from the admin panel.\": \"Grist beží v prostredí ktoré neumožňuje reštart z admin panelu.\",\n        \"Restart\": \"Reštart\",\n        \"Restart Grist\": \"Reštart Grist\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"Reštartuje Grist a aplikuje čakajúce zmeny alebo vyrieši problémy.\",\n        \"Restart Grist?\": \"Reštartovať Grist?\",\n        \"Restarting Grist...\": \"Grist sa reštartuje...\",\n        \"This will apply any pending changes and briefly interrupt access for all users.\": \"Krátkodobo preruší prístup pre všetkých používateľov a aplikuje všetky čakajúce zmeny.\",\n        \"You can still restart Grist manually.\": \"Stále môžete reštartovať Grist ručne.\",\n        \"error in {{provider}}: {{verdict}}\": \"chyba v {{provider}}: {{verdict}}\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Môžete nastaviť posielanie audit udalostí z Gristu do externého systému (SIEM) ak zapnete Grist Enterprise. Viac sa dozviete na{{contactUsLink}}.\"\n    },\n    \"TimingPage\": {\n        \"Table ID\": \"ID Tabuľky\",\n        \"Total Time (s)\": \"Celkový Čas (s)\",\n        \"Average Time (s)\": \"Priemerný Čas (s)\",\n        \"Column ID\": \"ID Stĺpca\",\n        \"Formula timer\": \"Časovač Vzorca\",\n        \"Number of Calls\": \"Počet Hovorov\",\n        \"Max Time (s)\": \"Maximálny Čas (s)\",\n        \"Loading timing data. Don't close this tab.\": \"Načítanie časových údajov. Túto kartu nezatvárajte.\"\n    },\n    \"ChoiceListEditor\": {\n        \"No choices to select\": \"Žiadne možnosti na výber\",\n        \"Error in dropdown condition\": \"Chyba v rozbaľovacej podmienke\",\n        \"No choices matching condition\": \"Žiadne možnosti nezodpovedajú podmienke\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"Rozbaľovacia Podmienka\",\n        \"Set dropdown condition\": \"Nastaviť rozbaľovaciu podmienku\",\n        \"Invalid columns: {{colIds}}\": \"Neplatné stĺpce: {{colIds}}\"\n    },\n    \"FormRenderer\": {\n        \"Select...\": \"Vybrať...\",\n        \"Submit\": \"Odoslať\",\n        \"Reset\": \"Resetovať\",\n        \"Search\": \"Hľadať\",\n        \"Submitting…\": \"Odosielam…\",\n        \"Clear selection for: {{-inputLabel}}\": \"Vyčistí výber pre: {{-inputLabel}}\"\n    },\n    \"widgetTypesMap\": {\n        \"Calendar\": \"Kalendár\",\n        \"Card\": \"Karta\",\n        \"Card List\": \"Zoznam Kariet\",\n        \"Chart\": \"Graf\",\n        \"Custom\": \"Vlastné\",\n        \"Form\": \"Formulár\",\n        \"Table\": \"Tabuľka\"\n    },\n    \"ReferenceUtils\": {\n        \"No choices to select\": \"Žiadne možnosti na výber\",\n        \"Error in dropdown condition\": \"Chyba v rozbaľovacej podmienke\",\n        \"No choices matching condition\": \"Žiadne možnosti zodpovedajúce podmienke\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Kliknutím vložiť\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"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.\",\n        \"free coaching call\": \"bezplatný hovor koučovi\",\n        \"Maybe later\": \"Možno Neskôr\",\n        \"Schedule call\": \"Naplánovať Hovor\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Naplánujte si {{freeCoachingCall}} s členom nášho tímu.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Ak sa chcete dozvedieť viac o Griste, môžete skúsiť náš {{ourWeeklyWebinars}}.\",\n        \"our weekly webinars\": \"naše týždenné webináre\",\n        \"Free coaching call\": \"Koučovací telefonát zadarmo\",\n        \"Grist 101\": \"Základy Gristu\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"Tiež môžete pozrieť náš úvodný webinár, {{ourWeeklyWebinars}}, vytvorený aby pomohol novým používateľom                   s navigáciou základmi Gristu.\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"Tiež môžete pozrieť náš úvodný webinár, {{ourWeeklyWebinars}}, vytvorený aby pomohol novým používateľom so základmi Gristu.\"\n    },\n    \"FormConfig\": {\n        \"Horizontal\": \"Horizontálne\",\n        \"Options Alignment\": \"Možnosti Zarovnania\",\n        \"Options Sort Order\": \"Možnosti Zoradiť Poradie\",\n        \"Field rules\": \"Pravidlá poľa\",\n        \"Required field\": \"Vyžadované pole\",\n        \"Ascending\": \"Vzostupne\",\n        \"Default\": \"Predvolené\",\n        \"Field Rules\": \"Pravidlá Poľa\",\n        \"Radio\": \"Rádio\",\n        \"Select\": \"Výber\",\n        \"Vertical\": \"Vertikálne\",\n        \"Field Format\": \"Formát Poľa\",\n        \"Descending\": \"Zostupne\",\n        \"Accept value from URL\": \"Prijať hodnotu z URL\",\n        \"Hidden field\": \"Skryté pole\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"URL parameter:\\n{{colId}}=VALUE\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Odstrániť\"\n    },\n    \"Menu\": {\n        \"Paragraph\": \"Odsek\",\n        \"Building blocks\": \"Stavebné bloky\",\n        \"Columns\": \"Stĺpce\",\n        \"Copy\": \"Kopírovať\",\n        \"Cut\": \"Vystrihnúť\",\n        \"Insert question above\": \"Vložte otázku vyššie\",\n        \"Insert question below\": \"Vložiť otázku nižšie\",\n        \"Paste\": \"Prilepiť\",\n        \"Separator\": \"Oddeľovač\",\n        \"Unmapped fields\": \"Nemapované polia\",\n        \"Header\": \"Hlavička\",\n        \"More\": \"Viac\",\n        \"New question\": \"Nová otázka\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Unmap fields\": \"Nemapovať polia\",\n        \"Map fields\": \"Mapovať polia\",\n        \"Mapped\": \"Zmapované\",\n        \"Select all\": \"Vybrať Všetko\",\n        \"Unmapped\": \"Nemapované\",\n        \"Clear\": \"Vyčistiť\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Niektoré povinné stĺpce nie sú namapované\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Ak chcete použiť tento widget, namapujte všetky nepovinné stĺpce z panela tvorcov vpravo.\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Ak chcete použiť tento widget, všetky mapované stĺpce musia byť viditeľné. Kontaktujte vlastníka dokumentu alebo upravte pravidlá prístupu.\",\n        \"Some required columns are hidden by access rules\": \"Niektoré požadované stĺpce sú skryté pravidlami prístupu\"\n    },\n    \"Field\": {\n        \"No values in show column of referenced table\": \"V zobrazenom stĺpci referenčnej tabuľky nie sú žiadne hodnoty\",\n        \"No choices configured\": \"Nie sú nakonfigurované žiadne voľby\",\n        \"Hide\": \"Skryť\"\n    },\n    \"Toggle\": {\n        \"Field Format\": \"Formát Poľa\",\n        \"Checkbox\": \"Začiarkavacie políčko\",\n        \"Switch\": \"Prepínač\"\n    },\n    \"ChoiceEditor\": {\n        \"No choices to select\": \"Žiadne možnosti na výber\",\n        \"Error in dropdown condition\": \"Chyba v rozbaľovacej podmienke\",\n        \"No choices matching condition\": \"Žiadne možnosti nezodpovedajúce podmienke\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Pri odosielaní formulára sa vyskytla chyba. Prosím skúste znova.\"\n    },\n    \"FormModel\": {\n        \"You don't have access to this form.\": \"K tomuto formuláru nemáte prístup.\",\n        \"Oops! The form you're looking for doesn't exist.\": \"Ojoj! Formulár, ktorý hľadáte, neexistuje.\",\n        \"Oops! This form is no longer published.\": \"Ojoj! Tento formulár už nie je zverejnený.\",\n        \"There was a problem loading the form.\": \"Pri načítavaní formulára sa vyskytol problém.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Formulár Odoslaný\",\n        \"Thank you! Your response has been recorded.\": \"Ďakujem! Vaša odpoveď bola zaznamenaná.\",\n        \"Submit new response\": \"Odoslať novú odpoveď\"\n    },\n    \"DateRangeOptions\": {\n        \"This week\": \"Tento týždeň\",\n        \"Last 30 days\": \"Posledných 30 dní\",\n        \"Last week\": \"Minulý Týždeň\",\n        \"Next 7 days\": \"Ďalších 7 dní\",\n        \"This month\": \"Tento mesiac\",\n        \"Last 7 days\": \"Posledných 7 dní\",\n        \"This year\": \"Tento rok\",\n        \"Today\": \"Dnes\"\n    },\n    \"Section\": {\n        \"Insert section below\": \"Vložiť časť nižšie\",\n        \"Insert section above\": \"Vložiť časť vyššie\",\n        \"## **Header**\": \"## **Hlavička**\",\n        \"Description\": \"Popis\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Zadajte podmienku.\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Dokončiť\",\n        \"Previous\": \"Predošlé\",\n        \"Next\": \"Ďalej\"\n    },\n    \"breadcrumbs\": {\n        \"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.\",\n        \"unsaved\": \"neuložené\",\n        \"fiddle\": \"pokus\",\n        \"override\": \"prepísať\",\n        \"recovery mode\": \"režim obnovenia\",\n        \"snapshot\": \"snímka\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Môžete robiť úpravy,\\nale neovplyvnia pôvodný dokument.\\nMôžete ich navrhnúť ako zmenu.\",\n        \"editing\": \"editácia\",\n        \"suggesting\": \"návrhnúť\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Duplikovať stránku\",\n        \"Remove\": \"Odobrať\",\n        \"Rename\": \"Premenovať\",\n        \"You do not have edit access to this document\": \"Nemáte prístup k úpravám tohto dokumentu\",\n        \"(default)\": \"(predvolené)\",\n        \"Collapse {{maybeDefault}}\": \"Zložiť {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Rozbaliť {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Predvolené: Zložiť\",\n        \"Set default: Expand\": \"Predvolené: Rozbaliť\",\n        \"context menu - {{- pageName }}\": \"kontextové menu - {{- pageName }}\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Skopírované do schránky\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Vytvoriť novú tímovú lokalitu\",\n        \"Switch Sites\": \"Prepnúť lokality\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Pridať Stĺpec\",\n        \"Empty values last\": \"Prázdne hodnoty zostávajú\",\n        \"Natural sort\": \"Prirodzené triedenie\",\n        \"Update data\": \"Aktualizovať Údaje\",\n        \"Search Columns\": \"Hľadať stĺpce\",\n        \"Use choice position\": \"Použiť výber pozície\",\n        \"Remove sort setting - {{- columnName }} column\": \"Odstrániť nastavenie triedenia - {{- columnName }} stĺpec\",\n        \"Sort in ascending order (current: descending)\": \"Zoradiť vo vzostupnom poradí (aktuálne: zostupne)\",\n        \"Sort in descending order (current: ascending)\": \"Zoradiť v zostupnom poradí (aktuálne: vzostupne)\",\n        \"Sort options - {{- columnName }} column\": \"Možnosti zoradenia - {{- columnName }} stĺpec\",\n        \"{{- columnName }} column\": \"{{- columnName }} stĺpec\"\n    },\n    \"SortFilterConfig\": {\n        \"Filter\": \"FILTER\",\n        \"Revert\": \"Návrat\",\n        \"Save\": \"Uložiť\",\n        \"Sort\": \"TRIEDIŤ\",\n        \"Update Sort & Filter settings\": \"Aktualizovať nastavenie triedenia a filtrovania\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Vzhľad \",\n        \"Switch appearance automatically to match system\": \"Automaticky prepnúť vzhľad tak, aby zodpovedal systému\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Pravidlo {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Aktualizovať vzorec (Shift+Enter)\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"Položky nie je možné pretiahnuť do skrytých polí\",\n        \"Clear\": \"Vyčistiť\",\n        \"Hidden Fields cannot be reordered\": \"Poradie Skrytých Polí nie je možné zmeniť\",\n        \"Select all\": \"Vybrať Všetko\",\n        \"Visible {{label}}\": \"Viditeľné {{label}}\",\n        \"Hide {{label}}\": \"Skryť {{label}}\",\n        \"Hidden {{label}}\": \"Skryté {{label}}\",\n        \"Show {{label}}\": \"Zobraziť {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Skryť {{label}} (dávkový režim)\",\n        \"Show {{label}} (batch mode)\": \"Zobraziť {{label}} (dávkový režim)\"\n    },\n    \"search\": {\n        \"No results\": \"Žiadne výsledky\",\n        \"Search in document\": \"Hľadať v dokumente\",\n        \"Search\": \"Hľadať\",\n        \"Find Next \": \"Nájsť Ďaľší \",\n        \"Find Previous \": \"Nájsť Predošlý \",\n        \"Close search bar\": \"Zatvoriť vyhľadávací panel\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Odoslať súbor na Disk Google\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"Príklady Používateľov\",\n        \"Users from table\": \"Používatelia z tabuľky\",\n        \"View as\": \"Zobraziť ako\",\n        \"Other users from table\": \"Ostatní používatelia z tabuľky\",\n        \"Shared users\": \"Zdieľaní používatelia\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"ŠTÝL BUNKY\",\n        \"Cell style\": \"Štýl Bunky\",\n        \"Default cell style\": \"Predvolený štýl bunky\",\n        \"Mixed style\": \"Zmiešaný štýl\",\n        \"HEADER STYLE\": \"ŠTÝL HLAVIČKY\",\n        \"Open row styles\": \"Otvoriť štýly riadkov\",\n        \"Default header style\": \"Predvolený štýl hlavičky\",\n        \"Header Style\": \"Štýl Hlavičky\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"VOĽBY\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"POPIS STĹPCA\",\n        \"COLUMN LABEL\": \"OZNAČENIE STĹPCA\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Neplatná mena\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Stĺpec alebo pole je povinné\",\n        \"Error in the cell\": \"Chyba v bunke\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Chyby v {{numErrors}} z {{numCells}} buniek\",\n        \"Enter formula or {{button}}.\": \"Zadajte vzorec alebo {{button}}.\",\n        \"Expand Editor\": \"Rozbaliť Editor\",\n        \"use AI Assistant\": \"použiť AI Asistenta\",\n        \"editingFormula is required\": \"Vyžaduje sa úprava vzorca\",\n        \"Errors in all {{numErrors}} cells\": \"Chyby vo všetkých {{numErrors}} bunkách\",\n        \"Enter formula.\": \"Zadajte vzorec.\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[link label] URL\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"POPIS\",\n        \"Set description\": \"Nastaviť popis\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Mám to\",\n        \"Unavailable Command\": \"Nedostupný Príkaz\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"Menu príkaz {{action}}, nie je dostupná v tomto prehliadači. Avšak stále môžete spustiť {{action}} použitím klávesovej skratky {{shortcut}}.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Žiadne údaje\",\n        \"No row selected in {{title}}\": \"V {{title}} nie je vybratý žiadny riadok\",\n        \"Not all data is shown\": \"Nezobrazujú sa všetky údaje\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Zbaliť Editor\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Maximalizovať\",\n        \"Minimize\": \"Minimalizovať\"\n    },\n    \"CardContextMenu\": {\n        \"Copy anchor link\": \"Kopírovať odkaz na kotvu\",\n        \"Delete card\": \"Odstrániť kartu\",\n        \"Duplicate card\": \"Duplikát karty\",\n        \"Insert card\": \"Vložiť kartu\",\n        \"Insert card above\": \"Vložiť kartu vyššie\",\n        \"Insert card below\": \"Vložiť kartu nižšie\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Skryté polia\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Vytvorte si vlastný formulár\",\n        \"Powered by\": \"Poháňaný\",\n        \"Powered by Grist\": \"Poháňaný Gristom\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Chyba\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Vyčistiť\",\n        \"Map fields\": \"Mapovať polia\",\n        \"Mapped\": \"Zmapované\",\n        \"Select all\": \"Vybrať Všetko\",\n        \"Unmap fields\": \"Nemapovať polia\",\n        \"Unmapped\": \"Nemapované\",\n        \"Hide {{label}}\": \"Skryť {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Skryť {{label}} (hromadný režim)\",\n        \"Unmap {{label}}\": \"Odmapovať {{label}}\",\n        \"Unmap {{label}} (batch mode)\": \"Udmapovať {{label}} (hromadný režim)\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Zrušiť\",\n        \"Domain name is required\": \"Vyžaduje sa názov domény\",\n        \"Go to your site\": \"Prejdite na svoju stránku\",\n        \"Team name\": \"Názov tímu\",\n        \"Team name is required\": \"Vyžaduje sa názov tímu\",\n        \"Team site created\": \"Vytvorená tímová stránka\",\n        \"Team url\": \"Adresa url tímu\",\n        \"Work as a Team\": \"Pracujte ako Tím\",\n        \"Billing is not supported in grist-core\": \"Fakturácia nie je podporovaná v grist-core\",\n        \"Choose a name and url for your team site\": \"Vyberte si názov a adresu url pre svoju tímovú stránku\",\n        \"Create site\": \"Vytvoriť stránku\",\n        \"Domain name is invalid\": \"Názov domény je neplatný\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Neznámy Používateľ\",\n        \"You are viewing this document as\": \"Zobraziť dokument z pohľadu\",\n        \"View as Yourself\": \"Vlastný Pohľad\",\n        \"You're seeing what this user would see if given access\": \"Vidíte, čo by tento používateľ videl, keby mu bol udelený prístup\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Previesť stĺpec na vzorec\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Jazyk\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Prehľadať všetky stránky\",\n        \"Search all tables\": \"Prehľadať všetky tabuľky\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Vyhľadať\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"Zobrazuje sa {{displayedCount}} z {{totalCount}} položiek. Hľadajte viac.\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Odobrať stĺpec\"\n    },\n    \"DocTutorial\": {\n        \"End tutorial\": \"Ukončiť tutorial\",\n        \"Finish\": \"Dokončiť\",\n        \"Next\": \"Ďaľší\",\n        \"Previous\": \"Predošlý\",\n        \"Restart\": \"Reštart\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Chcete reštartovať výukový program? Všetok pokrok sa stratí.\",\n        \"Click to expand\": \"Kliknutím rozbaliť\"\n    },\n    \"OnboardingCards\": {\n        \"Complete our basics tutorial\": \"Náš kompletný základný návod\",\n        \"Complete the tutorial\": \"Dokončiť tutoriál\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Naučte sa základy referenčných stĺpcov, prepojených widgetov, typy stĺpcov a kariet.\",\n        \"3 minute video tour\": \"3 minútová videoprehliadka\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Naučte sa základy referenčných stĺpcov, prepojených miniaplikácií, typov stĺpcov a kariet.\"\n    },\n    \"OnboardingPage\": {\n        \"Back\": \"Späť\",\n        \"Discover Grist in 3 minutes\": \"Preskúmať Grist za 3 minúty\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Praktický tutoriál Základy Gristu\",\n        \"Go to the tutorial!\": \"Prejsť na tutoriál!\",\n        \"Next step\": \"Ďalší krok\",\n        \"Skip step\": \"Prekočiť krok\",\n        \"Skip tutorial\": \"Preskočiť tutoriál\",\n        \"Tell us who you are\": \"Povedz nám, kto si\",\n        \"Welcome\": \"Vitajte\",\n        \"What brings you to Grist (you can select multiple)?\": \"Čo vás privádza do Gristu (môžete vybrať viacero)?\",\n        \"What is your role?\": \"Aká je vaša úloha?\",\n        \"What organization are you with?\": \"Aká je Vaša organizácia?\",\n        \"Type here\": \"Zadať sem\",\n        \"Your organization\": \"Vaša organizácia\",\n        \"Your role\": \"Vaša úloha\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist môže vyzerať ako tabuľka, ale nie vždy sa tak správa. Objavte v čom je Grist iný.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"Disable Grist Enterprise\": \"Zakázať Grist Enterprise\",\n        \"Enable Grist Enterprise\": \"Povoliť Grist Enterprise\",\n        \"Grist Enterprise is **enabled**.\": \"Grist Enterprise je **povolený**.\",\n        \"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}}).\": \"Aktivačný kľúč sa používa na spustenie Grist Enterprise po uplynutí skúšobnej\\ndoby 30 dní. Získajte aktivačný kľúč [registráciou pre Grist\\nPodnik]({{signupLink}}). Na spustenie nepotrebujete aktivačný kľúč\\nGrist Core.\\n\\nZistite viac v našom [Help Center] ({{helpCenter}}).\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Aktivačný klúč slúží na spustenie aplikace Grist Enterprise po skúšobnej dobe.\\npo uplynutí 30dennej zkúšobnej dobe. Aktivačný klúč získate [kontaktujte nás]({{contactLink}}) ešte dnes.\\nAktivačný klúč nepotrebujete na spustenie aplikáce Grist Core.\\n\\nViac informacií sa dozviete v našom [Centre nápovedy]({{helpCenter}}).\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Aktivačný kľúč sa používa na spustenie Grist Enterprise po skúšobnej dobe\\n. . . . . . . . uplynulo 30 dní. Získajte aktivačný kľúč [prihlásenie sa na  Grist\\n. . . . . . . . Enterprise]({{signupLink}}). Na spustenie nepotrebujete aktivačný kľúč\\n. . . . . . . . Grist Core.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Ak chcete pokračovať v používaní Grist Enterprise, je potrebné aktívne predplatné. Môžete\\nsvoje predplatné aktivovať [registráciou do Grist Enterprise] ({{signupLink}}) a vložením\\naktivačného kľúča nižšie.\",\n        \"Expiration date\": \"Dátum konca platnosti\",\n        \"Installation ID copied to clipboard\": \"ID Inštalácie skopírované do schránky\",\n        \"Installation ID:\": \"ID Inštalácie:\",\n        \"Activate\": \"Aktivovať\",\n        \"Activation key\": \"Aktivačný kľúč\",\n        \"Copy to clipboard\": \"Kopírovať do schránky\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Zistite viac v našom [Centre Pomoci] ({{helpCenter}}).\",\n        \"Paste your activation key\": \"Vložiť Váš aktivačný kľúč\",\n        \"Plan name\": \"Názov plánu\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Vaša inštancia bude v **read-only** móde **{{days}}** dní.\",\n        \"You are currently trialing Grist Enterprise.\": \"Momentálne skúšate Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"Nemáte aktívne predplatné.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Platnosť vášho aktivačného kľúča vypršala z dôvodu prekročenia limitov.\",\n        \"Your subscription expired on {{date}}.\": \"Platnosť vášho predplatného vypršala dňa {{date}}.\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Ak chcete pokračovať v používaní Grist Enterprise, musíte\\n                                  [nás kontaktovať]({{signupLink}})  aby ste dostali aktivačný kľúč.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Vaše skúšobné obdobie vypršalo **{{expireAt}}**. Ak chcete pokračovať v používaní Grist Enterprise, musíte\\nsa [prihlásiť do Grist Enterprise]({{signupLink}}) a vložiť svoj aktivačný kľúč nižšie.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Odstrániť\",\n        \"Delete data and this widget.\": \"Odstrániť údaje a tento widget.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Uchovať údaje a odstrániť widget. Tabuľka zostane dostupná v {{rawDataLink}}\",\n        \"Raw Data page\": \"Strana surových údajov\",\n        \"Table {{tableName}} will no longer be visible\": \"Tabuľka {{tableName}} nebude dlho viditeľná\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Admin Panel\"\n    },\n    \"CustomWidgetGallery\": {\n        \"(Missing info)\": \"(Chýbajúce informácie)\",\n        \"Add Your Own Widget\": \"Pridať vlastný widget\",\n        \"Add widget\": \"Pridať widget\",\n        \"Add a widget from outside this gallery.\": \"Pridať widget mimo tejto galérie.\",\n        \"Cancel\": \"Zrušiť\",\n        \"Choose custom widget\": \"Zvoliť vlastný widget\",\n        \"Community Widget\": \"Komunitný widget\",\n        \"Custom URL\": \"Vlastná URL\",\n        \"Developer:\": \"Vývojár:\",\n        \"Grist Widget\": \"Grist Widget\",\n        \"Last updated:\": \"Posledná aktualizácia:\",\n        \"Learn more about custom widgets\": \"Ďalšie informácie o vlastných widgetoch\",\n        \"No matching widgets\": \"Žiadne zodpovedajúce widgety\",\n        \"Search\": \"Vyhľadať\",\n        \"Widget URL\": \"URL widgetu\",\n        \"Change widget\": \"Zmeniť widget\"\n    },\n    \"SupportGristButton\": {\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Ďakujem! Vaša dôvera a podpora je veľmi cenená. Odhlásiť sa kedykoľvek cez {{link}} v užívateľskom menu.\",\n        \"Opt in to Telemetry\": \"Pripojiť sa k Telemetrii\",\n        \"Admin Panel\": \"Administračný Panel\",\n        \"Close\": \"Zavrieť\",\n        \"Help Center\": \"Centrum Pomoci\",\n        \"Opted In\": \"Pripojený\",\n        \"Support Grist\": \"Podporiť Grist\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Zbierame len štatistiky o používaní, ako je to popísané v {{helpCenterLink}}, nikdy nie obsah dokumentov. Kedykoľvek to môžete zrušiť cez {{supportGristLink}} v menu používaľa.\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Môžete si zapnúť telemetriu, ktorá nám pomôže lepšie porozumieť ako sa produkt používa, aby sme vedeli prioritizovať budúci vývoj.\"\n    },\n    \"markdown\": {\n        \"The toggle is **on**\": \"Prepínač je **on**\",\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nová funkcia Markdown\\n *\\n *      Môžete použiť _write_ [the usual Markdown](https://markdownguide.org) *vo vnútri*\\n *      elementu Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"Prepínač je **off**\"\n    },\n    \"HomeIntroCards\": {\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"Nájdite riešenia a preskúmajte viac zdrojov {{helpCenterLink}}\",\n        \"3 minute video tour\": \"3 minútová videoprehliadka\",\n        \"Blank document\": \"Prázdny dokument\",\n        \"Finish our basics tutorial\": \"Dokončiť náš základný tutorial\",\n        \"Help center\": \"Centrum pomoci\",\n        \"Import file\": \"Importovať súbor\",\n        \"Learn more {{webinarsLinks}}\": \"Dozvedieť sa viac {{webinarsLinks}}\",\n        \"Start a new document\": \"Spustiť nový dokument\",\n        \"Templates\": \"Šablóny\",\n        \"Tutorial\": \"Tutorial\",\n        \"Webinars\": \"Webináre\",\n        \"Find solutions and explore more resources\": \"Nájdite riešenie a objavte viac\",\n        \"Learn more\": \"Zistiť viac\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nová funkcia Markdown\\n *\\n *      Môžete použiť _write_ [the usual Markdown](https://markdownguide.org) *vo vnútri*\\n *      elementu Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"Prepínač je **off**\",\n        \"The toggle is **on**\": \"Prepínač je **on**\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Add two-way reference\": \"Pridať obojsmernú referenciu\",\n        \"Column\": \"Stĺpec\",\n        \"Delete\": \"Odstrániť\",\n        \"Delete column {{column}} in table {{table}}?\": \"Odstrániť stĺpec {{column}} v tabuľke {{table}}?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Je to rub referenčného stĺpca {{column}} v tabuľke {{table}}.\",\n        \"Table\": \"Tabuľka\",\n        \"Two-way Reference\": \"Obojsmerná Referencia\",\n        \"Delete two-way reference?\": \"Odstrániť obojsmernú referenciu?\",\n        \"Target table\": \"Cieľová tabuľka\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Toto odstráni referenciu na stĺpec {{refCol}} v tabuľke {{refTable}}. Referenčný stĺpec {{myName}} ostane v aktuálnej tabuľke {{myTable}}.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Zrušiť\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Každý záznam {{targetTable}} môže byť priradený iba jednému záznamu {{sourceTable}}.\",\n        \"Reassign\": \"Preradiť\",\n        \"Reassign to new {{sourceTable}} records.\": \"Preradiť do nových záznamov {{sourceTable}}.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Preradiť na {{sourceTable}} záznam {{sourceName}}.\",\n        \"Record already assigned_one\": \"Záznam je už priradený\",\n        \"Record already assigned_other\": \"Záznamy už priradené\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} záznam {{targetName}} je už priradený k {{sourceTable}} záznam {{oldSourceName}}.\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Add destination\": \"Pridať cieľ\",\n        \"Add streaming destination\": \"Pridať cieľ streamingu\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Naozaj chcete odstrániť tento cieľ streamovania? Túto akciu nie je možné vrátiť späť.\",\n        \"Cancel\": \"Zrušiť\",\n        \"Delete\": \"Zmazať\",\n        \"Delete streaming destination?\": \"Zmazať cieľ streamingu?\",\n        \"Destination\": \"Cieľ\",\n        \"Destinations\": \"Ciele\",\n        \"Edit\": \"Upraviť\",\n        \"Edit streaming destination\": \"Zmeniť cieľ streamingu\",\n        \"Enter URL\": \"Zadať URL\",\n        \"Enter token\": \"Zadať token\",\n        \"Learn more\": \"Ďaľšie informácie\",\n        \"Save\": \"Uložiť\",\n        \"Splunk\": \"Splunk\",\n        \"Start streaming\": \"Spustiť streaming\",\n        \"Token\": \"Token\",\n        \"URL\": \"URL\",\n        \"Other\": \"Ostatné\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Nastaví streamovanie audit udalostí z Gristu do externého informačného systému (SIEM) ako napríklad Splunk. {{learnMoreLink}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Contact us\": \"Kontaktujte nás\",\n        \"Home\": \"Domov\",\n        \"Log streaming\": \"Streaming logov\",\n        \"Only site owners may access audit logs.\": \"Ku denníkom auditu môžu pristupovať iba vlastníci stránok.\",\n        \"upgrade your plan\": \"aktualizovať svoj plán\",\n        \"Audit logs for {{siteName}}\": \"Záznami auditu pre {{siteName}}\",\n        \"Audit Logs\": \"Záznami auditu\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Môžete nastaviť posielanie audit udalostí z Gristu do externého SIEM (Správa bezpečnostných informácií a udalostí) ak zapnete Grist Enterprise. Viac sa dozviete na{{contactUsLink}}.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Môžete nastaviť posielanie audit udalostí z Gristu do externého SIEM (Správa bezpečnostných informácií a udalostí) ak {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"Access details\": \"Podrobnosti prístupu\",\n        \"All\": \"Všetko\",\n        \"Current workspace\": \"Aktuálny pracovný priestor\",\n        \"Delete {{name}}\": \"Odstrániť {{name}}\",\n        \"Last edited\": \"Posledná úprava\",\n        \"Manage users\": \"Spravovať používateľov\",\n        \"Move\": \"Presunúť\",\n        \"Move {{name}} to workspace\": \"Presunúť {{name}} do pracovného priestoru\",\n        \"Name\": \"Meno\",\n        \"No documents to show.\": \"Žiadne dokumenty na zobrazenie.\",\n        \"Pin\": \"Pripnúť\",\n        \"Pinned\": \"Pripnuté\",\n        \"Recent\": \"Nedávne\",\n        \"Rename and set icon\": \"Premenovať a vybrať ikonu\",\n        \"Requires edit permissions\": \"Vyžaduje povolenia na úpravu\",\n        \"Sort by name\": \"Zoradiť podľa názvu\",\n        \"Unpin\": \"Odopnúť\",\n        \"Workspace\": \"Pracovný priestor\",\n        \"Sort by date\": \"Zoradiť podľa dátumu\",\n        \"Delete\": \"Odstrániť\",\n        \"Document will be moved to Trash.\": \"Dokument bude presunutý do Koša.\",\n        \"Edited {{at}}\": \"Upravené {{at}}\",\n        \"context menu - {{- documentName }}\": \"kontextové menu - {{- documentName }}\",\n        \"Documents list\": \"Zoznam dokumentov\",\n        \"Download document...\": \"Stiahnuť dokument...\",\n        \"Deleted {{at}}\": \"Zmazaný {{at}}\"\n    },\n    \"RenameDocModal\": {\n        \"Choose color\": \"Vybrať farbu\",\n        \"Choose icon\": \"Vybrať ikonu\",\n        \"Enter document name\": \"Vložiť názov dokumentu\",\n        \"Icon\": \"Ikona\",\n        \"Name\": \"Názov\",\n        \"Rename and set icon\": \"Premenovať a nastaviť ikonu\",\n        \"Reset icon\": \"Obnoviť ikonu\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Buďte opatrní s neznámymi vlastnými widgetmi\",\n        \"Please review the following before adding a new custom widget.\": \"Pred pridaním vlastného widgetu prosím skontrolujte nasledujúce.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Vlastné widgety sú **výkonné**! Môžu byť schopné čítať a zapisovať údaje vášho dokumentu a posielať ich inde.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Ste si istý, že **dôverujete zdroju** na tejto adrese URL?\",\n        \"Do you **trust the person** who shared this link?\": \"Veríte **osobe**, ktorá zdieľala tento odkaz?\",\n        \"Have you **reviewed the code** at this URL?\": \"**Skontrolovali ste kód** na tejto URL?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Ak si nie ste istý, neinštalujte tento widget, alebo poproste administrátora svojej organizácie, aby ho pre bezpečnosť skontroloval.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Potvrdzujem, že rozumiem týmto upozorneniam a prijímam riziká\"\n    },\n    \"RightPanelUtils\": {\n        \"series_one\": \"Série\",\n        \"series_other\": \"Série\",\n        \"fields_other\": \"Polia\",\n        \"fields_one\": \"Polia\",\n        \"columns_one\": \"stĺpec\",\n        \"columns_other\": \"stĺpce\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin area\": \"Admin oblasť\",\n        \"Docs\": \"Dokumenty\",\n        \"Installation\": \"Inštalácia\",\n        \"Learn more\": \"Zistiť viac\",\n        \"Orgs\": \"Organizácie\",\n        \"Users\": \"Používatelia\",\n        \"Workspaces\": \"Pracovné plochy\",\n        \"Settings\": \"Nastavenia\"\n    },\n    \"Assistant\": {\n        \"AI Assistant is only available for logged in users.\": \"AI Asistent je dostupný len pre prihlásených používateľov.\",\n        \"Apply\": \"Použiť\",\n        \"For higher limits, contact the site owner.\": \"Pre vyššie limity, kontaktuje správcu stránky.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Pre vyššie limity, {{upgradeNudge}}.\",\n        \"Learn more.\": \"Zistiť viac.\",\n        \"Press Enter to apply suggested formula.\": \"Stlačením Entere prijmete navrhnutý vzorec.\",\n        \"Sign Up for Free\": \"Registrujte sa zadarmo\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Zaregistruje si zadarmo Grist účet na využívanie Grist AI Asistenta.\",\n        \"What do you need help with?\": \"S čím potrebujete pomôcť?\",\n        \"You have used all available credits.\": \"Využili ste všetky dostupné kredity.\",\n        \"You have {{numCredits}} remaining credits.\": \"Máte {{numCredits}} zostávajúcich kreditov.\",\n        \"start a new chat\": \"začať nový čet\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Povýšte na Grist Enterprise ak chcete vyskúšať nový Grist Assistent. {{learnMoreLink}}\",\n        \"upgrade to the Pro Team plan\": \"povýšiť na Pro Team Plan\",\n        \"upgrade your plan\": \"povýšte svoj plán\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"Konverzácia je už príliš dlhá a nemôžem ďalej efektívne odpovedať. Prosím {{startANewChatButton}} na pokračovanie s asistentom.\"\n    },\n    \"apiconsole\": {\n        \"Confirm Deletion\": \"Potvrdiť vymazanie\",\n        \"Delete\": \"Vymazať\",\n        \"Deletion was not confirmed, skipping.\": \"Vymazanie nepotvrdené, preskakujem.\",\n        \"Type DELETE here if you wish to proceed.\": \"Napíšte sem DELETE ak chcete pokračovať.\",\n        \"Are you sure you want to delete the following?\": \"Naozaj chcete zmazať nasledujúce?\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Napíšte DELETE ak naozaj chcete vykonvať toto zmazanie.\\nAk si nie ste istý alebo nerozumiete čo táto operácia vykoná,\\nbude rozumné ju zrušiť.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"žiadny prístup\",\n        \"...loading\": \"...načítanie\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Je dostupná dôležitá aktualizácia Gristu.\\nZvážte aktualizáciu verzie {{version}} čo najskôr.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Vaša verzia Gristup je neaktuálna.\\nZvážte aktualizáciu na verziu {{version}} čo najskôr.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Set the document to use external storage.\": \"Nastaviť dokument na využívanie externého úložiska.\",\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Odporúčanie: {{storageRecommendation}}\\nPri ukladaní veľkých príloh alebo veľkého počtu, odporúčame\\nukladať ich na externé úložisko. Tento dokument momentálne\\npoužíva na prílohy interné úložisko, ktoré sú súčasťou databázy,\\nale môžu znižovať výkon.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Prosím počkajte kým sa dokončí predošlá operácia.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Vypršal čas čakania na reštarrt Grist backendu\"\n    },\n    \"Experiments\": {\n        \"Don't worry, you can disable it later if needed.\": \"Nebojte sa, môžete ju vypnúť neskôr ak treba.\",\n        \"Enable feature\": \"Zapnúť vlastnosť\",\n        \"Experimental feature\": \"Experimentálna funkcia\",\n        \"Reload the page\": \"Obnoviť stránku\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Práve idete vypnúť túto experimentálnu funkciu: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Práve idete zapnúť túto experimentálnu funkciu: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} vypnutý.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} zapnutý.\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Kedykoľvek môžete prestať používať túto funkciu navštívením tejto URL: {{url}}\",\n        \"Disable feature\": \"Vypnúť funkciu\",\n        \"New record button\": \"Tlačidlo nový záznam\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Nová karta\",\n        \"New record\": \"Nový záznam\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Snažíte sa otvoriť panel tvorcu? Použite {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Duplikovať widget\",\n        \"Duplicate widgets\": \"Duplikovať widgety\",\n        \"Active\": \"Aktívny\",\n        \"Create new page\": \"Vytvoriť novú stranu\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Nahrávam, prosím čakajte…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Pridať\",\n        \"Delete\": \"Zmazať\",\n        \"Download\": \"Stiahnuť\",\n        \"Drop files here to attach\": \"Súbory priložíte presunutím sem\",\n        \"Drop files here to attach.\": \"Súbory priložíte presunutím sem.\",\n        \"No attachments\": \"Žiadne prílohy\",\n        \"Preview not available.\": \"Náhľad nedostupný.\",\n        \"{{index}} of {{total}}\": \"{{index}} z {{total}}\",\n        \"Uploading…\": \"Nahrávam…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Rozšíriť všetky riadky na túto výšku\",\n        \"Max height\": \"Max výška\",\n        \"Max row height\": \"Max výška riadku\",\n        \"Change\": \"Zmeniť\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Zbaliť\",\n        \"Expand\": \"Rozbaliť\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"aktívny používateľ\",\n        \"active user list\": \"zoznam aktívnych používateľov\",\n        \"open full active user list\": \"otvoriť celý zoznam aktívny používateľov\"\n    },\n    \"AdminChecks\": {\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Grist má malý vstavaný healthcheck často používaný v kontajneroch.\",\n        \"It is good practice not to run Grist as the root user.\": \"Je dobrým zvykom nespúštať Grist pod rootom.\",\n        \"The main page of Grist should be available.\": \"Hlavná strana Gristu by mala byť dostupná.\",\n        \"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 umôžňuje veľmi silné vzorce použítím Pythonu. Odporúčame nastaviť environment premennú GRIST_SANDBOX_FLAVOR na gvisor ak to váš hardvér podporuje (väčšinou áno), a vzorce každého dokumentu budú bežať vo vlastonm pieskovisku izolovanom od ostaných dokumentov a izolované od siete.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"Požiadavky prichádzajúce do Gristu by mli obsahovať presnú hlavičku Host. Je to povinné ak je nastavené GRIST_SERVE_SAME_ORIGIN.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"Táto štartovacia stránka by nemala byť ľahko dostupná. Buď ju vypnite keď je konfigurácia ok (vypnutím GRIST_BOOT_KEY) alebo nastavte GRIST_BOOT_KEY na dlhé kryptograficky bezpečnú hodnotu.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"Websocket spojenia vyžadujú HTTP 1.1 a možnosť preposielať niekoľko ďalších hlavičiek. Niekedy nastavenia reverznej proxy tomu bránia.\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"+{{count}} viac\",\n        \"+{{count}} more_other\": \"+{{count}} viac\",\n        \"Edit\": \"Upraviť\",\n        \"No choices configured\": \"Žiadne nastavené voľby\",\n        \"Reset\": \"Reset\",\n        \"Cancel\": \"Zrušiť\",\n        \"Save\": \"Uložiť\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Použiť\",\n        \"Cancel\": \"Zrušiť\",\n        \"Preview\": \"Náhľad\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Zavrieť\",\n        \"Update preview\": \"Aktualizovať náhľad\",\n        \"Convert quoted fields\": \"Konvertovať polia v úvodzovkách\",\n        \"Escape character\": \"Escape znak\",\n        \"Field separator\": \"Oddeľovač polí\",\n        \"First row contains headers\": \"Prvý riadok obsahuje hlavičky\",\n        \"Line terminator\": \"Znak konca riadku\",\n        \"Number of rows\": \"Počet riadkov\",\n        \"Quote character\": \"Znak úvodzovky\",\n        \"Quotes in fields are doubled\": \"Úvodzovky v poliach sú zdvojené\",\n        \"Skip leading whitespace\": \"Preskočiť medzery na začiatku\",\n        \"Start with row\": \"Začať riadkom\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Kódovanie znakov. Pozrite [podporované kódeky]({{link}})\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" alebo \",\n        \"Accessibility\": \"Prístupnosť\",\n        \"Close\": \"Zavrieť\",\n        \"High contrast theme\": \"Téma s vysokým kontrastom\",\n        \"Keyboard navigation\": \"Ovládanie klávesnicou\",\n        \"Other important keyboard shortcuts\": \"Ďalšie dôležité klávesové skratky\",\n        \"The left panel, home of the main navigation.\": \"Ľavý panel, domov hlavnej navigácie.\",\n        \"The top panel, or the document header.\": \"Vrchný panel, alebo hlavička dokumentu.\",\n        \"To see other available themes, go to your {{profileSettingsLink}}.\": \"Ďalšie témy nájdete vo vašom {{profileSettingsLink}}.\",\n        \"profile settings\": \"nastavenia profilu\",\n        \"\\\"Regions\\\" are what we call the different parts of the user interface:\": \"\\\"Regióny\\\" nazývame rôzne časti používateľského rozhrania:\",\n        \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\": \"A nakoniec, pravý panel - alebo panel tvorcu - je dostupný cez svoju klávesovú skratku a nie je zahrnutý v cykle regiónov pre ďalej a späť.\",\n        \"Focus on other parts of the user interface using the following shortcuts:\": \"Na iné časti používateľského rozhrania sa zameriate nasledujúcimi skratkami:\",\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"Na stránke dokumentu, klávesová navigácia je najprv zamknutá na aktuálny widget.\",\n        \"On document pages, each [widget]({{supportPageUrl}}) is a region that can receive focus.\": \"Na stránke dokumentu, každý [widget]({{supportPageUrl}}) je región ktorý sa môže zamerať focusom.\",\n        \"On non-document pages, the main content area is a region.\": \"Na nedokumentových stránkach, je región jej hlavný obsah.\",\n        \"Use the high contrast theme (light appearance)\": \"Použiť témy s vysokým kontrastom (svetlá)\",\n        \"You are currently **not using** the high contrast theme.\": \"Aktuálne **nepoužívate** témy s vysokým kontrastom.\",\n        \"You are currently using the high contrast theme.\": \"Aktuálne používateľ tému s vysokým kontrasom.\",\n        \"{{accessibilityModal}} Show the accessibility options (this modal)\": \"{{accessibilityModal}} Zobrazí voľby prístupnosti (toto okno)\",\n        \"{{creatorPanelShortcut}} Focus to and from the creator panel\": \"{{creatorPanelShortcut}} Zameria sa na panel tvorcu\",\n        \"{{nextRegionShortcut}} Focus on the next region\": \"{{nextRegionShortcut}} Prejde na ďalší región\",\n        \"{{prevRegionShortcut}} Focus on the previous region\": \"{{prevRegionShortcut}} Prejde na ďalší región\",\n        \"{{shortcutsModal}} Show the complete list of keyboard shortcuts\": \"{{shortcutsModal}} Zobrazí zoznam klávesových skratiek\"\n    },\n    \"ProposedChangesPage\": {\n        \"This is a list of changes relative to the original document.\": \"Toto je zoznam zmien oproti pôvodnému dokumentu.\",\n        \"Proposed changes\": \"Navrhované zmeny\",\n        \"Retract suggestion\": \"Stiahnuť návrh\",\n        \"Suggest change\": \"Navrhnúť zmenu\",\n        \"Suggest changes\": \"Navrhnúť zmeny\",\n        \"Update suggestion\": \"Upraviť návrh\",\n        \"Replace original\": \"Nahradiť pôvodné\",\n        \"Accept\": \"Prijať\",\n        \"Accepted {{at}}.\": \"Prijaté {{at}}.\",\n        \"Dismiss\": \"Zamietnuť\",\n        \"Dismissed {{at}}.\": \"Zamietnuté {{at}}.\",\n        \"Learn more\": \"Zistiť viac\",\n        \"No changes found to suggest. Please make some edits.\": \"Nenašli sa žiadne odporúčané úpravy. Spravte prosím nejaké úpravy.\",\n        \"Retracted {{at}}.\": \"Stiahnuté {{at}}.\",\n        \"Suggestion made {{at}}.\": \"Návrh vytvorený {{at}}.\",\n        \"Suggestions\": \"Návrhy\",\n        \"The original document isn't asking for proposed changes.\": \"Pôvodný dokument nežiada o navrhované zmeny.\",\n        \"There are fresh changes that haven't been added to the suggestion yet.\": \"Existujú čerstvé zmeny, ktoré zatiaľ neboli pridané do návrhov.\",\n        \"This is a list of changes relative to the {{originalDocument}}.\": \"Toto je zoznam zmien oproti {{originalDocument}}.\",\n        \"Work on a copy\": \"Pracovať na kópii\",\n        \"Your suggestions\": \"Vaše návrhy\",\n        \"experiment\": \"experiment\",\n        \"original document\": \"pôvodný dokument\",\n        \"Undo dismissal\": \"Vrátiť zamietnutie\"\n    },\n    \"commandList\": {\n        \"Show accessibility options\": \"Zobraziť voľby prístupnosti\",\n        \"Activate assistant\": \"Aktivovať asistenta\",\n        \"Add a new viewsection to the currently active view\": \"Pridá novú sekciu do aktuálneho pohľadu\",\n        \"Adds all elements above the cursor to the selected range\": \"Pridá všetky prvky na kurzorom do aktuálneho výberu\",\n        \"Adds all elements below the cursor to the selected range\": \"Pridá všetky prvky pod kurzorom do vybratého rozsahu\",\n        \"Adds all elements to the left of the cursor to the selected range\": \"Pridá všetky prvky vľavo od kurzoru do vybratého rozsahu\",\n        \"Adds all elements to the right of the cursor to the selected range\": \"Pridá všetky prvky vpravo od kurzoru do vybratého rozsahu\",\n        \"Adds the element above the cursor to the selected range\": \"Pridá všetky prvky nad kurzorom do vybratého rozsahu\",\n        \"Adds the element below the cursor to the selected range\": \"Pridá prvok pod kurzorom do vybratého rozsahu\",\n        \"Duplicate the currently selected record(s)\": \"Duplikovať aktuálne vybraté záznamy\",\n        \"Edit label of the currently-selected field\": \"Upraví popis aktuálne označeného poľa\",\n        \"Edit record layout\": \"Upraviť rozloženie záznamu\",\n        \"Enter text into currently-selected cell and start editing\": \"Vloží text do aktuálne označenej bunky a začne úpravy\",\n        \"Enters section linking mode in the current view\": \"Vstúpi do režimu prepájania sekcií v aktuálnom pohľade\",\n        \"Exits section linking mode in the current view\": \"Vystúpi z režimu prepájania sekcií v aktuálnom pohľade\",\n        \"Expand collapsed viewsection\": \"Rozloží zloženú sekciu\",\n        \"Fills current selection with the contents of the top row in the selection\": \"Naplní aktuálny výber obsahom z prvé riadku výberu\",\n        \"Find\": \"Nájsť\",\n        \"Find next occurrence\": \"Nájsť ďalší výskyt\",\n        \"Find previous occurrence\": \"Nájsť predošlý výskyt\",\n        \"Finish editing a cell and save without moving to next record\": \"Dokončiť úpravu bunky a uloží bez presunutia na ďalší záznam\",\n        \"Finish editing a cell, saving the value\": \"Dokončí úpravu bunky s uložením hodnoty\",\n        \"Focus next page panel or widget\": \"Prejde na ďalší panel strany alebo widgetu\",\n        \"Focus previous page panel or widget\": \"Prejde na predošlý panel strany alebo widgetu\",\n        \"Freeze or unfreeze selected columns\": \"Zamkne alebo odomkne vybraté stĺpce\",\n        \"Hide the currently selected columns\": \"Skryť vybraté stĺpce\",\n        \"Hide the currently selected fields\": \"Skryť vybraté polia\",\n        \"Insert a new column, after the currently selected one\": \"Vloží nový stĺpec, za aktuálne vybratý\",\n        \"Insert a new column, before the currently selected one\": \"Vloží nový stĺpec pred aktuálne vybratý\",\n        \"Insert a new record, after the currently selected one in an unsorted table\": \"Vloží nový záznam za aktuálne vybratý v nezoradenej tabuľke\",\n        \"Insert a new record, before the currently selected one in an unsorted table\": \"Vloží nový záznam pred aktuálne vybratý v nezoradenej tabuľke\",\n        \"Insert new column in default location\": \"Vloží nový stĺpec na predvolené miesto\",\n        \"Insert the current date\": \"Vložiť aktuálny dátum\",\n        \"Insert the current date and time\": \"Vložiť aktuálny dátum a čas\",\n        \"Maximize the active section\": \"Maximalizovať aktívnu sekciu\",\n        \"Move down one page of records, or to next record in a card list\": \"Posunie sa na ďalšiu stranu záznamov, alebo na ďalší záznam v zozname kariet\",\n        \"Move down to the last record\": \"Presunúť sa na posledný záznam\",\n        \"Move downward five records\": \"Presunúť sa na dole o päť záznamov\",\n        \"Move downward to next record or field\": \"Presunúť sa dole na ďalší záznam alebo pole\",\n        \"Move left to the previous field\": \"Presunúť vľavo na predošlé pole\",\n        \"Move right to the next field\": \"Presunúť vpravo na ďalšie pole\",\n        \"Move to the first field or the beginning of a row\": \"Presunúť na prvé pole alebo na začiatok riadku\",\n        \"Move to the last field or the end of a row\": \"Presunúť na posledné pole alebo na koniec riadku\",\n        \"Move to the next field, saving changes if editing a value\": \"Presunúť na ďalšie pole s uložením aktuálne upravenej hodnoty\",\n        \"Move to the previous field, saving changes if editing a value\": \"Presunúť na predošlé pole s uložením aktuálne upravenej hodnoty\",\n        \"Move up one page of records, or to previous record in a card list\": \"Presunúť hore o stranu záznamov alebo na predošlý záznam v zozname kariet\",\n        \"Move up to the first record\": \"Presunúť hore na prvý záznam\",\n        \"Move upward five records\": \"Presunúť hore o päť záznamov\",\n        \"Move upward to previous record or field\": \"Presunúť hore na predošlý záznam alebo pole\",\n        \"Moves the cursor to the correct location\": \"Presunúť kurzor na správne miesto\",\n        \"Open Custom widget configuration screen\": \"Otvoriť konfiguráciu Vlastného widgetu\",\n        \"Open comment thread\": \"Otvoriť vlákno komentára\",\n        \"Open next page\": \"Otvoriť ďalšiu stranu\",\n        \"Open previous page\": \"Otvoriť predošlú stranu\",\n        \"Opens document list\": \"Otvorí zoznam dokumentov\",\n        \"Paste clipboard contents at cursor\": \"Vložiť obsah schránky na mieste kurzoru\",\n        \"Print currently selected page widget\": \"Vytlačiť aktuálne vybratú stranu widgetu\",\n        \"Push an undo action\": \"Pridať undo akciu\",\n        \"Redo last action\": \"Obnoviť poslednú akciu\",\n        \"Rename the currently selected column\": \"Premenovať aktuálne vybratý stĺpec\",\n        \"Saves the sections links in the current view\": \"Uloží odkazy sekcií v aktuálnom pohľade\",\n        \"Selects all currently displayed cells\": \"Vyberie všetky aktuálne zobrazené bunky\",\n        \"Shortcut to data selection tab\": \"Skratka do záložky výberu údajov\",\n        \"Shortcut to open document tab\": \"Skratka na otvorenie záložky dokumentu\",\n        \"Shortcut to open field tab\": \"Skratka na otvorenie záložky poľa\",\n        \"Shortcut to open sort & filter menu\": \"Skratka na otvorenie menu zoradenie&filter\",\n        \"Shortcut to open the left panel\": \"Skratka na otvorenie ľavého panelu\",\n        \"Shortcut to open the right panel\": \"Skratka na otvorenie pravého panelu\",\n        \"Shortcut to open view tab\": \"Skratka na otvorenie záložky pohľadu\",\n        \"Shortcut to sort & filter tab\": \"Skratka na záložku zoradenie&filter\",\n        \"Show hidden columns\": \"Zobraziť skryté stĺpce\",\n        \"Show raw data widget for table of currently selected page widget\": \"Zobrazí widget surových údajov pre tabuľku v aktuálnom widgete\",\n        \"Show the record card widget of the selected record\": \"Zobrazí widget záznamovej karty pre vybratý záznam\",\n        \"Sort the view data by the currently selected field in ascending order\": \"Zoradí údaje v pohľade podľa aktuálne vybraného poľa vzostupom poradí\",\n        \"Sort the view data by the currently selected field in descending order\": \"Zoradí údaje v pohľade podľa aktuálne vybratého poľa v zostupnom poradí\",\n        \"Start editing the currently-selected cell\": \"Začne úpravu aktuálne vybranej bunky\",\n        \"Toggle the currently selected checkbox or switch cell\": \"Prepne aktuálne vybratý prepínač\",\n        \"Undo last action\": \"Vrátiť poslednú akciu\",\n        \"Use the currently selected row as table headers\": \"Použije aktuálne vybratý riadok ako hlavičku tabuľky\",\n        \"Adds the currently selected column(ascending) to the current view's sort spec\": \"Pridá aktuálne vybratý stĺpec (vzostupne) do zoradenia aktuálneho pohľadu\",\n        \"Adds the currently selected column(descending) to the current view's sort spec\": \"Pridá vybratý stĺpec (zostupne) do zoradenia aktuálneho pohľadu\",\n        \"Adds the element to the left of the cursor to the selected range\": \"Pridá prvok naľavo od kurzoru do vybratého rozsahu\",\n        \"Adds the element to the right of the cursor to the selected range\": \"Pridá prvok napravo od kurzoru do vybratého rozsahu\",\n        \"Clear the selected columns\": \"Vyčistiť vybraté stĺpce\",\n        \"Clears the current copy selection, if any\": \"Vyčistí aktuálny výber kópie, ak existuje\",\n        \"Clears the currently selected cells\": \"Vyčistí aktuálne vybraté bunky\",\n        \"Clears the section links in the current view\": \"Vyčistí odkazy sekcií v aktuálnom pohľade\",\n        \"Collapse the currently active viewsection\": \"Zbalí aktívnu sekciu\",\n        \"Convert the selected columns from formula to data\": \"Konvertuje vybraté stĺpce zo vzorcov na údaje\",\n        \"Copy anchor link\": \"Kopírovať odkaz\",\n        \"Copy current selection to clipboard\": \"Kopírovať aktuálny výber do schránky\",\n        \"Copy current selection to clipboard including headers\": \"Kopírovať aktuálny vyber do schránky vrátane hlavičiek\",\n        \"Creates form for active table\": \"Vytvoriť formulár pre aktívnu tabuľku\",\n        \"Cut current selection to clipboard\": \"Vyrezať aktuálny výber do schránky\",\n        \"Delete collapsed viewsection\": \"Zmazať zbalenú sekciu\",\n        \"Delete the currently active viewsection\": \"Zmazať aktuálne aktívnu sekciu\",\n        \"Delete the currently selected columns\": \"Zmazať aktuálne vybraté stĺpce\",\n        \"Delete the currently selected record(s)\": \"Zmazať aktuálne vybraté záznamy\",\n        \"Detach active editor\": \"Odpojiť aktívny editor\",\n        \"Discard changes to a cell value\": \"Zahodiť zmeny v hodnote bunky\",\n        \"Display Grist documentation\": \"Zobraziť Grist dokumentáciu\",\n        \"Display shortcuts pane\": \"Zobraziť panel skratiek\",\n        \"Duplicate the currently active viewsection\": \"Duplikovať aktuálne aktívnu sekciu\",\n        \"Reverts the sections links to the saved links the current view\": \"Vrátiť odkazy sekcií na uložené odkazy v aktuálnom pohľade\",\n        \"Filter this column by just this cell's value\": \"Filtrovať tento stĺpec hodnotou tejto bunky\",\n        \"Clears the section links in the current viewsection\": \"Vyčistí odkazy sekcií v aktuálnej sekcii\",\n        \"Shortcut to focus view tab if creator panel is open\": \"Skratka na zameranie záložky pohľadu ak je panel tvorcu otvorený\",\n        \"Toggle creator panel keyboard focus\": \"Prepnúť zameranie klávesnice na panel tvorcu\",\n        \"When in the search bar, close it and focus the current match\": \"Keď ste vo vyhľadávacom poli, zavrie sa a zameria na aktuálny výsledok\",\n        \"When typed at the start of a cell, make this a formula column\": \"Napísaním na začiatku bunky, spraví z daného stĺpca vzorec\"\n    },\n    \"AuthenticationSection\": {\n        \"Close\": \"Zavrieť\",\n        \"Configure\": \"Konfigurovať\",\n        \"Configured\": \"Konfigurované\",\n        \"Confirm\": \"Potvrdiť\",\n        \"Disabled on restart\": \"Vypnuté po reštarte\",\n        \"Error\": \"Chyba\",\n        \"Error details\": \"Podrobnosti chyby\",\n        \"Instructions\": \"Inštrukcie\",\n        \"No authentication method is active.\": \"Nie je aktívna žiadna autentifikačná metóda.\",\n        \"Set as active method\": \"Nastaviť ako aktívnu metódu\",\n        \"Set as active method?\": \"Nastaviť ako aktívnu metódu?\",\n        \"The new method will go into effect after you restart Grist.\": \"Nová metóda sa prejaví po reštarte Gristu.\",\n        \"Active\": \"Aktívne\",\n        \"Active method is controlled by an environment variable. Unset variable to change active method.\": \"Aktívna metóda sa ovláda environment premennou. Vypnite premennú ak chcete zmeniť metódu.\",\n        \"Active on restart\": \"Aktívne po reštarte\",\n        \"Are you sure you want to set **{{name}}** as the active authentication method?\": \"Naozaj chcete na staviť **{{name}}** ako aktívnu autentifikačnú metódu?\"\n    },\n    \"DetailView\": {\n        \"This row is unavailable or does not exist\": \"Tento riadok je nedostupný alebo neexistuje\"\n    },\n    \"GetGristComProvider\": {\n        \"**Sign in with getgrist.com** allows users on your Grist server to sign in using their account on getgrist.com, the cloud version of Grist managed by Grist Labs.\": \"**Prihlásiť pomocou gegrist.com** umožňuje používateľom na vašom Grist serveri sa prihlasovať pomocou ich účtu na getgrist.com, cloudovej verzie Grist spravovavej spoločnosťou Grist Labs.\",\n        \"Cancel\": \"Zrušiť\",\n        \"Configure\": \"Konfigurovať\",\n        \"Configure Sign in with getgrist.com\": \"Konfigurovať Prihlásenie pomocou getgrist.com\",\n        \"Home URL is not set; cannot configure Sign in with getgrist.com\": \"Domovská URL nie je nastavená; nedá sa nastaviť Prihlásenie pomocou getgrist.com\",\n        \"Instructions\": \"Inštrukcie\",\n        \"Learn more about Sign in with getgrist.com\": \"Zistite viac o Prihlasovaní pomocou getgrist.com\",\n        \"Paste configuration key here\": \"Vložte sem konfiguračný kľúč\",\n        \"Register your Grist server\": \"Registrujte svoj Grist server\",\n        \"Sign in with getgrist.com\": \"Prihlásiť pomocou getgrist.com\",\n        \"To set up {{provider}}, you need to register your Grist server on getgrist.com and paste the configuration key you receive below.\": \"Pre nastavenie {{provider}}, musíte registrovať svoj Grist server na getgrist.com a nižšie vložiť konfiguračný kľúč ktorý dostanete.\",\n        \"When signing in, users will be redirected to the getgrist.com login page to log in or register. After authenticating on getgrist.com, they'll be redirected back to your Grist server and signed in as the user they authenticated as.\": \"Pri prihlasovaní, používatelia budú presmerovaní na prihlasovaciu stránku getgrist.com, aby sa prihlásili alebo registrovali. Po autentifikácii na getgrist.com, budú presmerovaní späť na váš Grist server a prihlásení ako používateľ pod ktorým sa prihlásili.\"\n    },\n    \"GridViewMenusDateHelpers\": {\n        \"12-hour format\": \"12-hodinový formát\",\n        \"24-hour format\": \"24-hodinový formát\",\n        \"AM\": {\n            \"PM\": \"AM/PM\"\n        },\n        \"Calendar\": \"Kalendár\",\n        \"Date helpers…\": \"Pomôcky dátumu…\",\n        \"Day\": \"Deň\",\n        \"Day of month\": \"Deň mesiaca\",\n        \"Day of week\": \"Deň týždňa\",\n        \"Day of week (abbrev)\": \"Deň týždňa (krátky)\",\n        \"Day of week (full)\": \"Deň týdžňa (celý)\",\n        \"Day of week (numeric)\": \"Deň týždňa (číselný)\",\n        \"Days since\": \"Dní od\",\n        \"Days until\": \"Dni do\",\n        \"Default\": \"Predvolené\",\n        \"End of\": \"Koniec\",\n        \"Full date\": \"Celý dátum\",\n        \"Full name with year\": \"Celý názov s rokom\",\n        \"Hour\": \"Hodina\",\n        \"Intervals\": \"Intervaly\",\n        \"Is weekend?\": \"Je víkend?\",\n        \"Minute\": \"Minúta\",\n        \"Month\": \"Mesiac\",\n        \"Months since\": \"Mesiacov od\",\n        \"Months until\": \"Mesiacov do\",\n        \"Name only\": \"Len meno\",\n        \"Number only\": \"Len číslo\",\n        \"Quarter\": \"Štvrťrok\",\n        \"Quick Picks\": \"Rýchle výbery\",\n        \"Relative\": \"Relatívne\",\n        \"Short with year\": \"Krátky s rokom\",\n        \"Sortable\": \"Zoraditeľné\",\n        \"Start of\": \"Začiatok\",\n        \"Time\": \"Čas\",\n        \"Time bucket\": \"Časové okno\",\n        \"Week\": \"Týždeň\",\n        \"Week of year\": \"Týždeň v roku\",\n        \"Year\": \"Rok\",\n        \"Years since\": \"Rokov od\",\n        \"Years until\": \"Rokov do\"\n    },\n    \"selectBy\": {\n        \"Select widget\": \"Vybrať widget\"\n    },\n    \"CoreNewDocMethods\": {\n        \"Untitled document\": \"Dokument bez názvu\"\n    }\n}\n"
  },
  {
    "path": "static/locales/sk.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Overte svoj e-mail u poskytovateľa identity a prihláste sa znova.\"\n    },\n    \"sendAppPage\": {\n        \"Loading...\": \"Načítavam...\",\n        \"og-description\": \"Moderný, open source tabuľkový procesor, viac ako mriežka\",\n        \"og-title\": \"Grist, evolúcia tabuľkových procesorov\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"Nemáte prístup k tomuto dokumentu.\",\n        \"docDisabled\": \"Tento dokument je deaktivovaný.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"Nenašli sa žiadni vlastníci v organizácii admina definovanej ako `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"orgUser\": \"Požívateľ je vlastníkom organizácie admina definovanej ako `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"accountByEmail\": \"Admin účet definovaný `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\",\n        \"noAdminEmail\": \"Chýba admin účet pretože `GRIST_ADMIN_EMAIL` a `GRIST_DEFAULT_EMAIL` nie sú nastavené\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Dokument bez názvu\"\n    }\n}\n"
  },
  {
    "path": "static/locales/sl.client.json",
    "content": "{\n  \"AccessRules\": {\n    \"Delete table rules\": \"Brisanje pravil tabele\",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Urednikom omogoči urejanje strukture (npr. spreminjanje in brisanje tabel, stolpcev, postavitev) in pisanje formul, ki omogočajo dostop do vseh podatkov ne glede na omejitve branja.\",\n    \"Default rules\": \"Privzeta pravila\",\n    \"Invalid\": \"Neveljavno\",\n    \"Lookup Column\": \"Stolpec za iskanje\",\n    \"Permission to access the document in full when needed\": \"Dovoljenje za dostop do celotnega dokumenta, kadar je to potrebno\",\n    \"Permission to view Access Rules\": \"Dovoljenje za ogled pravil za dostop\",\n    \"Permissions\": \"Dovoljenja\",\n    \"Remove column {{- colId }} from {{- tableId }} rules\": \"Odstranitev stolpca {{- colId }} iz pravil {{- tableId }}\",\n    \"Remove {{- tableId }} rules\": \"Odstranitev pravil {{- tableId }}\",\n    \"Remove {{- name }} user attribute\": \"Odstranitev uporabniškega atributa {{- name }}\",\n    \"Reset\": \"Ponastavitev\",\n    \"Rules for table \": \"Pravila za mizo \",\n    \"Save\": \"Shrani\",\n    \"Saved\": \"Shranjeno\",\n    \"Special rules\": \"Posebna pravila\",\n    \"Type message to display when this rule blocks an action…\": \"Vnesite sporočilo…\",\n    \"User Attributes\": \"Atributi uporabnika\",\n    \"View as\": \"Poglej kot\",\n    \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Pri dodajanju pravil za tabele samodejno dodaj pravilo, ki lastniku omogoča popoln dostop.\",\n    \"Permission to edit document structure\": \"Dovoljenje za urejanje strukture dokumenta\",\n    \"Everyone\": \"Vsi\",\n    \"Everyone Else\": \"Vsi ostali\",\n    \"Checking...\": \"Preverjanje…\",\n    \"Condition\": \"Stanje\",\n    \"Enter Condition\": \"Vnesite pogoj\",\n    \"Add column rule\": \"Dodaj pravila za stolpec\",\n    \"Add Default Rule\": \"Dodaj privzeto pravilo\",\n    \"Add table rules\": \"Dodaj pravila za tabelo\",\n    \"Add user attributes\": \"Dodaj atribute za uporabnika\",\n    \"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.\": \"Vsakomur omogoči kopiranje celotnega dokumenta ali pa ogled v fiddle načinu.\\nUporabno za primere in predloge, ne pa za občutljive podatke.\",\n    \"Allow everyone to view Access Rules.\": \"Omogoči vsakomur ogled pravil za dostop.\",\n    \"Attribute name\": \"Ime atributa\",\n    \"Attribute to Look Up\": \"Atribut za iskanje\",\n    \"Lookup Table\": \"Preglednica za iskanje\",\n    \"This default should be changed if editors' access is to be limited. \": \"To privzeto nastavitev je treba spremeniti, če je treba omejiti dostop urednikov. \",\n    \"Seed rules\": \"Privzete pravice\",\n    \"Add table-wide rule\": \"Dodaj pravilo za celotno tabelo\"\n  },\n  \"ACUserManager\": {\n    \"We'll email an invite to {{email}}\": \"Vabilo bomo poslali po e-pošti {{email}}\",\n    \"Enter email address\": \"Vnesi e-poštni naslov\",\n    \"Invite new member\": \"Povabi novega člana\"\n  },\n  \"AccountPage\": {\n    \"API\": \"API\",\n    \"Account settings\": \"Nastavitve računa\",\n    \"Allow signing in to this account with Google\": \"Omogočanje prijave v ta račun z Googlom\",\n    \"Change password\": \"Sprememba gesla\",\n    \"Email\": \"E-naslov\",\n    \"Name\": \"Ime\",\n    \"Names only allow letters, numbers and certain special characters\": \"Imena dovoljujejo samo črke, številke in nekatere posebne znake\",\n    \"Password & security\": \"Geslo in varnost\",\n    \"Save\": \"Shrani\",\n    \"Theme\": \"Tema\",\n    \"Two-factor authentication\": \"Preverjanje pristnosti z dvema dejavnikoma\",\n    \"Language\": \"Jezik\",\n    \"Edit\": \"Uredi\",\n    \"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.\": \"Dvostopenjsko preverjanje pristnosti je dodatna stopnja varnosti za vaš račun Grist, ki zagotavlja, da ste edina oseba, ki lahko dostopa do vašega računa, tudi če nekdo pozna vaše geslo.\",\n    \"Login method\": \"Metoda prijave\",\n    \"API Key\": \"API ključ\"\n  },\n  \"AccountWidget\": {\n    \"Access Details\": \"Podrobnosti o dostopu\",\n    \"Accounts\": \"Računi\",\n    \"Add account\": \"Dodajanje računa\",\n    \"Document settings\": \"Nastavitve dokumentov\",\n    \"Manage team\": \"Upravljanje ekipe\",\n    \"Pricing\": \"Oblikovanje cen\",\n    \"Profile settings\": \"Nastavitve profila\",\n    \"Sign out\": \"Odjavi se\",\n    \"Sign in\": \"Prijavi se\",\n    \"Switch Accounts\": \"Preklop računov\",\n    \"Toggle Mobile Mode\": \"Preklapljanje mobilnega načina\",\n    \"Activation\": \"Aktivacija\",\n    \"Billing account\": \"Račun za zaračunavanje\",\n    \"Support Grist\": \"Grist podpora\",\n    \"Upgrade Plan\": \"Načrt nadgradnje\",\n    \"Use This Template\": \"Uporabite to predlogo\",\n    \"Sign up\": \"Prijava\"\n  },\n  \"ViewAsDropdown\": {\n    \"View as\": \"Poglej kot\",\n    \"Users from table\": \"Uporabniki iz tabele\",\n    \"Example Users\": \"Primer Uporabniki\"\n  },\n  \"ActionLog\": {\n    \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Stolpec {{colId}} je bil pozneje odstranjen v akciji #{{action.actionNum}}\",\n    \"Action Log failed to load\": \"Dnevnik ukrepov se ni uspel naložiti\",\n    \"This row was subsequently removed in action {{action.actionNum}}\": \"Ta vrstica je bila pozneje odstranjena z akcijo {{action.actionNum}}\",\n    \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Tabela {{tableId}} je bila pozneje odstranjena v akciji #{{actionNum}}\",\n    \"All tables\": \"Vse tabele\"\n  },\n  \"ApiKey\": {\n    \"Remove\": \"Odstrani\",\n    \"Create\": \"Ustvari\",\n    \"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?\": \"Želite izbrisati ključ API. To bo povzročilo zavrnitev vseh prihodnjih zahtevkov, ki bodo uporabljali ta ključ API. Ali še vedno želite izbrisati?\",\n    \"Click to show\": \"Kliknite za prikaz\",\n    \"Remove API Key\": \"Odstranite API ključ\",\n    \"This API key can be used to access this account anonymously via the API.\": \"Ta ključ lahko uporabite za anonimen dostop do tega računa prek vmesnika API.\",\n    \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"API ključ lahko uporabite za dostop do svojega računa prek API vmesnika. Svojega API ključa ne delite z nikomer.\",\n    \"By generating an API key, you will be able to make API calls for your own account.\": \"Z ustvarjanjem API ključa boste lahko uporabljali klice API funkcij za svoj račun.\"\n  },\n  \"App\": {\n    \"Description\": \"Opis\",\n    \"Key\": \"Ključ\",\n    \"Memory Error\": \"Napaka pomnilnika\",\n    \"Translators: please translate this only when your language is ready to be offered to users\": \"Prevajalci: prosimo, prevedite to šele, ko bo vaš jezik pripravljen, da se ponudi uporabnikom\"\n  },\n  \"CellContextMenu\": {\n    \"Delete {{count}} columns_one\": \"Briši stolpec\",\n    \"Delete {{count}} columns_other\": \"Brisanje stolpcev {{count}}\",\n    \"Delete {{count}} rows_one\": \"Brisanje vrstice\",\n    \"Delete {{count}} rows_other\": \"Brisanje vrstic {{count}}\",\n    \"Filter by this value\": \"Filtriranje po tej vrednosti\",\n    \"Copy anchor link\": \"Kopiranje sidrne povezave\",\n    \"Duplicate rows_one\": \"Podvoji vrstico\",\n    \"Duplicate rows_other\": \"Podvoji vrstice\",\n    \"Insert column to the right\": \"Vstavi stolpec na desno stran\",\n    \"Insert column to the left\": \"Vstavi stolpec na levo stran\",\n    \"Insert row\": \"Vstavljanje vrstice\",\n    \"Insert row above\": \"Vstavite vrstico zgoraj\",\n    \"Insert row below\": \"Vstavi vrstico spodaj\",\n    \"Reset {{count}} columns_one\": \"Ponastavi stolpec\",\n    \"Reset {{count}} columns_other\": \"Ponastavit {{count}} stolpcev\",\n    \"Reset {{count}} entire columns_one\": \"Ponastavi celote stolpec\",\n    \"Reset {{count}} entire columns_other\": \"Ponastavi {{count}} celotnih stolpcev\",\n    \"Comment\": \"Komentar\",\n    \"Copy\": \"Kopiraj\",\n    \"Cut\": \"Izreži\",\n    \"Paste\": \"Prilepi\",\n    \"Clear values\": \"Izbriši vrednosti\",\n    \"Clear cell\": \"Čista celica\",\n    \"Copy with headers\": \"Kopiraj z glavami\"\n  },\n  \"DocMenu\": {\n    \"Document will be moved to Trash.\": \"Dokument se bo premaknil v koš.\",\n    \"Trash\": \"Koš\",\n    \"Rename\": \"Preimenuj\",\n    \"Delete\": \"Izbriši\",\n    \"Delete Forever\": \"Izbriši za vedno\",\n    \"Trash is empty.\": \"Koš je prazen.\",\n    \"You may delete a workspace forever once it has no documents in it.\": \"Delovni prostor lahko izbrišete za vedno, ko v njem ni več dokumentov.\",\n    \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Dokumenti ostanejo v košu 30 dni, nato pa se trajno izbrišejo.\",\n    \"Deleted {{at}}\": \"Izbrisano {{at}}\",\n    \"Delete {{name}}\": \"Izbriši {{name}}\",\n    \"Document will be permanently deleted.\": \"Dokument bo trajno izbrisan.\",\n    \"Permanently Delete \\\"{{name}}\\\"?\": \"Trajno izbrišem \\\"{{name}}\\\"?\",\n    \"(The organization needs a paid plan)\": \"(Organizacija potrebuje plačljiv načrt)\",\n    \"Access Details\": \"Podrobnosti o dostopu\",\n    \"All documents\": \"Vsi dokumenti\",\n    \"By Date Modified\": \"Po datumu spremembe\",\n    \"By Name\": \"Po imenu\",\n    \"Current workspace\": \"Trenutni delovni prostor\",\n    \"Discover More Templates\": \"Odkrijte več predlog\",\n    \"Edited {{at}}\": \"Urejeno {{at}}\",\n    \"Examples and Templates\": \"Primeri in predloge\",\n    \"Featured\": \"Priporočeni\",\n    \"Manage users\": \"Upravljanje uporabnikov\",\n    \"More Examples and Templates\": \"Več primerov in predlog\",\n    \"This service is not available right now\": \"Ta storitev trenutno ni na voljo\",\n    \"Workspace not found\": \"Ne najdem delovnega prostora\",\n    \"Pin Document\": \"Pripni dokument\",\n    \"Remove\": \"Odstrani\",\n    \"Move\": \"Premakni\",\n    \"Unpin Document\": \"Odpni dokument\",\n    \"Requires edit permissions\": \"Zahteva dovoljenja za urejanje\",\n    \"Other Sites\": \"Druga spletna mesta\",\n    \"Pinned Documents\": \"Pripeti dokumenti\",\n    \"To restore this document, restore the workspace first.\": \"Če želiš obnoviti ta dokument, najprej obnovi delovni prostor.\",\n    \"You are on your personal site. You also have access to the following sites:\": \"Nahajate se na svojem osebnem spletnem mestu. Prav tako imate dostop do naslednjih spletnih mest:\",\n    \"Restore\": \"Obnovi\",\n    \"Move {{name}} to workspace\": \"Premakni {{name}} v delovni prostor\",\n    \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Nahajate se na spletnem mestu {{siteName}}. Prav tako imate dostop do naslednjih spletnih mest:\",\n    \"Examples & Templates\": \"Primeri & predloge\",\n    \"Any documents created in this site will appear here.\": \"Vsi dokumenti, ustvarjeni na tem mestu, bodo prikazani tukaj.\",\n    \"Create my first document\": \"Ustvari moj prvi dokument\",\n    \"personal site\": \"osebno spletno mesto\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Do tega mesta imate dostop samo za branje. Trenutno ni dokumentov.\"\n  },\n  \"GridViewMenus\": {\n    \"Rename column\": \"Preimenuj stolpec\",\n    \"Delete {{count}} columns_one\": \"Briši stolpec\",\n    \"Delete {{count}} columns_other\": \"Brisanje stolpcev {{count}}\",\n    \"Unfreeze {{count}} columns_one\": \"Odmrzni ta stolpec\",\n    \"Sorted (#{{count}})_one\": \"Razvrščeno (#{{count}})\",\n    \"Unfreeze all columns\": \"Odmrznitev vseh stolpcev\",\n    \"Freeze {{count}} columns_other\": \"Zamrznite {{count}} stolpcev\",\n    \"Show column {{- label}}\": \"Prikaži stolpec {{- label}}\",\n    \"Sort\": \"Razvrsti\",\n    \"Column Options\": \"Možnosti stolpca\",\n    \"Filter Data\": \"Filtriranje podatkov\",\n    \"Hide {{count}} columns_other\": \"Skrij {{count}} stolpcev\",\n    \"Add column\": \"Dodaj stolpec\",\n    \"Reset {{count}} columns_one\": \"Ponastavi stolpec\",\n    \"Freeze {{count}} columns_one\": \"Zamrzni stolpec\",\n    \"More sort options ...\": \"Več možnosti razvrščanja…\",\n    \"Freeze {{count}} more columns_one\": \"Zamrznite še en stolpec\",\n    \"Reset {{count}} columns_other\": \"Ponastavi {{count}} stolpcev\",\n    \"Clear values\": \"Izbriši vrednosti\",\n    \"Add to sort\": \"Dodaj v razvrščanje\",\n    \"Convert formula to data\": \"Pretvarjanje formule v podatke\",\n    \"Freeze {{count}} more columns_other\": \"Zamrznite še {{count}} stolpcev\",\n    \"Hide {{count}} columns_one\": \"Skrij stolpec\",\n    \"Sorted (#{{count}})_other\": \"Razvrščeno (#{{count}})\",\n    \"Insert column to the {{to}}\": \"Vstavi stolpec na {{to}}\",\n    \"Reset {{count}} entire columns_other\": \"Ponastavi {{count}} stolpcev\",\n    \"Unfreeze {{count}} columns_other\": \"Odmrznite {{count}} stolpcev\",\n    \"Insert column to the right\": \"Vstavi stolpec na desno stran\",\n    \"Reset {{count}} entire columns_one\": \"Ponastavi celoten stolpec\",\n    \"Insert column to the left\": \"Vstavi stolpec na levo\",\n    \"Shortcuts\": \"Bližnjice\",\n    \"Show hidden columns\": \"Prikaži skrite stolpce\",\n    \"Created At\": \"Ustvarjeno pri\",\n    \"Authorship\": \"Avtorstvo\",\n    \"Last Updated By\": \"Nazadnje posodobil\",\n    \"Hidden Columns\": \"Skriti stolpci\",\n    \"Lookups\": \"Iskanje\",\n    \"Apply on record changes\": \"Uporabi na spremenjenih zapisih\",\n    \"Created By\": \"Ustvaril\",\n    \"Last Updated At\": \"Nazadnje posodobljeno ob\",\n    \"Apply to new records\": \"Uporabi na novih zapisih\",\n    \"Timestamp\": \"Časovni žig\",\n    \"no reference column\": \"ni referenčnega stolpca\",\n    \"Detect Duplicates in...\": \"Zaznaj dvojnike v ...\",\n    \"UUID\": \"UUID\",\n    \"No reference columns.\": \"Ni referenčnih stolpcev.\",\n    \"Duplicate in {{- label}}\": \"Dvojnik v {{- label}}\",\n    \"Search columns\": \"Preišči stolpce\",\n    \"Adding UUID column\": \"Dodajanje UUID stolpca\",\n    \"Adding duplicates column\": \"Dodajanje podvojenega stolpca\",\n    \"Add formula column\": \"Dodaj stolpec z formulo\",\n    \"Add column with type\": \"Dodaj stolpec z tipom\",\n    \"Created by\": \"Ustvaril\",\n    \"Created at\": \"Ustvarjeno ob\",\n    \"Last updated by\": \"Nazadnje posodobil\",\n    \"Detect duplicates in...\": \"Zaznaj dvojnike v ...\",\n    \"Last updated at\": \"Nazadnje posodobljeno ob\",\n    \"Choice\": \"Izbira\",\n    \"Choice List\": \"Izbirni seznam\",\n    \"Reference\": \"Referenca\",\n    \"Reference List\": \"Referenčni seznam\",\n    \"Attachment\": \"Priponka\",\n    \"Any\": \"Karkoli\",\n    \"Toggle\": \"Preklopi\",\n    \"Date\": \"Datum\",\n    \"Numeric\": \"Numeričen\",\n    \"Text\": \"Tekst\",\n    \"Integer\": \"celo število\",\n    \"DateTime\": \"Datum čas\"\n  },\n  \"HomeLeftPane\": {\n    \"Trash\": \"Koš\",\n    \"Rename\": \"Preimenuj\",\n    \"Delete\": \"Izbriši\",\n    \"Delete {{workspace}} and all included documents?\": \"Izbriši {{workspace}} in vse vključene dokumente?\",\n    \"All documents\": \"Vsi dokumenti\",\n    \"Manage users\": \"Upravljanje uporabnikov\",\n    \"Tutorial\": \"Vadnica\",\n    \"Create empty document\": \"Ustvari prazen dokument\",\n    \"Create workspace\": \"Ustvari delovni prostor\",\n    \"Import document\": \"Uvozi dokument\",\n    \"Access Details\": \"Podrobnosti o dostopu\",\n    \"Workspaces\": \"Delovni prostori\",\n    \"Workspace will be moved to Trash.\": \"Delovni prostor se bo premaknil v koš.\",\n    \"Examples & Templates\": \"Predloge\",\n    \"Terms of service\": \"Pogoji storitve\",\n    \"Grist Resources\": \"Viri Grista\"\n  },\n  \"OnBoardingPopups\": {\n    \"Finish\": \"Zaključek\",\n    \"Next\": \"Naslednji\",\n    \"Previous\": \"Prejšenj\"\n  },\n  \"Pages\": {\n    \"Delete\": \"Izbriši\",\n    \"Delete data and this page.\": \"Izbriši podatke in to stran.\",\n    \"The following tables will no longer be visible_one\": \"Naslednja tabela ne bo več vidna\",\n    \"The following tables will no longer be visible_other\": \"Naslednje tabele ne bodo več vidne\"\n  },\n  \"RowContextMenu\": {\n    \"Delete\": \"Izbriši\",\n    \"Insert row\": \"Vstavi vrstico\",\n    \"Insert row below\": \"Vstavi vrstico spodaj\",\n    \"Copy anchor link\": \"Kopiraj sidrno povezavo\",\n    \"Duplicate rows_one\": \"Podvoji vrstico\",\n    \"Duplicate rows_other\": \"Podvoji vrstice\",\n    \"Insert row above\": \"Vstavi vrstico zgoraj\",\n    \"View as card\": \"Kartični pogled\",\n    \"Use as table headers\": \"Uporabi kot glave tabel\"\n  },\n  \"Tools\": {\n    \"Delete\": \"Izbriši\",\n    \"Delete document tour?\": \"Izbriši ogled dokumenta?\",\n    \"TOOLS\": \"ORODJA\",\n    \"Settings\": \"Nastavitve\",\n    \"Access Rules\": \"Pravila dostopa\",\n    \"Code view\": \"Koda\",\n    \"Raw data\": \"Neobdelani podatki\",\n    \"Document history\": \"Zgodovina Dokumentov\",\n    \"Validate Data\": \"Potrdi podatke\",\n    \"How-to Tutorial\": \"Vadnica kako narediti\",\n    \"Tour of this Document\": \"Ogled tega dokumenta\",\n    \"Return to viewing as yourself\": \"Vrnite se k ogledu kot vi\",\n    \"API console\": \"API Konzola\"\n  },\n  \"pages\": {\n    \"Rename\": \"Preimenuj\",\n    \"Duplicate page\": \"Podvoji stran\",\n    \"You do not have edit access to this document\": \"Nimate dovoljenja za urejanje tega dokumenta\",\n    \"Remove\": \"Odstrani\"\n  },\n  \"search\": {\n    \"Find Next \": \"Poišči naslednjega \",\n    \"Search in document\": \"Iskanje v dokumentu\",\n    \"No results\": \"Brez rezultatov\",\n    \"Find Previous \": \"Poiščite Prejšnjega \",\n    \"Search\": \"Iskanje\"\n  },\n  \"AddNewButton\": {\n    \"Add new\": \"Dodaj\"\n  },\n  \"DataTables\": {\n    \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Izbriši podatke {{formattedTableName}} in jih odstrani z vseh strani?\",\n    \"Click to copy\": \"Kliknite za kopiranje\",\n    \"Duplicate table\": \"Podvoji tabelo\",\n    \"Table ID copied to clipboard\": \"ID tabele kopiran v odložišče\",\n    \"You do not have edit access to this document\": \"Nimate dostopa za urejanje tega dokumenta\",\n    \"Raw Data Tables\": \"Neobdelana tabela\",\n    \"Edit record card\": \"Uredi evidenčno kartico\",\n    \"Rename table\": \"Preimenuj Tabelo\",\n    \"{{action}} Record Card\": \"{{action}} Evidenčno Kartico\",\n    \"Record Card\": \"Evidenčna kartica\",\n    \"Remove table\": \"Odstrani Tabelo\",\n    \"Record Card Disabled\": \"Evidenčna kartica onemogočena\"\n  },\n  \"ViewLayoutMenu\": {\n    \"Delete record\": \"Briši zapis\",\n    \"Delete widget\": \"Izbriši gradnik\",\n    \"Advanced sort & filter\": \"Napredno razvrščanje in filtriranje\",\n    \"Data selection\": \"Izbira podatkov\",\n    \"Download as XLSX\": \"Prenesi kot XLSX\",\n    \"Download as CSV\": \"Prenesi kot CSV\",\n    \"Widget options\": \"Možnosti gradnika\",\n    \"Print widget\": \"Natisni\",\n    \"Open configuration\": \"Odpri konfiguracijo\",\n    \"Edit card layout\": \"Uredi postaviev kartice\",\n    \"Add to page\": \"Dodaj na stran\",\n    \"Show raw data\": \"Prikaži neobdelane podatke\",\n    \"Copy anchor link\": \"Kopiraj sidrno povezavo\",\n    \"Collapse widget\": \"Strni gradnik\",\n    \"Create a form\": \"Ustvari obrazec\"\n  },\n  \"FieldEditor\": {\n    \"Unable to finish saving edited cell\": \"Ni mogoče dokončati shranjevanja urejene celice\",\n    \"It should be impossible to save a plain data value into a formula column\": \"Nemogoče bi bilo shraniti navadne podatkovne vrednosti v stolpec formule\"\n  },\n  \"AppHeader\": {\n    \"Home page\": \"Domača stran\",\n    \"Personal Site\": \"Osebna stran\",\n    \"Team Site\": \"Spletna stran ekipe\",\n    \"Grist Templates\": \"Grist predloge\",\n    \"Legacy\": \"Zapuščina\",\n    \"Billing account\": \"Račun za obračunavanje\",\n    \"Manage team\": \"Upravljanje ekipe\"\n  },\n  \"ChartView\": {\n    \"Pick a column\": \"Izberite stolpec\",\n    \"Toggle chart aggregation\": \"Preklopite združevanje grafikonov\",\n    \"Create separate series for each value of the selected column.\": \"Ustvarite ločene serije za vsako vrednost izbranega stolpca.\",\n    \"selected new group data columns\": \"izbrani novi stolpci podatkovnih skupin\",\n    \"Each Y series is followed by a series for the length of error bars.\": \"Vsaki seriji Y sledi serija za dolžino vrstic napak.\",\n    \"Each Y series is followed by two series, for top and bottom error bars.\": \"Vsaki seriji Y sledita dve seriji, za zgornjo in spodnjo vrstico napak.\"\n  },\n  \"ColumnFilterMenu\": {\n    \"All\": \"Vse\",\n    \"All except\": \"Vse razen\",\n    \"All shown\": \"Vse prikazano\",\n    \"Future values\": \"Prihodnje vrednosti\",\n    \"No matching values\": \"Ni ustreznih vrednosti\",\n    \"None\": \"Ni\",\n    \"Min\": \"Min\",\n    \"Max\": \"Max\",\n    \"Start\": \"Začetek\",\n    \"End\": \"Konec\",\n    \"Other Matching\": \"Drugo ujemanje\",\n    \"Other Non-Matching\": \"Drugo neujemanje\",\n    \"Other values\": \"Druge vrednosti\",\n    \"Others\": \"Drugo\",\n    \"Search\": \"Iskanje\",\n    \"Search values\": \"Iskanje vrednosti\",\n    \"Filter by Range\": \"Filtriranje po obsegu\"\n  },\n  \"CustomSectionConfig\": {\n    \" (optional)\": \" (neobvezno)\",\n    \"Add\": \"Dodaj\",\n    \"Enter Custom URL\": \"Vnesite URL po meri\",\n    \"Full document access\": \"Dostop do celotnega dokumenta\",\n    \"Open configuration\": \"Odpri konfiguracijo\",\n    \"Pick a column\": \"Izberite stolpec\",\n    \"Pick a {{columnType}} column\": \"Izberite stolpec {{columnType}}\",\n    \"Read selected table\": \"Preberite izbrano tabelo\",\n    \"Learn more about custom widgets\": \"Preberite več o gradnikih po meri\",\n    \"Widget needs {{fullAccess}} to this document.\": \"Widget potrebuje {{fullAccess}} tega dokumenta.\",\n    \"No document access\": \"Brez dostopa do dokumentov\",\n    \"Widget does not require any permissions.\": \"Widget ne zahteva nobenih dovoljenj.\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} stolpec, ki ni{{columnType}}, ni prikazan\",\n    \"Select Custom Widget\": \"Izberite Prilagojeni pripomoček\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} stolpci, ki niso{{columnType}}, niso prikazani\",\n    \"Widget needs to {{read}} the current table.\": \"Widget mora {{read}} trenutno tabelo.\",\n    \"No {{columnType}} columns in table.\": \"V tabeli ni stolpcev tipa {{columnType}}.\",\n    \"Clear selection\": \"Briši izbor\",\n    \"Custom URL\": \"URL po meri\",\n    \"Developer:\": \"Razvijalec:\",\n    \"ACCESS LEVEL\": \"STOPNJA DOSTOPOV\",\n    \"Reject\": \"Zavrni\",\n    \"Accept\": \"Sprejmi\",\n    \"Last updated:\": \"Zadnja posodobitev:\",\n    \"Missing description and author information.\": \"Manjka opis in podatki o avtorju.\",\n    \"Widget\": \"Widget\"\n  },\n  \"DocHistory\": {\n    \"Activity\": \"Dejavnost\",\n    \"Beta\": \"Beta\",\n    \"Compare to current\": \"Primerjava s trenutnim\",\n    \"Compare to previous\": \"Primerjava s prejšnjimi\",\n    \"Snapshots\": \"Posnetki\",\n    \"Snapshots are unavailable.\": \"Posnetki niso na voljo.\",\n    \"Open snapshot\": \"Odpri posnetek stanja\",\n    \"Only owners have access to snapshots for documents with access rules.\": \"Samo lastniki imajo dostop do posnetkov za dokumente s pravili dostopa.\"\n  },\n  \"ExampleInfo\": {\n    \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Oglejte si sorodno navodilo za povezovanje podatkov in ustvarjanje visokoproduktivnih postavitev.\",\n    \"Afterschool Program\": \"Program za izvenšolsko vzgojo\",\n    \"Welcome to the Investment Research template\": \"Dobrodošli v predlogi za investicijske raziskave\",\n    \"Welcome to the Afterschool Program template\": \"Dobrodošli v predlogi programa za popoldansko izobraževanje\",\n    \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Oglejte si sorodno navodilo, v katerem boste izvedeli, kako ustvariti zbirne tabele in grafe ter dinamično povezati grafe.\",\n    \"Investment Research\": \"Investicijske raziskave\",\n    \"Tutorial: Create a CRM\": \"Učni pripomoček: Ustvarite CRM\",\n    \"Tutorial: Manage Business Data\": \"Učni pripomoček: Upravljanje poslovnih podatkov\",\n    \"Tutorial: Analyze & Visualize\": \"Učni pripomoček: Analizirajte in vizualizirajte\",\n    \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Oglejte si sorodna navodila za modeliranje poslovnih podatkov, uporabo formul in obvladovanje kompleksnosti.\",\n    \"Welcome to the Lightweight CRM template\": \"Dobrodošli v predlogi enostavnega CRM\",\n    \"Lightweight CRM\": \"Enostavni CRM\"\n  },\n  \"CodeEditorPanel\": {\n    \"Access denied\": \"Dostop zavrnjen\",\n    \"Code View is available only when you have full document access.\": \"Koda je na voljo le, če imate popoln dostop do dokumenta.\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"Uporabi\",\n    \"Cancel\": \"Prekliči\",\n    \"Default cell style\": \"Privzet slog celic\"\n  },\n  \"Drafts\": {\n    \"Undo discard\": \"Preklic zavrženja\",\n    \"Restore last edit\": \"Obnovitev zadnjega urejanja\"\n  },\n  \"FieldConfig\": {\n    \"Column options are limited in summary tables.\": \"Možnosti stolpcev so v zbirnih tabelah omejene.\",\n    \"Set formula\": \"Nastavite formulo\",\n    \"Data columns_other\": \"Stolpci podatkov\",\n    \"DESCRIPTION\": \"OPIS\",\n    \"Clear and reset\": \"Briši in ponastavi\",\n    \"Convert column to data\": \"Pretvori stolpec v podatke\",\n    \"Empty columns_other\": \"Prazni stolpci\",\n    \"COLUMN LABEL AND ID\": \"OZNAKA IN ID STOLPCA\",\n    \"Empty columns_one\": \"Prazen stolpec\",\n    \"Formula columns_other\": \"Stolpci formule\",\n    \"Formula columns_one\": \"Stolpec formule\",\n    \"Enter formula\": \"Vnesite formulo\",\n    \"Clear and make into formula\": \"Briši in pretvori v formulo\",\n    \"Mixed Behavior\": \"Mešano vedenje\",\n    \"Convert to trigger formula\": \"Pretvori v sprožitveno formulo\",\n    \"Data columns_one\": \"Stolpec podatkov\",\n    \"TRIGGER FORMULA\": \"SPROŽILNA FORMULA\",\n    \"Set trigger formula\": \"Nastavi sprožitveno formulo\",\n    \"Make into data column\": \"Spremeni v podatkovni stolpec\",\n    \"COLUMN BEHAVIOR\": \"OBNAŠANJE STOLPCA\"\n  },\n  \"DuplicateTable\": {\n    \"Only the document default access rules will apply to the copy.\": \"Za kopijo bodo veljala samo privzeta pravila dostopa do dokumenta.\",\n    \"Copy all data in addition to the table structure.\": \"Poleg strukture tabele kopirajte tudi vse podatke.\",\n    \"Name for new table\": \"Ime za novo tabelo\",\n    \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Namesto podvajanja tabel je običajno bolje podatke segmentirati s povezanimi pogledi. {{link}}\"\n  },\n  \"DocPageModel\": {\n    \"Sorry, access to this document has been denied. [{{error}}]\": \"Žal je bil dostop do tega dokumenta zavrnjen. [{{error}}]\",\n    \"Add empty table\": \"Dodaj prazno tabelo\",\n    \"You do not have edit access to this document\": \"Nimate dostopa za urejanje tega dokumenta\",\n    \"Add widget to page\": \"Dodaj widget na stran\",\n    \"Add page\": \"Dodaj stran\",\n    \"Document owners can attempt to recover the document. [{{error}}]\": \"Lastniki dokumentov lahko poskušajo obnoviti dokument. [{{error}}]\",\n    \"Error accessing document\": \"Napaka pri dostopu do dokumenta\",\n    \"Enter recovery mode\": \"Vstopite v način obnovitve\",\n    \"Reload\": \"Ponovno naloži\",\n    \"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}}]\": \"Poskusite ponovno naložiti dokument ali pa uporabite način obnovitve. Način obnovitve odpre dokument, ki je v celoti dostopen samo lastnikom, drugim pa ne. Prav tako pa ta način onemogoči formule. [{{error}}]\"\n  },\n  \"DocumentSettings\": {\n    \"Ok\": \"V REDU\",\n    \"API\": \"API\",\n    \"Save\": \"Shrani\",\n    \"Document ID copied to clipboard\": \"ID dokumenta kopiran v odložišče\",\n    \"Local currency ({{currency}})\": \"Lokalna valuta ({{currency}})\",\n    \"Save and Reload\": \"Shrani in ponovno naloži\",\n    \"Time Zone:\": \"Časovni pas:\",\n    \"Currency:\": \"Valuta:\",\n    \"Document settings\": \"Nastavitve dokumentov\",\n    \"Locale:\": \"Lokalizacija:\",\n    \"This document's ID (for API use):\": \"ID tega dokumenta (za uporabo API):\",\n    \"Manage Webhooks\": \"Upravljanje spletnih kljuk\",\n    \"Webhooks\": \"Spletne kljuke\",\n    \"Engine (experimental {{span}} change at own risk):\": \"Pogon (eksperimentalno {{span}} spreminjanje na lastno odgovornost):\",\n    \"API console\": \"API konzola\",\n    \"API documentation.\": \"API dokumentacija.\",\n    \"Base doc URL: {{docApiUrl}}\": \"URL osnovnega dokumenta: {{docApiUrl}}\",\n    \"Coming soon\": \"Prihaja kmalu\",\n    \"Copy to clipboard\": \"Kopiraj v odložišče\",\n    \"Currency\": \"Valuta\",\n    \"Data engine\": \"Podatkovna mašina\",\n    \"Document ID\": \"ID dokumenta\",\n    \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"ID dokumenta, ki se uporabi vsakič, ko REST API  pokliče {{docId}}. Oglej si {{apiURL}}\",\n    \"Find slow formulas\": \"Poišči počasne formule\",\n    \"For currency columns\": \"Za valutne stolpce\",\n    \"For number and date formats\": \"Za format števila in datuma\",\n    \"Formula times\": \"Časi formule\",\n    \"API URL copied to clipboard\": \"URL API-ja kopiran v odložišče\",\n    \"Default for DateTime columns\": \"Privzeto za stolpce DateTime\",\n    \"ID for API use\": \"ID za uporabo API-ja\",\n    \"Locale\": \"Lokalizacija\",\n    \"Hard reset of data engine\": \"Ponastavitev podatkovnega mehanizma\",\n    \"Manage webhooks\": \"Upravljanje webhookov\",\n    \"Notify other services on doc changes\": \"Obvesti druge storitve o spremembah dokumenta\",\n    \"Python\": \"Python\",\n    \"Python version used\": \"Uporabljena različica Pythona\",\n    \"Reload\": \"Ponovno naloži\",\n    \"Time zone\": \"Časovni pas\",\n    \"Try API calls from the browser\": \"Poskusi klice API-ja iz brskalnika\",\n    \"python3 (recommended)\": \"python3 (priporočeno)\",\n    \"python2 (legacy)\": \"python2 (odsvetovano)\",\n    \"Formula timer\": \"Časovnik formule\",\n    \"Reload data engine\": \"Ponovno naloži podatkovni mehanizem\",\n    \"Reload data engine?\": \"Ponovno naložiti podatkovni mehanizem?\",\n    \"Start timing\": \"Začni meriti čas\",\n    \"Stop timing...\": \"Ustavi merjenje časa ...\",\n    \"Time reload\": \"Ponovno nalaganje časa\",\n    \"Cancel\": \"Prekliči\",\n    \"Force reload the document while timing formulas, and show the result.\": \"Prisilno znova naloži dokument med časovnimi formulami in prikaži rezultat.\",\n    \"Timing is on\": \"Merjenje časa je vklopljeno\",\n    \"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.\",\n    \"Only available to document editors\": \"Na voljo samo urednikom dokumentov\",\n    \"Only available to document owners\": \"Na voljo samo lastnikom dokumentov\"\n  },\n  \"GridOptions\": {\n    \"Horizontal gridlines\": \"Vodoravne linije\",\n    \"Vertical gridlines\": \"Navpične linije\",\n    \"Grid Options\": \"Možnosti mreže\",\n    \"Zebra stripes\": \"Zebraste vrstice\"\n  },\n  \"DocumentUsage\": {\n    \"Data size\": \"Velikost podatkov\",\n    \"Usage statistics are only available to users with full access to the document data.\": \"Statistični podatki o uporabi so na voljo le uporabnikom s polnim dostopom do podatkov o dokumentu.\",\n    \"Usage\": \"Uporaba\",\n    \"Size of attachments\": \"Velikost prilog\",\n    \"For higher limits, \": \"Za višje omejitve, \",\n    \"Contact the site owner to upgrade the plan to raise limits.\": \"Obrnite se na lastnika spletnega mesta in nadgradite načrt za povečanje omejitev.\",\n    \"start your 30-day free trial of the Pro plan.\": \"začnite 30-dnevni brezplačni preizkus Pro različice.\",\n    \"Rows\": \"Vrstice\"\n  },\n  \"FieldMenus\": {\n    \"Use separate settings\": \"Uporaba ločenih nastavitev\",\n    \"Revert to common settings\": \"Vrnitev na običajne nastavitve\",\n    \"Using common settings\": \"Uporaba skupnih nastavitev\",\n    \"Using separate settings\": \"Uporaba ločenih nastavitev\",\n    \"Save as common settings\": \"Shrani kot skupne nastavitve\"\n  },\n  \"FilterConfig\": {\n    \"Add column\": \"Dodaj stolpec\"\n  },\n  \"AppModel\": {\n    \"This team site is suspended. Documents can be read, but not modified.\": \"To spletno mesto ekipe je začasno zaprto. Dokumente lahko berete, vendar jih ne morete spreminjati.\"\n  },\n  \"FormulaAssistant\": {\n    \"Data\": \"Podatki\",\n    \"Press Enter to apply suggested formula.\": \"Za uporabo predlagane formule pritisnite Enter.\",\n    \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Prijavite se na brezplačen račun Grist in začnite uporabljati pomočnika Formula AI Assistant.\",\n    \"New Chat\": \"Nov klepet\",\n    \"Code view\": \"Kodni pogled\",\n    \"Apply\": \"Uporabi\",\n    \"Learn more\": \"Preberite več\",\n    \"Regenerate\": \"Regeneracija\",\n    \"Community\": \"Skupnost\",\n    \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Pomagam lahko le s formulami. Ne morem sestavljati tabel, stolpcev in pogledov ter pisati pravil dostopa.\",\n    \"Hi, I'm the Grist Formula AI Assistant.\": \"Pozdravljeni, sem pomočnik umetne inteligence formule Grist.\",\n    \"Preview\": \"Predogled\",\n    \"Ask the bot.\": \"Vprašajte bota.\",\n    \"Function List\": \"Seznam funkcij\",\n    \"For higher limits, contact the site owner.\": \"Za višje omejitve se obrnite na lastnika spletnega mesta.\",\n    \"Tips\": \"Nasveti\",\n    \"Save\": \"Shrani\",\n    \"Sign Up for Free\": \"Prijavite se brezplačno\",\n    \"Formula AI Assistant is only available for logged in users.\": \"Pomočnik Formula AI je na voljo le prijavljenim uporabnikom.\",\n    \"upgrade to the Pro Team plan\": \"nadgradnja na načrt Pro Team\",\n    \"You have used all available credits.\": \"Izkoristili ste vse razpoložljive kredite.\",\n    \"upgrade your plan\": \"nadgradite svoj načrt\",\n    \"Formula Help. \": \"Pomoč pri formuli. \",\n    \"You have {{numCredits}} remaining credits.\": \"Na voljo imate {{numCredits}} preostalih kreditov.\",\n    \"Capabilities\": \"Zmožnosti\",\n    \"What do you need help with?\": \"Pri čem potrebujete pomoč?\",\n    \"Cancel\": \"Prekliči\",\n    \"Need help? Our AI assistant can help.\": \"Potrebujete pomoč? Naš pomočnik z umetno inteligenco vam lahko pomaga.\",\n    \"AI Assistant\": \"AI pomočnik\",\n    \"There are some things you should know when working with me:\": \"Pri sodelovanju z mano morate vedeti nekaj stvari:\",\n    \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Oglejte si {{helpFunction}} in {{formulaCheat}}, za več pomoči pa obiščite {{community}}.\",\n    \"Grist's AI Assistance\": \"Pomoč umetne inteligence\",\n    \"Grist's AI Formula Assistance. \": \"Pomoč umetne inteligence pri formuli . \",\n    \"Clear conversation\": \"Briši pogovor\",\n    \"Formula Cheat Sheet\": \"Plonk listek za formule\",\n    \"For higher limits, {{upgradeNudge}}.\": \"Za višje omejitve {{upgradeNudge}}.\"\n  },\n  \"RightPanel\": {\n    \"WIDGET TITLE\": \"NASLOV PRIPOMOČKA\",\n    \"COLUMN TYPE\": \"VRSTA STOLPCA\",\n    \"SELECT BY\": \"IZBOR PO\",\n    \"Edit data selection\": \"Uredi izbor podatkov\",\n    \"DATA TABLE NAME\": \"IME PODATKOVNE TABELE\",\n    \"fields_one\": \"Polje\",\n    \"Save\": \"Shrani\",\n    \"You do not have edit access to this document\": \"Nimate dostopa za urejanje tega dokumenta\",\n    \"DATA TABLE\": \"PODATKOVNA TABELA\",\n    \"Theme\": \"Tema\",\n    \"columns_other\": \"Stolpci\",\n    \"Data\": \"Podatki\",\n    \"series_one\": \"Serija\",\n    \"GROUPED BY\": \"RAZVRŠČENO PO\",\n    \"SOURCE DATA\": \"IZVORNI PODATKI\",\n    \"CHART TYPE\": \"VRSTA DIAGRAMA\",\n    \"Detach\": \"Odklopi\",\n    \"Change widget\": \"Spremeni Pripomoček\",\n    \"columns_one\": \"Stolpec\",\n    \"series_other\": \"Serija\",\n    \"fields_other\": \"Polja\",\n    \"Row style\": \"Stil vrstic\",\n    \"CUSTOM\": \"CUSTOM\",\n    \"Select widget\": \"Izberite widget\",\n    \"Add referenced columns\": \"Dodajanje referenčnih stolpcev\",\n    \"TRANSFORM\": \"TRANSFORM\",\n    \"SELECTOR FOR\": \"SELEKTOR ZA\",\n    \"Sort & filter\": \"Razvrščanje in filtriranje\",\n    \"Widget\": \"Pripomoček\",\n    \"Reset form\": \"Ponastavi obrazec\",\n    \"Default field value\": \"Privzeta vrednost polja\",\n    \"Display button\": \"Gumb za prikaz\",\n    \"Field rules\": \"Pravila polja\",\n    \"Field title\": \"Naziv polja\",\n    \"Hidden field\": \"Skrito polje\",\n    \"Layout\": \"Postavitev\",\n    \"Redirection\": \"Preusmeritev\",\n    \"Required field\": \"Obvezno polje\",\n    \"Submit another response\": \"Predložite drug odgovor\",\n    \"Submission\": \"Predložitev\",\n    \"Success text\": \"Uspešno besedilo\",\n    \"Table column name\": \"Ime stolpca tabele\",\n    \"Configuration\": \"Konfiguracija\",\n    \"Enter text\": \"Vnesi besedilo\",\n    \"Redirect automatically after submission\": \"Po oddaji samodejno preusmeri\",\n    \"Enter redirect URL\": \"Vnesi preusmeritveni URL\",\n    \"Submit button label\": \"Oznaka gumba za pošiljanje\",\n    \"Select a field in the form widget to configure.\": \"Izberi polje v gradniku obrazca, ki ga želiš konfigurirati.\",\n    \"No field selected\": \"Nobeno polje ni izbrano\"\n  },\n  \"FloatingPopup\": {\n    \"Maximize\": \"Povečajte\",\n    \"Minimize\": \"Zmanjšajte\"\n  },\n  \"MakeCopyMenu\": {\n    \"Include the structure without any of the data.\": \"Vključite strukturo brez podatkov.\",\n    \"Original Looks Unrelated\": \"Original izgleda nepovezan\",\n    \"Overwrite\": \"Prepiši\",\n    \"It will be overwritten, losing any content not in this document.\": \"Dokument bo prepisan, pri čemer bo izgubljena vsa vsebina, ki ni v tem dokumentu.\",\n    \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Bodite previdni, izvirnik ima spremembe, ki niso v tem dokumentu. Te spremembe bodo prepisane.\",\n    \"Workspace\": \"Delovni prostor\",\n    \"As template\": \"Kot predloga\",\n    \"Cancel\": \"Prekliči\",\n    \"Sign up\": \"Prijava\",\n    \"Enter document name\": \"Vnesite ime dokumenta\",\n    \"Name\": \"Ime\",\n    \"Update\": \"Posodobitev\",\n    \"Original Has Modifications\": \"Izvirnik Ima spremembe\",\n    \"No destination workspace\": \"Ni ciljnega delovnega prostora\",\n    \"You do not have write access to the selected workspace\": \"Nimate dostopa za pisanje v izbrani delovni prostor\",\n    \"Download document structure only (no data, for template use)\": \"Odstranite vse podatke, vendar ohranite strukturo in jo uporabite kot predlogo\",\n    \"Original Looks Identical\": \"Izvirnik je videti identičen\",\n    \"Organization\": \"Organizacija\",\n    \"Replacing the original requires editing rights on the original document.\": \"Za zamenjavo izvirnika so potrebne pravice za urejanje izvirnega dokumenta.\",\n    \"Download document without history (can significantly reduce file size)\": \"Odstranitev zgodovine dokumenta (lahko znatno zmanjša velikost datoteke)\",\n    \"To save your changes, please sign up, then reload this page.\": \"Če želiš shraniti spremembe, se prijavi in nato ponovno naloži to stran.\",\n    \"The original version of this document will be updated.\": \"Prvotna različica tega dokumenta bo posodobljena.\",\n    \"However, it appears to be already identical.\": \"Vendar se zdi, da je že identična.\",\n    \"Update Original\": \"Posodobitev izvirnika\",\n    \"You do not have write access to this site\": \"Nimate dovoljenja za pisanje za to spletno mesto\",\n    \"Download document and history\": \"Prenesite celoten dokument in zgodovino\",\n    \"Download\": \"Prenesi\",\n    \"Download document\": \"Prenesi dokument\"\n  },\n  \"SortConfig\": {\n    \"Add column\": \"Dodaj stolpec\",\n    \"Natural sort\": \"Naravni vrstni red\",\n    \"Search Columns\": \"Stolpci za iskanje\",\n    \"Update data\": \"Posodobitev podatkov\",\n    \"Empty values last\": \"Prazne vrednosti na koncu\",\n    \"Use choice position\": \"Uporaba položaja izbire\"\n  },\n  \"Clipboard\": {\n    \"Unavailable Command\": \"Ukaz ni na voljo\",\n    \"Got it\": \"Imam ga\"\n  },\n  \"SupportGristPage\": {\n    \"You have opted out of telemetry.\": \"Odpovedali ste se telemetriji.\",\n    \"Support Grist\": \"Podpri Grist\",\n    \"Opt out of Telemetry\": \"Odjava od telemetrije\",\n    \"GitHub Sponsors page\": \"GitHub sponzorska stran\",\n    \"Sponsor Grist Labs on GitHub\": \"Sponzoriraj Grist Labs na GitHubu\",\n    \"Manage Sponsorship\": \"Upravljanje sponzorstva\",\n    \"Help Center\": \"Center za pomoč\",\n    \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Zbiramo samo statistične podatke o uporabi, kot je podrobno opisano v naši spletni strani {{link}}, nikoli pa ne zbiramo vsebine dokumentov.\",\n    \"You can opt out of telemetry at any time from this page.\": \"Telemetrijo lahko kadar koli odjavite na tej strani.\",\n    \"Home\": \"Domov\",\n    \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Ta primerek je izključen iz telemetrije. To lahko spremeni samo skrbnik spletnega mesta.\",\n    \"Telemetry\": \"Telemetrija\",\n    \"Opt in to Telemetry\": \"Prijava na telemetrijo\",\n    \"You have opted in to telemetry. Thank you!\": \"Prijavili ste se za telemetrijo. Zahvaljujemo se vam!\",\n    \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Ta primerek je prijavljen v telemetrijo. To lahko spremeni le skrbnik spletnega mesta.\",\n    \"GitHub\": \"GitHub\",\n    \"Sponsor\": \"Sponzor\"\n  },\n  \"GristTooltips\": {\n    \"Updates every 5 minutes.\": \"Posodablja se vsakih 5 minut.\",\n    \"entire\": \"celoten\",\n    \"You can filter by more than one column.\": \"Filtrirate lahko po več kot enem stolpcu.\",\n    \"Anchor Links\": \"Sidrne povezave\",\n    \"relational\": \"relacijski\",\n    \"Add new\": \"Dodaj novo\",\n    \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Z ikono 𝚺 ustvarite zbirne (ali vrtilne) tabele za seštevke ali vmesne seštevke.\",\n    \"Access Rules\": \"Pravila dostopa\",\n    \"Select the table to link to.\": \"Izberite tabelo, s katero želite vzpostaviti povezavo.\",\n    \"The total size of all data in this document, excluding attachments.\": \"Skupna velikost vseh podatkov v tem dokumentu, brez prilog.\",\n    \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Izberete lahko enega od naših vnaprej pripravljenih gradnikov ali pa vgradite svojega, tako da navedete njegov celoten naslov URL.\",\n    \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Pravila dostopa vam omogočajo, da ustvarite podrobna pravila, s katerimi določite, kdo lahko vidi ali ureja posamezne dele dokumenta.\",\n    \"Rearrange the fields in your card by dragging and resizing cells.\": \"Z vlečenjem in spreminjanjem velikosti polj na kartici spremenite njihovo razporeditev.\",\n    \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Uporabno za shranjevanje časovnega žiga ali avtorja novega zapisa, čiščenje podatkov in drugo.\",\n    \"Click the Add new button to create new documents or workspaces, or import data.\": \"Klikni gumb dodaj novega, če želiš ustvariti nove dokumente, delovne prostore ali uvoziti podatke.\",\n    \"Nested Filtering\": \"Vgnezdeno filtriranje\",\n    \"Only those rows will appear which match all of the filters.\": \"Prikazane bodo samo tiste vrstice, ki ustrezajo vsem filtrom.\",\n    \"Editing Card Layout\": \"Uredi postavitev kartice\",\n    \"Raw Data page\": \"Stran z neobdelanimi podatki\",\n    \"Selecting Data\": \"Izbira podatkov\",\n    \"Learn more.\": \"Preberite več.\",\n    \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"formule, ki se sprožijo v določenih primerih in shranijo izračunano vrednost kot podatke.\",\n    \"Select the table containing the data to show.\": \"Izberite tabelo s podatki, ki jih želite prikazati.\",\n    \"Pinning Filters\": \"Filtri za pripenjanje\",\n    \"They allow for one record to point (or refer) to another.\": \"Omogočajo, da en zapis kaže (ali se sklicuje) na drugega.\",\n    \"Reference Columns\": \"Referenčni stolpci\",\n    \"To configure your calendar, select columns for start\": {\n      \"end dates and event titles. Note each column's type.\": \"Če želiš konfigurirati svoj koledar, izberi stolpce za začetne/končne datume in naslove dogodkov. Upoštevaj vrsto vsakega stolpca.\"\n    },\n    \"Calendar\": \"Koledar\",\n    \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Uporabi pogojno oblikovanje za celice v tem stolpcu, ko so izpolnjeni pogoji formule.\",\n    \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Če želiš narediti sidrno povezavo, ki uporabnika pripelje do določene celice, klikni vrstico in pritisni {{shortcut}}.\",\n    \"Unpin to hide the the button while keeping the filter.\": \"Odpnite, da skrijete gumb in obdržite filter.\",\n    \"Apply conditional formatting to rows based on formulas.\": \"Uporabi pogojno oblikovanje za vrstice na podlagi formul.\",\n    \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Klikni »Odpri sloge vrstic«, da za vrstice uporabiš pogojno oblikovanje.\",\n    \"Pinned filters are displayed as buttons above the widget.\": \"Pripeti filtri so prikazani kot gumbi nad pripomočkom.\",\n    \"Link your new widget to an existing widget on this page.\": \"Povežite svoj novi pripomoček z obstoječim pripomočkom na tej strani.\",\n    \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Uporabite ikono \\\\u{1D6BA} za ustvarjanje povzetkov (ali vrtilnih) tabel za vsote ali delne vsote.\",\n    \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Če kliknete {{EyeHideIcon}} v vsaki celici, skrijete polje iz tega pogleda, ne da bi ga izbrisali.\",\n    \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Ne najdete pravih stolpcev? Kliknite 'Spremeni gradnik', da izberete tabelo s podatki o dogodkih.\",\n    \"Custom Widgets\": \"Pripomočki po meri\",\n    \"This is the secret to Grist's dynamic and productive layouts.\": \"To je skrivnost Gristovih dinamičnih in produktivnih postavitev.\",\n    \"Linking Widgets\": \"Povezovanje pripomočkov\",\n    \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Preizkusite spremembe v kopiji, nato pa se odločite, ali boste izvirnik zamenjali s svojimi popravki.\",\n    \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Na strani z neobdelanimi podatki so navedene vse podatkovne tabele v vašem dokumentu, vključno s tabelami s povzetki in tabelami, ki niso vključene v postavitve strani.\",\n    \"Reference columns are the key to {{relational}} data in Grist.\": \"Referenčni stolpci so ključ do {{relational}} podatkov v Gristu.\",\n    \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Celice v referenčnem stolpcu vedno identificirajo {{entire}} zapis v tej tabeli, vendar lahko izberete, kateri stolpec iz tega zapisa želite prikazati.\",\n    \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID je naključno ustvarjen niz, ki je uporaben za edinstvene identifikatorje in ključe povezav.\",\n    \"Lookups return data from related tables.\": \"Iskanje vrne podatke iz povezanih tabel.\",\n    \"Use reference columns to relate data in different tables.\": \"Uporabite referenčne stolpce za povezavo podatkov v različnih tabelah.\",\n    \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Izbirate lahko med pripomočki, ki so vam na voljo v spustnem meniju, ali vdelate svojega tako, da navedete njegov polni URL.\",\n    \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Formule podpirajo številne Excelove funkcije, polno Pythonovo sintakso in vključujejo koristnega AI pomočnika.\",\n    \"Forms are here!\": \"Obrazci so tukaj!\",\n    \"Learn more\": \"Nauči se več\",\n    \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Ustvari preproste obrazce neposredno v Gristu in jih deli z enim klikom z našim novim pripomočkom. {{learnMoreButton}}\",\n    \"These rules are applied after all column rules have been processed, if applicable.\": \"Ta pravila se uporabijo, ko so obdelana vsa pravila stolpcev, če so na voljo.\",\n    \"Filter displayed dropdown values with a condition.\": \"Filtriraj prikazane vrednosti .\",\n    \"Example: {{example}}\": \"Primer: {{example}}\",\n    \"Creates a reverse column in target table that can be edited from either end.\": \"Ustvari vzvratni stolpec v ciljni tabeli, ki ga je mogoče urejati z obeh koncev.\",\n    \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Če želiš dovoliti več dodelitev, spremeni vrsto stolpca Reference v Seznam referenc.\",\n    \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Če želiš omogočiti več dodelitev, spremeni vrsto referenčnega stolpca v Referenčni seznam.\",\n    \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Dvosmerne reference trenutno niso podprte za stolpce formule ali formule sprožilca\",\n    \"Community widgets are created and maintained by Grist community members.\": \"Pripomočke skupnosti ustvarjajo in vzdržujejo člani skupnosti Grist.\",\n    \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Do te omejitve pride, ko je en konec dvosmerne reference konfiguriran kot ena sama referenca.\",\n    \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Do te omejitve pride, ko ima en stolpec v dvosmernem sklicu vrsto Reference.\"\n  },\n  \"UserManager\": {\n    \"Anyone with link \": \"Vsakdo s povezavo \",\n    \"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}}.\": \"Ko odstranite svoj dostop, ga ne boste mogli vrniti brez pomoči druge osebe z zadostnim dostopom do spletnega mesta {{name}}.\",\n    \"Your role for this team site\": \"Vaša vloga v tej ekipi\",\n    \"Copy link\": \"Kopiraj povezavo\",\n    \"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.\": \"Uporabnik ima vpogled v {{resource}}, ki je posledica ročno nastavljenega dostopa do virov v njem. Če ga tukaj odstranite, bo ta uporabnik izgubil dostop do notranjih virov.\",\n    \"User may not modify their own access.\": \"Uporabnik ne more spreminjati lastnega dostopa.\",\n    \"member\": \"član\",\n    \"Add {{member}} to your team\": \"Dodaj {{member}} v svojo ekipo\",\n    \"Collaborator\": \"Sodelavec\",\n    \"Link copied to clipboard\": \"Povezava kopirana v odložišče\",\n    \"team site\": \"spletno mesto ekipe\",\n    \"Create a team to share with more people\": \"Ustvarite ekipo in jo delite z več ljudmi\",\n    \"guest\": \"gost\",\n    \"Public access: \": \"Javni dostop: \",\n    \"Team member\": \"Član ekipe\",\n    \"Off\": \"Izklopljeno\",\n    \"free collaborator\": \"brezplačni sodelavec\",\n    \"Save & \": \"Shrani & \",\n    \"Outside collaborator\": \"Zunanji sodelavec\",\n    \"{{collaborator}} limit exceeded\": \"{{collaborator}} presežena meja\",\n    \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Uporabnik podeduje dovoljenja od {{parent}}}. Če jih želite odstraniti, nastavite možnost \\\"Podeduje dostop\\\" na \\\"Ne\\\".\",\n    \"Your role for this {{resourceType}}\": \"Vaša vloga pri tem {{resourceType}}\",\n    \"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}}.\": \"Ko odstranite svoj dostop, ga ne boste mogli vrniti brez pomoči druge osebe z zadostnim dostopom do spletnega mesta {{resourceType}}.\",\n    \"Close\": \"Zapri\",\n    \"Allow anyone with the link to open.\": \"Omogoči odprtje vsakemu, ki ima povezavo.\",\n    \"Invite people to {{resourceType}}\": \"Povabite ljudi k {{resourceType}}\",\n    \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Uporabnik podeduje dovoljenja od {{parent}}. Če jih želiš odstraniti, nastavi možnost \\\"Podeduje dostop\\\" na \\\"Ne\\\".\",\n    \"Remove my access\": \"Odstranitev mojega dostopa\",\n    \"Public access\": \"Javni dostop\",\n    \"Cancel\": \"Prekliči\",\n    \"Grist support\": \"Grist podpora\",\n    \"You are about to remove your own access to this {{resourceType}}\": \"Odstranili boste svoj dostop do tega {{resourceType}}\",\n    \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Uporabnik podeduje dovoljenja od {{parent}}. Če jih želiš odstraniti, nastavi možnost \\\"Podeduje dostop\\\" na \\\"Ni\\\".\",\n    \"Guest\": \"Gost\",\n    \"Invite multiple\": \"Povabite več\",\n    \"Confirm\": \"Potrdite\",\n    \"On\": \"Vklopljeno\",\n    \"Open Access Rules\": \"Pravila odprtega dostopa\",\n    \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Brez privzetega dostopa omogoča dostop do posameznih dokumentov ali delovnih prostorov in ne do celotnega spletnega mesta ekipe.\",\n    \"Manage members of team site\": \"Upravljanje članov ekipe\",\n    \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Brez privzetega dostopa omogoča dostop do posameznih dokumentov ali delovnih prostorov in ne do celotnega spletnega mesta ekipe.\",\n    \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} od {{limitTop}} {{collaborator}}\"\n  },\n  \"GristDoc\": {\n    \"go to webhook settings\": \"pojdite v nastavitve webhook\",\n    \"Saved linked section {{title}} in view {{name}}\": \"Shranjeno povezano poglavje {{title}} v pogledu {{name}}\",\n    \"Added new linked section to view {{viewName}}\": \"Dodan nov povezan razdelek za ogled {{viewName}}\",\n    \"Import from file\": \"Uvoz iz datoteke\"\n  },\n  \"Importer\": {\n    \"Merge rows that match these fields:\": \"Združite vrstice, ki ustrezajo tem poljem:\",\n    \"Column mapping\": \"Preslikava stolpcev\",\n    \"Grist column\": \"Grist stolpec\",\n    \"{{count}} unmatched field_one\": \"{{count}} neusklajeno polje\",\n    \"{{count}} unmatched field in import_one\": \"{{count}} neusklajenih polj v uvozu\",\n    \"Revert\": \"Povrni\",\n    \"Skip Import\": \"Preskoči uvoz\",\n    \"{{count}} unmatched field_other\": \"{{count}} neusklajena polja\",\n    \"Select fields to match on\": \"Izberite polja, ki se ujemajo z\",\n    \"New Table\": \"Nova tabela\",\n    \"Skip\": \"Preskoči\",\n    \"Column Mapping\": \"Preslikava stolpcev\",\n    \"Destination table\": \"Ciljna tabela\",\n    \"Skip Table on Import\": \"Preskoči tabelo pri uvozu\",\n    \"Import from file\": \"Uvoz iz datoteke\",\n    \"{{count}} unmatched field in import_other\": \"{{count}} neusklajenih polj v uvozu\",\n    \"Update existing records\": \"Posodobi obstoječe zapise\",\n    \"Source column\": \"Stolpec vira\"\n  },\n  \"buildViewSectionDom\": {\n    \"Not all data is shown\": \"Vsi podatki niso prikazani\",\n    \"No row selected in {{title}}\": \"Nobena vrstica ni izbrana v {{title}}\",\n    \"No data\": \"Ni podatkov\"\n  },\n  \"ColumnTitle\": {\n    \"Column ID copied to clipboard\": \"ID stolpca kopiran v odložišče\",\n    \"Add description\": \"Dodaj opis\",\n    \"Column description\": \"Opis stolpca\",\n    \"Provide a column label\": \"Navedite oznako stolpca\",\n    \"Close\": \"Zapri\",\n    \"Cancel\": \"Prekliči\",\n    \"Column label\": \"Oznaka stolpca\",\n    \"Save\": \"Shrani\",\n    \"COLUMN ID: \": \"ID STOLPCA: \"\n  },\n  \"ViewConfigTab\": {\n    \"Section: \": \"Oddelek: \",\n    \"Form\": \"Obrazec\",\n    \"Blocks\": \"Bloki\",\n    \"Plugin: \": \"Vtičnik: \",\n    \"Unmark On-Demand\": \"Odznači na zahtevo\",\n    \"Compact\": \"Kompakten\",\n    \"Advanced settings\": \"Napredne nastavitve\",\n    \"Make On-Demand\": \"Naredite na zahtevo\",\n    \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Velike tabele so lahko označene kot \\\"na zahtevo\\\", da se izognete njihovemu nalaganju v podatkovni pogon.\",\n    \"Edit card layout\": \"Uredi postavitev kartice\"\n  },\n  \"SupportGristNudge\": {\n    \"Support Grist\": \"Podpri Grist\",\n    \"Close\": \"Zapri\",\n    \"Opt in to Telemetry\": \"Prijava na telemetrijo\",\n    \"Help Center\": \"Center za pomoč\",\n    \"Contribute\": \"Prispevajte\",\n    \"Support Grist page\": \"Grist podpora\",\n    \"Opted In\": \"Prijavljeno\",\n    \"Admin Panel\": \"Skrbniški panel\",\n    \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Hvala ti! Tvoje zaupanje in podporo zelo cenimo. Kadar koli se odjavi na {{link}} v uporabniškem meniju.\"\n  },\n  \"HomeIntro\": {\n    \"personal site\": \"osebna stran\",\n    \"Any documents created in this site will appear here.\": \"Vsi dokumenti, ustvarjeni na tem spletnem mestu, bodo prikazani tukaj.\",\n    \"Welcome to Grist, {{- name}}!\": \"Dobrodošli v Gristu, {{-name}}!\",\n    \"Get started by inviting your team and creating your first Grist document.\": \"Začnite tako, da povabite svojo ekipo in ustvarite prvi Grist dokument .\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Do tega spletnega mesta imate dostop samo za branje. Trenutno ni dokumentov.\",\n    \"Help Center\": \"Center za pomoč\",\n    \"Interested in using Grist outside of your team? Visit your free \": \"Želite uporabljati Grist tudi zunaj svoje ekipe? Obiščite svoj brezplačni \",\n    \"Get started by creating your first Grist document.\": \"Začni z ustvarjanjem prvega Grist dokumenta .\",\n    \"This workspace is empty.\": \"Ta delovni prostor je prazen.\",\n    \"Visit our {{link}} to learn more.\": \"Za več informacij obiščite našo spletno stran {{link}}.\",\n    \"{{signUp}} to save your work. \": \"{{signUp}} da shranite svoje delo. \",\n    \"Welcome to {{- orgName}}\": \"Dobrodošli v {{-orgName}}\",\n    \"Welcome to Grist, {{name}}!\": \"Dobrodošli v Gristu, {{name}}!\",\n    \"Browse Templates\": \"Brskanje po predlogah\",\n    \"Welcome to {{orgName}}\": \"Dobrodošli v {{orgName}}\",\n    \"Invite Team Members\": \"Povabite člane ekipe\",\n    \"Get started by exploring templates, or creating your first Grist document.\": \"Začnite z raziskovanjem predlog ali ustvarjanjem prvega Grist dokumenta.\",\n    \"Import document\": \"Uvozi dokument\",\n    \"Create empty document\": \"Ustvari prazen dokument\",\n    \"Sign up\": \"Prijavi se\",\n    \"Sprouts Program\": \"Program Sprouts\",\n    \"Welcome to Grist!\": \"Dobrodošli v Gristu!\",\n    \"Visit our {{link}} to learn more about Grist.\": \"Obiščite našo spletno stran {{link}} da izveste več o Grisstu.\",\n    \"Sign in\": \"Prijavi se\",\n    \"To use Grist, please either sign up or sign in.\": \"Če želiš uporabljati Grist, se prijavi ali prvič prijavi.\",\n    \"Learn more in our {{helpCenterLink}}.\": \"Izvedi več v našem {{helpCenterLink}}.\",\n    \"Only show documents\": \"Pokaži samo dokumente\"\n  },\n  \"WelcomeSitePicker\": {\n    \"You have access to the following Grist sites.\": \"Imate dostop do naslednjih Grist spletnih mest .\",\n    \"Welcome back\": \"Dobrodošli nazaj\",\n    \"You can always switch sites using the account menu.\": \"Spletna mesta lahko vedno zamenjate v meniju računa.\"\n  },\n  \"ShareMenu\": {\n    \"Current Version\": \"Trenutna različica\",\n    \"Download...\": \"Prenesi...\",\n    \"Show in folder\": \"Prikaži v mapi\",\n    \"Share\": \"Deli\",\n    \"Export CSV\": \"Izvozi CSV\",\n    \"Send to Google Drive\": \"Pošlji v Google Drive\",\n    \"Export XLSX\": \"Izvozi XLSX\",\n    \"Access Details\": \"Podrobnosti o dostopu\",\n    \"Compare to {{termToUse}}\": \"Primerjaj z {{termToUse}}\",\n    \"Download\": \"Prenesi\",\n    \"Replace {{termToUse}}...\": \"Zamenjajte {{termToUse}}…\",\n    \"Duplicate document\": \"Podvoji dokument\",\n    \"Original\": \"Izvirnik\",\n    \"Back to current\": \"Nazaj na Aktualno\",\n    \"Edit without affecting the original\": \"Uredi brez vpliva na izvirnik\",\n    \"Work on a copy\": \"Delo na kopiji\",\n    \"Manage users\": \"Upravljanje uporabnikov\",\n    \"Unsaved\": \"Neshranjeno\",\n    \"Save Document\": \"Shrani dokument\",\n    \"Save copy\": \"Shrani kopijo\",\n    \"Return to {{termToUse}}\": \"Vrnitev na {{termToUse}}\",\n    \"Export as...\": \"Izvozi kot ...\",\n    \"Comma Separated Values (.csv)\": \"Vrednosti, ločene z vejicami (.csv)\",\n    \"DOO Separated Values (.dsv)\": \"DOO ločene vrednosti (.dsv)\",\n    \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n    \"Tab Separated Values (.tsv)\": \"Vrednosti, ločene s tabulatorji (.tsv)\"\n  },\n  \"NotifyUI\": {\n    \"Go to your free personal site\": \"Pojdite na brezplačno osebno spletno mesto\",\n    \"Upgrade Plan\": \"Načrt nadgradnje\",\n    \"Ask for help\": \"Zaprosi za pomoč\",\n    \"Renew\": \"Obnovite\",\n    \"Manage billing\": \"Upravljanje zaračunavanja\",\n    \"Give feedback\": \"Podajte povratne informacije\",\n    \"Cannot find personal site, sorry!\": \"Ne morem najti osebne strani, žal!\",\n    \"Notifications\": \"Obvestila\",\n    \"Report a problem\": \"Prijava težave\",\n    \"No notifications\": \"Brez obvestil\"\n  },\n  \"PermissionsWidget\": {\n    \"Deny all\": \"Zavrni vse\",\n    \"Read only\": \"Samo za branje\",\n    \"Allow all\": \"Dovoli vse\"\n  },\n  \"FieldContextMenu\": {\n    \"Hide field\": \"Skrij polje\",\n    \"Copy\": \"Kopiraj\",\n    \"Paste\": \"Prilepi\",\n    \"Clear field\": \"Počisti polje\",\n    \"Cut\": \"Izreži\",\n    \"Copy anchor link\": \"Kopiraj sidrno povezavo\"\n  },\n  \"RecordLayoutEditor\": {\n    \"Show field {{- label}}\": \"Prikaži polje {{- label}}\",\n    \"Add field\": \"Dodaj polje\",\n    \"Save layout\": \"Shrani postavitev\",\n    \"Cancel\": \"Prekliči\",\n    \"Create new field\": \"Ustvari novo polje\"\n  },\n  \"PageWidgetPicker\": {\n    \"Select widget\": \"Izberite widget\",\n    \"Add to page\": \"Dodaj na stran\",\n    \"Select data\": \"Izberi podatke\",\n    \"Group by\": \"Grupiraj po\",\n    \"Building {{- label}} widget\": \"Gradnja gradnika {{- label}}\"\n  },\n  \"DescriptionTextArea\": {\n    \"DESCRIPTION\": \"OPIS\"\n  },\n  \"PluginScreen\": {\n    \"Import failed: \": \"Uvoz ni uspel: \"\n  },\n  \"FilterBar\": {\n    \"Search Columns\": \"Stolpci za iskanje\",\n    \"SearchColumns\": \"Stolpci za iskanje\"\n  },\n  \"GridView\": {\n    \"Click to insert\": \"Kliknite za vstavitev\"\n  },\n  \"RefSelect\": {\n    \"No columns to add\": \"Ni stolpcev za dodajanje\",\n    \"Add column\": \"Dodaj stolpec\"\n  },\n  \"SortFilterConfig\": {\n    \"Update Sort & Filter settings\": \"Posodobi nastavitve sortiranja in filtriranja\",\n    \"Save\": \"Shrani\",\n    \"Sort\": \"SORT\",\n    \"Filter\": \"FILTER\",\n    \"Revert\": \"Povrni\"\n  },\n  \"OpenVideoTour\": {\n    \"Video Tour\": \"Video ogled\",\n    \"YouTube video player\": \"Predvajalnik videoposnetkov YouTube\",\n    \"Grist Video Tour\": \"Grist video ogled\"\n  },\n  \"LeftPanelCommon\": {\n    \"Help Center\": \"Center za pomoč\"\n  },\n  \"searchDropdown\": {\n    \"Search\": \"Iskanje\"\n  },\n  \"SearchModel\": {\n    \"Search all tables\": \"Iskanje po vseh tabelah\",\n    \"Search all pages\": \"Iskanje po vseh straneh\"\n  },\n  \"ThemeConfig\": {\n    \"Appearance \": \"Videz \",\n    \"Switch appearance automatically to match system\": \"Samodejno preklopite videz, da se ujema s sistemom\"\n  },\n  \"SiteSwitcher\": {\n    \"Switch Sites\": \"Preklopi mesta\",\n    \"Create new team site\": \"Ustvarite novo spletno mesto ekipe\"\n  },\n  \"WebhookPage\": {\n    \"Clear queue\": \"Počisti čakalno vrsto\",\n    \"Webhook settings\": \"Nastavitve Webhook\",\n    \"Enabled\": \"Omogočeno\",\n    \"Memo\": \"Beležka\",\n    \"Name\": \"Ime\",\n    \"Ready Column\": \"Pripravljen stolpec\",\n    \"Removed webhook.\": \"Odstranjen webhook.\",\n    \"Sorry, not all fields can be edited.\": \"Žal vseh polj ni mogoče urejati.\",\n    \"Status\": \"Status\",\n    \"Table\": \"Tabela\",\n    \"Filter for changes in these columns (semicolon-separated ids)\": \"Filter za spremembe v teh stolpcih (id-ji, ločeni s podpičjem)\",\n    \"Cleared webhook queue.\": \"Čakalna vrsta webhook je počiščena.\",\n    \"URL\": \"URL\",\n    \"Webhook Id\": \"Webhook ID\",\n    \"Columns to check when update (separated by ;)\": \"Stolpci za preverjanje ob posodobitvi (ločeni z ;)\",\n    \"Event Types\": \"Vrste dogodkov\",\n    \"Header Authorization\": \"Pooblastilo za glavo\"\n  },\n  \"RecordLayout\": {\n    \"Updating record layout.\": \"Posodobitev postavitve zapisa.\"\n  },\n  \"SelectionSummary\": {\n    \"Copied to clipboard\": \"Kopirano v odložišče\"\n  },\n  \"DescriptionConfig\": {\n    \"DESCRIPTION\": \"OPIS\"\n  },\n  \"TriggerFormulas\": {\n    \"Any field\": \"Katero koli polje\",\n    \"Close\": \"Zapri\",\n    \"Cancel\": \"Prekliči\",\n    \"Apply on changes to:\": \"Uporabi pri spremembah:\",\n    \"Apply to new records\": \"Uporabi za nove zapise\",\n    \"OK\": \"V REDU\",\n    \"Current field \": \"Tekoče polje \",\n    \"Apply on record changes\": \"Uporabi pri spremembah zapisa\"\n  },\n  \"DocTour\": {\n    \"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.\": \"Iz podatkov v tem dokumentu ni mogoče sestaviti ogleda dokumenta. Prepričajte se, da obstaja tabela z imenom GristDocTour s stolpci Naslov, Telo, Umestitev in Lokacija.\",\n    \"No valid document tour\": \"Ni veljavne turneje dokumenta\"\n  },\n  \"ViewSectionMenu\": {\n    \"FILTER\": \"FILTER\",\n    \"(customized)\": \"(po meri)\",\n    \"Revert\": \"Povrni\",\n    \"Save\": \"Shrani\",\n    \"Custom options\": \"Možnosti po meri\",\n    \"SORT\": \"SORT\",\n    \"Update Sort&Filter settings\": \"Posodobi nastavitve sortiranja in filtriranja\",\n    \"(empty)\": \"(prazno)\",\n    \"(modified)\": \"(spremenjeno)\"\n  },\n  \"ValidationPanel\": {\n    \"Update formula (Shift+Enter)\": \"Posodobitev formule (Shift+Enter)\",\n    \"Rule {{length}}\": \"Pravilo {{length}}\"\n  },\n  \"TopBar\": {\n    \"Manage team\": \"Uredi ekipo\"\n  },\n  \"UserManagerModel\": {\n    \"View & edit\": \"Ogled in urejanje\",\n    \"Owner\": \"Lastnik\",\n    \"None\": \"Noben\",\n    \"View only\": \"Samo pogled\",\n    \"No Default Access\": \"Brez privzetega dostopa\",\n    \"In full\": \"V celoti\",\n    \"Viewer\": \"Pregledovalnik\",\n    \"Editor\": \"Editor\"\n  },\n  \"ViewAsBanner\": {\n    \"UnknownUser\": \"Neznani uporabnik\"\n  },\n  \"VisibleFieldsConfig\": {\n    \"Cannot drop items into Hidden Fields\": \"Ne morete spustiti predmetov v skrita polja\",\n    \"Hidden Fields cannot be reordered\": \"Skritih polj ni mogoče preurediti\",\n    \"Visible {{label}}\": \"Vidno {{label}}\",\n    \"Select all\": \"Izberi vse\",\n    \"Hide {{label}}\": \"Skrij {{label}}\",\n    \"Clear\": \"Izbriši\",\n    \"Show {{label}}\": \"Pokaži {{label}}\",\n    \"Hidden {{label}}\": \"Skrito {{label}}\"\n  },\n  \"TypeTransformation\": {\n    \"Revise\": \"Revizija\",\n    \"Update formula (Shift+Enter)\": \"Posodobitev formule (Shift+Enter)\",\n    \"Preview\": \"Predogled\",\n    \"Apply\": \"Uporabi\",\n    \"Cancel\": \"Prekliči\"\n  },\n  \"menus\": {\n    \"Integer\": \"Celo število\",\n    \"Text\": \"Tekst\",\n    \"Attachment\": \"Priponka\",\n    \"Choice\": \"Izbira\",\n    \"Select fields\": \"Izberi polja\",\n    \"Reference\": \"Referenca\",\n    \"Numeric\": \"Numeričen\",\n    \"Date\": \"Datum\",\n    \"Reference List\": \"Referenčni seznam\",\n    \"* Workspaces are available on team plans. \": \"* Delovni prostori so na voljo v skupinskih načrtih. \",\n    \"Upgrade now\": \"Nadgradi zdaj\",\n    \"Toggle\": \"Preklop\",\n    \"Choice List\": \"Izbirni seznam\",\n    \"Any\": \"Katerikoli\",\n    \"DateTime\": \"Datum čas\",\n    \"Search columns\": \"Iskalni stolpci\"\n  },\n  \"errorPages\": {\n    \"Something went wrong\": \"Nekaj je šlo narobe\",\n    \"Sign in\": \"Prijavi se\",\n    \"Add account\": \"Dodaj račun\",\n    \"Signed out{{suffix}}\": \"Odjavljen{{suffix}}\",\n    \"Sign in again\": \"Ponovno se prijavi\",\n    \"There was an error: {{message}}\": \"Prišlo je do napake: {{message}}\",\n    \"Go to main page\": \"Pojdite na glavno stran\",\n    \"Access denied{{suffix}}\": \"Dostop zavrnjen{{suffix}}\",\n    \"There was an unknown error.\": \"Prišlo je do neznane napake.\",\n    \"You do not have access to this organization's documents.\": \"Nimate dostopa do dokumentov te organizacije.\",\n    \"You are now signed out.\": \"Zdaj ste odjavljeni.\",\n    \"Page not found{{suffix}}\": \"Stran ni najdena{{suffix}}\",\n    \"Error{{suffix}}\": \"Napaka{{suffix}}\",\n    \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Prijavljeni ste kot {{email}}. Lahko se prijavite z drugim računom ali prosite skrbnika za dostop.\",\n    \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Ni bilo mogoče najti zahtevane strani.{{separator}}Preverite URL in poskusite znova.\",\n    \"Sign in to access this organization's documents.\": \"Prijavite se za dostop do dokumentov te organizacije.\",\n    \"Contact support\": \"Obrnite se na podporo\",\n    \"Account deleted{{suffix}}\": \"Račun je izbrisan{{suffix}}\",\n    \"Your account has been deleted.\": \"Vaš račun je bil izbrisan.\",\n    \"Sign up\": \"Prijava\",\n    \"Build your own form\": \"Ustvari svoj obrazec\",\n    \"Powered by\": \"Poganja ga\",\n    \"An unknown error occurred.\": \"Prišlo je do neznane napake.\",\n    \"Form not found\": \"Ne najdem obrazca\",\n    \"Sign-in failed{{suffix}}\": \"Prijava ni uspela{{suffix}}\",\n    \"Failed to log in.{{separator}}Please try again or contact support.\": \"Prijava ni uspela.{{separator}}Poskusi znova ali se obrni na podporo.\"\n  },\n  \"WidgetTitle\": {\n    \"DATA TABLE NAME\": \"IME PODATKOVNE TABELE\",\n    \"Save\": \"Shrani\",\n    \"Override widget title\": \"Zamenjaj naslov pripomočka\",\n    \"WIDGET TITLE\": \"NASLOV PRIPOMOČKA\",\n    \"Cancel\": \"Prekliči\",\n    \"WIDGET DESCRIPTION\": \"OPIS PRIPOMOČKA\",\n    \"Provide a table name\": \"Podajte ime tabele\"\n  },\n  \"breadcrumbs\": {\n    \"snapshot\": \"posnetek\",\n    \"override\": \"preglasiti\",\n    \"unsaved\": \"neshranjeno\",\n    \"recovery mode\": \"obnovitveni način\",\n    \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Lahko jih uredite, vendar bodo ustvarili novo kopijo in ne bodo\\nvplivali na izvirni dokument.\",\n    \"fiddle\": \"poskus\"\n  },\n  \"WelcomeQuestions\": {\n    \"Welcome to Grist!\": \"Dobrodošli v Gristu!\",\n    \"HR & Management\": \"HR in management\",\n    \"Sales\": \"Prodaja\",\n    \"Marketing\": \"Prodaja\",\n    \"Type here\": \"Piši tukaj\",\n    \"Other\": \"Ostalo\",\n    \"Product Development\": \"Razvoj izdelkov\",\n    \"Media Production\": \"Medijska produkcija\",\n    \"What brings you to Grist? Please help us serve you better.\": \"Kaj te je pripeljalo v Grist? Prosimo, pomagajte nam, da vam bolje služimo.\",\n    \"Education\": \"izobraževanje\",\n    \"IT & Technology\": \"IT in tehnologija\",\n    \"Finance & Accounting\": \"Finance in računovodstvo\",\n    \"Research\": \"Raziskovanje\"\n  },\n  \"PagePanels\": {\n    \"Open creator panel\": \"Odprite panel za ustvarjalce\",\n    \"Close Creator Panel\": \"Zaprite panel za ustvarjalce\"\n  },\n  \"ColumnInfo\": {\n    \"Cancel\": \"Prekliči\",\n    \"COLUMN ID: \": \"ID STOLPCA: \",\n    \"Save\": \"Shrani\",\n    \"COLUMN DESCRIPTION\": \"OPIS STOLPCA\",\n    \"COLUMN LABEL\": \"OZNAKA STOLPCA\"\n  },\n  \"DiscussionEditor\": {\n    \"Show resolved comments\": \"Prikaži razrešene komentarje\",\n    \"Save\": \"Shrani\",\n    \"Reply to a comment\": \"Odgovorite na komentar\",\n    \"Comment\": \"Komentiraj\",\n    \"Started discussion\": \"Začeta razprava\",\n    \"Write a comment\": \"Napišite komentar\",\n    \"Cancel\": \"Prekliči\",\n    \"Only current page\": \"Samo trenutna stran\",\n    \"Reply\": \"Odgovori\",\n    \"Marked as resolved\": \"Označeno kot rešeno\",\n    \"Remove\": \"Odstrani\",\n    \"Open\": \"Odpri\",\n    \"Only my threads\": \"Samo moje niti\",\n    \"Edit\": \"Uredi\",\n    \"Resolve\": \"razreši\",\n    \"Showing last {{nb}} comments\": \"Prikaz zadnjih {{nb}} komentarjev\"\n  },\n  \"WelcomeTour\": {\n    \"Make it relational! Use the {{ref}} type to link tables. \": \"Naj bo relacijsko! Za povezovanje tabel uporabite tip {{ref}}. \",\n    \"Enter\": \"Vstopite\",\n    \"Use {{helpCenter}} for documentation or questions.\": \"Za dokumentacijo ali vprašanja uporabite {{helpCenter}}.\",\n    \"creator panel\": \"panel ustvarjalca\",\n    \"Sharing\": \"Skupna raba\",\n    \"Configuring your document\": \"Konfiguriranje vašega dokumenta\",\n    \"Reference\": \"Referenca\",\n    \"Editing Data\": \"Urejanje podatkov\",\n    \"template library\": \"knjižnica predlog\",\n    \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Uporabite {{addNew}} za dodajanje gradnikov, strani ali uvoz več podatkov. \",\n    \"Use the Share button ({{share}}) to share the document or export data.\": \"Za skupno rabo dokumenta ali izvoz podatkov uporabite gumb za skupno rabo ({{share}}).\",\n    \"Help Center\": \"Center za pomoč\",\n    \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Prebrskajte našo {{templateLibrary}}, da odkrijete, kaj je mogoče, in poiščite navdih.\",\n    \"Share\": \"Daj v skupno rabo\",\n    \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Nastavite možnosti oblikovanja, formule ali vrste stolpcev, kot so datumi, možnosti ali priloge. \",\n    \"Flying higher\": \"Leti višje\",\n    \"Customizing columns\": \"Prilagajanje stolpcev\",\n    \"Double-click or hit {{enter}} on a cell to edit it. \": \"Dvokliknite ali pritisnite {{enter}} na celico, da jo uredite. \",\n    \"Welcome to Grist!\": \"Dobrodošli v Gristu!\",\n    \"Add new\": \"Dodaj\",\n    \"Toggle the {{creatorPanel}} to format columns, \": \"Preklopite {{creatorPanel}} za oblikovanje stolpcev, \",\n    \"convert to card view, select data, and more.\": \"pretvorite v pogled kartice, izberite podatke in drugo.\",\n    \"Building up\": \"Gradnja\",\n    \"Start with {{equal}} to enter a formula.\": \"Začnite z {{equal}}, da vnesete formulo.\"\n  },\n  \"FieldBuilder\": {\n    \"Mixed format\": \"Mešan format\",\n    \"CELL FORMAT\": \"FORMAT CELICE\",\n    \"Mixed types\": \"Mešani tipi\",\n    \"DATA FROM TABLE\": \"PODATKI IZ TABELE\",\n    \"Revert field settings for {{colId}} to common\": \"Povrni nastavitve polja za {{colId}} na privzeto\",\n    \"Save field settings for {{colId}} as common\": \"Shrani nastavitve polja za {{colId}} kot običajne\",\n    \"Changing multiple column types\": \"Spreminjanje več tipov stolpca\",\n    \"Use separate field settings for {{colId}}\": \"Uporabite ločene nastavitve polja za {{colId}}\",\n    \"Apply formula to data\": \"Izvedi formulo nad podatki\",\n    \"Changing column type\": \"Spreminjanje vrste stolpca\"\n  },\n  \"FormulaEditor\": {\n    \"Enter formula or {{button}}.\": \"Vnesite formulo ali {{button}}.\",\n    \"Error in the cell\": \"Napaka v celici\",\n    \"Errors in {{numErrors}} of {{numCells}} cells\": \"Napake v {{numErrors}} od {{numCells}} celic\",\n    \"Enter formula.\": \"Vnesite formulo.\",\n    \"Column or field is required\": \"Obvezen je stolpec ali polje\",\n    \"Expand Editor\": \"Razširite urejevalnik\",\n    \"use AI Assistant\": \"uporabite AI Asistenta\",\n    \"Errors in all {{numErrors}} cells\": \"Napake v vseh {{numErrors}} celicah\",\n    \"editingFormula is required\": \"potrebno je urediti formulo\"\n  },\n  \"ChoiceTextBox\": {\n    \"CHOICES\": \"IZBIRE\"\n  },\n  \"CellStyle\": {\n    \"HEADER STYLE\": \"SLOG GLAVE\",\n    \"Header Style\": \"Slog glave\",\n    \"Default header style\": \"Privzeti slog glave\",\n    \"Mixed style\": \"Mešani slog\",\n    \"Default cell style\": \"Privzeti slog celice\",\n    \"CELL STYLE\": \"SLOG CELICE\",\n    \"Cell style\": \"Slog celice\",\n    \"Open row styles\": \"Odpri sloge vrstic\"\n  },\n  \"ConditionalStyle\": {\n    \"Rule must return True or False\": \"Pravilo mora vrniti True ali False\",\n    \"Row style\": \"Slog vrstice\",\n    \"Add another rule\": \"Dodaj dodatno pravilo\",\n    \"Add conditional style\": \"Dodaj pogojni slog\",\n    \"Error in style rule\": \"Napaka v slogovnem pravilu\",\n    \"IF...\": \"ČE ...\",\n    \"Conditional Style\": \"Pogojni slog\"\n  },\n  \"EditorTooltip\": {\n    \"Convert column to formula\": \"Pretvori stolpec v formulo\"\n  },\n  \"NTextBox\": {\n    \"false\": \"napačno\",\n    \"true\": \"pravilno\",\n    \"Lines\": \"Ćrte\",\n    \"Field Format\": \"Oblika polja\",\n    \"Multi line\": \"Več vrstic\",\n    \"Single line\": \"Ena vrstica\"\n  },\n  \"NumericTextBox\": {\n    \"Decimals\": \"Decimalke\",\n    \"Currency\": \"Valuta\",\n    \"Default currency ({{defaultCurrency}})\": \"Privzeta valuta ({{defaultCurrency}})\",\n    \"Number Format\": \"Format številke\",\n    \"Field Format\": \"Oblika polja\",\n    \"Spinner\": \"Spinner\",\n    \"Text\": \"Tekst\",\n    \"max\": \"maks\",\n    \"min\": \"min\"\n  },\n  \"Reference\": {\n    \"CELL FORMAT\": \"FORMAT CELICE\",\n    \"Row ID\": \"ID vrstice\",\n    \"SHOW COLUMN\": \"PRIKAŽI STOLPEC\"\n  },\n  \"HyperLinkEditor\": {\n    \"[link label] url\": \"[link label] URL\"\n  },\n  \"FloatingEditor\": {\n    \"Collapse Editor\": \"Strni urejevalnik\"\n  },\n  \"ColumnEditor\": {\n    \"COLUMN DESCRIPTION\": \"OPIS STOLPCA\",\n    \"COLUMN LABEL\": \"OZNAKA STOLPCA\"\n  },\n  \"ACLUsers\": {\n    \"View as\": \"Poglej kot\",\n    \"Example Users\": \"Primeri uporabnikov\",\n    \"Users from table\": \"Uporabniki iz tabele\"\n  },\n  \"TypeTransform\": {\n    \"Cancel\": \"Prekliči\",\n    \"Revise\": \"Revidiraj\",\n    \"Apply\": \"Uporabi\",\n    \"Update formula (Shift+Enter)\": \"Posodobi formulo (Shift+Enter)\",\n    \"Preview\": \"Predogled\"\n  },\n  \"LanguageMenu\": {\n    \"Language\": \"Jezik\"\n  },\n  \"duplicatePage\": {\n    \"Note that this does not copy data, but creates another view of the same data.\": \"Upoštevajte, da s tem ne kopirate podatkov, ampak ustvarite drug pogled istih podatkov.\",\n    \"Duplicate page {{pageName}}\": \"Podvoji stran {{pageName}}\"\n  },\n  \"CurrencyPicker\": {\n    \"Invalid currency\": \"Neveljavna valuta\"\n  },\n  \"modals\": {\n    \"Save\": \"Shrani\",\n    \"Cancel\": \"Prekliči\",\n    \"Ok\": \"v redu\",\n    \"Are you sure you want to delete this record?\": \"Ali si prepričan, da želiš izbrisati ta zapis?\",\n    \"Dismiss\": \"Opusti\",\n    \"Don't ask again.\": \"Ne sprašuj več.\",\n    \"Don't show again.\": \"Ne pokaži več.\",\n    \"Don't show tips\": \"Ne pokaži nasvetov\",\n    \"Undo to restore\": \"Razveljavi obnovitev\",\n    \"Got it\": \"Razumem\",\n    \"Don't show again\": \"Ne pokaži več\",\n    \"Are you sure you want to delete these records?\": \"Ali si prepričan, da želiš izbrisati te zapise?\",\n    \"Delete\": \"Briši\",\n    \"TIP\": \"NAMIG\"\n  },\n  \"sendToDrive\": {\n    \"Sending file to Google Drive\": \"Pošiljanje datoteke v Google Drive\"\n  },\n  \"CardContextMenu\": {\n    \"Insert card above\": \"Vstavi kartico zgoraj\",\n    \"Duplicate card\": \"Podvoji kartico\",\n    \"Insert card below\": \"Vstavi kartico spodaj\",\n    \"Delete card\": \"Briši kartico\",\n    \"Copy anchor link\": \"Kopiraj sidrno povezavo\",\n    \"Insert card\": \"Vstavi kartico\"\n  },\n  \"HiddenQuestionConfig\": {\n    \"Hidden fields\": \"Skrita polja\"\n  },\n  \"FormView\": {\n    \"Publish\": \"Objavi\",\n    \"Unpublish your form?\": \"Želiš preklicati objavo obrazca?\",\n    \"Publish your form?\": \"Želiš objaviti obrazec?\",\n    \"Unpublish\": \"Prekliči objavo\",\n    \"Link copied to clipboard\": \"Povezava je bila kopirana v odložišče\",\n    \"Preview\": \"Predogled\",\n    \"Reset\": \"Ponastaviti\",\n    \"Save your document to publish this form.\": \"Shrani dokument, da objaviš ta obrazec.\",\n    \"Share this form\": \"Deli ta dokument\",\n    \"View\": \"Pogled\",\n    \"Anyone with the link below can see the empty form and submit a response.\": \"Vsakdo s spodnjo povezavo si lahko ogleda prazen obrazec in odda odgovor.\",\n    \"Are you sure you want to reset your form?\": \"Ali si prepričan, da želiš ponastaviti obrazec?\",\n    \"Code copied to clipboard\": \"Koda je bila kopirana v odložišče\",\n    \"Copy code\": \"Kopiraj kodo\",\n    \"Copy link\": \"Kopiraj povezavo\",\n    \"Embed this form\": \"Vdelaj ta obrazec\",\n    \"Reset form\": \"Ponastavi obrazec\",\n    \"Share\": \"Deli\"\n  },\n  \"Menu\": {\n    \"Building blocks\": \"Gradniki\",\n    \"Columns\": \"Stolpci\",\n    \"Copy\": \"Kopiraj\",\n    \"Cut\": \"Izreži\",\n    \"Insert question above\": \"Vstavi vprašanje zgoraj\",\n    \"Paste\": \"Prilepi\",\n    \"Separator\": \"Ločilo\",\n    \"Unmapped fields\": \"Nepreslikana polja\",\n    \"Header\": \"Glava\",\n    \"Insert question below\": \"Vstavi vprašanje spodaj\",\n    \"Paragraph\": \"Odstavek\"\n  },\n  \"UnmappedFieldsConfig\": {\n    \"Clear\": \"Očisti\",\n    \"Mapped\": \"Preslikano\",\n    \"Select all\": \"Izberi vse\",\n    \"Map fields\": \"Preslikaj polja\",\n    \"Unmap fields\": \"Odstrani preslikavo polj\",\n    \"Unmapped\": \"Nepreslikan\"\n  },\n  \"Editor\": {\n    \"Delete\": \"Briši\"\n  },\n  \"WelcomeCoachingCall\": {\n    \"free coaching call\": \"brezplačen trenerski klic\",\n    \"Schedule call\": \"Načrtuj klic\",\n    \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Dogovori se za {{freeCoachingCall}} s članom naše ekipe.\",\n    \"Maybe later\": \"Mogoče kasneje\",\n    \"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.\": \"Med klicem si bomo vzeli čas, da bomo razumeli vaše potrebe in vam klic prilagodili. Lahko vam pokažemo osnove Grista ali pa takoj začnemo delati z vašimi podatki, da zgradimo nadzorne plošče, ki jih potrebujete.\"\n  },\n  \"FormConfig\": {\n    \"Required field\": \"Obvezno polje\",\n    \"Field rules\": \"Pravila polj\",\n    \"Descending\": \"Padajoče\",\n    \"Field Format\": \"Format polja\",\n    \"Field Rules\": \"Pravila polja\",\n    \"Horizontal\": \"Vodoraven\",\n    \"Options Alignment\": \"Poravnava možnosti\",\n    \"Options Sort Order\": \"Vrstni red Možnosti\",\n    \"Radio\": \"Radio\",\n    \"Select\": \"Izberi\",\n    \"Vertical\": \"Navpičen\",\n    \"Ascending\": \"Naraščajoče\",\n    \"Default\": \"Privzeto\"\n  },\n  \"CustomView\": {\n    \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Če želite uporabiti ta pripomoček, preslikajte vse neobvezne stolpce na plošči za ustvarjanje na desni.\",\n    \"Some required columns aren't mapped\": \"Nekateri zahtevani stolpci niso preslikani\"\n  },\n  \"FormContainer\": {\n    \"Build your own form\": \"Ustvari svoj obrazec\",\n    \"Powered by\": \"Poganja ga\"\n  },\n  \"FormErrorPage\": {\n    \"Error\": \"Napaka\"\n  },\n  \"FormModel\": {\n    \"Oops! The form you're looking for doesn't exist.\": \"Ups! Obrazec, ki ga iščeš, ne obstaja.\",\n    \"Oops! This form is no longer published.\": \"Ups! Ta obrazec ni več objavljen.\",\n    \"There was a problem loading the form.\": \"Pri nalaganju obrazca je prišlo do težave.\",\n    \"You don't have access to this form.\": \"Nimaš dostopa do tega obrazca.\"\n  },\n  \"FormPage\": {\n    \"There was an error submitting your form. Please try again.\": \"Pri pošiljanju obrazca je prišlo do napake. Prosim poskusi ponovno.\"\n  },\n  \"FormSuccessPage\": {\n    \"Form Submitted\": \"Obrazec oddan\",\n    \"Thank you! Your response has been recorded.\": \"Hvala ti! Tvoj odgovor je bil zabeležen.\",\n    \"Submit new response\": \"Predloži nov odgovor\"\n  },\n  \"DateRangeOptions\": {\n    \"Last 30 days\": \"Zadnjih 30 dni\",\n    \"Last 7 days\": \"Zadnjih 7 dni\",\n    \"Last week\": \"Zadnji teden\",\n    \"Next 7 days\": \"Naslednjih 7 dni\",\n    \"This month\": \"Ta mesec\",\n    \"This week\": \"Ta teden\",\n    \"This year\": \"To leto\",\n    \"Today\": \"Danes\"\n  },\n  \"MappedFieldsConfig\": {\n    \"Clear\": \"Počisti\",\n    \"Unmapped\": \"Nepreslikan\",\n    \"Map fields\": \"Preslikaj polja\",\n    \"Mapped\": \"Preslikan\",\n    \"Select all\": \"Izberi vse\",\n    \"Unmap fields\": \"Nepreslikana polja\"\n  },\n  \"Section\": {\n    \"Insert section above\": \"Vstavi razdelek zgoraj\",\n    \"Insert section below\": \"Vstavite razdelek spodaj\"\n  },\n  \"AdminPanel\": {\n    \"Help us make Grist better\": \"Pomagaj nam izboljšati Grist\",\n    \"Home\": \"Domov\",\n    \"Admin Panel\": \"Skrbniški panel\",\n    \"Current\": \"Trenutno\",\n    \"Current version of Grist\": \"Trenutna različica Grista\",\n    \"Sponsor\": \"Sponzor\",\n    \"Support Grist\": \"Podpora Gristu\",\n    \"Telemetry\": \"Telemetrija\",\n    \"Support Grist Labs on GitHub\": \"Podpri Grist Labs na GitHubu\",\n    \"Version\": \"Verzija\",\n    \"Auto-check when this page loads\": \"Samodejno preveri, ko se ta stran naloži\",\n    \"Check now\": \"Preveri\",\n    \"Checking for updates...\": \"Iskanje posodobitev ...\",\n    \"Error\": \"Napaka\",\n    \"Error checking for updates\": \"Napaka pri preverjanju posodobitev\",\n    \"Grist is up to date\": \"Grist je posodobljen\",\n    \"Last checked {{time}}\": \"Nazadnje preverjeno {{time}}\",\n    \"Learn more.\": \"Nauči se več.\",\n    \"Newer version available\": \"Na voljo je novejša različica\",\n    \"No information available\": \"Podatki niso na voljo\",\n    \"OK\": \"OK\",\n    \"Sandbox settings for data engine\": \"Nastavitve peskovnika za podatkovni mehanizem\",\n    \"Sandboxing\": \"Peskovnik\",\n    \"Security Settings\": \"Varnostne nastavitve\",\n    \"Updates\": \"Posodobitve\",\n    \"unconfigured\": \"nekonfigurirano\",\n    \"unknown\": \"neznano\",\n    \"Grist releases are at \": \"Grist verzije so pri \",\n    \"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 omogoča zelo zmogljive formule z uporabo Pythona. Priporočamo, da spremenljivko okolja GRIST_SANDBOX_FLAVOR nastavite na gvisor, če vaša strojna oprema to podpira (večina bo), da zaženete formule v vsakem dokumentu znotraj peskovnika, izoliranega od drugih dokumentov in izoliranega od omrežja.\",\n    \"Authentication\": \"Preverjanje pristnosti\",\n    \"Check succeeded.\": \"Preverjanje uspelo.\",\n    \"Current authentication method\": \"Trenutna metoda preverjanja pristnosti\",\n    \"Details\": \"Podrobnosti\",\n    \"No fault detected.\": \"Ni zaznane napake.\",\n    \"Notes\": \"Opombe\",\n    \"Check failed.\": \"Preverjanje ni uspelo.\",\n    \"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.\",\n    \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Lahko pa kot nadomestno možnost nastavite: {{bootKey}} v okolju in obiščete: {{url}}\",\n    \"Results\": \"Rezultati\",\n    \"Self Checks\": \"Samopregledi\",\n    \"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.\",\n    \"Administrator Panel Unavailable\": \"Skrbniška plošča ni na voljo\",\n    \"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.\",\n    \"Key to sign sessions with\": \"Ključ za podpisovanje sej\",\n    \"Session Secret\": \"Skrivnost seje\",\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 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.\",\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 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.\",\n    \"Enable Grist Enterprise\": \"Omogoči Grist Enterprise\",\n    \"Enterprise\": \"Podjetje\",\n    \"checking\": \"preverjanje\"\n  },\n  \"ChoiceEditor\": {\n    \"Error in dropdown condition\": \"Napaka v spustnem meniju\",\n    \"No choices matching condition\": \"Ni izbire, ki se ujema z pogojem\",\n    \"No choices to select\": \"Ni izbire\"\n  },\n  \"ChoiceListEditor\": {\n    \"Error in dropdown condition\": \"Napaka v spustnem meniju\",\n    \"No choices matching condition\": \"Ni izbire za dane pogoje\",\n    \"No choices to select\": \"Ni izbire\"\n  },\n  \"CreateTeamModal\": {\n    \"Cancel\": \"Prekliči\",\n    \"Billing is not supported in grist-core\": \"Zaračunavanje ni podprto v grist-core\",\n    \"Choose a name and url for your team site\": \"Izberi ime in url za spletno mesto tvoje ekipe\",\n    \"Create site\": \"Ustvari spletno mesto\",\n    \"Domain name is invalid\": \"Ime domene ni veljavno\",\n    \"Domain name is required\": \"Ime domene je obvezno\",\n    \"Go to your site\": \"Pojdi na svoje spletno mesto\",\n    \"Team name\": \"Ime ekipe\",\n    \"Team name is required\": \"Potrebno je ime ekipe\",\n    \"Team site created\": \"Spletno mesto ekipe je ustvarjeno\",\n    \"Team url\": \"URL ekipe\",\n    \"Work as a Team\": \"Delajte kot ekipa\"\n  },\n  \"Field\": {\n    \"No values in show column of referenced table\": \"Ni vrednosti v prikaznem stolpcu referenčne tabele\",\n    \"No choices configured\": \"Ni konfiguriranih izbir\"\n  },\n  \"Toggle\": {\n    \"Field Format\": \"Format Polja\",\n    \"Switch\": \"Stikalo\",\n    \"Checkbox\": \"Potrditveno polje\"\n  },\n  \"Columns\": {\n    \"Remove Column\": \"Odstrani stolpec\"\n  },\n  \"DropdownConditionEditor\": {\n    \"Enter condition.\": \"Vnesi pogoj\"\n  },\n  \"ReferenceUtils\": {\n    \"Error in dropdown condition\": \"Napaka v spustnem pogoju\",\n    \"No choices to select\": \"Ni izbire\",\n    \"No choices matching condition\": \"Ni izbora, ki ustreza pogoju\"\n  },\n  \"DropdownConditionConfig\": {\n    \"Set dropdown condition\": \"Nastqavi dropdown pogoj\",\n    \"Dropdown Condition\": \"Pogoj za spustni menu\",\n    \"Invalid columns: {{colIds}}\": \"Neveljavni stolpci: {{colIds}}\"\n  },\n  \"FormRenderer\": {\n    \"Search\": \"Poišči\",\n    \"Select...\": \"Izberi\",\n    \"Submit\": \"Predloži\",\n    \"Reset\": \"Ponastavi\"\n  },\n  \"widgetTypesMap\": {\n    \"Calendar\": \"Koledar\",\n    \"Card\": \"Kartica\",\n    \"Card List\": \"Seznam kartic\",\n    \"Chart\": \"grafikon\",\n    \"Custom\": \"Po meri\",\n    \"Form\": \"Forma\",\n    \"Table\": \"Tabela\"\n  },\n  \"TimingPage\": {\n    \"Loading timing data. Don't close this tab.\": \"Nalaganje časovnih podatkov. Ne zapri tega zavihka.\",\n    \"Max Time (s)\": \"Največji čas (i)\",\n    \"Number of Calls\": \"Število klicev\",\n    \"Table ID\": \"ID tabele\",\n    \"Total Time (s)\": \"Skupni čas\",\n    \"Average Time (s)\": \"Povprečni čas (i)\",\n    \"Column ID\": \"ID stolpca\",\n    \"Formula timer\": \"Časovnik formule\"\n  },\n  \"DocTutorial\": {\n    \"Click to expand\": \"Kliknite za razširitev\",\n    \"Do you want to restart the tutorial? All progress will be lost.\": \"Ali želite znova zagnati učbenik? Ves napredek bo izgubljen.\",\n    \"End tutorial\": \"Končaj vadnico\",\n    \"Finish\": \"Končaj\",\n    \"Next\": \"Naslednji\",\n    \"Previous\": \"Prejšnji\",\n    \"Restart\": \"Ponovni zagon\"\n  },\n  \"OnboardingCards\": {\n    \"3 minute video tour\": \"3 minutni video ogled\",\n    \"Complete our basics tutorial\": \"Dokončaj našo vadnico o osnovah\",\n    \"Complete the tutorial\": \"Dokončaj vadnico\",\n    \"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.\",\n    \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Naučite se osnov referenčnih stolpcev, povezanih pripomočkov, vrst stolpcev in kartic.\"\n  },\n  \"OnboardingPage\": {\n    \"Back\": \"Nazaj\",\n    \"Next step\": \"Naslednji korak\",\n    \"Skip step\": \"Preskoči korak\",\n    \"Skip tutorial\": \"Preskoči vadnico\",\n    \"Tell us who you are\": \"Povej nam, kdo si\",\n    \"Type here\": \"Piši tukaj\",\n    \"Welcome\": \"Dobrodošel\",\n    \"What brings you to Grist (you can select multiple)?\": \"Kaj te je pripeljalo do Grista (lahko izbereš več)?\",\n    \"What is your role?\": \"Kakšna je tvoja vloga?\",\n    \"What organization are you with?\": \"V kateri organizaciji si?\",\n    \"Your organization\": \"Tvoja organizacija\",\n    \"Your role\": \"Tvoja vloga\",\n    \"Go hands-on with the Grist Basics tutorial\": \"Preizkusi vadnico Osnove Grista\",\n    \"Discover Grist in 3 minutes\": \"Odkrij Grist v 3 minutah\",\n    \"Go to the tutorial!\": \"Pojdi na vadnico!\"\n  },\n  \"ToggleEnterpriseWidget\": {\n    \"Disable Grist Enterprise\": \"Onemogoči Grist Enterprise\",\n    \"Enable Grist Enterprise\": \"Omogoči Grist Enterprise\",\n    \"Grist Enterprise is **enabled**.\": \"Grist Enterprise je **omogočen**.\",\n    \"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}}).\"\n  },\n  \"ViewLayout\": {\n    \"Delete\": \"Briši\",\n    \"Delete data and this widget.\": \"Izbriši podatke in ta pripomoček.\",\n    \"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}}\",\n    \"Table {{tableName}} will no longer be visible\": \"Tabela {{tableName}} ne bo več vidna\",\n    \"Raw Data page\": \"stran z neobdelanimi podatki\"\n  },\n  \"AdminPanelName\": {\n    \"Admin Panel\": \"Skrbniška plošča\"\n  },\n  \"CustomWidgetGallery\": {\n    \"(Missing info)\": \"(Manjkajo informacije)\",\n    \"Add widget\": \"Dodaj pripomoček\",\n    \"Add Your Own Widget\": \"Dodajte svoj pripomoček\",\n    \"Add a widget from outside this gallery.\": \"Dodaj pripomoček zunaj te galerije.\",\n    \"Cancel\": \"Prekliči\",\n    \"Change widget\": \"Spremeni pripomoček\",\n    \"Developer:\": \"razvijalec:\",\n    \"Grist Widget\": \"Grist Pripomoček\",\n    \"Learn more about custom widgets\": \"Izvedite več o pripomočkih po meri\",\n    \"No matching widgets\": \"Ni ujemajočih se pripomočkov\",\n    \"Search\": \"Iskanje\",\n    \"Widget URL\": \"URL pripomočka\",\n    \"Choose custom widget\": \"Izberite Pripomoček po meri\",\n    \"Community Widget\": \"Pripomoček skupnosti\",\n    \"Custom URL\": \"URL po meri\",\n    \"Last updated:\": \"Zadnja posodobitev:\"\n  },\n  \"markdown\": {\n    \"The toggle is **off**\": \"Preklop je **izklopljen**\",\n    \"The toggle is **on**\": \"Preklop je **vklopljen**\",\n    \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n      \"\": {\n        \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nova funkcija Markdown\\n *\\n * Lahko _napišemo_ [običajni Markdown](https://markdownguide.org) *znotraj*\\n * element Grainjs.\"\n      }\n    }\n  },\n  \"markdown.d\": {\n    \"The toggle is **on**\": \"Preklop je **vklopljen**\",\n    \"The toggle is **off**\": \"Preklop je **izklopljen**\",\n    \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n      \"\": {\n        \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Nova funkcija Markdown\\n *\\n * Lahko _napišemo_ [običajni Markdown](https://markdownguide.org) *znotraj*\\n * element Grainjs.\"\n      }\n    }\n  },\n  \"HomeIntroCards\": {\n    \"3 minute video tour\": \"3 minutni video ogled\",\n    \"Blank document\": \"Prazen dokument\",\n    \"Finish our basics tutorial\": \"Dokončaj našo vadnico o osnovah\",\n    \"Learn more {{webinarsLinks}}\": \"Več o tem {{webinarsLinks}}\",\n    \"Start a new document\": \"Začni nov dokument\",\n    \"Templates\": \"Predloge\",\n    \"Tutorial\": \"Vadnica\",\n    \"Find solutions and explore more resources {{helpCenterLink}}\": \"Poiščite rešitve in raziščite več virov {{helpCenterLink}}\",\n    \"Help center\": \"Center za pomoč\",\n    \"Import file\": \"Uvozi datoteko\",\n    \"Webinars\": \"Spletni seminarji\"\n  },\n  \"ReverseReferenceConfig\": {\n    \"Add two-way reference\": \"Dodaj dvosmerno referenco\",\n    \"Delete column {{column}} in table {{table}}?\": \"Želiš izbrisati stolpec {{column}} v tabeli {{table}}?\",\n    \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Je obratna stran referenčnega stolpca {{column}} v tabeli {{table}}.\",\n    \"Two-way Reference\": \"Dvosmerna referenca\",\n    \"Delete two-way reference?\": \"Želiš izbrisati dvosmerno referenco?\",\n    \"Target table\": \"Ciljna tabela\",\n    \"Column\": \"Stolpec\",\n    \"Delete\": \"Izbriši\",\n    \"Table\": \"Tabela\"\n  },\n  \"SupportGristButton\": {\n    \"Admin Panel\": \"Skrbniška plošča\",\n    \"Close\": \"Zapri\",\n    \"Help Center\": \"Center za pomoč\",\n    \"Opted In\": \"Omogočeno\",\n    \"Opt in to Telemetry\": \"Omogoči uporabo telemetrije\",\n    \"Support Grist\": \"Podpora Gristu\",\n    \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Hvala! Vaše zaupanje in podporo zelo cenimo. Kadar koli se odjavite na {{link}} v uporabniškem meniju.\"\n  },\n  \"buildReassignModal\": {\n    \"Cancel\": \"Prekliči\",\n    \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Vsak zapis {{targetTable}} je lahko dodeljen samo enemu zapisu {{sourceTable}}.\",\n    \"Reassign\": \"Prerazporedi\",\n    \"Reassign to new {{sourceTable}} records.\": \"Ponovno dodelite novim zapisom {{sourceTable}}.\",\n    \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Znova dodeli zapisu {{sourceTable}} {{sourceName}}.\",\n    \"Record already assigned_one\": \"Zapis je že dodeljen\",\n    \"Record already assigned_other\": \"Zapis je že dodeljen\",\n    \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"Zapis {{targetTable}} {{targetName}} je že dodeljen zapisu {{sourceTable}} {{oldSourceName}}.\"\n  }\n}\n"
  },
  {
    "path": "static/locales/sl.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/sv.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Enter email address\": \"Ange e-postadress\",\n        \"Invite new member\": \"Bjud in ny medlem\",\n        \"We'll email an invite to {{email}}\": \"Vi kommer att skicka en inbjudan till {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Add column rule\": \"Lägg till kolumnregel\",\n        \"Add Default Rule\": \"Lägg till standardregel\",\n        \"Add table rules\": \"Lägg till tabellregler\",\n        \"Add user attributes\": \"Lägg till användarattribut\",\n        \"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.\": \"Tillåt alla att kopiera hela dokumentet, eller visa hela dokumentet i experimentläge.\\nAnvändbart för exempel och mallar, men inte för känslig data.\",\n        \"Allow everyone to view Access Rules.\": \"Tillåt vem som helst att se åtkomstregler.\",\n        \"Attribute name\": \"Attributnamn\",\n        \"Attribute to Look Up\": \"Attribut att slå upp\",\n        \"Checking...\": \"Kontrollerar…\",\n        \"Condition\": \"Villkor\",\n        \"Default rules\": \"Standardregler\",\n        \"Delete table rules\": \"Radera tabell regler\",\n        \"Enter Condition\": \"Ange villkor\",\n        \"Everyone\": \"Alla\",\n        \"Everyone Else\": \"Alla andra\",\n        \"Invalid\": \"Ogiltig\",\n        \"Lookup Column\": \"Slå upp Kollumn\",\n        \"Lookup Table\": \"Slå upp tabell\",\n        \"Permission to access the document in full when needed\": \"Behörighet att få tillgång till dokumentet i sin helhet vid behov\",\n        \"Permission to view Access Rules\": \"Behörighet att visa åtkomstregler\",\n        \"Permissions\": \"Behörigheter\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Radera kolumn {{- colId }} från regler för {{- tableId }}\",\n        \"Remove {{- tableId }} rules\": \"Radera reglerna för {{- tableId }}\",\n        \"Remove {{- name }} user attribute\": \"Radera användarattrubutet {{- name }}\",\n        \"Reset\": \"Återställ\",\n        \"Rules for table \": \"Regler för tabell \",\n        \"Save\": \"Spara\",\n        \"Saved\": \"Sparad\",\n        \"Special rules\": \"Specialregler\",\n        \"Type message to display when this rule blocks an action…\": \"Skriv meddelande att visa när denna regel hindrar en åtgärd…\",\n        \"User Attributes\": \"Användarattribut\",\n        \"View as\": \"Visa som\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Vid tilläg av regler för tabell, lägg automatiskt till en regel som ger OWNER fullständiga rättigheter.\",\n        \"Permission to edit document structure\": \"Behörighet att redigera dokumentstrukturen\",\n        \"This default should be changed if editors' access is to be limited. \": \"Detta standardval skall användas om redigerarnas tillgång ska begränsas. \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Tillåt redaktörer att redigera struktur (som ändra och radera tabeller, kolumner och layouter) och skriva formler. Oavsett behörigheten på tabell och kolumn nivå, så kan formler fortfarande regieras och komma åt alla data.\",\n        \"Add table-wide rule\": \"Lägg till regel för hela tabellen\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Behörighets reglerna har ändrats. Tryck på Återställ för att ångra dina ändringar och ladda om reglerna.\",\n        \"All\": \"Alla\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"Kolumnen {{colId}} finns i flera regler för tabellen {{tableId}} som kan vara beroende av ordningen. Kan du dela upp reglerna på ett annat sätt?\",\n        \"Columns\": \"Kolumner\",\n        \"Condition cannot be blank\": \"Villkoret kan inte var tomt\",\n        \"Default resource missing in resource map\": \"Standard resurs saknas i resurs listan\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Felaktiga kolumner i tabellen {{tableId}}: {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"Felaktig tabell: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Felaktig användarattributsregel: {{prop}} måste vara satt\",\n        \"Invalid user attribute to look up\": \"Felaktigt användarattribut att slå upp\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"Inga kolumner listade i kolumn regel för tabell {{tableId}}\",\n        \"Not a valid user attribute\": \"Inte ett giltigt användarattribut\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Resursen saknas i resurslistan: {{resourceKey}}\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"Försöker att lägga till Tabellregler för existerande tabell {{tableId}}\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"Använd ett enkelt attribut i user.LinkKey, som user.LinkKey.ennyckel\",\n        \"hidden\": \"dold\",\n        \"Allow everyone to view access rules.\": \"Tillåt alla att visa behörighets regler.\",\n        \"Continue\": \"Fortsätt\",\n        \"Disable Access Rules\": \"Avaktivera behörighetsregler\",\n        \"Disable and save\": \"Avaktivera och spara\",\n        \"Enable Access Rules\": \"Aktivera behörighetsregler\",\n        \"Special rules for templates\": \"Specialregler för mallar\",\n        \"This options should be off if Editors' access is to be limited. \": \"Denna inställning skall vara av om behörigheten för Redigerare skall begränsas. \",\n        \"Seed rules\": \"Delningsregler\",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Behörighetsregler\\n\\nGrundläggande tillgång till detta dokument styrs av 'Hantera användare' inställningen i 'Dela' menyn, där du kan lägga till användare med rollerna Ägare, Redigerare eller Läsare.\\n\\nFör mer detaljerad styrning, kan du skapa Behörighetsregler för att begränsa vem som kan läsa eller redigera specifika tabeller, kolumner eller rader — vilket är användbart för känslig data eller rollbaserade behörigheter.\\n\\n[Lär dig mer.]({{helpAccessRules}})\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Behörighetsregler\\n\\nDu har inte behörighet att visa eller redigera behörighetsregler för detta dokument.\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Specialregler** (öppna respektive regel för att välja vem den ska gälla för)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"När Behörighetsreglerna stängs av kommer Redigerare kunna ändra dokumentets struktur och redigera formler. Redigerare och Läsare kommer att kunna se alla data i dokumentet, samt ladda ner en kopia av hela dokumentet.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"När Behörighetsreglerna aktiveras kommer inte Redigerare att kunna ändra strukturen på \\ndokumentet samt formler. Enbart Ägare kommer att kunna kopiera hela dokumentet.\\n\\nDessa inställningar kan ändras under 'Specialregler'.\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"Tillåt Redigerare att ändra strukturen (som att ändra och ta bort tabeller, kolumner och layouter) och skriva formler.  Viktigt: om vald, så kommer Redigerare att kunna ändra formler som kan komma åt alla data, oavsett Behörighetsregler för tabeller och kolumner!\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Kringgå alla läsbegränsningar och tillåt alla att kopiera hela dokumentet, eller läsa det i experiment läge. Använd bara för exempel och mallar, inte för dokument med känsliga data.\",\n        \"Permission to access the document in full by all users\": \"Behörighet att komma åt hela dokumentet för alla användare\",\n        \"Permission to access the document in full by unrestricted users\": \"Behörighet att komma åt hela dokumentet för användare utan begränsningar\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Hindra alla utom Ägare från att kopiera eller ladda ner hela dokumentet. Notera: detta påverkar användare utan begränsningar för läsning, eftersom andra kommer att begränsar oavsett denna inställning.\"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"API Nyckel\",\n        \"Account settings\": \"Kontoinställningar\",\n        \"Allow signing in to this account with Google\": \"Tillåt inloggning till detta konto genom Google\",\n        \"Change password\": \"Byt lösenord\",\n        \"Edit\": \"Redigera\",\n        \"Email\": \"Epost\",\n        \"Login method\": \"Inloggningsmetod\",\n        \"Name\": \"Namn\",\n        \"Password & security\": \"Lösenord och säkerhet\",\n        \"Save\": \"Spara\",\n        \"Theme\": \"Tema\",\n        \"Two-factor authentication\": \"Tvåfaktors autentisering\",\n        \"Language\": \"Språk\",\n        \"Names only allow letters, numbers and certain special characters\": \"Namn får bara innehålla bokstäver, siffror och vissa specialtecken\",\n        \"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.\": \"Tvåfaktorsautentisering är ett extra lager säkerhet för ditt Gristkonto, för att säkerställa att bara du kan komma åt ditt konto.\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Tillgångsdetaljer\",\n        \"Accounts\": \"Konton\",\n        \"Add account\": \"Lägg till konto\",\n        \"Document settings\": \"Dokument inställningar\",\n        \"Manage team\": \"Hantera arbetslag\",\n        \"Pricing\": \"Prissättning\",\n        \"Profile settings\": \"Profiliställningar\",\n        \"Sign out\": \"Logga ut\",\n        \"Sign in\": \"Logga in\",\n        \"Switch Accounts\": \"Växla konton\",\n        \"Toggle Mobile Mode\": \"Växla mobilläge\",\n        \"Activation\": \"Aktivering\",\n        \"Billing account\": \"Betalningskonto\",\n        \"Support Grist\": \"Stöd Grist\",\n        \"Upgrade Plan\": \"Uppgradera abonnemang\",\n        \"Sign up\": \"Registrera\",\n        \"Use This Template\": \"Använd denna mall\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Visa som\",\n        \"Users from table\": \"Användare från tabell\",\n        \"Example Users\": \"Exempelanvänare\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"Aktivitetsloggen kunde inte laddas\",\n        \"All tables\": \"Alla tabeller\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Kolumn {{colId}} togs bort i och med åtgärd # {{action.actionNum}}\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Tabell {{tableId}} togs bort i och med åtgärd # {{actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Denna rad togs bort i och med åtgärd # {{action.actionNum}}\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"Kolumn {{colId}} togs bort i och med åtgärd # {{actionNum}}\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Denna rad togs bort i och med åtgärd # {{actionNum}}\",\n        \"History blocked because of access rules.\": \"Historiken inte tillgänglig på grund av behörighetsreger.\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Lägg till ny\"\n    },\n    \"ApiKey\": {\n        \"Click to show\": \"Klicka för att visa\",\n        \"Create\": \"Skapa\",\n        \"Remove\": \"Ta bort\",\n        \"Remove API Key\": \"Ta bort API nyckel\",\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Genom att skapa en API-nyckel gör du det möjligt att göra API-anrop som ditt eget konto.\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Denna API-nyckel kan användas för att komma åt detta konto anonymt genom APIet.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Denna API-nyckel kan användas för att komma åt ditt konto via APIet. Dela inte din API-nyckel med någon.\",\n        \"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?\": \"Du håller på att radera en API-nyckel. Detta kommer att blockera alla framtida anrop med denna API-nyckel. Vill du ändå radera den?\"\n    },\n    \"App\": {\n        \"Description\": \"Beskrivning\",\n        \"Key\": \"Nyckel\",\n        \"Memory Error\": \"Minnesfel\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Översättare: var god översätt denna enbart när ditt språk är redo att erbjudas till användare\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Hemsida\",\n        \"Personal Site\": \"Personlig sida\",\n        \"Team Site\": \"Arbetslagssida\",\n        \"Grist Templates\": \"Gristmallar\",\n        \"Legacy\": \"Gammalt\",\n        \"Billing account\": \"Konto för fakturering\",\n        \"Manage team\": \"Hantera arbetslag\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Tillbaka till hemskärm\"\n    },\n    \"RightPanel\": {\n        \"Save\": \"Spara\",\n        \"CHART TYPE\": \"DIAGRAM TYP\",\n        \"COLUMN TYPE\": \"KOLUMN TYP\",\n        \"CUSTOM\": \"ANPASSAD\",\n        \"Change widget\": \"Ändra widget\",\n        \"columns_one\": \"Kolumn\",\n        \"columns_other\": \"Kolumner\",\n        \"DATA TABLE\": \"DATA TABELL\",\n        \"DATA TABLE NAME\": \"DATA TABELLNAMN\",\n        \"Data\": \"Data\",\n        \"Detach\": \"Lossa\",\n        \"Edit data selection\": \"Redigera data val\",\n        \"fields_one\": \"Fällt\",\n        \"fields_other\": \"Fällt\",\n        \"GROUPED BY\": \"GRUPPPERAD PÅ\",\n        \"Row style\": \"Rad stil\",\n        \"SELECT BY\": \"VALD AV\",\n        \"SELECTOR FOR\": \"VÄLJER FÖR\",\n        \"SOURCE DATA\": \"KÄLLDATA\",\n        \"Select widget\": \"Välj widget\",\n        \"series_one\": \"Serier\",\n        \"series_other\": \"Serier\",\n        \"Sort & filter\": \"Sortera & filtrera\",\n        \"TRANSFORM\": \"TRANSFORMERA\",\n        \"Theme\": \"Tema\",\n        \"WIDGET TITLE\": \"WIDGET TITEL\",\n        \"Widget\": \"Widget\",\n        \"You do not have edit access to this document\": \"Du har inte redigeringsbehörighet till detta dokument\",\n        \"Add referenced columns\": \"Lägg till refererade kolumner\",\n        \"Reset form\": \"Återställ formulär\",\n        \"Configuration\": \"Konfiguration\",\n        \"Default field value\": \"Standardvärde för fällt\",\n        \"Display button\": \"Visa knapp\",\n        \"Enter text\": \"Full i text\",\n        \"Field rules\": \"Fälltregler\",\n        \"Field title\": \"Fällttitel\",\n        \"Hidden field\": \"Dolt fällt\",\n        \"Layout\": \"Layout\",\n        \"Redirect automatically after submission\": \"Omdirigera automatiskt efter inlämning\",\n        \"Redirection\": \"Omdirigering\",\n        \"Required field\": \"Obligatoriskt fällt\",\n        \"Submission\": \"Inlämning\",\n        \"Submit another response\": \"Lämna in ytterligare svar\",\n        \"Submit button label\": \"Skicka knappens text\",\n        \"Success text\": \"Text när det fungerar\",\n        \"Table column name\": \"Tabelens kolumnnamn\",\n        \"Enter redirect URL\": \"Full i omdirigerings URL\",\n        \"No field selected\": \"Inget fällt valt\",\n        \"Select a field in the form widget to configure.\": \"Välj ett fällt i formulärwidget för att ställa in.\",\n        \"Submit\": \"Skicka in\",\n        \"Thank you! Your response has been recorded.\": \"Tack! Ditt svar har sparats.\",\n        \"Chart options\": \"Diagraminställningar\"\n    },\n    \"ShareMenu\": {\n        \"Save copy\": \"Spara kopia\",\n        \"Save Document\": \"Spara dokument\",\n        \"Access Details\": \"Behörighetsdetaljer\",\n        \"Back to current\": \"Tillbaka till nuvarande\",\n        \"Compare to {{termToUse}}\": \"Jämför med {{termToUse}}\",\n        \"Current Version\": \"Nuvarande version\",\n        \"Download\": \"Ladda ner\",\n        \"Duplicate document\": \"Duplicera dokument\",\n        \"Edit without affecting the original\": \"Redigera utan att påverka originalet\",\n        \"Export CSV\": \"Exportera CSV\",\n        \"Export XLSX\": \"Exportera XLSX\",\n        \"Manage users\": \"Hantera användare\",\n        \"Original\": \"Original\",\n        \"Replace {{termToUse}}...\": \"Ersätt {{termToUse}}…\",\n        \"Return to {{termToUse}}\": \"Återvänd till {{termToUse}}\",\n        \"Send to Google Drive\": \"Skicka till Google Drive\",\n        \"Show in folder\": \"Visa i mapp\",\n        \"Unsaved\": \"Ej sparad\",\n        \"Work on a copy\": \"Arbeta på en kopia\",\n        \"Share\": \"Dela\",\n        \"Download...\": \"Ladda ner...\",\n        \"Comma Separated Values (.csv)\": \"Komma separerade värden (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"DOO separerade värden (.dsv)\",\n        \"Export as...\": \"Exportera som...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Tabb Separerade Värden (.tsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Export är bara tillgänglig från dokumentsidorna. Välj en dokumentsida och försök igen.\",\n        \"Download attachments...\": \"Ladda ner bilagor...\",\n        \"Download document...\": \"Ladda ner dokument...\",\n        \"Suggest changes\": \"Föreslå ändringar\",\n        \"current version\": \"nuvarande version\",\n        \"original\": \"original\"\n    },\n    \"SortFilterConfig\": {\n        \"Save\": \"Spara\",\n        \"Filter\": \"FILTER\",\n        \"Revert\": \"Ångra\",\n        \"Sort\": \"SORTERA\",\n        \"Update Sort & Filter settings\": \"Uppdatera sortera- & filtrerainställningar\"\n    },\n    \"ViewSectionMenu\": {\n        \"Save\": \"Spara\",\n        \"(customized)\": \"(anpassad)\",\n        \"(empty)\": \"(tom)\",\n        \"(modified)\": \"(ändrad)\",\n        \"Custom options\": \"Anpassade inställningar\",\n        \"FILTER\": \"FILTER\",\n        \"Revert\": \"Ångra\",\n        \"SORT\": \"SORTERA\",\n        \"Update Sort&Filter settings\": \"Uppdatera sortering- & filterinställningar\",\n        \"Sort and filter\": \"Sortera och filtrera\"\n    },\n    \"WidgetTitle\": {\n        \"Save\": \"Spara\",\n        \"Cancel\": \"Avbryt\",\n        \"DATA TABLE NAME\": \"DATA TABELLNAMN\",\n        \"Override widget title\": \"Ignorera widget titel\",\n        \"Provide a table name\": \"Ge ett tabellnamn\",\n        \"WIDGET TITLE\": \"WIDGETTITEL\",\n        \"WIDGET DESCRIPTION\": \"WIDGETBESKRIVNING\"\n    },\n    \"modals\": {\n        \"Save\": \"Spara\",\n        \"Cancel\": \"Avbryt\",\n        \"Ok\": \"OK\",\n        \"Are you sure you want to delete these records?\": \"Är du säker på att du vill radera dessa rader?\",\n        \"Are you sure you want to delete this record?\": \"Är du säker på att du vill radera denna rad?\",\n        \"Delete\": \"Radera\",\n        \"Dismiss\": \"Avfärda\",\n        \"Don't ask again.\": \"Fråga inte igen.\",\n        \"Don't show again.\": \"Visa inte igen.\",\n        \"Don't show tips\": \"Visa inte tipps\",\n        \"Undo to restore\": \"Ångra för att återställa\",\n        \"Got it\": \"Uppfattat\",\n        \"Don't show again\": \"Visa inte igen\",\n        \"TIP\": \"TIPS\",\n        \"Confirm\": \"Bekräfta\"\n    },\n    \"ColumnInfo\": {\n        \"Save\": \"Spara\",\n        \"COLUMN DESCRIPTION\": \"KOLUMNBESKRIVNING\",\n        \"COLUMN ID: \": \"KOLUMN ID: \",\n        \"COLUMN LABEL\": \"KOLUMNNAMN\",\n        \"Cancel\": \"Avbryt\"\n    },\n    \"DiscussionEditor\": {\n        \"Save\": \"Spara\",\n        \"{{count}} comments_one\": \"{{count}} kommentar\",\n        \"{{count}} comments_other\": \"{{count}} kommentarer\",\n        \"Cancel\": \"Avbryt\",\n        \"Comment\": \"Kommentar\",\n        \"Copy link\": \"Kopiera länk\",\n        \"Edit\": \"Redigera\",\n        \"Marked as resolved\": \"Markera som löst\",\n        \"Only current page\": \"Enbart nuvarande sida\",\n        \"Only my threads\": \"Bara mina trådar\",\n        \"Open\": \"Öppna\",\n        \"Remove\": \"Ta bort\",\n        \"Reply\": \"Svara\",\n        \"Reply to a comment\": \"Svara på kommentar\",\n        \"Resolve\": \"Lös\",\n        \"Show resolved comments\": \"Visa lösta kommentarer\",\n        \"Showing last {{nb}} comments\": \"Visa senaste {{nb}} kommentarer\",\n        \"Started discussion\": \"Startade diskussion\",\n        \"Write a comment\": \"Skriv en kommentar\",\n        \"Remove thread\": \"Ta bort tråd\",\n        \"updated\": \"uppdaterad\"\n    },\n    \"ColumnTitle\": {\n        \"Save\": \"Spara\",\n        \"Add description\": \"Lägg till beskrivning\",\n        \"COLUMN ID: \": \"KOLUMN ID: \",\n        \"Cancel\": \"Avbryt\",\n        \"Column ID copied to clipboard\": \"Kolumn ID kopierad till utklipp\",\n        \"Column description\": \"Kolumnbeskrivning\",\n        \"Column label\": \"Kolumnnamn\",\n        \"Provide a column label\": \"Ange ett kolumnnamn\",\n        \"Close\": \"Stäng\"\n    },\n    \"FormulaAssistant\": {\n        \"Save\": \"Spara\",\n        \"Ask the bot.\": \"Fråga en robot.\",\n        \"Capabilities\": \"Möjligheter\",\n        \"Community\": \"Användargrupp\",\n        \"Data\": \"Data\",\n        \"Formula Cheat Sheet\": \"Formel fusklapp\",\n        \"Formula Help. \": \"Formel hjälp. \",\n        \"Function List\": \"Funktionslista\",\n        \"Grist's AI Assistance\": \"Grists AIassistent\",\n        \"Grist's AI Formula Assistance. \": \"Grists AIformelassistent. \",\n        \"Need help? Our AI assistant can help.\": \"Behöver du hjälp? Vår AIassistent kan hjälpa dig.\",\n        \"New Chat\": \"Ny chatt\",\n        \"Preview\": \"Förhandsvisa\",\n        \"Regenerate\": \"Generera om\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Se vår {{helpFunction}} och {{formulaCheat}}, eller besök vår {{community}} för att få mer hjälp.\",\n        \"Tips\": \"Tipps\",\n        \"AI Assistant\": \"AIassistent\",\n        \"Apply\": \"Applicera\",\n        \"Cancel\": \"Avbryt\",\n        \"Clear conversation\": \"Rensa konversation\",\n        \"Code view\": \"Kodvy\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Hej, jag är Grist formel AIassistent.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Jag kan bara hjälpa dig med formler. Jag kan inte skapa tabeller, kolumner, vyer eller skriva behörighetsregler.\",\n        \"Learn more\": \"Lär dig mer\",\n        \"Press Enter to apply suggested formula.\": \"Tryck på Enter för att applicera föreslagen formel.\",\n        \"Sign Up for Free\": \"Registrera dig gratis\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Registrera ett gratis Gristkonto för att börja använda formel AIassistenten.\",\n        \"There are some things you should know when working with me:\": \"Det finns några saker du behöver känna till när du arbetar med mig:\",\n        \"What do you need help with?\": \"Vad behöver du hjälp med?\",\n        \"Formula AI Assistant is only available for logged in users.\": \"Formel AIassistenten är bara tillgänglig för inloggade användare.\",\n        \"For higher limits, contact the site owner.\": \"För minskade begränsningar, kontakta sajtägaren.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"För minskade begränsningar, {{upgradeNudge}}.\",\n        \"You have used all available credits.\": \"Du har använt alla tillgängliga krediter.\",\n        \"You have {{numCredits}} remaining credits.\": \"Du har {{numCredits}} krediter kvar.\",\n        \"upgrade to the Pro Team plan\": \"uppgradera till Professionellt arbetslagsabonnemang\",\n        \"upgrade your plan\": \"upgradera ditt abonnemang\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"För mer hjälp med formler, kolla in vår {{functionList}} och {{formulaCheatSheet}}, eller besök vår {{community}} för mer hjälp.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"När du pratar med mig kommer dina frågor och din dokumentstruktur (som du kan se i {{codeView}} att skickas till OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Prata med mig som till en person. Du behöver inte specificera tabell och kolumnnamn. Exempelvis kan du fråga \\\"Vad god räkna ut det totala beloppet att fakturera.\\\"\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Save\": \"Spara\",\n        \"Add destination\": \"Lägg till mål\",\n        \"Add streaming destination\": \"Lägg till mål för strömning\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Är du säker på att du vill radera detta strömningsmål? Denna åtgärd kan inte ångras.\",\n        \"Cancel\": \"Avbryt\",\n        \"Delete\": \"Radera\",\n        \"Delete streaming destination?\": \"Radera strömningsmål?\",\n        \"Destination\": \"Mål\",\n        \"Destinations\": \"Mål\",\n        \"Edit\": \"Redigera\",\n        \"Edit streaming destination\": \"Redigera mål för strömning\",\n        \"Enter URL\": \"Fyll i URL\",\n        \"Enter token\": \"Fyll i token\",\n        \"Learn more\": \"Lär dig mer\",\n        \"Other\": \"Annat\",\n        \"Splunk\": \"Splunk\",\n        \"Start streaming\": \"Starta strömmning\",\n        \"Token\": \"Token\",\n        \"URL\": \"URL\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Ställ in strömming av audit händelser från Grist till en extern säkerhetsinformations- och händelsehanteringssystem (SIEM) som Splunk. {{learnMoreLink}}.\"\n    },\n    \"ChoiceListEntry\": {\n        \"Save\": \"Spara\",\n        \"+{{count}} more_one\": \"+{{count}} mer\",\n        \"+{{count}} more_other\": \"+{{count}} mer\",\n        \"Edit\": \"Redigera\",\n        \"No choices configured\": \"Inga alternativ inställda\",\n        \"Reset\": \"Återställ\",\n        \"Cancel\": \"Avbryt\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Detta arbetslag är inaktiverat. Dokumenten kan läsas, men inte ändras.\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"Rensa cell\",\n        \"Clear values\": \"Rensa värden\",\n        \"Copy anchor link\": \"Kopiera ankarlänk\",\n        \"Delete {{count}} columns_one\": \"Radera column\",\n        \"Delete {{count}} columns_other\": \"Radera {{count}} kolumner\",\n        \"Delete {{count}} rows_one\": \"Radera rad\",\n        \"Delete {{count}} rows_other\": \"Radera {{count}} rader\",\n        \"Duplicate rows_one\": \"Duplicera rad\",\n        \"Duplicate rows_other\": \"Duplicera raderna\",\n        \"Filter by this value\": \"Filtrera med detta värde\",\n        \"Insert column to the left\": \"Infoga kolumn till vänster\",\n        \"Insert column to the right\": \"Infoga kolumn till höger\",\n        \"Insert row\": \"Infoga rad\",\n        \"Insert row above\": \"Infoga rad ovanför\",\n        \"Insert row below\": \"Infoga rad nedanför\",\n        \"Reset {{count}} columns_one\": \"Återställ kolumn\",\n        \"Reset {{count}} columns_other\": \"Återställ {{count}} kolumner\",\n        \"Reset {{count}} entire columns_one\": \"Återställ hela kolumnen\",\n        \"Reset {{count}} entire columns_other\": \"Återställ {{count}} hela kolumner\",\n        \"Comment\": \"Kommentar\",\n        \"Copy\": \"Kopiera\",\n        \"Cut\": \"Klipp ut\",\n        \"Paste\": \"Klistra in\",\n        \"Copy with headers\": \"Kopiera med tabellrubriker\"\n    },\n    \"ChartView\": {\n        \"LABEL\": \"ETIKETT\",\n        \"Bar chart\": \"Stapeldiagram\",\n        \"Pie chart\": \"Cirkeldiagrem\",\n        \"Donut chart\": \"Ringdiagram\",\n        \"Area chart\": \"Ytdiagram\",\n        \"Line chart\": \"Linjediagram\",\n        \"Scatter plot\": \"Punktdiagram\",\n        \"Kaplan-Meier plot\": \"Kaplan-Meier kurva\",\n        \"Split series\": \"Dela serier\",\n        \"Invert Y-axis\": \"Invertera Y-axel\",\n        \"Orientation\": \"Orientering\",\n        \"Vertical\": \"Vertikal\",\n        \"Horizontal\": \"Horisontell\",\n        \"Log scale Y-axis\": \"Logaritmisk Y-axel skala\",\n        \"Hole size\": \"Hålstorlek\",\n        \"Show total\": \"Visa total\",\n        \"Text size\": \"Textstorlek\",\n        \"Connect gaps\": \"Överbrygga mellanrum\",\n        \"Show markers\": \"Visa markörer\",\n        \"Stack series\": \"Stapla serier\",\n        \"Error bars\": \"Felstaplar\",\n        \"None\": \"Ingen\",\n        \"Symmetric\": \"Symmetrisk\",\n        \"Above+Below\": \"Över+Under\",\n        \"Split Series\": \"Dela serier\",\n        \"X-AXIS\": \"X-AXEL\",\n        \"Pick a column\": \"Välj en kolumn\",\n        \"Aggregate values\": \"Aggrega värden\",\n        \"SERIES\": \"SERIER\",\n        \"Add series\": \"Lägg till serier\",\n        \"non-numeric columns are not shown\": \"icke numeriska kolumner visas inte\",\n        \"non-numeric column is not shown\": \"icke numerisk kolumn visas inte\",\n        \"selected new x-axis\": \"vald ny x-axel\",\n        \"Remove\": \"Ta bort\",\n        \"Create separate series for each value of the selected column.\": \"Skapa separata serier för varje värde i den valda kolumnen.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"Varje Y serie följs av serier för längden på avvikelsestaplarna.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"Varje Y serie följs av två serier, för övre och under avvikelse staplar.\",\n        \"Toggle chart aggregation\": \"Växla diagram aggregering\",\n        \"selected new group data columns\": \"valda nya gruppdatakolumner\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"Åtkomst nekad\",\n        \"Code View is available only when you have full document access.\": \"Kodvyn är bara tillgänglig där du har fulla dokumenträttigheter.\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Applicera\",\n        \"Cancel\": \"Avbryt\",\n        \"Default cell style\": \"Standardstil för celler\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"Alla\",\n        \"All except\": \"Alla utom\",\n        \"All shown\": \"Alla som visas\",\n        \"Filter by Range\": \"Filtrera efter spann\",\n        \"Future values\": \"Framtida värden\",\n        \"No matching values\": \"Inga matchande värden\",\n        \"None\": \"Ingen\",\n        \"Min\": \"Min\",\n        \"Max\": \"Max\",\n        \"Start\": \"Start\",\n        \"End\": \"Slut\",\n        \"Other Matching\": \"Annan matchning\",\n        \"Other Non-Matching\": \"Annan icke matchande\",\n        \"Other values\": \"Andra värden\",\n        \"Others\": \"Andra\",\n        \"Search\": \"Sök\",\n        \"Search values\": \"Sökvärden\",\n        \"Clear search\": \"Rensa sökning\",\n        \"Pin filter\": \"Fäst filter\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Sortera i bokstavsordning (är nu sorterad efter antal förekomster)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"sorterad efter antal förekomster (är nu sorterad i bokstavsordning)\",\n        \"Unpin filter\": \"Lossa filter\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" (valfri)\",\n        \"Add\": \"Lägg till\",\n        \"Enter Custom URL\": \"Fyll i anpassad URL\",\n        \"Full document access\": \"Full dokument åtkomst\",\n        \"Learn more about custom widgets\": \"Lär dig mer om anpassade widgets\",\n        \"No document access\": \"Ingen dokument åtkomst\",\n        \"Open configuration\": \"Öppna konfigurationen\",\n        \"Pick a column\": \"Välj en kolumn\",\n        \"Pick a {{columnType}} column\": \"Välj en {{columnType}} kolumn\",\n        \"Read selected table\": \"Läs den valda tabellen\",\n        \"Select Custom Widget\": \"Välj anpassad widget\",\n        \"Widget does not require any permissions.\": \"Widgeten kräver inga behörigheter.\",\n        \"Widget needs to {{read}} the current table.\": \"Widgeten behöver {{read}} nuvarande tabellen.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Widgeten behöver {{fullAccess}} till detta dokument.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} kolumn som inte är {{columnType}} visas ej\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} kolumner som inte är {{columnType}} visas ej\",\n        \"Clear selection\": \"Rensa val\",\n        \"No {{columnType}} columns in table.\": \"Inga {{columnType}} kolumner i tabellen.\",\n        \"ACCESS LEVEL\": \"Behörighets nivå\",\n        \"Accept\": \"Acceptera\",\n        \"Custom URL\": \"Anpassad URL\",\n        \"Developer:\": \"Utvecklare:\",\n        \"Last updated:\": \"Senast uppdaterad:\",\n        \"Missing description and author information.\": \"Saknad beskrivning och information om utgivare.\",\n        \"Reject\": \"Avvisa\",\n        \"Widget\": \"Widget\",\n        \"Change custom widget\": \"Byt anpassad widget\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Klicka för att kopiera\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Radera innehåll i {{formattedTableName}} och ta bort från alla sidor?\",\n        \"Duplicate table\": \"Duplicera tabell\",\n        \"Raw Data Tables\": \"Rådatatabeller\",\n        \"Table ID copied to clipboard\": \"Tabell ID kopierat till utklipp\",\n        \"You do not have edit access to this document\": \"Du har inte redigeringsbehörighet till detta dokument\",\n        \"Edit record card\": \"Redigera kort för post\",\n        \"Record Card\": \"Kort för post\",\n        \"Record Card Disabled\": \"Kort för post inaktiverad\",\n        \"Remove table\": \"Ta bort tabell\",\n        \"Rename table\": \"Byt namn på tabell\",\n        \"{{action}} Record Card\": \"{{action}} Kort för post\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Händelse\",\n        \"Beta\": \"Beta\",\n        \"Compare to current\": \"Jämför med nuvarande\",\n        \"Compare to previous\": \"Jämför med föregående\",\n        \"Open snapshot\": \"Öppna ögonblicksbild\",\n        \"Snapshots\": \"Ögonblicksbilder\",\n        \"Snapshots are unavailable.\": \"Ögonblicksbilder är inte tillgängliga.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Enbart ägare har tillgång till ögonblicksbilder för dokument med behörighetsregler.\"\n    },\n    \"DocMenu\": {\n        \"(The organization needs a paid plan)\": \"(Organisationen behöver ett betalt abonnemang)\",\n        \"Access Details\": \"Behörighetsdetaljer\",\n        \"All documents\": \"Alla dokument\",\n        \"By Date Modified\": \"Efter modifierad datum\",\n        \"By Name\": \"Efter namn\",\n        \"Current workspace\": \"Nuvarande arbetsyta\",\n        \"Delete\": \"Radera\",\n        \"Delete Forever\": \"Radera för alltid\",\n        \"Delete {{name}}\": \"Radera {{name}}\",\n        \"Deleted {{at}}\": \"Raderad {{at}}\",\n        \"Discover More Templates\": \"Upptäck fler mallar\",\n        \"Document will be moved to Trash.\": \"Dokument kommer att flyttas till papperskorgen.\",\n        \"Document will be permanently deleted.\": \"Dokument kommer att raderas permanent.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Dokument sparas i papperskorgen i 30 dagar, vart efter de raderas permanent.\",\n        \"Edited {{at}}\": \"Redigerad {{at}}\",\n        \"Examples & Templates\": \"Exempel & Mallar\",\n        \"Examples and Templates\": \"Exempel och mallar\",\n        \"Featured\": \"Utvalda\",\n        \"Grid view\": \"Rutnätsvy\",\n        \"List view\": \"Listvy\",\n        \"Manage users\": \"Hantera användare\",\n        \"More Examples and Templates\": \"Fler exempel och mallar\",\n        \"Move\": \"Flytta\",\n        \"Move {{name}} to workspace\": \"Flytta {{name}} till arbetsyta\",\n        \"Other Sites\": \"Andra webbplatser\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Radera \\\"{{name}}\\\" permanent?\",\n        \"Pin Document\": \"Fäst dokument\",\n        \"Pinned Documents\": \"Fästa dokument\",\n        \"Remove\": \"Ta bort\",\n        \"Rename\": \"Byt namn\",\n        \"Requires edit permissions\": \"Kräver redigerings behörighet\",\n        \"Restore\": \"Återställ\",\n        \"This service is not available right now\": \"Denna tjänst är inte tillgänglig just nu\",\n        \"To restore this document, restore the workspace first.\": \"För att återställa detta dokument, återställ fört arbetsytan.\",\n        \"Trash\": \"Papperskorg\",\n        \"Trash is empty.\": \"Papperskorgen är tom.\",\n        \"Unpin Document\": \"Lossa dokument\",\n        \"Workspace not found\": \"Arbetsyarbetsytan kunde inte hittas\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Du befinner dig på {{siteName}} sajt. Du har också tillgång till följande sajter:\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Du är på din personliga sajt. Du har också tillgång till följande sajter:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Du kan radera en arbetsyta för alltid först när det inte finns några dokument på den.\",\n        \"Any documents created in this site will appear here.\": \"Alla dokument som skapas på denna sajt kommer att visas här.\",\n        \"Create my first document\": \"Skapa mitt första dokument\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Du har läsbehörighet till denna sajt. För närvarande finns inga dokument.\",\n        \"personal site\": \"personlig sajt\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Lägg till en tom tabell\",\n        \"Add page\": \"Lägg till sida\",\n        \"Add widget to page\": \"Lägg till widget till sida\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Dokumentägare kan försöka återställa dokumentet. {{error}}\",\n        \"Enter recovery mode\": \"Öppna återställningsläge\",\n        \"Error accessing document\": \"Fel vid öppnande av dokument\",\n        \"Reload\": \"Ladda om\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"Beklagar, åtkomst till detta dokument har nekats. {{error}}\",\n        \"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}}]\": \"Du kan försöka ladda om dokumentet eller använda återställningsläget. Återställningsläget öppnar dokumentet för ägare med fulla rättigheter, och låser det helt för andra. Det inaktiverar också alla formler. [{{error}}]\",\n        \"You do not have edit access to this document\": \"Du har inte redigeringsrättighet till detta dokument\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Var god, ladda om dokumentet och om felet kvarstår, kontakta dokumentets ägare för att försöka göra en dokumenteåterställning. [{{error}}]\"\n    },\n    \"DocTour\": {\n        \"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.\": \"Kan inte skapa en dokument guide med datan i detta dokument. Säkerställ att det finns en tabell med namnet GristDocTour med kolumnerna Title, Body, Placement och Location.\",\n        \"No valid document tour\": \"Ingen giltig dokumentguide\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"Valuta:\",\n        \"Document settings\": \"Dokumentinställningar\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Motor (experimentell {{span}} ändras på egen risk):\",\n        \"Local currency ({{currency}})\": \"Lokal valuta ({{currency}})\",\n        \"Locale:\": \"Språk/region:\",\n        \"Save\": \"Spara\",\n        \"Save and Reload\": \"Spara och ladda om\",\n        \"This document's ID (for API use):\": \"Detta dokuments ID (för att användas med API):\",\n        \"Time Zone:\": \"Tidszon:\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"Dokumentets ID kopierades till utklipp\",\n        \"Ok\": \"OK\",\n        \"Manage Webhooks\": \"Hantera Webhooks\",\n        \"Webhooks\": \"Webhooks\",\n        \"API console\": \"API konsol\",\n        \"API URL copied to clipboard\": \"API URL kopierades till utklipp\",\n        \"API documentation.\": \"API dokumentation.\",\n        \"Base doc URL: {{docApiUrl}}\": \"Bas dokument URL: {{docApiUrl}}\",\n        \"Coming soon\": \"Kommer snart\",\n        \"Copy to clipboard\": \"Kopiera till utklipp\",\n        \"Currency\": \"Valuta\",\n        \"Data engine\": \"Databas motor\",\n        \"Default for DateTime columns\": \"Standard för DatumTid kolumner\",\n        \"Document ID\": \"Dokument ID\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"Dokument-ID att använda när REST APIet begär {{docId}}. Se {{apiURL}}\",\n        \"Find slow formulas\": \"Hitts långsamma formler\",\n        \"For currency columns\": \"För valutakolumner\",\n        \"For number and date formats\": \"För nummer- och datumformat\",\n        \"Formula times\": \"Formeltider\",\n        \"Hard reset of data engine\": \"Hård återställning av databasmotor\",\n        \"ID for API use\": \"ID för API-användning\",\n        \"Locale\": \"Språk/region\",\n        \"Manage webhooks\": \"Hantera webhooks\",\n        \"Notify other services on doc changes\": \"Notifiera andra tjänster vid ändringar i dokumentet\",\n        \"Python\": \"Python\",\n        \"Python version used\": \"Pythonversion som används\",\n        \"Reload\": \"Ladda om\",\n        \"Time zone\": \"Tidszon\",\n        \"Try API calls from the browser\": \"Provs API-Anrop från webbläsaren\",\n        \"python2 (legacy)\": \"python2 (gammal)\",\n        \"python3 (recommended)\": \"python3 (rekomenderas)\",\n        \"Cancel\": \"Avbryt\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Tvinga omladdning av dokumentet vid tidsmätning av formler, och visa resultatet.\",\n        \"Formula timer\": \"Formeltidsmätare\",\n        \"Reload data engine\": \"Ladda om databasmotorn\",\n        \"Reload data engine?\": \"Ladda om databasmotorn?\",\n        \"Start timing\": \"Starta tidsmätning\",\n        \"Stop timing...\": \"Stoppa tidsmätning...\",\n        \"Time reload\": \"Ladda om tiden\",\n        \"Timing is on\": \"Tidsmätning är på\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Du kan göra ändringar i dokumentet, stoppa sedan tidsmätningen för att se resultaten.\",\n        \"Only available to document editors\": \"Enbart tillgänglig för dokumentredigerare\",\n        \"Only available to document owners\": \"Bara tillgänglig för dokumentägare\",\n        \"Template mode\": \"Mall läge\",\n        \"Change document type\": \"Ändra dokumenttyp\",\n        \"Edit\": \"Redigera\",\n        \"Change nature of document\": \"Ändra dokumentets grundegenskaper\",\n        \"Regular document\": \"Vanligt dokument\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Funktionen för normalt dokument. Alla användare arbetar på en och samma kopia av dokument.\",\n        \"Regular\": \"Vanlig\",\n        \"Template\": \"Mall\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Dokument öppnas automatiskt i {{fiddleModeDocUrl}}. Alla kan redigera, vilket skapar en ny osparad kopia.\",\n        \"fiddle mode\": \"Experexperimentläge\",\n        \"Tutorial\": \"Handledning\",\n        \"Document automatically opens as a user-specific copy.\": \"Dokument öppnas automatiskt som en användarspecifik kopia.\",\n        \"Confirm change\": \"Bekräfta ändring\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Detta kommer att göra en hård omstart av databasmotorn. Detta kan hjälpa om motorn är fast i en oändlig loop, aldrig blir klar med att hantera senaste ändringen eller har kraschat. Ingen data går förlorad, för utom möjligen pågående ändringar.\",\n        \"**Some existing attachments are still external**.\": \"**Vissa bifogade filer lagras fortfarande externt**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Vissa bifogade filer lagras fortfarande internt**(lagrade i SQLite databasfilen).\",\n        \"Attachment storage\": \"Lagring av bifogade filer\",\n        \"Being transfer\": \"Påbörja överföring\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Klicka på \\\"Starta överföring\\\" för att överföra dom till den externa lagningen.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Klicka på \\\"Starta överföring\\\" för att överföra dom till den interna lagringen (i dokumentets SQLite databasfil).\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Nyuppladdade bilagor kommer att lagras på den externa lagringen.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Nyuppladdade bilagor kommer att lagras i den interna lagningen.\",\n        \"No external stores available\": \"Ingen extern lagring tillgänglig\",\n        \"Preferred storage for this document\": \"Föredragen lagring för detta dokument\",\n        \"Start transfer\": \"Starta överföring\",\n        \"External\": \"Extern\",\n        \"Internal\": \"Intern\",\n        \"Transfer in progress\": \"Överföring pågår\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Vissa existerande bifogade filer lagras fortfarande [externt]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Vissa existerande bifogade filer lagras fortfarande [internt]({{internalLink}})** (lagras i SQLite databasfilen).\",\n        \"[Learn more.]({{learnLink}})\": \"[Lär dig mer.]({{learnLink}})\",\n        \"Upload\": \"Ladda upp\",\n        \"Upload missing attachments\": \"Ladda upp saknade bifogade filer\",\n        \"Uploading...\": \"Laddar upp...\",\n        \"Default\": \"Standard\",\n        \"Default, template, or tutorial\": \"Standard, mall eller handledning\",\n        \"Document type\": \"Dokumenttyp\",\n        \"Allow others to suggest changes\": \"Tillåt andra att föreslå ändringar\",\n        \"Enable suggestions\": \"Aktivera ändringsförslag\",\n        \"Suggestions\": \"Ändringsförslag\",\n        \"experiment\": \"experiment\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"När tidmätningen startar, kommer Grist att mäta tiden det tar att köra varje formel. Detta kan användas för att tar rada på vilka formler som orsakar att dokumentet tar lång tid att öppna eller reagera på ändringar.\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"Storlek på bifogade filer\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Kontakta sajtägaren för att uppgradera abonnemanget för att lyfta begränsningarna.\",\n        \"Data size\": \"Datastorlek\",\n        \"For higher limits, \": \"För minskade begränsningar, \",\n        \"Rows\": \"Rader\",\n        \"Usage\": \"Användning\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"Användningsstatistik är bara tillgänglig för användare som har full åtkomst till dokumentdatan.\",\n        \"start your 30-day free trial of the Pro plan.\": \"starta din 30 dagars gratis testperiod för det Pro-abonnemanget.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Ångra senaste redigering\",\n        \"Undo discard\": \"Ångra borttagning\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"Kopiera alla data i tillägg till tabellstrukturen.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Istället för att duplicera tabeller, är det vanligtvis bättre att segmentera datan med länkade vyer. {{link}}\",\n        \"Name for new table\": \"Namn för ny tabell\",\n        \"Only the document default access rules will apply to the copy.\": \"Enbart dokumentets standard behörighetsregler kommer gälla för kopian.\"\n    },\n    \"ExampleInfo\": {\n        \"Afterschool Program\": \"Fritidsprogram\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Läs vår relaterade guide om hur du länkar data och skapar layouter för hög produktivitet.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Läs vår relaterade guide om hur du organiserar företagsdata, använder formler och hanterar komplexitet.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Läs vår relaterade guide om hur du skapar summeringstabeller, diagram och länkar till diagram dynamiskt.\",\n        \"Investment Research\": \"Investeringsforskning\",\n        \"Lightweight CRM\": \"Enkelt CRM\",\n        \"Tutorial: Analyze & Visualize\": \"Guide: Analysera och visualisera\",\n        \"Tutorial: Create a CRM\": \"Guide: Skapa ett CRM\",\n        \"Tutorial: Manage Business Data\": \"Guide: Hantera företagsdata\",\n        \"Welcome to the Afterschool Program template\": \"Välkommen till fritidsprogramsmallen\",\n        \"Welcome to the Investment Research template\": \"Välkommen til investeringsforskningsmallen\",\n        \"Welcome to the Lightweight CRM template\": \"Välkommen till den enkla CRM mallen\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN BEHAVIOR\": \"KOLUMN BETEENDE\",\n        \"COLUMN LABEL AND ID\": \"KOLUMN RUBRIK OCH ID\",\n        \"Clear and make into formula\": \"Rensa och gör till formel\",\n        \"Clear and reset\": \"Rensa och återställ\",\n        \"Column options are limited in summary tables.\": \"Kolumninställningar är begränsade i summeringstabeller.\",\n        \"Convert column to data\": \"Konvertera kolumnen till data\",\n        \"Convert to trigger formula\": \"Omvandla till triggad formel\",\n        \"Data columns_one\": \"Datakolumn\",\n        \"Data columns_other\": \"Datakolumner\",\n        \"Empty columns_one\": \"Tom kolumn\",\n        \"Empty columns_other\": \"Tomma kolumner\",\n        \"Enter formula\": \"Fyll i formel\",\n        \"Formula columns_one\": \"Formelkolumn\",\n        \"Formula columns_other\": \"Formelkolumner\",\n        \"Make into data column\": \"Omvandla till datakolumn\",\n        \"Mixed Behavior\": \"Varierat beteende\",\n        \"Set formula\": \"Ange formel\",\n        \"Set trigger formula\": \"Ange triggad fornel\",\n        \"TRIGGER FORMULA\": \"TRIGGAD FORMEL\",\n        \"DESCRIPTION\": \"BESKRIVNING\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Återgå till gemensamma inställningar\",\n        \"Save as common settings\": \"Spara som gemensamma inställningar\",\n        \"Use separate settings\": \"Använd separata inställningar\",\n        \"Using common settings\": \"Använd gemensamma inställningar\",\n        \"Using separate settings\": \"Använder separata inställningar\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Lägg till kolumn\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Fäst filter - kolum {{- columnName}} (nu: inte fäst)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Lossa filter - kolumn {{- columnName}} (nu: fäst)\",\n        \"remove filter - {{- columnName}} column\": \"Ta bort filter - kolumn {{- columnName}}\",\n        \"{{- columnName }} column filters\": \"{{- columnName }} kolumnfilter\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Sök kolumner\",\n        \"Search Columns\": \"Sök Kolumner\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"Rutnätsinställningar\",\n        \"Horizontal gridlines\": \"Horisontella rutnätslinjer\",\n        \"Vertical gridlines\": \"Vertikala rutnätslinjer\",\n        \"Zebra stripes\": \"Zebralinjer\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"Lägg till kolumn\",\n        \"Add to sort\": \"Lägg till sortering\",\n        \"Clear values\": \"Rensa värden\",\n        \"Column Options\": \"Columninställningar\",\n        \"Convert formula to data\": \"Konvertera formel till data\",\n        \"Delete {{count}} columns_one\": \"Radera kolumn\",\n        \"Delete {{count}} columns_other\": \"Radera {{count}} columner\",\n        \"Filter Data\": \"Filtrera data\",\n        \"Freeze {{count}} columns_one\": \"Lås denna kolumn\",\n        \"Freeze {{count}} columns_other\": \"Lås {{count}} columner\",\n        \"Freeze {{count}} more columns_one\": \"Lås ytterligare en kolumn\",\n        \"Freeze {{count}} more columns_other\": \"Lås ytterligare {{count}} kolumner\",\n        \"Hide {{count}} columns_one\": \"Dölj kolumn\",\n        \"Hide {{count}} columns_other\": \"Dölj {{count}} kolumner\",\n        \"Insert column to the {{to}}\": \"Infoga kolumner till {{to}}\",\n        \"More sort options ...\": \"Fler sorteringsinställningar…\",\n        \"Rename column\": \"Byt namn på kolumn\",\n        \"Reset {{count}} columns_one\": \"Återställ kolumn\",\n        \"Reset {{count}} columns_other\": \"Återställ {{count}} kolumner\",\n        \"Reset {{count}} entire columns_one\": \"Återställ hela kolumnen\",\n        \"Reset {{count}} entire columns_other\": \"Återställ {{count}} hela kolumner\",\n        \"Show column {{- label}}\": \"Visa kolumn {{- label}}\",\n        \"Sort\": \"Sortera\",\n        \"Sorted (#{{count}})_one\": \"Sorterade (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Sorterade (#{{count}})\",\n        \"Unfreeze all columns\": \"Lossa alla kolumner\",\n        \"Unfreeze {{count}} columns_one\": \"Lossa denna kolumn\",\n        \"Unfreeze {{count}} columns_other\": \"Lossa {{count}} kolumner\",\n        \"Insert column to the left\": \"Infoga kolumn till vänster\",\n        \"Insert column to the right\": \"Infoga kolumn till höger\",\n        \"Apply on record changes\": \"Kör vid radändringar\",\n        \"Apply to new records\": \"Kör på nya rader\",\n        \"Authorship\": \"Ägandeskap\",\n        \"Created At\": \"Skapad\",\n        \"Created By\": \"Skapad av\",\n        \"Hidden Columns\": \"Dolda kolumner\",\n        \"Last Updated At\": \"Senast uppdaterad\",\n        \"Last Updated By\": \"Senast uppdaterad av\",\n        \"Lookups\": \"Uppslagningar\",\n        \"Shortcuts\": \"Genvägar\",\n        \"Show hidden columns\": \"Visa dolda kolumner\",\n        \"Timestamp\": \"Tidsstämpel\",\n        \"no reference column\": \"ingen referenskolumn\",\n        \"Adding UUID column\": \"Lägger till UUID-column\",\n        \"Adding duplicates column\": \"Lägger till kolumn med dubletter\",\n        \"Detect Duplicates in...\": \"Upptäck dubbletter i...\",\n        \"Duplicate in {{- label}}\": \"Dubblett i {{- label}}\",\n        \"No reference columns.\": \"Inga refereskolumner.\",\n        \"Search columns\": \"Sök kolumner\",\n        \"UUID\": \"UUID\",\n        \"Add column with type\": \"Lägg till kolumn med typ\",\n        \"Add formula column\": \"Lägg till formelkolumn\",\n        \"Created at\": \"Skapad\",\n        \"Created by\": \"Skapad av\",\n        \"Detect duplicates in...\": \"Upptäck dubbletter i...\",\n        \"Last updated at\": \"Senast uppdaterad\",\n        \"Last updated by\": \"Senast uppdaterad av\",\n        \"Any\": \"Vilken som helst\",\n        \"Numeric\": \"Tal\",\n        \"Text\": \"Text\",\n        \"Integer\": \"Heltal\",\n        \"Toggle\": \"Av/på\",\n        \"Date\": \"Datum\",\n        \"DateTime\": \"Datum och tid\",\n        \"Choice\": \"Valalternativ\",\n        \"Choice List\": \"Flervalsalternativ\",\n        \"Reference\": \"Referens\",\n        \"Reference List\": \"Referenslista\",\n        \"Attachment\": \"Filbilaga\"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"Lade till ny länkad sektion till vyn {{viewName}}\",\n        \"Import from file\": \"Importera från fil\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Sparade länkad sektio {{title}} i vyn {{name}}\",\n        \"go to webhook settings\": \"gå till webhookinställningar\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Nya ändringar är tillfälligt pausade. Wehbooks kön är överfull. Kontrollera webhooksinställningar, ta bort felaktiga webhooks och rensa kön.\",\n        \"Import from Airtable\": \"Importera från Airtable\"\n    },\n    \"HomeIntro\": {\n        \"Any documents created in this site will appear here.\": \"Alla dokument som skapas på denna sajt kommer synas här.\",\n        \"Browse Templates\": \"Bläddra bland mallar\",\n        \"Create empty document\": \"Skapa ett tomt dokument\",\n        \"Get started by creating your first Grist document.\": \"Kom igång genom att skapa ditt första Gristdokument.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Kom igång genom att utforska mallar eller skapa dit första Gristdokument.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Kom igång genom att bjuda in ditt arbetslag och skapa ditt första Gristdokument.\",\n        \"Help Center\": \"Hjälpcenter\",\n        \"Import document\": \"Importera dokument\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Intresserad av att använda Grist utanför arbetslaget? Besök din gratis \",\n        \"Invite Team Members\": \"Bjud in arbetslagsmedlemmar\",\n        \"Sign up\": \"Registrera\",\n        \"Sprouts Program\": \"Plantskola\",\n        \"This workspace is empty.\": \"Arbetsytan är tom.\",\n        \"Visit our {{link}} to learn more.\": \"Besök vår {{link}} för att lära dig mer.\",\n        \"Welcome to Grist!\": \"Välkommen till Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Välkommen till Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Välkommen till {{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Du har läsberhörighet till denna sajt. Just nu finns det inga dokument.\",\n        \"personal site\": \"personlig sajt\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} för att spara ditt arbete. \",\n        \"Welcome to Grist, {{- name}}!\": \"Välkommen till Grist, {{- name}}!\",\n        \"Welcome to {{- orgName}}\": \"Välkommen till {{- orgName}}\",\n        \"Sign in\": \"Logga in\",\n        \"To use Grist, please either sign up or sign in.\": \"För att använda Grist, registrera dig eller logga in.\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Besök vår {{link}} för att lära dig med om Grist.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Lär dig mer i vårt {{helpCenterLink}}.\",\n        \"Only show documents\": \"Visa bara dokument\"\n    },\n    \"HomeLeftPane\": {\n        \"Access Details\": \"Behörighets detaljer\",\n        \"All documents\": \"Alla dokument\",\n        \"Create empty document\": \"Skapa tomt dokument\",\n        \"Create workspace\": \"Skapa arbetsyta\",\n        \"Delete\": \"Radera\",\n        \"Delete {{workspace}} and all included documents?\": \"Radera {{workspace}} och alla dokument där i?\",\n        \"Examples & Templates\": \"Mallar\",\n        \"Import document\": \"Importera dokument\",\n        \"Manage users\": \"Hantera användare\",\n        \"Rename\": \"Byt namn\",\n        \"Trash\": \"Papperskorg\",\n        \"Workspace will be moved to Trash.\": \"Arbetsyta kommer flyttas till papperskorgen.\",\n        \"Workspaces\": \"Arbetsytor\",\n        \"Tutorial\": \"Instruktionsguide\",\n        \"Terms of service\": \"Användarvillkor\",\n        \"Grist Resources\": \"Grist resurser\",\n        \"context menu - {{- workspaceName }}\": \"snabbmeny - {{- workspaceName }}\",\n        \"Import from Airtable\": \"Importera från Airtable\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"Slå ihop rader som där dessa fällt matchar:\",\n        \"Select fields to match on\": \"Välj fält som ska matcha\",\n        \"Update existing records\": \"Uppdatera existerande rader\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} omatchade fällt vid import\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} omatchade fällt vid import\",\n        \"{{count}} unmatched field_one\": \"{{count}} omatchade fällt\",\n        \"{{count}} unmatched field_other\": \"{{count}} omatchade fällt\",\n        \"Column Mapping\": \"Kolumnöversättning\",\n        \"Column mapping\": \"Kolumnöversättning\",\n        \"Destination table\": \"Måltabell\",\n        \"Grist column\": \"Gristkolumn\",\n        \"Import from file\": \"Importera från fil\",\n        \"New Table\": \"Ny tabell\",\n        \"Revert\": \"Återställ\",\n        \"Skip\": \"Hoppa över\",\n        \"Skip Import\": \"Hoppa över import\",\n        \"Skip Table on Import\": \"Hoppa över tabell vid import\",\n        \"Source column\": \"Källkolumn\",\n        \"Import options\": \"Importinställningar\",\n        \"Cancel\": \"Avbryt\",\n        \"Import\": \"Importera\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Hjälpcenter\",\n        \"Accessibility\": \"Tillgänglighet\"\n    },\n    \"MakeCopyMenu\": {\n        \"As template\": \"Som mall\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Var försiktig, det finns ändringar i originalet som inte finns i detta dokument. Ändringarna i originalet kommer att skrivas över.\",\n        \"Cancel\": \"Avbryt\",\n        \"Enter document name\": \"Full i dokumentnamn\",\n        \"However, it appears to be already identical.\": \"Men, de verkar redan vara identiska.\",\n        \"Include the structure without any of the data.\": \"Inkludera strukturen utan någon data.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Det kommer att skrivas över, alla data som inte finns i detta dokument kommer att gå förlorade.\",\n        \"Name\": \"Namn\",\n        \"No destination workspace\": \"Ingen målarbetsyta\",\n        \"Organization\": \"Organisation\",\n        \"Original Has Modifications\": \"Originalet har ändringar\",\n        \"Original Looks Unrelated\": \"Originalet verkar inte höra ihop\",\n        \"Original Looks Identical\": \"Originalet verkar identiskt\",\n        \"Overwrite\": \"Skriv över\",\n        \"Replacing the original requires editing rights on the original document.\": \"Att ersätta originalet kräver redigeringsbehörighet i orginaldokumentet.\",\n        \"Sign up\": \"Registrera\",\n        \"The original version of this document will be updated.\": \"Originalversionen av detta dokument kommer att uppdateras.\",\n        \"To save your changes, please sign up, then reload this page.\": \"För att spara dina ändringar, registrera dig och ladda sedan om denna sida.\",\n        \"Update\": \"Uppdatera\",\n        \"Update Original\": \"Uppdatera originalet\",\n        \"Workspace\": \"Arbetsyta\",\n        \"You do not have write access to the selected workspace\": \"Du har inte redigeringsbehörighet till den valda arbetsytan\",\n        \"You do not have write access to this site\": \"Du har inte redigeringsbehörighet till denna sajt\",\n        \"Download\": \"Ladda ner\",\n        \"Download document\": \"Ladda ner dokument\",\n        \"Download document and history\": \"Ladda ner dokument och historik\",\n        \"Download document without history (can significantly reduce file size)\": \"Ladda ner dokument utan historik (kan drastiskt minska filstorleken)\",\n        \"Download document structure only (no data, for template use)\": \"Ladda ner enbart dokumentstrukturen (ingen data, använd som mall)\",\n        \".tar (recommended)\": \".tar (rekommenderad)\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"Ladda ner ett arkiv med alla bilagor i detta dokument.\",\n        \"Download attachments\": \"Ladda ner bilagor\",\n        \"Download full document and history\": \"Ladda ner hela dokumentet inklusive historik\",\n        \"Format:\": \"Format:\",\n        \"Learn more\": \"Lär dig mer\",\n        \"download attachments\": \"ladda ner bilagor\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Bilagor är externa och inte inkluderade i denna nedladdning. Om du laddar upp dokumentet till en separat Gristinstallation behöver du också {{downloadLink}} separat. \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"Im du planerar att ladda upp dokumentet till en Gristinstallation behöver du arkivet i \\\".tar\\\" format för att återställa bilagorna. \"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"Fråga efter hjälp\",\n        \"Cannot find personal site, sorry!\": \"Kan inte hitta personlig sajt, beklagar!\",\n        \"Give feedback\": \"Ge återkoppling\",\n        \"Go to your free personal site\": \"Gå till din gratis personliga sajt\",\n        \"No notifications\": \"Inga notiser\",\n        \"Notifications\": \"Notiser\",\n        \"Renew\": \"Förnya\",\n        \"Report a problem\": \"Rapportera ett problem\",\n        \"Upgrade Plan\": \"Uppgradera abonnemang\",\n        \"Manage billing\": \"Hantera fakturering\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Avsluta\",\n        \"Next\": \"Nästa\",\n        \"Previous\": \"Föregående\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Grist Videogenomgång\",\n        \"Video Tour\": \"Videogenomgång\",\n        \"YouTube video player\": \"YouTube video spelare\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Lägg till på sida\",\n        \"Building {{- label}} widget\": \"Bygger {{- label}} widget\",\n        \"Group by\": \"Gruppera på\",\n        \"Select data\": \"Välj data\",\n        \"Select widget\": \"Välj widget\",\n        \"New Table\": \"Ny tabell\",\n        \"SELECT BY\": \"VAL AV\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Radera\",\n        \"Delete data and this page.\": \"Radera data och denna sida.\",\n        \"The following tables will no longer be visible_one\": \"Följande tabell kommer inte längre att vara synlig\",\n        \"The following tables will no longer be visible_other\": \"Följande tabeller kommer inte längre att vara synlig\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Behåll data och radera sidan. Tabellen är fortsatt tillgänglig på {{rawDataLink}}\",\n        \"raw data page\": \"rådatasidan\",\n        \"Document pages\": \"Dokumentsidor\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"Tillåt alla\",\n        \"Deny all\": \"Neka alla\",\n        \"Read only\": \"Endast rättigheter\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Import misslyckades: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Uppdaterar radlayout.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Lägg till fällt\",\n        \"Create new field\": \"Skapa nytt fällt\",\n        \"Show field {{- label}}\": \"Visa fällt {{- label}}\",\n        \"Save layout\": \"Spara layout\",\n        \"Cancel\": \"Avbryt\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Lägg till kolumn\",\n        \"No columns to add\": \"Inga kolumner att lägga till\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"Kopiera ankarlänk\",\n        \"Delete\": \"Radera\",\n        \"Duplicate rows_one\": \"Duplicera rad\",\n        \"Duplicate rows_other\": \"Duplicera rader\",\n        \"Insert row\": \"Infoga rad\",\n        \"Insert row above\": \"Infoga rad ovanför\",\n        \"Insert row below\": \"Infoga rad under\",\n        \"View as card\": \"Visa som kort\",\n        \"Use as table headers\": \"Använd som tabellrubriker\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Kopierad till utklipp\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Skapa ny arbetslagssajt\",\n        \"Switch Sites\": \"Växla sajter\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Lägg till kolumn\",\n        \"Empty values last\": \"Tomma värden sist\",\n        \"Natural sort\": \"Naturlig ordning\",\n        \"Update data\": \"Uppdatera data\",\n        \"Use choice position\": \"Använd position för val\",\n        \"Search Columns\": \"Sök kolumner\",\n        \"Remove sort setting - {{- columnName }} column\": \"Ta bort sorteringsinställning - kolumn {{- columnName }}\",\n        \"Sort in ascending order (current: descending)\": \"Sortera i stigande ordning (nuvarande: fallande)\",\n        \"Sort in descending order (current: ascending)\": \"Sortera i fallande ordning (nuvarande: stigande)\",\n        \"Sort options - {{- columnName }} column\": \"Sorteringsinställningar - kolumn {{- columnName }}\",\n        \"{{- columnName }} column\": \"kolumn {{- columnName }}\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"Utseende \",\n        \"Switch appearance automatically to match system\": \"Växla utseende automatiskt för att matcha systemet\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Behörighetsregler\",\n        \"Code view\": \"Kodvy\",\n        \"Delete\": \"Radera\",\n        \"Delete document tour?\": \"Radera dokumentrundtur?\",\n        \"Document history\": \"Dokumenthistorik\",\n        \"How-to Tutorial\": \"Hur gör jag? - Instruktionsguide\",\n        \"Raw data\": \"Rådata\",\n        \"Return to viewing as yourself\": \"Återvänd till att visa som dig själv\",\n        \"TOOLS\": \"VERKTYG\",\n        \"Tour of this Document\": \"Tundtur av detta dokument\",\n        \"Validate Data\": \"Validera data\",\n        \"Settings\": \"Inställningar\",\n        \"API console\": \"API-konsol\",\n        \"context menu - Access Rules\": \"snabbmeny - Behörighetsregler\",\n        \"Delete document tour\": \"Radera dokumentrundtur\",\n        \"Preview the tutorial\": \"Förhandsvisa instruktionsguide\",\n        \"Proposed changes\": \"Föreslagna ändringar\",\n        \"Suggest changes\": \"Föreslå ändringar\",\n        \"Suggestions\": \"Förslag\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Hantera arbetslag\",\n        \"Redo\": \"Gör om\",\n        \"Undo\": \"Ångra\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"Valfritt fällt\",\n        \"Apply on changes to:\": \"Applicera på ändringar i:\",\n        \"Apply on record changes\": \"Applicera vid radändringar\",\n        \"Apply to new records\": \"Applicera på nya rader\",\n        \"Cancel\": \"Avbryt\",\n        \"Close\": \"Stäng\",\n        \"Current field \": \"Nuvarande fällt \",\n        \"OK\": \"OK\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"Applicera\",\n        \"Cancel\": \"Avbryt\",\n        \"Preview\": \"Förhandsvisa\",\n        \"Revise\": \"Revidera\",\n        \"Update formula (Shift+Enter)\": \"Uppdatera formel (Skift+Enter)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Redigerare\",\n        \"In full\": \"Komplett\",\n        \"No Default Access\": \"Inget standard åtkomst\",\n        \"None\": \"Ingen\",\n        \"Owner\": \"Ägare\",\n        \"View & edit\": \"Visa & redigera\",\n        \"View only\": \"Enbart visa\",\n        \"Viewer\": \"Läsbehörig\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Regel {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Uppdatera formel (Skrift+Enter)\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Okänd användare\",\n        \"View as Yourself\": \"Visa som dig själv\",\n        \"You are viewing this document as\": \"Du tittar på detta dokument som\",\n        \"You're seeing what this user would see if given access\": \"Du ser var denna användare skulle se om den gavs behörighet\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Avancerade inställningar\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Stora tabeller kan markeras som \\\"vid-behov\\\" för att undvika att de laddas in i databasmotorn.\",\n        \"Blocks\": \"Block\",\n        \"Compact\": \"Kompakt\",\n        \"Edit card layout\": \"Redigera kort layout\",\n        \"Form\": \"Formulär\",\n        \"Make On-Demand\": \"Ställ om till Vid-behov\",\n        \"Plugin: \": \"Plugin: \",\n        \"Section: \": \"Sektion: \",\n        \"Unmark On-Demand\": \"Ställ om från Vid-behov\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"Vid-behovtabeller har är markerad för utfasning på grund av avsaknad av funktionalitet och bekymmer med användarvänlighet.\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Funktion som håller på att fasas ut\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Avancerad sortering & filtrering\",\n        \"Copy anchor link\": \"Kopiera ankarlänk\",\n        \"Data selection\": \"Dataval\",\n        \"Delete record\": \"Radera rad\",\n        \"Delete widget\": \"Radera widget\",\n        \"Download as CSV\": \"Ladda ner som CSV\",\n        \"Download as XLSX\": \"Ladda ner som XLSX\",\n        \"Edit card layout\": \"Redigera layout för kort\",\n        \"Open configuration\": \"Öppna inställningarna\",\n        \"Print widget\": \"Skriv ut widget\",\n        \"Show raw data\": \"Visa rådata\",\n        \"Widget options\": \"Widgetinställningar\",\n        \"Add to page\": \"Lägg till på sida\",\n        \"Collapse widget\": \"Komprimera widget\",\n        \"Create a form\": \"Skapa ett formulär\",\n        \"Duplicate widget\": \"Duplicera widget\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"Kan inte släppa den i dolda fällt\",\n        \"Clear\": \"Rensa\",\n        \"Hidden Fields cannot be reordered\": \"Dolda fällt kan inte organiseras om\",\n        \"Select all\": \"Välj alla\",\n        \"Visible {{label}}\": \"Synlig {{label}}\",\n        \"Hide {{label}}\": \"Dölj {{label}}\",\n        \"Hidden {{label}}\": \"Dold {{label}}\",\n        \"Show {{label}}\": \"Visa {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Dölj {{label}} (gruppvis)\",\n        \"Show {{label}} (batch mode)\": \"Visa {{label}} (gruppvis)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Utbildning\",\n        \"Finance & Accounting\": \"Finans och redovisning\",\n        \"HR & Management\": \"HR och ledning\",\n        \"IT & Technology\": \"IT och teknologi\",\n        \"Marketing\": \"Marknadsföring\",\n        \"Media Production\": \"Medieproduktion\",\n        \"Other\": \"Annat\",\n        \"Product Development\": \"Produkt-utveckling\",\n        \"Research\": \"Forskning\",\n        \"Sales\": \"Försäljning\",\n        \"Type here\": \"Skriv här\",\n        \"Welcome to Grist!\": \"Välkommen till Grist!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Hur kom du in på Grist? Hjälp oss att stötta dig på bästa sätt.\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Du kan redigera, men det kommer att skapa en ny kopia och\\nkommer inte påverka originaldokumentet.\",\n        \"fiddle\": \"experimentera\",\n        \"override\": \"ignorera\",\n        \"recovery mode\": \"återställningsläge\",\n        \"snapshot\": \"ögonblicksbild\",\n        \"unsaved\": \"ej sparad\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Du kan redigera,\\nmen det kommer inte att påverka originaldokumentet.\\nDu kan lämna ändringsförslag.\",\n        \"editing\": \"redigering\",\n        \"suggesting\": \"föreslår\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Duplicera sida {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Notera att detta inte kopierar data, det skapar en ny vy med samma data.\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Åtkomst nekad {{suffix}}\",\n        \"Add account\": \"Lägg till konto\",\n        \"Contact support\": \"Kontakta support\",\n        \"Error{{suffix}}\": \"Fel {{suffix}}\",\n        \"Go to main page\": \"Gå till huvudsidan\",\n        \"Page not found{{suffix}}\": \"Sida kunde inte hittas {{suffix}}\",\n        \"Sign in\": \"Logga in\",\n        \"Sign in again\": \"Logga in igen\",\n        \"Sign in to access this organization's documents.\": \"Logga in för att komma åt denna organisations dokument.\",\n        \"Signed out{{suffix}}\": \"Utloggad {{suffix}}\",\n        \"Something went wrong\": \"Något gick fel\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Kunde hitta den efterfrågade sidan.{{separator}}Kontrollera adressen och försök igen.\",\n        \"There was an error: {{message}}\": \"Ett fel uppstod: {{message}}\",\n        \"There was an unknown error.\": \"Ett okänt fel uppstod.\",\n        \"You are now signed out.\": \"Du är nu utloggad.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Du är inloggad som {{email}}. Du kan logga in med ett annat konto eller be administratören om behörighet.\",\n        \"You do not have access to this organization's documents.\": \"Du har inte tillgång till denna organisations dokument.\",\n        \"Account deleted{{suffix}}\": \"Kontot raderat{{suffix}}\",\n        \"Sign up\": \"Registrera\",\n        \"Your account has been deleted.\": \"Ditt konto har raderats.\",\n        \"An unknown error occurred.\": \"Ett oväntat fel har uppstått.\",\n        \"Build your own form\": \"Skapa ditt eget formulär\",\n        \"Form not found\": \"Formuläret kunde inte hittas\",\n        \"Powered by\": \"Drivs av\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Inloggningen misslyckades.{{separator}}Testa igen eller kontakta support.\",\n        \"Sign-in failed{{suffix}}\": \"Inloggning misslyckades{{suffix}}\",\n        \"Manage settings\": \"Hantera inställningar\",\n        \"Need Help?\": \"Behöver du hjälp?\",\n        \"There was an error\": \"Ett fel uppstod\",\n        \"Unsubscribed{{suffix}}\": \"Prenumerationen avslutad{{suffix}}\",\n        \"We could not unsubscribe you\": \"Prenumerationen kunde inte avslutas\",\n        \"You are unsubscribed\": \"Din prenumeration är avslutad\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Du kan fortfarande avsluta prenumerationen från detta dokument genom att uppdatera dina val i dokumentinställningar\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Du kommer inte längre att få epost notifieringar vid {{changes}} i {{docName}} till {{email}}.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Du kommer inte längre att få epost notifieringar vid {{comments}} i {{docName}} till {{email}}.\",\n        \"changes\": \"ändringar\",\n        \"comments\": \"kommentarer\",\n        \"this document\": \"detta dokument\",\n        \"your email\": \"din e-post\",\n        \"You will no longer receive email notifications about {{suggestions}} in {{docName}} at {{email}}.\": \"Du kommer inte längre få e-postnotiser om {{suggestions}} i {{docName}} till {{email}}.\",\n        \"suggestions\": \"ändringsförslag\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* Arbetsytor är tillgängliga i arbetslagsprenumerationer. \",\n        \"Select fields\": \"Välj fällt\",\n        \"Upgrade now\": \"Upgradera nu\",\n        \"Any\": \"Valfri\",\n        \"Numeric\": \"Tal\",\n        \"Text\": \"Text\",\n        \"Integer\": \"Heltal\",\n        \"Toggle\": \"Av/på\",\n        \"Date\": \"Datum\",\n        \"DateTime\": \"Datum och tid\",\n        \"Choice\": \"Alternativ\",\n        \"Choice List\": \"Alternativlista\",\n        \"Reference\": \"Referens\",\n        \"Reference List\": \"Referenslista\",\n        \"Attachment\": \"Bilaga\",\n        \"Search columns\": \"Sök kolumner\",\n        \"By Name\": \"Efter namn\",\n        \"By Date Modified\": \"Efter ändringsdatum\",\n        \"Light\": \"Ljust\",\n        \"Custom\": \"Anpassad\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Duplicera sida\",\n        \"Remove\": \"Ta bort\",\n        \"Rename\": \"Byt namn\",\n        \"You do not have edit access to this document\": \"Du har inte redigeringsbehörighet i detta dokument\",\n        \"(default)\": \"(standard)\",\n        \"Collapse {{maybeDefault}}\": \"Komprimera {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Expandera {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Välj standard: Komprimera\",\n        \"Set default: Expand\": \"Välj standard: Expandera\",\n        \"context menu - {{- pageName }}\": \"snabbmeny - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Find Next \": \"Hitta nästa \",\n        \"Find Previous \": \"Hitta föregående \",\n        \"No results\": \"Inga resultat\",\n        \"Search in document\": \"Sök i dokumentet\",\n        \"Search\": \"Sök\",\n        \"Close search bar\": \"Stäng sökruta\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Skickar fil till Google Drive\"\n    },\n    \"NTextBox\": {\n        \"false\": \"falskt\",\n        \"true\": \"sant\",\n        \"Field Format\": \"Fälltformat\",\n        \"Lines\": \"Rader\",\n        \"Multi line\": \"Flerradig\",\n        \"Single line\": \"En rad\",\n        \"Maximum characters\": \"Max antal tecken\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"Exempelanvändare\",\n        \"Users from table\": \"Användare från tabell\",\n        \"View as\": \"Visa som\",\n        \"Other users from table\": \"Andra användare från tabell\",\n        \"Shared users\": \"Delade användare\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Applicera\",\n        \"Cancel\": \"Avbryt\",\n        \"Preview\": \"Förhandsvisa\",\n        \"Revise\": \"Revidera\",\n        \"Update formula (Shift+Enter)\": \"Uppdatera formel (Skift+Enter)\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"CELL STIL\",\n        \"Cell style\": \"Cell stil\",\n        \"Default cell style\": \"Standardstil för celler\",\n        \"Mixed style\": \"Blandad stil\",\n        \"Open row styles\": \"Öppna radstilar\",\n        \"Default header style\": \"Standard rubrikstil\",\n        \"Header Style\": \"Rubrikstil\",\n        \"HEADER STYLE\": \"RUBRIKSTIL\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"ALTERNATIV\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"KOLUMNBESKRIVNING\",\n        \"COLUMN LABEL\": \"KOLUMNNAMN\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"Lägg till ytterligare villkor\",\n        \"Add conditional style\": \"Lägg till villkorsstyrd stil\",\n        \"Error in style rule\": \"Fel i stilvillkor\",\n        \"Row style\": \"Radstil\",\n        \"Rule must return True or False\": \"Villkoret måste returnera sant eller falskt\",\n        \"Conditional Style\": \"Villkorsstyrd stil\",\n        \"IF...\": \"OM...\",\n        \"Row Style\": \"Radstil\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Ogiltig valuta\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Konvertera kolumn till formel\"\n    },\n    \"FieldBuilder\": {\n        \"Apply formula to data\": \"Applicera formel på data\",\n        \"CELL FORMAT\": \"CELLFORMAT\",\n        \"Changing multiple column types\": \"Ändrar flera kolumntyper\",\n        \"DATA FROM TABLE\": \"DATA FRÅN TABELL\",\n        \"Mixed format\": \"Blandade format\",\n        \"Mixed types\": \"Blandade typer\",\n        \"Revert field settings for {{colId}} to common\": \"Återställ fälltinställningar för {{colId}} till standard\",\n        \"Save field settings for {{colId}} as common\": \"Spara fällinställningar för {{colId}} som standard\",\n        \"Use separate field settings for {{colId}}\": \"Använda separat fällinställningar för {{colId}}\",\n        \"Changing column type\": \"Ändrar kolumntyp\",\n        \"Common\": \"Gemensamma\",\n        \"Separate\": \"Separata\",\n        \"Field in {{count}} views_one\": \"Fällt i en vy\",\n        \"Field in {{count}} views_other\": \"Fällt i {{count}} vyer\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"Det ska vara omöjligt att spara ett datavärde i en formelkolumn\",\n        \"Unable to finish saving edited cell\": \"Det gick inte att spara redigeringen av cellen\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Kolumn eller fällt är obligatoriskt\",\n        \"Error in the cell\": \"Fel i cellen\",\n        \"Errors in all {{numErrors}} cells\": \"Fel i alla {{numErrors}} celler\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Fel i {{numErrors}} av {{numCells}} celler\",\n        \"editingFormula is required\": \"att redigera formel är obligatoriskt\",\n        \"Enter formula or {{button}}.\": \"Full i formel eller {{button}}.\",\n        \"Enter formula.\": \"Full i formel.\",\n        \"Expand Editor\": \"Expandera redigeringsruta\",\n        \"use AI Assistant\": \"använd AI assistent\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[länknamn] URL\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"Valuta\",\n        \"Decimals\": \"Decimaler\",\n        \"Default currency ({{defaultCurrency}})\": \"Standardvaluta ({{defaultCurrency}})\",\n        \"Number Format\": \"Nummerformat\",\n        \"Field Format\": \"Fälltformat\",\n        \"Spinner\": \"Rullväljare\",\n        \"Text\": \"Text\",\n        \"max\": \"max\",\n        \"min\": \"min\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"CELLFORMAT\",\n        \"Row ID\": \"Rad ID\",\n        \"SHOW COLUMN\": \"VISA KOLUMN\"\n    },\n    \"WelcomeTour\": {\n        \"Add new\": \"Lägg till ny\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Bläddra i vår {{templateLibrary}} för att upptäcka möjligheter och blir inspirerad.\",\n        \"Building up\": \"Uppbyggnad\",\n        \"Configuring your document\": \"Konfigurera ditt dokument\",\n        \"Customizing columns\": \"Anpassa kolumner\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Dubbelklicka eller tryck {{enter}} på en cell för att redigera den. \",\n        \"Editing Data\": \"Redigera data\",\n        \"Enter\": \"Full i\",\n        \"Flying higher\": \"Flyger högre\",\n        \"Help Center\": \"Hjälpcenter\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Gör den relationell! Använd {{ref}} typen för att länka tabeller. \",\n        \"Reference\": \"Referens\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Ställ in formatinställningar, formler eller kolumntyper,så som datum, alternativ eller bilagor. \",\n        \"Share\": \"Dela\",\n        \"Sharing\": \"Delning\",\n        \"Start with {{equal}} to enter a formula.\": \"Börja med {{equal}} för att fyllai en formel.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Visa {{creatorPanel}} för att formatera kolumner. \",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Använd delaknappen ({{share}}) för att dela dokumentet eller exportera data.\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Använd {{addNew}} för att lägga till widgets, sidor eller importera mer data. \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Använd {{helpCenter}} för dokumentation eller frågor.\",\n        \"Welcome to Grist!\": \"Välkommen till Grist!\",\n        \"convert to card view, select data, and more.\": \"konvertera till kortvy, välj data med mera.\",\n        \"creator panel\": \"designpanel\",\n        \"template library\": \"mallbibliotek\",\n        \"AI Assistant\": \"AI assistent\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Språk\"\n    },\n    \"GristTooltips\": {\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Applicera villkorsstyrd fomatering för celler i denna kolumn när formelvillkoren är uppnådda.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"Applicera villkorsstyrd formatering till rader baserat på formler.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Celler i en referenskolumn innehåller alltid en {{entire}} rad i den refererade tabellen, men du kan välja vilken kolumn från den raden ska visas.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Klicka på \\\"Öppna radstilar\\\" för att applicera villkorsstyrd formatering för rader.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Klicka på Lägg till ny knappen för att skapa nya dokument eller arbetsytor eller importera data.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Att klicka på {{EyeHideIcon}} i varje cell döljer fältet från denna vy utan att radera det.\",\n        \"Editing Card Layout\": \"Redigera kortlayout\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Formler som aktiveras i villa fall, och sparar beräknat värde som data.\",\n        \"Learn more.\": \"Lär dig mer.\",\n        \"Link your new widget to an existing widget on this page.\": \"Länka din nya widget till en existerande widget på denna sida.\",\n        \"Linking Widgets\": \"Länka widgets\",\n        \"Nested Filtering\": \"Filtrering i flera nivåer\",\n        \"Only those rows will appear which match all of the filters.\": \"Enbart de rader som matchar alla filter kommer att synas.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Fästa filer visar som knapar ovanför den widget de tillhör.\",\n        \"Pinning Filters\": \"Fästa filter\",\n        \"Raw Data page\": \"Rådatasida\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Ändra ordningen på fällt i ditt kort genom att dra och ändra storlek på celler.\",\n        \"Reference Columns\": \"Referenskolumner\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Referenskolumner är nyckeln till {{relational}} data i Grist.\",\n        \"Select the table containing the data to show.\": \"Välj tabellen som innehåller datan som ska visas.\",\n        \"Select the table to link to.\": \"Välj tabellen att länka till.\",\n        \"Selecting Data\": \"Att välja data\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Rådatasidan listar alla datatabeller i ditt dokument, inklusive summeringstabeller och tabeller som inte visas på någon sidlayout.\",\n        \"The total size of all data in this document, excluding attachments.\": \"Den totala storleken av all data i detta dokument, exklusive bilagor.\",\n        \"They allow for one record to point (or refer) to another.\": \"De låter en rad att peka på (eller referera till) en annan.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"Detta är hemligheten med Grists dynamik och produktiva layouter.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Testa ändringar i en kopia, du kan sedan bestämma om du vill ersätta originalet med din kopia med ändringar.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Lossa för att dölja knappen och samtidigt behålla filtret.\",\n        \"Updates every 5 minutes.\": \"Uppdateras var 5:e minut.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Använd \\\\u{1D6BA} ikonen för att skapa summeringstabeller (eller pivåtabeller), för totalsummor och delsummor.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Användbart för att lagra tidsstämpeln, vem som skapade en ny rad, datarensning med mera.\",\n        \"You can filter by more than one column.\": \"Du kan filtrera på mer än en kolumn.\",\n        \"entire\": \"hela\",\n        \"relational\": \"relationell\",\n        \"Access Rules\": \"Behörighetsregler\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Behörighetsregler ger dig makten att skapa nyanserade regler som bestämmer vem som kan se eller redigera olika delar i ditt dokument.\",\n        \"Add new\": \"Lägg till ny\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Använd 𝚺 ikonen för att skapa summeringstabeller (eller pivåtabeller), för totalsummor och delsummor.\",\n        \"Anchor Links\": \"Ankarlänkar\",\n        \"Custom Widgets\": \"Anpassade widgetar\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"För att göra en ankarlänk som tar användaren till en specifik cell, klicka på en rad och tryck på {{shortcut}}.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Du kan välja en av våra redan skapade widgetar eller lägga in din egen genom att ange dess kompletta URL.\",\n        \"Calendar\": \"Kalender\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Hittar du inte rätt kolumner? Klicka på 'Ändra widget' för att välja tabellen med händelsedata.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"För att ställa in din kalender, välj columner för start/slut datum och händelsetitel. Observera respektive kolumntyp.\"\n        },\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"En UUID är en slupgenererad sträng som är användbar för att skapa unika referensid och länknycklar.\",\n        \"Lookups return data from related tables.\": \"Uppslag hämtar data från relaterade tabeller.\",\n        \"Use reference columns to relate data in different tables.\": \"Använd referenskolumner för att koppla data i olika tabeller.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Du kan välja bland widgetar som finns i rullgardinsmenyn eller lägga in din egen genom att ange dess kompletta URL.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Formerna stödjer många Excelfunktioner, komplett Python syntax och inkluderar en hjälpsam AIassistent.\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Skapa enkla formulär direkt från Grist och dela med ett klick med vår nya widget. {{learnMoreButton}}\",\n        \"Forms are here!\": \"Formulär finns här!\",\n        \"Learn more\": \"Lär dig mer\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Dessa regler appliceras när alla kolumnregler har hantersts, om applicerbart.\",\n        \"Example: {{example}}\": \"Exempel: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"Filtrera synliga värden i rullgardinsmenyn med ett villkor.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Användargruppswidgets är skapade och underhålls av Grists användargrupps medlemmar.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Skapar en omvänd kolumn i måltabellen som kan redigeras från båda håll.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Denna behränsning uppstår när ena änden av en tvåvägsreferens är inställd som enkel referens.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"För att tillåta flera kopplingar, ändra typen på referenskolumnen till referenslista.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Denna begränsning uppstår när en kolumn i en tvåvägsreferens har typen referens.\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"För att tillåta flera kopplingar, ändra typen på den refererade kolumnen till referenslista.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Tvåvägsreferenser stöds just nu inte för formelkolumner eller kolumner med triggade formler\",\n        \"The preview below this header shows how the selected user will see this document\": \"Förhandsvisningen nedanför denna rubrik visar hur dokumentet ser ur för den valda användaren\",\n        \"[Learn more.]({{link}})\": \"[Lär dig mer.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Summeringstabeller kan bara innehålla formelkolumner.\",\n        \"Manage users and resources in a Grist installation.\": \"Hantera användare och resurser i en Gristinstallation.\",\n        \"The new Grist Assistant is here!\": \"Den nya Gristassistenten är här!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Formler stöder många Excelfunktioner och har full Python syntax.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Skapar en ny referenslistekolumn i måltabellen, med både denna och måltabellens kolumner redigerbara och synkroniserade.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Intern lagring betyder att alla bilagor sparas i dokumentets SQLitefil, medan extern lagring betyder att alla bilagor lagras i denna externa lagring.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Detta gör att du kan lägga till bilagor som saknas i den extern lagringen, tillexempel i ett importerat dokument. Enbart .tar arkiv med bilagor som är nedladdade från Grist kan laddas upp här.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Förstå, ändra och arbeta med dina data och formler med hjälp av Grists nya AIassistent!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Detta formulär är skapad av en Gristanvändare och Grist Labs, Ink eller anna part som erhåller denna tjänst står inte bakom. För din egen säkerhet, fyll inte i lösenord i detta formulär och var försikttig när du trycker på länkar i formuläret. Rapportera bedrägliga formulär till [{{mail}}](mailto:{{mail}}).\",\n        \"Set the maximum number of lines for multi-line text.\": \"Ställ in det maximala antalet rader för flerradstext.\",\n        \"Comments are here!\": \"Kommentarer är här!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Du kan Lägg till kommentarer i celler, svara på en kommentarstråd och @-omnämna deltagare.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"När vald, kommer detta fällts standardvärde att vara förifyllt från URLen genom dess frågeparametrar.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"Med ändringsförslag kan användare göra ändringar i en egen kopia utan att ändra orginaldokumenet, sedan kan de skicka in dessa ändringsförslag för att bli granskade av dokumentägaren innan de accepteras.\",\n        \"Unpin to hide the button while keeping the filter.\": \"Lossa för att dölja knappen och samtidigt behålla filtret.\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"BESKRIVNING\",\n        \"Set description\": \"Ställ in beskrivning\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"Stäng designpanel\",\n        \"Open creator panel\": \"Öppna designpanel\",\n        \"Creator panel (right panel)\": \"Designpanel (höger panel)\",\n        \"Document header\": \"Dokument rubrik\",\n        \"Main content\": \"Huvudinnehåll\",\n        \"Main navigation and document settings (left panel)\": \"Huvudmeny and dokumentinställningar (vänster panel)\",\n        \"Close navigation panel (left panel)\": \"Stäng navigationspanel (vänster panel)\",\n        \"Open navigation panel (left panel)\": \"Öppna navigationspanel (vänster panel)\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Uppfattat\",\n        \"Unavailable Command\": \"Kommandot är inte tillgängligt\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"Detta {{action}} meny kommando är inte tillgängligt i denna webbläsare. Du kan fortfarande {{action}} genom att använda kortkommandot {{shortcut}}.\"\n    },\n    \"FieldContextMenu\": {\n        \"Clear field\": \"Rensa fällt\",\n        \"Copy\": \"Kopiera\",\n        \"Copy anchor link\": \"Kopiera ankarlänk\",\n        \"Cut\": \"Klipp ut\",\n        \"Hide field\": \"Dölj fällt\",\n        \"Paste\": \"Klistra in\",\n        \"Comment\": \"Kommentera\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Rensa kö\",\n        \"Webhook settings\": \"Webhookinställningar\",\n        \"Cleared webhook queue.\": \"Rensade webhookskön.\",\n        \"Columns to check when update (separated by ;)\": \"Kolumner att kontrollera vid uppdatering (separerade av ;)\",\n        \"Enabled\": \"Aktiverad\",\n        \"Event Types\": \"Aktivitetstyper\",\n        \"Memo\": \"Pm\",\n        \"Name\": \"Namn\",\n        \"Ready Column\": \"Färdigkolumn\",\n        \"Removed webhook.\": \"Tog bort webhook.\",\n        \"Sorry, not all fields can be edited.\": \"Beklagar, alla fällt kan inte redigeras.\",\n        \"Status\": \"Status\",\n        \"URL\": \"URL\",\n        \"Webhook Id\": \"Webhookid\",\n        \"Table\": \"Tabell\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Filtrera efter ändringar i dessa kolumner (semikolonseparerade id:n)\",\n        \"Header Authorization\": \"Header Auktorisering\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooks är inte tillgängliga i dokument kopior som ej är sparade\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Klicka för att infoga\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"Välkommen tillbaka\",\n        \"You can always switch sites using the account menu.\": \"Du kan alltid växla mellan sajter genom kontomenyn.\",\n        \"You have access to the following Grist sites.\": \"Du har tillgång till följande Gristsajter.\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"BESKRIVNING\"\n    },\n    \"UserManager\": {\n        \"Add {{member}} to your team\": \"Lägg till {{member}} till ditt arbetslag\",\n        \"Allow anyone with the link to open.\": \"Tillåt alla med länken att öppna.\",\n        \"Anyone with link \": \"Alla med länken \",\n        \"Cancel\": \"Avbryt\",\n        \"Close\": \"Stäng\",\n        \"Collaborator\": \"Deltagare\",\n        \"Confirm\": \"Bekräfta\",\n        \"Copy link\": \"Kopiera länk\",\n        \"Create a team to share with more people\": \"Skapa ett arbetslag för att dela med fler personer\",\n        \"Grist support\": \"Grist support\",\n        \"Guest\": \"Gäst\",\n        \"Invite multiple\": \"Bjud in flera\",\n        \"Invite people to {{resourceType}}\": \"Bjud personer till {{resourceType}}\",\n        \"Link copied to clipboard\": \"Länk kopierad till utklipp\",\n        \"Manage members of team site\": \"Hantera medlemmar i arbetslagsajt\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Ingen standardbehörighet tillåter behörighet att          styras för respektive  dokument och arbetsyta, istället för hela arbetslagssajten.\",\n        \"Off\": \"Av\",\n        \"On\": \"På\",\n        \"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}}.\": \"När du har tagit bor din egen behörighet             kommer du inte att kunna komma tillbaka utan hjälp              från någon annan med tillräcklig behörighet till {{name}}.\",\n        \"Open Access Rules\": \"Öppna behörighetsregler\",\n        \"Outside collaborator\": \"Extern deltagare\",\n        \"Public access\": \"Publik åtkomst\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Publik åtkomst ärvd från {{parent}}. För att ta bort, ställ in 'Ärv behörighet' inställningen till 'Ingen'.\",\n        \"Public access: \": \"Publik åtkomst: \",\n        \"Remove my access\": \"Ta bort min behörighet\",\n        \"Save & \": \"Spara & \",\n        \"Team member\": \"Arbetslagsmedlem\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Användare ärver behörigheter från {{parent})}. För att ta bort           ställ in 'Ärv behörighet' inställningen till 'Ingen'.\",\n        \"User may not modify their own access.\": \"Användare kan inte ändra sin egen behörighet.\",\n        \"Your role for this team site\": \"Din roll för denna arbetslagssajt\",\n        \"Your role for this {{resourceType}}\": \"Din roll för denna {{resourceType}}\",\n        \"free collaborator\": \"gratis deltagare\",\n        \"guest\": \"gäst\",\n        \"member\": \"medlem\",\n        \"team site\": \"arbetslagssajt\",\n        \"{{collaborator}} limit exceeded\": \"{{collaborator}} begränsning överskriden\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} av {{limitTop}} {{collaborator}}e\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Ingen standardbehörighet gör det möjligt att ge tillgång till individuella dokument eller arbetsytor, istället för till hela arbetslagssajten.\",\n        \"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}}.\": \"När du har tagit bort din egen behörighet, kan du inte få tillbaka den utan hjälp från någon som har tillräcklig behörighet till {{resourceType}}.\",\n        \"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.\": \"Användaren har läsbehörighet till {{resource}} genom en manuell inställning för åtkomst till resurser där i. Om den tas bort här kommer denna användare förlora tillgången till resurserna där i.\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Användaren ärver behörigheter från {{parent}}. För att ta bort ställ in 'Ärv behörighet' inställningen till 'Ingen'.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Du är på väg att ta bort din egen behörighet för {{resourceType}}\",\n        \"Inherit access: \": \"Ärv behörighet: \",\n        \"Access overview\": \"Behörighetsöversikt\",\n        \"Share it publicly\": \"Dela den ofantligt\",\n        \"Verify your sensitive data before sharing publicly\": \"Kontrollera din känsliga data innan du delar ofäntligt\",\n        \"Your {{resourceType}} will be accessible to anyone with the link, whether shared directly or found through a search engine. \\n Ensure that your {{resourceType}} does not contain sensitive data before sharing.\": \"Din {{resourceType}} kommer att vara tillgänglig för vem som helst med länken, oavsett om den delas direkt eller hittas via en sökmotor.\\n Säkerställ att din {{resourceType}} inte innehåller någon känslig data innan du delar.\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Sök på alla sidor\",\n        \"Search all tables\": \"Sök i alla tabeller\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Sök\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"Visar {{displayedCount}} av {{totalCount}} träffar. Sök efter fler.\"\n    },\n    \"SupportGristNudge\": {\n        \"Close\": \"Stäng\",\n        \"Contribute\": \"Bidra\",\n        \"Help Center\": \"Hjälpcenter\",\n        \"Opt in to Telemetry\": \"Aktivera tillgång till telemetri\",\n        \"Opted In\": \"Aktiverad\",\n        \"Support Grist\": \"Stöd Grist\",\n        \"Support Grist page\": \"Stöd Gristsida\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Tack! Ditt förtroende och stöd är mycket uppskattat. Avaktivera när som helst på {{link}} i använarmenyn.\",\n        \"Admin Panel\": \"Administrationspanel\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"GitHub sponsorsida\",\n        \"Help Center\": \"Hjälpcenter\",\n        \"Home\": \"Hem\",\n        \"Manage Sponsorship\": \"Hantera sponsring\",\n        \"Opt in to Telemetry\": \"Aktivera telemetri\",\n        \"Opt out of Telemetry\": \"Avaktivera telemetri\",\n        \"Sponsor Grist Labs on GitHub\": \"Sponsra Grist Labs på GitHub\",\n        \"Support Grist\": \"Stöd Grist\",\n        \"Telemetry\": \"Telemetri\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Denna instans har telemetri aktiverat. Det är bara sajt administratören som har behörighet att ändra detta.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Denna instans har inte telemetri aktiverat. Det är bara sajt administratören som har behörighet att ändra detta.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Vi samlar bra användarstatistik som beskrivs på vår {{link}}, aldrig dokumentinnehåll.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Du kan avaktivera telemetri när som helst från denna sida.\",\n        \"You have opted in to telemetry. Thank you!\": \"Du har aktiverat telemetri. Tack!\",\n        \"You have opted out of telemetry.\": \"Du har avaktiverat telemetri.\",\n        \"Sponsor\": \"Sponsor\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Gristmkukvaran är utvecklad av Girst Labs, som erbjuder gratis och betalda abbonemang. Vi gör också Gristkoden tillgänglig under en standard, gratis och öppen OSS licens (Apache 2.0) på {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Stöd Grist genom att aktivera telemetri, som hjälper oss förstå hur produkten används och därmed prioritera framtida förbättringar.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Vi är ett litet och dedikerat arbetslag. Ditt stöd betyder mycket för oss. Det visar också andra att det finns en dedikerad användargrupp bakom produkten.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Du kan stödja Grist söppenkällkodsutveckling genom att sponsra oss på vår {{link}}.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Ingen data\",\n        \"No row selected in {{title}}\": \"Ingen rad vald i {{title}}\",\n        \"Not all data is shown\": \"Alla dara visas inte\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Komprimera redigerare\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Maximera\",\n        \"Minimize\": \"Minimera\"\n    },\n    \"CardContextMenu\": {\n        \"Copy anchor link\": \"Kopiera ankarlänk\",\n        \"Delete card\": \"Radera kort\",\n        \"Duplicate card\": \"Duplicera kort\",\n        \"Insert card\": \"Infoga kort\",\n        \"Insert card above\": \"Infoga kort ovanför\",\n        \"Insert card below\": \"Infoga kort nedanför\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Dolda fällt\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"gratis coachingsamtal\",\n        \"Maybe later\": \"Kanske senare\",\n        \"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.\": \"Under samtalet kommer vi att sätta oss in dina/era behov och skräddarsy samtalet därefter. Vi kan visa dig grunderna i Grist eller börja arbeta med din data direkt för att skapa de kontrollpaneler du behöver.\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Svhemalägg ditt {{freeCoachingCall}} med en av oss.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Du kan också kolla in {{ourWeeklyWebinars}} för att lära dig mer om Grist.\",\n        \"our weekly webinars\": \"våra veckovisa webinarier\",\n        \"Free coaching call\": \"Gratis coachingsamtal\",\n        \"Grist 101\": \"Grist ABC\",\n        \"Schedule call\": \"Schemalägg samtal\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"Du kan också kolla in vårt introduktions webinar, {{ourWeeklyWebinars}}, designad för att hjälpa nya användare                att komma under fund med grunderna i Grist.\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"Du kan också kolla in vårt introduktions webinar, {{ourWeeklyWebinars}}, designad för att hjälpa nya användare att komma under fund med grunderna i Grist.\"\n    },\n    \"FormView\": {\n        \"Publish\": \"Publicera\",\n        \"Publish your form?\": \"Publicera ditt formulär?\",\n        \"Unpublish\": \"Avpublicera\",\n        \"Unpublish your form?\": \"Avpublicera ditt formulär?\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Alla med länken nedan kan se det tomma formuläret och skicka in ett svar.\",\n        \"Are you sure you want to reset your form?\": \"Är du säker på att du vill återställa ditt formulär?\",\n        \"Code copied to clipboard\": \"Kod kopierad till utklipp\",\n        \"Copy code\": \"Kopiera kod\",\n        \"Copy link\": \"Kopiera länk\",\n        \"Embed this form\": \"Bäddain detta formulär\",\n        \"Link copied to clipboard\": \"Länk kopierad till utklipp\",\n        \"Preview\": \"Förhandsvisa\",\n        \"Reset\": \"Återställ\",\n        \"Reset form\": \"Återställ formulär\",\n        \"Save your document to publish this form.\": \"Spara ditt dokument för att publicera detta formulär.\",\n        \"Share\": \"Dela\",\n        \"Share this form\": \"Dela detta formulär\",\n        \"View\": \"Visa\",\n        \"# **Form Title**\": \"# **Formulärtitel**\",\n        \"Your form description goes here.\": \"Din formulärbeskrivning läggs in här.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"Att publicera ditt formulär genererar en delningslänk. Alla med länken kan se det tomma formuläret och skicka in ett svar.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Att avpublicera formuläret kommer att stänga av delningslänken så att användare som kommer åt ditt formulär via den länken får se ett felmeddelande.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Användare är begränsade till att skicka in svar (rader i din tabell) och läsa förifyllda värden i vissa fällt, så som referens- och alternativkolumner.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Ditt formulär år publicerat. Alla ändringar är live och synliga för användare med tillgång till formuläret. Om du vill göra ändringar i ett utkast, avpublicera formuläret.\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Radera\"\n    },\n    \"Menu\": {\n        \"Building blocks\": \"Byggstenar\",\n        \"Columns\": \"Kolumner\",\n        \"Copy\": \"Kopiera\",\n        \"Cut\": \"Klipp ut\",\n        \"Insert question above\": \"Infoga fråga ovanför\",\n        \"Insert question below\": \"Infoga fråga nedanför\",\n        \"Paragraph\": \"Stycke\",\n        \"Paste\": \"Klistra in\",\n        \"Separator\": \"Avdelare\",\n        \"Unmapped fields\": \"Koppla loss fällt\",\n        \"Header\": \"Sidhuvud\",\n        \"New question\": \"Ny fråga\",\n        \"More\": \"Mer\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Clear\": \"Rensa\",\n        \"Map fields\": \"Koppla fällt\",\n        \"Mapped\": \"Kopplade\",\n        \"Select all\": \"Välj alla\",\n        \"Unmap fields\": \"Koppla loss fällt\",\n        \"Unmapped\": \"Okopplade\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Fältregler\",\n        \"Required field\": \"Obligatoriskt fällt\",\n        \"Ascending\": \"Stigande\",\n        \"Default\": \"Standard\",\n        \"Descending\": \"Fallande\",\n        \"Field Format\": \"Fälltformat\",\n        \"Field Rules\": \"Fälltregler\",\n        \"Horizontal\": \"Horisontell\",\n        \"Options Alignment\": \"Inställningsjustering\",\n        \"Options Sort Order\": \"Ordning för inställningssortering\",\n        \"Radio\": \"Radio\",\n        \"Select\": \"Välj\",\n        \"Vertical\": \"Vertikal\",\n        \"Accept value from URL\": \"Acceptera värde från URL\",\n        \"Hidden field\": \"Dolt fällt\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"URL parameter\\n{{colId}}=VÄRDE\",\n        \"Options limit\": \"Inställningsbegränsningar\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Några obligatoriskt kolumner är inte kopplade\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"För att använda denna widget koppla alla obligatoriska kolumner från designpanelen till höger.\",\n        \"Some required columns are hidden by access rules\": \"Några obligatoriska kolumner är dolda av behörighetsregler\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"För att använda denna widget måste alla kopplade kolumner vara synliga. Var god kontakta dokumentägaren eller ändra behörighetsreglerna.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Skapa ditt eget formulär\",\n        \"Powered by\": \"Drivs av\",\n        \"Powered by Grist\": \"Drivs av Grist\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Fel\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"Oops! Formuläret du försöker öppna finns inte.\",\n        \"Oops! This form is no longer published.\": \"Oops! Detta formulär är inte publicerat längre.\",\n        \"There was a problem loading the form.\": \"Ett problem uppstod när formuläret skulle laddas.\",\n        \"You don't have access to this form.\": \"Du har inte behörighet till detta formulär.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Det uppstod ett fel när ditt formulär skulle skickas in. Var god försök igen.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Formuläret inskickat\",\n        \"Thank you! Your response has been recorded.\": \"Tack! Ditt svar är sparat.\",\n        \"Submit new response\": \"Skicka ett nytt svar\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"Senaste 30 dagarna\",\n        \"Last 7 days\": \"Senaste 7 dagarna\",\n        \"Last week\": \"Senaste veckan\",\n        \"Next 7 days\": \"Kommande 7 dagarna\",\n        \"This month\": \"Denna månad\",\n        \"This week\": \"Denna vecka\",\n        \"This year\": \"Detta år\",\n        \"Today\": \"Idag\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Rensa\",\n        \"Map fields\": \"Koppla fällt\",\n        \"Mapped\": \"Kopplade\",\n        \"Select all\": \"Välj alla\",\n        \"Unmap fields\": \"Okopplade fällt\",\n        \"Unmapped\": \"Okopplade\",\n        \"Hide {{label}}\": \"Dölj {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Dölj {{label}} (gruppvis)\",\n        \"Unmap {{label}}\": \"Koppla loss {{label}}\",\n        \"Unmap {{label}} (batch mode)\": \"Koppla loss {{label}} (gruppvis)\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Infoga sektion ovanför\",\n        \"Insert section below\": \"Infoga sektion nedanför\",\n        \"## **Header**\": \"## **Rubrik**\",\n        \"Description\": \"Beskrivning\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Avbryt\",\n        \"Choose a name and url for your team site\": \"Välj ett namn och URL för din arbetslagsajt\",\n        \"Create site\": \"Skapa sajt\",\n        \"Domain name is invalid\": \"Domännamnet är inte giltigt\",\n        \"Domain name is required\": \"Domännamn är obligatoriskt\",\n        \"Go to your site\": \"Gå till din sajt\",\n        \"Team name\": \"Arbetslagsnamn\",\n        \"Team name is required\": \"Arbetslagsnamn är obligatoriskt\",\n        \"Team site created\": \"Arbetslagsajt skapad\",\n        \"Team url\": \"ArbetslagsURL\",\n        \"Work as a Team\": \"Jobba som ett arbetslag\",\n        \"Billing is not supported in grist-core\": \"Fakturering stöds inte i grist-core\"\n    },\n    \"AdminPanel\": {\n        \"Admin Panel\": \"Administrationspanel\",\n        \"Current\": \"Nuvarande\",\n        \"Current version of Grist\": \"Nuvarande version av Grist\",\n        \"Help us make Grist better\": \"Hjälp oss att göra Grist bättre\",\n        \"Home\": \"Hem\",\n        \"Sponsor\": \"Sponsor\",\n        \"Support Grist\": \"Stöd Grist\",\n        \"Support Grist Labs on GitHub\": \"Stöd Grist Lab på GitHub\",\n        \"Telemetry\": \"Telemetri\",\n        \"Version\": \"Version\",\n        \"Auto-check when this page loads\": \"Kontrollera automatiskt när denna sida laddas\",\n        \"Check now\": \"Kolla nu\",\n        \"Checking for updates...\": \"Söker efter uppdateringar...\",\n        \"Error\": \"Fel\",\n        \"Error checking for updates\": \"Fel vid sökning efter uppdateringar\",\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 signerar användarens sessionskakor med en hemlig nyckel. Ställ in denna nyckel via miljövariabeln GRIST_SESSION_SECRET. Grist använder till en hårdkodad standardinställning när den inte är inställd. Vi kan komma att ta bort detta meddelande i framtiden eftersom sessions-ID:n som genererats sedan v1.1.16 är i sig kryptografiskt säkra.\",\n        \"Grist is up to date\": \"Grist är uppdaterad\",\n        \"Grist releases are at \": \"Grist versioner finns på \",\n        \"Last checked {{time}}\": \"Senast kontrollerad {{time}}\",\n        \"Learn more.\": \"Lär dig mer.\",\n        \"Newer version available\": \"Nyare version tillgänglig\",\n        \"No information available\": \"Ingen information tillgänglig\",\n        \"OK\": \"OK\",\n        \"Sandbox settings for data engine\": \"Sandbox-Inställningar för databasmotor\",\n        \"Sandboxing\": \"Sandboxning\",\n        \"Security Settings\": \"Säkerhetsinställningar\",\n        \"Updates\": \"Uppdateringar\",\n        \"unconfigured\": \"inte inställd\",\n        \"unknown\": \"okänd\",\n        \"Administrator Panel Unavailable\": \"Administratörspanelen är inte tillgänglig\",\n        \"Authentication\": \"Autentisering\",\n        \"Check failed.\": \"Kontrollen misslyckades.\",\n        \"Check succeeded.\": \"Kontrollen lyckades.\",\n        \"Current authentication method\": \"Nuvarande autentiseringmetod\",\n        \"Details\": \"Detaljer\",\n        \"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 tillåter konfigurering av olika typer av autentisering, inklusive SAML och OIDC.     Vi rekommenderar att du aktiverar en av dessa om Grist är tillgängligt över nätverket eller görs tillgängligt      för flera personer.\",\n        \"No fault detected.\": \"Ingen fel upptäckt.\",\n        \"Notes\": \"Anteckningar\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Eller, som en nödlösning, så kan du ställa in: miljövariabeln {{bootKey}} och öppna: {{url}}\",\n        \"Results\": \"Resultat\",\n        \"Self Checks\": \"Självkontroller\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"Du har inte behörighet för att komma åt administratörspanelen.\\nLogga in som administratör.\",\n        \"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 tillåter konfigurering av olika typer av autentisering, inklusive SAML och OIDC. Vi rekommenderar att du aktiverar en av dessa om Grist är tillgängligt över nätverket eller görs tillgängligt för flera personer.\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"Grist signerar användarens sessionskakor med en hemlig nyckel. Ställ in denna nyckel via miljövariabeln GRIST_SESSION_SECRET. Grist använder till en hårdkodad standardinställning när den inte är inställd. Vi kan komma att ta bort detta meddelande i framtiden eftersom sessions-ID:n som genererats sedan v1.1.16 är i sig kryptografiskt säkra.\",\n        \"Key to sign sessions with\": \"Nyckel att signera sessioner med\",\n        \"Session Secret\": \"Sessionshemlighet\",\n        \"Enable Grist Enterprise\": \"Aktivera Grist för företag\",\n        \"Enterprise\": \"Företagsversion\",\n        \"checking\": \"kontrollerar\",\n        \"Audit Logs\": \"Granskningsloggar\",\n        \"Contact us\": \"Kontakta oss\",\n        \"Log Streaming\": \"Strömma logning\",\n        \"New, Enterprise\": \"Ny, Företagsversion\",\n        \"Off\": \"Av\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} mer\",\n        \"{{count}} admin accounts_one\": \"{{count}} administrationskonto\",\n        \"{{count}} admin accounts_other\": \"{{count}} administartionskonton\",\n        \"On\": \"På\",\n        \"Grist Instance\": \"Grist-instans\",\n        \"Auto-check weekly\": \"Kontrollerar automatiskt veckovis\",\n        \"No record of last version check\": \"Det finns ingen information om senaste versionskontroll\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Du kan konfigurera strömning av loggar från Grist till ett externt SIEM-system (säkerhetsinformation och händelsehantering) om du aktiverar Grist för företag. {{contactUsLink}} för att läsa mer.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Automatiska kontrollera avaktiverade. Ställ in miljövariabeln GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING till \\\"true\\\" för att aktivera dom.\",\n        \"auth error\": \"autentiseringsfel\",\n        \"configured\": \"inställd\",\n        \"default\": \"standard\",\n        \"more...\": \"mer...\",\n        \"no authentication\": \"ingen autentisering\",\n        \"unavailable\": \"inte tillgänglig\",\n        \"Admin account not found\": \"Administrationskonto kunde inte hittas\",\n        \"Administrative accounts\": \"Administrationskonton\",\n        \"The users with administrative accounts\": \"Användare med administratörskonton\",\n        \"Version {{versionNumber}}\": \"Version {{versionNumber}}\",\n        \"no admin accounts\": \"inga administratörskonton\",\n        \"Are you sure you want to restart Grist?\": \"Är du säker på att du vill starta om Grist?\",\n        \"Grist is running in an environment that doesn't support restarting from the admin panel.\": \"Grist kör i en miljö som inte stöder omstart från administrationspanelen.\",\n        \"Restart\": \"Starta om\",\n        \"Restart Grist\": \"Starta om Grist\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"Starta om Grist för att applicera väntande ändringar eller lösa problem.\",\n        \"Restart Grist?\": \"Start om Grist?\",\n        \"Restarting Grist...\": \"Startar om Grist...\",\n        \"This will apply any pending changes and briefly interrupt access for all users.\": \"Detta kommer att applicera alla väntande ändringar och kortvarigt bryta åtkomsten för alla användare.\",\n        \"You can still restart Grist manually.\": \"Du kan fortfarande starta om Grist manuellt.\",\n        \"error in {{provider}}: {{verdict}}\": \"fel i {{provider}}: {{verdict}}\",\n        \"Please restart Grist manually.\": \"Start om Grist manuellt.\",\n        \"Restart Grist to apply pending changes.\": \"Start om Grist för att aktivera väntande ändringar.\",\n        \"Restart unavailable\": \"Om start inte tillgänglig\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Ta bort kolumn\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Inga val inställda\",\n        \"No values in show column of referenced table\": \"Inga värden i visakolumnen i den refererade tabellen\",\n        \"Hide\": \"Dölj\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"Kryssruta\",\n        \"Field Format\": \"Fälltformat\",\n        \"Switch\": \"Växla\"\n    },\n    \"ChoiceEditor\": {\n        \"Error in dropdown condition\": \"Fel i rullgardinsmenyvillkor\",\n        \"No choices matching condition\": \"Inga alternativ matchar villkoret\",\n        \"No choices to select\": \"Inga alternativ att välja\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"Fel i rullgardinsmenyvillkor\",\n        \"No choices matching condition\": \"Inga alternativ matchar villkoret\",\n        \"No choices to select\": \"Inga alternativ att välja\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"Rullgardinsmenyvillkor\",\n        \"Invalid columns: {{colIds}}\": \"Ogiltiga kolumner: {{colIds}}\",\n        \"Set dropdown condition\": \"Ställ in rullgardinsmenyvillkor\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Fyll i villkor.\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Fel i rullgardinsmenyvillkor\",\n        \"No choices matching condition\": \"Inga alternativ matchar villkoret\",\n        \"No choices to select\": \"Inga alternativ att välja\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"återställ\",\n        \"Search\": \"Sök\",\n        \"Select...\": \"Välj...\",\n        \"Submit\": \"Skicka in\",\n        \"Submitting…\": \"Skickar in…\",\n        \"Clear selection for: {{-inputLabel}}\": \"Rensa val för: {{-inputLabel}}\"\n    },\n    \"widgetTypesMap\": {\n        \"Calendar\": \"Kalender\",\n        \"Card\": \"Kort\",\n        \"Card List\": \"Kortlista\",\n        \"Chart\": \"Diagram\",\n        \"Custom\": \"Anpassad\",\n        \"Form\": \"Formulär\",\n        \"Table\": \"Tabell\"\n    },\n    \"TimingPage\": {\n        \"Average Time (s)\": \"Snittid (s)\",\n        \"Column ID\": \"Kolumn ID\",\n        \"Formula timer\": \"Formeltidtagare\",\n        \"Loading timing data. Don't close this tab.\": \"Laddar tidtagningsdata. Stäng inte denna flik.\",\n        \"Max Time (s)\": \"Max tid (s)\",\n        \"Number of Calls\": \"Antal anrop\",\n        \"Table ID\": \"Tabell ID\",\n        \"Total Time (s)\": \"Total tid (s)\"\n    },\n    \"DocTutorial\": {\n        \"Click to expand\": \"Klicka för att expandera\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Vill du starta om instruktionsguiden? Allt du slutfört försvinner.\",\n        \"End tutorial\": \"Avsluta instruktionsguide\",\n        \"Finish\": \"Avsluta\",\n        \"Next\": \"Nästa\",\n        \"Previous\": \"Föregående\",\n        \"Restart\": \"Start om\"\n    },\n    \"OnboardingCards\": {\n        \"3 minute video tour\": \"3 minuters videogenomgång\",\n        \"Complete our basics tutorial\": \"Slutför vår grundläggande instruktionsguide\",\n        \"Complete the tutorial\": \"Slutför instruktionsguiden\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Lär dig grunderna för referenskolumner, länkade widgets, kolumntyper & kort.\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Lär dig grunderna för referenskolumner, länkade widgets, kolumntyper & kort.\"\n    },\n    \"OnboardingPage\": {\n        \"Back\": \"Tillbaka\",\n        \"Discover Grist in 3 minutes\": \"Upptäck Grist på 3 minuter\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Testa själv med Grists grundläggande instruktionsguide\",\n        \"Go to the tutorial!\": \"Gå till instruktionsguide!\",\n        \"Next step\": \"Nästa steg\",\n        \"Skip step\": \"Hoppa över steg\",\n        \"Skip tutorial\": \"Hoppa över instruktionsguide\",\n        \"Tell us who you are\": \"Berätta för oss vem du är\",\n        \"Type here\": \"Skriv här\",\n        \"Welcome\": \"Välkommen\",\n        \"What brings you to Grist (you can select multiple)?\": \"Hur kom du i kontakt med Grist (Du kan välja flera)?\",\n        \"What is your role?\": \"Vilken är din roll?\",\n        \"What organization are you with?\": \"Vilken organisation är du från?\",\n        \"Your organization\": \"Din organisation\",\n        \"Your role\": \"Din roll\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist kan se ut som ett kalkylblad, men det fungerar inte alltid som ett. Upptäck vad som gör Grist annorlunda.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"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}}).\": \"En aktiveringsnyckel används för att köra Grist Företagsversion efter att försöksperioden\\npå 30 dagar är slut. Skaffa en aktiveringsnyckel genom att [registrera för Grist\\nFöretagsversion]({{signupLink}}). Du behöver ingen aktiveringsnyckel för att köra \\nGrist Core.\\n\\nLär dig mer i vårt [Hjälpcenter]({{helpCenter}}).\",\n        \"Disable Grist Enterprise\": \"Avaktivera Grist företagsversion\",\n        \"Enable Grist Enterprise\": \"Akrivera Grist företagsversion\",\n        \"Grist Enterprise is **enabled**.\": \"Grist företagsversion är **aktiverad**.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"En aktiveringsnyckel används för att köra Grist Företagsversion efter att försöksperioden\\npå 30 dagar är slut. Skaffa en aktiveringsnyckel genom att [kontakta oss]({{contactLink}}). Du behöver ingen aktiveringsnyckel för att köra \\nGrist Core.\\n\\nLär dig mer i vårt [Hjälpcenter]({{helpCenter}}).\",\n        \"Activate\": \"Aktivera\",\n        \"Activation key\": \"Aktiveringsnyckel\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"En aktiveringsnyckel används för att köra Grist Företagsversion efter att försöksperioden\\n        på 30 dagar är slut. Skaffa en aktiveringsnyckel genom att [registrera för Grist\\n        Företagsversion]({{signupLink}}). Du behöver ingen aktiveringsnyckel för att köra \\n        Grist Core.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Ett aktivt abonnemang krävs för att köra Grist Företagsversion. Du kan aktivera ditt abonnemang genom att [registrera för Grist\\nFöretagsversion]({{signupLink}}) ock klistra in\\naktiveringsnyckeln nedan.\",\n        \"Copy to clipboard\": \"Kopiera till utklipp\",\n        \"Expiration date\": \"Utgångsdatum\",\n        \"Installation ID copied to clipboard\": \"Installations ID kopierat till utklipp\",\n        \"Installation ID:\": \"Installations ID:\",\n        \"Installation seats\": \"Installations säten\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Lär dig mer i vårt [Hjälpcenter]({{helpCenter}}).\",\n        \"Paste your activation key\": \"Klistra in din aktiveringdnyckel\",\n        \"Plan name\": \"Abonnemangsnamn\",\n        \"You do not have an active subscription.\": \"Du har inget aktivt abonnemang.\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"För att fortsätta använda Grist Företagsversion behöver du\\n                   [kontakta oss]({{signupLink}}) för att få din aktiveringsnyckel.\",\n        \"You are currently trialing Grist Enterprise.\": \"Du har just nu en provperiod av Grist företagsversion.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Din aktiveringsnyckel har gått ut på grund av att begränsningar överskridits.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Din instans kommer övergå till **skrivskyddat** läge om **{{days}}** dag(ar).\",\n        \"Your subscription expired on {{date}}.\": \"Ditt abonnemang tog slut {{date}}.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Din provperiod tog slut **{{expireAt}}**. För att fortsätta använda Grist företagsversion, du behöver\\n[Registrera dig för Grist företagsversion]({{signupLink}}) och klistra in aktiveringsnyckeln nedan.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Radera\",\n        \"Delete data and this widget.\": \"Radera data och denna widget.\",\n        \"Raw Data page\": \"Rådatasida\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Behåll datan och radera widget. Tabellen kommer fortsatt vara tillgänglig på {{rawDataLink}}\",\n        \"Table {{tableName}} will no longer be visible\": \"Tabell {{tableName}} kommer inte längre att vara synlig\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Administrationspanel\"\n    },\n    \"CustomWidgetGallery\": {\n        \"(Missing info)\": \"(Information saknas)\",\n        \"Add widget\": \"Lägg till widget\",\n        \"Add Your Own Widget\": \"Lägg till din egen widget\",\n        \"Add a widget from outside this gallery.\": \"Lägg till en widget från annan plats.\",\n        \"Cancel\": \"Avbryt\",\n        \"Change widget\": \"Ändra widget\",\n        \"Choose custom widget\": \"Välj anpassad widget\",\n        \"Community Widget\": \"Widget från användargruppen\",\n        \"Custom URL\": \"Anpassad URL\",\n        \"Developer:\": \"Utvecklare:\",\n        \"Grist Widget\": \"Grist widget\",\n        \"Last updated:\": \"Senast uppdaterad:\",\n        \"Learn more about custom widgets\": \"Lär dig mer om anpassade widgets\",\n        \"No matching widgets\": \"Inga matchande widgets\",\n        \"Search\": \"Sök\",\n        \"Widget URL\": \"Widget URL\"\n    },\n    \"markdown\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Ny Markdownfunktion\\n\\n      Vi kan _skriva_ [vanlig Markdown](https://markdownguide.org) *inne* i\\n      ett Grainjs element.\"\n            }\n        },\n        \"The toggle is **off**\": \"Detta av/på-val är **av**\",\n        \"The toggle is **on**\": \"Detta av/på-val är **på**\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Ny Markdownfunktion\\n\\n      Vi kan _skriva_ [vanlig Markdown](https://markdownguide.org) *inne* i\\n      ett Grainjs element.\"\n            }\n        },\n        \"The toggle is **off**\": \"Detta av/på-val är **av**\",\n        \"The toggle is **on**\": \"Detta av/på-val är **på**\"\n    },\n    \"HomeIntroCards\": {\n        \"3 minute video tour\": \"3 minuters videogenomgång\",\n        \"Blank document\": \"Tomt dokument\",\n        \"Find solutions and explore more resources\": \"Hitta läsningar och utforska fler resurser\",\n        \"Finish our basics tutorial\": \"Slutför vår grundläggande instruktionsguide\",\n        \"Help center\": \"Hjälpcenter\",\n        \"Import file\": \"Importera fil\",\n        \"Learn more\": \"Lär dig mer\",\n        \"Start a new document\": \"Börja med ett nytt dokument\",\n        \"Templates\": \"Mallar\",\n        \"Tutorial\": \"Instruktionsguide\",\n        \"Webinars\": \"Webinarier\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Add two-way reference\": \"Lägg till tvåvägsreferens\",\n        \"Column\": \"Kolumn\",\n        \"Delete\": \"Radera\",\n        \"Delete column {{column}} in table {{table}}?\": \"Radera kolumn {{column}} i tabell {{table}}?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Det är det omvända av referenskolumn {{column}} i tabell {{table}}.\",\n        \"Table\": \"Tabell\",\n        \"Two-way Reference\": \"Tvåvägsreferens\",\n        \"Delete two-way reference?\": \"Radera tvåvägsreferens?\",\n        \"Target table\": \"Måltabell\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Detta kommer att radera referenskolumn {{refCol}} i tabell {{refTable}}. Referenskolumnen {{myName}} kommer att finnas kvar i nuvarande tabell {{myTable}}.\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"Administrationspanel\",\n        \"Close\": \"Stäng\",\n        \"Help Center\": \"Hjälpcenter\",\n        \"Opt in to Telemetry\": \"Aktivera telemetri\",\n        \"Opted In\": \"Aktiverad\",\n        \"Support Grist\": \"Stöd Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Tack! Ditt förtroende och stöd är mycket uppskattat. Avaktivera när som helst på {{link}} i användarmenyn.\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Stöd Grist genom att aktivera telemetri, som hjälper oss förstå hur produkten används, så att vi kan prioritera framtida förbättringar.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Vi samlar bra användarstatistik som beskrivs i vårt {{helpCenterLink}}, aldrig dokumentinnehåll. Avaktivera när som helst från {{supportGristLink}} i användarmenyn.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Avbryt\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Respektive {{targetTable}} rad kan bara kopplas till en enda rad i {{sourceTable}}.\",\n        \"Reassign\": \"Byt koppling\",\n        \"Reassign to new {{sourceTable}} records.\": \"Byt koppling till en nya {{sourceTable}} rader.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Byt koppling till {{sourceTable}} rad {{sourceName}}.\",\n        \"Record already assigned_one\": \"Raden är redan kopplad\",\n        \"Record already assigned_other\": \"Raderna är redan kopplade\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} rad {{targetName}} är redan kopplad till {{sourceTable}} rad           {{oldSourceName}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit Logs\": \"Audit loggar\",\n        \"Audit logs for {{siteName}}\": \"Auditloggar för {{siteName}}\",\n        \"Contact us\": \"Kontakta oss\",\n        \"Home\": \"Hem\",\n        \"Log streaming\": \"Loggströmning\",\n        \"Only site owners may access audit logs.\": \"Enbart sajtägare kan komma åt auditloggar.\",\n        \"upgrade your plan\": \"Upgradera ditt abonnemang\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Du kan konfigurera strömning av loggar från Grist till ett externt SIEM-system (säkerhetsinformation och händelsehantering) om du aktiverar Grist för företag. {{contactUsLink}} för att lära dig mer.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Du kan konfigurera strömning av loggar från Grist till ett externt SIEM-system (säkerhetsinformation och händelsehantering) om du {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"Access details\": \"Behörigheter\",\n        \"All\": \"Alla\",\n        \"Current workspace\": \"Nuvarande arbetsyta\",\n        \"Delete\": \"Radera\",\n        \"Delete {{name}}\": \"Radera {{name}}\",\n        \"Document will be moved to Trash.\": \"Dokumentet kommer flyttas till p­apperskorgen.\",\n        \"Edited {{at}}\": \"Redigerad {{at}}\",\n        \"Last edited\": \"Senast redigerad\",\n        \"Manage users\": \"Hantera användare\",\n        \"Move\": \"Flytta\",\n        \"Move {{name}} to workspace\": \"Flytta {{name}} till arbetsyta\",\n        \"Name\": \"Namn\",\n        \"No documents to show.\": \"Inga dokument att visa.\",\n        \"Pin\": \"Fäst\",\n        \"Pinned\": \"Fästa\",\n        \"Recent\": \"Nyligen\",\n        \"Rename and set icon\": \"Byt namn och välj ikon\",\n        \"Requires edit permissions\": \"Kräver redigeringsbehörighet\",\n        \"Sort by date\": \"Sortera efter datum\",\n        \"Sort by name\": \"Sortera efter namn\",\n        \"Unpin\": \"Lossa\",\n        \"Workspace\": \"Arbetsyta\",\n        \"context menu - {{- documentName }}\": \"snabbmeny - {{- documentName }}\",\n        \"Documents list\": \"Dokumentlista\",\n        \"Download document...\": \"Ladda ner dokument...\",\n        \"Deleted {{at}}\": \"Raderad {{at}}\"\n    },\n    \"RenameDocModal\": {\n        \"Choose color\": \"Välj färg\",\n        \"Choose icon\": \"Välj ikon\",\n        \"Enter document name\": \"Fyll i dokument namn\",\n        \"Icon\": \"Ikon\",\n        \"Name\": \"Namn\",\n        \"Rename and set icon\": \"But namn och välj ikon\",\n        \"Reset icon\": \"Återställ ikon\"\n    },\n    \"RightPanelUtils\": {\n        \"columns_one\": \"kolumn\",\n        \"columns_other\": \"kolumner\",\n        \"fields_one\": \"fällt\",\n        \"fields_other\": \"fällt\",\n        \"series_one\": \"serie\",\n        \"series_other\": \"serier\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Var försiktig med okända anpassade widgets\",\n        \"Please review the following before adding a new custom widget.\": \"Var god kontrollera följande innan du lägger till en ny anpassad widget.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Anpassade widgets är **kraftfulla**! Det är möjligt att dom kan läsa och skriva i ditt dokument och skicka data vidare någon annanstans.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Är du säker på att du **litar på resursen** på denna URL?\",\n        \"Do you **trust the person** who shared this link?\": \"**Litar du på personen** som delat denna länk?\",\n        \"Have you **reviewed the code** at this URL?\": \"Har du **kontrollerat koden** på denna URL?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Om du är osäker, installera inte denna widget eller be en administratör i din organisation att kontrollera säkerheten.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Jag bekräftar att jag förstår dessa varningar och accepterar risken\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin area\": \"Administrationsyta\",\n        \"Admin controls\": \"Administrationsinställningar\",\n        \"Docs\": \"Dokument\",\n        \"Installation\": \"Installation\",\n        \"Learn more\": \"Lär dig mer\",\n        \"Orgs\": \"Organisationer\",\n        \"Users\": \"Användare\",\n        \"Workspaces\": \"Arbetsytor\",\n        \"Admin Controls\": \"Administrationsintällningar\",\n        \"Settings\": \"Inställningar\"\n    },\n    \"Assistant\": {\n        \"AI Assistant is only available for logged in users.\": \"AIassistenten är bara tillgänglig för inloggade användare.\",\n        \"Apply\": \"Applicera\",\n        \"For higher limits, contact the site owner.\": \"För minskade begränsningar, kontakta sajtägaren.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"För minskade begränsningar, {{upgradeNudge}}.\",\n        \"Learn more.\": \"Lär dig mer.\",\n        \"Press Enter to apply suggested formula.\": \"Tryck på enter för att applicera formeln.\",\n        \"Sign Up for Free\": \"Registrera dig gratis\",\n        \"What do you need help with?\": \"Vad behöver du hjälp med?\",\n        \"You have used all available credits.\": \"Du har använt alla dina krediter.\",\n        \"You have {{numCredits}} remaining credits.\": \"Du har {{numCredits}} krediter kvar.\",\n        \"start a new chat\": \"starta en ny chat\",\n        \"upgrade to the Pro Team plan\": \"uppgradera till professionellt arbetslagsabbonmang\",\n        \"upgrade your plan\": \"uppgradera ditt abonnemang\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Registrera ett gratis Gristkonto för att börja använda AIassistenten.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Upgradera till Grist Företagsversion för att prova den nya Grist assistenten. {{learnMoreLink}}\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"Konversation har blivit för lång och kan inte svara effektivt längre. Var god {{startANewChatButton}} för att fortsätta få hjälp.\"\n    },\n    \"apiconsole\": {\n        \"Are you sure you want to delete the following?\": \"Är du säker på att du vill radera följande?\",\n        \"Confirm Deletion\": \"Bekräfta borttagning\",\n        \"Delete\": \"Radera\",\n        \"Deletion was not confirmed, skipping.\": \"Borttagningen bekräftades ej, hoppar över.\",\n        \"Type DELETE here if you wish to proceed.\": \"Skriv DELETE här om du vill fortsätta.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Skriv DELETE om du är säker på att du vi radera.\\nOm du inte är säker eller inte förstår vad denna åtgärd gör,\\när det klokt att avbryta.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"ingen behörighet\",\n        \"...loading\": \"...laddar\"\n    },\n    \"VersionUpdateBanner\": {\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Din Gristverson är gammal.\\nÖverväg att uppgradera till version {{version}} så snart som möjligt.\",\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Det finns en kristisk uppdatering till Grist tillgänglig.\\nÖverväg att uppgrader till version {{version}} så fort som möjligt.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Set the document to use external storage.\": \"Ställ in dokumentet att använda extern lagring.\",\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Rekomendation: {{storageRecommendation}}\\nNär du sparar stora eller många bilagor rekomenderar vi\\natt spara dem på extern lagring. Detta dokument använder nu\\nintern lagring för bilagor, som håller alla bilagor och\\ndokument som en enhet men kan begränsa hastigheten.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Vänta på att den föregående åtgärden blir klar.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Timeout nåddes under väntan på omstart av Grist servern\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Stäng av funktion\",\n        \"Don't worry, you can disable it later if needed.\": \"Oroa dig inte du kan stänga av det senare om det behövs.\",\n        \"Enable feature\": \"Slå på funktion\",\n        \"Experimental feature\": \"Experimentell funktion\",\n        \"New record button\": \"Ny rad knapp\",\n        \"Reload the page\": \"Ladda om sidan\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Besök denna URL när som helst för att sluta använda denna funktion: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Du håller på att stänga av denna experimentella funktion: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Du håller på att slå på denna experimentella funktion: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} avstängd.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} påslagen.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Nytt kort\",\n        \"New record\": \"Ny rad\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Försöker du komma åt designpanelen? Använd {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Duplicera widget\",\n        \"Duplicate widgets\": \"Duplicera widgetar\",\n        \"Active\": \"Aktiv\",\n        \"Create new page\": \"Skapa ny sida\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Ladda upp, var god vänta…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Lägg till\",\n        \"Delete\": \"Radera\",\n        \"Download\": \"Ladda ner\",\n        \"Drop files here to attach\": \"Släpp filer här för att bifoga\",\n        \"Drop files here to attach.\": \"Släpp filer här för att bifoga.\",\n        \"No attachments\": \"Inga bilagor\",\n        \"Preview not available.\": \"Förhandsvisning inte tillgänglig.\",\n        \"{{index}} of {{total}}\": \"{{index}} av {{total}}\",\n        \"Uploading…\": \"Laddar upp…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Expandera alla rader till denna höjd\",\n        \"Max height\": \"Maxhöjd\",\n        \"Max row height\": \"Max rad höjd\",\n        \"Change\": \"Ändra\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Komprimera\",\n        \"Expand\": \"Expandera\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"aktiv användare\",\n        \"active user list\": \"aktiv användarelista\",\n        \"open full active user list\": \"öppna hela aktiv användarelistan\"\n    },\n    \"AdminChecks\": {\n        \"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 möjliggör välidgt kraftfulla formler, som använder Python. Vi rekomenderar att ställa in miljövariabeln GRIST_SANDBOX_FLAVOR till gvisor om din hårdvara stöder det (villket de flesta gör), för att köra formlerna i respektive dokument i en egen isolerad sandbox, utan åtkomst till andra dokumen eller nätverket.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Girst har en egen liten inbygg sjävkontroll som ofta används när Grist körs i en kontainer.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"Anrop som kommer till Grist ska ha en korekt värd i headern (HTTP Host header). Det är esensiellt om du ställt in GRIST_SERVE_SAME_ORIGIN.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"Denna start sida ska inte vara för lätt att komma åt. Stäng av den när inställningarna är klara (genom att ta bort GRIST_BOOT_KEY) eller gör GRIST_BOOT_KEY lång och kryprografiskt säker.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"Websocketanslutningar behöver HTTP1.1 and möjligheten att lägga till några extra headers för att fungera. Ibland kan en revrese proxy hinda det.\",\n        \"It is good practice not to run Grist as the root user.\": \"Det är en bra grundregel att inte köra Grist som root-användare.\",\n        \"The main page of Grist should be available.\": \"Grists huvudsida borde vara tillgänglig.\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Applicera\",\n        \"Cancel\": \"Avbryt\",\n        \"Preview\": \"Förhandsvining\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Stäng\",\n        \"Update preview\": \"Uppdatera förhandsvisning\",\n        \"Convert quoted fields\": \"Omvadla citerade fällt\",\n        \"Escape character\": \"Escapetecken\",\n        \"Field separator\": \"Fälltavdelare\",\n        \"First row contains headers\": \"Första raden innehåller rubriker\",\n        \"Line terminator\": \"Radavslut\",\n        \"Number of rows\": \"Antal rader\",\n        \"Quote character\": \"Citeringstecken\",\n        \"Quotes in fields are doubled\": \"Citattecken inne i fällt är dubblerade\",\n        \"Skip leading whitespace\": \"Hoppa över inledande blanksteg\",\n        \"Start with row\": \"Börja med rad\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Teckenkodning. Se [teckenkodningar som stöds]({{link}})\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" eller \",\n        \"\\\"Regions\\\" are what we call the different parts of the user interface:\": \"\\\"Regioner\\\" är vad vi kallar de olika delarna av användargränssnittet:\",\n        \"Accessibility\": \"Tillgänglighet\",\n        \"Close\": \"Stäng\",\n        \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\": \"Slutligen, den högra panelen - eller designpanelen - är bara tillgänglig genom sin egen genväg och är inte inkuderad i nästa och föregoende regioncykel.\",\n        \"Focus on other parts of the user interface using the following shortcuts:\": \"Fokusera på de andra delar in användargränssnittet genom följande genvägar:\",\n        \"High contrast theme\": \"Högkontrasttema\",\n        \"Keyboard navigation\": \"Tangentbordsnavigering\",\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"På dokumentsidam, är tangentbordsnavigering initialt låst på nuvarande widget.\",\n        \"On document pages, each [widget]({{supportPageUrl}}) is a region that can receive focus.\": \"På dokumentsidorna, är [widget]({{supportPageUrl}}) en region som kan få fokus.\",\n        \"On non-document pages, the main content area is a region.\": \"Utanför dokumensidor är områet huvudinnehåll en region.\",\n        \"Other important keyboard shortcuts\": \"Andra viktiga tangetbordsgenvägar\",\n        \"The left panel, home of the main navigation.\": \"Vänsterpanelen, hem för huvudnavigationen.\",\n        \"The top panel, or the document header.\": \"Den övre panelen eller dokumenhuvudet.\",\n        \"To see other available themes, go to your {{profileSettingsLink}}.\": \"För att se andra tillgängliga teman gå till vår {{profileSettingsLink}}.\",\n        \"Use the high contrast theme (light appearance)\": \"Använd högkontrasttemat (ljust utseende)\",\n        \"You are currently **not using** the high contrast theme.\": \"Du **använder inte** högkontrasttemat nu.\",\n        \"You are currently using the high contrast theme.\": \"Du använder högkontrasttemat nu.\",\n        \"profile settings\": \"profilinställningar\",\n        \"{{accessibilityModal}} Show the accessibility options (this modal)\": \"{{accessibilityModal}} Visa tillgänglighetsinställningar (denna modal)\",\n        \"{{creatorPanelShortcut}} Focus to and from the creator panel\": \"{{creatorPanelShortcut}} Fokusera till och från designpanelen\",\n        \"{{nextRegionShortcut}} Focus on the next region\": \"{{nextRegionShortcut}} Fokusera på nästa region\",\n        \"{{prevRegionShortcut}} Focus on the previous region\": \"{{prevRegionShortcut}} Fokusra på den föregående regionen\",\n        \"{{shortcutsModal}} Show the complete list of keyboard shortcuts\": \"{{shortcutsModal}} Visa hela listan av tangentbordsgenvägar\"\n    },\n    \"ProposedChangesPage\": {\n        \"Proposed changes\": \"Föreslagna ändringar\",\n        \"Replace original\": \"Ersätt original\",\n        \"This is a list of changes relative to the original document.\": \"Detta är en lista över ändringar i jämföresle med orginaldokumentet.\",\n        \"Accept\": \"Acceptera\",\n        \"Accepted {{at}}.\": \"Accepterad {{at}}.\",\n        \"Dismiss\": \"Avfärda\",\n        \"Dismissed {{at}}.\": \"Avfärdad {{at}}.\",\n        \"Learn more\": \"Lär dig mer\",\n        \"No changes found to suggest. Please make some edits.\": \"Inga ändringar att föreslå. Var god göra några ändringar.\",\n        \"Retract suggestion\": \"Dra tillbaka föreslag\",\n        \"Retracted {{at}}.\": \"Tillbakadragen {{at}}.\",\n        \"Suggest change\": \"Föreslå ändring\",\n        \"Suggest changes\": \"Föreslå ändringar\",\n        \"Suggestion made {{at}}.\": \"Föreslagen {{at}}.\",\n        \"Suggestions\": \"Ändringsförslag\",\n        \"The original document isn't asking for proposed changes.\": \"Orignaldokumentet efterfrågar inga förslag på ändringar.\",\n        \"There are fresh changes that haven't been added to the suggestion yet.\": \"Det finns nya ändringar som inte har lagts till som ändringsförslag ännu.\",\n        \"This is a list of changes relative to the {{originalDocument}}.\": \"Detta är en list av ändringr i jämföresle med {{originalDocument}}.\",\n        \"Update suggestion\": \"Uppdatera ändringsförslag\",\n        \"Work on a copy\": \"Arbeta på en kopia\",\n        \"Your suggestions\": \"Dina ändringsförslag\",\n        \"experiment\": \"experiment\",\n        \"original document\": \"originaldokument\",\n        \"Undo dismissal\": \"Ångra avvisande\"\n    },\n    \"commandList\": {\n        \"Show accessibility options\": \"Visa tillgänglighetsinställningar\",\n        \"Activate assistant\": \"Aktivera assistent\",\n        \"Add a new viewsection to the currently active view\": \"Lägg till en ny vysection till den nu aktiva vyn\",\n        \"Adds all elements above the cursor to the selected range\": \"Lägger till alla element ovanför markören till det valda området\",\n        \"Adds all elements below the cursor to the selected range\": \"Lägger till alla element nedanför markören till det valda området\",\n        \"Adds all elements to the left of the cursor to the selected range\": \"Lägger till alla element till vänster av markören till det vala området\",\n        \"Adds all elements to the right of the cursor to the selected range\": \"Lägger till alla element till höger om markören till det valda området\",\n        \"Adds the currently selected column(ascending) to the current view's sort spec\": \"Lägger till den nu valda kolumnen(stigande) till den nuvarane vyns sortering\",\n        \"Adds the currently selected column(descending) to the current view's sort spec\": \"Lägger till den nu valda kolumnen (fallande) till den nuvarande vyns sorterig\",\n        \"Adds the element above the cursor to the selected range\": \"Lägger till elementet ovanför markören till det valda området\",\n        \"Adds the element below the cursor to the selected range\": \"Lägger till elementet nedanför markören till det valda området\",\n        \"Adds the element to the left of the cursor to the selected range\": \"Lägger till elementet till vänster om markören till det valda området\",\n        \"Adds the element to the right of the cursor to the selected range\": \"Lägger till elementet till höger om markören till det vala området\",\n        \"Clear the selected columns\": \"Rensa de valda kolumnerna\",\n        \"Clears the current copy selection, if any\": \"Rensar den nuvarande kopieringsvalet, om det finns\",\n        \"Clears the currently selected cells\": \"Rensar de nu valda cellerna\",\n        \"Clears the section links in the current view\": \"Rensar sektionslänkar i nuvarande vyn\",\n        \"Clears the section links in the current viewsection\": \"Rensar sektionslänkar i den nuvarande vysektionen\",\n        \"Collapse the currently active viewsection\": \"Komprimera den aktiva vysektionen\",\n        \"Convert the selected columns from formula to data\": \"Kovertera de valda kolumnerna från formel till data\",\n        \"Copy anchor link\": \"Kopiera ankarlänk\",\n        \"Copy current selection to clipboard\": \"Kopiera nuvarade val till utklipp\",\n        \"Copy current selection to clipboard including headers\": \"Kopiera nuvarande sektion till utklipp inklusive rubriker\",\n        \"Creates form for active table\": \"Skpar formulär för aktiv tabell\",\n        \"Cut current selection to clipboard\": \"Klipp ut nuvarande val till utklipp\",\n        \"Delete collapsed viewsection\": \"Radera den komprimerade vysektionen\",\n        \"Delete the currently active viewsection\": \"Radera den aktiva vysektionen\",\n        \"Delete the currently selected columns\": \"Radera de vald kolumnerna\",\n        \"Delete the currently selected record(s)\": \"Radera de valda rader(na)\",\n        \"Detach active editor\": \"Lossa aktiv redigerare\",\n        \"Discard changes to a cell value\": \"Släng cellvärdesändringar\",\n        \"Display Grist documentation\": \"Visa Grist dokumentation\",\n        \"Display shortcuts pane\": \"Visa genvägsyta\",\n        \"Duplicate the currently active viewsection\": \"Duplicera den aktiva vysektionen\",\n        \"Duplicate the currently selected record(s)\": \"Duplicera valda rade(er)\",\n        \"Edit label of the currently-selected field\": \"Redigera namn på det valda fälltet\",\n        \"Edit record layout\": \"Redigera radlayout\",\n        \"Enter text into currently-selected cell and start editing\": \"Fyll i text i den valda cellen och börja redigera\",\n        \"Enters section linking mode in the current view\": \"Startar sektionslänkningsläget i den nuvarande vyn\",\n        \"Exits section linking mode in the current view\": \"Avslutar sektionslänkningsläget i den nuvarande vyn\",\n        \"Expand collapsed viewsection\": \"Expandera komprimerad vysektion\",\n        \"Fills current selection with the contents of the top row in the selection\": \"Fyller den nuvarande sektionen med innehållet i den översta raden i sektionen\",\n        \"Find\": \"Hitta\",\n        \"Find next occurrence\": \"Hitta nästa förekomst\",\n        \"Find previous occurrence\": \"Hitta föregående förekomst\",\n        \"Finish editing a cell and save without moving to next record\": \"Avsluta redigering av en cell och spara utan att flytta till nästa rad\",\n        \"Finish editing a cell, saving the value\": \"Avsluta redigering av en cell, värdet sparas\",\n        \"Focus next page panel or widget\": \"Flytta fokus till nästa sidopanel eller widget\",\n        \"Focus previous page panel or widget\": \"Flytta fokus till föregående sidopanel eller widget\",\n        \"Freeze or unfreeze selected columns\": \"Frys eller lossa valda kolumner\",\n        \"Hide the currently selected columns\": \"Dölj valda kolumner\",\n        \"Hide the currently selected fields\": \"Dölj valda fällt\",\n        \"Insert a new column, after the currently selected one\": \"Infoga en ny kolumn efter den valda\",\n        \"Insert a new column, before the currently selected one\": \"Infoga en ny kollumn före den nu valda\",\n        \"Insert a new record, after the currently selected one in an unsorted table\": \"Infoga en ny rad efter den nu valda i en osorterad tabell\",\n        \"Insert a new record, before the currently selected one in an unsorted table\": \"Infoga en ny rad före den nu valda i en osorterad tabell\",\n        \"Insert new column in default location\": \"Infoga en nu kolumn på standardposition\",\n        \"Insert the current date\": \"Infoga dagens datum\",\n        \"Insert the current date and time\": \"Infoga dagen datum och tiden nu\",\n        \"Maximize the active section\": \"Maximera den aktiva sektionen\",\n        \"Move down one page of records, or to next record in a card list\": \"Flytta nedåt en sida med rader eller nästa rad i en kortlista\",\n        \"Move down to the last record\": \"Flytta ner till den sista raden\",\n        \"Move downward five records\": \"Flytta nedåt fem rader\",\n        \"Move downward to next record or field\": \"Flytta nedåt till nästa rad eller fällt\",\n        \"Move left to the previous field\": \"Flytta vänster till föregående fällt\",\n        \"Move right to the next field\": \"Flytta höger till nästa fällt\",\n        \"Move to the first field or the beginning of a row\": \"Flytta till det första fältet eller början av en rad\",\n        \"Move to the last field or the end of a row\": \"Flytta till det sista fältet eller slutat på en rad\",\n        \"Move to the next field, saving changes if editing a value\": \"Flytta till nästa fällt, eventuella ändringar sparas\",\n        \"Move to the previous field, saving changes if editing a value\": \"Flytta till föregående fällt, eventuella ändringar sparas\",\n        \"Move up one page of records, or to previous record in a card list\": \"Flytta en sida med rader uppåt eller till föregående rad i en kortlista\",\n        \"Move up to the first record\": \"Flytta upp till den första raden\",\n        \"Move upward five records\": \"Flytta uppåt fem rader\",\n        \"Move upward to previous record or field\": \"Flytta uppåt till föregående rad eller fällt\",\n        \"Moves the cursor to the correct location\": \"Flyttar pekaren till rätt position\",\n        \"Open Custom widget configuration screen\": \"Öppna inställningar för anpassad widget\",\n        \"Open comment thread\": \"Öppna kommentarstråd\",\n        \"Open next page\": \"Öppna nästa sida\",\n        \"Open previous page\": \"Öppna föregående sida\",\n        \"Opens document list\": \"Öppna dokumentetlista\",\n        \"Paste clipboard contents at cursor\": \"Klistra in från utklipp vid markören\",\n        \"Print currently selected page widget\": \"Skriv ut vald sidowidget\",\n        \"Push an undo action\": \"Lägg in en ånga åtgärd\",\n        \"Redo last action\": \"Gör om senaste åtgärden\",\n        \"Rename the currently selected column\": \"Byt namn på den valda kolumnen\",\n        \"Reverts the sections links to the saved links the current view\": \"Återställer sektionslänkarna till sparade länkar i nuvarande vyn\",\n        \"Saves the sections links in the current view\": \"Sparar sektionslänkar i nuvarande vyn\",\n        \"Selects all currently displayed cells\": \"Väljer alla celler som visas\",\n        \"Shortcut to data selection tab\": \"Genväg till datavalsflik\",\n        \"Shortcut to focus view tab if creator panel is open\": \"Genväg för att fokusera vyflik om designpanelen är öppen\",\n        \"Shortcut to open document tab\": \"Genväg för att öppna dokumentetflik\",\n        \"Shortcut to open field tab\": \"Genväg för att öppna fälltflik\",\n        \"Shortcut to open sort & filter menu\": \"Genväg för att öppna sortera & filtrera menyn\",\n        \"Shortcut to open the left panel\": \"Genväg för att öppna vänstra panelen\",\n        \"Shortcut to open the right panel\": \"Genväg för att öppna högra panelen\",\n        \"Shortcut to open view tab\": \"Genväg för att öppna vyflik\",\n        \"Shortcut to sort & filter tab\": \"Genväg till sortera & filtrera flik\",\n        \"Show hidden columns\": \"Visa dolda kolumner\",\n        \"Show raw data widget for table of currently selected page widget\": \"Visa rådatawidget för tabell för vald sidwidget\",\n        \"Show the record card widget of the selected record\": \"Visa kortwidget för den valda raden\",\n        \"Sort the view data by the currently selected field in ascending order\": \"Sortera vydatan efter det valda fältet i stigande ordning\",\n        \"Sort the view data by the currently selected field in descending order\": \"Sortera vydatan efter det valda fältet i fallande ordning\",\n        \"Start editing the currently-selected cell\": \"Börja redigera den valda cellen\",\n        \"Toggle creator panel keyboard focus\": \"Växla designpanel tangentbordsfokus\",\n        \"Toggle the currently selected checkbox or switch cell\": \"Växla den valda kryssrutatan eller byt cell\",\n        \"Undo last action\": \"Ångra senaste åtgärd\",\n        \"Use the currently selected row as table headers\": \"Använd den valda raden som tabellrubriker\",\n        \"When in the search bar, close it and focus the current match\": \"När du är i sökfältet, stäng det och flytta fokus till nuvarande träff\",\n        \"When typed at the start of a cell, make this a formula column\": \"När det skrivs i början av en cell blir det en formelkolumn\",\n        \"showing a behavioral popup\": \"visar en beteendepopup\",\n        \"Filter this column by just this cell's value\": \"Filtrera denna kolumn bara efter cellernas värde\"\n    },\n    \"GridViewMenusDateHelpers\": {\n        \"12-hour format\": \"12-timmarsformat\",\n        \"24-hour format\": \"24-timmarsformat\",\n        \"AM\": {\n            \"PM\": \"AM/PM\"\n        },\n        \"Calendar\": \"Kalender\",\n        \"Date helpers…\": \"Datum stöd…\",\n        \"Day\": \"Dag\",\n        \"Day of month\": \"Dag i månaden\",\n        \"Day of week\": \"Dag i veckan\",\n        \"Day of week (abbrev)\": \"Dag i veckan (förkortningar)\",\n        \"Day of week (full)\": \"Dag i veckan (komplett)\",\n        \"Day of week (numeric)\": \"Dag i veckan (nummer)\",\n        \"Days since\": \"Dagar sedan\",\n        \"Days until\": \"Dagar tills\",\n        \"Default\": \"Standard\",\n        \"End of\": \"Slutet på\",\n        \"Full date\": \"Komplett datum\",\n        \"Full name with year\": \"Komplett namn med år\",\n        \"Hour\": \"Timma\",\n        \"Intervals\": \"Intervall\",\n        \"Is weekend?\": \"Är det helg?\",\n        \"Minute\": \"Minut\",\n        \"Month\": \"Månad\",\n        \"Months since\": \"Månader sedan\",\n        \"Months until\": \"Månader tills\",\n        \"Name only\": \"Enbart namn\",\n        \"Number only\": \"Enbart nummer\",\n        \"Quarter\": \"Kvartal\",\n        \"Quick Picks\": \"Snabbval\",\n        \"Relative\": \"Relativt\",\n        \"Short with year\": \"Kort med år\",\n        \"Sortable\": \"Sorterbar\",\n        \"Start of\": \"Början av\",\n        \"Time\": \"Tid\",\n        \"Time bucket\": \"Tidsspan\",\n        \"Week\": \"Vecka\",\n        \"Week of year\": \"Vecka för år\",\n        \"Year\": \"År\",\n        \"Years since\": \"År sedan\",\n        \"Years until\": \"År tills\"\n    },\n    \"selectBy\": {\n        \"Select widget\": \"Välj widget\"\n    },\n    \"CoreNewDocMethods\": {\n        \"Untitled document\": \"Namnlöst dokument\"\n    },\n    \"AuthenticationSection\": {\n        \"Active\": \"Aktiv\",\n        \"Active method is controlled by an environment variable. Unset variable to change active method.\": \"Den aktiva metoden styrs av en miljövariabel. Ta bort variabeln för att bya aktiv metod.\",\n        \"Active on restart\": \"Aktiv vid omstart\",\n        \"Are you sure you want to set **{{name}}** as the active authentication method?\": \"Är du säker på att du vill ställ in **{{name}}** som den aktiva autentiseringsmetoden?\",\n        \"Close\": \"Stäng\",\n        \"Configure\": \"Ställ in\",\n        \"Configured\": \"Inställd\",\n        \"Confirm\": \"Bekräfta\",\n        \"Disabled on restart\": \"Avstängd vid omstart\",\n        \"Error\": \"Fel\",\n        \"Error details\": \"Felinformation\",\n        \"Instructions\": \"Instruktioner\",\n        \"No authentication method is active.\": \"Ingen autentiseringsmetod är aktiv.\",\n        \"Set as active method\": \"Ställ in som aktiv metod\",\n        \"Set as active method?\": \"Ställ in som aktiv metod?\",\n        \"The new method will go into effect after you restart Grist.\": \"Den nys metoden kommer aktiveras när du startar om Grist.\",\n        \"Change admin user\": \"Ändra administratörsanvändare\",\n        \"Prepare changes\": \"Förbered ändringar\",\n        \"Revert change of admin user\": \"Ångra ändringar av administratörsanvändaren\",\n        \"You are signed in as {{email}}. After restart, the new administrative user will be {{newEmail}}.\": \"Du är inloggad som {{email}}. Efter omstart kommer den nya administratörsanvändaren att vara {{newEmail}}.\",\n        \"**Grist Connect** is a login solution built and maintained by Grist Labs that integrates seamlessly with your Grist server.\": \"**Grist Connect** är en inloggningslösning som är skapad och drivs av Grist Labs som integrerar sömmlöst med din Grist server.\",\n        \"**OIDC** allows users on your Grist server to sign in using an external identity provider that supports the OpenID Connect standard.\": \"**OIDC** tillåter användare på din Gristserver att logga in med hjälp av en extern identitets leverantör som stödjer standarden OpenID Connect.\",\n        \"**SAML** allows users on your Grist server to sign in using an external identity provider that supports the SAML 2.0 standard.\": \"**SAML** tillåter användare på din Gristserver att logga in med en extern identitets leverantör som stödjer standarden SAML 2.0.\",\n        \"If Grist is accessible on your network, or is available to multiple people, configure one of the authentication methods below.\": \"Om Grist går att komma åt på ditt nätverk, eller är tillgänglig för flera personer, ställ in en av inloggnings metoderna nedan.\",\n        \"No authentication: unrestricted sign-in as demo user\": \"Ingen inloggning: obegränsad inloggning som demoanvändare\",\n        \"Restart required. Authentication change may affect your access\": \"Omstart krävs. Inloggningsändring kan påverka din åtkost\",\n        \"See \\\"Restart Grist\\\" section on top of this page to restart.\": \"Se \\\"Starta om Grist\\\" avsnittet högst upp på denna sida för att starta om.\",\n        \"To set up **Grist Connect**, follow the instructions in [the Grist support article for Grist Connect](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"grist-connect\": {\n                            \").\": \"För att ställa in **Grist Connect**, följe instruktionerna i [Grist supportartikeln för Grist Connect](https://support.getgrist.com/install/grist-connect/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **OIDC**, follow the instructions in [the Grist support article for OIDC](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"oidc).\": \"För att ställa in **OIDC**, följ instruktionerna i [Grist supportartikeln för OIDC](https://support.getgrist.com/install/oidc).\"\n                    }\n                }\n            }\n        },\n        \"To set up **SAML**, follow the instructions in [the Grist support article for SAML](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"saml\": {\n                            \").\": \"För att ställa in **SAML**, följ instruktionerna i [Grist supportartikeln för SAML](https://support.getgrist.com/install/saml/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"**Forwarded headers** allows your Grist server to trust authentication performed by an external proxy (e.g. Traefik ForwardAuth).\": \"**Forwarded headers** får din Gristserver att använda inloggning genom extern proxy (till exempel Traefik ForwardAut).\",\n        \"To set up **forwarded headers**, follow the instructions in [the Grist support article for forwarded headers](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"forwarded-headers\": {\n                            \").\": \"För att ställa in **forwarded headers**, följ instruktionerna i [Grist supportartikel för forwarded headers](https://support.getgrist.com/install/forwarded-headers/).\"\n                        }\n                    }\n                }\n            }\n        },\n        \"When a user accesses Grist, the proxy handles authentication and forwards verified user information through HTTP headers. Grist uses these headers to identify the user.\": \"När en användare använder Grist så hanterar proxyn inloggningen och vidarebefordrar verifierad användarinformation genom HTTP headers. Grist använder dessa headers för att identifiera användaren.\",\n        \"When signing in, users will be redirected to a Grist Connect login page where they can authenticate using various identity providers. After authentication, they'll be redirected back to your Grist server and signed in.\": \"När användare loggar in blir de omdirigerade till Grist Connects inloggnings sida där de kan logga in genom olika identitets leverantörer. Efter inloggning kommer de att omdirigeras tillbaka till din Gristserver inloggade.\",\n        \"When signing in, users will be redirected to your chosen identity provider's login page to authenticate. After successful authentication, they'll be redirected back to your Grist server and signed in as the user verified by the provider.\": \"Vid inloggning kommer användare att skickas till din vada identitets leverantörs inloggningssida för att logga in. Efter lyckad inloggning kommer de att omdirigeras tillbaka till din Gristserver, inloggade som den användare som verifierats av leverantören.\",\n        \"You are signed in as {{email}}. You may lose access to this server if you cannot sign in as this user after switching the authentication system.\": \"Du är inloggad om {{email}}. Du kan förlora tillgången till denna server om du inte kan logga in som denna användare efter byte av inloggninssystem.\"\n    },\n    \"DetailView\": {\n        \"This row is unavailable or does not exist\": \"Denna rad är inte tillgänglig eller finns inte\"\n    },\n    \"GetGristComProvider\": {\n        \"Cancel\": \"Avbryt\",\n        \"Configure\": \"Ställ in\",\n        \"Register your Grist server\": \"Registrera din Gristserver\",\n        \"Sign in with getgrist.com\": \"Logga in med getgrist.com\",\n        \"Configure Sign in with getgrist.com\": \"Ställ in inloggning med getgrist.com\",\n        \"Instructions\": \"Instruktioner\",\n        \"Learn more about Sign in with getgrist.com\": \"Lär dig mer om inloggning med getgrist.com\",\n        \"Paste configuration key here\": \"Klistra in inställningsnyckeln här\",\n        \"Home URL is not set; cannot configure Sign in with getgrist.com\": \"Hem URL är inte inställd; kan inte ställa in inloggning med getgrist.com\",\n        \"**Sign in with getgrist.com** allows users on your Grist server to sign in using their account on getgrist.com, the cloud version of Grist managed by Grist Labs.\": \"**Logga in med getgrist.com** gör att användare på din Gristserver kan logga in med sitt konto på getgrist.com, molnversionen av Grist som drivs av Grist Labs.\",\n        \"To set up {{provider}}, you need to register your Grist server on getgrist.com and paste the configuration key you receive below.\": \"För att ställa in {{provider}} behöver du registrera din Gristserver på getgrist.com och klistra ininställningsnyckeln du får nedan.\",\n        \"When signing in, users will be redirected to the getgrist.com login page to log in or register. After authenticating on getgrist.com, they'll be redirected back to your Grist server and signed in as the user they authenticated as.\": \"Vid inloggning kommer användarna att skickas vidare till inloggningssidan på getgrist.com för att logga in eller registrera sig. Efter autentisering på getgrist.com skickas användaren tillbaka till din Gristserver inloggad som användaren som de autentiserdes som.\"\n    },\n    \"AirtableImportUI\": {\n        \"Back\": \"Tillbaka\",\n        \"Cancel\": \"Avbryt\",\n        \"Choose destination\": \"Välj mål\",\n        \"Connect\": \"Koppla\",\n        \"Connect with Airtable\": \"Anslut till Airtable\",\n        \"Connect your Airtable account to access your bases.\": \"Anslut ditt Airtablekonto för att komma åt dina databaser där.\",\n        \"Connected via {{method}}\": \"Ansluten via {{method}}\",\n        \"Connecting...\": \"Ansluter...\",\n        \"Continue\": \"Fortsätt\",\n        \"Destination\": \"Mål\",\n        \"Disconnect\": \"Koppla ner\",\n        \"Existing tables\": \"Existerande tabeller\",\n        \"Failed to fetch base schema\": \"Kunde inte hämta databasschema\",\n        \"Failed to fetch bases\": \"Kunde inte hämta databaser\",\n        \"Import tables\": \"Importera tabeller\",\n        \"Import {{count}} tables_one\": \"Importera {{count}} tabeller\",\n        \"Import {{count}} tables_other\": \"Importera {{count}} tabeller\",\n        \"New table\": \"Ny tabell\",\n        \"New table: structure only\": \"Ny tabell: enbart struktur\",\n        \"No bases found\": \"Inga databaser hittades\",\n        \"OAuth\": \"OAuth\",\n        \"Choose an Airtable base to import from\": \"Välj en Airtable databas att importera från\",\n        \"Grist configuration required\": \"Inställning av Grist krävs\",\n        \"Import from {{baseName}} in progress. Do not navigate away from this page.\": \"Import från {{baseName}} pågår. Lämna inte denna sida.\",\n        \"Make sure your token has the correct permissions.\": \"Säkerställ att din token har rätt behörigheter.\",\n        \"Personal access token\": \"Personlig behörighetstoken\",\n        \"Please enter a personal access token\": \"Fyll i en personlig behörighetstoken\",\n        \"Refresh\": \"Uppdatera\",\n        \"Select tables to import from {{baseName}}\": \"Välj tabeller att importera från {{baseName}}\",\n        \"Skip\": \"Hoppa över\",\n        \"Source tables\": \"Källtabeller\",\n        \"Structure only\": \"Enbart struktur\",\n        \"Use personal access token instead\": \"Använd personlig behörighetstoken istället\",\n        \"loading your bases...\": \"laddar dina databaser...\",\n        \"loading your tables...\": \"laddar dina tabeller...\",\n        \"or\": \"eller\",\n        \"{{count}} warnings_one\": \"{{count}} varningar\",\n        \"{{count}} warnings_other\": \"{{count}} varningar\",\n        \"OAuth credentials not configured. Please set OAUTH2_AIRTABLE_CLIENT_ID and OAUTH2_AIRTABLE_CLIENT_SECRET, or use personal access token.\": \"OAuth nycklarna är inte valda. Ställ in OAUTH2_AIRTABLE_CLIENT_ID och OAUTH2_AIRTABLE_CLIENT_SECRET, eller använd en personlig behörighetstoken.\",\n        \"[Generate a token]({{url}}) in your Airtable account with scopes that include at least **\\\\`schema.bases:read\\\\`** and **\\\\`data.records:read\\\\`**.\\n\\nYour token is never sent to Grist's servers, and is only used to make API calls to Airtable from your browser.\": \"[Generera en token]({{url}}) på ditt Airtable acctount med behörighet som inkluderar minst **\\\\`schema.bases:read\\\\`** och **\\\\`data.records:read\\\\`**.\\n\\nDin token skickas aldrig till Grists servrar, den avvänds bara för att göra API-anrop till Airtable från din webbläsare.\",\n        \"Use personal access token\": \"Använd personlig behörighetstoken\",\n        \"The more convenient ‘Connect with Airtable’ option can be configured by the installation administrator. [Learn more.]({{url}})\": \"Alternativet ‘Anslut till Airtable’ som är smidigare kan ställas in av installationsadministratören. [Lär dig mer.]({{url}})\"\n    },\n    \"ChangeAdminModal\": {\n        \"New admin\": \"Ny administratör\",\n        \"Enter new admin email\": \"Fyll i ny administratörs se-post\",\n        \"Make the new email the installation admin. Orgs, workspaces, and documents will remain owned by {{email}}. These changes will take effect after you restart this Grist server.\": \"Gör den nya e-postadressen till installationens administratör. Organisationer, arbetsytor och dokument kommer fortsatt att vara ägda av {{email}}. Dessa ändringar kommer att få effekt först efter omstart av denna Gristserver.\",\n        \"Replace {{email}} with the new email throughout. The new email will become the installation admin, as well as the owner of all materials previously owned by you@example.com.\": \"Ersätt {{email}} med den nya e-postadressen för allt. Den nya e-postadressen blir installationens administratör och ägare av allt material som tidigare ägdes av you@example.com.\"\n    },\n    \"startDocAirtableImport\": {\n        \"Import from Airtable\": \"Importera från Airtable\"\n    },\n    \"startHomeAirtableImport\": {\n        \"Import from Airtable\": \"Importera från Airtable\",\n        \"The current workspace can't be imported to.\": \"Det går inte att importera till nuvarande arbetsyta.\"\n    },\n    \"AirtableImporter\": {\n        \"Creating a new Grist document...\": \"Skapar ett nytt Gristdokument...\",\n        \"Preparing to import base from Airtable...\": \"Förbereder för art importera databas från Airtable...\",\n        \"Setting up tables...\": \"Ställer in tabeller...\"\n    }\n}\n"
  },
  {
    "path": "static/locales/sv.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"Loading...\": \"Laddar…\",\n        \"og-description\": \"Ett modernt öppen källkods kalkylblad som går bortom tabellen\",\n        \"og-title\": \"Grist, en evolution av kalkylark\"\n    },\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Var god och verifiera din e-post hos identitetstjänsten och logga in igen.\"\n    },\n    \"access\": {\n        \"docDisabled\": \"Dokumentet är inaktiverat.\",\n        \"docNoAccess\": \"Du har inte behörighet att komma åt detta dokument.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"Inga ägare hittades i administratörsorgranisationen som styrs av `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"orgUser\": \"Användaren är ägare till administratörsorgranisationen som styrs av `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"noAdminEmail\": \"Administratörskonto saknas eftersom `GRIST_ADMIN_EMAIL` och `GRIST_DEFAULT_EMAIL` inte är satta\",\n        \"accountByEmail\": \"Administratörskontot styrs av `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Namnlöst dokument\"\n    }\n}\n"
  },
  {
    "path": "static/locales/ta.client.json",
    "content": "{\n    \"AccessRules\": {\n        \"Checking...\": \"சோதனை…\",\n        \"Save\": \"சேமி\",\n        \"Saved\": \"சேமிக்கப்பட்டது\",\n        \"Special rules\": \"சிறப்பு விதிகள்\",\n        \"Type message to display when this rule blocks an action…\": \"ஒரு செய்தியைத் தட்டச்சு செய்க…\",\n        \"User Attributes\": \"பயனர் பண்புக்கூறுகள்\",\n        \"View as\": \"காண்க\",\n        \"Seed rules\": \"விதை விதிகள்\",\n        \"Add column rule\": \"நெடுவரிசை விதியைச் சேர்க்கவும்\",\n        \"Add Default Rule\": \"இயல்புநிலை விதியைச் சேர்க்கவும்\",\n        \"Add table rules\": \"அட்டவணை விதிகளைச் சேர்க்கவும்\",\n        \"Add user attributes\": \"பயனர் பண்புகளைச் சேர்க்கவும்\",\n        \"Remove {{- tableId }} rules\": \"{{- tableId }} விதிகளை அகற்று\",\n        \"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.\": \"அனைவரையும் முழு ஆவணத்தையும் நகலெடுக்க அனுமதிக்கவும் அல்லது அதை ஃபிடில் பயன்முறையில் முழுமையாக பார்க்கவும்.\\n எடுத்துக்காட்டுகள் மற்றும் வார்ப்புருக்களுக்கு பயனுள்ளதாக இருக்கும், ஆனால் முக்கியமான தரவுகளுக்கு அல்ல.\",\n        \"Allow everyone to view Access Rules.\": \"அணுகல் விதிகளைக் காண அனைவரையும் அனுமதிக்கவும்.\",\n        \"Attribute name\": \"பண்புக்கூறு பெயர்\",\n        \"Attribute to Look Up\": \"பார்க்க பண்புக்கூறு\",\n        \"Condition\": \"நிபந்தனை\",\n        \"Default rules\": \"இயல்புநிலை விதிகள்\",\n        \"Delete table rules\": \"அட்டவணை விதிகளை நீக்கு\",\n        \"Enter Condition\": \"நிபந்தனையை உள்ளிடவும்\",\n        \"Everyone\": \"எல்லோரும்\",\n        \"Everyone Else\": \"எல்லோரும்\",\n        \"Invalid\": \"செல்லுபடியாகாத\",\n        \"Lookup Column\": \"தேடல் நெடுவரிசை\",\n        \"Lookup Table\": \"தேடல் அட்டவணை\",\n        \"Permission to access the document in full when needed\": \"தேவைப்படும்போது ஆவணத்தை முழுமையாக அணுக இசைவு\",\n        \"Permission to view Access Rules\": \"அணுகல் விதிகளைக் காண இசைவு\",\n        \"Permissions\": \"அனுமதிகள்\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"{{- tableId }} விதிகளிலிருந்து {{- colId }} நெடுவரிசையை அகற்று\",\n        \"Remove {{- name }} user attribute\": \"{{- name }} பயனர் பண்புக்கூறு அகற்று\",\n        \"Reset\": \"மீட்டமை\",\n        \"Rules for table \": \"அட்டவணைக்கான விதிகள் \",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"அட்டவணை விதிகளைச் சேர்க்கும்போது, உரிமையாளருக்கு முழு அணுகலை வழங்க தானாக ஒரு விதியைச் சேர்க்கவும்.\",\n        \"Permission to edit document structure\": \"ஆவண கட்டமைப்பைத் திருத்த இசைவு\",\n        \"This default should be changed if editors' access is to be limited. \": \"எடிட்டர்களின் அணுகல் குறைவாக இருந்தால் இந்த இயல்புநிலை மாற்றப்பட வேண்டும். \",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"கட்டமைப்பைத் திருத்த ஆசிரியர்களை அனுமதிக்கவும் (எ.கா. அட்டவணைகள், நெடுவரிசைகள், தளவமைப்புகளை மாற்றியமைத்து நீக்கவும்) மற்றும் சூத்திரங்களை எழுதவும், அவை வாசிப்பு கட்டுப்பாடுகளைப் பொருட்படுத்தாமல் எல்லா தரவிற்கும் அணுகலை வழங்குகின்றன.\",\n        \"Add table-wide rule\": \"அட்டவணை அளவிலான விதியைச் சேர்க்கவும்\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"அணுகல் விதிகள் மாற்றப்பட்டுள்ளன. உங்கள் மாற்றங்களை மாற்றியமைக்கவும் விதிகளைப் புதுப்பிக்கவும் மீட்டமை என்பதைக் சொடுக்கு செய்யவும்.\",\n        \"All\": \"அனைத்தும்\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"நெடுவரிசை {{colId}} அட்டவணை {{tableId}}க்கான பல விதிகளில் தோன்றும், அது ஆர்டர் சார்ந்ததாக இருக்கலாம். விதிகளை வித்தியாசமாகப் பிரிக்க முயற்சிக்கிறீர்களா?\",\n        \"Columns\": \"நெடுவரிசைகள்\",\n        \"Condition cannot be blank\": \"நிலை காலியாக இருக்க முடியாது\",\n        \"Default resource missing in resource map\": \"ஆதார வரைபடத்தில் இயல்புநிலை சான்று இல்லை\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"{{tableId}} அட்டவணையில் தவறான நெடுவரிசைகள்: {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"தவறான அட்டவணை: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"தவறான பயனர் பண்புக்கூறு விதி: {{prop}} அமைக்கப்பட வேண்டும்\",\n        \"Invalid user attribute to look up\": \"தேடுவதற்கு தவறான பயனர் பண்புக்கூறு\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"{{tableId}} அட்டவணைக்கான நெடுவரிசை விதியில் நெடுவரிசைகள் எதுவும் பட்டியலிடப்படவில்லை\",\n        \"Not a valid user attribute\": \"சரியான பயனர் பண்புக்கூறு இல்லை\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"ஆதார வரைபடத்தில் சான்று இல்லை: {{resourceKey}}\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"ஏற்கனவே உள்ள அட்டவணை {{tableId}}க்கு அட்டவணை விதிகளைச் சேர்க்க முயற்சிக்கிறது\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"பயனர்.LinkKey இன் எளிய பண்புக்கூறைப் பயன்படுத்தவும், எ.கா. பயனர்.LinkKey.ஏதாவது\",\n        \"hidden\": \"மறைக்கப்பட்டுள்ளது\",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## அணுகல் விதிகள் \\n\\nஇந்த ஆவணத்திற்கான அடிப்படை அணுகல், 'பகிர்' பட்டியலில் உள்ள 'பயனர்களை நிர்வகி' விருப்பத்தைப் பயன்படுத்தி கட்டுப்படுத்தப்படுகிறது, அங்கு நீங்கள் உரிமையாளர், எடிட்டர் அல்லது பார்வையாளர் போன்ற கூட்டுப்பணியாளர் பாத்திரங்களை ஒதுக்கலாம். \\n\\nமேலும் நுணுக்கமான கட்டுப்பாட்டிற்கு, குறிப்பிட்டவர்களை யார் பார்க்கலாம் அல்லது திருத்தலாம் என்பதைக் கட்டுப்படுத்த அணுகல் விதிகளை நீங்கள் உருவாக்கலாம் \\nஅட்டவணைகள், நெடுவரிசைகள் அல்லது வரிசைகள் - முக்கியமான தரவு அல்லது பங்கு அடிப்படையிலான அனுமதிகளுக்கு பயனுள்ளதாக இருக்கும். \\n[மேலும் அறிக.]({{helpAccessRules}})\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## அணுகல் விதிகள் \\n\\nஇந்த ஆவணத்திற்கான அணுகல் விதிகளைப் பார்க்க அல்லது திருத்த உங்களுக்கு இசைவு இல்லை.\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**சிறப்பு விதிகள்** (ஒவ்வொரு விதியும் யாருக்கு பொருந்தும் என்பதைத் தனிப்பயனாக்க விரிவாக்கவும்)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"அணுகல் விதிகளை முடக்கிய பிறகு, எடிட்டர்கள் ஆவணத்தின் கட்டமைப்பை மாற்றவும் சூத்திரங்களைத் திருத்தவும் முடியும். எடிட்டர்கள் மற்றும் பார்வையாளர்கள் ஆவணத்தில் உள்ள எல்லா தரவையும் பார்க்க முடியும், அத்துடன் அதை நகலெடுக்கவும் அல்லது பதிவிறக்கவும்.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"அணுகல் விதிகளை இயக்கிய பிறகு, எடிட்டர்களால் அதன் கட்டமைப்பை மாற்ற முடியாது \\nசூத்திரங்களை ஆவணப்படுத்தவும் அல்லது திருத்தவும். உரிமையாளர்கள் மட்டுமே ஆவணத்தை நகலெடுக்க அல்லது பதிவிறக்க முடியும். \\n\\nஇந்த அமைப்புகளை 'சிறப்பு விதிகளின்' கீழ் மாற்றலாம்.\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"கட்டமைப்பைத் திருத்த எடிட்டர்களை அனுமதிக்கவும் (எ.கா. அட்டவணைகள், நெடுவரிசைகள் மற்றும் தளவமைப்புகளை மாற்றவும் மற்றும் நீக்கவும்) மற்றும் சூத்திரங்களை எழுதவும். முக்கியமானது: சரிபார்க்கப்பட்டால், அட்டவணை மற்றும் நெடுவரிசை அணுகல் விதிகளைப் பொருட்படுத்தாமல் எல்லா தரவையும் அணுகக்கூடிய சூத்திரங்களை எடிட்டர்களால் திருத்த முடியும்!\",\n        \"Allow everyone to view access rules.\": \"அணுகல் விதிகளைப் பார்க்க அனைவரையும் அனுமதிக்கவும்.\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"அனைத்து வாசிப்புக் கட்டுப்பாடுகளையும் கடந்து, முழு ஆவணத்தையும் நகலெடுக்க அனைவரையும் அனுமதிக்கவும் அல்லது ஃபிடில் பயன்முறையில் முழுமையாகப் பார்க்கவும். எடுத்துக்காட்டுகள் மற்றும் டெம்ப்ளேட்டுகளுக்கு மட்டுமே பயன்படுத்தவும், முக்கிய தரவு கொண்ட ஆவணங்களுக்கு அல்ல.\",\n        \"Continue\": \"தொடரவும்\",\n        \"Disable Access Rules\": \"அணுகல் விதிகளை முடக்கு\",\n        \"Disable and save\": \"முடக்கி சேமிக்கவும்\",\n        \"Enable Access Rules\": \"அணுகல் விதிகளை இயக்கு\",\n        \"Permission to access the document in full by all users\": \"அனைத்து பயனர்களாலும் ஆவணத்தை முழுமையாக அணுகுவதற்கான இசைவு\",\n        \"Permission to access the document in full by unrestricted users\": \"கட்டுப்பாடற்ற பயனர்களால் ஆவணத்தை முழுமையாக அணுகுவதற்கான இசைவு\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"முழு ஆவணத்தையும் நகலெடுப்பதிலிருந்து அல்லது பதிவிறக்குவதிலிருந்து உரிமையாளர் அல்லாதவர்களைக் கட்டுப்படுத்துங்கள். குறிப்பு: இது வாசிப்பு கட்டுப்பாடுகள் இல்லாத பயனர்களை மட்டுமே பாதிக்கும், ஏனெனில் இந்த அமைப்பைப் பொருட்படுத்தாமல் மற்றவர்கள் கட்டுப்படுத்தப்படுவார்கள்.\",\n        \"Special rules for templates\": \"வார்ப்புருக்களுக்கான சிறப்பு விதிகள்\",\n        \"This options should be off if Editors' access is to be limited. \": \"எடிட்டர்களின் அணுகல் வரம்பிடப்பட வேண்டுமானால், இந்த விருப்பங்கள் முடக்கப்பட்டிருக்க வேண்டும். \"\n    },\n    \"AccountPage\": {\n        \"API\": \"பநிஇ\",\n        \"Name\": \"பெயர்\",\n        \"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.\": \"உங்கள் கடவுச்சொல்லை யாராவது அறிந்திருந்தாலும் கூட, உங்கள் கணக்கை அணுகக்கூடிய ஒரே நபர் நீங்கள் மட்டுமே என்பதை உறுதிப்படுத்த வடிவமைக்கப்பட்ட உங்கள் கிரிச்ட் கணக்கிற்கான கூடுதல் பாதுகாப்பின் இரண்டு காரணி ஏற்பு.\",\n        \"Language\": \"மொழி\",\n        \"API Key\": \"பநிஇ விசை\",\n        \"Account settings\": \"கணக்கு அமைப்புகள்\",\n        \"Allow signing in to this account with Google\": \"Google உடன் இந்த கணக்கில் உள்நுழைய அனுமதிக்கவும்\",\n        \"Change password\": \"கடவுச்சொல்லை மாற்றவும்\",\n        \"Edit\": \"தொகு\",\n        \"Names only allow letters, numbers and certain special characters\": \"பெயர்கள் கடிதங்கள், எண்கள் மற்றும் சில சிறப்பு எழுத்துக்களை மட்டுமே அனுமதிக்கின்றன\",\n        \"Password & security\": \"கடவுச்சொல் மற்றும் பாதுகாப்பு\",\n        \"Save\": \"சேமி\",\n        \"Theme\": \"கருப்பொருள்\",\n        \"Email\": \"மின்னஞ்சல்\",\n        \"Login method\": \"உள்நுழைவு முறை\",\n        \"Two-factor authentication\": \"இரண்டு காரணி ஏற்பு\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"அணுகல் விவரங்கள்\",\n        \"Accounts\": \"கணக்குகள்\",\n        \"Sign in\": \"விடுபதிகை\",\n        \"Add account\": \"கணக்கைச் சேர்க்கவும்\",\n        \"Switch Accounts\": \"கணக்குகளை மாற்றவும்\",\n        \"Toggle Mobile Mode\": \"மொபைல் பயன்முறையை மாற்றவும்\",\n        \"Activation\": \"செயல்படுத்தல்\",\n        \"Billing account\": \"பட்டியலிடல் கணக்கு\",\n        \"Support Grist\": \"உதவி கிரிச்ட்\",\n        \"Upgrade Plan\": \"திட்டத்தை மேம்படுத்தவும்\",\n        \"Sign up\": \"பதிவு செய்க\",\n        \"Document settings\": \"ஆவண அமைப்புகள்\",\n        \"Manage team\": \"அணியை நிர்வகிக்கவும்\",\n        \"Pricing\": \"விலை\",\n        \"Profile settings\": \"சுயவிவர அமைப்புகள்\",\n        \"Sign out\": \"விடுபதிகை\",\n        \"Use This Template\": \"இந்த வார்ப்புருவைப் பயன்படுத்தவும்\"\n    },\n    \"ActionLog\": {\n        \"All tables\": \"அனைத்து அட்டவணைகள்\",\n        \"Action Log failed to load\": \"செயல் பதிவு ஏற்றத் தவறிவிட்டது\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"நெடுவரிசை {{colId}} பின்னர் செயலில் அகற்றப்பட்டது #{{action.actionNum}}\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"அட்டவணை {{tableId}} பின்னர் செயலில் அகற்றப்பட்டது #{{actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"இந்த வரிசை பின்னர் செயலில் அகற்றப்பட்டது {{action.actionNum}}\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"நெடுவரிசை {{colId}} பின்னர் செயல் #{{actionNum}} இல் அகற்றப்பட்டது\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"{{actionNum}} செயல்பாட்டில் இந்த வரிசை அகற்றப்பட்டது\",\n        \"History blocked because of access rules.\": \"அணுகல் விதிகளின் காரணமாக வரலாறு தடுக்கப்பட்டது.\"\n    },\n    \"AppHeader\": {\n        \"Team Site\": \"குழு தளம்\",\n        \"Grist Templates\": \"கிரிச்ட் வார்ப்புருக்கள்\",\n        \"Manage team\": \"அணியை நிர்வகிக்கவும்\",\n        \"Home page\": \"முகப்பு பக்கம்\",\n        \"Legacy\": \"மரபு\",\n        \"Personal Site\": \"தனிப்பட்ட தளம்\",\n        \"Billing account\": \"பட்டியலிடல் கணக்கு\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - வீட்டிற்குத் திரும்பு\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"இந்த குழு தளம் இடைநிறுத்தப்பட்டுள்ளது. ஆவணங்களைப் படிக்கலாம், ஆனால் மாற்றியமைக்கப்படவில்லை.\"\n    },\n    \"CellContextMenu\": {\n        \"Delete {{count}} columns_one\": \"நெடுவரிசையை நீக்கு\",\n        \"Delete {{count}} columns_other\": \"{{count}} நெடுவரிசைகளை நீக்கு\",\n        \"Comment\": \"கருத்து\",\n        \"Clear cell\": \"செல் அழி\",\n        \"Clear values\": \"மதிப்புகளை அழிக்கவும்\",\n        \"Copy anchor link\": \"நங்கூரம் இணைப்பை நகலெடுக்கவும்\",\n        \"Delete {{count}} rows_one\": \"வரிசையை நீக்கு\",\n        \"Delete {{count}} rows_other\": \"{{count}} வரிசைகளை நீக்கு\",\n        \"Duplicate rows_one\": \"நகல் வரிசை\",\n        \"Duplicate rows_other\": \"நகல் வரிசைகள்\",\n        \"Filter by this value\": \"இந்த மதிப்பால் வடிகட்டவும்\",\n        \"Insert column to the left\": \"நெடுவரிசையை இடதுபுறமாக செருகவும்\",\n        \"Insert column to the right\": \"நெடுவரிசையை வலதுபுறமாக செருகவும்\",\n        \"Insert row\": \"வரிசையைச் செருகவும்\",\n        \"Insert row above\": \"மேலே வரிசையைச் செருகவும்\",\n        \"Insert row below\": \"கீழே வரிசையைச் செருகவும்\",\n        \"Reset {{count}} columns_one\": \"நெடுவரிசையை மீட்டமை\",\n        \"Reset {{count}} columns_other\": \"{{count}} நெடுவரிசைகளை மீட்டமைக்கவும்\",\n        \"Reset {{count}} entire columns_one\": \"முழு நெடுவரிசையையும் மீட்டமைக்கவும்\",\n        \"Reset {{count}} entire columns_other\": \"மீட்டமை {{count}} முழு நெடுவரிசைகளையும்\",\n        \"Copy\": \"நகலெடு\",\n        \"Cut\": \"வெட்டு\",\n        \"Paste\": \"ஒட்டு\",\n        \"Copy with headers\": \"தலைப்புகளுடன் நகலெடுக்கவும்\"\n    },\n    \"CustomSectionConfig\": {\n        \"Widget does not require any permissions.\": \"விட்செட்டுக்கு எந்த அனுமதிகளும் தேவையில்லை.\",\n        \" (optional)\": \" (விரும்பினால்)\",\n        \"Add\": \"கூட்டு\",\n        \"Enter Custom URL\": \"தனிப்பயன் முகவரி ஐ உள்ளிடவும்\",\n        \"Full document access\": \"முழு ஆவண அணுகல்\",\n        \"Learn more about custom widgets\": \"தனிப்பயன் விட்செட்டுகள் பற்றி மேலும் அறிக\",\n        \"No document access\": \"ஆவண அணுகல் இல்லை\",\n        \"Open configuration\": \"திறந்த உள்ளமைவு\",\n        \"Pick a column\": \"ஒரு நெடுவரிசையைத் தேர்ந்தெடுங்கள்\",\n        \"Pick a {{columnType}} column\": \"ஒரு {{columnType}} நெடுவரிசையைத் தேர்ந்தெடுக்கவும்\",\n        \"Read selected table\": \"தேர்ந்தெடுக்கப்பட்ட அட்டவணையைப் படியுங்கள்\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} non-{{columnType}} நெடுவரிசை காட்டப்படவில்லை\",\n        \"Select Custom Widget\": \"தனிப்பயன் விட்செட்டைத் தேர்ந்தெடுக்கவும்\",\n        \"Widget needs to {{read}} the current table.\": \"விட்செட்டுக்கு தற்போதைய அட்டவணை {{read}}}}.\",\n        \"Widget needs {{fullAccess}} to this document.\": \"இந்த ஆவணத்திற்கு விட்செட்டுக்கு {{fullAccess}} தேவை.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} non-{{columnType}} நெடுவரிசைகள் காட்டப்படவில்லை\",\n        \"No {{columnType}} columns in table.\": \"அட்டவணையில் {{columnType}} நெடுவரிசைகள் இல்லை.\",\n        \"ACCESS LEVEL\": \"அணுகல் நிலை\",\n        \"Accept\": \"ஏற்றுக்கொள்\",\n        \"Custom URL\": \"தனிப்பயன் முகவரி\",\n        \"Developer:\": \"உருவாக்குநர்:\",\n        \"Clear selection\": \"தெளிவான தேர்வு\",\n        \"Last updated:\": \"கடைசியாக புதுப்பிக்கப்பட்டது:\",\n        \"Missing description and author information.\": \"விவரம் மற்றும் ஆசிரியர் செய்தி காணவில்லை.\",\n        \"Reject\": \"நிராகரிக்கவும்\",\n        \"Widget\": \"விட்செட்\",\n        \"Change custom widget\": \"தனிப்பயன் விட்செட்டை மாற்றவும்\"\n    },\n    \"DataTables\": {\n        \"Raw Data Tables\": \"மூல தரவு அட்டவணைகள்\",\n        \"Click to copy\": \"நகலெடுக்க சொடுக்கு செய்க\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"{{formattedTableName}}} தரவை நீக்கி, எல்லா பக்கங்களிலிருந்தும் அதை அகற்றவா?\",\n        \"Duplicate table\": \"நகல் அட்டவணை\",\n        \"Table ID copied to clipboard\": \"அட்டவணை ஐடி இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது\",\n        \"You do not have edit access to this document\": \"இந்த ஆவணத்திற்கான திருத்த அணுகல் உங்களிடம் இல்லை\",\n        \"Edit record card\": \"பதிவு அட்டையைத் திருத்தவும்\",\n        \"Record Card\": \"பதிவு அட்டை\",\n        \"Record Card Disabled\": \"பதிவு அட்டை முடக்கப்பட்டது\",\n        \"Remove table\": \"அட்டவணையை அகற்று\",\n        \"Rename table\": \"அட்டவணை மறுபெயரிடுங்கள்\",\n        \"{{action}} Record Card\": \"{{action}} பதிவு அட்டை\"\n    },\n    \"DocMenu\": {\n        \"Deleted {{at}}\": \"{{at}} நீக்கப்பட்டது\",\n        \"To restore this document, restore the workspace first.\": \"இந்த ஆவணத்தை மீட்டெடுக்க, முதலில் பணியிடத்தை மீட்டெடுக்கவும்.\",\n        \"Trash\": \"குப்பை\",\n        \"(The organization needs a paid plan)\": \"(நிறுவனத்திற்கு கட்டண திட்டம் தேவை)\",\n        \"Access Details\": \"அணுகல் விவரங்கள்\",\n        \"All documents\": \"அனைத்து ஆவணங்களும்\",\n        \"By Date Modified\": \"தேதியில் மாற்றியமைக்கப்பட்டது\",\n        \"By Name\": \"பெயரால்\",\n        \"Current workspace\": \"தற்போதைய பணியிடம்\",\n        \"Delete\": \"நீக்கு\",\n        \"Delete Forever\": \"என்றென்றும் நீக்கு\",\n        \"Delete {{name}}\": \"{{name}} ஐ நீக்கு\",\n        \"Discover More Templates\": \"மேலும் வார்ப்புருக்களைக் கண்டறியவும்\",\n        \"Document will be moved to Trash.\": \"ஆவணம் குப்பைக்கு நகர்த்தப்படும்.\",\n        \"Featured\": \"இடம்பெற்றது\",\n        \"Document will be permanently deleted.\": \"ஆவணம் நிரந்தரமாக நீக்கப்படும்.\",\n        \"Manage users\": \"பயனர்களை நிர்வகிக்கவும்\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"ஆவணங்கள் 30 நாட்கள் குப்பைத்தொட்டியில் இருக்கும், அதன் பிறகு அவை நிரந்தரமாக நீக்கப்படும்.\",\n        \"Edited {{at}}\": \"{{at}} திருத்தப்பட்டது\",\n        \"Examples & Templates\": \"எடுத்துக்காட்டுகள் மற்றும் வார்ப்புருக்கள்\",\n        \"Examples and Templates\": \"எடுத்துக்காட்டுகள் மற்றும் வார்ப்புருக்கள்\",\n        \"More Examples and Templates\": \"மேலும் எடுத்துக்காட்டுகள் மற்றும் வார்ப்புருக்கள்\",\n        \"Move\": \"நகர்த்தவும்\",\n        \"Move {{name}} to workspace\": \"{{name}} பணியிடத்திற்கு நகர்த்தவும்\",\n        \"Other Sites\": \"மற்ற தளங்கள்\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"\\\"{{name}}\\\" நிரந்தரமாக நீக்கவா?\",\n        \"Pin Document\": \"முள் ஆவணம்\",\n        \"Pinned Documents\": \"பின் செய்யப்பட்ட ஆவணங்கள்\",\n        \"Remove\": \"அகற்று\",\n        \"Rename\": \"மறுபெயரிடுங்கள்\",\n        \"Requires edit permissions\": \"திருத்து அனுமதிகள் தேவை\",\n        \"Restore\": \"மீட்டமை\",\n        \"This service is not available right now\": \"இந்த பணி இப்போது கிடைக்கவில்லை\",\n        \"Trash is empty.\": \"குப்பை காலியாக உள்ளது.\",\n        \"Unpin Document\": \"Unpin ஆவணம்\",\n        \"Workspace not found\": \"பணியிடம் கிடைக்கவில்லை\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"நீங்கள் {{siteName}} தளத்தில் இருக்கிறீர்கள். பின்வரும் தளங்களுக்கும் அணுகல் உள்ளது:\",\n        \"You are on your personal site. You also have access to the following sites:\": \"நீங்கள் உங்கள் தனிப்பட்ட தளத்தில் இருக்கிறீர்கள். பின்வரும் தளங்களுக்கும் அணுகல் உள்ளது:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"ஒரு பணியிடத்தை எந்த ஆவணங்களும் இல்லாதவுடன் நீங்கள் என்றென்றும் நீக்கலாம்.\",\n        \"Any documents created in this site will appear here.\": \"இந்த தளத்தில் உருவாக்கப்பட்ட எந்த ஆவணங்களும் இங்கே தோன்றும்.\",\n        \"Create my first document\": \"எனது முதல் ஆவணத்தை உருவாக்கவும்\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"இந்த தளத்தை நீங்கள் படிக்க மட்டுமே அணுகலாம். தற்போது ஆவணங்கள் எதுவும் இல்லை.\",\n        \"personal site\": \"தனிப்பட்ட தளம்\",\n        \"Grid view\": \"கட்டக் காட்சி\",\n        \"List view\": \"பட்டியல் காட்சி\"\n    },\n    \"DocPageModel\": {\n        \"Reload\": \"ஏற்றவும்\",\n        \"You do not have edit access to this document\": \"இந்த ஆவணத்திற்கான திருத்த அணுகல் உங்களிடம் இல்லை\",\n        \"Add page\": \"பக்கத்தைச் சேர்க்கவும்\",\n        \"Add widget to page\": \"பக்கத்தில் விட்செட்டைச் சேர்க்கவும்\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"ஆவண உரிமையாளர்கள் ஆவணத்தை மீட்டெடுக்க முயற்சி செய்யலாம். [{{error}}]\",\n        \"Enter recovery mode\": \"மீட்பு பயன்முறையை உள்ளிடவும்\",\n        \"Add empty table\": \"வெற்று அட்டவணையைச் சேர்க்கவும்\",\n        \"Error accessing document\": \"ஆவணத்தை அணுகுவதில் பிழை\",\n        \"You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]\": \"ஆவணத்தை மீண்டும் ஏற்ற முயற்சி செய்யலாம் அல்லது மீட்பு பயன்முறையைப் பயன்படுத்தலாம். மீட்பு முறை உரிமையாளர்களுக்கு முழுமையாக அணுகக்கூடிய ஆவணத்தை திறக்கிறது, மற்றவர்களுக்கு அணுக முடியாதது. இது சூத்திரங்களையும் முடக்குகிறது. [{{error}}]\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"மன்னிக்கவும், இந்த ஆவணத்திற்கான அணுகல் மறுக்கப்பட்டுள்ளது. [{{error}}]\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"தயவுசெய்து ஆவணத்தை மீண்டும் ஏற்றவும், பிழை தொடர்ந்தால், ஆவண உரிமையாளர்களைத் தொடர்பு கொள்ளவும் ஆவண மீட்டெடுப்பு. [{{error}}]\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"நாணயம்:\",\n        \"Webhooks\": \"வெப்ஊக்ச்\",\n        \"Currency\": \"நாணயம்\",\n        \"Data engine\": \"தரவு இயந்திரம்\",\n        \"Default for DateTime columns\": \"தேதிநேர நெடுவரிசைகளுக்கான இயல்புநிலை\",\n        \"Manage webhooks\": \"வெப்ஊக்குகளை நிர்வகிக்கவும்\",\n        \"Time reload\": \"நேர மறுஏற்றம்\",\n        \"Timing is on\": \"நேரம் இயக்கத்தில் உள்ளது\",\n        \"Save and Reload\": \"சேமித்து மீண்டும் ஏற்றவும்\",\n        \"Document settings\": \"ஆவண அமைப்புகள்\",\n        \"Engine (experimental {{span}} change at own risk):\": \"இயந்திரம் (சோதனை {{span}} சொந்த ஆபத்தில் மாற்றம்):\",\n        \"Local currency ({{currency}})\": \"உள்ளக நாணயம் ({{currency}})\",\n        \"This document's ID (for API use):\": \"இந்த ஆவணத்தின் ஐடி (API பயன்பாட்டிற்கு):\",\n        \"Locale:\": \"இடம்:\",\n        \"Save\": \"சேமி\",\n        \"Time Zone:\": \"நேர மண்டலம்:\",\n        \"API\": \"பநிஇ\",\n        \"Document ID\": \"ஆவண ஐடி\",\n        \"Document ID copied to clipboard\": \"ஆவண ஐடி இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது\",\n        \"Ok\": \"சரி\",\n        \"Find slow formulas\": \"மெதுவான சூத்திரங்களைக் கண்டறியவும்\",\n        \"Manage Webhooks\": \"வெப்ஊக்குகளை நிர்வகிக்கவும்\",\n        \"API console\": \"பநிஇ கன்சோல்\",\n        \"API URL copied to clipboard\": \"பநிஇ முகவரி இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது\",\n        \"API documentation.\": \"பநிஇ ஆவணங்கள்.\",\n        \"Base doc URL: {{docApiUrl}}\": \"அடிப்படை DOC URL: {{docApiUrl}}}\",\n        \"Coming soon\": \"விரைவில் வரும்\",\n        \"Copy to clipboard\": \"இடைநிலைப்பலகைக்கு நகலெடுக்கவும்\",\n        \"For currency columns\": \"நாணய நெடுவரிசைகளுக்கு\",\n        \"For number and date formats\": \"எண் மற்றும் தேதி வடிவங்களுக்கு\",\n        \"Formula times\": \"சூத்திர நேரங்கள்\",\n        \"Hard reset of data engine\": \"தரவு இயந்திரத்தின் கடின மீட்டமைப்பு\",\n        \"ID for API use\": \"பநிஇ பயன்பாட்டிற்கான ஐடி\",\n        \"Locale\": \"மொழி\",\n        \"Notify other services on doc changes\": \"ஆவண மாற்றங்கள்குறித்த பிற சேவைகளுக்கு அறிவிக்கவும்\",\n        \"Python\": \"பைதான்\",\n        \"Python version used\": \"பயன்படுத்தப்பட்ட பைதான் பதிப்பு\",\n        \"Reload\": \"ஏற்றவும்\",\n        \"Time zone\": \"நேர மண்டலம்\",\n        \"Try API calls from the browser\": \"உலாவியில் இருந்து பநிஇ அழைப்புகளை முயற்சிக்கவும்\",\n        \"python2 (legacy)\": \"பைதான் 2 (மரபு)\",\n        \"python3 (recommended)\": \"பைதான் 3 (பரிந்துரைக்கப்படுகிறது)\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Force reload the document while timing formulas, and show the result.\": \"நேர சூத்திரங்கள் போது ஆவணத்தை மீண்டும் ஏற்றவும், முடிவைக் காட்டவும்.\",\n        \"Formula timer\": \"சூத்திர நேரம்\",\n        \"Reload data engine\": \"தரவு இயந்திரத்தை மீண்டும் ஏற்றவும்\",\n        \"Reload data engine?\": \"தரவு இயந்திரத்தை மீண்டும் ஏற்றவா?\",\n        \"Start timing\": \"நேரத்தைத் தொடங்கவும்\",\n        \"Stop timing...\": \"நேரத்தை நிறுத்துங்கள் ...\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"நீங்கள் ஆவணத்தில் மாற்றங்களைச் செய்யலாம், பின்னர் முடிவுகளைக் காண நேரத்தை நிறுத்துங்கள்.\",\n        \"Only available to document editors\": \"ஆவண ஆசிரியர்களுக்கு மட்டுமே கிடைக்கும்\",\n        \"Only available to document owners\": \"ஆவண உரிமையாளர்களுக்கு மட்டுமே கிடைக்கும்\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"REST பநிஇ {{apiURL}} க்கு அழைக்கும் போதெல்லாம் பயன்படுத்த ஆவண ஐடி. {{docId}} ஐப் பார்க்கவும்\",\n        \"Template mode\": \"வார்ப்புரு பயன்முறை\",\n        \"Change document type\": \"ஆவண வகையை மாற்றவும்\",\n        \"Edit\": \"தொகு\",\n        \"Change nature of document\": \"ஆவணத்தின் தன்மையை மாற்றவும்\",\n        \"Regular document\": \"வழக்கமான ஆவணம்\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"சாதாரண ஆவண நடத்தை. அனைத்து பயனர்களும் ஆவணத்தின் ஒரே நகலில் வேலை செய்கிறார்கள்.\",\n        \"Regular\": \"வழக்கமான\",\n        \"Template\": \"வார்ப்புரு\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"ஆவணம் தானாகவே {{fiddleModeDocUrl}} இல் திறக்கிறது. யாரும் திருத்தலாம், இது புதிய சேமிக்கப்படாத நகலை உருவாக்கும்.\",\n        \"fiddle mode\": \"பிடில் பயன்முறை\",\n        \"Tutorial\": \"பயிற்சி\",\n        \"Document automatically opens as a user-specific copy.\": \"ஆவணம் தானாகவே பயனர் குறிப்பிட்ட நகலாக திறக்கும்.\",\n        \"Confirm change\": \"மாற்றத்தை உறுதிப்படுத்தவும்\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"இது தரவு இயந்திரத்தின் கடினமான மறுஏற்றம் செய்யும். தரவு இயந்திரம் எல்லையற்ற சுழற்சியில் சிக்கி, காலவரையின்றி அண்மைக் கால மாற்றத்தை செயலாக்குகிறது அல்லது செயலிழந்தால் இது உதவக்கூடும். தற்போது நிலுவையில் உள்ள செயல்களைத் தவிர வேறு எந்த தரவும் இழக்கப்படாது.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"நீங்கள் நேரத்தைத் தொடங்கியதும், ஒவ்வொரு சூத்திரத்தையும் மதிப்பீடு செய்ய எடுக்கும் நேரத்தை கிரிச்ட் அளவிடும். ஒரு ஆவணம் முதன்முதலில் திறக்கப்படும்போது அல்லது மாற்றங்களுக்கு ஒரு ஆவணம் பதிலளிக்கும் போது மெதுவான செயல்திறனுக்கு எந்த சூத்திரங்கள் பொறுப்பு என்பதைக் கண்டறிய இது அனுமதிக்கிறது.\",\n        \"**Some existing attachments are still external**.\": \"** தற்போதுள்ள சில இணைப்புகள் இன்னும் வெளிப்புறமாக உள்ளன **.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"** தற்போதுள்ள சில இணைப்புகள் இன்னும் உள் ** (SQLite கோப்பில் சேமிக்கப்படுகின்றன).\",\n        \"Attachment storage\": \"இணைப்பு சேமிப்பு\",\n        \"Being transfer\": \"இடமாற்றம்\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"வெளிப்புற சேமிப்பகத்திற்கு மாற்ற \\\"பரிமாற்றம்\\\" என்பதைக் சொடுக்கு செய்க.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"உள் சேமிப்பகத்திற்கு மாற்ற \\\"பரிமாற்றம்\\\" என்பதைக் சொடுக்கு செய்க (ஆவண SQLITE கோப்பில் சேமிக்கப்படுகிறது).\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"புதிதாக பதிவேற்றப்பட்ட இணைப்புகள் வெளிப்புற சேமிப்பகத்தில் வைக்கப்படும்.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"புதிதாக பதிவேற்றப்பட்ட இணைப்புகள் உள் சேமிப்பகத்தில் வைக்கப்படும்.\",\n        \"No external stores available\": \"வெளிப்புற கடைகள் எதுவும் கிடைக்கவில்லை\",\n        \"Preferred storage for this document\": \"இந்த ஆவணத்திற்கு விருப்பமான சேமிப்பு\",\n        \"Start transfer\": \"பரிமாற்றத்தைத் தொடங்குங்கள்\",\n        \"External\": \"வெளிப்புறம்\",\n        \"Internal\": \"உள்\",\n        \"Transfer in progress\": \"முன்னேற்றத்தில் பரிமாற்றம்\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"** தற்போதுள்ள சில இணைப்புகள் இன்னும் [வெளிப்புற] ({{externalLink}}) **.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"** தற்போதுள்ள சில இணைப்புகள் இன்னும் [உள்] ({{internalLink}}) ** (SQLite கோப்பில் சேமிக்கப்படுகிறது).\",\n        \"[Learn more.]({{learnLink}})\": \"[மேலும் அறிக.] ({{learnLink}})\",\n        \"Upload\": \"பதிவேற்றும்\",\n        \"Upload missing attachments\": \"காணாமல் போன இணைப்புகளை பதிவேற்றவும்\",\n        \"Uploading...\": \"பதிவேற்றுதல் ...\",\n        \"Default\": \"இயல்புநிலை\",\n        \"Default, template, or tutorial\": \"இயல்புநிலை, வார்ப்புரு அல்லது பயிற்சி\",\n        \"Document type\": \"ஆவண வகை\",\n        \"Allow others to suggest changes\": \"மாற்றங்களைப் பரிந்துரைக்க மற்றவர்களை அனுமதிக்கவும்\",\n        \"Enable suggestions\": \"பரிந்துரைகளை இயக்கு\",\n        \"Suggestions\": \"பரிந்துரைகள்\",\n        \"experiment\": \"ஆய்வு\"\n    },\n    \"DocumentUsage\": {\n        \"For higher limits, \": \"அதிக வரம்புகளுக்கு, \",\n        \"Rows\": \"வரிசைகள்\",\n        \"Usage\": \"பயன்பாடு\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"பயன்பாட்டு புள்ளிவிவரங்கள் ஆவணத் தரவுக்கு முழு அணுகல் கொண்ட பயனர்களுக்கு மட்டுமே கிடைக்கின்றன.\",\n        \"Size of attachments\": \"இணைப்புகளின் அளவு\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"வரம்புகளை உயர்த்துவதற்கான திட்டத்தை மேம்படுத்த தள உரிமையாளரைத் தொடர்பு கொள்ளுங்கள்.\",\n        \"Data size\": \"தரவு அளவு\",\n        \"start your 30-day free trial of the Pro plan.\": \"புரோ திட்டத்தின் உங்கள் 30 நாள் இலவச சோதனையைத் தொடங்கவும்.\"\n    },\n    \"Drafts\": {\n        \"Undo discard\": \"நிராகரிக்கவும்\",\n        \"Restore last edit\": \"கடைசி திருத்தத்தை மீட்டெடுங்கள்\"\n    },\n    \"ExampleInfo\": {\n        \"Lightweight CRM\": \"இலகுரக சி.ஆர்.எம்\",\n        \"Afterschool Program\": \"ஆஃப்டர் ச்கூல் திட்டம்\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"தரவை எவ்வாறு இணைப்பது என்பதற்கான எங்கள் தொடர்புடைய டுடோரியலைப் பாருங்கள், மேலும் உயர் தயாரிப்பு தளவமைப்புகளை உருவாக்கவும்.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"வணிகத் தரவை எவ்வாறு மாதிரியாக்குவது, சூத்திரங்களைப் பயன்படுத்துவது மற்றும் சிக்கலை நிர்வகிப்பது என்பதற்கான எங்கள் தொடர்புடைய டுடோரியலைப் பாருங்கள்.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"சுருக்க அட்டவணைகள் மற்றும் விளக்கப்படங்களை எவ்வாறு உருவாக்குவது என்பதை அறியவும், விளக்கப்படங்களை மாறும் வகையில் இணைக்கவும் எங்கள் தொடர்புடைய டுடோரியலைப் பாருங்கள்.\",\n        \"Investment Research\": \"முதலீட்டு ஆராய்ச்சி\",\n        \"Tutorial: Analyze & Visualize\": \"பயிற்சி: பகுப்பாய்வு செய்து காட்சிப்படுத்தவும்\",\n        \"Tutorial: Create a CRM\": \"பயிற்சி: ஒரு CRM ஐ உருவாக்கவும்\",\n        \"Tutorial: Manage Business Data\": \"பயிற்சி: வணிக தரவை நிர்வகிக்கவும்\",\n        \"Welcome to the Afterschool Program template\": \"ஆஃப்டர் ச்கூல் நிரல் வார்ப்புருவுக்கு வருக\",\n        \"Welcome to the Investment Research template\": \"முதலீட்டு ஆராய்ச்சி வார்ப்புருவுக்கு வருக\",\n        \"Welcome to the Lightweight CRM template\": \"இலகுரக சிஆர்எம் வார்ப்புருவுக்கு வருக\"\n    },\n    \"FieldConfig\": {\n        \"Make into data column\": \"தரவு நெடுவரிசையில் செய்யுங்கள்\",\n        \"Mixed Behavior\": \"கலப்பு நடத்தை\",\n        \"Set formula\": \"சூத்திரத்தை அமைக்கவும்\",\n        \"COLUMN BEHAVIOR\": \"நெடுவரிசை நடத்தை\",\n        \"COLUMN LABEL AND ID\": \"நெடுவரிசை சிட்டை மற்றும் ஐடி\",\n        \"Clear and make into formula\": \"அழி, சூத்திரமாக மாற்றவும்\",\n        \"Clear and reset\": \"அழி மீட்டமை\",\n        \"Column options are limited in summary tables.\": \"நெடுவரிசை விருப்பங்கள் சுருக்க அட்டவணைகளில் வரையறுக்கப்பட்டுள்ளன.\",\n        \"Convert column to data\": \"நெடுவரிசையை தரவுக்கு மாற்றவும்\",\n        \"Convert to trigger formula\": \"தூண்டுதல் சூத்திரத்திற்கு மாற்றவும்\",\n        \"Data columns_one\": \"தரவு நெடுவரிசை\",\n        \"Data columns_other\": \"தரவு நெடுவரிசைகள்\",\n        \"Empty columns_one\": \"வெற்று நெடுவரிசை\",\n        \"Empty columns_other\": \"வெற்று நெடுவரிசைகள்\",\n        \"Enter formula\": \"சூத்திரத்தை உள்ளிடவும்\",\n        \"Formula columns_one\": \"ஃபார்முலா நெடுவரிசை\",\n        \"Formula columns_other\": \"சூத்திர நெடுவரிசைகள்\",\n        \"Set trigger formula\": \"தூண்டுதல் சூத்திரத்தை அமைக்கவும்\",\n        \"TRIGGER FORMULA\": \"தூண்டுதல் தேற்றம்\",\n        \"DESCRIPTION\": \"விவரம்\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"பொதுவான அமைப்புகளுக்கு திரும்பவும்\",\n        \"Save as common settings\": \"பொதுவான அமைப்புகளாக சேமிக்கவும்\",\n        \"Use separate settings\": \"தனி அமைப்புகளைப் பயன்படுத்தவும்\",\n        \"Using common settings\": \"பொதுவான அமைப்புகளைப் பயன்படுத்துதல்\",\n        \"Using separate settings\": \"தனி அமைப்புகளைப் பயன்படுத்துதல்\"\n    },\n    \"GridViewMenus\": {\n        \"Freeze {{count}} more columns_one\": \"மேலும் ஒரு நெடுவரிசையை உறைய வைக்கவும்\",\n        \"Unfreeze {{count}} columns_other\": \"{{count}} நெடுவரிசைகளை முடக்குகிறது\",\n        \"Created By\": \"உருவாக்கியது\",\n        \"Hidden Columns\": \"மறைக்கப்பட்ட நெடுவரிசைகள்\",\n        \"Last updated by\": \"கடைசியாக புதுப்பிக்கப்பட்டது\",\n        \"DateTime\": \"தேதிநேரம்\",\n        \"Choice\": \"தேர்வு\",\n        \"Add column\": \"நெடுவரிசையைச் சேர்க்கவும்\",\n        \"Add to sort\": \"வரிசைப்படுத்த சேர்க்கவும்\",\n        \"Clear values\": \"மதிப்புகளை அழிக்கவும்\",\n        \"Column Options\": \"நெடுவரிசை விருப்பங்கள்\",\n        \"Convert formula to data\": \"சூத்திரத்தை தரவுக்கு மாற்றவும்\",\n        \"Delete {{count}} columns_one\": \"நெடுவரிசையை நீக்கு\",\n        \"Delete {{count}} columns_other\": \"{{count}} நெடுவரிசைகளை நீக்கு\",\n        \"Filter Data\": \"தரவை வடிகட்டவும்\",\n        \"Freeze {{count}} columns_one\": \"இந்த நெடுவரிசையை உறைய வைக்கவும்\",\n        \"Freeze {{count}} columns_other\": \"{{count}} நெடுவரிசைகளை முடக்கு\",\n        \"Freeze {{count}} more columns_other\": \"{{count}} மேலும் நெடுவரிசைகளை முடக்கு\",\n        \"Hide {{count}} columns_one\": \"நெடுவரிசையை மறைக்கவும்\",\n        \"Hide {{count}} columns_other\": \"{{count}} நெடுவரிசைகளை மறைக்கவும்\",\n        \"Insert column to the {{to}}\": \"{{to}} க்கு நெடுவரிசையைச் செருகவும்\",\n        \"More sort options ...\": \"மேலும் வரிசை விருப்பங்கள்…\",\n        \"Rename column\": \"நெடுவரிசையை மறுபெயரிடுங்கள்\",\n        \"Reset {{count}} columns_one\": \"நெடுவரிசையை மீட்டமை\",\n        \"Reset {{count}} columns_other\": \"{{count}} நெடுவரிசைகளை மீட்டமைக்கவும்\",\n        \"Reset {{count}} entire columns_one\": \"முழு நெடுவரிசையையும் மீட்டமைக்கவும்\",\n        \"Reset {{count}} entire columns_other\": \"மீட்டமை {{count}} முழு நெடுவரிசைகளையும்\",\n        \"Show column {{- label}}\": \"நெடுவரிசையைக் காட்டு {{- label}}\",\n        \"Unfreeze {{count}} columns_one\": \"இந்த நெடுவரிசையை முடக்கவும்\",\n        \"Insert column to the left\": \"நெடுவரிசையை இடதுபுறமாக செருகவும்\",\n        \"Insert column to the right\": \"நெடுவரிசையை வலதுபுறமாக செருகவும்\",\n        \"Apply on record changes\": \"பதிவு மாற்றங்களில் விண்ணப்பிக்கவும்\",\n        \"Apply to new records\": \"புதிய பதிவுகளுக்கு பொருந்தும்\",\n        \"Authorship\": \"படைப்புரிமை\",\n        \"Created At\": \"உருவாக்கப்பட்டது\",\n        \"Last Updated At\": \"கடைசியாக புதுப்பிக்கப்பட்டது\",\n        \"Sort\": \"வரிசைப்படுத்து\",\n        \"Sorted (#{{count}})_one\": \"வரிசைப்படுத்தப்பட்டது (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"வரிசைப்படுத்தப்பட்டது (#{{count}})\",\n        \"Unfreeze all columns\": \"அனைத்து நெடுவரிசைகளையும் முடக்கவும்\",\n        \"Last Updated By\": \"கடைசியாக புதுப்பிக்கப்பட்டது\",\n        \"Lookups\": \"தேடல்கள்\",\n        \"Shortcuts\": \"குறுக்குவழிகள்\",\n        \"Show hidden columns\": \"மறைக்கப்பட்ட நெடுவரிசைகளைக் காட்டு\",\n        \"Timestamp\": \"நேர முத்திரை\",\n        \"no reference column\": \"குறிப்பு நெடுவரிசை இல்லை\",\n        \"Adding UUID column\": \"UUID நெடுவரிசையைச் சேர்க்கிறது\",\n        \"Adding duplicates column\": \"நகல் நெடுவரிசையைச் சேர்க்கிறது\",\n        \"Detect Duplicates in...\": \"நகல்களைக் கண்டறியவும் ...\",\n        \"Duplicate in {{- label}}\": \"{{- label}} இல் நகல்\",\n        \"No reference columns.\": \"குறிப்பு நெடுவரிசைகள் இல்லை.\",\n        \"Search columns\": \"நெடுவரிசைகளைத் தேடுங்கள்\",\n        \"UUID\": \"Uuid\",\n        \"Add column with type\": \"வகையுடன் நெடுவரிசையைச் சேர்க்கவும்\",\n        \"Add formula column\": \"ஃபார்முலா நெடுவரிசையைச் சேர்க்கவும்\",\n        \"Created at\": \"உருவாக்கப்பட்டது\",\n        \"Created by\": \"உருவாக்கியது\",\n        \"Detect duplicates in...\": \"நகல்களைக் கண்டறியவும் ...\",\n        \"Toggle\": \"மாற்று\",\n        \"Date\": \"திகதி\",\n        \"Choice List\": \"தேர்வு பட்டியல்\",\n        \"Last updated at\": \"கடைசியாக புதுப்பிக்கப்பட்டது\",\n        \"Any\": \"ஏதேனும்\",\n        \"Numeric\": \"எண் வரிசை\",\n        \"Text\": \"உரை\",\n        \"Integer\": \"முழு எண்\",\n        \"Reference\": \"குறிப்பு\",\n        \"Reference List\": \"குறிப்பு பட்டியல்\",\n        \"Attachment\": \"இணைப்பு\"\n    },\n    \"GristDoc\": {\n        \"Saved linked section {{title}} in view {{name}}\": \"சேமிக்கப்பட்ட இணைக்கப்பட்ட பிரிவு {{title}} பார்வையில் {{name}}\",\n        \"Added new linked section to view {{viewName}}\": \"{{viewName}} ஐக் காண புதிய இணைக்கப்பட்ட பகுதியைச் சேர்த்தது\",\n        \"Import from file\": \"கோப்பிலிருந்து இறக்குமதி\",\n        \"go to webhook settings\": \"வெப்ஊக் அமைப்புகளுக்குச் செல்லவும்\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"புதிய மாற்றங்கள் தற்காலிகமாக இடைநிறுத்தப்படுகின்றன. வெப்ஊக்ச் வரிசை நிரம்பி வழிகிறது. தயவுசெய்து வெப்ஊக்ச் அமைப்புகளைச் சரிபார்த்து, தவறான வெப்ஊக்குகளை அகற்றி, வரிசையை தூய்மை செய்யுங்கள்.\",\n        \"Import from Airtable\": \"Airtable இலிருந்து இறக்குமதி செய்யவும்\"\n    },\n    \"HomeLeftPane\": {\n        \"Examples & Templates\": \"வார்ப்புருக்கள்\",\n        \"Create empty document\": \"வெற்று ஆவணத்தை உருவாக்கவும்\",\n        \"Access Details\": \"அணுகல் விவரங்கள்\",\n        \"All documents\": \"அனைத்து ஆவணங்களும்\",\n        \"Create workspace\": \"பணியிடத்தை உருவாக்கவும்\",\n        \"Delete\": \"நீக்கு\",\n        \"Delete {{workspace}} and all included documents?\": \"{{workspace}} மற்றும் அனைத்து ஆவணங்களையும்?\",\n        \"Import document\": \"இறக்குமதி ஆவணம்\",\n        \"Manage users\": \"பயனர்களை நிர்வகிக்கவும்\",\n        \"Rename\": \"மறுபெயரிடுங்கள்\",\n        \"Trash\": \"குப்பை\",\n        \"Grist Resources\": \"ஒட்டுமொத்த வளங்கள்\",\n        \"Workspace will be moved to Trash.\": \"பணியிடம் குப்பைக்கு நகர்த்தப்படும்.\",\n        \"Workspaces\": \"பணியிடங்கள்\",\n        \"Tutorial\": \"பயிற்சி\",\n        \"Terms of service\": \"பணி விதிமுறைகள்\",\n        \"context menu - {{- workspaceName }}\": \"சூழல் பட்டியல் - {{- workspaceName }}\",\n        \"Import from Airtable\": \"Airtable இலிருந்து இறக்குமதி செய்யவும்\"\n    },\n    \"Importer\": {\n        \"Revert\": \"திரும்பவும்\",\n        \"Skip\": \"தவிர்\",\n        \"Merge rows that match these fields:\": \"இந்த புலங்களுடன் பொருந்தக்கூடிய வரிசைகளை ஒன்றிணைக்கவும்:\",\n        \"Select fields to match on\": \"பொருந்தக்கூடிய புலங்களைத் தேர்ந்தெடுக்கவும்\",\n        \"Update existing records\": \"இருக்கும் பதிவுகளைப் புதுப்பிக்கவும்\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} இறக்குமதியில் ஒப்பிடமுடியாத புலம்\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} இறக்குமதியில் ஒப்பிடமுடியாத புலங்கள்\",\n        \"{{count}} unmatched field_one\": \"{{count}} ஒப்பிடமுடியாத புலம்\",\n        \"{{count}} unmatched field_other\": \"{{count}} ஒப்பிடமுடியாத புலங்கள்\",\n        \"Column Mapping\": \"நெடுவரிசை மேப்பிங்\",\n        \"Column mapping\": \"நெடுவரிசை மேப்பிங்\",\n        \"Destination table\": \"இலக்கு அட்டவணை\",\n        \"Grist column\": \"கிரிச்ட் நெடுவரிசை\",\n        \"Import from file\": \"கோப்பிலிருந்து இறக்குமதி\",\n        \"New Table\": \"புதிய அட்டவணை\",\n        \"Skip Import\": \"இறக்குமதியைத் தவிர்க்கவும்\",\n        \"Skip Table on Import\": \"இறக்குமதியில் அட்டவணையைத் தவிர்க்கவும்\",\n        \"Source column\": \"மூல நெடுவரிசை\",\n        \"Import options\": \"இறக்குமதி விருப்பங்கள்\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Import\": \"இறக்குமதி\"\n    },\n    \"MakeCopyMenu\": {\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Sign up\": \"பதிவு செய்க\",\n        \"As template\": \"வார்ப்புருவாக\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"கவனமாக இருங்கள், அசல் இந்த ஆவணத்தில் அல்ல. அந்த மாற்றங்கள் மேலெழுதப்படும்.\",\n        \"Enter document name\": \"ஆவண பெயரை உள்ளிடவும்\",\n        \"However, it appears to be already identical.\": \"இருப்பினும், இது ஏற்கனவே ஒரே மாதிரியாகத் தெரிகிறது.\",\n        \"Include the structure without any of the data.\": \"எந்த தரவு இல்லாமல் கட்டமைப்பைச் சேர்க்கவும்.\",\n        \"Name\": \"பெயர்\",\n        \"No destination workspace\": \"இலக்கு பணியிடம் இல்லை\",\n        \"Organization\": \"அமைப்பு\",\n        \"Original Has Modifications\": \"அசல் மாற்றங்களைக் கொண்டுள்ளது\",\n        \"Original Looks Unrelated\": \"அசல் தொடர்பில்லாதது\",\n        \"Original Looks Identical\": \"அசல் ஒரே மாதிரியாக இருக்கிறது\",\n        \"It will be overwritten, losing any content not in this document.\": \"இந்த ஆவணத்தில் இல்லாத எந்தவொரு உள்ளடக்கத்தையும் இழந்து, அது மேலெழுதப்படும்.\",\n        \"Overwrite\": \"மேலெழுதும்\",\n        \"Replacing the original requires editing rights on the original document.\": \"அசலை மாற்றுவதற்கு அசல் ஆவணத்தில் திருத்துதல் உரிமைகள் தேவை.\",\n        \"The original version of this document will be updated.\": \"இந்த ஆவணத்தின் அசல் பதிப்பு புதுப்பிக்கப்படும்.\",\n        \"To save your changes, please sign up, then reload this page.\": \"உங்கள் மாற்றங்களைச் சேமிக்க, தயவுசெய்து பதிவுசெய்து, பின்னர் இந்த பக்கத்தை மீண்டும் ஏற்றவும்.\",\n        \"Update\": \"புதுப்பிப்பு\",\n        \"Update Original\": \"அசல் புதுப்பிக்கவும்\",\n        \"Workspace\": \"பணியிடம்\",\n        \"You do not have write access to the selected workspace\": \"தேர்ந்தெடுக்கப்பட்ட பணியிடத்திற்கு உங்களுக்கு எழுத்து அணுகல் இல்லை\",\n        \"You do not have write access to this site\": \"இந்த தளத்திற்கு உங்களுக்கு எழுத்து அணுகல் இல்லை\",\n        \"Download document and history\": \"ஆவணத்தையும் வரலாற்றையும் பதிவிறக்கவும்\",\n        \"Download document structure only (no data, for template use)\": \"எல்லா தரவையும் அகற்று, ஆனால் ஒரு வார்ப்புருவாக பயன்படுத்த கட்டமைப்பை வைத்திருங்கள்\",\n        \"Download document without history (can significantly reduce file size)\": \"ஆவண வரலாற்றை அகற்று (கோப்பு அளவைக் கணிசமாகக் குறைக்க முடியும்)\",\n        \"Download\": \"பதிவிறக்கம்\",\n        \"Download document\": \"ஆவணத்தைப் பதிவிறக்கவும்\",\n        \".tar (recommended)\": \".tar (பரிந்துரைக்கப்படுகிறது)\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"இந்த ஆவணத்தில் உள்ள அனைத்து இணைப்புகளின் காப்பகத்தையும் பதிவிறக்கவும்.\",\n        \"Download attachments\": \"இணைப்புகளைப் பதிவிறக்கவும்\",\n        \"Download full document and history\": \"முழு ஆவணத்தையும் வரலாற்றையும் பதிவிறக்கவும்\",\n        \"Format:\": \"வடிவம்:\",\n        \"Learn more\": \"மேலும் அறிக\",\n        \"download attachments\": \"இணைப்புகளைப் பதிவிறக்கவும்\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"இணைப்புகள் வெளிப்புறங்கள் மற்றும் இந்த பதிவிறக்கத்தில் சேர்க்கப்படவில்லை. ஆவணத்தை ஒரு தனி கிரிச்ட் நிறுவலில் பதிவேற்றினால், நீங்கள் தனித்தனியாக {{downloadLink}} வேண்டும். \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"இந்த ஆவணத்தை கிரிச்ட் நிறுவலில் பதிவேற்ற நீங்கள் திட்டமிட்டால், இணைப்புகளை மீட்டமைக்க \\\".tar\\\" வடிவத்தில் காப்பகம் தேவைப்படும். \"\n    },\n    \"NotifyUI\": {\n        \"Ask for help\": \"உதவி கேட்க\",\n        \"Cannot find personal site, sorry!\": \"தனிப்பட்ட தளத்தைக் கண்டுபிடிக்க முடியவில்லை, மன்னிக்கவும்!\",\n        \"Give feedback\": \"கருத்து தெரிவிக்கவும்\",\n        \"Report a problem\": \"ஒரு சிக்கலைப் புகாரளிக்கவும்\",\n        \"No notifications\": \"அறிவிப்புகள் இல்லை\",\n        \"Notifications\": \"அறிவிப்புகள்\",\n        \"Go to your free personal site\": \"உங்கள் இலவச தனிப்பட்ட தளத்திற்குச் செல்லுங்கள்\",\n        \"Renew\": \"புதுப்பித்தல்\",\n        \"Upgrade Plan\": \"திட்டத்தை மேம்படுத்தவும்\",\n        \"Manage billing\": \"பட்டியலிடல் நிர்வகிக்கவும்\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"ஒட்டுமொத்த வீடியோ சுற்றுப்பயணம்\",\n        \"Video Tour\": \"வீடியோ சுற்றுப்பயணம்\",\n        \"YouTube video player\": \"YouTube வீடியோ பிளேயர்\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"பக்கத்தில் சேர்க்கவும்\",\n        \"Group by\": \"குழு\",\n        \"Building {{- label}} widget\": \"கட்டிடம் {{- label}} விட்செட்\",\n        \"Select data\": \"தரவைத் தேர்ந்தெடுக்கவும்\",\n        \"Select widget\": \"விட்செட்டைத் தேர்ந்தெடுக்கவும்\",\n        \"New Table\": \"புதிய அட்டவணை\",\n        \"SELECT BY\": \"மூலம் தேர்ந்தெடுக்கவும்\"\n    },\n    \"PermissionsWidget\": {\n        \"Read only\": \"படிக்கவும்\",\n        \"Allow all\": \"அனைத்தையும் அனுமதிக்கவும்\",\n        \"Deny all\": \"அனைத்தையும் மறுக்கவும்\"\n    },\n    \"RightPanel\": {\n        \"columns_other\": \"நெடுவரிசைகள்\",\n        \"Edit data selection\": \"தரவு தேர்வைத் திருத்தவும்\",\n        \"fields_one\": \"புலம்\",\n        \"fields_other\": \"புலங்கள்\",\n        \"GROUPED BY\": \"குழு\",\n        \"Row style\": \"வரிசை நடை\",\n        \"Success text\": \"செய் உரை\",\n        \"Table column name\": \"அட்டவணை நெடுவரிசை பெயர்\",\n        \"CHART TYPE\": \"விளக்கப்பட வகை\",\n        \"COLUMN TYPE\": \"நெடுவரிசை வகை\",\n        \"CUSTOM\": \"தனிப்பயன்\",\n        \"Change widget\": \"விட்செட்டை மாற்றவும்\",\n        \"columns_one\": \"நெடுவரிசை\",\n        \"DATA TABLE\": \"தரவு அட்டவணை\",\n        \"DATA TABLE NAME\": \"தரவு அட்டவணை பெயர்\",\n        \"Data\": \"தகவல்கள்\",\n        \"Detach\": \"விவரங்கள்\",\n        \"SELECT BY\": \"மூலம் தேர்ந்தெடுக்கவும்\",\n        \"Add referenced columns\": \"குறிப்பிடப்பட்ட நெடுவரிசைகளைச் சேர்க்கவும்\",\n        \"SELECTOR FOR\": \"தேர்வாளர்\",\n        \"SOURCE DATA\": \"மூல தரவு\",\n        \"Save\": \"சேமி\",\n        \"Select widget\": \"விட்செட்டைத் தேர்ந்தெடுக்கவும்\",\n        \"series_one\": \"தொடர்\",\n        \"series_other\": \"தொடர்\",\n        \"Sort & filter\": \"வரிசைப்படுத்துதல் & வடிகட்டி\",\n        \"TRANSFORM\": \"உருமாற்று, உருமாற்றம்\",\n        \"Theme\": \"கருப்பொருள்\",\n        \"WIDGET TITLE\": \"விட்செட் தலைப்பு\",\n        \"Widget\": \"விட்செட்\",\n        \"You do not have edit access to this document\": \"இந்த ஆவணத்திற்கான திருத்த அணுகல் உங்களிடம் இல்லை\",\n        \"Reset form\": \"படிவத்தை மீட்டமை\",\n        \"Configuration\": \"உள்ளமைவு\",\n        \"Default field value\": \"இயல்புநிலை புல மதிப்பு\",\n        \"Display button\": \"காட்சி பொத்தான்\",\n        \"Enter text\": \"உரையை உள்ளிடவும்\",\n        \"Field rules\": \"புல விதிகள்\",\n        \"Field title\": \"கள தலைப்பு\",\n        \"Hidden field\": \"மறைக்கப்பட்ட புலம்\",\n        \"Layout\": \"மனையமைவு\",\n        \"Redirect automatically after submission\": \"சமர்ப்பித்த பிறகு தானாக திருப்பி விடுங்கள்\",\n        \"Redirection\": \"திசை திருப்புதல்\",\n        \"Required field\": \"தேவையான புலம்\",\n        \"Submission\": \"சமர்ப்பிப்பு\",\n        \"Submit another response\": \"மற்றொரு பதிலை சமர்ப்பிக்கவும்\",\n        \"Submit button label\": \"பொத்தான் லேபிளை சமர்ப்பிக்கவும்\",\n        \"Enter redirect URL\": \"திருப்பி முகவரி ஐ உள்ளிடவும்\",\n        \"No field selected\": \"எந்த புலமும் தேர்ந்தெடுக்கப்படவில்லை\",\n        \"Select a field in the form widget to configure.\": \"உள்ளமைக்க படிவ விட்செட்டில் ஒரு புலத்தைத் தேர்ந்தெடுக்கவும்.\",\n        \"Submit\": \"சமர்ப்பிக்கவும்\",\n        \"Thank you! Your response has been recorded.\": \"நன்றி! உங்கள் பதில் பதிவு செய்யப்பட்டுள்ளது.\",\n        \"Chart options\": \"விளக்கப்பட விருப்பங்கள்\"\n    },\n    \"RowContextMenu\": {\n        \"Delete\": \"நீக்கு\",\n        \"Copy anchor link\": \"நங்கூரம் இணைப்பை நகலெடுக்கவும்\",\n        \"Duplicate rows_one\": \"நகல் வரிசை\",\n        \"Duplicate rows_other\": \"நகல் வரிசைகள்\",\n        \"Insert row\": \"வரிசையைச் செருகவும்\",\n        \"Insert row above\": \"மேலே வரிசையைச் செருகவும்\",\n        \"Insert row below\": \"கீழே வரிசையைச் செருகவும்\",\n        \"View as card\": \"அட்டையாக பார்க்கவும்\",\n        \"Use as table headers\": \"அட்டவணை தலைப்புகளாக பயன்படுத்தவும்\"\n    },\n    \"SortFilterConfig\": {\n        \"Save\": \"சேமி\",\n        \"Filter\": \"வடிப்பி\",\n        \"Revert\": \"திரும்பவும்\",\n        \"Sort\": \"வரிசைப்படுத்து\",\n        \"Update Sort & Filter settings\": \"வரிசை மற்றும் வடிகட்டி அமைப்புகளைப் புதுப்பிக்கவும்\"\n    },\n    \"Tools\": {\n        \"Delete document tour?\": \"ஆவண சுற்றுப்பயணத்தை நீக்கவா?\",\n        \"Access Rules\": \"அணுகல் விதிகள்\",\n        \"Code view\": \"குறியீடு பார்வை\",\n        \"Delete\": \"நீக்கு\",\n        \"Document history\": \"ஆவண வரலாறு\",\n        \"How-to Tutorial\": \"எப்படி-டுடோரியல்\",\n        \"Raw data\": \"மூல தரவு\",\n        \"Return to viewing as yourself\": \"உங்களைப் போலவே பார்க்க திரும்பவும்\",\n        \"TOOLS\": \"கருவிகள்\",\n        \"Tour of this Document\": \"இந்த ஆவணத்தின் சுற்றுப்பயணம்\",\n        \"Validate Data\": \"தரவை சரிபார்க்கவும்\",\n        \"Settings\": \"அமைப்புகள்\",\n        \"API console\": \"பநிஇ கன்சோல்\",\n        \"context menu - Access Rules\": \"சூழல் பட்டியல் - அணுகல் விதிகள்\",\n        \"Delete document tour\": \"ஆவணப் பயணத்தை நீக்கு\",\n        \"Preview the tutorial\": \"டுடோரியலை முன்னோட்டமிடுங்கள்\",\n        \"Proposed changes\": \"முன்மொழியப்பட்ட மாற்றங்கள்\",\n        \"Suggest changes\": \"மாற்றங்களை பரிந்துரைக்கவும்\",\n        \"Suggestions\": \"பரிந்துரைகள்\"\n    },\n    \"ViewConfigTab\": {\n        \"Form\": \"வடிவம்\",\n        \"Unmark On-Demand\": \"தேவைப்பட்டால்\",\n        \"Advanced settings\": \"மேம்பட்ட அமைப்புகள்\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"பெரிய அட்டவணைகள் தரவு இயந்திரத்தில் அவற்றை ஏற்றுவதைத் தவிர்க்க \\\"தேவைக்கேற்ப\\\" என்று குறிக்கப்படலாம்.\",\n        \"Blocks\": \"தொகுதிகள்\",\n        \"Compact\": \"கச்சிதமான\",\n        \"Edit card layout\": \"அட்டை தளவமைப்பைத் திருத்து\",\n        \"Make On-Demand\": \"தேவைக்கேற்ப உருவாக்குங்கள்\",\n        \"Plugin: \": \"சொருகி: \",\n        \"Section: \": \"பிரிவு: \",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"செயல்பாட்டின் பற்றாக்குறை மற்றும் பயன்பாட்டினை கவலைகள் காரணமாக தேவைக்கேற்ப அட்டவணைகள் நீக்கப்பட்டுள்ளன.\",\n        \"⚠️ Deprecated Feature\": \"Defed நீக்கப்பட்ட நற்பொருத்தம்\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"மேம்பட்ட வரிசை & வடிகட்டி\",\n        \"Add to page\": \"பக்கத்தில் சேர்க்கவும்\",\n        \"Copy anchor link\": \"நங்கூரம் இணைப்பை நகலெடுக்கவும்\",\n        \"Data selection\": \"தரவு தேர்வு\",\n        \"Delete record\": \"பதிவை நீக்கு\",\n        \"Delete widget\": \"விட்செட்டை நீக்கு\",\n        \"Download as CSV\": \"காபிம ஆக பதிவிறக்கவும்\",\n        \"Download as XLSX\": \"XLSX ஆக பதிவிறக்கவும்\",\n        \"Edit card layout\": \"அட்டை தளவமைப்பைத் திருத்து\",\n        \"Open configuration\": \"திறந்த உள்ளமைவு\",\n        \"Print widget\": \"அச்சு விட்செட்\",\n        \"Show raw data\": \"மூல தரவைக் காட்டு\",\n        \"Widget options\": \"விட்செட் விருப்பங்கள்\",\n        \"Collapse widget\": \"சட்டை விட்செட்\",\n        \"Create a form\": \"ஒரு படிவத்தை உருவாக்கவும்\",\n        \"Duplicate widget\": \"நகல் விட்செட்\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"ரத்துசெய்\",\n        \"DATA TABLE NAME\": \"தரவு அட்டவணை பெயர்\",\n        \"Override widget title\": \"விட்செட் தலைப்பை மேலெழுதவும்\",\n        \"Provide a table name\": \"அட்டவணை பெயரை வழங்கவும்\",\n        \"Save\": \"சேமி\",\n        \"WIDGET TITLE\": \"விட்செட் தலைப்பு\",\n        \"WIDGET DESCRIPTION\": \"விட்செட் விளக்கம்\"\n    },\n    \"errorPages\": {\n        \"Sign in\": \"விடுபதிகை\",\n        \"There was an unknown error.\": \"தெரியாத பிழை இருந்தது.\",\n        \"Access denied{{suffix}}\": \"அணுகல் மறுக்கப்பட்டது {{suffix}}}\",\n        \"Add account\": \"கணக்கைச் சேர்க்கவும்\",\n        \"Contact support\": \"தொடர்பு உதவி\",\n        \"Error{{suffix}}\": \"பிழை {{suffix}}\",\n        \"Go to main page\": \"முதன்மையான பக்கத்திற்குச் செல்லவும்\",\n        \"Page not found{{suffix}}\": \"பக்கம் காணப்படவில்லை {{suffix}}\",\n        \"Sign in again\": \"மீண்டும் உள்நுழைக\",\n        \"Sign in to access this organization's documents.\": \"இந்த அமைப்பின் ஆவணங்களை அணுக உள்நுழைக.\",\n        \"Signed out{{suffix}}\": \"கையொப்பமிடப்பட்டது {{suffix}}\",\n        \"Something went wrong\": \"ஏதோ தவறு நடந்தது\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"கோரப்பட்ட பக்கத்தைக் கண்டுபிடிக்க முடியவில்லை. {{separator}} தயவுசெய்து முகவரி ஐ சரிபார்த்து மீண்டும் முயற்சிக்கவும்.\",\n        \"There was an error: {{message}}\": \"ஒரு பிழை இருந்தது: {{message}}\",\n        \"You are now signed out.\": \"நீங்கள் இப்போது கையெழுத்திட்டுள்ளீர்கள்.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"நீங்கள் {{email}} ஆக உள்நுழைந்துள்ளீர்கள். நீங்கள் வேறு கணக்கில் உள்நுழையலாம் அல்லது அணுகலை நிர்வாகியிடம் கேட்கலாம்.\",\n        \"You do not have access to this organization's documents.\": \"இந்த அமைப்பின் ஆவணங்களுக்கான அணுகல் உங்களிடம் இல்லை.\",\n        \"Account deleted{{suffix}}\": \"கணக்கு நீக்கப்பட்டது {{suffix}}\",\n        \"Sign up\": \"பதிவு செய்க\",\n        \"Your account has been deleted.\": \"உங்கள் கணக்கு நீக்கப்பட்டது.\",\n        \"An unknown error occurred.\": \"அறியப்படாத பிழை ஏற்பட்டது.\",\n        \"Build your own form\": \"உங்கள் சொந்த வடிவத்தை உருவாக்குங்கள்\",\n        \"Form not found\": \"படிவம் கிடைக்கவில்லை\",\n        \"Powered by\": \"மூலம் இயக்கப்படுகிறது\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"உள்நுழையத் தவறிவிட்டது. {{separator}} தயவுசெய்து மீண்டும் முயற்சிக்கவும் அல்லது ஆதரவைத் தொடர்பு கொள்ளவும்.\",\n        \"Sign-in failed{{suffix}}\": \"உள்நுழைவு தோல்வியுற்றது {{suffix}}\",\n        \"Manage settings\": \"அமைப்புகளை நிர்வகிக்கவும்\",\n        \"Need Help?\": \"உதவி தேவையா?\",\n        \"There was an error\": \"பிழை ஏற்பட்டது\",\n        \"Unsubscribed{{suffix}}\": \"குழுவிலகப்பட்டது{{suffix}}\",\n        \"We could not unsubscribe you\": \"உங்களை குழுவிலக முடியவில்லை\",\n        \"You are unsubscribed\": \"நீங்கள் குழுவிலகியுள்ளீர்கள்\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"ஆவண அமைப்புகளில் உங்கள் விருப்பத்தேர்வுகளைப் புதுப்பிப்பதன் மூலம் இந்த ஆவணத்திலிருந்து நீங்கள் குழுவிலகலாம்\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"{{changes}} இல் {{docName}} பற்றிய மின்னஞ்சல் அறிவிப்புகளை {{email}} இல் இனி நீங்கள் பெறமாட்டீர்கள்.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"{{docName}} இல் உள்ள {{comments}} பற்றிய மின்னஞ்சல் அறிவிப்புகளை {{email}} இல் இனி நீங்கள் பெறமாட்டீர்கள்.\",\n        \"changes\": \"மாற்றங்கள்\",\n        \"comments\": \"கருத்துக்கள்\",\n        \"this document\": \"இந்த ஆவணம்\",\n        \"your email\": \"உங்கள் மின்னஞ்சல்\",\n        \"You will no longer receive email notifications about {{suggestions}} in {{docName}} at {{email}}.\": \"{{suggestions}} இல் உள்ள {{docName}} பற்றிய மின்னஞ்சல் அறிவிப்புகளை {{email}} இல் இனி நீங்கள் பெறமாட்டீர்கள்.\",\n        \"suggestions\": \"பரிந்துரைகள்\"\n    },\n    \"menus\": {\n        \"Toggle\": \"மாற்று\",\n        \"Date\": \"திகதி\",\n        \"DateTime\": \"தேதிநேரம்\",\n        \"Choice\": \"தேர்வு\",\n        \"Choice List\": \"தேர்வு பட்டியல்\",\n        \"* Workspaces are available on team plans. \": \"* குழு திட்டங்களில் பணியிடங்கள் கிடைக்கின்றன. \",\n        \"Select fields\": \"புலங்களைத் தேர்ந்தெடுக்கவும்\",\n        \"Upgrade now\": \"இப்போது மேம்படுத்தவும்\",\n        \"Any\": \"ஏதேனும்\",\n        \"Numeric\": \"எண் வரிசை\",\n        \"Text\": \"உரை\",\n        \"Integer\": \"முழு எண்\",\n        \"Reference\": \"குறிப்பு\",\n        \"Reference List\": \"குறிப்பு பட்டியல்\",\n        \"Attachment\": \"இணைப்பு\",\n        \"Search columns\": \"நெடுவரிசைகளைத் தேடுங்கள்\",\n        \"By Name\": \"பெயரால்\",\n        \"By Date Modified\": \"தேதியில் மாற்றியமைக்கப்பட்டது\",\n        \"Light\": \"ஒளி\",\n        \"Custom\": \"தனிப்பயன்\"\n    },\n    \"modals\": {\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Ok\": \"சரி\",\n        \"Save\": \"சேமி\",\n        \"Are you sure you want to delete these records?\": \"இந்த பதிவுகளை நீக்க விரும்புகிறீர்களா?\",\n        \"Don't ask again.\": \"மீண்டும் கேட்க வேண்டாம்.\",\n        \"Don't show again.\": \"மீண்டும் காட்ட வேண்டாம்.\",\n        \"Don't show tips\": \"உதவிக்குறிப்புகளைக் காட்ட வேண்டாம்\",\n        \"Undo to restore\": \"மீட்டமைக்க செயல்தவிர்க்கவும்\",\n        \"Got it\": \"கிடைத்தது\",\n        \"Don't show again\": \"மீண்டும் காட்ட வேண்டாம்\",\n        \"TIP\": \"உதவிக்குறிப்பு\",\n        \"Are you sure you want to delete this record?\": \"இந்த பதிவை நீக்க விரும்புகிறீர்களா?\",\n        \"Delete\": \"நீக்கு\",\n        \"Dismiss\": \"தள்ளுபடி\",\n        \"Confirm\": \"உறுதிப்படுத்தவும்\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"தேர்வுகள்\"\n    },\n    \"ColumnInfo\": {\n        \"Cancel\": \"ரத்துசெய்\",\n        \"COLUMN DESCRIPTION\": \"நெடுவரிசை விளக்கம்\",\n        \"COLUMN ID: \": \"நெடுவரிசை ஐடி: \",\n        \"COLUMN LABEL\": \"நெடுவரிசை சிட்டை\",\n        \"Save\": \"சேமி\"\n    },\n    \"FieldBuilder\": {\n        \"Use separate field settings for {{colId}}\": \"{{colId}} க்கு தனி புல அமைப்புகளைப் பயன்படுத்தவும்\",\n        \"CELL FORMAT\": \"செல் வடிவம்\",\n        \"Changing multiple column types\": \"பல நெடுவரிசை வகைகளை மாற்றுதல்\",\n        \"Apply formula to data\": \"தரவுக்கு சூத்திரத்தைப் பயன்படுத்துங்கள்\",\n        \"DATA FROM TABLE\": \"அட்டவணையிலிருந்து தரவு\",\n        \"Mixed format\": \"கலப்பு வடிவம்\",\n        \"Mixed types\": \"கலப்பு வகைகள்\",\n        \"Revert field settings for {{colId}} to common\": \"{{colId}} க்கான புல அமைப்புகளைப் பொதுவானதாக மாற்றவும்\",\n        \"Save field settings for {{colId}} as common\": \"{{colId}} க்கான புல அமைப்புகளைப் பொதுவானதாகச் சேமி\",\n        \"Changing column type\": \"நெடுவரிசை வகையை மாற்றுதல்\",\n        \"Common\": \"பொதுவானது\",\n        \"Separate\": \"தனி\",\n        \"Field in {{count}} views_one\": \"ஒரு பார்வையில் புலம்\",\n        \"Field in {{count}} views_other\": \"புலம் {{count}} பார்வைகள்\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"நெடுவரிசை அல்லது புலம் தேவை\",\n        \"Error in the cell\": \"கலத்தில் பிழை\",\n        \"Errors in all {{numErrors}} cells\": \"எல்லா {{numErrors}} கலங்களிலும் பிழைகள்\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"{{numErrors}} கலங்களின் {{numCells}} இல் உள்ள பிழைகள்\",\n        \"editingFormula is required\": \"திருத்துதல் ஃபார்முலா தேவை\",\n        \"Enter formula or {{button}}.\": \"சூத்திரத்தை உள்ளிடவும் அல்லது {{button}}.\",\n        \"Enter formula.\": \"சூத்திரத்தை உள்ளிடவும்.\",\n        \"Expand Editor\": \"விரிவாக்க ஆசிரியர்\",\n        \"use AI Assistant\": \"AI உதவியாளரைப் பயன்படுத்துங்கள்\"\n    },\n    \"WelcomeTour\": {\n        \"Editing Data\": \"தரவைத் திருத்துதல்\",\n        \"Reference\": \"குறிப்பு\",\n        \"Share\": \"பங்கு\",\n        \"Sharing\": \"பகிர்வு\",\n        \"Add new\": \"புதியதைச் சேர்க்கவும்\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"சாத்தியமானதைக் கண்டுபிடித்து ஊக்கம் பெற எங்கள் {{templateLibrary}} below உலாவவும்.\",\n        \"Building up\": \"கட்டிடம்\",\n        \"Configuring your document\": \"உங்கள் ஆவணத்தை உள்ளமைத்தல்\",\n        \"Customizing columns\": \"நெடுவரிசைகளைத் தனிப்பயனாக்குதல்\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"அதைத் திருத்த ஒரு கலத்தில் {{enter}}உள்ளிடவும். \",\n        \"Enter\": \"உள்ளிடவும்\",\n        \"Flying higher\": \"உயரமாக பறக்கிறது\",\n        \"Help Center\": \"உதவி நடுவண்\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"விட்செட்டுகள், பக்கங்களைச் சேர்க்க அல்லது கூடுதல் தரவை இறக்குமதி செய்ய {{addNew}} ஐப் பயன்படுத்தவும். \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"ஆவணங்கள் அல்லது கேள்விகளுக்கு {{helpCenter}} ஐப் பயன்படுத்தவும்.\",\n        \"Welcome to Grist!\": \"கிரிச்டுக்கு வருக!\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"அதை தொடர்புடையதாக ஆக்குங்கள்! அட்டவணைகளை இணைக்க {{ref}} வகையைப் பயன்படுத்தவும். \",\n        \"convert to card view, select data, and more.\": \"அட்டை பார்வைக்கு மாற்றவும், தரவைத் தேர்ந்தெடுக்கவும் மற்றும் பல.\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"தேதிகள், தேர்வுகள் அல்லது இணைப்புகள் போன்ற வடிவமைப்பு விருப்பங்கள், சூத்திரங்கள் அல்லது நெடுவரிசை வகைகளை அமைக்கவும். \",\n        \"Start with {{equal}} to enter a formula.\": \"ஒரு சூத்திரத்தை உள்ளிட {{equal}} உடன் தொடங்கவும்.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"நெடுவரிசைகளை வடிவமைக்க {{creatorPanel}} ஐ மாற்றவும், \",\n        \"creator panel\": \"உருவாக்கியவர் குழு\",\n        \"template library\": \"வார்ப்புரு நூலகம்\",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"ஆவணத்தைப் பகிர அல்லது தரவை ஏற்றுமதி செய்ய பங்கு பொத்தானை ({{share}}) பயன்படுத்தவும்.\",\n        \"AI Assistant\": \"AI உதவியாளர்\"\n    },\n    \"GristTooltips\": {\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"ஒவ்வொரு கலத்திலும் {{EyeHideIcon}} ஐக் சொடுக்கு செய்வது இந்தப் பார்வையிலிருந்து நீக்காமல் புலத்தை மறைக்கிறது.\",\n        \"Editing Card Layout\": \"அட்டை தளவமைப்பைத் திருத்துதல்\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"சில சந்தர்ப்பங்களில் தூண்டக்கூடிய சூத்திரங்கள், மற்றும் கணக்கிடப்பட்ட மதிப்பை தரவுகளாக சேமிக்கின்றன.\",\n        \"Linking Widgets\": \"விட்செட்களை இணைக்கிறது\",\n        \"Nested Filtering\": \"உள்ளமைக்கப்பட்ட வடிகட்டுதல்\",\n        \"Only those rows will appear which match all of the filters.\": \"அனைத்து வடிப்பான்களுக்கும் பொருந்தக்கூடிய அந்த வரிசைகள் மட்டுமே தோன்றும்.\",\n        \"Pinning Filters\": \"பின்னடைவு வடிப்பான்கள்\",\n        \"Raw Data page\": \"மூல தரவு பக்கம்\",\n        \"Select the table to link to.\": \"இணைக்க அட்டவணையைத் தேர்ந்தெடுக்கவும்.\",\n        \"Selecting Data\": \"தரவைத் தேர்ந்தெடுப்பது\",\n        \"They allow for one record to point (or refer) to another.\": \"அவை ஒரு பதிவை மற்றொன்றைக் காட்டவும் (அல்லது குறிப்பிட) அனுமதிக்கின்றன.\",\n        \"Updates every 5 minutes.\": \"ஒவ்வொரு 5 நிமிடங்களுக்கும் புதுப்பிக்கும்.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"மொத்தம் அல்லது சப்டோட்டல்களுக்கு சுருக்கம் (அல்லது பிவோட்) அட்டவணைகளை உருவாக்க \\\\ உ {1D6BA} ஐகானைப் பயன்படுத்தவும்.\",\n        \"Anchor Links\": \"நங்கூர இணைப்புகள்\",\n        \"Custom Widgets\": \"தனிப்பயன் விட்செட்டுகள்\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"பயனரை ஒரு குறிப்பிட்ட கலத்திற்கு அழைத்துச் செல்லும் ஒரு நங்கூர இணைப்பை உருவாக்க, ஒரு வரிசையில் சொடுக்கு செய்து {{shortcut}} ஐ அழுத்தவும்.\",\n        \"Calendar\": \"நாட்காட்டி\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"கீழ்தோன்றலில் உங்களுக்கு கிடைக்கும் விட்செட்டுகளிலிருந்து நீங்கள் தேர்வு செய்யலாம் அல்லது அதன் முழு முகவரி ஐ வழங்குவதன் மூலம் உங்கள் சொந்தத்தை உட்பொதிக்கலாம்.\",\n        \"Forms are here!\": \"படிவங்கள் இங்கே!\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"பல பணிகளை அனுமதிக்க, குறிப்பிடப்பட்ட நெடுவரிசையின் வகையை குறிப்பு பட்டியலுக்கு மாற்றவும்.\",\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"சூத்திர நிபந்தனைகள் நிறைவு செய்யப்படும்போது இந்த நெடுவரிசையில் உள்ள கலங்களுக்கு நிபந்தனை வடிவமைப்பைப் பயன்படுத்துங்கள்.\",\n        \"Apply conditional formatting to rows based on formulas.\": \"சூத்திரங்களின் அடிப்படையில் வரிசைகளுக்கு நிபந்தனை வடிவமைப்பைப் பயன்படுத்துங்கள்.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"குறிப்பு நெடுவரிசையில் உள்ள கலங்கள் எப்போதும் அந்த அட்டவணையில் {{entire}} பதிவை அடையாளம் காண்க, ஆனால் அந்த பதிவிலிருந்து எந்த நெடுவரிசையை காண்பிக்க நீங்கள் தேர்ந்தெடுக்கலாம்.\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"வரிசைகளுக்கு நிபந்தனை வடிவமைப்பைப் பயன்படுத்த “திறந்த வரிசை பாணிகளை” சொடுக்கு செய்க.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"புதிய ஆவணங்கள் அல்லது பணியிடங்களை உருவாக்க புதிய பொத்தானைச் சேர் அல்லது தரவை இறக்குமதி செய்யுங்கள்.\",\n        \"Learn more.\": \"மேலும் அறிக.\",\n        \"Link your new widget to an existing widget on this page.\": \"உங்கள் புதிய விட்செட்டை இந்த பக்கத்தில் ஏற்கனவே உள்ள விட்செட்டுடன் இணைக்கவும்.\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"பின் செய்யப்பட்ட வடிப்பான்கள் விட்செட்டுக்கு மேலே உள்ள பொத்தான்களாக காட்டப்படும்.\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"கலங்களை இழுத்து மறுஅளவாக்குவதன் மூலம் உங்கள் அட்டையில் உள்ள புலங்களை மறுசீரமைக்கவும்.\",\n        \"Reference Columns\": \"குறிப்பு நெடுவரிசைகள்\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"குறிப்பு நெடுவரிசைகள் கிரிச்டில் உள்ள {{relational}} தரவுக்கு முக்கியமாகும்.\",\n        \"Select the table containing the data to show.\": \"காண்பிக்க தரவைக் கொண்ட அட்டவணையைத் தேர்ந்தெடுக்கவும்.\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"உங்கள் ஆவணத்தில் உள்ள அனைத்து தரவு அட்டவணைகளையும் மூல தரவு பக்கம் பட்டியலிடுகிறது, இதில் சுருக்க அட்டவணைகள் மற்றும் பக்க தளவமைப்புகளில் சேர்க்கப்படாத அட்டவணைகள் அடங்கும்.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"கிரிச்டின் மாறும் மற்றும் விளைவாக்கம் தளவமைப்புகளுக்கு இது மறைபொருள்.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"ஒரு நகலில் மாற்றங்களை முயற்சிக்கவும், பின்னர் உங்கள் திருத்தங்களுடன் அசலை மாற்றலாமா என்பதை முடிவு செய்யுங்கள்.\",\n        \"The total size of all data in this document, excluding attachments.\": \"இணைப்புகளைத் தவிர்த்து, இந்த ஆவணத்தில் உள்ள அனைத்து தரவுகளின் மொத்த அளவு.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"வடிகட்டியை வைத்திருக்கும்போது பொத்தானை மறைக்க அவிழ்த்து விடுங்கள்.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"புதிய பதிவு, தரவு தூய்மை மற்றும் பலவற்றின் நேர முத்திரை அல்லது ஆசிரியரை சேமிக்க பயனுள்ளதாக இருக்கும்.\",\n        \"You can filter by more than one column.\": \"நீங்கள் ஒன்றுக்கு மேற்பட்ட நெடுவரிசைகளால் வடிகட்டலாம்.\",\n        \"entire\": \"முழு\",\n        \"relational\": \"தொடர்புடைய\",\n        \"Access Rules\": \"அணுகல் விதிகள்\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"அணுகல் விதிகள் உங்கள் ஆவணத்தின் எந்த பகுதிகளை யார் பார்க்கலாம் அல்லது திருத்தலாம் என்பதை தீர்மானிக்க நுணுக்கமான விதிகளை உருவாக்க உங்களுக்கு சக்தியை வழங்குகின்றன.\",\n        \"Add new\": \"புதியதைச் சேர்க்கவும்\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"மொத்தம் அல்லது சப்டோடல்களுக்கு, சுருக்கம் (அல்லது பிவோட்) அட்டவணைகளை உருவாக்க 𝚺 ஐகானைப் பயன்படுத்தவும்.\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"எங்கள் முன் தயாரிக்கப்பட்ட விட்செட்களில் ஒன்றை நீங்கள் தேர்வு செய்யலாம் அல்லது அதன் முழு முகவரி ஐ வழங்குவதன் மூலம் உங்கள் சொந்தத்தை உட்பொதிக்கலாம்.\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"சரியான நெடுவரிசைகளைக் கண்டுபிடிக்க முடியவில்லையா? நிகழ்வுகள் தரவைக் கொண்ட அட்டவணையைத் தேர்ந்தெடுக்க 'விட்செட்டை மாற்று' என்பதைக் சொடுக்கு செய்க.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"உங்கள் காலெண்டரை உள்ளமைக்க, தொடக்க/இறுதி தேதிகள் மற்றும் நிகழ்வு தலைப்புகளுக்கான நெடுவரிசைகளைத் தேர்ந்தெடுக்கவும். ஒவ்வொரு நெடுவரிசையின் வகையையும் கவனியுங்கள்.\"\n        },\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"ஒரு UUID என்பது தோராயமாக உருவாக்கிய சரம் ஆகும், இது தனித்துவமான அடையாளங்காட்டிகள் மற்றும் இணைப்பு விசைகளுக்கு பயனுள்ளதாக இருக்கும்.\",\n        \"Lookups return data from related tables.\": \"தொடர்புடைய அட்டவணைகளிலிருந்து தேடல்கள் தரவைத் தருகின்றன.\",\n        \"Use reference columns to relate data in different tables.\": \"வெவ்வேறு அட்டவணையில் தரவை தொடர்புபடுத்த குறிப்பு நெடுவரிசைகளைப் பயன்படுத்தவும்.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"சூத்திரங்கள் பல எக்செல் செயல்பாடுகளை ஆதரிக்கின்றன, முழு பைதான் தொடரியல், மற்றும் பயனுள்ள AI உதவியாளரை உள்ளடக்கியது.\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"எளிய படிவங்களை ஒட்டுமொத்தமாக உருவாக்கி, எங்கள் புதிய விட்செட்டுடன் ஒரு கிளிக்கில் பகிரவும். {{learnMoreButton}}}\",\n        \"Learn more\": \"மேலும் அறிக\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"பொருந்தினால், அனைத்து நெடுவரிசை விதிகளும் செயலாக்கப்பட்ட பிறகு இந்த விதிகள் பயன்படுத்தப்படுகின்றன.\",\n        \"Example: {{example}}\": \"எடுத்துக்காட்டு: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"ஒரு நிபந்தனையுடன் காண்பிக்கப்படும் கீழ்தோன்றும் மதிப்புகளை வடிகட்டவும்.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"சமூக விட்செட்டுகள் கிரிச்ட் சமூக உறுப்பினர்களால் உருவாக்கப்பட்டு பராமரிக்கப்படுகின்றன.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"இலக்கு அட்டவணையில் தலைகீழ் நெடுவரிசையை உருவாக்குகிறது, அவை இரு முனைகளிலிருந்தும் திருத்தப்படலாம்.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"இரு வழி குறிப்பின் ஒரு முனை ஒற்றை குறிப்பாக கட்டமைக்கப்படும்போது இந்த வரம்பு ஏற்படுகிறது.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"பல பணிகளை அனுமதிக்க, குறிப்பு நெடுவரிசையின் வகையை குறிப்பு பட்டியலுக்கு மாற்றவும்.\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"இரு வழி குறிப்பில் ஒரு நெடுவரிசை குறிப்பு வகையைக் கொண்டிருக்கும்போது இந்த வரம்பு ஏற்படுகிறது.\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"ஃபார்முலா அல்லது தூண்டுதல் சூத்திர நெடுவரிசைகளுக்கு இரு வழி குறிப்புகள் தற்போது ஆதரிக்கப்படவில்லை\",\n        \"The preview below this header shows how the selected user will see this document\": \"தேர்ந்தெடுக்கப்பட்ட பயனர் இந்த ஆவணத்தை எவ்வாறு பார்ப்பார் என்பதை இந்த தலைப்புக்கு கீழே உள்ள முன்னோட்டம் காட்டுகிறது\",\n        \"[Learn more.]({{link}})\": \"[மேலும் அறிக.] ({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"சுருக்க அட்டவணைகள் சூத்திர நெடுவரிசைகளை மட்டுமே கொண்டிருக்க முடியும்.\",\n        \"Manage users and resources in a Grist installation.\": \"ஒரு கிரிச்ட் நிறுவலில் பயனர்களையும் வளங்களையும் நிர்வகிக்கவும்.\",\n        \"The new Grist Assistant is here!\": \"புதிய கிரிச்ட் உதவியாளர் இங்கே இருக்கிறார்!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"சூத்திரங்கள் பல எக்செல் செயல்பாடுகள் மற்றும் முழு பைதான் தொடரியல் ஆகியவற்றை ஆதரிக்கின்றன.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"இலக்கு அட்டவணையில் புதிய குறிப்பு பட்டியல் நெடுவரிசையை உருவாக்குகிறது, இது மற்றும் இலக்கு நெடுவரிசைகள் திருத்தக்கூடிய மற்றும் ஒத்திசைக்கப்பட்டவை.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"உள் சேமிப்பிடம் என்றால் அனைத்து இணைப்புகளும் ஆவண SQLITE கோப்பில் சேமிக்கப்படுகின்றன, அதே நேரத்தில் வெளிப்புற சேமிப்பு அனைத்து இணைப்புகளும் ஒரே வெளிப்புற சேமிப்பகத்தில் சேமிக்கப்படுவதைக் குறிக்கிறது.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"வெளிப்புற சேமிப்பகத்திலிருந்து காணாமல் போன இணைப்புகளைச் சேர்க்க இது உங்களை அனுமதிக்கிறது, எ.கா. இறக்குமதி செய்யப்பட்ட ஆவணத்தில். .\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"கிரிச்டின் புதிய AI உதவியாளரின் உதவியுடன் உங்கள் தரவு மற்றும் சூத்திரங்களுடன் புரிந்து கொள்ளுங்கள், மாற்றியமைத்து வேலை செய்யுங்கள்!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs. Do not submit passwords through this form, and be careful with links in it. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"இந்த படிவம் ஒரு கிரிச்ட் பயனரால் உருவாக்கப்பட்டது, மேலும் இது கிரிச்ட் ஆய்வகங்களால் அங்கீகரிக்கப்படவில்லை. இந்த படிவத்தின் மூலம் கடவுச்சொற்களைச் சமர்ப்பிக்க வேண்டாம், அதில் உள்ள இணைப்புகளுடன் கவனமாக இருங்கள். தீங்கிழைக்கும் படிவங்களை [{{mail}}] (மெயில்டோ: {{mail}}) க்கு புகாரளிக்கவும்.\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"இந்தப் படிவம் Grist பயனரால் உருவாக்கப்பட்டது, மேலும் Grist Labs, Inc. அல்லது இந்தச் சேவையை வழங்கும் எந்தவொரு தரப்பினராலும் அங்கீகரிக்கப்படவில்லை. உங்கள் பாதுகாப்பிற்காக, இந்தப் படிவத்தின் மூலம் கடவுச்சொற்களைச் சமர்ப்பிக்க வேண்டாம், உட்பொதிக்கப்பட்ட இணைப்புகளைக் சொடுக்கு செய்யும் போது கவனமாக இருங்கள். தீங்கிழைக்கும் படிவங்களை [{{mail}}](mailto:{{mail}}) க்கு புகாரளிக்கவும்.\",\n        \"Set the maximum number of lines for multi-line text.\": \"பல வரி உரைக்கு அதிகபட்ச வரிகளை அமைக்கவும்.\",\n        \"Comments are here!\": \"கருத்துகள் இங்கே!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"நீங்கள் கலங்களில் கருத்துகளைச் சேர்க்கலாம், கருத்துத் தொடரிழைகளுக்குப் பதிலளிக்கலாம் மற்றும் @-குறிப்பிடும் கூட்டுப்பணியாளர்களை நீங்கள் செய்யலாம்.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"சரிபார்க்கும் போது, இந்த புலத்தின் இயல்புநிலை மதிப்பை வினவல் அளவுருக்களைப் பயன்படுத்தி முகவரி இலிருந்து முன் நிரப்பலாம்.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"பரிந்துரைகளுடன், அசல் ஆவணத்தை மாற்றாமல் தனிப்பட்ட நகலில் பயனர்கள் மாற்றங்களைச் செய்கிறார்கள், பின்னர் இந்த பரிந்துரைகளை ஒருங்கிணைப்பதற்கு முன் ஆவண உரிமையாளரால் மதிப்பாய்வு செய்யப்படும்.\",\n        \"Unpin to hide the button while keeping the filter.\": \"வடிகட்டியை வைத்திருக்கும் போது பட்டனை மறைக்க அன்பின் செய்யவும்.\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"விவரம்\",\n        \"Set description\": \"விளக்கத்தை அமைக்கவும்\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"படைப்பாளர் குழு மூடு\",\n        \"Open creator panel\": \"திறந்த கிரியேட்டர் பேனல்\",\n        \"Creator panel (right panel)\": \"கிரியேட்டர் பேனல் (வலது குழு)\",\n        \"Document header\": \"ஆவண தலைப்பு\",\n        \"Main content\": \"முக்கிய உள்ளடக்கம்\",\n        \"Main navigation and document settings (left panel)\": \"முதன்மையான வழிசெலுத்தல் மற்றும் ஆவண அமைப்புகள் (இடது குழு)\",\n        \"Close navigation panel (left panel)\": \"வழிசெலுத்தல் பேனலை மூடு (இடது பேனல்)\",\n        \"Open navigation panel (left panel)\": \"வழிசெலுத்தல் பேனலைத் திற (இடது பேனல்)\"\n    },\n    \"FieldContextMenu\": {\n        \"Copy\": \"நகலெடு\",\n        \"Cut\": \"வெட்டு\",\n        \"Clear field\": \"தெளிவான புலம்\",\n        \"Copy anchor link\": \"நங்கூரம் இணைப்பை நகலெடுக்கவும்\",\n        \"Hide field\": \"புலம் மறைக்க\",\n        \"Paste\": \"ஒட்டு\",\n        \"Comment\": \"கருத்து\"\n    },\n    \"WebhookPage\": {\n        \"Removed webhook.\": \"வெப்ஊக் அகற்றப்பட்டது.\",\n        \"Status\": \"நிலை\",\n        \"Clear queue\": \"தெளிவான வரிசை\",\n        \"Webhook settings\": \"வெப்ஊக் அமைப்புகள்\",\n        \"Cleared webhook queue.\": \"வெப்ஊக் வரிசையை அழித்துவிட்டது.\",\n        \"Columns to check when update (separated by ;)\": \"புதுப்பிப்பு போது சரிபார்க்க வேண்டிய நெடுவரிசைகள் (பிரிக்கப்பட்டவை;)\",\n        \"Enabled\": \"இயக்கப்பட்டது\",\n        \"Event Types\": \"நிகழ்வு வகைகள்\",\n        \"Memo\": \"மெமோ\",\n        \"Name\": \"பெயர்\",\n        \"Ready Column\": \"ஆயத்தம் நெடுவரிசை\",\n        \"Sorry, not all fields can be edited.\": \"மன்னிக்கவும், எல்லா புலங்களையும் திருத்த முடியாது.\",\n        \"URL\": \"முகவரி\",\n        \"Webhook Id\": \"வெப்ஊக் ஐடி\",\n        \"Table\": \"அட்டவணை\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"இந்த நெடுவரிசைகளில் ஏற்படும் மாற்றங்களுக்கு வடிகட்டி (அரைக்காற்புள்ளியால் பிரிக்கப்பட்ட ஐடிஎச்)\",\n        \"Header Authorization\": \"தலைப்பு ஏற்பு\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"சேமிக்கப்படாத ஆவண நகல்களில் வெப்ஊக்ச் கிடைக்கவில்லை\"\n    },\n    \"FormulaAssistant\": {\n        \"Grist's AI Formula Assistance. \": \"கிரிச்டின் AI சூத்திர உதவி. \",\n        \"Community\": \"சமூகம்\",\n        \"Ask the bot.\": \"போட் கேளுங்கள்.\",\n        \"Capabilities\": \"திறன்கள்\",\n        \"Data\": \"தகவல்கள்\",\n        \"Formula Cheat Sheet\": \"சூத்திர ஏமாற்றுத் தாள்\",\n        \"Formula Help. \": \"சூத்திர உதவி. \",\n        \"Function List\": \"செயல்பாடு பட்டியல்\",\n        \"Grist's AI Assistance\": \"கிரிச்டின் AI உதவி\",\n        \"Need help? Our AI assistant can help.\": \"உதவி தேவையா? எங்கள் AI உதவியாளர் உதவ முடியும்.\",\n        \"New Chat\": \"புதிய அரட்டை\",\n        \"Preview\": \"முன்னோட்டம்\",\n        \"Regenerate\": \"மீளுருவாக்கம்\",\n        \"Save\": \"சேமி\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"எங்கள் {{helpFunction}}} மற்றும் {{formulaCheat}} ஐப் பார்க்கவும், அல்லது கூடுதல் உதவிக்கு எங்கள் {{community}}ஐப் பார்வையிடவும்.\",\n        \"Tips\": \"உதவிக்குறிப்புகள்\",\n        \"AI Assistant\": \"உங்களுக்கு உதவியாளர் இருக்கிறார்\",\n        \"Apply\": \"இடு\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Clear conversation\": \"உரையாடலை அழிக்கவும்\",\n        \"Code view\": \"குறியீடு பார்வை\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"ஆய், நான் கிரிச்ட் ஃபார்முலா AI உதவியாளர்.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"நான் சூத்திரங்களுக்கு மட்டுமே உதவ முடியும். நான் அட்டவணைகள், நெடுவரிசைகள் மற்றும் காட்சிகளை உருவாக்கவோ அல்லது அணுகல் விதிகளை எழுதவோ முடியாது.\",\n        \"Learn more\": \"மேலும் அறிக\",\n        \"Press Enter to apply suggested formula.\": \"பரிந்துரைக்கப்பட்ட சூத்திரத்தை விண்ணப்பிக்க Enter ஐ அழுத்தவும்.\",\n        \"Sign Up for Free\": \"இலவசமாக பதிவு செய்க\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"ஃபார்முலா AI உதவியாளரைப் பயன்படுத்தத் தொடங்க இலவச கிரிச்ட் கணக்கிற்கு பதிவுபெறுக.\",\n        \"There are some things you should know when working with me:\": \"என்னுடன் பணிபுரியும் போது நீங்கள் தெரிந்து கொள்ள வேண்டிய சில விசயங்கள் உள்ளன:\",\n        \"What do you need help with?\": \"உங்களுக்கு என்ன உதவி தேவை?\",\n        \"Formula AI Assistant is only available for logged in users.\": \"ஃபார்முலா AI உதவியாளர் பயனர்களில் உள்நுழைந்தவருக்கு மட்டுமே கிடைக்கிறது.\",\n        \"For higher limits, contact the site owner.\": \"அதிக வரம்புகளுக்கு, தள உரிமையாளரைத் தொடர்பு கொள்ளுங்கள்.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"அதிக வரம்புகளுக்கு, {{upgradeNudge}}.\",\n        \"You have used all available credits.\": \"கிடைக்கக்கூடிய அனைத்து வரவுகளையும் நீங்கள் பயன்படுத்தியுள்ளீர்கள்.\",\n        \"You have {{numCredits}} remaining credits.\": \"உங்களிடம் {{numCredits}} மீதமுள்ள வரவு.\",\n        \"upgrade to the Pro Team plan\": \"சார்பு குழு திட்டத்திற்கு மேம்படுத்தவும்\",\n        \"upgrade your plan\": \"உங்கள் திட்டத்தை மேம்படுத்தவும்\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"ஃபார்முலாக்களுடன் கூடுதல் உதவிக்கு, எங்கள் {{functionList}}} மற்றும் {{formulaCheatSheet}} அல்லது கூடுதல் உதவிக்கு எங்கள் {{community}} பார்வையிடவும்.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"நீங்கள் என்னுடன் பேசும்போது, உங்கள் கேள்விகள் மற்றும் உங்கள் ஆவண அமைப்பு ({{codeView}} இல் தெரியும்) OpenAI க்கு அனுப்பப்படும். {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"ஒரு நபரைப் போல என்னுடன் பேசுங்கள். அட்டவணைகள் மற்றும் நெடுவரிசை பெயர்களைக் குறிப்பிட தேவையில்லை. எடுத்துக்காட்டாக, \\\"மொத்த விலைப்பட்டியல் தொகையைக் கணக்கிடுங்கள்\\\" என்று நீங்கள் கேட்கலாம்\"\n    },\n    \"WelcomeSitePicker\": {\n        \"You can always switch sites using the account menu.\": \"கணக்கு மெனுவைப் பயன்படுத்தி நீங்கள் எப்போதும் தளங்களை மாற்றலாம்.\",\n        \"You have access to the following Grist sites.\": \"பின்வரும் கிரிச்ட் தளங்களுக்கான அணுகல் உங்களுக்கு உள்ளது.\",\n        \"Welcome back\": \"மீண்டும் வருக\"\n    },\n    \"UserManager\": {\n        \"Collaborator\": \"ஒத்துழைப்பாளர்\",\n        \"Confirm\": \"உறுதிப்படுத்தவும்\",\n        \"Copy link\": \"இணைப்பை நகலெடுக்கவும்\",\n        \"Create a team to share with more people\": \"அதிகமானவர்களுடன் பகிர்ந்து கொள்ள ஒரு குழுவை உருவாக்கவும்\",\n        \"Grist support\": \"கிரிச்ட் உதவி\",\n        \"Guest\": \"விருந்தினர்\",\n        \"Invite multiple\": \"பலவற்றை அழைக்கவும்\",\n        \"Off\": \"அணை\",\n        \"On\": \"ஆன்\",\n        \"Open Access Rules\": \"அணுகல் விதிகள்\",\n        \"Outside collaborator\": \"ஒத்துழைப்பாளருக்கு வெளியே\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"பொது அணுகல் {{parent}} இலிருந்து பெறப்படுகிறது. அகற்ற, 'எதுவுமில்லை' என்ற 'அணுகல்' விருப்பத்தை அமைக்கவும்.\",\n        \"User may not modify their own access.\": \"பயனர் தங்கள் சொந்த அணுகலை மாற்றக்கூடாது.\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"இயல்புநிலை அணுகல் முழு குழு தளத்தை விட தனிப்பட்ட ஆவணங்கள் அல்லது பணியிடங்களுக்கு அணுகலை வழங்க அனுமதிக்காது.\",\n        \"Add {{member}} to your team\": \"உங்கள் குழுவில் {{member}} சேர்க்கவும்\",\n        \"Allow anyone with the link to open.\": \"இணைப்பு உள்ள எவரையும் திறக்க அனுமதிக்கவும்.\",\n        \"Anyone with link \": \"இணைப்பு உள்ள எவரும் \",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Close\": \"மூடு\",\n        \"Invite people to {{resourceType}}\": \"{{resourceType}} க்கு மக்களை அழைக்கவும்\",\n        \"Link copied to clipboard\": \"கிளிப்போர்டில் இணைப்பு நகலெடுக்கப்பட்டது\",\n        \"Manage members of team site\": \"குழு தளத்தின் உறுப்பினர்களை நிர்வகிக்கவும்\",\n        \"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}}.\": \"உங்கள் சொந்த அணுகலை நீங்கள் அகற்றியவுடன், {{name}} க்கு போதுமான அணுகல் கொண்ட வேறொருவரின் உதவி இல்லாமல் அதைத் திரும்பப் பெற முடியாது.\",\n        \"Public access\": \"பொது அணுகல்\",\n        \"Public access: \": \"பொது அணுகல்: \",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"இயல்புநிலை அணுகல் எதுவும் முழு குழு தளத்தை விட தனிப்பட்ட ஆவணங்கள் அல்லது பணியிடங்களுக்கு அணுகலை வழங்க அனுமதிக்காது.\",\n        \"Remove my access\": \"எனது அணுகலை அகற்று\",\n        \"Save & \": \"சேமிக்கவும் & \",\n        \"Team member\": \"குழு உறுப்பினர்\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"{{parent}) இருந்து இலிருந்து பயனர் அனுமதிகளைப் பெறுகிறார். அகற்ற, 'எதுவுமில்லை' என்ற 'அணுகல்' விருப்பத்தை அமைக்கவும்.\",\n        \"Your role for this team site\": \"இந்த குழு தளத்திற்கான உங்கள் பங்கு\",\n        \"Your role for this {{resourceType}}\": \"இதற்கான உங்கள் பங்கு {{resourceType}}\",\n        \"free collaborator\": \"இலவச ஒத்துழைப்பாளர்\",\n        \"guest\": \"விருந்தினர்\",\n        \"member\": \"உறுப்பினர்\",\n        \"team site\": \"குழு தளம்\",\n        \"{{collaborator}} limit exceeded\": \"{{collaborator}} வரம்பு மீறியது\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} இன் {{limitTop}} {{collaborator}}கள்\",\n        \"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}} க்கு போதுமான அணுகல் கொண்ட வேறொருவரின் உதவி இல்லாமல் அதைத் திரும்பப் பெற முடியாது.\",\n        \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\": \"பயனருக்கு {{resource}} க்கான அணுகல் உள்ளது, இதன் விளைவாக உள்ளே உள்ள வளங்களுக்கான கைமுறையாக அமைக்கப்பட்டுள்ளது. இங்கே அகற்றப்பட்டால், இந்தப் பயனர் உள்ளே வளங்களுக்கான அணுகலை இழக்கும்.\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"{{parent}} இலிருந்து பயனர் அனுமதிகளைப் பெறுகிறார். அகற்ற, 'எதுவுமில்லை' என்ற 'அணுகல்' விருப்பத்தை அமைக்கவும்.\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"இந்த {{resourceType}} க்கான உங்கள் சொந்த அணுகலை நீங்கள் அகற்ற உள்ளீர்கள்\",\n        \"Inherit access: \": \"அணுகலைப் பெறுதல்: \",\n        \"Access overview\": \"அணுகல் கண்ணோட்டம்\",\n        \"Share it publicly\": \"பொதுவில் பகிரவும்\",\n        \"Verify your sensitive data before sharing publicly\": \"பொதுவில் பகிர்வதற்கு முன், உங்கள் முக்கியத் தரவைச் சரிபார்க்கவும்\",\n        \"Your {{resourceType}} will be accessible to anyone with the link, whether shared directly or found through a search engine. \\n Ensure that your {{resourceType}} does not contain sensitive data before sharing.\": \"உங்கள் {{resourceType}} இணைப்பைக் கொண்டுள்ள எவரும், நேரடியாகப் பகிரப்பட்டாலும் அல்லது தேடுபொறி மூலம் கண்டறியப்பட்டாலும் அணுக முடியும். \\nபகிர்வதற்கு முன், உங்கள் {{resourceType}} இல் முக்கியமான தரவு இல்லை என்பதை உறுதிப்படுத்தவும்.\"\n    },\n    \"SupportGristPage\": {\n        \"Help Center\": \"உதவி நடுவண்\",\n        \"Home\": \"வீடு\",\n        \"Manage Sponsorship\": \"ச்பான்சர்சிப்பை நிர்வகிக்கவும்\",\n        \"You can opt out of telemetry at any time from this page.\": \"இந்த பக்கத்திலிருந்து எந்த நேரத்திலும் நீங்கள் டெலிமெட்ரியிலிருந்து விலகலாம்.\",\n        \"Sponsor\": \"ஒப்புரவாளர்\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"இந்த நிகழ்வு டெலிமெட்ரியிலிருந்து விலகப்படுகிறது. இதை மாற்ற தள நிர்வாகிக்கு மட்டுமே இசைவு உள்ளது.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"எங்கள் {{link}} இல் விவரிக்கப்பட்டுள்ளபடி, பயன்பாட்டு புள்ளிவிவரங்களை மட்டுமே நாங்கள் சேகரிக்கிறோம், உள்ளடக்கங்களை ஒருபோதும் ஆவணப்படுத்த வேண்டாம்.\",\n        \"GitHub\": \"கிரப்\",\n        \"GitHub Sponsors page\": \"அறிவிலிமையம் ஒப்புரவாளர்கள் பக்கம்\",\n        \"Opt in to Telemetry\": \"டெலிமெட்ரி தேர்வு செய்யவும்\",\n        \"Opt out of Telemetry\": \"டெலிமெட்ரியிலிருந்து விலகுங்கள்\",\n        \"Sponsor Grist Labs on GitHub\": \"கிட்அப்பில் ஒப்புரவாளர் கிரிச்ட் ஆய்வகங்கள்\",\n        \"Support Grist\": \"உதவி கிரிச்ட்\",\n        \"Telemetry\": \"டெலிமெட்ரி\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"இந்த நிகழ்வு டெலிமெட்ரி தேர்வு செய்யப்படுகிறது. இதை மாற்ற தள நிர்வாகிக்கு மட்டுமே இசைவு உள்ளது.\",\n        \"You have opted in to telemetry. Thank you!\": \"நீங்கள் டெலிமெட்ரி தேர்வு செய்துள்ளீர்கள். நன்றி!\",\n        \"You have opted out of telemetry.\": \"நீங்கள் டெலிமெட்ரியிலிருந்து விலகிவிட்டீர்கள்.\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"கிரிச்ட் மென்பொருளைக் கிரிச்ட் லேப்சால் உருவாக்கப்பட்டது, இது இலவச மற்றும் கட்டண புரவலன் திட்டங்களை வழங்குகிறது. {{link}} இல் நிலையான இலவச மற்றும் திறந்த OSS உரிமத்தின் (அப்பாச்சி 2.0) கீழ் கிரிச்ட் குறியீட்டை நாங்கள் கிடைக்கச் செய்கிறோம்.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"டெலிமெட்ரியைத் தேர்ந்தெடுப்பதன் மூலம் உதவி திருட்டு, இது தயாரிப்பு எவ்வாறு பயன்படுத்தப்படுகிறது என்பதைப் புரிந்துகொள்ள உதவுகிறது, இதன் மூலம் எதிர்கால மேம்பாடுகளுக்கு முன்னுரிமை அளிக்க முடியும்.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"நாங்கள் ஒரு சிறிய மற்றும் உறுதியான குழு. உங்கள் உதவி எங்களுக்கு நிறைய முக்கியமானது. இந்த தயாரிப்புக்கு பின்னால் ஒரு உறுதியான சமூகம் உள்ளது என்பதையும் இது மற்றவர்களுக்கு காட்டுகிறது.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"எங்கள் {{link}} இல் எங்களுக்கு நிதியுதவி செய்வதன் மூலம் கிரிச்ட் திறந்த-மூல வளர்ச்சியை நீங்கள் ஆதரிக்கலாம்.\"\n    },\n    \"CardContextMenu\": {\n        \"Insert card below\": \"கார்டை கீழே செருகவும்\",\n        \"Copy anchor link\": \"நங்கூரம் இணைப்பை நகலெடுக்கவும்\",\n        \"Delete card\": \"அட்டையை நீக்கு\",\n        \"Duplicate card\": \"நகல் அட்டை\",\n        \"Insert card\": \"அட்டையைச் செருகவும்\",\n        \"Insert card above\": \"மேலே கார்டைச் செருகவும்\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"மறைக்கப்பட்ட புலங்கள்\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"Schedule call\": \"அழைப்பு திட்டமிடு\",\n        \"Maybe later\": \"ஒருவேளை பின்னர்\",\n        \"free coaching call\": \"இலவச பயிற்சி அழைப்பு\",\n        \"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.\": \"அழைப்பில், உங்கள் தேவைகளைப் புரிந்துகொள்வதற்கும் உங்களுக்கு அழைப்பைத் தக்கவைத்துக்கொள்வதற்கும் நாங்கள் நேரம் எடுப்போம். உங்களுக்கு தேவையான டாச்போர்டுகளை உருவாக்க உங்கள் தரவுத்தளங்களை நாங்கள் உங்களுக்குக் காண்பிக்கலாம், அல்லது உங்கள் தரவுகளுடன் இப்போதே பணியாற்றத் தொடங்கலாம்.\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"எங்கள் அணியின் உறுப்பினருடன் உங்கள் {{freeCoachingCall}} திட்டமிடவும்.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"கிரிஸ்ட் பற்றி மேலும் அறிய {{ourWeeklyWebinars}} நீங்கள் பார்க்கலாம்.\",\n        \"our weekly webinars\": \"எங்கள் வாராந்திர வலைத்தளங்கள்\",\n        \"Free coaching call\": \"இலவச பயிற்சி அழைப்பு\",\n        \"Grist 101\": \"கிரிச்ட் 101\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"புதிய பயனர்கள் கிரிச்டின் அடிப்படைகளை வழிசெலுத்துவதற்கு உதவுவதற்காக வடிவமைக்கப்பட்ட எங்கள் அறிமுக வலைப்பக்கமான {{ourWeeklyWebinars}}ஐயும் நீங்கள் பார்க்கலாம்.\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"புதிய பயனர்கள் கிரிச்டின் அடிப்படைகளை வழிசெலுத்துவதற்கு உதவுவதற்காக வடிவமைக்கப்பட்ட எங்கள் அறிமுக வலைப்பக்கமான {{ourWeeklyWebinars}}ஐயும் நீங்கள் பார்க்கலாம்.\"\n    },\n    \"FormView\": {\n        \"Share this form\": \"இந்த படிவத்தைப் பகிரவும்\",\n        \"View\": \"பார்வை\",\n        \"Publish\": \"வெளியிடுங்கள்\",\n        \"Publish your form?\": \"உங்கள் படிவத்தை வெளியிடவா?\",\n        \"Unpublish\": \"வெளியிடுதல்\",\n        \"Unpublish your form?\": \"உங்கள் படிவத்தை வெளியிடவா?\",\n        \"Code copied to clipboard\": \"குறியீடு இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"கீழேயுள்ள இணைப்பு உள்ள எவரும் வெற்று வடிவத்தைக் காணலாம் மற்றும் பதிலைச் சமர்ப்பிக்கலாம்.\",\n        \"Are you sure you want to reset your form?\": \"உங்கள் படிவத்தை மீட்டமைக்க விரும்புகிறீர்களா?\",\n        \"Copy code\": \"குறியீட்டை நகலெடுக்கவும்\",\n        \"Copy link\": \"இணைப்பை நகலெடுக்கவும்\",\n        \"Embed this form\": \"இந்த படிவத்தை உட்பொதிக்கவும்\",\n        \"Link copied to clipboard\": \"கிளிப்போர்டில் இணைப்பு நகலெடுக்கப்பட்டது\",\n        \"Share\": \"பங்கு\",\n        \"Preview\": \"முன்னோட்டம்\",\n        \"Reset\": \"மீட்டமை\",\n        \"Reset form\": \"படிவத்தை மீட்டமை\",\n        \"Save your document to publish this form.\": \"இந்த படிவத்தை வெளியிட உங்கள் ஆவணத்தை சேமிக்கவும்.\",\n        \"# **Form Title**\": \"# ** படிவ தலைப்பு **\",\n        \"Your form description goes here.\": \"உங்கள் படிவ விளக்கம் இங்கே செல்கிறது.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"உங்கள் படிவத்தை வெளியிடுவது ஒரு பங்கு இணைப்பை உருவாக்கும். இணைப்பு உள்ள எவரும் வெற்று வடிவத்தைக் காணலாம் மற்றும் பதிலைச் சமர்ப்பிக்கலாம்.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"படிவத்தை வெளியிடுவது பங்கு இணைப்பை முடக்கும், இதனால் அந்த இணைப்பு வழியாக உங்கள் படிவத்தை அணுகும் பயனர்கள் பிழையைக் காண்பார்கள்.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"பயனர்கள் உள்ளீடுகளை சமர்ப்பிப்பதற்கும் (உங்கள் அட்டவணையில் பதிவுகள்) மற்றும் குறிப்பு மற்றும் தேர்வு நெடுவரிசைகள் போன்ற நியமிக்கப்பட்ட துறைகளில் முன் அமைக்கப்பட்ட மதிப்புகளைப் படிப்பதற்கும் மட்டுப்படுத்தப்பட்டவர்கள்.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"உங்கள் படிவம் வெளியிடப்பட்டது. ஒவ்வொரு மாற்றமும் நேரடி மற்றும் படிவத்தை அணுகக்கூடிய பயனர்களுக்கு தெரியும். வரைவில் மாற்றங்களைச் செய்ய விரும்பினால், படிவத்தை வெளியிடுங்கள்.\"\n    },\n    \"Editor\": {\n        \"Delete\": \"நீக்கு\"\n    },\n    \"Menu\": {\n        \"Columns\": \"நெடுவரிசைகள்\",\n        \"Header\": \"தலைப்பி\",\n        \"Building blocks\": \"கட்டுமான தொகுதிகள்\",\n        \"Copy\": \"நகலெடு\",\n        \"Cut\": \"வெட்டு\",\n        \"Insert question above\": \"மேலே கேள்வியைச் செருகவும்\",\n        \"Insert question below\": \"கேள்வியை கீழே செருகவும்\",\n        \"Paragraph\": \"பத்தி\",\n        \"Paste\": \"ஒட்டு\",\n        \"Separator\": \"பிரிப்பான்\",\n        \"Unmapped fields\": \"தடையற்ற புலங்கள்\",\n        \"New question\": \"புதிய கேள்வி\",\n        \"More\": \"மேலும்\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Clear\": \"தெளிவான\",\n        \"Map fields\": \"வரைபட புலங்கள்\",\n        \"Mapped\": \"வரைபடமாக்கப்பட்டது\",\n        \"Select all\": \"அனைத்தையும் தெரிவுசெய்\",\n        \"Unmap fields\": \"உமிழ்வான புலங்கள்\",\n        \"Unmapped\": \"கவனிக்கப்படாதது\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"பிழை\"\n    },\n    \"CreateTeamModal\": {\n        \"Domain name is required\": \"டொமைன் பெயர் தேவை\",\n        \"Go to your site\": \"உங்கள் தளத்திற்குச் செல்லுங்கள்\",\n        \"Team url\": \"குழு முகவரி\",\n        \"Work as a Team\": \"ஒரு குழுவாக வேலை செய்யுங்கள்\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Choose a name and url for your team site\": \"உங்கள் குழு தளத்திற்கு ஒரு பெயர் மற்றும் முகவரி ஐத் தேர்வுசெய்க\",\n        \"Create site\": \"தளத்தை உருவாக்கவும்\",\n        \"Domain name is invalid\": \"டொமைன் பெயர் தவறானது\",\n        \"Team name\": \"அணி பெயர்\",\n        \"Team name is required\": \"குழு பெயர் தேவை\",\n        \"Team site created\": \"குழு தளம் உருவாக்கப்பட்டது\",\n        \"Billing is not supported in grist-core\": \"கிரிச்ட்-கோரில் பட்டியலிடல் ஆதரிக்கப்படவில்லை\"\n    },\n    \"AdminPanel\": {\n        \"Support Grist Labs on GitHub\": \"கிதுபில் கிரிச்ட் ஆய்வகங்களை ஆதரிக்கவும்\",\n        \"Telemetry\": \"டெலிமெட்ரி\",\n        \"Version\": \"பதிப்பு\",\n        \"Error checking for updates\": \"புதுப்பிப்புகளைச் சரிபார்ப்பதில் பிழை\",\n        \"OK\": \"சரி\",\n        \"Updates\": \"புதுப்பிப்புகள்\",\n        \"unconfigured\": \"கட்டமைக்கப்படாதது\",\n        \"unknown\": \"தெரியவில்லை\",\n        \"Administrator Panel Unavailable\": \"நிர்வாகி குழு கிடைக்கவில்லை\",\n        \"Authentication\": \"ஏற்பு\",\n        \"Check failed.\": \"காசோலை தோல்வியுற்றது.\",\n        \"Check succeeded.\": \"காசோலை செய் பெற்றது.\",\n        \"Details\": \"விவரங்கள்\",\n        \"No fault detected.\": \"தவறு எதுவும் கண்டறியப்படவில்லை.\",\n        \"Notes\": \"குறிப்புகள்\",\n        \"Results\": \"முடிவுகள்\",\n        \"Self Checks\": \"தன்வய காசோலைகள்\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"அல்லது, ஒரு குறைவடையும் என, நீங்கள் அமைக்கலாம்: {{bootKey}} சூழலில் மற்றும் பார்வையிடவும்: {{url}}\",\n        \"Admin Panel\": \"நிர்வாக குழு\",\n        \"Current\": \"மின்னோட்ட்ம், ஓட்டம்\",\n        \"Current version of Grist\": \"கிரிச்டின் தற்போதைய பதிப்பு\",\n        \"Help us make Grist better\": \"கிரிச்ட்டை சிறப்பாகச் செய்ய எங்களுக்கு உதவுங்கள்\",\n        \"Home\": \"வீடு\",\n        \"Sponsor\": \"ஒப்புரவாளர்\",\n        \"Support Grist\": \"உதவி கிரிச்ட்\",\n        \"Auto-check when this page loads\": \"இந்த பக்கம் ஏற்றும்போது தானாக சரிபார்க்கவும்\",\n        \"Check now\": \"இப்போது சரிபார்க்க\",\n        \"Checking for updates...\": \"புதுப்பிப்புகளைச் சரிபார்க்கிறது ...\",\n        \"Error\": \"பிழை\",\n        \"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_sandbox_flaver ஐ gvisor க்கு அமைக்க பரிந்துரைக்கிறோம், ஒவ்வொரு ஆவணத்திலும் சூத்திரங்களை மற்ற ஆவணங்களிலிருந்து தனிமைப்படுத்தப்பட்டு நெட்வொர்க்கிலிருந்து தனிமைப்படுத்தப்பட்ட சாண்ட்பாக்சுக்குள்.\",\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_session_secret வழியாக இந்த விசையை அமைக்கவும். கிரிச்ட் அமைக்கப்படாதபோது கடின குறியீட்டு இயல்புநிலைக்கு திரும்பும். V1.1.16 இயல்பாகவே குறியாக்கவியல் ரீதியாக பாதுகாப்பாக இருப்பதால் அமர்வு ஐடிகள் உருவாக்கப்பட்டதால் எதிர்காலத்தில் இந்த அறிவிப்பை நாம் அகற்றலாம்.\",\n        \"Grist is up to date\": \"கிரிச்ட் புதுப்பித்த நிலையில் உள்ளது\",\n        \"Grist releases are at \": \"கிரிச்ட் வெளியீடுகள் உள்ளன \",\n        \"Last checked {{time}}\": \"கடைசியாக சரிபார்க்கப்பட்டது {{time}}\",\n        \"Learn more.\": \"மேலும் அறிக.\",\n        \"Newer version available\": \"புதிய பதிப்பு கிடைக்கிறது\",\n        \"No information available\": \"எந்த தகவலும் கிடைக்கவில்லை\",\n        \"Sandbox settings for data engine\": \"தரவு இயந்திரத்திற்கான சாண்ட்பாக்ச் அமைப்புகள்\",\n        \"Sandboxing\": \"சாண்ட்பாக்சிங்\",\n        \"Security Settings\": \"பாதுகாப்பு அமைப்புகள்\",\n        \"Current authentication method\": \"தற்போதைய அங்கீகார முறை\",\n        \"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.\": \"SAML மற்றும் OIDC உள்ளிட்ட பல்வேறு வகையான அங்கீகாரங்களை உள்ளமைக்க கிரிச்ட் அனுமதிக்கிறது. நெட்வொர்க்கில் கிரிச்ட் அணுகப்பட்டால் அல்லது பல நபர்களுக்கு கிடைக்கக்கூடியதாக இருந்தால் இவற்றில் ஒன்றை இயக்க பரிந்துரைக்கிறோம்.\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"நிர்வாகி குழுவுக்கு உங்களுக்கு அணுகல் இல்லை.\\n தயவுசெய்து நிர்வாகியாக உள்நுழைக.\",\n        \"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.\": \"SAML மற்றும் OIDC உள்ளிட்ட பல்வேறு வகையான அங்கீகாரங்களை உள்ளமைக்க கிரிச்ட் அனுமதிக்கிறது. நெட்வொர்க்கில் கிரிச்ட் அணுகப்பட்டால் அல்லது பல நபர்களுக்கு கிடைக்கக்கூடியதாக இருந்தால் இவற்றில் ஒன்றை இயக்க பரிந்துரைக்கிறோம்.\",\n        \"Key to sign sessions with\": \"அதனுடன் அமர்வுகளில் கையொப்பமிடுவதற்கான திறவுகோல்\",\n        \"Session Secret\": \"அமர்வு மறைபொருள்\",\n        \"Enable Grist Enterprise\": \"கிரிச்ட் நிறுவனத்தை இயக்கவும்\",\n        \"Enterprise\": \"நிறுவனம்\",\n        \"checking\": \"சோதனை\",\n        \"Audit Logs\": \"தணிக்கை பதிவுகள்\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"கிரிச்ட் ஒரு ரகசிய விசையுடன் பயனர் அமர்வு குக்கீகளை கையொப்பமிடுகிறது. சுற்றுச்சூழல் மாறி grist_session_secret வழியாக இந்த விசையை அமைக்கவும். கிரிச்ட் அமைக்கப்படாதபோது கடின குறியீட்டு இயல்புநிலைக்கு திரும்பும். V1.1.16 இயல்பாகவே குறியாக்கவியல் ரீதியாக பாதுகாப்பாக இருப்பதால் அமர்வு ஐடிகள் உருவாக்கப்பட்டதால் எதிர்காலத்தில் இந்த அறிவிப்பை நாம் அகற்றலாம்.\",\n        \"Contact us\": \"எங்களை தொடர்புகொள்\",\n        \"Log Streaming\": \"பதிவு ச்ட்ரீமிங்\",\n        \"New, Enterprise\": \"புதிய, நிறுவன\",\n        \"Off\": \"அணை\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} மேலும்\",\n        \"On\": \"ஆன்\",\n        \"Grist Instance\": \"கிரிச்ட் நிகழ்வு\",\n        \"Auto-check weekly\": \"தானாக சரிபார்ப்பு வாராந்திர\",\n        \"No record of last version check\": \"கடைசி பதிப்பு காசோலையின் பதிவு இல்லை\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"நீங்கள் கிரிச்ட் எண்டர்பிரைசை இயக்கினால், கிரிச்டிலிருந்து வெளிப்புற பாதுகாப்பு செய்தி மற்றும் நிகழ்வு மேலாண்மை (SIEM) அமைப்புக்குத் தணிக்கை நிகழ்வுகளின் ச்ட்ரீமிங்கை அமைக்கலாம். {{contactUsLink}} மேலும் அறிய.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"தானியங்கி சோதனைகள் முடக்கப்பட்டுள்ளன. சுற்றுச்சூழல் மாறி grist_allow_automatic_version_chechking ஐ \\\"உண்மை\\\" என்று அமைக்கவும்.\",\n        \"{{count}} admin accounts_one\": \"{{count}} நிர்வாகி கணக்கு\",\n        \"{{count}} admin accounts_other\": \"{{count}} நிர்வாகி கணக்குகள்\",\n        \"auth error\": \"அங்கீகார பிழை\",\n        \"configured\": \"கட்டமைக்கப்பட்டது\",\n        \"default\": \"இயல்புநிலை\",\n        \"more...\": \"மேலும்...\",\n        \"no authentication\": \"ஏற்பு இல்லை\",\n        \"unavailable\": \"கிடைக்கவில்லை\",\n        \"Admin account not found\": \"நிர்வாகி கணக்கு கிடைக்கவில்லை\",\n        \"Administrative accounts\": \"நிர்வாக கணக்குகள்\",\n        \"The users with administrative accounts\": \"நிர்வாகக் கணக்குகளைக் கொண்ட பயனர்கள்\",\n        \"Version {{versionNumber}}\": \"பதிப்பு {{versionNumber}}\",\n        \"no admin accounts\": \"நிர்வாகி கணக்குகள் இல்லை\",\n        \"Are you sure you want to restart Grist?\": \"நிச்சயமாக Grist ஐ மீண்டும் தொடங்க விரும்புகிறீர்களா?\",\n        \"Grist is running in an environment that doesn't support restarting from the admin panel.\": \"நிர்வாக குழுவிலிருந்து மறுதொடக்கம் செய்வதை ஆதரிக்காத சூழலில் Grist இயங்குகிறது.\",\n        \"Restart\": \"மறுதொடக்கம்\",\n        \"Restart Grist\": \"Grist ஐ மீண்டும் தொடங்கவும்\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"நிலுவையில் உள்ள மாற்றங்களைப் பயன்படுத்த அல்லது சிக்கல்களைத் தீர்க்க Grist ஐ மீண்டும் தொடங்கவும்.\",\n        \"Restart Grist?\": \"Grist ஐ மீண்டும் தொடங்கவா?\",\n        \"Restarting Grist...\": \"Grist ஐ மீண்டும் தொடங்குகிறது...\",\n        \"This will apply any pending changes and briefly interrupt access for all users.\": \"இது நிலுவையில் உள்ள ஏதேனும் மாற்றங்களைப் பயன்படுத்துவதோடு, அனைத்துப் பயனர்களுக்கும் அணுகலைச் சுருக்கமாக குறுக்கிடும்.\",\n        \"You can still restart Grist manually.\": \"நீங்கள் இன்னும் Grist ஐ கைமுறையாக மறுதொடக்கம் செய்யலாம்.\",\n        \"error in {{provider}}: {{verdict}}\": \"{{provider}} இல் பிழை: {{verdict}}\",\n        \"Please restart Grist manually.\": \"கிரிச்டை கைமுறையாக மறுதொடக்கம் செய்யவும்.\",\n        \"Restart Grist to apply pending changes.\": \"நிலுவையில் உள்ள மாற்றங்களைப் பயன்படுத்த Grist ஐ மீண்டும் தொடங்கவும்.\",\n        \"Restart unavailable\": \"மறுதொடக்கம் கிடைக்கவில்லை\"\n    },\n    \"Toggle\": {\n        \"Field Format\": \"புலம் வடிவம்\",\n        \"Checkbox\": \"தேர்வுப்பெட்டி\",\n        \"Switch\": \"ஆளி, நிலைமாறி\"\n    },\n    \"DocTutorial\": {\n        \"Click to expand\": \"விரிவாக்க சொடுக்கு செய்க\",\n        \"End tutorial\": \"இறுதி பயிற்சி\",\n        \"Finish\": \"முடிக்க\",\n        \"Next\": \"அடுத்தது\",\n        \"Previous\": \"முந்தைய\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"டுடோரியலை மறுதொடக்கம் செய்ய விரும்புகிறீர்களா? அனைத்து முன்னேற்றங்களும் இழக்கப்படும்.\",\n        \"Restart\": \"மறுதொடக்கம்\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Column\": \"நெடுவரிசை\",\n        \"Add two-way reference\": \"இரு வழி குறிப்பைச் சேர்க்கவும்\",\n        \"Delete\": \"நீக்கு\",\n        \"Delete column {{column}} in table {{table}}?\": \"{{table}} அட்டவணையில் {{column}} இல் நெடுவரிசையை நீக்கவா?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"இது அட்டவணை {{table}}இல் உள்ள குறிப்பு நெடுவரிசை {{column}}இன் தலைகீழ் ஆகும்.\",\n        \"Table\": \"அட்டவணை\",\n        \"Two-way Reference\": \"இருவழி குறிப்பு\",\n        \"Delete two-way reference?\": \"இரு வழி குறிப்பை நீக்கவா?\",\n        \"Target table\": \"இலக்கு அட்டவணை\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"இது {{refTable}} அட்டவணையில் உள்ள {{refCol}} குறிப்பு நெடுவரிசையை நீக்கும். {{myName}} குறிப்பு நெடுவரிசை தற்போதைய {{myTable}} அட்டவணையிலேயே இருக்கும்.\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Delete\": \"நீக்கு\",\n        \"Delete streaming destination?\": \"ச்ட்ரீமிங் இலக்கை நீக்கவா?\",\n        \"Destination\": \"இலக்கு\",\n        \"Add destination\": \"இலக்கைச் சேர்க்கவும்\",\n        \"Add streaming destination\": \"ச்ட்ரீமிங் இலக்கைச் சேர்க்கவும்\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"இந்த ச்ட்ரீமிங் இலக்கை நீக்க விரும்புகிறீர்களா? இந்த செயலை செயல்தவிர்க்க முடியாது.\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Destinations\": \"இடங்கள்\",\n        \"Edit\": \"தொகு\",\n        \"Enter URL\": \"முகவரி ஐ உள்ளிடவும்\",\n        \"Enter token\": \"கிள்ளாக்கை உள்ளிடவும்\",\n        \"Learn more\": \"மேலும் அறிக\",\n        \"Other\": \"மற்றொன்று\",\n        \"Save\": \"சேமி\",\n        \"Edit streaming destination\": \"ச்ட்ரீமிங் இலக்கைத் திருத்து\",\n        \"Splunk\": \"ச்ப்ளங்க்\",\n        \"Start streaming\": \"ச்ட்ரீமிங் தொடங்கவும்\",\n        \"Token\": \"கிள்ளாக்கு\",\n        \"URL\": \"முகவரி\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"கிரிச்ட்டில் இருந்து வெளிப்புற பாதுகாப்பு செய்தி மற்றும் நிகழ்வு மேலாண்மை (SIEM) அமைப்புக்கு தணிக்கை நிகழ்வுகளின் ச்ட்ரீமிங் ச்ப்ளங்க் போன்றவற்றை அமைக்கவும். {{learnMoreLink}}.\"\n    },\n    \"OnboardingPage\": {\n        \"Go hands-on with the Grist Basics tutorial\": \"கிரிச்ட் அடிப்படைகள் டுடோரியலுடன் கைகோர்த்துக் கொள்ளுங்கள்\",\n        \"Skip tutorial\": \"டுடோரியலைத் தவிர்க்கவும்\",\n        \"Go to the tutorial!\": \"டுடோரியலுக்குச் செல்லுங்கள்!\",\n        \"Next step\": \"அடுத்த அடி\",\n        \"Skip step\": \"படி தவிர்க்கவும்\",\n        \"Tell us who you are\": \"நீங்கள் யார் என்று சொல்லுங்கள்\",\n        \"Type here\": \"இங்கே தட்டச்சு செய்க\",\n        \"Welcome\": \"வரவேற்கிறோம்\",\n        \"What brings you to Grist (you can select multiple)?\": \"உங்களைத் தூண்டுவது எது (நீங்கள் பலவற்றை தேர்ந்தெடுக்கலாம்)?\",\n        \"What is your role?\": \"உங்கள் பங்கு என்ன?\",\n        \"What organization are you with?\": \"நீங்கள் எந்த அமைப்புடன் இருக்கிறீர்கள்?\",\n        \"Your organization\": \"உங்கள் அமைப்பு\",\n        \"Your role\": \"உங்கள் பங்கு\",\n        \"Back\": \"பின்\",\n        \"Discover Grist in 3 minutes\": \"3 நிமிடங்களில் கிரிச்ட்டைக் கண்டறியவும்\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"கிரிச்ட் ஒரு விரிதாள் போல தோன்றலாம், ஆனால் அது எப்போதும் ஒன்றைப் போல செயல்படாது. கிரிச்ட்டை வேறுபடுத்துவதைக் கண்டறியவும்.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"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}}).\": \"சோதனைக் காலத்திற்குப் பிறகு கிரிச்ட் நிறுவனத்தை இயக்க ஒரு செயல்படுத்தும் விசை பயன்படுத்தப்படுகிறது\\n 30 நாட்கள் காலாவதியானது. [கிரிச்டுக்கு பதிவுபெறுவதன் மூலம் செயல்படுத்தும் விசையைப் பெறுங்கள்\\n நிறுவன] ({{signupLink}}). இயக்க உங்களுக்கு செயல்படுத்தும் விசை தேவையில்லை\\n கிரிச்ட் கோர்.\\n\\n எங்கள் [உதவி மையத்தில்] மேலும் அறிக ({{helpCenter}}).\",\n        \"Disable Grist Enterprise\": \"கிரிச்ட் நிறுவனத்தை முடக்கு\",\n        \"Enable Grist Enterprise\": \"கிரிச்ட் நிறுவனத்தை இயக்கவும்\",\n        \"Grist Enterprise is **enabled**.\": \"கிரிச்ட் எண்டர்பிரைச் ** இயக்கப்பட்டது **.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"சோதனைக் காலத்திற்குப் பிறகு கிரிச்ட் நிறுவனத்தை இயக்க ஒரு செயல்படுத்தும் விசை பயன்படுத்தப்படுகிறது\\n 30 நாட்கள் காலாவதியானது. இன்று [எங்களைத் தொடர்புகொள்வதன்] ({{contactLink}}) மூலம் செயல்படுத்தும் விசையைப் பெறுங்கள். நீங்கள் செய்கிறீர்கள்\\n கிரிச்ட் கோரை இயக்க செயல்படுத்தும் விசை தேவையில்லை.\\n\\n எங்கள் [உதவி மையத்தில்] மேலும் அறிக ({{helpCenter}}).\",\n        \"Activate\": \"செயல்படுத்து\",\n        \"Activation key\": \"செயல்படுத்தும் விசை\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"சோதனைக் காலத்திற்குப் பிறகு கிரிச்ட் நிறுவனத்தை இயக்க ஒரு செயல்படுத்தும் விசை பயன்படுத்தப்படுகிறது \\n30 நாட்கள் காலாவதியானது. [கிரிச்டுக்கு பதிவுபெறுவதன் மூலம் செயல்படுத்தும் விசையைப் பெறுங்கள் \\nநிறுவன] ({{signupLink}}). இயக்க உங்களுக்கு செயல்படுத்தும் விசை தேவையில்லை \\nகிரிச்ட் கோர்.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"கிரிச்ட் எண்டர்பிரைசைப் பயன்படுத்துவதற்கு செயலில் சந்தா தேவை. உங்களால் முடியும் \\n[கிரிச்ட் எண்டர்பிரைசுக்கு பதிவுபெறுதல்] ({{signupLink}}) மற்றும் உங்கள் சந்தாவை செயல்படுத்துகிறீர்கள் \\nசெயல்படுத்தும் விசை கீழே.\",\n        \"Copy to clipboard\": \"இடைநிலைப்பலகைக்கு நகலெடுக்கவும்\",\n        \"Expiration date\": \"காலாவதி தேதி\",\n        \"Installation ID copied to clipboard\": \"நிறுவல் அடையாளம் இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது\",\n        \"Installation ID:\": \"நிறுவல் ஐடி:\",\n        \"Installation seats\": \"நிறுவல் இருக்கைகள்\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"எங்கள் [உதவி மையத்தில்] மேலும் அறிக ({{helpCenter}}).\",\n        \"Paste your activation key\": \"உங்கள் செயல்படுத்தும் விசையை ஒட்டவும்\",\n        \"Plan name\": \"திட்ட பெயர்\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"கிரிச்ட் நிறுவனத்தைப் பயன்படுத்த, நீங்கள் தொடர்புகொள்ள வேண்டும் \\n                  உங்கள் செயல்படுத்தும் விசையைப் பெற [தொடர்பு]({{signupLink}}).\",\n        \"You are currently trialing Grist Enterprise.\": \"நீங்கள் தற்போது கிரிச்ட் எண்டர்பிரைசை சோதனை செய்கிறீர்கள்.\",\n        \"You do not have an active subscription.\": \"உங்களிடம் செயலில் சந்தா இல்லை.\",\n        \"Your activation key has expired due to exceeding limits.\": \"உங்கள் செயல்படுத்தும் விசை வரம்புகளை மீறுவதால் காலாவதியானது.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"உங்கள் நிகழ்வு ** {{days}} ** நாள் (கள்) இல் ** படிக்க மட்டும் ** பயன்முறையில் இருக்கும்.\",\n        \"Your subscription expired on {{date}}.\": \"உங்கள் சந்தா {{date}} இல் காலாவதியானது.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"உங்கள் சோதனை காலம் ** {{expireAt}} ** இல் காலாவதியானது. கிரிச்ட் நிறுவனத்தைப் பயன்படுத்த, நீங்கள் வேண்டும் \\n[கிரிஸ்ட் நிறுவனத்திற்கு பதிவுபெறுக]({{signupLink}}) மற்றும் உங்கள் செயல்படுத்தும் விசையைக் கீழே ஒட்டு.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"நீக்கு\",\n        \"Delete data and this widget.\": \"தரவு மற்றும் இந்த விட்செட்டை நீக்கு.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"தரவை வைத்து விட்செட்டை நீக்கவும். அட்டவணை {{rawDataLink}} இல் கிடைக்கும்\",\n        \"Table {{tableName}} will no longer be visible\": \"அட்டவணை {{tableName}} இனி தெரியாது\",\n        \"Raw Data page\": \"மூல தரவு பக்கம்\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"நிர்வாக குழு\"\n    },\n    \"CustomWidgetGallery\": {\n        \"(Missing info)\": \"(காணாமல் போன தகவல்)\",\n        \"Add widget\": \"விட்செட்டைச் சேர்க்கவும்\",\n        \"Add Your Own Widget\": \"உங்கள் சொந்த விட்செட்டைச் சேர்க்கவும்\",\n        \"Add a widget from outside this gallery.\": \"இந்த கேலரிக்கு வெளியில் இருந்து ஒரு விட்செட்டைச் சேர்க்கவும்.\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Change widget\": \"விட்செட்டை மாற்றவும்\",\n        \"Choose custom widget\": \"தனிப்பயன் விட்செட்டைத் தேர்வுசெய்க\",\n        \"Community Widget\": \"சமூக விட்செட்\",\n        \"Custom URL\": \"தனிப்பயன் முகவரி\",\n        \"Developer:\": \"உருவாக்குநர்:\",\n        \"Grist Widget\": \"ஒட்டுமொத்த விட்செட்\",\n        \"Last updated:\": \"கடைசியாக புதுப்பிக்கப்பட்டது:\",\n        \"Learn more about custom widgets\": \"தனிப்பயன் விட்செட்டுகள் பற்றி மேலும் அறிக\",\n        \"No matching widgets\": \"பொருந்தக்கூடிய விட்செட்டுகள் இல்லை\",\n        \"Search\": \"தேடல்\",\n        \"Widget URL\": \"விட்செட் முகவரி\"\n    },\n    \"HomeIntroCards\": {\n        \"Finish our basics tutorial\": \"எங்கள் அடிப்படைகள் டுடோரியலை முடிக்கவும்\",\n        \"Help center\": \"உதவி நடுவண்\",\n        \"3 minute video tour\": \"3 நிமிட வீடியோ சுற்றுப்பயணம்\",\n        \"Blank document\": \"வெற்று ஆவணம்\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"தீர்வுகளைக் கண்டறிந்து மேலும் வளங்களை ஆராயுங்கள் {{helpCenterLink}}\",\n        \"Import file\": \"கோப்பு இறக்குமதி\",\n        \"Learn more {{webinarsLinks}}\": \"மேலும் அறிக {{webinarsLinks}}}\",\n        \"Start a new document\": \"புதிய ஆவணத்தைத் தொடங்கவும்\",\n        \"Templates\": \"வார்ப்புருக்கள்\",\n        \"Tutorial\": \"பயிற்சி\",\n        \"Webinars\": \"வெபினார்கள்\",\n        \"Find solutions and explore more resources\": \"தீர்வுகளைக் கண்டறிந்து மேலும் ஆதாரங்களை ஆராயுங்கள்\",\n        \"Learn more\": \"மேலும் அறிக\"\n    },\n    \"markdown\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# புதிய மார்க் பேரூர் செயல்பாடு\\n *\\n * நாம் _write_ [வழக்கமான மார்க் டவுன்] (https://markdownguide.org) * உள்ளே *\\n * ஒரு தானியங்கள் உறுப்பு.\"\n            }\n        },\n        \"The toggle is **off**\": \"மாற்று ** ஆஃப் **\",\n        \"The toggle is **on**\": \"மாற்று ** **\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# புதிய மார்க் பேரூர் செயல்பாடு\\n *\\n * நாம் _write_ [வழக்கமான மார்க் டவுன்] (https://markdownguide.org) * உள்ளே *\\n * ஒரு தானியங்கள் உறுப்பு.\"\n            }\n        },\n        \"The toggle is **off**\": \"மாற்று ** ஆஃப் **\",\n        \"The toggle is **on**\": \"மாற்று ** **\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"நிர்வாக குழு\",\n        \"Close\": \"மூடு\",\n        \"Help Center\": \"உதவி நடுவண்\",\n        \"Opt in to Telemetry\": \"டெலிமெட்ரி தேர்வு செய்யவும்\",\n        \"Opted In\": \"தேர்வு செய்யப்பட்டது\",\n        \"Support Grist\": \"உதவி கிரிச்ட்\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"நன்றி! உங்கள் நம்பிக்கையும் ஆதரவும் பெரிதும் பாராட்டப்படுகிறது. பயனர் பட்டியலில் {{link}} இலிருந்து எந்த நேரத்தையும் தேர்வு செய்யவும்.\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"தயாரிப்பு எவ்வாறு பயன்படுத்தப்படுகிறது என்பதைப் புரிந்துகொள்ள எங்களுக்கு உதவ டெலிமெட்ரியைத் தேர்வுசெய்க, இதன் மூலம் எதிர்கால மேம்பாடுகளுக்கு முன்னுரிமை அளிக்க முடியும்.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"எங்கள் {{helpCenterLink}} இல் விவரிக்கப்பட்டுள்ளபடி, பயன்பாட்டு புள்ளிவிவரங்களை மட்டுமே நாங்கள் சேகரிக்கிறோம், உள்ளடக்கங்களை ஒருபோதும் ஆவணப்படுத்த வேண்டாம். பயனர் பட்டியலில் {{supportGristLink}} இலிருந்து எந்த நேரத்தையும் தேர்வு செய்யவும்.\"\n    },\n    \"ACUserManager\": {\n        \"Enter email address\": \"மின்னஞ்சல் முகவரியை உள்ளிடவும்\",\n        \"Invite new member\": \"புதிய உறுப்பினரை அழைக்கவும்\",\n        \"We'll email an invite to {{email}}\": \"{{email}} க்கு அழைப்பை மின்னஞ்சல் செய்வோம்\"\n    },\n    \"buildReassignModal\": {\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"{{sourceTable}} பதிவு {{sourceName}} க்கு மறுசீரமைக்கவும்.\",\n        \"Record already assigned_one\": \"ஏற்கனவே ஒதுக்கப்பட்ட பதிவு\",\n        \"Record already assigned_other\": \"பதிவுகள் ஏற்கனவே ஒதுக்கப்பட்டுள்ளன\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}} பதிவு {{targetName}} ஏற்கனவே {{sourceTable}} பதிவுக்கு ஒதுக்கப்பட்டுள்ளது {{oldSourceName}}.\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"ஒவ்வொரு {{targetTable}} பதிவும் ஒற்றை {{sourceTable}} பதிவுக்கு மட்டுமே ஒதுக்கப்படலாம்.\",\n        \"Reassign\": \"மீண்டும் இணைக்கவும்\",\n        \"Reassign to new {{sourceTable}} records.\": \"புதிய {{sourceTable}} பதிவுகளுக்கு மறுசீரமைக்கவும்.\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"காண்க\",\n        \"Users from table\": \"அட்டவணையில் இருந்து பயனர்கள்\",\n        \"Example Users\": \"எடுத்துக்காட்டு பயனர்கள்\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"புதியதைச் சேர்க்கவும்\"\n    },\n    \"ApiKey\": {\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"பநிஇ விசையை உருவாக்குவதன் மூலம், உங்கள் சொந்த கணக்கிற்கான பநிஇ அழைப்புகளை நீங்கள் செய்ய முடியும்.\",\n        \"Click to show\": \"காண்பிக்க சொடுக்கு செய்க\",\n        \"Create\": \"உருவாக்கு\",\n        \"Remove\": \"அகற்று\",\n        \"Remove API Key\": \"பநிஇ விசையை அகற்று\",\n        \"This API key can be used to access this account anonymously via the API.\": \"இந்த கணக்கை பநிஇ வழியாக அநாமதேயமாக அணுக இந்த பநிஇ விசையைப் பயன்படுத்தலாம்.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"இந்த பநிஇ விசையை பநிஇ வழியாக உங்கள் கணக்கை அணுக பயன்படுத்தலாம். உங்கள் பநிஇ விசையை யாருடனும் பகிர வேண்டாம்.\",\n        \"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?\": \"நீங்கள் ஒரு பநிஇ விசையை நீக்க உள்ளீர்கள். இந்த பநிஇ விசையைப் பயன்படுத்தி எதிர்கால கோரிக்கைகள் அனைத்து கோரிக்கைகளையும் நிராகரிக்கக்கூடும். நீங்கள் இன்னும் நீக்க விரும்புகிறீர்களா?\"\n    },\n    \"App\": {\n        \"Description\": \"விவரம்\",\n        \"Key\": \"விசை\",\n        \"Memory Error\": \"நினைவக பிழை\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"மொழிபெயர்ப்பாளர்கள்: உங்கள் மொழி பயனர்களுக்கு வழங்கத் தயாராக இருக்கும்போது மட்டுமே இதை மொழிபெயர்க்கவும்\"\n    },\n    \"ChartView\": {\n        \"Create separate series for each value of the selected column.\": \"தேர்ந்தெடுக்கப்பட்ட நெடுவரிசையின் ஒவ்வொரு மதிப்புக்கும் தனி தொடரை உருவாக்கவும்.\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"ஒவ்வொரு ஒய் தொடர்களும் பிழைப் பட்டிகளின் நீளத்திற்கான தொடரைத் தொடர்ந்து.\",\n        \"Pick a column\": \"ஒரு நெடுவரிசையைத் தேர்ந்தெடுங்கள்\",\n        \"Toggle chart aggregation\": \"விளக்கப்படம் திரட்டலை மாற்றவும்\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"ஒவ்வொரு ஒய் தொடர்களும் இரண்டு தொடர்களைத் தொடர்ந்து, மேல் மற்றும் கீழ் பிழை பட்டிகளுக்கு.\",\n        \"selected new group data columns\": \"புதிய குழு-தரவு நெடுவரிசைகளைத் தேர்ந்தெடுத்தது\",\n        \"LABEL\": \"சிட்டை\",\n        \"Bar chart\": \"பார் விளக்கப்படம்\",\n        \"Pie chart\": \"பை விளக்கப்படம்\",\n        \"Donut chart\": \"டோனட் விளக்கப்படம்\",\n        \"Area chart\": \"பகுதி விளக்கப்படம்\",\n        \"Line chart\": \"வரி விளக்கப்படம்\",\n        \"Scatter plot\": \"சிதறல் சூழ்ச்சி\",\n        \"Kaplan-Meier plot\": \"கப்லான்-மேயர் சூழ்ச்சி\",\n        \"Split series\": \"பிளவு தொடர்\",\n        \"Invert Y-axis\": \"Y- அச்சு தலைகீழ்\",\n        \"Orientation\": \"நோக்குநிலை\",\n        \"Vertical\": \"செங்குத்து\",\n        \"Horizontal\": \"கிடைமட்டமாக\",\n        \"Log scale Y-axis\": \"பதிவு அளவிலான Y- அச்சு\",\n        \"Hole size\": \"துளை அளவு\",\n        \"Show total\": \"மொத்தத்தைக் காட்டு\",\n        \"Text size\": \"உரை அளவு\",\n        \"Connect gaps\": \"இடைவெளிகளை இணைக்கவும்\",\n        \"Show markers\": \"குறிப்பான்களைக் காட்டு\",\n        \"Stack series\": \"அடுக்கு தொடர்\",\n        \"Error bars\": \"பிழை பார்கள்\",\n        \"None\": \"எதுவுமில்லை\",\n        \"Symmetric\": \"சமச்சீர்\",\n        \"Above+Below\": \"மேலே+கீழே\",\n        \"Split Series\": \"பிளவு தொடர்\",\n        \"X-AXIS\": \"எக்ச்-அச்சு\",\n        \"Aggregate values\": \"மொத்த மதிப்புகள்\",\n        \"SERIES\": \"தொடர்\",\n        \"Add series\": \"தொடரைச் சேர்க்கவும்\",\n        \"non-numeric columns are not shown\": \"எண் அல்லாத நெடுவரிசைகள் காட்டப்படவில்லை\",\n        \"non-numeric column is not shown\": \"எண் அல்லாத நெடுவரிசை காட்டப்படவில்லை\",\n        \"selected new x-axis\": \"புதிய எக்ச்-அச்சு தேர்ந்தெடுக்கப்பட்டது\",\n        \"Remove\": \"அகற்று\"\n    },\n    \"CodeEditorPanel\": {\n        \"Code View is available only when you have full document access.\": \"உங்களிடம் முழு ஆவண அணுகல் இருக்கும்போது மட்டுமே குறியீடு பார்வை கிடைக்கும்.\",\n        \"Access denied\": \"அணுகல் மறுக்கப்பட்டது\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"இடு\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Default cell style\": \"இயல்புநிலை செல் நடை\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"அனைத்தும்\",\n        \"All except\": \"தவிர\",\n        \"All shown\": \"அனைத்தும் காட்டப்பட்டுள்ளன\",\n        \"None\": \"எதுவுமில்லை\",\n        \"Min\": \"மணித்துளி\",\n        \"Max\": \"அதிகபட்சம்\",\n        \"Filter by Range\": \"வரம்பால் வடிகட்டவும்\",\n        \"Future values\": \"எதிர்கால மதிப்புகள்\",\n        \"No matching values\": \"பொருந்தக்கூடிய மதிப்புகள் இல்லை\",\n        \"Start\": \"தொடங்கு\",\n        \"End\": \"முடிவு\",\n        \"Other Matching\": \"பிற பொருத்தம்\",\n        \"Other Non-Matching\": \"மற்ற பொருந்தாத பிற\",\n        \"Other values\": \"பிற மதிப்புகள்\",\n        \"Others\": \"மற்றவர்கள்\",\n        \"Search\": \"தேடல்\",\n        \"Search values\": \"தேடல் மதிப்புகள்\",\n        \"Clear search\": \"தேடலை அழி\",\n        \"Pin filter\": \"பின் வடிகட்டி\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"அகரவரிசைப்படி வரிசைப்படுத்து (தற்போதைய: நிகழ்வுகளின் எண்ணிக்கையால் வரிசைப்படுத்தப்பட்டது)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"நிகழ்வுகளின் எண்ணிக்கையின்படி வரிசைப்படுத்தவும் (தற்போதைய: அகரவரிசைப்படி வரிசைப்படுத்தப்பட்டது)\",\n        \"Unpin filter\": \"வடிப்பானை அகற்று\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"செய்கைப்பாடு\",\n        \"Beta\": \"பீட்டா\",\n        \"Compare to current\": \"மின்னோட்டத்துடன் ஒப்பிடுக\",\n        \"Compare to previous\": \"முந்தையதை ஒப்பிடுக\",\n        \"Open snapshot\": \"திறந்த ச்னாப்சாட்\",\n        \"Snapshots\": \"ச்னாப்சாட்கள்\",\n        \"Snapshots are unavailable.\": \"ச்னாப்சாட்கள் கிடைக்கவில்லை.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"அணுகல் விதிகளைக் கொண்ட ஆவணங்களுக்கான ச்னாப்சாட்களை உரிமையாளர்களுக்கு மட்டுமே அணுகலாம்.\"\n    },\n    \"DocTour\": {\n        \"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.\": \"இந்த ஆவணத்தில் உள்ள தரவுகளிலிருந்து ஆவண சுற்றுப்பயணத்தை உருவாக்க முடியாது. நெடுவரிசைகள் தலைப்பு, உடல், வேலைவாய்ப்பு மற்றும் இருப்பிடத்துடன் கிரிச்ட்டாக்டோர் என்ற அட்டவணை இருப்பதை உறுதிசெய்க.\",\n        \"No valid document tour\": \"செல்லுபடியாகும் ஆவண சுற்றுப்பயணம் இல்லை\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit Logs\": \"தணிக்கை பதிவுகள்\",\n        \"Audit logs for {{siteName}}\": \"{{siteName}} க்கான தணிக்கை பதிவுகள்\",\n        \"Contact us\": \"எங்களை தொடர்புகொள்\",\n        \"Home\": \"வீடு\",\n        \"Log streaming\": \"பதிவு ச்ட்ரீமிங்\",\n        \"Only site owners may access audit logs.\": \"தள உரிமையாளர்கள் மட்டுமே தணிக்கை பதிவுகளை அணுகலாம்.\",\n        \"upgrade your plan\": \"உங்கள் திட்டத்தை மேம்படுத்தவும்\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"நீங்கள் கிரிச்ட் எண்டர்பிரைசை இயக்கினால், கிரிச்டிலிருந்து வெளிப்புற SIEM (பாதுகாப்பு செய்தி மற்றும் நிகழ்வு மேலாண்மை) அமைப்புக்குத் தணிக்கை நிகழ்வுகளின் ச்ட்ரீமிங்கை அமைக்கலாம். {{contactUsLink}} மேலும் அறிய.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"நீங்கள் {{upgradePlanButton}} என்றால், கிரிச்டிலிருந்து வெளிப்புற SIEM (பாதுகாப்பு செய்தி மற்றும் நிகழ்வு மேலாண்மை) அமைப்புக்கு தணிக்கை நிகழ்வுகளின் ச்ட்ரீமிங் அமைக்கலாம்.\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"அட்டவணை கட்டமைப்பிற்கு கூடுதலாக எல்லா தரவையும் நகலெடுக்கவும்.\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"அட்டவணைகளை நகலெடுப்பதற்குப் பதிலாக, இணைக்கப்பட்ட காட்சிகளைப் பயன்படுத்தி தரவைப் பிரிப்பது நல்லது. {{link}}\",\n        \"Name for new table\": \"புதிய அட்டவணைக்கு பெயர்\",\n        \"Only the document default access rules will apply to the copy.\": \"ஆவண இயல்புநிலை அணுகல் விதிகள் மட்டுமே நகலுக்கு பொருந்தும்.\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"நெடுவரிசையைச் சேர்க்கவும்\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"பின் வடிகட்டி - {{- columnName}} நெடுவரிசை (தற்போதைய: பின் நீக்கப்பட்டது)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"வடிப்பான் அகற்று - {{- columnName}} நெடுவரிசை (தற்போதைய: பின் செய்யப்பட்டது)\",\n        \"remove filter - {{- columnName}} column\": \"வடிகட்டியை அகற்று - {{- columnName}} நெடுவரிசை\",\n        \"{{- columnName }} column filters\": \"{{- columnName }} நெடுவரிசை வடிப்பான்கள்\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"நெடுவரிசைகளைத் தேடுங்கள்\",\n        \"Search Columns\": \"நெடுவரிசைகளைத் தேடுங்கள்\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"கட்ட விருப்பங்கள்\",\n        \"Horizontal gridlines\": \"கிடைமட்ட கட்டங்கள்\",\n        \"Vertical gridlines\": \"செங்குத்து கட்டங்கள்\",\n        \"Zebra stripes\": \"சீப்ரா கோடுகள்\"\n    },\n    \"HomeIntro\": {\n        \"Any documents created in this site will appear here.\": \"இந்த தளத்தில் உருவாக்கப்பட்ட எந்த ஆவணங்களும் இங்கே தோன்றும்.\",\n        \"Browse Templates\": \"வார்ப்புருக்கள் உலாவுக\",\n        \"Create empty document\": \"வெற்று ஆவணத்தை உருவாக்கவும்\",\n        \"Get started by creating your first Grist document.\": \"உங்கள் முதல் கிரிச்ட் ஆவணத்தை உருவாக்குவதன் மூலம் தொடங்கவும்.\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"வார்ப்புருக்களை ஆராய்வதன் மூலம் அல்லது உங்கள் முதல் கிரிச்ட் ஆவணத்தை உருவாக்குவதன் மூலம் தொடங்கவும்.\",\n        \"Help Center\": \"உதவி நடுவண்\",\n        \"Import document\": \"இறக்குமதி ஆவணம்\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"உங்கள் அணிக்கு வெளியே கிரிச்டைப் பயன்படுத்த ஆர்வமா? உங்கள் இலவசத்தைப் பார்வையிடவும் \",\n        \"Invite Team Members\": \"குழு உறுப்பினர்களை அழைக்கவும்\",\n        \"Sign up\": \"பதிவு செய்க\",\n        \"Sprouts Program\": \"முளைகள் திட்டம்\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"உங்கள் குழுவை அழைப்பதன் மூலமும், உங்கள் முதல் கிரிச்ட் ஆவணத்தை உருவாக்குவதன் மூலமும் தொடங்கவும்.\",\n        \"This workspace is empty.\": \"இந்த பணியிடம் காலியாக உள்ளது.\",\n        \"Visit our {{link}} to learn more.\": \"மேலும் அறிய எங்கள் {{link}} ஐப் பார்வையிடவும்.\",\n        \"Welcome to Grist!\": \"கிரிச்டுக்கு வருக!\",\n        \"Welcome to Grist, {{name}}!\": \"கிரிச்டுக்கு வருக, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"{{orgName}} க்கு வருக\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"இந்த தளத்தை நீங்கள் படிக்க மட்டுமே அணுகலாம். தற்போது ஆவணங்கள் எதுவும் இல்லை.\",\n        \"personal site\": \"தனிப்பட்ட தளம்\",\n        \"{{signUp}} to save your work. \": \"Your உங்கள் வேலையைச் சேமிக்க {{signUp}}. \",\n        \"Welcome to Grist, {{- name}}!\": \"கிரிச்டுக்கு வருக, {{- name}}!\",\n        \"Welcome to {{- orgName}}\": \"{{- orgName}} க்கு வருக\",\n        \"Sign in\": \"விடுபதிகை\",\n        \"To use Grist, please either sign up or sign in.\": \"கிரிச்டைப் பயன்படுத்த, தயவுசெய்து பதிவு செய்க அல்லது உள்நுழைக.\",\n        \"Visit our {{link}} to learn more about Grist.\": \"கிரிச்ட் பற்றி மேலும் அறிய எங்கள் {{link}} ஐப் பார்வையிடவும்.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"எங்கள் {{helpCenterLink}} இல் மேலும் அறிக.\",\n        \"Only show documents\": \"ஆவணங்களை மட்டுமே காட்டு\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"உதவி நடுவண்\",\n        \"Accessibility\": \"அணுகல்\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"முடிக்க\",\n        \"Next\": \"அடுத்தது\",\n        \"Previous\": \"முந்தைய\"\n    },\n    \"Pages\": {\n        \"Delete\": \"நீக்கு\",\n        \"Delete data and this page.\": \"தரவையும் இந்த பக்கத்தையும் நீக்கு.\",\n        \"The following tables will no longer be visible_one\": \"பின்வரும் அட்டவணை இனி தெரியாது\",\n        \"The following tables will no longer be visible_other\": \"பின்வரும் அட்டவணைகள் இனி தெரியாது\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"தரவை வைத்து பக்கத்தை நீக்கவும். அட்டவணை {{rawDataLink}} இல் கிடைக்கும்\",\n        \"raw data page\": \"மூல தரவு பக்கம்\",\n        \"Document pages\": \"ஆவணப் பக்கங்கள்\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"இறக்குமதி தோல்வியுற்றது: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"பதிவு தளவமைப்பைப் புதுப்பித்தல்.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"புலத்தைச் சேர்க்கவும்\",\n        \"Create new field\": \"புதிய புலத்தை உருவாக்கவும்\",\n        \"Show field {{- label}}\": \"புலத்தைக் காட்டு {{- label}}\",\n        \"Save layout\": \"தளவமைப்பை சேமிக்கவும்\",\n        \"Cancel\": \"ரத்துசெய்\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"நெடுவரிசையைச் சேர்க்கவும்\",\n        \"No columns to add\": \"சேர்க்க நெடுவரிசைகள் இல்லை\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"அணுகல் விவரங்கள்\",\n        \"Back to current\": \"மின்னோட்டத்திற்குத் திரும்பு\",\n        \"Compare to {{termToUse}}\": \"{{termToUse}} உடன் ஒப்பிடுக\",\n        \"Current Version\": \"தற்போதைய பதிப்பு\",\n        \"Download\": \"பதிவிறக்கம்\",\n        \"Duplicate document\": \"நகல் ஆவணம்\",\n        \"Edit without affecting the original\": \"அசலை பாதிக்காமல் திருத்து\",\n        \"Export CSV\": \"காபிம ஏற்றுமதி\",\n        \"Export XLSX\": \"எக்ச்எல்எச்எக்ச் ஏற்றுமதி\",\n        \"Original\": \"அசல்\",\n        \"Replace {{termToUse}}...\": \"{{termToUse}} ஐ மாற்றவும்…\",\n        \"Manage users\": \"பயனர்களை நிர்வகிக்கவும்\",\n        \"Return to {{termToUse}}\": \"{{termToUse}}க்கு திரும்பவும்\",\n        \"Save copy\": \"நகலை சேமிக்கவும்\",\n        \"Save Document\": \"ஆவணத்தை சேமிக்கவும்\",\n        \"Send to Google Drive\": \"Google இயக்ககத்திற்கு அனுப்பவும்\",\n        \"Show in folder\": \"கோப்புறையில் காட்டு\",\n        \"Unsaved\": \"சேமிக்கப்படாதது\",\n        \"Work on a copy\": \"ஒரு நகலில் வேலை செய்யுங்கள்\",\n        \"Share\": \"பங்கு\",\n        \"Download...\": \"பதிவிறக்க ...\",\n        \"Comma Separated Values (.csv)\": \"கமா பிரிக்கப்பட்ட மதிப்புகள் (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"DOO பிரிக்கப்பட்ட மதிப்புகள் (.DSV)\",\n        \"Export as...\": \"ஏற்றுமதி ...\",\n        \"Microsoft Excel (.xlsx)\": \"நுண்மென் எக்செல் (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"தாவல் பிரிக்கப்பட்ட மதிப்புகள் (.tsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"ஏற்றுமதி ஆவண பக்கங்களிலிருந்து மட்டுமே கிடைக்கிறது. தயவுசெய்து ஒரு ஆவணப் பக்கத்தைத் தேர்ந்தெடுத்து மீண்டும் முயற்சிக்கவும்.\",\n        \"Download attachments...\": \"இணைப்புகளைப் பதிவிறக்குக ...\",\n        \"Download document...\": \"ஆவணத்தைப் பதிவிறக்குக ...\",\n        \"Suggest changes\": \"மாற்றங்களை பரிந்துரைக்கவும்\",\n        \"current version\": \"தற்போதைய பதிப்பு\",\n        \"original\": \"அசல்\"\n    },\n    \"SortConfig\": {\n        \"Update data\": \"தரவைப் புதுப்பிக்கவும்\",\n        \"Use choice position\": \"தேர்வு நிலையைப் பயன்படுத்தவும்\",\n        \"Search Columns\": \"நெடுவரிசைகளைத் தேடுங்கள்\",\n        \"Add column\": \"நெடுவரிசையைச் சேர்க்கவும்\",\n        \"Empty values last\": \"வெற்று மதிப்புகள் நீடிக்கும்\",\n        \"Natural sort\": \"இயற்கை வரிசை\",\n        \"Remove sort setting - {{- columnName }} column\": \"வரிசை அமைப்பை அகற்று - {{- columnName }} நெடுவரிசை\",\n        \"Sort in ascending order (current: descending)\": \"ஏறுவரிசையில் வரிசைப்படுத்தவும் (தற்போதைய: இறங்கு)\",\n        \"Sort in descending order (current: ascending)\": \"இறங்கு வரிசையில் வரிசைப்படுத்து (தற்போதைய: ஏறுவரிசை)\",\n        \"Sort options - {{- columnName }} column\": \"வரிசை விருப்பங்கள் - {{- columnName }} நெடுவரிசை\",\n        \"{{- columnName }} column\": \"{{- columnName }} நெடுவரிசை\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"புதிய குழு தளத்தை உருவாக்கவும்\",\n        \"Switch Sites\": \"தளங்களை மாற்றவும்\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"தோற்றம் \",\n        \"Switch appearance automatically to match system\": \"கணினியுடன் பொருந்தக்கூடிய வகையில் தானாகவே தோற்றத்தை மாற்றவும்\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"அணியை நிர்வகிக்கவும்\",\n        \"Redo\": \"மீண்டும்செய்\",\n        \"Undo\": \"செயல்தவிர்\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"எந்த புலம்\",\n        \"Apply on changes to:\": \"இதற்கு மாற்றங்களில் விண்ணப்பிக்கவும்:\",\n        \"Apply on record changes\": \"பதிவு மாற்றங்களில் விண்ணப்பிக்கவும்\",\n        \"Apply to new records\": \"புதிய பதிவுகளுக்கு பொருந்தும்\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Close\": \"மூடு\",\n        \"Current field \": \"தற்போதைய புலம் \",\n        \"OK\": \"சரி\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"இடு\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Preview\": \"முன்னோட்டம்\",\n        \"Revise\": \"திருத்தவும்\",\n        \"Update formula (Shift+Enter)\": \"புதுப்பிப்பு தேற்றம் (சிப்ட்+என்டர்)\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"திருத்தி\",\n        \"In full\": \"முழுமையாக\",\n        \"No Default Access\": \"இயல்புநிலை அணுகல் இல்லை\",\n        \"None\": \"எதுவுமில்லை\",\n        \"Owner\": \"உரிமையாளர்\",\n        \"View & edit\": \"பார்வை & திருத்து\",\n        \"View only\": \"பார்க்க மட்டுமே\",\n        \"Viewer\": \"பார்வையாளர்\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"விதி {{length}}\",\n        \"Update formula (Shift+Enter)\": \"புதுப்பிப்பு தேற்றம் (சிப்ட்+என்டர்)\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"தெரியாத பயனர்\",\n        \"View as Yourself\": \"உங்களைப் போல பாருங்கள்\",\n        \"You are viewing this document as\": \"இந்த ஆவணத்தை நீங்கள் பார்க்கிறீர்கள்\",\n        \"You're seeing what this user would see if given access\": \"அணுகல் வழங்கப்பட்டால், இந்தப் பயனர் என்ன பார்ப்பார் என்பதை நீங்கள் பார்க்கிறீர்கள்\"\n    },\n    \"ViewSectionMenu\": {\n        \"(customized)\": \"(தனிப்பயனாக்கப்பட்டது)\",\n        \"(empty)\": \"(காலியாக)\",\n        \"(modified)\": \"(மாற்றியமைக்கப்பட்ட)\",\n        \"Custom options\": \"தனிப்பயன் விருப்பங்கள்\",\n        \"FILTER\": \"வடிப்பி\",\n        \"Revert\": \"மாற்றியமைக்கவும்\",\n        \"SORT\": \"வரிசைப்படுத்து\",\n        \"Save\": \"சேமி\",\n        \"Update Sort&Filter settings\": \"வரிசை மற்றும் வடிகட்டி அமைப்புகளைப் புதுப்பிக்கவும்\",\n        \"Sort and filter\": \"வரிசைப்படுத்தி வடிகட்டவும்\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Clear\": \"தெளிவான\",\n        \"Cannot drop items into Hidden Fields\": \"மறைக்கப்பட்ட புலங்களில் உருப்படிகளை கைவிட முடியாது\",\n        \"Hidden Fields cannot be reordered\": \"மறைக்கப்பட்ட புலங்களை மறுவரிசைப்படுத்த முடியாது\",\n        \"Select all\": \"அனைத்தையும் தெரிவுசெய்\",\n        \"Visible {{label}}\": \"தெரியும் {{label}}}\",\n        \"Hide {{label}}\": \"{{label}}}\",\n        \"Hidden {{label}}\": \"மறைக்கப்பட்ட {{label}}\",\n        \"Show {{label}}\": \"{{label}} ஐக் காட்டு\",\n        \"Hide {{label}} (batch mode)\": \"மறை {{label}} (தொகுப்பு முறை)\",\n        \"Show {{label}} (batch mode)\": \"{{label}} (தொகுப்பு முறை)\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"கல்வி\",\n        \"Finance & Accounting\": \"பொருள் மற்றும் கணக்கியல்\",\n        \"HR & Management\": \"மனிதவள மற்றும் மேலாண்மை\",\n        \"IT & Technology\": \"செய்தி தொழில்நுட்பம் மற்றும் தொழில்நுட்பம்\",\n        \"Marketing\": \"சந்தைப்படுத்தல்\",\n        \"Media Production\": \"ஊடக தயாரிப்பு\",\n        \"Other\": \"மற்றொன்று\",\n        \"Product Development\": \"தயாரிப்பு மேம்பாடு\",\n        \"Research\": \"ஆராய்ச்சி\",\n        \"Sales\": \"விற்பனை\",\n        \"Type here\": \"இங்கே தட்டச்சு செய்க\",\n        \"Welcome to Grist!\": \"கிரிச்டுக்கு வருக!\",\n        \"What brings you to Grist? Please help us serve you better.\": \"உங்களைத் தூண்டுவது எது? தயவுசெய்து உங்களுக்கு சிறப்பாக பணி செய்ய எங்களுக்கு உதவுங்கள்.\"\n    },\n    \"breadcrumbs\": {\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"நீங்கள் திருத்தங்களைச் செய்யலாம், ஆனால் அவை புதிய நகலை உருவாக்கும்\\n அசல் ஆவணத்தை பாதிக்காது.\",\n        \"fiddle\": \"பிடில்\",\n        \"override\": \"மேலெழுதவும்\",\n        \"recovery mode\": \"மீட்பு முறை\",\n        \"snapshot\": \"ச்னாப்சாட்\",\n        \"unsaved\": \"சேமிக்கப்படாதது\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"நீங்கள் திருத்தங்களைச் செய்யலாம், \\nஆனால் அவை அசல் ஆவணத்தை பாதிக்காது. \\nநீங்கள் அவற்றை பரிந்துரைகளாக முன்மொழியலாம்.\",\n        \"editing\": \"திருத்துதல்\",\n        \"suggesting\": \"பரிந்துரைக்கிறது\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"நகல் பக்கம் {{pageName}}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"இது தரவை நகலெடுக்காது என்பதை நினைவில் கொள்க, ஆனால் அதே தரவின் மற்றொரு பார்வையை உருவாக்குகிறது.\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"நகல் பக்கம்\",\n        \"Remove\": \"அகற்று\",\n        \"Rename\": \"மறுபெயரிடுங்கள்\",\n        \"You do not have edit access to this document\": \"இந்த ஆவணத்திற்கான திருத்த அணுகல் உங்களிடம் இல்லை\",\n        \"(default)\": \"(இயல்புநிலை)\",\n        \"Collapse {{maybeDefault}}\": \"{{maybeDefault}}ஐச் சுருக்கு\",\n        \"Expand {{maybeDefault}}\": \"{{maybeDefault}}ஐ விரிவாக்கு\",\n        \"Set default: Collapse\": \"இயல்புநிலையை அமைக்கவும்: சுருக்கவும்\",\n        \"Set default: Expand\": \"இயல்புநிலையை அமைக்கவும்: விரிவாக்கு\",\n        \"context menu - {{- pageName }}\": \"சூழல் பட்டியல் - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Find Next \": \"அடுத்து கண்டுபிடிக்கவும் \",\n        \"Find Previous \": \"முந்தையதைக் கண்டறியவும் \",\n        \"No results\": \"முடிவுகள் இல்லை\",\n        \"Search in document\": \"ஆவணத்தில் தேடுங்கள்\",\n        \"Search\": \"தேடல்\",\n        \"Close search bar\": \"தேடல் பட்டியை மூடு\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Google இயக்ககத்திற்கு கோப்பை அனுப்புகிறது\"\n    },\n    \"TypeTransform\": {\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Apply\": \"இடு\",\n        \"Preview\": \"முன்னோட்டம்\",\n        \"Revise\": \"திருத்தவும்\",\n        \"Update formula (Shift+Enter)\": \"புதுப்பிப்பு தேற்றம் (சிப்ட்+என்டர்)\"\n    },\n    \"NTextBox\": {\n        \"false\": \"தவறு\",\n        \"true\": \"உண்மை\",\n        \"Field Format\": \"புலம் வடிவம்\",\n        \"Lines\": \"வரிகள்\",\n        \"Multi line\": \"பல வரி\",\n        \"Single line\": \"ஒற்றை இருப்புப்பாதை\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"எடுத்துக்காட்டு பயனர்கள்\",\n        \"Users from table\": \"அட்டவணையில் இருந்து பயனர்கள்\",\n        \"View as\": \"காண்க\",\n        \"Other users from table\": \"அட்டவணையில் இருந்து பிற பயனர்கள்\",\n        \"Shared users\": \"பகிரப்பட்ட பயனர்கள்\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"செல் நடை\",\n        \"Cell style\": \"செல் நடை\",\n        \"Default cell style\": \"இயல்புநிலை செல் நடை\",\n        \"Mixed style\": \"கலப்பு நடை\",\n        \"Open row styles\": \"திறந்த வரிசை பாணிகள்\",\n        \"Default header style\": \"இயல்புநிலை தலைப்பு நடை\",\n        \"Header Style\": \"தலைப்பு நடை\",\n        \"HEADER STYLE\": \"தலைப்பு நடை\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"நெடுவரிசை விளக்கம்\",\n        \"COLUMN LABEL\": \"நெடுவரிசை சிட்டை\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"மற்றொரு விதியைச் சேர்க்கவும்\",\n        \"Add conditional style\": \"நிபந்தனை பாணியைச் சேர்க்கவும்\",\n        \"Error in style rule\": \"பாணி விதியில் பிழை\",\n        \"Row style\": \"வரிசை நடை\",\n        \"Rule must return True or False\": \"விதி உண்மை அல்லது தவறானது\",\n        \"Conditional Style\": \"நிபந்தனை நடை\",\n        \"IF...\": \"என்றால் ...\",\n        \"Row Style\": \"வரிசை உடை\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"தவறான நாணயம்\"\n    },\n    \"DiscussionEditor\": {\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Comment\": \"கருத்து\",\n        \"Edit\": \"தொகு\",\n        \"Marked as resolved\": \"தீர்க்கப்பட்டதாக குறிக்கப்பட்டுள்ளது\",\n        \"Only current page\": \"தற்போதைய பக்கம் மட்டுமே\",\n        \"Only my threads\": \"என் நூல்கள் மட்டுமே\",\n        \"Open\": \"திற\",\n        \"Remove\": \"அகற்று\",\n        \"Reply\": \"பதில்\",\n        \"Reply to a comment\": \"ஒரு கருத்துக்கு பதில்\",\n        \"Resolve\": \"தீர்க்க\",\n        \"Save\": \"சேமி\",\n        \"Show resolved comments\": \"தீர்க்கப்பட்ட கருத்துகளைக் காட்டுங்கள்\",\n        \"Showing last {{nb}} comments\": \"கடைசி {{nb}} கருத்துகளைக் காட்டுகிறது\",\n        \"Started discussion\": \"கலந்துரையாடல் தொடங்கியது\",\n        \"Write a comment\": \"ஒரு கருத்தை எழுதுங்கள்\",\n        \"Remove thread\": \"நூலை அகற்று\",\n        \"updated\": \"புதுப்பிக்கப்பட்டது\",\n        \"{{count}} comments_one\": \"{{count}} கருத்து\",\n        \"{{count}} comments_other\": \"{{count}} கருத்துகள்\",\n        \"Copy link\": \"இணைப்பை நகலெடுக்கவும்\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"நெடுவரிசையை சூத்திரமாக மாற்றவும்\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"எளிய தரவு மதிப்பை ஒரு சூத்திர நெடுவரிசையில் சேமிப்பது சாத்தியமில்லை\",\n        \"Unable to finish saving edited cell\": \"திருத்தப்பட்ட கலத்தை சேமிப்பதை முடிக்க முடியவில்லை\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[இணைப்பு லேபிள்] முகவரி\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"நாணயம்\",\n        \"Decimals\": \"தசமங்கள்\",\n        \"Default currency ({{defaultCurrency}})\": \"இயல்புநிலை நாணயம் ({{defaultCurrency}})\",\n        \"Number Format\": \"எண் வடிவம்\",\n        \"Field Format\": \"புலம் வடிவம்\",\n        \"Spinner\": \"ச்பின்னர்\",\n        \"Text\": \"உரை\",\n        \"max\": \"அதிகபட்சம்\",\n        \"min\": \"மணித்துளி\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"செல் வடிவம்\",\n        \"Row ID\": \"வரிசை ஐடி\",\n        \"SHOW COLUMN\": \"நெடுவரிசையைக் காட்டு\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"மொழி\"\n    },\n    \"ColumnTitle\": {\n        \"Column description\": \"நெடுவரிசை விளக்கம்\",\n        \"Add description\": \"விளக்கத்தைச் சேர்க்கவும்\",\n        \"COLUMN ID: \": \"நெடுவரிசை ஐடி: \",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Column ID copied to clipboard\": \"நெடுவரிசை ஐடி இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது\",\n        \"Column label\": \"நெடுவரிசை சிட்டை\",\n        \"Provide a column label\": \"ஒரு நெடுவரிசை லேபிளை வழங்கவும்\",\n        \"Save\": \"சேமி\",\n        \"Close\": \"மூடு\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"கிடைத்தது\",\n        \"Unavailable Command\": \"கிடைக்காத கட்டளை\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"{{action}} பட்டியல் கட்டளை இந்த உலாவியில் கிடைக்கவில்லை. விசைப்பலகை குறுக்குவழி {{action}} ஐப் பயன்படுத்துவதன் மூலம் நீங்கள் இன்னும் {{shortcut}}.\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"செருக சொடுக்கு செய்க\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"விவரம்\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"எல்லா பக்கங்களையும் தேடுங்கள்\",\n        \"Search all tables\": \"எல்லா அட்டவணைகளையும் தேடுங்கள்\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"தேடல்\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"{{displayedCount}} உருப்படிகளில் {{totalCount}} ஐக் காட்டுகிறது. மேலும் தேடவும்.\"\n    },\n    \"SupportGristNudge\": {\n        \"Close\": \"மூடு\",\n        \"Contribute\": \"பங்களிப்பு\",\n        \"Help Center\": \"உதவி நடுவண்\",\n        \"Opt in to Telemetry\": \"டெலிமெட்ரி தேர்வு செய்யவும்\",\n        \"Opted In\": \"தேர்வு செய்யப்பட்டது\",\n        \"Support Grist\": \"உதவி கிரிச்ட்\",\n        \"Support Grist page\": \"கிரிச்ட் பக்கத்தை ஆதரிக்கவும்\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"நன்றி! உங்கள் நம்பிக்கையும் ஆதரவும் பெரிதும் பாராட்டப்படுகிறது. பயனர் பட்டியலில் {{link}} இலிருந்து எந்த நேரத்தையும் தேர்வு செய்யவும்.\",\n        \"Admin Panel\": \"நிர்வாக குழு\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"தரவு இல்லை\",\n        \"No row selected in {{title}}\": \"{{title}} இல் தேர்ந்தெடுக்கப்பட்ட வரிசை இல்லை\",\n        \"Not all data is shown\": \"எல்லா தரவுகளும் காட்டப்படவில்லை\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"சரிவு ஆசிரியர்\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"அதிகரிக்க\",\n        \"Minimize\": \"குறைக்கவும்\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"புல விதிகள்\",\n        \"Required field\": \"தேவையான புலம்\",\n        \"Ascending\": \"ஏறுதல்\",\n        \"Default\": \"இயல்புநிலை\",\n        \"Descending\": \"இறங்கு\",\n        \"Field Format\": \"புலம் வடிவம்\",\n        \"Field Rules\": \"புல விதிகள்\",\n        \"Horizontal\": \"கிடைமட்டமாக\",\n        \"Options Alignment\": \"விருப்பங்கள் சீரமைப்பு\",\n        \"Options Sort Order\": \"விருப்பங்கள் வரிசைப்படுத்தல்\",\n        \"Radio\": \"வானொலி\",\n        \"Select\": \"தேர்ந்தெடு\",\n        \"Vertical\": \"செங்குத்து\",\n        \"Accept value from URL\": \"முகவரி இலிருந்து மதிப்பை ஏற்கவும்\",\n        \"Hidden field\": \"மறைக்கப்பட்ட புலம்\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"முகவரி அளவுரு: \\n{{colId}}=VALUE\",\n        \"Options limit\": \"விருப்பங்கள் வரம்பு\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"தேவையான சில நெடுவரிசைகள் வரைபடமாக்கப்படவில்லை\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"இந்த விட்செட்டைப் பயன்படுத்த, தயவுசெய்து படைப்பாளி பேனலில் இருந்து விருப்பமற்ற அனைத்து நெடுவரிசைகளையும் வலதுபுறத்தில் வரைபடமாக்கவும்.\",\n        \"Some required columns are hidden by access rules\": \"தேவையான சில நெடுவரிசைகள் அணுகல் விதிகளால் மறைக்கப்படுகின்றன\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"இந்த விட்செட்டைப் பயன்படுத்த, அனைத்து வரைபட நெடுவரிசைகளும் தெரியும். ஆவண உரிமையாளரைத் தொடர்பு கொள்ளவும் அல்லது அணுகல் விதிகளை மாற்றவும்.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"உங்கள் சொந்த வடிவத்தை உருவாக்குங்கள்\",\n        \"Powered by\": \"மூலம் இயக்கப்படுகிறது\",\n        \"Powered by Grist\": \"கிரிச்ட் மூலம் இயக்கப்படுகிறது\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"அச்சச்சோ! நீங்கள் தேடும் வடிவம் இல்லை.\",\n        \"Oops! This form is no longer published.\": \"அச்சச்சோ! இந்த படிவம் இனி வெளியிடப்படவில்லை.\",\n        \"There was a problem loading the form.\": \"படிவத்தை ஏற்றுவதில் சிக்கல் இருந்தது.\",\n        \"You don't have access to this form.\": \"இந்த படிவத்திற்கான அணுகல் உங்களுக்கு இல்லை.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"உங்கள் படிவத்தை சமர்ப்பிப்பதில் பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும்.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"படிவம் சமர்ப்பிக்கப்பட்டது\",\n        \"Thank you! Your response has been recorded.\": \"நன்றி! உங்கள் பதில் பதிவு செய்யப்பட்டுள்ளது.\",\n        \"Submit new response\": \"புதிய பதிலை சமர்ப்பிக்கவும்\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"கடந்த 30 நாட்கள்\",\n        \"Last 7 days\": \"கடந்த 7 நாட்கள்\",\n        \"Last week\": \"கடந்த வாரம்\",\n        \"Next 7 days\": \"அடுத்த 7 நாட்கள்\",\n        \"This month\": \"இந்த மாதம்\",\n        \"This week\": \"இந்த வாரம்\",\n        \"This year\": \"இந்த ஆண்டு\",\n        \"Today\": \"இன்று\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"தெளிவான\",\n        \"Map fields\": \"வரைபட புலங்கள்\",\n        \"Mapped\": \"வரைபடமாக்கப்பட்டது\",\n        \"Select all\": \"அனைத்தையும் தெரிவுசெய்\",\n        \"Unmap fields\": \"உமிழ்வான புலங்கள்\",\n        \"Unmapped\": \"கவனிக்கப்படாதது\",\n        \"Hide {{label}}\": \"{{label}} ஐ மறை\",\n        \"Hide {{label}} (batch mode)\": \"மறை {{label}} (தொகுப்பு முறை)\",\n        \"Unmap {{label}}\": \"{{label}} வரைபடத்தை நீக்கவும்\",\n        \"Unmap {{label}} (batch mode)\": \"{{label}} (தொகுப்பு முறை)\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"மேலே பகுதியைச் செருகவும்\",\n        \"Insert section below\": \"கீழே பகுதியைச் செருகவும்\",\n        \"## **Header**\": \"## ** தலைப்பு **\",\n        \"Description\": \"விவரம்\"\n    },\n    \"widgetTypesMap\": {\n        \"Form\": \"வடிவம்\",\n        \"Calendar\": \"நாட்காட்டி\",\n        \"Card\": \"அட்டை\",\n        \"Card List\": \"அட்டை பட்டியல்\",\n        \"Chart\": \"விளக்கப்படம்\",\n        \"Custom\": \"தனிப்பயன்\",\n        \"Table\": \"அட்டவணை\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"நெடுவரிசையை அகற்று\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"தேர்வுகள் கட்டமைக்கப்படவில்லை\",\n        \"No values in show column of referenced table\": \"குறிப்பிடப்பட்ட அட்டவணையின் நெடுவரிசையில் மதிப்புகள் இல்லை\",\n        \"Hide\": \"மறை\"\n    },\n    \"ChoiceEditor\": {\n        \"Error in dropdown condition\": \"கீழ்தோன்றும் நிலையில் பிழை\",\n        \"No choices matching condition\": \"பொருந்தும் நிலையில் தேர்வுகள் இல்லை\",\n        \"No choices to select\": \"தேர்ந்தெடுக்க தேர்வுகள் இல்லை\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"கீழ்தோன்றும் நிலையில் பிழை\",\n        \"No choices matching condition\": \"பொருந்தும் நிலையில் தேர்வுகள் இல்லை\",\n        \"No choices to select\": \"தேர்ந்தெடுக்க தேர்வுகள் இல்லை\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"கீழ்தோன்றும் நிலை\",\n        \"Invalid columns: {{colIds}}\": \"தவறான நெடுவரிசைகள்: {{colIds}}\",\n        \"Set dropdown condition\": \"கீழ்தோன்றும் நிலையை அமைக்கவும்\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"நிபந்தனையை உள்ளிடவும்.\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"கீழ்தோன்றும் நிலையில் பிழை\",\n        \"No choices matching condition\": \"பொருந்தும் நிலையில் தேர்வுகள் இல்லை\",\n        \"No choices to select\": \"தேர்ந்தெடுக்க தேர்வுகள் இல்லை\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"மீட்டமை\",\n        \"Search\": \"தேடல்\",\n        \"Select...\": \"தேர்ந்தெடுக்கவும் ...\",\n        \"Submit\": \"சமர்ப்பிக்கவும்\",\n        \"Submitting…\": \"சமர்ப்பிக்கிறது…\",\n        \"Clear selection for: {{-inputLabel}}\": \"இதற்கான தேர்வை அழிக்கவும்: {{-inputLabel}}\"\n    },\n    \"TimingPage\": {\n        \"Average Time (s)\": \"சராசரி நேரம் (கள்)\",\n        \"Column ID\": \"நெடுவரிசை ஐடி\",\n        \"Formula timer\": \"சூத்திர நேரம்\",\n        \"Loading timing data. Don't close this tab.\": \"நேர தரவை ஏற்றுகிறது. இந்த தாவலை மூட வேண்டாம்.\",\n        \"Max Time (s)\": \"அதிகபட்ச நேரம் (கள்)\",\n        \"Number of Calls\": \"அழைப்புகளின் எண்ணிக்கை\",\n        \"Table ID\": \"அட்டவணை ஐடி\",\n        \"Total Time (s)\": \"மொத்த நேரம் (கள்)\"\n    },\n    \"OnboardingCards\": {\n        \"3 minute video tour\": \"3 நிமிட வீடியோ சுற்றுப்பயணம்\",\n        \"Complete our basics tutorial\": \"எங்கள் அடிப்படைகள் டுடோரியலை முடிக்கவும்\",\n        \"Complete the tutorial\": \"டுடோரியலை முடிக்கவும்\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"குறிப்பு நெடுவரிசைகள், இணைக்கப்பட்ட விட்செட்டுகள், நெடுவரிசை வகைகள் மற்றும் அட்டைகளின் அடிப்படையைக் கற்றுக்கொள்ளுங்கள்.\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"குறிப்பு நெடுவரிசைகள், இணைக்கப்பட்ட விட்செட்டுகள், நெடுவரிசை வகைகள் மற்றும் அட்டைகளின் அடிப்படைகளைக் கற்றுக்கொள்ளுங்கள்.\"\n    },\n    \"DocList\": {\n        \"Access details\": \"அணுகல் விவரங்கள்\",\n        \"All\": \"அனைத்தும்\",\n        \"Current workspace\": \"தற்போதைய பணியிடம்\",\n        \"Delete\": \"நீக்கு\",\n        \"Delete {{name}}\": \"{{name}} ஐ நீக்கு\",\n        \"Document will be moved to Trash.\": \"ஆவணம் குப்பைக்கு நகர்த்தப்படும்.\",\n        \"Edited {{at}}\": \"{{at}} திருத்தப்பட்டது\",\n        \"Last edited\": \"கடைசியாக திருத்தப்பட்டது\",\n        \"Manage users\": \"பயனர்களை நிர்வகிக்கவும்\",\n        \"Move\": \"நகர்த்தவும்\",\n        \"Move {{name}} to workspace\": \"{{name}} பணியிடத்திற்கு நகர்த்தவும்\",\n        \"Name\": \"பெயர்\",\n        \"No documents to show.\": \"காட்ட ஆவணங்கள் இல்லை.\",\n        \"Pin\": \"முள்\",\n        \"Pinned\": \"பின்\",\n        \"Recent\": \"அண்மைக் கால\",\n        \"Rename and set icon\": \"மறுபெயரிட்டு ஐகானை அமைக்கவும்\",\n        \"Requires edit permissions\": \"திருத்து அனுமதிகள் தேவை\",\n        \"Sort by date\": \"தேதியால் வரிசைப்படுத்துங்கள்\",\n        \"Sort by name\": \"பெயரால் வரிசைப்படுத்துங்கள்\",\n        \"Unpin\": \"மூள்நீக்கு\",\n        \"Workspace\": \"பணியிடம்\",\n        \"context menu - {{- documentName }}\": \"சூழல் பட்டியல் - {{- documentName }}\",\n        \"Documents list\": \"ஆவணங்களின் பட்டியல்\",\n        \"Download document...\": \"ஆவணத்தைப் பதிவிறக்கு...\",\n        \"Deleted {{at}}\": \"{{at}} நீக்கப்பட்டது\"\n    },\n    \"RenameDocModal\": {\n        \"Choose color\": \"வண்ணத்தைத் தேர்வுசெய்க\",\n        \"Choose icon\": \"ஐகானைத் தேர்வுசெய்க\",\n        \"Enter document name\": \"ஆவண பெயரை உள்ளிடவும்\",\n        \"Icon\": \"படவுரு\",\n        \"Name\": \"பெயர்\",\n        \"Rename and set icon\": \"மறுபெயரிட்டு ஐகானை அமைக்கவும்\",\n        \"Reset icon\": \"ஐகானை மீட்டமைக்கவும்\"\n    },\n    \"RightPanelUtils\": {\n        \"columns_one\": \"நெடுவரிசைகள்\",\n        \"columns_other\": \"நெடுவரிசைகள்\",\n        \"fields_one\": \"புலங்கள்\",\n        \"fields_other\": \"புலங்கள்\",\n        \"series_one\": \"தொடர்\",\n        \"series_other\": \"தொடர்\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"அறியப்படாத தனிப்பயன் விட்செட்களுடன் கவனமாக இருங்கள்\",\n        \"Please review the following before adding a new custom widget.\": \"புதிய தனிப்பயன் விட்செட்டைச் சேர்ப்பதற்கு முன் பின்வருவனவற்றை மதிப்பாய்வு செய்யவும்.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"தனிப்பயன் விட்செட்டுகள் ** சக்திவாய்ந்தவை **! அவர்கள் உங்கள் ஆவணத் தரவை படித்து எழுதலாம், அதை வேறு இடத்திற்கு அனுப்பலாம்.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"இந்த முகவரி இல் வளத்தை ** நம்புகிறீர்களா?\",\n        \"Do you **trust the person** who shared this link?\": \"நீங்கள் ** நபரை நம்புகிறீர்களா ** இந்த இணைப்பை யார் பகிர்ந்து கொண்டார்கள்?\",\n        \"Have you **reviewed the code** at this URL?\": \"இந்த முகவரி இல் நீங்கள் ** குறியீட்டை ** மதிப்பாய்வு செய்தீர்களா?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"ஐயம் இருந்தால், இந்த விட்செட்டை நிறுவ வேண்டாம், அல்லது உங்கள் நிறுவனத்தின் நிர்வாகியிடம் பாதுகாப்பிற்காக அதை மதிப்பாய்வு செய்யச் சொல்லுங்கள்.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"இந்த எச்சரிக்கைகளை நான் புரிந்துகொண்டு அபாயங்களை ஏற்றுக்கொள்கிறேன் என்பதை உறுதிப்படுத்துகிறேன்\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin area\": \"நிர்வாக பகுதி\",\n        \"Admin controls\": \"நிர்வாக கட்டுப்பாடுகள்\",\n        \"Docs\": \"கோப்புகள்\",\n        \"Installation\": \"நிறுவல்\",\n        \"Learn more\": \"மேலும் அறிக\",\n        \"Orgs\": \"நிறுவனங்கள்\",\n        \"Users\": \"பயனர்கள்\",\n        \"Workspaces\": \"பணியிடங்கள்\",\n        \"Admin Controls\": \"நிர்வாக கட்டுப்பாடுகள்\",\n        \"Settings\": \"அமைப்புகள்\"\n    },\n    \"Assistant\": {\n        \"AI Assistant is only available for logged in users.\": \"AI உதவியாளர் பயனர்களில் உள்நுழைந்தவருக்கு மட்டுமே கிடைக்கிறது.\",\n        \"Apply\": \"இடு\",\n        \"For higher limits, contact the site owner.\": \"அதிக வரம்புகளுக்கு, தள உரிமையாளரைத் தொடர்பு கொள்ளுங்கள்.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"அதிக வரம்புகளுக்கு, {{upgradeNudge}}.\",\n        \"Learn more.\": \"மேலும் அறிக.\",\n        \"Press Enter to apply suggested formula.\": \"பரிந்துரைக்கப்பட்ட சூத்திரத்தை விண்ணப்பிக்க Enter ஐ அழுத்தவும்.\",\n        \"Sign Up for Free\": \"இலவசமாக பதிவு செய்க\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"AI உதவியாளரைப் பயன்படுத்தத் தொடங்க இலவச கிரிச்ட் கணக்கிற்கு பதிவுபெறுக.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"புதிய கிரிச்ட் உதவியாளரை முயற்சிக்க எண்டர்பிரைசுக்கு மேம்படுத்தவும். {{learnMoreLink}}}\",\n        \"What do you need help with?\": \"உங்களுக்கு என்ன உதவி தேவை?\",\n        \"You have used all available credits.\": \"கிடைக்கக்கூடிய அனைத்து வரவுகளையும் நீங்கள் பயன்படுத்தியுள்ளீர்கள்.\",\n        \"You have {{numCredits}} remaining credits.\": \"உங்களிடம் {{numCredits}} மீதமுள்ள வரவுகளை வைத்திருக்கிறீர்கள்.\",\n        \"start a new chat\": \"புதிய அரட்டையைத் தொடங்கவும்\",\n        \"upgrade to the Pro Team plan\": \"சார்பு குழு திட்டத்திற்கு மேம்படுத்தவும்\",\n        \"upgrade your plan\": \"உங்கள் திட்டத்தை மேம்படுத்தவும்\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"உரையாடல் மிக நீளமாகிவிட்டது, இனி என்னால் திறம்பட பதிலளிக்க முடியாது. தொடர்ந்து உதவியைப் பெறுவதற்கு {{startANewChatButton}}.\"\n    },\n    \"apiconsole\": {\n        \"Are you sure you want to delete the following?\": \"பின்வருவனவற்றை நீக்க விரும்புகிறீர்களா?\",\n        \"Confirm Deletion\": \"நீக்குதலை உறுதிப்படுத்தவும்\",\n        \"Delete\": \"நீக்கு\",\n        \"Deletion was not confirmed, skipping.\": \"நீக்குதல் உறுதிப்படுத்தப்படவில்லை, தவிர்க்கப்பட்டது.\",\n        \"Type DELETE here if you wish to proceed.\": \"நீங்கள் தொடர விரும்பினால் இங்கே நீக்கு.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"இந்த நீக்குதலை நீங்கள் செய்ய விரும்பினால் உறுதியாக இருந்தால் நீக்கு. \\nஉங்களுக்கு உறுதியாக தெரியவில்லை என்றால், அல்லது இந்த செயல்பாடு என்ன செய்யும் என்று புரியவில்லை என்றால், \\nஅதை ரத்து செய்வது புத்திசாலித்தனமாக இருக்கும்.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"அணுகல் இல்லை\",\n        \"...loading\": \"...ஏற்றுகிறது\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"ஒரு முக்கியமான கிரிச்ட் புதுப்பிப்பு உள்ளது. \\nபதிப்பு {{version}} க்கு மேம்படுத்துவதைக் கவனியுங்கள்.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"உங்கள் கிரிச்ட் பதிப்பு காலாவதியானது. \\nபதிப்பு {{version}} க்கு மேம்படுத்துவதைக் கவனியுங்கள்.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"பரிந்துரை: {{storageRecommendation}}} \\nபெரிய இணைப்புகளை அல்லது அவற்றில் பலவற்றை சேமிக்கும்போது, நாங்கள் பரிந்துரைக்கிறோம் \\nஅவற்றை வெளிப்புற சேமிப்பகத்தில் வைத்திருத்தல். இந்த ஆவணம் தற்போது உள்ளது \\nஇணைப்புகளுக்கு உள் சேமிப்பிடத்தைப் பயன்படுத்துதல், அது வைத்திருக்கிறது \\nதன்னிறைவான ஆனால் செயல்திறனைக் கட்டுப்படுத்தலாம்.\",\n        \"Set the document to use external storage.\": \"வெளிப்புற சேமிப்பிடத்தைப் பயன்படுத்த ஆவணத்தை அமைக்கவும்.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"முந்தைய செயல்பாடு முடிவடையும் வரை காத்திருங்கள்.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"கிரிச்ட் பின்தளத்தில் மறுதொடக்கம் செய்ய காத்திருக்கும் நேரம் முடிந்தது\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"அம்சத்தை முடக்கு\",\n        \"Don't worry, you can disable it later if needed.\": \"கவலைப்பட வேண்டாம், தேவைப்பட்டால் அதை முடக்கலாம்.\",\n        \"Enable feature\": \"அம்சத்தை இயக்கவும்\",\n        \"Experimental feature\": \"சோதனை நற்பொருத்தம்\",\n        \"New record button\": \"புதிய பதிவு பொத்தான்\",\n        \"Reload the page\": \"பக்கத்தை மீண்டும் ஏற்றவும்\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"இந்த அம்சத்தைப் பயன்படுத்துவதை நிறுத்த எந்த நேரத்திலும் இந்த முகவரி ஐப் பார்வையிடவும்: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"இந்த சோதனை அம்சத்தை நீங்கள் முடக்க உள்ளீர்கள்: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"இந்த சோதனை அம்சத்தை நீங்கள் இயக்க உள்ளீர்கள்: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} முடக்கப்பட்டது.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} இயக்கப்பட்டது.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"புதிய அட்டை\",\n        \"New record\": \"புதிய பதிவு\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"கிரியேட்டர் பேனலை அணுக முயற்சிக்கிறீர்களா? {{key}} ஐப் பயன்படுத்தவும்.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"நகல் விட்செட்\",\n        \"Duplicate widgets\": \"நகல் விட்செட்டுகள்\",\n        \"Active\": \"செயலில்\",\n        \"Create new page\": \"புதிய பக்கத்தை உருவாக்கவும்\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"பதிவேற்றுகிறது, காத்திருக்கவும்…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"கூட்டு\",\n        \"Delete\": \"நீக்கு\",\n        \"Download\": \"பதிவிறக்கம்\",\n        \"Drop files here to attach\": \"இணைக்க கோப்புகளை இங்கே விடவும்\",\n        \"Drop files here to attach.\": \"இணைக்க கோப்புகளை இங்கே விடவும்.\",\n        \"No attachments\": \"இணைப்புகள் இல்லை\",\n        \"Preview not available.\": \"முன்னோட்டம் கிடைக்கவில்லை.\",\n        \"{{index}} of {{total}}\": \"{{total}} இல் {{index}}\",\n        \"Uploading…\": \"பதிவேற்றுகிறது…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"அனைத்து வரிசைகளையும் இந்த உயரத்திற்கு விரிவாக்குங்கள்\",\n        \"Max height\": \"அதிகபட்ச உயரம்\",\n        \"Max row height\": \"அதிகபட்ச வரிசை உயரம்\",\n        \"Change\": \"மாற்றம்\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"சுருக்கு\",\n        \"Expand\": \"விரிவாக்கு\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"செயலில் உள்ள பயனர்\",\n        \"active user list\": \"செயலில் உள்ள பயனர் பட்டியல்\",\n        \"open full active user list\": \"முழு செயலில் உள்ள பயனர் பட்டியலைத் திறக்கவும்\"\n    },\n    \"AdminChecks\": {\n        \"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_SANDBOX_FLAVOR ஐ உங்கள் வன்பொருள் ஆதரிக்கும் பட்சத்தில் (பெரும்பாலான விருப்பம்) gvisor க்கு அமைக்க பரிந்துரைக்கிறோம், மற்ற ஆவணங்களிலிருந்து தனிமைப்படுத்தப்பட்ட மற்றும் பிணையத்திலிருந்து தனிமைப்படுத்தப்பட்ட சாண்ட்பாக்சில் உள்ள சூத்திரங்களை இயக்குவதற்கு.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"க்ரிச்ட் ஒரு சிறிய உள்ளமைக்கப்பட்ட சுகாதார சோதனையை அடிக்கடி கொள்கலனாக இயக்கும்போது பயன்படுத்தப்படுகிறது.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"கிரிச்டுக்கு வரும் கோரிக்கைகள் துல்லியமான புரவலன் எடரைக் கொண்டிருக்க வேண்டும். GRIST_SERVE_SAME_ORIGIN அமைக்கப்படும்போது இது தேவை.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"இந்த துவக்கப் பக்கத்தை அணுகுவது மிகவும் எளிதாக இருக்கக்கூடாது. உள்ளமைவு சரியாக இருக்கும் போது அதை அணைக்கவும் (GRIST_BOOT_KEY ஐ அமைப்பதன் மூலம்) அல்லது GRIST_BOOT_KEY ஐ நீளமாகவும், குறியாக்கவியல் ரீதியாகவும் பாதுகாப்பானதாக மாற்றவும்.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"வெப்சாக்கெட் இணைப்புகளுக்கு HTTP 1.1 மற்றும் வேலை செய்வதற்கு சில கூடுதல் தலைப்புகளை அனுப்பும் திறன் தேவை. சில நேரங்களில் ஒரு தலைகீழ் பதிலாள் இந்த தேவைகளில் தலையிடலாம்.\",\n        \"It is good practice not to run Grist as the root user.\": \"கிரிச்ட்டை ரூட் பயனராக இயக்காமல் இருப்பது நல்ல நடைமுறை.\",\n        \"The main page of Grist should be available.\": \"Grist இன் முதன்மையான பக்கம் இருக்க வேண்டும்.\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"+{{count}} மேலும்\",\n        \"+{{count}} more_other\": \"+{{count}} மேலும்\",\n        \"Edit\": \"தொகு\",\n        \"No choices configured\": \"தேர்வுகள் எதுவும் கட்டமைக்கப்படவில்லை\",\n        \"Reset\": \"மீட்டமை\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Save\": \"சேமி\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"இடு\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Preview\": \"முன்னோட்டம்\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"மூடு\",\n        \"Update preview\": \"முன்னோட்டத்தைப் புதுப்பிக்கவும்\",\n        \"Convert quoted fields\": \"மேற்கோள் காட்டப்பட்ட புலங்களை மாற்றவும்\",\n        \"Escape character\": \"எச்கேப் கேரக்டர்\",\n        \"Field separator\": \"புலம் பிரிப்பான்\",\n        \"First row contains headers\": \"முதல் வரிசையில் தலைப்புகள் உள்ளன\",\n        \"Line terminator\": \"லைன் டெர்மினேட்டர்\",\n        \"Number of rows\": \"வரிசைகளின் எண்ணிக்கை\",\n        \"Quote character\": \"மேற்கோள் பாத்திரம்\",\n        \"Quotes in fields are doubled\": \"புலங்களில் மேற்கோள்கள் இரட்டிப்பாகும்\",\n        \"Skip leading whitespace\": \"முன்னணி இடைவெளியைத் தவிர்க்கவும்\",\n        \"Start with row\": \"வரிசையுடன் தொடங்குங்கள்\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"எழுத்து குறியாக்கம். [ஆதரிக்கப்படும் கோடெக்குகள்]({{link}}) பார்க்கவும்\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" அல்லது \",\n        \"\\\"Regions\\\" are what we call the different parts of the user interface:\": \"\\\"பிராந்தியங்கள்\\\" என்பது பயனர் இடைமுகத்தின் வெவ்வேறு பகுதிகளை அழைக்கிறோம்:\",\n        \"Accessibility\": \"அணுகல்\",\n        \"Close\": \"மூடு\",\n        \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\": \"இறுதியாக, வலது பேனல் - அல்லது கிரியேட்டர் பேனல் - அதன் சொந்த குறுக்குவழியில் மட்டுமே கிடைக்கும் மற்றும் அடுத்த மற்றும் முந்தைய மண்டல சுழற்சியில் சேர்க்கப்படவில்லை.\",\n        \"Focus on other parts of the user interface using the following shortcuts:\": \"பின்வரும் குறுக்குவழிகளைப் பயன்படுத்தி பயனர் இடைமுகத்தின் பிற பகுதிகளில் கவனம் செலுத்துங்கள்:\",\n        \"High contrast theme\": \"உயர் மாறுபாடு கருப்பொருள்\",\n        \"Keyboard navigation\": \"விசைப்பலகை வழிசெலுத்தல்\",\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"ஆவணப் பக்கத்தில், விசைப்பலகை வழிசெலுத்தல் முதலில் தற்போதைய விட்செட்டில் பூட்டப்பட்டுள்ளது.\",\n        \"On document pages, each [widget]({{supportPageUrl}}) is a region that can receive focus.\": \"ஆவணப் பக்கங்களில், ஒவ்வொரு [விட்செட்]({{supportPageUrl}}) கவனம் பெறக்கூடிய பகுதி.\",\n        \"Other important keyboard shortcuts\": \"மற்ற முக்கியமான விசைப்பலகை குறுக்குவழிகள்\",\n        \"The left panel, home of the main navigation.\": \"இடது பேனல், முதன்மையான வழிசெலுத்தலின் முகப்பு.\",\n        \"The top panel, or the document header.\": \"மேல் குழு அல்லது ஆவணத்தின் தலைப்பு.\",\n        \"To see other available themes, go to your {{profileSettingsLink}}.\": \"கிடைக்கக்கூடிய பிற தீம்களைப் பார்க்க, உங்கள் {{profileSettingsLink}}க்குச் செல்லவும்.\",\n        \"Use the high contrast theme (light appearance)\": \"உயர் கான்ட்ராச்ட் கருப்பொருள் (ஒளி தோற்றம்) பயன்படுத்தவும்\",\n        \"You are currently **not using** the high contrast theme.\": \"நீங்கள் தற்போது ** உயர் மாறுபாடு கருப்பொருள் பயன்படுத்தவில்லை.\",\n        \"You are currently using the high contrast theme.\": \"நீங்கள் தற்போது உயர் மாறுபாடு கருப்பொருள் பயன்படுத்துகிறீர்கள்.\",\n        \"profile settings\": \"சுயவிவர அமைப்புகள்\",\n        \"{{accessibilityModal}} Show the accessibility options (this modal)\": \"{{accessibilityModal}} அணுகல்தன்மை விருப்பங்களைக் காட்டு (இந்த மாதிரி)\",\n        \"{{creatorPanelShortcut}} Focus to and from the creator panel\": \"{{creatorPanelShortcut}} கிரியேட்டர் பேனலில் கவனம் செலுத்தவும்\",\n        \"{{nextRegionShortcut}} Focus on the next region\": \"{{nextRegionShortcut}} அடுத்த பகுதியில் கவனம் செலுத்தவும்\",\n        \"{{prevRegionShortcut}} Focus on the previous region\": \"{{prevRegionShortcut}} முந்தைய பகுதியில் கவனம் செலுத்தவும்\",\n        \"{{shortcutsModal}} Show the complete list of keyboard shortcuts\": \"{{shortcutsModal}} விசைப்பலகை குறுக்குவழிகளின் முழுமையான பட்டியலைக் காட்டு\",\n        \"On non-document pages, the main content area is a region.\": \"ஆவணம் அல்லாத பக்கங்களில், முக்கிய உள்ளடக்க பகுதி ஒரு பகுதி.\"\n    },\n    \"ProposedChangesPage\": {\n        \"Proposed changes\": \"முன்மொழியப்பட்ட மாற்றங்கள்\",\n        \"Replace original\": \"அசல் மாற்றவும்\",\n        \"This is a list of changes relative to the original document.\": \"இது அசல் ஆவணத்துடன் தொடர்புடைய மாற்றங்களின் பட்டியல்.\",\n        \"Accept\": \"ஏற்றுக்கொள்\",\n        \"Accepted {{at}}.\": \"{{at}} ஏற்றுக்கொள்ளப்பட்டது.\",\n        \"Dismiss\": \"நிராகரி\",\n        \"Dismissed {{at}}.\": \"{{at}} நிராகரிக்கப்பட்டது.\",\n        \"Learn more\": \"மேலும் அறிக\",\n        \"No changes found to suggest. Please make some edits.\": \"பரிந்துரைக்கும் மாற்றங்கள் எதுவும் இல்லை. தயவுசெய்து சில திருத்தங்களைச் செய்யுங்கள்.\",\n        \"Retract suggestion\": \"பரிந்துரையை திரும்பப் பெறவும்\",\n        \"Retracted {{at}}.\": \"{{at}} திரும்பப் பெறப்பட்டது.\",\n        \"Suggest change\": \"மாற்றத்தை பரிந்துரைக்கவும்\",\n        \"Suggest changes\": \"மாற்றங்களை பரிந்துரைக்கவும்\",\n        \"Suggestion made {{at}}.\": \"{{at}} பரிந்துரை செய்யப்பட்டது.\",\n        \"Suggestions\": \"பரிந்துரைகள்\",\n        \"The original document isn't asking for proposed changes.\": \"அசல் ஆவணம் முன்மொழியப்பட்ட மாற்றங்களைக் கேட்கவில்லை.\",\n        \"There are fresh changes that haven't been added to the suggestion yet.\": \"பரிந்துரையில் இதுவரை சேர்க்கப்படாத புதிய மாற்றங்கள் உள்ளன.\",\n        \"This is a list of changes relative to the {{originalDocument}}.\": \"இது {{originalDocument}} தொடர்பான மாற்றங்களின் பட்டியல்.\",\n        \"Update suggestion\": \"பரிந்துரையைப் புதுப்பிக்கவும்\",\n        \"Work on a copy\": \"ஒரு நகலில் வேலை செய்யுங்கள்\",\n        \"Your suggestions\": \"உங்கள் பரிந்துரைகள்\",\n        \"experiment\": \"ஆய்வு\",\n        \"original document\": \"அசல் ஆவணம்\",\n        \"Undo dismissal\": \"பணிநீக்கத்தை செயல்தவிர்க்கவும்\"\n    },\n    \"commandList\": {\n        \"Show accessibility options\": \"அணுகல்தன்மை விருப்பங்களைக் காட்டு\",\n        \"Activate assistant\": \"உதவியாளரை இயக்கவும்\",\n        \"Add a new viewsection to the currently active view\": \"தற்போது செயலில் உள்ள காட்சியில் புதிய காட்சிப் பகுதியைச் சேர்க்கவும்\",\n        \"Adds all elements above the cursor to the selected range\": \"தேர்ந்தெடுக்கப்பட்ட வரம்பில் கர்சருக்கு மேலே உள்ள அனைத்து கூறுகளையும் சேர்க்கிறது\",\n        \"Adds all elements below the cursor to the selected range\": \"தேர்ந்தெடுக்கப்பட்ட வரம்பில் கர்சருக்கு கீழே உள்ள அனைத்து கூறுகளையும் சேர்க்கிறது\",\n        \"Adds all elements to the right of the cursor to the selected range\": \"தேர்ந்தெடுக்கப்பட்ட வரம்பில் கர்சரின் வலதுபுறத்தில் உள்ள அனைத்து கூறுகளையும் சேர்க்கிறது\",\n        \"Adds the currently selected column(ascending) to the current view's sort spec\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட நெடுவரிசையை (ஏறுவரிசை) தற்போதைய பார்வையின் வரிசை விவரக்குறிப்பில் சேர்க்கிறது\",\n        \"Adds the currently selected column(descending) to the current view's sort spec\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட நெடுவரிசையை (இறங்கும்) தற்போதைய பார்வையின் வரிசை விவரக்குறிப்பில் சேர்க்கிறது\",\n        \"Adds the element above the cursor to the selected range\": \"தேர்ந்தெடுக்கப்பட்ட வரம்பில் கர்சருக்கு மேலே உள்ள உறுப்பைச் சேர்க்கிறது\",\n        \"Adds the element below the cursor to the selected range\": \"தேர்ந்தெடுக்கப்பட்ட வரம்பில் கர்சருக்குக் கீழே உள்ள உறுப்பைச் சேர்க்கிறது\",\n        \"Adds the element to the left of the cursor to the selected range\": \"தேர்ந்தெடுக்கப்பட்ட வரம்பில் கர்சரின் இடதுபுறத்தில் உறுப்பைச் சேர்க்கிறது\",\n        \"Adds the element to the right of the cursor to the selected range\": \"தேர்ந்தெடுக்கப்பட்ட வரம்பில் கர்சரின் வலதுபுறத்தில் உறுப்பைச் சேர்க்கிறது\",\n        \"Clear the selected columns\": \"தேர்ந்தெடுக்கப்பட்ட நெடுவரிசைகளை அழிக்கவும்\",\n        \"Clears the currently selected cells\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட கலங்களை அழிக்கிறது\",\n        \"Clears the section links in the current view\": \"தற்போதைய பார்வையில் உள்ள பிரிவு இணைப்புகளை அழிக்கிறது\",\n        \"Clears the section links in the current viewsection\": \"தற்போதைய காட்சிப் பிரிவில் உள்ள பிரிவு இணைப்புகளை அழிக்கிறது\",\n        \"Collapse the currently active viewsection\": \"தற்போது செயலில் உள்ள காட்சிப் பகுதியைச் சுருக்கவும்\",\n        \"Convert the selected columns from formula to data\": \"தேர்ந்தெடுக்கப்பட்ட நெடுவரிசைகளை சூத்திரத்திலிருந்து தரவுக்கு மாற்றவும்\",\n        \"Copy anchor link\": \"நங்கூரம் இணைப்பை நகலெடு\",\n        \"Copy current selection to clipboard\": \"தற்போதைய தேர்வை இடைநிலைப்பலகைக்கு நகலெடுக்கவும்\",\n        \"Copy current selection to clipboard including headers\": \"தற்போதைய தேர்வை தலைப்புகள் உட்பட இடைநிலைப்பலகைக்கு நகலெடுக்கவும்\",\n        \"Creates form for active table\": \"செயலில் உள்ள அட்டவணைக்கான படிவத்தை உருவாக்குகிறது\",\n        \"Cut current selection to clipboard\": \"தற்போதைய தேர்வை இடைநிலைப்பலகைக்கு வெட்டுங்கள்\",\n        \"Delete collapsed viewsection\": \"சுருக்கப்பட்ட காட்சிப் பகுதியை நீக்கு\",\n        \"Delete the currently active viewsection\": \"தற்போது செயலில் உள்ள காட்சிப் பகுதியை நீக்கவும்\",\n        \"Delete the currently selected columns\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட நெடுவரிசைகளை நீக்கவும்\",\n        \"Delete the currently selected record(s)\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட பதிவை(களை) நீக்கு\",\n        \"Detach active editor\": \"செயலில் உள்ள எடிட்டரைத் துண்டிக்கவும்\",\n        \"Discard changes to a cell value\": \"செல் மதிப்பில் மாற்றங்களை நிராகரிக்கவும்\",\n        \"Display Grist documentation\": \"காட்சி கிரிச்ட் ஆவணங்கள்\",\n        \"Display shortcuts pane\": \"சார்ட்கட் பலகத்தைக் காண்பி\",\n        \"Duplicate the currently active viewsection\": \"தற்போது செயலில் உள்ள காட்சிப் பகுதியை நகலெடுக்கவும்\",\n        \"Edit label of the currently-selected field\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட புலத்தின் லேபிளைத் திருத்தவும்\",\n        \"Edit record layout\": \"பதிவு தளவமைப்பைத் திருத்தவும்\",\n        \"Enter text into currently-selected cell and start editing\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட கலத்தில் உரையை உள்ளிட்டு திருத்தத் தொடங்கவும்\",\n        \"Enters section linking mode in the current view\": \"தற்போதைய பார்வையில் பிரிவு இணைக்கும் பயன்முறையில் நுழைகிறது\",\n        \"Adds all elements to the left of the cursor to the selected range\": \"தேர்ந்தெடுக்கப்பட்ட வரம்பில் கர்சரின் இடதுபுறத்தில் உள்ள அனைத்து கூறுகளையும் சேர்க்கிறது\",\n        \"Clears the current copy selection, if any\": \"தற்போதைய நகல் தேர்வு ஏதேனும் இருந்தால் அழிக்கும்\",\n        \"Duplicate the currently selected record(s)\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட பதிவை(களை) நகலெடுக்கவும்\",\n        \"Exits section linking mode in the current view\": \"தற்போதைய பார்வையில் பிரிவு இணைக்கும் பயன்முறையிலிருந்து வெளியேறுகிறது\",\n        \"Expand collapsed viewsection\": \"சுருக்கப்பட்ட காட்சிப் பகுதியை விரிவாக்கு\",\n        \"Fills current selection with the contents of the top row in the selection\": \"தேர்வில் உள்ள மேல் வரிசையின் உள்ளடக்கங்களுடன் தற்போதைய தேர்வை நிரப்புகிறது\",\n        \"Find\": \"கண்டுபிடி\",\n        \"Find next occurrence\": \"அடுத்த நிகழ்வைக் கண்டறி\",\n        \"Find previous occurrence\": \"முந்தைய நிகழ்வைக் கண்டறி\",\n        \"Finish editing a cell and save without moving to next record\": \"கலத்தைத் திருத்துவதை முடித்துவிட்டு, அடுத்த பதிவுக்கு நகராமல் சேமிக்கவும்\",\n        \"Finish editing a cell, saving the value\": \"கலத்தைத் திருத்துவதை முடித்து, மதிப்பைச் சேமிக்கவும்\",\n        \"Focus next page panel or widget\": \"அடுத்த பக்க பேனல் அல்லது விட்செட்டில் கவனம் செலுத்துங்கள்\",\n        \"Focus previous page panel or widget\": \"முந்தைய பக்க பேனல் அல்லது விட்செட்டை கவனம் செய்யவும்\",\n        \"Freeze or unfreeze selected columns\": \"தேர்ந்தெடுக்கப்பட்ட நெடுவரிசைகளை முடக்கவும் அல்லது முடக்கவும்\",\n        \"Hide the currently selected columns\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட நெடுவரிசைகளை மறை\",\n        \"Hide the currently selected fields\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட புலங்களை மறை\",\n        \"Insert a new column, after the currently selected one\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட நெடுவரிசைக்குப் பிறகு புதிய நெடுவரிசையைச் செருகவும்\",\n        \"Insert a new column, before the currently selected one\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட நெடுவரிசைக்கு முன் புதிய நெடுவரிசையைச் செருகவும்\",\n        \"Insert a new record, after the currently selected one in an unsorted table\": \"வரிசைப்படுத்தப்படாத அட்டவணையில் தற்போது தேர்ந்தெடுக்கப்பட்ட பதிவிற்குப் பிறகு புதிய பதிவைச் செருகவும்\",\n        \"Insert a new record, before the currently selected one in an unsorted table\": \"வரிசைப்படுத்தப்படாத அட்டவணையில் தற்போது தேர்ந்தெடுக்கப்பட்ட பதிவிற்கு முன் புதிய பதிவைச் செருகவும்\",\n        \"Insert new column in default location\": \"இயல்புநிலை இடத்தில் புதிய நெடுவரிசையைச் செருகவும்\",\n        \"Insert the current date\": \"தற்போதைய தேதியைச் செருகவும்\",\n        \"Insert the current date and time\": \"தற்போதைய தேதி மற்றும் நேரத்தைச் செருகவும்\",\n        \"Maximize the active section\": \"செயலில் உள்ள பகுதியை அதிகரிக்கவும்\",\n        \"Move down one page of records, or to next record in a card list\": \"பதிவுகளின் ஒரு பக்கத்தை கீழே நகர்த்தவும் அல்லது அட்டை பட்டியலில் அடுத்த பதிவுக்கு நகர்த்தவும்\",\n        \"Move down to the last record\": \"கடைசி பதிவுக்கு கீழே செல்லவும்\",\n        \"Move downward five records\": \"ஐந்து பதிவுகளை கீழ்நோக்கி நகர்த்தவும்\",\n        \"Move downward to next record or field\": \"அடுத்த பதிவு அல்லது புலத்திற்கு கீழ்நோக்கி நகர்த்தவும்\",\n        \"Move left to the previous field\": \"இடதுபுறம் முந்தைய புலத்திற்கு நகர்த்தவும்\",\n        \"Move right to the next field\": \"வலதுபுறம் அடுத்த புலத்திற்குச் செல்லவும்\",\n        \"Move to the first field or the beginning of a row\": \"முதல் புலத்திற்கு அல்லது வரிசையின் தொடக்கத்திற்குச் செல்லவும்\",\n        \"Move to the last field or the end of a row\": \"கடைசி புலத்திற்கு அல்லது ஒரு வரிசையின் முடிவுக்கு நகர்த்தவும்\",\n        \"Move to the next field, saving changes if editing a value\": \"மதிப்பைத் திருத்தினால், மாற்றங்களைச் சேமிக்கும் அடுத்த புலத்திற்குச் செல்லவும்\",\n        \"Move to the previous field, saving changes if editing a value\": \"முந்தைய புலத்திற்குச் செல்லவும், மதிப்பைத் திருத்தினால் மாற்றங்களைச் சேமிக்கவும்\",\n        \"Move up one page of records, or to previous record in a card list\": \"பதிவுகளின் ஒரு பக்கத்தை மேலே நகர்த்தவும் அல்லது அட்டைப் பட்டியலில் முந்தைய பதிவுக்கு நகர்த்தவும்\",\n        \"Move up to the first record\": \"முதல் பதிவு வரை செல்லவும்\",\n        \"Move upward five records\": \"ஐந்து பதிவுகளை மேல்நோக்கி நகர்த்தவும்\",\n        \"Move upward to previous record or field\": \"முந்தைய பதிவு அல்லது புலத்திற்கு மேல்நோக்கி நகர்த்தவும்\",\n        \"Moves the cursor to the correct location\": \"கர்சரை சரியான இடத்திற்கு நகர்த்துகிறது\",\n        \"Open Custom widget configuration screen\": \"தனிப்பயன் விட்செட் உள்ளமைவுத் திரையைத் திறக்கவும்\",\n        \"Open comment thread\": \"கருத்துத் தொடரை திறக்கவும்\",\n        \"Open next page\": \"அடுத்த பக்கத்தைத் திறக்கவும்\",\n        \"Open previous page\": \"முந்தைய பக்கத்தைத் திறக்கவும்\",\n        \"Opens document list\": \"ஆவணப் பட்டியலைத் திறக்கும்\",\n        \"Paste clipboard contents at cursor\": \"இடைநிலைப்பலகை உள்ளடக்கங்களை கர்சரில் ஒட்டவும்\",\n        \"Print currently selected page widget\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட பக்க விட்செட்டை அச்சிடுக\",\n        \"Push an undo action\": \"செயல்தவிர்க்க அழுத்தவும்\",\n        \"Redo last action\": \"கடைசி செயலை மீண்டும் வெற்றி\",\n        \"Rename the currently selected column\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட நெடுவரிசைக்கு மறுபெயரிடவும்\",\n        \"Reverts the sections links to the saved links the current view\": \"சேமித்த இணைப்புகளின் தற்போதைய பார்வைக்கு பிரிவு இணைப்புகளை மாற்றியமைக்கிறது\",\n        \"Saves the sections links in the current view\": \"தற்போதைய பார்வையில் பிரிவுகளின் இணைப்புகளைச் சேமிக்கிறது\",\n        \"Selects all currently displayed cells\": \"தற்போது காட்டப்படும் அனைத்து கலங்களையும் தேர்ந்தெடுக்கிறது\",\n        \"Shortcut to data selection tab\": \"தரவு தேர்வு தாவலுக்கு குறுக்குவழி\",\n        \"Shortcut to focus view tab if creator panel is open\": \"கிரியேட்டர் பேனல் திறந்திருந்தால், பார்வை தாவலை மையப்படுத்த சார்ட்கட்\",\n        \"Shortcut to open document tab\": \"ஆவணத் தாவலைத் திறப்பதற்கான குறுக்குவழி\",\n        \"Shortcut to open field tab\": \"புலத் தாவலைத் திறப்பதற்கான குறுக்குவழி\",\n        \"Shortcut to open sort & filter menu\": \"வரிசைப்படுத்துதல் & வடிகட்டி மெனுவைத் திறப்பதற்கான குறுக்குவழி\",\n        \"Shortcut to open the left panel\": \"இடது பேனலைத் திறக்க குறுக்குவழி\",\n        \"Shortcut to open the right panel\": \"வலது பேனலைத் திறக்க குறுக்குவழி\",\n        \"Shortcut to open view tab\": \"பார்வை தாவலைத் திறப்பதற்கான குறுக்குவழி\",\n        \"Shortcut to sort & filter tab\": \"வரிசைப்படுத்த & வடிகட்டுவதற்கான குறுக்குவழி தாவல்\",\n        \"Show hidden columns\": \"மறைக்கப்பட்ட நெடுவரிசைகளைக் காட்டு\",\n        \"Show raw data widget for table of currently selected page widget\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட பக்க விட்செட்டின் அட்டவணைக்கான மூல தரவு விட்செட்டைக் காட்டு\",\n        \"Show the record card widget of the selected record\": \"தேர்ந்தெடுக்கப்பட்ட பதிவின் பதிவு அட்டை விட்செட்டைக் காட்டு\",\n        \"Sort the view data by the currently selected field in ascending order\": \"காட்சி தரவை தற்போது தேர்ந்தெடுக்கப்பட்ட புலத்தின் மூலம் ஏறுவரிசையில் வரிசைப்படுத்தவும்\",\n        \"Sort the view data by the currently selected field in descending order\": \"காட்சி தரவை தற்போது தேர்ந்தெடுக்கப்பட்ட புலத்தின் மூலம் இறங்கு வரிசையில் வரிசைப்படுத்தவும்\",\n        \"Start editing the currently-selected cell\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட கலத்தைத் திருத்தத் தொடங்கவும்\",\n        \"Toggle creator panel keyboard focus\": \"கிரியேட்டர் பேனல் கீபோர்டு ஃபோகசை நிலைமாற்று\",\n        \"Toggle the currently selected checkbox or switch cell\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட தேர்வுப்பெட்டியை மாற்றவும் அல்லது கலத்தை மாற்றவும்\",\n        \"Undo last action\": \"கடைசி செயலைச் செயல்தவிர்க்கவும்\",\n        \"Use the currently selected row as table headers\": \"தற்போது தேர்ந்தெடுக்கப்பட்ட வரிசையை அட்டவணை தலைப்புகளாகப் பயன்படுத்தவும்\",\n        \"When in the search bar, close it and focus the current match\": \"தேடல் பட்டியில் இருக்கும்போது, அதை மூடிவிட்டு தற்போதைய பொருத்தத்தை மையப்படுத்தவும்\",\n        \"When typed at the start of a cell, make this a formula column\": \"கலத்தின் தொடக்கத்தில் தட்டச்சு செய்யும் போது, இதை ஃபார்முலா நெடுவரிசையாக மாற்றவும்\",\n        \"showing a behavioral popup\": \"ஒரு நடத்தை பாப்அப்பைக் காட்டுகிறது\",\n        \"Filter this column by just this cell's value\": \"இந்த கலத்தின் மதிப்பால் இந்த நெடுவரிசையை வடிகட்டவும்\"\n    },\n    \"GridViewMenusDateHelpers\": {\n        \"12-hour format\": \"12-மணிநேர வடிவம்\",\n        \"24-hour format\": \"24-மணிநேர வடிவம்\",\n        \"AM\": {\n            \"PM\": \"AM/PM\"\n        },\n        \"Calendar\": \"நாள்காட்டி\",\n        \"Date helpers…\": \"தேதி உதவியாளர்கள்…\",\n        \"Day\": \"நாள்\",\n        \"Day of month\": \"மாதத்தின் நாள்\",\n        \"Day of week\": \"வாரத்தின் நாள்\",\n        \"Day of week (abbrev)\": \"வாரத்தின் நாள் (சுருக்கம்)\",\n        \"Day of week (full)\": \"வாரத்தின் நாள் (முழு)\",\n        \"Day of week (numeric)\": \"வாரத்தின் நாள் (எண்)\",\n        \"Days since\": \"இருந்து நாட்கள்\",\n        \"Days until\": \"நாட்கள் வரை\",\n        \"Default\": \"இயல்புநிலை\",\n        \"End of\": \"முடிவு\",\n        \"Full date\": \"முழு தேதி\",\n        \"Full name with year\": \"ஆண்டுடன் முழு பெயர்\",\n        \"Hour\": \"மணி\",\n        \"Intervals\": \"இடைவெளிகள்\",\n        \"Is weekend?\": \"வார இறுதியா?\",\n        \"Minute\": \"மணித்துளி\",\n        \"Month\": \"மாதம்\",\n        \"Months since\": \"மாதங்கள் ஆகிவிட்டது\",\n        \"Months until\": \"மாதங்கள் வரை\",\n        \"Name only\": \"பெயர் மட்டும்\",\n        \"Number only\": \"எண் மட்டுமே\",\n        \"Quarter\": \"காலாண்டு\",\n        \"Quick Picks\": \"விரைவான தேர்வுகள்\",\n        \"Relative\": \"உறவினர்\",\n        \"Short with year\": \"வருடத்துடன் குறுகியது\",\n        \"Sortable\": \"வரிசைப்படுத்தக்கூடியது\",\n        \"Start of\": \"தொடக்கம்\",\n        \"Time\": \"நேரம்\",\n        \"Time bucket\": \"நேர வாளி\",\n        \"Week\": \"வாரம்\",\n        \"Week of year\": \"ஆண்டின் வாரம்\",\n        \"Year\": \"ஆண்டு\",\n        \"Years since\": \"வருடங்கள்\",\n        \"Years until\": \"ஆண்டுகள் வரை\"\n    },\n    \"selectBy\": {\n        \"Select widget\": \"விட்செட்டைத் தேர்ந்தெடுக்கவும்\"\n    },\n    \"CoreNewDocMethods\": {\n        \"Untitled document\": \"பெயரிடப்படாத ஆவணம்\"\n    },\n    \"AuthenticationSection\": {\n        \"Active\": \"செயலில்\",\n        \"Active method is controlled by an environment variable. Unset variable to change active method.\": \"செயலில் உள்ள முறை சுற்றுச்சூழல் மாறியால் கட்டுப்படுத்தப்படுகிறது. செயலில் உள்ள முறையை மாற்ற மாறியை அமைக்கவில்லை.\",\n        \"Active on restart\": \"மறுதொடக்கம் செய்யும்போது செயலில் உள்ளது\",\n        \"Are you sure you want to set **{{name}}** as the active authentication method?\": \"**{{name}}** செயலில் உள்ள அங்கீகார முறையாக அமைக்க விரும்புகிறீர்களா?\",\n        \"Close\": \"மூடு\",\n        \"Configure\": \"கட்டமைக்கவும்\",\n        \"Configured\": \"கட்டமைக்கப்பட்டது\",\n        \"Confirm\": \"உறுதிப்படுத்தவும்\",\n        \"Disabled on restart\": \"மறுதொடக்கம் செய்யும்போது முடக்கப்பட்டது\",\n        \"Error\": \"பிழை\",\n        \"Error details\": \"பிழை விவரங்கள்\",\n        \"Instructions\": \"வழிமுறைகள்\",\n        \"No authentication method is active.\": \"எந்த அங்கீகார முறையும் செயலில் இல்லை.\",\n        \"Set as active method\": \"செயலில் உள்ள முறையாக அமைக்கவும்\",\n        \"Set as active method?\": \"செயலில் உள்ள முறையாக அமைக்கவா?\",\n        \"The new method will go into effect after you restart Grist.\": \"நீங்கள் Grist ஐ மறுதொடக்கம் செய்த பிறகு புதிய முறை நடைமுறைக்கு வரும்.\",\n        \"**Forwarded headers** allows your Grist server to trust authentication performed by an external proxy (e.g. Traefik ForwardAuth).\": \"**முன்னனுப்பப்பட்ட தலைப்புகள்** வெளிப்புற பதிலாள் (எ.கா. Traefik ForwardAuth) மூலம் செய்யப்படும் அங்கீகாரத்தை நம்புவதற்கு உங்கள் Grist சேவையகத்தை அனுமதிக்கிறது.\",\n        \"**Grist Connect** is a login solution built and maintained by Grist Labs that integrates seamlessly with your Grist server.\": \"**கிரிச்ட் கனெக்ட்** என்பது உங்கள் கிரிச்ட் சர்வருடன் தடையின்றி ஒருங்கிணைக்கும் கிரிச்ட் லேப்சால் கட்டமைக்கப்பட்டு பராமரிக்கப்படும் உள்நுழைவு தீர்வாகும்.\",\n        \"**OIDC** allows users on your Grist server to sign in using an external identity provider that supports the OpenID Connect standard.\": \"**OIDC** உங்கள் Grist சர்வரில் உள்ள பயனர்களை OpenID இணை தரநிலையை ஆதரிக்கும் வெளிப்புற அடையாள வழங்குநரைப் பயன்படுத்தி உள்நுழைய அனுமதிக்கிறது.\",\n        \"**SAML** allows users on your Grist server to sign in using an external identity provider that supports the SAML 2.0 standard.\": \"**SAML** உங்கள் கடன் சேவையகத்தில் உள்ள பயனர்களை SAML 2.0 தரநிலையை ஆதரிக்கும் வெளிப்புற அடையாள வழங்குநரைப் பயன்படுத்தி உள்நுழைய அனுமதிக்கிறது.\",\n        \"Change admin user\": \"நிர்வாக பயனரை மாற்றவும்\",\n        \"If Grist is accessible on your network, or is available to multiple people, configure one of the authentication methods below.\": \"உங்கள் நெட்வொர்க்கில் கிரிச்ட் அணுகக்கூடியதாக இருந்தால் அல்லது பலருக்குக் கிடைத்தால், கீழே உள்ள அங்கீகார முறைகளில் ஒன்றை உள்ளமைக்கவும்.\",\n        \"No authentication: unrestricted sign-in as demo user\": \"ஏற்பு இல்லை: டெமோ பயனராக கட்டுப்பாடற்ற உள்நுழைவு\",\n        \"Prepare changes\": \"மாற்றங்களைத் தயாரிக்கவும்\",\n        \"Restart required. Authentication change may affect your access\": \"மறுதொடக்கம் தேவை. அங்கீகார மாற்றம் உங்கள் அணுகலைப் பாதிக்கலாம்\",\n        \"Revert change of admin user\": \"நிர்வாக பயனரின் மாற்றத்தை மாற்றவும்\",\n        \"See \\\"Restart Grist\\\" section on top of this page to restart.\": \"மறுதொடக்கம் செய்ய இந்தப் பக்கத்தின் மேல் உள்ள \\\"ரிச்டார்ட் கிரிச்ட்\\\" பகுதியைப் பார்க்கவும்.\",\n        \"To set up **Grist Connect**, follow the instructions in [the Grist support article for Grist Connect](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"grist-connect\": {\n                            \").\": \"**Grist Connect**ஐ அமைக்க, [Grist இணை க்கான Grist உதவி கட்டுரை](https://support.getgrist.com/install/grist-connect/) உள்ள வழிமுறைகளைப் பின்பற்றவும்.\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **OIDC**, follow the instructions in [the Grist support article for OIDC](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"oidc).\": \"**OIDC**ஐ அமைக்க, [OIDCக்கான Grist ஆதரவுக் கட்டுரை](https://support.getgrist.com/install/oidc) உள்ள வழிமுறைகளைப் பின்பற்றவும்.\"\n                    }\n                }\n            }\n        },\n        \"To set up **SAML**, follow the instructions in [the Grist support article for SAML](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"saml\": {\n                            \").\": \"**SAML** ஐ அமைக்க, [SAML க்கான Grist உதவி கட்டுரை](https://support.getgrist.com/install/saml/) உள்ள வழிமுறைகளைப் பின்பற்றவும்.\"\n                        }\n                    }\n                }\n            }\n        },\n        \"To set up **forwarded headers**, follow the instructions in [the Grist support article for forwarded headers](https:\": {\n            \"\": {\n                \"support.getgrist.com\": {\n                    \"install\": {\n                        \"forwarded-headers\": {\n                            \").\": \"** முன்னனுப்பப்பட்ட தலைப்புகளை** அமைக்க, [ஃபார்வர்டு செய்யப்பட்ட தலைப்புகளுக்கான கிரிச்ட் ஆதரவுக் கட்டுரை] (https://support.getgrist.com/install/forwarded-headers/) உள்ள வழிமுறைகளைப் பின்பற்றவும்.\"\n                        }\n                    }\n                }\n            }\n        },\n        \"When a user accesses Grist, the proxy handles authentication and forwards verified user information through HTTP headers. Grist uses these headers to identify the user.\": \"ஒரு பயனர் Grist ஐ அணுகும்போது, ப்ராக்சி அங்கீகாரத்தைக் கையாளுகிறது மற்றும் HTTP தலைப்புகள் மூலம் சரிபார்க்கப்பட்ட பயனர் தகவலை அனுப்புகிறது. பயனரை அடையாளம் காண கிரிச்ட் இந்த தலைப்புகளைப் பயன்படுத்துகிறார்.\",\n        \"When signing in, users will be redirected to a Grist Connect login page where they can authenticate using various identity providers. After authentication, they'll be redirected back to your Grist server and signed in.\": \"உள்நுழையும் போது, பயனர்கள் Grist இணை உள்நுழைவு பக்கத்திற்கு திருப்பி விடப்படுவார்கள், அங்கு அவர்கள் பல்வேறு அடையாள வழங்குநர்களைப் பயன்படுத்தி அங்கீகரிக்க முடியும். அங்கீகாரத்திற்குப் பிறகு, அவை உங்கள் கிரிச்ட் சேவையகத்திற்குத் திருப்பிவிடப்பட்டு உள்நுழையப்படும்.\",\n        \"When signing in, users will be redirected to your chosen identity provider's login page to authenticate. After successful authentication, they'll be redirected back to your Grist server and signed in as the user verified by the provider.\": \"உள்நுழையும்போது, அங்கீகரிப்பதற்காக நீங்கள் தேர்ந்தெடுத்த அடையாள வழங்குநரின் உள்நுழைவுப் பக்கத்திற்குப் பயனர்கள் திருப்பிவிடப்படுவார்கள். வெற்றிகரமான அங்கீகாரத்திற்குப் பிறகு, அவை உங்கள் கிரிச்ட் சேவையகத்திற்குத் திருப்பிவிடப்பட்டு வழங்குநரால் சரிபார்க்கப்பட்ட பயனராக உள்நுழையப்படும்.\",\n        \"You are signed in as {{email}}. After restart, the new administrative user will be {{newEmail}}.\": \"நீங்கள் {{email}} ஆக உள்நுழைந்துள்ளீர்கள். மறுதொடக்கம் செய்த பிறகு, புதிய நிர்வாகப் பயனர் {{newEmail}} ஆக இருப்பார்.\",\n        \"You are signed in as {{email}}. You may lose access to this server if you cannot sign in as this user after switching the authentication system.\": \"நீங்கள் {{email}} ஆக உள்நுழைந்துள்ளீர்கள். அங்கீகார அமைப்பை மாற்றிய பிறகு இந்த பயனராக உங்களால் உள்நுழைய முடியாவிட்டால், இந்த சேவையகத்திற்கான அணுகலை நீங்கள் இழக்க நேரிடும்.\"\n    },\n    \"DetailView\": {\n        \"This row is unavailable or does not exist\": \"இந்த வரிசை கிடைக்கவில்லை அல்லது இல்லை\"\n    },\n    \"GetGristComProvider\": {\n        \"**Sign in with getgrist.com** allows users on your Grist server to sign in using their account on getgrist.com, the cloud version of Grist managed by Grist Labs.\": \"**getgrist.com மூலம் உள்நுழைக** உங்கள் Grist சர்வரில் உள்ள பயனர்கள் Grist Labs நிர்வகிக்கும் Grist இன் முகில் பதிப்பான getgrist.com இல் தங்கள் கணக்கைப் பயன்படுத்தி உள்நுழைய அனுமதிக்கிறது.\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Configure\": \"கட்டமைக்கவும்\",\n        \"Configure Sign in with getgrist.com\": \"getgrist.com உடன் உள்நுழைவை உள்ளமைக்கவும்\",\n        \"Home URL is not set; cannot configure Sign in with getgrist.com\": \"முகப்பு முகவரி அமைக்கப்படவில்லை; getgrist.com உடன் உள்நுழைய முடியாது\",\n        \"Instructions\": \"வழிமுறைகள்\",\n        \"Learn more about Sign in with getgrist.com\": \"getgrist.com மூலம் உள்நுழைவது பற்றி மேலும் அறிக\",\n        \"Paste configuration key here\": \"உள்ளமைவு விசையை இங்கே ஒட்டவும்\",\n        \"Register your Grist server\": \"உங்கள் கிரிச்ட் சர்வரை பதிவு செய்யவும்\",\n        \"Sign in with getgrist.com\": \"getgrist.com மூலம் உள்நுழையவும்\",\n        \"To set up {{provider}}, you need to register your Grist server on getgrist.com and paste the configuration key you receive below.\": \"{{provider}} ஐ அமைக்க, உங்கள் Grist சேவையகத்தை getgrist.com இல் பதிவு செய்து, நீங்கள் பெறும் உள்ளமைவு விசையை கீழே ஒட்ட வேண்டும்.\",\n        \"When signing in, users will be redirected to the getgrist.com login page to log in or register. After authenticating on getgrist.com, they'll be redirected back to your Grist server and signed in as the user they authenticated as.\": \"உள்நுழையும் போது, பயனர்கள் உள்நுழைய அல்லது பதிவு செய்ய getgrist.com உள்நுழைவு பக்கத்திற்கு திருப்பி விடப்படுவார்கள். getgrist.com இல் அங்கீகரித்த பிறகு, அவர்கள் உங்கள் Grist சேவையகத்திற்குத் திருப்பிவிடப்பட்டு, அவர்கள் அங்கீகரித்த பயனராக உள்நுழைவார்கள்.\"\n    },\n    \"AirtableImportUI\": {\n        \"Back\": \"பின்\",\n        \"Cancel\": \"ரத்துசெய்\",\n        \"Choose an Airtable base to import from\": \"இறக்குமதி செய்ய ஏர்டேபிள் தளத்தைத் தேர்வு செய்யவும்\",\n        \"Choose destination\": \"சேருமிடத்தைத் தேர்வுசெய்க\",\n        \"Connect\": \"இணை\",\n        \"Connect with Airtable\": \"Airtable உடன் இணைக்கவும்\",\n        \"Connect your Airtable account to access your bases.\": \"உங்கள் தளங்களை அணுக உங்கள் Airtable கணக்கை இணைக்கவும்.\",\n        \"Connected via {{method}}\": \"{{method}} வழியாக இணைக்கப்பட்டது\",\n        \"Connecting...\": \"இணைக்கிறது...\",\n        \"Continue\": \"தொடரவும்\",\n        \"Destination\": \"இலக்கு\",\n        \"Disconnect\": \"துண்டிக்கவும்\",\n        \"Existing tables\": \"இருக்கும் அட்டவணைகள்\",\n        \"Failed to fetch base schema\": \"அடிப்படைத் திட்டத்தைப் பெறுவதில் தோல்வி\",\n        \"Failed to fetch bases\": \"தளங்களைப் பெறுவதில் தோல்வி\",\n        \"Grist configuration required\": \"கிரிச்ட் உள்ளமைவு தேவை\",\n        \"Import from {{baseName}} in progress. Do not navigate away from this page.\": \"{{baseName}} இலிருந்து இறக்குமதி செய்யப்படுகிறது. இந்தப் பக்கத்திலிருந்து விலகிச் செல்ல வேண்டாம்.\",\n        \"Import tables\": \"அட்டவணைகளை இறக்குமதி செய்யவும்\",\n        \"Import {{count}} tables_one\": \"இறக்குமதி {{count}} அட்டவணைகள்\",\n        \"Import {{count}} tables_other\": \"{{count}} அட்டவணைகளை இறக்குமதி வெற்றி\",\n        \"Make sure your token has the correct permissions.\": \"உங்கள் டோக்கனில் சரியான அனுமதிகள் உள்ளதா என்பதை உறுதிப்படுத்தவும்.\",\n        \"New table\": \"புதிய அட்டவணை\",\n        \"New table: structure only\": \"புதிய அட்டவணை: கட்டமைப்பு மட்டும்\",\n        \"No bases found\": \"எந்த அடிப்படையும் இல்லை\",\n        \"OAuth\": \"OAuth\",\n        \"OAuth credentials not configured. Please set OAUTH2_AIRTABLE_CLIENT_ID and OAUTH2_AIRTABLE_CLIENT_SECRET, or use personal access token.\": \"OAuth நற்சான்றிதழ்கள் உள்ளமைக்கப்படவில்லை. OAUTH2_AIRTABLE_CLIENT_ID மற்றும் OAUTH2_AIRTABLE_CLIENT_SECRET ஐ அமைக்கவும் அல்லது தனிப்பட்ட அணுகல் கிள்ளாக்கைப் பயன்படுத்தவும்.\",\n        \"Personal access token\": \"Personal access கிள்ளாக்கு\",\n        \"Please enter a personal access token\": \"தனிப்பட்ட அணுகல் கிள்ளாக்கை உள்ளிடவும்\",\n        \"Refresh\": \"புதுப்பிப்பு\",\n        \"Select tables to import from {{baseName}}\": \"{{baseName}} இலிருந்து இறக்குமதி செய்ய அட்டவணைகளைத் தேர்ந்தெடுக்கவும்\",\n        \"Skip\": \"தவிர்\",\n        \"Source tables\": \"ஆதார அட்டவணைகள்\",\n        \"Structure only\": \"கட்டமைப்பு மட்டுமே\",\n        \"Use personal access token instead\": \"அதற்குப் பதிலாக தனிப்பட்ட அணுகல் கிள்ளாக்கைப் பயன்படுத்தவும்\",\n        \"[Generate a token]({{url}}) in your Airtable account with scopes that include at least **\\\\`schema.bases:read\\\\`** and **\\\\`data.records:read\\\\`**.\\n\\nYour token is never sent to Grist's servers, and is only used to make API calls to Airtable from your browser.\": \"உங்கள் ஏர்டேபிள் கணக்கில் குறைந்தபட்சம் **\\\\`schema.bases:read\\\\`** மற்றும் **\\\\`data.records:read\\\\`** ஆகியவற்றை உள்ளடக்கிய ச்கோப்புடன் [டோக்கனை உருவாக்கவும்]({{url}}). \\n\\nஉங்கள் கிள்ளாக்கு Grist இன் சேவையகங்களுக்கு அனுப்பப்படாது, மேலும் உங்கள் உலாவியில் இருந்து Airtable க்கு பநிஇ அழைப்புகளைச் செய்ய மட்டுமே பயன்படுத்தப்படும்.\",\n        \"loading your bases...\": \"உங்கள் தளங்களை ஏற்றுகிறது...\",\n        \"loading your tables...\": \"உங்கள் அட்டவணைகளை ஏற்றுகிறது...\",\n        \"or\": \"அல்லது\",\n        \"{{count}} warnings_one\": \"{{count}} எச்சரிக்கைகள்\",\n        \"{{count}} warnings_other\": \"{{count}} எச்சரிக்கைகள்\",\n        \"The more convenient ‘Connect with Airtable’ option can be configured by the installation administrator. [Learn more.]({{url}})\": \"மிகவும் வசதியான 'ஏர்டேபிளுடன் இணைக்கவும்' விருப்பத்தை நிறுவல் நிர்வாகியால் கட்டமைக்க முடியும். [மேலும் அறிக.]({{url}})\",\n        \"Use personal access token\": \"தனிப்பட்ட அணுகல் கிள்ளாக்கைப் பயன்படுத்தவும்\"\n    },\n    \"AirtableImporter\": {\n        \"Creating a new Grist document...\": \"புதிய Grist ஆவணத்தை உருவாக்குகிறது...\",\n        \"Preparing to import base from Airtable...\": \"Airtable இலிருந்து தளத்தை இறக்குமதி செய்யத் தயாராகிறது...\",\n        \"Setting up tables...\": \"அட்டவணைகளை அமைக்கிறது...\"\n    },\n    \"ChangeAdminModal\": {\n        \"Enter new admin email\": \"புதிய நிர்வாக மின்னஞ்சலை உள்ளிடவும்\",\n        \"Make the new email the installation admin. Orgs, workspaces, and documents will remain owned by {{email}}. These changes will take effect after you restart this Grist server.\": \"புதிய மின்னஞ்சலை நிறுவல் நிர்வாகியாக்கு. அமைப்புகள், பணியிடங்கள் மற்றும் ஆவணங்கள் {{email}}க்கு சொந்தமானதாக இருக்கும். இந்த Grist சேவையகத்தை நீங்கள் மறுதொடக்கம் செய்த பிறகு இந்த மாற்றங்கள் நடைமுறைக்கு வரும்.\",\n        \"New admin\": \"புதிய நிர்வாகி\",\n        \"Replace {{email}} with the new email throughout. The new email will become the installation admin, as well as the owner of all materials previously owned by you@example.com.\": \"{{email}}ஐ புதிய மின்னஞ்சலை முழுவதுமாக மாற்றவும். புதிய மின்னஞ்சலானது நிறுவல் நிர்வாகியாகவும், முன்பு you@example.com க்கு சொந்தமான அனைத்து பொருட்களின் உரிமையாளராகவும் மாறும்.\"\n    },\n    \"startDocAirtableImport\": {\n        \"Import from Airtable\": \"Airtable இலிருந்து இறக்குமதி செய்யவும்\"\n    },\n    \"startHomeAirtableImport\": {\n        \"Import from Airtable\": \"Airtable இலிருந்து இறக்குமதி செய்யவும்\",\n        \"The current workspace can't be imported to.\": \"தற்போதைய பணியிடத்தை இறக்குமதி செய்ய முடியாது.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/ta.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"அடையாள வழங்குநருடன் உங்கள் மின்னஞ்சலைச் சரிபார்த்து, மீண்டும் உள்நுழைக.\"\n    },\n    \"sendAppPage\": {\n        \"Loading...\": \"ஏற்றுகிறது...\",\n        \"og-description\": \"கட்டத்திற்கு அப்பாற்பட்ட ஒரு நவீன, திறந்த மூல விரிதாள்\",\n        \"og-title\": \"கிரிச்ட், விரிதாள்களின் பரிணாமம்\"\n    },\n    \"access\": {\n        \"docDisabled\": \"இந்த ஆவணம் முடக்கப்பட்டுள்ளது.\",\n        \"docNoAccess\": \"இந்த ஆவணத்திற்கான அணுகல் உங்களிடம் இல்லை.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"`GRIST_INSTALL_ADMIN_ORG={{org}}` ஆல் வரையறுக்கப்பட்ட நிர்வாக அமைப்பில் உரிமையாளர்கள் இல்லை\",\n        \"orgUser\": \"பயனர் `GRIST_INSTALL_ADMIN_ORG={{org}}` ஆல் வரையறுக்கப்பட்ட நிர்வாக அமைப்பின் உரிமையாளர்\",\n        \"noAdminEmail\": \"`GRIST_ADMIN_EMAIL` மற்றும் `GRIST_DEFAULT_EMAIL` அமைக்கப்படாததால் நிர்வாகி கணக்கு இல்லை\",\n        \"accountByEmail\": \"நிர்வாகி கணக்கு `GRIST_DEFAULT_EMAIL={{defaultEmail}}` ஆல் வரையறுக்கப்பட்டது\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"பெயரிடப்படாத ஆவணம்\"\n    }\n}\n"
  },
  {
    "path": "static/locales/th.client.json",
    "content": "{\n    \"ACUserManager\": {\n        \"Enter email address\": \"กรอกอีเมล\",\n        \"Invite new member\": \"เชิญสมาชิกใหม่\",\n        \"We'll email an invite to {{email}}\": \"เราจะส่งคำเชิญไปยังอีเมล {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Add column rule\": \"เพิ่มกฎของคอลัมน์\",\n        \"Add table rules\": \"เพิ่มกฎของตาราง\",\n        \"Add user attributes\": \"เพิ่มข้อมูลคุณลักษณะของผู้ใช้\"\n    }\n}\n"
  },
  {
    "path": "static/locales/th.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"Loading...\": \"กำลังโหลด...\",\n        \"og-description\": \"ตารางข้อมูลที่เป็นมากกว่าตาราง ล้ำหน้า และโอเพนซอร์ส\",\n        \"og-title\": \"Grist, วิวัฒนาการของโปรแกรมตารางข้อมูล\"\n    },\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"กรุณายืนยันอีเมลของคุณกับทางผู้ให้บริการยืนยันตัวตนก่อน แล้วเข้าสู่ระบบอีกครั้ง\"\n    },\n    \"access\": {\n        \"docDisabled\": \"เอกสารนี้ถูกปิดการใช้งาน\",\n        \"docNoAccess\": \"คุณไม่มีสิทธิ์เข้าถึงเอกสารนี้\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"เอกสารไม่มีชื่อ\"\n    }\n}\n"
  },
  {
    "path": "static/locales/tr.client.json",
    "content": "{\n  \"ACUserManager\": {\n    \"Invite new member\": \"Yeni üyeyi davet edin\",\n    \"Enter email address\": \"E-posta adresini girin\",\n    \"We'll email an invite to {{email}}\": \"{{email}} adresine bir davet e-postası göndereceğiz\"\n  },\n  \"AccessRules\": {\n    \"Add column rule\": \"Sütuna Kural ekle\",\n    \"Add Default Rule\": \"Varsayılan kural ekle\",\n    \"Add table rules\": \"Tabloya kural ekle\",\n    \"Reset\": \"Sıfırla\",\n    \"Saved\": \"Kaydedildi\",\n    \"Attribute name\": \"Nitelik adı\",\n    \"Save\": \"Kaydet\",\n    \"Add user attributes\": \"Kullanıcı özellikleri ekle\"\n  },\n  \"AdminPanel\": {\n    \"Off\": \"Kapalı\",\n    \"Log Streaming\": \"Günlük Akışları\",\n    \"Audit Logs\": \"Denetim Günlükleri\",\n    \"Contact us\": \"Bize Ulaşın\"\n  },\n  \"Columns\": {\n    \"Remove Column\": \"Sütunu Sil\"\n  },\n  \"Field\": {\n    \"No choices configured\": \"Seçim yapılmamış\"\n  },\n  \"AccountPage\": {\n    \"API\": \"API\",\n    \"API Key\": \"API anahtarı\",\n    \"Edit\": \"Düzenle\",\n    \"Password & security\": \"Parola ve Güvenlik\",\n    \"Language\": \"Dil\",\n    \"Save\": \"Kaydet\",\n    \"Email\": \"E-posta\",\n    \"Login method\": \"Giriş yöntemi\",\n    \"Name\": \"Adı\",\n    \"Change password\": \"Parola değiştir\",\n    \"Account settings\": \"Hesap ayarları\"\n  },\n  \"AccountWidget\": {\n    \"Access Details\": \"Erişim ayrıntıları\",\n    \"Add account\": \"Hesap ekle\",\n    \"Document settings\": \"Belge Ayarları\",\n    \"Profile settings\": \"Profil Ayarları\",\n    \"Sign in\": \"Oturum aç\",\n    \"Accounts\": \"Hesaplar\",\n    \"Sign out\": \"Oturumu kapat\"\n  }\n}\n"
  },
  {
    "path": "static/locales/tr.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Lütfen e-postanızı kimlik sağlayıcısıyla doğrulayın ve tekrar giriş yapın.\"\n    },\n    \"sendAppPage\": {\n        \"Loading...\": \"Yükleniyor...\",\n        \"og-description\": \"Izgara tasarımının ötesine geçen modern, açık kaynaklı bir e-tablo\",\n        \"og-title\": \"Grist, e-tabloların evrimi\"\n    }\n}\n"
  },
  {
    "path": "static/locales/uk.client.json",
    "content": "{\n    \"ExampleInfo\": {\n        \"Investment Research\": \"Інвестиційні дослідження\",\n        \"Lightweight CRM\": \"Полегшена CRM\",\n        \"Tutorial: Manage Business Data\": \"Підручник: управління бізнес-даними\",\n        \"Welcome to the Investment Research template\": \"Ласкаво просимо до шаблону інвестиційного дослідження\",\n        \"Afterschool Program\": \"Позашкільна програма\",\n        \"Tutorial: Analyze & Visualize\": \"Посібник: аналіз та візуалізація\",\n        \"Tutorial: Create a CRM\": \"Підручник: створити CRM\",\n        \"Welcome to the Afterschool Program template\": \"Ласкаво просимо в шаблон програми позашкільної освіти\",\n        \"Welcome to the Lightweight CRM template\": \"Ласкаво просимо до шаблону полегшеної CRM\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Перегляньте наш відповідний посібник, щоб дізнатись, як зв'язати дані та створити високопродуктивні макети.\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Перегляньте наш відповідний посібник, щоб дізнатись, як створювати підсумкові таблиці та графіки, і як налаштувати динамічний зв’язок з діаграмами.\",\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Перегляньте наш відповідний посібник, щоб дізнатись, як моделювати бізнес-дані, використовувати формули та керувати складністю.\"\n    },\n    \"FieldConfig\": {\n        \"Clear and reset\": \"Очистити та скинути\",\n        \"Empty columns_one\": \"Пустий стовпець\",\n        \"Make into data column\": \"Перетворити в стовпець даних\",\n        \"COLUMN LABEL AND ID\": \"НАЗВА ТА ІДЕНТИФІКАТОР СТОВПЦЯ\",\n        \"Clear and make into formula\": \"Очистити та перетворити у формулу\",\n        \"Data columns_one\": \"Стовпець даних\",\n        \"Data columns_other\": \"Стовпці даних\",\n        \"Empty columns_other\": \"Пусті стовпці\",\n        \"Enter formula\": \"Введіть формулу\",\n        \"Formula columns_one\": \"Стовпець формули\",\n        \"Formula columns_other\": \"Стовпці формул\",\n        \"Mixed Behavior\": \"Змішане поводження\",\n        \"Set formula\": \"Задайте формулу\",\n        \"Set trigger formula\": \"Задайте трігерну формулу\",\n        \"TRIGGER FORMULA\": \"ТРІГЕРНА ФОРМУЛА\",\n        \"COLUMN BEHAVIOR\": \"ПОВОДЖЕННЯ СТОВПЦІВ\",\n        \"Convert to trigger formula\": \"Перетворити на тригерну формулу\",\n        \"Column options are limited in summary tables.\": \"Налаштування стовпців у підсумкових таблицях обмежені.\",\n        \"Convert column to data\": \"Перетворити стовпець у дані\",\n        \"DESCRIPTION\": \"ОПИС\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"Повернутися до загальних налаштувань\",\n        \"Save as common settings\": \"Зберегти як загальні налаштування\",\n        \"Use separate settings\": \"Використати окремі налаштування\",\n        \"Using common settings\": \"Використати загальні налаштування\",\n        \"Using separate settings\": \"Використання окремих налаштувань\"\n    },\n    \"ShareMenu\": {\n        \"Manage users\": \"Керувати користувачами\",\n        \"Unsaved\": \"Не збережено\",\n        \"Access Details\": \"Параметри доступу\",\n        \"Back to current\": \"Повернутися до поточного\",\n        \"Compare to {{termToUse}}\": \"Порівняти з {{termToUse}}\",\n        \"Current Version\": \"Поточна версія\",\n        \"Download\": \"Завантажити\",\n        \"Duplicate document\": \"Дублювати документ\",\n        \"Edit without affecting the original\": \"Редагувати, не змінюючи оригінал\",\n        \"Export CSV\": \"Експорт CSV\",\n        \"Export XLSX\": \"Експорт XLSX\",\n        \"Original\": \"Оригінал\",\n        \"Replace {{termToUse}}...\": \"Замінити {{termToUse}}…\",\n        \"Return to {{termToUse}}\": \"Повернутися до {{termToUse}}\",\n        \"Save copy\": \"Зберегти копію\",\n        \"Save Document\": \"Зберегти документ\",\n        \"Send to Google Drive\": \"Надіслати на Google Drive\",\n        \"Show in folder\": \"Показати в папці\",\n        \"Work on a copy\": \"Працювати над копією\",\n        \"Download...\": \"Завантажити...\",\n        \"Share\": \"Поділитися\",\n        \"Comma Separated Values (.csv)\": \"Значення, розділені комами (.csv)\",\n        \"DOO Separated Values (.dsv)\": \"Розділені значення DOO (.dsv)\",\n        \"Export as...\": \"Експортувати як...\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"Tab Separated Values (.tsv)\": \"Значення, розділені табуляцією (.tsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"Експорт доступний лише зі сторінок документа. Виберіть сторінку документа та повторіть спробу.\",\n        \"Download attachments...\": \"Завантажити вкладення...\",\n        \"Download document...\": \"Завантажити документ...\",\n        \"Suggest changes\": \"Запропонувати зміни\",\n        \"current version\": \"поточна версія\",\n        \"original\": \"оригінальний\"\n    },\n    \"MakeCopyMenu\": {\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Зверніть увагу! В оригіналі є зміни, яких немає в цьому документі. Ці зміни будуть перезаписані.\",\n        \"Include the structure without any of the data.\": \"Включіть структуру без будь-яких даних.\",\n        \"It will be overwritten, losing any content not in this document.\": \"Його буде перезаписано, тож буде втрачено весь вміст, якого немає в цьому документі.\",\n        \"Overwrite\": \"Перезаписати\",\n        \"Replacing the original requires editing rights on the original document.\": \"Для заміни оригіналу потрібні права на редагування оригінального документа.\",\n        \"Sign up\": \"Зареєструватися\",\n        \"The original version of this document will be updated.\": \"Оригінальна версія цього документа буде оновлена.\",\n        \"As template\": \"Як шаблон\",\n        \"Cancel\": \"Скасувати\",\n        \"Enter document name\": \"Введіть ім'я документа\",\n        \"Name\": \"Ім'я\",\n        \"No destination workspace\": \"Немає цільового робочого простіру\",\n        \"Organization\": \"Організація\",\n        \"Original Has Modifications\": \"Оригінал має зміни\",\n        \"Original Looks Unrelated\": \"Оригінал виглядає не пов'язаним\",\n        \"Original Looks Identical\": \"Оригінал виглядає ідентично\",\n        \"To save your changes, please sign up, then reload this page.\": \"Щоб зберегти зміни - будь ласка, зареєструйтесь, а потім перезавантажте цю сторінку.\",\n        \"Update\": \"Оновлення\",\n        \"Update Original\": \"Оновити оригінал\",\n        \"Workspace\": \"Робочий простір\",\n        \"You do not have write access to the selected workspace\": \"Ви не маєте права на запис у вибраному робочому просторі\",\n        \"You do not have write access to this site\": \"Ви не маєте права на запис на цьому сайті\",\n        \"However, it appears to be already identical.\": \"Втім, схоже, що вони вже ідентичні.\",\n        \"Download document structure only (no data, for template use)\": \"Видалити всі дані, але зберегти структуру, щоб використовувати як шаблон\",\n        \"Download document without history (can significantly reduce file size)\": \"Видалити історію документа (може значно зменшити розмір файлу)\",\n        \"Download document and history\": \"Завантажити документ та історію\",\n        \"Download\": \"Завантажити\",\n        \"Download document\": \"Завантажити документ\",\n        \".tar (recommended)\": \".tar (рекомендовано)\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"Завантажте архів усіх вкладень, що містяться в цьому документі.\",\n        \"Download attachments\": \"Завантажити вкладення\",\n        \"Download full document and history\": \"Завантажити повний документ та історію\",\n        \"Format:\": \"Формат:\",\n        \"Learn more\": \"Дізнатися більше\",\n        \"download attachments\": \"завантажити вкладення\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"Вкладення є зовнішніми та не включені до цього завантаження. Якщо ви завантажуєте документ до окремої інсталяції Grist, вам також потрібно буде окремо {{downloadLink}}. \",\n        \"If you're planning to upload this document to a Grist installation, you will need the archive in the \\\".tar\\\" format to restore attachments. \": \"Якщо ви плануєте завантажити цей документ до інсталяції Grist, вам знадобиться архів у форматі \\\".tar\\\" для відновлення вкладень. \"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"Додати стовпеця\",\n        \"Empty values last\": \"Порожні значення йдуть останніми\",\n        \"Natural sort\": \"Природне сортування\",\n        \"Update data\": \"Оновити дані\",\n        \"Use choice position\": \"Використовувати позицію вибору\",\n        \"Search Columns\": \"Стовпці пошуку\",\n        \"Remove sort setting - {{- columnName }} column\": \"Вилучити налаштування сортування – стовпець {{- columnName }}\",\n        \"Sort in ascending order (current: descending)\": \"Сортувати за зростанням (поточний: спадання)\",\n        \"Sort in descending order (current: ascending)\": \"Сортувати за спаданням (поточний: за зростанням)\",\n        \"Sort options - {{- columnName }} column\": \"Параметри сортування – стовпець {{- columnName }}\",\n        \"{{- columnName }} column\": \"{{- columnName }} Стовпець\"\n    },\n    \"SortFilterConfig\": {\n        \"Save\": \"Зберегти\",\n        \"Sort\": \"СОРТУВАТИ\",\n        \"Filter\": \"ФІЛЬТР\",\n        \"Revert\": \"Повернутися\",\n        \"Update Sort & Filter settings\": \"Оновити параметри сортування та фільтрування\"\n    },\n    \"GridOptions\": {\n        \"Horizontal gridlines\": \"Горизонтальні лінії сітки\",\n        \"Grid Options\": \"Параметри сітки\",\n        \"Vertical gridlines\": \"Вертикальні лінії сітки\",\n        \"Zebra stripes\": \"Смужки зебри\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"Додати стовпець\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"Фільтр закріплення – стовпець {{- columnName}} (поточний: відкріплений)\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"Відкріпити фільтр – стовпець {{- columnName}} (поточний: закріплений)\",\n        \"remove filter - {{- columnName}} column\": \"вилучити фільтр – стовпець {{- columnName}}\",\n        \"{{- columnName }} column filters\": \"{{- columnName }} фільтри стовпців\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"Стовпці пошуку\",\n        \"Search Columns\": \"Стовпці пошуку\"\n    },\n    \"GridViewMenus\": {\n        \"Column Options\": \"Параметри стовпця\",\n        \"Delete {{count}} columns_other\": \"Видалити {{count}} стовпців\",\n        \"Freeze {{count}} more columns_one\": \"Закріпити ще один стовпець\",\n        \"Reset {{count}} columns_other\": \"Скинути {{count}} стовпців\",\n        \"Sort\": \"Сортувати\",\n        \"Add column\": \"Додати стовпець\",\n        \"Add to sort\": \"Додати до сортування\",\n        \"Clear values\": \"Очистити значення\",\n        \"Convert formula to data\": \"Перетворити формулу в дані\",\n        \"Delete {{count}} columns_one\": \"Видалити стовпець\",\n        \"Filter Data\": \"Фільтрувати дані\",\n        \"Freeze {{count}} columns_one\": \"Закріпити цей стовпець\",\n        \"Freeze {{count}} more columns_other\": \"Закріпити ще {{count}} стовпців\",\n        \"Hide {{count}} columns_one\": \"Сховати стовпець\",\n        \"Freeze {{count}} columns_other\": \"Закріпити {{count}} стовпців\",\n        \"Hide {{count}} columns_other\": \"Сховати {{count}} стовпців\",\n        \"Insert column to the {{to}}\": \"Вставити стовпець у {{to}}\",\n        \"More sort options ...\": \"Більше опцій сортування…\",\n        \"Rename column\": \"Перейменувати стовпець\",\n        \"Reset {{count}} columns_one\": \"Скинути стовпець\",\n        \"Reset {{count}} entire columns_one\": \"Скинути весь стовпець\",\n        \"Reset {{count}} entire columns_other\": \"Скинути {{count}} цілих стовпців\",\n        \"Show column {{- label}}\": \"Показати стовпець {{- label}}\",\n        \"Sorted (#{{count}})_one\": \"Сортування (#{{count}})\",\n        \"Sorted (#{{count}})_other\": \"Відсортовано (#{{count}})\",\n        \"Unfreeze all columns\": \"Відкріпити всі стовпці\",\n        \"Unfreeze {{count}} columns_one\": \"Відкріпити цей стовпець\",\n        \"Unfreeze {{count}} columns_other\": \"Відкріпити {{count}} стовпців\",\n        \"Insert column to the right\": \"Вставити стовпець праворуч\",\n        \"Insert column to the left\": \"Вставити стовпець ліворуч\",\n        \"Detect Duplicates in...\": \"Виявлено дублікатів у...\",\n        \"UUID\": \"UUID\",\n        \"Shortcuts\": \"Ярлики\",\n        \"Show hidden columns\": \"Показати приховані стовпці\",\n        \"Created At\": \"Дата створення\",\n        \"Authorship\": \"Авторство\",\n        \"Last Updated By\": \"Автор останньої зміни\",\n        \"Hidden Columns\": \"Приховані стовпці\",\n        \"Lookups\": \"Пошукові запити\",\n        \"No reference columns.\": \"Немає довідкових стовпців.\",\n        \"Apply on record changes\": \"Застосовувати при зміні записів\",\n        \"Duplicate in {{- label}}\": \"Дублікат у {{- label}}\\\"\",\n        \"Created By\": \"Автор створення\",\n        \"Last Updated At\": \"Дата останньої зміни\",\n        \"Apply to new records\": \"Застосувати до нових записів\",\n        \"Search columns\": \"Шукати стовпці\",\n        \"Timestamp\": \"Часова мітка\",\n        \"no reference column\": \"немає довідкових стовпців\",\n        \"Adding UUID column\": \"Додавання стовпця UUID\",\n        \"Adding duplicates column\": \"Додавання дублікатів стовпця\",\n        \"Add column with type\": \"Додати стовпець із типом\",\n        \"Add formula column\": \"Додати стовпець формули\",\n        \"Created at\": \"Створено в\",\n        \"Created by\": \"Створено\",\n        \"Detect duplicates in...\": \"Виявлення дублікатів у...\",\n        \"Last updated at\": \"Останнє оновлення о\",\n        \"Last updated by\": \"Останнє оновлення\",\n        \"Any\": \"Будь-який\",\n        \"Numeric\": \"Числовий\",\n        \"Text\": \"Текст\",\n        \"Integer\": \"Ціле число\",\n        \"Toggle\": \"Перемикач\",\n        \"Date\": \"Дата\",\n        \"DateTime\": \"Дата та час\",\n        \"Choice\": \"Вибір\",\n        \"Choice List\": \"Список вибору\",\n        \"Reference\": \"Довідка\",\n        \"Reference List\": \"Список літератури\",\n        \"Attachment\": \"Вкладення\"\n    },\n    \"ThemeConfig\": {\n        \"Switch appearance automatically to match system\": \"Автоматично змінювати оформлення відповідно до системи\",\n        \"Appearance \": \"Оформлення \"\n    },\n    \"HomeIntro\": {\n        \"Create empty document\": \"Створити пустий документ\",\n        \"Get started by creating your first Grist document.\": \"Почніть із створення свого першого документа Grist.\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"Почніть роботу, запросивши свою команду та створивши свій перший документ Grist.\",\n        \"Visit our {{link}} to learn more.\": \"Відвідайте наш сайт {{link}}, щоб дізнатися більше.\",\n        \"Any documents created in this site will appear here.\": \"Тут з'являться будь-які документи, створені на цьому сайті.\",\n        \"Browse Templates\": \"Переглянути шаблони\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"Почніть із вивчення шаблонів або створення свого першого документа Grist.\",\n        \"Help Center\": \"Центр допомоги\",\n        \"Import document\": \"Імпортувати документ\",\n        \"Interested in using Grist outside of your team? Visit your free \": \"Ви зацікавлені у використанні Grist за межами вашої команди? Відвідайте свій безкоштовний \",\n        \"Invite Team Members\": \"Запросити членів команди\",\n        \"Sign up\": \"Зареєструватися\",\n        \"Sprouts Program\": \"Тренінг програма\",\n        \"This workspace is empty.\": \"Цей робочий простір порожній.\",\n        \"Welcome to Grist!\": \"Ласкаво просимо до Grist!\",\n        \"Welcome to Grist, {{name}}!\": \"Ласкаво просимо до Grist, {{name}}!\",\n        \"Welcome to {{orgName}}\": \"Ласкаво просимо до {{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Ви маєте доступ до цього сайту лише для читання. Наразі документів немає.\",\n        \"personal site\": \"особистий сайт\",\n        \"{{signUp}} to save your work. \": \"{{signUp}}, щоб зберегти вашу роботу. \",\n        \"Welcome to Grist, {{- name}}!\": \"Ласкаво просимо до Grist, {{- name}}!\",\n        \"Visit our {{link}} to learn more about Grist.\": \"Відвідайте наш {{link}}, щоб дізнатися більше про Grist.\",\n        \"Welcome to {{- orgName}}\": \"Ласкаво просимо до {{- orgName}}\",\n        \"Sign in\": \"Увійти в систему\",\n        \"To use Grist, please either sign up or sign in.\": \"Щоб користуватися Grist, будь ласка, зареєструйтеся або увійдіть.\",\n        \"Learn more in our {{helpCenterLink}}.\": \"Дізнайтеся більше в нашому {{helpCenterLink}}.\",\n        \"Only show documents\": \"Показувати лише документи\"\n    },\n    \"NotifyUI\": {\n        \"Go to your free personal site\": \"Перейдіть на свій безкоштовний персональний сайт\",\n        \"Ask for help\": \"Зверніться за допомогою\",\n        \"Cannot find personal site, sorry!\": \"На жаль, персональний сайт не знайдено!\",\n        \"Give feedback\": \"Залишити відгук\",\n        \"No notifications\": \"Немає повідомлень\",\n        \"Notifications\": \"Повідомлення\",\n        \"Renew\": \"Поновити\",\n        \"Report a problem\": \"Повідомити про проблему\",\n        \"Upgrade Plan\": \"Змінити тарифний план\",\n        \"Manage billing\": \"Управління рахунком\"\n    },\n    \"ViewSectionMenu\": {\n        \"Revert\": \"Повернутися\",\n        \"FILTER\": \"ФІЛЬТР\",\n        \"SORT\": \"СОРТУВАТИ\",\n        \"Save\": \"Зберегти\",\n        \"Update Sort&Filter settings\": \"Оновити параметри сортування та фільтрування\",\n        \"(customized)\": \"(індивідуальний)\",\n        \"(empty)\": \"(порожній)\",\n        \"(modified)\": \"(змінений)\",\n        \"Custom options\": \"Розширені параметри\",\n        \"Sort and filter\": \"Сортування та фільтрування\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Clear\": \"Очистити\",\n        \"Cannot drop items into Hidden Fields\": \"Неможливо перенести елементи у приховані поля\",\n        \"Hidden Fields cannot be reordered\": \"Приховані поля не можна перевпорядкувати\",\n        \"Select all\": \"Виділити всі\",\n        \"Show {{label}}\": \"Показати {{label}}\",\n        \"Visible {{label}}\": \"Видимі {{label}}\",\n        \"Hide {{label}}\": \"Приховати {{label}}\",\n        \"Hidden {{label}}\": \"Приховані {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Приховати {{label}} (пакетний режим)\",\n        \"Show {{label}} (batch mode)\": \"Показати {{label}} (пакетний режим)\"\n    },\n    \"TriggerFormulas\": {\n        \"Apply to new records\": \"Застосувати до нових записів\",\n        \"Apply on record changes\": \"Застосувати до змінених записів\",\n        \"Any field\": \"Будь-яке поле\",\n        \"Apply on changes to:\": \"Застосовувати при змінах в:\",\n        \"Cancel\": \"Відмінити\",\n        \"Close\": \"Закрити\",\n        \"Current field \": \"Поточне поле \",\n        \"OK\": \"ОК\"\n    },\n    \"TypeTransformation\": {\n        \"Update formula (Shift+Enter)\": \"Оновити формулу (Shift+Enter)\",\n        \"Apply\": \"Застосувати\",\n        \"Cancel\": \"Відмінити\",\n        \"Preview\": \"Попередній перегляд\",\n        \"Revise\": \"Переглянути'\"\n    },\n    \"CodeEditorPanel\": {\n        \"Code View is available only when you have full document access.\": \"Перегляд коду доступний тільки при наявності повного доступу до документа.\",\n        \"Access denied\": \"Доступ заборонено\"\n    },\n    \"ChartView\": {\n        \"Pick a column\": \"Виберіть стовпець\",\n        \"selected new group data columns\": \"вибрана нова група стовпців даних\",\n        \"Create separate series for each value of the selected column.\": \"Створіть окремі серії для кожного значення вибраного стовпця.\",\n        \"Toggle chart aggregation\": \"Перемикання створення діаграм\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"За кожною серією Y слідує серія для довжини стовпців помилок.\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"За кожною серією Y слідують дві серії - для верхніх та нижніх стовпців помилок.\",\n        \"LABEL\": \"Назва\",\n        \"Bar chart\": \"Діаграма стовпчиків\",\n        \"Pie chart\": \"Графік \\\"пирога\\\"\",\n        \"Donut chart\": \"Діаграма \\\"Пончика\\\"\",\n        \"Area chart\": \"Діаграма області\",\n        \"Line chart\": \"Діаграма лінії\",\n        \"Scatter plot\": \"Діаграма розсіювання\",\n        \"Kaplan-Meier plot\": \"Діаграма Каплана-Майєра\",\n        \"Split series\": \"Розділення серій\",\n        \"Invert Y-axis\": \"Інвертувати вісь Y\",\n        \"Orientation\": \"Орієнтація\",\n        \"Vertical\": \"Вертикальний\",\n        \"Horizontal\": \"Горизонтальний\",\n        \"Log scale Y-axis\": \"Шкала журналу Y-ось\",\n        \"Hole size\": \"Розмір отвору\",\n        \"Show total\": \"Показати все\",\n        \"Text size\": \"Розмір тексту\",\n        \"Connect gaps\": \"З'єднати щілини\",\n        \"Show markers\": \"Показати маркери\",\n        \"Stack series\": \"Укласти серії\",\n        \"Error bars\": \"Смуги помилок\",\n        \"None\": \"Нічого\",\n        \"Symmetric\": \"Симетричні\",\n        \"Above+Below\": \"Вище+нижче\",\n        \"Split Series\": \"Розділити серії\",\n        \"X-AXIS\": \"Вісь X\",\n        \"Aggregate values\": \"Сукупні значення\",\n        \"SERIES\": \"Серії\",\n        \"Add series\": \"Додати серії\",\n        \"non-numeric columns are not shown\": \"нецифрові стовпці не показані\",\n        \"non-numeric column is not shown\": \"нецифровий стовпець не показан\",\n        \"selected new x-axis\": \"вибрана нова вісь x\",\n        \"Remove\": \"Прибрати\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"Все\",\n        \"Max\": \"Максимум\",\n        \"Search\": \"Пошук\",\n        \"Min\": \"Мінімум\",\n        \"All except\": \"Все, крім\",\n        \"All shown\": \"Все показане\",\n        \"Filter by Range\": \"Фільтрувати по діапазону\",\n        \"Start\": \"Початок\",\n        \"End\": \"Кінець\",\n        \"Other Matching\": \"Інші співпадіння\",\n        \"Other Non-Matching\": \"Інші неспівпадіння\",\n        \"Future values\": \"Майбутні значення\",\n        \"None\": \"Ні\",\n        \"Other values\": \"Інші значення\",\n        \"Others\": \"Інші\",\n        \"Search values\": \"Пошук значень\",\n        \"No matching values\": \"Немає співпадаючих значень\",\n        \"Clear search\": \"Очистити пошук\",\n        \"Pin filter\": \"Фільтр Pin\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"Сортувати за алфавітом (поточне: відсортовано за кількістю входжень)\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"Сортувати за кількістю входжень (поточне: відсортовано в алфавітному порядку)\",\n        \"Unpin filter\": \"Відкріпити фільтр\"\n    },\n    \"CustomSectionConfig\": {\n        \"Pick a {{columnType}} column\": \"Виберіть {{columnType}} стовпець\",\n        \"Read selected table\": \"Прочитати обрану таблицю\",\n        \" (optional)\": \" (опціонально)\",\n        \"Add\": \"Додати\",\n        \"Enter Custom URL\": \"Введіть користувацький URL\",\n        \"Full document access\": \"Повний доступ до документа\",\n        \"Learn more about custom widgets\": \"Дізнайтеся більше про розширені віджети\",\n        \"No document access\": \"Немає доступу до документа\",\n        \"Open configuration\": \"Відкрита конфігурація\",\n        \"Pick a column\": \"Виберіть стовпець\",\n        \"Select Custom Widget\": \"Виберіть віджет користувача\",\n        \"Widget needs {{fullAccess}} to this document.\": \"Віджету необхідний {{fullAccess}} до цього документа.\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} не-{{columnType}} стовпець не відображається\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} не-{{columnType}} стовпці не відображаються\",\n        \"Widget does not require any permissions.\": \"Віджет не вимагає ніяких дозволів.\",\n        \"Widget needs to {{read}} the current table.\": \"Віджету необхідно {{read}} поточну таблицю.\",\n        \"No {{columnType}} columns in table.\": \"У таблиці немає стовпців типу {{columnType}}.\",\n        \"Clear selection\": \"Очистити вибір\",\n        \"ACCESS LEVEL\": \"РІВЕНЬ ДОСТУПУ\",\n        \"Accept\": \"Прийняти\",\n        \"Custom URL\": \"Користувацький URL\",\n        \"Developer:\": \"Розробник:\",\n        \"Last updated:\": \"Востаннє оновлено:\",\n        \"Missing description and author information.\": \"Відсутній опис та інформація про автора.\",\n        \"Reject\": \"Відхилити'\",\n        \"Widget\": \"Віджет\",\n        \"Change custom widget\": \"Змінити власний віджет\"\n    },\n    \"ACUserManager\": {\n        \"Invite new member\": \"Запросити нового користувача\",\n        \"Enter email address\": \"Введіть електронну адресу\",\n        \"We'll email an invite to {{email}}\": \"Ми надішлемо запрошення на адресу {{email}}\"\n    },\n    \"AccessRules\": {\n        \"Add column rule\": \"Додати правило стовпця\",\n        \"Add table rules\": \"Додати правила таблиці\",\n        \"Add user attributes\": \"Додати атрибути користувача\",\n        \"Attribute name\": \"Назва атрибута\",\n        \"Checking...\": \"Перевірка…\",\n        \"Attribute to Look Up\": \"Атрибут для пошуку\",\n        \"Condition\": \"Умова\",\n        \"Default rules\": \"Правила за замовчуванням\",\n        \"Delete table rules\": \"Видалити правила таблиці\",\n        \"Enter Condition\": \"Введіть умову\",\n        \"Everyone Else\": \"Всі інші\",\n        \"Invalid\": \"Недійсний\",\n        \"Everyone\": \"Всі\",\n        \"Permission to access the document in full when needed\": \"Дозвіл на доступ до документа в повному обсязі, коли необхідно\",\n        \"Remove {{- tableId }} rules\": \"Видалити {{- tableId }} правила\",\n        \"Remove {{- name }} user attribute\": \"Видалити {{- name }} атрибут користувача\",\n        \"Reset\": \"Скинути\",\n        \"Permission to view Access Rules\": \"Дозвіл на перегляд правил доступу\",\n        \"Rules for table \": \"Правила для таблиці \",\n        \"Save\": \"Зберегти\",\n        \"Lookup Table\": \"Пошукова таблиця\",\n        \"Lookup Column\": \"Стовпець пошуку\",\n        \"Saved\": \"Збережено\",\n        \"Special rules\": \"Особливі правила\",\n        \"Type message to display when this rule blocks an action…\": \"Введіть повідомлення…\",\n        \"User Attributes\": \"Атрибути користувача\",\n        \"View as\": \"Переглянути як\",\n        \"Permission to edit document structure\": \"Дозвіл на редагування структури документа\",\n        \"Add Default Rule\": \"Додати правило за замовчуванням\",\n        \"Allow everyone to view Access Rules.\": \"Дозволити всім переглядати правила доступу.\",\n        \"Permissions\": \"Дозволи\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"Видалити стовпець {{- colId }} з правил {{- tableId }}\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"При додаванні правил таблиці, автоматично додавати правило для надання ВЛАСНИКУ повного доступу.\",\n        \"Seed rules\": \"Успадковані правила\",\n        \"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.\": \"Дозволити кожному скопіювати весь документ або переглянути його повністю в режимі створення нових копій.\\nКорисно для прикладів і шаблонів, але не для конфіденційних даних.\",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Дозволити редакторам редагувати структуру (наприклад, змінювати та видаляти таблиці, стовпці, макети), а також писати формули, які надають доступ до всіх даних незалежно від обмежень на читання.\",\n        \"This default should be changed if editors' access is to be limited. \": \"Цей параметр слід змінити, якщо ви хочете обмежити доступ редакторів. \",\n        \"Add table-wide rule\": \"Додати правило таблиці\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"Правила доступу змінилися. Натисніть кнопку «Скинути», щоб скасувати зміни та оновити правила.\",\n        \"All\": \"Усі\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"Стовпець {{colId}} з'являється в кількох правилах для таблиці {{tableId}}, які можуть залежати від порядку. Спробуйте розділити правила по-іншому?\",\n        \"Columns\": \"Колонки\",\n        \"Condition cannot be blank\": \"Умова не може бути порожньою\",\n        \"Default resource missing in resource map\": \"На карті ресурсів відсутній ресурс за замовчуванням\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"Недійсні стовпці в таблиці {{tableId}}: {{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"Недійсна таблиця: {{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"Недійсне правило атрибута користувача: {{prop}} має бути встановлено\",\n        \"Invalid user attribute to look up\": \"Недійсний атрибут користувача для пошуку\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"У правилі для стовпців таблиці немає стовпців {{tableId}}\",\n        \"Not a valid user attribute\": \"Недійсний атрибут користувача\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"Ресурс відсутній на карті ресурсів: {{resourceKey}}\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"Спроба додати TableRules для існуючої таблиці {{tableId}}\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"Використовуйте простий атрибут user.LinkKey, наприклад, user.LinkKey.something\",\n        \"hidden\": \"прихований\",\n        \"## Access Rules\\n\\nBasic access to this document is controlled using the 'Manage Users' option in the 'Share' menu, where you can assign collaborator roles such as Owner, Editor, or Viewer.\\n\\nFor more granular control, you can create Access Rules to limit who can view or edit specific\\ntables, columns, or rows — useful for sensitive data or role-based permissions.\\n[Learn more.]({{helpAccessRules}})\": \"## Правила доступу\\n\\nБазовий доступ до цього документа контролюється за допомогою опції «Керування користувачами» в меню «Спільний доступ», де ви можете призначити ролі співавторів, такі як Власник, Редактор або Переглядач.\\n\\nДля більш детального контролю ви можете створити Правила доступу, щоб обмежити, хто може переглядати або редагувати певні\\nтаблиці, стовпці або рядки — це корисно для конфіденційних даних або дозволів на основі ролей.\\n[Дізнатися більше.]({{helpAccessRules}})\",\n        \"## Access Rules\\n\\nYou don't have permission to view or edit access rules for this document.\": \"## Правила доступу\\n\\nУ вас немає дозволу на перегляд або редагування правил доступу для цього документа.\",\n        \"**Special rules** (expand each rule to customize who it applies to)\": \"**Спеціальні правила** (розгорніть кожне правило, щоб налаштувати, до кого воно застосовується)\",\n        \"After disabling Access Rules, Editors will be able to change the structure of the document and edit formulas. Editors and Viewers will be able to see all data in the document, as well as copy or download it.\": \"Після вимкнення правил доступу редактори зможуть змінювати структуру документа та редагувати формули. Редактори та читачі зможуть бачити всі дані в документі, а також копіювати або завантажувати їх.\",\n        \"After enabling Access Rules, Editors will no longer be able to change the structure of the\\ndocument or edit formulas. Only Owners will be able to copy or download the document.\\n\\nThese settings can be changed under 'Special rules'.\": \"Після ввімкнення правил доступу редактори більше не зможуть змінювати структуру документа або\\nредагувати формули. Тільки власники зможуть копіювати або завантажувати документ.\\n\\nЦі налаштування можна змінити в розділі «Спеціальні правила».\",\n        \"Allow Editors to edit structure (e.g. modify and delete tables, columns, and layouts) and write formulas.  Important: if checked, Editors will be able to edit formulas, which can access all data, regardless of table and column access rules!\": \"Дозволити редакторам редагувати структуру (наприклад, змінювати та видаляти таблиці, стовпці та макети) та створювати формули. Важливо: якщо позначено, редактори зможуть редагувати формули, які матимуть доступ до всіх даних, незалежно від правил доступу до таблиць та стовпців!\",\n        \"Allow everyone to view access rules.\": \"Дозволити всім переглядати правила доступу.\",\n        \"Circumvent all read restrictions and allow everyone to copy the entire document, or view it in full in fiddle mode. Only use for for examples and templates, not for documents with sensitive data.\": \"Обійти всі обмеження на читання та дозволити всім копіювати весь документ або переглядати його повністю в режимі скрипки. Використовуйте лише для прикладів і шаблонів, а не для документів із конфіденційними даними.\",\n        \"Continue\": \"Продовжити\",\n        \"Disable Access Rules\": \"Вимкнути правила доступу\",\n        \"Disable and save\": \"Вимкнути та зберегти\",\n        \"Enable Access Rules\": \"Увімкнути правила доступу\",\n        \"Permission to access the document in full by all users\": \"Дозвіл на повний доступ до документа всім користувачам\",\n        \"Permission to access the document in full by unrestricted users\": \"Дозвіл на повний доступ до документа для користувачів без обмежень\",\n        \"Restrict non-Owners from copying or downloading the full document. Note: this only affects users without read restrictions, since others will be restricted regardless of this setting.\": \"Обмежити копіювання або завантаження повного документа користувачами, які не є власниками. Примітка: це стосується лише користувачів без обмежень на читання, оскільки інші будуть обмежені незалежно від цього налаштування.\",\n        \"Special rules for templates\": \"Спеціальні правила для шаблонів\",\n        \"This options should be off if Editors' access is to be limited. \": \"Цей параметр слід вимкнути, якщо доступ редакторів має бути обмежений. \"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"API Ключ\",\n        \"Account settings\": \"Налаштування облікового запису\",\n        \"Allow signing in to this account with Google\": \"Дозволити вхід в обліковий запис за допомогою Google\",\n        \"Change password\": \"Змінити пароль\",\n        \"Email\": \"Електронна пошта\",\n        \"Name\": \"Ім'я\",\n        \"Login method\": \"Спосіб входу в систему\",\n        \"Names only allow letters, numbers and certain special characters\": \"Імена мають містити тільки букви, цифри та певні спеціальні символи\",\n        \"Password & security\": \"Пароль та безпека\",\n        \"Save\": \"Зберегти\",\n        \"Theme\": \"Тема\",\n        \"Two-factor authentication\": \"Двофакторна аутентифікація\",\n        \"Language\": \"Мова\",\n        \"Edit\": \"Редагувати\",\n        \"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.\": \"Двофакторна аутентифікація - це додатковий рівень безпеки для вашого облікового запису Grist. Завдяки чому тільки ви будете мати доступ до вашого облікового запису, навіть якщо хтось інший дізнається ваш пароль.\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"Відомості про доступ\",\n        \"Accounts\": \"Облікові записи\",\n        \"Add account\": \"Додати обліковий запис\",\n        \"Manage team\": \"Керувати командою\",\n        \"Pricing\": \"Тарифи\",\n        \"Profile settings\": \"Налаштування профілю\",\n        \"Sign out\": \"Вийти з системи\",\n        \"Sign in\": \"Увійти в систему\",\n        \"Toggle Mobile Mode\": \"Переключити в мобільний режим\",\n        \"Document settings\": \"Параметри документа\",\n        \"Switch Accounts\": \"Змінити обліковий запис\",\n        \"Activation\": \"Активація\",\n        \"Support Grist\": \"Підтримати Grist\",\n        \"Upgrade Plan\": \"Оновити План\",\n        \"Use This Template\": \"Використати цей шаблон\",\n        \"Billing account\": \"Рахунок оплати\",\n        \"Sign up\": \"Зареєструватися\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"Переглянути як\",\n        \"Users from table\": \"Користувачі з таблиці\",\n        \"Example Users\": \"Приклади користувачів\"\n    },\n    \"ActionLog\": {\n        \"Action Log failed to load\": \"Не вдалося завантажити журнал дій\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Таблиця {{tableId}} згодом була видалена під час події #{{actionNum}}\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"Ця строка згодом була видалена під час події {{action.actionNum}}\",\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Колонка {{colId}} згодом була видалена під час події #{{action.actionNum}}\",\n        \"All tables\": \"Всі таблиці\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"Колонку {{colId}} згодом було видалено в дії №{{actionNum}}\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"Цей рядок згодом було вилучено в рамках дії {{actionNum}}\",\n        \"History blocked because of access rules.\": \"Історію заблоковано через правила доступу.\"\n    },\n    \"ApiKey\": {\n        \"Click to show\": \"Натисніть, щоб показати\",\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"Створивши ключ API, ви зможете здійснювати запити API для власного облікового запису.\",\n        \"Create\": \"Створити\",\n        \"Remove\": \"Вилучити\",\n        \"Remove API Key\": \"Видалити ключ API\",\n        \"This API key can be used to access this account anonymously via the API.\": \"Цей ключ API може використовуватись для анонімного доступу до цього облікового запису через API.\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"Цей ключ API може використовуватись для доступу до вашого облікового запису через API. Не передавайте свій ключ API іншим особам.\",\n        \"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. Всі майбутні запити, які використовують цей ключ API, будуть відхилені. Ви впевнені, що хочете його видалити?\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"Додати нове\"\n    },\n    \"App\": {\n        \"Description\": \"Опис\",\n        \"Key\": \"Ключ\",\n        \"Memory Error\": \"Помилка памʼяті\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"Перекладачі: будь ласка, перекладайте це тільки тоді, коли ваша мова буде готова для користувачів\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"Домашня сторінка\",\n        \"Legacy\": \"Застаріла версія\",\n        \"Personal Site\": \"Особистий сайт\",\n        \"Team Site\": \"Сайт команди\",\n        \"Grist Templates\": \"Шаблони від Grist\",\n        \"Billing account\": \"Розрахунковий рахунок\",\n        \"Manage team\": \"Керувати командою\",\n        \"{{ organizationName }} - Back to home\": \"{{ organizationName }} - Назад додому\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - Назад додому\"\n    },\n    \"CellContextMenu\": {\n        \"Clear cell\": \"Очистити клітинку\",\n        \"Clear values\": \"Очистити значення\",\n        \"Copy anchor link\": \"Скопіювати якірне посилання\",\n        \"Delete {{count}} columns_one\": \"Видалити стовпець\",\n        \"Delete {{count}} columns_other\": \"Видалити {{count}} стовпці\",\n        \"Delete {{count}} rows_one\": \"Видалити рядок\",\n        \"Delete {{count}} rows_other\": \"Видалити {{count}} рядок\",\n        \"Duplicate rows_one\": \"Дублювати рядок\",\n        \"Duplicate rows_other\": \"Дублювати рядки\",\n        \"Filter by this value\": \"Фільтрувати за цим значенням\",\n        \"Insert column to the left\": \"Вставити стовпець ліворуч\",\n        \"Insert column to the right\": \"Вставити стовпець праворуч\",\n        \"Insert row\": \"Вставити рядок\",\n        \"Insert row above\": \"Вставити рядок вище\",\n        \"Insert row below\": \"Вставити рядок нижче\",\n        \"Reset {{count}} columns_one\": \"Скинути стовпець\",\n        \"Reset {{count}} columns_other\": \"Скинути {{count}} стовпці\",\n        \"Reset {{count}} entire columns_one\": \"Скинути весь стовпець\",\n        \"Reset {{count}} entire columns_other\": \"Скинути {{count}} всі стовпці\",\n        \"Copy\": \"Копіювати\",\n        \"Comment\": \"Коментар\",\n        \"Cut\": \"Вирізати\",\n        \"Paste\": \"Вставити\",\n        \"Copy with headers\": \"Копіювати з заголовками\"\n    },\n    \"ColorSelect\": {\n        \"Apply\": \"Застосовувати\",\n        \"Cancel\": \"Відмінити\",\n        \"Default cell style\": \"Стиль комірки за замовчуванням\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"Натисніть для копіювання\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Видалити дані {{formattedTableName}} та видалити їх з усіх сторінок?\",\n        \"Duplicate table\": \"Дублювати таблицю\",\n        \"Raw Data Tables\": \"Таблиці необроблених даних\",\n        \"Table ID copied to clipboard\": \"Ідентифікатор таблиці скопійовано в буфер\",\n        \"You do not have edit access to this document\": \"У вас немає доступу до редагування цього документа\",\n        \"Edit record card\": \"Редагувати картку запису\",\n        \"Record Card\": \"Картка запису\",\n        \"Record Card Disabled\": \"Карта запису вимкнена\",\n        \"Remove table\": \"Вилучити таблицю\",\n        \"Rename table\": \"Перейменувати таблицю\",\n        \"{{action}} Record Card\": \"{{action}} Картка запису\"\n    },\n    \"DocHistory\": {\n        \"Activity\": \"Активність\",\n        \"Beta\": \"Бета\",\n        \"Compare to current\": \"Порівняти з поточним\",\n        \"Compare to previous\": \"Порівняти з попереднім\",\n        \"Open snapshot\": \"Відкрити знімок\",\n        \"Snapshots\": \"Знімки\",\n        \"Snapshots are unavailable.\": \"Знімки недоступні.\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"Доступ до знімків для документів з правилами доступу мають тільки власники.\"\n    },\n    \"DocMenu\": {\n        \"Access Details\": \"Параметри доступу\",\n        \"All documents\": \"Всі документи\",\n        \"By Date Modified\": \"По даті редагування\",\n        \"By Name\": \"По імені\",\n        \"Current workspace\": \"Поточний робочий простір\",\n        \"Delete\": \"Видалити\",\n        \"Delete Forever\": \"Видалити назавжди\",\n        \"Delete {{name}}\": \"Видалити {{name}}\",\n        \"Discover More Templates\": \"Знайти більше шаблонів\",\n        \"Document will be moved to Trash.\": \"Документ буде переміщено в кошик.\",\n        \"Document will be permanently deleted.\": \"Документ буде видалений назавжди.\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Документи зберігаються в кошику протягом 30 днів, після чого видаляються назавжди.\",\n        \"Edited {{at}}\": \"Відредаговано {{at}}\",\n        \"Examples & Templates\": \"Приклади та шаблони\",\n        \"Examples and Templates\": \"Приклади та шаблони\",\n        \"Featured\": \"Рекомендовані\",\n        \"Manage users\": \"Керувати користувачами\",\n        \"More Examples and Templates\": \"Більше прикладів та шаблонів\",\n        \"Move\": \"Перемістити\",\n        \"Move {{name}} to workspace\": \"Перемістити {{name}} в робочий простір\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"Видалити назавжди \\\"{{name}}\\\"?\",\n        \"Pin Document\": \"Закріпити документ\",\n        \"Pinned Documents\": \"Закріплені документи\",\n        \"Remove\": \"Вилучити\",\n        \"Rename\": \"Перейменувати\",\n        \"Requires edit permissions\": \"Потрібні дозволи для редагування\",\n        \"Restore\": \"Відновити\",\n        \"This service is not available right now\": \"Зараз ця послуга недоступна\",\n        \"To restore this document, restore the workspace first.\": \"Щоб відновити цей документ, спочатку відновіть робочий простір.\",\n        \"Trash\": \"Кошик\",\n        \"Trash is empty.\": \"Кошик порожній.\",\n        \"Unpin Document\": \"Відкріпити документ\",\n        \"You are on your personal site. You also have access to the following sites:\": \"Ви знаходитесь на своєму особистому сайті. Також ви маєте доступ до наступних сайтів:\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"Ви можете назавжди видалити робочий простір, якщо в ньому немає документів.\",\n        \"(The organization needs a paid plan)\": \"(Організації потрібен платний тариф)\",\n        \"Deleted {{at}}\": \"Видалити {{at}}\",\n        \"Other Sites\": \"Інші сайти\",\n        \"Workspace not found\": \"Робочий простір не знайдений\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Ви знаходитесь на сайті {{siteName}}. Також ви маєте доступ до наступних сайтів:\",\n        \"Any documents created in this site will appear here.\": \"Будь-які документи, створені на цьому сайті, з'являться тут.\",\n        \"Create my first document\": \"Створіть мій перший документ\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"Ви маєте доступ лише для читання на цей сайт. Наразі документів немає.\",\n        \"personal site\": \"персональний сайт\",\n        \"Grid view\": \"Сітковий вигляд\",\n        \"List view\": \"Список\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"Додати порожню таблицю\",\n        \"Add page\": \"Додати сторінку\",\n        \"Add widget to page\": \"Додати віджет до сторінки\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"Власники документа можуть спробувати відновити документ. [{{error}}]\",\n        \"Error accessing document\": \"Помилка доступу до документа\",\n        \"Reload\": \"Перезавантаження\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"На жаль, у доступі до цього документа було відмовлено. [{{error}}]\",\n        \"You do not have edit access to this document\": \"У вас немає доступу для редагування цього документа\",\n        \"Enter recovery mode\": \"Увійти в режим відновлення\",\n        \"You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]\": \"Ви можете спробувати перезавантажити документ або скористатися режимом відновлення. У режимі відновлення документ стане повністю доступним для власників але недоступним для інших користувачів. Також будуть відключені формули. [{{error}}]\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"Будь ласка, перезавантажте документ, і якщо помилка не зникає, зв’яжіться з власниками документа, щоб спробувати його відновити. [{{error}}]\"\n    },\n    \"DocumentSettings\": {\n        \"Time Zone:\": \"Часовий пояс:\",\n        \"Currency:\": \"Валюта:\",\n        \"Document settings\": \"Параметри документу\",\n        \"Local currency ({{currency}})\": \"Місцева валюта ({{currency}})\",\n        \"Locale:\": \"Регіон:\",\n        \"Save\": \"Зберегти\",\n        \"Save and Reload\": \"Зберегти та перезавантажити\",\n        \"This document's ID (for API use):\": \"Ідентифікатор цього документа (для використання API):\",\n        \"API\": \"API\",\n        \"Document ID copied to clipboard\": \"Ідентифікатор документа скопійований у буфер\",\n        \"Ok\": \"ОК\",\n        \"Engine (experimental {{span}} change at own risk):\": \"Обчислювальна система (експериментальна версія {{span}} змінюйте на власний ризик):\",\n        \"Manage Webhooks\": \"Керування веб-хуками\",\n        \"Webhooks\": \"Веб-хуки\",\n        \"API console\": \"Консоль API'\",\n        \"API URL copied to clipboard\": \"URL API скопійовано в буфер обміну\",\n        \"API documentation.\": \"Документація API.\",\n        \"Base doc URL: {{docApiUrl}}\": \"URL-адреса базового документа: {{docApiUrl}}\",\n        \"Coming soon\": \"Скоро\",\n        \"Copy to clipboard\": \"Копіювати в буфер обміну\",\n        \"Currency\": \"Валюта\",\n        \"Data engine\": \"Механізм даних\",\n        \"Default for DateTime columns\": \"Типові для стовпців DateTime\",\n        \"Document ID\": \"Ідентифікатор документа\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"Ідентифікатор документа для використання щоразу, коли REST API вимагає {{docId}}. Див. {{apiURL}}\",\n        \"Find slow formulas\": \"Знайти повільні формули\",\n        \"For currency columns\": \"Для валютних стовпчиків\",\n        \"For number and date formats\": \"Для форматів номерів і дат\",\n        \"Formula times\": \"Часи формули\",\n        \"Hard reset of data engine\": \"Жорстке скидання механізму даних\",\n        \"ID for API use\": \"ID для використання API\",\n        \"Locale\": \"Локальний\",\n        \"Manage webhooks\": \"Керування вебхуками\",\n        \"Notify other services on doc changes\": \"Повідомляти інші служби про зміни в документах\",\n        \"Python\": \"Пайтон\",\n        \"Python version used\": \"Використана версія Python\",\n        \"Reload\": \"Перезавантаження\",\n        \"Time zone\": \"Часовий пояс\",\n        \"Try API calls from the browser\": \"Спробуйте виклики API з браузера\",\n        \"python2 (legacy)\": \"python2 (legacy версія)\",\n        \"python3 (recommended)\": \"python3 (рекомендовано)\",\n        \"Cancel\": \"Скасувати\",\n        \"Force reload the document while timing formulas, and show the result.\": \"Примусово перезавантажте документ під час визначення часу формул і покажіть результат.\",\n        \"Formula timer\": \"Таймер формули\",\n        \"Reload data engine\": \"Перезавантажити механізм даних\",\n        \"Reload data engine?\": \"Перезавантажити механізм даних?\",\n        \"Start timing\": \"Початок часу\",\n        \"Stop timing...\": \"Зупинити час...\",\n        \"Time reload\": \"Перезавантаження часу\",\n        \"Timing is on\": \"Час увімкнено\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"Ви можете внести зміни в документ, а потім припинити час, щоб побачити результати.\",\n        \"Only available to document editors\": \"Доступно лише для редакторів документів\",\n        \"Only available to document owners\": \"Доступно лише власникам документів\",\n        \"Template mode\": \"Режим шаблону\",\n        \"Change document type\": \"Змінити тип документа\",\n        \"Edit\": \"Редагувати\",\n        \"Change nature of document\": \"Змінити характер документа\",\n        \"Regular document\": \"Звичайний документ\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"Звичайна поведінка документа. Усі користувачі працюють над однією копією документа.\",\n        \"Regular\": \"Звичайний\",\n        \"Template\": \"Шаблон\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"Документ автоматично відкривається в {{fiddleModeDocUrl}}. Будь-хто може редагувати його, що призведе до створення нової незбереженої копії.\",\n        \"fiddle mode\": \"режим скрипки\",\n        \"Tutorial\": \"Підручник\",\n        \"Document automatically opens as a user-specific copy.\": \"Документ автоматично відкривається як копія, призначена для користувача.\",\n        \"Confirm change\": \"Підтвердити зміну\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"Це призведе до жорсткого перезавантаження механізму обробки даних. Це може допомогти, якщо механізм обробки даних застряг у нескінченному циклі, безкінечно обробляє останні зміни або стався збій. Дані не будуть втрачені, окрім, можливо, дій, що очікують на виконання.\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"Після початку відліку часу Grist виміряє час, необхідний для оцінки кожної формули. Це дозволяє діагностувати, які формули відповідають за низьку продуктивність під час першого відкриття документа або коли документ реагує на зміни.\",\n        \"**Some existing attachments are still external**.\": \"**Деякі існуючі вкладення все ще є зовнішніми**.\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**Деякі існуючі вкладення все ще є внутрішніми** (зберігаються у файлі SQLite).\",\n        \"Attachment storage\": \"Зберігання вкладень\",\n        \"Being transfer\": \"Передача\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"Натисніть «Почати передачу», щоб перенести їх на зовнішній носій.\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"Натисніть кнопку «Почати передачу», щоб перенести їх до внутрішньої пам’яті (зберігається у файлі документа SQLite).\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"Щойно завантажені вкладення будуть розміщені в зовнішній пам’яті.\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"Нові завантажені вкладення будуть розміщені у внутрішній пам’яті.\",\n        \"No external stores available\": \"Немає доступних зовнішніх магазинів\",\n        \"Preferred storage for this document\": \"Бажане сховище для цього документа\",\n        \"Start transfer\": \"Розпочати переказ\",\n        \"External\": \"Зовнішній\",\n        \"Internal\": \"Внутрішній\",\n        \"Transfer in progress\": \"Триває перенесення\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**Деякі існуючі вкладення все ще є [external]({{externalLink}})**.\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**Деякі існуючі вкладення все ще [внутрішні]({{internalLink}})** (зберігаються у файлі SQLite).\",\n        \"[Learn more.]({{learnLink}})\": \"[Дізнатися більше.]({{learnLink}})\",\n        \"Upload\": \"Вавантажити\",\n        \"Upload missing attachments\": \"Завантажити відсутні вкладення\",\n        \"Uploading...\": \"Вавантажуємо...\",\n        \"Default\": \"За замовчуванням\",\n        \"Default, template, or tutorial\": \"За замовчуванням, шаблон або навчальний посібник\",\n        \"Document type\": \"Тип документа\",\n        \"Allow others to suggest changes\": \"Дозволити іншим пропонувати зміни\",\n        \"Enable suggestions\": \"Увімкнути пропозиції\",\n        \"Suggestions\": \"Пропозиції\",\n        \"experiment\": \"експеримент\"\n    },\n    \"DocTour\": {\n        \"No valid document tour\": \"Немає дійсного огляду документа\",\n        \"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.\": \"Не вдається створити огляд документа на основі даних цього документа. Переконайтеся, що існує таблиця з назвою GristDocTour зі стовпцями Title, Body, Placement і Location.\"\n    },\n    \"DocumentUsage\": {\n        \"Data size\": \"Розмір даних\",\n        \"For higher limits, \": \"Для збільшення лімітів, \",\n        \"Rows\": \"Рядки\",\n        \"Size of attachments\": \"Розмір вкладених файлів\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"Зв'яжіться з власником сайту, щоб оновити тариф та збільшити ліміти.\",\n        \"Usage\": \"Використання\",\n        \"start your 30-day free trial of the Pro plan.\": \"розпочніть свою 30-денну безкоштовну пробну версію тарифу Pro.\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"Статистика використання доступна лише користувачам, які мають повний доступ до даних документа.\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"Відновити останнє редагування\",\n        \"Undo discard\": \"Відмінити скасування\"\n    },\n    \"DuplicateTable\": {\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Замість дублювання таблиць, як правило, краще сегментувати дані, використовуючи пов'язані відображення. {{link}}\",\n        \"Name for new table\": \"Ім'я для нової таблиці\",\n        \"Copy all data in addition to the table structure.\": \"Скопіювати всі дані на додаток до структури таблиці.\",\n        \"Only the document default access rules will apply to the copy.\": \"До копії застосовуватимуться лише правила доступу до документа за замовчуванням.\"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"Додано новий пов’язаний розділ для перегляду {{viewName}}\",\n        \"Import from file\": \"Імпортувати з файлу\",\n        \"Saved linked section {{title}} in view {{name}}\": \"Збережений зв'язаний розділ {{title}}, що відображається в {{name}}\",\n        \"go to webhook settings\": \"перейти до налаштувань веб-хуків\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"Нові зміни тимчасово призупинено. Черга вебхуків переповнена. Перевірте налаштування вебхуків, видаліть недійсні вебхуки та очистіть чергу.\"\n    },\n    \"HomeLeftPane\": {\n        \"Delete\": \"Видалити\",\n        \"Create empty document\": \"Створити пустий документ\",\n        \"All documents\": \"Всі документи\",\n        \"Access Details\": \"Відомості про доступ\",\n        \"Manage users\": \"Керувати користувачами\",\n        \"Delete {{workspace}} and all included documents?\": \"Видалити {{workspace}} та всі включені документи?\",\n        \"Create workspace\": \"Створити робочій простір\",\n        \"Examples & Templates\": \"Шаблони\",\n        \"Import document\": \"Імпортувати документ\",\n        \"Trash\": \"Кошик\",\n        \"Workspace will be moved to Trash.\": \"Робочий простір буде переміщено до кошика.\",\n        \"Rename\": \"Перейменувати\",\n        \"Workspaces\": \"Робочій простір\",\n        \"Tutorial\": \"Туторіал\",\n        \"Terms of service\": \"Умови надання послуг\",\n        \"Grist Resources\": \"Ресурси Grist\",\n        \"context menu - {{- workspaceName }}\": \"контекстне меню - {{- workspaceName }}\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"Об'єднати рядки, які відповідають цим полям:\",\n        \"Update existing records\": \"Оновити існуючі записи\",\n        \"Select fields to match on\": \"Оберіть поля для зіставлення\",\n        \"Column mapping\": \"Співставлення колонок\",\n        \"Grist column\": \"Стовпець у Grist\",\n        \"{{count}} unmatched field_one\": \"{{count}} невідповідне поле\",\n        \"{{count}} unmatched field in import_one\": \"{{count}} невідповідне поле при імпорті\",\n        \"Revert\": \"Повернути\",\n        \"Skip Import\": \"Пропустити імпорт\",\n        \"{{count}} unmatched field_other\": \"{{count}} невідповідних полів\",\n        \"New Table\": \"Нова таблиця\",\n        \"Skip\": \"Пропустити\",\n        \"Column Mapping\": \"Співставлення колонок\",\n        \"Destination table\": \"Таблиця призначення\",\n        \"Skip Table on Import\": \"Пропустити таблицю при імпорті\",\n        \"Import from file\": \"Імпорт з файлу\",\n        \"{{count}} unmatched field in import_other\": \"{{count}} невідповідних полів при імпорті\",\n        \"Source column\": \"Вихідний стовпець\",\n        \"Import options\": \"Параметри імпорту\",\n        \"Cancel\": \"Скасувати\",\n        \"Import\": \"Імпорт\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"Центр допомоги\",\n        \"Accessibility\": \"Доступність\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"Завершити\",\n        \"Next\": \"Далі\",\n        \"Previous\": \"Попередній\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Відео-тур по Grist\",\n        \"Video Tour\": \"Відео-тур\",\n        \"YouTube video player\": \"Відеоплеєр YouTube\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"Додати на сторінку\",\n        \"Building {{- label}} widget\": \"Створення {{- label}} віджету\",\n        \"Group by\": \"Групувати за\",\n        \"Select data\": \"Виберіть дані\",\n        \"Select widget\": \"Виберіть віджет\",\n        \"New Table\": \"Нова таблиця\",\n        \"SELECT BY\": \"ВИБРАТИ ЗА\"\n    },\n    \"Pages\": {\n        \"Delete\": \"Видалити\",\n        \"Delete data and this page.\": \"Видалити дані та цю сторінку.\",\n        \"The following tables will no longer be visible_one\": \"Наступна таблиця більше не відображатиметься\",\n        \"The following tables will no longer be visible_other\": \"Наступні таблиці більше не відображатимуться\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"Зберегти дані та видалити сторінку. Таблиця залишиться доступною в {{rawDataLink}}\",\n        \"raw data page\": \"сторінка необроблених даних\",\n        \"Document pages\": \"Сторінки документа\"\n    },\n    \"PermissionsWidget\": {\n        \"Read only\": \"Тільки для читання\",\n        \"Allow all\": \"Дозволити все\",\n        \"Deny all\": \"Відхилити все\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"Помилка імпорту: \"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"Оновлення макета запису.\"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"Додати поле\",\n        \"Create new field\": \"Створити нове поле\",\n        \"Show field {{- label}}\": \"Показати поле {{- label}}\",\n        \"Save layout\": \"Зберегти макет\",\n        \"Cancel\": \"Відмінити\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"Додати стовпеця\",\n        \"No columns to add\": \"Немає стовпців для додавання\"\n    },\n    \"RightPanel\": {\n        \"CHART TYPE\": \"ТИП ДІАГРАМИ\",\n        \"COLUMN TYPE\": \"ТИП СТОВПЦЯ\",\n        \"CUSTOM\": \"НЕСТАНДАРТНИЙ\",\n        \"Change widget\": \"Змінити віджет\",\n        \"columns_one\": \"Стовпець\",\n        \"columns_other\": \"Стовпці\",\n        \"DATA TABLE\": \"ТАБЛИЦЯ ДАНИХ\",\n        \"DATA TABLE NAME\": \"ІМ'Я ТАБЛИЦІ ДАНИХ\",\n        \"Data\": \"Дані\",\n        \"Detach\": \"Від'єднати\",\n        \"Edit data selection\": \"Редагувати вибір даних\",\n        \"fields_one\": \"Поле\",\n        \"fields_other\": \"Поля\",\n        \"GROUPED BY\": \"ЗГРУПУВАНО ЗА\",\n        \"Row style\": \"Стиль рядка\",\n        \"SELECT BY\": \"ВИБРАТИ ЗА\",\n        \"SELECTOR FOR\": \"СЕЛЕКТОР ДЛЯ\",\n        \"SOURCE DATA\": \"ВИХІДНІ ДАНІ\",\n        \"Save\": \"Зберегти\",\n        \"Select widget\": \"Виберіть віджет\",\n        \"series_one\": \"Серії\",\n        \"series_other\": \"Серії\",\n        \"Sort & filter\": \"Порядок та фільтр\",\n        \"TRANSFORM\": \"ПЕРЕТВОРИТИ\",\n        \"Theme\": \"Тема\",\n        \"WIDGET TITLE\": \"НАЗВА ВІДЖЕТА\",\n        \"Widget\": \"Віджет\",\n        \"You do not have edit access to this document\": \"Ви не маєте права на редагування цього документа\",\n        \"Add referenced columns\": \"Додати стовпець за посиланням\",\n        \"Reset form\": \"Скинути форму\",\n        \"Configuration\": \"Конфігурація\",\n        \"Default field value\": \"Значення поля за замовчуванням\",\n        \"Display button\": \"Кнопка відображення\",\n        \"Enter text\": \"Введіть текст\",\n        \"Field rules\": \"Правил поля\",\n        \"Field title\": \"Назва поля\",\n        \"Hidden field\": \"Приховане поле\",\n        \"Layout\": \"Макет\",\n        \"Redirect automatically after submission\": \"Автоматичне перенаправлення після надсилання\",\n        \"Redirection\": \"Перенаправлення\",\n        \"Required field\": \"Обов'язкове поле\",\n        \"Submission\": \"Подання\",\n        \"Submit another response\": \"Надіслати ще одну відповідь\",\n        \"Submit button label\": \"Мітка кнопки «Надіслати»\",\n        \"Success text\": \"Текст про успіх\",\n        \"Table column name\": \"Назва стовпця таблиці\",\n        \"Enter redirect URL\": \"Введіть URL-адресу перенаправлення\",\n        \"No field selected\": \"Поле не вибрано\",\n        \"Select a field in the form widget to configure.\": \"Виберіть поле у віджеті форми для налаштування.\",\n        \"Submit\": \"Надіслати\",\n        \"Thank you! Your response has been recorded.\": \"Дякуємо! Вашу відповідь записано.\",\n        \"Chart options\": \"Параметри діаграми\"\n    },\n    \"RowContextMenu\": {\n        \"Copy anchor link\": \"Скопіювати якірне посилання\",\n        \"Delete\": \"Видалити\",\n        \"Duplicate rows_one\": \"Дублювати рядок\",\n        \"Duplicate rows_other\": \"Дублювати рядки\",\n        \"Insert row\": \"Вставити рядок\",\n        \"Insert row above\": \"Вставити рядок вище\",\n        \"Insert row below\": \"Вставити рядок нижче\",\n        \"View as card\": \"Переглянути як картку\",\n        \"Use as table headers\": \"Використовувати як заголовки таблиці\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"Скопійовано в буфер\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"Створення нового сайту команди\",\n        \"Switch Sites\": \"Переключення між сайтами\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"Правила доступу\",\n        \"Code view\": \"Перегляд коду\",\n        \"Delete\": \"Видалити\",\n        \"Raw data\": \"Вихідні дані\",\n        \"Delete document tour?\": \"Видалити огляд документа?\",\n        \"Document history\": \"Історія документа\",\n        \"How-to Tutorial\": \"Навчальний посібник\",\n        \"Return to viewing as yourself\": \"Повернутись до перегляду від свого імені\",\n        \"TOOLS\": \"ІНСТРУМЕНТИ\",\n        \"Tour of this Document\": \"Огляд цього документа\",\n        \"Validate Data\": \"Перевірити дані\",\n        \"Settings\": \"Налаштування\",\n        \"API console\": \"Консоль API\",\n        \"context menu - Access Rules\": \"контекстне меню - Правила доступу\",\n        \"Delete document tour\": \"Видалити огляд документа\",\n        \"Preview the tutorial\": \"Попередній перегляд посібника\",\n        \"Proposed changes\": \"Запропоновані зміни\",\n        \"Suggest changes\": \"Запропонувати зміни\",\n        \"Suggestions\": \"Пропозиції\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"Керування командою\"\n    },\n    \"UserManagerModel\": {\n        \"Editor\": \"Редактор\",\n        \"In full\": \"Повністю\",\n        \"No Default Access\": \"Немає доступу за замовчуванням\",\n        \"None\": \"Ні\",\n        \"Owner\": \"Власник\",\n        \"View & edit\": \"Перегляд та редагування\",\n        \"View only\": \"Лише перегляд\",\n        \"Viewer\": \"Глядач\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"Правило {{length}}\",\n        \"Update formula (Shift+Enter)\": \"Оновити формулу (Shift+Enter)\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"Невідомий користувач\",\n        \"View as Yourself\": \"Переглянути як себе\",\n        \"You are viewing this document as\": \"Ви переглядаєте цей документ як\",\n        \"You're seeing what this user would see if given access\": \"Ви бачите те, що бачив би цей користувач, якби йому надали доступ\"\n    },\n    \"ViewConfigTab\": {\n        \"Advanced settings\": \"Розширені налаштування\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Великі таблиці можуть бути позначені «на вимогу», щоб уникнути їх завантаження в систему даних.\",\n        \"Blocks\": \"Блоки\",\n        \"Compact\": \"Компактний\",\n        \"Edit card layout\": \"Редагувати розмітку картки\",\n        \"Form\": \"Форма\",\n        \"Plugin: \": \"Плагін: \",\n        \"Section: \": \"Секція: \",\n        \"Unmark On-Demand\": \"Відмінити статус \\\"на вимогу\\\"\",\n        \"Make On-Demand\": \"Встановити статус \\\"на вимогу\\\"\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"Таблиці на вимогу застаріли через брак функціональності та проблеми зі зручністю використання.\",\n        \"⚠️ Deprecated Feature\": \"⚠️ Застаріла функція\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Advanced sort & filter\": \"Розширені порядок та фільтр\",\n        \"Copy anchor link\": \"Скопіювати якірне посилання\",\n        \"Data selection\": \"Вибір даних\",\n        \"Delete record\": \"Видалити запис\",\n        \"Delete widget\": \"Видалити віджет\",\n        \"Download as CSV\": \"Завантажити як CSV\",\n        \"Download as XLSX\": \"Завантажити як XLSX\",\n        \"Edit card layout\": \"Редагувати розмітку картки\",\n        \"Open configuration\": \"Відкрита конфігурація\",\n        \"Print widget\": \"Роздрукувати віджет\",\n        \"Show raw data\": \"Показати вихідні дані\",\n        \"Widget options\": \"Параметри віджету\",\n        \"Add to page\": \"Додати на сторінку\",\n        \"Collapse widget\": \"Згорнути віджет\",\n        \"Create a form\": \"Створити форму\",\n        \"Duplicate widget\": \"Дублікат віджета\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"Цей сайт команди обмежений. Документи можна читати, але редагування не доступне.\"\n    },\n    \"WelcomeQuestions\": {\n        \"Education\": \"Освіта\",\n        \"Finance & Accounting\": \"Фінанси та облік\",\n        \"Marketing\": \"Маркетинг\",\n        \"Media Production\": \"Медіавиробництво\",\n        \"Other\": \"Інше\",\n        \"Product Development\": \"Розробка продукту\",\n        \"Research\": \"Дослідження\",\n        \"Sales\": \"Продажі\",\n        \"Type here\": \"Введіть тут\",\n        \"What brings you to Grist? Please help us serve you better.\": \"Що привело вас до Grist? Будь ласка, допоможіть зробити наш сервіс кращим.\",\n        \"HR & Management\": \"Управління персоналом та Менеджмент\",\n        \"IT & Technology\": \"IT та Технології\",\n        \"Welcome to Grist!\": \"Ласкаво просимо до Grist!\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"Відмінити\",\n        \"DATA TABLE NAME\": \"ІМ'Я ТАБЛИЦІ ДАНИХ\",\n        \"Override widget title\": \"Перевизначити назву віджета\",\n        \"Provide a table name\": \"Вкажіть ім'я таблиці\",\n        \"Save\": \"Зберегти\",\n        \"WIDGET TITLE\": \"НАЗВА ВІДЖЕТА\",\n        \"WIDGET DESCRIPTION\": \"ОПИС ВІДЖЕТУ\"\n    },\n    \"breadcrumbs\": {\n        \"fiddle\": \"fiddle\",\n        \"override\": \"перевизначити\",\n        \"recovery mode\": \"режим відновлення\",\n        \"snapshot\": \"знімок\",\n        \"unsaved\": \"не збережено\",\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Ви можете вносити зміни, але вони створять нову копію \\nі не впливатимуть на початковий документ.\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"Ви можете вносити зміни,\\nале вони не вплинуть на оригінальний документ.\\nВи можете запропонувати їх як пропозиції.\",\n        \"editing\": \"редагування\",\n        \"suggesting\": \"пропонуючи\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"Дублювати сторінку {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"Зауважте, що це не копіювання даних, а створення іншого подання тих самих даних.\"\n    },\n    \"errorPages\": {\n        \"Access denied{{suffix}}\": \"Доступ заборонено{{suffix}}\",\n        \"Add account\": \"Додати обліковий запис\",\n        \"Contact support\": \"Зв'язатись зі службою підтримки\",\n        \"Error{{suffix}}\": \"Помилка{{suffix}}\",\n        \"Go to main page\": \"Перейти на головну сторінку\",\n        \"Page not found{{suffix}}\": \"Сторінка не знайдена {{suffix}}\",\n        \"Sign in\": \"Увійти в систему\",\n        \"Sign in again\": \"Увійдіть ще раз\",\n        \"Signed out{{suffix}}\": \"Ви вийшли з системи {{suffix}}\",\n        \"Something went wrong\": \"Щось пішло не так\",\n        \"There was an error: {{message}}\": \"Сталася помилка: {{message}}\",\n        \"There was an unknown error.\": \"Виникла невідома помилка.\",\n        \"You are now signed out.\": \"Ви вийшли з облікового запису.\",\n        \"You do not have access to this organization's documents.\": \"У вас немає доступу до документів цієї організації.\",\n        \"Sign in to access this organization's documents.\": \"Увійдіть, щоб отримати доступ до документів цієї організації.\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Не вдалося знайти запитувану сторінку.{{separator}}Будь ласка, перевірте URL-адресу і спробуйте ще раз.\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Ви увійшли як {{email}}. Ви можете увійти під іншим обліковим записом або попросити доступ у адміністратора.\",\n        \"Account deleted{{suffix}}\": \"Обліковий запис видалено{{suffix}}\",\n        \"Your account has been deleted.\": \"Ваш обліковий запис видалено.\",\n        \"Sign up\": \"Зареєструватися\",\n        \"An unknown error occurred.\": \"Сталася невідома помилка.\",\n        \"Build your own form\": \"Створіть власну форму\",\n        \"Form not found\": \"Форму не знайдено\",\n        \"Powered by\": \"Працює на\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"Не вдалося ввійти.{{separator}}Будь ласка, спробуйте ще раз або зверніться до служби підтримки.\",\n        \"Sign-in failed{{suffix}}\": \"Не вдалося ввійти{{suffix}}\",\n        \"Manage settings\": \"Керування налаштуваннями\",\n        \"Need Help?\": \"Потрібна допомога?\",\n        \"There was an error\": \"Сталася помилка\",\n        \"Unsubscribed{{suffix}}\": \"Скасовано підписку{{suffix}}\",\n        \"We could not unsubscribe you\": \"Нам не вдалося скасувати вашу підписку\",\n        \"You are unsubscribed\": \"Ви скасували підписку\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"Ви все ще можете відмовитися від підписки на цей документ, оновивши свої налаштування в налаштуваннях документа\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"Ви більше не отримуватимете сповіщення електронною поштою про {{changes}} у {{docName}} на адресу {{email}}.\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"Ви більше не отримуватимете сповіщення електронною поштою про {{comments}} у {{docName}} на адресу {{email}}.\",\n        \"changes\": \"зміни\",\n        \"comments\": \"коментарі\",\n        \"this document\": \"цей документ\",\n        \"your email\": \"ваша електронна адреса\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* Робочі простори доступні в командних тарифах. \",\n        \"Select fields\": \"Виберіть поля\",\n        \"Upgrade now\": \"Оновити зараз\",\n        \"Any\": \"Будь-які\",\n        \"Numeric\": \"Числа\",\n        \"Text\": \"Текст\",\n        \"Integer\": \"Цілі числа\",\n        \"Toggle\": \"Перемикач\",\n        \"Date\": \"Дата\",\n        \"Choice\": \"Вибір\",\n        \"Choice List\": \"Список вибору\",\n        \"Reference\": \"Посилання\",\n        \"DateTime\": \"Дата і час\",\n        \"Reference List\": \"Список посилань\",\n        \"Attachment\": \"Вкладення\",\n        \"Search columns\": \"Шукати стовпці\",\n        \"By Name\": \"За іменем\",\n        \"By Date Modified\": \"За датою змін\",\n        \"Light\": \"Світло\",\n        \"Custom\": \"Користувацька\"\n    },\n    \"modals\": {\n        \"Cancel\": \"Відмінити\",\n        \"Ok\": \"ОК\",\n        \"Save\": \"Зберегти\",\n        \"Are you sure you want to delete these records?\": \"Ви впевнені, що хочете видалити ці записи?\",\n        \"Are you sure you want to delete this record?\": \"Ви впевнені, що хочете видалити цей запис?\",\n        \"Delete\": \"Видалити\",\n        \"Dismiss\": \"Відхилити\",\n        \"Don't ask again.\": \"Не питай більше.\",\n        \"Don't show again.\": \"Більше не показуй.\",\n        \"Don't show tips\": \"Не показувати підказки\",\n        \"Undo to restore\": \"Скасувати для відновлення\",\n        \"Got it\": \"Зрозуміло\",\n        \"Don't show again\": \"Більше не показувати\",\n        \"TIP\": \"ПОРАДА\",\n        \"Confirm\": \"Підтвердити\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"Дублювати сторінку\",\n        \"Remove\": \"Вилучити\",\n        \"Rename\": \"Перейменувати\",\n        \"You do not have edit access to this document\": \"Ви не маєте права на редагування цього документа\",\n        \"(default)\": \"(за замовчуванням)\",\n        \"Collapse {{maybeDefault}}\": \"Згорнути {{maybeDefault}}\",\n        \"Expand {{maybeDefault}}\": \"Розгорнути {{maybeDefault}}\",\n        \"Set default: Collapse\": \"Встановити за замовчуванням: Згорнути\",\n        \"Set default: Expand\": \"Встановити за замовчуванням: Розгорнути\",\n        \"context menu - {{- pageName }}\": \"контекстне меню - {{- pageName }}\"\n    },\n    \"search\": {\n        \"Find Next \": \"Знайти наступне \",\n        \"Find Previous \": \"Знайти попереднє \",\n        \"Search in document\": \"Пошук у документі\",\n        \"No results\": \"Немає результатів\",\n        \"Search\": \"Пошук\",\n        \"Close search bar\": \"Закрити рядок пошуку\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"Надсилання файлу на Google Диск\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"Приклади користувачів\",\n        \"View as\": \"Переглянути як\",\n        \"Users from table\": \"Користувачі з таблиці\",\n        \"Other users from table\": \"Інші користувачі з таблиці\",\n        \"Shared users\": \"Спільні користувачі\"\n    },\n    \"NTextBox\": {\n        \"true\": \"true\",\n        \"false\": \"хиба\",\n        \"Field Format\": \"Формат поля\",\n        \"Lines\": \"Лінії\",\n        \"Multi line\": \"Багаторядковий\",\n        \"Single line\": \"Одна лінія\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"Застосувати\",\n        \"Preview\": \"Попередній перегляд\",\n        \"Revise\": \"Переглянути'\",\n        \"Update formula (Shift+Enter)\": \"Оновити формулу (Shift+Enter)\",\n        \"Cancel\": \"Відмінити\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"СТИЛЬ КЛІТИНКИ\",\n        \"Default cell style\": \"Стиль клітинки за замовчуванням\",\n        \"Mixed style\": \"Змішаний стиль\",\n        \"Open row styles\": \"Відкрити стилі рядків\",\n        \"Cell style\": \"Стиль клітинки\",\n        \"HEADER STYLE\": \"СТИЛЬ ЗАГОЛОВКА\",\n        \"Header Style\": \"Стиль Заголовка\",\n        \"Default header style\": \"Стиль заголовка за замовчуванням\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"ВАРІАНТИ\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN LABEL\": \"ІМ'Я СТОВПЦЯ\",\n        \"COLUMN DESCRIPTION\": \"ОПИС СТОВПЦЯ\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN DESCRIPTION\": \"ОПИС СТОВПЦЯ\",\n        \"COLUMN ID: \": \"ІДЕНТИФІКАТОР СТОВПЦЯ: \",\n        \"Cancel\": \"Відмінити\",\n        \"Save\": \"Зберегти\",\n        \"COLUMN LABEL\": \"ІМ'Я СТОВПЦЯ\"\n    },\n    \"ConditionalStyle\": {\n        \"Add another rule\": \"Додайте інше правило\",\n        \"Error in style rule\": \"Помилка в правилі стилю\",\n        \"Row style\": \"Стиль рядка\",\n        \"Rule must return True or False\": \"Правило має повертати True або False\",\n        \"Add conditional style\": \"Додайте умовний стиль\",\n        \"Conditional Style\": \"Умовний стиль\",\n        \"IF...\": \"ЯКЩО...\",\n        \"Row Style\": \"Стиль Рядка\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"Недійсна валюта\"\n    },\n    \"DiscussionEditor\": {\n        \"Edit\": \"Редагувати\",\n        \"Marked as resolved\": \"Позначено як вирішене\",\n        \"Only current page\": \"Тільки поточна сторінка\",\n        \"Open\": \"Відкрити\",\n        \"Remove\": \"Вилучити\",\n        \"Reply\": \"Відповісти\",\n        \"Resolve\": \"Вирішити\",\n        \"Save\": \"Зберегти\",\n        \"Show resolved comments\": \"Показати вирішені коментарі\",\n        \"Showing last {{nb}} comments\": \"Показати останні {{nb}} коментарів\",\n        \"Started discussion\": \"Розпочато обговорення\",\n        \"Write a comment\": \"Написати коментар\",\n        \"Comment\": \"Коментар\",\n        \"Cancel\": \"Відмінити\",\n        \"Reply to a comment\": \"Відповісти на коментар\",\n        \"Only my threads\": \"Тільки мої теми\",\n        \"Remove thread\": \"Вилучити нитку\",\n        \"updated\": \"оновлено\",\n        \"Copy link\": \"Копіювати посилання\",\n        \"{{count}} comments_one\": \"{{count}} коментар\",\n        \"{{count}} comments_other\": \"{{count}} коментарі\"\n    },\n    \"FieldBuilder\": {\n        \"CELL FORMAT\": \"ФОРМАТ КЛІТИНКИ\",\n        \"Changing multiple column types\": \"Зміна кількох типів стовпців\",\n        \"DATA FROM TABLE\": \"ДАНІ З ТАБЛИЦІ\",\n        \"Mixed format\": \"Змішаний формат\",\n        \"Mixed types\": \"Змішані типи\",\n        \"Use separate field settings for {{colId}}\": \"Використати окремі налаштування поля для {{colId}}\",\n        \"Apply formula to data\": \"Застосувати формулу до даних\",\n        \"Revert field settings for {{colId}} to common\": \"Змінити налаштування поля для {{colId}} на загальні\",\n        \"Save field settings for {{colId}} as common\": \"Зберегти налаштування поля для {{colId}} як загальні\",\n        \"Changing column type\": \"Зміна типу стовпця\",\n        \"Common\": \"Звичайний'\",\n        \"Separate\": \"Окремі\",\n        \"Field in {{count}} views_one\": \"Поле в одному вигляді\",\n        \"Field in {{count}} views_other\": \"Поле в {{count}} переглядах\"\n    },\n    \"FieldEditor\": {\n        \"Unable to finish saving edited cell\": \"Неможливо зберегти відредаговану клітинку\",\n        \"It should be impossible to save a plain data value into a formula column\": \"Має бути неможливо зберегти просте значення даних у стовпець формули\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"Стовпець або поле є обов’язковим\",\n        \"Error in the cell\": \"Помилка в клітинці\",\n        \"Errors in all {{numErrors}} cells\": \"Помилки в усіх клітинках {{numErrors}}\",\n        \"editingFormula is required\": \"editingFormula обов'язкове\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"Помилки в {{numErrors}} в {{numCells}} клітинках\",\n        \"Enter formula or {{button}}.\": \"Введіть формулу або {{button}}.\",\n        \"Enter formula.\": \"Введіть формулу.\",\n        \"Expand Editor\": \"Розгорнути редактор\",\n        \"use AI Assistant\": \"Використати AI Помічника\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[мітка посилання] URL\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"Валюта\",\n        \"Decimals\": \"Десяткові\",\n        \"Default currency ({{defaultCurrency}})\": \"Валюта за замовчуванням ({{defaultCurrency}})\",\n        \"Number Format\": \"Числовий формат\",\n        \"Field Format\": \"Формат поля\",\n        \"Spinner\": \"Спіннер\",\n        \"Text\": \"Текст\",\n        \"max\": \"макс.\",\n        \"min\": \"мін.\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"ФОРМАТ КЛІТИНКИ\",\n        \"Row ID\": \"ID рядка\",\n        \"SHOW COLUMN\": \"ПОКАЗАТИ СТОВПЕЦЬ\"\n    },\n    \"WelcomeTour\": {\n        \"Add new\": \"Додати нове\",\n        \"Building up\": \"Створюйте\",\n        \"Configuring your document\": \"Налаштування вашого документа\",\n        \"Customizing columns\": \"Налаштування стовпців\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"Двічі клацніть на клітинку або натисніть {{enter}}, щоб відредагувати її. \",\n        \"Enter\": \"Увійти\",\n        \"Flying higher\": \"Летимо вище\",\n        \"Help Center\": \"Центр допомоги\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"Зробіть їх зв'язаними! Використовуйте тип {{ref}} для зв'язування таблиць. \",\n        \"Reference\": \"Посилання\",\n        \"Sharing\": \"Поширення\",\n        \"Start with {{equal}} to enter a formula.\": \"Почати з {{equal}}, щоб ввести формулу.\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"Перемкніть {{creatorPanel}}, щоб форматувати стовпці, \",\n        \"Share\": \"Поділитись\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Використовуйте {{addNew}}, щоб додати віджети, сторінки або імпортувати більше даних. \",\n        \"Welcome to Grist!\": \"Ласкаво просимо до Grist!\",\n        \"creator panel\": \"панель автора\",\n        \"template library\": \"бібліотека шаблонів\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Надихайтесь і взнавайте більше про існуючі можливості, відвідавши наш {{templateLibrary}}.\",\n        \"Editing Data\": \"Редагування даних\",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Встановити параметри форматування, формули або типи стовпців, наприклад - дати, варіанти або вкладені файли. \",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"Використовуйте кнопку \\\"Поділитись\\\" ({{share}}), щоб поділитись документом або експортувати дані.\",\n        \"Use {{helpCenter}} for documentation or questions.\": \"Знайдіть технічну документацію чи відповіді на запитання у {{helpCenter}}.\",\n        \"convert to card view, select data, and more.\": \"перетворити в представлення картки, вибрати дані, та інші можливості.\",\n        \"AI Assistant\": \"Помічник зі штучним інтелектом\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"Перетворити стовпець у формулу\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"Мова\"\n    },\n    \"GristTooltips\": {\n        \"Apply conditional formatting to rows based on formulas.\": \"Застосовувати умовне форматування рядків на основі формул.\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"Натисніть кнопку \\\"Додати\\\", щоб створити нові документи, робочі області або імпортувати дані.\",\n        \"Editing Card Layout\": \"Редагування розмітки картки\",\n        \"Learn more.\": \"Дізнайтесь більше.\",\n        \"Linking Widgets\": \"Зв'язування віджетів\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"Закріплені фільтри відображаються як кнопки над віджетом.\",\n        \"Nested Filtering\": \"Вкладена фільтрація\",\n        \"Reference Columns\": \"Довідкові стовпці\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"Довідкові стовпці є ключем до даних {{relational}} у Grist.\",\n        \"Select the table containing the data to show.\": \"Виділіть таблицю, що містить дані, які потрібно показати.\",\n        \"Select the table to link to.\": \"Виберіть таблицю, на яку потрібно зробити посилання.\",\n        \"Selecting Data\": \"Вибір даних\",\n        \"The total size of all data in this document, excluding attachments.\": \"Загальний розмір усіх даних у цьому документі, за винятком вкладених файлів.\",\n        \"They allow for one record to point (or refer) to another.\": \"Вони дозволяють одному запису вказувати (або посилатися) на інший.\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"У цьому і полягає секрет динамічних і продуктивних макетів Grist.\",\n        \"Unpin to hide the the button while keeping the filter.\": \"Відкріпіть, щоб приховати кнопку, залишивши фільтр.\",\n        \"Updates every 5 minutes.\": \"Оновлюється кожні 5 хвилин.\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Використовуйте значок \\\\u{1D6BA} для створення підсумкових (або pivot) таблиць для підсумків або проміжних підсумків.\",\n        \"entire\": \"весь\",\n        \"relational\": \"зв'язаний\",\n        \"Access Rules\": \"Правила доступу\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Використовуйте іконку 𝚺 для створення зведених (або pivot) таблиць, для підсумків, або проміжних результатів.\",\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Застосувати умовне форматування до клітинок у цьому стовпці, якщо виконуються умови формули.\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Клітинки в довідковому стовпці завжди ідентифікують запис {{entire}} у цій таблиці, але ви можете вибрати, який саме стовпець з цього запису показувати.\",\n        \"Add new\": \"Додати нове\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Натисніть \\\"Відкрити стилі рядків\\\", щоб застосувати умовне форматування до рядків.\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Натискання {{EyeHideIcon}} у кожній клітинці робить поле прихованим, але не видалає його.\",\n        \"Link your new widget to an existing widget on this page.\": \"Зв'яжіть свій новий віджет з існуючим віджетом на цій сторінці.\",\n        \"Pinning Filters\": \"Закріплення фільтрів\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Формули, які запускаються в певних випадках і зберігають обчислене значення як дані.\",\n        \"Only those rows will appear which match all of the filters.\": \"З'являться лише ті рядки, які відповідають усім фільтрам.\",\n        \"Raw Data page\": \"Сторінка необроблених даних\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"Змінюйте розташування полів у вашій картці, перетягуючи клітинки та змінюючи їх розмір.\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Сторінка «Необроблені дані» містить список усіх таблиць даних у вашому документі, включно з підсумковими таблицями та таблицями, не включеними до макетів сторінок.\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Корисно для збереження позначки часу або автора нового запису, очищення даних тощо.\",\n        \"You can filter by more than one column.\": \"Ви можете фільтрувати за кількома стовпцями.\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Спробуйте змінити копію, а потім вирішіть, чи слід замінювати оригінал своїми правками.\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Правила доступу дають вам можливість створювати детальні правила, щоб визначити, хто може бачити або редагувати (та які) частини вашого документа.\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Щоб створити якірне посилання, яке перенаправляє користувача до певної комірки, клацніть на рядку та натисніть {{shortcut}}.\",\n        \"Anchor Links\": \"Якірні посилання\",\n        \"Custom Widgets\": \"Користувацькі віджети\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Ви можете вибрати один із наших готових віджетів або вбудувати свій власний, надавши його повну URL-адресу.\",\n        \"Calendar\": \"Календар\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Не можете знайти потрібні стовпці? Натисніть «Змінити віджет», щоб вибрати таблицю з даними подій.\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"Щоб налаштувати календар, виберіть стовпці для дат початку/закінчення та назв подій. Зверніть увагу на тип кожного стовпця.\"\n        },\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID — це випадково згенерований рядок, корисний для унікальних ідентифікаторів та ключів посилань.\",\n        \"Lookups return data from related tables.\": \"Пошукові запити повертають дані з пов'язаних таблиць.\",\n        \"Use reference columns to relate data in different tables.\": \"Використовуйте стовпці-посилання для зв'язку даних у різних таблицях.\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Ви можете вибрати один із доступних вам віджетів у випадаючому списку або вбудувати свій власний, вказавши його повну URL-адресу.\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Формули підтримують багато функцій Excel, повний синтаксис Python та містять корисного помічника зі штучним інтелектом.\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Створюйте прості форми прямо в Grist та діліться ними одним кліком за допомогою нашого нового віджета. {{learnMoreButton}}\",\n        \"Forms are here!\": \"Форми тут!\",\n        \"Learn more\": \"Дізнатися більше\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"Ці правила застосовуються після обробки всіх правил стовпців, якщо такі є.\",\n        \"Example: {{example}}\": \"Приклад: {{example}}\",\n        \"Filter displayed dropdown values with a condition.\": \"Фільтрувати відображені значення випадаючого списку за допомогою умови.\",\n        \"Community widgets are created and maintained by Grist community members.\": \"Віджети спільноти створюються та підтримуються членами спільноти Grist.\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"Створює зворотний стовпець у цільовій таблиці, який можна редагувати з будь-якого кінця.\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"Це обмеження виникає, коли один кінець двостороннього посилання налаштовано як одне посилання.\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"Щоб дозволити кілька призначень, змініть тип стовпця «Посилання» на «Список посилань».\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"Це обмеження виникає, коли один стовпець у двосторонньому посиланні має тип «Посилання».\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"Щоб дозволити кілька призначень, змініть тип стовпця, на який посилаються, на «Список посилань».\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"Двосторонні посилання наразі не підтримуються для стовпців «Формула» або «Формула тригера»\",\n        \"The preview below this header shows how the selected user will see this document\": \"Попередній перегляд під цим заголовком показує, як вибраний користувач бачитиме цей документ\",\n        \"[Learn more.]({{link}})\": \"[Дізнатися більше.]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"Зведені таблиці можуть містити лише стовпці формул.\",\n        \"Manage users and resources in a Grist installation.\": \"Керування користувачами та ресурсами в установці Grist.\",\n        \"The new Grist Assistant is here!\": \"Новий помічник Grist вже тут!\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"Формули підтримують багато функцій Excel та повний синтаксис Python.\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"Створює новий стовпець списку посилань у цільовій таблиці, при цьому як цей, так і цільові стовпці можна редагувати та синхронізувати.\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"Внутрішнє сховище означає, що всі вкладення зберігаються у файлі SQLite документа, тоді як зовнішнє сховище означає, що всі вкладення зберігаються в одному зовнішньому сховищі.\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"Це дозволяє додавати вкладення, яких немає на зовнішньому сховищі, наприклад, в імпортованому документі. Сюди можна завантажувати лише архіви вкладень .tar, завантажені з Grist.\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"Розумійте, змінюйте та працюйте зі своїми даними й формулами за допомогою нового ШІ-помічника Grist!\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs. Do not submit passwords through this form, and be careful with links in it. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Цю форму створив користувач Grist і не схвалює Grist Labs. Не надсилайте паролі через цю форму та будьте обережні з посиланнями в ній. Повідомляйте про шкідливі форми на адресу [{{mail}}](mailto:{{mail}}).\",\n        \"Set the maximum number of lines for multi-line text.\": \"Встановіть максимальну кількість рядків для багаторядкового тексту.\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"Ця форма створена користувачем Grist і не схвалена компанією Grist Labs, Inc. або будь-якою іншою стороною, що надає цю послугу. Для вашої безпеки не надсилайте паролі через цю форму та будьте обережні, натискаючи на вбудовані посилання. Про зловмисні форми повідомляйте за адресою [{{mail}}](mailto:{{mail}}).\",\n        \"Comments are here!\": \"Коментарі тут!\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"Ви можете додавати коментарі до комірок, відповідати на ланцюжки коментарів та згадувати співавторів за допомогою @.\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"Якщо позначено, значення цього поля за замовчуванням можна попередньо заповнити з URL-адреси за допомогою параметрів запиту.\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"За допомогою пропозицій користувачі вносять зміни до особистої копії, не змінюючи оригінальний документ, а потім надсилають ці пропозиції на розгляд власнику документа перед інтеграцією.\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"ОПИС\",\n        \"Set description\": \"Опис набору\"\n    },\n    \"FieldContextMenu\": {\n        \"Copy anchor link\": \"Скопіювати якірне посилання\",\n        \"Clear field\": \"Очистити поле\",\n        \"Copy\": \"Копіювати\",\n        \"Cut\": \"Вирізати\",\n        \"Hide field\": \"Приховати поле\",\n        \"Paste\": \"Вставити\",\n        \"Comment\": \"Коментар\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"Закрити панель автора\",\n        \"Open creator panel\": \"Відкрити панель творця\",\n        \"Creator panel (right panel)\": \"Панель творця (права панель)\",\n        \"Document header\": \"Заголовок документа\",\n        \"Main content\": \"Основний зміст\",\n        \"Main navigation and document settings (left panel)\": \"Основна навігація та налаштування документа (ліва панель)\",\n        \"Close navigation panel (left panel)\": \"Закрити панель навігації (ліва панель)\",\n        \"Open navigation panel (left panel)\": \"Відкрити панель навігації (ліва панель)\"\n    },\n    \"ColumnTitle\": {\n        \"Add description\": \"Додати опис\",\n        \"COLUMN ID: \": \"ІДЕНТИФІКАТОР СТОЛОВЦЯ: \",\n        \"Cancel\": \"Скасувати\",\n        \"Column ID copied to clipboard\": \"Ідентифікатор стовпця скопійовано в буфер обміну\",\n        \"Column description\": \"Опис стовпця\",\n        \"Column label\": \"Мітка стовпця\",\n        \"Provide a column label\": \"Вкажіть підпис стовпця\",\n        \"Save\": \"Зберегти\",\n        \"Close\": \"Закрити\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"Зрозуміло\",\n        \"Unavailable Command\": \"Недоступна команда\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"Команда меню {{action}} недоступна в цьому браузері. Ви все ще можете виконати {{action}} за допомогою комбінації клавіш {{shortcut}}.\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"Очистити чергу\",\n        \"Webhook settings\": \"Налаштування вебхука\",\n        \"Cleared webhook queue.\": \"Очищено чергу вебхуків.\",\n        \"Columns to check when update (separated by ;)\": \"Стовпці для перевірки під час оновлення (розділені ;)\",\n        \"Enabled\": \"Увімкнено\",\n        \"Event Types\": \"Типи подій\",\n        \"Memo\": \"Пам'ятка\",\n        \"Name\": \"Ім'я\",\n        \"Ready Column\": \"Колонка готова\",\n        \"Removed webhook.\": \"Вилучено вебхук.\",\n        \"Sorry, not all fields can be edited.\": \"Вибачте, не всі поля можна редагувати.\",\n        \"Status\": \"Статус\",\n        \"URL\": \"URL-адреса\",\n        \"Webhook Id\": \"Ідентифікатор вебхука\",\n        \"Table\": \"Таблиця\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"Фільтрувати зміни в цих стовпцях (ідентифікатори, розділені крапкою з комою)\",\n        \"Header Authorization\": \"Авторизація заголовка\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Вебхуки недоступні в незбережених копіях документів\"\n    },\n    \"FormulaAssistant\": {\n        \"Ask the bot.\": \"Запитайте бота.\",\n        \"Capabilities\": \"Можливості\",\n        \"Community\": \"Громада\",\n        \"Data\": \"Дані\",\n        \"Formula Cheat Sheet\": \"Шпаргалка з формулами\",\n        \"Formula Help. \": \"Довідка з формулою. \",\n        \"Function List\": \"Список функцій\",\n        \"Grist's AI Assistance\": \"Допомога ШІ від Grist\",\n        \"Grist's AI Formula Assistance. \": \"Допомога Grist з формулами штучного інтелекту. \",\n        \"Need help? Our AI assistant can help.\": \"Потрібна допомога? Наш помічник зі штучним інтелектом може допомогти.\",\n        \"New Chat\": \"Новий чат\",\n        \"Preview\": \"Попередній перегляд\",\n        \"Regenerate\": \"Регенерувати\",\n        \"Save\": \"Зберегти\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Дивіться наші {{helpFunction}} та {{formulaCheat}} або відвідайте нашу {{community}} для отримання додаткової допомоги.\",\n        \"Tips\": \"Поради\",\n        \"AI Assistant\": \"Помічник зі штучним інтелектом\",\n        \"Apply\": \"Застосувати\",\n        \"Cancel\": \"Скасувати\",\n        \"Clear conversation\": \"Очистити розмову\",\n        \"Code view\": \"Перегляд коду\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"Привіт, я помічник зі штучного інтелекту Grist Formula.\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Я можу допомогти лише з формулами. Я не можу створювати таблиці, стовпці та подання, а також писати правила доступу.\",\n        \"Learn more\": \"Дізнатися більше\",\n        \"Press Enter to apply suggested formula.\": \"Натисніть Enter, щоб застосувати запропоновану формулу.\",\n        \"Sign Up for Free\": \"Зареєструйтесь безкоштовно\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Зареєструйте безкоштовний обліковий запис Grist, щоб почати користуватися помічником Formula AI.\",\n        \"There are some things you should know when working with me:\": \"Є кілька речей, які вам слід знати, працюючи зі мною:\",\n        \"What do you need help with?\": \"З чим вам потрібна допомога?\",\n        \"Formula AI Assistant is only available for logged in users.\": \"Помічник Formula AI доступний лише для зареєстрованих користувачів.\",\n        \"For higher limits, contact the site owner.\": \"Щоб дізнатися про вищі ліміти, зверніться до власника сайту.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Для вищих лімітів, {{upgradeNudge}}.\",\n        \"You have used all available credits.\": \"Ви використали всі доступні кредити.\",\n        \"You have {{numCredits}} remaining credits.\": \"У вас залишилося кредитів: {{numCredits}}.\",\n        \"upgrade to the Pro Team plan\": \"перейти на план Pro Team\",\n        \"upgrade your plan\": \"оновіть свій план\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"Щоб отримати додаткову допомогу з формулами, перегляньте наші {{functionList}} та {{formulaCheatSheet}} або відвідайте нашу {{community}} для отримання додаткової допомоги.\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"Коли ви спілкуєтесь зі мною, ваші запитання та структура вашого документа (видима в {{codeView}}) надсилаються до OpenAI. {{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"Розмовляйте зі мною як з людиною. Не потрібно вказувати назви таблиць і стовпців. Наприклад, ви можете запитати: «Будь ласка, розрахуйте загальну суму рахунку-фактури.»\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"Натисніть, щоб вставити\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"Ласкаво просимо назад\",\n        \"You can always switch sites using the account menu.\": \"Ви завжди можете перемикатися між сайтами за допомогою меню облікового запису.\",\n        \"You have access to the following Grist sites.\": \"Ви маєте доступ до наступних сайтів Grist.\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"ОПИС\"\n    },\n    \"UserManager\": {\n        \"Add {{member}} to your team\": \"Додайте {{member}} до своєї команди\",\n        \"Allow anyone with the link to open.\": \"Дозволити відкривати будь-кому, хто має посилання.\",\n        \"Anyone with link \": \"Будь-хто, хто має посилання \",\n        \"Cancel\": \"Скасувати\",\n        \"Close\": \"Закрити\",\n        \"Collaborator\": \"Співробітник\",\n        \"Confirm\": \"Підтвердити\",\n        \"Copy link\": \"Копіювати Посилання\",\n        \"Create a team to share with more people\": \"Створіть команду, щоб ділитися інформацією з більшою кількістю людей\",\n        \"Grist support\": \"підтримка Grist\",\n        \"Guest\": \"Гість\",\n        \"Invite multiple\": \"Запросити кількох\",\n        \"Invite people to {{resourceType}}\": \"Запросіть людей до {{resourceType}}\",\n        \"Link copied to clipboard\": \"Посилання скопійовано в буфер обміну\",\n        \"Manage members of team site\": \"Керування учасниками сайту команди\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Відсутність доступу за замовчуванням дозволяє надавати доступ до окремих документів або робочих просторів, а не до всього сайту команди.\",\n        \"Off\": \"Вимк\",\n        \"On\": \"Увімк\",\n        \"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}}.\": \"Після того, як ви скасували власний доступ,           ви не зможете його відновити без допомоги когось іншого,                хто має достатній доступ до {{name}}.\",\n        \"Open Access Rules\": \"Правила відкритого доступу\",\n        \"Outside collaborator\": \"Зовнішній співробітник\",\n        \"Public access\": \"Публічний доступу\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Публічний доступ успадковано від {{parent}}. Щоб видалити, встановіть для опції «Успадкувати доступ» значення «Немає».\",\n        \"Public access: \": \"Публічний доступ: \",\n        \"Remove my access\": \"Вилучити мій доступ\",\n        \"Save & \": \"Зберегти & \",\n        \"Team member\": \"Член команди\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Користувач успадковує дозволи від {{parent})}. Щоб видалити їх,             встановіть для опції «Успадкувати доступ» значення «Немає».\",\n        \"User may not modify their own access.\": \"Користувач не може змінювати власні налаштування доступу.\",\n        \"Your role for this team site\": \"Ваша роль для цього сайту команди\",\n        \"Your role for this {{resourceType}}\": \"Ваша роль у цьому {{resourceType}}\",\n        \"free collaborator\": \"безкоштовний співавтор\",\n        \"guest\": \"гість\",\n        \"member\": \"член\",\n        \"team site\": \"сайт команди\",\n        \"{{collaborator}} limit exceeded\": \"{{collaborator}} перевищено ліміт\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} з {{limitTop}} {{collaborator}}\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Відсутність доступ за замовчуванням дозволяє надавати доступ до окремих документів або робочих просторів, а не до всього сайту команди.\",\n        \"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}}.\",\n        \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\": \"Користувач має доступ до перегляду {{resource}} внаслідок ручного налаштування доступу до ресурсів усередині. Якщо видалити цей доступ, цей користувач втратить доступ до ресурсів усередині.\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Користувач успадковує дозволи від {{parent}}. Щоб видалити їх, встановіть для опції «Успадкувати доступ» значення «Немає».\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"Ви збираєтеся позбавити себе доступу до цього {{resourceType}}\",\n        \"Inherit access: \": \"Успадкування доступу: \",\n        \"Access overview\": \"Огляд доступу\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"Пошук на всіх сторінках\",\n        \"Search all tables\": \"Пошук у всіх таблицях\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"Пошук\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"Показано {{displayedCount}} з {{totalCount}} елементів. Шукайте більше.\"\n    },\n    \"SupportGristNudge\": {\n        \"Close\": \"Закрити\",\n        \"Contribute\": \"Зробити внесок\",\n        \"Help Center\": \"Довідковий Центр\",\n        \"Opt in to Telemetry\": \"Підключитися до телеметрії\",\n        \"Opted In\": \"Згода\",\n        \"Support Grist\": \"Підтримка Grist\",\n        \"Support Grist page\": \"Сторінка підтримки Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Дякуємо! Ми дуже цінуємо вашу довіру та підтримку. Ви можете відмовитися будь-коли через {{link}} у меню користувача.\",\n        \"Admin Panel\": \"Панель адміністратора\"\n    },\n    \"SupportGristPage\": {\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"Сторінка спонсорів GitHub\",\n        \"Help Center\": \"Довідковий Центр\",\n        \"Home\": \"Дім\",\n        \"Manage Sponsorship\": \"Керування спонсорством\",\n        \"Opt in to Telemetry\": \"Підключитися до телеметрії\",\n        \"Opt out of Telemetry\": \"Відмова від телеметрії\",\n        \"Sponsor Grist Labs on GitHub\": \"Спонсоруйте Grist Labs на GitHub\",\n        \"Support Grist\": \"Підтримка Grist\",\n        \"Telemetry\": \"Телеметрія\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Цей екземпляр увімкнено для телеметрії. Тільки адміністратор сайту має дозвіл змінювати це.\",\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Цей екземпляр вимкнено з телеметрії. Тільки адміністратор сайту має дозвіл змінювати це.\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Ми збираємо лише статистику використання, як детально описано в нашому {{link}}, ніколи не документуємо вміст.\",\n        \"You can opt out of telemetry at any time from this page.\": \"Ви можете будь-коли відмовитися від телеметрії на цій сторінці.\",\n        \"You have opted in to telemetry. Thank you!\": \"Ви підключилися до телеметрії. Дякуємо!\",\n        \"You have opted out of telemetry.\": \"Ви відмовилися від телеметрії.\",\n        \"Sponsor\": \"Спонсор\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Програмне забезпечення Grist розроблено компанією Grist Labs, яка пропонує безкоштовні та платні плани розміщення. Ми також робимо код Grist доступним за стандартною безкоштовною та відкритою ліцензією OSS (Apache 2.0) на {{link}}.\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"Підтримайте Grist, підписавшись на телеметрію, яка допомагає нам зрозуміти, як використовується продукт, щоб ми могли визначити пріоритети майбутніх удосконалень.\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"Ми — невелика та цілеспрямована команда. Ваша підтримка дуже важлива для нас. Вона також показує іншим, що за цим продуктом стоїть цілеспрямована спільнота.\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"Ви можете підтримати розробку Grist з відкритим кодом, спонсоруючи нас на нашому {{link}}.\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"Немає даних\",\n        \"No row selected in {{title}}\": \"У {{title}} не вибрано жодного рядка\",\n        \"Not all data is shown\": \"Відображаються не всі дані\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"Згорнути редактор\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"Максимізувати\",\n        \"Minimize\": \"Згорнути\"\n    },\n    \"CardContextMenu\": {\n        \"Copy anchor link\": \"Копіювати посилання-якоря\",\n        \"Delete card\": \"Видалити картку\",\n        \"Duplicate card\": \"Дублікат картки\",\n        \"Insert card\": \"Вставити картку\",\n        \"Insert card above\": \"Вставте картку вище\",\n        \"Insert card below\": \"Вставте картку нижче\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"Приховані поля\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"free coaching call\": \"безкоштовний коучинговий дзвінок\",\n        \"Maybe later\": \"Можливо, пізніше\",\n        \"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.\": \"Під час дзвінка ми приділимо час, щоб зрозуміти ваші потреби та адаптувати розмову до вас. Ми можемо показати вам основи Grist або одразу почати працювати з вашими даними, щоб створити потрібні вам інформаційні панелі.\",\n        \"Schedule call\": \"Запланувати дзвінок\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Заплануйте свій {{freeCoachingCall}} з членом нашої команди.\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"Ви також можете переглянути {{ourWeeklyWebinars}}, щоб дізнатися більше про Grist.\",\n        \"our weekly webinars\": \"наші щотижневі вебінари\",\n        \"Free coaching call\": \"Безкоштовний коучинговий дзвінок\",\n        \"Grist 101\": \"Зерно 101\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users                navigate the fundamentals of Grist.\": \"Ви також можете переглянути наш вступний вебінар {{ourWeeklyWebinars}}, розроблений, щоб допомогти новим користувачам зорієнтуватися в основах Grist.\",\n        \"You may also check out our introductory webinar, {{ourWeeklyWebinars}}, designed to help new users navigate the fundamentals of Grist.\": \"Ви також можете переглянути наш вступний вебінар {{ourWeeklyWebinars}}, розроблений, щоб допомогти новим користувачам зорієнтуватися в основах Grist.\"\n    },\n    \"FormView\": {\n        \"Publish\": \"Опублікувати\",\n        \"Publish your form?\": \"Опублікувати вашу форму?\",\n        \"Unpublish\": \"Скасувати публікацію\",\n        \"Unpublish your form?\": \"Скасувати публікацію вашої форми?\",\n        \"Anyone with the link below can see the empty form and submit a response.\": \"Будь-хто, хто має посилання нижче, може переглянути порожню форму та надіслати відповідь.\",\n        \"Are you sure you want to reset your form?\": \"Ви впевнені, що хочете скинути налаштування форми?\",\n        \"Code copied to clipboard\": \"Код скопійовано в буфер обміну\",\n        \"Copy code\": \"Скопіювати код\",\n        \"Copy link\": \"Копіювати посилання\",\n        \"Embed this form\": \"Вбудувати цю форму\",\n        \"Link copied to clipboard\": \"Посилання скопійовано в буфер обміну\",\n        \"Preview\": \"Попередній перегляд\",\n        \"Reset\": \"Скинути\",\n        \"Reset form\": \"Скинути форму\",\n        \"Save your document to publish this form.\": \"Збережіть документ, щоб опублікувати цю форму.\",\n        \"Share\": \"Поділитися\",\n        \"Share this form\": \"Поділитися цією формою\",\n        \"View\": \"Переглянути\",\n        \"# **Form Title**\": \"# **Назва форми**\",\n        \"Your form description goes here.\": \"Тут розміщується опис вашої форми.\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"Публікація вашої форми створить посилання для спільного доступу. Будь-хто, хто має посилання, зможе побачити порожню форму та надіслати відповідь.\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"Скасування публікації форми призведе до деактивації посилання для спільного доступу, тому користувачі, які отримують доступ до вашої форми за цим посиланням, побачать помилку.\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"Користувачі можуть лише надсилати (записи у вашій таблиці) та зчитувати попередньо встановлені значення у визначених полях, таких як стовпці посилань та вибору.\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"Вашу форму опубліковано. Кожна зміна активована та видима для користувачів, які мають доступ до форми. Якщо ви хочете внести зміни до чернетки, скасуйте публікацію форми.\"\n    },\n    \"Editor\": {\n        \"Delete\": \"Видалити\"\n    },\n    \"Menu\": {\n        \"Building blocks\": \"Будівельні блоки\",\n        \"Columns\": \"Колонки\",\n        \"Copy\": \"Копіювати\",\n        \"Cut\": \"Вирізати\",\n        \"Insert question above\": \"Вставити запитання вище\",\n        \"Insert question below\": \"Вставити запитання нижче\",\n        \"Paragraph\": \"Абзац\",\n        \"Paste\": \"Вставити\",\n        \"Separator\": \"Роздільник\",\n        \"Unmapped fields\": \"Незіставлені поля\",\n        \"Header\": \"Заголовок\",\n        \"New question\": \"Нове питання\",\n        \"More\": \"Більше\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Clear\": \"Очистити\",\n        \"Map fields\": \"Поля карти\",\n        \"Mapped\": \"Нанесено на карту\",\n        \"Select all\": \"Вибрати все\",\n        \"Unmap fields\": \"Скасувати зіставлення полів\",\n        \"Unmapped\": \"Не нанесено на карту\"\n    },\n    \"FormConfig\": {\n        \"Field rules\": \"Правил поля\",\n        \"Required field\": \"Обов'язкове поле\",\n        \"Ascending\": \"Зростаючий\",\n        \"Default\": \"За замовчуванням\",\n        \"Descending\": \"Спадання\",\n        \"Field Format\": \"Формат поля\",\n        \"Field Rules\": \"Правила поля\",\n        \"Horizontal\": \"Горизонтальний\",\n        \"Options Alignment\": \"Вирівнювання параметрів\",\n        \"Options Sort Order\": \"Порядок сортування параметрів\",\n        \"Radio\": \"Радіо\",\n        \"Select\": \"Виберіть\",\n        \"Vertical\": \"Вертикальний\",\n        \"Accept value from URL\": \"Прийняти значення з URL-адреси\",\n        \"Hidden field\": \"Приховане поле\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"Параметр URL-адреси:\\n{{colId}}=VALUE\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"Деякі обов'язкові стовпці не зіставлені\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Щоб використовувати цей віджет, будь ласка, зіставте всі необов'язкові стовпці з панелі автора праворуч.\",\n        \"Some required columns are hidden by access rules\": \"Деякі обов'язкові стовпці приховані правилами доступу\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"Щоб використовувати цей віджет, усі зіставлені стовпці мають бути видимими. Зверніться до власника документа або змініть правила доступу.\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"Створіть власну форму\",\n        \"Powered by\": \"Працює на\",\n        \"Powered by Grist\": \"Працює на Grist\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"Помилка\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"Ой! Потрібної вам форми не існує.\",\n        \"Oops! This form is no longer published.\": \"Ой! Цю форму більше не публікують.\",\n        \"There was a problem loading the form.\": \"Виникла проблема із завантаженням форми.\",\n        \"You don't have access to this form.\": \"У вас немає доступу до цієї форми.\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"Сталася помилка під час надсилання форми. Будь ласка, спробуйте ще раз.\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"Форму надіслано\",\n        \"Thank you! Your response has been recorded.\": \"Дякуємо! Вашу відповідь записано.\",\n        \"Submit new response\": \"Надіслати нову відповідь\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"Останні 30 днів\",\n        \"Last 7 days\": \"Останні 7 днів\",\n        \"Last week\": \"Минулого тижня\",\n        \"Next 7 days\": \"Наступні 7 днів\",\n        \"This month\": \"Цього місяця\",\n        \"This week\": \"Цього тижня\",\n        \"This year\": \"Цього року\",\n        \"Today\": \"Сьогодні\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Clear\": \"Очистити\",\n        \"Map fields\": \"Поля карти\",\n        \"Mapped\": \"Нанесено на карту\",\n        \"Select all\": \"Вибрати все\",\n        \"Unmap fields\": \"Скасувати зіставлення полів\",\n        \"Unmapped\": \"Не нанесено на карту\",\n        \"Hide {{label}}\": \"Приховати {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"Приховати {{label}} (пакетний режим)\",\n        \"Unmap {{label}}\": \"Зняти з карти {{label}}\",\n        \"Unmap {{label}} (batch mode)\": \"Розв'язати зіставлення {{label}} (пакетний режим)\"\n    },\n    \"Section\": {\n        \"Insert section above\": \"Вставити розділ вище\",\n        \"Insert section below\": \"Вставити розділ нижче\",\n        \"## **Header**\": \"## **Заголовок**\",\n        \"Description\": \"Опис\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"Скасувати\",\n        \"Choose a name and url for your team site\": \"Виберіть назву та URL-адресу для сайту вашої команди\",\n        \"Create site\": \"Створити сайт\",\n        \"Domain name is invalid\": \"Доменне ім'я недійсне\",\n        \"Domain name is required\": \"Потрібне доменне ім'я\",\n        \"Go to your site\": \"Перейдіть на свій сайт\",\n        \"Team name\": \"Назва команди\",\n        \"Team name is required\": \"Назва команди обов'язкова\",\n        \"Team site created\": \"Сайт команди створено\",\n        \"Team url\": \"URL-адреса команди\",\n        \"Work as a Team\": \"Працюйте в команді\",\n        \"Billing is not supported in grist-core\": \"Виставлення рахунків не підтримується в grist-core\"\n    },\n    \"AdminPanel\": {\n        \"Admin Panel\": \"Панель адміністратора\",\n        \"Current\": \"Поточний\",\n        \"Current version of Grist\": \"Поточна версія Grist\",\n        \"Help us make Grist better\": \"Допоможіть нам покращити Grist\",\n        \"Home\": \"Дім\",\n        \"Sponsor\": \"Спонсор\",\n        \"Support Grist\": \"Підтримка Grist\",\n        \"Support Grist Labs on GitHub\": \"Підтримайте Grist Labs на GitHub\",\n        \"Telemetry\": \"Телеметрія\",\n        \"Version\": \"Версія\",\n        \"Auto-check when this page loads\": \"Автоматично перевіряти під час завантаження цієї сторінки\",\n        \"Check now\": \"Перевірте зараз\",\n        \"Checking for updates...\": \"Перевірка оновлень...\",\n        \"Error\": \"Помилка\",\n        \"Error checking for updates\": \"Помилка перевірки оновлень\",\n        \"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 дозволяє створювати дуже потужні формули за допомогою Python. Ми рекомендуємо встановити змінну середовища GRIST_SANDBOX_FLAVOR у значення gvisor, якщо ваше обладнання це підтримує (більшість підтримує), щоб запускати формули в кожному документі в межах пісочниці, ізольованої від інших документів та мережі.\",\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 підписує файли cookie сеансу користувача секретним ключем. Будь ласка, встановіть цей ключ за допомогою змінної середовища GRIST_SESSION_SECRET. Grist повертається до жорстко запрограмованого значення за замовчуванням, якщо його не встановлено. Ми можемо видалити це повідомлення в майбутньому, оскільки ідентифікатори сеансу, згенеровані з версії 1.1.16, є за своєю суттю криптографічно захищеними.\",\n        \"Grist is up to date\": \"Grist актуальний\",\n        \"Grist releases are at \": \"Релізи Grist знаходяться на \",\n        \"Last checked {{time}}\": \"Остання перевірка {{time}}\",\n        \"Learn more.\": \"Дізнайтеся більше.\",\n        \"Newer version available\": \"Доступна новіша версія\",\n        \"No information available\": \"Інформація недоступна\",\n        \"OK\": \"Гаразд\",\n        \"Sandbox settings for data engine\": \"Налаштування пісочниці для механізму обробки даних\",\n        \"Sandboxing\": \"Пісочниця\",\n        \"Security Settings\": \"Налаштування безпеки\",\n        \"Updates\": \"Оновленням\",\n        \"unconfigured\": \"неналаштований\",\n        \"unknown\": \"невідомий\",\n        \"Administrator Panel Unavailable\": \"Панель адміністратора недоступна\",\n        \"Authentication\": \"Автентифікація\",\n        \"Check failed.\": \"Перевірка не вдалася.\",\n        \"Check succeeded.\": \"Перевірка успішна.\",\n        \"Current authentication method\": \"Поточний метод автентифікації\",\n        \"Details\": \"Деталі\",\n        \"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 доступний через мережу або      доступний кільком людям.\",\n        \"No fault detected.\": \"Несправності не виявлено.\",\n        \"Notes\": \"Нотатки\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"Або, як резервний варіант, ви можете встановити: {{bootKey}} у середовищі та відвідати: {{url}}\",\n        \"Results\": \"Результати\",\n        \"Self Checks\": \"Самоперевірки\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"У вас немає доступу до панелі адміністратора.\\nБудь ласка, увійдіть як адміністратор.\",\n        \"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 доступний через мережу або доступний кільком людям.\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"Grist підписує файли cookie сеансу користувача секретним ключем. Будь ласка, встановіть цей ключ за допомогою змінної середовища GRIST_SESSION_SECRET. Grist повертається до жорстко запрограмованого значення за замовчуванням, якщо його не встановлено. Ми можемо видалити це повідомлення в майбутньому, оскільки ідентифікатори сеансу, згенеровані з версії 1.1.16, є за своєю суттю криптографічно захищеними.\",\n        \"Key to sign sessions with\": \"Ключ до сеансів підписання з\",\n        \"Session Secret\": \"Секрет сесії\",\n        \"Enable Grist Enterprise\": \"Увімкнути Grist Enterprise\",\n        \"Enterprise\": \"Підприємство\",\n        \"checking\": \"перевірка\",\n        \"Audit Logs\": \"Журнали аудиту\",\n        \"Contact us\": \"Зв'яжіться з нами\",\n        \"Log Streaming\": \"Потокове Передавання журналів\",\n        \"New, Enterprise\": \"Новий, Корпоративний\",\n        \"Off\": \"Вимк\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + ще {{- remainingDestinationsCount}}\",\n        \"On\": \"Увімк\",\n        \"Grist Instance\": \"Примірник зерна\",\n        \"Auto-check weekly\": \"Автоматична перевірка щотижня\",\n        \"No record of last version check\": \"Немає запису про останню перевірку версії\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Ви можете налаштувати потокову передачу подій аудиту з Grist до зовнішньої системи керування інформацією та подіями безпеки (SIEM), якщо ввімкнете Grist Enterprise. {{contactUsLink}}, щоб дізнатися більше.\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"Автоматичні перевірки вимкнено. Щоб увімкнути їх, встановіть для змінної середовища GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING значення \\\"true\\\".\",\n        \"auth error\": \"помилка авторизації\",\n        \"configured\": \"налаштовано\",\n        \"default\": \"за замовчуванням\",\n        \"more...\": \"більше...\",\n        \"no authentication\": \"без автентифікації\",\n        \"unavailable\": \"недоступний\",\n        \"{{count}} admin accounts_one\": \"обліковий запис адміністратора ({{count}})\",\n        \"{{count}} admin accounts_other\": \"{{count}} облікових записів адміністратора\",\n        \"Admin account not found\": \"Обліковий запис адміністратора не знайдено\",\n        \"Administrative accounts\": \"Адміністративні облікові записи\",\n        \"The users with administrative accounts\": \"Користувачі з обліковими записами адміністраторів\",\n        \"Version {{versionNumber}}\": \"Версія {{versionNumber}}\",\n        \"no admin accounts\": \"немає облікових записів адміністратора\",\n        \"Are you sure you want to restart Grist?\": \"Ви впевнені, що хочете перезапустити Grist?\",\n        \"Grist is running in an environment that doesn't support restarting from the admin panel.\": \"Grist працює в середовищі, яке не підтримує перезапуск з панелі адміністратора.\",\n        \"Restart\": \"Перезавантажити\",\n        \"Restart Grist\": \"Перезавантажте Grist\",\n        \"Restart Grist to apply pending changes or resolve issues.\": \"Перезапустіть Grist, щоб застосувати зміни, що очікують на виконання, або вирішити проблеми.\",\n        \"Restart Grist?\": \"Перезапустити Grist?\",\n        \"Restarting Grist...\": \"Перезапуск Grist...\",\n        \"This will apply any pending changes and briefly interrupt access for all users.\": \"Це застосує всі незавершені зміни та ненадовго перерве доступ для всіх користувачів.\",\n        \"You can still restart Grist manually.\": \"Ви все ще можете перезапустити Grist вручну.\",\n        \"error in {{provider}}: {{verdict}}\": \"помилка в {{provider}}: {{verdict}}\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"Вилучити стовпець\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"Немає налаштованих варіантів\",\n        \"No values in show column of referenced table\": \"Немає значень у стовпці \\\"Показати\\\" таблиці з посиланням\",\n        \"Hide\": \"Приховати\"\n    },\n    \"Toggle\": {\n        \"Checkbox\": \"Прапорець\",\n        \"Field Format\": \"Формат поля\",\n        \"Switch\": \"Перемикач'\"\n    },\n    \"ChoiceEditor\": {\n        \"Error in dropdown condition\": \"Помилка в умові випадаючого списку\",\n        \"No choices matching condition\": \"Немає варіантів, що відповідають умові\",\n        \"No choices to select\": \"Немає варіантів для вибору\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"Помилка в умові випадаючого списку\",\n        \"No choices matching condition\": \"Немає варіантів, що відповідають умові\",\n        \"No choices to select\": \"Немає варіантів для вибору\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"Умова випадаючого списку\",\n        \"Invalid columns: {{colIds}}\": \"Недійсні стовпці: {{colIds}}\",\n        \"Set dropdown condition\": \"Встановити умову випадаючого списку\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"Введіть умову.\"\n    },\n    \"ReferenceUtils\": {\n        \"Error in dropdown condition\": \"Помилка в умові випадаючого списку\",\n        \"No choices matching condition\": \"Немає варіантів, що відповідають умові\",\n        \"No choices to select\": \"Немає варіантів для вибору\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"Скинути\",\n        \"Search\": \"Пошук\",\n        \"Select...\": \"Виберіть...\",\n        \"Submit\": \"Надіслати\",\n        \"Submitting…\": \"Надсилання…\",\n        \"Clear selection for: {{-inputLabel}}\": \"Очистити вибір для: {{-inputLabel}}\"\n    },\n    \"widgetTypesMap\": {\n        \"Calendar\": \"Календар\",\n        \"Card\": \"Картка\",\n        \"Card List\": \"Список карток\",\n        \"Chart\": \"Діаграма\",\n        \"Custom\": \"Користувацька\",\n        \"Form\": \"Форма\",\n        \"Table\": \"Стіл\"\n    },\n    \"TimingPage\": {\n        \"Average Time (s)\": \"Середній час (с)\",\n        \"Column ID\": \"Ідентифікатор стовпця\",\n        \"Formula timer\": \"Таймер формули\",\n        \"Loading timing data. Don't close this tab.\": \"Завантаження даних часу. Не закривайте цю вкладку.\",\n        \"Max Time (s)\": \"Максимальний час (с)\",\n        \"Number of Calls\": \"Кількість дзвінків\",\n        \"Table ID\": \"Ідентифікатор таблиці\",\n        \"Total Time (s)\": \"Загальний час (с)\"\n    },\n    \"DocTutorial\": {\n        \"Click to expand\": \"Натисніть, щоб розгорнути\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"Бажаєте перезапустити навчальний посібник? Весь прогрес буде втрачено.\",\n        \"End tutorial\": \"Закінчити навчальний посібник\",\n        \"Finish\": \"Фініш\",\n        \"Next\": \"Далі\",\n        \"Previous\": \"Попередній\",\n        \"Restart\": \"Перезавантажити\"\n    },\n    \"OnboardingCards\": {\n        \"3 minute video tour\": \"3-хвилинний відеотур\",\n        \"Complete our basics tutorial\": \"Пройдіть наш базовий посібник\",\n        \"Complete the tutorial\": \"Завершіть навчання\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"Вивчіть основи роботи з довідковими стовпцями, пов'язаними віджетами, типами стовпців та картками.\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"Вивчіть основи роботи з довідковими стовпцями, пов’язаними віджетами, типами стовпців і картками.\"\n    },\n    \"OnboardingPage\": {\n        \"Back\": \"Назад\",\n        \"Discover Grist in 3 minutes\": \"Відкрийте для себе Grist за 3 хвилини\",\n        \"Go hands-on with the Grist Basics tutorial\": \"Ознайомтеся з практичним посібником з основ Grist\",\n        \"Go to the tutorial!\": \"Перейдіть до навчального посібника!\",\n        \"Next step\": \"Наступний крок\",\n        \"Skip step\": \"Пропустити крок\",\n        \"Skip tutorial\": \"Пропустити навчальний посібник\",\n        \"Tell us who you are\": \"Розкажіть нам, хто ви\",\n        \"Type here\": \"Введіть тут\",\n        \"Welcome\": \"Ласкаво просимо\",\n        \"What brings you to Grist (you can select multiple)?\": \"Що привело вас до Grist (можна вибрати кілька варіантів)?\",\n        \"What is your role?\": \"Яка ваша роль?\",\n        \"What organization are you with?\": \"З якою організацією ви працюєте?\",\n        \"Your organization\": \"Ваша організація\",\n        \"Your role\": \"Ваша роль\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist може виглядати як електронна таблиця, але він не завжди так поводиться. Дізнайтеся, що відрізняє Grist від інших.\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"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 після закінчення 30-денного пробного періоду.\\nОтримайте ключ активації, [зареєструвавшись у Grist\\nEnterprise]({{signupLink}}). Вам не потрібен ключ активації для запуску\\nGrist Core.\\n\\nДізнайтеся більше в нашому [Центрі довідки]({{helpCenter}}).\",\n        \"Disable Grist Enterprise\": \"Вимкнути Grist Enterprise\",\n        \"Enable Grist Enterprise\": \"Увімкнути Grist Enterprise\",\n        \"Grist Enterprise is **enabled**.\": \"Grist Enterprise **активовано**.\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"Ключ активації використовується для запуску Grist Enterprise після закінчення 30-денного пробного періоду.\\nОтримайте ключ активації, [зв'язавшись з нами]({{contactLink}}) сьогодні.\\nВам не потрібен ключ активації для запуску Grist Core.\\n\\nДізнайтеся більше в нашому [Центрі допомоги]({{helpCenter}}).\",\n        \"Activate\": \"Активувати\",\n        \"Activation key\": \"Ключ активації\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"Ключ активації використовується для запуску Grist Enterprise після пробного періоду.\\n        30-денний термін минув. Отримайте ключ активації, [зареєструвавшись у Grist]\\n        Enterprise]({{signupLink}}). Вам не потрібен ключ активації для запуску\\n        Grist Core.\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"Для продовження використання Grist Enterprise потрібна активна підписка.\\nВи можете активувати свою підписку, [зареєструвавшись у Grist Enterprise]({{signupLink}}) та вставивши свій\\nключ активації нижче.\",\n        \"Copy to clipboard\": \"Копіювати в буфер обміну\",\n        \"Expiration date\": \"Термін дії\",\n        \"Installation ID copied to clipboard\": \"Ідентифікатор інсталяції скопійовано в буфер обміну\",\n        \"Installation ID:\": \"Ідентифікатор установки:\",\n        \"Installation seats\": \"Монтажні сидіння\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"Дізнайтеся більше в нашому [Центрі допомоги]({{helpCenter}}).\",\n        \"Paste your activation key\": \"Вставте свій ключ активації\",\n        \"Plan name\": \"Назва плану\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"Щоб продовжити користуватися Grist Enterprise, вам потрібно\\n                  [зв’яжіться з нами]({{signupLink}}), щоб отримати ключ активації.\",\n        \"You are currently trialing Grist Enterprise.\": \"Ви зараз тестуєте Grist Enterprise.\",\n        \"You do not have an active subscription.\": \"У вас немає активної підписки.\",\n        \"Your activation key has expired due to exceeding limits.\": \"Термін дії вашого ключа активації закінчився через перевищення лімітів.\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"Ваш екземпляр буде в режимі **лише для читання** через **{{days}}** днів.\",\n        \"Your subscription expired on {{date}}.\": \"Ваша підписка закінчилася {{date}}.\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"Ваш пробний період закінчився **{{expireAt}}**. Щоб продовжити користуватися Grist Enterprise, вам потрібно\\n[зареєструватися в Grist Enterprise]({{signupLink}}) та вставити ключ активації нижче.\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"Видалити\",\n        \"Delete data and this widget.\": \"Видалити дані та цей віджет.\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"Зберегти дані та видалити віджет. Таблиця залишатиметься доступною в {{rawDataLink}}\",\n        \"Table {{tableName}} will no longer be visible\": \"Таблиця {{tableName}} більше не буде видимою\",\n        \"Raw Data page\": \"сторінка необроблених даних\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"Панель адміністратора\"\n    },\n    \"CustomWidgetGallery\": {\n        \"(Missing info)\": \"(Відсутня інформація)\",\n        \"Add widget\": \"Додати віджет\",\n        \"Add Your Own Widget\": \"Додайте свій власний віджет\",\n        \"Add a widget from outside this gallery.\": \"Додайте віджет ззовні цієї галереї.\",\n        \"Cancel\": \"Скасувати\",\n        \"Change widget\": \"Змінити віджет\",\n        \"Choose custom widget\": \"Виберіть власний віджет\",\n        \"Community Widget\": \"Віджет спільноти\",\n        \"Custom URL\": \"Користувацька URL-адреса\",\n        \"Developer:\": \"Розробник:\",\n        \"Grist Widget\": \"Віджет Grist\",\n        \"Last updated:\": \"Останнє оновлення:\",\n        \"Learn more about custom widgets\": \"Дізнайтеся більше про користувацькі віджети\",\n        \"No matching widgets\": \"Немає відповідних віджетів\",\n        \"Search\": \"Пошук\",\n        \"Widget URL\": \"URL-адреса віджета\"\n    },\n    \"markdown\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Нова функція Markdown\\n*\\n* Ми можемо _записати_ [звичайний Markdown](https://markdownguide.org) *всередині*\\n* елемента Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"Перемикач **вимкнено**\",\n        \"The toggle is **on**\": \"Перемикач **увімкнено**\"\n    },\n    \"markdown.d\": {\n        \"# New Markdown Function\\n *\\n *      We can _write_ [the usual Markdown](https:\": {\n            \"\": {\n                \"markdownguide.org) *inside*\\n *      a Grainjs element.\": \"# Нова функція Markdown\\n*\\n* Ми можемо _записати_ [звичайний Markdown](https://markdownguide.org) *всередині*\\n* елемента Grainjs.\"\n            }\n        },\n        \"The toggle is **off**\": \"Перемикач **вимкнено**\",\n        \"The toggle is **on**\": \"Перемикач **увімкнено**\"\n    },\n    \"HomeIntroCards\": {\n        \"3 minute video tour\": \"3-хвилинний відеотур\",\n        \"Blank document\": \"Пустий документ\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"Знайдіть рішення та ознайомтеся з іншими ресурсами {{helpCenterLink}}\",\n        \"Finish our basics tutorial\": \"Завершіть наш базовий навчальний посібник\",\n        \"Help center\": \"Довідковий центр\",\n        \"Import file\": \"Імпортувати файл\",\n        \"Learn more {{webinarsLinks}}\": \"Дізнатися більше {{webinarsLinks}}\",\n        \"Start a new document\": \"Створити новий документ\",\n        \"Templates\": \"Шаблони\",\n        \"Tutorial\": \"Підручник\",\n        \"Webinars\": \"Вебінари\",\n        \"Find solutions and explore more resources\": \"Знайдіть рішення та ознайомтеся з іншими ресурсами\",\n        \"Learn more\": \"Дізнатися більше\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Add two-way reference\": \"Додати двостороннє посилання\",\n        \"Column\": \"Колонка\",\n        \"Delete\": \"Видалити\",\n        \"Delete column {{column}} in table {{table}}?\": \"Видалити стовпець {{column}} у таблиці {{table}}?\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"Це зворотна сторона до стовпця посилання {{column}} у таблиці {{table}}.\",\n        \"Table\": \"Стіл\",\n        \"Two-way Reference\": \"Двосторонній посилальний зв'язок\",\n        \"Delete two-way reference?\": \"Видалити двостороннє посилання?\",\n        \"Target table\": \"Цільова таблиця\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"Це видалить стовпець посилання {{refCol}} у таблиці {{refTable}}. Стовпець посилання {{myName}} залишиться в поточній таблиці {{myTable}}.\"\n    },\n    \"SupportGristButton\": {\n        \"Admin Panel\": \"Панель адміністратора\",\n        \"Close\": \"Закрити\",\n        \"Help Center\": \"Довідковий центр\",\n        \"Opt in to Telemetry\": \"Підключитися до телеметрії\",\n        \"Opted In\": \"Згода у\",\n        \"Support Grist\": \"Підтримка Grist\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"Дякуємо! Ми дуже цінуємо вашу довіру та підтримку. Ви можете відмовитися будь-коли через {{link}} у меню користувача.\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"Підключіться до телеметрії, щоб ми могли зрозуміти, як використовується продукт, і визначити пріоритети майбутніх удосконалень.\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"Ми збираємо лише статистику використання, як детально описано в нашому {{helpCenterLink}}, ніколи не документуємо вміст. Ви можете відмовитися від цього будь-коли через {{supportGristLink}} у меню користувача.\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"Скасувати\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"Кожен запис {{targetTable}} може бути призначений лише одному запису {{sourceTable}}.\",\n        \"Reassign\": \"Перепризначити\",\n        \"Reassign to new {{sourceTable}} records.\": \"Перепризначити новим записам {{sourceTable}}.\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"Перепризначити запису {{sourceTable}} для запису {{sourceName}}.\",\n        \"Record already assigned_one\": \"Запис уже призначено\",\n        \"Record already assigned_other\": \"Записи вже призначені\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"Запис {{targetTable}} {{targetName}} вже призначено запису {{sourceTable}} {{oldSourceName}}.\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Add destination\": \"Додати пункт призначення\",\n        \"Add streaming destination\": \"Додати місце для потокової передачі\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"Ви впевнені, що хочете видалити цей канал потокового передавання? Цю дію не можна скасувати.\",\n        \"Cancel\": \"Скасувати\",\n        \"Delete\": \"Видалити\",\n        \"Delete streaming destination?\": \"Видалити місце призначення для потокової трансляції?\",\n        \"Destination\": \"Пункт призначення\",\n        \"Destinations\": \"Напрямки\",\n        \"Edit\": \"Редагувати\",\n        \"Edit streaming destination\": \"Редагувати місце призначення потокової передачі\",\n        \"Enter URL\": \"Введіть URL-адресу\",\n        \"Enter token\": \"Введіть токен\",\n        \"Learn more\": \"Дізнатися більше\",\n        \"Other\": \"Інше\",\n        \"Save\": \"Зберегти\",\n        \"Splunk\": \"Спланк\",\n        \"Start streaming\": \"Почати трансляцію\",\n        \"Token\": \"Токен\",\n        \"URL\": \"URL-адреса\",\n        \"Set up streaming of audit events from Grist to an external security information and event management (SIEM) system like Splunk. {{learnMoreLink}}.\": \"Налаштуйте потокову передачу подій аудиту з Grist до зовнішньої системи управління інформацією та подіями безпеки (SIEM), такої як Splunk. {{learnMoreLink}}.\"\n    },\n    \"AuditLogsPage\": {\n        \"Audit Logs\": \"Журнали аудиту\",\n        \"Audit logs for {{siteName}}\": \"Журнали аудиту для {{siteName}}\",\n        \"Contact us\": \"Зв'яжіться з нами\",\n        \"Home\": \"Дім\",\n        \"Log streaming\": \"Потокове передавання журналів\",\n        \"Only site owners may access audit logs.\": \"Доступ до журналів аудиту мають лише власники сайтів.\",\n        \"upgrade your plan\": \"оновіть свій план\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"Ви можете налаштувати потокову передачу подій аудиту з Grist до зовнішньої системи SIEM (системи управління інформацією та подіями безпеки), якщо ввімкнете Grist Enterprise. {{contactUsLink}}, щоб дізнатися більше.\",\n        \"You can set up streaming of audit events from Grist to an external SIEM (security information and event management) system if you {{upgradePlanButton}}.\": \"Ви можете налаштувати потокову передачу подій аудиту з Grist до зовнішньої системи SIEM (системи управління інформацією та подіями безпеки), якщо ви {{upgradePlanButton}}.\"\n    },\n    \"DocList\": {\n        \"Access details\": \"Деталі доступу\",\n        \"All\": \"Усі\",\n        \"Current workspace\": \"Поточна робоча область\",\n        \"Delete\": \"Видалити\",\n        \"Delete {{name}}\": \"Видалити {{name}}\",\n        \"Document will be moved to Trash.\": \"Документ буде переміщено до кошика.\",\n        \"Edited {{at}}\": \"Відредаговано {{at}}\",\n        \"Last edited\": \"Остання редакція\",\n        \"Manage users\": \"Керування користувачами\",\n        \"Move\": \"Перемістити\",\n        \"Move {{name}} to workspace\": \"Перемістити {{name}} до робочої області\",\n        \"Name\": \"Ім'я\",\n        \"No documents to show.\": \"Немає документів для пред'явлення.\",\n        \"Pin\": \"Закріпити\",\n        \"Pinned\": \"Закріплено\",\n        \"Recent\": \"Нещодавні\",\n        \"Rename and set icon\": \"Перейменувати та встановити значок\",\n        \"Requires edit permissions\": \"Потрібні дозволи на редагування\",\n        \"Sort by date\": \"Сортувати за датою\",\n        \"Sort by name\": \"Сортувати за назвою\",\n        \"Unpin\": \"Відкріпити\",\n        \"Workspace\": \"Робочий простір\",\n        \"context menu - {{- documentName }}\": \"контекстне меню - {{- documentName }}\",\n        \"Documents list\": \"Список документів\",\n        \"Download document...\": \"Завантажити документ...\",\n        \"Deleted {{at}}\": \"Видалено {{at}}\"\n    },\n    \"RenameDocModal\": {\n        \"Choose color\": \"Виберіть колір\",\n        \"Choose icon\": \"Виберіть значок\",\n        \"Enter document name\": \"Введіть назву документа\",\n        \"Icon\": \"Значок\",\n        \"Name\": \"Ім'я\",\n        \"Rename and set icon\": \"Перейменувати та встановити значок\",\n        \"Reset icon\": \"Значок скидання\"\n    },\n    \"RightPanelUtils\": {\n        \"columns_one\": \"Стовпець\",\n        \"columns_other\": \"Колонки\",\n        \"fields_one\": \"Поле\",\n        \"fields_other\": \"Поля\",\n        \"series_one\": \"Серія\",\n        \"series_other\": \"Серія\"\n    },\n    \"userTrustsCustomWidget\": {\n        \"Be careful with unknown custom widgets\": \"Будьте обережні з невідомими користувацькими віджетами\",\n        \"Please review the following before adding a new custom widget.\": \"Будь ласка, перегляньте наступне, перш ніж додавати новий власний віджет.\",\n        \"Custom widgets are **powerful**! They may be able to read and write your document data, and send it elsewhere.\": \"Користувацькі віджети **потужні**! Вони можуть читати та записувати дані вашого документа, а також надсилати їх в інше місце.\",\n        \"Are you sure you **trust the resource** at this URL?\": \"Ви впевнені, що **довіряєте** ресурсу за цією URL-адресою?\",\n        \"Do you **trust the person** who shared this link?\": \"Ви **довіряєте** людині, яка поділилась цим посиланням?\",\n        \"Have you **reviewed the code** at this URL?\": \"Ви **переглянули** код за цією URL-адресою?\",\n        \"If in doubt, do not install this widget, or ask an administrator of your organization to review it for safety.\": \"Якщо ви сумніваєтеся, не встановлюйте цей віджет або попросіть адміністратора вашої організації перевірити його на предмет безпеки.\",\n        \"I confirm that I understand these warnings and accept the risks\": \"Я підтверджую, що розумію ці попередження та приймаю ризики\"\n    },\n    \"AdminLeftPanel\": {\n        \"Admin area\": \"Адміністративна область\",\n        \"Admin controls\": \"Адміністраторські елементи керування\",\n        \"Docs\": \"Документи\",\n        \"Installation\": \"Інсталяція\",\n        \"Learn more\": \"Дізнатися більше\",\n        \"Orgs\": \"Організації\",\n        \"Users\": \"Користувачі\",\n        \"Workspaces\": \"Робочі простори\",\n        \"Admin Controls\": \"Елементи керування адміністратора\",\n        \"Settings\": \"Налаштування\"\n    },\n    \"Assistant\": {\n        \"AI Assistant is only available for logged in users.\": \"AI Assistant доступний лише для зареєстрованих користувачів.\",\n        \"Apply\": \"Застосувати\",\n        \"For higher limits, contact the site owner.\": \"Щоб дізнатися про вищі ліміти, зверніться до власника сайту.\",\n        \"For higher limits, {{upgradeNudge}}.\": \"Для вищих лімітів, {{upgradeNudge}}.\",\n        \"Learn more.\": \"Дізнайтеся більше.\",\n        \"Press Enter to apply suggested formula.\": \"Натисніть Enter, щоб застосувати запропоновану формулу.\",\n        \"Sign Up for Free\": \"Зареєструйтесь безкоштовно\",\n        \"Sign up for a free Grist account to start using the AI Assistant.\": \"Зареєструйте безкоштовний обліковий запис Grist, щоб почати користуватися AI Assistant.\",\n        \"Upgrade to Grist Enterprise to try the new Grist Assistant. {{learnMoreLink}}\": \"Оновіть Grist Enterprise, щоб спробувати новий Grist Assistant. {{learnMoreLink}}\",\n        \"What do you need help with?\": \"З чим вам потрібна допомога?\",\n        \"You have used all available credits.\": \"Ви використали всі доступні кредити.\",\n        \"You have {{numCredits}} remaining credits.\": \"У вас залишилося кредитів: {{numCredits}}.\",\n        \"start a new chat\": \"почати новий чат\",\n        \"upgrade to the Pro Team plan\": \"перейти на план Pro Team\",\n        \"upgrade your plan\": \"оновіть свій план\",\n        \"The conversation has become too long and I can no longer respond effectively. Please {{startANewChatButton}} to continue receiving assistance.\": \"Розмова стала занадто довгою, і я більше не можу ефективно відповідати. Будь ласка, {{startANewChatButton}}, щоб продовжувати отримувати допомогу.\"\n    },\n    \"apiconsole\": {\n        \"Are you sure you want to delete the following?\": \"Ви впевнені, що хочете видалити наступне?\",\n        \"Confirm Deletion\": \"Підтвердити видалення\",\n        \"Delete\": \"Видалити\",\n        \"Deletion was not confirmed, skipping.\": \"Видалення не підтверджено, пропускаю.\",\n        \"Type DELETE here if you wish to proceed.\": \"Введіть тут DELETE, якщо хочете продовжити.\",\n        \"Type DELETE if you are sure you do indeed wish to do this deletion.\\nIf you are not sure, or do not understand what this operation will do,\\nit would be wise to cancel it.\": \"Введіть DELETE, якщо ви впевнені, що дійсно хочете виконати це видалення.\\nЯкщо ви не впевнені або не розумієте, що робитиме ця операція,\\nбуло б розумно скасувати її.\"\n    },\n    \"MentionTextBox\": {\n        \"no access\": \"немає доступу\",\n        \"...loading\": \"...завантаження\"\n    },\n    \"VersionUpdateBanner\": {\n        \"There is a critical Grist update available.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Доступне критичне оновлення Grist.\\nРозгляньте можливість оновлення до версії {{version}} якомога швидше.\",\n        \"Your Grist version is outdated.\\nConsider upgrading to version {{version}} as soon as possible.\": \"Ваша версія Grist застаріла.\\nПодумайте про оновлення до версії {{version}} якомога швидше.\"\n    },\n    \"ExternalAttachmentBanner\": {\n        \"Recommendation: {{storageRecommendation}}\\nWhen storing large attachments, or many of them, we recommend\\nkeeping them in external storage. This document is currently\\nusing internal storage for attachments, which keeps it\\nself-contained but may limit performance.\": \"Рекомендація: {{storageRecommendation}}\\nПід час зберігання великих вкладень або багатьох з них ми рекомендуємо\\nзберігати їх у зовнішньому сховищі. Цей документ наразі\\nвикористовує внутрішнє сховище для вкладень, що забезпечує його автономність,\\nале може обмежувати продуктивність.\",\n        \"Set the document to use external storage.\": \"Налаштуйте використання зовнішнього сховища для документа.\"\n    },\n    \"ToggleEnterpriseModel\": {\n        \"Please wait for the previous operation to complete.\": \"Будь ласка, зачекайте завершення попередньої операції.\",\n        \"Timed out on waiting for the Grist backend to restart\": \"Час очікування на перезапуск серверної частини Grist минув\"\n    },\n    \"Experiments\": {\n        \"Disable feature\": \"Вимкнути функцію\",\n        \"Don't worry, you can disable it later if needed.\": \"Не хвилюйтеся, ви можете вимкнути його пізніше, якщо потрібно.\",\n        \"Enable feature\": \"Увімкнути функцію\",\n        \"Experimental feature\": \"Експериментальна функція\",\n        \"New record button\": \"Кнопка нового запису\",\n        \"Reload the page\": \"Перезавантажте сторінку\",\n        \"Visit this URL at any time to stop using this feature: {{url}}\": \"Щоб припинити використання цієї функції, відвідайте цю URL-адресу будь-коли: {{url}}\",\n        \"You are about to disable this experimental feature: {{experiment}}\": \"Ви збираєтеся вимкнути цю експериментальну функцію: {{experiment}}\",\n        \"You are about to enable this experimental feature: {{experiment}}\": \"Ви збираєтеся ввімкнути цю експериментальну функцію: {{experiment}}\",\n        \"{{experiment}} disabled.\": \"{{experiment}} вимкнено.\",\n        \"{{experiment}} enabled.\": \"{{experiment}} увімкнено.\"\n    },\n    \"NewRecordButton\": {\n        \"New card\": \"Нова картка\",\n        \"New record\": \"Новий рекорд\"\n    },\n    \"RegionFocusSwitcher\": {\n        \"Trying to access the creator panel? Use {{key}}.\": \"Намагаєтеся отримати доступ до панелі автора? Використайте {{key}}.\"\n    },\n    \"duplicateWidget\": {\n        \"Duplicate widget\": \"Дублікат віджета\",\n        \"Duplicate widgets\": \"Дублікати віджетів\",\n        \"Active\": \"Активний\",\n        \"Create new page\": \"Створити нову сторінку\"\n    },\n    \"AttachmentsWidget\": {\n        \"Uploading, please wait…\": \"Завантаження, зачекайте…\"\n    },\n    \"AttachmentsEditor\": {\n        \"Add\": \"Додати\",\n        \"Delete\": \"Видалити\",\n        \"Download\": \"Завантажити\",\n        \"Drop files here to attach\": \"Перетягніть сюди файли, щоб прикріпити їх\",\n        \"Drop files here to attach.\": \"Перетягніть сюди файли, щоб прикріпити їх.\",\n        \"No attachments\": \"Без вкладень\",\n        \"Preview not available.\": \"Попередній перегляд недоступний.\",\n        \"{{index}} of {{total}}\": \"{{index}} з {{total}}\",\n        \"Uploading…\": \"Вавантаження…\"\n    },\n    \"RowHeightConfig\": {\n        \"Expand all rows to this height\": \"Розгорнути всі рядки до цієї висоти\",\n        \"Max height\": \"Максимальна висота\",\n        \"Max row height\": \"Максимальна висота рядка\",\n        \"Change\": \"Зміна\"\n    },\n    \"TreeViewComponent\": {\n        \"Collapse\": \"Згорнути'\",\n        \"Expand\": \"Розгорнути\"\n    },\n    \"ActiveUserList\": {\n        \"active user\": \"активний користувач\",\n        \"active user list\": \"список активних користувачів\",\n        \"open full active user list\": \"відкрити повний список активних користувачів\"\n    },\n    \"AdminChecks\": {\n        \"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 дозволяє створювати дуже потужні формули за допомогою Python. Ми рекомендуємо встановити змінну середовища GRIST_SANDBOX_FLAVOR на gvisor, якщо ваше обладнання це підтримує (а більшість підтримує), щоб запускати формули в кожному документі в ізольованому від інших документів і мережі пісочниці.\",\n        \"Grist has a small built-in health check often used when running it as a container.\": \"Grist має невелику вбудовану перевірку справності, яка часто використовується під час запуску його як контейнера.\",\n        \"Requests arriving to Grist should have an accurate Host header. This is essential when GRIST_SERVE_SAME_ORIGIN is set.\": \"Запити, що надходять до Grist, повинні мати точний заголовок Host. Це важливо, коли встановлено GRIST_SERVE_SAME_ORIGIN.\",\n        \"This boot page should not be too easy to access. Either turn it off when configuration is ok (by unsetting GRIST_BOOT_KEY) or make GRIST_BOOT_KEY long and cryptographically secure.\": \"Ця сторінка завантаження не повинна бути надто легкодоступною. Або вимкніть її, коли конфігурація влаштована (скинувши GRIST_BOOT_KEY), або зробіть GRIST_BOOT_KEY довгим та криптографічно захищеним.\",\n        \"Websocket connections need HTTP 1.1 and the ability to pass a few extra headers in order to work. Sometimes a reverse proxy can interfere with these requirements.\": \"Для роботи вебсокетних з'єднань потрібен HTTP 1.1 та можливість передавати кілька додаткових заголовків. Іноді зворотний проксі-сервер може перешкоджати цим вимогам.\",\n        \"It is good practice not to run Grist as the root user.\": \"Не рекомендується запускати Grist від імені користувача root.\",\n        \"The main page of Grist should be available.\": \"Головна сторінка Grist має бути доступна.\"\n    },\n    \"ChoiceListEntry\": {\n        \"+{{count}} more_one\": \"+{{count}} більше\",\n        \"+{{count}} more_other\": \"+{{count}} більше\",\n        \"Edit\": \"Редагувати\",\n        \"No choices configured\": \"Немає налаштованих варіантів\",\n        \"Reset\": \"Скинути\",\n        \"Cancel\": \"Скасувати\",\n        \"Save\": \"Зберегти\"\n    },\n    \"FormulaTransform\": {\n        \"Apply\": \"Застосувати\",\n        \"Cancel\": \"Скасувати\",\n        \"Preview\": \"Попередній перегляд\"\n    },\n    \"ParseOptions\": {\n        \"Close\": \"Закрити\",\n        \"Update preview\": \"Попередній перегляд оновлення\",\n        \"Convert quoted fields\": \"Конвертувати поля в лапках\",\n        \"Escape character\": \"Символ Escape\",\n        \"Field separator\": \"Роздільник полів\",\n        \"First row contains headers\": \"Перший рядок містить заголовки\",\n        \"Line terminator\": \"Термінатор лінії\",\n        \"Number of rows\": \"Кількість рядків\",\n        \"Quote character\": \"Цитата персонажа\",\n        \"Quotes in fields are doubled\": \"Лапки в полях подвоюються\",\n        \"Skip leading whitespace\": \"Пропустити початкові пробіли\",\n        \"Start with row\": \"Почати з рядка\",\n        \"Character encoding. See [the supported codecs]({{link}})\": \"Кодування символів. Див. [підтримувані кодеки]({{link}})\"\n    },\n    \"OpenAccessibilityModal\": {\n        \" or \": \" або \",\n        \"\\\"Regions\\\" are what we call the different parts of the user interface:\": \"«Регіонами» ми називаємо різні частини інтерфейсу користувача:\",\n        \"Accessibility\": \"Доступність\",\n        \"Close\": \"Закрити\",\n        \"Finally, the right panel – or the creator panel – is only available through its own shortcut and is not included in the next and previous region cycle.\": \"Зрештою, права панель – або панель творця – доступна лише через власний ярлик і не включена до наступного та попереднього циклу регіонів.\",\n        \"Focus on other parts of the user interface using the following shortcuts:\": \"Зосередьтеся на інших частинах інтерфейсу користувача за допомогою таких комбінацій клавіш:\",\n        \"High contrast theme\": \"Тема високої контрастності\",\n        \"Keyboard navigation\": \"Навігація по клавіатурі\",\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"На сторінці документа навігація за допомогою клавіатури спочатку блокується на поточному віджеті.\",\n        \"On document pages, each [widget]({{supportPageUrl}}) is a region that can receive focus.\": \"На сторінках документа кожен [віджет]({{supportPageUrl}}) – це область, на яку може перевести фокус.\",\n        \"On non-document pages, the main content area is a region.\": \"На сторінках, що не є документами, основною областю вмісту є регіон.\",\n        \"Other important keyboard shortcuts\": \"Інші важливі комбінації клавіш\",\n        \"The left panel, home of the main navigation.\": \"Ліва панель, де розташована основна панель навігації.\",\n        \"The top panel, or the document header.\": \"Верхня панель або заголовок документа.\",\n        \"To see other available themes, go to your {{profileSettingsLink}}.\": \"Щоб переглянути інші доступні теми, перейдіть до свого {{profileSettingsLink}}.\",\n        \"Use the high contrast theme (light appearance)\": \"Використовуйте тему високої контрастності (світлий вигляд)\",\n        \"You are currently **not using** the high contrast theme.\": \"Ви зараз **не використовуєте** тему високої контрастності.\",\n        \"You are currently using the high contrast theme.\": \"Ви зараз використовуєте тему високої контрастності.\",\n        \"profile settings\": \"налаштування профілю\",\n        \"{{accessibilityModal}} Show the accessibility options (this modal)\": \"{{accessibilityModal}} Показати параметри доступності (це модальне вікно)\",\n        \"{{creatorPanelShortcut}} Focus to and from the creator panel\": \"{{creatorPanelShortcut}} Фокус на панель творця та назад\",\n        \"{{nextRegionShortcut}} Focus on the next region\": \"{{nextRegionShortcut}} Перейти до наступного регіону\",\n        \"{{prevRegionShortcut}} Focus on the previous region\": \"{{prevRegionShortcut}} Фокус на попередньому регіоні\",\n        \"{{shortcutsModal}} Show the complete list of keyboard shortcuts\": \"{{shortcutsModal}} Показати повний список комбінацій клавіш\"\n    },\n    \"ProposedChangesPage\": {\n        \"Proposed changes\": \"Запропоновані зміни\",\n        \"Replace original\": \"Замінити оригінал\",\n        \"This is a list of changes relative to the original document.\": \"Це список змін порівняно з оригінальним документом.\",\n        \"Accept\": \"Прийняти\",\n        \"Accepted {{at}}.\": \"Прийнято {{at}}.\",\n        \"Dismiss\": \"Відхилити\",\n        \"Dismissed {{at}}.\": \"Відхилено {{at}}.\",\n        \"Learn more\": \"Дізнатися більше\",\n        \"No changes found to suggest. Please make some edits.\": \"Не знайдено жодних змін, які можна запропонувати. Будь ласка, внесіть деякі зміни.\",\n        \"Retract suggestion\": \"Відкликати пропозицію\",\n        \"Retracted {{at}}.\": \"Відтягнуто {{at}}.\",\n        \"Suggest change\": \"Запропонувати зміну\",\n        \"Suggest changes\": \"Запропонувати зміни\",\n        \"Suggestion made {{at}}.\": \"Зроблено пропозицію {{at}}.\",\n        \"Suggestions\": \"Пропозиції\",\n        \"The original document isn't asking for proposed changes.\": \"У вихідному документі не міститься запит на внесення змін.\",\n        \"There are fresh changes that haven't been added to the suggestion yet.\": \"До пропозиції ще не додано нові зміни.\",\n        \"This is a list of changes relative to the {{originalDocument}}.\": \"Це список змін, що стосуються {{originalDocument}}.\",\n        \"Update suggestion\": \"Пропозиція оновлення\",\n        \"Work on a copy\": \"Робота над копією\",\n        \"Your suggestions\": \"Ваші пропозиції\",\n        \"experiment\": \"експеримент\",\n        \"original document\": \"оригінальний документ\",\n        \"Undo dismissal\": \"Відмінити відхилення\"\n    },\n    \"commandList\": {\n        \"Show accessibility options\": \"Показати параметри доступності\",\n        \"Activate assistant\": \"Активувати помічника\",\n        \"Add a new viewsection to the currently active view\": \"Додати новий розділ перегляду до поточного активного перегляду\",\n        \"Adds all elements above the cursor to the selected range\": \"Додає всі елементи над курсором до вибраного діапазону\",\n        \"Adds all elements below the cursor to the selected range\": \"Додає всі елементи під курсором до вибраного\",\n        \"Adds all elements to the left of the cursor to the selected range\": \"Додає всі елементи ліворуч від курсора до вибраного діапазону\",\n        \"Adds all elements to the right of the cursor to the selected range\": \"Додає всі елементи праворуч від курсора до вибраного діапазону\",\n        \"Adds the currently selected column(ascending) to the current view's sort spec\": \"Додає поточний вибраний стовпець (за зростанням) до специфікації сортування поточного перегляду\",\n        \"Adds the currently selected column(descending) to the current view's sort spec\": \"Додає поточний вибраний стовпець (за спаданням) до специфікації сортування поточного перегляду\",\n        \"Adds the element above the cursor to the selected range\": \"Додає елемент над курсором до вибраного діапазону\",\n        \"Adds the element below the cursor to the selected range\": \"Додає елемент під курсором до вибраного діапазону\",\n        \"Adds the element to the left of the cursor to the selected range\": \"Додає елемент ліворуч від курсора до вибраного діапазону\",\n        \"Adds the element to the right of the cursor to the selected range\": \"Додає елемент праворуч від курсора до вибраного діапазону\",\n        \"Clear the selected columns\": \"Очистити вибрані стовпці\",\n        \"Clears the current copy selection, if any\": \"Очищає поточний вибір копії, якщо такий є\",\n        \"Clears the currently selected cells\": \"Очищає вибрані клітинки\",\n        \"Clears the section links in the current view\": \"Очищає посилання розділів у поточному поданні\",\n        \"Clears the section links in the current viewsection\": \"Очищає посилання розділів у поточному розділі перегляду\",\n        \"Collapse the currently active viewsection\": \"Згорнути поточну активну секцію перегляду\",\n        \"Convert the selected columns from formula to data\": \"Перетворити вибрані стовпці з формули на дані\",\n        \"Copy anchor link\": \"Копіювати посилання-якоря\",\n        \"Copy current selection to clipboard\": \"Копіювати поточний вибір у буфер обміну\",\n        \"Copy current selection to clipboard including headers\": \"Копіювати поточний вибір у буфер обміну, включаючи заголовки\",\n        \"Creates form for active table\": \"Створює форму для активної таблиці\",\n        \"Cut current selection to clipboard\": \"Вирізати поточний вибір у буфер обміну\",\n        \"Delete collapsed viewsection\": \"Видалити згорнутий розділ перегляду\",\n        \"Delete the currently active viewsection\": \"Видалити поточну активну секцію перегляду\",\n        \"Delete the currently selected columns\": \"Видалити вибрані стовпці\",\n        \"Delete the currently selected record(s)\": \"Видалити вибрані записи\",\n        \"Detach active editor\": \"Від’єднати активний редактор\",\n        \"Discard changes to a cell value\": \"Скасувати зміни значення клітинки\",\n        \"Display Grist documentation\": \"Документація про відображення Grist\",\n        \"Display shortcuts pane\": \"Відображення панелі швидких команд\",\n        \"Duplicate the currently active viewsection\": \"Дублювати поточну активну секцію перегляду\",\n        \"Duplicate the currently selected record(s)\": \"Дублювати вибрані записи\",\n        \"Edit label of the currently-selected field\": \"Редагувати підпис вибраного поля\",\n        \"Edit record layout\": \"Редагувати макет запису\",\n        \"Enter text into currently-selected cell and start editing\": \"Введіть текст у вибрану клітинку та розпочніть редагування\",\n        \"Enters section linking mode in the current view\": \"Вводить режим зв'язування розділів у поточному поданні\",\n        \"Exits section linking mode in the current view\": \"Вихід із режиму зв'язування розділів у поточному поданні\",\n        \"Expand collapsed viewsection\": \"Розгорнути згорнутий розділ перегляду\",\n        \"Fills current selection with the contents of the top row in the selection\": \"Заповнює поточний вибір вмістом верхнього рядка у виділенні\",\n        \"Find\": \"Знайти\",\n        \"Find next occurrence\": \"Знайти наступний екземпляр\",\n        \"Find previous occurrence\": \"Знайти попереднє входження\",\n        \"Finish editing a cell and save without moving to next record\": \"Завершити редагування комірки та зберегти зміни без переходу до наступного запису\",\n        \"Finish editing a cell, saving the value\": \"Завершити редагування клітинки, зберігаючи значення\",\n        \"Focus next page panel or widget\": \"Фокус на наступній сторінці панелі або віджета\",\n        \"Focus previous page panel or widget\": \"Фокус на попередній панелі сторінки або віджеті\",\n        \"Freeze or unfreeze selected columns\": \"Заморозити або розморозити вибрані стовпці\",\n        \"Hide the currently selected columns\": \"Приховати вибрані стовпці\",\n        \"Hide the currently selected fields\": \"Приховати вибрані поля\",\n        \"Insert a new column, after the currently selected one\": \"Вставити новий стовпець після поточного вибраного\",\n        \"Insert a new column, before the currently selected one\": \"Вставити новий стовпець перед поточним вибраним\",\n        \"Insert a new record, after the currently selected one in an unsorted table\": \"Вставити новий запис після поточного вибраного в невідсортовану таблицю\",\n        \"Insert a new record, before the currently selected one in an unsorted table\": \"Вставити новий запис перед поточним вибраним у невідсортованій таблиці\",\n        \"Insert new column in default location\": \"Вставити новий стовпець у місце за замовчуванням\",\n        \"Insert the current date\": \"Вставте поточну дату\",\n        \"Insert the current date and time\": \"Вставити поточну дату та час\",\n        \"Maximize the active section\": \"Максимізувати активний розділ\",\n        \"Move down one page of records, or to next record in a card list\": \"Перейти на одну сторінку записів вниз або до наступного запису у списку карток\",\n        \"Move down to the last record\": \"Перейти до останнього запису\",\n        \"Move downward five records\": \"Перемістити вниз на п'ять записів\",\n        \"Move downward to next record or field\": \"Перейти вниз до наступного запису або поля\",\n        \"Move left to the previous field\": \"Перейти ліворуч до попереднього поля\",\n        \"Move right to the next field\": \"Перейти праворуч до наступного поля\",\n        \"Move to the first field or the beginning of a row\": \"Перехід до першого поля або початку рядка\",\n        \"Move to the last field or the end of a row\": \"Перехід до останнього поля або кінця рядка\",\n        \"Move to the next field, saving changes if editing a value\": \"Перейти до наступного поля, зберігаючи зміни, якщо значення редагується\",\n        \"Move to the previous field, saving changes if editing a value\": \"Перейти до попереднього поля, зберігаючи зміни, якщо значення редагується\",\n        \"Move up one page of records, or to previous record in a card list\": \"Перейти на одну сторінку записів угору або до попереднього запису у списку карток\",\n        \"Move up to the first record\": \"Перейти до першого запису\",\n        \"Move upward five records\": \"Перемістити на п'ять записів угору\",\n        \"Move upward to previous record or field\": \"Перейти вгору до попереднього запису або поля\",\n        \"Moves the cursor to the correct location\": \"Переміщує курсор у потрібне місце\",\n        \"Open Custom widget configuration screen\": \"Відкрити екран налаштування власного віджета\",\n        \"Open comment thread\": \"Відкрити ланцюжок коментарів\",\n        \"Open next page\": \"Відкрити наступну сторінку\",\n        \"Open previous page\": \"Відкрити попередню сторінку\",\n        \"Opens document list\": \"Відкриває список документів\",\n        \"Paste clipboard contents at cursor\": \"Вставити вміст буфера обміну в місце курсора\",\n        \"Print currently selected page widget\": \"Віджет друку поточної вибраної сторінки\",\n        \"Push an undo action\": \"Скасувати дію\",\n        \"Redo last action\": \"Повторити останню дію\",\n        \"Rename the currently selected column\": \"Перейменувати вибраний стовпець\",\n        \"Reverts the sections links to the saved links the current view\": \"Повертає посилання розділів до збережених посилань поточного перегляду\",\n        \"Saves the sections links in the current view\": \"Зберігає посилання розділів у поточному поданні\",\n        \"Selects all currently displayed cells\": \"Вибирає всі відображені на даний момент клітинки\",\n        \"Shortcut to data selection tab\": \"Ярлик до вкладки вибору даних\",\n        \"Shortcut to focus view tab if creator panel is open\": \"Ярлик для переходу на вкладку перегляду, якщо відкрито панель автора\",\n        \"Shortcut to open document tab\": \"Ярлик для відкриття вкладки документа\",\n        \"Shortcut to open field tab\": \"Ярлик для відкриття вкладки поля\",\n        \"Shortcut to open sort & filter menu\": \"Ярлик для відкриття меню сортування та фільтрації\",\n        \"Shortcut to open the left panel\": \"Швидкий доступ для відкриття лівої панелі\",\n        \"Shortcut to open the right panel\": \"Швидкий доступ для відкриття правої панелі\",\n        \"Shortcut to open view tab\": \"Ярлик для відкриття вкладки «Вигляд»\",\n        \"Shortcut to sort & filter tab\": \"Ярлик до вкладки сортування та фільтрації\",\n        \"Show hidden columns\": \"Показати приховані стовпці\",\n        \"Show raw data widget for table of currently selected page widget\": \"Віджет показу необроблених даних для таблиці віджета поточної вибраної сторінки\",\n        \"Show the record card widget of the selected record\": \"Показати віджет картки запису вибраного запису\",\n        \"Sort the view data by the currently selected field in ascending order\": \"Сортувати дані перегляду за вибраним полем у порядку зростання\",\n        \"Sort the view data by the currently selected field in descending order\": \"Сортувати дані перегляду за вибраним полем у порядку спадання\",\n        \"Start editing the currently-selected cell\": \"Почати редагування поточної вибраної комірки\",\n        \"Toggle creator panel keyboard focus\": \"Перемикання фокусу клавіатури панелі творця\",\n        \"Toggle the currently selected checkbox or switch cell\": \"Перемикання вибраного прапорця або перемикання клітинки\",\n        \"Undo last action\": \"Скасувати останню дію\",\n        \"Use the currently selected row as table headers\": \"Використовувати поточний вибраний рядок як заголовки таблиці\",\n        \"When in the search bar, close it and focus the current match\": \"У рядку пошуку закрити його та виділити поточний збіг\",\n        \"When typed at the start of a cell, make this a formula column\": \"Якщо ввести на початку комірки, зробити цей стовпець формулою\",\n        \"showing a behavioral popup\": \"показ поведінкового спливаючого вікна\",\n        \"Filter this column by just this cell's value\": \"Фільтрувати цей стовпець лише за значенням цієї комірки\"\n    },\n    \"GridViewMenusDateHelpers\": {\n        \"12-hour format\": \"12-годинний формат\",\n        \"24-hour format\": \"24-годинний формат\",\n        \"AM\": {\n            \"PM\": \"AM/PM\"\n        },\n        \"Calendar\": \"Календар\",\n        \"Date helpers…\": \"Помічники на побаченнях…\",\n        \"Day\": \"День\",\n        \"Day of month\": \"День місяця\",\n        \"Day of week\": \"День тижня\",\n        \"Day of week (abbrev)\": \"День тижня (скорочено)\",\n        \"Day of week (full)\": \"День тижня (повний)\",\n        \"Day of week (numeric)\": \"День тижня (числовий)\",\n        \"Days since\": \"Днів з того часу\",\n        \"Days until\": \"Днів до\",\n        \"Default\": \"За замовчуванням\",\n        \"End of\": \"Кінець у\",\n        \"Full date\": \"Повна дата\",\n        \"Full name with year\": \"Повне ім'я з роком\",\n        \"Hour\": \"Година\",\n        \"Intervals\": \"Інтервали\",\n        \"Is weekend?\": \"Вихідні?\",\n        \"Minute\": \"Хвилина\",\n        \"Month\": \"Місяць\",\n        \"Months since\": \"Місяці з того часу\",\n        \"Months until\": \"Місяців до\",\n        \"Name only\": \"Тільки ім'я\",\n        \"Number only\": \"Тільки номер\",\n        \"Quarter\": \"Чверть\",\n        \"Quick Picks\": \"Швидкий вибір\",\n        \"Relative\": \"Родич\",\n        \"Short with year\": \"Короткий з роком\",\n        \"Sortable\": \"Сортування\",\n        \"Start of\": \"Початок у\",\n        \"Time\": \"Час\",\n        \"Time bucket\": \"Часовий проміжок\",\n        \"Week\": \"Тиждень\",\n        \"Week of year\": \"Тиждень року\",\n        \"Year\": \"Рік\",\n        \"Years since\": \"Років з того часу\",\n        \"Years until\": \"Років до\"\n    },\n    \"selectBy\": {\n        \"Select widget\": \"Вибрати віджет\"\n    },\n    \"CoreNewDocMethods\": {\n        \"Untitled document\": \"Документ без назви\"\n    },\n    \"AuthenticationSection\": {\n        \"Active\": \"Активний\",\n        \"Active method is controlled by an environment variable. Unset variable to change active method.\": \"Активний метод контролюється змінною середовища. Скасуйте значення змінної, щоб змінити активний метод.\",\n        \"Active on restart\": \"Активний після перезапуску\",\n        \"Are you sure you want to set **{{name}}** as the active authentication method?\": \"Ви впевнені, що хочете встановити **{{name}}** як активний метод автентифікації?\",\n        \"Close\": \"Закрити\",\n        \"Configure\": \"Налаштувати\",\n        \"Configured\": \"Налаштовано\",\n        \"Confirm\": \"Підтвердити\",\n        \"Disabled on restart\": \"Вимкнено після перезавантаження\",\n        \"Error\": \"Помилка\",\n        \"Error details\": \"Деталі помилки\",\n        \"Instructions\": \"Інструкції\",\n        \"No authentication method is active.\": \"Жоден метод автентифікації не активний.\",\n        \"Set as active method\": \"Встановити як активний метод\",\n        \"Set as active method?\": \"Встановити як активний метод?\",\n        \"The new method will go into effect after you restart Grist.\": \"Новий метод набуде чинності після перезапуску Grist.\"\n    },\n    \"DetailView\": {\n        \"This row is unavailable or does not exist\": \"Цей рядок недоступний або не існує\"\n    }\n}\n"
  },
  {
    "path": "static/locales/uk.server.json",
    "content": "{\n    \"sendAppPage\": {\n        \"Loading...\": \"Завантаження...\",\n        \"og-description\": \"Сучасна електронна таблиця з відкритим вихідним кодом, що виходить за межі сітки\",\n        \"og-title\": \"Grist- еволюція електронних таблиць\"\n    },\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"Будь ласка, підтвердьте свою електронну адресу через постачальника ідентифікації та увійдіть знову.\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"Ви не маєте доступу до цього документа.\",\n        \"docDisabled\": \"Цей документ вимкнено.\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"В адміністративній організації, визначеній користувачем, не знайдено власників `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"orgUser\": \"Користувач є власником адміністративної організації, визначеної `GRIST_INSTALL_ADMIN_ORG={{org}}`\",\n        \"accountByEmail\": \"Обліковий запис адміністратора, визначений `GRIST_DEFAULT_EMAIL={{defaultEmail}}`\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"Документ без назви\"\n    }\n}\n"
  },
  {
    "path": "static/locales/ur.client.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/ur.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/vi.client.json",
    "content": "{\n  \"ACUserManager\": {\n    \"Enter email address\": \"Nhập địa chỉ Email\",\n    \"Invite new member\": \"Mời thành viên mới\",\n    \"We'll email an invite to {{email}}\": \"Chúng tôi sẽ gửi email lời mời đến {{email}}\"\n  },\n  \"AccessRules\": {\n    \"Add Default Rule\": \"Thêm quy tác mặc định\",\n    \"Add column rule\": \"Thêm quy tắc cho cột\",\n    \"Allow everyone to copy the entire document, or view it in full in fiddle mode.\\nUseful for examples and templates, but not for sensitive data.\": \"Cho phép mọi người sao chép toàn bộ tài liệu hoặc xem toàn bộ tài liệu ở chế độ trung gian. Hữu ích cho các ví dụ và mẫu, nhưng không dành cho dữ liệu nhạy cảm.\",\n    \"Allow everyone to view Access Rules.\": \"Cho phép mọi người xem quy tắc truy cập\",\n    \"Attribute name\": \"Tên thuộc tính\",\n    \"Attribute to Look Up\": \"Thuộc tính để tra cứu\",\n    \"Checking...\": \"Đang kiểm tra.…\",\n    \"Condition\": \"Điều kiện\",\n    \"Default rules\": \"Quy tắc mặc định\",\n    \"Delete table rules\": \"Xóa quy tắc bảng\",\n    \"Enter Condition\": \"Nhập điều kiện\",\n    \"Everyone\": \"Tất cả mọi người\",\n    \"Everyone Else\": \"Những người khác\",\n    \"Lookup Column\": \"Cột tra cứu\",\n    \"Lookup Table\": \"Bảng tra cứu\",\n    \"Permission to access the document in full when needed\": \"Cho phép truy cập toàn bộ tài liệu khi cần thiết\",\n    \"Permission to view Access Rules\": \"Quyền xem Quy tắc truy cập\",\n    \"Permissions\": \"Quyền hạn\",\n    \"Remove column {{- colId }} from {{- tableId }} rules\": \"Xóa cột {{- colId }} khỏi quy tắc {{- tableId}}\",\n    \"Remove {{- tableId }} rules\": \"Xóa {{- tableId} } quy tắc\",\n    \"Remove {{- name }} user attribute\": \"Xóa {{- name }} thuộc tính người dùng\",\n    \"Reset\": \"Đặt lại\",\n    \"Rules for table \": \"Quy tắc cho bảng \",\n    \"Save\": \"Lưu\",\n    \"Saved\": \"Đã lưu\",\n    \"Special rules\": \"Quy tắc đặc biệt\",\n    \"Type message to display when this rule blocks an action…\": \"Nhập tin nhắn\",\n    \"User Attributes\": \"Thuộc tính người dùng\",\n    \"View as\": \"Xem dưới dạng\",\n    \"Seed rules\": \"Quy tắc hạt giống\",\n    \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"Khi thêm quy tắc bảng, tự động thêm quy tắc để cấp cho CHỦ SỞ HỮU toàn quyền truy cập.\",\n    \"This default should be changed if editors' access is to be limited. \": \"Mặc định này nên được thay đổi nếu quyền truy cập của biên tập viên bị hạn chế. \",\n    \"Add table-wide rule\": \"Thêm quy tắc toàn bảng\",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"Cho phép người chỉnh sửa chỉnh sửa cấu trúc (ví dụ: sửa đổi và xóa bảng, cột, bố cục) và viết công thức, cho phép truy cập vào tất cả dữ liệu bất kể hạn chế đọc.\",\n    \"Add table rules\": \"Thêm quy tắc cho bảng\",\n    \"Add user attributes\": \"Thêm thuộc tính người dùng\",\n    \"Invalid\": \"Không hợp lệ\",\n    \"Permission to edit document structure\": \"Quyền chỉnh sửa cấu trúc tài liệu\"\n  },\n  \"AccountPage\": {\n    \"API\": \"API\",\n    \"API Key\": \"API Key\",\n    \"Account settings\": \"Cài đặt tài khoản\",\n    \"Allow signing in to this account with Google\": \"Cho phép đăng nhập vào tài khoản này bằng Google\",\n    \"Change password\": \"Thay đổi mật khẩu\",\n    \"Edit\": \"Chỉnh sửa\",\n    \"Login method\": \"Phương thức đăng nhập\",\n    \"Name\": \"Tên\",\n    \"Names only allow letters, numbers and certain special characters\": \"Tên chỉ cho phép chữ cái, số và một số ký tự đặc biệt nhất định\",\n    \"Save\": \"Lưu\",\n    \"Theme\": \"Mục\",\n    \"Two-factor authentication\": \"Xác thực hai lớp\",\n    \"Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.\": \"Xác thực hai yếu tố là một lớp bảo mật bổ sung cho tài khoản Grist của bạn được thiết kế để đảm bảo rằng bạn là người duy nhất có thể truy cập vào tài khoản của mình, ngay cả khi ai đó biết mật khẩu của bạn.\",\n    \"Language\": \"Ngôn ngữ\",\n    \"Email\": \"Email\",\n    \"Password & security\": \"Mật khẩu & Bảo mật\"\n  },\n  \"AccountWidget\": {\n    \"Access Details\": \"Chi tiết truy cập\",\n    \"Accounts\": \"Tài khoản\",\n    \"Add account\": \"Thêm tài khoản\",\n    \"Document settings\": \"Cài đặt tài liệu\",\n    \"Manage team\": \"Quản lý nhóm\",\n    \"Pricing\": \"Định giá\",\n    \"Profile settings\": \"Cài đặt hồ sơ\",\n    \"Sign out\": \"Đăng xuất\",\n    \"Sign in\": \"Đăng nhập\",\n    \"Switch Accounts\": \"Chuyển đổi tài khoản, Sử dụng tài khoản khác\",\n    \"Toggle Mobile Mode\": \"Chuyển đổi chế độ di động\",\n    \"Billing account\": \"Tài khoản thanh toán\",\n    \"Support Grist\": \"Hỗ trợ Grist\",\n    \"Upgrade Plan\": \"Gói nâng cấp\",\n    \"Sign up\": \"Đăng ký\",\n    \"Use This Template\": \"Sử dụng mẫu này\",\n    \"Activation\": \"Kích hoạt\"\n  },\n  \"ViewAsDropdown\": {\n    \"Users from table\": \"Người dùng từ bảng\",\n    \"Example Users\": \"Ví dụ người dùng\",\n    \"View as\": \"Xem dưới dạng\"\n  },\n  \"ActionLog\": {\n    \"Action Log failed to load\": \"Nhật ký hành động không tải được\",\n    \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"Cột {{colld}} đã bị xóa trong quá trình hành động #{{action.actionNum}}\",\n    \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"Bảng {{tableld}} đã bị xóa trong quá trình hành động #{{action.actionNum}}\",\n    \"This row was subsequently removed in action {{action.actionNum}}\": \"Hàng này đã bị xóa trong quá trình hành động {{action.actionNum}}\",\n    \"All tables\": \"Tất cả các bảng\"\n  },\n  \"AddNewButton\": {\n    \"Add new\": \"Thêm mới, Tạo mới\"\n  },\n  \"ApiKey\": {\n    \"By generating an API key, you will be able to make API calls for your own account.\": \"Bằng cách tạo khóa API, bạn sẽ có thể thực hiện các cuộc gọi API cho tài khoản của riêng mình\",\n    \"Click to show\": \"Nhấp để hiển thị, Xem thêm\",\n    \"Remove API Key\": \"Gỡ khóa API\",\n    \"This API key can be used to access this account anonymously via the API.\": \"Khóa API này có thể được dùng để truy cập ẩn danh vào tài khoản này thông qua API\",\n    \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\",\n    \"You're about to delete an API key. This will cause all future requests using this API key to be rejected. Do you still want to delete?\": \"Bạn đang chuẩn bị xóa một khóa API. Điều này sẽ khiến tất cả các yêu cầu trong tương lai sử dụng khóa API này đều bị từ chối. Bạn có chắc chắn vẫn muốn xóa không?\",\n    \"Create\": \"Tạo\",\n    \"Remove\": \"Gỡ, Xóa\"\n  },\n  \"App\": {\n    \"Memory Error\": \"Lỗi bộ nhớ\",\n    \"Translators: please translate this only when your language is ready to be offered to users\": \"Người dịch: vui lòng dịch điều này chỉ khi ngôn ngữ của bạn đã sẵn sàng để được cung cấp cho người dùng\",\n    \"Description\": \"Mô tả\",\n    \"Key\": \"Ký hiệu\"\n  },\n  \"AppHeader\": {\n    \"Home page\": \"Trang chủ\",\n    \"Legacy\": \"Di sản\",\n    \"Personal Site\": \"Trang wed cá nhân\",\n    \"Team Site\": \"Trang wed nhóm\",\n    \"Grist Templates\": \"Mẫu Grist\"\n  },\n  \"AppModel\": {\n    \"This team site is suspended. Documents can be read, but not modified.\": \"Trang wed của nhóm này đã bị tạm dừng. Tài liệu có thể được đọc, nhưng không thể sửa\"\n  },\n  \"CellContextMenu\": {\n    \"Clear cell\": \"Xóa ô\",\n    \"Clear values\": \"Xóa giá trị\",\n    \"Copy anchor link\": \"Sao chép liên kết Anchor\",\n    \"Delete {{count}} columns_one\": \"Xóa cột\",\n    \"Delete {{count}} columns_other\": \"Xóa {{count}} cột\",\n    \"Delete {{count}} rows_one\": \"Xóa hàng\",\n    \"Delete {{count}} rows_other\": \"Xóa {{count}} hàng\",\n    \"Duplicate rows_one\": \"Sao chép dòng, Sao chép hàng\",\n    \"Duplicate rows_other\": \"Các dòng trùng nhau\",\n    \"Filter by this value\": \"Lọc theo giá trị này\",\n    \"Insert column to the left\": \"Thêm một cột ở bên trái\",\n    \"Insert column to the right\": \"Thêm một cột ở bên phải\",\n    \"Insert row\": \"Chèn dòng mới\",\n    \"Insert row above\": \"Chèn hàng ở trên\",\n    \"Insert row below\": \"Chèn hàng ở bên dưới\",\n    \"Reset {{count}} columns_one\": \"Làm mới cột\",\n    \"Reset {{count}} columns_other\": \"Làm mới {{count}} cột\",\n    \"Reset {{count}} entire columns_one\": \"Làm mới toàn bộ các cột\",\n    \"Reset {{count}} entire columns_other\": \"Làm mới toàn bộ {{count}} cột\",\n    \"Comment\": \"Bình luận\",\n    \"Copy\": \"Sao chép\",\n    \"Cut\": \"Cắt\",\n    \"Paste\": \"Dán (Paste)\"\n  },\n  \"ChartView\": {\n    \"Create separate series for each value of the selected column.\": \"Tạo các chuỗi khác nhau cho mỗi giá trị của cột đã chọn\",\n    \"Each Y series is followed by a series for the length of error bars.\": \"Sau mỗi chuỗi Y là một chuỗi về độ dài của các thanh lỗi.\",\n    \"Each Y series is followed by two series, for top and bottom error bars.\": \"Mỗi chuỗi Y được theo sau mỗi hai chuỗi,dành cho các thanh lỗi trên và dưới\",\n    \"Pick a column\": \"Chọn một cột\",\n    \"Toggle chart aggregation\": \"Chuyển đổi tổng hợp biểu đồ\",\n    \"selected new group data columns\": \"Các cột dữ liệu nhóm mới đã được chọn\"\n  },\n  \"CodeEditorPanel\": {\n    \"Access denied\": \"Từ chối truy cập\",\n    \"Code View is available only when you have full document access.\": \"Chế độ xem mã chỉ khả dụng khi bạn có toàn quyền truy cập tài liệu.\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"Áp dụng\",\n    \"Cancel\": \"Hủy\",\n    \"Default cell style\": \"Kiểu ô mặc định\"\n  },\n  \"ColumnFilterMenu\": {\n    \"All\": \"Tất cả\",\n    \"All except\": \"Tất cả ngoại trừ\",\n    \"All shown\": \"Hiển thị tất cả\",\n    \"Filter by Range\": \"Lọc theo phạm vi\",\n    \"Future values\": \"Giá trị tương lai\",\n    \"No matching values\": \"Không có giá trị phù hợp\",\n    \"None\": \"Không có\",\n    \"Min\": \"Tối thiểu\",\n    \"Max\": \"Tối đa\",\n    \"Start\": \"Bắt đầu\",\n    \"End\": \"Kết thúc\",\n    \"Other Matching\": \"Đối xứng khác\",\n    \"Other Non-Matching\": \"Khác không phù hợp\",\n    \"Other values\": \"Các giá trị khác\",\n    \"Others\": \"Khác\",\n    \"Search\": \"Tìm kiếm\",\n    \"Search values\": \"Tìm kiếm giá trị\"\n  },\n  \"CustomSectionConfig\": {\n    \" (optional)\": \" (không bắt buộc)\",\n    \"Add\": \"Thêm vào\",\n    \"Enter Custom URL\": \"Nhập một URL ngẫu nhiên\",\n    \"Full document access\": \"Toàn quyền truy cập tài liệu\",\n    \"Learn more about custom widgets\": \"Tìm hiểu thêm về tiện ích tùy chỉnh\",\n    \"No document access\": \"Không có quyền truy cập tài liệu\",\n    \"Open configuration\": \"Mở cấu hình\",\n    \"Pick a column\": \"Chọn một cột\",\n    \"Pick a {{columnType}} column\": \"Chọn một cột {{columnType}}\",\n    \"Read selected table\": \"Đọc bảng đã chọn\",\n    \"Select Custom Widget\": \"Chọn tiện ích tùy chỉnh\",\n    \"Widget needs to {{read}} the current table.\": \"Tiện ích cần {{read}} bảng hiện tại.\",\n    \"Widget does not require any permissions.\": \"Tiện ích không yêu cầu bất kỳ quyền nào.\",\n    \"Widget needs {{fullAccess}} to this document.\": \"Tiện ích cần {{fullAccess}} cho tài liệu này.\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"Cột {{wrongTypeCount}} không phải{{columnType}} đang không được hiển thị\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} cột không phải{{columnType}} không được hiển thị\",\n    \"No {{columnType}} columns in table.\": \"Không có {{columnType}} cột trong bảng.\",\n    \"Clear selection\": \"Xoá lựa chọn\"\n  },\n  \"DataTables\": {\n    \"Click to copy\": \"Bấm để sao chép\",\n    \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"Xóa {{formattedTableName}} dữ liệu và xóa nó khỏi tất cả các trang?\",\n    \"Duplicate table\": \"Bảng Trùng lặp\",\n    \"Raw Data Tables\": \"Bảng Dữ liệu thô\",\n    \"Table ID copied to clipboard\": \"Đã sao chép ID bảng vào bảng nhớ tạm\",\n    \"You do not have edit access to this document\": \"Bạn không có quyền chỉnh sửa tài liệu này\",\n    \"Edit record card\": \"Chỉnh sửa bản ghi\",\n    \"Record Card\": \"Thẻ ghi âm\",\n    \"Record Card Disabled\": \"Thẻ ghi âm bị vô hiệu hóa\",\n    \"Remove table\": \"Xoá bảng\",\n    \"Rename table\": \"Đổi tên bảng\",\n    \"{{action}} Record Card\": \"{{action}} Thẻ ghi âm\"\n  },\n  \"DocHistory\": {\n    \"Activity\": \"Hoạt động công việc\",\n    \"Beta\": \"Bản thử nghiệm\",\n    \"Compare to current\": \"So sánh với dữ liệu hiện tại\",\n    \"Compare to previous\": \"So sánh với dữ liệu trước đó\",\n    \"Open snapshot\": \"Mở ảnh chụp nhanh\",\n    \"Snapshots\": \"Ảnh chụp nhanh\",\n    \"Snapshots are unavailable.\": \"Ảnh chụp nhanh không có sẵn\"\n  },\n  \"DocMenu\": {\n    \"(The organization needs a paid plan)\": \"(Tổ chức cần gói dịch vụ trả phí)\",\n    \"Access Details\": \"Chi tiết truy cập\",\n    \"All documents\": \"Toàn bộ tài liệu\",\n    \"Discover More Templates\": \"Khám phá thêm các mẫu\",\n    \"By Date Modified\": \"Lọc theo ngày đã sửa đổi\",\n    \"By Name\": \"Theo tên\",\n    \"Current workspace\": \"Không gian làm việc hiện tại\",\n    \"Delete Forever\": \"Xóa vĩnh viễn\",\n    \"Delete {{name}}\": \"Xóa {{name}}?\",\n    \"Deleted {{at}}\": \"Đã xóa {{at}}\",\n    \"Document will be moved to Trash.\": \"Tài liệu sẽ được đưa vào thùng rác\",\n    \"Document will be permanently deleted.\": \"Tài liệu sẽ được xóa vĩnh viễn\",\n    \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"Tài liệu nằm trong thùng rác 30 ngày, sau đó sẽ bị xóa vĩnh viễn\",\n    \"Edited {{at}}\": \"Đã sửa {{at}}\",\n    \"Examples & Templates\": \"Các ví dụ và mẫu\",\n    \"Featured\": \"Tính năng hoặc mục nổi bật\",\n    \"Examples and Templates\": \"Ví dụ và mẫu\",\n    \"Manage users\": \"Quản lí người dùng\",\n    \"More Examples and Templates\": \"Thêm nhiều ví dụ và mẫu khác\",\n    \"Move {{name}} to workspace\": \"Di chuyển {{name}} tới không gian làm việc\",\n    \"Other Sites\": \"Các web khác\",\n    \"Permanently Delete \\\"{{name}}\\\"?\": \"Xóa {{name}} vĩnh viễn?\",\n    \"Pin Document\": \"Ghim tài liệu\",\n    \"Remove\": \"Gỡ, Xóa\",\n    \"Rename\": \"Đổi tên\",\n    \"Requires edit permissions\": \"Yêu cầu quyền chỉnh sửa\",\n    \"Restore\": \"Khôi phục\",\n    \"This service is not available right now\": \"Dịch vụ không khả dụng\",\n    \"To restore this document, restore the workspace first.\": \"Để khôi phục tài liệu này, khôi phục không gian làm việc trước\",\n    \"Trash\": \"Thùng rác\",\n    \"Trash is empty.\": \"Thùng rác trống\",\n    \"Unpin Document\": \"Gỡ ghim tài liệu\",\n    \"Workspace not found\": \"Không tìm thấy không gian làm việc\",\n    \"You are on your personal site. You also have access to the following sites:\": \"Bạn đang ở trang cá nhân của bạn. bạn cũng có thể truy cập vào trang:\",\n    \"You may delete a workspace forever once it has no documents in it.\": \"Bạn có thể xóa không gian tài liệu vĩnh viễn khi không còn tài liệu nào trong đó\",\n    \"You are on the {{siteName}} site. You also have access to the following sites:\": \"Bạn đang ở trang{{siteName}}. bạn cũng có thể truy cập vào trang:\",\n    \"Delete\": \"Xóa\",\n    \"Move\": \"Di chuyển\",\n    \"Pinned Documents\": \"Tài liệu đã ghim\"\n  },\n  \"DocPageModel\": {\n    \"Add empty table\": \"Thêm bảng trống\",\n    \"Add page\": \"Thêm trang\",\n    \"Add widget to page\": \"Thêm tiện ích vào trang\",\n    \"Document owners can attempt to recover the document. [{{error}}]\": \"Chủ tài liệu có thể cố khôi phục tài liệu [{{error}}]\",\n    \"Enter recovery mode\": \"Vào chế độ phục hồi\",\n    \"Sorry, access to this document has been denied. [{{error}}]\": \"Xin lỗi, quyền truy cập tài liệu bị từ chối[{{error}}]\",\n    \"You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]\": \"Bạn có thể thử tải lại tài liệu, hoặc sử dụng chế độ khôi phục. Chế độ khôi phục mở tài liệu để trở nên hoàn toàn truy cập được cho chủ sở hữu và không thể truy cập được cho người khác. Nó cũng vô hiệu hóa các công thức [{{error}}]\",\n    \"Error accessing document\": \"Lỗi truy cập tài liệu\",\n    \"Reload\": \"Tải lại\",\n    \"You do not have edit access to this document\": \"Bạn không có quyền chỉnh sửa tài liệu này\"\n  },\n  \"DocTour\": {\n    \"No valid document tour\": \"Không có tài liệu hành trình hợp lệ\",\n    \"Cannot construct a document tour from the data in this document. Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location.\": \"Không thể tạo hành trình tài liệu từ dữ liệu trong tài liệu này. Đảm bảo rằng có một bảng có tên là GristDocTour với các cột tiêu đề, nội dung, vị trí và địa điểm\"\n  },\n  \"DocumentSettings\": {\n    \"Currency:\": \"Đơn vị tiền tệ\",\n    \"Engine (experimental {{span}} change at own risk):\": \"Động cơ (thay đổi thử nghiệm{{span}} tự chịu rủi ro)\",\n    \"Document settings\": \"Cài đặt tài liệu\",\n    \"Local currency ({{currency}})\": \"Nội tệ ({{currency}})\",\n    \"Locale:\": \"Cài đặt vị trí\",\n    \"Save\": \"Lưu\",\n    \"Save and Reload\": \"Lưu và tải lại\",\n    \"This document's ID (for API use):\": \"ID tài liệu này (cho API sử dụng)\",\n    \"Time Zone:\": \"Múi giờ\",\n    \"Document ID copied to clipboard\": \"ID tài liệu được sao chép vào khay nhớ tạm\",\n    \"Ok\": \"Được, chốt\",\n    \"Manage Webhooks\": \"Quản lý Webhooks\",\n    \"API console\": \"Bảng điều khiển API\",\n    \"API\": \"API\",\n    \"Webhooks\": \"Webhooks\"\n  },\n  \"DocumentUsage\": {\n    \"Contact the site owner to upgrade the plan to raise limits.\": \"Liên hệ với chủ sở hữu trang web để nâng cấp cho kế hoạch để tăng giới hạn\",\n    \"Size of attachments\": \"Kích thước của tệp đính kèm\",\n    \"Data size\": \"Kích thước dữ liệu\",\n    \"For higher limits, \": \"Với các giới hạn cao hơn \",\n    \"Usage\": \"Công dụng\",\n    \"Usage statistics are only available to users with full access to the document data.\": \"Thống kê sử dụng chỉ có cho người dùng có toàn quyền truy cập tới dữ liệu tài liệu\",\n    \"start your 30-day free trial of the Pro plan.\": \"Bắt đầu dùng thử gói miễn phí Pro trong 30 ngày.\",\n    \"Rows\": \"Nhiều hàng\"\n  },\n  \"Drafts\": {\n    \"Undo discard\": \"Huỷ hoàn tác\",\n    \"Restore last edit\": \"Khôi phục lại chỉnh sửa lần cuối\"\n  },\n  \"DuplicateTable\": {\n    \"Copy all data in addition to the table structure.\": \"Sao chép tất cả dữ liệu đến cấu trúc bảng\",\n    \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"Thay vì sao chép các bảng, sẽ tốt hơn nếu phân chia dữ liệu bằng chế độ xem liên kết. {{link}}\",\n    \"Only the document default access rules will apply to the copy.\": \"Chỉ có quyền truy cập tài liệu mặc định mới áp dụng cho bản sao\",\n    \"Name for new table\": \"Tên cho bảng mới\"\n  },\n  \"ExampleInfo\": {\n    \"Afterschool Program\": \"Chương trình học ngoài giờ\",\n    \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"Hãy xem hướng dẫn của chúng tôi liên quan tới cách làm thế nào để liên kết dữ liệu và tạo bố cục nâng cao năng suất\",\n    \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"Hãy xem hướng dẫn của chúng tôi để tìm hiểu cách để tạo bảng và biểu đồ, và liên kết tới biểu đồ một cách linh hoạt\",\n    \"Tutorial: Analyze & Visualize\": \"Hướng dẫn: Phân tích và hình dung\",\n    \"Tutorial: Create a CRM\": \"Hướng dẫn: Tạo CRM cơ bản\",\n    \"Tutorial: Manage Business Data\": \"Hướng dẫn: Quản lý dữ liệu doanh nghiệp\",\n    \"Welcome to the Afterschool Program template\": \"Chào mừng bạn đến với Chương trình học ngoài giờ\",\n    \"Welcome to the Investment Research template\": \"Chào mừng bạn đến với nghiên cứu mẫu đầu tư\",\n    \"Welcome to the Lightweight CRM template\": \"Chào mừng bạn đến với mẫu CRM cơ bản\",\n    \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"Hãy xem hướng dẫn của chúng tôi về cách lập mô hình dữ liệu kinh doanh, sử dụng công thức, và quản lý độ phức tạp\",\n    \"Investment Research\": \"Tra cứu đầu tư\",\n    \"Lightweight CRM\": \"CRM cơ bản\"\n  },\n  \"FieldConfig\": {\n    \"COLUMN BEHAVIOR\": \"TẬP TÍNH CỦA CỘT\",\n    \"COLUMN LABEL AND ID\": \"Nhãn cột và ID\",\n    \"Clear and make into formula\": \"Xoá và tạo thành công thức\",\n    \"Clear and reset\": \"Xoá và tải lại\",\n    \"Column options are limited in summary tables.\": \"tuỳ chọn cột bị giới hạn trong các bảng sơ lược\",\n    \"Convert column to data\": \"Chuyển đổi cột thành dữ liệu\",\n    \"Convert to trigger formula\": \"Chuyển thành công thức kích hoạt\",\n    \"Data columns_one\": \"Dữ liệu cột\",\n    \"Data columns_other\": \"Dữ liệu cột\",\n    \"Empty columns_one\": \"Cột trống\",\n    \"Empty columns_other\": \"Nhiều cột trống\",\n    \"Enter formula\": \"Nhập công thức\",\n    \"Formula columns_one\": \"Cột công thức\",\n    \"Formula columns_other\": \"Nhiều cột công thức\",\n    \"Make into data column\": \"Biến thành cột dữ liệu\",\n    \"Mixed Behavior\": \"Hành vi tập tính\",\n    \"Set formula\": \"Đặt công thức\",\n    \"Set trigger formula\": \"Đặt công thức kích hoạt\",\n    \"TRIGGER FORMULA\": \"Kích hoạt công thức\",\n    \"DESCRIPTION\": \"Mô Tả\"\n  },\n  \"FieldMenus\": {\n    \"Using separate settings\": \"Dùng cài đặt riêng\",\n    \"Revert to common settings\": \"hoàn lại về cài đặt chung\",\n    \"Save as common settings\": \"Lưu lại bởi cài đặt chung\",\n    \"Use separate settings\": \"Sử dụng cài đặt riêng\",\n    \"Using common settings\": \"Sử dụng các cài đặt thường\"\n  },\n  \"FilterBar\": {\n    \"SearchColumns\": \"Tìm cột\",\n    \"Search Columns\": \"Tìm nhiều cột\"\n  },\n  \"GridOptions\": {\n    \"Grid Options\": \"Cài đặt kẻ bảng\",\n    \"Horizontal gridlines\": \"Đường lưới nằm ngang\",\n    \"Vertical gridlines\": \"Đường lưới dọc\",\n    \"Zebra stripes\": \"Dải sọc Ngựa vằn\"\n  },\n  \"GridViewMenus\": {\n    \"Add column\": \"Thêm cột\",\n    \"Add to sort\": \"Thêm vào danh sách sắp xếp đã có sẵn\",\n    \"Clear values\": \"Xóa giá trị\",\n    \"Column Options\": \"Column Options\",\n    \"Convert formula to data\": \"Chuyển đổi công thức thành dữ liệu\",\n    \"Delete {{count}} columns_one\": \"Xóa cột\",\n    \"Delete {{count}} columns_other\": \"Xóa {{count}} cột\",\n    \"Freeze {{count}} columns_one\": \"Cố định cột này\",\n    \"Freeze {{count}} columns_other\": \"Cố định {{count}} cột\",\n    \"Freeze {{count}} more columns_one\": \"Cố định thêm một cột\",\n    \"Freeze {{count}} more columns_other\": \"Cố định {{count}} thêm cột\",\n    \"Hide {{count}} columns_one\": \"Ẩn cột\",\n    \"Hide {{count}} columns_other\": \"Ẩn {{count}} cột\",\n    \"Insert column to the {{to}}\": \"Chèn thêm cột vào {{to}}\",\n    \"More sort options ...\": \"Các lựa chọn sắp xếp khác\",\n    \"Rename column\": \"Đổi tên cột\",\n    \"Reset {{count}} columns_one\": \"Làm mới cột\",\n    \"Reset {{count}} columns_other\": \"Làm mới {{count}} cột\",\n    \"Reset {{count}} entire columns_one\": \"Làm mới toàn bộ các cột\",\n    \"Reset {{count}} entire columns_other\": \"Làm mới toàn bộ {{count}} cột\",\n    \"Show column {{- label}}\": \"Hiển thị cột {{- label}}\",\n    \"Sort\": \"Sắp xếp\",\n    \"Sorted (#{{count}})_one\": \"Đã sắp xếp (#{{count}})\",\n    \"Sorted (#{{count}})_other\": \"Đã sắp xếp (#{{count}})\",\n    \"Unfreeze all columns\": \"Hủy cố định tất cả các cột\",\n    \"Unfreeze {{count}} columns_one\": \"Hủy cố định cột này\",\n    \"Unfreeze {{count}} columns_other\": \"Hủy cố định {{count}} cột\",\n    \"Insert column to the left\": \"Thêm một cột ở bên trái\",\n    \"Insert column to the right\": \"Thêm một cột ở bên phải\",\n    \"Apply on record changes\": \"Áp dụng thay đổi cho bản lưu\",\n    \"Apply to new records\": \"Áp dụng cho bản lưu mới\",\n    \"Authorship\": \"Quyền tác giả\",\n    \"Created At\": \"Tạo vào lúc\",\n    \"Created By\": \"Tạo bởi\",\n    \"Hidden Columns\": \"Cột đang ẩn\",\n    \"Last Updated At\": \"Cập nhật lần cuối vào\",\n    \"Last Updated By\": \"Lần cuối cập nhập bởi\",\n    \"Lookups\": \"Tra cứu\",\n    \"Shortcuts\": \"Phím tắt\",\n    \"Show hidden columns\": \"Hiển thị các cột ẩn\",\n    \"no reference column\": \"Không thẩm quyền chỉnh sửa cột này\",\n    \"Adding UUID column\": \"Thêm UUID cho cột\",\n    \"Adding duplicates column\": \"Thêm cột trùng lặp\",\n    \"Detect Duplicates in...\": \"Dò ra trùng lặp trong\",\n    \"Duplicate in {{- label}}\": \"Sao chép vào {{- label}}\",\n    \"No reference columns.\": \"Cột không có thẩm quyền thay đổi\",\n    \"Search columns\": \"Tìm cột\",\n    \"UUID\": \"Định Dạng Số Duy Nhất Toàn Cầu\",\n    \"Add column with type\": \"Thêm cột với loại\",\n    \"Add formula column\": \"Thêm cột công thức\",\n    \"Created at\": \"Đã được tạo lúc\",\n    \"Created by\": \"Được tạo bởi\",\n    \"Detect duplicates in...\": \"Dò ra các bản sao trong\",\n    \"Last updated at\": \"Cập nhập lần cuối vào\",\n    \"Last updated by\": \"Cập nhập lần cuối bởi\",\n    \"Any\": \"Bất kỳ\",\n    \"Numeric\": \"Số\",\n    \"Text\": \"Văn bản\",\n    \"Integer\": \"Số nguyên\",\n    \"Toggle\": \"Chuyển đổi\",\n    \"Date\": \"Ngày\",\n    \"DateTime\": \"Ngày giờ\",\n    \"Choice\": \"Lựa chọn\",\n    \"Choice List\": \"Danh sách lựa chọn\",\n    \"Reference\": \"Phản hồi\",\n    \"Reference List\": \"Danh sách phản hồi\",\n    \"Attachment\": \"Tập tin đính kèm\",\n    \"Filter Data\": \"Lọc dữ liệu\",\n    \"Timestamp\": \"Dấu thời gian\"\n  },\n  \"GristDoc\": {\n    \"Added new linked section to view {{viewName}}\": \"Thêm phần được liên kết mới để xem {{viewName}}\",\n    \"Import from file\": \"Nhập từ tệp\",\n    \"Saved linked section {{title}} in view {{name}}\": \"Phần được liên kết {{tilte}} trong chế độ xem {{name}}\",\n    \"go to webhook settings\": \"Vào trang cài đặt của webhook\"\n  },\n  \"HomeIntro\": {\n    \"Any documents created in this site will appear here.\": \"Các tài liệu được tạo trong trang trang web này sẽ được xuất hiện ở đây.\",\n    \"Browse Templates\": \"Duyệt các mẫu.\",\n    \"Create empty document\": \"Tạo tài liệu trống\",\n    \"Get started by creating your first Grist document.\": \"Bắt đầu bằng việc tạo tài liệu Grist đầu tiên của bạn\",\n    \"Get started by exploring templates, or creating your first Grist document.\": \"Bắt đầu bằng việc khám phá các mẫu hoặc tạo tài liệu Grist đầu tiên của bạn\",\n    \"Get started by inviting your team and creating your first Grist document.\": \"Bắt đầu bằng việc mời nhóm của bạn và tạo tài liệu Grist đầu tiên của bạn\",\n    \"Help Center\": \"trung tâm Trợ giúp\",\n    \"Import document\": \"Nhập Tài liệu\",\n    \"Interested in using Grist outside of your team? Visit your free \": \"Quan tâm tới việc sử dụng Grist bên ngoài nhóm của bạn? Truy cập miễn phí \",\n    \"Invite Team Members\": \"Mời thêm thành viên vào nhóm\",\n    \"Sign up\": \"Đăng ký\",\n    \"Sprouts Program\": \"Chương trình Sprouts\",\n    \"This workspace is empty.\": \"Không gian học tập đang trống\",\n    \"Visit our {{link}} to learn more.\": \"Truy cập {{link}} của chúng tôi đẻ tìm hiểu thêm\",\n    \"Welcome to Grist!\": \"Chào mừng bạn đến với Grist!\",\n    \"Welcome to {{orgName}}\": \"Chào mừng bạn đến với {{orgName}}\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"Bạn chỉ quyền truy cập để đọc ở trang web này. Hiện tại trong còn tệp nào nữa\",\n    \"personal site\": \"trang web cá nhân\",\n    \"Welcome to Grist, {{- name}}!\": \"Chào mừng bạn đến với Grist, {{- name}}!\",\n    \"Welcome to {{- orgName}}\": \"Chào mừng bạn đến với {{- orgName}}\",\n    \"Sign in\": \"Đăng nhập\",\n    \"To use Grist, please either sign up or sign in.\": \"Để có thể sử dụng Grist, vui lòng đăng kí hoặc đăng nhập\",\n    \"Visit our {{link}} to learn more about Grist.\": \"Truy cập {{link}} của chúng tôi để có thể tìm hiểu thêm về Grist\",\n    \"Welcome to Grist, {{name}}!\": \"Chào mừng bạn đến với Grist, {{name}}!\",\n    \"{{signUp}} to save your work. \": \"{{signUp}} để lưu bài làm của bạn \"\n  },\n  \"HomeLeftPane\": {\n    \"Access Details\": \"Chi tiết truy cập\",\n    \"All documents\": \"Toàn bộ tài liệu\",\n    \"Create empty document\": \"Tạo tài liệu trống\",\n    \"Create workspace\": \"Tạo không gian học tập\",\n    \"Delete\": \"Xóa\",\n    \"Delete {{workspace}} and all included documents?\": \"Xóa {{workspace}} và bao gồm các tài liệu đi kèm?\",\n    \"Examples & Templates\": \"Mẫu\",\n    \"Import document\": \"Nhập Tài liệu\",\n    \"Manage users\": \"Quản lí người dùng\",\n    \"Rename\": \"Đổi tên\",\n    \"Trash\": \"Thùng rác\",\n    \"Workspace will be moved to Trash.\": \"Không gian học tập sẽ được chuyển vào thùng rác\",\n    \"Tutorial\": \"Hướng dẫn\",\n    \"Workspaces\": \"Không gian học tập\"\n  },\n  \"Importer\": {\n    \"Merge rows that match these fields:\": \"Hợp nhất các lựa chọn trùng với những tệp này:\",\n    \"Select fields to match on\": \"Chọn các tệp trùng với\",\n    \"Update existing records\": \"Cập nhập các bản lưu đang có\",\n    \"{{count}} unmatched field in import_one\": \"{{count}} trường chưa khớp trong quá trình nhập\",\n    \"{{count}} unmatched field in import_other\": \"{{count}} lĩnh vực chưa khớp trong quá trình nhập\",\n    \"{{count}} unmatched field_one\": \"{{count}} lĩnh vực chưa khớp\",\n    \"{{count}} unmatched field_other\": \"{{count}} lĩnh vực chưa khớp\",\n    \"Column Mapping\": \"Ánh xạ cột\",\n    \"Column mapping\": \"Ánh xạ cột\",\n    \"Destination table\": \"Bảng đích\",\n    \"Grist column\": \"cột Grist\",\n    \"Import from file\": \"Nhập từ tệp\",\n    \"New Table\": \"Bảng mới\",\n    \"Revert\": \"Hoàn lại\",\n    \"Skip\": \"Bỏ qua\",\n    \"Skip Import\": \"Bỏ qua bước nhập\",\n    \"Source column\": \"Cột nguồn\",\n    \"Skip Table on Import\": \"Bỏ qua bảng khi nhập\"\n  },\n  \"LeftPanelCommon\": {\n    \"Help Center\": \"trung tâm Trợ giúp\"\n  },\n  \"MakeCopyMenu\": {\n    \"As template\": \"Lưu dưới dạng mẫu\",\n    \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"Hãy cẩn thận, bản gốc có những thay đổi không có trong tài liệu này. Những thay đổi đó sẽ bị ghi đè.\",\n    \"Enter document name\": \"Nhập tên tài liệu\",\n    \"However, it appears to be already identical.\": \"Tuy nhiên, nó dường như đã giống hệt nhau.\",\n    \"It will be overwritten, losing any content not in this document.\": \"Nó sẽ bị ghi đè, mất đi những nội dung không có trong tài liệu này.\",\n    \"Name\": \"Tên\",\n    \"No destination workspace\": \"Không có không gian làm việc tại điểm đến\",\n    \"Organization\": \"Tổ chức\",\n    \"Original Has Modifications\": \"Bản gốc có sửa đổi\",\n    \"Original Looks Unrelated\": \"Ngoại hình ban đầu không liên quan\",\n    \"Original Looks Identical\": \"Ngoại hình ban đầu giống hệt nhau\",\n    \"Replacing the original requires editing rights on the original document.\": \"Việc thay thế bản gốc yêu cầu quyền chỉnh sửa trên tài liệu gốc.\",\n    \"Sign up\": \"Đăng ký\",\n    \"The original version of this document will be updated.\": \"Phiên bản gốc của tài liệu này sẽ được cập nhật.\",\n    \"To save your changes, please sign up, then reload this page.\": \"Để lưu các thay đổi của bạn, vui lòng đăng ký, sau đó tải lại trang này.\",\n    \"Update\": \"Cập nhập\",\n    \"Update Original\": \"Cập nhật bản gốc\",\n    \"Workspace\": \"Không gian làm việc\",\n    \"You do not have write access to the selected workspace\": \"Bạn không có quyền ghi vào không gian làm việc chưa chọn\",\n    \"You do not have write access to this site\": \"Bạn không có quyền ghi vào trang web này\",\n    \"Download document and history\": \"Tải xuống toàn bộ tài liệu và lịch sử\",\n    \"Download document structure only (no data, for template use)\": \"Xóa lịch sử tài liệu (có thể làm giảm đáng kể kích thước tệp)\",\n    \"Download\": \"Tải xuống\",\n    \"Download document\": \"Tải xuống tài liệu PDF\",\n    \"Cancel\": \"Hủy\",\n    \"Overwrite\": \"Ghi đè\",\n    \"Include the structure without any of the data.\": \"Bao gồm cấu trúc mà không có bất kỳ dữ liệu nào.\"\n  },\n  \"NotifyUI\": {\n    \"Ask for help\": \"Nhờ giúp đỡ\",\n    \"Cannot find personal site, sorry!\": \"Không thể tìm thấy trang web cá nhân, xin lỗi!\",\n    \"Give feedback\": \"Đưa ra phản hồi\",\n    \"Go to your free personal site\": \"Truy cập trang web cá nhân miễn phí của bạn\",\n    \"No notifications\": \"Không có thông báo\",\n    \"Notifications\": \"Thông báo\",\n    \"Renew\": \"Làm mới\",\n    \"Report a problem\": \"Báo cáo sự cố\",\n    \"Upgrade Plan\": \"Gói nâng cấp\",\n    \"Manage billing\": \"Quản lý thanh toán\"\n  },\n  \"OnBoardingPopups\": {\n    \"Finish\": \"Hoàn thành\",\n    \"Next\": \"Tiếp theo\"\n  },\n  \"OpenVideoTour\": {\n    \"Grist Video Tour\": \"Video giới thiệu về grist\",\n    \"Video Tour\": \"Video giới thiệu\",\n    \"YouTube video player\": \"Trình phát video YouTube\"\n  },\n  \"PageWidgetPicker\": {\n    \"Add to page\": \"Thêm vào trang\",\n    \"Building {{- label}} widget\": \"Xây dựng tiện ích {{- label}}\",\n    \"Group by\": \"Nhóm của\",\n    \"Select data\": \"Chọn dữ liệu\",\n    \"Select widget\": \"Chọn khu vực widget\"\n  },\n  \"Pages\": {\n    \"The following tables will no longer be visible_one\": \"Bảng sau đây sẽ không còn hiển thị nữa\",\n    \"The following tables will no longer be visible_other\": \"Các bảng sau đây sẽ không còn hiển thị nữa\",\n    \"Delete\": \"Xóa\",\n    \"Delete data and this page.\": \"Xóa dữ liệu và trang này.\"\n  },\n  \"PermissionsWidget\": {\n    \"Deny all\": \"Từ chối tất cả\",\n    \"Read only\": \"Chỉ đọc\",\n    \"Allow all\": \"Cho phép tất cả\"\n  },\n  \"PluginScreen\": {\n    \"Import failed: \": \"Không nhập được \"\n  },\n  \"RecordLayout\": {\n    \"Updating record layout.\": \"Cập nhật bố cục hồ sơ.\"\n  },\n  \"RecordLayoutEditor\": {\n    \"Add field\": \"Thêm Field\",\n    \"Create new field\": \"Tạo trường dữ liệu mới\",\n    \"Show field {{- label}}\": \"Hiển thị trường dữ liệu\",\n    \"Save layout\": \"Lưu bố cục\",\n    \"Cancel\": \"Hủy\"\n  },\n  \"RefSelect\": {\n    \"Add column\": \"Thêm cột\",\n    \"No columns to add\": \"Không có cột nào để thêm\"\n  },\n  \"RightPanel\": {\n    \"CHART TYPE\": \"LOẠI BIỂU ĐỒ\",\n    \"COLUMN TYPE\": \"LOẠI CỘT\",\n    \"CUSTOM\": \"TÙY CHỈNH\",\n    \"Change widget\": \"Thay đổi tiện ích\",\n    \"columns_one\": \"Cột\",\n    \"columns_other\": \"Nhiều cột\",\n    \"DATA TABLE NAME\": \"TÊN CỦA BẢNG DỮ LIỆU\",\n    \"Detach\": \"Tách rời\",\n    \"Edit data selection\": \"Chỉnh sửa việc chọn dữ liệu\",\n    \"fields_one\": \"Tường thông tin\",\n    \"fields_other\": \"Các trường thông tin\",\n    \"GROUPED BY\": \"Được nhóm theo\",\n    \"Row style\": \"Kiểu hàng\",\n    \"SELECT BY\": \"CHỌN THEO\",\n    \"SELECTOR FOR\": \"Chọn lọc cho\",\n    \"SOURCE DATA\": \"DỮ LIỆU NGUỒN\",\n    \"Select widget\": \"Chọn khu vực widget\",\n    \"series_one\": \"Chuỗi\",\n    \"series_other\": \"Chuỗi\",\n    \"Sort & filter\": \"Sắp xếp & Lọc\",\n    \"TRANSFORM\": \"CHUYỂN ĐỔI\",\n    \"Theme\": \"Mục\",\n    \"WIDGET TITLE\": \"Tiêu đề của tiện ích con\",\n    \"Widget\": \"Tiện ích con\",\n    \"You do not have edit access to this document\": \"Bạn không có quyền chỉnh sửa tài liệu này\",\n    \"Add referenced columns\": \"Thêm cột tham chiếu\",\n    \"Reset form\": \"Đặt lại biểu mẫu\",\n    \"Configuration\": \"Cấu hình\",\n    \"Default field value\": \"Giá trị mặc định của trường dữ liệu\",\n    \"Display button\": \"Nút hiển thị\",\n    \"Layout\": \"Bố cục\",\n    \"Enter text\": \"Nhập văn bản\",\n    \"Field rules\": \"Quy định về trường dữ liệu\",\n    \"Field title\": \"Tên của trường dữ liệu\",\n    \"Hidden field\": \"Ẩn trường dữ liệu\",\n    \"Redirect automatically after submission\": \"Tự động chuyển hướng sau khi gửi\",\n    \"Redirection\": \"Chuyển hướng\",\n    \"Required field\": \"Trường dữ liệu bắc buộc\",\n    \"Submission\": \"Bài nộp\",\n    \"Submit another response\": \"Gửi một phản hồi khác\",\n    \"Submit button label\": \"Kiểm tra nút của nhãn\",\n    \"Success text\": \"Văn bản đã được gửi thành công\",\n    \"Table column name\": \"Tên của cột trong bảng\",\n    \"Enter redirect URL\": \"Nhập URL để chuyển hướng\",\n    \"Data\": \"DỮ LIỆU\",\n    \"Save\": \"Lưu\",\n    \"DATA TABLE\": \"BẢNG DỮ LIỆU\",\n    \"No field selected\": \"Không có trường dữ liệu nào được chọn\",\n    \"Select a field in the form widget to configure.\": \"Chọn một trường dữ liệu trong tiện ích con để chỉnh sửa\"\n  },\n  \"RowContextMenu\": {\n    \"Copy anchor link\": \"Sao chép liên kết Anchor\",\n    \"Insert row below\": \"Chèn hàng ở bên dưới\",\n    \"Delete\": \"Xóa\",\n    \"Insert row\": \"Chèn dòng mới\",\n    \"Insert row above\": \"Chèn hàng ở trên\",\n    \"View as card\": \"Xem dưới dạng thẻ\",\n    \"Use as table headers\": \"Sử dụng để làm tiêu đề bảng\",\n    \"Duplicate rows_one\": \"Sao chép dòng, Sao chép hàng\",\n    \"Duplicate rows_other\": \"Các dòng trùng nhau\"\n  },\n  \"SelectionSummary\": {\n    \"Copied to clipboard\": \"Đã sao chép vào bộ nhớ đệm\"\n  },\n  \"ShareMenu\": {\n    \"Access Details\": \"Chi tiết truy cập\",\n    \"Back to current\": \"Quay lại lại nội dung hiện tại\",\n    \"Compare to {{termToUse}}\": \"So sánh với {{termToUse}}\",\n    \"Current Version\": \"Phiên bản hiện tại\",\n    \"Download\": \"Tải xuống\",\n    \"Edit without affecting the original\": \"Chỉnh sửa nhưng không ảnh hưởng đến bản gốc\",\n    \"Duplicate document\": \"Nhân đôi trang tính\",\n    \"Export CSV\": \"Xuất ra dưới dạng CVS\",\n    \"Export XLSX\": \"Xuất ra dưới dạng XLSX\",\n    \"Manage users\": \"Quản lí người dùng\",\n    \"Replace {{termToUse}}...\": \"Thay thế {{termToUse}}…\",\n    \"Return to {{termToUse}}\": \"Quay lại {{termToUse}}\",\n    \"Save copy\": \"Lưu bản sao\",\n    \"Save Document\": \"Lưu tài liệu\",\n    \"Send to Google Drive\": \"Gửi đến Google Drive\",\n    \"Show in folder\": \"Hiển thị trong thư mục\",\n    \"Unsaved\": \"Chưa được lưu\",\n    \"Work on a copy\": \"làm việc trên một bản sao\",\n    \"Share\": \"Chia sẻ\",\n    \"Download...\": \"Tải xuống\",\n    \"Original\": \"Bản gốc\",\n    \"Comma Separated Values (.csv)\": \"Các giá trị được tách bằng dấu phẩy (.csv)\",\n    \"DOO Separated Values (.dsv)\": \"Giá trị được tách bằng DOO (.dsv)\",\n    \"Export as...\": \"Xuất dưới dạng...\",\n    \"Microsoft Excel (.xlsx)\": \"Microsofl Excel (.xlsx)\",\n    \"Tab Separated Values (.tsv)\": \"TSV (.tsv)\"\n  },\n  \"SiteSwitcher\": {\n    \"Create new team site\": \"Tạo một nhóm làm việc mới\",\n    \"Switch Sites\": \"Chuyển trang\"\n  },\n  \"SortConfig\": {\n    \"Add column\": \"Thêm cột\",\n    \"Empty values last\": \"Giá trị rỗng\",\n    \"Natural sort\": \"Phân loại tự nhiên\",\n    \"Update data\": \"Cập nhật dữ liệu\",\n    \"Use choice position\": \"Lựa chọn vị trí sử dụng\",\n    \"Search Columns\": \"Tìm cột\"\n  },\n  \"SortFilterConfig\": {\n    \"Filter\": \"BỘ LỌC\",\n    \"Sort\": \"SẮP XẾP\",\n    \"Update Sort & Filter settings\": \"Cập nhật sắp xếp & Thiết lập bộ lọc\",\n    \"Revert\": \"Hoàn lại\",\n    \"Save\": \"Lưu\"\n  },\n  \"ThemeConfig\": {\n    \"Appearance \": \"Chế độ \",\n    \"Switch appearance automatically to match system\": \"Tự động chuyển đổi giao diện để khớp với hệ thống\"\n  },\n  \"Tools\": {\n    \"Access Rules\": \"Quy tắc truy cập\",\n    \"Delete\": \"Xóa\",\n    \"Delete document tour?\": \"Xóa tham khảo thư mục\",\n    \"Document history\": \"Lịch sử dữ liệu\",\n    \"How-to Tutorial\": \"Hướng dẫn cách thực hiện\",\n    \"Raw data\": \"Dữ liệu gốc\",\n    \"Return to viewing as yourself\": \"Quay lại chế độ xem dưới dạng bản thân\",\n    \"TOOLS\": \"CÔNG CỤ\",\n    \"Tour of this Document\": \"Tham khảo thư mục này\",\n    \"Validate Data\": \"Xác thực dữ liệu\",\n    \"Settings\": \"Cài đặt\",\n    \"API console\": \"Bảng điều khiển API\",\n    \"Code view\": \"Hiển thị mã\"\n  },\n  \"TopBar\": {\n    \"Manage team\": \"Quản lý nhóm\"\n  },\n  \"TriggerFormulas\": {\n    \"Any field\": \"Bất kỳ trường dữ liệu nào\",\n    \"Apply on changes to:\": \"Áp dụng thay đổi cho:\",\n    \"Apply on record changes\": \"Áp dụng thay đổi cho bản lưu\",\n    \"Apply to new records\": \"Áp dụng cho bản lưu mới\",\n    \"Cancel\": \"Hủy\",\n    \"Close\": \"Đóng\",\n    \"Current field \": \"Trường dữ liệu hiện tại \",\n    \"OK\": \"Được, chốt\"\n  },\n  \"TypeTransformation\": {\n    \"Update formula (Shift+Enter)\": \"Cập nhật công thức (Shift+Enter)\",\n    \"Apply\": \"Áp dụng\",\n    \"Cancel\": \"Hủy\",\n    \"Preview\": \"Xem trước\",\n    \"Revise\": \"Xem lại\"\n  },\n  \"UserManagerModel\": {\n    \"Editor\": \"Trình chỉnh sửa\",\n    \"In full\": \"Toàn bộ\",\n    \"No Default Access\": \"Không có quyền truy cập mặc định\",\n    \"None\": \"Không có\",\n    \"Owner\": \"Chủ sở hữu\",\n    \"View & edit\": \"Xem & Chỉnh sửa\",\n    \"View only\": \"Chỉ xem\",\n    \"Viewer\": \"người xem\"\n  },\n  \"ValidationPanel\": {\n    \"Rule {{length}}\": \"Quy tắc {{length}}\",\n    \"Update formula (Shift+Enter)\": \"Cập nhật công thức (Shift+Enter)\"\n  },\n  \"ViewAsBanner\": {\n    \"UnknownUser\": \"Người dùng không xác định\"\n  },\n  \"ViewConfigTab\": {\n    \"Advanced settings\": \"Cài đặt nâng cao\",\n    \"Make On-Demand\": \"Tạo theo yêu cầu\",\n    \"Plugin: \": \"phần mở rộng \",\n    \"Section: \": \"Bộ phận: \",\n    \"Unmark On-Demand\": \"Bỏ đánh dấu theo yêu cầu\",\n    \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"Các bảng có kích thước lớn có thể được đánh dấu là 'theo yêu cầu' để tránh việc tải chúng vào bộ động cơ dữ liệu.\",\n    \"Blocks\": \"Khối\",\n    \"Compact\": \"Thu gọn\",\n    \"Edit card layout\": \"Chỉnh sửa bố cục thẻ\",\n    \"Form\": \"Biểu mẫu\"\n  },\n  \"ViewLayoutMenu\": {\n    \"Advanced sort & filter\": \"Sắp xếp và Lọc Nâng cao\",\n    \"Copy anchor link\": \"Sao chép liên kết Anchor\",\n    \"Data selection\": \"Chọn lọc dữ liệu\",\n    \"Delete record\": \"Xoá bản ghi\",\n    \"Delete widget\": \"Xóa tiện ích\",\n    \"Download as CSV\": \"Tải xuống dưới dạng CSV\",\n    \"Download as XLSX\": \"Tải xuống dưới dạng XLSX\",\n    \"Edit card layout\": \"Chỉnh sửa bố cục thẻ\",\n    \"Open configuration\": \"Mở cấu hình\",\n    \"Print widget\": \"In tiện ích\",\n    \"Show raw data\": \"Hiển thị dữ liệu gốc\",\n    \"Widget options\": \"Tùy chọn tiện ích\",\n    \"Add to page\": \"Thêm vào trang\",\n    \"Collapse widget\": \"Thu nhỏ tiện ích\",\n    \"Create a form\": \"Tạo một biểu mẫu\"\n  },\n  \"ViewSectionMenu\": {\n    \"(customized)\": \"(Tùy chỉnh)\",\n    \"(empty)\": \"(trống rỗng, không có gì)\",\n    \"(modified)\": \"(Đã chỉnh sửa)\",\n    \"Custom options\": \"Custom options\",\n    \"FILTER\": \"BỘ LỌC\",\n    \"Revert\": \"Hoàn lại\",\n    \"SORT\": \"SẮP XẾP\",\n    \"Save\": \"Lưu\",\n    \"Update Sort&Filter settings\": \"Cập nhật cài đặt cho phân loại và bộ lọc\"\n  },\n  \"VisibleFieldsConfig\": {\n    \"Cannot drop items into Hidden Fields\": \"Không thể đặt các phần tử trong các trường ẩn\",\n    \"Clear\": \"Xóa\",\n    \"Visible {{label}}\": \"Hiển thị {{label}}\",\n    \"Hide {{label}}\": \"Ẩn {{label}}\",\n    \"Hidden {{label}}\": \"Đã ẩn {{label}}\",\n    \"Show {{label}}\": \"Hiển thị {label}}\",\n    \"Hidden Fields cannot be reordered\": \"Các trường ẩn không thể được sắp xếp lại\",\n    \"Select all\": \"Chọn tất cả\"\n  },\n  \"WelcomeQuestions\": {\n    \"Education\": \"Giáo dục\",\n    \"Finance & Accounting\": \"Tài chính và kế toán\",\n    \"HR & Management\": \"Quản lý nhân sự\",\n    \"IT & Technology\": \"Công nghệ thông tin\",\n    \"Marketing\": \"Tiếp thị\",\n    \"Media Production\": \"Sản xuất phương tiện truyền thông\",\n    \"Other\": \"Khác\",\n    \"Product Development\": \"Phát triển sản phẩm\",\n    \"Research\": \"Nghiên cứu\",\n    \"Sales\": \"Kinh doanh\",\n    \"Type here\": \"Nhập vào đây\",\n    \"Welcome to Grist!\": \"Chào mừng bạn đến với Grist!\",\n    \"What brings you to Grist? Please help us serve you better.\": \"Điều gì đưa bạn đến Grist? Hãy giúp chúng tôi phục vụ bạn tốt hơn.\"\n  },\n  \"WidgetTitle\": {\n    \"Cancel\": \"Hủy\",\n    \"DATA TABLE NAME\": \"TÊN CỦA BẢNG DỮ LIỆU\",\n    \"Override widget title\": \"Ghi đè tiêu đề tiện ích\",\n    \"Provide a table name\": \"Cung cấp tên cho một bảng\",\n    \"WIDGET TITLE\": \"Tiêu đề của tiện ích con\",\n    \"WIDGET DESCRIPTION\": \"MÔ TẢ TIỆN ÍCH\",\n    \"Save\": \"Lưu\"\n  },\n  \"breadcrumbs\": {\n    \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"Bạn có thể chỉnh sửa, nhưng họ sẽ tạo một bản sao mới và sẽ\\nkhông ảnh hưởng đến tài liệu gốc.\",\n    \"fiddle\": \"Thử nghiệm hoặc điều chỉnh\",\n    \"override\": \"Ghi đè\",\n    \"recovery mode\": \"Chế độ khôi phục\",\n    \"snapshot\": \"Lưu nhanh\",\n    \"unsaved\": \"chưa lưu\"\n  },\n  \"duplicatePage\": {\n    \"Duplicate page {{pageName}}\": \"Trang trùng lặp {{pageName}}\",\n    \"Note that this does not copy data, but creates another view of the same data.\": \"Lưu ý rằng điều này không sao chép dữ liệu, mà tạo ra một cách nhìn khác của cùng dữ liệu đó.\"\n  },\n  \"errorPages\": {\n    \"Access denied{{suffix}}\": \"Quyền truy cập bị từ chối\",\n    \"Add account\": \"Thêm tài khoản\",\n    \"Contact support\": \"Liên hệ hỗ trợ\",\n    \"Error{{suffix}}\": \"Lỗi\",\n    \"Go to main page\": \"Tới trang chính\",\n    \"Page not found{{suffix}}\": \"Không tìm thấy trang\",\n    \"Sign in\": \"Đăng nhập\",\n    \"Sign in again\": \"Đăng nhập lại\",\n    \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"Không thể tìm thấy trang được yêu cầu. Vui lòng kiểm tra URL và thử lại\",\n    \"There was an unknown error.\": \"Đã xảy ra lỗi không thể xác định\",\n    \"You are now signed out.\": \"Hiện tại bạn đã đăng xuất\",\n    \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"Bạn đã đăng nhập với . Bạn có thể truy cập bằng một tâif khoản khác hoặc yêu cầu quản trị viên cấp quyền truy cập\",\n    \"You do not have access to this organization's documents.\": \"Bạn không có quyền truy cập vào tài liệu của tổ chức này\",\n    \"Account deleted{{suffix}}\": \"Đã xóa tài khoản\",\n    \"Sign up\": \"Đăng ký\",\n    \"Your account has been deleted.\": \"Tài khoản của bạn đã bị xóa\",\n    \"An unknown error occurred.\": \"Đã xảy ra lỗi không xác định được.\",\n    \"Build your own form\": \"Tạo biểu mẫu của riêng bạn\",\n    \"Form not found\": \"Không tìm thấy hình\",\n    \"Powered by\": \"Cung cấp bởi\",\n    \"Sign in to access this organization's documents.\": \"Đăng nhập để truy cập tài liệu của tổ chức này\",\n    \"Signed out{{suffix}}\": \"Đã đăng xuất\",\n    \"Something went wrong\": \"Đã xảy ra lỗi\",\n    \"There was an error: {{message}}\": \"Đã xảy ra lỗi\"\n  },\n  \"menus\": {\n    \"* Workspaces are available on team plans. \": \"* Không gian làm việc có sẵn trong kế hoạch nhóm. \",\n    \"Select fields\": \"Chọn trường\",\n    \"Upgrade now\": \"Nâng cấp ngay\",\n    \"Any\": \"Bất kỳ\",\n    \"Numeric\": \"Số\",\n    \"Text\": \"Văn bản\",\n    \"Toggle\": \"Chuyển đổi\",\n    \"Date\": \"Ngày\",\n    \"DateTime\": \"Ngày giờ\",\n    \"Choice\": \"Lựa chọn\",\n    \"Choice List\": \"Danh sách lựa chọn\",\n    \"Reference\": \"Phản hồi\",\n    \"Reference List\": \"Danh sách phản hồi\",\n    \"Attachment\": \"Tập tin đính kèm\",\n    \"Search columns\": \"Tìm cột\",\n    \"Integer\": \"Số nguyên\"\n  },\n  \"modals\": {\n    \"Cancel\": \"Hủy\",\n    \"Ok\": \"Được, chốt\",\n    \"Save\": \"Lưu\",\n    \"Are you sure you want to delete these records?\": \"Bạn có chắc muốn xoá các bản ghi này?\",\n    \"Are you sure you want to delete this record?\": \"Bạn có chắc chắn muốn xóa dòng này?\",\n    \"Delete\": \"Xóa\",\n    \"Dismiss\": \"Bỏ qua\",\n    \"Don't ask again.\": \"Không hỏi lại\",\n    \"Don't show again.\": \"Không hiển thị lại\",\n    \"Don't show tips\": \"Không hiển thị mẹo\",\n    \"Undo to restore\": \"Hoàn tác để khôi phục\",\n    \"Got it\": \"Hiểu rồi\",\n    \"Don't show again\": \"Không hiển thị lại\"\n  },\n  \"CellStyle\": {\n    \"Default header style\": \"Kiểu tiêu đề mặc định\",\n    \"Header Style\": \"Kiểu tiêu đề\",\n    \"HEADER STYLE\": \"Kiểu tiêu đề\",\n    \"CELL STYLE\": \"Kiểu ô\",\n    \"Cell style\": \"Kiểu dáng ô\",\n    \"Default cell style\": \"Kiểu ô mặc định\",\n    \"Mixed style\": \"Phong cách hỗn hợp\",\n    \"Open row styles\": \"Kiểu hàng mở\"\n  },\n  \"ChoiceTextBox\": {\n    \"CHOICES\": \"Lựa chọn\"\n  },\n  \"ColumnEditor\": {\n    \"COLUMN DESCRIPTION\": \"Mô tả cột\",\n    \"COLUMN LABEL\": \"Nhãn cột\"\n  },\n  \"ColumnInfo\": {\n    \"COLUMN DESCRIPTION\": \"Mô tả cột\",\n    \"COLUMN ID: \": \"ID CỘT: \",\n    \"COLUMN LABEL\": \"Nhãn cột\",\n    \"Cancel\": \"Hủy\",\n    \"Save\": \"Lưu\"\n  },\n  \"ConditionalStyle\": {\n    \"Add another rule\": \"Thêm một quy tắc khác\",\n    \"Add conditional style\": \"Thêm kiểu có điều kiện\",\n    \"Error in style rule\": \"Lỗi trong kiểu quy tắc\",\n    \"Row style\": \"Kiểu hàng\",\n    \"Rule must return True or False\": \"Quy tắc phải trả về Đúng hoặc Sai\"\n  },\n  \"CurrencyPicker\": {\n    \"Invalid currency\": \"Đơn vị tiền tệ không hợp lệ\"\n  },\n  \"DiscussionEditor\": {\n    \"Cancel\": \"Hủy\",\n    \"Comment\": \"Bình luận\",\n    \"Edit\": \"Chỉnh sửa\",\n    \"Marked as resolved\": \"Được đánh dấu là đã giải quyết\",\n    \"Only current page\": \"Chỉ trang hiện tại\",\n    \"Only my threads\": \"Chỉ chủ đề của tôi\",\n    \"Open\": \"Mở\",\n    \"Remove\": \"Gỡ, Xóa\",\n    \"Reply\": \"Trả lời\",\n    \"Reply to a comment\": \"Trả lời một bình luận\",\n    \"Resolve\": \"Giải quyết\",\n    \"Save\": \"Lưu\",\n    \"Show resolved comments\": \"Hiển thị bình luận đã xử lí\",\n    \"Showing last {{nb}} comments\": \"Đang hiển thị bình luận cuối cùng\",\n    \"Started discussion\": \"Đã bắt đầu thảo luận\",\n    \"Write a comment\": \"Viết bình luận\"\n  },\n  \"EditorTooltip\": {\n    \"Convert column to formula\": \"Chuyển đổi cột thành công thức\"\n  },\n  \"FieldBuilder\": {\n    \"Apply formula to data\": \"Áp dụng công thức cho dữ liệu\",\n    \"CELL FORMAT\": \"ĐỊNH DẠNG Ô\",\n    \"Changing multiple column types\": \"Thay đổi nhiều loại cột\",\n    \"Mixed format\": \"Định dạng hỗn hợp\",\n    \"Mixed types\": \"Các loại hỗn hợp\",\n    \"Revert field settings for {{colId}} to common\": \"Hoàn nguyên cài đặt trường cho {{colld}} thành chung\",\n    \"Save field settings for {{colId}} as common\": \"Lưu cài đặt trường cho {{colld}} vào chung\",\n    \"Use separate field settings for {{colId}}\": \"Sử dụng cài đặt trường riêng cho {{colId}}\",\n    \"DATA FROM TABLE\": \"DỮ LIỆU TỪ BẢNG\",\n    \"Changing column type\": \"Thay đổi loại cột\"\n  },\n  \"FormulaEditor\": {\n    \"use AI Assistant\": \"sử dụng Trợ lý AI\",\n    \"Column or field is required\": \"Cột hoặc trường là bắt buộc\",\n    \"Error in the cell\": \"Lỗi trong ô\",\n    \"Errors in all {{numErrors}} cells\": \"Lỗi trong tất cả ô {{numErrors}}\",\n    \"Errors in {{numErrors}} of {{numCells}} cells\": \"Lỗi trong {{numErrors}}/{{numCells}} của các  ô\",\n    \"editingFormula is required\": \"chỉnh sửa công thức là bắt buộc\",\n    \"Enter formula or {{button}}.\": \"Nhập công thức hoặc {{button}}.\",\n    \"Enter formula.\": \"Nhập công thức.\",\n    \"Expand Editor\": \"Mở rộng trình chỉnh sửa\"\n  },\n  \"HyperLinkEditor\": {\n    \"[link label] url\": \"URL [link label]\"\n  },\n  \"NumericTextBox\": {\n    \"Currency\": \"Tiền tệ\",\n    \"Decimals\": \"Số Thập Phân\",\n    \"Number Format\": \"Định dạng số\",\n    \"Default currency ({{defaultCurrency}})\": \"Đơn vị tiền tệ mặc định ({{defaultCurrency}})\"\n  },\n  \"Reference\": {\n    \"CELL FORMAT\": \"ĐỊNH DẠNG Ô\",\n    \"SHOW COLUMN\": \"Hiển Thị Cột Chế Độ (Show Mode Column)\",\n    \"Row ID\": \"ID hàng\"\n  },\n  \"WelcomeTour\": {\n    \"Add new\": \"Thêm mới, Tạo mới\",\n    \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"Duyệt qua {{templateLibrary}} của chúng tôi để khám phá những gì có thể và lấy cảm hứng.\",\n    \"Building up\": \"xây dựng\",\n    \"Configuring your document\": \"Cấu hình tài liệu của bạn\",\n    \"Customizing columns\": \"Tùy chỉnh cột\",\n    \"Double-click or hit {{enter}} on a cell to edit it. \": \"Nhấp đúp hoặc nhấn {{enter}} trên một ô để chỉnh sửa. \",\n    \"Editing Data\": \"Chỉnh sửa dữ liệu\",\n    \"Flying higher\": \"Bay cao hơn\",\n    \"Help Center\": \"trung tâm Trợ giúp\",\n    \"Make it relational! Use the {{ref}} type to link tables. \": \"Làm cho nó quan hệ! Sử dụng loại {{ref}} để liên kết các bảng. \",\n    \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"Đặt các tùy chọn định dạng, công thức hoặc loại cột, chẳng hạn như ngày tháng, lựa chọn hoặc tệp đính kèm. \",\n    \"Share\": \"Chia sẻ\",\n    \"Sharing\": \"Chia sẻ\",\n    \"Start with {{equal}} to enter a formula.\": \"Bắt đầu bằng {{equal}} để nhập công thức.\",\n    \"Toggle the {{creatorPanel}} to format columns, \": \"Chuyển đổi {{creatorPanel}} thành định dạng cột, \",\n    \"Use the Share button ({{share}}) to share the document or export data.\": \"Sử dụng nút Chia sẻ ({{share}}) để chia sẻ tài liệu hoặc xuất dữ liệu.\",\n    \"Use {{addNew}} to add widgets, pages, or import more data. \": \"Sử dụng {{addNew}} để thêm tiện ích, trang hoặc nhập thêm dữ liệu. \",\n    \"Use {{helpCenter}} for documentation or questions.\": \"Sử dụng {{helpCenter}} cho tài liệu hoặc câu hỏi.\",\n    \"convert to card view, select data, and more.\": \"chuyển đổi sang chế độ xem thẻ, chọn dữ liệu và hơn thế nữa.\",\n    \"creator panel\": \"bảng điều khiển cho người sáng tạo\",\n    \"template library\": \"Thư viện mẫu\",\n    \"Enter\": \"Nhập\",\n    \"Reference\": \"Phản hồi\",\n    \"Welcome to Grist!\": \"Chào mừng bạn đến với Grist!\"\n  },\n  \"GristTooltips\": {\n    \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"Áp dụng định dạng có điều kiện cho các ô trong cột này khi các điều kiện công thức được đáp ứng.\",\n    \"Apply conditional formatting to rows based on formulas.\": \"Áp dụng định dạng có điều kiện cho các hàng dựa trên công thức.\",\n    \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"Các ô trong cột tham chiếu luôn xác định một bản ghi {{entire}} trong bảng đó, nhưng bạn có thể chọn cột nào từ bản ghi đó để hiển thị.\",\n    \"Click on “Open row styles” to apply conditional formatting to rows.\": \"Nhấp vào “Open row styles” để áp dụng định dạng có điều kiện cho các hàng.\",\n    \"Click the Add new button to create new documents or workspaces, or import data.\": \"Nhấp vào nút Thêm mới để tạo tài liệu hoặc không gian làm việc mới hoặc nhập dữ liệu.\",\n    \"Learn more.\": \"Tìm hiểu thêm.\",\n    \"Link your new widget to an existing widget on this page.\": \"Liên kết tiện ích con mới của bạn với một tiện ích con hiện có trên trang này.\",\n    \"Linking Widgets\": \"Các tiện ích liên kết\",\n    \"Only those rows will appear which match all of the filters.\": \"Chỉ những hàng đó sẽ xuất hiện khớp với tất cả các bộ lọc.\",\n    \"Pinned filters are displayed as buttons above the widget.\": \"Bộ lọc đã ghim được hiển thị dưới dạng các nút phía trên tiện ích.\",\n    \"Raw Data page\": \"Trang Dữ liệu thô\",\n    \"Rearrange the fields in your card by dragging and resizing cells.\": \"Sắp xếp lại các trường trong thẻ của bạn bằng cách kéo và thay đổi kích thước ô.\",\n    \"Reference Columns\": \"Cột tham chiếu\",\n    \"Select the table containing the data to show.\": \"Chọn bảng chứa dữ liệu cần hiển thị.\",\n    \"Select the table to link to.\": \"Chọn bảng để liên kết đến\",\n    \"Selecting Data\": \"Chọn dữ liệu\",\n    \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"Trang dữ liệu thô liệt kê tất cả các bảng dữ liệu trong tài liệu của bạn, bao gồm các bảng tóm tắt và các bảng không có trong bố cục trang.\",\n    \"The total size of all data in this document, excluding attachments.\": \"Tổng kích thước của tất cả dữ liệu trong tài liệu này, không bao gồm tệp đính kèm\",\n    \"They allow for one record to point (or refer) to another.\": \"Chúng cho phép một bản ghi trỏ (hoặc giới thiệu) đến một bản ghi khác.\",\n    \"This is the secret to Grist's dynamic and productive layouts.\": \"Đây là bí quyết để bố cục năng động và hiệu quả của Grist.\",\n    \"Updates every 5 minutes.\": \"Cập nhật 5 phút một lần.\",\n    \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"Sử dụng biểu tượng\\\\u{1D6BA} để tạo các bảng tóm tắt (hoặc xoay vòng), cho tổng hoặc tổng phụ.\",\n    \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"Hữu ích để lưu trữ dấu thời gian hoặc tác giả của bản ghi mới, làm sạch dữ liệu và hơn thế nữa.\",\n    \"You can filter by more than one column.\": \"Bạn có thể lọc nhiều hơn một cột.\",\n    \"entire\": \"toàn bộ\",\n    \"relational\": \"Mối quan hệ\",\n    \"Access Rules\": \"Quy tắc truy cập\",\n    \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"Quy tắc truy cập cung cấp cho bạn khả năng tạo các quy tắc sắc thái để xác định ai có thể xem hoặc chỉnh sửa phần nào trong tài liệu của bạn.\",\n    \"Add new\": \"Thêm mới, Tạo mới\",\n    \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"Sử dụng biểu tượng * để tạo các bảng tóm tắt (hoặc xoay vòng), cho tổng số hoặc tổng phụ.\",\n    \"Anchor Links\": \"Liên kết neo\",\n    \"Custom Widgets\": \"Tùy chỉnh Widget\",\n    \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"Để tạo một liên kết neo đưa người dùng đến một ô cụ thể, hãy nhấp vào một hàng và nhấn {{shortcut}}.\",\n    \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"Bạn có thể chọn một trong các tiện ích được tạo sẵn của chúng tôi hoặc nhúng tiện ích của riêng bạn bằng cách cung cấp URL đầy đủ của tiện ích.\",\n    \"Calendar\": \"Lịch\",\n    \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"Bạn không tìm thấy cột phù hợp? Nhấp vào 'Thay đổi tiện ích' để chọn bảng có dữ liệu sự kiện.\",\n    \"To configure your calendar, select columns for start\": {\n      \"end dates and event titles. Note each column's type.\": \"Để định cấu hình lịch của bạn, hãy chọn các cột cho ngày bắt đầu/kết thúc và tiêu đề sự kiện. Lưu ý loại của từng cột.\"\n    },\n    \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID là một chuỗi được tạo ngẫu nhiên hữu ích cho các mã định danh duy nhất và các khóa liên kết.\",\n    \"Use reference columns to relate data in different tables.\": \"Sử dụng các cột tham chiếu để liên kết dữ liệu trong các bảng khác nhau.\",\n    \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"Bạn có thể chọn từ các tiện ích có sẵn cho bạn trong trình đơn thả xuống hoặc nhúng tiện ích của riêng bạn bằng cách cung cấp URL đầy đủ của tiện ích.\",\n    \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"Các công thức hỗ trợ nhiều hàm Excel, cú pháp Python đầy đủ và bao gồm Trợ lý AI hữu ích.\",\n    \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"Xây dựng các biểu mẫu đơn giản ngay trong Grist và chia sẻ chỉ bằng một cú nhấp chuột với tiện ích mới của chúng tôi. {{learnMoreButton}}\",\n    \"Forms are here!\": \"Đã có biểu mẫu!\",\n    \"These rules are applied after all column rules have been processed, if applicable.\": \"Các quy tắc này được áp dụng sau khi tất cả các quy tắc cột đã được xử lý, nếu có.\",\n    \"Nested Filtering\": \"Lọc lồng nhau\",\n    \"Pinning Filters\": \"Bộ lọc ghim\",\n    \"Reference columns are the key to {{relational}} data in Grist.\": \"Các cột tham chiếu là chìa khóa cho dữ liệu {{relational}} trong Grist.\",\n    \"Lookups return data from related tables.\": \"Tra cứu trả về dữ liệu từ các bảng liên quan.\",\n    \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"Nhấp vào {{EyeHideIcon}} trong mỗi ô để ẩn trường khỏi chế độ xem này mà không xóa nó.\",\n    \"Editing Card Layout\": \"Chỉnh sửa bố cục thẻ\",\n    \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"Các công thức kích hoạt trong một số trường hợp nhất định và lưu trữ giá trị được tính toán dưới dạng dữ liệu.\",\n    \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"Hãy thử các thay đổi trong một bản sao, sau đó quyết định có nên thay thế bản gốc bằng các chỉnh sửa của bạn hay không.\",\n    \"Unpin to hide the the button while keeping the filter.\": \"Bỏ ghim để ẩn nút trong khi vẫn giữ bộ lọc.\",\n    \"Learn more\": \"Tìm hiểu thêm\"\n  },\n  \"PagePanels\": {\n    \"Close Creator Panel\": \"Đóng bảng điều khiển của người sáng tạo\",\n    \"Open creator panel\": \"Mở Bảng điều khiển của người sáng tạo\"\n  },\n  \"ColumnTitle\": {\n    \"COLUMN ID: \": \"ID CỘT: \",\n    \"Column ID copied to clipboard\": \"Đã sao chép ID vào cột khay nhớ tạm\",\n    \"Column description\": \"Sửa mô tả cột\",\n    \"Column label\": \"Nhãn cột\",\n    \"Provide a column label\": \"Cung cấp nhãn cột\",\n    \"Save\": \"Lưu\",\n    \"Close\": \"Đóng\",\n    \"Cancel\": \"Hủy\",\n    \"Add description\": \"Thêm mô tả\"\n  },\n  \"Clipboard\": {\n    \"Got it\": \"Hiểu rồi\",\n    \"Unavailable Command\": \"Lệnh không khả dụng\"\n  },\n  \"FieldContextMenu\": {\n    \"Clear field\": \"Xóa trường văn bản\",\n    \"Copy\": \"Sao chép\",\n    \"Copy anchor link\": \"Sao chép liên kết Anchor\",\n    \"Cut\": \"Cắt\",\n    \"Hide field\": \"Ẩn khung này\",\n    \"Paste\": \"Dán (Paste)\"\n  },\n  \"WebhookPage\": {\n    \"Clear queue\": \"Xóa hàng đợi\",\n    \"Webhook settings\": \"Cài đặt Webhook\"\n  },\n  \"FormulaAssistant\": {\n    \"Ask the bot.\": \"Hỏi bot\",\n    \"Capabilities\": \"Khả năng\",\n    \"Community\": \"Cộng đồng\",\n    \"Data\": \"DỮ LIỆU\",\n    \"Formula Cheat Sheet\": \"Bảng công thức gian lận\",\n    \"Formula Help. \": \"Trợ giúp về công thức \",\n    \"Function List\": \"Danh sách chức năng\",\n    \"Grist's AI Assistance\": \"Hỗ trợ AI của Grist\",\n    \"Grist's AI Formula Assistance. \": \"Hỗ trợ công thức AI của Grist \",\n    \"Need help? Our AI assistant can help.\": \"Bạn cần trợ giúp? Trợ lý AI của chúng tôi có thể trợ giúp.\",\n    \"New Chat\": \"Cuộc trò chuyện mới\",\n    \"Preview\": \"Xem trước\",\n    \"Regenerate\": \"Tạo lại\",\n    \"Save\": \"Lưu\",\n    \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"Xem {{helpFunction}} và {{formulaCheat}} của chúng tôi hoặc truy cập {{community}} của chúng tôi để được trợ giúp thêm.\",\n    \"AI Assistant\": \"Trợ lí AI\",\n    \"Apply\": \"Áp dụng\",\n    \"Cancel\": \"Hủy\",\n    \"Clear conversation\": \"Xóa cuộc trò chuyện\",\n    \"Code view\": \"Hiển thị mã\",\n    \"Hi, I'm the Grist Formula AI Assistant.\": \"Xin chào, tôi là Trợ lý AI Công thức Grist.\",\n    \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"Tôi chỉ có thể giúp với các công thức. Tôi không thể xây dựng bảng, cột và chế độ xem hoặc viết các quy tắc truy cập.\",\n    \"Learn more\": \"Tìm hiểu thêm\",\n    \"Press Enter to apply suggested formula.\": \"Nhấn Enter để áp dụng công thức được đề xuất.\",\n    \"Sign Up for Free\": \"Đăng kí miễn phí\",\n    \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"Đăng ký tài khoản Grist miễn phí để bắt đầu sử dụng Trợ lý Công thức AI.\",\n    \"There are some things you should know when working with me:\": \"Có một số điều bạn nên biết khi làm việc với tôi:\",\n    \"Formula AI Assistant is only available for logged in users.\": \"Công thức AI Assistant chỉ khả dụng cho người dùng đã đăng nhập.\",\n    \"For higher limits, contact the site owner.\": \"Để có giới hạn cao hơn nữa, Hãy liên hệ với chủ sở hữu trang website\",\n    \"For higher limits, {{upgradeNudge}}.\": \"Đối với giới hạn cao hơn,Hãy {{upgradeNudge}}.\",\n    \"You have used all available credits.\": \"Bạn đã sử dụng toàn bộ những tài khoản tín dụng có sẵn.\",\n    \"You have {{numCredits}} remaining credits.\": \"Bạn còn lại{{numCredits}} điểm tín dụng.\",\n    \"upgrade to the Pro Team plan\": \"Nâng cấp lên để có gói Pro Team\",\n    \"upgrade your plan\": \"Lên ý tưởng mới cho kế hoạch, dàn ý của bạn\",\n    \"Tips\": \"Mẹo\",\n    \"What do you need help with?\": \"Bạn cần tôi giúp gì ?\"\n  },\n  \"GridView\": {\n    \"Click to insert\": \"Nhấp để chèn vào.\"\n  },\n  \"WelcomeSitePicker\": {\n    \"Welcome back\": \"Chào mừng bạn trở lại\",\n    \"You can always switch sites using the account menu.\": \"Bạn luôn có thể chuyển đổi trang web bằng menu tài khoản.\",\n    \"You have access to the following Grist sites.\": \"Bạn có quyền truy cập vào các trang Grist sau.\"\n  },\n  \"DescriptionTextArea\": {\n    \"DESCRIPTION\": \"Mô Tả\"\n  },\n  \"UserManager\": {\n    \"Add {{member}} to your team\": \"Add {{member}} to your team\",\n    \"Allow anyone with the link to open.\": \"Cho phép bất kì ai với liên kết để mở\",\n    \"Anyone with link \": \"Bất kì ai cũng có thể vào nếu có liên kết \",\n    \"Close\": \"Đóng\",\n    \"Collaborator\": \"Cộng tác viên\",\n    \"Confirm\": \"Xác nhận\",\n    \"Copy link\": \"Sao chép liên kết\",\n    \"Create a team to share with more people\": \"Tạo nhóm để chia với nhiều người hơn\",\n    \"Grist support\": \"Tính năng hỗ trợ của grist\",\n    \"Guest\": \"Khách\",\n    \"Invite multiple\": \"Mời thêm nhiều người\",\n    \"Invite people to {{resourceType}}\": \"Mời mọi người tham gia {{resourceType}}\",\n    \"Link copied to clipboard\": \"Đã sao chép liên kết vào bộ nhớ đệm\",\n    \"Manage members of team site\": \"Quản lý các thành viên của trang web nhóm\",\n    \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"Không có quyền truy cập mặc định nào cho phép cấp quyền truy cập vào các tài liệu hoặc không gian làm việc riêng lẻ thay vì toàn bộ trang web của nhóm.\",\n    \"Once you have removed your own access,             you will not be able to get it back without assistance              from someone else with sufficient access to the {{name}}.\": \"Khi bạn đã xóa quyền truy cập của chính mình, bạn sẽ không thể lấy lại quyền truy cập đó nếu không có sự trợ giúp từ người khác có đủ quyền truy cập vào {{name}}.\",\n    \"Open Access Rules\": \"Mở các quy tắc để truy cập\",\n    \"Outside collaborator\": \"Cộng tác viên bên ngoài\",\n    \"Public access\": \"Đường truy cập công khai\",\n    \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Quyền truy cập công khai được kế thừa từ {{parent}}. Để xóa, hãy đặt tùy chọn 'Kế thừa quyền truy cập' thành 'Không có'.\",\n    \"Public access: \": \"Đường truy cập công khai: \",\n    \"Save & \": \"Lưu & \",\n    \"Team member\": \"Thành viên nhóm\",\n    \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"Người dùng kế thừa quyền từ {{parent})}. Để xóa, hãy đặt tùy chọn 'Kế thừa quyền truy cập' thành 'Không có'.\",\n    \"User may not modify their own access.\": \"Người dùng có thể không được sửa đổi quyền truy cập của riêng họ.\",\n    \"Your role for this team site\": \"Vai trò của bạn cho trang web nhóm này\",\n    \"Your role for this {{resourceType}}\": \"Vai trò của bạn cho {{resourceType}} này\",\n    \"free collaborator\": \"Cộng tác viên miễn phí\",\n    \"guest\": \"Khách\",\n    \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\": \"Người dùng có quyền truy cập xem vào {{resource}} nhờ quyền truy cập được đặt thủ công vào các tài nguyên bên trong. Nếu xóa ở đây, người dùng này sẽ mất quyền truy cập vào tài nguyên bên trong.\",\n    \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"Người dùng kế thừa quyền từ {{parent}}. Để xóa, hãy đặt tùy chọn 'Kế thừa quyền truy cập' thành 'Không có'.\",\n    \"You are about to remove your own access to this {{resourceType}}\": \"Bạn sắp xóa quyền truy cập của riêng mình vào {{resourceType}} này\",\n    \"Cancel\": \"Hủy\",\n    \"Off\": \"Tắt\",\n    \"On\": \"Bật\",\n    \"Remove my access\": \"Xóa quyền truy cập của tôi\",\n    \"member\": \"Thành viên\",\n    \"team site\": \"Trang web nhóm\",\n    \"{{collaborator}} limit exceeded\": \"Đã vượt quá giới hạn {{collaborator}}\",\n    \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} của {{limitTop}} {{collaborator}}s\",\n    \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"Không có quyền truy cập mặc định nào cho phép cấp quyền truy cập vào các tài liệu hoặc không gian làm việc, thay vì toàn bộ trang web của nhóm.\",\n    \"Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.\": \"Khi bạn đã xóa quyền truy cập của riêng mình, bạn sẽ không thể lấy lại quyền truy cập mà không có sự trợ giúp từ người khác có đủ quyền truy cập vào {{resourceType}}.\"\n  },\n  \"SearchModel\": {\n    \"Search all tables\": \"Tìm kiếm tất cả các bảng\",\n    \"Search all pages\": \"Tìm kiếm tất cả các trang\"\n  },\n  \"searchDropdown\": {\n    \"Search\": \"Tìm kiếm\"\n  },\n  \"SupportGristNudge\": {\n    \"Close\": \"Đóng\",\n    \"Contribute\": \"Đóng góp, góp phần\",\n    \"Help Center\": \"trung tâm Trợ giúp\",\n    \"Opt in to Telemetry\": \"Chọn trong Đo từ xa\",\n    \"Opted In\": \"Đã chọn trong\",\n    \"Support Grist\": \"Hỗ trợ Grist\",\n    \"Support Grist page\": \"Hỗ trợ của trang grist\"\n  },\n  \"SupportGristPage\": {\n    \"GitHub Sponsors page\": \"Trang Nhà tài trợ GitHub\",\n    \"Help Center\": \"trung tâm Trợ giúp\",\n    \"Home\": \"Trang đầu\",\n    \"Manage Sponsorship\": \"Quản lý tài trợ\",\n    \"Opt in to Telemetry\": \"Chọn trong Đo từ xa\",\n    \"Opt out of Telemetry\": \"Chọn không tham gia Đo từ xa\",\n    \"Sponsor Grist Labs on GitHub\": \"Nhà tài trợ Grist Labs trên GitHub\",\n    \"Support Grist\": \"Hỗ trợ Grist\",\n    \"Telemetry\": \"Đo từ xa\",\n    \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"Những ví dụ này được chọn trong Đo từ xa. Chỉ quản trị viên trang web mới có quyền thay đổi cái này\",\n    \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"Những ví dụ này được chọn trong Đo từ xa. Chỉ quản trị viên trang web mới có quyền thay đổi cái này\",\n    \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"Chúng tôi chỉ thu thập số liệu thống kê sử dụng, như được nêu chi tiết trong {{link}} của chúng tôi, không bao giờ thu thập nội dung tài liệu.\",\n    \"You can opt out of telemetry at any time from this page.\": \"Bạn có thể chọn không tham gia đo từ xa bất kỳ lúc nào từ trang này.\",\n    \"You have opted in to telemetry. Thank you!\": \"Bạn đã chọn tham gia đo từ xa. Cảm ơn bạn!\",\n    \"You have opted out of telemetry.\": \"Bạn không chọn tham gia đo từ xa.\",\n    \"GitHub\": \"Trang web github\"\n  },\n  \"buildViewSectionDom\": {\n    \"No row selected in {{title}}\": \"Không có hàng nào được chọn trong {{title}}\",\n    \"Not all data is shown\": \"Không phải tất cả dữ liệu đều được hiển thị\",\n    \"No data\": \"Không có dữ liệu\"\n  },\n  \"FloatingEditor\": {\n    \"Collapse Editor\": \"Trình soạn thảo thu gọn\"\n  },\n  \"FloatingPopup\": {\n    \"Maximize\": \"Tối đa\",\n    \"Minimize\": \"Tối thiểu\"\n  },\n  \"CardContextMenu\": {\n    \"Copy anchor link\": \"Sao chép liên kết Anchor\",\n    \"Delete card\": \"Xoá thẻ\",\n    \"Duplicate card\": \"Nhân đôi thẻ\",\n    \"Insert card\": \"Chèn thẻ\",\n    \"Insert card above\": \"Chèn thẻ ở trên\",\n    \"Insert card below\": \"Chèn thẻ ở dưới\"\n  },\n  \"HiddenQuestionConfig\": {\n    \"Hidden fields\": \"Ẩn trường\"\n  },\n  \"WelcomeCoachingCall\": {\n    \"free coaching call\": \"Cuộc gọi tư vấn miễn phí\",\n    \"Maybe later\": \"Xem sau\",\n    \"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.\": \"Trong cuộc gọi, chúng tôi sẽ dành thời gian để biết thêm nhu cầu của bạn và điều chỉnh cuộc gọi cho bạn. Chúng tôi có thể cho bạn xem thông tin cơ bản về Grist, hoặc bắt đầu làm việc với dữ liệu của bạn ngay để xây dựng các trang tổng quan bạn cần.\",\n    \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"Đặt lịch {{freeCoachingCall}} với một thành viên trong đội của chúng tôi.\",\n    \"Schedule call\": \"Đặt lịch gọi\"\n  },\n  \"FormView\": {\n    \"Publish\": \"Đăng\",\n    \"Publish your form?\": \"Đăng biểu mẫu của bạn?\",\n    \"Unpublish\": \"Bỏ đăng\",\n    \"Unpublish your form?\": \"Bỏ đăng biểu mẫu của bạn?\",\n    \"Anyone with the link below can see the empty form and submit a response.\": \"Bất kỳ ai có liên kết dưới đây đều có thể xem biểu mẫu trống và gửi phản hồi\",\n    \"Are you sure you want to reset your form?\": \"Bạn có chắc chắn muốn làm lại biểu mẫu?\",\n    \"Code copied to clipboard\": \"Mã đã được sao chép vào clipboard\",\n    \"Copy code\": \"Sao chép mã\",\n    \"Copy link\": \"Sao chép liên kết\",\n    \"Embed this form\": \"Nhúng biểu mẫu này\",\n    \"Link copied to clipboard\": \"Đã sao chép liên kết vào bộ nhớ đệm\",\n    \"Preview\": \"Xem trước\",\n    \"Reset\": \"Đặt lại\",\n    \"Reset form\": \"Đặt lại biểu mẫu\",\n    \"Save your document to publish this form.\": \"Lưu tài liệu của bạn để đăng biểu mẫu này.\",\n    \"Share\": \"Chia sẻ\",\n    \"Share this form\": \"Chia sẻ biểu mẫu này\",\n    \"View\": \"Xem\"\n  },\n  \"Menu\": {\n    \"Insert question below\": \"Chèn câu hỏi bên dưới\",\n    \"Paragraph\": \"Đoạn văn\",\n    \"Paste\": \"Dán (Paste)\",\n    \"Separator\": \"Dấu phân cách\",\n    \"Unmapped fields\": \"Các trường dữ liệu chưa được kết nối\",\n    \"Header\": \"Đầu mục\",\n    \"Building blocks\": \"thành phần cơ bản\",\n    \"Columns\": \"Nhiều cột\",\n    \"Copy\": \"Sao chép\",\n    \"Cut\": \"Cắt\",\n    \"Insert question above\": \"Chèn câu hỏi ở phía trên\"\n  },\n  \"UnmappedFieldsConfig\": {\n    \"Clear\": \"Xóa\",\n    \"Map fields\": \"Kết nối trường dữ liệu\",\n    \"Mapped\": \"Kết nối\",\n    \"Select all\": \"Chọn tất cả\",\n    \"Unmap fields\": \"Bỏ kết nối các trường\",\n    \"Unmapped\": \"Bỏ kết nối\"\n  },\n  \"FormConfig\": {\n    \"Field rules\": \"Quy định về trường dữ liệu\",\n    \"Required field\": \"Trường dữ liệu bắc buộc\"\n  },\n  \"CustomView\": {\n    \"Some required columns aren't mapped\": \"Một số cột bắt buộc được không được kết nối\",\n    \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"Để sử dụng công cụ này, vui lòng kết nối tất cả các cột không tuỳ chọn từ bảng điều khiển của người sáng tạo ở bên phải.\"\n  },\n  \"FormContainer\": {\n    \"Build your own form\": \"Tạo biểu mẫu của riêng bạn\",\n    \"Powered by\": \"Cung cấp bởi\"\n  },\n  \"FormErrorPage\": {\n    \"Error\": \"Lỗi\"\n  },\n  \"FormModel\": {\n    \"Oops! The form you're looking for doesn't exist.\": \"Rất tiếc! Biểu mẫy bạn đang tìm kiếm không tồn tại.\",\n    \"Oops! This form is no longer published.\": \"Rất tiếc! Biểu mẫu này còn được xuất bản nữa.\",\n    \"You don't have access to this form.\": \"Bạn không có quyền truy cập vào biểu mẫu này.\",\n    \"There was a problem loading the form.\": \"Đã có sự cố khi tải biểu mẫu.\"\n  },\n  \"FormPage\": {\n    \"There was an error submitting your form. Please try again.\": \"Đã xãy ra lỗi khi gửi biểu mẫu của bạn. Vui lòng thử lại.\"\n  },\n  \"FormSuccessPage\": {\n    \"Form Submitted\": \"Đã gửi biểu mẫu.\",\n    \"Thank you! Your response has been recorded.\": \"Cảm ơn bạn! Phản hồi của bạn đã được ghi lại.\"\n  },\n  \"DateRangeOptions\": {\n    \"Last 30 days\": \"30 ngày trước\",\n    \"Last 7 days\": \"7 ngày trước\",\n    \"Last week\": \"Tuần trước\",\n    \"Next 7 days\": \"7 ngày sau\",\n    \"This month\": \"Tháng này\",\n    \"This week\": \"Tuần này\",\n    \"This year\": \"Năm nay\",\n    \"Today\": \"Hôm nay\"\n  },\n  \"LanguageMenu\": {\n    \"Language\": \"Ngôn ngữ\"\n  },\n  \"FilterConfig\": {\n    \"Add column\": \"Thêm cột\"\n  },\n  \"pages\": {\n    \"Duplicate page\": \"Trang trùng lặp\",\n    \"Remove\": \"Gỡ, Xóa\",\n    \"Rename\": \"Đổi tên\",\n    \"You do not have edit access to this document\": \"Bạn không có quyền chỉnh sửa tài liệu này\"\n  },\n  \"search\": {\n    \"Find Next \": \"Tìm bước kế tiếp \",\n    \"Find Previous \": \"Tìm trước đó \",\n    \"No results\": \"Không có kết quả\",\n    \"Search in document\": \"Tìm kiếm trong tài liệu\",\n    \"Search\": \"Tìm kiếm\"\n  },\n  \"sendToDrive\": {\n    \"Sending file to Google Drive\": \"Gửi tệp tới google drive\"\n  },\n  \"NTextBox\": {\n    \"false\": \"Sai\",\n    \"true\": \"true\"\n  },\n  \"ACLUsers\": {\n    \"Example Users\": \"Ví dụ người dùng\",\n    \"Users from table\": \"Người dùng từ bảng\",\n    \"View as\": \"Xem dưới dạng\"\n  },\n  \"TypeTransform\": {\n    \"Apply\": \"Áp dụng\",\n    \"Cancel\": \"Hủy\",\n    \"Preview\": \"Xem trước\",\n    \"Revise\": \"Xem lại\",\n    \"Update formula (Shift+Enter)\": \"Cập nhật công thức (Shift+Enter)\"\n  },\n  \"FieldEditor\": {\n    \"It should be impossible to save a plain data value into a formula column\": \"Không thể lưu giá trị dữ liệu đơn giản vào một cột công thức\",\n    \"Unable to finish saving edited cell\": \"Không thể hoàn tất lưu ô đã chỉnh sửa\"\n  },\n  \"DescriptionConfig\": {\n    \"DESCRIPTION\": \"Mô Tả\"\n  },\n  \"Editor\": {\n    \"Delete\": \"Xóa\"\n  },\n  \"MappedFieldsConfig\": {\n    \"Clear\": \"Xóa\",\n    \"Map fields\": \"Kết nối trường dữ liệu\",\n    \"Mapped\": \"Kết nối\",\n    \"Select all\": \"Chọn tất cả\",\n    \"Unmap fields\": \"Bỏ kết nối các trường\",\n    \"Unmapped\": \"Bỏ kết nối\"\n  },\n  \"Section\": {\n    \"Insert section above\": \"Chèn phần ở phía trên\",\n    \"Insert section below\": \"Chèn phần ở bên dưới\"\n  }\n}\n"
  },
  {
    "path": "static/locales/vi.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/zh_Hans.client.json",
    "content": "{\n    \"AccessRules\": {\n        \"Remove {{- tableId }} rules\": \"删除 {{- tableId }} 规则\",\n        \"Remove {{- name }} user attribute\": \"删除 {{- name }} 用户属性\",\n        \"Reset\": \"重置\",\n        \"Rules for table \": \"表格规则 \",\n        \"Save\": \"保存\",\n        \"Saved\": \"已保存\",\n        \"Special rules\": \"特殊规则\",\n        \"Type message to display when this rule blocks an action…\": \"输入一个拒绝时的提示信息…\",\n        \"User Attributes\": \"用户属性\",\n        \"View as\": \"查看为\",\n        \"Seed rules\": \"种子规则\",\n        \"Permission to edit document structure\": \"编辑文档结构的权限\",\n        \"Add column rule\": \"添加列规则\",\n        \"Attribute to Look Up\": \"要查找的属性\",\n        \"Everyone\": \"每个人\",\n        \"Permission to access the document in full when needed\": \"允许在需要时获取文件的全部内容\",\n        \"Add Default Rule\": \"添加默认规则\",\n        \"Add table rules\": \"添加表格规则\",\n        \"Add user attributes\": \"添加用户属性\",\n        \"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.\": \"允许所有人复制整个文档，或在演示模式下完整查看。\\n适用于示例和模板，但不适用于包含敏感数据的文档。\",\n        \"Attribute name\": \"属性名称\",\n        \"Condition\": \"条件\",\n        \"Default rules\": \"默认规则\",\n        \"Enter Condition\": \"输入条件\",\n        \"Permission to view Access Rules\": \"查看访问规则的权限\",\n        \"Allow everyone to view Access Rules.\": \"允许所有人查看访问规则。\",\n        \"Lookup Table\": \"查找表\",\n        \"Checking...\": \"检查中…\",\n        \"Delete table rules\": \"删除表格规则\",\n        \"Everyone Else\": \"其他所有人\",\n        \"Invalid\": \"无效的\",\n        \"Lookup Column\": \"查找列\",\n        \"Permissions\": \"权限\",\n        \"Remove column {{- colId }} from {{- tableId }} rules\": \"从 {{- tableId }} 规则中删除列 {{- colId }}\",\n        \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"添加表规则时，自动添加规则以授予 OWNER 完全访问权限。\",\n        \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"允许编辑者编辑结构（例如修改和删除表、列、布局）和编写公式，无论读取限制如何，都可以访问所有数据。\",\n        \"This default should be changed if editors' access is to be limited. \": \"如果要限制编辑者的访问权限，则应更改此默认值。 \",\n        \"Add table-wide rule\": \"添加全表规则\",\n        \"Access rules have changed. Click Reset to revert your changes and refresh the rules.\": \"访问规则已更改。单击重置可还原更改并刷新规则。\",\n        \"All\": \"全部\",\n        \"Column {{colId}} appears in multiple rules for table {{tableId}} that might be order-dependent. Try splitting rules up differently?\": \"{{colId}} 列出现在表{{tableId}} 的多条规则中，可能与顺序有关。尝试以不同方式拆分规则？\",\n        \"Columns\": \"列\",\n        \"Condition cannot be blank\": \"条件不能为空\",\n        \"Default resource missing in resource map\": \"资源地图中缺少默认资源\",\n        \"Invalid columns in table {{tableId}}: {{invalidColIds}}\": \"表{{tableId}} 中的无效列 ：{{invalidColIds}}\",\n        \"Invalid table: {{tableId}}\": \"无效表格:{{tableId}}\",\n        \"Invalid user attribute rule: {{prop}} must be set\": \"无效的用户属性规则：必须设置{{prop}}\",\n        \"Invalid user attribute to look up\": \"要查询的用户属性无效\",\n        \"No columns listed in a column rule for table {{tableId}}\": \"表{{tableId}}所列出的规则中未包含列\",\n        \"Not a valid user attribute\": \"用户属性无效\",\n        \"Resource missing in resource map: {{resourceKey}}\": \"资源地图：{{resourceKey}}中缺少资源\",\n        \"Trying to add TableRules for existing table {{tableId}}\": \"尝试为现有表:{{tableId}}添加列表规则\",\n        \"Use a simple attribute of user.LinkKey, e.g. user.LinkKey.something\": \"使用简单的用户属性,比如. user.LinkKey.等等\",\n        \"hidden\": \"隐藏\"\n    },\n    \"AccountPage\": {\n        \"API\": \"API\",\n        \"API Key\": \"API密钥\",\n        \"Account settings\": \"帐户设置\",\n        \"Allow signing in to this account with Google\": \"允许使用 Google 登录此账户\",\n        \"Change password\": \"更改密码\",\n        \"Edit\": \"编辑\",\n        \"Email\": \"电子邮件\",\n        \"Login method\": \"登录方式\",\n        \"Name\": \"名字\",\n        \"Names only allow letters, numbers and certain special characters\": \"名称仅允许使用字母、数字和某些特殊字符\",\n        \"Password & security\": \"密码\",\n        \"Save\": \"保存\",\n        \"Theme\": \"主题\",\n        \"Two-factor authentication\": \"双因素身份验证\",\n        \"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.\": \"双因素身份验证是您的Grist帐户的额外安全层，旨在确保您是唯一可以访问您的帐户的人，即使有人知道您的密码。\",\n        \"Language\": \"语言\"\n    },\n    \"CodeEditorPanel\": {\n        \"Access denied\": \"访问被拒绝\",\n        \"Code View is available only when you have full document access.\": \"代码视图只有在你有完整的文档权限时才可用。\"\n    },\n    \"ColorSelect\": {\n        \"Cancel\": \"取消\",\n        \"Default cell style\": \"默认单元格样式\",\n        \"Apply\": \"应用\"\n    },\n    \"ColumnFilterMenu\": {\n        \"All\": \"全部\",\n        \"All shown\": \"全部显示\",\n        \"Filter by Range\": \"按范围筛选\",\n        \"No matching values\": \"没有匹配的值\",\n        \"None\": \"None\",\n        \"Min\": \"最小值\",\n        \"Max\": \"最大值\",\n        \"Start\": \"开始\",\n        \"Other Matching\": \"其他匹配\",\n        \"Other Non-Matching\": \"其他不匹配的\",\n        \"Others\": \"其他\",\n        \"Search\": \"搜索\",\n        \"All except\": \"全部除外\",\n        \"End\": \"结束\",\n        \"Other values\": \"其他值\",\n        \"Search values\": \"搜索值\",\n        \"Future values\": \"未来价值\",\n        \"Clear search\": \"清除搜索\",\n        \"Sort alphabetically (current: sorted by number of occurrences)\": \"按字母顺序排序（当前按出现次数排序）\",\n        \"Sort by number of occurrences (current: sorted alphabetically)\": \"按出现次数排序（当前按字母顺序排序）\",\n        \"Pin filter\": \"固定筛选器\",\n        \"Unpin filter\": \"取消固定筛选器\"\n    },\n    \"CustomSectionConfig\": {\n        \" (optional)\": \" （可选）\",\n        \"Add\": \"添加\",\n        \"Full document access\": \"完整文档访问权限\",\n        \"Learn more about custom widgets\": \"了解有关自定义小部件的更多信息\",\n        \"No document access\": \"没有文档访问权限\",\n        \"Open configuration\": \"打开配置\",\n        \"Pick a column\": \"选择一列\",\n        \"Read selected table\": \"读取选定的表\",\n        \"Select Custom Widget\": \"选择自定义小部件\",\n        \"Widget does not require any permissions.\": \"小部件不需要任何权限。\",\n        \"Widget needs to {{read}} the current table.\": \"小部件需要 {{read}} 当前表。\",\n        \"Widget needs {{fullAccess}} to this document.\": \"小部件需要 {{fullAccess}} 才能访问此文档。\",\n        \"Enter Custom URL\": \"输入自定义URL\",\n        \"Pick a {{columnType}} column\": \"选择一个 {{columnType}} 列\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} 非 {{columnType}} 列未显示\",\n        \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} 非 {{columnType}} 列未显示\",\n        \"No {{columnType}} columns in table.\": \"表格中没有{{columnType}} 列。\",\n        \"Clear selection\": \"清除选择\",\n        \"Last updated:\": \"上次更新：\",\n        \"Reject\": \"拒绝\",\n        \"ACCESS LEVEL\": \"访问级别\",\n        \"Accept\": \"接受\",\n        \"Custom URL\": \"自定义 URL\",\n        \"Developer:\": \"开发者：\",\n        \"Missing description and author information.\": \"缺少描述和作者信息。\",\n        \"Widget\": \"小部件\",\n        \"Change custom widget\": \"更改自定义小部件\"\n    },\n    \"ChartView\": {\n        \"Create separate series for each value of the selected column.\": \"为所选列的每个值创建单独的系列。\",\n        \"Toggle chart aggregation\": \"切换图表聚合\",\n        \"selected new group data columns\": \"选定的新组数据列\",\n        \"Pick a column\": \"选择一列\",\n        \"Each Y series is followed by a series for the length of error bars.\": \"每个 Y 系列后跟一个误差条长度的系列。\",\n        \"Each Y series is followed by two series, for top and bottom error bars.\": \"每个 Y 系列后跟两个系列，用于顶部和底部误差线。\",\n        \"Bar chart\": \"条形图\",\n        \"Donut chart\": \"环形图\",\n        \"Area chart\": \"面积图\",\n        \"Scatter plot\": \"散点图\",\n        \"Kaplan-Meier plot\": \"Kaplan-Meier 图\",\n        \"Invert Y-axis\": \"反转 Y 轴\",\n        \"Log scale Y-axis\": \"对数标尺 Y 轴\",\n        \"Text size\": \"字体大小\",\n        \"None\": \"无\",\n        \"Symmetric\": \"对称\",\n        \"X-AXIS\": \"X 轴\",\n        \"Aggregate values\": \"聚合值\",\n        \"non-numeric column is not shown\": \"非数字列不显示\",\n        \"LABEL\": \"标签\",\n        \"Pie chart\": \"饼图\",\n        \"Line chart\": \"折线图\",\n        \"Show total\": \"显示总计\",\n        \"Show markers\": \"显示标记\",\n        \"non-numeric columns are not shown\": \"非数字列不显示\",\n        \"Vertical\": \"垂直\",\n        \"Horizontal\": \"水平\",\n        \"Error bars\": \"误差线\",\n        \"Above+Below\": \"上下\",\n        \"selected new x-axis\": \"选择新的 x 轴\",\n        \"Remove\": \"删除\",\n        \"Orientation\": \"方向\",\n        \"Connect gaps\": \"连接间隔\",\n        \"Split series\": \"拆分序列\",\n        \"Split Series\": \"拆分序列\",\n        \"SERIES\": \"序列\",\n        \"Add Series\": \"增加序列\",\n        \"Hole Size\": \"孔径\",\n        \"Stack series\": \"堆叠序列\",\n        \"Hole size\": \"体积\",\n        \"Add series\": \"添加系列\"\n    },\n    \"DataTables\": {\n        \"Click to copy\": \"点击复制\",\n        \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"删除 {{formattedTableName}} 数据，并将其从所有页面中删除？\",\n        \"Duplicate table\": \"复制表\",\n        \"Raw Data Tables\": \"原始数据表\",\n        \"Table ID copied to clipboard\": \"表 ID 已复制到剪贴板\",\n        \"You do not have edit access to this document\": \"您没有对此文档的编辑权限\",\n        \"Edit record card\": \"编辑记录卡片\",\n        \"Rename table\": \"重新命名表格\",\n        \"{{action}} Record Card\": \"{{action}} 记录卡片\",\n        \"Record Card\": \"记录卡片\",\n        \"Remove table\": \"移除表格\",\n        \"Record Card Disabled\": \"记录卡片禁用\"\n    },\n    \"CellContextMenu\": {\n        \"Duplicate rows_one\": \"重复行\",\n        \"Delete {{count}} columns_other\": \"删除 {{count}} 列\",\n        \"Delete {{count}} rows_one\": \"删除行\",\n        \"Duplicate rows_other\": \"重复行\",\n        \"Filter by this value\": \"按此值筛选\",\n        \"Insert column to the left\": \"在左侧插入列\",\n        \"Insert row below\": \"在下方插入行\",\n        \"Insert column to the right\": \"在右侧插入列\",\n        \"Reset {{count}} columns_other\": \"重置 {{count}} 列\",\n        \"Insert row\": \"插入行\",\n        \"Insert row above\": \"在上方插入行\",\n        \"Reset {{count}} columns_one\": \"重置列\",\n        \"Reset {{count}} entire columns_one\": \"重置整列\",\n        \"Reset {{count}} entire columns_other\": \"重置 {{count}} 整列\",\n        \"Clear cell\": \"清除单元格\",\n        \"Clear values\": \"清除值\",\n        \"Copy anchor link\": \"复制锚点链接\",\n        \"Delete {{count}} columns_one\": \"删除列\",\n        \"Delete {{count}} rows_other\": \"删除 {{count}} 行\",\n        \"Paste\": \"粘贴\",\n        \"Comment\": \"评论\",\n        \"Copy\": \"拷贝\",\n        \"Cut\": \"剪切\",\n        \"Copy with headers\": \"带表头复制\"\n    },\n    \"ACUserManager\": {\n        \"Enter email address\": \"请输入电子邮件地址\",\n        \"Invite new member\": \"邀请新成员\",\n        \"We'll email an invite to {{email}}\": \"我们将通过电子邮件向 {{email}} 发送邀请\"\n    },\n    \"ActionLog\": {\n        \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"列 {{colId}} 将在操作 #{{action.actionNum}} 中删除\",\n        \"Action Log failed to load\": \"操作日志加载失败\",\n        \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"表 {{tableId}} 将在操作 #{{actionNum}} 中删除\",\n        \"This row was subsequently removed in action {{action.actionNum}}\": \"该行将在操作 {{action.actionNum}} 中删除\",\n        \"All tables\": \"所有表格\",\n        \"Column {{colId}} was subsequently removed in action #{{actionNum}}\": \"{{colId}} 列将在 #{{actionNum}} 操作后被删除\",\n        \"This row was subsequently removed in action {{actionNum}}\": \"该行将在 #{{actionNum}} 操作后被删除\",\n        \"History blocked because of access rules.\": \"因访问规则而被阻止的历史记录。\"\n    },\n    \"ApiKey\": {\n        \"This API key can be used to access this account anonymously via the API.\": \"该API密钥可用于通过API匿名访问该账户。\",\n        \"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 密钥。这将导致将来使用此 API 密钥的所有请求被拒绝。是否仍要删除？\",\n        \"By generating an API key, you will be able to make API calls for your own account.\": \"通过生成 API 密钥，您将能够为自己的帐户进行 API 调用。\",\n        \"Click to show\": \"点击显示\",\n        \"Create\": \"创建\",\n        \"Remove\": \"删除\",\n        \"Remove API Key\": \"删除 API 密钥\",\n        \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"此 API 密钥可用于通过 API 访问您的账户。请勿将您的 API 密钥透露给任何人。\"\n    },\n    \"AccountWidget\": {\n        \"Access Details\": \"访问详情\",\n        \"Accounts\": \"帐户\",\n        \"Add account\": \"新增帐户\",\n        \"Document settings\": \"文档设置\",\n        \"Manage team\": \"管理团队\",\n        \"Pricing\": \"定价\",\n        \"Profile settings\": \"配置设置\",\n        \"Sign out\": \"登出\",\n        \"Sign in\": \"登录\",\n        \"Switch Accounts\": \"切换帐户\",\n        \"Toggle Mobile Mode\": \"切换移动模式\",\n        \"Activation\": \"激活\",\n        \"Billing account\": \"计费账户\",\n        \"Support Grist\": \"支持 Grist\",\n        \"Upgrade Plan\": \"升级计划\",\n        \"Sign up\": \"注册\",\n        \"Use This Template\": \"使用这个模板\"\n    },\n    \"ViewAsDropdown\": {\n        \"View as\": \"查看为\",\n        \"Users from table\": \"表中的用户\",\n        \"Example Users\": \"示例用户\"\n    },\n    \"AddNewButton\": {\n        \"Add new\": \"新增\"\n    },\n    \"App\": {\n        \"Description\": \"描述\",\n        \"Key\": \"关键\",\n        \"Memory Error\": \"内存错误\",\n        \"Translators: please translate this only when your language is ready to be offered to users\": \"[TRANSLATED]\"\n    },\n    \"AppHeader\": {\n        \"Home page\": \"主页\",\n        \"Legacy\": \"遗产\",\n        \"Personal Site\": \"个人网站\",\n        \"Team Site\": \"团队网站\",\n        \"Grist Templates\": \"Grist 模板\",\n        \"Billing account\": \"计费账户\",\n        \"Manage team\": \"管理团队\",\n        \"{{- organizationName }} - Back to home\": \"{{- organizationName }} - 回到首页\"\n    },\n    \"AppModel\": {\n        \"This team site is suspended. Documents can be read, but not modified.\": \"该团队站点已暂停使用。文档可阅读，但不可修改。\"\n    },\n    \"DocMenu\": {\n        \"You are on your personal site. You also have access to the following sites:\": \"你在你的个人网站上。 您还可以访问以下站点：\",\n        \"Document will be permanently deleted.\": \"文档将被永久删除。\",\n        \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"文档在回收站中保留 30 天，之后它们将被永久删除。\",\n        \"Edited {{at}}\": \"编辑于{{at}}\",\n        \"Examples & Templates\": \"示例和模板\",\n        \"Examples and Templates\": \"示例和模板\",\n        \"Featured\": \"精选\",\n        \"Manage users\": \"管理用户\",\n        \"More Examples and Templates\": \"更多示例和模板\",\n        \"Move\": \"移动\",\n        \"Move {{name}} to workspace\": \"将 {{name}} 移动到工作区\",\n        \"Other Sites\": \"其他网站\",\n        \"Permanently Delete \\\"{{name}}\\\"?\": \"永久删除“{{name}}”？\",\n        \"Pin Document\": \"置顶文档\",\n        \"Pinned Documents\": \"置顶的文档\",\n        \"Remove\": \"移除\",\n        \"Rename\": \"重命名\",\n        \"(The organization needs a paid plan)\": \"（该组织需要付费计划）\",\n        \"Access Details\": \"访问详细信息\",\n        \"All documents\": \"所有文件\",\n        \"By Date Modified\": \"按修改日期\",\n        \"By Name\": \"按名字\",\n        \"Current workspace\": \"当前工作区\",\n        \"Delete\": \"删除\",\n        \"Delete Forever\": \"永久删除\",\n        \"Deleted {{at}}\": \"已删除 {{at}}\",\n        \"Discover More Templates\": \"发现更多模板\",\n        \"Document will be moved to Trash.\": \"文档将被移至回收站。\",\n        \"Requires edit permissions\": \"需要编辑权限\",\n        \"Restore\": \"恢复\",\n        \"This service is not available right now\": \"该服务目前不可用\",\n        \"To restore this document, restore the workspace first.\": \"要恢复此文档，请先恢复工作区。\",\n        \"Trash\": \"回收站\",\n        \"Trash is empty.\": \"回收站是空的。\",\n        \"Unpin Document\": \"取消置顶文档\",\n        \"Workspace not found\": \"找不到工作区\",\n        \"You are on the {{siteName}} site. You also have access to the following sites:\": \"您在 {{siteName}} 网站上。 您还可以访问以下站点：\",\n        \"You may delete a workspace forever once it has no documents in it.\": \"一旦其中没有文档，您可以永远删除工作区。\",\n        \"Delete {{name}}\": \"删除 {{name}}\",\n        \"personal site\": \"个人站点\",\n        \"Any documents created in this site will appear here.\": \"在此站点上创建的任何文档都将显示在此处。\",\n        \"Create my first document\": \"创建我的首个文档\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"您对此站点具有只读访问权限。目前没有任何文件。\",\n        \"Grid view\": \"网格视图\",\n        \"List view\": \"列表视图\"\n    },\n    \"DocPageModel\": {\n        \"Add empty table\": \"添加空表\",\n        \"Add page\": \"添加页面\",\n        \"Add widget to page\": \"将小部件添加到页面\",\n        \"Enter recovery mode\": \"进入恢复模式\",\n        \"Error accessing document\": \"访问文档时出错\",\n        \"Reload\": \"重新加载\",\n        \"You do not have edit access to this document\": \"您没有此文档的编辑权限\",\n        \"Sorry, access to this document has been denied. [{{error}}]\": \"对不起，该文档的访问已被拒绝。[{{error}}]\",\n        \"You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]\": \"您可以尝试重新加载文档，或使用恢复模式。在恢复模式中打开文档时，所有者可以完全访问，其他人无法访问。它还会禁用公式。 [{{error}}]\",\n        \"Document owners can attempt to recover the document. [{{error}}]\": \"文档所有者可以尝试恢复文档。 [{{error}}]\",\n        \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"请重新加载文档，如果错误仍然存在，请联系文档所有者尝试恢复文档。[{{error}}]\"\n    },\n    \"SortFilterConfig\": {\n        \"Sort\": \"排序\",\n        \"Filter\": \"筛选\",\n        \"Revert\": \"恢复\",\n        \"Save\": \"保存\",\n        \"Update Sort & Filter settings\": \"更新排序和筛选设置\"\n    },\n    \"DocHistory\": {\n        \"Snapshots\": \"快照\",\n        \"Activity\": \"活动\",\n        \"Beta\": \"测试版\",\n        \"Compare to current\": \"与当前比较\",\n        \"Compare to previous\": \"与以前相比\",\n        \"Open snapshot\": \"打开快照\",\n        \"Snapshots are unavailable.\": \"快照不可用。\",\n        \"Only owners have access to snapshots for documents with access rules.\": \"只有拥有者可以访问具有访问规则的文档快照。\"\n    },\n    \"WelcomeTour\": {\n        \"Share\": \"分享\",\n        \"Reference\": \"参考\",\n        \"Flying higher\": \"飞得更高\",\n        \"Help Center\": \"帮助中心\",\n        \"Sharing\": \"分享\",\n        \"Welcome to Grist!\": \"欢迎来到Grist！\",\n        \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"浏览我们的 {{templateLibrary}} 发现可能性并获得灵感。\",\n        \"Building up\": \"建起来\",\n        \"Add new\": \"添新\",\n        \"Configuring your document\": \"配置您的文档\",\n        \"Customizing columns\": \"自定义列\",\n        \"Double-click or hit {{enter}} on a cell to edit it. \": \"双击或点击单元格上的 {{enter}} 对其进行编辑。 \",\n        \"Editing Data\": \"编辑数据\",\n        \"Enter\": \"进入\",\n        \"Make it relational! Use the {{ref}} type to link tables. \": \"让它有关系！ 使用 {{ref}} 类型链接表格。 \",\n        \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"设置格式设置选项、公式或列类型，例如日期、选项或附件。 \",\n        \"Start with {{equal}} to enter a formula.\": \"从 {{equal}} 开始输入公式。\",\n        \"Toggle the {{creatorPanel}} to format columns, \": \"切换 {{creatorPanel}} 以格式化列， \",\n        \"Use the Share button ({{share}}) to share the document or export data.\": \"使用共享按钮 ({{share}}) 共享文档或导出数据。\",\n        \"Use {{addNew}} to add widgets, pages, or import more data. \": \"使用 {{addNew}} 添加小部件、页面或导入更多数据。 \",\n        \"Use {{helpCenter}} for documentation or questions.\": \"使用 {{helpCenter}} 获取文档或问题。\",\n        \"convert to card view, select data, and more.\": \"转换为卡片视图、选择数据等。\",\n        \"creator panel\": \"创作者面板\",\n        \"template library\": \"模板库\",\n        \"AI Assistant\": \"AI助手\"\n    },\n    \"HomeIntro\": {\n        \"Interested in using Grist outside of your team? Visit your free \": \"有兴趣在您的团队之外使用 Grist 吗？ 访问您的免费 \",\n        \"Invite Team Members\": \"邀请团队成员\",\n        \"Sign up\": \"注册\",\n        \"personal site\": \"个人网站\",\n        \"Any documents created in this site will appear here.\": \"在此站点中创建的任何文档都将显示在此处。\",\n        \"Browse Templates\": \"浏览模板\",\n        \"Create empty document\": \"创建空文档\",\n        \"Get started by creating your first Grist document.\": \"从创建您的第一个 Grist 文档开始。\",\n        \"Get started by exploring templates, or creating your first Grist document.\": \"从浏览模板或创建您的第一个 Grist 文档开始。\",\n        \"Get started by inviting your team and creating your first Grist document.\": \"开始邀请您的团队并创建您的第一个 Grist 文档。\",\n        \"Help Center\": \"帮助中心\",\n        \"Import document\": \"导入文档\",\n        \"Sprouts Program\": \"新芽计划\",\n        \"This workspace is empty.\": \"这个工作区是空的。\",\n        \"Visit our {{link}} to learn more.\": \"访问我们的{{link}}以了解更多信息。\",\n        \"Welcome to Grist!\": \"欢迎来到 Grist！\",\n        \"Welcome to Grist, {{name}}!\": \"欢迎来到 Grist，{{name}}！\",\n        \"Welcome to {{orgName}}\": \"欢迎来到{{orgName}}\",\n        \"You have read-only access to this site. Currently there are no documents.\": \"您对该站点具有只读访问权限。 目前没有文件。\",\n        \"{{signUp}} to save your work. \": \"{{signUp}} 保存您的工作。 \",\n        \"Welcome to {{- orgName}}\": \"欢迎来到 {{- orgName}}\",\n        \"Welcome to Grist, {{- name}}!\": \"欢迎来到 Grist，{{- name}}！\",\n        \"Visit our {{link}} to learn more about Grist.\": \"访问我们的{{link}} ，了解有关 Grist 的更多信息。\",\n        \"Sign in\": \"登录\",\n        \"To use Grist, please either sign up or sign in.\": \"要使用 Grist，请注册或登录。\",\n        \"Only show documents\": \"仅显示文档\",\n        \"Learn more in our {{helpCenterLink}}.\": \"在我们的 {{helpCenterLink}} 中了解更多信息。\"\n    },\n    \"HomeLeftPane\": {\n        \"Delete\": \"删除\",\n        \"Trash\": \"回收站\",\n        \"Access Details\": \"访问详细信息\",\n        \"All documents\": \"所有文档\",\n        \"Create empty document\": \"创建空文档\",\n        \"Create workspace\": \"创建工作区\",\n        \"Delete {{workspace}} and all included documents?\": \"删除 {{workspace}} 和所有包含的文档？\",\n        \"Examples & Templates\": \"模板\",\n        \"Import document\": \"导入文档\",\n        \"Manage users\": \"管理用户\",\n        \"Rename\": \"重命名\",\n        \"Workspace will be moved to Trash.\": \"工作区将移至回收站。\",\n        \"Workspaces\": \"工作区\",\n        \"Tutorial\": \"教程\",\n        \"Grist Resources\": \"Grist 资源\",\n        \"Terms of service\": \"服务条款\",\n        \"context menu - {{- workspaceName }}\": \"上下文菜单{{- workspaceName }}\"\n    },\n    \"MakeCopyMenu\": {\n        \"Cancel\": \"取消\",\n        \"Enter document name\": \"输入文档名称\",\n        \"Name\": \"姓名\",\n        \"No destination workspace\": \"没有目标工作区\",\n        \"Organization\": \"组织\",\n        \"Sign up\": \"注册\",\n        \"Update\": \"更新\",\n        \"As template\": \"作为模板\",\n        \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"请注意，原始文档中没有更改。 这些更改将被覆盖。\",\n        \"However, it appears to be already identical.\": \"但是，它似乎已经完全相同。\",\n        \"Include the structure without any of the data.\": \"包括没有任何数据的结构。\",\n        \"It will be overwritten, losing any content not in this document.\": \"它将被覆盖，丢失不在本文档中的任何内容。\",\n        \"Original Has Modifications\": \"原始有修改\",\n        \"Original Looks Unrelated\": \"原始外观无关\",\n        \"Original Looks Identical\": \"原始外观相同\",\n        \"Overwrite\": \"覆盖\",\n        \"Replacing the original requires editing rights on the original document.\": \"替换原件需要对原始文档的编辑权限。\",\n        \"The original version of this document will be updated.\": \"本文档的原始版本将被更新。\",\n        \"To save your changes, please sign up, then reload this page.\": \"要保存您的更改，请注册，然后重新加载此页面。\",\n        \"Update Original\": \"更新原件\",\n        \"Workspace\": \"工作区\",\n        \"You do not have write access to the selected workspace\": \"您没有所选工作区的写入权限\",\n        \"You do not have write access to this site\": \"您没有此网站的写入权限\",\n        \"Download document structure only (no data, for template use)\": \"删除所有数据，但保留结构以用作模板\",\n        \"Download document without history (can significantly reduce file size)\": \"删除文件历史记录（可大幅减少文件大小）\",\n        \"Download document and history\": \"下载完整文档和历史记录\",\n        \"Download\": \"下载\",\n        \"Download document\": \"下载文档\",\n        \".tar (recommended)\": \".tar（推荐）\",\n        \".zip\": \".zip\",\n        \"Download an archive of all the attachments present in this document.\": \"下载本文档中所有附件的压缩包。\",\n        \"Download attachments\": \"下载附件\",\n        \"Download full document and history\": \"下载完整文档和历史记录\",\n        \"Format:\": \"格式：\",\n        \"Learn more\": \"了解更多\",\n        \"download attachments\": \"下载附件\",\n        \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"附件为外部附件，不包含在本次下载中。如果将文档上传到单独的 Grist 安装目录，您还需要单独下载 {{downloadLink}} 文件。 \"\n    },\n    \"NotifyUI\": {\n        \"Go to your free personal site\": \"转到您的免费个人网站\",\n        \"No notifications\": \"没有通知\",\n        \"Notifications\": \"通知\",\n        \"Renew\": \"更新\",\n        \"Ask for help\": \"请求帮忙\",\n        \"Cannot find personal site, sorry!\": \"找不到个人网站，抱歉！\",\n        \"Give feedback\": \"给予反馈\",\n        \"Report a problem\": \"报告一个问题\",\n        \"Upgrade Plan\": \"升级计划\",\n        \"Manage billing\": \"管理计费\"\n    },\n    \"OpenVideoTour\": {\n        \"Grist Video Tour\": \"Grist 视频导览\",\n        \"Video Tour\": \"视频导览\",\n        \"YouTube video player\": \"YouTube 视频播放器\"\n    },\n    \"RecordLayout\": {\n        \"Updating record layout.\": \"更新记录布局。\"\n    },\n    \"RightPanel\": {\n        \"DATA TABLE\": \"数据表\",\n        \"DATA TABLE NAME\": \"数据表名称\",\n        \"CHART TYPE\": \"图表类型\",\n        \"COLUMN TYPE\": \"列类型\",\n        \"CUSTOM\": \"风俗\",\n        \"Change widget\": \"更改小部件\",\n        \"columns_one\": \"列\",\n        \"columns_other\": \"列\",\n        \"Data\": \"数据\",\n        \"Detach\": \"分离\",\n        \"Edit data selection\": \"编辑数据选择\",\n        \"fields_one\": \"字段\",\n        \"fields_other\": \"字段\",\n        \"GROUPED BY\": \"分组依据\",\n        \"Row style\": \"行样式\",\n        \"SELECT BY\": \"选择依据\",\n        \"SELECTOR FOR\": \"选择器\",\n        \"SOURCE DATA\": \"源数据\",\n        \"Save\": \"保存\",\n        \"Select widget\": \"选择小部件\",\n        \"series_one\": \"系列\",\n        \"series_other\": \"系列\",\n        \"Sort & filter\": \"排序&过滤\",\n        \"TRANSFORM\": \"转换\",\n        \"Theme\": \"主题\",\n        \"WIDGET TITLE\": \"小部件标题\",\n        \"Widget\": \"小部件\",\n        \"You do not have edit access to this document\": \"您没有此文档的编辑权限\",\n        \"Add referenced columns\": \"添加引用列\",\n        \"Configuration\": \"配置\",\n        \"Required field\": \"必填字段\",\n        \"Redirection\": \"重定向\",\n        \"Redirect automatically after submission\": \"提交后自动重定向\",\n        \"Success text\": \"提交成功后的文本\",\n        \"Submit button label\": \"提交按钮标签\",\n        \"Reset form\": \"重置表单\",\n        \"Hidden field\": \"隐藏的字段\",\n        \"No field selected\": \"未选中字段\",\n        \"Layout\": \"布局\",\n        \"Enter text\": \"输入文本\",\n        \"Field rules\": \"字段规则\",\n        \"Field title\": \"字段标题\",\n        \"Submission\": \"提交\",\n        \"Enter redirect URL\": \"输入重定向 URL\",\n        \"Select a field in the form widget to configure.\": \"在表单小部件中选中要配置的字段。\",\n        \"Display button\": \"显示按钮\",\n        \"Default field value\": \"默认字段值\",\n        \"Table column name\": \"表格的列名\",\n        \"Submit another response\": \"提交其他响应\",\n        \"Submit\": \"提交\",\n        \"Thank you! Your response has been recorded.\": \"谢谢！您的回复已记录。\",\n        \"Chart options\": \"图表选项\"\n    },\n    \"RowContextMenu\": {\n        \"Insert row below\": \"在下方插入行\",\n        \"Copy anchor link\": \"复制锚点链接\",\n        \"Delete\": \"删除\",\n        \"Duplicate rows_one\": \"重复行\",\n        \"Duplicate rows_other\": \"重复行\",\n        \"Insert row\": \"插入行\",\n        \"Insert row above\": \"在上方插入行\",\n        \"View as card\": \"以卡片形式查看\",\n        \"Use as table headers\": \"作为表头使用\"\n    },\n    \"UserManagerModel\": {\n        \"In full\": \"在全\",\n        \"No Default Access\": \"没有默认访问权限\",\n        \"None\": \"没有任何\",\n        \"Viewer\": \"查看者\",\n        \"Editor\": \"编辑者\",\n        \"Owner\": \"所有者\",\n        \"View & edit\": \"查看和编辑\",\n        \"View only\": \"只读\"\n    },\n    \"ValidationPanel\": {\n        \"Rule {{length}}\": \"规则 {{length}}\",\n        \"Update formula (Shift+Enter)\": \"更新公式（Shift+Enter）\"\n    },\n    \"ViewConfigTab\": {\n        \"Compact\": \"袖珍的\",\n        \"Edit card layout\": \"编辑卡片布局\",\n        \"Form\": \"形式\",\n        \"Make On-Demand\": \"标记为“按需”\",\n        \"Plugin: \": \"插入： \",\n        \"Section: \": \"部分： \",\n        \"Advanced settings\": \"高级设置\",\n        \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"大表可能被标记为“按需”以避免将它们加载到数据引擎中。\",\n        \"Blocks\": \"积木\",\n        \"Unmark On-Demand\": \"取消标记为“按需”\",\n        \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"由于功能不足和易用性问题，按需表已被弃用。\",\n        \"⚠️ Deprecated Feature\": \"⚠️ 已弃用的功能\"\n    },\n    \"ViewLayoutMenu\": {\n        \"Delete record\": \"删除记录\",\n        \"Open configuration\": \"打开配置\",\n        \"Print widget\": \"打印小部件\",\n        \"Advanced sort & filter\": \"高级排序和过滤\",\n        \"Copy anchor link\": \"复制锚点链接\",\n        \"Data selection\": \"数据选择\",\n        \"Delete widget\": \"删除小部件\",\n        \"Download as CSV\": \"下载为 CSV\",\n        \"Download as XLSX\": \"下载为 XLSX\",\n        \"Edit card layout\": \"编辑卡片布局\",\n        \"Show raw data\": \"显示原始数据\",\n        \"Widget options\": \"小部件选项\",\n        \"Add to page\": \"添加至页面\",\n        \"Collapse widget\": \"折叠小部件\",\n        \"Create a form\": \"创建表单\",\n        \"Duplicate widget\": \"复制小部件\"\n    },\n    \"ViewSectionMenu\": {\n        \"Save\": \"保存\",\n        \"Update Sort&Filter settings\": \"更新排序和筛选设置\",\n        \"Revert\": \"恢复\",\n        \"(customized)\": \"（定制）\",\n        \"(empty)\": \"（空的）\",\n        \"(modified)\": \"（修改的）\",\n        \"Custom options\": \"自定义选项\",\n        \"FILTER\": \"筛选\",\n        \"SORT\": \"排序\",\n        \"Sort and filter\": \"排序和筛选\"\n    },\n    \"VisibleFieldsConfig\": {\n        \"Cannot drop items into Hidden Fields\": \"无法将项目放入隐藏字段\",\n        \"Clear\": \"清除\",\n        \"Hidden Fields cannot be reordered\": \"隐藏字段无法重新排序\",\n        \"Select all\": \"全选\",\n        \"Hide {{label}}\": \"隐藏 {{label}}\",\n        \"Hidden {{label}}\": \"隐藏 {{label}}\",\n        \"Show {{label}}\": \"显示 {{label}}\",\n        \"Visible {{label}}\": \"可见 {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"隐藏 {{label}}（批量模式）\",\n        \"Show {{label}} (batch mode)\": \"显示 {{label}}（批量模式）\"\n    },\n    \"WelcomeQuestions\": {\n        \"IT & Technology\": \"IT和技术\",\n        \"Sales\": \"销售量\",\n        \"What brings you to Grist? Please help us serve you better.\": \"是什么让你来到格里斯特？ 请帮助我们更好地为您服务。\",\n        \"Education\": \"教育\",\n        \"Finance & Accounting\": \"财务与会计\",\n        \"HR & Management\": \"人力资源和管理\",\n        \"Marketing\": \"营销\",\n        \"Media Production\": \"媒体制作\",\n        \"Other\": \"其他\",\n        \"Product Development\": \"产品开发\",\n        \"Research\": \"研究\",\n        \"Type here\": \"在此输入\",\n        \"Welcome to Grist!\": \"欢迎来到Grist！\"\n    },\n    \"breadcrumbs\": {\n        \"recovery mode\": \"恢复模式\",\n        \"snapshot\": \"快照\",\n        \"fiddle\": \"小提琴\",\n        \"override\": \"覆盖\",\n        \"unsaved\": \"未保存\",\n        \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"您可以进行编辑，但他们会创建一个新副本并且\\n不影响原文件。\",\n        \"You may make edits,\\nbut they will not affect the original document.\\nYou can propose them as suggestions.\": \"您可以进行编辑、\\n但不会作用于原始文档。\\n您可以将其作为建议提出。\",\n        \"editing\": \"编辑中\",\n        \"suggesting\": \"建议中\"\n    },\n    \"ConditionalStyle\": {\n        \"Row style\": \"行样式\",\n        \"Rule must return True or False\": \"规则必须返回 True 或 False\",\n        \"Add another rule\": \"添加另一条规则\",\n        \"Add conditional style\": \"添加条件样式\",\n        \"Error in style rule\": \"样式规则错误\",\n        \"Conditional Style\": \"条件样式\",\n        \"IF...\": \"如果...\",\n        \"Row Style\": \"行样式\"\n    },\n    \"CurrencyPicker\": {\n        \"Invalid currency\": \"货币无效\"\n    },\n    \"DiscussionEditor\": {\n        \"Only current page\": \"仅当前页面\",\n        \"Only my threads\": \"只有我的线程\",\n        \"Resolve\": \"解决\",\n        \"Save\": \"保存\",\n        \"Show resolved comments\": \"显示已解决的评论\",\n        \"Showing last {{nb}} comments\": \"显示最后 {{nb}} 条评论\",\n        \"Cancel\": \"取消\",\n        \"Comment\": \"评论\",\n        \"Edit\": \"编辑\",\n        \"Marked as resolved\": \"标记为已解决\",\n        \"Open\": \"打开\",\n        \"Remove\": \"删除\",\n        \"Reply\": \"回复\",\n        \"Reply to a comment\": \"回复评论\",\n        \"Started discussion\": \"开始讨论\",\n        \"Write a comment\": \"写一个评论\",\n        \"{{count}} comments_one\": \"{{count}} 评论\",\n        \"{{count}} comments_other\": \"{{count}} 评论\",\n        \"Copy link\": \"复制链接\",\n        \"updated\": \"已更新\",\n        \"Remove thread\": \"删除帖子\"\n    },\n    \"FieldBuilder\": {\n        \"Changing multiple column types\": \"更改多个列类型\",\n        \"Revert field settings for {{colId}} to common\": \"将 {{colId}} 的字段设置恢复为通用\",\n        \"Save field settings for {{colId}} as common\": \"将 {{colId}} 的字段设置保存为通用\",\n        \"Apply formula to data\": \"将公式应用于数据\",\n        \"CELL FORMAT\": \"单元格格式\",\n        \"DATA FROM TABLE\": \"表中数据\",\n        \"Mixed format\": \"混合格式\",\n        \"Mixed types\": \"混合类型\",\n        \"Use separate field settings for {{colId}}\": \"为 {{colId}} 使用单独的字段设置\",\n        \"Changing column type\": \"更改列类型\",\n        \"Common\": \"常规\",\n        \"Separate\": \"分离\",\n        \"Field in {{count}} views_one\": \"单视图中的字段\",\n        \"Field in {{count}} views_other\": \"{{count}}视图中的字段\"\n    },\n    \"FormulaEditor\": {\n        \"Column or field is required\": \"列或字段是必需的\",\n        \"Error in the cell\": \"单元格错误\",\n        \"Errors in all {{numErrors}} cells\": \"所有 {{numErrors}} 单元格中的错误\",\n        \"Errors in {{numErrors}} of {{numCells}} cells\": \"{{numCells}} 个单元格中的 {{numErrors}} 个错误\",\n        \"editingFormula is required\": \"需要编辑公式\",\n        \"Enter formula or {{button}}.\": \"输入公式或 {{button}} 。\",\n        \"Enter formula.\": \"输入公式。\",\n        \"Expand Editor\": \"展开编辑器\",\n        \"use AI Assistant\": \"使用AI助手\"\n    },\n    \"DocTour\": {\n        \"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.\": \"无法从该文档中的数据构建文档浏览。 确保有一个名为 GristDocTour 的表，其中包含 Title、Body、Placement 和 Location 列。\",\n        \"No valid document tour\": \"无有效证件游\"\n    },\n    \"DocumentSettings\": {\n        \"Currency:\": \"货币：\",\n        \"Document settings\": \"文档设置\",\n        \"Engine (experimental {{span}} change at own risk):\": \"引擎（实验性 {{span}} 更改风险自负）：\",\n        \"Local currency ({{currency}})\": \"当地货币（{{currency}}）\",\n        \"Locale:\": \"语言环境：\",\n        \"Save\": \"保存\",\n        \"Save and Reload\": \"保存并重新加载\",\n        \"This document's ID (for API use):\": \"本文档的 ID（供 API 使用）：\",\n        \"Time Zone:\": \"时区：\",\n        \"API\": \"应用程序接口\",\n        \"Document ID copied to clipboard\": \"文档 ID 已复制到剪贴板\",\n        \"Ok\": \"好的\",\n        \"Manage Webhooks\": \"管理 Webhooks\",\n        \"Webhooks\": \"Webhook\",\n        \"API console\": \"API 控制台\",\n        \"Find slow formulas\": \"查找慢公式\",\n        \"Data engine\": \"数据引擎\",\n        \"Document ID\": \"文档 ID\",\n        \"Formula timer\": \"公式计时器\",\n        \"Start timing\": \"开始计时\",\n        \"Stop timing...\": \"停止计时...\",\n        \"You can make changes to the document, then stop timing to see the results.\": \"您可以对文档进行更改，然后停止计时以查看结果。\",\n        \"Template\": \"模版\",\n        \"Regular document\": \"常规文档\",\n        \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"文档自动在{{fiddleModeDocUrl}}中打开。任何人都可以编辑，这将创建一个新的未保存的副本。\",\n        \"Python\": \"Python\",\n        \"Reload\": \"重新加载\",\n        \"Time zone\": \"时区\",\n        \"python2 (legacy)\": \"python2(过时）\",\n        \"python3 (recommended)\": \"python3(推荐)\",\n        \"Try API calls from the browser\": \"尝试从浏览器调用API\",\n        \"Cancel\": \"取消\",\n        \"Force reload the document while timing formulas, and show the result.\": \"在计时公式时强制重新加载文档，并显示结果。\",\n        \"Template mode\": \"模板模式\",\n        \"Confirm change\": \"确认更改\",\n        \"Edit\": \"编辑\",\n        \"Change nature of document\": \"更改文件性质\",\n        \"Normal document behavior. All users work on the same copy of the document.\": \"正常文档行为。所有用户都使用文档的同一副本。\",\n        \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"这将执行数据引擎的强制重新加载。如果数据引擎陷入无限循环、无限期地处理最新更改或已崩溃，这可能会有所帮助。除可能当前待处理的操作外，不会丢失任何数据。\",\n        \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"开始计时后，Grist 将测量每个公式所需的时间。这有助于诊断在文档首次打开或对变更做出响应时，哪些公式导致性能降低。\",\n        \"Change document type\": \"更改文档类型\",\n        \"Regular\": \"常规\",\n        \"Tutorial\": \"教程\",\n        \"Document automatically opens as a user-specific copy.\": \"文档自动作为用户特定副本打开。\",\n        \"Python version used\": \"Python版本\",\n        \"Reload data engine\": \"重新加载数据引擎\",\n        \"Reload data engine?\": \"重新加载数据引擎？\",\n        \"Only available to document editors\": \"仅适用于文档编辑者\",\n        \"Only available to document owners\": \"仅适用于文档所有者\",\n        \"Copy to clipboard\": \"复制到剪贴板\",\n        \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"REST API调用 {{docId}} 时使用的文档 ID。参见 {{apiURL}}\",\n        \"Time reload\": \"重新加载时间\",\n        \"API URL copied to clipboard\": \"复制 API URL 到剪贴板\",\n        \"For currency columns\": \"对于货币列\",\n        \"Hard reset of data engine\": \"强制重置数据引擎\",\n        \"ID for API use\": \"API 使用的 ID\",\n        \"For number and date formats\": \"对于数字和日期格式\",\n        \"Notify other services on doc changes\": \"在文档更改时通知其他服务\",\n        \"fiddle mode\": \"演示模式\",\n        \"API documentation.\": \"API 文档。\",\n        \"Base doc URL: {{docApiUrl}}\": \"基于文档 URL：{{docApiUrl}}\",\n        \"Coming soon\": \"即将推出\",\n        \"Currency\": \"货币\",\n        \"Default for DateTime columns\": \"DateTime 列的默认值\",\n        \"Locale\": \"区域设置\",\n        \"Manage webhooks\": \"管理 Webhooks\",\n        \"Timing is on\": \"正在计时\",\n        \"**Some existing attachments are still external**.\": \"**部分现有附件仍为外部附件**。\",\n        \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**部分现有附件仍为内部附件**（存储在 SQLite 文件中）。\",\n        \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"点击“开始传输”将这些文件传输到外部存储设备。\",\n        \"Being transfer\": \"传输中\",\n        \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"点击“开始传输”将这些文件传输到内部存储（存储在 SQLite 文件中）。\",\n        \"Attachment storage\": \"附件存储空间\",\n        \"Newly uploaded attachments will be placed in External storage.\": \"新上传的附件将放置在外部存储中。\",\n        \"Newly uploaded attachments will be placed in Internal storage.\": \"新上传的附件将放置在内部存储中。\",\n        \"No external stores available\": \"外部存储不可用\",\n        \"Preferred storage for this document\": \"本文档的偏好存储空间\",\n        \"Start transfer\": \"开始传输\",\n        \"External\": \"外部\",\n        \"Internal\": \"内部\",\n        \"Transfer in progress\": \"正在传输\",\n        \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**部分现有附件仍在[外部存储]({{externalLink}})**。\",\n        \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**部分现有附件仍在[内部]({{internalLink}})**（存储于SQLite文件中）。\",\n        \"[Learn more.]({{learnLink}})\": \"[了解更多。]({{learnLink}})\",\n        \"Upload\": \"上传\",\n        \"Upload missing attachments\": \"上传缺失的附件\",\n        \"Uploading...\": \"正在上传...\",\n        \"Default\": \"默认\",\n        \"Default, template, or tutorial\": \"默认、模板或教程\",\n        \"Document type\": \"文档类型\",\n        \"Formula times\": \"公式时间\",\n        \"Allow others to suggest changes\": \"允许他人提出修改建议\",\n        \"Enable suggestions\": \"启用建议\",\n        \"Suggestions\": \"建议\",\n        \"experiment\": \"试验\"\n    },\n    \"DocumentUsage\": {\n        \"Size of attachments\": \"附件大小\",\n        \"Contact the site owner to upgrade the plan to raise limits.\": \"联系站点所有者以升级计划以提高限制。\",\n        \"Data size\": \"数据大小\",\n        \"For higher limits, \": \"对于更高的限制， \",\n        \"Rows\": \"行数\",\n        \"Usage\": \"使用量\",\n        \"Usage statistics are only available to users with full access to the document data.\": \"使用情况统计信息仅供对文档数据具有完全访问权限的用户使用。\",\n        \"start your 30-day free trial of the Pro plan.\": \"开始 Pro 计划的 30 天免费试用。\"\n    },\n    \"Drafts\": {\n        \"Restore last edit\": \"恢复上次编辑\",\n        \"Undo discard\": \"撤消丢弃\"\n    },\n    \"DuplicateTable\": {\n        \"Copy all data in addition to the table structure.\": \"复制除表结构外的所有数据。\",\n        \"Name for new table\": \"新表的名称\",\n        \"Only the document default access rules will apply to the copy.\": \"只有文档默认访问规则将应用于副本。\",\n        \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"与其复制表，不如使用链接视图分割数据。{{link}}\"\n    },\n    \"ExampleInfo\": {\n        \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"查看我们的相关教程，了解如何建模业务数据、使用公式和管理复杂性。\",\n        \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"查看我们的相关教程，了解如何创建汇总表和图表，以及动态链接图表。\",\n        \"Investment Research\": \"投资研究\",\n        \"Lightweight CRM\": \"轻量级客户关系管理\",\n        \"Tutorial: Analyze & Visualize\": \"教程：分析和可视化\",\n        \"Tutorial: Create a CRM\": \"教程：创建 CRM\",\n        \"Tutorial: Manage Business Data\": \"教程：管理业务数据\",\n        \"Welcome to the Afterschool Program template\": \"欢迎使用课后活动计划模板\",\n        \"Welcome to the Investment Research template\": \"欢迎使用投资研究模板\",\n        \"Welcome to the Lightweight CRM template\": \"欢迎使用轻量级 CRM 模板\",\n        \"Afterschool Program\": \"课后活动\",\n        \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"查看我们的相关教程，了解如何链接数据和创建高效布局。\"\n    },\n    \"FieldConfig\": {\n        \"COLUMN LABEL AND ID\": \"列标签和 ID\",\n        \"Clear and make into formula\": \"清空并转为公式\",\n        \"Clear and reset\": \"清除并重置\",\n        \"Column options are limited in summary tables.\": \"列选项在汇总表中受到限制。\",\n        \"Convert column to data\": \"将列转换为数据\",\n        \"Convert to trigger formula\": \"转换为触发公式\",\n        \"Data columns_one\": \"数据栏\",\n        \"Data columns_other\": \"数据列\",\n        \"Empty columns_one\": \"空列\",\n        \"Empty columns_other\": \"空列\",\n        \"Enter formula\": \"输入公式\",\n        \"Formula columns_one\": \"公式栏\",\n        \"Formula columns_other\": \"公式列\",\n        \"Make into data column\": \"制作成数据栏\",\n        \"Mixed Behavior\": \"混合行为\",\n        \"Set formula\": \"设定公式\",\n        \"Set trigger formula\": \"设置触发公式\",\n        \"TRIGGER FORMULA\": \"触发公式\",\n        \"DESCRIPTION\": \"描述\",\n        \"COLUMN BEHAVIOR\": \"列行为\"\n    },\n    \"FieldMenus\": {\n        \"Revert to common settings\": \"恢复到常用设置\",\n        \"Save as common settings\": \"另存为常用设置\",\n        \"Using common settings\": \"使用通用设置\",\n        \"Use separate settings\": \"使用单独的设置\",\n        \"Using separate settings\": \"使用单独的设置\"\n    },\n    \"FilterConfig\": {\n        \"Add column\": \"添加列\",\n        \"Unpin filter - {{- columnName}} column (current: pinned)\": \"解除固定筛选 -{{- columnName}} 列（当前：已固定）\",\n        \"remove filter - {{- columnName}} column\": \"删除筛选 -{{- columnName}} 列\",\n        \"{{- columnName }} column filters\": \"{{- columnName }} 列过滤器\",\n        \"Pin filter - {{- columnName}} column (current: unpinned)\": \"解除固定筛选 -{{- columnName}} 列（当前：已固定）\"\n    },\n    \"FilterBar\": {\n        \"SearchColumns\": \"搜索列\",\n        \"Search Columns\": \"搜索列\"\n    },\n    \"GridOptions\": {\n        \"Grid Options\": \"网格选项\",\n        \"Horizontal gridlines\": \"水平网格线\",\n        \"Vertical gridlines\": \"垂直网格线\",\n        \"Zebra stripes\": \"斑马条纹\"\n    },\n    \"GridViewMenus\": {\n        \"Add column\": \"添加列\",\n        \"Add to sort\": \"添加到排序\",\n        \"Clear values\": \"清除值\",\n        \"Column Options\": \"列选项\",\n        \"Convert formula to data\": \"将公式转换为数据\",\n        \"Delete {{count}} columns_one\": \"删除列\",\n        \"Delete {{count}} columns_other\": \"删除 {{count}} 列\",\n        \"Filter Data\": \"筛选数据\",\n        \"Freeze {{count}} columns_one\": \"冻结此列\",\n        \"Freeze {{count}} columns_other\": \"冻结选中的 {{count}} 列\",\n        \"Freeze {{count}} more columns_one\": \"继续冻结一列\",\n        \"Freeze {{count}} more columns_other\": \"继续冻结 {{count}} 列\",\n        \"Hide {{count}} columns_one\": \"隐藏列\",\n        \"Hide {{count}} columns_other\": \"隐藏 {{count}} 列\",\n        \"Insert column to the {{to}}\": \"将列插入到 {{to}}\",\n        \"More sort options ...\": \"更多排序选项…\",\n        \"Rename column\": \"重命名列\",\n        \"Reset {{count}} columns_one\": \"重置列\",\n        \"Reset {{count}} columns_other\": \"重置 {{count}} 列\",\n        \"Reset {{count}} entire columns_one\": \"重置整列\",\n        \"Reset {{count}} entire columns_other\": \"重置 {{count}} 整列\",\n        \"Sort\": \"排序\",\n        \"Sorted (#{{count}})_one\": \"已排序（#{{count}}）\",\n        \"Sorted (#{{count}})_other\": \"已排序（#{{count}}）\",\n        \"Unfreeze {{count}} columns_one\": \"取消冻结此列\",\n        \"Unfreeze all columns\": \"取消冻结所有列\",\n        \"Unfreeze {{count}} columns_other\": \"取消冻结选中的 {{count}} 列\",\n        \"Show column {{- label}}\": \"显示列 {{- label}}\",\n        \"Insert column to the left\": \"在左侧插入列\",\n        \"Insert column to the right\": \"在右侧插入列\",\n        \"Detect Duplicates in...\": \"在……中检测重复\",\n        \"UUID\": \"UUID\",\n        \"Shortcuts\": \"捷径\",\n        \"Show hidden columns\": \"显示隐藏列\",\n        \"Created At\": \"创建时间\",\n        \"Authorship\": \"作者\",\n        \"Add formula column\": \"添加公式列\",\n        \"Last Updated By\": \"最后修改人\",\n        \"Hidden Columns\": \"隐藏列\",\n        \"Lookups\": \"查询\",\n        \"No reference columns.\": \"无引用列。\",\n        \"Apply on record changes\": \"记录更改时应用\",\n        \"Duplicate in {{- label}}\": \"在{{- label}}中的重复\",\n        \"Created By\": \"创建者\",\n        \"Last Updated At\": \"最后修改时间\",\n        \"Add column with type\": \"添加特定类型的列\",\n        \"Apply to new records\": \"应用到新记录\",\n        \"Search columns\": \"搜索列\",\n        \"Timestamp\": \"增加创建/修改时间列\",\n        \"no reference column\": \"无引用列\",\n        \"Adding UUID column\": \"添加 UUID 列\",\n        \"Adding duplicates column\": \"添加重复列\",\n        \"Date\": \"日期\",\n        \"Integer\": \"整数\",\n        \"Text\": \"文本\",\n        \"Attachment\": \"附件\",\n        \"Numeric\": \"数值\",\n        \"Detect duplicates in...\": \"在……中检测重复\",\n        \"Created at\": \"创建时间\",\n        \"Toggle\": \"开关\",\n        \"Reference\": \"引用（单选）\",\n        \"DateTime\": \"日期时间\",\n        \"Choice\": \"单选项\",\n        \"Choice List\": \"多选项\",\n        \"Reference List\": \"引用（多选）\",\n        \"Created by\": \"创建者\",\n        \"Last updated at\": \"最后修改时间\",\n        \"Last updated by\": \"最后修改人\",\n        \"Any\": \"任何\"\n    },\n    \"GristDoc\": {\n        \"Added new linked section to view {{viewName}}\": \"添加了新的链接部分以查看 {{viewName}}\",\n        \"Import from file\": \"从文件导入\",\n        \"Saved linked section {{title}} in view {{name}}\": \"已将链接部分 {{title}} 保存在视图 {{name}} 中\",\n        \"go to webhook settings\": \"去设置 webhook\",\n        \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"新更改暂时中止。Webhooks队列溢出。请检查Webhooks设置，删除无效Webhooks，并清理队列。\"\n    },\n    \"Importer\": {\n        \"Merge rows that match these fields:\": \"合并与这些字段匹配的行：\",\n        \"Select fields to match on\": \"选择要匹配的字段\",\n        \"Update existing records\": \"更新现有记录\",\n        \"{{count}} unmatched field_one\": \"{{count}} 个不匹配字段\",\n        \"{{count}} unmatched field in import_one\": \"导入过程中发现 {{count}} 个字段不匹配\",\n        \"{{count}} unmatched field_other\": \"{{count}} 个不匹配字段\",\n        \"{{count}} unmatched field in import_other\": \"导入过程中发现 {{count}} 个字段不匹配\",\n        \"Column mapping\": \"列映射\",\n        \"Grist column\": \"Grist 列\",\n        \"Revert\": \"恢复\",\n        \"Skip Import\": \"跳过导入\",\n        \"New Table\": \"新表\",\n        \"Skip\": \"跳过\",\n        \"Column Mapping\": \"列映射\",\n        \"Destination table\": \"目的表\",\n        \"Skip Table on Import\": \"导入时跳过表\",\n        \"Import from file\": \"从文件导入\",\n        \"Source column\": \"来源列\",\n        \"Cancel\": \"取消\",\n        \"Import\": \"导入\",\n        \"Import options\": \"导入选项\"\n    },\n    \"LeftPanelCommon\": {\n        \"Help Center\": \"帮助中心\",\n        \"Accessibility\": \"无障碍\"\n    },\n    \"OnBoardingPopups\": {\n        \"Finish\": \"结束\",\n        \"Next\": \"下一个\",\n        \"Previous\": \"上一个\"\n    },\n    \"PageWidgetPicker\": {\n        \"Add to page\": \"添加到页面\",\n        \"Building {{- label}} widget\": \"构建 {{- label}} 小部件\",\n        \"Group by\": \"通过...分组\",\n        \"Select data\": \"选择数据\",\n        \"Select widget\": \"选择小部件\",\n        \"New Table\": \"新表\",\n        \"SELECT BY\": \"选择依据\"\n    },\n    \"Pages\": {\n        \"Delete\": \"删除\",\n        \"Delete data and this page.\": \"删除数据和本页。\",\n        \"The following tables will no longer be visible_one\": \"下表将不再可见\",\n        \"The following tables will no longer be visible_other\": \"下表将不再可见\",\n        \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"保留数据并删除页面。表格将继续在 {{rawDataLink}} 中可用\",\n        \"raw data page\": \"原始数据页\",\n        \"Document pages\": \"文档页码\"\n    },\n    \"PermissionsWidget\": {\n        \"Allow all\": \"允许全部\",\n        \"Deny all\": \"全部拒绝\",\n        \"Read only\": \"只读\"\n    },\n    \"PluginScreen\": {\n        \"Import failed: \": \"导入失败： \"\n    },\n    \"RecordLayoutEditor\": {\n        \"Add field\": \"添加字段\",\n        \"Create new field\": \"创建新字段\",\n        \"Save layout\": \"保存布局\",\n        \"Cancel\": \"取消\",\n        \"Show field {{- label}}\": \"显示字段 {{- label}}\"\n    },\n    \"RefSelect\": {\n        \"Add column\": \"添加列\",\n        \"No columns to add\": \"没有要添加的列\"\n    },\n    \"SelectionSummary\": {\n        \"Copied to clipboard\": \"复制到剪贴板\"\n    },\n    \"ShareMenu\": {\n        \"Access Details\": \"访问详细信息\",\n        \"Back to current\": \"回到当前\",\n        \"Compare to {{termToUse}}\": \"与{{termToUse}}比较\",\n        \"Current Version\": \"当前版本\",\n        \"Download\": \"下载\",\n        \"Duplicate document\": \"复制文档\",\n        \"Edit without affecting the original\": \"在不影响原件的情况下编辑\",\n        \"Export CSV\": \"导出 CSV\",\n        \"Export XLSX\": \"导出 XLSX\",\n        \"Manage users\": \"管理用户\",\n        \"Original\": \"原来的\",\n        \"Replace {{termToUse}}...\": \"替换 {{termToUse}}…\",\n        \"Return to {{termToUse}}\": \"返回{{termToUse}}\",\n        \"Save copy\": \"保存副本\",\n        \"Save Document\": \"保存文档\",\n        \"Send to Google Drive\": \"发送到 Google 云端硬盘\",\n        \"Show in folder\": \"展现在文件夹中\",\n        \"Unsaved\": \"未保存\",\n        \"Work on a copy\": \"在副本上工作\",\n        \"Share\": \"分享\",\n        \"Download...\": \"下载...\",\n        \"Comma Separated Values (.csv)\": \"逗号分隔值 (.csv)\",\n        \"Tab Separated Values (.tsv)\": \"制表符分隔值 (.tsv)\",\n        \"Export as...\": \"导出为……\",\n        \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n        \"DOO Separated Values (.dsv)\": \"分隔符分隔值 (.dsv)\",\n        \"Exporting is only available from document pages. Please select a document page and try again.\": \"导出功能仅支持从文档页面导出。请选择一个文档页面，然后重试。\",\n        \"Download attachments...\": \"下载附件...\",\n        \"Download document...\": \"下载文档...\",\n        \"Suggest changes\": \"建议修改\",\n        \"current version\": \"当前版本\",\n        \"original\": \"原来的\"\n    },\n    \"SiteSwitcher\": {\n        \"Create new team site\": \"创建新的团队网站\",\n        \"Switch Sites\": \"切换站点\"\n    },\n    \"SortConfig\": {\n        \"Add column\": \"添加列\",\n        \"Empty values last\": \"最后为空值\",\n        \"Natural sort\": \"自然排序\",\n        \"Update data\": \"更新数据\",\n        \"Use choice position\": \"使用选择位置\",\n        \"Search Columns\": \"搜索列\",\n        \"Remove sort setting - {{- columnName }} column\": \"移除排序 - {{- columnName }} 列\",\n        \"Sort in ascending order (current: descending)\": \"按升序排序（当前：降序）\",\n        \"Sort in descending order (current: ascending)\": \"按降序排序（当前：升序）\",\n        \"Sort options - {{- columnName }} column\": \"排序选项 - {{- columnName }} 列\",\n        \"{{- columnName }} column\": \"{{- columnName }} 列\"\n    },\n    \"ThemeConfig\": {\n        \"Appearance \": \"外观 \",\n        \"Switch appearance automatically to match system\": \"自动切换外观以匹配系统\"\n    },\n    \"Tools\": {\n        \"Access Rules\": \"访问规则\",\n        \"Code view\": \"代码视图\",\n        \"Delete\": \"删除\",\n        \"Delete document tour?\": \"删除文档导览？\",\n        \"Document history\": \"文档历史\",\n        \"How-to Tutorial\": \"操作指南\",\n        \"Raw data\": \"原始数据\",\n        \"Return to viewing as yourself\": \"返回查看自己\",\n        \"TOOLS\": \"工具\",\n        \"Tour of this Document\": \"本文档导览\",\n        \"Validate Data\": \"验证数据\",\n        \"Settings\": \"设置\",\n        \"API console\": \"API 控制台\",\n        \"context menu - Access Rules\": \"上下文菜单 - 访问规则\",\n        \"Delete document tour\": \"删除文档导览\",\n        \"Preview the tutorial\": \"预览教程\",\n        \"Proposed changes\": \"拟议变更\",\n        \"Suggest changes\": \"建议修改\",\n        \"Suggestions\": \"建议\"\n    },\n    \"TopBar\": {\n        \"Manage team\": \"管理团队\"\n    },\n    \"TriggerFormulas\": {\n        \"Any field\": \"任何字段\",\n        \"Apply on changes to:\": \"字段更改时应用：\",\n        \"Apply on record changes\": \"记录更改时应用\",\n        \"Apply to new records\": \"应用到新记录\",\n        \"Cancel\": \"取消\",\n        \"Close\": \"关闭\",\n        \"Current field \": \"当前字段 \",\n        \"OK\": \"好的\"\n    },\n    \"TypeTransformation\": {\n        \"Apply\": \"应用\",\n        \"Cancel\": \"取消\",\n        \"Preview\": \"预览\",\n        \"Revise\": \"修订\",\n        \"Update formula (Shift+Enter)\": \"更新公式（Shift+Enter）\"\n    },\n    \"ViewAsBanner\": {\n        \"UnknownUser\": \"未知用户\",\n        \"View as Yourself\": \"以你自己的身份查看\",\n        \"You are viewing this document as\": \"您正在以以下方式查看此文档：\",\n        \"You're seeing what this user would see if given access\": \"您正在查看该用户获得访问权限后所能看到的内容\"\n    },\n    \"WidgetTitle\": {\n        \"Cancel\": \"取消\",\n        \"DATA TABLE NAME\": \"数据表名称\",\n        \"Override widget title\": \"覆盖小部件标题\",\n        \"Provide a table name\": \"提供表名\",\n        \"Save\": \"保存\",\n        \"WIDGET TITLE\": \"小部件标题\",\n        \"WIDGET DESCRIPTION\": \"小部件描述\"\n    },\n    \"duplicatePage\": {\n        \"Duplicate page {{pageName}}\": \"复制页面 {{pageName}}\",\n        \"Note that this does not copy data, but creates another view of the same data.\": \"请注意，这不会复制数据，而是创建相同数据的另一个视图。\"\n    },\n    \"menus\": {\n        \"* Workspaces are available on team plans. \": \"* 工作区在团队计划中可用。 \",\n        \"Select fields\": \"选择字段\",\n        \"Upgrade now\": \"现在升级\",\n        \"Reference List\": \"引用（多选）\",\n        \"Toggle\": \"切换\",\n        \"Date\": \"日期\",\n        \"DateTime\": \"日期时间\",\n        \"Choice\": \"单选\",\n        \"Choice List\": \"多选\",\n        \"Reference\": \"引用（单选）\",\n        \"Attachment\": \"附件\",\n        \"Any\": \"任何\",\n        \"Numeric\": \"数值\",\n        \"Text\": \"文本\",\n        \"Integer\": \"整数\",\n        \"Search columns\": \"搜索列\",\n        \"By Name\": \"按名称\",\n        \"By Date Modified\": \"按修改日期\",\n        \"Custom\": \"自定义\",\n        \"Light\": \"浅色\"\n    },\n    \"errorPages\": {\n        \"Add account\": \"新增帐户\",\n        \"Contact support\": \"联系支持\",\n        \"Go to main page\": \"转到主页\",\n        \"Sign in\": \"登录\",\n        \"Sign in again\": \"重新登录\",\n        \"Sign in to access this organization's documents.\": \"登录以访问该组织的文档。\",\n        \"Something went wrong\": \"出了些问题\",\n        \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"找不到请求的页面。{{separator}}请检查 URL 并重试。\",\n        \"There was an error: {{message}}\": \"出现错误：{{message}}\",\n        \"There was an unknown error.\": \"出现未知错误。\",\n        \"You are now signed out.\": \"您现在已注销。\",\n        \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"您以 {{email}} 身份登录。 您可以使用其他帐户登录，或向管理员申请访问权限。\",\n        \"You do not have access to this organization's documents.\": \"您无权访问该组织的文档。\",\n        \"Signed out{{suffix}}\": \"退出{{suffix}}\",\n        \"Page not found{{suffix}}\": \"找不到页面 {{suffix}}\",\n        \"Access denied{{suffix}}\": \"访问被拒绝{{suffix}}\",\n        \"Error{{suffix}}\": \"错误{{suffix}}\",\n        \"Account deleted{{suffix}}\": \"账户已删除{{suffix}}\",\n        \"Your account has been deleted.\": \"您的账户已被删除。\",\n        \"Sign up\": \"注册\",\n        \"An unknown error occurred.\": \"发生未知错误。\",\n        \"Form not found\": \"未找到表单\",\n        \"Build your own form\": \"创建自己的表单\",\n        \"Sign-in failed{{suffix}}\": \"登录失败{{suffix}}\",\n        \"Powered by\": \"驱动自\",\n        \"Failed to log in.{{separator}}Please try again or contact support.\": \"登录失败。{{separator}}请重试或联系支持人员。\",\n        \"Manage settings\": \"管理设置\",\n        \"Need Help?\": \"需要帮助？\",\n        \"There was an error\": \"发生错误\",\n        \"We could not unsubscribe you\": \"我们无法为您取消订阅\",\n        \"You are unsubscribed\": \"您已取消订阅\",\n        \"You can still unsubscribe from this document by updating your preferences in the document settings\": \"您仍可通过在文档设置中更新偏好来取消订阅此文档\",\n        \"You will no longer receive email notifications about {{changes}} in {{docName}} at {{email}}.\": \"您将不再收到有关 {{docName}} 中 {{changes}} 的电子邮件通知，邮箱地址为 {{email}}。\",\n        \"You will no longer receive email notifications about {{comments}} in {{docName}} at {{email}}.\": \"您将不再收到关于 {{docName}} 中 {{comments}} 的电子邮件通知，邮箱地址为 {{email}}。\",\n        \"changes\": \"变化\",\n        \"comments\": \"评论\",\n        \"this document\": \"本文档\",\n        \"your email\": \"您的电子邮件\",\n        \"Unsubscribed{{suffix}}\": \"未订阅{{suffix}}\"\n    },\n    \"modals\": {\n        \"Cancel\": \"取消\",\n        \"Ok\": \"好的\",\n        \"Save\": \"保存\",\n        \"Dismiss\": \"忽略\",\n        \"Are you sure you want to delete this record?\": \"确定要删除这条纪录吗？\",\n        \"Delete\": \"删除\",\n        \"Got it\": \"明白了\",\n        \"Don't ask again.\": \"不再询问。\",\n        \"Don't show again.\": \"不再显示。\",\n        \"Are you sure you want to delete these records?\": \"确定要删除这些纪录吗？\",\n        \"Don't show again\": \"不再显示\",\n        \"Don't show tips\": \"不要显示提示\",\n        \"Undo to restore\": \"撤消以恢复\",\n        \"TIP\": \"提示\",\n        \"Confirm\": \"确认\"\n    },\n    \"pages\": {\n        \"Duplicate page\": \"复制页面\",\n        \"Remove\": \"删除\",\n        \"Rename\": \"改名\",\n        \"You do not have edit access to this document\": \"您没有此文档的编辑权限\",\n        \"(default)\": \"（默认）\",\n        \"Expand {{maybeDefault}}\": \"展开{{maybeDefault}}\",\n        \"Set default: Collapse\": \"设置默认：折叠\",\n        \"Set default: Expand\": \"设置默认：扩展\",\n        \"context menu - {{- pageName }}\": \"上下文菜单{{- pageName }}\",\n        \"Collapse {{maybeDefault}}\": \"崩溃{{maybeDefault}}\"\n    },\n    \"search\": {\n        \"Find Next \": \"找下一个 \",\n        \"Find Previous \": \"查找上一个 \",\n        \"No results\": \"没有结果\",\n        \"Search in document\": \"在文档中搜索\",\n        \"Search\": \"搜索\",\n        \"Close search bar\": \"关闭搜索栏\"\n    },\n    \"sendToDrive\": {\n        \"Sending file to Google Drive\": \"发送文件到 Google 云端硬盘\"\n    },\n    \"NTextBox\": {\n        \"false\": \"错误的\",\n        \"true\": \"真的\",\n        \"Field Format\": \"字段格式\",\n        \"Multi line\": \"多行\",\n        \"Single line\": \"单行\",\n        \"Lines\": \"行\"\n    },\n    \"ACLUsers\": {\n        \"Example Users\": \"示例用户\",\n        \"Users from table\": \"表中的用户\",\n        \"View as\": \"查看为\",\n        \"Other users from table\": \"表格中的其他用户\",\n        \"Shared users\": \"共享用户\"\n    },\n    \"TypeTransform\": {\n        \"Apply\": \"应用\",\n        \"Cancel\": \"取消\",\n        \"Preview\": \"预览\",\n        \"Revise\": \"修订\",\n        \"Update formula (Shift+Enter)\": \"更新公式（Shift+Enter）\"\n    },\n    \"ColumnInfo\": {\n        \"COLUMN DESCRIPTION\": \"列说明\",\n        \"COLUMN ID: \": \"列号： \",\n        \"COLUMN LABEL\": \"列标签\",\n        \"Cancel\": \"取消\",\n        \"Save\": \"保存\"\n    },\n    \"CellStyle\": {\n        \"CELL STYLE\": \"单元格样式\",\n        \"Cell style\": \"单元样式\",\n        \"Default cell style\": \"默认单元格样式\",\n        \"Mixed style\": \"混合风格\",\n        \"Open row styles\": \"打开行样式\",\n        \"HEADER STYLE\": \"标题样式\",\n        \"Header Style\": \"标题样式\",\n        \"Default header style\": \"默认标题样式\"\n    },\n    \"ChoiceTextBox\": {\n        \"CHOICES\": \"选择\"\n    },\n    \"ColumnEditor\": {\n        \"COLUMN DESCRIPTION\": \"列说明\",\n        \"COLUMN LABEL\": \"列标签\"\n    },\n    \"EditorTooltip\": {\n        \"Convert column to formula\": \"将列转换为公式\"\n    },\n    \"FieldEditor\": {\n        \"It should be impossible to save a plain data value into a formula column\": \"不可能将纯数据值保存到公式列中\",\n        \"Unable to finish saving edited cell\": \"无法完成保存编辑的单元格\"\n    },\n    \"HyperLinkEditor\": {\n        \"[link label] url\": \"[链接标签] URL\"\n    },\n    \"NumericTextBox\": {\n        \"Currency\": \"货币\",\n        \"Decimals\": \"小数点\",\n        \"Default currency ({{defaultCurrency}})\": \"默认货币 ({{defaultCurrency}})\",\n        \"Number Format\": \"数字格式\",\n        \"Field Format\": \"字段格式\",\n        \"Text\": \"文本\",\n        \"Spinner\": \"下拉列表\",\n        \"max\": \"最大\",\n        \"min\": \"最小\"\n    },\n    \"Reference\": {\n        \"CELL FORMAT\": \"单元格格式\",\n        \"Row ID\": \"行号\",\n        \"SHOW COLUMN\": \"显示专栏\"\n    },\n    \"LanguageMenu\": {\n        \"Language\": \"语言\"\n    },\n    \"GristTooltips\": {\n        \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"当满足公式条件时，将条件格式应用于此列中的单元格。\",\n        \"Apply conditional formatting to rows based on formulas.\": \"根据公式将条件格式应用于行。\",\n        \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"参考列中的单元格始终标识该表中的 {{entire}} 记录，但您可以选择显示该记录中的哪一列。\",\n        \"Click on “Open row styles” to apply conditional formatting to rows.\": \"单击“打开行样式”以将条件格式应用于行。\",\n        \"Click the Add new button to create new documents or workspaces, or import data.\": \"点击“添加”按钮创建文档或工作区，或导入数据。\",\n        \"Learn more.\": \"了解更多。\",\n        \"Link your new widget to an existing widget on this page.\": \"将您的新小部件链接到此页面上的现有小部件。\",\n        \"Linking Widgets\": \"链接小部件\",\n        \"Nested Filtering\": \"嵌套过滤\",\n        \"Only those rows will appear which match all of the filters.\": \"只有那些匹配所有过滤器的行才会出现。\",\n        \"Pinned filters are displayed as buttons above the widget.\": \"固定过滤器显示为小部件上方的按钮。\",\n        \"Pinning Filters\": \"固定过滤器\",\n        \"Raw Data page\": \"原始数据页面\",\n        \"Rearrange the fields in your card by dragging and resizing cells.\": \"通过拖动和调整单元格大小来重新排列卡片中的字段。\",\n        \"Reference Columns\": \"参考列\",\n        \"Select the table containing the data to show.\": \"选择包含要显示的数据的表。\",\n        \"Select the table to link to.\": \"选择要链接到的表。\",\n        \"Selecting Data\": \"选择数据\",\n        \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"原始数据页面列出了文档中的所有数据表，包括汇总表和未包含在页面布局中的表。\",\n        \"The total size of all data in this document, excluding attachments.\": \"本文档中所有数据的总大小，不包括附件。\",\n        \"They allow for one record to point (or refer) to another.\": \"它们允许一条记录指向（或引用）另一条记录。\",\n        \"This is the secret to Grist's dynamic and productive layouts.\": \"这就是 Grist 的动态和高效布局的秘诀。\",\n        \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"尝试对副本进行更改，然后决定是否用您的编辑替换原始文件。\",\n        \"Unpin to hide the the button while keeping the filter.\": \"取消固定以隐藏按钮，同时保留过滤器。\",\n        \"Updates every 5 minutes.\": \"每 5 分钟更新一次。\",\n        \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"使用 \\\\u{1D6BA} 图标创建总计或小计的汇总（或数据透视表）表。\",\n        \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"用于存储新记录的时间戳或作者、数据清理等。\",\n        \"You can filter by more than one column.\": \"您可以按多个列进行过滤。\",\n        \"entire\": \"全部的\",\n        \"relational\": \"相关的\",\n        \"Access Rules\": \"访问规则\",\n        \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"访问规则使您能够创建细微的规则来确定谁可以查看或编辑文档的哪些部分。\",\n        \"Add new\": \"添新\",\n        \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"使用 𝚺 图标为总计或小计创建汇总（或数据透视表）表。\",\n        \"Reference columns are the key to {{relational}} data in Grist.\": \"引用列是 Grist 中 {{relational}} 数据的关键。\",\n        \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"单击每个单元格中的 {{EyeHideIcon}} 可隐藏此视图中的字段而不将其删除。\",\n        \"Editing Card Layout\": \"编辑卡片布局\",\n        \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"在某些情况下触发的公式，并将计算值存储为数据。\",\n        \"Anchor Links\": \"锚点链接\",\n        \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"要创建将用户带到特定单元格的锚点链接，请单击行并按 {{shortcut}} 。\",\n        \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"您可以选择我们的一个预制小部件或通过提供其完整的URL嵌入您自己的。\",\n        \"Custom Widgets\": \"自定义小部件\",\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID 是随机生成的字符串，可用于唯一标识符和链接密钥。\",\n        \"To configure your calendar, select columns for start\": {\n            \"end dates and event titles. Note each column's type.\": \"要配置日历，请选择开始/结束日期和事件标题的列。注意每列的类型。\"\n        },\n        \"Calendar\": \"日历\",\n        \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"公式支持许多 Excel 函数和完整的 Python 语法，还包括一个有用的人工智能助手。\",\n        \"Lookups return data from related tables.\": \"查找会返回相关表中的数据。\",\n        \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"您可以从下拉菜单中选择可用的小部件，也可以通过提供完整的 URL 嵌入自己的小部件。\",\n        \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"找不到正确的列？点击 \\\"更改小部件\\\"，选择包含事件数据的表格。\",\n        \"Use reference columns to relate data in different tables.\": \"使用参考列来关联不同表格中的数据。\",\n        \"Forms are here!\": \"表单来了！\",\n        \"Learn more\": \"了解更多\",\n        \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"直接在 Grist 中构建简单的表单，并使用我们全新的小部件一键共享。{{learnMoreButton}}\",\n        \"Example: {{example}}\": \"示例： {{example}}\",\n        \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"若要允许多个值，请将“引用”列的类型更改为“引用列表”。\",\n        \"These rules are applied after all column rules have been processed, if applicable.\": \"这些规则在处理完所有列规则后应用（如果适用）。\",\n        \"Creates a reverse column in target table that can be edited from either end.\": \"在目标表中创建一个反向列，该列可以从任意一端进行编辑。\",\n        \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"当双向引用的一端配置为单个引用时，就会出现此限制。\",\n        \"Filter displayed dropdown values with a condition.\": \"过滤器显示带有条件的下拉值。\",\n        \"Community widgets are created and maintained by Grist community members.\": \"社区小部件由Grist社区成员创建和维护。\",\n        \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"公式或触发器公式列当前不支持双向引用\",\n        \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"当双向引用中的一列具有“引用”类型时，会出现此限制。\",\n        \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"若要允许多个值，请将引用列的类型更改为“引用列表”。\",\n        \"The preview below this header shows how the selected user will see this document\": \"标题下方的预览可以展示所选用户将如何查看该文档\",\n        \"[Learn more.]({{link}})\": \"[了解更多。]({{link}})\",\n        \"Summary tables can only contain formula columns.\": \"摘要表只能包含公式列。\",\n        \"Manage users and resources in a Grist installation.\": \"管理 Grist 安装中的用户和资源。\",\n        \"The new Grist Assistant is here!\": \"新版 Grist 助手在这！\",\n        \"Formulas support many Excel functions and full Python syntax.\": \"公式支持众多 Excel 函数和完整的 Python 语法。\",\n        \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"在目标表中创建一个新的引用列表列，该列与目标列都可编辑且同步。\",\n        \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"内部存储表示所有附件都存储在文档 SQLite 文件中，而外部存储表示所有附件都存储在同一个外部存储中。\",\n        \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"在 Grist 全新人工智能助手的帮助下，了解、修改并处理您的数据和公式！\",\n        \"This form is created by a Grist user, and is not endorsed by Grist Labs, Inc. or any party providing this service. For your security, do not submit passwords through this form, and be careful when clicking embedded links. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"本表单由 Grist 用户创建，未经 Grist Labs 公司或提供此服务的任何一方认可。为了您的安全，请勿通过此表单提交密码，点击嵌入链接时请务必小心。请向 [{{mail}}](mailto:{{mail}}) 报告恶意表单。\",\n        \"Set the maximum number of lines for multi-line text.\": \"设置多行文本的最大行数。\",\n        \"Comments are here!\": \"在此发表评论！\",\n        \"You can add comments to cells, reply to comment threads, and @-mention collaborators.\": \"您可以在单元格中添加评论，回复评论主题，并@-提及合作者。\",\n        \"When checked, this field’s default value can be prefilled from the URL using query parameters.\": \"选中后，该字段的默认值可使用查询参数从 URL 中预填。\",\n        \"With suggestions, users make changes in a personal copy without modifying the original document, then submit these suggestions to be reviewed by the document owner prior to integration.\": \"利用建议功能，用户可以在不修改原始文档的情况下对个人副本进行修改，然后提交这些建议，由文档所有者在整合前进行审核。\",\n        \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"这样，您就可以添加外部存储中缺少的附件，例如导入的文档中的附件。只能上传从 Grist 下载的 .tar 附件档案。\"\n    },\n    \"DescriptionConfig\": {\n        \"DESCRIPTION\": \"描述\",\n        \"Set description\": \"设置说明\"\n    },\n    \"PagePanels\": {\n        \"Close Creator Panel\": \"关闭创作者面板\",\n        \"Open creator panel\": \"打开创作者面板\",\n        \"Creator panel (right panel)\": \"创作者面板（右侧面板）\",\n        \"Document header\": \"文档页眉\",\n        \"Main content\": \"主要内容\",\n        \"Main navigation and document settings (left panel)\": \"主导航和文档设置（左侧面板）\",\n        \"Close navigation panel (left panel)\": \"关闭导航面板（左侧面板）\",\n        \"Open navigation panel (left panel)\": \"打开导航面板（左侧面板）\"\n    },\n    \"ColumnTitle\": {\n        \"Column label\": \"列标签\",\n        \"Provide a column label\": \"提供列标签\",\n        \"Close\": \"关闭\",\n        \"COLUMN ID: \": \"列号： \",\n        \"Column description\": \"列说明\",\n        \"Add description\": \"添加说明\",\n        \"Cancel\": \"取消\",\n        \"Column ID copied to clipboard\": \"列ID已复制到剪贴板\",\n        \"Save\": \"保存\"\n    },\n    \"FieldContextMenu\": {\n        \"Copy\": \"拷贝\",\n        \"Copy anchor link\": \"复制锚点链接\",\n        \"Cut\": \"剪切\",\n        \"Hide field\": \"隐藏字段\",\n        \"Paste\": \"粘贴\",\n        \"Clear field\": \"清除字段\",\n        \"Comment\": \"评论\"\n    },\n    \"FormulaAssistant\": {\n        \"Capabilities\": \"技术支持\",\n        \"Formula Cheat Sheet\": \"公式备忘单\",\n        \"Regenerate\": \"重新生成\",\n        \"Tips\": \"建 议\",\n        \"Apply\": \"应用\",\n        \"Clear conversation\": \"清晰的对话\",\n        \"Hi, I'm the Grist Formula AI Assistant.\": \"你好，我是 Grist 公式的人工智能助手。\",\n        \"Learn more\": \"了解更多\",\n        \"Press Enter to apply suggested formula.\": \"按 Enter 键应用建议的公式。\",\n        \"Code view\": \"代码视图\",\n        \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"我只能帮忙写公式。我无法构建表、列和视图，也无法编写访问规则。\",\n        \"Ask the bot.\": \"咨询机器人。\",\n        \"Grist's AI Formula Assistance. \": \"Grist 的人工智能公式协助。 \",\n        \"Preview\": \"预览\",\n        \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"请参阅我们的 {{helpFunction}} 和 {{formulaCheat}}，或访问我们的 {{community}} 获取更多帮助。\",\n        \"AI Assistant\": \"AI助手\",\n        \"There are some things you should know when working with me:\": \"与我合作时，您应该了解一些事情：\",\n        \"What do you need help with?\": \"你有什么需要帮助的？\",\n        \"Cancel\": \"取消\",\n        \"Need help? Our AI assistant can help.\": \"需要帮忙？我们的人工智能助手可以提供帮助。\",\n        \"New Chat\": \"新聊天\",\n        \"Save\": \"保存\",\n        \"Community\": \"社区\",\n        \"Data\": \"数据\",\n        \"Formula Help. \": \"公式帮助。 \",\n        \"Function List\": \"函数列表\",\n        \"Grist's AI Assistance\": \"Grist 人工智能助手\",\n        \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"注册一个免费的Grist帐户，开始使用Formula AI助手。\",\n        \"Sign Up for Free\": \"免费注册\",\n        \"Formula AI Assistant is only available for logged in users.\": \"公式 AI 助手仅适用于登录用户。\",\n        \"For higher limits, contact the site owner.\": \"如需更高限额，请联系网站所有者。\",\n        \"upgrade to the Pro Team plan\": \"升级到专业团队计划\",\n        \"You have used all available credits.\": \"您已使用所有可用积分。\",\n        \"upgrade your plan\": \"升级您的计划\",\n        \"You have {{numCredits}} remaining credits.\": \"您还有{{numCredits}} 剩余积分。\",\n        \"For higher limits, {{upgradeNudge}}.\": \"如需更高限额，{{upgradeNudge}} 。\",\n        \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"有关公式的更多帮助，请查看我们的{{functionList}} 和{{formulaCheatSheet}} ，或访问我们的{{community}} 获取更多帮助。\",\n        \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"当您与我交谈时，您的问题和文档结构（在此{{codeView}} 可见）会被发送到 OpenAI。{{learnMore}}.\",\n        \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"像对人类一样跟我说话。无需指定表和列的名称。例如，您可以询问 \\\"请计算发票总金额\\\"。\"\n    },\n    \"WebhookPage\": {\n        \"Clear queue\": \"清除队列\",\n        \"Webhook settings\": \"Webhook设置\",\n        \"URL\": \"URL\",\n        \"Status\": \"状态\",\n        \"Enabled\": \"启用\",\n        \"Name\": \"名称\",\n        \"Event Types\": \"事件类型\",\n        \"Removed webhook.\": \"已删除的 webhook。\",\n        \"Table\": \"表\",\n        \"Webhook Id\": \"Webhook Id\",\n        \"Filter for changes in these columns (semicolon-separated ids)\": \"筛选这些列中的更改（以分号分隔的id）\",\n        \"Cleared webhook queue.\": \"已清除webhook队列。\",\n        \"Columns to check when update (separated by ;)\": \"更新时要检查的列（以 ; 分隔）\",\n        \"Sorry, not all fields can be edited.\": \"抱歉，并非所有字段都可编辑。\",\n        \"Header Authorization\": \"Header Authorization\",\n        \"Memo\": \"备忘录\",\n        \"Ready Column\": \"就绪列\",\n        \"Webhooks Unavailable In Unsaved Document Copies\": \"Webhooks在未保存的文档副本中不可用\"\n    },\n    \"WelcomeSitePicker\": {\n        \"Welcome back\": \"欢迎回来\",\n        \"You can always switch sites using the account menu.\": \"您始终可以使用帐户菜单切换站点。\",\n        \"You have access to the following Grist sites.\": \"您可以访问以下 Grist 网站。\"\n    },\n    \"UserManager\": {\n        \"Invite multiple\": \"邀请多人\",\n        \"Invite people to {{resourceType}}\": \"邀请人们加入 {{resourceType}}\",\n        \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"公共访问权限继承自 {{parent}}。要删除，请将“继承访问权限”选项设置为“无”。\",\n        \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"用户从 {{parent})} 继承权限。要删除，请将“继承访问权限”选项设置为“无”。\",\n        \"member\": \"成员\",\n        \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"只允许对单个文档或工作区配置无默认访问权限，而不能对整个团队网站配置。\",\n        \"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}}具有足够访问权限的其他人的帮助，您将无法将其恢复。\",\n        \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\": \"由于手动设置了对内部资源的访问权限，因此用户具有对{{resource}}的查看权限。如果在此处删除，此用户将无法访问内部资源。\",\n        \"Close\": \"关闭\",\n        \"Collaborator\": \"合作者\",\n        \"Create a team to share with more people\": \"创建团队与更多人分享\",\n        \"On\": \"打开\",\n        \"Off\": \"关闭\",\n        \"Open Access Rules\": \"开放访问规则\",\n        \"Outside collaborator\": \"外部合作者\",\n        \"Your role for this team site\": \"您在这个团队站点中的角色\",\n        \"Your role for this {{resourceType}}\": \"您在此{{resourceType}}中的角色\",\n        \"free collaborator\": \"自由合作者\",\n        \"guest\": \"游客\",\n        \"Allow anyone with the link to open.\": \"允许任何知道该链接的人打开。\",\n        \"Anyone with link \": \"任何有链接的人 \",\n        \"Cancel\": \"取消\",\n        \"Confirm\": \"确认\",\n        \"Copy link\": \"复制链接\",\n        \"Link copied to clipboard\": \"链接已复制到剪贴板\",\n        \"Manage members of team site\": \"管理团队网站的成员\",\n        \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"没有默认访问权限允许授予对单个文档或工作区的访问权限，而不是对整个团队网站的访问权限。\",\n        \"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}}.\": \"一旦您删除了自己的访问权限，如果没有对 {{name}} 有足够访问权限的其他人的帮助，您将无法取回该访问权限。\",\n        \"User may not modify their own access.\": \"用户不得修改自己的访问权限。\",\n        \"You are about to remove your own access to this {{resourceType}}\": \"您即将删除自己对此{{resourceType}}的访问权限\",\n        \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"用户从 {{parent}} 继承权限。要删除，请将“继承访问权限”选项设置为“无”。\",\n        \"Add {{member}} to your team\": \"将 {{member}} 添加到您的团队\",\n        \"Grist support\": \"Grist 支持\",\n        \"Guest\": \"游客\",\n        \"Public access\": \"公开访问\",\n        \"Public access: \": \"公开访问： \",\n        \"Remove my access\": \"删除我的访问权限\",\n        \"Save & \": \"保存 ＆ \",\n        \"Team member\": \"团队成员\",\n        \"team site\": \"团队网站\",\n        \"{{collaborator}} limit exceeded\": \"已超出{{collaborator}}限制\",\n        \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{limitAt}} of {{limitTop}} {{collaborator}}s\",\n        \"Inherit access: \": \"继承访问权限： \",\n        \"Access overview\": \"访问概览\"\n    },\n    \"SupportGristPage\": {\n        \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"该实例被选择退出遥测。只有网站管理员有权更改此设置。\",\n        \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"该实例被选择用于遥测。只有网站管理员有权更改此设置。\",\n        \"GitHub\": \"GitHub\",\n        \"GitHub Sponsors page\": \"GitHub赞助商页面\",\n        \"Help Center\": \"帮助中心\",\n        \"Opt in to Telemetry\": \"选择遥测\",\n        \"Opt out of Telemetry\": \"选择退出遥测\",\n        \"Support Grist\": \"支持 Grist\",\n        \"Telemetry\": \"遥测\",\n        \"Sponsor Grist Labs on GitHub\": \"在 GitHub 上赞助 Grist Labs\",\n        \"Home\": \"主页\",\n        \"Manage Sponsorship\": \"管理赞助\",\n        \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"我们只收集使用统计数据，如我们的{{link}}中所述，从不搜集文档内容。\",\n        \"You can opt out of telemetry at any time from this page.\": \"您可以随时从此页面选择退出遥测。\",\n        \"You have opted in to telemetry. Thank you!\": \"您已选择使用遥测。谢谢你，谢谢！\",\n        \"You have opted out of telemetry.\": \"您已选择退出遥测。\",\n        \"Sponsor\": \"赞助\",\n        \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Grist 软件由 Grist Labs 开发，提供免费和付费托管计划。我们还在标准免费开放源码软件许可证（Apache 2.0）下提供 Grist 代码，网址是{{link}} 。\",\n        \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"支持 Grist，选择加入遥测技术，这能帮助我们了解产品的使用情况，从而确定未来改进的优先次序。\",\n        \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"我们是一个小而坚定的团队。您的支持对我们非常重要。这也向其他人表明，这个产品背后有一个坚固的社区。\",\n        \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"您可以通过赞助我们的{{link}} 来支持 Grist 的开源开发。\"\n    },\n    \"SupportGristNudge\": {\n        \"Help Center\": \"帮助中心\",\n        \"Support Grist page\": \"支持Grist页面\",\n        \"Close\": \"关闭\",\n        \"Contribute\": \"投稿\",\n        \"Support Grist\": \"支持 Grist\",\n        \"Opt in to Telemetry\": \"选择遥测\",\n        \"Opted In\": \"选择加入\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"感谢您的信任和支持！您可以随时通过用户菜单中的 {{link}} 选择退出。\",\n        \"Admin Panel\": \"管理员面板\"\n    },\n    \"DescriptionTextArea\": {\n        \"DESCRIPTION\": \"描述\"\n    },\n    \"buildViewSectionDom\": {\n        \"No data\": \"没有数据\",\n        \"No row selected in {{title}}\": \"{{title}}中未选择行\",\n        \"Not all data is shown\": \"未显示所有数据\"\n    },\n    \"Clipboard\": {\n        \"Got it\": \"明白了\",\n        \"Unavailable Command\": \"命令不可用\",\n        \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"{{action}} 菜单命令在此浏览器中不可用。您仍然可以使用键盘快捷键{{shortcut}} 来执行{{action}} 。\"\n    },\n    \"GridView\": {\n        \"Click to insert\": \"点击插入\"\n    },\n    \"SearchModel\": {\n        \"Search all pages\": \"搜索所有页面\",\n        \"Search all tables\": \"搜索所有表\"\n    },\n    \"searchDropdown\": {\n        \"Search\": \"搜索\",\n        \"Showing {{displayedCount}} of {{totalCount}} items. Search for more.\": \"显示{{displayedCount}} 的{{totalCount}} 项目。搜索更多内容。\"\n    },\n    \"FloatingEditor\": {\n        \"Collapse Editor\": \"折叠编辑器\"\n    },\n    \"FloatingPopup\": {\n        \"Maximize\": \"最大化\",\n        \"Minimize\": \"最小化\"\n    },\n    \"CardContextMenu\": {\n        \"Insert card above\": \"在上方插入卡片\",\n        \"Duplicate card\": \"复制卡片\",\n        \"Insert card below\": \"在下方插入卡片\",\n        \"Delete card\": \"删除卡片\",\n        \"Copy anchor link\": \"复制锚点链接\",\n        \"Insert card\": \"插入卡片\"\n    },\n    \"FormView\": {\n        \"Anyone with the link below can see the empty form and submit a response.\": \"任何访问下方链接的人都可以看到空白表单并提交回复。\",\n        \"Copy link\": \"复制链接\",\n        \"Preview\": \"预览\",\n        \"Reset\": \"重置\",\n        \"Reset form\": \"重置表单\",\n        \"Share\": \"分享\",\n        \"Copy code\": \"复制代码\",\n        \"Link copied to clipboard\": \"链接已复制到剪贴板\",\n        \"Save your document to publish this form.\": \"保存此文档以便发布表单。\",\n        \"Are you sure you want to reset your form?\": \"您确定要重置表单吗？\",\n        \"Code copied to clipboard\": \"代码已复制到剪贴板\",\n        \"Share this form\": \"分享此表单\",\n        \"Unpublish your form?\": \"要取消发布表单吗？\",\n        \"Unpublish\": \"取消发布\",\n        \"Publish\": \"发布\",\n        \"Publish your form?\": \"要发布表单吗？\",\n        \"Embed this form\": \"嵌入此表单\",\n        \"View\": \"视图\",\n        \"# **Form Title**\": \"# **表单标题**\",\n        \"Your form description goes here.\": \"您可将表格描述置于此处。\",\n        \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"发布表单会生成一个共享链接。任何人通过该链接都可以看到空表单并提交回复。\",\n        \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"未发布表单将禁用共享链接，这样通过该链接访问表单的用户就会看到一个错误提示。\",\n        \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"用户仅限于提交条目（表中的记录）和读取指定字段（如参考和选择列）中的预设值。\",\n        \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"您的表单已经发布。访问该表单的用户都可以实时看到每项更改。如果想在草稿中进行更改，请取消发布表单。\"\n    },\n    \"FormConfig\": {\n        \"Field Rules\": \"字段规则\",\n        \"Field Format\": \"字段格式\",\n        \"Field rules\": \"字段规则\",\n        \"Required field\": \"必填字段\",\n        \"Ascending\": \"升序\",\n        \"Options Alignment\": \"选项对齐\",\n        \"Select\": \"选择\",\n        \"Vertical\": \"垂直\",\n        \"Horizontal\": \"水平的\",\n        \"Descending\": \"降序\",\n        \"Options Sort Order\": \"选项排序顺序\",\n        \"Default\": \"默认的\",\n        \"Radio\": \"单选\",\n        \"Accept value from URL\": \"接受来自 URL 的值\",\n        \"Hidden field\": \"隐藏的字段\",\n        \"URL parameter:\\n{{colId}}=VALUE\": \"URL 参数：\\n{{colId}}=VALUE\"\n    },\n    \"DateRangeOptions\": {\n        \"Last 30 days\": \"最近 30 天\",\n        \"This month\": \"本月\",\n        \"Last 7 days\": \"最近 7 天\",\n        \"This week\": \"本周\",\n        \"This year\": \"今年\",\n        \"Today\": \"今天\",\n        \"Next 7 days\": \"未来7天\",\n        \"Last week\": \"上周\"\n    },\n    \"AdminPanel\": {\n        \"Support Grist Labs on GitHub\": \"在 GitHub 上支持 Grist Labs\",\n        \"Current\": \"当前\",\n        \"Help us make Grist better\": \"帮助我们改进 Grist\",\n        \"Home\": \"主页\",\n        \"Sponsor\": \"赞助\",\n        \"Version\": \"版本\",\n        \"Learn more.\": \"了解更多。\",\n        \"Checking for updates...\": \"检查更新中……\",\n        \"Check now\": \"立即检查\",\n        \"OK\": \"好的\",\n        \"Newer version available\": \"有新版本\",\n        \"Updates\": \"更新\",\n        \"unconfigured\": \"未配置\",\n        \"Admin Panel\": \"管理员面板\",\n        \"Grist is up to date\": \"Grist已为最新版本\",\n        \"Auto-check when this page loads\": \"加载此页面时自动检查\",\n        \"Grist releases are at \": \"Grist在此处发布更新 \",\n        \"Last checked {{time}}\": \"上一次检查时间为{{time}}\",\n        \"Error\": \"错误\",\n        \"Security Settings\": \"安全设置\",\n        \"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可以利用Python支持非常强大的公式。如果您的硬件支持的话（大多数都支持），我们建议将环境变量 GRIST_SANDBOX_FLAVOR 设置为 gvisor，这样就可以在沙盒中运行文档中的公式，沙盒将与其他文档隔离、并与网络隔离。\",\n        \"unknown\": \"未知\",\n        \"Current version of Grist\": \"Grist的当前版本\",\n        \"Support Grist\": \"支持 Grist\",\n        \"Telemetry\": \"遥测\",\n        \"Sandboxing\": \"沙盒\",\n        \"Error checking for updates\": \"检查更新时出错\",\n        \"Check failed.\": \"检查失败。\",\n        \"Check succeeded.\": \"检查成功。\",\n        \"Current authentication method\": \"当前认证方式\",\n        \"No fault detected.\": \"未检测到故障。\",\n        \"Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}\": \"或者，作为后备方案，您可以在环境中设置：{{bootKey}}，然后访问：{{url}}\",\n        \"Results\": \"结果\",\n        \"You do not have access to the administrator panel.\\nPlease log in as an administrator.\": \"您无权访问管理员面板。\\n请以管理员身份登录。\",\n        \"Details\": \"详情\",\n        \"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 可通过网络访问或供多人使用，我们建议启用其中一种。\",\n        \"New, Enterprise\": \"新,企业版\",\n        \"{{firstDestinationName}} + {{- remainingDestinationsCount}} more\": \"{{firstDestinationName}} + {{- remainingDestinationsCount}} 更多\",\n        \"Authentication\": \"认证\",\n        \"Notes\": \"注意\",\n        \"Audit Logs\": \"审计日志\",\n        \"Contact us\": \"联系我们\",\n        \"Off\": \"关闭\",\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 使用密钥对用户会话 cookie 进行签名。请通过环境变量 GRIST_SESSION_SECRET 设置此密钥。当它未设置时，Grist 会回退到硬编码的默认值。由于自 v1.1.16 以来生成的会话 ID 本身就具有密码学安全性，因此我们可能会在未来删除此通知。\",\n        \"Key to sign sessions with\": \"密钥用于会话签署\",\n        \"Session Secret\": \"会话机密\",\n        \"Sandbox settings for data engine\": \"数据引擎的沙盒设置\",\n        \"Self Checks\": \"自检\",\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 as session IDs generated since v1.1.16 are inherently cryptographically secure.\": \"Grist 使用密钥对用户会话 cookie 进行签名。请通过环境变量 GRIST_SESSION_SECRET 设置此密钥。当未设置时，Grist 会回退到硬编码的默认值。由于自 v1.1.16 以来生成的会话 ID 本身就具有密码学安全性，因此我们可能会在未来删除此通知。\",\n        \"Enable Grist Enterprise\": \"启用Grist企业版\",\n        \"Enterprise\": \"企业\",\n        \"checking\": \"检查中\",\n        \"Administrator Panel Unavailable\": \"管理员面板不可用\",\n        \"No information available\": \"无可用信息\",\n        \"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 可通过网络访问或供多人使用，我们建议启用其中一种。\",\n        \"Log Streaming\": \"日志流式传输\",\n        \"No record of last version check\": \"未检测到上次版本检查记录\",\n        \"{{count}} admin accounts_one\": \"{{count}} 管理账户\",\n        \"{{count}} admin accounts_other\": \"{{count}} 管理账户\",\n        \"On\": \"开启\",\n        \"Grist Instance\": \"Grist 实例\",\n        \"Auto-check weekly\": \"每周自动检查\",\n        \"You can set up streaming of audit events from Grist to an external security information and event management (SIEM) system if you enable Grist Enterprise. {{contactUsLink}} to learn more.\": \"如果启用 Grist Enterprise，您可以设置将审计事件从 Grist 流式传输到外部安全信息和事件管理（SIEM）系统。如需了解更多信息，请访问{{contactUsLink}} 。\",\n        \"Automatic checks are disabled. Set the environment variable GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING to \\\"true\\\" to enable them.\": \"自动检查已禁用。将环境变量 GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING 设为 \\\"true\\\"，即可启用自动检查。\",\n        \"auth error\": \"认证错误\",\n        \"configured\": \"已配置\",\n        \"default\": \"默认\",\n        \"more...\": \"更多\",\n        \"no authentication\": \"无认证\",\n        \"unavailable\": \"不可用\",\n        \"Admin account not found\": \"未找到管理员帐户\",\n        \"Administrative accounts\": \"管理员账户\",\n        \"The users with administrative accounts\": \"拥有管理员权限的用户\",\n        \"Version {{versionNumber}}\": \"版本{{versionNumber}}\",\n        \"no admin accounts\": \"无管理员账户\"\n    },\n    \"Menu\": {\n        \"Copy\": \"拷贝\",\n        \"Header\": \"标题\",\n        \"Paste\": \"粘贴\",\n        \"Insert question below\": \"在下方插入问题\",\n        \"Paragraph\": \"段落\",\n        \"Cut\": \"剪切\",\n        \"Insert question above\": \"在上方插入问题\",\n        \"Separator\": \"分割线\",\n        \"Columns\": \"列\",\n        \"Unmapped fields\": \"未映射的字段\",\n        \"Building blocks\": \"构建区块\",\n        \"More\": \"更多\",\n        \"New question\": \"新问题\"\n    },\n    \"Toggle\": {\n        \"Field Format\": \"字段格式\",\n        \"Switch\": \"切换\",\n        \"Checkbox\": \"复选框\"\n    },\n    \"CreateTeamModal\": {\n        \"Cancel\": \"取消\",\n        \"Team name\": \"团队名称\",\n        \"Team name is required\": \"团队名称是必填项\",\n        \"Choose a name and url for your team site\": \"为你的团队站点选择名称和url\",\n        \"Create site\": \"创建站点\",\n        \"Go to your site\": \"转到您的站点\",\n        \"Work as a Team\": \"作为一个团队工作\",\n        \"Billing is not supported in grist-core\": \"grist-core不支持计费\",\n        \"Team site created\": \"团队站点已创建\",\n        \"Team url\": \"团队 url\",\n        \"Domain name is invalid\": \"域名无效\",\n        \"Domain name is required\": \"域名为必填项\"\n    },\n    \"HiddenQuestionConfig\": {\n        \"Hidden fields\": \"隐藏的字段\"\n    },\n    \"Editor\": {\n        \"Delete\": \"删除\"\n    },\n    \"FormErrorPage\": {\n        \"Error\": \"错误\"\n    },\n    \"FormModel\": {\n        \"Oops! The form you're looking for doesn't exist.\": \"抱歉！您要找的表单不存在。\",\n        \"There was a problem loading the form.\": \"加载表格时出现问题。\",\n        \"You don't have access to this form.\": \"您无权访问此表单。\",\n        \"Oops! This form is no longer published.\": \"啊哦！此表单现已不公开。\"\n    },\n    \"FormPage\": {\n        \"There was an error submitting your form. Please try again.\": \"提交表单时出现错误。请再试一次。\"\n    },\n    \"FormSuccessPage\": {\n        \"Form Submitted\": \"表单已提交\",\n        \"Thank you! Your response has been recorded.\": \"谢谢！您的回复已被记录。\",\n        \"Submit new response\": \"提交新的响应\"\n    },\n    \"FormContainer\": {\n        \"Build your own form\": \"创建自己的表单\",\n        \"Powered by\": \"驱动自\",\n        \"Powered by Grist\": \"由 Grist 提供技术支持\"\n    },\n    \"Columns\": {\n        \"Remove Column\": \"删除列\"\n    },\n    \"UnmappedFieldsConfig\": {\n        \"Map fields\": \"映射字段\",\n        \"Select all\": \"选择全部\",\n        \"Unmap fields\": \"取消映射字段\",\n        \"Unmapped\": \"未映射的\",\n        \"Clear\": \"清空\",\n        \"Mapped\": \"已映射的\"\n    },\n    \"MappedFieldsConfig\": {\n        \"Map fields\": \"映射字段\",\n        \"Mapped\": \"映射\",\n        \"Select all\": \"全选\",\n        \"Clear\": \"清空\",\n        \"Unmap fields\": \"取消映射字段\",\n        \"Unmapped\": \"未映射的\",\n        \"Hide {{label}}\": \"隐藏 {{label}}\",\n        \"Hide {{label}} (batch mode)\": \"隐藏 {{label}}（批量模式）\",\n        \"Unmap {{label}}\": \"解除映射{{label}}\",\n        \"Unmap {{label}} (batch mode)\": \"解除映射{{label}} （批处理模式）\"\n    },\n    \"WelcomeCoachingCall\": {\n        \"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.\": \"在通话中，我们会花时间了解您的需求，并为您量身定制通话内容。我们可以向您介绍 Grist 的基础知识，或者立即开始使用您的数据构建所需的仪表板。\",\n        \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"与我们的团队成员预约您的 {{freeCoachingCall}} 。\",\n        \"Schedule call\": \"预约通话\",\n        \"Maybe later\": \"或许稍后\",\n        \"free coaching call\": \"免费辅助热线\",\n        \"You may also check out {{ourWeeklyWebinars}} to learn more about Grist.\": \"您还可以查看{{ourWeeklyWebinars}} ，了解有关 Grist 的更多信息。\",\n        \"our weekly webinars\": \"我们的每周网络研讨会\"\n    },\n    \"AuditLogStreamingConfig\": {\n        \"Enter token\": \"输入令牌\",\n        \"Token\": \"令牌\",\n        \"URL\": \"URL\",\n        \"Enter URL\": \"输入URL\",\n        \"Other\": \"其他\",\n        \"Start streaming\": \"开始流式传输\",\n        \"Save\": \"保存\",\n        \"Learn more\": \"了解更多\",\n        \"Are you sure you want to delete this streaming destination? This action cannot be undone.\": \"您确定要删除此流式传输目标吗？此操作无法撤销。\",\n        \"Destination\": \"目标\",\n        \"Add streaming destination\": \"添加流式传输目标\",\n        \"Add destination\": \"添加目标\",\n        \"Cancel\": \"取消\",\n        \"Delete\": \"删除\",\n        \"Delete streaming destination?\": \"是否删除流式传输目标？\",\n        \"Destinations\": \"目标\",\n        \"Edit\": \"编辑\",\n        \"Splunk\": \"Splunk\",\n        \"Edit streaming destination\": \"编辑流式传输目标\"\n    },\n    \"AuditLogsPage\": {\n        \"Contact us\": \"联系我们\",\n        \"Home\": \"Home\",\n        \"Only site owners may access audit logs.\": \"只有站点所有者可以访问审计日志。\",\n        \"Audit Logs\": \"审计日志\",\n        \"Audit logs for {{siteName}}\": \"{{siteName}}的审计日志\",\n        \"Log streaming\": \"日志流\",\n        \"upgrade your plan\": \"升级您的计划\"\n    },\n    \"CustomView\": {\n        \"Some required columns aren't mapped\": \"部分必要的列尚未映射\",\n        \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"要使用此小部件，请从右侧创建者面板映射所有非可选列。\",\n        \"Some required columns are hidden by access rules\": \"因访问规则某些必填列已被隐藏\",\n        \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"要使用此小部件，所有映射列必须可见。请联系文档所有者或修改访问规则。\"\n    },\n    \"Section\": {\n        \"Insert section below\": \"在下面插入部分\",\n        \"Insert section above\": \"在上面插入部分\",\n        \"## **Header**\": \"## **标题**\",\n        \"Description\": \"描述\"\n    },\n    \"SupportGristButton\": {\n        \"Help Center\": \"帮助中心\",\n        \"Close\": \"关闭\",\n        \"Opted In\": \"选择加入\",\n        \"Admin Panel\": \"管理面板\",\n        \"Opt in to Telemetry\": \"选择加入 Telemetry\",\n        \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"谢谢！非常感谢您的信任和支持。您可以通过用户菜单中的{{link}}随时退出。\",\n        \"Support Grist\": \"支持 Grist\",\n        \"Opt in to telemetry to help us understand how the product is used, so that we can prioritize future improvements.\": \"选择使用遥测技术，帮助我们了解产品的使用情况，从而改进未来的开发。\",\n        \"We only collect usage statistics, as detailed in our {{helpCenterLink}}, never document contents. Opt out any time from the {{supportGristLink}} in the user menu.\": \"我们只收集使用统计数据，详情请参见{{helpCenterLink}} ，绝不收集文档内容。您可以随时从{{supportGristLink}} 用户菜单中选择退出。\"\n    },\n    \"Field\": {\n        \"No choices configured\": \"未配置任何选项\",\n        \"No values in show column of referenced table\": \"引用表的显示列中没有值\",\n        \"Hide\": \"隐藏\"\n    },\n    \"FormRenderer\": {\n        \"Reset\": \"重置\",\n        \"Select...\": \"选择...\",\n        \"Submit\": \"提交\",\n        \"Search\": \"搜索\",\n        \"Submitting…\": \"提交中…\",\n        \"Clear selection for: {{-inputLabel}}\": \"清除选择：{{-inputLabel}}\"\n    },\n    \"TimingPage\": {\n        \"Column ID\": \"列 ID\",\n        \"Max Time (s)\": \"最大时间 (s)\",\n        \"Loading timing data. Don't close this tab.\": \"加载计时数据。不要关闭此选项卡。\",\n        \"Total Time (s)\": \"总时间 (s)\",\n        \"Table ID\": \"表 ID\",\n        \"Average Time (s)\": \"平均时间 (s)\",\n        \"Formula timer\": \"公式计时器\",\n        \"Number of Calls\": \"通话次数\"\n    },\n    \"ChoiceEditor\": {\n        \"No choices matching condition\": \"没有符合条件的选择\",\n        \"Error in dropdown condition\": \"下拉框条件出错\",\n        \"No choices to select\": \"没有可供选择的选项\"\n    },\n    \"ReferenceUtils\": {\n        \"No choices to select\": \"没有可选的选项\",\n        \"Error in dropdown condition\": \"下拉框条件出错\",\n        \"No choices matching condition\": \"没有符合条件的选项\"\n    },\n    \"widgetTypesMap\": {\n        \"Custom\": \"自定义\",\n        \"Form\": \"表单\",\n        \"Table\": \"表\",\n        \"Card List\": \"卡片列表\",\n        \"Calendar\": \"日历\",\n        \"Card\": \"卡片\",\n        \"Chart\": \"图表\"\n    },\n    \"DocTutorial\": {\n        \"Finish\": \"完成\",\n        \"Do you want to restart the tutorial? All progress will be lost.\": \"你想重新开始教程吗？所有进度将会丢失。\",\n        \"Click to expand\": \"点击展开\",\n        \"End tutorial\": \"结束教程\",\n        \"Next\": \"下一个\",\n        \"Previous\": \"上一个\",\n        \"Restart\": \"重启\"\n    },\n    \"ChoiceListEditor\": {\n        \"Error in dropdown condition\": \"下拉框条件出错\",\n        \"No choices matching condition\": \"没有符合条件的选项\",\n        \"No choices to select\": \"没有可选的选项\"\n    },\n    \"OnboardingPage\": {\n        \"Tell us who you are\": \"告诉我们你是谁\",\n        \"Type here\": \"在此处输入\",\n        \"Welcome\": \"欢迎\",\n        \"Back\": \"返回\",\n        \"Discover Grist in 3 minutes\": \"3分钟快速入门Grist\",\n        \"Go hands-on with the Grist Basics tutorial\": \"动手实践 Grist 基础教程\",\n        \"Go to the tutorial!\": \"进入教程！\",\n        \"Next step\": \"下一步\",\n        \"Skip step\": \"跳过步骤\",\n        \"Skip tutorial\": \"跳过教程\",\n        \"Your role\": \"您的角色\",\n        \"What is your role?\": \"您的角色是什么？\",\n        \"What organization are you with?\": \"您所在的组织是？\",\n        \"What brings you to Grist (you can select multiple)?\": \"是什么吸引您使用Grist（您可以多选）？\",\n        \"Your organization\": \"您的组织\",\n        \"Grist may look like a spreadsheet, but it doesn't always act like one. Discover what makes Grist different.\": \"Grist 看起来像电子表格，但并不完全像电子表格。了解 Grist 的与众不同之处。\"\n    },\n    \"ToggleEnterpriseWidget\": {\n        \"Grist Enterprise is **enabled**.\": \"Grist 企业版**已启用**。\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\nof 30 days has expired. Get an activation key by [contacting us]({{contactLink}}) today. You do\\nnot need an activation key to run Grist Core.\\n\\nLearn more in our [Help Center]({{helpCenter}}).\": \"激活密钥用于在30天试用期结束后运行 Grist 企业版。\\n立即通过[联系我们]({{contactLink}})获取激活密钥。\\n您不需要激活密钥来运行 Grist Core。\\n\\n在我们的[帮助中心]({{helpCenter}})了解更多。\",\n        \"Disable Grist Enterprise\": \"禁用 Grist 企业版\",\n        \"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}}).\": \"激活密钥用于在30天试用期结束后运行 Grist 企业版。\\n通过[注册 Grist 企业版]({{signupLink}})获取激活密钥。\\n您不需要激活密钥来运行 Grist Core。\\n\\n在我们的[帮助中心]({{helpCenter}})了解更多信息。\",\n        \"Enable Grist Enterprise\": \"启用 Grist 企业版\",\n        \"Activate\": \"激活\",\n        \"Activation key\": \"激活密钥\",\n        \"An activation key is used to run Grist Enterprise after a trial period\\n        of 30 days has expired. Get an activation key by [signing up for Grist\\n        Enterprise]({{signupLink}}). You do not need an activation key to run\\n        Grist Core.\": \"如果想在30天试用期结束后继续运行 Grist Enterprise,\\n需要激活密钥。通过[注册 Grist Enterprise]({{signupLink}})可获取激活密钥。\\n运行 Grist Core 无需激活密钥。\",\n        \"An active subscription is required to continue using Grist Enterprise. You can\\nyou activate your subscription by [signing up for Grist Enterprise ]({{signupLink}}) and pasting your\\nactivation key below.\": \"继续使用 Grist Enterprise 需要激活订阅。您可以\\n注册 Grist Enterprise ]({{signupLink}}) 并在下面粘贴您的激活密钥。\",\n        \"Copy to clipboard\": \"复制到剪贴板\",\n        \"Expiration date\": \"过期时间\",\n        \"Installation ID copied to clipboard\": \"安装 ID 已复制到剪贴板\",\n        \"Installation ID:\": \"安装ID:\",\n        \"Learn more in our [Help Center]({{helpCenter}}).\": \"如需了解更多信息，请访问我们的 [帮助中心]({{helpCenter}})。\",\n        \"Paste your activation key\": \"粘贴激活密钥\",\n        \"Plan name\": \"计划名称\",\n        \"To continue using Grist Enterprise, you need to\\n                  [contact us]({{signupLink}}) to get your activation key.\": \"要继续使用 Grist Enterprise，您需要\\n[联系我们]({{signupLink}}) 获取激活密钥。\",\n        \"You are currently trialing Grist Enterprise.\": \"您正在试用 Grist Enterprise。\",\n        \"You do not have an active subscription.\": \"您没有激活订阅。\",\n        \"Your activation key has expired due to exceeding limits.\": \"由于超出限制，您的激活密钥已过期。\",\n        \"Your instance will be in **read-only** mode in **{{days}}** day(s).\": \"您的实例将在 **{{days}}** 天内处于**只读**模式。\",\n        \"Your subscription expired on {{date}}.\": \"您的订阅已于{{date}} 到期。\",\n        \"Your trial period has expired on **{{expireAt}}**. To continue using Grist Enterprise, you need to\\n[sign up for Grist Enterprise]({{signupLink}}) and paste your activation key below.\": \"您的试用期已于 **{{expireAt}}** 到期。要继续使用 Grist Enterprise，您需要\\n[注册 Grist Enterprise]({{signupLink}}) 并在下面粘贴您的激活密钥。\"\n    },\n    \"CustomWidgetGallery\": {\n        \"Custom URL\": \"自定义 URL\",\n        \"Developer:\": \"开发者：\",\n        \"(Missing info)\": \"（缺少信息）\",\n        \"Add widget\": \"添加小部件\",\n        \"Add Your Own Widget\": \"添加您自己的小部件\",\n        \"Add a widget from outside this gallery.\": \"从这个图库之外添加一个小部件。\",\n        \"Cancel\": \"取消\",\n        \"Change widget\": \"更改小部件\",\n        \"Choose custom widget\": \"选择自定义小部件\",\n        \"Community Widget\": \"社区小部件\",\n        \"Grist Widget\": \"Grist 小部件\",\n        \"Last updated:\": \"上次更新：\",\n        \"Learn more about custom widgets\": \"了解有关自定义小部件的更多信息\",\n        \"No matching widgets\": \"没有匹配的小部件\",\n        \"Widget URL\": \"小部件 URL\",\n        \"Search\": \"搜索\"\n    },\n    \"HomeIntroCards\": {\n        \"Finish our basics tutorial\": \"完成我们的基础教程\",\n        \"Help center\": \"帮助中心\",\n        \"Import file\": \"导入文件\",\n        \"Blank document\": \"空白文档\",\n        \"Find solutions and explore more resources {{helpCenterLink}}\": \"查找解决方案并探索更多资源 {{helpCenterLink}}\",\n        \"3 minute video tour\": \"3分钟视频速览\",\n        \"Webinars\": \"Webinars\",\n        \"Templates\": \"模版\",\n        \"Tutorial\": \"教程\",\n        \"Learn more {{webinarsLinks}}\": \"了解更多 {{webinarsLinks}}\",\n        \"Start a new document\": \"从一个新的文档开始吧\",\n        \"Find solutions and explore more resources\": \"寻找解决方案，探索更多资源\",\n        \"Learn more\": \"了解更多\"\n    },\n    \"ReverseReferenceConfig\": {\n        \"Table\": \"表\",\n        \"Target table\": \"目标表\",\n        \"Add two-way reference\": \"添加双向引用\",\n        \"It is the reverse of the reference column {{column}} in table {{table}}.\": \"它是表{{table}}中引用列{{column}}的反向。\",\n        \"Two-way Reference\": \"双向引用\",\n        \"Delete two-way reference?\": \"是否删除双向引用？\",\n        \"Column\": \"列\",\n        \"Delete\": \"删除\",\n        \"Delete column {{column}} in table {{table}}?\": \"是否删除表{{table}}中的列{{column}}？\",\n        \"This will delete the reference column {{refCol}} in table {{refTable}}. The reference column {{myName}} will remain in the current table {{myTable}}.\": \"这将删除表{{refTable}} 中的参考列{{refCol}} 。参考列{{myName}} 将保留在当前表{{myTable}} 中。\"\n    },\n    \"DropdownConditionConfig\": {\n        \"Dropdown Condition\": \"下拉框条件\",\n        \"Invalid columns: {{colIds}}\": \"无效的列：{{colIds}}\",\n        \"Set dropdown condition\": \"设置下拉框条件\"\n    },\n    \"DropdownConditionEditor\": {\n        \"Enter condition.\": \"输入条件。\"\n    },\n    \"OnboardingCards\": {\n        \"3 minute video tour\": \"3分钟视频速览\",\n        \"Complete our basics tutorial\": \"完成我们的基础教程\",\n        \"Complete the tutorial\": \"完成本教程\",\n        \"Learn the basic of reference columns, linked widgets, column types, & cards.\": \"学习参考列、关联小部件、列类型和卡片的基础知识。\",\n        \"Learn the basics of reference columns, linked widgets, column types, & cards.\": \"学习参考列、关联小部件、列类型和卡片的基础知识。\"\n    },\n    \"ViewLayout\": {\n        \"Delete\": \"删除\",\n        \"Delete data and this widget.\": \"删除数据和此小部件。\",\n        \"Table {{tableName}} will no longer be visible\": \"表{{tableName}}将不再可见\",\n        \"Raw Data page\": \"原始数据页\",\n        \"Keep data and delete widget. Table will remain available in {{rawDataLink}}\": \"保留数据并删除小部件。表格将在{{rawDataLink}}中仍然可用\"\n    },\n    \"AdminPanelName\": {\n        \"Admin Panel\": \"管理面板\"\n    },\n    \"markdown\": {\n        \"The toggle is **on**\": \"此开关已**打开**\",\n        \"The toggle is **off**\": \"此开关已**关闭**\"\n    },\n    \"markdown.d\": {\n        \"The toggle is **off**\": \"此开关已**关闭**\",\n        \"The toggle is **on**\": \"此开关已**打开**\"\n    },\n    \"buildReassignModal\": {\n        \"Cancel\": \"取消\",\n        \"Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.\": \"每个{{targetTable}}记录只能分配给单个{{sourceTable}}记录。\",\n        \"Reassign\": \"重新分配\",\n        \"Reassign to new {{sourceTable}} records.\": \"重新分配给新的{{sourceTable}}记录。\",\n        \"Reassign to {{sourceTable}} record {{sourceName}}.\": \"重新分配给{{sourceTable}}记录{{sourceName}}。\",\n        \"Record already assigned_other\": \"记录已分配\",\n        \"{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record          {{oldSourceName}}.\": \"{{targetTable}}记录{{targetName}}已分配给{{sourceTable}}记录           {{oldSourceName}}。\",\n        \"Record already assigned_one\": \"记录已分配\"\n    },\n    \"commandList\": {\n        \"showing a behavioral popup\": \"显示行为弹窗\",\n        \"When typed at the start of a cell, make this a formula column\": \"在单元格开头输入时，将此列设为公式列\",\n        \"Shortcut to open sort & filter menu\": \"快捷打开排序和筛选菜单\",\n        \"Shortcut to open the left panel\": \"快捷打开左侧面板\",\n        \"Shortcut to open the right panel\": \"快捷打开右侧面板\",\n        \"Shortcut to open view tab\": \"快捷打开视图选项卡\",\n        \"Shortcut to sort & filter tab\": \"快捷打开排序与筛选选项卡\",\n        \"Show hidden columns\": \"显示隐藏列\",\n        \"Show raw data widget for table of currently selected page widget\": \"为当前选中页面的表格显示原始数据小部件\",\n        \"Show the record card widget of the selected record\": \"显示所选记录的卡片小部件\",\n        \"Sort the view data by the currently selected field in ascending order\": \"按当前选定字段对视图数据升序排序\",\n        \"Sort the view data by the currently selected field in descending order\": \"按当前选定字段对视图数据降序排序\",\n        \"Start editing the currently-selected cell\": \"开始编辑当前选中的单元格\",\n        \"Toggle creator panel keyboard focus\": \"切换创作者面板键盘焦点\",\n        \"Toggle the currently selected checkbox or switch cell\": \"切换当前选中单元格的复选框或开关\",\n        \"Undo last action\": \"撤销上一步操作\",\n        \"Use the currently selected row as table headers\": \"将当前选中的行设置为表头\",\n        \"When in the search bar, close it and focus the current match\": \"关闭搜索栏时将焦点切换至当前匹配项\",\n        \"Move to the first field or the beginning of a row\": \"移至第一个字段或行首\",\n        \"Move up to the first record\": \"移至第一条记录\",\n        \"Move down to the last record\": \"移至最后一条记录\",\n        \"Move to the last field or the end of a row\": \"移至最后一个字段或行尾\",\n        \"Redo last action\": \"恢复上一步操作\",\n        \"Push an undo action\": \"执行撤销操作\",\n        \"Insert the current date and time\": \"插入当前日期和时间\",\n        \"Edit label of the currently-selected field\": \"编辑当前选定字段的标签\",\n        \"Fills current selection with the contents of the top row in the selection\": \"用当前选区顶部行内容填充选区\",\n        \"Hide the currently selected fields\": \"隐藏当前选中的字段\",\n        \"Insert a new column, after the currently selected one\": \"在当前选定列之后插入新列\",\n        \"Insert a new column, before the currently selected one\": \"在当前选定列之前插入新列\",\n        \"Insert a new record, after the currently selected one in an unsorted table\": \"在未排序表中的当前选定记录之后插入新记录\",\n        \"Insert a new record, before the currently selected one in an unsorted table\": \"在未排序表中，在当前选定记录之前插入一条新记录\",\n        \"Insert new column in default location\": \"在默认位置插入新列\",\n        \"Insert the current date\": \"插入当前日期\",\n        \"Maximize the active section\": \"最大化活动部分\",\n        \"Move down one page of records, or to next record in a card list\": \"下移一页记录，或移至卡片列表中的下一条记录\",\n        \"Move downward five records\": \"向下移动五个记录\"\n    },\n    \"ParseOptions\": {\n        \"First row contains headers\": \"第一行包含表头\"\n    },\n    \"OpenAccessibilityModal\": {\n        \"On a document page, keyboard navigation is first locked on the current widget.\": \"在文档页面中，键盘导航首先锁定在当前小部件上。\"\n    },\n    \"DocList\": {\n        \"Last edited\": \"最后编辑\"\n    }\n}\n"
  },
  {
    "path": "static/locales/zh_Hans.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"请向身份提供商验证您的电子邮件，然后重新登录。\"\n    },\n    \"sendAppPage\": {\n        \"Loading...\": \"加载中...\",\n        \"og-description\": \"超越表格的现代开源电子表格\",\n        \"og-title\": \"Grist，电子表格的革新之作\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"您无权访问此文档。\",\n        \"docDisabled\": \"本文档已禁用。\"\n    },\n    \"admin\": {\n        \"emptyOrg\": \"在 `GRIST_INSTALL_ADMIN_ORG={{org}}` 所定义的管理组织中未发现所有者\",\n        \"orgUser\": \"用户是一个由 \\\"GRIST_INSTALL_ADMIN_ORG={{org}}\\\"设定的管理组织的所有者\",\n        \"accountByEmail\": \"由 `GRIST_DEFAULT_EMAIL={{defaultEmail}}` 定义的管理员帐户\"\n    },\n    \"DocApi\": {\n        \"UntitledDocument\": \"无标题文档\"\n    }\n}\n"
  },
  {
    "path": "static/locales/zh_Hant.client.json",
    "content": "{\n  \"ApiKey\": {\n    \"Remove API Key\": \"移除 API 金鑰\",\n    \"Click to show\": \"點選顯示\",\n    \"This API key can be used to access this account anonymously via the API.\": \"此 API 金鑰可以用於透過 API 匿名存取此帳戶。\",\n    \"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 金鑰。這將導致所有使用此 API 金鑰的未來請求被拒絕。您還是要刪除嗎？\",\n    \"This API key can be used to access your account via the API. Don’t share your API key with anyone.\": \"此 API 金鑰可以用於透過 API 存取您的帳戶。請不要與任何人分享您的 API 金鑰。\",\n    \"Create\": \"建立\",\n    \"Remove\": \"移除\",\n    \"By generating an API key, you will be able to make API calls for your own account.\": \"產生 API 金鑰後，您將能夠為您自己的帳戶進行 API 呼叫。\"\n  },\n  \"breadcrumbs\": {\n    \"override\": \"覆蓋\",\n    \"unsaved\": \"未儲存\",\n    \"fiddle\": \"小提琴\",\n    \"recovery mode\": \"復原模式\",\n    \"snapshot\": \"快照\",\n    \"You may make edits, but they will create a new copy and will\\nnot affect the original document.\": \"您可以進行編輯，但這將建立一個新的副本，並不會影響原始文件。\"\n  },\n  \"HomeLeftPane\": {\n    \"All documents\": \"所有文件\",\n    \"Manage users\": \"管理使用者\",\n    \"Tutorial\": \"教學\",\n    \"Delete {{workspace}} and all included documents?\": \"刪除 {{workspace}} 及其中所有文件？\",\n    \"Create empty document\": \"建立空白文件\",\n    \"Create workspace\": \"建立工作區\",\n    \"Import document\": \"匯入文件\",\n    \"Access Details\": \"存取詳細資訊\",\n    \"Rename\": \"重新命名\",\n    \"Trash\": \"垃圾桶\",\n    \"Workspaces\": \"工作區\",\n    \"Workspace will be moved to Trash.\": \"工作區將被移至垃圾桶。\",\n    \"Examples & Templates\": \"範本\",\n    \"Delete\": \"刪除\",\n    \"Terms of service\": \"服務條款\",\n    \"Grist Resources\": \"Grist 資源\"\n  },\n  \"RowContextMenu\": {\n    \"Insert row\": \"插入列\",\n    \"Insert row below\": \"在下方插入列\",\n    \"Delete\": \"刪除\",\n    \"Copy anchor link\": \"複製錨點連結\",\n    \"Duplicate rows_one\": \"複製列\",\n    \"Duplicate rows_other\": \"複製列\",\n    \"Insert row above\": \"在上方插入列\",\n    \"View as card\": \"以卡片方式檢視\",\n    \"Use as table headers\": \"用作表格標頭\"\n  },\n  \"Drafts\": {\n    \"Undo discard\": \"撤銷丟棄\",\n    \"Restore last edit\": \"復原上次編輯\"\n  },\n  \"FormulaAssistant\": {\n    \"Data\": \"資料\",\n    \"Press Enter to apply suggested formula.\": \"按 Enter 套用建議的公式。\",\n    \"See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.\": \"檢視我們的 {{helpFunction}} 和 {{formulaCheat}}，或者造訪我們的 {{community}} 以取得更多協助。\",\n    \"Sign up for a free Grist account to start using the Formula AI Assistant.\": \"註冊一個免費的 Grist 帳戶以開始使用公式 AI 幫手。\",\n    \"Clear conversation\": \"清除對話\",\n    \"New Chat\": \"新聊天\",\n    \"Code view\": \"程式碼檢視\",\n    \"Apply\": \"套用\",\n    \"Learn more\": \"了解更多\",\n    \"Regenerate\": \"重新產生\",\n    \"Community\": \"社群\",\n    \"I can only help with formulas. I cannot build tables, columns, and views, or write access rules.\": \"我只能協助處理公式。我無法建立表格、欄位和檢視，或撰寫存取規則。\",\n    \"Hi, I'm the Grist Formula AI Assistant.\": \"嗨，我是 Grist 公式 AI 幫手。\",\n    \"Preview\": \"預覽\",\n    \"Ask the bot.\": \"詢問機器人。\",\n    \"Function List\": \"函式列表\",\n    \"For higher limits, contact the site owner.\": \"如需更高的限制，請聯絡網站擁有者。\",\n    \"Tips\": \"提示\",\n    \"Save\": \"儲存\",\n    \"Sign Up for Free\": \"免費註冊\",\n    \"Formula Cheat Sheet\": \"公式速查表\",\n    \"Grist's AI Assistance\": \"Grist 的 AI 幫手\",\n    \"Formula AI Assistant is only available for logged in users.\": \"公式 AI 幫手僅供已登入的使用者使用。\",\n    \"Grist's AI Formula Assistance. \": \"Grist 的 AI 公式幫手。 \",\n    \"upgrade to the Pro Team plan\": \"升級到專業團隊方案\",\n    \"You have used all available credits.\": \"您已使用所有可用的點數。\",\n    \"upgrade your plan\": \"升級您的方案\",\n    \"Formula Help. \": \"公式協助。 \",\n    \"You have {{numCredits}} remaining credits.\": \"您還有 {{numCredits}} 剩餘點數。\",\n    \"Capabilities\": \"能力\",\n    \"What do you need help with?\": \"您需要什麼樣的協助？\",\n    \"Cancel\": \"取消\",\n    \"Need help? Our AI assistant can help.\": \"需要幫忙？我們的 AI 幫手可以幫忙。\",\n    \"AI Assistant\": \"AI 幫手\",\n    \"For higher limits, {{upgradeNudge}}.\": \"如需更高的限制，{{upgradeNudge}}。\",\n    \"There are some things you should know when working with me:\": \"與我一起工作時，有些事情您應該知道：\",\n    \"For more help with formulas, check out our {{functionList}} and {{formulaCheatSheet}}, or visit our {{community}} for more help.\": \"如需更多公式相關協助，請參閱我們的 {{functionList}} 和 {{formulaCheatSheet}}，或造訪 {{community}} 尋求更多幫助。\",\n    \"When you talk to me, your questions and your document structure (visible in {{codeView}}) are sent to OpenAI. {{learnMore}}.\": \"當您與我互動時，您的問題與文件結構（可在 {{codeView}} 中查看）會傳送至 OpenAI。{{learnMore}}。\",\n    \"Talk to me like a person. No need to specify tables and column names. For example, you can ask \\\"Please calculate the total invoice amount.\\\"\": \"像與真人對話一樣與我互動，無需特別說明資料表和欄位名稱。例如，您可以這樣問：「請幫我計算發票總金額。」\"\n  },\n  \"GridViewMenus\": {\n    \"Unfreeze {{count}} columns_one\": \"解凍此欄位\",\n    \"Detect Duplicates in...\": \"在...中偵測重複\",\n    \"UUID\": \"UUID\",\n    \"Shortcuts\": \"快捷鍵\",\n    \"Sorted (#{{count}})_one\": \"已排序 (#{{count}})\",\n    \"Unfreeze all columns\": \"解凍所有欄位\",\n    \"Show hidden columns\": \"顯示隱藏欄位\",\n    \"Freeze {{count}} columns_other\": \"凍結 {{count}} 個欄位\",\n    \"Show column {{- label}}\": \"顯示欄位 {{- label}}\",\n    \"Sort\": \"排序\",\n    \"Column Options\": \"欄位選項\",\n    \"Rename column\": \"重新命名欄位\",\n    \"Filter Data\": \"篩選資料\",\n    \"Delete {{count}} columns_one\": \"刪除欄位\",\n    \"Created At\": \"建立於\",\n    \"Insert column to the {{to}}\": \"插入欄位到 {{to}}\",\n    \"Authorship\": \"作者\",\n    \"Hide {{count}} columns_other\": \"隱藏 {{count}} 個欄位\",\n    \"Add column\": \"新增欄位\",\n    \"Last Updated By\": \"最後更新者\",\n    \"Hidden Columns\": \"隱藏欄位\",\n    \"Lookups\": \"查閱\",\n    \"Reset {{count}} columns_one\": \"重設欄位\",\n    \"No reference columns.\": \"無參考欄位。\",\n    \"Freeze {{count}} columns_one\": \"凍結此欄位\",\n    \"More sort options ...\": \"更多排序選項…\",\n    \"Freeze {{count}} more columns_one\": \"凍結一個更多的欄位\",\n    \"Reset {{count}} entire columns_other\": \"重設 {{count}} 個整個欄位\",\n    \"Apply on record changes\": \"在記錄變更時套用\",\n    \"Reset {{count}} columns_other\": \"重設 {{count}} 個欄位\",\n    \"Clear values\": \"清除值\",\n    \"Delete {{count}} columns_other\": \"刪除 {{count}} 個欄位\",\n    \"Duplicate in {{- label}}\": \"{{- label}} 中的重複\",\n    \"Created By\": \"建立者\",\n    \"Unfreeze {{count}} columns_other\": \"解凍 {{count}} 個欄位\",\n    \"Last Updated At\": \"最後更新於\",\n    \"Apply to new records\": \"套用到新記錄\",\n    \"Add to sort\": \"新增到排序\",\n    \"Insert column to the right\": \"在右側插入欄位\",\n    \"Search columns\": \"搜尋欄位\",\n    \"Timestamp\": \"時間戳記\",\n    \"no reference column\": \"無參考欄位\",\n    \"Reset {{count}} entire columns_one\": \"重設整個欄位\",\n    \"Adding UUID column\": \"新增 UUID 欄位\",\n    \"Convert formula to data\": \"將公式轉換為資料\",\n    \"Freeze {{count}} more columns_other\": \"凍結 {{count}} 個更多的欄位\",\n    \"Adding duplicates column\": \"新增重複欄位\",\n    \"Hide {{count}} columns_one\": \"隱藏欄位\",\n    \"Insert column to the left\": \"在左側插入欄位\",\n    \"Sorted (#{{count}})_other\": \"已排序 (#{{count}})\",\n    \"Add column with type\": \"新增欄位並指定類型\",\n    \"Add formula column\": \"新增公式欄位\",\n    \"Created at\": \"建立於\",\n    \"Created by\": \"由…建立\",\n    \"Detect duplicates in...\": \"偵測重複項目於...\",\n    \"Last updated at\": \"最後更新於\",\n    \"Last updated by\": \"最後由...更新\",\n    \"Any\": \"任何\",\n    \"Numeric\": \"數字\",\n    \"Text\": \"文字\",\n    \"Integer\": \"整數\",\n    \"Toggle\": \"切換\",\n    \"Date\": \"日期\",\n    \"DateTime\": \"日期時間\",\n    \"Choice\": \"選擇\",\n    \"Choice List\": \"選擇清單\",\n    \"Reference\": \"引用\",\n    \"Reference List\": \"引用清單\",\n    \"Attachment\": \"附件\"\n  },\n  \"DocMenu\": {\n    \"This service is not available right now\": \"此服務目前無法使用\",\n    \"Workspace not found\": \"找不到工作區\",\n    \"Discover More Templates\": \"探索更多範本\",\n    \"Current workspace\": \"目前工作區\",\n    \"Edited {{at}}\": \"已於 {{at}} 編輯\",\n    \"Pin Document\": \"固定文件\",\n    \"Remove\": \"移除\",\n    \"By Date Modified\": \"按修改日期\",\n    \"Rename\": \"重新命名\",\n    \"Move\": \"移動\",\n    \"Delete Forever\": \"永久刪除\",\n    \"Trash is empty.\": \"垃圾桶是空的。\",\n    \"Unpin Document\": \"取消固定文件\",\n    \"Documents stay in Trash for 30 days, after which they get deleted permanently.\": \"文件將在垃圾桶中停留 30 天，之後將被永久刪除。\",\n    \"Requires edit permissions\": \"需要編輯權限\",\n    \"More Examples and Templates\": \"更多範例和範本\",\n    \"Access Details\": \"存取詳細資訊\",\n    \"Deleted {{at}}\": \"已於 {{at}} 刪除\",\n    \"You are on the {{siteName}} site. You also have access to the following sites:\": \"您正在 {{siteName}} 網站上。您也可以存取以下網站：\",\n    \"Other Sites\": \"其他網站\",\n    \"All documents\": \"所有文件\",\n    \"Pinned Documents\": \"已固定的文件\",\n    \"Featured\": \"精選\",\n    \"Delete {{name}}\": \"刪除 {{name}}\",\n    \"Manage users\": \"管理使用者\",\n    \"Examples and Templates\": \"範例和範本\",\n    \"Delete\": \"刪除\",\n    \"Document will be permanently deleted.\": \"文件將被永久刪除。\",\n    \"(The organization needs a paid plan)\": \"（組織需要付費方案）\",\n    \"To restore this document, restore the workspace first.\": \"要還原此文件，請先還原工作區。\",\n    \"You may delete a workspace forever once it has no documents in it.\": \"一旦工作區中沒有文件，您可以永久刪除它。\",\n    \"By Name\": \"按名稱\",\n    \"Examples & Templates\": \"範例 & 範本\",\n    \"Trash\": \"垃圾桶\",\n    \"You are on your personal site. You also have access to the following sites:\": \"您正在您的個人網站上。您也可以存取以下網站：\",\n    \"Restore\": \"還原\",\n    \"Move {{name}} to workspace\": \"將 {{name}} 移至工作區\",\n    \"Document will be moved to Trash.\": \"文件將被移至垃圾桶。\",\n    \"Permanently Delete \\\"{{name}}\\\"?\": \"永久刪除 \\\"{{name}}\\\"？\",\n    \"Any documents created in this site will appear here.\": \"在此網站建立的所有文件都會顯示在此處.\",\n    \"Create my first document\": \"創立我的第一份文件\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"您只有此網站的唯讀存取權限。目前沒有文件。\",\n    \"personal site\": \"個人網站\"\n  },\n  \"ChartView\": {\n    \"Each Y series is followed by a series for the length of error bars.\": \"每個 Y 數列後面都接著一個表示誤差棒長度的系列。\",\n    \"Pick a column\": \"選擇一欄\",\n    \"Toggle chart aggregation\": \"切換圖表聚合\",\n    \"Each Y series is followed by two series, for top and bottom error bars.\": \"每個 Y 數列後面都接著兩個數列，分別表示上方和下方的誤差棒。\",\n    \"Create separate series for each value of the selected column.\": \"為所選欄位的每個值建立單獨的數列。\",\n    \"selected new group data columns\": \"選擇新的群組資料欄位\",\n    \"LABEL\": \"標籤\",\n    \"Bar chart\": \"長條圖\",\n    \"Pie chart\": \"圓餅圖\",\n    \"Donut chart\": \"環狀圖\",\n    \"Area chart\": \"面積圖\",\n    \"Line chart\": \"折線圖\",\n    \"Scatter plot\": \"散布圖\",\n    \"Kaplan-Meier plot\": \"Kaplan-Meier 圖\",\n    \"Invert Y-axis\": \"反轉 Y 軸\",\n    \"Orientation\": \"方向\",\n    \"Vertical\": \"垂直\",\n    \"Horizontal\": \"水平\",\n    \"Log scale Y-axis\": \"Y 軸對數刻度\",\n    \"Show total\": \"顯示總計\",\n    \"Text size\": \"字體大小\",\n    \"Show markers\": \"顯示標記\",\n    \"None\": \"無\",\n    \"Symmetric\": \"對稱\",\n    \"X-AXIS\": \"X 軸\",\n    \"Add series\": \"增加數列\",\n    \"SERIES\": \"數列\",\n    \"non-numeric columns are not shown\": \"非數字欄不顯示\",\n    \"non-numeric column is not shown\": \"非數字欄不顯示\",\n    \"selected new x-axis\": \"選擇新 X 軸\",\n    \"Remove\": \"刪除\",\n    \"Split series\": \"拆分數列\",\n    \"Connect gaps\": \"連接間隔\",\n    \"Stack series\": \"堆疊數列\",\n    \"Error bars\": \"誤差線\",\n    \"Above+Below\": \"上下\",\n    \"Split Series\": \"拆分數列\",\n    \"Aggregate values\": \"彙總數值\",\n    \"Hole size\": \"內圈尺寸\"\n  },\n  \"RightPanel\": {\n    \"WIDGET TITLE\": \"小工具標題\",\n    \"COLUMN TYPE\": \"欄位類型\",\n    \"SELECT BY\": \"選擇依據\",\n    \"Edit data selection\": \"編輯資料選擇\",\n    \"DATA TABLE NAME\": \"資料表名稱\",\n    \"fields_one\": \"欄位\",\n    \"Save\": \"儲存\",\n    \"You do not have edit access to this document\": \"您無權限編輯此文件\",\n    \"DATA TABLE\": \"資料表\",\n    \"Theme\": \"主題\",\n    \"columns_other\": \"欄位\",\n    \"Data\": \"資料\",\n    \"series_one\": \"系列\",\n    \"GROUPED BY\": \"分組依據\",\n    \"SOURCE DATA\": \"來源資料\",\n    \"CHART TYPE\": \"圖表類型\",\n    \"Detach\": \"分離\",\n    \"Change widget\": \"更改小工具\",\n    \"columns_one\": \"欄位\",\n    \"series_other\": \"系列\",\n    \"fields_other\": \"欄位\",\n    \"Row style\": \"列樣式\",\n    \"CUSTOM\": \"自訂\",\n    \"Select widget\": \"選擇小工具\",\n    \"Add referenced columns\": \"新增引用的欄位\",\n    \"TRANSFORM\": \"轉換\",\n    \"SELECTOR FOR\": \"選擇器用於\",\n    \"Sort & filter\": \"排序與篩選\",\n    \"Widget\": \"小工具\",\n    \"Reset form\": \"重置表格\",\n    \"Configuration\": \"設定\",\n    \"Default field value\": \"欄位預設值\",\n    \"Display button\": \"顯示按鈕\",\n    \"Enter text\": \"輸入文字\",\n    \"Field rules\": \"欄位規則\",\n    \"Field title\": \"欄位標題\",\n    \"Hidden field\": \"隱藏的欄位\",\n    \"Layout\": \"版面配置\",\n    \"Redirect automatically after submission\": \"提交後自動重新導向\",\n    \"Redirection\": \"重新導向\",\n    \"Required field\": \"必填欄位\",\n    \"Submission\": \"提交\",\n    \"Submit another response\": \"提交另一個回應\",\n    \"Submit button label\": \"提交按鈕標籤\",\n    \"Success text\": \"成功顯示文字\",\n    \"Table column name\": \"表格欄位名稱\",\n    \"Enter redirect URL\": \"輸入重新導向的網址\",\n    \"No field selected\": \"未選擇欄位\",\n    \"Select a field in the form widget to configure.\": \"請選擇表單元件中的欄位以進行設定。\",\n    \"Submit\": \"提交\",\n    \"Thank you! Your response has been recorded.\": \"感謝您！您的回應已被記錄。\"\n  },\n  \"FloatingPopup\": {\n    \"Maximize\": \"最大化\",\n    \"Minimize\": \"最小化\"\n  },\n  \"MakeCopyMenu\": {\n    \"Include the structure without any of the data.\": \"包含結構但不包含任何資料。\",\n    \"Original Looks Unrelated\": \"原始文件看起來無關\",\n    \"Overwrite\": \"覆蓋\",\n    \"It will be overwritten, losing any content not in this document.\": \"它將被覆蓋，並且會失去任何不在此文件中的內容。\",\n    \"Be careful, the original has changes not in this document. Those changes will be overwritten.\": \"請注意，原始文件有些變更並未在此文件中。這些變更將會被覆蓋。\",\n    \"Workspace\": \"工作區\",\n    \"As template\": \"作為範本\",\n    \"Cancel\": \"取消\",\n    \"Sign up\": \"註冊\",\n    \"Enter document name\": \"輸入文件名稱\",\n    \"Name\": \"名稱\",\n    \"Update\": \"更新\",\n    \"Original Has Modifications\": \"原始文件有修改\",\n    \"No destination workspace\": \"沒有目標工作區\",\n    \"You do not have write access to the selected workspace\": \"您無法對選定的工作區進行寫入\",\n    \"Download document structure only (no data, for template use)\": \"移除所有資料但保留結構以作為範本\",\n    \"Original Looks Identical\": \"原始文件看起來相同\",\n    \"Organization\": \"組織\",\n    \"Replacing the original requires editing rights on the original document.\": \"替換原始文件需要對原始文件有編輯權限。\",\n    \"Download document without history (can significantly reduce file size)\": \"移除文件歷史（可以顯著減少檔案大小）\",\n    \"To save your changes, please sign up, then reload this page.\": \"要儲存您的變更，請註冊，然後重新載入此頁面。\",\n    \"The original version of this document will be updated.\": \"此文件的原始版本將被更新。\",\n    \"However, it appears to be already identical.\": \"然而，它似乎已經完全相同。\",\n    \"Update Original\": \"更新原始文件\",\n    \"You do not have write access to this site\": \"您無法對此網站進行寫入\",\n    \"Download document and history\": \"下載完整文件和歷史\",\n    \"Download\": \"下載\",\n    \"Download document\": \"下載文件\",\n    \".tar (recommended)\": \".tar (建議)\",\n    \".zip\": \".zip\",\n    \"Download an archive of all the attachments present in this document.\": \"下載包含此文件所有附件的歸檔檔案。\",\n    \"Download attachments\": \"下載附件\",\n    \"Download full document and history\": \"下載完整文件及歷史記錄\",\n    \"Format:\": \"格式:\",\n    \"Learn more\": \"了解更多\",\n    \"download attachments\": \"下載附件\",\n    \"Attachments are external and not included in this download. If uploading the document to a separate Grist installation, you will also need to {{downloadLink}} separately. \": \"附件為外部儲存，未包含在此次下載中。若要將此文件上傳至另一個 Grist 安裝環境，您會需要額外{{downloadLink}}。 \"\n  },\n  \"GristTooltips\": {\n    \"They allow for one record to point (or refer) to another.\": \"它們允許一個記錄指向（或參考）另一個記錄。\",\n    \"Updates every 5 minutes.\": \"每 5 分鐘更新一次。\",\n    \"entire\": \"整體\",\n    \"Select the table to link to.\": \"選擇要連結的表格。\",\n    \"The total size of all data in this document, excluding attachments.\": \"此文件中所有資料的總大小，不包括附件。\",\n    \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\": \"UUID 是一個隨機產生的字串，對於唯一識別碼和連結鍵非常有用。\",\n    \"You can choose one of our pre-made widgets or embed your own by providing its full URL.\": \"您可以選擇我們的預製小工具之一，或者提供完整的 URL 來嵌入您自己的小工具。\",\n    \"Reference Columns\": \"參考欄位\",\n    \"To configure your calendar, select columns for start\": {\n      \"end dates and event titles. Note each column's type.\": \"要設定您的日曆，請選擇開始/結束日期和事件標題的欄位。注意每個欄位的類型。\"\n    },\n    \"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.\": \"存取規則讓您有權力建立細緻的規則，以決定誰可以檢視或編輯您文件的哪些部分。\",\n    \"Rearrange the fields in your card by dragging and resizing cells.\": \"透過拖曳和調整儲存格大小來重新排列卡片中的欄位。\",\n    \"Calendar\": \"日曆\",\n    \"You can filter by more than one column.\": \"您可以按多個欄位進行篩選。\",\n    \"Apply conditional formatting to cells in this column when formula conditions are met.\": \"當公式條件符合時，對此欄位中的儲存格套用條件格式。\",\n    \"To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.\": \"要製作一個將使用者帶到特定儲存格的錨點連結，請點選一列並按 {{shortcut}}。\",\n    \"Unpin to hide the the button while keeping the filter.\": \"取消固定以隱藏按鈕，同時保留篩選器。\",\n    \"Useful for storing the timestamp or author of a new record, data cleaning, and more.\": \"對於儲存新記錄的時間戳記或作者、資料清理等非常有用。\",\n    \"Anchor Links\": \"錨點連結\",\n    \"Click the Add new button to create new documents or workspaces, or import data.\": \"點選新增按鈕以建立新文件或工作區，或匯入資料。\",\n    \"Nested Filtering\": \"巢狀篩選\",\n    \"relational\": \"關聯性\",\n    \"Apply conditional formatting to rows based on formulas.\": \"根據公式對列套用條件格式。\",\n    \"Add new\": \"新增\",\n    \"Click on “Open row styles” to apply conditional formatting to rows.\": \"點選“開啟列樣式”以對列套用條件格式。\",\n    \"Pinned filters are displayed as buttons above the widget.\": \"固定的篩選器將以按鈕的形式顯示在小工具上方。\",\n    \"Link your new widget to an existing widget on this page.\": \"將您的新小工具連結到此頁面上的現有小工具。\",\n    \"Use the \\\\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.\": \"使用 \\\\u{1D6BA} 圖示建立摘要（或樞紐）表格，以進行總計或小計。\",\n    \"Lookups return data from related tables.\": \"查詢會回傳相關表格的資料。\",\n    \"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.\": \"使用 𝚺 圖示建立摘要（或樞紐）表格，以進行總計或小計。\",\n    \"Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.\": \"在每個儲存格中點選 {{EyeHideIcon}} 可以隱藏此檢視中的欄位，而不刪除它。\",\n    \"Can't find the right columns? Click 'Change Widget' to select the table with events data.\": \"找不到正確的欄位？點選 '變更小工具' 以選擇包含事件資料的表格。\",\n    \"Only those rows will appear which match all of the filters.\": \"只有符合所有篩選條件的列才會顯示。\",\n    \"Custom Widgets\": \"自訂小工具\",\n    \"This is the secret to Grist's dynamic and productive layouts.\": \"這是 Grist 動態和高效版面配置的秘密。\",\n    \"Editing Card Layout\": \"編輯卡片版面配置\",\n    \"Linking Widgets\": \"連結小工具\",\n    \"Raw Data page\": \"原始資料頁面\",\n    \"Selecting Data\": \"選擇資料\",\n    \"Access Rules\": \"存取規則\",\n    \"Learn more.\": \"了解更多。\",\n    \"Try out changes in a copy, then decide whether to replace the original with your edits.\": \"在副本中嘗試變更，然後決定是否用您的編輯內容替換原始內容。\",\n    \"The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.\": \"原始資料頁面列出了您的文件中的所有資料表格，包括摘要表格和未包含在頁面版面配置中的表格。\",\n    \"Formulas that trigger in certain cases, and store the calculated value as data.\": \"在某些情況下觸發的公式，並將計算值儲存為資料。\",\n    \"Select the table containing the data to show.\": \"選擇包含要顯示的資料的表格。\",\n    \"Pinning Filters\": \"固定篩選器\",\n    \"Reference columns are the key to {{relational}} data in Grist.\": \"參考欄位是 Grist 中 {{relational}} 資料的關鍵。\",\n    \"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.\": \"參考欄位中的儲存格總是識別該表格中的一個{{entire}}記錄，但您可以選擇從該記錄中顯示哪個欄位。\",\n    \"Use reference columns to relate data in different tables.\": \"使用參考欄位將不同表格中的資料相關聯。\",\n    \"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}\": \"在 Grist 中直接建立簡易表單，並透過我們的新工具一鍵分享。{{learnMoreButton}}\",\n    \"Forms are here!\": \"表單功能上線了！\",\n    \"Learn more\": \"了解更多\",\n    \"These rules are applied after all column rules have been processed, if applicable.\": \"這些規則會在所有欄位規則處理完畢後套用（如適用）。\",\n    \"Example: {{example}}\": \"例:{{example}}\",\n    \"Filter displayed dropdown values with a condition.\": \"透過條件篩選下拉選單顯示內容.\",\n    \"Community widgets are created and maintained by Grist community members.\": \"社群小工具由 Grist 社群成員建立並維護。\",\n    \"Creates a reverse column in target table that can be edited from either end.\": \"在目標資料表中建立一個反向欄位，可從任一端進行編輯。\",\n    \"This limitation occurs when one end of a two-way reference is configured as a single Reference.\": \"當雙向參照的一端被設定為單一參照時，會出現此限制。\",\n    \"To allow multiple assignments, change the type of the Reference column to Reference List.\": \"若要允許多重指派，請將參照欄位的類型變更為參照清單。\",\n    \"This limitation occurs when one column in a two-way reference has the Reference type.\": \"當雙向參照中的其中一個欄位為參照類型時，會出現此限制。\",\n    \"To allow multiple assignments, change the referenced column's type to Reference List.\": \"若要允許多重指派，請將被參照欄位的類型變更為參照清單。\",\n    \"Two-way references are not currently supported for Formula or Trigger Formula columns\": \"目前不支援在公式欄位或觸發公式欄位中使用雙向參照\",\n    \"The preview below this header shows how the selected user will see this document\": \"此標題下方的預覽顯示所選使用者查看此文件時的畫面\",\n    \"[Learn more.]({{link}})\": \"[了解更多]({{link}})\",\n    \"Summary tables can only contain formula columns.\": \"摘要表只能包含公式欄位。\",\n    \"Manage users and resources in a Grist installation.\": \"管理 Grist 安裝的使用者與資源。\",\n    \"The new Grist Assistant is here!\": \"全新的 Grist 助理上線了！\",\n    \"Formulas support many Excel functions and full Python syntax.\": \"公式支援多種 Excel 函數以及完整的 Python 語法。\",\n    \"Creates a new Reference List column in the target table, with both this and the target columns editable and synchronized.\": \"在目標資料表中建立新的參照清單欄位，並可同步編輯此欄位與目標欄位的內容。\",\n    \"Internal storage means all attachments are stored in the document SQLite file, while external storage indicates all attachments are stored in the same external storage.\": \"內部儲存表示所有附件都儲存在文件的 SQLite 檔案中，而外部儲存則表示所有附件都儲存在同一個外部儲存空間中。\",\n    \"This allows you to add attachments that are missing from external storage, e.g. in an imported document. Only .tar attachment archives downloaded from Grist can be uploaded here.\": \"這可讓您補充外部儲存中遺漏的附件，例如匯入的文件中所缺少的部分。此處僅接受從 Grist 下載的 .tar 附件壓縮檔上傳。\",\n    \"Understand, modify and work with your data and formulas with the help of Grist's new AI Assistant!\": \"透過 Grist 全新的 AI 助理，協助您理解、修改並操作資料與公式！\",\n    \"This form is created by a Grist user, and is not endorsed by Grist Labs. Do not submit passwords through this form, and be careful with links in it. Report malicious forms to [{{mail}}](mailto:{{mail}}).\": \"此表單由 Grist 使用者建立，並未經 Grist Labs 認可。請勿透過此表單提交密碼，並留意其中的連結。如發現惡意表單，請回報至 [{{mail}}](mailto:{{mail}})。\",\n    \"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.\": \"您可以從下拉選單中選擇可用的小工具，或輸入完整的網址以嵌入您自己的小工具。\",\n    \"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.\": \"公式支援多種 Excel 函數、完整的 Python 語法，並內建實用的 AI 助手。\"\n  },\n  \"SortConfig\": {\n    \"Add column\": \"新增欄位\",\n    \"Natural sort\": \"自然排序\",\n    \"Search Columns\": \"搜尋欄位\",\n    \"Empty values last\": \"空值放最後\",\n    \"Update data\": \"更新資料\",\n    \"Use choice position\": \"使用選擇位置\"\n  },\n  \"Clipboard\": {\n    \"Unavailable Command\": \"無效的指令\",\n    \"Got it\": \"瞭解了\",\n    \"The {{action}} menu command is not available in this browser. You can still {{action}} by using the keyboard shortcut {{shortcut}}.\": \"此瀏覽器不支援 {{action}} 選單指令。您仍可使用鍵盤快捷鍵 {{shortcut}} 來執行 {{action}}。\"\n  },\n  \"SupportGristPage\": {\n    \"You have opted out of telemetry.\": \"您已選擇不參與遙測。\",\n    \"Support Grist\": \"支援 Grist\",\n    \"Opt out of Telemetry\": \"選擇不參與遙測\",\n    \"GitHub Sponsors page\": \"GitHub 贊助者頁面\",\n    \"Sponsor Grist Labs on GitHub\": \"在 GitHub 上贊助 Grist Labs\",\n    \"Manage Sponsorship\": \"管理贊助\",\n    \"Help Center\": \"說明中心\",\n    \"We only collect usage statistics, as detailed in our {{link}}, never document contents.\": \"我們只收集使用統計資料，如我們的 {{link}} 中詳述，絕不收集文件內容。\",\n    \"You can opt out of telemetry at any time from this page.\": \"您可以隨時在此頁面選擇不參與遙測。\",\n    \"Home\": \"首頁\",\n    \"This instance is opted out of telemetry. Only the site administrator has permission to change this.\": \"此實例已選擇不參與遙測。只有網站管理員有權更改此設定。\",\n    \"Telemetry\": \"遙測\",\n    \"Opt in to Telemetry\": \"選擇參與遙測\",\n    \"You have opted in to telemetry. Thank you!\": \"您已選擇參與遙測。謝謝您！\",\n    \"This instance is opted in to telemetry. Only the site administrator has permission to change this.\": \"此實例已選擇參與遙測。只有網站管理員有權更改此設定。\",\n    \"GitHub\": \"GitHub\",\n    \"Sponsor\": \"贊助\",\n    \"Grist software is developed by Grist Labs, which offers free and paid hosted plans. We also make Grist code available under a standard free and open OSS license (Apache 2.0) on {{link}}.\": \"Grist 軟體由 Grist Labs 開發，並提供免費與付費的雲端託管方案。我們也在 {{link}} 上以標準的自由開源軟體授權（Apache 2.0）提供 Grist 的原始碼。\",\n    \"Support Grist by opting in to telemetry, which helps us understand how the product is used, so that we can prioritize future improvements.\": \"透過啟用遙測功能來支持 Grist，這有助於我們了解產品的使用情況，從而優先改進未來功能。\",\n    \"We are a small and determined team. Your support matters a lot to us. It also shows to others that there is a determined community behind this product.\": \"我們是一個小而堅定的團隊，您的支持對我們意義重大。這也向其他人展現出，這個產品背後有一個充滿決心的社群。\",\n    \"You can support Grist open-source development by sponsoring us on our {{link}}.\": \"您可以透過在我們的 {{link}} 上贊助，支持 Grist 的開源開發。\"\n  },\n  \"VisibleFieldsConfig\": {\n    \"Show {{label}}\": \"顯示 {{label}}\",\n    \"Cannot drop items into Hidden Fields\": \"無法將項目拖放至隱藏欄位\",\n    \"Hidden {{label}}\": \"隱藏 {{label}}\",\n    \"Hidden Fields cannot be reordered\": \"無法重新排序隱藏欄位\",\n    \"Visible {{label}}\": \"可見 {{label}}\",\n    \"Select all\": \"全選\",\n    \"Hide {{label}}\": \"隱藏 {{label}}\",\n    \"Clear\": \"清除\"\n  },\n  \"ColumnFilterMenu\": {\n    \"Search values\": \"搜尋值\",\n    \"All shown\": \"全部顯示\",\n    \"Other Matching\": \"其他相符項目\",\n    \"All except\": \"全部之外\",\n    \"Start\": \"開始\",\n    \"Other Non-Matching\": \"其他不相符項目\",\n    \"No matching values\": \"沒有相符的值\",\n    \"End\": \"結束\",\n    \"Search\": \"搜尋\",\n    \"Max\": \"最大值\",\n    \"Others\": \"其他\",\n    \"Other values\": \"其他值\",\n    \"Min\": \"最小值\",\n    \"All\": \"全部\",\n    \"Future values\": \"未來值\",\n    \"Filter by Range\": \"依照範圍篩選\",\n    \"None\": \"無\"\n  },\n  \"FieldConfig\": {\n    \"Column options are limited in summary tables.\": \"摘要表格中的欄位選項有限。\",\n    \"Set formula\": \"設定公式\",\n    \"Data columns_other\": \"資料欄位\",\n    \"DESCRIPTION\": \"描述\",\n    \"Clear and reset\": \"清除並重設\",\n    \"Convert column to data\": \"將欄位轉換為資料\",\n    \"Empty columns_other\": \"空欄位\",\n    \"COLUMN LABEL AND ID\": \"欄位標籤與 ID\",\n    \"Empty columns_one\": \"空欄位\",\n    \"Formula columns_other\": \"公式欄位\",\n    \"Formula columns_one\": \"公式欄位\",\n    \"Make into data column\": \"轉換為資料欄位\",\n    \"Enter formula\": \"輸入公式\",\n    \"Clear and make into formula\": \"清除並轉換為公式\",\n    \"Mixed Behavior\": \"混合行為\",\n    \"Convert to trigger formula\": \"轉換為觸發公式\",\n    \"COLUMN BEHAVIOR\": \"欄位行為\",\n    \"Data columns_one\": \"資料欄位\",\n    \"TRIGGER FORMULA\": \"觸發公式\",\n    \"Set trigger formula\": \"設定觸發公式\"\n  },\n  \"DocHistory\": {\n    \"Compare to current\": \"與目前版本比較\",\n    \"Open snapshot\": \"開啟快照\",\n    \"Snapshots are unavailable.\": \"快照不可用。\",\n    \"Snapshots\": \"快照\",\n    \"Activity\": \"活動\",\n    \"Compare to previous\": \"與前一版本比較\",\n    \"Beta\": \"測試版\",\n    \"Only owners have access to snapshots for documents with access rules.\": \"只有擁有者能存取具有存取規則的文件快照.\"\n  },\n  \"UserManager\": {\n    \"Anyone with link \": \"任何持有連結的人 \",\n    \"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}}.\": \"一旦您移除了自己的存取權限，除非有其他具有足夠存取權限的人協助，否則您將無法復原對 {{name}} 的存取權限。\",\n    \"{{limitAt}} of {{limitTop}} {{collaborator}}s\": \"{{collaborator}} 的 {{limitAt}} / {{limitTop}}\",\n    \"Your role for this team site\": \"您在此團隊網站中的角色\",\n    \"Copy link\": \"複製連結\",\n    \"User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.\": \"使用者對 {{resource}} 有檢視存取權限，這是由於手動設定了內部資源的存取權限。如果在此處移除，此使用者將失去對內部資源的存取權限。\",\n    \"User may not modify their own access.\": \"使用者不得修改自己的存取權限。\",\n    \"member\": \"成員\",\n    \"Add {{member}} to your team\": \"將 {{member}} 加入您的團隊\",\n    \"Collaborator\": \"協作者\",\n    \"Link copied to clipboard\": \"連結已複製到剪貼簿\",\n    \"team site\": \"團隊網站\",\n    \"Create a team to share with more people\": \"建立一個團隊以與更多人分享\",\n    \"guest\": \"訪客\",\n    \"Public access: \": \"公開存取： \",\n    \"Team member\": \"團隊成員\",\n    \"Manage members of team site\": \"管理團隊網站的成員\",\n    \"Off\": \"關閉\",\n    \"free collaborator\": \"免費協作者\",\n    \"Save & \": \"儲存 & \",\n    \"Outside collaborator\": \"外部協作者\",\n    \"{{collaborator}} limit exceeded\": \"{{collaborator}} 限制已超過\",\n    \"User inherits permissions from {{parent})}. To remove,           set 'Inherit access' option to 'None'.\": \"使用者繼承自 {{parent})} 的權限。要移除，請將 '繼承存取' 選項設為 '無'。\",\n    \"Your role for this {{resourceType}}\": \"您在此 {{resourceType}} 中的角色\",\n    \"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}} 的存取權限。\",\n    \"Close\": \"關閉\",\n    \"Allow anyone with the link to open.\": \"允許任何持有連結的人開啟。\",\n    \"No default access allows access to be         granted to individual documents or workspaces, rather than the full team site.\": \"無預設存取權限允許將存取權限授予個別文件或工作區，而不是整個團隊網站。\",\n    \"Invite people to {{resourceType}}\": \"邀請人們到 {{resourceType}}\",\n    \"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"公開存取權限繼承自 {{parent}}。要移除，請將 '繼承存取' 選項設為 '無'。\",\n    \"Remove my access\": \"移除我的存取權限\",\n    \"Public access\": \"公開存取\",\n    \"Cancel\": \"取消\",\n    \"Grist support\": \"Grist 支援\",\n    \"You are about to remove your own access to this {{resourceType}}\": \"您即將移除自己對此 {{resourceType}} 的存取權限\",\n    \"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.\": \"使用者繼承自 {{parent}} 的權限。要移除，請將 '繼承存取' 選項設為 '無'。\",\n    \"Guest\": \"訪客\",\n    \"Invite multiple\": \"邀請多人\",\n    \"Confirm\": \"確認\",\n    \"On\": \"開啟\",\n    \"Open Access Rules\": \"開啟存取規則\",\n    \"No default access allows access to be granted to individual documents or workspaces, rather than the full team site.\": \"無預設存取權限允許將存取權限授予個別文件或工作區，而不是整個團隊網站。\",\n    \"Inherit access: \": \"繼承存取權限： \",\n    \"Access overview\": \"存取權限總覽\"\n  },\n  \"WelcomeQuestions\": {\n    \"Welcome to Grist!\": \"歡迎來到 Grist！\",\n    \"HR & Management\": \"人力資源與管理\",\n    \"Sales\": \"銷售\",\n    \"Marketing\": \"行銷\",\n    \"Type here\": \"在此輸入\",\n    \"Other\": \"其他\",\n    \"Product Development\": \"產品開發\",\n    \"Media Production\": \"媒體製作\",\n    \"What brings you to Grist? Please help us serve you better.\": \"您是怎麼認識 Grist 的？請告訴我們如何能更好地為您服務。\",\n    \"Education\": \"教育\",\n    \"IT & Technology\": \"資訊科技與科技\",\n    \"Finance & Accounting\": \"財務與會計\",\n    \"Research\": \"研究\"\n  },\n  \"PagePanels\": {\n    \"Open creator panel\": \"開啟建立者面板\",\n    \"Close Creator Panel\": \"關閉建立者面板\"\n  },\n  \"GristDoc\": {\n    \"go to webhook settings\": \"前往 webhook 設定\",\n    \"Saved linked section {{title}} in view {{name}}\": \"已在檢視 {{name}} 中儲存連結區段 {{title}}\",\n    \"Added new linked section to view {{viewName}}\": \"已新增新的連結區段到檢視 {{viewName}}\",\n    \"Import from file\": \"從檔案匯入\",\n    \"New changes are temporarily suspended. Webhooks queue overflowed. Please check webhooks settings, remove invalid webhooks, and clean the queue.\": \"新變更已暫時暫停。Webhooks 佇列已溢出。請檢查 Webhooks 設定，移除無效的 Webhook，並清理佇列。\"\n  },\n  \"AccessRules\": {\n    \"Permission to access the document in full when needed\": \"需要時存取整份文件的權限\",\n    \"Add column rule\": \"新增欄位規則\",\n    \"Reset\": \"重設\",\n    \"Lookup Column\": \"查詢欄位\",\n    \"Remove {{- tableId }} rules\": \"移除 {{- tableId }} 規則\",\n    \"Remove {{- name }} user attribute\": \"移除 {{- name }} 使用者屬性\",\n    \"Permissions\": \"權限\",\n    \"Enter Condition\": \"輸入條件\",\n    \"Everyone Else\": \"其他所有人\",\n    \"Saved\": \"已儲存\",\n    \"Remove column {{- colId }} from {{- tableId }} rules\": \"從 {{- tableId }} 規則中移除欄位 {{- colId }}\",\n    \"Allow everyone to view Access Rules.\": \"允許所有人檢視存取規則。\",\n    \"Type message to display when this rule blocks an action…\": \"輸入訊息…\",\n    \"Lookup Table\": \"查詢表格\",\n    \"View as\": \"檢視為\",\n    \"Add table rules\": \"新增表格規則\",\n    \"Invalid\": \"無效\",\n    \"Condition\": \"條件\",\n    \"Delete table rules\": \"刪除表格規則\",\n    \"Permission to view Access Rules\": \"檢視存取規則的權限\",\n    \"User Attributes\": \"使用者屬性\",\n    \"Default rules\": \"預設規則\",\n    \"Attribute name\": \"屬性名稱\",\n    \"When adding table rules, automatically add a rule to grant OWNER full access.\": \"新增表格規則時，自動新增一條授予擁有者完整存取權限的規則。\",\n    \"Add user attributes\": \"新增使用者屬性\",\n    \"Permission to edit document structure\": \"編輯文件結構的權限\",\n    \"Attribute to Look Up\": \"要查詢的屬性\",\n    \"Seed rules\": \"種子規則\",\n    \"Everyone\": \"所有人\",\n    \"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.\": \"允許所有人複製整份文件，或在完整模式下檢視。\\n對於範例和範本很有用，但不適用於敏感資料。\",\n    \"Add Default Rule\": \"新增預設規則\",\n    \"Allow editors to edit structure (e.g., modify and delete tables, columns, and layouts) and write formulas. Regardless of the permissions set at the table and column level, formulas can still be edited and can access all data.\": \"允許編輯者編輯結構（例如，修改和刪除表格、欄位、版面配置），並編寫公式，這些公式可以存取所有資料，無論讀取限制如何。\",\n    \"This default should be changed if editors' access is to be limited. \": \"如果要限制編輯者的存取權限，則應更改此預設值。 \",\n    \"Save\": \"儲存\",\n    \"Rules for table \": \"表格規則 \",\n    \"Checking...\": \"正在檢查…\",\n    \"Special rules\": \"特殊規則\",\n    \"Add table-wide rule\": \"增加全表規則\"\n  },\n  \"FieldEditor\": {\n    \"It should be impossible to save a plain data value into a formula column\": \"不應該將一般資料值儲存到公式欄位\",\n    \"Unable to finish saving edited cell\": \"無法完成儲存已編輯的儲存格\"\n  },\n  \"DuplicateTable\": {\n    \"Only the document default access rules will apply to the copy.\": \"只有文件的預設存取規則會套用到副本。\",\n    \"Copy all data in addition to the table structure.\": \"除了表格結構外，也複製所有資料。\",\n    \"Name for new table\": \"新表格的名稱\",\n    \"Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}\": \"通常，使用連結檢視來分段資料會比複製表格更好。{{link}}\"\n  },\n  \"ColumnInfo\": {\n    \"Cancel\": \"取消\",\n    \"COLUMN ID: \": \"欄位 ID: \",\n    \"Save\": \"儲存\",\n    \"COLUMN DESCRIPTION\": \"欄位描述\",\n    \"COLUMN LABEL\": \"欄位標籤\"\n  },\n  \"AccountPage\": {\n    \"Theme\": \"主題\",\n    \"API\": \"API\",\n    \"Change password\": \"更改密碼\",\n    \"Email\": \"電子郵件\",\n    \"Password & security\": \"密碼和安全\",\n    \"Account settings\": \"帳戶設定\",\n    \"Two-factor authentication\": \"雙因素驗證\",\n    \"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.\": \"雙因素驗證是為您的 Grist 帳戶設計的額外安全層，即使有人知道您的密碼，也可以確保只有您可以存取您的帳戶。\",\n    \"Language\": \"語言\",\n    \"Edit\": \"編輯\",\n    \"Names only allow letters, numbers and certain special characters\": \"名稱只允許字母、數字和某些特殊字元\",\n    \"Login method\": \"登入方式\",\n    \"API Key\": \"API 金鑰\",\n    \"Name\": \"名稱\",\n    \"Allow signing in to this account with Google\": \"允許使用 Google 登入此帳戶\",\n    \"Save\": \"儲存\"\n  },\n  \"DiscussionEditor\": {\n    \"Show resolved comments\": \"顯示已解決的評論\",\n    \"Save\": \"儲存\",\n    \"Reply to a comment\": \"回覆評論\",\n    \"Comment\": \"評論\",\n    \"Started discussion\": \"開始討論\",\n    \"Write a comment\": \"撰寫評論\",\n    \"Cancel\": \"取消\",\n    \"Only current page\": \"僅限目前頁面\",\n    \"Reply\": \"回覆\",\n    \"Marked as resolved\": \"標記為已解決\",\n    \"Remove\": \"移除\",\n    \"Open\": \"開啟\",\n    \"Only my threads\": \"僅限我的討論串\",\n    \"Edit\": \"編輯\",\n    \"Resolve\": \"解決\",\n    \"Showing last {{nb}} comments\": \"顯示最後 {{nb}} 則評論\",\n    \"Remove thread\": \"刪除討論串\",\n    \"updated\": \"已更新\"\n  },\n  \"CellContextMenu\": {\n    \"Copy anchor link\": \"複製錨點鏈結\",\n    \"Delete {{count}} rows_one\": \"刪除列\",\n    \"Insert row below\": \"在下方插入列\",\n    \"Reset {{count}} entire columns_other\": \"重設 {{count}} 整欄\",\n    \"Insert row\": \"插入列\",\n    \"Copy\": \"複製\",\n    \"Delete {{count}} columns_one\": \"刪除欄位\",\n    \"Delete {{count}} columns_other\": \"刪除 {{count}} 欄\",\n    \"Duplicate rows_one\": \"複製列\",\n    \"Insert row above\": \"在上方插入列\",\n    \"Delete {{count}} rows_other\": \"刪除 {{count}} 列\",\n    \"Clear values\": \"清除數值\",\n    \"Clear cell\": \"清除儲存格\",\n    \"Comment\": \"評論\",\n    \"Duplicate rows_other\": \"複製列\",\n    \"Reset {{count}} columns_one\": \"重設欄位\",\n    \"Insert column to the right\": \"向右插入欄位\",\n    \"Filter by this value\": \"依此數值篩選\",\n    \"Cut\": \"剪下\",\n    \"Reset {{count}} columns_other\": \"重設 {{count}} 個欄位\",\n    \"Reset {{count}} entire columns_one\": \"重設整欄\",\n    \"Insert column to the left\": \"向左插入欄位\",\n    \"Paste\": \"貼上\",\n    \"Copy with headers\": \"帶標頭複製\"\n  },\n  \"Importer\": {\n    \"Merge rows that match these fields:\": \"將符合這些欄位條件的資料進行合併：\",\n    \"Column mapping\": \"欄位對應\",\n    \"Grist column\": \"Grist 欄位\",\n    \"{{count}} unmatched field_one\": \"{{count}} 個未配對的欄位\",\n    \"{{count}} unmatched field in import_one\": \"匯入中有 {{count}} 個未配對的欄位\",\n    \"Revert\": \"還原\",\n    \"Skip Import\": \"跳過匯入\",\n    \"{{count}} unmatched field_other\": \"{{count}} 個未配對的欄位\",\n    \"Select fields to match on\": \"選擇要配對的欄位\",\n    \"New Table\": \"新表格\",\n    \"Skip\": \"跳過\",\n    \"Column Mapping\": \"欄位對應\",\n    \"Destination table\": \"目標表格\",\n    \"Skip Table on Import\": \"在匯入時跳過表格\",\n    \"Import from file\": \"從檔案匯入\",\n    \"{{count}} unmatched field in import_other\": \"匯入中有 {{count}} 個未配對的欄位\",\n    \"Update existing records\": \"更新現有紀錄\",\n    \"Source column\": \"來源欄位\"\n  },\n  \"WelcomeTour\": {\n    \"Make it relational! Use the {{ref}} type to link tables. \": \"讓它具有關聯性！使用 {{ref}} 類型連結表格。 \",\n    \"Enter\": \"輸入\",\n    \"Use {{helpCenter}} for documentation or questions.\": \"使用 {{helpCenter}} 查詢文件或問題。\",\n    \"creator panel\": \"建立者面板\",\n    \"Sharing\": \"分享中\",\n    \"Configuring your document\": \"設定您的文件\",\n    \"Reference\": \"參考\",\n    \"Editing Data\": \"編輯資料\",\n    \"template library\": \"範本庫\",\n    \"Use {{addNew}} to add widgets, pages, or import more data. \": \"使用 {{addNew}} 新增小工具、頁面或匯入更多資料。 \",\n    \"Use the Share button ({{share}}) to share the document or export data.\": \"使用分享按鈕 ({{share}}) 分享文件或匯出資料。\",\n    \"Help Center\": \"說明中心\",\n    \"Browse our {{templateLibrary}} to discover what's possible and get inspired.\": \"瀏覽我們的 {{templateLibrary}} 以發現可能性並獲得靈感。\",\n    \"Share\": \"分享\",\n    \"Set formatting options, formulas, or column types, such as dates, choices, or attachments. \": \"設定格式選項、公式或欄位類型，例如日期、選擇或附件。 \",\n    \"Flying higher\": \"飛得更高\",\n    \"Customizing columns\": \"自訂欄位\",\n    \"Double-click or hit {{enter}} on a cell to edit it. \": \"雙擊或在儲存格上按 {{enter}} 進行編輯。 \",\n    \"Welcome to Grist!\": \"歡迎使用 Grist！\",\n    \"Add new\": \"新增\",\n    \"Toggle the {{creatorPanel}} to format columns, \": \"切換 {{creatorPanel}} 以格式化欄位， \",\n    \"convert to card view, select data, and more.\": \"轉換為卡片檢視、選擇資料等等。\",\n    \"Building up\": \"建立中\",\n    \"Start with {{equal}} to enter a formula.\": \"以 {{equal}} 開始輸入公式。\"\n  },\n  \"buildViewSectionDom\": {\n    \"Not all data is shown\": \"並未顯示所有資料\",\n    \"No row selected in {{title}}\": \"{{title}} 中未選擇任何列\",\n    \"No data\": \"無資料\"\n  },\n  \"ViewSectionMenu\": {\n    \"FILTER\": \"篩選\",\n    \"(customized)\": \"(自訂)\",\n    \"Revert\": \"還原\",\n    \"Save\": \"儲存\",\n    \"Custom options\": \"自訂選項\",\n    \"SORT\": \"排序\",\n    \"Update Sort&Filter settings\": \"更新排序與篩選設定\",\n    \"(empty)\": \"(空)\",\n    \"(modified)\": \"(已修改)\"\n  },\n  \"Tools\": {\n    \"Delete document tour?\": \"刪除文件導覽？\",\n    \"TOOLS\": \"工具\",\n    \"Delete\": \"刪除\",\n    \"Settings\": \"設定\",\n    \"Access Rules\": \"存取規則\",\n    \"Validate Data\": \"驗證資料\",\n    \"How-to Tutorial\": \"操作教學\",\n    \"Tour of this Document\": \"本文件的導覽\",\n    \"Code view\": \"程式碼檢視\",\n    \"Return to viewing as yourself\": \"返回以自己的身份檢視\",\n    \"Raw data\": \"原始資料\",\n    \"Document history\": \"文件歷史\",\n    \"API console\": \"API 控制台\"\n  },\n  \"menus\": {\n    \"Reference List\": \"參考列表\",\n    \"Integer\": \"整數\",\n    \"* Workspaces are available on team plans. \": \"* 工作區在團隊方案中可用。 \",\n    \"Text\": \"文字\",\n    \"Attachment\": \"附件\",\n    \"Upgrade now\": \"現在升級\",\n    \"Toggle\": \"切換\",\n    \"Choice\": \"選擇\",\n    \"Select fields\": \"選擇欄位\",\n    \"Choice List\": \"選擇列表\",\n    \"Reference\": \"參考\",\n    \"Search columns\": \"搜尋欄位\",\n    \"Any\": \"任何\",\n    \"Numeric\": \"數值\",\n    \"DateTime\": \"日期時間\",\n    \"Date\": \"日期\",\n    \"By Name\": \"依名稱\",\n    \"By Date Modified\": \"依修改日期\",\n    \"Light\": \"日間\",\n    \"Custom\": \"自訂\"\n  },\n  \"DocPageModel\": {\n    \"Sorry, access to this document has been denied. [{{error}}]\": \"抱歉，您被拒絕存取此文件。[{{error}}]\",\n    \"Add empty table\": \"新增空表格\",\n    \"You do not have edit access to this document\": \"您無法編輯此文件\",\n    \"Add widget to page\": \"新增小工具到頁面\",\n    \"Add page\": \"新增頁面\",\n    \"Document owners can attempt to recover the document. [{{error}}]\": \"文件擁有者可以嘗試復原文件。[{{error}}]\",\n    \"Reload\": \"重新載入\",\n    \"Error accessing document\": \"存取文件時發生錯誤\",\n    \"Enter recovery mode\": \"進入復原模式\",\n    \"You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]\": \"您可以嘗試重新載入文件，或使用復原模式。復原模式將文件完全開放給擁有者，並對其他人設為不可存取。同時也會停用公式。[{{error}}]\",\n    \"Please reload the document and if the error persist, contact the document owners to attempt a document recovery. [{{error}}]\": \"請重新載入文件，如果錯誤仍然存在，請聯絡文件擁有者以嘗試進行文件修復。［{{error}}］\"\n  },\n  \"DocumentSettings\": {\n    \"Ok\": \"確定\",\n    \"Manage Webhooks\": \"管理 Webhooks\",\n    \"API\": \"API\",\n    \"Save\": \"儲存\",\n    \"Document ID copied to clipboard\": \"已複製文件 ID 到剪貼簿\",\n    \"Local currency ({{currency}})\": \"本地貨幣 ({{currency}})\",\n    \"Save and Reload\": \"儲存並重新載入\",\n    \"Time Zone:\": \"時區：\",\n    \"Webhooks\": \"Webhooks\",\n    \"Currency:\": \"貨幣：\",\n    \"Engine (experimental {{span}} change at own risk):\": \"引擎（實驗性 {{span}} 自行承擔風險變更）：\",\n    \"Document settings\": \"文件設定\",\n    \"Locale:\": \"語言環境：\",\n    \"This document's ID (for API use):\": \"此文件的 ID（供 API 使用）：\",\n    \"Manage webhooks\": \"管理 Webhooks\",\n    \"API console\": \"API 控制介面\",\n    \"API URL copied to clipboard\": \"API 網址已複製到剪貼簿\",\n    \"API documentation.\": \"API 技術文件.\",\n    \"Base doc URL: {{docApiUrl}}\": \"基本文件網址: {{docApiUrl}}\",\n    \"Coming soon\": \"敬請期待\",\n    \"Copy to clipboard\": \"複製到剪貼簿\",\n    \"Currency\": \"貨幣\",\n    \"Data engine\": \"數據引擎\",\n    \"Default for DateTime columns\": \"DateTime 欄位的預設值\",\n    \"Document ID\": \"文件 ID\",\n    \"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}\": \"當 REST API 需要 {{docId}} 時，請使用此文件 ID。參見 {{apiURL}}\",\n    \"Find slow formulas\": \"尋找 slow 公式\",\n    \"For currency columns\": \"貨幣欄\",\n    \"For number and date formats\": \"數字和日期格式\",\n    \"Formula times\": \"公式乘法\",\n    \"Hard reset of data engine\": \"強制重置數據引擎\",\n    \"ID for API use\": \"API 專用 ID\",\n    \"Locale\": \"語言地區設定\",\n    \"Notify other services on doc changes\": \"在文件變更時通知其他服務\",\n    \"Python\": \"Python\",\n    \"Python version used\": \"Python 版本使用\",\n    \"Reload\": \"重新載入\",\n    \"Time zone\": \"時區\",\n    \"Try API calls from the browser\": \"從瀏覽器嘗試發送 API 請求\",\n    \"python2 (legacy)\": \"python2 (舊版)\",\n    \"python3 (recommended)\": \"python3 (建議)\",\n    \"Cancel\": \"取消\",\n    \"Force reload the document while timing formulas, and show the result.\": \"在計算公式時間的同時強制重新載入文件，並顯示結果。\",\n    \"Reload data engine\": \"重新載入數據引擎\",\n    \"Reload data engine?\": \"重新載入數據引擎?\",\n    \"Start timing\": \"開始計時\",\n    \"Stop timing...\": \"停止計時...\",\n    \"Time reload\": \"重新載入時間\",\n    \"Timing is on\": \"計時啟用\",\n    \"You can make changes to the document, then stop timing to see the results.\": \"您可以先修改文件，然後停止計時以查看結果。\",\n    \"Only available to document editors\": \"僅限文件編輯者使用\",\n    \"Only available to document owners\": \"僅限文件擁有者使用\",\n    \"Template mode\": \"範本模式\",\n    \"Change document type\": \"變更文件類型\",\n    \"Edit\": \"編輯\",\n    \"Change nature of document\": \"修改文件屬性\",\n    \"Normal document behavior. All users work on the same copy of the document.\": \"一般文件行為：所有使用者在同一份文件副本上共同作業。\",\n    \"Template\": \"範本\",\n    \"Formula timer\": \"公式計時器\",\n    \"Document automatically opens in {{fiddleModeDocUrl}}. Anyone may edit, which will create a new unsaved copy.\": \"文件將自動在 {{fiddleModeDocUrl}} 中開啟。任何人都可以編輯，系統將建立一個新的未儲存副本。\",\n    \"fiddle mode\": \"沙盒模式\",\n    \"Tutorial\": \"教學\",\n    \"Document automatically opens as a user-specific copy.\": \"文件將自動開啟為個人專屬副本。\",\n    \"Confirm change\": \"確認修改\",\n    \"This will perform a hard reload of the data engine. This may help if the data engine is stuck in an infinite loop, is indefinitely processing the latest change, or has crashed. No data will be lost, except possibly currently pending actions.\": \"這將強制重新載入資料引擎。當資料引擎陷入無限迴圈、無限處理最近的變更，或已當機時，這可能有助於恢復。資料不會遺失，但當前尚未完成的操作可能會丟失。\",\n    \"Once you start timing, Grist will measure the time it takes to evaluate each formula. This allows diagnosing which formulas are responsible for slow performance when a document is first opened, or when a document responds to changes.\": \"開始計時後，Grist 將會測量每個公式的運算時間。這有助於診斷在文件初次開啟或對變更做出回應時，哪些公式導致效能變慢。\",\n    \"**Some existing attachments are still external**.\": \"**部分現有的附件仍為外部檔案**.\",\n    \"**Some existing attachments are still internal** (stored in SQLite file).\": \"**部分現有的附件仍為外部檔案** (存於 SQLite 檔).\",\n    \"Attachment storage\": \"附件儲存空間\",\n    \"Being transfer\": \"傳輸中\",\n    \"Click \\\"Start transfer\\\" to transfer those to External storage.\": \"點擊「開始傳輸」以將這些檔案轉移至外部儲存空間。\",\n    \"Click \\\"Start transfer\\\" to transfer those to Internal storage (stored in the document SQLite file).\": \"點擊「開始傳輸」以將這些檔案轉移至內部儲存空間（儲存在文件的 SQLite 檔案中）.\",\n    \"Newly uploaded attachments will be placed in External storage.\": \"新上傳的附件將儲存在外部儲存空間中。\",\n    \"Newly uploaded attachments will be placed in Internal storage.\": \"新上傳的附件將儲存在內部儲存空間中。\",\n    \"No external stores available\": \"沒有可用的外部儲存空間\",\n    \"Preferred storage for this document\": \"文件偏好儲存空間\",\n    \"Start transfer\": \"開始傳輸\",\n    \"External\": \"外部\",\n    \"Internal\": \"內部\",\n    \"Transfer in progress\": \"傳輸進行中\",\n    \"**Some existing attachments are still [external]({{externalLink}})**.\": \"**部分現有的附件仍為[外部儲存]({{externalLink}})**。\",\n    \"**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).\": \"**部分現有的附件仍為[內部儲存]({{internalLink}})**（儲存在 SQLite 檔案中）.\",\n    \"[Learn more.]({{learnLink}})\": \"[了解更多]({{learnLink}})\",\n    \"Upload\": \"上傳\",\n    \"Upload missing attachments\": \"上傳缺少的附件\",\n    \"Uploading...\": \"上傳中...\",\n    \"Default\": \"預設\",\n    \"Default, template, or tutorial\": \"預設, 範本, 或教學\",\n    \"Document type\": \"文件類型\",\n    \"Regular document\": \"普通文件\",\n    \"Regular\": \"普通\"\n  },\n  \"ColumnTitle\": {\n    \"Column ID copied to clipboard\": \"欄位 ID 已複製到剪貼簿\",\n    \"Add description\": \"新增描述\",\n    \"Column description\": \"欄位描述\",\n    \"COLUMN ID: \": \"欄位 ID: \",\n    \"Provide a column label\": \"提供欄位標籤\",\n    \"Close\": \"關閉\",\n    \"Cancel\": \"取消\",\n    \"Column label\": \"欄位標籤\",\n    \"Save\": \"儲存\"\n  },\n  \"ViewConfigTab\": {\n    \"Section: \": \"區段： \",\n    \"Form\": \"表單\",\n    \"Unmark On-Demand\": \"取消標記為按需\",\n    \"Compact\": \"緊湊\",\n    \"Blocks\": \"區塊\",\n    \"Advanced settings\": \"進階設定\",\n    \"Make On-Demand\": \"設為按需\",\n    \"Big tables may be marked as \\\"on-demand\\\" to avoid loading them into the data engine.\": \"大型表格可以標記為「按需」，以避免將它們載入資料引擎。\",\n    \"Plugin: \": \"插件： \",\n    \"Edit card layout\": \"編輯卡片佈局\",\n    \"On-Demand Tables have been deprecated due to lack of functionality and usability concerns.\": \"因功能不足及可用性問題，隨選表格已被淘汰。\",\n    \"⚠️ Deprecated Feature\": \"⚠️ 已淘汰功能\"\n  },\n  \"FieldBuilder\": {\n    \"Mixed format\": \"混合格式\",\n    \"CELL FORMAT\": \"儲存格格式\",\n    \"Mixed types\": \"混合類型\",\n    \"DATA FROM TABLE\": \"來自表格的資料\",\n    \"Revert field settings for {{colId}} to common\": \"將 {{colId}} 的欄位設定還原為一般\",\n    \"Save field settings for {{colId}} as common\": \"將 {{colId}} 的欄位設定儲存為一般\",\n    \"Changing multiple column types\": \"變更多個欄位類型\",\n    \"Use separate field settings for {{colId}}\": \"為 {{colId}} 使用獨立的欄位設定\",\n    \"Changing column type\": \"變更欄位類型\",\n    \"Apply formula to data\": \"將公式套用到資料\"\n  },\n  \"SupportGristNudge\": {\n    \"Support Grist\": \"支持 Grist\",\n    \"Close\": \"關閉\",\n    \"Opt in to Telemetry\": \"選擇參與遙測\",\n    \"Help Center\": \"說明中心\",\n    \"Opted In\": \"已選擇參與\",\n    \"Contribute\": \"貢獻\",\n    \"Support Grist page\": \"支持 Grist 頁面\",\n    \"Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.\": \"感謝您！非常感激您的信任與支持。您可隨時透過使用者選單中的 {{link}} 取消參與。\",\n    \"Admin Panel\": \"後台管理\"\n  },\n  \"ValidationPanel\": {\n    \"Update formula (Shift+Enter)\": \"更新公式 (Shift+Enter)\",\n    \"Rule {{length}}\": \"規則 {{length}}\"\n  },\n  \"AccountWidget\": {\n    \"Add account\": \"新增帳戶\",\n    \"Switch Accounts\": \"切換帳戶\",\n    \"Activation\": \"啟動\",\n    \"Toggle Mobile Mode\": \"切換手機模式\",\n    \"Support Grist\": \"支持 Grist\",\n    \"Access Details\": \"存取詳細資訊\",\n    \"Upgrade Plan\": \"升級方案\",\n    \"Sign out\": \"登出\",\n    \"Profile settings\": \"個人資料設定\",\n    \"Sign in\": \"登入\",\n    \"Pricing\": \"價格\",\n    \"Document settings\": \"文件設定\",\n    \"Use This Template\": \"使用此範本\",\n    \"Manage team\": \"管理團隊\",\n    \"Billing account\": \"帳單帳戶\",\n    \"Accounts\": \"帳戶\",\n    \"Sign up\": \"註冊\"\n  },\n  \"ViewLayoutMenu\": {\n    \"Widget options\": \"小工具選項\",\n    \"Advanced sort & filter\": \"進階排序與篩選\",\n    \"Print widget\": \"列印小工具\",\n    \"Data selection\": \"資料選擇\",\n    \"Download as XLSX\": \"下載為 XLSX\",\n    \"Open configuration\": \"開啟設定\",\n    \"Edit card layout\": \"編輯卡片版面配置\",\n    \"Add to page\": \"新增至頁面\",\n    \"Delete record\": \"刪除紀錄\",\n    \"Collapse widget\": \"收合小工具\",\n    \"Show raw data\": \"顯示原始資料\",\n    \"Delete widget\": \"刪除小工具\",\n    \"Copy anchor link\": \"複製錨點連結\",\n    \"Download as CSV\": \"下載為 CSV\",\n    \"Create a form\": \"建立表單\"\n  },\n  \"ACUserManager\": {\n    \"Invite new member\": \"邀請新成員\",\n    \"We'll email an invite to {{email}}\": \"我們將向 {{email}} 傳送邀請\",\n    \"Enter email address\": \"輸入電子郵件地址\"\n  },\n  \"HomeIntro\": {\n    \"personal site\": \"個人網站\",\n    \"Any documents created in this site will appear here.\": \"在此網站中建立的任何文件都會在此處顯示。\",\n    \"Welcome to Grist, {{- name}}!\": \"歡迎來到 Grist，{{- name}}！\",\n    \"Get started by inviting your team and creating your first Grist document.\": \"開始邀請您的團隊並建立您的第一份 Grist 文件。\",\n    \"You have read-only access to this site. Currently there are no documents.\": \"您只有此網站的唯讀存取權限。目前沒有文件。\",\n    \"Help Center\": \"說明中心\",\n    \"Interested in using Grist outside of your team? Visit your free \": \"對於在您的團隊之外使用 Grist 感興趣？請造訪您的免費 \",\n    \"Get started by creating your first Grist document.\": \"開始建立您的第一份 Grist 文件。\",\n    \"This workspace is empty.\": \"此工作區是空的。\",\n    \"Visit our {{link}} to learn more.\": \"造訪我們的 {{link}} 以瞭解更多。\",\n    \"Visit our {{link}} to learn more about Grist.\": \"造訪我們的 {{link}} 以瞭解更多關於 Grist 的資訊。\",\n    \"{{signUp}} to save your work. \": \"{{signUp}} 以儲存您的工作。 \",\n    \"Welcome to {{- orgName}}\": \"歡迎來到 {{- orgName}}\",\n    \"Welcome to Grist, {{name}}!\": \"歡迎來到 Grist，{{name}}！\",\n    \"Browse Templates\": \"瀏覽範本\",\n    \"Sign in\": \"登入\",\n    \"Welcome to {{orgName}}\": \"歡迎來到 {{orgName}}\",\n    \"Invite Team Members\": \"邀請團隊成員\",\n    \"Get started by exploring templates, or creating your first Grist document.\": \"開始探索範本，或建立您的第一份 Grist 文件。\",\n    \"Import document\": \"匯入文件\",\n    \"Create empty document\": \"建立空白文件\",\n    \"Sign up\": \"註冊\",\n    \"Sprouts Program\": \"Sprouts 計劃\",\n    \"To use Grist, please either sign up or sign in.\": \"要使用 Grist，請註冊或登入。\",\n    \"Welcome to Grist!\": \"歡迎來到 Grist！\",\n    \"Learn more in our {{helpCenterLink}}.\": \"前往 {{helpCenterLink}} 了解更多.\",\n    \"Only show documents\": \"僅顯示文件\"\n  },\n  \"CustomSectionConfig\": {\n    \"Open configuration\": \"開啟設定\",\n    \"Pick a {{columnType}} column\": \"選擇一個 {{columnType}} 欄\",\n    \"Widget needs {{fullAccess}} to this document.\": \"小工具需要對此文件有{{fullAccess}}。\",\n    \"Enter Custom URL\": \"輸入自訂 URL\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_one\": \"{{wrongTypeCount}} 個非 {{columnType}} 欄未顯示\",\n    \"Select Custom Widget\": \"選擇自訂小工具\",\n    \"{{wrongTypeCount}} non-{{columnType}} columns are not shown_other\": \"{{wrongTypeCount}} 個非 {{columnType}} 欄未顯示\",\n    \"Learn more about custom widgets\": \"了解更多關於自訂小工具的資訊\",\n    \"Add\": \"新增\",\n    \" (optional)\": \" （選填）\",\n    \"No {{columnType}} columns in table.\": \"表格中沒有 {{columnType}} 欄。\",\n    \"Read selected table\": \"讀取所選表格\",\n    \"Full document access\": \"完整文件存取權限\",\n    \"Widget needs to {{read}} the current table.\": \"小工具需要{{read}}目前表格。\",\n    \"Pick a column\": \"選擇一欄\",\n    \"Clear selection\": \"清除選擇\",\n    \"No document access\": \"無文件存取權限\",\n    \"Widget does not require any permissions.\": \"小工具不需要任何權限。\",\n    \"ACCESS LEVEL\": \"權限等級\",\n    \"Accept\": \"接受\",\n    \"Custom URL\": \"自定義網址\",\n    \"Developer:\": \"開發者:\",\n    \"Last updated:\": \"最後更新:\",\n    \"Missing description and author information.\": \"缺少描述與作者資訊.\",\n    \"Reject\": \"拒絕\",\n    \"Widget\": \"小工具\"\n  },\n  \"WelcomeSitePicker\": {\n    \"You have access to the following Grist sites.\": \"您可以存取以下的 Grist 網站。\",\n    \"Welcome back\": \"歡迎回來\",\n    \"You can always switch sites using the account menu.\": \"您可以隨時使用帳戶選單切換網站。\"\n  },\n  \"TopBar\": {\n    \"Manage team\": \"管理團隊\"\n  },\n  \"GridOptions\": {\n    \"Horizontal gridlines\": \"水平網格線\",\n    \"Vertical gridlines\": \"垂直網格線\",\n    \"Grid Options\": \"網格選項\",\n    \"Zebra stripes\": \"斑馬線\"\n  },\n  \"FormulaEditor\": {\n    \"Enter formula or {{button}}.\": \"輸入公式或 {{button}}.\",\n    \"Error in the cell\": \"儲存格錯誤\",\n    \"Errors in {{numErrors}} of {{numCells}} cells\": \"{{numCells}} 個儲存格中有 {{numErrors}} 個錯誤\",\n    \"Enter formula.\": \"輸入公式.\",\n    \"Column or field is required\": \"需要欄位或欄位\",\n    \"Expand Editor\": \"展開編輯器\",\n    \"use AI Assistant\": \"使用 AI 幫手\",\n    \"Errors in all {{numErrors}} cells\": \"所有 {{numErrors}} 個儲存格都有錯誤\",\n    \"editingFormula is required\": \"需要編輯公式\"\n  },\n  \"ShareMenu\": {\n    \"Current Version\": \"目前版本\",\n    \"Return to {{termToUse}}\": \"返回 {{termToUse}}\",\n    \"Download...\": \"下載…\",\n    \"Show in folder\": \"在資料夾中顯示\",\n    \"Share\": \"分享\",\n    \"Export CSV\": \"匯出 CSV\",\n    \"Send to Google Drive\": \"傳送到 Google 雲端硬碟\",\n    \"Export XLSX\": \"匯出 XLSX\",\n    \"Access Details\": \"存取詳細資訊\",\n    \"Compare to {{termToUse}}\": \"與 {{termToUse}} 比較\",\n    \"Download\": \"下載\",\n    \"Replace {{termToUse}}...\": \"替換 {{termToUse}}…\",\n    \"Duplicate document\": \"複製文件\",\n    \"Original\": \"原始\",\n    \"Back to current\": \"返回目前\",\n    \"Edit without affecting the original\": \"編輯而不影響原始文件\",\n    \"Work on a copy\": \"在副本上工作\",\n    \"Manage users\": \"管理使用者\",\n    \"Unsaved\": \"未儲存\",\n    \"Save Document\": \"儲存文件\",\n    \"Save copy\": \"儲存副本\",\n    \"Comma Separated Values (.csv)\": \"逗號分格值 (.csv)\",\n    \"DOO Separated Values (.dsv)\": \"DOO 分隔值 (.dsv)\",\n    \"Export as...\": \"匯出為...\",\n    \"Microsoft Excel (.xlsx)\": \"Microsoft Excel (.xlsx)\",\n    \"Tab Separated Values (.tsv)\": \"Tab 分隔值 (.tsv)\",\n    \"Exporting is only available from document pages. Please select a document page and try again.\": \"僅能從文件頁面進行匯出。請選擇一個文件頁面後再試一次。\",\n    \"Download attachments...\": \"下載附件...\",\n    \"Download document...\": \"下載文件...\"\n  },\n  \"UserManagerModel\": {\n    \"View & edit\": \"檢視與編輯\",\n    \"Owner\": \"擁有者\",\n    \"None\": \"無\",\n    \"View only\": \"僅檢視\",\n    \"No Default Access\": \"無預設存取權限\",\n    \"In full\": \"完全\",\n    \"Viewer\": \"檢視者\",\n    \"Editor\": \"編輯者\"\n  },\n  \"DocumentUsage\": {\n    \"Data size\": \"資料大小\",\n    \"Usage statistics are only available to users with full access to the document data.\": \"只有對文件資料有完整存取權限的使用者才能檢視使用統計。\",\n    \"Usage\": \"使用情況\",\n    \"Size of attachments\": \"附件大小\",\n    \"For higher limits, \": \"如需更高的限制， \",\n    \"Contact the site owner to upgrade the plan to raise limits.\": \"聯絡網站擁有者以升級方案並提高限制。\",\n    \"start your 30-day free trial of the Pro plan.\": \"開始您的專業方案 30 天免費試用。\",\n    \"Rows\": \"列\"\n  },\n  \"NotifyUI\": {\n    \"Go to your free personal site\": \"前往您的免費個人網站\",\n    \"Upgrade Plan\": \"升級方案\",\n    \"Ask for help\": \"尋求協助\",\n    \"Renew\": \"續訂\",\n    \"Manage billing\": \"管理帳單\",\n    \"Give feedback\": \"提供回饋\",\n    \"Cannot find personal site, sorry!\": \"抱歉，找不到個人網站！\",\n    \"Notifications\": \"通知\",\n    \"Report a problem\": \"回報問題\",\n    \"No notifications\": \"無通知\"\n  },\n  \"FieldContextMenu\": {\n    \"Copy anchor link\": \"複製錨點連結\",\n    \"Hide field\": \"隱藏欄位\",\n    \"Copy\": \"複製\",\n    \"Paste\": \"貼上\",\n    \"Clear field\": \"清除欄位\",\n    \"Cut\": \"剪下\",\n    \"Comment\": \"評論\"\n  },\n  \"WidgetTitle\": {\n    \"Override widget title\": \"覆寫小工具標題\",\n    \"WIDGET TITLE\": \"小工具標題\",\n    \"DATA TABLE NAME\": \"資料表名稱\",\n    \"Cancel\": \"取消\",\n    \"WIDGET DESCRIPTION\": \"小工具描述\",\n    \"Save\": \"儲存\",\n    \"Provide a table name\": \"提供一個表格名稱\"\n  },\n  \"ChoiceTextBox\": {\n    \"CHOICES\": \"選項\"\n  },\n  \"ExampleInfo\": {\n    \"Afterschool Program\": \"課後活動計劃\",\n    \"Welcome to the Investment Research template\": \"歡迎使用投資研究範本\",\n    \"Welcome to the Afterschool Program template\": \"歡迎使用課後活動計劃範本\",\n    \"Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.\": \"檢視我們相關的教學，了解如何建立摘要表格和圖表，並動態連結圖表。\",\n    \"Check out our related tutorial for how to link data, and create high-productivity layouts.\": \"檢視我們相關的教學，了解如何連結資料並建立高效率的版面配置。\",\n    \"Investment Research\": \"投資研究\",\n    \"Tutorial: Create a CRM\": \"教學：建立 CRM\",\n    \"Check out our related tutorial for how to model business data, use formulas, and manage complexity.\": \"檢視我們相關的教學，了解如何建模商業資料、使用公式並管理複雜性。\",\n    \"Welcome to the Lightweight CRM template\": \"歡迎使用輕量級 CRM 範本\",\n    \"Lightweight CRM\": \"輕量級 CRM\",\n    \"Tutorial: Manage Business Data\": \"教學：管理商業資料\",\n    \"Tutorial: Analyze & Visualize\": \"教學：分析與視覺化\"\n  },\n  \"ViewAsBanner\": {\n    \"UnknownUser\": \"未知使用者\",\n    \"View as Yourself\": \"以自己角色查看\",\n    \"You are viewing this document as\": \"您目前以以下身份檢視此文件\"\n  },\n  \"PermissionsWidget\": {\n    \"Deny all\": \"全部拒絕\",\n    \"Read only\": \"唯讀\",\n    \"Allow all\": \"全部允許\"\n  },\n  \"ActionLog\": {\n    \"Column {{colId}} was subsequently removed in action #{{action.actionNum}}\": \"在操作 #{{action.actionNum}} 中，後來移除了欄位 {{colId}}\",\n    \"Action Log failed to load\": \"操作日誌載入失敗\",\n    \"This row was subsequently removed in action {{action.actionNum}}\": \"在操作 {{action.actionNum}} 中，後來移除了此列\",\n    \"Table {{tableId}} was subsequently removed in action #{{actionNum}}\": \"在操作 #{{actionNum}} 中，後來移除了表格 {{tableId}}\",\n    \"All tables\": \"所有表格\"\n  },\n  \"errorPages\": {\n    \"Account deleted{{suffix}}\": \"帳戶已刪除{{suffix}}\",\n    \"Something went wrong\": \"出了些問題\",\n    \"There was an error: {{message}}\": \"出現錯誤：{{message}}\",\n    \"Go to main page\": \"前往首頁\",\n    \"Sign in\": \"登入\",\n    \"Access denied{{suffix}}\": \"存取被拒絕{{suffix}}\",\n    \"There was an unknown error.\": \"出現未知錯誤。\",\n    \"Add account\": \"新增帳戶\",\n    \"You do not have access to this organization's documents.\": \"您無權存取此組織的文件。\",\n    \"You are now signed out.\": \"您現在已登出。\",\n    \"Signed out{{suffix}}\": \"已登出{{suffix}}\",\n    \"Your account has been deleted.\": \"您的帳戶已被刪除。\",\n    \"Page not found{{suffix}}\": \"找不到頁面{{suffix}}\",\n    \"Error{{suffix}}\": \"錯誤{{suffix}}\",\n    \"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.\": \"您已登入為 {{email}}。您可以使用不同的帳戶登入，或向管理員請求存取權限。\",\n    \"Sign up\": \"註冊\",\n    \"Sign in again\": \"再次登入\",\n    \"The requested page could not be found.{{separator}}Please check the URL and try again.\": \"找不到請求的頁面。{{separator}}請檢查 URL 並再試一次。\",\n    \"Sign in to access this organization's documents.\": \"登入以存取此組織的文件。\",\n    \"Contact support\": \"聯絡支援\",\n    \"An unknown error occurred.\": \"發生未知錯誤。\",\n    \"Build your own form\": \"建立自己的表單\",\n    \"Form not found\": \"找不到表單\",\n    \"Powered by\": \"驅動於\",\n    \"Failed to log in.{{separator}}Please try again or contact support.\": \"登入失敗。{{separator}}請再試一次，或聯絡技術支援。\",\n    \"Sign-in failed{{suffix}}\": \"登入失敗{{suffix}}\"\n  },\n  \"RecordLayoutEditor\": {\n    \"Show field {{- label}}\": \"顯示欄位 {{- label}}\",\n    \"Add field\": \"新增欄位\",\n    \"Save layout\": \"儲存版面配置\",\n    \"Cancel\": \"取消\",\n    \"Create new field\": \"建立新欄位\"\n  },\n  \"CellStyle\": {\n    \"HEADER STYLE\": \"標題樣式\",\n    \"Header Style\": \"標題樣式\",\n    \"Default header style\": \"預設標題樣式\",\n    \"Mixed style\": \"混合樣式\",\n    \"Default cell style\": \"預設儲存格樣式\",\n    \"CELL STYLE\": \"儲存格樣式\",\n    \"Cell style\": \"儲存格樣式\",\n    \"Open row styles\": \"開啟列樣式\"\n  },\n  \"ConditionalStyle\": {\n    \"Rule must return True or False\": \"規則必須回傳 True 或 False\",\n    \"Row style\": \"列樣式\",\n    \"Add another rule\": \"新增其他規則\",\n    \"Add conditional style\": \"新增條件樣式\",\n    \"Error in style rule\": \"樣式規則錯誤\",\n    \"Conditional Style\": \"條件格式\",\n    \"IF...\": \"如...\"\n  },\n  \"ColorSelect\": {\n    \"Apply\": \"套用\",\n    \"Cancel\": \"取消\",\n    \"Default cell style\": \"預設儲存格樣式\"\n  },\n  \"search\": {\n    \"Search in document\": \"在文件中搜尋\",\n    \"Find Next \": \"尋找下一個 \",\n    \"No results\": \"無結果\",\n    \"Find Previous \": \"尋找上一個 \",\n    \"Search\": \"搜尋\"\n  },\n  \"FieldMenus\": {\n    \"Use separate settings\": \"使用獨立設定\",\n    \"Revert to common settings\": \"還原為共用設定\",\n    \"Using common settings\": \"使用共用設定\",\n    \"Using separate settings\": \"使用獨立設定\",\n    \"Save as common settings\": \"儲存為共用設定\"\n  },\n  \"FilterConfig\": {\n    \"Add column\": \"新增欄位\"\n  },\n  \"EditorTooltip\": {\n    \"Convert column to formula\": \"將欄位轉換為公式\"\n  },\n  \"PageWidgetPicker\": {\n    \"Select widget\": \"選擇小工具\",\n    \"Add to page\": \"新增到頁面\",\n    \"Select data\": \"選擇資料\",\n    \"Group by\": \"依...分組\",\n    \"Building {{- label}} widget\": \"建立 {{- label}} 小工具\"\n  },\n  \"Pages\": {\n    \"The following tables will no longer be visible_one\": \"以下的表格將不再顯示\",\n    \"Delete\": \"刪除\",\n    \"The following tables will no longer be visible_other\": \"以下的多個表格將不再顯示\",\n    \"Delete data and this page.\": \"刪除資料與此頁面。\",\n    \"Keep data and delete page. Table will remain available in {{rawDataLink}}\": \"保留資料並刪除頁面。資料表仍存在於 {{rawDataLink}}\",\n    \"Raw Data page\": \"原始資料頁面\"\n  },\n  \"DescriptionTextArea\": {\n    \"DESCRIPTION\": \"描述\"\n  },\n  \"WebhookPage\": {\n    \"Webhook settings\": \"Webhook 設定\",\n    \"Clear queue\": \"清除佇列\",\n    \"Cleared webhook queue.\": \"已清除 Webhook 佇列。\",\n    \"Columns to check when update (separated by ;)\": \"更新時要檢查的欄位（以 ; 分隔）\",\n    \"Enabled\": \"已啟用\",\n    \"Event Types\": \"活動類型\",\n    \"Memo\": \"備註\",\n    \"Name\": \"名稱\",\n    \"Ready Column\": \"就緒欄位\",\n    \"Removed webhook.\": \"已刪除的 webhook.\",\n    \"Sorry, not all fields can be edited.\": \"抱歉，並非所有欄位都可編輯。\",\n    \"Status\": \"狀態\",\n    \"URL\": \"URL\",\n    \"Webhook Id\": \"Webhook Id\",\n    \"Table\": \"表\",\n    \"Filter for changes in these columns (semicolon-separated ids)\": \"篩選這些欄位的變更（以分號分隔的 ID）\",\n    \"Header Authorization\": \"Header 授權\",\n    \"Webhooks Unavailable In Unsaved Document Copies\": \"未儲存的文件副本中無法使用 Webhook\"\n  },\n  \"TriggerFormulas\": {\n    \"Close\": \"關閉\",\n    \"Cancel\": \"取消\",\n    \"Apply on changes to:\": \"套用變更至：\",\n    \"Apply to new records\": \"套用至新記錄\",\n    \"OK\": \"確定\",\n    \"Current field \": \"目前欄位 \",\n    \"Any field\": \"任何欄位\",\n    \"Apply on record changes\": \"套用記錄變更\"\n  },\n  \"PluginScreen\": {\n    \"Import failed: \": \"匯入失敗： \"\n  },\n  \"AppHeader\": {\n    \"Personal Site\": \"個人網站\",\n    \"Home page\": \"首頁\",\n    \"Team Site\": \"團隊網站\",\n    \"Legacy\": \"傳統\",\n    \"Grist Templates\": \"Grist 範本\",\n    \"Billing account\": \"帳單帳戶\",\n    \"Manage team\": \"管理團隊\"\n  },\n  \"ViewAsDropdown\": {\n    \"View as\": \"檢視為\",\n    \"Example Users\": \"範例使用者\",\n    \"Users from table\": \"來自表格的使用者\"\n  },\n  \"DataTables\": {\n    \"Raw Data Tables\": \"原始資料表格\",\n    \"Duplicate table\": \"複製表格\",\n    \"You do not have edit access to this document\": \"您無法編輯此文件\",\n    \"Delete {{formattedTableName}} data, and remove it from all pages?\": \"刪除 {{formattedTableName}} 資料，並從所有頁面中移除？\",\n    \"Click to copy\": \"點選複製\",\n    \"Table ID copied to clipboard\": \"表格 ID 已複製到剪貼簿\",\n    \"Edit record card\": \"編輯記錄卡\",\n    \"Record Card\": \"記錄卡\",\n    \"Record Card Disabled\": \"記錄卡已停用\",\n    \"Remove table\": \"刪除資料表\",\n    \"Rename table\": \"更改表格名稱\",\n    \"{{action}} Record Card\": \"{{action}} 記錄卡\"\n  },\n  \"NTextBox\": {\n    \"false\": \"否\",\n    \"true\": \"是\",\n    \"Field Format\": \"欄位格式\",\n    \"Lines\": \"行\",\n    \"Multi line\": \"多行\",\n    \"Single line\": \"單行\"\n  },\n  \"FilterBar\": {\n    \"Search Columns\": \"搜尋欄位\",\n    \"SearchColumns\": \"搜尋欄位\"\n  },\n  \"TypeTransformation\": {\n    \"Revise\": \"修訂\",\n    \"Update formula (Shift+Enter)\": \"更新公式 (Shift+Enter)\",\n    \"Preview\": \"預覽\",\n    \"Apply\": \"套用\",\n    \"Cancel\": \"取消\"\n  },\n  \"NumericTextBox\": {\n    \"Decimals\": \"小數點\",\n    \"Currency\": \"貨幣\",\n    \"Default currency ({{defaultCurrency}})\": \"預設貨幣 ({{defaultCurrency}})\",\n    \"Number Format\": \"數字格式\",\n    \"Field Format\": \"欄位格式\",\n    \"Text\": \"文字\",\n    \"max\": \"最大\",\n    \"min\": \"最小\"\n  },\n  \"App\": {\n    \"Description\": \"描述\",\n    \"Translators: please translate this only when your language is ready to be offered to users\": \"給翻譯人員：請僅在您的語言版本準備就緒，並準備向使用者提供時，才翻譯此條訊息\",\n    \"Memory Error\": \"記憶體錯誤\",\n    \"Key\": \"金鑰\"\n  },\n  \"OnBoardingPopups\": {\n    \"Finish\": \"完成\",\n    \"Next\": \"下一步\",\n    \"Previous\": \"上一個\"\n  },\n  \"Reference\": {\n    \"CELL FORMAT\": \"儲存格格式\",\n    \"Row ID\": \"列 ID\",\n    \"SHOW COLUMN\": \"顯示欄位\"\n  },\n  \"HyperLinkEditor\": {\n    \"[link label] url\": \"[連結標籤] 網址\"\n  },\n  \"FloatingEditor\": {\n    \"Collapse Editor\": \"收起編輯器\"\n  },\n  \"ThemeConfig\": {\n    \"Switch appearance automatically to match system\": \"自動切換外觀以符合系統\",\n    \"Appearance \": \"外觀 \"\n  },\n  \"GridView\": {\n    \"Click to insert\": \"點選以插入\"\n  },\n  \"pages\": {\n    \"Duplicate page\": \"複製頁面\",\n    \"You do not have edit access to this document\": \"您無權限編輯此文件\",\n    \"Remove\": \"移除\",\n    \"Rename\": \"重新命名\"\n  },\n  \"ColumnEditor\": {\n    \"COLUMN DESCRIPTION\": \"欄位描述\",\n    \"COLUMN LABEL\": \"欄位標籤\"\n  },\n  \"RefSelect\": {\n    \"No columns to add\": \"無欄位可新增\",\n    \"Add column\": \"新增欄位\"\n  },\n  \"SortFilterConfig\": {\n    \"Update Sort & Filter settings\": \"更新排序與篩選設定\",\n    \"Save\": \"儲存\",\n    \"Sort\": \"排序\",\n    \"Filter\": \"篩選\",\n    \"Revert\": \"還原\"\n  },\n  \"CodeEditorPanel\": {\n    \"Code View is available only when you have full document access.\": \"當您具有完整文件存取權限時，才能使用程式碼檢視。\",\n    \"Access denied\": \"存取被拒\"\n  },\n  \"ACLUsers\": {\n    \"View as\": \"以此身份檢視\",\n    \"Example Users\": \"範例使用者\",\n    \"Users from table\": \"來自表格的使用者\"\n  },\n  \"TypeTransform\": {\n    \"Cancel\": \"取消\",\n    \"Revise\": \"修訂\",\n    \"Apply\": \"套用\",\n    \"Update formula (Shift+Enter)\": \"更新公式 (Shift+Enter)\",\n    \"Preview\": \"預覽\"\n  },\n  \"LanguageMenu\": {\n    \"Language\": \"語言\"\n  },\n  \"OpenVideoTour\": {\n    \"Video Tour\": \"影片導覽\",\n    \"YouTube video player\": \"YouTube 影片播放器\",\n    \"Grist Video Tour\": \"Grist 影片導覽\"\n  },\n  \"duplicatePage\": {\n    \"Note that this does not copy data, but creates another view of the same data.\": \"請注意，這並不會複製資料，而是建立相同資料的另一個檢視。\",\n    \"Duplicate page {{pageName}}\": \"複製頁面 {{pageName}}\"\n  },\n  \"CurrencyPicker\": {\n    \"Invalid currency\": \"無效的貨幣\"\n  },\n  \"LeftPanelCommon\": {\n    \"Help Center\": \"說明中心\"\n  },\n  \"searchDropdown\": {\n    \"Search\": \"搜尋\"\n  },\n  \"SearchModel\": {\n    \"Search all tables\": \"搜尋所有表格\",\n    \"Search all pages\": \"搜尋所有頁面\"\n  },\n  \"modals\": {\n    \"Save\": \"儲存\",\n    \"Cancel\": \"取消\",\n    \"Ok\": \"確定\",\n    \"Are you sure you want to delete these records?\": \"您確定要刪除這些紀錄嗎？\",\n    \"Are you sure you want to delete this record?\": \"您確定要刪除這筆紀錄嗎？\",\n    \"Delete\": \"刪除\",\n    \"Dismiss\": \"忽略\",\n    \"Don't ask again.\": \"不再詢問.\",\n    \"Don't show again.\": \"不再顯示.\",\n    \"Don't show tips\": \"不顯示提示\",\n    \"Undo to restore\": \"按一下「復原」可還原\",\n    \"Got it\": \"明白了\",\n    \"Don't show again\": \"不再顯示\",\n    \"TIP\": \"提示\",\n    \"Confirm\": \"確認\"\n  },\n  \"SiteSwitcher\": {\n    \"Switch Sites\": \"切換網站\",\n    \"Create new team site\": \"建立新的團隊網站\"\n  },\n  \"AddNewButton\": {\n    \"Add new\": \"新增\"\n  },\n  \"RecordLayout\": {\n    \"Updating record layout.\": \"正在更新紀錄版面配置。\"\n  },\n  \"DocTour\": {\n    \"No valid document tour\": \"沒有有效的文件導覽\",\n    \"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.\": \"無法從此文件中的資料建立文件導覽。請確保有一個名為 GristDocTour 的表格，並有標題、內文、位置和地點等欄位。\"\n  },\n  \"AppModel\": {\n    \"This team site is suspended. Documents can be read, but not modified.\": \"此團隊網站已被暫停。文件可以閱讀，但不能修改。\"\n  },\n  \"SelectionSummary\": {\n    \"Copied to clipboard\": \"已複製到剪貼簿\"\n  },\n  \"DescriptionConfig\": {\n    \"DESCRIPTION\": \"描述\"\n  },\n  \"sendToDrive\": {\n    \"Sending file to Google Drive\": \"正在將檔案傳送到 Google 雲端硬碟\"\n  },\n  \"CardContextMenu\": {\n    \"Copy anchor link\": \"複製錨點連結\",\n    \"Delete card\": \"刪除卡片\",\n    \"Duplicate card\": \"複製卡片\",\n    \"Insert card\": \"插入卡片\",\n    \"Insert card above\": \"在上方插入卡片\",\n    \"Insert card below\": \"在下方插入卡片\"\n  },\n  \"HiddenQuestionConfig\": {\n    \"Hidden fields\": \"隱藏欄位\"\n  },\n  \"WelcomeCoachingCall\": {\n    \"free coaching call\": \"免費諮詢通話\",\n    \"Maybe later\": \"暫時先不用\",\n    \"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.\": \"在通話中，我們會花時間了解您的需求，並根據您的情況量身打造內容。我們可以介紹 Grist 的基本功能，或是直接使用您的資料，幫您建立所需的儀表板。\",\n    \"Schedule call\": \"預約通話\",\n    \"Schedule your {{freeCoachingCall}} with a member of our team.\": \"安排與我們團隊成員的 {{freeCoachingCall}}。\"\n  },\n  \"FormView\": {\n    \"Publish\": \"發佈\",\n    \"Publish your form?\": \"要發佈表單嗎？\",\n    \"Unpublish\": \"取消發佈\",\n    \"Unpublish your form?\": \"要取消發佈表單嗎？\",\n    \"Anyone with the link below can see the empty form and submit a response.\": \"擁有以下連結的任何人都可以查看空白表單並提交回覆。\",\n    \"Are you sure you want to reset your form?\": \"您確定要重設表單嗎？\",\n    \"Code copied to clipboard\": \"程式碼已複製到剪貼簿\",\n    \"Copy code\": \"複製程式碼\",\n    \"Copy link\": \"複製連結\",\n    \"Embed this form\": \"嵌入此表單\",\n    \"Link copied to clipboard\": \"連結已複製到剪貼簿\",\n    \"Preview\": \"預覽\",\n    \"Reset\": \"重置\",\n    \"Reset form\": \"重置表單\",\n    \"Save your document to publish this form.\": \"請先儲存文件以發布此表單。\",\n    \"Share\": \"分享\",\n    \"Share this form\": \"分享此表單\",\n    \"View\": \"檢視\",\n    \"# **Form Title**\": \"# **表單標題**\",\n    \"Your form description goes here.\": \"請在此輸入表單描述.\",\n    \"Publishing your form will generate a share link. Anyone with the link can see the empty form and submit a response.\": \"發布表單後將產生一個分享連結。擁有該連結的任何人都可以查看空白表單並提交回覆。\",\n    \"Unpublishing the form will disable the share link so that users accessing your form via that link will see an error.\": \"取消發布表單後，分享連結將會失效，透過該連結存取表單的使用者將會看到錯誤訊息。\",\n    \"Users are limited to submitting entries (records in your table) and reading pre-set values in designated fields, such as reference and choice columns.\": \"使用者僅限提交項目（表格中的紀錄），以及閱讀指定欄位中的預設值，例如參照欄位和選項欄位。\",\n    \"Your form is published. Every change is live and visible to users with access to the form. If you want to make changes in draft, unpublish the form.\": \"您的表單已發布。所有變更都會即時生效，並對有權存取表單的使用者可見。如果您希望先進行草稿修改，請取消發布表單。\"\n  },\n  \"Editor\": {\n    \"Delete\": \"刪除\"\n  },\n  \"Menu\": {\n    \"Building blocks\": \"組件\",\n    \"Columns\": \"欄\",\n    \"Copy\": \"複製\",\n    \"Cut\": \"剪下\",\n    \"Insert question above\": \"在上方插入問題\",\n    \"Insert question below\": \"在下方插入問題\",\n    \"Paragraph\": \"段落\",\n    \"Paste\": \"貼上\",\n    \"Separator\": \"分隔線\",\n    \"Unmapped fields\": \"未對應的欄位\",\n    \"Header\": \"標頭\",\n    \"New question\": \"新的問題\",\n    \"More\": \"更多\"\n  },\n  \"UnmappedFieldsConfig\": {\n    \"Clear\": \"清除\",\n    \"Map fields\": \"對應欄位\",\n    \"Mapped\": \"已對應\",\n    \"Select all\": \"全選\",\n    \"Unmap fields\": \"取消欄位對應\",\n    \"Unmapped\": \"取消對應\"\n  },\n  \"FormConfig\": {\n    \"Field rules\": \"欄位規則\",\n    \"Required field\": \"必填欄位\",\n    \"Ascending\": \"升序\",\n    \"Default\": \"預設值\",\n    \"Descending\": \"降序\",\n    \"Field Format\": \"欄位格式\",\n    \"Field Rules\": \"欄位規則\",\n    \"Horizontal\": \"水平\",\n    \"Options Alignment\": \"選項對齊方式\",\n    \"Options Sort Order\": \"選項排序方式\",\n    \"Radio\": \"單選按鈕\",\n    \"Select\": \"選擇\",\n    \"Vertical\": \"垂直\"\n  },\n  \"CustomView\": {\n    \"Some required columns aren't mapped\": \"某些必填欄位尚未對應\",\n    \"To use this widget, please map all non-optional columns from the creator panel on the right.\": \"要使用此小工具，請從右側的建立面板中對應所有非選填欄位。\",\n    \"Some required columns are hidden by access rules\": \"某些必填欄位因存取規則而被隱藏\",\n    \"To use this widget, all mapped columns must be visible. Please contact document owner or modify access rules.\": \"若要使用此小工具，所有對應的欄位必須可見。請聯絡文件擁有者或修改存取規則。\"\n  },\n  \"FormContainer\": {\n    \"Powered by\": \"驅動於\",\n    \"Build your own form\": \"建立自己的表單\",\n    \"Powered by Grist\": \"Powered by Grist\"\n  },\n  \"FormErrorPage\": {\n    \"Error\": \"錯誤\"\n  },\n  \"FormModel\": {\n    \"Oops! This form is no longer published.\": \"哎呀！此表單已不再公開。\",\n    \"There was a problem loading the form.\": \"載入表單時發生問題。\",\n    \"You don't have access to this form.\": \"您無權存取此表單。\",\n    \"Oops! The form you're looking for doesn't exist.\": \"哎呀！找不到您要的表單。\"\n  },\n  \"FormPage\": {\n    \"There was an error submitting your form. Please try again.\": \"提交表單時發生錯誤，請再試一次。\"\n  },\n  \"FormSuccessPage\": {\n    \"Form Submitted\": \"表單已提交\",\n    \"Thank you! Your response has been recorded.\": \"感謝您！您的回覆已被記錄.\",\n    \"Submit new response\": \"提交新的回覆\"\n  },\n  \"DateRangeOptions\": {\n    \"Last 30 days\": \"最近 30 天\",\n    \"Last 7 days\": \"最近 7 天\",\n    \"Last week\": \"上週\",\n    \"Next 7 days\": \"未來 7 天\",\n    \"This month\": \"本月\",\n    \"This week\": \"本週\",\n    \"This year\": \"本年\",\n    \"Today\": \"今天\"\n  },\n  \"MappedFieldsConfig\": {\n    \"Clear\": \"清除\",\n    \"Map fields\": \"對應欄位\",\n    \"Mapped\": \"已對應\",\n    \"Select all\": \"全選\",\n    \"Unmap fields\": \"取消欄位對應\",\n    \"Unmapped\": \"已取消對應\"\n  },\n  \"Section\": {\n    \"Insert section above\": \"在上方插入區塊\",\n    \"Insert section below\": \"在下方插入區塊\",\n    \"## **Header**\": \"## **標頭**\",\n    \"Description\": \"敘述\"\n  },\n  \"CreateTeamModal\": {\n    \"Cancel\": \"取消\",\n    \"Choose a name and url for your team site\": \"為您的團隊網站選擇名稱和網址\",\n    \"Create site\": \"建立網站\",\n    \"Domain name is invalid\": \"網域名稱無效\",\n    \"Domain name is required\": \"必須填寫網域名稱\",\n    \"Go to your site\": \"前往您的網站\",\n    \"Team name\": \"團隊名稱\",\n    \"Team name is required\": \"需要填寫團隊名稱\",\n    \"Team site created\": \"已建立團隊網站\",\n    \"Team url\": \"團隊網址\",\n    \"Work as a Team\": \"團隊協作\",\n    \"Billing is not supported in grist-core\": \"grist-core 不支援帳務功能\"\n  },\n  \"ToggleEnterpriseModel\": {\n    \"Timed out on waiting for the Grist backend to restart\": \"等待 Grist 後端重啟時逾時\"\n  },\n  \"AdminPanel\": {\n    \"Admin Panel\": \"管理平台\",\n    \"Help us make Grist better\": \"幫助我們改進 Grist\",\n    \"Home\": \"首頁\",\n    \"Sponsor\": \"贊助\"\n  }\n}\n"
  },
  {
    "path": "static/locales/zh_Hant.server.json",
    "content": "{\n    \"oidc\": {\n        \"emailNotVerifiedError\": \"請透過身份驗證提供者驗證您的電子郵件，然後重新登入。\"\n    },\n    \"sendAppPage\": {\n        \"Loading...\": \"載入中...\",\n        \"og-description\": \"超越網格的現代開源試算表\",\n        \"og-title\": \"Grist，試算表的革命\"\n    },\n    \"access\": {\n        \"docNoAccess\": \"你沒有瀏覽這份文件的權限.\"\n    }\n}\n"
  },
  {
    "path": "static/locales/zun.client.json",
    "content": "{}\n"
  },
  {
    "path": "static/locales/zun.server.json",
    "content": "{}\n"
  },
  {
    "path": "static/message.html",
    "content": "<!doctype html>\n<html>\n<head>\n  <meta charset=\"utf8\">\n</head>\n<body>\n  <!-- INSERT MESSAGE -->\n  <script>\n    window.opener.postMessage(message, message.origin);\n  </script>\n</body>\n</html>\n"
  },
  {
    "path": "static/swagger-ui-dark.css",
    "content": "/**\n * Credit: https://github.com/Amoenus/SwaggerDark/ (MIT License)\n */\n @media only screen and (prefers-color-scheme: dark) {\n\n    a { color: #8c8cfa; }\n\n    ::-webkit-scrollbar-track-piece { background-color: rgba(255, 255, 255, .2) !important; }\n\n    ::-webkit-scrollbar-track { background-color: rgba(255, 255, 255, .3) !important; }\n\n    ::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, .5) !important; }\n\n    embed[type=\"application/pdf\"] { filter: invert(90%); }\n\n    html {\n        background: #1f1f1f !important;\n        box-sizing: border-box;\n        filter: contrast(100%) brightness(100%) saturate(100%);\n        overflow-y: scroll;\n    }\n\n    body {\n        background: #1f1f1f;\n        background-color: #1f1f1f;\n        background-image: none !important;\n    }\n\n    button, input, select, textarea {\n        background-color: #1f1f1f;\n        color: #bfbfbf;\n    }\n\n    font, html { color: #bfbfbf; }\n\n    .swagger-ui, .swagger-ui section h3 { color: #b5bac9; }\n\n    .swagger-ui a { background-color: transparent; }\n\n    .swagger-ui mark {\n        background-color: #664b00;\n        color: #bfbfbf;\n    }\n\n    .swagger-ui legend { color: inherit; }\n\n    .swagger-ui .debug * { outline: #e6da99 solid 1px; }\n\n    .swagger-ui .debug-white * { outline: #fff solid 1px; }\n\n    .swagger-ui .debug-black * { outline: #bfbfbf solid 1px; }\n\n    .swagger-ui .debug-grid { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) 0 0; }\n\n    .swagger-ui .debug-grid-16 { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODYyRjhERDU2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODYyRjhERDQ2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QTY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3QjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvCS01IAAABMSURBVHjaYmR4/5+BFPBfAMFm/MBgx8RAGWCn1AAmSg34Q6kBDKMGMDCwICeMIemF/5QawEipAWwUhwEjMDvbAWlWkvVBwu8vQIABAEwBCph8U6c0AAAAAElFTkSuQmCC) 0 0; }\n\n    .swagger-ui .debug-grid-8-solid { background: url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAAAAD/4QMxaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIxMjI0OTczNjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIxMjI0OTc0NjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjEyMjQ5NzE2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjEyMjQ5NzI2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAbGhopHSlBJiZBQi8vL0JHPz4+P0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHAR0pKTQmND8oKD9HPzU/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0f/wAARCAAIAAgDASIAAhEBAxEB/8QAWQABAQAAAAAAAAAAAAAAAAAAAAYBAQEAAAAAAAAAAAAAAAAAAAIEEAEBAAMBAAAAAAAAAAAAAAABADECA0ERAAEDBQAAAAAAAAAAAAAAAAARITFBUWESIv/aAAwDAQACEQMRAD8AoOnTV1QTD7JJshP3vSM3P//Z) 0 0 #1c1c21; }\n\n    .swagger-ui .debug-grid-16-solid { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzY3MkJEN0U2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzY3MkJEN0Y2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3RDY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pve6J3kAAAAzSURBVHjaYvz//z8D0UDsMwMjSRoYP5Gq4SPNbRjVMEQ1fCRDg+in/6+J1AJUxsgAEGAA31BAJMS0GYEAAAAASUVORK5CYII=) 0 0 #1c1c21; }\n\n    .swagger-ui .b--black { border-color: #000; }\n\n    .swagger-ui .b--near-black { border-color: #121212; }\n\n    .swagger-ui .b--dark-gray { border-color: #333; }\n\n    .swagger-ui .b--mid-gray { border-color: #545454; }\n\n    .swagger-ui .b--gray { border-color: #787878; }\n\n    .swagger-ui .b--silver { border-color: #999; }\n\n    .swagger-ui .b--light-silver { border-color: #6e6e6e; }\n\n    .swagger-ui .b--moon-gray { border-color: #4d4d4d; }\n\n    .swagger-ui .b--light-gray { border-color: #2b2b2b; }\n\n    .swagger-ui .b--near-white { border-color: #242424; }\n\n    .swagger-ui .b--white { border-color: #1c1c21; }\n\n    .swagger-ui .b--white-90 { border-color: rgba(28, 28, 33, .9); }\n\n    .swagger-ui .b--white-80 { border-color: rgba(28, 28, 33, .8); }\n\n    .swagger-ui .b--white-70 { border-color: rgba(28, 28, 33, .7); }\n\n    .swagger-ui .b--white-60 { border-color: rgba(28, 28, 33, .6); }\n\n    .swagger-ui .b--white-50 { border-color: rgba(28, 28, 33, .5); }\n\n    .swagger-ui .b--white-40 { border-color: rgba(28, 28, 33, .4); }\n\n    .swagger-ui .b--white-30 { border-color: rgba(28, 28, 33, .3); }\n\n    .swagger-ui .b--white-20 { border-color: rgba(28, 28, 33, .2); }\n\n    .swagger-ui .b--white-10 { border-color: rgba(28, 28, 33, .1); }\n\n    .swagger-ui .b--white-05 { border-color: rgba(28, 28, 33, .05); }\n\n    .swagger-ui .b--white-025 { border-color: rgba(28, 28, 33, .024); }\n\n    .swagger-ui .b--white-0125 { border-color: rgba(28, 28, 33, .01); }\n\n    .swagger-ui .b--black-90 { border-color: rgba(0, 0, 0, .9); }\n\n    .swagger-ui .b--black-80 { border-color: rgba(0, 0, 0, .8); }\n\n    .swagger-ui .b--black-70 { border-color: rgba(0, 0, 0, .7); }\n\n    .swagger-ui .b--black-60 { border-color: rgba(0, 0, 0, .6); }\n\n    .swagger-ui .b--black-50 { border-color: rgba(0, 0, 0, .5); }\n\n    .swagger-ui .b--black-40 { border-color: rgba(0, 0, 0, .4); }\n\n    .swagger-ui .b--black-30 { border-color: rgba(0, 0, 0, .3); }\n\n    .swagger-ui .b--black-20 { border-color: rgba(0, 0, 0, .2); }\n\n    .swagger-ui .b--black-10 { border-color: rgba(0, 0, 0, .1); }\n\n    .swagger-ui .b--black-05 { border-color: rgba(0, 0, 0, .05); }\n\n    .swagger-ui .b--black-025 { border-color: rgba(0, 0, 0, .024); }\n\n    .swagger-ui .b--black-0125 { border-color: rgba(0, 0, 0, .01); }\n\n    .swagger-ui .b--dark-red { border-color: #bc2f36; }\n\n    .swagger-ui .b--red { border-color: #c83932; }\n\n    .swagger-ui .b--light-red { border-color: #ab3c2b; }\n\n    .swagger-ui .b--orange { border-color: #cc6e33; }\n\n    .swagger-ui .b--purple { border-color: #5e2ca5; }\n\n    .swagger-ui .b--light-purple { border-color: #672caf; }\n\n    .swagger-ui .b--dark-pink { border-color: #ab2b81; }\n\n    .swagger-ui .b--hot-pink { border-color: #c03086; }\n\n    .swagger-ui .b--pink { border-color: #8f2464; }\n\n    .swagger-ui .b--light-pink { border-color: #721d4d; }\n\n    .swagger-ui .b--dark-green { border-color: #1c6e50; }\n\n    .swagger-ui .b--green { border-color: #279b70; }\n\n    .swagger-ui .b--light-green { border-color: #228762; }\n\n    .swagger-ui .b--navy { border-color: #0d1d35; }\n\n    .swagger-ui .b--dark-blue { border-color: #20497e; }\n\n    .swagger-ui .b--blue { border-color: #4380d0; }\n\n    .swagger-ui .b--light-blue { border-color: #20517e; }\n\n    .swagger-ui .b--lightest-blue { border-color: #143a52; }\n\n    .swagger-ui .b--washed-blue { border-color: #0c312d; }\n\n    .swagger-ui .b--washed-green { border-color: #0f3d2c; }\n\n    .swagger-ui .b--washed-red { border-color: #411010; }\n\n    .swagger-ui .b--transparent { border-color: transparent; }\n\n    .swagger-ui .b--gold, .swagger-ui .b--light-yellow, .swagger-ui .b--washed-yellow, .swagger-ui .b--yellow { border-color: #664b00; }\n\n    .swagger-ui .shadow-1 { box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; }\n\n    .swagger-ui .shadow-2 { box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; }\n\n    .swagger-ui .shadow-3 { box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; }\n\n    .swagger-ui .shadow-4 { box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; }\n\n    .swagger-ui .shadow-5 { box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; }\n\n    @media screen and (min-width: 30em) {\n        .swagger-ui .shadow-1-ns { box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; }\n\n        .swagger-ui .shadow-2-ns { box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; }\n\n        .swagger-ui .shadow-3-ns { box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; }\n\n        .swagger-ui .shadow-4-ns { box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; }\n\n        .swagger-ui .shadow-5-ns { box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; }\n    }\n\n    @media screen and (max-width: 60em) and (min-width: 30em) {\n        .swagger-ui .shadow-1-m { box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; }\n\n        .swagger-ui .shadow-2-m { box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; }\n\n        .swagger-ui .shadow-3-m { box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; }\n\n        .swagger-ui .shadow-4-m { box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; }\n\n        .swagger-ui .shadow-5-m { box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; }\n    }\n\n    @media screen and (min-width: 60em) {\n        .swagger-ui .shadow-1-l { box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; }\n\n        .swagger-ui .shadow-2-l { box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; }\n\n        .swagger-ui .shadow-3-l { box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; }\n\n        .swagger-ui .shadow-4-l { box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; }\n\n        .swagger-ui .shadow-5-l { box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; }\n    }\n\n    .swagger-ui .black-05 { color: rgba(191, 191, 191, .05); }\n\n    .swagger-ui .bg-black-05 { background-color: rgba(0, 0, 0, .05); }\n\n    .swagger-ui .black-90, .swagger-ui .hover-black-90:focus, .swagger-ui .hover-black-90:hover { color: rgba(191, 191, 191, .9); }\n\n    .swagger-ui .black-80, .swagger-ui .hover-black-80:focus, .swagger-ui .hover-black-80:hover { color: rgba(191, 191, 191, .8); }\n\n    .swagger-ui .black-70, .swagger-ui .hover-black-70:focus, .swagger-ui .hover-black-70:hover { color: rgba(191, 191, 191, .7); }\n\n    .swagger-ui .black-60, .swagger-ui .hover-black-60:focus, .swagger-ui .hover-black-60:hover { color: rgba(191, 191, 191, .6); }\n\n    .swagger-ui .black-50, .swagger-ui .hover-black-50:focus, .swagger-ui .hover-black-50:hover { color: rgba(191, 191, 191, .5); }\n\n    .swagger-ui .black-40, .swagger-ui .hover-black-40:focus, .swagger-ui .hover-black-40:hover { color: rgba(191, 191, 191, .4); }\n\n    .swagger-ui .black-30, .swagger-ui .hover-black-30:focus, .swagger-ui .hover-black-30:hover { color: rgba(191, 191, 191, .3); }\n\n    .swagger-ui .black-20, .swagger-ui .hover-black-20:focus, .swagger-ui .hover-black-20:hover { color: rgba(191, 191, 191, .2); }\n\n    .swagger-ui .black-10, .swagger-ui .hover-black-10:focus, .swagger-ui .hover-black-10:hover { color: rgba(191, 191, 191, .1); }\n\n    .swagger-ui .hover-white-90:focus, .swagger-ui .hover-white-90:hover, .swagger-ui .white-90 { color: rgba(255, 255, 255, .9); }\n\n    .swagger-ui .hover-white-80:focus, .swagger-ui .hover-white-80:hover, .swagger-ui .white-80 { color: rgba(255, 255, 255, .8); }\n\n    .swagger-ui .hover-white-70:focus, .swagger-ui .hover-white-70:hover, .swagger-ui .white-70 { color: rgba(255, 255, 255, .7); }\n\n    .swagger-ui .hover-white-60:focus, .swagger-ui .hover-white-60:hover, .swagger-ui .white-60 { color: rgba(255, 255, 255, .6); }\n\n    .swagger-ui .hover-white-50:focus, .swagger-ui .hover-white-50:hover, .swagger-ui .white-50 { color: rgba(255, 255, 255, .5); }\n\n    .swagger-ui .hover-white-40:focus, .swagger-ui .hover-white-40:hover, .swagger-ui .white-40 { color: rgba(255, 255, 255, .4); }\n\n    .swagger-ui .hover-white-30:focus, .swagger-ui .hover-white-30:hover, .swagger-ui .white-30 { color: rgba(255, 255, 255, .3); }\n\n    .swagger-ui .hover-white-20:focus, .swagger-ui .hover-white-20:hover, .swagger-ui .white-20 { color: rgba(255, 255, 255, .2); }\n\n    .swagger-ui .hover-white-10:focus, .swagger-ui .hover-white-10:hover, .swagger-ui .white-10 { color: rgba(255, 255, 255, .1); }\n\n    .swagger-ui .hover-moon-gray:focus, .swagger-ui .hover-moon-gray:hover, .swagger-ui .moon-gray { color: #ccc; }\n\n    .swagger-ui .hover-light-gray:focus, .swagger-ui .hover-light-gray:hover, .swagger-ui .light-gray { color: #ededed; }\n\n    .swagger-ui .hover-near-white:focus, .swagger-ui .hover-near-white:hover, .swagger-ui .near-white { color: #f5f5f5; }\n\n    .swagger-ui .dark-red, .swagger-ui .hover-dark-red:focus, .swagger-ui .hover-dark-red:hover { color: #e6999d; }\n\n    .swagger-ui .hover-red:focus, .swagger-ui .hover-red:hover, .swagger-ui .red { color: #e69d99; }\n\n    .swagger-ui .hover-light-red:focus, .swagger-ui .hover-light-red:hover, .swagger-ui .light-red { color: #e6a399; }\n\n    .swagger-ui .hover-orange:focus, .swagger-ui .hover-orange:hover, .swagger-ui .orange { color: #e6b699; }\n\n    .swagger-ui .gold, .swagger-ui .hover-gold:focus, .swagger-ui .hover-gold:hover { color: #e6d099; }\n\n    .swagger-ui .hover-yellow:focus, .swagger-ui .hover-yellow:hover, .swagger-ui .yellow { color: #e6da99; }\n\n    .swagger-ui .hover-light-yellow:focus, .swagger-ui .hover-light-yellow:hover, .swagger-ui .light-yellow { color: #ede6b6; }\n\n    .swagger-ui .hover-purple:focus, .swagger-ui .hover-purple:hover, .swagger-ui .purple { color: #b99ae4; }\n\n    .swagger-ui .hover-light-purple:focus, .swagger-ui .hover-light-purple:hover, .swagger-ui .light-purple { color: #bb99e6; }\n\n    .swagger-ui .dark-pink, .swagger-ui .hover-dark-pink:focus, .swagger-ui .hover-dark-pink:hover { color: #e699cc; }\n\n    .swagger-ui .hot-pink, .swagger-ui .hover-hot-pink:focus, .swagger-ui .hover-hot-pink:hover, .swagger-ui .hover-pink:focus, .swagger-ui .hover-pink:hover, .swagger-ui .pink { color: #e699c7; }\n\n    .swagger-ui .hover-light-pink:focus, .swagger-ui .hover-light-pink:hover, .swagger-ui .light-pink { color: #edb6d5; }\n\n    .swagger-ui .dark-green, .swagger-ui .green, .swagger-ui .hover-dark-green:focus, .swagger-ui .hover-dark-green:hover, .swagger-ui .hover-green:focus, .swagger-ui .hover-green:hover { color: #99e6c9; }\n\n    .swagger-ui .hover-light-green:focus, .swagger-ui .hover-light-green:hover, .swagger-ui .light-green { color: #a1e8ce; }\n\n    .swagger-ui .hover-navy:focus, .swagger-ui .hover-navy:hover, .swagger-ui .navy { color: #99b8e6; }\n\n    .swagger-ui .blue, .swagger-ui .dark-blue, .swagger-ui .hover-blue:focus, .swagger-ui .hover-blue:hover, .swagger-ui .hover-dark-blue:focus, .swagger-ui .hover-dark-blue:hover { color: #99bae6; }\n\n    .swagger-ui .hover-light-blue:focus, .swagger-ui .hover-light-blue:hover, .swagger-ui .light-blue { color: #a9cbea; }\n\n    .swagger-ui .hover-lightest-blue:focus, .swagger-ui .hover-lightest-blue:hover, .swagger-ui .lightest-blue { color: #d6e9f5; }\n\n    .swagger-ui .hover-washed-blue:focus, .swagger-ui .hover-washed-blue:hover, .swagger-ui .washed-blue { color: #f7fdfc; }\n\n    .swagger-ui .hover-washed-green:focus, .swagger-ui .hover-washed-green:hover, .swagger-ui .washed-green { color: #ebfaf4; }\n\n    .swagger-ui .hover-washed-yellow:focus, .swagger-ui .hover-washed-yellow:hover, .swagger-ui .washed-yellow { color: #fbf9ef; }\n\n    .swagger-ui .hover-washed-red:focus, .swagger-ui .hover-washed-red:hover, .swagger-ui .washed-red { color: #f9e7e7; }\n\n    .swagger-ui .color-inherit, .swagger-ui .hover-inherit:focus, .swagger-ui .hover-inherit:hover { color: inherit; }\n\n    .swagger-ui .bg-black-90, .swagger-ui .hover-bg-black-90:focus, .swagger-ui .hover-bg-black-90:hover { background-color: rgba(0, 0, 0, .9); }\n\n    .swagger-ui .bg-black-80, .swagger-ui .hover-bg-black-80:focus, .swagger-ui .hover-bg-black-80:hover { background-color: rgba(0, 0, 0, .8); }\n\n    .swagger-ui .bg-black-70, .swagger-ui .hover-bg-black-70:focus, .swagger-ui .hover-bg-black-70:hover { background-color: rgba(0, 0, 0, .7); }\n\n    .swagger-ui .bg-black-60, .swagger-ui .hover-bg-black-60:focus, .swagger-ui .hover-bg-black-60:hover { background-color: rgba(0, 0, 0, .6); }\n\n    .swagger-ui .bg-black-50, .swagger-ui .hover-bg-black-50:focus, .swagger-ui .hover-bg-black-50:hover { background-color: rgba(0, 0, 0, .5); }\n\n    .swagger-ui .bg-black-40, .swagger-ui .hover-bg-black-40:focus, .swagger-ui .hover-bg-black-40:hover { background-color: rgba(0, 0, 0, .4); }\n\n    .swagger-ui .bg-black-30, .swagger-ui .hover-bg-black-30:focus, .swagger-ui .hover-bg-black-30:hover { background-color: rgba(0, 0, 0, .3); }\n\n    .swagger-ui .bg-black-20, .swagger-ui .hover-bg-black-20:focus, .swagger-ui .hover-bg-black-20:hover { background-color: rgba(0, 0, 0, .2); }\n\n    .swagger-ui .bg-white-90, .swagger-ui .hover-bg-white-90:focus, .swagger-ui .hover-bg-white-90:hover { background-color: rgba(28, 28, 33, .9); }\n\n    .swagger-ui .bg-white-80, .swagger-ui .hover-bg-white-80:focus, .swagger-ui .hover-bg-white-80:hover { background-color: rgba(28, 28, 33, .8); }\n\n    .swagger-ui .bg-white-70, .swagger-ui .hover-bg-white-70:focus, .swagger-ui .hover-bg-white-70:hover { background-color: rgba(28, 28, 33, .7); }\n\n    .swagger-ui .bg-white-60, .swagger-ui .hover-bg-white-60:focus, .swagger-ui .hover-bg-white-60:hover { background-color: rgba(28, 28, 33, .6); }\n\n    .swagger-ui .bg-white-50, .swagger-ui .hover-bg-white-50:focus, .swagger-ui .hover-bg-white-50:hover { background-color: rgba(28, 28, 33, .5); }\n\n    .swagger-ui .bg-white-40, .swagger-ui .hover-bg-white-40:focus, .swagger-ui .hover-bg-white-40:hover { background-color: rgba(28, 28, 33, .4); }\n\n    .swagger-ui .bg-white-30, .swagger-ui .hover-bg-white-30:focus, .swagger-ui .hover-bg-white-30:hover { background-color: rgba(28, 28, 33, .3); }\n\n    .swagger-ui .bg-white-20, .swagger-ui .hover-bg-white-20:focus, .swagger-ui .hover-bg-white-20:hover { background-color: rgba(28, 28, 33, .2); }\n\n    .swagger-ui .bg-black, .swagger-ui .hover-bg-black:focus, .swagger-ui .hover-bg-black:hover { background-color: #000; }\n\n    .swagger-ui .bg-near-black, .swagger-ui .hover-bg-near-black:focus, .swagger-ui .hover-bg-near-black:hover { background-color: #121212; }\n\n    .swagger-ui .bg-dark-gray, .swagger-ui .hover-bg-dark-gray:focus, .swagger-ui .hover-bg-dark-gray:hover { background-color: #333; }\n\n    .swagger-ui .bg-mid-gray, .swagger-ui .hover-bg-mid-gray:focus, .swagger-ui .hover-bg-mid-gray:hover { background-color: #545454; }\n\n    .swagger-ui .bg-gray, .swagger-ui .hover-bg-gray:focus, .swagger-ui .hover-bg-gray:hover { background-color: #787878; }\n\n    .swagger-ui .bg-silver, .swagger-ui .hover-bg-silver:focus, .swagger-ui .hover-bg-silver:hover { background-color: #999; }\n\n    .swagger-ui .bg-white, .swagger-ui .hover-bg-white:focus, .swagger-ui .hover-bg-white:hover { background-color: #1c1c21; }\n\n    .swagger-ui .bg-transparent, .swagger-ui .hover-bg-transparent:focus, .swagger-ui .hover-bg-transparent:hover { background-color: transparent; }\n\n    .swagger-ui .bg-dark-red, .swagger-ui .hover-bg-dark-red:focus, .swagger-ui .hover-bg-dark-red:hover { background-color: #bc2f36; }\n\n    .swagger-ui .bg-red, .swagger-ui .hover-bg-red:focus, .swagger-ui .hover-bg-red:hover { background-color: #c83932; }\n\n    .swagger-ui .bg-light-red, .swagger-ui .hover-bg-light-red:focus, .swagger-ui .hover-bg-light-red:hover { background-color: #ab3c2b; }\n\n    .swagger-ui .bg-orange, .swagger-ui .hover-bg-orange:focus, .swagger-ui .hover-bg-orange:hover { background-color: #cc6e33; }\n\n    .swagger-ui .bg-gold, .swagger-ui .bg-light-yellow, .swagger-ui .bg-washed-yellow, .swagger-ui .bg-yellow, .swagger-ui .hover-bg-gold:focus, .swagger-ui .hover-bg-gold:hover, .swagger-ui .hover-bg-light-yellow:focus, .swagger-ui .hover-bg-light-yellow:hover, .swagger-ui .hover-bg-washed-yellow:focus, .swagger-ui .hover-bg-washed-yellow:hover, .swagger-ui .hover-bg-yellow:focus, .swagger-ui .hover-bg-yellow:hover { background-color: #664b00; }\n\n    .swagger-ui .bg-purple, .swagger-ui .hover-bg-purple:focus, .swagger-ui .hover-bg-purple:hover { background-color: #5e2ca5; }\n\n    .swagger-ui .bg-light-purple, .swagger-ui .hover-bg-light-purple:focus, .swagger-ui .hover-bg-light-purple:hover { background-color: #672caf; }\n\n    .swagger-ui .bg-dark-pink, .swagger-ui .hover-bg-dark-pink:focus, .swagger-ui .hover-bg-dark-pink:hover { background-color: #ab2b81; }\n\n    .swagger-ui .bg-hot-pink, .swagger-ui .hover-bg-hot-pink:focus, .swagger-ui .hover-bg-hot-pink:hover { background-color: #c03086; }\n\n    .swagger-ui .bg-pink, .swagger-ui .hover-bg-pink:focus, .swagger-ui .hover-bg-pink:hover { background-color: #8f2464; }\n\n    .swagger-ui .bg-light-pink, .swagger-ui .hover-bg-light-pink:focus, .swagger-ui .hover-bg-light-pink:hover { background-color: #721d4d; }\n\n    .swagger-ui .bg-dark-green, .swagger-ui .hover-bg-dark-green:focus, .swagger-ui .hover-bg-dark-green:hover { background-color: #1c6e50; }\n\n    .swagger-ui .bg-green, .swagger-ui .hover-bg-green:focus, .swagger-ui .hover-bg-green:hover { background-color: #279b70; }\n\n    .swagger-ui .bg-light-green, .swagger-ui .hover-bg-light-green:focus, .swagger-ui .hover-bg-light-green:hover { background-color: #228762; }\n\n    .swagger-ui .bg-navy, .swagger-ui .hover-bg-navy:focus, .swagger-ui .hover-bg-navy:hover { background-color: #0d1d35; }\n\n    .swagger-ui .bg-dark-blue, .swagger-ui .hover-bg-dark-blue:focus, .swagger-ui .hover-bg-dark-blue:hover { background-color: #20497e; }\n\n    .swagger-ui .bg-blue, .swagger-ui .hover-bg-blue:focus, .swagger-ui .hover-bg-blue:hover { background-color: #4380d0; }\n\n    .swagger-ui .bg-light-blue, .swagger-ui .hover-bg-light-blue:focus, .swagger-ui .hover-bg-light-blue:hover { background-color: #20517e; }\n\n    .swagger-ui .bg-lightest-blue, .swagger-ui .hover-bg-lightest-blue:focus, .swagger-ui .hover-bg-lightest-blue:hover { background-color: #143a52; }\n\n    .swagger-ui .bg-washed-blue, .swagger-ui .hover-bg-washed-blue:focus, .swagger-ui .hover-bg-washed-blue:hover { background-color: #0c312d; }\n\n    .swagger-ui .bg-washed-green, .swagger-ui .hover-bg-washed-green:focus, .swagger-ui .hover-bg-washed-green:hover { background-color: #0f3d2c; }\n\n    .swagger-ui .bg-washed-red, .swagger-ui .hover-bg-washed-red:focus, .swagger-ui .hover-bg-washed-red:hover { background-color: #411010; }\n\n    .swagger-ui .bg-inherit, .swagger-ui .hover-bg-inherit:focus, .swagger-ui .hover-bg-inherit:hover { background-color: inherit; }\n\n    .swagger-ui .shadow-hover { transition: all .5s cubic-bezier(.165, .84, .44, 1) 0s; }\n\n    .swagger-ui .shadow-hover::after {\n        border-radius: inherit;\n        box-shadow: rgba(0, 0, 0, .2) 0 0 16px 2px;\n        content: \"\";\n        height: 100%;\n        left: 0;\n        opacity: 0;\n        position: absolute;\n        top: 0;\n        transition: opacity .5s cubic-bezier(.165, .84, .44, 1) 0s;\n        width: 100%;\n        z-index: -1;\n    }\n\n    .swagger-ui .bg-animate, .swagger-ui .bg-animate:focus, .swagger-ui .bg-animate:hover { transition: background-color .15s ease-in-out 0s; }\n\n    .swagger-ui .nested-links a {\n        color: #99bae6;\n        transition: color .15s ease-in 0s;\n    }\n\n    .swagger-ui .nested-links a:focus, .swagger-ui .nested-links a:hover {\n        color: #a9cbea;\n        transition: color .15s ease-in 0s;\n    }\n\n    .swagger-ui .opblock-tag {\n        border-bottom: 1px solid rgba(58, 64, 80, .3);\n        color: #b5bac9;\n        transition: all .2s ease 0s;\n    }\n\n    .swagger-ui .opblock-tag svg, .swagger-ui section.models h4 svg { transition: all .4s ease 0s; }\n\n    .swagger-ui .opblock {\n        border: 1px solid #000;\n        border-radius: 4px;\n        box-shadow: rgba(0, 0, 0, .19) 0 0 3px;\n        margin: 0 0 15px;\n    }\n\n    .swagger-ui .opblock .tab-header .tab-item.active h4 span::after { background: gray; }\n\n    .swagger-ui .opblock.is-open .opblock-summary { border-bottom: 1px solid #000; }\n\n    .swagger-ui .opblock .opblock-section-header {\n        background: rgba(28, 28, 33, .8);\n        box-shadow: rgba(0, 0, 0, .1) 0 1px 2px;\n    }\n\n    .swagger-ui .opblock .opblock-section-header > label > span { padding: 0 10px 0 0; }\n\n    .swagger-ui .opblock .opblock-summary-method {\n        background: #000;\n        color: #fff;\n        text-shadow: rgba(0, 0, 0, .1) 0 1px 0;\n    }\n\n    .swagger-ui .opblock.opblock-post {\n        background: rgba(72, 203, 144, .1);\n        border-color: #48cb90;\n    }\n\n    .swagger-ui .opblock.opblock-post .opblock-summary-method, .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after { background: #48cb90; }\n\n    .swagger-ui .opblock.opblock-post .opblock-summary { border-color: #48cb90; }\n\n    .swagger-ui .opblock.opblock-put {\n        background: rgba(213, 157, 88, .1);\n        border-color: #d59d58;\n    }\n\n    .swagger-ui .opblock.opblock-put .opblock-summary-method, .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after { background: #d59d58; }\n\n    .swagger-ui .opblock.opblock-put .opblock-summary { border-color: #d59d58; }\n\n    .swagger-ui .opblock.opblock-delete {\n        background: rgba(200, 50, 50, .1);\n        border-color: #c83232;\n    }\n\n    .swagger-ui .opblock.opblock-delete .opblock-summary-method, .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after { background: #c83232; }\n\n    .swagger-ui .opblock.opblock-delete .opblock-summary { border-color: #c83232; }\n\n    .swagger-ui .opblock.opblock-get {\n        background: rgba(42, 105, 167, .1);\n        border-color: #2a69a7;\n    }\n\n    .swagger-ui .opblock.opblock-get .opblock-summary-method, .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after { background: #2a69a7; }\n\n    .swagger-ui .opblock.opblock-get .opblock-summary { border-color: #2a69a7; }\n\n    .swagger-ui .opblock.opblock-patch {\n        background: rgba(92, 214, 188, .1);\n        border-color: #5cd6bc;\n    }\n\n    .swagger-ui .opblock.opblock-patch .opblock-summary-method, .swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span::after { background: #5cd6bc; }\n\n    .swagger-ui .opblock.opblock-patch .opblock-summary { border-color: #5cd6bc; }\n\n    .swagger-ui .opblock.opblock-head {\n        background: rgba(140, 63, 207, .1);\n        border-color: #8c3fcf;\n    }\n\n    .swagger-ui .opblock.opblock-head .opblock-summary-method, .swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span::after { background: #8c3fcf; }\n\n    .swagger-ui .opblock.opblock-head .opblock-summary { border-color: #8c3fcf; }\n\n    .swagger-ui .opblock.opblock-options {\n        background: rgba(36, 89, 143, .1);\n        border-color: #24598f;\n    }\n\n    .swagger-ui .opblock.opblock-options .opblock-summary-method, .swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span::after { background: #24598f; }\n\n    .swagger-ui .opblock.opblock-options .opblock-summary { border-color: #24598f; }\n\n    .swagger-ui .opblock.opblock-deprecated {\n        background: rgba(46, 46, 46, .1);\n        border-color: #2e2e2e;\n        opacity: .6;\n    }\n\n    .swagger-ui .opblock.opblock-deprecated .opblock-summary-method, .swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span::after { background: #2e2e2e; }\n\n    .swagger-ui .opblock.opblock-deprecated .opblock-summary { border-color: #2e2e2e; }\n\n    .swagger-ui .filter .operation-filter-input { border: 2px solid #2b3446; }\n\n    .swagger-ui .tab li:first-of-type::after { background: rgba(0, 0, 0, .2); }\n\n    .swagger-ui .download-contents {\n        background: #7c8192;\n        color: #fff;\n    }\n\n    .swagger-ui .scheme-container {\n        background: #1c1c21;\n        box-shadow: rgba(0, 0, 0, .15) 0 1px 2px 0;\n    }\n\n    .swagger-ui .loading-container .loading::before {\n        animation: 1s linear 0s infinite normal none running rotation, .5s ease 0s 1 normal none running opacity;\n        border-color: rgba(0, 0, 0, .6) rgba(84, 84, 84, .1) rgba(84, 84, 84, .1);\n    }\n\n    .swagger-ui .response-control-media-type--accept-controller select { border-color: #196619; }\n\n    .swagger-ui .response-control-media-type__accept-message { color: #99e699; }\n\n    .swagger-ui .version-pragma__message code { background-color: #3b3b3b; }\n\n    .swagger-ui .btn {\n        background: 0 0;\n        border: 2px solid gray;\n        box-shadow: rgba(0, 0, 0, .1) 0 1px 2px;\n        color: #b5bac9;\n    }\n\n    .swagger-ui .btn:hover { box-shadow: rgba(0, 0, 0, .3) 0 0 5px; }\n\n    .swagger-ui .btn.authorize, .swagger-ui .btn.cancel {\n        background-color: transparent;\n        border-color: #a72a2a;\n        color: #e69999;\n    }\n\n    .swagger-ui .btn.cancel:hover {\n        background-color: #a72a2a;\n        color: #fff;\n    }\n\n    .swagger-ui .btn.authorize {\n        border-color: #48cb90;\n        color: #9ce3c3;\n    }\n\n    .swagger-ui .btn.authorize svg { fill: #9ce3c3; }\n\n    .btn.authorize.unlocked:hover {\n        background-color: #48cb90;\n        color: #fff;\n    }\n\n    .btn.authorize.unlocked:hover svg {\n        fill: #fbfbfb;\n    }\n\n    .swagger-ui .btn.execute {\n        background-color: #5892d5;\n        border-color: #5892d5;\n        color: #fff;\n    }\n\n    .swagger-ui .copy-to-clipboard { background: #7c8192; }\n\n    .swagger-ui .copy-to-clipboard button { background: url(\"data:image/svg+xml;charset=utf-8,<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" width=\\\"16\\\" height=\\\"16\\\" aria-hidden=\\\"true\\\"><path fill=\\\"%23fff\\\" fill-rule=\\\"evenodd\\\" d=\\\"M2 13h4v1H2v-1zm5-6H2v1h5V7zm2 3V8l-3 3 3 3v-2h5v-2H9zM4.5 9H2v1h2.5V9zM2 12h2.5v-1H2v1zm9 1h1v2c-.02.28-.11.52-.3.7-.19.18-.42.28-.7.3H1c-.55 0-1-.45-1-1V4c0-.55.45-1 1-1h3c0-1.11.89-2 2-2 1.11 0 2 .89 2 2h3c.55 0 1 .45 1 1v5h-1V6H1v9h10v-2zM2 5h8c0-.55-.45-1-1-1H8c-.55 0-1-.45-1-1s-.45-1-1-1-1 .45-1 1-.45 1-1 1H3c-.55 0-1 .45-1 1z\\\"/></svg>\") 50% center no-repeat; }\n\n    .swagger-ui select {\n        background: url(\"data:image/svg+xml;charset=utf-8,<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 20 20\\\"><path d=\\\"M13.418 7.859a.695.695 0 01.978 0 .68.68 0 010 .969l-3.908 3.83a.697.697 0 01-.979 0l-3.908-3.83a.68.68 0 010-.969.695.695 0 01.978 0L10 11l3.418-3.141z\\\"/></svg>\") right 10px center/20px no-repeat #212121;\n        background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQuc3ZnIgogICBpZD0ic3ZnNCIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMjAgMjAiPgogIDxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTEwIj4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZGVmcwogICAgIGlkPSJkZWZzOCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ic3ZnNCIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIgogICAgIGlua3NjYXBlOndpbmRvdy15PSItOSIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iLTkiCiAgICAgaW5rc2NhcGU6Y3k9IjEwIgogICAgIGlua3NjYXBlOmN4PSIxMCIKICAgICBpbmtzY2FwZTp6b29tPSI0MS41IgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEzLjQxOCA3Ljg1OWEuNjk1LjY5NSAwIDAxLjk3OCAwIC42OC42OCAwIDAxMCAuOTY5bC0zLjkwOCAzLjgzYS42OTcuNjk3IDAgMDEtLjk3OSAwbC0zLjkwOC0zLjgzYS42OC42OCAwIDAxMC0uOTY5LjY5NS42OTUgMCAwMS45NzggMEwxMCAxMWwzLjQxOC0zLjE0MXoiIC8+Cjwvc3ZnPgo=) right 10px center/20px no-repeat #1c1c21;\n        border: 2px solid #41444e;\n    }\n\n    .swagger-ui select[multiple] { background: #212121; }\n\n    .swagger-ui button.invalid, .swagger-ui input[type=email].invalid, .swagger-ui input[type=file].invalid, .swagger-ui input[type=password].invalid, .swagger-ui input[type=search].invalid, .swagger-ui input[type=text].invalid, .swagger-ui select.invalid, .swagger-ui textarea.invalid {\n        background: #390e0e;\n        border-color: #c83232;\n    }\n\n    .swagger-ui input[type=email], .swagger-ui input[type=file], .swagger-ui input[type=password], .swagger-ui input[type=search], .swagger-ui input[type=text], .swagger-ui textarea {\n        background: #1c1c21;\n        border: 1px solid #404040;\n    }\n\n    .swagger-ui textarea {\n        background: rgba(28, 28, 33, .8);\n        color: #b5bac9;\n    }\n\n    .swagger-ui input[disabled], .swagger-ui select[disabled] {\n        background-color: #1f1f1f;\n        color: #bfbfbf;\n    }\n\n    .swagger-ui textarea[disabled] {\n        background-color: #41444e;\n        color: #fff;\n    }\n\n    .swagger-ui select[disabled] { border-color: #878787; }\n\n    .swagger-ui textarea:focus { border: 2px solid #2a69a7; }\n\n    .swagger-ui .checkbox input[type=checkbox] + label > .item {\n        background: #303030;\n        box-shadow: #303030 0 0 0 2px;\n    }\n\n    .swagger-ui .checkbox input[type=checkbox]:checked + label > .item { background: url(\"data:image/svg+xml;charset=utf-8,<svg width=\\\"10\\\" height=\\\"8\\\" viewBox=\\\"3 7 10 8\\\" xmlns=\\\"http://www.w3.org/2000/svg\\\"><path fill=\\\"%2341474E\\\" fill-rule=\\\"evenodd\\\" d=\\\"M6.333 15L3 11.667l1.333-1.334 2 2L11.667 7 13 8.333z\\\"/></svg>\") 50% center no-repeat #303030; }\n\n    .swagger-ui .dialog-ux .backdrop-ux { background: rgba(0, 0, 0, .8); }\n\n    .swagger-ui .dialog-ux .modal-ux {\n        background: #1c1c21;\n        border: 1px solid #2e2e2e;\n        box-shadow: rgba(0, 0, 0, .2) 0 10px 30px 0;\n    }\n\n    .swagger-ui .dialog-ux .modal-ux-header .close-modal { background: 0 0; }\n\n    .swagger-ui .model .deprecated span, .swagger-ui .model .deprecated td { color: #bfbfbf !important; }\n\n    .swagger-ui .model-toggle::after { background: url(\"data:image/svg+xml;charset=utf-8,<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" width=\\\"24\\\" height=\\\"24\\\"><path d=\\\"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z\\\"/></svg>\") 50% center/100% no-repeat; }\n\n    .swagger-ui .model-hint {\n        background: rgba(0, 0, 0, .7);\n        color: #ebebeb;\n    }\n\n    .swagger-ui section.models { border: 1px solid rgba(58, 64, 80, .3); }\n\n    .swagger-ui section.models.is-open h4 { border-bottom: 1px solid rgba(58, 64, 80, .3); }\n\n    .swagger-ui section.models .model-container { background: rgba(0, 0, 0, .05); }\n\n    .swagger-ui section.models .model-container:hover { background: rgba(0, 0, 0, .07); }\n\n    .swagger-ui .model-box { background: rgba(0, 0, 0, .1); }\n\n    .swagger-ui .prop-type { color: #aaaad4; }\n\n    .swagger-ui table thead tr td, .swagger-ui table thead tr th {\n        border-bottom: 1px solid rgba(58, 64, 80, .2);\n        color: #b5bac9;\n    }\n\n    .swagger-ui .parameter__name.required::after { color: rgba(230, 153, 153, .6); }\n\n    .swagger-ui .topbar .download-url-wrapper .select-label { color: #f0f0f0; }\n\n    .swagger-ui .topbar .download-url-wrapper .download-url-button {\n        background: #63a040;\n        color: #fff;\n    }\n\n    .swagger-ui .info .title small { background: #7c8492; }\n\n    .swagger-ui .info .title small.version-stamp { background-color: #7a9b27; }\n\n    .swagger-ui .auth-container .errors {\n        background-color: #350d0d;\n        color: #b5bac9;\n    }\n\n    .swagger-ui .errors-wrapper {\n        background: rgba(200, 50, 50, .1);\n        border: 2px solid #c83232;\n    }\n\n    .swagger-ui .markdown code, .swagger-ui .renderedmarkdown code {\n        background: rgba(0, 0, 0, .05);\n        color: #c299e6;\n    }\n\n    .swagger-ui .model-toggle:after { background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQyLnN2ZyIKICAgaWQ9InN2ZzQiCiAgIHZlcnNpb249IjEuMSIKICAgaGVpZ2h0PSIyNCIKICAgd2lkdGg9IjI0Ij4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGExMCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczgiIC8+CiAgPHNvZGlwb2RpOm5hbWVkdmlldwogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9InN2ZzQiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTkiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9Ii05IgogICAgIGlua3NjYXBlOmN5PSIxMiIKICAgICBpbmtzY2FwZTpjeD0iMTIiCiAgICAgaW5rc2NhcGU6em9vbT0iMzQuNTgzMzMzIgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiIgLz4KPC9zdmc+Cg==) 50% no-repeat; }\n\n    /* arrows for each operation and request are now white */\n    .arrow, #large-arrow-up { fill: #fff; }\n\n    #unlocked { fill: #fff; }\n\n    ::-webkit-scrollbar-track { background-color: #646464 !important; }\n\n    ::-webkit-scrollbar-thumb {\n        background-color: #242424 !important;\n        border: 2px solid #3e4346 !important;\n    }\n\n    ::-webkit-scrollbar-button:vertical:start:decrement {\n        background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%);\n        background-color: #b6b6b6;\n    }\n\n    ::-webkit-scrollbar-button:vertical:end:increment {\n        background: linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%);\n        background-color: #b6b6b6;\n    }\n\n    ::-webkit-scrollbar-button:horizontal:end:increment {\n        background: linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%);\n        background-color: #b6b6b6;\n    }\n\n    ::-webkit-scrollbar-button:horizontal:start:decrement {\n        background: linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%);\n        background-color: #b6b6b6;\n    }\n\n    ::-webkit-scrollbar-button, ::-webkit-scrollbar-track-piece { background-color: #3e4346 !important; }\n\n    .swagger-ui .black, .swagger-ui .checkbox, .swagger-ui .dark-gray, .swagger-ui .download-url-wrapper .loading, .swagger-ui .errors-wrapper .errors small, .swagger-ui .fallback, .swagger-ui .filter .loading, .swagger-ui .gray, .swagger-ui .hover-black:focus, .swagger-ui .hover-black:hover, .swagger-ui .hover-dark-gray:focus, .swagger-ui .hover-dark-gray:hover, .swagger-ui .hover-gray:focus, .swagger-ui .hover-gray:hover, .swagger-ui .hover-light-silver:focus, .swagger-ui .hover-light-silver:hover, .swagger-ui .hover-mid-gray:focus, .swagger-ui .hover-mid-gray:hover, .swagger-ui .hover-near-black:focus, .swagger-ui .hover-near-black:hover, .swagger-ui .hover-silver:focus, .swagger-ui .hover-silver:hover, .swagger-ui .light-silver, .swagger-ui .markdown pre, .swagger-ui .mid-gray, .swagger-ui .model .property, .swagger-ui .model .property.primitive, .swagger-ui .model-title, .swagger-ui .near-black, .swagger-ui .parameter__extension, .swagger-ui .parameter__in, .swagger-ui .prop-format, .swagger-ui .renderedmarkdown pre, .swagger-ui .response-col_links .response-undocumented, .swagger-ui .response-col_status .response-undocumented, .swagger-ui .silver, .swagger-ui section.models h4, .swagger-ui section.models h5, .swagger-ui span.token-not-formatted, .swagger-ui span.token-string, .swagger-ui table.headers .header-example, .swagger-ui table.model tr.description, .swagger-ui table.model tr.extension { color: #bfbfbf; }\n\n    .swagger-ui .hover-white:focus, .swagger-ui .hover-white:hover, .swagger-ui .info .title small pre, .swagger-ui .topbar a, .swagger-ui .white { color: #fff; }\n\n    .swagger-ui .bg-black-10, .swagger-ui .hover-bg-black-10:focus, .swagger-ui .hover-bg-black-10:hover, .swagger-ui .stripe-dark:nth-child(2n + 1) { background-color: rgba(0, 0, 0, .1); }\n\n    .swagger-ui .bg-white-10, .swagger-ui .hover-bg-white-10:focus, .swagger-ui .hover-bg-white-10:hover, .swagger-ui .stripe-light:nth-child(2n + 1) { background-color: rgba(28, 28, 33, .1); }\n\n    .swagger-ui .bg-light-silver, .swagger-ui .hover-bg-light-silver:focus, .swagger-ui .hover-bg-light-silver:hover, .swagger-ui .striped--light-silver:nth-child(2n + 1) { background-color: #6e6e6e; }\n\n    .swagger-ui .bg-moon-gray, .swagger-ui .hover-bg-moon-gray:focus, .swagger-ui .hover-bg-moon-gray:hover, .swagger-ui .striped--moon-gray:nth-child(2n + 1) { background-color: #4d4d4d; }\n\n    .swagger-ui .bg-light-gray, .swagger-ui .hover-bg-light-gray:focus, .swagger-ui .hover-bg-light-gray:hover, .swagger-ui .striped--light-gray:nth-child(2n + 1) { background-color: #2b2b2b; }\n\n    .swagger-ui .bg-near-white, .swagger-ui .hover-bg-near-white:focus, .swagger-ui .hover-bg-near-white:hover, .swagger-ui .striped--near-white:nth-child(2n + 1) { background-color: #242424; }\n\n    .swagger-ui .opblock-tag:hover, .swagger-ui section.models h4:hover { background: rgba(0, 0, 0, .02); }\n\n    .swagger-ui .checkbox p, .swagger-ui .dialog-ux .modal-ux-content h4, .swagger-ui .dialog-ux .modal-ux-content p, .swagger-ui .dialog-ux .modal-ux-header h3, .swagger-ui .errors-wrapper .errors h4, .swagger-ui .errors-wrapper hgroup h4, .swagger-ui .info .base-url, .swagger-ui .info .title, .swagger-ui .info h1, .swagger-ui .info h2, .swagger-ui .info h3, .swagger-ui .info h4, .swagger-ui .info h5, .swagger-ui .info li, .swagger-ui .info p, .swagger-ui .info table, .swagger-ui .loading-container .loading::after, .swagger-ui .model, .swagger-ui .opblock .opblock-section-header h4, .swagger-ui .opblock .opblock-section-header > label, .swagger-ui .opblock .opblock-summary-description, .swagger-ui .opblock .opblock-summary-operation-id, .swagger-ui .opblock .opblock-summary-path, .swagger-ui .opblock .opblock-summary-path__deprecated, .swagger-ui .opblock-description-wrapper, .swagger-ui .opblock-description-wrapper h4, .swagger-ui .opblock-description-wrapper p, .swagger-ui .opblock-external-docs-wrapper, .swagger-ui .opblock-external-docs-wrapper h4, .swagger-ui .opblock-external-docs-wrapper p, .swagger-ui .opblock-tag small, .swagger-ui .opblock-title_normal, .swagger-ui .opblock-title_normal h4, .swagger-ui .opblock-title_normal p, .swagger-ui .parameter__name, .swagger-ui .parameter__type, .swagger-ui .response-col_links, .swagger-ui .response-col_status, .swagger-ui .responses-inner h4, .swagger-ui .responses-inner h5, .swagger-ui .scheme-container .schemes > label, .swagger-ui .scopes h2, .swagger-ui .servers > label, .swagger-ui .tab li, .swagger-ui label, .swagger-ui select, .swagger-ui table.headers td { color: #b5bac9; }\n\n    .swagger-ui .download-url-wrapper .failed, .swagger-ui .filter .failed, .swagger-ui .model-deprecated-warning, .swagger-ui .parameter__deprecated, .swagger-ui .parameter__name.required span, .swagger-ui table.model tr.property-row .star { color: #e69999; }\n\n    .swagger-ui .opblock-body pre.microlight, .swagger-ui textarea.curl {\n        background: #41444e;\n        border-radius: 4px;\n        color: #fff;\n    }\n\n    .swagger-ui .expand-methods svg, .swagger-ui .expand-methods:hover svg { fill: #bfbfbf; }\n\n    .swagger-ui .auth-container, .swagger-ui .dialog-ux .modal-ux-header { border-bottom: 1px solid #2e2e2e; }\n\n    .swagger-ui .topbar .download-url-wrapper .select-label select, .swagger-ui .topbar .download-url-wrapper input[type=text] { border: 2px solid #63a040; }\n\n    .swagger-ui .info a, .swagger-ui .info a:hover, .swagger-ui .scopes h2 a { color: #99bde6; }\n\n    /* Dark Scrollbar */\n    ::-webkit-scrollbar {\n        width: 14px;\n        height: 14px;\n    }\n\n    ::-webkit-scrollbar-button {\n        background-color: #3e4346 !important;\n    }\n\n    ::-webkit-scrollbar-track {\n        background-color: #646464 !important;\n    }\n\n    ::-webkit-scrollbar-track-piece {\n        background-color: #3e4346 !important;\n    }\n\n    ::-webkit-scrollbar-thumb {\n        height: 50px;\n        background-color: #242424 !important;\n        border: 2px solid #3e4346 !important;\n    }\n\n    ::-webkit-scrollbar-corner {}\n\n    ::-webkit-resizer {}\n\n    ::-webkit-scrollbar-button:vertical:start:decrement {\n        background:\n            linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),\n            linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%),\n            linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%);\n        background-color: #b6b6b6;\n    }\n\n    ::-webkit-scrollbar-button:vertical:end:increment {\n        background:\n            linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),\n            linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%),\n            linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%);\n        background-color: #b6b6b6;\n    }\n\n    ::-webkit-scrollbar-button:horizontal:end:increment {\n        background:\n            linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),\n            linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%),\n            linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%);\n        background-color: #b6b6b6;\n    }\n\n    ::-webkit-scrollbar-button:horizontal:start:decrement {\n        background:\n            linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),\n            linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%),\n            linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%);\n        background-color: #b6b6b6;\n    }\n}\n"
  },
  {
    "path": "static/test.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n  <head>\n    <meta charset=\"utf-8\">\n    <base href=\"/v/gtag/\">\n    <title>Grist Tests</title>\n    <script src=\"jquery/dist/jquery.min.js\"></script>\n    <script src=\"plotly/plotly-latest.min.js\"></script>\n    <script src=\"./mocha.js\"></script>\n    <script>mocha.setup('bdd')</script>\n    <script src=\"./test.bundle.js\"></script>\n    <script>\n      onload = function() {\n        $('#app-test').ready(function() {\n            try {\n              window.loadTests();\n            } catch (err) {\n              console.log(\"ERROR\", err);\n              mocha.failedTests = [];\n              mocha.failedTests.push({title: 'Failed to load', error: err.toString()});\n              document.getElementById('mocha-status').textContent = 'DONE - FAILED TO LOAD';\n              return;\n            }\n\n            mocha.checkLeaks();\n            // fxdriver_id and ret_nodes are set by selenium, execWebdriverJQuery by webdriverjq.js.\n            mocha.globals(['cmd', 'fxdriver_id', 'ret_nodes', 'execWebdriverJQuery']);\n            var runner = mocha.run();\n            mocha.failedTests = [];\n            runner.on('fail', function(test, err) {\n              mocha.failedTests.push({title: test.fullTitle(), error: err.toString()});\n            });\n            runner.on('end', function() {\n              document.getElementById('mocha-status').textContent = runner.failures > 0 ? 'DONE - FAILURE :(' : 'DONE - SUCCESS :)';\n            });\n        });\n      };\n\n      function scrollToBottom() {\n        var bottom = document.getElementById('mocha-end');\n        bottom.scrollIntoView(true);\n      }\n\n      afterEach(function() {\n        // keep scrolled to the bottom\n        return scrollToBottom();\n      });\n\n      after(function() {\n        // keep scrolled to the bottom\n        return scrollToBottom();\n      });\n    </script>\n\n    <style>\n      #mocha {\n        width: 50%;\n      }\n\n      #app-test {\n        position: fixed;\n        margin: -8px;\n        width: 40%;\n        height: 80%;\n        top: 20%;\n        left: 60%;\n      }\n\n      #mocha-status {\n        position: fixed;\n        bottom: 0px;\n        padding: 1rem;\n        border: 2px solid #cc9;\n        font-family: Helvetica, Arial, sans-serif;\n      }\n\n      /* mostly match #mocha-stats class */\n      .extra-info {\n        position: fixed;\n        top: 60px;\n        right: 10px;\n        font-size: 12px;\n        color: #888;\n        z-index: 1;\n      }\n\n    </style>\n\n    <link rel=\"stylesheet\" href=\"./mocha.css\">\n  </head>\n\n  <body>\n    <div id=\"mocha\">\n      <div class=\"extra-info\">\n        <a href=\"/test.html?timing=1\">Run tests with timings</a>\n      </div>\n    </div>\n    <div id=\"mocha-end\">&nbsp;</div>\n    <div id=\"mocha-status\">TBD - RUNNING...</div>\n  </body>\n\n</html>\n\n"
  },
  {
    "path": "static/testWebdriverJQuery.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\">\n    <base href=\"/v/gtag/\">\n    <title>WebdriverJQuery test</title>\n    <script src=\"jquery/dist/jquery.min.js\"></script>\n  </head>\n\n  <body>\n    <div class=\"foo bar\">\n      <span class=\"baz\">\n        Hello world\n      </span>\n    </div>\n    <div class=\"bar\">\n      <span class=\"baz\">\n        Good bye\n        <input id=\"btn\" type=\"button\" value=\"Go\" onclick=\"this.value += 'o'\">\n      </span>\n    </div>\n  </body>\n\n</html>\n"
  },
  {
    "path": "stubs/app/client/components/Banners.ts",
    "content": "export { buildHomeBanners, buildDocumentBanners } from \"app/client/components/CoreBanners\";\n"
  },
  {
    "path": "stubs/app/client/ui/ActivationPage.ts",
    "content": "import {\n  DefaultActivationPage, IActivationPageCreator,\n} from \"app/client/ui/DefaultActivationPage\";\n\nexport function getActivationPage(): IActivationPageCreator {\n  return DefaultActivationPage;\n}\n\nexport function showEnterpriseToggle() {\n  // To be changed by enterprise module\n  return false;\n}\n"
  },
  {
    "path": "stubs/app/client/ui/AdminControls.ts",
    "content": "import type { AppModel } from \"app/client/models/AppModel\";\nimport type { IDisposableOwner } from \"grainjs\";\n\nexport function buildAdminData(owner: IDisposableOwner, appModel: AppModel) {\n  return null;\n}\n"
  },
  {
    "path": "stubs/app/client/ui/BillingPage.ts",
    "content": "import { AppModel } from \"app/client/models/AppModel\";\n\nimport { Disposable } from \"grainjs\";\n\nexport class BillingPage extends Disposable {\n  constructor(_appModel: AppModel) {\n    super();\n  }\n\n  public buildDom() {\n    return null;\n  }\n}\n"
  },
  {
    "path": "stubs/app/client/ui/ChangePasswordDialog.ts",
    "content": "export function buildChangePasswordDialog() {\n  return null;\n}\n"
  },
  {
    "path": "stubs/app/client/ui/CustomThemes.ts",
    "content": "export type ProductFlavor = string;\n\n// TODO: move CustomTheme type outside of stub code\nexport interface CustomTheme {\n  bodyClassName?: string;\n  wideLogo?: boolean;\n}\n\nexport function getTheme(flavor: string): CustomTheme {\n  return {\n  };\n}\n"
  },
  {
    "path": "stubs/app/client/ui/DeleteAccountDialog.ts",
    "content": "import { FullUser } from \"app/common/UserAPI\";\n\nimport { Disposable } from \"grainjs\";\n\nexport class DeleteAccountDialog extends Disposable {\n  constructor(appModel: FullUser) {\n    super();\n  }\n\n  public buildDom() {\n    return null;\n  }\n}\n"
  },
  {
    "path": "stubs/app/client/ui/HomeImports.ts",
    "content": "import * as coreHomeImports from \"app/client/ui/CoreHomeImports\";\nexport const homeImports = coreHomeImports;\n"
  },
  {
    "path": "stubs/app/client/ui/MFAConfig.ts",
    "content": "import { FullUser } from \"app/common/UserAPI\";\n\nimport { Disposable } from \"grainjs\";\n\nexport class MFAConfig extends Disposable {\n  constructor(_user: FullUser) { super(); }\n\n  public buildDom() { return null; }\n}\n"
  },
  {
    "path": "stubs/app/client/ui/NewDocMethods.ts",
    "content": "import * as coreNewDocMethods from \"app/client/ui/CoreNewDocMethods\";\nexport const newDocMethods = coreNewDocMethods;\n"
  },
  {
    "path": "stubs/app/client/ui/Notifications.ts",
    "content": "import { DocInfo } from \"app/client/models/DocPageModel\";\nimport { DocAPI } from \"app/common/UserAPI\";\n\nimport { DomContents, IDisposableOwner } from \"grainjs\";\n\n// This is a stub, to be overridden in versions of Grist that implement Notifications.\nexport function buildNotificationsConfig(owner: IDisposableOwner, docAPI: DocAPI, doc: DocInfo | null): DomContents {\n  return null;\n}\n"
  },
  {
    "path": "stubs/app/client/ui/ProductUpgrades.ts",
    "content": "export * from \"app/client/ui/CreateTeamModal\";\n"
  },
  {
    "path": "stubs/app/client/widgets/AssistantPopup.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { IAssistantPopup } from \"app/client/ui/IAssistantPopup\";\n\nimport { DomElementArg } from \"grainjs\";\n\nexport function buildAssistantPopup(_gristDoc: GristDoc): IAssistantPopup | null {\n  return null;\n}\n\nexport function buildOpenAssistantButton(\n  _gristDoc: GristDoc,\n  ..._args: DomElementArg[]\n) {\n  return null;\n}\n"
  },
  {
    "path": "stubs/app/common/version.ts",
    "content": "import packageJson from \"package.json\";\n\nexport const version = packageJson.version;\nexport const channel = \"core\";\nexport const gitcommit = \"unknown\";\n"
  },
  {
    "path": "stubs/app/server/declarations.d.ts",
    "content": "// Copy official sqlite3 types to apply to @gristlabs/sqlite3.\ndeclare module \"@gristlabs/sqlite3\" {\n  export * from \"sqlite3\";\n\n  // Add minimal typings for sqlite backup api.\n  // TODO: remove this once the type definitions are updated upstream.\n  import { Database } from \"sqlite3\";\n  export class Backup {\n    public readonly remaining: number;\n    public readonly pageCount: number;\n    public readonly idle: boolean;\n    public readonly completed: boolean;\n    public readonly failed: boolean;\n    public step(pages: number, callback?: (err: Error | null) => void): void;\n    public finish(callback?: (err: Error | null) => void): void;\n  }\n  export class DatabaseWithBackup extends Database {\n    public backup(filename: string, callback?: (err: Error | null) => void): Backup;\n    public backup(filename: string, destDbName: \"main\", srcDbName: \"main\",\n      filenameIsDest: boolean, callback?: (err: Error | null) => void): Backup;\n  }\n}\n\n// Add declarations of the promisified methods of redis.\n// This is not exhaustive, there are a *lot* of methods.\n\ndeclare module \"redis\" {\n  function createClient(url?: string): RedisClient;\n\n  class RedisClient {\n    public readonly connected: boolean;\n    public eval(args: any[], callback?: (err: Error | null, res: any) => void): any;\n    public evalAsync(...args: any[]): Promise<any>;\n\n    public subscribe(channel: string): void;\n    public on(eventType: string, callback: (...args: any[]) => void): void;\n    public publishAsync(channel: string, message: string): Promise<number>;\n\n    public delAsync(key: string): Promise<\"OK\">;\n    public flushdbAsync(): Promise<void>;\n    public getAsync(key: string): Promise<string | null>;\n    public hdelAsync(key: string, field: string): Promise<number>;\n    public hgetallAsync(key: string): Promise<{ [field: string]: any } | null>;\n    public hkeysAsync(key: string): Promise<string[] | null>;\n    public hmsetAsync(key: string, val: { [field: string]: any }): Promise<\"OK\">;\n    public hsetAsync(key: string, field: string, val: string): Promise<1 | 0>;\n    public keysAsync(pattern: string): Promise<string[]>;\n    public multi(): Multi;\n    public quitAsync(): Promise<void>;\n    public saddAsync(key: string, val: string): Promise<\"OK\">;\n    public selectAsync(db: number): Promise<void>;\n    public setAsync(key: string, val: string): Promise<\"OK\">;\n    public setexAsync(key: string, ttl: number, val: string): Promise<\"OK\">;\n    public sismemberAsync(key: string, val: string): Promise<0 | 1>;\n    public smembersAsync(key: string): Promise<string[]>;\n    public srandmemberAsync(key: string): Promise<string | null>;\n    public sremAsync(key: string, val: string): Promise<\"OK\">;\n    public ttlAsync(key: string): Promise<number | null>;\n    public unwatchAsync(): Promise<\"OK\">;\n    public watchAsync(key: string): Promise<void>;\n    public lrangeAsync(key: string, start: number, end: number): Promise<string[]>;\n    public rpushAsync(key: string, ...vals: string[]): Promise<number>;\n    public pingAsync(): Promise<string>;\n    public zaddAsync(key: string, ...args: any[]): Promise<\"OK\">;\n    public zremAsync(key: string, val: string): Promise<\"OK\">;\n    public zrangeAsync(key: string, ...args: any[]): Promise<string[]>;\n    public zscoreAsync(key: string, val: string): Promise<number>;\n  }\n\n  class Multi {\n    public del(key: string): Multi;\n    public execAsync(): Promise<any[] | null>;\n    public get(key: string): Multi;\n    public hgetall(key: string): Multi;\n    public hmset(key: string, val: { [field: string]: any }): Multi;\n    public hset(key: string, field: string, val: string): Multi;\n    public sadd(key: string, val: string): Multi;\n    public set(key: string, val: string): Multi;\n    public setex(key: string, ttl: number, val: string): Multi;\n    public ttl(key: string): Multi;\n    public smembers(key: string): Multi;\n    public srandmember(key: string): Multi;\n    public srem(key: string, val: string): Multi;\n    public rpush(key: string, ...vals: string[]): Multi;\n    public ltrim(key: string, start: number, end: number): Multi;\n    public incr(key: string): Multi;\n    public expire(key: string, seconds: number): Multi;\n  }\n}\n"
  },
  {
    "path": "stubs/app/server/lib/create.ts",
    "content": "import { CoreCreate } from \"app/server/lib/coreCreator\";\nimport { ICreate } from \"app/server/lib/ICreate\";\n\nexport const create: ICreate = new CoreCreate();\n"
  },
  {
    "path": "stubs/app/server/lib/globalConfig.ts",
    "content": "import { IGristCoreConfig, loadGristCoreConfigFile } from \"app/server/lib/configCore\";\nimport log from \"app/server/lib/log\";\nimport { getInstanceRoot } from \"app/server/lib/places\";\n\nimport path from \"path\";\n\nconst globalConfigPath: string = path.join(getInstanceRoot(), \"config.json\");\nlet cachedGlobalConfig: IGristCoreConfig | undefined = undefined;\n\n/**\n * Retrieves the cached grist config, or loads it from the default global path.\n */\nexport function getGlobalConfig(): IGristCoreConfig {\n  if (!cachedGlobalConfig) {\n    log.info(`Loading config file from ${globalConfigPath}`);\n    cachedGlobalConfig = loadGristCoreConfigFile(globalConfigPath);\n  }\n\n  return cachedGlobalConfig;\n}\n"
  },
  {
    "path": "stubs/app/server/lib/loginSystems.ts",
    "content": "export { LOGIN_SYSTEMS } from \"app/server/lib/coreLogins\";\n"
  },
  {
    "path": "stubs/app/server/prometheus-exporter.ts",
    "content": "import http from \"http\";\n\nimport { collectDefaultMetrics, register } from \"prom-client\";\n\nconst reqListener = (req: http.IncomingMessage, res: http.ServerResponse) => {\n  register.metrics().then((metrics) => {\n    res.writeHead(200, { \"Content-Type\": register.contentType });\n    res.end(metrics);\n  }).catch((e) => {\n    res.writeHead(500);\n    res.end(e.message);\n  });\n};\n\nexport function runPrometheusExporter(port: number) {\n  collectDefaultMetrics();\n\n  if (isNaN(port)) {\n    throw new Error(`Invalid port: ${process.env.GRIST_PROMCLIENT_PORT}`);\n  }\n  const server = http.createServer(reqListener);\n  server.listen(port, \"0.0.0.0\");\n\n  console.log(`Prometheus exporter listening on port ${port}.`);\n  return server;\n}\n"
  },
  {
    "path": "stubs/app/server/server.ts",
    "content": "/**\n * Main entrypoint for grist-core server.\n *\n * By default, starts up on port 8484.\n */\n\nimport { normalizeEmail } from \"app/common/emails\";\nimport { commonUrls } from \"app/common/gristUrls\";\nimport { isAffirmative } from \"app/common/gutil\";\nimport { ActivationsManager } from \"app/gen-server/lib/ActivationsManager\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { AppSettings } from \"app/server/lib/AppSettings\";\nimport { updateDb } from \"app/server/lib/dbUtils\";\nimport { getAdminOrDefaultEmail } from \"app/server/lib/InstallAdmin\";\nimport log from \"app/server/lib/log\";\nimport { runPrometheusExporter } from \"app/server/prometheus-exporter\";\n\nimport * as fse from \"fs-extra\";\n\nconst debugging = isAffirmative(process.env.DEBUG) || isAffirmative(process.env.VERBOSE);\n\n// Set log levels before importing anything.\nif (!debugging) {\n  // Be a lot less noisy by default.\n  setDefaultEnv(\"GRIST_LOG_LEVEL\", \"error\");\n}\n\n// Use a distinct cookie.  Bump version to 2.\nsetDefaultEnv(\"GRIST_SESSION_COOKIE\", \"grist_core2\");\n\nsetDefaultEnv(\"GRIST_SERVE_SAME_ORIGIN\", \"true\");\nif (!process.env.DOC_WORKER_COUNT) {\n  setDefaultEnv(\"GRIST_SINGLE_PORT\", \"true\");\n}\nsetDefaultEnv(\"GRIST_DEFAULT_PRODUCT\", \"Free\");\n\nif (!process.env.GRIST_SINGLE_ORG) {\n  // org identifiers in domains are fiddly to configure right, so by\n  // default don't do that.\n  setDefaultEnv(\"GRIST_ORG_IN_PATH\", \"true\");\n}\n\nsetDefaultEnv(\"GRIST_UI_FEATURES\",\n  \"helpCenter,billing,templates,multiSite,multiAccounts,importFromAirtable,sendToDrive,createSite,supportGrist,themes\");\nsetDefaultEnv(\"GRIST_WIDGET_LIST_URL\", commonUrls.gristLabsWidgetRepository);\n\n// It's important that this comes after the setDefaultEnv calls above. MergedServer reads\n// some env vars at import time, including GRIST_WIDGET_LIST_URL.\n// TODO: Fix this reliance on side effects during import.\n// eslint-disable-next-line @import-x/order\nimport { MergedServer, parseServerTypes } from \"app/server/MergedServer\";\n\nconst G = {\n  port: parseInt(process.env.PORT!, 10) || 8484,\n};\n\n// Set a default for an environment variable.\nfunction setDefaultEnv(name: string, value: string) {\n  if (process.env[name] === undefined) {\n    process.env[name] = value;\n  }\n}\n\n/**\n * Creates the database if needed and applies pending migrations.\n *\n * Returns an instance of {@link HomeDBManager} connected to the database.\n */\nasync function createOrUpdateDb() {\n  // Make a blank db if needed.\n  if (process.env.TEST_CLEAN_DATABASE) {\n    // eslint-disable-next-line @typescript-eslint/no-require-imports\n    const { createInitialDb } = require(\"test/gen-server/seed\");\n    await createInitialDb();\n  } else {\n    await updateDb();\n  }\n  const db = new HomeDBManager();\n  await db.connect();\n  await db.initializeSpecialIds({ skipWorkspaces: true });\n  return db;\n}\n\n/**\n * Sets `GRIST_ADMIN_EMAIL` if `onRestartSetDefaultEmail` or `onRestartReplaceEmailWithAdmin`\n * are found in the `prefs` column of the `activations` table.\n *\n * This function is only intended for self-managed flavors of Grist (grist-core, grist-ee).\n * In the version of Grist hosted on getgrist.com, we use a different server entrypoint\n * and this file is unused. This is intentional, as we currently only want the preferences\n * above to take effect in self-managed flavors of Grist.\n */\nasync function setUpAdminEmail(db: HomeDBManager) {\n  try {\n    await db.runInTransaction(undefined, async (manager) => {\n      const activations = new ActivationsManager(db);\n      const { onRestartSetAdminEmail, onRestartReplaceEmailWithAdmin } = await activations.deletePrefs(\n        [\"onRestartSetAdminEmail\", \"onRestartReplaceEmailWithAdmin\"],\n        { transaction: manager },\n      );\n\n      const settings = new AppSettings(\"grist\");\n      const envVars = (await activations.current(manager)).prefs?.envVars || {};\n      settings.setEnvVars(envVars);\n\n      if (onRestartSetAdminEmail) {\n        log.info(`Setting GRIST_ADMIN_EMAIL to \"${onRestartSetAdminEmail}\".`);\n        const newEnvVars = { ...envVars, GRIST_ADMIN_EMAIL: onRestartSetAdminEmail };\n        await activations.updateAppEnvFile(newEnvVars, manager);\n        settings.setEnvVars(newEnvVars);\n        log.info(`Successfully set GRIST_ADMIN_EMAIL to \"${onRestartSetAdminEmail}\".`);\n      }\n\n      if (onRestartReplaceEmailWithAdmin) {\n        const adminEmail = getAdminOrDefaultEmail(settings);\n        if (!adminEmail) {\n          // We can reach this if GRIST_DEFAULT_EMAIL is set to \"\". The `setDefaultEnv`\n          // call that sets \"you@example.com\" as the default value for GRIST_DEFAULT_EMAIL\n          // is one place that lets such a value through. We can and probably should tighten\n          // things up to treat empty string as undefined, but need to check expectations\n          // elsewhere in code (e.g. an`AdminPanel` browser test sets it to \"\").\n          //\n          // TODO: Check implications of defaulting \"\" to \"you@example.com\".\n          throw new Error(\"GRIST_ADMIN_EMAIL and GRIST_DEFAULT_EMAIL are not set\");\n        }\n\n        if (normalizeEmail(onRestartReplaceEmailWithAdmin) === normalizeEmail(adminEmail)) {\n          return;\n        }\n\n        log.info(`Replacing \"${onRestartReplaceEmailWithAdmin}\" with GRIST_ADMIN_EMAIL (\"${adminEmail}\").`);\n        const user = await db.getExistingUserByLogin(onRestartReplaceEmailWithAdmin, manager);\n        if (!user) {\n          throw new Error(`user with email \"${onRestartReplaceEmailWithAdmin}\" not found`);\n        }\n\n        // If a user with `adminEmail` exists, we can't assign it to another user\n        // without violating the uniqueness constraint on the `email` column in the\n        // `logins` table. For now, just inform the user.\n        if (await db.getExistingUserByLogin(adminEmail, manager)) {\n          throw new Error(`cannot replace \"${onRestartReplaceEmailWithAdmin}\" with \"${adminEmail}\" ` +\n            \"because a user with that email already exists\");\n        }\n\n        const login = user.logins[0];\n        login.email = normalizeEmail(adminEmail);\n        login.displayEmail = adminEmail;\n        user.name = \"\";\n        await manager.save([login, user]);\n        log.info(`Successfully replaced \"${onRestartReplaceEmailWithAdmin}\" with GRIST_ADMIN_EMAIL (\"${adminEmail}\").`);\n      }\n    });\n  } catch (err) {\n    // Don't re-throw so we don't disrupt the rest of the startup process.\n    log.error(\"Failed to set up admin email:\", err);\n  }\n}\n\n/**\n * If `GRIST_SINGLE_ORG` is set to a value other than `\"docs\"`, checks that the org\n * exists and creates it if needed (with `getAdminOrDefaultEmail()` as the owner).\n */\nasync function setUpSingleOrg(db: HomeDBManager) {\n  // If a team/organization is specified, make sure it exists.\n  const org = process.env.GRIST_SINGLE_ORG;\n  if (org && org !== \"docs\") {\n    try {\n      db.unwrapQueryResult(await db.getOrg({\n        userId: db.getPreviewerUserId(),\n        includeSupport: false,\n      }, org));\n    } catch (e) {\n      if (!String(e).match(/organization not found/)) {\n        throw e;\n      }\n      const activations = new ActivationsManager(db);\n      const settings = new AppSettings(\"grist\");\n      settings.setEnvVars((await activations.current()).prefs?.envVars || {});\n      const email = getAdminOrDefaultEmail(settings);\n      if (!email) {\n        throw new Error(\"need GRIST_ADMIN_EMAIL or GRIST_DEFAULT_EMAIL to create site\");\n      }\n      const profile = { email, name: email };\n      const user = await db.getUserByLogin(email, { profile });\n      db.unwrapQueryResult(await db.addOrg(user, {\n        name: org,\n        domain: org,\n      }, {\n        setUserAsOwner: false,\n        useNewPlan: true,\n      }));\n    }\n  }\n}\n\nexport async function main() {\n  console.log(\"Welcome to Grist.\");\n  if (!debugging) {\n    console.log(`In quiet mode, see http://localhost:${G.port} to use.`);\n    console.log(\"For full logs, re-run with DEBUG=1\");\n  }\n\n  if (process.env.GRIST_PROMCLIENT_PORT) {\n    runPrometheusExporter(parseInt(process.env.GRIST_PROMCLIENT_PORT, 10));\n  }\n\n  // If auth is not configured, there's no login system, so provide a default email address.\n  setDefaultEnv(\"GRIST_DEFAULT_EMAIL\", \"you@example.com\");\n  // Set directory for uploaded documents.\n  setDefaultEnv(\"GRIST_DATA_DIR\", \"docs\");\n  setDefaultEnv(\"GRIST_SERVERS\", \"home,docs,static\");\n  if (process.env.GRIST_SERVERS?.includes(\"home\")) {\n    // By default, we will now start an untrusted port alongside a\n    // home server, for bundled custom widgets.\n    // Suppress with GRIST_UNTRUSTED_PORT=''\n    setDefaultEnv(\"GRIST_UNTRUSTED_PORT\", \"0\");\n  }\n  const serverTypes = parseServerTypes(process.env.GRIST_SERVERS);\n\n  await fse.mkdirp(process.env.GRIST_DATA_DIR!);\n\n  if (serverTypes.includes(\"home\")) {\n    log.info(\"Setting up database...\");\n    const db = await createOrUpdateDb();\n    await setUpAdminEmail(db);\n    await setUpSingleOrg(db);\n    log.info(\"Database setup complete.\");\n  }\n\n  // Launch single-port, self-contained version of Grist.\n  const mergedServer = await MergedServer.create(G.port, serverTypes);\n  await mergedServer.run();\n  if (process.env.GRIST_TESTING_SOCKET) {\n    await mergedServer.flexServer.addTestingHooks();\n  }\n  if (process.env.GRIST_SERVE_PLUGINS_PORT) {\n    await mergedServer.flexServer.startCopy(\"pluginServer\", parseInt(process.env.GRIST_SERVE_PLUGINS_PORT, 10));\n  }\n\n  return mergedServer.flexServer;\n}\n\nif (require.main === module) {\n  main().catch((err) => {\n    log.error(err);\n  });\n}\n"
  },
  {
    "path": "stubs/app/tsconfig.json",
    "content": "{\n  \"extends\": \"../../buildtools/tsconfig-base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    { \"path\": \"../../app/client\" },\n    { \"path\": \"../../app/common\" },\n    { \"path\": \"../../app/server\" }\n  ]\n}\n"
  },
  {
    "path": "test/assistant/data/formula-dataset-index.csv",
    "content": "no_formula,table_id,col_id,doc_id,Description\n0,Encrypt,Encrypted,n2se5cBJty1GyWdougSD2T,\"Encrypt with a simple Caeser cipher: Convert all letters to uppercase, then circular shift them forward by 6 positions. Leave all other characters unchanged. For example, 'abc xyz!' becomes 'GHI DEF!'. Use the `string` module.\"\n0,Contacts,Send_Email,hQHXqAQXceeQBPvRw5sSs1,\"Link to compose an email, if there is one\"\n0,Contacts,No_Notes,hQHXqAQXceeQBPvRw5sSs1,\"Number of notes for this contact\"\n0,Category,Contains_archived_project_,hQHXqAQXceeQBPvRw5sSs1,\"Whether any projects in this category are archived\"\n0,Tasks,Today,hQHXqAQXceeQBPvRw5sSs1,Needs to be done today (or every day)\n0,Tasks,Week_Day,hQHXqAQXceeQBPvRw5sSs1,Full name of deadline weekday\n0,Tasks,period,hQHXqAQXceeQBPvRw5sSs1,Whether this task was modified between (inclusive) the dates in the single row in Settings\n0,Expenses,Month,55Q2EtTbFvB1N6iizLh4Rk,e.g. 2022-01\n0,Payroll,Date_Range,5pHLanQNThxkEaEJHKJUf5,\"The start date, followed by a dash (no spaces) and the end date if there is one. Dates are month/day with no leading zeroes.\"\n0,Payroll,Per_Hour,5pHLanQNThxkEaEJHKJUf5,The hourly rate of the latest rate for this role and person that started on or before this date\n0,Payroll,Payment,5pHLanQNThxkEaEJHKJUf5,\"Total payment amount for hours worked, rounded to the nearest cent.\"\n0,Payroll_summary_Pay_Period_Person,Dates,5pHLanQNThxkEaEJHKJUf5,\"All date ranges in the group, separated by a comma and a space\"\n0,People,Full_Name,5pHLanQNThxkEaEJHKJUf5,\"e.g. Doe, John\"\n0,General_Ledger,Quarter,2YwYBWpREY2a1N2NV7cb55,e.g. 2020 Q4\n0,General_Ledger,Year,2YwYBWpREY2a1N2NV7cb55,\"Just the year of the date, as a string\"\n0,Time_Calculator,Time_Worked,np7TVHmuvFcHmo1K8h7Ur4,Formatted as hours:minutes. No leading zeroes for hours.\n0,Time_Calculator,Seconds_Worked,np7TVHmuvFcHmo1K8h7Ur4,\"Number of seconds between start/end times, if they're both there\"\n0,Funding_Source,Percentage,qprycQa2TVwajAe6Hb3bUZ,Ratio of the amount to the total across all rows\n0,Funding_Source_summary,Debt_to_Equity,qprycQa2TVwajAe6Hb3bUZ,Ratio of the total amounts in the group where the type is Debt vs Equity\n0,Invoices,Client,bReAxyLmzmEQfHF5L5Sc1e,Client's name followed by their address on the next line\n0,Invoices,Hours,bReAxyLmzmEQfHF5L5Sc1e,Total duration in hours across all time logs for this invoice\n0,Invoices,Due,bReAxyLmzmEQfHF5L5Sc1e,30 days after the invoice date\n0,Invoices,Invoice_ID,bReAxyLmzmEQfHF5L5Sc1e,Invoice date followed by the client's name in brackets\n0,Projects,Project_Name,bReAxyLmzmEQfHF5L5Sc1e,\"Client name and project name, e.g. John Doe: Big project\"\n0,Time_Log,Date,bReAxyLmzmEQfHF5L5Sc1e,Start date if there is one\n0,Time_Log,Duration_hrs_,bReAxyLmzmEQfHF5L5Sc1e,Duration (if there is one) in hours rounded to two decimal places\n0,Time_Log,Duration_min_,bReAxyLmzmEQfHF5L5Sc1e,\"Number of minutes between start and end time. If either time is missing, leave blank. If end is before start, give 0.\"\n0,Filtered_By_Formula,LabelCount,9nNr9uQwoXWAvxcWQDygh6,\"1 if the state is CA, otherwise 0\"\n0,Objects,Address,pyMHqncEspfZN5zfShCwT8,\"City and state, separated by comma space\"\n0,Books,search_terms,hdXy57qLiyNf35oNLzzgBG,\"Title and author name, with a space in between\"\n0,BOM_Items,Cost,e4gEm7dt4cgBMkouVBNMeY,Total cost if both quantity and cost are given\n0,Bill_Of_Materials,Cost,e4gEm7dt4cgBMkouVBNMeY,Total cost\n1,Bill_Of_Materials,Cost,e4gEm7dt4cgBMkouVBNMeY,Calculate the mean cost and add a row showing the variance from the mean\n0,All_Responses,Entry,qvND7WUcuNb2fU4n1vBJ7f,\"Name and submitted date in the format \"\"Name - month-day\"\"\"\n0,All_Responses,Month,qvND7WUcuNb2fU4n1vBJ7f,Submitted month (full name) and year\n0,Cap_Table,Common_Stock,iXggjrCPHut9u2BuhJxJkk,\"If the class is Options, RSUs, or Option Pool, return 0, otherwise return the fully diluted value.\"\n0,Cap_Table,Fully_Diluted,iXggjrCPHut9u2BuhJxJkk,\"The granted amount, minus the total pool used if the class is Option Pool\"\n0,Cap_Table,Fully_Diluted_,iXggjrCPHut9u2BuhJxJkk,Fully diluted as a fraction of the total\n0,Classes,Spots_Left,swLvb3Fic22gVzrdczcAoZ,or Full\n0,Classes,Count,swLvb3Fic22gVzrdczcAoZ,Number of enrollments for this class where the status is Confirmed\n1,Classes,Count,swLvb3Fic22gVzrdczcAoZ,Add a row at the end with the total number\n0,All_Survey_Responses,Product_Experience_Score,4ktYzGV1mUipSiQFtkLGqm,\"A number based on the experience:\nVery Dissatisfied: 1\nSomewhat Dissatisfied: 2\nNeutral: 3\nSomewhat Satisfied: 4\nVery Satisfied: 5\"\n0,Time_Sheet_Entries_summary_Account_Employee_Month,Total_Spend,oGxD8EnzeVs6vSQK3QBrUv,Total hours worked times hourly rate\n0,Time_Sheets,Title,oGxD8EnzeVs6vSQK3QBrUv,Month number and employee full name separated by a space\n0,All_Products,SKU,sXsBGDTKau1F3fvxkCyoaJ,\"Brand code, color code, and size, separated by dashes without spaces\"\n0,All_Products,QTY_on_Order,sXsBGDTKau1F3fvxkCyoaJ,Total quantity minus total received quantity across all incoming order line items for this product\n0,All_Products,Stock_Alert,sXsBGDTKau1F3fvxkCyoaJ,\"If the amount in stock and on order is more than 5: In Stock\nIf it's 0: OUT OF STOCK\nOtherwise: Low Stock\"\n0,Incoming_Order_Line_Items,Received_Qty,sXsBGDTKau1F3fvxkCyoaJ,\"The quantity, but only if the order is received\"\n0,Theaters,Latitude2,dKztiPYamcCpttT1LT1FnU,Coordinate before the comma\n1,Theaters,Latitude2,dKztiPYamcCpttT1LT1FnU,How can I see the coordinates on a map?\n0,Theaters,Longitude,dKztiPYamcCpttT1LT1FnU,Coordinate after the comma and space\n0,Families,Amount_Due,cJcSKdUC3nLNAv4wTjAxA6,\"Total charged minus total paid, capped at 0\"\n0,Families,Total_Applied,cJcSKdUC3nLNAv4wTjAxA6,Total charge for all paid sessions for this family\n0,Gifts_summary_Occasion_Who_Year,Over_Budget_,dr6epxpXUcy9rsFVUoXTEe,Did we spend more than the budget for this person?\n0,Gifts_summary_Year,Total_Budget,dr6epxpXUcy9rsFVUoXTEe,Total budget for all important dates this year\n0,Leases,Signer,5iMYwmESm33JpEECSqdZk2,The signing tenant for this lease\n1,Leases,Signer,5iMYwmESm33JpEECSqdZk2,Show the attached photo of the signing tenant\n0,Apartments,Have_Picture,5iMYwmESm33JpEECSqdZk2,Yes or No depending on if there's a picture\n0,Apartments,Current_Lease,5iMYwmESm33JpEECSqdZk2,The lease for this apartment whose current status is Active\n0,Current_Signers,Lease_Start_Date,5iMYwmESm33JpEECSqdZk2,The start date of the lease for this apartment whose current status is Active\n0,Leases,Lease_End_Date,5iMYwmESm33JpEECSqdZk2,Start date plus the lease term in years minus one day\n0,Tenancies,Minor,5iMYwmESm33JpEECSqdZk2,\"1 if the age is less than 18, otherwise 0\"\n0,Game_Schedule,Loser,1xJAp2uxM7tFCVUbEofKoF,The team that won fewer sets\n0,Standings,Win_Rate,1xJAp2uxM7tFCVUbEofKoF,Ratio of wins to total games\n0,Standings,Wins,1xJAp2uxM7tFCVUbEofKoF,Number of games won\n0,Prepare_Invoices,Due,9NH6D58FmxwPP43nw7uzQK,One month after the issued date if there is one\n1,Prepare_Invoices,Due,9NH6D58FmxwPP43nw7uzQK,Hello\n1,Prepare_Invoices,Due,9NH6D58FmxwPP43nw7uzQK,Can you help me?\n1,Prepare_Invoices,Due,9NH6D58FmxwPP43nw7uzQK,How do I create a new table?\n"
  },
  {
    "path": "test/assistant/v1/runCompletion.js",
    "content": "#!/usr/bin/env node\n\"use strict\";\nconst fs = require(\"fs\");\nconst path = require(\"path\");\n\nlet codeRoot = path.resolve(__dirname, \"..\", \"..\", \"..\");\nif (!fs.existsSync(path.join(codeRoot, \"_build\"))) {\n  codeRoot = path.dirname(codeRoot);\n}\n\nprocess.env.DATA_PATH = path.join(path.dirname(__dirname), \"data\");\n\nrequire(\"app-module-path\").addPath(path.join(codeRoot, \"_build\"));\nrequire(\"app-module-path\").addPath(path.join(codeRoot, \"_build\", \"core\"));\nrequire(\"app-module-path\").addPath(path.join(codeRoot, \"_build\", \"ext\"));\nrequire(\"app-module-path\").addPath(path.join(codeRoot, \"_build\", \"stubs\"));\nrequire(\"test/assistant/v1/runCompletion_impl\").runCompletion().catch(console.error);\n"
  },
  {
    "path": "test/assistant/v1/runCompletion_impl.ts",
    "content": "/**\n * This module holds an evaluation scripts for AI assistance. It tests ai assistance on the formula\n * dataset. The formula dataset is made of an index file (formula-dataset-index.csv) and a list of\n * grist documents hosted on S3. A row in the index file, reference one column (doc_id, table_id,\n * col_id) amongst theses documents and a free-text description.\n *\n * For each entries of the data set, the scripts load the document, requests assistance based on the\n * description, and applies the suggested actions to the document. Then it compares the col values\n * before and after. Finally it reverts the modification.\n *\n * The list of grist documents for the formula dataset is a screenshot of all templates document\n * taken somewhere in the beginning of Feb 2023.\n *\n * The script maintains a simple cache of all request to AI to save on the ai requests.\n *\n * USAGE:\n *  OPENAI_API_KEY=<my_openai_api_key> node test/assistant/v1/runCompletion.js\n * or\n *  ASSISTANT_CHAT_COMPLETION_ENDPOINT=http.... node test/assistant/v1/runCompletion.js\n * (see IAssistant.ts for more options).\n *\n *  # WITH VERBOSE:\n *  VERBOSE=1 OPENAI_API_KEY=<my_openai_api_key> node test/assistant/v1/runCompletion.js\n *\n *  # to reset cache\n *  rm test/assistant/data/cache/*\n */\n\nimport { AssistanceResponseV1, AssistanceState } from \"app/common/Assistance\";\nimport { CellValue } from \"app/plugin/GristData\";\nimport { ActiveDoc, Deps as ActiveDocDeps } from \"app/server/lib/ActiveDoc\";\nimport { configureOpenAIAssistantV1 } from \"app/server/lib/configureOpenAIAssistantV1\";\nimport log from \"app/server/lib/log\";\nimport { DEPS } from \"app/server/lib/OpenAIAssistantV1\";\nimport { createDocTools } from \"test/server/docTools\";\nimport { CachedFetcher } from \"test/server/utils/CachedFetcher\";\n\nimport * as fs from \"fs\";\nimport * as os from \"os\";\nimport path from \"path\";\nimport { pipeline } from \"stream\";\nimport { promisify } from \"util\";\n\nimport { parse } from \"csv-parse/sync\";\nimport JSZip from \"jszip\";\nimport { isEqual } from \"lodash\";\nimport fetch from \"node-fetch\";\n\nconst streamPipeline = promisify(pipeline);\n\nconst DATA_PATH = process.env.DATA_PATH || path.join(path.dirname(__dirname), \"data\");\nconst PATH_TO_DOC = path.join(DATA_PATH, \"templates\");\nconst PATH_TO_RESULTS = path.join(DATA_PATH, \"results\");\nconst PATH_TO_CSV = path.join(DATA_PATH, \"formula-dataset-index.csv\");\nconst PATH_TO_CACHE = path.join(DATA_PATH, \"cache\");\nconst TEMPLATE_URL = \"https://grist-static.com/datasets/grist_dataset_formulai_2023_02_20.zip\";\n\nconst oldFetch = DEPS.fetch;\n\ninterface FormulaRec {\n  no_formula: string;\n  table_id: string;\n  col_id: string;\n  doc_id: string;\n  Description: string;\n}\n\nconst SIMULATE_CONVERSATION = true;\nconst FOLLOWUP_EVALUATE = false;\n\nexport async function runCompletion() {\n  const assistant = configureOpenAIAssistantV1();\n  if (!assistant) {\n    throw new Error(\"Please set OPENAI_API_KEY or ASSISTANT_CHAT_COMPLETION_ENDPOINT\");\n  }\n\n  // This could take a long time for LLMs running on underpowered hardware >:)\n  ActiveDocDeps.ACTIVEDOC_TIMEOUT = 500000;\n\n  // if template directory not exists, make it\n  if (!fs.existsSync(path.join(PATH_TO_DOC))) {\n    fs.mkdirSync(path.join(PATH_TO_DOC), { recursive: true });\n\n    // create tempdir\n    const dir = fs.mkdtempSync(path.join(os.tmpdir(), \"grist-templates-\"));\n    const destPath = path.join(dir, \"template.zip\");\n\n    // start downloading\n    console.log(\n      `source url: ${TEMPLATE_URL}\\n` +\n      `destination: ${destPath}\\n` +\n      `download...`,\n    );\n    const response = await fetch(TEMPLATE_URL);\n    if (!response.ok) { throw new Error(`unexpected response ${response.statusText}`); }\n    await streamPipeline(response.body, fs.createWriteStream(destPath));\n    console.log(\"done!\\n\\n\" +\n      \"start extraction...\");\n\n    // unzip to directory\n    const data = fs.readFileSync(destPath);\n    const zip = await JSZip.loadAsync(data);\n    let count = 0;\n    for (const filename of Object.keys(zip.files)) {\n      if (filename.includes(\"/\")) { continue; }\n      const fileBuffer = await zip.files[filename].async(\"nodebuffer\");\n      fs.writeFileSync(path.join(PATH_TO_DOC, filename), fileBuffer);\n      count++;\n    }\n    console.log(\n      `Successfully extracted ${count} template files to ${PATH_TO_DOC}`,\n    );\n  }\n\n  const content = fs.readFileSync(PATH_TO_CSV, { encoding: \"utf8\" });\n  const records = parse(content, { columns: true }) as FormulaRec[];\n\n  // let's group by doc id to save on document loading time\n  records.sort((a, b) => a.doc_id.localeCompare(b.doc_id));\n\n  if (!process.env.VERBOSE) {\n    log.transports.file.level = \"error\";  // Suppress most of log output.\n  }\n  const docTools = createDocTools();\n  const session = docTools.createFakeSession(\"owners\");\n  await docTools.before();\n  let successCount = 0;\n  let caseCount = 0;\n  fs.mkdirSync(path.join(PATH_TO_RESULTS), { recursive: true });\n\n  const fetcher = new CachedFetcher(PATH_TO_CACHE);\n\n  console.log(\"Testing AI assistance: \");\n\n  try {\n    DEPS.fetch = ((info, init) =>\n      fetcher.fetch(info, init)) as typeof DEPS.fetch;\n\n    let activeDoc: ActiveDoc | undefined;\n    for (const rec of records) {\n      let success: boolean = false;\n      let suggestedActions: AssistanceResponseV1[\"suggestedActions\"] | undefined;\n      let newValues: CellValue[] | undefined;\n      let formula: string | undefined;\n      let history: AssistanceState = { messages: [] };\n      let lastFollowUp: string | undefined;\n\n      // load new document\n      if (!activeDoc || activeDoc.docName !== rec.doc_id) {\n        const docPath = path.join(PATH_TO_DOC, rec.doc_id + \".grist\");\n        activeDoc = await docTools.loadLocalDoc(docPath);\n        await activeDoc.waitForInitialization();\n      }\n\n      // get values\n      await activeDoc.docData!.fetchTable(rec.table_id);\n      const expected = activeDoc.docData!.getTable(rec.table_id)!.getColValues(rec.col_id)!.slice();\n\n      async function sendMessage(followUp?: string, rowId?: number) {\n        if (!activeDoc) {\n          throw new Error(\"No doc\");\n        }\n\n        // send prompt\n        const tableId = rec.table_id;\n        const colId = rec.col_id;\n        const description = rec.Description;\n        const colInfo = await activeDoc.docStorage.get(`\n          select *\n          from _grist_Tables_column as c\n                 left join _grist_Tables as t on t.id = c.parentId\n          where c.colId = ?\n            and t.tableId = ?\n        `, rec.col_id, rec.table_id);\n        formula = colInfo?.formula;\n\n        const result = await assistant!.getAssistance(session, activeDoc, {\n          conversationId: \"conversationId\",\n          context: {\n            tableId,\n            colId,\n            evaluateCurrentFormula: Boolean(followUp) && FOLLOWUP_EVALUATE,\n            rowId,\n          },\n          state: history,\n          text: followUp || description,\n        });\n        if (result.state) {\n          history = result.state;\n        }\n        if (rec.no_formula == \"1\") {\n          success = result.suggestedActions.length === 0;\n          return;\n        }\n        suggestedActions = result.suggestedActions;\n        if (!suggestedActions.length) {\n          success = false;\n          return;\n        }\n\n        // apply modification\n        const { actionNum } = await activeDoc.applyUserActions(session, suggestedActions);\n\n        // get new values\n        newValues = activeDoc.docData!.getTable(rec.table_id)!.getColValues(rec.col_id)!.slice();\n\n        // compare values\n        success = isEqual(expected, newValues);\n\n        if (!success && SIMULATE_CONVERSATION) {\n          for (let i = 0; i < expected.length; i++) {\n            const e = expected[i];\n            const v = newValues[i];\n            if (String(e) !== String(v)) {\n              const txt = `I got \\`${v}\\` where I expected \\`${e}\\`\\n` +\n                \"Please answer with the code block you (the assistant) just gave, \" +\n                \"revised based on this information. Your answer must include a code \" +\n                \"block. If you have to explain anything, do it after.\\n\";\n              const rowIds = activeDoc.docData!.getTable(rec.table_id)!.getRowIds();\n              const rowId = rowIds[i];\n              if (followUp) {\n                lastFollowUp = txt;\n              } else {\n                await sendMessage(txt, rowId);\n              }\n              break;\n            }\n          }\n        }\n        // revert modification\n        const [bundle] = await activeDoc.getActions([actionNum]);\n        await activeDoc.applyUserActionsById(session, [bundle!.actionNum], [bundle!.actionHash!], true);\n      }\n\n      try {\n        await sendMessage();\n      } catch (e) {\n        console.error(e);\n      }\n\n      console.log(` ${success ? \"Successfully\" : \"Failed to\"} complete formula ` +\n        `for column ${rec.table_id}.${rec.col_id} (doc=${rec.doc_id})`);\n\n      if (success) {\n        successCount++;\n      } else {\n        // TODO: log the difference between expected and actual, similar to what mocha does on\n        // failure.\n        // console.log('expected=', expected);\n        // console.log('actual=', newValues);\n      }\n      const suggestedFormula = suggestedActions?.length === 1 &&\n        suggestedActions[0][0] === \"ModifyColumn\" &&\n        suggestedActions[0][3].formula || suggestedActions;\n      fs.writeFileSync(\n        path.join(\n          PATH_TO_RESULTS,\n          `${rec.table_id}_${rec.col_id}_` +\n          caseCount.toLocaleString(\"en\", { minimumIntegerDigits: 8, useGrouping: false }) + \".json\"),\n        JSON.stringify({\n          formula,\n          suggestedFormula, success,\n          expectedValues: expected,\n          suggestedValues: newValues,\n          history,\n          lastFollowUp,\n        }, null, 2));\n      caseCount++;\n    }\n  } finally {\n    await docTools.after();\n    log.transports.file.level = \"debug\";\n    console.log(`Ai assistance requests stats: ${fetcher.callCount} calls`);\n    DEPS.fetch = oldFetch;\n    console.log(\n      `AI Assistance completed ${successCount} successful prompt on a total of ${records.length};`,\n    );\n    console.log(JSON.stringify(\n      {\n        hit: successCount,\n        total: records.length,\n        percentage: (100.0 * successCount) / Math.max(records.length, 1),\n      },\n    ));\n  }\n}\n\nexport function main() {\n  runCompletion().catch(console.error);\n}\n"
  },
  {
    "path": "test/chai-as-promised.js",
    "content": "const chai = require(\"chai\");\nconst chaiAsPromised = require(\"chai-as-promised\");\n\nchai.use(chaiAsPromised);\n\n// By default this is false, which affects asserts like isRejected and isFulfilled.\nchai.config.includeStack = true;\n"
  },
  {
    "path": "test/client/clientUtil.js",
    "content": "var assert = require(\"chai\").assert;\nvar Promise = require(\"bluebird\");\n\nvar browserGlobals = require(\"app/client/lib/browserGlobals\");\n\n/**\n * Set up browserGlobals to jsdom-mocked DOM globals and an empty document. Call this within test\n * suites to set the temporary browserGlobals before the suite runs, and restore them afterwards.\n *\n * Note that his does nothing when running under the browser (i.e. native globals will be used).\n * For one, jsdom doesn't work (at least not right away); more importantly, we want to be able to\n * test actual browser behavior.\n */\nfunction setTmpMochaGlobals() {\n  if (typeof window !== \"undefined\") {\n    return;\n  }\n\n  const {JSDOM} = require(\"jsdom\");\n\n  var prevGlobals;\n\n  before(function() {\n    const dom = new JSDOM(\"<!doctype html><html></html>\");\n\n    // Include JQuery ($) as an available global. Surprising, but it works.\n    const jquery = require(\"jquery\");\n    dom.window.$ = jquery(dom.window);\n\n    prevGlobals = browserGlobals.setGlobals(dom.window);\n  });\n\n  after(function() {\n    browserGlobals.setGlobals(prevGlobals);\n  });\n}\nexports.setTmpMochaGlobals = setTmpMochaGlobals;\n\n/**\n * Queries `el` for `selector` and resolves when `count` of found element is reached.\n *\n * @param {Element} el - DOM element to query\n * @param {string} selector - Selector to find\n * @param {number=} count - Optional count is the minimum number of elements to wait for. Defaults\n *    to 1.\n * @returns {Promise} - NodeList of found elements whose `length` is at least `count`.\n */\nfunction waitForSelectorAll(el, selector, count) {\n  assert(el.querySelectorAll, \"Must provide a DOMElement or HTMLElement\");\n  count = count || 1;\n  var i;\n  return new Promise(function(resolve, reject) {\n    i = setInterval(function() {\n      var q = el.querySelectorAll(selector);\n      if (q.length >= count) {\n        clearInterval(i);\n        resolve(q);\n      }\n    }, 50);\n  })\n    .timeout(1000)\n    .catch(function(err) {\n      clearInterval(i);\n      throw new Error(\"couldn't find selector: \" + selector);\n    });\n}\nexports.waitForSelectorAll = waitForSelectorAll;\n\n/**\n * Queries `el` for `selector` and returns when at least one is found.\n *\n * @param {Element} el - DOM element to query\n * @param {string} selector - Selector to find\n * @returns {Promise} - Node of found element.\n */\nfunction waitForSelector(el, selector) {\n  return waitForSelectorAll(el, selector, 1)\n    .then(function(els) {\n      return els[0];\n    });\n}\nexports.waitForSelector = waitForSelector;\n\n/**\n * Queries `el` for `selector` and returns the last element in the NodeList.\n */\nfunction querySelectorLast(el, selector) {\n  var rows = el.querySelectorAll(selector);\n  var last_row = rows && rows[rows.length - 1];\n  return last_row;\n}\nexports.querySelectorLast = querySelectorLast;\n\n/*\n *\n * Takes and observable and returns a promise when the observable changes.\n * it then unsubscribes from the observable\n * @param {observable} observable - Selector to find\n * @returns {Promise} - Node of found element.\n */\n\nfunction waitForChange(observable, delay) {\n  var sub;\n  return new Promise(function(resolve, reject) {\n    sub = observable.subscribe(function(val) {\n      console.warn(\"observable changed: \" + val.toString());\n      resolve(val);\n    });\n  })\n    .timeout(delay)\n    .finally(function(){\n      sub.dispose();\n    });\n}\nexports.waitForChange = waitForChange;\n"
  },
  {
    "path": "test/client/components/Layout.js",
    "content": "var assert = require(\"chai\").assert;\nvar clientUtil = require(\"../clientUtil\");\nvar dom = require(\"app/client/lib/dom\");\nvar Layout = require(\"app/client/components/Layout\");\n\ndescribe(\"Layout\", function() {\n\n  clientUtil.setTmpMochaGlobals();\n\n  var layout;\n\n  var sampleData = {\n    children: [{\n      children: [{\n        children: [{\n          leaf: 1\n        }, {\n          leaf: 2\n        }]\n      }, {\n        children: [{\n          children: [{\n            leaf: 3\n          }, {\n            leaf: 4\n          }]\n        }, {\n          leaf: 5\n        }]\n      }]\n    }, {\n      leaf: 6\n    }]\n  };\n\n  function createLeaf(leafId) {\n    return dom(\"div.layout_leaf_test\", \"#\" + leafId);\n  }\n\n  beforeEach(function() {\n    layout = Layout.Layout.create(sampleData, createLeaf);\n  });\n\n  afterEach(function() {\n    layout.dispose();\n    layout = null;\n  });\n\n  function getClasses(node) {\n    return Array.prototype.slice.call(node.classList, 0).sort();\n  }\n\n  it(\"should generate same layout spec as it was built with\", function() {\n    assert.deepEqual(layout.getLayoutSpec(), sampleData);\n    assert.deepEqual(layout.getAllLeafIds().sort(), [1, 2, 3, 4, 5, 6]);\n  });\n\n  it(\"should generate nested DOM structure\", function() {\n    var rootBox = layout.rootElem.querySelector(\".layout_box\");\n    assert(rootBox);\n    assert.strictEqual(rootBox, layout.rootBox().dom);\n    assert.deepEqual(getClasses(rootBox), [\"layout_box\", \"layout_last_child\",\n      \"layout_vbox\"]);\n\n    var rows = rootBox.children;\n    assert.equal(rows.length, 2);\n    assert.equal(rows[0].children.length, 2);\n    assert.deepEqual(getClasses(rows[0]), [\"layout_box\", \"layout_hbox\"]);\n    assert.deepEqual(getClasses(rows[0].children[0]), [\"layout_box\", \"layout_vbox\"]);\n    assert.deepEqual(getClasses(rows[0].children[1]), [\"layout_box\", \"layout_last_child\",\n      \"layout_vbox\"]);\n    assert.equal(rows[1].children.length, 1);\n    assert.includeMembers(getClasses(rows[1]), [\"layout_box\", \"layout_hbox\",\n      \"layout_last_child\", \"layout_leaf\"]);\n  });\n\n  it(\"should correctly handle removing boxes\", function() {\n    layout.getLeafBox(4).removeFromParent();\n    layout.getLeafBox(1).removeFromParent();\n    assert.deepEqual(layout.getAllLeafIds().sort(), [2, 3, 5, 6]);\n\n    assert.deepEqual(layout.getLayoutSpec(), {\n      children: [{\n        children: [{\n          leaf: 2\n        }, {\n          children: [{\n            leaf: 3\n          }, {\n            leaf: 5\n          }]\n        }]\n      }, {\n        leaf: 6\n      }]\n    });\n\n    // Here we get into a rare situation with a single child (to allow root box to be split\n    // vertically).\n    layout.getLeafBox(6).removeFromParent();\n    assert.deepEqual(layout.getLayoutSpec(), {\n      children: [{\n        children: [{\n          leaf: 2\n        }, {\n          children: [{\n            leaf: 3\n          }, {\n            leaf: 5\n          }]\n        }]\n      }]\n    });\n    assert.deepEqual(layout.getAllLeafIds().sort(), [2, 3, 5]);\n\n    // Here the special single-child box should collapse\n    layout.getLeafBox(2).removeFromParent();\n    assert.deepEqual(layout.getLayoutSpec(), {\n      children: [{\n        leaf: 3\n      }, {\n        leaf: 5\n      }]\n    });\n\n    layout.getLeafBox(3).removeFromParent();\n    assert.deepEqual(layout.getLayoutSpec(), {\n      leaf: 5\n    });\n    assert.deepEqual(layout.getAllLeafIds().sort(), [5]);\n  });\n\n  it(\"should correctly handle adding child and sibling boxes\", function() {\n    // In this test, we'll build up the sample layout from scratch, trying to exercise all code\n    // paths.\n    layout = Layout.Layout.create({ leaf: 1 }, createLeaf);\n    assert.deepEqual(layout.getLayoutSpec(), { leaf: 1 });\n    assert.deepEqual(layout.getAllLeafIds().sort(), [1]);\n\n    function makeBox(leafId) {\n      return layout.buildLayoutBox({leaf: leafId});\n    }\n\n    assert.strictEqual(layout.rootBox(), layout.getLeafBox(1));\n    layout.getLeafBox(1).addSibling(makeBox(5), true);\n    assert.deepEqual(layout.getLayoutSpec(), {children: [{\n      children: [{ leaf: 1 }, { leaf: 5 }]\n    }]});\n    assert.notStrictEqual(layout.rootBox(), layout.getLeafBox(1));\n\n    // An extra little check to add a sibling to a vertically-split root (in which case the split\n    // is really a level lower, and that's where the sibling should be added).\n    layout.rootBox().addSibling(makeBox(\"foo\"), true);\n    assert.deepEqual(layout.getLayoutSpec(), {children: [{\n      children: [{ leaf: 1 }, { leaf: 5 }, { leaf: \"foo\" }]\n    }]});\n    assert.deepEqual(layout.getAllLeafIds().sort(), [1, 5, \"foo\"]);\n    layout.getLeafBox(\"foo\").dispose();\n    assert.deepEqual(layout.getAllLeafIds().sort(), [1, 5]);\n\n    layout.getLeafBox(1).parentBox().addSibling(makeBox(6), true);\n    layout.getLeafBox(5).addChild(makeBox(3), false);\n    layout.getLeafBox(3).addChild(makeBox(4), true);\n    layout.getLeafBox(1).addChild(makeBox(2), true);\n    assert.deepEqual(layout.getLayoutSpec(), sampleData);\n    assert.deepEqual(layout.getAllLeafIds().sort(), [1, 2, 3, 4, 5, 6]);\n  });\n});\n"
  },
  {
    "path": "test/client/components/WidgetFrame.ts",
    "content": "import { MethodAccess } from \"app/client/components/WidgetFrame\";\nimport { AccessLevel } from \"app/common/CustomWidget\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"WidgetFrame\", function() {\n  it(\"should define access level per method\", function() {\n    class SampleApi {\n      public none() {\n        return true;\n      }\n\n      public read_table() {\n        return true;\n      }\n\n      public full() {}\n      public notMentioned() {}\n    }\n    const checker = new MethodAccess<SampleApi>()\n      .require(AccessLevel.none, \"none\")\n      .require(AccessLevel.read_table, \"read_table\")\n      .require(AccessLevel.full, \"full\");\n\n    const directTest = () => {\n      assert.isTrue(checker.check(AccessLevel.none, \"none\"));\n      assert.isFalse(checker.check(AccessLevel.none, \"read_table\"));\n      assert.isFalse(checker.check(AccessLevel.none, \"full\"));\n\n      assert.isTrue(checker.check(AccessLevel.read_table, \"none\"));\n      assert.isTrue(checker.check(AccessLevel.read_table, \"read_table\"));\n      assert.isFalse(checker.check(AccessLevel.read_table, \"full\"));\n\n      assert.isTrue(checker.check(AccessLevel.full, \"none\"));\n      assert.isTrue(checker.check(AccessLevel.full, \"read_table\"));\n      assert.isTrue(checker.check(AccessLevel.full, \"full\"));\n    };\n    directTest();\n\n    // Check that for any other method, access is denied.\n    assert.isFalse(checker.check(AccessLevel.none, \"notMentioned\"));\n    assert.isFalse(checker.check(AccessLevel.read_table, \"notMentioned\"));\n    // Even though access is full, the method was not mentioned, so it should be denied.\n    assert.isFalse(checker.check(AccessLevel.full, \"notMentioned\"));\n\n    // Now add a default rule.\n    checker.require(AccessLevel.none, \"*\");\n    assert.isTrue(checker.check(AccessLevel.none, \"notMentioned\"));\n    assert.isTrue(checker.check(AccessLevel.read_table, \"notMentioned\"));\n    assert.isTrue(checker.check(AccessLevel.full, \"notMentioned\"));\n    directTest();\n\n    checker.require(AccessLevel.read_table, \"*\");\n    assert.isFalse(checker.check(AccessLevel.none, \"notMentioned\"));\n    assert.isTrue(checker.check(AccessLevel.read_table, \"notMentioned\"));\n    assert.isTrue(checker.check(AccessLevel.full, \"notMentioned\"));\n    directTest();\n\n    checker.require(AccessLevel.full, \"*\");\n    assert.isFalse(checker.check(AccessLevel.none, \"notMentioned\"));\n    assert.isFalse(checker.check(AccessLevel.read_table, \"notMentioned\"));\n    assert.isTrue(checker.check(AccessLevel.full, \"notMentioned\"));\n    directTest();\n  });\n});\n"
  },
  {
    "path": "test/client/components/commands.js",
    "content": "var _ = require(\"underscore\");\nvar sinon = require(\"sinon\");\nvar assert = require(\"chai\").assert;\nvar ko = require(\"knockout\");\nvar Mousetrap = require(\"app/client/lib/Mousetrap\");\nvar commands = require(\"app/client/components/commands\");\nvar clientUtil = require(\"../clientUtil\");\n\ndescribe(\"commands\", function() {\n\n  clientUtil.setTmpMochaGlobals();\n\n  before(function() {\n    sinon.stub(Mousetrap, \"bind\");\n    sinon.stub(Mousetrap, \"unbind\");\n  });\n\n  after(function() {\n    Mousetrap.bind.restore();\n    Mousetrap.unbind.restore();\n  });\n\n  beforeEach(function() {\n    commands.init([{\n      group: \"Foo\",\n      commands: [{\n        name: \"cmd1\",\n        keys: [\"Ctrl+a\", \"Ctrl+b\"],\n        desc: \"Command 1\"\n      }, {\n        name: \"cmd2\",\n        keys: [\"Ctrl+c\"],\n        desc: \"Command 2\"\n      }, {\n        name: \"cmd3\",\n        keys: [\"Ctrl+a\"],\n        desc: \"Command 1B\"\n      }]\n    }]);\n  });\n\n  describe(\"activate\", function() {\n    it(\"should invoke Mousetrap.bind/unbind\", function() {\n      var obj = {};\n      var spy = sinon.spy();\n      var cmdGroup = commands.createGroup({ cmd1: spy }, obj, true);\n      sinon.assert.callCount(Mousetrap.bind, 2);\n      sinon.assert.calledWith(Mousetrap.bind, \"ctrl+a\");\n      sinon.assert.calledWith(Mousetrap.bind, \"ctrl+b\");\n      Mousetrap.bind.reset();\n      Mousetrap.unbind.reset();\n\n      commands.allCommands.cmd1.run();\n      sinon.assert.callCount(spy, 1);\n      sinon.assert.calledOn(spy, obj);\n\n      cmdGroup.activate(false);\n      sinon.assert.callCount(Mousetrap.bind, 0);\n      sinon.assert.callCount(Mousetrap.unbind, 2);\n      sinon.assert.calledWith(Mousetrap.unbind, \"ctrl+a\");\n      sinon.assert.calledWith(Mousetrap.unbind, \"ctrl+b\");\n      Mousetrap.bind.reset();\n      Mousetrap.unbind.reset();\n\n      commands.allCommands.cmd1.run();\n      sinon.assert.callCount(spy, 1);\n\n      cmdGroup.activate(true);\n      sinon.assert.callCount(Mousetrap.bind, 2);\n      sinon.assert.calledWith(Mousetrap.bind, \"ctrl+a\");\n      sinon.assert.calledWith(Mousetrap.bind, \"ctrl+b\");\n      sinon.assert.callCount(Mousetrap.unbind, 0);\n\n      commands.allCommands.cmd1.run();\n      sinon.assert.callCount(spy, 2);\n\n      cmdGroup.dispose();\n      sinon.assert.callCount(Mousetrap.unbind, 2);\n      sinon.assert.calledWith(Mousetrap.unbind, \"ctrl+a\");\n      sinon.assert.calledWith(Mousetrap.unbind, \"ctrl+b\");\n\n      commands.allCommands.cmd1.run();\n      sinon.assert.callCount(spy, 2);\n    });\n\n    /**\n     * For an object of the form { group1: { cmd1: sinon.spy() } }, goes through all spys, and\n     * returns a mapping of call counts: {'group1:cmd1': spyCallCount}.\n     */\n    function getCallCounts(groups) {\n      var counts = {};\n      _.each(groups, function(group, grpName) {\n        _.each(group, function(cmdSpy, cmdName) {\n          counts[grpName + \":\" + cmdName] = cmdSpy.callCount;\n        });\n      });\n      return counts;\n    }\n\n    /**\n     * Diffs two sets of call counts as produced by getCallCounts and returns the difference.\n     */\n    function diffCallCounts(callCounts1, callCounts2) {\n      return _.chain(callCounts2).mapObject(function(count, name) {\n        return count - callCounts1[name];\n      })\n        .pick(function(count, name) {\n          return count > 0;\n        })\n        .value();\n    }\n\n    /**\n     * Invokes the given command, and makes sure the difference of call counts before and after is\n     * as expected.\n     */\n    function assertCallCounts(groups, cmdOrFunc, expectedCounts) {\n      var before = getCallCounts(groups);\n      if (typeof cmdOrFunc === \"string\") {\n        commands.allCommands[cmdOrFunc].run();\n      } else if (cmdOrFunc === null) {\n        // nothing\n      } else {\n        cmdOrFunc();\n      }\n      var after = getCallCounts(groups);\n      assert.deepEqual(diffCallCounts(before, after), expectedCounts);\n    }\n\n    it(\"should respect order of CommandGroups\", function() {\n      var groups = {\n        group1: { cmd1: sinon.spy(), cmd3: sinon.spy() },\n        group2: { cmd1: sinon.spy(), cmd2: sinon.spy() },\n        group3: { cmd3: sinon.spy() },\n      };\n      var cmdGroup1 = commands.createGroup(groups.group1, null, true);\n      var cmdGroup2 = commands.createGroup(groups.group2, null, true);\n      var cmdGroup3 = commands.createGroup(groups.group3, null, false);\n\n      assertCallCounts(groups, \"cmd1\", {\"group2:cmd1\": 1});\n      assertCallCounts(groups, \"cmd2\", {\"group2:cmd2\": 1});\n      assertCallCounts(groups, \"cmd3\", {\"group1:cmd3\": 1});\n\n      cmdGroup2.activate(false);\n      assertCallCounts(groups, \"cmd1\", {\"group1:cmd1\": 1});\n      assertCallCounts(groups, \"cmd2\", {});\n      assertCallCounts(groups, \"cmd3\", {\"group1:cmd3\": 1});\n\n      cmdGroup3.activate(true);\n      cmdGroup1.activate(false);\n      assertCallCounts(groups, \"cmd1\", {});\n      assertCallCounts(groups, \"cmd2\", {});\n      assertCallCounts(groups, \"cmd3\", {\"group3:cmd3\": 1});\n\n      cmdGroup2.activate(true);\n      assertCallCounts(groups, \"cmd1\", {\"group2:cmd1\": 1});\n      assertCallCounts(groups, \"cmd2\", {\"group2:cmd2\": 1});\n      assertCallCounts(groups, \"cmd3\", {\"group3:cmd3\": 1});\n    });\n\n    it(\"should allow use of observable for activation flag\", function() {\n      var groups = {\n        groupFoo: { cmd1: sinon.spy() },\n      };\n      var isActive = ko.observable(false);\n      commands.createGroup(groups.groupFoo, null, isActive);\n      assertCallCounts(groups, \"cmd1\", {});\n      isActive(true);\n      assertCallCounts(groups, \"cmd1\", {\"groupFoo:cmd1\": 1});\n      // Check that subsequent calls continue working.\n      assertCallCounts(groups, \"cmd1\", {\"groupFoo:cmd1\": 1});\n      isActive(false);\n      assertCallCounts(groups, \"cmd1\", {});\n    });\n\n    function getFuncForShortcut(shortcut) {\n      function argsIncludeShortcut(args) {\n        return Array.isArray(args[0]) ? _.contains(args[0], shortcut) : (args[0] === shortcut);\n      }\n      var b = _.findLastIndex(Mousetrap.bind.args, argsIncludeShortcut);\n      var u = _.findLastIndex(Mousetrap.unbind.args, argsIncludeShortcut);\n      if (b < 0) {\n        return null;\n      } else if (u < 0) {\n        return Mousetrap.bind.args[b][1];\n      } else if (Mousetrap.bind.getCall(b).calledBefore(Mousetrap.unbind.getCall(u))) {\n        return null;\n      } else {\n        return Mousetrap.bind.args[b][1];\n      }\n    }\n\n    it(\"should allow same keys used for different commands\", function() {\n      // Both cmd1 and cmd3 use \"Ctrl+a\" shortcut, so cmd3 should win when group3 is active.\n      Mousetrap.bind.reset();\n      Mousetrap.unbind.reset();\n      var groups = {\n        group1: { cmd1: sinon.spy() },\n        group3: { cmd3: sinon.spy() },\n      };\n      var cmdGroup1 = commands.createGroup(groups.group1, null, true);\n      var cmdGroup3 = commands.createGroup(groups.group3, null, true);\n      assertCallCounts(groups, getFuncForShortcut(\"ctrl+a\"), {\"group3:cmd3\": 1});\n      assertCallCounts(groups, getFuncForShortcut(\"ctrl+b\"), {\"group1:cmd1\": 1});\n      cmdGroup3.activate(false);\n      assertCallCounts(groups, getFuncForShortcut(\"ctrl+a\"), {\"group1:cmd1\": 1});\n      assertCallCounts(groups, getFuncForShortcut(\"ctrl+b\"), {\"group1:cmd1\": 1});\n      cmdGroup1.activate(false);\n      assertCallCounts(groups, getFuncForShortcut(\"ctrl+a\"), {});\n      assertCallCounts(groups, getFuncForShortcut(\"ctrl+b\"), {});\n      cmdGroup3.activate(true);\n      assertCallCounts(groups, getFuncForShortcut(\"ctrl+a\"), {\"group3:cmd3\": 1});\n      assertCallCounts(groups, getFuncForShortcut(\"ctrl+b\"), {});\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/components/sampleLayout.js",
    "content": "var dom = require(\"app/client/lib/dom\");\nvar kd = require(\"app/client/lib/koDom\");\nvar kf = require(\"app/client/lib/koForm\");\nvar Layout = require(\"app/client/components/Layout\");\nvar LayoutEditor = require(\"app/client/components/LayoutEditor\");\n\nfunction createTestTab() {\n  return kf.topTab(\"Layout\",\n    kf.label(\"Layout Editor\")\n  );\n}\nexports.createTestTab = createTestTab;\n\nvar sampleData = {\n  children: [{\n    children: [{\n      children: [{\n        leaf: 1\n      }, {\n        leaf: 2\n      }, {\n        leaf: 7\n      }, {\n        leaf: 8\n      }]\n    }, {\n      children: [{\n        children: [{\n          leaf: 3\n        }, {\n          leaf: 4\n        }, {\n          leaf: 9\n        }, {\n          leaf: 10\n        }]\n      }, {\n        leaf: 5\n      }]\n    }]\n  }, {\n    leaf: 6\n  }]\n};\n\nfunction getMaxLeaf(spec) {\n  var maxChild = spec.children ? Math.max.apply(Math, spec.children.map(getMaxLeaf)) : -Infinity;\n  return Math.max(maxChild, spec.leaf || -Infinity);\n}\n\nfunction createLeaf(leafId) {\n  return dom(\"div.layout_leaf_test\", \"#\" + leafId,\n    kd.toggleClass(\"layout_leaf_test_big\", leafId % 2 === 0)\n  );\n}\n\nfunction createTestPane() {\n  var layout = Layout.Layout.create(sampleData, createLeaf);\n  var layoutEditor = LayoutEditor.LayoutEditor.create(layout);\n  var maxLeaf = getMaxLeaf(sampleData);\n  return dom(\"div\",\n    dom.autoDispose(layoutEditor),\n    dom.autoDispose(layout),\n    dom(\"div\",\n      dom(\"div.layout_new.pull-left\", \"+ Add New\",\n        dom.on(\"mousedown\", function(event) {\n          layoutEditor.dragInNewBox(event, ++maxLeaf);\n          return false;\n        })\n      ),\n      dom(\"div.layout_trash.pull-right\",\n        dom(\"span.glyphicon.glyphicon-trash\")\n      ),\n      dom(\"div.clearfix\")\n    ),\n    layout.rootElem\n  );\n}\n\nexports.createTestPane = createTestPane;\n"
  },
  {
    "path": "test/client/lib/ACIndex.ts",
    "content": "import { ACIndex, ACIndexImpl, ACItem, ACResults, highlightNone } from \"app/client/lib/ACIndex\";\nimport { nativeCompare } from \"app/common/gutil\";\nimport { fixturesRoot } from \"test/server/testUtils\";\n\nimport * as path from \"path\";\n\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\n\n/**\n * Set env ENABLE_TIMING_TESTS=1 to run the timing \"tests\". These don't assert anything but let\n * you compare the performance of different implementations.\n */\nconst ENABLE_TIMING_TESTS = Boolean(process.env.ENABLE_TIMING_TESTS);\n\ninterface TestACItem extends ACItem {\n  text: string;\n}\n\nfunction makeItem(text: string): TestACItem {\n  return { text, cleanText: text.trim().toLowerCase() };\n}\n\nconst colors: TestACItem[] = [\n  \"Blue\", \"Dark Red\", \"Reddish\", \"Red\", \"Orange\", \"Yellow\", \"Radical Deep Green\", \"Bright Red\",\n].map(makeItem);\n\nconst rounds: TestACItem[] = [\n  \"Round 1\", \"Round 2\", \"Round 3\", \"Round 4\",\n].map(makeItem);\n\nconst messy: TestACItem[] = [\n  \"\", \" \\t\", \"  RED  \", \"123\", \"-5.6\", \"red\", \"read \", \"Bread\", \"#red\", \"\\nred\\n#red\\nred\", \"\\n\\n\", \"REDIS/1\",\n].map(makeItem);\n\ndescribe(\"ACIndex\", function() {\n  it(\"should find items with matching words\", function() {\n    const items: ACItem[] = [\"blue\", \"dark red\", \"reddish\", \"red\", \"orange\", \"yellow\", \"radical green\"].map(\n      c => ({ cleanText: c }));\n    const acIndex = new ACIndexImpl(items, { maxResults: 5 });\n    assert.deepEqual(acIndex.search(\"red\").items.map(item => item.cleanText),\n      [\"red\", \"reddish\", \"dark red\", \"radical green\", \"blue\"]);\n  });\n\n  it(\"should return first few items when search text is empty\", function() {\n    let acResult = new ACIndexImpl(colors).search(\"\");\n    assert.deepEqual(acResult.items, colors);\n    assert.deepEqual(acResult.selectIndex, -1);\n\n    acResult = new ACIndexImpl(colors, { maxResults: 3 }).search(\"\");\n    assert.deepEqual(acResult.items, colors.slice(0, 3));\n    assert.deepEqual(acResult.selectIndex, -1);\n\n    acResult = new ACIndexImpl(rounds).search(\"\");\n    assert.deepEqual(acResult.items, rounds);\n    assert.deepEqual(acResult.selectIndex, -1);\n  });\n\n  it(\"should ignore items with empty text\", function() {\n    const acIndex = new ACIndexImpl(messy);\n    let acResult = acIndex.search(\"\");\n\n    assert.deepEqual(acResult.items, messy.filter(t => t.cleanText));\n    assert.lengthOf(acResult.items, 9);\n    assert.deepEqual(acResult.selectIndex, -1);\n\n    acResult = acIndex.search(\"bread\");\n    assert.deepEqual(acResult.items.map(i => i.text),\n      [\"Bread\", \"  RED  \", \"123\", \"-5.6\", \"red\", \"read \", \"#red\", \"\\nred\\n#red\\nred\", \"REDIS/1\"]);\n    assert.deepEqual(acResult.selectIndex, 0);\n  });\n\n  it(\"should find items with the most matching words, and order by best match\", function() {\n    const acIndex = new ACIndexImpl(colors);\n    let acResult: ACResults<TestACItem>;\n\n    // Try a few cases with a single word.\n    acResult = acIndex.search(\"red\");\n    assert.deepEqual(acResult.items.map(i => i.text),\n      [\"Red\", \"Reddish\", \"Dark Red\", \"Bright Red\", \"Radical Deep Green\", \"Blue\", \"Orange\", \"Yellow\"]);\n    assert.deepEqual(acResult.selectIndex, 0);\n\n    acResult = acIndex.search(\"rex\");\n    // In this case \"Reddish\" is as good as \"Red\", so comes first according to original order.\n    assert.deepEqual(acResult.items.map(i => i.text),\n      [\"Reddish\", \"Red\", \"Dark Red\", \"Bright Red\", \"Radical Deep Green\", \"Blue\", \"Orange\", \"Yellow\"]);\n    assert.deepEqual(acResult.selectIndex, -1);   // No great match.\n\n    acResult = acIndex.search(\"REDD\");\n    // In this case \"Reddish\" is strictly better than \"Red\".\n    assert.deepEqual(acResult.items.map(i => i.text),\n      [\"Reddish\", \"Red\", \"Dark Red\", \"Bright Red\", \"Radical Deep Green\", \"Blue\", \"Orange\", \"Yellow\"]);\n    assert.deepEqual(acResult.selectIndex, 0);    // It's a good match.\n\n    // Try a few cases with multiple words.\n    acResult = acIndex.search(\"dark red\");\n    assert.deepEqual(acResult.items.map(i => i.text),\n      [\"Dark Red\", \"Red\", \"Bright Red\", \"Reddish\", \"Radical Deep Green\", \"Blue\", \"Orange\", \"Yellow\"]);\n    assert.deepEqual(acResult.selectIndex, 0);\n\n    acResult = acIndex.search(\"da re\");\n    assert.deepEqual(acResult.items.map(i => i.text),\n      [\"Dark Red\", \"Radical Deep Green\", \"Reddish\", \"Red\", \"Bright Red\", \"Blue\", \"Orange\", \"Yellow\"]);\n    assert.deepEqual(acResult.selectIndex, 0);\n\n    acResult = acIndex.search(\"red d\");\n    assert.deepEqual(acResult.items.map(i => i.text),\n      [\"Dark Red\", \"Red\", \"Bright Red\", \"Reddish\", \"Radical Deep Green\", \"Blue\", \"Orange\", \"Yellow\"]);\n    assert.deepEqual(acResult.selectIndex, -1);\n\n    acResult = acIndex.search(\"EXTRA DARK RED WORDS DON'T HURT\");\n    assert.deepEqual(acResult.items.map(i => i.text),\n      [\"Dark Red\", \"Red\", \"Bright Red\", \"Radical Deep Green\", \"Reddish\", \"Blue\", \"Orange\", \"Yellow\"]);\n    assert.deepEqual(acResult.selectIndex, -1);\n\n    // Try a few poor matches.\n    acResult = acIndex.search(\"a\");\n    assert.deepEqual(acResult.items, colors);\n    acResult = acIndex.search(\"z\");\n    assert.deepEqual(acResult.items, colors);\n    acResult = acIndex.search(\"RA\");\n    assert.deepEqual(acResult.items.map(i => i.text),\n      [\"Radical Deep Green\", \"Reddish\", \"Red\", \"Dark Red\",  \"Bright Red\", \"Blue\", \"Orange\", \"Yellow\"]);\n    acResult = acIndex.search(\"RZ\");\n    assert.deepEqual(acResult.items.map(i => i.text),\n      [\"Reddish\", \"Red\", \"Radical Deep Green\", \"Dark Red\",  \"Bright Red\", \"Blue\", \"Orange\", \"Yellow\"]);\n  });\n\n  it(\"should maintain order of equally good matches\", function() {\n    const acIndex = new ACIndexImpl(rounds);\n    let acResult: ACResults<TestACItem>;\n\n    // Try a few cases with a single word.\n    acResult = acIndex.search(\"r\");\n    assert.deepEqual(acResult.items, rounds);\n\n    acResult = acIndex.search(\"round 1\");\n    assert.deepEqual(acResult.items, rounds);\n\n    acResult = acIndex.search(\"round 3\");\n    // Round 3 is moved to the front; the rest are unchanged.\n    assert.deepEqual(acResult.items.map(i => i.text), [\"Round 3\", \"Round 1\", \"Round 2\", \"Round 4\"]);\n  });\n\n  it(\"should prefer items with words in a similar order to search text\", function() {\n    const acIndex = new ACIndexImpl(colors);\n    let acResult: ACResults<TestACItem>;\n\n    // \"r d\" and \"d r\" prefer choices whose words are in the entered order.\n    acResult = acIndex.search(\"r d\");\n    assert.deepEqual(acResult.items.slice(0, 2).map(i => i.text), [\"Radical Deep Green\", \"Dark Red\"]);\n\n    acResult = acIndex.search(\"d r\");\n    assert.deepEqual(acResult.items.slice(0, 2).map(i => i.text), [\"Dark Red\", \"Radical Deep Green\"]);\n\n    // But a better match wins.\n    acResult = acIndex.search(\"de r\");\n    assert.deepEqual(acResult.items.slice(0, 2).map(i => i.text), [\"Radical Deep Green\", \"Dark Red\"]);\n  });\n\n  it(\"should limit results to maxResults\", function() {\n    const acIndex = new ACIndexImpl(colors, { maxResults: 3 });\n    let acResult: ACResults<TestACItem>;\n\n    acResult = acIndex.search(\"red\");\n    assert.deepEqual(acResult.items.map(i => i.text), [\"Red\", \"Reddish\", \"Dark Red\"]);\n    assert.deepEqual(acResult.selectIndex, 0);\n\n    acResult = acIndex.search(\"red d\");\n    assert.deepEqual(acResult.items.map(i => i.text), [\"Dark Red\", \"Red\", \"Bright Red\"]);\n    assert.deepEqual(acResult.selectIndex, -1);\n\n    acResult = acIndex.search(\"g\");\n    assert.deepEqual(acResult.items.map(i => i.text), [\"Radical Deep Green\", \"Blue\", \"Dark Red\"]);\n    assert.deepEqual(acResult.selectIndex, 0);\n  });\n\n  it(\"should split words on punctuation\", function() {\n    // Same as `colors` but with extra punctuation\n    const punctColors: TestACItem[] = [\n      \"$Blue$\", \"--Dark@#$%^&Red--\", \"(Reddish)\", \"]Red{\", \"**Orange\", \"-Yellow?!\",\n      \"_Radical ``Deep'' !!Green!!\", \"<Bright>=\\\"Red\\\"\",\n    ].map(makeItem);\n\n    const acIndex = new ACIndexImpl(punctColors);\n    let acResult: ACResults<TestACItem>;\n\n    // Try a few cases with a single word.\n    acResult = acIndex.search(\"~red-\");\n    assert.deepEqual(acResult.items.map(i => i.text), [\n      \"]Red{\", \"--Dark@#$%^&Red--\", \"<Bright>=\\\"Red\\\"\", \"(Reddish)\", \"_Radical ``Deep'' !!Green!!\",\n      \"$Blue$\", \"**Orange\", \"-Yellow?!\"]);\n    assert.deepEqual(acResult.selectIndex, 0);\n\n    acResult = acIndex.search(\"rex\");\n    // In this case \"Reddish\" is as good as \"Red\", so comes first according to original order.\n    assert.deepEqual(acResult.items.map(i => i.text), [\n      \"(Reddish)\", \"]Red{\", \"--Dark@#$%^&Red--\", \"<Bright>=\\\"Red\\\"\", \"_Radical ``Deep'' !!Green!!\",\n      \"$Blue$\", \"**Orange\", \"-Yellow?!\"]);\n    assert.deepEqual(acResult.selectIndex, -1);   // No great match.\n\n    acResult = acIndex.search(\"da-re\");\n    assert.deepEqual(acResult.items.map(i => i.text), [\n      \"--Dark@#$%^&Red--\", \"_Radical ``Deep'' !!Green!!\", \"(Reddish)\", \"]Red{\", \"<Bright>=\\\"Red\\\"\",\n      \"$Blue$\", \"**Orange\", \"-Yellow?!\",\n    ]);\n    assert.deepEqual(acResult.selectIndex, 0);\n\n    // Try a few poor matches.\n    acResult = acIndex.search(\"a\");\n    assert.deepEqual(acResult.items, punctColors);\n    acResult = acIndex.search(\"z\");\n    assert.deepEqual(acResult.items, punctColors);\n  });\n\n  it(\"should return an item to select when the match is good\", function() {\n    const acIndex = new ACIndexImpl(rounds);\n    let acResult: ACResults<TestACItem>;\n\n    // Try a few cases with a single word.\n    acResult = acIndex.search(\"r\");\n    assert.equal(acResult.selectIndex, 0);\n    assert.equal(acResult.items[0].text, \"Round 1\");\n\n    acResult = acIndex.search(\"round 2\");\n    assert.equal(acResult.selectIndex, 0);\n    assert.equal(acResult.items[0].text, \"Round 2\");\n\n    acResult = acIndex.search(\"round X\");\n    assert.equal(acResult.selectIndex, -1);\n\n    // We only suggest a selection when an item (or one of its words) starts with the search text.\n    acResult = acIndex.search(\"1\");\n    assert.equal(acResult.selectIndex, 0);\n\n    const acIndex2 = new ACIndexImpl(messy);\n    acResult = acIndex2.search(\"#r\");\n    assert.equal(acResult.selectIndex, 0);\n    assert.equal(acResult.items[0].text, \"#red\");\n\n    // Whitespace and case don't matter.\n    acResult = acIndex2.search(\"Red\");\n    assert.equal(acResult.selectIndex, 0);\n    assert.equal(acResult.items[0].text, \"  RED  \");\n  });\n\n  it(\"should return a useful highlight function\", function() {\n    const acIndex = new ACIndexImpl(colors, { maxResults: 3 });\n    let acResult: ACResults<TestACItem>;\n\n    // Here we split the items' (uncleaned) text with the returned highlightFunc. The values at\n    // odd-numbered indices should be the matching parts.\n    acResult = acIndex.search(\"red\");\n    assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),\n      [[\"\", \"Red\", \"\"], [\"\", \"Red\", \"dish\"], [\"Dark \", \"Red\", \"\"]]);\n\n    // Partial matches are highlighted too.\n    acResult = acIndex.search(\"darn\");\n    assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),\n      [[\"\", \"Dar\", \"k Red\"], [\"Radical \", \"D\", \"eep Green\"], [\"Blue\"]]);\n\n    // Empty search highlights nothing.\n    acResult = acIndex.search(\"\");\n    assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),\n      [[\"Blue\"], [\"Dark Red\"], [\"Reddish\"]]);\n\n    // Try some messier cases.\n    const acIndex2 = new ACIndexImpl(messy, { maxResults: 6 });\n    acResult = acIndex2.search(\"#r\");\n    assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),\n      [[\"#\", \"r\", \"ed\"], [\"  \", \"R\", \"ED  \"], [\"\", \"r\", \"ed\"], [\"\", \"r\", \"ead \"],\n        [\"\\n\", \"r\", \"ed\\n#\", \"r\", \"ed\\n\", \"r\", \"ed\"], [\"\", \"R\", \"EDIS/1\"]]);\n\n    acResult = acIndex2.search(\"read\");\n    assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)), [\n      [\"\", \"read\", \" \"], [\"  \", \"RE\", \"D  \"], [\"\", \"re\", \"d\"], [\"#\", \"re\", \"d\"],\n      [\"\\n\", \"re\", \"d\\n#\", \"re\", \"d\\n\", \"re\", \"d\"], [\"\", \"RE\", \"DIS/1\"]]);\n  });\n\n  it(\"should highlight multi-byte unicode\", function() {\n    const acIndex = new ACIndexImpl([\"Lorem ipsum 𝌆 dolor sit ameͨ͆t.\", \"mañana\", \"Москва\"].map(makeItem), {\n      maxResults: 3,\n    });\n    let acResult: ACResults<TestACItem> = acIndex.search(\"mañ моск am\");\n    assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text)),\n      [[\"\", \"Моск\", \"ва\"], [\"\", \"mañ\", \"ana\"], [\"Lorem ipsum 𝌆 dolor sit \", \"am\", \"eͨ͆t.\"]]);\n\n    const original = \"ameͨ͆\";\n    assert.equal(original.length, 5);\n    for (let end = 3; end <= original.length; end++) {\n      const text = original.slice(0, end);  // i.e. test: ame, ameͨ, ameͨ͆ (hard to see the difference in some editors)\n      acResult = acIndex.search(text);\n      assert.deepEqual(acResult.items.map(i => acResult.highlightFunc(i.text))[0],\n        [\"Lorem ipsum 𝌆 dolor sit \", original, \"t.\"]);\n    }\n  });\n\n  it(\"should match a brute-force scoring implementation\", function() {\n    const acIndex1 = new ACIndexImpl(colors);\n    const acIndex2 = new BruteForceACIndexImpl(colors);\n    for (const text of [\"RED\", \"blue\", \"a\", \"Z\", \"rea\", \"RZ\", \"da re\", \"re da\", \"\"]) {\n      assert.deepEqual(acIndex1.search(text).items, acIndex2.search(text).items,\n        `different results for \"${text}\"`);\n    }\n  });\n\n  // See ENABLE_TIMING_TESTS flag on top of this file.\n  if (ENABLE_TIMING_TESTS) {\n    // Returns a list of many items, for checking performance.\n    async function getCities(): Promise<TestACItem[]> {\n      // Pick a file we have with 4k+ rows. First two columns are city,country.\n      // To create more items, we'll return \"city N, country\" combinations for N in [0, 25).\n      const filePath = path.resolve(fixturesRoot, \"export-csv/many-rows.csv\");\n      const data = await fse.readFile(filePath, { encoding: \"utf8\" });\n      const result: TestACItem[] = [];\n      for (const line of data.split(\"\\n\")) {\n        const [city, country] = line.split(\",\");\n        for (let i = 0; i < 25; i++) {\n          result.push(makeItem(`${city} ${i}, ${country}`));\n        }\n      }\n      return result;\n    }\n\n    // Repeat `func()` call `count` times, returning [msec per call, last return value].\n    function repeat<T>(count: number, func: () => T): [number, T] {\n      const start = Date.now();\n      let ret: T;\n      for (let i = 0; i < count; i++) {\n        ret = func();\n      }\n      const msecTaken = Date.now() - start;\n      return [msecTaken / count, ret!];\n    }\n\n    describe(\"timing\", function() {\n      this.timeout(20000);\n\n      let items: TestACItem[];\n\n      before(async function() {\n        items = await getCities();\n      });\n\n      it(\"main algorithm\", function() {\n        const [buildTime, acIndex] = repeat(10, () => new ACIndexImpl(items, { maxResults: 100 }));\n        console.log(`Time to build index (${items.length} items): ${buildTime} ms`);\n\n        const [searchTime, result] = repeat(10, () => acIndex.search(\"YORK\"));\n        console.log(`Time to search index (${items.length} items): ${searchTime} ms`);\n        assert.equal(result.items[0].text, \"York 0, United Kingdom\");\n        assert.equal(result.items[75].text, \"New York 0, United States\");\n      });\n\n      it(\"brute-force algorithm\", function() {\n        const [buildTime, acIndex] = repeat(10, () => new BruteForceACIndexImpl(items, 100));\n        console.log(`Time to build index (${items.length} items): ${buildTime} ms`);\n\n        const [searchTime, result] = repeat(10, () => acIndex.search(\"YORK\"));\n        console.log(`Time to search index (${items.length} items): ${searchTime} ms`);\n        assert.equal(result.items[0].text, \"York 0, United Kingdom\");\n        assert.equal(result.items[75].text, \"New York 0, United States\");\n      });\n    });\n  }\n});\n\n// This is a brute force implementation of the same score-based search. It makes scoring logic\n// easier to understand.\nclass BruteForceACIndexImpl<Item extends ACItem> implements ACIndex<Item> {\n  constructor(private _allItems: Item[], private _maxResults: number = 50) {}\n\n  public search(searchText: string): ACResults<Item> {\n    const cleanedSearchText = searchText.trim().toLowerCase();\n    if (!cleanedSearchText) {\n      return {\n        items: this._allItems.slice(0, this._maxResults),\n        extraItems: [],\n        highlightFunc: highlightNone,\n        selectIndex: -1,\n      };\n    }\n\n    const searchWords = cleanedSearchText.split(/\\s+/);\n\n    // Each item consists of the item's score, item's index, and the item itself.\n    const matches: [number, number, Item][] = [];\n\n    // Get a score for each item based on the amount of overlap with text.\n    for (let i = 0; i < this._allItems.length; i++) {\n      const item = this._allItems[i];\n      const score: number = getScore(item.cleanText, searchWords);\n      matches.push([score, i, item]);\n    }\n\n    // Sort the matches by score first, and then by item (searchText).\n    matches.sort((a, b) => nativeCompare(b[0], a[0]) || nativeCompare(a[1], b[1]));\n    const items = matches.slice(0, this._maxResults).map(m => m[2]);\n\n    return { items, extraItems: [], highlightFunc: highlightNone, selectIndex: -1 };\n  }\n}\n\n// Scores text against an array of search words by adding the lengths of common prefixes between\n// the search words and words in the text.\nfunction getScore(text: string, searchWords: string[]) {\n  const textWords = text.split(/\\s+/);\n  let score = 0;\n  for (let k = 0; k < searchWords.length; k++) {\n    const w = searchWords[k];\n    // Power term for bonus disambiguates scores that are otherwise identical, to prioritize\n    // earlier words appearing in earlier positions.\n    const wordScore = Math.max(...textWords.map((sw, i) => getWordScore(sw, w, Math.pow(2, -(i + k)))));\n    score += wordScore;\n  }\n  if (text.startsWith(searchWords.join(\" \"))) {\n    score += 1;\n  }\n  return score;\n}\n\nfunction getWordScore(searchedWord: string, word: string, bonus: number) {\n  if (searchedWord === word) { return word.length + 1 + bonus; }\n  while (word) {\n    if (searchedWord.startsWith(word)) { return word.length + bonus; }\n    word = word.slice(0, -1);\n  }\n  return 0;\n}\n"
  },
  {
    "path": "test/client/lib/Delay.js",
    "content": "var assert = require(\"chai\").assert;\nvar sinon = require(\"sinon\");\nvar Promise = require(\"bluebird\");\n\nvar {Delay} = require(\"app/client/lib/Delay\");\nvar clientUtil = require(\"../clientUtil\");\n\nconst DELAY_MS = 50;\n\ndescribe(\"Delay\", function() {\n\n  clientUtil.setTmpMochaGlobals();\n\n  it(\"should set and clear timeouts\", function() {\n    var spy1 = sinon.spy(), spy2 = sinon.spy(), spy3 = sinon.spy(), spy4 = sinon.spy();\n    var delay = Delay.create();\n    assert(!delay.isPending());\n\n    delay.schedule(DELAY_MS * 2, spy1);\n    assert(delay.isPending());\n\n    delay.cancel();\n    assert(!delay.isPending());\n\n    delay.schedule(DELAY_MS * 2, spy2);\n    return Promise.delay(DELAY_MS).then(function() {\n      delay.cancel();\n      assert(!delay.isPending());\n      delay.schedule(DELAY_MS * 2, spy3);\n    })\n      .delay(DELAY_MS).then(function() {\n        delay.schedule(DELAY_MS * 2, spy4, null, 1, 2);\n      })\n      .delay(DELAY_MS * 4).then(function() {\n        sinon.assert.notCalled(spy1);\n        sinon.assert.notCalled(spy2);\n        sinon.assert.notCalled(spy3);\n        sinon.assert.calledOnce(spy4);\n        sinon.assert.calledOn(spy4, null);\n        sinon.assert.calledWith(spy4, 1, 2);\n        assert(!delay.isPending());\n      });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/DocSchemaImport.ts",
    "content": "import { getExistingDocSchema } from \"app/client/lib/DocSchemaImport\";\nimport { DocAPI } from \"app/common/UserAPI\";\nimport { TableMetadata } from \"app/plugin/DocApiTypes\";\nimport clientUtil from \"test/client/clientUtil\";\n\nimport { assert } from \"chai\";\nimport sinon from \"sinon\";\n\ndescribe(\"DocSchemaImport\", function() {\n  clientUtil.setTmpMochaGlobals();\n\n  describe(\"getExistingDocSchema\", () => {\n    it(\"returns a correctly formatted document description from an SQL response\", async () => {\n      const tables: TableMetadata[] = [\n        {\n          id: \"Table1\",\n          fields: {\n            tableRef: 1,\n          },\n          columns: [\n            {\n              id: \"Col1\",\n              fields: {\n                colRef: 1,\n                label: \"Column 1\",\n                isFormula: false,\n              },\n            },\n            {\n              id: \"Col2\",\n              fields: {\n                colRef: 2,\n                label: \"Column 2\",\n                isFormula: true,\n              },\n            },\n          ],\n        },\n        {\n          id: \"Table2\",\n          fields: {\n            tableRef: 2,\n          },\n          columns: [\n            {\n              id: \"Col3\",\n              fields: {\n                colRef: 3,\n                label: \"Column 3\",\n                isFormula: false,\n              },\n            },\n          ],\n        },\n      ];\n\n      const docApi = {\n        getTables: sinon.fake(() => ({ tables })),\n      } as unknown as DocAPI;\n\n      const schema = await getExistingDocSchema(docApi);\n      assert.lengthOf(schema.tables, 2);\n      assert.deepEqual(schema.tables.map(t => t.id), [\"Table1\", \"Table2\"]);\n      assert.deepEqual(schema.tables.map(t => t.ref), [1, 2]);\n\n      const table1 = schema.tables.find(t => t.id === \"Table1\")!;\n      assert.lengthOf(table1.columns, 2);\n      assert.deepEqual(table1.columns.map(col => col.id), [\"Col1\", \"Col2\"]);\n      assert.deepEqual(table1.columns.map(col => col.ref), [1, 2]);\n      assert.deepEqual(table1.columns.map(col => col.label), [\"Column 1\", \"Column 2\"]);\n      assert.deepEqual(table1.columns.map(col => col.isFormula), [false, true]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/ImportSourceElement.ts",
    "content": "import { ImportSourceElement } from \"app/client/lib/ImportSourceElement\";\nimport { createRpcLogger, PluginInstance } from \"app/common/PluginInstance\";\nimport { FileListItem } from \"app/plugin/grist-plugin-api\";\n\nimport { assert } from \"chai\";\nimport { Rpc } from \"grain-rpc\";\n\n// assign console to logger to show logs\nconst logger = {};\n\ndescribe(\"ImportSourceElement.importSourceStub#getImportSource()\", function() {\n  it(\"should accept buffer for FileContent.content\", async function() {\n    const plugin = createImportSourcePlugin({\n      getImportSource: () => (Promise.resolve({\n        item: {\n          kind: \"fileList\",\n          files: [{\n            content: new Uint8Array([1, 2]),\n            name: \"MyFile\",\n          }],\n        },\n      })),\n    });\n    const importSourceStub = ImportSourceElement.fromArray([plugin])[0].importSourceStub;\n    const res = await importSourceStub.getImportSource(0);\n    assert.equal((res!.item as FileListItem).files[0].name, \"MyFile\");\n    assert.deepEqual((res!.item as FileListItem).files[0].content, new Uint8Array([1, 2]));\n  });\n});\n\n// Helper that creates a plugin which contributes importSource.\nfunction createImportSourcePlugin(importSource: any): PluginInstance {\n  const plugin = new PluginInstance({\n    id: \"\",\n    path: \"\",\n    manifest: {\n      components: {\n        safeBrowser: \"index.html\",\n      },\n      contributions: {\n        importSources: [{\n          label: \"Importer\",\n          importSource: {\n            component: \"safeBrowser\",\n            name: \"importer\",\n          },\n        }],\n      },\n    },\n  }, createRpcLogger(logger, \"plugin instance\"));\n  const rpc = new Rpc({ logger: createRpcLogger(logger, \"rpc\") });\n  rpc.setSendMessage((mssg: any) => rpc.receiveMessage(mssg));\n  rpc.registerImpl(\"importer\", importSource);\n  plugin.rpc.registerForwarder(\"index.html\", rpc);\n  return plugin;\n}\n"
  },
  {
    "path": "test/client/lib/ObservableMap.js",
    "content": "const assert = require(\"chai\").assert;\nconst ko     = require(\"knockout\");\n\nconst clientUtil = require(\"../clientUtil\");\n\nconst ObservableMap = require(\"app/client/lib/ObservableMap\");\n\ndescribe(\"ObservableMap\", function () {\n  clientUtil.setTmpMochaGlobals();\n\n  let factor, mapFunc, map, additive;\n  let obsKey1, obsKey2, obsValue1, obsValue2;\n\n  before(function () {\n    factor = ko.observable(2);\n    additive = 0;\n    mapFunc = ko.computed(() => {\n      let f = factor();\n      return function (key) {\n        return key * f + additive;\n      };\n    });\n    map = ObservableMap.create(mapFunc);\n  });\n\n  it(\"should keep track of items and update values on key updates\", function () {\n    obsKey1 = ko.observable(1);\n    obsKey2 = ko.observable(2);\n\n    assert.isUndefined(map.get(1));\n    assert.isUndefined(map.get(2));\n\n    obsValue1 = map.add(obsKey1);\n    obsValue2 = map.add(obsKey2);\n\n    assert.equal(map.get(1).size, 1);\n    assert.equal(map.get(2).size, 1);\n\n    assert.equal(obsValue1(), 2);\n    assert.equal(obsValue2(), 4);\n\n    obsKey1(2);\n\n    assert.isUndefined(map.get(1));\n    assert.equal(map.get(2).size, 2);\n\n    assert.equal(obsValue1(), 4);\n    assert.equal(obsValue2(), 4);\n  });\n\n  it(\"should update all values if mapping function is updated\", function () {\n    assert.equal(obsValue1(), 4);\n    assert.equal(obsValue2(), 4);\n\n    factor(3);\n\n    assert.equal(obsValue1(), 6);\n    assert.equal(obsValue2(), 6);\n\n    obsKey1(4);\n    obsKey2(5);\n\n    assert.equal(obsValue1(), 12);\n    assert.equal(obsValue2(), 15);\n  });\n\n  it(\"updateKeys should update values for that key, but not other values\", function () {\n    additive = 7;\n\n    map.updateKeys([4]);\n\n    assert.equal(obsValue1(), 19);\n    assert.equal(obsValue2(), 15);\n  });\n\n  it(\"updateAll should update all values for all keys\", function () {\n    additive = 8;\n\n    map.updateAll();\n\n    assert.equal(obsValue1(), 20);\n    assert.equal(obsValue2(), 23);\n  });\n\n  it(\"should remove items when they are disposed\", function () {\n    let obsKey1 = ko.observable(6);\n    let obsKey2 = ko.observable(6);\n\n    assert.isUndefined(map.get(6));\n\n    let obsValue1 = map.add(obsKey1);\n    let obsValue2 = map.add(obsKey2);\n\n    assert(map.get(6).has(obsValue1));\n    assert(map.get(6).has(obsValue2));\n    assert.equal(map.get(6).size, 2);\n    obsValue1.dispose();\n    assert.isFalse(map.get(6).has(obsValue1));\n    assert.equal(map.get(6).size, 1);\n    obsValue2.dispose();\n    assert.isUndefined(map.get(6));\n  });\n\n  it(\"should unsubscribe from observables on disposal\", function () {\n    assert.equal(obsValue1(), 20);\n    assert.equal(obsValue2(), 23);\n\n    map.dispose();\n\n    obsKey1(10);\n    obsKey2(11);\n    factor(3);\n\n    assert.equal(obsValue1(), 20);\n    assert.equal(obsValue2(), 23);\n  });\n\n});\n"
  },
  {
    "path": "test/client/lib/ObservableSet.js",
    "content": "var assert = require(\"chai\").assert;\nvar ko = require(\"knockout\");\n\nvar clientUtil = require(\"../clientUtil\");\nvar ObservableSet = require(\"app/client/lib/ObservableSet\");\n\ndescribe(\"ObservableSet\", function() {\n  clientUtil.setTmpMochaGlobals();\n\n  it(\"should keep track of items\", function() {\n    var set = ObservableSet.create();\n    assert.equal(set.count(), 0);\n    assert.deepEqual(set.all(), []);\n\n    var obs1 = ko.observable(true), val1 = { foo: 5 },\n      obs2 = ko.observable(false), val2 = { foo: 17 };\n\n    var sub1 = set.add(obs1, val1),\n      sub2 = set.add(obs2, val2);\n\n    assert.equal(set.count(), 1);\n    assert.deepEqual(set.all(), [val1]);\n\n    obs1(false);\n    assert.equal(set.count(), 0);\n    assert.deepEqual(set.all(), []);\n\n    obs2(true);\n    assert.equal(set.count(), 1);\n    assert.deepEqual(set.all(), [val2]);\n\n    obs1(true);\n    assert.equal(set.count(), 2);\n    assert.deepEqual(set.all(), [val1, val2]);\n\n    sub1.dispose();\n    assert.equal(set.count(), 1);\n    assert.deepEqual(set.all(), [val2]);\n    assert.equal(obs1.getSubscriptionsCount(), 0);\n    assert.equal(obs2.getSubscriptionsCount(), 1);\n\n    sub2.dispose();\n    assert.equal(set.count(), 0);\n    assert.deepEqual(set.all(), []);\n    assert.equal(obs1.getSubscriptionsCount(), 0);\n    assert.equal(obs2.getSubscriptionsCount(), 0);\n  });\n});\n"
  },
  {
    "path": "test/client/lib/PluginApi.ts",
    "content": "import { ColumnsToMap, mapColumnNames, mapColumnNamesBack } from \"app/plugin/grist-plugin-api\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"PluginApi\", function() {\n  it(\"should map columns according to configuration\", function() {\n    const columns: ColumnsToMap = [\"Foo\", { name: \"Bar\", allowMultiple: true }, { name: \"Baz\", optional: true }];\n    let mappings: any = { Foo: null, Bar: [\"A\", \"B\"], Baz: null };\n    const record = { A: 1, B: 2, id: 1 };\n    // When there are not mappings, it should return original data.\n    assert.deepEqual(\n      record,\n      mapColumnNames(record),\n    );\n    assert.deepEqual(\n      record,\n      mapColumnNamesBack(record),\n    );\n    // Foo is not mapped, should be null.\n    assert.isNull(\n      mapColumnNames(record, {\n        mappings,\n        columns,\n      }),\n    );\n    assert.isNull(\n      mapColumnNames([record], {\n        mappings,\n        columns,\n      }),\n    );\n    // Map Foo to A\n    mappings = { ...mappings, Foo: \"A\" };\n    // Should map as Foo is mapped\n    assert.deepEqual(mapColumnNames(record, { mappings, columns }), { id: 1, Foo: 1, Bar: [1, 2] });\n    assert.deepEqual(mapColumnNames([record], { mappings, columns }), [{ id: 1, Foo: 1, Bar: [1, 2] }]);\n    assert.deepEqual(mapColumnNamesBack([{ id: 1, Foo: 1, Bar: [1, 2] }], { mappings, columns }), [record]);\n    // Map Baz\n    mappings = { ...mappings, Baz: \"B\" };\n    assert.deepEqual(mapColumnNames(record, { mappings, columns }), { id: 1, Foo: 1, Bar: [1, 2], Baz: 2 });\n    assert.deepEqual(mapColumnNames([record], { mappings, columns }), [{ id: 1, Foo: 1, Bar: [1, 2], Baz: 2 }]);\n    assert.deepEqual(mapColumnNamesBack([{ id: 1, Foo: 1, Bar: [1, 2], Baz: 5 }], { mappings, columns }),\n      [{ id: 1, A: 1, B: 5 }]);\n  });\n  it(\"should ignore when there are not mappings requested\", function() {\n    const columns: ColumnsToMap | undefined = undefined;\n    const mappings: any = undefined;\n    const record = { A: 1, B: 2, id: 1 };\n    assert.deepEqual(\n      mapColumnNames(record, {\n        mappings,\n        columns,\n      }),\n      record,\n    );\n  });\n});\n"
  },
  {
    "path": "test/client/lib/SafeBrowser.ts",
    "content": "import { ClientScope } from \"app/client/components/ClientScope\";\nimport { Disposable } from \"app/client/lib/dispose\";\nimport { ClientProcess, SafeBrowser } from \"app/client/lib/SafeBrowser\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { PluginInstance } from \"app/common/PluginInstance\";\nimport { GristAPI, RPC_GRISTAPI_INTERFACE } from \"app/plugin/GristAPI\";\nimport { Storage } from \"app/plugin/StorageAPI\";\nimport { checkers } from \"app/plugin/TypeCheckers\";\nimport * as clientUtil from \"test/client/clientUtil\";\n\nimport { basename } from \"path\";\nimport * as url from \"url\";\n\nimport { assert } from \"chai\";\nimport { Rpc } from \"grain-rpc\";\nimport { noop } from \"lodash\";\nimport * as sinon from \"sinon\";\nimport * as tic from \"ts-interface-checker\";\nimport { createCheckers } from \"ts-interface-checker\";\n\nclientUtil.setTmpMochaGlobals();\n\nconst LOG_RPC = false;\n// uncomment next line to turn on rpc logging\n// LOG_RPC = true;\n\ndescribe(\"SafeBrowser\", function() {\n  let clientScope: any;\n  const sandbox = sinon.createSandbox();\n  let browserProcesses: { path: string, proc: ClientProcess }[] = [];\n\n  let disposeSpy: sinon.SinonSpy;\n  const cleanup: (() => void)[] = [];\n\n  beforeEach(function() {\n    const callPluginFunction = sinon.stub();\n    callPluginFunction\n      .withArgs(\"testing-plugin\", \"unsafeNode\", \"func1\")\n      .callsFake((...args) => \"From Russia \" + args[3][0] + \"!\");\n    callPluginFunction\n      .withArgs(\"testing-plugin\", \"unsafeNode\", \"funkyName\")\n      .throws();\n    clientScope = new ClientScope();\n\n    browserProcesses = [];\n    sandbox.stub(SafeBrowser, \"createWorker\").callsFake(createProcess);\n    sandbox.stub(SafeBrowser, \"createView\").callsFake(createProcess as any);\n    sandbox.stub(PluginInstance.prototype, \"getRenderTarget\").returns(noop);\n    disposeSpy = sandbox.spy(Disposable.prototype, \"dispose\");\n  });\n\n  afterEach(function() {\n    sandbox.restore();\n    for (const cb of cleanup) { cb(); }\n    cleanup.splice(0);\n  });\n\n  it(\"should support rpc\", async function() {\n    const { safeBrowser, pluginRpc } = createSafeBrowser(\"test_rpc\");\n    const foo = pluginRpc.getStub<Foo>(\"grist@test_rpc\", FooDescription);\n    await safeBrowser.activate();\n    assert.equal(await foo.foo(\"rpc test\"), \"foo rpc test\");\n  });\n\n  it(\"can stub view processes\", async function() {\n    const { safeBrowser, pluginRpc } = createSafeBrowser(\"test_render\");\n    const foo = pluginRpc.getStub<Foo>(\"grist@test_render_view\", FooDescription);\n    await safeBrowser.activate();\n    assert.equal(await foo.foo(\"rpc test\"), \"foo rpc test from test_render_view\");\n  });\n\n  it(\"can forward rpc to a view process\", async function() {\n    const { safeBrowser, pluginRpc } = createSafeBrowser(\"test_forward\");\n    const foo = pluginRpc.getStub<Foo>(\"grist@test_forward\", FooDescription);\n    await safeBrowser.activate();\n    assert.equal(await foo.foo(\"safeBrowser\"), \"foo safeBrowser from test_forward_view\");\n  });\n\n  it(\"should forward messages\", async function() {\n    const { safeBrowser, pluginRpc } = createSafeBrowser(\"test_messages\");\n    const foo = pluginRpc.getStub<Foo>(\"foo@test_messages\", FooDescription);\n    await safeBrowser.activate();\n    assert.equal(await foo.foo(\"safeBrowser\"), \"from message view\");\n  });\n\n  it(\"should support disposing a rendered view\", async function() {\n    const { safeBrowser, pluginRpc } = createSafeBrowser(\"test_dispose\");\n    const foo = pluginRpc.getStub<Foo>(\"grist@test_dispose\", FooDescription);\n    await safeBrowser.activate();\n    await foo.foo(\"safeBrowser\");\n    assert.deepEqual(browserProcesses.map(p => p.path), [\"test_dispose\", \"test_dispose_view1\", \"test_dispose_view2\"]);\n\n    assert.equal(disposeSpy.calledOn(processByName(\"test_dispose_view1\")!), true);\n    assert.equal(disposeSpy.calledOn(processByName(\"test_dispose_view2\")!), false);\n  });\n\n  it(\"should dispose each process on deactivation\", async function() {\n    const { safeBrowser, pluginRpc } = createSafeBrowser(\"test_dispose\");\n    const foo = pluginRpc.getStub<Foo>(\"grist@test_dispose\", FooDescription);\n    await safeBrowser.activate();\n    await foo.foo(\"safeBrowser\");\n    await safeBrowser.deactivate();\n    assert.deepEqual(browserProcesses.map(p => p.path), [\"test_dispose\", \"test_dispose_view1\", \"test_dispose_view2\"]);\n    for (const { proc } of browserProcesses) {\n      assert.equal(disposeSpy.calledOn(proc), true);\n    }\n  });\n\n  // it('should allow calling unsafeNode functions', async function() {\n  //   const {safeBrowser, pluginRpc} = createSafeBrowser(\"test_function_call\");\n  //   const rpc = (safeBrowser as any)._pluginInstance.rpc as Rpc;\n  //   const foo = rpc.getStub<Foo>('grist@test_function_call', FooDescription);\n  //   await safeBrowser.activate();\n  //   assert.equal(await foo.foo('func1'), 'From Russia with love!');\n  //   await assert.isRejected(foo.foo('funkyName'));\n  // });\n\n  it(\"should allow access to client scope interfaces\", async function() {\n    const { safeBrowser, pluginRpc } = createSafeBrowser(\"test_client_scope\");\n    const foo = pluginRpc.getStub<Foo>(\"grist@test_client_scope\", FooDescription);\n    await safeBrowser.activate();\n    assert.equal(await foo.foo(\"green\"), \"#0f0\");\n  });\n\n  it(\"should allow access to client scope interfaces from view\", async function() {\n    const { safeBrowser, pluginRpc } = createSafeBrowser(\"test_client_scope_from_view\");\n    const foo = pluginRpc.getStub<Foo>(\"grist@test_client_scope_from_view\", FooDescription);\n    await safeBrowser.activate();\n    assert.equal(await foo.foo(\"red\"), \"red#f00\");\n  });\n\n  it(\"should have type-safe access to client scope interfaces\", async function() {\n    const { safeBrowser, pluginRpc } = createSafeBrowser(\"test_client_scope_typed\");\n    const foo = pluginRpc.getStub<Foo>(\"grist@test_client_scope_typed\", FooDescription);\n    await safeBrowser.activate();\n    await assert.isRejected(foo.foo(\"test\"), /is not a string/);\n  });\n\n  it(\"should allow creating a view process from grist\", async function() {\n    const { safeBrowser, pluginRpc } = createSafeBrowser(\"test_view_process\");\n    // let's call buildDom on test_rpc\n    const proc = safeBrowser.createViewProcess(\"test_rpc\");\n\n    // rpc should work\n    const foo = pluginRpc.getStub<Foo>(\"grist@test_rpc\", FooDescription);\n    assert.equal(await foo.foo(\"Santa\"), \"foo Santa\");\n\n    // now let's dispose\n    proc.dispose();\n  });\n\n  function createProcess(safeBrowser: SafeBrowser, _rpc: Rpc, src: string) {\n    const path: string = basename(url.parse(src).pathname!);\n    const rpc = new Rpc({ logger: LOG_RPC ? {\n      // let's prepend path to the console 'info' and 'warn' channels\n      info: console.info.bind(console, path),\n      warn: console.warn.bind(console, path),\n    } : {}, sendMessage: _rpc.receiveMessage.bind(_rpc) });\n    _rpc.setSendMessage(msg => rpc.receiveMessage(msg));\n    const api = rpc.getStub<GristAPI>(RPC_GRISTAPI_INTERFACE, checkers.GristAPI);\n    function ready() {\n      rpc.processIncoming();\n      void rpc.sendReadyMessage();\n    }\n    // Start up the mock process for the plugin.\n    const proc = new ClientProcess(safeBrowser, _rpc);\n    PROCESSES[path]({ rpc, api, ready });\n    browserProcesses.push({ path, proc });\n    return proc;\n  }\n\n  // At the moment, only the .definition field matters for SafeBrowser.\n  const localPlugin: LocalPlugin = {\n    manifest: {\n      components: { safeBrowser: \"main\" },\n      contributions: {},\n    },\n    id: \"testing-plugin\",\n    path: \"\",\n  };\n  function createSafeBrowser(mainPath: string): { safeBrowser: SafeBrowser, pluginRpc: Rpc } {\n    const pluginInstance = new PluginInstance(localPlugin, {});\n    const safeBrowser = new SafeBrowser({\n      pluginInstance,\n      clientScope,\n      untrustedContentOrigin: \"\",\n      mainPath,\n      baseLogger: {},\n    });\n    cleanup.push(() => safeBrowser.deactivate());\n    pluginInstance.rpc.registerForwarder(mainPath, safeBrowser);\n    return { safeBrowser, pluginRpc: pluginInstance.rpc };\n  }\n\n  function processByName(name: string): ClientProcess | undefined {\n    const procInfo = browserProcesses.find(p => (p.path === name));\n    return procInfo ? procInfo.proc : undefined;\n  }\n});\n\n/**\n * A Dummy Api to contribute to.\n */\ninterface Foo {\n  foo(name: string): Promise<string>;\n}\n\nconst FooDescription = createCheckers({\n  Foo: tic.iface([], {\n    foo: tic.func(\"string\", tic.param(\"name\", \"string\")),\n  }),\n}).Foo;\n\ninterface TestProcesses {\n  [s: string]: (grist: GristModule) => void;\n}\n\n/**\n * This interface describes what exposes grist-plugin-api.ts to the plugin.\n */\ninterface GristModule {\n  rpc: Rpc;\n  api: GristAPI;\n  ready(): void;\n}\n\n/**\n * The safeBrowser's script needed for test.\n */\nconst PROCESSES: TestProcesses = {\n  test_rpc: (grist: GristModule) => {\n    class MyFoo {\n      public async foo(name: string): Promise<string> {\n        return \"foo \" + name;\n      }\n    }\n    grist.rpc.registerImpl<Foo>(\"grist\", new MyFoo(), FooDescription);\n    grist.ready();\n  },\n  async test_render(grist: GristModule) {\n    await grist.api.render(\"test_render_view\", \"fullscreen\");\n    grist.ready();\n  },\n  test_render_view(grist: GristModule) {\n    grist.rpc.registerImpl<Foo>(\"grist\", {\n      foo: (name: string) => `foo ${name} from test_render_view`,\n    });\n    grist.ready();\n  },\n  async test_forward(grist: GristModule) {\n    grist.rpc.registerImpl<Foo>(\"grist\", {\n      foo: (name: string) => viewFoo.foo(name),\n    });\n    grist.api.render(\"test_forward_view\", \"fullscreen\"); // eslint-disable-line @typescript-eslint/no-floating-promises\n    const viewFoo = grist.rpc.getStub<Foo>(\"foo@test_forward_view\", FooDescription);\n    grist.ready();\n  },\n  test_forward_view: (grist: GristModule) => {\n    grist.rpc.registerImpl<Foo>(\"foo\", {\n      foo: async name => `foo ${name} from test_forward_view`,\n    }, FooDescription);\n    grist.ready();\n  },\n  test_messages: (grist: GristModule) => {\n    grist.rpc.registerImpl<Foo>(\"foo\", {\n      foo(name): Promise<string> {\n        return new Promise<string>((resolve) => {\n          grist.rpc.once(\"message\", resolve);\n          // eslint-disable-next-line @typescript-eslint/no-floating-promises\n          grist.api.render(\"test_messages_view\", \"fullscreen\");\n        });\n      },\n    }, FooDescription);\n    grist.ready();\n  },\n  test_messages_view: (grist: GristModule) => {\n    // test if works even if grist.ready() called after postmessage ?\n    grist.ready();\n    // eslint-disable-next-line @typescript-eslint/no-floating-promises\n    grist.rpc.postMessageForward(\"test_messages\", \"from message view\");\n  },\n  test_dispose: (grist: GristModule) => {\n    class MyFoo {\n      public async foo(name: string): Promise<string> {\n        const id = await grist.api.render(\"test_dispose_view1\", \"fullscreen\");\n        await grist.api.render(\"test_dispose_view2\", \"fullscreen\");\n        await grist.api.dispose(id);\n        return \"test\";\n      }\n    }\n    grist.rpc.registerImpl<Foo>(\"grist\", new MyFoo(), FooDescription);\n    grist.ready();\n  },\n  test_dispose_view1: grist => grist.ready(),\n  test_dispose_view2: grist => grist.ready(),\n  test_client_scope: (grist: GristModule) => {\n    class MyFoo {\n      public async foo(name: string): Promise<string> {\n        const stub = grist.rpc.getStub<Storage>(\"storage\");\n        stub.setItem(\"red\", \"#f00\");\n        stub.setItem(\"green\", \"#0f0\");\n        stub.setItem(\"blue\", \"#00f\");\n        return stub.getItem(name);\n      }\n    }\n    grist.rpc.registerImpl<Foo>(\"grist\", new MyFoo(), FooDescription);\n    grist.ready();\n  },\n  test_client_scope_from_view: (grist: GristModule) => {\n    // hit linting limit for number of classes in a single file :-)\n    const myFoo = {\n      foo(name: string): Promise<string> {\n        return new Promise<string>((resolve) => {\n          grist.rpc.once(\"message\", (msg: any) => resolve(name + msg));\n          // eslint-disable-next-line @typescript-eslint/no-floating-promises\n          grist.api.render(\"view_client_scope\", \"fullscreen\");\n        });\n      },\n    };\n    grist.rpc.registerImpl<Foo>(\"grist\", myFoo, FooDescription);\n    grist.ready();\n  },\n  test_client_scope_typed: (grist: GristModule) => {\n    const myFoo = {\n      foo(name: string): Promise<string> {\n        const stub = grist.rpc.getStub<any>(\"storage\");\n        return stub.setItem(1); // this should be an error\n      },\n    };\n    grist.rpc.registerImpl<Foo>(\"grist\", myFoo, FooDescription);\n    grist.ready();\n  },\n  view1: (grist: GristModule) => {\n    const myFoo = {\n      async foo(name: string): Promise<string> {\n        return `foo ${name} from view1`;\n      },\n    };\n    grist.rpc.registerImpl<Foo>(\"foo\", myFoo, FooDescription);\n    grist.ready();\n  },\n  view2: (grist: GristModule) => {\n    grist.ready();\n  },\n  view_client_scope: async (grist: GristModule) => {\n    const stub = grist.rpc.getStub<Storage>(\"storage\");\n    grist.ready();\n    stub.setItem(\"red\", \"#f00\");\n    const result = await stub.getItem(\"red\");\n    // eslint-disable-next-line @typescript-eslint/no-floating-promises\n    grist.rpc.postMessageForward(\"test_client_scope_from_view\", result);\n  },\n};\n"
  },
  {
    "path": "test/client/lib/Signal.ts",
    "content": "import { Signal } from \"app/client/lib/Signal\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"Signal\", function() {\n  it(\"computes new signal from other events\", function() {\n    const started = Signal.create(null, false);\n    const hovered = Signal.create(null, false);\n    const hoverAndStarted = Signal.compute(null, on => on(started) && on(hovered));\n\n    let flag: any = \"not-called\";\n    hoverAndStarted.listen(val => flag = val);\n\n    function start(emit: boolean, expected: boolean) {\n      started.emit(emit);\n      assert.equal(flag, expected);\n      flag = \"not-called\";\n    }\n\n    function hover(emit: boolean, expected: boolean) {\n      hovered.emit(emit);\n      assert.equal(flag, expected);\n      flag = \"not-called\";\n    }\n\n    start(true, false);\n    start(false, false);\n    start(true, false);\n\n    hover(true, true);\n    hover(false, false);\n    hover(true, true);\n\n    start(false, false);\n  });\n\n  it(\"works as flag\", function() {\n    const started = Signal.create(null, false);\n    const hovered = Signal.create(null, false);\n    const andEvent = Signal.compute(null, on => on(started) && on(hovered)).distinct();\n\n    const notCalled = {};\n    let andCalled = notCalled;\n    andEvent.listen(val => andCalled = val);\n\n    function start(emit: boolean, expected: any) {\n      started.emit(emit);\n      assert.equal(andCalled, expected);\n      andCalled = notCalled;\n    }\n\n    start(true, notCalled);\n    start(false, notCalled);\n    start(true, notCalled);\n\n    function hover(emit: boolean, expected: any) {\n      hovered.emit(emit);\n      assert.equal(andCalled, expected);\n      andCalled = notCalled;\n    }\n\n    hover(true, true);\n    hover(false, false);\n    hover(true, true);\n\n    start(false, false);\n  });\n\n  it(\"supports basic compositions\", function() {\n    const numbers = Signal.create(null, 0);\n    const even = numbers.filter(n => n % 2 === 0);\n    const odd = numbers.filter(n => n % 2 === 1);\n\n    let evenCount = 0;\n    let oddCount = 0;\n    even.listen(() => evenCount++);\n    odd.listen(() => oddCount++);\n    assert.equal(evenCount, 0);\n    assert.equal(oddCount, 0);\n\n    numbers.emit(2);\n    assert.equal(evenCount, 1);\n    assert.equal(oddCount, 0);\n\n    numbers.emit(3);\n    assert.equal(evenCount, 1);\n    assert.equal(oddCount, 1);\n\n    const distinct = numbers.distinct();\n    let distinctCount = 0;\n    distinct.listen(() => distinctCount++);\n    assert.equal(distinctCount, 0);\n\n    numbers.emit(3);\n    assert.equal(distinctCount, 0);\n    numbers.emit(3);\n    assert.equal(distinctCount, 0);\n    numbers.emit(4);\n    assert.equal(distinctCount, 1);\n    numbers.emit(4);\n    assert.equal(distinctCount, 1);\n    numbers.emit(5);\n    assert.equal(distinctCount, 2);\n\n    const trafficLight = Signal.create(null, false);\n    const onRoad = numbers.filter(n => !!trafficLight.state.get());\n\n    let onRoadCount = 0;\n    onRoad.listen(() => onRoadCount++);\n    assert.equal(onRoadCount, 0);\n    numbers.emit(5);\n    assert.equal(onRoadCount, 0);\n    trafficLight.emit(true);\n    assert.equal(onRoadCount, 0);\n    numbers.emit(6);\n    assert.equal(onRoadCount, 1);\n  });\n\n  it(\"detects cycles\", function() {\n    const first = Signal.create(null, 0);\n    const second = Signal.create(null, 0);\n    first.listen(n => second.emit(n + 1));\n    second.listen(n => first.emit(n + 1));\n    assert.throws(() => first.emit(1));\n  });\n});\n"
  },
  {
    "path": "test/client/lib/UrlState.ts",
    "content": "import { HistWindow, UrlState } from \"app/client/lib/UrlState\";\n\nimport { assert } from \"chai\";\nimport { dom } from \"grainjs\";\nimport { popGlobals, pushGlobals } from \"grainjs/dist/cjs/lib/browserGlobals\";\nimport { JSDOM } from \"jsdom\";\nimport fromPairs from \"lodash/fromPairs\";\n\ndescribe(\"UrlState\", function() {\n  let mockWindow: HistWindow;\n\n  function pushState(state: any, title: any, href: string) {\n    mockWindow.location = new URL(href) as unknown as Location;\n  }\n\n  beforeEach(function() {\n    mockWindow = {\n      location: new URL(\"http://localhost:8080\") as unknown as Location,\n      history: { pushState } as History,\n      addEventListener: () => undefined,\n      removeEventListener: () => undefined,\n      dispatchEvent: () => true,\n    };\n    // These grainjs browserGlobals are needed for using dom() in tests.\n    const jsdomDoc = new JSDOM(\"<!doctype html><html><body></body></html>\");\n    pushGlobals(jsdomDoc.window);\n  });\n\n  afterEach(function() {\n    popGlobals();\n  });\n\n  interface State {\n    [key: string]: string;\n  }\n\n  function encodeUrl(state: State, baseLocation: Location | URL): string {\n    const url = new URL(baseLocation.href);\n    for (const key of Object.keys(state)) { url.searchParams.set(key, state[key]); }\n    return url.href;\n  }\n  function decodeUrl(location: Location | URL): State {\n    const url = new URL(location.href);\n    return fromPairs(Array.from(url.searchParams.entries()));\n  }\n  function updateState(prevState: State, newState: State): State {\n    return { ...prevState, ...newState };\n  }\n  function needPageLoad(prevState: State, newState: State): boolean {\n    return false;\n  }\n  async function delayPushUrl(prevState: State, newState: State): Promise<void> {\n    // no-op\n  }\n\n  it(\"should produce correct results with configProd\", async function() {\n    mockWindow.location = new URL(\"https://example.com/?foo=A&bar=B\") as unknown as Location;\n    const urlState = new UrlState<State>(mockWindow, { encodeUrl, decodeUrl, updateState, needPageLoad, delayPushUrl });\n    assert.deepEqual(urlState.state.get(), { foo: \"A\", bar: \"B\" });\n\n    const link = dom(\"a\", urlState.setLinkUrl({ bar: \"C\" }));\n    assert.equal(link.getAttribute(\"href\"), \"https://example.com/?foo=A&bar=C\");\n\n    assert.equal(urlState.makeUrl({ bar: \"X\" }), \"https://example.com/?foo=A&bar=X\");\n    assert.equal(urlState.makeUrl({ foo: \"F\", bar: \"\" }), \"https://example.com/?foo=F&bar=\");\n\n    await urlState.pushUrl({ bar: \"X\" });\n    assert.equal(mockWindow.location.href, \"https://example.com/?foo=A&bar=X\");\n    assert.deepEqual(urlState.state.get(), { foo: \"A\", bar: \"X\" });\n    assert.equal(link.getAttribute(\"href\"), \"https://example.com/?foo=A&bar=C\");\n\n    await urlState.pushUrl({ foo: \"F\", baz: \"T\" });\n    assert.equal(mockWindow.location.href, \"https://example.com/?foo=F&bar=X&baz=T\");\n    assert.deepEqual(urlState.state.get(), { foo: \"F\", bar: \"X\", baz: \"T\" });\n    assert.equal(link.getAttribute(\"href\"), \"https://example.com/?foo=F&bar=C&baz=T\");\n  });\n});\n"
  },
  {
    "path": "test/client/lib/chartUtil.ts",
    "content": "import { consolidateValues, sortByXValues, splitValuesByIndex } from \"app/client/lib/chartUtil\";\n\nimport { assert } from \"chai\";\nimport { Datum } from \"plotly.js\";\n\ndescribe(\"chartUtil\", function() {\n  describe(\"sortByXValues\", function() {\n    function sort(data: Datum[][]) {\n      const series = data.map(values => ({ values, label: \"X\" }));\n      sortByXValues(series);\n      return series.map(s => s.values);\n    }\n    it(\"should sort all series according to the first one\", function() {\n      // Should handle simple and trivial cases.\n      assert.deepEqual(sort([]), []);\n      assert.deepEqual(sort([[2, 1, 3, 0.5]]), [[0.5, 1, 2, 3]]);\n      assert.deepEqual(sort([[], [], [], []]), [[], [], [], []]);\n\n      // All series should be sorted according to the first one.\n      assert.deepEqual(sort([[2, 1, 3, 0.5], [\"a\", \"b\", \"c\", \"d\"], [null, -1.1, \"X\", [\"a\"] as any]]),\n        [[0.5, 1, 2, 3], [\"d\", \"b\", \"a\", \"c\"], [[\"a\"] as any, -1.1, null, \"X\"]]);\n\n      // If the first one is sorted, there should be no changes.\n      assert.deepEqual(sort([[\"a\", \"b\", \"c\", \"d\"], [2, 1, 3, 0.5], [null, -1.1, \"X\", [\"a\"] as any]]),\n        [[\"a\", \"b\", \"c\", \"d\"], [2, 1, 3, 0.5], [null, -1.1, \"X\", [\"a\"] as any]]);\n\n      // Should cope if the first series contains values of different type.\n      assert.deepEqual(sort([[null, -1.1, \"X\", [\"a\"] as any], [2, 1, 3, 0.5], [\"a\", \"b\", \"c\", \"d\"]]),\n        [[-1.1, null, [\"a\"] as any, \"X\"], [1, 2, 0.5, 3], [\"b\", \"a\", \"d\", \"c\"]]);\n    });\n  });\n\n  describe(\"splitValuesByIndex\", function() {\n    it(\"should work correctly\", function() {\n      splitValuesByIndex([{ label: \"test\", values: [] }, { label: \"foo\", values: [] }], 0);\n      assert.deepEqual(splitValuesByIndex([\n        { label: \"foo\", values: [[\"L\", \"foo\", \"bar\"], [\"L\", \"baz\"]] as any },\n        { label: \"bar\", values: [\"santa\", \"janus\"] },\n      ], 0), [\n        { label: \"foo\", values: [\"foo\", \"bar\", \"baz\"] },\n        { label: \"bar\", values: [\"santa\", \"santa\", \"janus\"] },\n      ]);\n\n      assert.deepEqual(splitValuesByIndex([\n        { label: \"bar\", values: [\"santa\", \"janus\"] },\n        { label: \"foo\", values: [[\"L\", \"foo\", \"bar\"], [\"L\", \"baz\"]] as any },\n      ], 1), [\n        { label: \"bar\", values: [\"santa\", \"santa\", \"janus\"] },\n        { label: \"foo\", values: [\"foo\", \"bar\", \"baz\"] },\n      ]);\n    });\n  });\n\n  describe(\"consolidateValues\", function() {\n    it(\"should add missing values\", function() {\n      assert.deepEqual(\n        consolidateValues(\n          [\n            { values: [] },\n            { values: [] },\n          ],\n          [\"A\", \"B\"],\n        ),\n        [\n          { values: [\"A\", \"B\"] },\n          { values: [0, 0] },\n        ],\n      );\n\n      assert.deepEqual(\n        consolidateValues(\n          [\n            { values: [\"A\"] },\n            { values: [3] },\n          ],\n          [\"A\", \"B\"],\n        ),\n        [\n          { values: [\"A\", \"B\"] },\n          { values: [3, 0] },\n        ],\n      );\n\n      assert.deepEqual(\n        consolidateValues(\n          [\n            { values: [\"B\"] },\n            { values: [1] },\n          ],\n          [\"A\", \"B\"],\n        ),\n        [\n          { values: [\"A\", \"B\"] },\n          { values: [0, 1] },\n        ],\n      );\n    });\n\n    it(\"should keep redundant value\", function() {\n      assert.deepEqual(\n        consolidateValues(\n          [\n            { values: [\"A\", \"A\"] },\n            { values: [1, 2] },\n          ],\n          [\"A\", \"B\"],\n        ),\n        [\n          { values: [\"A\", \"A\", \"B\"] },\n          { values: [1, 2, 0] },\n        ],\n      );\n\n      assert.deepEqual(\n        consolidateValues(\n          [\n            { values: [\"B\", \"B\"] },\n            { values: [1, 2] },\n          ],\n          [\"A\", \"B\"],\n        ),\n        [\n          { values: [\"A\", \"B\", \"B\"] },\n          { values: [0, 1, 2] },\n        ],\n      );\n    });\n\n    it(\"another case\", function() {\n      assert.deepEqual(\n        consolidateValues(\n          [\n            { values: [\"A\", \"C\"] },\n            { values: [1, 2] },\n          ],\n          [\"A\", \"B\", \"C\", \"D\"],\n        ),\n        [\n          { values: [\"A\", \"B\", \"C\", \"D\"] },\n          { values: [1, 0, 2, 0] },\n        ],\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/dispose.js",
    "content": "var dispose = require(\"app/client/lib/dispose\");\n\nvar bluebird = require(\"bluebird\");\nvar {assert} = require(\"chai\");\nvar sinon = require(\"sinon\");\n\nvar clientUtil = require(\"../clientUtil\");\nvar dom = require(\"app/client/lib/dom\");\n\nrequire(\"chai\").config.truncateThreshold = 10000;\n\ndescribe(\"dispose\", function() {\n\n  clientUtil.setTmpMochaGlobals();\n\n  function Bar() {\n    this.dispose = sinon.spy();\n    this.destroy = sinon.spy();\n  }\n\n  describe(\"Disposable\", function() {\n    it(\"should dispose objects passed to autoDispose\", function() {\n\n      var bar = new Bar();\n      var baz = new Bar();\n      var container1 = dom(\"div\", dom(\"span\"));\n      var container2 = dom(\"div\", dom(\"span\"));\n      var cleanup = sinon.spy();\n      var stopListening = sinon.spy();\n\n      function Foo() {\n        this.bar = this.autoDispose(bar);\n        this.baz = this.autoDisposeWith(\"destroy\", baz);\n        this.child1 = this.autoDispose(container1.appendChild(dom(\"div\")));\n        this.child2 = container2.appendChild(dom(\"div\"));\n        this.autoDisposeWith(dispose.emptyNode, container2);\n        this.autoDisposeCallback(cleanup);\n        this.stopListening = stopListening;\n      }\n      dispose.makeDisposable(Foo);\n\n      var foo = new Foo();\n      assert(!foo.isDisposed());\n      assert.equal(container1.children.length, 2);\n      assert.equal(container2.children.length, 2);\n\n      foo.dispose();\n      assert(foo.isDisposed());\n      assert.equal(bar.dispose.callCount, 1);\n      assert.equal(bar.destroy.callCount, 0);\n      assert.equal(baz.dispose.callCount, 0);\n      assert.equal(baz.destroy.callCount, 1);\n      assert.equal(stopListening.callCount, 1);\n\n      assert(bar.dispose.calledOn(bar));\n      assert(bar.dispose.calledWithExactly());\n      assert(baz.destroy.calledOn(baz));\n      assert(baz.destroy.calledWithExactly());\n      assert(cleanup.calledOn(foo));\n      assert(cleanup.calledWithExactly());\n\n      // Verify that disposal is called in reverse order of autoDispose calls.\n      assert(cleanup.calledBefore(baz.destroy));\n      assert(baz.destroy.calledBefore(bar.dispose));\n      assert(bar.dispose.calledBefore(stopListening));\n\n      // Verify that DOM children got removed: in the second case, the container should be\n      // emptied.\n      assert.equal(container1.children.length, 1);\n      assert.equal(container2.children.length, 0);\n    });\n\n    it(\"should call multiple registered autoDisposeCallbacks in reverse order\", function() {\n      let spy = sinon.spy();\n\n      function Foo() {\n        this.autoDisposeCallback(() => {\n          spy(1);\n        });\n        this.autoDisposeCallback(() => {\n          spy(2);\n        });\n      }\n      dispose.makeDisposable(Foo);\n\n      var foo = new Foo(spy);\n      foo.autoDisposeCallback(() => {\n        spy(3);\n      });\n\n      foo.dispose();\n\n      assert(foo.isDisposed());\n      assert.equal(spy.callCount, 3);\n      assert.deepEqual(spy.firstCall.args,  [3]);\n      assert.deepEqual(spy.secondCall.args, [2]);\n      assert.deepEqual(spy.thirdCall.args,  [1]);\n    });\n  });\n\n  describe(\"create\", function() {\n\n    // Capture console.error messages.\n    const consoleErrors = [];\n    const origConsoleError = console.error;\n    before(function() { console.error = (...args) => consoleErrors.push(args.map(x => \"\"+x)); });\n    after(function() { console.error = origConsoleError; });\n\n    it(\"should dispose partially constructed objects\", function() {\n      var bar = new Bar();\n      var baz = new Bar();\n\n      function Foo(throwWhen) {\n        if (throwWhen === 0) { throw new Error(\"test-error1\"); }\n        this.bar = this.autoDispose(bar);\n        if (throwWhen === 1) { throw new Error(\"test-error2\"); }\n        this.baz = this.autoDispose(baz);\n        if (throwWhen === 2) { throw new Error(\"test-error3\"); }\n      }\n      dispose.makeDisposable(Foo);\n\n      var foo;\n      // If we throw right away, no surprises, nothing gets called.\n      assert.throws(function() { foo = Foo.create(0); }, /test-error1/);\n      assert.strictEqual(foo, undefined);\n      assert.equal(bar.dispose.callCount, 0);\n      assert.equal(baz.dispose.callCount, 0);\n\n      // If we constructed one object, that one object should have gotten disposed.\n      assert.throws(function() { foo = Foo.create(1); }, /test-error2/);\n      assert.strictEqual(foo, undefined);\n      assert.equal(bar.dispose.callCount, 1);\n      assert.equal(baz.dispose.callCount, 0);\n      bar.dispose.resetHistory();\n\n      // If we constructed two objects, both should have gotten disposed.\n      assert.throws(function() { foo = Foo.create(2); }, /test-error3/);\n      assert.strictEqual(foo, undefined);\n      assert.equal(bar.dispose.callCount, 1);\n      assert.equal(baz.dispose.callCount, 1);\n      assert(baz.dispose.calledBefore(bar.dispose));\n      bar.dispose.resetHistory();\n      baz.dispose.resetHistory();\n\n      // If we don't throw, then nothing should get disposed until we call .dispose().\n      assert.doesNotThrow(function() { foo = Foo.create(3); });\n      assert(!foo.isDisposed());\n      assert.equal(bar.dispose.callCount, 0);\n      assert.equal(baz.dispose.callCount, 0);\n      foo.dispose();\n      assert(foo.isDisposed());\n      assert.equal(bar.dispose.callCount, 1);\n      assert.equal(baz.dispose.callCount, 1);\n      assert(baz.dispose.calledBefore(bar.dispose));\n\n      const name = consoleErrors[0][1];\n      assert(name === Foo.name);\n      assert.deepEqual(consoleErrors[0], [\"Error constructing %s:\", name, \"Error: test-error1\"]);\n      assert.deepEqual(consoleErrors[1], [\"Error constructing %s:\", name, \"Error: test-error2\"]);\n      assert.deepEqual(consoleErrors[2], [\"Error constructing %s:\", name, \"Error: test-error3\"]);\n      assert.equal(consoleErrors.length, 3);\n    });\n\n    it(\"promised objects should resolve during normal creation\", function() {\n      const bar = new Bar();\n      bar.marker = 1;\n      const barPromise = bluebird.Promise.resolve(bar);\n      function Foo() {\n        this.bar = this.autoDisposePromise(barPromise);\n      }\n      dispose.makeDisposable(Foo);\n      const foo = Foo.create();\n      return foo.bar.then(bar => {\n        assert.ok(bar.marker);\n      });\n    });\n\n    it(\"promised objects should resolve to null if owner is disposed\", function() {\n      let resolveBar;\n      const barPromise = new bluebird.Promise(resolve => resolveBar = resolve);\n      function Foo() {\n        this.bar = this.autoDisposePromise(barPromise);\n      }\n      dispose.makeDisposable(Foo);\n      const foo = Foo.create();\n      const fooBar = foo.bar;\n      foo.dispose();\n      assert(foo.isDisposed);\n      assert(foo.bar === null);\n      const bar = new Bar();\n      resolveBar(bar);\n      return fooBar.then(bar => {\n        assert.isNull(bar);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/dom.js",
    "content": "var assert = require(\"chai\").assert;\nvar sinon = require(\"sinon\");\nvar Promise = require(\"bluebird\");\nvar ko = require(\"knockout\");\n\nvar dom = require(\"app/client/lib/dom\");\nvar clientUtil = require(\"../clientUtil\");\nvar G = require(\"app/client/lib/browserGlobals\").get(\"DocumentFragment\");\nvar utils = require(\"../../utils\");\n\ndescribe(\"dom\", function() {\n\n  clientUtil.setTmpMochaGlobals();\n\n  describe(\"dom construction\", function() {\n    it(\"should create elements with the right tag name, class and ID\", function() {\n      var elem = dom(\"div\", \"Hello world\");\n      assert.equal(elem.tagName, \"DIV\");\n      assert(!elem.className);\n      assert(!elem.id);\n      assert.equal(elem.textContent, \"Hello world\");\n\n      elem = dom(\"span#foo.bar.baz\", \"Hello world\");\n      assert.equal(elem.tagName, \"SPAN\");\n      assert.equal(elem.className, \"bar baz\");\n      assert.equal(elem.id, \"foo\");\n      assert.equal(elem.textContent, \"Hello world\");\n    });\n\n    it(\"should set attributes\", function() {\n      var elem = dom(\"a\", { title: \"foo\", id: \"bar\" });\n      assert.equal(elem.title, \"foo\");\n      assert.equal(elem.id, \"bar\");\n    });\n\n    it(\"should set children\", function() {\n      var elem = dom(\"div\",\n        \"foo\", dom(\"a#a\"),\n        [dom(\"a#b\"), \"bar\", dom(\"a#c\")],\n        dom.frag(dom(\"a#d\"), \"baz\", dom(\"a#e\")));\n      assert.equal(elem.childNodes.length, 8);\n      assert.equal(elem.childNodes[0].data, \"foo\");\n      assert.equal(elem.childNodes[1].id, \"a\");\n      assert.equal(elem.childNodes[2].id, \"b\");\n      assert.equal(elem.childNodes[3].data, \"bar\");\n      assert.equal(elem.childNodes[4].id, \"c\");\n      assert.equal(elem.childNodes[5].id, \"d\");\n      assert.equal(elem.childNodes[6].data, \"baz\");\n      assert.equal(elem.childNodes[7].id, \"e\");\n    });\n\n    it(\"should flatten nested arrays and arrays returned from functions\", function() {\n      var values = [\"apple\", \"orange\", [\"banana\", \"mango\"]];\n      var elem = dom(\"ul\",\n        values.map(function(value, index) {\n          return dom(\"li\", value);\n        }),\n        [\n          dom(\"li\", \"pear\"),\n          [\n            dom(\"li\", \"peach\"),\n            dom(\"li\", \"cranberry\"),\n          ],\n          dom(\"li\", \"date\")\n        ]\n      );\n\n      assert.equal(elem.outerHTML, \"<ul><li>apple</li><li>orange</li>\" +\n        \"<li>bananamango</li><li>pear</li><li>peach</li><li>cranberry</li>\" +\n        \"<li>date</li></ul>\");\n\n      elem = dom(\"ul\",\n        function(innerElem) {\n          return [\n            dom(\"li\", \"plum\"),\n            dom(\"li\", \"pomegranate\")\n          ];\n        },\n        function(innerElem) {\n          return function(moreInnerElem) {\n            return [\n              dom(\"li\", \"strawberry\"),\n              dom(\"li\", \"blueberry\")\n            ];\n          };\n        }\n      );\n      assert.equal(elem.outerHTML, \"<ul><li>plum</li><li>pomegranate</li>\" +\n        \"<li>strawberry</li><li>blueberry</li></ul>\");\n\n    });\n\n    it(\"should append append values returned from functions except undefined\", function() {\n      var elem = dom(\"div\",\n        function(divElem) {\n          divElem.classList.add(\"yogurt\");\n          return dom(\"div\", \"sneakers\");\n        },\n        dom(\"span\", \"melon\")\n      );\n\n      assert.equal(elem.classList[0], \"yogurt\",\n        \"function shold have applied new class to outer div\");\n      assert.equal(elem.childNodes.length, 2);\n      assert.equal(elem.childNodes[0].innerHTML, \"sneakers\");\n      assert.equal(elem.childNodes[1].innerHTML, \"melon\");\n\n      elem = dom(\"div\",\n        function(divElem) {\n          return undefined;\n        }\n      );\n      assert.equal(elem.childNodes.length, 0,\n        \"undefined returned from a function should not be added to the DOM tree\");\n    });\n\n    it(\"should not append nulls\", function() {\n      var elem = dom(\"div\",\n        [\n          \"hello\",\n          null,\n          \"world\",\n          null,\n          \"jazz\"\n        ],\n        \"hands\",\n        null\n      );\n      assert.equal(elem.childNodes.length, 4,\n        \"undefined returned from a function should not be added to the DOM tree\");\n      assert.equal(elem.childNodes[0].data, \"hello\");\n      assert.equal(elem.childNodes[1].data, \"world\");\n      assert.equal(elem.childNodes[2].data, \"jazz\");\n      assert.equal(elem.childNodes[3].data, \"hands\");\n    });\n\n  });\n\n  utils.timing.describe(\"dom\", function() {\n    var built, child;\n    before(function() {\n      child = dom(\"bar\");\n    });\n    utils.timing.it(40, \"should be fast\", function() {\n      built = utils.repeat(100, function() {\n        return dom(\"div#id1.class1.class2\", {disabled: \"disabled\"},\n          \"foo\",\n          child,\n          [\"hello\", \"world\"],\n          function(elem) {\n            return \"test\";\n          }\n        );\n      });\n    });\n    utils.timing.it(40, \"should be fast\", function() {\n      utils.repeat(100, function() {\n        dom(\"div#id1.class1.class2.class3\");\n        dom(\"div#id1.class1.class2.class3\");\n        dom(\"div#id1.class1.class2.class3\");\n        dom(\"div#id1.class1.class2.class3\");\n        dom(\"div#id1.class1.class2.class3\");\n      });\n    });\n    after(function() {\n      assert.equal(built.getAttribute(\"disabled\"), \"disabled\");\n      assert.equal(built.tagName, \"DIV\");\n      assert.equal(built.className, \"class1 class2\");\n      assert.equal(built.childNodes.length, 5);\n      assert.equal(built.childNodes[0].data, \"foo\");\n      assert.equal(built.childNodes[1], child);\n      assert.equal(built.childNodes[2].data, \"hello\");\n      assert.equal(built.childNodes[3].data, \"world\");\n      assert.equal(built.childNodes[4].data, \"test\");\n    });\n  });\n\n  describe(\"dom.frag\", function() {\n    it(\"should create DocumentFragments\", function() {\n      var elem1 = dom.frag([\"hello\", \"world\"]);\n      assert(elem1 instanceof G.DocumentFragment);\n      assert.equal(elem1.childNodes.length, 2);\n      assert.equal(elem1.childNodes[0].data, \"hello\");\n      assert.equal(elem1.childNodes[1].data, \"world\");\n\n      var elem2 = dom.frag(\"hello\", \"world\");\n      assert(elem2 instanceof G.DocumentFragment);\n      assert.equal(elem2.childNodes.length, 2);\n      assert.equal(elem2.childNodes[0].data, \"hello\");\n      assert.equal(elem2.childNodes[1].data, \"world\");\n\n      var elem3 = dom.frag(dom(\"div\"), [dom(\"span\"), \"hello\"], \"world\");\n      assert.equal(elem3.childNodes.length, 4);\n      assert.equal(elem3.childNodes[0].tagName, \"DIV\");\n      assert.equal(elem3.childNodes[1].tagName, \"SPAN\");\n      assert.equal(elem3.childNodes[2].data, \"hello\");\n      assert.equal(elem3.childNodes[3].data, \"world\");\n    });\n  });\n\n  describe(\"inlineable\", function() {\n    it(\"should return a function suitable for use as dom argument\", function() {\n      var ctx = {a:1}, b = dom(\"span\"), c = {c:1};\n      var spy = sinon.stub().returns(c);\n      var inlinable = dom.inlineable(spy);\n\n      // When the first argument is a Node, then calling inlineable is the same as calling spy.\n      inlinable.call(ctx, b, c, 1, \"asdf\");\n      sinon.assert.calledOnce(spy);\n      sinon.assert.calledOn(spy, ctx);\n      sinon.assert.calledWithExactly(spy, b, c, 1, \"asdf\");\n      assert.strictEqual(spy.returnValues[0], c);\n      spy.reset();\n      spy.returns(c);\n\n      // When the first Node argument is omitted, then the call is deferred. Check that it works\n      // correctly.\n      var func = inlinable.call(ctx, c, 1, \"asdf\");\n      sinon.assert.notCalled(spy);\n      assert.equal(typeof func, \"function\");\n      assert.deepEqual(spy.returnValues, []);\n      let r = func(b);\n      sinon.assert.calledOnce(spy);\n      sinon.assert.calledOn(spy, ctx);\n      sinon.assert.calledWithExactly(spy, b, c, 1, \"asdf\");\n      assert.deepEqual(r, c);\n      assert.strictEqual(spy.returnValues[0], c);\n    });\n  });\n\n  utils.timing.describe(\"dom.inlinable\", function() {\n    var elem, spy, inlinableCounter, inlinableSpy, count = 0;\n    before(function() {\n      elem = dom(\"span\");\n      spy = sinon.stub();\n      inlinableCounter = dom.inlinable(function(elem, a, b) {\n        count++;\n      });\n      inlinableSpy = dom.inlinable(spy);\n    });\n\n    utils.timing.it(25, \"should be fast\", function() {\n      utils.repeat(10000, function() {\n        inlinableCounter(1, \"asdf\")(elem);\n        inlinableCounter(1, \"asdf\")(elem);\n        inlinableCounter(1, \"asdf\")(elem);\n        inlinableCounter(1, \"asdf\")(elem);\n        inlinableCounter(1, \"asdf\")(elem);\n      });\n      inlinableSpy()(elem);\n      inlinableSpy(1)(elem);\n      inlinableSpy(1, \"asdf\")(elem);\n      inlinableSpy(1, \"asdf\", 56)(elem);\n      inlinableSpy(1, \"asdf\", 56, \"hello\")(elem);\n    });\n\n    after(function() {\n      assert.equal(count, 50000);\n      sinon.assert.callCount(spy, 5);\n      assert.deepEqual(spy.args[0], [elem]);\n      assert.deepEqual(spy.args[1], [elem, 1]);\n      assert.deepEqual(spy.args[2], [elem, 1, \"asdf\"]);\n      assert.deepEqual(spy.args[3], [elem, 1, \"asdf\", 56]);\n      assert.deepEqual(spy.args[4], [elem, 1, \"asdf\", 56, \"hello\"]);\n    });\n  });\n\n  describe(\"dom.defer\", function() {\n    it(\"should call supplied function after the current call stack\", function() {\n      var obj = {};\n      var spy1 = sinon.spy();\n      var spy2 = sinon.spy();\n      var div, span;\n      dom(\"div\",\n        span = dom(\"span\", dom.defer(spy1, obj)),\n        div = dom(\"div\", spy2)\n      );\n      sinon.assert.calledOnce(spy2);\n      sinon.assert.calledWithExactly(spy2, div);\n      sinon.assert.notCalled(spy1);\n      return Promise.delay(0).then(function() {\n        sinon.assert.calledOnce(spy2);\n        sinon.assert.calledOnce(spy1);\n        assert(spy2.calledBefore(spy1));\n        sinon.assert.calledOn(spy1, obj);\n        sinon.assert.calledWithExactly(spy1, span);\n      });\n    });\n  });\n\n  describe(\"dom.onDispose\", function() {\n    it(\"should call supplied function when an element is cleaned up\", function() {\n      var obj = {};\n      var spy1 = sinon.spy();\n      var spy2 = sinon.spy();\n      var div, span;\n      div = dom(\"div\",\n        span = dom(\"span\", dom.onDispose(spy1, obj)),\n        dom.onDispose(spy2)\n      );\n      sinon.assert.notCalled(spy1);\n      sinon.assert.notCalled(spy2);\n      ko.virtualElements.emptyNode(div);\n      sinon.assert.notCalled(spy2);\n      sinon.assert.calledOnce(spy1);\n      sinon.assert.calledOn(spy1, obj);\n      sinon.assert.calledWithExactly(spy1, span);\n      ko.removeNode(div);\n      sinon.assert.calledOnce(spy1);\n      sinon.assert.calledOnce(spy2);\n      sinon.assert.calledOn(spy2, undefined);\n      sinon.assert.calledWithExactly(spy2, div);\n    });\n  });\n\n  describe(\"dom.autoDispose\", function() {\n    it(\"should call dispose the supplied value when an element is cleaned up\", function() {\n      var obj = { dispose: sinon.spy() };\n      var div = dom(\"div\", dom.autoDispose(obj));\n      ko.cleanNode(div);\n      sinon.assert.calledOnce(obj.dispose);\n      sinon.assert.calledOn(obj.dispose, obj);\n      sinon.assert.calledWithExactly(obj.dispose);\n    });\n  });\n\n  describe(\"dom.findLastChild\", function() {\n    it(\"should return last matching child\", function() {\n      var el = dom(\"div\", dom(\"div.a.b\"), dom(\"div.b.c\"), dom(\"div.c.d\"));\n      assert.equal(dom.findLastChild(el, \".b\").className, \"b c\");\n      assert.equal(dom.findLastChild(el, \".f\"), null);\n      assert.equal(dom.findLastChild(el, \".c.d\").className, \"c d\");\n      assert.equal(dom.findLastChild(el, \".b.a\").className, \"a b\");\n      function filter(elem) { return elem.classList.length === 2; }\n      assert.equal(dom.findLastChild(el, filter).className, \"c d\");\n    });\n  });\n\n  describe(\"dom.findAncestor\", function() {\n    var el1, el2, el3, el4;\n    before(function() {\n      el1 = dom(\"div.foo.bar\",\n        el2 = dom(\"div.foo\",\n          el3 = dom(\"div.baz\")\n        ),\n        el4 = dom(\"div.foo.bar2\")\n      );\n    });\n\n    function assertSameElem(elem1, elem2) {\n      assert(elem1 === elem2, \"Expected \" + elem1 + \" to be \" + elem2);\n    }\n\n    it(\"should return the child itself if it matches\", function() {\n      assertSameElem(dom.findAncestor(el3, null, \".baz\"), el3);\n      assertSameElem(dom.findAncestor(el3, el3, \".baz\"), el3);\n    });\n\n    it(\"should stop at the nearest match\", function() {\n      assertSameElem(dom.findAncestor(el3, null, \".foo\"), el2);\n      assertSameElem(dom.findAncestor(el3, el1, \".foo\"), el2);\n      assertSameElem(dom.findAncestor(el3, el2, \".foo\"), el2);\n      assertSameElem(dom.findAncestor(el3, el3, \".foo\"), null);\n    });\n\n    it(\"should not go past container\", function() {\n      assertSameElem(dom.findAncestor(el3, null, \".bar\"), el1);\n      assertSameElem(dom.findAncestor(el3, el1, \".bar\"), el1);\n      assertSameElem(dom.findAncestor(el3, el2, \".bar\"), null);\n      assertSameElem(dom.findAncestor(el3, el3, \".bar\"), null);\n    });\n\n    it(\"should fail if child is outside of container\", function() {\n      assertSameElem(dom.findAncestor(el3, el4, \".foo\"), null);\n      assertSameElem(dom.findAncestor(el2, el3, \".foo\"), null);\n    });\n\n    it(\"should return null for no matches\", function() {\n      assertSameElem(dom.findAncestor(el3, null, \".blah\"), null);\n      assertSameElem(dom.findAncestor(el3, el1, \".blah\"), null);\n      assertSameElem(dom.findAncestor(el3, el2, \".blah\"), null);\n      assertSameElem(dom.findAncestor(el3, el3, \".blah\"), null);\n    });\n\n    function filter(elem) { return elem.classList.length === 2; }\n    it(\"should handle a custom filter function\", function() {\n      assertSameElem(dom.findAncestor(el3, null, filter), el1);\n      assertSameElem(dom.findAncestor(el3, el1, filter), el1);\n      assertSameElem(dom.findAncestor(el3, el2, filter), null);\n      assertSameElem(dom.findAncestor(el3, el3, filter), null);\n      assertSameElem(dom.findAncestor(el3, el4, filter), null);\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/domAsync.ts",
    "content": "import { domAsync } from \"app/client/lib/domAsync\";\n\nimport { assert } from \"chai\";\nimport { dom } from \"grainjs\";\nimport { G, popGlobals, pushGlobals } from \"grainjs/dist/cjs/lib/browserGlobals\";\nimport { JSDOM } from \"jsdom\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"domAsync\", function() {\n  beforeEach(function() {\n    // These grainjs browserGlobals are needed for using dom() in tests.\n    const jsdomDoc = new JSDOM(\"<!doctype html><html><body></body></html>\");\n    pushGlobals(jsdomDoc.window);\n  });\n\n  afterEach(function() {\n    popGlobals();\n  });\n\n  it(\"should populate DOM once promises resolve\", async function() {\n    let a: HTMLElement, b: HTMLElement, c: HTMLElement, d: HTMLElement;\n    const onError = sinon.spy();\n    const r1 = dom(\"button\"), r2 = [dom(\"img\"), dom(\"input\")], r4 = dom(\"hr\");\n    const promise1 = Promise.resolve(r1);\n    const promise2 = new Promise(r => setTimeout(r, 20)).then(() => r2);\n    const promise3 = Promise.reject(new Error(\"p3\"));\n    const promise4 = new Promise(r => setTimeout(r, 20)).then(() => r4);\n\n    // A few elements get populated by promises.\n    G.document.body.appendChild(dom(\"div\",\n      a = dom(\"span.a1\", domAsync(promise1)),\n      b = dom(\"span.a2\", domAsync(promise2)),\n      c = dom(\"span.a3\", domAsync(promise3, onError)),\n      d = dom(\"span.a2\", domAsync(promise4)),\n    ));\n\n    // Initially, none of the content is there.\n    assert.lengthOf(a.children, 0);\n    assert.lengthOf(b.children, 0);\n    assert.lengthOf(c.children, 0);\n    assert.lengthOf(d.children, 0);\n\n    // Check that content appears as promises get resolved.\n    await promise1;\n    assert.deepEqual([...a.children], [r1]);\n\n    // Disposing an element will ensure that content does not get added to it.\n    dom.domDispose(d);\n\n    // Need to wait for promise2 for its results to appear.\n    assert.lengthOf(b.children, 0);\n    await promise2;\n    assert.deepEqual([...b.children], r2);\n\n    // Promise4's results should not appear because of domDispose.\n    await promise4;\n    assert.deepEqual([...d.children], []);\n\n    // A rejected promise should not produce content, but call the onError callback.\n    await promise3.catch(() => null);\n    assert.deepEqual([...c.children], []);\n    sinon.assert.calledOnce(onError);\n    sinon.assert.calledWithMatch(onError, { message: \"p3\" });\n\n    assert.lengthOf(a.children, 1);\n    assert.lengthOf(b.children, 2);\n    assert.lengthOf(c.children, 0);\n    assert.lengthOf(d.children, 0);\n  });\n});\n"
  },
  {
    "path": "test/client/lib/koArray.js",
    "content": "var _ = require(\"underscore\");\nvar assert = require(\"assert\");\nvar ko = require(\"knockout\");\nvar sinon = require(\"sinon\");\n\nvar clientUtil = require(\"../clientUtil\");\nvar koArray = require(\"app/client/lib/koArray\");\n\ndescribe(\"koArray\", function() {\n  clientUtil.setTmpMochaGlobals();\n\n  it(\"should emit spliceChange events\", function() {\n    var arr = koArray([1, 2, 3]);\n\n    var events = [];\n\n    // Whenever we get an event, push it to events.\n    [\"change\", \"spliceChange\"].forEach(function(type) {\n      arr.subscribe(function(data) {\n        events.push({ type: type, data: data });\n      }, null, type);\n    });\n\n    function expectSplice(start, num, deleted, options) {\n      assert.equal(events.length, 2);\n      var e = events.shift();\n      assert.equal(e.type, \"spliceChange\");\n      assert.equal(e.data.start, start);\n      assert.equal(e.data.added, num);\n      assert.deepEqual(e.data.deleted, deleted);\n\n      e = events.shift();\n      assert.equal(e.type, \"change\");\n    }\n\n    assert.deepEqual(arr.all(), [1, 2, 3]);\n\n    // push should work fine.\n    arr.push(\"foo\");\n    expectSplice(3, 1, []);\n\n    arr.push(\"bar\");\n    expectSplice(4, 1, []);\n    assert.deepEqual(arr.all(), [1, 2, 3, \"foo\", \"bar\"]);\n    assert.deepEqual(arr.peek(), [1, 2, 3, \"foo\", \"bar\"]);\n    assert.equal(arr.peekLength, 5);\n\n    // insertions via splice should work.\n    arr.splice(1, 0, \"hello\", \"world\");\n    expectSplice(1, 2, []);\n    assert.deepEqual(arr.all(), [1, \"hello\", \"world\", 2, 3, \"foo\", \"bar\"]);\n\n    // including using negative indices.\n    arr.splice(-6, 2, \"blah\");\n    expectSplice(1, 1, [\"hello\", \"world\"]);\n    assert.deepEqual(arr.all(), [1, \"blah\", 2, 3, \"foo\", \"bar\"]);\n\n    // slice should work but not emit anything.\n    assert.deepEqual(arr.slice(3, 5), [3, \"foo\"]);\n    assert.equal(events.length, 0);\n\n    // deletions using splice should work\n    arr.splice(-2, 1);\n    expectSplice(4, 0, [\"foo\"]);\n    assert.deepEqual(arr.all(), [1, \"blah\", 2, 3, \"bar\"]);\n\n    // including deletions to the end\n    arr.splice(1);\n    expectSplice(1, 0, [\"blah\", 2, 3, \"bar\"]);\n    assert.deepEqual(arr.all(), [1]);\n\n    // setting a new array should also produce a splice event.\n    var newValues = [4, 5, 6];\n    arr.assign(newValues);\n    expectSplice(0, 3, [1]);\n\n    // Check that koArray does not affect the array passed-in on assignment.\n    arr.push(7);\n    expectSplice(3, 1, []);\n    assert.deepEqual(newValues, [4, 5, 6]);\n    assert.deepEqual(arr.peek(), [4, 5, 6, 7]);\n\n    // We don't support various observableArray() methods. If we do start supporting them, we\n    // need to make sure they emit correct events.\n    assert.throws(function() { arr.pop(); }, Error);\n    assert.throws(function() { arr.remove(\"b\"); }, Error);\n  });\n\n  it(\"should create dependencies when needed\", function() {\n    var arr = koArray([1, 2, 3]);\n    var sum = ko.computed(function() {\n      return arr.all().reduce(function(sum, item) { return sum + item; }, 0);\n    });\n    var peekSum = ko.computed(function() {\n      return arr.peek().reduce(function(sum, item) { return sum + item; }, 0);\n    });\n\n    assert.equal(sum(), 6);\n    assert.equal(peekSum(), 6);\n    arr.push(10);\n    assert.equal(sum(), 16);\n    assert.equal(peekSum(), 6);\n    arr.splice(1, 1);\n    assert.equal(sum(), 14);\n    assert.equal(peekSum(), 6);\n    arr.splice(0);\n    assert.equal(sum(), 0);\n    assert.equal(peekSum(), 6);\n  });\n\n  describe(\"#arraySplice\", function() {\n    it(\"should work similarly to splice\", function() {\n      var arr = koArray([1, 2, 3]);\n      arr.arraySplice(1, 2, []);\n      assert.deepEqual(arr.peek(), [1]);\n      arr.arraySplice(1, 0, [10, 11]);\n      assert.deepEqual(arr.peek(), [1, 10, 11]);\n      arr.arraySplice(0, 0, [4, 5]);\n      assert.deepEqual(arr.peek(), [4, 5, 1, 10, 11]);\n    });\n  });\n\n  describe(\"#makeLiveIndex\", function() {\n    it(\"should be kept valid\", function() {\n      var arr = koArray([1, 2, 3]);\n      var index = arr.makeLiveIndex();\n      assert.equal(index(), 0);\n\n      index(-1);\n      assert.equal(index(), 0);\n\n      index(null);\n      assert.equal(index(), 0);\n\n      index(100);\n      assert.equal(index(), 2);\n\n      arr.splice(1, 1);\n      assert.deepEqual(arr.peek(), [1, 3]);\n      assert.equal(index(), 1);\n\n      arr.splice(0, 1, 5, 6, 7);\n      assert.deepEqual(arr.peek(), [5, 6, 7, 3]);\n      assert.equal(index(), 3);\n\n      arr.push(10);\n      arr.splice(2, 2);\n      assert.deepEqual(arr.peek(), [5, 6, 10]);\n      assert.equal(index(), 2);\n\n      arr.splice(2, 1);\n      assert.deepEqual(arr.peek(), [5, 6]);\n      assert.equal(index(), 1);\n\n      arr.splice(0, 2);\n      assert.deepEqual(arr.peek(), []);\n      assert.equal(index(), null);\n\n      arr.splice(0, 0, 1, 2, 3);\n      assert.deepEqual(arr.peek(), [1, 2, 3]);\n      assert.equal(index(), 0);\n    });\n  });\n\n  describe(\"#map\", function() {\n    it(\"should map immediately and continuously\", function() {\n      var arr = koArray([1, 2, 3]);\n      var mapped = arr.map(function(orig) { return orig * 10; });\n      assert.deepEqual(mapped.peek(), [10, 20, 30]);\n      arr.push(4);\n      assert.deepEqual(mapped.peek(), [10, 20, 30, 40]);\n      arr.splice(1, 1);\n      assert.deepEqual(mapped.peek(), [10, 30, 40]);\n      arr.splice(0, 1, 5, 6, 7);\n      assert.deepEqual(mapped.peek(), [50, 60, 70, 30, 40]);\n      arr.splice(2, 0, 2);\n      assert.deepEqual(mapped.peek(), [50, 60, 20, 70, 30, 40]);\n      arr.splice(1, 3);\n      assert.deepEqual(mapped.peek(), [50, 30, 40]);\n      arr.splice(0, 0, 1, 2, 3);\n      assert.deepEqual(mapped.peek(), [10, 20, 30, 50, 30, 40]);\n      arr.splice(3, 3);\n      assert.deepEqual(mapped.peek(), [10, 20, 30]);\n\n      // Check that `this` argument works correctly.\n      var foo = { test: function(orig) { return orig * 100; } };\n      var mapped2 = arr.map(function(orig) { return this.test(orig); }, foo);\n      assert.deepEqual(mapped2.peek(), [100, 200, 300]);\n      arr.splice(1, 0, 4, 5);\n      assert.deepEqual(mapped2.peek(), [100, 400, 500, 200, 300]);\n    });\n  });\n\n  describe(\"#syncMap\", function() {\n    it(\"should keep two arrays in sync\", function() {\n      var arr1 = koArray([1, 2, 3]);\n      var arr2 = koArray([4, 5, 6]);\n      var mapped = koArray();\n\n      mapped.syncMap(arr1);\n      assert.deepEqual(mapped.peek(), [1, 2, 3]);\n      arr1.splice(1, 1, 8, 9);\n      assert.deepEqual(mapped.peek(), [1, 8, 9, 3]);\n\n      mapped.syncMap(arr2, function(x) { return x * 10; });\n      assert.deepEqual(mapped.peek(), [40, 50, 60]);\n      arr1.splice(1, 1, 8, 9);\n      assert.deepEqual(mapped.peek(), [40, 50, 60]);\n      arr2.push(8, 9);\n      assert.deepEqual(mapped.peek(), [40, 50, 60, 80, 90]);\n    });\n  });\n\n  describe(\"#subscribeForEach\", function() {\n    it(\"should call onAdd and onRemove callbacks\", function() {\n      var arr1 = koArray([1, 2, 3]);\n      var seen = [];\n      function onAdd(x) { seen.push([\"add\", x]); }\n      function onRm(x) { seen.push([\"rm\", x]); }\n      var sub = arr1.subscribeForEach({ add: onAdd, remove: onRm });\n      assert.deepEqual(seen, [[\"add\", 1], [\"add\", 2], [\"add\", 3]]);\n\n      seen = [];\n      arr1.push(4);\n      assert.deepEqual(seen, [[\"add\", 4]]);\n\n      seen = [];\n      arr1.splice(1, 2);\n      assert.deepEqual(seen, [[\"rm\", 2], [\"rm\", 3]]);\n\n      seen = [];\n      arr1.splice(0, 1, 5, 6);\n      assert.deepEqual(seen, [[\"rm\", 1], [\"add\", 5], [\"add\", 6]]);\n\n      // If subscription is disposed, callbacks should no longer get called.\n      sub.dispose();\n      seen = [];\n      arr1.push(10);\n      assert.deepEqual(seen, []);\n    });\n  });\n\n  describe(\"#setAutoDisposeValues\", function() {\n    it(\"should dispose elements when asked\", function() {\n      var objects = _.range(5).map(function(n) { return { value: n, dispose: sinon.spy() }; });\n      var arr = koArray(objects.slice(0, 3)).setAutoDisposeValues();\n\n      // Just to check what's in the array to start with.\n      assert.equal(arr.all().length, 3);\n      assert.strictEqual(arr.at(0), objects[0]);\n\n      // Delete two elements: they should get disposed, but the remaining one should not.\n      var x = arr.splice(0, 2);\n      assert.equal(arr.all().length, 1);\n      assert.strictEqual(arr.at(0), objects[2]);\n      assert.equal(x.length, 2);\n      sinon.assert.calledOnce(x[0].dispose);\n      sinon.assert.calledOnce(x[1].dispose);\n      sinon.assert.notCalled(objects[2].dispose);\n\n      // Reassign: the remaining element should now also get disposed.\n      arr.assign(objects.slice(3, 5));\n      assert.equal(arr.all().length, 2);\n      assert.strictEqual(arr.at(0), objects[3]);\n      sinon.assert.calledOnce(objects[2].dispose);\n      sinon.assert.notCalled(objects[3].dispose);\n      sinon.assert.notCalled(objects[4].dispose);\n\n      // Dispose the entire array: previously assigned elements should be disposed.\n      arr.dispose();\n      sinon.assert.calledOnce(objects[3].dispose);\n      sinon.assert.calledOnce(objects[4].dispose);\n\n      // Check that elements disposed earlier haven't been disposed more than once.\n      sinon.assert.calledOnce(objects[0].dispose);\n      sinon.assert.calledOnce(objects[1].dispose);\n      sinon.assert.calledOnce(objects[2].dispose);\n    });\n  });\n\n  describe(\"syncedKoArray\", function() {\n    it(\"should return array synced to the value of the observable\", function() {\n      var arr1 = koArray([\"1\", \"2\", \"3\"]);\n      var arr2 = koArray([\"foo\", \"bar\"]);\n      var arr3 = [\"hello\", \"world\"];\n      var obs = ko.observable(arr1);\n\n      var combined = koArray.syncedKoArray(obs);\n\n      // The values match the array returned by the observable, but mapped using wrap().\n      assert.deepEqual(combined.all(), [\"1\", \"2\", \"3\"]);\n\n      // Changes to the array changes the synced array.\n      arr1.push(\"4\");\n      assert.deepEqual(combined.all(), [\"1\", \"2\", \"3\", \"4\"]);\n\n      // Changing the observable changes the synced array; the value may be a plain array.\n      obs(arr3);\n      assert.deepEqual(combined.all(), [\"hello\", \"world\"]);\n\n      // Previously mapped observable array no longer affects the combined one. And of course\n      // modifying the non-observable array makes no difference either.\n      arr1.push(\"4\");\n      arr3.splice(0, 1);\n      arr3.push(\"qwer\");\n      assert.deepEqual(combined.all(), [\"hello\", \"world\"]);\n\n      // Test assigning again to a koArray.\n      obs(arr2);\n      assert.deepEqual(combined.all(), [\"foo\", \"bar\"]);\n      arr2.splice(0, 1);\n      assert.deepEqual(combined.all(), [\"bar\"]);\n      arr2.splice(0, 0, \"this\", \"is\", \"a\", \"test\");\n      assert.deepEqual(combined.all(), [\"this\", \"is\", \"a\", \"test\", \"bar\"]);\n      arr2.assign([\"10\", \"20\"]);\n      assert.deepEqual(combined.all(), [\"10\", \"20\"]);\n\n      // Check that only arr2 has a subscriber (not arr1), and that disposing unsubscribes from\n      // both the observable and the currently active array.\n      assert.equal(arr1.getObservable().getSubscriptionsCount(), 1);\n      assert.equal(arr2.getObservable().getSubscriptionsCount(), 2);\n      assert.equal(obs.getSubscriptionsCount(), 1);\n      combined.dispose();\n      assert.equal(obs.getSubscriptionsCount(), 0);\n      assert.equal(arr2.getObservable().getSubscriptionsCount(), 1);\n    });\n\n    it(\"should work with a mapper callback\", function() {\n      var arr1 = koArray([\"1\", \"2\", \"3\"]);\n      var obs = ko.observable();\n\n      function wrap(value) { return \"x\" + value; }\n      var combined = koArray.syncedKoArray(obs, wrap);\n      assert.deepEqual(combined.all(), []);\n      obs(arr1);\n      assert.deepEqual(combined.all(), [\"x1\", \"x2\", \"x3\"]);\n      arr1.push(\"4\");\n      assert.deepEqual(combined.all(), [\"x1\", \"x2\", \"x3\", \"x4\"]);\n      obs([\"foo\", \"bar\"]);\n      assert.deepEqual(combined.all(), [\"xfoo\", \"xbar\"]);\n      arr1.splice(1, 1);\n      obs(arr1);\n      arr1.splice(1, 1);\n      assert.deepEqual(combined.all(), [\"x1\", \"x4\"]);\n    });\n  });\n\n  describe(\"syncedMap\", function() {\n    it(\"should associate state with each item and dispose it\", function() {\n      var arr = koArray([\"1\", \"2\", \"3\"]);\n      var constructSpy = sinon.spy(), disposeSpy = sinon.spy();\n      var map = koArray.syncedMap(arr, (state, val) => {\n        constructSpy(val);\n        state.autoDisposeCallback(() => disposeSpy(val));\n      });\n      assert.deepEqual(constructSpy.args, [[\"1\"], [\"2\"], [\"3\"]]);\n      assert.deepEqual(disposeSpy.args, []);\n      arr.splice(1, 0, \"4\", \"5\");\n      assert.deepEqual(arr.peek(), [\"1\", \"4\", \"5\", \"2\", \"3\"]);\n      assert.deepEqual(constructSpy.args, [[\"1\"], [\"2\"], [\"3\"], [\"4\"], [\"5\"]]);\n      assert.deepEqual(disposeSpy.args, []);\n      arr.splice(0, 2);\n      assert.deepEqual(constructSpy.args, [[\"1\"], [\"2\"], [\"3\"], [\"4\"], [\"5\"]]);\n      assert.deepEqual(disposeSpy.args, [[\"1\"], [\"4\"]]);\n      map.dispose();\n      assert.deepEqual(constructSpy.args, [[\"1\"], [\"2\"], [\"3\"], [\"4\"], [\"5\"]]);\n      assert.deepEqual(disposeSpy.args, [[\"1\"], [\"4\"], [\"2\"], [\"3\"], [\"5\"]]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/koArrayWrap.ts",
    "content": "import koArray from \"app/client/lib/koArray\";\nimport { createObsArray } from \"app/client/lib/koArrayWrap\";\n\nimport { assert } from \"chai\";\nimport { Holder } from \"grainjs\";\nimport * as sinon from \"sinon\";\n\nfunction assertResetSingleCall(spy: sinon.SinonSpy, ...args: any[]): void {\n  sinon.assert.calledOnce(spy);\n  sinon.assert.calledOn(spy, undefined);\n  sinon.assert.calledWithExactly(spy, ...args);\n  spy.resetHistory();\n}\n\ndescribe(\"koArrayWrap\", function() {\n  it(\"should map splice changes correctly\", function() {\n    const kArr = koArray([1, 2, 3]);\n    const holder = Holder.create(null);\n    const gArr = createObsArray(holder, kArr);\n    assert.deepEqual(gArr.get(), [1, 2, 3]);\n\n    const spy = sinon.spy();\n    gArr.addListener(spy);\n\n    // Push to array.\n    kArr.push(4, 5);\n    assert.deepEqual(kArr.peek(), [1, 2, 3, 4, 5]);\n    assert.deepEqual(gArr.get(), [1, 2, 3, 4, 5]);\n    assertResetSingleCall(spy, gArr.get(), gArr.get(), { deleted: [], start: 3, numAdded: 2 });\n\n    // Splice to remove and add.\n    kArr.splice(1, 1, 11, 12);\n    assert.deepEqual(kArr.peek(), [1, 11, 12, 3, 4, 5]);\n    assert.deepEqual(gArr.get(), [1, 11, 12, 3, 4, 5]);\n    assertResetSingleCall(spy, gArr.get(), gArr.get(), { start: 1, numAdded: 2, deleted: [2] });\n\n    // Splice to just remove.\n    kArr.splice(2, 2);\n    assert.deepEqual(kArr.peek(), [1, 11, 4, 5]);\n    assert.deepEqual(gArr.get(), [1, 11, 4, 5]);\n    assertResetSingleCall(spy, gArr.get(), gArr.get(), { start: 2, numAdded: 0, deleted: [12, 3] });\n\n    // Splice to just add.\n    kArr.splice(3, 0, 21, 22);\n    assert.deepEqual(kArr.peek(), [1, 11, 4, 21, 22, 5]);\n    assert.deepEqual(gArr.get(), [1, 11, 4, 21, 22, 5]);\n    assertResetSingleCall(spy, gArr.get(), gArr.get(), { start: 3, numAdded: 2, deleted: [] });\n\n    // Splice to make empty.\n    kArr.splice(0);\n    assert.deepEqual(kArr.peek(), []);\n    assert.deepEqual(gArr.get(), []);\n    assertResetSingleCall(spy, gArr.get(), gArr.get(), { start: 0, numAdded: 0, deleted: [1, 11, 4, 21, 22, 5] });\n\n    // Unshift an empty array.\n    kArr.unshift(6, 7);\n    assert.deepEqual(kArr.peek(), [6, 7]);\n    assert.deepEqual(gArr.get(), [6, 7]);\n    assertResetSingleCall(spy, gArr.get(), gArr.get(), { start: 0, numAdded: 2, deleted: [] });\n  });\n\n  it(\"should handle array assignment\", function() {\n    const kArr = koArray([1, 2, 3]);\n    const holder = Holder.create(null);\n    const gArr = createObsArray(holder, kArr);\n    assert.deepEqual(gArr.get(), [1, 2, 3]);\n\n    const spy = sinon.spy();\n    gArr.addListener(spy);\n\n    // Set a new array.\n    kArr.assign([-1, -2]);\n    assert.deepEqual(kArr.peek(), [-1, -2]);\n    assert.deepEqual(gArr.get(), [-1, -2]);\n    assertResetSingleCall(spy, gArr.get(), gArr.get(), { start: 0, numAdded: 2, deleted: [1, 2, 3] });\n  });\n\n  it(\"should unsubscribe when disposed\", function() {\n    const kArr = koArray([1, 2, 3]);\n    const holder = Holder.create(null);\n    const gArr = createObsArray(holder, kArr);\n    assert.deepEqual(gArr.get(), [1, 2, 3]);\n\n    const spy = sinon.spy();\n    gArr.addListener(spy);\n\n    kArr.push(4);\n    assertResetSingleCall(spy, gArr.get(), gArr.get(), { deleted: [], start: 3, numAdded: 1 });\n    const countSubs = kArr.getObservable().getSubscriptionsCount();\n\n    holder.dispose();\n    assert.equal(gArr.isDisposed(), true);\n    assert.equal(kArr.getObservable().getSubscriptionsCount(), countSubs - 1);\n\n    kArr.push(5);\n    sinon.assert.notCalled(spy);\n  });\n});\n"
  },
  {
    "path": "test/client/lib/koDom.js",
    "content": "var assert = require(\"assert\");\nvar ko = require(\"knockout\");\nvar sinon = require(\"sinon\");\n\nvar dom = require(\"app/client/lib/dom\");\nvar kd = require(\"app/client/lib/koDom\");\nvar koArray = require(\"app/client/lib/koArray\");\nvar clientUtil = require(\"../clientUtil\");\n\ndescribe(\"koDom\", function() {\n\n  clientUtil.setTmpMochaGlobals();\n\n  describe(\"simple properties\", function() {\n    it(\"should update dynamically\", function() {\n      var obs = ko.observable(\"bar\");\n      var width = ko.observable(17);\n      var elem = dom(\"div\",\n        kd.attr(\"a1\", \"foo\"),\n        kd.attr(\"a2\", obs),\n        kd.attr(\"a3\", function() { return \"a3\" + obs(); }),\n        kd.text(obs),\n        kd.style(\"width\", function() { return width() + \"px\"; }),\n        kd.toggleClass(\"isbar\", function() { return obs() === \"bar\"; }),\n        kd.cssClass(function() { return \"class\" + obs(); }));\n\n      assert.equal(elem.getAttribute(\"a1\"), \"foo\");\n      assert.equal(elem.getAttribute(\"a2\"), \"bar\");\n      assert.equal(elem.getAttribute(\"a3\"), \"a3bar\");\n      assert.equal(elem.textContent, \"bar\");\n      assert.equal(elem.style.width, \"17px\");\n      assert.equal(elem.className, \"isbar classbar\");\n      obs(\"BAZ\");\n      width(\"34\");\n      assert.equal(elem.getAttribute(\"a1\"), \"foo\");\n      assert.equal(elem.getAttribute(\"a2\"), \"BAZ\");\n      assert.equal(elem.getAttribute(\"a3\"), \"a3BAZ\");\n      assert.equal(elem.textContent, \"BAZ\");\n      assert.equal(elem.style.width, \"34px\");\n      assert.equal(elem.className, \"classBAZ\");\n      obs(\"bar\");\n      assert.equal(elem.className, \"isbar classbar\");\n    });\n  });\n\n  describe(\"domData\", function() {\n    it(\"should set domData and reflect observables\", function() {\n      var foo = ko.observable(null);\n      var elem = dom(\"div\",\n        kd.domData(\"foo\", foo),\n        kd.domData(\"bar\", \"BAR\")\n      );\n      assert.equal(ko.utils.domData.get(elem, \"foo\"), null);\n      assert.equal(ko.utils.domData.get(elem, \"bar\"), \"BAR\");\n      foo(123);\n      assert.equal(ko.utils.domData.get(elem, \"foo\"), 123);\n    });\n  });\n\n  describe(\"scope\", function() {\n    it(\"should handle any number of children\", function() {\n      var obs = ko.observable();\n      var elem = dom(\"div\", \"Hello\",\n        kd.scope(obs, function(value) {\n          return value;\n        }),\n        \"World\");\n      assert.equal(elem.textContent, \"HelloWorld\");\n      obs(\"Foo\");\n      assert.equal(elem.textContent, \"HelloFooWorld\");\n      obs([]);\n      assert.equal(elem.textContent, \"HelloWorld\");\n      obs([\"Foo\", \"Bar\"]);\n      assert.equal(elem.textContent, \"HelloFooBarWorld\");\n      obs(null);\n      assert.equal(elem.textContent, \"HelloWorld\");\n      obs([dom.frag(\"Foo\", dom(\"span\", \"Bar\")), dom(\"div\", \"Baz\")]);\n      assert.equal(elem.textContent, \"HelloFooBarBazWorld\");\n    });\n\n    it(\"should cope with children getting removed outside\", function() {\n      var obs = ko.observable();\n      var elem = dom(\"div\", \"Hello\", kd.scope(obs, function(v) { return v; }), \"World\");\n      assert.equal(elem.innerHTML, \"Hello<!---->World\");\n\n      obs(dom.frag(dom(\"div\", \"Foo\"), dom(\"div\", \"Bar\")));\n      assert.equal(elem.innerHTML, \"Hello<!----><div>Foo</div><div>Bar</div>World\");\n      elem.removeChild(elem.childNodes[2]);\n      assert.equal(elem.innerHTML, \"Hello<!----><div>Bar</div>World\");\n      obs(null);\n      assert.equal(elem.innerHTML, \"Hello<!---->World\");\n\n      obs(dom.frag(dom(\"div\", \"Foo\"), dom(\"div\", \"Bar\")));\n      elem.removeChild(elem.childNodes[3]);\n      assert.equal(elem.innerHTML, \"Hello<!----><div>Foo</div>World\");\n      obs(dom.frag(dom(\"div\", \"Foo\"), dom(\"div\", \"Bar\")));\n      assert.equal(elem.innerHTML, \"Hello<!----><div>Foo</div><div>Bar</div>World\");\n    });\n\n  });\n\n  describe(\"maybe\", function() {\n    it(\"should handle any number of children\", function() {\n      var obs = ko.observable(0);\n      var elem = dom(\"div\", \"Hello\",\n        kd.maybe(function() { return obs() > 0; }, function() {\n          return dom(\"span\", \"Foo\");\n        }),\n        kd.maybe(function() { return obs() > 1; }, function() {\n          return [dom(\"span\", \"Foo\"), dom(\"span\", \"Bar\")];\n        }),\n        \"World\");\n      assert.equal(elem.textContent, \"HelloWorld\");\n      obs(1);\n      assert.equal(elem.textContent, \"HelloFooWorld\");\n      obs(2);\n      assert.equal(elem.textContent, \"HelloFooFooBarWorld\");\n      obs(0);\n      assert.equal(elem.textContent, \"HelloWorld\");\n    });\n\n    it(\"should pass truthy value to content function\", function() {\n      var obs = ko.observable(null);\n      var elem = dom(\"div\", \"Hello\", kd.maybe(obs, function(x) { return x; }), \"World\");\n      assert.equal(elem.innerHTML, \"Hello<!---->World\");\n      obs(dom(\"span\", \"Foo\"));\n      assert.equal(elem.innerHTML, \"Hello<!----><span>Foo</span>World\");\n      obs(0);   // Falsy values should destroy the content\n      assert.equal(elem.innerHTML, \"Hello<!---->World\");\n    });\n  });\n\n  describe(\"foreach\", function() {\n    it(\"should work with koArray\", function() {\n      var model = koArray();\n\n      // Make sure the loop notices elements already in the model.\n      model.assign([\"a\", \"b\", \"c\"]);\n      var elem = dom(\"div\", \"[\",\n        kd.foreach(model, function(item) {\n          return dom(\"span\", \":\", dom(\"span\", kd.text(item)));\n        }),\n        \"]\"\n      );\n\n      assert.equal(elem.textContent, \"[:a:b:c]\");\n\n      // Delete all elements.\n      model.splice(0);\n      assert.equal(elem.textContent, \"[]\");\n\n      // Test push.\n      model.push(\"hello\");\n      assert.equal(elem.textContent, \"[:hello]\");\n      model.push(\"world\");\n      assert.equal(elem.textContent, \"[:hello:world]\");\n\n      // Test splice that replaces some elements with more.\n      model.splice(0, 1, \"foo\", \"bar\", \"baz\");\n      assert.equal(elem.textContent, \"[:foo:bar:baz:world]\");\n\n      // Test splice which removes some elements.\n      model.splice(-3, 2);\n      assert.equal(elem.textContent, \"[:foo:world]\");\n\n      // Test splice which adds some elements in the middle.\n      model.splice(1, 0, \"test2\", \"test3\");\n      assert.equal(elem.textContent, \"[:foo:test2:test3:world]\");\n    });\n\n    it(\"should work when items disappear from under it\", function() {\n      var elements = [dom(\"span\", \"a\"), dom(\"span\", \"b\"), dom(\"span\", \"c\")];\n      var model = koArray();\n      model.assign(elements);\n      var elem = dom(\"div\", \"[\", kd.foreach(model, function(item) { return item; }), \"]\");\n      assert.equal(elem.textContent, \"[abc]\");\n\n      // Plain splice out.\n      var removed = model.splice(1, 1);\n      assert.deepEqual(removed, [elements[1]]);\n      assert.deepEqual(model.peek(), [elements[0], elements[2]]);\n      assert.equal(elem.textContent, \"[ac]\");\n\n      // Splice it back in.\n      model.splice(1, 0, elements[1]);\n      assert.equal(elem.textContent, \"[abc]\");\n\n      // Now remove the element from DOM manually.\n      elem.removeChild(elements[1]);\n      assert.equal(elem.textContent, \"[ac]\");\n      assert.deepEqual(model.peek(), elements);\n\n      // Use splice again, and make sure it still does the right thing.\n      removed = model.splice(2, 1);\n      assert.deepEqual(removed, [elements[2]]);\n      assert.deepEqual(model.peek(), [elements[0], elements[1]]);\n      assert.equal(elem.textContent, \"[a]\");\n\n      removed = model.splice(0, 2);\n      assert.deepEqual(removed, [elements[0], elements[1]]);\n      assert.deepEqual(model.peek(), []);\n      assert.equal(elem.textContent, \"[]\");\n    });\n\n    it(\"should work when items are null\", function() {\n      var model = koArray();\n      var elem = dom(\"div\", \"[\",\n        kd.foreach(model, function(item) { return item && dom(\"span\", item); }),\n        \"]\");\n      assert.equal(elem.textContent, \"[]\");\n\n      model.splice(0, 0, \"a\", \"b\", \"c\");\n      assert.equal(elem.textContent, \"[abc]\");\n\n      var childCount = elem.childNodes.length;\n      model.splice(1, 1, null);\n      assert.equal(elem.childNodes.length, childCount - 1);   // One child removed, non added.\n      assert.equal(elem.textContent, \"[ac]\");\n\n      model.splice(1, 0, \"x\");\n      assert.equal(elem.textContent, \"[axc]\");\n\n      model.splice(3, 0, \"y\");\n      assert.equal(elem.textContent, \"[axyc]\");\n\n      model.splice(1, 2);\n      assert.equal(elem.textContent, \"[ayc]\");\n\n      model.splice(0, 3);\n      assert.equal(elem.textContent, \"[]\");\n    });\n\n    it(\"should dispose subscribables for detached nodes\", function() {\n      var obs = ko.observable(\"AAA\");\n      var cb = sinon.spy(function(x) { return x; });\n      var data = koArray([ko.observable(\"foo\"), ko.observable(\"bar\")]);\n\n      var elem = dom(\"div\", kd.foreach(data, function(item) {\n        return dom(\"div\", kd.text(function() { return cb(item() + \":\" + obs()); }));\n      }));\n\n      assert.equal(elem.innerHTML, \"<!----><div>foo:AAA</div><div>bar:AAA</div>\");\n      obs(\"BBB\");\n      assert.equal(elem.innerHTML, \"<!----><div>foo:BBB</div><div>bar:BBB</div>\");\n      data.splice(1, 1);\n      assert.equal(elem.innerHTML, \"<!----><div>foo:BBB</div>\");\n      cb.resetHistory();\n      // Below is the core of the test: we are checking that the computed observable created for\n      // the second item of the array (\"bar\") does NOT trigger a call to cb.\n      obs(\"CCC\");\n      assert.equal(elem.innerHTML, \"<!----><div>foo:CCC</div>\");\n      sinon.assert.calledOnce(cb);\n      sinon.assert.calledWith(cb, \"foo:CCC\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/koDomScrolly.js",
    "content": "const {Scrolly} = require(\"app/client/lib/koDomScrolly\");\nconst clientUtil = require(\"../clientUtil\");\nconst G = require(\"app/client/lib/browserGlobals\").get(\"window\", \"$\");\nconst sinon = require(\"sinon\");\nconst assert = require(\"assert\");\n\ndescribe(\"koDomScrolly\", function() {\n\n  clientUtil.setTmpMochaGlobals();\n\n  before(function(){\n    sinon.stub(Scrolly.prototype, \"scheduleUpdateSize\");\n  });\n\n  beforeEach(function(){\n    Scrolly.prototype.scheduleUpdateSize.reset();\n  });\n\n  after(function(){\n    Scrolly.prototype.scheduleUpdateSize.restore();\n  });\n\n  it(\"should not remove other's resize handlers\", function(){\n    let scrolly1 = createScrolly(),\n      scrolly2 = createScrolly();\n    G.$(G.window).trigger(\"resize\");\n    let updateSpy = Scrolly.prototype.scheduleUpdateSize;\n    sinon.assert.called(updateSpy);\n    sinon.assert.calledOn(updateSpy, scrolly1);\n    sinon.assert.calledOn(updateSpy, scrolly2);\n    scrolly2.dispose();\n    updateSpy.reset();\n    G.$(G.window).trigger(\"resize\");\n    assert.deepEqual(updateSpy.thisValues, [scrolly1]);\n  });\n\n});\n\n\nfunction createScrolly() {\n  // subscribe should return a disposable subscription.\n  const dispose = () => {};\n  const subscription = { dispose };\n  const data = {subscribe: () => subscription, all: () => []};\n  return new Scrolly(data);\n}\n"
  },
  {
    "path": "test/client/lib/koForm.js",
    "content": "var assert = require(\"chai\").assert;\nvar ko = require(\"knockout\");\n\nvar kf = require(\"app/client/lib/koForm\");\nvar koArray = require(\"app/client/lib/koArray\");\nvar clientUtil = require(\"../clientUtil\");\n\nvar G = require(\"app/client/lib/browserGlobals\").get(\"$\");\n\ndescribe(\"koForm\", function() {\n\n  clientUtil.setTmpMochaGlobals();\n\n  function triggerInput(input, property, value) {\n    input[property] = value;\n    G.$(input).trigger(\"input\");\n  }\n\n  function triggerChange(input, property, value) {\n    input[property] = value;\n    G.$(input).trigger(\"change\");\n  }\n\n  function triggerClick(elem) {\n    G.$(elem).trigger(\"click\");\n  }\n\n  describe(\"button\", function() {\n    it(\"should call a function\", function() {\n      var calls = 0;\n      var btn = kf.button(function() { calls++; }, \"Test\");\n      triggerClick(btn);\n      triggerClick(btn);\n      triggerClick(btn);\n      assert.equal(calls, 3);\n    });\n  });\n\n  describe(\"checkButton\", function() {\n    it(\"should bind an observable\", function() {\n      var obs = ko.observable(false);\n\n      // Test observable->widget binding.\n      var btn = kf.checkButton(obs, \"Test\");\n      assert(!btn.classList.contains(\"active\"));\n      obs(true);\n      assert(btn.classList.contains(\"active\"));\n\n      btn = kf.checkButton(obs, \"Test2\");\n      assert(btn.classList.contains(\"active\"));\n      obs(false);\n      assert(!btn.classList.contains(\"active\"));\n\n      // Test widget->observable binding.\n      assert.equal(obs(), false);\n      triggerClick(btn);\n      assert.equal(obs(), true);\n      triggerClick(btn);\n      assert.equal(obs(), false);\n    });\n  });\n\n  describe(\"buttonSelect\", function() {\n    it(\"should bind an observable\", function() {\n      var obs = ko.observable(\"b\");\n      var a, b, c;\n\n      kf.buttonSelect(obs,\n        a = kf.optionButton(\"a\", \"Test A\"),\n        b = kf.optionButton(\"b\", \"Test B\"),\n        c = kf.optionButton(\"c\", \"Test C\")\n      );\n\n      // Test observable->widget binding.\n      assert(!a.classList.contains(\"active\"));\n      assert(b.classList.contains(\"active\"));\n      assert(!c.classList.contains(\"active\"));\n      obs(\"a\");\n      assert(a.classList.contains(\"active\"));\n      assert(!b.classList.contains(\"active\"));\n      obs(\"c\");\n      assert(!a.classList.contains(\"active\"));\n      assert(!b.classList.contains(\"active\"));\n      assert(c.classList.contains(\"active\"));\n\n      // Test widget->observable binding.\n      assert.equal(obs(), \"c\");\n      triggerClick(b);\n      assert.equal(obs(), \"b\");\n    });\n  });\n\n  describe(\"checkbox\", function() {\n    it(\"should bind an observable\", function() {\n      var obs = ko.observable(false);\n      var check = kf.checkbox(obs, \"Foo\").querySelector(\"input\");\n\n      // Test observable->widget binding.\n      assert.equal(check.checked, false);\n      obs(true);\n      assert.equal(check.checked, true);\n\n      check = kf.checkbox(obs, \"Foo\").querySelector(\"input\");\n      assert.equal(check.checked, true);\n      obs(false);\n      assert.equal(check.checked, false);\n\n      // Test widget->observable binding.\n      triggerChange(check, \"checked\", true);\n      assert.equal(obs(), true);\n      assert.equal(check.checked, true);\n\n      triggerChange(check, \"checked\", false);\n      assert.equal(obs(), false);\n      assert.equal(check.checked, false);\n    });\n  });\n\n  describe(\"text\", function() {\n    it(\"should bind an observable\", function() {\n      var obs = ko.observable(\"hello\");\n      var input = kf.text(obs).querySelector(\"input\");\n\n      // Test observable->widget binding.\n      assert.equal(input.value, \"hello\");\n      obs(\"world\");\n      assert.equal(input.value, \"world\");\n\n      // Test widget->observable binding.\n      triggerChange(input, \"value\", \"foo\");\n      assert.equal(obs(), \"foo\");\n    });\n  });\n\n  describe(\"text debounce\", function() {\n    it(\"should bind an observable\", function() {\n      var obs = ko.observable(\"hello\");\n      var input = kf.text(obs, {delay: 300}).querySelector(\"input\");\n\n      // Test observable->widget binding.\n      assert.equal(input.value, \"hello\");\n      obs(\"world\");\n      assert.equal(input.value, \"world\");\n\n      // Test widget->observable binding using interrupted by 'Enter' or loosing focus debounce.\n      triggerInput(input, \"value\", \"bar\");\n      assert.equal(input.value, \"bar\");\n      // Ensure that observable value wasn't changed immediately\n      assert.equal(obs(), \"world\");\n      // Simulate 'change' event (hitting 'Enter' or loosing focus)\n      triggerChange(input, \"value\", \"bar\");\n      // Ensure that observable value was changed on 'change' event\n      assert.equal(obs(), \"bar\");\n\n      // Test widget->observable binding using debounce.\n      triggerInput(input, \"value\", \"helloworld\");\n      input.selectionStart = 3;\n      input.selectionEnd = 7;\n      assert.equal(input.value.substring(input.selectionStart, input.selectionEnd), \"lowo\");\n      assert.equal(input.value, \"helloworld\");\n\n      // Ensure that observable value wasn't changed immediately, needs to wait 300 ms\n      assert.equal(obs(), \"bar\");\n\n      // Ensure that after delay value were changed\n      return clientUtil.waitForChange(obs, 350)\n        .then(() => {\n          assert.equal(obs(), \"helloworld\");\n          assert.equal(input.value, \"helloworld\");\n          // Ensure that selection is the same and cursor didn't jump to the end\n          assert.equal(input.value.substring(input.selectionStart, input.selectionEnd), \"lowo\");\n        });\n    });\n  });\n\n  describe(\"numText\", function() {\n    it(\"should bind an observable\", function() {\n      var obs = ko.observable(1234);\n      var input = kf.numText(obs).querySelector(\"input\");\n\n      // Test observable->widget binding.\n      assert.equal(input.value, \"1234\");\n      obs(\"-987.654\");\n      assert.equal(input.value, \"-987.654\");\n\n      // Test widget->observable binding.\n      triggerInput(input, \"value\", \"-1.2\");\n      assert.strictEqual(obs(), -1.2);\n    });\n  });\n\n  describe(\"select\", function() {\n    it(\"should bind an observable\", function() {\n      var obs = ko.observable(\"b\");\n      var input = kf.select(obs, [\"a\", \"b\", \"c\"]).querySelector(\"select\");\n      var options = Array.prototype.slice.call(input.querySelectorAll(\"option\"), 0);\n      function selected() {\n        return options.map(function(option) { return option.selected; });\n      }\n\n      // Test observable->widget binding.\n      assert.deepEqual(selected(), [false, true, false]);\n      obs(\"a\");\n      assert.deepEqual(selected(), [true, false, false]);\n      obs(\"c\");\n      assert.deepEqual(selected(), [false, false, true]);\n\n      // Test widget->observable binding.\n      triggerChange(options[0], \"selected\", true);\n      assert.deepEqual(selected(), [true, false, false]);\n      assert.equal(obs(), \"a\");\n\n      triggerChange(options[1], \"selected\", true);\n      assert.deepEqual(selected(), [false, true, false]);\n      assert.equal(obs(), \"b\");\n    });\n\n    it(\"should work with option array of objects\", function() {\n      var obs = ko.observable();\n      var foo = ko.observable(\"foo\");\n      var bar = ko.observable(\"bar\");\n      var values = koArray([\n        { label: foo, value: \"a1\" },\n        { label: bar, value: \"b1\" },\n      ]);\n\n      var select = kf.select(obs, values);\n      var options = Array.from(select.querySelectorAll(\"option\"));\n      assert.deepEqual(options.map(el => el.textContent), [\"foo\", \"bar\"]);\n\n      triggerChange(options[0], \"selected\", true);\n      assert.equal(obs(), \"a1\");\n\n      foo(\"foo2\");\n      bar(\"bar2\");\n\n      options = Array.from(select.querySelectorAll(\"option\"));\n      assert.deepEqual(options.map(el => el.textContent), [\"foo2\", \"bar2\"]);\n\n      triggerChange(options[1], \"selected\", true);\n      assert.equal(obs(), \"b1\");\n    });\n\n    it(\"should store actual, non-stringified values\", function() {\n      let obs = ko.observable();\n      let values = [\n        { label: \"a\", value: 1 },\n        { label: \"b\", value: \"2\" },\n        { label: \"c\", value: true },\n        { label: \"d\", value: { hello: \"world\" } },\n        { label: \"e\", value: new Date() },\n      ];\n      let options = Array.from(kf.select(obs, values).querySelectorAll(\"option\"));\n\n      for (let i = 0; i < values.length; i++) {\n        triggerChange(options[i], \"selected\", true);\n        assert.strictEqual(obs(), values[i].value);\n      }\n    });\n\n    it(\"should allow multi-select and save sorted values\", function() {\n      let obs = ko.observable();\n      let foo = { foo: \"bar\" };\n      let values = [{ label: \"a\", value: foo }, \"d\", { label: \"c\", value: 1 }, \"b\"];\n      let options = Array.from(\n        kf.select(obs, values, { multiple: true}).querySelectorAll(\"option\"));\n\n      triggerChange(options[0], \"selected\", true);\n      triggerChange(options[2], \"selected\", true);\n      triggerChange(options[3], \"selected\", true);\n\n      assert.deepEqual(obs(), [1, foo, \"b\"]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/koUtil.js",
    "content": "var assert = require(\"assert\");\nvar ko = require(\"knockout\");\nvar sinon = require(\"sinon\");\n\nvar koUtil = require(\"app/client/lib/koUtil\");\n\ndescribe(\"koUtil\", function() {\n\n  describe(\"observableWithDefault\", function() {\n    it(\"should be an observable with a default\", function() {\n      var foo = ko.observable();\n\n      var bar1 = koUtil.observableWithDefault(foo, \"defaultValue\");\n\n      var obj = { prop: 17 };\n      var bar2 = koUtil.observableWithDefault(foo, function() { return this.prop; }, obj);\n\n      assert.equal(bar1(), \"defaultValue\");\n      assert.equal(bar2(), 17);\n\n      foo(\"hello\");\n      assert.equal(bar1(), \"hello\");\n      assert.equal(bar2(), \"hello\");\n\n      obj.prop = 28;\n      foo(0);\n      assert.equal(bar1(), \"defaultValue\");\n      assert.equal(bar2(), 28);\n\n      bar1(\"world\");\n      assert.equal(foo(), \"world\");\n      assert.equal(bar1(), \"world\");\n      assert.equal(bar2(), \"world\");\n\n      bar2(\"blah\");\n      assert.equal(foo(), \"blah\");\n      assert.equal(bar1(), \"blah\");\n      assert.equal(bar2(), \"blah\");\n\n      bar1(null);\n      assert.equal(foo(), null);\n      assert.equal(bar1(), \"defaultValue\");\n      assert.equal(bar2(), 28);\n    });\n  });\n\n  describe(\"computedAutoDispose\", function() {\n    function testAutoDisposeValue(pure) {\n      var obj = [{dispose: sinon.spy()}, {dispose: sinon.spy()}, {dispose: sinon.spy()}];\n      var which = ko.observable(0);\n      var computedBody = sinon.spy(function() { return obj[which()]; });\n\n      var foo = koUtil.computedAutoDispose({ read: computedBody, pure: pure });\n\n      // An important difference between pure and not is whether it is immediately evaluated.\n      assert.equal(computedBody.callCount, pure ? 0 : 1);\n      assert.strictEqual(foo(), obj[0]);\n      assert.equal(computedBody.callCount, 1);\n      which(1);\n      assert.strictEqual(foo(), obj[1]);\n      assert.equal(computedBody.callCount, 2);\n      assert.equal(obj[0].dispose.callCount, 1);\n      assert.equal(obj[1].dispose.callCount, 0);\n\n      // Another difference is whether changes cause immediate re-evaluation.\n      which(2);\n      assert.equal(computedBody.callCount, pure ? 2 : 3);\n      assert.equal(obj[1].dispose.callCount, pure ? 0 : 1);\n\n      foo.dispose();\n      assert.equal(obj[0].dispose.callCount, 1);\n      assert.equal(obj[1].dispose.callCount, 1);\n      assert.equal(obj[2].dispose.callCount, pure ? 0 : 1);\n    }\n    it(\"autoDisposeValue for pure computed should be pure\", function() {\n      testAutoDisposeValue(true);\n    });\n    it(\"autoDisposeValue for non-pure computed should be non-pure\", function() {\n      testAutoDisposeValue(false);\n    });\n  });\n\n  describe(\"computedBuilder\", function() {\n    it(\"should create appropriate dependencies and dispose values\", function() {\n      var index = ko.observable(0);\n      var foo = ko.observable(\"foo\"); // used in the builder's constructor\n      var faz = ko.observable(\"faz\"); // used in the builder's dispose\n\n      var obj = [{dispose: sinon.spy(() => faz())}, {dispose: sinon.spy(() => faz())}];\n      var builder = sinon.spy(function(i) { obj[i].foo = foo(); return obj[i]; });\n\n      // The built observable should depend on index(), should NOT depend on foo() or faz(), and\n      // returned values should get disposed.\n      var built = koUtil.computedBuilder(function() { return builder.bind(null, index()); });\n\n      assert.equal(builder.callCount, 1);\n      assert.strictEqual(built(), obj[0]);\n      assert.equal(built().foo, \"foo\");\n      foo(\"bar\");\n      assert.equal(builder.callCount, 1);\n      faz(\"baz\");\n      assert.equal(builder.callCount, 1);\n\n      // Changing index should dispose the previous value and rebuild.\n      index(1);\n      assert.equal(obj[0].dispose.callCount, 1);\n      assert.equal(builder.callCount, 2);\n      assert.strictEqual(built(), obj[1]);\n      assert.equal(built().foo, \"bar\");\n\n      // Changing foo() or faz() should continue to have no effect (i.e. disposing the previous\n      // value should not have created any dependencies.)\n      foo(\"foo\");\n      assert.equal(builder.callCount, 2);\n      faz(\"faz\");\n      assert.equal(builder.callCount, 2);\n\n      // Disposing the built observable should dispose the last returned value.\n      assert.equal(obj[1].dispose.callCount, 0);\n      built.dispose();\n      assert.equal(obj[1].dispose.callCount, 1);\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/localStorageObs.ts",
    "content": "import { localStorageBoolObs, localStorageObs } from \"app/client/lib/localStorageObs\";\nimport { setTmpMochaGlobals } from \"test/client/clientUtil\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"localStorageObs\", function() {\n  setTmpMochaGlobals();\n\n  before(() => typeof localStorage !== \"undefined\" ? localStorage.clear() : null);\n\n  it(\"should persist localStorageObs values\", async function() {\n    const foo = localStorageObs(\"localStorageObs-foo\");\n    const bar = localStorageObs(\"localStorageObs-bar\");\n    assert.strictEqual(foo.get(), null);\n    foo.set(\"123\");\n    bar.set(\"456\");\n    assert.strictEqual(foo.get(), \"123\");\n    assert.strictEqual(bar.get(), \"456\");\n\n    // We can't really reload the window the way that the browser harness for test/client tests\n    // works, so just test in the same process with a new instance of these observables.\n    const foo2 = localStorageObs(\"localStorageObs-foo\");\n    const bar2 = localStorageObs(\"localStorageObs-bar\");\n    assert.strictEqual(foo2.get(), \"123\");\n    assert.strictEqual(bar2.get(), \"456\");\n  });\n\n  for (const defl of [false, true]) {\n    it(`should support localStorageBoolObs with default of ${defl}`, async function() {\n      const prefix = `localStorageBoolObs-${defl}`;\n      const foo = localStorageBoolObs(`${prefix}-foo`, defl);\n      const bar = localStorageBoolObs(`${prefix}-bar`, defl);\n      assert.strictEqual(foo.get(), defl);\n      assert.strictEqual(bar.get(), defl);\n      foo.set(true);\n      bar.set(false);\n      assert.strictEqual(foo.get(), true);\n      assert.strictEqual(bar.get(), false);\n      assert.strictEqual(localStorageBoolObs(`${prefix}-foo`, defl).get(), true);\n      assert.strictEqual(localStorageBoolObs(`${prefix}-bar`, defl).get(), false);\n\n      // If created with the opposite default value, it's not very intuitive: if its value matched\n      // the previous default value, then now it will be the opposite; if its value were flipped,\n      // then now it would stay flipped. So it'll match the new default value in either case.\n      assert.strictEqual(localStorageBoolObs(`${prefix}-foo`, !defl).get(), !defl);\n      assert.strictEqual(localStorageBoolObs(`${prefix}-bar`, !defl).get(), !defl);\n    });\n  }\n});\n"
  },
  {
    "path": "test/client/lib/localization.ts",
    "content": "import { makeT, t } from \"app/client/lib/localization\";\n\nimport { assert } from \"chai\";\nimport { Disposable, dom, DomContents, observable } from \"grainjs\";\nimport { G, popGlobals, pushGlobals } from \"grainjs/dist/cjs/lib/browserGlobals\";\nimport i18next, { i18n } from \"i18next\";\nimport { JSDOM } from \"jsdom\";\n\ndescribe(\"localization\", function() {\n  let instance: i18n;\n  before(async () => {\n    instance = i18next.createInstance();\n    await instance.init({\n      lng: \"en\",\n      resources: {\n        en: {\n          translation: {\n            Text: \"TranslatedText\",\n            Argument: \"Translated {{arg1}} {{arg2}}{{end}}\",\n            Argument_variant: \"Variant {{arg1}} {{arg2}}{{end}}\",\n            Parent: {\n              \"Child\": \"Translated child {{arg}}\",\n              \"Not.Valid:Characters\": \"Works\",\n            },\n          },\n        },\n      },\n    });\n  });\n\n  beforeEach(function() {\n    // These grainjs browserGlobals are needed for using dom() in tests.\n    const jsdomDoc = new JSDOM(\"<!doctype html><html><body></body></html>\");\n    pushGlobals(jsdomDoc.window);\n  });\n\n  afterEach(function() {\n    popGlobals();\n  });\n\n  it(\"supports basic operation for strings\", function() {\n    assert.equal(t(\"Argument\", { arg1: \"1\", arg2: \"2\", end: \".\" }, instance), \"Translated 1 2.\");\n    assert.equal(t(\"Argument\", { arg1: \"1\", arg2: \"2\", end: \".\", context: \"variant\" }, instance), \"Variant 1 2.\");\n    assert.equal(t(\"Text\", null, instance), \"TranslatedText\");\n  });\n\n  it(\"supports dom content interpolation\", function() {\n    class Component extends Disposable {\n      public buildDom() {\n        return dom(\"span\", \".\");\n      }\n    }\n    const obs = observable(\"Second\");\n    const result = t(\"Argument\", {\n      arg1: dom(\"span\", \"First\"),\n      arg2: dom.domComputed(obs, value => dom(\"span\", value)),\n      end: dom.create(Component),\n    }, instance) as any;\n    assert.isTrue(Array.isArray(result));\n    assert.equal(result.length, 5);\n    // First we have a plain string.\n    assert.equal(result[0], \"Translated \");\n    // Next we have a span element.\n    assert.equal(result[1]?.tagName, \"SPAN\");\n    assert.equal(result[1]?.textContent, \"First\");\n    // Empty space\n    assert.equal(result[2], \" \");\n    // Element 3 is the domComputed [Comment, Comment, function()]\n    assert.isTrue(Array.isArray(result[3]));\n    assert.isTrue(result[3][0] instanceof G.Node);\n    assert.isTrue(result[3][1] instanceof G.Node);\n    assert.isTrue(typeof result[3][2] === \"function\");\n    // As last we have \".\" as grainjs component.\n    assert.isTrue(Array.isArray(result[4]));\n    assert.isTrue(result[4][0] instanceof G.Node);\n    assert.isTrue(result[4][1] instanceof G.Node);\n    assert.isTrue(typeof result[4][2] === \"function\");\n\n    // Make sure that computed works.\n    const span = dom(\"span\", result);\n    assert.equal(span.textContent, \"Translated First Second.\");\n    obs.set(\"Third\");\n    assert.equal(span.textContent, \"Translated First Third.\");\n\n    // Test that context variable works.\n    const variantSpan = dom(\"span\", t(\"Argument\", {\n      arg1: dom(\"span\", \"First\"),\n      arg2: dom.domComputed(obs, value => dom(\"span\", value)),\n      end: dom.create(Component),\n      context: \"variant\",\n    }, instance));\n    assert.equal(variantSpan.textContent, \"Variant First Third.\");\n    obs.set(\"Fourth\");\n    assert.equal(variantSpan.textContent, \"Variant First Fourth.\");\n  });\n\n  it(\"supports scoping through makeT\", function() {\n    const scoped = makeT(\"Parent\", instance);\n    assert.equal(scoped(\"Child\", { arg: \"Arg\" }), \"Translated child Arg\");\n  });\n\n  it(\"infers result from parameters\", function() {\n    class Component extends Disposable {\n      public buildDom() {\n        return dom(\"span\", \".\");\n      }\n    }\n    // Here we only test that this \"compiles\" without errors and types are correct.\n    let typeString: string = \"\"; void typeString;\n    typeString = t(\"Argument\", { arg1: \"argument 1\", arg2: \"argument 2\" }, instance);\n    typeString = t(\"Argument\", { arg1: 1, arg2: true }, instance);\n    typeString = t(\"Argument\", undefined,  instance);\n    const scoped = makeT(\"Parent\", instance);\n    typeString = scoped(\"Child\", { arg: \"argument 1\" });\n    typeString = scoped(\"Child\", { arg: 1 });\n    typeString = scoped(\"Child\", undefined);\n\n    let domContent: DomContents = null; void domContent;\n\n    domContent = t(\"Argument\", { arg1: \"argument 1\", arg2: dom(\"span\") }, instance);\n    domContent = t(\"Argument\", { arg1: 1, arg2: dom.domComputed(observable(\"test\")) }, instance);\n    domContent = t(\"Argument\", undefined, instance);\n    domContent = scoped(\"Child\", { arg: dom.create(Component) });\n    domContent = scoped(\"Child\", { arg: dom.maybe(observable(true), () => dom(\"span\")) });\n  });\n\n  it(\"supports : and . characters in scoped function\", function() {\n    const scoped = makeT(\"Parent\", instance);\n    assert.equal(scoped(\"Not.Valid:Characters\"), \"Works\");\n  });\n\n  it(\"makeT helper fallbacks to an argument\", function() {\n    const scoped = makeT(\"Parent\", instance);\n    assert.equal(scoped(\"I'm not there\"), \"I'm not there\");\n  });\n});\n"
  },
  {
    "path": "test/client/lib/nameUtils.ts",
    "content": "import { checkName } from \"app/client/lib/nameUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"nameUtils\", function() {\n  describe(\"isValidName\", function() {\n    it(\"should detect invalid name\", function() {\n      assert.equal(checkName(\"santa\"), true);\n      assert.equal(checkName(\"_santa\"), true);\n      assert.equal(checkName(\"O'Neil\"), true);\n      assert.equal(checkName(\"Emily\"), true);\n      assert.equal(checkName(\"santa(2)\"), true);\n      assert.equal(checkName(\"Dr. noname\"), true);\n      assert.equal(checkName(\"santa-klaus\"), true);\n      assert.equal(checkName(\"Noémie\"), true);\n      assert.equal(checkName(\"张伟\"), true);\n\n      assert.equal(checkName(\",,__()\"), false);\n      assert.equal(checkName(\"<foo>\"), false);\n      assert.equal(checkName(\"<foo>\"), false);\n      assert.equal(checkName(\"(bar)\"), false);\n      assert.equal(checkName(\"foo <baz>\"), false);\n      assert.equal(checkName(\"-foo\"), false);\n      assert.equal(checkName(\"'foo\"), false);\n      assert.equal(checkName(\" Bob\"), false);\n\n      assert.equal(checkName(\"=\"), false);\n      assert.equal(checkName(\"santa=\"), false);\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/sanitizeUrl.ts",
    "content": "import {\n  Deps,\n  sanitizeHttpUrl,\n  sanitizeLinkUrl,\n} from \"app/client/lib/sanitizeUrl\";\n\nimport { assert } from \"chai\";\nimport DOMPurify from \"dompurify\";\nimport { JSDOM } from \"jsdom\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"sanitizeUrl\", function() {\n  let sandbox: sinon.SinonSandbox;\n\n  beforeEach(function() {\n    // These grainjs browserGlobals are needed for using dom() in tests.\n    const jsdomDoc = new JSDOM(\"<!doctype html><html><body></body></html>\");\n    sandbox = sinon.createSandbox();\n    sandbox.stub(Deps, \"DOMPurify\").value(DOMPurify(jsdomDoc.window));\n  });\n\n  afterEach(function() {\n    sandbox.restore();\n  });\n\n  describe(\"sanitizeHttpUrl\", function() {\n    it(\"returns the provided URL if valid\", function() {\n      assert.equal(\n        sanitizeHttpUrl(\"https://example.com\"),\n        \"https://example.com/\",\n      );\n      assert.equal(\n        sanitizeHttpUrl(\"http://example.com\"),\n        \"http://example.com/\",\n      );\n    });\n\n    it(\"returns null if the provided URL is invalid\", function() {\n      assert.isNull(sanitizeHttpUrl(\"www.example.com\"));\n      assert.isNull(sanitizeHttpUrl(\"\"));\n      assert.isNull(sanitizeHttpUrl(\"invalid\"));\n      assert.isNull(sanitizeHttpUrl(\"mailto:support@getgrist.com\"));\n      assert.isNull(sanitizeHttpUrl(\"ftp://getgrist.com/path\"));\n      assert.isNull(sanitizeHttpUrl(\"javascript:alert()\"));\n    });\n  });\n\n  describe(\"sanitizeLinkUrl\", function() {\n    it(\"returns the provided URL if valid\", function() {\n      assert.equal(\n        sanitizeLinkUrl(\"https://example.com\"),\n        \"https://example.com\",\n      );\n      assert.equal(sanitizeLinkUrl(\"http://example.com\"), \"http://example.com\");\n      assert.equal(sanitizeLinkUrl(\"www.example.com\"), \"www.example.com\");\n      assert.equal(sanitizeLinkUrl(\"\"), \"\");\n      assert.equal(\n        sanitizeLinkUrl(\"mailto:support@getgrist.com\"),\n        \"mailto:support@getgrist.com\",\n      );\n      assert.equal(sanitizeLinkUrl(\"tel:0123456789\"), \"tel:0123456789\");\n      assert.equal(\n        sanitizeLinkUrl(\"ftp://getgrist.com/path\"),\n        \"ftp://getgrist.com/path\",\n      );\n    });\n\n    it(\"returns null if the provided URL is unsafe\", function() {\n      assert.isNull(sanitizeLinkUrl(\"javascript:alert()\"));\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/sortUtil.ts",
    "content": "import { Sort, VirtualId } from \"app/common/SortSpec\";\n\nimport { assert } from \"chai\";\n\nconst { flipSort: flipColDirection, parseSortColRefs, reorderSortRefs } = Sort;\n\ndescribe(\"sortUtil\", function() {\n  it(\"should parse column expressions\", function() {\n    assert.deepEqual(Sort.getColRef(1), 1);\n    assert.deepEqual(Sort.getColRef(-1), 1);\n    assert.deepEqual(Sort.getColRef(\"-1\"), 1);\n    assert.deepEqual(Sort.getColRef(\"1\"), 1);\n    assert.deepEqual(Sort.getColRef(\"1:emptyLast\"), 1);\n    assert.deepEqual(Sort.getColRef(\"-1:emptyLast\"), 1);\n    assert.deepEqual(Sort.getColRef(\"-1:emptyLast;orderByChoice\"), 1);\n    assert.deepEqual(Sort.getColRef(\"1:emptyLast;orderByChoice\"), 1);\n  });\n\n  it(\"should support finding\", function() {\n    assert.equal(Sort.findCol([1, 2, 3], 1), 1);\n    assert.equal(Sort.findCol([1, 2, 3], \"1\"), 1);\n    assert.equal(Sort.findCol([1, 2, 3], \"-1\"), 1);\n    assert.equal(Sort.findCol([1, 2, 3], \"1\"), 1);\n    assert.equal(Sort.findCol([\"1\", 2, 3], 1), \"1\");\n    assert.equal(Sort.findCol([\"1:emptyLast\", 2, 3], 1), \"1:emptyLast\");\n    assert.equal(Sort.findCol([1, 2, 3], \"1:emptyLast\"), 1);\n    assert.equal(Sort.findCol([1, 2, 3], \"-1:emptyLast\"), 1);\n    assert.isUndefined(Sort.findCol([1, 2, 3], \"6\"));\n    assert.isUndefined(Sort.findCol([1, 2, 3], 6));\n    assert.equal(Sort.findColIndex([1, 2, 3], \"6\"), -1);\n    assert.equal(Sort.findColIndex([1, 2, 3], 6), -1);\n\n    assert.isTrue(Sort.contains([1, 2, 3], 1, Sort.ASC));\n    assert.isFalse(Sort.contains([-1, 2, 3], 1, Sort.ASC));\n    assert.isTrue(Sort.contains([-1, 2, 3], 1, Sort.DESC));\n    assert.isTrue(Sort.contains([\"1\", 2, 3], 1, Sort.ASC));\n    assert.isTrue(Sort.contains([\"1:emptyLast\", 2, 3], 1, Sort.ASC));\n    assert.isFalse(Sort.contains([\"-1:emptyLast\", 2, 3], 1, Sort.ASC));\n    assert.isTrue(Sort.contains([\"-1:emptyLast\", 2, 3], 1, Sort.DESC));\n\n    assert.isTrue(Sort.containsOnly([1], 1, Sort.ASC));\n    assert.isTrue(Sort.containsOnly([-1], 1, Sort.DESC));\n    assert.isFalse(Sort.containsOnly([1, 2], 1, Sort.ASC));\n    assert.isFalse(Sort.containsOnly([2, 1], 1, Sort.ASC));\n    assert.isFalse(Sort.containsOnly([2, 1], 1, Sort.DESC));\n    assert.isFalse(Sort.containsOnly([-1], 1, Sort.ASC));\n    assert.isFalse(Sort.containsOnly([1], 1, Sort.DESC));\n    assert.isTrue(Sort.containsOnly([\"1:emptyLast\"], 1, Sort.ASC));\n    assert.isFalse(Sort.containsOnly([\"1:emptyLast\", 2], 1, Sort.ASC));\n    assert.isTrue(Sort.containsOnly([\"-1:emptyLast\"], 1, Sort.DESC));\n    assert.isFalse(Sort.containsOnly([\"-1:emptyLast\"], 1, Sort.ASC));\n    assert.isFalse(Sort.containsOnly([\"1:emptyLast\"], 1, Sort.DESC));\n  });\n\n  it(\"should support swapping\", function() {\n    assert.deepEqual(Sort.swapColRef(1, 2), 2);\n    assert.deepEqual(Sort.swapColRef(-1, 2), -2);\n    assert.deepEqual(Sort.swapColRef(\"1\", 2), 2);\n    assert.deepEqual(Sort.swapColRef(\"-1\", 2), -2);\n    assert.deepEqual(Sort.swapColRef(\"-1:emptyLast\", 2), \"-2:emptyLast\");\n  });\n\n  it(\"should create column expressions\", function() {\n    assert.deepEqual(Sort.setColDirection(2, Sort.ASC), 2);\n    assert.deepEqual(Sort.setColDirection(-2, Sort.ASC), 2);\n    assert.deepEqual(Sort.setColDirection(-2, Sort.DESC), -2);\n    assert.deepEqual(Sort.setColDirection(\"2\", Sort.ASC), 2);\n    assert.deepEqual(Sort.setColDirection(\"-2\", Sort.ASC), 2);\n    assert.deepEqual(Sort.setColDirection(\"-2:emptyLast\", Sort.ASC), \"2:emptyLast\");\n    assert.deepEqual(Sort.setColDirection(\"2:emptyLast\", Sort.ASC), \"2:emptyLast\");\n\n    assert.deepEqual(Sort.setColDirection(2, Sort.DESC), -2);\n    assert.deepEqual(Sort.setColDirection(-2, Sort.DESC), -2);\n    assert.deepEqual(Sort.setColDirection(\"2\", Sort.DESC), -2);\n    assert.deepEqual(Sort.setColDirection(\"-2\", Sort.DESC), -2);\n    assert.deepEqual(Sort.setColDirection(\"-2:emptyLast\", Sort.DESC), \"-2:emptyLast\");\n    assert.deepEqual(Sort.setColDirection(\"2:emptyLast\", Sort.DESC), \"-2:emptyLast\");\n  });\n\n  it(\"should create column expressions for virtual ids\", function() {\n    assert.deepEqual(Sort.setColDirection(VirtualId(\"test\"), Sort.DESC), `-${VirtualId(\"test\")}`);\n    assert.deepEqual(Sort.setColDirection(VirtualId(\"test\"), Sort.ASC), VirtualId(\"test\"));\n    assert.deepEqual(Sort.setColDirection(`-${VirtualId(\"test\")}`, Sort.ASC), VirtualId(\"test\"));\n    assert.deepEqual(Sort.setColDirection(`-${VirtualId(\"test\")}`, Sort.DESC), `-${VirtualId(\"test\")}`);\n  });\n\n  const empty = { emptyLast: false, orderByChoice: false, naturalSort: false };\n\n  it(\"should parse details\", function() {\n    assert.deepEqual(Sort.specToDetails(2), { colRef: 2, direction: Sort.ASC });\n    assert.deepEqual(Sort.specToDetails(-2), { colRef: 2, direction: Sort.DESC });\n\n    assert.deepEqual(Sort.specToDetails(VirtualId(\"test\")), { colRef: VirtualId(\"test\"), direction: Sort.ASC });\n    assert.deepEqual(Sort.specToDetails(`-${VirtualId(\"test\")}`), { colRef: VirtualId(\"test\"), direction: Sort.DESC });\n\n    assert.deepEqual(Sort.specToDetails(\"-2:emptyLast\"),\n      { ...empty, colRef: 2, direction: Sort.DESC, emptyLast: true });\n    assert.deepEqual(Sort.specToDetails(\"-2:emptyLast;orderByChoice\"), {\n      ...empty,\n      colRef: 2,\n      direction: Sort.DESC,\n      emptyLast: true,\n      orderByChoice: true,\n    });\n\n    assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.ASC }), 2);\n    assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.DESC }), -2);\n\n    assert.deepEqual(Sort.detailsToSpec({ colRef: VirtualId(\"test\"), direction: Sort.ASC }), VirtualId(\"test\"));\n    assert.deepEqual(Sort.detailsToSpec({ colRef: VirtualId(\"test\"), direction: Sort.DESC }), `-${VirtualId(\"test\")}`);\n\n    assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.ASC, emptyLast: true }), \"2:emptyLast\");\n    assert.deepEqual(Sort.detailsToSpec({ colRef: 2, direction: Sort.DESC, emptyLast: true }), \"-2:emptyLast\");\n    assert.deepEqual(\n      Sort.detailsToSpec({ colRef: 1, direction: Sort.DESC, emptyLast: true, orderByChoice: true }),\n      \"-1:emptyLast;orderByChoice\",\n    );\n  });\n\n  it(\"should parse names\", function() {\n    const cols = new Map(Object.entries({ a: 1, id: 0 }));\n    assert.deepEqual(Sort.parseNames([\"1\"], cols), [\"1\"]);\n    assert.deepEqual(Sort.parseNames([\"0\"], cols), [\"0\"]);\n    assert.deepEqual(Sort.parseNames([\"id\"], cols), [\"0\"]);\n    assert.deepEqual(Sort.parseNames([\"-id\"], cols), [\"-0\"]);\n    assert.deepEqual(Sort.parseNames([\"-1\"], cols), [\"-1\"]);\n    assert.deepEqual(Sort.parseNames([\"a\"], cols), [\"1\"]);\n    assert.deepEqual(Sort.parseNames([\"-a\"], cols), [\"-1\"]);\n    assert.deepEqual(Sort.parseNames([\"a:flag\"], cols), [\"1:flag\"]);\n    assert.deepEqual(Sort.parseNames([\"-a:flag\"], cols), [\"-1:flag\"]);\n    assert.deepEqual(Sort.parseNames([\"-a:flag\"], cols), [\"-1:flag\"]);\n    assert.throws(() => Sort.parseNames([\"-a:flag\"], new Map()));\n  });\n\n  it(\"should produce correct results with flipColDirection\", function() {\n    // Should flip given sortRef.\n    // Column direction should not matter\n    assert.deepEqual(flipColDirection([1, 2, 3], 3), [1, 2, -3]);\n    assert.deepEqual(flipColDirection([1, 2, -3], -3), [1, 2, 3]);\n    assert.deepEqual(flipColDirection([1], 1), [-1]);\n    assert.deepEqual(flipColDirection([8, -3, 2, 5, -7, -12, 33], -7), [8, -3, 2, 5, 7, -12, 33]);\n    assert.deepEqual(flipColDirection([5, 4, 9, -2, -3, -6, -1], 4), [5, -4, 9, -2, -3, -6, -1]);\n    assert.deepEqual(flipColDirection([-1, -2, -3], -2), [-1, 2, -3]);\n\n    // Should return original when sortRef not found.\n    assert.deepEqual(flipColDirection([1, 2, 3], 4), [1, 2, 3]);\n    assert.deepEqual(flipColDirection([], 8), []);\n    assert.deepEqual(flipColDirection([1], 4), [1]);\n    assert.deepEqual(flipColDirection([-1], 2), [-1]);\n  });\n\n  it(\"should produce correct results with parseSortColRefs\", function() {\n    // Should parse correctly.\n    assert.deepEqual(parseSortColRefs(\"[1, 2, 3]\"), [1, 2, 3]);\n    assert.deepEqual(parseSortColRefs(\"[]\"), []);\n    assert.deepEqual(parseSortColRefs(\"[4, 12, -3, -2, -1, 18]\"), [4, 12, -3, -2, -1, 18]);\n\n    // Should return empty array on parse failure.\n    assert.deepEqual(parseSortColRefs(\"3]\"), []);\n    assert.deepEqual(parseSortColRefs(\"1, 2, 3\"), []);\n    assert.deepEqual(parseSortColRefs(\"[12; 16; 18]\"), []);\n  });\n\n  it(\"should produce correct results with reorderSortRefs\", function() {\n    // Should reorder correctly.\n    assert.deepEqual(reorderSortRefs([1, 2, 3], 2, 1), [2, 1, 3]);\n    assert.deepEqual(reorderSortRefs([12, 2, -4, -5, 6, 8], -4, 8), [12, 2, -5, 6, -4, 8]);\n    assert.deepEqual(reorderSortRefs([15, 3, -4, 2, 18], 15, -4), [3, 15, -4, 2, 18]);\n    assert.deepEqual(reorderSortRefs([-12, 22, 1, 4], 1, 4), [-12, 22, 1, 4]);\n    assert.deepEqual(reorderSortRefs([1, 2, 3], 2, null), [1, 3, 2]);\n    assert.deepEqual(reorderSortRefs([4, 3, -2, 5, -8, -9], 3, null), [4, -2, 5, -8, -9, 3]);\n    assert.deepEqual(reorderSortRefs([-2, 8, -6, -5, 18], 8, 2), [8, -2, -6, -5, 18]);\n\n    // Should return original array with invalid input.\n    assert.deepEqual(reorderSortRefs([1, 2, 3], 2, 4), [1, 2, 3]);\n    assert.deepEqual(reorderSortRefs([-5, -4, 6], 3, null), [-5, -4, 6]);\n  });\n\n  it(\"should flip columns\", function() {\n    assert.deepEqual(Sort.flipCol(\"1:emptyLast\"), \"-1:emptyLast\");\n    assert.deepEqual(Sort.flipCol(\"-1:emptyLast\"), \"1:emptyLast\");\n    assert.deepEqual(Sort.flipCol(2), -2);\n    assert.deepEqual(Sort.flipCol(-2), 2);\n    assert.deepEqual(Sort.flipSort([-2], 2), [2]);\n    assert.deepEqual(Sort.flipSort([2], 2), [-2]);\n    assert.deepEqual(Sort.flipSort([2, 1], 2), [-2, 1]);\n    assert.deepEqual(Sort.flipSort([-2, -1], 2), [2, -1]);\n    assert.deepEqual(Sort.flipSort([\"-2:emptyLast\", -1], 2), [\"2:emptyLast\", -1]);\n    assert.deepEqual(Sort.flipSort([\"2:emptyLast\", -1], 2), [\"-2:emptyLast\", -1]);\n    assert.deepEqual(Sort.flipSort([\"2:emptyLast\", -1], \"2\"), [\"-2:emptyLast\", -1]);\n    assert.deepEqual(Sort.flipSort([\"2:emptyLast\", -1], \"-2\"), [\"-2:emptyLast\", -1]);\n    assert.deepEqual(Sort.flipSort([\"2:emptyLast\", -1], \"-2:emptyLast\"), [\"-2:emptyLast\", -1]);\n    assert.deepEqual(Sort.flipSort([\"2:emptyLast\", -1], \"2:emptyLast\"), [\"-2:emptyLast\", -1]);\n  });\n});\n"
  },
  {
    "path": "test/client/lib/textUtils.ts",
    "content": "import { stripLinks } from \"app/client/lib/markdown\";\nimport { hashFnv32a, simpleStringHash } from \"app/client/lib/textUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"textUtils\", function() {\n  it(\"hashFnv32a should produce correct hashes\", function() {\n    // Test 32-bit for various strings\n    function check(s: string, expected: number) {\n      assert.equal(hashFnv32a(s), expected.toString(16).padStart(8, \"0\"));\n    }\n\n    // Based on https://github.com/sindresorhus/fnv1a/blob/053a8cb5a0f99212e71acb73a47823f26081b6e9/test.js\n    check((\"\"), 2_166_136_261);\n    check((\"h\"), 3_977_000_791);\n    check((\"he\"), 1_547_363_254);\n    check((\"hel\"), 179_613_742);\n    check((\"hell\"), 477_198_310);\n    check((\"hello\"), 1_335_831_723);\n    check((\"hello \"), 3_801_292_497);\n    check((\"hello w\"), 1_402_552_146);\n    check((\"hello wo\"), 3_611_200_775);\n    check((\"hello wor\"), 1_282_977_583);\n    check((\"hello worl\"), 2_767_971_961);\n    check((\"hello world\"), 3_582_672_807);\n    check(\"Lorem ipsum dolor sit amet, consectetuer adipiscing elit. \" +\n      \"Aenean commodo ligula eget dolor. Aenean massa. \" +\n      \"Cum sociis natoque penatibus et magnis dis parturient montes, \" +\n      \"nascetur ridiculus mus. Donec quam felis, ultricies nec, \" +\n      \"pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. \" +\n      \"Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. \" +\n      \"In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. \" +\n      \"Nullam dictum felis eu pede mollis pretium. \" +\n      \"Lorem ipsum dolor sit amet, consectetuer adipiscing elit. \" +\n      \"Aenean commodo ligula eget dolor. Aenean massa. \" +\n      \"Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. \" +\n      \"Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. \" +\n      \"Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, \" +\n      \"vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. \" +\n      \"Nullam dictum felis eu pede mollis pretium. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. \" +\n      \"Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient \" +\n      \"montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. \" +\n      \"Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. \" +\n      \"In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium.\",\n    2_964_896_417);\n  });\n\n  it(\"simpleStringHash should produce correct hashes\", function() {\n    // Not based on anything, just need to know if it changes\n    assert.equal(simpleStringHash(\"hello\"), \"4f9f2cab3cfabf04ee7da04597168630\");\n  });\n\n  it(\"removes links from markdown text\", function() {\n    // This test checks if the function stripLinks can successfully remove links from markdown text leaving any\n    // other text intact.\n\n    // Test data, markdown text and expected result\n    const testData: [string, string][] = [\n      [\"[link](https://example.com)\", \"link\"],\n      // In bold\n      [\"**[link](https://example.com)**\", \"**link**\"],\n      // Itallic\n      [\"*[link](https://example.com)*\", \"*link*\"],\n      // In bold and itallic\n      [\"***[link](https://example.com)***\", \"***link***\"],\n      // Line breaks\n      [\"[link](https://example.com)\\n[link](https://example.com/page?arg=%20&)\", \"link\\nlink\"],\n      // Line breaks in brakcets\n      [\"[first\\nsecond](https://example.com)\", \"first\\nsecond\"],\n      // Multiple line in brackets\n      [\"[first\\n\\nsecond](https://example.com)\", \"first\\n\\nsecond\"],\n      // Tables with links in headers\n      [`\n| [link](https://example.com) | [link](https://example.com) |\n| --- | --- |\n| [link](https://example.com) | [link](https://example.com) |`.trim(),\n      `\n| link | link |\n| --- | --- |\n| link | link |`.trim()],\n    ];\n\n    testData.forEach(([markdownText, expected]) => assert.equal(\n      stripLinks(markdownText),\n      expected,\n      `failed for ${markdownText}`,\n    ));\n  });\n});\n"
  },
  {
    "path": "test/client/lib/timeUtils.ts",
    "content": "import { getTimeFromNow } from \"app/client/lib/timeUtils\";\n\nimport { assert } from \"chai\";\nimport moment from \"moment\";\n\ndescribe(\"timeUtils\", function() {\n  describe(\"getTimeFromNow\", function() {\n    it(\"should give good summary of time that just passed\", function() {\n      const t = moment().subtract(10, \"s\");\n      assert.equal(getTimeFromNow(t.toISOString()), \"a few seconds ago\");\n    });\n\n    it(\"should gloss over times slightly in future\", function() {\n      const t = moment().add(2, \"s\");\n      assert.equal(getTimeFromNow(t.toISOString()), \"a few seconds ago\");\n    });\n\n    it(\"should not gloss over times further in future\", function() {\n      const t = moment().add(2, \"minutes\");\n      assert.equal(getTimeFromNow(t.toISOString()), \"in 2 minutes\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/lib/urlUtils.ts",
    "content": "import { buildURL, getLoginUrl } from \"app/client/lib/urlUtils\";\n\nimport { assert } from \"chai\";\nimport { popGlobals, pushGlobals } from \"grainjs/dist/cjs/lib/browserGlobals\";\nimport { JSDOM } from \"jsdom\";\n\ndescribe(\"urlUtils\", function() {\n  let originalWindow: any;\n\n  beforeEach(function() {\n    originalWindow = (global as any).window;\n    const jsdomDoc = new JSDOM(\"<!doctype html><html><body></body></html>\");\n    (global as any).window = jsdomDoc.window;\n    pushGlobals(jsdomDoc.window);\n  });\n\n  afterEach(function() {\n    (global as any).window = originalWindow;\n    popGlobals();\n  });\n\n  function setWindowLocation(href: string) {\n    (global as any).window = { location: { href } };\n  }\n\n  describe(\"buildURL\", function() {\n    it(\"returns appropriate urls\", async function() {\n      assert.equal(\n        buildURL(\"/\", {\n          base: \"https://example.com\",\n          searchParams: new URLSearchParams({ foo: \"A\" }),\n          hash: \"bar\",\n        }).href,\n        \"https://example.com/?foo=A#bar\",\n      );\n      assert.equal(\n        buildURL(\"/\", {\n          base: \"https://example.com?foo=A#bar\",\n        }).href,\n        \"https://example.com/?foo=A#bar\",\n      );\n      assert.equal(\n        buildURL(\"/\", {\n          base: \"https://example.com?foo=A#bar\",\n          searchParams: new URLSearchParams({ foo: \"B\" }),\n          hash: \"baz\",\n        }).href,\n        \"https://example.com/?foo=B#baz\",\n      );\n      assert.equal(\n        buildURL(\"/bar\", {\n          base: \"https://foo.example.com\",\n        }).href,\n        \"https://foo.example.com/bar\",\n      );\n      assert.equal(\n        buildURL(\"/bar\", {\n          base: \"https://example.com/foo\",\n        }).href,\n        \"https://example.com/bar\",\n      );\n      assert.equal(\n        buildURL(\"/bar\", {\n          base: \"https://example.com/o/foo\",\n        }).href,\n        \"https://example.com/o/foo/bar\",\n      );\n\n      setWindowLocation(\"http://localhost:8080/\");\n      assert.equal(\n        buildURL(\"/\", {\n          searchParams: new URLSearchParams({ foo: \"A\" }),\n          hash: \"bar\",\n        }).href,\n        \"http://localhost:8080/?foo=A#bar\",\n      );\n    });\n  });\n\n  describe(\"getLoginUrl\", function() {\n    it(\"returns appropriate login urls\", function() {\n      setWindowLocation(\"http://localhost:8080\");\n      assert.equal(getLoginUrl(), \"http://localhost:8080/login?next=%2F\");\n      setWindowLocation(\"https://docs.getgrist.com/\");\n      assert.equal(getLoginUrl(), \"https://docs.getgrist.com/login?next=%2F\");\n      setWindowLocation(\"https://foo.getgrist.com?foo=1&bar=2#baz\");\n      assert.equal(\n        getLoginUrl(),\n        \"https://foo.getgrist.com/login?next=%2F%3Ffoo%3D1%26bar%3D2%23baz\",\n      );\n      setWindowLocation(\"https://example.com\");\n      assert.equal(getLoginUrl(), \"https://example.com/login?next=%2F\");\n    });\n\n    it(\"encodes redirect url in next param\", function() {\n      setWindowLocation(\"http://localhost:8080/o/docs/foo\");\n      assert.equal(\n        getLoginUrl(),\n        \"http://localhost:8080/o/docs/login?next=%2Ffoo\",\n      );\n      setWindowLocation(\"https://docs.getgrist.com/RW25C4HAfG/Test-Document\");\n      assert.equal(\n        getLoginUrl(),\n        \"https://docs.getgrist.com/login?next=%2FRW25C4HAfG%2FTest-Document\",\n      );\n    });\n\n    it(\"includes query params and hashes in next param\", function() {\n      setWindowLocation(\n        \"https://foo.getgrist.com/Y5g3gBaX27D/With-Hash/p/1/#a1.s8.r2.c23\",\n      );\n      assert.equal(\n        getLoginUrl(),\n        \"https://foo.getgrist.com/login?next=%2FY5g3gBaX27D%2FWith-Hash%2Fp%2F1%2F%23a1.s8.r2.c23\",\n      );\n      setWindowLocation(\n        \"https://example.com/rHz46S3F77DF/With-Params?compare=RW25C4HAfG\",\n      );\n      assert.equal(\n        getLoginUrl(),\n        \"https://example.com/login?next=%2FrHz46S3F77DF%2FWith-Params%3Fcompare%3DRW25C4HAfG\",\n      );\n      setWindowLocation(\n        \"https://example.com/rHz46S3F77DF/With-Params?compare=RW25C4HAfG#a1.s8.r2.c23\",\n      );\n      assert.equal(\n        getLoginUrl(),\n        \"https://example.com/login?next=%2FrHz46S3F77DF%2FWith-Params%3Fcompare%3DRW25C4HAfG%23a1.s8.r2.c23\",\n      );\n    });\n\n    it(\"skips encoding redirect url on signed-out page\", function() {\n      setWindowLocation(\"http://localhost:8080/o/docs/signed-out\");\n      assert.equal(\n        getLoginUrl(),\n        \"http://localhost:8080/o/docs/login?next=%2F\",\n      );\n      setWindowLocation(\"https://docs.getgrist.com/signed-out\");\n      assert.equal(getLoginUrl(), \"https://docs.getgrist.com/login?next=%2F\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/models/ColumnFilter.ts",
    "content": "import { ALL_INCLUSIVE_FILTER_JSON, ColumnFilter } from \"app/client/models/ColumnFilter\";\nimport { CellValue } from \"app/common/DocActions\";\nimport { GristObjCode } from \"app/plugin/GristData\";\n\nimport { assert } from \"chai\";\n\nconst L = GristObjCode.List;\n\ndescribe(\"ColumnFilter\", function() {\n  it(\"should properly initialize from JSON spec\", async function() {\n    let filter = new ColumnFilter('{ \"excluded\": [\"Alice\", \"Bob\"] }');\n\n    assert.isFalse(filter.includes(\"Alice\"));\n    assert.isFalse(filter.includes(\"Bob\"));\n    assert.isTrue(filter.includes(\"Carol\"));\n\n    filter = new ColumnFilter('{ \"included\": [\"Alice\", \"Bob\"] }');\n\n    assert.isTrue(filter.includes(\"Alice\"));\n    assert.isTrue(filter.includes(\"Bob\"));\n    assert.isFalse(filter.includes(\"Carol\"));\n\n    filter = new ColumnFilter(\"\");\n    assert.isTrue(filter.includes(\"Alice\"));\n    assert.isTrue(filter.includes(\"Bob\"));\n    assert.isTrue(filter.includes(\"Carol\"));\n  });\n\n  it(\"should allow adding and removing values to existing filter\", async function() {\n    let filter = new ColumnFilter('{ \"excluded\": [\"Alice\", \"Bob\"] }');\n\n    assert.isFalse(filter.includes(\"Alice\"));\n    assert.isFalse(filter.includes(\"Bob\"));\n    assert.isTrue(filter.includes(\"Carol\"));\n\n    filter.add(\"Alice\");\n    filter.add(\"Carol\");\n\n    assert.isTrue(filter.includes(\"Alice\"));\n    assert.isFalse(filter.includes(\"Bob\"));\n    assert.isTrue(filter.includes(\"Carol\"));\n\n    filter.delete(\"Carol\");\n\n    assert.isTrue(filter.includes(\"Alice\"));\n    assert.isFalse(filter.includes(\"Bob\"));\n    assert.isFalse(filter.includes(\"Carol\"));\n\n    filter = new ColumnFilter('{ \"included\": [\"Alice\", \"Bob\"] }');\n    assert.isTrue(filter.includes(\"Alice\"));\n    assert.isTrue(filter.includes(\"Bob\"));\n    assert.isFalse(filter.includes(\"Carol\"));\n\n    filter.delete(\"Alice\");\n    filter.add(\"Carol\");\n    assert.isFalse(filter.includes(\"Alice\"));\n    assert.isTrue(filter.includes(\"Bob\"));\n    assert.isTrue(filter.includes(\"Carol\"));\n  });\n\n  it(\"should generate an all-inclusive filter from empty string/object or null\", async function() {\n    const filter = new ColumnFilter(\"\");\n    const defaultJson = filter.makeFilterJson();\n    assert.equal(defaultJson, ALL_INCLUSIVE_FILTER_JSON);\n\n    filter.clear();\n    assert.equal(filter.makeFilterJson(), '{\"included\":[]}');\n\n    filter.selectAll();\n    assert.equal(filter.makeFilterJson(), defaultJson);\n\n    // Check that the string 'null' initializes properly\n    assert.equal(new ColumnFilter(\"null\").makeFilterJson(), ALL_INCLUSIVE_FILTER_JSON);\n\n    // Check that the empty object initializes properly\n    assert.equal(new ColumnFilter(\"{}\").makeFilterJson(), ALL_INCLUSIVE_FILTER_JSON);\n  });\n\n  it(\"should generate a proper FilterFunc and JSON string\", async function() {\n    const data = [\"Carol\", \"Alice\", \"Bar\", \"Bob\", \"Alice\", \"Baz\"];\n    const filterJson = '{\"included\":[\"Alice\",\"Bob\"]}';\n    const filter = new ColumnFilter(filterJson);\n\n    assert.equal(filter.makeFilterJson(), filterJson);\n    assert.deepEqual(data.filter(filter.filterFunc.get()), [\"Alice\", \"Bob\", \"Alice\"]);\n    assert.isFalse(filter.hasChanged()); // `hasChanged` compares to the original JSON used to initialize ColumnFilter\n\n    filter.add(\"Carol\");\n    assert.equal(filter.makeFilterJson(), '{\"included\":[\"Alice\",\"Bob\",\"Carol\"]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), [\"Carol\", \"Alice\", \"Bob\", \"Alice\"]);\n    assert.isTrue(filter.hasChanged());\n\n    filter.delete(\"Alice\");\n    assert.equal(filter.makeFilterJson(), '{\"included\":[\"Bob\",\"Carol\"]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), [\"Carol\", \"Bob\"]);\n    assert.isTrue(filter.hasChanged());\n\n    filter.selectAll();\n    assert.equal(filter.makeFilterJson(), '{\"excluded\":[]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), data);\n    assert.isTrue(filter.hasChanged());\n\n    filter.add(\"Alice\");\n    assert.equal(filter.makeFilterJson(), '{\"excluded\":[]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), data);\n    assert.isTrue(filter.hasChanged());\n\n    filter.delete(\"Alice\");\n    assert.equal(filter.makeFilterJson(), '{\"excluded\":[\"Alice\"]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), [\"Carol\", \"Bar\", \"Bob\", \"Baz\"]);\n    assert.isTrue(filter.hasChanged());\n\n    filter.clear();\n    assert.equal(filter.makeFilterJson(), '{\"included\":[]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), []);\n    assert.isTrue(filter.hasChanged());\n\n    filter.add(\"Alice\");\n    assert.equal(filter.makeFilterJson(), '{\"included\":[\"Alice\"]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), [\"Alice\", \"Alice\"]);\n    assert.isTrue(filter.hasChanged());\n\n    filter.add(\"Bob\");\n    assert.equal(filter.makeFilterJson(), '{\"included\":[\"Alice\",\"Bob\"]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), [\"Alice\", \"Bob\", \"Alice\"]);\n    assert.isFalse(filter.hasChanged()); // We're back to the same state, so `hasChanged()` should be false\n  });\n\n  it(\"should generate a proper FilterFunc for Choice List columns\", async function() {\n    const data: CellValue[] = [[L, \"Alice\", \"Carol\"], [L, \"Alice\", \"Bob\"], [L, \"Bar\"], [L, \"Bob\"], null];\n    const filterJson = '{\"included\":[\"Alice\",\"Bob\"]}';\n    const filter = new ColumnFilter(filterJson, \"ChoiceList\");\n\n    assert.equal(filter.makeFilterJson(), filterJson);\n    assert.deepEqual(data.filter(filter.filterFunc.get()),\n      [[L, \"Alice\", \"Carol\"], [L, \"Alice\", \"Bob\"], [L, \"Bob\"]]);\n    assert.isFalse(filter.hasChanged()); // `hasChanged` compares to the original JSON used to initialize ColumnFilter\n\n    filter.add(\"Bar\");\n    assert.equal(filter.makeFilterJson(), '{\"included\":[\"Alice\",\"Bar\",\"Bob\"]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()),\n      [[L, \"Alice\", \"Carol\"], [L, \"Alice\", \"Bob\"], [L, \"Bar\"], [L, \"Bob\"]]);\n    assert.isTrue(filter.hasChanged());\n\n    filter.delete(\"Alice\");\n    assert.equal(filter.makeFilterJson(), '{\"included\":[\"Bar\",\"Bob\"]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()),\n      [[L, \"Alice\", \"Bob\"], [L, \"Bar\"], [L, \"Bob\"]]);\n    assert.isTrue(filter.hasChanged());\n\n    filter.selectAll();\n    assert.equal(filter.makeFilterJson(), '{\"excluded\":[]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), data);\n    assert.isTrue(filter.hasChanged());\n\n    filter.add(\"Alice\");\n    assert.equal(filter.makeFilterJson(), '{\"excluded\":[]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), data);\n    assert.isTrue(filter.hasChanged());\n\n    filter.delete(\"Alice\");\n    assert.equal(filter.makeFilterJson(), '{\"excluded\":[\"Alice\"]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()),\n      [[L, \"Alice\", \"Carol\"], [L, \"Alice\", \"Bob\"], [L, \"Bar\"], [L, \"Bob\"], null]);\n    assert.isTrue(filter.hasChanged());\n\n    filter.clear();\n    assert.equal(filter.makeFilterJson(), '{\"included\":[]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()), []);\n    assert.isTrue(filter.hasChanged());\n\n    filter.add(\"Alice\");\n    assert.equal(filter.makeFilterJson(), '{\"included\":[\"Alice\"]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()),\n      [[L, \"Alice\", \"Carol\"], [L, \"Alice\", \"Bob\"]]);\n    assert.isTrue(filter.hasChanged());\n\n    filter.add(\"Bob\");\n    assert.equal(filter.makeFilterJson(), '{\"included\":[\"Alice\",\"Bob\"]}');\n    assert.deepEqual(data.filter(filter.filterFunc.get()),\n      [[L, \"Alice\", \"Carol\"], [L, \"Alice\", \"Bob\"], [L, \"Bob\"]]);\n    assert.isFalse(filter.hasChanged()); // We're back to the same state, so `hasChanged()` should be false\n  });\n});\n"
  },
  {
    "path": "test/client/models/TreeModel.ts",
    "content": "import { TableData } from \"app/client/models/TableData\";\nimport { find, fixIndents, fromTableData, TreeItemRecord, TreeNodeRecord } from \"app/client/models/TreeModel\";\nimport { nativeCompare } from \"app/common/gutil\";\n\nimport { assert } from \"chai\";\nimport flatten from \"lodash/flatten\";\nimport noop from \"lodash/noop\";\nimport sinon from \"sinon\";\n\nconst buildDom = noop as any;\n\ninterface TreeRecord { indentation: number; id: number; name: string; pagePos: number; }\n\n// builds a tree model from ['A0', 'B1', ...] where 'A0' reads {id: 'A', indentation: 0}. Spy on\nfunction simpleArray(array: string[]) {\n  return array.map((s: string, id: number) => ({ id, name: s[0], indentation: Number(s[1]), pagePos: id }));\n}\n\nfunction toSimpleArray(records: TreeRecord[]) {\n  return records.map(rec => rec.name + rec.indentation);\n}\n\n// return ['a', ['b']] if item has name 'a' and one children with name 'b'.\nfunction toArray(item: any) {\n  const name = item.storage.records[item.index].name;\n  const children = flatten(item.children().get().map(toArray));\n  return children.length ? [name, children] : [name];\n}\n\nfunction toJson(model: any) {\n  return JSON.stringify(flatten(model.children().get().map(toArray)));\n}\n\nfunction findItems(model: TreeNodeRecord, names: string[]) {\n  return names.map(name => findItem(model, name));\n}\n\nfunction findItem(model: TreeNodeRecord, name: string) {\n  return find(model, (item: TreeItemRecord) => item.storage.records[item.index].name === name)!;\n}\n\nfunction testActions(records: TreeRecord[], actions: { update?: TreeRecord[], remove?: TreeRecord[] }) {\n  const update = actions.update || [];\n  const remove = actions.remove || [];\n  if (remove.length) {\n    const ids = remove.map(rec => rec.id);\n    records = records.filter(rec => !ids.includes(rec.id));\n  }\n  if (update.length) {\n    // In reality, the handling of pagePos is done by the sandbox (see relabeling.py, which is\n    // quite complicated to handle updates of large tables efficiently). Here we simulate it in a\n    // very simple way. The important property is that new pagePos values equal to existing ones\n    // are inserted immediately before the existing ones.\n    const map = new Map(update.map(rec => [rec.id, rec]));\n    const newRecords = update.map(rec => ({ ...rec, pagePos: rec.pagePos ?? Infinity }));\n    newRecords.push(...records.filter(rec => !map.has(rec.id)));\n    newRecords.sort((a, b) => nativeCompare(a.pagePos, b.pagePos));\n    records = newRecords.map((rec, i) => ({ ...rec, pagePos: i }));\n  }\n  return toSimpleArray(records);\n}\n\ndescribe(\"TreeModel\", function() {\n  let table: any;\n  let sendActionsSpy: any;\n  let records: TreeRecord[];\n\n  before(function() {\n    table = sinon.createStubInstance(TableData);\n    table.getRecords.callsFake(() => records);\n    sendActionsSpy = sinon.spy(TreeNodeRecord.prototype, \"sendActions\");\n  });\n\n  after(function() {\n    sendActionsSpy.restore();\n  });\n\n  afterEach(function() {\n    sendActionsSpy.resetHistory();\n  });\n\n  it(\"fixIndent should work correctly\", function() {\n    function fix(items: string[]) {\n      const recs = items.map((item, id) => ({ id, indentation: Number(item[1]), name: item[0], pagePos: id }));\n      return fixIndents(recs).map(rec => rec.name + rec.indentation);\n    }\n\n    assert.deepEqual(fix([\"A0\", \"B2\"]), [\"A0\", \"B1\"]);\n    assert.deepEqual(fix([\"A0\", \"B3\", \"C3\"]), [\"A0\", \"B1\", \"C2\"]);\n    assert.deepEqual(fix([\"A3\", \"B1\"]), [\"A0\", \"B1\"]);\n\n    // should not change when indentation is already correct\n    assert.deepEqual(fix([\"A0\", \"B1\", \"C0\", \"D1\", \"E2\", \"F0\"]), [\"A0\", \"B1\", \"C0\", \"D1\", \"E2\", \"F0\"]);\n  });\n\n  describe(\"fromTableData\", function() {\n    it(\"should build correct model\", function() {\n      records = simpleArray([\"A0\", \"B1\", \"C0\", \"D1\", \"E2\", \"F0\"]);\n      const model = fromTableData(table, buildDom);\n      assert.equal(toJson(model), JSON.stringify([\"A\", [\"B\"], \"C\", [\"D\", [\"E\"]], \"F\"]));\n    });\n\n    it(\"should build correct model even with gaps in indentation\", function() {\n      records = simpleArray([\"A0\", \"B3\", \"C3\"]);\n      const model = fromTableData(table, buildDom);\n      assert.equal(toJson(model), JSON.stringify([\"A\", [\"B\", [\"C\"]]]));\n    });\n\n    it(\"should sort records\", function() {\n      records = simpleArray([\"A0\", \"B1\", \"C0\", \"D1\", \"E2\", \"F0\"]);\n      // let's shuffle records\n      records = [2, 3, 5, 1, 4, 0].map(i => records[i]);\n      // check that it's shuffled\n      assert.deepEqual(toSimpleArray(records), [\"C0\", \"D1\", \"F0\", \"B1\", \"E2\", \"A0\"]);\n      const model = fromTableData(table, buildDom);\n      assert.equal(toJson(model), JSON.stringify([\"A\", [\"B\"], \"C\", [\"D\", [\"E\"]], \"F\"]));\n    });\n\n    it(\"should reuse item from optional oldModel\", function() {\n      // create a model\n      records = simpleArray([\"A0\", \"B1\", \"C0\"]);\n      const oldModel = fromTableData(table, buildDom);\n      assert.deepEqual(oldModel.storage.records.map(r => r.id), [0, 1, 2]);\n      const items = findItems(oldModel, [\"A\", \"B\", \"C\"]);\n\n      // create a new model with overlap in ids\n      records = simpleArray([\"A0\", \"B0\", \"C1\", \"D0\"]);\n      const model = fromTableData(table, buildDom, oldModel);\n      assert.deepEqual(model.storage.records.map(r => r.id), [0, 1, 2, 3]);\n\n      // item with same ids should be the same\n      assert.deepEqual(findItems(model, [\"A\", \"B\", \"C\"]), items);\n\n      // new model is correct\n      assert.equal(toJson(model), JSON.stringify([\"A\", \"B\", [\"C\"], \"D\"]));\n    });\n  });\n\n  describe(\"TreeNodeRecord\", function() {\n    it(\"removeChild(...) should work properly\", async function() {\n      records = simpleArray([\"A0\", \"B1\", \"C0\", \"D1\", \"E2\", \"F0\"]);\n      const model = fromTableData(table, buildDom);\n\n      await model.removeChild(model.children().get()[1]);\n\n      const [C, D, E] = [2, 3, 4].map(i => records[i]);\n      const actions = sendActionsSpy.getCall(0).args[0];\n      assert.deepEqual(actions, { remove: [C, D, E] });\n      assert.deepEqual(testActions(records, actions), [\"A0\", \"B1\", \"F0\"]);\n    });\n\n    describe(\"insertBefore\", function() {\n      it(\"should insert before a child properly\", async function() {\n        records = simpleArray([\"A0\", \"B1\", \"C0\", \"D1\", \"E2\", \"F0\"]);\n        const model = fromTableData(table, buildDom);\n\n        const F = model.children().get()[2];\n        const C = model.children().get()[1];\n        await model.insertBefore(F, C);\n\n        const actions = sendActionsSpy.getCall(0).args[0];\n        assert.deepEqual(actions, { update: [{ ...records[5], pagePos: 2 }] });\n        assert.deepEqual(testActions(records, actions), [\"A0\", \"B1\", \"F0\", \"C0\", \"D1\", \"E2\"]);\n      });\n\n      it(\"should insert as last child correctly\", async function() {\n        records = simpleArray([\"A0\", \"B1\", \"C0\", \"D1\", \"E2\", \"F0\"]);\n        const model = fromTableData(table, buildDom);\n\n        const B = findItem(model, \"B\");\n        await model.insertBefore(B, null);\n\n        let actions = sendActionsSpy.getCall(0).args[0];\n        assert.deepEqual(actions, { update: [{ ...records[1], indentation: 0, pagePos: null }] });\n        assert.deepEqual(testActions(records, actions), [\"A0\", \"C0\", \"D1\", \"E2\", \"F0\", \"B0\"]);\n\n        // handle case when the last child has chidlren\n        const C = model.children().get()[1];\n        await C.insertBefore(B, null);\n\n        actions = sendActionsSpy.getCall(1).args[0];\n        assert.deepEqual(actions, { update: [{ ...records[1], indentation: 1, pagePos: 5 }] });\n        assert.deepEqual(testActions(records, actions), [\"A0\", \"C0\", \"D1\", \"E2\", \"B1\", \"F0\"]);\n      });\n\n      it(\"should insert into a child correctly\", async function() {\n        records = simpleArray([\"A0\", \"B1\", \"C0\", \"D1\", \"E2\", \"F0\"]);\n        const model = fromTableData(table, buildDom);\n\n        const A = model.children().get()[0];\n        const F = model.children().get()[2];\n\n        await A.insertBefore(F, null);\n\n        const actions = sendActionsSpy.getCall(0).args[0];\n        assert.deepEqual(actions, { update: [{ ...records[5], indentation: 1, pagePos: 2 }] });\n        assert.deepEqual(testActions(records, actions), [\"A0\", \"B1\", \"F1\", \"C0\", \"D1\", \"E2\"]);\n      });\n\n      it(\"should insert item with nested children correctly\", async function() {\n        records = simpleArray([\"A0\", \"B1\", \"C0\", \"D1\", \"E2\", \"F0\"]);\n        const model = fromTableData(table, buildDom);\n\n        const D = model.children().get()[1].children().get()[0];\n\n        await model.insertBefore(D, null);\n\n        const actions = sendActionsSpy.getCall(0).args[0];\n        assert.deepEqual(actions, { update: [{ ...records[3], indentation: 0, pagePos: null },\n          { ...records[4], indentation: 1, pagePos: null }] });\n        assert.deepEqual(testActions(records, actions), [\"A0\", \"B1\", \"C0\", \"F0\", \"D0\", \"E1\"]);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/models/gristUrlState.ts",
    "content": "import { HistWindow, UrlState } from \"app/client/lib/UrlState\";\nimport { UrlStateImpl } from \"app/client/models/gristUrlState\";\nimport { IGristUrlState } from \"app/common/gristUrls\";\n\nimport { assert } from \"chai\";\nimport { dom } from \"grainjs\";\nimport { popGlobals, pushGlobals } from \"grainjs/dist/cjs/lib/browserGlobals\";\nimport { JSDOM } from \"jsdom\";\nimport clone from \"lodash/clone\";\nimport merge from \"lodash/merge\";\nimport omit from \"lodash/omit\";\nimport * as sinon from \"sinon\";\n\nfunction assertResetCall(spy: sinon.SinonSpy, ...args: any[]): void {\n  sinon.assert.calledOnce(spy);\n  sinon.assert.calledWithExactly(spy, ...args);\n  spy.resetHistory();\n}\n\ndescribe(\"gristUrlState\", function() {\n  let mockWindow: HistWindow;\n  // TODO add a test case where org is set, but isSingleOrg is false.\n  const prod = new UrlStateImpl({ gristConfig: { org: undefined, baseDomain: \".example.com\", pathOnly: false } });\n  const dev = new UrlStateImpl({ gristConfig: { org: undefined, pathOnly: true } });\n  const single = new UrlStateImpl({ gristConfig: { org: \"mars\", singleOrg: \"mars\", pathOnly: false } });\n  const custom = new UrlStateImpl({ gristConfig: { org: \"mars\", baseDomain: \".example.com\" } });\n\n  function pushState(state: any, title: any, href: string) {\n    mockWindow.location = new URL(href) as unknown as Location;\n  }\n\n  const sandbox = sinon.createSandbox();\n\n  beforeEach(function() {\n    mockWindow = {\n      location: new URL(\"http://localhost:8080\") as unknown as Location,\n      history: { pushState } as History,\n      addEventListener: () => undefined,\n      removeEventListener: () => undefined,\n      dispatchEvent: () => true,\n    };\n    // These grainjs browserGlobals are needed for using dom() in tests.\n    const jsdomDoc = new JSDOM(\"<!doctype html><html><body></body></html>\");\n    pushGlobals(jsdomDoc.window);\n  });\n\n  afterEach(function() {\n    popGlobals();\n    sandbox.restore();\n  });\n\n  it(\"should decode state in URLs correctly\", function() {\n    assert.deepEqual(prod.decodeUrl(new URL(\"http://localhost:8080\")), {});\n    assert.deepEqual(prod.decodeUrl(new URL(\"http://localhost:8080/ws/12\")), { ws: 12 });\n    assert.deepEqual(prod.decodeUrl(new URL(\"http://localhost:8080/o/foo/ws/12/\")), { org: \"foo\", ws: 12 });\n    assert.deepEqual(prod.decodeUrl(new URL(\"http://localhost:8080/o/foo/doc/bar/p/5\")),\n      { org: \"foo\", doc: \"bar\", docPage: 5 });\n\n    assert.deepEqual(dev.decodeUrl(new URL(\"http://localhost:8080\")), {});\n    assert.deepEqual(dev.decodeUrl(new URL(\"http://localhost:8080/ws/12\")), { ws: 12 });\n    assert.deepEqual(dev.decodeUrl(new URL(\"http://localhost:8080/o/foo/ws/12/\")), { org: \"foo\", ws: 12 });\n    assert.deepEqual(dev.decodeUrl(new URL(\"http://localhost:8080/o/foo/doc/bar/p/5\")),\n      { org: \"foo\", doc: \"bar\", docPage: 5 });\n\n    assert.deepEqual(single.decodeUrl(new URL(\"http://localhost:8080\")), { org: \"mars\" });\n    assert.deepEqual(single.decodeUrl(new URL(\"http://localhost:8080/ws/12\")), { org: \"mars\", ws: 12 });\n    assert.deepEqual(single.decodeUrl(new URL(\"http://localhost:8080/o/foo/ws/12/\")), { org: \"foo\", ws: 12 });\n    assert.deepEqual(single.decodeUrl(new URL(\"http://localhost:8080/o/foo/doc/bar/p/5\")),\n      { org: \"foo\", doc: \"bar\", docPage: 5 });\n\n    assert.deepEqual(prod.decodeUrl(new URL(\"https://bar.example.com\")), { org: \"bar\" });\n    assert.deepEqual(prod.decodeUrl(new URL(\"https://bar.example.com/ws/12/\")), { org: \"bar\", ws: 12 });\n    assert.deepEqual(prod.decodeUrl(new URL(\"https://foo.example.com/o/baz/ws/12\")), { org: \"baz\", ws: 12 });\n    assert.deepEqual(prod.decodeUrl(new URL(\"https://foo.example.com/\")), { org: \"foo\" });\n\n    assert.deepEqual(dev.decodeUrl(new URL(\"https://bar.example.com\")), {});\n    assert.deepEqual(dev.decodeUrl(new URL(\"https://bar.example.com/ws/12/\")), { ws: 12 });\n    assert.deepEqual(dev.decodeUrl(new URL(\"https://foo.example.com/o/baz/ws/12\")), { org: \"baz\", ws: 12 });\n    assert.deepEqual(dev.decodeUrl(new URL(\"https://foo.example.com/\")), {});\n\n    assert.deepEqual(single.decodeUrl(new URL(\"https://bar.example.com\")), { org: \"mars\" });\n    assert.deepEqual(single.decodeUrl(new URL(\"https://bar.example.com/ws/12/\")), { org: \"mars\", ws: 12 });\n    assert.deepEqual(single.decodeUrl(new URL(\"https://foo.example.com/o/baz/ws/12\")), { org: \"baz\", ws: 12 });\n    assert.deepEqual(single.decodeUrl(new URL(\"https://foo.example.com/\")), { org: \"mars\" });\n\n    // Trash page\n    assert.deepEqual(prod.decodeUrl(new URL(\"https://bar.example.com/p/trash\")), { org: \"bar\", homePage: \"trash\" });\n\n    // Billing routes\n    assert.deepEqual(prod.decodeUrl(new URL(\"https://bar.example.com/o/baz/billing\")),\n      { org: \"baz\", billing: \"billing\" });\n\n    // API routes\n    assert.deepEqual(prod.decodeUrl(new URL(\"https://bar.example.com/api/docs/bar\")),\n      { org: \"bar\", doc: \"bar\", api: true });\n    assert.deepEqual(prod.decodeUrl(new URL(\"http://localhost:8080/o/baz/api/docs/bar\")),\n      { org: \"baz\", doc: \"bar\", api: true });\n  });\n\n  it(\"should decode query strings in URLs correctly\", function() {\n    assert.deepEqual(prod.decodeUrl(new URL(\"https://bar.example.com?billingPlan=a\")),\n      { org: \"bar\", params: { billingPlan: \"a\" } });\n    assert.deepEqual(prod.decodeUrl(new URL(\"https://foo.example.com/o/baz/ws/12?billingPlan=b\")),\n      { org: \"baz\", ws: 12, params: { billingPlan: \"b\" } });\n    assert.deepEqual(prod.decodeUrl(new URL(\"https://bar.example.com/o/foo/doc/bar/p/5?billingPlan=e\")),\n      { org: \"foo\", doc: \"bar\", docPage: 5, params: { billingPlan: \"e\" } });\n  });\n\n  it(\"should encode state in URLs correctly\", function() {\n    const localBase = new URL(\"http://localhost:8080\");\n    const hostBase = new URL(\"https://bar.example.com\");\n\n    assert.equal(prod.encodeUrl({}, hostBase), \"https://bar.example.com/\");\n    assert.equal(prod.encodeUrl({ org: \"foo\" }, hostBase), \"https://foo.example.com/\");\n    assert.equal(prod.encodeUrl({ ws: 12 }, hostBase), \"https://bar.example.com/ws/12/\");\n    assert.equal(prod.encodeUrl({ org: \"foo\", ws: 12 }, hostBase), \"https://foo.example.com/ws/12/\");\n\n    assert.equal(dev.encodeUrl({ ws: 12 }, hostBase), \"https://bar.example.com/ws/12/\");\n    assert.equal(dev.encodeUrl({ org: \"foo\", ws: 12 }, hostBase), \"https://bar.example.com/o/foo/ws/12/\");\n\n    assert.equal(single.encodeUrl({ ws: 12 }, hostBase), \"https://bar.example.com/ws/12/\");\n    assert.equal(single.encodeUrl({ org: \"foo\", ws: 12 }, hostBase), \"https://bar.example.com/o/foo/ws/12/\");\n\n    assert.equal(prod.encodeUrl({ ws: 12 }, localBase), \"http://localhost:8080/ws/12/\");\n    assert.equal(prod.encodeUrl({ org: \"foo\", ws: 12 }, localBase), \"http://localhost:8080/o/foo/ws/12/\");\n    assert.equal(prod.encodeUrl({ org: \"foo\", doc: \"bar\" }, localBase), \"http://localhost:8080/o/foo/doc/bar\");\n    assert.equal(prod.encodeUrl({ org: \"foo\", doc: \"bar\", docPage: 2 }, localBase),\n      \"http://localhost:8080/o/foo/doc/bar/p/2\");\n\n    assert.equal(dev.encodeUrl({ ws: 12 }, localBase), \"http://localhost:8080/ws/12/\");\n    assert.equal(dev.encodeUrl({ org: \"foo\", ws: 12 }, localBase), \"http://localhost:8080/o/foo/ws/12/\");\n    assert.equal(dev.encodeUrl({ org: \"foo\", doc: \"bar\" }, localBase), \"http://localhost:8080/o/foo/doc/bar\");\n\n    assert.equal(single.encodeUrl({ ws: 12 }, localBase), \"http://localhost:8080/ws/12/\");\n    assert.equal(single.encodeUrl({ org: \"foo\", ws: 12 }, localBase), \"http://localhost:8080/o/foo/ws/12/\");\n    assert.equal(single.encodeUrl({ org: \"foo\", doc: \"bar\" }, localBase), \"http://localhost:8080/o/foo/doc/bar\");\n\n    // homePage values, including the \"Trash\" page\n    assert.equal(prod.encodeUrl({ homePage: \"trash\" }, localBase), \"http://localhost:8080/p/trash\");\n    assert.equal(prod.encodeUrl({ homePage: \"all\" }, localBase), \"http://localhost:8080/\");\n    assert.equal(prod.encodeUrl({ homePage: \"workspace\", ws: 12 }, localBase), \"http://localhost:8080/ws/12/\");\n\n    // Billing routes\n    assert.equal(prod.encodeUrl({ org: \"baz\", billing: \"billing\" }, hostBase),\n      \"https://baz.example.com/billing\");\n\n    // API routes\n    assert.equal(prod.encodeUrl({ org: \"baz\", doc: \"bar\", api: true }, hostBase), \"https://baz.example.com/api/docs/bar\");\n    assert.equal(prod.encodeUrl({ org: \"baz\", doc: \"bar\", api: true }, localBase),\n      \"http://localhost:8080/o/baz/api/docs/bar\");\n  });\n\n  it(\"should encode state in billing URLs correctly\", function() {\n    const hostBase = new URL(\"https://bar.example.com\");\n\n    assert.equal(prod.encodeUrl({ params: { billingPlan: \"a\" } }, hostBase),\n      \"https://bar.example.com/?billingPlan=a\");\n    assert.equal(prod.encodeUrl({ ws: 12, params: { billingPlan: \"b\" } }, hostBase),\n      \"https://bar.example.com/ws/12/?billingPlan=b\");\n    assert.equal(prod.encodeUrl({ org: \"foo\", doc: \"bar\", docPage: 5, params: { billingPlan: \"e\" } }, hostBase),\n      \"https://foo.example.com/doc/bar/p/5?billingPlan=e\");\n  });\n\n  describe(\"custom-domain\", function() {\n    it(\"should encode state in URLs correctly\", function() {\n      const localBase = new URL(\"http://localhost:8080\");\n      const hostBase = new URL(\"https://www.martian.com\");\n\n      assert.equal(custom.encodeUrl({}, hostBase), \"https://www.martian.com/\");\n      assert.equal(custom.encodeUrl({ org: \"foo\" }, hostBase), \"https://foo.example.com/\");\n      assert.equal(custom.encodeUrl({ ws: 12 }, hostBase), \"https://www.martian.com/ws/12/\");\n      assert.equal(custom.encodeUrl({ org: \"foo\", ws: 12 }, hostBase), \"https://foo.example.com/ws/12/\");\n\n      assert.equal(custom.encodeUrl({ ws: 12 }, localBase), \"http://localhost:8080/ws/12/\");\n      assert.equal(custom.encodeUrl({ org: \"foo\", ws: 12 }, localBase), \"http://localhost:8080/o/foo/ws/12/\");\n      assert.equal(custom.encodeUrl({ org: \"foo\", doc: \"bar\" }, localBase), \"http://localhost:8080/o/foo/doc/bar\");\n      assert.equal(custom.encodeUrl({ org: \"foo\", doc: \"bar\", docPage: 2 }, localBase),\n        \"http://localhost:8080/o/foo/doc/bar/p/2\");\n\n      assert.equal(custom.encodeUrl({ org: \"baz\", billing: \"billing\" }, hostBase),\n        \"https://baz.example.com/billing\");\n    });\n\n    it(\"should encode state in billing URLs correctly\", function() {\n      const hostBase = new URL(\"https://www.martian.com\");\n\n      assert.equal(custom.encodeUrl({ params: { billingPlan: \"a\" } }, hostBase),\n        \"https://www.martian.com/?billingPlan=a\");\n      assert.equal(custom.encodeUrl({ ws: 12, params: { billingPlan: \"b\" } }, hostBase),\n        \"https://www.martian.com/ws/12/?billingPlan=b\");\n      assert.equal(custom.encodeUrl({ org: \"foo\", doc: \"bar\", docPage: 5, params: { billingPlan: \"e\" } }, hostBase),\n        \"https://foo.example.com/doc/bar/p/5?billingPlan=e\");\n    });\n  });\n\n  it(\"should produce correct results with prod config\", async function() {\n    mockWindow.location = new URL(\"https://bar.example.com/ws/10/\") as unknown as Location;\n    const state = UrlState.create(null, mockWindow, prod);\n    const loadPageSpy = sandbox.spy(mockWindow, \"_urlStateLoadPage\");\n    assert.deepEqual(state.state.get(), { org: \"bar\", ws: 10 });\n\n    const link = dom(\"a\", state.setLinkUrl({ ws: 4 }));\n    assert.equal(link.getAttribute(\"href\"), \"https://bar.example.com/ws/4/\");\n\n    assert.equal(state.makeUrl({ ws: 4 }), \"https://bar.example.com/ws/4/\");\n    assert.equal(state.makeUrl({ ws: undefined }), \"https://bar.example.com/\");\n    assert.equal(state.makeUrl({ org: \"mars\" }), \"https://mars.example.com/\");\n    assert.equal(state.makeUrl({ org: \"mars\", doc: \"DOC\", docPage: 5 }), \"https://mars.example.com/doc/DOC/p/5\");\n\n    // If we change workspace, that stays on the same page, so no call to loadPageSpy.\n    await state.pushUrl({ ws: 17 });\n    sinon.assert.notCalled(loadPageSpy);\n    assert.equal(mockWindow.location.href, \"https://bar.example.com/ws/17/\");\n    assert.deepEqual(state.state.get(), { org: \"bar\", ws: 17 });\n    assert.equal(link.getAttribute(\"href\"), \"https://bar.example.com/ws/4/\");\n\n    // Loading a doc loads a new page, for now. TODO: this is expected to change ASAP, in which\n    // case loadPageSpy should essentially never get called.\n    // To simulate the loadState() on the new page, we call loadState() manually here.\n    await state.pushUrl({ doc: \"baz\" });\n    assertResetCall(loadPageSpy, \"https://bar.example.com/doc/baz\");\n    state.loadState();\n\n    assert.equal(mockWindow.location.href, \"https://bar.example.com/doc/baz\");\n    assert.deepEqual(state.state.get(), { org: \"bar\", doc: \"baz\" });\n    assert.equal(link.getAttribute(\"href\"), \"https://bar.example.com/ws/4/\");\n\n    await state.pushUrl({ org: \"foo\", ws: 12 });\n    assertResetCall(loadPageSpy, \"https://foo.example.com/ws/12/\");\n    state.loadState();\n\n    assert.equal(mockWindow.location.href, \"https://foo.example.com/ws/12/\");\n    assert.deepEqual(state.state.get(), { org: \"foo\", ws: 12 });\n    assert.equal(state.makeUrl({ ws: 4 }), \"https://foo.example.com/ws/4/\");\n\n    // Check form URLs in prod setup. They are produced on document pages.\n    await state.pushUrl({ org: \"foo\", doc: \"abc\" });\n    state.loadState();\n    assert.equal(\n      state.makeUrl({ doc: undefined, form: { vsId: 4, shareKey: \"key\" } }),\n      \"https://foo.example.com/forms/key/4\",\n    );\n    assert.equal(\n      state.makeUrl({ doc: \"abc\", form: { vsId: 4 } }),\n      \"https://foo.example.com/doc/abc/f/4\",\n    );\n    assert.equal(\n      state.makeUrl({ doc: \"abc\", slug: \"123\", form: { vsId: 4 } }),\n      \"https://foo.example.com/abc/123/f/4\",\n    );\n  });\n\n  it(\"should produce correct results with single-org config\", async function() {\n    mockWindow.location = new URL(\"https://example.com/ws/10/\") as unknown as Location;\n    const state = UrlState.create(null, mockWindow, single);\n    const loadPageSpy = sandbox.spy(mockWindow, \"_urlStateLoadPage\");\n    assert.deepEqual(state.state.get(), { org: \"mars\", ws: 10 });\n\n    const link = dom(\"a\", state.setLinkUrl({ ws: 4 }));\n    assert.equal(link.getAttribute(\"href\"), \"https://example.com/ws/4/\");\n\n    assert.equal(state.makeUrl({ ws: undefined }), \"https://example.com/\");\n    assert.equal(state.makeUrl({ org: \"AB\", doc: \"DOC\", docPage: 5 }), \"https://example.com/o/AB/doc/DOC/p/5\");\n\n    await state.pushUrl({ doc: \"baz\" });\n    assertResetCall(loadPageSpy, \"https://example.com/doc/baz\");\n    state.loadState();\n\n    assert.equal(mockWindow.location.href, \"https://example.com/doc/baz\");\n    assert.deepEqual(state.state.get(), { org: \"mars\", doc: \"baz\" });\n    assert.equal(link.getAttribute(\"href\"), \"https://example.com/ws/4/\");\n\n    await state.pushUrl({ org: \"foo\" });\n    assertResetCall(loadPageSpy, \"https://example.com/o/foo/\");\n    state.loadState();\n\n    assert.equal(mockWindow.location.href, \"https://example.com/o/foo/\");\n    assert.deepEqual(state.state.get(), { org: \"foo\" });\n    assert.equal(link.getAttribute(\"href\"), \"https://example.com/o/foo/ws/4/\");\n\n    // Check form URLs in single org setup from document pages.\n    await state.pushUrl({ org: \"foo\", doc: \"abc\" });\n    state.loadState();\n    assert.equal(\n      state.makeUrl({ doc: undefined, form: { vsId: 4, shareKey: \"key\" } }),\n      \"https://example.com/o/foo/forms/key/4\",\n    );\n    assert.equal(\n      state.makeUrl({ doc: \"abc\", form: { vsId: 4 } }),\n      \"https://example.com/o/foo/doc/abc/f/4\",\n    );\n    assert.equal(\n      state.makeUrl({ doc: \"abc\", slug: \"123\", form: { vsId: 4 } }),\n      \"https://example.com/o/foo/abc/123/f/4\",\n    );\n  });\n\n  it(\"should produce correct results with custom config\", async function() {\n    mockWindow.location = new URL(\"https://example.com/ws/10/\") as unknown as Location;\n    const state = UrlState.create(null, mockWindow, custom);\n    const loadPageSpy = sandbox.spy(mockWindow, \"_urlStateLoadPage\");\n    assert.deepEqual(state.state.get(), { org: \"mars\", ws: 10 });\n\n    const link = dom(\"a\", state.setLinkUrl({ ws: 4 }));\n    assert.equal(link.getAttribute(\"href\"), \"https://example.com/ws/4/\");\n\n    assert.equal(state.makeUrl({ ws: undefined }), \"https://example.com/\");\n    assert.equal(state.makeUrl({ org: \"ab-cd\", doc: \"DOC\", docPage: 5 }), \"https://ab-cd.example.com/doc/DOC/p/5\");\n\n    await state.pushUrl({ doc: \"baz\" });\n    assertResetCall(loadPageSpy, \"https://example.com/doc/baz\");\n    state.loadState();\n\n    assert.equal(mockWindow.location.href, \"https://example.com/doc/baz\");\n    assert.deepEqual(state.state.get(), { org: \"mars\", doc: \"baz\" });\n    assert.equal(link.getAttribute(\"href\"), \"https://example.com/ws/4/\");\n\n    await state.pushUrl({ org: \"foo\" });\n    assertResetCall(loadPageSpy, \"https://foo.example.com/\");\n    state.loadState();\n    assert.equal(mockWindow.location.href, \"https://foo.example.com/\");\n    // This test assumes gristConfig doesn't depend on the request, which is no longer the case,\n    // so some behavior isn't tested here, and this whole suite is a poor reflection of reality.\n  });\n\n  it(\"should support an update function to pushUrl and makeUrl\", async function() {\n    mockWindow.location = new URL(\"https://bar.example.com/doc/DOC/p/5\") as unknown as Location;\n    const state = UrlState.create(null, mockWindow, prod) as UrlState<IGristUrlState>;\n    await state.pushUrl({ params: { style: \"singlePage\", linkParameters: { foo: \"A\", bar: \"B\" } } });\n    assert.equal(mockWindow.location.href, \"https://bar.example.com/doc/DOC/p/5?style=singlePage&foo_=A&bar_=B\");\n    state.loadState();  // changing linkParameters requires a page reload\n    assert.equal(state.makeUrl(prevState => merge({}, prevState, { params: { style: \"full\" } })),\n      \"https://bar.example.com/doc/DOC/p/5?style=full&foo_=A&bar_=B\");\n    assert.equal(state.makeUrl((prevState) => { const s = clone(prevState); delete s.params?.style; return s; }),\n      \"https://bar.example.com/doc/DOC/p/5?foo_=A&bar_=B\");\n    assert.equal(state.makeUrl(prevState =>\n      merge(omit(prevState, \"params.style\", \"params.linkParameters.foo\"),\n        { params: { linkParameters: { baz: \"C\" } } })),\n    \"https://bar.example.com/doc/DOC/p/5?bar_=B&baz_=C\");\n    assert.equal(state.makeUrl(prevState =>\n      merge(omit(prevState, \"params.style\"), { docPage: 44, params: { linkParameters: { foo: \"X\" } } })),\n    \"https://bar.example.com/doc/DOC/p/44?foo_=X&bar_=B\");\n    await state.pushUrl(prevState => omit(prevState, \"params\"));\n    assert.equal(mockWindow.location.href, \"https://bar.example.com/doc/DOC/p/5\");\n  });\n});\n"
  },
  {
    "path": "test/client/models/modelUtil.js",
    "content": "var assert = require(\"assert\");\nvar ko = require(\"knockout\");\n\nvar modelUtil = require(\"app/client/models/modelUtil\");\nvar sinon = require(\"sinon\");\n\ndescribe(\"modelUtil\", function() {\n\n  describe(\"fieldWithDefault\", function() {\n    it(\"should be an observable with a default\", function() {\n      var foo = modelUtil.createField(\"foo\");\n      var bar = modelUtil.fieldWithDefault(foo, \"defaultValue\");\n      assert.equal(bar(), \"defaultValue\");\n      foo(\"test\");\n      assert.equal(bar(), \"test\");\n      bar(\"hello\");\n      assert.equal(bar(), \"hello\");\n      assert.equal(foo(), \"hello\");\n      foo(\"\");\n      assert.equal(bar(), \"defaultValue\");\n      assert.equal(foo(), \"\");\n    });\n    it(\"should exhibit specific behavior when used as a jsonObservable\", function() {\n      var custom = modelUtil.createField(\"custom\");\n      var common = ko.observable('{\"foo\": 2, \"bar\": 3}');\n      var combined = modelUtil.fieldWithDefault(custom, function() { return common(); });\n      combined = modelUtil.jsonObservable(combined);\n      assert.deepEqual(combined(), {\"foo\": 2, \"bar\": 3});\n\n      // Once the custom object is defined, the common object is not read.\n      combined({\"foo\": 20});\n      assert.deepEqual(combined(), {\"foo\": 20});\n      // Setting the custom object to be undefined should make read return the common object again.\n      combined(undefined);\n      assert.deepEqual(combined(), {\"foo\": 2, \"bar\": 3});\n      // Setting a property with an undefined custom object should initially copy all defaults from common.\n      combined(undefined);\n      combined.prop(\"foo\")(50);\n      assert.deepEqual(combined(), {\"foo\": 50, \"bar\": 3});\n      // Once the custom object is defined, changes to common should not affect the combined read value.\n      common('{\"bar\": 60}');\n      combined.prop(\"foo\")(70);\n      assert.deepEqual(combined(), {\"foo\": 70, \"bar\": 3});\n    });\n  });\n\n  describe(\"jsonObservable\", function() {\n    it(\"should auto parse and stringify\", function() {\n      var str = ko.observable();\n      var obj = modelUtil.jsonObservable(str);\n      assert.deepEqual(obj(), {});\n\n      str('{\"foo\": 1, \"bar\": \"baz\"}');\n      assert.deepEqual(obj(), {foo: 1, bar: \"baz\"});\n\n      obj({foo: 2, baz: \"bar\"});\n      assert.equal(str(), '{\"foo\":2,\"baz\":\"bar\"}');\n\n      obj.update({foo: 17, bar: null});\n      assert.equal(str(), '{\"foo\":17,\"baz\":\"bar\",\"bar\":null}');\n    });\n\n    it(\"should support saving\", function() {\n      var str = ko.observable('{\"foo\": 1, \"bar\": \"baz\"}');\n      var saved = null;\n      str.saveOnly = function(value) { saved = value; };\n      var obj = modelUtil.jsonObservable(str);\n\n      obj.saveOnly({foo: 2});\n      assert.equal(saved, '{\"foo\":2}');\n      assert.equal(str(), '{\"foo\": 1, \"bar\": \"baz\"}');\n      assert.deepEqual(obj(), {\"foo\": 1, \"bar\": \"baz\"});\n\n      obj.update({\"hello\": \"world\"});\n      obj.save();\n      assert.equal(saved, '{\"foo\":1,\"bar\":\"baz\",\"hello\":\"world\"}');\n      assert.equal(str(), '{\"foo\":1,\"bar\":\"baz\",\"hello\":\"world\"}');\n      assert.deepEqual(obj(), {\"foo\":1, \"bar\":\"baz\", \"hello\":\"world\"});\n\n      obj.setAndSave({\"hello\": \"world\"});\n      assert.equal(saved, '{\"hello\":\"world\"}');\n      assert.equal(str(), '{\"hello\":\"world\"}');\n      assert.deepEqual(obj(), {\"hello\":\"world\"});\n    });\n\n    it(\"should support property observables\", function() {\n      var str = ko.observable('{\"foo\": 1, \"bar\": \"baz\"}');\n      var saved = null;\n      str.saveOnly = function(value) { saved = value; };\n      var obj = modelUtil.jsonObservable(str);\n\n      var foo = obj.prop(\"foo\"), hello = obj.prop(\"hello\");\n      assert.equal(foo(), 1);\n      assert.equal(hello(), undefined);\n\n      obj.update({\"foo\": 17});\n      assert.equal(foo(), 17);\n      assert.equal(hello(), undefined);\n\n      foo(18);\n      assert.equal(str(), '{\"foo\":18,\"bar\":\"baz\"}');\n      hello(\"world\");\n      assert.equal(saved, null);\n      assert.equal(str(), '{\"foo\":18,\"bar\":\"baz\",\"hello\":\"world\"}');\n      assert.deepEqual(obj(), {\"foo\":18, \"bar\":\"baz\", \"hello\":\"world\"});\n\n      foo.setAndSave(20);\n      assert.equal(saved, '{\"foo\":20,\"bar\":\"baz\",\"hello\":\"world\"}');\n      assert.equal(str(), '{\"foo\":20,\"bar\":\"baz\",\"hello\":\"world\"}');\n      assert.deepEqual(obj(), {\"foo\":20, \"bar\":\"baz\", \"hello\":\"world\"});\n    });\n  });\n\n  describe(\"objObservable\", function() {\n    it(\"should support property observables\", function() {\n      var objObs = ko.observable({\"foo\": 1, \"bar\": \"baz\"});\n      var obj = modelUtil.objObservable(objObs);\n\n      var foo = obj.prop(\"foo\"), hello = obj.prop(\"hello\");\n      assert.equal(foo(), 1);\n      assert.equal(hello(), undefined);\n\n      obj.update({\"foo\": 17});\n      assert.equal(foo(), 17);\n      assert.equal(hello(), undefined);\n\n      foo(18);\n      hello(\"world\");\n      assert.deepEqual(obj(), {\"foo\":18, \"bar\":\"baz\", \"hello\":\"world\"});\n    });\n  });\n\n\n  it(\"should support customComputed\", function() {\n    var obs = ko.observable(\"hello\");\n    var spy = sinon.spy();\n    var cs = modelUtil.customComputed({\n      read: () => obs(),\n      save: (val) => spy(val)\n    });\n\n    // Check that customComputed auto-updates when the underlying value changes.\n    assert.equal(cs(), \"hello\");\n    assert.equal(cs.isSaved(), true);\n\n    obs(\"world2\");\n    assert.equal(cs(), \"world2\");\n    assert.equal(cs.isSaved(), true);\n\n    // Check that it can be set to something else, and will stop auto-updating.\n    cs(\"foo\");\n    assert.equal(cs(), \"foo\");\n    assert.equal(cs.isSaved(), false);\n    obs(\"world\");\n    assert.equal(cs(), \"foo\");\n    assert.equal(cs.isSaved(), false);\n\n    // Check that revert works.\n    cs.revert();\n    assert.equal(cs(), \"world\");\n    assert.equal(cs.isSaved(), true);\n\n    // Check that setting to the underlying value is same as revert.\n    cs(\"foo\");\n    assert.equal(cs.isSaved(), false);\n    cs(\"world\");\n    assert.equal(cs.isSaved(), true);\n\n    // Check that save calls the save function.\n    cs(\"foo\");\n    assert.equal(cs(), \"foo\");\n    assert.equal(cs.isSaved(), false);\n    return cs.save()\n      .then(() => {\n        sinon.assert.calledOnce(spy);\n        sinon.assert.calledWithExactly(spy, \"foo\");\n        // Once saved, the observable should revert.\n        assert.equal(cs(), \"world\");\n        assert.equal(cs.isSaved(), true);\n        spy.resetHistory();\n\n        // Check that saveOnly works similarly to save().\n        return cs.saveOnly(\"foo2\");\n      })\n      .then(() => {\n        sinon.assert.calledOnce(spy);\n        sinon.assert.calledWithExactly(spy, \"foo2\");\n        assert.equal(cs(), \"world\");\n        assert.equal(cs.isSaved(), true);\n        spy.resetHistory();\n\n        // Check that saving the underlying value does NOT call save().\n        return cs.saveOnly(\"world\");\n      })\n      .then(() => {\n        sinon.assert.notCalled(spy);\n        assert.equal(cs(), \"world\");\n        assert.equal(cs.isSaved(), true);\n        spy.resetHistory();\n\n        return cs.saveOnly(\"bar\");\n      })\n      .then(() => {\n        assert.equal(cs(), \"world\");\n        assert.equal(cs.isSaved(), true);\n        sinon.assert.calledOnce(spy);\n        sinon.assert.calledWithExactly(spy, \"bar\");\n        // If save() updated the underlying value, the customComputed should see it.\n        obs(\"bar\");\n        assert.equal(cs(), \"bar\");\n        assert.equal(cs.isSaved(), true);\n      });\n  });\n});\n"
  },
  {
    "path": "test/client/models/rowset.js",
    "content": "var _ = require(\"underscore\");\nvar assert = require(\"chai\").assert;\nvar sinon = require(\"sinon\");\nvar rowset = require(\"app/client/models/rowset\");\n\ndescribe(\"rowset\", function() {\n  describe(\"RowListener\", function() {\n    it(\"should translate events to callbacks\", function() {\n      var src = rowset.RowSource.create(null);\n      src.getAllRows = function() { return [1, 2, 3]; };\n\n      var lis = rowset.RowListener.create(null);\n      sinon.spy(lis, \"onAddRows\");\n      sinon.spy(lis, \"onRemoveRows\");\n      sinon.spy(lis, \"onUpdateRows\");\n\n      lis.subscribeTo(src);\n      assert.deepEqual(lis.onAddRows.args, [[[1, 2, 3], src]]);\n      lis.onAddRows.resetHistory();\n\n      src.trigger(\"rowChange\", \"add\", [5, 6]);\n      src.trigger(\"rowChange\", \"remove\", [6, 1]);\n      src.trigger(\"rowChange\", \"update\", [3, 5]);\n      assert.deepEqual(lis.onAddRows.args, [[[5, 6], src]]);\n      assert.deepEqual(lis.onRemoveRows.args, [[[6, 1], src]]);\n      assert.deepEqual(lis.onUpdateRows.args, [[[3, 5], src]]);\n    });\n\n    it(\"should support subscribing to multiple sources\", function() {\n      var src1 = rowset.RowSource.create(null);\n      src1.getAllRows = function() { return [1, 2, 3]; };\n\n      var src2 = rowset.RowSource.create(null);\n      src2.getAllRows = function() { return [\"a\", \"b\", \"c\"]; };\n\n      var lis = rowset.RowListener.create(null);\n      sinon.spy(lis, \"onAddRows\");\n      sinon.spy(lis, \"onRemoveRows\");\n      sinon.spy(lis, \"onUpdateRows\");\n\n      lis.subscribeTo(src1);\n      lis.subscribeTo(src2);\n      assert.deepEqual(lis.onAddRows.args, [[[1, 2, 3], src1], [[\"a\", \"b\", \"c\"], src2]]);\n\n      src1.trigger(\"rowChange\", \"update\", [2, 3]);\n      src2.trigger(\"rowChange\", \"remove\", [\"b\"]);\n      assert.deepEqual(lis.onUpdateRows.args, [[[2, 3], src1]]);\n      assert.deepEqual(lis.onRemoveRows.args, [[[\"b\"], src2]]);\n\n      lis.onAddRows.resetHistory();\n      lis.unsubscribeFrom(src1);\n      src1.trigger(\"rowChange\", \"add\", [4]);\n      src2.trigger(\"rowChange\", \"add\", [\"d\"]);\n      assert.deepEqual(lis.onAddRows.args, [[[\"d\"], src2]]);\n    });\n  });\n\n  describe(\"MappedRowSource\", function() {\n    it(\"should map row identifiers\", function() {\n      var src = rowset.RowSource.create(null);\n      src.getAllRows = function() { return [1, 2, 3]; };\n\n      var mapped = rowset.MappedRowSource.create(null, src, r => \"X\" + r);\n      assert.deepEqual(mapped.getAllRows(), [\"X1\", \"X2\", \"X3\"]);\n\n      var changeSpy = sinon.spy(), notifySpy = sinon.spy();\n      mapped.on(\"rowChange\", changeSpy);\n      mapped.on(\"rowNotify\", notifySpy);\n      src.trigger(\"rowChange\", \"add\", [4, 5, 6]);\n      src.trigger(\"rowNotify\", [2, 3, 4], \"hello\");\n      src.trigger(\"rowNotify\", rowset.ALL, \"world\");\n      src.trigger(\"rowChange\", \"remove\", [1, 5]);\n      src.trigger(\"rowChange\", \"update\", [4, 2]);\n      assert.deepEqual(changeSpy.args[0], [\"add\", [\"X4\", \"X5\", \"X6\"]]);\n      assert.deepEqual(changeSpy.args[1], [\"remove\", [\"X1\", \"X5\"]]);\n      assert.deepEqual(changeSpy.args[2], [\"update\", [\"X4\", \"X2\"]]);\n      assert.deepEqual(changeSpy.callCount, 3);\n      assert.deepEqual(notifySpy.args[0], [[\"X2\", \"X3\", \"X4\"], \"hello\"]);\n      assert.deepEqual(notifySpy.args[1], [rowset.ALL, \"world\"]);\n      assert.deepEqual(notifySpy.callCount, 2);\n    });\n  });\n\n  function suiteFilteredRowSource(FilteredRowSourceClass) {\n    it(\"should only forward matching rows\", function() {\n      var src = rowset.RowSource.create(null);\n      src.getAllRows = function() { return [1, 2, 3]; };\n\n      // Filter for only rows that are even numbers.\n      var filtered = FilteredRowSourceClass.create(null, function(r) { return r % 2 === 0; });\n      filtered.subscribeTo(src);\n      assert.deepEqual(Array.from(filtered.getAllRows()), [2]);\n\n      var spy = sinon.spy(), notifySpy = sinon.spy();\n      filtered.on(\"rowChange\", spy);\n      filtered.on(\"rowNotify\", notifySpy);\n      src.trigger(\"rowChange\", \"add\", [4, 5, 6]);\n      src.trigger(\"rowChange\", \"add\", [7]);\n      src.trigger(\"rowNotify\", [2, 3, 4], \"hello\");\n      src.trigger(\"rowNotify\", rowset.ALL, \"world\");\n      src.trigger(\"rowChange\", \"remove\", [1, 5]);\n      src.trigger(\"rowChange\", \"remove\", [2, 3, 6]);\n      assert.deepEqual(spy.args[0], [\"add\", [4, 6]]);\n      // Nothing for the middle 'add' and 'remove'.\n      assert.deepEqual(spy.args[1], [\"remove\", [2, 6]]);\n      assert.equal(spy.callCount, 2);\n\n      assert.deepEqual(notifySpy.args[0], [[2, 4], \"hello\"]);\n      assert.deepEqual(notifySpy.args[1], [rowset.ALL, \"world\"]);\n      assert.equal(notifySpy.callCount, 2);\n\n      assert.deepEqual(Array.from(filtered.getAllRows()), [4]);\n    });\n\n    it(\"should translate updates to adds or removes if needed\", function() {\n      var src = rowset.RowSource.create(null);\n      src.getAllRows = function() { return [1, 2, 3]; };\n      var includeSet = new Set([2, 3, 6]);\n\n      // Filter for only rows that are in includeMap.\n      var filtered = FilteredRowSourceClass.create(null, function(r) { return includeSet.has(r); });\n      filtered.subscribeTo(src);\n      assert.deepEqual(Array.from(filtered.getAllRows()), [2, 3]);\n\n      var spy = sinon.spy();\n      filtered.on(\"rowChange\", spy);\n\n      src.trigger(\"rowChange\", \"add\", [4, 5]);\n      assert.equal(spy.callCount, 0);\n\n      includeSet.add(4);\n      includeSet.delete(2);\n      src.trigger(\"rowChange\", \"update\", [3, 2, 4, 5]);\n      assert.equal(spy.callCount, 3);\n      assert.deepEqual(spy.args[0], [\"remove\", [2]]);\n      assert.deepEqual(spy.args[1], [\"update\", [3]]);\n      assert.deepEqual(spy.args[2], [\"add\", [4]]);\n\n      spy.resetHistory();\n      src.trigger(\"rowChange\", \"update\", [1]);\n      assert.equal(spy.callCount, 0);\n    });\n  }\n\n  describe(\"BaseFilteredRowSource\",  () => {\n    suiteFilteredRowSource(rowset.BaseFilteredRowSource);\n  });\n\n  describe(\"FilteredRowSource\",  () => {\n    suiteFilteredRowSource(rowset.FilteredRowSource);\n\n    // One extra test case for FilteredRowSource.\n    it(\"should support changing the filter function\", function() {\n      var src = rowset.RowSource.create(null);\n      src.getAllRows = function() { return [1, 2, 3, 4, 5]; };\n      var includeSet = new Set([2, 3, 6]);\n\n      // Filter for only rows that are in includeMap.\n      var filtered = rowset.FilteredRowSource.create(null, function(r) { return includeSet.has(r); });\n      filtered.subscribeTo(src);\n      assert.deepEqual(Array.from(filtered.getAllRows()), [2, 3]);\n\n      var spy = sinon.spy();\n      filtered.on(\"rowChange\", spy);\n      includeSet.add(4);\n      includeSet.delete(2);\n      filtered.updateFilter(function(r) { return includeSet.has(r); });\n      assert.equal(spy.callCount, 2);\n      assert.deepEqual(spy.args[0], [\"remove\", [2]]);\n      assert.deepEqual(spy.args[1], [\"add\", [4]]);\n      assert.deepEqual(Array.from(filtered.getAllRows()), [3, 4]);\n\n      spy.resetHistory();\n      includeSet.add(5);\n      includeSet.add(17);\n      includeSet.delete(3);\n      filtered.refilterRows([2, 4, 5, 17]);\n      // 3 is still in because we didn't ask to refilter it. 17 is still out because it's not in\n      // any original source.\n      assert.deepEqual(Array.from(filtered.getAllRows()), [3, 4, 5]);\n      assert.equal(spy.callCount, 1);\n      assert.deepEqual(spy.args[0], [\"add\", [5]]);\n    });\n  });\n\n  describe(\"RowGrouping\", function() {\n    it(\"should add/remove/notify rows in the correct group\", function() {\n      var src = rowset.RowSource.create(null);\n      src.getAllRows = function() { return [\"a\", \"b\", \"c\"]; };\n      var groups = {a: 1, b: 2, c: 2, d: 1, e: 3, f: 3};\n\n      var grouping = rowset.RowGrouping.create(null, function(r) { return groups[r]; });\n      grouping.subscribeTo(src);\n\n      var group1 = grouping.getGroup(1), group2 = grouping.getGroup(2);\n      assert.deepEqual(Array.from(group1.getAllRows()), [\"a\"]);\n      assert.deepEqual(Array.from(group2.getAllRows()), [\"b\", \"c\"]);\n\n      var lis1 = sinon.spy(), lis2 = sinon.spy(), nlis1 = sinon.spy(), nlis2 = sinon.spy();\n      group1.on(\"rowChange\", lis1);\n      group2.on(\"rowChange\", lis2);\n      group1.on(\"rowNotify\", nlis1);\n      group2.on(\"rowNotify\", nlis2);\n\n      src.trigger(\"rowChange\", \"add\", [\"d\", \"e\", \"f\"]);\n      assert.deepEqual(lis1.args, [[\"add\", [\"d\"]]]);\n      assert.deepEqual(lis2.args, []);\n\n      src.trigger(\"rowNotify\", [\"a\", \"e\"], \"foo\");\n      src.trigger(\"rowNotify\", rowset.ALL, \"bar\");\n      assert.deepEqual(nlis1.args, [[[\"a\"], \"foo\"], [rowset.ALL, \"bar\"]]);\n      assert.deepEqual(nlis2.args, [[rowset.ALL, \"bar\"]]);\n\n      lis1.resetHistory();\n      lis2.resetHistory();\n      src.trigger(\"rowChange\", \"remove\", [\"a\", \"b\", \"d\", \"e\"]);\n      assert.deepEqual(lis1.args, [[\"remove\", [\"a\", \"d\"]]]);\n      assert.deepEqual(lis2.args, [[\"remove\", [\"b\"]]]);\n\n      assert.deepEqual(Array.from(group1.getAllRows()), []);\n      assert.deepEqual(Array.from(group2.getAllRows()), [\"c\"]);\n      assert.deepEqual(Array.from(grouping.getGroup(3).getAllRows()), [\"f\"]);\n    });\n\n    it(\"should translate updates to adds or removes if needed\", function() {\n      var src = rowset.RowSource.create(null);\n      src.getAllRows = function() { return [\"a\", \"b\", \"c\", \"d\", \"e\"]; };\n      var groups = {a: 1, b: 2, c: 2, d: 1, e: 3, f: 3};\n\n      var grouping = rowset.RowGrouping.create(null, function(r) { return groups[r]; });\n      var group1 = grouping.getGroup(1), group2 = grouping.getGroup(2);\n      grouping.subscribeTo(src);\n      assert.deepEqual(Array.from(group1.getAllRows()), [\"a\", \"d\"]);\n      assert.deepEqual(Array.from(group2.getAllRows()), [\"b\", \"c\"]);\n\n      var lis1 = sinon.spy(), lis2 = sinon.spy();\n      group1.on(\"rowChange\", lis1);\n      group2.on(\"rowChange\", lis2);\n      _.extend(groups, {a: 2, b: 3, e: 1});\n      src.trigger(\"rowChange\", \"update\", [\"a\", \"b\", \"d\", \"e\"]);\n      assert.deepEqual(lis1.args, [[\"remove\", [\"a\"]], [\"update\", [\"d\"]], [\"add\", [\"e\"]]]);\n      assert.deepEqual(lis2.args, [[\"remove\", [\"b\"]], [\"add\", [\"a\"]]]);\n\n      lis1.resetHistory();\n      lis2.resetHistory();\n      src.trigger(\"rowChange\", \"update\", [\"a\", \"b\", \"d\", \"e\"]);\n      assert.deepEqual(lis1.args, [[\"update\", [\"d\", \"e\"]]]);\n      assert.deepEqual(lis2.args, [[\"update\", [\"a\"]]]);\n    });\n  });\n\n  describe(\"SortedRowSet\", function() {\n    var src, order, sortedSet, sortedArray;\n    beforeEach(function() {\n      src = rowset.RowSource.create(null);\n      src.getAllRows = function() { return [\"a\", \"b\", \"c\", \"d\", \"e\"]; };\n      order = {a: 4, b: 0, c: 1, d: 2, e: 3};\n      sortedSet = rowset.SortedRowSet.create(null, function(a, b) { return order[a] - order[b]; });\n      sortedArray = sortedSet.getKoArray();\n    });\n\n    it(\"should sort on first subscribe\", function() {\n      assert.deepEqual(sortedArray.peek(), []);\n      sortedSet.subscribeTo(src);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"c\", \"d\", \"e\", \"a\"]);\n    });\n\n    it(\"should maintain sort on adds and removes\", function() {\n      sortedSet.subscribeTo(src);\n\n      var lis = sinon.spy();\n      sortedArray.subscribe(lis, null, \"spliceChange\");\n      _.extend(order, {p: 2.5, q: 3.5});\n\n      // Small changes (currently < 2 elements) trigger individual splice events.\n      src.trigger(\"rowChange\", \"add\", [\"p\", \"q\"]);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"c\", \"d\", \"p\", \"e\", \"q\", \"a\"]);\n      assert.equal(lis.callCount, 2);\n      assert.equal(lis.args[0][0].added, 1);\n      assert.equal(lis.args[1][0].added, 1);\n\n      lis.resetHistory();\n      src.trigger(\"rowChange\", \"remove\", [\"a\", \"c\"]);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"d\", \"p\", \"e\", \"q\"]);\n      assert.equal(lis.callCount, 2);\n      assert.deepEqual(lis.args[0][0].deleted, [\"a\"]);\n      assert.deepEqual(lis.args[1][0].deleted, [\"c\"]);\n\n      // Bigger changes trigger full array reassignment.\n      lis.resetHistory();\n      src.trigger(\"rowChange\", \"remove\", [\"d\", \"e\", \"q\"]);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"p\"]);\n      assert.equal(lis.callCount, 1);\n\n      lis.resetHistory();\n      src.trigger(\"rowChange\", \"add\", [\"a\", \"c\", \"d\", \"e\", \"q\"]);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"c\", \"d\", \"p\", \"e\", \"q\", \"a\"]);\n      assert.equal(lis.callCount, 1);\n    });\n\n    it(\"should maintain sort on updates\", function() {\n      var lis = sinon.spy();\n      sortedArray.subscribe(lis, null, \"spliceChange\");\n      sortedSet.subscribeTo(src);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"c\", \"d\", \"e\", \"a\"]);\n      assert.equal(lis.callCount, 1);\n      assert.equal(lis.args[0][0].added, 5);\n\n      // Small changes (currently < 2 elements) trigger individual splice events.\n      lis.resetHistory();\n      _.extend(order, {\"b\": 1.5, \"a\": 2.5});\n      src.trigger(\"rowChange\", \"update\", [\"b\", \"a\"]);\n      assert.deepEqual(sortedArray.peek(), [\"c\", \"b\", \"d\", \"a\", \"e\"]);\n      assert.equal(lis.callCount, 4);\n      assert.deepEqual(lis.args[0][0].deleted, [\"b\"]);\n      assert.deepEqual(lis.args[1][0].deleted, [\"a\"]);\n      assert.deepEqual(lis.args[2][0].added, 1);\n      assert.deepEqual(lis.args[3][0].added, 1);\n\n      // Bigger changes trigger full array reassignment.\n      lis.resetHistory();\n      _.extend(order, {\"b\": 0, \"a\": 5, \"c\": 6});\n      src.trigger(\"rowChange\", \"update\", [\"c\", \"b\", \"a\"]);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"d\", \"e\", \"a\", \"c\"]);\n      assert.equal(lis.callCount, 1);\n      assert.deepEqual(lis.args[0][0].added, 5);\n    });\n\n    it(\"should not splice on irrelevant changes\", function() {\n      var lis = sinon.spy();\n      sortedArray.subscribe(lis, null, \"spliceChange\");\n      sortedSet.subscribeTo(src);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"c\", \"d\", \"e\", \"a\"]);\n\n      // Changes that don't affect the order do not cause splices.\n      lis.resetHistory();\n      src.trigger(\"rowChange\", \"update\", [\"d\"]);\n      src.trigger(\"rowChange\", \"update\", [\"a\", \"b\", \"c\"]);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"c\", \"d\", \"e\", \"a\"]);\n      assert.equal(lis.callCount, 0);\n    });\n\n    it(\"should pass on rowNotify events\", function() {\n      var lis = sinon.spy(), spy = sinon.spy();\n      sortedSet.subscribeTo(src);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"c\", \"d\", \"e\", \"a\"]);\n\n      sortedArray.subscribe(lis, null, \"spliceChange\");\n      sortedSet.on(\"rowNotify\", spy);\n\n      src.trigger(\"rowNotify\", [\"b\", \"e\"], \"hello\");\n      src.trigger(\"rowNotify\", rowset.ALL, \"world\");\n      assert.equal(lis.callCount, 0);\n      assert.deepEqual(spy.args, [[[\"b\", \"e\"], \"hello\"], [rowset.ALL, \"world\"]]);\n    });\n\n    it(\"should allow changing compareFunc\", function() {\n      sortedSet.subscribeTo(src);\n      assert.deepEqual(sortedArray.peek(), [\"b\", \"c\", \"d\", \"e\", \"a\"]);\n\n      var lis = sinon.spy();\n      sortedArray.subscribe(lis, null, \"spliceChange\");\n\n      // Replace the compare function with its negation.\n      sortedSet.updateSort(function(a, b) { return order[b] - order[a]; });\n      assert.equal(lis.callCount, 1);\n      assert.deepEqual(lis.args[0][0].added, 5);\n      assert.deepEqual(sortedArray.peek(), [\"a\", \"e\", \"d\", \"c\", \"b\"]);\n    });\n\n    it(\"should defer sorting while paused\", function() {\n      var sortCalled = false;\n      assert.deepEqual(sortedArray.peek(), []);\n      sortedSet.updateSort(function(a, b) { sortCalled = true; return order[a] - order[b]; });\n      sortCalled = false;\n\n      var lis = sinon.spy();\n      sortedArray.subscribe(lis, null, \"spliceChange\");\n\n      // Check that our little setup catching sort calls works; then reset.\n      sortedSet.subscribeTo(src);\n      assert.equal(sortCalled, true);\n      assert.equal(lis.callCount, 1);\n      sortedSet.unsubscribeFrom(src);\n      sortCalled = false;\n      lis.resetHistory();\n\n      // Now pause, do a bunch of operations, and check that sort has not been called.\n      function checkNoEffect() {\n        assert.equal(sortCalled, false);\n        assert.equal(lis.callCount, 0);\n      }\n      sortedSet.pause(true);\n\n      // Note that the initial order is [\"b\", \"c\", \"d\", \"e\", \"a\"]\n      sortedSet.subscribeTo(src);\n      checkNoEffect();\n\n      _.extend(order, {p: 2.5, q: 3.5});\n      src.trigger(\"rowChange\", \"add\", [\"p\", \"q\"]);\n      checkNoEffect();  // But we should now expect b,c,d,p,e,q,a\n\n      src.trigger(\"rowChange\", \"remove\", [\"q\", \"c\"]);\n      checkNoEffect();  // But we should now expect b,d,p,e,a\n\n      _.extend(order, {\"b\": 2.7, \"a\": 1});\n      src.trigger(\"rowChange\", \"update\", [\"b\", \"a\"]);\n      checkNoEffect();  // But we should now expect a,d,p,b,e\n\n      sortedSet.updateSort(function(a, b) { sortCalled = true; return order[b] - order[a]; });\n      checkNoEffect();  // We should expect a reversal: e,b,p,d,a\n\n      // rowNotify events should still be passed through.\n      var spy = sinon.spy();\n      sortedSet.on(\"rowNotify\", spy);\n      src.trigger(\"rowNotify\", [\"p\", \"e\"], \"hello\");\n      assert.deepEqual(spy.args[0], [[\"p\", \"e\"], \"hello\"]);\n\n      checkNoEffect();\n\n      // Now unpause, check that things get updated, and that the result is correct.\n      sortedSet.pause(false);\n      assert.equal(sortCalled, true);\n      assert.equal(lis.callCount, 1);\n      assert.deepEqual(sortedArray.peek(), [\"e\", \"b\", \"p\", \"d\", \"a\"]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/models/rowuid.js",
    "content": "var assert = require(\"chai\").assert;\nvar rowuid = require(\"app/client/models/rowuid\");\n\ndescribe(\"rowuid\", function() {\n  it(\"should combine and split tableRefs with rowId\", function() {\n    function verify(tableRef, rowId) {\n      var u = rowuid.combine(tableRef, rowId);\n      assert.equal(rowuid.tableRef(u), tableRef);\n      assert.equal(rowuid.rowId(u), rowId);\n      assert.equal(rowuid.toString(u), tableRef + \":\" + rowId);\n    }\n\n    // Simple case.\n    verify(4, 17);\n\n    // With 0 for one or both of the parts.\n    verify(0, 17);\n    verify(1, 0);\n    verify(0, 0);\n\n    // Test with values close to the upper limits\n    verify(rowuid.MAX_TABLES - 1, 17);\n    verify(1234, rowuid.MAX_ROWS - 1);\n    verify(rowuid.MAX_TABLES - 1, rowuid.MAX_ROWS - 1);\n  });\n});\n"
  },
  {
    "path": "test/client/shortcuts/excel.js",
    "content": "// This is from http://www.shortcutworld.com/shortcuts.php?l=en&p=win&application=Excel_2010\nexports.shortcuts = function() {\n  return [{\n    group: \"Navigate inside worksheets\",\n    shortcuts: [\n      \"Left,Up,Right,Down:  Move one cell up, down, left, or right in a worksheet.\",\n      \"PageDown,PageUp:  Move one screen down / one screen up in a worksheet.\",\n      \"Alt+PageDown,Alt+PageUp:  Move one screen to the right / to the left in a worksheet.\",\n      \"Tab,Shift+Tab:  Move one cell to the right / to the left in a worksheet.\",\n      \"Ctrl+Left,Ctrl+Up,Ctrl+Right,Ctrl+Down:  Move to the edge of next data region (cells that contains data)\",\n      \"Home:  Move to the beginning of a row in a worksheet.\",\n      \"Ctrl+Home:  Move to the beginning of a worksheet.\",\n      \"Ctrl+End:  Move to the last cell with content on a worksheet.\",\n      \"Ctrl+f:  Display the Find and Replace dialog box (with Find selected).\",\n      \"Ctrl+h:  Display the Find and Replace dialog box (with Replace selected).\",\n      \"Shift+F4:  Repeat last find.\",\n      \"Ctrl+g,F5:  Display the 'Go To' dialog box.\",\n      \"Ctrl+Left,Ctrl+Right:  Inside a cell: Move one word to the left / to the right.\",\n      \"Home,End:  Inside a cell: Move to the beginning / to the end of a cell entry.\",\n      \"Alt+Down:  Display the AutoComplete list e.g. in cell with dropdowns or autofilter.\",\n      \"End:  Turn 'End' mode on. In End mode, press arrow keys to move to the next nonblank cell in the same column or row as the active cell. From here use arrow keys to move by blocks of data, home to move to last cell, or enter to move to the last cell to the right.\",\n    ]\n  }, {\n    group: \"Select cells\",\n    shortcuts: [\n      \"Shift+Space:  Select the entire row.\",\n      \"Ctrl+Space:  Select the entire column.\",\n      \"Ctrl+Shift+*:  (asterisk) Select the current region around the active cell.\",\n      \"Ctrl+a,Ctrl+Shift+Space:  Select the entire worksheet or the data-containing area. Pressing Ctrl+a a second time then selects entire worksheet.\",\n      \"Ctrl+Shift+PageUp:  Select the current and previous sheet in a workbook.\",\n      \"Ctrl+Shift+o:  Select all cells with comments.\",\n      \"Shift+Left,Shift+Up,Shift+Right,Shift+Down:  Extend the selection by one cell.\",\n      \"Ctrl+Shift+Left,Ctrl+Shift+Up,Ctrl+Shift+Right,Ctrl+Shift+Down:  Extend the selection to the last cell with content in row or column.\",\n      \"Shift+PageDown,Shift+PageUp:  Extend the selection down one screen /up one screen.\",\n      \"Shift+Home:  Extend the selection to the beginning of the row.\",\n      \"Ctrl+Shift+Home:  Extend the selection to the beginning of the worksheet.\",\n      \"Ctrl+Shift+End:  Extend the selection to the last used cell on the worksheet (lower-right corner).\",\n    ]\n  }, {\n    group: \"Manage Active Selections\",\n    shortcuts: [\n      \"F8:  Turn on extension of selection with arrow keys without having to keep pressing Shift.\",\n      \"Shift+F8:  Add another (adjacent or non-adjacent) range of cells to the selection. Use arrow keys and Shift+arrow keys to add to selection.\",\n      \"Shift+Backspace:  Select only the active cell when multiple cells are selected.\",\n      \"Ctrl+Backspace:  Show active cell within selection.\",\n      \"Ctrl+.:  (period) Move clockwise to the next corner of the selection.\",\n      \"Enter,Shift+Enter:  Move active cell down / up in a selection.\",\n      \"Tab,Shift+Tab:  Move active cell right / left in a selection.\",\n      \"Ctrl+Alt+Right,Ctrl+Alt+Left:  Move to the right / to the left between non-adjacent selections (with multiple ranges selected).\",\n      \"Esc:  Cancel Selection.\",\n    ]\n  }, {\n    group: \"Select inside cells\",\n    shortcuts: [\n      \"Shift+Left,Shift+Right:  Select or unselect one character to the left / to the right.\",\n      \"Ctrl+Shift+Left,Ctrl+Shift+Right:  Select or unselect one word to the left / to the right.\",\n      \"Shift+Home,Shift+End:  Select from the insertion point to the beginning / to the end of the cell.\",\n    ]\n  }, {\n    group: \"Undo / Redo Shortcuts\",\n    shortcuts: [\n      \"Ctrl+z:  Undo last action (multiple levels).\",\n      \"Ctrl+y:  Redo last action (multiple levels).\",\n    ]\n  }, {\n    group: \"Work with Clipboard\",\n    shortcuts: [\n      \"Ctrl+c:  Copy contents of selected cells.\",\n      \"Ctrl+x:  Cut contents of selected cells.\",\n      \"Ctrl+v:  Paste content from clipboard into selected cell.\",\n      \"Ctrl+Alt+v:  If data exists in clipboard: Display the Paste Special dialog box.\",\n      \"Ctrl+Shift+Plus:  If data exists in clipboard: Display the Insert dialog box to insert blank cells.\",\n    ]\n  }, {\n    group: \"Edit Inside Cells\",\n    shortcuts: [\n      \"F2:  Edit the active cell with cursor at end of the line.\",\n      \"Alt+Enter:  Start a new line in the same cell.\",\n      \"Enter:  Complete a cell entry and move down in the selection. With multiple cells selected: fill cell range with current cell.\",\n      \"Shift+Enter:  Complete a cell entry and move up in the selection.\",\n      \"Tab,Shift+Tab:  Complete a cell entry and move to the right / to the left in the selection.\",\n      \"Esc:  Cancel a cell entry.\",\n      \"Backspace:  Delete the character to the left of the insertion point, or delete the selection.\",\n      \"Del:  Delete the character to the right of the insertion point, or delete the selection.\",\n      \"Ctrl+Del:  Delete text to the end of the line.\",\n      \"Ctrl+;:  (semicolon) Insert current date.\",\n      \"Ctrl+Shift+::  Insert current time.\",\n      \"Ctrl+t:  Show all content as standard numbers. (So 14:15 becomes 14.25 etc for the entire file) To undo press Ctrl + t again\",\n    ]\n  }, {\n    group: \"Edit Active or Selected Cells\",\n    shortcuts: [\n      \"Ctrl+d:  Fill complete cell down (Copy above cell).\",\n      \"Ctrl+r:  Fill complete cell to the right (Copy cell from the left).\",\n      \"Ctrl+\\\":  Fill cell values down and edit (Copy above cell values).\",\n      \"Ctrl+':  (apostrophe) Fill cell formulas down and edit (Copy above cell formulas).\",\n      \"Ctrl+l:  Insert a table (display Create Table dialog box).\",\n      \"Ctrl+-:  Delete Cell/Row/Column Menu, or do the action with row/column selected\",\n      \"Ctrl+Shift+Plus:  Insert Cell/Row/Column Menu, or do the action with row/column selected\",\n      \"Shift+F2:  Insert / Edit a cell comment.\",\n      \"Shift+f10 m:  Delete comment.\",\n      \"Alt+F1:  Create and insert chart with data in current range as embedded Chart Object.\",\n      \"F11:  Create and insert chart with data in current range in a separate Chart sheet.\",\n      \"Ctrl+k:  Insert a hyperlink.\",\n      \"Enter:  (in a cell with a hyperlink) Activate a hyperlink.\",\n    ]\n  }, {\n    group: \"Hide and Show Elements\",\n    shortcuts: [\n      \"Ctrl+9:  Hide the selected rows.\",\n      \"Ctrl+Shift+9:  Unhide any hidden rows within the selection.\",\n      \"Ctrl+0:  Hide the selected columns.\",\n      \"Ctrl+Shift+0:  Unhide any hidden columns within the selection*.\",\n      \"Ctrl+`:  (grave accent)  Alternate between displaying cell values and displaying cell formulas. Accent grave /not a quotation mark.\",\n      \"Alt+Shift+Right:  Group rows or columns.\",\n      \"Alt+Shift+Left:  Ungroup rows or columns.\",\n      \"Ctrl+6:  Alternate between hiding and displaying objects.\",\n      \"Ctrl+8:  Display or hides the outline symbols.\",\n      \"Ctrl+6:  Alternate between hiding objects, displaying objects, and displaying placeholders for objects.\",\n    ]\n  }, {\n    group: \"Adjust Column Width and Row Height\",\n    shortcuts: [\n      \"Alt+o c a:  Adjust Column width to fit content. Select complete column with Ctrl+Space first, otherwise column adjusts to content of current cell). Remember Format, Column Adjust.\",\n      \"Alt+o c w:  Adjust Columns width to specific value: Option, Cow, width\",\n      \"Alt+o r a:  Adjust Row height to fit content: Option, Row, Adjust\",\n      \"Alt+o r e:  Adjust Row height to specific value: Option, Row, Height\",\n    ]\n  }, {\n    group: \"Format Cells\",\n    shortcuts: [\n      \"Ctrl+1:  Format cells dialog.\",\n      \"Ctrl+b, Ctrl+2:  Apply or remove bold formatting.\",\n      \"Ctrl+i, Ctrl+3:  Apply or remove italic formatting.\",\n      \"Ctrl+u, Ctrl+4:  Apply or remove an underline.\",\n      \"Ctrl+5:  Apply or remove strikethrough formatting.\",\n      \"Ctrl+Shift+f:  Display the Format Cells with Fonts Tab active. Press tab 3x to get to font-size. Used to be Ctrl+Shift+p, but that seems just get to the Font Tab in 2010.\",\n      \"Alt+':  (apostrophe / single quote) Display the Style dialog box.\",\n    ]\n  }, {\n    group: \"Number Formats\",\n    shortcuts: [\n      \"Ctrl+Shift+$:  Apply the Currency format with two decimal places.\",\n      \"Ctrl+Shift+~:  Apply the General number format.\",\n      \"Ctrl+Shift+%:  Apply the Percentage format with no decimal places.\",\n      \"Ctrl+Shift+#:  Apply the Date format with the day, month, and year.\",\n      \"Ctrl+Shift+@:  Apply the Time format with the hour and minute, and indicate A.M. or P.M.\",\n      \"Ctrl+Shift+!:  Apply the Number format with two decimal places, thousands separator, and minus sign (-) for negative values.\",\n      \"Ctrl+Shift+^:  Apply the Scientific number format with two decimal places.\",\n      \"F4:  Repeat last formatting action: Apply previously applied Cell Formatting to a different Cell\",\n    ]\n  }, {\n    group: \"Apply Borders to Cells\",\n    shortcuts: [\n      \"Ctrl+Shift+&:  Apply outline border from cell or selection\",\n      \"Ctrl+Shift+_:  (underscore) Remove outline borders from cell or selection\",\n      \"Ctrl+1:  Access border menu in 'Format Cell' dialog. Once border was selected, it will show up directly on the next Ctrl+1\",\n      \"Alt+t:  Set top border\",\n      \"Alt+b:  Set bottom Border\",\n      \"Alt+l:  Set left Border\",\n      \"Alt+r:  Set right Border\",\n      \"Alt+d:  Set diagonal and down border\",\n      \"Alt+u:  Set diagonal and up border\",\n    ]\n  }, {\n    group: \"Align Cells\",\n    shortcuts: [\n      \"Alt+h a r:  Align Right\",\n      \"Alt+h a c:  Align Center\",\n      \"Alt+h a l:  Align Left\",\n    ]\n  }, {\n    group: \"Formulas\",\n    shortcuts: [\n      \"=:   Start a formula.\",\n      \"Alt+=:  Insert the AutoSum formula.\",\n      \"Shift+F3:  Display the Insert Function dialog box.\",\n      \"Ctrl+a:  Display Formula Window after typing formula name.\",\n      \"Ctrl+Shift+a:  Insert Arguments in formula after typing formula name. .\",\n      \"Shift+F3:  Insert a function into a formula .\",\n      \"Ctrl+Shift+Enter:  Enter a formula as an array formula.\",\n      \"F4:  After typing cell reference (e.g. =E3) makes reference absolute (=$E$4)\",\n      \"F9:  Calculate all worksheets in all open workbooks.\",\n      \"Shift+F9:  Calculate the active worksheet.\",\n      \"Ctrl+Alt+F9:  Calculate all worksheets in all open workbooks, regardless of whether they have changed since the last calculation.\",\n      \"Ctrl+Alt+Shift+F9:  Recheck dependent formulas, and then calculates all cells in all open workbooks, including cells not marked as needing to be calculated.\",\n      \"Ctrl+Shift+u:  Toggle expand or collapse formula bar.\",\n      \"Ctrl+`:  Toggle Show formula in cell instead of values\",\n    ]\n  }, {\n    group: \"Names\",\n    shortcuts: [\n      \"Ctrl+F3:  Define a name or dialog.\",\n      \"Ctrl+Shift+F3:  Create names from row and column labels.\",\n      \"F3:  Paste a defined name into a formula.\",\n    ]\n  }, {\n    group: \"Manage Multipe Worksheets\",\n    shortcuts: [\n      \"Shift+F11,Alt+Shift+F1:  Insert a new worksheet in current workbook.\",\n      \"Ctrl+PageDown,Ctrl+PageUp:  Move to the next / previous worksheet in current workbook.\",\n      \"Shift+Ctrl+PageDown,Shift+Ctrl+PageUp:  Select the current and next sheet(s) / select and previous sheet(s).\",\n      \"Alt+o h r:  Rename current worksheet (format, sheet, rename)\",\n      \"Alt+e l:  Delete current worksheet (Edit, delete)\",\n      \"Alt+e m:  Move current worksheet (Edit, move)\",\n    ]\n  }, {\n    group: \"Manage Multiple Workbooks\",\n    shortcuts: [\n      \"F6,Shift+F6:  Move to the next pane / previous pane in a workbook that has been split.\",\n      \"Ctrl+F4:  Close the selected workbook window.\",\n      \"Ctrl+n:  Create a new blank workbook (Excel File)\",\n      \"Ctrl+Tab,Ctrl+Shift+Tab:  Move to next / previous workbook window.\",\n      \"Alt+Space:  Display the Control menu for Main Excel window.\",\n      \"Ctrl+F9:  Minimize current workbook window to an icon. Also restores ('un-maximizes') all workbook windows.\",\n      \"Ctrl+F10:  Maximize or restores the selected workbook window.\",\n      \"Ctrl+F7:  Move Workbook Windows which are not maximized.\",\n      \"Ctrl+F8:  Perform size command for workbook windows which are not maximzed.\",\n      \"Alt+F4:  Close Excel.\",\n    ]\n  }, {\n    group: \"Various Excel Features\",\n    shortcuts: [\n      \"Ctrl+o:  Open File.\",\n      \"Ctrl+s:  Save the active file with its current file name, location, and file format.\",\n      \"F12:  Display the Save As dialog box.\",\n      \"F10, Alt:  Turn key tips on or off.\",\n      \"Ctrl+p:  Print File (Opens print menu).\",\n      \"F1:  Display the Excel Help task pane.\",\n      \"F7:  Display the Spelling dialog box.\",\n      \"Shift+F7:  Display the Thesaurus dialog box.\",\n      \"Alt+F8:  Display the Macro dialog box.\",\n      \"Alt+F11:  Open the Visual Basic Editor to create Macros.\",\n    ]\n  }, {\n    group: \"Work with the Excel Ribbon\",\n    shortcuts: [\n      \"Ctrl+F1:  Minimize or restore the Ribbon.s\",\n      \"Alt,F10:  Select the active tab of the Ribbon and activate the access keys. Press either of these keys again to move back to the document and cancel the access keys. and then arrow left or arrow right\",\n      \"Shift+F10:  Display the shortcut menu for the selected command.\",\n      \"Space,Enter:  Activate the selected command or control in the Ribbon, Open the selected menu or gallery in the Ribbon..\",\n      \"Enter:  Finish modifying a value in a control in the Ribbon, and move focus back to the document.\",\n      \"F1:  Get help on the selected command or control in the Ribbon. (If no Help topic is associated with the selected command, the Help table of contents for that program is shown instead.)\",\n    ]\n  }, {\n    group: \"Data Forms\",\n    shortcuts: [\n      \"Tab,Shift+Tab:  Move to the next / previous field which can be edited.\",\n      \"Enter,Shift+Enter:  Move to the first field in the next / previous record.\",\n      \"PageDown,PageUp:  Move to the same field 10 records forward / back.\",\n      \"Ctrl+PageDown:  Move to a new record.\",\n      \"Ctrl+PageUp:  Move to the first record.\",\n      \"Home,End:  Move to the beginning / end of a field.\",\n    ]\n  }, {\n    group: \"Pivot Tables\",\n    shortcuts: [\n      \"Left,Up,Right,Down:  Navigate inside Pivot tables.\",\n      \"Home,End:  Select the first / last visible item in the list.\",\n      \"Alt+c:  Move the selected field into the Column area.\",\n      \"Alt+d:  Move the selected field into the Data area.\",\n      \"Alt+l:  Display the PivotTable Field dialog box.\",\n      \"Alt+p:  Move the selected field into the Page area.\",\n      \"Alt+r:  Move the selected field into the Row area.\",\n      \"Ctrl+Shift+*:  (asterisk) Select the entire PivotTable report.\",\n      \"Alt+Down:  Display the list for the current field in a PivotTable report.\",\n      \"Alt+Down:  Display the list for the current page field in a PivotChart report.\",\n      \"Enter:  Display the selected item.\",\n      \"Space:  Select or clear a check box in the list.\",\n      \"Ctrl+Tab Ctrl+Shift+Tab:  select the PivotTable toolbar.\",\n      \"Down,Up:  After 'Enter', on a field button: select the area you want to move the selected field to.\",\n      \"Alt+Shift+Right:  Group selected PivotTable items.\",\n      \"Alt+Shift+Left:  Ungroup selected PivotTable items.\",\n    ]\n  }, {\n    group: \"Dialog Boxes\",\n    shortcuts: [\n      \"Left,Up,Right,Down:  Move between options in the active drop-down list box or between some options in a group of options.\",\n      \"Ctrl+Tab,Ctrl+Shift+Tab:  Switch to the next/ previous tab in dialog box.\",\n      \"Space:  In a dialog box: perform the action for the selected button, or select/clear a check box.\",\n      \"Tab,Shift+Tab:  Move to the next / previous option.\",\n      \"a ... z:  Move to an option in a drop-down list box starting with the letter\",\n      \"Alt+a ... Alt+z:  Select an option, or select or clear a check box.\",\n      \"Alt+Down:  Open the selected drop-down list box.\",\n      \"Enter:  Perform the action assigned to the default command button in the dialog box.\",\n      \"Esc:  Cancel the command and close the dialog box.\",\n    ]\n  }, {\n    group: \"Auto Filter\",\n    shortcuts: [\n      \"Alt+Down:  On the field with column head, display the AutoFilter list for the current column .\",\n      \"Down,Up:  Select the next item / previous item in the AutoFilter list.\",\n      \"Alt+Up:  Close the AutoFilter list for the current column.\",\n      \"Home,End:  Select the first item / last item in the AutoFilter list.\",\n      \"Enter:  Filter the list by using the selected item in the AutoFilter list.\",\n      \"Ctrl+Shift+L:  Apply filter on selected column headings.\",\n    ]\n  }, {\n    group: \"Work with Smart Art Graphics\",\n    shortcuts: [\n      \"Left,Up,Right,Down:  Select elements.\",\n      \"Esc:  Remove Focus from Selection.\",\n      \"F2:  Edit Selection Text in if possible (in formula bar).\",\n    ]\n  }];\n};\n"
  },
  {
    "path": "test/client/shortcuts/gsMac.js",
    "content": "// From https://support.google.com/docs/answer/181110?hl=en\nexports.shortcuts = function() {\n  return [{\n    group: \"Common actions\",\n    shortcuts: [\n      \"Ctrl + Space:  Select column \",\n      \"Shift + Space:  Select row\",\n      \"⌘ + A, ⌘ + Shift + Space :  Select all\",\n      \"⌘ + Shift + Backspace:  Hide background over selected cells \",\n      \"⌘ + Z:  Undo\",\n      \"⌘ + Y, ⌘ + Shift + Z, Fn + F4 :  Redo\",\n      \"⌘ + F:  Find\",\n      \"⌘ + Shift + H:  Find and replace\",\n      \"⌘ + Enter:  Fill range\",\n      \"⌘ + D:  Fill down \",\n      \"⌘ + R:  Fill right\",\n      \"⌘ + S:  Save; Every change is saved automatically in Drive\",\n      \"⌘ + O:  Open\",\n      \"⌘ + P:  Print \",\n      \"⌘ + C:  Copy\",\n      \"⌘ + X:  Cut \",\n      \"⌘ + V:  Paste \",\n      \"⌘ + Shift + V:  Paste values only \",\n      \"⌘ + /:  Show common keyboard shortcuts\",\n      \"Ctrl + Shift + F:  Compact controls\",\n      \"⌘ + Shift + K:  Input tools on/off (available in spreadsheets in non-Latin languages)\",\n      \"⌘ + Option + Shift + K:  Select input tools\",\n    ]\n  }, {\n    group: \"Cell formatting\",\n    shortcuts: [\n      \"⌘ + B:  Bold\",\n      \"⌘ + U:  Underline \",\n      \"⌘ + I:  Italic\",\n      \"Option + Shift + 5:  Strikethrough \",\n      \"⌘ + Shift + E:  Center align\",\n      \"⌘ + Shift + L:  Left align\",\n      \"⌘ + Shift + R:  Right align \",\n      \"Option + Shift + 1:  Apply top border\",\n      \"Option + Shift + 2:  Apply right border\",\n      \"Option + Shift + 3:  Apply bottom border \",\n      \"Option + Shift + 4:  Apply left border \",\n      \"Option + Shift + 6:  Remove borders\",\n      \"Option + Shift + 7:  Apply outer border\",\n      \"⌘ + K:  Insert link \",\n      \"⌘ + Shift + ;:  Insert time \",\n      \"⌘ + ;:  Insert date \",\n      \"⌘ + Shift + 1:  Format as decimal \",\n      \"⌘ + Shift + 2:  Format as time\",\n      \"⌘ + Shift + 3:  Format as date\",\n      \"⌘ + Shift + 4:  Format as currency\",\n      \"⌘ + Shift + 5:  Format as percentage\",\n      \"⌘ + Shift + 6:  Format as exponent\",\n      \"⌘ + \\\\:  Clear formatting\",\n    ]\n  }, {\n    group: \"Spreadsheet navigation\",\n    shortcuts: [\n      \"Home, Fn + Left:  Move to beginning of row\",\n      \"⌘ + Home, ⌘ + Fn + Left:  Move to beginning of sheet\",\n      \"End, Fn + Right:  Move to end of row\",\n      \"⌘ + End, ⌘ + Fn + Right:  Move to end of sheet\",\n      \"⌘ + Backspace:  Scroll to active cell \",\n      \"⌘ + Shift + PageDown, ⌘ + Shift + Fn + Down:  Move to next sheet\",\n      \"⌘ + Shift + PageUp, ⌘ + Shift + Fn + Up:  Move to previous sheet\",\n      \"Option + Shift + K:  Display list of sheets\",\n      \"Option + Enter:  Open hyperlink\",\n      \"Ctrl + ⌘ + Shift + M:  Move focus out of spreadsheet \",\n      \"Option + Shift + Q:  Move to quicksum (when a range of cells is selected) \",\n      \"Ctrl+⌘ +E Ctrl+⌘ +P:  Move focus to popup (for links, bookmarks, and images)\",\n      \"Ctrl + ⌘ + R:  Open drop-down menu on filtered cell\",\n      \"⌘ + Option + Shift + G:  Open revision history \",\n      \"Shift + Esc:  Open chat inside the spreadsheet\",\n      \"⌘ + Esc, Shift + Esc:  Close drawing editor\",\n    ]\n  }, {\n    group: \"Notes and comments\",\n    shortcuts: [\n      \"Shift + Fn + F2:  Insert/edit note\",\n      \"⌘ + Option + M:  Insert/edit comment \",\n      \"⌘ + Option + Shift + A:  Open comment discussion thread\",\n      \"Ctrl+⌘ +E Ctrl+⌘ +C:  Enter current comment \",\n      \"Ctrl+⌘ +N Ctrl+⌘ +C:  Move to next comment\",\n      \"Ctrl+⌘ +P Ctrl+⌘ +C:  Move to previous comment\",\n\n    ]\n  }, {\n    group: \"Menus\",\n    shortcuts: [\n      \"Ctrl + Option + F:  File menu \",\n      \"Ctrl + Option + E:  Edit menu \",\n      \"Ctrl + Option + V:  View menu \",\n      \"Ctrl + Option + I:  Insert menu \",\n      \"Ctrl + Option + O:  Format menu \",\n      \"Ctrl + Option + D:  Data menu \",\n      \"Ctrl + Option + T:  Tools menu\",\n      \"Ctrl + Option + M:  Form menu (present when the spreadsheet is connected to a form) \",\n      \"Ctrl + Option + N:  Add-ons menu (present in the new Google Sheets)\",\n      \"Ctrl + Option + H:  Help menu \",\n      \"Ctrl + Option + A:  Accessibility menu (present when screen reader support is enabled) \",\n      \"Option + Shift + S:  Sheet menu(copy, delete, and other sheet actions) \",\n      \"⌘ + Shift + \\\\:  Context menu\",\n\n    ]\n  }, {\n    group: \"Insert or delete rows or columns (via opening menu)\",\n    shortcuts: [\n      \"Ctrl+Option+I R:  Insert row above\",\n      \"Ctrl+Option+I W:  Insert row below\",\n      \"Ctrl+Option+I C:  Insert column to the left \",\n      \"Ctrl+Option+I G:  Insert column to the right\",\n      \"Ctrl+Option+E D:  Delete row\",\n      \"Ctrl+Option+E E:  Delete column \",\n\n    ]\n  }, {\n    group: \"Formulas\",\n    shortcuts: [\n      \"Ctrl + ~:  Show all formulas \",\n      \"⌘ + Shift + Enter:  Insert array formula\",\n      \"⌘ + E:  Collapse an expanded array formula\",\n      \"Shift + Fn + F1:  Show/hide formula help (when entering a formula) \",\n\n    ]\n  }, {\n    group: \"Screen reader support\",\n    shortcuts: [\n      \"⌘ + Option + Z:  Enable screen reader support\",\n      \"⌘ + Option + Shift + C:  Read column \",\n      \"⌘ + Option + Shift + R:  Read row\",\n    ]\n  }];\n};\n"
  },
  {
    "path": "test/client/ui/DocumentSettings.ts",
    "content": "/* global describe, it */\n\nimport { timezoneOptionsImpl } from \"app/client/widgets/TZAutocomplete\";\n\nimport { assert } from \"chai\";\nimport * as momentTimezone from \"moment-timezone\";\n\ndescribe(\"DocumentSettings\", function() {\n  describe(\"timezoneOptionsImpl\", function() {\n    it(\"should return zones in correct order\", function() {\n      // let's test ordering of zones at time the test was written (Tue Jul 18 12:04:56.641 2017)\n      const now = 1500393896641;\n      assert.deepEqual(timezoneOptionsImpl(now, [\n        \"Pacific/Marquesas\",\n        \"US/Aleutian\",\n        \"America/Juneau\",\n        \"America/Anchorage\",\n        \"Antarctica/Mawson\",\n        \"Asia/Calcutta\",\n        \"Asia/Colombo\",\n        \"Africa/Accra\",\n        \"Antarctica/Casey\",\n      ], momentTimezone).map(({ label }) => label), [\n        \"(GMT-09:30) Pacific/Marquesas\",\n        \"(GMT-09:00) US/Aleutian\",\n        \"(GMT-08:00) America/Anchorage\",\n        \"(GMT-08:00) America/Juneau\",\n        \"(GMT+00:00) Africa/Accra\",\n        \"(GMT+05:00) Antarctica/Mawson\",\n        \"(GMT+05:30) Asia/Calcutta\",\n        \"(GMT+05:30) Asia/Colombo\",\n        \"(GMT+11:00) Antarctica/Casey\",\n      ]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/ui/RelativeDatesOptions.ts",
    "content": "import { DEPS, relativeDatesOptions } from \"app/client/ui/RelativeDatesOptions\";\n\nimport { assert } from \"chai\";\nimport moment from \"moment-timezone\";\nimport sinon, { SinonStub } from \"sinon\";\n\nconst valueFormatter = (val: any) => moment(val * 1000).format(\"YYYY-MM-DD\");\nconst toGristDate = (val: moment.Moment) => Math.floor(val.valueOf() / 1000);\n\nfunction getOptions(date: string) {\n  const m = moment(date);\n  const dateUTC = moment.utc([m.year(), m.month(), m.date()]);\n  return relativeDatesOptions(toGristDate(dateUTC), valueFormatter);\n}\n\nfunction checkOption(options: { label: string, spec: any }[], label: string, spec: any) {\n  try {\n    assert.deepInclude(options, { label, spec });\n  } catch (e) {\n    const json = `{\\n  ${options.map(o => JSON.stringify({ label: o.label, spec: o.spec })).join(\"\\n  \")}\\n}`;\n    assert.fail(`expected ${json} to include\\n  ${JSON.stringify({ label, spec })}`);\n  }\n}\n\nfunction optionNotIncluded(options: any[], label: string) {\n  assert.notInclude(options.map(o => o.label), label);\n}\n\ndescribe(\"RelativeDatesOptions\", function() {\n  const sandbox = sinon.createSandbox();\n  let getCurrentTimeSub: SinonStub;\n\n  function setCurrentDate(now: string) {\n    getCurrentTimeSub.returns(moment(now));\n  }\n\n  before(() => {\n    getCurrentTimeSub = sandbox.stub(DEPS, \"getCurrentTime\");\n  });\n\n  after(() => {\n    sandbox.restore();\n  });\n\n  describe(\"relativeDateOptions\", function() {\n    it(\"should limit 'X days ago/from now' to 90 days ago/from now\", function() {\n      setCurrentDate(\"2022-09-26\");\n\n      checkOption(getOptions(\"2022-09-10\"), \"16 days ago\", [{ quantity: -16, unit: \"day\" }]);\n\n      checkOption(getOptions(\"2022-06-28\"), \"90 days ago\", [{ quantity: -90, unit: \"day\" }]);\n\n      // check no options of the form 'X days ago'\n      optionNotIncluded(getOptions(\"2022-06-27\"), \"91 days ago\");\n      assert.notOk(getOptions(\"2022-06-27\").find(o => /^[0-9]+ days ago$/.test(o.label)));\n\n      checkOption(getOptions(\"2022-09-26\"), \"Today\", [{ quantity: 0, unit: \"day\" }]);\n      checkOption(getOptions(\"2022-09-27\"), \"Tomorrow\", [{ quantity: 1, unit: \"day\" }]);\n      checkOption(getOptions(\"2022-10-02\"), \"6 days from now\", [{ quantity: 6, unit: \"day\" }]);\n    });\n\n    it(\"should limit 'WEEKDAY of X weeks ago/from now' to 4 weeks ago/from now\", function() {\n      setCurrentDate(\"2022-09-26\");\n\n      checkOption(getOptions(\"2022-09-20\"), \"Tuesday of last week\", [\n        { quantity: -1, unit: \"week\" }, { quantity: 2, unit: \"day\" }]);\n\n      checkOption(getOptions(\"2022-09-21\"), \"Wednesday of last week\", [\n        { quantity: -1, unit: \"week\" }, { quantity: 3, unit: \"day\" }]);\n\n      checkOption(getOptions(\"2022-08-31\"), \"Wednesday of 4 weeks ago\", [\n        { quantity: -4, unit: \"week\" }, { quantity: 3, unit: \"day\" }]);\n\n      assert.notDeepInclude(getOptions(\"2022-08-24\"), {\n        label: \"Wednesday of 5 weeks ago\",\n        spec: [{ quantity: -5, unit: \"week\" }, { quantity: 3, unit: \"day\" }],\n      });\n      assert.notOk(getOptions(\"2022-08-24\").find(o => o.label.includes(\"Wednesday\")));\n\n      checkOption(getOptions(\"2022-09-29\"), \"Thursday of this week\", [\n        { quantity: 0, unit: \"week\" }, { quantity: 4, unit: \"day\" }]);\n\n      checkOption(getOptions(\"2022-10-13\"), \"Thursday of 2 weeks from now\", [\n        { quantity: 2, unit: \"week\" }, { quantity: 4, unit: \"day\" }]);\n    });\n\n    it(\"should limit 'N day of X month ago/from no' to 3 months ago/from now\", function() {\n      setCurrentDate(\"2022-09-26\");\n\n      checkOption(getOptions(\"2022-09-27\"), \"27th day of this month\", [\n        { quantity: 0, unit: \"month\" }, { quantity: 26, unit: \"day\" }]);\n\n      checkOption(getOptions(\"2022-06-16\"), \"16th day of 3 months ago\", [\n        { quantity: -3, unit: \"month\" }, { quantity: 15, unit: \"day\" }]);\n\n      assert.notOk(getOptions(\"2022-05-16\").find(o => /months? ago/.test(o.label)));\n\n      checkOption(getOptions(\"2022-10-16\"), \"16th day of next month\", [\n        { quantity: 1, unit: \"month\" }, { quantity: 15, unit: \"day\" }]);\n\n      checkOption(getOptions(\"2022-11-16\"), \"16th day of 2 months from now\", [\n        { quantity: 2, unit: \"month\" }, { quantity: 15, unit: \"day\" }]);\n\n      assert.notOk(getOptions(\"2023-01-16\").find(o => /months? from now/.test(o.label)));\n    });\n\n    it(\"should limit '1st day of year' to 1st of Jan\", function() {\n      setCurrentDate(\"2022-09-26\");\n\n      checkOption(getOptions(\"2022-01-01\"), \"1st day of this year\", [\n        { quantity: 0, unit: \"year\" }]);\n\n      checkOption(getOptions(\"2021-01-01\"), \"1st day of last year\", [\n        { quantity: -1, unit: \"year\" }]);\n\n      checkOption(getOptions(\"2024-01-01\"), \"1st day of 2 years from now\", [\n        { quantity: 2, unit: \"year\" }]);\n    });\n\n    it(\"should limit 'Last day of X year ago/from now' to 31st of Dec\", function() {\n      setCurrentDate(\"2022-09-26\");\n\n      checkOption(getOptions(\"2022-12-31\"), \"Last day of this year\", [\n        { quantity: 0, unit: \"year\", endOf: true }]);\n\n      checkOption(getOptions(\"2019-12-31\"), \"Last day of 3 years ago\", [\n        { quantity: -3, unit: \"year\", endOf: true }]);\n\n      checkOption(getOptions(\"2027-12-31\"), \"Last day of 5 years from now\", [\n        { quantity: 5, unit: \"year\", endOf: true }]);\n    });\n\n    it(\"should offer 1st day of any month, limited to 12 months ago/from now\", function() {\n      setCurrentDate(\"2022-09-29\");\n\n      checkOption(getOptions(\"2022-09-01\"), \"1st day of this month\", [\n        { quantity: 0, unit: \"month\" }]);\n\n      checkOption(getOptions(\"2021-09-01\"), \"1st day of 12 months ago\", [\n        { quantity: -12, unit: \"month\" }]);\n\n      assert.notOk(getOptions(\"2021-08-01\").find(o => /1st day of [0-9]+ months? ago/.test(o.label)));\n\n      checkOption(getOptions(\"2022-11-01\"), \"1st day of 2 months from now\", [{\n        quantity: 2, unit: \"month\" }]);\n    });\n\n    it(\"should offer last day of the month, limited to 12 months ago/from now\", function() {\n      setCurrentDate(\"2022-09-29\");\n\n      checkOption(getOptions(\"2022-09-30\"), \"Last day of this month\", [\n        { quantity: 0, unit: \"month\", endOf: true }]);\n\n      checkOption(getOptions(\"2022-08-31\"), \"Last day of last month\", [\n        { quantity: -1, unit: \"month\", endOf: true }]);\n\n      assert.notOk(getOptions(\"2021-08-31\").find(o => /Last day of [0-9]+ months? ago/.test(o.label)));\n\n      checkOption(getOptions(\"2022-12-31\"), \"Last day of 3 months from now\", [\n        { quantity: 3, unit: \"month\", endOf: true }]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/ui/UserImage.ts",
    "content": "import { getInitials } from \"app/client/ui/UserImage\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"AppModel\", function() {\n  describe(\"getInitials\", function() {\n    it(\"should extract initials\", () => {\n      assert.equal(getInitials({ name: \"Foo Bar\" }), \"FB\");\n      assert.equal(getInitials({ name: \" foo  bar cat\" }), \"fb\");\n      assert.equal(getInitials({ name: \" foo-bar cat\" }), \"fc\");\n      assert.equal(getInitials({ name: \"foo-bar\" }), \"f\");\n      assert.equal(getInitials({ name: \"  Something\" }), \"S\");\n      assert.equal(getInitials({ name: \"  Something\", email: \"test@...\" }), \"S\");\n      assert.equal(getInitials({ name: \"\", email: \"test@...\" }), \"t\");\n      assert.equal(getInitials({ name: \" \", email: \"test@...\" }), \"t\");\n      assert.equal(getInitials({ email: \"something@example.com\" }), \"s\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/client/ui2018/cssVars.ts",
    "content": "import { colors, vars } from \"app/client/ui2018/cssVars\";\nimport { CssCustomProp } from \"app/common/CssCustomProp\";\nimport { legacyVarsMapping } from \"app/common/ThemePrefs\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"cssVars\", function() {\n  describe(\"legacy variables\", function() {\n    it(\"should be mapped to theme tokens\", () => {\n      const toCssVarsMappingFormat = (varsObject: Record<string, CssCustomProp>) => {\n        return Object.values(varsObject).reduce<Record<string, string>>((acc, item) => {\n          acc[`--grist-${item.name}`] = item.value instanceof CssCustomProp ? item.value.var() : item.value || \"\";\n          return acc;\n        }, {});\n      };\n\n      const allVars = { ...toCssVarsMappingFormat(colors), ...toCssVarsMappingFormat(vars) };\n\n      const errors: string[] = [];\n      legacyVarsMapping.forEach(({ old, new: newVar }) => {\n        if (!allVars[old]) {\n          errors.push(`${old} is missing, it should be mapped to ${newVar} theme token.`);\n        } else if (allVars[old] !== newVar) {\n          errors.push(`${old} should be mapped to ${newVar} theme token, but is mapped to ${allVars[old]}.`);\n        }\n      });\n\n      // assert only one time to show all errors on failure\n      if (errors.length > 0) {\n        assert.fail(\"Some tokens are not set correctly:\\n\" + errors.join(\"\\n\"));\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "test/client-harness/client.js",
    "content": "/* global window */\nwindow.loadTests = function() {\n  require(\"test/common/BinaryIndexedTree\");\n  require(\"test/common/CircularArray\");\n  require(\"test/common/MemBuffer\");\n  require(\"test/common/arraySplice\");\n  require(\"test/common/gutil\");\n  require(\"test/common/marshal\");\n  require(\"test/common/promises\");\n  require(\"test/common/serializeTiming\");\n  require(\"test/common/timeFormat\");\n  require(\"test/common/ValueFormatter\");\n  require(\"test/common/InactivityTimer\");\n\n  require(\"test/client/clientUtil\");\n  require(\"test/client/components/Layout\");\n  require(\"test/client/components/commands\");\n  require(\"test/client/components/sampleLayout\");\n  require(\"test/client/lib/ObservableMap\");\n  require(\"test/client/lib/ObservableSet\");\n  require(\"test/client/lib/dispose\");\n  require(\"test/client/lib/dom\");\n  require(\"test/client/lib/koArray\");\n  require(\"test/client/lib/koDom\");\n  require(\"test/client/lib/koForm\");\n  require(\"test/client/lib/koUtil\");\n  require(\"test/client/models/modelUtil\");\n  require(\"test/client/models/rowset\");\n  require(\"test/client/lib/localStorageObs\");\n};\n"
  },
  {
    "path": "test/common/ACLPermissions.ts",
    "content": "import { emptyPermissionSet, PartialPermissionSet, PermissionKey,\n  summarizePermissions, summarizePermissionSet } from \"app/common/ACLPermissions\";\nimport { makePartialPermissions, parsePermissions, permissionSetToText } from \"app/common/ACLPermissions\";\nimport { mergePartialPermissions, mergePermissions, trimPermissions } from \"app/common/ACLPermissions\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"ACLPermissions\", function() {\n  const empty = emptyPermissionSet();\n\n  it(\"should convert short permissions to permissionSet\", function() {\n    assert.deepEqual(parsePermissions(\"all\"),\n      { read: \"allow\", create: \"allow\", update: \"allow\", delete: \"allow\", schemaEdit: \"allow\" });\n    assert.deepEqual(parsePermissions(\"none\"),\n      { read: \"deny\", create: \"deny\", update: \"deny\", delete: \"deny\", schemaEdit: \"deny\" });\n    assert.deepEqual(parsePermissions(\"all\"), parsePermissions(\"+CRUDS\"));\n    assert.deepEqual(parsePermissions(\"none\"), parsePermissions(\"-CRUDS\"));\n\n    assert.deepEqual(parsePermissions(\"+R\"), { ...empty, read: \"allow\" });\n    assert.deepEqual(parsePermissions(\"-R\"), { ...empty, read: \"deny\" });\n    assert.deepEqual(parsePermissions(\"+S\"), { ...empty, schemaEdit: \"allow\" });\n    assert.deepEqual(parsePermissions(\"\"), empty);\n    assert.deepEqual(parsePermissions(\"+CUD-R\"),\n      { create: \"allow\", update: \"allow\", delete: \"allow\", read: \"deny\", schemaEdit: \"\" });\n    assert.deepEqual(parsePermissions(\"-R+CUD\"),\n      { create: \"allow\", update: \"allow\", delete: \"allow\", read: \"deny\", schemaEdit: \"\" });\n    assert.deepEqual(parsePermissions(\"+R-CUD\"),\n      { create: \"deny\", update: \"deny\", delete: \"deny\", read: \"allow\", schemaEdit: \"\" });\n    assert.deepEqual(parsePermissions(\"-CUD+R\"),\n      { create: \"deny\", update: \"deny\", delete: \"deny\", read: \"allow\", schemaEdit: \"\" });\n\n    assert.throws(() => parsePermissions(\"R\"), /Invalid permissions specification \"R\"/);\n    assert.throws(() => parsePermissions(\"x\"), /Invalid permissions specification \"x\"/);\n    assert.throws(() => parsePermissions(\"-R\\n\"), /Invalid permissions specification \"-R\\\\n\"/);\n  });\n\n  it(\"should convert permissionSets to short string\", function() {\n    assert.equal(permissionSetToText({ read: \"allow\" }), \"+R\");\n    assert.equal(permissionSetToText({ read: \"deny\" }), \"-R\");\n    assert.equal(permissionSetToText({ schemaEdit: \"allow\" }), \"+S\");\n    assert.equal(permissionSetToText({}), \"\");\n    assert.equal(permissionSetToText({ create: \"allow\", update: \"allow\", delete: \"allow\", read: \"deny\" }), \"+CUD-R\");\n    assert.equal(permissionSetToText({ create: \"deny\", update: \"deny\", delete: \"deny\", read: \"allow\" }), \"+R-CUD\");\n\n    assert.equal(permissionSetToText(parsePermissions(\"+CRUDS\")), \"all\");\n    assert.equal(permissionSetToText(parsePermissions(\"-CRUDS\")), \"none\");\n  });\n\n  it(\"should allow merging PermissionSets\", function() {\n    function mergeDirect(a: string, b: string) {\n      const aParsed = parsePermissions(a);\n      const bParsed = parsePermissions(b);\n      return permissionSetToText(mergePermissions([aParsed, bParsed], ([_a, _b]) => _a || _b));\n    }\n    testMerge(mergeDirect);\n  });\n\n  it(\"should allow merging PermissionSets via PartialPermissionSet\", function() {\n    // In practice, we work with more generalized PartialPermissionValues. Ensure that this\n    // pathway produces the same results.\n    function mergeViaPartial(a: string, b: string) {\n      const aParsed = parsePermissions(a);\n      const bParsed = parsePermissions(b);\n      return permissionSetToText(mergePartialPermissions(aParsed, bParsed));\n    }\n    testMerge(mergeViaPartial);\n  });\n\n  function testMerge(merge: (a: string, b: string) => string) {\n    assert.equal(merge(\"+R\", \"-R\"), \"+R\");\n    assert.equal(merge(\"+C-D\", \"+CDS-RU\"), \"+CS-RUD\");\n    assert.equal(merge(\"all\", \"+R-CUDS\"), \"all\");\n    assert.equal(merge(\"none\", \"-R+CUDS\"), \"none\");\n    assert.equal(merge(\"all\", \"none\"), \"all\");\n    assert.equal(merge(\"none\", \"all\"), \"none\");\n    assert.equal(merge(\"\", \"+RU-CD\"), \"+RU-CD\");\n    assert.equal(merge(\"-S\", \"+RU-CD\"), \"+RU-CDS\");\n  }\n\n  it(\"should merge PartialPermissionSets\", function() {\n    function merge(a: Partial<PartialPermissionSet>, b: Partial<PartialPermissionSet>): PartialPermissionSet {\n      return mergePartialPermissions({ ...empty, ...a }, { ...empty, ...b });\n    }\n\n    // Combining single bits.\n    assert.deepEqual(merge({ read: \"allow\" }, { read: \"deny\" }), { ...empty, read: \"allow\" });\n    assert.deepEqual(merge({ read: \"deny\" }, { read: \"allow\" }), { ...empty, read: \"deny\" });\n    assert.deepEqual(merge({ read: \"mixed\" }, { read: \"deny\" }), { ...empty, read: \"mixed\" });\n    assert.deepEqual(merge({ read: \"mixed\" }, { read: \"allow\" }), { ...empty, read: \"mixed\" });\n    assert.deepEqual(merge({ read: \"allowSome\" }, { read: \"allow\" }), { ...empty, read: \"allow\" });\n    assert.deepEqual(merge({ read: \"allowSome\" }, { read: \"allowSome\" }), { ...empty, read: \"allowSome\" });\n    assert.deepEqual(merge({ read: \"allowSome\" }, { read: \"deny\" }), { ...empty, read: \"mixed\" });\n    assert.deepEqual(merge({ read: \"allowSome\" }, { read: \"denySome\" }), { ...empty, read: \"mixed\" });\n    assert.deepEqual(merge({ read: \"denySome\" }, { read: \"deny\" }), { ...empty, read: \"deny\" });\n    assert.deepEqual(merge({ read: \"denySome\" }, { read: \"denySome\" }), { ...empty, read: \"denySome\" });\n    assert.deepEqual(merge({ read: \"denySome\" }, { read: \"allow\" }), { ...empty, read: \"mixed\" });\n    assert.deepEqual(merge({ read: \"denySome\" }, { read: \"allowSome\" }), { ...empty, read: \"mixed\" });\n\n    // Combining multiple bits.\n    assert.deepEqual(merge(\n      { read: \"allowSome\", create: \"allow\", update: \"denySome\", delete: \"deny\" },\n      { read: \"deny\", create: \"denySome\", update: \"deny\", delete: \"denySome\", schemaEdit: \"deny\" },\n    ),\n    { read: \"mixed\", create: \"allow\", update: \"deny\", delete: \"deny\", schemaEdit: \"deny\" },\n    );\n\n    assert.deepEqual(merge(makePartialPermissions(parsePermissions(\"all\")), parsePermissions(\"+U-D\")),\n      { read: \"allowSome\", create: \"allowSome\", update: \"allow\", delete: \"mixed\", schemaEdit: \"allowSome\" },\n    );\n    assert.deepEqual(merge(parsePermissions(\"+U-D\"), makePartialPermissions(parsePermissions(\"all\"))),\n      { read: \"allowSome\", create: \"allowSome\", update: \"allow\", delete: \"deny\", schemaEdit: \"allowSome\" },\n    );\n  });\n\n  it(\"should support trimPermissions\", function() {\n    const trim = (permissionsText: string, availableBits: PermissionKey[]) =>\n      permissionSetToText(trimPermissions(parsePermissions(permissionsText), availableBits));\n    assert.deepEqual(trim(\"+CRUD\", [\"read\", \"update\"]), \"+RU\");\n    assert.deepEqual(trim(\"all\", [\"read\", \"update\"]), \"+RU\");\n    assert.deepEqual(trim(\"-C+R-U+D-S\", [\"update\", \"read\"]), \"+R-U\");\n    assert.deepEqual(trim(\"none\", [\"read\", \"update\", \"create\", \"delete\", \"schemaEdit\"]), \"none\");\n    assert.deepEqual(trim(\"none\", [\"read\", \"update\", \"create\", \"delete\"]), \"-CRUD\");\n    assert.deepEqual(trim(\"none\", [\"read\"]), \"-R\");\n  });\n\n  it(\"should allow summarization of permission sets\", function() {\n    assert.deepEqual(summarizePermissionSet(parsePermissions(\"+U-D\")), \"mixed\");\n    assert.deepEqual(summarizePermissionSet(parsePermissions(\"+U+D\")), \"allow\");\n    assert.deepEqual(summarizePermissionSet(parsePermissions(\"-U-D\")), \"deny\");\n    assert.deepEqual(summarizePermissionSet(parsePermissions(\"-U-D\")), \"deny\");\n    assert.deepEqual(summarizePermissionSet(parsePermissions(\"none\")), \"deny\");\n    assert.deepEqual(summarizePermissionSet(parsePermissions(\"all\")), \"allow\");\n    assert.deepEqual(summarizePermissionSet(parsePermissions(\"\")), \"mixed\");\n    assert.deepEqual(summarizePermissionSet(parsePermissions(\"+CRUDS\")), \"allow\");\n    assert.deepEqual(summarizePermissionSet(parsePermissions(\"-CRUDS\")), \"deny\");\n    assert.deepEqual(summarizePermissionSet({ ...empty, read: \"allow\", update: \"allowSome\" }), \"allow\");\n    assert.deepEqual(summarizePermissionSet({ ...empty, read: \"allowSome\", update: \"allow\" }), \"allow\");\n    assert.deepEqual(summarizePermissionSet({ ...empty, read: \"allowSome\", update: \"allowSome\" }), \"allow\");\n    assert.deepEqual(summarizePermissionSet({ ...empty, read: \"allow\", update: \"denySome\" }), \"mixed\");\n    assert.deepEqual(summarizePermissionSet({ ...empty, read: \"denySome\", update: \"allowSome\" }), \"mixed\");\n    assert.deepEqual(summarizePermissionSet({ ...empty, read: \"denySome\", update: \"deny\" }), \"deny\");\n  });\n\n  it(\"should allow summarization of permissions\", function() {\n    assert.deepEqual(summarizePermissions([\"allow\", \"deny\"]), \"mixed\");\n    assert.deepEqual(summarizePermissions([\"allow\", \"allow\"]), \"allow\");\n    assert.deepEqual(summarizePermissions([\"deny\", \"allow\"]), \"mixed\");\n    assert.deepEqual(summarizePermissions([\"deny\", \"deny\"]), \"deny\");\n    assert.deepEqual(summarizePermissions([\"allow\"]), \"allow\");\n    assert.deepEqual(summarizePermissions([\"deny\"]), \"deny\");\n    assert.deepEqual(summarizePermissions([]), \"mixed\");\n    assert.deepEqual(summarizePermissions([\"allow\", \"allow\", \"deny\"]), \"mixed\");\n    assert.deepEqual(summarizePermissions([\"allow\", \"allow\", \"allow\"]), \"allow\");\n  });\n});\n"
  },
  {
    "path": "test/common/AsyncCreate.ts",
    "content": "import { AsyncCreate, asyncOnce, mapGetOrSet } from \"app/common/AsyncCreate\";\n\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"AsyncCreate\", function() {\n  it(\"should call create func on first use and after failure\", async function() {\n    const createFunc = sinon.stub();\n    const cp = new AsyncCreate(createFunc);\n    sinon.assert.notCalled(createFunc);\n\n    const value = { hello: \"world\" };\n    createFunc.returns(Promise.resolve(value));\n\n    // Check that .get() calls the createFunc and returns the expected value.\n    assert.strictEqual(await cp.get(), value);\n    sinon.assert.calledOnce(createFunc);\n    createFunc.resetHistory();\n\n    // Subsequent calls return the cached value.\n    assert.strictEqual(await cp.get(), value);\n    sinon.assert.notCalled(createFunc);\n\n    // After clearing, .get() calls createFunc again. We'll make this one fail.\n    cp.clear();\n    createFunc.returns(Promise.reject(new Error(\"fake-error1\")));\n    await assert.isRejected(cp.get(), /fake-error1/);\n    sinon.assert.calledOnce(createFunc);\n    createFunc.resetHistory();\n\n    // After failure, subsequent calls try again.\n    createFunc.returns(Promise.reject(new Error(\"fake-error2\")));\n    await assert.isRejected(cp.get(), /fake-error2/);\n    sinon.assert.calledOnce(createFunc);\n    createFunc.resetHistory();\n\n    // While a createFunc() is pending we do NOT call it again.\n    createFunc.returns(Promise.reject(new Error(\"fake-error3\")));\n    await Promise.all([\n      assert.isRejected(cp.get(), /fake-error3/),\n      assert.isRejected(cp.get(), /fake-error3/),\n    ]);\n    sinon.assert.calledOnce(createFunc);    // Called just once here.\n    createFunc.resetHistory();\n  });\n\n  it(\"asyncOnce should call func once and after failure\", async function() {\n    const createFunc = sinon.stub();\n    let onceFunc = asyncOnce(createFunc);\n    sinon.assert.notCalled(createFunc);\n\n    const value = { hello: \"world\" };\n    createFunc.returns(Promise.resolve(value));\n\n    // Check that .get() calls the createFunc and returns the expected value.\n    assert.strictEqual(await onceFunc(), value);\n    sinon.assert.calledOnce(createFunc);\n    createFunc.resetHistory();\n\n    // Subsequent calls return the cached value.\n    assert.strictEqual(await onceFunc(), value);\n    sinon.assert.notCalled(createFunc);\n\n    // Create a new onceFunc. We'll make this one fail.\n    onceFunc = asyncOnce(createFunc);\n    createFunc.returns(Promise.reject(new Error(\"fake-error1\")));\n    await assert.isRejected(onceFunc(), /fake-error1/);\n    sinon.assert.calledOnce(createFunc);\n    createFunc.resetHistory();\n\n    // After failure, subsequent calls try again.\n    createFunc.returns(Promise.reject(new Error(\"fake-error2\")));\n    await assert.isRejected(onceFunc(), /fake-error2/);\n    sinon.assert.calledOnce(createFunc);\n    createFunc.resetHistory();\n\n    // While a createFunc() is pending we do NOT call it again.\n    createFunc.returns(Promise.reject(new Error(\"fake-error3\")));\n    await Promise.all([\n      assert.isRejected(onceFunc(), /fake-error3/),\n      assert.isRejected(onceFunc(), /fake-error3/),\n    ]);\n    sinon.assert.calledOnce(createFunc);    // Called just once here.\n    createFunc.resetHistory();\n  });\n\n  describe(\"mapGetOrSet\", function() {\n    it(\"should call create func on first use and after failure\", async function() {\n      const createFunc = sinon.stub();\n      const amap = new Map<string, any>();\n\n      createFunc.callsFake(async (key: string) => ({ myKey: key.toUpperCase() }));\n\n      // Check that mapGetOrSet() calls the createFunc and returns the expected value.\n      assert.deepEqual(await mapGetOrSet(amap, \"foo\", createFunc), { myKey: \"FOO\" });\n      assert.deepEqual(await mapGetOrSet(amap, \"bar\", createFunc), { myKey: \"BAR\" });\n      sinon.assert.calledTwice(createFunc);\n      createFunc.resetHistory();\n\n      // Subsequent calls return the cached value.\n      assert.deepEqual(await mapGetOrSet(amap, \"foo\", createFunc), { myKey: \"FOO\" });\n      assert.deepEqual(await mapGetOrSet(amap, \"bar\", createFunc), { myKey: \"BAR\" });\n      sinon.assert.notCalled(createFunc);\n\n      // Calls to plain .get() also return the cached value.\n      assert.deepEqual(await amap.get(\"foo\"), { myKey: \"FOO\" });\n      assert.deepEqual(await amap.get(\"bar\"), { myKey: \"BAR\" });\n      sinon.assert.notCalled(createFunc);\n\n      // After clearing, .get() returns undefined. (The usual Map behavior.)\n      amap.delete(\"foo\");\n      assert.strictEqual(await amap.get(\"foo\"), undefined);\n\n      // After clearing, mapGetOrSet() calls createFunc again. We'll make this one fail.\n      createFunc.callsFake((key: string) => Promise.reject(new Error(\"fake-error1-\" + key)));\n      await assert.isRejected(mapGetOrSet(amap, \"foo\", createFunc), /fake-error1-foo/);\n      assert.strictEqual(await amap.get(\"foo\"), undefined);\n      sinon.assert.calledOnce(createFunc);\n      createFunc.resetHistory();\n\n      // Other keys should be unaffected.\n      assert.deepEqual(await mapGetOrSet(amap, \"bar\", createFunc), { myKey: \"BAR\" });\n      assert.deepEqual(await amap.get(\"bar\"), { myKey: \"BAR\" });\n      sinon.assert.notCalled(createFunc);\n\n      // After failure, subsequent calls try again.\n      createFunc.callsFake((key: string) => Promise.reject(new Error(\"fake-error2-\" + key)));\n      await assert.isRejected(mapGetOrSet(amap, \"foo\", createFunc), /fake-error2-foo/);\n      sinon.assert.calledOnce(createFunc);\n      createFunc.resetHistory();\n\n      // While a createFunc() is pending we do NOT call it again.\n      createFunc.callsFake((key: string) => Promise.reject(new Error(\"fake-error3-\" + key)));\n      amap.delete(\"bar\");\n      await Promise.all([\n        assert.isRejected(mapGetOrSet(amap, \"foo\", createFunc), /fake-error3-foo/),\n        assert.isRejected(mapGetOrSet(amap, \"bar\", createFunc), /fake-error3-bar/),\n        assert.isRejected(mapGetOrSet(amap, \"foo\", createFunc), /fake-error3-foo/),\n        assert.isRejected(mapGetOrSet(amap, \"bar\", createFunc), /fake-error3-bar/),\n      ]);\n      sinon.assert.calledTwice(createFunc);    // Called just twice, once for each value.\n      createFunc.resetHistory();\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/BigInt.ts",
    "content": "import { BigInt } from \"app/common/BigInt\";\n\nimport { assert } from \"chai\";\nimport { times } from \"lodash\";\n\ndescribe(\"BigInt\", function() {\n  it(\"should represent and convert various numbers correctly\", function() {\n    assert.strictEqual(new BigInt(16, [0xF, 0xA], +1).toString(16), \"af\");\n    assert.strictEqual(new BigInt(16, [0xA, 0xF], -1).toString(16), \"-fa\");\n    assert.strictEqual(new BigInt(16, [0xF, 0xF], +1).toString(10), \"255\");\n    assert.strictEqual(new BigInt(16, [0xF, 0xF], -1).toString(10), \"-255\");\n\n    assert.strictEqual(new BigInt(10, times(20, () => 5), 1).toString(10), \"55555555555555555555\");\n    assert.strictEqual(new BigInt(100, times(20, () => 5), 1).toString(10),\n      \"505050505050505050505050505050505050505\");\n    assert.strictEqual(new BigInt(1000, times(20, () => 5), 1).toString(10),\n      \"5005005005005005005005005005005005005005005005005005005005\");\n\n    assert.strictEqual(new BigInt(0x10000, [0xABCD, 0x1234, 0xF0F0, 0x5678], -1).toString(16),\n      \"-5678f0f01234abcd\");\n  });\n});\n"
  },
  {
    "path": "test/common/BinaryIndexedTree.js",
    "content": "var assert = require(\"assert\");\nvar BinaryIndexedTree = require(\"app/common/BinaryIndexedTree\");\n\ndescribe(\"BinaryIndexedTree\", function() {\n  describe(\"#leastSignificantOne\", function() {\n    it(\"should only keep the least significant one\", function() {\n      assert.equal(BinaryIndexedTree.leastSignificantOne(1), 1);\n      assert.equal(BinaryIndexedTree.leastSignificantOne(6), 2);\n      assert.equal(BinaryIndexedTree.leastSignificantOne(15), 1);\n      assert.equal(BinaryIndexedTree.leastSignificantOne(16), 16);\n      assert.equal(BinaryIndexedTree.leastSignificantOne(0), 0);\n    });\n  });\n\n  describe(\"#stripLeastSignificantOne\", function() {\n    it(\"should strip the least significant one\", function() {\n      assert.equal(BinaryIndexedTree.stripLeastSignificantOne(1), 0);\n      assert.equal(BinaryIndexedTree.stripLeastSignificantOne(6), 4);\n      assert.equal(BinaryIndexedTree.stripLeastSignificantOne(15), 14);\n      assert.equal(BinaryIndexedTree.stripLeastSignificantOne(16), 0);\n      assert.equal(BinaryIndexedTree.stripLeastSignificantOne(0), 0);\n      assert.equal(BinaryIndexedTree.stripLeastSignificantOne(24), 16);\n    });\n  });\n\n  describe(\"#mostSignificantOne\", function() {\n    it(\"should keep the most significant one\", function() {\n      assert.equal(BinaryIndexedTree.mostSignificantOne(1), 1);\n      assert.equal(BinaryIndexedTree.mostSignificantOne(6), 4);\n      assert.equal(BinaryIndexedTree.mostSignificantOne(15), 8);\n      assert.equal(BinaryIndexedTree.mostSignificantOne(16), 16);\n      assert.equal(BinaryIndexedTree.mostSignificantOne(24), 16);\n      assert.equal(BinaryIndexedTree.mostSignificantOne(0), 0);\n    });\n  });\n\n  describe(\"#cumulToValues\", function() {\n    it(\"should convert cumulative array to regular values\", function() {\n      assert.deepEqual(BinaryIndexedTree.cumulToValues([1, 3, 6, 10]), [1, 2, 3, 4]);\n      assert.deepEqual(BinaryIndexedTree.cumulToValues([1, 3, 6, 10, 15, 21]), [1, 2, 3, 4, 5, 6]);\n      assert.deepEqual(BinaryIndexedTree.cumulToValues([]), []);\n    });\n  });\n\n  describe(\"#valuesToCumul\", function() {\n    it(\"should convert value array to cumulative array\", function() {\n      assert.deepEqual(BinaryIndexedTree.valuesToCumul([1, 2, 3, 4]), [1, 3, 6, 10]);\n      assert.deepEqual(BinaryIndexedTree.valuesToCumul([1, 2, 3, 4, 5, 6]), [1, 3, 6, 10, 15, 21]);\n      assert.deepEqual(BinaryIndexedTree.valuesToCumul([]), []);\n    });\n  });\n\n  //----------------------------------------------------------------------\n\n  // Test array of length 25.\n  var data1 = [47, 17, 28, 96, 10, 2, 11, 43, 7, 94, 37, 81, 75, 2, 33, 57, 68, 71, 68, 86, 27, 44, 64, 41, 23];\n\n  // Test array of length 64.\n  var data2 = [722, 106, 637, 881, 752, 940, 989, 295, 344, 716, 283, 609, 482, 268, 884, 782, 628, 778, 442, 456, 171, 821, 346, 367, 12, 46, 582, 164, 876, 421, 749, 357, 586, 319, 847, 79, 649, 353, 545, 353, 609, 865, 229, 476, 697, 579, 109, 935, 412, 286, 701, 712, 288, 45, 990, 176, 775, 143, 187, 241, 721, 691, 162, 460];\n  var cdata1, cdata2;   // Cumulative versions.\n\n  function dumbGetCumulativeValue(array, index) {\n    for (var i = 0, x = 0; i <= index; i++) {\n      x += array[i];\n    }\n    return x;\n  }\n\n  /*\n  function dumbGetIndex(array, cumulValue) {\n    for (var i = 0, x = 0; i <= array.length && x <= cumulValue; i++) {\n      x += array[i];\n    }\n    return i;\n  }\n */\n\n  before(function() {\n    cdata1 = data1.map(function(value, i) { return dumbGetCumulativeValue(data1, i); });\n    cdata2 = data2.map(function(value, i) { return dumbGetCumulativeValue(data2, i); });\n  });\n\n  describe(\"BinaryIndexedTree class\", function() {\n    it(\"should construct trees with zeroes\", function() {\n      var bit = new BinaryIndexedTree();\n      assert.equal(bit.size(), 0);\n      bit.fillFromValues([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);\n      var bit2 = new BinaryIndexedTree(10);\n      assert.deepEqual(bit, bit2);\n    });\n\n    it(\"should convert from cumulative array and back\", function() {\n      var bit = new BinaryIndexedTree();\n      bit.fillFromCumulative(cdata1);\n      assert.equal(bit.size(), 25);\n      assert.deepEqual(bit.toCumulativeArray(), cdata1);\n      assert.deepEqual(bit.toValueArray(), data1);\n\n      bit.fillFromCumulative([]);\n      assert.equal(bit.size(), 0);\n      assert.deepEqual(bit.toCumulativeArray(), []);\n      assert.deepEqual(bit.toValueArray(), []);\n\n      bit.fillFromCumulative(cdata2);\n      assert.equal(bit.size(), 64);\n      assert.deepEqual(bit.toCumulativeArray(), cdata2);\n      assert.deepEqual(bit.toValueArray(), data2);\n    });\n\n    it(\"should convert from value array and back\", function() {\n      var bit = new BinaryIndexedTree();\n      bit.fillFromValues(data1);\n      assert.equal(bit.size(), 25);\n      assert.deepEqual(bit.toCumulativeArray(), cdata1);\n      assert.deepEqual(bit.toValueArray(), data1);\n\n      bit.fillFromValues([]);\n      assert.equal(bit.size(), 0);\n      assert.deepEqual(bit.toCumulativeArray(), []);\n      assert.deepEqual(bit.toValueArray(), []);\n\n      bit.fillFromValues(data2);\n      assert.equal(bit.size(), 64);\n      assert.deepEqual(bit.toCumulativeArray(), cdata2);\n      assert.deepEqual(bit.toValueArray(), data2);\n\n      bit.fillFromValues([1, 2, 3, 4, 5]);\n      assert.equal(bit.size(), 5);\n      assert.deepEqual(bit.toCumulativeArray(), [1, 3, 6, 10, 15]);\n      assert.deepEqual(bit.toValueArray(), [1, 2, 3, 4, 5]);\n    });\n\n    it(\"should compute individual and cumulative values\", function() {\n      var i, bit = new BinaryIndexedTree();\n      bit.fillFromValues(data1);\n      assert.equal(bit.size(), 25);\n      for (i = 0; i < 25; i++) {\n        assert.equal(bit.getValue(i), data1[i]);\n        assert.equal(bit.getCumulativeValue(i), cdata1[i]);\n        assert.equal(bit.getSumTo(i), cdata1[i] - data1[i]);\n      }\n      assert.equal(bit.getTotal(), data1.reduce(function(a, b) { return a + b; }));\n\n      bit.fillFromValues(data2);\n      assert.equal(bit.size(), 64);\n      for (i = 0; i < 64; i++) {\n        assert.equal(bit.getValue(i), data2[i]);\n        assert.equal(bit.getCumulativeValue(i), cdata2[i]);\n        assert.equal(bit.getSumTo(i), cdata2[i] - data2[i]);\n      }\n      assert.equal(bit.getTotal(), data2.reduce(function(a, b) { return a + b; }));\n    });\n\n    it(\"should compute cumulative range values\", function() {\n      var i, bit = new BinaryIndexedTree();\n      bit.fillFromValues(data1);\n\n      assert.equal(bit.getCumulativeValueRange(0, data1.length),\n        bit.getCumulativeValue(data1.length-1));\n      for(i = 1; i < 25; i++) {\n        assert.equal(bit.getCumulativeValueRange(i, 25),\n          cdata1[24] - cdata1[i-1]);\n      }\n      for(i = 24; i >= 0; i-- ){\n        assert.equal(bit.getCumulativeValueRange(0, i+1), cdata1[i]);\n      }\n\n      bit.fillFromValues(data2);\n      assert.equal(bit.getCumulativeValueRange(0, 64),\n        bit.getCumulativeValue(63));\n      for(i = 1; i < 64; i++) {\n        assert.equal(bit.getCumulativeValueRange(i, 64),\n          cdata2[63] - cdata2[i-1]);\n      }\n      for(i = 63; i >= 0; i-- ){\n        assert.equal(bit.getCumulativeValueRange(0, i+1), cdata2[i]);\n      }\n\n\n    });\n\n    it(\"should search by cumulative value\", function() {\n      var bit = new BinaryIndexedTree();\n      bit.fillFromValues([1, 2, 3, 4]);\n      assert.equal(bit.getIndex(-1), 0);\n      assert.equal(bit.getIndex(0), 0);\n      assert.equal(bit.getIndex(1), 0);\n      assert.equal(bit.getIndex(2), 1);\n      assert.equal(bit.getIndex(3), 1);\n      assert.equal(bit.getIndex(4), 2);\n      assert.equal(bit.getIndex(5), 2);\n      assert.equal(bit.getIndex(6), 2);\n      assert.equal(bit.getIndex(7), 3);\n      assert.equal(bit.getIndex(8), 3);\n      assert.equal(bit.getIndex(9), 3);\n      assert.equal(bit.getIndex(10), 3);\n      assert.equal(bit.getIndex(11), 4);\n\n      bit.fillFromValues(data1);\n      // data1 is [47,17,28,96,10,2,11,43,7,94,37,81,75,2,33,57,68,71,68,86,27,44,64,41,23];\n      assert.equal(bit.getIndex(0), 0);\n      assert.equal(bit.getIndex(1), 0);\n      assert.equal(bit.getIndex(46.9), 0);\n      assert.equal(bit.getIndex(47), 0);\n      assert.equal(bit.getIndex(63), 1);\n      assert.equal(bit.getIndex(64), 1);\n      assert.equal(bit.getIndex(64.1), 2);\n      assert.equal(bit.getIndex(bit.getCumulativeValue(5)), 5);\n      assert.equal(bit.getIndex(bit.getCumulativeValue(20)), 20);\n      assert.equal(bit.getIndex(bit.getCumulativeValue(24)), 24);\n      assert.equal(bit.getIndex(1000000), 25);\n    });\n\n    it(\"should support add and set\", function() {\n      var i, bit = new BinaryIndexedTree(4);\n      bit.setValue(1, 2);\n      assert.deepEqual(bit.toValueArray(), [0, 2, 0, 0]);\n      bit.setValue(3, 4);\n      assert.deepEqual(bit.toValueArray(), [0, 2, 0, 4]);\n      bit.setValue(0, 1);\n      assert.deepEqual(bit.toValueArray(), [1, 2, 0, 4]);\n      bit.addValue(2, 1);\n      assert.deepEqual(bit.toValueArray(), [1, 2, 1, 4]);\n      bit.addValue(2, 1);\n      assert.deepEqual(bit.toValueArray(), [1, 2, 2, 4]);\n      bit.addValue(2, 1);\n      assert.deepEqual(bit.toValueArray(), [1, 2, 3, 4]);\n\n      bit.fillFromValues(data1);\n      for (i = 0; i < data1.length; i++) {\n        bit.addValue(i, -data1[i]);\n      }\n      assert.deepEqual(bit.toValueArray(), data1.map(function() { return 0; }));\n\n      bit.fillFromValues(data1);\n      for (i = data1.length - 1; i >= 0; i--) {\n        bit.addValue(i, data1[i]);\n      }\n      assert.deepEqual(bit.toValueArray(), data1.map(function(x) { return 2*x; }));\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/ChoiceListParser.ts",
    "content": "import { DocumentSettings } from \"app/common/DocumentSettings\";\nimport { createParserRaw } from \"app/common/ValueParser\";\n\nimport { assert } from \"chai\";\n\nconst parser = createParserRaw(\"ChoiceList\", {}, {} as DocumentSettings);\n\nfunction testParse(input: string, expected?: string[]) {\n  const result = parser.cleanParse(input);\n  if (expected) {\n    assert.deepEqual(result, [\"L\", ...expected], input);\n  } else {\n    assert.isNull(result, input);\n  }\n}\n\ndescribe(\"ChoiceListParser\", function() {\n  it(\"should handle empty values\", function() {\n    testParse(\"\");\n    testParse(\" \");\n    testParse(\" , \");\n    testParse(\",,,\");\n    testParse(\" , , , \");\n    testParse(\"[]\");\n    testParse('[\"\"]');\n    testParse('[\"\", null, null, \"\"]');\n    testParse('\"\"');\n  });\n\n  it(\"should parse JSON\", function() {\n    testParse(\"[1]\", [\"1\"]);\n    testParse('[\"a\"]', [\"a\"]);\n    testParse('[\"a\", \"aa\"]', [\"a\", \"aa\"]);\n    testParse('   [\"a\", \"aa\"]   ', [\"a\", \"aa\"]);\n    testParse(\"[0, 1, 2]\", [\"0\", \"1\", \"2\"]);\n    testParse('[0, 1, 2, \"a\", \"b\", \"c\"]', [\"0\", \"1\", \"2\", \"a\", \"b\", \"c\"]);\n\n    // Remove nulls and empty strings\n    testParse('[\"a\", null, \"aa\", \"\", null]', [\"a\", \"aa\"]);\n\n    // Format nested JSON arrays and objects with formatDecoded\n    testParse('[0, 1, 2, \"a\", \"b\", \"c\", [\"d\", \"x\", \"y, z\"], [[\"e\"], \"f\"], {\"g\": [\"h\"]}]',\n      [\"0\", \"1\", \"2\", \"a\", \"b\", \"c\", 'd, x, \"y, z\"', '[[\"e\"], \"f\"]', '{\"g\": [\"h\"]}']);\n\n    // These are valid JSON but they're not arrays so _parseJSON doesn't touch them\n    testParse(\"null\", [\"null\"]);\n    testParse(\"123\", [\"123\"]);\n    testParse('\"123\"', [\"123\"]);\n    testParse('\"abc\"', [\"abc\"]);\n  });\n\n  it(\"should parse CSVs\", function() {\n    testParse('\"a\", \"aa\"', [\"a\", \"aa\"]);\n    testParse('\"a\", aa', [\"a\", \"aa\"]);\n    testParse('  \"  a  \" , aa', [\"a\", \"aa\"]);\n    testParse(\"a, aa\", [\"a\", \"aa\"]);\n    testParse(\"a,aa\", [\"a\", \"aa\"]);\n    testParse(\"a,aa b c\", [\"a\", \"aa b c\"]);\n    testParse('   \"a\", \"aa\"  ', [\"a\", \"aa\"]);\n    testParse(\"0, 1, 2\", [\"0\", \"1\", \"2\"]);\n    testParse('0, 1, 2, \"a\", \"b\", \"c\"', [\"0\", \"1\", \"2\", \"a\", \"b\", \"c\"]);\n\n    testParse('\"a\", null, \"aa\", \"\", null', [\"a\", \"null\", \"aa\", \"null\"]);\n  });\n\n  it(\"should split on newlines\", function() {\n    testParse(\"a,b \\r\\n c,d \\n e \\n\\n\\n f \\n \\n\\n \\n g\", [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\"]);\n  });\n});\n"
  },
  {
    "path": "test/common/CircularArray.js",
    "content": "var assert = require(\"assert\");\nvar CircularArray = require(\"app/common/CircularArray\");\n\ndescribe(\"CircularArray\", function() {\n  it(\"should lose old items\", function() {\n    var c = new CircularArray(5);\n    assert.equal(c.maxLength, 5);\n    assert.equal(c.length, 0);\n    c.push(\"a\");\n    assert.equal(c.get(0), \"a\");\n    c.push(\"b\");\n    c.push(\"c\");\n    assert.equal(c.length, 3);\n    assert.equal(c.get(2), \"c\");\n    assert.deepEqual(c.getArray(), [\"a\", \"b\", \"c\"]);\n    c.push(\"d\");\n    c.push(\"e\");\n    assert.equal(c.length, 5);\n    assert.equal(c.get(4), \"e\");\n    assert.deepEqual(c.getArray(), [\"a\", \"b\", \"c\", \"d\", \"e\"]);\n    c.push(\"f\");\n    assert.equal(c.length, 5);\n    assert.equal(c.get(0), \"b\");\n    assert.equal(c.get(4), \"f\");\n    assert.deepEqual(c.getArray(), [\"b\", \"c\", \"d\", \"e\", \"f\"]);\n    c.push(\"g\");\n    c.push(\"h\");\n    c.push(\"i\");\n    c.push(\"j\");\n    assert.equal(c.length, 5);\n    assert.equal(c.get(0), \"f\");\n    assert.equal(c.get(4), \"j\");\n    assert.deepEqual(c.getArray(), [\"f\", \"g\", \"h\", \"i\", \"j\"]);\n    assert.equal(c.maxLength, 5);\n  });\n});\n"
  },
  {
    "path": "test/common/ColumnFilterFunc.ts",
    "content": "import { makeFilterFunc } from \"app/common/ColumnFilterFunc\";\nimport { FilterState } from \"app/common/FilterState\";\n\nimport { assert } from \"chai\";\nimport moment from \"moment-timezone\";\n\nconst format = \"YYYY-MM-DD HH:mm:ss\";\nconst timezone = \"Europe/Paris\";\nconst parseDateTime = (dateStr: string) => moment.tz(dateStr, format, true, timezone).valueOf() / 1000;\nconst columnType = `DateTime:${timezone}`;\n\ndescribe(\"ColumnFilterFunc\", function() {\n  [\n    { date: \"2023-01-01 23:59:59\", expected: false },\n    { date: \"2023-01-02 00:00:00\", expected: true },\n    { date: \"2023-01-02 00:00:01\", expected: true },\n    { date: \"2023-01-02 01:00:01\", expected: true },\n  ].forEach(({ date, expected }) => {\n    const minStr = \"2023-01-02\";\n    const state: FilterState = { min: moment.utc(minStr).valueOf() / 1000 };\n    const filterFunc = makeFilterFunc(state, columnType);\n\n    it(`${minStr} <= ${date} should be ${expected}`, function() {\n      assert.equal(filterFunc(parseDateTime(date)), expected);\n    });\n  });\n\n  [\n    { date: \"2023-01-11 00:00:00\", expected: true },\n    { date: \"2023-01-11 23:59:59\", expected: true },\n    { date: \"2023-01-12 00:00:01\", expected: false },\n  ].forEach(({ date, expected }) => {\n    const maxStr = \"2023-01-11\";\n    const state: FilterState = { max: moment.utc(maxStr).valueOf() / 1000 };\n    const filterFunc = makeFilterFunc(state, columnType);\n\n    it(`${maxStr} >= ${date} should be ${expected}`, function() {\n      assert.equal(filterFunc(parseDateTime(date)), expected);\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/DocActions.ts",
    "content": "import { fromTableDataAction, TableDataAction, toTableDataAction } from \"app/common/DocActions\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"DocActions\", function() {\n  it(\"should convert correctly with toTableDataAction\", () => {\n    const colValues = { id: [2, 4, 6], foo: [\"a\", \"b\", \"c\"], bar: [false, \"y\", null] };\n\n    assert.deepEqual(toTableDataAction(\"Hello\", colValues),\n      [\"TableData\", \"Hello\", [2, 4, 6],\n        { foo: [\"a\", \"b\", \"c\"], bar: [false, \"y\", null] }]);\n\n    // Make sure colValues that was passed-in didn't get changed.\n    assert.deepEqual(colValues,\n      { id: [2, 4, 6], foo: [\"a\", \"b\", \"c\"], bar: [false, \"y\", null] });\n\n    assert.deepEqual(toTableDataAction(\"Foo\", { id: [] }), [\"TableData\", \"Foo\", [], {}]);\n  });\n\n  it(\"should convert correctly with fromTableDataAction\", () => {\n    const tableData: TableDataAction = [\"TableData\", \"Hello\", [2, 4, 6],\n      { foo: [\"a\", \"b\", \"c\"], bar: [false, \"y\", null] }];\n\n    assert.deepEqual(fromTableDataAction(tableData),\n      { id: [2, 4, 6], foo: [\"a\", \"b\", \"c\"], bar: [false, \"y\", null] });\n\n    // Make sure tableData itself is unchanged.\n    assert.deepEqual(tableData, [\"TableData\", \"Hello\", [2, 4, 6],\n      { foo: [\"a\", \"b\", \"c\"], bar: [false, \"y\", null] }]);\n\n    assert.deepEqual(fromTableDataAction([\"TableData\", \"Foo\", [], {}]), { id: [] });\n  });\n});\n"
  },
  {
    "path": "test/common/DocSchemaImport.ts",
    "content": "import { UserAction } from \"app/common/DocActions\";\nimport {\n  ApplyUserActionsFunc,\n  ColumnImportSchema, DocSchemaImportTool,\n  ImportSchema,\n  transformImportSchema,\n  validateImportSchema,\n} from \"app/common/DocSchemaImport\";\n\nimport { assert } from \"chai\";\nimport sinon from \"sinon\";\n\nfunction createTestSchema(): ImportSchema {\n  return {\n    tables: [\n      {\n        originalId: \"1\",\n        desiredGristId: \"Table A\",\n        columns: [\n          {\n            originalId: \"1\",\n            desiredGristId: \"Alpha\",\n            type: \"Text\",\n            label: \"Col Alpha\",\n            description: \"Alpha column description\",\n            widgetOptions: {},\n          },\n          {\n            originalId: \"2\",\n            desiredGristId: \"Bravo\",\n            type: \"Text\",\n            isFormula: true,\n            formula: {\n              formula: `$[R0]`,\n              replacements: [{ originalTableId: \"1\", originalColId: \"1\" }],\n            },\n          },\n        ],\n      },\n      {\n        originalId: \"2\",\n        desiredGristId: \"Table B\",\n        columns: [\n          {\n            originalId: \"1\",\n            desiredGristId: \"Alpha-2\",\n            type: \"Ref\",\n            ref: {\n              originalTableId: \"1\",\n              originalColId: \"1\",\n            },\n          },\n          {\n            originalId: \"2\",\n            desiredGristId: \"Bravo-2\",\n            type: \"Text\",\n            isFormula: true,\n            formula: {\n              formula: `[R0].lookupOne([R1]=\"A\")`,\n              replacements: [\n                { originalTableId: \"1\" },\n                { originalTableId: \"1\", originalColId: \"1\" },\n              ],\n            },\n          },\n        ],\n      },\n    ],\n  };\n}\n\ndescribe(\"DocSchemaImport\", function() {\n  describe(\"validateImportSchema\", () => {\n    it(\"should show no warnings for a correct, self-contained schema\", () => {\n      const schema = createTestSchema();\n      assert.isEmpty(validateImportSchema(schema));\n    });\n\n    it(\"should show no warnings for a correct schema referencing existing tables\", () => {\n      const schema = createTestSchema();\n      schema.tables[1].columns[0].ref = { existingTableId: \"A\", existingColId: \"A-1\" };\n      schema.tables[1].columns[1].formula = {\n        formula: `[R0].lookupOne([R1]=\"A\")`,\n        replacements: [\n          { existingTableId: \"A\" },\n          { existingTableId: \"A\", existingColId: \"A-1\" },\n        ],\n      };\n      const existingTables = {\n        tables: [{\n          id: \"A\",\n          columns: [\n            {\n              id: \"A-1\",\n              ref: 1,\n              isFormula: false,\n            },\n          ],\n        }],\n      };\n      assert.isEmpty(validateImportSchema(schema, existingTables));\n    });\n\n    it(\"should warn about invalid formula references\", () => {\n      const schema = createTestSchema();\n      const invalidFormulaCol: ColumnImportSchema = {\n        originalId: \"Invalid-Formula\",\n        desiredGristId: \"Invalid-Formula\",\n        type: \"Text\",\n        isFormula: true,\n        formula: {\n          formula: \"# [R0]\",\n          replacements: [{ originalTableId: \"987654321\" }],\n        },\n      };\n      schema.tables[0].columns.push(invalidFormulaCol);\n      assert.include(\n        validateImportSchema(schema)[0].message,\n        \"Formula contains a reference to an invalid table or column\",\n      );\n\n      invalidFormulaCol.formula = {\n        formula: \"# [R0] [R1]\",\n        replacements: [{ existingTableId: \"1\" }],\n      };\n      assert.include(\n        validateImportSchema(schema)[0].message,\n        \"Formula contains a reference to an invalid table or column\",\n      );\n    });\n\n    it(\"should warn about invalid reference columns\", () => {\n      const schema = createTestSchema();\n      const invalidRefCol: ColumnImportSchema = {\n        originalId: \"Invalid-Ref\",\n        desiredGristId: \"Invalid-Ref\",\n        type: \"Ref\",\n        ref: {\n          originalTableId: \"987654321\",\n          originalColId: \"123456789\",\n        },\n      };\n      schema.tables[0].columns.push(invalidRefCol);\n      assert.include(validateImportSchema(schema)[0].message, \"does not refer to a valid table or column\");\n\n      invalidRefCol.ref = { existingTableId: \"1\", existingColId: \"1\" };\n      assert.include(validateImportSchema(schema)[0].message, \"does not refer to a valid table or column\");\n    });\n  });\n\n  describe(\"transformImportSchema\", () => {\n    it(\"should remove skipped tables\", () => {\n      const schema = createTestSchema();\n      const idToSkip = schema.tables[0].originalId;\n      const { schema: newSchema, warnings } = transformImportSchema(schema, {\n        skipTableIds: [idToSkip],\n      });\n      assert.equal(newSchema.tables.length, 1);\n      assert.notEqual(newSchema.tables[0].originalId, idToSkip);\n      assert.lengthOf(warnings, 0);\n\n      // Check that the missing table triggers reference errors during validation.\n      assert.isTrue(validateImportSchema(newSchema).length > 0);\n    });\n\n    it(\"should correctly transform references when replacing a table with an existing one\", () => {\n      const schema = createTestSchema();\n\n      schema.tables[1].columns[0] = {\n        originalId: \"1\",\n        desiredGristId: \"Alpha-2\",\n        type: \"Ref\",\n        ref: {\n          originalTableId: \"1\",\n          originalColId: \"1\",\n        },\n      };\n\n      const existingDocSchema = {\n        tables: [{\n          id: \"Existing1\",\n          columns: [{\n            id: \"ExistingCol1\",\n            ref: 1,\n            // Needs to match the label on the source column for matching to work.\n            label: \"Col Alpha\",\n            isFormula: false,\n          }],\n        }],\n      };\n\n      const tableIdToReplace = \"1\";\n      const { schema: newSchema, warnings } = transformImportSchema(schema, {\n        mapExistingTableIds: new Map([[tableIdToReplace, \"Existing1\"]]),\n      }, existingDocSchema);\n\n      // Table is now at index 0 due to the replaced table being removed from the schema.\n      const transformedRef = newSchema.tables[0].columns[0].ref;\n      assert.equal(transformedRef?.existingTableId, \"Existing1\");\n      assert.equal(transformedRef?.existingColId, \"ExistingCol1\");\n      assert.lengthOf(warnings, 0);\n\n      assert.isFalse(newSchema.tables.some(table => table.originalId === tableIdToReplace));\n\n      // Check no validation warnings.\n      assert.lengthOf(validateImportSchema(newSchema, existingDocSchema), 0);\n    });\n\n    it(\"should warn if a ref couldn't be resolved during table mapping\", () => {\n      const schema = createTestSchema();\n\n      schema.tables[1].columns[0] = {\n        originalId: \"1\",\n        desiredGristId: \"Alpha-2\",\n        type: \"Ref\",\n        ref: {\n          originalTableId: \"12345\",\n          originalColId: \"54321\",\n        },\n      };\n\n      const existingDocSchema = { tables: [] };\n      const { schema: newSchema, warnings } = transformImportSchema(schema, {\n        mapExistingTableIds: new Map([[\"12345\", \"Existing1\"]]),\n      }, existingDocSchema);\n\n      assert.include(warnings[0].message, \"Could not find column information\");\n\n      // Ref should be unaltered due to the warning.\n      const originalRef = newSchema.tables[1].columns[0].ref;\n      assert.equal(originalRef?.originalTableId, \"12345\");\n      assert.equal(originalRef?.originalColId, \"54321\");\n\n      // Check validation fails due to the bad reference.\n      assert(validateImportSchema(newSchema).length > 0);\n    });\n\n    it(\"should warn if a matching table / column couldn't be found for a reference\", () => {\n      const schema = createTestSchema();\n\n      schema.tables[1].columns[0] = {\n        originalId: \"1\",\n        desiredGristId: \"Alpha-2\",\n        type: \"Ref\",\n        ref: {\n          originalTableId: \"1\",\n          originalColId: \"1\",\n        },\n      };\n\n      const existingDocSchema = {\n        tables: [{\n          id: \"Existing1\",\n          columns: [{\n            id: \"ExistingCol1\",\n            ref: 1,\n            // Label doesn't match the column schema's label - column shouldn't match.\n            label: \"\",\n            isFormula: false,\n          }],\n        }],\n      };\n\n      const { schema: newSchema, warnings } = transformImportSchema(schema, {\n        mapExistingTableIds: new Map([[\"1\", \"Existing1\"]]),\n      }, existingDocSchema);\n\n      assert.include(warnings[0].message, \"Could not match column schema\");\n\n      // Ref should be unaltered due to the warning\n      // Table is now at index 0 due to the replaced table being removed from the schema.\n      const originalRef = newSchema.tables[0].columns[0].ref;\n      assert.equal(originalRef?.originalTableId, \"1\");\n      assert.equal(originalRef?.originalColId, \"1\");\n\n      // Check validation fails due to the bad reference.\n      assert(validateImportSchema(newSchema).length > 0);\n    });\n  });\n\n  describe(\"DocSchemaImportTool\", () => {\n    it(\"generates the correct user actions for the test schema\", async () => {\n      const schema = createTestSchema();\n      const retValues = schema.tables.map((tableSchema, index) => ({\n        id: index,\n        table_id: `ArbitraryTableId_${index}`,\n        columns: tableSchema.columns.map(columnSchema => `ArbitraryColumnId_${columnSchema.desiredGristId}`),\n      }));\n      const applyUserActions: ApplyUserActionsFunc = sinon.fake.returns(Promise.resolve({\n        actionNum: 0,\n        actionHash: null,\n        retValues,\n        isModification: false,\n      }));\n      const importTool = new DocSchemaImportTool(applyUserActions);\n\n      await importTool.createTablesFromSchema(schema);\n\n      const userActionsSent = (applyUserActions as sinon.SinonSpy).firstCall.args[0];\n      const expectedAddTableActions = [\n        [\n          \"AddTable\",\n          \"Table A\",\n          [\n            {\n              id: \"Alpha\",\n              type: \"Any\",\n              isFormula: false,\n            },\n            {\n              id: \"Bravo\",\n              type: \"Any\",\n              isFormula: false,\n            },\n          ],\n        ],\n        [\n          \"AddTable\",\n          \"Table B\",\n          [\n            {\n              id: \"Alpha-2\",\n              type: \"Any\",\n              isFormula: false,\n            },\n            {\n              id: \"Bravo-2\",\n              type: \"Any\",\n              isFormula: false,\n            },\n          ],\n        ],\n      ];\n\n      assert.deepEqual(userActionsSent, expectedAddTableActions);\n\n      const modifyColumnActions = (applyUserActions as sinon.SinonSpy).secondCall.args[0];\n      const expectedModifyColumnActions = [\n        [\n          \"ModifyColumn\",\n          \"ArbitraryTableId_0\",\n          \"ArbitraryColumnId_Alpha\",\n          {\n            type: \"Text\",\n            isFormula: false,\n            formula: undefined,\n            label: \"Col Alpha\",\n            untieColIdFromLabel: true,\n            description: \"Alpha column description\",\n            widgetOptions: \"{}\",\n            visibleCol: undefined,\n            recalcDeps: undefined,\n            recalcWhen: undefined,\n          },\n        ],\n        [\n          \"ModifyColumn\",\n          \"ArbitraryTableId_0\",\n          \"ArbitraryColumnId_Bravo\",\n          {\n            type: \"Text\",\n            isFormula: true,\n            formula: \"$ArbitraryColumnId_Alpha\",\n            label: undefined,\n            untieColIdFromLabel: false,\n            description: undefined,\n            widgetOptions: undefined,\n            visibleCol: undefined,\n            recalcDeps: undefined,\n            recalcWhen: undefined,\n          },\n        ],\n        [\n          \"ModifyColumn\",\n          \"ArbitraryTableId_1\",\n          \"ArbitraryColumnId_Alpha-2\",\n          {\n            type: \"Ref:ArbitraryTableId_0\",\n            isFormula: false,\n            formula: undefined,\n            label: undefined,\n            untieColIdFromLabel: false,\n            description: undefined,\n            widgetOptions: undefined,\n            visibleCol: \"ArbitraryColumnId_Alpha\",\n            recalcDeps: undefined,\n            recalcWhen: undefined,\n          },\n        ],\n        [\n          \"ModifyColumn\",\n          \"ArbitraryTableId_1\",\n          \"ArbitraryColumnId_Bravo-2\",\n          {\n            type: \"Text\",\n            isFormula: true,\n            formula: 'ArbitraryTableId_0.lookupOne(ArbitraryColumnId_Alpha=\"A\")',\n            label: undefined,\n            untieColIdFromLabel: false,\n            description: undefined,\n            widgetOptions: undefined,\n            visibleCol: undefined,\n            recalcDeps: undefined,\n            recalcWhen: undefined,\n          },\n        ],\n      ];\n\n      assert.deepEqual(modifyColumnActions, expectedModifyColumnActions);\n    });\n\n    it(\"maps original table and column ids to the newly created ids\", async () => {\n      const schema = createTestSchema();\n\n      schema.tables.push({\n        originalId: \"Test1\",\n        desiredGristId: \"Test Table 1\",\n        columns: [\n          {\n            originalId: \"1\",\n            desiredGristId: \"Test1-1\",\n            type: \"Any\",\n          },\n          {\n            originalId: \"2\",\n            desiredGristId: \"Test1-2\",\n            type: \"Ref\",\n            ref: {\n              originalTableId: \"Test1\",\n              originalColId: \"1\",\n            },\n          },\n        ],\n      });\n\n      const retValues = schema.tables.map((tableSchema, index) => ({\n        id: index,\n        table_id: `ArbitraryTableId_${tableSchema.originalId}`,\n        columns: tableSchema.columns.map(columnSchema => `ArbitraryColumnId_${columnSchema.desiredGristId}`),\n      }));\n\n      const applyUserActions: ApplyUserActionsFunc = sinon.fake.returns(Promise.resolve({\n        actionNum: 0,\n        actionHash: null,\n        retValues,\n        isModification: false,\n      }));\n\n      const importTool = new DocSchemaImportTool(applyUserActions);\n      await importTool.createTablesFromSchema(schema);\n\n      // Check all ModifyColumn table and column ids are mapped.\n      const modifyColumnActions: any[] = (applyUserActions as sinon.SinonSpy).secondCall.args[0];\n      const tableIds = modifyColumnActions.map((action: UserAction) => action[1] as string);\n      assert.isTrue(tableIds.every((id: string) => id.startsWith(\"ArbitraryTableId_\")), \"Table id not mapped\");\n      const columnIds = modifyColumnActions.map((action: UserAction) => action[2] as string);\n      assert.isTrue(columnIds.every((id: string) => id.startsWith(\"ArbitraryColumnId_\")), \"Column id not mapped\");\n\n      const testAction = modifyColumnActions.find(\n        action => action[1] === \"ArbitraryTableId_Test1\" && action[2] === \"ArbitraryColumnId_Test1-2\",\n      );\n\n      assert(testAction !== undefined);\n      // Assert that the correct ID was added to the \"Ref:\" column type\n      assert.equal(testAction[3].type.split(\":\")[1], \"ArbitraryTableId_Test1\");\n      // Assert that the visible column was set to the new id\n      assert.equal(testAction[3].visibleCol, \"ArbitraryColumnId_Test1-1\");\n    });\n\n    it(\"substitutes existing table / column ids for formula replacements\", async () => {\n      const schema = createTestSchema();\n\n      schema.tables.push({\n        originalId: \"Test1\",\n        desiredGristId: \"Test Table 1\",\n        columns: [\n          {\n            originalId: \"1\",\n            desiredGristId: \"Test1-1\",\n            type: \"Any\",\n          },\n          {\n            originalId: \"2\",\n            desiredGristId: \"Test1-2\",\n            type: \"Any\",\n            formula: {\n              formula: \"print('[R0]') # [R0], [R1], no [R2]\",\n              replacements: [\n                { originalTableId: \"Test1\", originalColId: \"1\" },\n                { originalTableId: \"Test1\" },\n              ],\n            },\n          },\n        ],\n      });\n\n      const retValues = schema.tables.map((tableSchema, index) => ({\n        id: index,\n        table_id: `MyCol_${tableSchema.originalId}`,\n        columns: tableSchema.columns.map(columnSchema => `MyCol_${columnSchema.desiredGristId}`),\n      }));\n\n      const applyUserActions: ApplyUserActionsFunc = sinon.fake.returns(Promise.resolve({\n        actionNum: 0,\n        actionHash: null,\n        retValues,\n        isModification: false,\n      }));\n\n      const importTool = new DocSchemaImportTool(applyUserActions);\n      await importTool.createTablesFromSchema(schema);\n\n      const modifyColumnActions: any[] = (applyUserActions as sinon.SinonSpy).secondCall.args[0];\n      const [, , , colInfo] = modifyColumnActions.find(action => action[2] === \"MyCol_Test1-2\");\n\n      assert.equal(\n        colInfo.formula,\n        \"print('MyCol_Test1-1') # MyCol_Test1-1, MyCol_Test1, no [R2]\",\n      );\n    });\n\n    it(\"substitutes the original ids when formula replacements fail to resolve\", async () => {\n      const schema = createTestSchema();\n\n      schema.tables.push({\n        originalId: \"Test1\",\n        desiredGristId: \"Test Table 1\",\n        columns: [\n          {\n            originalId: \"2\",\n            desiredGristId: \"Test1-2\",\n            type: \"Any\",\n            formula: {\n              formula: \"print('[R0]') # [R0], [R1], no [R2]\",\n              replacements: [\n                { originalTableId: \"OtherTable\", originalColId: \"BadCol\" },\n                { originalTableId: \"OtherTable\" },\n              ],\n            },\n          },\n        ],\n      });\n\n      const retValues = schema.tables.map((tableSchema, index) => ({\n        id: index,\n        table_id: `MyCol_${tableSchema.originalId}`,\n        columns: tableSchema.columns.map(columnSchema => `MyCol_${columnSchema.desiredGristId}`),\n      }));\n\n      const applyUserActions: ApplyUserActionsFunc = sinon.fake.returns(Promise.resolve({\n        actionNum: 0,\n        actionHash: null,\n        retValues,\n        isModification: false,\n      }));\n\n      const importTool = new DocSchemaImportTool(applyUserActions);\n      await importTool.createTablesFromSchema(schema);\n\n      const modifyColumnActions: any[] = (applyUserActions as sinon.SinonSpy).secondCall.args[0];\n      const [, , , colInfo] = modifyColumnActions.find(action => action[2] === \"MyCol_Test1-2\");\n\n      assert.equal(\n        colInfo.formula,\n        \"print('unknown_column_BadCol') # unknown_column_BadCol, unknown_table_OtherTable, no [R2]\",\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/InactivityTimer.ts",
    "content": "import { InactivityTimer } from \"app/common/InactivityTimer\";\n\nimport { delay } from \"bluebird\";\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"InactivityTimer\", function() {\n  let spy: sinon.SinonSpy, timer: InactivityTimer;\n\n  beforeEach(() => {\n    spy = sinon.spy();\n    timer = new InactivityTimer(spy, 100);\n  });\n\n  it(\"if no activity, should trigger when time elapses after ping\", async function() {\n    timer.ping();\n    assert(spy.callCount === 0);\n    await delay(150);\n    assert.equal(spy.callCount, 1);\n  });\n\n  it(\"disableUntilFinish should clear timeout, and set it back after promise resolved\", async function() {\n    timer.ping();\n    timer.disableUntilFinish(delay(100)); // eslint-disable-line @typescript-eslint/no-floating-promises\n    await delay(150);\n    assert.equal(spy.callCount, 0);\n    await delay(100);\n    assert.equal(spy.callCount, 1);\n  });\n\n  it(\"should not trigger during async monitoring\", async function() {\n    timer.disableUntilFinish(delay(300)); // eslint-disable-line @typescript-eslint/no-floating-promises\n\n    // do not triggers after a ping\n    timer.ping();\n    await delay(150);\n    assert.equal(spy.callCount, 0);\n\n    // nor after an async monitored call\n    timer.disableUntilFinish(delay(0)); // eslint-disable-line @typescript-eslint/no-floating-promises\n    await delay(150);\n    assert.equal(spy.callCount, 0);\n\n    // finally triggers callback\n    await delay(150);\n    assert.equal(spy.callCount, 1);\n  });\n\n  it(\"should support disabling\", async function() {\n    timer.disable();\n    assert.equal(timer.isEnabled(), false);\n\n    // While disabled, ping doesn't trigger anything.\n    timer.ping();\n    assert.equal(timer.isScheduled(), false);\n    await delay(200);\n    assert.equal(spy.callCount, 0);\n\n    // When enabled, it triggers as usual.\n    timer.enable();\n    assert.equal(timer.isEnabled(), true);\n    assert.equal(timer.isScheduled(), true);\n    await delay(150);\n    assert.equal(spy.callCount, 1);\n    spy.resetHistory();\n\n    // When enabled, ping and disableUntilFinish both trigger the callback.\n    timer.disableUntilFinish(delay(50)).catch(() => null);\n    timer.disableUntilFinish(delay(150)).catch(() => null);\n    await delay(100);\n    assert.equal(spy.callCount, 0);\n    assert.equal(timer.isScheduled(), false);\n    await delay(100);\n    assert.equal(timer.isScheduled(), true);\n    assert.equal(spy.callCount, 0);\n    await delay(100);\n    assert.equal(spy.callCount, 1);\n    spy.resetHistory();\n\n    // When disabled, nothing is triggered.\n    timer.disableUntilFinish(delay(50)).catch(() => null);\n    timer.disableUntilFinish(delay(150)).catch(() => null);\n    await delay(100);\n    assert.equal(spy.callCount, 0);\n    assert.equal(timer.isEnabled(), true);\n    assert.equal(timer.isScheduled(), false);\n    timer.disable();\n    timer.ping();\n    timer.disableUntilFinish(delay(150)).catch(() => null);\n    assert.equal(timer.isEnabled(), false);\n    assert.equal(timer.isScheduled(), false);\n\n    // Nothing called even after disableUntilFinished have resumed.\n    await delay(200);\n    assert.equal(spy.callCount, 0);\n    assert.equal(timer.isScheduled(), false);\n\n    // Re-enabling will schedule after a new delay.\n    timer.enable();\n    assert.equal(timer.isEnabled(), true);\n    assert.equal(timer.isScheduled(), true);\n    await delay(50);\n    assert.equal(spy.callCount, 0);\n    await delay(150);\n    assert.equal(spy.callCount, 1);\n    assert.equal(timer.isEnabled(), true);\n    assert.equal(timer.isScheduled(), false);\n  });\n});\n"
  },
  {
    "path": "test/common/Interval.ts",
    "content": "import { Interval } from \"app/common/Interval\";\n\nimport { delay } from \"bluebird\";\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"Interval\", function() {\n  const delayMs = 100;\n  const varianceMs = 50;\n  const promiseDelayMs = 200;\n  const delayBufferMs = 20;\n\n  let interval: Interval;\n  let spy: sinon.SinonSpy;\n\n  beforeEach(() => {\n    spy = sinon.spy();\n  });\n\n  afterEach(async () => {\n    if (interval) {\n      await interval.disableAndFinish();\n    }\n  });\n\n  it(\"is not enabled by default\", async function() {\n    interval = new Interval(spy, { delayMs }, { onError: () => { /* do nothing */ } });\n    assert.equal(spy.callCount, 0);\n    await delay(delayMs + delayBufferMs);\n    assert.equal(spy.callCount, 0);\n  });\n\n  it(\"can be disabled\", async function() {\n    interval = new Interval(spy, { delayMs }, { onError: () => { /* do nothing */ } });\n    interval.enable();\n    await delay(delayMs + delayBufferMs);\n    assert.equal(spy.callCount, 1);\n\n    // Disable the interval, and check that the calls stop.\n    interval.disable();\n    await delay(delayMs + delayBufferMs);\n    assert.equal(spy.callCount, 1);\n\n    // Enable the interval again, and check that the calls resume.\n    interval.enable();\n    await delay(delayMs + delayBufferMs);\n    assert.equal(spy.callCount, 2);\n    spy.resetHistory();\n  });\n\n  it(\"calls onError if callback throws an error\", async function() {\n    const callback = () => { throw new Error(\"Something bad happened.\"); };\n    const onErrorSpy = sinon.spy();\n    interval = new Interval(callback, { delayMs }, { onError: onErrorSpy });\n    interval.enable();\n\n    // Check that onError is called when the callback throws.\n    assert.equal(onErrorSpy.callCount, 0);\n    await delay(delayMs + delayBufferMs);\n    assert.equal(onErrorSpy.callCount, 1);\n\n    // Check that the interval didn't stop (since the onError spy silenced the error).\n    await delay(delayMs + delayBufferMs);\n    assert.equal(onErrorSpy.callCount, 2);\n  });\n\n  describe(\"with a fixed delay\", function() {\n    beforeEach(() => {\n      interval = new Interval(spy, { delayMs }, { onError: () => { /* do nothing */ } });\n      interval.enable();\n    });\n\n    it(\"calls the callback on a fixed interval\", async function() {\n      await delay(delayMs + delayBufferMs);\n      assert.equal(spy.callCount, 1);\n      await delay(delayMs + delayBufferMs);\n      assert.equal(spy.callCount, 2);\n    });\n  });\n\n  describe(\"with a randomized delay\", function() {\n    beforeEach(() => {\n      interval = new Interval(spy, { delayMs, varianceMs }, {\n        onError: () => { /* do nothing */ },\n      });\n      interval.enable();\n    });\n\n    it(\"calls the callback on a randomized interval\", async function() {\n      const delays: number[] = [];\n      for (let i = 1; i <= 10; i++) {\n        // Get the current delay and check that it's within the expected range.\n        const currentDelayMs = interval.getDelayMs();\n        delays.push(currentDelayMs!);\n        assert.isDefined(currentDelayMs);\n        assert.isAtMost(currentDelayMs!, delayMs + varianceMs);\n        assert.isAtLeast(currentDelayMs!, delayMs - varianceMs);\n\n        // Wait for the delay, and check that the spy was called.\n        await delay(currentDelayMs!);\n        assert.equal(spy.callCount, i);\n      }\n\n      // Check that we didn't use the same delay all 10 times.\n      assert.notEqual([...new Set(delays)].length, 1);\n    });\n  });\n\n  describe(\"with a promise-based callback\", function() {\n    let promiseSpy: sinon.SinonSpy;\n\n    beforeEach(() => {\n      const promise = () => delay(promiseDelayMs);\n      promiseSpy = sinon.spy(promise);\n      interval = new Interval(promiseSpy, { delayMs }, { onError: () => { /* do nothing */ } });\n      interval.enable();\n    });\n\n    it(\"waits for promises to settle before scheduling the next call\", async function() {\n      assert.equal(promiseSpy.callCount, 0);\n      await delay(delayMs + delayBufferMs);\n      assert.equal(promiseSpy.callCount, 1);\n      await delay(delayMs + delayBufferMs);\n      assert.equal(promiseSpy.callCount, 1); // Still 1, because the first promise hasn't settled yet.\n      await delay(delayMs + delayBufferMs);\n      assert.equal(promiseSpy.callCount, 1); // Promise now settled, but there's still a 100ms delay.\n      await delay(delayMs + delayBufferMs);\n      assert.equal(promiseSpy.callCount, 2); // Now we finally call the callback again.\n    });\n\n    it(\"can wait for last promise to settle when disabling\", async function() {\n      assert.equal(promiseSpy.callCount, 0);\n      await delay(delayMs + delayBufferMs);\n      assert.equal(promiseSpy.callCount, 1);\n      await interval.disableAndFinish();\n\n      // Check that once disabled, no more calls are scheduled.\n      await delay(promiseDelayMs + delayMs + delayBufferMs);\n      assert.equal(promiseSpy.callCount, 1);\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/KeyedMutex.ts",
    "content": "import { KeyedMutex } from \"app/common/KeyedMutex\";\n\nimport { delay } from \"bluebird\";\nimport { assert } from \"chai\";\n\ndescribe(\"KeyedMutex\", function() {\n  it(\"orders actions correctly\", async function() {\n    const m = new KeyedMutex();\n    let v1: number = 0;\n    let v2: number = 0;\n\n    const fastAdd2 = m.acquire(\"2\").then((unlock) => {\n      v2++;\n      unlock();\n    });\n    const slowDouble2 = m.acquire(\"2\").then(async (unlock) => {\n      await delay(1000);\n      v2 *= 2;\n      unlock();\n    });\n    assert.equal(m.size, 1);\n\n    const slowAdd1 = m.acquire(\"1\").then(async (unlock) => {\n      await delay(500);\n      v1++;\n      unlock();\n    });\n    const immediateDouble1 = m.acquire(\"1\").then((unlock) => {\n      v1 *= 2;\n      unlock();\n    });\n    assert.equal(m.size, 2);\n\n    await Promise.all([slowAdd1, immediateDouble1]);\n    assert.equal(m.size, 1);\n    assert.equal(v1, 2);\n    assert.equal(v2, 1);\n\n    await Promise.all([fastAdd2, slowDouble2]);\n    assert.equal(m.size, 0);\n    assert.equal(v1, 2);\n    assert.equal(v2, 2);\n  });\n\n  it(\"runs operations exclusively\", async function() {\n    const m = new KeyedMutex();\n    let v1: number = 0;\n    let v2: number = 0;\n\n    const fastAdd2 = m.runExclusive(\"2\", async () => {\n      v2++;\n    });\n    const slowDouble2 = m.runExclusive(\"2\", async () => {\n      await delay(1000);\n      v2 *= 2;\n    });\n    assert.equal(m.size, 1);\n\n    const slowAdd1 = m.runExclusive(\"1\", async () => {\n      await delay(500);\n      v1++;\n    });\n    const immediateDouble1 = m.runExclusive(\"1\", async () => {\n      v1 *= 2;\n    });\n    assert.equal(m.size, 2);\n\n    await Promise.all([slowAdd1, immediateDouble1]);\n    assert.equal(m.size, 1);\n    assert.equal(v1, 2);\n    assert.equal(v2, 1);\n\n    await Promise.all([fastAdd2, slowDouble2]);\n    assert.equal(m.size, 0);\n    assert.equal(v1, 2);\n    assert.equal(v2, 2);\n  });\n});\n"
  },
  {
    "path": "test/common/MemBuffer.js",
    "content": "var assert = require(\"assert\");\nvar MemBuffer = require(\"app/common/MemBuffer\");\n\nfunction repeat(str, n) {\n  return new Array(n+1).join(str);\n}\n\ndescribe(\"MemBuffer\", function() {\n  describe(\"#reserve\", function() {\n    it(\"should reserve exponentially\", function() {\n      var mbuf = new MemBuffer();\n      assert.equal(mbuf.size(), 0);\n\n      var str = \"\";\n      var lastRes = mbuf.reserved();\n      var countReallocs = 0;\n\n      // Append 1 char at a time, 1000 times, and make sure we don't have more than 10 reallocs.\n      for (var i = 0; i < 1000; i++) {\n        var ch = \"a\".charCodeAt(0) + (i % 10);\n        str += String.fromCharCode(ch);\n\n        mbuf.writeUint8(ch);\n\n        assert.equal(mbuf.size(), i + 1);\n        assert.equal(mbuf.toString(), str);\n        assert.ok(mbuf.reserved() >= mbuf.size());\n        // Count reallocs.\n        if (mbuf.reserved() != lastRes) {\n          lastRes = mbuf.reserved();\n          countReallocs++;\n        }\n      }\n      assert.ok(countReallocs < 10 && countReallocs >= 2);\n    });\n\n    it(\"should not realloc when it can move data\", function() {\n      var mbuf = new MemBuffer();\n      mbuf.writeString(repeat(\"x\", 100));\n      assert.equal(mbuf.size(), 100);\n      assert.ok(mbuf.reserved() >= 100 && mbuf.reserved() < 200);\n\n      // Consume 99 characters, and produce 99 more, and the buffer shouldn't keep being reused.\n      var cons = mbuf.makeConsumer();\n      var value = mbuf.readString(cons, 99);\n      mbuf.consume(cons);\n      assert.equal(value, repeat(\"x\", 99));\n      assert.equal(mbuf.size(), 1);\n\n      var prevBuffer = mbuf.buffer;\n      mbuf.writeString(repeat(\"y\", 99));\n      assert.strictEqual(mbuf.buffer, prevBuffer);\n      assert.equal(mbuf.size(), 100);\n      assert.ok(mbuf.reserved() >= 100 && mbuf.reserved() < 200);\n\n      // Consume the whole buffer, and produce a new one, and it's still being reused.\n      cons = mbuf.makeConsumer();\n      value = mbuf.readString(cons, 100);\n      mbuf.consume(cons);\n      assert.equal(value, \"x\" + repeat(\"y\", 99));\n      assert.equal(mbuf.size(), 0);\n\n      mbuf.writeString(repeat(\"z\", 100));\n      assert.strictEqual(mbuf.buffer, prevBuffer);\n      assert.equal(mbuf.size(), 100);\n      assert.equal(mbuf.toString(), repeat(\"z\", 100));\n\n      // But if we produce enough new data (twice should do), it should have to realloc.\n      mbuf.writeString(repeat(\"w\", 100));\n      assert.notStrictEqual(mbuf.buffer, prevBuffer);\n      assert.equal(mbuf.size(), 200);\n      assert.equal(mbuf.toString(), repeat(\"z\", 100) + repeat(\"w\", 100));\n    });\n  });\n\n  describe(\"#write\", function() {\n    it(\"should append to the buffer\", function() {\n      var mbuf = new MemBuffer();\n      mbuf.writeString(\"a\");\n      mbuf.writeString(repeat(\"x\", 100));\n      assert.equal(mbuf.toString(), \"a\" + repeat(\"x\", 100));\n\n      var y = repeat(\"y\", 10000);\n      mbuf.writeString(y);\n      assert.equal(mbuf.toString(), \"a\" + repeat(\"x\", 100) + y);\n    });\n  });\n\n  describe(\"#consume\", function() {\n    it(\"should remove from start of buffer\", function() {\n      var mbuf = new MemBuffer();\n      mbuf.writeString(repeat(\"x\", 90));\n      mbuf.writeString(repeat(\"y\", 10));\n      assert.equal(mbuf.toString(), repeat(\"x\", 90) + repeat(\"y\", 10));\n      var cons = mbuf.makeConsumer();\n      assert.equal(mbuf.readString(cons, 1), \"x\");\n      assert.equal(mbuf.readString(cons, 90), repeat(\"x\", 89) + \"y\");\n      mbuf.consume(cons);\n      assert.equal(mbuf.toString(), repeat(\"y\", 9));\n\n      // Trying to read past the end should throw.\n      assert.throws(function() {\n        mbuf.readString(cons, 10);\n      }, function(err) {\n        assert.ok(err.needMoreData);\n        return true;\n      });\n\n      // Should leave the buffer empty if consume to the end.\n      assert.equal(mbuf.readString(cons, 9), repeat(\"y\", 9));\n      mbuf.consume(cons);\n      assert.equal(mbuf.size(), 0);\n    });\n\n    it(\"should read large strings\", function() {\n      var mbuf = new MemBuffer();\n      var y = repeat(\"y\", 10000);\n      mbuf.writeString(y);\n      var cons = mbuf.makeConsumer();\n      assert.equal(mbuf.readString(cons, 10000), y);\n      mbuf.consume(cons);\n      assert.equal(mbuf.size(), 0);\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/NumberFormat.ts",
    "content": "import { buildNumberFormat } from \"app/common/NumberFormat\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"NumberFormat\", function() {\n  const defaultDocSettings = {\n    locale: \"en-US\",\n  };\n\n  // useGrouping became more nuanced in recent node.\n  // Its old 'true' value may now be 'always' or 'auto'.\n  const useGroupingAlways = buildNumberFormat(\n    { numMode: \"decimal\" },\n    defaultDocSettings,\n  ).resolvedOptions().useGrouping as boolean | string;\n  const useGroupingAuto = (useGroupingAlways === \"always\") ? \"auto\" : true;\n\n  it(\"should convert Grist options into Intr.NumberFormat\", function() {\n    assert.include([\"true\", \"always\"], String(useGroupingAlways));\n\n    assert.ownInclude(buildNumberFormat({}, defaultDocSettings).resolvedOptions(), {\n      minimumFractionDigits: 0,\n      maximumFractionDigits: 10,\n      style: \"decimal\",\n      useGrouping: false,\n    });\n    assert.ownInclude(buildNumberFormat({ numMode: \"decimal\" }, defaultDocSettings).resolvedOptions(), {\n      minimumFractionDigits: 0,\n      maximumFractionDigits: 3,\n      style: \"decimal\",\n      useGrouping: useGroupingAlways,\n    });\n    assert.ownInclude(buildNumberFormat({ numMode: \"percent\" }, defaultDocSettings).resolvedOptions(), {\n      minimumFractionDigits: 0,\n      maximumFractionDigits: 0,\n      // style: 'percent',  // In node v14.17.0 style is 'decimal' (unclear why)\n      // so we check final formatting instead in this case.\n      useGrouping: useGroupingAuto,\n    });\n    assert.equal(buildNumberFormat({ numMode: \"percent\" }, defaultDocSettings).format(0.5), \"50%\");\n    assert.ownInclude(buildNumberFormat({ numMode: \"currency\" }, defaultDocSettings).resolvedOptions(), {\n      minimumFractionDigits: 2,\n      maximumFractionDigits: 2,\n      style: \"currency\",\n      useGrouping: useGroupingAuto,\n      currency: \"USD\",\n    });\n    assert.ownInclude(buildNumberFormat({ numMode: \"scientific\" }, defaultDocSettings).resolvedOptions(), {\n      minimumFractionDigits: 0,\n      maximumFractionDigits: 3,\n      style: \"decimal\",\n      // notation: 'scientific',    // Should be set, but node doesn't support it until node 12.\n    });\n\n    // Ensure we don't hit errors when max digits is less than the min (which could be implicit).\n    assert.ownInclude(buildNumberFormat({ numMode: \"currency\", maxDecimals: 1 }, defaultDocSettings).resolvedOptions(),\n      { minimumFractionDigits: 2, maximumFractionDigits: 2 });\n    assert.ownInclude(\n      buildNumberFormat({ numMode: \"currency\", decimals: 0, maxDecimals: 1 }, defaultDocSettings).resolvedOptions(),\n      { minimumFractionDigits: 0, maximumFractionDigits: 1 });\n    assert.ownInclude(buildNumberFormat({ decimals: 5 }, defaultDocSettings).resolvedOptions(),\n      { minimumFractionDigits: 5, maximumFractionDigits: 10 });\n    assert.ownInclude(buildNumberFormat({ decimals: 15 }, defaultDocSettings).resolvedOptions(),\n      { minimumFractionDigits: 15, maximumFractionDigits: 15 });\n  });\n\n  it(\"should clamp min/max decimals to valid values\", function() {\n    assert.ownInclude(buildNumberFormat({}, defaultDocSettings).resolvedOptions(),\n      { minimumFractionDigits: 0, maximumFractionDigits: 10 });\n    assert.ownInclude(buildNumberFormat({ decimals: 5 }, defaultDocSettings).resolvedOptions(),\n      { minimumFractionDigits: 5, maximumFractionDigits: 10 });\n    assert.ownInclude(buildNumberFormat({ maxDecimals: 5 }, defaultDocSettings).resolvedOptions(),\n      { minimumFractionDigits: 0, maximumFractionDigits: 5 });\n    assert.ownInclude(buildNumberFormat({ decimals: -10, maxDecimals: 50 }, defaultDocSettings).resolvedOptions(),\n      { minimumFractionDigits: 0, maximumFractionDigits: 20 });\n    assert.ownInclude(buildNumberFormat({ decimals: 21, maxDecimals: 1 }, defaultDocSettings).resolvedOptions(),\n      { minimumFractionDigits: 20, maximumFractionDigits: 20 });\n    assert.ownInclude(buildNumberFormat({ numMode: \"currency\", maxDecimals: 1 }, defaultDocSettings).resolvedOptions(),\n      { minimumFractionDigits: 2, maximumFractionDigits: 2 });    // Currency overrides the minimum\n  });\n\n  it(\"should convert locales to local currency\", function() {\n    assert.ownInclude(buildNumberFormat({ numMode: \"currency\" }, { locale: \"fr-BE\" }).resolvedOptions(), {\n      minimumFractionDigits: 2,\n      maximumFractionDigits: 2,\n      style: \"currency\",\n      useGrouping: useGroupingAuto,\n      currency: \"EUR\",\n    });\n    assert.ownInclude(buildNumberFormat({ numMode: \"currency\" }, { locale: \"en-NZ\" }).resolvedOptions(), {\n      minimumFractionDigits: 2,\n      maximumFractionDigits: 2,\n      style: \"currency\",\n      useGrouping: useGroupingAuto,\n      currency: \"NZD\",\n    });\n    assert.ownInclude(buildNumberFormat({ numMode: \"currency\" }, { locale: \"de-CH\" }).resolvedOptions(), {\n      minimumFractionDigits: 2,\n      maximumFractionDigits: 2,\n      style: \"currency\",\n      useGrouping: useGroupingAuto,\n      currency: \"CHF\",\n    });\n    assert.ownInclude(buildNumberFormat({ numMode: \"currency\" }, { locale: \"es-AR\" }).resolvedOptions(), {\n      minimumFractionDigits: 2,\n      maximumFractionDigits: 2,\n      style: \"currency\",\n      useGrouping: useGroupingAuto,\n      currency: \"ARS\",\n    });\n    assert.ownInclude(buildNumberFormat({ numMode: \"currency\" }, { locale: \"zh-TW\" }).resolvedOptions(), {\n      minimumFractionDigits: 2,\n      maximumFractionDigits: 2,\n      style: \"currency\",\n      useGrouping: useGroupingAuto,\n      currency: \"TWD\",\n    });\n    assert.ownInclude(buildNumberFormat({ numMode: \"currency\" }, { locale: \"en-AU\" }).resolvedOptions(), {\n      minimumFractionDigits: 2,\n      maximumFractionDigits: 2,\n      style: \"currency\",\n      useGrouping: useGroupingAuto,\n      currency: \"AUD\",\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/NumberParse.ts",
    "content": "import { getCurrency, locales } from \"app/common/Locales\";\nimport { NumMode, parseNumMode } from \"app/common/NumberFormat\";\nimport NumberParse from \"app/common/NumberParse\";\n\nimport { assert } from \"chai\";\nimport * as _ from \"lodash\";\n\ndescribe(\"NumberParse\", function() {\n  let parser = new NumberParse(\"en\", \"USD\");\n\n  function check(str: string, expected: number | null) {\n    const parsed = parser.parse(str);\n    assert.equal(parsed?.result ?? null, expected);\n  }\n\n  it(\"can do basic parsing\", function() {\n    check(\"123\", 123);\n    check(\"-123\", -123);\n    check(\"-123.456\", -123.456);\n    check(\"-1.234e56\", -1.234e56);\n    check(\"1.234e-56\", 1.234e-56);\n    check(\"(1.234e56)\", -1.234e56);\n    check(\"($1.23)\", -1.23);\n    check(\"($ 1.23)\", -1.23);\n    check(\"$ 1.23\", 1.23);\n    check(\"$1.23\", 1.23);\n    check(\"12.34%\", 0.1234);\n    check(\"1,234,567.89\", 1234567.89);\n    check(\".89\", 0.89);\n    check(\".89000\", 0.89);\n    check(\"0089\", 89);\n\n    // The digit separator is ',' but spaces are always removed anyway\n    check(\"1 234 567.89\", 1234567.89);\n\n    assert.equal(parser.parse(\"\"), null);\n    check(\" \", null);\n    check(\"()\", null);\n    check(\" ( ) \", null);\n    check(\" (,) \", null);\n    check(\" (.) \", null);\n    check(\",\", null);\n    check(\",.\", null);\n    check(\".,\", null);\n    check(\",,,\", null);\n    check(\"...\", null);\n    check(\".\", null);\n    check(\"%\", null);\n    check(\"$\", null);\n    check(\"(ABC)\", null);\n    check(\"ABC\", null);\n    check(\"USD\", null);\n\n    check(\"NaN\", null);\n    check(\"NAN\", null);\n    check(\"nan\", null);\n\n    // Currency symbol can only appear once\n    check(\"$$1.23\", null);\n\n    // Other currency symbols not allowed\n    check(\"USD 1.23\", null);\n    check(\"€ 1.23\", null);\n    check(\"£ 1.23\", null);\n    check(\"$ 1.23\", 1.23);\n\n    // Parentheses represent negative numbers,\n    // so the number inside can't also be negative or 0\n    check(\"(0)\", null);\n    check(\"(-1.23)\", null);\n    check(\"(1.23)\", -1.23);\n    check(\"-1.23\", -1.23);\n\n    // Only one % allowed\n    check(\"12.34%%\", null);\n    check(\"12.34%\", 0.1234);\n  });\n\n  it(\"can handle different minus sign positions\", function() {\n    parser = new NumberParse(\"fy\", \"EUR\");\n    let formatter = Intl.NumberFormat(\"fy\", { style: \"currency\", currency: \"EUR\" });\n\n    assert.isTrue(parser.currencyEndsInMinusSign);\n\n    // Note the '-' is at the end\n    assert.equal(formatter.format(-1), \"€ 1,00-\");\n\n    // The parser can handle this, it also allows the '-' in the beginning as usual\n    check(\"€ 1,00-\", -1);\n    check(\"€ -1,00\", -1);\n    check(\"-€ 1,00\", -1);\n\n    // But it's only allowed at the end for currency amounts, to match the formatter\n    check(\"1,00-\", null);\n    check(\"-1,00\", -1);\n\n    // By contrast, this locale doesn't put '-' at the end so the parser doesn't allow that\n    parser = new NumberParse(\"en\", \"USD\");\n    formatter = Intl.NumberFormat(\"en\", { style: \"currency\", currency: \"USD\" });\n\n    assert.isFalse(parser.currencyEndsInMinusSign);\n\n    assert.equal(formatter.format(-1), \"-$1.00\");\n\n    check(\"-$1.00\", -1);\n    check(\"$-1.00\", -1);\n    check(\"$1.00-\", null);\n\n    check(\"-1.00\", -1);\n    check(\"1.00-\", null);\n  });\n\n  it(\"can handle different separators\", function() {\n    let formatter = Intl.NumberFormat(\"en\", { useGrouping: true });\n    assert.equal(formatter.format(123456789.123), \"123,456,789.123\");\n\n    parser = new NumberParse(\"en\", \"USD\");\n\n    assert.equal(parser.digitGroupSeparator, \",\");\n    assert.equal(parser.digitGroupSeparatorCurrency, \",\");\n    assert.equal(parser.decimalSeparator, \".\");\n\n    check(\"123,456,789.123\", 123456789.123);\n\n    // The typical separator is ',' but spaces are always removed anyway\n    check(\"123 456 789.123\", 123456789.123);\n\n    // There must be at least two digits after the separator\n    check(\"123,456\", 123456);\n    check(\"12,34,56\", 123456);\n    check(\"1,2,3,4,5,6\", null);\n    check(\"123,,456\", null);\n    check(\"1,234\", 1234);\n    check(\"123,4\", null);\n\n    // This locale uses 'opposite' separators to the above, i.e. ',' and '.' have swapped roles\n    formatter = Intl.NumberFormat(\"de-AT\", { useGrouping: true, currency: \"EUR\", style: \"currency\" });\n    assert.equal(formatter.format(123456789.123), \"€ 123.456.789,12\");\n\n    // But only for currency amounts! Non-currency amounts use NBSP (non-breaking space) for the digit separator\n    formatter = Intl.NumberFormat(\"de-AT\", { useGrouping: true });\n    assert.equal(formatter.format(123456789.123), \"123 456 789,123\");\n\n    parser = new NumberParse(\"de-AT\", \"EUR\");\n\n    assert.equal(parser.digitGroupSeparator, \" \");\n    assert.equal(parser.digitGroupSeparatorCurrency, \".\");\n    assert.equal(parser.decimalSeparator, \",\");\n\n    check(\"€ 123.456.789,123\", 123456789.123);\n    check(\"€ 123 456 789,123\", 123456789.123);\n    // The parser allows the currency separator for non-currency amounts\n    check(\"  123.456.789,123\", 123456789.123);\n    check(\"  123 456 789,123\", 123456789.123);  // normal space\n    check(\"  123 456 789,123\", 123456789.123);  // NBSP\n\n    formatter = Intl.NumberFormat(\"af-ZA\", { useGrouping: true });\n    assert.equal(formatter.format(123456789.123), \"123 456 789,123\");\n\n    parser = new NumberParse(\"af-ZA\", \"ZAR\");\n\n    assert.equal(parser.digitGroupSeparator, \" \");\n    assert.equal(parser.digitGroupSeparatorCurrency, \" \");\n    assert.equal(parser.decimalSeparator, \",\");\n\n    // ',' is the official decimal separator of this locale,\n    // but in general '.' will also work as long as it's not the digit separator.\n    check(\"123 456 789,123\", 123456789.123);\n    check(\"123 456 789.123\", 123456789.123);\n  });\n\n  it(\"returns basic info about formatting options for a single string\", function() {\n    parser = new NumberParse(\"en\", \"USD\");\n\n    assert.isNull(parser.parse(\"\"));\n    assert.isNull(parser.parse(\"a b\"));\n\n    const defaultOptions = {\n      isCurrency: false,\n      isParenthesised: false,\n      hasDigitGroupSeparator: false,\n      isScientific: false,\n      isPercent: false,\n    };\n    assert.deepEqual(parser.parse(\"1\"),\n      { result: 1, cleaned: \"1\", options: defaultOptions });\n    assert.deepEqual(parser.parse(\"$1\"),\n      { result: 1, cleaned: \"1\", options: { ...defaultOptions, isCurrency: true } });\n    assert.deepEqual(parser.parse(\"100%\"),\n      { result: 1, cleaned: \"100\", options: { ...defaultOptions, isPercent: true } });\n    assert.deepEqual(parser.parse(\"1,000\"),\n      { result: 1000, cleaned: \"1000\", options: { ...defaultOptions, hasDigitGroupSeparator: true } });\n    assert.deepEqual(parser.parse(\"1E2\"),\n      { result: 100, cleaned: \"1e2\", options: { ...defaultOptions, isScientific: true } });\n    assert.deepEqual(parser.parse(\"$1,000\"),\n      {\n        result: 1000, cleaned: \"1000\", options: {\n          ...defaultOptions, isCurrency: true, hasDigitGroupSeparator: true,\n        },\n      });\n  });\n\n  it(\"guesses formatting options\", function() {\n    parser = new NumberParse(\"en\", \"USD\");\n\n    assert.deepEqual(parser.guessOptions([]), {});\n    assert.deepEqual(parser.guessOptions([\"\"]), {});\n    assert.deepEqual(parser.guessOptions([null]), {});\n    assert.deepEqual(parser.guessOptions([\"\", null]), {});\n    assert.deepEqual(parser.guessOptions([\"abc\"]), {});\n    assert.deepEqual(parser.guessOptions([\"1\"]), {});\n    assert.deepEqual(parser.guessOptions([\"1\", \"\", null, \"abc\"]), {});\n\n    assert.deepEqual(parser.guessOptions([\"$1,000\"]), { numMode: \"currency\", decimals: 0 });\n    assert.deepEqual(parser.guessOptions([\"1,000%\"]), { numMode: \"percent\" });\n    assert.deepEqual(parser.guessOptions([\"1,000\"]), { numMode: \"decimal\" });\n    assert.deepEqual(parser.guessOptions([\"1E2\"]), { numMode: \"scientific\" });\n\n    // Choose the most common mode when there are several candidates\n    assert.deepEqual(parser.guessOptions([\"$1\", \"$2\", \"3%\"]), { numMode: \"currency\", decimals: 0 });\n    assert.deepEqual(parser.guessOptions([\"$1\", \"2%\", \"3%\"]), { numMode: \"percent\" });\n\n    assert.deepEqual(parser.guessOptions([\"(2)\"]), { numSign: \"parens\" });\n    assert.deepEqual(parser.guessOptions([\"(2)\", \"3\"]), { numSign: \"parens\" });\n    // If we see a negative number not surrounded by parens, assume that other parens mean something else\n    assert.deepEqual(parser.guessOptions([\"(2)\", \"-3\"]), {});\n    assert.deepEqual(parser.guessOptions([\"($2)\"]), { numSign: \"parens\", numMode: \"currency\", decimals: 0 });\n\n    // Guess 'decimal' (i.e. with thousands separators) even if most numbers don't have separators\n    assert.deepEqual(parser.guessOptions([\"1\", \"10\", \"100\", \"1,000\"]), { numMode: \"decimal\" });\n\n    // For USD, currencies are formatted with minimum 2 decimal places by default,\n    // so if the data doesn't have that many decimals we have to explicitly specify the number of decimals, default 0.\n    // The number of digits for other currencies is defaultNumDecimalsCurrency, tested a bit further down.\n    assert.deepEqual(parser.guessOptions([\"$1\"]), { numMode: \"currency\", decimals: 0 });\n    assert.deepEqual(parser.guessOptions([\"$1.2\"]), { numMode: \"currency\", decimals: 0 });\n    assert.deepEqual(parser.guessOptions([\"$1.23\"]), { numMode: \"currency\" });\n    assert.deepEqual(parser.guessOptions([\"$1.234\"]), { numMode: \"currency\", maxDecimals: 3 });\n\n    // Otherwise decimal places are guessed based on trailing zeroes\n    assert.deepEqual(parser.guessOptions([\"$1.0\"]), { numMode: \"currency\", decimals: 1 });\n    assert.deepEqual(parser.guessOptions([\"$1.00\"]), { numMode: \"currency\", decimals: 2 });\n    assert.deepEqual(parser.guessOptions([\"$1.000\"]), { numMode: \"currency\", decimals: 3 });\n\n    assert.deepEqual(parser.guessOptions([\"1E2\"]), { numMode: \"scientific\" });\n    assert.deepEqual(parser.guessOptions([\"1.3E2\"]), { numMode: \"scientific\" });\n    assert.deepEqual(parser.guessOptions([\"1.34E2\"]), { numMode: \"scientific\" });\n    assert.deepEqual(parser.guessOptions([\"1.0E2\"]), { numMode: \"scientific\", decimals: 1 });\n    assert.deepEqual(parser.guessOptions([\"1.30E2\"]), { numMode: \"scientific\", decimals: 2 });\n\n    assert.equal(parser.defaultNumDecimalsCurrency, 2);\n    parser = new NumberParse(\"en\", \"TND\");\n    assert.equal(parser.defaultNumDecimalsCurrency, 3);\n    parser = new NumberParse(\"en\", \"ZMK\");\n    assert.equal(parser.defaultNumDecimalsCurrency, 0);\n  });\n\n  // Nice mixture of numbers of different sizes and containing all digits\n  const numbers = [\n    ..._.range(1, 12),\n    ..._.range(3, 20).map(n => Math.pow(3, n)),\n    ..._.range(10).map(n => Math.pow(10, -n) * 1234560798),\n  ];\n  numbers.push(...numbers.map(n => -n));\n  numbers.push(...numbers.map(n => 1 / n));\n  numbers.push(0);  // added at the end because of the division just before\n\n  // Formatter to compare numbers that only differ because of floating point precision errors\n  const basicFormatter = Intl.NumberFormat(\"en\", {\n    maximumSignificantDigits: 15,\n    useGrouping: false,\n  });\n\n  // All values supported by parseNumMode\n  const numModes: (NumMode | undefined)[] = [\"currency\", \"decimal\", \"percent\", \"scientific\", undefined];\n\n  // Generate a test suite for every supported locale\n  for (const locale of locales) {\n    describe(`with ${locale.code} locale (${locale.name})`, function() {\n      const currency = getCurrency(locale.code);\n\n      beforeEach(() => {\n        parser = new NumberParse(locale.code, currency);\n      });\n\n      it(\"has sensible parser attributes\", function() {\n        // These don't strictly need to have length 1, but it's nice to know\n        assert.lengthOf(parser.percentageSymbol, 1);\n        assert.lengthOf(parser.minusSign, 1);\n        assert.lengthOf(parser.decimalSeparator, 1);\n\n        // These *do* need to be a single character since the regex uses `[]`.\n        assert.lengthOf(parser.digitGroupSeparator, 1);\n        // This is the only symbol that's allowed to be empty\n        assert.include([0, 1], parser.digitGroupSeparatorCurrency.length);\n\n        assert.isNotEmpty(parser.exponentSeparator);\n        assert.isNotEmpty(parser.currencySymbol);\n\n        const symbols = [\n          parser.percentageSymbol,\n          parser.minusSign,\n          parser.decimalSeparator,\n          parser.digitGroupSeparator,\n          parser.exponentSeparator,\n          parser.currencySymbol,\n          ...parser.digitsMap.keys(),\n        ];\n\n        // All the symbols must be distinct\n        assert.equal(symbols.length, new Set(symbols).size);\n\n        // The symbols mustn't contain characters that the parser removes (e.g. spaces)\n        // or they won't be replaced correctly.\n        // The digit group separators are OK because they're removed anyway, and often the separator is a space.\n        // Currency is OK because it gets removed before these characters.\n        for (const symbol of symbols) {\n          if (![\n            parser.digitGroupSeparator,\n            parser.digitGroupSeparatorCurrency,\n            parser.currencySymbol,\n          ].includes(symbol)) {\n            assert.equal(symbol, symbol.replace(NumberParse.removeCharsRegex, \"REMOVED\"));\n          }\n        }\n\n        // Decimal and digit separators have to be different.\n        // We checked digitGroupSeparator already with the Set above,\n        // but not digitGroupSeparatorCurrency because it can equal digitGroupSeparator.\n        assert.notEqual(parser.decimalSeparator, parser.digitGroupSeparator);\n        assert.notEqual(parser.decimalSeparator, parser.digitGroupSeparatorCurrency);\n\n        for (const key of parser.digitsMap.keys()) {\n          assert.lengthOf(key, 1);\n          assert.lengthOf(parser.digitsMap.get(key)!, 1);\n        }\n      });\n\n      it(\"can parse formatted numbers\", function() {\n        for (const numMode of numModes) {\n          const formatter = Intl.NumberFormat(locale.code, {\n            ...parseNumMode(numMode, currency),\n            maximumFractionDigits: 15,\n            maximumSignificantDigits: 15,\n          });\n          for (const num of numbers) {\n            const fnum = formatter.format(num);\n            const formattedNumbers = [fnum];\n\n            if (num > 0 && fnum.startsWith(\"0\")) {\n              // E.g. test that '.5' is parsed as '0.5'\n              formattedNumbers.push(fnum.substring(1));\n            }\n\n            if (num < 0) {\n              formattedNumbers.push(`(${formatter.format(-num)})`);\n            }\n\n            for (const formatted of formattedNumbers) {\n              const parsed = parser.parse(formatted)?.result;\n\n              // Fast check, particularly to avoid formatting the numbers\n              // Makes the tests about 1.5s/30% faster.\n              if (parsed === num) {\n                continue;\n              }\n\n              try {\n                assert.exists(parsed);\n                assert.equal(\n                  basicFormatter.format(parsed!),\n                  basicFormatter.format(num),\n                );\n              } catch (e) {\n                // Handy information for understanding failures\n                console.log({\n                  num, formatted, parsed, numMode, parser,\n                  parts: formatter.formatToParts(num),\n                  formattedChars: [...formatted].map(char => ({\n                    char,\n                    // To see invisible characters, e.g. RTL/LTR marks\n                    codePoint: char.codePointAt(0),\n                    codePointHex: char.codePointAt(0)!.toString(16),\n                  })),\n                  formatterOptions: formatter.resolvedOptions(),\n                });\n                throw e;\n              }\n            }\n          }\n        }\n      });\n    });\n  }\n});\n"
  },
  {
    "path": "test/common/PluginInstance.ts",
    "content": "import * as browserGlobals from \"app/client/lib/browserGlobals\";\nimport { LocalPlugin } from \"app/common/plugin\";\nimport { PluginInstance } from \"app/common/PluginInstance\";\nimport * as clientUtil from \"test/client/clientUtil\";\n\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\nconst G: any = browserGlobals.get(\"$\");\n\ndescribe(\"PluginInstance\", function() {\n  clientUtil.setTmpMochaGlobals();\n  it(\"can manages render target\", function() {\n    const plugin = new PluginInstance({ manifest: { contributions: {} } } as LocalPlugin, {});\n    assert.throws(() => plugin.getRenderTarget(2), /Unknown render target.*/);\n    assert.doesNotThrow(() => plugin.getRenderTarget(\"fullscreen\"));\n    const renderTarget1 = sinon.spy();\n    const renderTarget2 = sinon.spy();\n\n    const el1 = G.$(\"<h1>el1</h1>\");\n    const el2 = G.$(\"<h1>el2</h1>\");\n\n    const handle1 = plugin.addRenderTarget(renderTarget1);\n    plugin.getRenderTarget(handle1)(el1, {});\n    sinon.assert.calledWith(renderTarget1, el1, {});\n    plugin.removeRenderTarget(handle1);\n    assert.throw(() => plugin.getRenderTarget(handle1));\n\n    const handle2 = plugin.addRenderTarget(renderTarget2);\n    plugin.getRenderTarget(handle2)(el2 as HTMLElement, {});\n    sinon.assert.calledWith(renderTarget2, el2, {});\n  });\n});\n"
  },
  {
    "path": "test/common/RecentItems.js",
    "content": "var assert = require(\"chai\").assert;\nvar RecentItems = require(\"app/common/RecentItems\");\n\ndescribe(\"RecentItems\", function() {\n  let simpleList = [\"foo\", \"bar\", \"baz\"];\n\n  let objList = [\n    { name: \"foo\", path: \"/foo\" },\n    { name: \"bar\", path: \"/bar\" },\n    { name: \"baz\", path: \"/baz\" },\n  ];\n\n  describe(\"listItems\", function() {\n    it(\"should return a valid list\", function() {\n      let recentItems = new RecentItems({\n        intialItems: simpleList\n      });\n      assert.deepEqual(recentItems.listItems(), [\"foo\", \"bar\", \"baz\"]);\n    });\n\n    it(\"should return a valid list given a keyFunc\", function() {\n      let recentItems = new RecentItems({\n        intialItems: objList,\n        keyFunc: item => item.path\n      });\n      assert.deepEqual(recentItems.listItems(), [\n        { name: \"foo\", path: \"/foo\" },\n        { name: \"bar\", path: \"/bar\" },\n        { name: \"baz\", path: \"/baz\" },\n      ]);\n    });\n\n    it(\"should produce a list of objects with unique keys\", function() {\n      let recentItems = new RecentItems({\n        intialItems: [\n          { name: \"foo\", path: \"/foo\" },\n          { name: \"bar\", path: \"/bar\" },\n          { name: \"foo\", path: \"/foo\" },\n          { name: \"baz\", path: \"/baz\" },\n          { name: \"foobar\", path: \"/foo\" },\n        ],\n        keyFunc: item => item.path\n      });\n      assert.deepEqual(recentItems.listItems(), [\n        { name: \"bar\", path: \"/bar\" },\n        { name: \"baz\", path: \"/baz\" },\n        { name: \"foobar\", path: \"/foo\" }\n      ]);\n      let recentItems2 = new RecentItems({\n        intialItems: simpleList,\n      });\n      assert.deepEqual(recentItems2.listItems(), [\"foo\", \"bar\", \"baz\"]);\n      for(let i = 0; i < 30; i++) {\n        recentItems2.addItems(simpleList);\n      }\n      assert.deepEqual(recentItems2.listItems(), [\"foo\", \"bar\", \"baz\"]);\n    });\n\n    it(\"should produce a list with the correct max length\", function() {\n      let recentItems = new RecentItems({\n        intialItems: objList,\n        maxCount: 2,\n        keyFunc: item => item.path\n      });\n      assert.deepEqual(recentItems.listItems(), [\n        { name: \"bar\", path: \"/bar\" },\n        { name: \"baz\", path: \"/baz\" }\n      ]);\n      recentItems.addItem({ name: \"foo\", path: \"/foo\" });\n      assert.deepEqual(recentItems.listItems(), [\n        { name: \"baz\", path: \"/baz\" },\n        { name: \"foo\", path: \"/foo\" }\n      ]);\n      recentItems.addItem({name: \"BAZ\", path: \"/baz\"});\n      assert.deepEqual(recentItems.listItems(), [\n        { name: \"foo\", path: \"/foo\" },\n        { name: \"BAZ\", path: \"/baz\" }\n      ]);\n      let recentItems2 = new RecentItems({\n        intialItems: simpleList,\n        maxCount: 10\n      });\n      let alphabet = \"abcdefghijklmnopqrstuvwxyz\".split(\"\");\n      recentItems2.addItems(alphabet);\n      assert.deepEqual(recentItems2.listItems(), \"qrstuvwxyz\".split(\"\"));\n      recentItems2.addItem(\"a\");\n      assert.deepEqual(recentItems2.listItems(), \"rstuvwxyza\".split(\"\"));\n      recentItems2.addItem(\"r\");\n      assert.deepEqual(recentItems2.listItems(), \"stuvwxyzar\".split(\"\"));\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/RefCountMap.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { RefCountMap } from \"app/common/RefCountMap\";\n\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\nfunction assertResetSingleCall(spy: sinon.SinonSpy, context: any, ...args: any[]): void {\n  sinon.assert.calledOnce(spy);\n  sinon.assert.calledOn(spy, context);\n  sinon.assert.calledWithExactly(spy, ...args);\n  spy.resetHistory();\n}\n\ndescribe(\"RefCountMap\", function() {\n  it(\"should dispose items when ref-count returns to 0\", function() {\n    const create = sinon.stub().callsFake(key => key.toUpperCase());\n    const dispose = sinon.spy();\n    const m = new RefCountMap<string, string>({ create, dispose, gracePeriodMs: 0 });\n\n    const subFoo1 = m.use(\"foo\");\n    assert.strictEqual(subFoo1.get(), \"FOO\");\n    assertResetSingleCall(create, null, \"foo\");\n\n    const subBar1 = m.use(\"bar\");\n    assert.strictEqual(subBar1.get(), \"BAR\");\n    assertResetSingleCall(create, null, \"bar\");\n\n    const subFoo2 = m.use(\"foo\");\n    assert.strictEqual(subFoo2.get(), \"FOO\");\n    sinon.assert.notCalled(create);\n\n    // Now dispose one by one.\n    subFoo1.dispose();\n    sinon.assert.notCalled(dispose);\n    subBar1.dispose();\n    assertResetSingleCall(dispose, null, \"bar\", \"BAR\");\n\n    // An extra subscription increases refCount, so subFoo2.dispose will not yet dispose it.\n    const subFoo3 = m.use(\"foo\");\n    assert.strictEqual(subFoo3.get(), \"FOO\");\n    sinon.assert.notCalled(create);\n\n    subFoo2.dispose();\n    sinon.assert.notCalled(dispose);\n    subFoo3.dispose();\n    assertResetSingleCall(dispose, null, \"foo\", \"FOO\");\n  });\n\n  it(\"should respect the grace period\", async function() {\n    const create = sinon.stub().callsFake(key => key.toUpperCase());\n    const dispose = sinon.spy();\n    const m = new RefCountMap<string, string>({ create, dispose, gracePeriodMs: 60 });\n\n    const subFoo1 = m.use(\"foo\");\n    assert.strictEqual(subFoo1.get(), \"FOO\");\n    assertResetSingleCall(create, null, \"foo\");\n\n    const subBar1 = m.use(\"bar\");\n    assert.strictEqual(subBar1.get(), \"BAR\");\n    assertResetSingleCall(create, null, \"bar\");\n\n    // Disposal is not immediate, we have some time.\n    subFoo1.dispose();\n    subBar1.dispose();\n    sinon.assert.notCalled(dispose);\n\n    // Wait a bit and add more usage to one of the keys.\n    await delay(30);\n\n    const subFoo2 = m.use(\"foo\");\n    assert.strictEqual(subFoo2.get(), \"FOO\");\n    sinon.assert.notCalled(create);\n\n    // Grace period hasn't expired yet, so dispose isn't called yet.\n    sinon.assert.notCalled(dispose);\n\n    // Now wait for the grace period to end.\n    await delay(40);\n\n    // Ensure that bar's disposal has run now, but not foo's.\n    assertResetSingleCall(dispose, null, \"bar\", \"BAR\");\n\n    // Dispose the second usage, and wait for the full grace period.\n    subFoo2.dispose();\n    await delay(70);\n    assertResetSingleCall(dispose, null, \"foo\", \"FOO\");\n  });\n\n  it(\"should dispose immediately on clear\", async function() {\n    const create = sinon.stub().callsFake(key => key.toUpperCase());\n    const dispose = sinon.spy();\n    const m = new RefCountMap<string, string>({ create, dispose, gracePeriodMs: 0 });\n    const subFoo1 = m.use(\"foo\");\n    const subBar1 = m.use(\"bar\");\n    const subFoo2 = m.use(\"foo\");\n    m.dispose();\n\n    assert.equal(dispose.callCount, 2);\n    assert.deepEqual(dispose.args, [[\"foo\", \"FOO\"], [\"bar\", \"BAR\"]]);\n    dispose.resetHistory();\n\n    // Should be a no-op to dispose subscriptions after RefCountMap is disposed.\n    subFoo1.dispose();\n    subFoo2.dispose();\n    subBar1.dispose();\n    sinon.assert.notCalled(dispose);\n\n    // It should not be a matter of gracePeriod, but make sure by waiting a bit.\n    await delay(30);\n    sinon.assert.notCalled(dispose);\n  });\n\n  it(\"should be safe to purge a key\", async function() {\n    const create = sinon.stub().callsFake(key => key.toUpperCase());\n    const dispose = sinon.spy();\n    const m = new RefCountMap<string, string>({ create, dispose, gracePeriodMs: 0 });\n    const subFoo1 = m.use(\"foo\");\n    const subBar1 = m.use(\"bar\");\n    const subFoo2 = m.use(\"foo\");\n\n    m.purgeKey(\"foo\");\n    assertResetSingleCall(dispose, null, \"foo\", \"FOO\");\n    m.purgeKey(\"bar\");\n    assertResetSingleCall(dispose, null, \"bar\", \"BAR\");\n\n    // The tricky case is when a new \"foo\" key is created after the purge.\n    const subFooNew1 = m.use(\"foo\");\n    const subBarNew1 = m.use(\"bar\");\n\n    // Should be a no-op to dispose purged subscriptions.\n    subFoo1.dispose();\n    subFoo2.dispose();\n    sinon.assert.notCalled(dispose);\n\n    // A new subscription with the same key should get disposed though.\n    subFooNew1.dispose();\n    assertResetSingleCall(dispose, null, \"foo\", \"FOO\");\n    subBarNew1.dispose();\n    assertResetSingleCall(dispose, null, \"bar\", \"BAR\");\n\n    // Still a no-op to dispose old purged subscriptions.\n    subBar1.dispose();\n    sinon.assert.notCalled(dispose);\n\n    // Ensure there are no scheduled disposals due to some other bug.\n    await delay(30);\n    sinon.assert.notCalled(dispose);\n  });\n\n  it(\"should not dispose a re-created key on timeout after purge\", async function() {\n    const create = sinon.stub().callsFake(key => key.toUpperCase());\n    const dispose = sinon.spy();\n    const m = new RefCountMap<string, string>({ create, dispose, gracePeriodMs: 60 });\n\n    const subFoo1 = m.use(\"foo\");\n    subFoo1.dispose();    // This schedules a disposal in 20ms\n    m.purgeKey(\"foo\");    // This should purge immediately AND unset the scheduled disposal\n    assertResetSingleCall(dispose, null, \"foo\", \"FOO\");\n\n    await delay(20);\n    const subFoo2 = m.use(\"foo\");   // Should not be affected by the scheduled disposal.\n    await delay(100);               // \"foo\" stays beyond grace period, since it's being used.\n    sinon.assert.notCalled(dispose);\n\n    subFoo2.dispose();              // Once disposed, it stays for grace period\n    await delay(20);\n    sinon.assert.notCalled(dispose);\n    await delay(100);               // And gets disposed after it.\n    assertResetSingleCall(dispose, null, \"foo\", \"FOO\");\n  });\n});\n"
  },
  {
    "path": "test/common/RelativeDates.ts",
    "content": "import { DEPS, getMatchingDoubleRelativeDate } from \"app/client/ui/RelativeDatesOptions\";\nimport { diffUnit } from \"app/common/RelativeDates\";\n\nimport { assert } from \"chai\";\nimport moment from \"moment-timezone\";\nimport sinon from \"sinon\";\n\nconst CURRENT_TIME = moment.tz(\"2022-09-26T12:13:32.018Z\", \"utc\");\nconst now = () => moment(CURRENT_TIME);\n\ndescribe(\"RelativeDates\", function() {\n  const sandbox = sinon.createSandbox();\n\n  before(() => {\n    sinon.stub(DEPS, \"getCurrentTime\").returns(now());\n  });\n\n  after(() => {\n    sandbox.restore();\n  });\n\n  describe(\"getMatchingDoubleRelativeDate\", function() {\n    it(\"should work correctly\", function() {\n      assert.deepEqual(\n        getMatchingDoubleRelativeDate(getDateValue(\"10/1/2022\"), { unit: \"month\" }),\n        [{ unit: \"month\", quantity: 1 }],\n      );\n\n      assert.deepEqual(\n        getMatchingDoubleRelativeDate(getDateValue(\"9/19/2022\"), { unit: \"week\" }),\n        [{ unit: \"week\", quantity: -1 }, { quantity: 1, unit: \"day\" }],\n      );\n\n      assert.deepEqual(\n        getMatchingDoubleRelativeDate(getDateValue(\"9/21/2022\"), { unit: \"week\" }),\n        [{ unit: \"week\", quantity: -1 }, { quantity: 3, unit: \"day\" }],\n      );\n\n      assert.deepEqual(\n        getMatchingDoubleRelativeDate(getDateValue(\"9/30/2022\"), { unit: \"month\" }),\n        [{ unit: \"month\", quantity: 0 }, { quantity: 29, unit: \"day\" }],\n      );\n\n      assert.deepEqual(\n        getMatchingDoubleRelativeDate(getDateValue(\"10/1/2022\"), { unit: \"month\" }),\n        [{ unit: \"month\", quantity: 1 }],\n      );\n    });\n  });\n\n  describe(\"diffUnit\", function() {\n    it(\"should work correctly\", function() {\n      assert.equal(diffUnit(moment(\"2022-09-30\"), moment(\"2022-10-01\"), \"month\"), -1);\n      assert.equal(diffUnit(moment(\"2022-10-01\"), moment(\"2022-09-30\"), \"month\"), 1);\n      assert.equal(diffUnit(moment(\"2022-09-30\"), moment(\"2022-10-01\"), \"week\"), 0);\n      assert.equal(diffUnit(moment(\"2022-09-30\"), moment(\"2022-10-02\"), \"week\"), -1);\n    });\n  });\n});\n\nfunction getDateValue(date: string): number {\n  return moment.tz(date, \"MM-DD-YYYY\", \"utc\").valueOf() / 1000;\n}\n"
  },
  {
    "path": "test/common/SortFunc.ts",
    "content": "import { emptyCompare, typedCompare } from \"app/common/SortFunc\";\n\nimport { format } from \"util\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"SortFunc\", function() {\n  it(\"should be transitive for values of different types\", function() {\n    const values = [\n      -10, 0, 2, 10.5,\n      null,\n      [\"a\"], [\"b\"], [\"b\", 1], [\"b\", 1, 2], [\"b\", 1, \"10\"], [\"c\"],\n      \"10.5\", \"2\", \"a\",\n      undefined as any,\n    ];\n\n    // Check that sorting works as expected (the values above are already sorted).\n    const sorted = values.slice(0);\n    sorted.sort(typedCompare);\n    assert.deepEqual(sorted, values);\n\n    // Check comparisons between each possible pair of values above.\n    for (let i = 0; i < values.length; i++) {\n      assert.equal(typedCompare(values[i], values[i]), 0, `Expected ${format(values[i])} == ${format(values[i])}`);\n      for (let j = i + 1; j < values.length; j++) {\n        assert.equal(typedCompare(values[i], values[j]), -1, `Expected ${format(values[i])} < ${format(values[j])}`);\n        assert.equal(typedCompare(values[j], values[i]), 1, `Expected ${format(values[j])} > ${format(values[i])}`);\n      }\n    }\n  });\n\n  it(\"typedCompare should treat empty values as equal\", function() {\n    assert.equal(typedCompare(null, null), 0);\n    assert.equal(typedCompare(\"\", \"\"), 0);\n  });\n\n  describe(\"emptyCompare\", function() {\n    it(\"should work correctly \", function() {\n      const comparator = emptyCompare(typedCompare);\n      assert.equal(comparator(null, null), 0);\n      assert.equal(comparator(\"\", \"\"), 0);\n\n      assert.equal(comparator(null, 0), 1);\n      assert.equal(comparator(null, -1), 1);\n      assert.equal(comparator(null, 1), 1);\n      assert.equal(comparator(null, \"a\"), 1);\n      assert.equal(comparator(null, \"z\"), 1);\n\n      assert.equal(comparator(0, null), -1);\n      assert.equal(comparator(-1, null), -1);\n      assert.equal(comparator(1, null), -1);\n      assert.equal(comparator(\"a\", null), -1);\n      assert.equal(comparator(\"z\", null), -1);\n    });\n\n    it(\"should keep sorting order consistent amongst empty values\", function() {\n      // values1 and values2 have same values but in different order. Sorting them with emptyCompare\n      // function should yield same results.\n      const values1 = [\"\", null, undefined, 2, 3, 4];\n      const values2 = [undefined, null, \"\", 2, 3, 4];\n      const comparator = emptyCompare(typedCompare);\n      values1.sort(comparator);\n      values2.sort(comparator);\n      assert.deepEqual(values1, values2);\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/StringUnion.ts",
    "content": "import { StringUnion } from \"app/common/StringUnion\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"StringUnion\", function() {\n  // Create Dog type\n  const Dog = StringUnion(\n    \"bulldog\",\n    \"poodle\",\n    \"greyhound\",\n  );\n  type Dog = typeof Dog.type;\n\n  // Create Cat type\n  const Cat = StringUnion(\n    \"siamese\",\n    \"sphynx\",\n    \"bengal\",\n  );\n  type Cat = typeof Cat.type;\n\n  it(\"should provide check and guard functions\", function() {\n    let dog: Dog;\n    let cat: Cat;\n\n    const greyhound = \"greyhound\";\n    const bengal = \"bengal\";\n    const giraffe = \"giraffe\";\n\n    // Use Dog check function.\n    dog = Dog.check(greyhound);\n    assert.equal(dog, greyhound);\n\n    assert.doesNotThrow(() => { dog = Dog.check(greyhound); });\n    assert.throws(() => { dog = Dog.check(bengal); },\n      `Value '\"bengal\"' is not assignable to type '\"bulldog\" | \"poodle\" | \"greyhound\"'`);\n    assert.throws(() => { dog = Dog.check(giraffe); },\n      `Value '\"giraffe\"' is not assignable to type '\"bulldog\" | \"poodle\" | \"greyhound\"'`);\n\n    // Use Cat check function.\n    cat = Cat.check(bengal);\n    assert.equal(cat, bengal);\n\n    assert.doesNotThrow(() => { cat = Cat.check(bengal); });\n    assert.throws(() => { cat = Cat.check(greyhound); },\n      `Value '\"greyhound\"' is not assignable to type '\"siamese\" | \"sphynx\" | \"bengal\"'`);\n    assert.throws(() => { cat = Cat.check(giraffe); },\n      `Value '\"giraffe\"' is not assignable to type '\"siamese\" | \"sphynx\" | \"bengal\"'`);\n\n    // Use Dog guard function.\n    assert.isTrue(Dog.guard(greyhound));\n    assert.isFalse(Dog.guard(bengal));\n    assert.isFalse(Dog.guard(giraffe));\n\n    // Use Cat guard function.\n    assert.isTrue(Cat.guard(bengal));\n    assert.isFalse(Cat.guard(greyhound));\n    assert.isFalse(Cat.guard(giraffe));\n  });\n});\n"
  },
  {
    "path": "test/common/TableData.ts",
    "content": "import { CellValue, TableDataAction } from \"app/common/DocActions\";\nimport { MetaTableData, TableData } from \"app/common/TableData\";\n\nimport { assert } from \"chai\";\nimport { unzip, zipObject } from \"lodash\";\n\ndescribe(\"TableData\", function() {\n  const sampleData: TableDataAction = [\"TableData\", \"Foo\", [1, 4, 5, 7], {\n    city: [\"New York\", \"Boston\", \"Boston\", \"Seattle\"],\n    state: [\"NY\", \"MA\", \"MA\", \"WA\"],\n    amount: [5, 4, \"NA\", 2],\n    bool: [true, true, false, false],\n  }];\n\n  // Transpose the given matrix. If empty, it's considered to consist of 0 rows and\n  // colArray.length columns, so that the transpose has colArray.length empty rows.\n  function transpose<T>(matrix: T[][], colArray: any[]): T[][] {\n    return matrix.length > 0 ? unzip(matrix) : colArray.map(c => []);\n  }\n\n  function verifyTableData(t: TableData, colIds: string[], data: CellValue[][]): void {\n    const idIndex = colIds.indexOf(\"id\");\n    assert(idIndex !== -1, \"verifyTableData expects 'id' column\");\n    const rowIds: number[] = data.map(row => row[idIndex]) as number[];\n    assert.strictEqual(t.numRecords(), data.length);\n    assert.sameMembers(t.getColIds(), colIds);\n    assert.deepEqual(t.getSortedRowIds(), rowIds);\n    assert.sameMembers(Array.from(t.getRowIds()), rowIds);\n    const transposed = transpose(data, colIds);\n\n    // Verify data using .getValue()\n    assert.deepEqual(rowIds.map(r => colIds.map(c => t.getValue(r, c))), data);\n\n    // Verify data using getRowPropFunc()\n    assert.deepEqual(colIds.map(c => rowIds.map(t.getRowPropFunc(c))), transposed);\n\n    // Verify data using getRecord()\n    const expRecords = data.map((row, i) => zipObject(colIds, row));\n    assert.deepEqual(rowIds.map(r => t.getRecord(r)) as any, expRecords);\n\n    // Verify data using getRecords().\n    assert.sameDeepMembers(t.getRecords(), expRecords);\n\n    // Verify data using getColValues().\n    const rawOrderedData = t.getRowIds().map(r => data[rowIds.indexOf(r)]);\n    const rawOrderedTransposed = transpose(rawOrderedData, colIds);\n    assert.deepEqual(colIds.map(c => t.getColValues(c)), rawOrderedTransposed);\n  }\n\n  it(\"should start out empty and support loadData\", function() {\n    const t = new TableData(\"Foo\", null, { city: \"Text\", state: \"Text\", amount: \"Numeric\", bool: \"Bool\" });\n    assert.equal(t.tableId, \"Foo\");\n    assert.isFalse(t.isLoaded);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], []);\n\n    t.loadData(sampleData);\n    assert.isTrue(t.isLoaded);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"Boston\", \"MA\", 4, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Seattle\", \"WA\", 2, false],\n    ]);\n  });\n\n  it(\"should start out with data from constructor\", function() {\n    const t = new TableData(\"Foo\", sampleData, { city: \"Text\", state: \"Text\", amount: \"Numeric\", bool: \"Bool\" });\n    assert.equal(t.tableId, \"Foo\");\n    assert.isTrue(t.isLoaded);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"Boston\", \"MA\", 4, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Seattle\", \"WA\", 2, false],\n    ]);\n  });\n\n  it(\"should support filterRecords and filterRowIds\", function() {\n    const t = new TableData(\"Foo\", sampleData, { city: \"Text\", state: \"Text\", amount: \"Numeric\", bool: \"Bool\" });\n    assert.deepEqual(t.filterRecords({ state: \"MA\" }), [\n      { id: 4, city: \"Boston\", state: \"MA\", amount: 4, bool: true },\n      { id: 5, city: \"Boston\", state: \"MA\", amount: \"NA\", bool: false }]);\n    assert.deepEqual(t.filterRowIds({ state: \"MA\" }), [4, 5]);\n\n    // After removing and re-adding a record, indices change, but filter behavior should not.\n    // Notice sameDeepMembers() below, rather than deepEqual(), since order is not guaranteed.\n    t.dispatchAction([\"RemoveRecord\", \"Foo\", 4]);\n    t.dispatchAction([\"AddRecord\", \"Foo\", 4, { city: \"BOSTON\", state: \"MA\" }]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"BOSTON\", \"MA\", 0, false],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Seattle\", \"WA\", 2, false],\n    ]);\n    assert.deepEqual(t.filterRecords({ city: \"BOSTON\", amount: 0.0 }), [\n      { id: 4, city: \"BOSTON\", state: \"MA\", amount: 0, bool: false }]);\n    assert.deepEqual(t.filterRowIds({ city: \"BOSTON\", amount: 0.0 }), [4]);\n    assert.sameDeepMembers(t.filterRecords({ state: \"MA\" }), [\n      { id: 4, city: \"BOSTON\", state: \"MA\", amount: 0, bool: false },\n      { id: 5, city: \"Boston\", state: \"MA\", amount: \"NA\", bool: false }]);\n    assert.sameDeepMembers(t.filterRowIds({ state: \"MA\" }), [4, 5]);\n    assert.deepEqual(t.filterRecords({ city: \"BOSTON\", state: \"NY\" }), []);\n    assert.deepEqual(t.filterRowIds({ city: \"BOSTON\", state: \"NY\" }), []);\n    assert.sameDeepMembers(t.filterRecords({}), [\n      { id: 1, city: \"New York\", state: \"NY\", amount: 5, bool: true },\n      { id: 4, city: \"BOSTON\", state: \"MA\", amount: 0, bool: false },\n      { id: 5, city: \"Boston\", state: \"MA\", amount: \"NA\", bool: false },\n      { id: 7, city: \"Seattle\", state: \"WA\", amount: 2, bool: false },\n    ]);\n    assert.sameDeepMembers(t.filterRowIds({}), [1, 4, 5, 7]);\n  });\n\n  it(\"should support findMatchingRow\", function() {\n    const t = new TableData(\"Foo\", sampleData, { city: \"Text\", state: \"Text\", amount: \"Numeric\", bool: \"Bool\" });\n    assert.equal(t.findMatchingRowId({ state: \"MA\" }), 4);\n    assert.equal(t.findMatchingRowId({ state: \"MA\", bool: false }), 5);\n    assert.equal(t.findMatchingRowId({ city: \"Boston\", state: \"MA\", bool: true }), 4);\n    assert.equal(t.findMatchingRowId({ city: \"BOSTON\", state: \"NY\" }), 0);\n    assert.equal(t.findMatchingRowId({ statex: \"MA\" }), 0);\n    assert.equal(t.findMatchingRowId({ id: 7 }), 7);\n    assert.equal(t.findMatchingRowId({}), 1);\n  });\n\n  it(\"should support findRow and findRecord\", function() {\n    const t = new TableData(\"Foo\", sampleData, { city: \"Text\", state: \"Text\", amount: \"Numeric\", bool: \"Bool\" });\n    t.dispatchAction([\"UpdateRecord\", \"Foo\", 4, { city: \"BOSTON\" }]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"BOSTON\", \"MA\", 4, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Seattle\", \"WA\", 2, false],\n    ]);\n    assert.equal(t.findRow(\"city\", \"Boston\"), 5);\n    assert.equal(t.findRow(\"city\", \"Nowhere\"), 0);\n    assert.equal(t.findRow(\"id\", 4), 4);\n    assert.equal(t.findRow(\"id\", 44), 0);\n    assert.include([4, 5], t.findRow(\"state\", \"MA\"));\n\n    assert.deepEqual(t.findRecord(\"city\", \"Boston\"), { id: 5, city: \"Boston\", state: \"MA\", amount: \"NA\", bool: false });\n    assert.deepEqual(t.findRecord(\"city\", \"Nowhere\"), undefined);\n    assert.deepEqual(t.findRecord(\"id\", 4), { id: 4, city: \"BOSTON\", state: \"MA\", amount: 4, bool: true });\n    assert.deepEqual(t.findRecord(\"id\", 44), undefined);\n    assert.deepEqual(t.findRecord(\"state\", \"MA\")?.state, \"MA\");\n    assert.include([4, 5], t.findRecord(\"state\", \"MA\")?.id);\n\n    // Test also these methods for the MetaTableData class.\n    const sampleTableData: TableDataAction = [\"TableData\", \"_grist_Tables\", [2, 1], { tableId: [\"Foo\", \"Bar\"] }];\n    const meta = new MetaTableData(\"_grist_Tables\", sampleTableData, { tableId: \"Text\" });\n    assert.equal(meta.findRow(\"tableId\", \"Bar\"), 1);\n    assert.equal(meta.findRow(\"id\", 2), 2);\n    assert.equal(meta.findRow(\"tableId\", \"Baz\"), 0);\n    assert.deepEqual(meta.findRecord(\"tableId\", \"Bar\"), { id: 1, tableId: \"Bar\" } as any);\n    assert.deepEqual(meta.findRecord(\"id\", 2), { id: 2, tableId: \"Foo\" } as any);\n    assert.deepEqual(meta.findRecord(\"tableId\", \"Baz\"), undefined);\n  });\n\n  it(\"should allow getRowPropFunc to be used before loadData\", function() {\n    // This tests a potential bug when getRowPropFunc is saved from before loadData() is called.\n    const t = new TableData(\"Foo\", null, { city: \"Text\", state: \"Text\", amount: \"Numeric\", bool: \"Bool\" });\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], []);\n    assert.isFalse(t.isLoaded);\n\n    const getters = [\"id\", \"city\", \"state\", \"amount\", \"bool\"].map(c => t.getRowPropFunc(c));\n    t.loadData(sampleData);\n    assert.isTrue(t.isLoaded);\n    assert.deepEqual(t.getSortedRowIds().map(r => getters.map(getter => getter(r))), [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"Boston\", \"MA\", 4, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Seattle\", \"WA\", 2, false],\n    ]);\n  });\n\n  it(\"should handle Add/RemoveRecord\", function() {\n    const t = new TableData(\"Foo\", sampleData, { city: \"Text\", state: \"Text\", amount: \"Numeric\", bool: \"Bool\" });\n\n    t.dispatchAction([\"RemoveRecord\", \"Foo\", 4]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Seattle\", \"WA\", 2, false],\n    ]);\n\n    t.dispatchAction([\"RemoveRecord\", \"Foo\", 7]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n    ]);\n\n    t.dispatchAction([\"AddRecord\", \"Foo\", 4, { city: \"BOSTON\", state: \"MA\", amount: 4, bool: true }]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"BOSTON\", \"MA\", 4, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n    ]);\n\n    t.dispatchAction([\"BulkAddRecord\", \"Foo\", [8, 9], {\n      city: [\"X\", \"Y\"], state: [\"XX\", \"YY\"], amount: [0.1, 0.2], bool: [null, true],\n    }]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"BOSTON\", \"MA\", 4, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [8, \"X\",      \"XX\", 0.1, null],\n      [9, \"Y\",      \"YY\", 0.2, true],\n    ]);\n\n    t.dispatchAction([\"BulkRemoveRecord\", \"Foo\", [1, 4, 9]]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [8, \"X\",      \"XX\", 0.1, null],\n    ]);\n  });\n\n  it(\"should handle UpdateRecord\", function() {\n    const t = new TableData(\"Foo\", sampleData, { city: \"Text\", state: \"Text\", amount: \"Numeric\", bool: \"Bool\" });\n\n    t.dispatchAction([\"UpdateRecord\", \"Foo\", 4, { city: \"BOSTON\", amount: 0.1 }]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"BOSTON\", \"MA\", 0.1, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Seattle\", \"WA\", 2, false],\n    ]);\n\n    t.dispatchAction([\"BulkUpdateRecord\", \"Foo\", [1, 7], {\n      city: [\"X\", \"Y\"], state: [\"XX\", \"YY\"], amount: [0.1, 0.2], bool: [null, true],\n    }]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\"], [\n      [1, \"X\",      \"XX\", 0.1, null],\n      [4, \"BOSTON\", \"MA\", 0.1, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Y\",      \"YY\", 0.2, true],\n    ]);\n  });\n\n  it(\"should work correctly after AddColumn\", function() {\n    const t = new TableData(\"Foo\", sampleData, { city: \"Text\", state: \"Text\", amount: \"Numeric\", bool: \"Bool\" });\n\n    t.dispatchAction([\"AddColumn\", \"Foo\", \"foo\", { type: \"Text\", isFormula: false, formula: \"\" }]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\", \"foo\"], [\n      [1, \"New York\", \"NY\", 5, true,   \"\"],\n      [4, \"Boston\", \"MA\", 4, true,     \"\"],\n      [5, \"Boston\", \"MA\", \"NA\", false, \"\"],\n      [7, \"Seattle\", \"WA\", 2, false,   \"\"],\n    ]);\n\n    t.dispatchAction([\"UpdateRecord\", \"Foo\", 4, { city: \"BOSTON\", foo: \"hello\" }]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\", \"foo\"], [\n      [1, \"New York\", \"NY\", 5, true,   \"\"],\n      [4, \"BOSTON\", \"MA\", 4, true,     \"hello\"],\n      [5, \"Boston\", \"MA\", \"NA\", false, \"\"],\n      [7, \"Seattle\", \"WA\", 2, false,   \"\"],\n    ]);\n    t.dispatchAction([\"AddRecord\", \"Foo\", 8, { city: \"X\", state: \"XX\" }]);\n    verifyTableData(t, [\"id\", \"city\", \"state\", \"amount\", \"bool\", \"foo\"], [\n      [1, \"New York\", \"NY\", 5, true,   \"\"],\n      [4, \"BOSTON\", \"MA\", 4, true,     \"hello\"],\n      [5, \"Boston\", \"MA\", \"NA\", false, \"\"],\n      [7, \"Seattle\", \"WA\", 2, false,   \"\"],\n      [8, \"X\",       \"XX\", 0, false,   \"\"],\n    ]);\n  });\n\n  it(\"should work correctly after RenameColumn\", function() {\n    const t = new TableData(\"Foo\", sampleData, { city: \"Text\", state: \"Text\", amount: \"Numeric\", bool: \"Bool\" });\n\n    t.dispatchAction([\"RenameColumn\", \"Foo\", \"city\", \"ciudad\"]);\n    verifyTableData(t, [\"id\", \"ciudad\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"Boston\", \"MA\", 4, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Seattle\", \"WA\", 2, false],\n    ]);\n\n    t.dispatchAction([\"UpdateRecord\", \"Foo\", 4, { ciudad: \"BOSTON\", state: \"XX\" }]);\n    verifyTableData(t, [\"id\", \"ciudad\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"BOSTON\", \"XX\", 4, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Seattle\", \"WA\", 2, false],\n    ]);\n    t.dispatchAction([\"AddRecord\", \"Foo\", 8, { ciudad: \"X\", state: \"XX\" }]);\n    verifyTableData(t, [\"id\", \"ciudad\", \"state\", \"amount\", \"bool\"], [\n      [1, \"New York\", \"NY\", 5, true],\n      [4, \"BOSTON\", \"XX\", 4, true],\n      [5, \"Boston\", \"MA\", \"NA\", false],\n      [7, \"Seattle\", \"WA\", 2, false],\n      [8, \"X\",       \"XX\", 0, false],\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/common/Telemetry.ts",
    "content": "import { buildTelemetryEventChecker, TelemetryEvent } from \"app/common/Telemetry\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"Telemetry\", function() {\n  describe(\"buildTelemetryEventChecker\", function() {\n    it(\"returns a function that checks telemetry data\", function() {\n      assert.isFunction(buildTelemetryEventChecker(\"full\"));\n    });\n\n    it(\"does not throw if event and metadata are valid\", function() {\n      const checker = buildTelemetryEventChecker(\"full\");\n      assert.doesNotThrow(() => checker(\"apiUsage\", {\n        method: \"GET\",\n        userId: 1,\n        userAgent: \"node-fetch/1.0\",\n      }));\n      assert.doesNotThrow(() => checker(\"siteUsage\", {\n        siteId: 1,\n        siteType: \"team\",\n        inGoodStanding: true,\n        stripePlanId: \"stripePlanId\",\n        numDocs: 1,\n        numWorkspaces: 1,\n        numMembers: 1,\n        lastActivity: new Date(\"2022-12-30T01:23:45\"),\n        earliestDocCreatedAt: new Date(\"2022-12-29T00:01:02\"),\n      }));\n      assert.doesNotThrow(() => checker(\"watchedVideoTour\", {\n        watchTimeSeconds: 30,\n        userId: 1,\n        altSessionId: \"altSessionId\",\n      }));\n    });\n\n    it(\"does not throw when metadata is a subset of what's expected\", function() {\n      const checker = buildTelemetryEventChecker(\"full\");\n      assert.doesNotThrow(() => checker(\"documentUsage\", {\n        docIdDigest: \"docIdDigest\",\n        siteId: 1,\n        rowCount: 123,\n        attachmentTypes: [\"pdf\"],\n      }));\n    });\n\n    it(\"does not throw if all metadata is less than or equal to the expected telemetry level\", function() {\n      const checker = buildTelemetryEventChecker(\"limited\");\n      assert.doesNotThrow(() => checker(\"documentUsage\", {\n        rowCount: 123,\n      }));\n      assert.doesNotThrow(() => checker(\"siteUsage\", {\n        siteId: 1,\n        siteType: \"team\",\n        inGoodStanding: true,\n        numDocs: 1,\n        numWorkspaces: 1,\n        numMembers: 1,\n        lastActivity: new Date(\"2022-12-30T01:23:45\"),\n        earliestDocCreatedAt: new Date(\"2022-12-29T00:01:02\"),\n      }));\n      assert.doesNotThrow(() => checker(\"watchedVideoTour\", {\n        watchTimeSeconds: 30,\n      }));\n    });\n\n    it(\"throws if event is invalid\", function() {\n      const checker = buildTelemetryEventChecker(\"full\");\n      assert.throws(\n        () => checker(\"invalidEvent\" as TelemetryEvent, {}),\n        /Unknown telemetry event: invalidEvent/,\n      );\n    });\n\n    it(\"throws if metadata is invalid\", function() {\n      const checker = buildTelemetryEventChecker(\"full\");\n      assert.throws(\n        () => checker(\"apiUsage\", { invalidMetadata: \"123\" }),\n        /Unknown metadata for telemetry event apiUsage: invalidMetadata/,\n      );\n    });\n\n    it(\"throws if metadata types do not match expected types\", function() {\n      const checker = buildTelemetryEventChecker(\"full\");\n      assert.throws(\n        () => checker(\"siteUsage\", { siteId: \"1\" }),\n        /Telemetry metadata siteId of event siteUsage expected a value of type number but received a value of type string/,\n      );\n      assert.throws(\n        () => checker(\"siteUsage\", { lastActivity: 1234567890 }),\n        /Telemetry metadata lastActivity of event siteUsage expected a value of type Date or string but received a value of type number/,\n      );\n      assert.throws(\n        () => checker(\"siteUsage\", { inGoodStanding: \"true\" }),\n        /Telemetry metadata inGoodStanding of event siteUsage expected a value of type boolean but received a value of type string/,\n      );\n      assert.throws(\n        () => checker(\"siteUsage\", { numDocs: \"1\" }),\n        /Telemetry metadata numDocs of event siteUsage expected a value of type number but received a value of type string/,\n      );\n      assert.throws(\n        () => checker(\"documentUsage\", { attachmentTypes: \"1,2,3\" }),\n        /Telemetry metadata attachmentTypes of event documentUsage expected a value of type array but received a value of type string/,\n      );\n      assert.throws(\n        () => checker(\"documentUsage\", { attachmentTypes: [\".txt\", 1, true] }),\n        /Telemetry metadata attachmentTypes of event documentUsage expected a value of type string\\[\\] but received a value of type object\\[\\]/,\n      );\n    });\n\n    it(\"throws if event requires an elevated telemetry level\", function() {\n      const checker = buildTelemetryEventChecker(\"limited\");\n      assert.throws(\n        () => checker(\"signupVerified\", {}),\n        /Telemetry event signupVerified requires a minimum telemetry level of 2 but the current level is 1/,\n      );\n    });\n\n    it(\"throws if metadata requires an elevated telemetry level\", function() {\n      const checker = buildTelemetryEventChecker(\"limited\");\n      assert.throws(\n        () => checker(\"watchedVideoTour\", {\n          watchTimeSeconds: 30,\n          userId: 1,\n          altSessionId: \"altSessionId\",\n        }),\n        /Telemetry metadata userId of event watchedVideoTour requires a minimum telemetry level of 2 but the current level is 1/,\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/ThemePrefs.ts",
    "content": "import { componentsCssMapping, tokensCssMapping } from \"app/common/ThemePrefs\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"ThemePrefs\", function() {\n  /**\n   * A couple of theme variables are manually appended to the DOM outside of themes.\n   * Make sure custom theme variables don't conflict with those.\n   */\n  it(\"should have theme variables not conflicting with app internal vars\", function() {\n    const tokensCssVars = Object.values(tokensCssMapping);\n    const componentsCssVars = Object.values(componentsCssMapping);\n    const hardcodedCssVars = [\"theme-bg\", \"theme-bg-color\"];\n\n    const conflictingVars = [...tokensCssVars, ...componentsCssVars].filter(cssVar =>\n      hardcodedCssVars.includes(`theme-${cssVar}`),\n    );\n    if (conflictingVars.length) {\n      assert.fail(\n        \"Found conflicting theme CSS variables.\\n\" +\n        `Change these in ThemePrefs: ${conflictingVars.join(\", \")}`,\n      );\n    }\n  });\n\n  /**\n   * Make sure css variable names are unique between tokens and components,\n   * because the variables are appended to the DOM as a whole on the same lvl.\n   */\n  it(\"should have unique variable names between tokens and components\", function() {\n    const tokensCssVars = Object.values(tokensCssMapping);\n    const componentsCssVars = Object.values(componentsCssMapping);\n    const conflictingVars = tokensCssVars.filter(cssVar =>\n      componentsCssVars.includes(cssVar as typeof componentsCssVars[number]),\n    );\n    if (conflictingVars.length) {\n      assert.fail(\n        \"Found duplicate CSS variables.\\n\" +\n        `Change these in ThemePrefs: ${conflictingVars.join(\", \")}`,\n      );\n    }\n  });\n\n  /**\n   * Make sure the variables don't start like \"grist\" or \"theme\",\n   * because those prefixes are automatically added by the app when appending variables to the DOM.\n   */\n  it(\"should have all variable names avoid including any variable prefix\", function() {\n    const tokensCssVars = Object.values(tokensCssMapping);\n    const componentsCssVars = Object.values(componentsCssMapping);\n    const invalidVars = [...tokensCssVars, ...componentsCssVars].filter(cssVar =>\n      cssVar.startsWith(\"grist-\") ||\n      cssVar.startsWith(\"theme-\") ||\n      cssVar.startsWith(\"themes-\") ||\n      cssVar.startsWith(\"--\"),\n    );\n    if (invalidVars.length) {\n      assert.fail(\n        'CSS variable names must not start with \"grist-\", \"theme-\", \"themes-\" or \"--\".\\n' +\n        \"Change these in ThemePrefs: \" + invalidVars.join(\", \"),\n      );\n    }\n  });\n});\n"
  },
  {
    "path": "test/common/ValueFormatter.ts",
    "content": "import { DocumentSettings } from \"app/common/DocumentSettings\";\nimport { NumberFormatOptions } from \"app/common/NumberFormat\";\nimport { parseDateTime } from \"app/common/parseDate\";\nimport { createFormatter, DateTimeFormatOptions } from \"app/common/ValueFormatter\";\n\nimport { assert } from \"chai\";\n\nconst defaultDocSettings = {\n  locale: \"en-US\",\n};\n\nconst dateNumber = parseDateTime(\"2020-10-31 12:34:56\", {});\n\ndescribe(\"ValueFormatter\", function() {\n  describe(\"DateFormatter\", function() {\n    function check(expected: string, dateFormat?: string) {\n      for (const value of [dateNumber, [\"d\", dateNumber], [\"D\", dateNumber, \"UTC\"]]) {\n        const actual = createFormatter(\"Date\", { dateFormat }, defaultDocSettings).formatAny(value);\n        assert.equal(actual, expected, String(value));\n      }\n    }\n\n    it(\"should format dates\", function() {\n      check(\"31/10/2020\", \"DD/MM/YYYY\");\n      check(\"10/31/2020\", \"MM/DD/YYYY\");\n      check(\"2020-10-31\");  // ISO by default\n    });\n  });\n\n  describe(\"DateTimeFormatter\", function() {\n    function check(expected: string, options: DateTimeFormatOptions, timezone: string = \"UTC\") {\n      for (const value of [dateNumber, [\"d\", dateNumber], [\"D\", dateNumber, timezone]]) {\n        const actual = createFormatter(`DateTime:${timezone}`, options, defaultDocSettings).formatAny(value);\n        assert.equal(actual, expected, String(value));\n      }\n    }\n\n    it(\"should format datetimes\", function() {\n      check(\"31/10/2020 12:34:56\", { dateFormat: \"DD/MM/YYYY\", timeFormat: \"HH:mm:ss\" });\n      check(\"10/31/2020 12:34\", { dateFormat: \"MM/DD/YYYY\", timeFormat: \"HH:mm\" });\n      check(\"2020-10-31 12:34pm\", {});  // default formats\n\n      check(\"31/10/2020 08:34:56\", { dateFormat: \"DD/MM/YYYY\", timeFormat: \"HH:mm:ss\" }, \"America/New_York\");\n      check(\"10/31/2020 08:34\", { dateFormat: \"MM/DD/YYYY\", timeFormat: \"HH:mm\" }, \"America/New_York\");\n      check(\"2020-10-31 8:34am\", {}, \"America/New_York\");  // default formats\n    });\n  });\n\n  describe(\"NumericFormatter\", function() {\n    // Normalize Unicode right single quotation mark (U+2019) to ASCII apostrophe (U+0027).\n    // ICU versions disagree on which to use as the de-CH grouping separator:\n    // ICU 72-77 uses U+2019, ICU 78 reverted to U+0027. Since ICU has changed this\n    // multiple times, this workaround should stay until the separator stabilizes across\n    // all Node versions we test against.\n    function fmt(options: NumberFormatOptions, value: number, docSettings: DocumentSettings) {\n      return createFormatter(\"Numeric\", options, docSettings).formatAny(value).replace(/\\u2019/g, \"'\");\n    }\n\n    function checkDefault(options: NumberFormatOptions, value: number, expected: string) {\n      assert.equal(fmt(options, value, defaultDocSettings), expected);\n    }\n\n    it(\"should support plain format\", function() {\n      checkDefault({}, 0, \"0\");\n      checkDefault({}, NaN, \"NaN\");\n      checkDefault({}, Infinity, \"∞\");\n      checkDefault({}, -Infinity, \"-∞\");\n      checkDefault({}, 0.67, \"0.67\");\n      checkDefault({}, -1234.56, \"-1234.56\");\n      checkDefault({}, -121e+25, \"-1210000000000000000000000000\");\n      checkDefault({}, 1.015e-8, \"0.0000000102\");   // maxDecimals defaults to 10 here.\n    });\n\n    it(\"should support min/max decimals\", function() {\n      checkDefault({ decimals: 2, maxDecimals: 4 }, 12, \"12.00\");\n      checkDefault({ decimals: 2, maxDecimals: 4 }, -1.00015, \"-1.0002\");\n      checkDefault({ decimals: 2, maxDecimals: 6 }, -1.00015, \"-1.00015\");\n      checkDefault({ decimals: 6, maxDecimals: 6 }, -1.00015, \"-1.000150\");\n      checkDefault({ decimals: 6, maxDecimals: 0 }, -1.00015, \"-1.000150\");\n      checkDefault({ decimals: 0, maxDecimals: 2 }, 12.0001, \"12\");\n      checkDefault({ decimals: 0, maxDecimals: 2 }, 12.001, \"12\");\n      checkDefault({ decimals: 0, maxDecimals: 2 }, 12.005, \"12.01\");\n      checkDefault({ maxDecimals: 8 }, 1.015e-8, \"0.00000001\");\n      checkDefault({ maxDecimals: 7 }, 1.015e-8, \"0\");\n\n      // Out-of-range values get clamped.\n      checkDefault({ decimals: -2, maxDecimals: 3 }, -1.2345, \"-1.235\");\n      checkDefault({ decimals: -2, maxDecimals: -3 }, -1.2345, \"-1\");\n    });\n\n    it(\"should support thousand separators\", function() {\n      checkDefault({ numMode: \"decimal\", decimals: 4 }, 1000000, \"1,000,000.0000\");\n      checkDefault({ numMode: \"decimal\" }, -1234.56, \"-1,234.56\");\n      checkDefault({ numMode: \"decimal\" }, -121e+25, \"-1,210,000,000,000,000,000,000,000,000\");\n      checkDefault({ numMode: \"decimal\" }, 0.1234567, \"0.123\");    // maxDecimals defaults to 3 here\n      checkDefault({ numMode: \"decimal\" }, 1.015e-8, \"0\");\n      checkDefault({ numMode: \"decimal\", maxDecimals: 10 }, 1.015e-8, \"0.0000000102\");\n    });\n\n    it(\"should support currency mode\", function() {\n      // Test currency formatting with default doc settings (locale: 'en-US').\n      checkDefault({ numMode: \"currency\" }, 1000000, \"$1,000,000.00\");\n      checkDefault({ numMode: \"currency\", decimals: 4 }, 1000000, \"$1,000,000.0000\");\n      checkDefault({ numMode: \"currency\" }, -1234.565, \"-$1,234.57\");\n      checkDefault({ numMode: \"currency\" }, -121e+25, \"-$1,210,000,000,000,000,000,000,000,000.00\");\n      checkDefault({ numMode: \"currency\" }, 0.1234567, \"$0.12\");    // maxDecimals defaults to 2 here\n      checkDefault({ numMode: \"currency\", maxDecimals: 0 }, 12.34567, \"$12.35\");\n      checkDefault({ numMode: \"currency\", decimals: 0, maxDecimals: 0 }, 12.34567, \"$12\");\n      checkDefault({ numMode: \"currency\" }, 1.015e-8, \"$0.00\");\n      checkDefault({ numMode: \"currency\", maxDecimals: 10 }, 1.015e-8, \"$0.0000000102\");\n      checkDefault({ numMode: \"currency\" }, -1.015e-8, \"-$0.00\");\n\n      // Test currency formatting with custom locales.\n      assert.equal(fmt({ numMode: \"currency\" }, 1000000, { locale: \"es-ES\" }), \"1.000.000,00 €\");\n      assert.equal(fmt({ numMode: \"currency\", decimals: 4 }, 1000000, { locale: \"en-NZ\" }), \"$1,000,000.0000\");\n      assert.equal(fmt({ numMode: \"currency\" }, -1234.565, { locale: \"de-CH\" }), \"CHF-1'234.57\");\n      assert.equal(fmt({ numMode: \"currency\" }, -121e+25, { locale: \"es-AR\" }),\n        \"-$ 1.210.000.000.000.000.000.000.000.000,00\");\n      assert.equal(fmt({ numMode: \"currency\" }, 0.1234567, { locale: \"fr-BE\" }), \"0,12 €\");\n      assert.equal(fmt({ numMode: \"currency\", maxDecimals: 0 }, 12.34567, { locale: \"en-GB\" }), \"£12.35\");\n      assert.equal(fmt({ numMode: \"currency\", decimals: 0, maxDecimals: 0 }, 12.34567, { locale: \"en-IE\" }), \"€12\");\n      assert.equal(fmt({ numMode: \"currency\" }, 1.015e-8, { locale: \"af-ZA\" }), \"R 0,00\");\n      assert.equal(fmt({ numMode: \"currency\", maxDecimals: 10 }, 1.015e-8, { locale: \"en-CA\" }), \"$0.0000000102\");\n      assert.equal(fmt({ numMode: \"currency\" }, -1.015e-8, { locale: \"nl-BE\" }), \"€ -0,00\");\n\n      // Test currency formatting with custom currency AND locales (e.g. column-specific currency setting).\n      assert.equal(fmt({ numMode: \"currency\" }, 1000000, { locale: \"es-ES\", currency: \"USD\" }), \"1.000.000,00 $\");\n      assert.equal(\n        fmt({ numMode: \"currency\", decimals: 4 }, 1000000, { locale: \"en-NZ\", currency: \"JPY\" }),\n        \"¥1,000,000.0000\");\n      assert.equal(fmt({ numMode: \"currency\" }, -1234.565, { locale: \"de-CH\", currency: \"JMD\" }), \"$-1'234.57\");\n      assert.equal(\n        fmt({ numMode: \"currency\" }, -121e+25, { locale: \"es-AR\", currency: \"GBP\" }),\n        \"-£ 1.210.000.000.000.000.000.000.000.000,00\");\n      assert.equal(fmt({ numMode: \"currency\" }, 0.1234567, { locale: \"fr-BE\", currency: \"GBP\" }), \"0,12 £\");\n      assert.equal(\n        fmt({ numMode: \"currency\", maxDecimals: 0 }, 12.34567, { locale: \"en-GB\", currency: \"USD\" }),\n        \"$12.35\");\n      assert.equal(\n        fmt({ numMode: \"currency\", decimals: 0, maxDecimals: 0 }, 12.34567, { locale: \"en-IE\", currency: \"SGD\" }),\n        \"$12\");\n      assert.equal(fmt({ numMode: \"currency\" }, 1.015e-8, { locale: \"af-ZA\", currency: \"HKD\" }), \"$0,00\");\n      assert.equal(\n        fmt({ numMode: \"currency\", maxDecimals: 10 }, 1.015e-8, { locale: \"en-CA\", currency: \"RUB\" }),\n        \"₽0.0000000102\");\n      assert.equal(fmt({ numMode: \"currency\" }, -1.015e-8, { locale: \"nl-BE\", currency: \"USD\" }), \"$ -0,00\");\n    });\n\n    it(\"should support percentages\", function() {\n      checkDefault({ numMode: \"percent\" }, 0.5, \"50%\");\n      checkDefault({ numMode: \"percent\" }, -0.15, \"-15%\");\n      checkDefault({ numMode: \"percent\" }, 0.105, \"11%\");\n      checkDefault({ numMode: \"percent\", maxDecimals: 5 }, 0.105, \"10.5%\");\n      checkDefault({ numMode: \"percent\", decimals: 5 }, 0.105, \"10.50000%\");\n      checkDefault({ numMode: \"percent\", maxDecimals: 2 }, 1.2345, \"123.45%\");\n      checkDefault({ numMode: \"percent\" }, -1234.567, \"-123,457%\");  // maxDecimals defaults to 0 here\n      checkDefault({ numMode: \"percent\" }, 1.015e-8, \"0%\");\n      checkDefault({ numMode: \"percent\", maxDecimals: 10 }, 1.015e-8, \"0.000001015%\");\n    });\n\n    it(\"should support parentheses for negative numbers\", function() {\n      checkDefault({ numSign: \"parens\", numMode: \"decimal\" }, -1234.56, \"(1,234.56)\");\n      checkDefault({ numSign: \"parens\", numMode: \"decimal\" }, +1234.56, \" 1,234.56 \");\n      checkDefault({ numSign: \"parens\", numMode: \"decimal\" }, -121e+25, \"(1,210,000,000,000,000,000,000,000,000)\");\n      checkDefault({ numSign: \"parens\", numMode: \"decimal\" }, 0.1234567, \" 0.123 \");\n      checkDefault({ numSign: \"parens\", numMode: \"decimal\" }, 1.015e-8, \" 0 \");\n      checkDefault({ numSign: \"parens\", numMode: \"currency\" }, -1234.565, \"($1,234.57)\");\n      checkDefault({ numSign: \"parens\", numMode: \"currency\" }, -121e+20, \"($12,100,000,000,000,000,000,000.00)\");\n      checkDefault({ numSign: \"parens\", numMode: \"currency\" }, 121e+20, \" $12,100,000,000,000,000,000,000.00 \");\n      checkDefault({ numSign: \"parens\", numMode: \"currency\" }, 1.015e-8, \" $0.00 \");\n      checkDefault({ numSign: \"parens\", numMode: \"currency\" }, -1.015e-8, \"($0.00)\");\n      checkDefault({ numSign: \"parens\" }, -1234.56, \"(1234.56)\");\n      checkDefault({ numSign: \"parens\" }, +1234.56, \" 1234.56 \");\n      checkDefault({ numSign: \"parens\", numMode: \"percent\" }, -0.1234, \"(12%)\");\n      checkDefault({ numSign: \"parens\", numMode: \"percent\" }, +0.1234, \" 12% \");\n    });\n\n    it(\"should support scientific mode\", function() {\n      checkDefault({ numMode: \"scientific\" }, 0.5, \"5E-1\");\n      checkDefault({ numMode: \"scientific\" }, -0.15, \"-1.5E-1\");\n      checkDefault({ numMode: \"scientific\" }, -1234.56, \"-1.235E3\");\n      checkDefault({ numMode: \"scientific\" }, +1234.56, \"1.235E3\");\n      checkDefault({ numMode: \"scientific\" }, 1.015e-8, \"1.015E-8\");\n      checkDefault({ numMode: \"scientific\", maxDecimals: 10 }, 1.015e-8, \"1.015E-8\");\n      checkDefault({ numMode: \"scientific\", decimals: 10 }, 1.015e-8, \"1.0150000000E-8\");\n      checkDefault({ numMode: \"scientific\", maxDecimals: 2 }, 1.015e-8, \"1.02E-8\");\n      checkDefault({ numMode: \"scientific\", maxDecimals: 1 }, 1.015e-8, \"1E-8\");\n      checkDefault({ numMode: \"scientific\" }, -121e+25, \"-1.21E27\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/ValueGuesser.ts",
    "content": "import { arrayRepeat } from \"app/common/gutil\";\nimport { guessColInfo, guessColInfoForImports, GuessResult } from \"app/common/ValueGuesser\";\n\nimport { assert } from \"chai\";\n\nconst defaultDocSettings = {\n  locale: \"en-US\",\n};\n\nfunction check(values: (string | null)[], expectedResult: GuessResult) {\n  const result = guessColInfo(values, defaultDocSettings, \"America/New_York\");\n  assert.deepEqual(result, expectedResult);\n}\n\ndescribe(\"ValueGuesser\", function() {\n  it(\"should guess booleans and numbers correctly\", function() {\n    check(\n      [\"true\", \"false\"],\n      {\n        values: [true, false],\n        colInfo: { type: \"Bool\" },\n      },\n    );\n\n    // 1 and 0 in a boolean column would be converted to true and false,\n    // but they're guessed as numbers, not booleans\n    check(\n      [\"1\", \"0\"],\n      {\n        values: [1, 0],\n        colInfo: { type: \"Numeric\" },\n      },\n    );\n\n    // Even here, guessing booleans would be sensible, but the original values would be lost\n    // if the user didn't like the guess and converted boolean column was converted back to Text.\n    // Also note that when we fallback to Text without any parsing, guessColInfo doesn't return any values,\n    // as sending them back to the data engine would be wasteful.\n    check(\n      [\"true\", \"false\", \"1\", \"0\"],\n      { colInfo: { type: \"Text\" } },\n    );\n\n    // Now that 90% if the values are straightforward booleans, it guesses Bool\n    // \"0\" is still not parsed by guessColInfo as it's trying to be lossless.\n    // However, it will actually be converted in Python by Bool.do_convert,\n    // so this is a small way information can still be lost.\n    check(\n      [...arrayRepeat(9, \"true\"), \"0\"],\n      {\n        values: [...arrayRepeat(9, true), \"0\"],\n        colInfo: { type: \"Bool\" },\n      },\n    );\n\n    // If there are blank values (\"\" or null) then leave them as text,\n    // because the data engine would convert them to false which would lose info.\n    check(\n      [\"true\", \"\"],\n      { colInfo: { type: \"Text\" } },\n    );\n    check(\n      [\"false\", null],\n      { colInfo: { type: \"Text\" } },\n    );\n  });\n\n  it(\"should handle formatted numbers\", function() {\n    check(\n      [\"0.0\", \"1.0\"],\n      {\n        values: [0, 1],\n        colInfo: { type: \"Numeric\", widgetOptions: { decimals: 1 } },\n      },\n    );\n\n    check(\n      [\"$1.00\"],\n      {\n        values: [1],\n        colInfo: { type: \"Numeric\", widgetOptions: { numMode: \"currency\", decimals: 2 } },\n      },\n    );\n\n    check(\n      [\"$1\"],\n      {\n        values: [1],\n        colInfo: { type: \"Numeric\", widgetOptions: { numMode: \"currency\", decimals: 0 } },\n      },\n    );\n\n    // Inconsistent number of decimal places\n    check(\n      [\"$1\", \"$1.00\"],\n      { colInfo: { type: \"Text\" } },\n    );\n\n    // Inconsistent use of currency\n    check(\n      [\"1.00\", \"$1.00\"],\n      { colInfo: { type: \"Text\" } },\n    );\n\n    check(\n      [\"500\", \"6000\"],\n      {\n        values: [500, 6000],\n        colInfo: { type: \"Numeric\" },\n      },\n    );\n    check(\n      [\"500\", \"6,000\"],\n      {\n        values: [500, 6000],\n        colInfo: { type: \"Numeric\", widgetOptions: { numMode: \"decimal\" } },\n      },\n    );\n    // Inconsistent use of thousands separators\n    check(\n      [\"5000\", \"6,000\"],\n      { colInfo: { type: \"Text\" } },\n    );\n  });\n\n  it(\"should guess dates and datetimes correctly\", function() {\n    check(\n      [\"1970-01-21\", null, \"\"],\n      {\n        // The number represents 1970-01-21 parsed to a timestamp.\n        // null and \"\" are converted to null.\n        values: [20 * 24 * 60 * 60, null, null],\n        colInfo: {\n          type: \"Date\",\n          widgetOptions: {\n            dateFormat: \"YYYY-MM-DD\",\n            timeFormat: \"\",\n            isCustomDateFormat: false,\n            isCustomTimeFormat: true,\n          },\n        },\n      },\n    );\n\n    check(\n      [\"1970-01-01 05:00:00\"],\n      {\n        // 05:00 in the given timezone is 10:00 in UTC\n        values: [10 * 60 * 60],\n        colInfo: {\n          // \"America/New_York\" is the timezone given by `check`\n          type: \"DateTime:America/New_York\",\n          widgetOptions: {\n            dateFormat: \"YYYY-MM-DD\",\n            timeFormat: \"HH:mm:ss\",\n            isCustomDateFormat: false,\n            isCustomTimeFormat: false,\n          },\n        },\n      },\n    );\n\n    // A mixture of Date and DateTime cannot be guessed as either, fallback to Text\n    check(\n      [\n        \"1970-01-01\",\n        \"1970-01-01\",\n        \"1970-01-01\",\n        \"1970-01-01 05:00:00\",\n      ],\n      { colInfo: { type: \"Text\" } },\n    );\n  });\n\n  it(\"should require 90% of values to be parsed\", function() {\n    // 90% of the strings can be parsed to numbers, so guess Numeric.\n    check(\n      [...arrayRepeat(9, \"12\"), \"foo\"],\n      {\n        values: [...arrayRepeat(9, 12), \"foo\"],\n        colInfo: { type: \"Numeric\" },\n      },\n    );\n\n    // Less than 90% are numbers, so fallback to Text\n    check(\n      [...arrayRepeat(8, \"12\"), \"foo\"],\n      { colInfo: { type: \"Text\" } },\n    );\n\n    // Same as the previous two checks but with a bunch of blanks\n    check(\n      [...arrayRepeat(9, \"12\"), \"foo\", ...arrayRepeat(90, \"\")],\n      {\n        values: [...arrayRepeat(9, 12), \"foo\", ...arrayRepeat(90, null)],\n        colInfo: { type: \"Numeric\" },\n      },\n    );\n    check(\n      [...arrayRepeat(8, \"12\"), \"foo\", ...arrayRepeat(90, \"\")],\n      { colInfo: { type: \"Text\" } },\n    );\n\n    // Just a bunch of blanks and text, no numbers or anything\n    check(\n      [...arrayRepeat(100, null), \"foo\", \"bar\"],\n      { colInfo: { type: \"Text\" } },\n    );\n  });\n\n  describe(\"guessColInfoForImports\", function() {\n    // Prepare dummy docData; just the minimum to satisfy the code that uses it.\n    const docData: any = {\n      docSettings: () => defaultDocSettings,\n      docInfo: () => ({ timezone: \"America/New_York\" }),\n    };\n    it(\"should guess empty column when all cells are empty\", function() {\n      assert.deepEqual(guessColInfoForImports([null, \"\", \"\", null], docData), {\n        values: [null, \"\", \"\", null],\n        colMetadata: { type: \"Any\", isFormula: true, formula: \"\" },\n      });\n    });\n    it(\"should do proper numeric format guessing for a mix of number/string types\", function() {\n      assert.deepEqual(guessColInfoForImports([-5.5, \"1,234.6\", null, 0], docData), {\n        values: [-5.5, 1234.6, null, 0],\n        colMetadata: { type: \"Numeric\", widgetOptions: '{\"numMode\":\"decimal\"}' },\n      });\n    });\n    it(\"should not guess empty column when values are not actually empty\", function() {\n      assert.deepEqual(guessColInfoForImports([null, 0, \"\", false], docData), {\n        values: [null, 0, \"\", false],\n        colMetadata: { type: \"Text\" },\n      });\n    });\n    it(\"should do no guessing for object values\", function() {\n      assert.deepEqual(guessColInfoForImports([\"test\", [\"L\" as any, 1]], docData), {\n        values: [\"test\", [\"L\" as any, 1]],\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/airtable/AirtableAPI.ts",
    "content": "import { listRecords } from \"app/common/airtable/AirtableAPI\";\n\nimport Airtable from \"airtable\";\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"AirtableAPI\", () => {\n  interface RawRecord {\n    id: string;\n    fields: Airtable.FieldSet;\n  }\n\n  describe(\"listRecords\", () => {\n    function makeFakedListRecordsBase(records: RawRecord[]) {\n      return Object.assign(\n        new Airtable({ apiKey: \"AnyOldKey\" }).base(\"\"),\n        { makeRequest: makeListRecordsRequestFake(records) },\n      );\n    }\n\n    function makeListRecordsRequestFake(records: RawRecord[]) {\n      type MakeRequest = Airtable.Base[\"makeRequest\"];\n      return sinon.fake((...params: NonNullable<Parameters<MakeRequest>>): ReturnType<MakeRequest> => {\n        const qs = params[0]?.qs ?? {};\n        const offset = Number.parseInt((qs.offset ?? \"itr0\").replace(\"itr\", \"\"));\n        const pageSize = qs.pageSize ?? 100;\n        const hasMore = offset + pageSize < records.length;\n\n        return Promise.resolve({\n          headers: new Headers(),\n          statusCode: 200,\n          body: {\n            records: records.slice(offset, pageSize + offset),\n            // Airtable uses string offsets, replicate it here.\n            offset: hasMore ? `itr${offset + pageSize}` : undefined,\n          },\n        });\n      });\n    }\n\n    it(\"can request a page of records from the correct API endpoint\", async () => {\n      const mockRecords = [\n        { id: \"rec1\", fields: { Name: \"Alice\" } },\n        { id: \"rec2\", fields: { Name: \"Bob\" } },\n      ];\n\n      const mockBase = makeFakedListRecordsBase(mockRecords);\n\n      const result = await listRecords(mockBase, \"TestTable\", {});\n\n      assert.equal(result.records.length, 2);\n      assert.isFalse(result.hasMoreRecords);\n      assert.equal((await result.fetchNextPage()).records.length, 0);\n\n      // Verify the request was made correctly\n      const call = mockBase.makeRequest.getCall(0);\n      assert.equal(call.args[0]?.method, \"GET\");\n      assert.equal(call.args[0]?.path, \"/TestTable\");\n      assert.isUndefined(call.args[0]?.qs?.offset);\n    });\n\n    it(\"sends query parameters to the Airtable API\", async () => {\n      const mockRecords = [\n        { id: \"rec1\", fields: { Name: \"Alice\" } },\n      ];\n\n      const mockBase = makeFakedListRecordsBase(mockRecords);\n\n      const params = {\n        fields: [\"Name\", \"Email\"],\n        filterByFormula: \"{Name}='Alice'\",\n        maxRecords: 10,\n      };\n\n      await listRecords(mockBase, \"TestTable\", params);\n\n      const call = mockBase.makeRequest.getCall(0);\n      assert.deepInclude(call.args[0]?.qs, params);\n    });\n\n    it(\"correctly encodes table names with special characters\", async () => {\n      const mockBase = makeFakedListRecordsBase([]);\n\n      await listRecords(mockBase, \"My Table With Spaces\", {});\n\n      const call = mockBase.makeRequest.getCall(0);\n      assert.equal(call.args[0]?.path, \"/My%20Table%20With%20Spaces\");\n    });\n\n    it(\"indicates when there are more records to fetch\", async () => {\n      const mockRecords = Array.from({ length: 3 }, (_, i) => ({\n        id: `rec${i}`,\n        fields: { Name: `User${i}` },\n      }));\n\n      const mockBase = makeFakedListRecordsBase(mockRecords);\n\n      const result = await listRecords(mockBase, \"TestTable\", { pageSize: 1 });\n\n      assert.isTrue(result.hasMoreRecords);\n    });\n\n    it(\"can fetch the next page when more records exist\", async () => {\n      const mockRecords = Array.from({ length: 6 }, (_, i) => ({\n        id: `rec${i}`,\n        fields: { Name: `User${i}` },\n      }));\n\n      const mockBase = makeFakedListRecordsBase(mockRecords);\n      const firstResult = await listRecords(mockBase, \"TestTable\", { pageSize: 2 });\n\n      assert.isTrue(firstResult.hasMoreRecords);\n      assert.equal(firstResult.records.length, 2);\n\n      const secondResult = await firstResult.fetchNextPage();\n\n      assert.isTrue(secondResult.hasMoreRecords);\n      assert.equal(secondResult.records.length, 2);\n\n      // Verify offset was passed to second request\n      const secondCall = mockBase.makeRequest.getCall(1);\n      assert.equal(secondCall.args[0]?.qs?.offset, \"itr2\");\n\n      const thirdResult = await secondResult.fetchNextPage();\n\n      assert.isFalse(thirdResult.hasMoreRecords);\n      assert.equal(secondResult.records.length, 2);\n    });\n\n    it(\"returns empty fetchNextPage when there are no more records\", async () => {\n      const mockBase = makeFakedListRecordsBase([]);\n\n      const result = await listRecords(mockBase, \"TestTable\", {});\n\n      assert.isFalse(result.hasMoreRecords);\n\n      const nextPage = await result.fetchNextPage();\n\n      assert.equal(nextPage.records.length, 0);\n      assert.isFalse(nextPage.hasMoreRecords);\n    });\n\n    it(\"retains query parameters across requests\", async () => {\n      const mockRecords = Array.from({ length: 4 }, (_, i) => ({\n        id: `rec${i}`,\n        fields: { Status: \"Active\", Name: `User${i}` },\n      }));\n\n      const mockBase = makeFakedListRecordsBase(mockRecords);\n\n      const params = {\n        filterByFormula: \"{Status}='Active'\",\n        fields: [\"Name\", \"Status\"],\n        pageSize: 2,\n      };\n\n      const page1 = await listRecords(mockBase, \"TestTable\", params);\n      await page1.fetchNextPage();\n\n      // Verify both requests included the query parameters\n      assert.deepInclude(mockBase.makeRequest.getCall(0).args[0]?.qs, params);\n      assert.deepInclude(mockBase.makeRequest.getCall(1).args[0]?.qs, {\n        ...params,\n        offset: \"itr2\",\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/airtable/AirtableDataImporter.ts",
    "content": "import { AirtableTableId } from \"app/common/airtable/AirtableAPITypes\";\nimport {\n  AirtableBaseSchemaCrosswalk,\n  AirtableTableCrosswalk, GristTableId,\n} from \"app/common/airtable/AirtableCrosswalk\";\nimport { importDataFromAirtableBase } from \"app/common/airtable/AirtableDataImporter\";\nimport { AirtableDataImportParams } from \"app/common/airtable/AirtableDataImporterTypes\";\nimport { ReferenceTracker } from \"app/common/airtable/AirtableReferenceTracker\";\nimport { AirtableIdColumnLabel } from \"app/common/airtable/AirtableSchemaImporter\";\nimport { ExistingColumnSchema } from \"app/common/DocSchemaImportTypes\";\nimport { BulkColValues, GristObjCode } from \"app/plugin/GristData\";\n\nimport Airtable from \"airtable\";\nimport { assert } from \"chai\";\nimport { sum } from \"lodash\";\nimport nock from \"nock\";\nimport fetch from \"node-fetch\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"AirtableDataImporter\", function() {\n  const basicCrosswalkFields = [\n    {\n      airtableField: { id: \"fld0\", name: \"Name\", type: \"singleLineText\" as const, options: {} },\n      gristColumn: { id: \"Name\", ref: 100, label: \"Name\", isFormula: false },\n    },\n    {\n      airtableField: { id: \"fld1\", name: \"Count\", type: \"number\" as const, options: {} },\n      gristColumn: { id: \"Count\", ref: 101, label: \"Count\", isFormula: true },\n    },\n    {\n      airtableField: { id: \"fld2\", name: \"Formula\", type: \"formula\" as const, options: {} },\n      gristColumn: { id: \"Formula\", ref: 102, label: \"Formula\", isFormula: true },\n    },\n    {\n      airtableField: { id: \"fld3\", name: \"Links\", type: \"multipleRecordLinks\" as const, options: {} },\n      gristColumn: { id: \"Links\", ref: 103, label: \"Links\", isFormula: false },\n    },\n    {\n      airtableField: { id: \"fld4\", name: \"AiField\", type: \"aiText\" as const, options: {} },\n      gristColumn: { id: \"AiField\", ref: 104, label: \"AiField\", isFormula: false },\n    },\n    {\n      airtableField: { id: \"fld5\", name: \"CreatedBy\", type: \"createdBy\" as const, options: {} },\n      gristColumn: { id: \"CreatedBy\", ref: 105, label: \"CreatedBy\", isFormula: false },\n    },\n    {\n      airtableField: { id: \"fld6\", name: \"ModifiedBy\", type: \"lastModifiedBy\" as const, options: {} },\n      gristColumn: { id: \"ModifiedBy\", ref: 106, label: \"ModifiedBy\", isFormula: false },\n    },\n    {\n      airtableField: { id: \"fld7\", name: \"Collaborators\", type: \"multipleCollaborators\" as const, options: {} },\n      gristColumn: { id: \"Collaborators\", ref: 107, label: \"Collaborators\", isFormula: false },\n    },\n    {\n      airtableField: { id: \"fld8\", name: \"SingleCollaborator\", type: \"singleCollaborator\" as const, options: {} },\n      gristColumn: { id: \"SingleCollaborator\", ref: 108, label: \"SingleCollaborator\", isFormula: false },\n    },\n    {\n      airtableField: { id: \"fld9\", name: \"MultipleSelects\", type: \"multipleSelects\" as const, options: {} },\n      gristColumn: { id: \"MultipleSelects\", ref: 109, label: \"MultipleSelects\", isFormula: false },\n    },\n    {\n      airtableField: { id: \"fld10\", name: \"Rollup\", type: \"rollup\" as const, options: {} },\n      gristColumn: { id: \"Rollup\", ref: 110, label: \"Rollup\", isFormula: true },\n    },\n    {\n      airtableField: { id: \"fld11\", name: \"Lookup\", type: \"lookup\" as const, options: {} },\n      gristColumn: { id: \"Lookup\", ref: 111, label: \"Lookup\", isFormula: true },\n    },\n    {\n      airtableField: { id: \"fld12\", name: \"Attachments\", type: \"multipleAttachments\" as const, options: {} },\n      gristColumn: { id: \"Attachments\", ref: 112, label: \"Attachments\", isFormula: false },\n    },\n  ];\n\n  function createBasicTableCrosswalk(airtableTableId: string, gristTableId: string): AirtableTableCrosswalk {\n    const fields: AirtableTableCrosswalk[\"fields\"] = new Map();\n    const gristColumns: ExistingColumnSchema[] = [];\n    const airtableFields: any[] = [];\n\n    for (const fieldPair of basicCrosswalkFields) {\n      airtableFields.push(fieldPair.airtableField);\n      gristColumns.push(fieldPair.gristColumn);\n      fields.set(fieldPair.airtableField.name, fieldPair);\n    }\n\n    const airtableIdColumn = { id: \"Airtable_Id\", ref: 111, label: AirtableIdColumnLabel, isFormula: false };\n    gristColumns.push(airtableIdColumn);\n\n    return {\n      airtableTable: { id: airtableTableId, name: gristTableId, primaryFieldId: \"fld0\", fields: airtableFields },\n      gristTable: { id: gristTableId, ref: 1, columns: gristColumns },\n      fields,\n      airtableIdColumn,\n    };\n  }\n\n  function createBasicSchemaCrosswalk(tableIdPairs: [AirtableTableId, GristTableId][]): AirtableBaseSchemaCrosswalk {\n    return {\n      tables: new Map(tableIdPairs.map(\n        ([airtableTableId, gristTableId]) =>\n          [airtableTableId, createBasicTableCrosswalk(airtableTableId, gristTableId)],\n      )),\n    };\n  }\n\n  const addRowsMock =\n    sinon.fake(async (tableId, rows) => {\n      const key = Object.keys(rows)[0];\n      const values: any[] | undefined = rows[key];\n      if (!values) {\n        return Promise.resolve([]);\n      }\n\n      const lastIds = await addRowsMock.lastCall.returnValue ?? [0];\n      const maxId = Math.max(...lastIds);\n\n      const newIds = values.map((_, index) => maxId + 1 + index);\n\n      return Promise.resolve(newIds);\n    }) satisfies AirtableDataImportParams[\"addRows\"];\n\n  const updateRowsMock =\n    sinon.fake((tableId, rows) => Promise.resolve(rows.id)) satisfies AirtableDataImportParams[\"updateRows\"];\n\n  let attachmentId = 1;\n  const uploadAttachmentMock =\n    sinon.fake(async (value, filename) => attachmentId++) satisfies AirtableDataImportParams[\"uploadAttachment\"];\n\n  let originalFetch: typeof global.fetch;\n\n  beforeEach(function() {\n    // nock@13 can't intercept requests using global fetch, and 14 has some outstanding timeout issues in a\n    // few tests that are preventing us from upgrading (https://github.com/nock/nock/issues/2857).\n    originalFetch = global.fetch;\n    (global as any).fetch = fetch;\n\n    nock.disableNetConnect();\n  });\n\n  afterEach(function() {\n    attachmentId = 1;\n    global.fetch = originalFetch;\n    nock.abortPendingRequests();\n    nock.cleanAll();\n    nock.enableNetConnect();\n    sinon.reset();\n  });\n\n  describe(\"ReferenceTracker\", () => {\n    it(\"stores and retrieves mappings from original record id to Grist record id\", () => {\n      const tracker = new ReferenceTracker();\n\n      tracker.addRecordIdMapping(\"airtable-rec-1\", 42);\n      tracker.addRecordIdMapping(\"airtable-rec-2\", 99);\n\n      assert.equal(tracker.resolve(\"airtable-rec-1\"), 42);\n      assert.equal(tracker.resolve(\"airtable-rec-2\"), 99);\n    });\n\n    it(\"returns undefined for unknown record ids\", () => {\n      const tracker = new ReferenceTracker();\n\n      assert.isUndefined(tracker.resolve(\"unknown-rec-id\"));\n    });\n  });\n\n  describe(\"TableReferenceTracker.bulkUpdateRowsWithUnresolvedReferences\", () => {\n    it(\"resolves reference updates correctly\", async () => {\n      const tracker = new ReferenceTracker();\n\n      tracker.addRecordIdMapping(\"airtable-rec-1\", 10);\n      tracker.addRecordIdMapping(\"airtable-rec-2\", 20);\n      tracker.addRecordIdMapping(\"airtable-rec-3\", 30);\n      tracker.addRecordIdMapping(\"airtable-food-1\", 101);\n      tracker.addRecordIdMapping(\"airtable-food-2\", 102);\n\n      const tableTracker = tracker.addTable(\"country\", [\"cities\", \"foods\"]);\n\n      tableTracker.addUnresolvedRecord({\n        gristRecordId: 1,\n        refsByColumnId: {\n          cities: [\"airtable-rec-1\", \"airtable-rec-2\"],\n          foods: [\"airtable-food-1\", \"airtable-food-2\"],\n        },\n      });\n\n      tableTracker.addUnresolvedRecord({\n        gristRecordId: 2,\n        refsByColumnId: {\n          cities: [\"airtable-rec-3\"],\n          // Omit foods - make sure undefined reference values are handled correctly\n        },\n      });\n\n      await tableTracker.bulkUpdateRowsWithUnresolvedReferences(updateRowsMock);\n\n      const call = updateRowsMock.getCall(0);\n      assert.equal(call.args[0], \"country\");\n\n      const updates = call.args[1];\n      assert.deepEqual(updates, {\n        id: [1, 2],\n        cities: [\n          [GristObjCode.List, 10, 20],\n          [GristObjCode.List, 30],\n        ],\n        foods: [\n          [GristObjCode.List, 101, 102],\n          [GristObjCode.List],\n        ],\n      });\n    });\n\n    it(\"skips unresolvable references without error\", async () => {\n      const tracker = new ReferenceTracker();\n\n      tracker.addRecordIdMapping(\"airtable-rec-1\", 10);\n\n      const tableTracker = tracker.addTable(\"users\", [\"friends\"]);\n\n      // Reference to an unmapped record\n      tableTracker.addUnresolvedRecord({\n        gristRecordId: 1,\n        refsByColumnId: {\n          friends: [\"airtable-rec-1\", \"airtable-rec-unknown\"],\n        },\n      });\n\n      await tableTracker.bulkUpdateRowsWithUnresolvedReferences(updateRowsMock);\n\n      const call = updateRowsMock.getCall(0);\n      const updates = call.args[1];\n\n      // Only the resolvable reference should be included\n      assert.deepEqual(updates.friends, [[GristObjCode.List, 10]]);\n    });\n\n    it(\"handles batch updates with default batch size\", async () => {\n      const tracker = new ReferenceTracker();\n\n      tracker.addRecordIdMapping(\"airtable-rec-1\", 10);\n      const tableTracker = tracker.addTable(\"users\", [\"col1\"]);\n\n      // Add more than default batch size (100) records\n      for (let i = 0; i < 150; i++) {\n        tableTracker.addUnresolvedRecord({\n          gristRecordId: i + 1,\n          refsByColumnId: { col1: [\"airtable-rec-1\"] },\n        });\n      }\n\n      await tableTracker.bulkUpdateRowsWithUnresolvedReferences(updateRowsMock);\n\n      // Should be called twice: once for first 100, once for remaining 50\n      assert.equal(updateRowsMock.callCount, 2);\n\n      const firstCall = updateRowsMock.getCall(0);\n      const firstUpdates = firstCall.args[1];\n      assert.equal(firstUpdates.id.length, 100);\n\n      const secondCall = updateRowsMock.getCall(1);\n      const secondUpdates = secondCall.args[1];\n      assert.equal(secondUpdates.id.length, 50);\n    });\n\n    it(\"respects custom batch size option\", async () => {\n      const tracker = new ReferenceTracker();\n\n      tracker.addRecordIdMapping(\"airtable-rec-1\", 10);\n      const tableTracker = tracker.addTable(\"users\", [\"col1\"]);\n\n      // Add 25 records\n      for (let i = 0; i < 25; i++) {\n        tableTracker.addUnresolvedRecord({\n          gristRecordId: i + 1,\n          refsByColumnId: { col1: [\"airtable-rec-1\"] },\n        });\n      }\n\n      await tableTracker.bulkUpdateRowsWithUnresolvedReferences(\n        updateRowsMock,\n        { batchSize: 10 },\n      );\n\n      // Should be called 3 times: 10, 10, 5\n      assert.equal(updateRowsMock.callCount, 3);\n    });\n\n    it(\"does not update if there are no unresolved records\", async () => {\n      const tracker = new ReferenceTracker();\n      const tableTracker = tracker.addTable(\"users\", [\"friends\"]);\n\n      const updateRowsMock = sinon.stub().resolves([]);\n\n      await tableTracker.bulkUpdateRowsWithUnresolvedReferences(updateRowsMock);\n\n      assert.isFalse(updateRowsMock.called);\n    });\n  });\n\n  describe(\"importDataFromAirtableBase\", () => {\n    it(\"calls addRows for each table with converted field values\", async () => {\n      const mockRecord = {\n        id: \"rec123\",\n        fields: {\n          Name: \"Test Name\",\n          Count: 42,\n        },\n      };\n\n      const listRecords = createListRecordsFake(new Map([[\"tblMain\", [mockRecord]]]));\n\n      const schemaCrosswalk = createBasicSchemaCrosswalk([[\"tblMain\", \"Main\"]]);\n\n      await importDataFromAirtableBase({\n        listRecords,\n        addRows: addRowsMock,\n        updateRows: updateRowsMock,\n        uploadAttachment: uploadAttachmentMock,\n        schemaCrosswalk,\n      });\n\n      assert.isTrue(addRowsMock.called);\n      const call = addRowsMock.getCall(0);\n      assert.equal(call.args[0], \"Main\");\n      assert.deepEqual(\n        call.args[1],\n        getBulkColSyntaxForRecords(schemaCrosswalk.tables.get(\"tblMain\")!, [mockRecord]));\n    });\n\n    it(\"excludes formula columns from import\", async () => {\n      const mockRecord = {\n        id: \"rec123\",\n        fields: {\n          Name: \"Test\",\n          Formula: \"should be ignored\",\n          Rollup: \"some value\",\n          Count: 42,\n          Lookup: [\"value1\", \"value2\"],\n        },\n      };\n\n      const listRecords = createListRecordsFake(new Map([[\"tblMain\", [mockRecord]]]));\n\n      const schemaCrosswalk = createBasicSchemaCrosswalk([[\"tblMain\", \"Main\"]]);\n\n      await importDataFromAirtableBase({\n        listRecords,\n        addRows: addRowsMock,\n        updateRows: updateRowsMock,\n        uploadAttachment: uploadAttachmentMock,\n        schemaCrosswalk,\n      });\n\n      const call = addRowsMock.getCall(0);\n      const bulkColValues = call.args[1];\n      assert.notProperty(bulkColValues, \"Formula\");\n      assert.notProperty(bulkColValues, \"Count\");\n      assert.notProperty(bulkColValues, \"Rollup\");\n      assert.notProperty(bulkColValues, \"Lookup\");\n      assert.property(bulkColValues, \"Name\");\n    });\n\n    async function testAirtableIdColumn(params = { omitAirtableId: false }) {\n      const mockRecord = {\n        id: \"rec999\",\n        fields: {\n          Name: \"Test\",\n        },\n      };\n\n      const listRecords = createListRecordsFake(new Map([[\"tblMain\", [mockRecord]]]));\n\n      const schemaCrosswalk = createBasicSchemaCrosswalk([[\"tblMain\", \"Main\"]]);\n      if (params.omitAirtableId) {\n        schemaCrosswalk.tables.get(\"tblMain\")!.airtableIdColumn = undefined;\n      }\n\n      await importDataFromAirtableBase({\n        listRecords,\n        addRows: addRowsMock,\n        updateRows: updateRowsMock,\n        uploadAttachment: uploadAttachmentMock,\n        schemaCrosswalk,\n      });\n\n      const call = addRowsMock.getCall(0);\n      return call.args[1];\n    }\n\n    it(\"stores airtable id when airtableIdColumn is configured\", async () => {\n      const bulkColValues = await testAirtableIdColumn({ omitAirtableId: false });\n      assert.deepEqual(bulkColValues.Airtable_Id, [\"rec999\"], \"Airtable ID column data missing\");\n    });\n\n    it(\"skips airtable id when airtableIdColumn is missing\", async () => {\n      const bulkColValues = await testAirtableIdColumn({ omitAirtableId: true });\n      assert.isUndefined(bulkColValues.Airtable_Id, \"Airtable ID column present when it shouldn't be\");\n    });\n\n    it(\"handles multipleRecordLinks references and defers resolution\", async () => {\n      const mockRecords = [\n        {\n          id: \"recA\",\n          fields: {\n            Name: \"Test\",\n            Links: [\"recC\", \"recB\"],\n          },\n        },\n        {\n          id: \"recB\",\n          fields: {},\n        },\n        {\n          id: \"recC\",\n          fields: {},\n        },\n      ];\n\n      const listRecords = createListRecordsFake(new Map([[\"tblMain\", mockRecords]]));\n\n      const schemaCrosswalk = createBasicSchemaCrosswalk([[\"tblMain\", \"Main\"]]);\n\n      await importDataFromAirtableBase({\n        listRecords,\n        addRows: addRowsMock,\n        updateRows: updateRowsMock,\n        uploadAttachment: uploadAttachmentMock,\n        schemaCrosswalk,\n      });\n\n      // First add rows with links as null\n      const addRowsCall = addRowsMock.getCall(0);\n      const initialBulkValues = addRowsCall.args[1];\n      assert.deepEqual(initialBulkValues.Links, [null, null, null]);\n\n      // Update rows with resolved links\n      assert.isTrue(updateRowsMock.called);\n      const updateCall = updateRowsMock.getCall(0);\n      const updates = updateCall.args[1];\n      // Row IDs are created incrementally starting at 1 - so the two referenced rows have 2 and 3.\n      assert.deepEqual(updates.Links, [[GristObjCode.List, 3, 2], [GristObjCode.List], [GristObjCode.List]]);\n    });\n\n    it(\"handles multiple pages of records\", async () => {\n      const mockRecords = [\n        { id: \"rec1\", fields: { Name: \"Alice\" } },\n        { id: \"rec2\", fields: { Name: \"Bob\" } },\n        { id: \"rec3\", fields: { Name: \"Charlie\" } },\n        { id: \"rec4\", fields: { Name: \"Diana\" } },\n        { id: \"rec5\", fields: { Name: \"Eve\" } },\n        { id: \"rec6\", fields: { Name: \"Frank\" } },\n        { id: \"rec7\", fields: { Name: \"Grace\" } },\n        { id: \"rec8\", fields: { Name: \"Hank\" } },\n        { id: \"rec9\", fields: { Name: \"Ivy\" } },\n        { id: \"rec10\", fields: { Name: \"Jack\" } },\n      ];\n\n      const listRecords = createListRecordsFake(new Map([[\"tblMain\", mockRecords]]), { pageSize: 3 });\n\n      const schemaCrosswalk = createBasicSchemaCrosswalk([[\"tblMain\", \"Main\"]]);\n\n      await importDataFromAirtableBase({\n        listRecords,\n        addRows: addRowsMock,\n        updateRows: updateRowsMock,\n        uploadAttachment: uploadAttachmentMock,\n        schemaCrosswalk,\n      });\n\n      // Expect 4 pages - 3 pages of 3, and 1 of 1.\n      assert.equal(addRowsMock.callCount, 4);\n      const totalRows = sum(addRowsMock.getCalls().map(call => call.args[1].Name.length));\n      // Expect all rows to have been added.\n      assert.equal(totalRows, 10);\n    });\n\n    async function testFieldValueConversion(fieldName: string, fieldValue: any) {\n      sinon.reset();\n\n      const mockRecord: AirtableRecordKeyFieldsOnly = {\n        id: \"rec123\",\n        fields: {},\n      };\n\n      mockRecord.fields[fieldName] = fieldValue;\n\n      const listRecords = createListRecordsFake(new Map([[\"tblMain\", [mockRecord]]]));\n      const schemaCrosswalk = createBasicSchemaCrosswalk([[\"tblMain\", \"Main\"]]);\n\n      await importDataFromAirtableBase({\n        listRecords,\n        addRows: addRowsMock,\n        updateRows: updateRowsMock,\n        uploadAttachment: uploadAttachmentMock,\n        schemaCrosswalk,\n      });\n\n      let bulkColValues: BulkColValues;\n      let colId: string;\n      if (fieldName === \"Attachments\") {\n        const call = updateRowsMock.getCall(1);\n        bulkColValues = call.args[1];\n        colId = schemaCrosswalk.tables.get(\"tblMain\")!.fields.get(fieldName)!.gristColumn.id;\n        if (bulkColValues[colId] === undefined) {\n          throw new Error(\"Expected column not in updateRows call\");\n        }\n        return bulkColValues[colId][0];\n      } else {\n        const call = addRowsMock.getCall(0);\n        bulkColValues = call.args[1];\n        colId = schemaCrosswalk.tables.get(\"tblMain\")!.fields.get(fieldName)!.gristColumn.id;\n        if (bulkColValues[colId] === undefined) {\n          throw new Error(\"Expected column not in addRows call\");\n        }\n      }\n\n      return bulkColValues[colId][0];\n    }\n\n    it(\"converts aiText fields correctly\", async () => {\n      const value1 = await testFieldValueConversion(\"AiField\", { value: \"Generated text\" });\n      assert.equal(value1, \"Generated text\");\n\n      const value2 = await testFieldValueConversion(\"AiField\", undefined);\n      assert.isNull(value2);\n    });\n\n    it(\"converts createdBy fields correctly\", async () => {\n      const value1 = await testFieldValueConversion(\"CreatedBy\", { name: \"Alice\", email: \"alice@example.com\" });\n      assert.equal(value1, \"Alice\");\n\n      const value2 = await testFieldValueConversion(\"CreatedBy\", { name: \"Bob\" });\n      assert.equal(value2, \"Bob\");\n\n      const value3 = await testFieldValueConversion(\"CreatedBy\", undefined);\n      assert.isNull(value3);\n    });\n\n    it(\"converts lastModifiedBy fields correctly\", async () => {\n      const value1 = await testFieldValueConversion(\"ModifiedBy\", { name: \"Charlie\", email: \"charlie@example.com\" });\n      assert.equal(value1, \"Charlie\");\n\n      const value2 = await testFieldValueConversion(\"ModifiedBy\", undefined);\n      assert.isNull(value2);\n    });\n\n    it(\"converts singleCollaborator fields correctly\", async () => {\n      const value1 = await testFieldValueConversion(\"SingleCollaborator\", { name: \"Diana\" });\n      assert.equal(value1, \"Diana\");\n\n      const value2 = await testFieldValueConversion(\"SingleCollaborator\", undefined);\n      assert.isNull(value2);\n    });\n\n    it(\"converts multipleCollaborators fields correctly\", async () => {\n      const value1 = await testFieldValueConversion(\"Collaborators\", [\n        { name: \"Eve\" },\n        { name: \"Frank\" },\n      ]);\n      assert.equal(value1, \"Eve, Frank\");\n\n      const value2 = await testFieldValueConversion(\"Collaborators\", [{ name: \"Grace\" }]);\n      assert.equal(value2, \"Grace\");\n\n      const value3 = await testFieldValueConversion(\"Collaborators\", undefined);\n      assert.isNull(value3);\n\n      const value4 = await testFieldValueConversion(\"Collaborators\", []);\n      assert.equal(value4, \"\");\n    });\n\n    it(\"converts multipleSelects fields correctly\", async () => {\n      const value1 = await testFieldValueConversion(\"MultipleSelects\", [\"Option1\", \"Option2\"]);\n      assert.deepEqual(value1, [GristObjCode.List, \"Option1\", \"Option2\"]);\n\n      const value2 = await testFieldValueConversion(\"MultipleSelects\", [\"Single\"]);\n      assert.deepEqual(value2, [GristObjCode.List, \"Single\"]);\n\n      const value3 = await testFieldValueConversion(\"MultipleSelects\", undefined);\n      assert.isNull(value3);\n\n      const value4 = await testFieldValueConversion(\"MultipleSelects\", []);\n      assert.deepEqual(value4, [GristObjCode.List]);\n    });\n\n    it(\"converts multipleAttachments fields correctly\", async () => {\n      nock(\"https://example.com\")\n        .get(\"/file1.pdf\")\n        .reply(200, \"file1\", { \"Content-Type\": \"application/pdf\" });\n      nock(\"https://example.com\")\n        .get(\"/file2.jpeg\")\n        .reply(200, \"file2\", { \"Content-Type\": \"image/jpeg\" });\n      nock(\"https://example.com\")\n        .get(\"/file3.txt\")\n        .reply(200, \"file3\", { \"Content-Type\": \"text/plain\" });\n\n      const getBlobContents = async (blob: Blob) => await blob.text();\n\n      const value1 = await testFieldValueConversion(\"Attachments\", [\n        { id: \"att0\", url: \"https://example.com/file1.pdf\", filename: \"file1.pdf\" },\n        { id: \"att1\", url: \"https://example.com/file2.jpeg\", filename: \"file2.jpeg\" },\n      ]);\n      assert.deepEqual(value1, [GristObjCode.List, 1, 2]);\n      assert.equal(uploadAttachmentMock.callCount, 2);\n      const blob1 = uploadAttachmentMock.firstCall.args[0] as Blob;\n      const filename1 = uploadAttachmentMock.firstCall.args[1];\n      assert.equal(await getBlobContents(blob1), \"file1\");\n      assert.equal(blob1.type, \"application/pdf\");\n      assert.equal(filename1, \"file1.pdf\");\n      const blob2 = uploadAttachmentMock.secondCall.args[0] as Blob;\n      const filename2 = uploadAttachmentMock.secondCall.args[1];\n      assert.equal(await getBlobContents(blob2), \"file2\");\n      assert.equal(blob2.type, \"image/jpeg\");\n      assert.equal(filename2, \"file2.jpeg\");\n\n      const value2 = await testFieldValueConversion(\"Attachments\", [\n        { id: \"att2\", url: \"https://example.com/file3.txt\", filename: \"file3.txt\" },\n      ]);\n      assert.deepEqual(value2, [GristObjCode.List, 3]);\n      assert.equal(uploadAttachmentMock.callCount, 1);\n      const blob3 = uploadAttachmentMock.firstCall.args[0] as Blob;\n      const filename3 = uploadAttachmentMock.firstCall.args[1];\n      assert.equal(await getBlobContents(blob3), \"file3\");\n      assert.equal(blob3.type, \"text/plain\");\n      assert.equal(filename3, \"file3.txt\");\n\n      const value3 = await testFieldValueConversion(\"Attachments\", undefined);\n      assert.deepEqual(value3, [GristObjCode.List]);\n\n      const value4 = await testFieldValueConversion(\"Attachments\", []);\n      assert.deepEqual(value4, [GristObjCode.List]);\n    });\n\n    it(\"skips count field data conversion because it's a formula column\", async () => {\n      await assert.isRejected(\n        testFieldValueConversion(\"Count\", 42),\n        \"Expected column not in addRows call\",\n      );\n    });\n\n    it(\"skips formula field data conversion because it's a formula column\", async () => {\n      await assert.isRejected(\n        testFieldValueConversion(\"Formula\", \"computed result\"),\n        \"Expected column not in addRows call\",\n      );\n    });\n\n    it(\"skips lookup field data conversion because it's a formula column\", async () => {\n      await assert.isRejected(\n        testFieldValueConversion(\"Lookup\", [\"value1\", \"value2\"]),\n        \"Expected column not in addRows call\",\n      );\n    });\n\n    it(\"skips rollup field data conversion because it's a formula column\", async () => {\n      await assert.isRejected(\n        testFieldValueConversion(\"Rollup\", {}),\n        \"Expected column not in addRows call\",\n      );\n    });\n\n    it(\"preserves the values of fields without explicit converters\", async () => {\n      const value1 = await testFieldValueConversion(\"Name\", \"Plain text\");\n      assert.equal(value1, \"Plain text\");\n\n      const value2 = await testFieldValueConversion(\"Name\", 42);\n      assert.equal(value2, 42);\n\n      const value3 = await testFieldValueConversion(\"Name\", [GristObjCode.List, 1]);\n      assert.deepEqual(value3, [GristObjCode.List, 1]);\n\n      const value4 = await testFieldValueConversion(\"Name\", undefined);\n      assert.isNull(value4);\n    });\n\n    it(\"uses null for crosswalk fields without values in the record\", async () => {\n      const mockRecord = {\n        id: \"rec123\",\n        fields: {\n        },\n      };\n\n      const listRecords = createListRecordsFake(new Map([[\"tblMain\", [mockRecord]]]));\n\n      const schemaCrosswalk = createBasicSchemaCrosswalk([[\"tblMain\", \"Main\"]]);\n\n      await importDataFromAirtableBase({\n        listRecords,\n        addRows: addRowsMock,\n        updateRows: updateRowsMock,\n        uploadAttachment: uploadAttachmentMock,\n        schemaCrosswalk,\n      });\n\n      const call = addRowsMock.getCall(0);\n      const bulkColValues = call.args[1];\n      assert.deepEqual(bulkColValues.Name, [null]);\n      assert.deepEqual(bulkColValues.CreatedBy, [null]);\n    });\n\n    it(\"propagates errors thrown from listRecords\", async () => {\n      const listRecords = () => {\n        throw new Error(\"Airtable API error\");\n      };\n\n      const schemaCrosswalk = createBasicSchemaCrosswalk([[\"tblMain\", \"Main\"]]);\n\n      try {\n        await importDataFromAirtableBase({\n          listRecords,\n          addRows: addRowsMock,\n          updateRows: updateRowsMock,\n          uploadAttachment: uploadAttachmentMock,\n          schemaCrosswalk,\n        });\n        assert.fail(\"Should have thrown\");\n      } catch (e: any) {\n        assert.equal(e.message, \"Airtable API error\");\n      }\n    });\n\n    it(\"handles an empty base without errors\", async () => {\n      const schemaCrosswalk: AirtableBaseSchemaCrosswalk = {\n        tables: new Map(),\n      };\n\n      const listEmptyResult = () => Promise.resolve({\n        records: [],\n        hasMoreRecords: false,\n        fetchNextPage: listEmptyResult,\n      });\n\n      // Should not throw\n      await importDataFromAirtableBase({\n        listRecords: listEmptyResult,\n        addRows: addRowsMock,\n        updateRows: updateRowsMock,\n        uploadAttachment: uploadAttachmentMock,\n        schemaCrosswalk,\n      });\n\n      assert.isFalse(addRowsMock.called);\n      assert.isFalse(updateRowsMock.called);\n    });\n  });\n});\n\ntype AirtableRecordKeyFieldsOnly = Pick<Airtable.Record<any>, \"id\" | \"fields\">;\n\n// Converts Airtable records into the expected bulk-column syntax\nfunction getBulkColSyntaxForRecords(tableCrosswalk: AirtableTableCrosswalk, records: AirtableRecordKeyFieldsOnly[]) {\n  const fieldMappings = Array.from(tableCrosswalk.fields.values()).filter(mapping => !mapping.gristColumn.isFormula);\n  const bulkCol: BulkColValues = {};\n\n  for (const fieldMapping of fieldMappings) {\n    bulkCol[fieldMapping.gristColumn.id] = [];\n  }\n\n  if (tableCrosswalk.airtableIdColumn) {\n    bulkCol[tableCrosswalk.airtableIdColumn.id] = [];\n  }\n\n  for (const record of records) {\n    for (const fieldMapping of fieldMappings) {\n      const bulkValues = bulkCol[fieldMapping.gristColumn.id];\n      bulkValues.push(record.fields[fieldMapping.airtableField.name] ?? null);\n    }\n\n    if (tableCrosswalk.airtableIdColumn) {\n      const bulkValues = bulkCol[tableCrosswalk.airtableIdColumn.id];\n      bulkValues.push(record.id);\n    }\n  }\n  return bulkCol;\n}\n\nfunction createListRecordsFake(\n  data: Map<string, Pick<Airtable.Record<any>, \"id\" | \"fields\">[]>,\n  { pageSize } = { pageSize: 100 },\n): AirtableDataImportParams[\"listRecords\"]  {\n  return function(tableId: string) {\n    if (!data.has(tableId)) {\n      throw new Error(\"TableId is not valid - table does not exist in fake data\");\n    }\n    function doListing(offset: number = 0) {\n      const tableRecords = data.get(tableId)!;\n      return Promise.resolve({\n        // Cast to prevent us having to create a full Airtable.Record instance. Any issues should show in tests.\n        records: tableRecords.slice(offset, offset + pageSize) as unknown as Airtable.Records<any>,\n        hasMoreRecords: tableRecords.length > offset + pageSize,\n        fetchNextPage: () => doListing(offset + pageSize),\n      });\n    }\n    return doListing();\n  };\n}\n"
  },
  {
    "path": "test/common/airtable/AirtableSchemaImporter.ts",
    "content": "import {\n  AirtableBaseSchema,\n  AirtableFieldSchema,\n  AirtableTableSchema,\n} from \"app/common/airtable/AirtableAPITypes\";\nimport { gristDocSchemaFromAirtableSchema } from \"app/common/airtable/AirtableSchemaImporter\";\nimport { ColumnImportSchema } from \"app/common/DocSchemaImport\";\nimport { RecalcWhen } from \"app/common/gristTypes\";\n\nimport * as crypto from \"crypto\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"AirtableSchemaImporter\", function() {\n  const firstTableName = \"A basic table\";\n  const firstTableId = tableNameToId(firstTableName);\n\n  function createTableSchema(): AirtableTableSchema {\n    return {\n      id: firstTableId,\n      name: firstTableName,\n      primaryFieldId: fieldNameToId(\"Arbitrary column name 1\"),\n      fields: [{\n        type: \"singleLineText\",\n        id: fieldNameToId(\"Arbitrary column name 1\"),\n        name: \"Arbitrary column name 1\",\n      }],\n    };\n  }\n\n  function createBaseSchema(): AirtableBaseSchema {\n    return {\n      tables: [createTableSchema()],\n    };\n  }\n\n  function createBasicAirtableField(name: string, type: string) {\n    return {\n      id: fieldNameToId(name),\n      name,\n      type,\n      options: {} as any,\n    };\n  }\n\n  describe(\"gristDocSchemaFromAirtableSchema\", () => {\n    it(\"correctly converts a very basic airtable schema\", () => {\n      const result = gristDocSchemaFromAirtableSchema(createBaseSchema());\n      assert.deepEqual(result.schema, {\n        tables: [\n          {\n            originalId: firstTableId,\n            desiredGristId: \"A basic table\",\n            columns: [\n              {\n                originalId: \"airtableId\",\n                desiredGristId: \"Airtable Id\",\n                label: \"Airtable Id\",\n                type: \"Text\",\n                untieColIdFromLabel: true,\n              },\n              {\n                originalId: fieldNameToId(\"Arbitrary column name 1\"),\n                desiredGristId: \"Arbitrary column name 1\",\n                label: \"Arbitrary column name 1\",\n                type: \"Text\",\n              },\n            ],\n          },\n        ],\n      });\n      assert.isEmpty(result.warnings);\n    });\n\n    interface ConvertAirtableFieldParams {\n      field: AirtableFieldSchema,\n      fieldDependenciesByTable?: { [tableId: string]: AirtableFieldSchema[] },\n    }\n\n    function convertAirtableField(params: ConvertAirtableFieldParams) {\n      const airtableSchema = createBaseSchema();\n      airtableSchema.tables[0].fields[0] = params.field;\n      const { fieldDependenciesByTable } = params;\n      if (fieldDependenciesByTable) {\n        Object.keys(fieldDependenciesByTable).forEach((tableId) => {\n          const fields = fieldDependenciesByTable[tableId];\n          let table = airtableSchema.tables.find(table => table.id === tableId);\n          if (!table) {\n            table = {\n              id: tableId,\n              name: tableId,\n              primaryFieldId: fields[0].id,\n              fields: [],\n            };\n            airtableSchema.tables.push(table);\n          }\n          table.fields.push(...fields);\n        });\n      }\n      const result = gristDocSchemaFromAirtableSchema(airtableSchema);\n      return {\n        airtableSchema,\n        importSchema: result.schema,\n        warnings: result.warnings,\n        testField: airtableSchema.tables[0].fields.find(field => field.id === params.field.id)!,\n        testColumn: result.schema.tables[0].columns.find(column => column.originalId === params.field.id)!,\n      };\n    }\n\n    function runBasicFieldTest(field: AirtableFieldSchema, column: ColumnImportSchema) {\n      assert.equal(field.id, column.originalId);\n      assert.equal(field.name, column.desiredGristId);\n      assert.equal(field.name, column.label);\n    }\n\n    // The majority of tests are simple checks that input field X became some column Y with specific\n    // properties set.\n    // This helper provides a minimalist way of checking that case.\n    function basicDeepIncludeFieldTest(\n      fieldParams: Omit<AirtableFieldSchema, \"id\">,\n      getIncludesValue: (testField: AirtableFieldSchema, testColumn: ColumnImportSchema) => any,\n    ) {\n      const field = {\n        ...createBasicAirtableField(fieldParams.name, fieldParams.type),\n        ...fieldParams,\n      };\n      const { testField, testColumn } = convertAirtableField({ field });\n      runBasicFieldTest(testField, testColumn);\n      assert.deepInclude(testColumn, getIncludesValue(testField, testColumn));\n    }\n\n    it(\"correctly converts an aiText column\", () => {\n      const field = createBasicAirtableField(\"An AiText column\", \"aiText\");\n      const referencedField = createBasicAirtableField(\"A referenced field\", \"Text\");\n      const fieldDependenciesByTable = {\n        [firstTableId]: [referencedField],\n      };\n\n      field.options = {\n        referencedFieldIds: [referencedField.id],\n        prompt: [\"This is an example prompt, referencing field: \", {\n          field: {\n            fieldId: referencedField.id,\n          },\n        }],\n      };\n\n      // No additional options or changes needed for aiText field.\n      const { testField, testColumn } = convertAirtableField({ field, fieldDependenciesByTable });\n\n      runBasicFieldTest(testField, testColumn);\n\n      assert.deepInclude(testColumn, {\n        type: \"Text\",\n      });\n    });\n\n    it(\"correctly converts an autoNumber column\", () => basicDeepIncludeFieldTest(\n      { name: \"An autonumber column\", type: \"autoNumber\" },\n      (testField, testColumn) => ({\n        type: \"Numeric\",\n      }),\n    ));\n\n    it(\"correctly converts a checkbox column\", () => basicDeepIncludeFieldTest(\n      { name: \"A checkbox column\", type: \"checkbox\" },\n      (testField, testColumn) => ({\n        type: \"Bool\",\n      }),\n    ));\n\n    it(\"correctly converts a count column\", () => {\n      const field = createBasicAirtableField(\"A bool column\", \"count\");\n      const referencedField = createBasicAirtableField(\"A referenced field\", \"Text\");\n      const fieldDependenciesByTable = {\n        [firstTableId]: [referencedField],\n      };\n      field.options = {\n        isValid: true,\n        recordLinkFieldId: referencedField.id,\n      };\n      const { testField, testColumn } = convertAirtableField({ field, fieldDependenciesByTable });\n      runBasicFieldTest(testField, testColumn);\n      assert.deepInclude(testColumn, {\n        type: \"Numeric\",\n        isFormula: true,\n        formula: {\n          formula: \"len($[R0])\",\n          replacements: [{ originalTableId: firstTableId, originalColId: referencedField.id }],\n        },\n      });\n    });\n\n    it(\"correctly converts a createdBy column\", () => basicDeepIncludeFieldTest(\n      { name: \"A createdBy column\", type: \"createdBy\" },\n      (testField, testColumn) => ({\n        type: \"Text\",\n      }),\n    ));\n\n    it(\"correctly converts a createdTime column\", () => basicDeepIncludeFieldTest(\n      { name: \"A createdTime column\", type: \"createdTime\" },\n      (testField, testColumn) => ({\n        type: \"DateTime\",\n        formula: {\n          formula: \"NOW()\",\n        },\n        recalcWhen: RecalcWhen.DEFAULT,\n      }),\n    ));\n\n    it(\"correctly converts a currency column\", () => {\n      const field = createBasicAirtableField(\"A currency column\", \"currency\");\n      field.options = {\n        precision: 3,\n        // Can't easily convert this back to the correct currency code\n        symbol: \"$\",\n      };\n      const { testField, testColumn } = convertAirtableField({ field });\n      runBasicFieldTest(testField, testColumn);\n      assert.deepInclude(testColumn, {\n        type: \"Numeric\",\n        widgetOptions: {\n          decimals: 3,\n          maxDecimals: 3,\n        },\n      });\n\n      field.options.precision = undefined;\n      const noPrecisionResult = convertAirtableField({ field });\n      assert.deepInclude(noPrecisionResult.testColumn, {\n        widgetOptions: {\n          decimals: 2,\n          maxDecimals: 2,\n        },\n      });\n    });\n\n    it(\"correctly converts a date column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"A date column\",\n        type: \"date\",\n        options: {\n          dateFormat: {\n            name: \"local\",\n            format: \"l\",\n          },\n        },\n      },\n      (testField, testColumn) => ({\n        type: \"Date\",\n        widgetOptions: {\n          isCustomDateFormat: true,\n          dateFormat: \"l\",\n        },\n      }),\n    ));\n\n    it(\"correctly converts a dateTime column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"A dateTime column\",\n        type: \"dateTime\",\n        options: {\n          dateFormat: {\n            name: \"iso\",\n            format: \"YYYY-MM-DD\",\n          },\n          timeFormat: {\n            name: \"24hour\",\n            format: \"HH:mm\",\n          },\n          timeZone: \"utc\",\n        },\n      },\n      (testField, testColumn) => ({\n        type: \"DateTime\",\n        widgetOptions: {\n          isCustomDateFormat: true,\n          dateFormat: \"YYYY-MM-DD\",\n          isCustomTimeFormat: true,\n          timeFormat: \"HH:mm\",\n        },\n      }),\n    ));\n\n    it(\"correctly converts a duration column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"A duration column\",\n        type: \"duration\",\n        options: {\n          durationFormat: \"h:mm\",\n        },\n      },\n      (testField, testColumn) => ({\n        type: \"Numeric\",\n      }),\n    ));\n\n    it(\"correctly converts an email column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"An email column\",\n        type: \"email\",\n      },\n      (testField, testColumn) => ({\n        type: \"Text\",\n      }),\n    ));\n\n    it(\"correctly converts a formula column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"A formula column\",\n        type: \"formula\",\n        options: {\n          formula: \"DATETIME_DIFF({fldBSBtZ30nsLogpl}, TODAY(), 'days')\",\n          referencedFieldIds: [\"fldBSBtZ30nsLogpl\"],\n          result: {\n            type: \"number\",\n            options: {\n              precision: 0,\n            },\n          },\n        },\n      },\n      (testField, testColumn) => ({\n        type: \"Any\",\n        isFormula: true,\n        formula: {\n          // Expect to find a commented out version of the formula\n          formula: `#${testField.options?.formula}`,\n        },\n      }),\n    ));\n\n    it(\"correctly converts a lastModifiedBy column\", () => basicDeepIncludeFieldTest(\n      { name: \"A lastModifiedBy column\", type: \"lastModifiedBy\" },\n      (testField, testColumn) => ({\n        type: \"Text\",\n        formula: {\n          formula: 'user and f\"{user.Name}\"',\n        },\n        recalcWhen: RecalcWhen.MANUAL_UPDATES,\n      }),\n    ));\n\n    it(\"correctly converts a lastModifiedTime column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"A lastModifiedTime column\",\n        type: \"lastModifiedTime\",\n        options: {\n          isValid: true,\n          referencedFieldIds: [],\n          result: {\n            type: \"date\",\n            dateFormat: {\n              name: \"iso\",\n              format: \"YYYY-MM-DD\",\n            },\n            timeFormat: {\n              name: \"24hour\",\n              format: \"HH:mm\",\n            },\n          },\n        },\n      },\n      (testField, testColumn) => ({\n        type: \"DateTime\",\n        formula: { formula: \"NOW()\" },\n        recalcWhen: RecalcWhen.MANUAL_UPDATES,\n        widgetOptions: {\n          isCustomDateFormat: true,\n          dateFormat: \"YYYY-MM-DD\",\n          isCustomTimeFormat: true,\n          timeFormat: \"HH:mm\",\n        },\n      }),\n    ));\n\n    it(\"correctly converts a multilineText column\", () => basicDeepIncludeFieldTest(\n      { name: \"A multiline text column\", type: \"multilineText\" },\n      (testField, testColumn) => ({\n        type: \"Text\",\n      }),\n    ));\n\n    it(\"correctly converts a multipleAttachments column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"A multipleAttachments column\",\n        type: \"multipleAttachments\",\n        options: {\n          isReversed: true,\n        },\n      },\n      (testField, testColumn) => ({\n        type: \"Attachments\",\n      }),\n    ));\n\n    it(\"correctly converts a multipleCollaborators column\", () => basicDeepIncludeFieldTest(\n      { name: \"A multipleCollaborators column\", type: \"multipleCollaborators\" },\n      (testField, testColumn) => ({\n        type: \"Text\",\n      }),\n    ));\n\n    it(\"correctly converts a multipleLookupValues column\", () => {\n      const otherTableId = tableNameToId(\"other table\");\n      const otherField = createBasicAirtableField(\"Any field\", \"\");\n      const refField = {\n        ...createBasicAirtableField(\"A multipleRecordLinks column\", \"multipleRecordLinks\"),\n        options: {\n          linkedTableId: otherTableId,\n          isReversed: false,\n          prefersSingleRecordLink: false,\n          // No valid value needed for this test\n          inverseLinkFieldId: \"\",\n        },\n      };\n      const fieldDependenciesByTable = {\n        [firstTableId]: [refField],\n        [otherTableId]: [otherField],\n      };\n\n      const field = {\n        ...createBasicAirtableField(\"A multipleLookupValues column\", \"multipleLookupValues\"),\n        options: {\n          isValid: true,\n          recordLinkFieldId: refField.id,\n          fieldIdInLinkedTable: otherField.id,\n          referencedFieldIds: [],\n          result: {\n            type: \"singleLineText\",\n          },\n        },\n      };\n\n      const { testField, testColumn } = convertAirtableField({ field, fieldDependenciesByTable });\n      runBasicFieldTest(testField, testColumn);\n      assert.deepInclude(testColumn, {\n        type: \"Any\",\n        isFormula: true,\n        formula: {\n          formula: \"$[R0].[R1]\",\n          replacements: [\n            { originalTableId: firstTableId, originalColId: refField.id },\n            { originalTableId: otherTableId, originalColId: otherField.id },\n          ],\n        },\n      });\n    });\n\n    [\n      {\n        name: \"single record\",\n        options: { prefersSingleRecordLink: true },\n        expectedColProps: { type: \"Ref\" },\n      },\n      {\n        name: \"multiple records\",\n        options: { prefersSingleRecordLink: false },\n        expectedColProps: { type: \"RefList\" },\n      },\n    ].forEach(({ name: variantName, options: variantOptions, expectedColProps }) => {\n      it(`correctly converts a multipleRecordLinks column - ${variantName}`, () => {\n        const field = {\n          ...createBasicAirtableField(\"A multipleRecordLinks column\", \"multipleRecordLinks\"),\n          options: {\n            linkedTableId: firstTableId,\n            isReversed: false,\n            inverseLinkFieldId: \"\",\n            ...variantOptions,\n          },\n        };\n        const inverseField = {\n          ...createBasicAirtableField(\"An inverse multipleRecordLinks column\", \"multipleRecordLinks\"),\n          options: {\n            linkedTableId: firstTableId,\n            isReversed: true,\n            inverseLinkFieldId: field.id,\n            ...variantOptions,\n          },\n        };\n        field.options.inverseLinkFieldId = inverseField.id;\n        const fieldDependenciesByTable = {\n          [firstTableId]: [inverseField],\n        };\n        const { testField, testColumn } = convertAirtableField({ field, fieldDependenciesByTable });\n        runBasicFieldTest(testField, testColumn);\n        assert.deepInclude(testColumn, {\n          ref: { originalTableId: firstTableId },\n          ...expectedColProps,\n        });\n      });\n    });\n\n    it(\"correctly converts a multipleSelects column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"A multipleSelects column\",\n        type: \"multipleSelects\",\n        options: {\n          choices: [\n            {\n              id: \"selyK3p8gKM4n1gXF\",\n              name: \"Tag 1\",\n              color: \"blueLight2\",\n            },\n            {\n              id: \"selIcGw9oH8NCd8TA\",\n              name: \"Tag 2\",\n              color: \"cyanLight2\",\n            },\n            {\n              id: \"self9MQIcLOj4iW9d\",\n              name: \"Tag 3\",\n              color: \"tealLight2\",\n            },\n          ],\n        },\n      },\n      (testField, testColumn) => ({\n        type: \"ChoiceList\",\n        widgetOptions: {\n          choices: [\"Tag 1\", \"Tag 2\", \"Tag 3\"],\n          choiceOptions: {},\n        },\n      }),\n    ));\n\n    it(\"correctly converts a number column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"A number column\",\n        type: \"number\",\n        options: {\n          precision: 4,\n        },\n      },\n      (testField, testColumn) => ({\n        type: \"Numeric\",\n        widgetOptions: {\n          decimals: 4,\n        },\n      }),\n    ));\n\n    it(\"correctly converts a percent column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"A percent column\",\n        type: \"percent\",\n        options: {\n          precision: 4,\n        },\n      },\n      (testField, testColumn) => ({\n        type: \"Numeric\",\n        widgetOptions: {\n          decimals: 4,\n          numMode: \"percent\",\n        },\n      }),\n    ));\n\n    it(\"correctly converts a phoneNumber column\", () => basicDeepIncludeFieldTest(\n      { name: \"A phoneNumber column\", type: \"phoneNumber\" },\n      (testField, testColumn) => ({\n        type: \"Text\",\n      }),\n    ));\n\n    it(\"correctly converts a rating column\", () => basicDeepIncludeFieldTest(\n      { name: \"A rating column\", type: \"rating\" },\n      (testField, testColumn) => ({\n        type: \"Int\",\n      }),\n    ));\n\n    it(\"correctly converts a richText column\", () => basicDeepIncludeFieldTest(\n      { name: \"A richText column\", type: \"richText\" },\n      (testField, testColumn) => ({\n        type: \"Text\",\n        widgetOptions: {\n          widget: \"Markdown\",\n        },\n      }),\n    ));\n\n    it(\"correctly converts a rollup column\", () => {\n      const otherTableId = tableNameToId(\"other table\");\n      const otherField = createBasicAirtableField(\"Any field\", \"\");\n      const refField = {\n        ...createBasicAirtableField(\"A multipleRecordLinks column\", \"multipleRecordLinks\"),\n        options: {\n          linkedTableId: otherTableId,\n          isReversed: false,\n          prefersSingleRecordLink: false,\n          // No valid value needed for this test\n          inverseLinkFieldId: \"\",\n        },\n      };\n      const fieldDependenciesByTable = {\n        [firstTableId]: [refField],\n        [otherTableId]: [otherField],\n      };\n\n      const field = {\n        ...createBasicAirtableField(\"A rollup column\", \"rollup\"),\n        options: {\n          isValid: true,\n          recordLinkFieldId: refField.id,\n          fieldIdInLinkedTable: otherField.id,\n          referencedFieldIds: [],\n          result: {\n            type: \"singleLineText\",\n          },\n        },\n      };\n\n      const { testField, testColumn } = convertAirtableField({ field, fieldDependenciesByTable });\n      runBasicFieldTest(testField, testColumn);\n      assert.deepInclude(testColumn, {\n        type: \"Any\",\n        isFormula: true,\n        formula: {\n          formula: \"$[R0].[R1]\",\n          replacements: [\n            { originalTableId: firstTableId, originalColId: refField.id },\n            { originalTableId: otherTableId, originalColId: otherField.id },\n          ],\n        },\n      });\n    });\n\n    it(\"correctly converts a singleCollaborator column\", () => basicDeepIncludeFieldTest(\n      { name: \"A singleCollaborator column\", type: \"singleCollaborator\" },\n      (testField, testColumn) => ({\n        type: \"Text\",\n      }),\n    ));\n\n    it(\"correctly converts a singleLineText column\", () => basicDeepIncludeFieldTest(\n      { name: \"A singleLineText column\", type: \"singleLineText\" },\n      (testField, testColumn) => ({\n        type: \"Text\",\n      }),\n    ));\n\n    it(\"correctly converts a singleSelect column\", () => basicDeepIncludeFieldTest(\n      {\n        name: \"A singleSelect column\",\n        type: \"singleSelect\",\n        options: {\n          choices: [\n            {\n              id: \"selyK3p8gKM4n1gXF\",\n              name: \"Tag 1\",\n              color: \"blueLight2\",\n            },\n            {\n              id: \"selIcGw9oH8NCd8TA\",\n              name: \"Tag 2\",\n              color: \"cyanLight2\",\n            },\n            {\n              id: \"self9MQIcLOj4iW9d\",\n              name: \"Tag 3\",\n              color: \"tealLight2\",\n            },\n          ],\n        },\n      },\n      (testField, testColumn) => ({\n        type: \"Choice\",\n        widgetOptions: {\n          choices: [\"Tag 1\", \"Tag 2\", \"Tag 3\"],\n          choiceOptions: {},\n        },\n      }),\n    ));\n\n    it(\"correctly converts a url column\", () => basicDeepIncludeFieldTest(\n      { name: \"A url column\", type: \"url\" },\n      (testField, testColumn) => ({\n        type: \"Text\",\n        widgetOptions: {\n          widget: \"HyperLink\",\n        },\n      }),\n    ));\n  });\n});\n\n// Field ids seem to be randomly generated, but always 17 characters prefixed with \"fld\".\n// Approximate that by hashing the field name and using the first 14 characters.\nfunction fieldNameToId(name: string) {\n  return `fld${crypto.createHash(\"md5\").update(name).digest(\"base64\").substring(0, 14)}`;\n}\n\n// Table ids seem to be randomly generated, but always 17 characters prefixed with \"tbl\".\n// Approximate that by hashing the field name and using the first 14 characters.\nfunction tableNameToId(name: string) {\n  return `tbl${crypto.createHash(\"md5\").update(name).digest(\"base64\").substring(0, 14)}`;\n}\n"
  },
  {
    "path": "test/common/arraySplice.js",
    "content": "var _ = require(\"underscore\");\nvar assert = require(\"chai\").assert;\nvar gutil = require(\"app/common/gutil\");\nvar utils = require(\"../utils\");\n\n/**\n * Set env ENABLE_TIMING_TESTS=1 to run the timing tests.\n * These tests rely on mocha's reported timings to allow you to compare the performance of\n * different implementations.\n */\nvar ENABLE_TIMING_TESTS = Boolean(process.env.ENABLE_TIMING_TESTS);\n\n//----------------------------------------------------------------------\n\n// Following recommendations such as here:\n// http://stackoverflow.com/questions/7032550/javascript-insert-an-array-inside-another-array\n// However, this won't work for large arrToInsert because .apply has a limit on length of args.\nfunction spliceApplyConcat(target, start, arrToInsert) {\n  target.splice.apply(target, [start, 0].concat(arrToInsert));\n  return target;\n}\n\n//----------------------------------------------------------------------\n\n// Seems like could be faster, but disturbingly mutates the last argument.\n// However, this won't work for large arrToInsert because .apply has a limit on length of args.\nfunction spliceApplyUnshift(target, start, arrToInsert) {\n  var spliceArgs = arrToInsert;\n  spliceArgs.unshift(start, 0);\n  try {\n    target.splice.apply(target, spliceArgs);\n  } finally {\n    spliceArgs.splice(0, 2);\n  }\n  return target;\n}\n\n//----------------------------------------------------------------------\n\n// This is from the same stackoverflow answer, but builds a new array instead of mutating target.\nfunction nonSpliceUsingSlice(target, start, arrToInsert) {\n  return target.slice(0, start).concat(arrToInsert, target.slice(start));\n}\n\n//----------------------------------------------------------------------\n\n// A simple manual implementation, that performs reasonably well in all environments.\nfunction spliceManualWithTailCopy(target, start, arrToInsert) {\n  var insLen = arrToInsert.length;\n  if (insLen === 1) {\n    target.splice(start, 0, arrToInsert[0]);\n  } else if (insLen > 1) {\n    var i, len, tail = target.slice(start);\n    for (i = 0; i < insLen; i++, start++) {\n      target[start] = arrToInsert[i];\n    }\n    for (i = 0, len = tail.length; i < len; i++, start++) {\n      target[start] = tail[i];\n    }\n  }\n  return target;\n}\n\n//----------------------------------------------------------------------\n\nfunction spliceCopyWithTail(helpers) {\n  var copyForward = helpers.copyForward;\n  return function(target, start, arrToInsert) {\n    var tail = target.slice(start), insLen = arrToInsert.length;\n    copyForward(target, start, arrToInsert, 0, insLen);\n    copyForward(target, start + insLen, tail, 0, tail.length);\n    return target;\n  };\n}\n\n//----------------------------------------------------------------------\n\n// This implementation avoids creating a copy of the tail, but fills in the array\n// non-contiguously.\nfunction spliceFwdBackCopy(helpers) {\n  var copyForward = helpers.copyForward,\n    copyBackward = helpers.copyBackward;\n  return function(target, start, arrayToInsert) {\n    var count = arrayToInsert.length;\n    copyBackward(target, start + count, target, start, target.length - start);\n    copyForward(target, start, arrayToInsert, 0, count);\n    return target;\n  };\n}\n\n//----------------------------------------------------------------------\n\n// This implementation tries to be smarter by avoiding allocations, appending to the array\n// contiguously, then filling in the gap.\nfunction spliceAppendCopy(helpers) {\n  var appendFunc = helpers.append,\n    copyForward = helpers.copyForward,\n    copyBackward = helpers.copyBackward;\n  return function(target, start, arrToInsert) {\n    var origLen = target.length;\n    var tailLen = origLen - start;\n    var insLen = arrToInsert.length;\n    if (insLen > tailLen) {\n      appendFunc(target, arrToInsert, tailLen, insLen - tailLen);\n      appendFunc(target, target, start, tailLen);\n      copyForward(target, start, arrToInsert, 0, tailLen);\n    } else {\n      appendFunc(target, target, origLen - insLen, insLen);\n      copyBackward(target, start + insLen, target, start, tailLen - insLen);\n      copyForward(target, start, arrToInsert, 0, insLen);\n    }\n    return target;\n  };\n}\n\n//----------------------------------------------------------------------\n\n// This implementation only appends, but requires splicing out the tail from the original.\n// It is consistently slower on Node.\nfunction spliceAppendOnly(helpers) {\n  var appendFunc = helpers.append;\n  return function(target, start, arrToInsert) {\n    var tail = target.splice(start, target.length);\n    appendFunc(target, arrToInsert, 0, arrToInsert.length);\n    appendFunc(target, tail, 0, tail.length);\n    return target;\n  };\n}\n\n//----------------------------------------------------------------------\n// COPY-FORWARD FUNCTIONS\n//----------------------------------------------------------------------\nvar copyForward = {\n  gutil: gutil.arrayCopyForward,\n\n  copyForward1: function(toArray, toStart, fromArray, fromStart, count) {\n    for (var end = toStart + count; toStart < end; ++toStart, ++fromStart) {\n      toArray[toStart] = fromArray[fromStart];\n    }\n  },\n\n  copyForward8: function(toArray, toStart, fromArray, fromStart, count) {\n    var end = toStart + count;\n    for (var xend = end - 7; toStart < xend; fromStart += 8, toStart += 8) {\n      toArray[toStart] = fromArray[fromStart];\n      toArray[toStart+1] = fromArray[fromStart+1];\n      toArray[toStart+2] = fromArray[fromStart+2];\n      toArray[toStart+3] = fromArray[fromStart+3];\n      toArray[toStart+4] = fromArray[fromStart+4];\n      toArray[toStart+5] = fromArray[fromStart+5];\n      toArray[toStart+6] = fromArray[fromStart+6];\n      toArray[toStart+7] = fromArray[fromStart+7];\n    }\n    for (; toStart < end; ++fromStart, ++toStart) {\n      toArray[toStart] = fromArray[fromStart];\n    }\n  },\n\n  copyForward64: function(toArray, toStart, fromArray, fromStart, count) {\n    var end = toStart + count;\n    for (var xend = end - 63; toStart < xend; fromStart += 64, toStart += 64) {\n      toArray[toStart]=fromArray[fromStart]; toArray[toStart+1]=fromArray[fromStart+1];\n      toArray[toStart+2]=fromArray[fromStart+2]; toArray[toStart+3]=fromArray[fromStart+3];\n      toArray[toStart+4]=fromArray[fromStart+4]; toArray[toStart+5]=fromArray[fromStart+5];\n      toArray[toStart+6]=fromArray[fromStart+6]; toArray[toStart+7]=fromArray[fromStart+7];\n      toArray[toStart+8]=fromArray[fromStart+8]; toArray[toStart+9]=fromArray[fromStart+9];\n      toArray[toStart+10]=fromArray[fromStart+10]; toArray[toStart+11]=fromArray[fromStart+11];\n      toArray[toStart+12]=fromArray[fromStart+12]; toArray[toStart+13]=fromArray[fromStart+13];\n      toArray[toStart+14]=fromArray[fromStart+14]; toArray[toStart+15]=fromArray[fromStart+15];\n      toArray[toStart+16]=fromArray[fromStart+16]; toArray[toStart+17]=fromArray[fromStart+17];\n      toArray[toStart+18]=fromArray[fromStart+18]; toArray[toStart+19]=fromArray[fromStart+19];\n      toArray[toStart+20]=fromArray[fromStart+20]; toArray[toStart+21]=fromArray[fromStart+21];\n      toArray[toStart+22]=fromArray[fromStart+22]; toArray[toStart+23]=fromArray[fromStart+23];\n      toArray[toStart+24]=fromArray[fromStart+24]; toArray[toStart+25]=fromArray[fromStart+25];\n      toArray[toStart+26]=fromArray[fromStart+26]; toArray[toStart+27]=fromArray[fromStart+27];\n      toArray[toStart+28]=fromArray[fromStart+28]; toArray[toStart+29]=fromArray[fromStart+29];\n      toArray[toStart+30]=fromArray[fromStart+30]; toArray[toStart+31]=fromArray[fromStart+31];\n      toArray[toStart+32]=fromArray[fromStart+32]; toArray[toStart+33]=fromArray[fromStart+33];\n      toArray[toStart+34]=fromArray[fromStart+34]; toArray[toStart+35]=fromArray[fromStart+35];\n      toArray[toStart+36]=fromArray[fromStart+36]; toArray[toStart+37]=fromArray[fromStart+37];\n      toArray[toStart+38]=fromArray[fromStart+38]; toArray[toStart+39]=fromArray[fromStart+39];\n      toArray[toStart+40]=fromArray[fromStart+40]; toArray[toStart+41]=fromArray[fromStart+41];\n      toArray[toStart+42]=fromArray[fromStart+42]; toArray[toStart+43]=fromArray[fromStart+43];\n      toArray[toStart+44]=fromArray[fromStart+44]; toArray[toStart+45]=fromArray[fromStart+45];\n      toArray[toStart+46]=fromArray[fromStart+46]; toArray[toStart+47]=fromArray[fromStart+47];\n      toArray[toStart+48]=fromArray[fromStart+48]; toArray[toStart+49]=fromArray[fromStart+49];\n      toArray[toStart+50]=fromArray[fromStart+50]; toArray[toStart+51]=fromArray[fromStart+51];\n      toArray[toStart+52]=fromArray[fromStart+52]; toArray[toStart+53]=fromArray[fromStart+53];\n      toArray[toStart+54]=fromArray[fromStart+54]; toArray[toStart+55]=fromArray[fromStart+55];\n      toArray[toStart+56]=fromArray[fromStart+56]; toArray[toStart+57]=fromArray[fromStart+57];\n      toArray[toStart+58]=fromArray[fromStart+58]; toArray[toStart+59]=fromArray[fromStart+59];\n      toArray[toStart+60]=fromArray[fromStart+60]; toArray[toStart+61]=fromArray[fromStart+61];\n      toArray[toStart+62]=fromArray[fromStart+62]; toArray[toStart+63]=fromArray[fromStart+63];\n    }\n    for (; toStart < end; ++fromStart, ++toStart) {\n      toArray[toStart] = fromArray[fromStart];\n    }\n  }\n};\n\n//----------------------------------------------------------------------\n// COPY-BACKWARD FUNCTIONS\n//----------------------------------------------------------------------\n\nvar copyBackward = {\n  gutil: gutil.arrayCopyBackward,\n\n  copyBackward1: function(toArray, toStart, fromArray, fromStart, count) {\n    for (var i = toStart + count - 1, j = fromStart + count - 1; i >= toStart; --i, --j) {\n      toArray[i] = fromArray[j];\n    }\n  },\n\n  copyBackward8: function(toArray, toStart, fromArray, fromStart, count) {\n    var i = toStart + count - 1, j = fromStart + count - 1;\n    for (var xStart = toStart + 7; i >= xStart; i -= 8, j -= 8) {\n      toArray[i] = fromArray[j];\n      toArray[i-1] = fromArray[j-1];\n      toArray[i-2] = fromArray[j-2];\n      toArray[i-3] = fromArray[j-3];\n      toArray[i-4] = fromArray[j-4];\n      toArray[i-5] = fromArray[j-5];\n      toArray[i-6] = fromArray[j-6];\n      toArray[i-7] = fromArray[j-7];\n    }\n    for ( ; i >= toStart; --i, --j) {\n      toArray[i] = fromArray[j];\n    }\n  },\n\n  copyBackward64: function(toArray, toStart, fromArray, fromStart, count) {\n    var i = toStart + count - 1, j = fromStart + count - 1;\n    for (var xStart = toStart + 63; i >= xStart; i -= 64, j -= 64) {\n      toArray[i]=fromArray[j]; toArray[i-1]=fromArray[j-1];\n      toArray[i-2]=fromArray[j-2]; toArray[i-3]=fromArray[j-3];\n      toArray[i-4]=fromArray[j-4]; toArray[i-5]=fromArray[j-5];\n      toArray[i-6]=fromArray[j-6]; toArray[i-7]=fromArray[j-7];\n      toArray[i-8]=fromArray[j-8]; toArray[i-9]=fromArray[j-9];\n      toArray[i-10]=fromArray[j-10]; toArray[i-11]=fromArray[j-11];\n      toArray[i-12]=fromArray[j-12]; toArray[i-13]=fromArray[j-13];\n      toArray[i-14]=fromArray[j-14]; toArray[i-15]=fromArray[j-15];\n      toArray[i-16]=fromArray[j-16]; toArray[i-17]=fromArray[j-17];\n      toArray[i-18]=fromArray[j-18]; toArray[i-19]=fromArray[j-19];\n      toArray[i-20]=fromArray[j-20]; toArray[i-21]=fromArray[j-21];\n      toArray[i-22]=fromArray[j-22]; toArray[i-23]=fromArray[j-23];\n      toArray[i-24]=fromArray[j-24]; toArray[i-25]=fromArray[j-25];\n      toArray[i-26]=fromArray[j-26]; toArray[i-27]=fromArray[j-27];\n      toArray[i-28]=fromArray[j-28]; toArray[i-29]=fromArray[j-29];\n      toArray[i-30]=fromArray[j-30]; toArray[i-31]=fromArray[j-31];\n      toArray[i-32]=fromArray[j-32]; toArray[i-33]=fromArray[j-33];\n      toArray[i-34]=fromArray[j-34]; toArray[i-35]=fromArray[j-35];\n      toArray[i-36]=fromArray[j-36]; toArray[i-37]=fromArray[j-37];\n      toArray[i-38]=fromArray[j-38]; toArray[i-39]=fromArray[j-39];\n      toArray[i-40]=fromArray[j-40]; toArray[i-41]=fromArray[j-41];\n      toArray[i-42]=fromArray[j-42]; toArray[i-43]=fromArray[j-43];\n      toArray[i-44]=fromArray[j-44]; toArray[i-45]=fromArray[j-45];\n      toArray[i-46]=fromArray[j-46]; toArray[i-47]=fromArray[j-47];\n      toArray[i-48]=fromArray[j-48]; toArray[i-49]=fromArray[j-49];\n      toArray[i-50]=fromArray[j-50]; toArray[i-51]=fromArray[j-51];\n      toArray[i-52]=fromArray[j-52]; toArray[i-53]=fromArray[j-53];\n      toArray[i-54]=fromArray[j-54]; toArray[i-55]=fromArray[j-55];\n      toArray[i-56]=fromArray[j-56]; toArray[i-57]=fromArray[j-57];\n      toArray[i-58]=fromArray[j-58]; toArray[i-59]=fromArray[j-59];\n      toArray[i-60]=fromArray[j-60]; toArray[i-61]=fromArray[j-61];\n      toArray[i-62]=fromArray[j-62]; toArray[i-63]=fromArray[j-63];\n    }\n    for ( ; i >= toStart; --i, --j) {\n      toArray[i] = fromArray[j];\n    }\n  }\n};\n\n//----------------------------------------------------------------------\n// APPEND FUNCTIONS.\n//----------------------------------------------------------------------\n\nvar append = {\n  gutil: gutil.arrayAppend,\n\n  append1: function(toArray, fromArray, fromStart, count) {\n    var end = fromStart + count;\n    for (var i = fromStart; i < end; i++) {\n      toArray.push(fromArray[i]);\n    }\n  },\n\n  appendCopy1: function(toArray, fromArray, fromStart, count) {\n    if (count === 1) {\n      toArray.push(fromArray[fromStart]);\n    } else if (count > 1) {\n      var len = toArray.length;\n      toArray.length = len + count;\n      copyForward.copyForward1(toArray, len, fromArray, fromStart, count);\n    }\n  },\n\n  append8: function(toArray, fromArray, fromStart, count) {\n    var end = fromStart + count;\n    for (var xend = end - 7; fromStart < xend; fromStart += 8) {\n      toArray.push(\n        fromArray[fromStart],\n        fromArray[fromStart + 1],\n        fromArray[fromStart + 2],\n        fromArray[fromStart + 3],\n        fromArray[fromStart + 4],\n        fromArray[fromStart + 5],\n        fromArray[fromStart + 6],\n        fromArray[fromStart + 7]);\n    }\n    for ( ; fromStart < end; ++fromStart) {\n      toArray.push(fromArray[fromStart]);\n    }\n  },\n\n  append64: function(toArray, fromArray, fromStart, count) {\n    var end = fromStart + count;\n    for (var xend = end - 63; fromStart < xend; fromStart += 64) {\n      toArray.push(\n        fromArray[fromStart], fromArray[fromStart + 1],\n        fromArray[fromStart + 2], fromArray[fromStart + 3],\n        fromArray[fromStart + 4], fromArray[fromStart + 5],\n        fromArray[fromStart + 6], fromArray[fromStart + 7],\n        fromArray[fromStart + 8], fromArray[fromStart + 9],\n        fromArray[fromStart + 10], fromArray[fromStart + 11],\n        fromArray[fromStart + 12], fromArray[fromStart + 13],\n        fromArray[fromStart + 14], fromArray[fromStart + 15],\n        fromArray[fromStart + 16], fromArray[fromStart + 17],\n        fromArray[fromStart + 18], fromArray[fromStart + 19],\n        fromArray[fromStart + 20], fromArray[fromStart + 21],\n        fromArray[fromStart + 22], fromArray[fromStart + 23],\n        fromArray[fromStart + 24], fromArray[fromStart + 25],\n        fromArray[fromStart + 26], fromArray[fromStart + 27],\n        fromArray[fromStart + 28], fromArray[fromStart + 29],\n        fromArray[fromStart + 30], fromArray[fromStart + 31],\n        fromArray[fromStart + 32], fromArray[fromStart + 33],\n        fromArray[fromStart + 34], fromArray[fromStart + 35],\n        fromArray[fromStart + 36], fromArray[fromStart + 37],\n        fromArray[fromStart + 38], fromArray[fromStart + 39],\n        fromArray[fromStart + 40], fromArray[fromStart + 41],\n        fromArray[fromStart + 42], fromArray[fromStart + 43],\n        fromArray[fromStart + 44], fromArray[fromStart + 45],\n        fromArray[fromStart + 46], fromArray[fromStart + 47],\n        fromArray[fromStart + 48], fromArray[fromStart + 49],\n        fromArray[fromStart + 50], fromArray[fromStart + 51],\n        fromArray[fromStart + 52], fromArray[fromStart + 53],\n        fromArray[fromStart + 54], fromArray[fromStart + 55],\n        fromArray[fromStart + 56], fromArray[fromStart + 57],\n        fromArray[fromStart + 58], fromArray[fromStart + 59],\n        fromArray[fromStart + 60], fromArray[fromStart + 61],\n        fromArray[fromStart + 62], fromArray[fromStart + 63]\n      );\n    }\n    for ( ; fromStart < end; ++fromStart) {\n      toArray.push(fromArray[fromStart]);\n    }\n  },\n\n  appendSlice64: function(toArray, fromArray, fromStart, count) {\n    var end = fromStart + count;\n    for ( ; fromStart < end; fromStart += 64) {\n      Array.prototype.push.apply(toArray, fromArray.slice(fromStart, Math.min(fromStart + 64, end)));\n    }\n  }\n};\n\n//----------------------------------------------------------------------\n\nvar helpers1 = {\n  copyForward: copyForward.copyForward1,\n  copyBackward: copyBackward.copyBackward1,\n  append: append.append1,\n};\n\nvar helpers8 = {\n  copyForward: copyForward.copyForward8,\n  copyBackward: copyBackward.copyBackward8,\n  append: append.append8,\n};\n\nvar helpers64 = {\n  copyForward: copyForward.copyForward64,\n  copyBackward: copyBackward.copyBackward64,\n  append: append.append64,\n};\n\nvar allArraySpliceFuncs = {\n  spliceApplyConcat:  spliceApplyConcat,\n  spliceApplyUnshift:  spliceApplyUnshift,\n  nonSpliceUsingSlice:  nonSpliceUsingSlice,\n\n  spliceGutil:  gutil.arraySplice,\n  spliceManualWithTailCopy:  spliceManualWithTailCopy,\n\n  spliceCopyWithTail1:  spliceCopyWithTail(helpers1),\n  spliceCopyWithTail8:  spliceCopyWithTail(helpers8),\n  spliceCopyWithTail64:  spliceCopyWithTail(helpers64),\n\n  spliceFwdBackCopy1:  spliceFwdBackCopy(helpers1),\n  spliceFwdBackCopy8:  spliceFwdBackCopy(helpers8),\n  spliceFwdBackCopy64:  spliceFwdBackCopy(helpers64),\n\n  spliceAppendCopy1:  spliceAppendCopy(helpers1),\n  spliceAppendCopy8:  spliceAppendCopy(helpers8),\n  spliceAppendCopy64:  spliceAppendCopy(helpers64),\n\n  spliceAppendOnly1:  spliceAppendOnly(helpers1),\n  spliceAppendOnly8:  spliceAppendOnly(helpers8),\n  spliceAppendOnly64:  spliceAppendOnly(helpers64),\n};\n\nvar timedArraySpliceFuncs = {\n  // The following two naive implementations cannot cope with large arrays, and raise\n  // \"RangeError: Maximum call stack size exceeded\".\n\n  //spliceApplyConcat:  spliceApplyConcat,\n  //spliceApplyUnshift:  spliceApplyUnshift,\n\n  // This isn't a real splice, it doesn't modify the array.\n  //nonSpliceUsingSlice:  nonSpliceUsingSlice,\n\n  // The implementations commented out below are the slower ones.\n  spliceGutil:  gutil.arraySplice,\n  spliceManualWithTailCopy:  spliceManualWithTailCopy,\n\n  spliceCopyWithTail1:  spliceCopyWithTail(helpers1),\n  //spliceCopyWithTail8:  spliceCopyWithTail(helpers8),\n  //spliceCopyWithTail64:  spliceCopyWithTail(helpers64),\n\n  //spliceFwdBackCopy1:  spliceFwdBackCopy(helpers1),\n  //spliceFwdBackCopy8:  spliceFwdBackCopy(helpers8),\n  //spliceFwdBackCopy64:  spliceFwdBackCopy(helpers64),\n\n  spliceAppendCopy1:  spliceAppendCopy(helpers1),\n  spliceAppendCopy8:  spliceAppendCopy(helpers8),\n  spliceAppendCopy64:  spliceAppendCopy(helpers64),\n\n  //spliceAppendOnly1:  spliceAppendOnly(helpers1),\n  //spliceAppendOnly8:  spliceAppendOnly(helpers8),\n  //spliceAppendOnly64:  spliceAppendOnly(helpers64),\n};\n\n//----------------------------------------------------------------------\n\ndescribe(\"array copy functions\", function() {\n  it(\"copyForward should copy correctly\", function() {\n    _.each(copyForward, function(copyFunc, name) {\n      var data = _.range(10000);\n      copyFunc(data, 0, data, 1, 9999);\n      copyFunc(data, 0, data, 1, 9999);\n      assert.equal(data[0], 2);\n      assert.equal(data[1], 3);\n      assert.equal(data[9996], 9998);\n      assert.equal(data[9997], 9999);\n      assert.equal(data[9998], 9999);\n      assert.equal(data[9999], 9999);\n    });\n  });\n\n  it(\"copyBackward should copy correctly\", function() {\n    _.each(copyBackward, function(copyFunc, name) {\n      var data = _.range(10000);\n      copyFunc(data, 1, data, 0, 9999);\n      copyFunc(data, 1, data, 0, 9999);\n      assert.equal(data[0], 0);\n      assert.equal(data[1], 0);\n      assert.equal(data[2], 0);\n      assert.equal(data[3], 1);\n      assert.equal(data[9998], 9996);\n      assert.equal(data[9999], 9997);\n    });\n  });\n\n  it(\"arrayAppend should append correctly\", function() {\n    _.each(append, function(appendFunc, name) {\n      var out = [];\n      var data = _.range(20000);\n      appendFunc(out, data, 100, 1);\n      appendFunc(out, data, 100, 1000);\n      appendFunc(out, data, 100, 10000);\n      assert.deepEqual(out.slice(0, 4), [100, 100, 101, 102]);\n      assert.deepEqual(out.slice(1000, 1004), [1099, 100, 101, 102]);\n      assert.deepEqual(out.slice(11000), [10099]);\n    });\n  });\n\n  // See ENABLE_TIMING_TESTS flag on top of this file.\n  if (ENABLE_TIMING_TESTS) {\n    describe(\"timing\", function() {\n      var a1m = _.range(1000000);\n      describe(\"copyForward\", function() {\n        var reps = 40;\n        _.each(copyForward, function(copyFunc, name) {\n          var b1m = a1m.slice(0);\n          it(name, function() {\n            utils.repeat(reps, copyFunc, b1m, 0, b1m, 1, 999999);\n\n            // Make sure it actually worked. These checks shouldn't affect timings much.\n            assert.deepEqual(b1m.slice(0, 10), _.range(reps, reps + 10));\n            assert.equal(b1m[999999-reps-1], 999998);\n            assert.equal(b1m[999999-reps], 999999);\n            assert.deepEqual(b1m.slice(1000000-reps), _.times(reps, _.constant(999999)));\n          });\n        });\n      });\n\n      describe(\"copyBackward\", function() {\n        var reps = 40;\n        _.each(copyBackward, function(copyFunc, name) {\n          var b1m = a1m.slice(0);\n          it(name, function() {\n            utils.repeat(reps, copyFunc, b1m, 1, b1m, 0, 999999);\n\n            // Make sure it actually worked. These checks shouldn't affect timings much.\n            assert.deepEqual(b1m.slice(0, reps), _.times(reps, _.constant(0)));\n            assert.equal(b1m[reps], 0);\n            assert.equal(b1m[reps + 1], 1);\n            assert.deepEqual(b1m.slice(999990), _.range(999990-reps, 1000000-reps));\n          });\n        });\n      });\n\n      describe(\"append\", function() {\n        var data = _.range(1000000);\n        function chunkedAppend(appendFunc, data, chunk) {\n          var out = [];\n          var count = data.length / chunk;\n          for (var i = 0; i < count; i++) {\n            appendFunc(out, data, i * chunk, chunk);\n          }\n          return out;\n        }\n\n        _.each(append, function(appendFunc, name) {\n          it(name, function() {\n            var out1 = chunkedAppend(appendFunc, data, 1);\n            var out2 = chunkedAppend(appendFunc, data, 1000);\n            var out3 = chunkedAppend(appendFunc, data, 1000000);\n\n            // Make sure it actually worked. Keep the checks short to avoid affecting timings.\n            assert.deepEqual(out1.slice(0, 10), data.slice(0, 10));\n            assert.deepEqual(out1.slice(data.length - 10), data.slice(data.length - 10));\n            assert.deepEqual(out2.slice(0, 10), data.slice(0, 10));\n            assert.deepEqual(out2.slice(data.length - 10), data.slice(data.length - 10));\n            assert.deepEqual(out3.slice(0, 10), data.slice(0, 10));\n            assert.deepEqual(out3.slice(data.length - 10), data.slice(data.length - 10));\n          });\n        });\n      });\n    });\n  }\n});\n\ndescribe(\"arraySplice\", function() {\n\n  // Make sure all our functions produce the same results as spliceApplyConcat for simple cases.\n  var refSpliceFunc = spliceApplyConcat;\n\n  it(\"all candidate functions should be correct for simpler cases\", function() {\n    _.each(allArraySpliceFuncs, function(spliceFunc, name) {\n      var a10 = _.range(10), a100 = _.range(100);\n      function checkSpliceFunc(target, start, arrToInsert) {\n        assert.deepEqual(spliceFunc(target.slice(0), start, arrToInsert),\n          refSpliceFunc(target.slice(0), start, arrToInsert),\n          \"splice function incorrect for \" + name);\n      }\n\n      checkSpliceFunc(a10, 5, a100);\n      checkSpliceFunc(a100, 50, a10);\n      checkSpliceFunc(a100, 90, a10);\n      checkSpliceFunc(a100, 0, a10);\n      checkSpliceFunc(a100, 100, a10);\n      checkSpliceFunc(a10, 0, a100);\n      checkSpliceFunc(a10, 10, a100);\n      checkSpliceFunc(a10, 1, a10);\n      checkSpliceFunc(a10, 5, a10);\n      checkSpliceFunc(a10, 5, []);\n      assert.deepEqual(spliceFunc(a10.slice(0), 5, a10),\n        [0, 1, 2, 3, 4, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 5, 6, 7, 8, 9]);\n    });\n  });\n\n  // See ENABLE_TIMING_TESTS flag on top of this file.\n  if (ENABLE_TIMING_TESTS) {\n    describe(\"timing\", function() {\n      var a1 = _.range(1);\n      var a1k = _.range(1000);\n      var a1m = _.range(1000000);\n\n      describe(\"insert-one\", function() {\n        _.each(timedArraySpliceFuncs, function(spliceFunc, name) {\n          var b1m = a1m.slice(0);\n          it(name, function() {\n            utils.repeat(40, spliceFunc, b1m, 500000, a1);\n          });\n        });\n      });\n\n      describe(\"insert-1k\", function() {\n        _.each(timedArraySpliceFuncs, function(spliceFunc, name) {\n          var b1m = a1m.slice(0);\n          it(name, function() {\n            utils.repeat(40, spliceFunc, b1m, 500000, a1k);\n          });\n        });\n      });\n\n      describe(\"insert-1m\", function() {\n        _.each(timedArraySpliceFuncs, function(spliceFunc, name) {\n          var b1m = a1m.slice(0);\n          it(name, function() {\n            utils.repeat(4, spliceFunc, b1m, 500000, a1m);\n          });\n        });\n      });\n    });\n  }\n});\n"
  },
  {
    "path": "test/common/csvFormat.ts",
    "content": "import * as csvFormat from \"app/common/csvFormat\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"csvFormat\", function() {\n  it(\"should encode/decode csv values correctly\", function() {\n    function verify(plain: string, encoded: string) {\n      assert.equal(csvFormat.csvEncodeCell(plain), encoded);\n      assert.equal(csvFormat.csvDecodeCell(encoded), plain);\n    }\n    verify(\"hello world\", \"hello world\");\n    verify(`Commas,, galore, `, `\"Commas,, galore, \"`);\n    verify(`\"Quote\" 'me,', \"\"please!\"\"`, `\"\"\"Quote\"\" 'me,', \"\"\"\"please!\"\"\"\"\"`);\n    verify(` sing\"le `, `\" sing\"\"le \"`);\n    verify(``, ``);\n    verify(`\"\"`, `\"\"\"\"\"\"`);\n    verify(`\\t\\n'\\`\\\\`, `\"\\t\\n'\\`\\\\\"`);\n    // The exact interpretation of invalid encodings isn't too important, but should include most\n    // of the value and not throw exceptions.\n    assert.equal(csvFormat.csvDecodeCell(`invalid\"e\\ncoding `), `invalid\"e\\ncoding`);\n    assert.equal(csvFormat.csvDecodeCell(`\"invalid\"e`), `invalid\"e`);\n  });\n\n  it(\"should encode/decode csv rows correctly\", function() {\n    function verify(plain: string[], encoded: string, prettier: boolean) {\n      assert.equal(csvFormat.csvEncodeRow(plain, { prettier }), encoded);\n      assert.deepEqual(csvFormat.csvDecodeRow(encoded), plain);\n    }\n    verify([\"hello\", \"world\"], \"hello,world\", false);\n    verify([\"hello\", \"world\"], \"hello, world\", true);\n    verify([\"hello \", \" world\"], `\"hello \",\" world\"`, false);\n    verify([\"hello \", \" world\"], `\"hello \", \" world\"`, true);\n    verify([\" \"], `\" \"`, false);\n    verify([\"\", \"\"], `,`, false);\n    verify([\"\", \" \", \"\"], `, \" \", `, true);\n    verify([\n      \"Commas,, galore, \",\n      `\"Quote\" 'me,', \"\"please!\"\"`,\n      ` sing\"le `,\n      \" \",\n      \"\",\n    ], `\"Commas,, galore, \",\"\"\"Quote\"\" 'me,', \"\"\"\"please!\"\"\"\"\",\" sing\"\"le \",\" \",`, false);\n    verify([\"Medium\", \"Very high\", `with, comma*=~!|more`, `asdf\\nsdf`],\n      `Medium, Very high, \"with, comma*=~!|more\", \"asdf\\nsdf\"`, true);\n    // The exact interpretation of invalid encodings isn't too important, but should include most\n    // of the value and not throw exceptions.\n    assert.deepEqual(csvFormat.csvDecodeRow(`invalid\"e\\ncoding,\",\"`),\n      ['invalid\"e\\ncoding,', \"\"]);\n  });\n});\n"
  },
  {
    "path": "test/common/getTableTitle.ts",
    "content": "import { getTableTitle } from \"app/common/ActiveDocAPI\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"getTableTitle\", function() {\n  it(\"should construct correct table titles\", async function() {\n    function check(groupByColLabels: string[] | null, expected: string) {\n      assert.equal(getTableTitle({ title: \"My Table\", groupByColLabels, colIds: [] }), expected);\n    }\n\n    check(null, \"My Table\");\n    check([], \"My Table [Totals]\");\n    check([\"A\"], \"My Table [by A]\");\n    check([\"A\", \"B\"], \"My Table [by A, B]\");\n  });\n});\n"
  },
  {
    "path": "test/common/gristUrls.ts",
    "content": "import { commonUrls as defaultCommonUrls,\n  decodeUrl, getCommonUrls,\n  getHostType, getSlugIfNeeded, IGristUrlState, parseFirstUrlPart,\n} from \"app/common/gristUrls\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport Sinon from \"sinon\";\n\ndescribe(\"gristUrls\", function() {\n  let sandbox: Sinon.SinonSandbox;\n\n  beforeEach(function() {\n    sandbox = Sinon.createSandbox();\n  });\n\n  afterEach(function() {\n    sandbox.restore();\n  });\n\n  function assertUrlDecode(url: string, expected: Partial<IGristUrlState>) {\n    const actual = decodeUrl({}, new URL(url));\n\n    for (const property in expected) {\n      const expectedValue = expected[property as keyof IGristUrlState];\n      const actualValue = actual[property as keyof IGristUrlState];\n\n      assert.deepEqual(actualValue, expectedValue);\n    }\n  }\n\n  describe(\"encodeUrl\", function() {\n    it(\"should detect theme appearance override\", function() {\n      assertUrlDecode(\n        \"http://localhost/?themeAppearance=light\",\n        { params: { themeAppearance: \"light\" } },\n      );\n\n      assertUrlDecode(\n        \"http://localhost/?themeAppearance=dark\",\n        { params: { themeAppearance: \"dark\" } },\n      );\n    });\n\n    it(\"should detect theme sync with os override\", function() {\n      assertUrlDecode(\n        \"http://localhost/?themeSyncWithOs=true\",\n        { params: { themeSyncWithOs: true } },\n      );\n    });\n\n    it(\"should detect theme name override\", function() {\n      assertUrlDecode(\n        \"http://localhost/?themeName=GristLight\",\n        { params: { themeName: \"GristLight\" } },\n      );\n\n      assertUrlDecode(\n        \"http://localhost/?themeName=GristDark\",\n        { params: { themeName: \"GristDark\" } },\n      );\n    });\n\n    it(\"should detect API URLs\", function() {\n      assertUrlDecode(\n        \"http://localhost/o/docs/api/docs\",\n        { api: true },\n      );\n\n      assertUrlDecode(\n        \"http://public.getgrist.com/api/docs\",\n        { api: true },\n      );\n    });\n  });\n\n  describe(\"parseFirstUrlPart\", function() {\n    it(\"should strip out matching tag\", function() {\n      assert.deepEqual(parseFirstUrlPart(\"o\", \"/o/foo/bar?x#y\"), { value: \"foo\", path: \"/bar?x#y\" });\n      assert.deepEqual(parseFirstUrlPart(\"o\", \"/o/foo?x#y\"), { value: \"foo\", path: \"/?x#y\" });\n      assert.deepEqual(parseFirstUrlPart(\"o\", \"/o/foo#y\"), { value: \"foo\", path: \"/#y\" });\n      assert.deepEqual(parseFirstUrlPart(\"o\", \"/o/foo\"), { value: \"foo\", path: \"/\" });\n    });\n\n    it(\"should pass unchanged non-matching path or tag\", function() {\n      assert.deepEqual(parseFirstUrlPart(\"xxx\", \"/o/foo/bar?x#y\"), { path: \"/o/foo/bar?x#y\" });\n      assert.deepEqual(parseFirstUrlPart(\"o\", \"/O/foo/bar?x#y\"), { path: \"/O/foo/bar?x#y\" });\n      assert.deepEqual(parseFirstUrlPart(\"o\", \"/bar?x#y\"), { path: \"/bar?x#y\" });\n      assert.deepEqual(parseFirstUrlPart(\"o\", \"/o/?x#y\"), { path: \"/o/?x#y\" });\n      assert.deepEqual(parseFirstUrlPart(\"o\", \"/#y\"), { path: \"/#y\" });\n      assert.deepEqual(parseFirstUrlPart(\"o\", \"\"), { path: \"\" });\n    });\n  });\n\n  describe(\"getHostType\", function() {\n    const defaultOptions = {\n      baseDomain: \"getgrist.com\",\n      pluginUrl: \"https://plugin.getgrist.com\",\n    };\n\n    let oldEnv: testUtils.EnvironmentSnapshot;\n\n    beforeEach(function() {\n      oldEnv = new testUtils.EnvironmentSnapshot();\n    });\n\n    afterEach(function() {\n      oldEnv.restore();\n    });\n\n    it('should interpret localhost as \"native\"', function() {\n      assert.equal(getHostType(\"localhost\", defaultOptions), \"native\");\n      assert.equal(getHostType(\"localhost:8080\", defaultOptions), \"native\");\n    });\n\n    it('should interpret base domain as \"native\"', function() {\n      assert.equal(getHostType(\"getgrist.com\", defaultOptions), \"native\");\n      assert.equal(getHostType(\"www.getgrist.com\", defaultOptions), \"native\");\n      assert.equal(getHostType(\"foo.getgrist.com\", defaultOptions), \"native\");\n      assert.equal(getHostType(\"foo.getgrist.com:8080\", defaultOptions), \"native\");\n    });\n\n    it('should interpret plugin domain as \"plugin\"', function() {\n      assert.equal(getHostType(\"plugin.getgrist.com\", defaultOptions), \"plugin\");\n      assert.equal(getHostType(\"PLUGIN.getgrist.com\", { pluginUrl: \"https://pLuGin.getgrist.com\" }), \"plugin\");\n    });\n\n    it('should interpret other domains as \"custom\"', function() {\n      assert.equal(getHostType(\"foo.com\", defaultOptions), \"custom\");\n      assert.equal(getHostType(\"foo.bar.com\", defaultOptions), \"custom\");\n    });\n\n    it('should interpret doc internal url as \"native\"', function() {\n      sandbox.define(process.env, \"APP_DOC_INTERNAL_URL\", \"https://doc-worker-123.internal/path\");\n      assert.equal(getHostType(\"doc-worker-123.internal\", defaultOptions), \"native\");\n      assert.equal(getHostType(\"doc-worker-123.internal:8080\", defaultOptions), \"custom\");\n      assert.equal(getHostType(\"doc-worker-124.internal\", defaultOptions), \"custom\");\n\n      sandbox.restore();\n      sandbox.define(process.env, \"APP_DOC_INTERNAL_URL\", \"https://doc-worker-123.internal:8080/path\");\n      assert.equal(getHostType(\"doc-worker-123.internal:8080\", defaultOptions), \"native\");\n      assert.equal(getHostType(\"doc-worker-123.internal\", defaultOptions), \"custom\");\n      assert.equal(getHostType(\"doc-worker-124.internal:8080\", defaultOptions), \"custom\");\n      assert.equal(getHostType(\"doc-worker-123.internal:8079\", defaultOptions), \"custom\");\n    });\n  });\n\n  describe(\"getSlugIfNeeded\", function() {\n    it(\"should only return a slug when a valid urlId is used\", function() {\n      assert.strictEqual(getSlugIfNeeded({ id: \"1234567890abcdef\", urlId: \"1234567890ab\", name: \"Foo\" }), \"Foo\");\n      // urlId too short\n      assert.strictEqual(getSlugIfNeeded({ id: \"1234567890abcdef\", urlId: \"12345678\", name: \"Foo\" }), undefined);\n      // urlId doesn't match docId\n      assert.strictEqual(getSlugIfNeeded({ id: \"1234567890abcdef\", urlId: \"1234567890ac\", name: \"Foo\" }), undefined);\n      // no urlId\n      assert.strictEqual(getSlugIfNeeded({ id: \"1234567890abcdef\", urlId: \"\", name: \"Foo\" }), undefined);\n      assert.strictEqual(getSlugIfNeeded({ id: \"1234567890abcdef\", urlId: null, name: \"Foo\" }), undefined);\n    });\n\n    it(\"should leave only alphamerics after replacing reasonable unicode chars\", function() {\n      const id = \"1234567890abcdef\", urlId = \"1234567890ab\";\n      // This is mainly a test of the `slugify` library we now use. What matters isn't the\n      // specific result, but that the result is reasonable.\n      assert.strictEqual(getSlugIfNeeded({ id, urlId, name: \"Foo\" }), \"Foo\");\n      assert.strictEqual(getSlugIfNeeded({ id, urlId, name: \"Hélène's résumé\" }), \"Helenes-resume\");\n      assert.strictEqual(getSlugIfNeeded({ id, urlId, name: \"Привіт, Їжак!\" }), \"Privit-Yizhak\");\n      assert.strictEqual(getSlugIfNeeded({ id, urlId, name: \"S&P500 is ~$4,894.16\" }), \"SandP500-is-dollar489416\");\n    });\n  });\n\n  describe(\"getCommonUrls\", function() {\n    it(\"should return the default URLs\", function() {\n      const commonUrls = getCommonUrls();\n      assert.isObject(commonUrls);\n      assert.equal(commonUrls.help, \"https://support.getgrist.com\");\n    });\n\n    describe(\"with GRIST_CUSTOM_COMMON_URLS env var set\", function() {\n      it(\"should return the values set by the GRIST_CUSTOM_COMMON_URLS env var\", function() {\n        const customHelpCenterUrl = \"http://custom.helpcenter\";\n        sandbox.define(process.env, \"GRIST_CUSTOM_COMMON_URLS\",\n          `{\"help\": \"${customHelpCenterUrl}\"}`);\n        const commonUrls = getCommonUrls();\n        assert.isObject(commonUrls);\n        assert.equal(commonUrls.help, customHelpCenterUrl);\n        assert.equal(commonUrls.helpAccessRules, \"https://support.getgrist.com/access-rules\");\n      });\n\n      it(\"should throw when keys extraneous to the ICommonUrls interface are added\", function() {\n        const nonExistingKey = \"iDontExist\";\n        sandbox.define(process.env, \"GRIST_CUSTOM_COMMON_URLS\",\n          `{\"${nonExistingKey}\": \"foo\", \"help\": \"https://getgrist.com\"}`);\n        assert.throws(() => getCommonUrls(), `value.${nonExistingKey} is extraneous`);\n      });\n\n      it(\"should throw when the passed JSON is malformed\", function() {\n        sandbox.define(process.env, \"GRIST_CUSTOM_COMMON_URLS\", '{\"malformed\": 42');\n        assert.throws(() => getCommonUrls(), \"The JSON passed to GRIST_CUSTOM_COMMON_URLS is malformed\");\n      });\n\n      it(\"should throw when keys has unexpected type\", function() {\n        const regularValueKey = \"help\";\n        const numberValueKey = \"helpAccessRules\";\n        const objectValueKey = \"helpAssistant\";\n        const arrayValueKey = \"helpAssistantDataUse\";\n        const nullValueKey = \"helpFormulaAssistantDataUse\";\n\n        sandbox.define(process.env, \"GRIST_CUSTOM_COMMON_URLS\",\n          JSON.stringify({\n            [regularValueKey]: \"https://getgrist.com\",\n            [numberValueKey]: 42,\n            [objectValueKey]: { key: \"value\" },\n            [arrayValueKey]: [\"foo\"],\n          }),\n        );\n        const buildExpectedErrRegEx = (...keys: string[]) => new RegExp(\n          keys.map(key => `value\\\\.${key}`).join(\".*\"),\n          \"ms\",\n        );\n        assert.throws(() => getCommonUrls(), buildExpectedErrRegEx(numberValueKey, objectValueKey, arrayValueKey));\n        sandbox.restore();\n        sandbox.define(process.env, \"GRIST_CUSTOM_COMMON_URLS\",\n          JSON.stringify({\n            [regularValueKey]: \"https://getgrist.com\",\n            [nullValueKey]: null,\n          }),\n        );\n        assert.throws(() => getCommonUrls(), buildExpectedErrRegEx(nullValueKey));\n      });\n\n      it(\"should return the default URLs when the parsed value is not an object\", function() {\n        sandbox.define(process.env, \"GRIST_CUSTOM_COMMON_URLS\", \"42\");\n        assert.deepEqual(getCommonUrls(), defaultCommonUrls);\n        sandbox.restore();\n        sandbox.define(process.env, \"GRIST_CUSTOM_COMMON_URLS\", \"null\");\n        assert.deepEqual(getCommonUrls(), defaultCommonUrls);\n      });\n    });\n\n    describe(\"client-side when customized by the admin\", function() {\n      it(\"should read the admin-defined values gristConfig\", function() {\n        sandbox.define(globalThis, \"window\", {\n          gristConfig: {\n            adminDefinedUrls: JSON.stringify({\n              help: \"https://getgrist.com\",\n            }),\n          },\n          // Fake location to make isClient() believe the code is executed client-side.\n          location: {\n            hostname: \"getgrist.com\",\n          },\n        });\n        const commonUrls = getCommonUrls();\n        assert.isObject(commonUrls);\n        assert.equal(commonUrls.help, \"https://getgrist.com\");\n        assert.equal(commonUrls.helpAccessRules, \"https://support.getgrist.com/access-rules\");\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/gutil.js",
    "content": "var assert = require(\"chai\").assert;\nvar gutil = require(\"app/common/gutil\");\nvar _ = require(\"underscore\");\n\ndescribe(\"gutil\", function() {\n\n  describe(\"mapToObject\", function() {\n    it(\"should produce an object with all keys\", function() {\n      assert.deepEqual(gutil.mapToObject([\"foo\", \"bar\", \"baz\"], function(value, i) {\n        return [value.toUpperCase(), i];\n      }), {\n        \"foo\": [\"FOO\", 0],\n        \"bar\": [\"BAR\", 1],\n        \"baz\": [\"BAZ\", 2]\n      });\n\n      assert.deepEqual(gutil.mapToObject([\"foo\", \"bar\", \"baz\"], function() {}), {\n        \"foo\": undefined,\n        \"bar\": undefined,\n        \"baz\": undefined,\n      });\n    });\n\n    it(\"should work on an empty array\", function() {\n      var countCalls = 0;\n      assert.deepEqual(gutil.mapToObject([], function() { countCalls++; }), {});\n      assert.equal(countCalls, 0);\n    });\n\n    it(\"should override values for duplicate keys\", function() {\n      assert.deepEqual(gutil.mapToObject([\"foo\", \"bar\", \"foo\"], function(val, i) { return i; }),\n        { \"foo\": 2, \"bar\": 1 });\n    });\n  });\n\n  describe(\"multiCompareFunc\", function() {\n    var firstName = {\n      0: \"John\",\n      1: \"John\",\n      2: \"John\",\n      3: \"John\",\n      4: \"Johnson\",\n      5: \"Johnson\",\n    };\n    var lastName = {\n      0: \"Smith\",\n      1: \"Smith\",\n      2: \"Smith\",\n      3: \"Smithy\",\n      4: \"Smithy\",\n      5: \"Smith\",\n    };\n    var age = {\n      0: 20,\n      1: 30,\n      2: 21,\n      3: 31,\n      4: 40,\n      5: 50,\n    };\n\n    it(\"should do single comparisons\", function() {\n      var sort1 = [_.propertyOf(firstName)];\n      var compareA = gutil.multiCompareFunc(sort1, [gutil.nativeCompare], [1]);\n      var compareD = gutil.multiCompareFunc(sort1, [gutil.nativeCompare], [-1]);\n      assert.equal(compareA(0, 1), 0);   // John == John\n      assert.equal(compareD(0, 1), 0);\n      assert.isBelow(compareA(0, 4), 0); // John < Johnson if ascending\n      assert.isAbove(compareA(4, 0), 0);\n      assert.isAbove(compareD(0, 4), 0); // John > Johnson if descending\n      assert.isBelow(compareD(4, 0), 0);\n    });\n\n    it(\"should do multiple comparisons\", function() {\n      var sort2 = [_.propertyOf(firstName), _.propertyOf(lastName)];\n      var sort3 = [_.propertyOf(firstName), _.propertyOf(lastName), _.propertyOf(age)];\n      var compare2 = gutil.multiCompareFunc(sort2, [gutil.nativeCompare, gutil.nativeCompare], [1, 1]);\n      var compare3 = gutil.multiCompareFunc(sort3,\n        [gutil.nativeCompare, gutil.nativeCompare, gutil.nativeCompare], [1, 1, -1]);\n\n      assert.equal(compare2(0, 1), 0); // John Smith, 20 = John Smith, 30\n      assert.equal(compare2(1, 2), 0); // John Smith, 30 = John Smith, 21\n      assert.isBelow(compare2(0, 3), 0); // John Smith < John Smithy\n      assert.isBelow(compare2(0, 4), 0); // John Smith < Johnson Smithy\n      assert.isBelow(compare2(0, 5), 0); // John Smith < Johnson Smith\n\n      assert.isAbove(compare3(0, 1), 0); // John Smith, 20 > John Smith, 30 (age descending)\n      assert.isBelow(compare3(1, 2), 0); // John Smith, 30 < John Smith, 21\n      assert.isBelow(compare3(0, 3), 0); // John Smith, 20 < John Smithy, 31\n      assert.isBelow(compare3(0, 4), 0); // John Smith, 20 < Johnson Smithy, 40\n      assert.isBelow(compare3(3, 4), 0); // John Smithy, 20 < Johnson Smithy, 40\n      assert.isAbove(compare3(4, 5), 0); // Johnson Smithy > Johnson Smith\n    });\n  });\n\n  describe(\"deepExtend\", function() {\n    var sample = {\n      a: 1,\n      b: \"hello\",\n      c: [1, 2, 3],\n      d: { e: 1, f: 2 }\n    };\n    it(\"should copy recursively\", function() {\n      assert.deepEqual(gutil.deepExtend({}, {}), {});\n      assert.deepEqual(gutil.deepExtend({}, sample), sample);\n      assert.deepEqual(gutil.deepExtend({}, sample, {}), sample);\n      assert.deepEqual(gutil.deepExtend({}, sample, sample), sample);\n      assert.deepEqual(gutil.deepExtend({}, sample, {a: 2}).a, 2);\n      assert.deepEqual(gutil.deepExtend({}, sample, {d: {g: 3}}).d, {e:1, f:2, g:3});\n      assert.deepEqual(gutil.deepExtend({c: [4, 5, 6, 7], d: {g: 3}}, sample).d, {e:1, f:2, g:3});\n      assert.deepEqual(gutil.deepExtend({c: [4, 5, 6, 7], d: {g: 3}}, sample).c, [1, 2, 3, 7]);\n    });\n  });\n\n  describe(\"maxsplit\", function() {\n    it(\"should respect maxNumSplits parameter\", function() {\n      assert.deepEqual(gutil.maxsplit(\"foo bar baz\", \" \", 0), [\"foo bar baz\"]);\n      assert.deepEqual(gutil.maxsplit(\"foo bar baz\", \" \", 1), [\"foo\", \"bar baz\"]);\n      assert.deepEqual(gutil.maxsplit(\"foo bar baz\", \" \", 2), [\"foo\", \"bar\", \"baz\"]);\n      assert.deepEqual(gutil.maxsplit(\"foo bar baz\", \" \", 3), [\"foo\", \"bar\", \"baz\"]);\n      assert.deepEqual(gutil.maxsplit(\"foo<x>bar<x>baz\", \"<x>\", 1), [\"foo\", \"bar<x>baz\"]);\n    });\n  });\n\n  describe(\"arrayInsertBefore\", function() {\n    it(\"should insert before the given nextValue\", function() {\n      var array = [\"foo\", \"bar\", \"baz\"];\n      gutil.arrayInsertBefore(array, \"asdf\", \"foo\");\n      assert.deepEqual(array, [\"asdf\", \"foo\", \"bar\", \"baz\"]);\n      gutil.arrayInsertBefore(array, \"hello\", \"baz\");\n      assert.deepEqual(array, [\"asdf\", \"foo\", \"bar\", \"hello\", \"baz\"]);\n      gutil.arrayInsertBefore(array, \"zoo\", \"unknown\");\n      assert.deepEqual(array, [\"asdf\", \"foo\", \"bar\", \"hello\", \"baz\", \"zoo\"]);\n    });\n  });\n\n  describe(\"popFromMap\", function() {\n    it(\"should return the value for the popped key\", function() {\n      var map = new Map([[\"foo\", 1], [\"bar\", 2], [\"baz\", 3]]);\n      assert.equal(gutil.popFromMap(map, \"bar\"), 2);\n      assert.deepEqual(Array.from(map), [[\"foo\", 1], [\"baz\", 3]]);\n      assert.strictEqual(gutil.popFromMap(map, \"unknown\"), undefined);\n      assert.deepEqual(Array.from(map), [[\"foo\", 1], [\"baz\", 3]]);\n    });\n  });\n\n  describe(\"isSubset\", function() {\n    it(\"should determine the subset relationship for Sets\", function() {\n      let sEmpty = new Set(),\n        sFoo = new Set([1]),\n        sBar = new Set([2, 3]),\n        sBaz = new Set([1, 2, 3]);\n\n      assert.isTrue(gutil.isSubset(sEmpty, sFoo));\n      assert.isFalse(gutil.isSubset(sFoo, sEmpty));\n\n      assert.isTrue(gutil.isSubset(sFoo, sBaz));\n      assert.isFalse(gutil.isSubset(sFoo, sBar));\n\n      assert.isTrue(gutil.isSubset(sBar, sBaz));\n      assert.isTrue(gutil.isSubset(sBar, sBar));\n\n      assert.isTrue(gutil.isSubset(sBaz, sBaz));\n      assert.isFalse(gutil.isSubset(sBaz, sBar));\n    });\n  });\n\n  describe(\"growMatrix\", function() {\n    it(\"should grow the matrix to the desired size\", function() {\n      let matrix = [[\"a\", 1], [\"b\", 2], [\"c\", 3]];\n      assert.deepEqual(gutil.growMatrix(matrix, 4, 4),\n        [[\"a\", 1, \"a\", 1],\n          [\"b\", 2, \"b\", 2],\n          [\"c\", 3, \"c\", 3],\n          [\"a\", 1, \"a\", 1]]);\n      assert.deepEqual(gutil.growMatrix(matrix, 3, 4),\n        [[\"a\", 1, \"a\", 1],\n          [\"b\", 2, \"b\", 2],\n          [\"c\", 3, \"c\", 3]]);\n      assert.deepEqual(gutil.growMatrix(matrix, 6, 2),\n        [[\"a\", 1],\n          [\"b\", 2],\n          [\"c\", 3],\n          [\"a\", 1],\n          [\"b\", 2],\n          [\"c\", 3]]);\n    });\n  });\n\n  describe(\"sortedScan\", function() {\n    it(\"should callback on the correct items for simple arrays\", function() {\n      const a = [1, 2, 4, 5, 7, 8, 9, 10, 11, 15, 17];\n      const b = [2, 3, 4, 5, 9, 11, 19];\n\n      // Run the scan function, allowing it to populate callArgs.\n      let callArgs = [];\n      gutil.sortedScan(a, b, (ai, bi) => { callArgs.push([ai, bi]); });\n\n      assert.deepEqual(callArgs,\n        [[1, null], [2, 2], [null, 3], [4, 4],\n          [5, 5], [7, null], [8, null], [9, 9],\n          [10, null], [11, 11], [15, null], [17, null],\n          [null, 19]]);\n    });\n\n    it(\"should callback on the correct items for object arrays\", function() {\n      const a = [{ id: 1,  fruit: \"apple\"     },\n        { id: 2,  fruit: \"banana\"    },\n        { id: 4,  fruit: \"orange\"    },\n        { id: 5,  fruit: \"peach\"     },\n        { id: 6,  fruit: \"plum\"      }];\n      const b = [{ id: 2,  fruit: \"apple\"     },\n        { id: 3,  fruit: \"avocado\"   },\n        { id: 4,  fruit: \"peach\"     },\n        { id: 6,  fruit: \"pear\"      },\n        { id: 9,  fruit: \"plum\"      },\n        { id: 10, fruit: \"raspberry\" }];\n\n      // Run the scan function.\n      let fruitArgs = [];\n      gutil.sortedScan(a, b, (ai, bi) => {\n        fruitArgs.push([ai ? ai.fruit : \"\", bi ? bi.fruit : \"\"]);\n      }, item => item.id);\n\n      assert.deepEqual(fruitArgs,\n        [[\"apple\", \"\"], [\"banana\", \"apple\"], [\"\", \"avocado\"],\n          [\"orange\", \"peach\"], [\"peach\", \"\"], [\"plum\", \"pear\"],\n          [\"\", \"plum\"], [\"\", \"raspberry\"]]);\n\n      // Run the scan function again, using fruit as the key.\n      let idArgs = [];\n      gutil.sortedScan(a, b, (ai, bi) => {\n        idArgs.push([ai ? ai.id : 0, bi ? bi.id : 0]);\n      }, item => item.fruit);\n\n      assert.deepEqual(idArgs,\n        [[1, 2], [0, 3], [2, 0], [4, 0],\n          [5, 4], [0, 6], [6, 9], [0, 10]]);\n    });\n  });\n\n  describe(\"isEmail\", function() {\n    it(\"should distinguish valid and invalid emails\", function() {\n      // Reference: https://blogs.msdn.microsoft.com/testing123/2009/02/06/email-address-test-cases/\n      assert.isTrue(gutil.isEmail(\"email@domain.com\"));\n      assert.isTrue(gutil.isEmail(\"e-mail_123@domain.com\"));\n      assert.isTrue(gutil.isEmail(\"email@subdomain.do-main.com\"));\n      assert.isTrue(gutil.isEmail(\"firstname+lastname@domain.com\"));\n      assert.isTrue(gutil.isEmail(\"email@domain.co.jp\"));\n      assert.isTrue(gutil.isEmail(\"marie@isola.corsica\"));\n\n      assert.isFalse(gutil.isEmail(\"plainaddress\"));\n      assert.isFalse(gutil.isEmail(\"@domain.com\"));\n      assert.isFalse(gutil.isEmail(\"email@domain@domain.com\"));\n      assert.isFalse(gutil.isEmail(\".email@domain.com\"));\n      assert.isFalse(gutil.isEmail(\"email.@domain.com\"));\n      assert.isFalse(gutil.isEmail(\"email..email@domain.com\"));\n      assert.isFalse(gutil.isEmail(\"あいうえお@domain.com\"));\n      assert.isFalse(gutil.isEmail(\"email@domain\"));\n    });\n  });\n\n});\n"
  },
  {
    "path": "test/common/gutil2.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport * as gutil from \"app/common/gutil\";\n\nimport { assert } from \"chai\";\nimport { Observable } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"gutil2\", function() {\n  describe(\"waitObs\", function() {\n    it(\"should resolve promise when predicate matches\", async function() {\n      const obs: ko.Observable<number | null> = ko.observable<number | null>(null);\n      const promise1 = gutil.waitObs(obs, val => Boolean(val));\n      const promise2 = gutil.waitObs(obs, val => (val === null));\n      const promise3 = gutil.waitObs(obs, val => (val! > 20));\n      const spy1 = sinon.spy(), spy2 = sinon.spy(), spy3 = sinon.spy();\n      const done = Promise.all([\n        promise1.then((val) => { spy1(); assert.strictEqual(val, 17); }),\n        promise2.then((val) => { spy2(); assert.strictEqual(val, null); }),\n        promise3.then((val) => { spy3(); assert.strictEqual(val, 30); }),\n      ]);\n\n      await delay(1);\n      obs(17);\n      await delay(1);\n      obs(30);\n      await delay(1);\n\n      await done;\n      sinon.assert.callOrder(spy2, spy1, spy3);\n    });\n  });\n\n  describe(\"waitGrainObs\", function() {\n    it(\"should resolve promise when predicate matches\", async function() {\n      const obs = Observable.create<number | null>(null, null);\n      const promise1 = gutil.waitGrainObs(obs, val => Boolean(val));\n      const promise2 = gutil.waitGrainObs(obs, val => (val === null));\n      const promise3 = gutil.waitGrainObs(obs, val => (val! > 20));\n      const spy1 = sinon.spy(), spy2 = sinon.spy(), spy3 = sinon.spy();\n      const done = Promise.all([\n        promise1.then((val) => { spy1(); assert.strictEqual(val, 17); }),\n        promise2.then((val) => { spy2(); assert.strictEqual(val, null); }),\n        promise3.then((val) => { spy3(); assert.strictEqual(val, 30); }),\n      ]);\n\n      await delay(1);\n      obs.set(17);\n      await delay(1);\n      obs.set(30);\n      await delay(1);\n\n      await done;\n      sinon.assert.callOrder(spy2, spy1, spy3);\n    });\n  });\n\n  describe(\"PromiseChain\", function() {\n    it(\"should resolve promises in order\", async function() {\n      const chain = new gutil.PromiseChain();\n\n      const spy1 = sinon.spy(), spy2 = sinon.spy(), spy3 = sinon.spy();\n      const done = Promise.all([\n        chain.add(() => delay(30).then(spy1).then(() => 1)),\n        chain.add(() => delay(20).then(spy2).then(() => 2)),\n        chain.add(() => delay(10).then(spy3).then(() => 3)),\n      ]);\n      assert.deepEqual(await done, [1, 2, 3]);\n      sinon.assert.callOrder(spy1, spy2, spy3);\n    });\n\n    it(\"should skip pending callbacks, but not new callbacks, on error\", async function() {\n      const chain = new gutil.PromiseChain();\n\n      const spy1 = sinon.spy(), spy2 = sinon.spy(), spy3 = sinon.spy();\n      let res1: any, res2: any, res3: any;\n      await assert.isRejected(Promise.all([\n        res1 = chain.add(() => delay(30).then(spy1).then(() => { throw new Error(\"Err1\"); })),\n        res2 = chain.add(() => delay(20).then(spy2)),\n        res3 = chain.add(() => delay(10).then(spy3)),\n      ]), /Err1/);\n\n      // Check that already-scheduled callbacks did not get called.\n      sinon.assert.calledOnce(spy1);\n      sinon.assert.notCalled(spy2);\n      sinon.assert.notCalled(spy3);\n      spy1.resetHistory();\n\n      // Ensure skipped add() calls return a rejection.\n      await assert.isRejected(res1, /^Err1/);\n      await assert.isRejected(res2, /^Skipped due to an earlier error/);\n      await assert.isRejected(res3, /^Skipped due to an earlier error/);\n\n      // New promises do get scheduled.\n      await assert.isRejected(Promise.all([\n        res1 = chain.add(() => delay(1).then(spy1).then(() => 17)),\n        res2 = chain.add(() => delay(1).then(spy2).then(() => { throw new Error(\"Err2\"); })),\n        res3 = chain.add(() => delay(1).then(spy3)),\n      ]), /Err2/);\n      sinon.assert.callOrder(spy1, spy2);\n      sinon.assert.notCalled(spy3);\n\n      // Check the return values of add() calls.\n      assert.strictEqual(await res1, 17);\n      await assert.isRejected(res2, /^Err2/);\n      await assert.isRejected(res3, /^Skipped due to an earlier error/);\n    });\n  });\n\n  describe(\"isLongerThan\", function() {\n    it(\"should work correctly\", async function() {\n      assert.equal(await gutil.isLongerThan(delay(200), 100), true);\n      assert.equal(await gutil.isLongerThan(delay(10), 100), false);\n\n      // A promise that throws before the timeout, causes the returned promise to resolve to false.\n      const err = new Error(\"some error\");\n      let promise = delay(10).then(() => { throw err; });\n      assert.equal(await gutil.isLongerThan(promise, 100), false);\n      await assert.isRejected(promise, err);\n\n      // A promise that throws after the timeout, causes the returned promise to resolve to true.\n      promise = delay(200).then(() => { throw err; });\n      assert.equal(await gutil.isLongerThan(promise, 100), true);\n      await assert.isRejected(promise, err);\n    });\n  });\n\n  describe(\"timeoutReached\", function() {\n    const DELAY_1 = 20;\n    const DELAY_2 = 2 * DELAY_1;\n    it(\"should return true for timed out promise\", async function() {\n      assert.isTrue(await gutil.timeoutReached(DELAY_1, delay(DELAY_2)));\n      assert.isTrue(await gutil.timeoutReached(DELAY_1, delay(DELAY_2).then(() => { throw new Error(\"test error\"); })));\n    });\n\n    it(\"should return false for promise that completes before timeout\", async function() {\n      assert.isFalse(await gutil.timeoutReached(DELAY_2, delay(DELAY_1)));\n      assert.isFalse(await gutil.timeoutReached(DELAY_2, delay(DELAY_1)\n        .then(() => { throw new Error(\"test error\"); })));\n      assert.isFalse(await gutil.timeoutReached(DELAY_2, Promise.resolve(\"foo\")));\n      assert.isFalse(await gutil.timeoutReached(DELAY_2, Promise.reject(new Error(\"bar\"))));\n    });\n  });\n\n  describe(\"isValidHex\", function() {\n    it(\"should work correctly\", async function() {\n      assert.equal(gutil.isValidHex(\"#FF00FF\"), true);\n      assert.equal(gutil.isValidHex(\"#FF00FFF\"), false);\n      assert.equal(gutil.isValidHex(\"#FF0\"), false);\n      assert.equal(gutil.isValidHex(\"#FF00\"), false);\n      assert.equal(gutil.isValidHex(\"FF00FF\"), false);\n      assert.equal(gutil.isValidHex(\"#FF00FG\"), false);\n    });\n  });\n\n  describe(\"pruneArray\", function() {\n    function check<T>(arr: T[], indexes: number[], expect: T[]) {\n      gutil.pruneArray(arr, indexes);\n      assert.deepEqual(arr, expect);\n    }\n    it(\"should remove correct elements\", function() {\n      check([\"a\", \"b\", \"c\"], [], [\"a\", \"b\", \"c\"]);\n      check([\"a\", \"b\", \"c\"], [0], [\"b\", \"c\"]);\n      check([\"a\", \"b\", \"c\"], [1], [\"a\", \"c\"]);\n      check([\"a\", \"b\", \"c\"], [2], [\"a\", \"b\"]);\n      check([\"a\", \"b\", \"c\"], [0, 1], [\"c\"]);\n      check([\"a\", \"b\", \"c\"], [0, 2], [\"b\"]);\n      check([\"a\", \"b\", \"c\"], [1, 2], [\"a\"]);\n      check([\"a\", \"b\", \"c\"], [0, 1, 2], []);\n      check([], [], []);\n      check([\"a\"], [], [\"a\"]);\n      check([\"a\"], [0], []);\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/marshal.js",
    "content": "var assert  = require(\"chai\").assert;\nvar marshal = require(\"app/common/marshal\");\nvar MemBuffer = require(\"app/common/MemBuffer\");\n\n\ndescribe(\"marshal\", function() {\n  function binStringToArray(binaryString) {\n    var a = new Uint8Array(binaryString.length);\n    for (var i = 0; i < binaryString.length; i++) {\n      a[i] = binaryString.charCodeAt(i);\n    }\n    return a;\n  }\n  function arrayToBinString(array) {\n    return String.fromCharCode.apply(String, array);\n  }\n  var samples = [\n    [null, \"N\"],\n    [1, \"i\\x01\\x00\\x00\\x00\"],\n    [1000000, \"i@B\\x0f\\x00\"],\n    [-123456, \"i\\xc0\\x1d\\xfe\\xff\"],\n    [1.23, \"g\\xae\\x47\\xe1\\x7a\\x14\\xae\\xf3\\x3f\", 2],\n    [-625e-4, \"g\\x00\\x00\\x00\\x00\\x00\\x00\\xb0\\xbf\", 2],\n    [12.34, \"f\\x0512.34\", 0],\n    [6.02e23, \"f\\x086.02e+23\", 0],\n    [true, \"T\"],\n    [false, \"F\"],\n    [MemBuffer.stringToArray(\"Hello world\"), \"s\\x0b\\x00\\x00\\x00Hello world\"],\n    [\"Résumé\", \"s\\x08\\x00\\x00\\x00R\\xc3\\xa9sum\\xc3\\xa9\"],\n    [[1, 2, 3],\n      \"[\\x03\\x00\\x00\\x00i\\x01\\x00\\x00\\x00i\\x02\\x00\\x00\\x00i\\x03\\x00\\x00\\x00\"],\n    [{\"This\": 4, \"is\": 0, \"a\": MemBuffer.stringToArray(\"test\")},\n      \"{s\\x04\\x00\\x00\\x00Thisi\\x04\\x00\\x00\\x00s\\x01\\x00\\x00\\x00as\\x04\\x00\\x00\\x00tests\\x02\\x00\\x00\\x00isi\\x00\\x00\\x00\\x000\"],\n  ];\n\n  describe(\"basic data structures\", function() {\n    it(\"should serialize correctly\", function() {\n      var m0 = new marshal.Marshaller({ stringToBuffer: true, version: 0 });\n      var m2 = new marshal.Marshaller({ stringToBuffer: true, version: 2 });\n      for (var i = 0; i < samples.length; i++) {\n        var value = samples[i][0];\n        var expected = binStringToArray(samples[i][1]);\n        var version = samples[i].length === 3 ? samples[i][2] : 0;\n        var currentMarshaller = version >= 2 ? m2 : m0;\n        currentMarshaller.marshal(value);\n        var marshalled = currentMarshaller.dump();\n        assert.deepEqual(marshalled, expected,\n          \"Wrong serialization of \" + JSON.stringify(value) +\n                           \"\\n        actual: \" + escape(arrayToBinString(marshalled)) + \"\\n\" +\n                           \"\\n      expected: \" + escape(arrayToBinString(expected)));\n      }\n    });\n\n    it(\"should deserialize correctly\", function() {\n      var m = new marshal.Unmarshaller();\n      var values = [];\n      m.on(\"value\", function(val) { values.push(val); });\n\n      for (var i = 0; i < samples.length; i++) {\n        values.length = 0;\n        var expected = samples[i][0];\n        m.push(binStringToArray(samples[i][1]));\n        assert.strictEqual(values.length, 1);\n        var value = values[0];\n        if (typeof expected === \"string\") {\n          // This tests marshals JS strings to Python strings, but unmarshalls to Uint8Arrays. So\n          // when the source is a string, we need to tweak the returned value for comparison.\n          value = MemBuffer.arrayToString(value);\n        }\n        assert.deepEqual(value, expected);\n      }\n    });\n\n    it(\"should support stringToBuffer and bufferToString\", function() {\n      var mY = new marshal.Marshaller({ stringToBuffer: true });\n      var mN = new marshal.Marshaller({ stringToBuffer: false });\n      var uY = new marshal.Unmarshaller({ bufferToString: true });\n      var uN = new marshal.Unmarshaller({ bufferToString: false });\n      var helloBuf = MemBuffer.stringToArray(\"hello\");\n      function passThrough(m, u, value) {\n        var ret = null;\n        u.on(\"value\", function(v) { ret = v; });\n        m.marshal(value);\n        u.push(m.dump());\n        return ret;\n      }\n      // No conversion, no change.\n      assert.deepEqual(passThrough(mN, uN, \"hello\"), \"hello\");\n      assert.deepEqual(passThrough(mN, uN, helloBuf), helloBuf);\n\n      // If convert to strings on the way back, then see all strings.\n      assert.deepEqual(passThrough(mN, uY, \"hello\"), \"hello\");\n      assert.deepEqual(passThrough(mN, uY, helloBuf), \"hello\");\n\n      // If convert to buffers on the way forward, and no conversion back, then see all buffers.\n      assert.deepEqual(passThrough(mY, uN, \"hello\"), helloBuf);\n      assert.deepEqual(passThrough(mY, uN, helloBuf), helloBuf);\n\n      // If convert to buffers on the way forward, and to strings back, then see all strings.\n      assert.deepEqual(passThrough(mY, uY, \"hello\"), \"hello\");\n      assert.deepEqual(passThrough(mY, uY, helloBuf), \"hello\");\n    });\n\n  });\n\n\n  function mkbuf(arg) { return new Uint8Array(arg); }\n\n  function dumps(codeStr, value) {\n    var m = new marshal.Marshaller();\n    m.marshal(marshal.wrap(codeStr, value));\n    return m.dump();\n  }\n\n  describe(\"int64\", function() {\n    it(\"should serialize 32-bit values correctly\", function() {\n      assert.deepEqual(dumps(\"INT64\", 0x7FFFFFFF), mkbuf([73, 255, 255, 255, 127, 0, 0, 0, 0]));\n      assert.deepEqual(dumps(\"INT64\", -0x80000000), mkbuf([73, 0, 0, 0, 128, 255, 255, 255, 255]));\n\n      // TODO: larger values fail now, but of course it's better to fix, and change this test.\n      assert.throws(function() { dumps(\"INT64\", 0x7FFFFFFF+1); }, /int64/);\n      assert.throws(function() { dumps(\"INT64\", -0x80000000-1); }, /int64/);\n    });\n\n    it(\"should deserialize 32-bit values correctly\", function() {\n      assert.strictEqual(marshal.loads([73, 255, 255, 255, 127, 0, 0, 0, 0]), 0x7FFFFFFF);\n      assert.strictEqual(marshal.loads([73, 0, 0, 0, 128, 255, 255, 255, 255]), -0x80000000);\n\n      // Can be verified in Python with: marshal.loads(\"\".join(chr(r) for r in [73, 255, ...]))\n      assert.strictEqual(marshal.loads([73, 255, 255, 255, 127, 255, 255, 255, 255]), -0x80000001);\n      assert.strictEqual(marshal.loads([73, 0, 0, 0, 128, 0, 0, 0, 0]), 0x80000000);\n\n      // Be sure to test with low and high 32-bit words being positive or negative. Note that\n      // integers that are too large to be safely represented are currently returned as strings.\n      assert.strictEqual(marshal.loads([73, 1, 2, 3, 190, 4, 5, 6, 200]), \"-4033530898337824255\");\n      assert.strictEqual(marshal.loads([73, 1, 2, 3, 190, 4, 5, 6,  20]), \"1442846248544698881\");\n      assert.strictEqual(marshal.loads([73, 1, 2, 3,  90, 4, 5, 6, 200]), \"-4033530900015545855\");\n      assert.strictEqual(marshal.loads([73, 1, 2, 3,  90, 4, 5, 6,  20]), \"1442846246866977281\");\n    });\n  });\n\n  describe(\"interned strings\", function() {\n    it(\"should parse interned strings correctly\", function() {\n      var testData = \"{t\\x03\\x00\\x00\\x00aaat\\x03\\x00\\x00\\x00bbbR\\x01\\x00\\x00\\x00R\\x00\\x00\\x00\\x000\";\n      assert.deepEqual(marshal.loads(binStringToArray(testData)),\n        { \"aaa\": MemBuffer.stringToArray(\"bbb\"),\n          \"bbb\": MemBuffer.stringToArray(\"aaa\")\n        });\n    });\n  });\n\n  describe(\"longs\", function() {\n    // This is generated as [991**i for i in xrange(10)] + [-678**i for i in xrange(10)].\n    // Note how overly large values currently get stringified.\n    const sampleData = [1, 991, 982081, 973242271, 964483090561, 955802742745951,\n      \"947200518061237441\", \"938675713398686304031\", \"930227631978098127294721\",\n      \"921855583290295244149068511\",\n      -1, -678, -459684, -311665752, -211309379856, -143267759542368, \"-97135540969725504\",\n      \"-65857896777473891712\", \"-44651654015127298580736\", \"-30273821422256308437739008\"];\n\n    const serialized = \"[\\x14\\x00\\x00\\x00i\\x01\\x00\\x00\\x00i\\xdf\\x03\\x00\\x00iA\\xfc\\x0e\\x00i\\x9f\\x7f\\x02:I\\x81\\x08\\xac\\x8f\\xe0\\x00\\x00\\x00I_\\xeb\\xf4*Le\\x03\\x00I\\xc1$\\x1bJ\\xda!%\\rl\\x05\\x00\\x00\\x00\\x1fG&>\\x130\\xf0\\x15.\\x03l\\x06\\x00\\x00\\x00\\x01Q@\\x17n\\x1b\\x84m\\xbbO\\x18\\x00l\\x06\\x00\\x00\\x00\\xdf\\x123\\x03\\x86/\\xd0r4(Q_i\\xff\\xff\\xff\\xffiZ\\xfd\\xff\\xffi\\\\\\xfc\\xf8\\xffi\\xa8[l\\xedI\\xf0\\xbe\\xfa\\xcc\\xce\\xff\\xff\\xffI\\xa0\\xaf\\x15\\xe0\\xb2}\\xff\\xffI\\xc0!oy\\xbd\\xe7\\xa6\\xfel\\xfb\\xff\\xff\\xff\\x80\\x1dYG\\xc1\\x00\\xb2\\x0f9\\x00l\\xfa\\xff\\xff\\xff\\x00!Rv\\x9f\\x00p\\x11I\\x17\\x01\\x00l\\xfa\\xff\\xff\\xff\\x00f\\xda]\\x8c'\\xa3.\\xb2+!\\x03\";\n\n    it(\"should deserialize arbitrarily long integers correctly\", function() {\n      assert.deepEqual(marshal.loads(binStringToArray(serialized)), sampleData);\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/parseDate.ts",
    "content": "/* global describe, it */\nimport { guessDateFormat, guessDateFormats, parseDate, parseDateStrict, parseDateTime } from \"app/common/parseDate\";\n\nimport { assert } from \"chai\";\nimport * as moment from \"moment-timezone\";\n\nconst today = new Date();\nconst year = today.getUTCFullYear();\nconst month = String(today.getUTCMonth() + 1).padStart(2, \"0\");\n\n/**\n * Assert that parseDate and parseDateStrict parse `input` correctly,\n * returning a date that looks like expectedDateStr in ISO format.\n * parseDate should always produce a parsed date from `input`.\n * parseDateStrict should return at most one date, i.e. the formats it tries shouldn't allow ambiguity.\n *\n * fallback=true indicates the date cannot be parsed strictly with the given format\n * so parseDate has to fallback to another format and parseDateStrict gives no results.\n *\n * Otherwise, parseDateStrict should return a result\n * unless no dateFormat is given in which case it may or may not.\n */\nfunction testParse(\n  dateFormat: string | null, input: string, expectedDateStr: string | null, fallback: boolean = false,\n) {\n  assertDateEqual(parseDate(input, dateFormat ? { dateFormat } : {}), expectedDateStr);\n\n  const strict = new Set<number>();\n  parseDateStrict(input, dateFormat, strict);\n  assert.include([0, 1], strict.size);\n\n  // fallback=true indicates the date cannot be parsed strictly with the given format\n  // so it has to fallback to another format.\n  if (fallback) {\n    assert.isEmpty(strict);\n  } else if (dateFormat) {\n    assert.equal(strict.size, 1);\n  }\n\n  if (strict.size) {\n    const strictParsed = [...strict][0];\n    assertDateEqual(strictParsed, expectedDateStr);\n    assertDateEqual(parseDateTime(input, dateFormat ? { dateFormat } : {})!, expectedDateStr);\n  }\n}\n\nfunction assertDateEqual(parsed: number | null, expectedDateStr: string | null) {\n  const formatted = parsed === null ? null : new Date(parsed * 1000).toISOString().slice(0, 10);\n  assert.equal(formatted, expectedDateStr);\n}\n\nfunction testTimeParse(input: string, expectedUTCTimeStr: string | null, timezone?: string) {\n  const parsed1 = parseDateTime(\"1993-04-02T\" + input,\n    { timeFormat: \"Z\", timezone, dateFormat: \"YYYY-MM-DD\" }) || null;\n  const parsed2 = parseDate(\"1993-04-02\", { time: input, timeFormat: \"UNUSED\", timezone });\n  for (const parsed of [parsed1, parsed2]) {\n    if (expectedUTCTimeStr === null) {\n      assert.isNull(parsed);\n      return;\n    }\n    const output = new Date(parsed! * 1000).toISOString().slice(11, 19);\n    assert.equal(output, expectedUTCTimeStr, `testTimeParse(${input}, ${timezone})`);\n  }\n}\n\nfunction testDateTimeParse(\n  date: string, time: string, expectedUTCTimeStr: string | null, timezone: string, dateFormat?: string,\n) {\n  const parsed1 = parseDateTime(date + \" \" + time,\n    { timeFormat: \"Z\", timezone, dateFormat: dateFormat || \"YYYY-MM-DD\" }) || null;\n\n  // This is for testing the combination of date and time which is important when daylight savings is involved\n  const parsed2 = parseDate(date, { time, timeFormat: \"UNUSED\", timezone, dateFormat });\n\n  for (const parsed of [parsed1, parsed2]) {\n    if (expectedUTCTimeStr === null) {\n      assert.isNull(parsed);\n      return;\n    }\n    const output = new Date(parsed! * 1000).toISOString().slice(0, 19).replace(\"T\", \" \");\n    assert.equal(output, expectedUTCTimeStr);\n  }\n}\n\nfunction testDateTimeStringParse(\n  dateTime: string, expectedUTCTimeStr: string | null, dateFormat: string, timezone?: string,\n) {\n  const parsed = parseDateTime(dateTime, { timezone, dateFormat });\n\n  if (expectedUTCTimeStr === null) {\n    assert.isUndefined(parsed);\n    return;\n  }\n  const output = new Date(parsed! * 1000).toISOString().slice(0, 19).replace(\"T\", \" \");\n  assert.equal(output, expectedUTCTimeStr);\n}\n\ndescribe(\"parseDate\", function() {\n  this.timeout(5000);\n  this.slow(50);\n\n  it(\"should allow parsing common date formats\", function() {\n    testParse(null, \"November 18th, 1994\",  \"1994-11-18\");\n    testParse(null, \"nov 18 1994\",          \"1994-11-18\");\n    testParse(null, \"11-18-94\",             \"1994-11-18\");\n    testParse(null, \"11-18-1994\",           \"1994-11-18\");\n    testParse(null, \"1994-11-18\",           \"1994-11-18\");\n    testParse(null, \"November 18, 1994\",    \"1994-11-18\");\n    testParse(\"DD/MM/YY\", \"18/11/94\",       \"1994-11-18\");\n    // fallback format is used because 18 is not a valid month\n    testParse(\"MM/DD/YY\", \"18/11/94\",       \"1994-11-18\", true);\n\n    testParse(null,       \"18/11/94\",       \"1994-11-18\");\n    testParse(null,       \"12/11/94\",       \"1994-12-11\");\n    testParse(\"DD/MM/YY\", \"12/11/94\",       \"1994-11-12\");\n    testParse(\"MM/DD/YY\", \"11/12/94\",       \"1994-11-12\");\n\n    testParse(null, \"25\", `${year}-${month}-25`);\n    testParse(null, \"10\", `${year}-${month}-10`);\n    testParse(\"DD/MM/YY\", \"10\", `${year}-${month}-10`);\n    testParse(\"DD/MM/YY\", \"3/4\", `${year}-04-03`);\n    // Separators in the format should not affect the parsing (for better or worse).\n    testParse(\"YY-DD/MM\", \"3/4\", `${year}-04-03`);\n    testParse(\"YY/DD-MM\", \"3/4\", `${year}-04-03`);\n    testParse(\"MM/DD/YY\", \"3/4\", `${year}-03-04`);\n    testParse(\"YY/MM/DD\", \"3/4\", `${year}-03-04`);\n    testParse(null, \"3/4\", `${year}-03-04`);\n\n    // Single number gets parse according to the most specific item in the format string.\n    testParse(\"DD\",     \"10\",   `${year}-${month}-10`);\n    testParse(\"DD/MM\",  \"10\",   `${year}-${month}-10`);\n    testParse(\"MM\",     \"10\",   `${year}-10-01`);\n    testParse(\"MM/YY\",  \"10\",   `${year}-10-01`);\n    testParse(\"MMM\",    \"10\",   `${year}-10-01`);\n    testParse(\"YY\",     \"10\",   `2010-01-01`);\n    testParse(\"YYYY\",   \"10\",   `2010-01-01`);\n\n    testParse(\"YY\",   \"05\",     `2005-01-01`);\n    testParse(\"YY\",   \"5\",      `${year}-05-01`, true);   // Not a valid year, so falls back to \"M\" format\n    testParse(\"YYYY\", \"1910\",   `1910-01-01`);\n    testParse(\"YY\",   \"3/4\",    `${year}-03-04`, true);   // Falls back to another format\n    testParse(\"DD/MM\", \"3/4\",   `${year}-04-03`);\n    testParse(\"MM/YY\", \"3/04\",  `2004-03-01`);\n    testParse(\"MM/YY\", \"3/4\",   `${year}-03-04`, true);   // Not a valid year, so falls back to \"M/D\" format\n\n    testParse(null, \"4/2/93\",           \"1993-04-02\");\n    testParse(null, \"04-02-1993\",       \"1993-04-02\");\n    testParse(null, \"4-02-93\",          \"1993-04-02\");\n    testParse(null, \"April 2nd, 1993\",  \"1993-04-02\");\n\n    testParse(\"DD MMM YY\",   \"15-Jan 99\",   \"1999-01-15\");\n    testParse(\"DD MMM YYYY\", \"15-Jan 1999\", \"1999-01-15\");\n    testParse(\"DD MMM\",      \"15-Jan 1999\", \"1999-01-15\");\n\n    testParse(\"MMMM Do, YYYY\", \"April 2nd, 1993\",  \"1993-04-02\");\n    testParse(\"MMM Do YYYY\", \"Apr 2nd 1993\",  `1993-04-02`);\n    testParse(\"Do MMMM YYYY\", \"2nd April 1993\",  `1993-04-02`);\n    testParse(\"Do MMM YYYY\", \"2nd Apr 1993\",  `1993-04-02`);\n    testParse(\"MMMM D, YYYY\", \"April 2, 1993\",  \"1993-04-02\");\n    testParse(\"MMM D YYYY\", \"Apr 2 1993\",  `1993-04-02`);\n    testParse(\"D MMMM YYYY\", \"2 April 1993\",  `1993-04-02`);\n    testParse(\"D MMM YYYY\", \"2 Apr 1993\",  `1993-04-02`);\n    testParse(\"MMMM Do, \", \"April 2nd, 1993\",  \"1993-04-02\");\n    testParse(\"MMM Do \", \"Apr 2nd 1993\",  `1993-04-02`);\n    testParse(\"Do MMMM \", \"2nd April 1993\",  `1993-04-02`);\n    testParse(\"Do MMM \", \"2nd Apr 1993\",  `1993-04-02`);\n    testParse(\"MMMM D, \", \"April 2, 1993\",  \"1993-04-02\");\n    testParse(\"MMM D \", \"Apr 2 1993\",  `1993-04-02`);\n    testParse(\"D MMMM \", \"2 April 1993\",  `1993-04-02`);\n    testParse(\"D MMM \", \"2 Apr 1993\",  `1993-04-02`);\n    testParse(\"MMMM Do, \", \"April 2nd\",  `${year}-04-02`);\n    testParse(\"MMM Do \", \"Apr 2nd\",  `${year}-04-02`);\n    testParse(\"Do MMMM \", \"2nd April\",  `${year}-04-02`);\n    testParse(\"Do MMM \", \"2nd Apr\",  `${year}-04-02`);\n    testParse(\"MMMM D, \", \"April 2\",  `${year}-04-02`);\n    testParse(\"MMM D \", \"Apr 2\",  `${year}-04-02`);\n    testParse(\"D MMMM \", \"2 April\",  `${year}-04-02`);\n    testParse(\"D MMM \", \"2 Apr\",  `${year}-04-02`);\n\n    // Test the combination of Do and YY, which was buggy at one point.\n    testParse(\"MMMM Do, YY\", \"April 2nd, 93\",  \"1993-04-02\");\n    testParse(\"MMM Do, YY\", \"Apr 2nd, 93\",  \"1993-04-02\");\n    testParse(\"Do MMMM YY\", \"2nd April 93\",  `1993-04-02`);\n    testParse(\"Do MMM YY\", \"2nd Apr 93\",  `1993-04-02`);\n\n    testParse(\"  D   MMM   \", \" 2  Apr \",  `${year}-04-02`);\n    testParse(\"D MMM\", \" 2  Apr \",  `${year}-04-02`);\n    testParse(\"  D   MMM   \", \"2 Apr\",  `${year}-04-02`);\n\n    testParse(null, \"  11-18-94     \",       \"1994-11-18\");\n    testParse(\"   DD   MM   YY\", \"18/11/94\", \"1994-11-18\");\n  });\n\n  it(\"should allow parsing common date-time formats\", function() {\n    // These are the test cases from before.\n    testTimeParse(\"22:18:04\", \"22:18:04\");\n    testTimeParse(\"8pm\",      \"20:00:00\");\n    testTimeParse(\"22:18:04\", \"22:18:04\", \"UTC\");\n    testTimeParse(\"22:18:04\", \"03:18:04\", \"America/New_York\");\n    testTimeParse(\"22:18:04\", \"06:18:04\", \"America/Los_Angeles\");\n    testTimeParse(\"22:18:04\", \"13:18:04\", \"Japan\");\n\n    // Weird time formats are no longer parsed\n    // testTimeParse('HH-mm',    '1-15',     '01:15:00');\n    // testTimeParse('ss mm HH', '4 23 3',   '03:23:04');\n\n    // The current behavior parses any standard-like format (with HH:MM:SS components in the usual\n    // order) regardless of the format requested.\n\n    // Test a few variations of spelling AM/PM.\n    for (const [am, pm] of [[\"A\", \" p\"], [\"  am\", \"pM\"], [\"AM\", \" PM\"]]) {\n      testTimeParse(\"1\", \"01:00:00\");\n      testTimeParse(\"1\" + am, \"01:00:00\");\n      testTimeParse(\"1\" + pm, \"13:00:00\");\n      testTimeParse(\"22\", \"22:00:00\");\n      testTimeParse(\"22\" + am, \"22:00:00\");   // Best guess for 22am/22pm is 22:00.\n      testTimeParse(\"22\" + pm, \"22:00:00\");\n      testTimeParse(\"0\", \"00:00:00\");\n      testTimeParse(\"0\" + am, \"00:00:00\");\n      testTimeParse(\"0\" + pm, \"00:00:00\");\n      testTimeParse(\"12\", \"12:00:00\");        // 12:00 is more likely 12pm than 12am\n      testTimeParse(\"12\" + am, \"00:00:00\");\n      testTimeParse(\"12\" + pm, \"12:00:00\");\n      testTimeParse(\"9:8\", \"09:08:00\");\n      testTimeParse(\"9:8\" + am, \"09:08:00\");\n      testTimeParse(\"9:8\" + pm, \"21:08:00\");\n      testTimeParse(\"09:08\", \"09:08:00\");\n      testTimeParse(\"09:08\" + am, \"09:08:00\");\n      testTimeParse(\"09:08\" + pm, \"21:08:00\");\n      testTimeParse(\"21:59\", \"21:59:00\");\n      testTimeParse(\"21:59\" + am, \"21:59:00\");\n      testTimeParse(\"21:59\" + pm, \"21:59:00\");\n      testTimeParse(\"10:18:04\", \"10:18:04\");\n      testTimeParse(\"10:18:04\" + am, \"10:18:04\");\n      testTimeParse(\"10:18:04\" + pm, \"22:18:04\");\n      testTimeParse(\"22:18:04\", \"22:18:04\");\n      testTimeParse(\"22:18:04\" + am, \"22:18:04\");\n      testTimeParse(\"22:18:04\" + pm, \"22:18:04\");\n      testTimeParse(\"12:18:04\", \"12:18:04\");\n      testTimeParse(\"12:18:04\" + am, \"00:18:04\");\n      testTimeParse(\"12:18:04\" + pm, \"12:18:04\");\n      testTimeParse(\"908\", \"09:08:00\");\n      testTimeParse(\"0910\", \"09:10:00\");\n      testTimeParse(\"2112\", \"21:12:00\");\n    }\n\n    // Tests with time zones.\n    testTimeParse(\"09:08\", \"09:08:00\", \"UTC\");\n    testTimeParse(\"09:08\", \"14:08:00\", \"America/New_York\");\n    testTimeParse(\"09:08\", \"00:08:00\", \"Japan\");\n    testTimeParse(\"09:08 Z\", \"09:08:00\");\n    testTimeParse(\"09:08z\", \"09:08:00\");\n    testTimeParse(\"09:08 UT\", \"09:08:00\");\n    testTimeParse(\"09:08 UTC\", \"09:08:00\");\n    testTimeParse(\"09:08-05\", \"14:08:00\");\n    testTimeParse(\"09:08-5\", \"14:08:00\");\n    testTimeParse(\"09:08-0500\", \"14:08:00\");\n    testTimeParse(\"09:08-05:00\", \"14:08:00\");\n    testTimeParse(\"09:08-500\", \"14:08:00\");\n    testTimeParse(\"09:08-5:00\", \"14:08:00\");\n    testTimeParse(\"09:08+05\", \"04:08:00\");\n    testTimeParse(\"09:08+5\", \"04:08:00\");\n    testTimeParse(\"09:08+0500\", \"04:08:00\");\n    testTimeParse(\"09:08+5:00\", \"04:08:00\");\n    testTimeParse(\"09:08+05:00\", \"04:08:00\");\n  });\n\n  it(\"should handle timezone abbreviations\", function() {\n    // New York can be abbreviated as EDT or EST depending on the time of year for daylight savings.\n    // We ignore the abbreviation so it's parsed the same whichever is used.\n    // However the parsed UTC time depends on the date.\n    testDateTimeParse(\"2020-02-02\", \"09:45 edt\", \"2020-02-02 14:45:00\", \"America/New_York\");\n    testDateTimeParse(\"2020-10-10\", \"09:45 edt\", \"2020-10-10 13:45:00\", \"America/New_York\");\n    testDateTimeParse(\"2020-02-02\", \"09:45 est\", \"2020-02-02 14:45:00\", \"America/New_York\");\n    testDateTimeParse(\"2020-10-10\", \"09:45 est\", \"2020-10-10 13:45:00\", \"America/New_York\");\n    // Spaces and case shouldn't matter.\n    testDateTimeParse(\"2020-10-10\", \"09:45 EST\", \"2020-10-10 13:45:00\", \"America/New_York\");\n    testDateTimeParse(\"2020-10-10\", \"09:45EST\", \"2020-10-10 13:45:00\", \"America/New_York\");\n    testDateTimeParse(\"2020-10-10\", \"09:45EDT\", \"2020-10-10 13:45:00\", \"America/New_York\");\n\n    // Testing that AEDT is rejected in the New York timezone even though it ends with EDT which is valid.\n    testTimeParse(\"09:45:00 aedt\", null, \"America/New_York\");\n    testTimeParse(\"09:45:00AEDT\",  null, \"America/New_York\");\n    testTimeParse(\"09:45:00 aedt\", \"23:45:00\", \"Australia/ACT\");\n    testTimeParse(\"09:45:00AEDT\",  \"23:45:00\", \"Australia/ACT\");\n\n    // Testing multiple abbreviations of US/Pacific\n    testDateTimeParse(\"2020-02-02\", \"09:45 PST\", null, \"America/New_York\");\n    testDateTimeParse(\"2020-02-02\", \"09:45 PST\", \"2020-02-02 17:45:00\", \"US/Pacific\");\n    testDateTimeParse(\"2020-10-10\", \"09:45 PST\", \"2020-10-10 16:45:00\", \"US/Pacific\");\n    testDateTimeParse(\"2020-02-02\", \"09:45 PDT\", \"2020-02-02 17:45:00\", \"US/Pacific\");\n    testDateTimeParse(\"2020-10-10\", \"09:45 PDT\", \"2020-10-10 16:45:00\", \"US/Pacific\");\n    // PWT and PPT are some obscure abbreviations apparently used at some time and thus supported by moment\n    testDateTimeParse(\"2020-10-10\", \"09:45 PWT\", \"2020-10-10 16:45:00\", \"US/Pacific\");\n    testDateTimeParse(\"2020-10-10\", \"09:45 PPT\", \"2020-10-10 16:45:00\", \"US/Pacific\");\n    // POT is not valid\n    testDateTimeParse(\"2020-10-10\", \"09:45 POT\", null, \"US/Pacific\");\n\n    // Both these timezones have CST and CDT, but not COT.\n    // The timezones are far apart so the parsed UTC times are too.\n    testTimeParse(\"09:45 CST\", \"01:45:00\", \"Asia/Shanghai\");\n    testTimeParse(\"09:45 CDT\", \"01:45:00\", \"Asia/Shanghai\");\n    testTimeParse(\"09:45 CST\", \"15:45:00\", \"Canada/Central\");\n    testTimeParse(\"09:45 CDT\", \"15:45:00\", \"Canada/Central\");\n    testTimeParse(\"09:45 COT\", null, \"Asia/Shanghai\");\n    testTimeParse(\"09:45 COT\", null, \"Canada/Central\");\n  });\n\n  it(\"should parse datetime strings\", function() {\n    for (const separator of [\" \", \"T\"]) {\n      for (let tz of [\"Z\", \"UTC\", \"+00:00\", \"-00\", \"\"]) {\n        for (const tzSeparator of [\"\", \" \"]) {\n          tz = tzSeparator + tz;\n\n          let expected = \"2020-03-04 12:34:56\";\n          testDateTimeStringParse(\n            ` 2020-03-04${separator}12:34:56${tz} `, expected, \"YYYY-MM-DD\",\n          );\n          testDateTimeStringParse(\n            ` 03-04-2020${separator}12:34:56${tz} `, expected, \"MM/DD/YYYY\",\n          );\n          testDateTimeStringParse(\n            ` 04-03-20${separator}12:34:56${tz} `, expected, \"DD-MM-YY\",\n          );\n          testDateTimeStringParse(\n            ` 2020-03-04${separator}12:34:56${tz} `, expected, \"\",\n          );\n          expected = \"2020-03-04 12:34:00\";\n          testDateTimeStringParse(\n            ` 04-03-20${separator}12:34${tz} `, expected, \"DD-MM-YY\",\n          );\n        }\n      }\n    }\n  });\n\n  it(\"should handle datetimes as formatted by moment\", function() {\n    this.timeout(10000);  // there may be a LOT of timezone names.\n    for (const date of [\"2020-02-03\", \"2020-06-07\", \"2020-10-11\"]) {  // different months for daylight savings\n      const dateTime = date + \" 12:34:56\";\n      const utcMoment = moment.tz(dateTime, \"UTC\");\n      for (const dateFormat of [\"DD/MM/YY\", \"MM/DD/YY\"]) {\n        for (const tzFormat of [\"z\", \"Z\"]) {  // abbreviation (z) vs +/-HH:MM (Z)\n          assert.isTrue(utcMoment.isValid());\n          for (const tzName of moment.tz.names()) {\n            const tzMoment = moment.tz(utcMoment, tzName);\n            const formattedTime = tzMoment.format(\"HH:mm:ss \" + tzFormat);\n            const formattedDate = tzMoment.format(dateFormat);\n            testDateTimeParse(formattedDate, formattedTime, dateTime, tzName, dateFormat);\n          }\n        }\n      }\n    }\n  });\n\n  it(\"should be flexible in parsing the preferred format\", function() {\n    for (const format of [\"DD-MM-YYYY\", \"DD-MM-YY\", \"DD-MMM-YYYY\", \"DD-MMM-YY\"]) {\n      testParse(format, \"1/2/21\",     \"2021-02-01\");\n      testParse(format, \"01/02/2021\", \"2021-02-01\");\n      testParse(format, \"1-02-21\",    \"2021-02-01\");\n    }\n\n    for (const format of [\"MM-DD-YYYY\", \"MM-DD-YY\", \"MMM-DD-YYYY\", \"MMM-DD-YY\"]) {\n      testParse(format, \"1/2/21\",     \"2021-01-02\");\n      testParse(format, \"01/02/2021\", \"2021-01-02\");\n      testParse(format, \"1-02-21\",    \"2021-01-02\");\n    }\n\n    for (const format of [\"YY-MM-DD\", \"YYYY-MM-DD\", \"YY-MMM-DD\", \"YYYY-MMM-DD\"]) {\n      testParse(format, \"01/2/3\",     \"2001-02-03\");\n      testParse(format, \"2001/02/03\", \"2001-02-03\");\n      testParse(format, \"01-02-03\",   \"2001-02-03\");\n      testParse(format, \"10/11\",      `${year}-10-11`);\n      testParse(format, \"2/3\",        `${year}-02-03`);\n      testParse(format, \"12\",         `${year}-${month}-12`);\n    }\n\n    testParse(\"DD MMM YYYY\", \"1 FEB 2021\", \"2021-02-01\");\n    testParse(\"DD MMM YYYY\", \"1-feb-21\",   \"2021-02-01\");\n    testParse(\"DD MMM YYYY\", \"1/2/21\",     \"2021-02-01\");\n    testParse(\"DD MMM YYYY\", \"01/02/2021\", \"2021-02-01\");\n    testParse(\"DD MMM YYYY\", \"1-02-21\",    \"2021-02-01\");\n    testParse(\"DD MMM YYYY\", \"1 2\",        `${year}-02-01`);\n    testParse(\"DD MMM YYYY\", \"1 feb\",      `${year}-02-01`);\n\n    testParse(\"DD MMM\", \"1 FEB 2021\", \"2021-02-01\");\n    testParse(\"DD MMM\", \"1-feb-2021\", \"2021-02-01\");\n    testParse(\"DD MMM\", \"1/2/2021\",   \"2021-02-01\");\n    testParse(\"DD MMM\", \"01/02/2021\", \"2021-02-01\");\n    testParse(\"DD MMM\", \"1-02-2021\",  \"2021-02-01\");\n    testParse(\"DD MMM\", \"1 2 2021\",   `2021-02-01`);\n    testParse(\"DD MMM\", \"1 feb 2021\", `2021-02-01`);\n  });\n\n  it(\"should support underscores as separators\", async function() {\n    testParse(\"DD_MM_YY\", \"3/4\",      `${year}-04-03`);\n    testParse(\"DD_MM_YY\", \"3_4\",      `${year}-04-03`);\n    testParse(\"DD_MM_YY\", \"3_4_98\",   `1998-04-03`);\n    testParse(\"DD/MM/YY\", \"3_4_98\",   `1998-04-03`);\n  });\n\n  it(\"should interpret two-digit years as bootstrap datepicker does\", function() {\n    const yy = year % 100;\n    // These checks are expected to work as long as today's year is between 2021 and 2088.\n    testParse(\"MM-DD-YY\", `1/2/${yy}`, `20${yy}-01-02`);\n    testParse(\"MM-DD-YY\", `1/2/${yy + 9}`, `20${yy + 9}-01-02`);\n    testParse(\"MM-DD-YY\", `1/2/${yy + 11}`, `19${yy + 11}-01-02`);\n    // These should work until 2045 (after that 55 would be interpreted as 2055).\n    testParse(\"MM-DD-YY\", `1/2/00`, `2000-01-02`);\n    testParse(\"MM-DD-YY\", `1/2/08`, `2008-01-02`);\n    testParse(\"MM-DD-YY\", `1/2/20`, `2020-01-02`);\n    testParse(\"MM-DD-YY\", `1/2/30`, `2030-01-02`);\n    testParse(\"MM-DD-YY\", `1/2/55`, `1955-01-02`);\n    testParse(\"MM-DD-YY\", `1/2/79`, `1979-01-02`);\n    testParse(\"MM-DD-YY\", `1/2/98`, `1998-01-02`);\n  });\n\n  it(\"should parse timestamps as dates\", function() {\n    testParse(null,   \"123456789\", \"1973-11-29\");\n    testParse(null,   \"100000000\", \"1973-03-03\");\n    testParse(null,  \"1000000000\", \"2001-09-09\");\n    testParse(null, \"10000000000\", null);\n\n    testParse(null,   \"20230926\", null);\n    testParse(null, \"12345678\", null);\n    testParse(null, \"-1000000\", null);\n    testParse(null, \"-9999999\", null);\n    testParse(null, \"123456789.0\", null);\n    testParse(null,  \"-100000000\", null);\n\n    testParse(null, \"100000000000\", null);\n    testParse(null, \"1000000000000\", null);\n\n    // Test exact times.\n    assert.equal(parseDate(\"123456789\"), 123456789);\n    assert.equal(parseDate(\"100000000\"), 100000000);\n\n    // Now those that don't fit into our format.\n    assert.isNull(parseDate(\"1234567\"));\n    assert.isNull(parseDate(\"-999999\"));\n    assert.isNull(parseDate(\"12345678.0\"));\n    assert.isNull(parseDate(\"-100000000\"));\n    assert.isNull(parseDate(\"100000000000\"));\n    assert.isNull(parseDate(\"1000000000000\"));\n  });\n\n  describe(\"guessDateFormat\", function() {\n    it(\"should guess date formats\", function() {\n      // guessDateFormats with an *s* shows all the equally likely guesses.\n      // It's only directly used in tests, just to reveal the inner workings.\n      // guessDateFormat picks one of those formats which is actually used in type conversion etc.\n\n      // ISO YYYY-MM-DD is king\n      assert.deepEqual(guessDateFormats([\"2020-01-02\"]), [\"YYYY-MM-DD\"]);\n      assert.deepEqual(guessDateFormat([\"2020-01-02\"]), \"YYYY-MM-DD\");\n\n      // Some ambiguous dates\n      assert.deepEqual(guessDateFormats([\"01/01/2020\"]), [\"DD/MM/YYYY\", \"MM/DD/YYYY\"]);\n      assert.deepEqual(guessDateFormats([\"01/02/03\"]), [\"DD/MM/YY\", \"MM/DD/YY\", \"YY/MM/DD\"]);\n      assert.deepEqual(guessDateFormats([\"01-01-2020\"]), [\"DD-MM-YYYY\", \"MM-DD-YYYY\"]);\n      assert.deepEqual(guessDateFormats([\"01-02-03\"]), [\"DD-MM-YY\", \"MM-DD-YY\", \"YY-MM-DD\"]);\n      assert.deepEqual(guessDateFormat([\"01/01/2020\"]), \"MM/DD/YYYY\");\n      assert.deepEqual(guessDateFormat([\"01/02/03\"]), \"YY/MM/DD\");\n      assert.deepEqual(guessDateFormat([\"01-01-2020\"]), \"MM-DD-YYYY\");\n      assert.deepEqual(guessDateFormat([\"01-02-03\"]), \"YY-MM-DD\");\n\n      // Ambiguous date with only two parts\n      assert.deepEqual(guessDateFormats([\"01/02\"]), [\"DD/MM\", \"MM/DD\", \"YY/MM\"]);\n      assert.deepEqual(guessDateFormat([\"01/02\"]), \"YY/MM\");\n\n      // First date is ambiguous, second date makes the guess unambiguous.\n      assert.deepEqual(guessDateFormats([\"01/01/2020\", \"20/01/2020\"]), [\"DD/MM/YYYY\"]);\n      assert.deepEqual(guessDateFormats([\"01/01/2020\", \"01/20/2020\"]), [\"MM/DD/YYYY\"]);\n      assert.deepEqual(guessDateFormat([\"01/01/2020\", \"20/01/2020\"]), \"DD/MM/YYYY\");\n      assert.deepEqual(guessDateFormat([\"01/01/2020\", \"01/20/2020\"]), \"MM/DD/YYYY\");\n\n      // Not a date at all, guess YYYY-MM-DD as the default.\n      assert.deepEqual(guessDateFormats([\"foo bar\"]), null);\n      assert.deepEqual(guessDateFormat([\"foo bar\"]), \"YYYY-MM-DD\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/promises.js",
    "content": "/**\n * Do some timing of promises, as well as of nextTick and setTimeout, so that we have an idea of\n * how long different things take.\n *\n * To see actual timings, comment out the console.log inside the `log` function below.\n */\n\n\nvar assert = require(\"chai\").assert;\n\nvar bluebird = require(\"bluebird\");\n\n// Disable longStackTraces, which seem to be enabled in the browser by default.\nbluebird.config({ longStackTraces: false });\n\nfunction log(message) {\n  //console.log(message);\n}\n\n/**\n * Measurement helpers. Usage:\n *  var start = startTimer();\n *  ...\n *  var usec = usecElapsed(start);    // Returns microseconds.\n */\nvar startTimer, usecElapsed;\nif (typeof process !== \"undefined\" && typeof process.hrtime !== \"undefined\") {\n  startTimer = function() {\n    return process.hrtime();\n  };\n  usecElapsed = function(start) {\n    var elapsed = process.hrtime(start);\n    return elapsed[0] * 1000000 + elapsed[1] / 1000;\n  };\n} else {\n  startTimer = function() {\n    return Date.now();\n  };\n  usecElapsed = function(start) {\n    var elapsedMs = (Date.now() - start);\n    return elapsedMs * 1000;\n  };\n}\n\n/**\n * Helper to run timing tests. Adds a test case to run the given function, possibly multiple\n * times, and check the timing value that it returns.\n *\n * Example:\n *    describe(\"myClass\", function() {\n *      timeIt(\"myFunc\", { reps: 3, expectedUs: 100, fudgeFactor: 4}, myFunc);\n *    });\n * Produces:\n *    myFunc should take ~100us (up to x4) [got 123us]: 316ms\n * Notes:\n *  - The number at the end isn't very meaningful (includes repetitions and measurements).\n *  - Fudge factors should be pretty large, since tests often take shorter or longer depending\n *    on platform, system load, etc.\n *\n * @param {Number} options.reps - Run the test this many times and check the min value.\n * @param {Number} options.expectedUs - Expected number of microseconds to receive from func.\n * @param {Number} options.fudgeFactor - It's fine if the test takes this factor longer or shorter.\n * @param {Number} options.noLowerBound - don't test for being too fast.\n * @param {Function} func - Will call func(reportUs), where reportUs is a function that should be\n *      called with the test measurement when func is done.\n * @return {Function} Function that takes a `done` callback and calls it when all is done.\n */\nfunction timeIt(name, options, func) {\n  var reps = options.reps || 1;\n  var fudgeFactor = options.fudgeFactor || 1;\n  var expectedUs = options.expectedUs;\n  var noLowerBound = options.noLowerBound;\n  var test = it(name + \" should take ~\" + expectedUs + \"us (up to x\" + fudgeFactor + \")\",\n    function(done) {\n      var n = 0;\n      var minTimeUs = Infinity;\n      function iteration(timeUs) {\n        try {\n          minTimeUs = Math.min(minTimeUs, timeUs);\n          if (n++ < reps) {\n            func(next);\n            return;\n          }\n          log(\"Ran test \" + n + \" times, min time \" + minTimeUs);\n          assert(minTimeUs <= expectedUs * fudgeFactor,\n            \"Time of \" + minTimeUs + \"us is longer than expected (\" + expectedUs + \") \" +\n               \"by more than fudge factor of \" + fudgeFactor);\n          if (!noLowerBound) {\n            assert(minTimeUs >= expectedUs / fudgeFactor,\n              \"Time of \" + minTimeUs + \"us is shorter than expected (\" + expectedUs + \") \" +\n                 \"by more than fudge factor of \" + fudgeFactor);\n          }\n          tackOnMeasuredTime(test, minTimeUs);\n          done();\n        } catch (err) {\n          tackOnMeasuredTime(test, minTimeUs);\n          done(err);\n        }\n      }\n      function next(timeUs) {\n        setTimeout(iteration, 0, timeUs);\n      }\n      next(Infinity);\n    });\n}\n\nfunction tackOnMeasuredTime(test, timeUs) {\n  // Output the measured time as 123.1, or 0.0005 when small\n  var str = timeUs > 10 ? timeUs.toFixed(0) : timeUs.toPrecision(2);\n  test.title = test.title.replace(/( \\[got [^]]*us\\])?$/, \" [got \" + str + \"us]\");\n}\n\ndescribe(\"promises\", function() {\n  // These are normally skipped. They are not really tests of our code, but timings to help\n  // understand how long different things take. Because of global state affecting tests (e.g.\n  // longStackTraces setting, async_hooks affecting timings), it doesn't work well to run these as\n  // part of the full test suite. Instead, they can be run manually using\n  //\n  //      ENABLE_TIMING_TESTS=1 bin/mocha test/common/promises.ts\n  //\n  // (Note that things in mocha.opts, such as report-why-tests-hang, affect them and may need to\n  // be commented out to see accurate timings.)\n  //\n  before(function() {\n    if (!process.env.ENABLE_TIMING_TESTS) {\n      this.skip();\n    }\n  });\n\n  function test(arg) {\n    return arg + 2;\n  }\n\n  timeIt(\"simple calls\", { reps: 3, expectedUs: 0.005, fudgeFactor: 10, noLowerBound: true },\n    function(reportUs) {\n      var iterations = 10000000;\n      var start = startTimer();\n      var value = 0;\n      for (var i = 0; i < iterations; i++) {\n        value = test(value);\n      }\n      var us = usecElapsed(start) / iterations;\n      assert.equal(value, iterations * 2);\n      log(\"Direct calls took \" + us + \" us / iteration\");\n      reportUs(us);\n    });\n\n  function testPromiseLib(promiseLib, libName, setupFunc, timingOptions) {\n    var iterations = timingOptions.iters;\n    timeIt(libName + \" chain\", timingOptions, function(reportUs) {\n      setupFunc();\n      var start = startTimer();\n      var chain = promiseLib.resolve(0);\n      for (var i = 0; i < iterations; i++) {\n        chain = chain.then(test);\n      }\n      var chainDone = false;\n      chain.then(function(value) {\n        var us = usecElapsed(start) / iterations;\n        chainDone = true;\n        assert.equal(value, iterations * 2);\n        log(libName + \" promise chain took \" + us + \" us / iteration\");\n        reportUs(us);\n      });\n      assert.equal(chainDone, false);\n    });\n  }\n\n  // Measure bluebird with and without longStackSupport. If switching promise libraries, we could\n  // add similar timings here to compare performance. E.g. Q is nearly two orders of magnitude\n  // slower than bluebird.\n  var isNode = Boolean(process.version);\n\n  testPromiseLib(bluebird, \"bluebird (no long traces)\",\n    // Sadly, no way to turn off bluebird.longStackTraces, so just do this test first.\n    function() {\n      assert.isFalse(bluebird.hasLongStackTraces(), \"longStackTraces should be off\");\n    },\n    { iters: 20000, reps: 3, expectedUs: isNode ? 0.3 : 1, fudgeFactor: 8});\n\n  // TODO: with bluebird 3, we can no longer switch between having and not having longStackTraces.\n  // We'd have to measure it in two different test runs. For now, can run this test with\n  // BLUEBIRD_DEBUG=1 environment variable.\n  //testPromiseLib(bluebird, 'bluebird (with long traces)',\n  //               function() { bluebird.longStackTraces(); },\n  //               { iters: 20000, reps: 3, expectedUs: isNode ? 0.3 : 1, fudgeFactor: 8});\n\n\n  function testRepeater(repeaterFunc, name, timingOptions) {\n    var iterations = timingOptions.iters;\n    timeIt(\"timing of \" + name, timingOptions, function(reportUs) {\n      var count = 0;\n      function step() {\n        if (count < iterations) {\n          repeaterFunc(step);\n          count++;\n        } else {\n          var us = usecElapsed(start) / iterations;\n          assert.equal(count, iterations);\n          log(name + \" took \" + us + \" us / iteration (\" + iterations + \" iterations)\");\n          reportUs(us);\n        }\n      }\n      var start = startTimer();\n      step();\n    });\n  }\n\n  if (process.maxTickDepth) {\n    // Probably running under Node\n    testRepeater(process.nextTick, \"process.nextTick\",\n      { iters: process.maxTickDepth*9/10, reps: 20, expectedUs: 0.1, fudgeFactor: 4 });\n  }\n  if (typeof setImmediate !== \"undefined\") {\n    testRepeater(setImmediate, \"setImmediate\",\n      { iters: 100, reps: 10, expectedUs: 2.0, fudgeFactor: 4 });\n  }\n});\n"
  },
  {
    "path": "test/common/roles.ts",
    "content": "import * as roles from \"app/common/roles\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"roles\", function() {\n  describe(\"getStrongestRole\", function() {\n    it(\"should return the strongest role\", function() {\n      assert.equal(roles.getStrongestRole(roles.OWNER, roles.EDITOR), roles.OWNER);\n      assert.equal(roles.getStrongestRole(roles.OWNER, roles.VIEWER, null), roles.OWNER);\n      assert.equal(roles.getStrongestRole(roles.EDITOR, roles.VIEWER), roles.EDITOR);\n      assert.equal(roles.getStrongestRole(roles.VIEWER), roles.VIEWER);\n      assert.equal(roles.getStrongestRole(roles.VIEWER, roles.GUEST), roles.VIEWER);\n      assert.equal(roles.getStrongestRole(roles.OWNER, roles.GUEST), roles.OWNER);\n      assert.equal(roles.getStrongestRole(null, roles.GUEST), roles.GUEST);\n      assert.equal(roles.getStrongestRole(null, roles.EDITOR), roles.EDITOR);\n      assert.equal(roles.getStrongestRole(roles.EDITOR, roles.EDITOR, roles.EDITOR), roles.EDITOR);\n      assert.equal(roles.getStrongestRole(roles.EDITOR, roles.OWNER, roles.EDITOR), roles.OWNER);\n      assert.equal(roles.getStrongestRole(null, null, roles.EDITOR, roles.VIEWER, roles.EDITOR), roles.EDITOR);\n      assert.equal(roles.getStrongestRole(null, null, null), null);\n\n      assert.throws(() => roles.getStrongestRole(undefined as any, roles.EDITOR), /Invalid role undefined/);\n      assert.throws(() => roles.getStrongestRole(undefined as any, null), /Invalid role undefined/);\n      assert.throws(() => roles.getStrongestRole(undefined as any, undefined), /Invalid role undefined/);\n      assert.throws(() => roles.getStrongestRole(\"XXX\" as any, roles.EDITOR), /Invalid role XXX/);\n      assert.throws(() => roles.getStrongestRole(\"XXX\" as any, null), /Invalid role XXX/);\n      assert.throws(() => roles.getStrongestRole(\"XXX\" as any, \"YYY\"), /Invalid role XXX/);\n      assert.throws(() => roles.getStrongestRole(), /No roles given/);\n    });\n  });\n\n  describe(\"getWeakestRole\", function() {\n    it(\"should return the weakest role\", function() {\n      assert.equal(roles.getWeakestRole(roles.OWNER, roles.EDITOR), roles.EDITOR);\n      assert.equal(roles.getWeakestRole(roles.OWNER, roles.VIEWER, null), null);\n      assert.equal(roles.getWeakestRole(roles.EDITOR, roles.VIEWER), roles.VIEWER);\n      assert.equal(roles.getWeakestRole(roles.VIEWER), roles.VIEWER);\n      assert.equal(roles.getWeakestRole(roles.VIEWER, roles.GUEST), roles.GUEST);\n      assert.equal(roles.getWeakestRole(roles.OWNER, roles.GUEST), roles.GUEST);\n      assert.equal(roles.getWeakestRole(null, roles.EDITOR), null);\n      assert.equal(roles.getWeakestRole(roles.EDITOR, roles.EDITOR, roles.EDITOR), roles.EDITOR);\n      assert.equal(roles.getWeakestRole(roles.EDITOR, roles.OWNER, roles.EDITOR), roles.EDITOR);\n      assert.equal(roles.getWeakestRole(null, null, roles.EDITOR, roles.VIEWER, roles.EDITOR), null);\n      assert.equal(roles.getWeakestRole(roles.OWNER, roles.OWNER), roles.OWNER);\n\n      assert.throws(() => roles.getWeakestRole(undefined as any, roles.EDITOR), /Invalid role undefined/);\n      assert.throws(() => roles.getWeakestRole(undefined as any, null), /Invalid role undefined/);\n      assert.throws(() => roles.getWeakestRole(undefined as any, undefined), /Invalid role undefined/);\n      assert.throws(() => roles.getWeakestRole(\"XXX\" as any, roles.EDITOR), /Invalid role XXX/);\n      assert.throws(() => roles.getWeakestRole(\"XXX\" as any, null), /Invalid role XXX/);\n      assert.throws(() => roles.getWeakestRole(\"XXX\" as any, \"YYY\"), /Invalid role XXX/);\n      assert.throws(() => roles.getWeakestRole(), /No roles given/);\n    });\n  });\n});\n"
  },
  {
    "path": "test/common/serializeTiming.js",
    "content": "var _ = require(\"underscore\");\nvar assert = require(\"assert\");\nvar Chance = require(\"chance\");\nvar utils = require(\"../utils\");\nvar marshal = require(\"app/common/marshal\");\n\n/**\n * This test measures the complete encoding/decoding time of several ways to serialize an array of\n * data. This is intended both to choose a good serialization format, and to optimize its\n * implementation. This test is supposed to work both in Node and in browsers.\n */\ndescribe(\"Serialization\", function() {\n\n  function marshalV0(data) {\n    var m = new marshal.Marshaller({stringToBuffer: true, version: 0});\n    m.marshal(data);\n    return m.dump();\n  }\n\n  function marshalV2(data) {\n    var m = new marshal.Marshaller({stringToBuffer: true, version: 2});\n    m.marshal(data);\n    return m.dump();\n  }\n\n  function unmarshal(buffer) {\n    var m = new marshal.Unmarshaller({bufferToString: true});\n    var value;\n    m.on(\"value\", function(v) { value = v; });\n    m.push(buffer);\n    m.removeAllListeners();\n    return value;\n  }\n\n  var encoders = {\n    \"marshal_v0\":  {enc: marshalV0,      dec: unmarshal},\n    \"marshal_v2\":  {enc: marshalV2,      dec: unmarshal},\n    \"json\":        {enc: JSON.stringify, dec: JSON.parse},\n  };\n\n  describe(\"correctness\", function() {\n    var data;\n    before(function() {\n      // Generate an array of random data using the Chance module\n      var chance = new Chance(1274323391); // seed is arbitrary\n      data = {\n        \"floats1k\": chance.n(chance.floating, 1000),\n        \"strings1k\": chance.n(chance.string, 1000),\n      };\n    });\n\n    _.each(encoders, function(encoder, name) {\n      it(name, function() {\n        assert.deepEqual(encoder.dec(encoder.enc(data.floats1k)), data.floats1k);\n        assert.deepEqual(encoder.dec(encoder.enc(data.strings1k)), data.strings1k);\n      });\n    });\n  });\n\n  utils.timing.describe(\"timings\", function() {\n    var data, encoded = {}, results = {};\n    before(function() {\n      this.timeout(10000);\n      // Generate an array of random data using the Chance module\n      var chance = new Chance(1274323391); // seed is arbitrary\n      data = {\n        \"floats100k\": chance.n(chance.floating, 100000),\n        \"strings100k\": chance.n(chance.string, 100000),\n      };\n      // And prepare an encoded version for each encoder so that we can time decoding.\n      _.each(data, function(values, key) {\n        _.each(encoders, function(encoder, name) {\n          encoded[key + \":\" + name] = encoder.enc(values);\n        });\n      });\n    });\n\n    function test_encode(name, key, expectedMs) {\n      utils.timing.it(expectedMs, \"encodes \" + key + \" with \" + name, function() {\n        utils.repeat(5, encoders[name].enc, data[key]);\n      });\n    }\n\n    function test_decode(name, key, expectedMs) {\n      utils.timing.it(expectedMs, \"decodes \" + key + \" with \" + name, function() {\n        var ret = utils.repeat(5, encoders[name].dec, encoded[key + \":\" + name]);\n        results[key + \":\" + name] = ret;\n      });\n    }\n\n    after(function() {\n      // Verify the results of decoding tests outside the timed test case.\n      _.each(results, function(result, keyName) {\n        var key = keyName.split(\":\")[0];\n        assert.deepEqual(result, data[key], \"wrong result decoding \" + keyName);\n      });\n    });\n\n    // Note that these tests take quite a bit longer when running ALL tests than when running them\n    // separately, so the expected times are artificially inflated below to let them pass. This\n    // may be because memory allocation is slower due to memory fragmentation. Just running gc()\n    // before the tests doesn't remove the discrepancy.\n    // Also note that the expected time needs to be high enough for both node and browser.\n    test_encode(\"marshal_v0\", \"floats100k\", 1600);\n    test_decode(\"marshal_v0\", \"floats100k\", 600);\n    test_encode(\"marshal_v0\", \"strings100k\", 1000);\n    test_decode(\"marshal_v0\", \"strings100k\", 800);\n\n    test_encode(\"marshal_v2\", \"floats100k\", 160);\n    test_decode(\"marshal_v2\", \"floats100k\", 160);\n    test_encode(\"marshal_v2\", \"strings100k\", 1000);\n    test_decode(\"marshal_v2\", \"strings100k\", 800);\n\n    test_encode(\"json\", \"floats100k\", 120);\n    test_decode(\"json\", \"floats100k\", 120);\n    test_encode(\"json\", \"strings100k\", 80);\n    test_decode(\"json\", \"strings100k\", 80);\n  });\n});\n"
  },
  {
    "path": "test/common/sortTiming.js",
    "content": "var assert = require(\"assert\");\nvar gutil = require(\"app/common/gutil\");\nvar _ = require(\"underscore\");\nvar utils = require(\"../utils\");\n\n// Uncomment to see logs\nfunction log(messages) {\n  //console.log.apply(console, messages);\n}\n/**\n* Compares performance of underscore.sortedIndex and gutil.sortedIndex on ranges of the\n* given array.\n* @param {array} arr - array to call sortedIndex on\n* @param {function} keyFunc - a sort key function used to sort the array\n* @param {function} cmp - a compare function used to sort the array\n* @param {object} object - object of settings for utils.time\n* @param {string} msg - helpful message to display with time results\n**/\nfunction benchmarkSortedIndex(arr, keyFunc, cmp, options, msg) {\n  var t1, t2;\n  var currArray = [], currSearchElems = [];\n  var sortedArr = _.sortBy(arr, keyFunc);\n  var compareFunc = gutil.multiCompareFunc([keyFunc], [cmp], [true]);\n\n  function testUnderscore(arr, searchElems) {\n    searchElems.forEach(function(i) { _.sortedIndex(arr, i, keyFunc); });\n  }\n  function testGutil(arr, searchElems) {\n    searchElems.forEach(function(i) { gutil.sortedIndex(arr, i, compareFunc); });\n  }\n\n  // TODO: Write a library function that does this for loop stuff b/c its largely the same\n  // across the 3 benchmark functions. This is kind of messy to abstract b/c of issues\n  // with array sorting side effects and function context.\n  for(var p = 1; 2 * currArray.length <= arr.length; p++) {\n    log([\"==========================================================\"]);\n    currArray = sortedArr.slice(0, Math.pow(2, p));\n    currSearchElems = arr.slice(0, Math.pow(2, p));\n    log([\"Calling sortedIndex\", currArray.length, \"times averaged over\", options.iters,\n      \"iterations |\", msg]);\n    t1 = utils.time(testUnderscore, null, [currArray, currSearchElems], options);\n    t2 = utils.time(testGutil, null, [currArray, currSearchElems], options);\n    log([\"Underscore.sortedIndex:\", t1, \"ms.\", \"Avg time per call:\", t1/currArray.length]);\n    log([\"gutil.sortedIndex     :\", t2, \"ms.\", \"Avg time per call:\", t2/currArray.length]);\n  }\n}\n\n/**\n* Compares performance of sorting using 1-key, 2-key, ... (keys.length)-key comparison\n* functions on ranges of the given array.\n* @param {array} arr - array to sort\n* @param {function array} keys - array of sort key functions\n* @param {function array} cmps - array of compare functions parallel to keys\n* @param {boolean array} asc - array of booleans denoting asc/descending. This is largely\n                              irrelevant to performance\n* @param {object} object - object of settings for utils.time\n* @param {string} msg - helpful message to display with time results\n**/\nfunction benchmarkMultiCompareSort(arr, keys, cmps, asc, options, msg) {\n  var elapsed;\n  var compareFuncs = [], currArray = [];\n  for(var l = 0; l < keys.length; l++) {\n    compareFuncs.push(gutil.multiCompareFunc(keys.slice(0, l+1), cmps.slice(0, l+1), asc.slice(0, l+1)));\n  }\n\n  for(var p = 1; 2 * currArray.length <= arr.length; p++) {\n    currArray = arr.slice(0, Math.pow(2, p));\n    log([\"==========================================================\"]);\n    log([\"Sorting\", currArray.length, \"elements averaged over\", options.iters,\n      \"iterations |\", msg]);\n    for(var i = 0; i < compareFuncs.length; i++) {\n      elapsed = utils.time(Array.prototype.sort, currArray, [compareFuncs[i]], options);\n      log([(i+1) + \"-key compare sort took: \", elapsed, \"ms\"]);\n    }\n  }\n}\n\n/**\n* Compares performance of Array.sort, Array.sort with a gutilMultiCompareFunc(on 1-key), and\n* Underscore's sort function on ranges of the given array.\n* @param {array} arr - array to sort\n* @param {function} compareKey - compare function to use for sorting\n* @param {function} keyFunc - key function used to construct a compare function for sorting with\n                              Array.sort\n* @param {object} object - object of settings for utils.time\n* @param {string} msg - helpful message to display with time results\n**/\nfunction benchmarkNormalSort(arr, compareFunc, keyFunc, options, msg) {\n  var t1, t2, t3;\n  var currArray = [];\n  var gutilCompare = gutil.multiCompareFunc([keyFunc], [compareFunc], [true]);\n\n  for (var p = 1; 2 * currArray.length <= arr.length; p++) {\n    log([\"==========================================================\"]);\n    currArray = arr.slice(0, Math.pow(2, p));\n    log([\"Sorting\", currArray.length, \"elements averaged over\", options.iters,\n      \"iterations |\", msg]);\n    t1 = utils.time(Array.prototype.sort, currArray, [compareFunc], options);\n    t2 = utils.time(Array.prototype.sort, currArray, [gutilCompare], options);\n    t3 = utils.time(_.sortBy, null, [currArray, keyFunc], options);\n    log([\"Array.sort with compare func                 :\", t1]);\n    log([\"Array.sort with constructed multicompare func:\", t2]);\n    log([\"Underscore sort                              :\", t3]);\n  }\n}\n\ndescribe(\"Performance tests\", function() {\n  var maxPower = 10; // tweak as needed\n  var options = {\"iters\": 10, \"avg\": true};\n  var timeout = 5000000; // arbitrary\n  var length = Math.pow(2, maxPower);\n\n  // sample data to do our sorting on. generating these random lists can take a while...\n  var nums = utils.genItems(\"floating\", length, {min:0, max:length});\n  var people = utils.genPeople(length);\n  var strings = utils.genItems(\"string\", length, {length:10});\n\n  describe(\"Benchmark test for gutil.sortedIndex\", function() {\n    it(\"should be close to underscore.sortedIndex's performance\", function() {\n      this.timeout(timeout);\n      benchmarkSortedIndex(nums, _.identity, gutil.nativeCompare, options,\n        \"Sorted index benchmark on numbers\");\n      benchmarkSortedIndex(strings, _.identity, gutil.nativeCompare, options,\n        \"Sorted index benchmark on strings\");\n      assert(true);\n    });\n  });\n\n  describe(\"Benchmarks for various sorting\", function() {\n    var peopleKeys = [_.property(\"last\"), _.property(\"first\"), _.property(\"age\"),\n      _.property(\"year\"), _.property(\"month\"), _.property(\"day\")];\n    var cmp1 = [gutil.nativeCompare, gutil.nativeCompare, gutil.nativeCompare, gutil.nativeCompare,\n      gutil.nativeCompare, gutil.nativeCompare];\n    var stringKeys = [_.identity, function (x) { return x.length; },\n      function (x) { return x[0]; } ];\n    var cmp2 = [gutil.nativeCompare, gutil.nativeCompare, gutil.nativeCompare];\n    var numKeys = [_.identity, utils.mod(2), utils.mod(3), utils.mod(5)];\n    var cmp3 = numKeys.map(function() { return gutil.nativeCompare; });\n    var asc = [1, 1, -1, 1, 1]; // bools for ascending/descending in multicompare\n\n    it(\"should be close to _.sortBy with only 1 compare key\", function() {\n      this.timeout(timeout);\n      benchmarkNormalSort(strings, gutil.nativeCompare, _.identity, options,\n        \"Regular sort test on string array\");\n      benchmarkNormalSort(people, function(a, b) { return a.age - b.age; }, _.property(\"age\"),\n        options, \"Regular sort test on people array using age as sort key\");\n      benchmarkNormalSort(nums, gutil.nativeCompare, _.identity, options,\n        \"Regular sort test on number array\");\n      assert(true);\n    });\n\n    it(\"should have consistent performance when no tie breakers are needed\", function() {\n      this.timeout(timeout);\n      benchmarkMultiCompareSort(strings, stringKeys, cmp2, asc, options, \"Consistency test on string array\");\n      benchmarkMultiCompareSort(nums, numKeys, cmp3, asc, options, \"Consistency test on number array\");\n      assert(true);\n    });\n\n    it(\"should scale linearly in the number of compare keys used\", function() {\n      this.timeout(timeout);\n      benchmarkMultiCompareSort(people, peopleKeys, cmp1, asc, options, \"Linear scaling test on people array\");\n      assert(true);\n    });\n  });\n\n});\n"
  },
  {
    "path": "test/common/timeFormat.js",
    "content": "var assert = require(\"assert\");\nvar {timeFormat} = require(\"app/common/timeFormat\");\n\ndescribe(\"timeFormat\", function() {\n\n  var date = new Date(2014, 3, 4, 22, 28, 16, 123);\n\n  it(\"should format date\", function() {\n    assert.equal(timeFormat(\"Y\", date), \"20140404\");\n    assert.equal(timeFormat(\"D\", date), \"2014-04-04\");\n  });\n\n  it(\"should format time\", function() {\n    assert.equal(timeFormat(\"T\", date), \"22:28:16\");\n    assert.equal(timeFormat(\"T + M\", date), \"22:28:16 + 123\");\n  });\n\n  it(\"should format date and time\", function() {\n    assert.equal(timeFormat(\"A\", date), \"2014-04-04 22:28:16.123\");\n  });\n});\n"
  },
  {
    "path": "test/common/tsvFormat.ts",
    "content": "import { tsvDecode, tsvEncode } from \"app/common/tsvFormat\";\n\nimport { assert } from \"chai\";\n\nconst sampleData = [\n  [\"plain value\", \"plain value\"],\n  ['quotes \"inside\" hello', 'quotes \"inside\" hello'],\n  ['\"half\" quotes', '\"half\" quotes'],\n  ['half \"quotes\"', 'half \"quotes\"'],\n  ['\"full quotes\"', '\"full quotes\"'],\n  ['\"extra\" \"quotes\"', '\"extra\" \"quotes\"'],\n  ['\"has\" \"\"double\"\" quotes\"', '\"has\" \"\"double\"\" quotes\"'],\n  ['\"more \"\"double\"\"', '\"more \"\"double\"\"'],\n  [\"tab\\tinside\", \"tab\\tinside\"],\n  [\"\\ttab first\", \"\\ttab first\"],\n  [\"tab last\\t\", \"tab last\\t\"],\n  [\" space first\", \" space first\"],\n  [\"space last \", \"space last \"],\n  [\"\\nnewline first\", \"\\nnewline first\"],\n  [\"newline last\\n\", \"newline last\\n\"],\n  [\"newline\\ninside\", \"newline\\ninside\"],\n  ['\"tab\\tinside quotes outside\"', '\"tab\\tinside quotes outside\"'],\n  ['\"tab\"\\tbetween \"quoted\"', '\"tab\"\\tbetween \"quoted\"'],\n  ['\"newline\\ninside quotes outside\"', '\"newline\\ninside quotes outside\"'],\n  ['\"newline\"\\nbetween \"quoted\"', '\"newline\"\\nbetween \"quoted\"'],\n  ['\"', '\"'],\n  ['\"\"', '\"\"'],\n  // A few special characters on their own that should work correctly.\n  [\"\", \" \", \"\\t\", \"\\n\", \"'\", \"\\\\\"],\n  // Some non-string values\n  [0, 1, false, true, undefined, null, Number.NaN],\n];\n\n// This is the encoding produced by Excel (latest version on Mac as of March 2017).\nconst sampleEncoded = `plain value\\tplain value\nquotes \"inside\" hello\\tquotes \"inside\" hello\n\"half\" quotes\\t\"half\" quotes\nhalf \"quotes\"\\thalf \"quotes\"\n\"full quotes\"\\t\"full quotes\"\n\"extra\" \"quotes\"\\t\"extra\" \"quotes\"\n\"has\" \"\"double\"\" quotes\"\\t\"has\" \"\"double\"\" quotes\"\n\"more \"\"double\"\"\\t\"more \"\"double\"\"\n\"tab\\tinside\"\\t\"tab\\tinside\"\n\"\\ttab first\"\\t\"\\ttab first\"\n\"tab last\\t\"\\t\"tab last\\t\"\n space first\\t space first\nspace last \\tspace last ${\"\"/* the trailing space is intentional */}\n\"\\nnewline first\"\\t\"\\nnewline first\"\n\"newline last\\n\"\\t\"newline last\\n\"\n\"newline\\ninside\"\\t\"newline\\ninside\"\n\"\"\"tab\\tinside quotes outside\"\"\"\\t\"\"\"tab\\tinside quotes outside\"\"\"\n\"\"\"tab\"\"\\tbetween \"\"quoted\"\"\"\\t\"\"\"tab\"\"\\tbetween \"\"quoted\"\"\"\n\"\"\"newline\\ninside quotes outside\"\"\"\\t\"\"\"newline\\ninside quotes outside\"\"\"\n\"\"\"newline\"\"\\nbetween \"\"quoted\"\"\"\\t\"\"\"newline\"\"\\nbetween \"\"quoted\"\"\"\n\"\\t\"\n\"\"\\t\"\"\n\\t \\t\"\\t\"\\t\"\\n\"\\t'\\t\\\\\n0\\t1\\tfalse\\ttrue\\t\\t\\tNaN`;\n\nconst sampleDecoded = [\n  [\"plain value\", \"plain value\"],\n  ['quotes \"inside\" hello', 'quotes \"inside\" hello'],\n  [\"half quotes\", \"half quotes\"],         // not what was encoded, but matches Excel\n  ['half \"quotes\"', 'half \"quotes\"'],\n  [\"full quotes\", \"full quotes\"],         // not what was encoded, but matches Excel\n  ['extra \"quotes\"', 'extra \"quotes\"'],   // not what was encoded, but matches Excel\n  ['has \"\"double\"\" quotes\"', 'has \"\"double\"\" quotes\"'], // not what was encoded, but matches Excel\n  ['more \"double\"\\tmore \"\"double\"\"'],     // not what was encoded, but matches Excel\n  [\"tab\\tinside\", \"tab\\tinside\"],\n  [\"\\ttab first\", \"\\ttab first\"],\n  [\"tab last\\t\", \"tab last\\t\"],\n  [\" space first\", \" space first\"],\n  [\"space last \", \"space last \"],\n  [\"\\nnewline first\", \"\\nnewline first\"],\n  [\"newline last\\n\", \"newline last\\n\"],\n  [\"newline\\ninside\", \"newline\\ninside\"],\n  ['\"tab\\tinside quotes outside\"', '\"tab\\tinside quotes outside\"'],\n  ['\"tab\"\\tbetween \"quoted\"', '\"tab\"\\tbetween \"quoted\"'],\n  ['\"newline\\ninside quotes outside\"', '\"newline\\ninside quotes outside\"'],\n  ['\"newline\"\\nbetween \"quoted\"', '\"newline\"\\nbetween \"quoted\"'],\n  [\"\\t\"],                                 // not what was encoded, but matches Excel\n  [\"\", \"\"],                               // not what was encoded, but matches Excel\n  // A few special characters on their own that should work correctly.\n  [\"\", \" \", \"\\t\", \"\\n\", \"'\", \"\\\\\"],\n  // All values get parsed as strings.\n  [\"0\", \"1\", \"false\", \"true\", \"\", \"\", \"NaN\"],\n];\n\ndescribe(\"tsvFormat\", function() {\n  it(\"should encode tab-separated values as Excel does\", function() {\n    assert.deepEqual(tsvEncode(sampleData), sampleEncoded);\n  });\n\n  it(\"should decode tab-separated values as Excel does\", function() {\n    assert.deepEqual(tsvDecode(sampleEncoded), sampleDecoded);\n  });\n});\n"
  },
  {
    "path": "test/declarations.d.ts",
    "content": "declare module \"test/nbrowser/gristUtil-nbrowser\" {\n  // TODO - tsc can now do nice type inference for most of this, except $,\n  // so could change how export is done. Right now it leads to a mess because\n  // of $.\n  export declare let $: any;\n  export declare let gu: any;\n  export declare let server: any;\n  export declare let test: any;\n}\n\n// Adds missing type declaration to chai\ndeclare namespace Chai {\n  interface AssertStatic {\n    notIncludeMembers<T>(superset: T[], subset: T[], message?: string): void;\n  }\n}\n"
  },
  {
    "path": "test/deployment/ActionLog.ts",
    "content": "import \"test/nbrowser/ActionLog\";\n"
  },
  {
    "path": "test/deployment/ChoiceList.ts",
    "content": "import \"test/nbrowser/ChoiceList\";\n"
  },
  {
    "path": "test/deployment/DuplicateDocument.ts",
    "content": "import \"test/nbrowser/DuplicateDocument\";\n"
  },
  {
    "path": "test/deployment/Fork.ts",
    "content": "import \"test/nbrowser/Fork\";\n"
  },
  {
    "path": "test/deployment/HomeIntro.ts",
    "content": "import \"test/nbrowser/HomeIntro\";\n"
  },
  {
    "path": "test/deployment/Pages.ts",
    "content": "import \"test/nbrowser/Pages\";\n"
  },
  {
    "path": "test/deployment/README.md",
    "content": "# Deployment tests\n\nLink or import here all tests that can be run against an external server or\na docker container (i.e: tests that don't rely on in-memory TestServer).\n"
  },
  {
    "path": "test/deployment/ReferenceColumns.ts",
    "content": "import \"test/nbrowser/ReferenceColumns\";\n"
  },
  {
    "path": "test/deployment/ReferenceList.ts",
    "content": "import \"test/nbrowser/ReferenceList\";\n"
  },
  {
    "path": "test/deployment/Smoke.ts",
    "content": "import \"test/nbrowser/Smoke\";\n"
  },
  {
    "path": "test/fixtures/export-csv/CCTransactions-DBA-desc.csv",
    "content": "Date,Description,Card Member,Account,Amount,Extended Details,Doing Business As,Street Address,City State Zip,Reference,Category\n2015-07-16,VISTAPR*VISTAPRINT.C866 893 6743 CA,Vera O'Connor,XXXX-XXXXXX-41106,5.44,\" Additional Information: 866-614-8002 \n \",WWW.VISTAPRINT.COM,\"95 HAYDEN AVE\nLEXINGTON\nMA\",\"02421-7942\nUNITED STATES OF AMERICA (THE)\",320151980521681765,Merchandise & Supplies-Internet Purchase\n2015-07-17,VISTAPR*VISTAPRINT.C866 893 6743 CA,Vera O'Connor,XXXX-XXXXXX-41106,66.62,\" Additional Information: 866-614-8002 \n \",WWW.VISTAPRINT.COM,\"95 HAYDEN AVE\nLEXINGTON\nMA\",\"02421-7942\nUNITED STATES OF AMERICA (THE)\",320151990540257622,Merchandise & Supplies-Internet Purchase\n2015-07-21,VISTAPR*VISTAPRINT.C866 893 6743 CA,Vera O'Connor,XXXX-XXXXXX-41106,-5.9,\" Additional Information: 866-614-8002 \n \",WWW.VISTAPRINT.COM,\"95 HAYDEN AVE\nLEXINGTON\nMA\",\"02421-7942\nUNITED STATES OF AMERICA (THE)\",320152030601233931,Merchandise & Supplies-Internet Purchase\n2015-05-01,WICHCRAFT BRYANT PARNEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,453.6,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151210250625154,Restaurant-Bar & Café\n2015-05-08,WICHCRAFT BRYANT PARNEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,384.93,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151280366691479,Restaurant-Bar & Café\n2015-06-29,WICHCRAFT BRYANT PARNEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,1143.76,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151800216664401,Restaurant-Bar & Café\n2015-07-02,WICHCRAFT BRYANT PARNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,1215.24,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151830283404245,Restaurant-Bar & Café\n2015-07-10,WICHCRAFT BRYANT PARNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,1320,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151910396270471,Restaurant-Bar & Café\n2015-07-16,WICHCRAFT BRYANT PARNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,990,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151970493780060,Restaurant-Bar & Café\n2015-08-13,WICHCRAFT BRYANT PARNEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,214.45,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320152250967825071,Restaurant-Bar & Café\n2015-09-03,WICHCRAFT BRYANT PARNEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,219.82,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320152460298452869,Restaurant-Bar & Café\n2015-05-02,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,33.91,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151230294133437,Merchandise & Supplies-Groceries\n2015-05-09,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,38.2,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151300405656534,Merchandise & Supplies-Groceries\n2015-06-28,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,3.25,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151800223528639,Merchandise & Supplies-Groceries\n2015-06-28,WHOLEFDS TRB 10245 0NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,29.82,\" Additional Information: 2123496555 \n  Description  Price \n  GROCERY STORES  $29.82 \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151800223964992,Merchandise & Supplies-Groceries\n2015-09-20,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,12.49,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320152640596948939,Merchandise & Supplies-Groceries\n2015-10-04,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,13.48,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320152780825369796,Merchandise & Supplies-Groceries\n2015-04-25,DUANE READE #14247 0NEW YORK NY,Clare Dudley,XXXX-XXXXXX-41098,2.17,\" Additional Information: 8002892273 \n  Description \n  REFER TO RECEIPT \n \",WALGREEN,\"4 W 4TH ST\nNEW YORK\nNY\",\"10012-1168\nUNITED STATES OF AMERICA (THE)\",320151160181404910,Merchandise & Supplies-Pharmacies\n2015-07-10,DUANE READE #14247 0NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,4.99,\" Additional Information: 8002892273 \n  Description \n  REFER TO RECEIPT \n \",WALGREEN,\"4 W 4TH ST\nNEW YORK\nNY\",\"10012-1168\nUNITED STATES OF AMERICA (THE)\",320151920416442525,Merchandise & Supplies-Pharmacies\n2015-08-03,USPS 333675955102007HOBOKEN NJ,Maris Burton,XXXX-XXXXXX-43060,5.75,\" Additional Information: 800-2758777 \n \",USPS/HOBOKEN,\"89 RIVER ST\nHOBOKEN\nNJ\",\"07030-9998\nUNITED STATES OF AMERICA (THE)\",320152160817163880,Business Services-Mailing & Shipping\n2015-10-03,USPS PO BOXES 101510WASHINGTON DC,Clare Dudley,XXXX-XXXXXX-41098,156,\" Additional Information: 800-3447779 \n \",USPS PO BOXES ONLINE,\"475 LENFANT PLZ SW\nWASHINGTON\nDC\",\"20260-0004\nUNITED STATES OF AMERICA (THE)\",320152770808332661,Business Services-Mailing & Shipping\n2015-03-11,USPS 359605000800484NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,9.8,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE-NEW YORK CITY,\"421 8TH AVE\nRM 3007\nNEW YORK\nNY\",\"10199-1003\nUNITED STATES OF AMERICA (THE)\",320150710470965085,Business Services-Mailing & Shipping\n2015-02-13,USPS 354395021106656KINGSTON NY,Callum Wilson,XXXX-XXXXXX-42021,147,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE,\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\",\"10610-1000\nUNITED STATES OF AMERICA (THE)\",320150450074080962,Business Services-Mailing & Shipping\n2015-06-24,USPS 354395021106656KINGSTON NY,Callum Wilson,XXXX-XXXXXX-42021,5.95,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE,\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\",\"10610-1000\nUNITED STATES OF AMERICA (THE)\",320151760163504192,Business Services-Mailing & Shipping\n2015-07-11,USPS 354395021106656KINGSTON NY,Callum Wilson,XXXX-XXXXXX-42021,17.34,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE,\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\",\"10610-1000\nUNITED STATES OF AMERICA (THE)\",320151930433305676,Business Services-Mailing & Shipping\n2015-06-28,TWO BOOTS PIZZA NEW YORK NY,Clare Dudley,XXXX-XXXXXX-41098,102.25,\" Additional Information: 212-777-2668 \n  Description \n  FOOD/BEVERAGE \n \",TWO BOOTS TO GO WEST,\"201 W 11TH ST\nNEW YORK\nNY\",\"10014\nUNITED STATES OF AMERICA (THE)\",320151800225150895,Restaurant-Restaurant\n2015-06-30,SUBWAY 999912MIAMI FL,Howard Washington,XXXX-XXXXXX-43003,813.84,\" Additional Information: 305-6700041 \n \",SUBWAY,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320151820254568483,Restaurant-Bar & Café\n2015-07-07,SUBWAY 999912MIAMI FL,Maris Burton,XXXX-XXXXXX-43060,813.84,\" Additional Information: 305-6700041 \n \",SUBWAY,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320151890365608242,Restaurant-Bar & Café\n2015-07-14,SUBWAY 999912MIAMI FL,Maris Burton,XXXX-XXXXXX-43060,747.5,\" Additional Information: 305-6700041 \n \",SUBWAY,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320151960480941768,Restaurant-Bar & Café\n2015-10-01,SUBWAY MIAMI FL,Vera O'Connor,XXXX-XXXXXX-41106,117,\" Additional Information: 305-6700041 \n \",SUBWAY,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320152750771031096,Restaurant-Bar & Café\n2015-08-14,STARBUCKS #07497 NEWNew York NY,Darius Burgess,XXXX-XXXXXX-41148,16.28,\" Additional Information: New York \n \",STARBUCKS,\"665 BROADWAY\nBROADWAY AND BOND\nNEW YORK\nNY\",\"10012\nUNITED STATES OF AMERICA (THE)\",320152260973467313,Restaurant-Bar & Café\n2015-03-05,STAPLES 00242 (800)333-3330,Callum Wilson,XXXX-XXXXXX-42021,72.1,\" Additional Information: 00242000106701 12401 \n  SPLS 8.5X11 MULTI 20/96 RM \n  AVY LSR LBL 3000PK 1X2 5/8 \n  CLASP ENV BRN KRAFT 9X12 -100 \n \",STAPLES,\"1399 ULSTER AVE\nKINGSTON\nNY\",\"12401\nUNITED STATES OF AMERICA (THE)\",320150650374451756,Business Services-Office Supplies\n2015-05-02,STAPLES 01106 (800)333-3330,Callum Wilson,XXXX-XXXXXX-42021,2.16,\" Additional Information: 01106000109206 10003 \n  CRA-Z-ART WHITE CHALK 16 CT \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151230292173007,Business Services-Office Supplies\n2015-05-09,STAPLES 00193 (800)333-3330,Vera O'Connor,XXXX-XXXXXX-41106,10.43,\" Additional Information: 00193000224345 10007 \n  9X12 CLEAR CLASP ENV 10PK \n \",STAPLES,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320151300408500603,Business Services-Office Supplies\n2015-05-09,STAPLES 00193 (800)333-3330,Vera O'Connor,XXXX-XXXXXX-41106,10.58,\" Additional Information: 00193002523005 10007 \n  BW SS P@SS LTR/LGL \n \",STAPLES,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320151300407029361,Business Services-Office Supplies\n2015-05-09,STAPLES 00193 (800)333-3330,Vera O'Connor,XXXX-XXXXXX-41106,37.1,\" Additional Information: 00193002523001 10007 \n  BW SS P@SS LTR/LGL \n \",STAPLES,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320151300408517031,Business Services-Office Supplies\n2015-06-29,STAPLES 01232 (800)333-3330,Clare Dudley,XXXX-XXXXXX-41098,6.85,\" Additional Information: 01232000706107 11230 \n  NAME BDG BLUE BORDER LBL \n \",STAPLES,\"1880 CONEY ISLAND AVE\nBROOKLYN\nNY\",\"11230\nUNITED STATES OF AMERICA (THE)\",320151810242451340,Business Services-Office Supplies\n2015-07-01,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,11.97,\" Additional Information: 01106000121928 10003 \n  POSTERBOARD 22X28 FLUR ASST 5 \n  SCOTCH INVISIBLE TAPE 3/4X300 \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151830278394728,Business Services-Office Supplies\n2015-07-07,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,4.34,\" Additional Information: 01798000228531 10011 \n  3TAB FILE FLDR LBL \n \",STAPLES,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151890371868920,Business Services-Office Supplies\n2015-07-09,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,148.97,\" Additional Information: 01106000511857 10003 \n  PASTELS 8.5X11 GREEN PAPER RM \n  PASTELS 8.5X11 SALMON PAPER RM \n  PASTELS 8.5X11 BLUE PAPER RM \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151910406467408,Business Services-Office Supplies\n2015-07-14,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,-4.56,\" Additional Information: 01106000712254 10003 \n  3TAB FILE FLDR LBL \n  POSTERBOARD 22X28 FLUR ASST 5 \n  GRTNR CERT 8.5X11 BLUE/SLV 100 \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151960486379349,Business Services-Office Supplies\n2015-07-15,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,9.03,\" Additional Information: 01106002530465 10003 \n  COMPUTER RENTAL \n  CW BW PRNT \n  BW SS P@SS LTR/LGL \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151970501140377,Business Services-Office Supplies\n2015-07-16,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,5.99,\" Additional Information: 01798002507818 10011 \n  COMPUTER RENTAL \n  CW BW PRNT \n \",STAPLES,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151980518721472,Business Services-Office Supplies\n2015-07-16,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,24.91,\" Additional Information: 01798002507807 10011 \n  BW SS P@SS LTR/LGL \n  CLR SS P@SS LTR/LGL \n \",STAPLES,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151980516856896,Business Services-Office Supplies\n2015-07-16,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,93.9,\" Additional Information: 01798000535198 10011 \n  STPLS MEMO CUBE 500CT \n  251-500 BW2 LTR STD \n  STAPLING \n \",STAPLES,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151980519021999,Business Services-Office Supplies\n2015-07-17,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,54.98,\" Additional Information: 01106000512518 10003 \n  STAPLING \n  1-100 BW 32LB ULTRA PREM \n  1-100 BW2 32LB ULTRA PREM \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151990538533185,Business Services-Office Supplies\n2015-08-08,STAPLES 01106 (800)333-3330,Clare Dudley,XXXX-XXXXXX-41098,20.25,\" Additional Information: 01106002531781 10003 \n  BW SS P@SS LTR/LGL \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320152210896145086,Business Services-Office Supplies\n2015-12-02,STAPLES 00193 NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,3.74,\"001006221 00193001006221 10007\n00193001006221 10007\nBW SS P@SS LTR/LGL\nCLASP ENV BRN KRAFT 9X12 -12\n\n\n\n 00193001006221 10007 \n  BW SS P@SS LTR/LGL  \n  CLASP ENV BRN KRAFT 9X12 -12  \n \",STAPLES,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320153370819408517,Business Services-Office Supplies\n2015-12-02,STAPLES 00193 NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,9.15,\"002558833 00193002558833 10007\n00193002558833 10007\nBW SS P@SS LTR/LGL\n\n\n\n\n 00193002558833 10007 \n  BW SS P@SS LTR/LGL  \n \",STAPLES,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320153370816757331,Business Services-Office Supplies\n2015-07-17,SACRED CHOW 212-337-0863,Maris Burton,XXXX-XXXXXX-43060,16.15,\" Additional Information: 212-337-0863 \n \",SACRED CHOW,\"227 SULLIVAN ST\nFRNT 1\nNEW YORK\nNY\",\"10012-4803\nUNITED STATES OF AMERICA (THE)\",320151990541493366,Restaurant-Restaurant\n2015-02-20,PIZZERIA REGINA SO 5BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,15.61,\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $15.61 \n \",PIZZERIA REGINA AT SOUTH,\"2 SOUTH STA\nBOSTON\nMA\",\"02110-2208\nUNITED STATES OF AMERICA (THE)\",320150520176216796,Restaurant-Bar & Café\n2015-02-20,PIZZERIA REGINA SO 5BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,44.8,\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $44.80 \n \",PIZZERIA REGINA AT SOUTH,\"2 SOUTH STA\nBOSTON\nMA\",\"02110-2208\nUNITED STATES OF AMERICA (THE)\",320150520176217729,Restaurant-Bar & Café\n2015-02-21,PIZZERIA REGINA SO 5BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,115.91,\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $115.91 \n \",PIZZERIA REGINA AT SOUTH,\"2 SOUTH STA\nBOSTON\nMA\",\"02110-2208\nUNITED STATES OF AMERICA (THE)\",320150530188890159,Restaurant-Bar & Café\n2015-02-06,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,402.04,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320150400993249691,Restaurant-Restaurant\n2015-02-27,PIZZA MERCATO NEW YORK NY,Nyssa O'Neil,XXXX-XXXXXX-41122,382.04,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320150590274046170,Restaurant-Restaurant\n2015-05-21,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,383.59,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151430627016798,Restaurant-Restaurant\n2015-06-19,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,328.86,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151730114686576,Restaurant-Restaurant\n2015-06-29,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,502.25,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151810247097284,Restaurant-Restaurant\n2015-07-01,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,649.25,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151840296360778,Restaurant-Restaurant\n2015-07-06,PIZZA MERCATO NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,664.69,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151880360068571,Restaurant-Restaurant\n2015-07-13,PIZZA MERCATO NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,618.34,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320151950473655224,Restaurant-Restaurant\n2015-07-17,PIZZA MERCATO NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,204.54,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152010569229393,Restaurant-Restaurant\n2015-09-25,PIZZA MERCATO NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,590.43,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152710713208356,Restaurant-Restaurant\n2015-10-16,PIZZA MERCATO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,57.75,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152920061757486,Restaurant-Restaurant\n2015-10-16,PIZZA MERCATO NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,774.62,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152920062466899,Restaurant-Restaurant\n2015-11-13,PIZZA MERCATO NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,774.62,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320153200524725369,Restaurant-Restaurant\n2015-11-15,PIZZA MERCATO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,93.8,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320153210543933275,Restaurant-Restaurant\n2015-02-20,PINKBERRY 165 BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,10.5,\" Additional Information: FAST FOOD RESTAURANT \n  FOOD/BEVERAGE  $10.50 \n \",PINKBERRY,\"700 ATLANTIC AVE, STE105\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150520176459450,Restaurant-Bar & Café\n2015-05-29,PENN STATER CONF CTRSTATE COLLEGE PA,Nyssa O'Neil,XXXX-XXXXXX-41122,3598.48,\" Additional Information: 814-865-8500 \n \",PENN STATER CONF CNTR HTL,\"215 INNOVATION BLVD\nSTATE COLLEGE\nPA\",\"16803-6603\nUNITED STATES OF AMERICA (THE)\",320151500729234751,Travel-Lodging\n2015-01-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320150210695238055,Merchandise & Supplies-Internet Purchase\n2015-02-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,81.66,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320150540197243171,Merchandise & Supplies-Internet Purchase\n2015-03-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,81.66,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320150820630530881,Merchandise & Supplies-Internet Purchase\n2015-04-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,83.83,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320151110091111545,Merchandise & Supplies-Internet Purchase\n2015-05-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,83.83,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320151410579383246,Merchandise & Supplies-Internet Purchase\n2015-06-22,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,83.83,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320151730103193144,Merchandise & Supplies-Internet Purchase\n2015-07-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,92.54,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152030590555153,Merchandise & Supplies-Internet Purchase\n2015-08-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,94.72,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152340102862781,Merchandise & Supplies-Internet Purchase\n2015-09-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,81.66,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152650604655989,Merchandise & Supplies-Internet Purchase\n2015-10-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098979714,Merchandise & Supplies-Internet Purchase\n2015-10-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098983888,Merchandise & Supplies-Internet Purchase\n2015-10-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098983890,Merchandise & Supplies-Internet Purchase\n2015-10-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098983894,Merchandise & Supplies-Internet Purchase\n2015-10-22,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152960115550127,Merchandise & Supplies-Internet Purchase\n2015-10-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133427869,Merchandise & Supplies-Internet Purchase\n2015-10-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133427871,Merchandise & Supplies-Internet Purchase\n2015-10-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133427873,Merchandise & Supplies-Internet Purchase\n2015-10-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133464913,Merchandise & Supplies-Internet Purchase\n2015-11-23,888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,79.48,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320153280650085362,Merchandise & Supplies-Internet Purchase\n2015-05-29,PANERA BREAD #601723NEW YORK NY,Nyssa O'Neil,XXXX-XXXXXX-41122,1142.37,\" Additional Information: 9999999999 \n \",PANERA BREAD CAFE INSTORE,\"330 FIFTH AVE\n-\nNEW YORK\nNY\",\"10001\nUNITED STATES OF AMERICA (THE)\",320151500733586616,Restaurant-Bar & Café\n2015-11-21,PJS PANCAKE HOUSE 65PRINCETON NJ,Darius Burgess,XXXX-XXXXXX-41148,127.75,\" Additional Information: 6099241353 \n \",P J'S PANCAKE HOUSE RESTAURANT,\"154 NASSAU ST\nPRINCETON\nNJ\",\"08542-7006\nUNITED STATES OF AMERICA (THE)\",320153260617461233,Restaurant-Restaurant\n2015-05-16,NORTH SQUARE RESTAURNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,114,\" Additional Information: 2122541200 \n \",NORTH SQUARE RESTAURANT & LOUNGE,\"103 WAVERLY PL\nNEW YORK\nNY\",\"10011-9110\nUNITED STATES OF AMERICA (THE)\",320151370527011129,Restaurant-Restaurant\n2015-05-16,NORTH SQUARE RESTAURNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,612,\" Additional Information: 2122541200 \n \",NORTH SQUARE RESTAURANT & LOUNGE,\"103 WAVERLY PL\nNEW YORK\nNY\",\"10011-9110\nUNITED STATES OF AMERICA (THE)\",320151370525443159,Restaurant-Restaurant\n2015-11-20,NJT NY PENN STA 50NEW YORK NJ,Darius Burgess,XXXX-XXXXXX-41148,1011.75,\" Additional Information: 973-2755555 \n \",NJ TRANSIT,\"NEW YORK PENN STATION\n34TH & 7TH AVENUES\nNEW YORK\nNY\",\"10016\nUNITED STATES OF AMERICA (THE)\",320153250615656980,Transportation-Rail Services\n2015-01-17,MYPIZZA.COM*MYPIZZA STATEN ISLA NY,Howard Washington,XXXX-XXXXXX-43003,382.06,\" Additional Information: 888-974-9928 \n  Description \n  MYPIZZA COM \n \",MYPIZZA.COM - E COMMERCE,\"97 NEW DORP PLZ N\nSTATEN ISLAND\nNY\",\"10306-2903\nUNITED STATES OF AMERICA (THE)\",320150170634561830,Restaurant-Bar & Café\n2015-10-27,\"MES*RINGCENTRAL, INC6504724100\",Vera O'Connor,XXXX-XXXXXX-41106,160.56,\" Additional Information: 3719004008 94002 \n \",\"MES*RINGCENTRAL, INC\",\"1400 FASHION IS\nSAN MATEO\nCA\",\"944\nUNITED STATES OF AMERICA (THE)\",320153010200395072,Communications-Mobile Telecom\n2015-02-20,MCDONALD'S F11729 00BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,7.27,\" Additional Information: 6173549027 \n \",MC DONALD'S,\"2 SOUTH STA\nFL 5\nBOSTON\nMA\",\"02110-2288\nUNITED STATES OF AMERICA (THE)\",320150520174787823,Restaurant-Bar & Café\n2015-06-29,MAOZ VEGETARIAN - 8TNEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,12.52,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151810238061322,Restaurant-Bar & Café\n2015-06-30,MAOZ VEGETARIAN - 8TNEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,14.7,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151820255845813,Restaurant-Bar & Café\n2015-07-01,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,14.7,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151830273492010,Restaurant-Bar & Café\n2015-07-02,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151840291994605,Restaurant-Bar & Café\n2015-07-06,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151880350844359,Restaurant-Bar & Café\n2015-07-07,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,14.7,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151890367176935,Restaurant-Bar & Café\n2015-07-08,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,6.61,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151900383693086,Restaurant-Bar & Café\n2015-07-09,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151910401557217,Restaurant-Bar & Café\n2015-07-10,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,836,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151920425504018,Restaurant-Bar & Café\n2015-07-13,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,14.7,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151950464236262,Restaurant-Bar & Café\n2015-07-14,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151960482805860,Restaurant-Bar & Café\n2015-07-15,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,753.22,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151970499604816,Restaurant-Bar & Café\n2015-07-16,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,12.52,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151980516521333,Restaurant-Bar & Café\n2015-02-03,MAILCHIMP MAILCHIMP.COM GA,Callum Wilson,XXXX-XXXXXX-42021,212.5,\" Additional Information: EMAIL MKTG \n \",MAILCHIMP,\"512 MEANS ST NW\nSTE 404\nATLANTA\nGA\",\"30318-5788\nUNITED STATES OF AMERICA (THE)\",320150350916563707,Other-Miscellaneous\n2015-02-22,LEMERIDIEN CAMBRIDGECAMBRIDGE MA,Nyssa O'Neil,XXXX-XXXXXX-41122,1516.77,\" Arrival Date  Departure Date \n  02/20/15  02/21/15 \n  00000000 \n  LODGING \n \",LE MERIDIEN HOTEL,\"20 SIDNEY ST\nCAMBRIDGE\nMA\",\"02139-4122\nUNITED STATES OF AMERICA (THE)\",320150540207459315,Travel-Lodging\n2015-04-17,INKWELL GLOBAL MARKEMANALAPAN NJ,Howard Washington,XXXX-XXXXXX-43003,1241,\" Additional Information: 7325362822 \n \",INKWELL GLOBAL MARKETING,\"600 MADISON AVE\nMANALAPAN\nNJ\",\"07726-9594\nUNITED STATES OF AMERICA (THE)\",320151080049401834,Merchandise & Supplies-Mail Order\n2015-11-21,HN-DUNKIN ST212 0000NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,43.14,\" Additional Information: 800-326-7711 \n  Description \n  GROCERIES/SUNDRIES \n \",HUDSON NEWS,\"8TH AVE -PENN STN TKT LVL\nNEW YORK\nNY\",\"10001\nUNITED STATES OF AMERICA (THE)\",320153260627158328,Merchandise & Supplies-Book Stores\n2015-03-19,HP HOME STORE 888-345-5409 CA,Vera O'Connor,XXXX-XXXXXX-41106,46.51,\" Additional Information: COMPUTER \n \",HPSHOPPING.COM,\"SVP01 4TH FLOOR MS 3541\n950 MAUDE AVENUE\nSUNNYVALE\nCA\",\"94085\nUNITED STATES OF AMERICA (THE)\",320150790598271773,Merchandise & Supplies-Computer Supplies\n2015-03-27,5% OPEN Savings at HP,Vera O'Connor,XXXX-XXXXXX-41106,-2.33,\" Additional Information: SEE SUMMARY GRID FOR MORE INFORMATION \n \",HPSHOPPING.COM,\"SVP01 4TH FLOOR MS 3541\n950 MAUDE AVENUE\nSUNNYVALE\nCA\",\"94085\nUNITED STATES OF AMERICA (THE)\",320150860708183861,Merchandise & Supplies-Computer Supplies\n2015-05-31,HILTON GARDEN INN 12STATE COLLEGE PA,Nyssa O'Neil,XXXX-XXXXXX-41122,111.76,\" Arrival Date  Departure Date \n  05/29/15  05/30/15 \n  00000000 \n  LODGING \n \",HILTON GARDEN INN,\"1221 EAST COLLEGE AVENUE\nSTATE COLLEGE\nPA\",\"16801\nUNITED STATES OF AMERICA (THE)\",320151510740498966,Travel-Lodging\n2015-05-31,HILTON GARDEN INN 12STATE COLLEGE PA,Nyssa O'Neil,XXXX-XXXXXX-41122,111.76,\" Arrival Date  Departure Date \n  05/29/15  05/30/15 \n  00000000 \n  LODGING \n \",HILTON GARDEN INN,\"1221 EAST COLLEGE AVENUE\nSTATE COLLEGE\nPA\",\"16801\nUNITED STATES OF AMERICA (THE)\",320151510740707763,Travel-Lodging\n2015-10-15,GROUPON INC 877-788-7858 IL,Vera O'Connor,XXXX-XXXXXX-41106,39.5,\" Additional Information: COUPONS \n \",GROUPON INC,\"600 W CHICAGO AVE\nSTE 400\nCHICAGO\nIL\",\"60654-2067\nUNITED STATES OF AMERICA (THE)\",320152890013469812,Merchandise & Supplies-Internet Purchase\n2015-05-29,GRISTEDES # 508 5429NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,82.05,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $82.05 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151500729902113,Merchandise & Supplies-Groceries\n2015-06-29,GRISTEDES # 508 5429NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,78.03,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $78.03 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151810242916035,Merchandise & Supplies-Groceries\n2015-06-30,GRISTEDES # 508 5429NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,118.97,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $118.97 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151820256966499,Merchandise & Supplies-Groceries\n2015-07-01,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,69.09,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $69.09 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151830273619758,Merchandise & Supplies-Groceries\n2015-07-02,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,4.56,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $4.56 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151840293289242,Merchandise & Supplies-Groceries\n2015-07-02,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,63.82,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $63.82 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151840293808568,Merchandise & Supplies-Groceries\n2015-07-06,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.82,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $7.82 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151880352939553,Merchandise & Supplies-Groceries\n2015-07-06,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,104.06,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $104.06 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151880351787873,Merchandise & Supplies-Groceries\n2015-07-07,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,66.06,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $66.06 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151890369426433,Merchandise & Supplies-Groceries\n2015-07-08,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,8.69,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $8.69 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151900384374156,Merchandise & Supplies-Groceries\n2015-07-08,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,72.26,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $72.26 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151900384651040,Merchandise & Supplies-Groceries\n2015-07-09,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,61.25,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $61.25 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151910403911324,Merchandise & Supplies-Groceries\n2015-07-10,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,106.79,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $106.79 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151920418500382,Merchandise & Supplies-Groceries\n2015-07-13,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,113.97,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $113.97 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151950464750950,Merchandise & Supplies-Groceries\n2015-07-14,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,72.49,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $72.49 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151960484410879,Merchandise & Supplies-Groceries\n2015-07-15,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,13.03,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $13.03 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151970502025423,Merchandise & Supplies-Groceries\n2015-07-15,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,69.36,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $69.36 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151970500708696,Merchandise & Supplies-Groceries\n2015-07-16,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,54.81,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $54.81 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151980517736119,Merchandise & Supplies-Groceries\n2015-07-17,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,74.03,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $74.03 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151990534884422,Merchandise & Supplies-Groceries\n2015-09-18,FRESH & CO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,150.95,\" Additional Information: FAST FOOD RESTAURANT \n  Description \n  182599 \n \",FRESH & CO,\"58 EAST 8TH ST\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320152640601966334,Restaurant-Bar & Café\n2015-09-19,FRESH & CO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,8.66,\" Additional Information: 2124737374 \n  FOOD/BEVERAGE  $8.66 \n \",FRESH & CO,\"729 BROADWAY\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320152630582376228,Restaurant-Bar & Café\n2015-10-30,FRESH & CO NEW YORK,Vera O'Connor,XXXX-XXXXXX-41106,137.89,\" Additional Information: FAST FOOD RESTAURANT \n  Description \n  105071 \n \",FRESH & CO,\"58 EAST 8TH ST\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320153060292051275,Restaurant-Bar & Café\n2015-07-29,THE FARM ON ADDERLY BROOKLYN NY,Darren Graham,XXXX-XXXXXX-41130,45,\" Additional Information: RESTAURANT \n \",FARM ON ADDERLY,\"1108 CORTELYOU RD\nBROOKLYN\nNY\",\"11218-5304\nUNITED STATES OF AMERICA (THE)\",320152120748477327,Restaurant-Restaurant\n2015-03-20,FAMOUS FAMIGLIA PI 5NEW YORK NY,Nyssa O'Neil,XXXX-XXXXXX-41122,287.1,\" Additional Information: 2129969797 \n  FOOD/BEVERAGE  $287.10 \n \",FAMOUS FAMIGLIA PIZZERIA,\"1398 MADISON AVE\nNEW YORK\nNY\",\"10029-6903\nUNITED STATES OF AMERICA (THE)\",320150800614157648,Restaurant-Restaurant\n2015-02-21,DUNKIN #300223 QCAMBRIDGE MA,Nyssa O'Neil,XXXX-XXXXXX-41122,187.68,\" Additional Information: 617-354-8944 \n \",DUNKIN' DONUTS,\"616 MASSACHUSETTS AVE\nCAMBRIDGE\nMA\",\"02139-3307\nUNITED STATES OF AMERICA (THE)\",320150540208503704,Restaurant-Bar & Café\n2015-05-02,DUNKIN #346983 QNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,17.41,\" Additional Information: 212-375-9999 \n \",DUNKIN' DONUTS,\"72 W 3RD ST\nNEW YORK\nNY\",\"10012-1026\nUNITED STATES OF AMERICA (THE)\",320151230291502060,Restaurant-Bar & Café\n2015-05-02,DUNKIN #346983 QNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,22.9,\" Additional Information: 212-375-9999 \n \",DUNKIN' DONUTS,\"72 W 3RD ST\nNEW YORK\nNY\",\"10012-1026\nUNITED STATES OF AMERICA (THE)\",320151230292167646,Restaurant-Bar & Café\n2015-02-21,CVS/PHARMACY #10174 BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,3.33,\" Additional Information: 8007467287 \n  Description  Price \n  PHARMACIES  $3.33 \n \",CVS/PHARMACY #10174,\"650 ATLANTIC AVE\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150530191956222,Merchandise & Supplies-Pharmacies\n2015-07-01,CVS/PHARMACY #08900 8007467287,Maris Burton,XXXX-XXXXXX-43060,31.7,\" Additional Information: 8007467287 \n  PHARMACIES \n \",CVS PHARMACY,\"20 UNIVERSITY PL\nNEW YORK\nNY\",\"10003-4530\nUNITED STATES OF AMERICA (THE)\",320151830277898080,Merchandise & Supplies-Pharmacies\n2015-06-08,CUSTOMINK TSHIRTS 03FAIRFAX VA,Clare Dudley,XXXX-XXXXXX-41098,920.68,\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \",CUSTOMINK LLC,\"2910 DISTRICT AVE\nFAIRFAX\nVA\",\"22031\nUNITED STATES OF AMERICA (THE)\",320151600896830010,Merchandise & Supplies-Mail Order\n2015-06-15,CUSTOMINK TSHIRTS FAIRFAX VA,Clare Dudley,XXXX-XXXXXX-41098,-25,\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \",CUSTOMINK LLC,\"2910 DISTRICT AVE\nFAIRFAX\nVA\",\"22031\nUNITED STATES OF AMERICA (THE)\",320151670011681934,Merchandise & Supplies-Mail Order\n2015-06-15,CUSTOMINK TSHIRTS 03FAIRFAX VA,Clare Dudley,XXXX-XXXXXX-41098,33.8,\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \",CUSTOMINK LLC,\"2910 DISTRICT AVE\nFAIRFAX\nVA\",\"22031\nUNITED STATES OF AMERICA (THE)\",320151670013120604,Merchandise & Supplies-Mail Order\n2015-07-15,CUBA 88430123896 NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,340,\" Additional Information: 212-420-7878 \n \",CUBA,\"222 THOMPSON ST\nNEW YORK\nNY\",\"10012-1363\nUNITED STATES OF AMERICA (THE)\",320151970498730613,Restaurant-Restaurant\n2015-07-16,CUBA 88430123896 NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,536,\" Additional Information: 212-420-7878 \n \",CUBA,\"222 THOMPSON ST\nNEW YORK\nNY\",\"10012-1363\nUNITED STATES OF AMERICA (THE)\",320151980516094880,Restaurant-Restaurant\n2015-02-20,SUBWAY SOUTH STATN 0BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,10,\" Additional Information: 6172223200 \n  Description  Price \n  COMMUTER TRANS.  $10.00 \n \",COUMMUTER RAIL N STATION,\"10 PARK PLZ\nSTE 1\nBOSTON\nMA\",\"02116-3977\nUNITED STATES OF AMERICA (THE)\",320150530195010108,Transportation-Rail Services\n2015-02-20,SUBWAY SOUTH STATN 0BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,40,\" Additional Information: 6172223200 \n  Description  Price \n  COMMUTER TRANS.  $40.00 \n \",COUMMUTER RAIL N STATION,\"10 PARK PLZ\nSTE 1\nBOSTON\nMA\",\"02116-3977\nUNITED STATES OF AMERICA (THE)\",320150530193534594,Transportation-Rail Services\n2015-02-21,COSI - #205 BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,144.6,\" Additional Information: 6179519999 \n  FOOD/BEVERAGE  $122.60 \n  TIP  $22.00 \n \",COSI #205 SOUTH STATION,\"2 SOUTH STATION STE #182\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150530194145455,Restaurant-Restaurant\n2015-07-08,CHIPOTLE 0590 0094 NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,1357.5,\" Additional Information: 212-982-3081 \n  Description \n  FAST FOOD RESTAURAN \n \",CHIPOTLE,\"55C EAST 8TH ST\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151900389776137,Restaurant-Bar & Café\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850587329,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850900842,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851052217,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851183402,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851243871,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851421525,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850586825,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850586860,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850743209,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850743367,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850901096,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851051765,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851051982,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851052120,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851182737,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851183168,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851421352,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851421393,Travel-Lodging\n2015-02-20,CAJUN & GRILL 650000BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,9.08,\" Additional Information: 6174398687 \n \",CAJUN & GRILL,\"630 ATLANTIC AVE\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150520181047308,Restaurant-Restaurant\n2015-02-20,CAJUN & GRILL 650000BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,9.08,\" Additional Information: 6174398687 \n \",CAJUN & GRILL,\"630 ATLANTIC AVE\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150520181504626,Restaurant-Restaurant\n2015-04-23,CAFE AMORE'S PIZZERINEW YORK NY,Leandra Miles,XXXX-XXXXXX-41114,442.98,\" Additional Information: 212-619-0802 \n  Description \n  FOOD/BEVERAGE \n \",CAFE AMORE'S PIZZERIA,\"147 CHAMBERS ST\nNEW YORK\nNY\",\"10007\nUNITED STATES OF AMERICA (THE)\",320151140150597007,Restaurant-Restaurant\n2015-05-30,BURGER KING #8697 00BLOOMSBURG PA,Leandra Miles,XXXX-XXXXXX-41114,196.21,\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \",BURGER KING,\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\",\"17815-8357\nUNITED STATES OF AMERICA (THE)\",320151510750551934,Restaurant-Bar & Café\n2015-05-30,BURGER KING #8697 00BLOOMSBURG PA,Leandra Miles,XXXX-XXXXXX-41114,216.29,\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \",BURGER KING,\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\",\"17815-8357\nUNITED STATES OF AMERICA (THE)\",320151510748702755,Restaurant-Bar & Café\n2015-05-30,BURGER KING #8697 00BLOOMSBURG PA,Leandra Miles,XXXX-XXXXXX-41114,480.72,\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \",BURGER KING,\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\",\"17815-8357\nUNITED STATES OF AMERICA (THE)\",320151510749938819,Restaurant-Bar & Café\n2015-07-18,RICKERS #71 8831 INDIANAPOLIS IN,Howard Washington,XXXX-XXXXXX-43003,20,\" Additional Information: 317-920-0850 \n  Description \n  Unleaded Regular \n \",BP FDMS INSIDE,\"28100 TORCH PKWY\nWARRENVILLE\nIL\",\"60555-3938\nUNITED STATES OF AMERICA (THE)\",320151990528129463,Transportation-Fuel\n2015-07-18,RICKERS #71 8831 INDIANAPOLIS IN,Howard Washington,XXXX-XXXXXX-43003,-20,,BP FDMS INSIDE,\"28100 TORCH PKWY\nWARRENVILLE\nIL\",\"60555-3938\nUNITED STATES OF AMERICA (THE)\",320152020572738126,Transportation-Fuel\n2015-02-17,ARTCO`S COPY HUT 845-339-2336,Callum Wilson,XXXX-XXXXXX-42021,267.5,\" Additional Information: 845-339-2336 \n \",ARTCOS COPY HUT,\"508 ALBANY AVE\nKINGSTON\nNY\",\"12401-2131\nUNITED STATES OF AMERICA (THE)\",320150490121864816,Business Services-Printing & Publishing\n2015-03-06,ARTCO`S COPY HUT 845-339-2336,Callum Wilson,XXXX-XXXXXX-42021,58.8,\" Additional Information: 845-339-2336 \n \",ARTCOS COPY HUT,\"508 ALBANY AVE\nKINGSTON\nNY\",\"12401-2131\nUNITED STATES OF AMERICA (THE)\",320150680424371140,Business Services-Printing & Publishing\n2015-01-20,AMTRAK TELEPHONE SALWASHINGTON DC,Nyssa O'Neil,XXXX-XXXXXX-41122,4011,\" Additional Information: Ticket Number: 0201083059570 \n  1 (800) 872-7245 \n \",AMTRAK TELEPHONE SALE,\"60 MASSACHUSETTS AVE NE\nWASHINGTON\nDC\",\"20002\nUNITED STATES OF AMERICA (THE)\",320150210698966825,Transportation-Rail Services\n2015-01-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-1745.53,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320150120569599421,\n2015-01-31,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-19.02,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320150310857958321,Fees & Adjustments-Fees & Adjustments\n2015-02-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-4462.48,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320150430042521523,\n2015-03-01,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-44.7,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320150600302988147,Fees & Adjustments-Fees & Adjustments\n2015-03-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-3206.31,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320150710473227346,\n2015-03-30,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-32.28,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320150890754246198,Fees & Adjustments-Fees & Adjustments\n2015-04-11,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-890.98,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320151010946509246,\n2015-05-01,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-12.26,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320151210265694636,Fees & Adjustments-Fees & Adjustments\n2015-05-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-1737.7,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320151320445840058,\n2015-05-29,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-17.7,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320151490722848778,Fees & Adjustments-Fees & Adjustments\n2015-06-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-2192.38,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320151630951557213,\n2015-06-28,Credit Adjustment for Billing Inquiry,Howard Washington,XXXX-XXXXXX-43003,-50,,,,,320151793201385681,Fees & Adjustments-Fees & Adjustments\n2015-06-29,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-24.47,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320151800229937629,Fees & Adjustments-Fees & Adjustments\n2015-07-06,ONLINE PAYMENT - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-7220.06,,,,,320151870342051089,\n2015-07-30,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-72.88,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320152110735569063,Fees & Adjustments-Fees & Adjustments\n2015-08-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-15481.02,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320152240951742758,\n2015-08-31,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-169.34,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320152430260111596,Fees & Adjustments-Fees & Adjustments\n2015-09-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-323.57,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320152550459055529,\n2015-09-30,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-4.77,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320152730747361096,Fees & Adjustments-Fees & Adjustments\n2015-10-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-304.24,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320152850946530301,\n2015-10-30,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-4.74,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320153030245166855,Fees & Adjustments-Fees & Adjustments\n2015-11-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-1981.87,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320153160462707016,\n2015-12-03,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-19.87,,,,,320153390865190560,Fees & Adjustments-Fees & Adjustments\n"
  },
  {
    "path": "test/fixtures/export-csv/CCTransactions.csv",
    "content": "Date,Description,Card Member,Account,Amount,Extended Details,Doing Business As,Street Address,City State Zip,Reference,Category\n2015-01-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-1745.53,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320150120569599421,\n2015-01-17,MYPIZZA.COM*MYPIZZA STATEN ISLA NY,Howard Washington,XXXX-XXXXXX-43003,382.06,\" Additional Information: 888-974-9928 \n  Description \n  MYPIZZA COM \n \",MYPIZZA.COM - E COMMERCE,\"97 NEW DORP PLZ N\nSTATEN ISLAND\nNY\",\"10306-2903\nUNITED STATES OF AMERICA (THE)\",320150170634561830,Restaurant-Bar & Café\n2015-01-20,AMTRAK TELEPHONE SALWASHINGTON DC,Nyssa O'Neil,XXXX-XXXXXX-41122,4011,\" Additional Information: Ticket Number: 0201083059570 \n  1 (800) 872-7245 \n \",AMTRAK TELEPHONE SALE,\"60 MASSACHUSETTS AVE NE\nWASHINGTON\nDC\",\"20002\nUNITED STATES OF AMERICA (THE)\",320150210698966825,Transportation-Rail Services\n2015-01-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320150210695238055,Merchandise & Supplies-Internet Purchase\n2015-01-31,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-19.02,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320150310857958321,Fees & Adjustments-Fees & Adjustments\n2015-02-03,MAILCHIMP MAILCHIMP.COM GA,Callum Wilson,XXXX-XXXXXX-42021,212.5,\" Additional Information: EMAIL MKTG \n \",MAILCHIMP,\"512 MEANS ST NW\nSTE 404\nATLANTA\nGA\",\"30318-5788\nUNITED STATES OF AMERICA (THE)\",320150350916563707,Other-Miscellaneous\n2015-02-06,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,402.04,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320150400993249691,Restaurant-Restaurant\n2015-02-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-4462.48,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320150430042521523,\n2015-02-13,USPS 354395021106656KINGSTON NY,Callum Wilson,XXXX-XXXXXX-42021,147,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE,\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\",\"10610-1000\nUNITED STATES OF AMERICA (THE)\",320150450074080962,Business Services-Mailing & Shipping\n2015-02-17,ARTCO`S COPY HUT 845-339-2336,Callum Wilson,XXXX-XXXXXX-42021,267.5,\" Additional Information: 845-339-2336 \n \",ARTCOS COPY HUT,\"508 ALBANY AVE\nKINGSTON\nNY\",\"12401-2131\nUNITED STATES OF AMERICA (THE)\",320150490121864816,Business Services-Printing & Publishing\n2015-02-20,CAJUN & GRILL 650000BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,9.08,\" Additional Information: 6174398687 \n \",CAJUN & GRILL,\"630 ATLANTIC AVE\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150520181047308,Restaurant-Restaurant\n2015-02-20,CAJUN & GRILL 650000BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,9.08,\" Additional Information: 6174398687 \n \",CAJUN & GRILL,\"630 ATLANTIC AVE\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150520181504626,Restaurant-Restaurant\n2015-02-20,MCDONALD'S F11729 00BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,7.27,\" Additional Information: 6173549027 \n \",MC DONALD'S,\"2 SOUTH STA\nFL 5\nBOSTON\nMA\",\"02110-2288\nUNITED STATES OF AMERICA (THE)\",320150520174787823,Restaurant-Bar & Café\n2015-02-20,PINKBERRY 165 BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,10.5,\" Additional Information: FAST FOOD RESTAURANT \n  FOOD/BEVERAGE  $10.50 \n \",PINKBERRY,\"700 ATLANTIC AVE, STE105\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150520176459450,Restaurant-Bar & Café\n2015-02-20,PIZZERIA REGINA SO 5BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,15.61,\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $15.61 \n \",PIZZERIA REGINA AT SOUTH,\"2 SOUTH STA\nBOSTON\nMA\",\"02110-2208\nUNITED STATES OF AMERICA (THE)\",320150520176216796,Restaurant-Bar & Café\n2015-02-20,PIZZERIA REGINA SO 5BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,44.8,\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $44.80 \n \",PIZZERIA REGINA AT SOUTH,\"2 SOUTH STA\nBOSTON\nMA\",\"02110-2208\nUNITED STATES OF AMERICA (THE)\",320150520176217729,Restaurant-Bar & Café\n2015-02-20,SUBWAY SOUTH STATN 0BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,10,\" Additional Information: 6172223200 \n  Description  Price \n  COMMUTER TRANS.  $10.00 \n \",COUMMUTER RAIL N STATION,\"10 PARK PLZ\nSTE 1\nBOSTON\nMA\",\"02116-3977\nUNITED STATES OF AMERICA (THE)\",320150530195010108,Transportation-Rail Services\n2015-02-20,SUBWAY SOUTH STATN 0BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,40,\" Additional Information: 6172223200 \n  Description  Price \n  COMMUTER TRANS.  $40.00 \n \",COUMMUTER RAIL N STATION,\"10 PARK PLZ\nSTE 1\nBOSTON\nMA\",\"02116-3977\nUNITED STATES OF AMERICA (THE)\",320150530193534594,Transportation-Rail Services\n2015-02-21,COSI - #205 BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,144.6,\" Additional Information: 6179519999 \n  FOOD/BEVERAGE  $122.60 \n  TIP  $22.00 \n \",COSI #205 SOUTH STATION,\"2 SOUTH STATION STE #182\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150530194145455,Restaurant-Restaurant\n2015-02-21,CVS/PHARMACY #10174 BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,3.33,\" Additional Information: 8007467287 \n  Description  Price \n  PHARMACIES  $3.33 \n \",CVS/PHARMACY #10174,\"650 ATLANTIC AVE\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150530191956222,Merchandise & Supplies-Pharmacies\n2015-02-21,DUNKIN #300223 QCAMBRIDGE MA,Nyssa O'Neil,XXXX-XXXXXX-41122,187.68,\" Additional Information: 617-354-8944 \n \",DUNKIN' DONUTS,\"616 MASSACHUSETTS AVE\nCAMBRIDGE\nMA\",\"02139-3307\nUNITED STATES OF AMERICA (THE)\",320150540208503704,Restaurant-Bar & Café\n2015-02-21,PIZZERIA REGINA SO 5BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,115.91,\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $115.91 \n \",PIZZERIA REGINA AT SOUTH,\"2 SOUTH STA\nBOSTON\nMA\",\"02110-2208\nUNITED STATES OF AMERICA (THE)\",320150530188890159,Restaurant-Bar & Café\n2015-02-22,LEMERIDIEN CAMBRIDGECAMBRIDGE MA,Nyssa O'Neil,XXXX-XXXXXX-41122,1516.77,\" Arrival Date  Departure Date \n  02/20/15  02/21/15 \n  00000000 \n  LODGING \n \",LE MERIDIEN HOTEL,\"20 SIDNEY ST\nCAMBRIDGE\nMA\",\"02139-4122\nUNITED STATES OF AMERICA (THE)\",320150540207459315,Travel-Lodging\n2015-02-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,81.66,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320150540197243171,Merchandise & Supplies-Internet Purchase\n2015-02-27,PIZZA MERCATO NEW YORK NY,Nyssa O'Neil,XXXX-XXXXXX-41122,382.04,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320150590274046170,Restaurant-Restaurant\n2015-03-01,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-44.7,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320150600302988147,Fees & Adjustments-Fees & Adjustments\n2015-03-05,STAPLES 00242 (800)333-3330,Callum Wilson,XXXX-XXXXXX-42021,72.1,\" Additional Information: 00242000106701 12401 \n  SPLS 8.5X11 MULTI 20/96 RM \n  AVY LSR LBL 3000PK 1X2 5/8 \n  CLASP ENV BRN KRAFT 9X12 -100 \n \",STAPLES,\"1399 ULSTER AVE\nKINGSTON\nNY\",\"12401\nUNITED STATES OF AMERICA (THE)\",320150650374451756,Business Services-Office Supplies\n2015-03-06,ARTCO`S COPY HUT 845-339-2336,Callum Wilson,XXXX-XXXXXX-42021,58.8,\" Additional Information: 845-339-2336 \n \",ARTCOS COPY HUT,\"508 ALBANY AVE\nKINGSTON\nNY\",\"12401-2131\nUNITED STATES OF AMERICA (THE)\",320150680424371140,Business Services-Printing & Publishing\n2015-03-11,USPS 359605000800484NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,9.8,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE-NEW YORK CITY,\"421 8TH AVE\nRM 3007\nNEW YORK\nNY\",\"10199-1003\nUNITED STATES OF AMERICA (THE)\",320150710470965085,Business Services-Mailing & Shipping\n2015-03-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-3206.31,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320150710473227346,\n2015-03-19,HP HOME STORE 888-345-5409 CA,Vera O'Connor,XXXX-XXXXXX-41106,46.51,\" Additional Information: COMPUTER \n \",HPSHOPPING.COM,\"SVP01 4TH FLOOR MS 3541\n950 MAUDE AVENUE\nSUNNYVALE\nCA\",\"94085\nUNITED STATES OF AMERICA (THE)\",320150790598271773,Merchandise & Supplies-Computer Supplies\n2015-03-20,FAMOUS FAMIGLIA PI 5NEW YORK NY,Nyssa O'Neil,XXXX-XXXXXX-41122,287.1,\" Additional Information: 2129969797 \n  FOOD/BEVERAGE  $287.10 \n \",FAMOUS FAMIGLIA PIZZERIA,\"1398 MADISON AVE\nNEW YORK\nNY\",\"10029-6903\nUNITED STATES OF AMERICA (THE)\",320150800614157648,Restaurant-Restaurant\n2015-03-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,81.66,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320150820630530881,Merchandise & Supplies-Internet Purchase\n2015-03-27,5% OPEN Savings at HP,Vera O'Connor,XXXX-XXXXXX-41106,-2.33,\" Additional Information: SEE SUMMARY GRID FOR MORE INFORMATION \n \",HPSHOPPING.COM,\"SVP01 4TH FLOOR MS 3541\n950 MAUDE AVENUE\nSUNNYVALE\nCA\",\"94085\nUNITED STATES OF AMERICA (THE)\",320150860708183861,Merchandise & Supplies-Computer Supplies\n2015-03-30,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-32.28,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320150890754246198,Fees & Adjustments-Fees & Adjustments\n2015-04-11,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-890.98,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320151010946509246,\n2015-04-17,INKWELL GLOBAL MARKEMANALAPAN NJ,Howard Washington,XXXX-XXXXXX-43003,1241,\" Additional Information: 7325362822 \n \",INKWELL GLOBAL MARKETING,\"600 MADISON AVE\nMANALAPAN\nNJ\",\"07726-9594\nUNITED STATES OF AMERICA (THE)\",320151080049401834,Merchandise & Supplies-Mail Order\n2015-04-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,83.83,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320151110091111545,Merchandise & Supplies-Internet Purchase\n2015-04-23,CAFE AMORE'S PIZZERINEW YORK NY,Leandra Miles,XXXX-XXXXXX-41114,442.98,\" Additional Information: 212-619-0802 \n  Description \n  FOOD/BEVERAGE \n \",CAFE AMORE'S PIZZERIA,\"147 CHAMBERS ST\nNEW YORK\nNY\",\"10007\nUNITED STATES OF AMERICA (THE)\",320151140150597007,Restaurant-Restaurant\n2015-04-25,DUANE READE #14247 0NEW YORK NY,Clare Dudley,XXXX-XXXXXX-41098,2.17,\" Additional Information: 8002892273 \n  Description \n  REFER TO RECEIPT \n \",WALGREEN,\"4 W 4TH ST\nNEW YORK\nNY\",\"10012-1168\nUNITED STATES OF AMERICA (THE)\",320151160181404910,Merchandise & Supplies-Pharmacies\n2015-05-01,WICHCRAFT BRYANT PARNEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,453.6,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151210250625154,Restaurant-Bar & Café\n2015-05-01,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-12.26,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320151210265694636,Fees & Adjustments-Fees & Adjustments\n2015-05-02,DUNKIN #346983 QNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,17.41,\" Additional Information: 212-375-9999 \n \",DUNKIN' DONUTS,\"72 W 3RD ST\nNEW YORK\nNY\",\"10012-1026\nUNITED STATES OF AMERICA (THE)\",320151230291502060,Restaurant-Bar & Café\n2015-05-02,DUNKIN #346983 QNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,22.9,\" Additional Information: 212-375-9999 \n \",DUNKIN' DONUTS,\"72 W 3RD ST\nNEW YORK\nNY\",\"10012-1026\nUNITED STATES OF AMERICA (THE)\",320151230292167646,Restaurant-Bar & Café\n2015-05-02,STAPLES 01106 (800)333-3330,Callum Wilson,XXXX-XXXXXX-42021,2.16,\" Additional Information: 01106000109206 10003 \n  CRA-Z-ART WHITE CHALK 16 CT \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151230292173007,Business Services-Office Supplies\n2015-05-02,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,33.91,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151230294133437,Merchandise & Supplies-Groceries\n2015-05-08,WICHCRAFT BRYANT PARNEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,384.93,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151280366691479,Restaurant-Bar & Café\n2015-05-09,STAPLES 00193 (800)333-3330,Vera O'Connor,XXXX-XXXXXX-41106,10.43,\" Additional Information: 00193000224345 10007 \n  9X12 CLEAR CLASP ENV 10PK \n \",STAPLES,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320151300408500603,Business Services-Office Supplies\n2015-05-09,STAPLES 00193 (800)333-3330,Vera O'Connor,XXXX-XXXXXX-41106,10.58,\" Additional Information: 00193002523005 10007 \n  BW SS P@SS LTR/LGL \n \",STAPLES,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320151300407029361,Business Services-Office Supplies\n2015-05-09,STAPLES 00193 (800)333-3330,Vera O'Connor,XXXX-XXXXXX-41106,37.1,\" Additional Information: 00193002523001 10007 \n  BW SS P@SS LTR/LGL \n \",STAPLES,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320151300408517031,Business Services-Office Supplies\n2015-05-09,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,38.2,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151300405656534,Merchandise & Supplies-Groceries\n2015-05-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-1737.7,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320151320445840058,\n2015-05-16,NORTH SQUARE RESTAURNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,114,\" Additional Information: 2122541200 \n \",NORTH SQUARE RESTAURANT & LOUNGE,\"103 WAVERLY PL\nNEW YORK\nNY\",\"10011-9110\nUNITED STATES OF AMERICA (THE)\",320151370527011129,Restaurant-Restaurant\n2015-05-16,NORTH SQUARE RESTAURNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,612,\" Additional Information: 2122541200 \n \",NORTH SQUARE RESTAURANT & LOUNGE,\"103 WAVERLY PL\nNEW YORK\nNY\",\"10011-9110\nUNITED STATES OF AMERICA (THE)\",320151370525443159,Restaurant-Restaurant\n2015-05-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,83.83,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320151410579383246,Merchandise & Supplies-Internet Purchase\n2015-05-21,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,383.59,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151430627016798,Restaurant-Restaurant\n2015-05-29,GRISTEDES # 508 5429NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,82.05,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $82.05 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151500729902113,Merchandise & Supplies-Groceries\n2015-05-29,PANERA BREAD #601723NEW YORK NY,Nyssa O'Neil,XXXX-XXXXXX-41122,1142.37,\" Additional Information: 9999999999 \n \",PANERA BREAD CAFE INSTORE,\"330 FIFTH AVE\n-\nNEW YORK\nNY\",\"10001\nUNITED STATES OF AMERICA (THE)\",320151500733586616,Restaurant-Bar & Café\n2015-05-29,PENN STATER CONF CTRSTATE COLLEGE PA,Nyssa O'Neil,XXXX-XXXXXX-41122,3598.48,\" Additional Information: 814-865-8500 \n \",PENN STATER CONF CNTR HTL,\"215 INNOVATION BLVD\nSTATE COLLEGE\nPA\",\"16803-6603\nUNITED STATES OF AMERICA (THE)\",320151500729234751,Travel-Lodging\n2015-05-29,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-17.7,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320151490722848778,Fees & Adjustments-Fees & Adjustments\n2015-05-30,BURGER KING #8697 00BLOOMSBURG PA,Leandra Miles,XXXX-XXXXXX-41114,196.21,\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \",BURGER KING,\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\",\"17815-8357\nUNITED STATES OF AMERICA (THE)\",320151510750551934,Restaurant-Bar & Café\n2015-05-30,BURGER KING #8697 00BLOOMSBURG PA,Leandra Miles,XXXX-XXXXXX-41114,216.29,\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \",BURGER KING,\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\",\"17815-8357\nUNITED STATES OF AMERICA (THE)\",320151510748702755,Restaurant-Bar & Café\n2015-05-30,BURGER KING #8697 00BLOOMSBURG PA,Leandra Miles,XXXX-XXXXXX-41114,480.72,\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \",BURGER KING,\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\",\"17815-8357\nUNITED STATES OF AMERICA (THE)\",320151510749938819,Restaurant-Bar & Café\n2015-05-31,HILTON GARDEN INN 12STATE COLLEGE PA,Nyssa O'Neil,XXXX-XXXXXX-41122,111.76,\" Arrival Date  Departure Date \n  05/29/15  05/30/15 \n  00000000 \n  LODGING \n \",HILTON GARDEN INN,\"1221 EAST COLLEGE AVENUE\nSTATE COLLEGE\nPA\",\"16801\nUNITED STATES OF AMERICA (THE)\",320151510740498966,Travel-Lodging\n2015-05-31,HILTON GARDEN INN 12STATE COLLEGE PA,Nyssa O'Neil,XXXX-XXXXXX-41122,111.76,\" Arrival Date  Departure Date \n  05/29/15  05/30/15 \n  00000000 \n  LODGING \n \",HILTON GARDEN INN,\"1221 EAST COLLEGE AVENUE\nSTATE COLLEGE\nPA\",\"16801\nUNITED STATES OF AMERICA (THE)\",320151510740707763,Travel-Lodging\n2015-06-08,CUSTOMINK TSHIRTS 03FAIRFAX VA,Clare Dudley,XXXX-XXXXXX-41098,920.68,\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \",CUSTOMINK LLC,\"2910 DISTRICT AVE\nFAIRFAX\nVA\",\"22031\nUNITED STATES OF AMERICA (THE)\",320151600896830010,Merchandise & Supplies-Mail Order\n2015-06-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-2192.38,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320151630951557213,\n2015-06-15,CUSTOMINK TSHIRTS FAIRFAX VA,Clare Dudley,XXXX-XXXXXX-41098,-25,\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \",CUSTOMINK LLC,\"2910 DISTRICT AVE\nFAIRFAX\nVA\",\"22031\nUNITED STATES OF AMERICA (THE)\",320151670011681934,Merchandise & Supplies-Mail Order\n2015-06-15,CUSTOMINK TSHIRTS 03FAIRFAX VA,Clare Dudley,XXXX-XXXXXX-41098,33.8,\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \",CUSTOMINK LLC,\"2910 DISTRICT AVE\nFAIRFAX\nVA\",\"22031\nUNITED STATES OF AMERICA (THE)\",320151670013120604,Merchandise & Supplies-Mail Order\n2015-06-19,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,328.86,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151730114686576,Restaurant-Restaurant\n2015-06-22,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,83.83,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320151730103193144,Merchandise & Supplies-Internet Purchase\n2015-06-24,USPS 354395021106656KINGSTON NY,Callum Wilson,XXXX-XXXXXX-42021,5.95,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE,\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\",\"10610-1000\nUNITED STATES OF AMERICA (THE)\",320151760163504192,Business Services-Mailing & Shipping\n2015-06-28,Credit Adjustment for Billing Inquiry,Howard Washington,XXXX-XXXXXX-43003,-50,,,,,320151793201385681,Fees & Adjustments-Fees & Adjustments\n2015-06-28,TWO BOOTS PIZZA NEW YORK NY,Clare Dudley,XXXX-XXXXXX-41098,102.25,\" Additional Information: 212-777-2668 \n  Description \n  FOOD/BEVERAGE \n \",TWO BOOTS TO GO WEST,\"201 W 11TH ST\nNEW YORK\nNY\",\"10014\nUNITED STATES OF AMERICA (THE)\",320151800225150895,Restaurant-Restaurant\n2015-06-28,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,3.25,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151800223528639,Merchandise & Supplies-Groceries\n2015-06-28,WHOLEFDS TRB 10245 0NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,29.82,\" Additional Information: 2123496555 \n  Description  Price \n  GROCERY STORES  $29.82 \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151800223964992,Merchandise & Supplies-Groceries\n2015-06-29,GRISTEDES # 508 5429NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,78.03,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $78.03 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151810242916035,Merchandise & Supplies-Groceries\n2015-06-29,MAOZ VEGETARIAN - 8TNEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,12.52,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151810238061322,Restaurant-Bar & Café\n2015-06-29,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,502.25,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151810247097284,Restaurant-Restaurant\n2015-06-29,STAPLES 01232 (800)333-3330,Clare Dudley,XXXX-XXXXXX-41098,6.85,\" Additional Information: 01232000706107 11230 \n  NAME BDG BLUE BORDER LBL \n \",STAPLES,\"1880 CONEY ISLAND AVE\nBROOKLYN\nNY\",\"11230\nUNITED STATES OF AMERICA (THE)\",320151810242451340,Business Services-Office Supplies\n2015-06-29,WICHCRAFT BRYANT PARNEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,1143.76,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151800216664401,Restaurant-Bar & Café\n2015-06-29,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-24.47,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320151800229937629,Fees & Adjustments-Fees & Adjustments\n2015-06-30,GRISTEDES # 508 5429NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,118.97,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $118.97 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151820256966499,Merchandise & Supplies-Groceries\n2015-06-30,MAOZ VEGETARIAN - 8TNEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,14.7,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151820255845813,Restaurant-Bar & Café\n2015-06-30,SUBWAY 999912MIAMI FL,Howard Washington,XXXX-XXXXXX-43003,813.84,\" Additional Information: 305-6700041 \n \",SUBWAY,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320151820254568483,Restaurant-Bar & Café\n2015-07-01,CVS/PHARMACY #08900 8007467287,Maris Burton,XXXX-XXXXXX-43060,31.7,\" Additional Information: 8007467287 \n  PHARMACIES \n \",CVS PHARMACY,\"20 UNIVERSITY PL\nNEW YORK\nNY\",\"10003-4530\nUNITED STATES OF AMERICA (THE)\",320151830277898080,Merchandise & Supplies-Pharmacies\n2015-07-01,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,69.09,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $69.09 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151830273619758,Merchandise & Supplies-Groceries\n2015-07-01,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,14.7,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151830273492010,Restaurant-Bar & Café\n2015-07-01,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,649.25,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151840296360778,Restaurant-Restaurant\n2015-07-01,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,11.97,\" Additional Information: 01106000121928 10003 \n  POSTERBOARD 22X28 FLUR ASST 5 \n  SCOTCH INVISIBLE TAPE 3/4X300 \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151830278394728,Business Services-Office Supplies\n2015-07-02,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,4.56,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $4.56 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151840293289242,Merchandise & Supplies-Groceries\n2015-07-02,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,63.82,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $63.82 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151840293808568,Merchandise & Supplies-Groceries\n2015-07-02,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151840291994605,Restaurant-Bar & Café\n2015-07-02,WICHCRAFT BRYANT PARNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,1215.24,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151830283404245,Restaurant-Bar & Café\n2015-07-06,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.82,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $7.82 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151880352939553,Merchandise & Supplies-Groceries\n2015-07-06,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,104.06,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $104.06 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151880351787873,Merchandise & Supplies-Groceries\n2015-07-06,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151880350844359,Restaurant-Bar & Café\n2015-07-06,ONLINE PAYMENT - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-7220.06,,,,,320151870342051089,\n2015-07-06,PIZZA MERCATO NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,664.69,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151880360068571,Restaurant-Restaurant\n2015-07-07,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,66.06,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $66.06 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151890369426433,Merchandise & Supplies-Groceries\n2015-07-07,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,14.7,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151890367176935,Restaurant-Bar & Café\n2015-07-07,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,4.34,\" Additional Information: 01798000228531 10011 \n  3TAB FILE FLDR LBL \n \",STAPLES,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151890371868920,Business Services-Office Supplies\n2015-07-07,SUBWAY 999912MIAMI FL,Maris Burton,XXXX-XXXXXX-43060,813.84,\" Additional Information: 305-6700041 \n \",SUBWAY,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320151890365608242,Restaurant-Bar & Café\n2015-07-08,CHIPOTLE 0590 0094 NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,1357.5,\" Additional Information: 212-982-3081 \n  Description \n  FAST FOOD RESTAURAN \n \",CHIPOTLE,\"55C EAST 8TH ST\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151900389776137,Restaurant-Bar & Café\n2015-07-08,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,8.69,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $8.69 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151900384374156,Merchandise & Supplies-Groceries\n2015-07-08,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,72.26,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $72.26 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151900384651040,Merchandise & Supplies-Groceries\n2015-07-08,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,6.61,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151900383693086,Restaurant-Bar & Café\n2015-07-09,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,61.25,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $61.25 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151910403911324,Merchandise & Supplies-Groceries\n2015-07-09,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151910401557217,Restaurant-Bar & Café\n2015-07-09,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,148.97,\" Additional Information: 01106000511857 10003 \n  PASTELS 8.5X11 GREEN PAPER RM \n  PASTELS 8.5X11 SALMON PAPER RM \n  PASTELS 8.5X11 BLUE PAPER RM \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151910406467408,Business Services-Office Supplies\n2015-07-10,DUANE READE #14247 0NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,4.99,\" Additional Information: 8002892273 \n  Description \n  REFER TO RECEIPT \n \",WALGREEN,\"4 W 4TH ST\nNEW YORK\nNY\",\"10012-1168\nUNITED STATES OF AMERICA (THE)\",320151920416442525,Merchandise & Supplies-Pharmacies\n2015-07-10,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,106.79,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $106.79 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151920418500382,Merchandise & Supplies-Groceries\n2015-07-10,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,836,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151920425504018,Restaurant-Bar & Café\n2015-07-10,WICHCRAFT BRYANT PARNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,1320,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151910396270471,Restaurant-Bar & Café\n2015-07-11,USPS 354395021106656KINGSTON NY,Callum Wilson,XXXX-XXXXXX-42021,17.34,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE,\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\",\"10610-1000\nUNITED STATES OF AMERICA (THE)\",320151930433305676,Business Services-Mailing & Shipping\n2015-07-13,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,113.97,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $113.97 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151950464750950,Merchandise & Supplies-Groceries\n2015-07-13,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,14.7,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151950464236262,Restaurant-Bar & Café\n2015-07-13,PIZZA MERCATO NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,618.34,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320151950473655224,Restaurant-Restaurant\n2015-07-14,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,72.49,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $72.49 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151960484410879,Merchandise & Supplies-Groceries\n2015-07-14,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151960482805860,Restaurant-Bar & Café\n2015-07-14,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,-4.56,\" Additional Information: 01106000712254 10003 \n  3TAB FILE FLDR LBL \n  POSTERBOARD 22X28 FLUR ASST 5 \n  GRTNR CERT 8.5X11 BLUE/SLV 100 \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151960486379349,Business Services-Office Supplies\n2015-07-14,SUBWAY 999912MIAMI FL,Maris Burton,XXXX-XXXXXX-43060,747.5,\" Additional Information: 305-6700041 \n \",SUBWAY,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320151960480941768,Restaurant-Bar & Café\n2015-07-15,CUBA 88430123896 NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,340,\" Additional Information: 212-420-7878 \n \",CUBA,\"222 THOMPSON ST\nNEW YORK\nNY\",\"10012-1363\nUNITED STATES OF AMERICA (THE)\",320151970498730613,Restaurant-Restaurant\n2015-07-15,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,13.03,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $13.03 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151970502025423,Merchandise & Supplies-Groceries\n2015-07-15,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,69.36,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $69.36 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151970500708696,Merchandise & Supplies-Groceries\n2015-07-15,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,753.22,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151970499604816,Restaurant-Bar & Café\n2015-07-15,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,9.03,\" Additional Information: 01106002530465 10003 \n  COMPUTER RENTAL \n  CW BW PRNT \n  BW SS P@SS LTR/LGL \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151970501140377,Business Services-Office Supplies\n2015-07-16,CUBA 88430123896 NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,536,\" Additional Information: 212-420-7878 \n \",CUBA,\"222 THOMPSON ST\nNEW YORK\nNY\",\"10012-1363\nUNITED STATES OF AMERICA (THE)\",320151980516094880,Restaurant-Restaurant\n2015-07-16,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,54.81,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $54.81 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151980517736119,Merchandise & Supplies-Groceries\n2015-07-16,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,12.52,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151980516521333,Restaurant-Bar & Café\n2015-07-16,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,5.99,\" Additional Information: 01798002507818 10011 \n  COMPUTER RENTAL \n  CW BW PRNT \n \",STAPLES,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151980518721472,Business Services-Office Supplies\n2015-07-16,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,24.91,\" Additional Information: 01798002507807 10011 \n  BW SS P@SS LTR/LGL \n  CLR SS P@SS LTR/LGL \n \",STAPLES,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151980516856896,Business Services-Office Supplies\n2015-07-16,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,93.9,\" Additional Information: 01798000535198 10011 \n  STPLS MEMO CUBE 500CT \n  251-500 BW2 LTR STD \n  STAPLING \n \",STAPLES,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151980519021999,Business Services-Office Supplies\n2015-07-16,VISTAPR*VISTAPRINT.C866 893 6743 CA,Vera O'Connor,XXXX-XXXXXX-41106,5.44,\" Additional Information: 866-614-8002 \n \",WWW.VISTAPRINT.COM,\"95 HAYDEN AVE\nLEXINGTON\nMA\",\"02421-7942\nUNITED STATES OF AMERICA (THE)\",320151980521681765,Merchandise & Supplies-Internet Purchase\n2015-07-16,WICHCRAFT BRYANT PARNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,990,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151970493780060,Restaurant-Bar & Café\n2015-07-17,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,74.03,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $74.03 \n \",GRISTEDES,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151990534884422,Merchandise & Supplies-Groceries\n2015-07-17,PIZZA MERCATO NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,204.54,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152010569229393,Restaurant-Restaurant\n2015-07-17,SACRED CHOW 212-337-0863,Maris Burton,XXXX-XXXXXX-43060,16.15,\" Additional Information: 212-337-0863 \n \",SACRED CHOW,\"227 SULLIVAN ST\nFRNT 1\nNEW YORK\nNY\",\"10012-4803\nUNITED STATES OF AMERICA (THE)\",320151990541493366,Restaurant-Restaurant\n2015-07-17,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,54.98,\" Additional Information: 01106000512518 10003 \n  STAPLING \n  1-100 BW 32LB ULTRA PREM \n  1-100 BW2 32LB ULTRA PREM \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151990538533185,Business Services-Office Supplies\n2015-07-17,VISTAPR*VISTAPRINT.C866 893 6743 CA,Vera O'Connor,XXXX-XXXXXX-41106,66.62,\" Additional Information: 866-614-8002 \n \",WWW.VISTAPRINT.COM,\"95 HAYDEN AVE\nLEXINGTON\nMA\",\"02421-7942\nUNITED STATES OF AMERICA (THE)\",320151990540257622,Merchandise & Supplies-Internet Purchase\n2015-07-18,RICKERS #71 8831 INDIANAPOLIS IN,Howard Washington,XXXX-XXXXXX-43003,20,\" Additional Information: 317-920-0850 \n  Description \n  Unleaded Regular \n \",BP FDMS INSIDE,\"28100 TORCH PKWY\nWARRENVILLE\nIL\",\"60555-3938\nUNITED STATES OF AMERICA (THE)\",320151990528129463,Transportation-Fuel\n2015-07-18,RICKERS #71 8831 INDIANAPOLIS IN,Howard Washington,XXXX-XXXXXX-43003,-20,,BP FDMS INSIDE,\"28100 TORCH PKWY\nWARRENVILLE\nIL\",\"60555-3938\nUNITED STATES OF AMERICA (THE)\",320152020572738126,Transportation-Fuel\n2015-07-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,92.54,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152030590555153,Merchandise & Supplies-Internet Purchase\n2015-07-21,VISTAPR*VISTAPRINT.C866 893 6743 CA,Vera O'Connor,XXXX-XXXXXX-41106,-5.9,\" Additional Information: 866-614-8002 \n \",WWW.VISTAPRINT.COM,\"95 HAYDEN AVE\nLEXINGTON\nMA\",\"02421-7942\nUNITED STATES OF AMERICA (THE)\",320152030601233931,Merchandise & Supplies-Internet Purchase\n2015-07-29,THE FARM ON ADDERLY BROOKLYN NY,Darren Graham,XXXX-XXXXXX-41130,45,\" Additional Information: RESTAURANT \n \",FARM ON ADDERLY,\"1108 CORTELYOU RD\nBROOKLYN\nNY\",\"11218-5304\nUNITED STATES OF AMERICA (THE)\",320152120748477327,Restaurant-Restaurant\n2015-07-30,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-72.88,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320152110735569063,Fees & Adjustments-Fees & Adjustments\n2015-08-03,USPS 333675955102007HOBOKEN NJ,Maris Burton,XXXX-XXXXXX-43060,5.75,\" Additional Information: 800-2758777 \n \",USPS/HOBOKEN,\"89 RIVER ST\nHOBOKEN\nNJ\",\"07030-9998\nUNITED STATES OF AMERICA (THE)\",320152160817163880,Business Services-Mailing & Shipping\n2015-08-08,STAPLES 01106 (800)333-3330,Clare Dudley,XXXX-XXXXXX-41098,20.25,\" Additional Information: 01106002531781 10003 \n  BW SS P@SS LTR/LGL \n \",STAPLES,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320152210896145086,Business Services-Office Supplies\n2015-08-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-15481.02,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320152240951742758,\n2015-08-13,WICHCRAFT BRYANT PARNEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,214.45,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320152250967825071,Restaurant-Bar & Café\n2015-08-14,STARBUCKS #07497 NEWNew York NY,Darius Burgess,XXXX-XXXXXX-41148,16.28,\" Additional Information: New York \n \",STARBUCKS,\"665 BROADWAY\nBROADWAY AND BOND\nNEW YORK\nNY\",\"10012\nUNITED STATES OF AMERICA (THE)\",320152260973467313,Restaurant-Bar & Café\n2015-08-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,94.72,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152340102862781,Merchandise & Supplies-Internet Purchase\n2015-08-31,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-169.34,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320152430260111596,Fees & Adjustments-Fees & Adjustments\n2015-09-03,WICHCRAFT BRYANT PARNEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,219.82,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320152460298452869,Restaurant-Bar & Café\n2015-09-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-323.57,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320152550459055529,\n2015-09-18,FRESH & CO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,150.95,\" Additional Information: FAST FOOD RESTAURANT \n  Description \n  182599 \n \",FRESH & CO,\"58 EAST 8TH ST\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320152640601966334,Restaurant-Bar & Café\n2015-09-19,FRESH & CO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,8.66,\" Additional Information: 2124737374 \n  FOOD/BEVERAGE  $8.66 \n \",FRESH & CO,\"729 BROADWAY\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320152630582376228,Restaurant-Bar & Café\n2015-09-20,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,12.49,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320152640596948939,Merchandise & Supplies-Groceries\n2015-09-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,81.66,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152650604655989,Merchandise & Supplies-Internet Purchase\n2015-09-25,PIZZA MERCATO NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,590.43,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152710713208356,Restaurant-Restaurant\n2015-09-30,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-4.77,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320152730747361096,Fees & Adjustments-Fees & Adjustments\n2015-10-01,SUBWAY MIAMI FL,Vera O'Connor,XXXX-XXXXXX-41106,117,\" Additional Information: 305-6700041 \n \",SUBWAY,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320152750771031096,Restaurant-Bar & Café\n2015-10-03,USPS PO BOXES 101510WASHINGTON DC,Clare Dudley,XXXX-XXXXXX-41098,156,\" Additional Information: 800-3447779 \n \",USPS PO BOXES ONLINE,\"475 LENFANT PLZ SW\nWASHINGTON\nDC\",\"20260-0004\nUNITED STATES OF AMERICA (THE)\",320152770808332661,Business Services-Mailing & Shipping\n2015-10-04,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,13.48,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320152780825369796,Merchandise & Supplies-Groceries\n2015-10-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-304.24,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320152850946530301,\n2015-10-15,GROUPON INC 877-788-7858 IL,Vera O'Connor,XXXX-XXXXXX-41106,39.5,\" Additional Information: COUPONS \n \",GROUPON INC,\"600 W CHICAGO AVE\nSTE 400\nCHICAGO\nIL\",\"60654-2067\nUNITED STATES OF AMERICA (THE)\",320152890013469812,Merchandise & Supplies-Internet Purchase\n2015-10-16,PIZZA MERCATO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,57.75,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152920061757486,Restaurant-Restaurant\n2015-10-16,PIZZA MERCATO NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,774.62,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152920062466899,Restaurant-Restaurant\n2015-10-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098979714,Merchandise & Supplies-Internet Purchase\n2015-10-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098983888,Merchandise & Supplies-Internet Purchase\n2015-10-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098983890,Merchandise & Supplies-Internet Purchase\n2015-10-21,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098983894,Merchandise & Supplies-Internet Purchase\n2015-10-22,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152960115550127,Merchandise & Supplies-Internet Purchase\n2015-10-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133427869,Merchandise & Supplies-Internet Purchase\n2015-10-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133427871,Merchandise & Supplies-Internet Purchase\n2015-10-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133427873,Merchandise & Supplies-Internet Purchase\n2015-10-23,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133464913,Merchandise & Supplies-Internet Purchase\n2015-10-27,\"MES*RINGCENTRAL, INC6504724100\",Vera O'Connor,XXXX-XXXXXX-41106,160.56,\" Additional Information: 3719004008 94002 \n \",\"MES*RINGCENTRAL, INC\",\"1400 FASHION IS\nSAN MATEO\nCA\",\"944\nUNITED STATES OF AMERICA (THE)\",320153010200395072,Communications-Mobile Telecom\n2015-10-30,FRESH & CO NEW YORK,Vera O'Connor,XXXX-XXXXXX-41106,137.89,\" Additional Information: FAST FOOD RESTAURANT \n  Description \n  105071 \n \",FRESH & CO,\"58 EAST 8TH ST\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320153060292051275,Restaurant-Bar & Café\n2015-10-30,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-4.74,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,320153030245166855,Fees & Adjustments-Fees & Adjustments\n2015-11-12,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-1981.87,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,320153160462707016,\n2015-11-13,PIZZA MERCATO NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,774.62,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320153200524725369,Restaurant-Restaurant\n2015-11-15,PIZZA MERCATO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,93.8,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320153210543933275,Restaurant-Restaurant\n2015-11-20,NJT NY PENN STA 50NEW YORK NJ,Darius Burgess,XXXX-XXXXXX-41148,1011.75,\" Additional Information: 973-2755555 \n \",NJ TRANSIT,\"NEW YORK PENN STATION\n34TH & 7TH AVENUES\nNEW YORK\nNY\",\"10016\nUNITED STATES OF AMERICA (THE)\",320153250615656980,Transportation-Rail Services\n2015-11-21,HN-DUNKIN ST212 0000NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,43.14,\" Additional Information: 800-326-7711 \n  Description \n  GROCERIES/SUNDRIES \n \",HUDSON NEWS,\"8TH AVE -PENN STN TKT LVL\nNEW YORK\nNY\",\"10001\nUNITED STATES OF AMERICA (THE)\",320153260627158328,Merchandise & Supplies-Book Stores\n2015-11-21,PJS PANCAKE HOUSE 65PRINCETON NJ,Darius Burgess,XXXX-XXXXXX-41148,127.75,\" Additional Information: 6099241353 \n \",P J'S PANCAKE HOUSE RESTAURANT,\"154 NASSAU ST\nPRINCETON\nNJ\",\"08542-7006\nUNITED STATES OF AMERICA (THE)\",320153260617461233,Restaurant-Restaurant\n2015-11-23,888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,79.48,,PAYCYCLE INC,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320153280650085362,Merchandise & Supplies-Internet Purchase\n2015-12-02,STAPLES 00193 NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,3.74,\"001006221 00193001006221 10007\n00193001006221 10007\nBW SS P@SS LTR/LGL\nCLASP ENV BRN KRAFT 9X12 -12\n\n\n\n 00193001006221 10007 \n  BW SS P@SS LTR/LGL  \n  CLASP ENV BRN KRAFT 9X12 -12  \n \",STAPLES,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320153370819408517,Business Services-Office Supplies\n2015-12-02,STAPLES 00193 NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,9.15,\"002558833 00193002558833 10007\n00193002558833 10007\nBW SS P@SS LTR/LGL\n\n\n\n\n 00193002558833 10007 \n  BW SS P@SS LTR/LGL  \n \",STAPLES,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320153370816757331,Business Services-Office Supplies\n2015-12-03,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-19.87,,,,,320153390865190560,Fees & Adjustments-Fees & Adjustments\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850587329,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850900842,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851052217,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851183402,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851243871,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851421525,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850586825,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850586860,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850743209,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850743367,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850901096,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851051765,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851051982,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851052120,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851182737,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851183168,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851421352,Travel-Lodging\n2015-12-04,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851421393,Travel-Lodging\n"
  },
  {
    "path": "test/fixtures/export-csv/choice.csv",
    "content": "TextBox,Spinner\nfoo,foo\nbar,Baz\n"
  },
  {
    "path": "test/fixtures/export-csv/date.csv",
    "content": "YYYY-MM-DD,MM-DD-YYYY,MM/DD/YYYY,MM-DD-YY,MM/DD/YY,\"MMMM Do, YYYY\",DD-MM-YYYY,Custom YYYY-MM\n2016-12-01,12-03-2016,12/08/2016,12-13-16,12/23/16,\"December 1st, 2016\",08-12-2016,2016-12\n2016-12-22,12-28-2016,12/28/2016,12-11-16,12/11/16,\"December 26th, 2016\",25-12-2016,2016-11\n"
  },
  {
    "path": "test/fixtures/export-csv/datetime.csv",
    "content": "h:mma,h:mma z,HH:mm,HH:mm z,HH:mm:ss,HH:mm:ss z\n2016-12-08 7:00pm,2016-12-01 11:08pm WAT,2016-12-09 00:00,2016-12-28 03:00 -02,2016-12-08 13:33:00,2016-12-08 12:45:12 EST\n2016-12-29 5:07am,2016-12-29 4:00am WAT,2016-12-31 08:00,2016-12-06 21:00 -02,2016-12-07 13:33:55,2016-12-28 10:52:34 EST\n"
  },
  {
    "path": "test/fixtures/export-csv/field-options.csv",
    "content": "Text Formula,MM-DD-YY\r\na,04-14-17\r\n\"b ,d\",04-03-17\r\na,05-15-17\r\n"
  },
  {
    "path": "test/fixtures/export-csv/filtered-ref-list.csv",
    "content": "A,B\n\"John, Bob\",John\n\"Bob, Alice\",Alice\nBob,\n"
  },
  {
    "path": "test/fixtures/export-csv/filters-manual.csv",
    "content": "Color,Place,Height\nyellow,springfield,100\nred,cambridge,200\n"
  },
  {
    "path": "test/fixtures/export-csv/filters-saved.csv",
    "content": "Color,Place,Height\ngreen,kansas,50\ngreen,earth,75\nyellow,springfield,100\n"
  },
  {
    "path": "test/fixtures/export-csv/hidden-text.csv",
    "content": "Id is Baz Label is this,Formula,Foo,Link\nhello,a --- grist https://www.getgrist.com/,1,grist https://www.getgrist.com/\nworld,\"b ,d --- https://www.getgrist.com/\",2,https://www.getgrist.com/\n,\"the \"\"quote marks\"\" ? --- \",3,\n"
  },
  {
    "path": "test/fixtures/export-csv/integer.csv",
    "content": "TextBox,Spinner\n500,foo\n200,4\n"
  },
  {
    "path": "test/fixtures/export-csv/many-rows.csv",
    "content": "Name,Country,District,Population\n[San Cristóbal de] la Laguna,Spain,Canary Islands,127945\n´s-Hertogenbosch,Netherlands,Noord-Brabant,129170\nA Coruña (La Coruña),Spain,Galicia,243402\nAachen,Germany,Nordrhein-Westfalen,243825\nAalborg,Denmark,Nordjylland,161161\nAba,Nigeria,Imo & Abia,298900\nAbadan,Iran,Khuzestan,206073\nAbaetetuba,Brazil,Pará,111258\nAbakan,Russian Federation,Hakassia,169200\nAbbotsford,Canada,British Colombia,105403\nAbeokuta,Nigeria,Ogun,427400\nAberdeen,United Kingdom,Scotland,213070\nAbha,Saudi Arabia,Asir,112300\nAbidjan,Côte d’Ivoire,Abidjan,2500000\nAbiko,Japan,Chiba,126670\nAbilene,United States,Texas,115930\nAbohar,India,Punjab,107163\nAbottabad,Pakistan,Nothwest Border Prov,106000\nAbu Dhabi,United Arab Emirates,Abu Dhabi,398695\nAbuja,Nigeria,Federal Capital Dist,350100\nAcámbaro,Mexico,Guanajuato,110487\nAcapulco de Juárez,Mexico,Guerrero,721011\nAcarigua,Venezuela,Portuguesa,158954\nAccra,Ghana,Greater Accra,1070000\nAchalpur,India,Maharashtra,96216\nAcheng,China,Heilongjiang,197595\nAcuña,Mexico,Coahuila de Zaragoza,110388\nAdamstown,Pitcairn,–,42\nAdana,Turkey,Adana,1131198\nAddis Abeba,Ethiopia,Addis Abeba,2495000\nAdelaide,Australia,South Australia,978100\nAden,Yemen,Aden,398300\nAdiyaman,Turkey,Adiyaman,141529\nAdo-Ekiti,Nigeria,Ondo & Ekiti,359400\nAdoni,India,Andhra Pradesh,136182\nAfyon,Turkey,Afyon,103984\nAgadir,Morocco,Souss Massa-Draâ,155244\nAgaña,Guam,–,1139\nAgartala,India,Tripura,157358\nAgege,Nigeria,Lagos,105000\nAgeo,Japan,Saitama,209442\nAgra,India,Uttar Pradesh,891790\nÁguas Lindas de Goiás,Brazil,Goiás,89200\nAguascalientes,Mexico,Aguascalientes,643360\nAhmadnagar,India,Maharashtra,181339\nAhmadpur East,Pakistan,Punjab,96000\nAhmedabad,India,Gujarat,2876710\nAhome,Mexico,Sinaloa,358663\nAhvaz,Iran,Khuzestan,804980\nAix-en-Provence,France,Provence-Alpes-Côte,134222\nAizawl,India,Mizoram,155240\nAizuwakamatsu,Japan,Fukushima,119287\nAjman,United Arab Emirates,Ajman,114395\nAjmer,India,Rajasthan,402700\nAkashi,Japan,Hyogo,292253\nAkishima,Japan,Tokyo-to,106914\nAkita,Japan,Akita,314440\nAkola,India,Maharashtra,328034\nAkron,United States,Ohio,217074\nAksaray,Turkey,Aksaray,102681\nAkure,Nigeria,Ondo & Ekiti,162300\nal-Amara,Iraq,Maysan,208797\nal-Arish,Egypt,Shamal Sina,100447\nal-Ayn,United Arab Emirates,Abu Dhabi,225970\nal-Dammam,Saudi Arabia,al-Sharqiya,482300\nal-Diwaniya,Iraq,al-Qadisiya,196519\nal-Faiyum,Egypt,al-Faiyum,260964\nal-Fashir,Sudan,Darfur al-Shamaliya,141884\nal-Hawamidiya,Egypt,Giza,91700\nal-Hawiya,Saudi Arabia,Mekka,93900\nal-Hilla,Iraq,Babil,268834\nal-Hufuf,Saudi Arabia,al-Sharqiya,225800\nal-Kharj,Saudi Arabia,Riad,152100\nal-Khubar,Saudi Arabia,al-Sharqiya,141700\nal-Kut,Iraq,Wasit,183183\nal-Mahallat al-Kubra,Egypt,al-Gharbiya,395402\nal-Manama,Bahrain,al-Manama,148000\nal-Mansura,Egypt,al-Daqahliya,369621\nal-Minya,Egypt,al-Minya,201360\nal-Mubarraz,Saudi Arabia,al-Sharqiya,219100\nal-Mukalla,Yemen,Hadramawt,122400\nal-Najaf,Iraq,al-Najaf,309010\nal-Nasiriya,Iraq,DhiQar,265937\nal-Qadarif,Sudan,al-Qadarif,191164\nal-Qamishliya,Syria,al-Hasaka,144286\nal-Qatif,Saudi Arabia,al-Sharqiya,98900\nal-Ramadi,Iraq,al-Anbar,192556\nal-Raqqa,Syria,al-Raqqa,108020\nal-Rusayfa,Jordan,al-Zarqa,137247\nal-Salimiya,Kuwait,Hawalli,130215\nal-Sib,Oman,Masqat,155000\nal-Sulaymaniya,Iraq,al-Sulaymaniya,364096\nal-Taif,Saudi Arabia,Mekka,416100\nal-Tuqba,Saudi Arabia,al-Sharqiya,125700\nal-Zarqa,Jordan,al-Zarqa,389815\nal-Zawiya,Libyan Arab Jamahiriya,al-Zawiya,89338\nAlagoinhas,Brazil,Bahia,126820\nAlandur,India,Tamil Nadu,125244\nAlanya,Turkey,Antalya,117300\nAlbacete,Spain,Kastilia-La Mancha,147527\nAlbany,United States,New York,93994\nAlberton,South Africa,Gauteng,410102\nAlbuquerque,United States,New Mexico,448607\nAlcalá de Henares,Spain,Madrid,164463\nAlcorcón,Spain,Madrid,142048\nAleppo,Syria,Aleppo,1261983\nAlessandria,Italy,Piemonte,90289\nAlexandria,Egypt,Aleksandria,3328196\nAlexandria,United States,Virginia,128283\nAlgeciras,Spain,Andalusia,103106\nAlger,Algeria,Alger,2168000\nAlicante [Alacant],Spain,Valencia,272432\nAligarh,India,Uttar Pradesh,480520\nAlkmaar,Netherlands,Noord-Holland,92713\nAllahabad,India,Uttar Pradesh,792858\nAllappuzha (Alleppey),India,Kerala,174666\nAllende,Mexico,Guanajuato,134645\nAllentown,United States,Pennsylvania,106632\nAlmaty,Kazakstan,Almaty Qalasy,1129400\nAlmere,Netherlands,Flevoland,142465\nAlmería,Spain,Andalusia,169027\nAlmetjevsk,Russian Federation,Tatarstan,140700\nAlmirante Brown,Argentina,Buenos Aires,538918\nAlmoloya de Juárez,Mexico,México,110550\nAlofi,Niue,–,682\nAlor Setar,Malaysia,Kedah,124412\nAltamira,Mexico,Tamaulipas,127490\nAltševsk,Ukraine,Lugansk,119000\nAlvorada,Brazil,Rio Grande do Sul,175574\nAlwar,India,Rajasthan,205086\nAmadora,Portugal,Lisboa,122106\nAmagasaki,Japan,Hyogo,481434\nAmarillo,United States,Texas,173627\nAmbala,India,Haryana,122596\nAmbala Sadar,India,Haryana,90712\nAmbato,Ecuador,Tungurahua,169612\nAmbattur,India,Tamil Nadu,215424\nAmbon,Indonesia,Molukit,249312\nAmericana,Brazil,São Paulo,177409\nAmersfoort,Netherlands,Utrecht,126270\nAmiens,France,Picardie,135501\nAmman,Jordan,Amman,1000000\nAmol,Iran,Mazandaran,159092\nAmoy [Xiamen],China,Fujian,627500\nAmravati,India,Maharashtra,421576\nAmritsar,India,Punjab,708835\nAmroha,India,Uttar Pradesh,137061\nAmsterdam,Netherlands,Noord-Holland,731200\nAnaheim,United States,California,328014\nAnand,India,Gujarat,110266\nAnanindeua,Brazil,Pará,400940\nAnantapur,India,Andhra Pradesh,174924\nAnápolis,Brazil,Goiás,282197\nAnchorage,United States,Alaska,260283\nAncona,Italy,Marche,98329\nAnda,China,Heilongjiang,136446\nAndijon,Uzbekistan,Andijon,318600\nAndimeshk,Iran,Khuzestan,106923\nAndong,South Korea,Kyongsangbuk,188443\nAndorra la Vella,Andorra,Andorra la Vella,21189\nAndria,Italy,Apulia,94443\nAngarsk,Russian Federation,Irkutsk,264700\nAngeles,Philippines,Central Luzon,263971\nAngers,France,Pays de la Loire,151279\nAngra dos Reis,Brazil,Rio de Janeiro,96864\nAngren,Uzbekistan,Toskent,128000\nAnjo,Japan,Aichi,153823\nAnkang,China,Shaanxi,142170\nAnkara,Turkey,Ankara,3038159\nAnn Arbor,United States,Michigan,114024\nAnnaba,Algeria,Annaba,222518\nAnqing,China,Anhui,250718\nAnsan,South Korea,Kyonggi,510314\nAnshan,China,Liaoning,1200000\nAnshun,China,Guizhou,174142\nAntalya,Turkey,Antalya,564914\nAntananarivo,Madagascar,Antananarivo,675669\nAntipolo,Philippines,Southern Tagalog,470866\nAntofagasta,Chile,Antofagasta,251429\nAntsirabé,Madagascar,Antananarivo,120239\nAntwerpen,Belgium,Antwerpen,446525\nAnyang,China,Henan,420332\nAnyang,South Korea,Kyonggi,591106\nAnzero-Sudzensk,Russian Federation,Kemerovo,96100\nAomori,Japan,Aomori,295969\nAparecida de Goiânia,Brazil,Goiás,324662\nApatzingán,Mexico,Michoacán de Ocampo,117849\nApeldoorn,Netherlands,Gelderland,153491\nApia,Samoa,Upolu,35900\nApodaca,Mexico,Nuevo León,282941\nApopa,El Salvador,San Salvador,88800\nApucarana,Brazil,Paraná,105114\nAqsu,China,Xinxiang,164092\nAqtau,Kazakstan,Mangghystau,143400\nAqtöbe,Kazakstan,Aqtöbe,253100\nAra´ar,Saudi Arabia,al-Khudud al-Samaliy,108100\nAracaju,Brazil,Sergipe,445555\nAraçatuba,Brazil,São Paulo,169303\nArad,Romania,Arad,184408\nAraguaína,Brazil,Tocantins,114948\nAraguari,Brazil,Minas Gerais,98399\nArak,Iran,Markazi,380755\nArapiraca,Brazil,Alagoas,178988\nAraraquara,Brazil,São Paulo,174381\nAraras,Brazil,São Paulo,101046\nAraure,Venezuela,Portuguesa,94269\nArayat,Philippines,Central Luzon,101792\nArdebil,Iran,Ardebil,340386\nArden-Arcade,United States,California,92040\nArecibo,Puerto Rico,Arecibo,100131\nArequipa,Peru,Arequipa,762000\nArezzo,Italy,Toscana,91729\nArgenteuil,France,Île-de-France,93961\nÅrhus,Denmark,Århus,284846\nAriana,Tunisia,Ariana,197000\nArica,Chile,Tarapacá,189036\nArkangeli,Russian Federation,Arkangeli,361800\nArlington,United States,Texas,332969\nArlington,United States,Virginia,174838\nArmavir,Russian Federation,Krasnodar,164900\nArmenia,Colombia,Quindío,288977\nArnhem,Netherlands,Gelderland,138020\nArrah (Ara),India,Bihar,157082\nArusha,Tanzania,Arusha,102500\nArvada,United States,Colorado,102153\nArzamas,Russian Federation,Nizni Novgorod,110700\nAsahikawa,Japan,Hokkaido,364813\nAsaka,Japan,Saitama,114815\nAsan,South Korea,Chungchongnam,154663\nAsansol,India,West Bengali,262188\nAshdod,Israel,Ha Darom,155800\nAshgabat,Turkmenistan,Ahal,540600\nAshikaga,Japan,Tochigi,165243\nAshoknagar-Kalyangarh,India,West Bengali,96315\nAshqelon,Israel,Ha Darom,92300\nAsmara,Eritrea,Maekel,431000\nAssuan,Egypt,Assuan,219017\nAstana,Kazakstan,Astana,311200\nAstrahan,Russian Federation,Astrahan,486100\nAsunción,Paraguay,Asunción,557776\nAsyut,Egypt,Asyut,343498\nAthenai,Greece,Attika,772072\nAthens-Clarke County,United States,Georgia,101489\nAtibaia,Brazil,São Paulo,100356\nAtizapán de Zaragoza,Mexico,México,467262\nAtlanta,United States,Georgia,416474\nAtlixco,Mexico,Puebla,117019\nAtšinsk,Russian Federation,Krasnojarsk,121600\nAtsugi,Japan,Kanagawa,212407\nAtyrau,Kazakstan,Atyrau,142500\nAuckland,New Zealand,Auckland,381800\nAugsburg,Germany,Baijeri,254867\nAugusta-Richmond County,United States,Georgia,199775\nAurora,United States,Colorado,276393\nAurora,United States,Illinois,142990\nAustin,United States,Texas,656562\nAvadi,India,Tamil Nadu,183215\nAvarua,Cook Islands,Rarotonga,11900\nAvellaneda,Argentina,Buenos Aires,353046\nAwka,Nigeria,Anambra & Enugu & Eb,111200\nAyacucho,Peru,Ayacucho,118960\nAydin,Turkey,Aydin,128651\nBabol,Iran,Mazandaran,158346\nBacabal,Brazil,Maranhão,93121\nBacau,Romania,Bacau,209235\nBacolod,Philippines,Western Visayas,429076\nBacoor,Philippines,Southern Tagalog,305699\nBadajoz,Spain,Extremadura,136613\nBadalona,Spain,Katalonia,209635\nBærum,Norway,Akershus,101340\nBafoussam,Cameroon,Ouest,131000\nBagé,Brazil,Rio Grande do Sul,120793\nBaghdad,Iraq,Baghdad,4336000\nBago,Philippines,Western Visayas,141721\nBaguio,Philippines,CAR,252386\nBahawalnagar,Pakistan,Punjab,109600\nBahawalpur,Pakistan,Punjab,403408\nBahía Blanca,Argentina,Buenos Aires,239810\nBahir Dar,Ethiopia,Amhara,96140\nBahraich,India,Uttar Pradesh,135400\nBahtim,Egypt,al-Qalyubiya,275807\nBaia Mare,Romania,Maramures,149665\nBaicheng,China,Jilin,217987\nBaidyabati,India,West Bengali,90601\nBairiki,Kiribati,South Tarawa,2226\nBaiyin,China,Gansu,204970\nBakersfield,United States,California,247057\nBaku,Azerbaijan,Baki,1787800\nBalakovo,Russian Federation,Saratov,206000\nBalašiha,Russian Federation,Moskova,132900\nBalašov,Russian Federation,Saratov,97100\nBalikesir,Turkey,Balikesir,196382\nBalikpapan,Indonesia,Kalimantan Timur,338752\nBaliuag,Philippines,Central Luzon,119675\nBally,India,West Bengali,184474\nBalti,Moldova,Balti,153400\nBaltimore,United States,Maryland,651154\nBalurghat,India,West Bengali,119796\nBamako,Mali,Bamako,809552\nBamenda,Cameroon,Nord-Ouest,138000\nBanda,India,Uttar Pradesh,97227\nBanda Aceh,Indonesia,Aceh,143409\nBandar Lampung,Indonesia,Lampung,680332\nBandar Seri Begawan,Brunei,Brunei and Muara,21484\nBandar-e Anzali,Iran,Gilan,98500\nBandar-e-Abbas,Iran,Hormozgan,273578\nBandirma,Turkey,Balikesir,90200\nBandung,Indonesia,West Java,2429000\nBangalore,India,Karnataka,2660088\nBangkok,Thailand,Bangkok,6320174\nBangui,Central African Republic,Bangui,524000\nBanha,Egypt,al-Qalyubiya,145792\nBani Suwayf,Egypt,Bani Suwayf,172032\nBanja Luka,Bosnia and Herzegovina,Republika Srpska,143079\nBanjarmasin,Indonesia,Kalimantan Selatan,482931\nBanjul,Gambia,Banjul,42326\nBankura,India,West Bengali,114876\nBansberia,India,West Bengali,93447\nBantam,Cocos (Keeling) Islands,Home Island,503\nBanyuwangi,Indonesia,East Java,89900\nBaoding,China,Hebei,483155\nBaoji,China,Shaanxi,337765\nBaotou,China,Inner Mongolia,980000\nBaquba,Iraq,Diyala,114516\nBarahanagar (Baranagar),India,West Bengali,224821\nBarakaldo,Spain,Baskimaa,98212\nBaranovitši,Belarus,Brest,167000\nBarasat,India,West Bengali,107365\nBarbacena,Brazil,Minas Gerais,113079\nBarcelona,Spain,Katalonia,1503451\nBarcelona,Venezuela,Anzoátegui,322267\nBarddhaman (Burdwan),India,West Bengali,245079\nBareilly,India,Uttar Pradesh,587211\nBari,Italy,Apulia,331848\nBarinas,Venezuela,Barinas,217831\nBarisal,Bangladesh,Barisal,170232\nBarletta,Italy,Apulia,91904\nBarnaul,Russian Federation,Altai,580100\nBarquisimeto,Venezuela,Lara,877239\nBarra do Piraí,Brazil,Rio de Janeiro,89388\nBarra Mansa,Brazil,Rio de Janeiro,168953\nBarrackpur,India,West Bengali,133265\nBarrancabermeja,Colombia,Santander,178020\nBarranquilla,Colombia,Atlántico,1223260\nBarreiras,Brazil,Bahia,127801\nBarretos,Brazil,São Paulo,104156\nBarrie,Canada,Ontario,89269\nBarueri,Brazil,São Paulo,208426\nBaruta,Venezuela,Miranda,207290\nBasel,Switzerland,Basel-Stadt,166700\nBasildon,United Kingdom,England,100924\nBasirhat,India,West Bengali,101409\nBasra,Iraq,Basra,406296\nBasse-Terre,Guadeloupe,Basse-Terre,12433\nBassein (Pathein),Myanmar,Irrawaddy [Ayeyarwad,183900\nBasseterre,Saint Kitts and Nevis,St George Basseterre,11600\nBat Yam,Israel,Tel Aviv,137000\nBataisk,Russian Federation,Rostov-na-Donu,97300\nBatam,Indonesia,Riau,91871\nBatangas,Philippines,Southern Tagalog,247588\nBatman,Turkey,Batman,203793\nBatna,Algeria,Batna,183377\nBaton Rouge,United States,Louisiana,227818\nBattambang,Cambodia,Battambang,129800\nBatumi,Georgia,Adzaria [Atšara],137700\nBauru,Brazil,São Paulo,313670\nBawshar,Oman,Masqat,107500\nBayambang,Philippines,Ilocos,96609\nBayamo,Cuba,Granma,141000\nBayamón,Puerto Rico,Bayamón,224044\nBayawan (Tulong),Philippines,Central Visayas,101391\nBaybay,Philippines,Eastern Visayas,95630\nBayugan,Philippines,Caraga,93623\nBeau Bassin-Rose Hill,Mauritius,Plaines Wilhelms,100616\nBeaumont,United States,Texas,113866\nBeawar,India,Rajasthan,105363\nBéchar,Algeria,Béchar,107311\nBeerseba,Israel,Ha Darom,163700\nBei´an,China,Heilongjiang,204899\nBeihai,China,Guangxi,112673\nBeipiao,China,Liaoning,194301\nBeira,Mozambique,Sofala,397368\nBeirut,Lebanon,Beirut,1100000\nBéjaïa,Algeria,Béjaïa,117162\nBekasi,Indonesia,West Java,644300\nBelém,Brazil,Pará,1186926\nBelfast,United Kingdom,North Ireland,287500\nBelford Roxo,Brazil,Rio de Janeiro,425194\nBelgaum,India,Karnataka,326399\nBelgorod,Russian Federation,Belgorod,342000\nBelize City,Belize,Belize City,55810\nBellary,India,Karnataka,245391\nBellevue,United States,Washington,109569\nBello,Colombia,Antioquia,333470\nBelmopan,Belize,Cayo,7105\nBelo Horizonte,Brazil,Minas Gerais,2139125\nBender (Tîghina),Moldova,Bender (Tîghina),125700\nBene Beraq,Israel,Tel Aviv,133900\nBengasi,Libyan Arab Jamahiriya,Bengasi,804000\nBengbu,China,Anhui,449245\nBengkulu,Indonesia,Bengkulu,146439\nBenguela,Angola,Benguela,128300\nBeni-Mellal,Morocco,Tadla-Azilal,140212\nBenin City,Nigeria,Edo & Delta,229400\nBenito Juárez,Mexico,Quintana Roo,419276\nBenoni,South Africa,Gauteng,365467\nBento Gonçalves,Brazil,Rio Grande do Sul,89254\nBenxi,China,Liaoning,770000\nBeograd,Yugoslavia,Central Serbia,1204000\nBeppu,Japan,Oita,127486\nBerazategui,Argentina,Buenos Aires,276916\nBerdjansk,Ukraine,Zaporizzja,130000\nBerdytšiv,Ukraine,Zytomyr,90000\nBerezniki,Russian Federation,Perm,181900\nBergamo,Italy,Lombardia,117837\nBergen,Norway,Hordaland,230948\nBergisch Gladbach,Germany,Nordrhein-Westfalen,106150\nBerhampore (Baharampur),India,West Bengali,115144\nBerkeley,United States,California,102743\nBerlin,Germany,Berliini,3386667\nBern,Switzerland,Bern,122700\nBesançon,France,Franche-Comté,117733\nBetim,Brazil,Minas Gerais,302108\nBettiah,India,Bihar,92583\nBhagalpur,India,Bihar,253225\nBharatpur,India,Rajasthan,148519\nBharuch (Broach),India,Gujarat,133102\nBhatinda (Bathinda),India,Punjab,159042\nBhatpara,India,West Bengali,304952\nBhavnagar,India,Gujarat,402338\nBhilai,India,Chhatisgarh,386159\nBhilwara,India,Rajasthan,183965\nBhimavaram,India,Andhra Pradesh,121314\nBhind,India,Madhya Pradesh,109755\nBhir (Bid),India,Maharashtra,112434\nBhiwandi,India,Maharashtra,379070\nBhiwani,India,Haryana,121629\nBhopal,India,Madhya Pradesh,1062771\nBhubaneswar,India,Orissa,411542\nBhuj,India,Gujarat,102176\nBhusawal,India,Maharashtra,145143\nBialystok,Poland,Podlaskie,283937\nBida,Nigeria,Niger,125500\nBidar,India,Karnataka,108016\nBielefeld,Germany,Nordrhein-Westfalen,321125\nBielsko-Biala,Poland,Slaskie,180307\nBiên Hoa,Vietnam,Dong Nai,282095\nBihar Sharif,India,Bihar,201323\nBijapur,India,Karnataka,186939\nBijsk,Russian Federation,Altai,225000\nBikaner,India,Rajasthan,416289\nBikenibeu,Kiribati,South Tarawa,5055\nBila Tserkva,Ukraine,Kiova,215000\nBilaspur,India,Chhatisgarh,179833\nBilbao,Spain,Baskimaa,357589\nBilbays,Egypt,al-Sharqiya,113608\nBillings,United States,Montana,92988\nBiñan,Philippines,Southern Tagalog,201186\nBinangonan,Philippines,Southern Tagalog,187691\nBinjai,Indonesia,Sumatera Utara,127222\nBinzhou,China,Shandong,133555\nBiratnagar,Nepal,Eastern,157764\nBirgunj,Nepal,Central,90639\nBirigui,Brazil,São Paulo,94685\nBirjand,Iran,Khorasan,127608\nBirkenhead,United Kingdom,England,93087\nBirkirkara,Malta,Outer Harbour,21445\nBirmingham,United Kingdom,England,1013000\nBirmingham,United States,Alabama,242820\nBiserta,Tunisia,Biserta,108900\nBishkek,Kyrgyzstan,Bishkek shaary,589400\nBiskra,Algeria,Biskra,128281\nBislig,Philippines,Caraga,97860\nBismil,Turkey,Diyarbakir,101400\nBissau,Guinea-Bissau,Bissau,241000\nBlackburn,United Kingdom,England,140000\nBlackpool,United Kingdom,England,151000\nBlagoveštšensk,Russian Federation,Amur,222000\nBlantyre,Malawi,Blantyre,478155\nBlida (el-Boulaida),Algeria,Blida,127284\nBlitar,Indonesia,East Java,122600\nBloemfontein,South Africa,Free State,334341\nBlumenau,Brazil,Santa Catarina,244379\nBoa Vista,Brazil,Roraima,167185\nBobo-Dioulasso,Burkina Faso,Houet,300000\nBobruisk,Belarus,Mogiljov,221000\nBoca del Río,Mexico,Veracruz-Llave,135721\nBochum,Germany,Nordrhein-Westfalen,392830\nBogor,Indonesia,West Java,285114\nBogra,Bangladesh,Rajshahi,120170\nBoise City,United States,Idaho,185787\nBojnurd,Iran,Khorasan,134835\nBokaro Steel City,India,Jharkhand,333683\nBoksburg,South Africa,Gauteng,262648\nBologna,Italy,Emilia-Romagna,381161\nBolton,United Kingdom,England,139020\nBolzano,Italy,Trentino-Alto Adige,97232\nBoma,\"Congo, The Democratic Republic of the\",Bas-Zaïre,135284\nBonn,Germany,Nordrhein-Westfalen,301048\nBorås,Sweden,West Götanmaan län,96883\nBordeaux,France,Aquitaine,215363\nBorisov,Belarus,Minsk,151000\nBorujerd,Iran,Lorestan,217804\nBose,China,Guangxi,93009\nBoston,United States,Massachusetts,589141\nBotosani,Romania,Botosani,128730\nBotshabelo,South Africa,Free State,177971\nBottrop,Germany,Nordrhein-Westfalen,121097\nBotucatu,Brazil,São Paulo,107663\nBouaké,Côte d’Ivoire,Bouaké,329850\nBoulder,United States,Colorado,91238\nBoulogne-Billancourt,France,Île-de-France,106367\nBournemouth,United Kingdom,England,162000\nBozhou,China,Anhui,106346\nBradford,United Kingdom,England,289376\nBraga,Portugal,Braga,90535\nBragança Paulista,Brazil,São Paulo,116929\nBrahmanbaria,Bangladesh,Chittagong,109032\nBrahmapur,India,Orissa,210418\nBraila,Romania,Braila,233756\nBrakpan,South Africa,Gauteng,171363\nBrampton,Canada,Ontario,296711\nBrasília,Brazil,Distrito Federal,1969868\nBrasov,Romania,Brasov,314225\nBratislava,Slovakia,Bratislava,448292\nBratsk,Russian Federation,Irkutsk,277600\nBraunschweig,Germany,Niedersachsen,246322\nBrazzaville,Congo,Brazzaville,950000\nBreda,Netherlands,Noord-Brabant,160398\nBremen,Germany,Bremen,540330\nBremerhaven,Germany,Bremen,122735\nBrescia,Italy,Lombardia,191317\nBrest,France,Bretagne,149634\nBrest,Belarus,Brest,286000\nBridgeport,United States,Connecticut,139529\nBridgetown,Barbados,St Michael,6070\nBrighton,United Kingdom,England,156124\nBrindisi,Italy,Apulia,93454\nBrisbane,Australia,Queensland,1291117\nBristol,United Kingdom,England,402000\nBrjansk,Russian Federation,Brjansk,457400\nBrno,Czech Republic,Jizní Morava,381862\nBrockton,United States,Massachusetts,93653\nBrovary,Ukraine,Kiova,89000\nBrownsville,United States,Texas,139722\nBrugge,Belgium,West Flanderi,116246\nBruxelles [Brussel],Belgium,Bryssel,133859\nBucaramanga,Colombia,Santander,515555\nBucuresti,Romania,Bukarest,2016131\nBudapest,Hungary,Budapest,1811552\nBudaun,India,Uttar Pradesh,116695\nBuenaventura,Colombia,Valle,224336\nBuenos Aires,Argentina,Distrito Federal,2982146\nBuffalo,United States,New York,292648\nBuga,Colombia,Valle,110699\nBugulma,Russian Federation,Tatarstan,94100\nBuhoro,Uzbekistan,Buhoro,237100\nBujumbura,Burundi,Bujumbura,300000\nBukan,Iran,West Azerbaidzan,120020\nBukavu,\"Congo, The Democratic Republic of the\",South Kivu,201569\nBulandshahr,India,Uttar Pradesh,127201\nBulaq al-Dakrur,Egypt,Giza,148787\nBulawayo,Zimbabwe,Bulawayo,621742\nBuon Ma Thuot,Vietnam,Dac Lac,97044\nBurayda,Saudi Arabia,al-Qasim,248600\nBurbank,United States,California,100316\nBurgas,Bulgaria,Burgas,195255\nBurgos,Spain,Castilla and León,162802\nBurhanpur,India,Madhya Pradesh,172710\nBurlington,Canada,Ontario,145150\nBurnaby,Canada,British Colombia,179209\nBurnpur,India,West Bengali,174933\nBursa,Turkey,Bursa,1095842\nBushehr,Iran,Bushehr,143641\nButembo,\"Congo, The Democratic Republic of the\",North Kivu,109406\nButuan,Philippines,Caraga,267279\nBuzau,Romania,Buzau,148372\nBydgoszcz,Poland,Kujawsko-Pomorskie,386855\nBytom,Poland,Slaskie,205560\nCabanatuan,Philippines,Central Luzon,222859\nCabimas,Venezuela,Zulia,221329\nCabo de Santo Agostinho,Brazil,Pernambuco,149964\nCabo Frio,Brazil,Rio de Janeiro,119503\nCabuyao,Philippines,Southern Tagalog,106630\nCachoeirinha,Brazil,Rio Grande do Sul,103240\nCachoeiro de Itapemirim,Brazil,Espírito Santo,155024\nCadiz,Philippines,Western Visayas,141954\nCádiz,Spain,Andalusia,142449\nCaen,France,Basse-Normandie,113987\nCagayan de Oro,Philippines,Northern Mindanao,461877\nCagliari,Italy,Sardinia,165926\nCaguas,Puerto Rico,Caguas,140502\nCainta,Philippines,Southern Tagalog,242511\nCairns,Australia,Queensland,92273\nCairo,Egypt,Kairo,6789479\nCajamarca,Peru,Cajamarca,108009\nCajeme,Mexico,Sonora,355679\nCalabar,Nigeria,Cross River,174400\nCalabozo,Venezuela,Guárico,107146\nCalama,Chile,Antofagasta,137265\nCalamba,Philippines,Southern Tagalog,281146\nCalapan,Philippines,Southern Tagalog,105910\nCalbayog,Philippines,Eastern Visayas,147187\nCalcutta [Kolkata],India,West Bengali,4399819\nCalgary,Canada,Alberta,768082\nCali,Colombia,Valle,2077386\nCalicut (Kozhikode),India,Kerala,419831\nCallao,Peru,Callao,424294\nCam Pha,Vietnam,Quang Binh,209086\nCam Ranh,Vietnam,Khanh Hoa,114041\nCamaçari,Brazil,Bahia,149146\nCamagüey,Cuba,Camagüey,298726\nCamaragibe,Brazil,Pernambuco,118968\nCambridge,United Kingdom,England,121000\nCambridge,Canada,Ontario,109186\nCambridge,United States,Massachusetts,101355\nCametá,Brazil,Pará,92779\nCampeche,Mexico,Campeche,216735\nCampina Grande,Brazil,Paraíba,352497\nCampinas,Brazil,São Paulo,950043\nCampo Grande,Brazil,Mato Grosso do Sul,649593\nCampo Largo,Brazil,Paraná,91203\nCampos dos Goytacazes,Brazil,Rio de Janeiro,398418\nCan Tho,Vietnam,Can Tho,215587\nCanberra,Australia,Capital Region,322723\nCandelaria,Philippines,Southern Tagalog,92429\nCangzhou,China,Hebei,242708\nCanoas,Brazil,Rio Grande do Sul,294125\nCapas,Philippines,Central Luzon,95219\nCape Breton,Canada,Nova Scotia,114733\nCape Coral,United States,Florida,102286\nCape Town,South Africa,Western Cape,2352121\nCaracas,Venezuela,Distrito Federal,1975294\nCarapicuíba,Brazil,São Paulo,357552\nCárdenas,Mexico,Tabasco,216903\nCardiff,United Kingdom,Wales,321000\nCariacica,Brazil,Espírito Santo,319033\nCarmen,Mexico,Campeche,171367\nCarolina,Puerto Rico,Carolina,186076\nCarrefour,Haiti,Ouest,290204\nCarrollton,United States,Texas,109576\nCarson,United States,California,89089\nCartagena,Spain,Murcia,177709\nCartagena,Colombia,Bolívar,805757\nCartago,Colombia,Valle,125884\nCaruaru,Brazil,Pernambuco,244247\nCarúpano,Venezuela,Sucre,119639\nCary,United States,North Carolina,91213\nCasablanca,Morocco,Casablanca,2940623\nCascavel,Brazil,Paraná,237510\nCastanhal,Brazil,Pará,127634\nCastellón de la Plana [Castell,Spain,Valencia,139712\nCastilla,Peru,Piura,90642\nCastries,Saint Lucia,Castries,2301\nCatanduva,Brazil,São Paulo,107761\nCatania,Italy,Sisilia,337862\nCatanzaro,Italy,Calabria,96700\nCatia La Mar,Venezuela,Distrito Federal,117012\nCauayan,Philippines,Cagayan Valley,103952\nCaucaia,Brazil,Ceará,238738\nCavite,Philippines,Southern Tagalog,99367\nCaxias,Brazil,Maranhão,133980\nCaxias do Sul,Brazil,Rio Grande do Sul,349581\nCayenne,French Guiana,Cayenne,50699\nCebu,Philippines,Central Visayas,718821\nCedar Rapids,United States,Iowa,120758\nCelaya,Mexico,Guanajuato,382140\nCentral Coast,Australia,New South Wales,227657\nCentro (Villahermosa),Mexico,Tabasco,519873\nCesena,Italy,Emilia-Romagna,89852\nCeské Budejovice,Czech Republic,Jizní Cechy,98186\nCeyhan,Turkey,Adana,102412\nChaguanas,Trinidad and Tobago,Caroni,56601\nChalco,Mexico,México,222201\nChampdani,India,West Bengali,98818\nChandannagar,India,West Bengali,120378\nChandigarh,India,Chandigarh,504094\nChandler,United States,Arizona,176581\nChandrapur,India,Maharashtra,226105\nChang-won,South Korea,Kyongsangnam,481694\nChangchun,China,Jilin,2812000\nChangde,China,Hunan,301276\nChanghwa,Taiwan,Changhwa,227715\nChangji,China,Xinxiang,132260\nChangsha,China,Hunan,1809800\nChangshu,China,Jiangsu,181805\nChangzhi,China,Shanxi,317144\nChangzhou,China,Jiangsu,530000\nChaohu,China,Anhui,123676\nChaoyang,China,Liaoning,222394\nChaozhou,China,Guangdong,313469\nChapecó,Brazil,Santa Catarina,144158\nChapra,India,Bihar,136877\nChärjew,Turkmenistan,Lebap,189200\nCharleroi,Belgium,Hainaut,200827\nCharleston,United States,South Carolina,89063\nCharlotte,United States,North Carolina,540828\nCharlotte Amalie,\"Virgin Islands, U.S.\",St Thomas,13000\nChatsworth,South Africa,KwaZulu-Natal,189885\nChattanooga,United States,Tennessee,155554\nChechon,South Korea,Chungchongbuk,137070\nCheju,South Korea,Cheju,258511\nChelmsford,United Kingdom,England,97451\nCheltenham,United Kingdom,England,106000\nChemnitz,Germany,Saksi,263222\nChengde,China,Hebei,246799\nChengdu,China,Sichuan,3361500\nChennai (Madras),India,Tamil Nadu,3841396\nChenzhou,China,Hunan,169400\nChesapeake,United States,Virginia,199184\nChhindwara,India,Madhya Pradesh,93731\nChiang Mai,Thailand,Chiang Mai,171100\nChiayi,Taiwan,Chiayi,265109\nChiba,Japan,Chiba,863930\nChicago,United States,Illinois,2896016\nChiclayo,Peru,Lambayeque,517000\nChifeng,China,Inner Mongolia,350077\nChigasaki,Japan,Kanagawa,216015\nChihuahua,Mexico,Chihuahua,670208\nChilapa de Alvarez,Mexico,Guerrero,102716\nChillán,Chile,Bíobío,178182\nChilpancingo de los Bravo,Mexico,Guerrero,192509\nChimalhuacán,Mexico,México,490245\nChimbote,Peru,Ancash,336000\nChimoio,Mozambique,Manica,171056\nChinandega,Nicaragua,Chinandega,97387\nChincha Alta,Peru,Ica,110016\nChingola,Zambia,Copperbelt,142400\nChinhae,South Korea,Kyongsangnam,125997\nChiniot,Pakistan,Punjab,169300\nChinju,South Korea,Kyongsangnam,329886\nChishtian Mandi,Pakistan,Punjab,101700\nChisinau,Moldova,Chisinau,719900\nChittagong,Bangladesh,Chittagong,1392860\nChittoor,India,Andhra Pradesh,133462\nChitungwiza,Zimbabwe,Harare,274912\nChofu,Japan,Tokyo-to,201585\nChonan,South Korea,Chungchongnam,330259\nChong-up,South Korea,Chollabuk,139111\nChongjin,North Korea,Hamgyong P,582480\nChongju,South Korea,Chungchongbuk,531376\nChongqing,China,Chongqing,6351600\nChonju,South Korea,Chollabuk,563153\nChorzów,Poland,Slaskie,121708\nChristchurch,New Zealand,Canterbury,324200\nChula Vista,United States,California,173556\nChunchon,South Korea,Kang-won,234528\nChungho,Taiwan,Taipei,392176\nChungju,South Korea,Chungchongbuk,205206\nChungli,Taiwan,Taoyuan,318649\nChuzhou,China,Anhui,125341\nCianjur,Indonesia,West Java,114300\nCibinong,Indonesia,West Java,101300\nCiego de Ávila,Cuba,Ciego de Ávila,98505\nCienfuegos,Cuba,Cienfuegos,132770\nCilacap,Indonesia,Central Java,206900\nCilegon,Indonesia,West Java,117000\nCimahi,Indonesia,West Java,344600\nCimanggis,Indonesia,West Java,205100\nCincinnati,United States,Ohio,331285\nCiomas,Indonesia,West Java,187400\nCiparay,Indonesia,West Java,111500\nCiputat,Indonesia,West Java,270800\nCircik,Uzbekistan,Toskent,146400\nCirebon,Indonesia,West Java,254406\nCiteureup,Indonesia,West Java,105100\nCitrus Heights,United States,California,103455\nCittà del Vaticano,Holy See (Vatican City State),–,455\nCiudad Bolívar,Venezuela,Bolívar,301107\nCiudad de Guatemala,Guatemala,Guatemala,823301\nCiudad de México,Mexico,Distrito Federal,8591309\nCiudad de Panamá,Panama,Panamá,471373\nCiudad del Este,Paraguay,Alto Paraná,133881\nCiudad Guayana,Venezuela,Bolívar,663713\nCiudad Losada,Venezuela,,134501\nCiudad Madero,Mexico,Tamaulipas,182012\nCiudad Ojeda,Venezuela,Zulia,99354\nCiudad Valles,Mexico,San Luis Potosí,146411\nCixi,China,Zhejiang,107329\nCizah,Uzbekistan,Cizah,124800\nClarksville,United States,Tennessee,108787\nClearwater,United States,Florida,99936\nClermont-Ferrand,France,Auvergne,137140\nCleveland,United States,Ohio,478403\nCluj-Napoca,Romania,Cluj,332498\nCoacalco de Berriozábal,Mexico,México,252270\nCoatzacoalcos,Mexico,Veracruz,267037\nCochabamba,Bolivia,Cochabamba,482800\nCochin (Kochi),India,Kerala,564589\nCockburn Town,Turks and Caicos Islands,Grand Turk,4800\nCodó,Brazil,Maranhão,103153\nCoimbatore,India,Tamil Nadu,816321\nCoímbra,Portugal,Coímbra,96100\nColatina,Brazil,Espírito Santo,107354\nColchester,United Kingdom,England,96063\nColima,Mexico,Colima,129454\nColombo,Brazil,Paraná,177764\nColombo,Sri Lanka,Western,645000\nColorado Springs,United States,Colorado,360890\nColumbia,United States,South Carolina,116278\nColumbus,United States,Ohio,711470\nColumbus,United States,Georgia,186291\nComalcalco,Mexico,Tabasco,164640\nComilla,Bangladesh,Chittagong,135313\nComitán de Domínguez,Mexico,Chiapas,104986\nComodoro Rivadavia,Argentina,Chubut,124104\nCompton,United States,California,92864\nConakry,Guinea,Conakry,1090610\nConcepcion,Philippines,Central Luzon,115171\nConcepción,Chile,Bíobío,217664\nConcord,United States,California,121780\nConcordia,Argentina,Entre Rios,116485\nConselheiro Lafaiete,Brazil,Minas Gerais,97507\nConstanta,Romania,Constanta,342264\nConstantine,Algeria,Constantine,443727\nContagem,Brazil,Minas Gerais,520801\nCopiapó,Chile,Atacama,120128\nCoquimbo,Chile,Coquimbo,143353\nCoquitlam,Canada,British Colombia,101820\nCoral Springs,United States,Florida,117549\nCórdoba,Argentina,Córdoba,1157507\nCórdoba,Spain,Andalusia,311708\nCórdoba,Mexico,Veracruz,176952\nCork,Ireland,Munster,127187\nÇorlu,Turkey,Tekirdag,123300\nCorona,United States,California,124966\nCoronel,Chile,Bíobío,93061\nCoronel Fabriciano,Brazil,Minas Gerais,95933\nCorpus Christi,United States,Texas,277454\nCorrientes,Argentina,Corrientes,258103\nÇorum,Turkey,Çorum,145495\nCorumbá,Brazil,Mato Grosso do Sul,90111\nCosoleacaque,Mexico,Veracruz,97199\nCosta Mesa,United States,California,108724\nCotabato,Philippines,Central Mindanao,163849\nCotia,Brazil,São Paulo,140042\nCotonou,Benin,Atlantique,536827\nCottbus,Germany,Brandenburg,110894\nCoventry,United Kingdom,England,304000\nCraiova,Romania,Dolj,313530\nCrato,Brazil,Ceará,98965\nCrawley,United Kingdom,England,97000\nCriciúma,Brazil,Santa Catarina,167661\nCuauhtémoc,Mexico,Chihuahua,124279\nCuautitlán Izcalli,Mexico,México,452976\nCuautla,Mexico,Morelos,153132\nCubatão,Brazil,São Paulo,102372\nCúcuta,Colombia,Norte de Santander,606932\nCuddalore,India,Tamil Nadu,153086\nCuddapah,India,Andhra Pradesh,121463\nCuenca,Ecuador,Azuay,270353\nCuernavaca,Mexico,Morelos,337966\nCuiabá,Brazil,Mato Grosso,453813\nCuliacán,Mexico,Sinaloa,744859\nCumaná,Venezuela,Sucre,293105\nCunduacán,Mexico,Tabasco,104164\nCuricó,Chile,Maule,115766\nCuritiba,Brazil,Paraná,1584232\nCusco,Peru,Cusco,291000\nCzestochowa,Poland,Slaskie,257812\nDa Lat,Vietnam,Lam Dong,106409\nDa Nang,Vietnam,Quang Nam-Da Nang,382674\nDa´an,China,Jilin,138963\nDabgram,India,West Bengali,147217\nDabrowa Górnicza,Poland,Slaskie,131037\nDadu,Pakistan,Sind,98600\nDagupan,Philippines,Ilocos,130328\nDaito,Japan,Osaka,130594\nDakar,Senegal,Cap-Vert,785071\nDalap-Uliga-Darrit,Marshall Islands,Majuro,28000\nDali,China,Yunnan,136554\nDalian,China,Liaoning,2697000\nDallas,United States,Texas,1188580\nDaloa,Côte d’Ivoire,Daloa,121842\nDaly City,United States,California,103621\nDamanhur,Egypt,al-Buhayra,212203\nDamascus,Syria,Damascus,1347000\nDamoh,India,Madhya Pradesh,95661\nDanao,Philippines,Central Visayas,98781\nDandong,China,Liaoning,520000\nDanjiangkou,China,Hubei,103211\nDanyang,China,Jiangsu,169603\nDaqing,China,Heilongjiang,660000\nDar es Salaam,Tanzania,Dar es Salaam,1747000\nDaraga (Locsin),Philippines,Bicol,101031\nDarbhanga,India,Bihar,218391\nDarmstadt,Germany,Hessen,137776\nDashhowuz,Turkmenistan,Dashhowuz,141800\nDaska,Pakistan,Punjab,101500\nDasmariñas,Philippines,Southern Tagalog,379520\nDatong,China,Shanxi,800000\nDaugavpils,Latvia,Daugavpils,114829\nDavangere,India,Karnataka,266082\nDavao,Philippines,Southern Mindanao,1147116\nDavenport,United States,Iowa,98256\nDaxian,China,Sichuan,188101\nDayr al-Zawr,Syria,Dayr al-Zawr,140459\nDayton,United States,Ohio,166179\nDeba Habe,Nigeria,Bauchi & Gombe,138600\nDebrecen,Hungary,Hajdú-Bihar,203648\nDehiwala,Sri Lanka,Western,203000\nDehra Dun,India,Uttaranchal,270159\nDehri,India,Bihar,94526\nDelft,Netherlands,Zuid-Holland,95268\nDelhi,India,Delhi,7206704\nDelhi Cantonment,India,Delhi,94326\nDelicias,Mexico,Chihuahua,116132\nDelmas,Haiti,Ouest,240429\nDelta,Canada,British Colombia,95411\nDenizli,Turkey,Denizli,253848\nDenpasar,Indonesia,Bali,435000\nDenver,United States,Colorado,554636\nDepok,Indonesia,West Java,365200\nDepok,Indonesia,Yogyakarta,106800\nDera Ghazi Khan,Pakistan,Punjab,188100\nDera Ismail Khan,Pakistan,Nothwest Border Prov,90400\nDerbent,Russian Federation,Dagestan,92300\nDerby,United Kingdom,England,236000\nDes Moines,United States,Iowa,198682\nDese,Ethiopia,Amhara,97314\nDetroit,United States,Michigan,951270\nDewas,India,Madhya Pradesh,164364\nDeyang,China,Sichuan,182488\nDezful,Iran,Khuzestan,202639\nDezhou,China,Shandong,195485\nDhaka,Bangladesh,Dhaka,3612850\nDhanbad,India,Jharkhand,151789\nDhule (Dhulia),India,Maharashtra,278317\nDiadema,Brazil,São Paulo,335078\nDibrugarh,India,Assam,120127\nDigos,Philippines,Southern Mindanao,125171\nDijon,France,Bourgogne,149867\nDili,East Timor,Dili,47900\nDimitrovgrad,Russian Federation,Uljanovsk,137000\nDinajpur,Bangladesh,Rajshahi,127815\nDindigul,India,Tamil Nadu,182477\nDiourbel,Senegal,Diourbel,99400\nDipolog,Philippines,Western Mindanao,99862\nDire Dawa,Ethiopia,Dire Dawa,164851\nDisuq,Egypt,Kafr al-Shaykh,91300\nDivinópolis,Brazil,Minas Gerais,185047\nDiyarbakir,Turkey,Diyarbakir,479884\nDjibouti,Djibouti,Djibouti,383000\nDjougou,Benin,Atacora,134099\nDniprodzerzynsk,Ukraine,Dnipropetrovsk,270000\nDnipropetrovsk,Ukraine,Dnipropetrovsk,1103000\nDobric,Bulgaria,Varna,100399\nDodoma,Tanzania,Dodoma,189000\nDoha,Qatar,Doha,355000\nDolores Hidalgo,Mexico,Guanajuato,128675\nDonetsk,Ukraine,Donetsk,1050000\nDongtai,China,Jiangsu,192247\nDongwan,China,Guangdong,308669\nDongying,China,Shandong,281728\nDonostia-San Sebastián,Spain,Baskimaa,179208\nDordrecht,Netherlands,Zuid-Holland,119811\nDortmund,Germany,Nordrhein-Westfalen,590213\nDos Hermanas,Spain,Andalusia,94591\nDos Quebradas,Colombia,Risaralda,159363\nDouala,Cameroon,Littoral,1448300\nDouglas,United Kingdom,–,23487\nDourados,Brazil,Mato Grosso do Sul,164716\nDowney,United States,California,107323\nDresden,Germany,Saksi,476668\nDrobeta-Turnu Severin,Romania,Mehedinti,117865\nDubai,United Arab Emirates,Dubai,669181\nDublin,Ireland,Leinster,481854\nDudley,United Kingdom,England,192171\nDuisburg,Germany,Nordrhein-Westfalen,519793\nDujiangyan,China,Sichuan,123357\nDuma,Syria,Damaskos,131158\nDumaguete,Philippines,Central Visayas,102265\nDundee,United Kingdom,Scotland,146690\nDunedin,New Zealand,Dunedin,119600\nDunhua,China,Jilin,235100\nDuque de Caxias,Brazil,Rio de Janeiro,746758\nDuran [Eloy Alfaro],Ecuador,Guayas,152514\nDurango,Mexico,Durango,490524\nDurban,South Africa,KwaZulu-Natal,566120\nDüren,Germany,Nordrhein-Westfalen,91092\nDurg,India,Chhatisgarh,150645\nDurgapur,India,West Bengali,425836\nDurham,United States,North Carolina,187035\nDushanbe,Tajikistan,Karotegin,524000\nDüsseldorf,Germany,Nordrhein-Westfalen,568855\nDuyun,China,Guizhou,132971\nDzerzinsk,Russian Federation,Nizni Novgorod,277100\nEast London,South Africa,Eastern Cape,221047\nEast Los Angeles,United States,California,126379\nEast York,Canada,Ontario,114034\nEastbourne,United Kingdom,England,90000\nEbetsu,Japan,Hokkaido,118805\nEbina,Japan,Kanagawa,115571\nEcatepec de Morelos,Mexico,México,1620303\nEch-Chleff (el-Asnam),Algeria,Chlef,96794\nEde,Netherlands,Gelderland,101574\nEde,Nigeria,Oyo & Osun,307100\nEdinburgh,United Kingdom,Scotland,450180\nEdirne,Turkey,Edirne,123383\nEdmonton,Canada,Alberta,616306\nEffon-Alaiye,Nigeria,Oyo & Osun,153100\nEindhoven,Netherlands,Noord-Brabant,201843\nEjigbo,Nigeria,Oyo & Osun,105900\nEkibastuz,Kazakstan,Pavlodar,127200\nEl Alto,Bolivia,La Paz,534466\nEl Araich,Morocco,Tanger-Tétouan,90400\nEl Cajon,United States,California,94578\nEl Fuerte,Mexico,Sinaloa,89556\nEl Jadida,Morocco,Doukkala-Abda,119083\nEl Limón,Venezuela,Aragua,90000\nEl Mante,Mexico,Tamaulipas,112453\nEl Monte,United States,California,115965\nEl Paso,United States,Texas,563662\nEl Tigre,Venezuela,Anzoátegui,116256\nEl-Aaiún,Western Sahara,El-Aaiún,169000\nElâzig,Turkey,Elâzig,228815\nElblag,Poland,Warminsko-Mazurskie,129782\nElche [Elx],Spain,Valencia,193174\nEldoret,Kenya,Rift Valley,111882\nElektrostal,Russian Federation,Moskova,147000\nElgin,United States,Illinois,89408\nElista,Russian Federation,Kalmykia,103300\nElizabeth,United States,New Jersey,120568\nEluru,India,Andhra Pradesh,212866\nEmbu,Brazil,São Paulo,222223\nEmeishan,China,Sichuan,94000\nEmmen,Netherlands,Drenthe,105853\nEngels,Russian Federation,Saratov,189000\nEnschede,Netherlands,Overijssel,149544\nEnsenada,Mexico,Baja California,369573\nEnshi,China,Hubei,93056\nEnugu,Nigeria,Anambra & Enugu & Eb,316100\nEnvigado,Colombia,Antioquia,135848\nEpe,Nigeria,Lagos,101000\nErfurt,Germany,Thüringen,201267\nErie,United States,Pennsylvania,103717\nErlangen,Germany,Baijeri,100750\nErode,India,Tamil Nadu,159232\nErzincan,Turkey,Erzincan,102304\nErzurum,Turkey,Erzurum,246535\nEscobar,Argentina,Buenos Aires,116675\nEscondido,United States,California,133559\nEsfahan,Iran,Esfahan,1266072\nEskisehir,Turkey,Eskisehir,470781\nEslamshahr,Iran,Teheran,265450\nEsmeraldas,Ecuador,Esmeraldas,123045\nEspoo,Finland,Newmaa,213271\nEssen,Germany,Nordrhein-Westfalen,599515\nEsslingen am Neckar,Germany,Baden-Württemberg,89667\nEsteban Echeverría,Argentina,Buenos Aires,235760\nEtawah,India,Uttar Pradesh,124072\nEtobicoke,Canada,Ontario,348845\nEttadhamen,Tunisia,Ariana,178600\nEugene,United States,Oregon,137893\nEunápolis,Brazil,Bahia,96610\nEvansville,United States,Indiana,121582\nExeter,United Kingdom,England,111000\nEzeiza,Argentina,Buenos Aires,99578\nEzhou,China,Hubei,190123\nFaaa,French Polynesia,Tahiti,25888\nFagatogo,American Samoa,Tutuila,2323\nFairfield,United States,California,92256\nFaisalabad,Pakistan,Punjab,1977246\nFaizabad,India,Uttar Pradesh,124437\nFakaofo,Tokelau,Fakaofo,300\nFall River,United States,Massachusetts,90555\nFargona,Uzbekistan,Fargona,180500\nFaridabad,India,Haryana,703592\nFarrukhabad-cum-Fatehgarh,India,Uttar Pradesh,194567\nFatehpur,India,Uttar Pradesh,117675\nFayetteville,United States,North Carolina,121015\nFeira de Santana,Brazil,Bahia,479992\nFengcheng,China,Jiangxi,193784\nFengshan,Taiwan,Kaohsiung,318562\nFengyuan,Taiwan,Taichung,161032\nFernando de la Mora,Paraguay,Central,95287\nFerrara,Italy,Emilia-Romagna,132127\nFerraz de Vasconcelos,Brazil,São Paulo,139283\nFès,Morocco,Fès-Boulemane,541162\nFianarantsoa,Madagascar,Fianarantsoa,99005\nFirenze,Italy,Toscana,376662\nFirozabad,India,Uttar Pradesh,215128\nFlint,United States,Michigan,124943\nFlorencia,Colombia,Caquetá,108574\nFlorencio Varela,Argentina,Buenos Aires,315432\nFlorianópolis,Brazil,Santa Catarina,281928\nFloridablanca,Colombia,Santander,221913\nFlying Fish Cove,Christmas Island,–,700\nFocsani,Romania,Vrancea,98979\nFoggia,Italy,Apulia,154891\nFontana,United States,California,128929\nForlì,Italy,Emilia-Romagna,107475\nFormosa,Argentina,Formosa,147636\nFort Collins,United States,Colorado,118652\nFort Lauderdale,United States,Florida,152397\nFort Wayne,United States,Indiana,205727\nFort Worth,United States,Texas,534694\nFort-de-France,Martinique,Fort-de-France,94050\nFortaleza,Brazil,Ceará,2097757\nFoshan,China,Guangdong,303160\nFoz do Iguaçu,Brazil,Paraná,259425\nFranca,Brazil,São Paulo,290139\nFrancisco Morato,Brazil,São Paulo,121197\nFrancistown,Botswana,Francistown,101805\nFranco da Rocha,Brazil,São Paulo,108964\nFrankfurt am Main,Germany,Hessen,643821\nFrederiksberg,Denmark,Frederiksberg,90327\nFreetown,Sierra Leone,Western,850000\nFreiburg im Breisgau,Germany,Baden-Württemberg,202455\nFremont,United States,California,203413\nFresnillo,Mexico,Zacatecas,182744\nFresno,United States,California,427652\nFu´an,China,Fujian,105265\nFuchu,Japan,Tokyo-to,220576\nFuenlabrada,Spain,Madrid,171173\nFuji,Japan,Shizuoka,231527\nFujieda,Japan,Shizuoka,126897\nFujimi,Japan,Saitama,96972\nFujin,China,Heilongjiang,103104\nFujinomiya,Japan,Shizuoka,119714\nFujisawa,Japan,Kanagawa,372840\nFukaya,Japan,Saitama,102156\nFukui,Japan,Fukui,254818\nFukuoka,Japan,Fukuoka,1308379\nFukushima,Japan,Fukushima,287525\nFukuyama,Japan,Hiroshima,376921\nFuling,China,Sichuan,173878\nFullerton,United States,California,126003\nFunabashi,Japan,Chiba,545299\nFunafuti,Tuvalu,Funafuti,4600\nFuqing,China,Fujian,99193\nFürth,Germany,Baijeri,109771\nFushun,China,Liaoning,1200000\nFuxin,China,Liaoning,640000\nFuyang,China,Anhui,179572\nFuyu,China,Jilin,192981\nFuzhou,China,Fujian,1593800\nGabès,Tunisia,Gabès,106600\nGaborone,Botswana,Gaborone,213017\nGadag Betigeri,India,Karnataka,134051\nGainesville,United States,Florida,92291\nGalati,Romania,Galati,330276\nGäncä,Azerbaijan,Gäncä,299300\nGandhidham,India,Gujarat,104585\nGandhinagar,India,Gujarat,123359\nGanganagar,India,Rajasthan,161482\nGanzhou,China,Jiangxi,220129\nGaranhuns,Brazil,Pernambuco,114603\nGarapan,Northern Mariana Islands,Saipan,9200\nGarden Grove,United States,California,165196\nGarland,United States,Texas,215768\nGaroua,Cameroon,Nord,177000\nGarut,Indonesia,West Java,95800\nGary,United States,Indiana,102746\nGatineau,Canada,Québec,100702\nGävle,Sweden,Gävleborgs län,90742\nGaya,India,Bihar,291675\nGaza,Palestine,Gaza,353632\nGaziantep,Turkey,Gaziantep,789056\nGazipur,Bangladesh,Dhaka,96717\nGdansk,Poland,Pomorskie,458988\nGdynia,Poland,Pomorskie,253521\nGebze,Turkey,Kocaeli,264170\nGeelong,Australia,Victoria,125382\nGejiu,China,Yunnan,214294\nGelsenkirchen,Germany,Nordrhein-Westfalen,281979\nGeneral Escobedo,Mexico,Nuevo León,232961\nGeneral Mariano Alvarez,Philippines,Southern Tagalog,112446\nGeneral San Martín,Argentina,Buenos Aires,422542\nGeneral Santos,Philippines,Southern Mindanao,411822\nGeneral Trias,Philippines,Southern Tagalog,107691\nGeneve,Switzerland,Geneve,173500\nGenova,Italy,Liguria,636104\nGent,Belgium,East Flanderi,224180\nGeorge,South Africa,Western Cape,93818\nGeorge Town,Cayman Islands,Grand Cayman,19600\nGeorgetown,Guyana,Georgetown,254000\nGera,Germany,Thüringen,114718\nGermiston,South Africa,Gauteng,164252\nGetafe,Spain,Madrid,145371\nGhardaïa,Algeria,Ghardaïa,89415\nGhaziabad,India,Uttar Pradesh,454156\nGhulja,China,Xinxiang,177193\nGibraltar,Gibraltar,–,27025\nGifu,Japan,Gifu,408007\nGijón,Spain,Asturia,267980\nGilbert,United States,Arizona,109697\nGillingham,United Kingdom,England,92000\nGingoog,Philippines,Northern Mindanao,102379\nGirardot,Colombia,Cundinamarca,110963\nGiron,Colombia,Santander,90688\nGiugliano in Campania,Italy,Campania,93286\nGiza,Egypt,Giza,2221868\nGjumri,Armenia,Širak,211700\nGlasgow,United Kingdom,Scotland,619680\nGlazov,Russian Federation,Udmurtia,106300\nGlendale,United States,Arizona,218812\nGlendale,United States,California,194973\nGliwice,Poland,Slaskie,212164\nGloucester,United Kingdom,England,107000\nGloucester,Canada,Ontario,107314\nGodhra,India,Gujarat,96813\nGodoy Cruz,Argentina,Mendoza,206998\nGoiânia,Brazil,Goiás,1056330\nGojra,Pakistan,Punjab,115000\nGold Coast,Australia,Queensland,311932\nGoma,\"Congo, The Democratic Republic of the\",North Kivu,109094\nGombe,Nigeria,Bauchi & Gombe,107800\nGomel,Belarus,Gomel,475000\nGómez Palacio,Mexico,Durango,272806\nGonbad-e Qabus,Iran,Mazandaran,111253\nGonda,India,Uttar Pradesh,106078\nGonder,Ethiopia,Amhara,112249\nGondiya,India,Maharashtra,109470\nGongziling,China,Jilin,226569\nGorakhpur,India,Uttar Pradesh,505566\nGorgan,Iran,Golestan,188710\nGorlivka,Ukraine,Donetsk,299000\nGorontalo,Indonesia,Sulawesi Utara,94058\nGorzów Wielkopolski,Poland,Lubuskie,126019\nGothenburg [Göteborg],Sweden,West Götanmaan län,466990\nGöttingen,Germany,Niedersachsen,124775\nGovernador Valadares,Brazil,Minas Gerais,231724\nGranada,Spain,Andalusia,244767\nGrand Prairie,United States,Texas,127427\nGrand Rapids,United States,Michigan,197800\nGravataí,Brazil,Rio Grande do Sul,223011\nGraz,Austria,Steiermark,240967\nGreen Bay,United States,Wisconsin,102313\nGreensboro,United States,North Carolina,223891\nGrenoble,France,Rhône-Alpes,153317\nGrimsby,United Kingdom,England,89000\nGrodno,Belarus,Grodno,302000\nGroningen,Netherlands,Groningen,172701\nGrozny,Russian Federation,Tšetšenia,186000\nGrudziadz,Poland,Kujawsko-Pomorskie,102434\nGuacara,Venezuela,Carabobo,131334\nGuadalajara,Mexico,Jalisco,1647720\nGuadalupe,Mexico,Nuevo León,668780\nGuadalupe,Mexico,Zacatecas,108881\nGuagua,Philippines,Central Luzon,96858\nGuaíba,Brazil,Rio Grande do Sul,92224\nGuanajuato,Mexico,Guanajuato,141215\nGuanare,Venezuela,Portuguesa,125621\nGuangshui,China,Hubei,102770\nGuangyuan,China,Sichuan,182241\nGuantánamo,Cuba,Guantánamo,205078\nGuarapuava,Brazil,Paraná,160510\nGuaratinguetá,Brazil,São Paulo,103433\nGuarenas,Venezuela,Miranda,165889\nGuarujá,Brazil,São Paulo,237206\nGuarulhos,Brazil,São Paulo,1095874\nGuasave,Mexico,Sinaloa,277201\nGuatire,Venezuela,Miranda,109121\nGuayaquil,Ecuador,Guayas,2070040\nGuaymallén,Argentina,Mendoza,200595\nGuaymas,Mexico,Sonora,130108\nGuaynabo,Puerto Rico,Guaynabo,100053\nGudivada,India,Andhra Pradesh,101656\nGuelph,Canada,Ontario,103593\nGuigang,China,Guangxi,114025\nGuilin,China,Guangxi,364130\nGuiyang,China,Guizhou,1465200\nGujranwala,Pakistan,Punjab,1124749\nGujrat,Pakistan,Punjab,250121\nGulbarga,India,Karnataka,304099\nGuna,India,Madhya Pradesh,100490\nGuntakal,India,Andhra Pradesh,107592\nGuntur,India,Andhra Pradesh,471051\nGurgaon,India,Haryana,128608\nGurue,Mozambique,Zambézia,99300\nGusau,Nigeria,Sokoto & Kebbi & Zam,158000\nGütersloh,Germany,Nordrhein-Westfalen,95028\nGuwahati (Gauhati),India,Assam,584342\nGwalior,India,Madhya Pradesh,690765\nGweru,Zimbabwe,Midlands,128037\nGyör,Hungary,Györ-Moson-Sopron,127119\nHaag,Netherlands,Zuid-Holland,440900\nHaarlem,Netherlands,Noord-Holland,148772\nHaarlemmermeer,Netherlands,Noord-Holland,110722\nHabarovsk,Russian Federation,Habarovsk,609400\nHabikino,Japan,Osaka,118968\nHabra,India,West Bengali,100223\nHachinohe,Japan,Aomori,242979\nHachioji,Japan,Tokyo-to,513451\nHadano,Japan,Kanagawa,166512\nHaeju,North Korea,Hwanghae N,229172\nHafar al-Batin,Saudi Arabia,al-Sharqiya,137800\nHafizabad,Pakistan,Punjab,130200\nHagen,Germany,Nordrhein-Westfalen,205201\nHagonoy,Philippines,Central Luzon,111425\nHaicheng,China,Liaoning,205560\nHaifa,Israel,Haifa,265700\nHaikou,China,Hainan,454300\nHail,Saudi Arabia,Hail,176800\nHailar,China,Inner Mongolia,180650\nHailun,China,Heilongjiang,133565\nHaining,China,Zhejiang,100478\nHaiphong,Vietnam,Haiphong,783133\nHakodate,Japan,Hokkaido,294788\nHaldia,India,West Bengali,100347\nHaldwani-cum-Kathgodam,India,Uttaranchal,104195\nHalifax,United Kingdom,England,91069\nHalifax,Canada,Nova Scotia,113910\nHalisahar,India,West Bengali,114028\nHalle/Saale,Germany,Anhalt Sachsen,254360\nHama,Syria,Hama,343361\nHamadan,Iran,Hamadan,401281\nHamamatsu,Japan,Shizuoka,568796\nHamburg,Germany,Hamburg,1704735\nHamhung,North Korea,Hamgyong N,709730\nHami,China,Xinxiang,161315\nHamilton,Bermuda,Hamilton,1200\nHamilton,Canada,Ontario,335614\nHamilton,New Zealand,Hamilton,117100\nHamm,Germany,Nordrhein-Westfalen,181804\nHampton,United States,Virginia,146437\nHanam,South Korea,Kyonggi,115812\nHanda,Japan,Aichi,108600\nHandan,China,Hebei,840000\nHangzhou,China,Zhejiang,2190500\nHannover,Germany,Niedersachsen,514718\nHanoi,Vietnam,Hanoi,1410000\nHanzhong,China,Shaanxi,169930\nHaora (Howrah),India,West Bengali,950435\nHapur,India,Uttar Pradesh,146262\nHarare,Zimbabwe,Harare,1410000\nHarbin,China,Heilongjiang,4289800\nHardwar (Haridwar),India,Uttaranchal,147305\nHargeysa,Somalia,Woqooyi Galbeed,90000\nHarkova [Harkiv],Ukraine,Harkova,1500000\nHartford,United States,Connecticut,121578\nHartlepool,United Kingdom,England,92000\nHassan,India,Karnataka,90803\nHat Yai,Thailand,Songkhla,148632\nHatay (Antakya),Turkey,Hatay,143982\nHathras,India,Uttar Pradesh,113285\nHayward,United States,California,140030\nHazaribag,India,Jharkhand,97712\nHebi,China,Henan,212976\nHebron,Palestine,Hebron,119401\nHeerlen,Netherlands,Limburg,95052\nHefei,China,Anhui,1369100\nHegang,China,Heilongjiang,520000\nHeidelberg,Germany,Baden-Württemberg,139672\nHeilbronn,Germany,Baden-Württemberg,119526\nHelsingborg,Sweden,Skåne län,117737\nHelsinki [Helsingfors],Finland,Newmaa,555474\nHenderson,United States,Nevada,175381\nHengshui,China,Hebei,104269\nHengyang,China,Hunan,487148\nHenzada (Hinthada),Myanmar,Irrawaddy [Ayeyarwad,104700\nHerakleion,Greece,Crete,116178\nHerat,Afghanistan,Herat,186800\nHermosillo,Mexico,Sonora,608697\nHerne,Germany,Nordrhein-Westfalen,175661\nHerson,Ukraine,Herson,353000\nHeyuan,China,Guangdong,120101\nHeze,China,Shandong,189293\nHialeah,United States,Florida,226419\nHidalgo,Mexico,Michoacán de Ocampo,106198\nHidalgo del Parral,Mexico,Chihuahua,100881\nHigashihiroshima,Japan,Hiroshima,119166\nHigashikurume,Japan,Tokyo-to,111666\nHigashimatsuyama,Japan,Saitama,93342\nHigashimurayama,Japan,Tokyo-to,136970\nHigashiosaka,Japan,Osaka,517785\nHikone,Japan,Shiga,105508\nHildesheim,Germany,Niedersachsen,104013\nHimeji,Japan,Hyogo,475167\nHimki,Russian Federation,Moskova,133700\nHims,Syria,Hims,507404\nHindupur,India,Andhra Pradesh,104651\nHino,Japan,Tokyo-to,166770\nHirakata,Japan,Osaka,403151\nHiratsuka,Japan,Kanagawa,254207\nHirosaki,Japan,Aomori,177522\nHiroshima,Japan,Hiroshima,1119117\nHisar (Hissar),India,Haryana,172677\nHitachi,Japan,Ibaragi,196622\nHitachinaka,Japan,Tokyo-to,148006\nHmelnytskyi,Ukraine,Hmelnytskyi,262000\nHo Chi Minh City,Vietnam,Ho Chi Minh City,3980000\nHobart,Australia,Tasmania,126118\nHodeida,Yemen,Hodeida,298500\nHofu,Japan,Yamaguchi,118751\nHohhot,China,Inner Mongolia,916700\nHolguín,Cuba,Holguín,249492\nHollywood,United States,Florida,139357\nHolon,Israel,Tel Aviv,163100\nHong Gai,Vietnam,Quang Ninh,127484\nHonghu,China,Hubei,190772\nHongjiang,China,Hunan,116188\nHoniara,Solomon Islands,Honiara,50100\nHonolulu,United States,Hawaii,371657\nHortolândia,Brazil,São Paulo,135755\nHoshiarpur,India,Punjab,122705\nHospet,India,Karnataka,96322\nHouston,United States,Texas,1953631\nHoya,Japan,Tokyo-to,100313\nHradec Králové,Czech Republic,Východní Cechy,98080\nHsichuh,Taiwan,Taipei,154976\nHsinchu,Taiwan,Hsinchu,361958\nHsinchuang,Taiwan,Taipei,365048\nHsintien,Taiwan,Taipei,263603\nHuadian,China,Jilin,175873\nHuai´an,China,Jiangsu,131149\nHuaibei,China,Anhui,366549\nHuaihua,China,Hunan,126785\nHuainan,China,Anhui,700000\nHuaiyin,China,Jiangsu,239675\nHualien,Taiwan,Hualien,108407\nHuambo,Angola,Huambo,163100\nHuancayo,Peru,Junín,327000\nHuangshan,China,Anhui,102628\nHuangshi,China,Hubei,457601\nHuangyan,China,Zhejiang,89288\nHuánuco,Peru,Huanuco,129688\nHuaying,China,Sichuan,89400\nHubli-Dharwad,India,Karnataka,648298\nHuddersfield,United Kingdom,England,143726\nHue,Vietnam,Thua Thien-Hue,219149\nHuejutla de Reyes,Mexico,Hidalgo,108017\nHuelva,Spain,Andalusia,140583\nHugli-Chinsurah,India,West Bengali,151806\nHuimanguillo,Mexico,Tabasco,158335\nHuixquilucan,Mexico,México,193156\nHuizhou,China,Guangdong,161023\nHunjiang,China,Jilin,482043\nHuntington Beach,United States,California,189594\nHuntsville,United States,Alabama,158216\nHurlingham,Argentina,Buenos Aires,170028\nHuzhou,China,Zhejiang,218071\nHyderabad,India,Andhra Pradesh,2964638\nHyderabad,Pakistan,Sindh,1151274\nHyesan,North Korea,Yanggang,178020\nIasi,Romania,Iasi,348070\nIbadan,Nigeria,Oyo & Osun,1432000\nIbagué,Colombia,Tolima,393664\nIbaraki,Japan,Osaka,261020\nIbarra,Ecuador,Imbabura,130643\nIbb,Yemen,Ibb,103300\nIbirité,Brazil,Minas Gerais,125982\nIca,Peru,Ica,194820\nIchalkaranji,India,Maharashtra,214950\nIchihara,Japan,Chiba,279280\nIchikawa,Japan,Chiba,441893\nIchinomiya,Japan,Aichi,270828\nIchon,South Korea,Kyonggi,155332\nIdfu,Egypt,Qina,94200\nIdlib,Syria,Idlib,91081\nIfe,Nigeria,Oyo & Osun,296800\nIgboho,Nigeria,Oyo & Osun,106800\nIguala de la Independencia,Mexico,Guerrero,123883\nIida,Japan,Nagano,107583\nIjebu-Ode,Nigeria,Ogun,156400\nIkare,Nigeria,Ondo & Ekiti,140800\nIkeda,Japan,Osaka,102710\nIkerre,Nigeria,Ondo & Ekiti,244600\nIkire,Nigeria,Oyo & Osun,123300\nIkirun,Nigeria,Oyo & Osun,181400\nIkoma,Japan,Nara,111645\nIkorodu,Nigeria,Lagos,184900\nIksan,South Korea,Chollabuk,322685\nIla,Nigeria,Oyo & Osun,264000\nIlagan,Philippines,Cagayan Valley,119990\nIlam,Iran,Ilam,126346\nIlan,Taiwan,Ilan,92000\nIlawe-Ekiti,Nigeria,Ondo & Ekiti,184500\nIlesha,Nigeria,Oyo & Osun,378400\nIlhéus,Brazil,Bahia,254970\nIligan,Philippines,Central Mindanao,285061\nIlobu,Nigeria,Oyo & Osun,199000\nIloilo,Philippines,Western Visayas,365820\nIlorin,Nigeria,Kwara & Kogi,475800\nImabari,Japan,Ehime,119357\nImperatriz,Brazil,Maranhão,224564\nImphal,India,Manipur,198535\nImus,Philippines,Southern Tagalog,195482\nInanda,South Africa,KwaZulu-Natal,634065\nInazawa,Japan,Aichi,98746\nInchon,South Korea,Inchon,2559424\nIndaiatuba,Brazil,São Paulo,135968\nIndependence,United States,Missouri,113288\nIndianapolis,United States,Indiana,791926\nIndore,India,Madhya Pradesh,1091674\nInegöl,Turkey,Bursa,90500\nInglewood,United States,California,112580\nIngolstadt,Germany,Baijeri,114826\nIngraj Bazar (English Bazar),India,West Bengali,139204\nInisa,Nigeria,Oyo & Osun,119800\nInnsbruck,Austria,Tiroli,111752\nIpatinga,Brazil,Minas Gerais,206338\nIpoh,Malaysia,Perak,382853\nIpswich,United Kingdom,England,114000\nIquique,Chile,Tarapacá,177892\nIquitos,Peru,Loreto,367000\nIrapuato,Mexico,Guanajuato,440039\nIrbid,Jordan,Irbid,231511\nIrbil,Iraq,Irbil,485968\nIrkutsk,Russian Federation,Irkutsk,593700\nIruma,Japan,Saitama,145922\nIrvine,United States,California,143072\nIrving,United States,Texas,191615\nIsahaya,Japan,Nagasaki,93058\nIse,Japan,Mie,101732\nIse-Ekiti,Nigeria,Ondo & Ekiti,103400\nIsehara,Japan,Kanagawa,98123\nIserlohn,Germany,Nordrhein-Westfalen,99474\nIsesaki,Japan,Gumma,123285\nIseyin,Nigeria,Oyo & Osun,217300\nIshinomaki,Japan,Miyagi,120963\nIskenderun,Turkey,Hatay,153022\nIslamabad,Pakistan,Islamabad,524500\nIsmailia,Egypt,Ismailia,254477\nIsparta,Turkey,Isparta,121911\nIstanbul,Turkey,Istanbul,8787958\nItabira,Brazil,Minas Gerais,102217\nItaboraí,Brazil,Rio de Janeiro,173977\nItabuna,Brazil,Bahia,182148\nItagüí,Colombia,Antioquia,228985\nItaituba,Brazil,Pará,101320\nItajaí,Brazil,Santa Catarina,145197\nItami,Japan,Hyogo,190886\nItapecerica da Serra,Brazil,São Paulo,126672\nItapetininga,Brazil,São Paulo,119391\nItapevi,Brazil,São Paulo,150664\nItaquaquecetuba,Brazil,São Paulo,270874\nItu,Brazil,São Paulo,132736\nItuiutaba,Brazil,Minas Gerais,90507\nItuzaingó,Argentina,Buenos Aires,158197\nIvano-Frankivsk,Ukraine,Ivano-Frankivsk,237000\nIvanovo,Russian Federation,Ivanovo,459200\nIwaki,Japan,Fukushima,361737\nIwakuni,Japan,Yamaguchi,106647\nIwatsuki,Japan,Saitama,110034\nIwo,Nigeria,Oyo & Osun,362000\nIxtapaluca,Mexico,México,293160\nIxtlahuaca,Mexico,México,115548\nIzevsk,Russian Federation,Udmurtia,652800\nIzmajil,Ukraine,Odesa,90000\nIzmir,Turkey,Izmir,2130359\nIzmit (Kocaeli),Turkey,Kocaeli,210068\nIzumi,Japan,Osaka,166979\nIzumisano,Japan,Osaka,92583\nJabaliya,Palestine,North Gaza,113901\nJabalpur,India,Madhya Pradesh,741927\nJaboatão dos Guararapes,Brazil,Pernambuco,558680\nJacareí,Brazil,São Paulo,170356\nJackson,United States,Mississippi,184256\nJacksonville,United States,Florida,735167\nJacobabad,Pakistan,Sind,137700\nJacobina,Brazil,Bahia,96131\nJaén,Spain,Andalusia,109247\nJaffna,Sri Lanka,Northern,149000\nJahrom,Iran,Fars,94200\nJaipur,India,Rajasthan,1458483\nJakarta,Indonesia,Jakarta Raya,9604900\nJakutsk,Russian Federation,Saha (Jakutia),195400\nJalandhar (Jullundur),India,Punjab,509510\nJalgaon,India,Maharashtra,242193\nJalib al-Shuyukh,Kuwait,Hawalli,102178\nJalna,India,Maharashtra,174985\nJamalpur,Bangladesh,Dhaka,103556\nJambi,Indonesia,Jambi,385201\nJamestown,Saint Helena,Saint Helena,1500\nJammu,India,Jammu and Kashmir,214737\nJamnagar,India,Gujarat,341637\nJamshedpur,India,Jharkhand,460577\nJaraguá do Sul,Brazil,Santa Catarina,102580\nJaramana,Syria,Damaskos,138469\nJaranwala,Pakistan,Punjab,103300\nJaroslavl,Russian Federation,Jaroslavl,616700\nJastrzebie-Zdrój,Poland,Slaskie,102294\nJaú,Brazil,São Paulo,109965\nJaunpur,India,Uttar Pradesh,136062\nJaworzno,Poland,Slaskie,97929\nJaya Pura,Indonesia,West Irian,94700\nJedda,Saudi Arabia,Mekka,2046300\nJekaterinburg,Russian Federation,Sverdlovsk,1266300\nJelenia Góra,Poland,Dolnoslaskie,93901\nJelets,Russian Federation,Lipetsk,119400\nJember,Indonesia,East Java,218500\nJena,Germany,Thüringen,99779\nJenakijeve,Ukraine,Donetsk,105000\nJequié,Brazil,Bahia,179128\nJerez de la Frontera,Spain,Andalusia,182660\nJersey City,United States,New Jersey,240055\nJerusalem,Israel,Jerusalem,633700\nJessentuki,Russian Federation,Stavropol,97900\nJessore,Bangladesh,Khulna,139710\nJevpatorija,Ukraine,Krim,112000\nJhang,Pakistan,Punjab,292214\nJhansi,India,Uttar Pradesh,300850\nJhelum,Pakistan,Punjab,145800\nJi-Paraná,Brazil,Rondônia,93346\nJi´an,China,Jiangxi,148583\nJiamusi,China,Heilongjiang,493409\nJiangmen,China,Guangdong,230587\nJiangyin,China,Jiangsu,213659\nJiangyou,China,Sichuan,175753\nJiaohe,China,Jilin,176367\nJiaonan,China,Shandong,121397\nJiaozhou,China,Shandong,153364\nJiaozuo,China,Henan,409100\nJiaxing,China,Zhejiang,211526\nJieyang,China,Guangdong,98531\nJilin,China,Jilin,1040000\nJinan,China,Shandong,2278100\nJinchang,China,Gansu,105287\nJincheng,China,Shanxi,136396\nJingdezhen,China,Jiangxi,281183\nJinhua,China,Zhejiang,144280\nJining,China,Shandong,265248\nJining,China,Inner Mongolia,163552\nJinmen,China,Hubei,160794\nJinxi,China,Liaoning,357052\nJinzhou,China,Liaoning,570000\nJinzhou,China,Liaoning,95761\nJirja,Egypt,Sawhaj,95400\nJiujiang,China,Jiangxi,291187\nJiutai,China,Jilin,180130\nJiutepec,Mexico,Morelos,170428\nJixi,China,Heilongjiang,683885\nJoão Pessoa,Brazil,Paraíba,584029\nJodhpur,India,Rajasthan,666279\nJoetsu,Japan,Niigata,133505\nJohannesburg,South Africa,Gauteng,756653\nJohor Baharu,Malaysia,Johor,328436\nJoinville,Brazil,Santa Catarina,428011\nJokohama [Yokohama],Japan,Kanagawa,3339594\nJoliet,United States,Illinois,106221\nJombang,Indonesia,East Java,92600\nJönköping,Sweden,Jönköpings län,117095\nJos,Nigeria,Plateau & Nassarawa,206300\nJosé Azueta,Mexico,Guerrero,95448\nJosé C. Paz,Argentina,Buenos Aires,221754\nJoškar-Ola,Russian Federation,Marinmaa,249200\nJuárez,Mexico,Chihuahua,1217818\nJuazeiro,Brazil,Bahia,201073\nJuazeiro do Norte,Brazil,Ceará,199636\nJuba,Sudan,Bahr al-Jabal,114980\nJubayl,Saudi Arabia,al-Sharqiya,140800\nJuiz de Fora,Brazil,Minas Gerais,450288\nJuliaca,Peru,Puno,142576\nJunagadh,India,Gujarat,130484\nJunan,China,Shandong,90222\nJundíaí,Brazil,São Paulo,296127\nJuzno-Sahalinsk,Russian Federation,Sahalin,179200\nKabankalan,Philippines,Western Visayas,149769\nKabul,Afghanistan,Kabol,1780000\nKabwe,Zambia,Central,154300\nKadoma,Japan,Osaka,138953\nKaduna,Nigeria,Kaduna,342200\nKaesong,North Korea,Kaesong-si,171500\nKafr al-Dawwar,Egypt,al-Buhayra,231978\nKafr al-Shaykh,Egypt,Kafr al-Shaykh,124819\nKagoshima,Japan,Kagoshima,549977\nKahramanmaras,Turkey,Kahramanmaras,245772\nKaifeng,China,Henan,510000\nKaili,China,Guizhou,113958\nKairouan,Tunisia,Kairouan,113100\nKaiserslautern,Germany,Rheinland-Pfalz,100025\nKaiyuan,China,Liaoning,124219\nKaiyuan,China,Yunnan,91999\nKakamigahara,Japan,Gifu,131831\nKakinada,India,Andhra Pradesh,279980\nKakogawa,Japan,Hyogo,266281\nKalemie,\"Congo, The Democratic Republic of the\",Shaba,101309\nKaliningrad,Russian Federation,Kaliningrad,424400\nKalisz,Poland,Wielkopolskie,106641\nKallithea,Greece,Attika,114233\nKalookan,Philippines,National Capital Reg,1177604\nKaluga,Russian Federation,Kaluga,339300\nKalyan,India,Maharashtra,1014557\nKamagaya,Japan,Chiba,100821\nKamakura,Japan,Kanagawa,167661\nKamalia,Pakistan,Punjab,95300\nKamarhati,India,West Bengali,266889\nKamensk-Uralski,Russian Federation,Sverdlovsk,190600\nKameoka,Japan,Kyoto,92398\nKamjanets-Podilskyi,Ukraine,Hmelnytskyi,109000\nKamoke,Pakistan,Punjab,151000\nKampala,Uganda,Central,890800\nKamyšin,Russian Federation,Volgograd,124600\nKananga,\"Congo, The Democratic Republic of the\",West Kasai,393030\nKanazawa,Japan,Ishikawa,455386\nKanchipuram,India,Tamil Nadu,150100\nKanchrapara,India,West Bengali,100194\nKandy,Sri Lanka,Central,140000\nKanggye,North Korea,Chagang,223410\nKangnung,South Korea,Kang-won,220403\nKangshan,Taiwan,Kaohsiung,92200\nKano,Nigeria,Kano & Jigawa,674100\nKanpur,India,Uttar Pradesh,1874409\nKanpur Cantonment,India,Uttar Pradesh,93109\nKansas City,United States,Missouri,441545\nKansas City,United States,Kansas,146866\nKansk,Russian Federation,Krasnojarsk,107400\nKanton [Guangzhou],China,Guangdong,4256300\nKanuma,Japan,Tochigi,93053\nKaohsiung,Taiwan,Kaohsiung,1475505\nKaolack,Senegal,Kaolack,199000\nKarabük,Turkey,Karabük,118285\nKarachi,Pakistan,Sindh,9269265\nKaraj,Iran,Teheran,940968\nKaraman,Turkey,Karaman,104200\nKarawang,Indonesia,West Java,145000\nKarbala,Iraq,Karbala,296705\nKarimnagar,India,Andhra Pradesh,148583\nKariya,Japan,Aichi,127969\nKarlsruhe,Germany,Baden-Württemberg,277204\nKarnal,India,Haryana,173751\nKars,Turkey,Kars,93000\nKarsi,Uzbekistan,Qashqadaryo,194100\nKashan,Iran,Esfahan,201372\nKashihara,Japan,Nara,124013\nKashiwa,Japan,Chiba,320296\nKashiwazaki,Japan,Niigata,91229\nKassala,Sudan,Kassala,234622\nKassel,Germany,Hessen,196211\nKasuga,Japan,Fukuoka,101344\nKasugai,Japan,Aichi,282348\nKasukabe,Japan,Saitama,201838\nKasur,Pakistan,Punjab,241649\nKataka (Cuttack),India,Orissa,403418\nKathmandu,Nepal,Central,591835\nKatihar,India,Bihar,154367\nKatowice,Poland,Slaskie,345934\nKatsina,Nigeria,Katsina,206500\nKaunas,Lithuania,Kaunas,412639\nKawachinagano,Japan,Osaka,119666\nKawagoe,Japan,Saitama,327211\nKawaguchi,Japan,Saitama,452155\nKawanishi,Japan,Hyogo,149794\nKawasaki,Japan,Kanagawa,1217359\nKayseri,Turkey,Kayseri,475657\nKazan,Russian Federation,Tatarstan,1101000\nKecskemét,Hungary,Bács-Kiskun,105606\nKediri,Indonesia,East Java,253760\nKeelung (Chilung),Taiwan,Keelung,385201\nKelang,Malaysia,Selangor,243355\nKelowna,Canada,British Colombia,89442\nKemerovo,Russian Federation,Kemerovo,492700\nKempton Park,South Africa,Gauteng,442633\nKendari,Indonesia,Sulawesi Tenggara,94800\nKénitra,Morocco,Gharb-Chrarda-Béni H,292600\nKenosha,United States,Wisconsin,89447\nKerman,Iran,Kerman,384991\nKermanshah,Iran,Kermanshah,692986\nKertš,Ukraine,Krim,162000\nKhairpur,Pakistan,Sind,102200\nKhamis Mushayt,Saudi Arabia,Asir,217900\nKhammam,India,Andhra Pradesh,127992\nKhan Yunis,Palestine,Khan Yunis,123175\nKhandwa,India,Madhya Pradesh,145133\nKhanewal,Pakistan,Punjab,133000\nKhanpur,Pakistan,Punjab,117800\nKharagpur,India,West Bengali,177989\nKhartum,Sudan,Khartum,947483\nKhomeynishahr,Iran,Esfahan,165888\nKhon Kaen,Thailand,Khon Kaen,126500\nKhorramabad,Iran,Lorestan,272815\nKhorramshahr,Iran,Khuzestan,105636\nKhouribga,Morocco,Chaouia-Ouardigha,152090\nKhoy,Iran,West Azerbaidzan,148944\nKhujand,Tajikistan,Khujand,161500\nKhulna,Bangladesh,Khulna,663340\nKhuzdar,Pakistan,Baluchistan,93100\nKidapawan,Philippines,Central Mindanao,101205\nKiel,Germany,Schleswig-Holstein,233795\nKielce,Poland,Swietokrzyskie,212383\nKigali,Rwanda,Kigali,286000\nKikwit,\"Congo, The Democratic Republic of the\",Bandundu,182142\nKilis,Turkey,Kilis,118245\nKimberley,South Africa,Northern Cape,197254\nKimchaek,North Korea,Hamgyong P,179000\nKimchon,South Korea,Kyongsangbuk,147027\nKimhae,South Korea,Kyongsangnam,256370\nKimitsu,Japan,Chiba,93216\nKimje,South Korea,Chollabuk,115427\nKinešma,Russian Federation,Ivanovo,100000\nKingston,Jamaica,St. Andrew,103962\nKingston,Norfolk Island,–,800\nKingston upon Hull,United Kingdom,England,262000\nKingstown,Saint Vincent and the Grenadines,St George,17100\nKinshasa,\"Congo, The Democratic Republic of the\",Kinshasa,5064000\nKioto,Japan,Kyoto,1461974\nKirikkale,Turkey,Kirikkale,142044\nKirkuk,Iraq,al-Tamim,418624\nKirov,Russian Federation,Kirov,466200\nKirovo-Tšepetsk,Russian Federation,Kirov,91600\nKirovograd,Ukraine,Kirovograd,265000\nKiryu,Japan,Gumma,118326\nKisangani,\"Congo, The Democratic Republic of the\",Haute-Zaïre,417517\nKisarazu,Japan,Chiba,121967\nKiseljovsk,Russian Federation,Kemerovo,110000\nKishiwada,Japan,Osaka,197276\nKislovodsk,Russian Federation,Stavropol,120400\nKismaayo,Somalia,Jubbada Hoose,90000\nKisumu,Kenya,Nyanza,192733\nKitakyushu,Japan,Fukuoka,1016264\nKitami,Japan,Hokkaido,111295\nKitchener,Canada,Ontario,189959\nKitwe,Zambia,Copperbelt,288600\nKiziltepe,Turkey,Mardin,112000\nKlagenfurt,Austria,Kärnten,91141\nKlaipeda,Lithuania,Klaipeda,202451\nKlaten,Indonesia,Central Java,103300\nKlerksdorp,South Africa,North West,261911\nKlin,Russian Federation,Moskova,90000\nKnoxville,United States,Tennessee,173890\nKobe,Japan,Hyogo,1425139\nKøbenhavn,Denmark,København,495699\nKoblenz,Germany,Rheinland-Pfalz,108003\nKochi,Japan,Kochi,324710\nKodaira,Japan,Tokyo-to,174984\nKofu,Japan,Yamanashi,199753\nKoganei,Japan,Tokyo-to,110969\nKohat,Pakistan,Nothwest Border Prov,125300\nKoje,South Korea,Kyongsangnam,147562\nKökshetau,Kazakstan,North Kazakstan,123400\nKokubunji,Japan,Tokyo-to,106996\nKolhapur,India,Maharashtra,406370\nKollam (Quilon),India,Kerala,139852\nKöln,Germany,Nordrhein-Westfalen,962507\nKolomna,Russian Federation,Moskova,150700\nKolpino,Russian Federation,Pietari,141200\nKolwezi,\"Congo, The Democratic Republic of the\",Shaba,417810\nKomaki,Japan,Aichi,139827\nKomatsu,Japan,Ishikawa,107937\nKomsomolsk-na-Amure,Russian Federation,Habarovsk,291600\nKonan,Japan,Aichi,95521\nKongju,South Korea,Chungchongnam,131229\nKonotop,Ukraine,Sumy,96000\nKonya,Turkey,Konya,628364\nKorba,India,Chhatisgarh,124501\nKorhogo,Côte d’Ivoire,Korhogo,109445\nKoriyama,Japan,Fukushima,330335\nKorla,China,Xinxiang,159344\nKorolev,Russian Federation,Moskova,132400\nKoronadal,Philippines,Southern Mindanao,133786\nKoror,Palau,Koror,12000\nKoshigaya,Japan,Saitama,301446\nKošice,Slovakia,Východné Slovensko,241874\nKostjantynivka,Ukraine,Donetsk,95000\nKostroma,Russian Federation,Kostroma,288100\nKoszalin,Poland,Zachodnio-Pomorskie,112375\nKota,India,Rajasthan,537371\nKota Bharu,Malaysia,Kelantan,219582\nKoudougou,Burkina Faso,Boulkiemdé,105000\nKovrov,Russian Federation,Vladimir,159900\nKowloon and New Kowloon,Hong Kong,Kowloon and New Kowl,1987996\nKoyang,South Korea,Kyonggi,518282\nKragujevac,Yugoslavia,Central Serbia,147305\nKraków,Poland,Malopolskie,738150\nKramatorsk,Ukraine,Donetsk,186000\nKrasnodar,Russian Federation,Krasnodar,639000\nKrasnogorsk,Russian Federation,Moskova,91000\nKrasnojarsk,Russian Federation,Krasnojarsk,875500\nKrasnyi Lutš,Ukraine,Lugansk,101000\nKrefeld,Germany,Nordrhein-Westfalen,241769\nKrementšuk,Ukraine,Pultava,239000\nKrishnanagar,India,West Bengali,121110\nKrugersdorp,South Africa,Gauteng,181503\nKryvyi Rig,Ukraine,Dnipropetrovsk,703000\nKsar el Kebir,Morocco,Tanger-Tétouan,107065\nKuala Lumpur,Malaysia,Wilayah Persekutuan,1297526\nKuala Terengganu,Malaysia,Terengganu,228119\nKuantan,Malaysia,Pahang,199484\nKuching,Malaysia,Sarawak,148059\nKudus,Indonesia,Central Java,95300\nKueishan,Taiwan,,112195\nKukatpalle,India,Andhra Pradesh,185378\nKükon,Uzbekistan,Fargona,190100\nKulti-Barakar,India,West Bengali,108518\nKumagaya,Japan,Saitama,157171\nKumamoto,Japan,Kumamoto,656734\nKumasi,Ghana,Ashanti,385192\nKumbakonam,India,Tamil Nadu,139483\nKumi,South Korea,Kyongsangbuk,311431\nKumo,Nigeria,Bauchi & Gombe,148000\nKunming,China,Yunnan,1829500\nKunpo,South Korea,Kyonggi,235233\nKunsan,South Korea,Chollabuk,266569\nKunshan,China,Jiangsu,102052\nKupang,Indonesia,Nusa Tenggara Timur,129300\nKurashiki,Japan,Okayama,425103\nKure,Japan,Hiroshima,206504\nKurgan,Russian Federation,Kurgan,364700\nKuri,South Korea,Kyonggi,142173\nKurnool,India,Andhra Pradesh,236800\nKursk,Russian Federation,Kursk,443500\nKurume,Japan,Fukuoka,235611\nKusatsu,Japan,Shiga,106232\nKushiro,Japan,Hokkaido,197608\nKusti,Sudan,al-Bahr al-Abyad,173599\nKütahya,Turkey,Kütahya,144761\nKutaisi,Georgia,Imereti,240900\nKuwait,Kuwait,al-Asima,28859\nKuwana,Japan,Mie,106121\nKuytun,China,Xinxiang,118553\nKuznetsk,Russian Federation,Penza,98200\nKwang-yang,South Korea,Chollanam,122052\nKwangju,South Korea,Kwangju,1368341\nKwangmyong,South Korea,Kyonggi,350914\nKyiv,Ukraine,Kiova,2624000\nKyongju,South Korea,Kyongsangbuk,272968\nKyongsan,South Korea,Kyongsangbuk,173746\nKyzyl,Russian Federation,Tyva,101100\nL´Hospitalet de Llobregat,Spain,Katalonia,247986\nLa Ceiba,Honduras,Atlántida,89200\nLa Habana,Cuba,La Habana,2256000\nLa Matanza,Argentina,Buenos Aires,1266461\nLa Paz,Bolivia,La Paz,758141\nLa Paz,Mexico,México,213045\nLa Paz,Mexico,Baja California Sur,196708\nLa Plata,Argentina,Buenos Aires,521936\nLa Rioja,Argentina,La Rioja,138117\nLa Romana,Dominican Republic,La Romana,140204\nLa Serena,Chile,Coquimbo,137409\nLa Spezia,Italy,Liguria,95504\nLadysmith,South Africa,KwaZulu-Natal,89292\nLafayette,United States,Louisiana,110257\nLafia,Nigeria,Plateau & Nassarawa,122500\nLages,Brazil,Santa Catarina,139570\nLagos,Nigeria,Lagos,1518000\nLagos de Moreno,Mexico,Jalisco,127949\nLahore,Pakistan,Punjab,5063499\nLahti,Finland,Päijät-Häme,96921\nLaiwu,China,Shandong,246833\nLaiyang,China,Shandong,137080\nLaizhou,China,Shandong,198664\nLakewood,United States,Colorado,144126\nLalbahadur Nagar,India,Andhra Pradesh,155500\nLalitapur,Nepal,Central,145847\nLambaré,Paraguay,Central,99681\nLancaster,United States,California,118718\nLangfang,China,Hebei,148105\nLansing,United States,Michigan,119128\nLanús,Argentina,Buenos Aires,469735\nLanzhou,China,Gansu,1565800\nLaoag,Philippines,Ilocos,94466\nLaohekou,China,Hubei,123366\nLapu-Lapu,Philippines,Central Visayas,217019\nLaredo,United States,Texas,176576\nLarisa,Greece,Thessalia,113090\nLarkana,Pakistan,Sindh,270366\nLas Heras,Argentina,Mendoza,145823\nLas Margaritas,Mexico,Chiapas,97389\nLas Palmas de Gran Canaria,Spain,Canary Islands,354757\nLas Piñas,Philippines,National Capital Reg,472780\nLas Vegas,United States,Nevada,478434\nLashio (Lasho),Myanmar,Shan,107600\nLatakia,Syria,Latakia,264563\nLatina,Italy,Latium,114099\nLatur,India,Maharashtra,197408\nLauro de Freitas,Brazil,Bahia,109236\nLausanne,Switzerland,Vaud,114500\nLaval,Canada,Québec,330393\nLázaro Cárdenas,Mexico,Michoacán de Ocampo,170878\nLe Havre,France,Champagne-Ardenne,190905\nLe Mans,France,Pays de la Loire,146105\nLe-Cap-Haïtien,Haiti,Nord,102233\nLecce,Italy,Apulia,98208\nLeeds,United Kingdom,England,424194\nLeganés,Spain,Madrid,173163\nLegazpi,Philippines,Bicol,157010\nLegnica,Poland,Dolnoslaskie,109335\nLeicester,United Kingdom,England,294000\nLeiden,Netherlands,Zuid-Holland,117196\nLeipzig,Germany,Saksi,489532\nLeiyang,China,Hunan,130115\nLengshuijiang,China,Hunan,137994\nLeninsk-Kuznetski,Russian Federation,Kemerovo,113800\nLeón,Spain,Castilla and León,139809\nLeón,Mexico,Guanajuato,1133576\nLeón,Nicaragua,León,123865\nLerdo,Mexico,Durango,112272\nLerma,Mexico,México,99714\nLes Abymes,Guadeloupe,Grande-Terre,62947\nLeshan,China,Sichuan,341128\nLeverkusen,Germany,Nordrhein-Westfalen,160841\nLexington-Fayette,United States,Kentucky,260512\nLhasa,China,Tibet,120000\nLhokseumawe,Indonesia,Aceh,109600\nLiangcheng,China,Shandong,156307\nLianyuan,China,Hunan,118858\nLianyungang,China,Jiangsu,354139\nLiaocheng,China,Shandong,207844\nLiaoyang,China,Liaoning,492559\nLiaoyuan,China,Jilin,354141\nLiberec,Czech Republic,Severní Cechy,99155\nLibreville,Gabon,Estuaire,419000\nLida,Belarus,Grodno,101000\nLiège,Belgium,Liège,185639\nLiepaja,Latvia,Liepaja,89439\nLigao,Philippines,Bicol,90603\nLikasi,\"Congo, The Democratic Republic of the\",Shaba,299118\nLiling,China,Hunan,108504\nLille,France,Rhône-Alpes,184657\nLilongwe,Malawi,Lilongwe,435964\nLima,Peru,Lima,6464693\nLimassol,Cyprus,Limassol,154400\nLimeira,Brazil,São Paulo,245497\nLimoges,France,Limousin,133968\nLinchuan,China,Jiangxi,121949\nLincoln,United States,Nebraska,225581\nLinfen,China,Shanxi,187309\nLinhai,China,Zhejiang,90870\nLinhares,Brazil,Espírito Santo,106278\nLinhe,China,Inner Mongolia,133183\nLinköping,Sweden,East Götanmaan län,133168\nLinqing,China,Shandong,123958\nLinyi,China,Shandong,324720\nLinz,Austria,North Austria,188022\nLipa,Philippines,Southern Tagalog,218447\nLipetsk,Russian Federation,Lipetsk,521000\nLisboa,Portugal,Lisboa,563210\nLittle Rock,United States,Arkansas,183133\nLiu´an,China,Anhui,144248\nLiupanshui,China,Guizhou,363954\nLiuzhou,China,Guangxi,610000\nLiverpool,United Kingdom,England,461000\nLivonia,United States,Michigan,100545\nLivorno,Italy,Toscana,161673\nLiyang,China,Jiangsu,109520\nLjubertsy,Russian Federation,Moskova,163900\nLjubljana,Slovenia,Osrednjeslovenska,270986\nLleida (Lérida),Spain,Katalonia,112207\nLobito,Angola,Benguela,130000\nLódz,Poland,Lodzkie,800110\nLogroño,Spain,La Rioja,127093\nLoja,Ecuador,Loja,123875\nLomas de Zamora,Argentina,Buenos Aires,622013\nLomé,Togo,Maritime,375000\nLondon,United Kingdom,England,7285000\nLondon,Canada,Ontario,339917\nLondrina,Brazil,Paraná,432257\nLong Beach,United States,California,461522\nLong Xuyen,Vietnam,An Giang,132681\nLongjing,China,Jilin,139417\nLongkou,China,Shandong,148362\nLongueuil,Canada,Québec,127977\nLongyan,China,Fujian,134481\nLongyearbyen,Svalbard and Jan Mayen,Länsimaa,1438\nLos Angeles,Chile,Bíobío,158215\nLos Angeles,United States,California,3694820\nLos Cabos,Mexico,Baja California Sur,105199\nLos Teques,Venezuela,Miranda,178784\nLoudi,China,Hunan,128418\nLouisville,United States,Kentucky,256231\nLowell,United States,Massachusetts,105167\nLower Hutt,New Zealand,Wellington,98100\nLuanda,Angola,Luanda,2022000\nLuanshya,Zambia,Copperbelt,118100\nLubao,Philippines,Central Luzon,125699\nLubbock,United States,Texas,199564\nLübeck,Germany,Schleswig-Holstein,213326\nLublin,Poland,Lubelskie,356251\nLubumbashi,\"Congo, The Democratic Republic of the\",Shaba,851381\nLucena,Philippines,Southern Tagalog,196075\nLuchou,Taiwan,Taipei,160516\nLucknow,India,Uttar Pradesh,1619115\nLudhiana,India,Punjab,1042740\nLudwigshafen am Rhein,Germany,Rheinland-Pfalz,163771\nLugansk,Ukraine,Lugansk,469000\nLund,Sweden,Skåne län,98948\nLünen,Germany,Nordrhein-Westfalen,92044\nLungtan,Taiwan,Taipei,103088\nLuohe,China,Henan,126438\nLuoyang,China,Henan,760000\nLusaka,Zambia,Lusaka,1317000\nLuton,United Kingdom,England,183000\nLutsk,Ukraine,Volynia,217000\nLuxembourg [Luxemburg/Lëtzebuerg],Luxembourg,Luxembourg,80700\nLuxor,Egypt,Luxor,360503\nLuzhou,China,Sichuan,262892\nLuziânia,Brazil,Goiás,125597\nLviv,Ukraine,Lviv,788000\nLyon,France,Rhône-Alpes,445452\nLysytšansk,Ukraine,Lugansk,116000\nMa´anshan,China,Anhui,305421\nMaastricht,Netherlands,Limburg,122087\nMabalacat,Philippines,Central Luzon,171045\nMacaé,Brazil,Rio de Janeiro,125597\nMacao,Macao,Macau,437500\nMacapá,Brazil,Amapá,256033\nMaceió,Brazil,Alagoas,786288\nMachakos,Kenya,Eastern,116293\nMachala,Ecuador,El Oro,210368\nMachida,Japan,Tokyo-to,364197\nMachilipatnam (Masulipatam),India,Andhra Pradesh,159110\nMacon,United States,Georgia,113336\nMacuspana,Mexico,Tabasco,133795\nMadison,United States,Wisconsin,208054\nMadiun,Indonesia,East Java,171532\nMadrid,Spain,Madrid,2879052\nMadurai,India,Tamil Nadu,977856\nMaebashi,Japan,Gumma,284473\nMagadan,Russian Federation,Magadan,121000\nMagdeburg,Germany,Anhalt Sachsen,235073\nMagé,Brazil,Rio de Janeiro,196147\nMagelang,Indonesia,Central Java,123800\nMagnitogorsk,Russian Federation,Tšeljabinsk,427900\nMahabad,Iran,West Azerbaidzan,107799\nMahajanga,Madagascar,Mahajanga,100807\nMahatškala,Russian Federation,Dagestan,332800\nMahbubnagar,India,Andhra Pradesh,116833\nMaicao,Colombia,La Guajira,108053\nMaidstone,United Kingdom,England,90878\nMaiduguri,Nigeria,Borno & Yobe,320000\nMaikop,Russian Federation,Adygea,167300\nMainz,Germany,Rheinland-Pfalz,183134\nMaizuru,Japan,Kyoto,94784\nMajalaya,Indonesia,West Java,93200\nMakati,Philippines,National Capital Reg,444867\nMakijivka,Ukraine,Donetsk,384000\nMakurdi,Nigeria,Benue,123100\nMalabo,Equatorial Guinea,Bioko,40000\nMalabon,Philippines,National Capital Reg,338855\nMálaga,Spain,Andalusia,530553\nMalang,Indonesia,East Java,716862\nMalasiqui,Philippines,Ilocos,113190\nMalatya,Turkey,Malatya,330312\nMalaybalay,Philippines,Northern Mindanao,123672\nMalayer,Iran,Hamadan,144373\nMale,Maldives,Maale,71000\nMalegaon,India,Maharashtra,342595\nMalita,Philippines,Southern Mindanao,100000\nMalkajgiri,India,Andhra Pradesh,126066\nMallawi,Egypt,al-Minya,119283\nMalmö,Sweden,Skåne län,259579\nMalolos,Philippines,Central Luzon,175291\nMalungon,Philippines,Southern Mindanao,93232\nMalvinas Argentinas,Argentina,Buenos Aires,290335\nMamoutzou,Mayotte,Mamoutzou,12000\nManado,Indonesia,Sulawesi Utara,332288\nManagua,Nicaragua,Managua,959000\nManaus,Brazil,Amazonas,1255049\nManchester,United Kingdom,England,430000\nManchester,United States,New Hampshire,107006\nMandalay,Myanmar,Mandalay,885300\nMandaluyong,Philippines,National Capital Reg,278474\nMandasor,India,Madhya Pradesh,95758\nMandaue,Philippines,Central Visayas,259728\nMandi Bahauddin,Pakistan,Punjab,97300\nMandi Burewala,Pakistan,Punjab,149900\nMandya,India,Karnataka,120265\nMangalore,India,Karnataka,273304\nMango,India,Jharkhand,110024\nManila,Philippines,National Capital Reg,1581082\nManisa,Turkey,Manisa,207148\nManizales,Colombia,Caldas,337580\nMannheim,Germany,Baden-Württemberg,307730\nManta,Ecuador,Manabí,164739\nManukau,New Zealand,Auckland,281800\nManzanillo,Cuba,Granma,109350\nManzanillo,Mexico,Colima,124014\nManzhouli,China,Inner Mongolia,120023\nMaoming,China,Guangdong,178683\nMaputo,Mozambique,Maputo,1018938\nMar del Plata,Argentina,Buenos Aires,512880\nMarabá,Brazil,Pará,167795\nMaracaíbo,Venezuela,Zulia,1304776\nMaracanaú,Brazil,Ceará,162022\nMaracay,Venezuela,Aragua,444443\nMaradi,Niger,Maradi,112965\nMaragheh,Iran,East Azerbaidzan,132318\nMarand,Iran,East Azerbaidzan,96400\nMarawi,Philippines,Central Mindanao,131090\nMarbella,Spain,Andalusia,101144\nMardan,Pakistan,Nothwest Border Prov,244511\nMargilon,Uzbekistan,Fargona,140800\nMaribor,Slovenia,Podravska,115532\nMarikina,Philippines,National Capital Reg,391170\nMarilao,Philippines,Central Luzon,101017\nMarília,Brazil,São Paulo,188691\nMaringá,Brazil,Paraná,286461\nMariupol,Ukraine,Donetsk,490000\nMarkham,Canada,Ontario,189098\nMarl,Germany,Nordrhein-Westfalen,93735\nMaroua,Cameroon,Extrême-Nord,143000\nMarrakech,Morocco,Marrakech-Tensift-Al,621914\nMarseille,France,Provence-Alpes-Côte,798430\nMartínez de la Torre,Mexico,Veracruz,118815\nMarv Dasht,Iran,Fars,103579\nMary,Turkmenistan,Mary,101000\nMasan,South Korea,Kyongsangnam,441242\nMasaya,Nicaragua,Masaya,88971\nMaseru,Lesotho,Maseru,297000\nMashhad,Iran,Khorasan,1887405\nMasjed-e-Soleyman,Iran,Khuzestan,116883\nMasqat,Oman,Masqat,51969\nMata-Utu,Wallis and Futuna,Wallis,1137\nMatadi,\"Congo, The Democratic Republic of the\",Bas-Zaïre,172730\nMatamoros,Mexico,Tamaulipas,416428\nMatamoros,Mexico,Coahuila de Zaragoza,91858\nMatanzas,Cuba,Matanzas,123273\nMataram,Indonesia,Nusa Tenggara Barat,306600\nMataró,Spain,Katalonia,104095\nMathura,India,Uttar Pradesh,226691\nMati,Philippines,Southern Mindanao,105908\nMatola,Mozambique,Maputo,424662\nMatsubara,Japan,Osaka,135010\nMatsudo,Japan,Chiba,461126\nMatsue,Japan,Shimane,149821\nMatsumoto,Japan,Nagano,206801\nMatsusaka,Japan,Mie,123582\nMatsuyama,Japan,Ehime,466133\nMaturín,Venezuela,Monagas,319726\nMauá,Brazil,São Paulo,375055\nMaunath Bhanjan,India,Uttar Pradesh,136697\nMaxixe,Mozambique,Inhambane,93985\nMayagüez,Puerto Rico,Mayagüez,98434\nMazar-e-Sharif,Afghanistan,Balkh,127800\nMazatlán,Mexico,Sinaloa,380265\nMbabane,Swaziland,Hhohho,61000\nMbandaka,\"Congo, The Democratic Republic of the\",Equateur,169841\nMbeya,Tanzania,Mbeya,130800\nMbour,Senegal,Thiès,109300\nMbuji-Mayi,\"Congo, The Democratic Republic of the\",East Kasai,806475\nMcAllen,United States,Texas,106414\nMdantsane,South Africa,Eastern Cape,182639\nMedan,Indonesia,Sumatera Utara,1843919\nMedellín,Colombia,Antioquia,1861265\nMedina,Saudi Arabia,Medina,608300\nMeerut,India,Uttar Pradesh,753778\nMeerut Cantonment,India,Uttar Pradesh,94876\nMeihekou,China,Jilin,209038\nMeikhtila,Myanmar,Mandalay,129700\nMeixian,China,Guangdong,132156\nMejicanos,El Salvador,San Salvador,138800\nMekele,Ethiopia,Tigray,96938\nMekka,Saudi Arabia,Mekka,965700\nMeknès,Morocco,Meknès-Tafilalet,460000\nMelbourne,Australia,Victoria,2865329\nMelipilla,Chile,Santiago,91056\nMelitopol,Ukraine,Zaporizzja,169000\nMemphis,United States,Tennessee,650100\nMendoza,Argentina,Mendoza,123027\nMergui (Myeik),Myanmar,Tenasserim [Tanintha,122700\nMérida,Mexico,Yucatán,703324\nMérida,Venezuela,Mérida,224887\nMerlo,Argentina,Buenos Aires,463846\nMersin (Içel),Turkey,Içel,587212\nMeru,Kenya,Eastern,94947\nMesa,United States,Arizona,396375\nMesquite,United States,Texas,124523\nMessina,Italy,Sisilia,259156\nMetairie,United States,Louisiana,149428\nMetepec,Mexico,México,194265\nMetz,France,Lorraine,123776\nMexicali,Mexico,Baja California,764902\nMexico,Philippines,Central Luzon,109481\nMeycauayan,Philippines,Central Luzon,163037\nMezduretšensk,Russian Federation,Kemerovo,104400\nMiami,United States,Florida,362470\nMiami Beach,United States,Florida,97855\nMiandoab,Iran,West Azerbaidzan,90100\nMianyang,China,Sichuan,262947\nMiaoli,Taiwan,Miaoli,90000\nMiass,Russian Federation,Tšeljabinsk,166200\nMiddlesbrough,United Kingdom,England,145000\nMidland,United States,Texas,98293\nMidnapore (Medinipur),India,West Bengali,125498\nMidsayap,Philippines,Central Mindanao,105760\nMilagro,Ecuador,Guayas,124177\nMilano,Italy,Lombardia,1300977\nMilwaukee,United States,Wisconsin,596974\nMinatitlán,Mexico,Veracruz,152983\nMingäçevir,Azerbaijan,Mingäçevir,93900\nMingora,Pakistan,Nothwest Border Prov,174500\nMinna,Nigeria,Niger,136900\nMinneapolis,United States,Minnesota,382618\nMinoo,Japan,Osaka,127026\nMinsk,Belarus,Horad Minsk,1674000\nMira Bhayandar,India,Maharashtra,175372\nMiraj,India,Maharashtra,125407\nMirpur Khas,Pakistan,Sind,184500\nMiryang,South Korea,Kyongsangnam,121501\nMirzapur-cum-Vindhyachal,India,Uttar Pradesh,169336\nMisato,Japan,Saitama,132957\nMishan,China,Heilongjiang,132744\nMishima,Japan,Shizuoka,109699\nMiskolc,Hungary,Borsod-Abaúj-Zemplén,172357\nMisrata,Libyan Arab Jamahiriya,Misrata,121669\nMission Viejo,United States,California,98049\nMississauga,Canada,Ontario,608072\nMit Ghamr,Egypt,al-Daqahliya,101801\nMitaka,Japan,Tokyo-to,167268\nMito,Japan,Ibaragi,246559\nMitšurinsk,Russian Federation,Tambov,120700\nMixco,Guatemala,Guatemala,209791\nMiyakonojo,Japan,Miyazaki,133183\nMiyazaki,Japan,Miyazaki,303784\nMobara,Japan,Chiba,91664\nMobile,United States,Alabama,198915\nMocuba,Mozambique,Zambézia,124700\nModena,Italy,Emilia-Romagna,176022\nModesto,United States,California,188856\nModinagar,India,Uttar Pradesh,101660\nMoers,Germany,Nordrhein-Westfalen,106837\nMoga,India,Punjab,108304\nMogadishu,Somalia,Banaadir,997000\nMogiljov,Belarus,Mogiljov,356000\nMohammedia,Morocco,Casablanca,154706\nMoji das Cruzes,Brazil,São Paulo,339194\nMoji-Guaçu,Brazil,São Paulo,123782\nMojokerto,Indonesia,East Java,96626\nMokpo,South Korea,Chollanam,247452\nMolodetšno,Belarus,Minsk,97000\nMombasa,Kenya,Coast,461753\nMonaco-Ville,Monaco,–,1234\nMönchengladbach,Germany,Nordrhein-Westfalen,263697\nMonclova,Mexico,Coahuila de Zaragoza,193657\nMonrovia,Liberia,Montserrado,850000\nMons,Belgium,Hainaut,90935\nMonte-Carlo,Monaco,–,13154\nMontería,Colombia,Córdoba,248245\nMonterrey,Mexico,Nuevo León,1108499\nMontes Claros,Brazil,Minas Gerais,286058\nMontevideo,Uruguay,Montevideo,1236000\nMontgomery,United States,Alabama,201568\nMontpellier,France,Languedoc-Roussillon,225392\nMontréal,Canada,Québec,1016376\nMontreuil,France,Île-de-France,90674\nMonywa,Myanmar,Sagaing,138600\nMonza,Italy,Lombardia,119516\nMoradabad,India,Uttar Pradesh,429214\nMoratuwa,Sri Lanka,Western,190000\nMorelia,Mexico,Michoacán de Ocampo,619958\nMorena,India,Madhya Pradesh,147124\nMoreno,Argentina,Buenos Aires,356993\nMoreno Valley,United States,California,142381\nMoriguchi,Japan,Osaka,155941\nMorioka,Japan,Iwate,287353\nMorogoro,Tanzania,Morogoro,117800\nMorón,Argentina,Buenos Aires,349246\nMoroni,Comoros,Njazidja,36000\nMorvi,India,Gujarat,90357\nMoscow,Russian Federation,Moscow (City),8389200\nMoshi,Tanzania,Kilimanjaro,96800\nMossoró,Brazil,Rio Grande do Norte,214901\nMostaganem,Algeria,Mostaganem,115212\nMóstoles,Spain,Madrid,195351\nMosul,Iraq,Ninawa,879000\nMoulmein (Mawlamyine),Myanmar,Mon,307900\nMoundou,Chad,Logone Occidental,99500\nMount Darwin,Zimbabwe,Harare,164362\nMozyr,Belarus,Gomel,110000\nMudanjiang,China,Heilongjiang,570000\nMufulira,Zambia,Copperbelt,123900\nMukatševe,Ukraine,Taka-Karpatia,89000\nMülheim an der Ruhr,Germany,Nordrhein-Westfalen,173895\nMulhouse,France,Alsace,110359\nMultan,Pakistan,Punjab,1182441\nMumbai (Bombay),India,Maharashtra,10500000\nMun-gyong,South Korea,Kyongsangbuk,92239\nMunger (Monghyr),India,Bihar,150112\nMunich [München],Germany,Baijeri,1194560\nMünster,Germany,Nordrhein-Westfalen,264670\nMuntinlupa,Philippines,National Capital Reg,379310\nMurcia,Spain,Murcia,353504\nMuridke,Pakistan,Punjab,108600\nMurmansk,Russian Federation,Murmansk,376300\nMurom,Russian Federation,Vladimir,142400\nMuroran,Japan,Hokkaido,108275\nMurwara (Katni),India,Madhya Pradesh,163431\nMusashino,Japan,Tokyo-to,134426\nMushin,Nigeria,Lagos,333200\nMutare,Zimbabwe,Manicaland,131367\nMuzaffargarh,Pakistan,Punjab,121600\nMuzaffarnagar,India,Uttar Pradesh,240609\nMuzaffarpur,India,Bihar,241107\nMwanza,Tanzania,Mwanza,172300\nMwene-Ditu,\"Congo, The Democratic Republic of the\",East Kasai,137459\nMy Tho,Vietnam,Tien Giang,108404\nMyingyan,Myanmar,Mandalay,103600\nMykolajiv,Ukraine,Mykolajiv,508000\nMymensingh,Bangladesh,Dhaka,188713\nMysore,India,Karnataka,480692\nMytištši,Russian Federation,Moskova,155700\nN´Djaména,Chad,Chari-Baguirmi,530965\nNabereznyje Tšelny,Russian Federation,Tatarstan,514700\nNablus,Palestine,Nablus,100231\nNaçala-Porto,Mozambique,Nampula,158248\nNadiad,India,Gujarat,167051\nNador,Morocco,Oriental,112450\nNaga,Philippines,Bicol,137810\nNagano,Japan,Nagano,361391\nNagaoka,Japan,Niigata,192407\nNagaon,India,Assam,93350\nNagar Coil,India,Tamil Nadu,190084\nNagareyama,Japan,Chiba,147738\nNagasaki,Japan,Nagasaki,432759\nNagoya,Japan,Aichi,2154376\nNagpur,India,Maharashtra,1624752\nNaha,Japan,Okinawa,299851\nNahodka,Russian Federation,Primorje,157700\nNaihati,India,West Bengali,132701\nNairobi,Kenya,Nairobi,2290000\nNajafabad,Iran,Esfahan,178498\nNajran,Saudi Arabia,Najran,91000\nNaju,South Korea,Chollanam,107831\nNakhon Pathom,Thailand,Nakhon Pathom,94100\nNakhon Ratchasima,Thailand,Nakhon Ratchasima,181400\nNakhon Sawan,Thailand,Nakhon Sawan,123800\nNakuru,Kenya,Rift Valley,163927\nNaltšik,Russian Federation,Kabardi-Balkaria,233400\nNam Dinh,Vietnam,Nam Ha,171699\nNamangan,Uzbekistan,Namangan,370500\nNamibe,Angola,Namibe,118200\nNampo,North Korea,Nampo-si,566200\nNampula,Mozambique,Nampula,303346\nNamur,Belgium,Namur,105419\nNamwon,South Korea,Chollabuk,103544\nNamyangju,South Korea,Kyonggi,229060\nNanchang,China,Jiangxi,1691600\nNanchong,China,Sichuan,180273\nNancy,France,Lorraine,103605\nNanded (Nander),India,Maharashtra,275083\nNandyal,India,Andhra Pradesh,119813\nNanking [Nanjing],China,Jiangsu,2870300\nNanning,China,Guangxi,1161800\nNanping,China,Fujian,195064\nNantes,France,Pays de la Loire,270251\nNantong,China,Jiangsu,343341\nNantou,Taiwan,Nantou,104723\nNanyang,China,Henan,243303\nNaogaon,Bangladesh,Rajshahi,101266\nNaperville,United States,Illinois,128358\nNapoli,Italy,Campania,1002619\nNara,Japan,Nara,362812\nNarashino,Japan,Chiba,152849\nNarayanganj,Bangladesh,Dhaka,202134\nNarita,Japan,Chiba,91470\nNarsinghdi,Bangladesh,Dhaka,98342\nNashik (Nasik),India,Maharashtra,656925\nNashville-Davidson,United States,Tennessee,569891\nNassau,Bahamas,New Providence,172000\nNasugbu,Philippines,Southern Tagalog,96113\nNatal,Brazil,Rio Grande do Norte,688955\nNaucalpan de Juárez,Mexico,México,857511\nNavadwip,India,West Bengali,125037\nNavoi,Uzbekistan,Navoi,116300\nNavojoa,Mexico,Sonora,140495\nNavolato,Mexico,Sinaloa,145396\nNavotas,Philippines,National Capital Reg,230403\nNavsari,India,Gujarat,126089\nNawabganj,Bangladesh,Rajshahi,130577\nNawabshah,Pakistan,Sind,183100\nNazilli,Turkey,Aydin,99900\nNazret,Ethiopia,Oromia,127842\nNdola,Zambia,Copperbelt,329200\nNeftejugansk,Russian Federation,Hanti-Mansia,97400\nNeftekamsk,Russian Federation,Baškortostan,115700\nNegombo,Sri Lanka,Western,100000\nNeijiang,China,Sichuan,256012\nNeiva,Colombia,Huila,300052\nNellore,India,Andhra Pradesh,316606\nNepean,Canada,Ontario,115100\nNetanya,Israel,Ha Merkaz,154900\nNeuquén,Argentina,Neuquén,167296\nNeuss,Germany,Nordrhein-Westfalen,149702\nNevinnomyssk,Russian Federation,Stavropol,132600\nNew Bedford,United States,Massachusetts,94780\nNew Bombay,India,Maharashtra,307297\nNew Delhi,India,Delhi,301297\nNew Haven,United States,Connecticut,123626\nNew Orleans,United States,Louisiana,484674\nNew York,United States,New York,8008278\nNewark,United States,New Jersey,273546\nNewcastle,Australia,New South Wales,270324\nNewcastle,South Africa,KwaZulu-Natal,222993\nNewcastle upon Tyne,United Kingdom,England,189150\nNewport,United Kingdom,Wales,139000\nNewport News,United States,Virginia,180150\nNeyagawa,Japan,Osaka,257315\nNeyshabur,Iran,Khorasan,158847\nNeyveli,India,Tamil Nadu,118080\nNezahualcóyotl,Mexico,México,1224924\nNha Trang,Vietnam,Khanh Hoa,221331\nNiamey,Niger,Niamey,420000\nNice,France,Provence-Alpes-Côte,342738\nNicolás Romero,Mexico,México,269393\nNicosia,Cyprus,Nicosia,195000\nNigel,South Africa,Gauteng,96734\nNiigata,Japan,Niigata,497464\nNiihama,Japan,Ehime,127207\nNiiza,Japan,Saitama,147744\nNijmegen,Netherlands,Gelderland,152463\nNikopol,Ukraine,Dnipropetrovsk,149000\nNilópolis,Brazil,Rio de Janeiro,153383\nNîmes,France,Languedoc-Roussillon,133424\nNingbo,China,Zhejiang,1371200\nNiš,Yugoslavia,Central Serbia,175391\nNishinomiya,Japan,Hyogo,397618\nNishio,Japan,Chiba,100032\nNiterói,Brazil,Rio de Janeiro,459884\nNizamabad,India,Andhra Pradesh,241034\nNiznekamsk,Russian Federation,Tatarstan,223400\nNiznevartovsk,Russian Federation,Hanti-Mansia,233900\nNizni Novgorod,Russian Federation,Nizni Novgorod,1357000\nNizni Tagil,Russian Federation,Sverdlovsk,390900\nNkongsamba,Cameroon,Littoral,112454\nNobeoka,Japan,Miyazaki,125547\nNoda,Japan,Chiba,121030\nNogales,Mexico,Sonora,159103\nNoginsk,Russian Federation,Moskova,117200\nNoida,India,Uttar Pradesh,146514\nNojabrsk,Russian Federation,Yamalin Nenetsia,97300\nNonsan,South Korea,Chungchongnam,146619\nNonthaburi,Thailand,Nonthaburi,292100\nNorfolk,United States,Virginia,234403\nNorilsk,Russian Federation,Krasnojarsk,140800\nNorman,United States,Oklahoma,94193\nNorrköping,Sweden,East Götanmaan län,122199\nNorth Barrackpur,India,West Bengali,100513\nNorth Dum Dum,India,West Bengali,149965\nNorth Las Vegas,United States,Nevada,115488\nNorth Shore,New Zealand,Auckland,187700\nNorth York,Canada,Ontario,622632\nNorthampton,United Kingdom,England,196000\nNorwalk,United States,California,103298\nNorwich,United Kingdom,England,124000\nNossa Senhora do Socorro,Brazil,Sergipe,131351\nNottingham,United Kingdom,England,287000\nNouâdhibou,Mauritania,Dakhlet Nouâdhibou,97600\nNouakchott,Mauritania,Nouakchott,667300\nNouméa,New Caledonia,–,76293\nNova Friburgo,Brazil,Rio de Janeiro,170697\nNova Iguaçu,Brazil,Rio de Janeiro,862225\nNovara,Italy,Piemonte,102037\nNovi Sad,Yugoslavia,Vojvodina,179626\nNovo Hamburgo,Brazil,Rio Grande do Sul,239940\nNovokuibyševsk,Russian Federation,Samara,116200\nNovokuznetsk,Russian Federation,Kemerovo,561600\nNovomoskovsk,Russian Federation,Tula,138100\nNovopolotsk,Belarus,Vitebsk,106000\nNovorossijsk,Russian Federation,Krasnodar,203300\nNovošahtinsk,Russian Federation,Rostov-na-Donu,101900\nNovosibirsk,Russian Federation,Novosibirsk,1398800\nNovotroitsk,Russian Federation,Orenburg,109600\nNovotšeboksarsk,Russian Federation,Tšuvassia,123400\nNovotšerkassk,Russian Federation,Rostov-na-Donu,184400\nNovouralsk,Russian Federation,Sverdlovsk,93300\nNovyi Urengoi,Russian Federation,Yamalin Nenetsia,89800\nNowshera,Pakistan,Nothwest Border Prov,89400\nNueva San Salvador,El Salvador,La Libertad,98400\nNuevo Laredo,Mexico,Tamaulipas,310277\nNuku´alofa,Tonga,Tongatapu,22400\nNukus,Uzbekistan,Karakalpakistan,194100\nNumazu,Japan,Shizuoka,211382\nNürnberg,Germany,Baijeri,486628\nNuuk,Greenland,Kitaa,13445\nNyala,Sudan,Darfur al-Janubiya,227183\nNyeri,Kenya,Central,91258\nNyiregyháza,Hungary,Szabolcs-Szatmár-Ber,112419\nOakland,United States,California,399484\nOakville,Canada,Ontario,139192\nOaxaca de Juárez,Mexico,Oaxaca,256848\nObeid,Sudan,Kurdufan al-Shamaliy,229425\nOberhausen,Germany,Nordrhein-Westfalen,222349\nOberholzer,South Africa,Gauteng,164367\nObihiro,Japan,Hokkaido,173685\nObninsk,Russian Federation,Kaluga,108300\nOceanside,United States,California,161029\nOcosingo,Mexico,Chiapas,171495\nOcumare del Tuy,Venezuela,Miranda,97168\nOdawara,Japan,Kanagawa,200171\nOdense,Denmark,Fyn,183912\nOdesa,Ukraine,Odesa,1011000\nOdessa,United States,Texas,89293\nOdintsovo,Russian Federation,Moskova,127400\nOffa,Nigeria,Kwara & Kogi,197200\nOffenbach am Main,Germany,Hessen,116627\nOgaki,Japan,Gifu,151758\nOgbomosho,Nigeria,Oyo & Osun,730000\nOita,Japan,Oita,433401\nOka-Akoko,Nigeria,Ondo & Ekiti,142900\nOkara,Pakistan,Punjab,200901\nOkayama,Japan,Okayama,624269\nOkazaki,Japan,Aichi,328711\nOkinawa,Japan,Okinawa,117748\nOklahoma City,United States,Oklahoma,506132\nOktjabrski,Russian Federation,Baškortostan,111500\nOldbury/Smethwick (Warley),United Kingdom,England,145542\nOldenburg,Germany,Niedersachsen,154125\nOldham,United Kingdom,England,103931\nOleksandrija,Ukraine,Kirovograd,99000\nOlinda,Brazil,Pernambuco,354732\nOlmalik,Uzbekistan,Toskent,114900\nOlomouc,Czech Republic,Severní Morava,102702\nOlongapo,Philippines,Central Luzon,194260\nOlsztyn,Poland,Warminsko-Mazurskie,170904\nOmaha,United States,Nebraska,390007\nOmdurman,Sudan,Khartum,1271403\nOme,Japan,Tokyo-to,139216\nOmiya,Japan,Saitama,441649\nOmsk,Russian Federation,Omsk,1148900\nOmuta,Japan,Fukuoka,142889\nOndo,Nigeria,Ondo & Ekiti,173600\nOngole,India,Andhra Pradesh,100836\nOnitsha,Nigeria,Anambra & Enugu & Eb,371900\nOnomichi,Japan,Hiroshima,93756\nOntario,United States,California,158007\nOpole,Poland,Opolskie,129553\nOradea,Romania,Bihor,222239\nOrai,India,Uttar Pradesh,98640\nOral,Kazakstan,West Kazakstan,195500\nOran,Algeria,Oran,609823\nOrange,United States,California,128821\nOranjestad,Aruba,–,29034\nOrdu,Turkey,Ordu,133642\nÖrebro,Sweden,Örebros län,124207\nOrehovo-Zujevo,Russian Federation,Moskova,124900\nOrenburg,Russian Federation,Orenburg,523600\nOrizaba,Mexico,Veracruz,118488\nOrjol,Russian Federation,Orjol,344500\nOrlando,United States,Florida,185951\nOrléans,France,Centre,113126\nOrmoc,Philippines,Eastern Visayas,154297\nOrša,Belarus,Vitebsk,124000\nOrsk,Russian Federation,Orenburg,273900\nOruro,Bolivia,Oruro,223553\nOsaka,Japan,Osaka,2595674\nOsasco,Brazil,São Paulo,659604\nOsh,Kyrgyzstan,Osh,222700\nOshawa,Canada,Ontario,140173\nOshogbo,Nigeria,Oyo & Osun,476800\nOsijek,Croatia,Osijek-Baranja,104761\nÖskemen,Kazakstan,East Kazakstan,311000\nOslo,Norway,Oslo,508726\nOsmaniye,Turkey,Osmaniye,146003\nOsnabrück,Germany,Niedersachsen,164539\nOsorno,Chile,Los Lagos,141468\nOstrava,Czech Republic,Severní Morava,320041\nOta,Japan,Gumma,145317\nOtaru,Japan,Hokkaido,155784\nOthón P. Blanco (Chetumal),Mexico,Quintana Roo,208014\nOtsu,Japan,Shiga,282070\nOttawa,Canada,Ontario,335277\nOuagadougou,Burkina Faso,Kadiogo,824000\nOujda,Morocco,Oriental,365382\nOulu,Finland,Pohjois-Pohjanmaa,120753\nOurense (Orense),Spain,Galicia,109120\nOurinhos,Brazil,São Paulo,96291\nOvalle,Chile,Coquimbo,94854\nOverland Park,United States,Kansas,149080\nOviedo,Spain,Asturia,200453\nOwo,Nigeria,Ondo & Ekiti,183500\nOxford,United Kingdom,England,144000\nOxnard,United States,California,170358\nOyama,Japan,Tochigi,152820\nOyo,Nigeria,Oyo & Osun,256400\nOzamis,Philippines,Northern Mindanao,110420\nPaarl,South Africa,Western Cape,105768\nPabna,Bangladesh,Rajshahi,103277\nPachuca de Soto,Mexico,Hidalgo,244688\nPadang,Indonesia,Sumatera Barat,534474\nPadang Sidempuan,Indonesia,Sumatera Utara,91200\nPaderborn,Germany,Nordrhein-Westfalen,137647\nPadova,Italy,Veneto,211391\nPagadian,Philippines,Western Mindanao,142515\nPagakku (Pakokku),Myanmar,Magwe [Magway],94800\nPaju,South Korea,Kyonggi,163379\nPak Kret,Thailand,Nonthaburi,126055\nPak Pattan,Pakistan,Punjab,107800\nPalangka Raya,Indonesia,Kalimantan Tengah,99693\nPalayankottai,India,Tamil Nadu,97662\nPalembang,Indonesia,Sumatera Selatan,1222764\nPalermo,Italy,Sisilia,683794\nPalghat (Palakkad),India,Kerala,123289\nPalhoça,Brazil,Santa Catarina,89465\nPali,India,Rajasthan,136842\nPalikir,\"Micronesia, Federated States of\",Pohnpei,8600\nPallavaram,India,Tamil Nadu,111866\nPalma de Mallorca,Spain,Balears,326993\nPalmas,Brazil,Tocantins,121919\nPalmdale,United States,California,116670\nPalmira,Colombia,Valle,226509\nPalu,Indonesia,Sulawesi Tengah,142800\nPamplona [Iruña],Spain,Navarra,180483\nPanabo,Philippines,Southern Mindanao,133950\nPanchiao,Taiwan,Taipei,523850\nPanevezys,Lithuania,Panevezys,133695\nPangkal Pinang,Indonesia,Sumatera Selatan,124000\nPanihati,India,West Bengali,275990\nPanipat,India,Haryana,215218\nPanjin,China,Liaoning,362773\nPánuco,Mexico,Veracruz,90551\nPanzhihua,China,Sichuan,415466\nPapantla,Mexico,Veracruz,170123\nPapeete,French Polynesia,Tahiti,25553\nParadise,United States,Nevada,124682\nParakou,Benin,Borgou,103577\nParamaribo,Suriname,Paramaribo,112000\nParaná,Argentina,Entre Rios,207041\nParanaguá,Brazil,Paraná,126076\nParañaque,Philippines,National Capital Reg,449811\nParbhani,India,Maharashtra,190255\nPardubice,Czech Republic,Východní Cechy,91309\nParis,France,Île-de-France,2125246\nParma,Italy,Emilia-Romagna,168717\nParnaíba,Brazil,Piauí,129756\nParnamirim,Brazil,Rio Grande do Norte,96210\nPasadena,United States,California,141674\nPasadena,United States,Texas,133936\nPasay,Philippines,National Capital Reg,354908\nPasig,Philippines,National Capital Reg,505058\nPasso Fundo,Brazil,Rio Grande do Sul,166343\nPassos,Brazil,Minas Gerais,98570\nPasto,Colombia,Nariño,332396\nPasuruan,Indonesia,East Java,134019\nPatan,India,Gujarat,96109\nPate,Taiwan,Taoyuan,161700\nPaterson,United States,New Jersey,149222\nPathankot,India,Punjab,123930\nPatiala,India,Punjab,238368\nPatna,India,Bihar,917243\nPatos,Brazil,Paraíba,90519\nPatos de Minas,Brazil,Minas Gerais,119262\nPatras,Greece,West Greece,153344\nPaulista,Brazil,Pernambuco,248473\nPaulo Afonso,Brazil,Bahia,97291\nPavlodar,Kazakstan,Pavlodar,300500\nPavlograd,Ukraine,Dnipropetrovsk,127000\nPécs,Hungary,Baranya,157332\nPegu (Bago),Myanmar,Pegu [Bago],190900\nPekalongan,Indonesia,Central Java,301504\nPekan Baru,Indonesia,Riau,438638\nPeking,China,Peking,7472000\nPelotas,Brazil,Rio Grande do Sul,315415\nPemalang,Indonesia,Central Java,103500\nPematang Siantar,Indonesia,Sumatera Utara,203056\nPembroke Pines,United States,Florida,137427\nPénjamo,Mexico,Guanajuato,143927\nPenza,Russian Federation,Penza,532200\nPeoria,United States,Illinois,112936\nPeoria,United States,Arizona,108364\nPercut Sei Tuan,Indonesia,Sumatera Utara,129000\nPereira,Colombia,Risaralda,381725\nPeristerion,Greece,Attika,137288\nPerm,Russian Federation,Perm,1009700\nPerpignan,France,Languedoc-Roussillon,105115\nPerth,Australia,West Australia,1096829\nPerugia,Italy,Umbria,156673\nPervouralsk,Russian Federation,Sverdlovsk,136100\nPesaro,Italy,Marche,88987\nPescara,Italy,Abruzzit,115698\nPeshawar,Pakistan,Nothwest Border Prov,988005\nPetah Tiqwa,Israel,Ha Merkaz,159400\nPetaling Jaya,Malaysia,Selangor,254350\nPetare,Venezuela,Miranda,488868\nPeterborough,United Kingdom,England,156000\nPetrolina,Brazil,Pernambuco,210540\nPetropavl,Kazakstan,North Kazakstan,203500\nPetropavlovsk-Kamtšatski,Russian Federation,Kamtšatka,194100\nPetrópolis,Brazil,Rio de Janeiro,279183\nPetroskoi,Russian Federation,Karjala,282100\nPforzheim,Germany,Baden-Württemberg,117227\nPhan Thiêt,Vietnam,Binh Thuan,114236\nPhiladelphia,United States,Pennsylvania,1517550\nPhnom Penh,Cambodia,Phnom Penh,570155\nPhoenix,United States,Arizona,1321045\nPhyongsong,North Korea,Pyongan N,272934\nPiacenza,Italy,Emilia-Romagna,98384\nPiatra Neamt,Romania,Neamt,125070\nPiedras Negras,Mexico,Coahuila de Zaragoza,127898\nPietermaritzburg,South Africa,KwaZulu-Natal,370190\nPihkova,Russian Federation,Pihkova,201500\nPikine,Senegal,Cap-Vert,855287\nPilar,Argentina,Buenos Aires,113428\nPilibhit,India,Uttar Pradesh,106605\nPimpri-Chinchwad,India,Maharashtra,517083\nPinang,Malaysia,Pulau Pinang,219603\nPinar del Río,Cuba,Pinar del Río,142100\nPindamonhangaba,Brazil,São Paulo,121904\nPinetown,South Africa,KwaZulu-Natal,378810\nPingchen,Taiwan,Taoyuan,188344\nPingdingshan,China,Henan,410775\nPingdu,China,Shandong,150123\nPingliang,China,Gansu,99265\nPingtung,Taiwan,Pingtung,214727\nPingxiang,China,Jiangxi,425579\nPingyi,China,Shandong,89373\nPinhais,Brazil,Paraná,98198\nPinsk,Belarus,Brest,130000\nPiracicaba,Brazil,São Paulo,319104\nPireus,Greece,Attika,182671\nPisa,Italy,Toscana,92379\nPitesti,Romania,Arges,187170\nPittsburgh,United States,Pennsylvania,334563\nPiura,Peru,Piura,325000\nPjatigorsk,Russian Federation,Stavropol,132500\nPlano,United States,Texas,222030\nPleven,Bulgaria,Lovec,121952\nPlock,Poland,Mazowieckie,131011\nPloiesti,Romania,Prahova,251348\nPlovdiv,Bulgaria,Plovdiv,342584\nPlymouth,United Kingdom,England,253000\nPlymouth,Montserrat,Plymouth,2000\nPlzen,Czech Republic,Zapadní Cechy,166759\nPoá,Brazil,São Paulo,89236\nPoços de Caldas,Brazil,Minas Gerais,129683\nPodgorica,Yugoslavia,Montenegro,135000\nPodolsk,Russian Federation,Moskova,194300\nPohang,South Korea,Kyongsangbuk,508899\nPointe-Noire,Congo,Kouilou,500000\nPokhara,Nepal,Western,146318\nPolomolok,Philippines,Southern Mindanao,110709\nPomona,United States,California,149473\nPonce,Puerto Rico,Ponce,186475\nPondicherry,India,Pondicherry,203065\nPondok Aren,Indonesia,West Java,92700\nPondokgede,Indonesia,West Java,263200\nPonta Grossa,Brazil,Paraná,268013\nPontianak,Indonesia,Kalimantan Barat,409632\nPoole,United Kingdom,England,141000\nPopayán,Colombia,Cauca,200719\nPorbandar,India,Gujarat,116671\nPort Elizabeth,South Africa,Eastern Cape,752319\nPort Harcourt,Nigeria,Rivers & Bayelsa,410000\nPort Moresby,Papua New Guinea,National Capital Dis,247000\nPort Said,Egypt,Port Said,469533\nPort Sudan,Sudan,al-Bahr al-Ahmar,308195\nPort-au-Prince,Haiti,Ouest,884472\nPort-Louis,Mauritius,Port-Louis,138200\nPort-of-Spain,Trinidad and Tobago,Port-of-Spain,43396\nPort-Vila,Vanuatu,Shefa,33700\nPortland,United States,Oregon,529121\nPortmore,Jamaica,St. Andrew,99799\nPorto,Portugal,Porto,273060\nPorto Alegre,Brazil,Rio Grande do Sul,1314032\nPorto Velho,Brazil,Rondônia,309750\nPorto-Novo,Benin,Ouémé,194000\nPortoviejo,Ecuador,Manabí,176413\nPortsmouth,United Kingdom,England,190000\nPortsmouth,United States,Virginia,100565\nPoryong,South Korea,Chungchongnam,122604\nPosadas,Argentina,Misiones,201273\nPotchefstroom,South Africa,North West,101817\nPotosí,Bolivia,Potosí,140642\nPotsdam,Germany,Brandenburg,128983\nPouso Alegre,Brazil,Minas Gerais,100028\nPoza Rica de Hidalgo,Mexico,Veracruz,152678\nPoznan,Poland,Wielkopolskie,576899\nPozuelos,Venezuela,Anzoátegui,105690\nPraha,Czech Republic,Hlavní mesto Praha,1181126\nPraia,Cape Verde,São Tiago,94800\nPraia Grande,Brazil,São Paulo,168434\nPrato,Italy,Toscana,172473\nPresidente Prudente,Brazil,São Paulo,185340\nPrešov,Slovakia,Východné Slovensko,93977\nPreston,United Kingdom,England,135000\nPretoria,South Africa,Gauteng,658630\nPriština,Yugoslavia,Kosovo and Metohija,155496\nPrizren,Yugoslavia,Kosovo and Metohija,92303\nProbolinggo,Indonesia,East Java,120770\nProddatur,India,Andhra Pradesh,133914\nProkopjevsk,Russian Federation,Kemerovo,237300\nProme (Pyay),Myanmar,Pegu [Bago],105700\nProvidence,United States,Rhode Island,173618\nProvo,United States,Utah,105166\nPucallpa,Peru,Ucayali,220866\nPuchon,South Korea,Kyonggi,779412\nPudukkottai,India,Tamil Nadu,98619\nPuebla,Mexico,Puebla,1346176\nPueblo,United States,Colorado,102121\nPuente Alto,Chile,Santiago,386236\nPuerto Cabello,Venezuela,Carabobo,187722\nPuerto La Cruz,Venezuela,Anzoátegui,155700\nPuerto Montt,Chile,Los Lagos,152194\nPuerto Princesa,Philippines,Southern Tagalog,161912\nPuerto Vallarta,Mexico,Jalisco,183741\nPultava [Poltava],Ukraine,Pultava,313000\nPune,India,Maharashtra,1566651\nPuno,Peru,Puno,101578\nPunta Arenas,Chile,Magallanes,125631\nPunto Fijo,Venezuela,Falcón,167215\nPuqi,China,Hubei,117264\nPuri,India,Orissa,125199\nPurnea (Purnia),India,Jharkhand,114912\nPurulia,India,Jharkhand,92574\nPurwakarta,Indonesia,West Java,95900\nPurwokerto,Indonesia,Central Java,202500\nPusan,South Korea,Pusan,3804522\nPuškin,Russian Federation,Pietari,92900\nPutian,China,Fujian,91030\nPuyang,China,Henan,175988\nPyongtaek,South Korea,Kyonggi,312927\nPyongyang,North Korea,Pyongyang-si,2484000\nQaemshahr,Iran,Mazandaran,143286\nQalyub,Egypt,al-Qalyubiya,97200\nQandahar,Afghanistan,Qandahar,237500\nQaraghandy,Kazakstan,Qaraghandy,436900\nQaramay,China,Xinxiang,197602\nQarchak,Iran,Teheran,142690\nQashqar,China,Xinxiang,174570\nQazvin,Iran,Qazvin,291117\nQianjiang,China,Hubei,205504\nQidong,China,Jiangsu,126872\nQina,Egypt,Qina,171275\nQingdao,China,Shandong,2596000\nQingyuan,China,Guangdong,164641\nQingzhou,China,Shandong,128258\nQinhuangdao,China,Hebei,364972\nQinzhou,China,Guangxi,114586\nQiqihar,China,Heilongjiang,1070000\nQitaihe,China,Heilongjiang,214957\nQods,Iran,Teheran,138278\nQom,Iran,Qom,777677\nQomsheh,Iran,Esfahan,89800\nQostanay,Kazakstan,Qostanay,221400\nQuanzhou,China,Fujian,185154\nQuébec,Canada,Québec,167264\nQueimados,Brazil,Rio de Janeiro,115020\nQuelimane,Mozambique,Zambézia,150116\nQuerétaro,Mexico,Querétaro de Arteaga,639839\nQuetta,Pakistan,Baluchistan,560307\nQuetzaltenango,Guatemala,Quetzaltenango,90801\nQuevedo,Ecuador,Los Ríos,129631\nQuezon,Philippines,National Capital Reg,2173831\nQuilmes,Argentina,Buenos Aires,559249\nQuilpué,Chile,Valparaíso,118857\nQuito,Ecuador,Pichincha,1573458\nQujing,China,Yunnan,178669\nQutubullapur,India,Andhra Pradesh,105380\nQuy Nhon,Vietnam,Binh Dinh,163385\nQuzhou,China,Zhejiang,112373\nQyzylorda,Kazakstan,Qyzylorda,157400\nRabat,Morocco,Rabat-Salé-Zammour-Z,623457\nRach Gia,Vietnam,Kien Giang,141132\nRadom,Poland,Mazowieckie,232262\nRae Bareli,India,Uttar Pradesh,129904\nRafah,Palestine,Rafah,92020\nRafsanjan,Iran,Kerman,98300\nRahim Yar Khan,Pakistan,Punjab,228479\nRaichur,India,Karnataka,157551\nRaiganj,India,West Bengali,151045\nRaigarh,India,Chhatisgarh,89166\nRaipur,India,Chhatisgarh,438639\nRaj Nandgaon,India,Chhatisgarh,125371\nRajahmundry,India,Andhra Pradesh,324851\nRajapalaiyam,India,Tamil Nadu,114202\nRajkot,India,Gujarat,559407\nRajshahi,Bangladesh,Rajshahi,294056\nRaleigh,United States,North Carolina,276093\nRamagundam,India,Andhra Pradesh,214384\nRamat Gan,Israel,Tel Aviv,126900\nRâmnicu Vâlcea,Romania,Vâlcea,119741\nRampur,India,Uttar Pradesh,243742\nRancagua,Chile,O´Higgins,212977\nRanchi,India,Jharkhand,599306\nRancho Cucamonga,United States,California,127743\nRandburg,South Africa,Gauteng,341288\nRandfontein,South Africa,Gauteng,120838\nRangoon (Yangon),Myanmar,Rangoon [Yangon],3361700\nRangpur,Bangladesh,Rajshahi,191398\nRasht,Iran,Gilan,417748\nRatingen,Germany,Nordrhein-Westfalen,90951\nRatlam,India,Madhya Pradesh,183375\nRaurkela,India,Orissa,215489\nRaurkela Civil Township,India,Orissa,140408\nRavenna,Italy,Emilia-Romagna,138418\nRawalpindi,Pakistan,Punjab,1406214\nReading,United Kingdom,England,148000\nRecife,Brazil,Pernambuco,1378087\nRecklinghausen,Germany,Nordrhein-Westfalen,125022\nRegensburg,Germany,Baijeri,125236\nReggio di Calabria,Italy,Calabria,179617\nReggio nell´ Emilia,Italy,Emilia-Romagna,143664\nRegina,Canada,Saskatchewan,180400\nRehovot,Israel,Ha Merkaz,90300\nReims,France,Nord-Pas-de-Calais,187206\nRemscheid,Germany,Nordrhein-Westfalen,120125\nRennes,France,Haute-Normandie,206229\nReno,United States,Nevada,180480\nRenqiu,China,Hebei,114256\nResende,Brazil,Rio de Janeiro,100627\nResistencia,Argentina,Chaco,229212\nResita,Romania,Caras-Severin,93976\nReutlingen,Germany,Baden-Württemberg,110343\nRewa,India,Madhya Pradesh,128981\nReykjavík,Iceland,Höfuðborgarsvæði,109184\nReynosa,Mexico,Tamaulipas,419776\nRibeirão das Neves,Brazil,Minas Gerais,232685\nRibeirão Pires,Brazil,São Paulo,108121\nRibeirão Preto,Brazil,São Paulo,473276\nRichmond,Canada,British Colombia,148867\nRichmond,United States,Virginia,197790\nRichmond,United States,California,94100\nRichmond Hill,Canada,Ontario,116428\nRiga,Latvia,Riika,764328\nRijeka,Croatia,Primorje-Gorski Kota,167964\nRimini,Italy,Emilia-Romagna,131062\nRio Branco,Brazil,Acre,259537\nRío Bravo,Mexico,Tamaulipas,103901\nRio Claro,Brazil,São Paulo,163551\nRío Cuarto,Argentina,Córdoba,134355\nRio de Janeiro,Brazil,Rio de Janeiro,5598953\nRio Grande,Brazil,Rio Grande do Sul,182222\nRio Verde,Brazil,Goiás,107755\nRíobamba,Ecuador,Chimborazo,123163\nRishon Le Ziyyon,Israel,Ha Merkaz,188200\nRishra,India,West Bengali,102649\nRiverside,United States,California,255166\nRivne,Ukraine,Rivne,245000\nRiyadh,Saudi Arabia,Riyadh,3324000\nRizhao,China,Shandong,185048\nRjazan,Russian Federation,Rjazan,529900\nRoad Town,\"Virgin Islands, British\",Tortola,8000\nRoanoke,United States,Virginia,93357\nRochdale,United Kingdom,England,94313\nRochester,United States,New York,219773\nRockford,United States,Illinois,150115\nRodriguez (Montalban),Philippines,Southern Tagalog,115167\nRohtak,India,Haryana,233400\nRoma,Italy,Latium,2643581\nRondonópolis,Brazil,Mato Grosso,155115\nRoodepoort,South Africa,Gauteng,279340\nRosario,Argentina,Santa Fé,907718\nRoseau,Dominica,St George,16243\nRostock,Germany,Mecklenburg-Vorpomme,203279\nRostov-na-Donu,Russian Federation,Rostov-na-Donu,1012700\nRotherham,United Kingdom,England,121380\nRotterdam,Netherlands,Zuid-Holland,593321\nRoubaix,France,Nord-Pas-de-Calais,96984\nRouen,France,Haute-Normandie,106592\nRoxas,Philippines,Western Visayas,126352\nRubtsovsk,Russian Federation,Altai,162600\nRuda Slaska,Poland,Slaskie,159665\nRudnyy,Kazakstan,Qostanay,109500\nRufisque,Senegal,Cap-Vert,150000\nRui´an,China,Zhejiang,156468\nRuse,Bulgaria,Ruse,166467\nRustavi,Georgia,Kvemo Kartli,155400\nRustenburg,South Africa,North West,97008\nRybinsk,Russian Federation,Jaroslavl,239600\nRybnik,Poland,Slaskie,144582\nRzeszów,Poland,Podkarpackie,162049\nSaanich,Canada,British Colombia,101388\nSaarbrücken,Germany,Saarland,183836\nSabadell,Spain,Katalonia,184859\nSabará,Brazil,Minas Gerais,107781\nSabzevar,Iran,Khorasan,170738\nSachon,South Korea,Kyongsangnam,113494\nSacramento,United States,California,407018\nSadiqabad,Pakistan,Punjab,141500\nSafi,Morocco,Doukkala-Abda,262300\nSaga,Japan,Saga,170034\nSagamihara,Japan,Kanagawa,586300\nSagar,India,Madhya Pradesh,195346\nSagay,Philippines,Western Visayas,129765\nSaharanpur,India,Uttar Pradesh,374945\nSahiwal,Pakistan,Punjab,207388\nŠahty,Russian Federation,Rostov-na-Donu,221800\nSaidpur,Bangladesh,Rajshahi,96777\nSaint Catharines,Canada,Ontario,136216\nSaint George,Bermuda,Saint George´s,1800\nSaint George´s,Grenada,St George,4621\nSaint Helens,United Kingdom,England,106293\nSaint Helier,United Kingdom,Jersey,27523\nSaint John´s,Antigua and Barbuda,St John,24000\nSaint John´s,Canada,Newfoundland,101936\nSaint Louis,United States,Missouri,348189\nSaint Paul,United States,Minnesota,287151\nSaint Petersburg,United States,Florida,248232\nSaint-Denis,Réunion,Saint-Denis,131480\nSaint-Louis,Senegal,Saint-Louis,132400\nSaint-Pierre,Saint Pierre and Miquelon,Saint-Pierre,5808\nSakado,Japan,Saitama,98221\nSakai,Japan,Osaka,797735\nSakarya (Adapazari),Turkey,Sakarya,190641\nSakata,Japan,Yamagata,101651\nSakura,Japan,Chiba,168072\nSalala,Oman,Zufar,131813\nSalamanca,Spain,Castilla and León,158720\nSalamanca,Mexico,Guanajuato,226864\nSalatiga,Indonesia,Central Java,103000\nSalavat,Russian Federation,Baškortostan,156800\nSalé,Morocco,Rabat-Salé-Zammour-Z,504420\nSalem,India,Tamil Nadu,366712\nSalem,United States,Oregon,136924\nSalerno,Italy,Campania,142055\nSalinas,United States,California,151060\nSalt Lake City,United States,Utah,181743\nSalta,Argentina,Salta,367550\nSaltillo,Mexico,Coahuila de Zaragoza,577352\nSalto,Brazil,São Paulo,96348\nSalvador,Brazil,Bahia,2302832\nSalvatierra,Mexico,Guanajuato,94322\nSalzburg,Austria,Salzburg,144247\nSalzgitter,Germany,Niedersachsen,112934\nSamara,Russian Federation,Samara,1156100\nSamarinda,Indonesia,Kalimantan Timur,399175\nSamarkand,Uzbekistan,Samarkand,361800\nSambalpur,India,Orissa,131138\nSambhal,India,Uttar Pradesh,150869\nSamsun,Turkey,Samsun,339871\nSan Andrés Tuxtla,Mexico,Veracruz,142251\nSan Antonio,United States,Texas,1144646\nSan Bernardino,United States,California,185401\nSan Bernardo,Chile,Santiago,241910\nSan Buenaventura,United States,California,100916\nSan Carlos,Philippines,Ilocos,154264\nSan Carlos,Philippines,Western Visayas,118259\nSan Cristóbal,Venezuela,Táchira,319373\nSan Cristóbal de las Casas,Mexico,Chiapas,132317\nSan Diego,United States,California,1223400\nSan Felipe,Mexico,Guanajuato,95305\nSan Felipe,Venezuela,Yaracuy,90940\nSan Felipe de Puerto Plata,Dominican Republic,Puerto Plata,89423\nSan Felipe del Progreso,Mexico,México,177330\nSan Fernando,Argentina,Buenos Aires,153036\nSan Fernando,Philippines,Central Luzon,221857\nSan Fernando,Philippines,Ilocos,102082\nSan Fernando de Apure,Venezuela,Apure,93809\nSan Fernando del Valle de Cata,Argentina,Catamarca,134935\nSan Francisco,United States,California,776733\nSan Francisco de Macorís,Dominican Republic,Duarte,108485\nSan Francisco del Rincón,Mexico,Guanajuato,100149\nSan Isidro,Argentina,Buenos Aires,306341\nSan Jose,Philippines,Southern Tagalog,111009\nSan Jose,Philippines,Central Luzon,108254\nSan Jose,United States,California,894943\nSan José,Costa Rica,San José,339131\nSan José del Monte,Philippines,Central Luzon,315807\nSan Juan,Argentina,San Juan,119152\nSan Juan,Puerto Rico,San Juan,434374\nSan Juan Bautista Tuxtepec,Mexico,Oaxaca,133675\nSan Juan del Monte,Philippines,National Capital Reg,117680\nSan Juan del Río,Mexico,Querétaro,179300\nSan Lorenzo,Paraguay,Central,133395\nSan Luis,Argentina,San Luis,110136\nSan Luis de la Paz,Mexico,Guanajuato,96763\nSan Luis Potosí,Mexico,San Luis Potosí,669353\nSan Luis Río Colorado,Mexico,Sonora,145276\nSan Marino,San Marino,San Marino,2294\nSan Martín Texmelucan,Mexico,Puebla,121093\nSan Mateo,Philippines,Southern Tagalog,135603\nSan Mateo,United States,California,91799\nSan Miguel,Argentina,Buenos Aires,248700\nSan Miguel,El Salvador,San Miguel,127696\nSan Miguel,Philippines,Central Luzon,123824\nSan Miguel de Tucumán,Argentina,Tucumán,470809\nSan Miguelito,Panama,San Miguelito,315382\nSan Nicolás de los Arroyos,Argentina,Buenos Aires,119302\nSan Nicolás de los Garza,Mexico,Nuevo León,495540\nSan Pablo,Philippines,Southern Tagalog,207927\nSan Pedro,Philippines,Southern Tagalog,231403\nSan Pedro Cholula,Mexico,Puebla,99734\nSan Pedro de la Paz,Chile,Bíobío,91684\nSan Pedro de Macorís,Dominican Republic,San Pedro de Macorís,124735\nSan Pedro Garza García,Mexico,Nuevo León,126147\nSan Pedro Sula,Honduras,Cortés,383900\nSan Rafael,Argentina,Mendoza,94651\nSan Salvador,El Salvador,San Salvador,415346\nSan Salvador de Jujuy,Argentina,Jujuy,178748\nSanaa,Yemen,Sanaa,503600\nSanandaj,Iran,Kordestan,277808\nSanchung,Taiwan,Taipei,380084\nSancti-Spíritus,Cuba,Sancti-Spíritus,100751\nSanda,Japan,Hyogo,105643\nSandakan,Malaysia,Sabah,125841\nSandy,United States,Utah,101853\nSangju,South Korea,Kyongsangbuk,124116\nSangli,India,Maharashtra,193197\nSanliurfa,Turkey,Sanliurfa,405905\nSanmenxia,China,Henan,120523\nSanming,China,Fujian,160691\nSanta Ana,El Salvador,Santa Ana,139389\nSanta Ana,United States,California,337977\nSanta Ana de Coro,Venezuela,Falcón,185766\nSanta Bárbara d´Oeste,Brazil,São Paulo,171657\nSanta Catarina,Mexico,Nuevo León,226573\nSanta Clara,Cuba,Villa Clara,207350\nSanta Clara,United States,California,102361\nSanta Clarita,United States,California,151088\nSanta Coloma de Gramenet,Spain,Katalonia,120802\nSanta Cruz,Philippines,Southern Tagalog,92694\nSanta Cruz de la Sierra,Bolivia,Santa Cruz,935361\nSanta Cruz de Tenerife,Spain,Canary Islands,213050\nSanta Cruz do Sul,Brazil,Rio Grande do Sul,106734\nSanta Fé,Argentina,Santa Fé,353063\nSanta Luzia,Brazil,Minas Gerais,164704\nSanta Maria,Brazil,Rio Grande do Sul,238473\nSanta Maria,Philippines,Central Luzon,144282\nSanta Marta,Colombia,Magdalena,359147\nSanta Monica,United States,California,91084\nSanta Rita,Brazil,Paraíba,113135\nSanta Rosa,Philippines,Southern Tagalog,185633\nSanta Rosa,United States,California,147595\nSantafé de Bogotá,Colombia,Santafé de Bogotá,6260862\nSantana do Livramento,Brazil,Rio Grande do Sul,91779\nSantander,Spain,Cantabria,184165\nSantarém,Brazil,Pará,241771\nSantiago,Philippines,Cagayan Valley,110531\nSantiago de Chile,Chile,Santiago,4703954\nSantiago de Compostela,Spain,Galicia,93745\nSantiago de Cuba,Cuba,Santiago de Cuba,433180\nSantiago de los Caballeros,Dominican Republic,Santiago,365463\nSantiago del Estero,Argentina,Santiago del Estero,189947\nSantiago Ixcuintla,Mexico,Nayarit,95311\nSantipur,India,West Bengali,109956\nSanto André,Brazil,São Paulo,630073\nSanto Domingo de Guzmán,Dominican Republic,Distrito Nacional,1609966\nSanto Domingo de los Colorados,Ecuador,Pichincha,202111\nSantos,Brazil,São Paulo,408748\nSanya,China,Hainan,102820\nSão Bernardo do Campo,Brazil,São Paulo,723132\nSão Caetano do Sul,Brazil,São Paulo,133321\nSão Carlos,Brazil,São Paulo,187122\nSão Gonçalo,Brazil,Rio de Janeiro,869254\nSão João de Meriti,Brazil,Rio de Janeiro,440052\nSão José,Brazil,Santa Catarina,155105\nSão José de Ribamar,Brazil,Maranhão,98318\nSão José do Rio Preto,Brazil,São Paulo,351944\nSão José dos Campos,Brazil,São Paulo,515553\nSão José dos Pinhais,Brazil,Paraná,196884\nSão Leopoldo,Brazil,Rio Grande do Sul,189258\nSão Lourenço da Mata,Brazil,Pernambuco,91999\nSão Luís,Brazil,Maranhão,837588\nSão Paulo,Brazil,São Paulo,9968485\nSão Tomé,Sao Tome and Principe,Aqua Grande,49541\nSão Vicente,Brazil,São Paulo,286848\nSapele,Nigeria,Edo & Delta,139200\nSapporo,Japan,Hokkaido,1790886\nSapucaia do Sul,Brazil,Rio Grande do Sul,120217\nSaqqez,Iran,Kordestan,115394\nSarajevo,Bosnia and Herzegovina,Federaatio,360000\nSaransk,Russian Federation,Mordva,314800\nSarapul,Russian Federation,Udmurtia,105700\nSaratov,Russian Federation,Saratov,874000\nSargodha,Pakistan,Punjab,455360\nSari,Iran,Mazandaran,195882\nSariaya,Philippines,Southern Tagalog,114568\nSariwon,North Korea,Hwanghae P,254146\nSasaram,India,Bihar,98220\nSasebo,Japan,Nagasaki,244240\nSaskatoon,Canada,Saskatchewan,193647\nSassari,Italy,Sardinia,120803\nSatara,India,Maharashtra,95133\nSatna,India,Madhya Pradesh,156630\nSatu Mare,Romania,Satu Mare,130059\nSavannah,United States,Georgia,131510\nSavannakhet,Laos,Savannakhet,96652\nSaveh,Iran,Qom,111245\nSawangan,Indonesia,West Java,91100\nSawhaj,Egypt,Sawhaj,170125\nSayama,Japan,Saitama,162472\nScarborough,Canada,Ontario,594501\nSchaan,Liechtenstein,Schaan,5346\nSchaerbeek,Belgium,Bryssel,105692\nSchwerin,Germany,Mecklenburg-Vorpomme,102878\nScottsdale,United States,Arizona,202705\nSeattle,United States,Washington,563374\nSecunderabad,India,Andhra Pradesh,167461\nSekondi-Takoradi,Ghana,Western,103653\nSelayang Baru,Malaysia,Selangor,124228\nSemarang,Indonesia,Central Java,1104405\nSemey,Kazakstan,East Kazakstan,269600\nSemnan,Iran,Semnan,91045\nSendai,Japan,Miyagi,989975\nSeoul,South Korea,Seoul,9981619\nSerampore,India,West Bengali,137028\nSerang,Indonesia,West Java,122400\nSerekunda,Gambia,Kombo St Mary,102600\nSeremban,Malaysia,Negeri Sembilan,182869\nSergijev Posad,Russian Federation,Moskova,111100\nSerov,Russian Federation,Sverdlovsk,100400\nSerpuhov,Russian Federation,Moskova,132000\nSerra,Brazil,Espírito Santo,302666\nSerravalle,San Marino,Serravalle/Dogano,4802\nSertãozinho,Brazil,São Paulo,98140\nSete Lagoas,Brazil,Minas Gerais,182984\nSétif,Algeria,Sétif,179055\nSeto,Japan,Aichi,130470\nSettat,Morocco,Chaouia-Ouardigha,96200\nSevastopol,Ukraine,Krim,348000\nSeverodvinsk,Russian Federation,Arkangeli,229300\nSeversk,Russian Federation,Tomsk,118600\nSevilla,Spain,Andalusia,701927\nSfax,Tunisia,Sfax,257800\nShagamu,Nigeria,Ogun,117200\nShah Alam,Malaysia,Selangor,102019\nShahjahanpur,India,Uttar Pradesh,237713\nShahr-e Kord,Iran,Chaharmahal va Bakht,100477\nShahrud,Iran,Semnan,104765\nShaki,Nigeria,Oyo & Osun,174500\nShambajinagar (Aurangabad),India,Maharashtra,573272\nShanghai,China,Shanghai,9696300\nShangqiu,China,Henan,164880\nShangrao,China,Jiangxi,132455\nShangzi,China,Heilongjiang,215373\nShantou,China,Guangdong,580000\nShanwei,China,Guangdong,107847\nShaoguan,China,Guangdong,350043\nShaowu,China,Fujian,90286\nShaoxing,China,Zhejiang,179818\nShaoyang,China,Hunan,247227\nSharja,United Arab Emirates,Sharja,320095\nSharq al-Nil,Sudan,Khartum,700887\nShashi,China,Hubei,281352\nSheffield,United Kingdom,England,431607\nSheikhupura,Pakistan,Punjab,271875\nShenyang,China,Liaoning,4265200\nShenzhen,China,Guangdong,950500\nShibin al-Kawm,Egypt,al-Minufiya,159909\nShihezi,China,Xinxiang,299676\nShihung,South Korea,Kyonggi,133443\nShijiazhuang,China,Hebei,2041500\nShikarpur,Pakistan,Sind,133300\nShillong,India,Meghalaya,131719\nShimizu,Japan,Shizuoka,239123\nShimoga,India,Karnataka,179258\nShimonoseki,Japan,Yamaguchi,257263\nShiraz,Iran,Fars,1053025\nShishou,China,Hubei,104571\nShivapuri,India,Madhya Pradesh,108277\nShiyan,China,Hubei,273786\nShizuishan,China,Ningxia,257862\nShizuoka,Japan,Shizuoka,473854\nShomolu,Nigeria,Lagos,147700\nShreveport,United States,Louisiana,200145\nShuangcheng,China,Heilongjiang,142659\nShuangyashan,China,Heilongjiang,386081\nShubra al-Khayma,Egypt,al-Qalyubiya,870716\nShulin,Taiwan,Taipei,151260\nShymkent,Kazakstan,South Kazakstan,360100\nSialkot,Pakistan,Punjab,417597\nŠiauliai,Lithuania,Šiauliai,146563\nSibiu,Romania,Sibiu,169611\nSibu,Malaysia,Sarawak,126381\nSidi Bel Abbès,Algeria,Sidi Bel Abbès,153106\nSiegen,Germany,Nordrhein-Westfalen,109225\nSiem Reap,Cambodia,Siem Reap,105100\nSiirt,Turkey,Siirt,107100\nSikar,India,Rajasthan,148272\nSilang,Philippines,Southern Tagalog,156137\nSilao,Mexico,Guanajuato,134037\nSilay,Philippines,Western Visayas,107722\nSilchar,India,Assam,115483\nSiliguri (Shiliguri),India,West Bengali,216950\nSimferopol,Ukraine,Krim,339000\nSimi Valley,United States,California,111351\nSincelejo,Colombia,Sucre,220704\nSingapore,Singapore,–,4017733\nSinuiju,North Korea,Pyongan P,326011\nSioux Falls,United States,South Dakota,123975\nSiping,China,Jilin,317223\nSirajganj,Bangladesh,Rajshahi,99669\nSirjan,Iran,Kerman,135024\nSirsa,India,Haryana,125000\nSitapur,India,Uttar Pradesh,121842\nSittwe (Akyab),Myanmar,Rakhine,137600\nSivas,Turkey,Sivas,246642\nSjeverodonetsk,Ukraine,Lugansk,127000\nSkikda,Algeria,Skikda,128747\nSkopje,Macedonia,Skopje,444299\nSliven,Bulgaria,Burgas,105530\nSlough,United Kingdom,England,112000\nSlovjansk,Ukraine,Donetsk,127000\nSlupsk,Poland,Pomorskie,102370\nSmolensk,Russian Federation,Smolensk,353400\nSoacha,Colombia,Cundinamarca,272058\nSobral,Brazil,Ceará,146005\nSofija,Bulgaria,Grad Sofija,1122302\nSogamoso,Colombia,Boyacá,107728\nSohumi,Georgia,Abhasia [Aphazeti],111700\nSoka,Japan,Saitama,222768\nSokoto,Nigeria,Sokoto & Kebbi & Zam,204900\nSolapur (Sholapur),India,Maharashtra,604215\nSoledad,Colombia,Atlántico,295058\nSoledad de Graciano Sánchez,Mexico,San Luis Potosí,179956\nSoligorsk,Belarus,Minsk,101000\nSolihull,United Kingdom,England,94531\nSolikamsk,Russian Federation,Perm,106000\nSolingen,Germany,Nordrhein-Westfalen,165583\nSongkhla,Thailand,Songkhla,94900\nSongnam,South Korea,Kyonggi,869094\nSonipat (Sonepat),India,Haryana,143922\nSorocaba,Brazil,São Paulo,466823\nSorsogon,Philippines,Bicol,92512\nSosan,South Korea,Chungchongnam,134746\nSoshanguve,South Africa,Gauteng,242727\nSosnowiec,Poland,Slaskie,244102\nŠostka,Ukraine,Sumy,90000\nSotši,Russian Federation,Krasnodar,358600\nSousse,Tunisia,Sousse,145900\nSouth Bend,United States,Indiana,107789\nSouth Dum Dum,India,West Bengali,232811\nSouth Hill,Anguilla,–,961\nSouthampton,United Kingdom,England,216000\nSouthend-on-Sea,United Kingdom,England,176000\nSouthport,United Kingdom,England,90959\nSoweto,South Africa,Gauteng,904165\nSoyapango,El Salvador,San Salvador,129800\nSpanish Town,Jamaica,St. Catherine,110379\nSplit,Croatia,Split-Dalmatia,189388\nSpokane,United States,Washington,195629\nSpringfield,United States,Massachusetts,152082\nSpringfield,United States,Missouri,151580\nSpringfield,United States,Illinois,111454\nSprings,South Africa,Gauteng,162072\nSri Jayawardenepura Kotte,Sri Lanka,Western,118000\nSrinagar,India,Jammu and Kashmir,892506\nSt Petersburg,Russian Federation,Pietari,4694000\nSt-Étienne,France,Bretagne,180210\nStahanov,Ukraine,Lugansk,101000\nStamford,United States,Connecticut,117083\nStanley,Falkland Islands,East Falkland,1636\nStara Zagora,Bulgaria,Haskovo,147939\nStaryi Oskol,Russian Federation,Belgorod,213800\nStavanger,Norway,Rogaland,108848\nStavropol,Russian Federation,Stavropol,343300\nSterling Heights,United States,Michigan,124471\nSterlitamak,Russian Federation,Baškortostan,265200\nStockholm,Sweden,Lisboa,750348\nStockport,United Kingdom,England,132813\nStockton,United States,California,243771\nStoke-on-Trent,United Kingdom,England,252000\nStrasbourg,France,Alsace,264115\nŠtšolkovo,Russian Federation,Moskova,104900\nStuttgart,Germany,Baden-Württemberg,582443\nSubotica,Yugoslavia,Vojvodina,100386\nSuceava,Romania,Suceava,118549\nSucre,Bolivia,Chuquisaca,178426\nSudbury,Canada,Ontario,92686\nSuez,Egypt,Suez,417610\nSuhar,Oman,al-Batina,90814\nSuihua,China,Heilongjiang,227881\nSuining,China,Sichuan,146086\nSuita,Japan,Osaka,345750\nSuizhou,China,Hubei,142302\nSukabumi,Indonesia,West Java,125766\nSukkur,Pakistan,Sindh,329176\nSullana,Peru,Piura,147361\nSultan Kudarat,Philippines,ARMM,94861\nSultanbeyli,Turkey,Istanbul,211068\nSumaré,Brazil,São Paulo,186205\nŠumen,Bulgaria,Varna,94686\nSumqayit,Azerbaijan,Sumqayit,283000\nSumy,Ukraine,Sumy,294000\nSunchon,South Korea,Chollanam,249263\nSunderland,United Kingdom,England,183310\nSundsvall,Sweden,Västernorrlands län,93126\nSungai Petani,Malaysia,Kedah,114763\nSunggal,Indonesia,Sumatera Utara,92300\nSunnyvale,United States,California,131760\nSunrise Manor,United States,Nevada,95362\nSuqian,China,Jiangsu,105021\nSurabaya,Indonesia,East Java,2663820\nSurakarta,Indonesia,Central Java,518600\nSurat,India,Gujarat,1498817\nSurendranagar,India,Gujarat,105973\nSurgut,Russian Federation,Hanti-Mansia,274900\nSurigao,Philippines,Caraga,118534\nSurrey,Canada,British Colombia,304477\nSutton Coldfield,United Kingdom,England,106001\nSuva,Fiji Islands,Central,77366\nSuwon,South Korea,Kyonggi,755550\nSuzano,Brazil,São Paulo,195434\nSuzhou,China,Jiangsu,710000\nSuzhou,China,Anhui,151862\nSuzuka,Japan,Mie,184061\nSwansea,United Kingdom,Wales,230000\nSwindon,United Kingdom,England,180000\nSydney,Australia,New South Wales,3276207\nSyktyvkar,Russian Federation,Komi,229700\nSylhet,Bangladesh,Sylhet,117396\nSyracuse,United States,New York,147306\nSyrakusa,Italy,Sisilia,126282\nSyzran,Russian Federation,Samara,186900\nSzczecin,Poland,Zachodnio-Pomorskie,416988\nSzeged,Hungary,Csongrád,158158\nSzékesfehérvár,Hungary,Fejér,105119\nTabaco,Philippines,Bicol,107166\nTaboão da Serra,Brazil,São Paulo,197550\nTabora,Tanzania,Tabora,92800\nTabriz,Iran,East Azerbaidzan,1191043\nTabuk,Saudi Arabia,Tabuk,292600\nTachikawa,Japan,Tokyo-to,159430\nTacloban,Philippines,Eastern Visayas,178639\nTacna,Peru,Tacna,215683\nTacoma,United States,Washington,193556\nTaegu,South Korea,Taegu,2548568\nTaejon,South Korea,Taejon,1425835\nTafuna,American Samoa,Tutuila,5200\nTaganrog,Russian Federation,Rostov-na-Donu,284400\nTaguig,Philippines,National Capital Reg,467375\nTagum,Philippines,Southern Mindanao,179531\nTai´an,China,Shandong,350696\nTaichung,Taiwan,Taichung,940589\nTainan,Taiwan,Tainan,728060\nTaipei,Taiwan,Taipei,2641312\nTaiping,Malaysia,Perak,183261\nTaiping,Taiwan,,165524\nTaitung,Taiwan,Taitung,111039\nTaiyuan,China,Shanxi,1968400\nTaizhou,China,Jiangsu,152442\nTaizz,Yemen,Taizz,317600\nTajimi,Japan,Gifu,103171\nTakamatsu,Japan,Kagawa,332471\nTakaoka,Japan,Toyama,174380\nTakarazuka,Japan,Hyogo,205993\nTakasago,Japan,Hyogo,97632\nTakasaki,Japan,Gumma,239124\nTakatsuki,Japan,Osaka,361747\nTalavera,Philippines,Central Luzon,97329\nTalca,Chile,Maule,187557\nTalcahuano,Chile,Bíobío,277752\nTaldyqorghan,Kazakstan,Almaty,98000\nTali,Taiwan,Taichung,171940\nTaliao,Taiwan,,115897\nTalisay,Philippines,Central Visayas,148110\nTalkha,Egypt,al-Daqahliya,97700\nTallahassee,United States,Florida,150624\nTallinn,Estonia,Harjumaa,403981\nTama,Japan,Ibaragi,146712\nTamale,Ghana,Northern,151069\nTaman,Indonesia,East Java,107000\nTambaram,India,Tamil Nadu,107187\nTambov,Russian Federation,Tambov,312000\nTampa,United States,Florida,303447\nTampere,Finland,Pirkanmaa,195468\nTampico,Mexico,Tamaulipas,294789\nTamuning,Guam,–,9500\nTanauan,Philippines,Southern Tagalog,117539\nTandil,Argentina,Buenos Aires,91101\nTando Adam,Pakistan,Sind,103400\nTanga,Tanzania,Tanga,137400\nTangail,Bangladesh,Dhaka,106004\nTanger,Morocco,Tanger-Tétouan,521735\nTangerang,Indonesia,West Java,1198300\nTangshan,China,Hebei,1040000\nTanjung Pinang,Indonesia,Riau,89900\nTanshui,Taiwan,Taipei,111882\nTanta,Egypt,al-Gharbiya,371010\nTantoyuca,Mexico,Veracruz,94709\nTanza,Philippines,Southern Tagalog,110517\nTaonan,China,Jilin,150168\nTaoyuan,Taiwan,Taoyuan,316438\nTapachula,Mexico,Chiapas,271141\nTaranto,Italy,Apulia,208214\nTaraz,Kazakstan,Taraz,330100\nTârgoviste,Romania,Dâmbovita,98980\nTârgu Jiu,Romania,Gorj,98524\nTârgu Mures,Romania,Mures,165153\nTarija,Bolivia,Tarija,125255\nTarlac,Philippines,Central Luzon,262481\nTarnów,Poland,Malopolskie,121494\nTarragona,Spain,Katalonia,113016\nTarsus,Turkey,Adana,246206\nTartu,Estonia,Tartumaa,101246\nTasikmalaya,Indonesia,West Java,179800\nTatuí,Brazil,São Paulo,93897\nTaubaté,Brazil,São Paulo,229130\nTaunggyi (Taunggye),Myanmar,Shan,131500\nTavoy (Dawei),Myanmar,Tenasserim [Tanintha,96800\nTaxco de Alarcón,Mexico,Guerrero,99907\nTaytay,Philippines,Southern Tagalog,198183\nTaza,Morocco,Taza-Al Hoceima-Taou,92700\nTbilisi,Georgia,Tbilisi,1235200\nTébessa,Algeria,Tébessa,112007\nTebing Tinggi,Indonesia,Sumatera Utara,129300\nTecámac,Mexico,México,172410\nTecomán,Mexico,Colima,99296\nTegal,Indonesia,Central Java,289744\nTegucigalpa,Honduras,Distrito Central,813900\nTeheran,Iran,Teheran,6758845\nTehuacán,Mexico,Puebla,225943\nTeixeira de Freitas,Brazil,Bahia,108441\nTejupilco,Mexico,México,94934\nTekirdag,Turkey,Tekirdag,106077\nTel Aviv-Jaffa,Israel,Tel Aviv,348100\nTellicherry (Thalassery),India,Kerala,103579\nTema,Ghana,Greater Accra,109975\nTemapache,Mexico,Veracruz,102824\nTémara,Morocco,Rabat-Salé-Zammour-Z,126303\nTemirtau,Kazakstan,Qaraghandy,170500\nTemixco,Mexico,Morelos,92686\nTempe,United States,Arizona,158625\nTemuco,Chile,La Araucanía,233041\nTenali,India,Andhra Pradesh,143726\nTengzhou,China,Shandong,315083\nTeófilo Otoni,Brazil,Minas Gerais,124489\nTepatitlán de Morelos,Mexico,Jalisco,118948\nTepic,Mexico,Nayarit,305025\nTeresina,Brazil,Piauí,691942\nTeresópolis,Brazil,Rio de Janeiro,128079\nTermiz,Uzbekistan,Surkhondaryo,109500\nTerni,Italy,Umbria,107770\nTernopil,Ukraine,Ternopil,236000\nTerrassa,Spain,Katalonia,168695\nTete,Mozambique,Tete,101984\nTétouan,Morocco,Tanger-Tétouan,277516\nTexcoco,Mexico,México,203681\nThai Nguyen,Vietnam,Bac Thai,127643\nThane (Thana),India,Maharashtra,803389\nThanjavur,India,Tamil Nadu,202013\nThe Valley,Anguilla,–,595\nThessaloniki,Greece,Central Macedonia,383967\nThiès,Senegal,Thiès,248000\nThimphu,Bhutan,Thimphu,22000\nThiruvananthapuram (Trivandrum,India,Kerala,524006\nThousand Oaks,United States,California,117005\nThunder Bay,Canada,Ontario,115913\nTianjin,China,Tianjin,5286800\nTianmen,China,Hubei,186332\nTianshui,China,Gansu,244974\nTiaret,Algeria,Tiaret,100118\nTiefa,China,Liaoning,131807\nTieli,China,Heilongjiang,265683\nTieling,China,Liaoning,254842\nTierra Blanca,Mexico,Veracruz,89143\nTigre,Argentina,Buenos Aires,296226\nTijuana,Mexico,Baja California,1212232\nTilburg,Netherlands,Noord-Brabant,193238\nTimisoara,Romania,Timis,324304\nTimkur,India,Karnataka,138903\nTimon,Brazil,Maranhão,125812\nTirana,Albania,Tirana,270000\nTiraspol,Moldova,Dnjestria,194300\nTiruchirapalli,India,Tamil Nadu,387223\nTirunelveli,India,Tamil Nadu,135825\nTirupati,India,Andhra Pradesh,174369\nTiruppur (Tirupper),India,Tamil Nadu,235661\nTiruvannamalai,India,Tamil Nadu,109196\nTiruvottiyur,India,Tamil Nadu,172562\nTitagarh,India,West Bengali,114085\nTjumen,Russian Federation,Tjumen,503400\nTlajomulco de Zúñiga,Mexico,Jalisco,123220\nTlalnepantla de Baz,Mexico,México,720755\nTlaquepaque,Mexico,Jalisco,475472\nTlemcen (Tilimsen),Algeria,Tlemcen,110242\nToa Baja,Puerto Rico,Toa Baja,94085\nToamasina,Madagascar,Toamasina,127441\nTobolsk,Russian Federation,Tjumen,97600\nToda,Japan,Saitama,103969\nTokai,Japan,Aichi,99738\nTokat,Turkey,Tokat,99500\nTokorozawa,Japan,Saitama,325809\nTokushima,Japan,Tokushima,269649\nTokuyama,Japan,Yamaguchi,107078\nTokyo,Japan,Tokyo-to,7980230\nToledo,Brazil,Paraná,99387\nToledo,Philippines,Central Visayas,141174\nToledo,United States,Ohio,313619\nToljatti,Russian Federation,Samara,722900\nToluca,Mexico,México,665617\nTomakomai,Japan,Hokkaido,171958\nTomsk,Russian Federation,Tomsk,482100\nTonalá,Mexico,Jalisco,336109\nTondabayashi,Japan,Osaka,125094\nTong Xian,China,Peking,97168\nTong-yong,South Korea,Kyongsangnam,131717\nTongchuan,China,Shaanxi,280657\nTonghae,South Korea,Kang-won,95472\nTonghua,China,Jilin,324600\nTongliao,China,Inner Mongolia,255129\nTongling,China,Anhui,228017\nTonk,India,Rajasthan,100079\nTopeka,United States,Kansas,122377\nTorbat-e Heydariyeh,Iran,Khorasan,94600\nTorino,Italy,Piemonte,903705\nToronto,Canada,Ontario,688275\nTorrance,United States,California,137946\nTorre del Greco,Italy,Campania,94505\nTorrejón de Ardoz,Spain,Madrid,92262\nTorreón,Mexico,Coahuila de Zaragoza,529093\nTórshavn,Faroe Islands,Streymoyar,14542\nTorun,Poland,Kujawsko-Pomorskie,206158\nToskent,Uzbekistan,Toskent Shahri,2117500\nTottori,Japan,Tottori,147523\nTouliu,Taiwan,Yünlin,98900\nToulon,France,Provence-Alpes-Côte,160639\nToulouse,France,Midi-Pyrénées,390350\nTourcoing,France,Nord-Pas-de-Calais,93540\nTours,France,Centre,132820\nTownsville,Australia,Queensland,109914\nToyama,Japan,Toyama,325790\nToyohashi,Japan,Aichi,360066\nToyokawa,Japan,Aichi,115781\nToyonaka,Japan,Osaka,396689\nToyota,Japan,Aichi,346090\nTrabzon,Turkey,Trabzon,138234\nTrento,Italy,Trentino-Alto Adige,104906\nTres de Febrero,Argentina,Buenos Aires,352311\nTrier,Germany,Rheinland-Pfalz,99891\nTrieste,Italy,Friuli-Venezia Giuli,216459\nTripoli,Lebanon,al-Shamal,240000\nTripoli,Libyan Arab Jamahiriya,Tripoli,1682000\nTrondheim,Norway,Sør-Trøndelag,150166\nTrujillo,Peru,La Libertad,652000\nTšaikovski,Russian Federation,Perm,90000\nTsaotun,Taiwan,Nantou,96800\nTšeboksary,Russian Federation,Tšuvassia,459200\nTšeljabinsk,Russian Federation,Tšeljabinsk,1083200\nTšerepovets,Russian Federation,Vologda,324400\nTšerkasy,Ukraine,Tšerkasy,309000\nTšerkessk,Russian Federation,Karatšai-Tšerkessia,121700\nTšernigiv,Ukraine,Tšernigiv,313000\nTšernivtsi,Ukraine,Tšernivtsi,259000\nTshikapa,\"Congo, The Democratic Republic of the\",West Kasai,180860\nTšita,Russian Federation,Tšita,309900\nTsu,Japan,Mie,164543\nTsuchiura,Japan,Ibaragi,134072\nTsukuba,Japan,Ibaragi,160768\nTsuruoka,Japan,Yamagata,100713\nTsuyama,Japan,Okayama,91170\nTucheng,Taiwan,Taipei,224897\nTucson,United States,Arizona,486699\nTuguegarao,Philippines,Cagayan Valley,120645\nTula,Russian Federation,Tula,506100\nTulancingo de Bravo,Mexico,Hidalgo,121946\nTulcea,Romania,Tulcea,96278\nTulsa,United States,Oklahoma,393049\nTultepec,Mexico,México,93364\nTultitlán,Mexico,México,432411\nTuluá,Colombia,Valle,152488\nTumen,China,Jilin,91471\nTungi,Bangladesh,Dhaka,168702\nTunis,Tunisia,Tunis,690600\nTunja,Colombia,Boyacá,109740\nTurku [Åbo],Finland,Varsinais-Suomi,172561\nTurmero,Venezuela,Aragua,217499\nTuticorin,India,Tamil Nadu,199854\nTúxpam,Mexico,Veracruz,126475\nTuxtla Gutiérrez,Mexico,Chiapas,433544\nTver,Russian Federation,Tver,454900\nTychy,Poland,Slaskie,133178\nUbe,Japan,Yamaguchi,175206\nUberaba,Brazil,Minas Gerais,249225\nUberlândia,Brazil,Minas Gerais,487222\nUbon Ratchathani,Thailand,Ubon Ratchathani,116300\nUdaipur,India,Rajasthan,308571\nUdine,Italy,Friuli-Venezia Giuli,94932\nUdon Thani,Thailand,Udon Thani,158100\nUeda,Japan,Nagano,124217\nUfa,Russian Federation,Baškortostan,1091200\nUgep,Nigeria,Cross River,102600\nUhta,Russian Federation,Komi,98000\nUijongbu,South Korea,Kyonggi,276111\nUitenhage,South Africa,Eastern Cape,192120\nUiwang,South Korea,Kyonggi,108788\nUji,Japan,Kyoto,188735\nUjjain,India,Madhya Pradesh,362266\nUjung Pandang,Indonesia,Sulawesi Selatan,1060257\nUlan Bator,Mongolia,Ulaanbaatar,773700\nUlan-Ude,Russian Federation,Burjatia,370400\nUlanhot,China,Inner Mongolia,159538\nUlhasnagar,India,Maharashtra,369077\nUljanovsk,Russian Federation,Uljanovsk,667400\nUlm,Germany,Baden-Württemberg,116103\nUlsan,South Korea,Kyongsangnam,1084891\nUluberia,India,West Bengali,155172\nUman,Ukraine,Tšerkasy,90000\nUmeå,Sweden,Västerbottens län,104512\nUmlazi,South Africa,KwaZulu-Natal,339233\nUnayza,Saudi Arabia,Qasim,91100\nUnnao,India,Uttar Pradesh,107425\nUppsala,Sweden,Uppsala län,189569\nUrasoe,Japan,Okinawa,96002\nUrawa,Japan,Saitama,469675\nUrayasu,Japan,Chiba,127550\nUrdaneta,Philippines,Ilocos,111582\nÜrgenc,Uzbekistan,Khorazm,138900\nUrmia,Iran,West Azerbaidzan,435200\nUruapan,Mexico,Michoacán de Ocampo,265211\nUruguaiana,Brazil,Rio Grande do Sul,126305\nUrumtši [Ürümqi],China,Xinxiang,1310100\nUsak,Turkey,Usak,128162\nUsolje-Sibirskoje,Russian Federation,Irkutsk,103500\nUssurijsk,Russian Federation,Primorje,157300\nUst-Ilimsk,Russian Federation,Irkutsk,105200\nÚstí nad Labem,Czech Republic,Severní Cechy,95491\nUtrecht,Netherlands,Utrecht,234323\nUtsunomiya,Japan,Tochigi,440353\nUttarpara-Kotrung,India,West Bengali,100867\nUvira,\"Congo, The Democratic Republic of the\",South Kivu,115590\nUzgorod,Ukraine,Taka-Karpatia,127000\nVacoas-Phoenix,Mauritius,Plaines Wilhelms,98464\nVadodara (Baroda),India,Gujarat,1031346\nVaduz,Liechtenstein,Vaduz,5043\nValdivia,Chile,Los Lagos,133106\nValencia,Spain,Valencia,739412\nValencia,Philippines,Northern Mindanao,147924\nValencia,Venezuela,Carabobo,794246\nValenzuela,Philippines,National Capital Reg,485433\nValera,Venezuela,Trujillo,130281\nValladolid,Spain,Castilla and León,319998\nValle de Chalco Solidaridad,Mexico,México,323113\nValle de la Pascua,Venezuela,Guárico,95927\nValle de Santiago,Mexico,Guanajuato,130557\nValledupar,Colombia,Cesar,263247\nVallejo,United States,California,116760\nValletta,Malta,Inner Harbour,7073\nValparai,India,Tamil Nadu,106523\nValparaíso,Chile,Valparaíso,293800\nVan,Turkey,Van,219319\nVanadzor,Armenia,Lori,172700\nVancouver,Canada,British Colombia,514008\nVancouver,United States,Washington,143560\nVanderbijlpark,South Africa,Gauteng,468931\nVantaa,Finland,Newmaa,178471\nVaramin,Iran,Teheran,107233\nVaranasi (Benares),India,Uttar Pradesh,929270\nVarginha,Brazil,Minas Gerais,108314\nVarna,Bulgaria,Varna,299801\nVárzea Grande,Brazil,Mato Grosso,214435\nVästerås,Sweden,Västmanlands län,126328\nVaughan,Canada,Ontario,147889\nVejalpur,India,Gujarat,89053\nVelbert,Germany,Nordrhein-Westfalen,89881\nVeliki Novgorod,Russian Federation,Novgorod,299500\nVelikije Luki,Russian Federation,Pihkova,116300\nVellore,India,Tamil Nadu,175061\nVenezia,Italy,Veneto,277305\nVentanilla,Peru,Callao,101056\nVeracruz,Mexico,Veracruz,457119\nVeraval,India,Gujarat,123000\nVereeniging,South Africa,Gauteng,328535\nVerona,Italy,Veneto,255268\nViamão,Brazil,Rio Grande do Sul,207557\nVicente López,Argentina,Buenos Aires,288341\nVicenza,Italy,Veneto,109738\nVictoria,Hong Kong,Hongkong,1312637\nVictoria,Mexico,Tamaulipas,262686\nVictoria,Seychelles,Mahé,41000\nVictoria de las Tunas,Cuba,Las Tunas,132350\nVidisha,India,Madhya Pradesh,92917\nVientiane,Laos,Viangchan,531800\nVigo,Spain,Galicia,283670\nVihari,Pakistan,Punjab,92300\nVijayawada,India,Andhra Pradesh,701827\nVila Velha,Brazil,Espírito Santo,318758\nVilla Nueva,Guatemala,Guatemala,101295\nVillavicencio,Colombia,Meta,273140\nVilleurbanne,France,Rhône-Alpes,124215\nVilnius,Lithuania,Vilna,577969\nViña del Mar,Chile,Valparaíso,312493\nVinh,Vietnam,Nghe An,112455\nVinnytsja,Ukraine,Vinnytsja,391000\nViransehir,Turkey,Sanliurfa,106400\nVirginia Beach,United States,Virginia,425257\nVisalia,United States,California,91762\nVishakhapatnam,India,Andhra Pradesh,752037\nVitebsk,Belarus,Vitebsk,340000\nVitória,Brazil,Espírito Santo,270626\nVitória da Conquista,Brazil,Bahia,253587\nVitória de Santo Antão,Brazil,Pernambuco,113595\nVitoria-Gasteiz,Spain,Baskimaa,217154\nVizianagaram,India,Andhra Pradesh,160359\nVladikavkaz,Russian Federation,North Ossetia-Alania,310100\nVladimir,Russian Federation,Vladimir,337100\nVladivostok,Russian Federation,Primorje,606200\nVolgodonsk,Russian Federation,Rostov-na-Donu,178200\nVolgograd,Russian Federation,Volgograd,993400\nVologda,Russian Federation,Vologda,302500\nVolta Redonda,Brazil,Rio de Janeiro,240315\nVolzski,Russian Federation,Volgograd,286900\nVorkuta,Russian Federation,Komi,92600\nVoronez,Russian Federation,Voronez,907700\nVotkinsk,Russian Federation,Udmurtia,101700\nVotorantim,Brazil,São Paulo,91777\nVung Tau,Vietnam,Ba Ria-Vung Tau,145145\nWaco,United States,Texas,113726\nWad Madani,Sudan,al-Jazira,211362\nWadi al-Sir,Jordan,Amman,89104\nWafangdian,China,Liaoning,251733\nWah,Pakistan,Punjab,198400\nWaitakere,New Zealand,Auckland,170600\nWakayama,Japan,Wakayama,391233\nWalbrzych,Poland,Dolnoslaskie,136923\nWalsall,United Kingdom,England,174739\nWanxian,China,Sichuan,156823\nWarangal,India,Andhra Pradesh,447657\nWardha,India,Maharashtra,102985\nWarraq al-Arab,Egypt,Giza,127108\nWarren,United States,Michigan,138247\nWarri,Nigeria,Edo & Delta,126100\nWarszawa,Poland,Mazowieckie,1615369\nWaru,Indonesia,East Java,124300\nWashington,United States,District of Columbia,572059\nWaterbury,United States,Connecticut,107271\nWatford,United Kingdom,England,113080\nWazirabad,Pakistan,Punjab,89700\nWeifang,China,Shandong,428522\nWeihai,China,Shandong,128888\nWeinan,China,Shaanxi,140169\nWelkom,South Africa,Free State,203296\nWellington,New Zealand,Wellington,166700\nWendeng,China,Shandong,133910\nWeno,\"Micronesia, Federated States of\",Chuuk,22000\nWenzhou,China,Zhejiang,401871\nWest Bromwich,United Kingdom,England,146386\nWest Covina,United States,California,105080\nWest Island,Cocos (Keeling) Islands,West Island,167\nWest Valley City,United States,Utah,108896\nWestminster,United States,Colorado,100940\nWestonaria,South Africa,Gauteng,159632\nWichita,United States,Kansas,344284\nWichita Falls,United States,Texas,104197\nWien,Austria,Wien,1608144\nWiesbaden,Germany,Hessen,268716\nWillemstad,Netherlands Antilles,Curaçao,2345\nWindhoek,Namibia,Khomas,169000\nWindsor,Canada,Ontario,207588\nWinnipeg,Canada,Manitoba,618477\nWinston-Salem,United States,North Carolina,185776\nWitbank,South Africa,Mpumalanga,167183\nWitten,Germany,Nordrhein-Westfalen,103384\nWloclawek,Poland,Kujawsko-Pomorskie,123373\nWoking/Byfleet,United Kingdom,England,92000\nWolfsburg,Germany,Niedersachsen,121954\nWollongong,Australia,New South Wales,219761\nWolverhampton,United Kingdom,England,242000\nWonderboom,South Africa,Gauteng,283289\nWonju,South Korea,Kang-won,237460\nWonsan,North Korea,Kangwon,300148\nWorcester,United Kingdom,England,95000\nWorcester,United States,Massachusetts,172648\nWorthing,United Kingdom,England,100000\nWroclaw,Poland,Dolnoslaskie,636765\nWuhai,China,Inner Mongolia,264081\nWuhan,China,Hubei,4344600\nWuhu,China,Anhui,425740\nWuppertal,Germany,Nordrhein-Westfalen,368993\nWürzburg,Germany,Baijeri,127350\nWuwei,China,Gansu,133101\nWuxi,China,Jiangsu,830000\nWuzhou,China,Guangxi,210452\nXai-Xai,Mozambique,Gaza,99442\nXalapa,Mexico,Veracruz,390058\nXi´an,China,Shaanxi,2761400\nXiangfan,China,Hubei,410407\nXiangtan,China,Hunan,441968\nXianning,China,Hubei,136811\nXiantao,China,Hubei,222884\nXianyang,China,Shaanxi,352125\nXiaogan,China,Hubei,166280\nXiaoshan,China,Zhejiang,162930\nXichang,China,Sichuan,134419\nXilin Hot,China,Inner Mongolia,90646\nXingcheng,China,Liaoning,102384\nXinghua,China,Jiangsu,161910\nXingtai,China,Hebei,302789\nXining,China,Qinghai,700200\nXintai,China,Shandong,281248\nXinxiang,China,Henan,473762\nXinyang,China,Henan,192509\nXinyu,China,Jiangxi,173524\nXinzhou,China,Shanxi,98667\nXuangzhou,China,Anhui,112673\nXuchang,China,Henan,208815\nXuzhou,China,Jiangsu,810000\nYa´an,China,Sichuan,95900\nYachiyo,Japan,Chiba,161222\nYaizu,Japan,Shizuoka,117258\nYakeshi,China,Inner Mongolia,377869\nYamagata,Japan,Yamagata,255617\nYamaguchi,Japan,Yamaguchi,138210\nYamato,Japan,Kanagawa,208234\nYamatokoriyama,Japan,Nara,95165\nYamoussoukro,Côte d’Ivoire,Yamoussoukro,130000\nYamuna Nagar,India,Haryana,144346\nYan´an,China,Shaanxi,113277\nYanbu,Saudi Arabia,Medina,119800\nYancheng,China,Jiangsu,296831\nYangjiang,China,Guangdong,215196\nYangmei,Taiwan,Taoyuan,126323\nYangor,Nauru,–,4050\nYangquan,China,Shanxi,362268\nYangsan,South Korea,Kyongsangnam,163351\nYangzhou,China,Jiangsu,312892\nYanji,China,Jilin,230892\nYantai,China,Shandong,452127\nYao,Japan,Osaka,276421\nYaoundé,Cameroon,Centre,1372800\nYaren,Nauru,–,559\nYatsushiro,Japan,Kumamoto,107661\nYazd,Iran,Yazd,326776\nYeotmal (Yavatmal),India,Maharashtra,108578\nYerevan,Armenia,Yerevan,1248700\nYibin,China,Sichuan,241019\nYichang,China,Hubei,371601\nYichun,China,Heilongjiang,800000\nYichun,China,Jiangxi,151585\nYinchuan,China,Ningxia,544500\nYingkou,China,Liaoning,421589\nYixing,China,Jiangsu,200824\nYiyang,China,Hunan,185818\nYizheng,China,Jiangsu,109268\nYogyakarta,Indonesia,Yogyakarta,418944\nYokkaichi,Japan,Mie,288173\nYokosuka,Japan,Kanagawa,430200\nYonago,Japan,Tottori,136461\nYonezawa,Japan,Yamagata,95592\nYong-in,South Korea,Kyonggi,242643\nYong´an,China,Fujian,111762\nYongchon,South Korea,Kyongsangbuk,113511\nYongju,South Korea,Kyongsangbuk,131097\nYonkers,United States,New York,196086\nYork,United Kingdom,England,104425\nYork,Canada,Ontario,154980\nYosu,South Korea,Chollanam,183596\nYuanjiang,China,Hunan,107004\nYuanlin,Taiwan,Changhwa,126402\nYuci,China,Shanxi,191356\nYueyang,China,Hunan,302800\nYulin,China,Guangxi,144467\nYumen,China,Gansu,109234\nYuncheng,China,Shanxi,108359\nYungho,Taiwan,Taipei,227700\nYungkang,Taiwan,Tainan,193005\nYushu,China,Jilin,131861\nYuyao,China,Zhejiang,114065\nYuzhou,China,Henan,92889\nZaanstad,Netherlands,Noord-Holland,135621\nZabol,Iran,Sistan va Baluchesta,100887\nZabrze,Poland,Slaskie,200177\nZacatecas,Mexico,Zacatecas,123700\nZagazig,Egypt,al-Sharqiya,267351\nZagreb,Croatia,Grad Zagreb,706770\nZahedan,Iran,Sistan va Baluchesta,419518\nZalantun,China,Inner Mongolia,130031\nZama,Japan,Kanagawa,122046\nZamboanga,Philippines,Western Mindanao,601794\nZamora,Mexico,Michoacán de Ocampo,161191\nZanjan,Iran,Zanjan,286295\nZanzibar,Tanzania,Zanzibar West,157634\nZaoyang,China,Hubei,162198\nZaozhuang,China,Shandong,380846\nZapopan,Mexico,Jalisco,1002239\nZaporizzja,Ukraine,Zaporizzja,848000\nZaragoza,Spain,Aragonia,603367\nZaria,Nigeria,Kaduna,379200\nZelenodolsk,Russian Federation,Tatarstan,100200\nZelenograd,Russian Federation,Moscow (City),207100\nZeleznodoroznyi,Russian Federation,Moskova,100100\nZeleznogorsk,Russian Federation,Kursk,96900\nZeleznogorsk,Russian Federation,Krasnojarsk,94000\nZenica,Bosnia and Herzegovina,Federaatio,96027\nZhangjiagang,China,Jiangsu,97994\nZhangjiakou,China,Hebei,530000\nZhangjiang,China,Guangdong,400997\nZhangzhou,China,Fujian,181424\nZhaodong,China,Heilongjiang,179976\nZhaoqing,China,Guangdong,194784\nZhengzhou,China,Henan,2107200\nZhenjiang,China,Jiangsu,368316\nZhezqazghan,Kazakstan,Qaraghandy,90000\nZhongshan,China,Guangdong,278829\nZhoukou,China,Henan,146288\nZhoushan,China,Zhejiang,156317\nZhucheng,China,Shandong,102134\nZhuhai,China,Guangdong,164747\nZhumadian,China,Henan,123232\nZhuzhou,China,Hunan,409924\nZibo,China,Shandong,1140000\nZielona Góra,Poland,Lubuskie,118182\nZigong,China,Sichuan,393184\nZiguinchor,Senegal,Ziguinchor,192000\nZinacantepec,Mexico,México,121715\nZinder,Niger,Zinder,120892\nZitácuaro,Mexico,Michoacán de Ocampo,137970\nZixing,China,Hunan,110048\nZlatoust,Russian Federation,Tšeljabinsk,196900\nZoetermeer,Netherlands,Zuid-Holland,110214\nZonguldak,Turkey,Zonguldak,111542\nZukovski,Russian Federation,Moskova,96500\nZumpango,Mexico,México,99781\nZunyi,China,Guizhou,261862\nZürich,Switzerland,Zürich,336800\nZwickau,Germany,Saksi,104146\nZwolle,Netherlands,Overijssel,105819\nZytomyr,Ukraine,Zytomyr,297000\n"
  },
  {
    "path": "test/fixtures/export-csv/numeric.csv",
    "content": "TextBox,Spinner,OneDecimal\n1.33,5000,700.6\n2.01,0,0.55\n400.00,2000.55,0.0\n"
  },
  {
    "path": "test/fixtures/export-csv/order-color-desc.csv",
    "content": "Color,Place,Height\nyellow,zoo,88\nyellow,springfield,100\nred,cambridge,200\ngreen,kansas,50\ngreen,earth,75\n"
  },
  {
    "path": "test/fixtures/export-csv/order-color-manual.csv",
    "content": "Color,Place,Height\ngreen,kansas,50\ngreen,earth,75\nred,cambridge,200\nyellow,zoo,88\nyellow,springfield,100\n"
  },
  {
    "path": "test/fixtures/export-csv/order-color-place.csv",
    "content": "Color,Place,Height\ngreen,earth,75\ngreen,kansas,50\nred,cambridge,200\nyellow,springfield,100\nyellow,zoo,88\n"
  },
  {
    "path": "test/fixtures/export-csv/order-manual.csv",
    "content": "Color,Place,Height\ngreen,kansas,50\ngreen,earth,75\nyellow,zoo,88\nyellow,springfield,100\nred,cambridge,200\n"
  },
  {
    "path": "test/fixtures/export-csv/reference.csv",
    "content": "Text Bar,Int Text,Text Formula,RowId\n\"the \"\"quote marks\"\" ?\",500,a --- grist https://www.getgrist.com/,Text[3]\n\"b ,d\",200,\"b ,d --- https://www.getgrist.com/\",Text[2]\n\"the \"\"quote marks\"\" ?\",0,a --- grist https://www.getgrist.com/,\n"
  },
  {
    "path": "test/fixtures/export-csv/text.csv",
    "content": "Foo,Bar,Id is Baz Label is this,Link,Formula\n1,a,hello,grist https://www.getgrist.com/,a --- grist https://www.getgrist.com/\n2,\"b ,d\",world,https://www.getgrist.com/,\"b ,d --- https://www.getgrist.com/\"\n3,\"the \"\"quote marks\"\" ?\",,,\"the \"\"quote marks\"\" ? --- \"\n"
  },
  {
    "path": "test/fixtures/export-csv/toggle.csv",
    "content": "TextBox,CheckBox\ntrue,false\nfalse,true\n"
  },
  {
    "path": "test/fixtures/export-dsv/CCTransactions.dsv",
    "content": "Date💩Description💩Card Member💩Account💩Amount💩Extended Details💩Doing Business As💩Street Address💩City State Zip💩Reference💩Category\n2015-01-12💩AUTOPAY PAYMENT RECEIVED - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-1745.53💩\" TD BANK, NATIONAL ASSOCIATION \n \"💩💩💩💩320150120569599421💩\n2015-01-17💩MYPIZZA.COM*MYPIZZA STATEN ISLA NY💩Howard Washington💩XXXX-XXXXXX-43003💩382.06💩\" Additional Information: 888-974-9928 \n  Description \n  MYPIZZA COM \n \"💩MYPIZZA.COM - E COMMERCE💩\"97 NEW DORP PLZ N\nSTATEN ISLAND\nNY\"💩\"10306-2903\nUNITED STATES OF AMERICA (THE)\"💩320150170634561830💩Restaurant-Bar & Café\n2015-01-20💩AMTRAK TELEPHONE SALWASHINGTON DC💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩4011💩\" Additional Information: Ticket Number: 0201083059570 \n  1 (800) 872-7245 \n \"💩AMTRAK TELEPHONE SALE💩\"60 MASSACHUSETTS AVE NE\nWASHINGTON\nDC\"💩\"20002\nUNITED STATES OF AMERICA (THE)\"💩320150210698966825💩Transportation-Rail Services\n2015-01-21💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩77.3💩\" Additional Information: PAYROLL SVC \n \"💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320150210695238055💩Merchandise & Supplies-Internet Purchase\n2015-01-31💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-19.02💩\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"💩💩💩💩320150310857958321💩Fees & Adjustments-Fees & Adjustments\n2015-02-03💩MAILCHIMP MAILCHIMP.COM GA💩Callum Wilson💩XXXX-XXXXXX-42021💩212.5💩\" Additional Information: EMAIL MKTG \n \"💩MAILCHIMP💩\"512 MEANS ST NW\nSTE 404\nATLANTA\nGA\"💩\"30318-5788\nUNITED STATES OF AMERICA (THE)\"💩320150350916563707💩Other-Miscellaneous\n2015-02-06💩PIZZA MERCATO NEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩402.04💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PLACE\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320150400993249691💩Restaurant-Restaurant\n2015-02-12💩AUTOPAY PAYMENT RECEIVED - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-4462.48💩\" TD BANK, NATIONAL ASSOCIATION \n \"💩💩💩💩320150430042521523💩\n2015-02-13💩USPS 354395021106656KINGSTON NY💩Callum Wilson💩XXXX-XXXXXX-42021💩147💩\" Additional Information: 800-2758777 \n \"💩US POSTAL SERVICE💩\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\"💩\"10610-1000\nUNITED STATES OF AMERICA (THE)\"💩320150450074080962💩Business Services-Mailing & Shipping\n2015-02-17💩ARTCO`S COPY HUT 845-339-2336💩Callum Wilson💩XXXX-XXXXXX-42021💩267.5💩\" Additional Information: 845-339-2336 \n \"💩ARTCOS COPY HUT💩\"508 ALBANY AVE\nKINGSTON\nNY\"💩\"12401-2131\nUNITED STATES OF AMERICA (THE)\"💩320150490121864816💩Business Services-Printing & Publishing\n2015-02-20💩CAJUN & GRILL 650000BOSTON MA💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩9.08💩\" Additional Information: 6174398687 \n \"💩CAJUN & GRILL💩\"630 ATLANTIC AVE\nBOSTON\nMA\"💩\"02110\nUNITED STATES OF AMERICA (THE)\"💩320150520181047308💩Restaurant-Restaurant\n2015-02-20💩CAJUN & GRILL 650000BOSTON MA💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩9.08💩\" Additional Information: 6174398687 \n \"💩CAJUN & GRILL💩\"630 ATLANTIC AVE\nBOSTON\nMA\"💩\"02110\nUNITED STATES OF AMERICA (THE)\"💩320150520181504626💩Restaurant-Restaurant\n2015-02-20💩MCDONALD'S F11729 00BOSTON MA💩Leandra Miles💩XXXX-XXXXXX-41114💩7.27💩\" Additional Information: 6173549027 \n \"💩MC DONALD'S💩\"2 SOUTH STA\nFL 5\nBOSTON\nMA\"💩\"02110-2288\nUNITED STATES OF AMERICA (THE)\"💩320150520174787823💩Restaurant-Bar & Café\n2015-02-20💩PINKBERRY 165 BOSTON MA💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩10.5💩\" Additional Information: FAST FOOD RESTAURANT \n  FOOD/BEVERAGE  $10.50 \n \"💩PINKBERRY💩\"700 ATLANTIC AVE, STE105\nBOSTON\nMA\"💩\"02110\nUNITED STATES OF AMERICA (THE)\"💩320150520176459450💩Restaurant-Bar & Café\n2015-02-20💩PIZZERIA REGINA SO 5BOSTON MA💩Leandra Miles💩XXXX-XXXXXX-41114💩15.61💩\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $15.61 \n \"💩PIZZERIA REGINA AT SOUTH💩\"2 SOUTH STA\nBOSTON\nMA\"💩\"02110-2208\nUNITED STATES OF AMERICA (THE)\"💩320150520176216796💩Restaurant-Bar & Café\n2015-02-20💩PIZZERIA REGINA SO 5BOSTON MA💩Leandra Miles💩XXXX-XXXXXX-41114💩44.8💩\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $44.80 \n \"💩PIZZERIA REGINA AT SOUTH💩\"2 SOUTH STA\nBOSTON\nMA\"💩\"02110-2208\nUNITED STATES OF AMERICA (THE)\"💩320150520176217729💩Restaurant-Bar & Café\n2015-02-20💩SUBWAY SOUTH STATN 0BOSTON MA💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩10💩\" Additional Information: 6172223200 \n  Description  Price \n  COMMUTER TRANS.  $10.00 \n \"💩COUMMUTER RAIL N STATION💩\"10 PARK PLZ\nSTE 1\nBOSTON\nMA\"💩\"02116-3977\nUNITED STATES OF AMERICA (THE)\"💩320150530195010108💩Transportation-Rail Services\n2015-02-20💩SUBWAY SOUTH STATN 0BOSTON MA💩Leandra Miles💩XXXX-XXXXXX-41114💩40💩\" Additional Information: 6172223200 \n  Description  Price \n  COMMUTER TRANS.  $40.00 \n \"💩COUMMUTER RAIL N STATION💩\"10 PARK PLZ\nSTE 1\nBOSTON\nMA\"💩\"02116-3977\nUNITED STATES OF AMERICA (THE)\"💩320150530193534594💩Transportation-Rail Services\n2015-02-21💩COSI - #205 BOSTON MA💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩144.6💩\" Additional Information: 6179519999 \n  FOOD/BEVERAGE  $122.60 \n  TIP  $22.00 \n \"💩COSI #205 SOUTH STATION💩\"2 SOUTH STATION STE #182\nBOSTON\nMA\"💩\"02110\nUNITED STATES OF AMERICA (THE)\"💩320150530194145455💩Restaurant-Restaurant\n2015-02-21💩CVS/PHARMACY #10174 BOSTON MA💩Leandra Miles💩XXXX-XXXXXX-41114💩3.33💩\" Additional Information: 8007467287 \n  Description  Price \n  PHARMACIES  $3.33 \n \"💩CVS/PHARMACY #10174💩\"650 ATLANTIC AVE\nBOSTON\nMA\"💩\"02110\nUNITED STATES OF AMERICA (THE)\"💩320150530191956222💩Merchandise & Supplies-Pharmacies\n2015-02-21💩DUNKIN #300223 QCAMBRIDGE MA💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩187.68💩\" Additional Information: 617-354-8944 \n \"💩DUNKIN' DONUTS💩\"616 MASSACHUSETTS AVE\nCAMBRIDGE\nMA\"💩\"02139-3307\nUNITED STATES OF AMERICA (THE)\"💩320150540208503704💩Restaurant-Bar & Café\n2015-02-21💩PIZZERIA REGINA SO 5BOSTON MA💩Leandra Miles💩XXXX-XXXXXX-41114💩115.91💩\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $115.91 \n \"💩PIZZERIA REGINA AT SOUTH💩\"2 SOUTH STA\nBOSTON\nMA\"💩\"02110-2208\nUNITED STATES OF AMERICA (THE)\"💩320150530188890159💩Restaurant-Bar & Café\n2015-02-22💩LEMERIDIEN CAMBRIDGECAMBRIDGE MA💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩1516.77💩\" Arrival Date  Departure Date \n  02/20/15  02/21/15 \n  00000000 \n  LODGING \n \"💩LE MERIDIEN HOTEL💩\"20 SIDNEY ST\nCAMBRIDGE\nMA\"💩\"02139-4122\nUNITED STATES OF AMERICA (THE)\"💩320150540207459315💩Travel-Lodging\n2015-02-23💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩81.66💩\" Additional Information: PAYROLL SVC \n \"💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320150540197243171💩Merchandise & Supplies-Internet Purchase\n2015-02-27💩PIZZA MERCATO NEW YORK NY💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩382.04💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PLACE\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320150590274046170💩Restaurant-Restaurant\n2015-03-01💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-44.7💩\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"💩💩💩💩320150600302988147💩Fees & Adjustments-Fees & Adjustments\n2015-03-05💩STAPLES 00242 (800)333-3330💩Callum Wilson💩XXXX-XXXXXX-42021💩72.1💩\" Additional Information: 00242000106701 12401 \n  SPLS 8.5X11 MULTI 20/96 RM \n  AVY LSR LBL 3000PK 1X2 5/8 \n  CLASP ENV BRN KRAFT 9X12 -100 \n \"💩STAPLES💩\"1399 ULSTER AVE\nKINGSTON\nNY\"💩\"12401\nUNITED STATES OF AMERICA (THE)\"💩320150650374451756💩Business Services-Office Supplies\n2015-03-06💩ARTCO`S COPY HUT 845-339-2336💩Callum Wilson💩XXXX-XXXXXX-42021💩58.8💩\" Additional Information: 845-339-2336 \n \"💩ARTCOS COPY HUT💩\"508 ALBANY AVE\nKINGSTON\nNY\"💩\"12401-2131\nUNITED STATES OF AMERICA (THE)\"💩320150680424371140💩Business Services-Printing & Publishing\n2015-03-11💩USPS 359605000800484NEW YORK NY💩Vera O'Connor💩XXXX-XXXXXX-41106💩9.8💩\" Additional Information: 800-2758777 \n \"💩US POSTAL SERVICE-NEW YORK CITY💩\"421 8TH AVE\nRM 3007\nNEW YORK\nNY\"💩\"10199-1003\nUNITED STATES OF AMERICA (THE)\"💩320150710470965085💩Business Services-Mailing & Shipping\n2015-03-12💩AUTOPAY PAYMENT RECEIVED - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-3206.31💩\" TD BANK, NATIONAL ASSOCIATION \n \"💩💩💩💩320150710473227346💩\n2015-03-19💩HP HOME STORE 888-345-5409 CA💩Vera O'Connor💩XXXX-XXXXXX-41106💩46.51💩\" Additional Information: COMPUTER \n \"💩HPSHOPPING.COM💩\"SVP01 4TH FLOOR MS 3541\n950 MAUDE AVENUE\nSUNNYVALE\nCA\"💩\"94085\nUNITED STATES OF AMERICA (THE)\"💩320150790598271773💩Merchandise & Supplies-Computer Supplies\n2015-03-20💩FAMOUS FAMIGLIA PI 5NEW YORK NY💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩287.1💩\" Additional Information: 2129969797 \n  FOOD/BEVERAGE  $287.10 \n \"💩FAMOUS FAMIGLIA PIZZERIA💩\"1398 MADISON AVE\nNEW YORK\nNY\"💩\"10029-6903\nUNITED STATES OF AMERICA (THE)\"💩320150800614157648💩Restaurant-Restaurant\n2015-03-23💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩81.66💩\" Additional Information: PAYROLL SVC \n \"💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320150820630530881💩Merchandise & Supplies-Internet Purchase\n2015-03-27💩5% OPEN Savings at HP💩Vera O'Connor💩XXXX-XXXXXX-41106💩-2.33💩\" Additional Information: SEE SUMMARY GRID FOR MORE INFORMATION \n \"💩HPSHOPPING.COM💩\"SVP01 4TH FLOOR MS 3541\n950 MAUDE AVENUE\nSUNNYVALE\nCA\"💩\"94085\nUNITED STATES OF AMERICA (THE)\"💩320150860708183861💩Merchandise & Supplies-Computer Supplies\n2015-03-30💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-32.28💩\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"💩💩💩💩320150890754246198💩Fees & Adjustments-Fees & Adjustments\n2015-04-11💩AUTOPAY PAYMENT RECEIVED - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-890.98💩\" TD BANK, NATIONAL ASSOCIATION \n \"💩💩💩💩320151010946509246💩\n2015-04-17💩INKWELL GLOBAL MARKEMANALAPAN NJ💩Howard Washington💩XXXX-XXXXXX-43003💩1241💩\" Additional Information: 7325362822 \n \"💩INKWELL GLOBAL MARKETING💩\"600 MADISON AVE\nMANALAPAN\nNJ\"💩\"07726-9594\nUNITED STATES OF AMERICA (THE)\"💩320151080049401834💩Merchandise & Supplies-Mail Order\n2015-04-21💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩83.83💩\" Additional Information: PAYROLL SVC \n \"💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320151110091111545💩Merchandise & Supplies-Internet Purchase\n2015-04-23💩CAFE AMORE'S PIZZERINEW YORK NY💩Leandra Miles💩XXXX-XXXXXX-41114💩442.98💩\" Additional Information: 212-619-0802 \n  Description \n  FOOD/BEVERAGE \n \"💩CAFE AMORE'S PIZZERIA💩\"147 CHAMBERS ST\nNEW YORK\nNY\"💩\"10007\nUNITED STATES OF AMERICA (THE)\"💩320151140150597007💩Restaurant-Restaurant\n2015-04-25💩DUANE READE #14247 0NEW YORK NY💩Clare Dudley💩XXXX-XXXXXX-41098💩2.17💩\" Additional Information: 8002892273 \n  Description \n  REFER TO RECEIPT \n \"💩WALGREEN💩\"4 W 4TH ST\nNEW YORK\nNY\"💩\"10012-1168\nUNITED STATES OF AMERICA (THE)\"💩320151160181404910💩Merchandise & Supplies-Pharmacies\n2015-05-01💩WICHCRAFT BRYANT PARNEW YORK NY💩Vera O'Connor💩XXXX-XXXXXX-41106💩453.6💩\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"💩WICHCRAFT KIOSK💩\"41 W 40TH STREET\nNEW YORK\nNY\"💩\"10018\nUNITED STATES OF AMERICA (THE)\"💩320151210250625154💩Restaurant-Bar & Café\n2015-05-01💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-12.26💩\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"💩💩💩💩320151210265694636💩Fees & Adjustments-Fees & Adjustments\n2015-05-02💩DUNKIN #346983 QNEW YORK NY💩Callum Wilson💩XXXX-XXXXXX-42021💩17.41💩\" Additional Information: 212-375-9999 \n \"💩DUNKIN' DONUTS💩\"72 W 3RD ST\nNEW YORK\nNY\"💩\"10012-1026\nUNITED STATES OF AMERICA (THE)\"💩320151230291502060💩Restaurant-Bar & Café\n2015-05-02💩DUNKIN #346983 QNEW YORK NY💩Callum Wilson💩XXXX-XXXXXX-42021💩22.9💩\" Additional Information: 212-375-9999 \n \"💩DUNKIN' DONUTS💩\"72 W 3RD ST\nNEW YORK\nNY\"💩\"10012-1026\nUNITED STATES OF AMERICA (THE)\"💩320151230292167646💩Restaurant-Bar & Café\n2015-05-02💩STAPLES 01106 (800)333-3330💩Callum Wilson💩XXXX-XXXXXX-42021💩2.16💩\" Additional Information: 01106000109206 10003 \n  CRA-Z-ART WHITE CHALK 16 CT \n \"💩STAPLES💩\"769 BROADWAY\nMANHATTAN\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151230292173007💩Business Services-Office Supplies\n2015-05-02💩WHOLEFDS TRB 10245 02123496555💩Vera O'Connor💩XXXX-XXXXXX-41106💩33.91💩\" Additional Information: 2123496555 \n  GROCERY STORES \n \"💩WHOLE FOODS MARKET💩\"270 GREENWICH ST\nNEW YORK\nNY\"💩\"10007-1150\nUNITED STATES OF AMERICA (THE)\"💩320151230294133437💩Merchandise & Supplies-Groceries\n2015-05-08💩WICHCRAFT BRYANT PARNEW YORK NY💩Vera O'Connor💩XXXX-XXXXXX-41106💩384.93💩\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"💩WICHCRAFT KIOSK💩\"41 W 40TH STREET\nNEW YORK\nNY\"💩\"10018\nUNITED STATES OF AMERICA (THE)\"💩320151280366691479💩Restaurant-Bar & Café\n2015-05-09💩STAPLES 00193 (800)333-3330💩Vera O'Connor💩XXXX-XXXXXX-41106💩10.43💩\" Additional Information: 00193000224345 10007 \n  9X12 CLEAR CLASP ENV 10PK \n \"💩STAPLES💩\"217 BROADWAY\nNEW YORK\nNY\"💩\"10007-2909\nUNITED STATES OF AMERICA (THE)\"💩320151300408500603💩Business Services-Office Supplies\n2015-05-09💩STAPLES 00193 (800)333-3330💩Vera O'Connor💩XXXX-XXXXXX-41106💩10.58💩\" Additional Information: 00193002523005 10007 \n  BW SS P@SS LTR/LGL \n \"💩STAPLES💩\"217 BROADWAY\nNEW YORK\nNY\"💩\"10007-2909\nUNITED STATES OF AMERICA (THE)\"💩320151300407029361💩Business Services-Office Supplies\n2015-05-09💩STAPLES 00193 (800)333-3330💩Vera O'Connor💩XXXX-XXXXXX-41106💩37.1💩\" Additional Information: 00193002523001 10007 \n  BW SS P@SS LTR/LGL \n \"💩STAPLES💩\"217 BROADWAY\nNEW YORK\nNY\"💩\"10007-2909\nUNITED STATES OF AMERICA (THE)\"💩320151300408517031💩Business Services-Office Supplies\n2015-05-09💩WHOLEFDS TRB 10245 02123496555💩Vera O'Connor💩XXXX-XXXXXX-41106💩38.2💩\" Additional Information: 2123496555 \n  GROCERY STORES \n \"💩WHOLE FOODS MARKET💩\"270 GREENWICH ST\nNEW YORK\nNY\"💩\"10007-1150\nUNITED STATES OF AMERICA (THE)\"💩320151300405656534💩Merchandise & Supplies-Groceries\n2015-05-12💩AUTOPAY PAYMENT RECEIVED - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-1737.7💩\" TD BANK, NATIONAL ASSOCIATION \n \"💩💩💩💩320151320445840058💩\n2015-05-16💩NORTH SQUARE RESTAURNEW YORK NY💩Callum Wilson💩XXXX-XXXXXX-42021💩114💩\" Additional Information: 2122541200 \n \"💩NORTH SQUARE RESTAURANT & LOUNGE💩\"103 WAVERLY PL\nNEW YORK\nNY\"💩\"10011-9110\nUNITED STATES OF AMERICA (THE)\"💩320151370527011129💩Restaurant-Restaurant\n2015-05-16💩NORTH SQUARE RESTAURNEW YORK NY💩Callum Wilson💩XXXX-XXXXXX-42021💩612💩\" Additional Information: 2122541200 \n \"💩NORTH SQUARE RESTAURANT & LOUNGE💩\"103 WAVERLY PL\nNEW YORK\nNY\"💩\"10011-9110\nUNITED STATES OF AMERICA (THE)\"💩320151370525443159💩Restaurant-Restaurant\n2015-05-21💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩83.83💩\" Additional Information: PAYROLL SVC \n \"💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320151410579383246💩Merchandise & Supplies-Internet Purchase\n2015-05-21💩PIZZA MERCATO NEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩383.59💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PLACE\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151430627016798💩Restaurant-Restaurant\n2015-05-29💩GRISTEDES # 508 5429NEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩82.05💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $82.05 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151500729902113💩Merchandise & Supplies-Groceries\n2015-05-29💩PANERA BREAD #601723NEW YORK NY💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩1142.37💩\" Additional Information: 9999999999 \n \"💩PANERA BREAD CAFE INSTORE💩\"330 FIFTH AVE\n-\nNEW YORK\nNY\"💩\"10001\nUNITED STATES OF AMERICA (THE)\"💩320151500733586616💩Restaurant-Bar & Café\n2015-05-29💩PENN STATER CONF CTRSTATE COLLEGE PA💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩3598.48💩\" Additional Information: 814-865-8500 \n \"💩PENN STATER CONF CNTR HTL💩\"215 INNOVATION BLVD\nSTATE COLLEGE\nPA\"💩\"16803-6603\nUNITED STATES OF AMERICA (THE)\"💩320151500729234751💩Travel-Lodging\n2015-05-29💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-17.7💩\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"💩💩💩💩320151490722848778💩Fees & Adjustments-Fees & Adjustments\n2015-05-30💩BURGER KING #8697 00BLOOMSBURG PA💩Leandra Miles💩XXXX-XXXXXX-41114💩196.21💩\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \"💩BURGER KING💩\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\"💩\"17815-8357\nUNITED STATES OF AMERICA (THE)\"💩320151510750551934💩Restaurant-Bar & Café\n2015-05-30💩BURGER KING #8697 00BLOOMSBURG PA💩Leandra Miles💩XXXX-XXXXXX-41114💩216.29💩\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \"💩BURGER KING💩\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\"💩\"17815-8357\nUNITED STATES OF AMERICA (THE)\"💩320151510748702755💩Restaurant-Bar & Café\n2015-05-30💩BURGER KING #8697 00BLOOMSBURG PA💩Leandra Miles💩XXXX-XXXXXX-41114💩480.72💩\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \"💩BURGER KING💩\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\"💩\"17815-8357\nUNITED STATES OF AMERICA (THE)\"💩320151510749938819💩Restaurant-Bar & Café\n2015-05-31💩HILTON GARDEN INN 12STATE COLLEGE PA💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩111.76💩\" Arrival Date  Departure Date \n  05/29/15  05/30/15 \n  00000000 \n  LODGING \n \"💩HILTON GARDEN INN💩\"1221 EAST COLLEGE AVENUE\nSTATE COLLEGE\nPA\"💩\"16801\nUNITED STATES OF AMERICA (THE)\"💩320151510740498966💩Travel-Lodging\n2015-05-31💩HILTON GARDEN INN 12STATE COLLEGE PA💩Nyssa O'Neil💩XXXX-XXXXXX-41122💩111.76💩\" Arrival Date  Departure Date \n  05/29/15  05/30/15 \n  00000000 \n  LODGING \n \"💩HILTON GARDEN INN💩\"1221 EAST COLLEGE AVENUE\nSTATE COLLEGE\nPA\"💩\"16801\nUNITED STATES OF AMERICA (THE)\"💩320151510740707763💩Travel-Lodging\n2015-06-08💩CUSTOMINK TSHIRTS 03FAIRFAX VA💩Clare Dudley💩XXXX-XXXXXX-41098💩920.68💩\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \"💩CUSTOMINK LLC💩\"2910 DISTRICT AVE\nFAIRFAX\nVA\"💩\"22031\nUNITED STATES OF AMERICA (THE)\"💩320151600896830010💩Merchandise & Supplies-Mail Order\n2015-06-12💩AUTOPAY PAYMENT RECEIVED - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-2192.38💩\" TD BANK, NATIONAL ASSOCIATION \n \"💩💩💩💩320151630951557213💩\n2015-06-15💩CUSTOMINK TSHIRTS FAIRFAX VA💩Clare Dudley💩XXXX-XXXXXX-41098💩-25💩\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \"💩CUSTOMINK LLC💩\"2910 DISTRICT AVE\nFAIRFAX\nVA\"💩\"22031\nUNITED STATES OF AMERICA (THE)\"💩320151670011681934💩Merchandise & Supplies-Mail Order\n2015-06-15💩CUSTOMINK TSHIRTS 03FAIRFAX VA💩Clare Dudley💩XXXX-XXXXXX-41098💩33.8💩\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \"💩CUSTOMINK LLC💩\"2910 DISTRICT AVE\nFAIRFAX\nVA\"💩\"22031\nUNITED STATES OF AMERICA (THE)\"💩320151670013120604💩Merchandise & Supplies-Mail Order\n2015-06-19💩PIZZA MERCATO NEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩328.86💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PLACE\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151730114686576💩Restaurant-Restaurant\n2015-06-22💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩83.83💩\" Additional Information: PAYROLL SVC \n \"💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320151730103193144💩Merchandise & Supplies-Internet Purchase\n2015-06-24💩USPS 354395021106656KINGSTON NY💩Callum Wilson💩XXXX-XXXXXX-42021💩5.95💩\" Additional Information: 800-2758777 \n \"💩US POSTAL SERVICE💩\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\"💩\"10610-1000\nUNITED STATES OF AMERICA (THE)\"💩320151760163504192💩Business Services-Mailing & Shipping\n2015-06-28💩Credit Adjustment for Billing Inquiry💩Howard Washington💩XXXX-XXXXXX-43003💩-50💩💩💩💩💩320151793201385681💩Fees & Adjustments-Fees & Adjustments\n2015-06-28💩TWO BOOTS PIZZA NEW YORK NY💩Clare Dudley💩XXXX-XXXXXX-41098💩102.25💩\" Additional Information: 212-777-2668 \n  Description \n  FOOD/BEVERAGE \n \"💩TWO BOOTS TO GO WEST💩\"201 W 11TH ST\nNEW YORK\nNY\"💩\"10014\nUNITED STATES OF AMERICA (THE)\"💩320151800225150895💩Restaurant-Restaurant\n2015-06-28💩WHOLEFDS TRB 10245 02123496555💩Vera O'Connor💩XXXX-XXXXXX-41106💩3.25💩\" Additional Information: 2123496555 \n  GROCERY STORES \n \"💩WHOLE FOODS MARKET💩\"270 GREENWICH ST\nNEW YORK\nNY\"💩\"10007-1150\nUNITED STATES OF AMERICA (THE)\"💩320151800223528639💩Merchandise & Supplies-Groceries\n2015-06-28💩WHOLEFDS TRB 10245 0NEW YORK NY💩Vera O'Connor💩XXXX-XXXXXX-41106💩29.82💩\" Additional Information: 2123496555 \n  Description  Price \n  GROCERY STORES  $29.82 \n \"💩WHOLE FOODS MARKET💩\"270 GREENWICH ST\nNEW YORK\nNY\"💩\"10007-1150\nUNITED STATES OF AMERICA (THE)\"💩320151800223964992💩Merchandise & Supplies-Groceries\n2015-06-29💩GRISTEDES # 508 5429NEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩78.03💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $78.03 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151810242916035💩Merchandise & Supplies-Groceries\n2015-06-29💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩12.52💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151810238061322💩Restaurant-Bar & Café\n2015-06-29💩PIZZA MERCATO NEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩502.25💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PLACE\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151810247097284💩Restaurant-Restaurant\n2015-06-29💩STAPLES 01232 (800)333-3330💩Clare Dudley💩XXXX-XXXXXX-41098💩6.85💩\" Additional Information: 01232000706107 11230 \n  NAME BDG BLUE BORDER LBL \n \"💩STAPLES💩\"1880 CONEY ISLAND AVE\nBROOKLYN\nNY\"💩\"11230\nUNITED STATES OF AMERICA (THE)\"💩320151810242451340💩Business Services-Office Supplies\n2015-06-29💩WICHCRAFT BRYANT PARNEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩1143.76💩\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"💩WICHCRAFT KIOSK💩\"41 W 40TH STREET\nNEW YORK\nNY\"💩\"10018\nUNITED STATES OF AMERICA (THE)\"💩320151800216664401💩Restaurant-Bar & Café\n2015-06-29💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-24.47💩\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"💩💩💩💩320151800229937629💩Fees & Adjustments-Fees & Adjustments\n2015-06-30💩GRISTEDES # 508 5429NEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩118.97💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $118.97 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151820256966499💩Merchandise & Supplies-Groceries\n2015-06-30💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩14.7💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151820255845813💩Restaurant-Bar & Café\n2015-06-30💩SUBWAY 999912MIAMI FL💩Howard Washington💩XXXX-XXXXXX-43003💩813.84💩\" Additional Information: 305-6700041 \n \"💩SUBWAY💩\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\"💩\"33156-2715\nUNITED STATES OF AMERICA (THE)\"💩320151820254568483💩Restaurant-Bar & Café\n2015-07-01💩CVS/PHARMACY #08900 8007467287💩Maris Burton💩XXXX-XXXXXX-43060💩31.7💩\" Additional Information: 8007467287 \n  PHARMACIES \n \"💩CVS PHARMACY💩\"20 UNIVERSITY PL\nNEW YORK\nNY\"💩\"10003-4530\nUNITED STATES OF AMERICA (THE)\"💩320151830277898080💩Merchandise & Supplies-Pharmacies\n2015-07-01💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩69.09💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $69.09 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151830273619758💩Merchandise & Supplies-Groceries\n2015-07-01💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩14.7💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151830273492010💩Restaurant-Bar & Café\n2015-07-01💩PIZZA MERCATO NEW YORK NY💩Howard Washington💩XXXX-XXXXXX-43003💩649.25💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PLACE\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151840296360778💩Restaurant-Restaurant\n2015-07-01💩STAPLES 01106 (800)333-3330💩Maris Burton💩XXXX-XXXXXX-43060💩11.97💩\" Additional Information: 01106000121928 10003 \n  POSTERBOARD 22X28 FLUR ASST 5 \n  SCOTCH INVISIBLE TAPE 3/4X300 \n \"💩STAPLES💩\"769 BROADWAY\nMANHATTAN\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151830278394728💩Business Services-Office Supplies\n2015-07-02💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩4.56💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $4.56 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151840293289242💩Merchandise & Supplies-Groceries\n2015-07-02💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩63.82💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $63.82 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151840293808568💩Merchandise & Supplies-Groceries\n2015-07-02💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩7.35💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151840291994605💩Restaurant-Bar & Café\n2015-07-02💩WICHCRAFT BRYANT PARNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩1215.24💩\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"💩WICHCRAFT KIOSK💩\"41 W 40TH STREET\nNEW YORK\nNY\"💩\"10018\nUNITED STATES OF AMERICA (THE)\"💩320151830283404245💩Restaurant-Bar & Café\n2015-07-06💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩7.82💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $7.82 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151880352939553💩Merchandise & Supplies-Groceries\n2015-07-06💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩104.06💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $104.06 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151880351787873💩Merchandise & Supplies-Groceries\n2015-07-06💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩7.35💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151880350844359💩Restaurant-Bar & Café\n2015-07-06💩ONLINE PAYMENT - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-7220.06💩💩💩💩💩320151870342051089💩\n2015-07-06💩PIZZA MERCATO NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩664.69💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PLACE\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151880360068571💩Restaurant-Restaurant\n2015-07-07💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩66.06💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $66.06 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151890369426433💩Merchandise & Supplies-Groceries\n2015-07-07💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩14.7💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151890367176935💩Restaurant-Bar & Café\n2015-07-07💩STAPLES 01798 (800)333-3330💩Maris Burton💩XXXX-XXXXXX-43060💩4.34💩\" Additional Information: 01798000228531 10011 \n  3TAB FILE FLDR LBL \n \"💩STAPLES💩\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\"💩\"10011-8415\nUNITED STATES OF AMERICA (THE)\"💩320151890371868920💩Business Services-Office Supplies\n2015-07-07💩SUBWAY 999912MIAMI FL💩Maris Burton💩XXXX-XXXXXX-43060💩813.84💩\" Additional Information: 305-6700041 \n \"💩SUBWAY💩\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\"💩\"33156-2715\nUNITED STATES OF AMERICA (THE)\"💩320151890365608242💩Restaurant-Bar & Café\n2015-07-08💩CHIPOTLE 0590 0094 NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩1357.5💩\" Additional Information: 212-982-3081 \n  Description \n  FAST FOOD RESTAURAN \n \"💩CHIPOTLE💩\"55C EAST 8TH ST\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151900389776137💩Restaurant-Bar & Café\n2015-07-08💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩8.69💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $8.69 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151900384374156💩Merchandise & Supplies-Groceries\n2015-07-08💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩72.26💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $72.26 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151900384651040💩Merchandise & Supplies-Groceries\n2015-07-08💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩6.61💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151900383693086💩Restaurant-Bar & Café\n2015-07-09💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩61.25💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $61.25 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151910403911324💩Merchandise & Supplies-Groceries\n2015-07-09💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩7.35💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151910401557217💩Restaurant-Bar & Café\n2015-07-09💩STAPLES 01106 (800)333-3330💩Maris Burton💩XXXX-XXXXXX-43060💩148.97💩\" Additional Information: 01106000511857 10003 \n  PASTELS 8.5X11 GREEN PAPER RM \n  PASTELS 8.5X11 SALMON PAPER RM \n  PASTELS 8.5X11 BLUE PAPER RM \n \"💩STAPLES💩\"769 BROADWAY\nMANHATTAN\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151910406467408💩Business Services-Office Supplies\n2015-07-10💩DUANE READE #14247 0NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩4.99💩\" Additional Information: 8002892273 \n  Description \n  REFER TO RECEIPT \n \"💩WALGREEN💩\"4 W 4TH ST\nNEW YORK\nNY\"💩\"10012-1168\nUNITED STATES OF AMERICA (THE)\"💩320151920416442525💩Merchandise & Supplies-Pharmacies\n2015-07-10💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩106.79💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $106.79 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151920418500382💩Merchandise & Supplies-Groceries\n2015-07-10💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩836💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151920425504018💩Restaurant-Bar & Café\n2015-07-10💩WICHCRAFT BRYANT PARNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩1320💩\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"💩WICHCRAFT KIOSK💩\"41 W 40TH STREET\nNEW YORK\nNY\"💩\"10018\nUNITED STATES OF AMERICA (THE)\"💩320151910396270471💩Restaurant-Bar & Café\n2015-07-11💩USPS 354395021106656KINGSTON NY💩Callum Wilson💩XXXX-XXXXXX-42021💩17.34💩\" Additional Information: 800-2758777 \n \"💩US POSTAL SERVICE💩\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\"💩\"10610-1000\nUNITED STATES OF AMERICA (THE)\"💩320151930433305676💩Business Services-Mailing & Shipping\n2015-07-13💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩113.97💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $113.97 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151950464750950💩Merchandise & Supplies-Groceries\n2015-07-13💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩14.7💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151950464236262💩Restaurant-Bar & Café\n2015-07-13💩PIZZA MERCATO NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩618.34💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PL\nNEW YORK\nNY\"💩\"10003-6722\nUNITED STATES OF AMERICA (THE)\"💩320151950473655224💩Restaurant-Restaurant\n2015-07-14💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩72.49💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $72.49 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151960484410879💩Merchandise & Supplies-Groceries\n2015-07-14💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩7.35💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151960482805860💩Restaurant-Bar & Café\n2015-07-14💩STAPLES 01106 (800)333-3330💩Maris Burton💩XXXX-XXXXXX-43060💩-4.56💩\" Additional Information: 01106000712254 10003 \n  3TAB FILE FLDR LBL \n  POSTERBOARD 22X28 FLUR ASST 5 \n  GRTNR CERT 8.5X11 BLUE/SLV 100 \n \"💩STAPLES💩\"769 BROADWAY\nMANHATTAN\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151960486379349💩Business Services-Office Supplies\n2015-07-14💩SUBWAY 999912MIAMI FL💩Maris Burton💩XXXX-XXXXXX-43060💩747.5💩\" Additional Information: 305-6700041 \n \"💩SUBWAY💩\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\"💩\"33156-2715\nUNITED STATES OF AMERICA (THE)\"💩320151960480941768💩Restaurant-Bar & Café\n2015-07-15💩CUBA 88430123896 NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩340💩\" Additional Information: 212-420-7878 \n \"💩CUBA💩\"222 THOMPSON ST\nNEW YORK\nNY\"💩\"10012-1363\nUNITED STATES OF AMERICA (THE)\"💩320151970498730613💩Restaurant-Restaurant\n2015-07-15💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩13.03💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $13.03 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151970502025423💩Merchandise & Supplies-Groceries\n2015-07-15💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩69.36💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $69.36 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151970500708696💩Merchandise & Supplies-Groceries\n2015-07-15💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩753.22💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151970499604816💩Restaurant-Bar & Café\n2015-07-15💩STAPLES 01106 (800)333-3330💩Maris Burton💩XXXX-XXXXXX-43060💩9.03💩\" Additional Information: 01106002530465 10003 \n  COMPUTER RENTAL \n  CW BW PRNT \n  BW SS P@SS LTR/LGL \n \"💩STAPLES💩\"769 BROADWAY\nMANHATTAN\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151970501140377💩Business Services-Office Supplies\n2015-07-16💩CUBA 88430123896 NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩536💩\" Additional Information: 212-420-7878 \n \"💩CUBA💩\"222 THOMPSON ST\nNEW YORK\nNY\"💩\"10012-1363\nUNITED STATES OF AMERICA (THE)\"💩320151980516094880💩Restaurant-Restaurant\n2015-07-16💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩54.81💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $54.81 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151980517736119💩Merchandise & Supplies-Groceries\n2015-07-16💩MAOZ VEGETARIAN - 8TNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩12.52💩\" Additional Information: 212-420-5999 \n \"💩MAOZ VEGETARIAN💩\"59 E 8TH ST\nNEW YORK\nNY\"💩\"10003-6450\nUNITED STATES OF AMERICA (THE)\"💩320151980516521333💩Restaurant-Bar & Café\n2015-07-16💩STAPLES 01798 (800)333-3330💩Maris Burton💩XXXX-XXXXXX-43060💩5.99💩\" Additional Information: 01798002507818 10011 \n  COMPUTER RENTAL \n  CW BW PRNT \n \"💩STAPLES💩\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\"💩\"10011-8415\nUNITED STATES OF AMERICA (THE)\"💩320151980518721472💩Business Services-Office Supplies\n2015-07-16💩STAPLES 01798 (800)333-3330💩Maris Burton💩XXXX-XXXXXX-43060💩24.91💩\" Additional Information: 01798002507807 10011 \n  BW SS P@SS LTR/LGL \n  CLR SS P@SS LTR/LGL \n \"💩STAPLES💩\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\"💩\"10011-8415\nUNITED STATES OF AMERICA (THE)\"💩320151980516856896💩Business Services-Office Supplies\n2015-07-16💩STAPLES 01798 (800)333-3330💩Maris Burton💩XXXX-XXXXXX-43060💩93.9💩\" Additional Information: 01798000535198 10011 \n  STPLS MEMO CUBE 500CT \n  251-500 BW2 LTR STD \n  STAPLING \n \"💩STAPLES💩\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\"💩\"10011-8415\nUNITED STATES OF AMERICA (THE)\"💩320151980519021999💩Business Services-Office Supplies\n2015-07-16💩VISTAPR*VISTAPRINT.C866 893 6743 CA💩Vera O'Connor💩XXXX-XXXXXX-41106💩5.44💩\" Additional Information: 866-614-8002 \n \"💩WWW.VISTAPRINT.COM💩\"95 HAYDEN AVE\nLEXINGTON\nMA\"💩\"02421-7942\nUNITED STATES OF AMERICA (THE)\"💩320151980521681765💩Merchandise & Supplies-Internet Purchase\n2015-07-16💩WICHCRAFT BRYANT PARNEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩990💩\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"💩WICHCRAFT KIOSK💩\"41 W 40TH STREET\nNEW YORK\nNY\"💩\"10018\nUNITED STATES OF AMERICA (THE)\"💩320151970493780060💩Restaurant-Bar & Café\n2015-07-17💩GRISTEDES # 508 5429NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩74.03💩\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $74.03 \n \"💩GRISTEDES💩\"246 MERCER ST\nNEW YORK\nNY\"💩\"10012-1111\nUNITED STATES OF AMERICA (THE)\"💩320151990534884422💩Merchandise & Supplies-Groceries\n2015-07-17💩PIZZA MERCATO NEW YORK NY💩Maris Burton💩XXXX-XXXXXX-43060💩204.54💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PL\nNEW YORK\nNY\"💩\"10003-6722\nUNITED STATES OF AMERICA (THE)\"💩320152010569229393💩Restaurant-Restaurant\n2015-07-17💩SACRED CHOW 212-337-0863💩Maris Burton💩XXXX-XXXXXX-43060💩16.15💩\" Additional Information: 212-337-0863 \n \"💩SACRED CHOW💩\"227 SULLIVAN ST\nFRNT 1\nNEW YORK\nNY\"💩\"10012-4803\nUNITED STATES OF AMERICA (THE)\"💩320151990541493366💩Restaurant-Restaurant\n2015-07-17💩STAPLES 01106 (800)333-3330💩Maris Burton💩XXXX-XXXXXX-43060💩54.98💩\" Additional Information: 01106000512518 10003 \n  STAPLING \n  1-100 BW 32LB ULTRA PREM \n  1-100 BW2 32LB ULTRA PREM \n \"💩STAPLES💩\"769 BROADWAY\nMANHATTAN\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320151990538533185💩Business Services-Office Supplies\n2015-07-17💩VISTAPR*VISTAPRINT.C866 893 6743 CA💩Vera O'Connor💩XXXX-XXXXXX-41106💩66.62💩\" Additional Information: 866-614-8002 \n \"💩WWW.VISTAPRINT.COM💩\"95 HAYDEN AVE\nLEXINGTON\nMA\"💩\"02421-7942\nUNITED STATES OF AMERICA (THE)\"💩320151990540257622💩Merchandise & Supplies-Internet Purchase\n2015-07-18💩RICKERS #71 8831 INDIANAPOLIS IN💩Howard Washington💩XXXX-XXXXXX-43003💩20💩\" Additional Information: 317-920-0850 \n  Description \n  Unleaded Regular \n \"💩BP FDMS INSIDE💩\"28100 TORCH PKWY\nWARRENVILLE\nIL\"💩\"60555-3938\nUNITED STATES OF AMERICA (THE)\"💩320151990528129463💩Transportation-Fuel\n2015-07-18💩RICKERS #71 8831 INDIANAPOLIS IN💩Howard Washington💩XXXX-XXXXXX-43003💩-20💩💩BP FDMS INSIDE💩\"28100 TORCH PKWY\nWARRENVILLE\nIL\"💩\"60555-3938\nUNITED STATES OF AMERICA (THE)\"💩320152020572738126💩Transportation-Fuel\n2015-07-21💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩92.54💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152030590555153💩Merchandise & Supplies-Internet Purchase\n2015-07-21💩VISTAPR*VISTAPRINT.C866 893 6743 CA💩Vera O'Connor💩XXXX-XXXXXX-41106💩-5.9💩\" Additional Information: 866-614-8002 \n \"💩WWW.VISTAPRINT.COM💩\"95 HAYDEN AVE\nLEXINGTON\nMA\"💩\"02421-7942\nUNITED STATES OF AMERICA (THE)\"💩320152030601233931💩Merchandise & Supplies-Internet Purchase\n2015-07-29💩THE FARM ON ADDERLY BROOKLYN NY💩Darren Graham💩XXXX-XXXXXX-41130💩45💩\" Additional Information: RESTAURANT \n \"💩FARM ON ADDERLY💩\"1108 CORTELYOU RD\nBROOKLYN\nNY\"💩\"11218-5304\nUNITED STATES OF AMERICA (THE)\"💩320152120748477327💩Restaurant-Restaurant\n2015-07-30💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-72.88💩\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"💩💩💩💩320152110735569063💩Fees & Adjustments-Fees & Adjustments\n2015-08-03💩USPS 333675955102007HOBOKEN NJ💩Maris Burton💩XXXX-XXXXXX-43060💩5.75💩\" Additional Information: 800-2758777 \n \"💩USPS/HOBOKEN💩\"89 RIVER ST\nHOBOKEN\nNJ\"💩\"07030-9998\nUNITED STATES OF AMERICA (THE)\"💩320152160817163880💩Business Services-Mailing & Shipping\n2015-08-08💩STAPLES 01106 (800)333-3330💩Clare Dudley💩XXXX-XXXXXX-41098💩20.25💩\" Additional Information: 01106002531781 10003 \n  BW SS P@SS LTR/LGL \n \"💩STAPLES💩\"769 BROADWAY\nMANHATTAN\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320152210896145086💩Business Services-Office Supplies\n2015-08-12💩AUTOPAY PAYMENT RECEIVED - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-15481.02💩\" TD BANK, NATIONAL ASSOCIATION \n \"💩💩💩💩320152240951742758💩\n2015-08-13💩WICHCRAFT BRYANT PARNEW YORK NY💩Darius Burgess💩XXXX-XXXXXX-41148💩214.45💩\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"💩WICHCRAFT KIOSK💩\"41 W 40TH STREET\nNEW YORK\nNY\"💩\"10018\nUNITED STATES OF AMERICA (THE)\"💩320152250967825071💩Restaurant-Bar & Café\n2015-08-14💩STARBUCKS #07497 NEWNew York NY💩Darius Burgess💩XXXX-XXXXXX-41148💩16.28💩\" Additional Information: New York \n \"💩STARBUCKS💩\"665 BROADWAY\nBROADWAY AND BOND\nNEW YORK\nNY\"💩\"10012\nUNITED STATES OF AMERICA (THE)\"💩320152260973467313💩Restaurant-Bar & Café\n2015-08-21💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩94.72💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152340102862781💩Merchandise & Supplies-Internet Purchase\n2015-08-31💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-169.34💩\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"💩💩💩💩320152430260111596💩Fees & Adjustments-Fees & Adjustments\n2015-09-03💩WICHCRAFT BRYANT PARNEW YORK NY💩Darius Burgess💩XXXX-XXXXXX-41148💩219.82💩\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"💩WICHCRAFT KIOSK💩\"41 W 40TH STREET\nNEW YORK\nNY\"💩\"10018\nUNITED STATES OF AMERICA (THE)\"💩320152460298452869💩Restaurant-Bar & Café\n2015-09-12💩AUTOPAY PAYMENT RECEIVED - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-323.57💩\" TD BANK, NATIONAL ASSOCIATION \n \"💩💩💩💩320152550459055529💩\n2015-09-18💩FRESH & CO NEW YORK NY💩Vera O'Connor💩XXXX-XXXXXX-41106💩150.95💩\" Additional Information: FAST FOOD RESTAURANT \n  Description \n  182599 \n \"💩FRESH & CO💩\"58 EAST 8TH ST\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320152640601966334💩Restaurant-Bar & Café\n2015-09-19💩FRESH & CO NEW YORK NY💩Vera O'Connor💩XXXX-XXXXXX-41106💩8.66💩\" Additional Information: 2124737374 \n  FOOD/BEVERAGE  $8.66 \n \"💩FRESH & CO💩\"729 BROADWAY\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320152630582376228💩Restaurant-Bar & Café\n2015-09-20💩WHOLEFDS TRB 10245 02123496555💩Vera O'Connor💩XXXX-XXXXXX-41106💩12.49💩\" Additional Information: 2123496555 \n  GROCERY STORES \n \"💩WHOLE FOODS MARKET💩\"270 GREENWICH ST\nNEW YORK\nNY\"💩\"10007-1150\nUNITED STATES OF AMERICA (THE)\"💩320152640596948939💩Merchandise & Supplies-Groceries\n2015-09-21💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩81.66💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152650604655989💩Merchandise & Supplies-Internet Purchase\n2015-09-25💩PIZZA MERCATO NEW YORK NY💩Darius Burgess💩XXXX-XXXXXX-41148💩590.43💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PL\nNEW YORK\nNY\"💩\"10003-6722\nUNITED STATES OF AMERICA (THE)\"💩320152710713208356💩Restaurant-Restaurant\n2015-09-30💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-4.77💩\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"💩💩💩💩320152730747361096💩Fees & Adjustments-Fees & Adjustments\n2015-10-01💩SUBWAY MIAMI FL💩Vera O'Connor💩XXXX-XXXXXX-41106💩117💩\" Additional Information: 305-6700041 \n \"💩SUBWAY💩\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\"💩\"33156-2715\nUNITED STATES OF AMERICA (THE)\"💩320152750771031096💩Restaurant-Bar & Café\n2015-10-03💩USPS PO BOXES 101510WASHINGTON DC💩Clare Dudley💩XXXX-XXXXXX-41098💩156💩\" Additional Information: 800-3447779 \n \"💩USPS PO BOXES ONLINE💩\"475 LENFANT PLZ SW\nWASHINGTON\nDC\"💩\"20260-0004\nUNITED STATES OF AMERICA (THE)\"💩320152770808332661💩Business Services-Mailing & Shipping\n2015-10-04💩WHOLEFDS TRB 10245 02123496555💩Vera O'Connor💩XXXX-XXXXXX-41106💩13.48💩\" Additional Information: 2123496555 \n  GROCERY STORES \n \"💩WHOLE FOODS MARKET💩\"270 GREENWICH ST\nNEW YORK\nNY\"💩\"10007-1150\nUNITED STATES OF AMERICA (THE)\"💩320152780825369796💩Merchandise & Supplies-Groceries\n2015-10-12💩AUTOPAY PAYMENT RECEIVED - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-304.24💩\" TD BANK, NATIONAL ASSOCIATION \n \"💩💩💩💩320152850946530301💩\n2015-10-15💩GROUPON INC 877-788-7858 IL💩Vera O'Connor💩XXXX-XXXXXX-41106💩39.5💩\" Additional Information: COUPONS \n \"💩GROUPON INC💩\"600 W CHICAGO AVE\nSTE 400\nCHICAGO\nIL\"💩\"60654-2067\nUNITED STATES OF AMERICA (THE)\"💩320152890013469812💩Merchandise & Supplies-Internet Purchase\n2015-10-16💩PIZZA MERCATO NEW YORK NY💩Vera O'Connor💩XXXX-XXXXXX-41106💩57.75💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PL\nNEW YORK\nNY\"💩\"10003-6722\nUNITED STATES OF AMERICA (THE)\"💩320152920061757486💩Restaurant-Restaurant\n2015-10-16💩PIZZA MERCATO NEW YORK NY💩Darius Burgess💩XXXX-XXXXXX-41148💩774.62💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PL\nNEW YORK\nNY\"💩\"10003-6722\nUNITED STATES OF AMERICA (THE)\"💩320152920062466899💩Restaurant-Restaurant\n2015-10-21💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩77.3💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152950098979714💩Merchandise & Supplies-Internet Purchase\n2015-10-21💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩77.3💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152950098983888💩Merchandise & Supplies-Internet Purchase\n2015-10-21💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩77.3💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152950098983890💩Merchandise & Supplies-Internet Purchase\n2015-10-21💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩77.3💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152950098983894💩Merchandise & Supplies-Internet Purchase\n2015-10-22💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩77.3💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152960115550127💩Merchandise & Supplies-Internet Purchase\n2015-10-23💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩-77.3💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152970133427869💩Merchandise & Supplies-Internet Purchase\n2015-10-23💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩-77.3💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152970133427871💩Merchandise & Supplies-Internet Purchase\n2015-10-23💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩-77.3💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152970133427873💩Merchandise & Supplies-Internet Purchase\n2015-10-23💩INTUIT PAYROLL 888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩-77.3💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320152970133464913💩Merchandise & Supplies-Internet Purchase\n2015-10-27💩MES*RINGCENTRAL, INC6504724100💩Vera O'Connor💩XXXX-XXXXXX-41106💩160.56💩\" Additional Information: 3719004008 94002 \n \"💩MES*RINGCENTRAL, INC💩\"1400 FASHION IS\nSAN MATEO\nCA\"💩\"944\nUNITED STATES OF AMERICA (THE)\"💩320153010200395072💩Communications-Mobile Telecom\n2015-10-30💩FRESH & CO NEW YORK💩Vera O'Connor💩XXXX-XXXXXX-41106💩137.89💩\" Additional Information: FAST FOOD RESTAURANT \n  Description \n  105071 \n \"💩FRESH & CO💩\"58 EAST 8TH ST\nNEW YORK\nNY\"💩\"10003\nUNITED STATES OF AMERICA (THE)\"💩320153060292051275💩Restaurant-Bar & Café\n2015-10-30💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-4.74💩\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"💩💩💩💩320153030245166855💩Fees & Adjustments-Fees & Adjustments\n2015-11-12💩AUTOPAY PAYMENT RECEIVED - THANK YOU💩Howard Washington💩XXXX-XXXXXX-43003💩-1981.87💩\" TD BANK, NATIONAL ASSOCIATION \n \"💩💩💩💩320153160462707016💩\n2015-11-13💩PIZZA MERCATO NEW YORK NY💩Darius Burgess💩XXXX-XXXXXX-41148💩774.62💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PL\nNEW YORK\nNY\"💩\"10003-6722\nUNITED STATES OF AMERICA (THE)\"💩320153200524725369💩Restaurant-Restaurant\n2015-11-15💩PIZZA MERCATO NEW YORK NY💩Vera O'Connor💩XXXX-XXXXXX-41106💩93.8💩\" Additional Information: 212-420-8432 \n \"💩PIZZA MERCATO💩\"11 WAVERLY PL\nNEW YORK\nNY\"💩\"10003-6722\nUNITED STATES OF AMERICA (THE)\"💩320153210543933275💩Restaurant-Restaurant\n2015-11-20💩NJT NY PENN STA 50NEW YORK NJ💩Darius Burgess💩XXXX-XXXXXX-41148💩1011.75💩\" Additional Information: 973-2755555 \n \"💩NJ TRANSIT💩\"NEW YORK PENN STATION\n34TH & 7TH AVENUES\nNEW YORK\nNY\"💩\"10016\nUNITED STATES OF AMERICA (THE)\"💩320153250615656980💩Transportation-Rail Services\n2015-11-21💩HN-DUNKIN ST212 0000NEW YORK NY💩Darius Burgess💩XXXX-XXXXXX-41148💩43.14💩\" Additional Information: 800-326-7711 \n  Description \n  GROCERIES/SUNDRIES \n \"💩HUDSON NEWS💩\"8TH AVE -PENN STN TKT LVL\nNEW YORK\nNY\"💩\"10001\nUNITED STATES OF AMERICA (THE)\"💩320153260627158328💩Merchandise & Supplies-Book Stores\n2015-11-21💩PJS PANCAKE HOUSE 65PRINCETON NJ💩Darius Burgess💩XXXX-XXXXXX-41148💩127.75💩\" Additional Information: 6099241353 \n \"💩P J'S PANCAKE HOUSE RESTAURANT💩\"154 NASSAU ST\nPRINCETON\nNJ\"💩\"08542-7006\nUNITED STATES OF AMERICA (THE)\"💩320153260617461233💩Restaurant-Restaurant\n2015-11-23💩888-537-7794 CA💩Howard Washington💩XXXX-XXXXXX-43003💩79.48💩💩PAYCYCLE INC💩\"210 PORTAGE AVE\nPALO ALTO\nCA\"💩\"94306-2242\nUNITED STATES OF AMERICA (THE)\"💩320153280650085362💩Merchandise & Supplies-Internet Purchase\n2015-12-02💩STAPLES 00193 NEW YORK NY💩Vera O'Connor💩XXXX-XXXXXX-41106💩3.74💩\"001006221 00193001006221 10007\n00193001006221 10007\nBW SS P@SS LTR/LGL\nCLASP ENV BRN KRAFT 9X12 -12\n\n\n\n 00193001006221 10007 \n  BW SS P@SS LTR/LGL  \n  CLASP ENV BRN KRAFT 9X12 -12  \n \"💩STAPLES💩\"217 BROADWAY\nNEW YORK\nNY\"💩\"10007-2909\nUNITED STATES OF AMERICA (THE)\"💩320153370819408517💩Business Services-Office Supplies\n2015-12-02💩STAPLES 00193 NEW YORK NY💩Vera O'Connor💩XXXX-XXXXXX-41106💩9.15💩\"002558833 00193002558833 10007\n00193002558833 10007\nBW SS P@SS LTR/LGL\n\n\n\n\n 00193002558833 10007 \n  BW SS P@SS LTR/LGL  \n \"💩STAPLES💩\"217 BROADWAY\nNEW YORK\nNY\"💩\"10007-2909\nUNITED STATES OF AMERICA (THE)\"💩320153370816757331💩Business Services-Office Supplies\n2015-12-03💩YOUR CASH BACK THIS PERIOD IS💩Howard Washington💩XXXX-XXXXXX-43003💩-19.87💩💩💩💩💩320153390865190560💩Fees & Adjustments-Fees & Adjustments\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩148.79💩\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390850587329💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩148.79💩\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390850900842💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩148.79💩\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851052217💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩148.79💩\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851183402💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩148.79💩\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851243871💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩148.79💩\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851421525💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390850586825💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390850586860💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390850743209💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390850743367💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390850901096💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851051765💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851051982💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851052120💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851182737💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851183168💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851421352💩Travel-Lodging\n2015-12-04💩CHARLES HOTEL CAMBRIDGE MA💩Darius Burgess💩XXXX-XXXXXX-41148💩177.4💩\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"💩CHARLES HOTEL💩\"1 BENNETT ST\nCAMBRIDGE\nMA\"💩\"02138-5707\nUNITED STATES OF AMERICA (THE)\"💩320153390851421393💩Travel-Lodging\n"
  },
  {
    "path": "test/fixtures/export-dsv/text.dsv",
    "content": "Foo💩Bar💩Id is Baz Label is this💩Link💩Formula\n1💩a💩hello💩grist https://www.getgrist.com/💩a --- grist https://www.getgrist.com/\n2💩b ,d💩world💩https://www.getgrist.com/💩b ,d --- https://www.getgrist.com/\n3💩\"the \"\"quote marks\"\" ?\"💩💩💩\"the \"\"quote marks\"\" ? --- \"\n"
  },
  {
    "path": "test/fixtures/export-tsv/CCTransactions.tsv",
    "content": "Date\tDescription\tCard Member\tAccount\tAmount\tExtended Details\tDoing Business As\tStreet Address\tCity State Zip\tReference\tCategory\n2015-01-12\tAUTOPAY PAYMENT RECEIVED - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-1745.53\t\" TD BANK, NATIONAL ASSOCIATION \n \"\t\t\t\t320150120569599421\t\n2015-01-17\tMYPIZZA.COM*MYPIZZA STATEN ISLA NY\tHoward Washington\tXXXX-XXXXXX-43003\t382.06\t\" Additional Information: 888-974-9928 \n  Description \n  MYPIZZA COM \n \"\tMYPIZZA.COM - E COMMERCE\t\"97 NEW DORP PLZ N\nSTATEN ISLAND\nNY\"\t\"10306-2903\nUNITED STATES OF AMERICA (THE)\"\t320150170634561830\tRestaurant-Bar & Café\n2015-01-20\tAMTRAK TELEPHONE SALWASHINGTON DC\tNyssa O'Neil\tXXXX-XXXXXX-41122\t4011\t\" Additional Information: Ticket Number: 0201083059570 \n  1 (800) 872-7245 \n \"\tAMTRAK TELEPHONE SALE\t\"60 MASSACHUSETTS AVE NE\nWASHINGTON\nDC\"\t\"20002\nUNITED STATES OF AMERICA (THE)\"\t320150210698966825\tTransportation-Rail Services\n2015-01-21\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t77.3\t\" Additional Information: PAYROLL SVC \n \"\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320150210695238055\tMerchandise & Supplies-Internet Purchase\n2015-01-31\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-19.02\t\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"\t\t\t\t320150310857958321\tFees & Adjustments-Fees & Adjustments\n2015-02-03\tMAILCHIMP MAILCHIMP.COM GA\tCallum Wilson\tXXXX-XXXXXX-42021\t212.5\t\" Additional Information: EMAIL MKTG \n \"\tMAILCHIMP\t\"512 MEANS ST NW\nSTE 404\nATLANTA\nGA\"\t\"30318-5788\nUNITED STATES OF AMERICA (THE)\"\t320150350916563707\tOther-Miscellaneous\n2015-02-06\tPIZZA MERCATO NEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t402.04\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PLACE\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320150400993249691\tRestaurant-Restaurant\n2015-02-12\tAUTOPAY PAYMENT RECEIVED - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-4462.48\t\" TD BANK, NATIONAL ASSOCIATION \n \"\t\t\t\t320150430042521523\t\n2015-02-13\tUSPS 354395021106656KINGSTON NY\tCallum Wilson\tXXXX-XXXXXX-42021\t147\t\" Additional Information: 800-2758777 \n \"\tUS POSTAL SERVICE\t\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\"\t\"10610-1000\nUNITED STATES OF AMERICA (THE)\"\t320150450074080962\tBusiness Services-Mailing & Shipping\n2015-02-17\tARTCO`S COPY HUT 845-339-2336\tCallum Wilson\tXXXX-XXXXXX-42021\t267.5\t\" Additional Information: 845-339-2336 \n \"\tARTCOS COPY HUT\t\"508 ALBANY AVE\nKINGSTON\nNY\"\t\"12401-2131\nUNITED STATES OF AMERICA (THE)\"\t320150490121864816\tBusiness Services-Printing & Publishing\n2015-02-20\tCAJUN & GRILL 650000BOSTON MA\tNyssa O'Neil\tXXXX-XXXXXX-41122\t9.08\t\" Additional Information: 6174398687 \n \"\tCAJUN & GRILL\t\"630 ATLANTIC AVE\nBOSTON\nMA\"\t\"02110\nUNITED STATES OF AMERICA (THE)\"\t320150520181047308\tRestaurant-Restaurant\n2015-02-20\tCAJUN & GRILL 650000BOSTON MA\tNyssa O'Neil\tXXXX-XXXXXX-41122\t9.08\t\" Additional Information: 6174398687 \n \"\tCAJUN & GRILL\t\"630 ATLANTIC AVE\nBOSTON\nMA\"\t\"02110\nUNITED STATES OF AMERICA (THE)\"\t320150520181504626\tRestaurant-Restaurant\n2015-02-20\tMCDONALD'S F11729 00BOSTON MA\tLeandra Miles\tXXXX-XXXXXX-41114\t7.27\t\" Additional Information: 6173549027 \n \"\tMC DONALD'S\t\"2 SOUTH STA\nFL 5\nBOSTON\nMA\"\t\"02110-2288\nUNITED STATES OF AMERICA (THE)\"\t320150520174787823\tRestaurant-Bar & Café\n2015-02-20\tPINKBERRY 165 BOSTON MA\tNyssa O'Neil\tXXXX-XXXXXX-41122\t10.5\t\" Additional Information: FAST FOOD RESTAURANT \n  FOOD/BEVERAGE  $10.50 \n \"\tPINKBERRY\t\"700 ATLANTIC AVE, STE105\nBOSTON\nMA\"\t\"02110\nUNITED STATES OF AMERICA (THE)\"\t320150520176459450\tRestaurant-Bar & Café\n2015-02-20\tPIZZERIA REGINA SO 5BOSTON MA\tLeandra Miles\tXXXX-XXXXXX-41114\t15.61\t\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $15.61 \n \"\tPIZZERIA REGINA AT SOUTH\t\"2 SOUTH STA\nBOSTON\nMA\"\t\"02110-2208\nUNITED STATES OF AMERICA (THE)\"\t320150520176216796\tRestaurant-Bar & Café\n2015-02-20\tPIZZERIA REGINA SO 5BOSTON MA\tLeandra Miles\tXXXX-XXXXXX-41114\t44.8\t\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $44.80 \n \"\tPIZZERIA REGINA AT SOUTH\t\"2 SOUTH STA\nBOSTON\nMA\"\t\"02110-2208\nUNITED STATES OF AMERICA (THE)\"\t320150520176217729\tRestaurant-Bar & Café\n2015-02-20\tSUBWAY SOUTH STATN 0BOSTON MA\tNyssa O'Neil\tXXXX-XXXXXX-41122\t10\t\" Additional Information: 6172223200 \n  Description  Price \n  COMMUTER TRANS.  $10.00 \n \"\tCOUMMUTER RAIL N STATION\t\"10 PARK PLZ\nSTE 1\nBOSTON\nMA\"\t\"02116-3977\nUNITED STATES OF AMERICA (THE)\"\t320150530195010108\tTransportation-Rail Services\n2015-02-20\tSUBWAY SOUTH STATN 0BOSTON MA\tLeandra Miles\tXXXX-XXXXXX-41114\t40\t\" Additional Information: 6172223200 \n  Description  Price \n  COMMUTER TRANS.  $40.00 \n \"\tCOUMMUTER RAIL N STATION\t\"10 PARK PLZ\nSTE 1\nBOSTON\nMA\"\t\"02116-3977\nUNITED STATES OF AMERICA (THE)\"\t320150530193534594\tTransportation-Rail Services\n2015-02-21\tCOSI - #205 BOSTON MA\tNyssa O'Neil\tXXXX-XXXXXX-41122\t144.6\t\" Additional Information: 6179519999 \n  FOOD/BEVERAGE  $122.60 \n  TIP  $22.00 \n \"\tCOSI #205 SOUTH STATION\t\"2 SOUTH STATION STE #182\nBOSTON\nMA\"\t\"02110\nUNITED STATES OF AMERICA (THE)\"\t320150530194145455\tRestaurant-Restaurant\n2015-02-21\tCVS/PHARMACY #10174 BOSTON MA\tLeandra Miles\tXXXX-XXXXXX-41114\t3.33\t\" Additional Information: 8007467287 \n  Description  Price \n  PHARMACIES  $3.33 \n \"\tCVS/PHARMACY #10174\t\"650 ATLANTIC AVE\nBOSTON\nMA\"\t\"02110\nUNITED STATES OF AMERICA (THE)\"\t320150530191956222\tMerchandise & Supplies-Pharmacies\n2015-02-21\tDUNKIN #300223 QCAMBRIDGE MA\tNyssa O'Neil\tXXXX-XXXXXX-41122\t187.68\t\" Additional Information: 617-354-8944 \n \"\tDUNKIN' DONUTS\t\"616 MASSACHUSETTS AVE\nCAMBRIDGE\nMA\"\t\"02139-3307\nUNITED STATES OF AMERICA (THE)\"\t320150540208503704\tRestaurant-Bar & Café\n2015-02-21\tPIZZERIA REGINA SO 5BOSTON MA\tLeandra Miles\tXXXX-XXXXXX-41114\t115.91\t\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $115.91 \n \"\tPIZZERIA REGINA AT SOUTH\t\"2 SOUTH STA\nBOSTON\nMA\"\t\"02110-2208\nUNITED STATES OF AMERICA (THE)\"\t320150530188890159\tRestaurant-Bar & Café\n2015-02-22\tLEMERIDIEN CAMBRIDGECAMBRIDGE MA\tNyssa O'Neil\tXXXX-XXXXXX-41122\t1516.77\t\" Arrival Date  Departure Date \n  02/20/15  02/21/15 \n  00000000 \n  LODGING \n \"\tLE MERIDIEN HOTEL\t\"20 SIDNEY ST\nCAMBRIDGE\nMA\"\t\"02139-4122\nUNITED STATES OF AMERICA (THE)\"\t320150540207459315\tTravel-Lodging\n2015-02-23\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t81.66\t\" Additional Information: PAYROLL SVC \n \"\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320150540197243171\tMerchandise & Supplies-Internet Purchase\n2015-02-27\tPIZZA MERCATO NEW YORK NY\tNyssa O'Neil\tXXXX-XXXXXX-41122\t382.04\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PLACE\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320150590274046170\tRestaurant-Restaurant\n2015-03-01\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-44.7\t\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"\t\t\t\t320150600302988147\tFees & Adjustments-Fees & Adjustments\n2015-03-05\tSTAPLES 00242 (800)333-3330\tCallum Wilson\tXXXX-XXXXXX-42021\t72.1\t\" Additional Information: 00242000106701 12401 \n  SPLS 8.5X11 MULTI 20/96 RM \n  AVY LSR LBL 3000PK 1X2 5/8 \n  CLASP ENV BRN KRAFT 9X12 -100 \n \"\tSTAPLES\t\"1399 ULSTER AVE\nKINGSTON\nNY\"\t\"12401\nUNITED STATES OF AMERICA (THE)\"\t320150650374451756\tBusiness Services-Office Supplies\n2015-03-06\tARTCO`S COPY HUT 845-339-2336\tCallum Wilson\tXXXX-XXXXXX-42021\t58.8\t\" Additional Information: 845-339-2336 \n \"\tARTCOS COPY HUT\t\"508 ALBANY AVE\nKINGSTON\nNY\"\t\"12401-2131\nUNITED STATES OF AMERICA (THE)\"\t320150680424371140\tBusiness Services-Printing & Publishing\n2015-03-11\tUSPS 359605000800484NEW YORK NY\tVera O'Connor\tXXXX-XXXXXX-41106\t9.8\t\" Additional Information: 800-2758777 \n \"\tUS POSTAL SERVICE-NEW YORK CITY\t\"421 8TH AVE\nRM 3007\nNEW YORK\nNY\"\t\"10199-1003\nUNITED STATES OF AMERICA (THE)\"\t320150710470965085\tBusiness Services-Mailing & Shipping\n2015-03-12\tAUTOPAY PAYMENT RECEIVED - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-3206.31\t\" TD BANK, NATIONAL ASSOCIATION \n \"\t\t\t\t320150710473227346\t\n2015-03-19\tHP HOME STORE 888-345-5409 CA\tVera O'Connor\tXXXX-XXXXXX-41106\t46.51\t\" Additional Information: COMPUTER \n \"\tHPSHOPPING.COM\t\"SVP01 4TH FLOOR MS 3541\n950 MAUDE AVENUE\nSUNNYVALE\nCA\"\t\"94085\nUNITED STATES OF AMERICA (THE)\"\t320150790598271773\tMerchandise & Supplies-Computer Supplies\n2015-03-20\tFAMOUS FAMIGLIA PI 5NEW YORK NY\tNyssa O'Neil\tXXXX-XXXXXX-41122\t287.1\t\" Additional Information: 2129969797 \n  FOOD/BEVERAGE  $287.10 \n \"\tFAMOUS FAMIGLIA PIZZERIA\t\"1398 MADISON AVE\nNEW YORK\nNY\"\t\"10029-6903\nUNITED STATES OF AMERICA (THE)\"\t320150800614157648\tRestaurant-Restaurant\n2015-03-23\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t81.66\t\" Additional Information: PAYROLL SVC \n \"\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320150820630530881\tMerchandise & Supplies-Internet Purchase\n2015-03-27\t5% OPEN Savings at HP\tVera O'Connor\tXXXX-XXXXXX-41106\t-2.33\t\" Additional Information: SEE SUMMARY GRID FOR MORE INFORMATION \n \"\tHPSHOPPING.COM\t\"SVP01 4TH FLOOR MS 3541\n950 MAUDE AVENUE\nSUNNYVALE\nCA\"\t\"94085\nUNITED STATES OF AMERICA (THE)\"\t320150860708183861\tMerchandise & Supplies-Computer Supplies\n2015-03-30\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-32.28\t\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"\t\t\t\t320150890754246198\tFees & Adjustments-Fees & Adjustments\n2015-04-11\tAUTOPAY PAYMENT RECEIVED - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-890.98\t\" TD BANK, NATIONAL ASSOCIATION \n \"\t\t\t\t320151010946509246\t\n2015-04-17\tINKWELL GLOBAL MARKEMANALAPAN NJ\tHoward Washington\tXXXX-XXXXXX-43003\t1241\t\" Additional Information: 7325362822 \n \"\tINKWELL GLOBAL MARKETING\t\"600 MADISON AVE\nMANALAPAN\nNJ\"\t\"07726-9594\nUNITED STATES OF AMERICA (THE)\"\t320151080049401834\tMerchandise & Supplies-Mail Order\n2015-04-21\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t83.83\t\" Additional Information: PAYROLL SVC \n \"\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320151110091111545\tMerchandise & Supplies-Internet Purchase\n2015-04-23\tCAFE AMORE'S PIZZERINEW YORK NY\tLeandra Miles\tXXXX-XXXXXX-41114\t442.98\t\" Additional Information: 212-619-0802 \n  Description \n  FOOD/BEVERAGE \n \"\tCAFE AMORE'S PIZZERIA\t\"147 CHAMBERS ST\nNEW YORK\nNY\"\t\"10007\nUNITED STATES OF AMERICA (THE)\"\t320151140150597007\tRestaurant-Restaurant\n2015-04-25\tDUANE READE #14247 0NEW YORK NY\tClare Dudley\tXXXX-XXXXXX-41098\t2.17\t\" Additional Information: 8002892273 \n  Description \n  REFER TO RECEIPT \n \"\tWALGREEN\t\"4 W 4TH ST\nNEW YORK\nNY\"\t\"10012-1168\nUNITED STATES OF AMERICA (THE)\"\t320151160181404910\tMerchandise & Supplies-Pharmacies\n2015-05-01\tWICHCRAFT BRYANT PARNEW YORK NY\tVera O'Connor\tXXXX-XXXXXX-41106\t453.6\t\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"\tWICHCRAFT KIOSK\t\"41 W 40TH STREET\nNEW YORK\nNY\"\t\"10018\nUNITED STATES OF AMERICA (THE)\"\t320151210250625154\tRestaurant-Bar & Café\n2015-05-01\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-12.26\t\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"\t\t\t\t320151210265694636\tFees & Adjustments-Fees & Adjustments\n2015-05-02\tDUNKIN #346983 QNEW YORK NY\tCallum Wilson\tXXXX-XXXXXX-42021\t17.41\t\" Additional Information: 212-375-9999 \n \"\tDUNKIN' DONUTS\t\"72 W 3RD ST\nNEW YORK\nNY\"\t\"10012-1026\nUNITED STATES OF AMERICA (THE)\"\t320151230291502060\tRestaurant-Bar & Café\n2015-05-02\tDUNKIN #346983 QNEW YORK NY\tCallum Wilson\tXXXX-XXXXXX-42021\t22.9\t\" Additional Information: 212-375-9999 \n \"\tDUNKIN' DONUTS\t\"72 W 3RD ST\nNEW YORK\nNY\"\t\"10012-1026\nUNITED STATES OF AMERICA (THE)\"\t320151230292167646\tRestaurant-Bar & Café\n2015-05-02\tSTAPLES 01106 (800)333-3330\tCallum Wilson\tXXXX-XXXXXX-42021\t2.16\t\" Additional Information: 01106000109206 10003 \n  CRA-Z-ART WHITE CHALK 16 CT \n \"\tSTAPLES\t\"769 BROADWAY\nMANHATTAN\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151230292173007\tBusiness Services-Office Supplies\n2015-05-02\tWHOLEFDS TRB 10245 02123496555\tVera O'Connor\tXXXX-XXXXXX-41106\t33.91\t\" Additional Information: 2123496555 \n  GROCERY STORES \n \"\tWHOLE FOODS MARKET\t\"270 GREENWICH ST\nNEW YORK\nNY\"\t\"10007-1150\nUNITED STATES OF AMERICA (THE)\"\t320151230294133437\tMerchandise & Supplies-Groceries\n2015-05-08\tWICHCRAFT BRYANT PARNEW YORK NY\tVera O'Connor\tXXXX-XXXXXX-41106\t384.93\t\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"\tWICHCRAFT KIOSK\t\"41 W 40TH STREET\nNEW YORK\nNY\"\t\"10018\nUNITED STATES OF AMERICA (THE)\"\t320151280366691479\tRestaurant-Bar & Café\n2015-05-09\tSTAPLES 00193 (800)333-3330\tVera O'Connor\tXXXX-XXXXXX-41106\t10.43\t\" Additional Information: 00193000224345 10007 \n  9X12 CLEAR CLASP ENV 10PK \n \"\tSTAPLES\t\"217 BROADWAY\nNEW YORK\nNY\"\t\"10007-2909\nUNITED STATES OF AMERICA (THE)\"\t320151300408500603\tBusiness Services-Office Supplies\n2015-05-09\tSTAPLES 00193 (800)333-3330\tVera O'Connor\tXXXX-XXXXXX-41106\t10.58\t\" Additional Information: 00193002523005 10007 \n  BW SS P@SS LTR/LGL \n \"\tSTAPLES\t\"217 BROADWAY\nNEW YORK\nNY\"\t\"10007-2909\nUNITED STATES OF AMERICA (THE)\"\t320151300407029361\tBusiness Services-Office Supplies\n2015-05-09\tSTAPLES 00193 (800)333-3330\tVera O'Connor\tXXXX-XXXXXX-41106\t37.1\t\" Additional Information: 00193002523001 10007 \n  BW SS P@SS LTR/LGL \n \"\tSTAPLES\t\"217 BROADWAY\nNEW YORK\nNY\"\t\"10007-2909\nUNITED STATES OF AMERICA (THE)\"\t320151300408517031\tBusiness Services-Office Supplies\n2015-05-09\tWHOLEFDS TRB 10245 02123496555\tVera O'Connor\tXXXX-XXXXXX-41106\t38.2\t\" Additional Information: 2123496555 \n  GROCERY STORES \n \"\tWHOLE FOODS MARKET\t\"270 GREENWICH ST\nNEW YORK\nNY\"\t\"10007-1150\nUNITED STATES OF AMERICA (THE)\"\t320151300405656534\tMerchandise & Supplies-Groceries\n2015-05-12\tAUTOPAY PAYMENT RECEIVED - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-1737.7\t\" TD BANK, NATIONAL ASSOCIATION \n \"\t\t\t\t320151320445840058\t\n2015-05-16\tNORTH SQUARE RESTAURNEW YORK NY\tCallum Wilson\tXXXX-XXXXXX-42021\t114\t\" Additional Information: 2122541200 \n \"\tNORTH SQUARE RESTAURANT & LOUNGE\t\"103 WAVERLY PL\nNEW YORK\nNY\"\t\"10011-9110\nUNITED STATES OF AMERICA (THE)\"\t320151370527011129\tRestaurant-Restaurant\n2015-05-16\tNORTH SQUARE RESTAURNEW YORK NY\tCallum Wilson\tXXXX-XXXXXX-42021\t612\t\" Additional Information: 2122541200 \n \"\tNORTH SQUARE RESTAURANT & LOUNGE\t\"103 WAVERLY PL\nNEW YORK\nNY\"\t\"10011-9110\nUNITED STATES OF AMERICA (THE)\"\t320151370525443159\tRestaurant-Restaurant\n2015-05-21\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t83.83\t\" Additional Information: PAYROLL SVC \n \"\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320151410579383246\tMerchandise & Supplies-Internet Purchase\n2015-05-21\tPIZZA MERCATO NEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t383.59\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PLACE\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151430627016798\tRestaurant-Restaurant\n2015-05-29\tGRISTEDES # 508 5429NEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t82.05\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $82.05 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151500729902113\tMerchandise & Supplies-Groceries\n2015-05-29\tPANERA BREAD #601723NEW YORK NY\tNyssa O'Neil\tXXXX-XXXXXX-41122\t1142.37\t\" Additional Information: 9999999999 \n \"\tPANERA BREAD CAFE INSTORE\t\"330 FIFTH AVE\n-\nNEW YORK\nNY\"\t\"10001\nUNITED STATES OF AMERICA (THE)\"\t320151500733586616\tRestaurant-Bar & Café\n2015-05-29\tPENN STATER CONF CTRSTATE COLLEGE PA\tNyssa O'Neil\tXXXX-XXXXXX-41122\t3598.48\t\" Additional Information: 814-865-8500 \n \"\tPENN STATER CONF CNTR HTL\t\"215 INNOVATION BLVD\nSTATE COLLEGE\nPA\"\t\"16803-6603\nUNITED STATES OF AMERICA (THE)\"\t320151500729234751\tTravel-Lodging\n2015-05-29\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-17.7\t\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"\t\t\t\t320151490722848778\tFees & Adjustments-Fees & Adjustments\n2015-05-30\tBURGER KING #8697 00BLOOMSBURG PA\tLeandra Miles\tXXXX-XXXXXX-41114\t196.21\t\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \"\tBURGER KING\t\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\"\t\"17815-8357\nUNITED STATES OF AMERICA (THE)\"\t320151510750551934\tRestaurant-Bar & Café\n2015-05-30\tBURGER KING #8697 00BLOOMSBURG PA\tLeandra Miles\tXXXX-XXXXXX-41114\t216.29\t\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \"\tBURGER KING\t\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\"\t\"17815-8357\nUNITED STATES OF AMERICA (THE)\"\t320151510748702755\tRestaurant-Bar & Café\n2015-05-30\tBURGER KING #8697 00BLOOMSBURG PA\tLeandra Miles\tXXXX-XXXXXX-41114\t480.72\t\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \"\tBURGER KING\t\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\"\t\"17815-8357\nUNITED STATES OF AMERICA (THE)\"\t320151510749938819\tRestaurant-Bar & Café\n2015-05-31\tHILTON GARDEN INN 12STATE COLLEGE PA\tNyssa O'Neil\tXXXX-XXXXXX-41122\t111.76\t\" Arrival Date  Departure Date \n  05/29/15  05/30/15 \n  00000000 \n  LODGING \n \"\tHILTON GARDEN INN\t\"1221 EAST COLLEGE AVENUE\nSTATE COLLEGE\nPA\"\t\"16801\nUNITED STATES OF AMERICA (THE)\"\t320151510740498966\tTravel-Lodging\n2015-05-31\tHILTON GARDEN INN 12STATE COLLEGE PA\tNyssa O'Neil\tXXXX-XXXXXX-41122\t111.76\t\" Arrival Date  Departure Date \n  05/29/15  05/30/15 \n  00000000 \n  LODGING \n \"\tHILTON GARDEN INN\t\"1221 EAST COLLEGE AVENUE\nSTATE COLLEGE\nPA\"\t\"16801\nUNITED STATES OF AMERICA (THE)\"\t320151510740707763\tTravel-Lodging\n2015-06-08\tCUSTOMINK TSHIRTS 03FAIRFAX VA\tClare Dudley\tXXXX-XXXXXX-41098\t920.68\t\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \"\tCUSTOMINK LLC\t\"2910 DISTRICT AVE\nFAIRFAX\nVA\"\t\"22031\nUNITED STATES OF AMERICA (THE)\"\t320151600896830010\tMerchandise & Supplies-Mail Order\n2015-06-12\tAUTOPAY PAYMENT RECEIVED - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-2192.38\t\" TD BANK, NATIONAL ASSOCIATION \n \"\t\t\t\t320151630951557213\t\n2015-06-15\tCUSTOMINK TSHIRTS FAIRFAX VA\tClare Dudley\tXXXX-XXXXXX-41098\t-25\t\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \"\tCUSTOMINK LLC\t\"2910 DISTRICT AVE\nFAIRFAX\nVA\"\t\"22031\nUNITED STATES OF AMERICA (THE)\"\t320151670011681934\tMerchandise & Supplies-Mail Order\n2015-06-15\tCUSTOMINK TSHIRTS 03FAIRFAX VA\tClare Dudley\tXXXX-XXXXXX-41098\t33.8\t\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \"\tCUSTOMINK LLC\t\"2910 DISTRICT AVE\nFAIRFAX\nVA\"\t\"22031\nUNITED STATES OF AMERICA (THE)\"\t320151670013120604\tMerchandise & Supplies-Mail Order\n2015-06-19\tPIZZA MERCATO NEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t328.86\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PLACE\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151730114686576\tRestaurant-Restaurant\n2015-06-22\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t83.83\t\" Additional Information: PAYROLL SVC \n \"\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320151730103193144\tMerchandise & Supplies-Internet Purchase\n2015-06-24\tUSPS 354395021106656KINGSTON NY\tCallum Wilson\tXXXX-XXXXXX-42021\t5.95\t\" Additional Information: 800-2758777 \n \"\tUS POSTAL SERVICE\t\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\"\t\"10610-1000\nUNITED STATES OF AMERICA (THE)\"\t320151760163504192\tBusiness Services-Mailing & Shipping\n2015-06-28\tCredit Adjustment for Billing Inquiry\tHoward Washington\tXXXX-XXXXXX-43003\t-50\t\t\t\t\t320151793201385681\tFees & Adjustments-Fees & Adjustments\n2015-06-28\tTWO BOOTS PIZZA NEW YORK NY\tClare Dudley\tXXXX-XXXXXX-41098\t102.25\t\" Additional Information: 212-777-2668 \n  Description \n  FOOD/BEVERAGE \n \"\tTWO BOOTS TO GO WEST\t\"201 W 11TH ST\nNEW YORK\nNY\"\t\"10014\nUNITED STATES OF AMERICA (THE)\"\t320151800225150895\tRestaurant-Restaurant\n2015-06-28\tWHOLEFDS TRB 10245 02123496555\tVera O'Connor\tXXXX-XXXXXX-41106\t3.25\t\" Additional Information: 2123496555 \n  GROCERY STORES \n \"\tWHOLE FOODS MARKET\t\"270 GREENWICH ST\nNEW YORK\nNY\"\t\"10007-1150\nUNITED STATES OF AMERICA (THE)\"\t320151800223528639\tMerchandise & Supplies-Groceries\n2015-06-28\tWHOLEFDS TRB 10245 0NEW YORK NY\tVera O'Connor\tXXXX-XXXXXX-41106\t29.82\t\" Additional Information: 2123496555 \n  Description  Price \n  GROCERY STORES  $29.82 \n \"\tWHOLE FOODS MARKET\t\"270 GREENWICH ST\nNEW YORK\nNY\"\t\"10007-1150\nUNITED STATES OF AMERICA (THE)\"\t320151800223964992\tMerchandise & Supplies-Groceries\n2015-06-29\tGRISTEDES # 508 5429NEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t78.03\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $78.03 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151810242916035\tMerchandise & Supplies-Groceries\n2015-06-29\tMAOZ VEGETARIAN - 8TNEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t12.52\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151810238061322\tRestaurant-Bar & Café\n2015-06-29\tPIZZA MERCATO NEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t502.25\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PLACE\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151810247097284\tRestaurant-Restaurant\n2015-06-29\tSTAPLES 01232 (800)333-3330\tClare Dudley\tXXXX-XXXXXX-41098\t6.85\t\" Additional Information: 01232000706107 11230 \n  NAME BDG BLUE BORDER LBL \n \"\tSTAPLES\t\"1880 CONEY ISLAND AVE\nBROOKLYN\nNY\"\t\"11230\nUNITED STATES OF AMERICA (THE)\"\t320151810242451340\tBusiness Services-Office Supplies\n2015-06-29\tWICHCRAFT BRYANT PARNEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t1143.76\t\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"\tWICHCRAFT KIOSK\t\"41 W 40TH STREET\nNEW YORK\nNY\"\t\"10018\nUNITED STATES OF AMERICA (THE)\"\t320151800216664401\tRestaurant-Bar & Café\n2015-06-29\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-24.47\t\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"\t\t\t\t320151800229937629\tFees & Adjustments-Fees & Adjustments\n2015-06-30\tGRISTEDES # 508 5429NEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t118.97\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $118.97 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151820256966499\tMerchandise & Supplies-Groceries\n2015-06-30\tMAOZ VEGETARIAN - 8TNEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t14.7\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151820255845813\tRestaurant-Bar & Café\n2015-06-30\tSUBWAY 999912MIAMI FL\tHoward Washington\tXXXX-XXXXXX-43003\t813.84\t\" Additional Information: 305-6700041 \n \"\tSUBWAY\t\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\"\t\"33156-2715\nUNITED STATES OF AMERICA (THE)\"\t320151820254568483\tRestaurant-Bar & Café\n2015-07-01\tCVS/PHARMACY #08900 8007467287\tMaris Burton\tXXXX-XXXXXX-43060\t31.7\t\" Additional Information: 8007467287 \n  PHARMACIES \n \"\tCVS PHARMACY\t\"20 UNIVERSITY PL\nNEW YORK\nNY\"\t\"10003-4530\nUNITED STATES OF AMERICA (THE)\"\t320151830277898080\tMerchandise & Supplies-Pharmacies\n2015-07-01\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t69.09\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $69.09 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151830273619758\tMerchandise & Supplies-Groceries\n2015-07-01\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t14.7\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151830273492010\tRestaurant-Bar & Café\n2015-07-01\tPIZZA MERCATO NEW YORK NY\tHoward Washington\tXXXX-XXXXXX-43003\t649.25\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PLACE\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151840296360778\tRestaurant-Restaurant\n2015-07-01\tSTAPLES 01106 (800)333-3330\tMaris Burton\tXXXX-XXXXXX-43060\t11.97\t\" Additional Information: 01106000121928 10003 \n  POSTERBOARD 22X28 FLUR ASST 5 \n  SCOTCH INVISIBLE TAPE 3/4X300 \n \"\tSTAPLES\t\"769 BROADWAY\nMANHATTAN\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151830278394728\tBusiness Services-Office Supplies\n2015-07-02\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t4.56\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $4.56 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151840293289242\tMerchandise & Supplies-Groceries\n2015-07-02\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t63.82\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $63.82 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151840293808568\tMerchandise & Supplies-Groceries\n2015-07-02\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t7.35\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151840291994605\tRestaurant-Bar & Café\n2015-07-02\tWICHCRAFT BRYANT PARNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t1215.24\t\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"\tWICHCRAFT KIOSK\t\"41 W 40TH STREET\nNEW YORK\nNY\"\t\"10018\nUNITED STATES OF AMERICA (THE)\"\t320151830283404245\tRestaurant-Bar & Café\n2015-07-06\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t7.82\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $7.82 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151880352939553\tMerchandise & Supplies-Groceries\n2015-07-06\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t104.06\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $104.06 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151880351787873\tMerchandise & Supplies-Groceries\n2015-07-06\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t7.35\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151880350844359\tRestaurant-Bar & Café\n2015-07-06\tONLINE PAYMENT - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-7220.06\t\t\t\t\t320151870342051089\t\n2015-07-06\tPIZZA MERCATO NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t664.69\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PLACE\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151880360068571\tRestaurant-Restaurant\n2015-07-07\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t66.06\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $66.06 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151890369426433\tMerchandise & Supplies-Groceries\n2015-07-07\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t14.7\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151890367176935\tRestaurant-Bar & Café\n2015-07-07\tSTAPLES 01798 (800)333-3330\tMaris Burton\tXXXX-XXXXXX-43060\t4.34\t\" Additional Information: 01798000228531 10011 \n  3TAB FILE FLDR LBL \n \"\tSTAPLES\t\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\"\t\"10011-8415\nUNITED STATES OF AMERICA (THE)\"\t320151890371868920\tBusiness Services-Office Supplies\n2015-07-07\tSUBWAY 999912MIAMI FL\tMaris Burton\tXXXX-XXXXXX-43060\t813.84\t\" Additional Information: 305-6700041 \n \"\tSUBWAY\t\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\"\t\"33156-2715\nUNITED STATES OF AMERICA (THE)\"\t320151890365608242\tRestaurant-Bar & Café\n2015-07-08\tCHIPOTLE 0590 0094 NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t1357.5\t\" Additional Information: 212-982-3081 \n  Description \n  FAST FOOD RESTAURAN \n \"\tCHIPOTLE\t\"55C EAST 8TH ST\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151900389776137\tRestaurant-Bar & Café\n2015-07-08\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t8.69\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $8.69 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151900384374156\tMerchandise & Supplies-Groceries\n2015-07-08\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t72.26\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $72.26 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151900384651040\tMerchandise & Supplies-Groceries\n2015-07-08\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t6.61\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151900383693086\tRestaurant-Bar & Café\n2015-07-09\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t61.25\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $61.25 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151910403911324\tMerchandise & Supplies-Groceries\n2015-07-09\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t7.35\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151910401557217\tRestaurant-Bar & Café\n2015-07-09\tSTAPLES 01106 (800)333-3330\tMaris Burton\tXXXX-XXXXXX-43060\t148.97\t\" Additional Information: 01106000511857 10003 \n  PASTELS 8.5X11 GREEN PAPER RM \n  PASTELS 8.5X11 SALMON PAPER RM \n  PASTELS 8.5X11 BLUE PAPER RM \n \"\tSTAPLES\t\"769 BROADWAY\nMANHATTAN\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151910406467408\tBusiness Services-Office Supplies\n2015-07-10\tDUANE READE #14247 0NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t4.99\t\" Additional Information: 8002892273 \n  Description \n  REFER TO RECEIPT \n \"\tWALGREEN\t\"4 W 4TH ST\nNEW YORK\nNY\"\t\"10012-1168\nUNITED STATES OF AMERICA (THE)\"\t320151920416442525\tMerchandise & Supplies-Pharmacies\n2015-07-10\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t106.79\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $106.79 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151920418500382\tMerchandise & Supplies-Groceries\n2015-07-10\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t836\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151920425504018\tRestaurant-Bar & Café\n2015-07-10\tWICHCRAFT BRYANT PARNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t1320\t\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"\tWICHCRAFT KIOSK\t\"41 W 40TH STREET\nNEW YORK\nNY\"\t\"10018\nUNITED STATES OF AMERICA (THE)\"\t320151910396270471\tRestaurant-Bar & Café\n2015-07-11\tUSPS 354395021106656KINGSTON NY\tCallum Wilson\tXXXX-XXXXXX-42021\t17.34\t\" Additional Information: 800-2758777 \n \"\tUS POSTAL SERVICE\t\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\"\t\"10610-1000\nUNITED STATES OF AMERICA (THE)\"\t320151930433305676\tBusiness Services-Mailing & Shipping\n2015-07-13\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t113.97\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $113.97 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151950464750950\tMerchandise & Supplies-Groceries\n2015-07-13\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t14.7\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151950464236262\tRestaurant-Bar & Café\n2015-07-13\tPIZZA MERCATO NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t618.34\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PL\nNEW YORK\nNY\"\t\"10003-6722\nUNITED STATES OF AMERICA (THE)\"\t320151950473655224\tRestaurant-Restaurant\n2015-07-14\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t72.49\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $72.49 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151960484410879\tMerchandise & Supplies-Groceries\n2015-07-14\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t7.35\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151960482805860\tRestaurant-Bar & Café\n2015-07-14\tSTAPLES 01106 (800)333-3330\tMaris Burton\tXXXX-XXXXXX-43060\t-4.56\t\" Additional Information: 01106000712254 10003 \n  3TAB FILE FLDR LBL \n  POSTERBOARD 22X28 FLUR ASST 5 \n  GRTNR CERT 8.5X11 BLUE/SLV 100 \n \"\tSTAPLES\t\"769 BROADWAY\nMANHATTAN\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151960486379349\tBusiness Services-Office Supplies\n2015-07-14\tSUBWAY 999912MIAMI FL\tMaris Burton\tXXXX-XXXXXX-43060\t747.5\t\" Additional Information: 305-6700041 \n \"\tSUBWAY\t\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\"\t\"33156-2715\nUNITED STATES OF AMERICA (THE)\"\t320151960480941768\tRestaurant-Bar & Café\n2015-07-15\tCUBA 88430123896 NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t340\t\" Additional Information: 212-420-7878 \n \"\tCUBA\t\"222 THOMPSON ST\nNEW YORK\nNY\"\t\"10012-1363\nUNITED STATES OF AMERICA (THE)\"\t320151970498730613\tRestaurant-Restaurant\n2015-07-15\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t13.03\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $13.03 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151970502025423\tMerchandise & Supplies-Groceries\n2015-07-15\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t69.36\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $69.36 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151970500708696\tMerchandise & Supplies-Groceries\n2015-07-15\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t753.22\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151970499604816\tRestaurant-Bar & Café\n2015-07-15\tSTAPLES 01106 (800)333-3330\tMaris Burton\tXXXX-XXXXXX-43060\t9.03\t\" Additional Information: 01106002530465 10003 \n  COMPUTER RENTAL \n  CW BW PRNT \n  BW SS P@SS LTR/LGL \n \"\tSTAPLES\t\"769 BROADWAY\nMANHATTAN\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151970501140377\tBusiness Services-Office Supplies\n2015-07-16\tCUBA 88430123896 NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t536\t\" Additional Information: 212-420-7878 \n \"\tCUBA\t\"222 THOMPSON ST\nNEW YORK\nNY\"\t\"10012-1363\nUNITED STATES OF AMERICA (THE)\"\t320151980516094880\tRestaurant-Restaurant\n2015-07-16\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t54.81\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $54.81 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151980517736119\tMerchandise & Supplies-Groceries\n2015-07-16\tMAOZ VEGETARIAN - 8TNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t12.52\t\" Additional Information: 212-420-5999 \n \"\tMAOZ VEGETARIAN\t\"59 E 8TH ST\nNEW YORK\nNY\"\t\"10003-6450\nUNITED STATES OF AMERICA (THE)\"\t320151980516521333\tRestaurant-Bar & Café\n2015-07-16\tSTAPLES 01798 (800)333-3330\tMaris Burton\tXXXX-XXXXXX-43060\t5.99\t\" Additional Information: 01798002507818 10011 \n  COMPUTER RENTAL \n  CW BW PRNT \n \"\tSTAPLES\t\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\"\t\"10011-8415\nUNITED STATES OF AMERICA (THE)\"\t320151980518721472\tBusiness Services-Office Supplies\n2015-07-16\tSTAPLES 01798 (800)333-3330\tMaris Burton\tXXXX-XXXXXX-43060\t24.91\t\" Additional Information: 01798002507807 10011 \n  BW SS P@SS LTR/LGL \n  CLR SS P@SS LTR/LGL \n \"\tSTAPLES\t\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\"\t\"10011-8415\nUNITED STATES OF AMERICA (THE)\"\t320151980516856896\tBusiness Services-Office Supplies\n2015-07-16\tSTAPLES 01798 (800)333-3330\tMaris Burton\tXXXX-XXXXXX-43060\t93.9\t\" Additional Information: 01798000535198 10011 \n  STPLS MEMO CUBE 500CT \n  251-500 BW2 LTR STD \n  STAPLING \n \"\tSTAPLES\t\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\"\t\"10011-8415\nUNITED STATES OF AMERICA (THE)\"\t320151980519021999\tBusiness Services-Office Supplies\n2015-07-16\tVISTAPR*VISTAPRINT.C866 893 6743 CA\tVera O'Connor\tXXXX-XXXXXX-41106\t5.44\t\" Additional Information: 866-614-8002 \n \"\tWWW.VISTAPRINT.COM\t\"95 HAYDEN AVE\nLEXINGTON\nMA\"\t\"02421-7942\nUNITED STATES OF AMERICA (THE)\"\t320151980521681765\tMerchandise & Supplies-Internet Purchase\n2015-07-16\tWICHCRAFT BRYANT PARNEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t990\t\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"\tWICHCRAFT KIOSK\t\"41 W 40TH STREET\nNEW YORK\nNY\"\t\"10018\nUNITED STATES OF AMERICA (THE)\"\t320151970493780060\tRestaurant-Bar & Café\n2015-07-17\tGRISTEDES # 508 5429NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t74.03\t\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $74.03 \n \"\tGRISTEDES\t\"246 MERCER ST\nNEW YORK\nNY\"\t\"10012-1111\nUNITED STATES OF AMERICA (THE)\"\t320151990534884422\tMerchandise & Supplies-Groceries\n2015-07-17\tPIZZA MERCATO NEW YORK NY\tMaris Burton\tXXXX-XXXXXX-43060\t204.54\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PL\nNEW YORK\nNY\"\t\"10003-6722\nUNITED STATES OF AMERICA (THE)\"\t320152010569229393\tRestaurant-Restaurant\n2015-07-17\tSACRED CHOW 212-337-0863\tMaris Burton\tXXXX-XXXXXX-43060\t16.15\t\" Additional Information: 212-337-0863 \n \"\tSACRED CHOW\t\"227 SULLIVAN ST\nFRNT 1\nNEW YORK\nNY\"\t\"10012-4803\nUNITED STATES OF AMERICA (THE)\"\t320151990541493366\tRestaurant-Restaurant\n2015-07-17\tSTAPLES 01106 (800)333-3330\tMaris Burton\tXXXX-XXXXXX-43060\t54.98\t\" Additional Information: 01106000512518 10003 \n  STAPLING \n  1-100 BW 32LB ULTRA PREM \n  1-100 BW2 32LB ULTRA PREM \n \"\tSTAPLES\t\"769 BROADWAY\nMANHATTAN\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320151990538533185\tBusiness Services-Office Supplies\n2015-07-17\tVISTAPR*VISTAPRINT.C866 893 6743 CA\tVera O'Connor\tXXXX-XXXXXX-41106\t66.62\t\" Additional Information: 866-614-8002 \n \"\tWWW.VISTAPRINT.COM\t\"95 HAYDEN AVE\nLEXINGTON\nMA\"\t\"02421-7942\nUNITED STATES OF AMERICA (THE)\"\t320151990540257622\tMerchandise & Supplies-Internet Purchase\n2015-07-18\tRICKERS #71 8831 INDIANAPOLIS IN\tHoward Washington\tXXXX-XXXXXX-43003\t20\t\" Additional Information: 317-920-0850 \n  Description \n  Unleaded Regular \n \"\tBP FDMS INSIDE\t\"28100 TORCH PKWY\nWARRENVILLE\nIL\"\t\"60555-3938\nUNITED STATES OF AMERICA (THE)\"\t320151990528129463\tTransportation-Fuel\n2015-07-18\tRICKERS #71 8831 INDIANAPOLIS IN\tHoward Washington\tXXXX-XXXXXX-43003\t-20\t\tBP FDMS INSIDE\t\"28100 TORCH PKWY\nWARRENVILLE\nIL\"\t\"60555-3938\nUNITED STATES OF AMERICA (THE)\"\t320152020572738126\tTransportation-Fuel\n2015-07-21\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t92.54\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152030590555153\tMerchandise & Supplies-Internet Purchase\n2015-07-21\tVISTAPR*VISTAPRINT.C866 893 6743 CA\tVera O'Connor\tXXXX-XXXXXX-41106\t-5.9\t\" Additional Information: 866-614-8002 \n \"\tWWW.VISTAPRINT.COM\t\"95 HAYDEN AVE\nLEXINGTON\nMA\"\t\"02421-7942\nUNITED STATES OF AMERICA (THE)\"\t320152030601233931\tMerchandise & Supplies-Internet Purchase\n2015-07-29\tTHE FARM ON ADDERLY BROOKLYN NY\tDarren Graham\tXXXX-XXXXXX-41130\t45\t\" Additional Information: RESTAURANT \n \"\tFARM ON ADDERLY\t\"1108 CORTELYOU RD\nBROOKLYN\nNY\"\t\"11218-5304\nUNITED STATES OF AMERICA (THE)\"\t320152120748477327\tRestaurant-Restaurant\n2015-07-30\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-72.88\t\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"\t\t\t\t320152110735569063\tFees & Adjustments-Fees & Adjustments\n2015-08-03\tUSPS 333675955102007HOBOKEN NJ\tMaris Burton\tXXXX-XXXXXX-43060\t5.75\t\" Additional Information: 800-2758777 \n \"\tUSPS/HOBOKEN\t\"89 RIVER ST\nHOBOKEN\nNJ\"\t\"07030-9998\nUNITED STATES OF AMERICA (THE)\"\t320152160817163880\tBusiness Services-Mailing & Shipping\n2015-08-08\tSTAPLES 01106 (800)333-3330\tClare Dudley\tXXXX-XXXXXX-41098\t20.25\t\" Additional Information: 01106002531781 10003 \n  BW SS P@SS LTR/LGL \n \"\tSTAPLES\t\"769 BROADWAY\nMANHATTAN\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320152210896145086\tBusiness Services-Office Supplies\n2015-08-12\tAUTOPAY PAYMENT RECEIVED - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-15481.02\t\" TD BANK, NATIONAL ASSOCIATION \n \"\t\t\t\t320152240951742758\t\n2015-08-13\tWICHCRAFT BRYANT PARNEW YORK NY\tDarius Burgess\tXXXX-XXXXXX-41148\t214.45\t\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"\tWICHCRAFT KIOSK\t\"41 W 40TH STREET\nNEW YORK\nNY\"\t\"10018\nUNITED STATES OF AMERICA (THE)\"\t320152250967825071\tRestaurant-Bar & Café\n2015-08-14\tSTARBUCKS #07497 NEWNew York NY\tDarius Burgess\tXXXX-XXXXXX-41148\t16.28\t\" Additional Information: New York \n \"\tSTARBUCKS\t\"665 BROADWAY\nBROADWAY AND BOND\nNEW YORK\nNY\"\t\"10012\nUNITED STATES OF AMERICA (THE)\"\t320152260973467313\tRestaurant-Bar & Café\n2015-08-21\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t94.72\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152340102862781\tMerchandise & Supplies-Internet Purchase\n2015-08-31\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-169.34\t\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"\t\t\t\t320152430260111596\tFees & Adjustments-Fees & Adjustments\n2015-09-03\tWICHCRAFT BRYANT PARNEW YORK NY\tDarius Burgess\tXXXX-XXXXXX-41148\t219.82\t\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \"\tWICHCRAFT KIOSK\t\"41 W 40TH STREET\nNEW YORK\nNY\"\t\"10018\nUNITED STATES OF AMERICA (THE)\"\t320152460298452869\tRestaurant-Bar & Café\n2015-09-12\tAUTOPAY PAYMENT RECEIVED - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-323.57\t\" TD BANK, NATIONAL ASSOCIATION \n \"\t\t\t\t320152550459055529\t\n2015-09-18\tFRESH & CO NEW YORK NY\tVera O'Connor\tXXXX-XXXXXX-41106\t150.95\t\" Additional Information: FAST FOOD RESTAURANT \n  Description \n  182599 \n \"\tFRESH & CO\t\"58 EAST 8TH ST\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320152640601966334\tRestaurant-Bar & Café\n2015-09-19\tFRESH & CO NEW YORK NY\tVera O'Connor\tXXXX-XXXXXX-41106\t8.66\t\" Additional Information: 2124737374 \n  FOOD/BEVERAGE  $8.66 \n \"\tFRESH & CO\t\"729 BROADWAY\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320152630582376228\tRestaurant-Bar & Café\n2015-09-20\tWHOLEFDS TRB 10245 02123496555\tVera O'Connor\tXXXX-XXXXXX-41106\t12.49\t\" Additional Information: 2123496555 \n  GROCERY STORES \n \"\tWHOLE FOODS MARKET\t\"270 GREENWICH ST\nNEW YORK\nNY\"\t\"10007-1150\nUNITED STATES OF AMERICA (THE)\"\t320152640596948939\tMerchandise & Supplies-Groceries\n2015-09-21\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t81.66\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152650604655989\tMerchandise & Supplies-Internet Purchase\n2015-09-25\tPIZZA MERCATO NEW YORK NY\tDarius Burgess\tXXXX-XXXXXX-41148\t590.43\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PL\nNEW YORK\nNY\"\t\"10003-6722\nUNITED STATES OF AMERICA (THE)\"\t320152710713208356\tRestaurant-Restaurant\n2015-09-30\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-4.77\t\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"\t\t\t\t320152730747361096\tFees & Adjustments-Fees & Adjustments\n2015-10-01\tSUBWAY MIAMI FL\tVera O'Connor\tXXXX-XXXXXX-41106\t117\t\" Additional Information: 305-6700041 \n \"\tSUBWAY\t\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\"\t\"33156-2715\nUNITED STATES OF AMERICA (THE)\"\t320152750771031096\tRestaurant-Bar & Café\n2015-10-03\tUSPS PO BOXES 101510WASHINGTON DC\tClare Dudley\tXXXX-XXXXXX-41098\t156\t\" Additional Information: 800-3447779 \n \"\tUSPS PO BOXES ONLINE\t\"475 LENFANT PLZ SW\nWASHINGTON\nDC\"\t\"20260-0004\nUNITED STATES OF AMERICA (THE)\"\t320152770808332661\tBusiness Services-Mailing & Shipping\n2015-10-04\tWHOLEFDS TRB 10245 02123496555\tVera O'Connor\tXXXX-XXXXXX-41106\t13.48\t\" Additional Information: 2123496555 \n  GROCERY STORES \n \"\tWHOLE FOODS MARKET\t\"270 GREENWICH ST\nNEW YORK\nNY\"\t\"10007-1150\nUNITED STATES OF AMERICA (THE)\"\t320152780825369796\tMerchandise & Supplies-Groceries\n2015-10-12\tAUTOPAY PAYMENT RECEIVED - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-304.24\t\" TD BANK, NATIONAL ASSOCIATION \n \"\t\t\t\t320152850946530301\t\n2015-10-15\tGROUPON INC 877-788-7858 IL\tVera O'Connor\tXXXX-XXXXXX-41106\t39.5\t\" Additional Information: COUPONS \n \"\tGROUPON INC\t\"600 W CHICAGO AVE\nSTE 400\nCHICAGO\nIL\"\t\"60654-2067\nUNITED STATES OF AMERICA (THE)\"\t320152890013469812\tMerchandise & Supplies-Internet Purchase\n2015-10-16\tPIZZA MERCATO NEW YORK NY\tVera O'Connor\tXXXX-XXXXXX-41106\t57.75\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PL\nNEW YORK\nNY\"\t\"10003-6722\nUNITED STATES OF AMERICA (THE)\"\t320152920061757486\tRestaurant-Restaurant\n2015-10-16\tPIZZA MERCATO NEW YORK NY\tDarius Burgess\tXXXX-XXXXXX-41148\t774.62\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PL\nNEW YORK\nNY\"\t\"10003-6722\nUNITED STATES OF AMERICA (THE)\"\t320152920062466899\tRestaurant-Restaurant\n2015-10-21\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t77.3\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152950098979714\tMerchandise & Supplies-Internet Purchase\n2015-10-21\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t77.3\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152950098983888\tMerchandise & Supplies-Internet Purchase\n2015-10-21\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t77.3\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152950098983890\tMerchandise & Supplies-Internet Purchase\n2015-10-21\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t77.3\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152950098983894\tMerchandise & Supplies-Internet Purchase\n2015-10-22\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t77.3\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152960115550127\tMerchandise & Supplies-Internet Purchase\n2015-10-23\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t-77.3\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152970133427869\tMerchandise & Supplies-Internet Purchase\n2015-10-23\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t-77.3\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152970133427871\tMerchandise & Supplies-Internet Purchase\n2015-10-23\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t-77.3\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152970133427873\tMerchandise & Supplies-Internet Purchase\n2015-10-23\tINTUIT PAYROLL 888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t-77.3\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320152970133464913\tMerchandise & Supplies-Internet Purchase\n2015-10-27\tMES*RINGCENTRAL, INC6504724100\tVera O'Connor\tXXXX-XXXXXX-41106\t160.56\t\" Additional Information: 3719004008 94002 \n \"\tMES*RINGCENTRAL, INC\t\"1400 FASHION IS\nSAN MATEO\nCA\"\t\"944\nUNITED STATES OF AMERICA (THE)\"\t320153010200395072\tCommunications-Mobile Telecom\n2015-10-30\tFRESH & CO NEW YORK\tVera O'Connor\tXXXX-XXXXXX-41106\t137.89\t\" Additional Information: FAST FOOD RESTAURANT \n  Description \n  105071 \n \"\tFRESH & CO\t\"58 EAST 8TH ST\nNEW YORK\nNY\"\t\"10003\nUNITED STATES OF AMERICA (THE)\"\t320153060292051275\tRestaurant-Bar & Café\n2015-10-30\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-4.74\t\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \"\t\t\t\t320153030245166855\tFees & Adjustments-Fees & Adjustments\n2015-11-12\tAUTOPAY PAYMENT RECEIVED - THANK YOU\tHoward Washington\tXXXX-XXXXXX-43003\t-1981.87\t\" TD BANK, NATIONAL ASSOCIATION \n \"\t\t\t\t320153160462707016\t\n2015-11-13\tPIZZA MERCATO NEW YORK NY\tDarius Burgess\tXXXX-XXXXXX-41148\t774.62\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PL\nNEW YORK\nNY\"\t\"10003-6722\nUNITED STATES OF AMERICA (THE)\"\t320153200524725369\tRestaurant-Restaurant\n2015-11-15\tPIZZA MERCATO NEW YORK NY\tVera O'Connor\tXXXX-XXXXXX-41106\t93.8\t\" Additional Information: 212-420-8432 \n \"\tPIZZA MERCATO\t\"11 WAVERLY PL\nNEW YORK\nNY\"\t\"10003-6722\nUNITED STATES OF AMERICA (THE)\"\t320153210543933275\tRestaurant-Restaurant\n2015-11-20\tNJT NY PENN STA 50NEW YORK NJ\tDarius Burgess\tXXXX-XXXXXX-41148\t1011.75\t\" Additional Information: 973-2755555 \n \"\tNJ TRANSIT\t\"NEW YORK PENN STATION\n34TH & 7TH AVENUES\nNEW YORK\nNY\"\t\"10016\nUNITED STATES OF AMERICA (THE)\"\t320153250615656980\tTransportation-Rail Services\n2015-11-21\tHN-DUNKIN ST212 0000NEW YORK NY\tDarius Burgess\tXXXX-XXXXXX-41148\t43.14\t\" Additional Information: 800-326-7711 \n  Description \n  GROCERIES/SUNDRIES \n \"\tHUDSON NEWS\t\"8TH AVE -PENN STN TKT LVL\nNEW YORK\nNY\"\t\"10001\nUNITED STATES OF AMERICA (THE)\"\t320153260627158328\tMerchandise & Supplies-Book Stores\n2015-11-21\tPJS PANCAKE HOUSE 65PRINCETON NJ\tDarius Burgess\tXXXX-XXXXXX-41148\t127.75\t\" Additional Information: 6099241353 \n \"\tP J'S PANCAKE HOUSE RESTAURANT\t\"154 NASSAU ST\nPRINCETON\nNJ\"\t\"08542-7006\nUNITED STATES OF AMERICA (THE)\"\t320153260617461233\tRestaurant-Restaurant\n2015-11-23\t888-537-7794 CA\tHoward Washington\tXXXX-XXXXXX-43003\t79.48\t\tPAYCYCLE INC\t\"210 PORTAGE AVE\nPALO ALTO\nCA\"\t\"94306-2242\nUNITED STATES OF AMERICA (THE)\"\t320153280650085362\tMerchandise & Supplies-Internet Purchase\n2015-12-02\tSTAPLES 00193 NEW YORK NY\tVera O'Connor\tXXXX-XXXXXX-41106\t3.74\t\"001006221 00193001006221 10007\n00193001006221 10007\nBW SS P@SS LTR/LGL\nCLASP ENV BRN KRAFT 9X12 -12\n\n\n\n 00193001006221 10007 \n  BW SS P@SS LTR/LGL  \n  CLASP ENV BRN KRAFT 9X12 -12  \n \"\tSTAPLES\t\"217 BROADWAY\nNEW YORK\nNY\"\t\"10007-2909\nUNITED STATES OF AMERICA (THE)\"\t320153370819408517\tBusiness Services-Office Supplies\n2015-12-02\tSTAPLES 00193 NEW YORK NY\tVera O'Connor\tXXXX-XXXXXX-41106\t9.15\t\"002558833 00193002558833 10007\n00193002558833 10007\nBW SS P@SS LTR/LGL\n\n\n\n\n 00193002558833 10007 \n  BW SS P@SS LTR/LGL  \n \"\tSTAPLES\t\"217 BROADWAY\nNEW YORK\nNY\"\t\"10007-2909\nUNITED STATES OF AMERICA (THE)\"\t320153370816757331\tBusiness Services-Office Supplies\n2015-12-03\tYOUR CASH BACK THIS PERIOD IS\tHoward Washington\tXXXX-XXXXXX-43003\t-19.87\t\t\t\t\t320153390865190560\tFees & Adjustments-Fees & Adjustments\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t148.79\t\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390850587329\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t148.79\t\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390850900842\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t148.79\t\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851052217\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t148.79\t\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851183402\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t148.79\t\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851243871\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t148.79\t\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851421525\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390850586825\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390850586860\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390850743209\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390850743367\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390850901096\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851051765\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851051982\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851052120\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851182737\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851183168\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851421352\tTravel-Lodging\n2015-12-04\tCHARLES HOTEL CAMBRIDGE MA\tDarius Burgess\tXXXX-XXXXXX-41148\t177.4\t\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \"\tCHARLES HOTEL\t\"1 BENNETT ST\nCAMBRIDGE\nMA\"\t\"02138-5707\nUNITED STATES OF AMERICA (THE)\"\t320153390851421393\tTravel-Lodging\n"
  },
  {
    "path": "test/fixtures/export-tsv/text.tsv",
    "content": "Foo\tBar\tId is Baz Label is this\tLink\tFormula\n1\ta\thello\tgrist https://www.getgrist.com/\ta --- grist https://www.getgrist.com/\n2\tb ,d\tworld\thttps://www.getgrist.com/\tb ,d --- https://www.getgrist.com/\n3\t\"the \"\"quote marks\"\" ?\"\t\t\t\"the \"\"quote marks\"\" ? --- \"\n"
  },
  {
    "path": "test/fixtures/plugins/.jshintrc",
    "content": "{\n  \"undef\": true,\n  \"unused\": \"vars\",\n  \"globalstrict\": true,\n  \"esnext\": true\n}\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/browser-GristDocAPI/main.js",
    "content": "\n\n/* global grist, self */\n\nself.importScripts(\"/grist-plugin-api.js\");\n\ngrist.rpc.registerImpl(\"testApiBrowser\", {\n  getImportSource() {\n    const api = grist.rpc.getStub(\"GristDocAPI@grist\", grist.checkers.GristDocAPI);\n    return api.getDocName()\n      .then((result) => {\n        const content = JSON.stringify({\n          tables: [{\n            table_name: \"\",\n            column_metadata: [{\n              id: \"getDocName\",\n              type: \"Text\"\n            }],\n            table_data: [[result]]\n          }]\n        });\n        const fileItem = {content, name: \"GristDocAPI.jgrist\"};\n        return {\n          item: { kind: \"fileList\", files: [fileItem] },\n          description: \"GristDocAPI results\"\n        };\n      });\n  }\n});\n\ngrist.ready();\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/browser-GristDocAPI/manifest.yml",
    "content": "name: browser-GristDocAPI\nversion: 0.0.0\ndescription:\ncomponents:\n  safeBrowser: main.js\n\ncontributions:\n  importSources:\n    - importSource:\n        component: safeBrowser\n        name: testApiBrowser\n      label: Test GristDocAPI\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/index-bis.html",
    "content": "<html>\n  <body>\n    <h1 id=\"hello-bis-title\">Hello Bis</h1>\n    </br>\n    <!-- numerous lines to produce a scrollable area !-->\n    0</br></br>\n    1</br></br>\n    2</br></br>\n    3</br></br>\n    4</br></br>\n    5</br></br>\n    6</br></br>\n    7</br></br>\n    8</br></br>\n    9</br></br>\n    10</br></br>\n    11</br></br>\n    12</br></br>\n    13</br></br>\n    14</br></br>\n    15</br></br>\n    16</br></br>\n    17</br></br>\n    18</br></br>\n    19</br></br>\n    20</br></br>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/index.html",
    "content": "<html>\n  <body>\n    <h1 id=\"hello-title\">Hello</h1>\n    </br>\n    <!-- numerous lines to produce a scrollable area !-->\n    0</br></br>\n    1</br></br>\n    2</br></br>\n    3</br></br>\n    4</br></br>\n    5</br></br>\n    6</br></br>\n    7</br></br>\n    8</br></br>\n    9</br></br>\n    10</br></br>\n    11</br></br>\n    12</br></br>\n    13</br></br>\n    14</br></br>\n    15</br></br>\n    16</br></br>\n    17</br></br>\n    18</br></br>\n    19</br></br>\n    20</br></br>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/main.js",
    "content": "\n\n/* globals self, grist */\n\nself.importScripts(\"/grist-plugin-api.js\");\n\nclass CustomSection {\n  createSection(renderTarget) {\n    return grist.api.render(\"index.html\", renderTarget);\n  }\n}\n\ngrist.rpc.registerImpl(\"hello\", new CustomSection(), grist.CustomSectionDescription);\ngrist.ready();\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/manifest.yml",
    "content": "name: helloSection\nversion: 0.0.0\ncomponents:\n  safeBrowser: main.js\ncontributions:\n  customSections:\n    - path: index.html\n      name: Hello World\n    - path: index-bis.html\n      name: Hello World (bis)\n    - path: test-subscribe-api.html\n      name: dataAPI test\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/test-subscribe-api.html",
    "content": "<html>\n  <head>\n    <script src=\"/grist-plugin-api.js\"></script>\n    <script src=\"/plugins/assets/jquery/dist/jquery.min.js\"></script>\n    <script src=\"test-subscribe-api.js\"></script>\n  </head>\n  <body>\n    <h1 id=\"data-api-section\">Data API</h1>\n    <div id=\"panel\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/custom-section/test-subscribe-api.js",
    "content": "\n\n/* global grist, window, $, document */\nlet tableId = \"Table1\";\n\ngrist.ready();\ngrist.api.subscribe(tableId);\n\nwindow.onload = () => {\n  showColumn(\"A\");\n};\n\ngrist.rpc.on(\"message\", (msg) => {\n  if (msg.type === \"docAction\") {\n    // There could by many doc actions and fetching table is expensive, in practice this call would\n    // be be throttle\n    if (msg.action[0] === \"RenameTable\") {\n      tableId = msg.action[2];\n    }\n    showColumn(\"A\");\n  }\n});\n\n// fetch table and call the view with values of coldId\nfunction showColumn(colId) {\n  grist.docApi.fetchTable(tableId).then(cols => updateView(cols[colId]));\n}\n\n// show the first column\nfunction updateView(values) {\n  $(\"#panel\").empty();\n  const res = $('<div class=\"result\"></div>');\n  const text = document.createTextNode(JSON.stringify(values));\n  res.append(text);\n  $(\"#panel\").append(res);\n}\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/index.html",
    "content": "<html>\n  <head>\n    <script src=\"/grist-plugin-api.js\"></script>\n    <script src=\"script.js\"></script>\n    <!-- jquery is required for running browser test (see: `test/browser/webdriverjq.js`) -->\n    <script src=\"/plugins/assets/jquery/dist/jquery.min.js\"></script>\n    <style type=\"text/css\">\n      body {\n        background-color: #ffffffb0;\n      }\n    </style>\n  </head>\n  <body>\n    <input id=\"call-safePython\" type=\"button\" value=\"call safePython\">\n    <input id=\"call-unsafeNode\" type=\"button\" value=\"call unsafeNode\">\n    <input id=\"cancel\" type=\"button\" value=\"cancel\">\n    <br/>\n    \"name of the file: \"\n    <input id=\"name\" type=\"text\">\n    <input id=\"ok\" type=\"button\" value=\"validate\">\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/main.js",
    "content": "\n\n/* global grist, self */\n\nself.importScripts(\"/grist-plugin-api.js\");\n\ngrist.addImporter(\"dummy\", \"index.html\", \"fullscreen\");\ngrist.addImporter(\"dummy-inlined\", \"index.html\", \"inline\");\ngrist.ready();\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/manifest.yml",
    "content": "name: pluginName\nversion: 0.0.1\ncomponents:\n  safeBrowser: main.js\n  safePython: sandbox/main.py\n  unsafeNode: node/main.js\ncontributions:\n  importSources:\n    - importSource:\n        component: safeBrowser\n        name: dummy\n      label: Dummy importer\n    - importSource:\n        component: safeBrowser\n        name: dummy-inlined\n      label: Inline Importer\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/node/main.js",
    "content": "const grist = require(\"grist-plugin-api\");\n\ngrist.rpc.registerFunc(\"func1\", (name) => `Yo: ${name}`);\ngrist.ready();\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/sandbox/main.py",
    "content": "import sandbox\n\ndef greet(val):\n  return \"With love: \" + val\n\ndef main():\n  sandbox.register(\"func1\", greet)\n  sandbox.run()\n\nif __name__ == \"__main__\":\n  main()\n"
  },
  {
    "path": "test/fixtures/plugins/browserInstalledPlugins/plugins/dummy-importer/script.js",
    "content": "\n\n/* global grist, window, document, $ */\n\nlet resolve;\n\nconst importer = {\n  getImportSource: () => new Promise((_resolve) => {\n    resolve = _resolve;\n  })\n};\n\ngrist.rpc.registerImpl(\"dummy\", importer );\ngrist.rpc.registerImpl(\"dummy-inlined\", importer );\n\ngrist.ready();\n\nwindow.onload = function() {\n  callFunctionOnClick(\"#call-safePython\", \"func1@sandbox/main.py\", \"Bob\");\n  callFunctionOnClick(\"#call-unsafeNode\", \"func1@node/main.js\", \"Alice\");\n  document.querySelector(\"#cancel\").addEventListener(\"click\", () => resolve());\n  document.querySelector(\"#ok\").addEventListener(\"click\", () => {\n    const name = $(\"#name\").val();\n    resolve({\n      item: {\n        kind: \"fileList\",\n        files: [{content: \"A,B\\n1,2\\n\", name}]\n      },\n      description: name + \" selected!\"\n    });\n  });\n};\n\nfunction callFunctionOnClick(selector, funcName, ...args) {\n  document.querySelector(selector).addEventListener(\"click\", () => {\n    grist.rpc.callRemoteFunc(funcName, ...args)\n      .then(val => {\n        const resElement = document.createElement(\"h1\");\n        resElement.classList.add(`result`);\n        resElement.textContent = val;\n        document.body.appendChild(resElement);\n      });\n  });\n}\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/2/manifest.yml",
    "content": "version: 0.0.1\ncontributions:\n  importSources:\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/experimental-plugin/manifest.yml",
    "content": "name: crazy-plugin\nversion: 0.0.1\nexperimental: true\ncomponents:\n  safePython: sandbox/main.py\n\ncontributions:\n  fileParsers:\n    - fileExtensions: [\"csv\"]\n      parseFile:\n        component: \"safePython\"\n        name: \"csv_parser\"\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/experimental-plugin/sandbox/main.py",
    "content": "# nothing\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/invalid-contrib-point/manifest.yml",
    "content": "version: 0.0.1\ncontributions:\n  invalidContibutionPoint:\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/long-call/manifest.yml",
    "content": "name: pluginName\nversion: 0.0.1\ncomponents:\n  safePython: sandbox/main.py\n  deactivate:\n    # Let's keep it low for tests to be fast, but big enough for test to be accurate.\n    inactivitySec: 0.1\n\ncontributions:\n  fileParsers:\n    - fileExtensions: [\"csv\"]\n      parseFile:\n        component: \"safePython\"\n        name: \"csv_parser\"\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/long-call/sandbox/main.py",
    "content": "import time\nimport sandbox\n\n# pylint: disable=unused-argument\n# pylint: disable=no-member\n\ndef import_files(file_source, parse_options):\n  end = time.time() + 1\n  while time.time() < end:\n    pass\n  return {\n    \"parseOptions\": {},\n    # Make sure the output is a list of GristTables as documented at app/plugin/GristTable.ts\n    \"tables\": [{\n      \"table_name\": \"mytable\",\n      \"column_metadata\": [],\n      \"table_data\": [],\n    }]\n  }\n\n\ndef main():\n  sandbox.register(\"csv_parser.parseFile\", import_files)\n  sandbox.run() # pylint: disable=no-member\n\n\nif __name__ == \"__main__\":\n  main()\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/missing-component/manifest.yml",
    "content": "name: missing-components\nversion: 0.0.1\n# missing `components` entry\ncontributions:\n  fileParsers:\n    - fileExtensions: [\"csv\"]\n      parseFile:\n        component: \"safePython\"\n        name: \"csv_parser\"\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/missing-safePython/manifest.yml",
    "content": "name: missing-safePython\nversion: 0.0.1\ncomponents:\n  # missing `safePython` component\ncontributions:\n  fileParsers:\n    - fileExtensions: [\"csv\"]\n      parseFile:\n        component: \"safePython\"\n        name: \"csv_parser\"\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/safePython-deactivate-fast/manifest.yml",
    "content": "name: pluginName\nversion: 0.0.1\ncomponents:\n  safePython: sandbox/main.py\n  deactivate:\n    # Let's keep it low for tests to be fast, but big enough for test to be accurate.\n    inactivitySec: 0.1\n\ncontributions:\n  fileParsers:\n    - fileExtensions: [\"csv\"]\n      parseFile:\n        component: \"safePython\"\n        name: \"csv_parser\"\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/safePython-deactivate-fast/sandbox/main.py",
    "content": "import sandbox\n\n# pylint: disable=unused-argument\n# pylint: disable=no-member\n\n# TODO: configure pylint behavior for both `test/fixtures/plugins` and\n# `/plugins` folders: either to ignore them completely or to ignore\n# above mentioned rules.\n\ndef import_files(file_source, parse_options=None):\n  return {\n    \"parseOptions\": {},\n    \"tables\": [{\n      \"table_name\": \"mytable\",\n      \"column_metadata\": [],\n      \"table_data\": []\n    }]}\n\n\ndef main():\n  # Todo: Grist should expose a register method accepting arguments as\n  # follow: register('csv_parser', 'importFiles', can_parse)\n  sandbox.register(\"csv_parser.parseFile\", import_files)\n  sandbox.run() # pylint: disable=no-member\n\n\nif __name__ == \"__main__\":\n  main()\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/backend.js",
    "content": "const grist = require(\"grist-plugin-api\");\n\ngrist.rpc.registerFunc(\"yo\", (name) => `yo ${name}`);\ngrist.rpc.registerFunc(\"yoSafePython\", (name) => grist.rpc.callRemoteFunc(\"yo@sandbox/main.py\", name));\ngrist.ready();\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/manifest.yml",
    "content": "name: testPluginFunction\nversion: 0.0.1\ncomponents:\n  safePython: sandbox/main.py\n  unsafeNode: backend.js\n  safeBrowser: main.js\n\n# For the purpose of this unit-test contributions property is actually\n# NOT need and only provided for the sake of making this manifest\n# valid,\n\ncontributions:\n  importSources:\n    - importSource:\n        component: \"safeBrowser\"\n        name: index.html\n      label: My safe importer\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/testing-function-call-plugin/sandbox/main.py",
    "content": "import sandbox\n\ndef greet(name):\n  return \"Hi \" + name\n\ndef yo(name):\n  return \"yo \" + name + \" from safePython\"\n\ndef main():\n  sandbox.register(\"greet\", greet)\n  sandbox.register(\"yo\", yo)\n  sandbox.run()\n\nif __name__ == \"__main__\":\n  main()\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/valid-file-parser/manifest.yml",
    "content": "name: pluginName\nversion: 0.0.1\ncomponents:\n  safePython: sandbox/main.py\n\ncontributions:\n  fileParsers:\n    - fileExtensions: [\"csv\"]\n      parseFile:\n        component: \"safePython\"\n        name: \"csv_parser\"\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/valid-file-parser/sandbox/main.py",
    "content": "import sandbox\n\n# pylint: disable=unused-argument\n# pylint: disable=no-member\n\n# TODO: configure pylint behavior for both `test/fixtures/plugins` and\n# `/plugins` folders: either to ignore them completely or to ignore\n# above mentioned rules.\n\ndef import_files(file_source, parse_options):\n  parse_options.update({\"NUM_ROWS\" : 1})\n  return {\n    \"parseOptions\": parse_options,\n    \"tables\": [{\n      \"table_name\": \"mytable\",\n      \"column_metadata\": [],\n      \"table_data\": []\n    }]}\n\n\ndef main():\n  # Todo: Grist should expose a register method accepting arguments as\n  # follow: register('csv_parser', 'parseFile', can_parse)\n  sandbox.register(\"csv_parser.parseFile\", import_files)\n  sandbox.run() # pylint: disable=no-member\n\n\nif __name__ == \"__main__\":\n  main()\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/valid-import-source/manifest.yml",
    "content": "name: pluginName\nversion: 0.0.1\ncomponents:\n  safeBrowser: '.'\ncontributions:\n  importSources:\n    - importSource:\n        component: \"safeBrowser\"\n        name: index.html\n      label: My safe importer\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/wrong-json/manifest.json",
    "content": "wrong manifest as well in json\n"
  },
  {
    "path": "test/fixtures/plugins/builtInPlugins/plugins/wrong-yaml/manifest.yml",
    "content": ":some-wrong-manifest\n"
  },
  {
    "path": "test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/TestSubscribe.js",
    "content": "const grist = require(\"grist-plugin-api\");\n\nconst {foo} = grist.rpc.getStub(\"foo@grist\");\nlet tableId = \"Table1\";\nconst colId = \"A\";\nlet promise = Promise.resolve(true);\n\ngrist.rpc.on(\"message\", msg => {\n  if (msg.type === \"docAction\") {\n    if (msg.action[0] === \"RenameTable\") {\n      tableId = msg.action[2];\n    }\n    promise = getColValues(colId).then(foo);\n  }\n});\n\nfunction getColValues(colId) {\n  return grist.docApi.fetchTable(tableId).then(data => data[colId]);\n}\n\nclass TestSubscribe {\n\n  invoke(api, name, args){\n    return grist[api][name](...args);\n  }\n\n  // Returns a promise that resolves when an ongoing call resolves. Resolves right-awa if plugin has\n  // no pending call.\n  waitForPlugin() {\n    return promise.then(() => true);\n  }\n}\n\nmodule.exports = TestSubscribe;\n"
  },
  {
    "path": "test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/main.js",
    "content": "const grist = require(\"grist-plugin-api\");\nconst TestSubscribe = require(\"./TestSubscribe\");\n\ngrist.rpc.registerImpl(\"testApiNode\", { // todo rename to testGristDocApiNode\n  invoke: (name, args) => {\n    const api = grist.rpc.getStub(\"GristDocAPI@grist\", grist.checkers.GristDocAPI);\n    return api[name](...args)\n      .then((result) => [`node-GristDocAPI ${name}(${args.join(\",\")})`, result]);\n  },\n});\n\ngrist.rpc.registerImpl(\"testDocStorage\", {\n  invoke: (name, args) => {\n    const api = grist.rpc.getStub(\"DocStorage@grist\", grist.checkers.Storage);\n    return api[name](...args);\n  },\n});\n\ngrist.rpc.registerImpl(\"testSubscribe\", new TestSubscribe());\n\ngrist.ready();\n"
  },
  {
    "path": "test/fixtures/plugins/installedPlugins/plugins/node-GristDocAPI/manifest.yml",
    "content": "name: node-GristDocAPI\nversion: 0.0.0\ndescription:\ncomponents:\n  unsafeNode: main.js\n\ncontributions: {}\n"
  },
  {
    "path": "test/fixtures/plugins/installedPlugins/plugins/node-fail/main.js",
    "content": "\n\n// die immediately.\n"
  },
  {
    "path": "test/fixtures/plugins/installedPlugins/plugins/node-fail/manifest.yml",
    "content": "name: node-fail\nversion: 0.0.0\ndescription:\ncomponents:\n  unsafeNode: main.js\ncontributions:\n  fileParsers:\n    - fileExtensions: [\"csv\"]\n      parseFile:\n        component: unsafeNode\n        name: node-fail\n"
  },
  {
    "path": "test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/manifest.yml",
    "content": "name: minicsv\nversion: 0.0.0\ndescription: minicsv\ncomponents:\n  unsafeNode: nodebox/main.js\ncontributions:\n  fileParsers:\n    - fileExtensions: [\"csv\"]\n      parseFile:\n        component: unsafeNode\n        name: MiniCSV\n"
  },
  {
    "path": "test/fixtures/plugins/installedPlugins/plugins/node-mini-csv/nodebox/main.js",
    "content": "/**\n *\n * A minimal CSV reader with no type detection.\n * All communication done by hand - real plugins should have helper code for\n * RPC.\n *\n */\n\nconst csv = require(\"csv\");\nconst fs = require(\"fs\");\nconst path = require(\"path\");\n\nfunction readCsv(data, replier) {\n  csv.parse(data, {}, function(err, output) {\n    const result = {\n      parseOptions: {\n        options: \"\"\n      },\n      tables: [\n        {\n          table_name: \"space-monkey\" + require(\"dependency_test\"),\n          column_metadata: output[0].map(name => {\n            return {\n              id: name,\n              type: \"Text\"\n            };\n          }),\n          table_data: output[0].map((name, idx) => {\n            return output.slice(1).map(row => row[idx]);\n          })\n        }\n      ]\n    };\n    replier(result);\n  });\n}\n\nfunction processMessage(msg, replier, error_replier) {\n  if (msg.meth == \"parseFile\") {\n    var dir = msg.dir;\n    var fname = msg.args[0].path;\n    var data = fs.readFileSync(path.resolve(dir, fname));\n    readCsv(data, replier);\n  } else {\n    error_replier(\"unknown method\");\n  }\n}\n\nprocess.on(\"message\", (m) => {\n  const sendReply = (result) => {\n    process.send({\n      mtype: 2, /* RespData */\n      reqId: m.reqId,\n      data: result\n    });\n  };\n  const sendError = (txt) => {\n    process.send({\n      mtype: 3, /* RespErr */\n      reqId: m.reqId,\n      mesg: txt\n    });\n  };\n  processMessage(m, sendReply, sendError);\n});\n\n// Once we have a handler for 'message' set up, send home a ready\n// message to give the all-clear.\nprocess.send({ mtype: 4, data: {ready: true }});\n"
  },
  {
    "path": "test/fixtures/plugins/installedPlugins/plugins/node-wrong-message/main.js",
    "content": "\n\nprocess.send({ greeny: true });\n"
  },
  {
    "path": "test/fixtures/plugins/installedPlugins/plugins/node-wrong-message/manifest.yml",
    "content": "name: node-wrong-message\nversion: 0.0.0\ndescription:\ncomponents:\n  unsafeNode: main.js\ncontributions:\n  fileParsers:\n    - fileExtensions: [\"csv\"]\n      parseFile:\n        component: unsafeNode\n        name: node-wrong-message\n"
  },
  {
    "path": "test/fixtures/plugins/installedPlugins/plugins/valid-import-source/manifest.yml",
    "content": "name: validPluginName\nversion: 0.0.1\ncomponents:\n  safeBrowser: '.'\ncontributions:\n  importSources:\n    - importSource:\n        component: \"safeBrowser\"\n        name: index.html\n      label: My custom safe importer\n"
  },
  {
    "path": "test/fixtures/projects/AddNewButton.ts",
    "content": "import { addNewButton } from \"app/client/ui/AddNewButton\";\nimport { resizeFlexVHandle } from \"app/client/ui/resizeHandle\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\n\nimport { dom, makeTestId, observable, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-add-\");\n\nfunction setupTest() {\n  const isOpen = observable(false);\n  return [\n    testBox(\n      cssFlex(\n        { style: \"width: 480px\" },\n        cssButtonBox({ style: \"width: 160px;\" }, addNewButton({ isOpen: true })),\n        resizeFlexVHandle({ target: \"left\", onSave: () => null }, testId(\"left-resizer\")),\n        cssButtonBox({ style: \"flex: 1 1 0px;\" }, addNewButton({ isOpen: true })),\n      ),\n      cssFlex(\n        dom(\"div\", { style: \"margin: auto 16px\" },\n          dom(\"input\", { type: \"checkbox\" },\n            testId(\"expand\"),\n            dom.prop(\"checked\", isOpen),\n            dom.on(\"change\", (ev, elem) => isOpen.set(elem.checked)),\n          ),\n          \"Expand this button\",\n        ),\n        dom(\"div\", { style: \"flex: none\" },\n          dom.style(\"width\", use => use(isOpen) ? \"240px\" : \"48px\"),\n          addNewButton({ isOpen })),\n      ),\n    ),\n  ];\n}\n\nconst cssFlex = styled(\"div\", `display: flex; position: relative; height: 100px`);\nconst cssButtonBox = styled(\"div\", `min-width: 160px; max-width: 320px;`);\n\nconst testBox = styled(\"div\", `\n  width: 80vw;\n  margin: 1rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n  overflow: hidden;\n`);\n\ninitGristStyles();\ndom.update(document.body, setupTest());\n"
  },
  {
    "path": "test/fixtures/projects/ApiKey.ts",
    "content": "import { ApiKey } from \"app/client/ui/ApiKey\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport * as bluebird from \"bluebird\";\nimport { dom, observable, styled } from \"grainjs\";\n\nconst apiKeys = [\n  \"9204c0f1ea5928b31e4e21e55cf975e874281d8e\",\n  \"e03ab513535137a7ec60978b40c9a896db6d8706\"];\nlet i = 0;\n\n// a delay below 200 was breaking test on dev environment.\nconst delay = () => bluebird.delay(300);\n\nfunction newApiKey() {\n  return apiKeys[++i % 2];\n}\n\nfunction setupTest() {\n  const apiKey = observable(\"\");\n\n  async function onCreate() {\n    await delay();\n    apiKey.set(newApiKey());\n  }\n\n  async function onDelete() {\n    await delay();\n    apiKey.set(\"\");\n  }\n\n  return [\n    testBox(dom.create(ApiKey, { apiKey, onCreate, onDelete })),\n  ];\n}\n\nconst testBox = styled(\"div\", `\n  float: left;\n  width: 25rem;\n  font-family: sans-serif;\n  font-size: 1rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n  padding: 1rem;\n  margin: 1rem;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/ColorSelect.ts",
    "content": "import { ColorOption, colorSelect } from \"app/client/ui2018/ColorSelect\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { Disposable, dom, IDisposableOwner, makeTestId, obsArray, Observable, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-\");\n\nfunction toBool(value: string) {\n  return value === \"\" ? undefined : value === \"true\";\n}\n\nfunction optionToString(value?: boolean) {\n  return value === undefined ? \"undefined\" : String(value);\n}\n\nfunction obsOption() {\n  return Observable.create(null, undefined) as Observable<boolean | undefined>;\n}\n\n/**\n * TODO: This borrows code from `fixtures/projects/editableLabel` which could be factored out.\n */\nclass SaveableSetup extends Disposable {\n  public savedTextColor = Observable.create(null, \"#000000\");\n  public savedFillColor = Observable.create(null, \"#FFFFFF\");\n\n  public textColor = Observable.create(null, \"#000000\");\n  public fillColor = Observable.create(null, \"#FFFFFF\");\n\n  public savedFontBold = obsOption();\n  public savedFontItalic = obsOption();\n  public savedFontUnderline = obsOption();\n  public savedFontStrikethrough = obsOption();\n\n  public fontBold = obsOption();\n  public fontItalic = obsOption();\n  public fontUnderline = obsOption();\n  public fontStrikethrough = obsOption();\n\n  public textColorInput: HTMLInputElement;\n  public fillColorInput: HTMLInputElement;\n\n  public fontBoldInput: HTMLInputElement;\n  public fontItalicInput: HTMLInputElement;\n  public fontUnderlineInput: HTMLInputElement;\n  public fontStrikethroughInput: HTMLInputElement;\n\n  // A log of calls made and completed, for testing the sequence of events.\n  public callLog = this.autoDispose(obsArray<string>([]));\n\n  constructor() {\n    super();\n\n    // exposes a way to trigger update directly from webdriver:\n    // driver.executeScript('triggerUpdate()')\n    (window as any).triggerUpdate = () => this.onServerUpdate();\n  }\n\n  public onServerUpdate() {\n    this.savedTextColor.set(this.textColorInput.value);\n    this.savedFillColor.set(this.fillColorInput.value);\n    this.textColor.set(this.textColorInput.value);\n    this.fillColor.set(this.fillColorInput.value);\n\n    this.savedFontBold.set(toBool(this.fontBoldInput.value));\n    this.savedFontItalic.set(toBool(this.fontItalicInput.value));\n    this.savedFontUnderline.set(toBool(this.fontUnderlineInput.value));\n    this.savedFontStrikethrough.set(toBool(this.fontStrikethroughInput.value));\n\n    this.fontBold.set(toBool(this.fontBoldInput.value));\n    this.fontItalic.set(toBool(this.fontItalicInput.value));\n    this.fontUnderline.set(toBool(this.fontUnderlineInput.value));\n    this.fontStrikethrough.set(toBool(this.fontStrikethroughInput.value));\n  }\n\n  public async makeSaveCall(): Promise<void> {\n    const callValue = JSON.stringify({\n      fill: this.fillColor.get(),\n      text: this.textColor.get(),\n      bold: this.fontBold.get(),\n      underline: this.fontUnderline.get(),\n      italic: this.fontItalic.get(),\n      strikethrough: this.fontStrikethrough.get(),\n    });\n    this.callLog.push(`Called: ${callValue}`);\n  }\n\n  public buildDom() {\n    // To test server changes while editableLabel is being edited, listen to a Ctrl-U key\n    // combination to act as the \"Update\" button without affecting focus.\n    this.autoDispose(dom.onElem(document.body, \"keydown\", (ev) => {\n      if (ev.code === \"KeyU\" && ev.ctrlKey) {\n        this.onServerUpdate();\n      }\n    }));\n\n    return [\n      testBox(\n        cssItem(\n          dom(\"h3\", \"Server value\"),\n          cssRow(\n            cssHeader(\"text: \"),\n            cssCellBox(dom.style(\"background-color\", this.savedTextColor)),\n            cssCellBox(dom.text(this.savedTextColor)),\n          ),\n          cssRow(\n            cssHeader(\"fill: \"),\n            cssCellBox(dom.style(\"background-color\", this.savedFillColor)),\n            cssCellBox(dom.text(this.savedFillColor)),\n          ),\n          cssRow(\n            cssHeader(\"bold: \"),\n            cssCellBox(dom.text(use => optionToString(use(this.savedFontBold)))),\n          ),\n          cssRow(\n            cssHeader(\"underline: \"),\n            cssCellBox(dom.text(use => optionToString(use(this.savedFontUnderline)))),\n          ),\n          cssRow(\n            cssHeader(\"italic: \"),\n            cssCellBox(dom.text(use => optionToString(use(this.savedFontItalic)))),\n          ),\n          cssRow(\n            cssHeader(\"strikethrough: \"),\n            cssCellBox(dom.text(use => optionToString(use(this.savedFontStrikethrough)))),\n          ),\n        ),\n        cssItem(\n          dom(\"h3\", \"Update value\"),\n          cssRow(\n            cssHeader(\"text: \"),\n            cssCellBox(\n              this.textColorInput = dom(\"input\", { value: \"#000000\" }),\n            ),\n            testId(\"text-server-value\"),\n          ),\n          cssRow(\n            cssHeader(\"fill: \"),\n            cssCellBox(\n              this.fillColorInput = dom(\"input\", { value: \"#FFFFFF\" }),\n            ),\n            testId(\"fill-server-value\"),\n          ),\n          cssRow(\n            cssHeader(\"bold: \"),\n            cssCellBox(\n              this.fontBoldInput = dom(\"input\", { value: \"\" }),\n            ),\n            testId(\"bold-server-value\"),\n          ),\n          cssRow(\n            cssHeader(\"underline: \"),\n            cssCellBox(\n              this.fontUnderlineInput = dom(\"input\", { value: \"\" }),\n            ),\n            testId(\"underline-server-value\"),\n          ),\n          cssRow(\n            cssHeader(\"italic: \"),\n            cssCellBox(\n              this.fontItalicInput = dom(\"input\", { value: \"\" }),\n            ),\n            testId(\"italic-server-value\"),\n          ),\n          cssRow(\n            cssHeader(\"strikethrough: \"),\n            cssCellBox(\n              this.fontStrikethroughInput = dom(\"input\", { value: \"\" }),\n            ),\n            testId(\"strikethrough-server-value\"),\n          ),\n          dom(\"input\", { type: \"button\", value: \"Update\" }, testId(\"server-update\"),\n            dom.on(\"click\", () => this.onServerUpdate()),\n            testId(\"server-update\")),\n        ),\n        cssItem(\n          dom(\"h3\", dom.text(\"Client\")),\n          cssRow(\n            cssHeader(\"cell: \"),\n            cssCellBox(\n              dom.style(\"color\", this.textColor),\n              dom.style(\"background-color\", this.fillColor),\n              dom.cls(\"font-bold\", use => use(this.fontBold) ?? false),\n              dom.cls(\"font-italic\", use => use(this.fontItalic) ?? false),\n              dom.cls(\"font-underline\", use => use(this.fontUnderline) ?? false),\n              dom.cls(\"font-strikethrough\", use => use(this.fontStrikethrough) ?? false),\n              dom.text(\"foo\"),\n              testId(\"client-cell\"),\n            ),\n          ),\n          colorSelect({\n            textColor: new ColorOption({ color: this.textColor }),\n            fillColor: new ColorOption({ color: this.fillColor }),\n            fontBold: this.fontBold,\n            fontItalic: this.fontItalic,\n            fontUnderline: this.fontUnderline,\n            fontStrikethrough: this.fontStrikethrough,\n          }, {\n            onSave: () => this.makeSaveCall(),\n          }),\n        ),\n      ),\n      testBox(\n        cssItem(\n          dom(\"div\", \"Call Log\"),\n          dom(\"ul\", testId(\"call-log\"),\n            dom.forEach(this.callLog, val => dom(\"li\", val))),\n        ),\n      ),\n    ];\n  }\n}\n\nfunction setupTest(owner: IDisposableOwner) {\n  const value = Observable.create(owner, dom.create(SaveableSetup));\n  return [\n    dom(\"div\", dom(\"input\", { type: \"button\", value: \"Reset All\" },\n      testId(\"reset\"),\n      dom.on(\"click\", () => value.set(dom.create(SaveableSetup))))),\n    dom(\"div\", dom.domComputed(value)),\n  ];\n}\n\nconst testBox = styled(\"div\", `\n  width: 260px;\n  padding: 16px;\n  box-shadow: 1px 1px 4px 2px #AAA;\n  margin-left: 50px;\n  margin-top: 50px;\n  float: left;\n`);\n\nconst cssCellBox = styled(\"div\", `\n  flex-grow: 1;\n  height: 30px;\n  border: 1px solid gray;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-family: monospace;\n  width: 0;\n  & input {\n    width: calc(100% - 8px);\n  }\n`);\n\nconst cssRow = styled(\"div\", `\n  display: flex;\n`);\n\nconst cssItem = styled(\"div\", `\n  margin: 25px 0;\n`);\n\nconst cssHeader = styled(\"div\", `\n  min-width: 40px;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, dom.create(setupTest)));\n"
  },
  {
    "path": "test/fixtures/projects/ColumnFilterMenu.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { ColumnFilter } from \"app/client/models/ColumnFilter\";\nimport { ColumnFilterMenuModel, IFilterCount } from \"app/client/models/ColumnFilterMenuModel\";\nimport * as modelUtil from \"app/client/models/modelUtil\";\nimport { columnFilterMenu, cssItemValue, IFilterMenuOptions } from \"app/client/ui/ColumnFilterMenu\";\nimport { createFormatter } from \"app/common/ValueFormatter\";\nimport { createParserRaw } from \"app/common/ValueParser\";\nimport { CellValue } from \"app/plugin/GristData\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, DomArg, IDisposableOwner, makeTestId, Observable, styled } from \"grainjs\";\nimport ko from \"knockout\";\nimport { noop } from \"lodash\";\nimport { IOpenController, setPopupToCreateDom } from \"popweasel\";\n\nconst testId = makeTestId(\"fixture-\");\nconst dateFormatter = createFormatter(\"Date\", { dateFormat: \"YYYY-MM-DD\" }, { locale: \"en-US\" });\nconst dateParser = createParserRaw(\"Date\", { dateFormat: \"YYYY-MM-DD\" }, { locale: \"en-US\" });\n\nconst DATA_BY_TYPES: { [k: string]: Partial<IFilterMenuOptions> } = {\n  Numeric: {\n    valueCounts: new Map(patchFilterCount([\n      [1, { label: \"1\", count: 12 }],\n      [2, { label: \"2\", count: 24 }],\n      [3, { label: \"3\", count: 1 }],\n      [7, { label: \"7\", count: 1 }],\n      [9, { label: \"9\", count: 1 }],\n      [31, { label: \"311\", count: 1 }],\n      [541, { label: \"541\", count: 1 }],\n      [44, { label: \"44\", count: 1 }],\n      [81, { label: \"81\", count: 43 }],\n    ])),\n  },\n  Date: {\n    valueCounts: new Map(([[\"2022-05-05\", 3], [\"2022-04-05\", 1], [\"2022-01-05\", 5]] as const)\n      .map(([d, count]) => {\n        const num = dateParser.cleanParse(d);\n        return [num, { label: d, count, displayValue: num }];\n      })),\n    valueParser: dateParser.cleanParse.bind(dateParser),\n    valueFormatter: dateFormatter.formatAny.bind(dateFormatter),\n  },\n  Text: {\n    valueCounts: new Map(patchFilterCount([\n      [\"Apples\",       { label: \"Apples\", count: 12 }],\n      [\"Bananas\",      { label: \"Bananas\", count: 17 }],\n      [\"Cranberries; a very very very long-named fruit\",\n        { label: \"Cranberries; a very very very long-named fruit\", count: 8000 }],\n      [\"Dates\",        { label: \"Dates\", count: 1 }],\n      [\"Figs\",         { label: \"Figs\", count: 1 }],\n      [\"Goji berries\", { label: \"Goji berries\", count: 1 }],\n      [\"Honeydew\",     { label: \"Honeydew\", count: 1 }],\n      [\"Icicles\",      { label: \"Icicles\", count: 1 }],\n      [\"Joojoo\",       { label: \"Joojoo\", count: 1 }],\n      [\"Knapples\",     { label: \"Knapples\", count: 2 }],\n      [\"Lemons\",       { label: \"Lemons\", count: 9 }],\n      [\"Mandarins\",    { label: \"Mandarins\", count: 3 }],\n      [\"Nectarines\",   { label: \"Nectarines\", count: 5 }],\n      [\"Oranges\",      { label: \"Oranges\", count: 14 }],\n      [\"Plums\",        { label: \"Plums\", count: 32 }],\n      [\"Quince\",       { label: \"Quince\", count: 15 }],\n      [\"Rhubarb\",      { label: \"Rhubarb\", count: 42 }],\n    ])),\n  },\n};\n\nfunction setupTest(owner: IDisposableOwner, opts: { limitShown?: number, filterType?: string | null } = {},\n  resetBtn: DomArg) {\n  const limitShown = opts.limitShown;\n  const filterType = opts.filterType || \"Text\";\n  const valueCounts = DATA_BY_TYPES[filterType].valueCounts!;\n\n  const columnFilter = ColumnFilter.create(null, \"\", filterType, filterType,\n    Array.from(valueCounts).map(arr => arr[0]));\n\n  const filter = modelUtil.customComputed({ read: () => \"\" });\n  const pinned = modelUtil.customComputed({ read: () => false });\n  const filterInfo = {\n    filter,\n    pinned,\n    fieldOrColumn: \"unused\" as any,\n    viewSection: \"unused\" as any,\n    isFiltered: ko.pureComputed(() => filter() !== \"\"),\n    isPinned: ko.pureComputed(() => pinned()),\n  };\n  const gristDoc: GristDoc = {\n    behavioralPromptsManager: {\n      attachPopup: () => {},\n    },\n  } as any;\n\n  const model = ColumnFilterMenuModel.create(null, {\n    columnFilter,\n    filterInfo,\n    valueCount: Array.from(valueCounts),\n    limitShow: limitShown,\n    gristDoc,\n  });\n\n  const renderValue = (key: CellValue, value: IFilterCount) =>\n    cssItemValue(value.label === undefined ? String(key) : value.label);\n  const doCancel = () => columnFilter.setState(columnFilter.initialFilterJson);\n  const openFilterMenu = (ctl: IOpenController) => dom(\"div\",\n    dom.cls(\"grist-floating-menu\"),\n    columnFilterMenu(ctl, {\n      valueCounts,\n      model,\n      renderValue,\n      doCancel,\n      doSave: noop,\n      onClose: () => ctl.close(),\n      ...DATA_BY_TYPES[filterType],\n    }),\n  );\n\n  return [\n    testWrapper(\n      testControls(\n        dom(\"button\", \"Open menu\", testId(\"filter-menu-btn\"),\n          (elem) => {\n            setPopupToCreateDom(elem, openFilterMenu, {\n              attach: \"body\",\n              placement: \"bottom-start\",\n              trigger: [\"click\"],\n            });\n          }),\n      ),\n      testContent(\n        dom(\n          \"div\",\n          testId(\"stored-menu\"),\n          dom.create(columnFilterMenu, ({ model, valueCounts, renderValue, doCancel, doSave: noop, onClose: noop,\n            ...DATA_BY_TYPES[filterType] })),\n        ),\n        dom.domComputed(columnFilter.filterFunc, filterFunc =>\n          testOutput(\n            resetBtn,\n            dom(\"div\", testId(\"json\"), columnFilter.makeFilterJson()),\n            dom(\"div\", \"All values: \",\n              dom(\"span\", testId(\"all-values\"),\n                `[${Array.from(valueCounts.keys()).join(\", \")}]`)),\n            dom(\"div\", \"Displayed values: \",\n              dom(\"span\", testId(\"displayed-values\"),\n                `[${Array.from(valueCounts.keys()).filter(filterFunc).join(\", \")}]`)),\n          ),\n        ),\n      ),\n    ),\n  ];\n}\n\nfunction patchFilterCount(arr: [any, { label: string, count: number }][]): [any, IFilterCount][] {\n  return arr.map(([val, filterCount]) => [val, { ...filterCount, displayValue: filterCount.label }]);\n}\n\nfunction getFilterTypeFromUrl() {\n  const params = (new URL(document.location.href)).searchParams;\n  return params.get(\"filterType\");\n}\n\nfunction setFilterType(val: string) {\n  const url = new URL(document.location.href);\n  const params = url.searchParams;\n  params.set(\"filterType\", val);\n  document.location.href = url.href;\n}\n\nfunction setup(owner: IDisposableOwner) {\n  let limitShownInput: HTMLInputElement;\n  const filterType = getFilterTypeFromUrl() || \"Text\";\n  const getOpt = () => ({\n    limitShown: limitShownInput.value ? Number(limitShownInput.value) : undefined,\n    filterType: getFilterTypeFromUrl(),\n  });\n  const resetBtn = [\n    dom(\n      \"div\",\n      \"limitShown: \",\n      limitShownInput = dom(\n        \"input\", { type: \"text\", value: \"\" },\n        testId(\"limit-shown\"),\n      ),\n    ),\n    dom(\n      \"input\", { type: \"button\", value: \"Reset All\" },\n      testId(\"reset\"),\n      dom.on(\"click\", () => { value.set(dom.create(setupTest, getOpt(), resetBtn)); }),\n    ),\n    dom(\n      \"select\",\n      [\"Numeric\", \"Date\", \"Text\"].map(value => (\n        dom(\"option\", { value, selected: filterType === value }, value)\n      )),\n      dom.on(\"input\", (ev, el) => setFilterType(el.value)),\n    ),\n  ];\n  const value = Observable.create(owner, dom.create(setupTest, getOpt(), resetBtn));\n  return [\n    dom(\"div\", dom.domComputed(value)),\n  ];\n}\n\nconst testWrapper = styled(\"div\", `\n  display: flex;\n`);\n\nconst testControls = styled(\"div\", `\n  min-width: 400px;\n`);\n\nconst testContent = styled(\"div\", `\n  display: flex;\n`);\n\nconst testOutput = styled(\"div\", `\n  max-width: 300px;\n  padding: 5px;\n  margin: 12px;\n  border: 1px solid black;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, dom.create(setup)));\n"
  },
  {
    "path": "test/fixtures/projects/DocMenu.ts",
    "content": "import { TopAppModelImpl } from \"app/client/models/AppModel\";\nimport { urlState } from \"app/client/models/gristUrlState\";\nimport { createAppUI } from \"app/client/ui/AppUI\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { MockUserAPI } from \"test/fixtures/projects/helpers/MockUserAPI\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom } from \"grainjs\";\n\nconst mockUserApi = new MockUserAPI();\n// Simple mock values - not used in tests, but required for home plugins\nconst globalWindow = {\n  gristConfig: {\n    homeUrl: \"http://localhost:0\",\n    timestampMs: 0,\n    pluginUrl: \"http://localhost:0\",\n    plugins: [],\n  },\n};\nconst mockAppModel = TopAppModelImpl.create(null, globalWindow, mockUserApi);\n\nfunction setupTest() {\n  createAppUI(mockAppModel, {} as any);\n}\n\n// Match the font-size setting in the Grist app, which affects all rem units.\ndocument.documentElement.style.fontSize = \"10px\";\n(window as any).gristConfig = {\n  pathOnly: true,\n  features: [\"multiAccounts\", \"multiSite\"],\n};\n\n// This little hack allows visiting /DocMenu#org=foo and end up in /o/foo as if that page loaded,\n// to simulate what happens when switching to an arbitrary org. Similarly, #user=anon and\n// #user=null allow simulating what happens when the user is anonymous or missing.\nfunction simulateOrgChangeFromHash() {\n  const hashOrgMatch = /[#&]org=(\\w+)/.exec(window.location.href);\n  const hashUserMatch = /[#&]user=(\\w+)/.exec(window.location.href);\n  let loadState = false;\n  if (hashOrgMatch) {\n    window.history.replaceState(null, \"\", urlState().makeUrl({ org: hashOrgMatch[1] }));\n    loadState = true;\n  }\n  if (hashUserMatch) {\n    mockUserApi.activeUser = hashUserMatch[1];\n    mockAppModel.initialize();\n    loadState = true;\n  }\n  if (loadState) {\n    urlState().loadState();\n  }\n}\n\ninitGristStyles();\nvoid withLocale(() => {\n  dom.update(document.body, setupTest());\n  dom.onElem(window, \"popstate\", simulateOrgChangeFromHash);\n  simulateOrgChangeFromHash();\n});\n"
  },
  {
    "path": "test/fixtures/projects/DocumentSettings.ts",
    "content": "import { addSaveInterface, KoSaveableObservable, objObservable } from \"app/client/models/modelUtil\";\nimport { DocSettingsPage } from \"app/client/ui/DocumentSettings\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { ColValues } from \"app/common/DocActions\";\nimport { DocumentSettings } from \"app/common/DocumentSettings\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { Computed, dom, fromKo, input, observable, Observable, styled } from \"grainjs\";\nimport * as ko from \"knockout\";\n\nfunction savable<T>(initial: T) {\n  async function save(value: T) {\n    result(value);\n  }\n  const result = addSaveInterface(ko.observable<T>(initial), save);\n  return result;\n}\n\nfunction setupTest() {\n  const timezone = savable(\"\");\n  const documentSettingsJson: KoSaveableObservable<DocumentSettings> = objObservable(savable<DocumentSettings>({\n    locale: \"en-US\",\n  }));\n  const docInfo = {\n    timezone,\n    documentSettingsJson,\n    updateColValues: async function({ timezone: newTimezone, documentSettings }: ColValues): Promise<void> {\n      await timezone.saveOnly(String(newTimezone));\n      await documentSettingsJson.saveOnly(JSON.parse(String(documentSettings)));\n    },\n  };\n  const docPageModel = {\n    currentDocId: Observable.create(null, \"docId\"),\n    currentDoc: Observable.create(null, { access: \"owners\" }),\n    type: Observable.create(null, null),\n    isFork: Observable.create(null, false),\n  };\n  const gristDoc: any = {\n    docInfo,\n    docPageModel,\n    isTimingOn: observable(false),\n    attachmentTransfer: observable(null),\n    docApi: {\n      getAttachmentTransferStatus: async () => undefined,\n      getAttachmentStores: async () => [],\n      transferAllAttachments: async () => undefined,\n    },\n  };\n\n  const locale = Computed.create(null, fromKo(documentSettingsJson),\n    (_use, settings) => settings.locale);\n  const currency = Computed.create(null, fromKo(documentSettingsJson),\n    (_use, settings) => String(settings.currency));\n\n  return [\n    testBox(\n      dom(\"div\", \"Document Settings\"),\n      dom.create(DocSettingsPage, gristDoc),\n    ),\n    testBox(\n      dom(\"div\", \"Timezone Value\"),\n      dom(\"div\", input(fromKo(timezone), {}, testId(\"result-timezone\"))),\n    ),\n    testBox(\n      dom(\"div\", \"Locale Value\"),\n      dom(\"div\", input(locale, {}, testId(\"result-locale\"))),\n    ),\n    testBox(\n      dom(\"div\", \"Currency Value\"),\n      dom(\"div\", input(currency, {}, testId(\"result-currency\"))),\n    ),\n  ];\n}\n\nconst testBox = styled(\"div\", `\n  float: left;\n  width: 25rem;\n  font-family: sans-serif;\n  font-size: 1rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n  padding: 1rem;\n  margin: 1rem;\n  & > div { margin: 1rem; }\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/ErrorNotify.ts",
    "content": "import { INotifyOptions, Notifier } from \"app/client/models/NotifyModel\";\nimport { buildNotifyMenuButton, buildSnackbarDom } from \"app/client/ui/NotifyUI\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\n\nimport { delay } from \"bluebird\";\nimport { dom, Holder, MultiHolder, styled } from \"grainjs\";\n\nlet errHolder1 = Holder.create(null);\nlet errHolder2 = Holder.create(null);\n\nconst notifier = Notifier.create(null);\nlet multiHolder = new MultiHolder();\nmultiHolder.autoDispose(errHolder1);\nmultiHolder.autoDispose(errHolder2);\n\nfunction radioGroup(clb: (value: string) => any) {\n  const store = radioGroup as any;\n  store.group = store.group || 0;\n  store.radioId = store.radioId || 0;\n  const group = store.group++;\n  return (name: string, value: string) => {\n    const radioId = store.radioId++;\n    return [\n      dom(\"input\", {\n        type: \"radio\",\n        name: `radio_group_${group}`,\n        value: value,\n        id: `radio${radioId}`,\n      }, dom.on(\"change\", () => clb(value))),\n      dom(\"label\", { for: `radio${radioId}` }, dom.text(name)),\n    ];\n  };\n}\n\nlet notify = (message: string, options?: Partial<INotifyOptions>) =>  {\n  return multiHolder.autoDispose(notifier.createUserMessage(message, { ...options, inDropdown: true }) as any);\n};\n\nfunction setLevels(level: string) {\n  function show(l: string) {\n    return (msg: string, options?: Partial<INotifyOptions>) =>\n      multiHolder.autoDispose(notifier.createUserMessage(msg, { ...options, level: l as any, inDropdown: true }));\n  }\n  if (level === \"all\") {\n    notify = (...args: any[]) => {\n      const holder = new MultiHolder();\n      holder.autoDispose(show(\"error\")(args[0], args[1]));\n      holder.autoDispose(show(\"warning\")(args[0], args[1]));\n      holder.autoDispose(show(\"success\")(args[0], args[1]));\n      holder.autoDispose(show(\"message\")(args[0], args[1]));\n      holder.autoDispose(show(\"info\")(args[0], args[1]));\n      multiHolder.autoDispose(holder);\n      return holder;\n    };\n  } else {\n    notify = show(level);\n  }\n}\n\nfunction setupTest() {\n  const radio = radioGroup(setLevels);\n  return cssWrapper(\n    buildNotifyMenuButton(notifier, null),\n    dom(\"div\", \"Message level\"),\n    dom(\"div\", [\n      radio(\"Message\", \"message\"),\n      radio(\"Info\", \"info\"),\n      radio(\"Success\", \"success\"),\n      radio(\"Warning\", \"warning\"),\n      radio(\"Error\", \"error\"),\n      radio(\"All\", \"all\"),\n    ]),\n    dom(\"button\",\n      \"Close all\",\n      dom.on(\"click\", () =>  {\n        multiHolder.dispose();\n        multiHolder = new MultiHolder();\n        errHolder1 = new Holder();\n        errHolder2 = new Holder();\n        multiHolder.autoDispose(errHolder1);\n        multiHolder.autoDispose(errHolder2);\n      }),\n    ),\n    dom(\"br\"),\n    dom(\"button.user-error-default\",\n      \"User error example (default expire)\",\n      dom.on(\"click\", () =>  {\n        notify(`Workspace name is duplicated (default)`, { expireSec: 1 });\n      }),\n    ),\n    dom(\"br\"),\n    dom(\"button.user-error-2sec\",\n      \"User multi-line error example (custom expire in 2 secs)\",\n      dom.on(\"click\", () => {\n        notify(`Workspace name is duplicated and the error is way too long for one line (custom)`,\n          { expireSec: 2 });\n      }),\n    ),\n    dom(\"br\"),\n    dom(\"button\",\n      \"User error example (default expire or on click)\",\n      dom.on(\"click\", () => {\n        if (errHolder1.isEmpty()) {\n          errHolder1.autoDispose(notify(`Workspace name is duplicated (clear on click)`));\n        } else {\n          errHolder1.clear();\n        }\n      }),\n    ),\n    dom(\"br\"),\n    dom(\"button\",\n      \"User error example (no expire until click)\",\n      dom.on(\"click\", () => {\n        if (errHolder2.isEmpty()) {\n          errHolder2.autoDispose(notify(`Workspace name is duplicated (no expire)`,\n            { expireSec: 0 }));\n        } else {\n          errHolder2.clear();\n        }\n      }),\n    ),\n    dom(\"br\"),\n    dom(\"button\",\n      \"User error with dismiss\",\n      dom.on(\"click\", () => {\n        notify(`Example error with dismiss`, { expireSec: 0, canUserClose: true });\n      }),\n    ),\n    dom(\"br\"),\n    dom(\"button\",\n      \"User multi-line error with dismiss\",\n      dom.on(\"click\", () => {\n        notify(`Example error with dismiss and a long, long, long message`, { canUserClose: true });\n      }),\n    ),\n    dom(\"br\"),\n    dom(\"button\",\n      \"Unexpected error\",\n      dom.on(\"click\", () => {\n        notify(\"10:03:10 Cannot read property of null (reading 'callback')\",\n          {\n            title: \"Unexpected error\",\n            actions: [\"report-problem\"],\n            expireSec: 0,\n            canUserClose: true,\n          });\n      }),\n    ),\n    dom(\"hr\"),\n    dom(\"button\",\n      \"Import a file - success\",\n      dom.on(\"click\", async () => {\n        const progress = notifier.createProgressIndicator(\"Foo Sample.pdf\", \"12mb\");\n        multiHolder.autoDispose(progress);\n        for (let i = 1; i <= 4; i++) {\n          await delay(500);\n          if (progress.isDisposed()) {\n            break;\n          }\n          progress.setProgress(25 * i);\n        }\n      }),\n    ),\n    dom(\"button\",\n      \"Import a file - failure\",\n      dom.on(\"click\", async () => {\n        const holder = Holder.create(null);\n        multiHolder.autoDispose(holder);\n        const progress = notifier.createProgressIndicator(\"Foo Sample.pdf\", \"12mb\");\n        holder.autoDispose(progress);\n        for (let i = 1; i <= 3; i++) {\n          await delay(500);\n          if (progress.isDisposed()) {\n            return;\n          }\n          progress.setProgress(25 * i);\n        }\n        holder.autoDispose(notifier.createUserMessage(\"Unable to upload Foo Sample.pdf\",\n          { expireSec: 0, canUserClose: true, level: \"error\" }));\n      }),\n    ),\n    dom(\"hr\"),\n    dom(\"button\",\n      \"Common popups\",\n      dom.on(\"click\", async () => {\n        multiHolder.dispose();\n        multiHolder = new MultiHolder();\n        const noExp = { expireSec: 0, canUserClose: true };\n        let n = notifier.createUserMessage(\"10:03:10 Cannot read property of null (reading 'callback')\",\n          {\n            title: \"Unexpected error\",\n            actions: [\"report-problem\"],\n            level: \"error\",\n            ...noExp,\n          });\n        multiHolder.autoDispose(n);\n        n = notifier.createUserMessage(\"Blocked by table update access rules\", noExp);\n        multiHolder.autoDispose(n);\n        n = notifier.createUserMessage(\"No more documents permitted\", {\n          title: \"Reached plan limit\",\n          actions: [\"upgrade\"],\n          ...noExp,\n        });\n        multiHolder.autoDispose(n);\n        n = notifier.createUserMessage(\"Still working ...\", noExp);\n        multiHolder.autoDispose(n);\n        n = notifier.createUserMessage(\"Link copied to clipboard\", noExp);\n        multiHolder.autoDispose(n);\n        n = notifier.createUserMessage(\"Cannot change summary column 'count' between formula and data\", {\n          actions: [\"ask-for-help\"],\n          level: \"error\",\n          ...noExp,\n        });\n        multiHolder.autoDispose(n);\n        n = notifier.createUserMessage(\"Cannot change summary column 'count' between formula and data\", {\n          actions: [\"ask-for-help\"],\n          title: \"Warning\",\n          level: \"error\",\n          ...noExp,\n        });\n        multiHolder.autoDispose(n);\n        const progress = notifier.createProgressIndicator(\"Foo Sample.pdf\", \"12mb\");\n        progress.setProgress(25);\n        multiHolder.autoDispose(progress);\n      }),\n    ),\n    buildSnackbarDom(notifier, null),\n  );\n}\n\nconst cssWrapper = styled(\"div\", `\n`);\n\n// Load icons.css, wait for it to load, then build the page.\ninitGristStyles();\ndom.update(document.body, setupTest());\n"
  },
  {
    "path": "test/fixtures/projects/Icons.ts",
    "content": "import { IconList, IconName } from \"app/client/ui2018/IconList\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport { loadingSpinner } from \"app/client/ui2018/loaders\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, styled } from \"grainjs\";\nimport { times } from \"lodash\";\n\nconst bigBlueIconCss = `\n  background-color: blue;\n  width: 32px;\n  height: 32px;\n`;\nconst bigBlueIcon = styled(icon, bigBlueIconCss);\n\nconst searchIconCss = `\n  background-color: lightgrey;\n  margin: 4px;\n`;\nconst searchIcon = styled(\"span\", searchIconCss);\n\nconst searchBox = styled(\"div\", `\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  border: 1px solid lightgrey;\n  border-radius: 8px;\n`);\n\nconst searchInput = styled(\"input#search\", `\n  outline: none;\n  border: none;\n  margin: 4px;\n  line-height: 1.4;\n`);\n\nconst checkbox = styled(\"input#checkbox\", `\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  width: 1rem;\n  height: 1rem;\n  border: 1px solid blue;\n  box-sizing: content-box;\n\n  &:checked::before {\n    position: absolute;\n    content: var(--icon-Tick);\n  }\n`);\n\nconst allIcons = styled(\"div\", `\n  width: 650px;\n`);\nconst iconBlock = styled(\"div\", `\n  display: inline-flex;\n  align-items: center;\n  width: 120px;\n  margin: 2px;\n  white-space: pre;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`);\nconst iconBox = styled(\"div\", `\n  flex: none;\n  border: 1px solid lightgrey;\n  padding: 1px;\n  margin-right: 4px;\n`);\nconst testPage = styled(\"div\", `\n  font-size: 14px;\n  font-family: sans-serif;\n`);\n\nfunction setupTest() {\n  return testPage(\n    dom(\"h4\", \"All icons\"),\n    dom(\"div#all_icons\",\n      allIcons(\n        dom.forEach(IconList.slice().sort(), (name: IconName) =>\n          iconBlock(iconBox(icon(name)), dom(\"span\", name)),\n        ),\n      ),\n    ),\n    dom(\"hr\"),\n    dom(\"div#search_icon\",\n      icon(\"Search\"), ` unstyled`,\n    ),\n    dom(\"div#big_search_icon\",\n      bigBlueIcon(\"Search\"), ` styled with {${bigBlueIconCss.replace(/\\s+/g, \" \")}}`,\n    ),\n    dom(\"section\",\n      searchBox(\n        icon(\"Search\", dom.cls(searchIcon.className)),\n        searchInput({ type: \"search\" }),\n      ),\n      ` styled with {${searchIconCss.replace(/\\s+/g, \" \")}}`,\n    ),\n    dom(\"hr\"),\n    dom(\"div\",\n      times(100, () => icon(\"FieldDateTime\")),\n    ),\n    dom(\"section\",\n      checkbox({ type: \"checkbox\", checked: true }),\n    ),\n    dom(\"hr\"),\n    thumbPreview(),\n    dom(\"hr\"),\n    dom(\"h2\", \"Loaders\"),\n    loadingSpinner(),\n  );\n}\n\nconst thumbPreview = styled(\"div\", `\n  flex: none;\n  height: 48px;\n  width: 48px;\n  background-image: var(--icon-ThumbPreview);\n  background-repeat: no-repeat;\n  background-position: center;\n  background-color: #262633;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/Importer.ts",
    "content": "import { GristDoc } from \"app/client/components/GristDoc\";\nimport { Importer, SourceInfo } from \"app/client/components/Importer\";\nimport koArray from \"app/client/lib/koArray\";\nimport { ViewSectionRec } from \"app/client/models/DocModel\";\nimport { SortedRowSet } from \"app/client/models/rowset\";\nimport { bigBasicButton, cssButton } from \"app/client/ui2018/buttons\";\nimport { UploadResult } from \"app/common/uploads\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { initSchema, initValues } from \"test/fixtures/projects/helpers/ParseOptionsData\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, Holder, Observable, styled } from \"grainjs\";\nimport * as ko from \"knockout\";\n\nlet colRef = 1;\n\nfunction makeDummyViewSection(name: string) {\n  const field = (fieldName: string, ref = colRef++) => ({\n    colRef: ko.observable(ref),\n    colId: ko.observable(`Col${colRef}`),\n    label: ko.observable(fieldName),\n    column: ko.observable({\n      id: ko.observable(ref),\n      label: ko.observable(fieldName),\n      colId: ko.observable(`Col${colRef}`),\n      formula: ko.observable(\"\"),\n      getRowId: () => ref,\n      refTable: ko.observable({\n        tableId: ko.observable(name),\n      }),\n      visibleColModel: ko.observable({\n        label: ko.observable(fieldName),\n        formula: ko.observable(\"\"),\n        getRowId: () => ref,\n        colId: ko.observable(ref),\n      }),\n      pureType: ko.observable(\"Text\"),\n    }),\n  });\n  return {\n    _isDeleted: ko.observable(false),\n    isDisposed: ko.observable(false),\n    title: ko.observable(name),\n    viewFields: ko.observable(koArray([\n      field(\"Column 1\"),\n      field(\"Column 2\"),\n      field(\"Column 3\"),\n      field(\"Column 4\"),\n    ])),\n  } as unknown as ViewSectionRec;\n}\n\n// By setting those two, you can modify what will be shown first, start screen is when both are null.\nconst PANEL = 2;\nconst DEST_TABLE_ID = \"aaa\";\n\nconst sampleSourceInfoArray: SourceInfo[] = [{\n  destTableId: Observable.create(null, DEST_TABLE_ID),\n  hiddenTableId: \"GristHidden_import\",\n  origTableName: \"\",\n  sourceSection: makeDummyViewSection(\"source1\"),\n  transformSection: Observable.create(null, makeDummyViewSection(\"dest1\")),\n  uploadFileIndex: 0,\n  lastGenImporterViewPromise: null,\n  isLoadingSection: Observable.create(null, false),\n  selectedView: Observable.create(null, PANEL),\n  customizedColumns: Observable.create(null, new Set()),\n}, {\n  destTableId: Observable.create(null, DEST_TABLE_ID),\n  hiddenTableId: \"GristHidden_import2\",\n  origTableName: \"\",\n  sourceSection: makeDummyViewSection(\"source2\"),\n  transformSection: Observable.create(null, makeDummyViewSection(\"dest2\")),\n  uploadFileIndex: 1,\n  lastGenImporterViewPromise: null,\n  isLoadingSection: Observable.create(null, false),\n  selectedView: Observable.create(null, PANEL),\n  customizedColumns: Observable.create(null, new Set()),\n}, {\n  destTableId: Observable.create(null, DEST_TABLE_ID),\n  hiddenTableId: \"GristHidden_import3\",\n  origTableName: \"NYC List\",\n  sourceSection: makeDummyViewSection(\"source3\"),\n  transformSection: Observable.create(null, makeDummyViewSection(\"dest3\")),\n  uploadFileIndex: 2,\n  lastGenImporterViewPromise: null,\n  isLoadingSection: Observable.create(null, false),\n  selectedView: Observable.create(null, PANEL),\n  customizedColumns: Observable.create(null, new Set()),\n}, {\n  destTableId: Observable.create(null, DEST_TABLE_ID),\n  hiddenTableId: \"GristHidden_import4\",\n  origTableName: \"Stats\",\n  sourceSection: makeDummyViewSection(\"source4\"),\n  transformSection: Observable.create(null, makeDummyViewSection(\"dest4\")),\n  uploadFileIndex: 2,\n  lastGenImporterViewPromise: null,\n  isLoadingSection: Observable.create(null, false),\n  selectedView: Observable.create(null, PANEL),\n  customizedColumns: Observable.create(null, new Set()),\n}, {\n  destTableId: Observable.create(null, DEST_TABLE_ID),\n  hiddenTableId: \"GristHidden_import4\",\n  origTableName: \"AAA\",\n  sourceSection: makeDummyViewSection(\"source5\"),\n  transformSection: Observable.create(null, makeDummyViewSection(\"dest5\")),\n  uploadFileIndex: 2,\n  lastGenImporterViewPromise: null,\n  isLoadingSection: Observable.create(null, false),\n  selectedView: Observable.create(null, PANEL),\n  customizedColumns: Observable.create(null, new Set()),\n}];\n\nconst sampleUploadResult: UploadResult = {\n  uploadId: 4,\n  files: [{\n    origName: \"foo.csv\",\n    size: 13,\n    ext: \".csv\",\n  }, {\n    origName: \"Hello World, what a long name you have.csv\",\n    size: 57,\n    ext: \".csv\",\n  }, {\n    origName: \"NYC Restaurants.xlsx\",\n    size: 68259,\n    ext: \".xlsx\",\n  }],\n};\n\nfunction setupTest() {\n  const mode = Observable.create(null, \"main\");\n\n  const docModel = {\n    visibleTableIds: koArray([\"Table1\", \"Hello_World\", \"Some_Other_Longish_Name\"]),\n    viewSections: {\n      getRowModel() {\n        return makeDummyViewSection(\"dest3\");\n      },\n    },\n  };\n  const gristDoc: GristDoc = {\n    docComm: null,\n    docModel,\n    viewModel: {\n      activeSectionId: ko.observable(null),\n    },\n    docData: {\n      sendAction() {\n        return Promise.resolve(1);\n      },\n    },\n  } as any;\n  const holder = Holder.create<Importer>(null);\n  const createPreview = (vs: ViewSectionRec) => ({\n    viewPane: dom(\"div\", `GridView for ${vs.titleDef()}`),\n    dispose: () => null,\n    listenTo: (..._args: any[]) => undefined,\n    sortedRows: SortedRowSet.create(null, (a, b) => 0),\n  });\n\n  function render(modeValue: string) {\n    mode.set(modeValue);\n    const parseOptions = { ...initValues, SCHEMA: initSchema };\n    const importer = Importer.create(holder, gristDoc, null, createPreview);\n    (importer as any)._parseOptions.set(parseOptions);\n    (importer as any)._sourceInfoArray.set(sampleSourceInfoArray);\n    (importer as any)._sourceInfoSelected.set(sampleSourceInfoArray[0]);\n    (importer as any)._prepareMergeOptions();\n    (importer as any)._renderMain(sampleUploadResult);\n    switch (modeValue) {\n      case \"spinner\": return (importer as any)._renderSpinner();\n      case \"error\": return (importer as any)._renderError(\"This is a test error message\");\n      case \"plugin\": return (importer as any)._renderPlugin(\n        dom(\"div\", \"Hello, \", dom(\"input\", { type: \"text\" }), \" world\", dom(\"button\", \"Go!\")),\n      );\n      case \"preview\": return (importer as any)._renderMain(sampleUploadResult);\n      case \"parseopts\": return (importer as any)._renderParseOptions(initSchema, null);\n    }\n  }\n\n  setTimeout(() => render(\"preview\"), 0);\n\n  return [\n    testBox(\n      dom(\"div\", { style: \"display: flex; padding: 8px;\" },\n        myButton(\"Spinner\",\n          cssButton.cls(\"-primary\", use => use(mode) === \"spinner\"),\n          dom.on(\"click\", () => render(\"spinner\")),\n        ),\n        myButton(\"Error\",\n          cssButton.cls(\"-primary\", use => use(mode) === \"error\"),\n          dom.on(\"click\", () => render(\"error\")),\n        ),\n        myButton(\"Plugin\",\n          cssButton.cls(\"-primary\", use => use(mode) === \"plugin\"),\n          dom.on(\"click\", () => render(\"plugin\")),\n        ),\n        myButton(\"Preview\",\n          cssButton.cls(\"-primary\", use => use(mode) === \"preview\"),\n          dom.on(\"click\", () => render(\"preview\")),\n        ),\n        myButton(\"Parse Options\",\n          cssButton.cls(\"-primary\", use => use(mode) === \"parseopts\"),\n          dom.on(\"click\", () => render(\"parseopts\")),\n        ),\n      ),\n    ),\n  ];\n}\n\nconst testBox = styled(\"div\", `\n  flex: 1 0 auto;\n  margin: 2rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n  overflow: hidden;\n`);\n\nconst myButton = styled(bigBasicButton, `\n  margin-right: 16px;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/Mentions.ts",
    "content": "import { buildMentionTextBox, CommentWithMentions } from \"app/client/widgets/MentionTextBox\";\nimport { PermissionData } from \"app/common/UserAPI\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, input, makeTestId, observable, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-\");\n\ninitGristStyles();\n\nsetTimeout(() => {\n  void withLocale(() => dom.update(document.body, dom.create(setupTest)));\n});\n\nfunction setupTest() {\n  const initial = observable<string>(\"\");\n  (window as any).initial = initial; // Expose for debugging\n  return cssCenter(\n    input(initial, { onInput: true }, { type: \"text\" }),\n    dom(\"span\", new Date().toLocaleString()),\n    dom.domComputed(initial, init => [\n      buildDom(init),\n    ]),\n    cssAway(testId(\"away\")),\n  );\n}\n\nfunction buildDom(init: string) {\n  const text = observable(new CommentWithMentions(init));\n  const rawHtml = observable(\"\");\n  const data: PermissionData = {\n    users: [\n      { name: \"Alice\", id: 1, ref: \"alice\", email: \"\", access: \"editors\" },\n      { name: \"Bob\", id: 2, ref: \"bob\", email: \"\", access: \"editors\" },\n      { name: \"Charlie\", id: 3, ref: \"charlie\", email: \"\", access: \"editors\" },\n      { name: \"Dave\", id: 4, ref: \"dave\", email: \"\", access: \"editors\" },\n    ],\n  };\n\n  const access = observable<PermissionData | null>(data);\n\n  // Exposed for debugging purposes.\n  (window as any).loadData = () => {\n    access.set(data);\n  };\n\n  (window as any).clearData = () => {\n    access.set(null);\n  };\n\n  return [\n    dom(\"div.box\",\n      buildMentionTextBox(\n        text,\n        access,\n        testId(\"input\"),\n        dom.on(\"input\", (_, el) => rawHtml.set(el.innerHTML)),\n      ),\n      dom(\"button\", \"Load\", dom.on(\"click\", () => { access.set(data); })),\n      dom(\"button\", \"Clear\", dom.on(\"click\", () => { access.set(null); })),\n      dom(\"button\", \"Load after\", dom.on(\"click\", () => {\n        setTimeout(() => {\n          access.set(data);\n        }, 5000);\n      })),\n    ),\n    dom(\"div.box wide\",\n      dom(\"div\", \"Markdown\"),\n      dom(\"pre\",\n        dom.style(\"white-space\", \"pre-wrap\"),\n        dom.text(use => use(text)?.text || \"\"),\n        testId(\"output\"),\n      ),\n    ),\n    dom(\"div.box wide\",\n      dom(\"div\", \"Raw HTML\"),\n      dom(\"pre\",\n        dom.style(\"white-space\", \"pre-wrap\"),\n        dom.text(use => use(rawHtml)),\n        testId(\"output\"),\n      ),\n    ),\n  ];\n}\n\nconst cssAway = styled(\"div\", `\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 40px;\n  height: 40px;\n  background-color: rgba(0, 0, 0, 0.5);\n  border-radius: 50%;\n  z-index: 1000;\n  cursor: pointer;\n  &:active {\n    background-color: rgba(0, 0, 0, 0.7);}\n`);\n\nconst cssCenter = styled(\"div\", `\n  display: flex;\n  gap: 16px;\n  flex-direction: column;\n  justify-content: center;\n  align-items: center;\n  height: 100vh;\n  width: 100vw;\n  background-color: #f0f0f0;\n\n  font-size: 13px;\n\n  .box {\n    width: 300px;\n    height: 300px;\n  }\n\n  .wide {\n    width: min(80%, 600px);\n   }\n\n  .grist-mention {\n    text-decoration: none;\n    outline: none;\n    &:hover, &:active {\n      outline: none;\n      text-decoration: none;\n    }\n  }\n\n\n`);\n"
  },
  {
    "path": "test/fixtures/projects/MultiSelector.ts",
    "content": "import { BaseItem, MultiItemSelector } from \"app/client/ui/MultiSelector\";\nimport { States } from \"test/fixtures/projects/helpers/States\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, MutableObsArray, obsArray, styled } from \"grainjs\";\n\n// Sample data\nclass StateSelector extends MultiItemSelector<{ label: string, value: string }> {\n  protected static defaultItem: BaseItem;\n\n  constructor(_myStates: MutableObsArray<BaseItem> = obsArray([])) {\n    super(_myStates, obsArray(States), {\n      addItemText: \"Add new state\",\n      addItemLabel: \"Select state\",\n    });\n  }\n}\n\nfunction setupTest() {\n  const _myStates = obsArray([]);\n  return cssTestBox(\n    dom.create(StateSelector, _myStates),\n    dom(\"pre\", dom.text(use => JSON.stringify(use(_myStates), null, 2))),\n  );\n}\n\nconst cssTestBox = styled(\"div\", `\n  display: flex;\n`);\n\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/OnBoardingPopups.ts",
    "content": "import { IOnBoardingMsg, startOnBoarding } from \"app/client/ui/OnBoardingPopups\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, DomElementArg, observable, styled } from \"grainjs\";\n\nconst message: IOnBoardingMsg[] = [\n  {\n    selector: \".tour-add-new\",\n    title: \"Add New\",\n    body: \"Click here to add new ...\",\n    placement: \"right\",\n  },\n  {\n    selector: \"#export\",\n    title: \"Export\",\n    body: \"Export let's you ...\",\n    placement: \"right\",\n  },\n  {\n    selector: \".tour-left-panel\",\n    title: \"Left Panel\",\n    body: \"This pane holds many useful stuff ...\",\n    placement: \"right\",\n  },\n  {\n    selector: \".tour-options\",\n    title: \"Options\",\n    body: \"You can customize, just click the options ...\",\n    placement: \"left\",\n  },\n  {\n    selector: \".tour-sharing\",\n    title: \"Sharing\",\n    body: \"You can share with who you care ...\",\n    placement: \"bottom\",\n  },\n  {\n    selector: \".tour-tools\",\n    title: \"Tools\",\n    body: \"Great tools to build great things ...\",\n    placement: \"top-start\",\n  },\n];\n\nconst logs = observable<string[]>([]);\nfunction log(msg: string) {\n  logs.set(logs.get().concat(msg));\n}\n\nfunction dummyButton(name: string, ...args: DomElementArg[]) {\n  return dom(\n    \"button\", `${name} [FAKE]`,\n    dom.on(\"click\", () => log(`CLICKED ${name}!`)),\n    ...args,\n  );\n}\n\nfunction setupTest() {\n  return [\n    leftPane(\n      dummyButton(\"Add New\", dom.cls(\"tour-add-new\")),\n      dummyButton(\"Export\", { id: \"export\" }),\n      dom(\"button\", \"Start\",\n        dom.on(\"click\", () => startOnBoarding(message, () => log(\"On Boarding FINISHED!\")))),\n      dom(\"button\", \"Reset logs\",\n        dom.on(\"click\", () => logs.set([]))),\n      dom.cls(\"tour-left-panel\"),\n    ),\n    Share(\"Share#sharing\", dom.cls(\"tour-sharing\")),\n    Tools(\"Tools\", dom.cls(\"tour-tools\")),\n    rightPane(\n      dummyButton(\"Options\", { style: \"margin-top: 40px\" }, dom.cls(\"tour-options\")),\n    ),\n    dom.domComputed(logs, logsArray => (\n      dom(\"div\", { style: \"position: absolute; margin-top: 300px;\" },\n        logsArray.map(msg => dom(\"div\", msg, testId(\"logs\"))),\n      )\n    )),\n  ];\n}\n\nconst basePane = styled(\"div\", `\n  position: absolute;\n  display: flex;\n  flex-direction: column;\n  border: 1px solid gray;\n  height: 300px;\n`);\n\nconst leftPane = styled(basePane, `\n  left: 10px;\n`);\n\nconst rightPane = styled(basePane, `\n  left: 600px;\n`);\n\nconst Share = styled(dummyButton, `\n  position: absolute;\n  left: 300px;\n`);\n\nconst Tools = styled(dummyButton, `\n  position: absolute;\n  left: 140px;\n  top: 290px;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/PagePanels.ts",
    "content": "import { AppModel, TopAppModelImpl } from \"app/client/models/AppModel\";\nimport { addNewButton, cssAddNewButton } from \"app/client/ui/AddNewButton\";\nimport { AppHeader } from \"app/client/ui/AppHeader\";\nimport { PageContents, pagePanels } from \"app/client/ui/PagePanels\";\nimport { attachPageWidgetPicker, openPageWidgetPicker } from \"app/client/ui/PageWidgetPicker\";\nimport { primaryButton } from \"app/client/ui2018/buttons\";\nimport { menu, menuIcon, menuItem } from \"app/client/ui2018/menus\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { addNewPage, addPages, selected } from \"test/fixtures/projects/helpers/Pages\";\nimport { gristDocMock } from \"test/fixtures/projects/helpers/widgetPicker\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, DomContents, makeTestId, observable, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-pp-\");\n\nfunction renderPage(appModel: AppModel, showRightPane: boolean, showLeftOpener: boolean,\n  optimizeNarrowScreen: boolean): DomContents {\n  const leftPanelOpen = observable(true);\n  const page: PageContents = {\n    leftPanel: {\n      panelWidth: observable<number>(240),\n      panelOpen: leftPanelOpen,\n      hideOpener: !showLeftOpener,\n      header: dom.create(\n        AppHeader,\n        appModel,\n      ),\n      content: dom(\"div\",\n        addNewButton({ isOpen: leftPanelOpen },\n          menu(() => addMenu(), {\n            placement: \"bottom-start\",\n            stretchToSelector: `.${cssAddNewButton.className}`,\n          }),\n          testId(\"addNew\")),\n        addPages(leftPanelOpen),\n        \"This is long left-pane content\",\n      ),\n    },\n    rightPanel: showRightPane ? {\n      panelWidth: observable<number>(240),\n      panelOpen: observable(optimizeNarrowScreen ? false : true),\n      header: testContent(\"Header Right\"),\n      content: dom(\"div\",\n        primaryButton(\n          elem => attachPageWidgetPicker(\n            elem, gristDocMock,\n            async (val) => { selected.get().record.widget = val; },\n            {\n              value: () => selected.get().record.widget,\n              buttonLabel: \"Save\",\n            }),\n          \"Edit Data Selection\",\n          dom.prop(\"disabled\", use => !use(selected)),\n          testId(\"editDataBtn\"),\n        ),\n        testContent(\"Long right-pane content\"),\n      ),\n    } : undefined,\n    headerMain: testContent(\"Header Middle\"),\n    contentMain: testContent(\"Content Middle\"),\n    testId,\n  };\n  return pagePanels(page);\n}\n\nfunction setupTest() {\n  const mockAppModel = TopAppModelImpl.create(null, {});\n  const showRightPane = observable(true);\n  const showLeftOpener = observable(true);\n  const optimizeNarrowScreen = observable(false);\n  return [\n    testBox(dom.domComputed((use) => {\n      const appModel = use(mockAppModel.appObs);\n      if (!appModel) { return null; }\n      appModel.currentOrgName = \"SmartLab with very long and overflowing name\";\n      return renderPage(appModel, use(showRightPane), use(showLeftOpener), use(optimizeNarrowScreen));\n    })),\n    controls(\n      dom(\"input\", { type: \"checkbox\" },\n        testId(\"show-right\"),\n        dom.prop(\"checked\", showRightPane),\n        dom.on(\"change\", (ev, elem: any) => showRightPane.set(elem.checked)),\n      ),\n      \"Show right pane\",\n      dom(\"br\"),\n      dom(\"input\", { type: \"checkbox\" },\n        testId(\"show-left-opener\"),\n        dom.prop(\"checked\", showLeftOpener),\n        dom.on(\"change\", (ev, elem: any) => showLeftOpener.set(elem.checked)),\n      ),\n      \"Show left opener\",\n      dom(\"br\"),\n      dom(\"input\", { type: \"checkbox\" },\n        testId(\"optimize-narrow-screen\"),\n        dom.prop(\"checked\", optimizeNarrowScreen),\n        dom.on(\"change\", (ev, elem: any) => optimizeNarrowScreen.set(elem.checked)),\n      ),\n      \"Optimize narrow screen\",\n\n    ),\n  ];\n}\n\nfunction addMenu() {\n  return [\n    menuItem(() => addNewPage(), menuIcon(\"TypeTable\"), \"Empty Table\"),\n    menuItem(\n      elem => openPageWidgetPicker(elem, gristDocMock, addNewPage),\n      menuIcon(\"Page\"), \"Page\", testId(\"addNewPage\")),\n  ];\n}\n\nconst testContent = styled(\"div\", `\n  padding: 5px;\n  text-align: center;\n  flex: 1 1 0px;\n`);\n\nconst testBox = styled(\"div\", `\n  position: relative;\n  width: 80vw;\n  height: 80vh;\n  margin: 1rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n  transform: scale(1); /* Defines the containing block for the side panels*/\n`);\nconst controls = styled(\"div\", `margin: 1rem`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/PageWidgetPicker.ts",
    "content": "import { attachPageWidgetPicker, IOptions, IPageWidget, ISaveFunc } from \"app/client/ui/PageWidgetPicker\";\nimport { basicButton } from \"app/client/ui2018/buttons\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { gristDocMock } from \"test/fixtures/projects/helpers/widgetPicker\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, domComputed, DomElementMethod, obsArray, observable, styled } from \"grainjs\";\n\ninterface ISaveCall {\n  resolve: () => void;\n  value: IPageWidget;\n}\n\nfunction setupTest() {\n  const isNewPageObs = observable(false);\n  const valueOpt = observable<IPageWidget | null>(null);\n  const saveCalls = obsArray<ISaveCall>([]);\n\n  const onSelect: ISaveFunc = async (val) => {\n    const promise = new Promise<void>((resolve) => {\n      saveCalls.push({ resolve, value: val });\n    });\n    await promise;\n  };\n\n  function pageWidgetPicker(onSave: ISaveFunc, option: IOptions): DomElementMethod {\n    return (elem) => {\n      attachPageWidgetPicker(elem, gristDocMock, onSave, option);\n    };\n  }\n\n  return [\n\n    domComputed((use) => {\n      const isNewPage = use(isNewPageObs);\n      const value = use(valueOpt) ? () => valueOpt.get()! : undefined;\n      return { isNewPage, value };\n    }, option => [\n      basicButton(\n        \"Page widget picker\",\n        pageWidgetPicker(onSelect, option),\n        testId(\"trigger\"),\n      ),\n      dom(\n        \"div\",\n        dom(\"h3\", \"Options\"),\n        dom(\n          \"div\", \"isNewPage: \",\n          dom(\n            \"input\", { type: \"checkbox\" },\n            dom.prop(\"checked\", isNewPageObs),\n            dom.on(\"change\", (ev, elem) => isNewPageObs.set(elem.checked)),\n            testId(\"option-isNewPage\"),\n          ),\n        ),\n        dom(\n          \"div\", \"value: \", dom.text(use => JSON.stringify(use(valueOpt))),\n          dom(\n            \"button\", \"Change\",\n            pageWidgetPicker(async val => valueOpt.set(val), option),\n            testId(\"option-value\"),\n          ),\n          dom(\n            \"button\", \"omit\",\n            dom.on(\"click\", () => valueOpt.set(null)),\n            testId(\"option-omit-value\"),\n          ),\n        ),\n      ),\n    ]),\n\n    cssCallLogs(\n      dom(\"h3\", \"Call logs: \"),\n      dom.forEach(saveCalls, call => dom(\n        \"div\",\n        dom(\"span\", JSON.stringify(call.value), testId(\"call-value\")),\n        dom(\"button\", \"Resolve\", dom.on(\"click\", (ev, el) => {\n          call.resolve();\n          el.toggleAttribute(\"disabled\", true);\n        }), testId(\"resolve\")),\n        testId(\"call-log\"),\n      )),\n      testId(\"call-logs\"),\n    ),\n  ];\n}\n\nconst cssCallLogs = styled(\"div\", `\n  position: absolute;\n  z-index: 1000;\n  border: 1px solid grey;\n  width: 400px;\n  padding: 8px;\n  bottom: 0;\n  right: 0;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/PagesComponent.ts",
    "content": "import { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { addNewPage, addPages } from \"test/fixtures/projects/helpers/Pages\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, observable, styled } from \"grainjs\";\n\nconst container = styled(\"div\", `\n  width: 240px;\n  box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.5);\n  padding-top: 20px;\n  padding-bottom: 20px;\n  float: left;\n`);\n\nfunction setupTest() {\n  return [\n    container(\n      addPages(observable(true)),\n    ),\n    dom(\"input\", { type: \"button\", value: \"create new page\" },\n      dom.on(\"click\", addNewPage),\n      { style: \"float: right;\" },\n    ),\n  ];\n}\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/ParseOptions.ts",
    "content": "import { buildParseOptionsForm, ParseOptionValues } from \"app/client/components/ParseOptions\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { initSchema, initValues } from \"test/fixtures/projects/helpers/ParseOptionsData\";\n\nimport { dom, Observable, styled } from \"grainjs\";\n\nfunction setupTest() {\n  const schemaObs = Observable.create(null, initSchema);\n  const valuesObs = Observable.create<ParseOptionValues>(null, initValues);\n  function doUpdate(values: ParseOptionValues) { valuesObs.set(values); }\n  function doCancel() { /* no-op */ }\n\n  return [\n    dom(\"div\", { style: \"display: flex; width: 100%; height: 400px\" },\n      testBox(\n        dom(\"textarea\", dom.text(use => JSON.stringify(use(schemaObs), null, 2)),\n          { style: \"width: 100%; height: 100%; min-width: 400px; border: none;\" },\n          dom.on(\"change\", (ev, elem) => schemaObs.set(JSON.parse(elem.value)))),\n        testId(\"schema\"),\n      ),\n      dom(\"div\",\n        testBox(\n          dom.domComputed(use =>\n            dom.create(buildParseOptionsForm, use(schemaObs), use(valuesObs), doUpdate, doCancel),\n          ),\n          testId(\"parse-options\"),\n        ),\n        testBox(\n          dom.text(use => JSON.stringify(use(valuesObs), null, 2)),\n          testId(\"values\"),\n        ),\n      ),\n    ),\n  ];\n}\n\nconst testBox = styled(\"div\", `\n  flex: 1 0 auto;\n  margin: 2rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n  overflow: hidden;\n`);\n\ninitGristStyles();\ndom.update(document.body, setupTest());\n"
  },
  {
    "path": "test/fixtures/projects/ProgressIndicator.ts",
    "content": "/**\n * This fixture just allows seeing the progress indicator created by HomeImports module. It should\n * soon be replaced with a Notifications-based version (but probably a similar look).\n */\nimport { Notifier } from \"app/client/models/NotifyModel\";\nimport { buildSnackbarDom } from \"app/client/ui/NotifyUI\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\n\nimport { dom } from \"grainjs\";\n\nfunction setupTest() {\n  const notifier = Notifier.create(null);\n  notifier.createProgressIndicator(\"test-file.txt\", \"12mb\");\n  const p1 = notifier.createProgressIndicator(\"test-file.txt\", \"12mb\");\n  p1.setProgress(50);\n  const p2 = notifier.createProgressIndicator(\"test-file.txt\", \"12mb\");\n  p2.setProgress(100);\n  return buildSnackbarDom(notifier, null);\n}\n\ninitGristStyles();\ndom.update(document.body, setupTest());\n"
  },
  {
    "path": "test/fixtures/projects/Selects.ts",
    "content": "import { bigBasicButton } from \"app/client/ui2018/buttons\";\nimport { theme } from \"app/client/ui2018/cssVars\";\nimport { select, selectMenu, selectOption, selectTitle } from \"app/client/ui2018/menus\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, observable, styled } from \"grainjs\";\n\nfunction setupTest() {\n  const text = observable(\"1\");\n  const elements = observable([\n    selectOption(() => {}, \"Option2\", \"Script\"),\n    selectOption(() => {}, \"Option2\", \"Script\"),\n    selectOption(() => {}, \"Option2\", \"Script\"),\n    selectOption(() => {}, \"Option2\", \"Script\"),\n  ]);\n  return [\n    myButton(\n      \"Click me\",\n      dom.on(\"click\", () => {\n        elements.set([...elements.get(), selectOption(() => {}, \"Option2\", \"Script\")]);\n      }),\n    ),\n    select(text, [\"1\", \"2\", \"3\"]),\n    dom(\"div\", \"Selected \", dom.text(text)),\n    dom.style(\"padding\", \"80px\"),\n\n    selectMenu(selectTitle(\"Title\", \"Script\"), () => [\n      selectOption(() => {}, \"Option1\", \"Database\"),\n      horizontalLine(),\n      dom.domComputed(elements, el => el),\n    ]),\n  ];\n}\n\nconst myButton = styled(bigBasicButton, `\n  margin-right: 16px;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n\nexport const horizontalLine = styled(\"hr\", `\n  border: 1px solid ${theme.loginPageLine};\n  flex-grow: 1;\n`);\n"
  },
  {
    "path": "test/fixtures/projects/TreeViewComponent.ts",
    "content": "import { TreeItem } from \"app/client/models/TreeModel\";\nimport { TreeViewComponent } from \"app/client/ui/TreeViewComponent\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, obsArray, observable, styled } from \"grainjs\";\nimport constant from \"lodash/constant\";\n\nconst modelCalls = obsArray<string>();\nconst disposed = obsArray<string>();\nconst selected = observable<TreeItem | null>(null);\n\nfunction getLabel(item: TreeItem | null) {\n  return item ? (item as any).label : \"null\";\n}\n\nfunction callbacks(label: string) {\n  return {\n    insertBefore: (newChild: TreeItem, nextChild: TreeItem | null) => modelCalls.push(\n      `insert ${getLabel(newChild)} before ${getLabel(nextChild)} in ${label}`,\n    ),\n    removeChild: (child: TreeItem) => modelCalls.push(\n      `remove child ${getLabel(child)} from ${label}`,\n    ),\n  };\n}\n\nfunction treeItem(label: string, children: TreeItem[] | null = null) {\n  let item: any;\n  return item = {\n    label,\n    buildDom: () => dom(\"div\",\n      dom.text(label),\n      dom.onDispose(() => disposed.push(label)),\n      dom.on(\"click\", () => selected.set(item)),\n    ),\n    children: constant(children ? obsArray(children) : null),\n    ...callbacks(label),\n  };\n}\n\nfunction buildTreeModel() {\n  return {\n    children: constant(obsArray([\n      treeItem(\"Page1\", [\n        treeItem(\"Page2\"),\n        treeItem(\"Page3\", [\n          treeItem(\"Page4\"),\n        ]),\n      ]),\n      treeItem(\"Page5\", []),\n      treeItem(\"Page6\"),\n    ])),\n    ...callbacks(\"Root\"),\n  };\n}\n\nconst treeModel = observable(buildTreeModel());\n\nconst subFolderChildren = () => treeModel.get().children().get()[0].children()!;\n\nfunction setupTest() {\n  const isOpen = observable(true);\n  const isReadonly = observable(false);\n  return [\n    testBox(\n      dom.style(\"width\", \"224px\"),\n      dom.create(\n        TreeViewComponent, treeModel, { expanderDelay: 1100, isOpen, dragStartDelay: 500, selected, isReadonly },\n      ),\n    ),\n    testBox(\n      dom.style(\"float\", \"right\"),\n      dom(\"input.insert\", { type: \"button\", value: \"top insert\" },\n        dom.on(\"click\", () => treeModel.get().children().push(treeItem(\"New Page\"))),\n      ),\n      dom(\"input.subInsert\", { type: \"button\", value: \"sub insert\" },\n        dom.on(\"click\", () => subFolderChildren().push(treeItem(\"New Page 5\"))),\n      ),\n      dom(\"input.clearLogs\", { type: \"button\", value: \"clear calls\" },\n        dom.on(\"click\", () => {\n          modelCalls.set([]);\n          disposed.set([]);\n        }),\n      ),\n      dom(\"input.reset\", { type: \"button\", value: \"reset\" },\n        dom.on(\"click\", () => treeModel.set(buildTreeModel()))),\n      dom(\"input.move\", { type: \"button\", value: \"move\" },\n        dom.on(\"click\", () => {\n          const src = treeModel.get().children().get()[0];\n          const dest = treeModel.get();\n          const item = src.children()!.get()[1];\n          // removeChild\n          src.children()!.splice(1, 1);\n          // insertBefore\n          dest.children().splice(2, 0, item as any);\n        })),\n      dom(\"input.remove\", { type: \"button\", value: \"remove\" },\n        dom.on(\"click\", () => {\n          treeModel.get().children().splice(0, 1);\n        })),\n      dom(\"input.removePage4\", { type: \"button\", value: \"remove Page4\" },\n        dom.on(\"click\", () => {\n          const page1 = treeModel.get().children().get()[0];\n          const page3 = page1.children()!.get()[1];\n          // remove page4\n          page3.children()!.get().splice(0, 1);\n          // then resinsert page3 to update\n          page1.children()!.splice(1, 1, page3);\n        })),\n      dom(\"h3\", \"Options\"),\n      dom(\n        \"div\",\n        dom(\n          \"input.isOpen\", { type: \"checkbox\", value: \"isOpen\", checked: true },\n          dom.on(\"click\", () => isOpen.set(!isOpen.get())),\n        ),\n        \"isOpen option\",\n      ),\n      dom(\n        \"div\",\n        dom(\n          \"input.isReadonly\", { type: \"checkbox\", value: \"isReadonly\", checked: false },\n          dom.on(\"click\", () => isReadonly.set(!isReadonly.get())),\n        ),\n        \"readonly mode\",\n      ),\n    ),\n  ];\n}\n\nconst testBox = styled(\"div\", `\n  width: 25rem;\n  font-family: sans-serif;\n  font-size: 1rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n  padding: 1rem;\n  margin: 1rem;\n`);\n\ninitGristStyles();\nvoid withLocale(() => {\n  dom.update(document.body, setupTest(),\n    dom(\"h3\", \"model calls\"),\n    dom.forEach(modelCalls, log => dom(\"div.model-calls\", log)),\n    dom(\"h3\", \"Disposed Items: \"),\n    dom.forEach(disposed, log => dom(\"div.disposed-items\", log)));\n});\n"
  },
  {
    "path": "test/fixtures/projects/UI2018.ts",
    "content": "import { docBreadcrumbs } from \"app/client/ui2018/breadcrumbs\";\nimport { basicButton, bigBasicButton, cssButtonGroup } from \"app/client/ui2018/buttons\";\nimport { bigPrimaryButton, primaryButton } from \"app/client/ui2018/buttons\";\nimport { basicButtonLink, bigBasicButtonLink } from \"app/client/ui2018/buttons\";\nimport { bigPrimaryButtonLink, primaryButtonLink } from \"app/client/ui2018/buttons\";\nimport { alignmentSelect, buttonSelect, colorSelect, cssButtonSelect } from \"app/client/ui2018/buttonSelect\";\nimport { buttonToggleSelect, ISelectorOption } from \"app/client/ui2018/buttonSelect\";\nimport { circleCheckbox, squareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { labeledCircleCheckbox, labeledSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { Indeterminate, labeledTriStateSquareCheckbox } from \"app/client/ui2018/checkbox\";\nimport { testId, vars } from \"app/client/ui2018/cssVars\";\nimport { editableLabel } from \"app/client/ui2018/editableLabel\";\nimport { icon } from \"app/client/ui2018/icons\";\nimport * as menu from \"app/client/ui2018/menus\";\nimport { searchBar } from \"app/client/ui2018/search\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { Computed, dom, makeTestId, obsArray, styled } from \"grainjs\";\nimport { observable, Observable } from \"grainjs\";\nimport noop from \"lodash/noop\";\n\nfunction setupTest() {\n  const actionText = observable(\"\");\n  const cssAction = dom(\"div\",\n    dom(\"button#action-reset\", \"Reset\", dom.on(\"click\", () => actionText.set(\"\"))),\n    \"Action: \",\n    dom(\"span#action-text\", dom.text(actionText)),\n  );\n\n  const cssElemRow = styled(\"div\", `\n    & > * {\n      margin-left: 8px;\n    }\n  `);\n\n  const buttons = dom(\"div#buttons\",\n    dom(\"h1\", \"Buttons\"),\n    dom(\"h4\", \"Default state\"),\n    cssElemRow(\n      dom.cls(\"elements\"),\n      basicButton(\"Basic button\", icon(\"Dropdown\"), dom.on(\"click\", () => actionText.set(\"Basic button\"))),\n      bigBasicButton(\"Big basic button\", icon(\"Dropdown\"), dom.on(\"click\", () => actionText.set(\"Big basic button\"))),\n      primaryButton(\"Primary button\", icon(\"Dropdown\"), dom.on(\"click\", () => actionText.set(\"Primary button\"))),\n      bigPrimaryButton(\"Big primary button\", icon(\"Dropdown\"),\n        dom.on(\"click\", () => actionText.set(\"Big primary button\"))),\n    ),\n    dom(\"h4\", \"Disabled state\"),\n    cssElemRow(\n      basicButton(\"Basic disabled\", icon(\"Dropdown\"), dom.prop(\"disabled\", true)),\n      bigBasicButton(\"Big basic button\", icon(\"Dropdown\"), dom.prop(\"disabled\", true)),\n      primaryButton(\"Primary disabled\", icon(\"Dropdown\"), dom.prop(\"disabled\", true)),\n      bigPrimaryButton(\"Big primary button\", icon(\"Dropdown\"), dom.prop(\"disabled\", true)),\n    ),\n    dom(\"h4\", \"Button links\"),\n    cssElemRow(\n      basicButtonLink(\"Basic Button Link\", { href: \"#\" }),\n      bigBasicButtonLink(\"Big Basic Button Link\", { href: \"#\" }),\n      primaryButtonLink(\"Primary Button Link\", { href: \"#\" }),\n      bigPrimaryButtonLink(\"Big Primary Button Link\", { href: \"#\" }),\n    ),\n  );\n\n  function myEditableLabel(obs: Observable<string>) {\n    return editableLabel(obs, { save: async val => obs.set(val) });\n  }\n\n  const labels = dom(\"div#labels\",\n    dom(\"h4\", \"Labels\"),\n    dom(\"div#editable-label\",\n      dom(\"div\", myEditableLabel(observable(\"Hello\"))),\n      dom(\"div\", myEditableLabel(observable(\"Small editable label\")),\n        { style: `font-size: ${vars.smallFontSize}` }),\n      dom(\"div\", myEditableLabel(observable(\"Medium (default) editable label\")),\n        { style: `font-size: ${vars.mediumFontSize}` }),\n      dom(\"div\", myEditableLabel(observable(\"Large editable label\")),\n        { style: `font-size: ${vars.largeFontSize}` }),\n    ),\n    dom(\"div#noneditable-label\",\n      dom(\"div\", styled(\"span\", `font-size: ${vars.smallFontSize}`)(dom.text(observable(\"Small label\")))),\n      dom(\"div\", styled(\"span\", `font-size: ${vars.mediumFontSize}`)(dom.text(observable(\"Medium (default) label\")))),\n      dom(\"div\", styled(\"span\", `font-size: ${vars.largeFontSize}`)(dom.text(observable(\"Large label\")))),\n    ),\n  );\n\n  const obsCheck1 = observable(false);\n  const obsCheck2 = observable(true);\n  const bothCheck = Computed.create(null, obsCheck1, obsCheck2, (_use, check1, check2) => {\n    if (check1 && check2) { return true; }\n    if (check1 || check2) { return Indeterminate; }\n    return false;\n  })\n    .onWrite((val) => {\n      if (val === Indeterminate) { return; }\n      obsCheck1.set(val); obsCheck2.set(val);\n    });\n\n  const checkbox = dom(\"div#checkbox\",\n    dom(\"h1\", \"Checkbox\"),\n    dom(\"h4\", \"Default\"),\n    dom(\"div\",\n      \"obsCheck1: \", dom.text(use => String(use(obsCheck1))),\n      \", obsCheck2: \", dom.text(use => String(use(obsCheck2))),\n    ),\n    cssElemRow(\n      squareCheckbox(obsCheck1),\n      labeledSquareCheckbox(obsCheck1, \"Include other values\"),\n      squareCheckbox(obsCheck2),\n      labeledSquareCheckbox(obsCheck2, \"Include other values\"),\n    ),\n    cssElemRow(\n      circleCheckbox(obsCheck1),\n      labeledCircleCheckbox(obsCheck1, \"Include other values\"),\n      circleCheckbox(obsCheck2),\n      labeledCircleCheckbox(obsCheck2, \"Include other values\"),\n    ),\n    dom(\"h4\", \"Disabled\"),\n    cssElemRow(\n      squareCheckbox(obsCheck1, dom.prop(\"disabled\", true)),\n      labeledSquareCheckbox(obsCheck1, \"Include other values\", dom.prop(\"disabled\", true)),\n      squareCheckbox(obsCheck2, dom.prop(\"disabled\", true)),\n      labeledSquareCheckbox(obsCheck2, \"Include other values\", dom.prop(\"disabled\", true)),\n    ),\n    cssElemRow(\n      circleCheckbox(obsCheck1, dom.prop(\"disabled\", true)),\n      labeledCircleCheckbox(obsCheck1, \"Include other values\", dom.prop(\"disabled\", true)),\n      circleCheckbox(obsCheck2, dom.prop(\"disabled\", true)),\n      labeledCircleCheckbox(obsCheck2, \"Include other values\", dom.prop(\"disabled\", true)),\n    ),\n    dom(\"h4\", \"Indeterminate\"),\n    cssElemRow(\n      labeledTriStateSquareCheckbox(bothCheck, \"All checked\", testId(\"both-check\")),\n    ),\n    cssElemRow(labeledSquareCheckbox(obsCheck1, \"Santa\", testId(\"check-1\")), { style: `margin-left: 16px` }),\n    cssElemRow(labeledSquareCheckbox(obsCheck2, \"Babar\"), { style: `margin-left: 16px` }),\n  );\n\n  const type = observable(\"\");\n  const types = obsArray<string>();\n  const typeOptions: menu.IOption<string>[] = [\n    { value: \"text\",       label: \"Text\",       icon: \"FieldText\"                      },\n    { value: \"numeric\",    label: \"Numeric\",    icon: \"FieldNumeric\"                   },\n    { value: \"integer\",    label: \"Integer\",    icon: \"FieldInteger\"                   },\n    { value: \"toggle\",     label: \"Toggle\",     icon: \"FieldToggle\"                    },\n    { value: \"date\",       label: \"Date\",       icon: \"FieldDate\"                      },\n    { value: \"datetime\",   label: \"DateTime\",   icon: \"FieldDateTime\"                  },\n    { value: \"choice\",     label: \"Choice\",     icon: \"FieldChoice\"                    },\n    { value: \"reference\",  label: \"Reference\",  icon: \"FieldReference\"                 },\n    { value: \"attachment\", label: \"Attachment\", icon: \"FieldAttachment\"                },\n    { value: \"any\",        label: \"Any\",        icon: \"FieldAny\",       disabled: true },\n    { value: \"fakeType\",   label: \"A very very long fake label for a very fake type\",\n      icon: \"FieldText\" },\n  ];\n  // If 4 or more items are selected in the multiSelect, turn on the error flag for testing purposes.\n  const multiSelectError = Computed.create(null, types, (_use, ts) => ts.length >= 4);\n\n  const menus = dom(\"div#menus\",\n    dom(\"h1\", \"Menus\"),\n    dom(\"h4\", \"Default\"),\n    primaryButton(\"Default menu\",\n      menu.menu(() => [\n        menu.menuItem(() => { console.log(\"Menu item: Hello\"); }, \"Log 'Hello'\"),\n        menu.menuDivider(),\n        menu.menuSubHeader(\"Subheader\"),\n        menu.menuItem(() => undefined, dom.cls(\"disabled\", true), \"Disabled\"),\n        menu.menuItem(() => { console.log(\"Menu item: World\"); }, \"Log 'World'\"),\n      ]),\n    ),\n    dom(\"h4\", \"Select menu\"),\n    dom(\"div\", { style: `width: 200px;` },\n      menu.select(type, typeOptions, { defaultLabel: \"Select column type\" }),\n    ),\n    dom(\"h4\", \"Scrollable select menu\"),\n    dom(\"div\", { style: `width: 100px;` },\n      menu.select(observable(\"0\"), [...Array(100).keys()].map(n => n.toString())),\n    ),\n    dom(\"h4\", \"Form select menu\"),\n    dom(\"div\", { style: \"width: 200px\" },\n      menu.formSelect(type, typeOptions, { defaultLabel: \"Select column type\" }),\n    ),\n    dom(\"h4\", \"Multi select menu\"),\n    dom(\"div\", { style: \"width: 200px\" },\n      menu.multiSelect(types, typeOptions, {\n        placeholder: \"Select column type\",\n        error: multiSelectError,\n      }, testId(\"multi-select\")),\n    ),\n  );\n\n  const cssSearchBarWrapper = styled(\"div#searchbar\", `\n    display: flex;\n    flex-direction: row-reverse;\n    border: 1px solid blue;\n    width: 240px;\n  `);\n\n  const searchModel = {\n    value: observable(\"\"),\n    isOpen: observable(false),\n    noMatch: observable(true),\n    isEmpty: observable(true),\n    isRunning: observable(false),\n    multiPage: observable(true),\n    allLabel: observable(\"Search on all pages\"),\n    findNext: () => Promise.resolve(),\n    findPrev: () => Promise.resolve(),\n    onPageChange: () => {},\n  };\n\n  const search = dom(\"div#search\",\n    dom(\"h4\", \"Search bar\"),\n    cssSearchBarWrapper(\n      searchBar(searchModel, makeTestId(\"test-search-\")),\n    ),\n  );\n\n  const ws = observable({ id: 0, name: \"Samples\" });\n  const docName = observable(\"Lightweight CRM\");\n  const pageName = observable(\"Clients\");\n\n  const breadcrumbs = dom(\"div#breadcrumbs\",\n    dom(\"h4\", \"Breadcrumbs\"),\n    docBreadcrumbs(ws, docName, pageName, {\n      docNameSave: async val => docName.set(val),\n      pageNameSave: async val => pageName.set(val),\n      cancelRecoveryMode: async () => undefined,\n      isFork: observable(false),\n      isBareFork: observable(false),\n      isTutorialFork: observable(false),\n      isFiddle: observable(false),\n      isRecoveryMode: observable(false),\n      isTemplate: observable(false),\n      isAnonymous: false,\n    }),\n  );\n\n  const alignmentObs = observable(\"left\");\n\n  const widgetObs = observable(1);\n  const widgetBtns: ISelectorOption<number>[] = [\n    { value: 0, label: \"Date\",    icon: \"FieldDate\" },\n    { value: 1, label: \"Spinner\", icon: \"FieldSpinner\" },\n  ];\n\n  const chartObs = observable(null);\n  const chartBtns: ISelectorOption<string>[] = [\n    { value: \"bar\",    icon: \"ChartBar\" },\n    { value: \"pie\",    icon: \"ChartPie\" },\n    { value: \"area\",   icon: \"ChartArea\" },\n    { value: \"line\",   icon: \"ChartLine\" },\n    { value: \"kaplan\", icon: \"ChartKaplan\" },\n  ];\n\n  const inline = styled(\"div\", `\n    display: inline-block;\n    margin: 0 10px 20px 0;\n  `);\n\n  const btnSel = dom(\"div#buttonselect\",\n    dom(\"h4\", \"Button Select\"),\n    dom(\"div\",\n      dom.cls(\"alignment-select\"),\n      inline(alignmentSelect(alignmentObs)),\n      dom(\"span\",\n        dom.cls(\"alignment-value\"),\n        dom.text(alignmentObs),\n      ),\n    ),\n    dom(\"div\",\n      dom.cls(\"widget-select\"),\n      inline({ style: \"width: 180px;\" }, buttonSelect(widgetObs, widgetBtns)),\n      dom(\"span\",\n        dom.cls(\"widget-value\"),\n        dom.text(use => String(use(widgetObs))),\n      ),\n    ),\n    dom(\"div\",\n      dom.cls(\"widget-select\"),\n      inline({ style: \"width: 180px;\" }, buttonSelect(widgetObs, widgetBtns, cssButtonSelect.cls(\"-light\"))),\n      dom(\"span\",\n        dom.cls(\"widget-value\"),\n        dom.text(use => String(use(widgetObs))),\n      ),\n    ),\n    dom(\"div\",\n      dom.cls(\"chart-select\"),\n      inline({ style: \"width: 200px;\" },\n        buttonToggleSelect(chartObs, chartBtns, { large: true, primary: true })),\n      dom(\"span\",\n        dom.cls(\"chart-value\"),\n        dom.text(use => String(use(chartObs))),\n      ),\n    ),\n  );\n\n  const colorObs = observable(\"#ff5555\");\n  const colorSel = dom(\"div#colorselect\",\n    dom(\"h4\", \"Color Select\"),\n    dom(\"div\", { style: \"display: flex; align-items: center;\" },\n      dom(\"span\", { style: \"margin-right: 10px;\" }, \"Pick a color:\"),\n      colorSelect(colorObs, () => null as any),\n    ),\n  );\n\n  const btnGroup = dom(\n    \"div#btnGroup\",\n    dom(\"h4\", \"Button Group\"),\n    cssButtonGroup(\n      primaryButton(\"Save Copy\"),\n      primaryButton(\n        icon(\"Dropdown\"),\n        menu.menu(() => [\n          menu.menuItem(noop, \"Do this\"),\n          menu.menuItem(noop, \"Do that\"),\n        ]),\n      ),\n    ),\n  );\n\n  return cssTestBox(\n    cssAction,\n    buttons,\n    labels,\n    breadcrumbs,\n    checkbox,\n    menus,\n    search,\n    btnSel,\n    colorSel,\n    btnGroup,\n  );\n}\n\nconst cssTestBox = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  overflow: auto;\n  height: 100%;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/UserImage.ts",
    "content": "/**\n * This test page shows the circular user-icons for users with and without images.\n */\nimport { createUserImage, Size } from \"app/client/ui/UserImage\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\n\nimport { dom, styled } from \"grainjs\";\nimport range from \"lodash/range\";\n\nfunction setupTest() {\n  return dom(\"div\",\n    dom(\"h3\", \"Legend\"),\n    cssTestBox(\n      createUserImage({ name: \" \", email: \"foo@example.com\" }, \"medium\"),\n      createUserImage({ name: \"George  Washington\", email: \"gf@example.com\" }, \"medium\"),\n      dom(\"div\", \"One- or two-letter initials\"),\n    ),\n    cssTestBox(\n      createUserImage({ name: \"\", email: \"\" }, \"medium\"),\n      createUserImage({ name: undefined as any, email: undefined as any }, \"medium\"),\n      dom(\"div\", \"Missing name or email (not supposed to happen)\"),\n    ),\n    cssTestBox(\n      createUserImage(null, \"medium\"),\n      createUserImage({ name: \"Anonymous\", email: \"anon@example.com\",\n        picture: \"https://avatars2.githubusercontent.com/u/1091143?s=40&v=4\",\n        anonymous: true, // this should take priority.\n      }, \"medium\"),\n      dom(\"div\", \"Missing or anonymous user (image not normally used)\"),\n    ),\n    cssTestBox(\n      createUserImage({ name: \"Someone\", email: \"\",\n        picture: \"https://avatars2.githubusercontent.com/u/1091143?s=40&v=4\" }, \"medium\"),\n      createUserImage({ name: \"Someone\", email: \"\",\n        picture: \"https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50\" }, \"medium\"),\n      dom(\"div\", \"An actual image (from gravatar.com)\"),\n    ),\n    dom(\"h3\", \"Medium (app header)\"),\n    createAssorted(\"medium\"),\n    cssTestBox(\n      dom.forEach(range(20).map(i => `A${i}`), name =>\n        createUserImage({ name, email: \"\" }, \"medium\"),\n      ),\n    ),\n    cssTestBox(\n      dom.forEach(range(20).map(i => `J M${i}`), name =>\n        createUserImage({ name, email: \"\" }, \"medium\"),\n      ),\n    ),\n    dom(\"h3\", \"Small (current users on the document)\"),\n    createAssorted(\"small\"),\n    dom(\"h3\", \"Large (manage users dialog)\"),\n    createAssorted(\"large\"),\n  );\n}\n\nfunction createAssorted(size: Size) {\n  return cssTestBox(\n    createUserImage({ name: \"George  Washington\", email: \"gf@example.com\" }, size),\n    createUserImage({ name: \"D S\", email: \"\" }, size),\n    createUserImage({ name: \" \", email: \"foo@example.com\" }, size),\n    createUserImage({ name: \"Bob\", email: \"\" }, size),\n    createUserImage({ name: \"\", email: \"\" }, size),\n    createUserImage({ name: undefined as any, email: undefined as any }, size),\n    createUserImage(null, size),\n    createUserImage({ name: \"Anonymous\", email: \"anon@example.com\",\n      picture: \"https://avatars2.githubusercontent.com/u/1091143?s=40&v=4\",\n      anonymous: true, // this should take priority.\n    }, size),\n    // Dmitry's gravatar\n    createUserImage({ name: \"\", email: \"\",\n      picture: \"https://avatars2.githubusercontent.com/u/1091143?s=40&v=4\" }, size),\n    // Image from https://en.gravatar.com/site/implement/images/\n    createUserImage({ name: \"Someone\", email: \"\",\n      picture: \"https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50\" }, size),\n    createUserImage({ name: \"Someone\", email: \"\",\n      picture: \"https://www.gravatar.com/avatar/00000000000000000000000000000000\" }, size),\n  );\n}\n\nconst cssTestBox = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  & > div {\n    margin: 4px;\n  }\n`);\n\ninitGristStyles();\ndom.update(document.body, setupTest());\n"
  },
  {
    "path": "test/fixtures/projects/UserManager.ts",
    "content": "import { UserManagerModelImpl } from \"app/client/models/UserManagerModel\";\nimport { UserManager } from \"app/client/ui/UserManager\";\nimport { PermissionData, PermissionDelta } from \"app/common/UserAPI\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, observable, styled } from \"grainjs\";\n\nfunction getInitialData(): PermissionData {\n  return {\n    maxInheritedRole: null,\n    users: [\n      {\n        id: 1,\n        name: \"Foo Johnson\",\n        email: \"foo@example.com\",\n        access: \"owners\",\n      },\n      {\n        id: 2,\n        name: \"Bar Jackson\",\n        email: \"bar@example.com\",\n        access: \"editors\",\n      },\n      {\n        id: 3,\n        name: \"Team Member\",\n        email: \"team@example.com\",\n        access: \"viewers\",\n        isMember: true,\n      },\n      {\n        id: 4,\n        name: \"Guest\",\n        email: \"guest@example.com\",\n        access: \"viewers\",\n        isMember: false,\n      },\n    ],\n  };\n}\n\nfunction setupTest() {\n  const lastDelta = observable<PermissionDelta>({});\n  const activeUser = { id: 5, email: \"test-usermanager@getgrist.com\", name: \"Test\" };\n  const model = new UserManagerModelImpl(getInitialData(), \"document\", { activeUser });\n  const um = observable(new UserManager(model, {}));\n  return [\n    testBox(\n      dom.domComputed(um, _um => _um.buildDom()),\n      dom(\"button.test-save\", \"Save\", dom.on(\"click\", () => { lastDelta.set(model.getDelta()); })),\n    ),\n    testBox(\n      dom(\"pre.test-result\", dom.text(use => JSON.stringify(use(lastDelta), null, 2))),\n      dom(\"button.test-reset\", \"Reset\", dom.on(\"click\", () => {\n        lastDelta.set({});\n        model.reset();\n      })),\n    ),\n  ];\n}\n\nconst testBox = styled(\"div\", `\n  float: left;\n  font-family: sans-serif;\n  font-size: 1rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n  padding: 1rem;\n  margin: 1rem;\n`);\n\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/contextMenu.ts",
    "content": "import { contextMenu } from  \"app/client/ui/contextMenu\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, observable, styled } from \"grainjs\";\nimport { menuItem } from \"popweasel\";\ninitGristStyles();\n\nfunction setupTest() {\n  const logs = observable<string[]>([]);\n  document.querySelector(\"html\")!.classList.add(cssBody.className);\n  document.querySelector(\"body\")!.classList.add(cssBody.className);\n\n  dom.update(\n    document.body,\n    dom.on(\"contextmenu\", ev => ev.preventDefault()),\n  );\n\n  return cssFullscreen(\n    \"right click any where...\",\n    contextMenu(() => [\n      menuItem(() => logs.set(logs.get().concat(\"foo\")), \"Foo\"),\n      menuItem(() => logs.set(logs.get().concat(\"bar\")), \"Bar\"),\n      menuItem(() => logs.set([]), \"Reset\"),\n    ]),\n    dom.forEach(logs, name => dom(\"div\", `${name} added`, testId(\"logs\"))),\n  );\n}\n\nconst cssFullscreen = styled(\"div\", `\n  position: absolute;\n  top: 0;\n  bottom: 0;\n  left: 0;\n  right: 0;\n`);\n\nconst cssBody = styled(\"div\", `\n  height: 100%;\n  margin: 0;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/editableLabel.ts",
    "content": "import { editableLabel, textInput } from \"app/client/ui2018/editableLabel\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { Computed, Disposable, dom, IDisposableOwner, makeTestId, obsArray, Observable, select, styled } from \"grainjs\";\n\ninterface PendingCall {\n  callValue: string;\n  resolve(): void;\n  reject(err: Error): void;\n}\n\ntype ComponentName = \"textInput\" | \"editableLabel\";\n\nconst testId = makeTestId(\"test-\");\n\n/**\n * This test simulates the flow when an editableLabel is used to edit a value that will get saved\n * to the server. The call to save it is asynchronous, and we expose a text-box and buttons for\n * each call to set the return value, and to resolve or reject it.\n */\nclass SaveableSetup extends Disposable {\n  // The value that reflects the state on the server.\n  public savedValue = Observable.create<string>(this, \"Hello\");\n\n  // To simulate a pending call that's been made to the server, this contains the resolve/reject\n  // methods to complete the call.\n  public pendingCalls = this.autoDispose(obsArray<PendingCall>([]));\n\n  // A log of calls made and completed, for testing the sequence of events.\n  public callLog = this.autoDispose(obsArray<string>([]));\n\n  constructor(public component: ComponentName) {\n    super();\n  }\n\n  // Simulates a value getting updated due to an update on the server side.\n  public onServerUpdate(value: string) {\n    this.savedValue.set(value);\n  }\n\n  // Simulates a save call to the server. This will add an entry to pendingCalls. To resolve it,\n  // call pendingCall.resolve (or reject), AND change the server value with onServerUpdate() to\n  // simulate a successful save.\n  public async makeSaveCall(callValue: string): Promise<void> {\n    this.callLog.push(`Called: ${callValue}`);\n    let pendingCall: PendingCall;\n    try {\n      await new Promise<void>((resolve, reject) => {\n        pendingCall = { callValue, resolve, reject };\n        this.pendingCalls.push(pendingCall);\n      });\n      this.callLog.push(`Resolved`);\n    } catch (e) {\n      this.callLog.push(`Rejected: ${e.message}`);\n      throw e;\n    } finally {\n      const index = this.pendingCalls.get().indexOf(pendingCall!);\n      this.pendingCalls.splice(index, 1);\n    }\n  }\n\n  public buildDom() {\n    let serverInput: HTMLInputElement;\n    const obs = Computed.create(this, use => use(this.savedValue));\n    const save = (val: string) => this.makeSaveCall(val);\n\n    // To test server changes while editableLabel is being edited, listen to a Ctrl-U key\n    // combination to act as the \"Update\" button without affecting focus.\n    this.autoDispose(dom.onElem(document.body, \"keydown\", (ev) => {\n      if (ev.code === \"KeyU\" && ev.ctrlKey) {\n        this.onServerUpdate(serverInput.value);\n      }\n    }));\n\n    return [\n      cssTestBox(\n        cssItem(\n          dom(\"div\", \"Editable label:\"),\n          this.component === \"editableLabel\" ?\n            cssEditableLabel(obs, { save, inputArgs: [testId(\"edit-label\")] }) :\n            cssTextInput(obs, save, testId(\"edit-label\")),\n        ),\n        cssItem(dom(\"div\", \"Saved value:\"),\n          dom(\"span\", dom.text(this.savedValue), testId(\"saved-value\"))),\n        cssItem(dom(\"div\", \"Server value:\"),\n          serverInput = dom(\"input\", { type: \"text\" }, testId(\"server-value\"),\n            dom.prop(\"value\", this.savedValue)),\n          dom(\"input\", { type: \"button\", value: \"Update\" }, testId(\"server-update\"),\n            dom.on(\"click\", ev => this.onServerUpdate(serverInput.value))),\n        ),\n        dom.forEach(this.pendingCalls, (pendingCall: PendingCall) =>\n          cssItem(dom(\"div\", \"Pending call:\"), dom.text(pendingCall.callValue),\n            testId(\"call\"),\n            dom(\"input\", { type: \"button\", value: \"Resolve\" }, testId(\"call-resolve\"),\n              dom.on(\"click\", () => pendingCall.resolve())),\n            dom(\"input\", { type: \"button\", value: \"Reject\" }, testId(\"call-reject\"),\n              dom.on(\"click\", () => pendingCall.reject(new Error(\"FakeError\")))),\n          ),\n        ),\n      ),\n      cssTestBox(\n        cssItem(\n          dom(\"div\", \"Call Log\"),\n          dom(\"ul\", testId(\"call-log\"),\n            dom.forEach(this.callLog, val => dom(\"li\", val))),\n        ),\n      ),\n    ];\n  }\n}\n\nfunction setupTest(owner: IDisposableOwner) {\n  // The only purpose of this observable is to trigger the rebuilding of SaveableSetup. It's\n  // strange, but fairly simple.\n  const value = Observable.create(owner, 1);\n  const component = Observable.create(owner, (window.location.hash || \"#textInput\").substr(1) as ComponentName);\n  return [\n    dom(\"div\", dom(\"input\", { type: \"button\", value: \"Reset All\" },\n      testId(\"reset\"),\n      dom.on(\"click\", () => value.set(value.get() + 1)))),\n    dom(\"div\", select(component, [\"textInput\", \"editableLabel\"]), testId(\"select-component\")),\n    dom(\"div\", dom.domComputed(use => (use(value), dom.create(SaveableSetup, use(component))))),\n  ];\n}\n\nconst cssTestBox = styled(\"div\", `\n  float: left;\n  width: 250px;\n  margin-left: 50px;\n  margin-top: 50px;\n  font-family: sans-serif;\n  font-size: 1rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n`);\n\nconst cssItem = styled(\"div\", `\n  margin: 30px;\n`);\n\nconst cssEditableLabel = styled(editableLabel, `\n  color: blue;\n   &:focus { background-color: lightgrey; }\n`);\n\nconst cssTextInput = styled(textInput, `\n  color: blue;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, dom.create(setupTest)));\n"
  },
  {
    "path": "test/fixtures/projects/forms.ts",
    "content": "/**\n * A fixture for checking the looks of the elements provided by the 'forms' module.\n */\nimport { formDataToObj } from \"app/client/lib/formUtils\";\nimport * as forms from \"app/client/ui/forms\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\n\nimport { dom, IDisposableOwner, Observable } from \"grainjs\";\n\nfunction setupTest(owner: IDisposableOwner) {\n  const formValue = Observable.create(owner, \"\");\n  const isFilled = Observable.create(owner, false);\n  return forms.form({ style: \"width: 50%\" },\n    forms.question(\n      forms.text(\"What color is the sky right now?\"),\n      forms.checkboxItem([{ name: \"sky-blue\" }], \"Blue\"),\n      forms.checkboxItem([{ name: \"sky-orange\" }], \"Orange\"),\n      forms.checkboxOther([], { name: \"sky-other\", placeholder: \"Other...\" }),\n    ),\n    forms.question(\n      forms.text(\"What is the meaning of life, universe, and everything?\"),\n      forms.textBox({ name: \"meaning\", placeholder: \"Your answer\" }),\n    ),\n\n    // Show the form contents.\n    forms.checkboxItem([{ disabled: true }, dom.prop(\"checked\", isFilled)], \"Is Form Filled?\"),\n    dom(\"textarea\", { rows: \"8\", cols: \"80\" }, dom.prop(\"value\", formValue)),\n    dom.on(\"change\", (e, form) => {\n      isFilled.set(forms.isFormFilled(form, [\"sky-*\", \"meaning\"]));\n      formValue.set(JSON.stringify(formDataToObj(form), null, 2));\n    }),\n  );\n}\n\ninitGristStyles();\ndom.update(document.body, dom.create(setupTest));\n"
  },
  {
    "path": "test/fixtures/projects/helpers/MockUserAPI.ts",
    "content": "import { urlState } from \"app/client/models/gristUrlState\";\nimport { ApplyUAResult } from \"app/common/ActiveDocAPI\";\nimport { ApiError } from \"app/common/ApiError\";\nimport { BillingAPI } from \"app/common/BillingAPI\";\nimport { ICustomWidget } from \"app/common/CustomWidget\";\nimport { TableColValues, UserAction } from \"app/common/DocActions\";\nimport { DocCreationInfo } from \"app/common/DocListAPI\";\nimport { createEmptyOrgUsageSummary, OrgUsageSummary } from \"app/common/DocUsage\";\nimport { arrayRemove } from \"app/common/gutil\";\nimport { FullUser } from \"app/common/LoginSessionAPI\";\nimport { NonGuestRole } from \"app/common/roles\";\nimport { ActiveSessionInfo, DocAPI, Document, DocumentOptions, DocumentProperties, DocWorkerAPI,\n  Organization, OrganizationProperties, PermissionData, PermissionDelta,\n  RenameDocOptions, UserAPI, Workspace } from \"app/common/UserAPI\";\n\nconst createdAt = \"2007-04-05T14:30Z\";\nconst updatedAt = \"2007-04-05T14:30Z\";\n\nconst TEMPLATES_ORG_ID = 5;\n\ninterface OrgEntry {\n  id: number;\n  name: string;\n  domain: string | null;\n  owner?: any;\n  workspaces: number[];\n  access: NonGuestRole;\n}\n\nfunction orgEntryToOrg({ id, name, domain, owner, access }: OrgEntry): Organization {\n  return { id, name, domain, owner, access, createdAt, updatedAt, host: null };\n}\n\ninterface OrgStore {\n  [key: string]: OrgEntry;\n}\n\ninterface WorkspaceEntry {\n  id: number;\n  name: string;\n  org: number;\n  docs: number[];\n  access: NonGuestRole;\n}\n\ninterface WorkspaceStore {\n  [key: string]: WorkspaceEntry;\n}\n\ninterface DocEntry {\n  id: number;\n  name: string;\n  workspace: number;\n  access: NonGuestRole;\n  isPinned: boolean;\n  age?: number;         // age in seconds\n  options?: DocumentOptions | null;\n}\n\ninterface DocStore {\n  [key: string]: DocEntry;\n}\n\n// needed to mock `createApiKey()`\nlet keyIndex = 0;\n\n/**\n * Mock implementation of UserAPI and DocWorkerAPI.\n *\n * Used by other tests that need to mock API calls, such as DocMenu and MFAConfig tests.\n */\nexport class MockUserAPI implements UserAPI, DocWorkerAPI {\n  public readonly url: string = \"http://localhost:0\";\n  public activeUser: string = \"santa\";    // Can be changed to pretend to be a different user\n\n  private _nextOrgId = 4;\n  private _nextWorkspaceId = 10;\n  private _nextDocId = 32;\n\n  private _orgs: OrgStore = {\n    1: { id: 1, domain: null, name: \"Personal\", workspaces: [1, 2, 3], access: \"owners\" },\n    2: { id: 2, domain: \"nike\", name: \"Nike\", workspaces: [4, 5], access: \"viewers\" },\n    3: { id: 3, domain: \"chase\", name: \"Chase\", workspaces: [6], access: \"owners\" },\n    4: { id: 4, domain: \"ms\", name: \"Microsoft\", workspaces: [7], access: \"owners\" },\n    [TEMPLATES_ORG_ID]: {\n      id: TEMPLATES_ORG_ID, domain: \"templates\", name: \"Grist Templates\", workspaces: [8, 9], access: \"viewers\",\n    },\n  };\n\n  private _workspaces: WorkspaceStore = {\n    1: { id: 1, name: \"Real estate\", org: 1, docs: [1, 2, 3, 4, 5, 6, 7, 8, 9], access: \"viewers\" },\n    2: {\n      id: 2, name: \"Personal\", org: 1,\n      docs: [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21], access: \"owners\",\n    },\n    3: { id: 3, name: \"August\", org: 1, docs: [22, 23], access: \"owners\" },\n    4: { id: 4, name: \"Hosted\", org: 2, docs: [24, 25, 26], access: \"owners\" },\n    5: { id: 5, name: \"Management\", org: 2, docs: [27], access: \"owners\" },\n    6: { id: 6, name: \"New project\", org: 3, docs: [28, 29, 30], access: \"owners\" },\n    7: { id: 7, name: \"September\", org: 4, docs: [31], access: \"owners\" },\n    8: { id: 8, name: \"Invoice\", org: TEMPLATES_ORG_ID, docs: [], access: \"viewers\" },\n    9: { id: 9, name: \"CRM\", org: TEMPLATES_ORG_ID, docs: [], access: \"viewers\" },\n  };\n\n  private _docs: DocStore = {\n    1: { id: 1, name: \"Doc01\", workspace: 1, access: \"owners\", isPinned: false },\n    2: { id: 2, name: \"Doc02\", workspace: 1, access: \"owners\", isPinned: false },\n    3: { id: 3, name: \"Doc03\", workspace: 1, access: \"owners\", isPinned: false },\n    4: { id: 4, name: \"Doc04\", workspace: 1, access: \"owners\", isPinned: false },\n    5: { id: 5, name: \"Doc05\", workspace: 1, access: \"owners\", isPinned: false },\n    6: { id: 6, name: \"Doc06\", workspace: 1, access: \"owners\", isPinned: false },\n    7: { id: 7, name: \"Doc07\", workspace: 1, access: \"owners\", isPinned: false },\n    8: { id: 8, name: \"Doc08\", workspace: 1, access: \"owners\", isPinned: false },\n    9: { id: 9, name: \"Doc09\", workspace: 1, access: \"viewers\", isPinned: false },\n    10: { id: 10, name: \"Doc10\", workspace: 2, access: \"owners\", isPinned: false, age: 600 },\n    11: { id: 11, name: \"Doc11\", workspace: 2, access: \"owners\", isPinned: false, age: 10 },\n    12: { id: 12, name: \"Doc12\", workspace: 2, access: \"owners\", isPinned: false, age: 100000 },\n    13: { id: 13, name: \"Doc13\", workspace: 2, access: \"owners\", isPinned: true, age: 5400 },\n    14: { id: 14, name: \"Doc14\", workspace: 2, access: \"owners\", isPinned: false, age: 60000000 },\n    15: { id: 15, name: \"Doc15\", workspace: 2, access: \"owners\", isPinned: false },\n    16: { id: 16, name: \"Doc16\", workspace: 2, access: \"owners\", isPinned: false },\n    17: {\n      id: 17, name: \"One doc to rule them all with a long name and a strong fist\",\n      workspace: 2, access: \"owners\", isPinned: true,\n    },\n    18: { id: 18, name: \"Doc18\", workspace: 2, access: \"owners\", isPinned: false },\n    19: { id: 19, name: \"Doc19\", workspace: 2, access: \"owners\", isPinned: false },\n    20: { id: 20, name: \"Doc20\", workspace: 2, access: \"owners\", isPinned: false },\n    21: { id: 21, name: \"Doc21\", workspace: 2, access: \"owners\", isPinned: false },\n    22: { id: 22, name: \"Doc22\", workspace: 3, access: \"owners\", isPinned: true },\n    23: { id: 23, name: \"Doc23\", workspace: 3, access: \"owners\", isPinned: false },\n    24: { id: 24, name: \"Plans\", workspace: 4, access: \"owners\", isPinned: false },\n    25: { id: 25, name: \"Progress\", workspace: 4, access: \"owners\", isPinned: false },\n    26: { id: 26, name: \"Ideas\", workspace: 4, access: \"owners\", isPinned: false },\n    27: { id: 27, name: \"Clients\", workspace: 5, access: \"owners\", isPinned: false },\n    28: { id: 28, name: \"Banking\", workspace: 6, access: \"owners\", isPinned: false },\n    29: { id: 29, name: \"Marketing\", workspace: 6, access: \"owners\", isPinned: false },\n    30: { id: 30, name: \"Money\", workspace: 6, access: \"owners\", isPinned: false },\n    31: { id: 31, name: \"Payroll\", workspace: 7, access: \"owners\", isPinned: false },\n    32: { id: 32, name: \"Timesheet\", workspace: 8, access: \"viewers\", isPinned: false },\n    33: { id: 33, name: \"Expense Report\", workspace: 8, access: \"viewers\", isPinned: false },\n    34: { id: 34, name: \"Lightweight CRM\", workspace: 9, access: \"viewers\", isPinned: true },\n  };\n\n  private _users = new Map<string, FullUser | null>([\n    [\"santa\", { id: 1, email: \"santa@getgrist.com\", name: \"Santa\" }],\n    [\"anon\", { id: 17, email: \"anon@getgrist.com\", name: \"Anonymous\", anonymous: true }],\n    [\"null\", null],\n  ]);\n\n  public async getSessionActive(): Promise<ActiveSessionInfo> {\n    const u = this._users.get(this.activeUser);\n    if (!u) { throw new ApiError(\"No such user\", 403); }\n    const domain = urlState().state.get().org;\n    const orgIndex = domain ? Object.keys(this._orgs).find(i => this._orgs[i].domain === domain) : 1;\n    return {\n      user: u, org: orgIndex ? orgEntryToOrg(this._orgs[orgIndex]) : null,\n      orgError: orgIndex ? undefined : { error: \"inaccessible org\", status: 403 },\n    };\n  }\n\n  public async setSessionActive(email: string): Promise<void> {\n    // This is not an accurate simulation, in that it doesn't keep org-to-user mapping.\n    this.activeUser = email.split(/@/)[0];\n  }\n\n  public async getSessionAll(): Promise<{ users: FullUser[], orgs: Organization[] }> {\n    return { users: [this._users.get(\"santa\")!], orgs: await this.getOrgs() };\n  }\n\n  public async getOrgs(): Promise<Organization[]> {\n    return Object.keys(this._orgs).map(key => orgEntryToOrg(this._orgs[key]));\n  }\n\n  public async getWorkspace(workspaceId: number): Promise<Workspace> {\n    const entry = this._workspaces[workspaceId];\n    const org = await this.getOrg(entry.org);\n    const workspace: Workspace = {\n      id: entry.id,\n      name: entry.name,\n      org,\n      orgDomain: org.domain ?? undefined,\n      docs: [],\n      access: entry.access,\n      createdAt,\n      updatedAt,\n    };\n    workspace.docs = entry.docs.map((docId: number) => ({\n      id: String(docId),\n      name: this._docs[docId].name,\n      workspace,\n      access: this._docs[docId].access,\n      isPinned: this._docs[docId].isPinned,\n      options: this._docs[docId].options,\n      updatedAt: this._docs[docId].age && new Date(Date.now() - this._docs[docId].age! * 1000).toUTCString(),\n    } as Partial<Document> as any));\n    return workspace;\n  }\n\n  public async getOrg(orgId: number): Promise<Organization> {\n    return orgEntryToOrg(this._orgs[orgId]);\n  }\n\n  public async getOrgWorkspaces(orgId: number): Promise<Workspace[]> {\n    const entry = this._orgs[orgId];\n    if (!entry) {\n      throw new Error(`Mock getOrgWorkspaces(${orgId}) failed with 404: not found`);\n    }\n    return Promise.all(entry.workspaces.map(key => this.getWorkspace(key)));\n  }\n\n  public async getOrgUsageSummary(): Promise<OrgUsageSummary> {\n    return createEmptyOrgUsageSummary();\n  }\n\n  public async getTemplates(): Promise<Workspace[]> {\n    return this.getOrgWorkspaces(TEMPLATES_ORG_ID);\n  }\n\n  public async getTemplate(docId: string): Promise<Document> {\n    throw new Error(\"not implemented\");\n  }\n\n  public async getDoc(docId: string): Promise<Document> {\n    return this._docs[docId] as any;\n  }\n\n  public async newOrg(props: any): Promise<number> {\n    const { domain, name } = props;\n    const id = this._nextOrgId;\n    this._orgs[id] = { id, name, domain: domain || null, workspaces: [], access: \"owners\" };\n    this._nextOrgId += 1;\n    return id;\n  }\n\n  public async newWorkspace(props: any, orgId: number): Promise<number> {\n    const { name } = props;\n    const id = this._nextWorkspaceId;\n    this._workspaces[id] = {\n      id,\n      name,\n      org: orgId,\n      docs: [],\n      access: \"owners\",\n    };\n    this._orgs[orgId].workspaces.push(id);\n    this._nextWorkspaceId += 1;\n    return id;\n  }\n\n  public async newDoc(props: any, workspaceId: number): Promise<string> {\n    const { name } = props;\n    const id = this._nextDocId;\n    this._docs[id] = { id, name, workspace: workspaceId, access: \"owners\", isPinned: false };\n    this._workspaces[workspaceId].docs.push(id);\n    this._nextDocId += 1;\n    return String(id);\n  }\n\n  public async newUnsavedDoc(): Promise<string> {\n    return \"new~doc\";\n  }\n\n  public async importUnsavedDoc(material: any, options?: any): Promise<string> {\n    return \"new~doc\";\n  }\n\n  public async importDocToWorkspace(uploadId: number, workspaceId: number): Promise<DocCreationInfo> {\n    throw new Error(\"Mock of importDocToWorkspace not yet implemented\");\n  }\n\n  public async renameOrg(orgId: number, name: string): Promise<void> {\n    this._orgs[orgId].name = name;\n  }\n\n  public async renameWorkspace(workspaceId: number, name: string): Promise<void> {\n    this._workspaces[workspaceId].name = name;\n  }\n\n  public async renameDoc(docId: string, name: string, options?: RenameDocOptions): Promise<void> {\n    this._docs[docId].name = name;\n    if (options) {\n      this._docs[docId].options = { appearance: options };\n    }\n  }\n\n  public async updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void> {\n    if (props.name) { this._docs[docId].name = props.name; }\n  }\n\n  public async updateOrg(ordId: any, props: Partial<OrganizationProperties>): Promise<void> {\n    return Promise.resolve();\n  }\n\n  public async deleteOrg(orgId: number): Promise<void> {\n    for (const workspaceId of this._orgs[orgId].workspaces) {\n      await this.deleteWorkspace(workspaceId);\n    }\n    delete this._orgs[orgId];\n  }\n\n  public async deleteWorkspace(workspaceId: number): Promise<void> {\n    const entry = this._workspaces[workspaceId];\n    for (const docId of entry.docs) {\n      await this.deleteDoc(String(docId));\n    }\n    arrayRemove(this._orgs[entry.org].workspaces, workspaceId);\n    delete this._workspaces[workspaceId];\n  }\n\n  public async softDeleteWorkspace(workspaceId: number): Promise<void> {\n    return this.deleteWorkspace(workspaceId);\n  }\n\n  public async undeleteWorkspace(workspaceId: number): Promise<void> {\n    throw new Error(\"not implemented\");\n  }\n\n  public async deleteDoc(docId: string): Promise<void> {\n    arrayRemove(this._workspaces[this._docs[docId].workspace].docs, parseInt(docId, 10));\n    delete this._docs[docId];\n  }\n\n  public async softDeleteDoc(docId: string): Promise<void> {\n    return this.deleteDoc(docId);\n  }\n\n  public async undeleteDoc(docId: string): Promise<void> {\n    throw new Error(\"not implemented\");\n  }\n\n  public async disableDoc(docId: string): Promise<void> {\n    return this.deleteDoc(docId);\n  }\n\n  public async enableDoc(docId: string): Promise<void> {\n    throw new Error(\"not implemented\");\n  }\n\n  public async updateOrgPermissions(orgId: number, delta: PermissionDelta): Promise<void> {\n    // TODO: Implement as mock\n  }\n\n  public async updateWorkspacePermissions(workspaceId: number, delta: PermissionDelta): Promise<void> {\n    // TODO: Implement as mock\n  }\n\n  public async updateDocPermissions(docId: string, delta: PermissionDelta): Promise<void> {\n    // TODO: Implement as mock\n  }\n\n  public async getOrgAccess(orgId: number): Promise<PermissionData> {\n    // TODO: Implement as mock\n    return {\n      users: [],\n    };\n  }\n\n  public async getWorkspaceAccess(workspaceId: number): Promise<PermissionData> {\n    // TODO: Implement as mock\n    return {\n      maxInheritedRole: null,\n      users: [],\n    };\n  }\n\n  public async getDocAccess(docId: string): Promise<PermissionData> {\n    // TODO: Implement as mock\n    return {\n      maxInheritedRole: null,\n      users: [],\n    };\n  }\n\n  public async pinDoc(docId: string): Promise<void> {\n    this._docs[docId].isPinned = true;\n  }\n\n  public async unpinDoc(docId: string): Promise<void> {\n    this._docs[docId].isPinned = false;\n  }\n\n  public async moveDoc(docId: string, workspaceId: number): Promise<void> {\n    const docIdNum = parseInt(docId, 10);\n    const doc = this._docs[docId];\n    const startWorkspaceDocs = this._workspaces[doc.workspace].docs;\n    const index = startWorkspaceDocs.findIndex(_docId => _docId === docIdNum);\n    startWorkspaceDocs.splice(index, 1);\n    this._workspaces[workspaceId].docs.push(docIdNum);\n  }\n\n  public async getUserProfile(): Promise<FullUser> {\n    const u = this._users.get(this.activeUser);\n    if (!u) { throw new ApiError(\"No such user\", 403); }\n    return u;\n  }\n\n  public async login(): Promise<void> {\n    window.location.href = window.location.origin;\n  }\n\n  public async logout(): Promise<void> {\n    window.location.href = window.location.origin;\n  }\n\n  public async getWorker(): Promise<string> {\n    return \"/\";\n  }\n\n  public async getWorkerFull() {\n    return {\n      selfPrefix: \"/\",\n      docWorkerUrl: null,\n      docWorkerId: null,\n    };\n  }\n\n  public async getWorkerAPI(key: string): Promise<DocWorkerAPI> {\n    return this;\n  }\n\n  public getBillingAPI(): BillingAPI {\n    throw new Error(\"billing api not implemented\");\n  }\n\n  public getDocAPI(): DocAPI {\n    // Return a mock implementation of DocAPI, just adding\n    // methods as needed.\n    const api: Partial<DocAPI> = {\n      async getAttachmentTransferStatus() {\n        return {\n          status: {\n            pendingTransferCount: 0,\n            isRunning: false,\n            failures: 0,\n            successes: 0,\n          },\n          locationSummary: \"internal\",\n        };\n      },\n      getDownloadUrl() {\n        return \"/mock/download/url\";\n      },\n    };\n    return api as DocAPI;\n  }\n\n  public fetchApiKey(): Promise<string> {\n    return Promise.resolve(\"\");\n  }\n\n  public createApiKey(): Promise<string> {\n    const apiKeys = [\n      \"9204c0f1ea5928b31e4e21e55cf975e874281d8e\",\n      \"e03ab513535137a7ec60978b40c9a896db6d8706\"];\n    return Promise.resolve(apiKeys[++keyIndex % 2]);\n  }\n\n  public deleteApiKey(): Promise<void> {\n    return Promise.resolve();\n  }\n\n  public getTable(docId: string, tableName: string): Promise<TableColValues> {\n    // TODO implements as mock\n    return Promise.resolve({ id: [] });\n  }\n\n  public applyUserActions(docId: string, actions: UserAction[]): Promise<ApplyUAResult> {\n    return Promise.resolve({ id: [] }) as any;\n  }\n\n  public async upload(material: any, filename?: string): Promise<number> {\n    return 0;\n  }\n\n  public async downloadDoc(docId: string): Promise<any> {\n    return null;\n  }\n\n  public async copyDoc(docId: string): Promise<any> {\n    return null;\n  }\n\n  public async updateUserName(name: string): Promise<void> {\n    // do nothing\n  }\n\n  public async updateUserLocale(locale: string): Promise<void> {\n    // do nothing\n  }\n\n  public async updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void> {\n    // do nothing\n  }\n\n  public async updateIsConsultant(): Promise<void> {\n    // do nothing\n  }\n\n  public async disableUser(userId: number): Promise<void> {\n    // do nothing\n  }\n\n  public async enableUser(userId: number): Promise<void> {\n    // do nothing\n  }\n\n  public async deleteUser(userId: number, name: string): Promise<void> {\n    // do nothing\n  }\n\n  public getBaseUrl() {\n    return \"http://localhost\";\n  }\n\n  public forRemoved(): UserAPI {\n    throw new Error(\"not implemented\");\n  }\n\n  public getGoogleAuthEndpoint(scope?: string): string {\n    throw new Error(\"not implemented\");\n  }\n\n  public async getWidgets(): Promise<ICustomWidget[]> {\n    return [];\n  }\n\n  public async closeAccount(userId: number): Promise<boolean> {\n    throw new Error(\"Method not implemented.\");\n  }\n\n  public async closeOrg(): Promise<void> {\n    throw new Error(\"Method not implemented.\");\n  }\n\n  public formUrl(): string {\n    return \"\";\n  }\n}\n"
  },
  {
    "path": "test/fixtures/projects/helpers/Pages.ts",
    "content": "import { find, fromTableData, TreeItemRecord, TreeNodeRecord } from \"app/client/models/TreeModel\";\nimport { addTreeView } from \"app/client/ui/TreeViewComponent\";\nimport { buildPageDom } from \"app/client/ui2018/pages\";\nimport { nativeCompare } from \"app/common/gutil\";\n\nimport { Computed, dom, makeTestId, observable, Observable } from \"grainjs\";\n\nconst testId = makeTestId(\"test-pages-\");\n\ninterface TreeRecord {\n  indentation: number;\n  id: number;\n  pagePos: number;\n  name: Observable<string>;\n}\n\nconst sampleData = [\"Interactions:0\", \"People:0\", \"User & Leads:1\", \"Overview:1\", \"Last:0\"];\nlet records = sampleData\n  .map(s => s.split(\":\"))\n  .map((chunks, index) => ({\n    id: index,\n    indentation: Number(chunks[1]),\n    name: observable(chunks[0]),\n    pagePos: index,\n  }));\n\nTreeNodeRecord.prototype.sendActions = async (actions: { update?: TreeRecord[] }) => {\n  if (actions.update?.length) {\n    const map = actions.update.reduce((acc, rec) => (acc[rec.id] = rec, acc), {} as { [id: number]: TreeRecord });\n    records = records.map(rec => map[rec.id] || rec).sort((a, b) => nativeCompare(a.pagePos, b.pagePos));\n    updateModel();\n  }\n};\n\nfunction buildModel() {\n  const table = { getRecords: () => records };\n  return fromTableData(table as any, buildDom);\n}\n\nexport const pagesModel = observable(buildModel());\nexport const selected = observable<TreeItemRecord>(pagesModel.get().children().get()[0]);\n\nfunction updateModel() {\n  pagesModel.set(buildModel());\n}\n\nasync function removePage(page: TreeRecord) {\n  const index = records.indexOf(page);\n  if (index + 1 < records.length) {\n    records[index + 1].indentation = Math.min(records[index + 1].indentation, records[index].indentation);\n  }\n  records.splice(index, 1);\n  updateModel();\n}\n\nexport async function addNewPage() {\n  records.push({\n    id: records.length,\n    name: observable(`New Page${records.length}`),\n    indentation: 0,\n    pagePos: records[records.length - 1].pagePos + 1,\n  });\n  updateModel();\n}\n\nexport function addPages(isOpen: Observable<boolean>) {\n  return addTreeView(pagesModel, { isOpen, selected });\n}\n\nfunction buildDom(id: number) {\n  const page = records.find(rec => rec.id === id)!;\n  const onRename = async (newName: string) => page.name.set(newName);\n  const onRemove = () => removePage(page);\n  const isRemoveDisabled = () => false;\n  const isReadonly = Observable.create(null, false);\n  const onDuplicate = () => null;\n  const isCollapsed = Observable.create(null, false);\n  const onCollapse = () => {};\n  const isCollapsedByDefault = Computed.create(null, () => false);\n  const onCollapseByDefault = async () => {};\n  const hasSubPages = () => false;\n  return buildPageDom(\n    page.name,\n    {\n      onRename,\n      onRemove,\n      isRemoveDisabled,\n      isReadonly,\n      onDuplicate,\n      isCollapsed,\n      onCollapse,\n      isCollapsedByDefault,\n      onCollapseByDefault,\n      hasSubPages,\n      href: \"#\",\n    },\n    testId(\"page\"),\n    dom.onMatch(\".test-docpage-link\", \"click\", () => {\n      const item = find(pagesModel.get(), (i: any) => i.record.id === id);\n      selected.set(item || null);\n    }),\n  );\n}\n"
  },
  {
    "path": "test/fixtures/projects/helpers/ParseOptionsData.ts",
    "content": "/**\n * Includes a fake schema and values for ParseOptions, to Importer fixture.\n */\nimport { ParseOptionValues } from \"app/client/components/ParseOptions\";\nimport { ParseOptionSchema } from \"app/plugin/FileParserAPI\";\n\nexport const initValues: ParseOptionValues = {\n  delimiter: \",\",\n  lineterminator: \"\\n\",\n  doublequote: true,\n  quoting: 1,\n  include_col_names_as_headers: true,\n  excludes: \"11,12,13\",\n  unused: \"UNXPECTED\",\n};\n\nexport const initSchema: ParseOptionSchema[] = [{\n  name: \"delimiter\",\n  type: \"string\",\n  visible: true,\n}, {\n  name: \"lineterminator\",\n  type: \"string\",\n  visible: true,\n}, {\n  name: \"doublequote\",\n  type: \"boolean\",\n  visible: true,\n}, {\n  name: \"quoting\",\n  type: \"number\",\n  visible: true,\n}, {\n  name: \"include_col_names_as_headers\",\n  type: \"boolean\",\n  visible: true,\n}, {\n  name: \"excludes\",\n  type: \"string\",\n  visible: true,\n}, {\n  name: \"unused\",\n  type: \"number\",\n  visible: false,\n}];\n"
  },
  {
    "path": "test/fixtures/projects/helpers/States.ts",
    "content": "export const States = [\n  {\n    label: \"Alabama\",\n    value: \"AL\",\n  },\n  {\n    label: \"Alaska\",\n    value: \"AK\",\n  },\n  {\n    label: \"Arizona\",\n    value: \"AZ\",\n  },\n  {\n    label: \"Arkansas\",\n    value: \"AR\",\n  },\n  {\n    label: \"California\",\n    value: \"CA\",\n  },\n  {\n    label: \"Colorado\",\n    value: \"CO\",\n  },\n  {\n    label: \"Connecticut\",\n    value: \"CT\",\n  },\n  {\n    label: \"Delaware\",\n    value: \"DE\",\n  },\n  {\n    label: \"Florida\",\n    value: \"FL\",\n  },\n  {\n    label: \"Georgia\",\n    value: \"GA\",\n  },\n  {\n    label: \"Hawaii\",\n    value: \"HI\",\n  },\n  {\n    label: \"Idaho\",\n    value: \"ID\",\n  },\n  {\n    label: \"Illinois\",\n    value: \"IL\",\n  },\n  {\n    label: \"Indiana\",\n    value: \"IN\",\n  },\n  {\n    label: \"Iowa\",\n    value: \"IA\",\n  },\n  {\n    label: \"Kansas\",\n    value: \"KS\",\n  },\n  {\n    label: \"Kentucky\",\n    value: \"KY\",\n  },\n  {\n    label: \"Louisiana\",\n    value: \"LA\",\n  },\n  {\n    label: \"Maine\",\n    value: \"ME\",\n  },\n  {\n    label: \"Maryland\",\n    value: \"MD\",\n  },\n  {\n    label: \"Massachusetts\",\n    value: \"MA\",\n  },\n  {\n    label: \"Michigan\",\n    value: \"MI\",\n  },\n  {\n    label: \"Minnesota\",\n    value: \"MN\",\n  },\n  {\n    label: \"Mississippi\",\n    value: \"MS\",\n  },\n  {\n    label: \"Missouri\",\n    value: \"MO\",\n  },\n  {\n    label: \"Montana\",\n    value: \"MT\",\n  },\n  {\n    label: \"Nebraska\",\n    value: \"NE\",\n  },\n  {\n    label: \"Nevada\",\n    value: \"NV\",\n  },\n  {\n    label: \"New Hampshire\",\n    value: \"NH\",\n  },\n  {\n    label: \"New Jersey\",\n    value: \"NJ\",\n  },\n  {\n    label: \"New Mexico\",\n    value: \"NM\",\n  },\n  {\n    label: \"New York\",\n    value: \"NY\",\n  },\n  {\n    label: \"North Carolina\",\n    value: \"NC\",\n  },\n  {\n    label: \"North Dakota\",\n    value: \"ND\",\n  },\n  {\n    label: \"Ohio\",\n    value: \"OH\",\n  },\n  {\n    label: \"Oklahoma\",\n    value: \"OK\",\n  },\n  {\n    label: \"Oregon\",\n    value: \"OR\",\n  },\n  {\n    label: \"Pennsylvania\",\n    value: \"PA\",\n  },\n  {\n    label: \"Rhode Island\",\n    value: \"RI\",\n  },\n  {\n    label: \"South Carolina\",\n    value: \"SC\",\n  },\n  {\n    label: \"South Dakota\",\n    value: \"SD\",\n  },\n  {\n    label: \"Tennessee\",\n    value: \"TN\",\n  },\n  {\n    label: \"Texas\",\n    value: \"TX\",\n  },\n  {\n    label: \"Utah\",\n    value: \"UT\",\n  },\n  {\n    label: \"Vermont\",\n    value: \"VT\",\n  },\n  {\n    label: \"Virginia\",\n    value: \"VA\",\n  },\n  {\n    label: \"Washington\",\n    value: \"WA\",\n  },\n  {\n    label: \"West Virginia\",\n    value: \"WV\",\n  },\n  {\n    label: \"Wisconsin\",\n    value: \"WI\",\n  },\n  {\n    label: \"Wyoming\",\n    value: \"WY\",\n  },\n];\n"
  },
  {
    "path": "test/fixtures/projects/helpers/gristStyles.ts",
    "content": "import { attachCssRootVars } from \"app/client/ui2018/cssVars\";\nimport { attachDefaultLightTheme } from \"app/client/ui2018/theme\";\n\nexport function initGristStyles() {\n  attachCssRootVars(\"grist\");\n  attachDefaultLightTheme();\n}\n"
  },
  {
    "path": "test/fixtures/projects/helpers/widgetPicker.ts",
    "content": "/**\n * This modules contains helpers to generate toy data for the widget picker.\n */\n\nimport { syncedKoArray } from \"app/client/lib/koArray\";\nimport { ColumnRec, TableRec } from \"app/client/models/DocModel\";\n\nimport { observable, toKo } from \"grainjs\";\nimport * as ko from \"knockout\";\nimport range from \"lodash/range\";\n\nfunction table(id: number, name: string) {\n  return { id: ko.observable(id), tableId: ko.observable(name), tableNameDef: ko.observable(name) } as any as TableRec;\n}\n\nlet colCounter = 0;\nfunction column(id: number, name: string, tableRef: number) {\n  return {\n    id: ko.observable(id),\n    label: ko.observable(name),\n    parentId: ko.observable(tableRef),\n    parentPos: ko.observable(++colCounter),\n    isHiddenCol: ko.observable(false),\n  } as any as ColumnRec;\n}\n\nexport const tables = observable([\n  table(0, \"Companies\"),\n  table(1, \"History\"),\n  table(2, \"A table with a very very long name, which include a description\"),\n  ...range(6).map(i => table(3 + i, `Table${i}`)),\n]);\n\nexport const columns = observable([\n  column(0, \"Field\", 0),\n  column(1, \"company_id\", 1),\n  column(2, \"URL\", 1),\n  column(3, \"city\", 1),\n  column(4, \"Long long long column name, because why not\", 2),\n  ...range(10).map(i => column(4 + i, `column`, 3)),\n]);\n\nconst tablesKo = syncedKoArray(toKo(ko, tables));\nconst columnsKo = syncedKoArray(toKo(ko, columns));\n\nexport const gristDocMock: any = {\n  docModel: {\n    visibleTables: tablesKo,\n    columns: {\n      createAllRowsModel: () => columnsKo,\n    },\n  },\n  behavioralPromptsManager: {\n    attachPopup: () => {},\n  },\n};\n"
  },
  {
    "path": "test/fixtures/projects/helpers/withLocale.ts",
    "content": "import { setupLocale } from \"app/client/lib/localization\";\n\n/**\n * Sets up locales needed for translating text in a fixture.\n *\n * Calls `cb` as soon as setup is completed.\n */\nexport async function withLocale(cb: () => void) {\n  await setupLocale();\n  cb();\n}\n"
  },
  {
    "path": "test/fixtures/projects/modals.ts",
    "content": "import { basicButton } from \"app/client/ui2018/buttons\";\nimport { primaryButton } from \"app/client/ui2018/buttons\";\nimport { confirmModal, modal, saveModal, spinnerModal } from \"app/client/ui2018/modals\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, input, makeTestId, styled } from \"grainjs\";\nimport { Computed, Observable, observable } from \"grainjs\";\n\nfunction setupTest() {\n  const confirmed = observable(false);\n  const isOpen = observable(false);\n  const isSaveModalOpen = observable(false);\n  const testId = makeTestId(\"testui-\");\n  const asyncTask = observable<{ resolve: () => void } | null>(null);\n  return cssTestBox(\n    dom(\"h1\", \"Modals\"),\n    dom(\"div\",\n      primaryButton(\"Confirmation modal\",\n        dom.on(\"click\", () => {\n          confirmed.set(false);\n          confirmModal(\"Default modal header\", \"OK\",\n            async () => confirmed.set(true),\n            { explanation: \"Default modal body\" });\n        }),\n        testId(\"confirm-modal-opener\"),\n      ),\n      dom(\"span\", \" Modal \", dom.text(use => use(confirmed) ? \"Confirmed\" : \"Cancelled\"),\n        testId(\"confirm-modal-text\"),\n      ),\n    ),\n    dom(\"div\",\n      basicButton(\"Custom modal\",\n        dom.on(\"click\", () =>\n          modal((ctl) => {\n            isOpen.set(true);\n            return dom(\"div\",\n              // This allows us to ensure that disposers get run when the modal is closed.\n              dom.onDispose(() => isOpen.set(false)),\n              primaryButton(\"Greetings!\", dom.on(\"click\", () => ctl.close()),\n                testId(\"custom-modal-btn\")),\n            );\n          }),\n        ),\n        testId(\"custom-modal-opener\"),\n      ),\n      dom(\"span\", \" Modal is \", dom.text(use => use(isOpen) ? \"Open\" : \"Closed\"),\n        testId(\"custom-modal-text\"),\n      ),\n    ),\n\n    // For saveModal, we check a number of features:\n    // 1. Various elements support arbitrary DomElementArg arguments.\n    // 2. saveDisabled argument is respected.\n    // 3. modalArgs argument is respected.\n    // 4. Save button is disabled while saving.\n    // 5. It waits for saveFunc before closing, and stays open on rejection\n    // 6. Closing disposes owned values.\n    dom(\"div\",\n      primaryButton(\"Save modal\",\n        testId(\"save-modal-opener\"),\n        dom.on(\"click\", () => saveModal((ctl, owner) => {\n          isSaveModalOpen.set(true);\n          const value = Observable.create(owner, \"Hello\");\n          const saving = Observable.create(owner, 0);\n\n          // To test disposal, increment a counter each time this saveModal is disposed.\n          owner.onDispose(() => isSaveModalOpen.set(false));\n\n          // To test saving, fulfill saveFunc() if \"y\" is pressed, reject if \"n\" is pressed.\n          // Also increment \"saving\" observable, so that we can tell while saveFunc() is pending.\n          async function saveFunc() {\n            saving.set(saving.get() + 1);\n            try {\n              await new Promise<void>((resolve, reject) => {\n                const sub = dom.onKeyElem(document.body, \"keydown\", {\n                  y: () => { sub.dispose(); resolve(); },\n                  n: () => { sub.dispose(); reject(new Error(\"fake-error\")); },\n                });\n              });\n            } finally {\n              saving.set(saving.get() - 1);\n            }\n          }\n\n          return {\n            title: dom.text(use => `Title [${use(value)}] (saving=${use(saving)})`),\n            body: [\n              dom(\"span\", \"Some value: \"),\n              input(value, { onInput: true }, testId(\"save-modal-input\")),\n            ],\n            saveLabel: dom.text(use => `Save [${use(value)}]`),\n            // To test saveDisabled, disable the button if the value is empty.\n            saveDisabled: Computed.create(owner, use => !use(value)),\n            saveFunc,\n            // To test modalArgs, change opacity when value is the text \"translucent\"\n            modalArgs: dom.style(\"opacity\", use => (use(value) === \"translucent\" ? \"0.5\" : \"\")),\n          };\n        })),\n      ),\n      dom(\"span\", \" Modal \", dom.text(use => use(isSaveModalOpen) ? \"Open\" : \"Closed\"),\n        testId(\"save-modal-is-open\"),\n      ),\n    ),\n\n    dom(\n      \"div\",\n      primaryButton(\n        \"Spinner modal\",\n        testId(\"spinner-modal-opener\"),\n        dom.on(\"click\", async () => {\n          const promise = new Promise<void>(resolve => asyncTask.set({ resolve }));\n          await spinnerModal(\"Spinner Modal\", promise);\n          document.body.appendChild(\n            dom(\"div\", \"After spinner\", testId(\"after-spinner\")),\n          );\n        }),\n        testId(\"spinner-modal-opener\"),\n      ),\n      dom.maybe(asyncTask, ({ resolve }) => cssResolve(\n        \"Async Taks\",\n        dom(\"button\", \"Resolve\",\n          dom.on(\"click\", () => { resolve(); asyncTask.set(null); }),\n          testId(\"resolve-spinner-task\"),\n        ),\n      )),\n    ),\n  );\n}\n\nconst cssTestBox = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  & > div { margin: 8px; }\n                          `);\n\nconst cssResolve = styled(\"div\", `\n  float: right;\n  position: relative;\n  z-index: 1000;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/mouseDrag.ts",
    "content": "import { mouseDrag } from \"app/client/ui/mouseDrag\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, makeTestId, observable, styled } from \"grainjs\";\nimport pick from \"lodash/pick\";\n\nconst testId = makeTestId(\"test-\");\n\nfunction desc(ev: MouseEvent) {\n  return pick(ev, [\"pageX\", \"pageY\"]);\n}\n\nfunction setupTest() {\n  const status = observable<any>({ status: \"not-dragging\" });\n  let events = \"\";\n\n  function onStart(startEv: MouseEvent, el: Element) {\n    events = \"s\";\n    status.set({ status: \"started\", start: desc(startEv), events });\n    return {\n      onMove(moveEv: MouseEvent) {\n        events += \"m\";\n        status.set({ status: \"moved\", start: desc(startEv), move: desc(moveEv), events });\n      },\n      onStop(stopEv: MouseEvent) {\n        events += \"S\";\n        status.set({ status: \"stopped\", start: desc(startEv), stop: desc(stopEv), events });\n      },\n    };\n  }\n\n  function reset() {\n    events = \"\";\n    status.set({ status: \"not-dragging\" });\n  }\n\n  return [\n    testBox(mouseDrag(onStart),\n      testId(\"box\"),\n      dom.style(\"background-color\", \"#A0A0FF\")),\n    testBox(\n      { style: \"left: 350px; width: 400px\" },\n      result(\n        testId(\"result\"),\n        dom.text(use => JSON.stringify(use(status), null, 2)),\n      ),\n    ),\n    dom(\"button\", \"Reset\", dom.on(\"click\", reset),\n      { style: \"position: absolute; left: 350px; top: 280px;\" },\n    ),\n  ];\n}\n\nconst testBox = styled(\"div\", `\n  position: absolute;\n  top: 50px;\n  left: 50px;\n  width: 250px;\n  height: 200px;\n  font-family: sans-serif;\n  font-size: 1rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n`);\n\nconst result = styled(\"pre\", `\n  margin: 0px;\n  padding: 0px;\n  height: 100%;\n  width: 100%;\n`);\n\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/resizeHandle.ts",
    "content": "import { resizeFlexVHandle } from \"app/client/ui/resizeHandle\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, observable, styled } from \"grainjs\";\n\nfunction setupTest() {\n  const width1 = observable<number | null>(null);\n  const width2 = observable<number | null>(null);\n  return [\n    testBox(\n      dom(\"div.test-left\",\n        dom.style(\"width\", use => use(width1) ? use(width1) + \"px\" : \"\"),\n        dom.text(use => `width ${use(width1)}`),\n      ),\n      myResizeFlexVHandle({ target: \"left\", onSave: v => width1.set(v) }),\n      dom(\"div\", { style: \"flex: auto\" }),\n      myResizeFlexVHandle({ target: \"right\", onSave: v => width2.set(v) }),\n      dom(\"div.test-right\",\n        dom.style(\"width\", use => use(width2) ? use(width2) + \"px\" : \"\"),\n        dom.text(use => `width ${use(width2)}`),\n      ),\n    ),\n    dom(\"button.test-reset\", { style: \"margin-left: 100px; margin-top: 25px\" },\n      \"Reset\", dom.on(\"click\", () => { width1.set(null); width2.set(null); })),\n  ];\n}\n\nconst myResizeFlexVHandle = styled(resizeFlexVHandle, `\n  --resize-handle-color: lightblue;\n  --resize-handle-highlight: red;\n`);\n\nconst testBox = styled(\"div\", `\n  position: relative;\n  display: flex;\n  margin-top: 50px;\n  margin-left: 100px;\n  width: 600px;\n  height: 200px;\n  font-family: sans-serif;\n  font-size: 1rem;\n  text-align: center;\n  & > .test-left, & > .test-right {\n    position: relative;\n    flex: none;\n    width: 150px;\n    min-width: 50px;\n    max-width: 275px;\n    background-color: #E0FFE0\n  }\n`);\n\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/searchDropdown.ts",
    "content": "import { dropdownWithSearch } from \"app/client/ui/searchDropdown\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, styled } from \"grainjs\";\n\nconst options = [\n  \"Foo\", \"Bar\",\n  \"Fusion\", \"Maya\", \"Santa\", \"Alice\", \"Bob\", \"Sam\", \"Clara\", \"Tarzan\",\n  \"Apple\", \"Microsoft\", \"Bill Gates\", \"Elon Musk\", \"Klimt\", \"Goran\", \"Vengo\",\n  \"Bach\", \"Otello\", \"Romeo\", \"Juliet\", \"Grease\", \"Stencil\", \"Yahoo\", \"AOL\", \"Bing\", \"Google\",\n  \"Meta\", \"Metaverse\", \"Zoro\", \"Atom\", \"Tesla\", \"Lenovo\",\n  \"A very very long even longer than that nameeeeeee \" +\n  \"A very very long even longer than that nameeeeeee\",\n];\n\nfunction setupTest() {\n  const logElem = dom(\"div\");\n\n  const addDropdown = () => dropdownWithSearch<string>({\n    options: () => options,\n    action: val => logElem.appendChild(cssLogEntry(`click: ${val}`, testId(\"logs\"))),\n  });\n\n  const resetBtn = () => dom(\"button\", dom.on(\"click\", () => {\n    while (logElem.firstChild) { logElem.firstChild.remove(); }\n  }), \"Reset\", { style: \"width: 50px\" }, testId(\"reset\"));\n\n  return cssTestBox(\n\n    cssExample(\n      \"searchableDropdown with a plain button\",\n      dom(\n        \"button\", \"Add column\",\n        addDropdown(),\n      ),\n    ),\n\n    dom(\"h3\", \"Logs: \"), resetBtn(),\n    logElem,\n  );\n}\n\nconst cssTestBox = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  margin: 40px;\n  max-width: 600px;\n`);\n\nconst cssLogEntry = styled(\"p\", `\n  color: red;\n`);\n\nconst cssExample = styled(\"div\", `\n  margin: 16px;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/sessionObs.ts",
    "content": "import { createSessionObs, isBoolean, isNumber } from \"app/client/lib/sessionObs\";\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { StringUnion } from \"app/common/StringUnion\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, makeTestId, MultiHolder, Observable, styled } from \"grainjs\";\n\nconst testId = makeTestId(\"test-\");\n\nconst FruitType = StringUnion(\"apples\", \"oranges\", \"melons\");\n\nfunction setupTest(owner: MultiHolder) {\n  const plainObs = Observable.create(owner, \"Hello\");   // Plain old observable, for comparison\n  const boolObs = createSessionObs(owner, \"boolObs\", true, isBoolean);\n  const numObs = createSessionObs(owner, \"numObs\", 100, isNumber);\n  const fruitObs = createSessionObs(owner, \"fruitObs\", \"apples\", FruitType.guard);\n\n  const inputs: HTMLInputElement[] = [];\n  return [\n    testBox(\n      cssRow(dom(\"div\", \"plainObs\"), dom(\"div\", dom.text(plainObs)),\n        inputs[0] = dom(\"input\", { value: plainObs.get() }, testId(\"plain-obs\"))),\n\n      cssRow(dom(\"div\", \"boolObs\"), dom(\"div\", dom.text(use => JSON.stringify(use(boolObs)))),\n        inputs[1] = dom(\"input\", { value: JSON.stringify(boolObs.get()) }, testId(\"bool-obs\"))),\n\n      cssRow(dom(\"div\", \"numObs\"), dom(\"div\", dom.text(use => JSON.stringify(use(numObs)))),\n        inputs[2] = dom(\"input\", { value: JSON.stringify(numObs.get()) }, testId(\"num-obs\"))),\n\n      cssRow(dom(\"div\", \"fruitObs\"), dom(\"div\", dom.text(fruitObs)),\n        inputs[3] = dom(\"input\", { value: fruitObs.get() }, testId(\"fruit-obs\"))),\n\n      cssRow(dom(\"button\", \"Save\", testId(\"save\"), dom.on(\"click\", () => {\n        plainObs.set(inputs[0].value);\n        boolObs.set(safeJsonParse(inputs[1].value, \"invalid\"));\n        numObs.set(safeJsonParse(inputs[2].value, \"invalid\"));\n        fruitObs.set(inputs[3].value as typeof FruitType.type);\n      }))),\n    ),\n  ];\n}\n\nconst testBox = styled(\"div\", `\n  position: relative;\n  width: 25rem;\n  font-family: sans-serif;\n  font-size: 1rem;\n  box-shadow: 1px 1px 4px 2px #AAA;\n  padding: 1rem;\n  margin: 1rem;\n`);\n\nconst cssRow = styled(\"div\", `\n  margin: 1rem;\n  display: flex;\n  & > div { width: 10rem; }\n`);\n\nvoid withLocale(() => dom.update(document.body, dom.create(setupTest)));\n"
  },
  {
    "path": "test/fixtures/projects/simpleList.ts",
    "content": "import { popupControl } from \"app/client/lib/popupControl\";\nimport { SimpleList } from \"app/client/lib/simpleList\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, obsArray, observable, styled } from \"grainjs\";\nimport { PopupControl } from \"popweasel\";\n\nfunction setupTest() {\n  const items = observable([\"foo\", \"bar\"]);\n  const logs = obsArray<string>([]);\n  let popup: PopupControl;\n  return cssTestBox(\n    dom(\"div\", \"click to show options\"),\n    cssInput(\n      (elem) => {\n        popup = popupControl(\n          elem,\n          (ctl) => {\n            const list = (SimpleList<string>).create(null, ctl, items, val => logs.push(val));\n            list.listenKeys(elem);\n            return list.content;\n          },\n          { placement: \"right-start\" },\n        );\n      },\n      dom.on(\"click\", () => popup.toggle()),\n    ),\n    dom(\"h1\", \"LOGS\"),\n    dom(\n      \"div\",\n      testId(\"logs\"),\n      dom.forEach(logs, log => cssLog(log)),\n    ),\n  );\n}\nconst cssTestBox = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  margin: 40px;\n  max-width: 600px;\n`);\nconst cssLog = styled(\"div\", `color: red;`);\nconst cssInput = styled(\"input\", `\n  width: 300px;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/template.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"utf8\">\n\n    <link rel=\"stylesheet\" href=\"jqueryui/themes/smoothness/jquery-ui.css\">\n    <link rel=\"stylesheet\" href=\"bootstrap-datepicker/dist/css/bootstrap-datepicker3.css\">\n\n    <link rel=\"stylesheet\" href=\"icons.css\">\n\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">\n    <base href=\"/\">\n  </head>\n  <body>\n    <script src=\"jquery/dist/jquery.min.js\" crossorigin=\"anonymous\"></script>\n    <script src=\"jqueryui/jquery-ui.min.js\" crossorigin=\"anonymous\"></script>\n    <script src=\"bootstrap-datepicker/dist/js/bootstrap-datepicker.js\" crossorigin=\"anonymous\"></script>\n    <script src='build/<NAME>.bundle.js'></script>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/projects/tokenfield.ts",
    "content": "import { ACIndexImpl, ACItem } from \"app/client/lib/ACIndex\";\nimport { buildHighlightedDom } from \"app/client/lib/ACIndex\";\nimport { IAutocompleteOptions } from \"app/client/lib/autocomplete\";\nimport { IToken, TokenField } from \"app/client/lib/TokenField\";\nimport { colors, testId } from \"app/client/ui2018/cssVars\";\nimport { menuCssClass } from \"app/client/ui2018/menus\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, styled } from \"grainjs\";\n\n/**\n * Tokenfield wishes (\"+\" means our implementation supports it).\n *\n * + Click on option to select\n * + Click on \"x\" to delete\n * + Shift+Click to extend selection (see how exactly others do it)\n * + Ctrl+Click to add to selection\n * + Cmd+A = select all options\n * + If no selection, backspace deletes last item. (What about delete?)\n * + Delete/Backspace delete selection\n * + Navigate selected token using arrow keys\n * + Shift+Up/Down | Shift+Left/Right (depending on orientation) to extend selection\n * + Cmd+C copies selection to clipboard, as comma-separated text, and a JSON blob with extra info\n * + Cmd+V pastes options from clipboard\n * + Cmd+X copies and deletes\n * + With textinput focused, copy+pasting should work fine.\n * + Copy-paste text format should CSV-encode when copying, CSV-decode when pasting.\n * - On pasting, should recognize and decode JSON.\n * + Horizontal: allow dragging. If selection, drag whole selection.\n * - Vertical: allow dragging.\n * + Support undo/redo for input + tokens (on best-effort basis, e.g. as gmail does).\n */\n\n// LESSONS LEARNED FROM VARIOUS APPROACHES.\n//\n// (1) Relying on ContentEditable for editing. This browser feature is poor and underspecified.\n// Deleting text leaves empty nodes, and sometimes extends them (deletes closing tag or opening),\n// sometimes inserts <br>, etc. That's just in one browser. Expecting any consistent behavior\n// across browsers is unrealistic.\n\n// (2) Supporting undo/redo by tying it to its implementation in a text input, e.g. interpreting\n// ranges of text (like \"[key]\") or individual characters as representing tokens. This doesn't\n// work: changing the input's value programmatically breaks undo/redo (at least on FF). Changing\n// the input's value using execCommand preserves undo/redo, but only works on FF for\n// contentEditable which is deprecated and unreliable.\n//\n// (3) Gmail handles undo/redo, but without using browser's stack. Seems to just listen to\n// keyboard events. That's what we do too.\n\nclass Item implements ACItem, IToken {\n  public cleanText: string;\n\n  constructor(\n    public label: string,\n    public numId: number,\n  ) {\n    this.cleanText = label.toLowerCase().trim();\n  }\n\n  public value(): string {\n    return `${this.label}=${this.numId}`;\n  }\n}\n\nfunction setupTest() {\n  const items: Item[] = [\n    new Item(\"Cat\", 10),\n    new Item(\"Dog\", 20),\n    new Item(\"Parakeet\", 30),\n    new Item(\"Frog\", 40),\n    new Item(\"Golden Monkey\", 50),\n  ];\n  const acIndex = new ACIndexImpl<Item>(items);\n  const acOptions: IAutocompleteOptions<Item> = {\n    menuCssClass: menuCssClass + \" test-autocomplete\",\n    search: async (term: string) => acIndex.search(term),\n    renderItem: (item: Item, highlightFunc) =>\n      cssItem(buildHighlightedDom(item.label, highlightFunc, cssMatchText)),\n    getItemText: (item: Item) => item.label,\n  };\n\n  const initialValue: Item[] = [items[0], items[3]];    // Cat, Frog\n  const createToken = (label: string) => new Item(label, 0);\n  const create2 = (label: string) => {\n    const res = acIndex.search(label);\n    return res.selectIndex >= 0 ? res.items[res.selectIndex] : undefined;\n  };\n\n  const renderToken = (item: IToken) => item.label;\n  const tokenFieldPlain = TokenField.create(null, { initialValue, createToken, renderToken });\n  const tokenFieldAC = TokenField.create(null, { initialValue, createToken: create2, acOptions, renderToken });\n\n  return cssTestBox(\n    cssExample(\n      \"TokenField with plain input\",\n      elem => tokenFieldPlain.attach(elem),\n      cssValue(\n        dom.text(use => JSON.stringify(\n          (use(tokenFieldPlain.tokensObs) as Item[])\n            .map((t: Item) => t.value()),\n        )),\n        testId(\"json-value\"),\n      ),\n      testId(\"tokenfield-plain\"),\n    ),\n    cssExample(\n      \"TokenField with autocomplete\",\n      elem => tokenFieldAC.attach(elem),\n      cssValue(\n        dom.text(use => JSON.stringify(\n          (use(tokenFieldAC.tokensObs) as Item[])\n            .map((t: Item) => t.value()),\n        )),\n        testId(\"json-value\"),\n      ),\n      testId(\"tokenfield-ac\"),\n    ),\n    cssTextArea({ placeholder: \"Copy-paste testing area\", rows: \"3\" },\n      testId(\"copypaste\"),\n    ),\n  );\n}\n\nconst cssTestBox = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  margin: 40px;\n  max-width: 600px;\n`);\n\nconst cssItem = styled(\"li\", `\n  display: block;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  outline: none;\n  padding: var(--weaseljs-menu-item-padding, 8px 24px);\n  cursor: pointer;\n\n  &.selected {\n    background-color: var(--weaseljs-selected-background-color, #5AC09C);\n    color: var(--weaseljs-selected-color, white);\n  }\n`);\n\nconst cssMatchText = styled(\"span\", `\n  color: ${colors.lightGreen};\n  .selected > & {\n    color: ${colors.lighterGreen};\n  }\n`);\n\nconst cssExample = styled(\"div\", `\n  margin: 16px;\n`);\n\nconst cssValue = styled(\"div\", `\n  color: blue;\n  margin: 8px 0;\n`);\n\nconst cssTextArea = styled(\"textarea\", `\n  margin: 16px;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/tooltips.ts",
    "content": "import { descriptionInfoTooltip, hoverTooltip, tooltipCloseButton, withInfoTooltip } from \"app/client/ui/tooltips\";\nimport { testId } from \"app/client/ui2018/cssVars\";\nimport { initGristStyles } from \"test/fixtures/projects/helpers/gristStyles\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, observable, styled } from \"grainjs\";\n\nfunction setupTest() {\n  const showTriggerObs = observable(true);\n  return cssTestBox(\n    cssTrigger(\"Plain hover\", hoverTooltip(\"Tooltip1\"),\n      testId(\"plain\"),\n    ),\n    cssTrigger(\"Click and Expire\", hoverTooltip(() => \"Tooltip2\", { openOnClick: true, timeoutMs: 1000 }),\n      testId(\"fancy\"),\n    ),\n    cssTrigger(\"Closable\", hoverTooltip(ctl => cssTip(\"Tooltip3\", tooltipCloseButton(ctl))),\n      testId(\"closable\"),\n    ),\n    cssRow(\n      dom.maybe(showTriggerObs, () => (\n        cssTrigger(\"Close on disposed\", hoverTooltip(\"Tooltip6\", { closeDelay: 2000, openDelay: 500 }),\n          dom(\"button\", dom.on(\"click\", () => showTriggerObs.set(false)), \"Hide\"),\n          testId(\"dispose\"))\n      )),\n      cssLabel(\n        dom(\n          \"input\",\n          { type: \"checkbox\" }, dom.prop(\"checked\", showTriggerObs),\n          dom.on(\"change\", (_ev, elem) => showTriggerObs.set(elem.checked)),\n        ),\n        \"Show trigger\",\n      ),\n    ),\n    cssRow(\n      cssTrigger(\"Added to a key\", hoverTooltip(\"Tooltip4\", { key: \"key\" }),\n        testId(\"with-key\")),\n      cssTrigger(\"Added to same key\", hoverTooltip(\"Tooltip5\", { key: \"key\" }),\n        testId(\"with-same-key\")),\n    ),\n    withInfoTooltip(cssTrigger(\"Info (Click)\"), \"selectBy\", { domArgs: [testId(\"info-click\")] }),\n    withInfoTooltip(cssTrigger(\"Info (Hover)\"), \"uuid\", {\n      variant: \"hover\",\n      domArgs: [testId(\"info-hover\")],\n    }),\n    cssTrigger(\"Close on Click\", hoverTooltip(\"Tooltip9\", { closeOnClick: true }),\n      testId(\"close-on-click\"),\n    ),\n    cssTrigger(\"Info tooltip\",\n      (el) => {\n        return descriptionInfoTooltip(\n          \"Multi line text\\nAnd a https://link.to/page.html?with=filter in it\",\n          \"prefix\");\n      },\n      testId(\"visible\"),\n    ),\n    cssTrigger(\"None\", testId(\"none\")),\n  );\n}\n\nconst cssTestBox = styled(\"div\", `\n  display: flex;\n  flex-direction: column;\n  flex-wrap: wrap;\n  margin: 40px;\n  max-height: 90vh;\n`);\n\nconst cssTrigger = styled(\"div\", `\n  background-color: lightgrey;\n  border-radius: 4px;\n  width: 200px;\n  padding: 8px 16px;\n  margin: 16px;\n`);\n\nconst cssTip = styled(\"div\", `\n  display: flex;\n  align-items: center;\n  gap: 8px;\n`);\n\nconst cssRow = styled(\"div\", `\n  display: flex;\n  flex-direction: row;\n  height: 70px;\n`);\n\nconst cssLabel = styled(\"label\", `\n  align-self: center;\n`);\n\ninitGristStyles();\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/transitions.ts",
    "content": "import { transition } from \"app/client/ui/transitions\";\nimport { withLocale } from \"test/fixtures/projects/helpers/withLocale\";\n\nimport { dom, input, Observable, styled } from \"grainjs\";\n\nfunction setupTest() {\n  const toggle = Observable.create(null, false);\n  const duration = Observable.create(null, \"1s\");\n  const finishCount = Observable.create(null, 0);\n  return [\n    testBox(\n      dom(\"div.test-left\",\n        dom.style(\"transition-duration\", duration),\n        transition(toggle, {\n          prepare(elem, val) { elem.style.opacity = \"0\"; },\n          run(elem, val) { elem.style.opacity = \"\"; },\n          finish(elem, val) { finishCount.set(finishCount.get() + 1); },\n        }),\n        dom.cls(\"expanded\", toggle),\n      ),\n      dom(\"div.test-right\"),\n    ),\n    dom(\"div\", { style: \"margin-left: 100px; margin-top: 25px\" },\n      dom(\"button.test-toggle\", \"Toggle\", dom.on(\"click\", () => { toggle.set(!toggle.get()); })),\n      \"Transition time (ms): \",\n      input(duration, {}, dom.cls(\"test-duration\")),\n      dom.text(duration),\n    ),\n    dom(\"div\", { style: \"margin-left: 100px; margin-top: 25px\" },\n      \" Number of finished transitions: \",\n      dom(\"span.test-finished\", dom.text(use => \"\" + use(finishCount))),\n    ),\n  ];\n}\n\nconst testBox = styled(\"div\", `\n  position: relative;\n  display: flex;\n  margin-top: 50px;\n  margin-left: 100px;\n  width: 500px;\n  height: 150px;\n  font-family: sans-serif;\n  font-size: 1rem;\n  text-align: center;\n  & > .test-left {\n    background-color: #80FF80;\n    width: 30px;\n    transition: width 1s linear, opacity 1s linear;\n  }\n  & > .test-right {\n    background-color: #FF8080;\n    flex: 1 1 0px;\n    min-width: 0px;\n  }\n  & > .test-left.expanded {\n    width: 470px;\n  }\n`);\n\nvoid withLocale(() => dom.update(document.body, setupTest()));\n"
  },
  {
    "path": "test/fixtures/projects/webpack-test-server.ts",
    "content": "/**\n * webpack-test-server makes possible browser tests against static fixture pages that are set up\n * to be served using webpack-dev-server with test/fixtures/projects/webpack.config.json.\n *\n * Use in a mocha test like so:\n *\n *    import {driver, useServer} from 'mocha-webdriver';\n *    import {server} from 'test/fixtures/projects/webpack-test-server';\n *    describe(..., () => {\n *      useServer(server);\n *      ...\n *      it(..., () => {\n *        await driver.get(`${server.getHost()}/MyPage`);\n *      })\n *    });\n *\n * It will start up webpack-dev-server before this suite is run, so that MyPage is available to\n * fetch using webdriver.\n */\nimport { exitPromise } from \"app/server/lib/serverUtils\";\n\nimport { ChildProcess, spawn } from \"child_process\";\nimport * as path from \"path\";\n\nimport { driver, IMochaContext, IMochaServer } from \"mocha-webdriver\";\nimport fetch from \"node-fetch\";\nimport stripAnsi from \"strip-ansi\";\n\nconst configPath = process.env.PROJECTS_WEBPACK_CONFIG || path.resolve(__dirname, \"webpack.config.js\");\n\nexport class WebpackServer implements IMochaServer {\n  // Fork a WebpackDevServer. See https://github.com/webpack/docs/wiki/webpack-dev-server\n  //\n  // It's possible to start WebpackDevServer within this same Node process, but we intentionally\n  // fork a separate one to ensure that modifications to low-level modules that might be done by\n  // test dependencies (e.g. cleverness for monitoring promises, or filesystem), do not affect\n  // webpack. When in the same process, it seems such things happen and cause major slowdown.\n\n  private _serverUrl: string;\n  private _server: ChildProcess;\n  private _exitPromise: Promise<number | string>;\n  private _webpackComplete: Promise<boolean>;\n\n  public async start(context: IMochaContext) {\n    context.timeout(60000);\n    logMessage(`starting with config ${configPath}`);\n\n    this._server = spawn(\"node\",\n      [\"node_modules/.bin/webpack-dev-server\", \"--config\", configPath, \"--no-open\"], {\n        stdio: [\"inherit\", \"pipe\", \"inherit\"],\n      });\n    this._exitPromise = exitPromise(this._server);\n\n    // Wait for a build status to show up on stdout, to know when webpack is finished.\n    this._webpackComplete = new Promise((resolve, reject) => {\n      let buffer = \"\";\n      this._server.stdout!.on(\"data\", (data) => {\n        buffer += data.toString(\"utf8\");\n        const clean = stripAnsi(buffer);\n        if (/compiled.*with.*errors/i.test(clean)) {\n          reject(new Error(\"Webpack failed\"));\n        }\n        if (/compiled.*successfully/i.test(clean)) {\n          resolve(true);\n        }\n      });\n    });\n\n    // eslint-disable-next-line @typescript-eslint/no-require-imports\n    const config = require(configPath);\n    const port = config.devServer.port;\n    this._serverUrl = `http://localhost:${port}`;\n\n    this._exitPromise\n      .then(() => (this._server.killed || logMessage(\"webpack-dev-server died unexpectedly\")))\n      .catch(() => undefined);\n    await this.waitServerReady(15000);\n    logMessage(\"webpack finished compiling\");\n  }\n\n  /**\n   * Returns whether the server is up and responsive.\n   */\n  public async isServerReady(): Promise<boolean> {\n    try {\n      return (await fetch(this._serverUrl, { timeout: 1000 })).ok;\n    } catch (err) {\n      return false;\n    }\n  }\n\n  /**\n   * Wait for the server to be up and responsitve, for up to `ms` milliseconds.\n   */\n  public async waitServerReady(ms: number): Promise<void> {\n    await driver.wait(() => Promise.race([\n      this._webpackComplete,\n      this._exitPromise.then((code) => {\n        throw new Error(`WebpackDevServer exited while waiting for it (exit status ${code})`);\n      }),\n    ]), ms);\n  }\n\n  public async stop() {\n    logMessage(\"stopping\");\n    this._server.kill();\n    await this._exitPromise;\n  }\n\n  public getHost(): string {\n    return this._serverUrl;\n  }\n}\n\nfunction logMessage(msg: string) {\n  console.error(\"[webpack-test-server] \" + msg);\n}\n\nexport const server = new WebpackServer();\n"
  },
  {
    "path": "test/fixtures/projects/webpack.config.js",
    "content": "/**\n * Test and develop a widget by running the following at the root of the git checkout:\n *\n *    bin/webpack-dev-server --config test/fixtures/projects/webpack.config.js\n *\n * To open a browser other than Chrome by default, prefix with OPEN_BROWSER=Firefox.\n * To avoid opening any browser, add --no-open flag.\n *\n * It will build and serve the demo code with live-reload at\n *\n *    http://localhost:9000/\n */\n\n\nconst fs = require(\"fs\");\nconst glob = require(\"glob\");\nconst path = require(\"path\");\nconst { ProvidePlugin } = require(\"webpack\");\n\n// Build each *.ts[x] project as its own bundle.\nconst entries = {};\nfor (const fixture of glob.sync(`test/fixtures/projects/*.{js,ts}`)) {\n  const name = path.basename(fixture, path.extname(fixture));\n  if (name.startsWith(\"webpack\")) { continue; }\n  entries[name] = fixture;\n}\n\n// Generic trivial html template for all projects.\nconst htmlTemplate = fs.readFileSync(`test/fixtures/projects/template.html`, \"utf8\");\n\nmodule.exports = {\n  mode: \"development\",\n  entry: entries,\n  output: {\n    path: path.resolve(__dirname),\n    filename: \"build/[name].bundle.js\",\n    // Distinguish auto-generated chunks from top-level bundles, so that\n    // buildtools/publish_test_projects doesn't treat them as stand-alone projects.\n    chunkFilename: \"build/[name].chunk.js\",\n    // credit to: https://github.com/webpack/webpack/issues/9732#issuecomment-555461786\n    sourceMapFilename: \"build/[file].map[query]\",\n  },\n  devtool: \"source-map\",\n  resolve: {\n    extensions: [\".ts\", \".js\"],\n    modules: [\n      path.resolve(\".\"),\n      path.resolve(\"./node_modules\"),\n      path.resolve(\"./stubs\"),\n      path.resolve(\"./ext\"),\n    ],\n    fallback: {\n      \"path\": require.resolve(\"path-browserify\"),\n    },\n  },\n  module: {\n    rules: [\n      {\n        test: /\\.(js|ts)?$/,\n        loader: \"esbuild-loader\",\n        options: {\n          loader: \"ts\",\n          target: \"es2017\",\n          sourcemap: true,\n        },\n        exclude: /node_modules/\n      },\n      { test: /\\.js$/,\n        use: [\"source-map-loader\"],\n        enforce: \"pre\"\n      }\n    ]\n  },\n  devServer: {\n    static: [\n      {\n        directory: \"./test/fixtures/projects\",\n      },\n      {\n        directory: \"./bower_components\",\n      },\n      {\n        directory: \"./static/locales\",\n        publicPath: \"/locales\",\n      },\n    ],\n    port: parseInt(process.env.PORT || \"8900\", 10),\n    open: process.env.OPEN_BROWSER || \"Google Chrome\",\n\n    // Serve a trivial little index page with a directory, and a template for each project.\n    setupMiddlewares: (middlewares, devServer) => {\n      // app is an express app; we get a chance to add custom endpoints to it.\n      devServer.app.get(\"/\", (req, res) =>\n        res.send(Object.keys(entries).map((e) => `<a href=\"${e}\">${e}</a><br>\\n`).join(\"\")));\n      devServer.app.get(Object.keys(entries).map((e) => `/${e}`), (req, res) => {\n        return res.send(htmlTemplate.replace(\"<NAME>\", path.basename(req.url.split(\"?\")[0])));\n      });\n      return middlewares;\n    },\n  },\n  plugins: [\n    // Some modules assume presence of Buffer and process.\n    new ProvidePlugin({\n      process: \"process\",\n      Buffer: [\"buffer\", \"Buffer\"]\n    })\n  ],\n  externals: {\n    // silence webpack when it's looking for jquery. It's available when it's needed.\n    jquery: \"jQuery\"\n  }\n};\n"
  },
  {
    "path": "test/fixtures/saml/keycloak.pem",
    "content": "-----BEGIN CERTIFICATE-----\nMIICmTCCAYECBgGVAWQW1TANBgkqhkiG9w0BAQsFADAQMQ4wDAYDVQQDDAVncmlzdDAeFw0yNTAyMTMyMjE2MzFaFw0zNTAyMTMyMjE4MTFaMBAxDjAMBgNVBAMMBWdyaXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAri3nbt/a7IooUk7Y7qJvkpdAoNrb8/kgUWj3rsgP8TUn050h4jn5SOA2Lq2hKrfyuCaVb6XHU9JvFK/GSNlxoCkQMy6nNQNaVS/Bc82HCFozEKhdPAvTvultyG8E+xwZDvE5o4pMi54qTED2J1zxtjj1bwuVArQyCoA53CossIYrzyoAaVKr4OWv5iA19DoNRHWWXmnB3nyNzZMt+8RuW5IME2FImxB+PI77+usPTbOmmqcH+s3UeEQZX3RdwKJBCP3gA9DJhFesL4kPBf8k/TXgNh7u0L/wrQ2bU/U1cx4MMZ/p2kq63X/F5PjbkU88F0X3mKM2cL8aiOFtVh583wIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCT5Ey7VjJTJ+7aROCWrcUfmapHVVaH90X8rFToH7FB1LK1cbcrkMRlsDt9u6ba9JsfTN5Vxl7Wo6wYclSMZ2e9hNXeFbOjm32+oSJ3ZAwVgWlW+ryz8Zk98RZRc89TfJzn+aiBj0OuzsVHdddD0G4XYgBuSc4byrnkguQr2dvCenwzdWVc8CIHN8tlZCNr4XFZXtmEIbsuvuOphHS08xo+uEi9Y60msVweaNi76R+bQD6LNlvjfKcAu/ihWHMuN/q+SiIk64EOj2p3iaZKOGv0IeVMPwoYKUrBV0zPUfudWgUmJQ2Z3Ca9W9VZzPr9v3z9FrFka74yxDXsfK128lby\n-----END CERTIFICATE-----"
  },
  {
    "path": "test/fixtures/saml/saml-login",
    "content": "PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIERlc3RpbmF0aW9uPSJodHRwczovL2dyaXN0LmxvY2FsaG9zdC9zYW1sL2Fzc2VydCIgSUQ9IklEXzU1MTc0ZmM5LTMwZmYtNDkwMi05NjIwLTVmZTRmZDUyZmIxMyIgSXNzdWVJbnN0YW50PSIyMDI1LTAyLTE3VDE4OjU2OjIzLjQ2NVoiIFZlcnNpb249IjIuMCI+PHNhbWw6SXNzdWVyPmh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZWFsbXMvZ3Jpc3Q8L3NhbWw6SXNzdWVyPjxkc2lnOlNpZ25hdHVyZSB4bWxuczpkc2lnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj48ZHNpZzpTaWduZWRJbmZvPjxkc2lnOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz48ZHNpZzpTaWduYXR1cmVNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNyc2Etc2hhMjU2Ii8+PGRzaWc6UmVmZXJlbmNlIFVSST0iI0lEXzU1MTc0ZmM5LTMwZmYtNDkwMi05NjIwLTVmZTRmZDUyZmIxMyI+PGRzaWc6VHJhbnNmb3Jtcz48ZHNpZzpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIvPjxkc2lnOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPjwvZHNpZzpUcmFuc2Zvcm1zPjxkc2lnOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI3NoYTI1NiIvPjxkc2lnOkRpZ2VzdFZhbHVlPmEvVGtOOC9XYkhKazJDaUcvZzNnZVBabjdLQ0xGYkRRZ2RvelZ3eWpWOWs9PC9kc2lnOkRpZ2VzdFZhbHVlPjwvZHNpZzpSZWZlcmVuY2U+PC9kc2lnOlNpZ25lZEluZm8+PGRzaWc6U2lnbmF0dXJlVmFsdWU+b2c1K3czOGVNeEZSRGsveXQ2a1FPdnJPL2ppeWt2alRGNS9ia2k4WW9SaDJ1WXUxdk1LUERQL253TGg1dXBsbGtoNGYybDE3a3orOTQxRGZJTHllbnBpS2tPNit3WlJ2VlpJakJLNFZQWHQyeVRUaFdmZTJtOXY3THcvdm1sNXlXMGRZdGNJQVBuSC9RbVk0WFh2dUpWcExIOG9jQ0FTemxMc1FNZG1keGxUeThRaUlubTZhRXJheStEVFJxNjl1QlE1ZGhkSEdNTVZtRTBDNEVXNllpZy81cEJQM1R5SnJHSU9nZUVnLzNiTXhVbmNvNkVUeXJjRlV2bW1jc1Iyeld0eEhyaG41RnhFMkUwTUxzZCt5WVMxTDM4dGdIM0FwSDVPVk5QN3lBTk9Fa0FnNXNuWHBNclVuR01Ua0JyN2RjdXI2TzFFbTNTZy9tczh4R0NabnhBPT08L2RzaWc6U2lnbmF0dXJlVmFsdWU+PGRzaWc6S2V5SW5mbz48ZHNpZzpYNTA5RGF0YT48ZHNpZzpYNTA5Q2VydGlmaWNhdGU+TUlJQ21UQ0NBWUVDQmdHVkFXUVcxVEFOQmdrcWhraUc5dzBCQVFzRkFEQVFNUTR3REFZRFZRUUREQVZuY21semREQWVGdzB5TlRBeU1UTXlNakUyTXpGYUZ3MHpOVEF5TVRNeU1qRTRNVEZhTUJBeERqQU1CZ05WQkFNTUJXZHlhWE4wTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFyaTNuYnQvYTdJb29VazdZN3FKdmtwZEFvTnJiOC9rZ1VXajNyc2dQOFRVbjA1MGg0am41U09BMkxxMmhLcmZ5dUNhVmI2WEhVOUp2RksvR1NObHhvQ2tRTXk2bk5RTmFWUy9CYzgySENGb3pFS2hkUEF2VHZ1bHR5RzhFK3h3WkR2RTVvNHBNaTU0cVRFRDJKMXp4dGpqMWJ3dVZBclF5Q29BNTNDb3NzSVlyenlvQWFWS3I0T1d2NWlBMTlEb05SSFdXWG1uQjNueU56Wk10KzhSdVc1SU1FMkZJbXhCK1BJNzcrdXNQVGJPbW1xY0grczNVZUVRWlgzUmR3S0pCQ1AzZ0E5REpoRmVzTDRrUEJmOGsvVFhnTmg3dTBML3dyUTJiVS9VMWN4NE1NWi9wMmtxNjNYL0Y1UGpia1U4OEYwWDNtS00yY0w4YWlPRnRWaDU4M3dJREFRQUJNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUNUNUV5N1ZqSlRKKzdhUk9DV3JjVWZtYXBIVlZhSDkwWDhyRlRvSDdGQjFMSzFjYmNya01SbHNEdDl1NmJhOUpzZlRONVZ4bDdXbzZ3WWNsU01aMmU5aE5YZUZiT2ptMzIrb1NKM1pBd1ZnV2xXK3J5ejhaazk4UlpSYzg5VGZKem4rYWlCajBPdXpzVkhkZGREMEc0WFlnQnVTYzRieXJua2d1UXIyZHZDZW53emRXVmM4Q0lITjh0bFpDTnI0WEZaWHRtRUlic3V2dU9waEhTMDh4byt1RWk5WTYwbXNWd2VhTmk3NlIrYlFENkxObHZqZktjQXUvaWhXSE11Ti9xK1NpSWs2NEVPajJwM2lhWktPR3YwSWVWTVB3b1lLVXJCVjB6UFVmdWRXZ1VtSlEyWjNDYTlXOVZaelByOXYzejlGckZrYTc0eXhEWHNmSzEyOGxieTwvZHNpZzpYNTA5Q2VydGlmaWNhdGU+PC9kc2lnOlg1MDlEYXRhPjwvZHNpZzpLZXlJbmZvPjwvZHNpZzpTaWduYXR1cmU+PHNhbWxwOlN0YXR1cz48c2FtbHA6U3RhdHVzQ29kZSBWYWx1ZT0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnN0YXR1czpTdWNjZXNzIi8+PC9zYW1scDpTdGF0dXM+PHNhbWw6QXNzZXJ0aW9uIHhtbG5zPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiBJRD0iSURfZDA3Y2Y0YTctMjMwYy00MTlkLWFkNTEtZjE4N2Y0NzY3OWFiIiBJc3N1ZUluc3RhbnQ9IjIwMjUtMDItMTdUMTg6NTY6MjMuNDY0WiIgVmVyc2lvbj0iMi4wIj48c2FtbDpJc3N1ZXI+aHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9ncmlzdDwvc2FtbDpJc3N1ZXI+PHNhbWw6U3ViamVjdD48c2FtbDpOYW1lSUQgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoxLjE6bmFtZWlkLWZvcm1hdDplbWFpbEFkZHJlc3MiPmpvcmRpQGdldGdyaXN0LmNvbTwvc2FtbDpOYW1lSUQ+PHNhbWw6U3ViamVjdENvbmZpcm1hdGlvbiBNZXRob2Q9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpjbTpiZWFyZXIiPjxzYW1sOlN1YmplY3RDb25maXJtYXRpb25EYXRhIE5vdE9uT3JBZnRlcj0iMjA3NS0wMi0wNVQxODo1NjoyMS40NjRaIiBSZWNpcGllbnQ9Imh0dHBzOi8vZ3Jpc3QubG9jYWxob3N0L3NhbWwvYXNzZXJ0Ii8+PC9zYW1sOlN1YmplY3RDb25maXJtYXRpb24+PC9zYW1sOlN1YmplY3Q+PHNhbWw6Q29uZGl0aW9ucyBOb3RCZWZvcmU9IjIwMjUtMDItMTdUMTg6NTY6MjEuNDY0WiIgTm90T25PckFmdGVyPSIyMDc1LTAyLTA1VDE4OjU2OjIxLjQ2NFoiPjxzYW1sOkF1ZGllbmNlUmVzdHJpY3Rpb24+PHNhbWw6QXVkaWVuY2U+aHR0cHM6Ly9ncmlzdC5sb2NhbGhvc3Qvc2FtbC9tZXRhZGF0YS54bWw8L3NhbWw6QXVkaWVuY2U+PC9zYW1sOkF1ZGllbmNlUmVzdHJpY3Rpb24+PC9zYW1sOkNvbmRpdGlvbnM+PHNhbWw6QXV0aG5TdGF0ZW1lbnQgQXV0aG5JbnN0YW50PSIyMDI1LTAyLTE3VDE4OjU2OjIzLjQ2NVoiIFNlc3Npb25JbmRleD0iOGE0ZGIwMzItMDY0ZS00ZTQwLTkyYTYtOTE4MGUzMzE3YjBmOjo1ZjUyMmZkNS1mNWQxLTQyN2MtOGI0ZS01MTBiOTk4NGQxMTAiIFNlc3Npb25Ob3RPbk9yQWZ0ZXI9IjIwNzUtMDItMDVUMTg6NTY6MjMuNDY1WiI+PHNhbWw6QXV0aG5Db250ZXh0PjxzYW1sOkF1dGhuQ29udGV4dENsYXNzUmVmPnVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOnVuc3BlY2lmaWVkPC9zYW1sOkF1dGhuQ29udGV4dENsYXNzUmVmPjwvc2FtbDpBdXRobkNvbnRleHQ+PC9zYW1sOkF1dGhuU3RhdGVtZW50PjxzYW1sOkF0dHJpYnV0ZVN0YXRlbWVudD48c2FtbDpBdHRyaWJ1dGUgTmFtZT0iUm9sZSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj52aWV3LXByb2ZpbGU8L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgTmFtZT0iUm9sZSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj5tYW5hZ2UtYWNjb3VudDwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJSb2xlIiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OmJhc2ljIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czpzdHJpbmciPm9mZmxpbmVfYWNjZXNzPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDpBdHRyaWJ1dGU+PHNhbWw6QXR0cmlidXRlIE5hbWU9IlJvbGUiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6YmFzaWMiPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOnN0cmluZyI+dW1hX2F1dGhvcml6YXRpb248L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgTmFtZT0iUm9sZSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj5tYW5hZ2UtYWNjb3VudC1saW5rczwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJSb2xlIiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OmJhc2ljIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czpzdHJpbmciPmRlZmF1bHQtcm9sZXMtZ3Jpc3Q8L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48L3NhbWw6QXR0cmlidXRlU3RhdGVtZW50Pjwvc2FtbDpBc3NlcnRpb24+PC9zYW1scDpSZXNwb25zZT4="
  },
  {
    "path": "test/fixtures/saml/saml-logout",
    "content": "PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIERlc3RpbmF0aW9uPSJodHRwczovL2dyaXN0LmxvY2FsaG9zdC9zYW1sL2Fzc2VydCIgSUQ9IklEXzkxMjMzOWIwLTkxODUtNGRhNC1iZTNmLWJlMDYyMjI0YmQyYyIgSXNzdWVJbnN0YW50PSIyMDI1LTAyLTE4VDAwOjIzOjQzLjA1N1oiIFZlcnNpb249IjIuMCI+PHNhbWw6SXNzdWVyPmh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZWFsbXMvZ3Jpc3Q8L3NhbWw6SXNzdWVyPjxkc2lnOlNpZ25hdHVyZSB4bWxuczpkc2lnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj48ZHNpZzpTaWduZWRJbmZvPjxkc2lnOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz48ZHNpZzpTaWduYXR1cmVNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNyc2Etc2hhMjU2Ii8+PGRzaWc6UmVmZXJlbmNlIFVSST0iI0lEXzkxMjMzOWIwLTkxODUtNGRhNC1iZTNmLWJlMDYyMjI0YmQyYyI+PGRzaWc6VHJhbnNmb3Jtcz48ZHNpZzpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIvPjxkc2lnOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPjwvZHNpZzpUcmFuc2Zvcm1zPjxkc2lnOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI3NoYTI1NiIvPjxkc2lnOkRpZ2VzdFZhbHVlPlN1VXVHUHpkZmhXWkF3S1ExVk93VlB0akNzRFFNdVhwZ2tTaWEzTHRvbGM9PC9kc2lnOkRpZ2VzdFZhbHVlPjwvZHNpZzpSZWZlcmVuY2U+PC9kc2lnOlNpZ25lZEluZm8+PGRzaWc6U2lnbmF0dXJlVmFsdWU+Q0FpYklUeVc4SnI3cXhBUnpzUUN5R0tWTjJzR04vUk1oV2VVNUd1RVNuUzQrei9ocWlDekdiWXBLTDNzMi9nVFd6YUY1M3pTS0dlV1lKRjBVclNGOXZsY2pFR2pXd29xd3ExK3k4V1kxUVE1SlB1elgzbWszQUNNYnZ3ZmV1WTB5SStnWGFPZzd1V1RnRTlkK3RCNk5NV200alcwY2k0K21sWFJmcmZtMFNMWENSV0JqUk1pWXFkeS9Hd29JdHNLSURCb012M3RmWG5JNnV0THJ4NHlXSVp3NU5XY3BkNTRjdVNPT3BPVWExUmxxMlo3elhKUEpMRDkycUZ1Sis4TGxKOEppQTE1L05hVlFmMS94aVdXSVZxWTZYRitNaFdZTkl5NjgxUExKcTFEemE5NElNVTNLNWRPREgyNWd4V3lYWlQ3Snk2NW5KMVNFcjRYclpmbXN3PT08L2RzaWc6U2lnbmF0dXJlVmFsdWU+PGRzaWc6S2V5SW5mbz48ZHNpZzpYNTA5RGF0YT48ZHNpZzpYNTA5Q2VydGlmaWNhdGU+TUlJQ21UQ0NBWUVDQmdHVkFXUVcxVEFOQmdrcWhraUc5dzBCQVFzRkFEQVFNUTR3REFZRFZRUUREQVZuY21semREQWVGdzB5TlRBeU1UTXlNakUyTXpGYUZ3MHpOVEF5TVRNeU1qRTRNVEZhTUJBeERqQU1CZ05WQkFNTUJXZHlhWE4wTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFyaTNuYnQvYTdJb29VazdZN3FKdmtwZEFvTnJiOC9rZ1VXajNyc2dQOFRVbjA1MGg0am41U09BMkxxMmhLcmZ5dUNhVmI2WEhVOUp2RksvR1NObHhvQ2tRTXk2bk5RTmFWUy9CYzgySENGb3pFS2hkUEF2VHZ1bHR5RzhFK3h3WkR2RTVvNHBNaTU0cVRFRDJKMXp4dGpqMWJ3dVZBclF5Q29BNTNDb3NzSVlyenlvQWFWS3I0T1d2NWlBMTlEb05SSFdXWG1uQjNueU56Wk10KzhSdVc1SU1FMkZJbXhCK1BJNzcrdXNQVGJPbW1xY0grczNVZUVRWlgzUmR3S0pCQ1AzZ0E5REpoRmVzTDRrUEJmOGsvVFhnTmg3dTBML3dyUTJiVS9VMWN4NE1NWi9wMmtxNjNYL0Y1UGpia1U4OEYwWDNtS00yY0w4YWlPRnRWaDU4M3dJREFRQUJNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUNUNUV5N1ZqSlRKKzdhUk9DV3JjVWZtYXBIVlZhSDkwWDhyRlRvSDdGQjFMSzFjYmNya01SbHNEdDl1NmJhOUpzZlRONVZ4bDdXbzZ3WWNsU01aMmU5aE5YZUZiT2ptMzIrb1NKM1pBd1ZnV2xXK3J5ejhaazk4UlpSYzg5VGZKem4rYWlCajBPdXpzVkhkZGREMEc0WFlnQnVTYzRieXJua2d1UXIyZHZDZW53emRXVmM4Q0lITjh0bFpDTnI0WEZaWHRtRUlic3V2dU9waEhTMDh4byt1RWk5WTYwbXNWd2VhTmk3NlIrYlFENkxObHZqZktjQXUvaWhXSE11Ti9xK1NpSWs2NEVPajJwM2lhWktPR3YwSWVWTVB3b1lLVXJCVjB6UFVmdWRXZ1VtSlEyWjNDYTlXOVZaelByOXYzejlGckZrYTc0eXhEWHNmSzEyOGxieTwvZHNpZzpYNTA5Q2VydGlmaWNhdGU+PC9kc2lnOlg1MDlEYXRhPjwvZHNpZzpLZXlJbmZvPjwvZHNpZzpTaWduYXR1cmU+PHNhbWxwOlN0YXR1cz48c2FtbHA6U3RhdHVzQ29kZSBWYWx1ZT0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnN0YXR1czpTdWNjZXNzIi8+PC9zYW1scDpTdGF0dXM+PHNhbWw6QXNzZXJ0aW9uIHhtbG5zPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiBJRD0iSURfM2IxZTZjOTItODM3OS00YTNlLWI1NTgtOGY4MjQyMzE1NGVlIiBJc3N1ZUluc3RhbnQ9IjIwMjUtMDItMThUMDA6MjM6NDMuMDU3WiIgVmVyc2lvbj0iMi4wIj48c2FtbDpJc3N1ZXI+aHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9ncmlzdDwvc2FtbDpJc3N1ZXI+PHNhbWw6U3ViamVjdD48c2FtbDpOYW1lSUQgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoxLjE6bmFtZWlkLWZvcm1hdDplbWFpbEFkZHJlc3MiPmpvcmRpQGdldGdyaXN0LmNvbTwvc2FtbDpOYW1lSUQ+PHNhbWw6U3ViamVjdENvbmZpcm1hdGlvbiBNZXRob2Q9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpjbTpiZWFyZXIiPjxzYW1sOlN1YmplY3RDb25maXJtYXRpb25EYXRhIE5vdE9uT3JBZnRlcj0iMjA3NS0wMi0wNlQwMDoyMzo0MS4wNTdaIiBSZWNpcGllbnQ9Imh0dHBzOi8vZ3Jpc3QubG9jYWxob3N0L3NhbWwvYXNzZXJ0Ii8+PC9zYW1sOlN1YmplY3RDb25maXJtYXRpb24+PC9zYW1sOlN1YmplY3Q+PHNhbWw6Q29uZGl0aW9ucyBOb3RCZWZvcmU9IjIwMjUtMDItMThUMDA6MjM6NDEuMDU3WiIgTm90T25PckFmdGVyPSIyMDc1LTAyLTA2VDAwOjIzOjQxLjA1N1oiPjxzYW1sOkF1ZGllbmNlUmVzdHJpY3Rpb24+PHNhbWw6QXVkaWVuY2U+aHR0cHM6Ly9ncmlzdC5sb2NhbGhvc3Qvc2FtbC9tZXRhZGF0YS54bWw8L3NhbWw6QXVkaWVuY2U+PC9zYW1sOkF1ZGllbmNlUmVzdHJpY3Rpb24+PC9zYW1sOkNvbmRpdGlvbnM+PHNhbWw6QXV0aG5TdGF0ZW1lbnQgQXV0aG5JbnN0YW50PSIyMDI1LTAyLTE4VDAwOjIzOjQzLjA1N1oiIFNlc3Npb25JbmRleD0iNzE4NjZmOTYtNGE0My00OTkxLWI4NWQtZTc5NjIzYzI0MDE5Ojo1ZjUyMmZkNS1mNWQxLTQyN2MtOGI0ZS01MTBiOTk4NGQxMTAiIFNlc3Npb25Ob3RPbk9yQWZ0ZXI9IjIwNzUtMDItMDZUMDA6MjM6NDMuMDU3WiI+PHNhbWw6QXV0aG5Db250ZXh0PjxzYW1sOkF1dGhuQ29udGV4dENsYXNzUmVmPnVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOnVuc3BlY2lmaWVkPC9zYW1sOkF1dGhuQ29udGV4dENsYXNzUmVmPjwvc2FtbDpBdXRobkNvbnRleHQ+PC9zYW1sOkF1dGhuU3RhdGVtZW50PjxzYW1sOkF0dHJpYnV0ZVN0YXRlbWVudD48c2FtbDpBdHRyaWJ1dGUgTmFtZT0iUm9sZSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj52aWV3LXByb2ZpbGU8L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgTmFtZT0iUm9sZSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj5tYW5hZ2UtYWNjb3VudDwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJSb2xlIiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OmJhc2ljIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czpzdHJpbmciPm9mZmxpbmVfYWNjZXNzPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDpBdHRyaWJ1dGU+PHNhbWw6QXR0cmlidXRlIE5hbWU9IlJvbGUiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6YmFzaWMiPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOnN0cmluZyI+dW1hX2F1dGhvcml6YXRpb248L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgTmFtZT0iUm9sZSIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDpiYXNpYyI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6c3RyaW5nIj5tYW5hZ2UtYWNjb3VudC1saW5rczwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJSb2xlIiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OmJhc2ljIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czpzdHJpbmciPmRlZmF1bHQtcm9sZXMtZ3Jpc3Q8L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48L3NhbWw6QXR0cmlidXRlU3RhdGVtZW50Pjwvc2FtbDpBc3NlcnRpb24+PC9zYW1scDpSZXNwb25zZT4="
  },
  {
    "path": "test/fixtures/saml/saml.crt",
    "content": "-----BEGIN CERTIFICATE-----\nMIIEGzCCAwOgAwIBAgIUXjpl7DIKS4xYB4zMShzi65bEntEwDQYJKoZIhvcNAQEL\nBQAwgZwxCzAJBgNVBAYTAmNhMRIwEAYDVQQIDAlRdcODwqliZWMxFDASBgNVBAcM\nC01vbnRyw4PCqWFsMRswGQYDVQQKDBJKb3JkaUdIIEluZHVzdHJpZXMxIzAhBgNV\nBAMMGkpvcmRpIEd1dGnDg8KpcnJleiBIZXJtb3NvMSEwHwYJKoZIhvcNAQkBFhJq\nb3JkaWdoQG9jdGF2ZS5vcmcwHhcNMjQwNDE1MjAzNzUzWhcNMzQwNDEzMjAzNzUz\nWjCBnDELMAkGA1UEBhMCY2ExEjAQBgNVBAgMCVF1w4PCqWJlYzEUMBIGA1UEBwwL\nTW9udHLDg8KpYWwxGzAZBgNVBAoMEkpvcmRpR0ggSW5kdXN0cmllczEjMCEGA1UE\nAwwaSm9yZGkgR3V0acODwqlycmV6IEhlcm1vc28xITAfBgkqhkiG9w0BCQEWEmpv\ncmRpZ2hAb2N0YXZlLm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB\nAKVPgccg5hNBtEySsHl3p2VuOulEBS8Xb6HW8IBtodajCbVhpuwA3CTdhjUaObzD\nZSZAWmYUWtZYJsYce8hqGogyWyv/zxyyoXKxc0fBY6uuRylPeHkZwsh9ar2mKuYg\nqHmVqTRoIVRiMR0XCxr96+/4gA/NizrFyJU+5pwe4/9h9HWAWZrarWNtCu7S9/05\nvD9gDZnFl6+Kh6kP4Ii9/uT8YO1mkLJYwGdXextGZoCdrs7eVYvO/XGpNFXmO/3a\nMEQb4RJWdKjoLPemfOBHUwTgSfXPUrfP9uVM3pqDQHQV+LJpXBmbjzKftu34DBJV\ntgONHhdEYQF2fJN5sxm/u2MCAwEAAaNTMFEwHQYDVR0OBBYEFJ/7baj0aRuwWtvy\nuxUE1jNEMmw1MB8GA1UdIwQYMBaAFJ/7baj0aRuwWtvyuxUE1jNEMmw1MA8GA1Ud\nEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBABRp3foUr9Bft+xskYoqVNWd\nYhDvrQ/B69py72geaesJRzcoeNQEKDP7BQ3KYJr2CPDM5Z/cNWGm7jlNAgVo0VGi\nbHZr8+dTo+pySX5VEAYZJhdlSvfduNPpw8tiEOTziuA5bbht69uoPe1x8MbZXSOB\n0dWDz5+DxJUCSQEo5ilK+IJcIzmcmR1o/boWN0wp71xh3h29OPIPm7noY03Cztep\n1SQqFrwz9MOL6Sw5XjYwKT6vLS6doBW9LYxCaniKXUER3RRLfk2EvTZ7+k4n+wKI\nM2cb6kdk48mw8orDFN4yDAYzyoE0+eDFZ7IXQiaZDy7LDwfrgUS8ozunHcAdv1w=\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "test/fixtures/saml/saml.key",
    "content": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQClT4HHIOYTQbRM\nkrB5d6dlbjrpRAUvF2+h1vCAbaHWowm1YabsANwk3YY1Gjm8w2UmQFpmFFrWWCbG\nHHvIahqIMlsr/88csqFysXNHwWOrrkcpT3h5GcLIfWq9pirmIKh5lak0aCFUYjEd\nFwsa/evv+IAPzYs6xciVPuacHuP/YfR1gFma2q1jbQru0vf9Obw/YA2ZxZevioep\nD+CIvf7k/GDtZpCyWMBnV3sbRmaAna7O3lWLzv1xqTRV5jv92jBEG+ESVnSo6Cz3\npnzgR1ME4En1z1K3z/blTN6ag0B0FfiyaVwZm48yn7bt+AwSVbYDjR4XRGEBdnyT\nebMZv7tjAgMBAAECggEAJZrvFlntDM9jERJDk/Y8vc20GBFxrnNB4UqQfl6yNK31\nDO5wdvqBGd/M4nKbVM4MSgXUqqkRuvXlqCadoAtHCtmNtPYl6szV/k3QwC+FmW31\nYTfjW4UZMtuY9xiAZMZkHPiHO9U+U8BclNYDoDnIPNhGZZHoDNAmX5EmC7jZO+Ry\nd8TFlEui+R4RIoTn8siSjB7eaOieHQKO53ZgBfAnxuj56DX+/+hIVyuihSEdFrux\ni/iJ0gyM2+lPrQvab2fo3wccWUL0VOlUU9x5u7rAdr/xUWVPDYNoWR5M8XSLFX9i\nyEX0AshYeWHs9kryZXcCClieX3FOhQ9o/66mE0IT4QKBgQDWWpnE26jR7Q9AMWld\nMoPXqUO2z4wjg8y6SN1RMNZGJibfyQAeh8kImJOhb4PJ8L69TucpJU1CLC+0J07Y\ncQdlOSI6tqTjTypHg7Hlmk4GrDMp13jwnvekwkxGa4buZLrGUsaQT6n0Y39bYde7\n8USrZ640xoQyE4FaSP3bw9xG4QKBgQDFbZ6v2/7/4OoPllIzWRZA0b1oBnhImtQ0\nLj/pNF/blco8DL+LeFE6Guuxegm/vYH0khsMUGr/h0XFNHam4iI2+ZBOKwHpRT60\nG41Co/hrTnGXTKnqFs+VPf5ERWLOtOFt8nKMT4f/AkpTw9Tz+MIhT94KXFsCj5Wt\nU4fSh3N+wwKBgCL8rMaV9+sz315h8km0+hIUXaoUHFKbq6noRL+A0iinB4dVXoCd\nNzIA/W/HLOKkOe3aWB6+KOsZHTwxgkwPvt5FwhGFSEqV3FfJ5hqM4hlyt/MnaWUU\n/WTWFe8Uk/SLWnUOg7yAVERAjUQUJ0tU6Rl1FdklYeRujJl4+n6JbIXhAoGAZEwS\nR+kNnNSYVB7b17Y0de2XuZc/2DLAB1pPoZu37wgj22nmjWYsbcZrYphLB5uwv5zS\nHOll0jbYnRzQAmvzUdZrFysGJ4nEFx/AHdDLTUhmsKSD1aaNApah6/EMB1MhCwgW\nZW2p+0UgmXltYBxKEz5N4RmWKrDjK1C6OZwScp0CgYEAhi0BSPY/A4k67a9XVYuo\nG+wegmCgOcId0l7bjibDvsJiaw8nP/HtqLsVKnUmS9aFyfZpGDIE/1A5LVG3Ymb4\nkcr3vAMLsC1rTX9hjc6Ze+leYw4RaJEhqRT9A8YsjZPqinLiiH7dfrN2KjGN/ZNW\nXpw3RxHpUyW5+K3ym398YoI=\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "test/fixtures/sites/config/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <script src=\"/grist-plugin-api.js\"></script>\n    <script src=\"page.js\"></script>\n  </head>\n  <body style=\"background: white;\">\n    <div id=\"ready\"></div>\n    <div id=\"access\"></div>\n    <div id=\"readonly\"></div>\n\n    <div>onOptions event data:</div>\n    <pre id=\"onOptions\"></pre>\n\n    <div>onRecord event data:</div>\n    <pre id=\"onRecord\"></pre>\n\n    <div>onRecord mapping data:</div>\n    <pre id=\"onRecordMappings\"></pre>\n\n    <div>onRecords event data:</div>\n    <pre id=\"onRecords\"></pre>\n\n    <div>onRecord mappings data:</div>\n    <pre id=\"onRecordsMappings\"></pre>\n    \n    <div>configure handler:</div>\n    <pre id=\"configure\"></pre>\n\n    <div>Method input json:</div>\n    <input type=\"text\" id=\"input\" value=\"\" />\n    <div>Method output json:</div>\n    <div id=\"output\"></div>\n    <div>Methods:</div>\n    <button onclick=\"getOptions()\">getOptions</button>\n    <button onclick=\"setOptions()\">setOptions</button>\n    <button onclick=\"getOption()\">getOption</button>\n    <button onclick=\"setOption()\">setOption</button>\n    <button onclick=\"mappings()\">mappings</button>\n    <button onclick=\"configure()\">configure</button>\n    <button onclick=\"clearOptions()\">clearOptions</button>\n    <button onclick=\"clearLog()\">clearLog</button>\n\n\n    <div>meta columns:</div>\n    <textarea id=\"log\"></textarea>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/config/page.js",
    "content": "/* global document, grist, window */\n\n// Ready message can be configured from url\nconst urlParams = new URLSearchParams(window.location.search);\nconst ready = urlParams.get(\"ready\") ? JSON.parse(urlParams.get(\"ready\")) : undefined;\n\nfunction setup() {\n  if (ready && ready.onEditOptions) {\n    ready.onEditOptions = () => {\n      document.getElementById(\"configure\").innerHTML = \"called\";\n    };\n  }\n\n  grist.ready(ready);\n\n  grist.onOptions(data => {\n    document.getElementById(\"onOptions\").innerHTML = JSON.stringify(data);\n  });\n\n  grist.onRecord((data, mappings) => {\n    document.getElementById(\"onRecord\").innerHTML = JSON.stringify(data);\n    document.getElementById(\"onRecordMappings\").innerHTML = JSON.stringify(mappings);\n  });\n\n  grist.onRecords((data, mappings) => {\n    document.getElementById(\"onRecords\").innerHTML = JSON.stringify(data);\n    document.getElementById(\"onRecordsMappings\").innerHTML = JSON.stringify(mappings);\n  });\n\n  grist.on(\"message\", event => {\n    const existing = document.getElementById(\"log\").textContent || \"\";\n    const newContent = `${existing}\\n${JSON.stringify(event)}`.trim();\n    document.getElementById(\"log\").innerHTML = newContent;\n  });\n}\n\nasync function run(handler) {\n  try {\n    document.getElementById(\"output\").innerText = \"waiting...\";\n    const result = await handler(JSON.parse(document.getElementById(\"input\").value || \"[]\"));\n    document.getElementById(\"output\").innerText = result === undefined ? \"undefined\" : JSON.stringify(result);\n  } catch (err) {\n    document.getElementById(\"output\").innerText = JSON.stringify({error: err.message || String(err)});\n  }\n}\n\n// eslint-disable-next-line no-unused-vars\nasync function getOptions() {\n  return run(() => grist.widgetApi.getOptions());\n}\n// eslint-disable-next-line no-unused-vars\nasync function setOptions() {\n  return run(options => grist.widgetApi.setOptions(...options));\n}\n// eslint-disable-next-line no-unused-vars\nasync function setOption() {\n  return run(options => grist.widgetApi.setOption(...options));\n}\n// eslint-disable-next-line no-unused-vars\nasync function getOption() {\n  return run(options => grist.widgetApi.getOption(...options));\n}\n// eslint-disable-next-line no-unused-vars\nasync function clearOptions() {\n  return run(() => grist.widgetApi.clearOptions());\n}\n// eslint-disable-next-line no-unused-vars\nasync function mappings() {\n  return run(() => grist.sectionApi.mappings());\n}\n// eslint-disable-next-line no-unused-vars\nasync function configure() {\n  return run((options) => grist.sectionApi.configure(...options));\n}\n\n// eslint-disable-next-line no-unused-vars\nasync function clearLog() {\n  return run(() => document.getElementById(\"log\").textContent = \"\");\n}\n\nwindow.onload = () => {\n  setup();\n  document.getElementById(\"ready\").innerText = \"ready\";\n  document.getElementById(\"access\").innerHTML = urlParams.get(\"access\");\n  document.getElementById(\"readonly\").innerHTML = urlParams.get(\"readonly\");\n};\n"
  },
  {
    "path": "test/fixtures/sites/deferred-ready/index.html",
    "content": "<html>\n  <head>\n    <script src=\"/grist-plugin-api.js\"></script>\n  </head>\n  <body>\n    <button onclick=\"grist.ready()\">Ready</button>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/embed/embed.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <style>\nhtml, body { height: 100%; }\n#outside { display: block; height: 15%; width: 100%; }\n#embed { height: 70%; width: 100%; }\n    </style>\n  </head>\n  <body>\n    <h3>Embed Grist</h3>\n    <textarea id='outside'></textarea>\n    <iframe id='embed' src=\"\"></iframe>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/fetchSelectedOptions/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <script src=\"/grist-plugin-api.js\"></script>\n    <script src=\"page.js\"></script>\n  </head>\n  <body>\n    <h1>FetchSelectedOptions</h1>\n    <pre id=\"data\"></pre>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/fetchSelectedOptions/page.js",
    "content": "/* global document, grist, window */\n\nfunction setup() {\n  const data = {\n    shown: 0,\n    default: {},\n    options: {},\n  };\n\n  function showData() {\n    data.shown += 1;\n    document.getElementById(\"data\").innerHTML = JSON.stringify(data, null, 2);\n  }\n\n  grist.onRecord(function (rec) {\n    data.default.onRecord = rec;\n    showData();\n  });\n  grist.onRecords(function (recs) {\n    data.default.onRecords = recs;\n    showData();\n  });\n  grist.fetchSelectedTable().then(function (table) {\n    data.default.fetchSelectedTable = table;\n    showData();\n  });\n  grist.fetchSelectedRecord(1).then(function (rec) {\n    data.default.fetchSelectedRecord = rec;\n    showData();\n  });\n  grist.viewApi.fetchSelectedTable().then(function (table) {\n    data.default.viewApiFetchSelectedTable = table;\n    showData();\n  });\n  grist.viewApi.fetchSelectedRecord(2).then(function (rec) {\n    data.default.viewApiFetchSelectedRecord = rec;\n    showData();\n  });\n\n  // NOTE: These cases will hit an access error when trying to trigger the callback\n  // when access level isn't full, and we can't catch that error.\n  grist.onRecord(function (rec) {\n    data.options.onRecord = rec;\n    showData();\n  }, {keepEncoded: true, includeColumns: \"normal\", format: \"columns\"});\n  grist.onRecords(function (recs) {\n    data.options.onRecords = recs;\n    showData();\n  }, {keepEncoded: true, includeColumns: \"all\", format: \"columns\"});\n\n  grist.fetchSelectedTable(\n    {keepEncoded: true, includeColumns: \"all\", format: \"rows\"}\n  ).then(function (table) {\n    data.options.fetchSelectedTable = table;\n    showData();\n  }).catch(function (err) {\n    data.options.fetchSelectedTable = String(err);\n    showData();\n  });\n  grist.fetchSelectedRecord(1,\n    {keepEncoded: true, includeColumns: \"normal\", format: \"rows\"}\n  ).then(function (rec) {\n    data.options.fetchSelectedRecord = rec;\n    showData();\n  }).catch(function (err) {\n    data.options.fetchSelectedRecord = String(err);\n    showData();\n  });\n  grist.viewApi.fetchSelectedTable(\n    {keepEncoded: false, includeColumns: \"all\", format: \"rows\"}\n  ).then(function (table) {\n    data.options.viewApiFetchSelectedTable = table;\n    showData();\n  }).catch(function (err) {\n    data.options.viewApiFetchSelectedTable = String(err);\n    showData();\n  });\n  grist.viewApi.fetchSelectedRecord(2,\n    {keepEncoded: false, includeColumns: \"normal\", format: \"rows\"}\n  ).then(function (rec) {\n    data.options.viewApiFetchSelectedRecord = rec;\n    showData();\n  }).catch(function (err) {\n    data.options.viewApiFetchSelectedRecord = String(err);\n    showData();\n  });\n\n  grist.ready();\n}\n\nwindow.onload = setup;\n"
  },
  {
    "path": "test/fixtures/sites/filter/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <script src=\"/grist-plugin-api.js\"></script>\n    <script src=\"page.js\"></script>\n  </head>\n  <body>\n    <h1>Filter</h1>\n    <p>Enter row ids (ie: \"1\" or \"1, 3, 4\"): </p>\n    <input type=\"text\" id=\"rowIds\"/>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/filter/page.js",
    "content": "\n/* global document, grist, window */\n\nfunction setup() {\n  grist.ready();\n  grist.allowSelectBy();\n  document.querySelector(\"#rowIds\").addEventListener(\"change\", (ev) => {\n    const rowIds = ev.target.value.split(\",\").map(Number);\n    grist.setSelectedRows(rowIds);\n  });\n}\n\nwindow.onload = setup;\n"
  },
  {
    "path": "test/fixtures/sites/hello/index.html",
    "content": "<html>\n  <body>\n    <script src=\"/grist-plugin-api.js\"></script>\n    <h1 id=\"hello-title\">Hello World</h1>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/paste/paste.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"utf8\">\n  </head>\n  <body>\n    <table cellspacing=\"0\" cellpadding=\"0\" border=\"1\">\n      <colgroup><col span=\"2\"></colgroup>\n      <tbody>\n        <tr>\n          <td>a</td>\n          <td>b</td>\n        </tr>\n        <tr>\n          <td colspan=\"2\">c</td>\n        </tr>\n        <tr>\n          <td>d</td>\n          <td rowspan=\"2\">e</td>\n        </tr>\n        <tr>\n          <td>f</td>\n        </tr>\n      </tbody>\n    </table>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/probe/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <script src=\"/grist-plugin-api.js\"></script>\n    <script src=\"page.js\"></script>\n  </head>\n  <body>\n    <h1>Probe</h1>\n    <div id=\"placeholder\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/probe/page.js",
    "content": "\n\n/* global document, grist, window */\n\ngrist.ready();\n\nfunction readDoc() {\n  const api = grist.rpc.getStub(\"GristDocAPI@grist\", grist.checkers.GristDocAPI);\n  const placeholder = document.getElementById(\"placeholder\");\n  const fallback = setTimeout(() => {\n    placeholder.innerHTML = '<div id=\"output\">no joy</div>';\n  }, 1000);\n  api.listTables()\n    .then(tables => {\n      clearTimeout(fallback);\n      placeholder.innerHTML = `<div id=\"output\">${JSON.stringify(tables)}</div>`;\n    });\n}\n\nwindow.onload = readDoc;\n"
  },
  {
    "path": "test/fixtures/sites/readout/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <script src=\"/grist-plugin-api.js\"></script>\n    <script src=\"page.js\"></script>\n  </head>\n  <body>\n    <h1>Readout</h1>\n    <h2>placeholder</h2>\n    <div id=\"placeholder\"></div>\n    <h2>rowId</h2>\n    <div id=\"rowId\"></div>\n    <h2>tableId</h2>\n    <div id=\"tableId\"></div>\n    <hr />\n    <h2>record</h2>\n    <div id=\"record\"></div>\n    <h2>records</h2>\n    <div id=\"records\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/readout/page.js",
    "content": "\n\n/* global document, grist, window */\n\nfunction readDoc() {\n  const fetchTable = grist.docApi.fetchSelectedTable();\n  const placeholder = document.getElementById(\"placeholder\");\n  const fallback = setTimeout(() => {\n    placeholder.innerHTML = '<div id=\"output\">no joy</div>';\n  }, 1000);\n  fetchTable\n    .then(table => {\n      clearTimeout(fallback);\n      placeholder.innerHTML = `<div id=\"output\">${JSON.stringify(table)}</div>`;\n    });\n}\n\nfunction setup() {\n  grist.ready();\n  grist.on(\"message\", function(e) {\n    if (\"options\" in e) return;\n    document.getElementById(\"rowId\").innerHTML = e.rowId || \"\";\n    document.getElementById(\"tableId\").innerHTML = e.tableId || \"\";\n    readDoc();\n  });\n  grist.onRecord(function(rec) {\n    document.getElementById(\"record\").innerHTML = JSON.stringify(rec);\n  });\n  grist.onRecords(function(recs) {\n    document.getElementById(\"records\").innerHTML = JSON.stringify(recs);\n  });\n  grist.onNewRecord(function(rec) {\n    document.getElementById(\"record\").innerHTML = \"new\";\n  });\n  grist.enableKeyboardShortcuts();\n}\n\nwindow.onload = setup;\n"
  },
  {
    "path": "test/fixtures/sites/types/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <script src=\"/grist-plugin-api.js\"></script>\n    <script src=\"page.js\"></script>\n  </head>\n  <body>\n    <h1>Types</h1>\n    <div>\n      onRecord() matches a record in table?\n      <div id=\"match\"></div>\n    </div>\n    <h2>record</h2>\n    <pre id=\"record\"></pre>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/types/page.js",
    "content": "/* global document, grist, window */\n\nfunction formatValue(value, indent=\"\") {\n  let basic = `${value} [typeof=${typeof value}]`;\n  if (value && typeof value === \"object\") {\n    basic += ` [name=${value.constructor.name}]`;\n  }\n  if (value instanceof Date) {\n    // For moment, use moment(value) or moment(value).tz(value.timezone), it's just hard to\n    // include moment into this test fixture.\n    basic += ` [date=${value.toISOString()}]`;\n  }\n  if (value && typeof value === \"object\" && value.constructor.name === \"Object\") {\n    basic += \"\\n\" + formatObject(value);\n  }\n  return basic;\n}\n\nfunction formatObject(obj) {\n  const keys = Object.keys(obj).sort();\n  const rows = keys.map(k => `${k}: ${formatValue(obj[k])}`.replace(/\\n/g, \"\\n  \"));\n  return rows.join(\"\\n\");\n}\n\nfunction setup() {\n  let lastRecords = [];\n  grist.ready();\n  grist.onRecords(function(records) { lastRecords = records; });\n  grist.onRecord(function(rec) {\n    const formatted = formatObject(rec);\n    document.getElementById(\"record\").innerHTML = formatted;\n\n    // Check that there is an identical object in lastRecords, to ensure that onRecords() returns\n    // the same kind of representation.\n    const rowInRecords = lastRecords.find(r => (r.id === rec.id));\n    const match = (formatObject(rowInRecords) === formatted);\n    document.getElementById(\"match\").textContent = String(match);\n\n  });\n}\n\nwindow.onload = setup;\n"
  },
  {
    "path": "test/fixtures/sites/types-raw-refs/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <script src=\"/grist-plugin-api.js\"></script>\n    <script src=\"page.js\"></script>\n  </head>\n  <body>\n    <h1>Types</h1>\n    <div>\n      onRecord() matches a record in table?\n      <div id=\"match\"></div>\n    </div>\n    <h2>record</h2>\n    <div id=\"record\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/types-raw-refs/page.js",
    "content": "/* global document, grist, window */\n\nfunction setup() {\n  let lastRecords = [];\n  grist.ready();\n  grist.onRecords(function(records) { lastRecords = records; });\n  grist.onRecord(function(rec) {\n    document.getElementById(\"record\").innerHTML = JSON.stringify(rec);\n\n    // Check that there is an identical object in lastRecords, to ensure that onRecords() returns\n    // the same kind of representation.\n    const rowInRecords = lastRecords.find(r => (r.id === rec.id));\n    const match = JSON.stringify(rowInRecords) === JSON.stringify(rec);\n    document.getElementById(\"match\").textContent = JSON.stringify(match);\n\n  }, {expandRefs: false});\n}\n\nwindow.onload = setup;\n"
  },
  {
    "path": "test/fixtures/sites/types-rest-api/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <script src=\"/grist-plugin-api.js\" defer></script>\n    <script src=\"page.js\" defer></script>\n  </head>\n  <body>\n    <h1>Types</h1>\n    <pre id=\"record\"></pre>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/types-rest-api/page.js",
    "content": "/* global document, grist */\n\nlet _token = null;\nconst getToken = () => (_token || (_token = grist.getAccessToken({readOnly: true})));\n\ngrist.ready();\ngrist.onRecord(async function(rec) {\n  const onRecordVersion = rec;\n\n  const fetchSelectedVersion = await grist.docApi.fetchSelectedRecord(rec.id, {\n    format: \"rows\",\n    cellFormat: \"typed\",\n    includeColumns: \"normal\",\n  });\n\n  // Also get record via /records?cellFormat=typed endpoint.\n  const tokenResult = await getToken();\n  const url = new URL(tokenResult.baseUrl + `/tables/Types/records`);\n  url.searchParams.set(\"auth\", tokenResult.token);\n  url.searchParams.set(\"filter\", JSON.stringify({ id: [rec.id] }));\n  url.searchParams.set(\"cellFormat\", \"typed\");\n  const {records} = await (await fetch(url)).json();\n  const r = records?.[0];\n  const restApiVersion = r ? grist.mapValues({id: r.id, ...r.fields}, grist.decodeObject) : null;\n\n  const result = { onRecordVersion, fetchSelectedVersion, restApiVersion };\n  document.getElementById(\"record\").innerHTML = JSON.stringify(result, null, 2);\n}, {cellFormat: \"typed\"});\n"
  },
  {
    "path": "test/fixtures/sites/zap/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <script src=\"/grist-plugin-api.js\"></script>\n    <script src=\"page.js\"></script>\n  </head>\n  <body>\n    <h1>Zap</h1>\n    <div id=\"placeholder\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/sites/zap/page.js",
    "content": "/* global document, grist, window */\n\n/**\n * This widget connects to the document, gets a list of all user tables in it,\n * and then tries to replace all cells with the text 'zap'.  It requires full\n * access to do this.\n */\n\nlet failures = 0;\nfunction problem(err) {\n  // Trying to zap formula columns will fail, but that's ok.\n  if (String(err).includes(\"formula column\")) { return; }\n  console.error(err);\n  document.getElementById(\"placeholder\").innerHTML = \"zap failed\";\n  failures++;\n}\n\nasync function zap() {\n  grist.ready();\n  try {\n    // If no access is granted, listTables will hang.  Detect this condition with\n    // a timeout.\n    const timeout = setTimeout(() => problem(new Error(\"cannot connect\")), 1000);\n    const tables = await grist.docApi.listTables();\n    clearTimeout(timeout);\n    // Iterate through user tables.\n    for (const tableId of tables) {\n      // Read table content.\n      const data = await grist.docApi.fetchTable(tableId);\n      const ids = data.id;\n      // Prepare to zap all columns except id and manualSort.\n      delete data.id;\n      delete data.manualSort;\n      for (const key of Object.keys(data)) {\n        const column = data[key];\n        for (let i = 0; i < ids.length; i++) {\n          column[i] = \"zap\";\n        }\n        // Zap columns one by one since if they are a formula column they will fail.\n        await grist.docApi.applyUserActions([[\n          \"BulkUpdateRecord\",\n          tableId,\n          ids,\n          {[key]: column},\n        ]]).catch(problem);\n      }\n    }\n  } catch(err) {\n    problem(err);\n  }\n  if (failures === 0) {\n    document.getElementById(\"placeholder\").innerHTML = \"zap succeeded\";\n  }\n}\n\nwindow.onload = zap;\n"
  },
  {
    "path": "test/fixtures/uploads/CCTransactions.csv",
    "content": "2015-01-12,,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-1745.53,,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,,320150120569599421,\n2015-01-17,,MYPIZZA.COM*MYPIZZA STATEN ISLA NY,Howard Washington,XXXX-XXXXXX-43003,382.06,,\" Additional Information: 888-974-9928 \n  Description \n  MYPIZZA COM \n \",MYPIZZA.COM - E COMMERCE,,\"97 NEW DORP PLZ N\nSTATEN ISLAND\nNY\",\"10306-2903\nUNITED STATES OF AMERICA (THE)\",320150170634561830,Restaurant-Bar & Café\n2015-01-20,,AMTRAK TELEPHONE SALWASHINGTON DC,Nyssa O'Neil,XXXX-XXXXXX-41122,4011,,\" Additional Information: Ticket Number: 0201083059570 \n  1 (800) 872-7245 \n \",AMTRAK TELEPHONE SALE,,\"60 MASSACHUSETTS AVE NE\nWASHINGTON\nDC\",\"20002\nUNITED STATES OF AMERICA (THE)\",320150210698966825,Transportation-Rail Services\n2015-01-21,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320150210695238055,Merchandise & Supplies-Internet Purchase\n2015-01-31,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-19.02,,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,,320150310857958321,Fees & Adjustments-Fees & Adjustments\n2015-02-03,,MAILCHIMP MAILCHIMP.COM GA,Callum Wilson,XXXX-XXXXXX-42021,212.5,,\" Additional Information: EMAIL MKTG \n \",MAILCHIMP,,\"512 MEANS ST NW\nSTE 404\nATLANTA\nGA\",\"30318-5788\nUNITED STATES OF AMERICA (THE)\",320150350916563707,Other-Miscellaneous\n2015-02-06,,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,402.04,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320150400993249691,Restaurant-Restaurant\n2015-02-12,,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-4462.48,,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,,320150430042521523,\n2015-02-13,,USPS 354395021106656KINGSTON NY,Callum Wilson,XXXX-XXXXXX-42021,147,,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE,,\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\",\"10610-1000\nUNITED STATES OF AMERICA (THE)\",320150450074080962,Business Services-Mailing & Shipping\n2015-02-17,,ARTCO`S COPY HUT 845-339-2336,Callum Wilson,XXXX-XXXXXX-42021,267.5,,\" Additional Information: 845-339-2336 \n \",ARTCOS COPY HUT,,\"508 ALBANY AVE\nKINGSTON\nNY\",\"12401-2131\nUNITED STATES OF AMERICA (THE)\",320150490121864816,Business Services-Printing & Publishing\n2015-02-20,,CAJUN & GRILL 650000BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,9.08,,\" Additional Information: 6174398687 \n \",CAJUN & GRILL,,\"630 ATLANTIC AVE\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150520181047308,Restaurant-Restaurant\n2015-02-20,,CAJUN & GRILL 650000BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,9.08,,\" Additional Information: 6174398687 \n \",CAJUN & GRILL,,\"630 ATLANTIC AVE\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150520181504626,Restaurant-Restaurant\n2015-02-20,,MCDONALD'S F11729 00BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,7.27,,\" Additional Information: 6173549027 \n \",MC DONALD'S,,\"2 SOUTH STA\nFL 5\nBOSTON\nMA\",\"02110-2288\nUNITED STATES OF AMERICA (THE)\",320150520174787823,Restaurant-Bar & Café\n2015-02-20,,PINKBERRY 165 BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,10.5,,\" Additional Information: FAST FOOD RESTAURANT \n  FOOD/BEVERAGE  $10.50 \n \",PINKBERRY,,\"700 ATLANTIC AVE, STE105\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150520176459450,Restaurant-Bar & Café\n2015-02-20,,PIZZERIA REGINA SO 5BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,15.61,,\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $15.61 \n \",PIZZERIA REGINA AT SOUTH,,\"2 SOUTH STA\nBOSTON\nMA\",\"02110-2208\nUNITED STATES OF AMERICA (THE)\",320150520176216796,Restaurant-Bar & Café\n2015-02-20,,PIZZERIA REGINA SO 5BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,44.8,,\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $44.80 \n \",PIZZERIA REGINA AT SOUTH,,\"2 SOUTH STA\nBOSTON\nMA\",\"02110-2208\nUNITED STATES OF AMERICA (THE)\",320150520176217729,Restaurant-Bar & Café\n2015-02-20,,SUBWAY SOUTH STATN 0BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,10,,\" Additional Information: 6172223200 \n  Description  Price \n  COMMUTER TRANS.  $10.00 \n \",COUMMUTER RAIL N STATION,,\"10 PARK PLZ\nSTE 1\nBOSTON\nMA\",\"02116-3977\nUNITED STATES OF AMERICA (THE)\",320150530195010108,Transportation-Rail Services\n2015-02-20,,SUBWAY SOUTH STATN 0BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,40,,\" Additional Information: 6172223200 \n  Description  Price \n  COMMUTER TRANS.  $40.00 \n \",COUMMUTER RAIL N STATION,,\"10 PARK PLZ\nSTE 1\nBOSTON\nMA\",\"02116-3977\nUNITED STATES OF AMERICA (THE)\",320150530193534594,Transportation-Rail Services\n2015-02-21,,COSI - #205 BOSTON MA,Nyssa O'Neil,XXXX-XXXXXX-41122,144.6,,\" Additional Information: 6179519999 \n  FOOD/BEVERAGE  $122.60 \n  TIP  $22.00 \n \",COSI #205 SOUTH STATION,,\"2 SOUTH STATION STE #182\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150530194145455,Restaurant-Restaurant\n2015-02-21,,CVS/PHARMACY #10174 BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,3.33,,\" Additional Information: 8007467287 \n  Description  Price \n  PHARMACIES  $3.33 \n \",CVS/PHARMACY #10174,,\"650 ATLANTIC AVE\nBOSTON\nMA\",\"02110\nUNITED STATES OF AMERICA (THE)\",320150530191956222,Merchandise & Supplies-Pharmacies\n2015-02-21,,DUNKIN #300223 QCAMBRIDGE MA,Nyssa O'Neil,XXXX-XXXXXX-41122,187.68,,\" Additional Information: 617-354-8944 \n \",DUNKIN' DONUTS,,\"616 MASSACHUSETTS AVE\nCAMBRIDGE\nMA\",\"02139-3307\nUNITED STATES OF AMERICA (THE)\",320150540208503704,Restaurant-Bar & Café\n2015-02-21,,PIZZERIA REGINA SO 5BOSTON MA,Leandra Miles,XXXX-XXXXXX-41114,115.91,,\" Additional Information: 6172616600 \n  FOOD/BEVERAGE  $115.91 \n \",PIZZERIA REGINA AT SOUTH,,\"2 SOUTH STA\nBOSTON\nMA\",\"02110-2208\nUNITED STATES OF AMERICA (THE)\",320150530188890159,Restaurant-Bar & Café\n2015-02-22,,LEMERIDIEN CAMBRIDGECAMBRIDGE MA,Nyssa O'Neil,XXXX-XXXXXX-41122,1516.77,,\" Arrival Date  Departure Date \n  02/20/15  02/21/15 \n  00000000 \n  LODGING \n \",LE MERIDIEN HOTEL,,\"20 SIDNEY ST\nCAMBRIDGE\nMA\",\"02139-4122\nUNITED STATES OF AMERICA (THE)\",320150540207459315,Travel-Lodging\n2015-02-23,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,81.66,,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320150540197243171,Merchandise & Supplies-Internet Purchase\n2015-02-27,,PIZZA MERCATO NEW YORK NY,Nyssa O'Neil,XXXX-XXXXXX-41122,382.04,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320150590274046170,Restaurant-Restaurant\n2015-03-01,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-44.7,,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,,320150600302988147,Fees & Adjustments-Fees & Adjustments\n2015-03-05,,STAPLES 00242 (800)333-3330,Callum Wilson,XXXX-XXXXXX-42021,72.1,,\" Additional Information: 00242000106701 12401 \n  SPLS 8.5X11 MULTI 20/96 RM \n  AVY LSR LBL 3000PK 1X2 5/8 \n  CLASP ENV BRN KRAFT 9X12 -100 \n \",STAPLES,,\"1399 ULSTER AVE\nKINGSTON\nNY\",\"12401\nUNITED STATES OF AMERICA (THE)\",320150650374451756,Business Services-Office Supplies\n2015-03-06,,ARTCO`S COPY HUT 845-339-2336,Callum Wilson,XXXX-XXXXXX-42021,58.8,,\" Additional Information: 845-339-2336 \n \",ARTCOS COPY HUT,,\"508 ALBANY AVE\nKINGSTON\nNY\",\"12401-2131\nUNITED STATES OF AMERICA (THE)\",320150680424371140,Business Services-Printing & Publishing\n2015-03-11,,USPS 359605000800484NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,9.8,,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE-NEW YORK CITY,,\"421 8TH AVE\nRM 3007\nNEW YORK\nNY\",\"10199-1003\nUNITED STATES OF AMERICA (THE)\",320150710470965085,Business Services-Mailing & Shipping\n2015-03-12,,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-3206.31,,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,,320150710473227346,\n2015-03-19,,HP HOME STORE 888-345-5409 CA,Vera O'Connor,XXXX-XXXXXX-41106,46.51,,\" Additional Information: COMPUTER \n \",HPSHOPPING.COM,,\"SVP01 4TH FLOOR MS 3541\n950 MAUDE AVENUE\nSUNNYVALE\nCA\",\"94085\nUNITED STATES OF AMERICA (THE)\",320150790598271773,Merchandise & Supplies-Computer Supplies\n2015-03-20,,FAMOUS FAMIGLIA PI 5NEW YORK NY,Nyssa O'Neil,XXXX-XXXXXX-41122,287.1,,\" Additional Information: 2129969797 \n  FOOD/BEVERAGE  $287.10 \n \",FAMOUS FAMIGLIA PIZZERIA,,\"1398 MADISON AVE\nNEW YORK\nNY\",\"10029-6903\nUNITED STATES OF AMERICA (THE)\",320150800614157648,Restaurant-Restaurant\n2015-03-23,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,81.66,,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320150820630530881,Merchandise & Supplies-Internet Purchase\n2015-03-27,,5% OPEN Savings at HP,Vera O'Connor,XXXX-XXXXXX-41106,-2.33,,\" Additional Information: SEE SUMMARY GRID FOR MORE INFORMATION \n \",HPSHOPPING.COM,,\"SVP01 4TH FLOOR MS 3541\n950 MAUDE AVENUE\nSUNNYVALE\nCA\",\"94085\nUNITED STATES OF AMERICA (THE)\",320150860708183861,Merchandise & Supplies-Computer Supplies\n2015-03-30,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-32.28,,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,,320150890754246198,Fees & Adjustments-Fees & Adjustments\n2015-04-11,,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-890.98,,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,,320151010946509246,\n2015-04-17,,INKWELL GLOBAL MARKEMANALAPAN NJ,Howard Washington,XXXX-XXXXXX-43003,1241,,\" Additional Information: 7325362822 \n \",INKWELL GLOBAL MARKETING,,\"600 MADISON AVE\nMANALAPAN\nNJ\",\"07726-9594\nUNITED STATES OF AMERICA (THE)\",320151080049401834,Merchandise & Supplies-Mail Order\n2015-04-21,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,83.83,,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320151110091111545,Merchandise & Supplies-Internet Purchase\n2015-04-23,,CAFE AMORE'S PIZZERINEW YORK NY,Leandra Miles,XXXX-XXXXXX-41114,442.98,,\" Additional Information: 212-619-0802 \n  Description \n  FOOD/BEVERAGE \n \",CAFE AMORE'S PIZZERIA,,\"147 CHAMBERS ST\nNEW YORK\nNY\",\"10007\nUNITED STATES OF AMERICA (THE)\",320151140150597007,Restaurant-Restaurant\n2015-04-25,,DUANE READE #14247 0NEW YORK NY,Clare Dudley,XXXX-XXXXXX-41098,2.17,,\" Additional Information: 8002892273 \n  Description \n  REFER TO RECEIPT \n \",WALGREEN,,\"4 W 4TH ST\nNEW YORK\nNY\",\"10012-1168\nUNITED STATES OF AMERICA (THE)\",320151160181404910,Merchandise & Supplies-Pharmacies\n2015-05-01,,WICHCRAFT BRYANT PARNEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,453.6,,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151210250625154,Restaurant-Bar & Café\n2015-05-01,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-12.26,,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,,320151210265694636,Fees & Adjustments-Fees & Adjustments\n2015-05-02,,DUNKIN #346983 QNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,17.41,,\" Additional Information: 212-375-9999 \n \",DUNKIN' DONUTS,,\"72 W 3RD ST\nNEW YORK\nNY\",\"10012-1026\nUNITED STATES OF AMERICA (THE)\",320151230291502060,Restaurant-Bar & Café\n2015-05-02,,DUNKIN #346983 QNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,22.9,,\" Additional Information: 212-375-9999 \n \",DUNKIN' DONUTS,,\"72 W 3RD ST\nNEW YORK\nNY\",\"10012-1026\nUNITED STATES OF AMERICA (THE)\",320151230292167646,Restaurant-Bar & Café\n2015-05-02,,STAPLES 01106 (800)333-3330,Callum Wilson,XXXX-XXXXXX-42021,2.16,,\" Additional Information: 01106000109206 10003 \n  CRA-Z-ART WHITE CHALK 16 CT \n \",STAPLES,,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151230292173007,Business Services-Office Supplies\n2015-05-02,,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,33.91,,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151230294133437,Merchandise & Supplies-Groceries\n2015-05-08,,WICHCRAFT BRYANT PARNEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,384.93,,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151280366691479,Restaurant-Bar & Café\n2015-05-09,,STAPLES 00193 (800)333-3330,Vera O'Connor,XXXX-XXXXXX-41106,10.43,,\" Additional Information: 00193000224345 10007 \n  9X12 CLEAR CLASP ENV 10PK \n \",STAPLES,,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320151300408500603,Business Services-Office Supplies\n2015-05-09,,STAPLES 00193 (800)333-3330,Vera O'Connor,XXXX-XXXXXX-41106,10.58,,\" Additional Information: 00193002523005 10007 \n  BW SS P@SS LTR/LGL \n \",STAPLES,,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320151300407029361,Business Services-Office Supplies\n2015-05-09,,STAPLES 00193 (800)333-3330,Vera O'Connor,XXXX-XXXXXX-41106,37.1,,\" Additional Information: 00193002523001 10007 \n  BW SS P@SS LTR/LGL \n \",STAPLES,,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320151300408517031,Business Services-Office Supplies\n2015-05-09,,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,38.2,,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151300405656534,Merchandise & Supplies-Groceries\n2015-05-12,,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-1737.7,,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,,320151320445840058,\n2015-05-16,,NORTH SQUARE RESTAURNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,114,,\" Additional Information: 2122541200 \n \",NORTH SQUARE RESTAURANT & LOUNGE,,\"103 WAVERLY PL\nNEW YORK\nNY\",\"10011-9110\nUNITED STATES OF AMERICA (THE)\",320151370527011129,Restaurant-Restaurant\n2015-05-16,,NORTH SQUARE RESTAURNEW YORK NY,Callum Wilson,XXXX-XXXXXX-42021,612,,\" Additional Information: 2122541200 \n \",NORTH SQUARE RESTAURANT & LOUNGE,,\"103 WAVERLY PL\nNEW YORK\nNY\",\"10011-9110\nUNITED STATES OF AMERICA (THE)\",320151370525443159,Restaurant-Restaurant\n2015-05-21,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,83.83,,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320151410579383246,Merchandise & Supplies-Internet Purchase\n2015-05-21,,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,383.59,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151430627016798,Restaurant-Restaurant\n2015-05-29,,GRISTEDES # 508 5429NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,82.05,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $82.05 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151500729902113,Merchandise & Supplies-Groceries\n2015-05-29,,PANERA BREAD #601723NEW YORK NY,Nyssa O'Neil,XXXX-XXXXXX-41122,1142.37,,\" Additional Information: 9999999999 \n \",PANERA BREAD CAFE INSTORE,,\"330 FIFTH AVE\n-\nNEW YORK\nNY\",\"10001\nUNITED STATES OF AMERICA (THE)\",320151500733586616,Restaurant-Bar & Café\n2015-05-29,,PENN STATER CONF CTRSTATE COLLEGE PA,Nyssa O'Neil,XXXX-XXXXXX-41122,3598.48,,\" Additional Information: 814-865-8500 \n \",PENN STATER CONF CNTR HTL,,\"215 INNOVATION BLVD\nSTATE COLLEGE\nPA\",\"16803-6603\nUNITED STATES OF AMERICA (THE)\",320151500729234751,Travel-Lodging\n2015-05-29,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-17.7,,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,,320151490722848778,Fees & Adjustments-Fees & Adjustments\n2015-05-30,,BURGER KING #8697 00BLOOMSBURG PA,Leandra Miles,XXXX-XXXXXX-41114,196.21,,\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \",BURGER KING,,\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\",\"17815-8357\nUNITED STATES OF AMERICA (THE)\",320151510750551934,Restaurant-Bar & Café\n2015-05-30,,BURGER KING #8697 00BLOOMSBURG PA,Leandra Miles,XXXX-XXXXXX-41114,216.29,,\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \",BURGER KING,,\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\",\"17815-8357\nUNITED STATES OF AMERICA (THE)\",320151510748702755,Restaurant-Bar & Café\n2015-05-30,,BURGER KING #8697 00BLOOMSBURG PA,Leandra Miles,XXXX-XXXXXX-41114,480.72,,\" Additional Information: 570-387-6260 \n  Description \n  FAST FOOD RESTAURAN \n \",BURGER KING,,\"191 COLUMBIA MALL DR\nBLOOMSBURG\nPA\",\"17815-8357\nUNITED STATES OF AMERICA (THE)\",320151510749938819,Restaurant-Bar & Café\n2015-05-31,,HILTON GARDEN INN 12STATE COLLEGE PA,Nyssa O'Neil,XXXX-XXXXXX-41122,111.76,,\" Arrival Date  Departure Date \n  05/29/15  05/30/15 \n  00000000 \n  LODGING \n \",HILTON GARDEN INN,,\"1221 EAST COLLEGE AVENUE\nSTATE COLLEGE\nPA\",\"16801\nUNITED STATES OF AMERICA (THE)\",320151510740498966,Travel-Lodging\n2015-05-31,,HILTON GARDEN INN 12STATE COLLEGE PA,Nyssa O'Neil,XXXX-XXXXXX-41122,111.76,,\" Arrival Date  Departure Date \n  05/29/15  05/30/15 \n  00000000 \n  LODGING \n \",HILTON GARDEN INN,,\"1221 EAST COLLEGE AVENUE\nSTATE COLLEGE\nPA\",\"16801\nUNITED STATES OF AMERICA (THE)\",320151510740707763,Travel-Lodging\n2015-06-08,,CUSTOMINK TSHIRTS 03FAIRFAX VA,Clare Dudley,XXXX-XXXXXX-41098,920.68,,\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \",CUSTOMINK LLC,,\"2910 DISTRICT AVE\nFAIRFAX\nVA\",\"22031\nUNITED STATES OF AMERICA (THE)\",320151600896830010,Merchandise & Supplies-Mail Order\n2015-06-12,,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-2192.38,,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,,320151630951557213,\n2015-06-15,,CUSTOMINK TSHIRTS FAIRFAX VA,Clare Dudley,XXXX-XXXXXX-41098,-25,,\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \",CUSTOMINK LLC,,\"2910 DISTRICT AVE\nFAIRFAX\nVA\",\"22031\nUNITED STATES OF AMERICA (THE)\",320151670011681934,Merchandise & Supplies-Mail Order\n2015-06-15,,CUSTOMINK TSHIRTS 03FAIRFAX VA,Clare Dudley,XXXX-XXXXXX-41098,33.8,,\" Additional Information: 800-293-4232 \n  Description \n  APPAREL/ACCESSORIES \n \",CUSTOMINK LLC,,\"2910 DISTRICT AVE\nFAIRFAX\nVA\",\"22031\nUNITED STATES OF AMERICA (THE)\",320151670013120604,Merchandise & Supplies-Mail Order\n2015-06-19,,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,328.86,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151730114686576,Restaurant-Restaurant\n2015-06-22,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,83.83,,\" Additional Information: PAYROLL SVC \n \",PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320151730103193144,Merchandise & Supplies-Internet Purchase\n2015-06-24,,USPS 354395021106656KINGSTON NY,Callum Wilson,XXXX-XXXXXX-42021,5.95,,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE,,\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\",\"10610-1000\nUNITED STATES OF AMERICA (THE)\",320151760163504192,Business Services-Mailing & Shipping\n2015-06-28,,Credit Adjustment for Billing Inquiry,Howard Washington,XXXX-XXXXXX-43003,-50,,,,,,,320151793201385681,Fees & Adjustments-Fees & Adjustments\n2015-06-28,,TWO BOOTS PIZZA NEW YORK NY,Clare Dudley,XXXX-XXXXXX-41098,102.25,,\" Additional Information: 212-777-2668 \n  Description \n  FOOD/BEVERAGE \n \",TWO BOOTS TO GO WEST,,\"201 W 11TH ST\nNEW YORK\nNY\",\"10014\nUNITED STATES OF AMERICA (THE)\",320151800225150895,Restaurant-Restaurant\n2015-06-28,,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,3.25,,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151800223528639,Merchandise & Supplies-Groceries\n2015-06-28,,WHOLEFDS TRB 10245 0NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,29.82,,\" Additional Information: 2123496555 \n  Description  Price \n  GROCERY STORES  $29.82 \n \",WHOLE FOODS MARKET,,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320151800223964992,Merchandise & Supplies-Groceries\n2015-06-29,,GRISTEDES # 508 5429NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,78.03,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $78.03 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151810242916035,Merchandise & Supplies-Groceries\n2015-06-29,,MAOZ VEGETARIAN - 8TNEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,12.52,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151810238061322,Restaurant-Bar & Café\n2015-06-29,,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,502.25,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151810247097284,Restaurant-Restaurant\n2015-06-29,,STAPLES 01232 (800)333-3330,Clare Dudley,XXXX-XXXXXX-41098,6.85,,\" Additional Information: 01232000706107 11230 \n  NAME BDG BLUE BORDER LBL \n \",STAPLES,,\"1880 CONEY ISLAND AVE\nBROOKLYN\nNY\",\"11230\nUNITED STATES OF AMERICA (THE)\",320151810242451340,Business Services-Office Supplies\n2015-06-29,,WICHCRAFT BRYANT PARNEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,1143.76,,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151800216664401,Restaurant-Bar & Café\n2015-06-29,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-24.47,,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,,320151800229937629,Fees & Adjustments-Fees & Adjustments\n2015-06-30,,GRISTEDES # 508 5429NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,118.97,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $118.97 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151820256966499,Merchandise & Supplies-Groceries\n2015-06-30,,MAOZ VEGETARIAN - 8TNEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,14.7,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151820255845813,Restaurant-Bar & Café\n2015-06-30,,SUBWAY 999912MIAMI FL,Howard Washington,XXXX-XXXXXX-43003,813.84,,\" Additional Information: 305-6700041 \n \",SUBWAY,,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320151820254568483,Restaurant-Bar & Café\n2015-07-01,,CVS/PHARMACY #08900 8007467287,Maris Burton,XXXX-XXXXXX-43060,31.7,,\" Additional Information: 8007467287 \n  PHARMACIES \n \",CVS PHARMACY,,\"20 UNIVERSITY PL\nNEW YORK\nNY\",\"10003-4530\nUNITED STATES OF AMERICA (THE)\",320151830277898080,Merchandise & Supplies-Pharmacies\n2015-07-01,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,69.09,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $69.09 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151830273619758,Merchandise & Supplies-Groceries\n2015-07-01,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,14.7,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151830273492010,Restaurant-Bar & Café\n2015-07-01,,PIZZA MERCATO NEW YORK NY,Howard Washington,XXXX-XXXXXX-43003,649.25,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151840296360778,Restaurant-Restaurant\n2015-07-01,,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,11.97,,\" Additional Information: 01106000121928 10003 \n  POSTERBOARD 22X28 FLUR ASST 5 \n  SCOTCH INVISIBLE TAPE 3/4X300 \n \",STAPLES,,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151830278394728,Business Services-Office Supplies\n2015-07-02,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,4.56,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $4.56 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151840293289242,Merchandise & Supplies-Groceries\n2015-07-02,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,63.82,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $63.82 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151840293808568,Merchandise & Supplies-Groceries\n2015-07-02,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151840291994605,Restaurant-Bar & Café\n2015-07-02,,WICHCRAFT BRYANT PARNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,1215.24,,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151830283404245,Restaurant-Bar & Café\n2015-07-06,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.82,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $7.82 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151880352939553,Merchandise & Supplies-Groceries\n2015-07-06,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,104.06,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $104.06 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151880351787873,Merchandise & Supplies-Groceries\n2015-07-06,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151880350844359,Restaurant-Bar & Café\n2015-07-06,,ONLINE PAYMENT - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-7220.06,,,,,,,320151870342051089,\n2015-07-06,,PIZZA MERCATO NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,664.69,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PLACE\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151880360068571,Restaurant-Restaurant\n2015-07-07,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,66.06,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $66.06 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151890369426433,Merchandise & Supplies-Groceries\n2015-07-07,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,14.7,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151890367176935,Restaurant-Bar & Café\n2015-07-07,,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,4.34,,\" Additional Information: 01798000228531 10011 \n  3TAB FILE FLDR LBL \n \",STAPLES,,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151890371868920,Business Services-Office Supplies\n2015-07-07,,SUBWAY 999912MIAMI FL,Maris Burton,XXXX-XXXXXX-43060,813.84,,\" Additional Information: 305-6700041 \n \",SUBWAY,,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320151890365608242,Restaurant-Bar & Café\n2015-07-08,,CHIPOTLE 0590 0094 NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,1357.5,,\" Additional Information: 212-982-3081 \n  Description \n  FAST FOOD RESTAURAN \n \",CHIPOTLE,,\"55C EAST 8TH ST\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151900389776137,Restaurant-Bar & Café\n2015-07-08,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,8.69,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $8.69 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151900384374156,Merchandise & Supplies-Groceries\n2015-07-08,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,72.26,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $72.26 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151900384651040,Merchandise & Supplies-Groceries\n2015-07-08,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,6.61,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151900383693086,Restaurant-Bar & Café\n2015-07-09,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,61.25,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $61.25 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151910403911324,Merchandise & Supplies-Groceries\n2015-07-09,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151910401557217,Restaurant-Bar & Café\n2015-07-09,,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,148.97,,\" Additional Information: 01106000511857 10003 \n  PASTELS 8.5X11 GREEN PAPER RM \n  PASTELS 8.5X11 SALMON PAPER RM \n  PASTELS 8.5X11 BLUE PAPER RM \n \",STAPLES,,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151910406467408,Business Services-Office Supplies\n2015-07-10,,DUANE READE #14247 0NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,4.99,,\" Additional Information: 8002892273 \n  Description \n  REFER TO RECEIPT \n \",WALGREEN,,\"4 W 4TH ST\nNEW YORK\nNY\",\"10012-1168\nUNITED STATES OF AMERICA (THE)\",320151920416442525,Merchandise & Supplies-Pharmacies\n2015-07-10,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,106.79,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $106.79 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151920418500382,Merchandise & Supplies-Groceries\n2015-07-10,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,836,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151920425504018,Restaurant-Bar & Café\n2015-07-10,,WICHCRAFT BRYANT PARNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,1320,,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151910396270471,Restaurant-Bar & Café\n2015-07-11,,USPS 354395021106656KINGSTON NY,Callum Wilson,XXXX-XXXXXX-42021,17.34,,\" Additional Information: 800-2758777 \n \",US POSTAL SERVICE,,\"1000 WESTCHESTER AVE\nWHITE PLAINS\nNY\",\"10610-1000\nUNITED STATES OF AMERICA (THE)\",320151930433305676,Business Services-Mailing & Shipping\n2015-07-13,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,113.97,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $113.97 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151950464750950,Merchandise & Supplies-Groceries\n2015-07-13,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,14.7,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151950464236262,Restaurant-Bar & Café\n2015-07-13,,PIZZA MERCATO NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,618.34,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320151950473655224,Restaurant-Restaurant\n2015-07-14,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,72.49,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $72.49 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151960484410879,Merchandise & Supplies-Groceries\n2015-07-14,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,7.35,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151960482805860,Restaurant-Bar & Café\n2015-07-14,,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,-4.56,,\" Additional Information: 01106000712254 10003 \n  3TAB FILE FLDR LBL \n  POSTERBOARD 22X28 FLUR ASST 5 \n  GRTNR CERT 8.5X11 BLUE/SLV 100 \n \",STAPLES,,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151960486379349,Business Services-Office Supplies\n2015-07-14,,SUBWAY 999912MIAMI FL,Maris Burton,XXXX-XXXXXX-43060,747.5,,\" Additional Information: 305-6700041 \n \",SUBWAY,,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320151960480941768,Restaurant-Bar & Café\n2015-07-15,,CUBA 88430123896 NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,340,,\" Additional Information: 212-420-7878 \n \",CUBA,,\"222 THOMPSON ST\nNEW YORK\nNY\",\"10012-1363\nUNITED STATES OF AMERICA (THE)\",320151970498730613,Restaurant-Restaurant\n2015-07-15,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,13.03,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $13.03 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151970502025423,Merchandise & Supplies-Groceries\n2015-07-15,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,69.36,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $69.36 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151970500708696,Merchandise & Supplies-Groceries\n2015-07-15,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,753.22,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151970499604816,Restaurant-Bar & Café\n2015-07-15,,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,9.03,,\" Additional Information: 01106002530465 10003 \n  COMPUTER RENTAL \n  CW BW PRNT \n  BW SS P@SS LTR/LGL \n \",STAPLES,,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151970501140377,Business Services-Office Supplies\n2015-07-16,,CUBA 88430123896 NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,536,,\" Additional Information: 212-420-7878 \n \",CUBA,,\"222 THOMPSON ST\nNEW YORK\nNY\",\"10012-1363\nUNITED STATES OF AMERICA (THE)\",320151980516094880,Restaurant-Restaurant\n2015-07-16,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,54.81,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $54.81 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151980517736119,Merchandise & Supplies-Groceries\n2015-07-16,,MAOZ VEGETARIAN - 8TNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,12.52,,\" Additional Information: 212-420-5999 \n \",MAOZ VEGETARIAN,,\"59 E 8TH ST\nNEW YORK\nNY\",\"10003-6450\nUNITED STATES OF AMERICA (THE)\",320151980516521333,Restaurant-Bar & Café\n2015-07-16,,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,5.99,,\" Additional Information: 01798002507818 10011 \n  COMPUTER RENTAL \n  CW BW PRNT \n \",STAPLES,,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151980518721472,Business Services-Office Supplies\n2015-07-16,,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,24.91,,\" Additional Information: 01798002507807 10011 \n  BW SS P@SS LTR/LGL \n  CLR SS P@SS LTR/LGL \n \",STAPLES,,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151980516856896,Business Services-Office Supplies\n2015-07-16,,STAPLES 01798 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,93.9,,\" Additional Information: 01798000535198 10011 \n  STPLS MEMO CUBE 500CT \n  251-500 BW2 LTR STD \n  STAPLING \n \",STAPLES,,\"390 AVENUE OF THE AMERIC\nNEW YORK\nNY\",\"10011-8415\nUNITED STATES OF AMERICA (THE)\",320151980519021999,Business Services-Office Supplies\n2015-07-16,,VISTAPR*VISTAPRINT.C866 893 6743 CA,Vera O'Connor,XXXX-XXXXXX-41106,5.44,,\" Additional Information: 866-614-8002 \n \",WWW.VISTAPRINT.COM,,\"95 HAYDEN AVE\nLEXINGTON\nMA\",\"02421-7942\nUNITED STATES OF AMERICA (THE)\",320151980521681765,Merchandise & Supplies-Internet Purchase\n2015-07-16,,WICHCRAFT BRYANT PARNEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,990,,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320151970493780060,Restaurant-Bar & Café\n2015-07-17,,GRISTEDES # 508 5429NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,74.03,,\" Additional Information: 2123533672 \n  Description  Price \n  GROCERY STORES, SUP  $74.03 \n \",GRISTEDES,,\"246 MERCER ST\nNEW YORK\nNY\",\"10012-1111\nUNITED STATES OF AMERICA (THE)\",320151990534884422,Merchandise & Supplies-Groceries\n2015-07-17,,PIZZA MERCATO NEW YORK NY,Maris Burton,XXXX-XXXXXX-43060,204.54,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152010569229393,Restaurant-Restaurant\n2015-07-17,,SACRED CHOW 212-337-0863,Maris Burton,XXXX-XXXXXX-43060,16.15,,\" Additional Information: 212-337-0863 \n \",SACRED CHOW,,\"227 SULLIVAN ST\nFRNT 1\nNEW YORK\nNY\",\"10012-4803\nUNITED STATES OF AMERICA (THE)\",320151990541493366,Restaurant-Restaurant\n2015-07-17,,STAPLES 01106 (800)333-3330,Maris Burton,XXXX-XXXXXX-43060,54.98,,\" Additional Information: 01106000512518 10003 \n  STAPLING \n  1-100 BW 32LB ULTRA PREM \n  1-100 BW2 32LB ULTRA PREM \n \",STAPLES,,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320151990538533185,Business Services-Office Supplies\n2015-07-17,,VISTAPR*VISTAPRINT.C866 893 6743 CA,Vera O'Connor,XXXX-XXXXXX-41106,66.62,,\" Additional Information: 866-614-8002 \n \",WWW.VISTAPRINT.COM,,\"95 HAYDEN AVE\nLEXINGTON\nMA\",\"02421-7942\nUNITED STATES OF AMERICA (THE)\",320151990540257622,Merchandise & Supplies-Internet Purchase\n2015-07-18,,RICKERS #71 8831 INDIANAPOLIS IN,Howard Washington,XXXX-XXXXXX-43003,20,,\" Additional Information: 317-920-0850 \n  Description \n  Unleaded Regular \n \",BP FDMS INSIDE,,\"28100 TORCH PKWY\nWARRENVILLE\nIL\",\"60555-3938\nUNITED STATES OF AMERICA (THE)\",320151990528129463,Transportation-Fuel\n2015-07-18,,RICKERS #71 8831 INDIANAPOLIS IN,Howard Washington,XXXX-XXXXXX-43003,-20,,,BP FDMS INSIDE,,\"28100 TORCH PKWY\nWARRENVILLE\nIL\",\"60555-3938\nUNITED STATES OF AMERICA (THE)\",320152020572738126,Transportation-Fuel\n2015-07-21,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,92.54,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152030590555153,Merchandise & Supplies-Internet Purchase\n2015-07-21,,VISTAPR*VISTAPRINT.C866 893 6743 CA,Vera O'Connor,XXXX-XXXXXX-41106,-5.9,,\" Additional Information: 866-614-8002 \n \",WWW.VISTAPRINT.COM,,\"95 HAYDEN AVE\nLEXINGTON\nMA\",\"02421-7942\nUNITED STATES OF AMERICA (THE)\",320152030601233931,Merchandise & Supplies-Internet Purchase\n2015-07-29,,THE FARM ON ADDERLY BROOKLYN NY,Darren Graham,XXXX-XXXXXX-41130,45,,\" Additional Information: RESTAURANT \n \",FARM ON ADDERLY,,\"1108 CORTELYOU RD\nBROOKLYN\nNY\",\"11218-5304\nUNITED STATES OF AMERICA (THE)\",320152120748477327,Restaurant-Restaurant\n2015-07-30,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-72.88,,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,,320152110735569063,Fees & Adjustments-Fees & Adjustments\n2015-08-03,,USPS 333675955102007HOBOKEN NJ,Maris Burton,XXXX-XXXXXX-43060,5.75,,\" Additional Information: 800-2758777 \n \",USPS/HOBOKEN,,\"89 RIVER ST\nHOBOKEN\nNJ\",\"07030-9998\nUNITED STATES OF AMERICA (THE)\",320152160817163880,Business Services-Mailing & Shipping\n2015-08-08,,STAPLES 01106 (800)333-3330,Clare Dudley,XXXX-XXXXXX-41098,20.25,,\" Additional Information: 01106002531781 10003 \n  BW SS P@SS LTR/LGL \n \",STAPLES,,\"769 BROADWAY\nMANHATTAN\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320152210896145086,Business Services-Office Supplies\n2015-08-12,,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-15481.02,,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,,320152240951742758,\n2015-08-13,,WICHCRAFT BRYANT PARNEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,214.45,,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320152250967825071,Restaurant-Bar & Café\n2015-08-14,,STARBUCKS #07497 NEWNew York NY,Darius Burgess,XXXX-XXXXXX-41148,16.28,,\" Additional Information: New York \n \",STARBUCKS,,\"665 BROADWAY\nBROADWAY AND BOND\nNEW YORK\nNY\",\"10012\nUNITED STATES OF AMERICA (THE)\",320152260973467313,Restaurant-Bar & Café\n2015-08-21,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,94.72,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152340102862781,Merchandise & Supplies-Internet Purchase\n2015-08-31,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-169.34,,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,,320152430260111596,Fees & Adjustments-Fees & Adjustments\n2015-09-03,,WICHCRAFT BRYANT PARNEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,219.82,,\" Additional Information: 212-780-0577 \n  Description \n  FOOD/BEVERAGE \n \",WICHCRAFT KIOSK,,\"41 W 40TH STREET\nNEW YORK\nNY\",\"10018\nUNITED STATES OF AMERICA (THE)\",320152460298452869,Restaurant-Bar & Café\n2015-09-12,,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-323.57,,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,,320152550459055529,\n2015-09-18,,FRESH & CO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,150.95,,\" Additional Information: FAST FOOD RESTAURANT \n  Description \n  182599 \n \",FRESH & CO,,\"58 EAST 8TH ST\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320152640601966334,Restaurant-Bar & Café\n2015-09-19,,FRESH & CO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,8.66,,\" Additional Information: 2124737374 \n  FOOD/BEVERAGE  $8.66 \n \",FRESH & CO,,\"729 BROADWAY\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320152630582376228,Restaurant-Bar & Café\n2015-09-20,,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,12.49,,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320152640596948939,Merchandise & Supplies-Groceries\n2015-09-21,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,81.66,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152650604655989,Merchandise & Supplies-Internet Purchase\n2015-09-25,,PIZZA MERCATO NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,590.43,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152710713208356,Restaurant-Restaurant\n2015-09-30,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-4.77,,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,,320152730747361096,Fees & Adjustments-Fees & Adjustments\n2015-10-01,,SUBWAY MIAMI FL,Vera O'Connor,XXXX-XXXXXX-41106,117,,\" Additional Information: 305-6700041 \n \",SUBWAY,,\"9200 S DADELAND BLVD\nSTE 705\nMIAMI\nFL\",\"33156-2715\nUNITED STATES OF AMERICA (THE)\",320152750771031096,Restaurant-Bar & Café\n2015-10-03,,USPS PO BOXES 101510WASHINGTON DC,Clare Dudley,XXXX-XXXXXX-41098,156,,\" Additional Information: 800-3447779 \n \",USPS PO BOXES ONLINE,,\"475 LENFANT PLZ SW\nWASHINGTON\nDC\",\"20260-0004\nUNITED STATES OF AMERICA (THE)\",320152770808332661,Business Services-Mailing & Shipping\n2015-10-04,,WHOLEFDS TRB 10245 02123496555,Vera O'Connor,XXXX-XXXXXX-41106,13.48,,\" Additional Information: 2123496555 \n  GROCERY STORES \n \",WHOLE FOODS MARKET,,\"270 GREENWICH ST\nNEW YORK\nNY\",\"10007-1150\nUNITED STATES OF AMERICA (THE)\",320152780825369796,Merchandise & Supplies-Groceries\n2015-10-12,,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-304.24,,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,,320152850946530301,\n2015-10-15,,GROUPON INC 877-788-7858 IL,Vera O'Connor,XXXX-XXXXXX-41106,39.5,,\" Additional Information: COUPONS \n \",GROUPON INC,,\"600 W CHICAGO AVE\nSTE 400\nCHICAGO\nIL\",\"60654-2067\nUNITED STATES OF AMERICA (THE)\",320152890013469812,Merchandise & Supplies-Internet Purchase\n2015-10-16,,PIZZA MERCATO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,57.75,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152920061757486,Restaurant-Restaurant\n2015-10-16,,PIZZA MERCATO NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,774.62,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320152920062466899,Restaurant-Restaurant\n2015-10-21,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098979714,Merchandise & Supplies-Internet Purchase\n2015-10-21,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098983888,Merchandise & Supplies-Internet Purchase\n2015-10-21,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098983890,Merchandise & Supplies-Internet Purchase\n2015-10-21,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152950098983894,Merchandise & Supplies-Internet Purchase\n2015-10-22,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,77.3,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152960115550127,Merchandise & Supplies-Internet Purchase\n2015-10-23,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133427869,Merchandise & Supplies-Internet Purchase\n2015-10-23,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133427871,Merchandise & Supplies-Internet Purchase\n2015-10-23,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133427873,Merchandise & Supplies-Internet Purchase\n2015-10-23,,INTUIT PAYROLL 888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,-77.3,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320152970133464913,Merchandise & Supplies-Internet Purchase\n2015-10-27,,\"MES*RINGCENTRAL, INC6504724100\",Vera O'Connor,XXXX-XXXXXX-41106,160.56,,\" Additional Information: 3719004008 94002 \n \",\"MES*RINGCENTRAL, INC\",,\"1400 FASHION IS\nSAN MATEO\nCA\",\"944\nUNITED STATES OF AMERICA (THE)\",320153010200395072,Communications-Mobile Telecom\n2015-10-30,,FRESH & CO NEW YORK,Vera O'Connor,XXXX-XXXXXX-41106,137.89,,\" Additional Information: FAST FOOD RESTAURANT \n  Description \n  105071 \n \",FRESH & CO,,\"58 EAST 8TH ST\nNEW YORK\nNY\",\"10003\nUNITED STATES OF AMERICA (THE)\",320153060292051275,Restaurant-Bar & Café\n2015-10-30,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-4.74,,\" AMERICAN EXPRESS CASH REBATE TRANSACTION \n \",,,,,320153030245166855,Fees & Adjustments-Fees & Adjustments\n2015-11-12,,AUTOPAY PAYMENT RECEIVED - THANK YOU,Howard Washington,XXXX-XXXXXX-43003,-1981.87,,\" TD BANK, NATIONAL ASSOCIATION \n \",,,,,320153160462707016,\n2015-11-13,,PIZZA MERCATO NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,774.62,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320153200524725369,Restaurant-Restaurant\n2015-11-15,,PIZZA MERCATO NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,93.8,,\" Additional Information: 212-420-8432 \n \",PIZZA MERCATO,,\"11 WAVERLY PL\nNEW YORK\nNY\",\"10003-6722\nUNITED STATES OF AMERICA (THE)\",320153210543933275,Restaurant-Restaurant\n2015-11-20,,NJT NY PENN STA 50NEW YORK NJ,Darius Burgess,XXXX-XXXXXX-41148,1011.75,,\" Additional Information: 973-2755555 \n \",NJ TRANSIT,,\"NEW YORK PENN STATION\n34TH & 7TH AVENUES\nNEW YORK\nNY\",\"10016\nUNITED STATES OF AMERICA (THE)\",320153250615656980,Transportation-Rail Services\n2015-11-21,,HN-DUNKIN ST212 0000NEW YORK NY,Darius Burgess,XXXX-XXXXXX-41148,43.14,,\" Additional Information: 800-326-7711 \n  Description \n  GROCERIES/SUNDRIES \n \",HUDSON NEWS,,\"8TH AVE -PENN STN TKT LVL\nNEW YORK\nNY\",\"10001\nUNITED STATES OF AMERICA (THE)\",320153260627158328,Merchandise & Supplies-Book Stores\n2015-11-21,,PJS PANCAKE HOUSE 65PRINCETON NJ,Darius Burgess,XXXX-XXXXXX-41148,127.75,,\" Additional Information: 6099241353 \n \",P J'S PANCAKE HOUSE RESTAURANT,,\"154 NASSAU ST\nPRINCETON\nNJ\",\"08542-7006\nUNITED STATES OF AMERICA (THE)\",320153260617461233,Restaurant-Restaurant\n2015-11-23,,888-537-7794 CA,Howard Washington,XXXX-XXXXXX-43003,79.48,,,PAYCYCLE INC,,\"210 PORTAGE AVE\nPALO ALTO\nCA\",\"94306-2242\nUNITED STATES OF AMERICA (THE)\",320153280650085362,Merchandise & Supplies-Internet Purchase\n2015-12-02,,STAPLES 00193 NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,3.74,,\"001006221 00193001006221 10007\n00193001006221 10007\nBW SS P@SS LTR/LGL\nCLASP ENV BRN KRAFT 9X12 -12\n\n\n\n 00193001006221 10007 \n  BW SS P@SS LTR/LGL  \n  CLASP ENV BRN KRAFT 9X12 -12  \n \",STAPLES,,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320153370819408517,Business Services-Office Supplies\n2015-12-02,,STAPLES 00193 NEW YORK NY,Vera O'Connor,XXXX-XXXXXX-41106,9.15,,\"002558833 00193002558833 10007\n00193002558833 10007\nBW SS P@SS LTR/LGL\n\n\n\n\n 00193002558833 10007 \n  BW SS P@SS LTR/LGL  \n \",STAPLES,,\"217 BROADWAY\nNEW YORK\nNY\",\"10007-2909\nUNITED STATES OF AMERICA (THE)\",320153370816757331,Business Services-Office Supplies\n2015-12-03,,YOUR CASH BACK THIS PERIOD IS,Howard Washington,XXXX-XXXXXX-43003,-19.87,,,,,,,320153390865190560,Fees & Adjustments-Fees & Adjustments\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850587329,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850900842,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851052217,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851183402,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851243871,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,148.79,,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851421525,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850586825,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850586860,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850743209,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850743367,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390850901096,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124124 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851051765,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851051982,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851052120,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851182737,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851183168,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124123 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851421352,Travel-Lodging\n2015-12-04,,CHARLES HOTEL CAMBRIDGE MA,Darius Burgess,XXXX-XXXXXX-41148,177.4,,\"124122 LODGING\nArrival Date: 20151204\nDeparture Date: 20151204\n\n Arrival Date  Departure Date \n  12/04/15  12/04/15 \n  LODGING \n \",CHARLES HOTEL,,\"1 BENNETT ST\nCAMBRIDGE\nMA\",\"02138-5707\nUNITED STATES OF AMERICA (THE)\",320153390851421393,Travel-Lodging"
  },
  {
    "path": "test/fixtures/uploads/ChartData-Sort_Test.csv",
    "content": "Group,X,Y1,Y2\nFoo,2.00,1.00,6.00\nFoo,4.00,2.50,5.00\nFoo,3.00,3.50,4.90\nFoo,5.00,4.00,7.00\nFoo,1.00,1.50,6.90\nBar,2.50,1.00,6.00\nBar,4.50,2.50,5.00\nBar,3.50,3.50,4.90\nBar,5.50,4.00,7.00\nBar,1.50,1.50,6.90\n"
  },
  {
    "path": "test/fixtures/uploads/ChartData.csv",
    "content": "label,value,largeValue,dataSet\n6,1,22,0\n5,2,33,0\n4,3,11,0\n3,4,44,1\n2,5,22,1\n1,6,55,1"
  },
  {
    "path": "test/fixtures/uploads/Cities.csv",
    "content": "city_name,city_district,city_pop,country,pop_thousands\nKabul,Kabol,3560000,2,3560\nQandahar,Qandahar,475000,2,475\nHerat,Herat,373600,2,373.6\nMazar-e-Sharif,Balkh,255600,2,255.6\nAmsterdam,Noord-Holland,1462400,159,1462.4\nRotterdam,Zuid-Holland,1186642,159,1186.642\nHaag,Zuid-Holland,881800,159,881.8\nUtrecht,Utrecht,468646,159,468.646\nEindhoven,Noord-Brabant,403686,159,403.686\nTilburg,Noord-Brabant,386476,159,386.476\nGroningen,Groningen,345402,159,345.402\nBreda,Noord-Brabant,320796,159,320.796\nApeldoorn,Gelderland,306982,159,306.982\nNijmegen,Gelderland,304926,159,304.926\nEnschede,Overijssel,299088,159,299.088\nHaarlem,Noord-Holland,297544,159,297.544\nAlmere,Flevoland,284930,159,284.93\nArnhem,Gelderland,276040,159,276.04\nZaanstad,Noord-Holland,271242,159,271.242\n´s-Hertogenbosch,Noord-Brabant,258340,159,258.34\nAmersfoort,Utrecht,252540,159,252.54\nMaastricht,Limburg,244174,159,244.174\nDordrecht,Zuid-Holland,239622,159,239.622\nLeiden,Zuid-Holland,234392,159,234.392\nHaarlemmermeer,Noord-Holland,221444,159,221.444\nZoetermeer,Zuid-Holland,220428,159,220.428\nEmmen,Drenthe,211706,159,211.706\nZwolle,Overijssel,211638,159,211.638\nEde,Gelderland,203148,159,203.148\nDelft,Zuid-Holland,190536,159,190.536\nHeerlen,Limburg,190104,159,190.104\nAlkmaar,Noord-Holland,185426,159,185.426\nWillemstad,Curaçao,4690,7,4.69\nTirana,Tirana,540000,5,540\nAlger,Alger,4336000,62,4336\nOran,Oran,1219646,62,1219.646\nConstantine,Constantine,887454,62,887.454\nAnnaba,Annaba,445036,62,445.036\nBatna,Batna,366754,62,366.754\nSétif,Sétif,358110,62,358.11\nSidi Bel Abbès,Sidi Bel Abbès,306212,62,306.212\nSkikda,Skikda,257494,62,257.494\nBiskra,Biskra,256562,62,256.562\nBlida (el-Boulaida),Blida,254568,62,254.568\nBéjaïa,Béjaïa,234324,62,234.324\nMostaganem,Mostaganem,230424,62,230.424\nTébessa,Tébessa,224014,62,224.014\nTlemcen (Tilimsen),Tlemcen,220484,62,220.484\nBéchar,Béchar,214622,62,214.622\nTiaret,Tiaret,200236,62,200.236\nEch-Chleff (el-Asnam),Chlef,193588,62,193.588\nGhardaïa,Ghardaïa,178830,62,178.83\nTafuna,Tutuila,10400,11,10.4\nFagatogo,Tutuila,4646,11,4.646\nAndorra la Vella,Andorra la Vella,42378,6,42.378\nLuanda,Luanda,4044000,3,4044\nHuambo,Huambo,326200,3,326.2\nLobito,Benguela,260000,3,260\nBenguela,Benguela,256600,3,256.6\nNamibe,Namibe,236400,3,236.4\nSouth Hill,–,1922,4,1.922\nThe Valley,–,1190,4,1.19\nSaint John´s,St John,48000,14,48\nDubai,Dubai,1338362,8,1338.362\nAbu Dhabi,Abu Dhabi,797390,8,797.39\nSharja,Sharja,640190,8,640.19\nal-Ayn,Abu Dhabi,451940,8,451.94\nAjman,Ajman,228790,8,228.79\nBuenos Aires,Distrito Federal,5964292,9,5964.292\nLa Matanza,Buenos Aires,2532922,9,2532.922\nCórdoba,Córdoba,2315014,9,2315.014\nRosario,Santa Fé,1815436,9,1815.436\nLomas de Zamora,Buenos Aires,1244026,9,1244.026\nQuilmes,Buenos Aires,1118498,9,1118.498\nAlmirante Brown,Buenos Aires,1077836,9,1077.836\nLa Plata,Buenos Aires,1043872,9,1043.872\nMar del Plata,Buenos Aires,1025760,9,1025.76\nSan Miguel de Tucumán,Tucumán,941618,9,941.618\nLanús,Buenos Aires,939470,9,939.47\nMerlo,Buenos Aires,927692,9,927.692\nGeneral San Martín,Buenos Aires,845084,9,845.084\nSalta,Salta,735100,9,735.1\nMoreno,Buenos Aires,713986,9,713.986\nSanta Fé,Santa Fé,706126,9,706.126\nAvellaneda,Buenos Aires,706092,9,706.092\nTres de Febrero,Buenos Aires,704622,9,704.622\nMorón,Buenos Aires,698492,9,698.492\nFlorencio Varela,Buenos Aires,630864,9,630.864\nSan Isidro,Buenos Aires,612682,9,612.682\nTigre,Buenos Aires,592452,9,592.452\nMalvinas Argentinas,Buenos Aires,580670,9,580.67\nVicente López,Buenos Aires,576682,9,576.682\nBerazategui,Buenos Aires,553832,9,553.832\nCorrientes,Corrientes,516206,9,516.206\nSan Miguel,Buenos Aires,497400,9,497.4\nBahía Blanca,Buenos Aires,479620,9,479.62\nEsteban Echeverría,Buenos Aires,471520,9,471.52\nResistencia,Chaco,458424,9,458.424\nJosé C. Paz,Buenos Aires,443508,9,443.508\nParaná,Entre Rios,414082,9,414.082\nGodoy Cruz,Mendoza,413996,9,413.996\nPosadas,Misiones,402546,9,402.546\nGuaymallén,Mendoza,401190,9,401.19\nSantiago del Estero,Santiago del Estero,379894,9,379.894\nSan Salvador de Jujuy,Jujuy,357496,9,357.496\nHurlingham,Buenos Aires,340056,9,340.056\nNeuquén,Neuquén,334592,9,334.592\nItuzaingó,Buenos Aires,316394,9,316.394\nSan Fernando,Buenos Aires,306072,9,306.072\nFormosa,Formosa,295272,9,295.272\nLas Heras,Mendoza,291646,9,291.646\nLa Rioja,La Rioja,276234,9,276.234\nSan Fernando del Valle de Cata,Catamarca,269870,9,269.87\nRío Cuarto,Córdoba,268710,9,268.71\nComodoro Rivadavia,Chubut,248208,9,248.208\nMendoza,Mendoza,246054,9,246.054\nSan Nicolás de los Arroyos,Buenos Aires,238604,9,238.604\nSan Juan,San Juan,238304,9,238.304\nEscobar,Buenos Aires,233350,9,233.35\nConcordia,Entre Rios,232970,9,232.97\nPilar,Buenos Aires,226856,9,226.856\nSan Luis,San Luis,220272,9,220.272\nEzeiza,Buenos Aires,199156,9,199.156\nSan Rafael,Mendoza,189302,9,189.302\nTandil,Buenos Aires,182202,9,182.202\nYerevan,Yerevan,2497400,10,2497.4\nGjumri,Širak,423400,10,423.4\nVanadzor,Lori,345400,10,345.4\nOranjestad,–,58068,1,58.068\nSydney,New South Wales,6552414,15,6552.414\nMelbourne,Victoria,5730658,15,5730.658\nBrisbane,Queensland,2582234,15,2582.234\nPerth,West Australia,2193658,15,2193.658\nAdelaide,South Australia,1956200,15,1956.2\nCanberra,Capital Region,645446,15,645.446\nGold Coast,Queensland,623864,15,623.864\nNewcastle,New South Wales,540648,15,540.648\nCentral Coast,New South Wales,455314,15,455.314\nWollongong,New South Wales,439522,15,439.522\nHobart,Tasmania,252236,15,252.236\nGeelong,Victoria,250764,15,250.764\nTownsville,Queensland,219828,15,219.828\nCairns,Queensland,184546,15,184.546\nBaku,Baki,3575600,17,3575.6\nGäncä,Gäncä,598600,17,598.6\nSumqayit,Sumqayit,566000,17,566\nMingäçevir,Mingäçevir,187800,17,187.8\nNassau,New Providence,344000,25,344\nal-Manama,al-Manama,296000,24,296\nDhaka,Dhaka,7225700,22,7225.7\nChittagong,Chittagong,2785720,22,2785.72\nKhulna,Khulna,1326680,22,1326.68\nRajshahi,Rajshahi,588112,22,588.112\nNarayanganj,Dhaka,404268,22,404.268\nRangpur,Rajshahi,382796,22,382.796\nMymensingh,Dhaka,377426,22,377.426\nBarisal,Barisal,340464,22,340.464\nTungi,Dhaka,337404,22,337.404\nJessore,Khulna,279420,22,279.42\nComilla,Chittagong,270626,22,270.626\nNawabganj,Rajshahi,261154,22,261.154\nDinajpur,Rajshahi,255630,22,255.63\nBogra,Rajshahi,240340,22,240.34\nSylhet,Sylhet,234792,22,234.792\nBrahmanbaria,Chittagong,218064,22,218.064\nTangail,Dhaka,212008,22,212.008\nJamalpur,Dhaka,207112,22,207.112\nPabna,Rajshahi,206554,22,206.554\nNaogaon,Rajshahi,202532,22,202.532\nSirajganj,Rajshahi,199338,22,199.338\nNarsinghdi,Dhaka,196684,22,196.684\nSaidpur,Rajshahi,193554,22,193.554\nGazipur,Dhaka,193434,22,193.434\nBridgetown,St Michael,12140,32,12.14\nAntwerpen,Antwerpen,893050,19,893.05\nGent,East Flanderi,448360,19,448.36\nCharleroi,Hainaut,401654,19,401.654\nLiège,Liège,371278,19,371.278\nBruxelles [Brussel],Bryssel,267718,19,267.718\nBrugge,West Flanderi,232492,19,232.492\nSchaerbeek,Bryssel,211384,19,211.384\nNamur,Namur,210838,19,210.838\nMons,Hainaut,181870,19,181.87\nBelize City,Belize City,111620,28,111.62\nBelmopan,Cayo,14210,28,14.21\nCotonou,Atlantique,1073654,20,1073.654\nPorto-Novo,Ouémé,388000,20,388\nDjougou,Atacora,268198,20,268.198\nParakou,Borgou,207154,20,207.154\nSaint George,Saint George´s,3600,29,3.6\nHamilton,Hamilton,2400,29,2.4\nThimphu,Thimphu,44000,34,44\nSanta Cruz de la Sierra,Santa Cruz,1870722,30,1870.722\nLa Paz,La Paz,1516282,30,1516.282\nEl Alto,La Paz,1068932,30,1068.932\nCochabamba,Cochabamba,965600,30,965.6\nOruro,Oruro,447106,30,447.106\nSucre,Chuquisaca,356852,30,356.852\nPotosí,Potosí,281284,30,281.284\nTarija,Tarija,250510,30,250.51\nSarajevo,Federaatio,720000,26,720\nBanja Luka,Republika Srpska,286158,26,286.158\nZenica,Federaatio,192054,26,192.054\nGaborone,Gaborone,426034,36,426.034\nFrancistown,Francistown,203610,36,203.61\nSão Paulo,São Paulo,19936970,31,19936.97\nRio de Janeiro,Rio de Janeiro,11197906,31,11197.906\nSalvador,Bahia,4605664,31,4605.664\nBelo Horizonte,Minas Gerais,4278250,31,4278.25\nFortaleza,Ceará,4195514,31,4195.514\nBrasília,Distrito Federal,3939736,31,3939.736\nCuritiba,Paraná,3168464,31,3168.464\nRecife,Pernambuco,2756174,31,2756.174\nPorto Alegre,Rio Grande do Sul,2628064,31,2628.064\nManaus,Amazonas,2510098,31,2510.098\nBelém,Pará,2373852,31,2373.852\nGuarulhos,São Paulo,2191748,31,2191.748\nGoiânia,Goiás,2112660,31,2112.66\nCampinas,São Paulo,1900086,31,1900.086\nSão Gonçalo,Rio de Janeiro,1738508,31,1738.508\nNova Iguaçu,Rio de Janeiro,1724450,31,1724.45\nSão Luís,Maranhão,1675176,31,1675.176\nMaceió,Alagoas,1572576,31,1572.576\nDuque de Caxias,Rio de Janeiro,1493516,31,1493.516\nSão Bernardo do Campo,São Paulo,1446264,31,1446.264\nTeresina,Piauí,1383884,31,1383.884\nNatal,Rio Grande do Norte,1377910,31,1377.91\nOsasco,São Paulo,1319208,31,1319.208\nCampo Grande,Mato Grosso do Sul,1299186,31,1299.186\nSanto André,São Paulo,1260146,31,1260.146\nJoão Pessoa,Paraíba,1168058,31,1168.058\nJaboatão dos Guararapes,Pernambuco,1117360,31,1117.36\nContagem,Minas Gerais,1041602,31,1041.602\nSão José dos Campos,São Paulo,1031106,31,1031.106\nUberlândia,Minas Gerais,974444,31,974.444\nFeira de Santana,Bahia,959984,31,959.984\nRibeirão Preto,São Paulo,946552,31,946.552\nSorocaba,São Paulo,933646,31,933.646\nNiterói,Rio de Janeiro,919768,31,919.768\nCuiabá,Mato Grosso,907626,31,907.626\nJuiz de Fora,Minas Gerais,900576,31,900.576\nAracaju,Sergipe,891110,31,891.11\nSão João de Meriti,Rio de Janeiro,880104,31,880.104\nLondrina,Paraná,864514,31,864.514\nJoinville,Santa Catarina,856022,31,856.022\nBelford Roxo,Rio de Janeiro,850388,31,850.388\nSantos,São Paulo,817496,31,817.496\nAnanindeua,Pará,801880,31,801.88\nCampos dos Goytacazes,Rio de Janeiro,796836,31,796.836\nMauá,São Paulo,750110,31,750.11\nCarapicuíba,São Paulo,715104,31,715.104\nOlinda,Pernambuco,709464,31,709.464\nCampina Grande,Paraíba,704994,31,704.994\nSão José do Rio Preto,São Paulo,703888,31,703.888\nCaxias do Sul,Rio Grande do Sul,699162,31,699.162\nMoji das Cruzes,São Paulo,678388,31,678.388\nDiadema,São Paulo,670156,31,670.156\nAparecida de Goiânia,Goiás,649324,31,649.324\nPiracicaba,São Paulo,638208,31,638.208\nCariacica,Espírito Santo,638066,31,638.066\nVila Velha,Espírito Santo,637516,31,637.516\nPelotas,Rio Grande do Sul,630830,31,630.83\nBauru,São Paulo,627340,31,627.34\nPorto Velho,Rondônia,619500,31,619.5\nSerra,Espírito Santo,605332,31,605.332\nBetim,Minas Gerais,604216,31,604.216\nJundíaí,São Paulo,592254,31,592.254\nCanoas,Rio Grande do Sul,588250,31,588.25\nFranca,São Paulo,580278,31,580.278\nSão Vicente,São Paulo,573696,31,573.696\nMaringá,Paraná,572922,31,572.922\nMontes Claros,Minas Gerais,572116,31,572.116\nAnápolis,Goiás,564394,31,564.394\nFlorianópolis,Santa Catarina,563856,31,563.856\nPetrópolis,Rio de Janeiro,558366,31,558.366\nItaquaquecetuba,São Paulo,541748,31,541.748\nVitória,Espírito Santo,541252,31,541.252\nPonta Grossa,Paraná,536026,31,536.026\nRio Branco,Acre,519074,31,519.074\nFoz do Iguaçu,Paraná,518850,31,518.85\nMacapá,Amapá,512066,31,512.066\nIlhéus,Bahia,509940,31,509.94\nVitória da Conquista,Bahia,507174,31,507.174\nUberaba,Minas Gerais,498450,31,498.45\nPaulista,Pernambuco,496946,31,496.946\nLimeira,São Paulo,490994,31,490.994\nBlumenau,Santa Catarina,488758,31,488.758\nCaruaru,Pernambuco,488494,31,488.494\nSantarém,Pará,483542,31,483.542\nVolta Redonda,Rio de Janeiro,480630,31,480.63\nNovo Hamburgo,Rio Grande do Sul,479880,31,479.88\nCaucaia,Ceará,477476,31,477.476\nSanta Maria,Rio Grande do Sul,476946,31,476.946\nCascavel,Paraná,475020,31,475.02\nGuarujá,São Paulo,474412,31,474.412\nRibeirão das Neves,Minas Gerais,465370,31,465.37\nGovernador Valadares,Minas Gerais,463448,31,463.448\nTaubaté,São Paulo,458260,31,458.26\nImperatriz,Maranhão,449128,31,449.128\nGravataí,Rio Grande do Sul,446022,31,446.022\nEmbu,São Paulo,444446,31,444.446\nMossoró,Rio Grande do Norte,429802,31,429.802\nVárzea Grande,Mato Grosso,428870,31,428.87\nPetrolina,Pernambuco,421080,31,421.08\nBarueri,São Paulo,416852,31,416.852\nViamão,Rio Grande do Sul,415114,31,415.114\nIpatinga,Minas Gerais,412676,31,412.676\nJuazeiro,Bahia,402146,31,402.146\nJuazeiro do Norte,Ceará,399272,31,399.272\nTaboão da Serra,São Paulo,395100,31,395.1\nSão José dos Pinhais,Paraná,393768,31,393.768\nMagé,Rio de Janeiro,392294,31,392.294\nSuzano,São Paulo,390868,31,390.868\nSão Leopoldo,Rio Grande do Sul,378516,31,378.516\nMarília,São Paulo,377382,31,377.382\nSão Carlos,São Paulo,374244,31,374.244\nSumaré,São Paulo,372410,31,372.41\nPresidente Prudente,São Paulo,370680,31,370.68\nDivinópolis,Minas Gerais,370094,31,370.094\nSete Lagoas,Minas Gerais,365968,31,365.968\nRio Grande,Rio Grande do Sul,364444,31,364.444\nItabuna,Bahia,364296,31,364.296\nJequié,Bahia,358256,31,358.256\nArapiraca,Alagoas,357976,31,357.976\nColombo,Paraná,355528,31,355.528\nAmericana,São Paulo,354818,31,354.818\nAlvorada,Rio Grande do Sul,351148,31,351.148\nAraraquara,São Paulo,348762,31,348.762\nItaboraí,Rio de Janeiro,347954,31,347.954\nSanta Bárbara d´Oeste,São Paulo,343314,31,343.314\nNova Friburgo,Rio de Janeiro,341394,31,341.394\nJacareí,São Paulo,340712,31,340.712\nAraçatuba,São Paulo,338606,31,338.606\nBarra Mansa,Rio de Janeiro,337906,31,337.906\nPraia Grande,São Paulo,336868,31,336.868\nMarabá,Pará,335590,31,335.59\nCriciúma,Santa Catarina,335322,31,335.322\nBoa Vista,Roraima,334370,31,334.37\nPasso Fundo,Rio Grande do Sul,332686,31,332.686\nDourados,Mato Grosso do Sul,329432,31,329.432\nSanta Luzia,Minas Gerais,329408,31,329.408\nRio Claro,São Paulo,327102,31,327.102\nMaracanaú,Ceará,324044,31,324.044\nGuarapuava,Paraná,321020,31,321.02\nRondonópolis,Mato Grosso,310230,31,310.23\nSão José,Santa Catarina,310210,31,310.21\nCachoeiro de Itapemirim,Espírito Santo,310048,31,310.048\nNilópolis,Rio de Janeiro,306766,31,306.766\nItapevi,São Paulo,301328,31,301.328\nCabo de Santo Agostinho,Pernambuco,299928,31,299.928\nCamaçari,Bahia,298292,31,298.292\nSobral,Ceará,292010,31,292.01\nItajaí,Santa Catarina,290394,31,290.394\nChapecó,Santa Catarina,288316,31,288.316\nCotia,São Paulo,280084,31,280.084\nLages,Santa Catarina,279140,31,279.14\nFerraz de Vasconcelos,São Paulo,278566,31,278.566\nIndaiatuba,São Paulo,271936,31,271.936\nHortolândia,São Paulo,271510,31,271.51\nCaxias,Maranhão,267960,31,267.96\nSão Caetano do Sul,São Paulo,266642,31,266.642\nItu,São Paulo,265472,31,265.472\nNossa Senhora do Socorro,Sergipe,262702,31,262.702\nParnaíba,Piauí,259512,31,259.512\nPoços de Caldas,Minas Gerais,259366,31,259.366\nTeresópolis,Rio de Janeiro,256158,31,256.158\nBarreiras,Bahia,255602,31,255.602\nCastanhal,Pará,255268,31,255.268\nAlagoinhas,Bahia,253640,31,253.64\nItapecerica da Serra,São Paulo,253344,31,253.344\nUruguaiana,Rio Grande do Sul,252610,31,252.61\nParanaguá,Paraná,252152,31,252.152\nIbirité,Minas Gerais,251964,31,251.964\nTimon,Maranhão,251624,31,251.624\nLuziânia,Goiás,251194,31,251.194\nMacaé,Rio de Janeiro,251194,31,251.194\nTeófilo Otoni,Minas Gerais,248978,31,248.978\nMoji-Guaçu,São Paulo,247564,31,247.564\nPalmas,Tocantins,243838,31,243.838\nPindamonhangaba,São Paulo,243808,31,243.808\nFrancisco Morato,São Paulo,242394,31,242.394\nBagé,Rio Grande do Sul,241586,31,241.586\nSapucaia do Sul,Rio Grande do Sul,240434,31,240.434\nCabo Frio,Rio de Janeiro,239006,31,239.006\nItapetininga,São Paulo,238782,31,238.782\nPatos de Minas,Minas Gerais,238524,31,238.524\nCamaragibe,Pernambuco,237936,31,237.936\nBragança Paulista,São Paulo,233858,31,233.858\nQueimados,Rio de Janeiro,230040,31,230.04\nAraguaína,Tocantins,229896,31,229.896\nGaranhuns,Pernambuco,229206,31,229.206\nVitória de Santo Antão,Pernambuco,227190,31,227.19\nSanta Rita,Paraíba,226270,31,226.27\nBarbacena,Minas Gerais,226158,31,226.158\nAbaetetuba,Pará,222516,31,222.516\nJaú,São Paulo,219930,31,219.93\nLauro de Freitas,Bahia,218472,31,218.472\nFranco da Rocha,São Paulo,217928,31,217.928\nTeixeira de Freitas,Bahia,216882,31,216.882\nVarginha,Minas Gerais,216628,31,216.628\nRibeirão Pires,São Paulo,216242,31,216.242\nSabará,Minas Gerais,215562,31,215.562\nCatanduva,São Paulo,215522,31,215.522\nRio Verde,Goiás,215510,31,215.51\nBotucatu,São Paulo,215326,31,215.326\nColatina,Espírito Santo,214708,31,214.708\nSanta Cruz do Sul,Rio Grande do Sul,213468,31,213.468\nLinhares,Espírito Santo,212556,31,212.556\nApucarana,Paraná,210228,31,210.228\nBarretos,São Paulo,208312,31,208.312\nGuaratinguetá,São Paulo,206866,31,206.866\nCachoeirinha,Rio Grande do Sul,206480,31,206.48\nCodó,Maranhão,206306,31,206.306\nJaraguá do Sul,Santa Catarina,205160,31,205.16\nCubatão,São Paulo,204744,31,204.744\nItabira,Minas Gerais,204434,31,204.434\nItaituba,Pará,202640,31,202.64\nAraras,São Paulo,202092,31,202.092\nResende,Rio de Janeiro,201254,31,201.254\nAtibaia,São Paulo,200712,31,200.712\nPouso Alegre,Minas Gerais,200056,31,200.056\nToledo,Paraná,198774,31,198.774\nCrato,Ceará,197930,31,197.93\nPassos,Minas Gerais,197140,31,197.14\nAraguari,Minas Gerais,196798,31,196.798\nSão José de Ribamar,Maranhão,196636,31,196.636\nPinhais,Paraná,196396,31,196.396\nSertãozinho,São Paulo,196280,31,196.28\nConselheiro Lafaiete,Minas Gerais,195014,31,195.014\nPaulo Afonso,Bahia,194582,31,194.582\nAngra dos Reis,Rio de Janeiro,193728,31,193.728\nEunápolis,Bahia,193220,31,193.22\nSalto,São Paulo,192696,31,192.696\nOurinhos,São Paulo,192582,31,192.582\nParnamirim,Rio Grande do Norte,192420,31,192.42\nJacobina,Bahia,192262,31,192.262\nCoronel Fabriciano,Minas Gerais,191866,31,191.866\nBirigui,São Paulo,189370,31,189.37\nTatuí,São Paulo,187794,31,187.794\nJi-Paraná,Rondônia,186692,31,186.692\nBacabal,Maranhão,186242,31,186.242\nCametá,Pará,185558,31,185.558\nGuaíba,Rio Grande do Sul,184448,31,184.448\nSão Lourenço da Mata,Pernambuco,183998,31,183.998\nSantana do Livramento,Rio Grande do Sul,183558,31,183.558\nVotorantim,São Paulo,183554,31,183.554\nCampo Largo,Paraná,182406,31,182.406\nPatos,Paraíba,181038,31,181.038\nItuiutaba,Minas Gerais,181014,31,181.014\nCorumbá,Mato Grosso do Sul,180222,31,180.222\nPalhoça,Santa Catarina,178930,31,178.93\nBarra do Piraí,Rio de Janeiro,178776,31,178.776\nBento Gonçalves,Rio Grande do Sul,178508,31,178.508\nPoá,São Paulo,178472,31,178.472\nÁguas Lindas de Goiás,Goiás,178400,31,178.4\nLondon,England,14570000,77,14570\nBirmingham,England,2026000,77,2026\nGlasgow,Scotland,1239360,77,1239.36\nLiverpool,England,922000,77,922\nEdinburgh,Scotland,900360,77,900.36\nSheffield,England,863214,77,863.214\nManchester,England,860000,77,860\nLeeds,England,848388,77,848.388\nBristol,England,804000,77,804\nCardiff,Wales,642000,77,642\nCoventry,England,608000,77,608\nLeicester,England,588000,77,588\nBradford,England,578752,77,578.752\nBelfast,North Ireland,575000,77,575\nNottingham,England,574000,77,574\nKingston upon Hull,England,524000,77,524\nPlymouth,England,506000,77,506\nStoke-on-Trent,England,504000,77,504\nWolverhampton,England,484000,77,484\nDerby,England,472000,77,472\nSwansea,Wales,460000,77,460\nSouthampton,England,432000,77,432\nAberdeen,Scotland,426140,77,426.14\nNorthampton,England,392000,77,392\nDudley,England,384342,77,384.342\nPortsmouth,England,380000,77,380\nNewcastle upon Tyne,England,378300,77,378.3\nSunderland,England,366620,77,366.62\nLuton,England,366000,77,366\nSwindon,England,360000,77,360\nSouthend-on-Sea,England,352000,77,352\nWalsall,England,349478,77,349.478\nBournemouth,England,324000,77,324\nPeterborough,England,312000,77,312\nBrighton,England,312248,77,312.248\nBlackpool,England,302000,77,302\nDundee,Scotland,293380,77,293.38\nWest Bromwich,England,292772,77,292.772\nReading,England,296000,77,296\nOldbury/Smethwick (Warley),England,291084,77,291.084\nMiddlesbrough,England,290000,77,290\nHuddersfield,England,287452,77,287.452\nOxford,England,288000,77,288\nPoole,England,282000,77,282\nBolton,England,278040,77,278.04\nBlackburn,England,280000,77,280\nNewport,Wales,278000,77,278\nPreston,England,270000,77,270\nStockport,England,265626,77,265.626\nNorwich,England,248000,77,248\nRotherham,England,242760,77,242.76\nCambridge,England,242000,77,242\nWatford,England,226160,77,226.16\nIpswich,England,228000,77,228\nSlough,England,224000,77,224\nExeter,England,222000,77,222\nCheltenham,England,212000,77,212\nGloucester,England,214000,77,214\nSaint Helens,England,212586,77,212.586\nSutton Coldfield,England,212002,77,212.002\nYork,England,208850,77,208.85\nOldham,England,207862,77,207.862\nBasildon,England,201848,77,201.848\nWorthing,England,200000,77,200\nChelmsford,England,194902,77,194.902\nColchester,England,192126,77,192.126\nCrawley,England,194000,77,194\nGillingham,England,184000,77,184\nSolihull,England,189062,77,189.062\nRochdale,England,188626,77,188.626\nBirkenhead,England,186174,77,186.174\nWorcester,England,190000,77,190\nHartlepool,England,184000,77,184\nHalifax,England,182138,77,182.138\nWoking/Byfleet,England,184000,77,184\nSouthport,England,181918,77,181.918\nMaidstone,England,181756,77,181.756\nEastbourne,England,180000,77,180\nGrimsby,England,178000,77,178\nSaint Helier,Jersey,55046,77,55.046\nDouglas,–,46974,77,46.974\nRoad Town,Tortola,16000,229,16\nBandar Seri Begawan,Brunei and Muara,42968,33,42.968\nSofija,Grad Sofija,2244604,23,2244.604\nPlovdiv,Plovdiv,685168,23,685.168\nVarna,Varna,599602,23,599.602\nBurgas,Burgas,390510,23,390.51\nRuse,Ruse,332934,23,332.934\nStara Zagora,Haskovo,295878,23,295.878\nPleven,Lovec,243904,23,243.904\nSliven,Burgas,211060,23,211.06\nDobric,Varna,200798,23,200.798\nŠumen,Varna,189372,23,189.372\nOuagadougou,Kadiogo,1648000,21,1648\nBobo-Dioulasso,Houet,600000,21,600\nKoudougou,Boulkiemdé,210000,21,210\nBujumbura,Bujumbura,600000,18,600\nGeorge Town,Grand Cayman,39200,54,39.2\nSantiago de Chile,Santiago,9407908,41,9407.908\nPuente Alto,Santiago,772472,41,772.472\nViña del Mar,Valparaíso,624986,41,624.986\nValparaíso,Valparaíso,587600,41,587.6\nTalcahuano,Bíobío,555504,41,555.504\nAntofagasta,Antofagasta,502858,41,502.858\nSan Bernardo,Santiago,483820,41,483.82\nTemuco,La Araucanía,466082,41,466.082\nConcepción,Bíobío,435328,41,435.328\nRancagua,O´Higgins,425954,41,425.954\nArica,Tarapacá,378072,41,378.072\nTalca,Maule,375114,41,375.114\nChillán,Bíobío,356364,41,356.364\nIquique,Tarapacá,355784,41,355.784\nLos Angeles,Bíobío,316430,41,316.43\nPuerto Montt,Los Lagos,304388,41,304.388\nCoquimbo,Coquimbo,286706,41,286.706\nOsorno,Los Lagos,282936,41,282.936\nLa Serena,Coquimbo,274818,41,274.818\nCalama,Antofagasta,274530,41,274.53\nValdivia,Los Lagos,266212,41,266.212\nPunta Arenas,Magallanes,251262,41,251.262\nCopiapó,Atacama,240256,41,240.256\nQuilpué,Valparaíso,237714,41,237.714\nCuricó,Maule,231532,41,231.532\nOvalle,Coquimbo,189708,41,189.708\nCoronel,Bíobío,186122,41,186.122\nSan Pedro de la Paz,Bíobío,183368,41,183.368\nMelipilla,Santiago,182112,41,182.112\nAvarua,Rarotonga,23800,47,23.8\nSan José,San José,678262,51,678.262\nDjibouti,Djibouti,766000,58,766\nRoseau,St George,32486,59,32.486\nSanto Domingo de Guzmán,Distrito Nacional,3219932,61,3219.932\nSantiago de los Caballeros,Santiago,730926,61,730.926\nLa Romana,La Romana,280408,61,280.408\nSan Pedro de Macorís,San Pedro de Macorís,249470,61,249.47\nSan Francisco de Macorís,Duarte,216970,61,216.97\nSan Felipe de Puerto Plata,Puerto Plata,178846,61,178.846\nGuayaquil,Guayas,4140080,63,4140.08\nQuito,Pichincha,3146916,63,3146.916\nCuenca,Azuay,540706,63,540.706\nMachala,El Oro,420736,63,420.736\nSanto Domingo de los Colorados,Pichincha,404222,63,404.222\nPortoviejo,Manabí,352826,63,352.826\nAmbato,Tungurahua,339224,63,339.224\nManta,Manabí,329478,63,329.478\nDuran [Eloy Alfaro],Guayas,305028,63,305.028\nIbarra,Imbabura,261286,63,261.286\nQuevedo,Los Ríos,259262,63,259.262\nMilagro,Guayas,248354,63,248.354\nLoja,Loja,247750,63,247.75\nRíobamba,Chimborazo,246326,63,246.326\nEsmeraldas,Esmeraldas,246090,63,246.09\nCairo,Kairo,13578958,64,13578.958\nAlexandria,Aleksandria,6656392,64,6656.392\nGiza,Giza,4443736,64,4443.736\nShubra al-Khayma,al-Qalyubiya,1741432,64,1741.432\nPort Said,Port Said,939066,64,939.066\nSuez,Suez,835220,64,835.22\nal-Mahallat al-Kubra,al-Gharbiya,790804,64,790.804\nTanta,al-Gharbiya,742020,64,742.02\nal-Mansura,al-Daqahliya,739242,64,739.242\nLuxor,Luxor,721006,64,721.006\nAsyut,Asyut,686996,64,686.996\nBahtim,al-Qalyubiya,551614,64,551.614\nZagazig,al-Sharqiya,534702,64,534.702\nal-Faiyum,al-Faiyum,521928,64,521.928\nIsmailia,Ismailia,508954,64,508.954\nKafr al-Dawwar,al-Buhayra,463956,64,463.956\nAssuan,Assuan,438034,64,438.034\nDamanhur,al-Buhayra,424406,64,424.406\nal-Minya,al-Minya,402720,64,402.72\nBani Suwayf,Bani Suwayf,344064,64,344.064\nQina,Qina,342550,64,342.55\nSawhaj,Sawhaj,340250,64,340.25\nShibin al-Kawm,al-Minufiya,319818,64,319.818\nBulaq al-Dakrur,Giza,297574,64,297.574\nBanha,al-Qalyubiya,291584,64,291.584\nWarraq al-Arab,Giza,254216,64,254.216\nKafr al-Shaykh,Kafr al-Shaykh,249638,64,249.638\nMallawi,al-Minya,238566,64,238.566\nBilbays,al-Sharqiya,227216,64,227.216\nMit Ghamr,al-Daqahliya,203602,64,203.602\nal-Arish,Shamal Sina,200894,64,200.894\nTalkha,al-Daqahliya,195400,64,195.4\nQalyub,al-Qalyubiya,194400,64,194.4\nJirja,Sawhaj,190800,64,190.8\nIdfu,Qina,188400,64,188.4\nal-Hawamidiya,Giza,183400,64,183.4\nDisuq,Kafr al-Shaykh,182600,64,182.6\nSan Salvador,San Salvador,830692,193,830.692\nSanta Ana,Santa Ana,278778,193,278.778\nMejicanos,San Salvador,277600,193,277.6\nSoyapango,San Salvador,259600,193,259.6\nSan Miguel,San Miguel,255392,193,255.392\nNueva San Salvador,La Libertad,196800,193,196.8\nApopa,San Salvador,177600,193,177.6\nAsmara,Maekel,862000,65,862\nMadrid,Madrid,5758104,67,5758.104\nBarcelona,Katalonia,3006902,67,3006.902\nValencia,Valencia,1478824,67,1478.824\nSevilla,Andalusia,1403854,67,1403.854\nZaragoza,Aragonia,1206734,67,1206.734\nMálaga,Andalusia,1061106,67,1061.106\nBilbao,Baskimaa,715178,67,715.178\nLas Palmas de Gran Canaria,Canary Islands,709514,67,709.514\nMurcia,Murcia,707008,67,707.008\nPalma de Mallorca,Balears,653986,67,653.986\nValladolid,Castilla and León,639996,67,639.996\nCórdoba,Andalusia,623416,67,623.416\nVigo,Galicia,567340,67,567.34\nAlicante [Alacant],Valencia,544864,67,544.864\nGijón,Asturia,535960,67,535.96\nL´Hospitalet de Llobregat,Katalonia,495972,67,495.972\nGranada,Andalusia,489534,67,489.534\nA Coruña (La Coruña),Galicia,486804,67,486.804\nVitoria-Gasteiz,Baskimaa,434308,67,434.308\nSanta Cruz de Tenerife,Canary Islands,426100,67,426.1\nBadalona,Katalonia,419270,67,419.27\nOviedo,Asturia,400906,67,400.906\nMóstoles,Madrid,390702,67,390.702\nElche [Elx],Valencia,386348,67,386.348\nSabadell,Katalonia,369718,67,369.718\nSantander,Cantabria,368330,67,368.33\nJerez de la Frontera,Andalusia,365320,67,365.32\nPamplona [Iruña],Navarra,360966,67,360.966\nDonostia-San Sebastián,Baskimaa,358416,67,358.416\nCartagena,Murcia,355418,67,355.418\nLeganés,Madrid,346326,67,346.326\nFuenlabrada,Madrid,342346,67,342.346\nAlmería,Andalusia,338054,67,338.054\nTerrassa,Katalonia,337390,67,337.39\nAlcalá de Henares,Madrid,328926,67,328.926\nBurgos,Castilla and León,325604,67,325.604\nSalamanca,Castilla and León,317440,67,317.44\nAlbacete,Kastilia-La Mancha,295054,67,295.054\nGetafe,Madrid,290742,67,290.742\nCádiz,Andalusia,284898,67,284.898\nAlcorcón,Madrid,284096,67,284.096\nHuelva,Andalusia,281166,67,281.166\nLeón,Castilla and León,279618,67,279.618\nCastellón de la Plana [Castell,Valencia,279424,67,279.424\nBadajoz,Extremadura,273226,67,273.226\n[San Cristóbal de] la Laguna,Canary Islands,255890,67,255.89\nLogroño,La Rioja,254186,67,254.186\nSanta Coloma de Gramenet,Katalonia,241604,67,241.604\nTarragona,Katalonia,226032,67,226.032\nLleida (Lérida),Katalonia,224414,67,224.414\nJaén,Andalusia,218494,67,218.494\nOurense (Orense),Galicia,218240,67,218.24\nMataró,Katalonia,208190,67,208.19\nAlgeciras,Andalusia,206212,67,206.212\nMarbella,Andalusia,202288,67,202.288\nBarakaldo,Baskimaa,196424,67,196.424\nDos Hermanas,Andalusia,189182,67,189.182\nSantiago de Compostela,Galicia,187490,67,187.49\nTorrejón de Ardoz,Madrid,184524,67,184.524\nCape Town,Western Cape,4704242,237,4704.242\nSoweto,Gauteng,1808330,237,1808.33\nJohannesburg,Gauteng,1513306,237,1513.306\nPort Elizabeth,Eastern Cape,1504638,237,1504.638\nPretoria,Gauteng,1317260,237,1317.26\nInanda,KwaZulu-Natal,1268130,237,1268.13\nDurban,KwaZulu-Natal,1132240,237,1132.24\nVanderbijlpark,Gauteng,937862,237,937.862\nKempton Park,Gauteng,885266,237,885.266\nAlberton,Gauteng,820204,237,820.204\nPinetown,KwaZulu-Natal,757620,237,757.62\nPietermaritzburg,KwaZulu-Natal,740380,237,740.38\nBenoni,Gauteng,730934,237,730.934\nRandburg,Gauteng,682576,237,682.576\nUmlazi,KwaZulu-Natal,678466,237,678.466\nBloemfontein,Free State,668682,237,668.682\nVereeniging,Gauteng,657070,237,657.07\nWonderboom,Gauteng,566578,237,566.578\nRoodepoort,Gauteng,558680,237,558.68\nBoksburg,Gauteng,525296,237,525.296\nKlerksdorp,North West,523822,237,523.822\nSoshanguve,Gauteng,485454,237,485.454\nNewcastle,KwaZulu-Natal,445986,237,445.986\nEast London,Eastern Cape,442094,237,442.094\nWelkom,Free State,406592,237,406.592\nKimberley,Northern Cape,394508,237,394.508\nUitenhage,Eastern Cape,384240,237,384.24\nChatsworth,KwaZulu-Natal,379770,237,379.77\nMdantsane,Eastern Cape,365278,237,365.278\nKrugersdorp,Gauteng,363006,237,363.006\nBotshabelo,Free State,355942,237,355.942\nBrakpan,Gauteng,342726,237,342.726\nWitbank,Mpumalanga,334366,237,334.366\nOberholzer,Gauteng,328734,237,328.734\nGermiston,Gauteng,328504,237,328.504\nSprings,Gauteng,324144,237,324.144\nWestonaria,Gauteng,319264,237,319.264\nRandfontein,Gauteng,241676,237,241.676\nPaarl,Western Cape,211536,237,211.536\nPotchefstroom,North West,203634,237,203.634\nRustenburg,North West,194016,237,194.016\nNigel,Gauteng,193468,237,193.468\nGeorge,Western Cape,187636,237,187.636\nLadysmith,KwaZulu-Natal,178584,237,178.584\nAddis Abeba,Addis Abeba,4990000,69,4990\nDire Dawa,Dire Dawa,329702,69,329.702\nNazret,Oromia,255684,69,255.684\nGonder,Amhara,224498,69,224.498\nDese,Amhara,194628,69,194.628\nMekele,Tigray,193876,69,193.876\nBahir Dar,Amhara,192280,69,192.28\nStanley,East Falkland,3272,72,3.272\nSuva,Central,154732,71,154.732\nQuezon,National Capital Reg,4347662,169,4347.662\nManila,National Capital Reg,3162164,169,3162.164\nKalookan,National Capital Reg,2355208,169,2355.208\nDavao,Southern Mindanao,2294232,169,2294.232\nCebu,Central Visayas,1437642,169,1437.642\nZamboanga,Western Mindanao,1203588,169,1203.588\nPasig,National Capital Reg,1010116,169,1010.116\nValenzuela,National Capital Reg,970866,169,970.866\nLas Piñas,National Capital Reg,945560,169,945.56\nAntipolo,Southern Tagalog,941732,169,941.732\nTaguig,National Capital Reg,934750,169,934.75\nCagayan de Oro,Northern Mindanao,923754,169,923.754\nParañaque,National Capital Reg,899622,169,899.622\nMakati,National Capital Reg,889734,169,889.734\nBacolod,Western Visayas,858152,169,858.152\nGeneral Santos,Southern Mindanao,823644,169,823.644\nMarikina,National Capital Reg,782340,169,782.34\nDasmariñas,Southern Tagalog,759040,169,759.04\nMuntinlupa,National Capital Reg,758620,169,758.62\nIloilo,Western Visayas,731640,169,731.64\nPasay,National Capital Reg,709816,169,709.816\nMalabon,National Capital Reg,677710,169,677.71\nSan José del Monte,Central Luzon,631614,169,631.614\nBacoor,Southern Tagalog,611398,169,611.398\nIligan,Central Mindanao,570122,169,570.122\nCalamba,Southern Tagalog,562292,169,562.292\nMandaluyong,National Capital Reg,556948,169,556.948\nButuan,Caraga,534558,169,534.558\nAngeles,Central Luzon,527942,169,527.942\nTarlac,Central Luzon,524962,169,524.962\nMandaue,Central Visayas,519456,169,519.456\nBaguio,CAR,504772,169,504.772\nBatangas,Southern Tagalog,495176,169,495.176\nCainta,Southern Tagalog,485022,169,485.022\nSan Pedro,Southern Tagalog,462806,169,462.806\nNavotas,National Capital Reg,460806,169,460.806\nCabanatuan,Central Luzon,445718,169,445.718\nSan Fernando,Central Luzon,443714,169,443.714\nLipa,Southern Tagalog,436894,169,436.894\nLapu-Lapu,Central Visayas,434038,169,434.038\nSan Pablo,Southern Tagalog,415854,169,415.854\nBiñan,Southern Tagalog,402372,169,402.372\nTaytay,Southern Tagalog,396366,169,396.366\nLucena,Southern Tagalog,392150,169,392.15\nImus,Southern Tagalog,390964,169,390.964\nOlongapo,Central Luzon,388520,169,388.52\nBinangonan,Southern Tagalog,375382,169,375.382\nSanta Rosa,Southern Tagalog,371266,169,371.266\nTagum,Southern Mindanao,359062,169,359.062\nTacloban,Eastern Visayas,357278,169,357.278\nMalolos,Central Luzon,350582,169,350.582\nMabalacat,Central Luzon,342090,169,342.09\nCotabato,Central Mindanao,327698,169,327.698\nMeycauayan,Central Luzon,326074,169,326.074\nPuerto Princesa,Southern Tagalog,323824,169,323.824\nLegazpi,Bicol,314020,169,314.02\nSilang,Southern Tagalog,312274,169,312.274\nOrmoc,Eastern Visayas,308594,169,308.594\nSan Carlos,Ilocos,308528,169,308.528\nKabankalan,Western Visayas,299538,169,299.538\nTalisay,Central Visayas,296220,169,296.22\nValencia,Northern Mindanao,295848,169,295.848\nCalbayog,Eastern Visayas,294374,169,294.374\nSanta Maria,Central Luzon,288564,169,288.564\nPagadian,Western Mindanao,285030,169,285.03\nCadiz,Western Visayas,283908,169,283.908\nBago,Western Visayas,283442,169,283.442\nToledo,Central Visayas,282348,169,282.348\nNaga,Bicol,275620,169,275.62\nSan Mateo,Southern Tagalog,271206,169,271.206\nPanabo,Southern Mindanao,267900,169,267.9\nKoronadal,Southern Mindanao,267572,169,267.572\nMarawi,Central Mindanao,262180,169,262.18\nDagupan,Ilocos,260656,169,260.656\nSagay,Western Visayas,259530,169,259.53\nRoxas,Western Visayas,252704,169,252.704\nLubao,Central Luzon,251398,169,251.398\nDigos,Southern Mindanao,250342,169,250.342\nSan Miguel,Central Luzon,247648,169,247.648\nMalaybalay,Northern Mindanao,247344,169,247.344\nTuguegarao,Cagayan Valley,241290,169,241.29\nIlagan,Cagayan Valley,239980,169,239.98\nBaliuag,Central Luzon,239350,169,239.35\nSurigao,Caraga,237068,169,237.068\nSan Carlos,Western Visayas,236518,169,236.518\nSan Juan del Monte,National Capital Reg,235360,169,235.36\nTanauan,Southern Tagalog,235078,169,235.078\nConcepcion,Central Luzon,230342,169,230.342\nRodriguez (Montalban),Southern Tagalog,230334,169,230.334\nSariaya,Southern Tagalog,229136,169,229.136\nMalasiqui,Ilocos,226380,169,226.38\nGeneral Mariano Alvarez,Southern Tagalog,224892,169,224.892\nUrdaneta,Ilocos,223164,169,223.164\nHagonoy,Central Luzon,222850,169,222.85\nSan Jose,Southern Tagalog,222018,169,222.018\nPolomolok,Southern Mindanao,221418,169,221.418\nSantiago,Cagayan Valley,221062,169,221.062\nTanza,Southern Tagalog,221034,169,221.034\nOzamis,Northern Mindanao,220840,169,220.84\nMexico,Central Luzon,218962,169,218.962\nSan Jose,Central Luzon,216508,169,216.508\nSilay,Western Visayas,215444,169,215.444\nGeneral Trias,Southern Tagalog,215382,169,215.382\nTabaco,Bicol,214332,169,214.332\nCabuyao,Southern Tagalog,213260,169,213.26\nCalapan,Southern Tagalog,211820,169,211.82\nMati,Southern Mindanao,211816,169,211.816\nMidsayap,Central Mindanao,211520,169,211.52\nCauayan,Cagayan Valley,207904,169,207.904\nGingoog,Northern Mindanao,204758,169,204.758\nDumaguete,Central Visayas,204530,169,204.53\nSan Fernando,Ilocos,204164,169,204.164\nArayat,Central Luzon,203584,169,203.584\nBayawan (Tulong),Central Visayas,202782,169,202.782\nKidapawan,Central Mindanao,202410,169,202.41\nDaraga (Locsin),Bicol,202062,169,202.062\nMarilao,Central Luzon,202034,169,202.034\nMalita,Southern Mindanao,200000,169,200\nDipolog,Western Mindanao,199724,169,199.724\nCavite,Southern Tagalog,198734,169,198.734\nDanao,Central Visayas,197562,169,197.562\nBislig,Caraga,195720,169,195.72\nTalavera,Central Luzon,194658,169,194.658\nGuagua,Central Luzon,193716,169,193.716\nBayambang,Ilocos,193218,169,193.218\nNasugbu,Southern Tagalog,192226,169,192.226\nBaybay,Eastern Visayas,191260,169,191.26\nCapas,Central Luzon,190438,169,190.438\nSultan Kudarat,ARMM,189722,169,189.722\nLaoag,Ilocos,188932,169,188.932\nBayugan,Caraga,187246,169,187.246\nMalungon,Southern Mindanao,186464,169,186.464\nSanta Cruz,Southern Tagalog,185388,169,185.388\nSorsogon,Bicol,185024,169,185.024\nCandelaria,Southern Tagalog,184858,169,184.858\nLigao,Bicol,181206,169,181.206\nTórshavn,Streymoyar,29084,74,29.084\nLibreville,Estuaire,838000,76,838\nSerekunda,Kombo St Mary,205200,83,205.2\nBanjul,Banjul,84652,83,84.652\nTbilisi,Tbilisi,2470400,78,2470.4\nKutaisi,Imereti,481800,78,481.8\nRustavi,Kvemo Kartli,310800,78,310.8\nBatumi,Adzaria [Atšara],275400,78,275.4\nSohumi,Abhasia [Aphazeti],223400,78,223.4\nAccra,Greater Accra,2140000,79,2140\nKumasi,Ashanti,770384,79,770.384\nTamale,Northern,302138,79,302.138\nTema,Greater Accra,219950,79,219.95\nSekondi-Takoradi,Western,207306,79,207.306\nGibraltar,–,54050,80,54.05\nSaint George´s,St George,9242,87,9.242\nNuuk,Kitaa,26890,88,26.89\nLes Abymes,Grande-Terre,125894,82,125.894\nBasse-Terre,Basse-Terre,24866,82,24.866\nTamuning,–,19000,91,19\nAgaña,–,2278,91,2.278\nCiudad de Guatemala,Guatemala,1646602,89,1646.602\nMixco,Guatemala,419582,89,419.582\nVilla Nueva,Guatemala,202590,89,202.59\nQuetzaltenango,Quetzaltenango,181602,89,181.602\nConakry,Conakry,2181220,81,2181.22\nBissau,Bissau,482000,84,482\nGeorgetown,Georgetown,508000,92,508\nPort-au-Prince,Ouest,1768944,97,1768.944\nCarrefour,Ouest,580408,97,580.408\nDelmas,Ouest,480858,97,480.858\nLe-Cap-Haïtien,Nord,204466,97,204.466\nTegucigalpa,Distrito Central,1627800,95,1627.8\nSan Pedro Sula,Cortés,767800,95,767.8\nLa Ceiba,Atlántida,178400,95,178.4\nKowloon and New Kowloon,Kowloon and New Kowl,3975992,93,3975.992\nVictoria,Hongkong,2625274,93,2625.274\nLongyearbyen,Länsimaa,2876,190,2.876\nJakarta,Jakarta Raya,19209800,99,19209.8\nSurabaya,East Java,5327640,99,5327.64\nBandung,West Java,4858000,99,4858\nMedan,Sumatera Utara,3687838,99,3687.838\nPalembang,Sumatera Selatan,2445528,99,2445.528\nTangerang,West Java,2396600,99,2396.6\nSemarang,Central Java,2208810,99,2208.81\nUjung Pandang,Sulawesi Selatan,2120514,99,2120.514\nMalang,East Java,1433724,99,1433.724\nBandar Lampung,Lampung,1360664,99,1360.664\nBekasi,West Java,1288600,99,1288.6\nPadang,Sumatera Barat,1068948,99,1068.948\nSurakarta,Central Java,1037200,99,1037.2\nBanjarmasin,Kalimantan Selatan,965862,99,965.862\nPekan Baru,Riau,877276,99,877.276\nDenpasar,Bali,870000,99,870\nYogyakarta,Yogyakarta,837888,99,837.888\nPontianak,Kalimantan Barat,819264,99,819.264\nSamarinda,Kalimantan Timur,798350,99,798.35\nJambi,Jambi,770402,99,770.402\nDepok,West Java,730400,99,730.4\nCimahi,West Java,689200,99,689.2\nBalikpapan,Kalimantan Timur,677504,99,677.504\nManado,Sulawesi Utara,664576,99,664.576\nMataram,Nusa Tenggara Barat,613200,99,613.2\nPekalongan,Central Java,603008,99,603.008\nTegal,Central Java,579488,99,579.488\nBogor,West Java,570228,99,570.228\nCiputat,West Java,541600,99,541.6\nPondokgede,West Java,526400,99,526.4\nCirebon,West Java,508812,99,508.812\nKediri,East Java,507520,99,507.52\nAmbon,Molukit,498624,99,498.624\nJember,East Java,437000,99,437\nCilacap,Central Java,413800,99,413.8\nCimanggis,West Java,410200,99,410.2\nPematang Siantar,Sumatera Utara,406112,99,406.112\nPurwokerto,Central Java,405000,99,405\nCiomas,West Java,374800,99,374.8\nTasikmalaya,West Java,359600,99,359.6\nMadiun,East Java,343064,99,343.064\nBengkulu,Bengkulu,292878,99,292.878\nKarawang,West Java,290000,99,290\nBanda Aceh,Aceh,286818,99,286.818\nPalu,Sulawesi Tengah,285600,99,285.6\nPasuruan,East Java,268038,99,268.038\nKupang,Nusa Tenggara Timur,258600,99,258.6\nTebing Tinggi,Sumatera Utara,258600,99,258.6\nPercut Sei Tuan,Sumatera Utara,258000,99,258\nBinjai,Sumatera Utara,254444,99,254.444\nSukabumi,West Java,251532,99,251.532\nWaru,East Java,248600,99,248.6\nPangkal Pinang,Sumatera Selatan,248000,99,248\nMagelang,Central Java,247600,99,247.6\nBlitar,East Java,245200,99,245.2\nSerang,West Java,244800,99,244.8\nProbolinggo,East Java,241540,99,241.54\nCilegon,West Java,234000,99,234\nCianjur,West Java,228600,99,228.6\nCiparay,West Java,223000,99,223\nLhokseumawe,Aceh,219200,99,219.2\nTaman,East Java,214000,99,214\nDepok,Yogyakarta,213600,99,213.6\nCiteureup,West Java,210200,99,210.2\nPemalang,Central Java,207000,99,207\nKlaten,Central Java,206600,99,206.6\nSalatiga,Central Java,206000,99,206\nCibinong,West Java,202600,99,202.6\nPalangka Raya,Kalimantan Tengah,199386,99,199.386\nMojokerto,East Java,193252,99,193.252\nPurwakarta,West Java,191800,99,191.8\nGarut,West Java,191600,99,191.6\nKudus,Central Java,190600,99,190.6\nKendari,Sulawesi Tenggara,189600,99,189.6\nJaya Pura,West Irian,189400,99,189.4\nGorontalo,Sulawesi Utara,188116,99,188.116\nMajalaya,West Java,186400,99,186.4\nPondok Aren,West Java,185400,99,185.4\nJombang,East Java,185200,99,185.2\nSunggal,Sumatera Utara,184600,99,184.6\nBatam,Riau,183742,99,183.742\nPadang Sidempuan,Sumatera Utara,182400,99,182.4\nSawangan,West Java,182200,99,182.2\nBanyuwangi,East Java,179800,99,179.8\nTanjung Pinang,Riau,179800,99,179.8\nMumbai (Bombay),Maharashtra,21000000,100,21000\nDelhi,Delhi,14413408,100,14413.408\nCalcutta [Kolkata],West Bengali,8799638,100,8799.638\nChennai (Madras),Tamil Nadu,7682792,100,7682.792\nHyderabad,Andhra Pradesh,5929276,100,5929.276\nAhmedabad,Gujarat,5753420,100,5753.42\nBangalore,Karnataka,5320176,100,5320.176\nKanpur,Uttar Pradesh,3748818,100,3748.818\nNagpur,Maharashtra,3249504,100,3249.504\nLucknow,Uttar Pradesh,3238230,100,3238.23\nPune,Maharashtra,3133302,100,3133.302\nSurat,Gujarat,2997634,100,2997.634\nJaipur,Rajasthan,2916966,100,2916.966\nIndore,Madhya Pradesh,2183348,100,2183.348\nBhopal,Madhya Pradesh,2125542,100,2125.542\nLudhiana,Punjab,2085480,100,2085.48\nVadodara (Baroda),Gujarat,2062692,100,2062.692\nKalyan,Maharashtra,2029114,100,2029.114\nMadurai,Tamil Nadu,1955712,100,1955.712\nHaora (Howrah),West Bengali,1900870,100,1900.87\nVaranasi (Benares),Uttar Pradesh,1858540,100,1858.54\nPatna,Bihar,1834486,100,1834.486\nSrinagar,Jammu and Kashmir,1785012,100,1785.012\nAgra,Uttar Pradesh,1783580,100,1783.58\nCoimbatore,Tamil Nadu,1632642,100,1632.642\nThane (Thana),Maharashtra,1606778,100,1606.778\nAllahabad,Uttar Pradesh,1585716,100,1585.716\nMeerut,Uttar Pradesh,1507556,100,1507.556\nVishakhapatnam,Andhra Pradesh,1504074,100,1504.074\nJabalpur,Madhya Pradesh,1483854,100,1483.854\nAmritsar,Punjab,1417670,100,1417.67\nFaridabad,Haryana,1407184,100,1407.184\nVijayawada,Andhra Pradesh,1403654,100,1403.654\nGwalior,Madhya Pradesh,1381530,100,1381.53\nJodhpur,Rajasthan,1332558,100,1332.558\nNashik (Nasik),Maharashtra,1313850,100,1313.85\nHubli-Dharwad,Karnataka,1296596,100,1296.596\nSolapur (Sholapur),Maharashtra,1208430,100,1208.43\nRanchi,Jharkhand,1198612,100,1198.612\nBareilly,Uttar Pradesh,1174422,100,1174.422\nGuwahati (Gauhati),Assam,1168684,100,1168.684\nShambajinagar (Aurangabad),Maharashtra,1146544,100,1146.544\nCochin (Kochi),Kerala,1129178,100,1129.178\nRajkot,Gujarat,1118814,100,1118.814\nKota,Rajasthan,1074742,100,1074.742\nThiruvananthapuram (Trivandrum,Kerala,1048012,100,1048.012\nPimpri-Chinchwad,Maharashtra,1034166,100,1034.166\nJalandhar (Jullundur),Punjab,1019020,100,1019.02\nGorakhpur,Uttar Pradesh,1011132,100,1011.132\nChandigarh,Chandigarh,1008188,100,1008.188\nMysore,Karnataka,961384,100,961.384\nAligarh,Uttar Pradesh,961040,100,961.04\nGuntur,Andhra Pradesh,942102,100,942.102\nJamshedpur,Jharkhand,921154,100,921.154\nGhaziabad,Uttar Pradesh,908312,100,908.312\nWarangal,Andhra Pradesh,895314,100,895.314\nRaipur,Chhatisgarh,877278,100,877.278\nMoradabad,Uttar Pradesh,858428,100,858.428\nDurgapur,West Bengali,851672,100,851.672\nAmravati,Maharashtra,843152,100,843.152\nCalicut (Kozhikode),Kerala,839662,100,839.662\nBikaner,Rajasthan,832578,100,832.578\nBhubaneswar,Orissa,823084,100,823.084\nKolhapur,Maharashtra,812740,100,812.74\nKataka (Cuttack),Orissa,806836,100,806.836\nAjmer,Rajasthan,805400,100,805.4\nBhavnagar,Gujarat,804676,100,804.676\nTiruchirapalli,Tamil Nadu,774446,100,774.446\nBhilai,Chhatisgarh,772318,100,772.318\nBhiwandi,Maharashtra,758140,100,758.14\nSaharanpur,Uttar Pradesh,749890,100,749.89\nUlhasnagar,Maharashtra,738154,100,738.154\nSalem,Tamil Nadu,733424,100,733.424\nUjjain,Madhya Pradesh,724532,100,724.532\nMalegaon,Maharashtra,685190,100,685.19\nJamnagar,Gujarat,683274,100,683.274\nBokaro Steel City,Jharkhand,667366,100,667.366\nAkola,Maharashtra,656068,100,656.068\nBelgaum,Karnataka,652798,100,652.798\nRajahmundry,Andhra Pradesh,649702,100,649.702\nNellore,Andhra Pradesh,633212,100,633.212\nUdaipur,Rajasthan,617142,100,617.142\nNew Bombay,Maharashtra,614594,100,614.594\nBhatpara,West Bengali,609904,100,609.904\nGulbarga,Karnataka,608198,100,608.198\nNew Delhi,Delhi,602594,100,602.594\nJhansi,Uttar Pradesh,601700,100,601.7\nGaya,Bihar,583350,100,583.35\nKakinada,Andhra Pradesh,559960,100,559.96\nDhule (Dhulia),Maharashtra,556634,100,556.634\nPanihati,West Bengali,551980,100,551.98\nNanded (Nander),Maharashtra,550166,100,550.166\nMangalore,Karnataka,546608,100,546.608\nDehra Dun,Uttaranchal,540318,100,540.318\nKamarhati,West Bengali,533778,100,533.778\nDavangere,Karnataka,532164,100,532.164\nAsansol,West Bengali,524376,100,524.376\nBhagalpur,Bihar,506450,100,506.45\nBellary,Karnataka,490782,100,490.782\nBarddhaman (Burdwan),West Bengali,490158,100,490.158\nRampur,Uttar Pradesh,487484,100,487.484\nJalgaon,Maharashtra,484386,100,484.386\nMuzaffarpur,Bihar,482214,100,482.214\nNizamabad,Andhra Pradesh,482068,100,482.068\nMuzaffarnagar,Uttar Pradesh,481218,100,481.218\nPatiala,Punjab,476736,100,476.736\nShahjahanpur,Uttar Pradesh,475426,100,475.426\nKurnool,Andhra Pradesh,473600,100,473.6\nTiruppur (Tirupper),Tamil Nadu,471322,100,471.322\nRohtak,Haryana,466800,100,466.8\nSouth Dum Dum,West Bengali,465622,100,465.622\nMathura,Uttar Pradesh,453382,100,453.382\nChandrapur,Maharashtra,452210,100,452.21\nBarahanagar (Baranagar),West Bengali,449642,100,449.642\nDarbhanga,Bihar,436782,100,436.782\nSiliguri (Shiliguri),West Bengali,433900,100,433.9\nRaurkela,Orissa,430978,100,430.978\nAmbattur,Tamil Nadu,430848,100,430.848\nPanipat,Haryana,430436,100,430.436\nFirozabad,Uttar Pradesh,430256,100,430.256\nIchalkaranji,Maharashtra,429900,100,429.9\nJammu,Jammu and Kashmir,429474,100,429.474\nRamagundam,Andhra Pradesh,428768,100,428.768\nEluru,Andhra Pradesh,425732,100,425.732\nBrahmapur,Orissa,420836,100,420.836\nAlwar,Rajasthan,410172,100,410.172\nPondicherry,Pondicherry,406130,100,406.13\nThanjavur,Tamil Nadu,404026,100,404.026\nBihar Sharif,Bihar,402646,100,402.646\nTuticorin,Tamil Nadu,399708,100,399.708\nImphal,Manipur,397070,100,397.07\nLatur,Maharashtra,394816,100,394.816\nSagar,Madhya Pradesh,390692,100,390.692\nFarrukhabad-cum-Fatehgarh,Uttar Pradesh,389134,100,389.134\nSangli,Maharashtra,386394,100,386.394\nParbhani,Maharashtra,380510,100,380.51\nNagar Coil,Tamil Nadu,380168,100,380.168\nBijapur,Karnataka,373878,100,373.878\nKukatpalle,Andhra Pradesh,370756,100,370.756\nBally,West Bengali,368948,100,368.948\nBhilwara,Rajasthan,367930,100,367.93\nRatlam,Madhya Pradesh,366750,100,366.75\nAvadi,Tamil Nadu,366430,100,366.43\nDindigul,Tamil Nadu,364954,100,364.954\nAhmadnagar,Maharashtra,362678,100,362.678\nBilaspur,Chhatisgarh,359666,100,359.666\nShimoga,Karnataka,358516,100,358.516\nKharagpur,West Bengali,355978,100,355.978\nMira Bhayandar,Maharashtra,350744,100,350.744\nVellore,Tamil Nadu,350122,100,350.122\nJalna,Maharashtra,349970,100,349.97\nBurnpur,West Bengali,349866,100,349.866\nAnantapur,Andhra Pradesh,349848,100,349.848\nAllappuzha (Alleppey),Kerala,349332,100,349.332\nTirupati,Andhra Pradesh,348738,100,348.738\nKarnal,Haryana,347502,100,347.502\nBurhanpur,Madhya Pradesh,345420,100,345.42\nHisar (Hissar),Haryana,345354,100,345.354\nTiruvottiyur,Tamil Nadu,345124,100,345.124\nMirzapur-cum-Vindhyachal,Uttar Pradesh,338672,100,338.672\nSecunderabad,Andhra Pradesh,334922,100,334.922\nNadiad,Gujarat,334102,100,334.102\nDewas,Madhya Pradesh,328728,100,328.728\nMurwara (Katni),Madhya Pradesh,326862,100,326.862\nGanganagar,Rajasthan,322964,100,322.964\nVizianagaram,Andhra Pradesh,320718,100,320.718\nErode,Tamil Nadu,318464,100,318.464\nMachilipatnam (Masulipatam),Andhra Pradesh,318220,100,318.22\nBhatinda (Bathinda),Punjab,318084,100,318.084\nRaichur,Karnataka,315102,100,315.102\nAgartala,Tripura,314716,100,314.716\nArrah (Ara),Bihar,314164,100,314.164\nSatna,Madhya Pradesh,313260,100,313.26\nLalbahadur Nagar,Andhra Pradesh,311000,100,311\nAizawl,Mizoram,310480,100,310.48\nUluberia,West Bengali,310344,100,310.344\nKatihar,Bihar,308734,100,308.734\nCuddalore,Tamil Nadu,306172,100,306.172\nHugli-Chinsurah,West Bengali,303612,100,303.612\nDhanbad,Jharkhand,303578,100,303.578\nRaiganj,West Bengali,302090,100,302.09\nSambhal,Uttar Pradesh,301738,100,301.738\nDurg,Chhatisgarh,301290,100,301.29\nMunger (Monghyr),Bihar,300224,100,300.224\nKanchipuram,Tamil Nadu,300200,100,300.2\nNorth Dum Dum,West Bengali,299930,100,299.93\nKarimnagar,Andhra Pradesh,297166,100,297.166\nBharatpur,Rajasthan,297038,100,297.038\nSikar,Rajasthan,296544,100,296.544\nHardwar (Haridwar),Uttaranchal,294610,100,294.61\nDabgram,West Bengali,294434,100,294.434\nMorena,Madhya Pradesh,294248,100,294.248\nNoida,Uttar Pradesh,293028,100,293.028\nHapur,Uttar Pradesh,292524,100,292.524\nBhusawal,Maharashtra,290286,100,290.286\nKhandwa,Madhya Pradesh,290266,100,290.266\nYamuna Nagar,Haryana,288692,100,288.692\nSonipat (Sonepat),Haryana,287844,100,287.844\nTenali,Andhra Pradesh,287452,100,287.452\nRaurkela Civil Township,Orissa,280816,100,280.816\nKollam (Quilon),Kerala,279704,100,279.704\nKumbakonam,Tamil Nadu,278966,100,278.966\nIngraj Bazar (English Bazar),West Bengali,278408,100,278.408\nTimkur,Karnataka,277806,100,277.806\nAmroha,Uttar Pradesh,274122,100,274.122\nSerampore,West Bengali,274056,100,274.056\nChapra,Bihar,273754,100,273.754\nPali,Rajasthan,273684,100,273.684\nMaunath Bhanjan,Uttar Pradesh,273394,100,273.394\nAdoni,Andhra Pradesh,272364,100,272.364\nJaunpur,Uttar Pradesh,272124,100,272.124\nTirunelveli,Tamil Nadu,271650,100,271.65\nBahraich,Uttar Pradesh,270800,100,270.8\nGadag Betigeri,Karnataka,268102,100,268.102\nProddatur,Andhra Pradesh,267828,100,267.828\nChittoor,Andhra Pradesh,266924,100,266.924\nBarrackpur,West Bengali,266530,100,266.53\nBharuch (Broach),Gujarat,266204,100,266.204\nNaihati,West Bengali,265402,100,265.402\nShillong,Meghalaya,263438,100,263.438\nSambalpur,Orissa,262276,100,262.276\nJunagadh,Gujarat,260968,100,260.968\nRae Bareli,Uttar Pradesh,259808,100,259.808\nRewa,Madhya Pradesh,257962,100,257.962\nGurgaon,Haryana,257216,100,257.216\nKhammam,Andhra Pradesh,255984,100,255.984\nBulandshahr,Uttar Pradesh,254402,100,254.402\nNavsari,Gujarat,252178,100,252.178\nMalkajgiri,Andhra Pradesh,252132,100,252.132\nMidnapore (Medinipur),West Bengali,250996,100,250.996\nMiraj,Maharashtra,250814,100,250.814\nRaj Nandgaon,Chhatisgarh,250742,100,250.742\nAlandur,Tamil Nadu,250488,100,250.488\nPuri,Orissa,250398,100,250.398\nNavadwip,West Bengali,250074,100,250.074\nSirsa,Haryana,250000,100,250\nKorba,Chhatisgarh,249002,100,249.002\nFaizabad,Uttar Pradesh,248874,100,248.874\nEtawah,Uttar Pradesh,248144,100,248.144\nPathankot,Punjab,247860,100,247.86\nGandhinagar,Gujarat,246718,100,246.718\nPalghat (Palakkad),Kerala,246578,100,246.578\nVeraval,Gujarat,246000,100,246\nHoshiarpur,Punjab,245410,100,245.41\nAmbala,Haryana,245192,100,245.192\nSitapur,Uttar Pradesh,243684,100,243.684\nBhiwani,Haryana,243258,100,243.258\nCuddapah,Andhra Pradesh,242926,100,242.926\nBhimavaram,Andhra Pradesh,242628,100,242.628\nKrishnanagar,West Bengali,242220,100,242.22\nChandannagar,West Bengali,240756,100,240.756\nMandya,Karnataka,240530,100,240.53\nDibrugarh,Assam,240254,100,240.254\nNandyal,Andhra Pradesh,239626,100,239.626\nBalurghat,West Bengali,239592,100,239.592\nNeyveli,Tamil Nadu,236160,100,236.16\nFatehpur,Uttar Pradesh,235350,100,235.35\nMahbubnagar,Andhra Pradesh,233666,100,233.666\nBudaun,Uttar Pradesh,233390,100,233.39\nPorbandar,Gujarat,233342,100,233.342\nSilchar,Assam,230966,100,230.966\nBerhampore (Baharampur),West Bengali,230288,100,230.288\nPurnea (Purnia),Jharkhand,229824,100,229.824\nBankura,West Bengali,229752,100,229.752\nRajapalaiyam,Tamil Nadu,228404,100,228.404\nTitagarh,West Bengali,228170,100,228.17\nHalisahar,West Bengali,228056,100,228.056\nHathras,Uttar Pradesh,226570,100,226.57\nBhir (Bid),Maharashtra,224868,100,224.868\nPallavaram,Tamil Nadu,223732,100,223.732\nAnand,Gujarat,220532,100,220.532\nMango,Jharkhand,220048,100,220.048\nSantipur,West Bengali,219912,100,219.912\nBhind,Madhya Pradesh,219510,100,219.51\nGondiya,Maharashtra,218940,100,218.94\nTiruvannamalai,Tamil Nadu,218392,100,218.392\nYeotmal (Yavatmal),Maharashtra,217156,100,217.156\nKulti-Barakar,West Bengali,217036,100,217.036\nMoga,Punjab,216608,100,216.608\nShivapuri,Madhya Pradesh,216554,100,216.554\nBidar,Karnataka,216032,100,216.032\nGuntakal,Andhra Pradesh,215184,100,215.184\nUnnao,Uttar Pradesh,214850,100,214.85\nBarasat,West Bengali,214730,100,214.73\nTambaram,Tamil Nadu,214374,100,214.374\nAbohar,Punjab,214326,100,214.326\nPilibhit,Uttar Pradesh,213210,100,213.21\nValparai,Tamil Nadu,213046,100,213.046\nGonda,Uttar Pradesh,212156,100,212.156\nSurendranagar,Gujarat,211946,100,211.946\nQutubullapur,Andhra Pradesh,210760,100,210.76\nBeawar,Rajasthan,210726,100,210.726\nHindupur,Andhra Pradesh,209302,100,209.302\nGandhidham,Gujarat,209170,100,209.17\nHaldwani-cum-Kathgodam,Uttaranchal,208390,100,208.39\nTellicherry (Thalassery),Kerala,207158,100,207.158\nWardha,Maharashtra,205970,100,205.97\nRishra,West Bengali,205298,100,205.298\nBhuj,Gujarat,204352,100,204.352\nModinagar,Uttar Pradesh,203320,100,203.32\nGudivada,Andhra Pradesh,203312,100,203.312\nBasirhat,West Bengali,202818,100,202.818\nUttarpara-Kotrung,West Bengali,201734,100,201.734\nOngole,Andhra Pradesh,201672,100,201.672\nNorth Barrackpur,West Bengali,201026,100,201.026\nGuna,Madhya Pradesh,200980,100,200.98\nHaldia,West Bengali,200694,100,200.694\nHabra,West Bengali,200446,100,200.446\nKanchrapara,West Bengali,200388,100,200.388\nTonk,Rajasthan,200158,100,200.158\nChampdani,West Bengali,197636,100,197.636\nOrai,Uttar Pradesh,197280,100,197.28\nPudukkottai,Tamil Nadu,197238,100,197.238\nSasaram,Bihar,196440,100,196.44\nHazaribag,Jharkhand,195424,100,195.424\nPalayankottai,Tamil Nadu,195324,100,195.324\nBanda,Uttar Pradesh,194454,100,194.454\nGodhra,Gujarat,193626,100,193.626\nHospet,Karnataka,192644,100,192.644\nAshoknagar-Kalyangarh,West Bengali,192630,100,192.63\nAchalpur,Maharashtra,192432,100,192.432\nPatan,Gujarat,192218,100,192.218\nMandasor,Madhya Pradesh,191516,100,191.516\nDamoh,Madhya Pradesh,191322,100,191.322\nSatara,Maharashtra,190266,100,190.266\nMeerut Cantonment,Uttar Pradesh,189752,100,189.752\nDehri,Bihar,189052,100,189.052\nDelhi Cantonment,Delhi,188652,100,188.652\nChhindwara,Madhya Pradesh,187462,100,187.462\nBansberia,West Bengali,186894,100,186.894\nNagaon,Assam,186700,100,186.7\nKanpur Cantonment,Uttar Pradesh,186218,100,186.218\nVidisha,Madhya Pradesh,185834,100,185.834\nBettiah,Bihar,185166,100,185.166\nPurulia,Jharkhand,185148,100,185.148\nHassan,Karnataka,181606,100,181.606\nAmbala Sadar,Haryana,181424,100,181.424\nBaidyabati,West Bengali,181202,100,181.202\nMorvi,Gujarat,180714,100,180.714\nRaigarh,Chhatisgarh,178332,100,178.332\nVejalpur,Gujarat,178106,100,178.106\nBaghdad,Baghdad,8672000,104,8672\nMosul,Ninawa,1758000,104,1758\nIrbil,Irbil,971936,104,971.936\nKirkuk,al-Tamim,837248,104,837.248\nBasra,Basra,812592,104,812.592\nal-Sulaymaniya,al-Sulaymaniya,728192,104,728.192\nal-Najaf,al-Najaf,618020,104,618.02\nKarbala,Karbala,593410,104,593.41\nal-Hilla,Babil,537668,104,537.668\nal-Nasiriya,DhiQar,531874,104,531.874\nal-Amara,Maysan,417594,104,417.594\nal-Diwaniya,al-Qadisiya,393038,104,393.038\nal-Ramadi,al-Anbar,385112,104,385.112\nal-Kut,Wasit,366366,104,366.366\nBaquba,Diyala,229032,104,229.032\nTeheran,Teheran,13517690,103,13517.69\nMashhad,Khorasan,3774810,103,3774.81\nEsfahan,Esfahan,2532144,103,2532.144\nTabriz,East Azerbaidzan,2382086,103,2382.086\nShiraz,Fars,2106050,103,2106.05\nKaraj,Teheran,1881936,103,1881.936\nAhvaz,Khuzestan,1609960,103,1609.96\nQom,Qom,1555354,103,1555.354\nKermanshah,Kermanshah,1385972,103,1385.972\nUrmia,West Azerbaidzan,870400,103,870.4\nZahedan,Sistan va Baluchesta,839036,103,839.036\nRasht,Gilan,835496,103,835.496\nHamadan,Hamadan,802562,103,802.562\nKerman,Kerman,769982,103,769.982\nArak,Markazi,761510,103,761.51\nArdebil,Ardebil,680772,103,680.772\nYazd,Yazd,653552,103,653.552\nQazvin,Qazvin,582234,103,582.234\nZanjan,Zanjan,572590,103,572.59\nSanandaj,Kordestan,555616,103,555.616\nBandar-e-Abbas,Hormozgan,547156,103,547.156\nKhorramabad,Lorestan,545630,103,545.63\nEslamshahr,Teheran,530900,103,530.9\nBorujerd,Lorestan,435608,103,435.608\nAbadan,Khuzestan,412146,103,412.146\nDezful,Khuzestan,405278,103,405.278\nKashan,Esfahan,402744,103,402.744\nSari,Mazandaran,391764,103,391.764\nGorgan,Golestan,377420,103,377.42\nNajafabad,Esfahan,356996,103,356.996\nSabzevar,Khorasan,341476,103,341.476\nKhomeynishahr,Esfahan,331776,103,331.776\nAmol,Mazandaran,318184,103,318.184\nNeyshabur,Khorasan,317694,103,317.694\nBabol,Mazandaran,316692,103,316.692\nKhoy,West Azerbaidzan,297888,103,297.888\nMalayer,Hamadan,288746,103,288.746\nBushehr,Bushehr,287282,103,287.282\nQaemshahr,Mazandaran,286572,103,286.572\nQarchak,Teheran,285380,103,285.38\nQods,Teheran,276556,103,276.556\nSirjan,Kerman,270048,103,270.048\nBojnurd,Khorasan,269670,103,269.67\nMaragheh,East Azerbaidzan,264636,103,264.636\nBirjand,Khorasan,255216,103,255.216\nIlam,Ilam,252692,103,252.692\nBukan,West Azerbaidzan,240040,103,240.04\nMasjed-e-Soleyman,Khuzestan,233766,103,233.766\nSaqqez,Kordestan,230788,103,230.788\nGonbad-e Qabus,Mazandaran,222506,103,222.506\nSaveh,Qom,222490,103,222.49\nMahabad,West Azerbaidzan,215598,103,215.598\nVaramin,Teheran,214466,103,214.466\nAndimeshk,Khuzestan,213846,103,213.846\nKhorramshahr,Khuzestan,211272,103,211.272\nShahrud,Semnan,209530,103,209.53\nMarv Dasht,Fars,207158,103,207.158\nZabol,Sistan va Baluchesta,201774,103,201.774\nShahr-e Kord,Chaharmahal va Bakht,200954,103,200.954\nBandar-e Anzali,Gilan,197000,103,197\nRafsanjan,Kerman,196600,103,196.6\nMarand,East Azerbaidzan,192800,103,192.8\nTorbat-e Heydariyeh,Khorasan,189200,103,189.2\nJahrom,Fars,188400,103,188.4\nSemnan,Semnan,182090,103,182.09\nMiandoab,West Azerbaidzan,180200,103,180.2\nQomsheh,Esfahan,179600,103,179.6\nDublin,Leinster,963708,102,963.708\nCork,Munster,254374,102,254.374\nReykjavík,Höfuðborgarsvæði,218368,105,218.368\nJerusalem,Jerusalem,1267400,106,1267.4\nTel Aviv-Jaffa,Tel Aviv,696200,106,696.2\nHaifa,Haifa,531400,106,531.4\nRishon Le Ziyyon,Ha Merkaz,376400,106,376.4\nBeerseba,Ha Darom,327400,106,327.4\nHolon,Tel Aviv,326200,106,326.2\nPetah Tiqwa,Ha Merkaz,318800,106,318.8\nAshdod,Ha Darom,311600,106,311.6\nNetanya,Ha Merkaz,309800,106,309.8\nBat Yam,Tel Aviv,274000,106,274\nBene Beraq,Tel Aviv,267800,106,267.8\nRamat Gan,Tel Aviv,253800,106,253.8\nAshqelon,Ha Darom,184600,106,184.6\nRehovot,Ha Merkaz,180600,106,180.6\nRoma,Latium,5287162,107,5287.162\nMilano,Lombardia,2601954,107,2601.954\nNapoli,Campania,2005238,107,2005.238\nTorino,Piemonte,1807410,107,1807.41\nPalermo,Sisilia,1367588,107,1367.588\nGenova,Liguria,1272208,107,1272.208\nBologna,Emilia-Romagna,762322,107,762.322\nFirenze,Toscana,753324,107,753.324\nCatania,Sisilia,675724,107,675.724\nBari,Apulia,663696,107,663.696\nVenezia,Veneto,554610,107,554.61\nMessina,Sisilia,518312,107,518.312\nVerona,Veneto,510536,107,510.536\nTrieste,Friuli-Venezia Giuli,432918,107,432.918\nPadova,Veneto,422782,107,422.782\nTaranto,Apulia,416428,107,416.428\nBrescia,Lombardia,382634,107,382.634\nReggio di Calabria,Calabria,359234,107,359.234\nModena,Emilia-Romagna,352044,107,352.044\nPrato,Toscana,344946,107,344.946\nParma,Emilia-Romagna,337434,107,337.434\nCagliari,Sardinia,331852,107,331.852\nLivorno,Toscana,323346,107,323.346\nPerugia,Umbria,313346,107,313.346\nFoggia,Apulia,309782,107,309.782\nReggio nell´ Emilia,Emilia-Romagna,287328,107,287.328\nSalerno,Campania,284110,107,284.11\nRavenna,Emilia-Romagna,276836,107,276.836\nFerrara,Emilia-Romagna,264254,107,264.254\nRimini,Emilia-Romagna,262124,107,262.124\nSyrakusa,Sisilia,252564,107,252.564\nSassari,Sardinia,241606,107,241.606\nMonza,Lombardia,239032,107,239.032\nBergamo,Lombardia,235674,107,235.674\nPescara,Abruzzit,231396,107,231.396\nLatina,Latium,228198,107,228.198\nVicenza,Veneto,219476,107,219.476\nTerni,Umbria,215540,107,215.54\nForlì,Emilia-Romagna,214950,107,214.95\nTrento,Trentino-Alto Adige,209812,107,209.812\nNovara,Piemonte,204074,107,204.074\nPiacenza,Emilia-Romagna,196768,107,196.768\nAncona,Marche,196658,107,196.658\nLecce,Apulia,196416,107,196.416\nBolzano,Trentino-Alto Adige,194464,107,194.464\nCatanzaro,Calabria,193400,107,193.4\nLa Spezia,Liguria,191008,107,191.008\nUdine,Friuli-Venezia Giuli,189864,107,189.864\nTorre del Greco,Campania,189010,107,189.01\nAndria,Apulia,188886,107,188.886\nBrindisi,Apulia,186908,107,186.908\nGiugliano in Campania,Campania,186572,107,186.572\nPisa,Toscana,184758,107,184.758\nBarletta,Apulia,183808,107,183.808\nArezzo,Toscana,183458,107,183.458\nAlessandria,Piemonte,180578,107,180.578\nCesena,Emilia-Romagna,179704,107,179.704\nPesaro,Marche,177974,107,177.974\nDili,Dili,95800,212,95.8\nWien,Wien,3216288,16,3216.288\nGraz,Steiermark,481934,16,481.934\nLinz,North Austria,376044,16,376.044\nSalzburg,Salzburg,288494,16,288.494\nInnsbruck,Tiroli,223504,16,223.504\nKlagenfurt,Kärnten,182282,16,182.282\nSpanish Town,St. Catherine,220758,108,220.758\nKingston,St. Andrew,207924,108,207.924\nPortmore,St. Andrew,199598,108,199.598\nTokyo,Tokyo-to,15960460,110,15960.46\nJokohama [Yokohama],Kanagawa,6679188,110,6679.188\nOsaka,Osaka,5191348,110,5191.348\nNagoya,Aichi,4308752,110,4308.752\nSapporo,Hokkaido,3581772,110,3581.772\nKioto,Kyoto,2923948,110,2923.948\nKobe,Hyogo,2850278,110,2850.278\nFukuoka,Fukuoka,2616758,110,2616.758\nKawasaki,Kanagawa,2434718,110,2434.718\nHiroshima,Hiroshima,2238234,110,2238.234\nKitakyushu,Fukuoka,2032528,110,2032.528\nSendai,Miyagi,1979950,110,1979.95\nChiba,Chiba,1727860,110,1727.86\nSakai,Osaka,1595470,110,1595.47\nKumamoto,Kumamoto,1313468,110,1313.468\nOkayama,Okayama,1248538,110,1248.538\nSagamihara,Kanagawa,1172600,110,1172.6\nHamamatsu,Shizuoka,1137592,110,1137.592\nKagoshima,Kagoshima,1099954,110,1099.954\nFunabashi,Chiba,1090598,110,1090.598\nHigashiosaka,Osaka,1035570,110,1035.57\nHachioji,Tokyo-to,1026902,110,1026.902\nNiigata,Niigata,994928,110,994.928\nAmagasaki,Hyogo,962868,110,962.868\nHimeji,Hyogo,950334,110,950.334\nShizuoka,Shizuoka,947708,110,947.708\nUrawa,Saitama,939350,110,939.35\nMatsuyama,Ehime,932266,110,932.266\nMatsudo,Chiba,922252,110,922.252\nKanazawa,Ishikawa,910772,110,910.772\nKawaguchi,Saitama,904310,110,904.31\nIchikawa,Chiba,883786,110,883.786\nOmiya,Saitama,883298,110,883.298\nUtsunomiya,Tochigi,880706,110,880.706\nOita,Oita,866802,110,866.802\nNagasaki,Nagasaki,865518,110,865.518\nYokosuka,Kanagawa,860400,110,860.4\nKurashiki,Okayama,850206,110,850.206\nGifu,Gifu,816014,110,816.014\nHirakata,Osaka,806302,110,806.302\nNishinomiya,Hyogo,795236,110,795.236\nToyonaka,Osaka,793378,110,793.378\nWakayama,Wakayama,782466,110,782.466\nFukuyama,Hiroshima,753842,110,753.842\nFujisawa,Kanagawa,745680,110,745.68\nAsahikawa,Hokkaido,729626,110,729.626\nMachida,Tokyo-to,728394,110,728.394\nNara,Nara,725624,110,725.624\nTakatsuki,Osaka,723494,110,723.494\nIwaki,Fukushima,723474,110,723.474\nNagano,Nagano,722782,110,722.782\nToyohashi,Aichi,720132,110,720.132\nToyota,Aichi,692180,110,692.18\nSuita,Osaka,691500,110,691.5\nTakamatsu,Kagawa,664942,110,664.942\nKoriyama,Fukushima,660670,110,660.67\nOkazaki,Aichi,657422,110,657.422\nKawagoe,Saitama,654422,110,654.422\nTokorozawa,Saitama,651618,110,651.618\nToyama,Toyama,651580,110,651.58\nKochi,Kochi,649420,110,649.42\nKashiwa,Chiba,640592,110,640.592\nAkita,Akita,628880,110,628.88\nMiyazaki,Miyazaki,607568,110,607.568\nKoshigaya,Saitama,602892,110,602.892\nNaha,Okinawa,599702,110,599.702\nAomori,Aomori,591938,110,591.938\nHakodate,Hokkaido,589576,110,589.576\nAkashi,Hyogo,584506,110,584.506\nYokkaichi,Mie,576346,110,576.346\nFukushima,Fukushima,575050,110,575.05\nMorioka,Iwate,574706,110,574.706\nMaebashi,Gumma,568946,110,568.946\nKasugai,Aichi,564696,110,564.696\nOtsu,Shiga,564140,110,564.14\nIchihara,Chiba,558560,110,558.56\nYao,Osaka,552842,110,552.842\nIchinomiya,Aichi,541656,110,541.656\nTokushima,Tokushima,539298,110,539.298\nKakogawa,Hyogo,532562,110,532.562\nIbaraki,Osaka,522040,110,522.04\nNeyagawa,Osaka,514630,110,514.63\nShimonoseki,Yamaguchi,514526,110,514.526\nYamagata,Yamagata,511234,110,511.234\nFukui,Fukui,509636,110,509.636\nHiratsuka,Kanagawa,508414,110,508.414\nMito,Ibaragi,493118,110,493.118\nSasebo,Nagasaki,488480,110,488.48\nHachinohe,Aomori,485958,110,485.958\nTakasaki,Gumma,478248,110,478.248\nShimizu,Shizuoka,478246,110,478.246\nKurume,Fukuoka,471222,110,471.222\nFuji,Shizuoka,463054,110,463.054\nSoka,Saitama,445536,110,445.536\nFuchu,Tokyo-to,441152,110,441.152\nChigasaki,Kanagawa,432030,110,432.03\nAtsugi,Kanagawa,424814,110,424.814\nNumazu,Shizuoka,422764,110,422.764\nAgeo,Saitama,418884,110,418.884\nYamato,Kanagawa,416468,110,416.468\nMatsumoto,Nagano,413602,110,413.602\nKure,Hiroshima,413008,110,413.008\nTakarazuka,Hyogo,411986,110,411.986\nKasukabe,Saitama,403676,110,403.676\nChofu,Tokyo-to,403170,110,403.17\nOdawara,Kanagawa,400342,110,400.342\nKofu,Yamanashi,399506,110,399.506\nKushiro,Hokkaido,395216,110,395.216\nKishiwada,Osaka,394552,110,394.552\nHitachi,Ibaragi,393244,110,393.244\nNagaoka,Niigata,384814,110,384.814\nItami,Hyogo,381772,110,381.772\nUji,Kyoto,377470,110,377.47\nSuzuka,Mie,368122,110,368.122\nHirosaki,Aomori,355044,110,355.044\nUbe,Yamaguchi,350412,110,350.412\nKodaira,Tokyo-to,349968,110,349.968\nTakaoka,Toyama,348760,110,348.76\nObihiro,Hokkaido,347370,110,347.37\nTomakomai,Hokkaido,343916,110,343.916\nSaga,Saga,340068,110,340.068\nSakura,Chiba,336144,110,336.144\nKamakura,Kanagawa,335322,110,335.322\nMitaka,Tokyo-to,334536,110,334.536\nIzumi,Osaka,333958,110,333.958\nHino,Tokyo-to,333540,110,333.54\nHadano,Kanagawa,333024,110,333.024\nAshikaga,Tochigi,330486,110,330.486\nTsu,Mie,329086,110,329.086\nSayama,Saitama,324944,110,324.944\nYachiyo,Chiba,322444,110,322.444\nTsukuba,Ibaragi,321536,110,321.536\nTachikawa,Tokyo-to,318860,110,318.86\nKumagaya,Saitama,314342,110,314.342\nMoriguchi,Osaka,311882,110,311.882\nOtaru,Hokkaido,311568,110,311.568\nAnjo,Aichi,307646,110,307.646\nNarashino,Chiba,305698,110,305.698\nOyama,Tochigi,305640,110,305.64\nOgaki,Gifu,303516,110,303.516\nMatsue,Shimane,299642,110,299.642\nKawanishi,Hyogo,299588,110,299.588\nHitachinaka,Tokyo-to,296012,110,296.012\nNiiza,Saitama,295488,110,295.488\nNagareyama,Chiba,295476,110,295.476\nTottori,Tottori,295046,110,295.046\nTama,Ibaragi,293424,110,293.424\nIruma,Saitama,291844,110,291.844\nOta,Gumma,290634,110,290.634\nOmuta,Fukuoka,285778,110,285.778\nKomaki,Aichi,279654,110,279.654\nOme,Tokyo-to,278432,110,278.432\nKadoma,Osaka,277906,110,277.906\nYamaguchi,Yamaguchi,276420,110,276.42\nHigashimurayama,Tokyo-to,273940,110,273.94\nYonago,Tottori,272922,110,272.922\nMatsubara,Osaka,270020,110,270.02\nMusashino,Tokyo-to,268852,110,268.852\nTsuchiura,Ibaragi,268144,110,268.144\nJoetsu,Niigata,267010,110,267.01\nMiyakonojo,Miyazaki,266366,110,266.366\nMisato,Saitama,265914,110,265.914\nKakamigahara,Gifu,263662,110,263.662\nDaito,Osaka,261188,110,261.188\nSeto,Aichi,260940,110,260.94\nKariya,Aichi,255938,110,255.938\nUrayasu,Chiba,255100,110,255.1\nBeppu,Oita,254972,110,254.972\nNiihama,Ehime,254414,110,254.414\nMinoo,Osaka,254052,110,254.052\nFujieda,Shizuoka,253794,110,253.794\nAbiko,Chiba,253340,110,253.34\nNobeoka,Miyazaki,251094,110,251.094\nTondabayashi,Osaka,250188,110,250.188\nUeda,Nagano,248434,110,248.434\nKashihara,Nara,248026,110,248.026\nMatsusaka,Mie,247164,110,247.164\nIsesaki,Gumma,246570,110,246.57\nZama,Kanagawa,244092,110,244.092\nKisarazu,Chiba,243934,110,243.934\nNoda,Chiba,242060,110,242.06\nIshinomaki,Miyagi,241926,110,241.926\nFujinomiya,Shizuoka,239428,110,239.428\nKawachinagano,Osaka,239332,110,239.332\nImabari,Ehime,238714,110,238.714\nAizuwakamatsu,Fukushima,238574,110,238.574\nHigashihiroshima,Hiroshima,238332,110,238.332\nHabikino,Osaka,237936,110,237.936\nEbetsu,Hokkaido,237610,110,237.61\nHofu,Yamaguchi,237502,110,237.502\nKiryu,Gumma,236652,110,236.652\nOkinawa,Okinawa,235496,110,235.496\nYaizu,Shizuoka,234516,110,234.516\nToyokawa,Aichi,231562,110,231.562\nEbina,Kanagawa,231142,110,231.142\nAsaka,Saitama,229630,110,229.63\nHigashikurume,Tokyo-to,223332,110,223.332\nIkoma,Nara,223290,110,223.29\nKitami,Hokkaido,222590,110,222.59\nKoganei,Tokyo-to,221938,110,221.938\nIwatsuki,Saitama,220068,110,220.068\nMishima,Shizuoka,219398,110,219.398\nHanda,Aichi,217200,110,217.2\nMuroran,Hokkaido,216550,110,216.55\nKomatsu,Ishikawa,215874,110,215.874\nYatsushiro,Kumamoto,215322,110,215.322\nIida,Nagano,215166,110,215.166\nTokuyama,Yamaguchi,214156,110,214.156\nKokubunji,Tokyo-to,213992,110,213.992\nAkishima,Tokyo-to,213828,110,213.828\nIwakuni,Yamaguchi,213294,110,213.294\nKusatsu,Shiga,212464,110,212.464\nKuwana,Mie,212242,110,212.242\nSanda,Hyogo,211286,110,211.286\nHikone,Shiga,211016,110,211.016\nToda,Saitama,207938,110,207.938\nTajimi,Gifu,206342,110,206.342\nIkeda,Osaka,205420,110,205.42\nFukaya,Saitama,204312,110,204.312\nIse,Mie,203464,110,203.464\nSakata,Yamagata,203302,110,203.302\nKasuga,Fukuoka,202688,110,202.688\nKamagaya,Chiba,201642,110,201.642\nTsuruoka,Yamagata,201426,110,201.426\nHoya,Tokyo-to,200626,110,200.626\nNishio,Chiba,200064,110,200.064\nTokai,Aichi,199476,110,199.476\nInazawa,Aichi,197492,110,197.492\nSakado,Saitama,196442,110,196.442\nIsehara,Kanagawa,196246,110,196.246\nTakasago,Hyogo,195264,110,195.264\nFujimi,Saitama,193944,110,193.944\nUrasoe,Okinawa,192004,110,192.004\nYonezawa,Yamagata,191184,110,191.184\nKonan,Aichi,191042,110,191.042\nYamatokoriyama,Nara,190330,110,190.33\nMaizuru,Kyoto,189568,110,189.568\nOnomichi,Hiroshima,187512,110,187.512\nHigashimatsuyama,Saitama,186684,110,186.684\nKimitsu,Chiba,186432,110,186.432\nIsahaya,Nagasaki,186116,110,186.116\nKanuma,Tochigi,186106,110,186.106\nIzumisano,Osaka,185166,110,185.166\nKameoka,Kyoto,184796,110,184.796\nMobara,Chiba,183328,110,183.328\nNarita,Chiba,182940,110,182.94\nKashiwazaki,Niigata,182458,110,182.458\nTsuyama,Okayama,182340,110,182.34\nSanaa,Sanaa,1007200,235,1007.2\nAden,Aden,796600,235,796.6\nTaizz,Taizz,635200,235,635.2\nHodeida,Hodeida,597000,235,597\nal-Mukalla,Hadramawt,244800,235,244.8\nIbb,Ibb,206600,235,206.6\nAmman,Amman,2000000,109,2000\nal-Zarqa,al-Zarqa,779630,109,779.63\nIrbid,Irbid,463022,109,463.022\nal-Rusayfa,al-Zarqa,274494,109,274.494\nWadi al-Sir,Amman,178208,109,178.208\nFlying Fish Cove,–,1400,53,1.4\nBeograd,Central Serbia,2408000,236,2408\nNovi Sad,Vojvodina,359252,236,359.252\nNiš,Central Serbia,350782,236,350.782\nPriština,Kosovo and Metohija,310992,236,310.992\nKragujevac,Central Serbia,294610,236,294.61\nPodgorica,Montenegro,270000,236,270\nSubotica,Vojvodina,200772,236,200.772\nPrizren,Kosovo and Metohija,184606,236,184.606\nPhnom Penh,Phnom Penh,1140310,114,1140.31\nBattambang,Battambang,259600,114,259.6\nSiem Reap,Siem Reap,210200,114,210.2\nDouala,Littoral,2896600,44,2896.6\nYaoundé,Centre,2745600,44,2745.6\nGaroua,Nord,354000,44,354\nMaroua,Extrême-Nord,286000,44,286\nBamenda,Nord-Ouest,276000,44,276\nBafoussam,Ouest,262000,44,262\nNkongsamba,Littoral,224908,44,224.908\nMontréal,Québec,2032752,38,2032.752\nCalgary,Alberta,1536164,38,1536.164\nToronto,Ontario,1376550,38,1376.55\nNorth York,Ontario,1245264,38,1245.264\nWinnipeg,Manitoba,1236954,38,1236.954\nEdmonton,Alberta,1232612,38,1232.612\nMississauga,Ontario,1216144,38,1216.144\nScarborough,Ontario,1189002,38,1189.002\nVancouver,British Colombia,1028016,38,1028.016\nEtobicoke,Ontario,697690,38,697.69\nLondon,Ontario,679834,38,679.834\nHamilton,Ontario,671228,38,671.228\nOttawa,Ontario,670554,38,670.554\nLaval,Québec,660786,38,660.786\nSurrey,British Colombia,608954,38,608.954\nBrampton,Ontario,593422,38,593.422\nWindsor,Ontario,415176,38,415.176\nSaskatoon,Saskatchewan,387294,38,387.294\nKitchener,Ontario,379918,38,379.918\nMarkham,Ontario,378196,38,378.196\nRegina,Saskatchewan,360800,38,360.8\nBurnaby,British Colombia,358418,38,358.418\nQuébec,Québec,334528,38,334.528\nYork,Ontario,309960,38,309.96\nRichmond,British Colombia,297734,38,297.734\nVaughan,Ontario,295778,38,295.778\nBurlington,Ontario,290300,38,290.3\nOshawa,Ontario,280346,38,280.346\nOakville,Ontario,278384,38,278.384\nSaint Catharines,Ontario,272432,38,272.432\nLongueuil,Québec,255954,38,255.954\nRichmond Hill,Ontario,232856,38,232.856\nThunder Bay,Ontario,231826,38,231.826\nNepean,Ontario,230200,38,230.2\nCape Breton,Nova Scotia,229466,38,229.466\nEast York,Ontario,228068,38,228.068\nHalifax,Nova Scotia,227820,38,227.82\nCambridge,Ontario,218372,38,218.372\nGloucester,Ontario,214628,38,214.628\nAbbotsford,British Colombia,210806,38,210.806\nGuelph,Ontario,207186,38,207.186\nSaint John´s,Newfoundland,203872,38,203.872\nCoquitlam,British Colombia,203640,38,203.64\nSaanich,British Colombia,202776,38,202.776\nGatineau,Québec,201404,38,201.404\nDelta,British Colombia,190822,38,190.822\nSudbury,Ontario,185372,38,185.372\nKelowna,British Colombia,178884,38,178.884\nBarrie,Ontario,178538,38,178.538\nPraia,São Tiago,189600,50,189.6\nAlmaty,Almaty Qalasy,2258800,111,2258.8\nQaraghandy,Qaraghandy,873800,111,873.8\nShymkent,South Kazakstan,720200,111,720.2\nTaraz,Taraz,660200,111,660.2\nAstana,Astana,622400,111,622.4\nÖskemen,East Kazakstan,622000,111,622\nPavlodar,Pavlodar,601000,111,601\nSemey,East Kazakstan,539200,111,539.2\nAqtöbe,Aqtöbe,506200,111,506.2\nQostanay,Qostanay,442800,111,442.8\nPetropavl,North Kazakstan,407000,111,407\nOral,West Kazakstan,391000,111,391\nTemirtau,Qaraghandy,341000,111,341\nQyzylorda,Qyzylorda,314800,111,314.8\nAqtau,Mangghystau,286800,111,286.8\nAtyrau,Atyrau,285000,111,285\nEkibastuz,Pavlodar,254400,111,254.4\nKökshetau,North Kazakstan,246800,111,246.8\nRudnyy,Qostanay,219000,111,219\nTaldyqorghan,Almaty,196000,111,196\nZhezqazghan,Qaraghandy,180000,111,180\nNairobi,Nairobi,4580000,112,4580\nMombasa,Coast,923506,112,923.506\nKisumu,Nyanza,385466,112,385.466\nNakuru,Rift Valley,327854,112,327.854\nMachakos,Eastern,232586,112,232.586\nEldoret,Rift Valley,223764,112,223.764\nMeru,Eastern,189894,112,189.894\nNyeri,Central,182516,112,182.516\nBangui,Bangui,1048000,37,1048\nShanghai,Shanghai,19392600,42,19392.6\nPeking,Peking,14944000,42,14944\nChongqing,Chongqing,12703200,42,12703.2\nTianjin,Tianjin,10573600,42,10573.6\nWuhan,Hubei,8689200,42,8689.2\nHarbin,Heilongjiang,8579600,42,8579.6\nShenyang,Liaoning,8530400,42,8530.4\nKanton [Guangzhou],Guangdong,8512600,42,8512.6\nChengdu,Sichuan,6723000,42,6723\nNanking [Nanjing],Jiangsu,5740600,42,5740.6\nChangchun,Jilin,5624000,42,5624\nXi´an,Shaanxi,5522800,42,5522.8\nDalian,Liaoning,5394000,42,5394\nQingdao,Shandong,5192000,42,5192\nJinan,Shandong,4556200,42,4556.2\nHangzhou,Zhejiang,4381000,42,4381\nZhengzhou,Henan,4214400,42,4214.4\nShijiazhuang,Hebei,4083000,42,4083\nTaiyuan,Shanxi,3936800,42,3936.8\nKunming,Yunnan,3659000,42,3659\nChangsha,Hunan,3619600,42,3619.6\nNanchang,Jiangxi,3383200,42,3383.2\nFuzhou,Fujian,3187600,42,3187.6\nLanzhou,Gansu,3131600,42,3131.6\nGuiyang,Guizhou,2930400,42,2930.4\nNingbo,Zhejiang,2742400,42,2742.4\nHefei,Anhui,2738200,42,2738.2\nUrumtši [Ürümqi],Xinxiang,2620200,42,2620.2\nAnshan,Liaoning,2400000,42,2400\nFushun,Liaoning,2400000,42,2400\nNanning,Guangxi,2323600,42,2323.6\nZibo,Shandong,2280000,42,2280\nQiqihar,Heilongjiang,2140000,42,2140\nJilin,Jilin,2080000,42,2080\nTangshan,Hebei,2080000,42,2080\nBaotou,Inner Mongolia,1960000,42,1960\nShenzhen,Guangdong,1901000,42,1901\nHohhot,Inner Mongolia,1833400,42,1833.4\nHandan,Hebei,1680000,42,1680\nWuxi,Jiangsu,1660000,42,1660\nXuzhou,Jiangsu,1620000,42,1620\nDatong,Shanxi,1600000,42,1600\nYichun,Heilongjiang,1600000,42,1600\nBenxi,Liaoning,1540000,42,1540\nLuoyang,Henan,1520000,42,1520\nSuzhou,Jiangsu,1420000,42,1420\nXining,Qinghai,1400400,42,1400.4\nHuainan,Anhui,1400000,42,1400\nJixi,Heilongjiang,1367770,42,1367.77\nDaqing,Heilongjiang,1320000,42,1320\nFuxin,Liaoning,1280000,42,1280\nAmoy [Xiamen],Fujian,1255000,42,1255\nLiuzhou,Guangxi,1220000,42,1220\nShantou,Guangdong,1160000,42,1160\nJinzhou,Liaoning,1140000,42,1140\nMudanjiang,Heilongjiang,1140000,42,1140\nYinchuan,Ningxia,1089000,42,1089\nChangzhou,Jiangsu,1060000,42,1060\nZhangjiakou,Hebei,1060000,42,1060\nDandong,Liaoning,1040000,42,1040\nHegang,Heilongjiang,1040000,42,1040\nKaifeng,Henan,1020000,42,1020\nJiamusi,Heilongjiang,986818,42,986.818\nLiaoyang,Liaoning,985118,42,985.118\nHengyang,Hunan,974296,42,974.296\nBaoding,Hebei,966310,42,966.31\nHunjiang,Jilin,964086,42,964.086\nXinxiang,Henan,947524,42,947.524\nHuangshi,Hubei,915202,42,915.202\nHaikou,Hainan,908600,42,908.6\nYantai,Shandong,904254,42,904.254\nBengbu,Anhui,898490,42,898.49\nXiangtan,Hunan,883936,42,883.936\nWeifang,Shandong,857044,42,857.044\nWuhu,Anhui,851480,42,851.48\nPingxiang,Jiangxi,851158,42,851.158\nYingkou,Liaoning,843178,42,843.178\nAnyang,Henan,840664,42,840.664\nPanzhihua,Sichuan,830932,42,830.932\nPingdingshan,Henan,821550,42,821.55\nXiangfan,Hubei,820814,42,820.814\nZhuzhou,Hunan,819848,42,819.848\nJiaozuo,Henan,818200,42,818.2\nWenzhou,Zhejiang,803742,42,803.742\nZhangjiang,Guangdong,801994,42,801.994\nZigong,Sichuan,786368,42,786.368\nShuangyashan,Heilongjiang,772162,42,772.162\nZaozhuang,Shandong,761692,42,761.692\nYakeshi,Inner Mongolia,755738,42,755.738\nYichang,Hubei,743202,42,743.202\nZhenjiang,Jiangsu,736632,42,736.632\nHuaibei,Anhui,733098,42,733.098\nQinhuangdao,Hebei,729944,42,729.944\nGuilin,Guangxi,728260,42,728.26\nLiupanshui,Guizhou,727908,42,727.908\nPanjin,Liaoning,725546,42,725.546\nYangquan,Shanxi,724536,42,724.536\nJinxi,Liaoning,714104,42,714.104\nLiaoyuan,Jilin,708282,42,708.282\nLianyungang,Jiangsu,708278,42,708.278\nXianyang,Shaanxi,704250,42,704.25\nTai´an,Shandong,701392,42,701.392\nChifeng,Inner Mongolia,700154,42,700.154\nShaoguan,Guangdong,700086,42,700.086\nNantong,Jiangsu,686682,42,686.682\nLeshan,Sichuan,682256,42,682.256\nBaoji,Shaanxi,675530,42,675.53\nLinyi,Shandong,649440,42,649.44\nTonghua,Jilin,649200,42,649.2\nSiping,Jilin,634446,42,634.446\nChangzhi,Shanxi,634288,42,634.288\nTengzhou,Shandong,630166,42,630.166\nChaozhou,Guangdong,626938,42,626.938\nYangzhou,Jiangsu,625784,42,625.784\nDongwan,Guangdong,617338,42,617.338\nMa´anshan,Anhui,610842,42,610.842\nFoshan,Guangdong,606320,42,606.32\nYueyang,Hunan,605600,42,605.6\nXingtai,Hebei,605578,42,605.578\nChangde,Hunan,602552,42,602.552\nShihezi,Xinxiang,599352,42,599.352\nYancheng,Jiangsu,593662,42,593.662\nJiujiang,Jiangxi,582374,42,582.374\nDongying,Shandong,563456,42,563.456\nShashi,Hubei,562704,42,562.704\nXintai,Shandong,562496,42,562.496\nJingdezhen,Jiangxi,562366,42,562.366\nTongchuan,Shaanxi,561314,42,561.314\nZhongshan,Guangdong,557658,42,557.658\nShiyan,Hubei,547572,42,547.572\nTieli,Heilongjiang,531366,42,531.366\nJining,Shandong,530496,42,530.496\nWuhai,Inner Mongolia,528162,42,528.162\nMianyang,Sichuan,525894,42,525.894\nLuzhou,Sichuan,525784,42,525.784\nZunyi,Guizhou,523724,42,523.724\nShizuishan,Ningxia,515724,42,515.724\nNeijiang,Sichuan,512024,42,512.024\nTongliao,Inner Mongolia,510258,42,510.258\nTieling,Liaoning,509684,42,509.684\nWafangdian,Liaoning,503466,42,503.466\nAnqing,Anhui,501436,42,501.436\nShaoyang,Hunan,494454,42,494.454\nLaiwu,Shandong,493666,42,493.666\nChengde,Hebei,493598,42,493.598\nTianshui,Gansu,489948,42,489.948\nNanyang,Henan,486606,42,486.606\nCangzhou,Hebei,485416,42,485.416\nYibin,Sichuan,482038,42,482.038\nHuaiyin,Jiangsu,479350,42,479.35\nDunhua,Jilin,470200,42,470.2\nYanji,Jilin,461784,42,461.784\nJiangmen,Guangdong,461174,42,461.174\nTongling,Anhui,456034,42,456.034\nSuihua,Heilongjiang,455762,42,455.762\nGongziling,Jilin,453138,42,453.138\nXiantao,Hubei,445768,42,445.768\nChaoyang,Liaoning,444788,42,444.788\nGanzhou,Jiangxi,440258,42,440.258\nHuzhou,Zhejiang,436142,42,436.142\nBaicheng,Jilin,435974,42,435.974\nShangzi,Heilongjiang,430746,42,430.746\nYangjiang,Guangdong,430392,42,430.392\nQitaihe,Heilongjiang,429914,42,429.914\nGejiu,Yunnan,428588,42,428.588\nJiangyin,Jiangsu,427318,42,427.318\nHebi,Henan,425952,42,425.952\nJiaxing,Zhejiang,423052,42,423.052\nWuzhou,Guangxi,420904,42,420.904\nMeihekou,Jilin,418076,42,418.076\nXuchang,Henan,417630,42,417.63\nLiaocheng,Shandong,415688,42,415.688\nHaicheng,Liaoning,411120,42,411.12\nQianjiang,Hubei,411008,42,411.008\nBaiyin,Gansu,409940,42,409.94\nBei´an,Heilongjiang,409798,42,409.798\nYixing,Jiangsu,401648,42,401.648\nLaizhou,Shandong,397328,42,397.328\nQaramay,Xinxiang,395204,42,395.204\nAcheng,Heilongjiang,395190,42,395.19\nDezhou,Shandong,390970,42,390.97\nNanping,Fujian,390128,42,390.128\nZhaoqing,Guangdong,389568,42,389.568\nBeipiao,Liaoning,388602,42,388.602\nFengcheng,Jiangxi,387568,42,387.568\nFuyu,Jilin,385962,42,385.962\nXinyang,Henan,385018,42,385.018\nDongtai,Jiangsu,384494,42,384.494\nYuci,Shanxi,382712,42,382.712\nHonghu,Hubei,381544,42,381.544\nEzhou,Hubei,380246,42,380.246\nHeze,Shandong,378586,42,378.586\nDaxian,Sichuan,376202,42,376.202\nLinfen,Shanxi,374618,42,374.618\nTianmen,Hubei,372664,42,372.664\nYiyang,Hunan,371636,42,371.636\nQuanzhou,Fujian,370308,42,370.308\nRizhao,Shandong,370096,42,370.096\nDeyang,Sichuan,364976,42,364.976\nGuangyuan,Sichuan,364482,42,364.482\nChangshu,Jiangsu,363610,42,363.61\nZhangzhou,Fujian,362848,42,362.848\nHailar,Inner Mongolia,361300,42,361.3\nNanchong,Sichuan,360546,42,360.546\nJiutai,Jilin,360260,42,360.26\nZhaodong,Heilongjiang,359952,42,359.952\nShaoxing,Zhejiang,359636,42,359.636\nFuyang,Anhui,359144,42,359.144\nMaoming,Guangdong,357366,42,357.366\nQujing,Yunnan,357338,42,357.338\nGhulja,Xinxiang,354386,42,354.386\nJiaohe,Jilin,352734,42,352.734\nPuyang,Henan,351976,42,351.976\nHuadian,Jilin,351746,42,351.746\nJiangyou,Sichuan,351506,42,351.506\nQashqar,Xinxiang,349140,42,349.14\nAnshun,Guizhou,348284,42,348.284\nFuling,Sichuan,347756,42,347.756\nXinyu,Jiangxi,347048,42,347.048\nHanzhong,Shaanxi,339860,42,339.86\nDanyang,Jiangsu,339206,42,339.206\nChenzhou,Hunan,338800,42,338.8\nXiaogan,Hubei,332560,42,332.56\nShangqiu,Henan,329760,42,329.76\nZhuhai,Guangdong,329494,42,329.494\nQingyuan,Guangdong,329282,42,329.282\nAqsu,Xinxiang,328184,42,328.184\nJining,Inner Mongolia,327104,42,327.104\nXiaoshan,Zhejiang,325860,42,325.86\nZaoyang,Hubei,324396,42,324.396\nXinghua,Jiangsu,323820,42,323.82\nHami,Xinxiang,322630,42,322.63\nHuizhou,Guangdong,322046,42,322.046\nJinmen,Hubei,321588,42,321.588\nSanming,Fujian,321382,42,321.382\nUlanhot,Inner Mongolia,319076,42,319.076\nKorla,Xinxiang,318688,42,318.688\nWanxian,Sichuan,313646,42,313.646\nRui´an,Zhejiang,312936,42,312.936\nZhoushan,Zhejiang,312634,42,312.634\nLiangcheng,Shandong,312614,42,312.614\nJiaozhou,Shandong,306728,42,306.728\nTaizhou,Jiangsu,304884,42,304.884\nSuzhou,Anhui,303724,42,303.724\nYichun,Jiangxi,303170,42,303.17\nTaonan,Jilin,300336,42,300.336\nPingdu,Shandong,300246,42,300.246\nJi´an,Jiangxi,297166,42,297.166\nLongkou,Shandong,296724,42,296.724\nLangfang,Hebei,296210,42,296.21\nZhoukou,Henan,292576,42,292.576\nSuining,Sichuan,292172,42,292.172\nYulin,Guangxi,288934,42,288.934\nJinhua,Zhejiang,288560,42,288.56\nLiu´an,Anhui,288496,42,288.496\nShuangcheng,Heilongjiang,285318,42,285.318\nSuizhou,Hubei,284604,42,284.604\nAnkang,Shaanxi,284340,42,284.34\nWeinan,Shaanxi,280338,42,280.338\nLongjing,Jilin,278834,42,278.834\nDa´an,Jilin,277926,42,277.926\nLengshuijiang,Hunan,275988,42,275.988\nLaiyang,Shandong,274160,42,274.16\nXianning,Hubei,273622,42,273.622\nDali,Yunnan,273108,42,273.108\nAnda,Heilongjiang,272892,42,272.892\nJincheng,Shanxi,272792,42,272.792\nLongyan,Fujian,268962,42,268.962\nXichang,Sichuan,268838,42,268.838\nWendeng,Shandong,267820,42,267.82\nHailun,Heilongjiang,267130,42,267.13\nBinzhou,Shandong,267110,42,267.11\nLinhe,Inner Mongolia,266366,42,266.366\nWuwei,Gansu,266202,42,266.202\nDuyun,Guizhou,265942,42,265.942\nMishan,Heilongjiang,265488,42,265.488\nShangrao,Jiangxi,264910,42,264.91\nChangji,Xinxiang,264520,42,264.52\nMeixian,Guangdong,264312,42,264.312\nYushu,Jilin,263722,42,263.722\nTiefa,Liaoning,263614,42,263.614\nHuai´an,Jiangsu,262298,42,262.298\nLeiyang,Hunan,260230,42,260.23\nZalantun,Inner Mongolia,260062,42,260.062\nWeihai,Shandong,257776,42,257.776\nLoudi,Hunan,256836,42,256.836\nQingzhou,Shandong,256516,42,256.516\nQidong,Jiangsu,253744,42,253.744\nHuaihua,Hunan,253570,42,253.57\nLuohe,Henan,252876,42,252.876\nChuzhou,Anhui,250682,42,250.682\nKaiyuan,Liaoning,248438,42,248.438\nLinqing,Shandong,247916,42,247.916\nChaohu,Anhui,247352,42,247.352\nLaohekou,Hubei,246732,42,246.732\nDujiangyan,Sichuan,246714,42,246.714\nZhumadian,Henan,246464,42,246.464\nLinchuan,Jiangxi,243898,42,243.898\nJiaonan,Shandong,242794,42,242.794\nSanmenxia,Henan,241046,42,241.046\nHeyuan,Guangdong,240202,42,240.202\nManzhouli,Inner Mongolia,240046,42,240.046\nLhasa,Tibet,240000,42,240\nLianyuan,Hunan,237716,42,237.716\nKuytun,Xinxiang,237106,42,237.106\nPuqi,Hubei,234528,42,234.528\nHongjiang,Hunan,232376,42,232.376\nQinzhou,Guangxi,229172,42,229.172\nRenqiu,Hebei,228512,42,228.512\nYuyao,Zhejiang,228130,42,228.13\nGuigang,Guangxi,228050,42,228.05\nKaili,Guizhou,227916,42,227.916\nYan´an,Shaanxi,226554,42,226.554\nBeihai,Guangxi,225346,42,225.346\nXuangzhou,Anhui,225346,42,225.346\nQuzhou,Zhejiang,224746,42,224.746\nYong´an,Fujian,223524,42,223.524\nZixing,Hunan,220096,42,220.096\nLiyang,Jiangsu,219040,42,219.04\nYizheng,Jiangsu,218536,42,218.536\nYumen,Gansu,218468,42,218.468\nLiling,Hunan,217008,42,217.008\nYuncheng,Shanxi,216718,42,216.718\nShanwei,Guangdong,215694,42,215.694\nCixi,Zhejiang,214658,42,214.658\nYuanjiang,Hunan,214008,42,214.008\nBozhou,Anhui,212692,42,212.692\nJinchang,Gansu,210574,42,210.574\nFu´an,Fujian,210530,42,210.53\nSuqian,Jiangsu,210042,42,210.042\nShishou,Hubei,209142,42,209.142\nHengshui,Hebei,208538,42,208.538\nDanjiangkou,Hubei,206422,42,206.422\nFujin,Heilongjiang,206208,42,206.208\nSanya,Hainan,205640,42,205.64\nGuangshui,Hubei,205540,42,205.54\nHuangshan,Anhui,205256,42,205.256\nXingcheng,Liaoning,204768,42,204.768\nZhucheng,Shandong,204268,42,204.268\nKunshan,Jiangsu,204104,42,204.104\nHaining,Zhejiang,200956,42,200.956\nPingliang,Gansu,198530,42,198.53\nFuqing,Fujian,198386,42,198.386\nXinzhou,Shanxi,197334,42,197.334\nJieyang,Guangdong,197062,42,197.062\nZhangjiagang,Jiangsu,195988,42,195.988\nTong Xian,Peking,194336,42,194.336\nYa´an,Sichuan,191800,42,191.8\nJinzhou,Liaoning,191522,42,191.522\nEmeishan,Sichuan,188000,42,188\nEnshi,Hubei,186112,42,186.112\nBose,Guangxi,186018,42,186.018\nYuzhou,Henan,185778,42,185.778\nKaiyuan,Yunnan,183998,42,183.998\nTumen,Jilin,182942,42,182.942\nPutian,Fujian,182060,42,182.06\nLinhai,Zhejiang,181740,42,181.74\nXilin Hot,Inner Mongolia,181292,42,181.292\nShaowu,Fujian,180572,42,180.572\nJunan,Shandong,180444,42,180.444\nHuaying,Sichuan,178800,42,178.8\nPingyi,Shandong,178746,42,178.746\nHuangyan,Zhejiang,178576,42,178.576\nBishkek,Bishkek shaary,1178800,113,1178.8\nOsh,Osh,445400,113,445.4\nBikenibeu,South Tarawa,10110,115,10.11\nBairiki,South Tarawa,4452,115,4.452\nSantafé de Bogotá,Santafé de Bogotá,12521724,48,12521.724\nCali,Valle,4154772,48,4154.772\nMedellín,Antioquia,3722530,48,3722.53\nBarranquilla,Atlántico,2446520,48,2446.52\nCartagena,Bolívar,1611514,48,1611.514\nCúcuta,Norte de Santander,1213864,48,1213.864\nBucaramanga,Santander,1031110,48,1031.11\nIbagué,Tolima,787328,48,787.328\nPereira,Risaralda,763450,48,763.45\nSanta Marta,Magdalena,718294,48,718.294\nManizales,Caldas,675160,48,675.16\nBello,Antioquia,666940,48,666.94\nPasto,Nariño,664792,48,664.792\nNeiva,Huila,600104,48,600.104\nSoledad,Atlántico,590116,48,590.116\nArmenia,Quindío,577954,48,577.954\nVillavicencio,Meta,546280,48,546.28\nSoacha,Cundinamarca,544116,48,544.116\nValledupar,Cesar,526494,48,526.494\nMontería,Córdoba,496490,48,496.49\nItagüí,Antioquia,457970,48,457.97\nPalmira,Valle,453018,48,453.018\nBuenaventura,Valle,448672,48,448.672\nFloridablanca,Santander,443826,48,443.826\nSincelejo,Sucre,441408,48,441.408\nPopayán,Cauca,401438,48,401.438\nBarrancabermeja,Santander,356040,48,356.04\nDos Quebradas,Risaralda,318726,48,318.726\nTuluá,Valle,304976,48,304.976\nEnvigado,Antioquia,271696,48,271.696\nCartago,Valle,251768,48,251.768\nGirardot,Cundinamarca,221926,48,221.926\nBuga,Valle,221398,48,221.398\nTunja,Boyacá,219480,48,219.48\nFlorencia,Caquetá,217148,48,217.148\nMaicao,La Guajira,216106,48,216.106\nSogamoso,Boyacá,215456,48,215.456\nGiron,Santander,181376,48,181.376\nMoroni,Njazidja,72000,49,72\nBrazzaville,Brazzaville,1900000,46,1900\nPointe-Noire,Kouilou,1000000,46,1000\nKinshasa,Kinshasa,10128000,45,10128\nLubumbashi,Shaba,1702762,45,1702.762\nMbuji-Mayi,East Kasai,1612950,45,1612.95\nKolwezi,Shaba,835620,45,835.62\nKisangani,Haute-Zaïre,835034,45,835.034\nKananga,West Kasai,786060,45,786.06\nLikasi,Shaba,598236,45,598.236\nBukavu,South Kivu,403138,45,403.138\nKikwit,Bandundu,364284,45,364.284\nTshikapa,West Kasai,361720,45,361.72\nMatadi,Bas-Zaïre,345460,45,345.46\nMbandaka,Equateur,339682,45,339.682\nMwene-Ditu,East Kasai,274918,45,274.918\nBoma,Bas-Zaïre,270568,45,270.568\nUvira,South Kivu,231180,45,231.18\nButembo,North Kivu,218812,45,218.812\nGoma,North Kivu,218188,45,218.188\nKalemie,Shaba,202618,45,202.618\nBantam,Home Island,1006,39,1.006\nWest Island,West Island,334,39,0.334\nPyongyang,Pyongyang-si,4968000,174,4968\nHamhung,Hamgyong N,1419460,174,1419.46\nChongjin,Hamgyong P,1164960,174,1164.96\nNampo,Nampo-si,1132400,174,1132.4\nSinuiju,Pyongan P,652022,174,652.022\nWonsan,Kangwon,600296,174,600.296\nPhyongsong,Pyongan N,545868,174,545.868\nSariwon,Hwanghae P,508292,174,508.292\nHaeju,Hwanghae N,458344,174,458.344\nKanggye,Chagang,446820,174,446.82\nKimchaek,Hamgyong P,358000,174,358\nHyesan,Yanggang,356040,174,356.04\nKaesong,Kaesong-si,343000,174,343\nSeoul,Seoul,19963238,117,19963.238\nPusan,Pusan,7609044,117,7609.044\nInchon,Inchon,5118848,117,5118.848\nTaegu,Taegu,5097136,117,5097.136\nTaejon,Taejon,2851670,117,2851.67\nKwangju,Kwangju,2736682,117,2736.682\nUlsan,Kyongsangnam,2169782,117,2169.782\nSongnam,Kyonggi,1738188,117,1738.188\nPuchon,Kyonggi,1558824,117,1558.824\nSuwon,Kyonggi,1511100,117,1511.1\nAnyang,Kyonggi,1182212,117,1182.212\nChonju,Chollabuk,1126306,117,1126.306\nChongju,Chungchongbuk,1062752,117,1062.752\nKoyang,Kyonggi,1036564,117,1036.564\nAnsan,Kyonggi,1020628,117,1020.628\nPohang,Kyongsangbuk,1017798,117,1017.798\nChang-won,Kyongsangnam,963388,117,963.388\nMasan,Kyongsangnam,882484,117,882.484\nKwangmyong,Kyonggi,701828,117,701.828\nChonan,Chungchongnam,660518,117,660.518\nChinju,Kyongsangnam,659772,117,659.772\nIksan,Chollabuk,645370,117,645.37\nPyongtaek,Kyonggi,625854,117,625.854\nKumi,Kyongsangbuk,622862,117,622.862\nUijongbu,Kyonggi,552222,117,552.222\nKyongju,Kyongsangbuk,545936,117,545.936\nKunsan,Chollabuk,533138,117,533.138\nCheju,Cheju,517022,117,517.022\nKimhae,Kyongsangnam,512740,117,512.74\nSunchon,Chollanam,498526,117,498.526\nMokpo,Chollanam,494904,117,494.904\nYong-in,Kyonggi,485286,117,485.286\nWonju,Kang-won,474920,117,474.92\nKunpo,Kyonggi,470466,117,470.466\nChunchon,Kang-won,469056,117,469.056\nNamyangju,Kyonggi,458120,117,458.12\nKangnung,Kang-won,440806,117,440.806\nChungju,Chungchongbuk,410412,117,410.412\nAndong,Kyongsangbuk,376886,117,376.886\nYosu,Chollanam,367192,117,367.192\nKyongsan,Kyongsangbuk,347492,117,347.492\nPaju,Kyonggi,326758,117,326.758\nYangsan,Kyongsangnam,326702,117,326.702\nIchon,Kyonggi,310664,117,310.664\nAsan,Chungchongnam,309326,117,309.326\nKoje,Kyongsangnam,295124,117,295.124\nKimchon,Kyongsangbuk,294054,117,294.054\nNonsan,Chungchongnam,293238,117,293.238\nKuri,Kyonggi,284346,117,284.346\nChong-up,Chollabuk,278222,117,278.222\nChechon,Chungchongbuk,274140,117,274.14\nSosan,Chungchongnam,269492,117,269.492\nShihung,Kyonggi,266886,117,266.886\nTong-yong,Kyongsangnam,263434,117,263.434\nKongju,Chungchongnam,262458,117,262.458\nYongju,Kyongsangbuk,262194,117,262.194\nChinhae,Kyongsangnam,251994,117,251.994\nSangju,Kyongsangbuk,248232,117,248.232\nPoryong,Chungchongnam,245208,117,245.208\nKwang-yang,Chollanam,244104,117,244.104\nMiryang,Kyongsangnam,243002,117,243.002\nHanam,Kyonggi,231624,117,231.624\nKimje,Chollabuk,230854,117,230.854\nYongchon,Kyongsangbuk,227022,117,227.022\nSachon,Kyongsangnam,226988,117,226.988\nUiwang,Kyonggi,217576,117,217.576\nNaju,Chollanam,215662,117,215.662\nNamwon,Chollabuk,207088,117,207.088\nTonghae,Kang-won,190944,117,190.944\nMun-gyong,Kyongsangbuk,184478,117,184.478\nAthenai,Attika,1544144,86,1544.144\nThessaloniki,Central Macedonia,767934,86,767.934\nPireus,Attika,365342,86,365.342\nPatras,West Greece,306688,86,306.688\nPeristerion,Attika,274576,86,274.576\nHerakleion,Crete,232356,86,232.356\nKallithea,Attika,228466,86,228.466\nLarisa,Thessalia,226180,86,226.18\nZagreb,Grad Zagreb,1413540,96,1413.54\nSplit,Split-Dalmatia,378776,96,378.776\nRijeka,Primorje-Gorski Kota,335928,96,335.928\nOsijek,Osijek-Baranja,209522,96,209.522\nLa Habana,La Habana,4512000,52,4512\nSantiago de Cuba,Santiago de Cuba,866360,52,866.36\nCamagüey,Camagüey,597452,52,597.452\nHolguín,Holguín,498984,52,498.984\nSanta Clara,Villa Clara,414700,52,414.7\nGuantánamo,Guantánamo,410156,52,410.156\nPinar del Río,Pinar del Río,284200,52,284.2\nBayamo,Granma,282000,52,282\nCienfuegos,Cienfuegos,265540,52,265.54\nVictoria de las Tunas,Las Tunas,264700,52,264.7\nMatanzas,Matanzas,246546,52,246.546\nManzanillo,Granma,218700,52,218.7\nSancti-Spíritus,Sancti-Spíritus,201502,52,201.502\nCiego de Ávila,Ciego de Ávila,197010,52,197.01\nal-Salimiya,Hawalli,260430,118,260.43\nJalib al-Shuyukh,Hawalli,204356,118,204.356\nKuwait,al-Asima,57718,118,57.718\nNicosia,Nicosia,390000,55,390\nLimassol,Limassol,308800,55,308.8\nVientiane,Viangchan,1063600,119,1063.6\nSavannakhet,Savannakhet,193304,119,193.304\nRiga,Riika,1528656,129,1528.656\nDaugavpils,Daugavpils,229658,129,229.658\nLiepaja,Liepaja,178878,129,178.878\nMaseru,Maseru,594000,126,594\nBeirut,Beirut,2200000,120,2200\nTripoli,al-Shamal,480000,120,480\nMonrovia,Montserrado,1700000,121,1700\nTripoli,Tripoli,3364000,122,3364\nBengasi,Bengasi,1608000,122,1608\nMisrata,Misrata,243338,122,243.338\nal-Zawiya,al-Zawiya,178676,122,178.676\nSchaan,Schaan,10692,124,10.692\nVaduz,Vaduz,10086,124,10.086\nVilnius,Vilna,1155938,127,1155.938\nKaunas,Kaunas,825278,127,825.278\nKlaipeda,Klaipeda,404902,127,404.902\nŠiauliai,Šiauliai,293126,127,293.126\nPanevezys,Panevezys,267390,127,267.39\nLuxembourg [Luxemburg/Lëtzebuerg],Luxembourg,161400,128,161.4\nEl-Aaiún,El-Aaiún,338000,66,338\nMacao,Macau,875000,130,875\nAntananarivo,Antananarivo,1351338,134,1351.338\nToamasina,Toamasina,254882,134,254.882\nAntsirabé,Antananarivo,240478,134,240.478\nMahajanga,Mahajanga,201614,134,201.614\nFianarantsoa,Fianarantsoa,198010,134,198.01\nSkopje,Skopje,888598,138,888.598\nBlantyre,Blantyre,956310,149,956.31\nLilongwe,Lilongwe,871928,149,871.928\nMale,Maale,142000,135,142\nKuala Lumpur,Wilayah Persekutuan,2595052,150,2595.052\nIpoh,Perak,765706,150,765.706\nJohor Baharu,Johor,656872,150,656.872\nPetaling Jaya,Selangor,508700,150,508.7\nKelang,Selangor,486710,150,486.71\nKuala Terengganu,Terengganu,456238,150,456.238\nPinang,Pulau Pinang,439206,150,439.206\nKota Bharu,Kelantan,439164,150,439.164\nKuantan,Pahang,398968,150,398.968\nTaiping,Perak,366522,150,366.522\nSeremban,Negeri Sembilan,365738,150,365.738\nKuching,Sarawak,296118,150,296.118\nSibu,Sarawak,252762,150,252.762\nSandakan,Sabah,251682,150,251.682\nAlor Setar,Kedah,248824,150,248.824\nSelayang Baru,Selangor,248456,150,248.456\nSungai Petani,Kedah,229526,150,229.526\nShah Alam,Selangor,204038,150,204.038\nBamako,Bamako,1619104,139,1619.104\nBirkirkara,Outer Harbour,42890,140,42.89\nValletta,Inner Harbour,14146,140,14.146\nCasablanca,Casablanca,5881246,131,5881.246\nRabat,Rabat-Salé-Zammour-Z,1246914,131,1246.914\nMarrakech,Marrakech-Tensift-Al,1243828,131,1243.828\nFès,Fès-Boulemane,1082324,131,1082.324\nTanger,Tanger-Tétouan,1043470,131,1043.47\nSalé,Rabat-Salé-Zammour-Z,1008840,131,1008.84\nMeknès,Meknès-Tafilalet,920000,131,920\nOujda,Oriental,730764,131,730.764\nKénitra,Gharb-Chrarda-Béni H,585200,131,585.2\nTétouan,Tanger-Tétouan,555032,131,555.032\nSafi,Doukkala-Abda,524600,131,524.6\nAgadir,Souss Massa-Draâ,310488,131,310.488\nMohammedia,Casablanca,309412,131,309.412\nKhouribga,Chaouia-Ouardigha,304180,131,304.18\nBeni-Mellal,Tadla-Azilal,280424,131,280.424\nTémara,Rabat-Salé-Zammour-Z,252606,131,252.606\nEl Jadida,Doukkala-Abda,238166,131,238.166\nNador,Oriental,224900,131,224.9\nKsar el Kebir,Tanger-Tétouan,214130,131,214.13\nSettat,Chaouia-Ouardigha,192400,131,192.4\nTaza,Taza-Al Hoceima-Taou,185400,131,185.4\nEl Araich,Tanger-Tétouan,180800,131,180.8\nDalap-Uliga-Darrit,Majuro,56000,137,56\nFort-de-France,Fort-de-France,188100,147,188.1\nNouakchott,Nouakchott,1334600,145,1334.6\nNouâdhibou,Dakhlet Nouâdhibou,195200,145,195.2\nPort-Louis,Port-Louis,276400,148,276.4\nBeau Bassin-Rose Hill,Plaines Wilhelms,201232,148,201.232\nVacoas-Phoenix,Plaines Wilhelms,196928,148,196.928\nMamoutzou,Mamoutzou,24000,151,24\nCiudad de México,Distrito Federal,17182618,136,17182.618\nGuadalajara,Jalisco,3295440,136,3295.44\nEcatepec de Morelos,México,3240606,136,3240.606\nPuebla,Puebla,2692352,136,2692.352\nNezahualcóyotl,México,2449848,136,2449.848\nJuárez,Chihuahua,2435636,136,2435.636\nTijuana,Baja California,2424464,136,2424.464\nLeón,Guanajuato,2267152,136,2267.152\nMonterrey,Nuevo León,2216998,136,2216.998\nZapopan,Jalisco,2004478,136,2004.478\nNaucalpan de Juárez,México,1715022,136,1715.022\nMexicali,Baja California,1529804,136,1529.804\nCuliacán,Sinaloa,1489718,136,1489.718\nAcapulco de Juárez,Guerrero,1442022,136,1442.022\nTlalnepantla de Baz,México,1441510,136,1441.51\nMérida,Yucatán,1406648,136,1406.648\nChihuahua,Chihuahua,1340416,136,1340.416\nSan Luis Potosí,San Luis Potosí,1338706,136,1338.706\nGuadalupe,Nuevo León,1337560,136,1337.56\nToluca,México,1331234,136,1331.234\nAguascalientes,Aguascalientes,1286720,136,1286.72\nQuerétaro,Querétaro de Arteaga,1279678,136,1279.678\nMorelia,Michoacán de Ocampo,1239916,136,1239.916\nHermosillo,Sonora,1217394,136,1217.394\nSaltillo,Coahuila de Zaragoza,1154704,136,1154.704\nTorreón,Coahuila de Zaragoza,1058186,136,1058.186\nCentro (Villahermosa),Tabasco,1039746,136,1039.746\nSan Nicolás de los Garza,Nuevo León,991080,136,991.08\nDurango,Durango,981048,136,981.048\nChimalhuacán,México,980490,136,980.49\nTlaquepaque,Jalisco,950944,136,950.944\nAtizapán de Zaragoza,México,934524,136,934.524\nVeracruz,Veracruz,914238,136,914.238\nCuautitlán Izcalli,México,905952,136,905.952\nIrapuato,Guanajuato,880078,136,880.078\nTuxtla Gutiérrez,Chiapas,867088,136,867.088\nTultitlán,México,864822,136,864.822\nReynosa,Tamaulipas,839552,136,839.552\nBenito Juárez,Quintana Roo,838552,136,838.552\nMatamoros,Tamaulipas,832856,136,832.856\nXalapa,Veracruz,780116,136,780.116\nCelaya,Guanajuato,764280,136,764.28\nMazatlán,Sinaloa,760530,136,760.53\nEnsenada,Baja California,739146,136,739.146\nAhome,Sinaloa,717326,136,717.326\nCajeme,Sonora,711358,136,711.358\nCuernavaca,Morelos,675932,136,675.932\nTonalá,Jalisco,672218,136,672.218\nValle de Chalco Solidaridad,México,646226,136,646.226\nNuevo Laredo,Tamaulipas,620554,136,620.554\nTepic,Nayarit,610050,136,610.05\nTampico,Tamaulipas,589578,136,589.578\nIxtapaluca,México,586320,136,586.32\nApodaca,Nuevo León,565882,136,565.882\nGuasave,Sinaloa,554402,136,554.402\nGómez Palacio,Durango,545612,136,545.612\nTapachula,Chiapas,542282,136,542.282\nNicolás Romero,México,538786,136,538.786\nCoatzacoalcos,Veracruz,534074,136,534.074\nUruapan,Michoacán de Ocampo,530422,136,530.422\nVictoria,Tamaulipas,525372,136,525.372\nOaxaca de Juárez,Oaxaca,513696,136,513.696\nCoacalco de Berriozábal,México,504540,136,504.54\nPachuca de Soto,Hidalgo,489376,136,489.376\nGeneral Escobedo,Nuevo León,465922,136,465.922\nSalamanca,Guanajuato,453728,136,453.728\nSanta Catarina,Nuevo León,453146,136,453.146\nTehuacán,Puebla,451886,136,451.886\nChalco,México,444402,136,444.402\nCárdenas,Tabasco,433806,136,433.806\nCampeche,Campeche,433470,136,433.47\nLa Paz,México,426090,136,426.09\nOthón P. Blanco (Chetumal),Quintana Roo,416028,136,416.028\nTexcoco,México,407362,136,407.362\nLa Paz,Baja California Sur,393416,136,393.416\nMetepec,México,388530,136,388.53\nMonclova,Coahuila de Zaragoza,387314,136,387.314\nHuixquilucan,México,386312,136,386.312\nChilpancingo de los Bravo,Guerrero,385018,136,385.018\nPuerto Vallarta,Jalisco,367482,136,367.482\nFresnillo,Zacatecas,365488,136,365.488\nCiudad Madero,Tamaulipas,364024,136,364.024\nSoledad de Graciano Sánchez,San Luis Potosí,359912,136,359.912\nSan Juan del Río,Querétaro,358600,136,358.6\nSan Felipe del Progreso,México,354660,136,354.66\nCórdoba,Veracruz,353904,136,353.904\nTecámac,México,344820,136,344.82\nOcosingo,Chiapas,342990,136,342.99\nCarmen,Campeche,342734,136,342.734\nLázaro Cárdenas,Michoacán de Ocampo,341756,136,341.756\nJiutepec,Morelos,340856,136,340.856\nPapantla,Veracruz,340246,136,340.246\nComalcalco,Tabasco,329280,136,329.28\nZamora,Michoacán de Ocampo,322382,136,322.382\nNogales,Sonora,318206,136,318.206\nHuimanguillo,Tabasco,316670,136,316.67\nCuautla,Morelos,306264,136,306.264\nMinatitlán,Veracruz,305966,136,305.966\nPoza Rica de Hidalgo,Veracruz,305356,136,305.356\nCiudad Valles,San Luis Potosí,292822,136,292.822\nNavolato,Sinaloa,290792,136,290.792\nSan Luis Río Colorado,Sonora,290552,136,290.552\nPénjamo,Guanajuato,287854,136,287.854\nSan Andrés Tuxtla,Veracruz,284502,136,284.502\nGuanajuato,Guanajuato,282430,136,282.43\nNavojoa,Sonora,280990,136,280.99\nZitácuaro,Michoacán de Ocampo,275940,136,275.94\nBoca del Río,Veracruz-Llave,271442,136,271.442\nAllende,Guanajuato,269290,136,269.29\nSilao,Guanajuato,268074,136,268.074\nMacuspana,Tabasco,267590,136,267.59\nSan Juan Bautista Tuxtepec,Oaxaca,267350,136,267.35\nSan Cristóbal de las Casas,Chiapas,264634,136,264.634\nValle de Santiago,Guanajuato,261114,136,261.114\nGuaymas,Sonora,260216,136,260.216\nColima,Colima,258908,136,258.908\nDolores Hidalgo,Guanajuato,257350,136,257.35\nLagos de Moreno,Jalisco,255898,136,255.898\nPiedras Negras,Coahuila de Zaragoza,255796,136,255.796\nAltamira,Tamaulipas,254980,136,254.98\nTúxpam,Veracruz,252950,136,252.95\nSan Pedro Garza García,Nuevo León,252294,136,252.294\nCuauhtémoc,Chihuahua,248558,136,248.558\nManzanillo,Colima,248028,136,248.028\nIguala de la Independencia,Guerrero,247766,136,247.766\nZacatecas,Zacatecas,247400,136,247.4\nTlajomulco de Zúñiga,Jalisco,246440,136,246.44\nTulancingo de Bravo,Hidalgo,243892,136,243.892\nZinacantepec,México,243430,136,243.43\nSan Martín Texmelucan,Puebla,242186,136,242.186\nTepatitlán de Morelos,Jalisco,237896,136,237.896\nMartínez de la Torre,Veracruz,237630,136,237.63\nOrizaba,Veracruz,236976,136,236.976\nApatzingán,Michoacán de Ocampo,235698,136,235.698\nAtlixco,Puebla,234038,136,234.038\nDelicias,Chihuahua,232264,136,232.264\nIxtlahuaca,México,231096,136,231.096\nEl Mante,Tamaulipas,224906,136,224.906\nLerdo,Durango,224544,136,224.544\nAlmoloya de Juárez,México,221100,136,221.1\nAcámbaro,Guanajuato,220974,136,220.974\nAcuña,Coahuila de Zaragoza,220776,136,220.776\nGuadalupe,Zacatecas,217762,136,217.762\nHuejutla de Reyes,Hidalgo,216034,136,216.034\nHidalgo,Michoacán de Ocampo,212396,136,212.396\nLos Cabos,Baja California Sur,210398,136,210.398\nComitán de Domínguez,Chiapas,209972,136,209.972\nCunduacán,Tabasco,208328,136,208.328\nRío Bravo,Tamaulipas,207802,136,207.802\nTemapache,Veracruz,205648,136,205.648\nChilapa de Alvarez,Guerrero,205432,136,205.432\nHidalgo del Parral,Chihuahua,201762,136,201.762\nSan Francisco del Rincón,Guanajuato,200298,136,200.298\nTaxco de Alarcón,Guerrero,199814,136,199.814\nZumpango,México,199562,136,199.562\nSan Pedro Cholula,Puebla,199468,136,199.468\nLerma,México,199428,136,199.428\nTecomán,Colima,198592,136,198.592\nLas Margaritas,Chiapas,194778,136,194.778\nCosoleacaque,Veracruz,194398,136,194.398\nSan Luis de la Paz,Guanajuato,193526,136,193.526\nJosé Azueta,Guerrero,190896,136,190.896\nSantiago Ixcuintla,Nayarit,190622,136,190.622\nSan Felipe,Guanajuato,190610,136,190.61\nTejupilco,México,189868,136,189.868\nTantoyuca,Veracruz,189418,136,189.418\nSalvatierra,Guanajuato,188644,136,188.644\nTultepec,México,186728,136,186.728\nTemixco,Morelos,185372,136,185.372\nMatamoros,Coahuila de Zaragoza,183716,136,183.716\nPánuco,Veracruz,181102,136,181.102\nEl Fuerte,Sinaloa,179112,136,179.112\nTierra Blanca,Veracruz,178286,136,178.286\nWeno,Chuuk,44000,75,44\nPalikir,Pohnpei,17200,75,17.2\nChisinau,Chisinau,1439800,133,1439.8\nTiraspol,Dnjestria,388600,133,388.6\nBalti,Balti,306800,133,306.8\nBender (Tîghina),Bender (Tîghina),251400,133,251.4\nMonte-Carlo,–,26308,132,26.308\nMonaco-Ville,–,2468,132,2.468\nUlan Bator,Ulaanbaatar,1547400,142,1547.4\nPlymouth,Plymouth,4000,146,4\nMaputo,Maputo,2037876,144,2037.876\nMatola,Maputo,849324,144,849.324\nBeira,Sofala,794736,144,794.736\nNampula,Nampula,606692,144,606.692\nChimoio,Manica,342112,144,342.112\nNaçala-Porto,Nampula,316496,144,316.496\nQuelimane,Zambézia,300232,144,300.232\nMocuba,Zambézia,249400,144,249.4\nTete,Tete,203968,144,203.968\nXai-Xai,Gaza,198884,144,198.884\nGurue,Zambézia,198600,144,198.6\nMaxixe,Inhambane,187970,144,187.97\nRangoon (Yangon),Rangoon [Yangon],6723400,141,6723.4\nMandalay,Mandalay,1770600,141,1770.6\nMoulmein (Mawlamyine),Mon,615800,141,615.8\nPegu (Bago),Pegu [Bago],381800,141,381.8\nBassein (Pathein),Irrawaddy [Ayeyarwad,367800,141,367.8\nMonywa,Sagaing,277200,141,277.2\nSittwe (Akyab),Rakhine,275200,141,275.2\nTaunggyi (Taunggye),Shan,263000,141,263\nMeikhtila,Mandalay,259400,141,259.4\nMergui (Myeik),Tenasserim [Tanintha,245400,141,245.4\nLashio (Lasho),Shan,215200,141,215.2\nProme (Pyay),Pegu [Bago],211400,141,211.4\nHenzada (Hinthada),Irrawaddy [Ayeyarwad,209400,141,209.4\nMyingyan,Mandalay,207200,141,207.2\nTavoy (Dawei),Tenasserim [Tanintha,193600,141,193.6\nPagakku (Pakokku),Magwe [Magway],189600,141,189.6\nWindhoek,Khomas,338000,152,338\nYangor,–,8100,162,8.1\nYaren,–,1118,162,1.118\nKathmandu,Central,1183670,161,1183.67\nBiratnagar,Eastern,315528,161,315.528\nPokhara,Western,292636,161,292.636\nLalitapur,Central,291694,161,291.694\nBirgunj,Central,181278,161,181.278\nManagua,Managua,1918000,157,1918\nLeón,León,247730,157,247.73\nChinandega,Chinandega,194774,157,194.774\nMasaya,Masaya,177942,157,177.942\nNiamey,Niamey,840000,154,840\nZinder,Zinder,241784,154,241.784\nMaradi,Maradi,225930,154,225.93\nLagos,Lagos,3036000,156,3036\nIbadan,Oyo & Osun,2864000,156,2864\nOgbomosho,Oyo & Osun,1460000,156,1460\nKano,Kano & Jigawa,1348200,156,1348.2\nOshogbo,Oyo & Osun,953600,156,953.6\nIlorin,Kwara & Kogi,951600,156,951.6\nAbeokuta,Ogun,854800,156,854.8\nPort Harcourt,Rivers & Bayelsa,820000,156,820\nZaria,Kaduna,758400,156,758.4\nIlesha,Oyo & Osun,756800,156,756.8\nOnitsha,Anambra & Enugu & Eb,743800,156,743.8\nIwo,Oyo & Osun,724000,156,724\nAdo-Ekiti,Ondo & Ekiti,718800,156,718.8\nAbuja,Federal Capital Dist,700200,156,700.2\nKaduna,Kaduna,684400,156,684.4\nMushin,Lagos,666400,156,666.4\nMaiduguri,Borno & Yobe,640000,156,640\nEnugu,Anambra & Enugu & Eb,632200,156,632.2\nEde,Oyo & Osun,614200,156,614.2\nAba,Imo & Abia,597800,156,597.8\nIfe,Oyo & Osun,593600,156,593.6\nIla,Oyo & Osun,528000,156,528\nOyo,Oyo & Osun,512800,156,512.8\nIkerre,Ondo & Ekiti,489200,156,489.2\nBenin City,Edo & Delta,458800,156,458.8\nIseyin,Oyo & Osun,434600,156,434.6\nKatsina,Katsina,413000,156,413\nJos,Plateau & Nassarawa,412600,156,412.6\nSokoto,Sokoto & Kebbi & Zam,409800,156,409.8\nIlobu,Oyo & Osun,398000,156,398\nOffa,Kwara & Kogi,394400,156,394.4\nIkorodu,Lagos,369800,156,369.8\nIlawe-Ekiti,Ondo & Ekiti,369000,156,369\nOwo,Ondo & Ekiti,367000,156,367\nIkirun,Oyo & Osun,362800,156,362.8\nShaki,Oyo & Osun,349000,156,349\nCalabar,Cross River,348800,156,348.8\nOndo,Ondo & Ekiti,347200,156,347.2\nAkure,Ondo & Ekiti,324600,156,324.6\nGusau,Sokoto & Kebbi & Zam,316000,156,316\nIjebu-Ode,Ogun,312800,156,312.8\nEffon-Alaiye,Oyo & Osun,306200,156,306.2\nKumo,Bauchi & Gombe,296000,156,296\nShomolu,Lagos,295400,156,295.4\nOka-Akoko,Ondo & Ekiti,285800,156,285.8\nIkare,Ondo & Ekiti,281600,156,281.6\nSapele,Edo & Delta,278400,156,278.4\nDeba Habe,Bauchi & Gombe,277200,156,277.2\nMinna,Niger,273800,156,273.8\nWarri,Edo & Delta,252200,156,252.2\nBida,Niger,251000,156,251\nIkire,Oyo & Osun,246600,156,246.6\nMakurdi,Benue,246200,156,246.2\nLafia,Plateau & Nassarawa,245000,156,245\nInisa,Oyo & Osun,239600,156,239.6\nShagamu,Ogun,234400,156,234.4\nAwka,Anambra & Enugu & Eb,222400,156,222.4\nGombe,Bauchi & Gombe,215600,156,215.6\nIgboho,Oyo & Osun,213600,156,213.6\nEjigbo,Oyo & Osun,211800,156,211.8\nAgege,Lagos,210000,156,210\nIse-Ekiti,Ondo & Ekiti,206800,156,206.8\nUgep,Cross River,205200,156,205.2\nEpe,Lagos,202000,156,202\nAlofi,–,1364,158,1.364\nKingston,–,1600,155,1.6\nOslo,Oslo,1017452,160,1017.452\nBergen,Hordaland,461896,160,461.896\nTrondheim,Sør-Trøndelag,300332,160,300.332\nStavanger,Rogaland,217696,160,217.696\nBærum,Akershus,202680,160,202.68\nAbidjan,Abidjan,5000000,43,5000\nBouaké,Bouaké,659700,43,659.7\nYamoussoukro,Yamoussoukro,260000,43,260\nDaloa,Daloa,243684,43,243.684\nKorhogo,Korhogo,218890,43,218.89\nal-Sib,Masqat,310000,164,310\nSalala,Zufar,263626,164,263.626\nBawshar,Masqat,215000,164,215\nSuhar,al-Batina,181628,164,181.628\nMasqat,Masqat,103938,164,103.938\nKarachi,Sindh,18538530,165,18538.53\nLahore,Punjab,10126998,165,10126.998\nFaisalabad,Punjab,3954492,165,3954.492\nRawalpindi,Punjab,2812428,165,2812.428\nMultan,Punjab,2364882,165,2364.882\nHyderabad,Sindh,2302548,165,2302.548\nGujranwala,Punjab,2249498,165,2249.498\nPeshawar,Nothwest Border Prov,1976010,165,1976.01\nQuetta,Baluchistan,1120614,165,1120.614\nIslamabad,Islamabad,1049000,165,1049\nSargodha,Punjab,910720,165,910.72\nSialkot,Punjab,835194,165,835.194\nBahawalpur,Punjab,806816,165,806.816\nSukkur,Sindh,658352,165,658.352\nJhang,Punjab,584428,165,584.428\nSheikhupura,Punjab,543750,165,543.75\nLarkana,Sindh,540732,165,540.732\nGujrat,Punjab,500242,165,500.242\nMardan,Nothwest Border Prov,489022,165,489.022\nKasur,Punjab,483298,165,483.298\nRahim Yar Khan,Punjab,456958,165,456.958\nSahiwal,Punjab,414776,165,414.776\nOkara,Punjab,401802,165,401.802\nWah,Punjab,396800,165,396.8\nDera Ghazi Khan,Punjab,376200,165,376.2\nMirpur Khas,Sind,369000,165,369\nNawabshah,Sind,366200,165,366.2\nMingora,Nothwest Border Prov,349000,165,349\nChiniot,Punjab,338600,165,338.6\nKamoke,Punjab,302000,165,302\nMandi Burewala,Punjab,299800,165,299.8\nJhelum,Punjab,291600,165,291.6\nSadiqabad,Punjab,283000,165,283\nJacobabad,Sind,275400,165,275.4\nShikarpur,Sind,266600,165,266.6\nKhanewal,Punjab,266000,165,266\nHafizabad,Punjab,260400,165,260.4\nKohat,Nothwest Border Prov,250600,165,250.6\nMuzaffargarh,Punjab,243200,165,243.2\nKhanpur,Punjab,235600,165,235.6\nGojra,Punjab,230000,165,230\nBahawalnagar,Punjab,219200,165,219.2\nMuridke,Punjab,217200,165,217.2\nPak Pattan,Punjab,215600,165,215.6\nAbottabad,Nothwest Border Prov,212000,165,212\nTando Adam,Sind,206800,165,206.8\nJaranwala,Punjab,206600,165,206.6\nKhairpur,Sind,204400,165,204.4\nChishtian Mandi,Punjab,203400,165,203.4\nDaska,Punjab,203000,165,203\nDadu,Sind,197200,165,197.2\nMandi Bahauddin,Punjab,194600,165,194.6\nAhmadpur East,Punjab,192000,165,192\nKamalia,Punjab,190600,165,190.6\nKhuzdar,Baluchistan,186200,165,186.2\nVihari,Punjab,184600,165,184.6\nDera Ismail Khan,Nothwest Border Prov,180800,165,180.8\nWazirabad,Punjab,179400,165,179.4\nNowshera,Nothwest Border Prov,178800,165,178.8\nKoror,Koror,24000,170,24\nCiudad de Panamá,Panamá,942746,166,942.746\nSan Miguelito,San Miguelito,630764,166,630.764\nPort Moresby,National Capital Dis,494000,171,494\nAsunción,Asunción,1115552,176,1115.552\nCiudad del Este,Alto Paraná,267762,176,267.762\nSan Lorenzo,Central,266790,176,266.79\nLambaré,Central,199362,176,199.362\nFernando de la Mora,Central,190574,176,190.574\nLima,Lima,12929386,168,12929.386\nArequipa,Arequipa,1524000,168,1524\nTrujillo,La Libertad,1304000,168,1304\nChiclayo,Lambayeque,1034000,168,1034\nCallao,Callao,848588,168,848.588\nIquitos,Loreto,734000,168,734\nChimbote,Ancash,672000,168,672\nHuancayo,Junín,654000,168,654\nPiura,Piura,650000,168,650\nCusco,Cusco,582000,168,582\nPucallpa,Ucayali,441732,168,441.732\nTacna,Tacna,431366,168,431.366\nIca,Ica,389640,168,389.64\nSullana,Piura,294722,168,294.722\nJuliaca,Puno,285152,168,285.152\nHuánuco,Huanuco,259376,168,259.376\nAyacucho,Ayacucho,237920,168,237.92\nChincha Alta,Ica,220032,168,220.032\nCajamarca,Cajamarca,216018,168,216.018\nPuno,Puno,203156,168,203.156\nVentanilla,Callao,202112,168,202.112\nCastilla,Piura,181284,168,181.284\nAdamstown,–,84,167,0.084\nGarapan,Saipan,18400,143,18.4\nLisboa,Lisboa,1126420,175,1126.42\nPorto,Porto,546120,175,546.12\nAmadora,Lisboa,244212,175,244.212\nCoímbra,Coímbra,192200,175,192.2\nBraga,Braga,181070,175,181.07\nSan Juan,San Juan,868748,173,868.748\nBayamón,Bayamón,448088,173,448.088\nPonce,Ponce,372950,173,372.95\nCarolina,Carolina,372152,173,372.152\nCaguas,Caguas,281004,173,281.004\nArecibo,Arecibo,200262,173,200.262\nGuaynabo,Guaynabo,200106,173,200.106\nMayagüez,Mayagüez,196868,173,196.868\nToa Baja,Toa Baja,188170,173,188.17\nWarszawa,Mazowieckie,3230738,172,3230.738\nLódz,Lodzkie,1600220,172,1600.22\nKraków,Malopolskie,1476300,172,1476.3\nWroclaw,Dolnoslaskie,1273530,172,1273.53\nPoznan,Wielkopolskie,1153798,172,1153.798\nGdansk,Pomorskie,917976,172,917.976\nSzczecin,Zachodnio-Pomorskie,833976,172,833.976\nBydgoszcz,Kujawsko-Pomorskie,773710,172,773.71\nLublin,Lubelskie,712502,172,712.502\nKatowice,Slaskie,691868,172,691.868\nBialystok,Podlaskie,567874,172,567.874\nCzestochowa,Slaskie,515624,172,515.624\nGdynia,Pomorskie,507042,172,507.042\nSosnowiec,Slaskie,488204,172,488.204\nRadom,Mazowieckie,464524,172,464.524\nKielce,Swietokrzyskie,424766,172,424.766\nGliwice,Slaskie,424328,172,424.328\nTorun,Kujawsko-Pomorskie,412316,172,412.316\nBytom,Slaskie,411120,172,411.12\nZabrze,Slaskie,400354,172,400.354\nBielsko-Biala,Slaskie,360614,172,360.614\nOlsztyn,Warminsko-Mazurskie,341808,172,341.808\nRzeszów,Podkarpackie,324098,172,324.098\nRuda Slaska,Slaskie,319330,172,319.33\nRybnik,Slaskie,289164,172,289.164\nWalbrzych,Dolnoslaskie,273846,172,273.846\nTychy,Slaskie,266356,172,266.356\nDabrowa Górnicza,Slaskie,262074,172,262.074\nPlock,Mazowieckie,262022,172,262.022\nElblag,Warminsko-Mazurskie,259564,172,259.564\nOpole,Opolskie,259106,172,259.106\nGorzów Wielkopolski,Lubuskie,252038,172,252.038\nWloclawek,Kujawsko-Pomorskie,246746,172,246.746\nChorzów,Slaskie,243416,172,243.416\nTarnów,Malopolskie,242988,172,242.988\nZielona Góra,Lubuskie,236364,172,236.364\nKoszalin,Zachodnio-Pomorskie,224750,172,224.75\nLegnica,Dolnoslaskie,218670,172,218.67\nKalisz,Wielkopolskie,213282,172,213.282\nGrudziadz,Kujawsko-Pomorskie,204868,172,204.868\nSlupsk,Pomorskie,204740,172,204.74\nJastrzebie-Zdrój,Slaskie,204588,172,204.588\nJaworzno,Slaskie,195858,172,195.858\nJelenia Góra,Dolnoslaskie,187802,172,187.802\nMalabo,Bioko,80000,85,80\nDoha,Doha,710000,179,710\nParis,Île-de-France,4250492,73,4250.492\nMarseille,Provence-Alpes-Côte,1596860,73,1596.86\nLyon,Rhône-Alpes,890904,73,890.904\nToulouse,Midi-Pyrénées,780700,73,780.7\nNice,Provence-Alpes-Côte,685476,73,685.476\nNantes,Pays de la Loire,540502,73,540.502\nStrasbourg,Alsace,528230,73,528.23\nMontpellier,Languedoc-Roussillon,450784,73,450.784\nBordeaux,Aquitaine,430726,73,430.726\nRennes,Haute-Normandie,412458,73,412.458\nLe Havre,Champagne-Ardenne,381810,73,381.81\nReims,Nord-Pas-de-Calais,374412,73,374.412\nLille,Rhône-Alpes,369314,73,369.314\nSt-Étienne,Bretagne,360420,73,360.42\nToulon,Provence-Alpes-Côte,321278,73,321.278\nGrenoble,Rhône-Alpes,306634,73,306.634\nAngers,Pays de la Loire,302558,73,302.558\nDijon,Bourgogne,299734,73,299.734\nBrest,Bretagne,299268,73,299.268\nLe Mans,Pays de la Loire,292210,73,292.21\nClermont-Ferrand,Auvergne,274280,73,274.28\nAmiens,Picardie,271002,73,271.002\nAix-en-Provence,Provence-Alpes-Côte,268444,73,268.444\nLimoges,Limousin,267936,73,267.936\nNîmes,Languedoc-Roussillon,266848,73,266.848\nTours,Centre,265640,73,265.64\nVilleurbanne,Rhône-Alpes,248430,73,248.43\nMetz,Lorraine,247552,73,247.552\nBesançon,Franche-Comté,235466,73,235.466\nCaen,Basse-Normandie,227974,73,227.974\nOrléans,Centre,226252,73,226.252\nMulhouse,Alsace,220718,73,220.718\nRouen,Haute-Normandie,213184,73,213.184\nBoulogne-Billancourt,Île-de-France,212734,73,212.734\nPerpignan,Languedoc-Roussillon,210230,73,210.23\nNancy,Lorraine,207210,73,207.21\nRoubaix,Nord-Pas-de-Calais,193968,73,193.968\nArgenteuil,Île-de-France,187922,73,187.922\nTourcoing,Nord-Pas-de-Calais,187080,73,187.08\nMontreuil,Île-de-France,181348,73,181.348\nCayenne,Cayenne,101398,90,101.398\nFaaa,Tahiti,51776,178,51.776\nPapeete,Tahiti,51106,178,51.106\nSaint-Denis,Saint-Denis,262960,180,262.96\nBucuresti,Bukarest,4032262,181,4032.262\nIasi,Iasi,696140,181,696.14\nConstanta,Constanta,684528,181,684.528\nCluj-Napoca,Cluj,664996,181,664.996\nGalati,Galati,660552,181,660.552\nTimisoara,Timis,648608,181,648.608\nBrasov,Brasov,628450,181,628.45\nCraiova,Dolj,627060,181,627.06\nPloiesti,Prahova,502696,181,502.696\nBraila,Braila,467512,181,467.512\nOradea,Bihor,444478,181,444.478\nBacau,Bacau,418470,181,418.47\nPitesti,Arges,374340,181,374.34\nArad,Arad,368816,181,368.816\nSibiu,Sibiu,339222,181,339.222\nTârgu Mures,Mures,330306,181,330.306\nBaia Mare,Maramures,299330,181,299.33\nBuzau,Buzau,296744,181,296.744\nSatu Mare,Satu Mare,260118,181,260.118\nBotosani,Botosani,257460,181,257.46\nPiatra Neamt,Neamt,250140,181,250.14\nRâmnicu Vâlcea,Vâlcea,239482,181,239.482\nSuceava,Suceava,237098,181,237.098\nDrobeta-Turnu Severin,Mehedinti,235730,181,235.73\nTârgoviste,Dâmbovita,197960,181,197.96\nFocsani,Vrancea,197958,181,197.958\nTârgu Jiu,Gorj,197048,181,197.048\nTulcea,Tulcea,192556,181,192.556\nResita,Caras-Severin,187952,181,187.952\nKigali,Kigali,572000,183,572\nStockholm,Lisboa,1500696,201,1500.696\nGothenburg [Göteborg],West Götanmaan län,933980,201,933.98\nMalmö,Skåne län,519158,201,519.158\nUppsala,Uppsala län,379138,201,379.138\nLinköping,East Götanmaan län,266336,201,266.336\nVästerås,Västmanlands län,252656,201,252.656\nÖrebro,Örebros län,248414,201,248.414\nNorrköping,East Götanmaan län,244398,201,244.398\nHelsingborg,Skåne län,235474,201,235.474\nJönköping,Jönköpings län,234190,201,234.19\nUmeå,Västerbottens län,209024,201,209.024\nLund,Skåne län,197896,201,197.896\nBorås,West Götanmaan län,193766,201,193.766\nSundsvall,Västernorrlands län,186252,201,186.252\nGävle,Gävleborgs län,181484,201,181.484\nJamestown,Saint Helena,3000,189,3\nBasseterre,St George Basseterre,23200,116,23.2\nCastries,Castries,4602,123,4.602\nKingstown,St George,34200,227,34.2\nSaint-Pierre,Saint-Pierre,11616,196,11.616\nBerlin,Berliini,6773334,57,6773.334\nHamburg,Hamburg,3409470,57,3409.47\nMunich [München],Baijeri,2389120,57,2389.12\nKöln,Nordrhein-Westfalen,1925014,57,1925.014\nFrankfurt am Main,Hessen,1287642,57,1287.642\nEssen,Nordrhein-Westfalen,1199030,57,1199.03\nDortmund,Nordrhein-Westfalen,1180426,57,1180.426\nStuttgart,Baden-Württemberg,1164886,57,1164.886\nDüsseldorf,Nordrhein-Westfalen,1137710,57,1137.71\nBremen,Bremen,1080660,57,1080.66\nDuisburg,Nordrhein-Westfalen,1039586,57,1039.586\nHannover,Niedersachsen,1029436,57,1029.436\nLeipzig,Saksi,979064,57,979.064\nNürnberg,Baijeri,973256,57,973.256\nDresden,Saksi,953336,57,953.336\nBochum,Nordrhein-Westfalen,785660,57,785.66\nWuppertal,Nordrhein-Westfalen,737986,57,737.986\nBielefeld,Nordrhein-Westfalen,642250,57,642.25\nMannheim,Baden-Württemberg,615460,57,615.46\nBonn,Nordrhein-Westfalen,602096,57,602.096\nGelsenkirchen,Nordrhein-Westfalen,563958,57,563.958\nKarlsruhe,Baden-Württemberg,554408,57,554.408\nWiesbaden,Hessen,537432,57,537.432\nMünster,Nordrhein-Westfalen,529340,57,529.34\nMönchengladbach,Nordrhein-Westfalen,527394,57,527.394\nChemnitz,Saksi,526444,57,526.444\nAugsburg,Baijeri,509734,57,509.734\nHalle/Saale,Anhalt Sachsen,508720,57,508.72\nBraunschweig,Niedersachsen,492644,57,492.644\nAachen,Nordrhein-Westfalen,487650,57,487.65\nKrefeld,Nordrhein-Westfalen,483538,57,483.538\nMagdeburg,Anhalt Sachsen,470146,57,470.146\nKiel,Schleswig-Holstein,467590,57,467.59\nOberhausen,Nordrhein-Westfalen,444698,57,444.698\nLübeck,Schleswig-Holstein,426652,57,426.652\nHagen,Nordrhein-Westfalen,410402,57,410.402\nRostock,Mecklenburg-Vorpomme,406558,57,406.558\nFreiburg im Breisgau,Baden-Württemberg,404910,57,404.91\nErfurt,Thüringen,402534,57,402.534\nKassel,Hessen,392422,57,392.422\nSaarbrücken,Saarland,367672,57,367.672\nMainz,Rheinland-Pfalz,366268,57,366.268\nHamm,Nordrhein-Westfalen,363608,57,363.608\nHerne,Nordrhein-Westfalen,351322,57,351.322\nMülheim an der Ruhr,Nordrhein-Westfalen,347790,57,347.79\nSolingen,Nordrhein-Westfalen,331166,57,331.166\nOsnabrück,Niedersachsen,329078,57,329.078\nLudwigshafen am Rhein,Rheinland-Pfalz,327542,57,327.542\nLeverkusen,Nordrhein-Westfalen,321682,57,321.682\nOldenburg,Niedersachsen,308250,57,308.25\nNeuss,Nordrhein-Westfalen,299404,57,299.404\nHeidelberg,Baden-Württemberg,279344,57,279.344\nDarmstadt,Hessen,275552,57,275.552\nPaderborn,Nordrhein-Westfalen,275294,57,275.294\nPotsdam,Brandenburg,257966,57,257.966\nWürzburg,Baijeri,254700,57,254.7\nRegensburg,Baijeri,250472,57,250.472\nRecklinghausen,Nordrhein-Westfalen,250044,57,250.044\nGöttingen,Niedersachsen,249550,57,249.55\nBremerhaven,Bremen,245470,57,245.47\nWolfsburg,Niedersachsen,243908,57,243.908\nBottrop,Nordrhein-Westfalen,242194,57,242.194\nRemscheid,Nordrhein-Westfalen,240250,57,240.25\nHeilbronn,Baden-Württemberg,239052,57,239.052\nPforzheim,Baden-Württemberg,234454,57,234.454\nOffenbach am Main,Hessen,233254,57,233.254\nUlm,Baden-Württemberg,232206,57,232.206\nIngolstadt,Baijeri,229652,57,229.652\nGera,Thüringen,229436,57,229.436\nSalzgitter,Niedersachsen,225868,57,225.868\nCottbus,Brandenburg,221788,57,221.788\nReutlingen,Baden-Württemberg,220686,57,220.686\nFürth,Baijeri,219542,57,219.542\nSiegen,Nordrhein-Westfalen,218450,57,218.45\nKoblenz,Rheinland-Pfalz,216006,57,216.006\nMoers,Nordrhein-Westfalen,213674,57,213.674\nBergisch Gladbach,Nordrhein-Westfalen,212300,57,212.3\nZwickau,Saksi,208292,57,208.292\nHildesheim,Niedersachsen,208026,57,208.026\nWitten,Nordrhein-Westfalen,206768,57,206.768\nSchwerin,Mecklenburg-Vorpomme,205756,57,205.756\nErlangen,Baijeri,201500,57,201.5\nKaiserslautern,Rheinland-Pfalz,200050,57,200.05\nTrier,Rheinland-Pfalz,199782,57,199.782\nJena,Thüringen,199558,57,199.558\nIserlohn,Nordrhein-Westfalen,198948,57,198.948\nGütersloh,Nordrhein-Westfalen,190056,57,190.056\nMarl,Nordrhein-Westfalen,187470,57,187.47\nLünen,Nordrhein-Westfalen,184088,57,184.088\nDüren,Nordrhein-Westfalen,182184,57,182.184\nRatingen,Nordrhein-Westfalen,181902,57,181.902\nVelbert,Nordrhein-Westfalen,179762,57,179.762\nEsslingen am Neckar,Baden-Württemberg,179334,57,179.334\nHoniara,Honiara,100200,191,100.2\nLusaka,Lusaka,2634000,238,2634\nNdola,Copperbelt,658400,238,658.4\nKitwe,Copperbelt,577200,238,577.2\nKabwe,Central,308600,238,308.6\nChingola,Copperbelt,284800,238,284.8\nMufulira,Copperbelt,247800,238,247.8\nLuanshya,Copperbelt,236200,238,236.2\nApia,Upolu,71800,234,71.8\nSerravalle,Serravalle/Dogano,9604,194,9.604\nSan Marino,San Marino,4588,194,4.588\nSão Tomé,Aqua Grande,99082,197,99.082\nRiyadh,Riyadh,6648000,184,6648\nJedda,Mekka,4092600,184,4092.6\nMekka,Mekka,1931400,184,1931.4\nMedina,Medina,1216600,184,1216.6\nal-Dammam,al-Sharqiya,964600,184,964.6\nal-Taif,Mekka,832200,184,832.2\nTabuk,Tabuk,585200,184,585.2\nBurayda,al-Qasim,497200,184,497.2\nal-Hufuf,al-Sharqiya,451600,184,451.6\nal-Mubarraz,al-Sharqiya,438200,184,438.2\nKhamis Mushayt,Asir,435800,184,435.8\nHail,Hail,353600,184,353.6\nal-Kharj,Riad,304200,184,304.2\nal-Khubar,al-Sharqiya,283400,184,283.4\nJubayl,al-Sharqiya,281600,184,281.6\nHafar al-Batin,al-Sharqiya,275600,184,275.6\nal-Tuqba,al-Sharqiya,251400,184,251.4\nYanbu,Medina,239600,184,239.6\nAbha,Asir,224600,184,224.6\nAra´ar,al-Khudud al-Samaliy,216200,184,216.2\nal-Qatif,al-Sharqiya,197800,184,197.8\nal-Hawiya,Mekka,187800,184,187.8\nUnayza,Qasim,182200,184,182.2\nNajran,Najran,182000,184,182\nPikine,Cap-Vert,1710574,186,1710.574\nDakar,Cap-Vert,1570142,186,1570.142\nThiès,Thiès,496000,186,496\nKaolack,Kaolack,398000,186,398\nZiguinchor,Ziguinchor,384000,186,384\nRufisque,Cap-Vert,300000,186,300\nSaint-Louis,Saint-Louis,264800,186,264.8\nMbour,Thiès,218600,186,218.6\nDiourbel,Diourbel,198800,186,198.8\nVictoria,Mahé,82000,203,82\nFreetown,Western,1700000,192,1700\nSingapore,–,8035466,187,8035.466\nBratislava,Bratislava,896584,199,896.584\nKošice,Východné Slovensko,483748,199,483.748\nPrešov,Východné Slovensko,187954,199,187.954\nLjubljana,Osrednjeslovenska,541972,200,541.972\nMaribor,Podravska,231064,200,231.064\nMogadishu,Banaadir,1994000,195,1994\nHargeysa,Woqooyi Galbeed,180000,195,180\nKismaayo,Jubbada Hoose,180000,195,180\nColombo,Western,1290000,125,1290\nDehiwala,Western,406000,125,406\nMoratuwa,Western,380000,125,380\nJaffna,Northern,298000,125,298\nKandy,Central,280000,125,280\nSri Jayawardenepura Kotte,Western,236000,125,236\nNegombo,Western,200000,125,200\nOmdurman,Khartum,2542806,185,2542.806\nKhartum,Khartum,1894966,185,1894.966\nSharq al-Nil,Khartum,1401774,185,1401.774\nPort Sudan,al-Bahr al-Ahmar,616390,185,616.39\nKassala,Kassala,469244,185,469.244\nObeid,Kurdufan al-Shamaliy,458850,185,458.85\nNyala,Darfur al-Janubiya,454366,185,454.366\nWad Madani,al-Jazira,422724,185,422.724\nal-Qadarif,al-Qadarif,382328,185,382.328\nKusti,al-Bahr al-Abyad,347198,185,347.198\nal-Fashir,Darfur al-Shamaliya,283768,185,283.768\nJuba,Bahr al-Jabal,229960,185,229.96\nHelsinki [Helsingfors],Newmaa,1110948,70,1110.948\nEspoo,Newmaa,426542,70,426.542\nTampere,Pirkanmaa,390936,70,390.936\nVantaa,Newmaa,356942,70,356.942\nTurku [Åbo],Varsinais-Suomi,345122,70,345.122\nOulu,Pohjois-Pohjanmaa,241506,70,241.506\nLahti,Päijät-Häme,193842,70,193.842\nParamaribo,Paramaribo,224000,198,224\nMbabane,Hhohho,122000,202,122\nZürich,Zürich,673600,40,673.6\nGeneve,Geneve,347000,40,347\nBasel,Basel-Stadt,333400,40,333.4\nBern,Bern,245400,40,245.4\nLausanne,Vaud,229000,40,229\nDamascus,Damascus,2694000,204,2694\nAleppo,Aleppo,2523966,204,2523.966\nHims,Hims,1014808,204,1014.808\nHama,Hama,686722,204,686.722\nLatakia,Latakia,529126,204,529.126\nal-Qamishliya,al-Hasaka,288572,204,288.572\nDayr al-Zawr,Dayr al-Zawr,280918,204,280.918\nJaramana,Damaskos,276938,204,276.938\nDuma,Damaskos,262316,204,262.316\nal-Raqqa,al-Raqqa,216040,204,216.04\nIdlib,Idlib,182162,204,182.162\nDushanbe,Karotegin,1048000,209,1048\nKhujand,Khujand,323000,209,323\nTaipei,Taipei,5282624,218,5282.624\nKaohsiung,Kaohsiung,2951010,218,2951.01\nTaichung,Taichung,1881178,218,1881.178\nTainan,Tainan,1456120,218,1456.12\nPanchiao,Taipei,1047700,218,1047.7\nChungho,Taipei,784352,218,784.352\nKeelung (Chilung),Keelung,770402,218,770.402\nSanchung,Taipei,760168,218,760.168\nHsinchuang,Taipei,730096,218,730.096\nHsinchu,Hsinchu,723916,218,723.916\nChungli,Taoyuan,637298,218,637.298\nFengshan,Kaohsiung,637124,218,637.124\nTaoyuan,Taoyuan,632876,218,632.876\nChiayi,Chiayi,530218,218,530.218\nHsintien,Taipei,527206,218,527.206\nChanghwa,Changhwa,455430,218,455.43\nYungho,Taipei,455400,218,455.4\nTucheng,Taipei,449794,218,449.794\nPingtung,Pingtung,429454,218,429.454\nYungkang,Tainan,386010,218,386.01\nPingchen,Taoyuan,376688,218,376.688\nTali,Taichung,343880,218,343.88\nTaiping,,331048,218,331.048\nPate,Taoyuan,323400,218,323.4\nFengyuan,Taichung,322064,218,322.064\nLuchou,Taipei,321032,218,321.032\nHsichuh,Taipei,309952,218,309.952\nShulin,Taipei,302520,218,302.52\nYuanlin,Changhwa,252804,218,252.804\nYangmei,Taoyuan,252646,218,252.646\nTaliao,,231794,218,231.794\nKueishan,,224390,218,224.39\nTanshui,Taipei,223764,218,223.764\nTaitung,Taitung,222078,218,222.078\nHualien,Hualien,216814,218,216.814\nNantou,Nantou,209446,218,209.446\nLungtan,Taipei,206176,218,206.176\nTouliu,Yünlin,197800,218,197.8\nTsaotun,Nantou,193600,218,193.6\nKangshan,Kaohsiung,184400,218,184.4\nIlan,Ilan,184000,218,184\nMiaoli,Miaoli,180000,218,180\nDar es Salaam,Dar es Salaam,3494000,219,3494\nDodoma,Dodoma,378000,219,378\nMwanza,Mwanza,344600,219,344.6\nZanzibar,Zanzibar West,315268,219,315.268\nTanga,Tanga,274800,219,274.8\nMbeya,Mbeya,261600,219,261.6\nMorogoro,Morogoro,235600,219,235.6\nArusha,Arusha,205000,219,205\nMoshi,Kilimanjaro,193600,219,193.6\nTabora,Tabora,185600,219,185.6\nKøbenhavn,København,991398,60,991.398\nÅrhus,Århus,569692,60,569.692\nOdense,Fyn,367824,60,367.824\nAalborg,Nordjylland,322322,60,322.322\nFrederiksberg,Frederiksberg,180654,60,180.654\nBangkok,Bangkok,12640348,208,12640.348\nNonthaburi,Nonthaburi,584200,208,584.2\nNakhon Ratchasima,Nakhon Ratchasima,362800,208,362.8\nChiang Mai,Chiang Mai,342200,208,342.2\nUdon Thani,Udon Thani,316200,208,316.2\nHat Yai,Songkhla,297264,208,297.264\nKhon Kaen,Khon Kaen,253000,208,253\nPak Kret,Nonthaburi,252110,208,252.11\nNakhon Sawan,Nakhon Sawan,247600,208,247.6\nUbon Ratchathani,Ubon Ratchathani,232600,208,232.6\nSongkhla,Songkhla,189800,208,189.8\nNakhon Pathom,Nakhon Pathom,188200,208,188.2\nLomé,Maritime,750000,207,750\nFakaofo,Fakaofo,600,210,0.6\nNuku´alofa,Tongatapu,44800,213,44.8\nChaguanas,Caroni,113202,214,113.202\nPort-of-Spain,Port-of-Spain,86792,214,86.792\nN´Djaména,Chari-Baguirmi,1061930,206,1061.93\nMoundou,Logone Occidental,199000,206,199\nPraha,Hlavní mesto Praha,2362252,56,2362.252\nBrno,Jizní Morava,763724,56,763.724\nOstrava,Severní Morava,640082,56,640.082\nPlzen,Zapadní Cechy,333518,56,333.518\nOlomouc,Severní Morava,205404,56,205.404\nLiberec,Severní Cechy,198310,56,198.31\nCeské Budejovice,Jizní Cechy,196372,56,196.372\nHradec Králové,Východní Cechy,196160,56,196.16\nÚstí nad Labem,Severní Cechy,190982,56,190.982\nPardubice,Východní Cechy,182618,56,182.618\nTunis,Tunis,1381200,215,1381.2\nSfax,Sfax,515600,215,515.6\nAriana,Ariana,394000,215,394\nEttadhamen,Ariana,357200,215,357.2\nSousse,Sousse,291800,215,291.8\nKairouan,Kairouan,226200,215,226.2\nBiserta,Biserta,217800,215,217.8\nGabès,Gabès,213200,215,213.2\nIstanbul,Istanbul,17575916,216,17575.916\nAnkara,Ankara,6076318,216,6076.318\nIzmir,Izmir,4260718,216,4260.718\nAdana,Adana,2262396,216,2262.396\nBursa,Bursa,2191684,216,2191.684\nGaziantep,Gaziantep,1578112,216,1578.112\nKonya,Konya,1256728,216,1256.728\nMersin (Içel),Içel,1174424,216,1174.424\nAntalya,Antalya,1129828,216,1129.828\nDiyarbakir,Diyarbakir,959768,216,959.768\nKayseri,Kayseri,951314,216,951.314\nEskisehir,Eskisehir,941562,216,941.562\nSanliurfa,Sanliurfa,811810,216,811.81\nSamsun,Samsun,679742,216,679.742\nMalatya,Malatya,660624,216,660.624\nGebze,Kocaeli,528340,216,528.34\nDenizli,Denizli,507696,216,507.696\nSivas,Sivas,493284,216,493.284\nErzurum,Erzurum,493070,216,493.07\nTarsus,Adana,492412,216,492.412\nKahramanmaras,Kahramanmaras,491544,216,491.544\nElâzig,Elâzig,457630,216,457.63\nVan,Van,438638,216,438.638\nSultanbeyli,Istanbul,422136,216,422.136\nIzmit (Kocaeli),Kocaeli,420136,216,420.136\nManisa,Manisa,414296,216,414.296\nBatman,Batman,407586,216,407.586\nBalikesir,Balikesir,392764,216,392.764\nSakarya (Adapazari),Sakarya,381282,216,381.282\nIskenderun,Hatay,306044,216,306.044\nOsmaniye,Osmaniye,292006,216,292.006\nÇorum,Çorum,290990,216,290.99\nKütahya,Kütahya,289522,216,289.522\nHatay (Antakya),Hatay,287964,216,287.964\nKirikkale,Kirikkale,284088,216,284.088\nAdiyaman,Adiyaman,283058,216,283.058\nTrabzon,Trabzon,276468,216,276.468\nOrdu,Ordu,267284,216,267.284\nAydin,Aydin,257302,216,257.302\nUsak,Usak,256324,216,256.324\nEdirne,Edirne,246766,216,246.766\nÇorlu,Tekirdag,246600,216,246.6\nIsparta,Isparta,243822,216,243.822\nKarabük,Karabük,236570,216,236.57\nKilis,Kilis,236490,216,236.49\nAlanya,Antalya,234600,216,234.6\nKiziltepe,Mardin,224000,216,224\nZonguldak,Zonguldak,223084,216,223.084\nSiirt,Siirt,214200,216,214.2\nViransehir,Sanliurfa,212800,216,212.8\nTekirdag,Tekirdag,212154,216,212.154\nKaraman,Karaman,208400,216,208.4\nAfyon,Afyon,207968,216,207.968\nAksaray,Aksaray,205362,216,205.362\nCeyhan,Adana,204824,216,204.824\nErzincan,Erzincan,204608,216,204.608\nBismil,Diyarbakir,202800,216,202.8\nNazilli,Aydin,199800,216,199.8\nTokat,Tokat,199000,216,199\nKars,Kars,186000,216,186\nInegöl,Bursa,181000,216,181\nBandirma,Balikesir,180400,216,180.4\nAshgabat,Ahal,1081200,211,1081.2\nChärjew,Lebap,378400,211,378.4\nDashhowuz,Dashhowuz,283600,211,283.6\nMary,Mary,202000,211,202\nCockburn Town,Grand Turk,9600,205,9.6\nFunafuti,Funafuti,9200,217,9.2\nKampala,Central,1781600,220,1781.6\nKyiv,Kiova,5248000,221,5248\nHarkova [Harkiv],Harkova,3000000,221,3000\nDnipropetrovsk,Dnipropetrovsk,2206000,221,2206\nDonetsk,Donetsk,2100000,221,2100\nOdesa,Odesa,2022000,221,2022\nZaporizzja,Zaporizzja,1696000,221,1696\nLviv,Lviv,1576000,221,1576\nKryvyi Rig,Dnipropetrovsk,1406000,221,1406\nMykolajiv,Mykolajiv,1016000,221,1016\nMariupol,Donetsk,980000,221,980\nLugansk,Lugansk,938000,221,938\nVinnytsja,Vinnytsja,782000,221,782\nMakijivka,Donetsk,768000,221,768\nHerson,Herson,706000,221,706\nSevastopol,Krim,696000,221,696\nSimferopol,Krim,678000,221,678\nPultava [Poltava],Pultava,626000,221,626\nTšernigiv,Tšernigiv,626000,221,626\nTšerkasy,Tšerkasy,618000,221,618\nGorlivka,Donetsk,598000,221,598\nZytomyr,Zytomyr,594000,221,594\nSumy,Sumy,588000,221,588\nDniprodzerzynsk,Dnipropetrovsk,540000,221,540\nKirovograd,Kirovograd,530000,221,530\nHmelnytskyi,Hmelnytskyi,524000,221,524\nTšernivtsi,Tšernivtsi,518000,221,518\nRivne,Rivne,490000,221,490\nKrementšuk,Pultava,478000,221,478\nIvano-Frankivsk,Ivano-Frankivsk,474000,221,474\nTernopil,Ternopil,472000,221,472\nLutsk,Volynia,434000,221,434\nBila Tserkva,Kiova,430000,221,430\nKramatorsk,Donetsk,372000,221,372\nMelitopol,Zaporizzja,338000,221,338\nKertš,Krim,324000,221,324\nNikopol,Dnipropetrovsk,298000,221,298\nBerdjansk,Zaporizzja,260000,221,260\nPavlograd,Dnipropetrovsk,254000,221,254\nSjeverodonetsk,Lugansk,254000,221,254\nSlovjansk,Donetsk,254000,221,254\nUzgorod,Taka-Karpatia,254000,221,254\nAltševsk,Lugansk,238000,221,238\nLysytšansk,Lugansk,232000,221,232\nJevpatorija,Krim,224000,221,224\nKamjanets-Podilskyi,Hmelnytskyi,218000,221,218\nJenakijeve,Donetsk,210000,221,210\nKrasnyi Lutš,Lugansk,202000,221,202\nStahanov,Lugansk,202000,221,202\nOleksandrija,Kirovograd,198000,221,198\nKonotop,Sumy,192000,221,192\nKostjantynivka,Donetsk,190000,221,190\nBerdytšiv,Zytomyr,180000,221,180\nIzmajil,Odesa,180000,221,180\nŠostka,Sumy,180000,221,180\nUman,Tšerkasy,180000,221,180\nBrovary,Kiova,178000,221,178\nMukatševe,Taka-Karpatia,178000,221,178\nBudapest,Budapest,3623104,98,3623.104\nDebrecen,Hajdú-Bihar,407296,98,407.296\nMiskolc,Borsod-Abaúj-Zemplén,344714,98,344.714\nSzeged,Csongrád,316316,98,316.316\nPécs,Baranya,314664,98,314.664\nGyör,Györ-Moson-Sopron,254238,98,254.238\nNyiregyháza,Szabolcs-Szatmár-Ber,224838,98,224.838\nKecskemét,Bács-Kiskun,211212,98,211.212\nSzékesfehérvár,Fejér,210238,98,210.238\nMontevideo,Montevideo,2472000,223,2472\nNouméa,–,152586,153,152.586\nAuckland,Auckland,763600,163,763.6\nChristchurch,Canterbury,648400,163,648.4\nManukau,Auckland,563600,163,563.6\nNorth Shore,Auckland,375400,163,375.4\nWaitakere,Auckland,341200,163,341.2\nWellington,Wellington,333400,163,333.4\nDunedin,Dunedin,239200,163,239.2\nHamilton,Hamilton,234200,163,234.2\nLower Hutt,Wellington,196200,163,196.2\nToskent,Toskent Shahri,4235000,225,4235\nNamangan,Namangan,741000,225,741\nSamarkand,Samarkand,723600,225,723.6\nAndijon,Andijon,637200,225,637.2\nBuhoro,Buhoro,474200,225,474.2\nKarsi,Qashqadaryo,388200,225,388.2\nNukus,Karakalpakistan,388200,225,388.2\nKükon,Fargona,380200,225,380.2\nFargona,Fargona,361000,225,361\nCircik,Toskent,292800,225,292.8\nMargilon,Fargona,281600,225,281.6\nÜrgenc,Khorazm,277800,225,277.8\nAngren,Toskent,256000,225,256\nCizah,Cizah,249600,225,249.6\nNavoi,Navoi,232600,225,232.6\nOlmalik,Toskent,229800,225,229.8\nTermiz,Surkhondaryo,219000,225,219\nMinsk,Horad Minsk,3348000,27,3348\nGomel,Gomel,950000,27,950\nMogiljov,Mogiljov,712000,27,712\nVitebsk,Vitebsk,680000,27,680\nGrodno,Grodno,604000,27,604\nBrest,Brest,572000,27,572\nBobruisk,Mogiljov,442000,27,442\nBaranovitši,Brest,334000,27,334\nBorisov,Minsk,302000,27,302\nPinsk,Brest,260000,27,260\nOrša,Vitebsk,248000,27,248\nMozyr,Gomel,220000,27,220\nNovopolotsk,Vitebsk,212000,27,212\nLida,Grodno,202000,27,202\nSoligorsk,Minsk,202000,27,202\nMolodetšno,Minsk,194000,27,194\nMata-Utu,Wallis,2274,233,2.274\nPort-Vila,Shefa,67400,232,67.4\nCittà del Vaticano,–,910,226,0.91\nCaracas,Distrito Federal,3950588,228,3950.588\nMaracaíbo,Zulia,2609552,228,2609.552\nBarquisimeto,Lara,1754478,228,1754.478\nValencia,Carabobo,1588492,228,1588.492\nCiudad Guayana,Bolívar,1327426,228,1327.426\nPetare,Miranda,977736,228,977.736\nMaracay,Aragua,888886,228,888.886\nBarcelona,Anzoátegui,644534,228,644.534\nMaturín,Monagas,639452,228,639.452\nSan Cristóbal,Táchira,638746,228,638.746\nCiudad Bolívar,Bolívar,602214,228,602.214\nCumaná,Sucre,586210,228,586.21\nMérida,Mérida,449774,228,449.774\nCabimas,Zulia,442658,228,442.658\nBarinas,Barinas,435662,228,435.662\nTurmero,Aragua,434998,228,434.998\nBaruta,Miranda,414580,228,414.58\nPuerto Cabello,Carabobo,375444,228,375.444\nSanta Ana de Coro,Falcón,371532,228,371.532\nLos Teques,Miranda,357568,228,357.568\nPunto Fijo,Falcón,334430,228,334.43\nGuarenas,Miranda,331778,228,331.778\nAcarigua,Portuguesa,317908,228,317.908\nPuerto La Cruz,Anzoátegui,311400,228,311.4\nCiudad Losada,,269002,228,269.002\nGuacara,Carabobo,262668,228,262.668\nValera,Trujillo,260562,228,260.562\nGuanare,Portuguesa,251242,228,251.242\nCarúpano,Sucre,239278,228,239.278\nCatia La Mar,Distrito Federal,234024,228,234.024\nEl Tigre,Anzoátegui,232512,228,232.512\nGuatire,Miranda,218242,228,218.242\nCalabozo,Guárico,214292,228,214.292\nPozuelos,Anzoátegui,211380,228,211.38\nCiudad Ojeda,Zulia,198708,228,198.708\nOcumare del Tuy,Miranda,194336,228,194.336\nValle de la Pascua,Guárico,191854,228,191.854\nAraure,Portuguesa,188538,228,188.538\nSan Fernando de Apure,Apure,187618,228,187.618\nSan Felipe,Yaracuy,181880,228,181.88\nEl Limón,Aragua,180000,228,180\nMoscow,Moscow (City),16778400,182,16778.4\nSt Petersburg,Pietari,9388000,182,9388\nNovosibirsk,Novosibirsk,2797600,182,2797.6\nNizni Novgorod,Nizni Novgorod,2714000,182,2714\nJekaterinburg,Sverdlovsk,2532600,182,2532.6\nSamara,Samara,2312200,182,2312.2\nOmsk,Omsk,2297800,182,2297.8\nKazan,Tatarstan,2202000,182,2202\nUfa,Baškortostan,2182400,182,2182.4\nTšeljabinsk,Tšeljabinsk,2166400,182,2166.4\nRostov-na-Donu,Rostov-na-Donu,2025400,182,2025.4\nPerm,Perm,2019400,182,2019.4\nVolgograd,Volgograd,1986800,182,1986.8\nVoronez,Voronez,1815400,182,1815.4\nKrasnojarsk,Krasnojarsk,1751000,182,1751\nSaratov,Saratov,1748000,182,1748\nToljatti,Samara,1445800,182,1445.8\nUljanovsk,Uljanovsk,1334800,182,1334.8\nIzevsk,Udmurtia,1305600,182,1305.6\nKrasnodar,Krasnodar,1278000,182,1278\nJaroslavl,Jaroslavl,1233400,182,1233.4\nHabarovsk,Habarovsk,1218800,182,1218.8\nVladivostok,Primorje,1212400,182,1212.4\nIrkutsk,Irkutsk,1187400,182,1187.4\nBarnaul,Altai,1160200,182,1160.2\nNovokuznetsk,Kemerovo,1123200,182,1123.2\nPenza,Penza,1064400,182,1064.4\nRjazan,Rjazan,1059800,182,1059.8\nOrenburg,Orenburg,1047200,182,1047.2\nLipetsk,Lipetsk,1042000,182,1042\nNabereznyje Tšelny,Tatarstan,1029400,182,1029.4\nTula,Tula,1012200,182,1012.2\nTjumen,Tjumen,1006800,182,1006.8\nKemerovo,Kemerovo,985400,182,985.4\nAstrahan,Astrahan,972200,182,972.2\nTomsk,Tomsk,964200,182,964.2\nKirov,Kirov,932400,182,932.4\nIvanovo,Ivanovo,918400,182,918.4\nTšeboksary,Tšuvassia,918400,182,918.4\nBrjansk,Brjansk,914800,182,914.8\nTver,Tver,909800,182,909.8\nKursk,Kursk,887000,182,887\nMagnitogorsk,Tšeljabinsk,855800,182,855.8\nKaliningrad,Kaliningrad,848800,182,848.8\nNizni Tagil,Sverdlovsk,781800,182,781.8\nMurmansk,Murmansk,752600,182,752.6\nUlan-Ude,Burjatia,740800,182,740.8\nKurgan,Kurgan,729400,182,729.4\nArkangeli,Arkangeli,723600,182,723.6\nSotši,Krasnodar,717200,182,717.2\nSmolensk,Smolensk,706800,182,706.8\nOrjol,Orjol,689000,182,689\nStavropol,Stavropol,686600,182,686.6\nBelgorod,Belgorod,684000,182,684\nKaluga,Kaluga,678600,182,678.6\nVladimir,Vladimir,674200,182,674.2\nMahatškala,Dagestan,665600,182,665.6\nTšerepovets,Vologda,648800,182,648.8\nSaransk,Mordva,629600,182,629.6\nTambov,Tambov,624000,182,624\nVladikavkaz,North Ossetia-Alania,620200,182,620.2\nTšita,Tšita,619800,182,619.8\nVologda,Vologda,605000,182,605\nVeliki Novgorod,Novgorod,599000,182,599\nKomsomolsk-na-Amure,Habarovsk,583200,182,583.2\nKostroma,Kostroma,576200,182,576.2\nVolzski,Volgograd,573800,182,573.8\nTaganrog,Rostov-na-Donu,568800,182,568.8\nPetroskoi,Karjala,564200,182,564.2\nBratsk,Irkutsk,555200,182,555.2\nDzerzinsk,Nizni Novgorod,554200,182,554.2\nSurgut,Hanti-Mansia,549800,182,549.8\nOrsk,Orenburg,547800,182,547.8\nSterlitamak,Baškortostan,530400,182,530.4\nAngarsk,Irkutsk,529400,182,529.4\nJoškar-Ola,Marinmaa,498400,182,498.4\nRybinsk,Jaroslavl,479200,182,479.2\nProkopjevsk,Kemerovo,474600,182,474.6\nNiznevartovsk,Hanti-Mansia,467800,182,467.8\nNaltšik,Kabardi-Balkaria,466800,182,466.8\nSyktyvkar,Komi,459400,182,459.4\nSeverodvinsk,Arkangeli,458600,182,458.6\nBijsk,Altai,450000,182,450\nNiznekamsk,Tatarstan,446800,182,446.8\nBlagoveštšensk,Amur,444000,182,444\nŠahty,Rostov-na-Donu,443600,182,443.6\nStaryi Oskol,Belgorod,427600,182,427.6\nZelenograd,Moscow (City),414200,182,414.2\nBalakovo,Saratov,412000,182,412\nNovorossijsk,Krasnodar,406600,182,406.6\nPihkova,Pihkova,403000,182,403\nZlatoust,Tšeljabinsk,393800,182,393.8\nJakutsk,Saha (Jakutia),390800,182,390.8\nPodolsk,Moskova,388600,182,388.6\nPetropavlovsk-Kamtšatski,Kamtšatka,388200,182,388.2\nKamensk-Uralski,Sverdlovsk,381200,182,381.2\nEngels,Saratov,378000,182,378\nSyzran,Samara,373800,182,373.8\nGrozny,Tšetšenia,372000,182,372\nNovotšerkassk,Rostov-na-Donu,368800,182,368.8\nBerezniki,Perm,363800,182,363.8\nJuzno-Sahalinsk,Sahalin,358400,182,358.4\nVolgodonsk,Rostov-na-Donu,356400,182,356.4\nAbakan,Hakassia,338400,182,338.4\nMaikop,Adygea,334600,182,334.6\nMiass,Tšeljabinsk,332400,182,332.4\nArmavir,Krasnodar,329800,182,329.8\nLjubertsy,Moskova,327800,182,327.8\nRubtsovsk,Altai,325200,182,325.2\nKovrov,Vladimir,319800,182,319.8\nNahodka,Primorje,315400,182,315.4\nUssurijsk,Primorje,314600,182,314.6\nSalavat,Baškortostan,313600,182,313.6\nMytištši,Moskova,311400,182,311.4\nKolomna,Moskova,301400,182,301.4\nElektrostal,Moskova,294000,182,294\nMurom,Vladimir,284800,182,284.8\nKolpino,Pietari,282400,182,282.4\nNorilsk,Krasnojarsk,281600,182,281.6\nAlmetjevsk,Tatarstan,281400,182,281.4\nNovomoskovsk,Tula,276200,182,276.2\nDimitrovgrad,Uljanovsk,274000,182,274\nPervouralsk,Sverdlovsk,272200,182,272.2\nHimki,Moskova,267400,182,267.4\nBalašiha,Moskova,265800,182,265.8\nNevinnomyssk,Stavropol,265200,182,265.2\nPjatigorsk,Stavropol,265000,182,265\nKorolev,Moskova,264800,182,264.8\nSerpuhov,Moskova,264000,182,264\nOdintsovo,Moskova,254800,182,254.8\nOrehovo-Zujevo,Moskova,249800,182,249.8\nKamyšin,Volgograd,249200,182,249.2\nNovotšeboksarsk,Tšuvassia,246800,182,246.8\nTšerkessk,Karatšai-Tšerkessia,243400,182,243.4\nAtšinsk,Krasnojarsk,243200,182,243.2\nMagadan,Magadan,242000,182,242\nMitšurinsk,Tambov,241400,182,241.4\nKislovodsk,Stavropol,240800,182,240.8\nJelets,Lipetsk,238800,182,238.8\nSeversk,Tomsk,237200,182,237.2\nNoginsk,Moskova,234400,182,234.4\nVelikije Luki,Pihkova,232600,182,232.6\nNovokuibyševsk,Samara,232400,182,232.4\nNeftekamsk,Baškortostan,231400,182,231.4\nLeninsk-Kuznetski,Kemerovo,227600,182,227.6\nOktjabrski,Baškortostan,223000,182,223\nSergijev Posad,Moskova,222200,182,222.2\nArzamas,Nizni Novgorod,221400,182,221.4\nKiseljovsk,Kemerovo,220000,182,220\nNovotroitsk,Orenburg,219200,182,219.2\nObninsk,Kaluga,216600,182,216.6\nKansk,Krasnojarsk,214800,182,214.8\nGlazov,Udmurtia,212600,182,212.6\nSolikamsk,Perm,212000,182,212\nSarapul,Udmurtia,211400,182,211.4\nUst-Ilimsk,Irkutsk,210400,182,210.4\nŠtšolkovo,Moskova,209800,182,209.8\nMezduretšensk,Kemerovo,208800,182,208.8\nUsolje-Sibirskoje,Irkutsk,207000,182,207\nElista,Kalmykia,206600,182,206.6\nNovošahtinsk,Rostov-na-Donu,203800,182,203.8\nVotkinsk,Udmurtia,203400,182,203.4\nKyzyl,Tyva,202200,182,202.2\nSerov,Sverdlovsk,200800,182,200.8\nZelenodolsk,Tatarstan,200400,182,200.4\nZeleznodoroznyi,Moskova,200200,182,200.2\nKinešma,Ivanovo,200000,182,200\nKuznetsk,Penza,196400,182,196.4\nUhta,Komi,196000,182,196\nJessentuki,Stavropol,195800,182,195.8\nTobolsk,Tjumen,195200,182,195.2\nNeftejugansk,Hanti-Mansia,194800,182,194.8\nBataisk,Rostov-na-Donu,194600,182,194.6\nNojabrsk,Yamalin Nenetsia,194600,182,194.6\nBalašov,Saratov,194200,182,194.2\nZeleznogorsk,Kursk,193800,182,193.8\nZukovski,Moskova,193000,182,193\nAnzero-Sudzensk,Kemerovo,192200,182,192.2\nBugulma,Tatarstan,188200,182,188.2\nZeleznogorsk,Krasnojarsk,188000,182,188\nNovouralsk,Sverdlovsk,186600,182,186.6\nPuškin,Pietari,185800,182,185.8\nVorkuta,Komi,185200,182,185.2\nDerbent,Dagestan,184600,182,184.6\nKirovo-Tšepetsk,Kirov,183200,182,183.2\nKrasnogorsk,Moskova,182000,182,182\nKlin,Moskova,180000,182,180\nTšaikovski,Perm,180000,182,180\nNovyi Urengoi,Yamalin Nenetsia,179600,182,179.6\nHo Chi Minh City,Ho Chi Minh City,7960000,231,7960\nHanoi,Hanoi,2820000,231,2820\nHaiphong,Haiphong,1566266,231,1566.266\nDa Nang,Quang Nam-Da Nang,765348,231,765.348\nBiên Hoa,Dong Nai,564190,231,564.19\nNha Trang,Khanh Hoa,442662,231,442.662\nHue,Thua Thien-Hue,438298,231,438.298\nCan Tho,Can Tho,431174,231,431.174\nCam Pha,Quang Binh,418172,231,418.172\nNam Dinh,Nam Ha,343398,231,343.398\nQuy Nhon,Binh Dinh,326770,231,326.77\nVung Tau,Ba Ria-Vung Tau,290290,231,290.29\nRach Gia,Kien Giang,282264,231,282.264\nLong Xuyen,An Giang,265362,231,265.362\nThai Nguyen,Bac Thai,255286,231,255.286\nHong Gai,Quang Ninh,254968,231,254.968\nPhan Thiêt,Binh Thuan,228472,231,228.472\nCam Ranh,Khanh Hoa,228082,231,228.082\nVinh,Nghe An,224910,231,224.91\nMy Tho,Tien Giang,216808,231,216.808\nDa Lat,Lam Dong,212818,231,212.818\nBuon Ma Thuot,Dac Lac,194088,231,194.088\nTallinn,Harjumaa,807962,68,807.962\nTartu,Tartumaa,202492,68,202.492\nNew York,New York,16016556,224,16016.556\nLos Angeles,California,7389640,224,7389.64\nChicago,Illinois,5792032,224,5792.032\nHouston,Texas,3907262,224,3907.262\nPhiladelphia,Pennsylvania,3035100,224,3035.1\nPhoenix,Arizona,2642090,224,2642.09\nSan Diego,California,2446800,224,2446.8\nDallas,Texas,2377160,224,2377.16\nSan Antonio,Texas,2289292,224,2289.292\nDetroit,Michigan,1902540,224,1902.54\nSan Jose,California,1789886,224,1789.886\nIndianapolis,Indiana,1583852,224,1583.852\nSan Francisco,California,1553466,224,1553.466\nJacksonville,Florida,1470334,224,1470.334\nColumbus,Ohio,1422940,224,1422.94\nAustin,Texas,1313124,224,1313.124\nBaltimore,Maryland,1302308,224,1302.308\nMemphis,Tennessee,1300200,224,1300.2\nMilwaukee,Wisconsin,1193948,224,1193.948\nBoston,Massachusetts,1178282,224,1178.282\nWashington,District of Columbia,1144118,224,1144.118\nNashville-Davidson,Tennessee,1139782,224,1139.782\nEl Paso,Texas,1127324,224,1127.324\nSeattle,Washington,1126748,224,1126.748\nDenver,Colorado,1109272,224,1109.272\nCharlotte,North Carolina,1081656,224,1081.656\nFort Worth,Texas,1069388,224,1069.388\nPortland,Oregon,1058242,224,1058.242\nOklahoma City,Oklahoma,1012264,224,1012.264\nTucson,Arizona,973398,224,973.398\nNew Orleans,Louisiana,969348,224,969.348\nLas Vegas,Nevada,956868,224,956.868\nCleveland,Ohio,956806,224,956.806\nLong Beach,California,923044,224,923.044\nAlbuquerque,New Mexico,897214,224,897.214\nKansas City,Missouri,883090,224,883.09\nFresno,California,855304,224,855.304\nVirginia Beach,Virginia,850514,224,850.514\nAtlanta,Georgia,832948,224,832.948\nSacramento,California,814036,224,814.036\nOakland,California,798968,224,798.968\nMesa,Arizona,792750,224,792.75\nTulsa,Oklahoma,786098,224,786.098\nOmaha,Nebraska,780014,224,780.014\nMinneapolis,Minnesota,765236,224,765.236\nHonolulu,Hawaii,743314,224,743.314\nMiami,Florida,724940,224,724.94\nColorado Springs,Colorado,721780,224,721.78\nSaint Louis,Missouri,696378,224,696.378\nWichita,Kansas,688568,224,688.568\nSanta Ana,California,675954,224,675.954\nPittsburgh,Pennsylvania,669126,224,669.126\nArlington,Texas,665938,224,665.938\nCincinnati,Ohio,662570,224,662.57\nAnaheim,California,656028,224,656.028\nToledo,Ohio,627238,224,627.238\nTampa,Florida,606894,224,606.894\nBuffalo,New York,585296,224,585.296\nSaint Paul,Minnesota,574302,224,574.302\nCorpus Christi,Texas,554908,224,554.908\nAurora,Colorado,552786,224,552.786\nRaleigh,North Carolina,552186,224,552.186\nNewark,New Jersey,547092,224,547.092\nLexington-Fayette,Kentucky,521024,224,521.024\nAnchorage,Alaska,520566,224,520.566\nLouisville,Kentucky,512462,224,512.462\nRiverside,California,510332,224,510.332\nSaint Petersburg,Florida,496464,224,496.464\nBakersfield,California,494114,224,494.114\nStockton,California,487542,224,487.542\nBirmingham,Alabama,485640,224,485.64\nJersey City,New Jersey,480110,224,480.11\nNorfolk,Virginia,468806,224,468.806\nBaton Rouge,Louisiana,455636,224,455.636\nHialeah,Florida,452838,224,452.838\nLincoln,Nebraska,451162,224,451.162\nGreensboro,North Carolina,447782,224,447.782\nPlano,Texas,444060,224,444.06\nRochester,New York,439546,224,439.546\nGlendale,Arizona,437624,224,437.624\nAkron,Ohio,434148,224,434.148\nGarland,Texas,431536,224,431.536\nMadison,Wisconsin,416108,224,416.108\nFort Wayne,Indiana,411454,224,411.454\nFremont,California,406826,224,406.826\nScottsdale,Arizona,405410,224,405.41\nMontgomery,Alabama,403136,224,403.136\nShreveport,Louisiana,400290,224,400.29\nAugusta-Richmond County,Georgia,399550,224,399.55\nLubbock,Texas,399128,224,399.128\nChesapeake,Virginia,398368,224,398.368\nMobile,Alabama,397830,224,397.83\nDes Moines,Iowa,397364,224,397.364\nGrand Rapids,Michigan,395600,224,395.6\nRichmond,Virginia,395580,224,395.58\nYonkers,New York,392172,224,392.172\nSpokane,Washington,391258,224,391.258\nGlendale,California,389946,224,389.946\nTacoma,Washington,387112,224,387.112\nIrving,Texas,383230,224,383.23\nHuntington Beach,California,379188,224,379.188\nModesto,California,377712,224,377.712\nDurham,North Carolina,374070,224,374.07\nColumbus,Georgia,372582,224,372.582\nOrlando,Florida,371902,224,371.902\nBoise City,Idaho,371574,224,371.574\nWinston-Salem,North Carolina,371552,224,371.552\nSan Bernardino,California,370802,224,370.802\nJackson,Mississippi,368512,224,368.512\nLittle Rock,Arkansas,366266,224,366.266\nSalt Lake City,Utah,363486,224,363.486\nReno,Nevada,360960,224,360.96\nNewport News,Virginia,360300,224,360.3\nChandler,Arizona,353162,224,353.162\nLaredo,Texas,353152,224,353.152\nHenderson,Nevada,350762,224,350.762\nArlington,Virginia,349676,224,349.676\nKnoxville,Tennessee,347780,224,347.78\nAmarillo,Texas,347254,224,347.254\nProvidence,Rhode Island,347236,224,347.236\nChula Vista,California,347112,224,347.112\nWorcester,Massachusetts,345296,224,345.296\nOxnard,California,340716,224,340.716\nDayton,Ohio,332358,224,332.358\nGarden Grove,California,330392,224,330.392\nOceanside,California,322058,224,322.058\nTempe,Arizona,317250,224,317.25\nHuntsville,Alabama,316432,224,316.432\nOntario,California,316014,224,316.014\nChattanooga,Tennessee,311108,224,311.108\nFort Lauderdale,Florida,304794,224,304.794\nSpringfield,Massachusetts,304164,224,304.164\nSpringfield,Missouri,303160,224,303.16\nSanta Clarita,California,302176,224,302.176\nSalinas,California,302120,224,302.12\nTallahassee,Florida,301248,224,301.248\nRockford,Illinois,300230,224,300.23\nPomona,California,298946,224,298.946\nMetairie,Louisiana,298856,224,298.856\nPaterson,New Jersey,298444,224,298.444\nOverland Park,Kansas,298160,224,298.16\nSanta Rosa,California,295190,224,295.19\nSyracuse,New York,294612,224,294.612\nKansas City,Kansas,293732,224,293.732\nHampton,Virginia,292874,224,292.874\nLakewood,Colorado,288252,224,288.252\nVancouver,Washington,287120,224,287.12\nIrvine,California,286144,224,286.144\nAurora,Illinois,285980,224,285.98\nMoreno Valley,California,284762,224,284.762\nPasadena,California,283348,224,283.348\nHayward,California,280060,224,280.06\nBrownsville,Texas,279444,224,279.444\nBridgeport,Connecticut,279058,224,279.058\nHollywood,Florida,278714,224,278.714\nWarren,Michigan,276494,224,276.494\nTorrance,California,275892,224,275.892\nEugene,Oregon,275786,224,275.786\nPembroke Pines,Florida,274854,224,274.854\nSalem,Oregon,273848,224,273.848\nPasadena,Texas,267872,224,267.872\nEscondido,California,267118,224,267.118\nSunnyvale,California,263520,224,263.52\nSavannah,Georgia,263020,224,263.02\nFontana,California,257858,224,257.858\nOrange,California,257642,224,257.642\nNaperville,Illinois,256716,224,256.716\nAlexandria,Virginia,256566,224,256.566\nRancho Cucamonga,California,255486,224,255.486\nGrand Prairie,Texas,254854,224,254.854\nEast Los Angeles,California,252758,224,252.758\nFullerton,California,252006,224,252.006\nCorona,California,249932,224,249.932\nFlint,Michigan,249886,224,249.886\nParadise,Nevada,249364,224,249.364\nMesquite,Texas,249046,224,249.046\nSterling Heights,Michigan,248942,224,248.942\nSioux Falls,South Dakota,247950,224,247.95\nNew Haven,Connecticut,247252,224,247.252\nTopeka,Kansas,244754,224,244.754\nConcord,California,243560,224,243.56\nEvansville,Indiana,243164,224,243.164\nHartford,Connecticut,243156,224,243.156\nFayetteville,North Carolina,242030,224,242.03\nCedar Rapids,Iowa,241516,224,241.516\nElizabeth,New Jersey,241136,224,241.136\nLansing,Michigan,238256,224,238.256\nLancaster,California,237436,224,237.436\nFort Collins,Colorado,237304,224,237.304\nCoral Springs,Florida,235098,224,235.098\nStamford,Connecticut,234166,224,234.166\nThousand Oaks,California,234010,224,234.01\nVallejo,California,233520,224,233.52\nPalmdale,California,233340,224,233.34\nColumbia,South Carolina,232556,224,232.556\nEl Monte,California,231930,224,231.93\nAbilene,Texas,231860,224,231.86\nNorth Las Vegas,Nevada,230976,224,230.976\nAnn Arbor,Michigan,228048,224,228.048\nBeaumont,Texas,227732,224,227.732\nWaco,Texas,227452,224,227.452\nMacon,Georgia,226672,224,226.672\nIndependence,Missouri,226576,224,226.576\nPeoria,Illinois,225872,224,225.872\nInglewood,California,225160,224,225.16\nSpringfield,Illinois,222908,224,222.908\nSimi Valley,California,222702,224,222.702\nLafayette,Louisiana,220514,224,220.514\nGilbert,Arizona,219394,224,219.394\nCarrollton,Texas,219152,224,219.152\nBellevue,Washington,219138,224,219.138\nWest Valley City,Utah,217792,224,217.792\nClarksville,Tennessee,217574,224,217.574\nCosta Mesa,California,217448,224,217.448\nPeoria,Arizona,216728,224,216.728\nSouth Bend,Indiana,215578,224,215.578\nDowney,California,214646,224,214.646\nWaterbury,Connecticut,214542,224,214.542\nManchester,New Hampshire,214012,224,214.012\nAllentown,Pennsylvania,213264,224,213.264\nMcAllen,Texas,212828,224,212.828\nJoliet,Illinois,212442,224,212.442\nLowell,Massachusetts,210334,224,210.334\nProvo,Utah,210332,224,210.332\nWest Covina,California,210160,224,210.16\nWichita Falls,Texas,208394,224,208.394\nErie,Pennsylvania,207434,224,207.434\nDaly City,California,207242,224,207.242\nCitrus Heights,California,206910,224,206.91\nNorwalk,California,206596,224,206.596\nGary,Indiana,205492,224,205.492\nBerkeley,California,205486,224,205.486\nSanta Clara,California,204722,224,204.722\nGreen Bay,Wisconsin,204626,224,204.626\nCape Coral,Florida,204572,224,204.572\nArvada,Colorado,204306,224,204.306\nPueblo,Colorado,204242,224,204.242\nSandy,Utah,203706,224,203.706\nAthens-Clarke County,Georgia,202978,224,202.978\nCambridge,Massachusetts,202710,224,202.71\nWestminster,Colorado,201880,224,201.88\nSan Buenaventura,California,201832,224,201.832\nPortsmouth,Virginia,201130,224,201.13\nLivonia,Michigan,201090,224,201.09\nBurbank,California,200632,224,200.632\nClearwater,Florida,199872,224,199.872\nMidland,Texas,196586,224,196.586\nDavenport,Iowa,196512,224,196.512\nMission Viejo,California,196098,224,196.098\nMiami Beach,Florida,195710,224,195.71\nSunrise Manor,Nevada,190724,224,190.724\nNew Bedford,Massachusetts,189560,224,189.56\nEl Cajon,California,189156,224,189.156\nNorman,Oklahoma,188386,224,188.386\nRichmond,California,188200,224,188.2\nAlbany,New York,187988,224,187.988\nBrockton,Massachusetts,187306,224,187.306\nRoanoke,Virginia,186714,224,186.714\nBillings,Montana,185976,224,185.976\nCompton,California,185728,224,185.728\nGainesville,Florida,184582,224,184.582\nFairfield,California,184512,224,184.512\nArden-Arcade,California,184080,224,184.08\nSan Mateo,California,183598,224,183.598\nVisalia,California,183524,224,183.524\nBoulder,Colorado,182476,224,182.476\nCary,North Carolina,182426,224,182.426\nSanta Monica,California,182168,224,182.168\nFall River,Massachusetts,181110,224,181.11\nKenosha,Wisconsin,178894,224,178.894\nElgin,Illinois,178816,224,178.816\nOdessa,Texas,178586,224,178.586\nCarson,California,178178,224,178.178\nCharleston,South Carolina,178126,224,178.126\nCharlotte Amalie,St Thomas,26000,230,26\nHarare,Harare,2820000,239,2820\nBulawayo,Bulawayo,1243484,239,1243.484\nChitungwiza,Harare,549824,239,549.824\nMount Darwin,Harare,328724,239,328.724\nMutare,Manicaland,262734,239,262.734\nGweru,Midlands,256074,239,256.074\nGaza,Gaza,707264,177,707.264\nKhan Yunis,Khan Yunis,246350,177,246.35\nHebron,Hebron,238802,177,238.802\nJabaliya,North Gaza,227802,177,227.802\nNablus,Nablus,200462,177,200.462\nRafah,Rafah,184040,177,184.04\n"
  },
  {
    "path": "test/fixtures/uploads/CodeEditor.test.csv",
    "content": "Alice,1\nBob,2\nCarol,3\n"
  },
  {
    "path": "test/fixtures/uploads/ColumnFilterData_A.csv",
    "content": "Alice,Jones\nBob,Mathews\nAlice,Jones\nCarol,Jones\nBob,Jones\nAlice,Barber\n"
  },
  {
    "path": "test/fixtures/uploads/ColumnFilterData_B.csv",
    "content": "Fruit,Name,Letter\nApple,Alice,a\nBanana,Bob,a\nOrange,Carol,a\nGrapefruit,Alice,b\nApple,Bob,b\nBanana,Carol,c\nOrange,Alice,c\nGrapefruit,Bob,d\nApple,Carol,d\nBanana,Alice,e\nOrange,Bob,e\nGrapefruit,Carol,f\nApple,Alice,f\nBanana,Bob,g\nOrange,Carol,g\nGrapefruit,Alice,h\nApple,Bob,h\nBanana,Carol,i\nOrange,Alice,i\nGrapefruit,Bob,j\nApple,Carol,j\nBanana,Alice,k\nOrange,Bob,k\nGrapefruit,Carol,l\nApple,Alice,l\nBanana,Bob,m\nOrange,Carol,m\nGrapefruit,Alice,n\nApple,Bob,n\nBanana,Carol,o\nOrange,Alice,p\nApple,Bob,q\nBanana,Carol,r\nApple,Alice,s\n"
  },
  {
    "path": "test/fixtures/uploads/EmptyDate.csv",
    "content": "Name,Birthday\nBob,2018-01-01\nAlice,\nCarol,2017-01-01\n"
  },
  {
    "path": "test/fixtures/uploads/FileUploadData.csv",
    "content": "fname,lname,start_year,end_year\ngeorge,washington,1789,1797\njohn,adams,1797,1801\nthomas,jefferson,1801,1809\n"
  },
  {
    "path": "test/fixtures/uploads/ImportReferences-Tasks.csv",
    "content": "Label,PName,PIndex,PIndex2,PDate,PRowID,PID\nFoo2,Clean,1000,\"1,000\",27 Mar 2023,,0\nBar2,Wash,3000,\"2,000\",,Projects[2],2\nBaz2,Build2,,2,20 Mar 2023,Projects[1],1\nZoo2,Clean,2000,\"4,000\",24 Apr 2023,Projects[3],3\n"
  },
  {
    "path": "test/fixtures/uploads/SchoolData.csv",
    "content": "School,Location\nCornell,Ithaca\nNYU,NYC\nU of R,Rochester"
  },
  {
    "path": "test/fixtures/uploads/StudentData.csv",
    "content": "Student,School,DOB\nMike,1,2/13/92\nJoe,2,12/30/85\nTom,1,1/4/96\nSue,3,5/18/91\nBill,3,6/12/94\n"
  },
  {
    "path": "test/fixtures/uploads/UploadedData1.csv",
    "content": "Name,Phone,Title\nLily,Jones,director\nKathy,Mills,student\nKaren,Gold,professor\n"
  },
  {
    "path": "test/fixtures/uploads/UploadedData1Extended.csv",
    "content": "Name,Phone,Title\nLily,Jones,student\nKathy,Mills,professor\nKaren,Gold,director\nMichael,Smith,student\nLily,James,student\n"
  },
  {
    "path": "test/fixtures/uploads/UploadedData2.csv",
    "content": "CourseId,CourseName,Instructor,StartDate,PassFail\nBUS100,Intro to Business,,01/13/2021,false\nBUS102,Business Law,Nathalie Patricia,01/13/2021,false\nBUS300,Business Operations,Michael Rian,01/14/2021,false\nBUS301,History of Business,Mariyam Melania,01/14/2021,false\nBUS500,Ethics and Law,Filip Andries,01/13/2021,false\nBUS540,Capstone,,01/13/2021,true\n"
  },
  {
    "path": "test/fixtures/uploads/UploadedData2Extended.csv",
    "content": "CourseId,CourseName,Instructor,StartDate,PassFail\nBUS100,Intro to Business,Mariyam Melania,01/13/2021,false\nBUS102,Business Law,Nathalie Patricia,01/13/2021,false\nBUS300,Business Operations,Michael Rian,01/14/2021,false\nBUS301,History of Business,Mariyam Melania,01/14/2021,false\nBUS500,,Filip Andries,01/13/2021,false\nBUS501,Marketing,Michael Rian,01/13/2021,false\nBUS539,Independent Study,,01/13/2021,true\nBUS540,Capstone,,01/13/2021,false\n"
  },
  {
    "path": "test/fixtures/uploads/UploadedData3.csv",
    "content": ",,,\n,,,\nmilk,1,sold\negg,2,in stock\nbutter,4,sold\n"
  },
  {
    "path": "test/fixtures/uploads/UploadedDataEmpty.csv",
    "content": "\n"
  },
  {
    "path": "test/fixtures/uploads/cities.jgrist",
    "content": "{\n    \"parseOptions\": {},\n    \"tables\": [\n        {\n            \"table_name\": \"city\",\n            \"column_metadata\": [\n                {\n                    \"id\": \"id\",\n                    \"type\": \"Int\"\n                },\n                {\n                    \"id\": \"city\",\n                    \"type\": \"Text\"\n                }\n            ],\n            \"table_data\": [\n                [\n                    1,\n                    2\n                ],\n                [\n                    \"Berlin\",\n                    \"Tokyo\"\n                ]\n            ]\n        }\n    ]\n}\n"
  },
  {
    "path": "test/fixtures/uploads/cities_broken.jgrist",
    "content": "{\n    \"parseOptions\": {},\n    \"tables\": [\n        {\n            \"table_name\": \"city\",\n            \"column_metadata\": [\n                {\n                    \"id\": \"id\",\n                    \"type\": \"Int\"\n                },\n                {\n                    \"id\": \"city\",\n                    \"type\": [\"Space\", \"Monkey\"]\n                }\n            ],\n            \"table_data\": [\n                [\n                    1,\n                    2\n                ],\n                [\n                    \"Berlin\",\n                    \"Tokyo\"\n                ]\n            ]\n        }\n    ]\n}\n"
  },
  {
    "path": "test/fixtures/uploads/dirtyNames.json",
    "content": "{\"**dirty_name**\": {\"a\": {\"b\": 1}}}\n"
  },
  {
    "path": "test/fixtures/uploads/empty_data.jgrist",
    "content": "{\n  \"parseOptions\": {\n  },\n  \"tables\": [\n    {\n      \"column_metadata\": [],\n      \"table_data\": [],\n      \"table_name\": \"no-data\"\n    }\n  ]\n}\n"
  },
  {
    "path": "test/fixtures/uploads/formatted_numbers.csv",
    "content": "fn_currency,fn_scientific,fn_decimal,fn_percent,fn_parens\n$1.00,1.20E3,\"2,000,000\",43%,(56)\n"
  },
  {
    "path": "test/fixtures/uploads/htmlfile.html",
    "content": "<html>\n  <body>\n    <script>\n      window.alert(\"ASDF\");\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "test/fixtures/uploads/mixed_dates.csv",
    "content": "date\n2020-03-04\n2020-03-05\n2020-03-06\n2020-03-04\n2020-03-05\n2020-03-06\n2020-03-04\n2020-03-05\n2020-03-06\n01/02/03\n"
  },
  {
    "path": "test/fixtures/uploads/name_references.csv",
    "content": "Name,Reference\nCharlie,Alice\nDennis,Bob\n"
  },
  {
    "path": "test/fixtures/uploads/names.json",
    "content": "[\n  {\n    \"name\": {\n      \"first\": \"Bob\"\n    }\n  },\n  {\n    \"name\": {\n      \"first\": \"Alice\"\n    }\n  }\n]\n"
  },
  {
    "path": "test/fixtures/uploads/simple_array.json",
    "content": "[{\"a\": 1, \"b\": \"baba\"}, {\"a\": 4, \"b\": \"abab\"}]\n"
  },
  {
    "path": "test/fixtures/uploads/spotifyGetSeveralAlbums.json",
    "content": "{\n  \"albums\" : [ {\n    \"album_type\" : \"album\",\n    \"artists\" : [ {\n      \"external_urls\" : {\n        \"spotify\" : \"https://open.spotify.com/artist/53A0W3U0s8diEn9RhXQhVz\"\n      },\n      \"href\" : \"https://api.spotify.com/v1/artists/53A0W3U0s8diEn9RhXQhVz\",\n      \"id\" : \"53A0W3U0s8diEn9RhXQhVz\",\n      \"name\" : \"Keane\",\n      \"type\" : \"artist\",\n      \"uri\" : \"spotify:artist:53A0W3U0s8diEn9RhXQhVz\"\n    } ],\n    \"available_markets\" : [ \"AD\", \"AR\", \"AT\", \"AU\", \"BE\", \"BG\", \"BO\", \"BR\", \"CH\", \"CL\", \"CO\", \"CR\", \"CY\", \"CZ\", \"DE\", \"DK\", \"DO\", \"EC\", \"EE\", \"ES\", \"FI\", \"FR\", \"GB\", \"GR\", \"GT\", \"HK\", \"HN\", \"HU\", \"IE\", \"IS\", \"IT\", \"LI\", \"LT\", \"LU\", \"LV\", \"MC\", \"MT\", \"MY\", \"NI\", \"NL\", \"NO\", \"NZ\", \"PA\", \"PE\", \"PH\", \"PL\", \"PT\", \"PY\", \"RO\", \"SE\", \"SG\", \"SI\", \"SK\", \"SV\", \"TR\", \"TW\", \"UY\" ],\n    \"copyrights\" : [ {\n      \"text\" : \"(C) 2013 Universal Island Records, a division of Universal Music Operations Limited\",\n      \"type\" : \"C\"\n    }, {\n      \"text\" : \"(P) 2013 Universal Island Records, a division of Universal Music Operations Limited\",\n      \"type\" : \"P\"\n    } ],\n    \"external_ids\" : {\n      \"upc\" : \"00602537518357\"\n    },\n    \"external_urls\" : {\n      \"spotify\" : \"https://open.spotify.com/album/41MnTivkwTO3UUJ8DrqEJJ\"\n    },\n    \"genres\" : [ ],\n    \"href\" : \"https://api.spotify.com/v1/albums/41MnTivkwTO3UUJ8DrqEJJ\",\n    \"id\" : \"41MnTivkwTO3UUJ8DrqEJJ\",\n    \"images\" : [ {\n      \"height\" : 640,\n      \"url\" : \"https://i.scdn.co/image/89b92c6b59131776c0cd8e5df46301ffcf36ed69\",\n      \"width\" : 640\n    }, {\n      \"height\" : 300,\n      \"url\" : \"https://i.scdn.co/image/eb6f0b2594d81f8d9dced193f3e9a3bc4318aedc\",\n      \"width\" : 300\n    }, {\n      \"height\" : 64,\n      \"url\" : \"https://i.scdn.co/image/21e1ebcd7ebd3b679d9d5084bba1e163638b103a\",\n      \"width\" : 64\n    } ],\n    \"name\" : \"The Best Of Keane (Deluxe Edition)\",\n    \"popularity\" : 65,\n    \"release_date\" : \"2013-11-08\",\n    \"release_date_precision\" : \"day\",\n    \"tracks\" : {\n      \"href\" : \"https://api.spotify.com/v1/albums/41MnTivkwTO3UUJ8DrqEJJ/tracks?offset=0&limit=50\",\n      \"items\" : [ {\n        \"artists\" : [ {\n          \"external_urls\" : {\n            \"spotify\" : \"https://open.spotify.com/artist/53A0W3U0s8diEn9RhXQhVz\"\n          },\n          \"href\" : \"https://api.spotify.com/v1/artists/53A0W3U0s8diEn9RhXQhVz\",\n          \"id\" : \"53A0W3U0s8diEn9RhXQhVz\",\n          \"name\" : \"Keane\",\n          \"type\" : \"artist\",\n          \"uri\" : \"spotify:artist:53A0W3U0s8diEn9RhXQhVz\"\n        } ],\n        \"available_markets\" : [ \"AD\", \"AR\", \"AT\", \"AU\", \"BE\", \"BG\", \"BO\", \"BR\", \"CH\", \"CL\", \"CO\", \"CR\", \"CY\", \"CZ\", \"DE\", \"DK\", \"DO\", \"EC\", \"EE\", \"ES\", \"FI\", \"FR\", \"GB\", \"GR\", \"GT\", \"HK\", \"HN\", \"HU\", \"IE\", \"IS\", \"IT\", \"LI\", \"LT\", \"LU\", \"LV\", \"MC\", \"MT\", \"MY\", \"NI\", \"NL\", \"NO\", \"NZ\", \"PA\", \"PE\", \"PH\", \"PL\", \"PT\", \"PY\", \"RO\", \"SE\", \"SG\", \"SI\", \"SK\", \"SV\", \"TR\", \"TW\", \"UY\" ],\n        \"disc_number\" : 1,\n        \"duration_ms\" : 215986,\n        \"explicit\" : false,\n        \"external_urls\" : {\n          \"spotify\" : \"https://open.spotify.com/track/4r9PmSmbAOOWqaGWLf6M9Q\"\n        },\n        \"href\" : \"https://api.spotify.com/v1/tracks/4r9PmSmbAOOWqaGWLf6M9Q\",\n        \"id\" : \"4r9PmSmbAOOWqaGWLf6M9Q\",\n        \"name\" : \"Everybody's Changing\",\n        \"preview_url\" : \"https://p.scdn.co/mp3-preview/641fd877ee0f42f3713d1649e20a9734cc64b8f9\",\n        \"track_number\" : 1,\n        \"type\" : \"track\",\n        \"uri\" : \"spotify:track:4r9PmSmbAOOWqaGWLf6M9Q\"\n      }, {\n        \"artists\" : [ {\n          \"external_urls\" : {\n            \"spotify\" : \"https://open.spotify.com/artist/53A0W3U0s8diEn9RhXQhVz\"\n          },\n          \"href\" : \"https://api.spotify.com/v1/artists/53A0W3U0s8diEn9RhXQhVz\",\n          \"id\" : \"53A0W3U0s8diEn9RhXQhVz\",\n          \"name\" : \"Keane\",\n          \"type\" : \"artist\",\n          \"uri\" : \"spotify:artist:53A0W3U0s8diEn9RhXQhVz\"\n        } ],\n        \"available_markets\" : [ \"AD\", \"AR\", \"AT\", \"AU\", \"BE\", \"BG\", \"BO\", \"BR\", \"CH\", \"CL\", \"CO\", \"CR\", \"CY\", \"CZ\", \"DE\", \"DK\", \"DO\", \"EC\", \"EE\", \"ES\", \"FI\", \"FR\", \"GB\", \"GR\", \"GT\", \"HK\", \"HN\", \"HU\", \"IE\", \"IS\", \"IT\", \"LI\", \"LT\", \"LU\", \"LV\", \"MC\", \"MT\", \"MY\", \"NI\", \"NL\", \"NO\", \"NZ\", \"PA\", \"PE\", \"PH\", \"PL\", \"PT\", \"PY\", \"RO\", \"SE\", \"SG\", \"SI\", \"SK\", \"SV\", \"TR\", \"TW\", \"UY\" ],\n        \"disc_number\" : 1,\n        \"duration_ms\" : 235880,\n        \"explicit\" : false,\n        \"external_urls\" : {\n          \"spotify\" : \"https://open.spotify.com/track/0HJQD8uqX2Bq5HVdLnd3ep\"\n        },\n        \"href\" : \"https://api.spotify.com/v1/tracks/0HJQD8uqX2Bq5HVdLnd3ep\",\n        \"id\" : \"0HJQD8uqX2Bq5HVdLnd3ep\",\n        \"name\" : \"Somewhere Only We Know\",\n        \"preview_url\" : \"https://p.scdn.co/mp3-preview/e001676375ea2b4807cee2f98b51f2f3fe0d109b\",\n        \"track_number\" : 2,\n        \"type\" : \"track\",\n        \"uri\" : \"spotify:track:0HJQD8uqX2Bq5HVdLnd3ep\"\n      }],\n      \"limit\" : 50,\n      \"next\" : null,\n      \"offset\" : 0,\n      \"previous\" : null,\n      \"total\" : 9\n    },\n    \"type\" : \"album\",\n    \"uri\" : \"spotify:album:6UXCm6bOO4gFlDQZV5yL37\"\n  } ]\n}\n"
  },
  {
    "path": "test/fixtures/uploads/unicode_headers.csv",
    "content": "Բարեւ աշխարհ,Γειά σου Κόσμε,123 test,สวัสดีชาวโลก,こんにちは世界,नमस्ते दुनिया,გამარჯობა მსოფლიო,你好世界,% test\n"
  },
  {
    "path": "test/gen-server/ApiServer.ts",
    "content": "import { createEmptyOrgUsageSummary, OrgUsageSummary } from \"app/common/DocUsage\";\nimport { TEAM_FREE_PLAN } from \"app/common/Features\";\nimport { isAffirmative } from \"app/common/gutil\";\nimport {\n  PostServiceAccount, ServiceAccountApiResponse, ServiceAccountCreationResponse,\n} from \"app/common/ServiceAccountTypes\";\nimport { DOCTYPE_NORMAL, DOCTYPE_TEMPLATE, DOCTYPE_TUTORIAL, Document, Workspace } from \"app/common/UserAPI\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Product } from \"app/gen-server/entity/Product\";\nimport { HomeDBManager, UserChange } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { testGetPreparedStatementCount, testResetPreparedStatements } from \"app/gen-server/lib/TypeORMPatches\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport {\n  configForApiKey, configForUser, configWithPermit, getRowCounts as getRowCountsForDb,\n} from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios, { AxiosRequestConfig, AxiosResponse } from \"axios\";\nimport * as chai from \"chai\";\nimport omit from \"lodash/omit\";\n\nconst assert = chai.assert;\n\nlet server: TestServer;\nlet dbManager: HomeDBManager;\nlet homeUrl: string;\nlet userCountUpdates: { [orgId: number]: number[] } = {};\n\nconst chimpy = configForUser(\"Chimpy\");\nconst kiwi = configForUser(\"Kiwi\");\nconst charon = configForUser(\"Charon\");\nconst ham = configForUser(\"Ham\");\nconst support = configForUser(\"Support\");\nconst nobody = configForUser(\"Anonymous\");\n\nconst chimpyEmail = \"chimpy@getgrist.com\";\nconst kiwiEmail = \"kiwi@getgrist.com\";\nconst charonEmail = \"charon@getgrist.com\";\nconst hamEmail = \"ham@getgrist.com\";\n\nlet chimpyRef = \"\";\nlet kiwiRef = \"\";\nlet charonRef = \"\";\n\nasync function getRowCounts() {\n  return getRowCountsForDb(dbManager);\n}\n\ndescribe(\"ApiServer\", function() {\n  let oldEnv: testUtils.EnvironmentSnapshot;\n\n  testUtils.setTmpLogLevel(\"error\");\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    process.env.GRIST_TEMPLATE_ORG = \"templates\";\n    // ham (as in dramatic actor) is the admin\n    process.env.GRIST_DEFAULT_EMAIL = hamEmail;\n    process.env.GRIST_ENABLE_SERVICE_ACCOUNTS = \"true\";\n    server = new TestServer(this);\n    homeUrl = await server.start([\"home\", \"docs\"]);\n    dbManager = server.dbManager;\n\n    chimpyRef = await dbManager.getUserByLogin(chimpyEmail).then(user => user.ref);\n    kiwiRef = await dbManager.getUserByLogin(kiwiEmail).then(user => user.ref);\n    charonRef = await dbManager.getUserByLogin(charonEmail).then(user => user.ref);\n\n    // Listen to user count updates and add them to an array.\n    server.server.onUserChange(async ({ org, countBefore, countAfter }: UserChange) => {\n      if (countBefore === countAfter) { return; }\n      userCountUpdates[org.id] = userCountUpdates[org.id] || [];\n      userCountUpdates[org.id].push(countAfter);\n    });\n\n    testResetPreparedStatements();\n  });\n\n  afterEach(async function() {\n    userCountUpdates = {};\n    await server.sanityCheck();\n  });\n\n  after(async function() {\n    oldEnv.restore();\n    await server.stop();\n  });\n\n  it(\"GET /api/orgs reports nothing for anonymous without org in url\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/orgs`, nobody);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, []);\n  });\n\n  it(\"GET /api/orgs reports nothing for anonymous with unavailable org\", async function() {\n    const resp = await axios.get(`${homeUrl}/o/deep/api/orgs`, nobody);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, []);\n  });\n\n  for (const users of [[\"anon\"], [\"anon\", \"everyone\"], [\"everyone\"]]) {\n    it(`GET /api/orgs reports something for anonymous with org available to ${users.join(\", \")}`, async function() {\n      const addUsers: { [key: string]: \"viewers\" | \"owners\" } = {};\n      const removeUsers: { [key: string]: null } = {};\n      for (const user of users) {\n        const email = `${user}@getgrist.com`;\n        addUsers[email] = \"viewers\";\n        removeUsers[email] = null;\n      }\n\n      // Get id of \"Abyss\" org (domain name: \"deep\")\n      const oid = await dbManager.testGetId(\"Abyss\");\n\n      try {\n        // Only support user has right currently to add/remove everyone@\n        let resp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {\n          delta: { users: { \"support@getgrist.com\": \"owners\" } },\n        }, charon);\n        assert.equal(resp.status, 200);\n\n        // Make anon@/everyone@ a viewer of Abyss org\n        resp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {\n          delta: { users: addUsers },\n        }, support);\n        assert.equal(resp.status, 200);\n\n        // Confirm that anon now sees this org when using a url mentioning the org\n        resp = await axios.get(`${homeUrl}/o/deep/api/orgs`, nobody);\n        assert.equal(resp.status, 200);\n        assert.deepEqual([\"Abyss\"], resp.data.map((o: any) => o.name));\n        // Org is marked as public\n        assert.equal(resp.data[0].public, true);\n\n        // Confirm that anon doesn't see this org from other urls\n        resp = await axios.get(`${homeUrl}/o/nasa/api/orgs`, nobody);\n        assert.equal(resp.status, 200);\n        assert.deepEqual([], resp.data.map((o: any) => o.name));\n\n        // Confirm that anon doesn't see this org from /session/access/all\n        resp = await axios.get(`${homeUrl}/api/session/access/all`,\n          await server.getCookieLogin(\"nasa\", null));\n        assert.equal(resp.status, 200);\n        assert.deepEqual([], resp.data.orgs.map((o: any) => o.name));\n\n        // Confirm that regular users don't see this org listed from other domains,\n        // either via api/orgs or api/session/access/all.\n        resp = await axios.get(`${homeUrl}/o/nasa/api/orgs`, chimpy);\n        assert.equal(resp.status, 200);\n        let orgs = resp.data.map((o: any) => o.name);\n        assert.notInclude(orgs, \"Abyss\");\n        resp = await axios.get(`${homeUrl}/o/nasa/api/session/access/all`,\n          await server.getCookieLogin(\"nasa\", { email: \"chimpy@getgrist.com\",\n            name: \"Chimpy\" }));\n        assert.equal(resp.status, 200);\n        orgs = resp.data.orgs.map((o: any) => o.name);\n        assert.notInclude(orgs, \"Abyss\");\n\n        // Confirm that regular users see this org only via api/orgs,\n        // and only when on the right domain, and only when shared with \"everyone@\".\n        resp = await axios.get(`${homeUrl}/o/deep/api/orgs`, chimpy);\n        assert.equal(resp.status, 200);\n        orgs = resp.data.map((o: any) => o.name);\n        if (users.includes(\"everyone\")) {\n          assert.include(orgs, \"Abyss\");\n        } else {\n          assert.notInclude(orgs, \"Abyss\");\n        }\n        resp = await axios.get(`${homeUrl}/o/deep/api/session/access/all`,\n          await server.getCookieLogin(\"deep\", { email: \"chimpy@getgrist.com\",\n            name: \"Chimpy\" }));\n        assert.equal(resp.status, 200);\n        orgs = resp.data.orgs.map((o: any) => o.name);\n        assert.notInclude(orgs, \"Abyss\");\n      } finally {\n        // Cleanup: remove anon from org\n        let resp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {\n          delta: { users: removeUsers },\n        }, support);\n        assert.equal(resp.status, 200);\n        resp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {\n          delta: { users: { \"support@getgrist.com\": null } },\n        }, charon);\n        assert.equal(resp.status, 200);\n\n        // Confirm that access has gone away\n        resp = await axios.get(`${homeUrl}/o/deep/api/orgs`, nobody);\n        assert.equal(resp.status, 200);\n        assert.deepEqual([], resp.data.map((o: any) => o.name));\n      }\n    });\n  }\n\n  it(\"GET /api/orgs is operational\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/orgs`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data.map((org: any) => org.name),\n      [\"Chimpyland\", \"EmptyOrg\", \"EmptyWsOrg\", \"Fish\", \"Flightless\",\n        \"FreeTeam\", \"NASA\", \"Primately\", \"TestAuditLogs\", \"TestDailyApiLimit\",\n        \"TestMaxNewUserInvites\"]);\n    // personal orgs should have an owner and no domain\n    // createdAt and updatedAt are omitted since exact times cannot be predicted.\n    assert.deepEqual(\n      omit(resp.data[0], \"createdAt\", \"updatedAt\", \"owner.createdAt\"),\n      {\n        id: await dbManager.testGetId(\"Chimpyland\"),\n        name: \"Chimpyland\",\n        access: \"owners\",\n        // public is not set.\n        domain: \"docs-1\",\n        host: null,\n        owner: {\n          id: await dbManager.testGetId(\"Chimpy\"),\n          ref: await dbManager.testGetRef(\"Chimpy\"),\n          name: \"Chimpy\",\n          picture: null,\n          type: \"login\",\n        },\n      },\n    );\n    assert.isNotNull(resp.data[0].updatedAt);\n    // regular orgs should have a domain and no owner\n    assert.equal(resp.data[1].domain, \"blankiest\");\n    assert.equal(resp.data[1].owner, null);\n  });\n\n  it(\"GET /api/orgs respects permissions\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/orgs`, kiwi);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data[0].name, \"Kiwiland\");\n    assert.equal(resp.data[0].owner.name, \"Kiwi\");\n    assert.deepEqual(resp.data.map((org: any) => org.name),\n      [\"Kiwiland\", \"Fish\", \"Flightless\", \"Primately\"]);\n  });\n\n  it(\"GET /api/orgs/{oid} is operational\", async function() {\n    const oid = await dbManager.testGetId(\"NASA\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.name, \"NASA\");\n  });\n\n  it(\"GET /api/orgs/{oid} accepts domains\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/orgs/nasa`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.name, \"NASA\");\n  });\n\n  it(\"GET /api/orgs/{oid} accepts current keyword\", async function() {\n    const resp = await axios.get(`${homeUrl}/o/nasa/api/orgs/current`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.name, \"NASA\");\n  });\n\n  it(\"GET /api/orgs/{oid} fails with current keyword if no domain active\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/orgs/current`, chimpy);\n    assert.equal(resp.status, 400);\n  });\n\n  it(\"GET /api/orgs/{oid} returns owner when available\", async function() {\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy);\n    assert.equal(resp.status, 200);\n    // billingAccount is omitted since it isn't focus of this test.\n    assert.deepEqual(\n      omit(resp.data, \"createdAt\", \"updatedAt\", \"billingAccount\", \"owner.createdAt\"),\n      {\n        id: oid,\n        name: \"Chimpyland\",\n        domain: \"docs-1\",\n        host: null,\n        access: \"owners\",\n        owner: {\n          id: await dbManager.testGetId(\"Chimpy\"),\n          ref: await dbManager.testGetRef(\"Chimpy\"),\n          name: \"Chimpy\",\n          picture: null,\n          type: \"login\",\n        },\n      },\n    );\n    assert.isNotNull(resp.data.updatedAt);\n  });\n\n  it(\"GET /api/orgs/{oid} respects permissions\", async function() {\n    const oid = await dbManager.testGetId(\"Kiwiland\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy);\n    assert.equal(resp.status, 403);\n    assert.deepEqual(resp.data, { error: \"access denied\" });\n  });\n\n  it(\"GET /api/orgs/{oid} returns 404 appropriately\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/orgs/9999`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: \"organization not found\" });\n  });\n\n  it(\"GET /api/orgs/{oid}/workspaces is operational\", async function() {\n    const oid = await dbManager.testGetId(\"NASA\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.lengthOf(resp.data, 2);\n    assert.deepEqual(resp.data.map((ws: any) => ws.name), [\"Horizon\", \"Rovers\"]);\n    assert.equal(resp.data[0].id, await dbManager.testGetId(\"Horizon\"));\n    assert.equal(resp.data[1].id, await dbManager.testGetId(\"Rovers\"));\n    assert.deepEqual(resp.data[0].docs.map((doc: any) => doc.name),\n      [\"Jupiter\", \"Pluto\", \"Beyond\"]);\n    // Check that Primately access is as expected.\n    const oid2 = await dbManager.testGetId(\"Primately\");\n    const resp2 = await axios.get(`${homeUrl}/api/orgs/${oid2}/workspaces`, kiwi);\n    assert.equal(resp2.status, 200);\n    assert.lengthOf(resp2.data, 2);\n    assert.deepEqual(resp2.data.map((ws: any) => ws.name), [\"Fruit\", \"Trees\"]);\n    assert.deepEqual(resp2.data[0].docs.map((doc: any) => omit(doc, \"createdAt\", \"updatedAt\")), [{\n      access: \"viewers\",\n      // public is not set\n      id: \"sampledocid_6\",\n      name: \"Bananas\",\n      isPinned: false,\n      urlId: null,\n      trunkId: null,\n      type: null,\n      forks: [],\n    }, {\n      access: \"viewers\",\n      // public is not set\n      id: \"sampledocid_7\",\n      name: \"Apples\",\n      isPinned: false,\n      urlId: null,\n      trunkId: null,\n      type: null,\n      forks: [],\n    }]);\n    assert.deepEqual(resp2.data[1].docs.map((doc: any) => omit(doc, \"createdAt\", \"updatedAt\")), [{\n      access: \"viewers\",\n      id: \"sampledocid_8\",\n      name: \"Tall\",\n      isPinned: false,\n      urlId: null,\n      trunkId: null,\n      type: null,\n      forks: [],\n    }, {\n      access: \"viewers\",\n      id: \"sampledocid_9\",\n      name: \"Short\",\n      isPinned: false,\n      urlId: null,\n      trunkId: null,\n      type: null,\n      forks: [],\n    }]);\n    // Assert that updatedAt values are present.\n    resp2.data[0].docs.map((doc: any) => assert.isNotNull(doc.updatedAt));\n    resp2.data[1].docs.map((doc: any) => assert.isNotNull(doc.updatedAt));\n  });\n\n  it(\"GET /api/orgs/{oid}/workspaces accepts domains\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/orgs/nasa/workspaces`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.lengthOf(resp.data, 2);\n    assert.deepEqual(resp.data.map((ws: any) => ws.name), [\"Horizon\", \"Rovers\"]);\n  });\n\n  it(\"GET /api/orgs/{oid}/workspaces accepts current keyword\", async function() {\n    const resp = await axios.get(`${homeUrl}/o/nasa/api/orgs/current/workspaces`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.lengthOf(resp.data, 2);\n    assert.deepEqual(resp.data.map((ws: any) => ws.name), [\"Horizon\", \"Rovers\"]);\n  });\n\n  it(\"GET /api/orgs/{oid}/workspaces returns 403 appropriately\", async function() {\n    const oid = await dbManager.testGetId(\"Kiwiland\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"GET /api/orgs/{oid}/workspaces lists individually shared workspaces\", async function() {\n    const oid = await dbManager.testGetId(\"Primately\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.lengthOf(resp.data, 1);  // 1 of 2 workspaces should be present\n    assert.equal(resp.data[0].name, \"Fruit\");\n  });\n\n  it(\"GET /api/orgs/{oid}/workspaces lists individually shared docs\", async function() {\n    const oid = await dbManager.testGetId(\"Flightless\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.lengthOf(resp.data, 1);\n    assert.equal(resp.data[0].name, \"Media\");\n    assert.lengthOf(resp.data[0].docs, 1);  // 1 of 2 docs should be available\n    assert.equal(resp.data[0].docs[0].name, \"Antartic\");\n  });\n\n  it(\"GET /api/orgs/{wid}/workspaces gives results when workspace is empty\", async function() {\n    const oid = await dbManager.testGetId(\"EmptyWsOrg\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data[0].name, \"Vacuum\");\n    assert.lengthOf(resp.data[0].docs, 0);  // No docs present\n  });\n\n  it(\"GET /api/workspaces/{wid} is operational\", async function() {\n    const wid = await dbManager.testGetId(\"Horizon\");\n    const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    assert.deepEqual(resp.data.docs.map((doc: any) => doc.name),\n      [\"Jupiter\", \"Pluto\", \"Beyond\"]);\n    assert.equal(resp.data.org.name, \"NASA\");\n    assert.equal(resp.data.org.owner, null);\n  });\n\n  it(\"GET /api/workspaces/{wid} lists individually shared docs\", async function() {\n    const wid = await dbManager.testGetId(\"Media\");\n    const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.lengthOf(resp.data.docs, 1);  // 1 of 2 docs should be available\n    assert.equal(resp.data.docs[0].name, \"Antartic\");\n  });\n\n  it(\"GET /api/workspaces/{wid} gives results when empty\", async function() {\n    const wid = await dbManager.testGetId(\"Vacuum\");\n    const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.name, \"Vacuum\");\n    assert.lengthOf(resp.data.docs, 0);  // No docs present\n  });\n\n  it(\"GET /api/workspaces/{wid} respects permissions\", async function() {\n    const wid = await dbManager.testGetId(\"Deep\");\n    const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"GET /api/workspaces/{wid} returns 404 appropriately\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/workspaces/9999`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"GET /api/workspaces/{wid} gives owner of org\", async function() {\n    const wid = await dbManager.testGetId(\"Private\");\n    const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);\n    assert.equal(resp.data.org.owner.name, \"Chimpy\");\n  });\n\n  it(\"POST /api/orgs/{oid}/workspaces is operational\", async function() {\n    // Add a 'Planets' workspace to the 'NASA' org.\n    const oid = await dbManager.testGetId(\"NASA\");\n    const wid = await getNextId(dbManager, \"workspaces\");\n    const resp = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, {\n      name: \"Planets\",\n    }, chimpy);\n    // Assert that the response is successful and contains the next available workspace id.\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data, wid);\n    // Assert that the added workspace can be fetched and returns as expected.\n    const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${resp.data}`, chimpy);\n    const workspace = omit(fetchResp.data, \"createdAt\", \"updatedAt\");\n    workspace.org = omit(workspace.org, \"createdAt\", \"updatedAt\");\n    assert.deepEqual(workspace, {\n      id: wid,\n      name: \"Planets\",\n      access: \"owners\",\n      docs: [],\n      isSupportWorkspace: false,\n      org: {\n        id: 1,\n        name: \"NASA\",\n        domain: \"nasa\",\n        host: null,\n        owner: null,\n      },\n    });\n  });\n\n  it(\"POST /api/orgs/{oid}/workspaces returns 404 appropriately\", async function() {\n    // Attempt to add to an org that doesn't exist.\n    const resp = await axios.post(`${homeUrl}/api/orgs/9999/workspaces`, {\n      name: \"Planets\",\n    }, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"POST /api/orgs/{oid}/workspaces returns 403 appropriately\", async function() {\n    // Attempt to add to an org that chimpy doesn't have write permission on.\n    const oid = await dbManager.testGetId(\"Primately\");\n    const resp = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, {\n      name: \"Apes\",\n    }, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"POST /api/orgs/{oid}/workspaces returns 400 appropriately\", async function() {\n    // Use an unknown property and check that the operation fails with status 400.\n    const oid = await dbManager.testGetId(\"NASA\");\n    const resp = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, { x: 1 }, chimpy);\n    assert.equal(resp.status, 400);\n  });\n\n  it(\"PATCH /api/workspaces/{wid} is operational\", async function() {\n    // Rename the 'Horizons' workspace to 'Horizons2'.\n    const wid = await dbManager.testGetId(\"Horizon\");\n    const resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Horizon2\",\n    }, chimpy);\n    // Assert that the response is successful.\n    assert.equal(resp.status, 200);\n    // Assert that the workspace was renamed as expected.\n    const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    const workspace = fetchResp.data;\n    assert.equal(workspace.name, \"Horizon2\");\n\n    // Change the name back.\n    const wid2 = await dbManager.testGetId(\"Horizon2\");\n    const resp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid2}`, {\n      name: \"Horizon\",\n    }, chimpy);\n    assert.equal(resp2.status, 200);\n  });\n\n  it(\"PATCH /api/workspaces/{wid} returns 404 appropriately\", async function() {\n    // Attempt to rename a workspace that doesn't exist.\n    const resp = await axios.patch(`${homeUrl}/api/workspaces/9999`, {\n      name: \"Rename\",\n    }, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"PATCH /api/workspaces/{wid} returns 403 appropriately\", async function() {\n    // Attempt to rename a workspace without UPDATE access.\n    const wid = await dbManager.testGetId(\"Fruit\");\n    const resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Fruit2\",\n    }, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"PATCH /api/workspaces/{wid} returns 400 appropriately\", async function() {\n    // Use an unavailable property and check that the operation fails with 400.\n    const wid = await dbManager.testGetId(\"Rovers\");\n    const resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, { x: 1 }, chimpy);\n    assert.equal(resp.status, 400);\n  });\n\n  it(\"DELETE /api/workspaces/{wid} is operational\", async function() {\n    // Add Kiwi to 'Public' workspace.\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    let wid = await dbManager.testGetId(\"Public\");\n\n    // Assert that the number of users in the org has not been updated.\n    assert.deepEqual(userCountUpdates[oid as number], undefined);\n\n    const delta = {\n      users: { [kiwiEmail]: \"viewers\" },\n    };\n    const accessResp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, { delta }, chimpy);\n    assert.equal(accessResp.status, 200);\n\n    // Assert that Kiwi is a guest of the org.\n    const orgResp = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);\n    assert.equal(orgResp.status, 200);\n    assert.deepEqual(orgResp.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: \"guests\",\n        isMember: false,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Assert that the number of non-guest users in the org is unchanged.\n    assert.deepEqual(userCountUpdates[oid as number], undefined);\n\n    const beforeDelCount = await getRowCounts();\n\n    // Delete 'Public' workspace.\n    const resp = await axios.delete(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    // Assert that the response is successful.\n    assert.equal(resp.status, 200);\n    // Assert that the workspace is no longer in the database.\n    const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    assert.equal(fetchResp.status, 404);\n\n    // Assert that Kiwi is no longer a guest of the org.\n    const orgResp2 = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);\n    assert.equal(orgResp2.status, 200);\n    assert.deepEqual(orgResp2.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Assert that the number of non-guest users in the org remains unchanged.\n    assert.deepEqual(userCountUpdates[oid as number], undefined);\n\n    const afterDelCount1 = await getRowCounts();\n    // Assert that one workspace was removed.\n    assert.equal(afterDelCount1.workspaces, beforeDelCount.workspaces - 1);\n    // Assert that Kiwi's workspace viewer and org guest items were removed.\n    assert.equal(afterDelCount1.groupUsers, beforeDelCount.groupUsers - 2);\n\n    // Re-add 'Public'\n    const addWsResp = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, {\n      name: \"Public\",\n    }, chimpy);\n    // Assert that the response is successful\n    assert.equal(addWsResp.status, 200);\n    wid = addWsResp.data;\n\n    // Add a doc to 'Public'\n    const addDocResp1 = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {\n      name: \"PublicDoc1\",\n    }, chimpy);\n\n    // Add another workspace, 'Public2'\n    const addWsResp2 = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, {\n      name: \"Public2\",\n    }, chimpy);\n    assert.equal(addWsResp2.status, 200);\n\n    // Get 'Public2' workspace.\n    const wid2 = addWsResp2.data;\n\n    // Add a doc to 'Public2'\n    const addDocResp2 = await axios.post(`${homeUrl}/api/workspaces/${wid2}/docs`, {\n      name: \"PublicDoc2\",\n    }, chimpy);\n    assert.equal(addDocResp2.status, 200);\n\n    // Get both doc's ids.\n    const did1 = addDocResp1.data;\n    const did2 = addDocResp2.data;\n\n    const beforeAddCount = await getRowCounts();\n\n    // Add Kiwi to the docs\n    const docAccessResp1 = await axios.patch(`${homeUrl}/api/docs/${did1}/access`, { delta }, chimpy);\n    assert.equal(docAccessResp1.status, 200);\n    const docAccessResp2 = await axios.patch(`${homeUrl}/api/docs/${did2}/access`, { delta }, chimpy);\n    assert.equal(docAccessResp2.status, 200);\n\n    // Assert that Kiwi is a guest of the org.\n    const orgResp3 = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);\n    assert.equal(orgResp3.status, 200);\n    assert.deepEqual(orgResp3.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: \"guests\",\n        isMember: false,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Assert that the number of non-guest users in the org remains unchanged.\n    assert.deepEqual(userCountUpdates[oid as number], undefined);\n\n    const afterAddCount = await getRowCounts();\n    // Assert that Kiwi's 2 doc viewer, 2 workspace guest and 1 org guest items were added.\n    assert.equal(afterAddCount.groupUsers, beforeAddCount.groupUsers + 5);\n\n    // Delete 'Public2' workspace.\n    const deleteResp2 = await axios.delete(`${homeUrl}/api/workspaces/${wid2}`, chimpy);\n    assert.equal(deleteResp2.status, 200);\n\n    const afterDelCount2 = await getRowCounts();\n    // Assert that one workspace was removed.\n    assert.equal(afterDelCount2.workspaces, afterAddCount.workspaces - 1);\n    // Assert that one doc was removed.\n    assert.equal(afterDelCount2.docs, afterAddCount.docs - 1);\n    // Assert that one of Kiwi's doc viewer items and one of Kiwi's workspace guest items were removed\n    // and guest chimpy assignment to org and chimpy owner assignment to doc and ws.\n    assert.equal(afterDelCount2.groupUsers, afterAddCount.groupUsers - 2 - 3);\n\n    // Assert that Kiwi is STILL a guest of the org, since Kiwi is still in the 'Public' doc.\n    const orgResp4 = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);\n    assert.equal(orgResp4.status, 200);\n    assert.deepEqual(orgResp4.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: \"guests\",\n        isMember: false,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Assert that the number of non-guest users in the org remains unchanged.\n    assert.deepEqual(userCountUpdates[oid as number], undefined);\n    // Delete 'Public' workspace.\n    const deleteResp3 = await axios.delete(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    // Assert that the response is successful.\n    assert.equal(deleteResp3.status, 200);\n\n    const afterDelCount3 = await getRowCounts();\n    // Assert that another workspace was removed.\n    assert.equal(afterDelCount3.workspaces, afterDelCount2.workspaces - 1);\n    // Assert that another doc was removed.\n    assert.equal(afterDelCount3.docs, afterDelCount2.docs - 1);\n    // Assert that Kiwi's doc viewer item, workspace guest and org guest items were removed, and Chimpy was\n    // removed from doc as owner, ws as guest and owner, and org as guest.\n    assert.equal(afterDelCount3.groupUsers, afterDelCount2.groupUsers - 7);\n\n    // Assert that Kiwi is no longer a guest of the org.\n    const orgResp5 = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);\n    assert.equal(orgResp5.status, 200);\n    assert.deepEqual(orgResp5.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Assert that the number of non-guest users in the org remains unchanged.\n    assert.deepEqual(userCountUpdates[oid as number], undefined);\n\n    // Re-add 'Public' finally\n    const addWsResp3 = await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, {\n      name: \"Public\",\n    }, chimpy);\n    // Assert that the response is successful\n    assert.equal(addWsResp3.status, 200);\n  });\n\n  it(\"DELETE /api/workspaces/{wid} returns 404 appropriately\", async function() {\n    // Attempt to delete a workspace that doesn't exist.\n    const resp = await axios.delete(`${homeUrl}/api/workspaces/9999`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"DELETE /api/workspaces/{wid} returns 403 appropriately\", async function() {\n    // Attempt to delete a workspace without REMOVE access.\n    const wid = await dbManager.testGetId(\"Fruit\");\n    const resp = await axios.delete(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"POST /api/workspaces/{wid}/docs is operational\", async function() {\n    // Add a 'Surprise' doc to the 'Rovers' workspace.\n    const wid = await dbManager.testGetId(\"Rovers\");\n    const resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {\n      name: \"Surprise\",\n    }, chimpy);\n    // Assert that the response is successful and contains the doc id.\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.length, 22); // The length of a short-uuid string\n    // Assert that the added doc can be fetched and returns as expected.\n    const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    const workspace = fetchResp.data;\n    assert.deepEqual(workspace.name, \"Rovers\");\n    assert.deepEqual(workspace.docs.map((d: any) => d.name),\n      [\"Curiosity\", \"Apathy\", \"Surprise\"]);\n  });\n\n  it(\"POST /api/workspaces/{wid}/docs handles urlIds\", async function() {\n    // Add a 'Boredom' doc to the 'Rovers' workspace.\n    const wid = await dbManager.testGetId(\"Rovers\");\n    let resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {\n      name: \"Boredom\",\n      urlId: \"Hohum\",\n    }, chimpy);\n    // Assert that the response is successful\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.length, 22); // The length of a short-uuid string\n    const docId = resp.data;\n    // Assert that the added doc can be fetched and returns as expected using urlId.\n    resp = await axios.get(`${homeUrl}/api/docs/Hohum`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.id, docId);\n    // Adding a new doc with the same urlId should fail.\n    resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {\n      name: \"NonEnthusiasm\",\n      urlId: \"Hohum\",\n    }, chimpy);\n    assert.equal(resp.status, 400);\n    // Change Boredom doc to use a different urlId\n    // Also, use the existing urlId in the endpoint just to check that works\n    resp = await axios.patch(`${homeUrl}/api/docs/Hohum`, {\n      urlId: \"sigh\",\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    // Hohum still resolves to Boredom for the moment\n    resp = await axios.get(`${homeUrl}/api/docs/Hohum`, chimpy);\n    assert.equal(resp.data.id, docId);\n    // Adding a new doc with the Hohum urlId should now succeed.\n    resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {\n      name: \"NonEnthusiasm\",\n      urlId: \"Hohum\",\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    const docId2 = resp.data;\n    // Hohum now resolves to the new doc.\n    resp = await axios.get(`${homeUrl}/api/docs/Hohum`, chimpy);\n    assert.equal(resp.data.id, docId2);\n    // Delete the new doc.  Use urlId to check that works.\n    await axios.delete(`${homeUrl}/api/docs/Hohum`, chimpy);\n  });\n\n  it(\"POST /api/workspaces/{wid}/docs returns 404 appropriately\", async function() {\n    // Attempt to add to an workspace that doesn't exist.\n    const resp = await axios.post(`${homeUrl}/api/workspaces/9999/docs`, {\n      name: \"Mercury\",\n    }, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"POST /api/workspaces/{wid}/docs returns 403 without access\", async function() {\n    // Attempt to add to a workspace that chimpy doesn't have any access to.\n    const wid = await dbManager.testGetId(\"Trees\");\n    const resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {\n      name: \"Bushy\",\n    }, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"POST /api/workspaces/{wid}/docs returns 403 with view access\", async function() {\n    // Attempt to add to a workspace that chimpy has only view access to.\n    const wid = await dbManager.testGetId(\"Fruit\");\n    const resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {\n      name: \"Oranges\",\n    }, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"POST /api/workspaces/{wid}/docs returns 400 appropriately\", async function() {\n    // Omit the new doc name and check that the operation fails with status 400.\n    const wid = await dbManager.testGetId(\"Rovers\");\n    const resp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {}, chimpy);\n    assert.equal(resp.status, 400);\n  });\n\n  it(\"POST /api/orgs is operational\", async function() {\n    const oid = await getNextId(dbManager, \"orgs\");\n    // Add a 'Magic' org.\n    const resp = await axios.post(`${homeUrl}/api/orgs`, {\n      name: \"Magic\",\n      domain: \"magic\",\n    }, chimpy);\n    // Assert that the response is successful and contains the next available org id.\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data, oid);\n    // Assert that the added org can be fetched and returns as expected.\n    let fetchResp = await axios.get(`${homeUrl}/api/orgs/${resp.data}`, chimpy);\n    const org = fetchResp.data;\n    assert.deepEqual(omit(org, \"createdAt\", \"updatedAt\"), {\n      id: oid,\n      name: \"Magic\",\n      access: \"owners\",\n      domain: `o-${oid}`,    // default product suppresses vanity domains\n      host: null,\n      owner: null,\n      billingAccount: {\n        id: oid,\n        inGoodStanding: true,\n        individual: false,\n        isManager: true,\n        paid: false,\n        product: {\n          id: await dbManager.testGetId(\"stub\"),\n          features: {},\n          name: \"stub\",\n        },\n        status: null,\n        externalId: null,\n        externalOptions: null,\n        features: null,\n        stripePlanId: null,\n        paymentLink: null,\n      },\n    });\n    assert.isNotNull(org.updatedAt);\n\n    // Upgrade this org to a fancier plan, and check that vanity domain starts working\n    await upgradeOrg(dbManager, \"Magic\");\n    // Check that we now get the vanity domain\n    fetchResp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy);\n    assert.equal(fetchResp.data.domain, \"magic\");\n  });\n\n  it(\"POST /api/orgs returns 400 appropriately\", async function() {\n    // Omit the new org name and check that the operation fails with status 400.\n    const resp = await axios.post(`${homeUrl}/api/orgs`, {\n      domain: \"invalid-req\",\n    }, chimpy);\n    assert.equal(resp.status, 400);\n  });\n\n  it(\"PATCH /api/orgs/{oid} is operational\", async function() {\n    // Rename the 'Magic' org to 'Holiday' with domain 'holiday'.\n    const oid = await dbManager.testGetId(\"Magic\");\n    const resp = await axios.patch(`${homeUrl}/api/orgs/${oid}`, {\n      name: \"Holiday\",\n      domain: \"holiday\",\n    }, chimpy);\n    // Assert that the response is successful.\n    assert.equal(resp.status, 200);\n    // Assert that the org was renamed as expected.\n    const fetchResp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy);\n    const org = fetchResp.data;\n    assert.equal(org.name, \"Holiday\");\n    assert.equal(org.domain, \"holiday\");\n    // Update the org domain to 'holiday2'.\n    const resp2 = await axios.patch(`${homeUrl}/api/orgs/${oid}`, {\n      domain: \"holiday2\",\n    }, chimpy);\n    // Assert that the response is successful.\n    assert.equal(resp2.status, 200);\n    // Assert that the org was updated as expected.\n    const fetchResp2 = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy);\n    assert.equal(fetchResp2.data.name, \"Holiday\");\n    assert.equal(fetchResp2.data.domain, \"holiday2\");\n  });\n\n  it(\"PATCH /api/orgs/{oid} returns 404 appropriately\", async function() {\n    // Attempt to rename an org that doesn't exist.\n    const resp = await axios.patch(`${homeUrl}/api/orgs/9999`, {\n      name: \"Rename\",\n    }, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"PATCH /api/orgs/{oid} returns 403 appropriately\", async function() {\n    // Attempt to rename an org without UPDATE access.\n    const oid = await dbManager.testGetId(\"Primately\");\n    const resp = await axios.patch(`${homeUrl}/api/orgs/${oid}`, {\n      name: \"Primately2\",\n    }, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"PATCH /api/orgs/{oid} returns 400 appropriately\", async function() {\n    // Use an unavailable property and check that the operation fails with 400.\n    const oid = await dbManager.testGetId(\"Holiday\");\n    const resp = await axios.patch(`${homeUrl}/api/orgs/${oid}`, { x: 1 }, chimpy);\n    assert.equal(resp.status, 400);\n    assert.match(resp.data.error, /unrecognized property/);\n  });\n\n  it(\"DELETE /api/orgs/{oid} no longer operates\", async function() {\n    // Delete the 'Holiday' org.\n    const oid = await dbManager.testGetId(\"Holiday\");\n    const resp = await axios.delete(`${homeUrl}/api/orgs/${oid}`, chimpy);\n    // Assert that the response fails.\n    assert.equal(resp.status, 410);\n    // Assert that the org is still in the database.\n    const fetchResp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy);\n    assert.equal(fetchResp.status, 200);\n  });\n\n  it(\"DELETE /api/orgs/{oid}/{name} is operational\", async function() {\n    // Delete the 'Holiday' org.\n    let oid = await dbManager.testGetId(\"Holiday\");\n    let resp = await axios.delete(`${homeUrl}/api/orgs/${oid}/Holida`, chimpy);\n    // Assert that the response is a failure with wrong name.\n    assert.equal(resp.status, 400);\n    assert.match(resp.data.error, /Name does not match organization/);\n    resp = await axios.delete(`${homeUrl}/api/orgs/${oid}/Holiday`, chimpy);\n    // Assert that the response is successful with right name.\n    assert.equal(resp.status, 200);\n    // Assert that the org is no longer in the database.\n    resp = await axios.get(`${homeUrl}/api/orgs/${oid}`, chimpy);\n    assert.equal(resp.status, 404);\n\n    async function createTestDomain(): Promise<number> {\n      const fetchResp = await axios.post(`${homeUrl}/api/orgs`, {\n        name: \"The Name\",\n        domain: \"the-domain\",\n      }, chimpy);\n      assert.equal(fetchResp.status, 200);\n      await upgradeOrg(dbManager, \"The Name\"); // for vanity domains\n      return fetchResp.data;\n    }\n\n    // Check use of org name, domain, and force-delete\n    for (const name of [\"The Name\", \"the-domain\", \"force-delete\"]) {\n      oid = await createTestDomain();\n      resp = await axios.delete(`${homeUrl}/api/orgs/${oid}/${name.slice(0, -1)}`, chimpy);\n      assert.equal(resp.status, 400);\n      resp = await axios.delete(`${homeUrl}/api/orgs/${oid}/${name}`, chimpy);\n      assert.equal(resp.status, 200);\n    }\n  });\n\n  it(\"DELETE /api/orgs/{oid}/{name} returns 404 appropriately\", async function() {\n    // Attempt to delete an org that doesn't exist.\n    const resp = await axios.delete(`${homeUrl}/api/orgs/9999/bing`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"DELETE /api/orgs/{oid}/{name} returns 403 appropriately\", async function() {\n    // Attempt to delete an org without REMOVE access.\n    const oid = await dbManager.testGetId(\"Primately\");\n    const resp = await axios.delete(`${homeUrl}/api/orgs/${oid}/Primately`, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"GET /api/docs/{did} is operational\", async function() {\n    const did = await dbManager.testGetId(\"Jupiter\");\n    const resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.equal(resp.status, 200);\n    const doc: Document = resp.data;\n    assert.equal(doc.name, \"Jupiter\");\n    assert.equal(doc.workspace.name, \"Horizon\");\n    assert.equal(doc.workspace.org.name, \"NASA\");\n    assert.equal(doc.public, undefined);\n  });\n\n  it(\"GET /api/docs/{did} returns 404 for nonexistent doc\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/docs/typotypotypo`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"GET /api/docs/{did} returns 403 without access\", async function() {\n    const did = await dbManager.testGetId(\"Jupiter\");\n    const resp = await axios.get(`${homeUrl}/api/docs/${did}`, kiwi);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"GET /api/docs/{did} returns 403 for disabled users\", async function() {\n    const chimpyUser = await dbManager.getUserByLogin(chimpyEmail);\n    const chimpyId = chimpyUser.id;\n    try {\n      const did = await dbManager.testGetId(\"Jupiter\");\n\n      // Chimpy has access at first.\n      let resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n      assert.equal(resp.status, 200);\n      await axios.get(`${homeUrl}/api/docs/${did}/records`, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Then chimpy misbehaves. So, kiwi tries to ban chimpy...\n      resp = await axios.post(`${homeUrl}/api/users/${chimpyId}/disable`, { name: \"Kiwi\" }, kiwi);\n      assert.equal(resp.status, 403);\n\n      // ... but it doesn't work!\n      resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Since kiwi doesn't have permission to ban chimpy, ham steps in with the banHAMmer\n      resp = await axios.post(`${homeUrl}/api/users/${chimpyId}/disable`, { name: \"Ham\" }, ham);\n      assert.equal(resp.status, 200);\n\n      // Poor chimpy now really is banned\n      resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n      assert.equal(resp.status, 403);\n      await axios.get(`${homeUrl}/api/docs/${did}/records`, chimpy);\n      assert.equal(resp.status, 403);\n\n      // Chimpy learns their lesson but kiwi can't let them back in\n      resp = await axios.post(`${homeUrl}/api/users/${chimpyId}/enable`, { name: \"Kiwi\" }, kiwi);\n      assert.equal(resp.status, 403);\n\n      // So ham has to give chimpy a second chance\n      resp = await axios.post(`${homeUrl}/api/users/${chimpyId}/enable`, { name: \"Ham\" }, ham);\n      assert.equal(resp.status, 200);\n\n      // Welcome back chimpy, we're all friends again\n      resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n      assert.equal(resp.status, 200);\n      await axios.get(`${homeUrl}/api/docs/${did}/records`, chimpy);\n      assert.equal(resp.status, 200);\n    } finally {\n      chimpyUser.disabledAt = null;\n      await chimpyUser.save();\n    }\n  });\n\n  // Unauthorized folks can currently check if a document uuid exists and that's ok,\n  // arguably, because uuids don't leak anything sensitive.\n  it(\"GET /api/docs/{did} returns 404 without org access for nonexistent doc\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/docs/typotypotypo`, kiwi);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"GET /api/docs/{did} returns 404 for doc accessed from wrong org\", async function() {\n    const did = await dbManager.testGetId(\"Jupiter\");\n    const resp = await axios.get(`${homeUrl}/o/pr/api/docs/${did}`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"GET /api/docs/{did} works for doc accessed from correct org\", async function() {\n    const did = await dbManager.testGetId(\"Jupiter\");\n    const resp = await axios.get(`${homeUrl}/o/nasa/api/docs/${did}`, chimpy);\n    assert.equal(resp.status, 200);\n  });\n\n  it(\"PATCH /api/docs/{did} is operational\", async function() {\n    // Rename the 'Surprise' doc to 'Surprise2'.\n    const did = await dbManager.testGetId(\"Surprise\");\n    const resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      name: \"Surprise2\",\n    }, chimpy);\n    // Assert that the response is successful.\n    assert.equal(resp.status, 200);\n    // Assert that the doc was renamed as expected.\n    const wid = await dbManager.testGetId(\"Rovers\");\n    const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    const workspace = fetchResp.data;\n    assert.deepEqual(workspace.name, \"Rovers\");\n    assert.deepEqual(workspace.docs.map((d: any) => d.name),\n      [\"Curiosity\", \"Apathy\", \"Surprise2\", \"Boredom\"]);\n  });\n\n  it(\"PATCH /api/docs/{did} works for urlIds\", async function() {\n    // Check that 'curio' is not yet a valid id for anything\n    let resp = await axios.get(`${homeUrl}/api/docs/curio`, chimpy);\n    assert.equal(resp.status, 404);\n    // Make 'curio' a urlId for document named 'Curiosity'\n    const did = await dbManager.testGetId(\"Curiosity\");\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      urlId: \"curio\",\n    }, chimpy);\n    // Assert that the response is successful.\n    assert.equal(resp.status, 200);\n    // Check we can now access same doc via urlId.\n    resp = await axios.get(`${homeUrl}/api/docs/curio`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.id, did);\n    assert.equal(resp.data.urlId, \"curio\");\n    // Check that we still have access via docId.\n    resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.id, did);\n    assert.equal(resp.data.urlId, \"curio\");\n    // Add another urlId for the same doc\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      urlId: \"hmm\",\n    }, chimpy);\n    // Check we can now access same doc via this new urlId.\n    resp = await axios.get(`${homeUrl}/api/docs/hmm`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.id, did);\n    assert.equal(resp.data.urlId, \"hmm\");\n    // Check that urlIds accumulate, and previous urlId still works.\n    resp = await axios.get(`${homeUrl}/api/docs/curio`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.id, did);\n    assert.equal(resp.data.urlId, \"hmm\");\n    // Check that we still have access via docId.\n    resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.id, did);\n    assert.equal(resp.data.urlId, \"hmm\");\n  });\n\n  it(\"PATCH /api/docs/{did} handles urlIds for different orgs independently\", async function() {\n    // set a urlId with the same name on two docs in different orgs\n    const did = await dbManager.testGetId(\"Curiosity\");  // part of NASA org\n    const did2 = await dbManager.testGetId(\"Herring\");   // part of Fish org\n    let resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      urlId: \"example\",\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.patch(`${homeUrl}/api/docs/${did2}`, {\n      urlId: \"example\",\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    // Check that we get the right doc in the right org.\n    resp = await axios.get(`${homeUrl}/o/nasa/api/docs/example`, chimpy);\n    assert.equal(resp.data.id, did);\n    resp = await axios.get(`${homeUrl}/o/fish/api/docs/example`, chimpy);\n    assert.equal(resp.data.id, did2);\n    // For a url that isn't associated with an org, the result is ambiguous for this user.\n    resp = await axios.get(`${homeUrl}/api/docs/example`, chimpy);\n    assert.equal(resp.status, 400);\n    // For user Kiwi, who has no NASA access, the result is not ambiguous.\n    resp = await axios.get(`${homeUrl}/api/docs/example`, kiwi);\n    assert.equal(resp.status, 200);\n  });\n\n  it(\"PATCH /api/docs/{did} can reuse urlIds within an org\", async function() {\n    // Make 'puzzler' a urlId for document named 'Curiosity'\n    const did = await dbManager.testGetId(\"Curiosity\");\n    let resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { urlId: \"puzzler\" }, chimpy);\n    // Assert that the response is successful.\n    assert.equal(resp.status, 200);\n    // Check we can now access same doc via urlId.\n    resp = await axios.get(`${homeUrl}/api/docs/puzzler`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.id, did);\n    assert.equal(resp.data.urlId, \"puzzler\");\n    // Try to make 'puzzler' a urlId for document within same org.\n    const did2 = await dbManager.testGetId(\"Apathy\");\n    resp = await axios.patch(`${homeUrl}/api/docs/${did2}`, { urlId: \"puzzler\" }, chimpy);\n    // Not allowed, since there's a live doc in the org using this urlId.\n    assert.equal(resp.status, 400);\n    // Remove the urlId from first doc\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { urlId: null }, chimpy);\n    assert.equal(resp.status, 200);\n    // The urlId should still forward (until we reuse it later).\n    resp = await axios.get(`${homeUrl}/api/docs/puzzler`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.id, did);\n    // Try to make 'puzzler' a urlId for second document again, it should work this time.\n    resp = await axios.patch(`${homeUrl}/api/docs/${did2}`, { urlId: \"puzzler\" }, chimpy);\n    assert.equal(resp.status, 200);\n    // Check we can now access new doc via urlId.\n    resp = await axios.get(`${homeUrl}/api/docs/puzzler`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.id, did2);\n    assert.equal(resp.data.urlId, \"puzzler\");\n    // Check that the first doc is accessible via its docId.\n    resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.id, did);\n    assert.equal(resp.data.urlId, null);\n  });\n\n  it(\"PATCH /api/docs/{did} forbids funky urlIds\", async function() {\n    const badUrlIds = new Set([\"sp/ace\", \"sp ace\", \"space!\", \"spa.ce\", \"\"]);\n    const goodUrlIds = new Set([\"sp-ace\", \"spAace\", \"SPac3\", \"s\"]);\n    const did = await dbManager.testGetId(\"Curiosity\");\n    let resp;\n    for (const urlId of [...badUrlIds, ...goodUrlIds]) {\n      resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { urlId }, chimpy);\n      assert.equal(resp.status, goodUrlIds.has(urlId) ? 200 : 400);\n    }\n    for (const urlId of [...badUrlIds, ...goodUrlIds]) {\n      resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, chimpy);\n      assert.equal(resp.status, goodUrlIds.has(urlId) ? 200 : 404);\n    }\n    // It is permissible to reset urlId to null\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { urlId: null }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/api/docs/sp-ace`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.urlId, null);\n  });\n\n  it(\"PATCH /api/docs/{did} supports options\", async function() {\n    // Set some options on the 'Surprise2' doc.\n    const did = await dbManager.testGetId(\"Surprise2\");\n    let resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      options: { description: \"boo\", openMode: \"fork\" },\n    }, chimpy);\n    assert.equal(resp.status, 200);\n\n    // Check they show up in a workspace request.\n    const wid = await dbManager.testGetId(\"Rovers\");\n    resp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    const workspace: Workspace = resp.data;\n    const doc = workspace.docs.find(d => d.name === \"Surprise2\");\n    assert.deepEqual(doc?.options, { description: \"boo\", openMode: \"fork\" });\n\n    // Check setting one option preserves others.\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      options: { description: \"boo!\" },\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.deepEqual(resp.data?.options, { description: \"boo!\", openMode: \"fork\" });\n\n    // Check setting to null removes an option.\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      options: { openMode: null },\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.deepEqual(resp.data?.options, { description: \"boo!\" });\n\n    // Check setting options object to null wipes it completely.\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      options: null,\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.deepEqual(resp.data?.options, undefined);\n\n    // Check setting icon works.\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      options: { icon: \"https://grist-static.com/icons/foo.png\" },\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.deepEqual(resp.data?.options, { icon: \"https://grist-static.com/icons/foo.png\" });\n\n    // Check random urls are not supported.\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      options: { icon: \"https://not-grist-static.com/icons/evil.exe\" },\n    }, chimpy);\n    assert.equal(resp.status, 400);\n    resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.deepEqual(resp.data?.options, { icon: \"https://grist-static.com/icons/foo.png\" });\n\n    // Check removing icon works.\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      options: { icon: null },\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.deepEqual(resp.data?.options, undefined);\n  });\n\n  it(\"PATCH /api/docs/{did} supports proper values for type key\", async function() {\n    const did = await dbManager.testGetId(\"Surprise2\");\n\n    // Check that we start with a DOCTYPE_NORMAL document.\n    const resp = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.isNull(resp.data.type);\n\n    const types = [DOCTYPE_TEMPLATE, DOCTYPE_TUTORIAL, DOCTYPE_NORMAL];\n\n    // Tests for all three Document types\n    for (const type of types) {\n      const resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { type }, chimpy);\n      assert.equal(resp.status, 200);\n      const resp2 = await axios.get(`${homeUrl}/api/docs/${did}`, chimpy);\n      assert.deepEqual(resp2.data.type, type);\n    }\n  });\n\n  it(\"PATCH /api/docs/{did} returns 404 appropriately\", async function() {\n    // Attempt to rename a doc that doesn't exist.\n    const resp = await axios.patch(`${homeUrl}/api/docs/9999`, {\n      name: \"Rename\",\n    }, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"PATCH /api/docs/{did} returns 403 with view access\", async function() {\n    // Attempt to rename a doc without UPDATE access.\n    const did = await dbManager.testGetId(\"Bananas\");\n    const resp = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      name: \"Bananas2\",\n    }, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"PATCH /api/docs/{did} returns 400 appropriately\", async function() {\n    // Use an unavailable property and check that the operation fails with 400.\n    const did = await dbManager.testGetId(\"Surprise2\");\n    const resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { x: 1 }, chimpy);\n    assert.equal(resp.status, 400);\n  });\n\n  it(\"PATCH /api/docs/{did} returns 400 on wrong type values\", async function() {\n    // Use an unavailable property and check that the operation fails with 400.\n    const did = await dbManager.testGetId(\"Surprise2\");\n    const resp = await axios.patch(`${homeUrl}/api/docs/${did}`, { type: \"invalid\" }, chimpy);\n    assert.equal(resp.status, 400);\n    assert.isObject(resp.data);\n    assert.hasAllKeys(resp.data, [\"error\"]);\n    assert.equal(resp.data.error, \"Bad Request. 'type' key authorized values : 'template', 'tutorial' or null\");\n  });\n\n  it(\"DELETE /api/docs/{did} is operational\", async function() {\n    const oid = await dbManager.testGetId(\"NASA\");\n    const wid = await dbManager.testGetId(\"Rovers\");\n    const did = await dbManager.testGetId(\"Surprise2\");\n\n    // Assert that the number of users in the org has not been updated.\n    assert.deepEqual(userCountUpdates[oid as number], undefined);\n\n    // Add Kiwi to the 'Surprise2' doc.\n    const delta = {\n      users: { [kiwiEmail]: \"viewers\" },\n    };\n    const accessResp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta }, chimpy);\n    assert.equal(accessResp.status, 200);\n\n    // Assert that Kiwi is a guest of the ws.\n    const wsResp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.equal(wsResp.status, 200);\n    assert.deepEqual(wsResp.data, {\n      maxInheritedRole: \"owners\",\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"guests\",\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: \"guests\",\n        parentAccess: null,\n        isMember: false,\n      }, {\n        // Note that Charon is listed despite lacking access since Charon is a guest of the org.\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: null,\n        parentAccess: null,\n        isMember: false,\n      }],\n    });\n\n    // Assert that the number of non-guest users in the org is unchanged.\n    assert.deepEqual(userCountUpdates[oid as number], undefined);\n\n    // Assert that Kiwi is a guest of the org.\n    const orgResp = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);\n    assert.equal(orgResp.status, 200);\n    assert.deepEqual(orgResp.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: \"guests\",\n        isMember: false,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"guests\",\n        isMember: false,\n      }],\n    });\n\n    const beforeDelCount = await getRowCounts();\n\n    // Delete the 'Surprise2' doc.\n    const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy);\n    // Assert that the response is successful.\n    assert.equal(deleteResp.status, 200);\n    // Assert that the doc is no longer in the database.\n    const fetchResp = await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    const workspace = fetchResp.data;\n    assert.deepEqual(workspace.name, \"Rovers\");\n    assert.deepEqual(workspace.docs.map((d: any) => d.name),\n      [\"Curiosity\", \"Apathy\", \"Boredom\"]);\n\n    // Assert that Kiwi is no longer a guest of the ws.\n    const wsResp2 = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.equal(wsResp2.status, 200);\n    assert.deepEqual(wsResp2.data, {\n      maxInheritedRole: \"owners\",\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"guests\",\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        // Note that Charon is listed despite lacking access since Charon is a guest of the org.\n        id: 3,\n        email: charonEmail,\n        ref: charonRef,\n        name: \"Charon\",\n        picture: null,\n        access: null,\n        parentAccess: null,\n        isMember: false,\n      }],\n    });\n\n    // Assert that Kiwi is no longer a guest of the org.\n    const orgResp2 = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);\n    assert.equal(orgResp2.status, 200);\n    assert.deepEqual(orgResp2.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"guests\",\n        isMember: false,\n      }],\n    });\n\n    // Assert that the number of non-guest users in the org is unchanged.\n    assert.deepEqual(userCountUpdates[oid as number], undefined);\n\n    const afterDelCount = await getRowCounts();\n    // Assert that the doc was removed.\n    assert.equal(afterDelCount.docs, beforeDelCount.docs - 1);\n    // Assert that Chimpy doc owner item, Kiwi's doc viewer item, workspace guest and org guest items were removed.\n    assert.equal(afterDelCount.groupUsers, beforeDelCount.groupUsers - 4);\n  });\n\n  it(\"DELETE /api/docs/{did} returns 404 appropriately\", async function() {\n    // Attempt to delete a doc that doesn't exist.\n    const resp = await axios.delete(`${homeUrl}/api/docs/9999`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"DELETE /api/docs/{did} returns 403 with view access\", async function() {\n    // Attempt to delete a doc without REMOVE access.\n    const did = await dbManager.testGetId(\"Bananas\");\n    const resp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"GET /api/zig is a 404\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/zig`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: \"not found: /api/zig\" });\n  });\n\n  it(\"PATCH /api/docs/{did}/move is operational within the same org\", async function() {\n    const did = await dbManager.testGetId(\"Jupiter\");\n    const wsId1 = await dbManager.testGetId(\"Horizon\");\n    const wsId2 = await dbManager.testGetId(\"Rovers\");\n    const orgId = await dbManager.testGetId(\"NASA\");\n\n    // Check that move returns 200\n    const resp1 = await axios.patch(`${homeUrl}/api/docs/${did}/move`,\n      { workspace: wsId2 }, chimpy);\n    assert.equal(resp1.status, 200);\n    // Check that the doc is removed from the source workspace\n    const verifyResp1 = await axios.get(`${homeUrl}/api/workspaces/${wsId1}`, chimpy);\n    assert.deepEqual(verifyResp1.data.docs.map((doc: any) => doc.name), [\"Pluto\", \"Beyond\"]);\n    // Check that the doc is added to the dest workspace\n    const verifyResp2 = await axios.get(`${homeUrl}/api/workspaces/${wsId2}`, chimpy);\n    assert.deepEqual(verifyResp2.data.docs.map((doc: any) => doc.name),\n      [\"Jupiter\", \"Curiosity\", \"Apathy\", \"Boredom\"]);\n\n    // Try a complex case - give a user special access to the doc then move it back\n    // Make Kiwi a doc editor for Jupiter\n    const delta1 = {\n      users: { [kiwiEmail]: \"editors\" },\n    };\n    const accessResp1 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta: delta1 }, chimpy);\n    assert.equal(accessResp1.status, 200);\n    // Check that Kiwi is a guest of the workspace/org\n    const kiwiResp1 = await axios.get(`${homeUrl}/api/workspaces/${wsId2}`, kiwi);\n    assert.equal(kiwiResp1.status, 200);\n    const kiwiResp2 = await axios.get(`${homeUrl}/api/orgs/${orgId}`, kiwi);\n    assert.equal(kiwiResp2.status, 200);\n    // Assert that the number of non-guest users in the org is unchanged.\n    assert.deepEqual(userCountUpdates[orgId as number], undefined);\n    // Move the doc back to Horizon\n    const resp2 = await axios.patch(`${homeUrl}/api/docs/${did}/move`,\n      { workspace: wsId1 }, chimpy);\n    assert.equal(resp2.status, 200);\n    // Assert that the number of non-guest users in the org is unchanged.\n    assert.deepEqual(userCountUpdates[orgId as number], undefined);\n    // Check that the doc is removed from the source workspace\n    const verifyResp3 = await axios.get(`${homeUrl}/api/workspaces/${wsId2}`, chimpy);\n    assert.deepEqual(verifyResp3.data.docs.map((doc: any) => doc.name),\n      [\"Curiosity\", \"Apathy\", \"Boredom\"]);\n    // Check that the doc is added to the dest workspace\n    const verifyResp4 = await axios.get(`${homeUrl}/api/workspaces/${wsId1}`, chimpy);\n    assert.deepEqual(verifyResp4.data.docs.map((doc: any) => doc.name),\n      [\"Jupiter\", \"Pluto\", \"Beyond\"]);\n    // Check that Kiwi is NO LONGER a guest of the source workspace\n    const kiwiResp3 = await axios.get(`${homeUrl}/api/workspaces/${wsId2}`, kiwi);\n    assert.equal(kiwiResp3.status, 403);\n    // Check that Kiwi is a guest of the destination workspace\n    const kiwiResp5 = await axios.get(`${homeUrl}/api/workspaces/${wsId1}`, kiwi);\n    assert.equal(kiwiResp5.status, 200);\n    // Finish by revoking Kiwi's access\n    const delta2 = {\n      users: { [kiwiEmail]: null },\n    };\n    const accessResp2 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta: delta2 }, chimpy);\n    assert.equal(accessResp2.status, 200);\n    // Assert that the number of non-guest users in the org is unchanged.\n    assert.deepEqual(userCountUpdates[orgId as number], undefined);\n\n    // Test adding a doc and moving it to a workspace with less access\n    const fishOrg = await dbManager.testGetId(\"Fish\");\n    const bigWs = await dbManager.testGetId(\"Big\");\n    const resp = await axios.post(`${homeUrl}/api/workspaces/${bigWs}/docs`, {\n      name: \"Magic\",\n    }, chimpy);\n    // Assert that the response is successful and contains the doc id.\n    assert.equal(resp.status, 200);\n    const magicDocId = resp.data;\n    // Remove chimpy's direct owner permission on this document. Chimpy is added directly as an owner.\n    // We need to do it as a different user.\n    assert.equal((await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`, { delta: {\n      users: { [charonEmail]: \"owners\" },\n    } }, chimpy)).status, 200);\n    assert.equal((await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`, { delta: {\n      users: { [chimpyEmail]: null },\n    } }, charon)).status, 200);\n    // Create a workspace and limit Chimpy's access to that workspace.\n    const addMediumWsResp = await axios.post(`${homeUrl}/api/orgs/${fishOrg}/workspaces`, {\n      name: \"Medium\",\n    }, chimpy);\n    assert.equal(addMediumWsResp.status, 200);\n    const mediumWs = addMediumWsResp.data;\n    // Limit all access to it expect for Kiwi.\n    const delta3 = {\n      maxInheritedRole: null,\n      users: { [kiwiEmail]: \"owners\" },\n    };\n    const accessResp3 = await axios.patch(`${homeUrl}/api/workspaces/${mediumWs}/access`,\n      { delta: delta3 }, chimpy);\n    assert.equal(accessResp3.status, 200);\n    // Chimpy's access must be removed by Kiwi, since Chimpy would have been granted access\n    // by being unable to limit his own access.\n    const delta4 = {\n      users: { [chimpyEmail]: \"editors\" },\n    };\n    const accessResp4 = await axios.patch(`${homeUrl}/api/workspaces/${mediumWs}/access`,\n      { delta: delta4 }, kiwi);\n    assert.equal(accessResp4.status, 200);\n\n    // Move the doc to the new 'Medium' workspace.\n    const moveMagicResp = await axios.patch(`${homeUrl}/api/docs/${magicDocId}/move`,\n      { workspace: mediumWs }, chimpy);\n    assert.equal(moveMagicResp.status, 200);\n    // Check that doc access on magic can no longer be edited by chimpy\n    const delta = {\n      users: {\n        [kiwiEmail]: \"editors\",\n      },\n    };\n    const accessResp = await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`,\n      { delta }, chimpy);\n    assert.equal(accessResp.status, 403);\n    // Check that chimpy can no longer access the magic doc\n    const chimpyRemoveResp = await axios.delete(`${homeUrl}/api/workspaces/${mediumWs}`, chimpy);\n    // Assert that the response is successful.\n    assert.equal(chimpyRemoveResp.status, 403);\n\n    // Finish by removing the added workspace\n    const kiwiRemoveResp = await axios.delete(`${homeUrl}/api/workspaces/${mediumWs}`, kiwi);\n    // Assert that the response is successful.\n    assert.equal(kiwiRemoveResp.status, 200);\n  });\n\n  it(\"PATCH /api/docs/{did}/move is operational between orgs\", async function() {\n    const did = await dbManager.testGetId(\"Jupiter\");\n    const srcWsId = await dbManager.testGetId(\"Horizon\");\n    const srcOrgId = await dbManager.testGetId(\"NASA\");\n    const dstWsId = await dbManager.testGetId(\"Private\");\n    const dstOrgId = await dbManager.testGetId(\"Chimpyland\");\n\n    // Check that move returns 200\n    const resp1 = await axios.patch(`${homeUrl}/api/docs/${did}/move`,\n      { workspace: dstWsId }, chimpy);\n    assert.equal(resp1.status, 200);\n    // Check that the doc is removed from the source workspace\n    const verifyResp1 = await axios.get(`${homeUrl}/api/workspaces/${srcWsId}`, chimpy);\n    assert.deepEqual(verifyResp1.data.docs.map((doc: any) => doc.name), [\"Pluto\", \"Beyond\"]);\n    // Check that the doc is added to the dest workspace\n    const verifyResp2 = await axios.get(`${homeUrl}/api/workspaces/${dstWsId}`, chimpy);\n    assert.deepEqual(verifyResp2.data.docs.map((doc: any) => doc.name),\n      [\"Jupiter\", \"Timesheets\", \"Appointments\"]);\n\n    // Assert that the number of non-guest users in the org is unchanged.\n    assert.deepEqual(userCountUpdates[srcOrgId as number], undefined);\n    assert.deepEqual(userCountUpdates[dstOrgId as number], undefined);\n\n    // Try a complex case - give a user special access to the doc then move it back\n    // Make Kiwi a doc editor for Jupiter\n    const delta1 = {\n      users: { [kiwiEmail]: \"editors\" },\n    };\n    const accessResp1 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta: delta1 }, chimpy);\n    assert.equal(accessResp1.status, 200);\n    // Check that Kiwi is a guest of the workspace/org\n    const kiwiResp1 = await axios.get(`${homeUrl}/api/workspaces/${dstWsId}`, kiwi);\n    assert.equal(kiwiResp1.status, 200);\n    const kiwiResp2 = await axios.get(`${homeUrl}/api/orgs/${dstOrgId}`, kiwi);\n    assert.equal(kiwiResp2.status, 200);\n    // Assert that the number of non-guest users in 'Chimpyland' is unchanged.\n    assert.deepEqual(userCountUpdates[srcOrgId as number], undefined);\n    assert.deepEqual(userCountUpdates[dstOrgId as number], undefined);\n    // Move the doc back to Horizon\n    const resp2 = await axios.patch(`${homeUrl}/api/docs/${did}/move`,\n      { workspace: srcWsId }, chimpy);\n    assert.equal(resp2.status, 200);\n    // Check that the doc is removed from the source workspace\n    const verifyResp3 = await axios.get(`${homeUrl}/api/workspaces/${dstWsId}`, chimpy);\n    assert.deepEqual(verifyResp3.data.docs.map((doc: any) => doc.name),\n      [\"Timesheets\", \"Appointments\"]);\n    // Check that the doc is added to the dest workspace\n    const verifyResp4 = await axios.get(`${homeUrl}/api/workspaces/${srcWsId}`, chimpy);\n    assert.deepEqual(verifyResp4.data.docs.map((doc: any) => doc.name),\n      [\"Jupiter\", \"Pluto\", \"Beyond\"]);\n    // Check that Kiwi is NO LONGER a guest of the workspace/org\n    const kiwiResp3 = await axios.get(`${homeUrl}/api/workspaces/${dstWsId}`, kiwi);\n    assert.equal(kiwiResp3.status, 403);\n    const kiwiResp4 = await axios.get(`${homeUrl}/api/orgs/${dstOrgId}`, kiwi);\n    assert.equal(kiwiResp4.status, 403);\n    // Check that Kiwi is a guest of the new workspace/org\n    const kiwiResp5 = await axios.get(`${homeUrl}/api/workspaces/${srcWsId}`, kiwi);\n    assert.equal(kiwiResp5.status, 200);\n    const kiwiResp6 = await axios.get(`${homeUrl}/api/orgs/${srcOrgId}`, kiwi);\n    assert.equal(kiwiResp6.status, 200);\n    // Assert that the number of non-guest users in the orgs have not changed.\n    assert.deepEqual(userCountUpdates[srcOrgId as number], undefined);\n    assert.deepEqual(userCountUpdates[dstOrgId as number], undefined);\n    // Finish by revoking Kiwi's access\n    const delta2 = {\n      users: { [kiwiEmail]: null },\n    };\n    const accessResp2 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta: delta2 }, chimpy);\n    assert.equal(accessResp2.status, 200);\n    // Assert that the number of non-guest users in the orgs have not changed.\n    assert.deepEqual(userCountUpdates[srcOrgId as number], undefined);\n    assert.deepEqual(userCountUpdates[dstOrgId as number], undefined);\n\n    // Add a doc and move it to a workspace with less access\n    const publicWs = await dbManager.testGetId(\"Public\");\n    const resp = await axios.post(`${homeUrl}/api/workspaces/${publicWs}/docs`, {\n      name: \"Magic\",\n    }, chimpy);\n    // Assert that the response is successful and contains the doc id.\n    assert.equal(resp.status, 200);\n    const magicDocId = resp.data;\n    // Remove chimpy's direct owner permission on this document. Chimpy is added directly as an owner.\n    // We need to do it as a different user.\n    assert.equal((await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`, { delta: {\n      users: { [charonEmail]: \"owners\" },\n    } }, chimpy)).status, 200);\n    assert.equal((await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`, { delta: {\n      users: { [chimpyEmail]: null },\n    } }, charon)).status, 200);\n    // Move the doc to Vacuum\n    const vacuum = await dbManager.testGetId(\"Vacuum\");\n    const moveMagicResp = await axios.patch(`${homeUrl}/api/docs/${magicDocId}/move`,\n      { workspace: vacuum }, chimpy);\n    assert.equal(moveMagicResp.status, 200);\n    // Check that doc access on magic can no longer be edited by chimpy\n    const delta = {\n      users: {\n        [kiwiEmail]: \"editors\",\n      },\n    };\n    const accessResp = await axios.patch(`${homeUrl}/api/docs/${magicDocId}/access`,\n      { delta }, chimpy);\n    assert.equal(accessResp.status, 403);\n    // Finish by removing the added doc\n    let removeResp = await axios.delete(`${homeUrl}/api/docs/${magicDocId}`, chimpy);\n    // Assert that the response is a failure - we are only editors.\n    assert.equal(removeResp.status, 403);\n    const store = server.getWorkStore().getPermitStore(\"internal\");\n    const goodDocPermit = await store.setPermit({ docId: magicDocId });\n    removeResp = await axios.delete(`${homeUrl}/api/docs/${magicDocId}`, configWithPermit(chimpy, goodDocPermit));\n    assert.equal(removeResp.status, 200);\n  });\n\n  it(\"PATCH /api/docs/{did}/move returns 404 appropriately\", async function() {\n    const workspace = await dbManager.testGetId(\"Private\");\n    const resp = await axios.patch(`${homeUrl}/api/docs/9999/move`,  { workspace }, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"PATCH /api/docs/{did}/move returns 403 appropriately\", async function() {\n    // Attempt moving a doc that the caller does not own, assert that it fails.\n    const did = await dbManager.testGetId(\"Bananas\");\n    const workspace = await dbManager.testGetId(\"Private\");\n    const resp = await axios.patch(`${homeUrl}/api/docs/${did}/move`, { workspace }, chimpy);\n    assert.equal(resp.status, 403);\n    // Attempt moving a doc that the caller owns to a workspace to which they do not\n    // have ADD access. Assert that it fails.\n    const did2 = await dbManager.testGetId(\"Timesheets\");\n    const workspace2 = await dbManager.testGetId(\"Fruit\");\n    const resp2 = await axios.patch(`${homeUrl}/api/docs/${did2}/move`,\n      { workspace: workspace2 }, chimpy);\n    assert.equal(resp2.status, 403);\n  });\n\n  it(\"PATCH /api/docs/{did}/move returns 400 appropriately\", async function() {\n    // Assert that attempting to move a doc to the workspace it starts in\n    // returns 400\n    const did = await dbManager.testGetId(\"Jupiter\");\n    const srcWsId = await dbManager.testGetId(\"Horizon\");\n    const resp = await axios.patch(`${homeUrl}/api/docs/${did}/move`,\n      { workspace: srcWsId }, chimpy);\n    assert.equal(resp.status, 400);\n  });\n\n  it(\"PATCH /api/docs/:did/pin is operational\", async function() {\n    const nasaOrgId = await dbManager.testGetId(\"NASA\");\n    const chimpylandOrgId = await dbManager.testGetId(\"Chimpyland\");\n    const plutoDocId = await dbManager.testGetId(\"Pluto\");\n    const timesheetsDocId = await dbManager.testGetId(\"Timesheets\");\n    const appointmentsDocId = await dbManager.testGetId(\"Appointments\");\n    // Pin 3 docs in 2 different orgs.\n    const resp1 = await axios.patch(`${homeUrl}/api/docs/${plutoDocId}/pin`, {}, chimpy);\n    assert.equal(resp1.status, 200);\n    const resp2 = await axios.patch(`${homeUrl}/api/docs/${timesheetsDocId}/pin`, {}, chimpy);\n    assert.equal(resp2.status, 200);\n    const resp3 = await axios.patch(`${homeUrl}/api/docs/${appointmentsDocId}/pin`, {}, chimpy);\n    assert.equal(resp3.status, 200);\n    // Assert that the docs are set as pinned when retrieved.\n    const fetchResp1 = await axios.get(`${homeUrl}/api/orgs/${nasaOrgId}/workspaces`, charon);\n    assert.equal(fetchResp1.status, 200);\n    assert.deepEqual(fetchResp1.data[0].docs.map((doc: any) => omit(doc, \"createdAt\", \"updatedAt\")), [{\n      id: await dbManager.testGetId(\"Pluto\"),\n      name: \"Pluto\",\n      access: \"viewers\",\n      isPinned: true,\n      urlId: null,\n      trunkId: null,\n      type: null,\n      forks: [],\n    }]);\n    const fetchResp2 = await axios.get(`${homeUrl}/api/orgs/${chimpylandOrgId}/workspaces`, charon);\n    assert.equal(fetchResp2.status, 200);\n    const privateWs = fetchResp2.data.find((ws: any) => ws.name === \"Private\");\n    assert.deepEqual(privateWs.docs.map((doc: any) => omit(doc, \"createdAt\", \"updatedAt\")), [{\n      id: timesheetsDocId,\n      name: \"Timesheets\",\n      access: \"viewers\",\n      isPinned: true,\n      urlId: null,\n      trunkId: null,\n      type: null,\n      forks: [],\n    }, {\n      id: appointmentsDocId,\n      name: \"Appointments\",\n      access: \"viewers\",\n      isPinned: true,\n      urlId: null,\n      trunkId: null,\n      type: null,\n      forks: [],\n    }]);\n    // Pin a doc that is already pinned and assert that it returns 200.\n    const resp4 = await axios.patch(`${homeUrl}/api/docs/${appointmentsDocId}/pin`, {}, chimpy);\n    assert.equal(resp4.status, 200);\n  });\n\n  it(\"PATCH /api/docs/:did/pin returns 404 appropriately\", async function() {\n    // Attempt to pin a doc that doesn't exist.\n    const resp1 = await axios.patch(`${homeUrl}/api/docs/9999/pin`, {}, charon);\n    assert.equal(resp1.status, 404);\n  });\n\n  it(\"PATCH /api/docs/:did/pin returns 403 appropriately\", async function() {\n    const antarticDocId = await dbManager.testGetId(\"Antartic\");\n    const sharkDocId = await dbManager.testGetId(\"Shark\");\n\n    // Attempt to pin a doc with only view access (should fail).\n    const resp1 = await axios.patch(`${homeUrl}/api/docs/${antarticDocId}/pin`, {}, chimpy);\n    assert.equal(resp1.status, 403);\n\n    // Attempt to pin a doc with org edit access but no doc access (should succeed).\n    const delta1 = { maxInheritedRole: \"viewers\" };\n    const setupResp1 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/access`, { delta: delta1 }, chimpy);\n    assert.equal(setupResp1.status, 200);\n    // Check that access to shark is as expected.\n    const setupResp2 = await axios.get(`${homeUrl}/api/docs/${sharkDocId}/access`, chimpy);\n    assert.equal(setupResp2.status, 200);\n    assert.deepEqual(setupResp2.data, {\n      maxInheritedRole: \"viewers\",\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",  // Chimpy's access is explicit since he is the owner who set access.\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: null,\n        parentAccess: \"editors\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: null,\n        parentAccess: \"viewers\",\n        isMember: true,\n      }],\n    });\n    // Perform the pin.\n    const resp2 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/pin`, {}, kiwi);\n    assert.equal(resp2.status, 200);\n    // And unpin and restore access to keep the state consistent.\n    const setupResp3 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/unpin`, {}, kiwi);\n    assert.equal(setupResp3.status, 200);\n\n    // Attempt to pin a doc with viewer org access but edit doc access (should fail).\n    const delta2 = {\n      maxInheritedRole: \"owners\",\n      users: {\n        [charonEmail]: \"editors\",\n      },\n    };\n    const setupResp4 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/access`, { delta: delta2 }, chimpy);\n    assert.equal(setupResp4.status, 200);\n    // Check that access to shark is as expected.\n    const setupResp5 = await axios.get(`${homeUrl}/api/docs/${sharkDocId}/access`, chimpy);\n    assert.equal(setupResp5.status, 200);\n    assert.deepEqual(setupResp5.data, {\n      maxInheritedRole: \"owners\",\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: null,\n        parentAccess: \"editors\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"editors\",\n        parentAccess: \"viewers\",\n        isMember: true,\n      }],\n    });\n    // Attempt the pin.\n    const resp3 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/pin`, {}, charon);\n    assert.equal(resp3.status, 403);\n    // Restore access to keep the state consistent.\n    const delta4 = {\n      users: {\n        [charonEmail]: null,\n      },\n    };\n    const setupResp6 = await axios.patch(`${homeUrl}/api/docs/${sharkDocId}/access`, { delta: delta4 }, chimpy);\n    assert.equal(setupResp6.status, 200);\n  });\n\n  it(\"PATCH /api/docs/:did/unpin is operational\", async function() {\n    const chimpylandOrgId = await dbManager.testGetId(\"Chimpyland\");\n    const plutoDocId = await dbManager.testGetId(\"Pluto\");\n    const timesheetsDocId = await dbManager.testGetId(\"Timesheets\");\n    const appointmentsDocId = await dbManager.testGetId(\"Appointments\");\n\n    // Unpin 3 previously pinned docs.\n    const resp1 = await axios.patch(`${homeUrl}/api/docs/${plutoDocId}/unpin`, {}, chimpy);\n    assert.equal(resp1.status, 200);\n    const resp2 = await axios.patch(`${homeUrl}/api/docs/${timesheetsDocId}/unpin`, {}, chimpy);\n    assert.equal(resp2.status, 200);\n    const resp3 = await axios.patch(`${homeUrl}/api/docs/${appointmentsDocId}/unpin`, {}, chimpy);\n    assert.equal(resp3.status, 200);\n\n    // Fetch pinned docs to ensure the docs are no longer pinned.\n    const fetchResp1 = await axios.get(`${homeUrl}/api/orgs/${chimpylandOrgId}/workspaces`, charon);\n    assert.equal(fetchResp1.status, 200);\n    const privateWs = fetchResp1.data.find((ws: any) => ws.name === \"Private\");\n    assert.deepEqual(privateWs.docs.map((doc: any) => omit(doc, \"createdAt\", \"updatedAt\")), [{\n      id: timesheetsDocId,\n      name: \"Timesheets\",\n      access: \"viewers\",\n      isPinned: false,\n      urlId: null,\n      trunkId: null,\n      type: null,\n      forks: [],\n    }, {\n      id: appointmentsDocId,\n      name: \"Appointments\",\n      access: \"viewers\",\n      isPinned: false,\n      urlId: null,\n      trunkId: null,\n      type: null,\n      forks: [],\n    }]);\n    // Unpin doc that is already not pinned and assert that it returns 200.\n    const resp4 = await axios.patch(`${homeUrl}/api/docs/${plutoDocId}/unpin`, {}, chimpy);\n    assert.equal(resp4.status, 200);\n  });\n\n  it(\"PATCH /api/docs/:did/unpin returns 404 appropriately\", async function() {\n    // Attempt to unpin a doc that doesn't exist.\n    const resp1 = await axios.patch(`${homeUrl}/api/docs/9999/unpin`, {}, charon);\n    assert.equal(resp1.status, 404);\n  });\n\n  it(\"PATCH /api/docs/:did/unpin returns 403 appropriately\", async function() {\n    const antarticDocId = await dbManager.testGetId(\"Antartic\");\n    // Attempt to pin a doc with only view access (should fail).\n    const resp1 = await axios.patch(`${homeUrl}/api/docs/${antarticDocId}/pin`, {}, chimpy);\n    assert.equal(resp1.status, 403);\n  });\n\n  it(\"GET /api/profile/user returns user info\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/profile/user`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, {\n      id: 1,\n      email: chimpyEmail,\n      ref: chimpyRef,\n      name: \"Chimpy\",\n      picture: null,\n      allowGoogleLogin: true,\n    });\n  });\n\n  it(\"GET /api/profile/user can return anonymous user\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/profile/user`, nobody);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.email, \"anon@getgrist.com\");\n    assert.equal(resp.data.anonymous, true);\n  });\n\n  it(\"POST /api/profile/user/name updates user' name\", async function() {\n    let resp: AxiosResponse<any>;\n    async function getName(config: AxiosRequestConfig = chimpy) {\n      return (await axios.get(`${homeUrl}/api/profile/user`, config)).data.name;\n    }\n\n    // name should 'Chimpy' initially\n    assert.equal(await getName(), \"Chimpy\");\n\n    // let's change it\n    resp = await axios.post(`${homeUrl}/api/profile/user/name`, { name: \"babaganoush\" }, chimpy);\n    assert.equal(resp.status, 200);\n\n    // check\n    assert.equal(await getName(), \"babaganoush\");\n\n    // revert to 'Chimpy'\n    resp = await axios.post(`${homeUrl}/api/profile/user/name`, { name: \"Chimpy\" }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(await getName(), \"Chimpy\");\n\n    // requests treated as bad unless it has name provided\n    resp = await axios.post(`${homeUrl}/api/profile/user/name`, null, chimpy);\n    assert.equal(resp.status, 400);\n    assert.match(resp.data.error, /name expected/i);\n\n    // anonymous user not allowed to set name\n    resp = await axios.post(`${homeUrl}/api/profile/user/name`, { name: \"Testy\" }, nobody);\n    assert.equal(resp.status, 401);\n    assert.match(resp.data.error, /not authorized/i);\n    assert.equal(await getName(nobody), \"Anonymous\");\n  });\n\n  it(\"POST /api/profile/allowGoogleLogin updates Google login preference\", async function() {\n    let loginCookie: AxiosRequestConfig = await server.getCookieLogin(\"nasa\", {\n      email: \"chimpy@getgrist.com\",\n      name: \"Chimpy\",\n      loginMethod: \"Email + Password\",\n    });\n\n    async function isGoogleLoginAllowed() {\n      return (await axios.get(`${homeUrl}/api/profile/user`, loginCookie)).data.allowGoogleLogin;\n    }\n\n    // Should be set to true by default.\n    assert(await isGoogleLoginAllowed());\n\n    // Setting it via an email/password session should work.\n    let resp = await axios.post(`${homeUrl}/api/profile/allowGoogleLogin`, { allowGoogleLogin: false }, loginCookie);\n    assert.equal(resp.status, 200);\n    assert.equal(await isGoogleLoginAllowed(), false);\n\n    // Setting it without the body param should fail.\n    resp = await axios.post(`${homeUrl}/api/profile/allowGoogleLogin`, null, loginCookie);\n    assert.equal(resp.status, 400);\n    assert.equal(resp.data.error, \"Missing body param: allowGoogleLogin\");\n\n    // Setting it via API or a Google login session should fail.\n    loginCookie = chimpy;\n    resp = await axios.post(`${homeUrl}/api/profile/allowGoogleLogin`, { allowGoogleLogin: false }, loginCookie);\n    assert.equal(resp.status, 401);\n    assert.equal(resp.data.error, \"Only users signed in via email can enable/disable Google login\");\n    assert.equal(await isGoogleLoginAllowed(), false);\n\n    loginCookie = await server.getCookieLogin(\"nasa\", {\n      email: \"chimpy@getgrist.com\",\n      name: \"Chimpy\",\n      loginMethod: \"Google\",\n    });\n    resp = await axios.post(`${homeUrl}/api/profile/allowGoogleLogin`, { allowGoogleLogin: false }, loginCookie);\n    assert.equal(resp.status, 401);\n    assert.equal(resp.data.error, \"Only users signed in via email can enable/disable Google login\");\n    assert.equal(await isGoogleLoginAllowed(), false);\n\n    // Setting it as an anonymous user should fail.\n    resp = await axios.post(`${homeUrl}/api/profile/allowGoogleLogin`, { allowGoogleLogin: false }, nobody);\n    assert.equal(resp.status, 401);\n    assert.match(resp.data.error, /not authorized/i);\n  });\n\n  it(\"DELETE /api/user/:uid can delete a user\", async function() {\n    const countsBefore = await getRowCounts();\n\n    // create a new user\n    const profile = { email: \"meep@getgrist.com\", name: \"Meep\" };\n    const user = await dbManager.getUserByLogin(\"meep@getgrist.com\", { profile });\n    const userId = user.id;\n    // set up an api key\n    await dbManager.connection.query(\"update users set api_key = 'api_key_for_meep' where id = $1\", [userId]);\n\n    // make sure we have at least one extra user, a login, an org, a group_user entry,\n    // a billing account, a billing account manager.\n    const countsWithMeep = await getRowCounts();\n    assert.isAbove(countsWithMeep.users, countsBefore.users);\n    assert.isAbove(countsWithMeep.logins, countsBefore.logins);\n    assert.isAbove(countsWithMeep.orgs, countsBefore.orgs);\n    assert.isAbove(countsWithMeep.groupUsers, countsBefore.groupUsers);\n    assert.isAbove(countsWithMeep.billingAccounts, countsBefore.billingAccounts);\n    assert.isAbove(countsWithMeep.billingAccountManagers, countsBefore.billingAccountManagers);\n\n    // requests treated as bad unless it has name provided\n    let resp = await axios.delete(`${homeUrl}/api/users/${userId}`, configForUser(\"meep\"));\n    assert.equal(resp.status, 400);\n    assert.match(resp.data.error, /provide their name/);\n\n    // others cannot delete this user\n    resp = await axios.delete(`${homeUrl}/api/users/${userId}`,\n      { data: { name: \"Meep\" }, ...configForUser(\"chimpy\") });\n    assert.equal(resp.status, 403);\n    assert.match(resp.data.error, /not permitted/);\n\n    // user cannot delete themselves if they get their name wrong\n    resp = await axios.delete(`${homeUrl}/api/users/${userId}`,\n      { data: { name: \"Moop\" }, ...configForUser(\"meep\") });\n    assert.equal(resp.status, 400);\n    assert.match(resp.data.error, /user name did not match/);\n\n    // user can delete themselves if they get the name right\n    resp = await axios.delete(`${homeUrl}/api/users/${userId}`,\n      { data: { name: \"Meep\" }, ...configForUser(\"meep\") });\n    assert.equal(resp.status, 200);\n\n    // create a user with a blank name\n    const userBlank = await dbManager.getUserByLogin(\"blank@getgrist.com\",\n      { profile: { email: \"blank@getgrist.com\",\n        name: \"\" } });\n    await dbManager.connection.query(\"update users set api_key = 'api_key_for_blank' where id = $1\", [userBlank.id]);\n\n    // check that user can delete themselves\n    resp = await axios.delete(`${homeUrl}/api/users/${userBlank.id}`,\n      { data: { name: \"\" }, ...configForUser(\"blank\") });\n    assert.equal(resp.status, 200);\n\n    const countsAfter = await getRowCounts();\n    assert.deepEqual(countsAfter, countsBefore);\n  });\n\n  describe(\"Service Accounts\", function() {\n    afterEach(async () => {\n      await dbManager.testDeleteAllServiceAccounts();\n    });\n\n    const SERVICE_ACCOUNT_BODY = {\n      label: \"A small service for the chimpy\",\n      description: \"A big service for robotkind\",\n      expiresAt: \"2042-07-21\",\n    };\n\n    async function createServiceAccount(body = SERVICE_ACCOUNT_BODY) {\n      const resp = await axios.post(`${homeUrl}/api/service-accounts/`, body, chimpy);\n      assert.equal(resp.status, 200);\n      return resp.data as ServiceAccountCreationResponse;\n    }\n\n    function requestConfigWithKey(key: string | undefined) {\n      assert.isDefined(key);\n      return configForApiKey(key);\n    }\n\n    function checkServiceAccount(\n      response: any,\n      knownProperties: PostServiceAccount,\n      options: { expectKey?: boolean } = {}) {\n      assert.deepEqual(response, {\n        id: response.id,\n        login: response.login,\n        hasValidKey: true,\n        ...(options.expectKey ? { key: response.key } : null),\n        ...knownProperties,\n      });\n    }\n\n    function bodyToExpectedProperties(body: PostServiceAccount) {\n      const expectedProperties = { ...body };\n      expectedProperties.expiresAt += \"T00:00:00.000Z\";\n      return expectedProperties;\n    }\n\n    function checkCommonErrors(\n      makeRequest: (saId: number, user: AxiosRequestConfig<any>) => Promise<AxiosResponse>,\n    ) {\n      it(\"returns 404 on non-existing {saId}\", async function() {\n        const resp = await makeRequest(0, chimpy);\n        assert.equal(resp.status, 404);\n      });\n\n      it(\"returns 403 for non-owned service accounts {saId}\", async function() {\n        const { id: serviceId } = await createServiceAccount();\n        const resp = await makeRequest(serviceId, kiwi);\n        assert.equal(resp.status, 403);\n        assert.match(resp.data.error, /non-owned/);\n      });\n\n      it(\"is rejected when requested by an anonymous user\", async function() {\n        const { id: serviceId } = await createServiceAccount();\n        const resp = await makeRequest(serviceId, nobody);\n        assert.equal(resp.status, 401);\n      });\n    }\n\n    describe(\"Endpoint POST /api/service-accounts\", function() {\n      it(\"is operational\", async function() {\n        const data = await createServiceAccount();\n\n        const knownProperties = {\n          label: SERVICE_ACCOUNT_BODY.label,\n          description: SERVICE_ACCOUNT_BODY.description,\n          expiresAt: new Date(SERVICE_ACCOUNT_BODY.expiresAt).toISOString(),\n          hasValidKey: true,\n        };\n        checkServiceAccount(data, knownProperties, { expectKey: true });\n      });\n\n      it(\"is rejected when requested by a service account\", async function() {\n        const { key: bearer } = await createServiceAccount();\n        const service = requestConfigWithKey(bearer);\n        const resp = await axios.post(`${homeUrl}/api/service-accounts/`, SERVICE_ACCOUNT_BODY, service);\n        assert.equal(resp.status, 403);\n        assert.match(resp.data.error, /Only regular users/);\n      });\n\n      it(\"is rejected when requested by an anonymous user\", async function() {\n        const resp = await axios.post(`${homeUrl}/api/service-accounts/`, SERVICE_ACCOUNT_BODY, nobody);\n        assert.equal(resp.status, 401);\n      });\n\n      it(\"returns 400 when passing invalid expiresAt\", async function() {\n        const body = {\n          expiresAt: \"tutu\",\n        };\n        let resp = await axios.post(`${homeUrl}/api/service-accounts/`, body, chimpy);\n        assert.equal(resp.status, 400);\n        const emptyBody = {};\n        resp = await axios.post(`${homeUrl}/api/service-accounts/`, emptyBody, chimpy);\n        assert.equal(resp.status, 400);\n      });\n    });\n\n    describe(\"Endpoint GET /api/service-accounts\", function() {\n      it(\"is operational\", async function() {\n        const body1 = SERVICE_ACCOUNT_BODY;\n        const body2 = {\n          label: \"More service\",\n          description: \"More robots\",\n          expiresAt: \"2042-07-22\",\n        };\n        await createServiceAccount(body1);\n        await createServiceAccount(body2);\n        const resp = await axios.get(`${homeUrl}/api/service-accounts/`, chimpy);\n        assert.equal(resp.status, 200);\n        const expectedProperties1 = bodyToExpectedProperties(body1);\n        const expectedProperties2 = bodyToExpectedProperties(body2);\n        const expectedProperties = [expectedProperties1, expectedProperties2];\n\n        assert.isArray(resp.data);\n        assert.lengthOf(resp.data, 2);\n        resp.data.forEach((service: ServiceAccountApiResponse, i: number) => {\n          checkServiceAccount(service, expectedProperties[i], { expectKey: false });\n        });\n      });\n\n      it(\"returns 200 and a empty list when there is no service account\", async function() {\n        const resp = await axios.get(`${homeUrl}/api/service-accounts`, kiwi);\n        assert.equal(resp.status, 200);\n        assert.isArray(resp.data);\n        assert.isEmpty(resp.data);\n      });\n\n      it(\"returns 401 when user is anonymous\", async function() {\n        const resp = await axios.get(`${homeUrl}/api/service-accounts`, nobody);\n        assert.equal(resp.status, 401);\n      });\n    });\n\n    describe(\"Endpoint GET /api/service-accounts/{saId}\", function() {\n      it(\"is operational\", async function() {\n        const { id: serviceId, login } = await createServiceAccount();\n        const expectedBody = {\n          ...SERVICE_ACCOUNT_BODY,\n          login,\n          id: serviceId,\n          expiresAt: `${SERVICE_ACCOUNT_BODY.expiresAt}T00:00:00.000Z`,\n          hasValidKey: true,\n        };\n        const resp = await axios.get(`${homeUrl}/api/service-accounts/${serviceId}`, chimpy);\n        assert.equal(resp.status, 200);\n        assert.isObject(resp.data);\n        assert.deepEqual(resp.data, expectedBody);\n      });\n\n      checkCommonErrors((saId, user) => axios.get(`${homeUrl}/api/service-accounts/${saId}`, user));\n    });\n\n    describe(\"Endpoint PATCH /api/service-accounts/{saId}\", function() {\n      it(\"is operational\", async function() {\n        const newDescription = \"to an end\";\n        const { id: serviceId, login } = await createServiceAccount();\n\n        const patch = {\n          description: newDescription,\n        };\n        const resp2 = await axios.patch(`${homeUrl}/api/service-accounts/${serviceId}`, patch, chimpy);\n        assert.equal(resp2.status, 200);\n        assert.isNull(resp2.data);\n\n        const resp3 = await axios.get(`${homeUrl}/api/service-accounts/${serviceId}`, chimpy);\n        const expectedBody = {\n          ...SERVICE_ACCOUNT_BODY,\n          id: serviceId,\n          login,\n          description: newDescription,\n          expiresAt: `${SERVICE_ACCOUNT_BODY.expiresAt}T00:00:00.000Z`,\n          hasValidKey: true,\n        };\n        assert.deepEqual(resp3.data, expectedBody);\n      });\n\n      it(\"returns 400 on invalid label\", async function() {\n        const { id: serviceId } = await createServiceAccount();\n        const patch = {\n          label: null,\n        };\n        const resp = await axios.patch(`${homeUrl}/api/service-accounts/${serviceId}`, patch, chimpy);\n        assert.equal(resp.status, 400);\n      });\n\n      it(\"returns 400 on invalid expiresAt\", async function() {\n        const { id: serviceId } = await createServiceAccount();\n        const patch = {\n          expiresAt: \"something\",\n        };\n        const resp = await axios.patch(`${homeUrl}/api/service-accounts/${serviceId}`, patch, chimpy);\n        assert.equal(resp.status, 400);\n      });\n\n      it(\"returns 400 if trying to update the owner or the service account\", async function() {\n        const { id: serviceId } = await createServiceAccount();\n        const patch = {\n          ownerId: 1,\n          owner_id: 1,\n          serviceOwnerId: \"something\",\n        };\n        const resp = await axios.patch(`${homeUrl}/api/service-accounts/${serviceId}`, patch, chimpy);\n        assert.equal(resp.status, 400);\n        for (const key in patch) {\n          assert.include(resp.data.details.userError, `${key} is extraneous`);\n        }\n      });\n\n      checkCommonErrors((saId, user) =>\n        axios.patch(`${homeUrl}/api/service-accounts/${saId}`, { description: \"description\" }, user),\n      );\n    });\n\n    describe(\"Endpoint DELETE /api/service-accounts/{saId}\", function() {\n      it(\"deletes the service account and only soft-delete the associated user\", async function() {\n        const body = {\n          label: \"Short life service\",\n          description: \"Doomed soon\",\n          expiresAt: \"2042-10-10\",\n        };\n        const { id: serviceId } = await createServiceAccount(body);\n        const resp2 = await axios.get(`${homeUrl}/api/service-accounts/${serviceId}`, chimpy);\n        assert.equal(resp2.status, 200);\n        const resp3 = await axios.delete(`${homeUrl}/api/service-accounts/${serviceId}`, chimpy);\n        assert.equal(resp3.status, 200);\n        assert.isNull(resp3.data);\n        const resp4 = await axios.get(`${homeUrl}/api/service-accounts/${serviceId}`, chimpy);\n        assert.equal(resp4.status, 404);\n      });\n\n      it(\"performs a soft-delete of the associated user\", async function() {\n        const { id: serviceId } = await createServiceAccount(SERVICE_ACCOUNT_BODY);\n        const serviceAccount = await dbManager.getServiceAccount(serviceId);\n        const resp = await axios.delete(`${homeUrl}/api/service-accounts/${serviceId}`, chimpy);\n        assert.equal(resp.status, 200);\n\n        const serviceUser = await dbManager.getUser(serviceAccount!.serviceUser.id);\n        assert.isNotEmpty(serviceUser);\n        assert.isNotNull(serviceUser?.disabledAt);\n      });\n\n      checkCommonErrors((saId, user) =>\n        axios.delete(`${homeUrl}/api/service-accounts/${saId}`, user),\n      );\n    });\n\n    describe(\"Endpoint POST /api/service-accounts/{saId}/apikey\", function() {\n      it(\"is operational\", async function() {\n        const body = {\n          label: \"Short life service\",\n          description: \"Doomed soon\",\n          expiresAt: \"2042-10-10\",\n        };\n        const { id: serviceId, key: apiKeyBefore } = await createServiceAccount(body);\n\n        const resp = await axios.post(`${homeUrl}/api/service-accounts/${serviceId}/apikey`, {}, chimpy);\n        const apiKeyAfter = resp.data.key;\n        assert.equal(resp.status, 200);\n        const expectedProperties = bodyToExpectedProperties(body);\n        checkServiceAccount(resp.data, expectedProperties, { expectKey: true });\n        assert.isNotEmpty(apiKeyAfter);\n        assert.notEqual(apiKeyBefore, apiKeyAfter);\n      });\n\n      checkCommonErrors((saId, user) =>\n        axios.post(`${homeUrl}/api/service-accounts/${saId}/apikey`, {}, user),\n      );\n    });\n\n    describe(\"Endpoint DELETE /api/service-accounts/{saId}/apikey\", function() {\n      it(\"is operational\", async function() {\n        const body = {\n          label: \"Short life service\",\n          description: \"Doomed soon\",\n          expiresAt: \"2042-10-10\",\n        };\n        const { id: serviceId, login } = await createServiceAccount(body);\n        const expectedBody = {\n          ...body,\n          id: serviceId,\n          login,\n          expiresAt: `${body.expiresAt}T00:00:00.000Z`,\n          hasValidKey: false,\n        };\n\n        const revokeAccess = await axios.delete(`${homeUrl}/api/service-accounts/${serviceId}/apikey`, chimpy);\n        assert.equal(revokeAccess.status, 200);\n        assert.isNull(revokeAccess.data);\n\n        const serviceAccountInfo = await axios.get(`${homeUrl}/api/service-accounts/${serviceId}`, chimpy);\n        assert.equal(serviceAccountInfo.status, 200);\n        assert.deepEqual(serviceAccountInfo.data, expectedBody);\n      });\n\n      checkCommonErrors((saId, user) =>\n        axios.delete(`${homeUrl}/api/service-accounts/${saId}/apikey`, user),\n      );\n    });\n\n    describe(\"Authentication\", function() {\n      async function setupServiceAccountWithAccessTo(orgName: string, creationBody = SERVICE_ACCOUNT_BODY) {\n        const oid = await dbManager.testGetId(orgName);\n\n        const { id: serviceId, key, login: serviceUserLogin } = await createServiceAccount(creationBody);\n        const serviceAccountReqConfig = requestConfigWithKey(key);\n\n        const checkChimpyAccess = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, chimpy);\n        assert.equal(checkChimpyAccess.status, 200, `chimpy should list ${orgName} workspaces`);\n\n        const accessBefore = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, serviceAccountReqConfig);\n        assert.equal(accessBefore.status, 403,\n          `Initially the service account should not get access to workspaces of ${orgName}`);\n\n        const delta = {\n          delta: {\n            users: {\n              [serviceUserLogin]: \"owners\",\n            },\n          },\n        };\n\n        const grantAccess = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, delta, chimpy);\n        assert.equal(grantAccess.status, 200, `Chimpy should add service account to ${orgName} org`);\n        return { oid, id: serviceId, serviceAccountReqConfig };\n      }\n\n      // Service account can be added to a document then\n      // do some action via api\n      it(\"with valid key and in its lifetime should access to resources it is added to\", async function() {\n        const { oid, serviceAccountReqConfig } = await setupServiceAccountWithAccessTo(\"NASA\");\n        const nasaOrgInfo = await axios.get(`${homeUrl}/api/orgs/${oid}`, serviceAccountReqConfig);\n        assert.equal(nasaOrgInfo.status, 200, \"Service Account should retrieve NASA org info\");\n      });\n\n      it(\"with revoked key should fail to access resource it is added to\", async function() {\n        const { oid, id: serviceId, serviceAccountReqConfig } = await setupServiceAccountWithAccessTo(\"NASA\");\n\n        await axios.delete(`${homeUrl}/api/service-accounts/${serviceId}/apikey`, chimpy);\n\n        const accessOfServiceAccountAfter = await axios.get(`${homeUrl}/api/orgs/${oid}`, serviceAccountReqConfig);\n        assert.equal(accessOfServiceAccountAfter.status, 401,\n          \"Service Account should not be granted access to NASA org\");\n        assert.match(accessOfServiceAccountAfter.data, /invalid API key/);\n      });\n\n      // outdated service account can't do api calls\n      it(\"with outdated expiresAt should fail to access resource it is added to\", async function() {\n        const body = {\n          label: \"A small service for the chimpy\",\n          description: \"A big service for robotkind\",\n          expiresAt: new Date(0).toISOString(),\n        };\n        const { key } = await createServiceAccount(body);\n        const serviceAccountConfig = requestConfigWithKey(key);\n        const oid = await dbManager.testGetId(\"NASA\");\n\n        // Let's jump directly to the check without adding the service account to the org members.\n        // The error is checked below in any case.\n        const accessToNasa = await axios.get(`${homeUrl}/api/orgs/${oid}`, serviceAccountConfig);\n        assert.equal(accessToNasa.status, 401);\n        assert.equal(accessToNasa.data, \"Service Account has expired\");\n      });\n\n      describe(\"On owner disabled\", function() {\n        it(\"Should be disabled as well\", async function() {\n          const chimpyUser = await dbManager.getUserByLogin(chimpyEmail);\n          const chimpyId = chimpyUser.id;\n\n          try {\n            const { serviceAccountReqConfig, oid } = await setupServiceAccountWithAccessTo(\"NASA\");\n            const accessToOrgBeforeBan = await axios.get(`${homeUrl}/api/orgs/${oid}`, serviceAccountReqConfig);\n            assert.equal(accessToOrgBeforeBan.status, 200, \"Service Account should list NASA org\");\n\n            // Ham bans chimpy, the owner of the service account\n            const ban = await axios.post(`${homeUrl}/api/users/${chimpyId}/disable`, { name: \"Ham\" }, ham);\n            assert.equal(ban.status, 200);\n\n            // Now its service account should no longer have access to resources\n            const accessToOrgAfterBan = await axios.get(`${homeUrl}/api/orgs/${oid}`, serviceAccountReqConfig);\n            assert.equal(accessToOrgAfterBan.status, 403, \"Service Account should no longer list NASA org\");\n          } finally {\n            // Unban chimpy so the next tests work\n            await axios.post(`${homeUrl}/api/users/${chimpyId}/enable`, { name: \"Ham\" }, ham);\n          }\n        });\n      });\n    });\n  });\n\n  describe(\"GET /api/orgs/{oid}/usage\", function() {\n    let freeTeamOrgId: number;\n    let freeTeamWorkspaceId: number;\n\n    async function assertOrgUsage(\n      orgId: string | number,\n      user: AxiosRequestConfig,\n      expected: OrgUsageSummary | \"denied\",\n    ) {\n      const resp = await axios.get(`${homeUrl}/api/orgs/${orgId}/usage`, user);\n      if (expected === \"denied\") {\n        assert.equal(resp.status, 403);\n        assert.deepEqual(resp.data, { error: \"access denied\" });\n      } else {\n        assert.equal(resp.status, 200);\n        assert.deepEqual(resp.data, expected);\n      }\n    }\n\n    before(async () => {\n      // Set up a free team site for testing usage. Avoid using billing endpoints,\n      // which may not be available in all test environments.\n      await axios.post(`${homeUrl}/api/orgs`, {\n        name: \"best-friends-squad\",\n        domain: \"best-friends-squad\",\n      }, chimpy);\n      freeTeamOrgId = await dbManager.testGetId(\"best-friends-squad\") as number;\n      const prevAccount = await dbManager.getBillingAccount(\n        { userId: dbManager.getPreviewerUserId() },\n        \"best-friends-squad\", false);\n      await dbManager.connection.query(\n        \"update billing_accounts set product_id = (select id from products where name = $1) where id = $2\",\n        [TEAM_FREE_PLAN, prevAccount.id],\n      );\n\n      const resp = await axios.post(`${homeUrl}/api/orgs/${freeTeamOrgId}/workspaces`, {\n        name: \"TestUsage\",\n      }, chimpy);\n      freeTeamWorkspaceId = resp.data;\n    });\n\n    after(async () => {\n      // Remove the free team site.\n      await axios.delete(`${homeUrl}/api/orgs/${freeTeamOrgId}`, chimpy);\n    });\n\n    it(\"is operational\", async function() {\n      await assertOrgUsage(freeTeamOrgId, chimpy, createEmptyOrgUsageSummary());\n\n      const nasaOrgId = await dbManager.testGetId(\"NASA\");\n      await assertOrgUsage(nasaOrgId, chimpy, createEmptyOrgUsageSummary());\n    });\n\n    it(\"requires owners access\", async function() {\n      await assertOrgUsage(freeTeamOrgId, kiwi, \"denied\");\n\n      const kiwilandOrgId = await dbManager.testGetId(\"Kiwiland\");\n      await assertOrgUsage(kiwilandOrgId, kiwi, createEmptyOrgUsageSummary());\n    });\n\n    it(\"fails if user is anon\", async function() {\n      await assertOrgUsage(freeTeamOrgId, nobody, \"denied\");\n\n      const primatelyOrgId = await dbManager.testGetId(\"Primately\");\n      await assertOrgUsage(primatelyOrgId, nobody, \"denied\");\n    });\n\n    it(\"reports count of docs approaching/exceeding limits\", async function() {\n      // Add a handful of documents to the TestUsage workspace.\n      const promises = [];\n      for (const name of [\"GoodStanding\", \"ApproachingLimits\", \"GracePeriod\", \"DeleteOnly\"]) {\n        promises.push(axios.post(`${homeUrl}/api/workspaces/${freeTeamWorkspaceId}/docs`, { name }, chimpy));\n      }\n      const docIds: string[] = (await Promise.all(promises)).map(resp => resp.data);\n\n      // Prepare one of each usage type.\n      const goodStanding = { rowCount: { total: 100 }, dataSizeBytes: 1024, attachmentsSizeBytes: 4096 };\n      const approachingLimits = {\n        rowCount: { total: 4501 }, dataSizeBytes: 4501 * 2 * 1024, attachmentsSizeBytes: 4096,\n      };\n      const gracePeriod = { rowCount: { total: 5001 }, dataSizeBytes: 5001 * 2 * 1024, attachmentsSizeBytes: 4096 };\n      const deleteOnly = gracePeriod;\n\n      // Set usage for each document. (This is normally done by ActiveDoc, but we\n      // facilitate here to keep the tests simple.)\n      const docUsage = [goodStanding, approachingLimits, gracePeriod, deleteOnly];\n      const idsAndUsage = docIds.map((id, i) => [id, docUsage[i]] as const);\n      for (const [id, usage] of idsAndUsage) {\n        await server.dbManager.setDocsMetadata({ [id]: { usage } });\n      }\n      await server.dbManager.setDocGracePeriodStart(docIds[docIds.length - 1], new Date(2000, 1, 1));\n      await server.dbManager.setDocGracePeriodStart(docIds[docIds.length - 2], new Date());\n\n      // Check that what's reported by /usage is accurate.\n      await assertOrgUsage(freeTeamOrgId, chimpy, {\n        countsByDataLimitStatus: {\n          approachingLimit: 1,\n          gracePeriod: 1,\n          deleteOnly: 1,\n        },\n        attachments: {\n          totalBytes: 16384,\n        },\n      });\n    });\n\n    it(\"only counts documents from org in path\", async function() {\n      // Check NASA's usage once more, and make sure everything is still 0. This test is mostly\n      // a sanity check that results are in fact scoped by org.\n      const nasaOrgId = await dbManager.testGetId(\"NASA\");\n      await assertOrgUsage(nasaOrgId, chimpy, createEmptyOrgUsageSummary());\n    });\n\n    it(\"excludes soft-deleted documents from count\", async function() {\n      // Add another document that's exceeding limits.\n      const docId: string = (await axios.post(`${homeUrl}/api/workspaces/${freeTeamWorkspaceId}/docs`, {\n        name: \"SoftDeleted\",\n      }, chimpy)).data;\n      await server.dbManager.setDocsMetadata({ [docId]: { usage: {\n        rowCount: { total: 9999 },\n        dataSizeBytes: 999999999,\n        attachmentsSizeBytes: 999999999,\n      } } });\n      await server.dbManager.setDocGracePeriodStart(docId, new Date());\n\n      // Check that /usage includes that document in the count.\n      await assertOrgUsage(freeTeamOrgId, chimpy, {\n        countsByDataLimitStatus: {\n          approachingLimit: 1,\n          gracePeriod: 2,\n          deleteOnly: 1,\n        },\n        attachments: {\n          totalBytes: 1000016383,\n        },\n      });\n\n      // Now soft-delete the newly added document; make sure /usage no longer counts it.\n      await axios.post(`${homeUrl}/api/docs/${docId}/remove`, {}, chimpy);\n      await assertOrgUsage(freeTeamOrgId, chimpy, {\n        countsByDataLimitStatus: {\n          approachingLimit: 1,\n          gracePeriod: 1,\n          deleteOnly: 1,\n        },\n        attachments: {\n          totalBytes: 16384,\n        },\n      });\n    });\n\n    it(\"excludes soft-deleted workspaces from count\", async function() {\n      // Remove the workspace containing all docs.\n      await axios.post(`${homeUrl}/api/workspaces/${freeTeamWorkspaceId}/remove`, {}, chimpy);\n\n      // Check that /usage now reports a count of zero for all status types.\n      await assertOrgUsage(freeTeamOrgId, chimpy, createEmptyOrgUsageSummary());\n    });\n  });\n\n  // Template test moved to the end, since it deletes an org and makes\n  // predicting ids a little trickier.\n  it(\"GET /api/templates is operational\", async function() {\n    let oid;\n\n    try {\n      // Add a 'Grist Templates' org.\n      await axios.post(`${homeUrl}/api/orgs`, {\n        name: \"Grist Templates\",\n        domain: \"templates\",\n      }, support);\n      oid = await dbManager.testGetId(\"Grist Templates\");\n      // Add some workspaces and templates (documents) to Grist Templates.\n      const crmWsId = (await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, { name: \"CRM\" }, support)).data;\n      const invoiceWsId = (await axios.post(`${homeUrl}/api/orgs/${oid}/workspaces`, { name: \"Invoice\" }, support)).data;\n      const crmDocId = (await axios.post(`${homeUrl}/api/workspaces/${crmWsId}/docs`,\n        { name: \"Lightweight CRM\", isPinned: true }, support)).data;\n      const reportDocId = (await axios.post(`${homeUrl}/api/workspaces/${invoiceWsId}/docs`,\n        { name: \"Expense Report\" }, support)).data;\n      const timesheetDocId = (await axios.post(`${homeUrl}/api/workspaces/${invoiceWsId}/docs`,\n        { name: \"Timesheet\" }, support)).data;\n      // Make anon@/everyone@ a viewer of the public docs on Grist Templates.\n      for (const id of [crmDocId, reportDocId, timesheetDocId]) {\n        await axios.patch(`${homeUrl}/api/docs/${id}/access`, {\n          delta: { users: { \"anon@getgrist.com\": \"viewers\", \"everyone@getgrist.com\": \"viewers\" } },\n        }, support);\n      }\n\n      // Make a request to retrieve all templates as an anonymous user.\n      const resp = await axios.get(`${homeUrl}/api/templates`, nobody);\n      // Assert that the response contains the right workspaces and template documents.\n      assert.equal(resp.status, 200);\n      assert.lengthOf(resp.data, 2);\n      assert.deepEqual(resp.data.map((ws: any) => ws.name), [\"CRM\", \"Invoice\"]);\n      assert.deepEqual(resp.data[0].docs.map((doc: any) => doc.name), [\"Lightweight CRM\"]);\n      assert.deepEqual(resp.data[1].docs.map((doc: any) => doc.name), [\"Expense Report\", \"Timesheet\"]);\n\n      // Add a new document to the CRM workspace, but don't share it with everyone.\n      await axios.post(`${homeUrl}/api/workspaces/${crmWsId}/docs`,\n        { name: \"Draft CRM Template\", isPinned: true }, support);\n      // Make another request to retrieve all templates as an anonymous user.\n      const resp3 = await axios.get(`${homeUrl}/api/templates`, nobody);\n      // Assert that the response does not include the new document.\n      assert.lengthOf(resp3.data, 2);\n      assert.deepEqual(resp3.data[0].docs.map((doc: any) => doc.name), [\"Lightweight CRM\"]);\n    } finally {\n      // Remove the 'Grist Templates' org.\n      if (oid) {\n        await axios.delete(`${homeUrl}/api/orgs/${oid}/force-delete`, support);\n      }\n    }\n  });\n\n  it(\"GET /api/templates returns 404 appropriately\", async function() {\n    // The 'Grist Templates' org currently doesn't exist.\n    const resp = await axios.get(`${homeUrl}/api/templates`, nobody);\n    // Assert that the response status is 404 because the templates org doesn't exist.\n    assert.equal(resp.status, 404);\n  });\n\n  // Please keep this as the last test. Could go in after(), but\n  // then it is a little harder to tell in logs if it wasn't skipped.\n  describe(\"Prepared Statements\", async function() {\n    it(\"creates prepared statements\", async function() {\n      if (dbManager.connection.driver.options.type !== \"postgres\" ||\n        !isAffirmative(process.env.GRIST_POSTGRES_USE_PREPARED_STATEMENTS)) {\n        this.skip();\n      }\n      // Check that the number of prepared statements looks sane.\n      // Basically, we shouldn't be getting variants for each\n      // user id or something like that, which could happen if the\n      // id is embedded in the query and not passed as a parameter.\n      // There are quite a few variants of similar looking queries\n      // for different situations though, and no doubt this will\n      // grow, so this number going up won't be surprising.\n      const count = testGetPreparedStatementCount();\n      assert.equal(count.usedCount, count.preparedCount);\n      assert.isAbove(count.usedCount, 30);\n      assert.isBelow(count.usedCount, 44); // was 43 as of 2025-09-05 on saas-deployment\n    });\n  });\n});\n\n// Predict the next id that will be used for a table.\n// Only reliable if we haven't been deleting records in that table.\n// Could make reliable by using sqlite_sequence in sqlite and the equivalent\n// in postgres.\nasync function getNextId(dbManager: HomeDBManager, table: \"orgs\" | \"workspaces\") {\n  // Check current top org id.\n  const row = await dbManager.connection.query(`select max(id) as id from ${table}`);\n  const id = row[0].id;\n  return id + 1;\n}\n\nasync function upgradeOrg(dbManager: HomeDBManager, name: string) {\n  // Upgrade this org to a fancier plan, for vanity domain\n  const db = dbManager.connection.manager;\n  const dbOrg = await db.findOne(Organization,\n    { where: { name },\n      relations: [\"billingAccount\", \"billingAccount.product\"] });\n  if (!dbOrg) { throw new Error(`cannot find ${name}`); }\n  const product = await db.findOne(Product, { where: { name: \"team\" } });\n  if (!product) { throw new Error(\"cannot find product\"); }\n  dbOrg.billingAccount.product = product;\n  await dbOrg.billingAccount.save();\n}\n"
  },
  {
    "path": "test/gen-server/ApiServerAccess.ts",
    "content": "import { Role } from \"app/common/roles\";\nimport * as roles from \"app/common/roles\";\nimport { getRealAccess, PermissionData, PermissionDelta, UserAPI } from \"app/common/UserAPI\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Product } from \"app/gen-server/entity/Product\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Deps as HomeDBManagerDeps, HomeDBManager, UserChange } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { SendGridConfig, SendGridMailWithTemplateId } from \"app/gen-server/lib/NotifierTypes\";\nimport { create } from \"app/server/lib/create\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { configForUser, waitForAllNotifications } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios, { AxiosResponse } from \"axios\";\nimport * as chai from \"chai\";\nimport fromPairs from \"lodash/fromPairs\";\nimport pick from \"lodash/pick\";\nimport moment from \"moment\";\nimport * as sinon from \"sinon\";\n\nconst assert = chai.assert;\n\nlet server: TestServer;\nlet dbManager: HomeDBManager;\nlet homeUrl: string;\nlet userCountUpdates: { [orgId: number]: number[] } = {};\nlet lastMail: SendGridMailWithTemplateId | null = null;\nlet lastMailDesc: string | null = null;\nconst sandbox = sinon.createSandbox();\n\nconst chimpy = configForUser(\"Chimpy\");\nconst kiwi = configForUser(\"Kiwi\");\nconst charon = configForUser(\"Charon\");\nconst nobody = configForUser(\"Anonymous\");\n\nconst chimpyEmail = \"chimpy@getgrist.com\";\nconst kiwiEmail = \"kiwi@getgrist.com\";\nconst charonEmail = \"charon@getgrist.com\";\nconst supportEmail = \"support@getgrist.com\";\nconst everyoneEmail = \"everyone@getgrist.com\";\n\nlet chimpyRef = \"\";\nlet kiwiRef = \"\";\nlet charonRef = \"\";\n\n// Test concerns only access-related functions of the ApiServer. Created to help break up the\n// large amount of tests on the ApiServer.\ndescribe(\"ApiServerAccess\", function() {\n  if (process.env.DEBUG) {\n    this.timeout(\"10m\");\n  }\n\n  testUtils.setTmpLogLevel(\"error\");\n\n  let notificationsConfig: SendGridConfig | undefined;\n  before(async function() {\n    server = new TestServer(this);\n    homeUrl = await server.start([\"home\", \"docs\"]);\n    const extensions = server.server.getNotifier().testSendGridExtensions?.();\n    notificationsConfig = extensions?.getConfig();\n    extensions?.setSendMessageCallback(\n      async (payload, desc) => {\n        // Filter for invite emails only - ignore any other categories of email\n        if (desc.includes(\"invite\")) {\n          lastMail = payload;\n          lastMailDesc = desc;\n        }\n      },\n    );\n    if (process.env.GRIST_NOTIFIER !== \"test\") {\n      // If the notifier is explicitly set to 'test', there is no sendGrid extensions configured, and we\n      // won't test notifications here.\n      // TODO: all those ifs should be removed and stubbed with proper test doubles.\n      if ([\"saas\", \"enterprise\"].includes(create.deploymentType())) {\n        assert.exists(notificationsConfig);\n      }\n    }\n    dbManager = server.dbManager;\n    chimpyRef = await dbManager.getUserByLogin(chimpyEmail).then(user => user.ref);\n    kiwiRef = await dbManager.getUserByLogin(kiwiEmail).then(user => user.ref);\n    charonRef = await dbManager.getUserByLogin(charonEmail).then(user => user.ref);\n    // Listen to user count updates and add them to an array.\n    server.server.onUserChange(async ({ org, countBefore, countAfter }: UserChange) => {\n      if (countBefore === countAfter) { return; }\n      userCountUpdates[org.id] = userCountUpdates[org.id] || [];\n      userCountUpdates[org.id].push(countAfter);\n    });\n  });\n\n  afterEach(async function() {\n    userCountUpdates = {};\n    await server.sanityCheck();\n  });\n\n  after(async function() {\n    await server.stop();\n    sandbox.restore();\n  });\n\n  async function getLastMail(maxWait: number = 1000) {\n    await waitForAllNotifications(server.server, maxWait);\n    const result = { payload: lastMail, description: lastMailDesc };\n    lastMailDesc = null;\n    lastMail = null;\n    return result;\n  }\n\n  async function assertLastMail(maxWait: number = 1000) {\n    const { payload, description } = await getLastMail(maxWait);\n    if (payload === null || description === null) {\n      throw new Error(\"no mail available\");\n    }\n    return { payload, description };\n  }\n\n  function assertResult(resp: AxiosResponse, status: number, errMessage: string) {\n    assert.equal(resp.status, status);\n    assert.equal(resp.data?.error, errMessage);\n  }\n\n  async function checkAccessChange(\n    resource:\n      | { orgId: string | number } |\n      { wsId: string | number } |\n      { docId: string | number },\n    accessByEmail: Record<string, Role | null>,\n    expected: { status: number; data: any },\n  ) {\n    let url: string;\n    if (\"orgId\" in resource) {\n      url = `${homeUrl}/api/orgs/${resource.orgId}/access`;\n    } else if (\"wsId\" in resource) {\n      url = `${homeUrl}/api/workspaces/${resource.wsId}/access`;\n    } else {\n      url = `${homeUrl}/api/docs/${resource.docId}/access`;\n    }\n    const resp = await axios.patch(\n      url,\n      {\n        delta: {\n          users: {\n            ...accessByEmail,\n          },\n        },\n      },\n      chimpy,\n    );\n    assert.equal(resp.status, expected.status);\n    assert.deepEqual(resp.data, expected.data);\n  }\n\n  it(\"PATCH /api/orgs/{oid}/access is operational\", async function() {\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const nasaOrgId = await dbManager.testGetId(\"NASA\");\n    // Assert that Charon is NOT allowed to rename a workspace in Chimpyland\n    const wid = await dbManager.testGetId(\"Private\");\n    const charonResp1 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Charon-Illegal-Rename\",\n    }, charon);\n    assert.equal(charonResp1.status, 403);\n    // Move Charon from 'viewers' to 'editors'.\n    const delta1 = {\n      users: {\n        [charonEmail]: \"editors\",\n      },\n    };\n    const resp1 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta: delta1 }, chimpy);\n    assert.equal(resp1.status, 200);\n    if (notificationsConfig) {\n      // Assert that no mail would be sent (Charon already had access).\n      assert.equal((await getLastMail()).payload, null);\n    }\n    // Assert that the number of users in the org has not been updated (Charon role modified only).\n    assert.deepEqual(userCountUpdates[oid as number], undefined);\n    // Assert that Charon is still not allowed to rename workspaces in Chimpyland\n    const charonResp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Charon-Rename\",\n    }, charon);\n    assert.equal(charonResp2.status, 403);\n    // Move Charon back to 'viewers' and add Kiwi to 'editors' (from no permission).\n    const delta2 = {\n      users: {\n        [charonEmail]: \"viewers\",\n        [kiwiEmail]: \"editors\",\n      },\n    };\n    const resp2 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta: delta2 }, chimpy);\n    assert.equal(resp2.status, 200);\n    // We should send mail about this one, since Kiwi had no access previously.\n    if (notificationsConfig) {\n      const mail = await assertLastMail();\n      assert.match(mail.description, /^invite kiwi@getgrist.com to http.*\\/o\\/docs\\/\\?utm_id=invite-org$/);\n      const env = mail.payload.personalizations[0].dynamic_template_data;\n      assert.deepEqual(pick(env, [\"resource.name\", \"resource.kind\", \"resource.kindUpperFirst\",\n        \"resource.isTeamSite\", \"resource.isWorkspace\", \"resource.isDocument\",\n        \"host.name\", \"host.email\",\n        \"user.name\", \"user.email\",\n        \"access.role\", \"access.canEdit\", \"access.canView\"]), {\n        resource: {\n          name: \"Chimpyland\", kind: \"team site\", kindUpperFirst: \"Team site\",\n          isTeamSite: true, isWorkspace: false, isDocument: false,\n        },\n        host: { name: \"Chimpy\", email: \"chimpy@getgrist.com\" },\n        user: { name: \"Kiwi\", email: \"kiwi@getgrist.com\" },\n        access: { role: \"editors\", canEdit: true, canView: true },\n      } as any);\n      assert.match(env.resource.url, /^http.*\\/o\\/docs\\/\\?utm_id=invite-org$/);\n      assert.deepEqual(mail.payload.personalizations[0].to[0], { email: \"kiwi@getgrist.com\", name: \"Kiwi\" });\n      assert.deepEqual(mail.payload.from, { email: \"support@getgrist.com\", name: \"Chimpy (via Grist)\" });\n      assert.deepEqual(mail.payload.reply_to, { email: \"chimpy@getgrist.com\", name: \"Chimpy\" });\n      assert.deepEqual(mail.payload.template_id, notificationsConfig.template.invite);\n    }\n    // Assert that the number of users in the org has updated (Kiwi was added).\n    assert.deepEqual(userCountUpdates[oid as number], [3]);\n    // Assert that Charon is once again NOT allowed to rename workspaces in Chimpyland\n    const charonResp3 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Charon-Illegal-Rename-2\",\n    }, charon);\n    assert.equal(charonResp3.status, 403);\n    // Assert that Kiwi is still not allowed to rename workspaces in Chimpyland\n    const kiwiResp1 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Private\",\n    }, kiwi);\n    assert.equal(kiwiResp1.status, 403);\n    // Revert the changes and check that behavior is expected once more for good measure.\n    const delta3 = {\n      users: {\n        [kiwiEmail]: null,\n      },\n    };\n    const resp3 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta: delta3 }, chimpy);\n    assert.equal(resp3.status, 200);\n    if (notificationsConfig) {\n      assert.equal((await getLastMail()).description, null);\n    }\n    // Assert that the number of users in the org has updated (Kiwi was removed).\n    assert.deepEqual(userCountUpdates[oid as number], [3, 2]);\n    // Assert that Kiwi is NOT allowed to rename workspaces in Chimpyland\n    const kiwiResp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Kiwi-Illegal-Rename-2\",\n    }, kiwi);\n    assert.equal(kiwiResp2.status, 403);\n\n    // Give Kiwi access to NASA as an editor.\n    // NOTE: This tests a bug with adding users to orgs that contain guests. The bug caused existing\n    // guests of the org to be removed on any access update.\n    const delta4 = {\n      users: {\n        [kiwiEmail]: \"editors\",\n      },\n    };\n    const resp4 = await axios.patch(`${homeUrl}/api/orgs/${nasaOrgId}/access`, { delta: delta4 }, chimpy);\n    assert.equal(resp4.status, 200);\n    if (notificationsConfig) {\n      assert.match(\n        (await assertLastMail()).description,\n        /^invite kiwi@getgrist.com to http.*\\/o\\/nasa\\/\\?utm_id=invite-org$/,\n      );\n    }\n    // Assert that the number of users in the org has updated (Kiwi was added).\n    assert.deepEqual(userCountUpdates[nasaOrgId as number], [2]);\n    // Check that access to NASA is as expected.\n    const resp5 = await axios.get(`${homeUrl}/api/orgs/${nasaOrgId}/access`, chimpy);\n    assert.equal(resp5.status, 200);\n    assert.deepEqual(resp5.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: \"editors\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"guests\",\n        isMember: false,\n      }],\n    });\n    // Revoke Kiwi's access to NASA.\n    const delta6 = {\n      users: {\n        [kiwiEmail]: null,\n      },\n    };\n    const resp6 = await axios.patch(`${homeUrl}/api/orgs/${nasaOrgId}/access`, { delta: delta6 }, chimpy);\n    assert.equal(resp6.status, 200);\n    if (notificationsConfig) {\n      assert.equal((await getLastMail()).description, null);\n    }\n    // Assert that the number of users in the org has updated (Kiwi was removed).\n    assert.deepEqual(userCountUpdates[nasaOrgId as number], [2, 1]);\n    // Check that access to NASA is again as expected, this time without Kiwi present.\n    const resp7 = await axios.get(`${homeUrl}/api/orgs/${nasaOrgId}/access`, chimpy);\n    assert.equal(resp7.status, 200);\n    assert.deepEqual(resp7.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"guests\",\n        isMember: false,\n      }],\n    });\n  });\n\n  it(\"PATCH /api/orgs/{oid}/access allows non-owners to remove themselves\", async function() {\n    const oid = await dbManager.testGetId(\"NASA\");\n    const url = `${homeUrl}/api/orgs/${oid}/access`;\n    await testAllowNonOwnersToRemoveThemselves(url);\n  });\n\n  it(\"PATCH /api/orgs/{oid}/access returns 404 appropriately\", async function() {\n    const delta = {\n      users: {\n        [charonEmail]: null,\n      },\n    };\n    const resp = await axios.patch(`${homeUrl}/api/orgs/9999/access`, { delta }, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"PATCH /api/orgs/{oid}/access returns 403 appropriately\", async function() {\n    // Attempt to set access with a user that does not have ACL_EDIT permissions.\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const delta = {\n      users: {\n        [kiwiEmail]: \"viewers\",\n      },\n    };\n    const resp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta }, charon);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"PATCH /api/orgs/{oid}/access returns 400 appropriately\", async function() {\n    // Omit the delta and check that the operation fails with 400.\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const resp1 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {}, chimpy);\n    assert.equal(resp1.status, 400);\n    // Omit the users object and check that the operation fails with 400.\n    const resp2 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta: {} }, chimpy);\n    assert.equal(resp2.status, 400);\n    // Include a maxInheritedRole value and check that the operation fails with 400.\n    const delta1 = { maxInheritedRole: null };\n    const resp3 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta: delta1 }, chimpy);\n    assert.equal(resp3.status, 400);\n    // Attempt to update own permissions check that the operation fails with 400.\n    const delta2 = {\n      users: {\n        [chimpyEmail]: \"viewers\",\n      },\n    };\n    const resp4 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta: delta2 }, chimpy);\n    assert.equal(resp4.status, 400);\n  });\n\n  it(\"PATCH /api/orgs/{oid}/access returns 403 if too many invites are pending\", async function() {\n    const orgId = await dbManager.testGetId(\"TestMaxNewUserInvites\");\n    const orgId2 = await dbManager.testGetId(\"Chimpyland\");\n    const sandbox = sinon.createSandbox();\n\n    try {\n      // Invite 3 users who don't (yet) have Grist accounts to the org.\n      await checkAccessChange(\n        { orgId },\n        {\n          \"user1@example.com\": \"editors\",\n          \"user2@example.com\": \"editors\",\n          \"user3@example.com\": \"editors\",\n        },\n        { status: 200, data: null },\n      );\n\n      // Invite a 4th user who doesn't have a Grist account to the org. It should fail.\n      await checkAccessChange(\n        { orgId },\n        {\n          \"user4@example.com\": \"editors\",\n        },\n        {\n          status: 403,\n          data: { error: \"Your site has too many pending invitations\" },\n        },\n      );\n\n      // Inviting existing Grist users is permitted.\n      await checkAccessChange(\n        { orgId },\n        {\n          [kiwiEmail]: \"editors\",\n        },\n        { status: 200, data: null },\n      );\n\n      // Removing pending invites is also permitted.\n      await checkAccessChange(\n        { orgId },\n        {\n          \"user1@example.com\": null,\n        },\n        { status: 200, data: null },\n      );\n\n      // Inviting a new user now succeeds; this isn't exactly desirable, and\n      // can be improved upon by implementing rate limiting on the number of\n      // new user emails that can be sent to the /access endpoint in a given\n      // time period.\n      await checkAccessChange(\n        { orgId },\n        {\n          // Changing access of an existing member is unaffected.\n          \"user2@example.com\": \"owners\",\n          \"user4@example.com\": \"editors\",\n        },\n        { status: 200, data: null },\n      );\n\n      // Invite 4 new users to Chimpy's org. There is no limit by default, so this time it should work.\n      await checkAccessChange(\n        { orgId: orgId2 },\n        {\n          \"user1@example.com\": \"editors\",\n          \"user2@example.com\": \"editors\",\n          \"user3@example.com\": \"editors\",\n          \"user4@example.com\": \"editors\",\n        },\n        { status: 200, data: null },\n      );\n\n      // Set the default limit to 2 and check that new invites are blocked.\n      sandbox\n        .stub(HomeDBManagerDeps.defaultMaxNewUserInvitesPerOrg, \"value\")\n        .value(2);\n      await checkAccessChange(\n        { orgId: orgId2 },\n        {\n          \"user5@example.com\": \"editors\",\n        },\n        {\n          status: 403,\n          data: { error: \"Your site has too many pending invitations\" },\n        },\n      );\n\n      // But inviting existing users and removing existing invites works.\n      await checkAccessChange(\n        { orgId: orgId2 },\n        {\n          [kiwiEmail]: \"editors\",\n        },\n        { status: 200, data: null },\n      );\n      await checkAccessChange(\n        { orgId: orgId2 },\n        {\n          \"user1@example.com\": null,\n          \"user2@example.com\": null,\n          \"user3@example.com\": null,\n        },\n        { status: 200, data: null },\n      );\n      await checkAccessChange(\n        { orgId: orgId2 },\n        {\n          \"user5@example.com\": \"editors\",\n        },\n        { status: 200, data: null },\n      );\n\n      // Check that only users created in the last 24 hours are counted.\n      const oldUser = await dbManager.getUserByLogin(\"user+old@example.com\");\n      oldUser.createdAt = moment().subtract(24, \"hours\").add(1, \"minute\").toDate();\n      await oldUser.save();\n      await checkAccessChange(\n        { orgId: orgId2 },\n        {\n          \"user+old@example.com\": \"editors\",\n        },\n        {\n          status: 403,\n          data: { error: \"Your site has too many pending invitations\" },\n        },\n      );\n      oldUser.createdAt = moment().subtract(24, \"hours\").toDate();\n      await oldUser.save();\n      await checkAccessChange(\n        { orgId: orgId2 },\n        {\n          \"user+old@example.com\": \"editors\",\n        },\n        { status: 200, data: null },\n      );\n    } finally {\n      await checkAccessChange(\n        { orgId },\n        {\n          \"user1@example.com\": null,\n          \"user2@example.com\": null,\n          \"user3@example.com\": null,\n          [kiwiEmail]: null,\n          \"user4@example.com\": null,\n        },\n        { status: 200, data: null },\n      );\n      await checkAccessChange(\n        { orgId: orgId2 },\n        {\n          \"user1@example.com\": null,\n          \"user2@example.com\": null,\n          \"user3@example.com\": null,\n          \"user4@example.com\": null,\n          [kiwiEmail]: null,\n          \"user5@example.com\": null,\n          \"user+old@example.com\": null,\n        },\n        { status: 200, data: null },\n      );\n      sandbox.restore();\n    }\n  });\n\n  it(\"PATCH /api/workspaces/{wid}/access is operational\", async function() {\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const wid = await dbManager.testGetId(\"Private\");\n\n    // Assert that Kiwi is unable to GET the org, since Kiwi has no permissions on the org.\n    const kiwiResp1 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);\n    assert.equal(kiwiResp1.status, 403);\n    const delta0 = {\n      users: { [kiwiEmail]: \"members\" },\n    };\n    const resp0 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta: delta0 }, chimpy);\n    assert.equal(resp0.status, 200);\n    // Make Kiwi an editor of the workspace\n    const delta1 = {\n      users: { [kiwiEmail]: \"editors\" },\n    };\n    const resp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, { delta: delta1 }, chimpy);\n    assert.equal(resp2.status, 200);\n    // Check we would sent an email to Kiwi about this\n    if (notificationsConfig) {\n      const mail = await assertLastMail();\n      assert.match(mail.description, /^invite kiwi@getgrist.com to http.*\\/o\\/docs\\/ws\\/[0-9]+\\/\\?utm_id=invite-ws$/);\n      const env = mail.payload.personalizations[0].dynamic_template_data;\n      assert.match(env.resource.url, /^http.*\\/o\\/docs\\/ws\\/[0-9]+\\/\\?utm_id=invite-ws$/);\n      assert.equal(env.resource.kind, \"workspace\");\n      assert.equal(env.resource.kindUpperFirst, \"Workspace\");\n      assert.equal(env.resource.isTeamSite, false);\n      assert.equal(env.resource.isWorkspace, true);\n      assert.equal(env.resource.isDocument, false);\n      assert.equal(env.resource.name, \"Private\");\n    }\n\n    // Assert that the number of users in Chimpyland has updated (Kiwi was added).\n    assert.deepEqual(userCountUpdates[oid as number], [3]);\n    // Assert that Kiwi is still now allowed to rename workspace 'Private' in Chimpyland\n    const kiwiResp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Kiwi-Rename\",\n    }, kiwi);\n    assert.equal(kiwiResp2.status, 403);\n    // Assert that Kiwi is also now able to GET the org, since Kiwi is now a guest of the org.\n    const kiwiResp3 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);\n    assert.equal(kiwiResp3.status, 200);\n\n    // Set the maxInheritedRole to 'viewers'\n    const delta2 = {\n      maxInheritedRole: \"viewers\",\n    };\n    const resp3 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, { delta: delta2 }, chimpy);\n    assert.equal(resp3.status, 200);\n    if (notificationsConfig) {\n      assert.equal((await getLastMail()).description, null);\n    }\n    // Assert that Kiwi is still not allowed to rename the workspace.\n    const kiwiResp4 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Kiwi-Rename2\",\n    }, kiwi);\n    assert.equal(kiwiResp4.status, 403);\n    // Assert that Charon is still allowed to GET the workspace.\n    const charonResp1 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);\n    assert.equal(charonResp1.status, 200);\n    // Assert that as the owner, Chimpy can still rename the workspace.\n    const resp4 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Chimpy-Rename\",\n    }, chimpy);\n    assert.equal(resp4.status, 200);\n\n    // Remove inheritance and also update Kiwi's role to viewer.\n    const delta3 = {\n      maxInheritedRole: null,\n      users: {\n        [kiwiEmail]: \"viewers\",\n      },\n    };\n    const resp5 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, { delta: delta3 }, chimpy);\n    assert.equal(resp5.status, 200);\n    if (notificationsConfig) {\n      assert.equal((await getLastMail()).description, null);\n    }\n    // Assert that Kiwi can still GET the workspace.\n    const kiwiResp5 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);\n    assert.equal(kiwiResp5.status, 200);\n    // Assert that Charon can NOT GET the workspace.\n    const charonResp2 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);\n    assert.equal(charonResp2.status, 403);\n    // Assert that as the owner, Chimpy can still rename the workspace.\n    const resp6 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Chimpy-Rename2\",\n    }, chimpy);\n    assert.equal(resp6.status, 200);\n\n    // Add Charon as an editor to 'Public', and make sure it does NOT affect org\n    // guest access for Kiwi.\n    const wid2 = await dbManager.testGetId(\"Public\");\n    const delta4 = {\n      users: {\n        [charonEmail]: \"editors\",\n      },\n    };\n    const resp7 = await axios.patch(`${homeUrl}/api/workspaces/${wid2}/access`, { delta: delta4 }, chimpy);\n    assert.equal(resp7.status, 200);\n    if (notificationsConfig) {\n      assert.match((await assertLastMail()).description, /^invite charon@getgrist.com /);\n    }\n    // Assert that Kiwi is still able to GET the org, since Kiwi is still a guest\n    // of the org.\n    const kiwiResp6 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);\n    assert.equal(kiwiResp6.status, 200);\n\n    // Remove Charon's custom permissions to 'Public'\n    const delta5 = {\n      users: {\n        [charonEmail]: null,\n      },\n    };\n    const resp8 = await axios.patch(`${homeUrl}/api/workspaces/${wid2}/access`, { delta: delta5 }, chimpy);\n    assert.equal(resp8.status, 200);\n    if (notificationsConfig) {\n      assert.equal((await getLastMail()).description, null);\n    }\n\n    // Reset inheritance and remove Kiwi's custom permissions\n    const delta6 = {\n      maxInheritedRole: \"owners\",\n    };\n    const resp9 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, { delta: delta6 }, chimpy);\n    assert.equal(resp9.status, 200);\n    if (notificationsConfig) {\n      assert.equal((await getLastMail()).description, null);\n    }\n\n    const removeKiwiDelta = {\n      users: { [kiwiEmail]: null },\n    };\n    const removeKiwiResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,\n      { delta: removeKiwiDelta }, chimpy);\n    assert.equal(removeKiwiResp.status, 200);\n    // TODO: Unnecessary once removing from org removes from all.\n    const removeKiwiResp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,\n      { delta: removeKiwiDelta }, chimpy);\n    assert.equal(removeKiwiResp2.status, 200);\n\n    // Assert that the number of users in the org has updated (Kiwi was removed).\n    assert.deepEqual(userCountUpdates[oid as number], [3, 2]);\n\n    // Assert that Kiwi is NOT allowed to GET the workspace\n    const kiwiResp7 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);\n    assert.equal(kiwiResp7.status, 403);\n    // Assert that Charon can once again GET the workspace\n    const charonResp3 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);\n    assert.equal(charonResp3.status, 200);\n    // Assert that as the owner, Chimpy can still rename the workspace.\n    const resp10 = await axios.patch(`${homeUrl}/api/workspaces/${wid}`, {\n      name: \"Private\",\n    }, chimpy);\n    assert.equal(resp10.status, 200);\n    // Assert that Kiwi is no longer able to GET the org, since Kiwi is no longer a guest\n    // of the org.\n    const kiwiResp8 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);\n    assert.equal(kiwiResp8.status, 403);\n  });\n\n  it(\"PATCH /api/workspaces/{wid}/access allows non-owners to remove themselves\", async function() {\n    const wid = await dbManager.testGetId(\"Private\");\n    const url = `${homeUrl}/api/workspaces/${wid}/access`;\n    await testAllowNonOwnersToRemoveThemselves(url);\n  });\n\n  it(\"PATCH /api/workspaces/{wid}/access returns 404 appropriately\", async function() {\n    const delta = {\n      users: {\n        [charonEmail]: null,\n      },\n    };\n    const resp = await axios.patch(`${homeUrl}/api/workspaces/9999/access`, { delta }, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"PATCH /api/workspaces/{wid}/access returns 403 appropriately\", async function() {\n    // Attempt to set access with a user that does not have ACL_EDIT permissions.\n    const wid = await dbManager.testGetId(\"Private\");\n    const delta = {\n      users: {\n        [kiwiEmail]: \"viewers\",\n      },\n    };\n    const resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, { delta }, charon);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"PATCH /api/workspaces/{wid}/access returns 400 appropriately\", async function() {\n    // Omit the delta and check that the operation fails with 400.\n    const wid = await dbManager.testGetId(\"Private\");\n    const resp1 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {}, chimpy);\n    assert.equal(resp1.status, 400);\n    // Omit the content and check that the operation fails with 400.\n    const resp2 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, { delta: {} }, chimpy);\n    assert.equal(resp2.status, 400);\n    // Attempt to update own permissions check that the operation fails with 400.\n    const delta = {\n      users: {\n        [chimpyEmail]: \"viewers\",\n      },\n    };\n    const resp3 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, { delta }, chimpy);\n    assert.equal(resp3.status, 400);\n  });\n\n  it(\"PATCH /api/workspaces/{wid}/access returns 403 if too many invites are pending\", async function() {\n    const orgId = await dbManager.testGetId(\"TestMaxNewUserInvites\");\n    const wsId = await dbManager.testGetId(\"TestMaxNewUserInvitesWs\");\n    try {\n      // Invite Kiwi to the workspace.\n      await checkAccessChange(\n        { orgId },\n        { [kiwiEmail]: \"editors\" },\n        { status: 200, data: null },\n      );\n      await checkAccessChange(\n        { wsId },\n        { [kiwiEmail]: \"viewers\" },\n        { status: 200, data: null },\n      );\n\n      // Invite enough guests to the org to reach maxNewUserInvitesPerOrg.\n      await checkAccessChange(\n        { orgId },\n        {\n          \"user1@example.com\": \"viewers\",\n          \"user2@example.com\": \"viewers\",\n          \"user3@example.com\": \"viewers\",\n        },\n        {\n          status: 200,\n          data: null,\n        },\n      );\n      await checkAccessChange(\n        { orgId },\n        {\n          \"user4@example.com\": \"viewers\",\n        },\n        {\n          status: 403,\n          data: { error: \"Your site has too many pending invitations\" },\n        },\n      );\n\n      // Invite Charon to the workspace. This should still work, as Charon is not a new user.\n      await checkAccessChange(\n        { orgId },\n        { [charonEmail]: \"viewers\" },\n        { status: 200, data: null },\n      );\n      await checkAccessChange(\n        { wsId },\n        { [charonEmail]: \"viewers\" },\n        {\n          status: 200,\n          data: null,\n        },\n      );\n    } finally {\n      await checkAccessChange(\n        { wsId },\n        { [kiwiEmail]: null, [charonEmail]: null },\n        { status: 200, data: null },\n      );\n      await checkAccessChange(\n        { orgId },\n        {\n          [kiwiEmail]: null,\n          \"user1@example.com\": null,\n          \"user2@example.com\": null,\n          \"user3@example.com\": null,\n          [charonEmail]: null,\n        },\n        { status: 200, data: null },\n      );\n    }\n  });\n\n  it(\"PATCH /api/docs/{did}/access is operational\", async function() {\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const wid = await dbManager.testGetId(\"Private\");\n    const did = await dbManager.testGetId(\"Timesheets\");\n\n    // Assert that Kiwi is unable to GET the workspace, since Kiwi has no permissions on\n    // the org/workspace.\n    const kiwiResp1 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);\n    assert.equal(kiwiResp1.status, 403);\n    // Make Kiwi a member of the org.\n    const delta0 = {\n      users: { [kiwiEmail]: \"members\" },\n    };\n    const resp1 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta: delta0 }, chimpy);\n    assert.equal(resp1.status, 200);\n\n    // Make Kiwi a doc editor for Timesheets\n    const delta1 = {\n      users: { [kiwiEmail]: \"editors\" },\n    };\n    const resp2 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta: delta1 }, chimpy);\n    assert.equal(resp2.status, 200);\n    // Check we would sent an email to Kiwi about this\n    if (notificationsConfig) {\n      const mail = await assertLastMail();\n      assert.match(mail.description, /^invite kiwi@getgrist.com to http.*\\/o\\/docs\\/doc\\/.*$/);\n      const env = mail.payload.personalizations[0].dynamic_template_data;\n      assert.match(env.resource.url, /^http.*\\/o\\/docs\\/doc\\/.*$/);\n      assert.equal(env.resource.kind, \"document\");\n      assert.equal(env.resource.kindUpperFirst, \"Document\");\n      assert.equal(env.resource.isTeamSite, false);\n      assert.equal(env.resource.isWorkspace, false);\n      assert.equal(env.resource.isDocument, true);\n      assert.equal(env.resource.name, \"Timesheets\");\n    }\n\n    // Assert that the number of users in Chimpyland has updated (Kiwi was added).\n    assert.deepEqual(userCountUpdates[oid as number], [3]);\n    // Assert that Kiwi is not allowed to rename doc 'Timesheets' in Chimpyland\n    const kiwiResp2 = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      name: \"Kiwi-Rename\",\n    }, kiwi);\n    assert.equal(kiwiResp2.status, 403);\n    // Assert that Kiwi is also now able to GET the workspace, since Kiwi is now a guest of\n    // the workspace.\n    const kiwiResp3 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);\n    assert.equal(kiwiResp3.status, 200);\n    // Assert that Kiwi is also now able to GET the org, since Kiwi is now a guest/member of the org.\n    const kiwiResp4 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);\n    assert.equal(kiwiResp4.status, 200);\n\n    // Set the maxInheritedRole to null\n    const delta2 = { maxInheritedRole: null };\n    const resp3 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta: delta2 }, chimpy);\n    assert.equal(resp3.status, 200);\n    if (notificationsConfig) {\n      assert.equal((await getLastMail()).description, null);\n    }\n    // Assert that Kiwi is still not allowed to rename the doc.\n    const kiwiResp5 = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      name: \"Kiwi-Rename2\",\n    }, kiwi);\n    assert.equal(kiwiResp5.status, 403);\n    // Assert that Charon cannot view 'Timesheets'.\n    const charonResp1 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);\n    assert.equal(charonResp1.status, 200);\n    assert.deepEqual(charonResp1.data.docs.map((doc: any) => doc.name), [\"Appointments\"]);\n    // Assert that as the owner, Chimpy can still rename the doc.\n    const resp4 = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      name: \"Chimpy-Rename\",\n    }, chimpy);\n    assert.equal(resp4.status, 200);\n\n    // Add inheritance for viewers and also update Kiwi's role to viewer.\n    const delta3 = {\n      maxInheritedRole: \"viewers\",\n      users: {\n        [kiwiEmail]: \"viewers\",\n      },\n    };\n    const resp5 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta: delta3 }, chimpy);\n    assert.equal(resp5.status, 200);\n    if (notificationsConfig) {\n      assert.equal((await getLastMail()).description, null);\n    }\n    // Assert that Kiwi can still view the doc.\n    const kiwiResp6 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);\n    assert.equal(kiwiResp6.status, 200);\n    assert.deepEqual(kiwiResp6.data.docs.map((doc: any) => doc.name),\n      [\"Chimpy-Rename\"]);\n    // Assert that Charon can now view the doc.\n    const charonResp2 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, charon);\n    assert.equal(charonResp2.status, 200);\n    assert.deepEqual(charonResp2.data.docs.map((doc: any) => doc.name),\n      [\"Chimpy-Rename\", \"Appointments\"]);\n    // Assert that Charon can NOT rename the doc.\n    const charonResp3 = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      name: \"Charon-Invalid-Rename\",\n    }, charon);\n    assert.equal(charonResp3.status, 403);\n    // Assert that as the owner, Chimpy can still rename the doc.\n    const resp6 = await axios.patch(`${homeUrl}/api/docs/${did}`, {\n      name: \"Timesheets\",\n    }, chimpy);\n    assert.equal(resp6.status, 200);\n\n    // Add Charon as an editor to 'Appointments', and make sure it does NOT affect org\n    // or workspace guest access for Kiwi.\n    const did2 = await dbManager.testGetId(\"Appointments\");\n    const delta4 = {\n      users: {\n        [charonEmail]: \"editors\",\n      },\n    };\n    const resp7 = await axios.patch(`${homeUrl}/api/docs/${did2}/access`, { delta: delta4 }, chimpy);\n    assert.equal(resp7.status, 200);\n    if (notificationsConfig) {\n      assert.match((await assertLastMail()).description, /^invite charon@getgrist.com /);\n    }\n    // Assert that Kiwi is still able to GET the workspace, since Kiwi is still a\n    // guest of the workspace.\n    const kiwiResp7 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);\n    assert.equal(kiwiResp7.status, 200);\n    // Assert that Kiwi is still able to GET the org, since Kiwi is still a guest\n    // of the org.\n    const kiwiResp8 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);\n    assert.equal(kiwiResp8.status, 200);\n\n    // Remove Charon's custom permissions to 'Appointments'\n    const delta5 = {\n      users: {\n        [charonEmail]: null,\n      },\n    };\n    const resp8 = await axios.patch(`${homeUrl}/api/docs/${did2}/access`, { delta: delta5 }, chimpy);\n    assert.equal(resp8.status, 200);\n\n    // Reset doc inheritance setting\n    const delta6 = {\n      maxInheritedRole: \"owners\",\n    };\n    const resp9 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta: delta6 }, chimpy);\n    assert.equal(resp9.status, 200);\n    if (notificationsConfig) {\n      assert.equal((await getLastMail()).description, null);\n    }\n\n    // Remove Kiwi from the org.\n    const removeKiwiDelta = {\n      users: { [kiwiEmail]: null },\n    };\n    const resp10 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,\n      { delta: removeKiwiDelta }, chimpy);\n    assert.equal(resp10.status, 200);\n    // TODO: Unnecessary once removing from org removes from all.\n    const resp11 = await axios.patch(`${homeUrl}/api/docs/${did}/access`,\n      { delta: removeKiwiDelta }, chimpy);\n    assert.equal(resp11.status, 200);\n\n    // Assert that the number of users in Chimpyland has updated (Kiwi was removed).\n    assert.deepEqual(userCountUpdates[oid as number], [3, 2]);\n    // Assert that Kiwi is no longer able to GET the workspace, since Kiwi is no longer a\n    // guest of the workspace.\n    const kiwiResp9 = await axios.get(`${homeUrl}/api/workspaces/${wid}`, kiwi);\n    assert.equal(kiwiResp9.status, 403);\n    // Assert that Kiwi is no longer able to GET the org, since Kiwi is no longer a guest/member\n    // of the org.\n    const kiwiResp10 = await axios.get(`${homeUrl}/api/orgs/${oid}`, kiwi);\n    assert.equal(kiwiResp10.status, 403);\n  });\n\n  it(\"PATCH /api/docs/{did}/access allows non-owners to remove themselves\", async function() {\n    const did = await dbManager.testGetId(\"Timesheets\");\n    const url = `${homeUrl}/api/docs/${did}/access`;\n    await testAllowNonOwnersToRemoveThemselves(url);\n  });\n\n  it(\"PATCH /api/docs/{did}/access can send multiple invites\", async function() {\n    const did = await dbManager.testGetId(\"Timesheets\");\n\n    let delta: PermissionDelta = {\n      users: {\n        \"user1@getgrist.com\": \"editors\",\n        \"user2@getgrist.com\": \"viewers\",\n        \"user3@getgrist.com\": \"viewers\",\n      },\n    };\n    let resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta }, chimpy);\n    assert.equal(resp.status, 200);\n    if (notificationsConfig) {\n      const mail = await assertLastMail();\n      assert.lengthOf(mail.payload.personalizations, 3);\n      assert.sameMembers(mail.payload.personalizations.map(p => p.to[0].email),\n        [\"user1@getgrist.com\", \"user2@getgrist.com\", \"user3@getgrist.com\"]);\n      assert.deepEqual(mail.payload.personalizations.map(p => p.dynamic_template_data.access),\n        [{ role: \"editors\", canEdit: true, canView: true, canEditAccess: false },\n          { role: \"viewers\", canEdit: false, canView: true, canEditAccess: false },\n          { role: \"viewers\", canEdit: false, canView: true, canEditAccess: false }]);\n    }\n    delta = {\n      users: {\n        \"user2@getgrist.com\": null,\n        \"user3@getgrist.com\": \"editors\",\n        \"user4@getgrist.com\": \"viewers\",\n      },\n    };\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta }, chimpy);\n    assert.equal(resp.status, 200);\n    if (notificationsConfig) {\n      const mail = await assertLastMail();\n      assert.lengthOf(mail.payload.personalizations, 1);\n      assert.deepEqual(mail.payload.personalizations[0].to, [{\n        email: \"user4@getgrist.com\",\n        name: \"\",  // name is blank since this user has never logged in.\n      }]);\n    }\n    delta = {\n      users: {\n        \"user1@getgrist.com\": null,\n        \"user3@getgrist.com\": null,\n        \"user4@getgrist.com\": null,\n      },\n    };\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta }, chimpy);\n    assert.equal(resp.status, 200);\n    if (notificationsConfig) {\n      assert.equal((await getLastMail()).payload, null);\n    }\n  });\n\n  it(\"PATCH /api/docs/{did}/access returns 404 appropriately\", async function() {\n    const delta = {\n      users: {\n        [charonEmail]: null,\n      },\n    };\n    const resp = await axios.patch(`${homeUrl}/api/docs/9999/access`, { delta }, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"PATCH /api/docs/{did}/access returns 403 appropriately\", async function() {\n    // Attempt to set access with a user that does not have ACL_EDIT permissions.\n    const did = await dbManager.testGetId(\"Timesheets\");\n    const delta = {\n      users: {\n        [kiwiEmail]: \"viewers\",\n      },\n    };\n    const resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta }, charon);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"PATCH /api/docs/{did}/access returns 403 if too many invites are pending\", async function() {\n    const did1 = await dbManager.testGetId(\"TestMaxNewUserInvitesDoc1\");\n    const did2 = await dbManager.testGetId(\"TestMaxNewUserInvitesDoc2\");\n    try {\n      // Invite 3 users who don't (yet) have Grist accounts to 2 documents.\n      await checkAccessChange(\n        { docId: did1 },\n        { \"user6@example.com\": \"editors\", \"user7@example.com\": \"editors\" },\n        { status: 200, data: null },\n      );\n      await checkAccessChange(\n        { docId: did2 },\n        { \"user8@example.com\": \"editors\" },\n        { status: 200, data: null },\n      );\n\n      // Invite a 4th user who doesn't have a Grist account to either document. It should fail.\n      for (const docId of [did1, did2]) {\n        await checkAccessChange(\n          { docId },\n          { \"user9@example.com\": \"editors\" },\n          {\n            status: 403,\n            data: { error: \"Your site has too many pending invitations\" },\n          },\n        );\n      }\n\n      // Inviting them to the org should also fail.\n      const orgId = await dbManager.testGetId(\"TestMaxNewUserInvites\");\n      await checkAccessChange(\n        { orgId },\n        { \"user9@example.com\": \"editors\" },\n        {\n          status: 403,\n          data: { error: \"Your site has too many pending invitations\" },\n        },\n      );\n\n      // Inviting existing Grist users is permitted.\n      await checkAccessChange(\n        { docId: did2 },\n        { [kiwiEmail]: \"editors\" },\n        { status: 200, data: null },\n      );\n\n      // Removing pending invites is also permitted.\n      await checkAccessChange(\n        { docId: did2 },\n        { \"user8@example.com\": null },\n        { status: 200, data: null },\n      );\n\n      // Inviting a new user now succeeds; this isn't exactly desirable, and\n      // can be improved upon by implementing rate limiting on the number of\n      // new user emails that can be sent to the /access endpoint in a given\n      // time period.\n      await checkAccessChange(\n        { docId: did2 },\n        { \"user9@example.com\": null },\n        { status: 200, data: null },\n      );\n    } finally {\n      await checkAccessChange(\n        { docId: did1 },\n        { \"user6@example.com\": null, \"user7@example.com\": null },\n        { status: 200, data: null },\n      );\n      await checkAccessChange(\n        { docId: did2 },\n        {\n          \"user8@example.com\": null,\n          [kiwiEmail]: null,\n          \"user9@example.com\": null,\n        },\n        { status: 200, data: null },\n      );\n    }\n  });\n\n  it(\"PATCH /api/docs/{did}/access returns 400 appropriately\", async function() {\n    // Omit the delta and check that the operation fails with 400.\n    const did = await dbManager.testGetId(\"Timesheets\");\n    const resp1 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, {}, chimpy);\n    assert.equal(resp1.status, 400);\n    // Omit the content and check that the operation fails with 400.\n    const resp2 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta: {} }, chimpy);\n    assert.equal(resp2.status, 400);\n    // Attempt to update own permissions check that the operation fails with 400.\n    const delta = {\n      users: {\n        [chimpyEmail]: \"viewers\",\n      },\n    };\n    const resp3 = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta }, chimpy);\n    assert.equal(resp3.status, 400);\n  });\n\n  it(\"GET /api/orgs/{oid}/access is operational\", async function() {\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"viewers\",\n        isMember: true,\n      }],\n    });\n  });\n\n  it(\"GET /api/orgs/{oid}/access returns 404 appropriately\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/orgs/9999/access`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"GET /api/orgs/{oid}/access returns 403 appropriately\", async function() {\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, kiwi);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"GET /api/workspaces/{wid}/access is operational\", async function() {\n    // Run a simple case on a Chimpyland workspace\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const wid = await dbManager.testGetId(\"Public\");\n    const resp1 = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.equal(resp1.status, 200);\n    assert.deepEqual(resp1.data, {\n      maxInheritedRole: \"owners\",\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: null,\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: null,\n        parentAccess: \"viewers\",\n        isMember: true,\n      }],\n    });\n    // Run a complex case by modifying maxInheritedRole and individual roles on the workspace,\n    // then querying for access\n    // Set the maxInheritedRole to null\n    const kiwiMemberDelta = {\n      users: { [kiwiEmail]: \"members\" },\n    };\n    const orgPatchResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,\n      { delta: kiwiMemberDelta }, chimpy);\n    assert.equal(orgPatchResp.status, 200);\n\n    const delta = {\n      maxInheritedRole: null,\n      users: {\n        [kiwiEmail]: \"editors\",\n      },\n    };\n    const patchResp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, { delta }, chimpy);\n    assert.equal(patchResp.status, 200);\n    const resp2 = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.equal(resp2.status, 200);\n    assert.deepEqual(resp2.data, {\n      maxInheritedRole: null,\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        // Note that chimpy's access has been elevated to \"owners\"\n        access: \"owners\",\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: \"editors\",\n        parentAccess: null,\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: null,\n        parentAccess: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    const deltaOrg = {\n      users: {\n        [kiwiEmail]: \"owners\",\n      },\n    };\n    const respDeltaOrg = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta: deltaOrg }, chimpy);\n    assert.equal(respDeltaOrg.status, 200);\n\n    const resp3 = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.include(resp3.data.users.find((user: any) => user.email === kiwiEmail), {\n      access: \"editors\",\n      parentAccess: \"owners\",\n    });\n\n    // Reset the access settings\n    const resetDelta = {\n      maxInheritedRole: \"owners\",\n      users: {\n        [kiwiEmail]: null,\n      },\n    };\n    const resetResp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, { delta: resetDelta }, chimpy);\n    assert.equal(resetResp.status, 200);\n    const resetOrgDelta = {\n      users: {\n        [kiwiEmail]: \"members\",\n      },\n    };\n    const resetOrgResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, { delta: resetOrgDelta }, chimpy);\n    assert.equal(resetOrgResp.status, 200);\n\n    // Assert that ws guests are properly displayed.\n    // Tests a minor bug that showed ws guests as having null access.\n    // Add a doc to 'Public', and add Kiwi to the doc.\n    // Add a doc to 'Public'\n    const addDocResp = await axios.post(`${homeUrl}/api/workspaces/${wid}/docs`, {\n      name: \"PublicDoc\",\n    }, chimpy);\n    // Assert that the response is successful\n    assert.equal(addDocResp.status, 200);\n    const did = addDocResp.data;\n\n    // Add Kiwi to the doc\n    const docAccessResp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta }, chimpy);\n    assert.equal(docAccessResp.status, 200);\n\n    // Assert that Kiwi is now a guest of public.\n    const wsResp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.equal(wsResp.status, 200);\n    assert.deepEqual(wsResp.data, {\n      maxInheritedRole: \"owners\",\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: \"guests\",\n        parentAccess: null,\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: null,\n        parentAccess: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Remove the doc.\n    const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy);\n    assert.equal(deleteResp.status, 200);\n\n    // Assert that Kiwi is no longer a guest of public.\n    const wsResp2 = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.equal(wsResp2.status, 200);\n    assert.deepEqual(wsResp2.data, {\n      maxInheritedRole: \"owners\",\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: null,\n        parentAccess: null,\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: null,\n        parentAccess: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Remove Kiwi from the org to reset initial settings\n    const kiwiResetDelta = {\n      users: { [kiwiEmail]: null },\n    };\n    const orgPatchResp2 = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,\n      { delta: kiwiResetDelta }, chimpy);\n    assert.equal(orgPatchResp2.status, 200);\n  });\n\n  it(\"GET /api/workspaces/{wid}/access returns 404 appropriately\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/workspaces/9999/access`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"GET /api/workspaces/{wid}/access returns 403 appropriately\", async function() {\n    const wid = await dbManager.testGetId(\"Private\");\n    const resp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, kiwi);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"GET /api/docs/{did}/access is operational\", async function() {\n    // Run a simple case on a Chimpyland doc\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const did = await dbManager.testGetId(\"Timesheets\");\n    const resp1 = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);\n    assert.equal(resp1.status, 200);\n    assert.deepEqual(resp1.data, {\n      maxInheritedRole: \"owners\",\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        // Note that Chimpy explicitly has owners access to the doc from a previous test.\n        access: \"owners\",\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: null,\n        parentAccess: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Add kiwi as a member of Chimpyland\n    const kiwiMemberDelta = {\n      users: { [kiwiEmail]: \"members\" },\n    };\n    const kiwiMemberResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,\n      { delta: kiwiMemberDelta }, chimpy);\n    assert.equal(kiwiMemberResp.status, 200);\n    // Run a complex case by modifying maxInheritedRole and individual roles on a doc then querying\n    // for access\n    // Set the maxInheritedRole to null\n    const delta = {\n      maxInheritedRole: null,\n      users: { [kiwiEmail]: \"editors\" },\n    };\n    const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta }, chimpy);\n    assert.equal(patchResp.status, 200);\n    const resp2 = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);\n    assert.equal(resp2.status, 200);\n    assert.deepEqual(resp2.data, {\n      maxInheritedRole: null,\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: \"editors\",\n        parentAccess: null,\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: null,\n        parentAccess: \"viewers\",\n        isMember: true,\n      }],\n    });\n    // Reset the access settings\n    const kiwiResetDelta = {\n      users: { [kiwiEmail]: null },\n    };\n    const kiwiResetResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,\n      { delta: kiwiResetDelta }, chimpy);\n    assert.equal(kiwiResetResp.status, 200);\n    // TODO: Unnecessary once removing from org removes from all.\n    const resetDelta = {\n      maxInheritedRole: \"owners\",\n      users: {\n        [kiwiEmail]: null,\n      },\n    };\n    const resetResp = await axios.patch(`${homeUrl}/api/docs/${did}/access`, { delta: resetDelta }, chimpy);\n    assert.equal(resetResp.status, 200);\n\n    // Run another complex case by modifying maxInheritedRole and individual roles of the workspace\n    // the doc is in then querying for access.\n    const shark = await dbManager.testGetId(\"Shark\");\n    const sharkWs = await dbManager.testGetId(\"Big\");\n    const wsDelta = {\n      maxInheritedRole: \"viewers\",\n    };\n    const patchResp2 = await axios.patch(`${homeUrl}/api/workspaces/${sharkWs}/access`, { delta: wsDelta }, chimpy);\n    assert.equal(patchResp2.status, 200);\n    const resp3 = await axios.get(`${homeUrl}/api/docs/${shark}/access`, chimpy);\n    assert.equal(resp3.status, 200);\n    // Assert that the maxInheritedRole of the workspace limits inherited access from the org.\n    assert.deepEqual(resp3.data, {\n      maxInheritedRole: \"owners\",\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        // Note that Chimpy's access to shark is inherited from the workspace, of which he is\n        // explicitly an owner.\n        access: null,\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: null,\n        parentAccess: \"viewers\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: null,\n        parentAccess: \"viewers\",\n        isMember: true,\n      }],\n    });\n    // Reset the access settings\n    const resetDelta2 = {\n      maxInheritedRole: \"owners\",\n    };\n    const resetResp2 = await axios.patch(`${homeUrl}/api/workspaces/${sharkWs}/access`, { delta: resetDelta2 }, chimpy);\n    assert.equal(resetResp2.status, 200);\n  });\n\n  it(\"GET /api/docs/{did}/access returns 404 appropriately\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/docs/9999/access`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"GET /api/docs/{did}/access returns 403 appropriately\", async function() {\n    const did = await dbManager.testGetId(\"Timesheets\");\n    const resp = await axios.get(`${homeUrl}/api/docs/${did}/access`, kiwi);\n    assert.equal(resp.status, 403);\n  });\n\n  describe(\"GET /api/docs/{did}/access shows collaborators\", async function() {\n    // Chimpy will be our org owner.\n    let thisDoc = \"\"; // This is a default document we will work with\n    let thisWs = 0; // This is the default workspace we will work with\n    let otherDocInWs = \"\";\n    const hamEmail = \"ham@getgrist.com\";\n    let otherWs = 0;\n    let chimpyApi: UserAPI, kiwiApi: UserAPI, charonApi: UserAPI, hamApi: UserAPI, anonApi: UserAPI;\n\n    // This is the structure for Fish org.\n    // Fish: {\n    //   members: {\n    //     Chimpy: 'owners',\n    //     Kiwi: 'editors',\n    //     Charon: 'viewers'\n    //   },\n    //   workspaces: [\n    //     { name: 'Small', docs: ['Anchovy', 'Herring'] }\n    //     { name: 'Big', docs: ['Shark'] },\n    //   ]\n    // },\n\n    before(async function() {\n      // Create a new document and add some users to it.\n      chimpyApi = await server.createHomeApi(\"chimpy\", \"fish\", true);\n      kiwiApi = await server.createHomeApi(\"kiwi\", \"fish\", true);\n      charonApi = await server.createHomeApi(\"charon\", \"fish\", true);\n      hamApi = await server.createHomeApi(\"ham\", \"fish\", true, false);\n      anonApi = await server.createHomeApi(\"anonymous\", \"fish\", true, false);\n\n      otherWs = await chimpyApi.getOrgWorkspaces(\"fish\").then(w => w.find(w => w.name === \"Big\")!.id);\n      thisWs = await chimpyApi.getOrgWorkspaces(\"fish\").then(w => w.find(w => w.name === \"Small\")!.id);\n\n      thisDoc = await chimpyApi.getWorkspace(thisWs).then(w => w.docs.find(d => d.name === \"Anchovy\")!.id);\n      otherDocInWs = await chimpyApi.getWorkspace(thisWs).then(w => w.docs.find(d => d.name === \"Herring\")!.id);\n    });\n\n    // Each test will revert all changes in the permissions.\n\n    it(\"org owners should see all users\", async function() {\n      // Owner see everyone.\n      await check(chimpyApi, thisDoc, [\"Chimpy\", \"Charon\", \"Kiwi\"]);\n\n      // Add Ham as a guest to the workspace.\n      let revert = await shareWs(chimpyApi, thisWs, hamEmail, roles.VIEWER);\n\n      // Now we see the guest.\n      await check(chimpyApi, thisDoc, [\"Chimpy\", \"Charon\", \"Kiwi\", \"Ham\"]);\n\n      // Add Ham as a guest to the doc.\n      await revert();\n      await check(chimpyApi, thisDoc, [\"Chimpy\", \"Charon\", \"Kiwi\"]);\n      revert = await shareDoc(chimpyApi, hamEmail, thisDoc, roles.VIEWER);\n      await check(chimpyApi, thisDoc, [\"Chimpy\", \"Charon\", \"Kiwi\", \"Ham\"]);\n      await revert();\n\n      // Break the inheritance for the doc.\n      await breakInheritance(chimpyApi, thisDoc);\n\n      // Owner still see everyone.\n      await check(chimpyApi, thisDoc, [\"Chimpy\", \"Charon\", \"Kiwi\"]);\n\n      // Even ws level guests.\n      revert = await shareWs(chimpyApi, thisWs, hamEmail, roles.VIEWER);\n      await check(chimpyApi, thisDoc, [\"Chimpy\", \"Charon\", \"Kiwi\", \"Ham\"]);\n      await revert();\n\n      // Restore the inheritance for the doc.\n      await fullInheritance(chimpyApi, thisDoc);\n    });\n\n    it(\"viewers should see themselves only\", async function() {\n      // Add Ham as doc viewer guest.\n      let revert = await shareDoc(chimpyApi, hamEmail, thisDoc, roles.VIEWER);\n      await check(charonApi, thisDoc, [\"Charon\"]);\n      await check(hamApi, thisDoc,  [\"Ham\"]);\n      await revert();\n\n      // Add Ham as ws viewer guest.\n      revert = await shareWs(chimpyApi, thisWs, hamEmail, roles.VIEWER);\n      await check(charonApi, thisDoc, [\"Charon\"]);\n      await check(hamApi, thisDoc, [\"Ham\"]);\n      await revert();\n    });\n\n    it(\"anonymous should see no one\", async function() {\n      // Sanity check that anonymous user don't have access.\n      await assert.isRejected(anonApi.getDocAccess(thisDoc));\n      // Make doc public as viewers.\n      let revert = await shareDoc(chimpyApi, everyoneEmail, thisDoc, roles.VIEWER);\n      await check(anonApi, thisDoc, []);\n      await check(charonApi, thisDoc, [\"Charon\"]);\n      await revert();\n\n      // Make ws public as viewers.\n      revert = await shareDoc(chimpyApi, everyoneEmail, thisDoc, roles.EDITOR);\n      await check(anonApi, thisDoc, []);\n      await check(charonApi, thisDoc, [\"Charon\"]);\n      await revert();\n    });\n\n    it(\"non-team collaborators should see only users with access to doc\", async function() {\n      // First lets remove charon from the org.\n      await unshareOrg(chimpyApi, charonEmail);\n\n      // And make him an owner of the second ws (Big)\n      await shareWs(chimpyApi, otherWs, charonEmail, roles.OWNER);\n\n      // Now lets see what ham (as guest) sees. Make him a guest editor on the thisDoc.\n      await shareDoc(chimpyApi, hamEmail, thisDoc, roles.EDITOR);\n\n      // Ham does not see Charon, as he is not a team member.\n      await check(hamApi, thisDoc, [\"Chimpy\", \"Ham\", \"Kiwi\"]);\n\n      // Check if Ham will see Charon if he is an owner of a doc in the same ws.\n      await unshareWs(chimpyApi, charonEmail, otherWs);\n      await shareDoc(chimpyApi, charonEmail, otherDocInWs, roles.OWNER);\n      // He still can't see him.\n      await check(hamApi, thisDoc, [\"Chimpy\", \"Ham\", \"Kiwi\"]);\n      // Check what Kiwi sees as a team member (editor on org). Charon should not be listed.\n      await check(kiwiApi, thisDoc, [\"Chimpy\", \"Ham\", \"Kiwi\"]);\n      // But Chimpy sees everyone.\n      await check(chimpyApi, thisDoc, [\"Charon\", \"Chimpy\", \"Ham\", \"Kiwi\"]);\n\n      // Revert all.\n      await unshareDoc(chimpyApi, charonEmail, otherDocInWs);\n      await shareOrg(chimpyApi, charonEmail, roles.VIEWER);\n    });\n\n    it(\"doc collaborators should see users with view access on doc\", async function() {\n      // Add Ham as workspace editor (so now he is a guest in the org)\n      let revert = await shareWs(chimpyApi, thisWs, hamEmail, roles.EDITOR);\n\n      // Team editor can see all doc collaborators\n      await check(kiwiApi, thisDoc, [\n        \"Chimpy\", \"Charon\", \"Kiwi\", \"Ham\",\n      ]);\n      // Workspace editor can see all doc collaborators\n      await check(hamApi, thisDoc, [\n        \"Chimpy\", \"Charon\", \"Kiwi\", \"Ham\",\n      ]);\n      await revert();\n\n      // Add Ham as doc editor guest.\n      revert = await shareDoc(chimpyApi, hamEmail, thisDoc, roles.EDITOR);\n      await check(kiwiApi, thisDoc, [\n        \"Chimpy\", \"Charon\", \"Kiwi\", \"Ham\",\n      ]);\n      await check(hamApi, thisDoc, [\n        \"Chimpy\", \"Charon\", \"Kiwi\", \"Ham\",\n      ]);\n      await revert();\n\n      // Now break the inheritance for the doc.\n      await breakInheritance(chimpyApi, thisDoc);\n\n      // No-one has access to the doc now.\n      await assert.isRejected(kiwiApi.getDocAccess(thisDoc));\n      await assert.isRejected(charonApi.getDocAccess(thisDoc));\n\n      // Share this doc with Kiwi as editor.\n      revert = await shareDoc(chimpyApi, kiwiEmail, thisDoc, roles.EDITOR);\n\n      // Kiwi is a team editor without any ownership, so he will only see doc collaborators\n      await check(kiwiApi, thisDoc, [\n        \"Chimpy\", \"Kiwi\",\n      ]);\n\n      // Since Charon has no access to the doc, Kiwi doesn't see Charon listed at all.\n      assert.notInclude(await listUsers(kiwiApi, thisDoc), charonEmail);\n\n      // Chimpy should see Charon on the list without any access.\n      const charonAccess = await realAccess(chimpyApi, charonEmail, thisDoc);\n      assert.isNull(charonAccess);\n\n      // Sanity check for Chimpy access.\n      const chimpyAccess = await realAccess(kiwiApi, chimpyEmail, thisDoc);\n      assert.equal(chimpyAccess, roles.OWNER);\n\n      // Revert changes.\n      await revert();\n      await fullInheritance(chimpyApi, thisDoc);\n    });\n\n    it(\"public users see only themselves\", async function() {\n      // Remove Kiwi from the org.\n      await unshareOrg(chimpyApi, kiwiEmail);\n\n      // Break the inheritance for the doc.\n      await breakInheritance(chimpyApi, thisDoc);\n\n      // Sanity check that Charon can't access the doc.\n      await assert.isRejected(charonApi.getDocAccess(thisDoc));\n\n      // Make doc public for viewers, now Charon can see it.\n      await shareDoc(chimpyApi, everyoneEmail, thisDoc, roles.VIEWER);\n      await assert.isFulfilled(charonApi.getDocAccess(thisDoc));\n      // And Charon can see only himself, he is public viewer.\n      await check(charonApi, thisDoc, [\"Charon\"]);\n      // Make him public editor.\n      await shareDoc(chimpyApi, everyoneEmail, thisDoc, roles.EDITOR);\n      // Still, though he is team member, he has public access to the doc.\n      await check(charonApi, thisDoc, [\"Charon\"]);\n      // Kiwi is an outside collaborator, so the list is empty, as grist treats Kiwi as anonymous\n      await assert.isFulfilled(kiwiApi.getDocAccess(thisDoc));\n      await check(kiwiApi, thisDoc, []);\n\n      // Revert all.\n      await unshareDoc(chimpyApi, everyoneEmail, thisDoc);\n      await fullInheritance(chimpyApi, thisDoc);\n      await shareOrg(chimpyApi, kiwiEmail, roles.EDITOR);\n    });\n\n    it(\"workspace owner should see all users from workspace\", async function() {\n      // Remove everyone from the the org.\n      await unshareOrg(chimpyApi, kiwiEmail);\n      await unshareOrg(chimpyApi, charonEmail);\n\n      // Make Kiwi a workspace owner.\n      await shareWs(chimpyApi, thisWs, kiwiEmail, roles.OWNER);\n      // Share the other doc with Charon as an Editor. He is now a guest in the workspace.\n      await shareDoc(chimpyApi, charonEmail, otherDocInWs, roles.EDITOR);\n      // Now test what Ham would see as a guest editor.\n      await shareDoc(chimpyApi, hamEmail, thisDoc, roles.EDITOR);\n      // He will see only doc collaborators (so not Charon).\n      await check(hamApi, thisDoc, [\n        \"Chimpy\", \"Kiwi\", \"Ham\",\n      ]);\n      // But Kiwi as a workspace owner will see Charon.\n      await check(kiwiApi, thisDoc, [\n        \"Chimpy\", \"Kiwi\", \"Ham\", \"Charon\",\n      ]);\n\n      // Make sure Kiwi, despite being a workspace owner, does not see Ham as a team member.\n      await unshareDoc(chimpyApi, hamEmail, thisDoc);\n      await shareOrg(chimpyApi, hamEmail, roles.EDITOR);\n      // With full inheritance, Kiwi can see Ham.\n      await check(kiwiApi, thisDoc, [\n        \"Chimpy\", \"Kiwi\", \"Charon\", \"Ham\",\n      ]);\n\n      // Without workspace inheritance, Kiwi won't see Ham (he is a editor member of the org).\n      await breakInheritance(chimpyApi, thisWs);\n      await check(kiwiApi, thisDoc, [\n        \"Chimpy\", \"Kiwi\", \"Charon\",\n      ]);\n\n      // If we break inheritance for the doc, Kiwi won't be able to access document (despite being a ws owner).\n      await breakInheritance(chimpyApi, thisDoc);\n      await assert.isRejected(kiwiApi.getDocAccess(thisDoc));\n\n      // Now share this doc with Kiwi as a viewer.\n      await shareDoc(chimpyApi, kiwiEmail, thisDoc, roles.VIEWER);\n\n      // Kiwi still only sees Kiwi, as this is what viewer can see.\n      await check(kiwiApi, thisDoc, [\n        \"Kiwi\",\n      ]);\n\n      await unshareDoc(chimpyApi, kiwiEmail, thisDoc);\n      await assert.isRejected(kiwiApi.getDocAccess(thisDoc));\n\n      // Revert all.\n      await fullInheritance(chimpyApi, thisDoc);\n      await fullInheritance(chimpyApi, thisWs);\n      await unshareDoc(chimpyApi, charonEmail, otherDocInWs);\n      await unshareOrg(chimpyApi, hamEmail);\n      await shareOrg(chimpyApi, kiwiEmail, roles.EDITOR);\n      await shareOrg(chimpyApi, charonEmail, roles.EDITOR);\n    });\n\n    async function sees(view: UserAPI, doc: string) {\n      const access = await view.getDocAccess(doc);\n      return access.users.map((u: any) => u.name).sort();\n    }\n\n    async function check(userApi: UserAPI, doc: string, list: string[]) {\n      assert.deepEqual(await sees(userApi, doc), list.sort());\n    }\n  });\n\n  it(\"should show special users if they are added\", async function() {\n    // TODO We may want to expose special flags in requests and responses rather than allow adding\n    // and retrieving special email addresses. For now, just make sure that if we succeed adding a\n    // a special user, that we can also retrieve it.\n    const wid = await dbManager.testGetId(\"Private\");\n    const did = await dbManager.testGetId(\"Timesheets\");    // This is inside workspace `wid`\n\n    // Turns users from PermissionData into a mapping from email address to [access, parentAccess],\n    // for more concise comparisons below.\n    function compactAccess(data: PermissionData): { [email: string]: [Role | null, Role | null] } {\n      return fromPairs(data.users.map(u => [u.email, [u.access, u.parentAccess || null]]));\n    }\n\n    let resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,\n      { delta: { users: { [everyoneEmail]: \"viewers\" } } }, chimpy);\n    assert.equal(resp.status, 200);\n\n    // The special user should be visible when we get the access list.\n    resp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.deepEqual(compactAccess(resp.data), {\n      [chimpyEmail]: [\"owners\", \"owners\"],\n      [charonEmail]: [null, \"viewers\"],\n      [everyoneEmail]: [\"viewers\", null],\n    });\n\n    // The special user should be visible on the doc too, since it's inherited.\n    resp = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);\n    assert.deepEqual(compactAccess(resp.data), {\n      [chimpyEmail]: [\"owners\", \"owners\"],\n      [charonEmail]: [null, \"viewers\"],\n      [everyoneEmail]: [null, \"viewers\"],\n    });\n\n    // Remove the special user; it should no longer be visible on either.\n    resp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,\n      { delta: { users: { [everyoneEmail]: null } } }, chimpy);\n    resp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.deepEqual(compactAccess(resp.data), {\n      [chimpyEmail]: [\"owners\", \"owners\"],\n      [charonEmail]: [null, \"viewers\"],\n    });\n    resp = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);\n    assert.deepEqual(compactAccess(resp.data), {\n      [chimpyEmail]: [\"owners\", \"owners\"],\n      [charonEmail]: [null, \"viewers\"],\n    });\n\n    // Add special user to the doc.\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`,\n      { delta: { users: { [everyoneEmail]: \"editors\" } } }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);\n    assert.deepEqual(compactAccess(resp.data), {\n      [chimpyEmail]: [\"owners\", \"owners\"],\n      [charonEmail]: [null, \"viewers\"],\n      [everyoneEmail]: [\"editors\", null],\n    });\n\n    // But it should not be visible on the workspace.\n    resp = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.deepEqual(compactAccess(resp.data), {\n      [chimpyEmail]: [\"owners\", \"owners\"],\n      [charonEmail]: [null, \"viewers\"],\n    });\n\n    // Remove the special user.\n    resp = await axios.patch(`${homeUrl}/api/docs/${did}/access`,\n      { delta: { users: { [everyoneEmail]: null } } }, chimpy);\n    resp = await axios.get(`${homeUrl}/api/docs/${did}/access`, chimpy);\n    assert.deepEqual(compactAccess(resp.data), {\n      [chimpyEmail]: [\"owners\", \"owners\"],\n      [charonEmail]: [null, \"viewers\"],\n    });\n  });\n\n  it(\"should allow setting member role\", async function() {\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const wid = await dbManager.testGetId(\"Private\");\n    const did = await dbManager.testGetId(\"Timesheets\");\n    const addDelta = {\n      users: { [kiwiEmail]: \"members\" },\n    };\n    const removeDelta = {\n      users: { [kiwiEmail]: null },\n    };\n\n    // Set Kiwi as a member of org 'Chimpyland'.\n    const addKiwiToOrg = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,\n      { delta: addDelta }, chimpy);\n    assert.equal(addKiwiToOrg.status, 200);\n\n    // Fetch workspace permissions and check that Kiwi has and inherits no access.\n    const kiwiWsAccess = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy);\n    assert.equal(kiwiWsAccess.status, 200);\n    assert.deepEqual(kiwiWsAccess.data, {\n      maxInheritedRole: \"owners\",\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        // Note that Chimpy already has ownership access to the workspace.\n        access: \"owners\",\n        parentAccess: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: null,\n        parentAccess: null,\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: null,\n        parentAccess: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Fetch org permissions and check that Kiwi is a member.\n    const kiwiOrgAccess = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);\n    assert.equal(kiwiOrgAccess.status, 200);\n    assert.deepEqual(kiwiOrgAccess.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: chimpyRef,\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: kiwiRef,\n        picture: null,\n        access: \"members\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: charonRef,\n        picture: null,\n        access: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Unset Kiwi as a member of org 'Chimpyland'.\n    const removeKiwiFromOrg = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,\n      { delta: removeDelta }, chimpy);\n    assert.equal(removeKiwiFromOrg.status, 200);\n\n    // Assert that updating a workspace user to \"members\" throws with status 400.\n    const invalidResp1 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,\n      { delta: addDelta }, chimpy);\n    assert.equal(invalidResp1.status, 400);\n\n    // Assert that updating a doc user to \"members\" throws with status 400.\n    const invalidResp2 = await axios.patch(`${homeUrl}/api/docs/${did}/access`,\n      { delta: addDelta }, chimpy);\n    assert.equal(invalidResp2.status, 400);\n\n    // Assert that updating the maxInheritedRole to \"members\" throws with status 400.\n    const invalidDelta = { maxInheritedRole: \"members\" };\n    const invalidResp3 = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,\n      { delta: invalidDelta }, chimpy);\n    assert.equal(invalidResp3.status, 400);\n  });\n\n  it(\"should disallow setting empty or invalid emails\", async function() {\n    const oid = await dbManager.testGetId(\"Chimpyland\");\n    const wid = await dbManager.testGetId(\"Private\");\n    const did = await dbManager.testGetId(\"Timesheets\");\n\n    async function testInvalidEmail(email: string) {\n      // Try setting on org level.\n      const invalidRespOrg = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,\n        { delta: { users: { [email]: \"viewers\" } } }, chimpy);\n      assertResult(invalidRespOrg, 400, \"Invalid email address included\");\n\n      // Fetch org permissions and check that our attempt didn't get added.\n      const orgAccess = await axios.get(`${homeUrl}/api/orgs/${oid}/access`, chimpy);\n      assert.equal(orgAccess.status, 200);\n      assert.deepEqual(orgAccess.data.users.map((u: any) => u.email), [chimpyEmail, charonEmail]);\n\n      // Try updating access to remove an invalid email; this should succeed, so that we don't\n      // block people from correcting bad sharing from before validation got added.\n      const removeResult = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`,\n        { delta: { users: { [email]: null } } }, chimpy);\n      assert.equal(removeResult.status, 200);\n\n      // Try workspace level; combining with a valid email shouldn't help.\n      const invalidRespWs = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`,\n        { delta: { users: { [kiwiEmail]: \"viewers\", [email]: \"editors\" } } }, chimpy);\n      assertResult(invalidRespWs, 400, \"Invalid email address included\");\n\n      // Try doc level.\n      const invalidRespDoc = await axios.patch(`${homeUrl}/api/docs/${did}/access`,\n        { delta: { users: { [email]: \"owners\" } } }, chimpy);\n      assertResult(invalidRespDoc, 400, \"Invalid email address included\");\n    }\n\n    // Try setting permissions for an empty email and for a few other invalid emails.\n    await testInvalidEmail(\"\");\n    await testInvalidEmail(\"\\n\");\n    await testInvalidEmail(\"hello\");\n    await testInvalidEmail(\"foo@example.com\\r\\nBcc: bar@example.com\");\n    await testInvalidEmail(\"' OR 1=1 --@example.com\");\n  });\n\n  describe(\"team plan\", function() {\n    let nasaOrg: Organization;\n    let oldProduct: Product;\n\n    before(async function() {\n      // Set NASA to be specifically on a team plan, with team plan restrictions.\n      const db = dbManager.connection.manager;\n      nasaOrg = (await db.findOne(Organization, { where: { domain: \"nasa\" },\n        relations: [\"billingAccount\",\n          \"billingAccount.product\"] }))!;\n      oldProduct = nasaOrg.billingAccount.product;\n      nasaOrg.billingAccount.product = (await db.findOne(Product, { where: { name: \"team\" } }))!;\n      await nasaOrg.billingAccount.save();\n    });\n\n    after(async function() {\n      nasaOrg.billingAccount.product = oldProduct;\n      await nasaOrg.billingAccount.save();\n    });\n\n    it(\"should prevent adding non-org-members to workspaces\", async function() {\n      // Add Kiwi to Horizon\n      const horizonWs = await dbManager.testGetId(\"Horizon\");\n      const addDelta = {\n        users: { [kiwiEmail]: \"viewers\" },\n      };\n      const errorResp = await axios.patch(`${homeUrl}/api/workspaces/${horizonWs}/access`,\n        { delta: addDelta }, chimpy);\n      assert.equal(errorResp.status, 403);\n      assert.equal(errorResp.data.error, \"No external workspace shares permitted\");\n    });\n\n    it(\"should prevent adding more than n non-org-members to docs\", async function() {\n      // Add Kiwi to Apathy\n      const apathyDoc = await dbManager.testGetId(\"Apathy\");\n      let resp = await axios.patch(`${homeUrl}/api/docs/${apathyDoc}/access`,\n        { delta: { users: { [kiwiEmail]: \"viewers\" } } }, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Add Support to Apathy, should not count\n      resp = await axios.patch(`${homeUrl}/api/docs/${apathyDoc}/access`,\n        { delta: { users: { [supportEmail]: \"viewers\" } } }, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Add Ella to Apathy\n      resp = await axios.patch(`${homeUrl}/api/docs/${apathyDoc}/access`,\n        { delta: { users: { \"ella@getgrist.com\": \"editors\" } } }, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Add Charon to Apathy\n      resp = await axios.patch(`${homeUrl}/api/docs/${apathyDoc}/access`,\n        { delta: { users: { [charonEmail]: \"viewers\" } } }, chimpy);\n      assert.equal(resp.status, 403);\n      assert.equal(resp.data.error, \"No more external document shares permitted\");\n\n      // Remove added users\n      const removeDelta = {\n        users: {\n          [kiwiEmail]: null,\n          [supportEmail]: null,\n        },\n      };\n      resp = await axios.patch(`${homeUrl}/api/docs/${apathyDoc}/access`,\n        { delta: removeDelta }, chimpy);\n      assert.equal(resp.status, 200);\n    });\n  });\n\n  it(\"should emit userChange events when expected\", async function() {\n    // Change org permissions ==>\n    const fishOrgId = await dbManager.testGetId(\"Fish\");\n\n    // Remove charon and kiwi from org\n    const removeCharonKiwi = {\n      users: { [charonEmail]: null, [kiwiEmail]: null },\n    };\n    const fishResp1 = await axios.patch(`${homeUrl}/api/orgs/${fishOrgId}/access`,\n      { delta: removeCharonKiwi }, chimpy);\n    assert.equal(fishResp1.status, 200);\n    assert.deepEqual(userCountUpdates[fishOrgId as number], [1]);\n\n    // Re-add charon\n    const addCharon = {\n      users: { [charonEmail]: \"viewers\" },\n    };\n    const fishResp2 = await axios.patch(`${homeUrl}/api/orgs/${fishOrgId}/access`,\n      { delta: addCharon }, chimpy);\n    assert.equal(fishResp2.status, 200);\n    assert.deepEqual(userCountUpdates[fishOrgId as number], [1, 2]);\n\n    // Re-add kiwi\n    const addKiwi = {\n      users: { [kiwiEmail]: \"editors\" },\n    };\n    const fishResp3 = await axios.patch(`${homeUrl}/api/orgs/${fishOrgId}/access`,\n      { delta: addKiwi }, chimpy);\n    assert.equal(fishResp3.status, 200);\n    assert.deepEqual(userCountUpdates[fishOrgId as number], [1, 2, 3]);\n\n    // Change workspace permissions ==>\n    const clOrgId = await dbManager.testGetId(\"Chimpyland\");\n    const publicWsId = await dbManager.testGetId(\"Public\");\n\n    // Add charon to ws\n    const publicResp1 = await axios.patch(`${homeUrl}/api/workspaces/${publicWsId}/access`,\n      { delta: addCharon }, chimpy);\n    assert.equal(publicResp1.status, 200);\n\n    // Remove charon\n    const removeCharon = {\n      users: { [charonEmail]: null },\n    };\n    const publicResp2 = await axios.patch(`${homeUrl}/api/workspaces/${publicWsId}/access`,\n      { delta: removeCharon }, chimpy);\n    assert.equal(publicResp2.status, 200);\n    // Assert that workspace user changes have no effect on userCount.\n    assert.deepEqual(userCountUpdates[clOrgId as number], undefined);\n  });\n\n  it(\"GET /api/profile/apikey gives user's api key\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/profile/apikey`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data, \"api_key_for_chimpy\");\n  });\n\n  it(\"POST /api/profile/apiKey fails for anonymous\", async function() {\n    const resp = await axios.post(`${homeUrl}/api/profile/apikey`, null, nobody);\n    assert.equal(resp.status, 401);\n    assert.deepEqual(resp.data, { error: \"user not authorized\" });\n  });\n\n  it(\"DELETE /api/profile/apiKey fails for anonymous\", async function() {\n    const resp = await axios.delete(`${homeUrl}/api/profile/apikey`, nobody);\n    assert.equal(resp.status, 401);\n    assert.deepEqual(resp.data, { error: \"user not authorized\" });\n  });\n\n  it(\"DELETE /api/profile/apikey delete api key\", async function() {\n    let resp: AxiosResponse;\n    resp = await axios.delete(`${homeUrl}/api/profile/apikey`, chimpy);\n    assert.equal(resp.status, 200);\n\n    // check that chimpy's apikey does not work any more\n    resp = await axios.get(`${homeUrl}/api/orgs`, chimpy);\n    assert.equal(resp.status, 401);\n    assert.deepEqual(resp.data, \"Bad request: invalid API key\");\n\n    // check that the apikey '' does not work either\n    resp = await axios.get(`${homeUrl}/api/orgs`, {\n      responseType: \"json\",\n      validateStatus: () => true,\n      headers: { Authorization: \"Bearer \" },\n    });\n    assert.equal(resp.status, 401);\n    assert.deepEqual(resp.data, \"Bad request: invalid API key\");\n\n    // check that db encoded null for the apikey\n    const chimpyUser = (await User.findOne({ where: { name: \"Chimpy\" } }))!;\n    assert.deepEqual(chimpyUser.apiKey, null);\n\n    // restore api key for chimpy\n    chimpyUser.apiKey = \"api_key_for_chimpy\";\n    await chimpyUser.save();\n  });\n\n  describe(\"POST /api/profile/apikey\", function() {\n    let resp: AxiosResponse;\n    it(\"fails if apiKey already set\", async function() {\n      resp = await axios.post(`${homeUrl}/api/profile/apikey`, null, kiwi);\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /apikey is already set/);\n    });\n\n    it(\"succeed if apiKey already set but force flag is used\", async function() {\n      resp = await axios.post(`${homeUrl}/api/profile/apikey`, { force: true }, kiwi);\n      assert.equal(resp.status, 200);\n      const apiKey = resp.data;\n\n      // check that old apikey does not work any more\n      resp = await axios.get(`${homeUrl}/api/orgs`, kiwi);\n      assert.equal(resp.status, 401);\n      assert.deepEqual(resp.data, \"Bad request: invalid API key\");\n\n      // check that the new api key works\n      kiwi.headers = { Authorization: \"Bearer \" + apiKey };\n      resp = await axios.get(`${homeUrl}/api/orgs`, kiwi);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data.map((org: any) => org.name),\n        [\"Kiwiland\", \"Fish\", \"Flightless\", \"Primately\"]);\n    });\n\n    describe(\"force flag is not needed if apiKey is not set\", function() {\n      before(function() {\n        // turn off api key access for chimpy\n        return dbManager.connection.query(`update users set api_key = null where name = 'Chimpy'`);\n      });\n\n      after(function() {\n        // bring back api key access for chimpy\n        return dbManager.connection.query(`update users set api_key = 'api_key_for_chimpy' where name = 'Chimpy'`);\n      });\n\n      it(\"force flag is not needed\", async function() {\n        // make sure api key access is off\n        resp = await axios.get(`${homeUrl}/api/orgs`, chimpy);\n        assert.equal(resp.status, 401);\n\n        const cookie = await server.getCookieLogin(\"nasa\", { email: \"chimpy@getgrist.com\",\n          name: \"Chimpy\" });\n\n        // let's create an apikey\n        resp = await axios.post(`${homeUrl}/o/nasa/api/profile/apikey`, {}, cookie);\n        // check call was successful\n        assert.equal(resp.status, 200);\n\n        // check that new api key works\n        chimpy.headers = { Authorization: \"Bearer \" + resp.data };\n        resp = await axios.get(`${homeUrl}/api/orgs`, chimpy);\n        assert.equal(resp.status, 200);\n        assert.deepEqual(resp.data.map((org: any) => org.name),\n          [\"Chimpyland\", \"EmptyOrg\", \"EmptyWsOrg\", \"Fish\", \"Flightless\",\n            \"FreeTeam\", \"NASA\", \"Primately\", \"TestAuditLogs\", \"TestDailyApiLimit\",\n            \"TestMaxNewUserInvites\"]);\n      });\n    });\n  });\n});\n\nasync function testAllowNonOwnersToRemoveThemselves(url: string) {\n  // Add a viewer and an editor.\n  let resp = await axios.patch(url, {\n    delta: {\n      users: {\n        [charonEmail]: \"editors\",\n        [kiwiEmail]: \"viewers\",\n      },\n    },\n  }, chimpy);\n  assert.equal(resp.status, 200);\n  // One cannot remove the other.\n  resp = await axios.patch(url, {\n    delta: {\n      users: {\n        [kiwiEmail]: null,\n      },\n    },\n  }, charon);\n  assert.equal(resp.status, 403);\n  // But they can remove themselves.\n  resp = await axios.patch(url, {\n    delta: {\n      users: {\n        [charonEmail]: null,\n      },\n    },\n  }, charon);\n  assert.equal(resp.status, 200);\n  resp = await axios.patch(url, {\n    delta: {\n      users: {\n        [kiwiEmail]: null,\n      },\n    },\n  }, kiwi);\n  assert.equal(resp.status, 200);\n}\n\nasync function listUsers(view: UserAPI, doc: string) {\n  return (await view.getDocAccess(doc)).users.map((u: any) => u.email);\n}\n\nasync function realAccess(view: UserAPI, email: string, doc: string) {\n  const access = await view.getDocAccess(doc);\n  const user = access.users.find((u: any) => u.email === email);\n  if (!user) {\n    throw new Error(`User ${email} not found in doc ${doc}`);\n  }\n  const effective = getRealAccess(user, access);\n  return effective;\n}\n\nasync function breakInheritance(api: UserAPI, docWs: string | number) {\n  if (typeof docWs === \"number\") {\n    await api.updateWorkspacePermissions(docWs, {\n      maxInheritedRole: null,\n    });\n  } else {\n    // Break inheritance for the doc.\n    await api.updateDocPermissions(docWs, {\n      maxInheritedRole: null,\n    });\n  }\n}\n\nasync function fullInheritance(api: UserAPI, docWs: string | number) {\n  if (typeof docWs === \"number\") {\n    await api.updateWorkspacePermissions(docWs, {\n      maxInheritedRole: \"owners\",\n    });\n  } else {\n    await api.updateDocPermissions(docWs, {\n      maxInheritedRole: \"owners\",\n    });\n  }\n}\n\nasync function unshareDoc(api: UserAPI, email: string, doc: string) {\n  await api.updateDocPermissions(doc, {\n    users: {\n      [email]: null,\n    },\n  });\n}\n\nasync function unshareWs(api: UserAPI, email: string, wsId: number) {\n  await api.updateWorkspacePermissions(wsId, {\n    users: {\n      [email]: null,\n    },\n  });\n}\n\nasync function unshareOrg(api: UserAPI, email: string) {\n  await api.updateOrgPermissions(\"fish\", {\n    users: {\n      [email]: null,\n    },\n  });\n}\n\nasync function shareDoc(api: UserAPI, email: string, doc: string, role: roles.BasicRole) {\n  await api.updateDocPermissions(doc, {\n    users: {\n      [email]: role,\n    },\n  });\n  return () => unshareDoc(api, email, doc);\n}\n\nasync function shareWs(api: UserAPI, wsId: number, email: string, role: roles.BasicRole) {\n  await api.updateWorkspacePermissions(wsId, {\n    users: {\n      [email]: role,\n    },\n  });\n  return () => unshareWs(api, email, wsId);\n}\n\nasync function shareOrg(api: UserAPI, email: string, role: roles.BasicRole) {\n  await api.updateOrgPermissions(\"fish\", {\n    users: {\n      [email]: role,\n    },\n  });\n  return () => unshareOrg(api, email);\n}\n"
  },
  {
    "path": "test/gen-server/ApiServerBenchmark.ts",
    "content": "import { FlexServer } from \"app/server/lib/FlexServer\";\nimport { createBenchmarkServer, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\n\nlet home: FlexServer;\nlet homeUrl: string;\n\nconst chimpy = configForUser(\"Chimpy\");\n\ndescribe(\"ApiServerBenchmark\", function() {\n  testUtils.setTmpLogLevel(\"error\");\n\n  before(async function() {\n    if (!process.env.ENABLE_BENCHMARKS) {\n      this.skip();\n      return;\n    }\n    this.timeout(600000);\n    setUpDB(this);\n    home = await createBenchmarkServer(0);\n    homeUrl = home.getOwnUrl();\n  });\n\n  after(async function() {\n    if (home) {\n      await home.stopListening();\n      await removeConnection();\n    }\n  });\n\n  it(\"GET /orgs returns in a timely manner\", async function() {\n    this.timeout(600000);\n    for (let i = 0; i < 10; i++) {\n      const resp = await axios.get(`${homeUrl}/api/orgs`, chimpy);\n      assert(resp.data.length === 100);\n    }\n  });\n\n  // Note the organization id which is being fetched.\n  it(\"GET /orgs/{oid} returns in a timely manner\", async function() {\n    this.timeout(600000);\n    for (let i = 0; i < 100; i++) {\n      await axios.get(`${homeUrl}/api/orgs/1`, chimpy);\n    }\n  });\n\n  // Note the organization id which is being fetched.\n  it(\"GET /orgs/{oid}/workspaces returns in a timely manner\", async function() {\n    this.timeout(600000);\n    for (let i = 0; i < 100; i++) {\n      await axios.get(`${homeUrl}/api/orgs/1/workspaces`, chimpy);\n    }\n  });\n\n  // Note the workspace ids which are being fetched.\n  it(\"GET /workspaces/{wid} returns in a timely manner\", async function() {\n    this.timeout(600000);\n    for (let wid = 0; wid < 100; wid++) {\n      await axios.get(`${homeUrl}/api/workspaces/${wid}`, chimpy);\n    }\n  });\n});\n"
  },
  {
    "path": "test/gen-server/ApiServerBugs.ts",
    "content": "import { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport * as chai from \"chai\";\n\nconst assert = chai.assert;\n\nlet server: TestServer;\nlet dbManager: HomeDBManager;\nlet homeUrl: string;\n\nconst charon = configForUser(\"Charon\");\nconst chimpy = configForUser(\"Chimpy\");\nconst kiwi = configForUser(\"Kiwi\");\n\nconst chimpyEmail = \"chimpy@getgrist.com\";\nconst kiwiEmail = \"kiwi@getgrist.com\";\nconst charonEmail = \"charon@getgrist.com\";\n\n// Tests specific complex scenarios that may have previously resulted in wrong behavior.\ndescribe(\"ApiServerBugs\", function() {\n  testUtils.setTmpLogLevel(\"error\");\n  let userRef: (email: string) => Promise<string>;\n\n  before(async function() {\n    server = new TestServer(this);\n    homeUrl = await server.start();\n    dbManager = server.dbManager;\n    userRef = email => server.dbManager.getUserByLogin(email).then(user => user.ref);\n  });\n\n  after(async function() {\n    await server.stop();\n  });\n\n  // Re-create a bug scenario in which users being in normal groups and guests groups at the\n  // same time resulted in them being dropped from groups arbitrarily on subsequent patches.\n  it(\"should properly handle users in multiple groups at once\", async function() {\n    // Add Chimpy/Charon/Kiwi to 'Herring' doc and set inheritance to none. They\n    // will become guests in the 'Fish' org along with their owner/viewer roles.\n    const fishOrg = await dbManager.testGetId(\"Fish\");\n    const herringDoc = await dbManager.testGetId(\"Herring\");\n    const delta1 = {\n      maxInheritedRole: null,\n      users: {\n        [kiwiEmail]: \"editors\",\n        [charonEmail]: \"viewers\",\n      },\n    };\n    let resp = await axios.patch(`${homeUrl}/api/docs/${herringDoc}/access`, {\n      delta: delta1,\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    // Ensure that the doc access is as expected.\n    resp = await axios.get(`${homeUrl}/api/docs/${herringDoc}/access`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, {\n      maxInheritedRole: null,\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: await userRef(chimpyEmail),\n        picture: null,\n        parentAccess: \"owners\",\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: await userRef(kiwiEmail),\n        picture: null,\n        parentAccess: \"editors\",\n        access: \"editors\",\n        isMember: true,\n      }, {\n        id: 3,\n        name: \"Charon\",\n        email: charonEmail,\n        ref: await userRef(charonEmail),\n        picture: null,\n        parentAccess: \"viewers\",\n        access: \"viewers\",\n        isMember: true,\n      }],\n    });\n\n    // Remove Charon from the 'Fish' org and ensure that Chimpy and Kiwi still have\n    // owner/editor roles on 'Fish'. Charon should no longer have guest access to the org.\n    const delta2 = {\n      users: {\n        [charonEmail]: null,\n      },\n    };\n    resp = await axios.patch(`${homeUrl}/api/orgs/${fishOrg}/access`, {\n      delta: delta2,\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/api/orgs/${fishOrg}/access`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, {\n      users: [{\n        id: 1,\n        name: \"Chimpy\",\n        email: chimpyEmail,\n        ref: await userRef(chimpyEmail),\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      }, {\n        id: 2,\n        name: \"Kiwi\",\n        email: kiwiEmail,\n        ref: await userRef(kiwiEmail),\n        picture: null,\n        access: \"editors\",\n        isMember: true,\n      }],\n    });\n\n    // Charon should no longer have access to the 'Herring' doc, now that user access\n    // is wiped entirely when removed from org.\n    resp = await axios.get(`${homeUrl}/api/docs/${herringDoc}`, charon);\n    assert.equal(resp.status, 403);\n    resp = await axios.get(`${homeUrl}/api/orgs/${fishOrg}`, charon);\n    assert.equal(resp.status, 403);\n\n    // Remove Kiwi as an editor from the 'Fish' org and ensure that Kiwi no longer has\n    // access to 'Fish' or 'Herring'\n    const delta3 = {\n      users: {\n        [kiwiEmail]: null,\n      },\n    };\n    resp = await axios.patch(`${homeUrl}/api/orgs/${fishOrg}/access`, {\n      delta: delta3,\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/api/docs/${herringDoc}`, kiwi);\n    assert.equal(resp.status, 403);\n    resp = await axios.get(`${homeUrl}/api/orgs/${fishOrg}`, kiwi);\n    assert.equal(resp.status, 403);\n\n    // Restore initial access.\n    const delta4 = {\n      maxInheritedRole: \"owners\",\n      users: {\n        [charonEmail]: null,\n        [kiwiEmail]: null,\n      },\n    };\n    resp = await axios.patch(`${homeUrl}/api/docs/${herringDoc}/access`, {\n      delta: delta4,\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    const delta5 = {\n      users: {\n        [kiwiEmail]: \"editors\",\n        [charonEmail]: \"viewers\",\n      },\n    };\n    resp = await axios.patch(`${homeUrl}/api/orgs/${fishOrg}/access`, {\n      delta: delta5,\n    }, chimpy);\n    assert.equal(resp.status, 200);\n  });\n});\n"
  },
  {
    "path": "test/gen-server/ApiSession.ts",
    "content": "import { UserProfile } from \"app/common/LoginSessionAPI\";\nimport { AccessOptionWithRole } from \"app/gen-server/entity/Organization\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { AxiosRequestConfig } from \"axios\";\nimport { assert } from \"chai\";\nimport omit from \"lodash/omit\";\n\nconst nobody: AxiosRequestConfig = {\n  responseType: \"json\",\n  validateStatus: (status: number) => true,\n};\n\ndescribe(\"ApiSession\", function() {\n  let server: TestServer;\n  let serverUrl: string;\n  testUtils.setTmpLogLevel(\"error\");\n\n  const regular = \"chimpy@getgrist.com\";\n\n  beforeEach(async function() {\n    this.timeout(5000);\n    server = new TestServer(this);\n    serverUrl = await server.start();\n  });\n\n  afterEach(async function() {\n    await server.stop();\n  });\n\n  it(\"GET /api/session/access/active returns user and org (with access)\", async function() {\n    const cookie = await server.getCookieLogin(\"nasa\", { email: regular, name: \"Chimpy\" });\n\n    const resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie);\n    assert.equal(resp.status, 200);\n    assert.sameMembers([\"user\", \"org\"], Object.keys(resp.data));\n    assert.deepEqual(omit(resp.data.user, [\"helpScoutSignature\", \"ref\"]), {\n      id: await server.dbManager.testGetId(\"Chimpy\"),\n      email: \"chimpy@getgrist.com\",\n      name: \"Chimpy\",\n      picture: null,\n    });\n    assert.deepEqual(omit(resp.data.org, [\"billingAccount\", \"createdAt\", \"updatedAt\"]), {\n      id: await server.dbManager.testGetId(\"NASA\"),\n      name: \"NASA\",\n      access: \"owners\",\n      domain: \"nasa\",\n      host: null,\n      owner: null,\n    });\n  });\n\n  it(\"GET /api/session/access/active returns org with billing account information\", async function() {\n    const cookie = await server.getCookieLogin(\"nasa\", { email: regular, name: \"Chimpy\" });\n\n    // Make Chimpy a billing account manager for NASA.\n    await server.addBillingManager(\"Chimpy\", \"nasa\");\n\n    const resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie);\n    assert.equal(resp.status, 200);\n    assert.hasAllKeys(resp.data.org, [\"id\", \"name\", \"access\", \"domain\", \"owner\", \"billingAccount\",\n      \"createdAt\", \"updatedAt\", \"host\"]);\n    assert.deepEqual(resp.data.org.billingAccount, {\n      id: 1,\n      individual: false,\n      inGoodStanding: true,\n      status: null,\n      externalId: null,\n      externalOptions: null,\n      paymentLink: null,\n      isManager: true,\n      paid: false,\n      features: null,\n      stripePlanId: null,\n      product: {\n        id: 1,\n        name: \"Free\",\n        features: {\n          workspaces: true,\n          vanityDomain: true,\n        },\n      },\n    });\n\n    // Check that internally we have access to stripe ids.\n    const userId = await server.dbManager.testGetId(\"Chimpy\") as number;\n    const org2 = await server.dbManager.getOrg({ userId }, \"nasa\");\n    assert.hasAllKeys(org2.data!.billingAccount,\n      [\"id\", \"individual\", \"inGoodStanding\", \"status\", \"stripeCustomerId\",\n        \"stripeSubscriptionId\", \"stripePlanId\", \"product\", \"paid\", \"isManager\",\n        \"externalId\", \"externalOptions\", \"features\", \"paymentLink\"]);\n  });\n\n  it(\"GET /api/session/access/active returns orgErr when org is forbidden\", async function() {\n    const cookie = await server.getCookieLogin(\"nasa\", { email: \"kiwi@getgrist.com\", name: \"Kiwi\" });\n\n    const resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie);\n    assert.equal(resp.status, 200);\n    assert.sameMembers([\"user\", \"org\", \"orgError\"], Object.keys(resp.data));\n    assert.deepEqual(omit(resp.data.user, [\"helpScoutSignature\", \"ref\"]), {\n      id: await server.dbManager.testGetId(\"Kiwi\"),\n      email: \"kiwi@getgrist.com\",\n      name: \"Kiwi\",\n      picture: null,\n    });\n    assert.equal(resp.data.org, null);\n    assert.deepEqual(resp.data.orgError, {\n      status: 403,\n      error: \"access denied\",\n    });\n  });\n\n  it(\"GET /api/session/access/active returns orgErr when org is non-existent\", async function() {\n    const cookie = await server.getCookieLogin(\"nasa\", { email: \"kiwi@getgrist.com\", name: \"Kiwi\" });\n\n    const resp = await axios.get(`${serverUrl}/o/boing/api/session/access/active`, cookie);\n    assert.equal(resp.status, 200);\n    assert.sameMembers([\"user\", \"org\", \"orgError\"], Object.keys(resp.data));\n    assert.deepEqual(omit(resp.data.user, [\"helpScoutSignature\", \"ref\"]), {\n      id: await server.dbManager.testGetId(\"Kiwi\"),\n      email: \"kiwi@getgrist.com\",\n      name: \"Kiwi\",\n      picture: null,\n    });\n    assert.equal(resp.data.org, null);\n    assert.deepEqual(resp.data.orgError, {\n      status: 404,\n      error: \"organization not found\",\n    });\n  });\n\n  it(\"POST /api/session/access/active can change user\", async function() {\n    // add two profiles\n    const cookie = await server.getCookieLogin(\"nasa\", { email: \"charon@getgrist.com\", name: \"Charon\" });\n    await server.getCookieLogin(\"pr\", { email: \"kiwi@getgrist.com\", name: \"Kiwi\" });\n\n    // pick kiwi profile for fish org\n    let resp = await axios.post(`${serverUrl}/o/fish/api/session/access/active`, {\n      email: \"kiwi@getgrist.com\",\n    }, cookie);\n    assert.equal(resp.status, 200);\n\n    // check kiwi profile stuck\n    resp = await axios.get(`${serverUrl}/o/fish/api/session/access/active`, cookie);\n    assert.equal(resp.data.user.email, \"kiwi@getgrist.com\");\n\n    // ... and that it didn't affect other org\n    resp = await axios.get(`${serverUrl}/o/nasa/api/session/access/active`, cookie);\n    assert.equal(resp.data.user.email, \"charon@getgrist.com\");\n\n    // pick charon profile for fish org\n    resp = await axios.post(`${serverUrl}/o/fish/api/session/access/active`, {\n      email: \"charon@getgrist.com\",\n    }, cookie);\n    assert.equal(resp.status, 200);\n\n    // check charon profile stuck\n    resp = await axios.get(`${serverUrl}/o/fish/api/session/access/active`, cookie);\n    assert.equal(resp.data.user.email, \"charon@getgrist.com\");\n\n    // make sure bogus profile for fish org fails\n    resp = await axios.post(`${serverUrl}/o/fish/api/session/access/active`, {\n      email: \"nonexistent@getgrist.com\",\n    }, cookie);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"GET /api/session/access/all returns users and orgs\", async function() {\n    const cookie = await server.getCookieLogin(\"nasa\", { email: \"charon@getgrist.com\", name: \"Charon\" });\n    await server.getCookieLogin(\"pr\", { email: \"kiwi@getgrist.com\", name: \"Kiwi\" });\n    const resp = await axios.get(`${serverUrl}/o/pr/api/session/access/all`, cookie);\n    assert.equal(resp.status, 200);\n    assert.sameMembers([\"users\", \"orgs\"], Object.keys(resp.data));\n    assert.sameMembers(resp.data.users.map((user: UserProfile) => user.name),\n      [\"Charon\", \"Kiwi\"]);\n    // In following list, 'Kiwiland' is the the merged personal org, and Chimpyland is not\n    // listed explicitly.\n    assert.sameMembers(resp.data.orgs.map((org: any) => org.name),\n      [\"Abyss\", \"Fish\", \"Flightless\", \"Kiwiland\", \"NASA\", \"Primately\"]);\n    const fish = resp.data.orgs.find((org: any) => org.name === \"Fish\");\n    const accessOptions: AccessOptionWithRole[] = fish.accessOptions;\n    assert.lengthOf(accessOptions, 2);\n    assert.equal(\"editors\", accessOptions.find(opt => opt.name === \"Kiwi\")!.access);\n    assert.equal(\"viewers\", accessOptions.find(opt => opt.name === \"Charon\")!.access);\n  });\n\n  it(\"GET /api/session/access/all functions with anonymous access\", async function() {\n    const resp = await axios.get(`${serverUrl}/o/pr/api/session/access/all`, nobody);\n    assert.equal(resp.status, 200);\n    // No orgs listed without access\n    assert.lengthOf(resp.data.orgs, 0);\n    // A single anonymous user\n    assert.lengthOf(resp.data.users, 1);\n    assert.equal(resp.data.users[0].anonymous, true);\n  });\n});\n"
  },
  {
    "path": "test/gen-server/AuthCaching.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { Deps } from \"app/server/lib/DocClients\";\nimport { FlexServer } from \"app/server/lib/FlexServer\";\nimport log from \"app/server/lib/log\";\nimport { MergedServer } from \"app/server/MergedServer\";\nimport { TestSession } from \"test/gen-server/apiUtils\";\nimport { createInitialDb, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport { configForUser, getGristConfig } from \"test/gen-server/testUtils\";\nimport { openClient } from \"test/server/gristClient\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { tmpdir } from \"os\";\nimport * as path from \"path\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\nimport * as sinon from \"sinon\";\n\nasync function createTestDir(ident: string): Promise<string> {\n  // Create a testDir of the form grist_test_{USER}_{SERVER_NAME}, removing any previous one.\n  const username = process.env.USER || \"nobody\";\n  const testDir = path.join(tmpdir(), `grist_test_${username}_${ident}`);\n  await fse.remove(testDir);\n  return testDir;\n}\n\nconst chimpy = configForUser(\"Chimpy\");\nconst kiwi = configForUser(\"Kiwi\");\nconst charon = configForUser(\"Charon\");\nconst chimpyEmail = \"chimpy@getgrist.com\";\nconst kiwiEmail = \"kiwi@getgrist.com\";\nconst charonEmail = \"charon@getgrist.com\";\n\ndescribe(\"AuthCaching\", function() {\n  this.timeout(10000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  let homeServer: FlexServer, docsServer: FlexServer;\n  let session: TestSession;\n  let homeUrl: string;\n  let helloDocId: string;\n\n  const sandbox = sinon.createSandbox();\n\n  before(async function() {\n    const testDir = process.env.TESTDIR || await createTestDir(\"authcaching\");\n    const testDocDir = path.join(testDir, \"data\");\n    await fse.mkdirs(testDocDir);\n    log.warn(`Test logs and data are at: ${testDir}/`);\n    setUpDB();\n    await createInitialDb();\n    process.env.GRIST_DATA_DIR = testDocDir;\n\n    const homeMS = await MergedServer.create(0, [\"home\"],\n      { logToConsole: false, externalStorage: false });\n    await homeMS.run();\n    homeServer = homeMS.flexServer;\n    homeUrl = homeServer.getOwnUrl();\n    const docsMS = await MergedServer.create(0, [\"docs\"],\n      { logToConsole: false, externalStorage: false });\n    await docsMS.run();\n    docsServer = docsMS.flexServer;\n\n    // Helpers for getting cookie-based logins.\n    session = new TestSession(homeServer);\n\n    // Copy a fixture doc to make it accessible with the given docId.\n    helloDocId = (await homeServer.getHomeDBManager().testGetId(\"Jupiter\")) as string;\n    const srcPath = path.resolve(testUtils.fixturesRoot, \"docs\", \"Hello.grist\");\n    await fse.copy(srcPath, path.resolve(docsServer.docsRoot, `${helloDocId}.grist`),\n      { dereference: true });\n\n    // Add Kiwi to 'viewers' for this doc.\n    const resp = await axios.patch(`${homeUrl}/api/docs/${helloDocId}/access`,\n      { delta: { users: { [kiwiEmail]: \"viewers\" } } },\n      chimpy);\n    assert.equal(resp.status, 200);\n  });\n\n  beforeEach(function() {\n    // Disables user presence features which cause additional cache hits / misses.\n    sandbox.stub(Deps, \"ENABLE_USER_PRESENCE\").value(false);\n  });\n\n  after(async function() {\n    delete process.env.GRIST_DATA_DIR;\n    await testUtils.captureLog(\"warn\", async () => {\n      await docsServer.close();\n      await homeServer.close();\n      await removeConnection();\n    });\n  });\n\n  afterEach(async function() {\n    sandbox.restore();\n  });\n\n  function getDocTracker(dbManager: HomeDBManager) {\n    const forced = sandbox.spy(dbManager, \"getDoc\");\n    const cached = sandbox.spy(dbManager, \"getDocAuthCached\");\n    const impl = sandbox.spy(dbManager, \"getDocImpl\");\n    function getCallCounts() {\n      return {\n        forced: forced.callCount,\n        misses: impl.callCount - forced.callCount,\n        hits: cached.callCount - (impl.callCount - forced.callCount),\n      };\n    }\n    function reset() {\n      forced.resetHistory();\n      cached.resetHistory();\n      impl.resetHistory();\n    }\n    function getAndReset() {\n      const res = getCallCounts();\n      reset();\n      return res;\n    }\n    return { getCallCounts, reset, getAndReset };\n  }\n\n  function flushCache() {\n    homeServer.getHomeDBManager().flushDocAuthCache();\n    docsServer.getHomeDBManager().flushDocAuthCache();\n  }\n\n  function getDocCallTracker() {\n    return {\n      home: getDocTracker(homeServer.getHomeDBManager()),\n      docs: getDocTracker(docsServer.getHomeDBManager()),\n    };\n  }\n\n  it(\"should not cache direct call for doc metadata\", async function() {\n    flushCache();\n    const getDocCalls = getDocCallTracker();\n\n    const resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}`, chimpy);\n    assert.equal(resp.data.name, \"Jupiter\");\n\n    // This is a metadata-only call, so only home server is involved.\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 1, misses: 0, hits: 0 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n\n    const resp2 = await axios.get(`${homeUrl}/api/docs/${helloDocId}`, chimpy);\n    assert.deepEqual(resp2.data, resp.data);\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 1, misses: 0, hits: 0 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n  });\n\n  it(\"should cache DocApi + DocApiForwarder calls\", async function() {\n    flushCache();\n    const getDocCalls = getDocCallTracker();\n    const resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, chimpy);\n    assert.deepInclude(resp.data, { E: [\"HELLO\", \"\", \"\", \"\"] });\n\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 1, hits: 0 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 1, hits: 0 });\n\n    // Try an endpoint requiring editing permissions.\n    const resp2 = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, { A: [\"Foo\"] }, chimpy);\n    assert.equal(resp2.status, 200);\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n\n    const resp3 = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, chimpy);\n    assert.deepInclude(resp3.data, { E: [\"HELLO\", \"\", \"\", \"\", \"FOO\"] });\n\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n  });\n\n  it(\"should cache DocAPI + DocApiForwarder no-access calls\", async function() {\n    flushCache();\n    const getDocCalls = getDocCallTracker();\n\n    // Kiwi has view-only access. Check that it's checked, and is cached too.\n    let resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, { A: [\"Bar\"] }, kiwi);\n    assert.equal(resp.status, 403);\n    assert.match(resp.data.error, /No write access/);\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 1, hits: 0 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 1, hits: 0 });\n\n    // Second call is cached, but otherwise identical.\n    resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, { A: [\"Bar\"] }, kiwi);\n    assert.equal(resp.status, 403);\n    assert.match(resp.data.error, /No write access/);\n    // The read/write distinction isn't checked by DocApiForwarder, so docsServer sees the request.\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n\n    // View access works.\n    resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, kiwi);\n    assert.deepInclude(resp.data, { E: [\"HELLO\", \"\", \"\", \"\", \"FOO\"] });\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n\n    // Charon has no access.\n    resp = await axios.get(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, charon);\n    assert.equal(resp.status, 403);\n    assert.match(resp.data.error, /No view access/);\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 1, hits: 0 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n\n    // ...or write access (but the check is cached).\n    resp = await axios.post(`${homeUrl}/api/docs/${helloDocId}/tables/Table1/data`, { A: [\"Bar\"] }, charon);\n    assert.equal(resp.status, 403);\n    assert.match(resp.data.error, /No view access/);\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n    // docsServer never sees the request.\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n  });\n\n  it(\"should not cache app.html endpoint\", async function() {\n    flushCache();\n    const getDocCalls = getDocCallTracker();\n    const cookie = await session.getCookieLogin(\"nasa\", { email: chimpyEmail, name: \"Chimpy\" });\n\n    const resp1 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie);\n\n    // gristConfig should include results of the getDoc call.\n    const gristConfig = getGristConfig(resp1.data);\n    assert.hasAnyKeys(gristConfig.getDoc, [helloDocId]);\n    assert.deepInclude(gristConfig.getDoc![helloDocId], { name: \"Jupiter\", id: helloDocId });\n\n    // All authentication and getDoc() call are made by homeServer, docsServer not yet in play\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 1, misses: 0, hits: 1 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n\n    // No caching on subsequent call because we force a fresh fetch for this endpoint.\n    const resp2 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie);\n    assert.deepEqual(getGristConfig(resp2.data).getDoc, gristConfig.getDoc);\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 1, misses: 0, hits: 1 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n  });\n\n  it(\"should cache openDoc and websocket methods\", async function() {\n    flushCache();\n    const getDocCalls = getDocCallTracker();\n\n    const cli = await openClient(docsServer, chimpyEmail, \"nasa\");\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    const openDoc = await cli.send(\"openDoc\", helloDocId);\n    assert.equal(openDoc.error, undefined);\n    assert.match(JSON.stringify(openDoc.data), /Table1/);\n\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 1, hits: 0 });\n\n    // Read access\n    const table = await cli.send(\"fetchTable\", 0, \"Table1\");\n    assert.includeMembers(table.data.tableData, [\"TableData\", \"Table1\"]);\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n\n    // Write access\n    const auaResult = await cli.send(\"applyUserActions\", 0,\n      [[\"UpdateRecord\", \"Table1\", 1, { A: \"auth-caching1\" }]]);\n    await delay(200); // give a little time for change broadcast.\n    assert.isNumber(auaResult.data.actionNum);\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 2 });\n\n    await cli.close();\n  });\n\n  it(\"should cache openDoc and websocket methods with access failures\", async function() {\n    flushCache();\n    const getDocCalls = getDocCallTracker();\n\n    // Repeat with a view-only user (Kiwi)\n    let cli = await openClient(docsServer, kiwiEmail, \"nasa\");\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    let openDoc = await cli.send(\"openDoc\", helloDocId);\n    assert.equal(openDoc.error, undefined);\n    assert.match(JSON.stringify(openDoc.data), /Table1/);\n\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 1, hits: 0 });\n\n    // Kiwi has read access\n    const table = await cli.send(\"fetchTable\", 0, \"Table1\");\n    assert.includeMembers(table.data.tableData, [\"TableData\", \"Table1\"]);\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n\n    // Kiwi has NO write access.\n    const auaResult = await cli.send(\"applyUserActions\", 0,\n      [[\"UpdateRecord\", \"Table1\", 1, { A: \"auth-caching2\" }]]);\n    assert.deepEqual(auaResult.error, \"No write access\");\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n\n    // Charon has no access at all\n    cli = await openClient(docsServer, charonEmail, \"nasa\");\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    openDoc = await cli.send(\"openDoc\", helloDocId);\n    assert.equal(openDoc.error, \"No view access\");\n\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 1, hits: 0 });\n    await cli.send(\"openDoc\", helloDocId);\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n\n    // Home server wasn't involved in this test case at all.\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n  });\n\n  it(\"should cache across different kinds of calls\", async function() {\n    // Fetch the document endpoint and follow with openDoc. Caching should apply.\n    flushCache();\n    const getDocCalls = getDocCallTracker();\n    const cookie = await session.getCookieLogin(\"nasa\", { email: chimpyEmail, name: \"Chimpy\" });\n\n    // app.html endpoint warms the cache for the home server.\n    const resp1 = await axios.get(`${homeUrl}/o/nasa/doc/${helloDocId}`, cookie);\n    const gristConfig = getGristConfig(resp1.data);\n    assert.hasAnyKeys(gristConfig.getDoc, [helloDocId]);\n    assert.deepInclude(gristConfig.getDoc![helloDocId], { name: \"Jupiter\", id: helloDocId });\n\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 1, misses: 0, hits: 1 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n\n    // openDoc call warms the cache for the doc-worker.\n    const cli = await openClient(docsServer, chimpyEmail, \"nasa\");\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    const openDoc = await cli.send(\"openDoc\", helloDocId);\n    assert.equal(openDoc.error, undefined);\n    assert.match(JSON.stringify(openDoc.data), /Table1/);\n\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 1, hits: 0 });\n\n    // the caching applies to API calls for the same doc/user/org combination.\n    const resp = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 1 });\n  });\n\n  it(\"should expire the cache after a timeout\", async function() {\n    this.timeout(10000);\n\n    // Make an API call; change access; check that after a while, the change is noticed.\n    flushCache();\n    const getDocCalls = getDocCallTracker();\n\n    // Connect up websockets for Kiwi and Charon.\n    const kiwiCli = await openClient(docsServer, kiwiEmail, \"nasa\");\n    assert.equal((await kiwiCli.readMessage()).type, \"clientConnect\");\n    const charonCli = await openClient(docsServer, charonEmail, \"nasa\");\n    assert.equal((await charonCli.readMessage()).type, \"clientConnect\");\n\n    // Kiwi has access, Charon doesn't.\n    let resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi);\n    let resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon);\n    assert.equal(resp1.status, 200);\n    assert.equal(resp2.status, 403);\n\n    // home server sees both calls, but only forwards one to the doc-worker.\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 2, hits: 0 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 1, hits: 0 });\n\n    assert.equal((await kiwiCli.send(\"openDoc\", helloDocId)).error, undefined);\n    assert.equal((await charonCli.send(\"openDoc\", helloDocId)).error, \"No view access\");\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 1, hits: 1 });\n\n    // Use Chimpy's access to change access for both.\n    const resp = await axios.patch(`${homeUrl}/o/nasa/api/docs/${helloDocId}/access`,\n      { delta: { users: { [kiwiEmail]: null, [charonEmail]: \"viewers\" } } },\n      chimpy);\n    assert.equal(resp.status, 200);\n\n    // Home's UserAPI methods don't call to getDoc() to check doc-level access, so access checks\n    // for Chimpy's patch-access call do not affect our counts.\n    assert.deepEqual(getDocCalls.home.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n    assert.deepEqual(getDocCalls.docs.getAndReset(), { forced: 0, misses: 0, hits: 0 });\n\n    // The change isn't visible immediately.\n    resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi);\n    resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon);\n    assert.equal(resp1.status, 200);\n    assert.equal(resp2.status, 403);\n\n    // But eventually it is. Should be within 5 seconds, we try up to 10.\n    let passed = false;\n    for (let i = 0; i < 50; i++) {\n      await delay(200);\n      try {\n        // Check if access changes are visible yet.\n        resp1 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, kiwi);\n        resp2 = await axios.get(`${homeUrl}/o/nasa/api/docs/${helloDocId}/tables/Table1/data`, charon);\n        assert.equal(resp1.status, 403);\n        assert.equal(resp2.status, 200);\n        assert.equal((await kiwiCli.send(\"openDoc\", helloDocId)).error, \"No view access\");\n        assert.equal((await charonCli.send(\"openDoc\", helloDocId)).error, undefined);\n        passed = true;\n        break;\n      } catch (err) {\n        continue;\n      }\n    }\n    assert.isTrue(passed);\n\n    const homeCalls = getDocCalls.home.getAndReset();\n    const docsCalls = getDocCalls.docs.getAndReset();\n    // There are many cache hits, but one set of misses that discovers the access changes.\n    assert.deepInclude(homeCalls, { forced: 0, misses: 2 });\n    assert.deepInclude(docsCalls, { forced: 0, misses: 2 });\n    assert.isAbove(homeCalls.hits, 10);\n    assert.isAbove(docsCalls.hits, 10);\n  });\n});\n"
  },
  {
    "path": "test/gen-server/SqliteSettings.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\n\ndescribe(\"SqliteSettings\", function() {\n  this.timeout(60000);\n  testUtils.setTmpLogLevel(\"warn\");\n\n  let home: TestServer;\n  let api: UserAPI;\n  let oldEnv: EnvironmentSnapshot;\n\n  for (const externalStorage of [true, false] as const) {\n    for (const mode of [\"\", \"sync\", \"wal\"] as const) {\n      describe(`mode ${mode || \"default\"}, externalStorage ${externalStorage}`, function() {\n        before(async function() {\n          this.timeout(60000);\n          oldEnv = new EnvironmentSnapshot();\n          process.env.GRIST_SQLITE_MODE = mode;\n          if (externalStorage && !process.env.GRIST_DOCS_MINIO_BUCKET &&\n            !process.env.GRIST_DOCS_S3_PREFIX) {\n            this.skip();\n          }\n          home = new TestServer(this);\n          await home.start([\"home\", \"docs\"], {}, {\n            externalStorage,\n          });\n          api = await home.createHomeApi(\"Support\", \"docs\");\n        });\n\n        after(async function() {\n          await home?.stop();\n          oldEnv.restore();\n        });\n\n        it(\"has expected files\", async function() {\n          const wsId = await api.newWorkspace({ name: \"test\" }, \"current\");\n          const docId = await api.newDoc({ name: \"test\" }, wsId);\n          await api.getDocAPI(docId).addRows(\"Table1\", {\n            A: [\"test1\", \"test2\"],\n          });\n          assert.deepEqual((await api.getDocAPI(docId).getRows(\"Table1\")).A,\n            [\"test1\", \"test2\"]);\n          const docPath = (await home.server.getDocManager().getActiveDoc(docId))?.docStorage.docPath;\n          if (!docPath) { throw new Error(\"doc should exist\"); }\n          assert.equal(await fse.pathExists(docPath), true);\n          assert.equal(await fse.pathExists(docPath + \"-shm\"), mode === \"wal\");\n          assert.equal(await fse.pathExists(docPath + \"-wal\"), mode === \"wal\");\n        });\n\n        it(\"forks correctly\", async function() {\n          const wsId = await api.newWorkspace({ name: \"test\" }, \"current\");\n          const docId = await api.newDoc({ name: \"test\" }, wsId);\n          await api.getDocAPI(docId).addRows(\"Table1\", {\n            A: [\"test1\", \"test2\"],\n          });\n          assert.deepEqual((await api.getDocAPI(docId).getRows(\"Table1\")).A,\n            [\"test1\", \"test2\"]);\n          const fork = await api.getDocAPI(docId).fork();\n          assert.include(fork.docId, \"~\");\n          assert.deepEqual((await api.getDocAPI(fork.docId).getRows(\"Table1\")).A,\n            [\"test1\", \"test2\"]);\n          const storage = home.server.getStorageManager();\n          const snapshots = storage.getSnapshotProgress(docId);\n          if (externalStorage) {\n            assert.isAtLeast(snapshots.pushes, 1);\n          } else {\n            assert.equal(snapshots.pushes, 0);\n          }\n        });\n\n        it(\"copies correctly\", async function() {\n          const wsId = await api.newWorkspace({ name: \"test\" }, \"current\");\n          const docId = await api.newDoc({ name: \"test\" }, wsId);\n          await api.getDocAPI(docId).addRows(\"Table1\", {\n            A: [\"test1\", \"test2\"],\n          });\n          assert.deepEqual((await api.getDocAPI(docId).getRows(\"Table1\")).A,\n            [\"test1\", \"test2\"]);\n          const copyDocId = await api.copyDoc(docId, wsId, {\n            documentName: \"copy\",\n          });\n          assert.notEqual(copyDocId, docId);\n          assert.deepEqual((await api.getDocAPI(copyDocId).getRows(\"Table1\")).A,\n            [\"test1\", \"test2\"]);\n        });\n      });\n    }\n  }\n});\n"
  },
  {
    "path": "test/gen-server/UpdateChecks.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { TelemetryEvent } from \"app/common/Telemetry\";\nimport { ILogMeta, LogMethods } from \"app/server/lib/LogMethods\";\nimport { Deps } from \"app/server/lib/UpdateManager\";\nimport { LatestVersion } from \"app/server/lib/UpdateManager\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport { Defer, serveSomething, Serving } from \"test/server/customUtil\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport * as chai from \"chai\";\nimport omit from \"lodash/omit\";\nimport * as sinon from \"sinon\";\n\nconst assert = chai.assert;\n\nlet testServer: TestServer;\n\nconst stop = async () => {\n  await testServer?.stop();\n  testServer = null as any;\n};\n\nlet homeUrl: string;\nlet dockerHub: Serving & { signal: () => Defer };\nlet sandbox: sinon.SinonSandbox;\nconst logMessages: [TelemetryEvent, ILogMeta][] = [];\n\nconst chimpy = configForUser(\"Chimpy\");\nconst headers = {\n  headers: { \"Content-Type\": \"application/json\" },\n};\n\n// Tests specific complex scenarios that may have previously resulted in wrong behavior.\ndescribe(\"UpdateChecks\", function() {\n  testUtils.setTmpLogLevel(\"error\");\n\n  this.timeout(\"20s\");\n\n  before(async function() {\n    testUtils.EnvironmentSnapshot.push();\n    dockerHub = await dummyDockerHub();\n    assert.equal((await fetch(dockerHub.url + \"/tags\")).status, 200);\n\n    // Start the server with correct configuration.\n    Object.assign(process.env, {\n      GRIST_TELEMETRY_LEVEL: \"full\",\n      GRIST_TEST_SERVER_DEPLOYMENT_TYPE: \"saas\",\n    });\n    sandbox = sinon.createSandbox();\n    sandbox.stub(Deps, \"REQUEST_TIMEOUT\").value(300);\n    sandbox.stub(Deps, \"RETRY_TIMEOUT\").value(400);\n    sandbox.stub(Deps, \"GOOD_RESULT_TTL\").value(500);\n    sandbox.stub(Deps, \"BAD_RESULT_TTL\").value(200);\n    sandbox.stub(Deps, \"DOCKER_ENDPOINT\").value(dockerHub.url + \"/tags\");\n    sandbox.stub(Deps, \"OLDEST_RECOMMENDED_VERSION\").value(\"8.8.8\");\n    sandbox.stub(LogMethods.prototype, \"rawLog\").callsFake((_level, _info, name, meta) => {\n      if (name !== \"checkedUpdateAPI\") {\n        return;\n      }\n      logMessages.push([name, meta]);\n    });\n\n    await startInProcess(this);\n  });\n\n  after(async function() {\n    sandbox.restore();\n    await dockerHub.shutdown();\n    await stop();\n    testUtils.EnvironmentSnapshot.pop();\n  });\n\n  afterEach(async function() {\n    await testServer.server.getUpdateManager().clear();\n  });\n\n  it(\"should read latest version as anonymous user in happy path\", async function() {\n    setEndpoint(dockerHub.url + \"/tags\");\n    const resp = await axios.get(`${homeUrl}/api/version`);\n    assert.equal(resp.status, 200, `${homeUrl}/api/version`);\n    const result: LatestVersion = resp.data;\n    assert.equal(result.latestVersion, \"10\");\n\n    // Also works in post method.\n    const resp2 = await axios.post(`${homeUrl}/api/version`, {}, headers);\n    assert.equal(resp2.status, 200);\n    assert.deepEqual(resp2.data, result);\n  });\n\n  it(\"should read latest version as existing user\", async function() {\n    setEndpoint(dockerHub.url + \"/tags\");\n    const resp = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(resp.status, 200);\n    const result: LatestVersion = resp.data;\n    assert.equal(result.latestVersion, \"10\");\n  });\n\n  it(\"passes errors to client\", async function() {\n    setEndpoint(dockerHub.url + \"/404\");\n    const resp = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: \"Not Found\" });\n  });\n\n  it(\"retries on 429\", async function() {\n    setEndpoint(dockerHub.url + \"/429\");\n\n    // First make sure that mock works.\n    assert.equal((await fetch(dockerHub.url + \"/429\")).status, 200);\n    assert.equal((await fetch(dockerHub.url + \"/429\")).status, 429);\n    assert.equal((await fetch(dockerHub.url + \"/429\")).status, 200);\n    assert.equal((await fetch(dockerHub.url + \"/429\")).status, 429);\n\n    // Now make sure that 4 subsequent requests are successful.\n    const check = async () => {\n      const resp = await axios.get(`${homeUrl}/api/version`, chimpy);\n      assert.equal(resp.status, 200);\n      const result: LatestVersion = resp.data;\n      assert.equal(result.latestVersion, \"10\");\n    };\n\n    await check();\n    await check();\n    await check();\n    await check();\n  });\n\n  it(\"throws when receives html\", async function() {\n    setEndpoint(dockerHub.url + \"/html\");\n    const resp = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(resp.status, 500);\n  });\n\n  it(\"caches data end errors\", async function() {\n    setEndpoint(dockerHub.url + \"/error\");\n    const r1 = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(r1.status, 500);\n    assert.equal(r1.data.error, \"1\");\n\n    const r2 = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(r2.status, 500);\n    assert.equal(r2.data.error, \"1\"); // since errors are cached for 200ms.\n\n    await delay(300); // error is cached for 200ms\n\n    const r3 = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(r3.status, 500);\n    assert.equal(r3.data.error, \"2\"); // second error is different, but still cached for 200ms.\n\n    const r4 = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(r4.status, 500);\n    assert.equal(r4.data.error, \"2\");\n\n    await delay(300);\n\n    // Now we should get correct result, but it will be cached for 500ms.\n\n    const r5 = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(r5.status, 200);\n    assert.equal(r5.data.latestVersion, \"3\"); // first successful response is cached for 2 seconds.\n\n    const r6 = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(r6.status, 200);\n    assert.equal(r6.data.latestVersion, \"3\");\n\n    await delay(700);\n\n    const r7 = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(r7.status, 200);\n    assert.equal(r7.data.latestVersion, \"4\");\n  });\n\n  it(\"can stop server when hangs\", async function() {\n    setEndpoint(dockerHub.url + \"/hang\");\n    const handCalled = dockerHub.signal();\n    const resp = axios\n      .get(`${homeUrl}/api/version`, chimpy)\n      .catch(err => ({ status: 999, data: null }));\n    await handCalled;\n    await stop();\n    const result = await resp;\n    assert.equal(result.status, 500);\n    assert.match(result.data.error, /aborted/);\n    // Start server again, and make sure it works.\n    await startInProcess(this);\n  });\n\n  it(\"dosent starts for non saas deployment\", async function() {\n    try {\n      testUtils.EnvironmentSnapshot.push();\n      Object.assign(process.env, {\n        GRIST_TEST_SERVER_DEPLOYMENT_TYPE: \"core\",\n      });\n      await stop();\n      await startInProcess(this);\n      const resp = await axios.get(`${homeUrl}/api/version`, chimpy);\n      assert.equal(resp.status, 404);\n    } finally {\n      testUtils.EnvironmentSnapshot.pop();\n    }\n\n    // Start normal one again.\n    await stop();\n    await startInProcess(this);\n  });\n\n  it(\"reports error when timeout happens\", async function() {\n    setEndpoint(dockerHub.url + \"/timeout\");\n    const resp = await axios.get(`${homeUrl}/api/version`, chimpy);\n    assert.equal(resp.status, 500);\n    assert.match(resp.data.error, /timeout/);\n  });\n\n  it(\"logs deploymentId, deploymentType, and currentVersion\", async function() {\n    logMessages.length = 0;\n    setEndpoint(dockerHub.url + \"/tags\");\n    const installationId = \"randomInstallationId\";\n    const deploymentType = \"test\";\n    const currentVersion = \"1.1.1\";\n    const resp = await axios.post(`${homeUrl}/api/version`, {\n      installationId,\n      deploymentType,\n      currentVersion,\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(logMessages.length, 1);\n    const [name, meta] = logMessages[0];\n    assert.equal(name, \"checkedUpdateAPI\");\n    assert.deepEqual(omit(meta, \"installationId\"), {\n      deploymentId: installationId,\n      deploymentType,\n      currentVersion,\n      eventName: \"checkedUpdateAPI\",\n      eventCategory: \"SelfHosted\",\n      eventSource: \"grist-saas\",\n      isInternalUser: true,\n    });\n  });\n\n  it(\"sets isCritical correctly\", async function() {\n    setEndpoint(dockerHub.url + \"/tags\");\n    const installationId = \"randomInstallationId\";\n    const deploymentType = \"test\";\n    async function testVersion(version: string, isCritical: boolean | \"fail\") {\n      const resp = await axios.post(`${homeUrl}/api/version`, {\n        installationId,\n        deploymentType,\n        currentVersion: version,\n      }, chimpy);\n      if (isCritical === \"fail\") {\n        assert.equal(resp.status, 400);\n      } else {\n        assert.equal(resp.status, 200);\n        assert.equal(resp.data.isCritical, isCritical);\n      }\n    }\n    // we've set 8.8.8 as the oldest recommended version.\n    await testVersion(\"1.1.1\", true);\n    await testVersion(\"v1.1.1\", true);\n    await testVersion(\"7.1.1\", true);\n    await testVersion(\"8.1.1\", true);\n    await testVersion(\"8.8.7\", true);\n    await testVersion(\"8.8.8\", false);\n    await testVersion(\"8.8.10\", false);\n    await testVersion(\"10.1.1\", false);\n    await testVersion(\"v10.9.0\", false);\n    await testVersion(\"11.1.1\", false);\n    await testVersion(\"10\", \"fail\");\n    await testVersion(\"goose\", \"fail\");\n    await testVersion(\"\", false);\n  });\n});\n\nasync function dummyDockerHub() {\n  let odds = 0;\n\n  // We offer a way to signal when request is received.\n  // Test can add a dummy promise using signal() method, and it is resolved\n  // when any request is received.\n  const signals: Defer[] = [];\n  let errorCount = 0;\n\n  const tempServer = await serveSomething((app) => {\n    app.use((req, res, next) => {\n      signals.forEach(p => p.resolve());\n      signals.length = 0;\n      next();\n    });\n    app.get(\"/404\", (_, res) => res.status(404).send(\"Not Found\").end());\n    app.get(\"/429\", (_, res) => {\n      if (odds++ % 2) {\n        res.status(429).send(\"Too Many Requests\");\n      } else {\n        res.json(SECOND_PAGE);\n      }\n    });\n    app.get(\"/timeout\", (_, res) => {\n      setTimeout(() => res.status(200).json(SECOND_PAGE), 500);\n    });\n\n    app.get(\"/error\", (_, res) => {\n      errorCount++;\n      // First 2 calls will return error, next will return numbers (3, 4, 5, 6, 7, 8, 9, 10)\n      if (errorCount <= 2) {\n        res.status(500).send(String(errorCount));\n      } else {\n        res.json(VERSION(errorCount));\n      }\n    });\n\n    app.get(\"/html\", (_, res) => {\n      res.status(200).send(\"<html></html>\");\n    });\n    app.get(\"/hang\", () => {});\n    app.get(\"/tags\", (_, res) => {\n      res.status(200).json(FIRST_PAGE(tempServer));\n    });\n    app.get(\"/next\", (_, res) => {\n      res.status(200).json(SECOND_PAGE);\n    });\n  });\n\n  return Object.assign(tempServer, {\n    signal() {\n      const p = new Defer();\n      signals.push(p);\n      return p;\n    },\n  });\n}\n\nfunction setEndpoint(endpoint: string) {\n  sinon.stub(Deps, \"DOCKER_ENDPOINT\").value(endpoint);\n}\n\nasync function startInProcess(context: Mocha.Context) {\n  testServer = new TestServer(context);\n  await testServer.start([\"home\"]);\n  homeUrl = testServer.serverUrl;\n}\n\nconst VERSION = (i: number) => ({\n  results: [\n    {\n      tag_last_pushed: \"2024-03-26T07:11:01.272113Z\",\n      name: \"stable\",\n      digest: \"stable\",\n    },\n    {\n      tag_last_pushed: \"2024-03-26T07:11:01.272113Z\",\n      name: i.toString(),\n      digest: \"stable\",\n    },\n  ],\n  count: 2,\n  next: null,\n});\n\nconst SECOND_PAGE = {\n  results: [\n    {\n      tag_last_pushed: \"2024-03-26T07:11:01.272113Z\",\n      name: \"stable\",\n      digest: \"stable\",\n    },\n    {\n      tag_last_pushed: \"2024-03-26T07:11:01.272113Z\",\n      name: \"latest\",\n      digest: \"latest\",\n    },\n    {\n      tag_last_pushed: \"2024-03-26T07:11:01.272113Z\",\n      name: \"1\",\n      digest: \"latest\",\n    },\n    {\n      tag_last_pushed: \"2024-03-26T07:11:01.272113Z\",\n      name: \"1\",\n      digest: \"stable\",\n    },\n    {\n      tag_last_pushed: \"2024-03-26T07:11:01.272113Z\",\n      name: \"9\",\n      digest: \"stable\",\n    },\n    {\n      tag_last_pushed: \"2024-03-26T07:11:01.272113Z\",\n      name: \"10\",\n      digest: \"stable\",\n    },\n  ],\n  count: 6,\n  next: null,\n};\n\nconst FIRST_PAGE = (tempServer: Serving) => ({\n  results: [],\n  count: 0,\n  next: tempServer.url + \"/next\",\n});\n"
  },
  {
    "path": "test/gen-server/apiUtils.ts",
    "content": "import { isAffirmative } from \"app/common/gutil\";\nimport { Role } from \"app/common/roles\";\nimport { UserAPIImpl, UserProfile } from \"app/common/UserAPI\";\nimport { AclRule, AclRuleDoc, AclRuleOrg, AclRuleWs } from \"app/gen-server/entity/AclRule\";\nimport { BillingAccountManager } from \"app/gen-server/entity/BillingAccountManager\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Resource } from \"app/gen-server/entity/Resource\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { getDocWorkerMap } from \"app/gen-server/lib/DocWorkerMap\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { SessionUserObj } from \"app/server/lib/BrowserSession\";\nimport * as docUtils from \"app/server/lib/docUtils\";\nimport { FlexServer, FlexServerOptions } from \"app/server/lib/FlexServer\";\nimport { MergedServer, ServerType } from \"app/server/MergedServer\";\nimport { createInitialDb, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport { setPlan, waitForAllNotifications } from \"test/gen-server/testUtils\";\nimport { fixturesRoot } from \"test/server/testUtils\";\n\nimport * as path from \"path\";\n\nimport axios from \"axios\";\nimport FormData from \"form-data\";\nimport fetch from \"node-fetch\";\n\nexport class TestServer {\n  public serverUrl: string;\n  public server: FlexServer;\n  public dbManager: HomeDBManager;\n  public defaultSession: TestSession;\n\n  constructor(context?: Mocha.Context) {\n    setUpDB(context);\n  }\n\n  public async start(servers: ServerType[] = [\"home\"],\n    options: FlexServerOptions = {},\n    {\n      seedData = true,\n      externalStorage = false,\n    } = {}): Promise<string> {\n    await createInitialDb(undefined, seedData ? true : \"migrateOnly\");\n    const mergedServer = await MergedServer.create(0, servers, { logToConsole: isAffirmative(process.env.DEBUG),\n      externalStorage, ...options });\n    this.server = mergedServer.flexServer;\n    await mergedServer.run();\n    this.serverUrl = this.server.getOwnUrl();\n    this.dbManager = this.server.getHomeDBManager();\n    this.defaultSession = new TestSession(this.server);\n    return this.serverUrl;\n  }\n\n  public async stop() {\n    await this.server.close();\n    // Wait a few seconds for any late notifications to finish up.\n    // TypeORM doesn't give us a very clean way to shut down the db connection,\n    // and node-sqlite3 has become fussier about this, and in regular tests\n    // we substitute sqlite for postgres.\n    if (this.server.hasNotifier()) {\n      await waitForAllNotifications(this.server, 3000);\n    }\n    await removeConnection();\n  }\n\n  // Set up a profile for the given org, and return an axios configuration to\n  // access the api via cookies with that profile.  Leave profile null for anonymous\n  // access.\n  public async getCookieLogin(\n    org: string,\n    profile: UserProfile | null,\n    options: { clearCache?: boolean, sessionProps?: Partial<SessionUserObj> } = {},\n  ) {\n    return this.defaultSession.getCookieLogin(org, profile, options);\n  }\n\n  // add named user as billing manager to org (identified by domain)\n  public async addBillingManager(userName: string, orgDomain: string) {\n    const ents = this.dbManager.connection.createEntityManager();\n    const org = await ents.findOne(Organization, {\n      relations: [\"billingAccount\"],\n      where: { domain: orgDomain },\n    });\n    const user = await ents.findOne(User, { where: { name: userName } });\n    const manager = new BillingAccountManager();\n    manager.user = user!;\n    manager.billingAccount = org!.billingAccount;\n    await manager.save();\n  }\n\n  // change a user's personal org to a different product (by default, one that allows anything)\n  public async upgradePersonalOrg(userName: string, productName: string = \"Free\") {\n    const user = await User.findOne({ where: { name: userName } });\n    if (!user) { throw new Error(`Could not find user ${userName}`); }\n    const org = await Organization.findOne({\n      relations: [\"billingAccount\", \"owner\"],\n      where: { owner: { id: user.id } },  // for some reason finding by name generates wrong SQL.\n    });\n    if (!org) { throw new Error(`Could not find personal org of ${userName}`); }\n    await setPlan(this.dbManager, org, productName);\n  }\n\n  // Get an api object for making requests for the named user with the named org.\n  // Careful: all api objects using cookie access will be in the same session.\n  public async createHomeApi(userName: string, orgDomain: string,\n    useApiKey: boolean = false,\n    checkAccess: boolean = true): Promise<UserAPIImpl> {\n    return this.defaultSession.createHomeApi(userName, orgDomain, useApiKey, checkAccess);\n  }\n\n  // Get a TestSession representing a distinct session for communicating with the server.\n  public newSession() {\n    return new TestSession(this.server);\n  }\n\n  /**\n   * Lists every resource a user is linked to via direct group\n   * membership.  The same resource can be listed multiple times if\n   * the user is in multiple of its groups (e.g. viewers and guests).\n   * A resource the user has access to will not be listed at all if\n   * access is granted indirectly (e.g. a doc the user is not linked\n   * to via direct group membership won't be listed even if user is in\n   * owners group of workspace containing that doc, and the doc\n   * inherits access from the workspace).\n   */\n  public async listUserMemberships(email: string): Promise<ResourceWithRole[]> {\n    const rules = await this.dbManager.connection.createQueryBuilder()\n      .select(\"acl_rules\")\n      .from(AclRule, \"acl_rules\")\n      .leftJoinAndSelect(\"acl_rules.group\", \"groups\")\n      .leftJoin(\"groups.memberUsers\", \"users\")\n      .leftJoin(\"users.logins\", \"logins\")\n      .where(\"logins.email = :email\", { email })\n      .getMany();\n    return Promise.all(rules.map(this._getResourceName.bind(this)));\n  }\n\n  /**\n   * Lists every user with the specified role on the given org.  Only\n   * roles set by direct group membership are listed, nothing indirect\n   * is included.\n   */\n  public async listOrgMembership(domain: string, role: Role | null): Promise<User[]> {\n    return this._listMembers(role)\n      .leftJoin(Organization, \"orgs\", \"orgs.id = acl_rules.org_id\")\n      .andWhere(\"orgs.domain = :domain\", { domain })\n      .getMany();\n  }\n\n  /**\n   * Lists every user with the specified role on the given workspace.  Only\n   * roles set by direct group membership are listed, nothing indirect\n   * is included.\n   */\n  public async listWorkspaceMembership(wsId: number, role: Role | null): Promise<User[]> {\n    return this._listMembers(role)\n      .leftJoin(Workspace, \"workspaces\", \"workspaces.id = acl_rules.workspace_id\")\n      .andWhere(\"workspaces.id = :wsId\", { wsId })\n      .getMany();\n  }\n\n  // check that the database structure looks sane.\n  public async sanityCheck() {\n    const badGroups = await this.getBadGroupLinks();\n    if (badGroups.length) {\n      throw new Error(`badGroups: ${JSON.stringify(badGroups)}`);\n    }\n  }\n\n  // Find instances of guests and members used in inheritance.\n  public async getBadGroupLinks(): Promise<Group[]> {\n    // guests and members should never be in other groups, or have groups.\n    return this.dbManager.connection.createQueryBuilder()\n      .select(\"groups\")\n      .from(Group, \"groups\")\n      .innerJoinAndSelect(\"groups.memberGroups\", \"memberGroups\")\n      .where(`memberGroups.name IN ('guests', 'members')`)\n      .orWhere(`groups.name IN ('guests', 'members')`)\n      .getMany();\n  }\n\n  /**\n   * Copy a fixture doc (e.g. \"Hello.grist\", no path needed) and make\n   * it accessible with the given docId (no \".grist\" extension or path).\n   */\n  public async copyFixtureDoc(srcName: string, docId: string) {\n    const docsRoot = this.server.docsRoot;\n    const srcPath = path.resolve(fixturesRoot, \"docs\", srcName);\n    await docUtils.copyFile(srcPath, path.resolve(docsRoot, `${docId}.grist`));\n  }\n\n  public getWorkStore() {\n    return getDocWorkerMap();\n  }\n\n  /**\n   * Looks up the resource related to an aclRule.\n   * TODO: rework AclRule to automate this kind of step.\n   */\n  private async _getResourceName(aclRule: AclRule): Promise<ResourceWithRole> {\n    const con = this.dbManager.connection.manager;\n    let res: Document | Workspace | Organization | null;\n    if (aclRule instanceof AclRuleDoc) {\n      res = await con.findOne(Document, { where: { id: aclRule.docId } });\n    } else if (aclRule instanceof AclRuleWs) {\n      res = await con.findOne(Workspace, { where: { id: aclRule.workspaceId } });\n    } else if (aclRule instanceof AclRuleOrg) {\n      res = await con.findOne(Organization, { where: { id: aclRule.orgId } });\n    } else {\n      throw new Error(\"unknown type\");\n    }\n    if (!res) { throw new Error(\"could not find resource\"); }\n    return { res, role: aclRule.group.name };\n  }\n\n  /**\n   * Lists users and the groups/aclRules they are members of.\n   * Filters for groups of the specified name.\n   */\n  private _listMembers(role: Role | null) {\n    let q = this.dbManager.connection.createQueryBuilder()\n      .select(\"users\")\n      .from(User, \"users\")\n      .leftJoin(\"users.groups\", \"groups\")\n      .leftJoin(\"groups.aclRule\", \"acl_rules\")\n      .leftJoinAndSelect(\"users.logins\", \"logins\");\n    if (role) {\n      q = q.andWhere(\"groups.name = :role\", { role });\n    }\n    return q;\n  }\n}\n\n/**\n * A distinct session.  Any api objects created with this that use cookies will share\n * the same session as each other, and be in a distinct session to other TestSessions.\n *\n * Calling createHomeApi on the server object directly results in api objects that are\n * all within the same session, which is not always desirable.  Api key access can be\n * used to work around this, but that can also be awkward.\n */\nexport class TestSession {\n  public headers: { [key: string]: string };\n\n  constructor(public home: FlexServer) {\n    this.headers = {};\n  }\n\n  // Set up a profile for the given org, and return an axios configuration to\n  // access the api via cookies with that profile.  Leave profile null for anonymous\n  // access.\n  public async getCookieLogin(\n    org: string,\n    profile: UserProfile | null,\n    { clearCache, sessionProps}: { clearCache?: boolean, sessionProps?: Partial<SessionUserObj> } = {},\n  ) {\n    const resp = await axios.get(`${this.home.getOwnUrl()}/test/session`,\n      { validateStatus: s => s < 400, headers: this.headers });\n    const cookie = this.headers.Cookie || resp.headers[\"set-cookie\"]![0];\n    const cid = decodeURIComponent(cookie.split(\"=\")[1].split(\";\")[0]);\n    const sessionId = this.home.getSessions().getSessionIdFromCookie(cid);\n    const scopedSession = this.home.getSessions().getOrCreateSession(sessionId as string, org, \"\");\n    await scopedSession.updateUserProfile({} as any, profile);\n    if (sessionProps) { await scopedSession.updateUser({} as any, sessionProps); }\n    if (clearCache) { this.home.getSessions().clearCacheIfNeeded(); }\n    this.headers.Cookie = cookie;\n    return {\n      validateStatus: (_status: number) => true,\n      headers: {\n        \"Cookie\": cookie,\n        \"X-Requested-With\": \"XMLHttpRequest\",\n      },\n    };\n  }\n\n  // get an api object for making requests for the named user with the named org.\n  public async createHomeApi(userName: string, orgDomain: string,\n    useApiKey: boolean = false,\n    checkAccess: boolean = true): Promise<UserAPIImpl> {\n    const headers: { [key: string]: string } = {};\n    if (userName !== \"anonymous\") {\n      if (useApiKey) {\n        headers.Authorization = \"Bearer api_key_for_\" + userName.toLowerCase();\n      } else {\n        const cookie = await this.getCookieLogin(orgDomain, {\n          email: `${userName.toLowerCase()}@getgrist.com`,\n          name: userName,\n        });\n        headers.Cookie = cookie.headers.Cookie;\n      }\n    }\n    const api = new UserAPIImpl(`${this.home.getOwnUrl()}/o/${orgDomain}`, {\n      fetch: fetch as any,\n      headers,\n      newFormData: () => new FormData() as any,\n    });\n    // Make sure api is functioning, and create user if this is their first time to hit API.\n    if (checkAccess) { await api.getOrg(\"current\"); }\n    return api;\n  }\n}\n\n/**\n * A resource and the name of the group associated with it that the user is in.\n */\nexport interface ResourceWithRole {\n  res: Resource;\n  role: string;\n}\n"
  },
  {
    "path": "test/gen-server/lib/DocApiForwarder.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { DocApiForwarder } from \"app/gen-server/lib/DocApiForwarder\";\nimport { DocWorkerMap, getDocWorkerMap } from \"app/gen-server/lib/DocWorkerMap\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { addRequestUser } from \"app/server/lib/Authorizer\";\nimport { jsonErrorHandler } from \"app/server/lib/expressWrap\";\nimport { createDummyGristServer } from \"app/server/lib/GristServer\";\nimport log from \"app/server/lib/log\";\nimport { createInitialDb, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { Server } from \"http\";\nimport { AddressInfo } from \"net\";\n\nimport axios, { AxiosResponse } from \"axios\";\nimport { fromCallback } from \"bluebird\";\nimport { assert } from \"chai\";\nimport express from \"express\";\nimport FormData from \"form-data\";\nimport defaultsDeep from \"lodash/defaultsDeep\";\nimport morganLogger from \"morgan\";\nimport sinon from \"sinon\";\n\nconst chimpy = configForUser(\"Chimpy\");\nconst kiwi = configForUser(\"kiwi\");\n\nconst logToConsole = false;\n\nasync function createServer(app: express.Application, name: string) {\n  let server: Server;\n  if (logToConsole) {\n    app.use(morganLogger((...args: any[]) => {\n      return `${log.timestamp()} ${name} ${morganLogger.dev(...args)}`;\n    }));\n  }\n  app.set(\"port\", 0);\n  await fromCallback((cb: any) => server = app.listen(app.get(\"port\"), \"localhost\", cb));\n  log.info(`${name} listening ${getUrl(server!)}`);\n  return server!;\n}\n\nfunction getUrl(server: Server) {\n  return `http://localhost:${(server.address() as AddressInfo).port}`;\n}\n\ndescribe(\"DocApiForwarder\", function() {\n  testUtils.setTmpLogLevel(\"error\");\n\n  let homeServer: Server;\n  let docWorker: Server;\n  let resp: AxiosResponse;\n  let homeUrl: string;\n  let dbManager: HomeDBManager;\n  const docWorkerStub = sinon.stub();\n\n  before(async function() {\n    setUpDB(this);\n    dbManager = new HomeDBManager();\n    await dbManager.connect();\n    await createInitialDb(dbManager.connection);\n    await dbManager.initializeSpecialIds();\n\n    // create cheap doc worker\n    let app = express();\n    docWorker = await createServer(app, \"docw\");\n    app.use(express.json());\n    app.use(docWorkerStub);\n\n    // create cheap home server\n    app = express();\n    homeServer = await createServer(app, \"home\");\n    homeUrl = getUrl(homeServer);\n\n    // stubs doc worker map\n    const docWorkerMapStub = sinon.createStubInstance(DocWorkerMap);\n    docWorkerMapStub.assignDocWorker.returns(Promise.resolve({\n      docWorker: {\n        internalUrl: getUrl(docWorker) + \"/dw/foo\",\n        publicUrl: \"\",\n        id: \"\",\n      },\n      docMD5: null,\n      isActive: true,\n    }));\n\n    // create and register forwarder\n    const docApiForwarder = new DocApiForwarder(docWorkerMapStub, dbManager, null as any);\n    app.use(\"/api\", addRequestUser.bind(null, dbManager, getDocWorkerMap().getPermitStore(\"internal\"),\n      { gristServer: createDummyGristServer() } as any));\n    docApiForwarder.addEndpoints(app);\n    app.use(\"/api\", jsonErrorHandler);\n  });\n\n  after(async function() {\n    await removeConnection();\n    homeServer.close();\n    docWorker.close();\n    dbManager.flushDocAuthCache();    // To avoid hanging up exit from tests.\n  });\n\n  beforeEach(() => {\n    docWorkerStub.resetHistory();\n    docWorkerStub.callsFake((req: any, res: any) => res.status(200).json(\"mango tree\"));\n  });\n\n  it(\"should forward GET /api/docs/:did/tables/:tid/data\", async function() {\n    resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data, \"mango tree\");\n    assert(docWorkerStub.calledOnce);\n    const req = docWorkerStub.getCall(0).args[0];\n    assert.equal(req.get(\"Authorization\"), \"Bearer api_key_for_chimpy\");\n    assert.equal(req.get(\"Content-Type\"), \"application/json\");\n    assert.equal(req.originalUrl, \"/dw/foo/api/docs/sampledocid_16/tables/table1/data\");\n    assert.equal(req.method, \"GET\");\n  });\n\n  it(\"should forward GET /api/docs/:did/tables/:tid/data?filter=<...>\", async function() {\n    const filter = encodeURIComponent(JSON.stringify({ FOO: [\"bar\"] })); // => %7B%22FOO%22%3A%5B%22bar%22%5D%7D\n    resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data?filter=${filter}`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data, \"mango tree\");\n    assert(docWorkerStub.calledOnce);\n    const req = docWorkerStub.getCall(0).args[0];\n    assert.equal(req.get(\"Authorization\"), \"Bearer api_key_for_chimpy\");\n    assert.equal(req.get(\"Content-Type\"), \"application/json\");\n    assert.equal(req.originalUrl,\n      \"/dw/foo/api/docs/sampledocid_16/tables/table1/data?filter=%7B%22FOO%22%3A%5B%22bar%22%5D%7D\");\n    assert.equal(req.method, \"GET\");\n  });\n\n  it(\"should deny user without view permissions\", async function() {\n    resp = await axios.get(`${homeUrl}/api/docs/sampledocid_13/tables/table1/data`, kiwi);\n    assert.equal(resp.status, 403);\n    assert.deepEqual(resp.data, { error: \"No view access\" });\n    assert.equal(docWorkerStub.callCount, 0);\n  });\n\n  it(\"should forward POST /api/docs/:did/tables/:tid/data\", async function() {\n    resp = await axios.post(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, { message: \"golden pears\" }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data, \"mango tree\");\n    assert(docWorkerStub.calledOnce);\n    const req = docWorkerStub.getCall(0).args[0];\n    assert.equal(req.get(\"Authorization\"), \"Bearer api_key_for_chimpy\");\n    assert.equal(req.get(\"Content-Type\"), \"application/json\");\n    assert.equal(req.originalUrl, \"/dw/foo/api/docs/sampledocid_16/tables/table1/data\");\n    assert.equal(req.method, \"POST\");\n    assert.deepEqual(req.body, { message: \"golden pears\" });\n  });\n\n  it(\"should forward PATCH /api/docs/:did/tables/:tid/data\", async function() {\n    resp = await axios.patch(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`,\n      { message: \"golden pears\" }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data, \"mango tree\");\n    assert(docWorkerStub.calledOnce);\n    const req = docWorkerStub.getCall(0).args[0];\n    assert.equal(req.get(\"Authorization\"), \"Bearer api_key_for_chimpy\");\n    assert.equal(req.get(\"Content-Type\"), \"application/json\");\n    assert.equal(req.originalUrl, \"/dw/foo/api/docs/sampledocid_16/tables/table1/data\");\n    assert.equal(req.method, \"PATCH\");\n    assert.deepEqual(req.body, { message: \"golden pears\" });\n  });\n\n  it(\"should forward POST /api/docs/:did/attachments\", async function() {\n    const formData = new FormData();\n    formData.append(\"upload\", \"abcdef\", \"hello.png\");\n    resp = await axios.post(`${homeUrl}/api/docs/sampledocid_16/attachments`, formData,\n      defaultsDeep({ headers: formData.getHeaders() }, chimpy));\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.headers[\"content-type\"], \"application/json; charset=utf-8\");\n    assert.deepEqual(resp.data, \"mango tree\");\n    assert(docWorkerStub.calledOnce);\n    const req = docWorkerStub.getCall(0).args[0];\n    assert.equal(req.get(\"Authorization\"), \"Bearer api_key_for_chimpy\");\n    assert.match(req.get(\"Content-Type\"), /^multipart\\/form-data; boundary=/);\n    assert.equal(req.originalUrl, \"/dw/foo/api/docs/sampledocid_16/attachments\");\n    assert.equal(req.method, \"POST\");\n  });\n\n  it(\"should forward GET /api/docs/:did/attachments/:attId/download\", async function() {\n    docWorkerStub.callsFake((_req: any, res: any) =>\n      res.status(200)\n        .type(\".png\")\n        .set(\"Content-Disposition\", 'attachment; filename=\"hello.png\"')\n        .set(\"Cache-Control\", \"private, max-age=3600\")\n        .send(Buffer.from(\"abcdef\")));\n    resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/attachments/123/download`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.headers[\"content-type\"], \"image/png\");\n    assert.deepEqual(resp.headers[\"content-disposition\"], 'attachment; filename=\"hello.png\"');\n    assert.deepEqual(resp.headers[\"cache-control\"], \"private, max-age=3600\");\n    assert.deepEqual(resp.data, \"abcdef\");\n    assert(docWorkerStub.calledOnce);\n    const req = docWorkerStub.getCall(0).args[0];\n    assert.equal(req.get(\"Authorization\"), \"Bearer api_key_for_chimpy\");\n    assert.equal(req.get(\"Content-Type\"), \"application/json\");\n    assert.equal(req.originalUrl, \"/dw/foo/api/docs/sampledocid_16/attachments/123/download\");\n    assert.equal(req.method, \"GET\");\n  });\n\n  it(\"should forward error message on failure\", async function() {\n    docWorkerStub.callsFake((_req: any, res: any) => res.status(500).send({ error: \"internal error\" }));\n    resp = await axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`, chimpy);\n    assert.equal(resp.status, 500);\n    assert.deepEqual(resp.data, { error: \"internal error\" });\n    assert(docWorkerStub.calledOnce);\n    const req = docWorkerStub.getCall(0).args[0];\n    assert.equal(req.get(\"Authorization\"), \"Bearer api_key_for_chimpy\");\n    assert.equal(req.get(\"Content-Type\"), \"application/json\");\n    assert.equal(req.originalUrl, \"/dw/foo/api/docs/sampledocid_16/tables/table1/data\");\n    assert.equal(req.method, \"GET\");\n  });\n\n  it(\"should notice aborted requests and cancel forwarded ones\", async function() {\n    let requestReceived: Function;\n    let closeReceived: Function;\n    let requestDone: Function;\n    const checkIsClosed = sinon.spy();\n    const promiseForRequestReceived = new Promise((r) => { requestReceived = r; });\n    const promiseForCloseReceived = new Promise((r) => { closeReceived = r; });\n    const promiseForRequestDone = new Promise((r) => { requestDone = r; });\n    docWorkerStub.callsFake(async (req: any, res: any) => {\n      req.on(\"close\", closeReceived);\n      requestReceived();\n      await Promise.race([promiseForCloseReceived, delay(100)]);\n      checkIsClosed(req.closed || req.aborted);\n      res.status(200).json(\"fig tree?\");\n      requestDone();\n    });\n    const CancelToken = axios.CancelToken;\n    const source = CancelToken.source();\n    const response = axios.get(`${homeUrl}/api/docs/sampledocid_16/tables/table1/data`,\n      { ...chimpy, cancelToken: source.token });\n    await promiseForRequestReceived;\n    source.cancel(\"cancelled for testing\");\n    await assert.isRejected(response, /cancelled for testing/);\n    await promiseForRequestDone;\n    sinon.assert.calledOnce(checkIsClosed);\n    assert.deepEqual(checkIsClosed.args, [[true]]);\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/DocPrefs.ts",
    "content": "import { FullUser } from \"app/common/LoginSessionAPI\";\nimport { DocPrefs } from \"app/common/Prefs\";\nimport { DocScope, HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"DocPrefs\", function() {\n  this.timeout(60000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  const org = \"docprefs\";\n  let dbManager: HomeDBManager;\n  let server: TestServer;\n\n  const everyoneEmail = \"everyone@getgrist.com\";\n  const users = {\n    owner: { email: \"chimpy@getgrist.com\" } as FullUser,\n    editor: { email: \"kiwi@getgrist.com\" } as FullUser,\n    viewer: { email: \"charon@getgrist.com\" } as FullUser,\n    nonmember: { email: \"ham@getgrist.com\" } as FullUser,\n  };\n\n  const docs: {\n    privateDoc: string;\n    publicDoc: string;\n  } = {} as any;\n\n  before(async function setUp(this: Mocha.Context) {\n    server = new TestServer(this);\n    await server.start([\"home\", \"docs\"]);\n    dbManager = server.dbManager;\n\n    // Fill in user info, to use throughout the test.\n    for (const profile of Object.values(users)) {\n      Object.assign(profile, await dbManager.getExistingUserByLogin(profile.email));\n    }\n\n    // Create an org, with a couple of documents with different sharing.\n    const api = await server.createHomeApi(\"chimpy\", \"docs\");\n    await api.newOrg({ name: \"docprefs\", domain: org });\n    const ws1 = await api.newWorkspace({ name: \"ws1\" }, org);\n    docs.privateDoc = await api.newDoc({ name: \"docPrivate\" }, ws1);\n    docs.publicDoc = await api.newDoc({ name: \"docPublic\" }, ws1);\n\n    await api.updateDocPermissions(docs.privateDoc, {\n      users: {\n        [users.viewer.email]: \"viewers\",\n        [users.editor.email]: \"editors\",\n      },\n    });\n    await api.updateDocPermissions(docs.publicDoc, {\n      users: {\n        [users.viewer.email]: \"viewers\",\n        [users.editor.email]: \"editors\",\n        [everyoneEmail]: \"editors\",\n      },\n    });\n  });\n\n  after(async function tearDown() {\n    const api = await server.createHomeApi(\"chimpy\", \"docs\");\n    await api.deleteOrg(org);\n    await server.stop();\n  });\n\n  function samplePrefs(num: number): DocPrefs {\n    return { foo: { num } } as DocPrefs;\n  }\n\n  for (const docName of [\"privateDoc\", \"publicDoc\"] as const) {\n    describe(docName, function() {\n      function getScope(user: keyof typeof users): DocScope {\n        return { userId: users[user].id, org, urlId: docs[docName] };\n      }\n\n      it(\"should support default and per-user prefs\", async function() {\n        await dbManager.setDocPrefs(getScope(\"owner\"),\n          { docDefaults: samplePrefs(1), currentUser: samplePrefs(2) });\n\n        // Check that a viewer can see this, and can set their own overrides.\n        assert.deepEqual(await dbManager.getDocPrefs(getScope(\"viewer\")),\n          { docDefaults: samplePrefs(1), currentUser: {} });\n        await dbManager.setDocPrefs(getScope(\"viewer\"), { currentUser: samplePrefs(3) });\n\n        // Check that various users see correct state.\n        assert.deepEqual(await dbManager.getDocPrefs(getScope(\"owner\")),\n          { docDefaults: samplePrefs(1), currentUser: samplePrefs(2) });\n        assert.deepEqual(await dbManager.getDocPrefs(getScope(\"editor\")),\n          { docDefaults: samplePrefs(1), currentUser: {} });\n        assert.deepEqual(await dbManager.getDocPrefs(getScope(\"viewer\")),\n          { docDefaults: samplePrefs(1), currentUser: samplePrefs(3) });\n      });\n\n      it(\"should fetch correctly merged prefs for a list of users\", async function() {\n        // Note: this is stateful: we are starting with the prefs set by the previous test case.\n        const userIds = [users.owner.id, users.editor.id, users.viewer.id];\n        assert.deepEqual(Array.from(await dbManager.getDocPrefsForUsers(docs[docName], userIds)), [\n          [null, samplePrefs(1)], // doc defaults\n          [users.owner.id, samplePrefs(2)],\n          [users.viewer.id, samplePrefs(3)],\n        ]);\n\n        assert.deepEqual(Array.from(await dbManager.getDocPrefsForUsers(docs[docName], [users.editor.id])), [\n          [null, samplePrefs(1)], // doc defaults\n        ]);\n\n        assert.deepEqual(Array.from(await dbManager.getDocPrefsForUsers(docs[docName], \"any\")), [\n          [null, samplePrefs(1)], // doc defaults\n          [users.owner.id, samplePrefs(2)],\n          [users.viewer.id, samplePrefs(3)],\n        ]);\n      });\n\n      it(\"should check access for prefs\", async function() {\n        // Note: this is stateful: we are starting with the prefs set by the previous test case.\n        const updateRej = /Only document owners may update document prefs/;\n\n        // Non-owners cannot change defaults.\n        await assert.isRejected(dbManager.setDocPrefs(getScope(\"viewer\"), { docDefaults: samplePrefs(4) }),\n          updateRej);\n        await assert.isRejected(dbManager.setDocPrefs(\n          getScope(\"viewer\"), { docDefaults: samplePrefs(5), currentUser: samplePrefs(6) }),\n        updateRej);\n        await assert.isRejected(dbManager.setDocPrefs(getScope(\"editor\"), { docDefaults: samplePrefs(7) }),\n          updateRej);\n\n        // Non-collaborators cannot do anything.\n        await assert.isRejected(dbManager.getDocPrefs(getScope(\"nonmember\")),\n          /access denied/);\n        await assert.isRejected(dbManager.setDocPrefs(getScope(\"nonmember\"), { docDefaults: samplePrefs(8) }),\n          /access denied/);\n        await assert.isRejected(dbManager.setDocPrefs(getScope(\"nonmember\"), { currentUser: samplePrefs(9) }),\n          /access denied/);\n\n        if (docName === \"publicDoc\") {\n          // Check that we are testing what we intend: nonMemberScope CAN access the document here.\n          assert.equal((await dbManager.getDoc(getScope(\"nonmember\"))).access, \"editors\");\n        }\n\n        // Ensure that failed attempts didn't affect what we stored (see previous test case).\n        assert.deepEqual(await dbManager.getDocPrefs(getScope(\"owner\")),\n          { docDefaults: samplePrefs(1), currentUser: samplePrefs(2) });\n        assert.deepEqual(await dbManager.getDocPrefs(getScope(\"editor\")),\n          { docDefaults: samplePrefs(1), currentUser: {} });\n        assert.deepEqual(await dbManager.getDocPrefs(getScope(\"viewer\")),\n          { docDefaults: samplePrefs(1), currentUser: samplePrefs(3) });\n      });\n    });\n  }\n});\n"
  },
  {
    "path": "test/gen-server/lib/DocWorkerMap.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport { DocWorkerMap, getDocWorkerMap } from \"app/gen-server/lib/DocWorkerMap\";\nimport { Deps } from \"app/server/lib/DocWorkerLoadTracker\";\nimport { DocStatus, DocWorkerInfo, IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport { FlexServer } from \"app/server/lib/FlexServer\";\nimport { NSandbox } from \"app/server/lib/NSandbox\";\nimport { Permit } from \"app/server/lib/Permit\";\nimport { MergedServer } from \"app/server/MergedServer\";\nimport { TestSession } from \"test/gen-server/apiUtils\";\nimport { createInitialDb, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport { delay, promisifyAll } from \"bluebird\";\nimport { assert, expect } from \"chai\";\nimport { countBy, values } from \"lodash\";\nimport { createClient, RedisClient } from \"redis\";\nimport sinon from \"sinon\";\n\npromisifyAll(RedisClient.prototype);\n\ndescribe(\"DocWorkerMap\", function() {\n  let cli: RedisClient;\n\n  testUtils.setTmpLogLevel(\"error\");\n\n  before(async function() {\n    if (!process.env.TEST_REDIS_URL) { this.skip(); }\n    cli = createClient(process.env.TEST_REDIS_URL);\n    await cli.flushdbAsync();\n  });\n\n  after(async function() {\n    if (cli) { await cli.quitAsync(); }\n  });\n\n  for (const GRIST_EXPERIMENTAL_WORKER_ASSIGNMENT of [0, 1]) {\n    describe(`with GRIST_EXPERIMENTAL_WORKER_ASSIGNMENT=${GRIST_EXPERIMENTAL_WORKER_ASSIGNMENT}`, function() {\n      let oldEnv: testUtils.EnvironmentSnapshot;\n\n      before(async function() {\n        oldEnv = new testUtils.EnvironmentSnapshot();\n        process.env.GRIST_EXPERIMENTAL_WORKER_ASSIGNMENT = `${GRIST_EXPERIMENTAL_WORKER_ASSIGNMENT}`;\n      });\n\n      after(async function() {\n        oldEnv.restore();\n      });\n\n      describe(\"assigment\", function() {\n        beforeEach(async function() {\n          if (cli) { await cli.delAsync(\"groups\"); }\n        });\n\n        afterEach(async function() {\n          if (cli) { await cli.flushdbAsync(); }\n        });\n\n        it(\"can assign a worker when available\", async function() {\n          const workers = new DocWorkerMap([cli]);\n\n          // No assignment without workers available\n          await assert.isRejected(workers.assignDocWorker(\"a-doc\"), /no doc workers/);\n\n          // Add a worker\n          await workers.addWorker({ id: \"worker1\", internalUrl: \"internal\", publicUrl: \"public\" });\n\n          // Still no assignment\n          await assert.isRejected(workers.assignDocWorker(\"a-doc\"), /no doc workers/);\n\n          // Make worker available\n          await workers.setWorkerAvailability(\"worker1\", true);\n\n          // That worker gets assigned\n          const worker = await workers.assignDocWorker(\"a-doc\");\n          assert.equal(worker.docWorker.id, \"worker1\");\n\n          // That assignment is remembered\n          let w = await workers.getDocWorker(\"a-doc\");\n          assert.equal(w?.docWorker.id, \"worker1\");\n\n          // Make worker unavailable for assigment\n          await workers.setWorkerAvailability(\"worker1\", false);\n\n          // Existing assignment remains\n          w = await workers.getDocWorker(\"a-doc\");\n          assert.equal(w?.docWorker.id, \"worker1\");\n\n          // Remove worker\n          await workers.removeWorker(\"worker1\");\n\n          // Assignment is gone away\n          w = await workers.getDocWorker(\"a-doc\");\n          assert.equal(w, null);\n        });\n\n        it(\"can release assignments\", async function() {\n          const workers = new DocWorkerMap([cli]);\n\n          await workers.addWorker({ id: \"worker1\", internalUrl: \"internal\", publicUrl: \"public\" });\n          await workers.addWorker({ id: \"worker2\", internalUrl: \"internal\", publicUrl: \"public\" });\n\n          await workers.setWorkerAvailability(\"worker1\", true);\n\n          let assignment: DocStatus | null = await workers.assignDocWorker(\"a-doc\");\n          assert.equal(assignment.docWorker.id, \"worker1\");\n\n          await workers.setWorkerAvailability(\"worker2\", true);\n          await workers.setWorkerAvailability(\"worker1\", false);\n\n          assignment = await workers.getDocWorker(\"a-doc\");\n          assert.equal(assignment!.docWorker.id, \"worker1\");\n\n          await workers.releaseAssignment(\"worker1\", \"a-doc\");\n\n          assignment = await workers.getDocWorker(\"a-doc\");\n          assert.equal(assignment, null);\n\n          assignment = await workers.assignDocWorker(\"a-doc\");\n          assert.equal(assignment.docWorker.id, \"worker2\");\n        });\n\n        it(\"can assign multiple workers\", async function() {\n          this.timeout(5000);   // Be more generous than 2s default, since this normally takes over 1s.\n\n          const workers = new DocWorkerMap([cli]);\n\n          // Make some workers available\n          const W = 4;\n          for (let i = 0; i < W; i++) {\n            await workers.addWorker({ id: `worker${i}`, internalUrl: \"internal\", publicUrl: \"public\" });\n            await workers.setWorkerAvailability(`worker${i}`, true);\n          }\n\n          // Assign some docs\n          const N = 100;\n          const docs: string[] = [];\n          const docWorkers: string[] = [];\n          for (let i = 0; i < N; i++) {\n            const name = `a-doc-${i}`;\n            docs.push(name);\n            const w = await workers.assignDocWorker(name);\n            docWorkers.push(w.docWorker.id);\n          }\n\n          // Check assignment looks plausible (random, so will fail with low prob)\n          const counts = countBy(docWorkers);\n          // Say over half the workers got assigned\n          assert.isAbove(values(counts).length, W / 2);\n          // Say no worker got over half the work\n          const highs = values(counts).filter((k, v) => v > N / 2);\n          assert.equal(highs.length, 0);\n\n          // Check assignments stick\n          for (let i = 0; i < N; i++) {\n            const name = docs[i];\n            const w = await workers.getDocWorker(name);\n            assert.equal(w?.docWorker.id, docWorkers[i]);\n          }\n\n          // Check assignments drop out as workers are removed\n          let remaining = N;\n          for (const w of Object.keys(counts)) {\n            await workers.removeWorker(w);\n            remaining -= counts[w];\n            let ct = 0;\n            for (const name of docs) {\n              if (null !== await workers.getDocWorker(name)) { ct++; }\n            }\n            assert.equal(remaining, ct);\n          }\n          assert.equal(remaining, 0);\n        });\n\n        if (GRIST_EXPERIMENTAL_WORKER_ASSIGNMENT === 1) {\n          it(\"can assign workers by load\", async function() {\n            this.timeout(5000);\n\n            const workerMap = new DocWorkerMap([cli]);\n            const workerCount = 4;\n            const workers = [];\n            for (let i = 1; i <= workerCount; i++) {\n              const worker: DocWorkerInfo = {\n                id: `worker${i}`,\n                internalUrl: \"internal\",\n                publicUrl: \"public\",\n              };\n              workers.push(worker);\n              await workerMap.addWorker(worker);\n              await workerMap.setWorkerAvailability(`worker${i}`, true);\n            }\n\n            // Initialize worker load.\n            const workerLoads = [0.0, 0.5, 0.875, 1.0];\n            for (let i = 0; i < workerLoads.length; i++) {\n              await workerMap.setWorkerLoad(workers[i], workerLoads[i]);\n            }\n\n            const assignmentCountByWorkerId = new Map<string, number>();\n            const docCount = 100;\n\n            const assignDocuments = async () => {\n              assignmentCountByWorkerId.clear();\n              for (let i = 1; i <= docCount; i++) {\n                const docId = `doc${i}`;\n                const { docWorker } = await workerMap.assignDocWorker(docId);\n                const count = assignmentCountByWorkerId.get(docWorker.id) ?? 0;\n                assignmentCountByWorkerId.set(docWorker.id, count + 1);\n                // We call this function multiple times, so we need to release\n                // the assignment to prevent it from affecting future calls.\n                await workerMap.releaseAssignment(docWorker.id, docId);\n              }\n            };\n\n            const count = (workerId: string) => {\n              return assignmentCountByWorkerId.get(workerId) ?? 0;\n            };\n\n            // Check that `worker${i}` gets more assignments than `worker${i + 1}`.\n            await assignDocuments();\n            assert.isAbove(count(\"worker1\"), count(\"worker2\"));\n            assert.isAbove(count(\"worker2\"), count(\"worker3\"));\n            assert.isAbove(count(\"worker3\"), count(\"worker4\"));\n            assert.equal(count(\"worker4\"), 0);\n\n            // Set each worker's load to 1.\n            for (let i = 0; i < workerCount; i++) {\n              await workerMap.setWorkerLoad(workers[i], 1);\n            }\n\n            // Check no worker gets over half the work.\n            await assignDocuments();\n            assert.equal(assignmentCountByWorkerId.size, workerCount);\n            assert.isEmpty(\n              [...assignmentCountByWorkerId.values()].filter(\n                v => v > docCount / 2,\n              ),\n            );\n          });\n        }\n\n        it(\"can elect workers to groups\", async function() {\n          this.timeout(5000);\n\n          // Say we want one worker reserved for \"blizzard\" and two for \"funkytown\"\n          await cli.hmsetAsync(\"groups\", {\n            blizzard: 1,\n            funkytown: 2,\n          });\n          for (let i = 0; i < 20; i++) {\n            await cli.setAsync(`doc-blizzard${i}-group`, \"blizzard\");\n            await cli.setAsync(`doc-funkytown${i}-group`, \"funkytown\");\n          }\n          let workers = new DocWorkerMap([cli], \"ver1\");\n          for (let i = 0; i < 5; i++) {\n            await workers.addWorker({ id: `worker${i}`, internalUrl: \"internal\", publicUrl: \"public\" });\n            await workers.setWorkerAvailability(`worker${i}`, true);\n          }\n          let elections = await cli.hgetallAsync(\"elections-ver1\");\n          assert.deepEqual(elections, { blizzard: '[\"worker0\"]', funkytown: '[\"worker1\",\"worker2\"]' });\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-blizzard\"), [\"worker0\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-funkytown\"), [\"worker1\", \"worker2\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-default\"), [\"worker3\", \"worker4\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available\"),\n            [\"worker0\", \"worker1\", \"worker2\", \"worker3\", \"worker4\"]);\n          for (let i = 0; i < 20; i++) {\n            const assignment = await workers.assignDocWorker(`blizzard${i}`);\n            assert.equal(assignment.docWorker.id, \"worker0\");\n          }\n          for (let i = 0; i < 20; i++) {\n            const assignment = await workers.assignDocWorker(`funkytown${i}`);\n            assert.include([\"worker1\", \"worker2\"], assignment.docWorker.id);\n          }\n          for (let i = 0; i < 20; i++) {\n            const assignment = await workers.assignDocWorker(`random${i}`);\n            assert.include([\"worker3\", \"worker4\"], assignment.docWorker.id);\n          }\n\n          // suppose worker0 dies, and worker5 is added to replace it\n          await workers.removeWorker(\"worker0\");\n          await workers.addWorker({ id: `worker5`, internalUrl: \"internal\", publicUrl: \"public\" });\n          await workers.setWorkerAvailability(\"worker5\", true);\n          for (let i = 0; i < 20; i++) {\n            const assignment = await workers.assignDocWorker(`blizzard${i}`);\n            assert.equal(assignment.docWorker.id, \"worker5\");\n          }\n\n          // suppose worker1 dies, and worker6 is added to replace it\n          await workers.removeWorker(\"worker1\");\n          await workers.addWorker({ id: `worker6`, internalUrl: \"internal\", publicUrl: \"public\" });\n          await workers.setWorkerAvailability(\"worker6\", true);\n          for (let i = 0; i < 20; i++) {\n            const assignment = await workers.assignDocWorker(`funkytown${i}`);\n            assert.include([\"worker2\", \"worker6\"], assignment.docWorker.id);\n          }\n\n          // suppose we add a new deployment...\n          workers = new DocWorkerMap([cli], \"ver2\");\n          for (let i = 0; i < 5; i++) {\n            await workers.addWorker({ id: `worker${i}_v2`, internalUrl: \"internal\", publicUrl: \"public\" });\n            await workers.setWorkerAvailability(`worker${i}_v2`, true);\n          }\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-blizzard\"),\n            [\"worker5\", \"worker0_v2\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-funkytown\"),\n            [\"worker2\", \"worker6\", \"worker1_v2\", \"worker2_v2\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-default\"),\n            [\"worker3\", \"worker4\", \"worker3_v2\", \"worker4_v2\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available\"),\n            [\"worker2\", \"worker3\", \"worker4\", \"worker5\", \"worker6\",\n              \"worker0_v2\", \"worker1_v2\", \"worker2_v2\", \"worker3_v2\", \"worker4_v2\"]);\n\n          // ...and then remove the old one\n          workers = new DocWorkerMap([cli], \"ver1\");\n          for (let i = 0; i < 7; i++) {\n            await workers.removeWorker(`worker${i}`);\n          }\n\n          // check everything looks as expected\n          workers = new DocWorkerMap([cli], \"ver2\");\n          elections = await cli.hgetallAsync(\"elections-ver2\");\n          assert.deepEqual(elections, { blizzard: '[\"worker0_v2\"]',\n            funkytown: '[\"worker1_v2\",\"worker2_v2\"]' });\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-blizzard\"), [\"worker0_v2\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-funkytown\"), [\"worker1_v2\", \"worker2_v2\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-default\"), [\"worker3_v2\", \"worker4_v2\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available\"),\n            [\"worker0_v2\", \"worker1_v2\", \"worker2_v2\", \"worker3_v2\", \"worker4_v2\"]);\n          for (let i = 0; i < 20; i++) {\n            const assignment = await workers.assignDocWorker(`blizzard${i}`);\n            assert.equal(assignment.docWorker.id, \"worker0_v2\");\n          }\n          for (let i = 0; i < 20; i++) {\n            const assignment = await workers.assignDocWorker(`funkytown${i}`);\n            assert.include([\"worker1_v2\", \"worker2_v2\"], assignment.docWorker.id);\n          }\n          for (let i = 0; i < 20; i++) {\n            const assignment = await workers.assignDocWorker(`random${i}`);\n            assert.include([\"worker3_v2\", \"worker4_v2\"], assignment.docWorker.id);\n          }\n\n          // check everything about previous deployment got cleaned up\n          assert.equal(await cli.hgetallAsync(\"elections-ver1\"), null);\n        });\n\n        it(\"can assign workers to groups\", async function() {\n          this.timeout(5000);\n          const workers = new DocWorkerMap([cli], \"ver1\");\n\n          // Register a few regular workers.\n          for (let i = 0; i < 3; i++) {\n            await workers.addWorker({ id: `worker${i}`, internalUrl: \"internal\", publicUrl: \"public\" });\n            await workers.setWorkerAvailability(`worker${i}`, true);\n          }\n\n          // Register a worker in a special group.\n          await workers.addWorker({ id: \"worker_secondary\", internalUrl: \"internal\", publicUrl: \"public\",\n            group: \"secondary\" });\n          await workers.setWorkerAvailability(\"worker_secondary\", true);\n\n          // Check that worker lists look sane.\n          assert.sameMembers(await cli.smembersAsync(\"workers\"),\n            [\"worker0\", \"worker1\", \"worker2\", \"worker_secondary\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available\"),\n            [\"worker0\", \"worker1\", \"worker2\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-default\"),\n            [\"worker0\", \"worker1\", \"worker2\"]);\n          assert.sameMembers(await cli.smembersAsync(\"workers-available-secondary\"),\n            [\"worker_secondary\"]);\n\n          // Check that worker-*-group keys are as expected.\n          assert.equal(await cli.getAsync(\"worker-worker_secondary-group\"), \"secondary\");\n          assert.equal(await cli.getAsync(\"worker-worker0-group\"), null);\n\n          // Check that a doc for the special group is assigned to the correct worker.\n          await cli.setAsync(\"doc-funkydoc-group\", \"secondary\");\n          assert.equal((await workers.assignDocWorker(\"funkydoc\")).docWorker.id, \"worker_secondary\");\n\n          // Check that other docs don't end up on the special group's worker.\n          for (let i = 0; i < 50; i++) {\n            assert.match((await workers.assignDocWorker(`normaldoc${i}`)).docWorker.id,\n              /^worker\\d$/);\n          }\n        });\n      });\n\n      describe(\"election\", function() {\n        it(\"can manage task election nominations\", async function() {\n          this.timeout(5000);\n\n          const store = new DocWorkerMap([cli]);\n          // allocate two tasks\n          const task1 = await store.getElection(\"task1\", 1000);\n          let task2 = await store.getElection(\"task2\", 1000);\n          assert.notEqual(task1, null);\n          assert.notEqual(task2, null);\n          assert.notEqual(task1, task2);\n\n          // check tasks cannot be immediately reallocated\n          assert.equal(await store.getElection(\"task1\", 1000), null);\n          assert.equal(await store.getElection(\"task2\", 1000), null);\n\n          // try to remove both tasks with a key that is correct for just one of them.\n          await assert.isRejected(store.removeElection(\"task1\", task2!), /could not remove/);\n          await store.removeElection(\"task2\", task2!);\n\n          // check task2 is freed up by reallocating it\n          task2 = await store.getElection(\"task2\", 3000);\n          assert.notEqual(task2, null);\n\n          await delay(1100);\n\n          // task1 should be free now, but not task2\n          const task1b = await store.getElection(\"task1\", 1000);\n          assert.notEqual(task1b, null);\n          assert.notEqual(task1b, task1);\n          assert.equal(await store.getElection(\"task2\", 1000), null);\n        });\n\n        it(\"can manage permits\", async function() {\n          const store = new DocWorkerMap([cli], undefined, { permitMsec: 1000 }).getPermitStore(\"1\");\n\n          // Make a doc permit and a workspace permit\n          const permit1: Permit = { docId: \"docId1\" };\n          const key1 = await store.setPermit(permit1);\n          assert(key1.startsWith(\"permit-1-\"));\n          const permit2: Permit = { workspaceId: 99 };\n          const key2 = await store.setPermit(permit2);\n          assert(key2.startsWith(\"permit-1-\"));\n          assert.notEqual(key1, key2);\n\n          // Check we can read the permits back\n          assert.deepEqual(await store.getPermit(key1), permit1);\n          assert.deepEqual(await store.getPermit(key2), permit2);\n\n          // Check that random permit keys give nothing\n          await assert.isRejected(store.getPermit(\"dud\"), /could not be read/);\n          assert.equal(await store.getPermit(\"permit-1-dud\"), null);\n\n          // Check that we can remove a permit\n          await store.removePermit(key1);\n          assert.equal(await store.getPermit(key1), null);\n          assert.deepEqual(await store.getPermit(key2), permit2);\n\n          // Check that permits expire\n          await delay(1100);\n          assert.equal(await store.getPermit(key2), null);\n\n          // make sure permit stores are distinct\n          const store2 = new DocWorkerMap([cli], undefined, { permitMsec: 1000 }).getPermitStore(\"2\");\n          const key3 = await store2.setPermit(permit1);\n          assert(key3.startsWith(\"permit-2-\"));\n          const fakeKey3 = key3.replace(\"permit-2-\", \"permit-1-\");\n          assert(fakeKey3.startsWith(\"permit-1-\"));\n          assert.equal(await store.getPermit(fakeKey3), null);\n          await assert.isRejected(store.getPermit(key3), /could not be read/);\n          assert.deepEqual(await store2.getPermit(key3), permit1);\n          await assert.isRejected(store2.getPermit(fakeKey3), /could not be read/);\n        });\n      });\n\n      describe(\"group assignment\", function() {\n        let servers: { [key: string]: FlexServer };\n        let workers: IDocWorkerMap;\n        before(async function() {\n          // Create a home server and some workers.\n          setUpDB(this);\n          await createInitialDb();\n          const opts = { logToConsole: false, externalStorage: false };\n          // We need to reset some environment variables - we do so\n          // naively, so throw if they are already set.\n          assert.equal(process.env.REDIS_URL, undefined);\n          assert.equal(process.env.GRIST_DOC_WORKER_ID, undefined);\n          assert.equal(process.env.GRIST_WORKER_GROUP, undefined);\n          process.env.REDIS_URL = process.env.TEST_REDIS_URL;\n\n          // Make home server.\n          const homeMergedServer = await MergedServer.create(0, [\"home\"], opts);\n          const home = homeMergedServer.flexServer;\n          await homeMergedServer.run();\n\n          // Make a worker, not associated with any group.\n          process.env.GRIST_DOC_WORKER_ID = \"worker1\";\n          const docs1MergedServer = await MergedServer.create(0, [\"docs\"], opts);\n          const docs1 = docs1MergedServer.flexServer;\n          await docs1MergedServer.run();\n\n          // Make a worker in \"special\" group.\n          process.env.GRIST_DOC_WORKER_ID = \"worker2\";\n          process.env.GRIST_WORKER_GROUP = \"special\";\n          const docs2MergedServer = await MergedServer.create(0, [\"docs\"], opts);\n          const docs2 = docs2MergedServer.flexServer;\n          await docs2MergedServer.run();\n\n          // Make two worker in \"other\" group.\n          process.env.GRIST_DOC_WORKER_ID = \"worker3\";\n          process.env.GRIST_WORKER_GROUP = \"other\";\n          const docs3MergedServer = await MergedServer.create(0, [\"docs\"], opts);\n          const docs3 = docs3MergedServer.flexServer;\n          await docs3MergedServer.run();\n          process.env.GRIST_DOC_WORKER_ID = \"worker4\";\n          process.env.GRIST_WORKER_GROUP = \"other\";\n          const docs4MergedServer = await MergedServer.create(0, [\"docs\"], opts);\n          const docs4 = docs4MergedServer.flexServer;\n          await docs4MergedServer.run();\n\n          servers = { home, docs1, docs2, docs3, docs4 };\n          workers = getDocWorkerMap();\n        });\n\n        after(async function() {\n          if (servers) {\n            await Promise.all(Object.values(servers).map(server => server.close()));\n            await removeConnection();\n            delete process.env.REDIS_URL;\n            delete process.env.GRIST_DOC_WORKER_ID;\n            delete process.env.GRIST_WORKER_GROUP;\n            await workers.close();\n          }\n        });\n\n        it(\"can reassign documents between groups\", async function() {\n          this.timeout(15000);\n\n          // Create a test documment.\n          const session = new TestSession(servers.home);\n          const api = await session.createHomeApi(\"chimpy\", \"nasa\");\n          const supportApi = await session.createHomeApi(\"support\", \"docs\", true);\n          const ws1 = await api.newWorkspace({ name: \"ws1\" }, \"current\");\n          const doc1 = await api.newDoc({ name: \"doc1\" }, ws1);\n\n          // Exercise it.\n          await api.getDocAPI(doc1).getRows(\"Table1\");\n\n          // Check it is served by only unspecialized worker.\n          assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, \"worker1\");\n\n          // Set doc to \"special\" group.\n          await cli.setAsync(`doc-${doc1}-group`, \"special\");\n\n          // Check doc gets reassigned to correct worker.\n          assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {\n            method: \"POST\",\n          })).json(), true);\n          await api.getDocAPI(doc1).getRows(\"Table1\");\n          assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, \"worker2\");\n\n          // Set doc to \"other\" group.\n          await cli.setAsync(`doc-${doc1}-group`, \"other\");\n\n          // Check doc gets reassigned to one of the correct workers.\n          assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {\n            method: \"POST\",\n          })).json(), true);\n          await api.getDocAPI(doc1).getRows(\"Table1\");\n          assert.oneOf((await workers.getDocWorker(doc1))?.docWorker.id, [\"worker3\", \"worker4\"]);\n\n          // Remove doc from groups.\n          await cli.delAsync(`doc-${doc1}-group`);\n          assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {\n            method: \"POST\",\n          })).json(), true);\n          await api.getDocAPI(doc1).getRows(\"Table1\");\n\n          // Check doc is again served by only unspecialized worker.\n          assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, \"worker1\");\n\n          // Check that hitting /assign without a change of group is reported as no-op (false).\n          assert.equal(await (await api.testRequest(`${api.getBaseUrl()}/api/docs/${doc1}/assign`, {\n            method: \"POST\",\n          })).json(), false);\n\n          // Check that Chimpy can't use `group` param to update doc group prior to reassignment.\n          const urlWithGroup = new URL(`${api.getBaseUrl()}/api/docs/${doc1}/assign`);\n          urlWithGroup.searchParams.set(\"group\", \"special\");\n          assert.equal(await (await api.testRequest(urlWithGroup.toString(), {\n            method: \"POST\",\n          })).json(), false);\n\n          // Check that support user can use `group` param in housekeeping endpoint to update\n          // doc group prior to reassignment.\n          const housekeepingUrl = new URL(`${api.getBaseUrl()}/api/housekeeping/docs/${doc1}/assign`);\n          housekeepingUrl.searchParams.set(\"group\", \"special\");\n          assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), {\n            method: \"POST\",\n          })).json(), true);\n          await api.getDocAPI(doc1).getRows(\"Table1\");\n          assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, \"worker2\");\n\n          // Check that hitting housekeeping endpoint with the same group is reported as no-op (false).\n          assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), {\n            method: \"POST\",\n          })).json(), false);\n\n          // Check that specifying a blank group reverts back to the unspecialized worker.\n          housekeepingUrl.searchParams.set(\"group\", \"\");\n          assert.equal(await (await supportApi.testRequest(housekeepingUrl.toString(), {\n            method: \"POST\",\n          })).json(), true);\n          await api.getDocAPI(doc1).getRows(\"Table1\");\n          assert.equal((await workers.getDocWorker(doc1))?.docWorker.id, \"worker1\");\n        });\n      });\n\n      describe(\"isWorkerRegistered\", () => {\n        const baseWorkerInfo: DocWorkerInfo = {\n          id: \"workerId\",\n          internalUrl: \"internalUrl\",\n          publicUrl: \"publicUrl\",\n          group: undefined,\n        };\n\n        [\n          {\n            itMsg: \"should check if worker is registered\",\n            sisMemberAsyncResolves: 1,\n            expectedResult: true,\n            expectedKey: \"workers-available-default\",\n          },\n          {\n            itMsg: \"should check if worker is registered in a certain group\",\n            sisMemberAsyncResolves: 1,\n            group: \"dummygroup\",\n            expectedResult: true,\n            expectedKey: \"workers-available-dummygroup\",\n          },\n          {\n            itMsg: \"should return false if worker is not registered\",\n            sisMemberAsyncResolves: 0,\n            expectedResult: false,\n            expectedKey: \"workers-available-default\",\n          },\n        ].forEach((ctx) => {\n          it(ctx.itMsg, async () => {\n            const sismemberAsyncStub = sinon.stub().resolves(ctx.sisMemberAsyncResolves);\n            const stubDocWorkerMap = {\n              _client: { sismemberAsync: sismemberAsyncStub },\n            };\n            const result = await DocWorkerMap.prototype.isWorkerRegistered.call(\n              stubDocWorkerMap, { ...baseWorkerInfo, group: ctx.group },\n            );\n            expect(result).to.equal(ctx.expectedResult);\n            expect(sismemberAsyncStub.calledOnceWith(ctx.expectedKey, baseWorkerInfo.id)).to.equal(true);\n          });\n        });\n      });\n\n      if (GRIST_EXPERIMENTAL_WORKER_ASSIGNMENT === 1) {\n        describe(\"load\", function() {\n          const MEMORY_PER_DOC_MB = 50;\n          const MAX_MEMORY_MB = 1024;\n\n          let servers: { [key: string]: FlexServer };\n          let workers: IDocWorkerMap;\n          let oldEnv: testUtils.EnvironmentSnapshot;\n          let sandbox: sinon.SinonSandbox;\n          let session: TestSession;\n          let api: UserAPI;\n          let wsId: number;\n          let docId: string;\n\n          before(async function() {\n            sandbox = sinon.createSandbox();\n            sandbox\n              .stub(NSandbox.prototype, \"reportMemoryUsage\")\n              .returns(Promise.resolve(MEMORY_PER_DOC_MB * 1024 * 1024));\n            sandbox.stub(Deps, \"docWorkerMaxMemoryMBForcedValue\").value(MAX_MEMORY_MB);\n            sandbox.stub(Deps, \"docWorkerUpdateLoadIntervalMs\").value(50);\n            sandbox.stub(Deps, \"docWorkerUpdateLoadVarianceMs\").value(0);\n\n            setUpDB(this);\n            await createInitialDb();\n\n            oldEnv = new testUtils.EnvironmentSnapshot();\n            process.env.REDIS_URL = process.env.TEST_REDIS_URL;\n            process.env.GRIST_WORKER_GROUP = \"default\";\n\n            const opts = { logToConsole: false, externalStorage: false };\n            const homeMergedServer = await MergedServer.create(0, [\"home\"], opts);\n            const home = homeMergedServer.flexServer;\n            await homeMergedServer.run();\n            servers = { home };\n            for (let i = 1; i <= 3; i++) {\n              const workerId = `worker${i}`;\n              process.env.GRIST_DOC_WORKER_ID = workerId;\n              const docsMergedServer = await MergedServer.create(0, [\"docs\"], opts);\n              servers[workerId] = docsMergedServer.flexServer;\n              await docsMergedServer.run();\n            }\n\n            workers = getDocWorkerMap();\n\n            session = new TestSession(servers.home);\n            api = await session.createHomeApi(\"chimpy\", \"nasa\", true);\n            wsId = await api.newWorkspace({ name: \"ws\" }, \"current\");\n            docId = await api.newDoc({ name: \"doc\" }, wsId);\n          });\n\n          after(async function() {\n            oldEnv.restore();\n            sandbox.restore();\n\n            if (servers) {\n              await Promise.all(Object.values(servers).map(server => server.close()));\n              await removeConnection();\n              await workers.close();\n            }\n          });\n\n          it(\"initializes when worker is added\", async function() {\n            const availableWorkersByLoad = await cli.zrangeAsync(\n              \"workers-available-by-load-default\",\n              0,\n              -1,\n              \"WITHSCORES\",\n            );\n            assert.deepEqual(availableWorkersByLoad, [\n              \"worker1\",\n              \"0\",\n              \"worker2\",\n              \"0\",\n              \"worker3\",\n              \"0\",\n            ]);\n          });\n\n          it(\"updates after document is opened or closed\", async function() {\n            this.timeout(10000);\n\n            // Open a document.\n            await api.getDocAPI(docId).getRows(\"Table1\");\n            const workerId = (await workers.getDocWorker(docId))!.docWorker.id;\n\n            // After 50 ms or so, load should reflect the opened document.\n            await waitForIt(async () => {\n              const load = await cli.zscoreAsync(`workers-available-by-load-default`, workerId);\n              assert.equal(load, MEMORY_PER_DOC_MB / MAX_MEMORY_MB);\n            }, 250, 50);\n\n            // Shut down all open documents. Load should reset soon after.\n            await Promise.all(Object.values(servers).map(server => server.testCloseDocs()));\n            await waitForIt(async () => {\n              const load = await cli.zscoreAsync(`workers-available-by-load-default`, workerId);\n              assert.equal(load, 0.0);\n            }, 250, 50);\n\n            // Now try opening a number of documents at once.\n            const docCount = 10;\n            const createDocPromises = [];\n            for (let i = 1; i <= docCount; i++) {\n              createDocPromises.push(api.newDoc({ name: \"doc\" }, wsId));\n            }\n            const docIds = await Promise.all(createDocPromises);\n            await Promise.all(docIds.map(docId => api.getDocAPI(docId).getRows(\"Table1\")));\n\n            // After 50 ms or so, load should reflect the opened documents.\n            await waitForIt(async () => {\n              let totalLoad = 0;\n              let numWorkersWithAssignments = 0;\n              for (const workerId of [\"worker1\", \"worker2\", \"worker3\"]) {\n                const load = await cli.zscoreAsync(\"workers-available-by-load-default\", workerId);\n                if (load) {\n                  totalLoad += Number(load);\n                  numWorkersWithAssignments += 1;\n                }\n              }\n              assert.equal(totalLoad, (MEMORY_PER_DOC_MB * docCount) / MAX_MEMORY_MB);\n              assert.isAbove(numWorkersWithAssignments, 1);\n            }, 250, 50);\n\n            // Shut down all open documents. Load should reset soon after.\n            await Promise.all(Object.values(servers).map(server => server.testCloseDocs()));\n            await waitForIt(async () => {\n              const availableWorkersByLoad = await cli.zrangeAsync(\n                \"workers-available-by-load-default\",\n                0,\n                -1,\n                \"WITHSCORES\",\n              );\n              assert.deepEqual(availableWorkersByLoad, [\n                \"worker1\",\n                \"0\",\n                \"worker2\",\n                \"0\",\n                \"worker3\",\n                \"0\",\n              ]);\n            }, 250, 50);\n          });\n        });\n      }\n    });\n  }\n});\n"
  },
  {
    "path": "test/gen-server/lib/HealthCheck.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { TcpForwarder } from \"test/server/tcpForwarder\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport { assert } from \"chai\";\nimport fetch from \"node-fetch\";\n\ndescribe(\"HealthCheck\", function() {\n  testUtils.setTmpLogLevel(\"error\");\n\n  for (const serverType of [\"home\", \"docs\"] as (\"home\" | \"docs\")[]) {\n    describe(serverType, function() {\n      let server: TestServer;\n      let oldEnv: testUtils.EnvironmentSnapshot;\n      let redisForwarder: TcpForwarder;\n\n      before(async function() {\n        oldEnv = new testUtils.EnvironmentSnapshot();\n\n        // We set up Redis via a TcpForwarder, so that we can simulate disconnects.\n        if (!process.env.TEST_REDIS_URL) {\n          throw new Error(\"TEST_REDIS_URL is expected\");\n        }\n        const redisUrl = new URL(process.env.TEST_REDIS_URL);\n        const redisPort = parseInt(redisUrl.port, 10) || 6379;\n        redisForwarder = new TcpForwarder(redisPort, redisUrl.host);\n        const forwarderPort = await redisForwarder.pickForwarderPort();\n        await redisForwarder.connect();\n\n        process.env.REDIS_URL = `redis://localhost:${forwarderPort}`;\n        server = new TestServer(this);\n        await server.start([serverType]);\n      });\n\n      after(async function() {\n        await server.stop();\n        await redisForwarder.disconnect();\n        oldEnv.restore();\n      });\n\n      it(\"has a working simple /status endpoint\", async function() {\n        const result = await fetch(server.server.getOwnUrl() + \"/status\");\n        const text = await result.text();\n        assert.match(text, /Grist server.*alive/);\n        assert.notMatch(text, /db|redis/);\n        assert.equal(result.ok, true);\n        assert.equal(result.status, 200);\n      });\n\n      it(\"allows asking for db and redis status\", async function() {\n        const result = await fetch(server.server.getOwnUrl() + \"/status?db=1&redis=1&timeout=500\");\n        assert.match(await result.text(), /Grist server.*alive.*db ok, redis ok/);\n        assert.equal(result.ok, true);\n        assert.equal(result.status, 200);\n      });\n\n      function blockPostgres(driver: any) {\n        // Make the database unhealthy by exausting the connection pool. This happens to be a way\n        // that has occurred in practice.\n        const blockers: Promise<void>[] = [];\n        const resolvers: (() => void)[] = [];\n        for (let i = 0; i < driver.master.options.max; i++) {\n          const promise = new Promise<void>((resolve) => { resolvers.push(resolve); });\n          blockers.push(server.dbManager.connection.transaction(manager => promise));\n        }\n        return {\n          blockerPromise: Promise.all(blockers),\n          resolve: () => resolvers.forEach(resolve => resolve()),\n        };\n      }\n\n      it(\"reports error when database is unhealthy\", async function() {\n        if (server.dbManager.connection.options.type !== \"postgres\") {\n          // On postgres, we have a way to interfere with connections. Elsewhere (sqlite) it's not\n          // so obvious how to make DB unhealthy, so don't bother testing that.\n          this.skip();\n        }\n        this.timeout(5000);\n\n        const { blockerPromise, resolve } = blockPostgres(server.dbManager.connection.driver as any);\n        try {\n          const result = await fetch(server.server.getOwnUrl() + \"/status?db=1&redis=1&timeout=500\");\n          assert.match(await result.text(), /Grist server.*unhealthy.*db not ok, redis ok/);\n          assert.equal(result.ok, false);\n          assert.equal(result.status, 500);\n\n          // Plain /status endpoint should be unaffected.\n          assert.isTrue((await fetch(server.server.getOwnUrl() + \"/status\")).ok);\n        } finally {\n          resolve();\n          await blockerPromise;\n        }\n        assert.isTrue((await fetch(server.server.getOwnUrl() + \"/status?db=1&redis=1&timeout=100\")).ok);\n      });\n\n      it(\"reports error when redis is unhealthy\", async function() {\n        this.timeout(5000);\n        await redisForwarder.disconnect();\n        try {\n          const result = await fetch(server.server.getOwnUrl() + \"/status?db=1&redis=1&timeout=500\");\n          assert.match(await result.text(), /Grist server.*unhealthy.*db ok, redis not ok/);\n          assert.equal(result.ok, false);\n          assert.equal(result.status, 500);\n\n          // Plain /status endpoint should be unaffected.\n          assert.isTrue((await fetch(server.server.getOwnUrl() + \"/status\")).ok);\n        } finally {\n          await redisForwarder.connect();\n          // Wait a little for various redis-using code to reconnect, to avoid test hangs,\n          // presumably caused by some race conditions when going into cleanup immediately.\n          await delay(400);\n        }\n        await waitForIt(async () =>\n          assert.isTrue((await fetch(server.server.getOwnUrl() + \"/status?db=1&redis=1&timeout=100\")).ok),\n        2000);\n      });\n    });\n  }\n});\n"
  },
  {
    "path": "test/gen-server/lib/HomeDBCaches.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { DocPrefs } from \"app/common/Prefs\";\nimport { EDITOR, OWNER, Role, VIEWER } from \"app/common/roles\";\nimport { PermissionData, PermissionDelta } from \"app/common/UserAPI\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Deps as CachesDeps, HomeDBCaches } from \"app/gen-server/lib/homedb/Caches\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { QueryResult } from \"app/gen-server/lib/homedb/Interfaces\";\nimport { createPubSubManager } from \"app/server/lib/PubSubManager\";\nimport { createInitialDb, removeConnection } from \"test/gen-server/seed\";\nimport { TestServer } from \"test/server/lib/helpers/TestServer\";\nimport { setupCleanup } from \"test/server/testCleanup\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport * as path from \"path\";\n\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\n/**\n * Tests homedb/Caches.ts, that caches work, expire appropriately, and are invalidated by correct\n * actions, including changes to parent resources, etc.\n */\ndescribe(\"HomeDBCaches\", function() {\n  this.timeout(10_000);\n  testUtils.setTmpLogLevel(\"error\");\n  const cleanup = setupCleanup();\n\n  let homeDb: HomeDBManager;\n  let caches: HomeDBCaches;\n  let entities: Awaited<ReturnType<typeof createTestFixture>>;\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  const sandboxAll = sinon.createSandbox();\n  const sandbox = sinon.createSandbox();\n\n  before(async function() {\n    if (!process.env.TEST_REDIS_URL) { this.skip(); }\n\n    oldEnv = new testUtils.EnvironmentSnapshot();\n\n    // If running with default DB type (SQLite), TestServer() will use :memory:. Let's keep a copy\n    // instead, so that it can be examined if a test fails.\n    if (!process.env.TYPEORM_DATABASE) {\n      const tmpDir = await testUtils.createTestDir(\"HomeDBCaches\");\n      process.env.TYPEORM_DATABASE = path.join(tmpDir, \"landing.db\");\n    }\n    process.env.TEST_CLEAN_DATABASE = \"true\";\n\n    // For this test, set cache TTL to a low value, just high enough not to trigger unexpectedly.\n    sandboxAll.stub(CachesDeps, \"DocAccessCacheTTL\").value(1000);\n    sandboxAll.stub(CachesDeps, \"DocPrefsCacheTTL\").value(1000);\n\n    const pubSubManager = createPubSubManager(process.env.TEST_REDIS_URL);\n    cleanup.addAfterAll(() => pubSubManager.close());\n\n    // Set up a homeDB with a few users, and some nested resources: org/workspaces/docs.\n    homeDb = new HomeDBManager(undefined, undefined, pubSubManager);\n    await createInitialDb(homeDb.connection, \"migrateOnly\");\n    await homeDb.connect();\n    await homeDb.initializeSpecialIds();\n    entities = await createTestFixture(homeDb);\n    caches = homeDb.caches!;\n  });\n\n  after(async function() {\n    await removeConnection();\n    oldEnv.restore();\n    sandboxAll.restore();\n  });\n\n  afterEach(function() {\n    homeDb.clearCaches();   // Clear caches to avoid test case affecting one another.\n    sandbox.restore();\n  });\n\n  // Turn a full PermissionData into a simple {Name: Access} map, for shorter asserts.\n  function shortDocAccessResult(result: QueryResult<PermissionData>) {\n    const { maxInheritedRole, users } = homeDb.unwrapQueryResult(result);\n    assert.strictEqual(maxInheritedRole, null);     // Because we expect flattened results.\n    return Object.fromEntries(users.map(u => [u.name, u.access]));\n  }\n\n  it(\"should cache and expire docAccess values\", async function() {\n    const rawCallSpy = sandbox.spy(HomeDBManager.prototype, \"getDocAccess\");\n\n    const expect1 = { Alice: OWNER, Bob: OWNER, Carol: EDITOR } as const;\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)), expect1);\n    assert.deepEqual(rawCallSpy.callCount, 1);\n\n    // A second call returns the same result, but does not cause another underlying call.\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)), expect1);\n    assert.deepEqual(rawCallSpy.callCount, 1);\n\n    // Call for another doc.\n    const expect2 = { Bob: OWNER, Carol: EDITOR } as const;\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)), expect2);\n    assert.deepEqual(rawCallSpy.callCount, 2);\n\n    // The two calls we have are for the two different docs.\n    assert.deepEqual(rawCallSpy.args.map(args => args[0].urlId), [\n      entities.docDesignSpec.id,\n      entities.docManual.id,\n    ]);\n\n    // Further calls skip underlying calls.\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)), expect2);\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)), expect2);\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)), expect1);\n    assert.deepEqual(rawCallSpy.callCount, 2);\n\n    // After expiration, the underlying calls are made again.\n    await delay(CachesDeps.DocAccessCacheTTL);\n\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)), expect2);\n    assert.deepEqual(rawCallSpy.callCount, 3);\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)), expect1);\n    assert.deepEqual(rawCallSpy.callCount, 4);\n    assert.deepEqual(rawCallSpy.args.map(args => args[0].urlId), [\n      entities.docDesignSpec.id,\n      entities.docManual.id,\n      entities.docManual.id,\n      entities.docDesignSpec.id,\n    ]);\n\n    // And then cached values are again used for a while.\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)), expect1);\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)), expect2);\n    assert.deepEqual(rawCallSpy.callCount, 4);\n  });\n\n  it(\"should cache invalidate docAccess values on access changes\", async function() {\n    const rawCallSpy = sandbox.spy(HomeDBManager.prototype, \"getDocAccess\");\n    const alice = entities.users.Alice;\n\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER, Bob: OWNER, Carol: EDITOR });\n    assert.deepEqual(rawCallSpy.callCount, 1);\n\n    // Check also another doc (which doesn't inherit from org or wsEng); this lets us confirm that\n    // invalidations don't affect unrelated docs.\n    const expect2 = { Bob: OWNER, Carol: EDITOR } as const;\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)), expect2);\n    assert.deepEqual(rawCallSpy.callCount, 2);\n\n    // Change org sharing. The result of getDocAccess() should get affected immediately in the local homeDb.\n    await homeDb.updateOrgPermissions({ userId: alice.id }, entities.org.id, { users: { \"dave@example.com\": VIEWER } });\n    // Prepare an undo after this test case.\n    cleanup.addAfterEach(async () => {\n      await homeDb.updateOrgPermissions({ userId: alice.id }, entities.org.id, { users: { \"dave@example.com\": null } });\n    });\n\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER, Bob: OWNER, Carol: EDITOR, Dave: VIEWER });     // Have Dave now.\n    assert.deepEqual(rawCallSpy.callCount, 3);                      // One more underlying call\n\n    // Change workspace sharing\n    await homeDb.updateWorkspacePermissions({ userId: alice.id }, entities.wsEng.id, { maxInheritedRole: null });\n    // Prepare an undo after this test case.\n    cleanup.addAfterEach(async () => {\n      await homeDb.updateWorkspacePermissions({ userId: alice.id }, entities.wsEng.id, { maxInheritedRole: OWNER });\n    });\n\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER });                              // No more Bob\n    assert.deepEqual(rawCallSpy.callCount, 4);      // One more underlying call\n\n    // Change doc sharing, share it with Carol now.\n    await homeDb.updateDocPermissions({ userId: alice.id, urlId: entities.docDesignSpec.id },\n      { users: { \"carol@example.com\": VIEWER } });\n    // Prepare an undo after this test case.\n    cleanup.addAfterEach(async () => {\n      await homeDb.updateDocPermissions({ userId: alice.id, urlId: entities.docDesignSpec.id },\n        { users: { \"carol@example.com\": null } });\n    });\n\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER, Carol: VIEWER });               // No more bob, but we got Carol as VIEWER\n    assert.deepEqual(rawCallSpy.callCount, 5);      // One more underlying call\n\n    // Check that none of these invalidations caused the other doc's cache to get recalculated.\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)), expect2);\n    assert.deepEqual(rawCallSpy.callCount, 5);      // No extra underlying calls here.\n  });\n\n  it(\"should invalidate docAccess values when doc is moved\", async function() {\n    const rawCallSpy = sandbox.spy(HomeDBManager.prototype, \"getDocAccess\");\n    const alice = entities.users.Alice;\n    const bob = entities.users.Bob;\n\n    // Check initial sharing for 'docManual'.\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)),\n      { Bob: OWNER, Carol: EDITOR });\n    assert.deepEqual(rawCallSpy.callCount, 1);\n\n    // Check initial sharing for 'docDesignSpec', already in our destination workspace (wsHr).\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER, Bob: OWNER, Carol: EDITOR });\n    assert.deepEqual(rawCallSpy.callCount, 2);\n\n    // Bob should be able to move 'docManual' from 'wsHr' workspace to 'wsEng'.\n    homeDb.unwrapQueryResult(\n      await homeDb.moveDoc({ userId: bob.id, urlId: entities.docManual.id }, entities.wsEng.id),\n    );\n    // Prepare an undo after this test case.\n    cleanup.addAfterEach(async () => {\n      await homeDb.moveDoc({ userId: bob.id, urlId: entities.docManual.id }, entities.wsHr.id);\n    });\n\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)),\n      { Alice: OWNER, Bob: OWNER, Carol: EDITOR });   // Newly shared with Alice after the move\n    assert.deepEqual(rawCallSpy.callCount, 3);      // One more underlying call.\n\n    // Just as another test of caching, make sure that without invalidations, calling\n    // getDocAccess() does not cause more underlying calls.\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER, Bob: OWNER, Carol: EDITOR });\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)),\n      { Alice: OWNER, Bob: OWNER, Carol: EDITOR });\n    assert.deepEqual(rawCallSpy.callCount, 3);      // No new underlying calls.\n\n    // Since wsEng inherits from the org, making a change to the org should affect both docs.\n    await homeDb.updateOrgPermissions({ userId: alice.id }, entities.org.id, { users: { \"dave@example.com\": VIEWER } });\n    // Prepare an undo after this test case.\n    cleanup.addAfterEach(async () => {\n      await homeDb.updateOrgPermissions({ userId: alice.id }, entities.org.id, { users: { \"dave@example.com\": null } });\n    });\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER, Bob: OWNER, Carol: EDITOR, Dave: VIEWER });\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)),\n      { Alice: OWNER, Bob: OWNER, Carol: EDITOR, Dave: VIEWER });\n    assert.deepEqual(rawCallSpy.callCount, 5);      // Two more underlying call.\n\n    // Also, making a change to wsEng should affect 2 docs.\n    await homeDb.updateWorkspacePermissions({ userId: alice.id }, entities.wsEng.id, { maxInheritedRole: null });\n    // Prepare an undo after this test case.\n    cleanup.addAfterEach(async () => {\n      await homeDb.updateWorkspacePermissions({ userId: alice.id }, entities.wsEng.id, { maxInheritedRole: OWNER });\n    });\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER });\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)),\n      { Alice: OWNER, Bob: OWNER });                // Bob remains as the explicitly added original creator.\n    assert.deepEqual(rawCallSpy.callCount, 7);    // Two more underlying call.\n  });\n\n  it(\"happens not to invalidate on user name changes\", async function() {\n    // Here's an example of a change that does not invalidate: if a user changes their name, we\n    // don't (currently) invalidate all docs that belong to them. This test would be fine to change\n    // or remove if we change the behavior, it's mainly here to confirm that this missed\n    // invalidation is known and considered acceptable (because name changes are very rare).\n    const rawCallSpy = sandbox.spy(HomeDBManager.prototype, \"getDocAccess\");\n    const bob = entities.users.Bob;\n\n    // Check initial sharing for 'docDesignSpec'.\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER, Bob: OWNER, Carol: EDITOR });\n    assert.deepEqual(rawCallSpy.callCount, 1);\n\n    bob.name = \"Robert\";\n    await bob.save();\n    // Prepare an undo after this test case.\n    cleanup.addAfterEach(async () => { bob.name = \"Bob\"; await bob.save(); });\n\n    // No invalidation: same result, no new underlying call.\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER, Bob: OWNER, Carol: EDITOR });\n    assert.deepEqual(rawCallSpy.callCount, 1);\n\n    // After expiration, we should notice the change.\n    await delay(CachesDeps.DocAccessCacheTTL);\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docDesignSpec.id)),\n      { Alice: OWNER, Robert: OWNER, Carol: EDITOR });\n    assert.deepEqual(rawCallSpy.callCount, 2);\n  });\n\n  function samplePrefs(num: number): DocPrefs {\n    return { foo: { num } } as DocPrefs;\n  }\n\n  it(\"should cache docPrefs and refetch when invalidated or expired\", async function() {\n    const rawCallSpy = sandbox.spy(HomeDBManager.prototype, \"getDocPrefsForUsers\");\n    const bob = entities.users.Bob;\n\n    assert.deepEqual(Array.from(await caches.getDocPrefs(entities.docDesignSpec.id)), []);\n    assert.equal(rawCallSpy.callCount, 1);\n\n    // The value stays cached.\n    assert.deepEqual(Array.from(await caches.getDocPrefs(entities.docDesignSpec.id)), []);\n    assert.equal(rawCallSpy.callCount, 1);\n\n    // A new pref gets noticed immediately.\n    await homeDb.setDocPrefs({ userId: bob.id, urlId: entities.docDesignSpec.id }, { currentUser: samplePrefs(1) });\n    assert.deepEqual(Array.from(await caches.getDocPrefs(entities.docDesignSpec.id)), [[bob.id, samplePrefs(1)]]);\n    assert.equal(rawCallSpy.callCount, 2);\n\n    // A changed pref gets noticed immediately.\n    await homeDb.setDocPrefs({ userId: bob.id, urlId: entities.docDesignSpec.id }, { currentUser: samplePrefs(2) });\n    assert.deepEqual(Array.from(await caches.getDocPrefs(entities.docDesignSpec.id)), [[bob.id, samplePrefs(2)]]);\n    assert.equal(rawCallSpy.callCount, 3);\n\n    // It stays cached until expiration\n    assert.deepEqual(Array.from(await caches.getDocPrefs(entities.docDesignSpec.id)), [[bob.id, samplePrefs(2)]]);\n    assert.equal(rawCallSpy.callCount, 3);\n\n    await delay(CachesDeps.DocAccessCacheTTL);\n\n    // After expiration, it gets refetched.\n    assert.deepEqual(Array.from(await caches.getDocPrefs(entities.docDesignSpec.id)), [[bob.id, samplePrefs(2)]]);\n    assert.equal(rawCallSpy.callCount, 4);    // New underlying call.\n\n    // Removing a pref also gets noticed immediately.\n    await homeDb.setDocPrefs({ userId: bob.id, urlId: entities.docDesignSpec.id },\n      { currentUser: { foo: undefined } as any });\n    assert.deepEqual(Array.from(await caches.getDocPrefs(entities.docDesignSpec.id)), [[bob.id, {}]]);\n  });\n\n  it(\"should invalidate across servers\", async function() {\n    // The Redis operation is mainly tested in PubSubCache and PubSubManager. This is a small but\n    // more comprehensive check that multiple instances of HomeDBManager notice invalidations from\n    // one another.\n\n    // This test case only makes sense for postgres.\n    if (process.env.TYPEORM_TYPE !== \"postgres\") { this.skip(); }\n\n    // Start a server in a separate process pointing to the same DB. Make sure this step does not wipe the DB.\n    const tempEnv = new testUtils.EnvironmentSnapshot();\n    delete process.env.TEST_CLEAN_DATABASE;\n    cleanup.addAfterEach(() => tempEnv.restore());\n\n    const testDir = await testUtils.createTestDir(\"HomeDBCaches\");\n    const server = await TestServer.startServer(\"home\", testDir, \"home\");\n    cleanup.addAfterEach(() => server.stop());\n\n    // Check what our local instance of HomeDBManager sees initially.\n    assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)),\n      { Bob: OWNER, Carol: EDITOR });\n\n    // Remove Carol from wsHr sharing using the OTHER server.\n    const bob = entities.users.Bob;\n    const changedAt = Date.now();\n    const api = server.makeUserApi(entities.org.domain, \"bob\");\n    await api.updateWorkspacePermissions(entities.wsHr.id, { users: { \"carol@example.com\": null } });\n\n    // Prepare an undo for after this test case.\n    cleanup.addAfterEach(async () => {\n      await homeDb.updateWorkspacePermissions({ userId: bob.id }, entities.wsHr.id,\n        { users: { \"carol@example.com\": EDITOR } });\n    });\n\n    // We may not notice immediately, but should notice fairly quickly.\n    await waitForIt(async () => {\n      assert.deepEqual(shortDocAccessResult(await caches.getDocAccess(entities.docManual.id)),\n        { Bob: OWNER });\n    }, 250, 50);\n\n    // Make sure we actually got this *before* expiration.\n    assert.isBelow(Date.now(), changedAt + CachesDeps.DocAccessCacheTTL);\n  });\n});\n\n/**\n * Creates a Home DB fixture, and returns its entities, structured as follows:\n * {\n *   users: User objects for 'Alice', 'Bob', 'Carol', 'Dave'.\n *   org: Alice OWNER/creator, Bob OWNER, Carol EDITOR, Dave not a member.\n *   - wsEng: inherits org permissions.\n *     - docDesignSpec: inherits ws permissions; Alice OWNER/creator\n *     - docBudget: no inherit; Bob OWNER/creator, Carol Editor, Dave VIEWER\n *   - wsHr: no inherit; Bob OWNER/creator, Carol EDITOR.\n *     - docManual: inherits ws permissions; Bob OWNER/creator\n *      -docPayroll: no inherit; Bob OWNER/creator, Alice EDITOR.\n * };\n */\nasync function createTestFixture(homeDb: HomeDBManager) {\n  const email = (u: string) => `${u.toLowerCase()}@example.com`;\n  const addUser = (name: string) => homeDb.getUserByLogin(email(name), { profile: { name, email: email(name) } });\n\n  // Create or fetch users, returns map { alice: dbUser, ... }\n  const users = {\n    Alice: await addUser(\"Alice\"),\n    Bob: await addUser(\"Bob\"),\n    Carol: await addUser(\"Carol\"),\n    Dave: await addUser(\"Dave\"),\n  };\n  type UserName = keyof typeof users;\n\n  // Give Bob an API key like other test utils use, for use with API.\n  users.Bob.apiKey = \"api_key_for_bob\";\n  await users.Bob.save();\n\n  // Helper: permission object (users) for delta\n  const perms = (entries: [UserName, Role][]) =>\n    ({ users: Object.fromEntries(entries.map(([u, role]) => [email(u), role])) }) as PermissionDelta;\n\n  // Helper to add workspace and set its permissions.\n  const addWs = async (creator: User, orgId: number, name: string, permDelta: PermissionDelta) => {\n    const ws = (await homeDb.addWorkspace({ userId: creator.id }, orgId, { name })).data!;\n    await homeDb.updateWorkspacePermissions({ userId: creator.id }, ws.id, permDelta);\n    return ws;\n  };\n\n  // Helper to add doc and set its permissions.\n  const addDoc = async (creator: User, wsId: number, name: string, permDelta: PermissionDelta) => {\n    const doc = (await homeDb.addDocument({ userId: creator.id }, wsId, { name })).data!;\n    await homeDb.updateDocPermissions({ userId: creator.id, urlId: doc.id }, permDelta);\n    return doc;\n  };\n\n  const orgCreator = users.Alice;\n  const org = (await homeDb.addOrg(orgCreator, { name: \"ACME\", domain: \"acme\" },\n    { setUserAsOwner: false, useNewPlan: true })).data!;\n  await homeDb.updateOrgPermissions({ userId: orgCreator.id }, org.id, perms([[\"Bob\", OWNER], [\"Carol\", EDITOR]]));\n\n  // Workspaces\n  const wsEng = await addWs(users.Alice, org.id, \"Engineering\", { maxInheritedRole: OWNER });\n  const wsHr  = await addWs(users.Bob, org.id, \"HR\", { maxInheritedRole: null, ...perms([[\"Carol\", EDITOR]]) });\n\n  // Docs\n  const docDesignSpec   = await addDoc(users.Alice, wsEng.id, \"DesignSpec\", { maxInheritedRole: OWNER });\n  const docBudget       = await addDoc(users.Bob,   wsEng.id, \"Budget\",\n    { maxInheritedRole: null, ...perms([[\"Carol\", EDITOR], [\"Dave\", VIEWER]]) });\n  const docManual       = await addDoc(users.Bob, wsHr.id, \"Manual\", { maxInheritedRole: OWNER });\n  const docPayroll      = await addDoc(users.Bob, wsHr.id, \"Payroll\",\n    { maxInheritedRole: null, ...perms([[\"Alice\", EDITOR]]) });\n\n  return {\n    users,\n    org,\n    wsEng, wsHr,\n    docDesignSpec, docBudget, docManual, docPayroll,\n  };\n}\n"
  },
  {
    "path": "test/gen-server/lib/HomeDBManager.ts",
    "content": "import { FREE_PLAN, STUB_PLAN, TEAM_PLAN } from \"app/common/Features\";\nimport { SHARE_KEY_PREFIX } from \"app/common/gristUrls\";\nimport { UserProfile } from \"app/common/LoginSessionAPI\";\nimport { NEW_DOCUMENT_CODE } from \"app/common/UserAPI\";\nimport { getAnonymousFeatures, Product } from \"app/gen-server/entity/Product\";\nimport { Share } from \"app/gen-server/entity/Share\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport omit from \"lodash/omit\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nconst charonProfile = { email: \"charon@getgrist.com\", name: \"Charon\" };\nconst chimpyProfile = { email: \"chimpy@getgrist.com\", name: \"Chimpy\" };\nconst kiwiProfile = { email: \"kiwi@getgrist.com\", name: \"Kiwi\" };\n\nconst teamOptions = {\n  setUserAsOwner: false, useNewPlan: true, product: TEAM_PLAN,\n};\n\ndescribe(\"HomeDBManager\", function() {\n  let server: TestServer;\n  let home: HomeDBManager;\n  testUtils.setTmpLogLevel(\"error\");\n\n  before(async function() {\n    server = new TestServer(this);\n    await server.start();\n    home = server.dbManager;\n  });\n\n  after(async function() {\n    await server.stop();\n  });\n\n  it(\"can find existing user by email\", async function() {\n    const user = await home.getUserByLogin(\"chimpy@getgrist.com\");\n    assert.equal(user.name, \"Chimpy\");\n  });\n\n  it(\"can create new user by email, with personal org\", async function() {\n    const profile = { email: \"unseen@getgrist.com\", name: \"Unseen\" };\n    const user = await home.getUserByLogin(\"unseen@getgrist.com\", { profile });\n    assert.equal(user.name, \"Unseen\");\n    const orgs = await home.getOrgs(user.id, null);\n    assert.isAtLeast(orgs.data!.length, 1);\n    assert.equal(orgs.data![0].name, \"Personal\");\n    assert.equal(orgs.data![0].owner.name, \"Unseen\");\n  });\n\n  it(\"parallel requests resulting in user creation give consistent results\", async function() {\n    const profile = {\n      email: uuidv4() + \"@getgrist.com\",\n      name: \"Testy McTestyTest\",\n    };\n    const queries = [];\n    for (let i = 0; i < 100; i++) {\n      queries.push(home.getUserByLoginWithRetry(profile.email, { profile }));\n    }\n    const result = await Promise.all(queries);\n    const refUser = result[0];\n    assert(refUser?.personalOrg && refUser.id && refUser.personalOrg.id);\n    result.forEach(user => assert.deepEqual(refUser, user));\n  });\n\n  it(\"can accumulate profile information\", async function() {\n    // log in without a name\n    let user = await home.getUserByLogin(\"unseen2@getgrist.com\");\n    // name is blank\n    assert.equal(user.name, \"\");\n    // log in with a name\n    const profile: UserProfile = { email: \"unseen2@getgrist.com\", name: \"Unseen2\" };\n    user = await home.getUserByLogin(\"unseen2@getgrist.com\", { profile });\n    // name is now set\n    assert.equal(user.name, \"Unseen2\");\n    // log in without a name\n    user = await home.getUserByLogin(\"unseen2@getgrist.com\");\n    // name is still set\n    assert.equal(user.name, \"Unseen2\");\n    // no picture yet\n    assert.equal(user.picture, null);\n    // log in with picture link\n    profile.picture = \"http://picture.pic\";\n    user = await home.getUserByLogin(\"unseen2@getgrist.com\", { profile });\n    // now should have a picture link\n    assert.equal(user.picture, \"http://picture.pic\");\n    // log in without picture\n    user = await home.getUserByLogin(\"unseen2@getgrist.com\");\n    // should still have picture link\n    assert.equal(user.picture, \"http://picture.pic\");\n  });\n\n  it(\"can add an org\", async function() {\n    const user = await home.getUserByLogin(\"chimpy@getgrist.com\");\n    const orgId = (await home.addOrg(user, { name: \"NewOrg\", domain: \"novel-org\" }, teamOptions)).data!.id;\n    const org = await home.getOrg({ userId: user.id }, orgId);\n    assert.equal(org.data!.name, \"NewOrg\");\n    assert.equal(org.data!.domain, \"novel-org\");\n    assert.equal(org.data!.billingAccount.product.name, TEAM_PLAN);\n    await home.deleteOrg({ userId: user.id }, orgId);\n  });\n\n  it(\"creates default plan if defined\", async function() {\n    const user = await home.getUserByLogin(\"chimpy@getgrist.com\");\n    const oldEnv = new testUtils.EnvironmentSnapshot();\n    try {\n      // Set the default product to be the free plan.\n      process.env.GRIST_DEFAULT_PRODUCT = FREE_PLAN;\n      let orgId = (await home.addOrg(user, { name: \"NewOrg\", domain: \"novel-org\" }, {\n        setUserAsOwner: false,\n        useNewPlan: true,\n        // omit plan, to use a default one (teamInitial)\n        // it will either be 'stub' or anything set in GRIST_DEFAULT_PRODUCT\n      })).data!.id;\n      let org = await home.getOrg({ userId: user.id }, orgId);\n      assert.equal(org.data!.name, \"NewOrg\");\n      assert.equal(org.data!.domain, \"novel-org\");\n      assert.equal(org.data!.billingAccount.product.name, FREE_PLAN);\n      await home.deleteOrg({ userId: user.id }, orgId);\n\n      // Now remove the default product, and check that the default plan is used.\n      delete process.env.GRIST_DEFAULT_PRODUCT;\n      orgId = (await home.addOrg(user, { name: \"NewOrg\", domain: \"novel-org\" }, {\n        setUserAsOwner: false,\n        useNewPlan: true,\n      })).data!.id;\n\n      org = await home.getOrg({ userId: user.id }, orgId);\n      assert.equal(org.data!.billingAccount.product.name, STUB_PLAN);\n      await home.deleteOrg({ userId: user.id }, orgId);\n    } finally {\n      oldEnv.restore();\n    }\n  });\n\n  it(\"cannot duplicate a domain\", async function() {\n    const user = await home.getUserByLogin(\"chimpy@getgrist.com\");\n    const domain = \"repeated-domain\";\n    const result = await home.addOrg(user, { name: `${domain}!`, domain }, teamOptions);\n    const orgId = result.data!.id;\n    assert.equal(result.status, 200);\n    await assert.isRejected(home.addOrg(user, { name: `${domain}!`, domain }, teamOptions),\n      /Domain already in use/);\n    await home.deleteOrg({ userId: user.id }, orgId);\n  });\n\n  it(\"cannot add an org with a (blacklisted) dodgy domain\", async function() {\n    const user = await home.getUserByLogin(\"chimpy@getgrist.com\");\n    const userId = user.id;\n    const misses = [\n      \"thing!\", \" thing\", \"ww\", \"docs-999\", \"o-99\", \"_domainkey\", \"www\", \"api\",\n      \"thissubdomainiswaytoolongmyfriendyoushouldrethinkitoratleastsummarizeit\",\n      \"google\", \"login\", \"doc-worker-1-1-1-1\", \"a\", \"bb\", \"x_y\", \"1ogin\",\n    ];\n    const hits = [\n      \"thing\", \"jpl\", \"xyz\", \"appel\", \"123\", \"1google\",\n    ];\n    for (const domain of misses) {\n      const result = await home.addOrg(user, { name: `${domain}!`, domain }, teamOptions);\n      assert.equal(result.status, 400);\n      const org = await home.getOrg({ userId }, domain);\n      assert.equal(org.status, 404);\n    }\n    for (const domain of hits) {\n      const result = await home.addOrg(user, { name: `${domain}!`, domain }, teamOptions);\n      assert.equal(result.status, 200);\n      const org = await home.getOrg({ userId }, domain);\n      assert.equal(org.status, 200);\n      await home.deleteOrg({ userId }, org.data!.id);\n    }\n  });\n\n  it(\"should allow setting doc metadata\", async function() {\n    const beforeRun = new Date();\n    const setDateISO1 = new Date(Date.UTC(1993, 3, 2)).toISOString();\n    const setDateISO2 = new Date(Date.UTC(2004, 6, 18)).toISOString();\n    const setUsage1 = { rowCount: { total: 123 }, dataSizeBytes: 456, attachmentsSizeBytes: 789 };\n    const setUsage2 = { rowCount: { total: 0 }, attachmentsSizeBytes: 0 };\n\n    // Set the doc updatedAt time on Bananas.\n    const primatelyOrgId = await home.testGetId(\"Primately\") as number;\n    const fishOrgId = await home.testGetId(\"Fish\") as number;\n    const applesDocId = await home.testGetId(\"Apples\") as string;\n    const bananasDocId = await home.testGetId(\"Bananas\") as string;\n    const sharkDocId = await home.testGetId(\"Shark\") as string;\n    await home.setDocsMetadata({\n      [applesDocId]: { usage: setUsage1 },\n      [bananasDocId]: { updatedAt: setDateISO1 },\n      [sharkDocId]: { updatedAt: setDateISO2, usage: setUsage2 },\n    });\n\n    // Fetch the doc and check that the updatedAt value is as expected.\n    const kiwi = await home.getUserByLogin(\"kiwi@getgrist.com\");\n    const resp1 = await home.getOrgWorkspaces({ userId: kiwi.id }, primatelyOrgId);\n    assert.equal(resp1.status, 200);\n\n    // Check that the apples metadata is as expected. updatedAt should have been set\n    // when the db was initialized before the update run - it should not have been updated\n    // to 1993. usage should be set.\n    const apples = resp1.data![0].docs.find((doc: any) => doc.name === \"Apples\");\n    const applesUpdate = new Date(apples!.updatedAt);\n    assert.isTrue(applesUpdate < beforeRun);\n    assert.isTrue(applesUpdate > new Date(\"2000-1-1\"));\n    assert.deepEqual(apples!.usage, setUsage1);\n\n    // Check that the bananas metadata is as expected. updatedAt should have been set\n    // to 1993. usage should be null.\n    const bananas = resp1.data![0].docs.find((doc: any) => doc.name === \"Bananas\");\n    assert.equal(bananas!.updatedAt.toISOString(), setDateISO1);\n    assert.equal(bananas!.usage, null);\n\n    // Check that the shark metadata is as expected. updatedAt should have been set\n    // to 2004. usage should be set.\n    const resp2 = await home.getOrgWorkspaces({ userId: kiwi.id }, fishOrgId);\n    assert.equal(resp2.status, 200);\n    const shark = resp2.data![0].docs.find((doc: any) => doc.name === \"Shark\");\n    assert.equal(shark!.updatedAt.toISOString(), setDateISO2);\n    assert.deepEqual(shark!.usage, setUsage2);\n  });\n\n  it(\"can pool orgs for two users\", async function() {\n    const charonOrgs = (await home.getOrgs([charonProfile], null)).data!;\n    const kiwiOrgs = (await home.getOrgs([kiwiProfile], null)).data!;\n    const pooledOrgs = (await home.getOrgs([charonProfile, kiwiProfile], null)).data!;\n    // test there is some overlap\n    assert.isAbove(pooledOrgs.length, charonOrgs.length);\n    assert.isAbove(pooledOrgs.length, kiwiOrgs.length);\n    assert.isBelow(pooledOrgs.length, charonOrgs.length + kiwiOrgs.length);\n    // check specific orgs returned\n    assert.sameDeepMembers(charonOrgs.map(org => org.name),\n      [\"Abyss\", \"Fish\", \"NASA\", \"Charonland\", \"Chimpyland\"]);\n    assert.sameDeepMembers(kiwiOrgs.map(org => org.name),\n      [\"Fish\", \"Flightless\", \"Kiwiland\", \"Primately\"]);\n    assert.sameDeepMembers(pooledOrgs.map(org => org.name),\n      [\"Abyss\", \"Fish\", \"Flightless\", \"NASA\", \"Primately\", \"Charonland\", \"Chimpyland\", \"Kiwiland\"]);\n\n    // make sure if there are no profiles that we get no orgs\n    const emptyOrgs = (await home.getOrgs([], null)).data!;\n    assert.lengthOf(emptyOrgs, 0);\n  });\n\n  it(\"can pool orgs for three users\", async function() {\n    const pooledOrgs = (await home.getOrgs([charonProfile, chimpyProfile, kiwiProfile], null)).data!;\n    assert.sameDeepMembers(pooledOrgs.map(org => org.name), [\n      \"Abyss\",\n      \"EmptyOrg\",\n      \"EmptyWsOrg\",\n      \"Fish\",\n      \"Flightless\",\n      \"FreeTeam\",\n      \"NASA\",\n      \"Primately\",\n      \"TestAuditLogs\",\n      \"TestDailyApiLimit\",\n      \"TestMaxNewUserInvites\",\n      \"Charonland\",\n      \"Chimpyland\",\n      \"Kiwiland\",\n    ]);\n  });\n\n  it(\"can pool orgs for multiple users with non-normalized emails\", async function() {\n    const refOrgs = (await home.getOrgs([charonProfile, kiwiProfile], null)).data!;\n    // Profiles in sessions can have email addresses with arbitrary capitalization.\n    const oddCharonProfile = { email: \"CharON@getgrist.COM\", name: \"charON\" };\n    const oddKiwiProfile = { email: \"KIWI@getgrist.COM\", name: \"KIwi\" };\n    const orgs = (await home.getOrgs([oddCharonProfile, kiwiProfile, oddKiwiProfile], null)).data!;\n    assert.deepEqual(refOrgs, orgs);\n  });\n\n  it(\"can get best user for accessing org\", async function() {\n    let suggestion = await home.getBestUserForOrg([charonProfile, kiwiProfile],\n      await home.testGetId(\"Fish\") as number);\n    assert.deepEqual(suggestion, {\n      id: await home.testGetId(\"Kiwi\") as number,\n      email: kiwiProfile.email,\n      name: kiwiProfile.name,\n      access: \"editors\",\n      perms: 15,\n    });\n    suggestion = await home.getBestUserForOrg([charonProfile, kiwiProfile],\n      await home.testGetId(\"Abyss\") as number);\n    assert.equal(suggestion!.email, charonProfile.email);\n    suggestion = await home.getBestUserForOrg([charonProfile, kiwiProfile],\n      await home.testGetId(\"EmptyOrg\") as number);\n    assert.equal(suggestion, null);\n  });\n\n  it(\"skips picking a user for merged personal org\", async function() {\n    // There isn't any particular way to favor one user over another when accessing\n    // the merged personal org.\n    assert.equal(await home.getBestUserForOrg([charonProfile, kiwiProfile], 0), null);\n  });\n\n  it(\"can access billingAccount for org\", async function() {\n    await server.addBillingManager(\"Chimpy\", \"nasa\");\n    const chimpyScope = { userId: await home.testGetId(\"Chimpy\") as number };\n    const charonScope = { userId: await home.testGetId(\"Charon\") as number };\n\n    // billing account without orgs+managers\n    let billingAccount = await home.getBillingAccount(chimpyScope, \"nasa\", false);\n    assert.hasAllKeys(billingAccount,\n      [\"id\", \"individual\", \"inGoodStanding\", \"status\", \"stripeCustomerId\",\n        \"stripeSubscriptionId\", \"stripePlanId\", \"product\", \"paid\", \"isManager\",\n        \"externalId\", \"externalOptions\", \"features\", \"paymentLink\"]);\n\n    // billing account with orgs+managers\n    billingAccount = await home.getBillingAccount(chimpyScope, \"nasa\", true);\n    assert.hasAllKeys(billingAccount,\n      [\"id\", \"individual\", \"inGoodStanding\", \"status\", \"stripeCustomerId\",\n        \"stripeSubscriptionId\", \"stripePlanId\", \"product\", \"orgs\", \"managers\", /* <-- here */\n        \"paid\", \"externalId\", \"externalOptions\", \"features\", \"paymentLink\"]);\n\n    await assert.isRejected(home.getBillingAccount(charonScope, \"nasa\", true),\n      /User does not have access to billing account/);\n  });\n\n  // TypeORM does not handle parameter name reuse well, so we monkey-patch to detect it.\n  it(\"will fail on parameter collision\", async function() {\n    // Check collision in a simple query.\n    // Note: it is query construction that fails, not query execution.\n    assert.throws(() => home.connection.createQueryBuilder().from(\"orgs\", \"orgs\")\n      .where(\"id = :id\", { id: 1 }).andWhere(\"id = :id\", { id: 2 }),\n    /parameter collision/);\n\n    // Check collision between subqueries.\n    assert.throws(\n      () => home.connection.createQueryBuilder().from(\"orgs\", \"orgs\")\n        .select(q => q.subQuery().from(\"orgs\", \"orgs\").where(\"x IN :x\", { x: [\"five\"] }))\n        .addSelect(q => q.subQuery().from(\"orgs\", \"orgs\").where(\"x IN :x\", { x: [\"six\"] })),\n      /parameter collision/);\n  });\n\n  it(\"can get the product associated with a docId\", async function() {\n    const urlId = \"sampledocid_6\";\n    const userId = await home.testGetId(\"Chimpy\") as number;\n    const scope = { userId, urlId };\n    const doc = await home.getDoc(scope);\n    const product = (await home.getDocProduct(urlId))!;\n    assert.equal(doc.workspace.org.billingAccount.product.id, product.id);\n    const features = await home.getDocFeatures(urlId);\n    assert.deepEqual(features, {\n      workspaces: true,\n      vanityDomain: true,\n    });\n  });\n\n  it(\"reads proper features for a doc\", async function() {\n    // Add new product with a feature.\n    const product = new Product();\n    product.name = \"dummyProduct\";\n    product.features = { maxDocsPerOrg: 2 };\n    await home.connection.manager.save(product);\n\n    // Add new org for chimpy with that product.\n    const user = await home.getUserByLogin(\"chimpy@getgrist.com\");\n    const userId = user.id;\n    const orgId = (await home.addOrg(user, { name: \"features\", domain: \"features\" }, {\n      ...teamOptions, product: product.name,\n    })).data!.id;\n\n    const ws = home.unwrapQueryResult(\n      await home.getOrgWorkspaces({ userId }, \"features\"),\n    )[0];\n\n    // Add a doc to that org.\n    const addedDoc = (await home.addDocument({ userId }, ws.id, { name: \"MyDoc1\" })).data!;\n    const readDoc = await home.getDoc({ userId, urlId: addedDoc.urlId! });\n\n    // Check that the doc has the expected features.\n    assert.equal(readDoc.workspace.org.billingAccount.getFeatures().maxDocsPerOrg, 2);\n\n    // Add an override on the billing account level.\n    await home.updateBillingAccount({ userId }, orgId, async (ba, tx) => {\n      ba.features = { maxDocsPerOrg: 3 };\n    });\n\n    // Reread the doc and check that it has the updated features.\n    const updatedDoc = await home.getDoc({ userId, urlId: addedDoc.urlId! });\n    assert.equal(updatedDoc.workspace.org.billingAccount.getFeatures().maxDocsPerOrg, 3);\n\n    // Now open this document as fork.\n    const forkId = `${addedDoc.id}~${uuidv4()}~${userId}`;\n    const forkedRead = await home.getDoc({ userId, urlId: forkId });\n\n    // Check that the forked document has the same features as the original.\n    assert.equal(forkedRead.workspace.org.billingAccount.getFeatures().maxDocsPerOrg, 3);\n\n    // Create a new document using the NEW_DOCUMENT_CODE\n    const newId = `${NEW_DOCUMENT_CODE}~${uuidv4()}~${userId}`;\n    const newRead = await home.getDoc({ userId, urlId: newId });\n    // Check that the new document has the same features as the original.\n    assert.deepEqual(newRead.workspace.org.billingAccount.getFeatures(), getAnonymousFeatures());\n\n    // Open document using share key.\n    const share = new Share();\n    share.docId = addedDoc.id;\n    share.key = \"shareKey\";\n    share.linkId = \"shareLink\";\n    share.options = {};\n    await home.connection.manager.save(share);\n\n    const shareRead = await home.getDoc({ userId, urlId: `${SHARE_KEY_PREFIX}${share.key}` });\n    // Check that the share document has the same features as the original.\n    assert.equal(shareRead.workspace.org.billingAccount.getFeatures().maxDocsPerOrg, 3);\n\n    // Remove the override.\n    await home.updateBillingAccount({ userId }, orgId, async (ba, tx) => {\n      ba.features = null;\n    });\n\n    // Reread the doc and check that it has the original features.\n    const finalDoc = await home.getDoc({ userId, urlId: addedDoc.urlId! });\n    assert.equal(finalDoc.workspace.org.billingAccount.getFeatures().maxDocsPerOrg, 2);\n  });\n\n  it(\"can fork docs\", async function() {\n    const user1 = await home.getUserByLogin(\"kiwi@getgrist.com\");\n    const user1Id = user1.id;\n    const orgId = await home.testGetId(\"Fish\") as number;\n    const doc1Id = await home.testGetId(\"Shark\") as string;\n    const scope = { userId: user1Id, urlId: doc1Id };\n    const doc1 = await home.getDoc(scope);\n\n    // Document \"Shark\" should initially have no forks.\n    const resp1 = await home.getOrgWorkspaces({ userId: user1Id }, orgId);\n    const resp1Doc = resp1.data![0].docs.find((d: any) => d.name === \"Shark\");\n    assert.deepEqual(resp1Doc!.forks, []);\n\n    // Fork \"Shark\" as Kiwi and check that their fork is listed.\n    const fork1Id = `${doc1Id}_fork_1`;\n    await home.forkDoc(user1Id, doc1, fork1Id);\n    const resp2 = await home.getOrgWorkspaces({ userId: user1Id }, orgId);\n    const resp2Doc = resp2.data![0].docs.find((d: any) => d.name === \"Shark\");\n    assert.deepEqual(\n      resp2Doc!.forks.map((fork: any) => omit(fork, \"updatedAt\")),\n      [\n        {\n          id: fork1Id,\n          trunkId: doc1Id,\n          createdBy: user1Id,\n          options: null,\n        },\n      ],\n    );\n\n    // Fork \"Shark\" again and check that Kiwi can see both forks.\n    const fork2Id = `${doc1Id}_fork_2`;\n    await home.forkDoc(user1Id, doc1, fork2Id);\n    const resp3 = await home.getOrgWorkspaces({ userId: user1Id }, orgId);\n    const resp3Doc = resp3.data![0].docs.find((d: any) => d.name === \"Shark\");\n    assert.sameDeepMembers(\n      resp3Doc!.forks.map((fork: any) => omit(fork, \"updatedAt\")),\n      [\n        {\n          id: fork1Id,\n          trunkId: doc1Id,\n          createdBy: user1Id,\n          options: null,\n        },\n        {\n          id: fork2Id,\n          trunkId: doc1Id,\n          createdBy: user1Id,\n          options: null,\n        },\n      ],\n    );\n\n    // Now fork \"Shark\" as Chimpy, and check that Kiwi's forks aren't listed.\n    const user2 = await home.getUserByLogin(\"chimpy@getgrist.com\");\n    const user2Id = user2.id;\n    const resp4 = await home.getOrgWorkspaces({ userId: user2Id }, orgId);\n    const resp4Doc = resp4.data![0].docs.find((d: any) => d.name === \"Shark\");\n    assert.deepEqual(resp4Doc!.forks, []);\n\n    const fork3Id = `${doc1Id}_fork_3`;\n    await home.forkDoc(user2Id, doc1, fork3Id);\n    const resp5 = await home.getOrgWorkspaces({ userId: user2Id }, orgId);\n    const resp5Doc = resp5.data![0].docs.find((d: any) => d.name === \"Shark\");\n    assert.deepEqual(\n      resp5Doc!.forks.map((fork: any) => omit(fork, \"updatedAt\")),\n      [\n        {\n          id: fork3Id,\n          trunkId: doc1Id,\n          createdBy: user2Id,\n          options: null,\n        },\n      ],\n    );\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/Housekeeper.ts",
    "content": "import { TelemetryEvent, TelemetryMetadataByLevel } from \"app/common/Telemetry\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { Housekeeper } from \"app/gen-server/lib/Housekeeper\";\nimport { Telemetry } from \"app/server/lib/Telemetry\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { openClient } from \"test/server/gristClient\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\nimport moment from \"moment\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"Housekeeper\", function() {\n  testUtils.setTmpLogLevel(\"error\");\n  this.timeout(60000);\n\n  const org: string = \"testy\";\n  const sandbox = sinon.createSandbox();\n  let home: TestServer;\n  let keeper: Housekeeper;\n\n  let oldEnv: testUtils.EnvironmentSnapshot;\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    process.env.GRIST_DEFAULT_EMAIL = \"ham@getgrist.com\";\n\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n    const api = await home.createHomeApi(\"chimpy\", \"docs\");\n    await api.newOrg({ name: org, domain: org });\n    keeper = home.server.housekeeper;\n    await keeper.stop();\n  });\n\n  after(async function() {\n    await home.stop();\n    sandbox.restore();\n    oldEnv.restore();\n  });\n\n  async function getDoc(docId: string) {\n    const manager = home.dbManager.connection.manager;\n    return manager.findOneOrFail(Document, { where: { id: docId } });\n  }\n\n  async function getWorkspace(wsId: number) {\n    const manager = home.dbManager.connection.manager;\n    return manager.findOneOrFail(Workspace, { where: { id: wsId } });\n  }\n\n  function daysAgo(days: number): Date {\n    return moment().subtract(days, \"days\").toDate();\n  }\n\n  async function ageDoc(docId: string, days: number) {\n    const dbDoc = await getDoc(docId);\n    dbDoc.removedAt = daysAgo(days);\n    await dbDoc.save();\n  }\n\n  async function ageDisabledDoc(docId: string, days: number) {\n    const dbDoc = await getDoc(docId);\n    dbDoc.disabledAt = daysAgo(days);\n    await dbDoc.save();\n  }\n\n  async function ageWorkspace(wsId: number, days: number) {\n    const dbWorkspace = await getWorkspace(wsId);\n    dbWorkspace.removedAt = daysAgo(days);\n    await dbWorkspace.save();\n  }\n\n  async function ageFork(forkId: string, days: number) {\n    const dbFork = await getDoc(forkId);\n    dbFork.updatedAt = daysAgo(days);\n    await dbFork.save();\n  }\n\n  it(\"can delete old soft-deleted docs and workspaces\", async function() {\n    // Make four docs in one workspace, two in another.\n    const api = await home.createHomeApi(\"chimpy\", org);\n    const adminApi = await home.createHomeApi(\"ham\", \"docs\", true);\n    const ws1 = await api.newWorkspace({ name: \"ws1\" }, \"current\");\n    const ws2 = await api.newWorkspace({ name: \"ws2\" }, \"current\");\n    const doc11 = await api.newDoc({ name: \"doc11\" }, ws1);\n    const doc12 = await api.newDoc({ name: \"doc12\" }, ws1);\n    const doc13 = await api.newDoc({ name: \"doc13\" }, ws1);\n    const doc14 = await api.newDoc({ name: \"doc14\" }, ws1);\n    const doc15 = await api.newDoc({ name: \"doc15\" }, ws1);\n    const doc21 = await api.newDoc({ name: \"doc21\" }, ws2);\n    const doc22 = await api.newDoc({ name: \"doc22\" }, ws2);\n\n    // Soft-delete some of the docs, and one workspace.\n    await api.softDeleteDoc(doc11);\n    await api.softDeleteDoc(doc12);\n    await api.softDeleteDoc(doc13);\n    await api.softDeleteWorkspace(ws2);\n    // Also disable one doc\n    await adminApi.disableDoc(doc15);\n\n    // Check that nothing is deleted by housekeeper.\n    await keeper.deleteTrash();\n    await assert.isFulfilled(getDoc(doc11));\n    await assert.isFulfilled(getDoc(doc12));\n    await assert.isFulfilled(getDoc(doc13));\n    await assert.isFulfilled(getDoc(doc14));\n    await assert.isFulfilled(getDoc(doc15));\n    await assert.isFulfilled(getDoc(doc21));\n    await assert.isFulfilled(getDoc(doc22));\n    await assert.isFulfilled(getWorkspace(ws1));\n    await assert.isFulfilled(getWorkspace(ws2));\n\n    // Age a doc and workspace somewhat, but not enough to trigger hard-deletion.\n    await ageDoc(doc11, 10);\n    await ageWorkspace(ws2, 20);\n    await keeper.deleteTrash();\n    await assert.isFulfilled(getDoc(doc11));\n    await assert.isFulfilled(getWorkspace(ws2));\n\n    // Prematurely age two of the soft-deleted docs, and the soft-deleted workspace.\n    await ageDoc(doc11, 40);\n    await ageDoc(doc12, 40);\n    await ageWorkspace(ws2, 40);\n\n    // Make sure that exactly those docs are deleted by housekeeper.\n    await keeper.deleteTrash();\n    await assert.isRejected(getDoc(doc11));\n    await assert.isRejected(getDoc(doc12));\n    await assert.isFulfilled(getDoc(doc13));\n    await assert.isFulfilled(getDoc(doc14));\n    await assert.isRejected(getDoc(doc21));\n    await assert.isRejected(getDoc(doc22));\n    await assert.isFulfilled(getWorkspace(ws1));\n    await assert.isRejected(getWorkspace(ws2));\n\n    // Age disabling time, see doc isn't deleted\n    await ageDisabledDoc(doc15, 40);\n    await keeper.deleteTrash();\n    await assert.isFulfilled(getDoc(doc15));\n\n    // Now age the disabled doc deletion time and check it's deleted\n    await ageDoc(doc15, 40);\n    await keeper.deleteTrash();\n    await assert.isRejected(getDoc(doc15));\n  });\n\n  it(\"enforces exclusivity of housekeeping\", async function() {\n    const first = keeper.deleteTrashExclusively();\n    const second = keeper.deleteTrashExclusively();\n    assert.equal(await first, true);\n    assert.equal(await second, false);\n    assert.equal(await keeper.deleteTrashExclusively(), false);\n    await keeper.testClearExclusivity();\n    assert.equal(await keeper.deleteTrashExclusively(), true);\n  });\n\n  it(\"can delete old forks\", async function() {\n    // Make a document with some forks.\n    const api = await home.createHomeApi(\"chimpy\", org);\n    const ws3 = await api.newWorkspace({ name: \"ws3\" }, \"current\");\n    const trunk = await api.newDoc({ name: \"trunk\" }, ws3);\n    const session = await api.getSessionActive();\n    const client = await openClient(home.server, session.user.email, session.org?.domain || \"docs\");\n    await client.openDocOnConnect(trunk);\n    const forkResponse1 = await client.send(\"fork\", 0);\n    const forkResponse2 = await client.send(\"fork\", 0);\n    const forkPath1 = home.server.getStorageManager().getPath(forkResponse1.data.docId);\n    const forkPath2 = home.server.getStorageManager().getPath(forkResponse2.data.docId);\n    const forkId1 = forkResponse1.data.forkId;\n    const forkId2 = forkResponse2.data.forkId;\n\n    // Age the forks somewhat, but not enough to trigger hard-deletion.\n    await ageFork(forkId1, 10);\n    await ageFork(forkId2, 20);\n    await keeper.deleteTrash();\n    await assert.isFulfilled(getDoc(forkId1));\n    await assert.isFulfilled(getDoc(forkId2));\n    assert.equal(await fse.pathExists(forkPath1), true);\n    assert.equal(await fse.pathExists(forkPath2), true);\n\n    // Age one of the forks beyond the cleanup threshold.\n    await ageFork(forkId2, 40);\n\n    // Make sure that only that fork is deleted by housekeeper.\n    await keeper.deleteTrash();\n    await assert.isFulfilled(getDoc(forkId1));\n    await assert.isRejected(getDoc(forkId2));\n    assert.equal(await fse.pathExists(forkPath1), true);\n    assert.equal(await fse.pathExists(forkPath2), false);\n  });\n\n  it(\"can log metrics about sites\", async function() {\n    const logMessages: [TelemetryEvent, TelemetryMetadataByLevel?][] = [];\n    sandbox.stub(Telemetry.prototype, \"shouldLogEvent\").callsFake(name => true);\n    sandbox.stub(Telemetry.prototype, \"logEvent\").callsFake((_, name, meta) => {\n      // Skip document usage events that could be arriving in the\n      // middle of this test.\n      if (name !== \"documentUsage\") {\n        logMessages.push([name, meta]);\n      }\n      return Promise.resolve();\n    });\n    await keeper.logMetrics();\n    assert.isNotEmpty(logMessages);\n    let [event, meta] = logMessages[0];\n    assert.equal(event, \"siteUsage\");\n    assert.hasAllKeys(meta?.limited, [\n      \"siteId\",\n      \"siteType\",\n      \"inGoodStanding\",\n      \"numDocs\",\n      \"numWorkspaces\",\n      \"numMembers\",\n      \"lastActivity\",\n      \"earliestDocCreatedAt\",\n    ]);\n    assert.hasAllKeys(meta?.full, [\n      \"stripePlanId\",\n    ]);\n    [event, meta] = logMessages[logMessages.length - 1];\n    assert.equal(event, \"siteMembership\");\n    assert.hasAllKeys(meta?.limited, [\n      \"siteId\",\n      \"siteType\",\n      \"numOwners\",\n      \"numEditors\",\n      \"numViewers\",\n    ]);\n    assert.isUndefined(meta?.full);\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/emails.ts",
    "content": "import { PermissionData, PermissionDelta } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\n\ndescribe(\"emails\", function() {\n  let server: TestServer;\n  let serverUrl: string;\n  testUtils.setTmpLogLevel(\"error\");\n\n  const regular = \"chimpy@getgrist.com\";\n  const variant = \"Chimpy@GETgrist.com\";\n  const apiKey = configForUser(\"Chimpy\");\n  let ref: (email: string) => Promise<string>;\n\n  beforeEach(async function() {\n    this.timeout(5000);\n    server = new TestServer(this);\n    ref = (email: string) => server.dbManager.getUserByLogin(email).then(user => user.ref);\n    serverUrl = await server.start();\n  });\n\n  afterEach(async function() {\n    await server.stop();\n  });\n\n  it(\"email capitalization from provider is sticky\", async function() {\n    let cookie = await server.getCookieLogin(\"nasa\", { email: regular, name: \"Chimpy\" });\n    const userRef = await ref(regular);\n    // profile starts off with chimpy@ email\n    let resp = await axios.get(`${serverUrl}/o/nasa/api/profile/user`, cookie);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, {\n      id: 1, email: regular, name: \"Chimpy\", ref: userRef, picture: null, allowGoogleLogin: true,\n    });\n\n    // now we log in with simulated provider giving a Chimpy@ capitalization.\n    cookie = await server.getCookieLogin(\"nasa\", { email: variant, name: \"Chimpy\" });\n    resp = await axios.get(`${serverUrl}/o/nasa/api/profile/user`, cookie);\n    assert.equal(resp.status, 200);\n    // Chimpy@ is now what we see in our profile, but our id is still the same.\n    assert.deepEqual(resp.data, {\n      id: 1, email: variant, loginEmail: regular, name: \"Chimpy\", ref: userRef, picture: null, allowGoogleLogin: true,\n    });\n\n    // read our profile with api key (no session involved) and make sure result is the same.\n    resp = await axios.get(`${serverUrl}/api/profile/user`, apiKey);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, {\n      id: 1, email: variant, loginEmail: regular, name: \"Chimpy\", ref: userRef, picture: null, allowGoogleLogin: true,\n    });\n  });\n\n  it(\"access endpoints show and accept display emails\", async function() {\n    // emails are used in access endpoints - make sure they provide the display email.\n\n    const resources = [\n      { type: \"orgs\", id: await server.dbManager.testGetId(\"NASA\") },\n      { type: \"workspaces\", id: await server.dbManager.testGetId(\"Horizon\") },\n      { type: \"docs\", id: await server.dbManager.testGetId(\"Jupiter\") },\n    ] as const;\n\n    for (const res of resources) {\n      // initially, should report regular chimpy address\n      const resp = await axios.get(`${serverUrl}/api/${res.type}/${res.id}/access`, apiKey);\n      assert.equal(resp.status, 200);\n      const delta: PermissionData = resp.data;\n      assert.notInclude(delta.users.map(u => u.email), variant);\n      assert.include(delta.users.map(u => u.email), regular);\n    }\n\n    const cookie = await server.getCookieLogin(\"nasa\", { email: variant, name: \"Chimpy\" });\n    await axios.get(`${serverUrl}/o/nasa/api/orgs`, cookie);\n\n    for (const res of resources) {\n      // now, should report variant chimpy address\n      let resp = await axios.get(`${serverUrl}/api/${res.type}/${res.id}/access`, apiKey);\n      assert.equal(resp.status, 200);\n      const delta: PermissionData = resp.data;\n      assert.include(delta.users.map(u => u.email), variant);\n      assert.notInclude(delta.users.map(u => u.email), regular);\n\n      // and make sure arbitrary capitalization is accepted and effective.\n      const delta2: { delta: PermissionDelta } = {\n        delta: {\n          users: {\n            \"chImPy@getGRIst.com\": \"viewers\",\n          },\n        },\n      };\n      resp = await axios.patch(`${serverUrl}/api/${res.type}/${res.id}/access`, delta2, apiKey);\n      // expect an error complaining about not being able to change own permissions.\n      assert.match(resp.data.error, /own permissions/);\n    }\n  });\n\n  it(\"PATCH access endpoints behave reasonably when multiple versions of email given\", async function() {\n    const orgId = await server.dbManager.testGetId(\"NASA\");\n\n    let resp = await axios.get(`${serverUrl}/api/orgs/${orgId}/access`, apiKey);\n    assert.deepEqual(resp.data, { users: [\n      {\n        id: 1,\n        name: \"Chimpy\",\n        email: \"chimpy@getgrist.com\",\n        ref: await ref(\"chimpy@getgrist.com\"),\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      },\n      {\n        id: 3,\n        name: \"Charon\",\n        email: \"charon@getgrist.com\",\n        ref: await ref(\"charon@getgrist.com\"),\n        picture: null,\n        access: \"guests\",\n        isMember: false,\n      },\n    ] });\n\n    const delta: { delta: PermissionDelta } = {\n      delta: {\n        users: {\n          \"kiWI@getGRIst.com\": \"viewers\",\n          \"KIwi@getgrist.com\": \"editors\",\n          \"charON@getgrist.com\": null,\n        },\n      },\n    };\n    resp = await axios.patch(`${serverUrl}/api/orgs/${orgId}/access`, delta, apiKey);\n    assert.equal(resp.status, 200);\n\n    resp = await axios.get(`${serverUrl}/api/orgs/${orgId}/access`, apiKey);\n    assert.deepEqual(resp.data, { users: [\n      {\n        id: 1,\n        name: \"Chimpy\",\n        email: \"chimpy@getgrist.com\",\n        ref: await ref(\"chimpy@getgrist.com\"),\n        picture: null,\n        access: \"owners\",\n        isMember: true,\n      },\n      {\n        id: 2,\n        name: \"Kiwi\",\n        email: \"kiwi@getgrist.com\",\n        ref: await ref(\"kiwi@getgrist.com\"),\n        picture: null,\n        access: \"editors\",\n        isMember: true,\n      },\n    ] });\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/everyone.ts",
    "content": "import { Workspace } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"everyone\", function() {\n  let home: TestServer;\n  testUtils.setTmpLogLevel(\"error\");\n\n  before(async function() {\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n  });\n\n  after(async function() {\n    await home.stop();\n  });\n\n  /**\n   * Assert that the specified workspaces and their material are public,\n   * and that all other workspaces are not.\n   */\n  async function assertPublic(wss: Workspace[], publicWorkspaces: string[]) {\n    for (const ws of wss) {\n      const expectedPublic = publicWorkspaces.includes(ws.name) || undefined;\n      assert.equal(ws.public, expectedPublic);\n      for (const doc of ws.docs) {\n        assert.equal(doc.public, expectedPublic);\n      }\n    }\n  }\n\n  it(\"support account can share a listed workspace with all users\", async function() {\n    // Share a workspace in support's personal org with everyone\n    let api = await home.createHomeApi(\"Support\", \"docs\");\n    await home.upgradePersonalOrg(\"Support\");\n    const wsId = await api.newWorkspace({ name: \"Samples\" }, \"current\");\n    const docId = await api.newDoc({ name: \"an example\" }, wsId);\n    await api.updateWorkspacePermissions(wsId, {\n      users: { \"everyone@getgrist.com\": \"viewers\",\n        \"anon@getgrist.com\": \"viewers\" },\n    });\n\n    // Check a fresh user can see that workspace\n    const altApi = await home.createHomeApi(\"testuser\", \"docs\");\n    let wss = await altApi.getOrgWorkspaces(\"current\");\n    assert.deepEqual(wss.map(ws => ws.name), [\"Home\", \"Samples\"]);\n    assert.deepEqual(wss[1].docs.map(doc => doc.id), [docId]);\n\n    // Check that public flag is set in everything the fresh user can see outside its Home.\n    await assertPublic(wss, [\"Samples\"]);\n\n    // Check existing users can see that workspace\n    const chimpyApi = await home.createHomeApi(\"Chimpy\", \"docs\");\n    wss = await chimpyApi.getOrgWorkspaces(\"current\");\n    assert.deepEqual(wss.map(ws => ws.name), [\"Private\", \"Public\", \"Samples\"]);\n    assert.deepEqual(wss.map(ws => ws.isSupportWorkspace), [false, false, true]);\n    // Public and Private could be in either order, but Samples should be last\n    // (api returns workspaces in chronological order).\n    assert.equal(wss[2].name, \"Samples\");\n    assert.deepEqual(wss[2].docs.map(doc => doc.id), [docId]);\n    await assertPublic(wss, [\"Samples\"]);\n\n    // Check that workspace also shows up in regular orgs\n    const nasaApi = await home.createHomeApi(\"Chimpy\", \"nasa\");\n    wss = await nasaApi.getOrgWorkspaces(\"current\");\n    assert.deepEqual(wss.map(ws => ws.name), [\"Horizon\", \"Rovers\", \"Samples\"]);\n    assert.deepEqual(wss.map(ws => ws.isSupportWorkspace), [false, false, true]);\n    await assertPublic(wss, [\"Samples\"]);\n\n    // Need to recreate api because of cookies\n    api = await home.createHomeApi(\"Support\", \"docs\");\n    await api.deleteWorkspace(wsId);\n  });\n\n  it(\"can share unlisted docs in personal org with all users\", async function() {\n    const api = await home.createHomeApi(\"Supportish\", \"docs\");\n    await home.upgradePersonalOrg(\"Supportish\");\n    const wsId = await api.newWorkspace({ name: \"Samples2\" }, \"current\");\n    const docId = await api.newDoc({ name: \"an example\" }, wsId);\n    // Check other users cannot access the doc yet\n    const chimpyApi = await home.createHomeApi(\"Chimpy\", \"docs\", true);\n    await assert.isRejected(chimpyApi.getDoc(docId), /access denied/);\n    // Share doc with everyone\n    await api.updateDocPermissions(docId, {\n      users: { \"everyone@getgrist.com\": \"viewers\" },\n    });\n    // Check other users can access the doc now\n    assert.equal((await chimpyApi.getDoc(docId)).access, \"viewers\");\n    // Check that doc is marked as public\n    assert.equal((await chimpyApi.getDoc(docId)).public, true);\n    // Check they don't see doc listed\n    let wss = await chimpyApi.getOrgWorkspaces(\"current\");\n    assert.deepEqual(wss.map(ws => ws.name), [\"Private\", \"Public\"]);\n\n    // Share every way possible via api\n    await api.updateWorkspacePermissions(wsId, {\n      users: { \"everyone@getgrist.com\": \"viewers\" },\n    });\n    await assert.isRejected(api.updateOrgPermissions(0, {\n      users: { \"everyone@getgrist.com\": \"viewers\" },\n    }), /cannot share with everyone at top level/);\n    // Check existing users still don't see doc listed\n    wss = await chimpyApi.getOrgWorkspaces(\"current\");\n    assert.deepEqual(wss.map(ws => ws.name), [\"Private\", \"Public\"]);\n  });\n\n  it(\"can share unlisted docs in team sites with all users\", async function() {\n    const chimpyApi = await home.createHomeApi(\"Chimpy\", \"nasa\", true);\n    const wsId = await chimpyApi.newWorkspace({ name: \"Samples\" }, \"current\");\n    const docId = await chimpyApi.newDoc({ name: \"an example\" }, wsId);\n\n    // Check a fresh user cannot see that doc\n    const altApi = await home.createHomeApi(\"testuser\", \"nasa\", false, false);\n    await assert.isRejected(altApi.getDoc(docId), /access denied/i);\n\n    // Share doc with everyone\n    await chimpyApi.updateDocPermissions(docId, {\n      users: { \"everyone@getgrist.com\": \"viewers\" },\n    });\n\n    // Check a fresh user can now see that doc\n    await assert.isFulfilled(altApi.getDoc(docId));\n\n    // Check that doc is marked as public\n    assert.equal((await altApi.getDoc(docId)).public, true);\n\n    // But can't list that doc in team site\n    await assert.isRejected(altApi.getOrgWorkspaces(\"current\"), /access denied/);\n\n    // Also can't list the doc in workspace\n    await assert.isRejected(altApi.getWorkspace(wsId), /access denied/);\n  });\n\n  it(\"can share public docs without them being listed indirectly\", async function() {\n    const chimpyApi = await home.createHomeApi(\"Chimpy\", \"nasa\", true);\n    const wsId = await chimpyApi.newWorkspace({ name: \"Samples\" }, \"current\");\n    const docId = await chimpyApi.newDoc({ name: \"an example\" }, wsId);\n    const docId2 = await chimpyApi.newDoc({ name: \"another example\" }, wsId);\n\n    // Share one doc with everyone\n    await chimpyApi.updateDocPermissions(docId, {\n      users: { \"everyone@getgrist.com\": \"viewers\" },\n    });\n\n    // Share one doc with everyone, the other with a specific test user at the doc level\n    const altApi = await home.createHomeApi(\"testuser\", \"nasa\", false, false);\n    await chimpyApi.updateDocPermissions(docId, {\n      users: { \"everyone@getgrist.com\": \"viewers\" },\n    });\n    await chimpyApi.updateDocPermissions(docId2, {\n      users: { \"testuser@getgrist.com\": \"viewers\" },\n    });\n\n    // Check test user can access both docs\n    await assert.isFulfilled(altApi.getDoc(docId));\n    await assert.isFulfilled(altApi.getDoc(docId2));\n\n    // Check test user can only list the documents shared with them\n    // through a route other than public sharing\n    assert.deepEqual((await altApi.getOrgWorkspaces(\"current\"))[0].docs.map(doc => doc.name),\n      [\"another example\"]);\n    assert.deepEqual((await altApi.getWorkspace(wsId)).docs.map(doc => doc.name),\n      [\"another example\"]);\n\n    // Check also that test user can only get doc prefs for documents shared with them through a\n    // route other than public sharing. No API endpoint here, so use DB method directly.\n    const dbManager = home.dbManager;\n    const userId = (await dbManager.getExistingUserByLogin(\"testuser@getgrist.com\"))!.id;\n    await assert.isRejected(home.dbManager.getDocPrefs({ userId, org: \"nasa\", urlId: docId }),\n      /access denied/);\n    assert.deepEqual((await home.dbManager.getDocPrefs({ userId, org: \"nasa\", urlId: docId2 })),\n      { docDefaults: {}, currentUser: {} });\n\n    // Check that a viewer at org level can see all docs listed, and access them\n    // (there was a bug where a doc shared with everyone@ as viewer would get hidden\n    // from top-level viewers)\n    await chimpyApi.updateOrgPermissions(\"current\", {\n      users: { \"testuser2@getgrist.com\": \"viewers\" },\n    });\n    const altApi2 = await home.createHomeApi(\"testuser2\", \"nasa\", false, false);\n    await assert.isFulfilled(altApi2.getDoc(docId));\n    await assert.isFulfilled(altApi2.getDoc(docId2));\n    assert.sameMembers((await altApi2.getWorkspace(wsId)).docs.map(doc => doc.name),\n      [\"an example\", \"another example\"]);\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/homedb/GroupsManager.ts",
    "content": "import { isAffirmative } from \"app/common/gutil\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { GroupWithMembersDescriptor } from \"app/gen-server/lib/homedb/Interfaces\";\nimport { createInitialDb, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport { EnvironmentSnapshot, setTmpLogLevel } from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport omit from \"lodash/omit\";\n\ndescribe(\"GroupsManager\", function() {\n  this.timeout(\"3m\");\n  let env: EnvironmentSnapshot;\n  let db: HomeDBManager;\n\n  before(async function() {\n    env = new EnvironmentSnapshot();\n    process.env.TEST_CLEAN_DATABASE = \"true\";\n    setUpDB(this);\n    db = new HomeDBManager();\n    await createInitialDb();\n    await db.connect();\n    await db.initializeSpecialIds();\n  });\n\n  after(async function() {\n    env?.restore();\n    await removeConnection();\n  });\n\n  afterEach(cleanupTestGroups);\n\n  async function cleanupTestGroups() {\n    if (isAffirmative(process.env.NO_CLEANUP)) {\n      return;\n    }\n    const { connection } = db;\n    await connection.transaction(async (manager) => {\n      const groupsToDelete = await manager.createQueryBuilder()\n        .select(\"groups\")\n        .from(Group, \"groups\")\n        .where(\"groups.name like 'test-%'\")\n        .getMany();\n      if (groupsToDelete.length > 0) {\n        const groupsToDeleteIds = groupsToDelete.map(g => g.id);\n        await manager.createQueryBuilder()\n          .delete()\n          .from(\"group_groups\")\n          .where(\"subgroup_id in (:...groupsToDeleteIds)\", { groupsToDeleteIds })\n          .execute();\n        await manager.remove(groupsToDelete);\n      }\n    });\n  }\n\n  function sanitizeUserPropertiesForMembership(user: User) {\n    return omit(user, \"logins\", \"personalOrg\");\n  }\n\n  const makeInnerGroupName = (groupName: string) => groupName + \"-inner\";\n\n  const ensureTestGroupName = (groupName: string) => { assert.match(groupName, /^test-/); };\n\n  async function createDummyGroup(\n    groupName: string, extraProps: Partial<GroupWithMembersDescriptor> & { type: string },\n  ) {\n    ensureTestGroupName(groupName);\n    const chimpy = (await db.getExistingUserByLogin(\"chimpy@getgrist.com\"))!;\n    const group = await db.createGroup({\n      name: groupName,\n      memberUsers: [chimpy.id],\n      ...extraProps,\n    });\n    return { group, chimpy };\n  }\n\n  async function createDummyTeamGroup(\n    groupName: string, extraProps: Partial<GroupWithMembersDescriptor> = {},\n  ) {\n    return await createDummyGroup(groupName, { ...extraProps, type: Group.TEAM_TYPE });\n  }\n\n  async function createDummyRole(groupName: string, extraProps: Partial<GroupWithMembersDescriptor> = {}) {\n    return await createDummyGroup(groupName, { ...extraProps, type: Group.ROLE_TYPE });\n  }\n\n  async function createDummyGroupAndInnerGroup(upperGroupName: string, opts?: {\n    upperGroupProps?: Partial<GroupWithMembersDescriptor>,\n    innerGroupProps?: Partial<GroupWithMembersDescriptor>\n  }) {\n    ensureTestGroupName(upperGroupName);\n    const { upperGroupProps = {}, innerGroupProps = {} } = opts ?? {};\n    const kiwi = (await db.getExistingUserByLogin(\"kiwi@getgrist.com\"))!;\n    const innerGroupName = makeInnerGroupName(upperGroupName);\n\n    const innerGroup = await db.createGroup({\n      name: innerGroupName,\n      type: Group.TEAM_TYPE,\n      memberUsers: [kiwi.id],\n      ...innerGroupProps,\n    });\n    const { group, chimpy } = await createDummyGroup(upperGroupName, {\n      type: Group.ROLE_TYPE,\n      memberGroups: [innerGroup.id],\n      ...upperGroupProps,\n    });\n\n    return { chimpy, kiwi, innerGroup, group };\n  }\n\n  describe(\"createGroup()\", function() {\n    setTmpLogLevel(\"info\");\n\n    it(`should create a new ${Group.TEAM_TYPE} group`, async function() {\n      const groupName = \"test-creategroup\";\n      const { group, chimpy } = await createDummyTeamGroup(groupName);\n      assert.equal(group.name, groupName);\n      assert.equal(group.type, Group.TEAM_TYPE);\n      assert.deepEqual(group.memberUsers, [sanitizeUserPropertiesForMembership(chimpy)]);\n      await Group.remove([group]);\n    });\n\n    it(`should create a new ${Group.TEAM_TYPE} group with groupMembers`, async function() {\n      const groupName = \"test-creategroup-with-groupMembers\";\n      const { group, innerGroup, chimpy } = await createDummyGroupAndInnerGroup(groupName);\n      assert.equal(group.name, groupName);\n      assert.equal(group.type, Group.ROLE_TYPE);\n      assert.deepEqual(group.memberUsers, [sanitizeUserPropertiesForMembership(chimpy)]);\n      assert.equal(group.memberGroups.length, 1);\n      assert.equal(group.memberGroups[0].name, innerGroup.name);\n      assert.equal(group.memberGroups[0].type, Group.TEAM_TYPE);\n    });\n\n    it(`should allow to create a ${Group.ROLE_TYPE} group with the same name as an existing one`, async function() {\n      const groupName = \"test-creategroup-same-name\";\n      const { group: firstGroup } = await createDummyRole(groupName);\n      const { group: secondGroup } = await createDummyRole(groupName);\n      assert.equal(firstGroup.name, groupName);\n      assert.equal(secondGroup.name, groupName);\n      assert.notEqual(firstGroup.id, secondGroup.id);\n    });\n\n    it(`should allow to create a ${Group.ROLE_TYPE} group with the same name as an existing ${Group.TEAM_TYPE} group`,\n      async function() {\n        const groupName = \"test-creategroup-same-name\";\n        const { group: firstGroup } = await createDummyTeamGroup(groupName);\n        const { group: secondGroup } = await createDummyRole(groupName);\n        assert.equal(firstGroup.name, groupName);\n        assert.equal(secondGroup.name, groupName);\n        assert.notEqual(firstGroup.id, secondGroup.id);\n      });\n\n    it(`should refuse to create a ${Group.TEAM_TYPE} group with the same name as an existing one`, async function() {\n      const groupName = \"test-creategroup-same-name\";\n      const { group: firstGroup } = await createDummyTeamGroup(groupName);\n      const promise = createDummyTeamGroup(groupName);\n      await assert.isRejected(promise, /already exists/);\n      assert.equal(firstGroup.name, groupName);\n    });\n\n    it(`should refuse adding a member to a ${Group.TEAM_TYPE} group`, async function() {\n      const groupName = \"test-create-nested-resource-users\";\n      const promise = createDummyGroupAndInnerGroup(groupName, {\n        upperGroupProps: { type: Group.TEAM_TYPE },\n      });\n      await assert.isRejected(promise, /cannot contain groups/);\n    });\n  });\n\n  describe(\"overwriteTeamGroup()\", function() {\n    setTmpLogLevel(\"info\");\n    it(\"should fail if the group is not found\", function() {\n      const promise = db.overwriteTeamGroup(999, {\n        name: \"test-overwrite\",\n        type: Group.TEAM_TYPE,\n      });\n      return assert.isRejected(promise, /not found/);\n    });\n\n    it(`should fail when setting memberGroups to a ${Group.TEAM_TYPE} group`, async function() {\n      const groupName = \"test-overwrite\";\n      const promise = createDummyGroupAndInnerGroup(groupName, {\n        upperGroupProps: { type: Group.TEAM_TYPE },\n        innerGroupProps: { type: Group.TEAM_TYPE },\n      });\n      await assert.isRejected(promise, /cannot contain groups/);\n      const promise2 = createDummyGroupAndInnerGroup(groupName, {\n        upperGroupProps: { type: Group.TEAM_TYPE },\n        innerGroupProps: { type: Group.ROLE_TYPE },\n      });\n      await assert.isRejected(promise2, /cannot contain groups/);\n    });\n\n    it(\"should refuse to set the name to an existing group name\", async function() {\n      const firstGroupName = \"test-group1\";\n      const secondGroupName = \"test-group2\";\n      await createDummyTeamGroup(firstGroupName);\n      const { group: secondGroup } = await createDummyTeamGroup(secondGroupName);\n      const promise = db.overwriteTeamGroup(secondGroup.id, {\n        name: firstGroupName,\n        type: Group.TEAM_TYPE,\n      });\n      await assert.isRejected(promise, /already exists/);\n    });\n\n    it(\"should overwrite the group info\", async function() {\n      const groupName = \"test-overwrite\";\n      const { group } = await createDummyTeamGroup(groupName);\n      const newGroupName = \"test-overwrite-new\";\n      const kiwi = (await db.getExistingUserByLogin(\"kiwi@getgrist.com\"))!;\n      await db.overwriteTeamGroup(group.id, {\n        name: newGroupName,\n        type: Group.TEAM_TYPE,\n        memberUsers: [kiwi.id],\n      });\n      const updatedGroup = (await db.getGroupWithMembersById(group.id))!;\n      assert.equal(updatedGroup.name, newGroupName);\n      assert.equal(updatedGroup.type, Group.TEAM_TYPE);\n      assert.deepEqual(updatedGroup.memberUsers, [sanitizeUserPropertiesForMembership(kiwi)]);\n    });\n\n    it(\"should overwrite the group info and unset unspecified properties\", async function() {\n      const groupName = \"test-overwrite\";\n      const { group } = await createDummyTeamGroup(groupName);\n      const newGroupName = \"test-overwrite-new\";\n      await db.overwriteTeamGroup(group.id, {\n        name: newGroupName,\n        type: Group.TEAM_TYPE,\n      });\n      const updatedGroup = (await db.getGroupWithMembersById(group.id))!;\n      assert.equal(updatedGroup.name, newGroupName);\n      assert.equal(updatedGroup.type, Group.TEAM_TYPE);\n      assert.isEmpty(updatedGroup.memberUsers);\n      assert.isEmpty(updatedGroup.memberGroups);\n    });\n  });\n\n  describe(\"overwriteRoleGroup()\", function() {\n    setTmpLogLevel(\"info\");\n    it(\"should fail if the group is not found\", function() {\n      const promise = db.overwriteRoleGroup(999, {\n        name: \"test-overwrite\",\n        type: Group.ROLE_TYPE,\n      });\n      return assert.isRejected(promise, /not found/);\n    });\n\n    it(`should fail when changing type to ${Group.TEAM_TYPE}`, async function() {\n      const groupName = \"test-overwrite\";\n      const { group } = await createDummyRole(groupName);\n      const promise = db.overwriteRoleGroup(group.id, {\n        name: groupName,\n        type: Group.TEAM_TYPE,\n      });\n      return assert.isRejected(promise, /cannot change type/);\n    });\n\n    it(\"should fail when adding itself to memberGroups\", async function() {\n      const groupName = \"test-overwrite\";\n      const { group } = await createDummyRole(groupName);\n      const promise = db.overwriteRoleGroup(group.id, {\n        name: groupName,\n        type: Group.ROLE_TYPE,\n        memberGroups: [group.id],\n      });\n      return assert.isRejected(promise, /cannot contain itself/);\n    });\n\n    it(\"should overwrite the group info\", async function() {\n      const groupName = \"test-overwrite\";\n      const newInnerGroupName = \"test-overwrite-inner-new\";\n      const { group } = await createDummyGroupAndInnerGroup(groupName, {\n        innerGroupProps: { type: Group.ROLE_TYPE },\n      });\n      const { group: newInnerGroup, kiwi } = await createDummyGroupAndInnerGroup(newInnerGroupName);\n      await db.overwriteRoleGroup(group.id, {\n        name: groupName,\n        type: Group.ROLE_TYPE,\n        memberUsers: [kiwi.id],\n        memberGroups: [newInnerGroup.id],\n      });\n      const updatedGroup = (await db.getGroupWithMembersById(group.id))!;\n      assert.equal(updatedGroup.name, groupName);\n      assert.equal(updatedGroup.type, Group.ROLE_TYPE);\n      assert.deepEqual(updatedGroup.memberUsers, [sanitizeUserPropertiesForMembership(kiwi)]);\n      assert.lengthOf(updatedGroup.memberGroups, 1);\n      assert.equal(updatedGroup.memberGroups[0].id, newInnerGroup.id);\n    });\n\n    it(\"should overwrite the group info and unset unspecified properties\", async function() {\n      const groupName = \"test-overwrite\";\n      const { group } = await createDummyGroupAndInnerGroup(groupName);\n      const newGroupName = \"test-overwrite-new\";\n      await db.overwriteRoleGroup(group.id, {\n        name: newGroupName,\n        type: Group.ROLE_TYPE,\n      });\n      const updatedGroup = (await db.getGroupWithMembersById(group.id))!;\n      assert.equal(updatedGroup.name, newGroupName);\n      assert.equal(updatedGroup.type, Group.ROLE_TYPE);\n      assert.isEmpty(updatedGroup.memberUsers);\n      assert.isEmpty(updatedGroup.memberGroups);\n    });\n  });\n\n  describe(\"getGroupsWithMembersByType()\", function() {\n    it(\"should return groups and members for roles\", async function() {\n      const groups = await db.getGroupsWithMembersByType(Group.ROLE_TYPE);\n      assert.isNotEmpty(groups, \"should return roles\");\n      const groupsNames = new Set(groups.map(group => group.name));\n\n      assert.sameMembers([...groupsNames], [\"owners\", \"editors\", \"viewers\", \"guests\", \"members\"]);\n      assert.isTrue(groups.some(g => g.memberUsers.length > 0), \"memberUsers should be populated\");\n      assert.isTrue(groups.some(g => g.memberGroups.length > 0), \"memberGroups should be populated\");\n      assert.isTrue(groups.every(g => g.type === Group.ROLE_TYPE), \"some groups retrieved are not of type \" +\n      Group.ROLE_TYPE);\n    });\n\n    it(`should return groups for ${Group.TEAM_TYPE}`, async function() {\n      const groupName = \"test-getGroupsWithMembers\";\n\n      const { innerGroup } = await createDummyGroupAndInnerGroup(groupName);\n      const groups = await db.getGroupsWithMembersByType(Group.TEAM_TYPE);\n      assert.deepEqual(groups, [innerGroup]);\n    });\n  });\n\n  describe(\"getGroupsWithMembers()\", function() {\n    it(\"should return all the groups and members\", async function() {\n      const omitGroupMembers = (group: Group) => omit(group, \"memberGroups\", \"memberUsers\");\n      const groupName = \"test-getGroupsWithMembers\";\n      const innerGroupName = makeInnerGroupName(groupName);\n      const { group: createdGroup, innerGroup } =  await createDummyGroupAndInnerGroup(groupName);\n      const groups = await db.getGroupsWithMembers();\n      assert.isNotEmpty(groups, \"should return groups\");\n      const groupsNames = new Set(groups.map(group => group.name));\n\n      assert.sameMembers([...groupsNames], [\"owners\", \"editors\", \"viewers\", \"guests\", \"members\",\n        groupName, innerGroupName]);\n      const group = groups.find(g => g.name === groupName)!;\n      assert.exists(group, \"group is not found\");\n      assert.deepEqual(omitGroupMembers(group), omitGroupMembers(createdGroup));\n      // TODO: should the getGroupsWithMembers return members details?\n      assert.deepEqual(group.memberGroups.map(g => g.id), [innerGroup.id]);\n    });\n\n    it(`should return groups for ${Group.TEAM_TYPE}`, async function() {\n      const groupName = \"test-getGroupsWithMembers\";\n\n      const { innerGroup } = await createDummyGroupAndInnerGroup(groupName);\n      const groups = await db.getGroupsWithMembersByType(Group.TEAM_TYPE);\n      assert.deepEqual(groups, [innerGroup]);\n    });\n  });\n\n  describe(\"getGroupWithMembersById()\", function() {\n    it(\"should return null when the group is not found\", async function() {\n      const nonExistingGroup = await db.getGroupWithMembersById(999);\n      assert.isNull(nonExistingGroup);\n    });\n\n    it(\"should return a group and with its members given an ID\", async function() {\n      const groupName = \"test-getGroupWithMembers\";\n\n      const { group: createdGroup, innerGroup, chimpy } = await createDummyGroupAndInnerGroup(groupName);\n\n      const group = (await db.getGroupWithMembersById(createdGroup.id))!;\n      assert.exists(group, \"group not found\");\n      assert.equal(group.name, groupName);\n      assert.equal(group.type, Group.ROLE_TYPE);\n      assert.deepEqual(group.memberUsers, [sanitizeUserPropertiesForMembership(chimpy)]);\n      assert.equal(group.memberGroups.length, 1);\n      assert.equal(group.memberGroups[0].name, innerGroup.name);\n      assert.equal(group.memberGroups[0].type, Group.TEAM_TYPE);\n    });\n  });\n\n  describe(\"deleteGroup()\", function() {\n    setTmpLogLevel(\"info\");\n\n    it(\"should fail when the group is not found\", async function() {\n      const promise = db.deleteGroup(999);\n      return assert.isRejected(promise, /not found/);\n    });\n\n    it(\"should delete a group\", async function() {\n      const groupName = \"test-deleteGroup\";\n      const { group } = await createDummyTeamGroup(groupName);\n      await db.deleteGroup(group.id);\n      const deletedGroup = await db.getGroupWithMembersById(group.id);\n      assert.isNull(deletedGroup);\n    });\n\n    it(\"should delete a group having members\", async function() {\n      const groupName = \"test-deleteGroup\";\n      const { group, innerGroup } = await createDummyGroupAndInnerGroup(groupName);\n      const anotherInnerGroup = await db.createGroup({\n        name: \"test-deleteGroup-inner2\",\n        type: Group.ROLE_TYPE,\n      });\n      await db.overwriteRoleGroup(group.id, {\n        name: groupName,\n        type: Group.ROLE_TYPE,\n        memberGroups: [innerGroup.id, anotherInnerGroup.id],\n      });\n      await db.deleteGroup(group.id);\n      const reloadedInnerGroup = (await db.getGroupWithMembersById(innerGroup.id));\n      assert.exists(reloadedInnerGroup, \"innerGroup not found after deleting parent group\");\n      const reloadedAnotherInnerGroup = (await db.getGroupWithMembersById(anotherInnerGroup.id));\n      assert.exists(reloadedAnotherInnerGroup, \"anotherInnerGroup not found after deleting parent group\");\n    });\n\n    it(\"should dereference the group from its parent group\", async function() {\n      const groupName = \"test-deleteGroup\";\n      const { group, innerGroup } = await createDummyGroupAndInnerGroup(groupName);\n      await db.deleteGroup(innerGroup.id);\n      const updatedGroup = (await db.getGroupWithMembersById(group.id))!;\n      assert.exists(updatedGroup, \"upper group not found\");\n      assert.isEmpty(updatedGroup.memberGroups);\n    });\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/homedb/UsersManager.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { buildUrlId, parseUrlId } from \"app/common/gristUrls\";\nimport { FullUser, UserProfile } from \"app/common/LoginSessionAPI\";\nimport { ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, PREVIEWER_EMAIL, UserAPIImpl, UserOptions } from \"app/common/UserAPI\";\nimport { AclRuleOrg } from \"app/gen-server/entity/AclRule\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { Login } from \"app/gen-server/entity/Login\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Pref } from \"app/gen-server/entity/Pref\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { GetUserOptions, NonGuestGroup, Resource } from \"app/gen-server/lib/homedb/Interfaces\";\nimport { SUPPORT_EMAIL, UsersManager } from \"app/gen-server/lib/homedb/UsersManager\";\nimport { updateDb } from \"app/server/lib/dbUtils\";\nimport { EmitNotifier } from \"app/server/lib/INotifier\";\nimport log from \"app/server/lib/log\";\nimport { MergedServer } from \"app/server/MergedServer\";\nimport { createInitialDb, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport { createTestDir, EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\nimport omit from \"lodash/omit\";\nimport fetch from \"node-fetch\";\nimport Sinon, { SinonSandbox, SinonSpy } from \"sinon\";\nimport { EntityManager } from \"typeorm\";\nimport winston from \"winston\";\n\ndescribe(\"UsersManager\", function() {\n  this.timeout(\"3m\");\n\n  describe(\"static method\", function() {\n    /**\n     * Create a simple iterator of integer starting from 0 which is incremented every time we call next()\n     */\n    function* makeUserIdIterator() {\n      for (let i = 0; i < 500; i++) {\n        yield i;\n      }\n    }\n\n    /**\n     * Create a table of users.\n     * @param nbUsers The number of users to create\n     * @param [userIdIterator=makeIdxIterator()] An iterator used to create users' id,\n     *        which keep track of the increment accross calls.\n     *        Pass your own iterator if you want to call this methods several times and keep the id unique.\n     *        If omitted, create its own iterator that starts from 0.\n     */\n    function makeUsers(nbUsers: number, userIdIterator = makeUserIdIterator()): User[] {\n      return Array(nbUsers).fill(null).map(() => {\n        const user = new User();\n        const itItem = userIdIterator.next();\n        if (itItem.done) {\n          throw new Error(\"Excessive number of users created\");\n        }\n        user.id = itItem.value;\n        user.name = `User ${itItem.value}`;\n        return user;\n      });\n    }\n\n    /**\n     * Populate passed resources with members\n     * @param resources The Resources\n     * @param nbUsersByResource The number of users to create for each resources (each one is unique)\n     * @returns The Resources and their respective members\n     */\n    function populateResourcesWithMembers(\n      resources: Resource[], nbUsersByResource: number, makeResourceGrpName?: (idx: number) => string,\n    ): Map<Resource, User[]> {\n      const membersByResource = new Map<Resource, User[]>();\n      const idxIterator = makeUserIdIterator();\n      for (const [idx, resource] of resources.entries()) {\n        const aclRule = new AclRuleOrg();\n        const members = makeUsers(nbUsersByResource, idxIterator);\n        const group = Group.create({\n          name: makeResourceGrpName?.(idx),\n          type: Group.ROLE_TYPE,\n          memberUsers: members,\n        });\n        aclRule.group = group;\n        resource.aclRules = [\n          aclRule,\n        ];\n        membersByResource.set(resource, members);\n      }\n      return membersByResource;\n    }\n\n    /**\n     * Populate a resource with members and return the members\n     */\n    function populateSingleResourceWithMembers(resource: Resource, nbUsers: number) {\n      const membersByResource = populateResourcesWithMembers([resource], nbUsers);\n      return membersByResource.get(resource)!;\n    }\n\n    describe(\"getResourceUsers()\", function() {\n      it(\"should return all users from a single organization ACL\", function() {\n        const resource = new Organization();\n        const expectedUsers = populateSingleResourceWithMembers(resource, 5);\n\n        const result = UsersManager.getResourceUsers(resource);\n\n        assert.deepEqual(result, expectedUsers);\n      });\n\n      it(\"should return all users from all resources ACL\", function() {\n        const resources: Resource[] = [new Organization(), new Workspace(), new Document()];\n        const membersByResource = populateResourcesWithMembers(resources, 5);\n\n        const result = UsersManager.getResourceUsers(resources);\n\n        assert.deepEqual(result, [...membersByResource.values()].flat());\n      });\n\n      it(\"should deduplicate the results\", function() {\n        const resources: Resource[] = [new Organization(), new Workspace()];\n        const membersByResource = populateResourcesWithMembers(resources, 1);\n        const usersList = [...membersByResource.values()];\n        const expectedResult = usersList.flat();\n\n        const duplicateUser = new User();\n        duplicateUser.id = usersList[1][0].id;\n        usersList[0].unshift(duplicateUser);\n\n        const result = UsersManager.getResourceUsers(resources);\n\n        assert.deepEqual(result, expectedResult);\n      });\n\n      it(\"should return users matching group names from all resources ACL\", function() {\n        const someOrg = new Organization();\n        const someWorkspace = new Workspace();\n        const someDoc = new Document();\n        const resources: Resource[] = [someOrg, someWorkspace, someDoc];\n        const allGroupNames = [\"OrgGrp\", \"WorkspaceGrp\", \"DocGrp\"];\n        const membersByResource = populateResourcesWithMembers(resources, 5, i => allGroupNames[i]);\n        const filteredGroupNames = [\"WorkspaceGrp\", \"DocGrp\"];\n\n        const result = UsersManager.getResourceUsers(resources, filteredGroupNames);\n\n        const expectedResult = [...membersByResource.get(someWorkspace)!, ...membersByResource.get(someDoc)!];\n        assert.deepEqual(result, expectedResult, \"should discard the users from the first resource\");\n      });\n    });\n\n    describe(\"getUsersWithRole()\", function() {\n      function makeGroups(groupDefinition: { [k in NonGuestGroup[\"name\"]]?: User[] | undefined }) {\n        const entries = Object.entries(groupDefinition) as [NonGuestGroup[\"name\"], User[] | undefined][];\n\n        return entries.map(([groupName, users], index) => {\n          return Group.create({\n            id: index,\n            name: groupName,\n            type: Group.ROLE_TYPE,\n            memberUsers: users,\n          }) as NonGuestGroup;\n        });\n      }\n\n      it(\"should retrieve no users if passed groups do not contain any\", function() {\n        const groups = makeGroups({\n          members: undefined,\n        });\n\n        const result = UsersManager.getUsersWithRole(groups);\n\n        assert.deepEqual(result, new Map([[\"members\", undefined]] as any));\n      });\n\n      it(\"should retrieve users of passed groups\", function() {\n        const idxIt = makeUserIdIterator();\n        const groupsUsersMap = {\n          editors: makeUsers(3, idxIt),\n          owners: makeUsers(4, idxIt),\n          members: makeUsers(5, idxIt),\n          viewers: [],\n        };\n        const groups = makeGroups(groupsUsersMap);\n\n        const result = UsersManager.getUsersWithRole(groups);\n\n        assert.deepEqual(result, new Map(Object.entries(groupsUsersMap)));\n      });\n\n      it(\"should exclude users of given IDs\", function() {\n        const groupUsersMap = {\n          editors: makeUsers(5),\n        };\n        const excludedUsersId = [1, 2, 3, 4];\n        const expectedUsers = [groupUsersMap.editors[0]];\n        const groups = makeGroups(groupUsersMap);\n\n        const result = UsersManager.getUsersWithRole(groups, excludedUsersId);\n\n        assert.deepEqual(result, new Map([[\"editors\", expectedUsers]]));\n      });\n    });\n  });\n\n  describe(\"class method\", function() {\n    const NON_EXISTING_USER_ID = 10001337;\n    let env: EnvironmentSnapshot;\n    let db: HomeDBManager;\n    let notifier: EmitNotifier;\n    let sandbox: SinonSandbox;\n    const docDeletes: string[] = [];\n    const uniqueLocalPart = new Set<string>();\n\n    function ensureUnique(localPart: string) {\n      if (uniqueLocalPart.has(localPart)) {\n        throw new Error(\"passed localPart is already used elsewhere\");\n      }\n      uniqueLocalPart.add(localPart);\n      return localPart;\n    }\n\n    function createUniqueUser(uniqueEmailLocalPart: string, options?: GetUserOptions) {\n      ensureUnique(uniqueEmailLocalPart);\n      return getOrCreateUser(uniqueEmailLocalPart, options);\n    }\n\n    async function getOrCreateUser(localPart: string, options?: GetUserOptions) {\n      return db.getUserByLogin(makeEmail(localPart), options);\n    }\n\n    function makeEmail(localPart: string) {\n      return localPart + \"@getgrist.com\";\n    }\n\n    async function getPersonalOrg(user: User) {\n      return db.getOrg({ userId: user.id }, user.personalOrg.id);\n    }\n\n    function disableLoggingLevel<T extends keyof winston.LoggerInstance>(method: T) {\n      return sandbox.stub(log, method);\n    }\n\n    /**\n    * Make a user profile.\n    * @param localPart A unique local part of the email (also used for the other fields).\n    */\n    function makeProfile(localPart: string): UserProfile {\n      ensureUnique(localPart);\n      return {\n        email: makeEmail(localPart),\n        name: `NewUser ${localPart}`,\n        connectId: `ConnectId-${localPart}`,\n        picture: `https://mypic.com/${localPart}.png`,\n        extra: {\n          extrafield: \"randomvalue\",\n        },\n      };\n    }\n\n    before(async function() {\n      env = new EnvironmentSnapshot();\n      process.env.TEST_CLEAN_DATABASE = \"true\";\n      setUpDB(this);\n      notifier = new EmitNotifier();\n      db = new HomeDBManager(\n        {\n          /**\n           * This is called if a document row should be deleted\n           * in the database and we want to make sure any associated\n           * storage is cleaned up. There is S3 cleanup, and file system\n           * cleanup, which is coordinated with some doc worker.\n           * We mock that here and just delete from home db.\n           */\n          async hardDeleteDoc(docId: string) {\n            const parts = parseUrlId(docId);\n            await db.connection.query(\"delete from docs where id = $1\", [parts.forkId || parts.trunkId]);\n            docDeletes.push(docId);\n          },\n        },\n        notifier);\n      await createInitialDb();\n      await db.connect();\n      await db.initializeSpecialIds();\n    });\n\n    after(async function() {\n      env?.restore();\n      await removeConnection();\n    });\n\n    beforeEach(function() {\n      sandbox = Sinon.createSandbox();\n    });\n\n    afterEach(function() {\n      sandbox.restore();\n    });\n\n    describe(\"Special User Ids\", function() {\n      const ANONYMOUS_USER_ID = 6;\n      const PREVIEWER_USER_ID = 7;\n      const EVERYONE_USER_ID = 8;\n      const SUPPORT_USER_ID = 5;\n      it(\"getAnonymousUserId() should retrieve anonymous user id\", function() {\n        assert.strictEqual(db.getAnonymousUserId(), ANONYMOUS_USER_ID);\n      });\n\n      it(\"getPreviewerUserId() should retrieve previewer user id\", function() {\n        assert.strictEqual(db.getPreviewerUserId(), PREVIEWER_USER_ID);\n      });\n\n      it(\"getEveryoneUserId() should retrieve 'everyone' user id\", function() {\n        assert.strictEqual(db.getEveryoneUserId(), EVERYONE_USER_ID);\n      });\n\n      it(\"getSupportUserId() should retrieve 'support' user id\", function() {\n        assert.strictEqual(db.getSupportUserId(), SUPPORT_USER_ID);\n      });\n\n      it(\"getSpecialUserIds() should retrieve all the special user ids\", function() {\n        assert.deepEqual(db.getSpecialUserIds(), [\n          ANONYMOUS_USER_ID,\n          PREVIEWER_USER_ID,\n          EVERYONE_USER_ID,\n          SUPPORT_USER_ID,\n        ]);\n      });\n    });\n\n    describe(\"getUserByKey()\", function() {\n      it(\"should return the user given their API Key\", async function() {\n        const user = await db.getUserByKey(\"api_key_for_chimpy\");\n\n        assert.strictEqual(user?.name, \"Chimpy\", \"should retrieve Chimpy by their API key\");\n        assert.strictEqual(user?.logins?.[0].email, \"chimpy@getgrist.com\");\n      });\n\n      it(\"should return undefined if no user matches the API key\", async function() {\n        const user = await db.getUserByKey(\"non-existing API key\");\n\n        assert.strictEqual(user, undefined);\n      });\n    });\n\n    describe(\"getUser()\", async function() {\n      it(\"should retrieve a user by their ID\", async function() {\n        const user = await db.getUser(db.getSupportUserId());\n        assertExists(user, \"Should have returned a user\");\n        assert.strictEqual(user.name, \"Support\");\n        assert.strictEqual(user.loginEmail, SUPPORT_EMAIL);\n        assert.notExists(user.prefs, \"should not have retrieved user's prefs\");\n      });\n\n      it(\"should retrieve a user along with their prefs with `includePrefs` set to true\", async function() {\n        const expectedUser = await createUniqueUser(\"getuser-userwithprefs\");\n        const user = await db.getUser(expectedUser.id, { includePrefs: true });\n        assertExists(user, \"Should have retrieved the user\");\n        assertExists(user.loginEmail);\n        assert.strictEqual(user.loginEmail, expectedUser.loginEmail);\n        assert.isTrue(Array.isArray(user.prefs), \"should not have retrieved user's prefs\");\n        assert.deepEqual(user.prefs, [{\n          userId: expectedUser.id,\n          orgId: expectedUser.personalOrg.id,\n          prefs: { showGristTour: true } as any,\n        }]);\n      });\n\n      it(\"should return undefined when the id is not found\", async function() {\n        assert.isUndefined(await db.getUser(NON_EXISTING_USER_ID));\n      });\n    });\n\n    describe(\"getFullUser()\", function() {\n      it(\"should return the support user\", async function() {\n        const supportId = db.getSupportUserId();\n\n        const user = await db.getFullUser(supportId);\n\n        const expectedResult: FullUser = {\n          isSupport: true,\n          email: SUPPORT_EMAIL,\n          id: supportId,\n          name: \"Support\",\n        };\n        assert.deepInclude(user, expectedResult);\n        assert.notOk(user.anonymous, \"anonymous property should be falsy\");\n      });\n\n      it(\"should return the anonymous user\", async function() {\n        const anonId = db.getAnonymousUserId();\n\n        const user = await db.getFullUser(anonId);\n\n        const expectedResult: FullUser = {\n          anonymous: true,\n          email: ANONYMOUS_USER_EMAIL,\n          id: anonId,\n          name: \"Anonymous\",\n        };\n        assert.deepInclude(user, expectedResult);\n        assert.notOk(user.isSupport, \"support property should be falsy\");\n      });\n\n      it(\"should reject when user is not found\", async function() {\n        await assert.isRejected(db.getFullUser(NON_EXISTING_USER_ID), \"unable to find user\");\n      });\n    });\n\n    describe(\"makeFullUser()\", function() {\n      const someUserDisplayEmail = \"SomeUser@getgrist.com\";\n      const normalizedSomeUserEmail = \"someuser@getgrist.com\";\n      const someUserLocale = \"en-US\";\n      const SOME_USER_ID = 42;\n      const prefWithOrg: Pref = {\n        prefs: { placeholder: \"pref-with-org\" },\n        orgId: 43,\n        user: new User(),\n        userId: SOME_USER_ID,\n      };\n      const prefWithoutOrg: Pref = {\n        prefs: { placeholder: \"pref-without-org\" },\n        orgId: null,\n        user: new User(),\n        userId: SOME_USER_ID,\n      };\n\n      function makeSomeUser() {\n        return User.create({\n          id: SOME_USER_ID,\n          ref: \"some ref\",\n          name: \"some user\",\n          picture: \"https://grist.com/mypic\",\n          options: {\n            locale: someUserLocale,\n          },\n          logins: [\n            Login.create({\n              userId: SOME_USER_ID,\n              email: normalizedSomeUserEmail,\n              displayEmail: someUserDisplayEmail,\n            }),\n          ],\n          prefs: [\n            prefWithOrg,\n            prefWithoutOrg,\n          ],\n        });\n      }\n\n      it(\"creates a FullUser from a User entity\", function() {\n        const input = makeSomeUser();\n\n        const fullUser = db.makeFullUser(input);\n\n        assert.deepEqual(fullUser, {\n          id: SOME_USER_ID,\n          email: someUserDisplayEmail,\n          loginEmail: normalizedSomeUserEmail,\n          name: input.name,\n          picture: input.picture,\n          ref: input.ref,\n          locale: someUserLocale,\n          prefs: prefWithoutOrg.prefs,\n          firstLoginAt: null,\n          disabledAt: null,\n        });\n      });\n\n      it(\"sets `anonymous` property to true for anon@getgrist.com\", function() {\n        const anon = db.getAnonymousUser();\n\n        const fullUser = db.makeFullUser(anon);\n\n        assert.isTrue(fullUser.anonymous, \"`anonymous` property should be set to true\");\n        assert.notOk(fullUser.isSupport, \"`isSupport` should be falsy\");\n      });\n\n      it(\"sets `isSupport` property to true for support account\", async function() {\n        const support = await db.getUser(db.getSupportUserId());\n\n        const fullUser = db.makeFullUser(support!);\n\n        assert.isTrue(fullUser.isSupport, \"`isSupport` property should be set to true\");\n        assert.notOk(fullUser.anonymous, \"`anonymouse` should be falsy\");\n      });\n\n      it(\"should throw when no displayEmail exist for this user\", function() {\n        const input = makeSomeUser();\n        input.logins[0].displayEmail = \"\";\n\n        assert.throws(() => db.makeFullUser(input), \"unable to find mandatory user email\");\n\n        input.logins = [];\n        assert.throws(() => db.makeFullUser(input), \"unable to find mandatory user email\");\n      });\n    });\n\n    describe(\"ensureExternalUser()\", function() {\n      let managerSaveSpy: SinonSpy;\n\n      beforeEach(function() {\n        managerSaveSpy = sandbox.spy(EntityManager.prototype, \"save\");\n      });\n\n      afterEach(function() {\n        managerSaveSpy.restore();\n      });\n\n      async function checkUserInfo(profile: UserProfile) {\n        const user = await db.getExistingUserByLogin(profile.email);\n        assertExists(user, \"the new user should be in database\");\n        assert.deepInclude(user, {\n          isFirstTimeUser: false,\n          name: profile.name,\n          picture: profile.picture,\n        });\n        assert.exists(user.logins?.[0]);\n        assert.deepInclude(user.logins[0], {\n          email: profile.email.toLowerCase(),\n          displayEmail: profile.email,\n        });\n        return user;\n      }\n\n      it(\"should not do anything if the user already exists and is up to date\", async function() {\n        await db.ensureExternalUser({\n          name: \"Chimpy\",\n          email: \"chimpy@getgrist.com\",\n        });\n\n        assert.isFalse(managerSaveSpy.called, \"manager.save() should not have been called\");\n      });\n\n      it(\"should save an unknown user\", async function() {\n        const profile = makeProfile(\"ensureExternalUser-saves-an-unknown-user\");\n        await db.ensureExternalUser(profile);\n        assert.isTrue(managerSaveSpy.called, \"manager.save() should have been called\");\n\n        await checkUserInfo(profile);\n      });\n\n      it(\"should update a user if they already exist in database\", async function() {\n        const oldProfile = makeProfile(\"ensureexternaluser-updates-an-existing-user_old\");\n\n        await db.ensureExternalUser(oldProfile);\n\n        let oldUser = await db.getExistingUserByLogin(oldProfile.email);\n        assertExists(oldUser);\n\n        const newProfile = {\n          ...makeProfile(\"ensureexternaluser-updates-an-existing-user_new\"),\n          connectId: oldProfile.connectId,\n        };\n\n        await db.ensureExternalUser(newProfile);\n\n        oldUser = await db.getExistingUserByLogin(oldProfile.email);\n        assert.notExists(oldUser, \"we should not retrieve the user given their old email address\");\n\n        await checkUserInfo(newProfile);\n      });\n\n      it(\"should normalize email address\", async function() {\n        const profile = makeProfile(\"ENSUREEXTERNALUSER-NORMALIZES-email-address\");\n\n        await db.ensureExternalUser(profile);\n\n        const user = await checkUserInfo(profile);\n        assert.equal(user.logins[0].email, profile.email.toLowerCase(), \"the email should be lowercase\");\n        assert.equal(user.logins[0].displayEmail, profile.email, \"the display email should keep the original case\");\n      });\n    });\n\n    describe(\"updateUser()\", function() {\n      let emitSpy: SinonSpy;\n\n      before(function() {\n        emitSpy = Sinon.spy();\n        notifier.on(\"firstLogin\", emitSpy);\n      });\n\n      after(function() {\n        notifier.off(\"firstLogin\", emitSpy);\n      });\n\n      afterEach(function() {\n        emitSpy.resetHistory();\n      });\n\n      function checkNoEventEmitted() {\n        assert.equal(emitSpy.callCount, 0, \"No event should have been emitted\");\n      }\n\n      it(\"should reject when user is not found\", async function() {\n        disableLoggingLevel(\"debug\");\n\n        const promise = db.updateUser(NON_EXISTING_USER_ID, { name: \"foobar\" });\n\n        await assert.isRejected(promise, \"unable to find user\");\n        checkNoEventEmitted();\n      });\n\n      it(\"should update a user name\", async function() {\n        const emailLocalPart = \"updateUser-should-update-user-name\";\n        const createdUser = await createUniqueUser(emailLocalPart);\n        assert.equal(createdUser.name, \"\");\n        const userName = \"user name\";\n\n        await db.updateUser(createdUser.id, { name: userName });\n\n        checkNoEventEmitted();\n        const updatedUser = await getOrCreateUser(emailLocalPart);\n        assert.equal(updatedUser.name, userName);\n      });\n\n      it(\"should not emit any event when isFirstTimeUser value has not changed\", async function() {\n        const localPart = \"updateuser-should-not-emit-when-isfirsttimeuser-not-changed\";\n        const createdUser = await createUniqueUser(localPart);\n        assert.equal(createdUser.isFirstTimeUser, true);\n\n        await db.updateUser(createdUser.id, { isFirstTimeUser: true });\n\n        checkNoEventEmitted();\n      });\n\n      it('should emit \"firstLogin\" event when isFirstTimeUser value has been toggled to false', async function() {\n        const localPart = \"updateuser-emits-firstlogin\";\n        const userName = \"user name\";\n        const newUser = await createUniqueUser(localPart);\n        assert.equal(newUser.isFirstTimeUser, true);\n\n        await db.updateUser(newUser.id, { isFirstTimeUser: false, name: userName });\n        assert.equal(emitSpy.callCount, 1, '\"firstLogin\" event should have been emitted');\n\n        const fullUserFromEvent = emitSpy.firstCall.args[0];\n        assertExists(fullUserFromEvent, 'a FullUser object should be passed with the \"firstLogin\" event');\n        assert.equal(fullUserFromEvent.name, userName);\n        assert.equal(fullUserFromEvent.email, makeEmail(localPart));\n\n        const updatedUser = await getOrCreateUser(localPart);\n        assert.equal(updatedUser.isFirstTimeUser, false, \"the user is not considered as being first time user anymore\");\n      });\n    });\n\n    describe(\"updateUserOptions()\", function() {\n      it(\"should reject when user is not found\", async function() {\n        disableLoggingLevel(\"debug\");\n\n        const promise = db.updateUserOptions(NON_EXISTING_USER_ID, {});\n\n        await assert.isRejected(promise, \"unable to find user\");\n      });\n\n      it(\"should update user options\", async function() {\n        const localPart = \"updateuseroptions-updates-user-options\";\n        const createdUser = await createUniqueUser(localPart);\n\n        assert.notExists(createdUser.options);\n\n        const options: UserOptions = {\n          locale: \"fr\", authSubject: \"subject\", isConsultant: true, allowGoogleLogin: true,\n        };\n        await db.updateUserOptions(createdUser.id, options);\n\n        const updatedUser = await getOrCreateUser(localPart);\n        assertExists(updatedUser.options);\n        assert.deepEqual(updatedUser.options, options);\n      });\n    });\n\n    describe(\"getExistingUserByLogin()\", function() {\n      it(\"should return an existing user\", async function() {\n        const retrievedUser = await db.getExistingUserByLogin(PREVIEWER_EMAIL);\n        assertExists(retrievedUser);\n\n        assert.equal(retrievedUser.id, db.getPreviewerUserId());\n        assert.equal(retrievedUser.name, \"Preview\");\n      });\n\n      it(\"should normalize the passed user email\", async function() {\n        const retrievedUser = await db.getExistingUserByLogin(PREVIEWER_EMAIL.toUpperCase());\n\n        assertExists(retrievedUser);\n      });\n\n      it(\"should return undefined when the user is not found\", async function() {\n        const nonExistingEmail = \"i-dont-exist@getgrist.com\";\n\n        const retrievedUser = await db.getExistingUserByLogin(nonExistingEmail);\n\n        assert.isUndefined(retrievedUser);\n      });\n    });\n\n    describe(\"getExistingUsersByLogin()\", function() {\n      it(\"should return existing users\", async function() {\n        const emails = [PREVIEWER_EMAIL, EVERYONE_EMAIL];\n        const retrievedUsers = await db.getExistingUsersByLogin(emails);\n        assertExists(retrievedUsers);\n\n        assert.equal(retrievedUsers[0].id, db.getPreviewerUserId());\n        assert.equal(retrievedUsers[0].name, \"Preview\");\n        assert.equal(retrievedUsers[1].id, db.getEveryoneUserId());\n        assert.equal(retrievedUsers[1].name, \"Everyone\");\n      });\n\n      it(\"should normalize the passed users email\", async function() {\n        const emails = [PREVIEWER_EMAIL.toUpperCase(), EVERYONE_EMAIL.toUpperCase()];\n        const retrievedUsers = await db.getExistingUsersByLogin(emails);\n\n        assert.lengthOf(retrievedUsers, 2);\n      });\n\n      it(\"should return an empty array when no user is found\", async function() {\n        const nonExistingEmails = [\"i-dont-exist@getgrist.com\", \"me-neither@getgrist.com\"];\n        const retrievedUsers = await db.getExistingUsersByLogin(nonExistingEmails);\n\n        assert.isEmpty(retrievedUsers);\n      });\n\n      it(\"should return an empty array when no emails/logins are given\", async function() {\n        const emptyEmailsList: string[] = [];\n        const retrievedUsers = await db.getExistingUsersByLogin(emptyEmailsList);\n\n        assert.isEmpty(retrievedUsers);\n      });\n    });\n\n    describe(\"getUserByLogin()\", function() {\n      it(\"should create a user when none exist with the corresponding email\", async function() {\n        const localPart = ensureUnique(\"getuserbylogin-creates-user-when-not-already-exists\");\n        const email = makeEmail(localPart);\n        assert.notExists(await db.getExistingUserByLogin(email));\n\n        const before = Date.now();\n        // We are storing time without milliseconds, so make sure at least 1 second has passed\n        await delay(2000);\n        const user = await db.getUserByLogin(makeEmail(localPart.toUpperCase()));\n        const after = Date.now();\n        assert.isTrue(user.isFirstTimeUser, \"should be marked as first time user\");\n        assert.equal(user.loginEmail, email);\n        assert.equal(user.logins[0].displayEmail, makeEmail(localPart.toUpperCase()));\n        assert.equal(user.name, \"\");\n        assertBetween(before, user.lastConnectionAt?.getTime(), after);\n      });\n\n      it(\"should create a personnal organization for the new user\", async function() {\n        const localPart = ensureUnique(\"getuserbylogin-creates-personnal-org\");\n\n        const user = await db.getUserByLogin(makeEmail(localPart));\n\n        const org = await getPersonalOrg(user);\n        assertExists(org.data, \"should have retrieved personnal org data\");\n        assert.equal(org.data.name, \"Personal\");\n      });\n\n      it(\"should not create organizations for non-login emails\", async function() {\n        const user = await db.getUserByLogin(EVERYONE_EMAIL);\n        assert.notExists(user.personalOrg);\n      });\n\n      it('should force the creation of service users with emails having an \".invalid\" tld', async function() {\n        disableLoggingLevel(\"debug\");\n        const legitEmail = ensureUnique(\"legit@serviceaccounts.invalid\");\n        const legitPromise = db.getUserByLogin(legitEmail, {}, \"service\");\n        await assert.isFulfilled(legitPromise);\n\n        const nonLegitEmail = ensureUnique(\"nonlegit@serviceaccounts.com\");\n        const nonLegitPromise = db.getUserByLogin(nonLegitEmail, {}, \"service\");\n        await assert.isRejected(nonLegitPromise,\n          \"Users of type service must have email like XXXXXX@serviceaccounts.invalid\");\n      });\n\n      it(\"should not update user information when no profile is passed\", async function() {\n        const localPart = ensureUnique(\"getuserbylogin-does-not-update-without-profile\");\n\n        const userFirstCall = await db.getUserByLogin(makeEmail(localPart));\n        const userSecondCall = await db.getUserByLogin(makeEmail(localPart));\n\n        assert.deepEqual(userFirstCall, userSecondCall);\n      });\n\n      // FIXME: postgresql doesn't like fake timers.\n      it.skip(\"should update lastConnectionAt only for different days\", async function() {\n        const fakeTimer = sandbox.useFakeTimers(0);\n        const localPart = ensureUnique(\"getuserbylogin-updates-last_connection_at-for-different-days\");\n        let user = await db.getUserByLogin(makeEmail(localPart));\n        const epochDateTime = \"1970-01-01 00:00:00.000\";\n        assert.equal(String(user.lastConnectionAt), epochDateTime);\n\n        await fakeTimer.tickAsync(42_000);\n        user = await db.getUserByLogin(makeEmail(localPart));\n        assert.equal(String(user.lastConnectionAt), epochDateTime);\n\n        await fakeTimer.tickAsync(\"1d\");\n        user = await db.getUserByLogin(makeEmail(localPart));\n        assert.match(String(user.lastConnectionAt), /^1970-01-02/);\n      });\n\n      describe(\"when passing information to update (using `profile`)\", function() {\n        // FIXME: postgresql doesn't like fake timers.\n        it.skip(\"should populate the firstTimeLogin and deduce the name from the email\", async function() {\n          const timers = sandbox.useFakeTimers(42_000);\n          const localPart = ensureUnique(\"getuserbylogin-with-profile-populates-first_time_login-and-name\");\n          const user = await db.getUserByLogin(makeEmail(localPart), {\n            profile: { name: \"\", email: makeEmail(localPart) },\n          });\n          assert.equal(user.name, localPart);\n          assert.equal(user.firstLoginAt?.getTime(), 42_000);\n          await timers.runAllAsync();\n\n          timers.restore();\n        });\n\n        it(\"should populate user with any passed information\", async function() {\n          const localPart = ensureUnique(\"getuserbylogin-with-profile-populates-user-with-passed-info_OLD\");\n          await db.getUserByLogin(makeEmail(localPart));\n          const originalNormalizedLoginEmail = makeEmail(localPart.toLowerCase());\n\n          const profile = makeProfile(makeEmail(\"getuserbylogin-with-profile-populates-user-with-passed-info_NEW\"));\n          const userOptions: UserOptions = { authSubject: \"my-auth-subject\" };\n\n          const updatedUser = await db.getUserByLogin(makeEmail(localPart), { profile, userOptions });\n          assert.deepInclude(updatedUser, {\n            name: profile.name,\n            connectId: profile.connectId,\n            picture: profile.picture,\n            options: {\n              authSubject: userOptions.authSubject,\n              ssoExtraInfo: {\n                extrafield: profile.extra!.extrafield,\n              },\n            },\n          });\n          assert.deepInclude(updatedUser.logins[0], {\n            displayEmail: profile.email,\n            email: originalNormalizedLoginEmail,\n          });\n        });\n      });\n    });\n\n    describe(\"getUserByLoginWithRetry()\", async function() {\n      async function ensureGetUserByLoginWithRetryWorks(localPart: string) {\n        const email = makeEmail(localPart);\n        const user = await db.getUserByLoginWithRetry(email);\n        assertExists(user);\n        assert.equal(user.loginEmail, email);\n      }\n\n      function makeQueryFailedError() {\n        const error = new Error() as any;\n        error.name = \"QueryFailedError\";\n        error.detail = \"Key (email)=whatever@getgrist.com already exists\";\n        return error;\n      }\n\n      it(\"should work just like getUserByLogin\", async function() {\n        await ensureGetUserByLoginWithRetryWorks(ensureUnique(\"getuserbyloginwithretry-works-like-getuserbylogin\"));\n      });\n\n      it(\"should make a second attempt on special error\", async function() {\n        sandbox.stub(UsersManager.prototype, \"getUserByLogin\")\n          .onFirstCall().throws(makeQueryFailedError())\n          .callThrough();\n        await ensureGetUserByLoginWithRetryWorks(\"getuserbyloginwithretry-makes-a-single-retry\");\n      });\n\n      it(\"should reject after 2 attempts\", async function() {\n        const secondError = makeQueryFailedError();\n        sandbox.stub(UsersManager.prototype, \"getUserByLogin\")\n          .onFirstCall().throws(makeQueryFailedError())\n          .onSecondCall().throws(secondError)\n          .callThrough();\n\n        const email = makeEmail(ensureUnique(\"getuserbyloginwithretry-rejects-after-2-attempts\"));\n        const promise = db.getUserByLoginWithRetry(email);\n        await assert.isRejected(promise);\n        await promise.catch(err => assert.equal(err, secondError));\n      });\n\n      it(\"should reject immediately if the error is not a QueryFailedError\", async function() {\n        const errorMsg = \"my error\";\n        sandbox.stub(UsersManager.prototype, \"getUserByLogin\")\n          .onFirstCall().rejects(new Error(errorMsg))\n          .callThrough();\n\n        const email = makeEmail(ensureUnique(\"getuserbyloginwithretry-rejects-immediately-when-not-queryfailederror\"));\n        const promise = db.getUserByLoginWithRetry(email);\n        await assert.isRejected(promise, errorMsg);\n      });\n    });\n\n    describe(\"deleteUser()\", function() {\n      function userHasPrefs(userId: number, manager: EntityManager) {\n        return manager.exists(Pref, { where: { userId: userId } });\n      }\n\n      function userHasGroupUsers(userId: number, manager: EntityManager) {\n        return manager.exists(\"group_users\", { where: { user_id: userId } });\n      }\n\n      async function assertUserStillExistsInDb(userId: number) {\n        assert.exists(await db.getUser(userId));\n      }\n\n      it(\"should refuse to delete the account of someone else\", async function() {\n        const userToDelete = await createUniqueUser(\"deleteuser-refuses-for-someone-else\");\n\n        const promise = db.deleteUser({ userId: 2 }, userToDelete.id);\n\n        await assert.isRejected(promise, \"not permitted to delete this user\");\n        await assertUserStillExistsInDb(userToDelete.id);\n      });\n\n      it(\"should refuse to delete a non existing account\", async function() {\n        disableLoggingLevel(\"debug\");\n\n        const promise = db.deleteUser({ userId: NON_EXISTING_USER_ID }, NON_EXISTING_USER_ID);\n\n        await assert.isRejected(promise, \"user not found\");\n      });\n\n      it(\"should refuse to delete the account if the passed name is not matching\", async function() {\n        disableLoggingLevel(\"debug\");\n        const localPart = \"deleteuser-refuses-if-name-not-matching\";\n\n        const userToDelete = await createUniqueUser(localPart, {\n          profile: {\n            name: \"someone to delete\",\n            email: makeEmail(localPart),\n          },\n        });\n\n        const promise = db.deleteUser({ userId: userToDelete.id }, userToDelete.id, \"wrong name\");\n\n        await assert.isRejected(promise);\n        await promise.catch(e => assert.match(e.message, /user name did not match/));\n        await assertUserStillExistsInDb(userToDelete.id);\n      });\n\n      it(\"should remove the user and cleanup their info and personal organization\", async function() {\n        const localPart = \"deleteuser-removes-user-and-cleanups-info\";\n        const userToDelete = await createUniqueUser(localPart, {\n          profile: {\n            name: \"someone to delete\",\n            email: makeEmail(localPart),\n          },\n        });\n\n        assertExists(await getPersonalOrg(userToDelete));\n\n        await db.connection.transaction(async (manager) => {\n          assert.isTrue(await userHasGroupUsers(userToDelete.id, manager));\n          assert.isTrue(await userHasPrefs(userToDelete.id, manager));\n        });\n\n        await db.deleteUser({ userId: userToDelete.id }, userToDelete.id);\n\n        assert.notExists(await db.getUser(userToDelete.id));\n        assert.deepEqual(await getPersonalOrg(userToDelete), { errMessage: \"organization not found\", status: 404 });\n\n        await db.connection.transaction(async (manager) => {\n          assert.isFalse(await userHasGroupUsers(userToDelete.id, manager));\n          assert.isFalse(await userHasPrefs(userToDelete.id, manager));\n        });\n      });\n\n      it(\"should remove the user and their forks\", async function() {\n        const localPart = \"deleteuser-removes-forks\";\n        const userToDelete = await createUniqueUser(localPart, {\n          profile: {\n            name: \"someone to delete\",\n            email: makeEmail(localPart),\n          },\n        });\n\n        // Make a little org owned by someone else, to hold a doc\n        // owned by that someone else, which our user will fork.\n        const support = (await db.getUser(db.getSupportUserId()))!;\n        await db.addOrg(support, {\n          name: \"deleteuser-org\",\n          domain: \"deleteuser-org\",\n        }, {\n          setUserAsOwner: false,\n          useNewPlan: true,\n        });\n        // Grant everyone access to org.\n        await db.updateOrgPermissions({ userId: support.id },\n          \"deleteuser-org\", {\n            users: {\n              \"everyone@getgrist.com\": \"owners\",\n            },\n          });\n        // Get the default workspace.\n        const ws = db.unwrapQueryResult(\n          await db.getOrgWorkspaces({ userId: support.id },\n            \"deleteuser-org\"),\n        )[0];\n        // Add a document to the workspace.\n        const doc = db.unwrapQueryResult(\n          await db.addDocument({ userId: support.id }, ws.id,\n            { name: \"doc-name\" }),\n        );\n        // Have our user-to-delete fork the document.\n        const forkId = db.unwrapQueryResult(\n          await db.forkDoc(userToDelete.id, doc, \"xyz\"),\n        );\n        const urlId = buildUrlId({ trunkId: doc.id, forkId, forkUserId: userToDelete.id });\n\n        // Check the fork is listed in the home db.\n        assert.deepEqual(\n          await db.connection.query(\"select name from docs where id = 'xyz'\"),\n          [{ name: \"doc-name\" }],\n        );\n\n        // Delete the user, and make sure the fork is deleted.\n        docDeletes.length = 0;\n        await db.deleteUser({ userId: userToDelete.id }, userToDelete.id);\n        assert.lengthOf(docDeletes, 1);\n        assert.deepEqual(docDeletes, [urlId]);\n\n        // Confirm the fork is no longer listed in the home db.\n        assert.deepEqual(\n          await db.connection.query(\"select name from docs where id = 'xyz'\"),\n          [],\n        );\n\n        // Confirm the user is gone.\n        assert.notExists(await db.getUser(userToDelete.id));\n      });\n\n      it(\"should remove the user when passed name corresponds to the user's name\", async function() {\n        const userName = \"someone to delete\";\n        const localPart = \"deleteuser-removes-user-when-name-matches\";\n        const userToDelete = await createUniqueUser(localPart, {\n          profile: {\n            name: userName,\n            email: makeEmail(localPart),\n          },\n        });\n\n        const promise = db.deleteUser({ userId: userToDelete.id }, userToDelete.id, userName);\n\n        await assert.isFulfilled(promise);\n      });\n    });\n\n    describe(\"completeProfiles()\", function() {\n      it(\"should return an empty array if no profiles are provided\", async function() {\n        const res = await db.completeProfiles([]);\n        assert.deepEqual(res, []);\n      });\n\n      it(\"should complete a single user profile with looking by normalized address\", async function() {\n        const localPart = ensureUnique(\"completeprofiles-with-single-profile\");\n        const email = makeEmail(localPart);\n        const emailUpperCase = email.toUpperCase();\n        const profile = {\n          name: \"completeprofiles-with-single-profile-username\",\n          email: makeEmail(localPart),\n          picture: \"https://mypic.com/me.png\",\n        };\n        const someLocale = \"fr-FR\";\n        const userCreated = await getOrCreateUser(localPart, { profile });\n        await db.updateUserOptions(userCreated.id, { locale: someLocale });\n\n        const res = await db.completeProfiles([{ name: \"whatever\", email: emailUpperCase }]);\n\n        assert.deepEqual(res, [{\n          ...profile,\n          id: userCreated.id,\n          locale: someLocale,\n          anonymous: false,\n        }]);\n      });\n\n      it(\"should complete several user profiles\", async function() {\n        const localPartPrefix = ensureUnique(\"completeprofiles-with-several-profiles\");\n        const seq = Array(10).fill(null).map((_, i) => i + 1);\n        const localParts = seq.map(i => `${localPartPrefix}_${i}`);\n        const usersCreated = await Promise.all(\n          localParts.map(localPart => getOrCreateUser(localPart)),\n        );\n\n        const res = await db.completeProfiles(\n          localParts.map(\n            localPart => ({ name: \"whatever\", email: makeEmail(localPart) }),\n          ),\n        );\n        assert.lengthOf(res, localParts.length);\n        for (const [index, localPart] of localParts.entries()) {\n          assert.deepInclude(res[index], {\n            id: usersCreated[index].id,\n            email: makeEmail(localPart),\n          });\n        }\n      });\n    });\n\n    describe(\"overwriteUser()\", function() {\n      it(\"should reject when user is not found\", async function() {\n        disableLoggingLevel(\"debug\");\n\n        const promise = db.overwriteUser(NON_EXISTING_USER_ID, {\n          email: \"whatever@getgrist.com\",\n          name: \"whatever\",\n        });\n\n        await assert.isRejected(promise, \"unable to find user to update\");\n      });\n\n      it(\"should update user information\", async function() {\n        const localPart = \"overwriteUser-updates-user-info\";\n        const newLocalPart = \"overwriteUser-updates-user-info-new\";\n        const user = await createUniqueUser(localPart);\n        const newInfo: UserProfile = {\n          name: \"new name\",\n          email: makeEmail(newLocalPart).toUpperCase(),\n          picture: \"https://mypic.com/me.png\",\n          locale: \"fr-FR\",\n        };\n\n        await db.overwriteUser(user.id, newInfo);\n\n        const updatedUser = await getOrCreateUser(newLocalPart);\n        assert.deepInclude(updatedUser, {\n          id: user.id,\n          name: newInfo.name,\n          picture: newInfo.picture,\n          options: { locale: newInfo.locale },\n        });\n        assert.deepInclude(updatedUser.logins[0], {\n          email: newInfo.email.toLowerCase(),\n          displayEmail: newInfo.email,\n        });\n      });\n    });\n\n    describe(\"getUsers()\", function() {\n      it(\"should return all users with their logins\", async function() {\n        const localPart = \"getUsers-user\";\n        const existingUser = await createUniqueUser(localPart);\n        const users = await db.getUsers();\n        assert.isAbove(users.length, 2);\n        const mapUsersById = new Map(users.map(user => [user.id, user]));\n\n        // Check that we retrieve the existing user in the result with all their property\n        // except the personalOrg\n        const existingUserInResult = mapUsersById.get(existingUser.id);\n        assertExists(existingUserInResult);\n        assertExists(existingUserInResult.logins);\n        assert.lengthOf(existingUserInResult.logins, 1);\n        assert.deepEqual(existingUserInResult, omit(existingUser, \"personalOrg\"));\n\n        // Check that we retrieve special accounts among the result\n        assert.exists(mapUsersById.get(db.getSupportUserId()));\n        assert.exists(mapUsersById.get(db.getEveryoneUserId()));\n        assert.exists(mapUsersById.get(db.getAnonymousUserId()));\n      });\n    });\n  });\n\n  describe(\"class method without db setup\", function() {\n    let db: HomeDBManager;\n    let env: EnvironmentSnapshot;\n\n    describe(\"initializeSpecialIds()\", function() {\n      before(async function() {\n        env = new EnvironmentSnapshot();\n        process.env.TEST_CLEAN_DATABASE = \"true\";\n        setUpDB(this);\n        db = new HomeDBManager();\n        await db.connect();\n        await createInitialDb(db.connection, false);\n        await updateDb(db.connection);\n      });\n\n      after(async function() {\n        await removeConnection();\n        env?.restore();\n      });\n\n      it(\"should initialize special ids\", async function() {\n        const specialAccounts = [\n          { name: \"Support\", email: SUPPORT_EMAIL },\n          { name: \"Anonymous\", email: ANONYMOUS_USER_EMAIL },\n          { name: \"Preview\", email: PREVIEWER_EMAIL },\n          { name: \"Everyone\", email: EVERYONE_EMAIL },\n        ];\n        for (const { email } of specialAccounts) {\n          assert.notExists(await db.getExistingUserByLogin(email));\n        }\n\n        assert.throws(() => db.getAnonymousUserId(), \"'Anonymous' user not available\");\n        assert.throws(() => db.getPreviewerUserId(), \"'Previewer' user not available\");\n        assert.throws(() => db.getEveryoneUserId(), \"'Everyone' user not available\");\n        assert.throws(() => db.getSupportUserId(), \"'Support' user not available\");\n\n        await db.initializeSpecialIds();\n\n        for (const { name, email } of specialAccounts) {\n          const res = await db.getExistingUserByLogin(email);\n          assertExists(res);\n          assert.equal(res.name, name);\n        }\n      });\n    });\n  });\n\n  /**\n   * Run selected tests in the context of multiple workers.\n   * This tests document deletion more thoroughly. Requires\n   * Redis.\n   */\n  describe(\"multi-worker\", function() {\n    let server: MergedServer;\n    let env: EnvironmentSnapshot;\n    let db: HomeDBManager;\n    let dataDir: string;\n    before(async function() {\n      if (!process.env.TEST_REDIS_URL) { this.skip(); }\n      setUpDB(this);\n      await createInitialDb();\n      env = new EnvironmentSnapshot();\n      process.env.REDIS_URL = process.env.TEST_REDIS_URL;\n      dataDir = await createTestDir(\"UsersManager\");\n      server = await MergedServer.create(0, [\"home\"], {\n        extraWorkers: 5,\n        dataDir,\n      });\n      await server.run();\n      db = server.flexServer.getHomeDBManager();\n      process.env.APP_HOME_URL = server.flexServer.getOwnUrl();\n    });\n\n    after(async function() {\n      env.restore();\n      await server.close();\n      if (dataDir) {\n        await fse.remove(dataDir);\n      }\n    });\n\n    describe(\"deleteUser()\", function() {\n      it(\"should remove the user and their forks\", async function() {\n        const apiKey = \"youllneverguess\";\n        const userToDelete = await db.getUserByLogin(\n          \"deleteuser-removes-forks-multi@getgrist.com\",\n        );\n        userToDelete.apiKey = apiKey;\n        await userToDelete.save();\n        const api = new UserAPIImpl(server.flexServer.getDefaultHomeUrl(), {\n          fetch: fetch as any,\n          headers: {\n            Authorization: `Bearer ${apiKey}`,\n          },\n        });\n\n        // Make a little org owned by someone else, to hold a doc\n        // owned by that someone else, which our user will fork.\n        const support = (await db.getUser(db.getSupportUserId()))!;\n        await db.addOrg(support, {\n          name: \"deleteuser-org-multi\",\n          domain: \"deleteuser-org-multi\",\n        }, {\n          setUserAsOwner: false,\n          useNewPlan: true,\n        });\n        // Grant everyone access to org.\n        await db.updateOrgPermissions({ userId: support.id },\n          \"deleteuser-org-multi\", {\n            users: {\n              \"everyone@getgrist.com\": \"owners\",\n            },\n          });\n        // Get the default workspace.\n        const ws = db.unwrapQueryResult(\n          await db.getOrgWorkspaces({ userId: support.id },\n            \"deleteuser-org-multi\"),\n        )[0];\n        // Add a document to the workspace.\n        // Created by user-to-delete, but belongs to workspace.\n        const doc = await api.newDoc({\n          name: \"doc-name\",\n        }, ws.id);\n        // Have our user-to-delete fork the document.\n        const fork = await api.getDocAPI(doc).fork();\n        // const urlId = buildUrlId({trunkId: doc.id, forkId, forkUserId: userToDelete.id});\n        const urlId = fork.docId;\n\n        // Check the fork exists.\n        const rows = await api.getDocAPI(urlId).getRows(\"_grist_Tables\");\n        assert.deepEqual(rows.tableId, [\"Table1\"]);\n\n        // Ok, tricky part, let's find where the doc is stored on disk.\n        const worker = await api.getWorkerFull(urlId);\n        const dw = server.testGetWorkerFromId(worker.docWorkerId!);\n        // Storage paths as configured here don't actually vary\n        // between workers (but they could if external\n        // storage were available).\n        const storage = dw.flexServer.getStorageManager();\n        const docPath = storage.getPath(urlId);\n        assert.isTrue(await fse.pathExists(docPath));\n\n        // Check the fork is listed in the home db.\n        assert.deepEqual(\n          await db.connection.query(\"select name from docs where id = $1\", [fork.forkId]),\n          [{ name: \"doc-name\" }],\n        );\n\n        // Delete the user.\n        await db.deleteUser({ userId: userToDelete.id }, userToDelete.id);\n\n        // Confirm the fork is no longer listed in the home db.\n        assert.deepEqual(\n          await db.connection.query(\"select name from docs where id = $1\", [fork.forkId]),\n          [],\n        );\n        // Confirm the fork is no longer on the file system.\n        assert.isFalse(await fse.pathExists(docPath));\n\n        // Confirm the user is gone.\n        assert.notExists(await db.getUser(userToDelete.id));\n      });\n    });\n  });\n});\n\n/**\n* Works around lacks of type narrowing after asserting the value is defined.\n* This is fixed in latest versions of @types/chai\n*\n* FIXME: once upgrading @types/chai to 4.3.17 or higher, remove this function which would not be useful anymore\n*/\nfunction assertExists<T>(value?: T, message?: string): asserts value is T {\n  assert.exists(value, message);\n}\n\nfunction assertBetween(min: number, value: number | null | undefined, max: number, message?: string) {\n  assert.isNotNull(value, message);\n  assert.isDefined(value, message);\n  if (value !== null && value !== undefined) {\n    assert.isAtLeast(value, min, message);\n    assert.isAtMost(value, max, message);\n  }\n}\n"
  },
  {
    "path": "test/gen-server/lib/limits.ts",
    "content": "import { ApiError } from \"app/common/ApiError\";\nimport { AssistanceRequestV1, AssistanceRequestV2 } from \"app/common/Assistance\";\nimport { IOptions } from \"app/common/BaseAPI\";\nimport { Features } from \"app/common/Features\";\nimport { resetOrg } from \"app/common/resetOrg\";\nimport { UserAPI, UserAPIImpl } from \"app/common/UserAPI\";\nimport { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { Limit } from \"app/gen-server/entity/Limit\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Product } from \"app/gen-server/entity/Product\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { configForUser, createUser } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport FormData from \"form-data\";\nimport fetch from \"node-fetch\";\n\ndescribe(\"limits\", function() {\n  let home: TestServer;\n  let dbManager: HomeDBManager;\n  let homeUrl: string;\n  let product: Product;\n  let api: UserAPI;\n  let nasa: UserAPI;\n  let billingId: number;\n  let oldEnv: testUtils.EnvironmentSnapshot;\n\n  testUtils.setTmpLogLevel(\"error\");\n\n  this.timeout(\"10s\");\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    process.env.OPENAI_API_KEY = \"test\";\n\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n\n    dbManager = home.dbManager;\n    homeUrl = home.serverUrl;\n\n    // Create a test product\n    product = new Product();\n    product.name = \"test_product\";\n    product.features = { workspaces: true };\n    await product.save();\n    // Create a new user\n    const samHome = await createUser(dbManager, \"sam\");\n    // Overwrite default product\n    billingId = samHome.billingAccount.id;\n    await dbManager.connection.createQueryBuilder()\n      .update(BillingAccount)\n      .set({ product })\n      .where(\"id = :billingId\", { billingId })\n      .execute();\n    // Set up an api object tied to the user's personal org\n    api = new UserAPIImpl(`${homeUrl}/o/docs`, {\n      fetch: fetch as any,\n      newFormData: () => new FormData() as any,\n      ...configForUser(\"sam\") as IOptions,\n    });\n    // Give chimpy access to this org\n    await api.updateOrgPermissions(\"current\", { users: { \"chimpy@getgrist.com\": \"owners\" } });\n    // Set up an api object tied to nasa\n    nasa = new UserAPIImpl(`${homeUrl}/o/nasa`, {\n      fetch: fetch as any,\n      ...configForUser(\"chimpy\") as IOptions,\n    });\n  });\n\n  after(async function() {\n    await home.stop();\n    oldEnv.restore();\n  });\n\n  async function setFeatures(features: Features) {\n    product.features = features;\n    await product.save();\n  }\n\n  it(\"can enforce limits on number of workspaces\", async function() {\n    await setFeatures({ maxWorkspacesPerOrg: 2, workspaces: true });\n\n    // initially have just one workspace, the default workspace\n    // created for a new personal org.\n    assert.lengthOf(await api.getOrgWorkspaces(\"current\"), 1);\n    await assert.isFulfilled(api.newWorkspace({ name: \"work2\" }, \"current\"));\n    await assert.isRejected(api.newWorkspace({ name: \"work3\" }, \"current\"),\n      /No more workspaces/);\n\n    await setFeatures({ maxWorkspacesPerOrg: 3, workspaces: true });\n    await assert.isFulfilled(api.newWorkspace({ name: \"work3\" }, \"current\"));\n    await assert.isRejected(api.newWorkspace({ name: \"work4\" }, \"current\"),\n      /No more workspaces/);\n\n    await setFeatures({ workspaces: true });\n    await assert.isFulfilled(api.newWorkspace({ name: \"work4\" }, \"current\"));\n\n    await setFeatures({ maxWorkspacesPerOrg: 1, workspaces: true });\n    await assert.isRejected(api.newWorkspace({ name: \"work5\" }, \"current\"),\n      /No more workspaces/);\n  });\n\n  it(\"can enforce limits on number of workspace shares\", async function() {\n    this.timeout(4000);\n    await setFeatures({ maxSharesPerWorkspace: 3, workspaces: true });\n    const wsId = await api.newWorkspace({ name: \"work\" }, \"docs\");\n\n    // Adding 4 users would exceed 3 user limit\n    await assert.isRejected(api.updateWorkspacePermissions(wsId, {\n      users: {\n        \"user1@getgrist.com\": \"owners\",\n        \"user2@getgrist.com\": \"viewers\",\n        \"user3@getgrist.com\": \"owners\",\n        \"user4@getgrist.com\": \"viewers\",\n      },\n    }), /No more external workspace shares/);\n\n    // Adding 1 user is ok\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: { \"user1@getgrist.com\": \"owners\" },\n    }));\n\n    // Adding 2nd+3rd user is ok\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: {\n        \"user2@getgrist.com\": \"owners\",\n        \"user3@getgrist.com\": \"owners\",\n      },\n    }));\n\n    // Adding 4th user fails\n    await assert.isRejected(api.updateWorkspacePermissions(wsId, {\n      users: { \"user4@getgrist.com\": \"owners\" },\n    }), /No more external workspace shares/);\n\n    // Adding support user is ok\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: { \"support@getgrist.com\": \"owners\" },\n    }));\n\n    // Replacing user is ok\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: {\n        \"user2@getgrist.com\": null,\n        \"user2b@getgrist.com\": \"owners\",\n      },\n    }));\n\n    // Removing a user and adding another is ok\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: { \"user1@getgrist.com\": null },\n    }));\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: { \"user1b@getgrist.com\": \"owners\" },\n    }));\n    await assert.isRejected(api.updateWorkspacePermissions(wsId, {\n      users: { \"user5@getgrist.com\": \"owners\" },\n    }), /No more external workspace shares/);\n\n    // Reduce to limit to allow just one share\n    await setFeatures({ maxSharesPerWorkspace: 1, workspaces: true });\n\n    // Cannot add or replace users, since we are over limit\n    await assert.isRejected(api.updateWorkspacePermissions(wsId, {\n      users: {\n        \"user3@getgrist.com\": null,\n        \"user3b@getgrist.com\": \"owners\",\n      },\n    }), /No more external workspace shares/);\n\n    // Can remove a user, while still being over limit\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: { \"user1b@getgrist.com\": null },\n    }));\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: { \"user2b@getgrist.com\": null },\n    }));\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: { \"user3@getgrist.com\": null },\n    }));\n\n    // Finally ok to add a user again\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: { \"user1@getgrist.com\": \"owners\" },\n    }));\n  });\n\n  it(\"can enforce limits on number of docs\", async function() {\n    await setFeatures({ maxDocsPerOrg: 2, workspaces: true });\n    const wsId = await api.newWorkspace({ name: \"work\" }, \"docs\");\n\n    await assert.isFulfilled(api.newDoc({ name: \"doc1\" }, wsId));\n    await assert.isFulfilled(api.newDoc({ name: \"doc2\" }, wsId));\n    await assert.isRejected(api.newDoc({ name: \"doc3\" }, wsId), /No more documents/);\n\n    await setFeatures({ maxDocsPerOrg: 3, workspaces: true });\n    await assert.isFulfilled(api.newDoc({ name: \"doc3\" }, wsId));\n    await assert.isRejected(api.newDoc({ name: \"doc4\" }, wsId), /No more documents/);\n\n    await setFeatures({ workspaces: true });\n    await assert.isFulfilled(api.newDoc({ name: \"doc4\" }, wsId));\n\n    await setFeatures({ maxDocsPerOrg: 1, workspaces: true });\n    await assert.isRejected(api.newDoc({ name: \"doc5\" }, wsId), /No more documents/);\n\n    // check that smuggling in a document from another org doesn't work.\n    await assert.isRejected(nasa.moveDoc(await dbManager.testGetId(\"Jupiter\") as string, wsId),\n      /No more documents/);\n\n    // now make space for the document and try again.\n    await setFeatures({ maxDocsPerOrg: 6, workspaces: true });\n    await assert.isFulfilled(nasa.moveDoc(await dbManager.testGetId(\"Jupiter\") as string, wsId));\n\n    // add a document in a workspace we are then going to make inaccessible.\n    const wsHiddenId = await api.newWorkspace({ name: \"hidden\" }, \"docs\");\n    await assert.isFulfilled(api.newDoc({ name: \"doc6\" }, wsHiddenId));\n    await assert.isRejected(api.newDoc({ name: \"doc7\" }, wsHiddenId), /No more documents/);\n\n    // transfer workspace ownership, and make inaccessible.\n    await api.updateWorkspacePermissions(wsHiddenId, { users: { \"charon@getgrist.com\": \"owners\" } });\n    const charon = await home.createHomeApi(\"charon\", \"docs\", true);\n    await charon.updateWorkspacePermissions(wsHiddenId, { maxInheritedRole: null });\n\n    // now try adding a document and make sure it is denied.\n    await assert.isRejected(api.newDoc({ name: \"doc7\" }, wsId), /No more documents/);\n\n    // clean up workspace.\n    await charon.deleteWorkspace(wsHiddenId);\n  });\n\n  it(\"can enforce limits on number of doc shares\", async function() {\n    // This can exceed the default of 2s on Jenkins\n    // - Changed from 4s to 8s on 2024-10-04\n    this.timeout(\"8s\");\n\n    await setFeatures({ maxSharesPerDoc: 3, workspaces: true });\n    const wsId = await api.newWorkspace({ name: \"shares\" }, \"docs\");\n    const docId = await api.newDoc({ name: \"doc\" }, wsId);\n\n    // Adding 4 users would exceed 3 user limit\n    await assert.isRejected(api.updateDocPermissions(docId, {\n      users: {\n        \"user1@getgrist.com\": \"owners\",\n        \"user2@getgrist.com\": \"viewers\",\n        \"user3@getgrist.com\": \"owners\",\n        \"user4@getgrist.com\": \"viewers\",\n      },\n    }), /No more external document shares/);\n\n    // Adding 1 user is ok\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"user1@getgrist.com\": \"owners\" },\n    }));\n\n    // Adding 2nd+3rd user is ok\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: {\n        \"user2@getgrist.com\": \"owners\",\n        \"user3@getgrist.com\": \"owners\",\n      },\n    }));\n\n    // Adding 4th user fails\n    await assert.isRejected(api.updateDocPermissions(docId, {\n      users: { \"user4@getgrist.com\": \"owners\" },\n    }), /No more external document shares/);\n\n    // Adding support user is ok\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"support@getgrist.com\": \"owners\" },\n    }));\n\n    // Replacing user is ok\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: {\n        \"user2@getgrist.com\": null,\n        \"user2b@getgrist.com\": \"owners\",\n      },\n    }));\n\n    // Removing a user and adding another is ok\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"user1@getgrist.com\": null },\n    }));\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"user1b@getgrist.com\": \"owners\" },\n    }));\n    await assert.isRejected(api.updateDocPermissions(docId, {\n      users: { \"user5@getgrist.com\": \"owners\" },\n    }), /No more external document shares/);\n\n    // Reduce to limit to allow just one share\n    await setFeatures({ maxSharesPerDoc: 1, workspaces: true });\n\n    // Cannot add or replace users, since we are over limit\n    await assert.isRejected(api.updateDocPermissions(docId, {\n      users: {\n        \"user3@getgrist.com\": null,\n        \"user3b@getgrist.com\": \"owners\",\n      },\n    }), /No more external document shares/);\n\n    // Can remove a user, while still being over limit\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"user1b@getgrist.com\": null },\n    }));\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"user2b@getgrist.com\": null },\n    }));\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"user3@getgrist.com\": null },\n    }));\n\n    // Finally ok to add a user again\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"user1@getgrist.com\": \"owners\" },\n    }));\n\n    // Try smuggling in a doc that breaks the rules\n    // Tweak NASA's product to allow 4 shares per doc.\n    const db = dbManager.connection.manager;\n    const nasaOrg = await db.findOne(Organization, { where: { domain: \"nasa\" },\n      relations: [\"billingAccount\",\n        \"billingAccount.product\"] });\n    if (!nasaOrg) { throw new Error(\"could not find nasa org\"); }\n    const nasaProduct = nasaOrg.billingAccount.product;\n    const originalFeatures = nasaProduct.features;\n\n    nasaProduct.features = { ...originalFeatures, maxSharesPerDoc: 4 };\n    await nasaProduct.save();\n\n    const pluto = await dbManager.testGetId(\"Pluto\") as string;\n    await nasa.updateDocPermissions(pluto, {\n      users: {\n        \"zig@getgrist.com\": \"owners\",\n        \"zag@getgrist.com\": \"editors\",\n        \"zog@getgrist.com\": \"viewers\",\n      },\n    });\n    await assert.isRejected(nasa.moveDoc(pluto, wsId), /Too many external document shares/);\n\n    // Increase the limit and try again\n    await setFeatures({ maxSharesPerDoc: 100, workspaces: true });\n    await assert.isFulfilled(nasa.moveDoc(pluto, wsId));\n  });\n\n  it(\"can enforce limits on number of doc shares per role\", async function() {\n    this.timeout(4000);      // This can exceed the default of 2s on Jenkins\n\n    await setFeatures({ maxSharesPerDoc: 10,\n      maxSharesPerDocPerRole: {\n        owners: 1,\n        editors: 2,\n      },\n      workspaces: true });\n    const wsId = await api.newWorkspace({ name: \"roleShares\" }, \"docs\");\n    const docId = await api.newDoc({ name: \"doc\" }, wsId);\n\n    // can add plenty of viewers\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: {\n        \"viewer1@getgrist.com\": \"viewers\",\n        \"viewer2@getgrist.com\": \"viewers\",\n        \"viewer3@getgrist.com\": \"viewers\",\n        \"viewer4@getgrist.com\": \"viewers\",\n      },\n    }));\n\n    // can add just one owner\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"owner1@getgrist.com\": \"owners\" },\n    }));\n    await assert.isRejected(api.updateDocPermissions(docId, {\n      users: { \"owner2@getgrist.com\": \"owners\" },\n    }), /No more external document owners/);\n\n    // can add at most two editors\n    await assert.isRejected(api.updateDocPermissions(docId, {\n      users: {\n        \"editor1@getgrist.com\": \"editors\",\n        \"editor2@getgrist.com\": \"editors\",\n        \"editor3@getgrist.com\": \"editors\",\n      },\n    }), /No more external document editors/);\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: {\n        \"editor1@getgrist.com\": \"editors\",\n        \"editor2@getgrist.com\": \"editors\",\n      },\n    }));\n\n    // can convert an editor to a viewer and then add another editor\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: {\n        \"editor1@getgrist.com\": \"viewers\",\n        \"editor3@getgrist.com\": \"editors\",\n      },\n    }));\n\n    // we are at 8 shares, can make just two more\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"viewer5@getgrist.com\": \"viewers\" },\n    }));\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: { \"viewer6@getgrist.com\": \"viewers\" },\n    }));\n    await assert.isRejected(api.updateDocPermissions(docId, {\n      users: { \"viewer7@getgrist.com\": \"viewers\" },\n    }), /No more external document shares/);\n\n    // Try smuggling in a doc that exceeds limits\n    const beyond = await dbManager.testGetId(\"Beyond\") as string;\n    await nasa.updateDocPermissions(beyond, {\n      users: {\n        \"zig@getgrist.com\": \"owners\",\n        \"zag@getgrist.com\": \"owners\",\n      },\n    });\n    await assert.isRejected(nasa.moveDoc(beyond, wsId), /Too many external document owners/);\n\n    // Increase the limit and try again\n    await setFeatures({ maxSharesPerDoc: 10,\n      maxSharesPerDocPerRole: {\n        owners: 2,\n        editors: 2,\n      },\n      workspaces: true });\n    await assert.isFulfilled(nasa.moveDoc(beyond, wsId));\n  });\n\n  it(\"can give good tips when exceeding doc shares\", async function() {\n    await setFeatures({ maxSharesPerDoc: 2, workspaces: true });\n    const wsId = await api.newWorkspace({ name: \"shares\" }, \"docs\");\n    const docId = await api.newDoc({ name: \"doc\" }, wsId);\n\n    await assert.isFulfilled(api.updateDocPermissions(docId, {\n      users: {\n        \"user1@getgrist.com\": \"owners\",\n        \"user2@getgrist.com\": \"viewers\",\n      },\n    }));\n    let err: ApiError = await api.updateDocPermissions(docId, {\n      users: {\n        \"user3@getgrist.com\": \"owners\",\n      },\n    }).catch(e => e);\n    // Advice should be to add users as members.\n    assert.sameMembers(err.details!.tips!.map(tip => tip.action), [\"add-members\"]);\n\n    // Now switch to a product that looks like a personal site\n    await setFeatures({ maxSharesPerDoc: 2, workspaces: true, maxWorkspacesPerOrg: 1 });\n    err = await api.updateDocPermissions(docId, {\n      users: {\n        \"user3@getgrist.com\": \"owners\",\n      },\n    }).catch(e => e);\n    // Advice should be to upgrade.\n    assert.sameMembers(err.details!.tips!.map(tip => tip.action), [\"upgrade\"]);\n  });\n\n  it(\"can give good tips when exceeding workspace shares\", async function() {\n    await setFeatures({ maxSharesPerWorkspace: 2, workspaces: true });\n    const wsId = await api.newWorkspace({ name: \"shares\" }, \"docs\");\n\n    await assert.isFulfilled(api.updateWorkspacePermissions(wsId, {\n      users: {\n        \"user1@getgrist.com\": \"owners\",\n        \"user2@getgrist.com\": \"viewers\",\n      },\n    }));\n    let err: ApiError = await api.updateWorkspacePermissions(wsId, {\n      users: {\n        \"user3@getgrist.com\": \"owners\",\n      },\n    }).catch(e => e);\n    // Advice should be to add users as members.\n    assert.sameMembers(err.details!.tips!.map(tip => tip.action), [\"add-members\"]);\n\n    // Now switch to a product that looks like a personal site (it should not\n    // be possible to share workspaces via UI in this case though)\n    await setFeatures({ maxSharesPerWorkspace: 0, workspaces: true, maxWorkspacesPerOrg: 1 });\n    err = await api.updateWorkspacePermissions(wsId, {\n      users: {\n        \"user3@getgrist.com\": \"owners\",\n      },\n    }).catch(e => e);\n    // Advice should be to upgrade.\n    assert.sameMembers(err.details!.tips!.map(tip => tip.action), [\"upgrade\"]);\n  });\n\n  it(\"discounts deleted and soft-deleted documents from quota\", async function() {\n    this.timeout(3000);      // This can exceed the default of 2s on Jenkins\n\n    // Reset org to contain no docs, and set limit on docs to 2\n    await resetOrg(api, \"docs\");\n    await setFeatures({ maxDocsPerOrg: 2, workspaces: true });\n    const wsId = await api.newWorkspace({ name: \"work\" }, \"docs\");\n\n    // Create 2 docs.  Then creating another will fail.\n    const doc1 = await api.newDoc({ name: \"doc1\" }, wsId);\n    const doc2 = await api.newDoc({ name: \"doc2\" }, wsId);\n    await assert.isRejected(api.newDoc({ name: \"doc3\" }, wsId), /No more documents/);\n\n    // Hard-delete one doc, then we can add another.\n    await api.deleteDoc(doc1);\n    const doc3 = await api.newDoc({ name: \"doc3\" }, wsId);\n\n    // Soft-delete one doc, then we can add another.\n    await api.softDeleteDoc(doc2);\n    await api.newDoc({ name: \"doc4\" }, wsId);\n\n    // Check we can neither create nor recover a doc when full again.\n    await assert.isRejected(api.newDoc({ name: \"doc5\" }, wsId), /No more documents/);\n    await assert.isRejected(api.undeleteDoc(doc2), /No more documents/);\n\n    // Check that if we make some space we can recover a doc.\n    await api.softDeleteDoc(doc3);\n    await api.undeleteDoc(doc2);\n  });\n\n  it(\"can enforce limits on total attachment file size\", async function() {\n    this.timeout(4000);\n\n    // Each attachment in this test will have one byte, so essentially we're limiting to two attachments\n    await setFeatures({ baseMaxAttachmentsBytesPerDocument: 2 });\n\n    const workspaces = await api.getOrgWorkspaces(\"current\");\n    const docId = await api.newDoc({ name: \"doc1\" }, workspaces[0].id);\n    await api.applyUserActions(docId, [[\"ModifyColumn\", \"Table1\", \"A\", { type: \"Attachments\" }]]);\n    const docApi = api.getDocAPI(docId);\n\n    // Add a cell referencing the attachments we're about to create.\n    // This ensures that they won't be immediately treated as soft-deleted and ignored in the total size calculation.\n    // Otherwise the uploads after this would succeed even if duplicate attachments were counted twice.\n    const rowIds = await docApi.addRows(\"Table1\", { A: [[GristObjCode.List, 1, 2, 3]] });\n    assert.deepEqual(rowIds, [1]);\n\n    // We're limited to 2 attachments, but the attachment 'a' is duplicated so it's only counted once.\n    const attachmentIds = [\n      await docApi.uploadAttachment(\"a\", \"a.txt\"),\n      await docApi.uploadAttachment(\"a\", \"a.txt\"),\n      await docApi.uploadAttachment(\"b\", \"b.txt\"),\n    ];\n    assert.deepEqual(attachmentIds, [1, 2, 3]);\n\n    // Now we're at the limit and trying to upload another attachment is rejected.\n    await assert.isRejected(docApi.uploadAttachment(\"c\", \"c.txt\"));\n\n    // Delete one reference to 'a', but there's still another one so we're still at the limit and can't upload more.\n    await docApi.updateRows(\"Table1\", { id: rowIds, A: [[GristObjCode.List, 2, 3]] });\n    await assert.isRejected(docApi.uploadAttachment(\"c\", \"c.txt\"));\n\n    // Delete the other reference to 'a' so now there's only one referenced attachment 'b' and we can upload again.\n    await docApi.updateRows(\"Table1\", { id: rowIds, A: [[GristObjCode.List, 3, 4]] });\n    assert.equal(await docApi.uploadAttachment(\"c\", \"c.txt\"), 4);\n\n    // Now we're at the limit again with 'b' and 'c' and can't upload further.\n    await assert.isRejected(docApi.uploadAttachment(\"d\", \"d.txt\"));\n  });\n\n  it(\"can enforce limits on assistant usage\", async function() {\n    const setLimit = async (limit: number | undefined) => {\n      await setFeatures({ baseMaxAssistantCalls: limit });\n      if (limit !== undefined) {\n        await dbManager.connection.createQueryBuilder()\n          .update(Limit)\n          .set({ limit })\n          .where(\"billing_account_id = :billingId\", { billingId })\n          .execute();\n      }\n    };\n\n    const sendAndAssert = async ({ fulfilled}: { fulfilled: boolean }) => {\n      const version = home.server.getAssistant()?.version;\n      const sharedPayload = {\n        conversationId: \"id\",\n        text: \"text\",\n      };\n      const v1: AssistanceRequestV1 = {\n        ...sharedPayload,\n        context: {\n          tableId: \"\",\n          colId: \"\",\n        },\n      };\n      const v2: AssistanceRequestV2 = {\n        ...sharedPayload,\n        context: {},\n      };\n      const response = docApi.getAssistance(version === 1 ? v1 : v2);\n      if (fulfilled) {\n        await assert.isFulfilled(response);\n      } else {\n        await assert.isRejected(response);\n      }\n    };\n\n    const workspaces = await api.getOrgWorkspaces(\"current\");\n    const docId = await api.newDoc({ name: \"doc2\" }, workspaces[0].id);\n    const docApi = api.getDocAPI(docId);\n\n    await setLimit(0);\n    await sendAndAssert({ fulfilled: false });\n\n    await setLimit(2);\n    await sendAndAssert({ fulfilled: true });\n    await sendAndAssert({ fulfilled: true });\n    await sendAndAssert({ fulfilled: false });\n\n    await setLimit(undefined);\n    await sendAndAssert({ fulfilled: true });\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/listing.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\n/**\n * Tests details of listing workspaces or documents via API.\n */\ndescribe(\"listing\", function() {\n  this.timeout(10000);\n  let home: TestServer;\n  testUtils.setTmpLogLevel(\"error\");\n\n  const org: string = \"testy\";\n  let api: UserAPI;\n  let viewer: UserAPI;\n  let editor: UserAPI;\n  let ws1: number;\n  let ws2: number;\n  let ws3: number;\n  let doc12: string;\n  let doc13: string;\n\n  before(async function() {\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n\n    // Create a test org with some workspaces and docs\n    api = await home.createHomeApi(\"chimpy\", \"docs\", true);\n    await api.newOrg({ name: org, domain: org });\n    api = await home.createHomeApi(\"chimpy\", org, true);\n    ws1 = await api.newWorkspace({ name: \"ws1\" }, \"current\");\n    ws2 = await api.newWorkspace({ name: \"ws2\" }, \"current\");\n    ws3 = await api.newWorkspace({ name: \"ws3\" }, \"current\");\n    await api.newDoc({ name: \"doc11\" }, ws1);\n    doc12 = await api.newDoc({ name: \"doc12\" }, ws1);\n    doc13 = await api.newDoc({ name: \"doc13\" }, ws1);\n    const doc21 = await api.newDoc({ name: \"doc21\" }, ws2);\n\n    // add an editor and a viewer to the org.\n    await api.updateOrgPermissions(\"current\", {\n      users: {\n        \"kiwi@getgrist.com\": \"viewers\",\n        \"support@getgrist.com\": \"editors\",\n      },\n    });\n    viewer = await home.createHomeApi(\"kiwi\", org, true);\n    editor = await home.createHomeApi(\"support\", org, true);\n\n    // add another user as an owner of two docs and two workspaces.\n    await api.updateDocPermissions(doc12, {\n      users: { \"charon@getgrist.com\": \"owners\" },\n    });\n    await api.updateDocPermissions(doc13, {\n      users: { \"charon@getgrist.com\": \"owners\" },\n    });\n    await api.updateWorkspacePermissions(ws2, {\n      users: { \"charon@getgrist.com\": \"owners\" },\n    });\n    await api.updateWorkspacePermissions(ws3, {\n      users: { \"charon@getgrist.com\": \"owners\" },\n    });\n\n    // Have that user remove or limit everyone else's access to those docs and workspaces.\n    const charon = await home.createHomeApi(\"charon\", org, true);\n    await charon.updateWorkspacePermissions(ws2, {\n      users: { \"chimpy@getgrist.com\": null }, // remove chimpy from ws2\n    });\n    await charon.updateDocPermissions(doc12, {\n      maxInheritedRole: null,\n      users: { \"chimpy@getgrist.com\": null }, // remove chimpy's direct access\n    });\n    await charon.updateDocPermissions(doc13, {\n      maxInheritedRole: \"viewers\",\n      users: { \"chimpy@getgrist.com\": null }, // remove chimpy's direct access\n    });\n    await charon.updateDocPermissions(doc21, {\n      users: { \"chimpy@getgrist.com\": null }, // remove chimpy's direct access\n    });\n    await charon.updateWorkspacePermissions(ws2, {\n      maxInheritedRole: null,\n      users: { \"chimpy@getgrist.com\": null }, // remove chimpy's direct access\n    });\n    await charon.updateWorkspacePermissions(ws3, {\n      maxInheritedRole: \"viewers\",\n      users: { \"chimpy@getgrist.com\": null }, // remove chimpy's direct access\n    });\n  });\n\n  after(async function() {\n    await api.deleteOrg(\"testy\");\n    await home.stop();\n  });\n\n  // Check lists acquired via getWorkspace or via getOrgWorkspaces.\n  for (const method of [\"getWorkspace\", \"getOrgWorkspaces\"] as const) {\n    it(`editors and owners can list docs they cannot view with ${method}`, async function() {\n      async function list(user: UserAPI) {\n        if (method === \"getWorkspace\") { return user.getWorkspace(ws1); }\n        return (await user.getOrgWorkspaces(\"current\")).find(ws => ws.name === \"ws1\")!;\n      }\n\n      // Check owner of a workspace can see a doc they don't have access to listed (doc12).\n      let listing = await list(api);\n      assert.lengthOf(listing.docs, 3);\n      assert.equal(listing.docs[0].name, \"doc11\");\n      assert.equal(listing.docs[0].access, \"owners\");\n      assert.equal(listing.docs[1].name, \"doc12\");\n      assert.equal(listing.docs[1].access, null);\n      assert.equal(listing.docs[2].name, \"doc13\");\n      assert.equal(listing.docs[2].access, \"viewers\");\n\n      // Editor's perspective should be like the owner.\n      listing = await list(editor);\n      assert.lengthOf(listing.docs, 3);\n      assert.equal(listing.docs[0].name, \"doc11\");\n      assert.equal(listing.docs[0].access, \"editors\");\n      assert.equal(listing.docs[1].name, \"doc12\");\n      assert.equal(listing.docs[1].access, null);\n      assert.equal(listing.docs[2].name, \"doc13\");\n      assert.equal(listing.docs[2].access, \"viewers\");\n\n      // Viewer's perspective should omit docs user has no access to.\n      listing = await list(viewer);\n      assert.lengthOf(listing.docs, 2);\n      assert.equal(listing.docs[0].name, \"doc11\");\n      assert.equal(listing.docs[0].access, \"viewers\");\n      assert.equal(listing.docs[1].name, \"doc13\");\n      assert.equal(listing.docs[1].access, \"viewers\");\n    });\n  }\n\n  it(\"editors and owners CANNOT list workspaces they cannot view\", async function() {\n    async function list(user: UserAPI) {\n      return (await user.getOrgWorkspaces(\"current\")).filter(ws => ws.name.startsWith(\"ws\"));\n    }\n\n    // Check owner of an org CANNOT see a workspace they don't have access to listed (ws2).\n    let listing = await list(api);\n    assert.lengthOf(listing, 2);\n    assert.equal(listing[0].name, \"ws1\");\n    assert.equal(listing[0].access, \"owners\");\n    assert.equal(listing[1].name, \"ws3\");\n    assert.equal(listing[1].access, \"viewers\");\n\n    // Viewer's perspective should be similar.\n    listing = await list(viewer);\n    assert.lengthOf(listing, 2);\n    assert.equal(listing[0].name, \"ws1\");\n    assert.equal(listing[0].access, \"viewers\");\n    assert.equal(listing[1].name, \"ws3\");\n    assert.equal(listing[1].access, \"viewers\");\n  });\n\n  // Make sure empty workspaces do not get filtered out of listings.\n  it(\"lists empty workspaces\", async function() {\n    // We'll need a second user for some operations.\n    const charon = await home.createHomeApi(\"charon\", org, true);\n\n    // Make an empty workspace.\n    await api.newWorkspace({ name: \"wsEmpty\" }, \"current\");\n\n    // Make a workspace with a single, inaccessible doc.\n    const wsWithDoc = await api.newWorkspace({ name: \"wsWithDoc\" }, \"current\");\n    const docInaccessible = await api.newDoc({ name: \"inaccessible\" }, wsWithDoc);\n    // Add another user as an owner of the doc.\n    await api.updateDocPermissions(docInaccessible, {\n      users: { \"charon@getgrist.com\": \"owners\" },\n    });\n    // Now remove everyone else's access.\n    await charon.updateDocPermissions(docInaccessible, {\n      maxInheritedRole: null,\n    });\n\n    // Make an inaccessible workspace.\n    const wsInaccessible = await api.newWorkspace({ name: \"wsInaccessible\" }, \"current\");\n    // Add another user as an owner of the workspace.\n    await api.updateWorkspacePermissions(wsInaccessible, {\n      users: { \"charon@getgrist.com\": \"owners\" },\n    });\n    // Now remove everyone else's access.\n    await charon.updateWorkspacePermissions(wsInaccessible, {\n      maxInheritedRole: null,\n    });\n\n    for (const user of [api, editor, viewer]) {\n      // Make sure both accessible workspaces are present in getOrgWorkspaces list,\n      // and don't get filtered out just because they are empty.\n      const listing = await user.getOrgWorkspaces(\"current\");\n      const names = listing.map(ws => ws.name);\n      assert.includeMembers(names, [\"wsEmpty\", \"wsWithDoc\"]);\n      assert.notInclude(names, [\"wsInaccessible\"]);\n    }\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/mergedOrgs.ts",
    "content": "import { Workspace } from \"app/common/UserAPI\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { FlexServer } from \"app/server/lib/FlexServer\";\nimport { MergedServer } from \"app/server/MergedServer\";\nimport { createInitialDb, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport { configForUser, createUser, setPlan } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\n\ndescribe(\"mergedOrgs\", function() {\n  let mergedServer: MergedServer;\n  let home: FlexServer;\n  let dbManager: HomeDBManager;\n  let homeUrl: string;\n  let sharedOrgDomain: string;\n  let sharedDocId: string;\n\n  testUtils.setTmpLogLevel(\"error\");\n\n  before(async function() {\n    setUpDB(this);\n    await createInitialDb();\n    mergedServer = await MergedServer.create(0, [\"home\", \"docs\"],\n      { logToConsole: false, externalStorage: false });\n    home = mergedServer.flexServer;\n    await mergedServer.run();\n    dbManager = home.getHomeDBManager();\n    homeUrl = home.getOwnUrl();\n  });\n\n  after(async function() {\n    await home.close();\n    await removeConnection();\n  });\n\n  it(\"can list all shared workspaces from personal orgs\", async function() {\n    // Org \"0\" or \"docs\" is a special pseudo-org, with the merged results of all\n    // workspaces in personal orgs that user has access to.\n    let resp = await axios.get(`${homeUrl}/api/orgs/0/workspaces`, configForUser(\"chimpy\"));\n    assert.equal(resp.status, 200);\n    // See only workspaces in Chimpy's personal org so far.\n    assert.sameMembers(resp.data.map((w: Workspace) => w.name), [\"Public\", \"Private\"]);\n    // Grant Chimpy access to Kiwi's personal org, and add a workspace to it.\n    const kiwilandOrgId = await dbManager.testGetId(\"Kiwiland\");\n    resp = await axios.patch(`${homeUrl}/api/orgs/${kiwilandOrgId}/access`, {\n      delta: { users: { \"chimpy@getgrist.com\": \"editors\" } },\n    }, configForUser(\"kiwi\"));\n    resp = await axios.post(`${homeUrl}/api/orgs/${kiwilandOrgId}/workspaces`, {\n      name: \"Kiwidocs\",\n    }, configForUser(\"kiwi\"));\n    resp = await axios.get(`${homeUrl}/api/orgs/0/workspaces`, configForUser(\"chimpy\"));\n    assert.sameMembers(resp.data.map((w: Workspace) => w.name), [\"Private\", \"Public\", \"Kiwidocs\"]);\n\n    // Create a new user with two workspaces, add chimpy to a document within\n    // one of them, and make sure chimpy sees that workspace.\n    const samHome = await createUser(dbManager, \"Sam\");\n    await setPlan(dbManager, samHome, \"Free\");\n    sharedOrgDomain = samHome.domain;\n    // A private workspace/doc that Sam won't share.\n    resp = await axios.post(`${homeUrl}/api/orgs/${samHome.id}/workspaces`, {\n      name: \"SamPrivateStuff\",\n    }, configForUser(\"sam\"));\n    assert.equal(resp.status, 200);\n    let wsId = resp.data;\n    resp = await axios.post(`${homeUrl}/api/workspaces/${wsId}/docs`, {\n      name: \"SamPrivateDoc\",\n    }, configForUser(\"sam\"));\n    assert.equal(resp.status, 200);\n    // A workspace/doc that Sam will share with Chimpy.\n    resp = await axios.post(`${homeUrl}/api/orgs/${samHome.id}/workspaces`, {\n      name: \"SamStuff\",\n    }, configForUser(\"sam\"));\n    assert.equal(resp.status, 200);\n    wsId = resp.data!;\n    resp = await axios.post(`${homeUrl}/api/workspaces/${wsId}/docs`, {\n      name: \"SamDoc\",\n    }, configForUser(\"sam\"));\n    assert.equal(resp.status, 200);\n    sharedDocId = resp.data!;\n    resp = await axios.patch(`${homeUrl}/api/docs/${sharedDocId}/access`, {\n      delta: { users: { \"chimpy@getgrist.com\": \"viewers\" } },\n    }, configForUser(\"sam\"));\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/api/orgs/0/workspaces`, configForUser(\"chimpy\"));\n    const sharedWss = [\"Private\", \"Public\", \"Kiwidocs\", \"SamStuff\"];\n    assert.sameMembers(resp.data.map((w: Workspace) => w.name), sharedWss);\n\n    // Check that all this is visible from docs domain expressed in different ways.\n    resp = await axios.get(`${homeUrl}/o/docs/api/orgs/current/workspaces`, configForUser(\"chimpy\"));\n    assert.sameMembers(resp.data.map((w: Workspace) => w.name), sharedWss);\n    resp = await axios.get(`${homeUrl}/api/orgs/docs/workspaces`, configForUser(\"chimpy\"));\n    assert.sameMembers(resp.data.map((w: Workspace) => w.name), sharedWss);\n  });\n\n  it(\"can access a document under merged domain\", async function() {\n    let resp = await axios.get(`${homeUrl}/o/docs/api/docs/${sharedDocId}/tables/Table1/data`,\n      configForUser(\"chimpy\"));\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/o/${sharedOrgDomain}/api/docs/${sharedDocId}/tables/Table1/data`,\n      configForUser(\"chimpy\"));\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${homeUrl}/o/nasa/api/docs/${sharedDocId}/tables/Table1/data`,\n      configForUser(\"chimpy\"));\n    assert.equal(resp.status, 404);\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/prefs.ts",
    "content": "import { UserAPI, UserAPIImpl } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"prefs\", function() {\n  this.timeout(60000);\n  let home: TestServer;\n  testUtils.setTmpLogLevel(\"error\");\n  let owner: UserAPIImpl;\n  let guest: UserAPI;\n  let stranger: UserAPI;\n\n  before(async function() {\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n    const api = await home.createHomeApi(\"chimpy\", \"docs\", true);\n    await api.newOrg({ name: \"testy\", domain: \"testy\" });\n    owner = await home.createHomeApi(\"chimpy\", \"testy\", true);\n    const ws = await owner.newWorkspace({ name: \"ws\" }, \"current\");\n    await owner.updateWorkspacePermissions(ws, { users: { \"charon@getgrist.com\": \"viewers\" } });\n    guest = await home.createHomeApi(\"charon\", \"testy\", true);\n    stranger = await home.createHomeApi(\"support\", \"testy\", true, false);\n  });\n\n  after(async function() {\n    const api = await home.createHomeApi(\"chimpy\", \"docs\");\n    await api.deleteOrg(\"testy\");\n    await home.stop();\n  });\n\n  it(\"can be set as combo orgUserPrefs when owner or guest\", async function() {\n    await owner.updateOrg(\"current\", {\n      userOrgPrefs: { placeholder: \"for owner\" },\n    });\n    await guest.updateOrg(\"current\", {\n      userOrgPrefs: { placeholder: \"for guest\" },\n    });\n    await assert.isRejected(stranger.updateOrg(\"current\", {\n      userOrgPrefs: { placeholder: \"for stranger\" },\n    }), /access denied/);\n    assert.equal((await owner.getOrg(\"current\")).userOrgPrefs?.placeholder, \"for owner\");\n    assert.equal((await guest.getOrg(\"current\")).userOrgPrefs?.placeholder, \"for guest\");\n    await assert.isRejected(stranger.getOrg(\"current\"), /access denied/);\n  });\n\n  it(\"can be updated as combo orgUserPrefs when owner or guest\", async function() {\n    await owner.updateOrg(\"current\", {\n      userOrgPrefs: { placeholder: \"for owner2\" },\n    });\n    await guest.updateOrg(\"current\", {\n      userOrgPrefs: { placeholder: \"for guest2\" },\n    });\n    await assert.isRejected(stranger.updateOrg(\"current\", {\n      userOrgPrefs: { placeholder: \"for stranger2\" },\n    }), /access denied/);\n    assert.equal((await owner.getOrg(\"current\")).userOrgPrefs?.placeholder, \"for owner2\");\n    assert.equal((await guest.getOrg(\"current\")).userOrgPrefs?.placeholder, \"for guest2\");\n    await assert.isRejected(stranger.getOrg(\"current\"), /access denied/);\n  });\n\n  it(\"can be set as orgPrefs when owner\", async function() {\n    await owner.updateOrg(\"current\", {\n      orgPrefs: { placeholder: \"general\" },\n    });\n    await assert.isRejected(guest.updateOrg(\"current\", {\n      orgPrefs: { placeholder: \"general!\" },\n    }), /access denied/);\n    await assert.isRejected(stranger.updateOrg(\"current\", {\n      orgPrefs: { placeholder: \"general!\" },\n    }), /access denied/);\n    assert.equal((await owner.getOrg(\"current\")).orgPrefs?.placeholder, \"general\");\n    assert.equal((await guest.getOrg(\"current\")).orgPrefs?.placeholder, \"general\");\n    await assert.isRejected(stranger.getOrg(\"current\"), /access denied/);\n  });\n\n  it(\"can be updated as orgPrefs when owner\", async function() {\n    await owner.updateOrg(\"current\", {\n      orgPrefs: { placeholder: \"general2\" },\n    });\n    await assert.isRejected(guest.updateOrg(\"current\", {\n      orgPrefs: { placeholder: \"general2!\" },\n    }), /access denied/);\n    await assert.isRejected(stranger.updateOrg(\"current\", {\n      orgPrefs: { placeholder: \"general2!\" },\n    }), /access denied/);\n    assert.equal((await owner.getOrg(\"current\")).orgPrefs?.placeholder, \"general2\");\n    assert.equal((await guest.getOrg(\"current\")).orgPrefs?.placeholder, \"general2\");\n    await assert.isRejected(stranger.getOrg(\"current\"), /access denied/);\n  });\n\n  it(\"can set as userPrefs when owner or guest\", async function() {\n    await owner.updateOrg(\"current\", {\n      userPrefs: { placeholder: \"userPrefs for owner\" },\n    });\n    await guest.updateOrg(\"current\", {\n      userPrefs: { placeholder: \"userPrefs for guest\" },\n    });\n    await assert.isRejected(stranger.updateOrg(\"current\", {\n      userPrefs: { placeholder: \"for stranger\" },\n    }), /access denied/);\n    assert.equal((await owner.getOrg(\"current\")).userPrefs?.placeholder, \"userPrefs for owner\");\n    assert.equal((await guest.getOrg(\"current\")).userPrefs?.placeholder, \"userPrefs for guest\");\n    await assert.isRejected(stranger.getOrg(\"current\"), /access denied/);\n  });\n\n  it(\"can be accessed as userPrefs on other orgs\", async function() {\n    const owner2 = await home.createHomeApi(\"chimpy\", \"docs\", true);\n    const guest2 = await home.createHomeApi(\"charon\", \"docs\", true);\n    const stranger2 = await home.createHomeApi(\"support\", \"docs\", true);\n    assert.equal((await owner2.getOrg(\"current\")).userPrefs?.placeholder, \"userPrefs for owner\");\n    assert.equal((await owner2.getOrg(\"current\")).userOrgPrefs?.placeholder, undefined);\n    assert.equal((await owner2.getOrg(\"current\")).orgPrefs?.placeholder, undefined);\n\n    assert.equal((await guest2.getOrg(\"current\")).userPrefs?.placeholder, \"userPrefs for guest\");\n    assert.equal((await guest2.getOrg(\"current\")).userOrgPrefs?.placeholder, undefined);\n    assert.equal((await guest2.getOrg(\"current\")).orgPrefs?.placeholder, undefined);\n\n    assert.equal((await stranger2.getOrg(\"current\")).userPrefs?.placeholder, undefined);\n    assert.equal((await stranger2.getOrg(\"current\")).userOrgPrefs?.placeholder, undefined);\n    assert.equal((await stranger2.getOrg(\"current\")).orgPrefs?.placeholder, undefined);\n  });\n\n  it(\"can be accessed as prefs from active session\", async function() {\n    const owner3 = await home.createHomeApi(\"chimpy\", \"docs\", true);\n    const guest3 = await home.createHomeApi(\"charon\", \"docs\", true);\n    const stranger3 = await home.createHomeApi(\"support\", \"docs\", true);\n    assert.equal((await owner3.getSessionActive()).user.prefs?.placeholder, \"userPrefs for owner\");\n    assert.equal((await guest3.getSessionActive()).user.prefs?.placeholder, \"userPrefs for guest\");\n    assert.equal((await stranger3.getSessionActive()).user.prefs?.placeholder, undefined);\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/previewer.ts",
    "content": "import { Organization } from \"app/gen-server/entity/Organization\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { AxiosRequestConfig } from \"axios\";\nimport { assert } from \"chai\";\n\nconst previewer = configForUser(\"thumbnail\");\n\nfunction permit(permitKey: string): AxiosRequestConfig {\n  return {\n    responseType: \"json\",\n    validateStatus: (status: number) => true,\n    headers: {\n      Permit: permitKey,\n    },\n  };\n}\n\ndescribe(\"previewer\", function() {\n  let home: TestServer;\n  let dbManager: HomeDBManager;\n  let homeUrl: string;\n\n  testUtils.setTmpLogLevel(\"error\");\n\n  before(async function() {\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n    dbManager = home.dbManager;\n    homeUrl = home.serverUrl;\n    // for these tests, give the previewer an api key.\n    await dbManager.connection.query(`update users set api_key = 'api_key_for_thumbnail' where name = 'Preview'`);\n  });\n\n  after(async function() {\n    await home.stop();\n  });\n\n  it(\"has view access to all orgs\", async function() {\n    const resp = await axios.get(`${homeUrl}/api/orgs`, previewer);\n    assert.equal(resp.status, 200);\n    const orgs: any[] = resp.data;\n    assert.lengthOf(orgs, await Organization.count());\n    orgs.forEach((org: any) => assert.equal(org.access, \"viewers\"));\n  });\n\n  it(\"has view access to workspaces and docs\", async function() {\n    const oid = await dbManager.testGetId(\"NASA\");\n    const resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, previewer);\n    assert.equal(resp.status, 200);\n    const workspaces: any[] = resp.data;\n    assert.lengthOf(workspaces, 2);\n    workspaces.forEach((ws: any) => {\n      assert.equal(ws.access, \"viewers\");\n      const docs: any[] = ws.docs;\n      docs.forEach((doc: any) => assert.equal(doc.access, \"viewers\"));\n    });\n  });\n\n  it(\"cannot delete or update docs and workspaces\", async function() {\n    const oid = await dbManager.testGetId(\"NASA\");\n    let resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, previewer);\n    assert.equal(resp.status, 200);\n\n    const wsId = resp.data[0].id;\n    const docId = resp.data[0].docs[0].id;\n\n    resp = await axios.get(`${homeUrl}/api/docs/${docId}`, previewer);\n    assert.equal(resp.status, 200);\n    resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, previewer);\n    assert.equal(resp.status, 403);\n    resp = await axios.patch(`${homeUrl}/api/docs/${docId}`, { name: \"diff\" }, previewer);\n    assert.equal(resp.status, 403);\n\n    resp = await axios.get(`${homeUrl}/api/workspaces/${wsId}`, previewer);\n    assert.equal(resp.status, 200);\n    resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, previewer);\n    assert.equal(resp.status, 403);\n    resp = await axios.patch(`${homeUrl}/api/workspaces/${wsId}`, { name: \"diff\" }, previewer);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"can delete workspaces and docs using permits\", async function() {\n    const oid = await dbManager.testGetId(\"NASA\");\n    let resp = await axios.get(`${homeUrl}/api/orgs/${oid}/workspaces`, previewer);\n    assert.equal(resp.status, 200);\n\n    const wsId = resp.data[0].id;\n    const docId = resp.data[0].docs[0].id;\n\n    const store = home.getWorkStore().getPermitStore(\"internal\");\n    const goodDocPermit = await store.setPermit({ docId });\n    const badDocPermit = await store.setPermit({ docId: \"dud\" });\n    const goodWsPermit = await store.setPermit({ workspaceId: wsId });\n    const badWsPermit = await store.setPermit({ workspaceId: wsId + 1 });\n\n    // Check that external store is no good for internal use.\n    const externalStore = home.getWorkStore().getPermitStore(\"external\");\n    const externalDocPermit = await externalStore.setPermit({ docId });\n    resp = await axios.get(`${homeUrl}/api/docs/${docId}`, permit(externalDocPermit));\n    // assert.equal(resp.status, 401);\n\n    resp = await axios.get(`${homeUrl}/api/docs/${docId}`, permit(badDocPermit));\n    assert.equal(resp.status, 403);\n    resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, permit(badDocPermit));\n    assert.equal(resp.status, 403);\n    resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, permit(goodWsPermit));\n    assert.equal(resp.status, 403);\n    resp = await axios.get(`${homeUrl}/api/docs/${docId}`, permit(goodDocPermit));\n    assert.equal(resp.status, 403);\n    resp = await axios.patch(`${homeUrl}/api/docs/${docId}`, { name: \"diff\" }, permit(goodDocPermit));\n    assert.equal(resp.status, 403);\n    resp = await axios.delete(`${homeUrl}/api/docs/${docId}`, permit(goodDocPermit));\n    assert.equal(resp.status, 200);\n\n    resp = await axios.get(`${homeUrl}/api/workspaces/${wsId}`, permit(badWsPermit));\n    assert.equal(resp.status, 403);\n    resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, permit(badWsPermit));\n    assert.equal(resp.status, 403);\n    resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, permit(goodDocPermit));\n    assert.equal(resp.status, 403);\n    resp = await axios.get(`${homeUrl}/api/workspaces/${wsId}`, permit(goodWsPermit));\n    assert.equal(resp.status, 200);  // workspace read respects permit\n    resp = await axios.patch(`${homeUrl}/api/workspaces/${wsId}`, { name: \"diff\" }, permit(goodWsPermit));\n    assert.equal(resp.status, 403);\n    resp = await axios.delete(`${homeUrl}/api/workspaces/${wsId}`, permit(goodWsPermit));\n    assert.equal(resp.status, 200);  // workspace delete respects permit\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/removedAt.ts",
    "content": "import { BaseAPI } from \"app/common/BaseAPI\";\nimport { UserAPI, Workspace } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport flatten from \"lodash/flatten\";\nimport sortBy from \"lodash/sortBy\";\n\ndescribe(\"removedAt\", function() {\n  let home: TestServer;\n  testUtils.setTmpLogLevel(\"error\");\n\n  before(async function() {\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n    const api = await home.createHomeApi(\"chimpy\", \"docs\");\n    await api.newOrg({ name: \"testy\", domain: \"testy\" });\n  });\n\n  after(async function() {\n    this.timeout(100000);\n    const api = await home.createHomeApi(\"chimpy\", \"docs\");\n    await api.deleteOrg(\"testy\");\n    await home.stop();\n  });\n\n  function docNames(data: Workspace | Workspace[]) {\n    if (\"docs\" in data) {\n      return data.docs.map(doc => doc.name).sort();\n    }\n    return flatten(\n      sortBy(data, \"name\")\n        .map(ws => ws.docs.map(d => `${ws.name}:${d.name}`).sort()));\n  }\n\n  function workspaceNames(data: Workspace[]) {\n    return data.map(ws => ws.name).sort();\n  }\n\n  describe(\"scenario\", function() {\n    const org: string = \"testy\";\n    let api: UserAPI;  // regular api\n    let xapi: UserAPI; // api for soft-deleted resources\n    let bapi: BaseAPI; // api cast to allow custom requests\n    let ws1: number;\n    let ws2: number;\n    let ws3: number;\n    let ws4: number;\n    let doc11: string;\n    let doc21: string;\n    let doc12: string;\n    let doc31: string;\n\n    before(async function() {\n      api = await home.createHomeApi(\"chimpy\", org);\n      xapi = api.forRemoved();\n      bapi = api as unknown as BaseAPI;\n      // Get rid of home workspace\n      for (const ws of await api.getOrgWorkspaces(\"current\")) {\n        await api.deleteWorkspace(ws.id);\n      }\n      // Make two workspaces with two docs apiece, one workspace with one doc,\n      // and one empty workspace\n      ws1 = await api.newWorkspace({ name: \"ws1\" }, \"current\");\n      ws2 = await api.newWorkspace({ name: \"ws2\" }, \"current\");\n      ws3 = await api.newWorkspace({ name: \"ws3\" }, \"current\");\n      ws4 = await api.newWorkspace({ name: \"ws4\" }, \"current\");\n      doc11 = await api.newDoc({ name: \"doc11\" }, ws1);\n      doc12 = await api.newDoc({ name: \"doc12\" }, ws1);\n      doc21 = await api.newDoc({ name: \"doc21\" }, ws2);\n      await api.newDoc({ name: \"doc22\" }, ws2);\n      doc31 = await api.newDoc({ name: \"doc31\" }, ws3);\n    });\n\n    it(\"hides soft-deleted docs from regular api\", async function() {\n      // This can be too low for running this test directly (when database needs to be created).\n      this.timeout(10000);\n\n      // Check that doc11 is visible via regular api\n      assert.equal((await api.getDoc(doc11)).name, \"doc11\");\n      assert.typeOf((await api.getDoc(doc11)).removedAt, \"undefined\");\n      assert.deepEqual(docNames(await api.getOrgWorkspaces(\"current\")),\n        [\"ws1:doc11\", \"ws1:doc12\", \"ws2:doc21\", \"ws2:doc22\", \"ws3:doc31\"]);\n      assert.deepEqual(workspaceNames(await api.getOrgWorkspaces(\"current\")),\n        [\"ws1\", \"ws2\", \"ws3\", \"ws4\"]);\n      assert.deepEqual(docNames(await api.getWorkspace(ws1)), [\"doc11\", \"doc12\"]);\n      assert.deepEqual(docNames(await api.getWorkspace(ws2)), [\"doc21\", \"doc22\"]);\n      assert.deepEqual(docNames(await api.getWorkspace(ws3)), [\"doc31\"]);\n      assert.deepEqual(docNames(await api.getWorkspace(ws4)), []);\n\n      // Soft-delete doc11, leaving one doc in ws1\n      await api.softDeleteDoc(doc11);\n\n      // Check that doc11 is no longer visible via regular api\n      await assert.isRejected(api.getDoc(doc11), /not found/);\n      assert.deepEqual(docNames(await api.getWorkspace(ws1)), [\"doc12\"]);\n      assert.deepEqual(docNames(await api.getOrgWorkspaces(\"current\")),\n        [\"ws1:doc12\", \"ws2:doc21\", \"ws2:doc22\", \"ws3:doc31\"]);\n\n      // Check that various related endpoints are forbidden\n      let docApi = api.getDocAPI(doc11);\n      await assert.isRejected(docApi.getSnapshots(), /404.*document is deleted/i);\n      await assert.isRejected(docApi.getRows(\"Table1\"), /404.*document is deleted/i);\n      await assert.isRejected(docApi.forceReload(), /404.*document is deleted/i);\n\n      // Check that doc11 is visible via forRemoved api\n      assert.equal((await xapi.getDoc(doc11)).name, \"doc11\");\n      assert.typeOf((await xapi.getDoc(doc11)).removedAt, \"string\");\n      assert.deepEqual(docNames(await xapi.getWorkspace(ws1)), [\"doc11\"]);\n      await assert.isRejected(xapi.getWorkspace(ws2), /not found/);\n      assert.deepEqual(docNames(await xapi.getOrgWorkspaces(\"current\")),\n        [\"ws1:doc11\"]);\n      assert.deepEqual(workspaceNames(await xapi.getOrgWorkspaces(\"current\")),\n        [\"ws1\"]);\n\n      docApi = xapi.getDocAPI(doc11);\n      await assert.isFulfilled(docApi.getSnapshots());\n      await assert.isFulfilled(docApi.getRows(\"Table1\"));\n      await assert.isFulfilled(docApi.forceReload());\n    });\n\n    it(\"lists workspaces even with all docs soft-deleted\", async function() {\n      // Soft-delete doc12, leaving ws1 empty\n      await api.softDeleteDoc(doc12);\n      // Soft-delete doc31, leaving ws3 empty\n      await api.softDeleteDoc(doc31);\n\n      // Check docs are not visible, but workspaces are\n      await assert.isRejected(api.getDoc(doc12), /not found/);\n      await assert.isRejected(api.getDoc(doc31), /not found/);\n      assert.deepEqual(docNames(await api.getOrgWorkspaces(\"current\")), [\"ws2:doc21\", \"ws2:doc22\"]);\n      assert.deepEqual(workspaceNames(await api.getOrgWorkspaces(\"current\")),\n        [\"ws1\", \"ws2\", \"ws3\", \"ws4\"]);\n      assert.deepEqual(docNames(await api.getWorkspace(ws1)), []);\n      assert.deepEqual(docNames(await api.getWorkspace(ws3)), []);\n\n      // Check docs are visible via forRemoved api\n      assert.equal((await xapi.getDoc(doc12)).name, \"doc12\");\n      assert.typeOf((await xapi.getDoc(doc12)).removedAt, \"string\");\n      assert.equal((await xapi.getDoc(doc31)).name, \"doc31\");\n      assert.typeOf((await xapi.getDoc(doc31)).removedAt, \"string\");\n      assert.equal((await xapi.getWorkspace(ws1)).name, \"ws1\");\n      assert.typeOf((await xapi.getWorkspace(ws1)).removedAt, \"undefined\");\n      assert.deepEqual(docNames(await xapi.getWorkspace(ws1)), [\"doc11\", \"doc12\"]);\n      assert.deepEqual(docNames(await xapi.getWorkspace(ws3)), [\"doc31\"]);\n      await assert.isRejected(xapi.getWorkspace(ws2), /not found/);\n      assert.deepEqual(docNames(await xapi.getOrgWorkspaces(\"current\")),\n        [\"ws1:doc11\", \"ws1:doc12\", \"ws3:doc31\"]);\n      assert.deepEqual(workspaceNames(await xapi.getOrgWorkspaces(\"current\")),\n        [\"ws1\", \"ws3\"]);\n    });\n\n    it(\"can revert soft-deleted docs\", async function() {\n      // Undelete docs\n      await api.undeleteDoc(doc11);\n      await api.undeleteDoc(doc12);\n      await api.undeleteDoc(doc31);\n\n      // Check that doc11 is visible via regular api again\n      assert.equal((await api.getDoc(doc11)).name, \"doc11\");\n      assert.typeOf((await api.getDoc(doc11)).removedAt, \"undefined\");\n      assert.deepEqual(docNames(await api.getOrgWorkspaces(\"current\")),\n        [\"ws1:doc11\", \"ws1:doc12\", \"ws2:doc21\", \"ws2:doc22\", \"ws3:doc31\"]);\n      assert.deepEqual(docNames(await api.getWorkspace(ws1)), [\"doc11\", \"doc12\"]);\n\n      // Check that no \"trash\" is visible anymore\n      assert.deepEqual(docNames(await xapi.getOrgWorkspaces(\"current\")), []);\n      await assert.isRejected(xapi.getWorkspace(ws1), /not found/);\n      await assert.isRejected(xapi.getWorkspace(ws2), /not found/);\n      await assert.isRejected(xapi.getWorkspace(ws3), /not found/);\n      await assert.isRejected(xapi.getWorkspace(ws4), /not found/);\n    });\n\n    it(\"hides soft-deleted workspaces from regular api\", async function() {\n      // Soft-delete ws1, ws3, and ws4\n      await api.softDeleteWorkspace(ws1);\n      await api.softDeleteWorkspace(ws3);\n      await api.softDeleteWorkspace(ws4);\n\n      // Check that workspaces are no longer visible via regular api\n      await assert.isRejected(api.getDoc(doc11), /not found/);\n      await assert.isRejected(api.getWorkspace(ws1), /not found/);\n      assert.deepEqual(docNames(await api.getWorkspace(ws2)), [\"doc21\", \"doc22\"]);\n      await assert.isRejected(api.getDoc(doc31), /not found/);\n      await assert.isRejected(api.getWorkspace(ws3), /not found/);\n      await assert.isRejected(api.getWorkspace(ws4), /not found/);\n      assert.deepEqual(docNames(await api.getOrgWorkspaces(\"current\")),\n        [\"ws2:doc21\", \"ws2:doc22\"]);\n\n      // Check that workspaces are visible via forRemoved api\n      assert.equal((await xapi.getWorkspace(ws1)).name, \"ws1\");\n      assert.typeOf((await xapi.getWorkspace(ws1)).removedAt, \"string\");\n      assert.equal((await xapi.getDoc(doc11)).name, \"doc11\");\n      assert.equal((await xapi.getWorkspace(ws3)).name, \"ws3\");\n      assert.typeOf((await xapi.getWorkspace(ws3)).removedAt, \"string\");\n      assert.equal((await xapi.getWorkspace(ws4)).name, \"ws4\");\n      assert.typeOf((await xapi.getWorkspace(ws4)).removedAt, \"string\");\n      // we may not want the following - may want to explicitly set removedAt\n      // on docs within a soft-deleted workspace\n      assert.typeOf((await xapi.getDoc(doc11)).removedAt, \"undefined\");\n      assert.typeOf((await xapi.getDoc(doc12)).removedAt, \"undefined\");\n      assert.typeOf((await xapi.getDoc(doc31)).removedAt, \"undefined\");\n      assert.deepEqual(docNames(await xapi.getWorkspace(ws1)), [\"doc11\", \"doc12\"]);\n      await assert.isRejected(xapi.getWorkspace(ws2), /not found/);\n      assert.deepEqual(docNames(await xapi.getWorkspace(ws3)), [\"doc31\"]);\n      assert.deepEqual(docNames(await xapi.getWorkspace(ws4)), []);\n      assert.deepEqual(docNames(await xapi.getOrgWorkspaces(\"current\")),\n        [\"ws1:doc11\", \"ws1:doc12\", \"ws3:doc31\"]);\n    });\n\n    it(\"can combine soft-deleted workspaces and soft-deleted docs\", async function() {\n      // Delete a doc in an undeleted workspace, and in a soft-deleted workspace.\n      await api.softDeleteDoc(doc21);\n      await xapi.softDeleteDoc(doc11);\n      assert.deepEqual(docNames(await api.getOrgWorkspaces(\"current\")),\n        [\"ws2:doc22\"]);\n      assert.deepEqual(docNames(await xapi.getOrgWorkspaces(\"current\")),\n        [\"ws1:doc11\", \"ws1:doc12\", \"ws2:doc21\", \"ws3:doc31\"]);\n    });\n\n    it(\"can revert soft-deleted workspaces\", async function() {\n      // Undelete workspaces and docs\n      await api.undeleteWorkspace(ws1);\n      await api.undeleteWorkspace(ws3);\n      await api.undeleteWorkspace(ws4);\n      await api.undeleteDoc(doc21);\n      await api.undeleteDoc(doc11);\n\n      // Check that docs are visible via regular api again\n      assert.equal((await api.getDoc(doc11)).name, \"doc11\");\n      assert.typeOf((await api.getDoc(doc11)).removedAt, \"undefined\");\n      assert.deepEqual(docNames(await api.getOrgWorkspaces(\"current\")),\n        [\"ws1:doc11\", \"ws1:doc12\", \"ws2:doc21\", \"ws2:doc22\", \"ws3:doc31\"]);\n      assert.deepEqual(docNames(await api.getWorkspace(ws1)), [\"doc11\", \"doc12\"]);\n\n      // Check that no \"trash\" is visible anymore\n      assert.deepEqual(docNames(await xapi.getOrgWorkspaces(\"current\")), []);\n    });\n\n    // This checks that the following problem is fixed:\n    //   If a document is deleted in a workspace with many other documents, the\n    //   deletion used to take an unreasonable length of time.\n    it(\"deletes documents reasonably quickly\", async function() {\n      this.timeout(15000);\n      const ws = await api.newWorkspace({ name: \"speedTest\" }, \"testy\");\n      // Create a batch of many documents.\n      const docIds = await Promise.all(new Array(50).fill(0).map(() => api.newDoc({ name: \"doc\" }, ws)));\n      // Explicitly set users on some of the documents.\n      await api.updateDocPermissions(docIds[5], {\n        users: {\n          \"test1@getgrist.com\": \"viewers\",\n        },\n      });\n      await api.updateDocPermissions(docIds[10], {\n        users: {\n          \"test2@getgrist.com\": \"owners\",\n          \"test3@getgrist.com\": \"editors\",\n        },\n      });\n      const userRef = (email: string) => home.dbManager.getUserByLogin(email).then(user => user.ref);\n      const idTest1 = (await home.dbManager.getUserByLogin(\"test1@getgrist.com\")).id;\n      const idTest2 = (await home.dbManager.getUserByLogin(\"test2@getgrist.com\")).id;\n      const idTest3 = (await home.dbManager.getUserByLogin(\"test3@getgrist.com\")).id;\n      // Create one extra document, with one extra user.\n      const extraDocId = await api.newDoc({ name: \"doc\" }, ws);\n      await api.updateDocPermissions(extraDocId, {\n        users: { \"kiwi@getgrist.com\": \"viewers\" },\n      });\n      assert.deepEqual(await api.getWorkspaceAccess(ws), {\n        maxInheritedRole: \"owners\",\n        users: [\n          {\n            id: 1,\n            name: \"Chimpy\",\n            email: \"chimpy@getgrist.com\",\n            ref: await userRef(\"chimpy@getgrist.com\"),\n            picture: null,\n            access: \"owners\",\n            parentAccess: \"owners\",\n            isMember: true,\n          },\n          {\n            id: 2,\n            name: \"Kiwi\",\n            email: \"kiwi@getgrist.com\",\n            ref: await userRef(\"kiwi@getgrist.com\"),\n            picture: null,\n            access: \"guests\",\n            parentAccess: null,\n            isMember: false,\n          },\n          {\n            id: idTest1,\n            name: \"\",\n            email: \"test1@getgrist.com\",\n            ref: await userRef(\"test1@getgrist.com\"),\n            picture: null,\n            access: \"guests\",\n            parentAccess: null,\n            isMember: false,\n          },\n          {\n            id: idTest2,\n            name: \"\",\n            email: \"test2@getgrist.com\",\n            ref: await userRef(\"test2@getgrist.com\"),\n            picture: null,\n            access: \"guests\",\n            parentAccess: null,\n            isMember: false,\n          },\n          {\n            id: idTest3,\n            name: \"\",\n            email: \"test3@getgrist.com\",\n            ref: await userRef(\"test3@getgrist.com\"),\n            picture: null,\n            access: \"guests\",\n            parentAccess: null,\n            isMember: false,\n          },\n        ],\n      });\n      // Delete the batch of documents, retaining the one extra.\n      await Promise.all(docIds.map(docId => api.deleteDoc(docId)));\n      // Make sure the guest from the extra doc is retained, while all others evaporate.\n      assert.deepEqual(await api.getWorkspaceAccess(ws), {\n        maxInheritedRole: \"owners\",\n        users: [\n          {\n            id: 1,\n            name: \"Chimpy\",\n            email: \"chimpy@getgrist.com\",\n            ref: await userRef(\"chimpy@getgrist.com\"),\n            picture: null,\n            access: \"owners\",\n            parentAccess: \"owners\",\n            isMember: true,\n          },\n          {\n            id: 2,\n            name: \"Kiwi\",\n            email: \"kiwi@getgrist.com\",\n            ref: await userRef(\"kiwi@getgrist.com\"),\n            picture: null,\n            access: \"guests\",\n            parentAccess: null,\n            isMember: false,\n          },\n        ],\n      });\n    });\n\n    it(\"does not interfere with DocAuthKey-based caching\", async function() {\n      const info = await api.getSessionActive();\n\n      // Flush cache, then try to access doc11 as a removed doc.\n      home.dbManager.flushDocAuthCache();\n      await assert.isRejected(xapi.getDoc(doc11), /not found/);\n\n      // Check that cached authentication is correct.\n      const auth = await home.dbManager.getDocAuthCached({ urlId: doc11, userId: info.user.id, org });\n      assert.equal(auth.access, \"owners\");\n    });\n\n    it(\"respects permanent flag on /api/docs/:did/remove\", async function() {\n      await bapi.testRequest(`${api.getBaseUrl()}/api/docs/${doc11}/remove`,\n        { method: \"POST\" });\n      await bapi.testRequest(`${api.getBaseUrl()}/api/docs/${doc12}/remove?permanent=1`,\n        { method: \"POST\" });\n      await api.undeleteDoc(doc11);\n      await assert.isRejected(api.undeleteDoc(doc12), /not found/);\n    });\n\n    it(\"respects permanent flag on /api/workspaces/:wid/remove\", async function() {\n      await bapi.testRequest(`${api.getBaseUrl()}/api/workspaces/${ws1}/remove`,\n        { method: \"POST\" });\n      await bapi.testRequest(`${api.getBaseUrl()}/api/workspaces/${ws2}/remove?permanent=1`,\n        { method: \"POST\" });\n      await api.undeleteWorkspace(ws1);\n      await assert.isRejected(api.undeleteWorkspace(ws2), /not found/);\n    });\n\n    it(\"can hard-delete a soft-deleted document\", async function() {\n      const tmp1 = await api.newDoc({ name: \"tmp1\" }, ws1);\n      const tmp2 = await api.newDoc({ name: \"tmp2\" }, ws1);\n      await api.softDeleteDoc(tmp1);\n      await api.deleteDoc(tmp1);\n      await api.softDeleteDoc(tmp2);\n      await bapi.testRequest(`${api.getBaseUrl()}/api/docs/${tmp2}/remove?permanent=1`,\n        { method: \"POST\" });\n      await assert.isRejected(api.undeleteDoc(tmp1));\n      await assert.isRejected(api.undeleteDoc(tmp2));\n    });\n\n    it(\"can hard-delete a soft-deleted workspace\", async function() {\n      const tmp1 = await api.newWorkspace({ name: \"tmp1\" }, \"current\");\n      const tmp2 = await api.newWorkspace({ name: \"tmp2\" }, \"current\");\n      await api.softDeleteWorkspace(tmp1);\n      await api.deleteWorkspace(tmp1);\n      await api.softDeleteWorkspace(tmp2);\n      await bapi.testRequest(`${api.getBaseUrl()}/api/workspaces/${tmp2}/remove?permanent=1`,\n        { method: \"POST\" });\n      await assert.isRejected(api.undeleteWorkspace(tmp1));\n      await assert.isRejected(api.undeleteWorkspace(tmp2));\n    });\n\n    // This checks that the following problem is fixed:\n    //   If I shared a doc with a friend, and then soft-deleted a doc in the same workspace,\n    //   that friend used to see the workspace in their trash (empty, but there).\n    it(\"does not show workspaces for docs user does not have access to\", async function() {\n      // Make two docs in a workspace, and share one with a friend.\n      const ws = await api.newWorkspace({ name: \"wsWithSharing\" }, \"testy\");\n      const shared = await api.newDoc({ name: \"shared\" }, ws);\n      const unshared = await api.newDoc({ name: \"unshared\" }, ws);\n      await api.updateDocPermissions(shared, {\n        users: { \"charon@getgrist.com\": \"viewers\" },\n      });\n\n      // Check the friend sees nothing in their trash.\n      const charon = await home.createHomeApi(\"charon\", \"docs\");\n      let result = await charon.forRemoved().getOrgWorkspaces(\"testy\");\n      assert.lengthOf(result, 0);\n\n      // Deleted the unshared doc, and check the friend still sees nothing in their trash.\n      await api.softDeleteDoc(unshared);\n      result = await charon.forRemoved().getOrgWorkspaces(\"testy\");\n      assert.lengthOf(result, 0);\n\n      // Deleted the shared doc. The friend has no other reason to have any org access, so\n      // should lose access.\n      await api.softDeleteDoc(shared);\n      await assert.isRejected(charon.forRemoved().getOrgWorkspaces(\"testy\"), /access denied/);\n    });\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/scrubUserFromOrg.ts",
    "content": "import { Role } from \"app/common/roles\";\nimport { PermissionData } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"scrubUserFromOrg\", function() {\n  let server: TestServer;\n  testUtils.setTmpLogLevel(\"error\");\n\n  beforeEach(async function() {\n    this.timeout(5000);\n    server = new TestServer(this);\n    await server.start();\n    // Use an empty org called \"org1\" created by \"user1\" for these tests.\n    const user1 = (await server.dbManager.getUserByLogin(\"user1@getgrist.com\"));\n    await server.dbManager.addOrg(user1, { name: \"org1\", domain: \"org1\" }, {\n      setUserAsOwner: false,\n      useNewPlan: true,\n    });\n  });\n\n  afterEach(async function() {\n    await server.stop();\n  });\n\n  // count how many rows there are in the group_users table, for sanity checks.\n  async function countGroupUsers() {\n    return await server.dbManager.connection.manager.count(\"group_users\");\n  }\n\n  // get the home api, making sure the user's api key is set.\n  async function getApi(userName: string, orgName: string) {\n    const user = (await server.dbManager.getUserByLogin(`${userName}@getgrist.com`));\n    user.apiKey = `api_key_for_${userName}`;\n    await user.save();\n    return server.createHomeApi(userName, orgName, true);\n  }\n\n  // check what role is listed for the given user in the results of an ACL endpoint.\n  function getRole(access: PermissionData, email: string): string | null | undefined {\n    const row = access.users.find(u => u.email === email);\n    if (!row) { return undefined; }\n    return row.access;\n  }\n\n  // list emails of all users with the given role for the given org.\n  async function listOrg(domain: string, role: Role | null): Promise<string[]> {\n    return (await server.listOrgMembership(domain, role))\n      .map(user => user.logins[0].email);\n  }\n\n  // list emails of all users with the given role for the given workspace, via\n  // directly granted access to the workspace (inherited access not considered).\n  async function listWs(wsId: number, role: Role | null): Promise<string[]> {\n    return (await server.listWorkspaceMembership(wsId, role))\n      .map(user => user.logins[0].email);\n  }\n\n  // list all resources a user has directly been granted access to, as a list\n  // of strings, each of the form \"role:resource-name\", such as \"guests:org1\".\n  async function listUser(email: string) {\n    return (await server.listUserMemberships(email))\n      .map(membership => `${membership.role}:${membership.res.name}`).sort();\n  }\n\n  it(\"can remove users from orgs while preserving doc access\", async function() {\n    this.timeout(5000);  // takes about a second locally, so give more time to\n    // avoid occasional slow runs on jenkins.\n    // In test org \"org1\", create a test workspace \"ws1\" and a test document \"doc1\"\n    const user1 = await getApi(\"user1\", \"org1\");\n    const wsId = await user1.newWorkspace({ name: \"ws1\" }, \"current\");\n    const docId = await user1.newDoc({ name: \"doc1\" }, wsId);\n\n    // Initially the org has only 1 guest - the creator.\n    assert.sameMembers(await listOrg(\"org1\", \"guests\"), [\"user1@getgrist.com\"]);\n\n    // Add a set of users to doc1\n    await user1.updateDocPermissions(docId, {\n      maxInheritedRole: \"viewers\",\n      users: {\n        \"user2@getgrist.com\": \"owners\",\n        \"user3@getgrist.com\": \"owners\",\n        \"user4@getgrist.com\": \"editors\",\n        \"user5@getgrist.com\": \"owners\",\n      },\n    });\n\n    // Check that the org now has the expected guests.  Even user1, who has\n    // direct access to the org, will be listed as a guest as well.\n    assert.sameMembers(await listOrg(\"org1\", \"guests\"),\n      [\"user1@getgrist.com\", \"user2@getgrist.com\", \"user3@getgrist.com\",\n        \"user4@getgrist.com\", \"user5@getgrist.com\"]);\n    // Check the the workspace also has the expected guests.\n    assert.sameMembers(await listWs(wsId, \"guests\"),\n      [\"user1@getgrist.com\", \"user2@getgrist.com\", \"user3@getgrist.com\",\n        \"user4@getgrist.com\", \"user5@getgrist.com\"]);\n\n    // Get the home api from user2's perspective (so we can tweak user1's access to doc1).\n    const user2 = await getApi(\"user2\", \"org1\");\n\n    // Confirm that user3's maximal role on the org currently is as a guest.\n    let access = await user1.getOrgAccess(\"current\");\n    assert.equal(getRole(access, \"user3@getgrist.com\"), \"guests\");\n\n    // Check that user1 is an owner on the doc (this happens when the doc's permissions\n    // were updated by user1, since the user changing access must remain an owner).\n    access = await user1.getDocAccess(docId);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"owners\");\n\n    // Lower user1's access to the doc.\n    await user2.updateDocPermissions(docId, {\n      users: { \"user1@getgrist.com\": \"viewers\" },\n    });\n    access = await user2.getDocAccess(docId);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"viewers\");\n\n    // Have user1 change user3's access to the org.\n    await user1.updateOrgPermissions(\"current\", {\n      users: { \"user3@getgrist.com\": \"viewers\" },\n    });\n    access = await user2.getDocAccess(docId);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"viewers\");\n    assert.equal(getRole(access, \"user3@getgrist.com\"), \"owners\");\n\n    // Ok, that has all been preamble.  Now to test user removal.\n    // Have user1 remove user3's access to the org, checking user1+user3's access before and after.\n    let countBefore = await countGroupUsers();\n    assert.sameMembers(await listUser(\"user3@getgrist.com\"),\n      [\"viewers:org1\", \"guests:org1\", \"guests:ws1\", \"owners:Personal\", \"owners:doc1\"]);\n    assert.sameMembers(await listUser(\"user1@getgrist.com\"),\n      [\"owners:org1\", \"guests:org1\", \"owners:ws1\", \"guests:ws1\", \"owners:Personal\", \"viewers:doc1\"]);\n    await user1.updateOrgPermissions(\"current\", {\n      users: { \"user3@getgrist.com\": null },\n    });\n    let countAfter = await countGroupUsers();\n    // The only resource user3 has access to now is their personal org.\n    assert.sameMembers(await listUser(\"user3@getgrist.com\"), [\"owners:Personal\"]);\n    assert.sameMembers(await listUser(\"user1@getgrist.com\"),\n      [\"owners:org1\", \"guests:org1\", \"guests:ws1\", \"owners:ws1\", \"owners:Personal\", \"owners:doc1\"]);\n    assert.sameMembers(await listOrg(\"org1\", \"guests\"),\n      [\"user1@getgrist.com\", \"user2@getgrist.com\",\n        \"user4@getgrist.com\", \"user5@getgrist.com\"]);\n    assert.sameMembers(await listWs(wsId, \"guests\"),\n      [\"user1@getgrist.com\", \"user2@getgrist.com\",\n        \"user4@getgrist.com\", \"user5@getgrist.com\"]);\n    // For overall count of rows in group_users table, here are the changes:\n    //  - Drops: user3 as owner of doc, editor on org, guest on ws and org.\n    //  - Changes: user1 from editor to owner of doc.\n    assert.equal(countAfter, countBefore - 4);\n\n    // Check view API that user3 is removed from the doc, and Owner1 promoted to owner.\n    access = await user2.getDocAccess(docId);\n    assert.equal(getRole(access, \"user3@getgrist.com\"), undefined);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"owners\");\n\n    // Lower user1's access to the doc again.\n    await user2.updateDocPermissions(docId, {\n      users: { \"user1@getgrist.com\": \"viewers\" },\n    });\n    access = await user2.getDocAccess(docId);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"viewers\");\n\n    // Now have user1 remove user4's access to the org.\n    countBefore = await countGroupUsers();\n    await user1.updateOrgPermissions(\"current\", {\n      users: { \"user4@getgrist.com\": null },\n    });\n    countAfter = await countGroupUsers();\n\n    // Drops: user4 as editor of doc, guest on ws and org.\n    // Adds: nothing.\n    assert.equal(countAfter, countBefore - 3);\n    assert.sameMembers(await listOrg(\"org1\", \"guests\"),\n      [\"user1@getgrist.com\", \"user2@getgrist.com\", \"user5@getgrist.com\"]);\n\n    // User4 should be removed from the doc, and user1's access unchanged (since user4 was\n    // not an owner)\n    access = await user2.getDocAccess(docId);\n    assert.equal(getRole(access, \"user4@getgrist.com\"), undefined);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"viewers\");\n\n    // Now have a fresh user remove user5's access to the org.\n    await user1.updateOrgPermissions(\"current\", {\n      users: {\n        \"user6@getgrist.com\": \"owners\",\n      },\n    });\n    const user6 = await getApi(\"user6\", \"org1\");\n    countBefore = await countGroupUsers();\n    await user6.updateOrgPermissions(\"current\", {\n      users: { \"user5@getgrist.com\": null },\n    });\n    countAfter = await countGroupUsers();\n\n    // Drops: user5 as owner of doc, guest on ws and org.\n    // Adds: user6 as owner of doc, guest on ws and org.\n    assert.equal(countAfter, countBefore);\n    assert.sameMembers(await listOrg(\"org1\", \"guests\"),\n      [\"user1@getgrist.com\", \"user2@getgrist.com\", \"user6@getgrist.com\"]);\n    assert(getRole(await user1.getWorkspaceAccess(wsId), \"user6@getgrist.com\"), \"guests\");\n  });\n\n  it(\"can remove users from orgs while preserving workspace access\", async function() {\n    this.timeout(5000);  // takes about a second locally, so give more time to\n    // avoid occasional slow runs on jenkins.\n    // In test org \"org1\", create a test workspace \"ws1\"\n    const user1 = await getApi(\"user1\", \"org1\");\n    const wsId = await user1.newWorkspace({ name: \"ws1\" }, \"current\");\n\n    // Initially the org has 1 guest - the creator.\n    assert.sameMembers(await listOrg(\"org1\", \"guests\"), [\"user1@getgrist.com\"]);\n\n    // Add a set of users to ws1\n    await user1.updateWorkspacePermissions(wsId, {\n      maxInheritedRole: \"viewers\",\n      users: {\n        \"user2@getgrist.com\": \"owners\",\n        \"user3@getgrist.com\": \"owners\",\n        \"user4@getgrist.com\": \"editors\",\n        \"user5@getgrist.com\": \"owners\",\n      },\n    });\n\n    // Check that the org now has the expected guests.  Even user1, who has\n    // direct access to the org, will be listed as a guest as well.\n    assert.sameMembers(await listOrg(\"org1\", \"guests\"),\n      [\"user1@getgrist.com\", \"user2@getgrist.com\", \"user3@getgrist.com\",\n        \"user4@getgrist.com\", \"user5@getgrist.com\"]);\n    // Check the the workspace has no guests.\n    assert.sameMembers(await listWs(wsId, \"guests\"), []);\n\n    // Get the home api from user2's perspective (so we can tweak user1's access to ws1).\n    const user2 = await getApi(\"user2\", \"org1\");\n\n    // Confirm that user3's maximal role on the org currently is as a guest.\n    let access = await user1.getOrgAccess(\"current\");\n    assert.equal(getRole(access, \"user3@getgrist.com\"), \"guests\");\n\n    // Check that user1 is an owner on ws1 (this happens when the workspace's permissions\n    // were updated by user1, since the user changing access must remain an owner).\n    access = await user1.getWorkspaceAccess(wsId);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"owners\");\n\n    // Lower user1's access to the workspace.\n    await user2.updateWorkspacePermissions(wsId, {\n      users: { \"user1@getgrist.com\": \"viewers\" },\n    });\n    access = await user2.getWorkspaceAccess(wsId);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"viewers\");\n\n    // Have user1 change user3's access to the org.\n    await user1.updateOrgPermissions(\"current\", {\n      users: { \"user3@getgrist.com\": \"viewers\" },\n    });\n    access = await user2.getWorkspaceAccess(wsId);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"viewers\");\n    assert.equal(getRole(access, \"user3@getgrist.com\"), \"owners\");\n\n    // Ok, that has all been preamble.  Now to test user removal.\n    // Have user1 remove user3's access to the org, checking user1+user3's access before and after.\n    let countBefore = await countGroupUsers();\n    assert.sameMembers(await listUser(\"user3@getgrist.com\"),\n      [\"viewers:org1\", \"guests:org1\", \"owners:Personal\", \"owners:ws1\"]);\n    assert.sameMembers(await listUser(\"user1@getgrist.com\"),\n      [\"owners:org1\", \"guests:org1\", \"owners:Personal\", \"viewers:ws1\"]);\n    await user1.updateOrgPermissions(\"current\", {\n      users: { \"user3@getgrist.com\": null },\n    });\n    let countAfter = await countGroupUsers();\n    // The only resource user3 has access to now is their personal org.\n    assert.sameMembers(await listUser(\"user3@getgrist.com\"), [\"owners:Personal\"]);\n    assert.sameMembers(await listUser(\"user1@getgrist.com\"),\n      [\"owners:org1\", \"guests:org1\", \"owners:Personal\", \"owners:ws1\"]);\n    assert.sameMembers(await listOrg(\"org1\", \"guests\"),\n      [\"user1@getgrist.com\", \"user2@getgrist.com\",\n        \"user4@getgrist.com\", \"user5@getgrist.com\"]);\n    assert.sameMembers(await listWs(wsId, \"guests\"), []);\n    // For overall count of rows in group_users table, here are the changes:\n    //  - Drops: user3 as owner of ws, editor on org, guest on org.\n    //  - Changes: user1 from editor to owner of ws.\n    assert.equal(countAfter, countBefore - 3);\n\n    // Check view API that user3 is removed from the workspace, and Owner1 promoted to owner.\n    access = await user2.getWorkspaceAccess(wsId);\n    assert.equal(getRole(access, \"user3@getgrist.com\"), undefined);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"owners\");\n\n    // Lower user1's access to the workspace again.\n    await user2.updateWorkspacePermissions(wsId, {\n      users: { \"user1@getgrist.com\": \"viewers\" },\n    });\n    access = await user2.getWorkspaceAccess(wsId);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"viewers\");\n\n    // Now have user1 remove user4's access to the org.\n    countBefore = await countGroupUsers();\n    await user1.updateOrgPermissions(\"current\", {\n      users: { \"user4@getgrist.com\": null },\n    });\n    countAfter = await countGroupUsers();\n\n    // Drops: user4 as editor of ws, guest on org.\n    // Adds: nothing.\n    assert.equal(countAfter, countBefore - 2);\n    assert.sameMembers(await listOrg(\"org1\", \"guests\"),\n      [\"user1@getgrist.com\", \"user2@getgrist.com\", \"user5@getgrist.com\"]);\n\n    // User4 should be removed from the workspace, and user1's access unchanged (since user4 was\n    // not an owner)\n    access = await user2.getWorkspaceAccess(wsId);\n    assert.equal(getRole(access, \"user4@getgrist.com\"), undefined);\n    assert.equal(getRole(access, \"user1@getgrist.com\"), \"viewers\");\n\n    // Now have a fresh user remove user5's access to the org.\n    await user1.updateOrgPermissions(\"current\", {\n      users: {\n        \"user6@getgrist.com\": \"owners\",\n      },\n    });\n    const user6 = await getApi(\"user6\", \"org1\");\n    countBefore = await countGroupUsers();\n    await user6.updateOrgPermissions(\"current\", {\n      users: { \"user5@getgrist.com\": null },\n    });\n    countAfter = await countGroupUsers();\n\n    // Drops: user5 as owner of workspace, guest on org.\n    // Adds: user6 as owner of workspace, guest on org.\n    assert.equal(countAfter, countBefore);\n    assert.sameMembers(await listOrg(\"org1\", \"guests\"),\n      [\"user1@getgrist.com\", \"user2@getgrist.com\", \"user6@getgrist.com\"]);\n  });\n\n  it(\"cannot remove users from orgs without permission\", async function() {\n    // In test org \"org1\", create a test workspace \"ws1\" and a test document \"doc1\".\n    const user1 = await getApi(\"user1\", \"org1\");\n    const wsId = await user1.newWorkspace({ name: \"ws1\" }, \"current\");\n    const docId = await user1.newDoc({ name: \"doc1\" }, wsId);\n\n    // Add user2 and user3 as owners of doc1\n    await user1.updateDocPermissions(docId, {\n      users: {\n        \"user2@getgrist.com\": \"owners\",\n        \"user3@getgrist.com\": \"owners\",\n      },\n    });\n\n    // Add user2 and user3 as owners of ws1\n    await user1.updateWorkspacePermissions(wsId, {\n      users: {\n        \"user2@getgrist.com\": \"owners\",\n        \"user3@getgrist.com\": \"owners\",\n      },\n    });\n\n    // Add user2 as member of org, add user3 as editor of org\n    await user1.updateOrgPermissions(\"current\", {\n      users: {\n        \"user2@getgrist.com\": \"members\",\n        \"user3@getgrist.com\": \"editors\",\n      },\n    });\n\n    // user3 should not have the right to remove user2 from org\n    const user3 = await getApi(\"user3\", \"org1\");\n    await assert.isRejected(user3.updateOrgPermissions(\"current\", {\n      users: { \"user2@getgrist.com\": null },\n    }));\n\n    // user2 should not have the right to remove user3 from org\n    const user2 = await getApi(\"user2\", \"org1\");\n    await assert.isRejected(user2.updateOrgPermissions(\"current\", {\n      users: { \"user3@getgrist.com\": null },\n    }));\n\n    // user2 and user3 should still have same access as before\n    assert.sameMembers(await listUser(\"user2@getgrist.com\"),\n      [\"owners:Personal\", \"members:org1\", \"owners:ws1\", \"owners:doc1\",\n        \"guests:org1\", \"guests:ws1\"]);\n    assert.sameMembers(await listUser(\"user3@getgrist.com\"),\n      [\"owners:Personal\", \"editors:org1\", \"owners:ws1\", \"owners:doc1\",\n        \"guests:org1\", \"guests:ws1\"]);\n  });\n\n  it(\"does not scrub user for removal from workspace or doc\", async function() {\n    // In test org \"org1\", create a test workspace \"ws1\" and a test document \"doc1\".\n    const user1 = await getApi(\"user1\", \"org1\");\n    const wsId = await user1.newWorkspace({ name: \"ws1\" }, \"current\");\n    const docId = await user1.newDoc({ name: \"doc1\" }, wsId);\n\n    // Add user2 and user3 as owners of doc1\n    await user1.updateDocPermissions(docId, {\n      users: {\n        \"user2@getgrist.com\": \"owners\",\n        \"user3@getgrist.com\": \"owners\",\n      },\n    });\n\n    // Add user2 and user3 as owners of ws1\n    await user1.updateWorkspacePermissions(wsId, {\n      users: {\n        \"user2@getgrist.com\": \"owners\",\n        \"user3@getgrist.com\": \"owners\",\n      },\n    });\n\n    // Add user2 as member of org, add user3 as editor of org\n    await user1.updateOrgPermissions(\"current\", {\n      users: {\n        \"user2@getgrist.com\": \"members\",\n        \"user3@getgrist.com\": \"editors\",\n      },\n    });\n\n    // user3 can removed user2 from workspace\n    const user3 = await getApi(\"user3\", \"org1\");\n    await user3.updateWorkspacePermissions(wsId, {\n      users: { \"user2@getgrist.com\": null },\n    });\n\n    // user3's access should be unchanged\n    assert.sameMembers(await listUser(\"user3@getgrist.com\"),\n      [\"owners:Personal\", \"editors:org1\", \"owners:ws1\", \"owners:doc1\",\n        \"guests:org1\", \"guests:ws1\"]);\n    // user2's access should be changed just as requested\n    assert.sameMembers(await listUser(\"user2@getgrist.com\"),\n      [\"owners:Personal\", \"members:org1\", \"owners:doc1\",\n        \"guests:org1\", \"guests:ws1\"]);\n\n    // put user2 back in workspace\n    await user3.updateWorkspacePermissions(wsId, {\n      users: { \"user2@getgrist.com\": \"owners\" },\n    });\n    assert.sameMembers(await listUser(\"user2@getgrist.com\"),\n      [\"owners:Personal\", \"members:org1\", \"owners:ws1\", \"owners:doc1\",\n        \"guests:org1\", \"guests:ws1\"]);\n\n    // user3 can removed user2 from doc\n    await user3.updateDocPermissions(docId, {\n      users: { \"user2@getgrist.com\": null },\n    });\n\n    // user3's access should be unchanged\n    assert.sameMembers(await listUser(\"user3@getgrist.com\"),\n      [\"owners:Personal\", \"editors:org1\", \"owners:ws1\", \"owners:doc1\",\n        \"guests:org1\", \"guests:ws1\"]);\n    // user2's access should be changed just as requested\n    assert.sameMembers(await listUser(\"user2@getgrist.com\"),\n      [\"owners:Personal\", \"members:org1\", \"owners:ws1\", \"guests:org1\"]);\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/suspension.ts",
    "content": "import { Organization } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { setPlan } from \"test/gen-server/testUtils\";\nimport { createTmpDir } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"suspension\", function() {\n  let home: TestServer;\n  let nasa: Organization;\n  testUtils.setTmpLogLevel(\"error\");\n\n  before(async function() {\n    const tmpDir = await createTmpDir();\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"], { dataDir: tmpDir });\n    const nasaApi = await home.createHomeApi(\"Chimpy\", \"nasa\");\n    nasa = await nasaApi.getOrg(\"current\");\n  });\n\n  after(async function() {\n    await setPlan(home.dbManager, nasa, nasa.billingAccount!.product.name);\n    await home.stop();\n  });\n\n  it(\"limits user to read-only access\", async function() {\n    this.timeout(4000);\n\n    // Open nasa as chimpy (an owner)\n    const nasaApi = await home.createHomeApi(\"Chimpy\", \"nasa\");\n    // Set up Jupiter document to have some content\n    const docId = await home.dbManager.testGetId(\"Jupiter\") as string;\n    await home.copyFixtureDoc(\"Hello.grist\", docId);\n    assert((await nasaApi.getDoc(docId)).access, \"owners\");\n\n    // Confirm that user can edit docs\n    const docApi = nasaApi.getDocAPI(docId);\n    await assert.isFulfilled(docApi.getRows(\"Table1\"));\n    await assert.isFulfilled(docApi.updateRows(\"Table1\", { id: [1], A: [\"v1\"] }));\n    await assert.isFulfilled(docApi.addRows(\"Table1\", { A: [\"v1\"] }));\n\n    // Now suspend org\n    await setPlan(home.dbManager, nasa, \"suspended\");\n\n    // User should no longer be able to edit, but can view and download\n    // Note a bit of cheating here: the call to getDoc() invalidates docAuthCache; without it, it\n    // would be a few seconds before the change in access level is visible.\n    assert((await nasaApi.getDoc(docId)).access, \"viewers\");\n    await assert.isFulfilled(docApi.getRows(\"Table1\"));\n    await assert.isRejected(docApi.updateRows(\"Table1\", { id: [1], A: [\"v1\"] }), /No write access/);\n    await assert.isRejected(docApi.addRows(\"Table1\", { A: [\"v1\"] }), /No write access/);\n    const worker = await nasaApi.getWorkerAPI(docId);\n    assert(await worker.downloadDoc(docId));  // download still works\n  });\n});\n"
  },
  {
    "path": "test/gen-server/lib/urlIds.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"urlIds\", function() {\n  let home: TestServer;\n  let supportWorkspaceId: number;\n  testUtils.setTmpLogLevel(\"error\");\n\n  before(async function() {\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n    const api = await home.createHomeApi(\"chimpy\", \"nasa\");\n    await api.updateOrgPermissions(\"current\", { users: {\n      \"testuser1@getgrist.com\": \"owners\",\n      \"testuser2@getgrist.com\": \"owners\",\n    } });\n\n    // Share a workspace in support's personal org with everyone\n    const support = await home.newSession().createHomeApi(\"Support\", \"docs\");\n    await home.upgradePersonalOrg(\"Support\");\n    supportWorkspaceId = await support.newWorkspace({ name: \"Examples & Templates\" }, \"current\");\n    await support.newDoc({ name: \"an example\", urlId: \"example\" }, supportWorkspaceId);\n    await support.updateWorkspacePermissions(supportWorkspaceId, {\n      users: { \"everyone@getgrist.com\": \"viewers\",\n        \"anon@getgrist.com\": \"viewers\" },\n    });\n    // Update special workspace informationn\n    await home.dbManager.initializeSpecialIds();\n  });\n\n  after(async function() {\n    // Undo test-specific configuration\n    const api = await home.createHomeApi(\"chimpy\", \"nasa\");\n    await api.updateOrgPermissions(\"current\", { users: {\n      \"testuser1@getgrist.com\": null,\n      \"testuser2@getgrist.com\": null,\n    } });\n    const support = await home.newSession().createHomeApi(\"Support\", \"docs\");\n    await support.deleteWorkspace(supportWorkspaceId);\n    await home.dbManager.initializeSpecialIds();\n\n    await home.stop();\n  });\n\n  for (const org of [\"docs\", \"nasa\"]) {\n    it(`cannot set two docs to the same urlId in ${org}`, async function() {\n      const api1 = await home.newSession().createHomeApi(\"testuser1\", org);\n      const api2 = await home.newSession().createHomeApi(\"testuser2\", org);\n      const ws1 = await getAnyWorkspace(api1);\n      const ws2 = await getAnyWorkspace(api2);\n      const doc1 = await api1.newDoc({ name: \"testdoc1\", urlId: \"urlid-common\" }, ws1);\n      await assert.isRejected(api2.newDoc({ name: \"testdoc2\", urlId: \"urlid-common\" }, ws2),\n        /urlId already in use/);\n      assert((await api1.getDoc(\"urlid-common\")).id, doc1);\n      assert((await api1.getDoc(\"urlid-common\")).urlId, \"urlid-common\");\n      await api1.deleteDoc(doc1);\n    });\n\n    it(`can set two docs to different urlIds in ${org}`, async function() {\n      const api1 = await home.newSession().createHomeApi(\"testuser1\", org);\n      const api2 = await home.newSession().createHomeApi(\"testuser2\", org);\n      const ws1 = await getAnyWorkspace(api1);\n      const ws2 = await getAnyWorkspace(api2);\n      const doc1 = await api1.newDoc({ name: \"testdoc1\", urlId: \"urlid1\" }, ws1);\n      const doc2 = await api2.newDoc({ name: \"testdoc2\", urlId: \"urlid2\" }, ws2);\n      assert((await api1.getDoc(\"urlid1\")).id, doc1);\n      assert((await api1.getDoc(\"urlid1\")).urlId, \"urlid1\");\n      assert((await api2.getDoc(\"urlid2\")).id, doc2);\n      assert((await api2.getDoc(\"urlid2\")).urlId, \"urlid2\");\n    });\n\n    it(`cannot reuse example urlIds in ${org}`, async function() {\n      const api1 = await home.newSession().createHomeApi(\"testuser1\", org);\n      const ws1 = await getAnyWorkspace(api1);\n      await assert.isRejected(api1.newDoc({ name: \"my example\", urlId: \"example\" }, ws1),\n        /urlId already in use/);\n    });\n\n    it(`cannot use an existing docId as a urlId in ${org}`, async function() {\n      const doc = await home.dbManager.connection.manager.findOneOrFail(Document, { where: {} });\n      const prevDocId = doc.id;\n      try {\n        // Change doc id to ensure it has characters permitted for a urlId.\n        // Not all docIds are like that (test doc ids have underscores; current\n        // style doc ids typically have capital letters in them).\n        doc.id = \"doc-id\";\n        await doc.save();\n        const api1 = await home.newSession().createHomeApi(\"testuser1\", org);\n        const ws1 = await getAnyWorkspace(api1);\n        await assert.isRejected(api1.newDoc({ name: \"my example\", urlId: doc.id }, ws1),\n          /urlId already in use as document id/);\n      } finally {\n        doc.id = prevDocId;\n        await doc.save();\n      }\n    });\n\n    it(`cannot reuse urlIds from ${org} in examples`, async function() {\n      const api1 = await home.newSession().createHomeApi(\"testuser1\", org);\n      const ws1 = await getAnyWorkspace(api1);\n      await api1.newDoc({ name: \"my example\", urlId: `urlid-${org}` }, ws1);\n      const support = await home.newSession().createHomeApi(\"Support\", \"docs\");\n      await assert.isRejected(support.newDoc({ name: \"my conflicting example\",\n        urlId: `urlid-${org}` }, supportWorkspaceId),\n      /urlId already in use/);\n    });\n  }\n\n  it(`correctly uses org information for urlId disambiguation`, async function() {\n    const api1 = await home.newSession().createHomeApi(\"testuser1\", \"docs\");\n    const api2 = await home.newSession().createHomeApi(\"testuser2\", \"nasa\");\n    const ws1 = await getAnyWorkspace(api1);\n    const ws2 = await getAnyWorkspace(api2);\n    const doc1 = await api1.newDoc({ name: \"testdoc1\", urlId: \"urlid-common\" }, ws1);\n    const doc2 = await api2.newDoc({ name: \"testdoc2\", urlId: \"urlid-common\" }, ws2);\n    assert.equal((await api1.getDoc(\"urlid-common\")).id, doc1);\n    assert.equal((await api2.getDoc(\"urlid-common\")).id, doc2);\n    await api1.updateDoc(\"urlid-common\", { name: \"testdoc1-updated\" });\n    await api2.updateDoc(\"urlid-common\", { name: \"testdoc2-updated\" });\n    assert.equal((await api1.getDoc(\"urlid-common\")).name, \"testdoc1-updated\");\n    assert.equal((await api2.getDoc(\"urlid-common\")).name, \"testdoc2-updated\");\n  });\n\n  async function getAnyWorkspace(api: UserAPI) {\n    const workspaces = await api.getOrgWorkspaces(\"current\");\n    return workspaces[0].id;\n  }\n});\n"
  },
  {
    "path": "test/gen-server/migrations.ts",
    "content": "import * as roles from \"app/common/roles\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { Permissions } from \"app/gen-server/lib/Permissions\";\nimport { Initial1536634251710 as Initial } from \"app/gen-server/migration/1536634251710-Initial\";\nimport { Login1539031763952 as Login } from \"app/gen-server/migration/1539031763952-Login\";\nimport { PinDocs1549313797109 as PinDocs } from \"app/gen-server/migration/1549313797109-PinDocs\";\nimport { UserPicture1549381727494 as UserPicture } from \"app/gen-server/migration/1549381727494-UserPicture\";\nimport { LoginDisplayEmail1551805156919\nas DisplayEmail } from \"app/gen-server/migration/1551805156919-LoginDisplayEmail\";\nimport { LoginDisplayEmailNonNull1552416614755\nas DisplayEmailNonNull } from \"app/gen-server/migration/1552416614755-LoginDisplayEmailNonNull\";\nimport { Indexes1553016106336 as Indexes } from \"app/gen-server/migration/1553016106336-Indexes\";\nimport { Billing1556726945436 as Billing } from \"app/gen-server/migration/1556726945436-Billing\";\nimport { Aliases1561589211752 as Aliases } from \"app/gen-server/migration/1561589211752-Aliases\";\nimport { TeamMembers1568238234987 as TeamMembers } from \"app/gen-server/migration/1568238234987-TeamMembers\";\nimport { FirstLogin1569593726320 as FirstLogin } from \"app/gen-server/migration/1569593726320-FirstLogin\";\nimport { FirstTimeUser1569946508569 as FirstTimeUser } from \"app/gen-server/migration/1569946508569-FirstTimeUser\";\nimport { CustomerIndex1573569442552 as CustomerIndex } from \"app/gen-server/migration/1573569442552-CustomerIndex\";\nimport { ExtraIndexes1579559983067 as ExtraIndexes } from \"app/gen-server/migration/1579559983067-ExtraIndexes\";\nimport { OrgHost1591755411755 as OrgHost } from \"app/gen-server/migration/1591755411755-OrgHost\";\nimport { DocRemovedAt1592261300044 as DocRemovedAt } from \"app/gen-server/migration/1592261300044-DocRemovedAt\";\nimport { Prefs1596456522124 as Prefs } from \"app/gen-server/migration/1596456522124-Prefs\";\nimport { ExternalBilling1623871765992\nas ExternalBilling } from \"app/gen-server/migration/1623871765992-ExternalBilling\";\nimport { DocOptions1626369037484 as DocOptions } from \"app/gen-server/migration/1626369037484-DocOptions\";\nimport { Secret1631286208009 as Secret } from \"app/gen-server/migration/1631286208009-Secret\";\nimport { UserOptions1644363380225 as UserOptions } from \"app/gen-server/migration/1644363380225-UserOptions\";\nimport { GracePeriodStart1647883793388\nas GracePeriodStart } from \"app/gen-server/migration/1647883793388-GracePeriodStart\";\nimport { DocumentUsage1651469582887 as DocumentUsage } from \"app/gen-server/migration/1651469582887-DocumentUsage\";\nimport { Activations1652273656610 as Activations } from \"app/gen-server/migration/1652273656610-Activations\";\nimport { UserConnectId1652277549983 as UserConnectId } from \"app/gen-server/migration/1652277549983-UserConnectId\";\nimport { UserUUID1663851423064 as UserUUID } from \"app/gen-server/migration/1663851423064-UserUUID\";\nimport { UserRefUnique1664528376930 as UserUniqueRefUUID } from \"app/gen-server/migration/1664528376930-UserRefUnique\";\nimport { Forks1673051005072 as Forks } from \"app/gen-server/migration/1673051005072-Forks\";\nimport { ForkIndexes1678737195050 as ForkIndexes } from \"app/gen-server/migration/1678737195050-ForkIndexes\";\nimport { ActivationPrefs1682636695021\nas ActivationPrefs } from \"app/gen-server/migration/1682636695021-ActivationPrefs\";\nimport { AssistantLimit1685343047786 as AssistantLimit } from \"app/gen-server/migration/1685343047786-AssistantLimit\";\nimport { Shares1701557445716 as Shares } from \"app/gen-server/migration/1701557445716-Shares\";\nimport { Billing1711557445716 as BillingFeatures } from \"app/gen-server/migration/1711557445716-Billing\";\nimport { UserLastConnection1713186031023\nas UserLastConnection } from \"app/gen-server/migration/1713186031023-UserLastConnection\";\nimport { ActivationEnabled1722529827161\nas ActivationEnabled } from \"app/gen-server/migration/1722529827161-Activation-Enabled\";\nimport { Configs1727747249153 as Configs } from \"app/gen-server/migration/1727747249153-Configs\";\nimport { LoginsEmailsIndex1729754662550\nas LoginsEmailsIndex } from \"app/gen-server/migration/1729754662550-LoginsEmailIndex\";\nimport { GracePeriod1732103776245 as GracePeriod } from \"app/gen-server/migration/1732103776245-GracePeriod\";\nimport { UserCreatedAt1738912357827 as UserCreatedAt } from \"app/gen-server/migration/1738912357827-UserCreatedAt\";\nimport { DocPref1746246433628 as DocPref } from \"app/gen-server/migration/1746246433628-DocPref\";\nimport { GroupUsersCreatedAt1749454162428\nas GroupUsersCreatedAt } from \"app/gen-server/migration/1749454162428-GroupUsersCreatedAt\";\nimport { GroupTypes1753088213255\nas GroupTypes } from \"app/gen-server/migration/1753088213255-GroupTypes\";\nimport { UserDisabledAt1754077317821\nas UserDisabledAt } from \"app/gen-server/migration/1754077317821-UserDisabledAt\";\nimport { UserUnsubscribeKey1756799894986\nas UserUnsubscribeKey } from \"app/gen-server/migration/1756799894986-UserUnsubscribeKey\";\nimport { ServiceAccounts1756918816559\nas ServiceAccounts } from \"app/gen-server/migration/1756918816559-ServiceAccounts\";\nimport { DocDisabledAt1759434763338\nas DocDisabledAt } from \"app/gen-server/migration/1759434763338-DocDisabledAt\";\nimport { OAuthClientsAndGrants1764872085347\nas OAuthClientsAndGrants } from \"app/gen-server/migration/1764872085347-OAuthClientsAndGrants\";\nimport { withSqliteForeignKeyConstraintDisabled } from \"app/server/lib/dbUtils\";\nimport { addSeedData, createInitialDb, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport { QueryRunner } from \"typeorm\";\n\nconst home: HomeDBManager = new HomeDBManager();\n\nconst migrations = [Initial, Login, PinDocs, UserPicture, DisplayEmail, DisplayEmailNonNull,\n  Indexes, Billing, Aliases, TeamMembers, FirstLogin, FirstTimeUser,\n  CustomerIndex, ExtraIndexes, OrgHost, DocRemovedAt, Prefs,\n  ExternalBilling, DocOptions, Secret, UserOptions, GracePeriodStart,\n  DocumentUsage, Activations, UserConnectId, UserUUID, UserUniqueRefUUID,\n  Forks, ForkIndexes, ActivationPrefs, AssistantLimit, Shares, BillingFeatures,\n  UserLastConnection, ActivationEnabled, Configs, LoginsEmailsIndex, GracePeriod,\n  UserCreatedAt, DocPref, GroupUsersCreatedAt, GroupTypes, UserUnsubscribeKey,\n  UserDisabledAt, ServiceAccounts, DocDisabledAt, OAuthClientsAndGrants];\n\n// Assert that the \"members\" acl rule and group exist (or not).\nfunction assertMembersGroup(org: Organization, exists: boolean) {\n  const memberAcl = org.aclRules.find(_aclRule => _aclRule.group.name === roles.MEMBER);\n  if (!exists) {\n    assert.isUndefined(memberAcl);\n  } else {\n    assert.isDefined(memberAcl);\n    assert.equal(memberAcl!.permissions, Permissions.VIEW);\n    assert.isDefined(memberAcl!.group);\n    assert.equal(memberAcl!.group.name, roles.MEMBER);\n  }\n}\n\ndescribe(\"migrations\", function() {\n  let oldEnv: EnvironmentSnapshot;\n\n  before(function() {\n    oldEnv = new EnvironmentSnapshot();\n    // This test is incompatible with TEST_CLEAN_DATABASE.\n    delete process.env.TEST_CLEAN_DATABASE;\n    setUpDB(this);\n  });\n\n  after(function() {\n    oldEnv.restore();\n  });\n\n  beforeEach(async function() {\n    await home.connect();\n    // If testing against postgres, remove all tables.\n    // If SQLite, we're using a fresh in-memory db each time.\n    const sqlite = home.connection.driver.options.type === \"sqlite\";\n    if (!sqlite) {\n      await home.connection.query(\"DROP SCHEMA public CASCADE\");\n      await home.connection.query(\"CREATE SCHEMA public\");\n    }\n    await createInitialDb(home.connection, false);\n  });\n\n  afterEach(async function() {\n    await removeConnection();\n  });\n\n  // a test to exercise the rollback scripts a bit\n  it(\"can migrate, do full rollback, and migrate again\", async function() {\n    this.timeout(60000);\n    const runner = home.connection.createQueryRunner();\n    for (const migration of migrations) {\n      await (new migration()).up(runner);\n    }\n    for (const migration of migrations.slice().reverse()) {\n      await (new migration()).down(runner);\n    }\n    for (const migration of migrations) {\n      await (new migration()).up(runner);\n    }\n    await addSeedData(home.connection);\n    // if we made it this far without an exception, then the rollback scripts must\n    // be doing something.\n  });\n\n  it(\"can migrate UserUUID and UserUniqueRefUUID with user in table\", async function() {\n    this.timeout(60000);\n    const runner = home.connection.createQueryRunner();\n\n    // Create 400 users to test the chunk (each chunk is 300 users)\n    const nbUsersToCreate = 400;\n    for (const migration of migrations) {\n      if (migration === UserUUID) {\n        for (let i = 0; i < nbUsersToCreate; i++) {\n          await runner.query(`INSERT INTO users (id, name, is_first_time_user) VALUES (${i}, 'name${i}', true)`);\n        }\n      }\n\n      await (new migration()).up(runner);\n    }\n\n    // Check that all refs are unique\n    const userList = await runner.manager.createQueryBuilder()\n      .select([\"users.id\", \"users.ref\"])\n      .from(\"users\", \"users\")\n      .getMany();\n    const setOfUserRefs = new Set(userList.map(u => u.ref));\n    assert.equal(nbUsersToCreate, userList.length);\n    assert.equal(setOfUserRefs.size, userList.length);\n    await addSeedData(home.connection);\n  });\n\n  it(\"can correctly switch display_email column to non-null with data\", async function() {\n    this.timeout(60000);\n    return withSqliteForeignKeyConstraintDisabled(home.connection, async () => {\n      const runner = home.connection.createQueryRunner();\n      for (const migration of migrations) {\n        await (new migration()).up(runner);\n      }\n      await addSeedData(home.connection);\n      // migrate back until just before display_email column added, so we have no\n      // display_emails\n      for (const migration of migrations.slice().reverse()) {\n        await (new migration()).down(runner);\n        if (migration.name === DisplayEmail.name) { break; }\n      }\n      // now check DisplayEmail and DisplayEmailNonNull succeed with data in the db.\n      await (new DisplayEmail()).up(runner);\n      await (new DisplayEmailNonNull()).up(runner);\n    });\n  });\n\n  // a test to ensure the TeamMember migration works on databases with existing content\n  it(\"can perform TeamMember migration with seed data set\", async function() {\n    this.timeout(30000);\n    return await withSqliteForeignKeyConstraintDisabled(home.connection, async () => {\n      const runner = home.connection.createQueryRunner();\n      // Perform full up migration and add the seed data.\n      for (const migration of migrations) {\n        await (new migration()).up(runner);\n      }\n      await addSeedData(home.connection);\n      const initAclCount = await getAclRowCount(runner);\n      const initGroupCount = await getGroupRowCount(runner);\n\n      // Assert that members groups are present to start.\n      for (const org of (await getAllOrgs(runner))) { assertMembersGroup(org, true); }\n\n      // Perform down TeamMembers migration with seed data and assert members groups are removed.\n      await (new GroupTypes()).down(runner);\n      await (new TeamMembers()).down(runner);\n      const downMigratedOrgs = await getAllOrgs(runner);\n      for (const org of downMigratedOrgs) { assertMembersGroup(org, false); }\n      // Assert that the correct number of ACLs and groups were removed.\n      assert.equal(await getAclRowCount(runner), initAclCount - downMigratedOrgs.length);\n      assert.equal(await getGroupRowCount(runner), initGroupCount - downMigratedOrgs.length);\n\n      // Perform up TeamMembers migration with seed data and assert members groups are added.\n      await (new TeamMembers()).up(runner);\n      await (new GroupTypes()).up(runner);\n      for (const org of (await getAllOrgs(runner))) { assertMembersGroup(org, true); }\n      // Assert that the correct number of ACLs and groups were re-added.\n      assert.equal(await getAclRowCount(runner), initAclCount);\n      assert.equal(await getGroupRowCount(runner), initGroupCount);\n    });\n  });\n});\n\n/**\n * Returns all orgs in the database with aclRules and groups joined.\n */\nfunction getAllOrgs(queryRunner: QueryRunner): Promise<Organization[]> {\n  const orgQuery = queryRunner.manager.createQueryBuilder()\n    .select(\"orgs\")\n    .from(Organization, \"orgs\")\n    .leftJoinAndSelect(\"orgs.aclRules\", \"org_acl_rules\")\n    .leftJoinAndSelect(\"org_acl_rules.group\", \"org_groups\");\n  return orgQuery.getMany();\n}\n\nasync function getAclRowCount(queryRunner: QueryRunner): Promise<number> {\n  const rows = await queryRunner.query(`SELECT id FROM acl_rules`);\n  return rows.length;\n}\n\nasync function getGroupRowCount(queryRunner: QueryRunner): Promise<number> {\n  const rows = await queryRunner.query(`SELECT id FROM groups`);\n  return rows.length;\n}\n"
  },
  {
    "path": "test/gen-server/seed.ts",
    "content": "/**\n *\n * Can run standalone as:\n *   ts-node test/gen-server/seed.ts serve\n * By default, uses a landing.db database in current directory.\n * Can prefix with database overrides, e.g.\n *   TYPEORM_DATABASE=:memory:\n *   TYPEORM_DATABASE=/tmp/test.db\n * To connect to a postgres database, change ormconfig.env, or add a bunch of variables:\n *   export TYPEORM_CONNECTION=postgres\n *   export TYPEORM_HOST=localhost\n *   export TYPEORM_DATABASE=landing\n *   export TYPEORM_USERNAME=development\n *   export TYPEORM_PASSWORD=*****\n *\n * To just set up the database (migrate and add seed data), and then stop immediately, do:\n *   ts-node test/gen-server/seed.ts init\n * To apply all migrations to the db, do:\n *   ts-node test/gen-server/seed.ts migrate\n * To revert the last migration:\n *   ts-node test/gen-server/seed.ts revert\n *\n */\n\n/* eslint-disable @import-x/order */\n\nimport * as path from \"path\";\n\nimport { addPath } from \"app-module-path\";\nimport { Context } from \"mocha\";\n\nif (require.main === module) {\n  addPath(path.dirname(path.dirname(__dirname)));\n}\n\nimport { AclRuleDoc, AclRuleOrg, AclRuleWs } from \"app/gen-server/entity/AclRule\";\nimport { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { Document } from \"app/gen-server/entity/Document\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport { Login } from \"app/gen-server/entity/Login\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Product, PRODUCTS, synchronizeProducts, teamFeatures, teamFreeFeatures } from \"app/gen-server/entity/Product\";\nimport { User } from \"app/gen-server/entity/User\";\nimport { Workspace } from \"app/gen-server/entity/Workspace\";\nimport { EXAMPLE_WORKSPACE_NAME } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { Permissions } from \"app/gen-server/lib/Permissions\";\nimport {\n  getOrCreateConnection, runMigrations, undoLastMigration, updateDb,\n} from \"app/server/lib/dbUtils\";\nimport { FlexServer } from \"app/server/lib/FlexServer\";\nimport * as fse from \"fs-extra\";\nimport { Connection, DataSource, Repository } from \"typeorm\";\n\nconst ACCESS_GROUPS = [\"owners\", \"editors\", \"viewers\", \"guests\", \"members\"];\n\nexport const testDailyApiLimitFeatures = {\n  ...teamFreeFeatures,\n  baseMaxApiUnitsPerDocumentPerDay: 3,\n};\n\nexport const testMaxNewUserInvitesFeatures = {\n  ...teamFeatures,\n  maxNewUserInvitesPerOrg: 3,\n  maxSharesPerDoc: 5,\n};\n\nexport const testAuditLogsFeatures = {\n  ...teamFeatures,\n};\n\nconst testProducts = [\n  ...PRODUCTS,\n  {\n    name: \"testDailyApiLimit\",\n    features: testDailyApiLimitFeatures,\n  },\n  {\n    name: \"testMaxNewUserInvites\",\n    features: testMaxNewUserInvitesFeatures,\n  },\n  {\n    name: \"testAuditLogs\",\n    features: testAuditLogsFeatures,\n  },\n];\n\nexport const exampleOrgs = [\n  {\n    name: \"NASA\",\n    domain: \"nasa\",\n    workspaces: [\n      {\n        name: \"Horizon\",\n        docs: [\"Jupiter\", \"Pluto\", \"Beyond\"],\n      },\n      {\n        name: \"Rovers\",\n        docs: [\"Curiosity\", \"Apathy\"],\n      },\n    ],\n  },\n  {\n    name: \"Primately\",\n    domain: \"pr\",\n    workspaces: [\n      {\n        name: \"Fruit\",\n        docs: [\"Bananas\", \"Apples\"],\n      },\n      {\n        name: \"Trees\",\n        docs: [\"Tall\", \"Short\"],\n      },\n    ],\n  },\n  {\n    name: \"Flightless\",\n    domain: \"fly\",\n    workspaces: [\n      {\n        name: \"Media\",\n        docs: [\"Australia\", \"Antartic\"],\n      },\n    ],\n  },\n  {\n    name: \"Abyss\",\n    domain: \"deep\",\n    workspaces: [\n      {\n        name: \"Deep\",\n        docs: [\"Unfathomable\"],\n      },\n    ],\n  },\n  {\n    name: \"Charonland\",\n    workspaces: [\n      {\n        name: \"Home\",\n        docs: [],\n      },\n    ],\n    // Some tests check behavior on new free personal plans.\n    product: \"personalFree\",\n  },\n  {\n    name: \"Chimpyland\",\n    workspaces: [\n      {\n        name: \"Private\",\n        docs: [\"Timesheets\", \"Appointments\"],\n      },\n      {\n        name: \"Public\",\n        docs: [],\n      },\n    ],\n  },\n  {\n    name: \"Kiwiland\",\n    workspaces: [],\n  },\n  {\n    name: \"Hamland\",\n    workspaces: [\n      {\n        name: \"Home\",\n        docs: [],\n      },\n    ],\n    // Some tests check behavior on legacy free personal plans.\n    product: \"starter\",\n  },\n  {\n    name: \"EmptyWsOrg\",\n    domain: \"blanky\",\n    workspaces: [\n      {\n        name: \"Vacuum\",\n        docs: [],\n      },\n    ],\n  },\n  {\n    name: \"EmptyOrg\",\n    domain: \"blankiest\",\n    workspaces: [],\n  },\n  {\n    name: \"Fish\",\n    domain: \"fish\",\n    workspaces: [\n      {\n        name: \"Big\",\n        docs: [\n          \"Shark\",\n        ],\n      },\n      {\n        name: \"Small\",\n        docs: [\n          \"Anchovy\",\n          \"Herring\",\n        ],\n      },\n    ],\n  },\n  {\n    name: \"Supportland\",\n    workspaces: [\n      {\n        name: EXAMPLE_WORKSPACE_NAME,\n        docs: [\"Hello World\", \"Sample Example\"],\n      },\n    ],\n  },\n  {\n    name: \"Shiny\",\n    domain: \"shiny\",\n    host: \"www.shiny-grist.io\",\n    workspaces: [\n      {\n        name: \"Tailor Made\",\n        docs: [\"Suits\", \"Shoes\"],\n      },\n    ],\n  },\n  {\n    name: \"FreeTeam\",\n    domain: \"freeteam\",\n    product: \"teamFree\",\n    workspaces: [\n      {\n        name: \"FreeTeamWs\",\n        docs: [],\n      },\n    ],\n  },\n  {\n    name: \"TestDailyApiLimit\",\n    domain: \"testdailyapilimit\",\n    product: \"testDailyApiLimit\",\n    workspaces: [\n      {\n        name: \"TestDailyApiLimitWs\",\n        docs: [],\n      },\n    ],\n  },\n  {\n    name: \"TestMaxNewUserInvites\",\n    domain: \"testmaxnewuserinvites\",\n    product: \"testMaxNewUserInvites\",\n    workspaces: [\n      {\n        name: \"TestMaxNewUserInvitesWs\",\n        docs: [\n          \"TestMaxNewUserInvitesDoc1\",\n          \"TestMaxNewUserInvitesDoc2\",\n        ],\n      },\n    ],\n  },\n  {\n    name: \"TestAuditLogs\",\n    domain: \"testauditlogs\",\n    product: \"testAuditLogs\",\n    workspaces: [\n      {\n        name: \"TestAuditLogsWs\",\n        docs: [],\n      },\n    ],\n  },\n];\n\nconst exampleUsers: { [user: string]: { [org: string]: string } } = {\n  Chimpy: {\n    TestDailyApiLimit: \"owners\",\n    TestMaxNewUserInvites: \"owners\",\n    TestAuditLogs: \"owners\",\n    FreeTeam: \"owners\",\n    Chimpyland: \"owners\",\n    NASA: \"owners\",\n    Primately: \"guests\",\n    Fruit: \"viewers\",\n    Flightless: \"guests\",\n    Media: \"guests\",\n    Antartic: \"viewers\",\n    EmptyOrg: \"editors\",\n    EmptyWsOrg: \"editors\",\n    Fish: \"owners\",\n  },\n  Kiwi: {\n    Kiwiland: \"owners\",\n    Flightless: \"editors\",\n    Primately: \"viewers\",\n    Fish: \"editors\",\n  },\n  Charon: {\n    Charonland: \"owners\",\n    NASA: \"guests\",\n    Horizon: \"guests\",\n    Pluto: \"viewers\",\n    Chimpyland: \"viewers\",\n    Fish: \"viewers\",\n    Abyss: \"owners\",\n  },\n  // User Ham has two-factor authentication enabled on staging/prod.\n  Ham: {\n    Hamland: \"owners\",\n  },\n  // User support@ owns a workspace \"Examples & Templates\" in its personal org. It can be shared\n  // with everyone@ to let all users see it (this is not done here to avoid impacting all tests).\n  Support: { Supportland: \"owners\" },\n};\n\ninterface Groups {\n  owners: Group;\n  editors: Group;\n  viewers: Group;\n  guests: Group;\n  members?: Group;\n}\n\nclass Seed {\n  public userRepository: Repository<User>;\n  public groupRepository: Repository<Group>;\n  public groups: { [key: string]: Groups };\n\n  constructor(public connection: Connection) {\n    this.userRepository = connection.getRepository(User);\n    this.groupRepository = connection.getRepository(Group);\n    this.groups = {};\n  }\n\n  public async createGroups(parent?: Organization | Workspace): Promise<Groups> {\n    const owners = Group.create({ name: \"owners\", type: Group.ROLE_TYPE });\n    const editors = Group.create({ name: \"editors\", type: Group.ROLE_TYPE });\n    const viewers = Group.create({ name: \"viewers\", type: Group.ROLE_TYPE });\n    const guests = Group.create({ name: \"guests\", type: Group.ROLE_TYPE });\n\n    if (parent) {\n      // Nest the parent groups inside the new groups\n      const parentGroups = this.groups[parent.name];\n      owners.memberGroups = [parentGroups.owners];\n      editors.memberGroups = [parentGroups.editors];\n      viewers.memberGroups = [parentGroups.viewers];\n    }\n\n    await this.groupRepository.save([owners, editors, viewers, guests]);\n\n    if (!parent) {\n      // Add the members group for orgs.\n      const members = Group.create({ name: \"members\", type: Group.ROLE_TYPE });\n      await this.groupRepository.save(members);\n      return {\n        owners,\n        editors,\n        viewers,\n        guests,\n        members,\n      };\n    } else {\n      return {\n        owners,\n        editors,\n        viewers,\n        guests,\n      };\n    }\n  }\n\n  public async addOrgToGroups(groups: Groups, org: Organization) {\n    const acl0 = new AclRuleOrg();\n    acl0.group = groups.members!;\n    acl0.permissions = Permissions.VIEW;\n    acl0.organization = org;\n\n    const acl1 = new AclRuleOrg();\n    acl1.group = groups.guests;\n    acl1.permissions = Permissions.VIEW;\n    acl1.organization = org;\n\n    const acl2 = new AclRuleOrg();\n    acl2.group = groups.viewers;\n    acl2.permissions = Permissions.VIEW;\n    acl2.organization = org;\n\n    const acl3 = new AclRuleOrg();\n    acl3.group = groups.editors;\n    acl3.permissions = Permissions.EDITOR;\n    acl3.organization = org;\n\n    const acl4 = new AclRuleOrg();\n    acl4.group = groups.owners;\n    acl4.permissions = Permissions.OWNER;\n    acl4.organization = org;\n\n    // should be able to save both together, but typeorm messes up on postgres.\n    await acl0.save();\n    await acl1.save();\n    await acl2.save();\n    await acl3.save();\n    await acl4.save();\n  }\n\n  public async addWorkspaceToGroups(groups: Groups, ws: Workspace) {\n    const acl1 = new AclRuleWs();\n    acl1.group = groups.guests;\n    acl1.permissions = Permissions.VIEW;\n    acl1.workspace = ws;\n\n    const acl2 = new AclRuleWs();\n    acl2.group = groups.viewers;\n    acl2.permissions = Permissions.VIEW;\n    acl2.workspace = ws;\n\n    const acl3 = new AclRuleWs();\n    acl3.group = groups.editors;\n    acl3.permissions = Permissions.EDITOR;\n    acl3.workspace = ws;\n\n    const acl4 = new AclRuleWs();\n    acl4.group = groups.owners;\n    acl4.permissions = Permissions.OWNER;\n    acl4.workspace = ws;\n\n    // should be able to save both together, but typeorm messes up on postgres.\n    await acl1.save();\n    await acl2.save();\n    await acl3.save();\n    await acl4.save();\n  }\n\n  public async addDocumentToGroups(groups: Groups, doc: Document) {\n    const acl1 = new AclRuleDoc();\n    acl1.group = groups.guests;\n    acl1.permissions = Permissions.VIEW;\n    acl1.document = doc;\n\n    const acl2 = new AclRuleDoc();\n    acl2.group = groups.viewers;\n    acl2.permissions = Permissions.VIEW;\n    acl2.document = doc;\n\n    const acl3 = new AclRuleDoc();\n    acl3.group = groups.editors;\n    acl3.permissions = Permissions.EDITOR;\n    acl3.document = doc;\n\n    const acl4 = new AclRuleDoc();\n    acl4.group = groups.owners;\n    acl4.permissions = Permissions.OWNER;\n    acl4.document = doc;\n\n    await acl1.save();\n    await acl2.save();\n    await acl3.save();\n    await acl4.save();\n  }\n\n  public async addUserToGroup(user: User, group: Group) {\n    await this.connection.createQueryBuilder()\n      .relation(Group, \"memberUsers\")\n      .of(group)\n      .add(user);\n  }\n\n  public async addDocs(orgs: { name: string, domain?: string, host?: string, product?: string,\n    workspaces: { name: string, docs: string[] }[] }[]) {\n    let docId = 1;\n    for (const org of orgs) {\n      const o = new Organization();\n      o.name = org.name;\n      const ba = new BillingAccount();\n      ba.individual = false;\n      const productName = org.product || \"Free\";\n      const product = await Product.findOne({ where: { name: productName } });\n      if (!product) {\n        throw new Error(`Product not found: ${productName}`);\n      }\n      ba.product = product;\n      o.billingAccount = ba;\n      if (org.domain) { o.domain = org.domain; }\n      if (org.host) { o.host = org.host; }\n      await ba.save();\n      await o.save();\n      const grps = await this.createGroups();\n      this.groups[o.name] = grps;\n      await this.addOrgToGroups(grps, o);\n      for (const workspace of org.workspaces) {\n        const w = new Workspace();\n        w.name = workspace.name;\n        w.org = o;\n        await w.save();\n        const wgrps = await this.createGroups(o);\n        this.groups[w.name] = wgrps;\n        await this.addWorkspaceToGroups(wgrps, w);\n        for (const doc of workspace.docs) {\n          const d = new Document();\n          d.name = doc;\n          d.workspace = w;\n          d.id = `sampledocid_${docId}`;\n          docId++;\n          await d.save();\n          const dgrps = await this.createGroups(w);\n          this.groups[d.name] = dgrps;\n          await this.addDocumentToGroups(dgrps, d);\n        }\n      }\n    }\n  }\n\n  public async run() {\n    if (await this.userRepository.findOne({ where: {} })) {\n      // we already have a user - skip seeding database\n      return;\n    }\n\n    await this.addDocs(exampleOrgs);\n    await this._buildUsers(exampleUsers);\n  }\n\n  // Creates benchmark data with 10 orgs, 50 workspaces per org and 20 docs per workspace.\n  public async runBenchmark() {\n    if (await this.userRepository.findOne({ where: {} })) {\n      // we already have a user - skip seeding database\n      return;\n    }\n\n    await this.connection.runMigrations();\n\n    const benchmarkOrgs = _generateData(100, 50, 20);\n    // Create an access object giving Chimpy random access to the orgs.\n    const chimpyAccess: { [name: string]: string } = {};\n    benchmarkOrgs.forEach((_org: any) => {\n      const zeroToThree = Math.floor(Math.random() * 4);\n      chimpyAccess[_org.name] = ACCESS_GROUPS[zeroToThree];\n    });\n\n    await this.addDocs(benchmarkOrgs);\n    await this._buildUsers({ Chimpy: chimpyAccess });\n  }\n\n  private async _buildUsers(userAccessMap: { [user: string]: { [org: string]: string } }) {\n    for (const name of Object.keys(userAccessMap)) {\n      const user = new User();\n      user.name = name;\n      user.apiKey = \"api_key_for_\" + name.toLowerCase();\n      user.type = \"login\";\n      await user.save();\n      const login = new Login();\n      login.displayEmail = login.email = name.toLowerCase() + \"@getgrist.com\";\n      login.user = user;\n      await login.save();\n      const personal = await Organization.findOne({ where: { name: name + \"land\" } });\n      if (personal) {\n        personal.owner = user;\n        await personal.save();\n      }\n      for (const org of Object.keys(userAccessMap[name])) {\n        await this.addUserToGroup(user, (this.groups[org] as any)[userAccessMap[name][org]]);\n      }\n    }\n  }\n}\n\n// When running mocha on several test files at once, we need to reset our database connection\n// if it exists.  This is a little ugly since it is stored globally.\nexport async function removeConnection() {\n  const connection = await getOrCreateConnection();\n  await connection.destroy();\n}\n\nexport async function createInitialDb(connection?: DataSource, migrateAndSeedData: boolean | \"migrateOnly\" = true) {\n  // In jenkins tests, we may want to reset the database to a clean\n  // state.  If so, TEST_CLEAN_DATABASE will have been set.  How to\n  // clean the database depends on what kind of database it is.  With\n  // postgres, it suffices to recreate our schema (\"public\", the\n  // default).  With sqlite, it suffices to delete the file -- but we\n  // are only allowed to do this if there is no connection open to it\n  // (so we fail if a connection has already been made).  If the\n  // sqlite db is in memory (\":memory:\") there's nothing to delete.\n  const uncommitted = !connection;  // has user already created a connection?\n  // if so we won't be able to delete sqlite db\n  connection = connection || await getOrCreateConnection();\n  const opt = connection.driver.options;\n  if (process.env.TEST_CLEAN_DATABASE) {\n    if (opt.type === \"sqlite\") {\n      const database = (opt as any).database;\n      // Only dbs on disk need to be deleted\n      if (database !== \":memory:\") {\n        // We can only delete on-file dbs if no connection is open to them\n        if (!uncommitted) {\n          throw Error(\"too late to clean sqlite db\");\n        }\n        await removeConnection();\n        if (await fse.pathExists(database)) {\n          await fse.unlink(database);\n        }\n        connection = await getOrCreateConnection();\n      }\n    } else if (opt.type === \"postgres\") {\n      // recreate schema, destroying everything that was inside it\n      await connection.query(\"DROP SCHEMA public CASCADE;\");\n      await connection.query(\"CREATE SCHEMA public;\");\n    } else {\n      throw new Error(`do not know how to clean a ${opt.type} db`);\n    }\n  }\n\n  // Finally - actually initialize the database.\n  if (migrateAndSeedData) {\n    await updateDb(connection);\n    if (migrateAndSeedData !== \"migrateOnly\") {\n      await addSeedData(connection);\n    }\n  }\n}\n\n// add some test data to the database.\nexport async function addSeedData(connection: Connection) {\n  await synchronizeProducts(connection, true, testProducts);\n  await connection.transaction(async (tr) => {\n    const seed = new Seed(tr.connection);\n    await seed.run();\n  });\n}\n\nexport async function createBenchmarkDb(connection?: Connection) {\n  connection = connection || await getOrCreateConnection();\n  await updateDb(connection);\n  await connection.transaction(async (tr) => {\n    const seed = new Seed(tr.connection);\n    await seed.runBenchmark();\n  });\n}\n\nexport async function createServer(\n  port: number, initDb: (ds: DataSource) => Promise<void> = createInitialDb,\n): Promise<FlexServer> {\n  const flexServer = new FlexServer(port);\n  flexServer.addJsonSupport();\n  await flexServer.start();\n  await initDb(await getOrCreateConnection());\n  await flexServer.initHomeDBManager();\n  flexServer.addDocWorkerMap();\n  await flexServer.addLoginMiddleware();\n  flexServer.addHosts();\n  flexServer.addAccessMiddleware();\n  flexServer.addApiMiddleware();\n  flexServer.addWidgetRepository();\n  flexServer.addHomeApi();\n  flexServer.addScimApi();\n  await flexServer.addTelemetry();\n  flexServer.addApiErrorHandlers();\n  flexServer.summary();\n  return flexServer;\n}\n\nexport async function createBenchmarkServer(port: number): Promise<FlexServer> {\n  return createServer(port, createBenchmarkDb);\n}\n\n// Generates a random dataset of orgs, workspaces and docs. The number of workspaces\n// given is per org, and the number of docs given is per workspace.\nfunction _generateData(numOrgs: number, numWorkspaces: number, numDocs: number) {\n  if (numOrgs < 1 || numWorkspaces < 1 || numDocs < 0) {\n    throw new Error(\"_generateData error: Invalid arguments\");\n  }\n  const example = [];\n  for (let i = 0; i < numOrgs; i++) {\n    const workspaces = [];\n    for (let j = 0; j < numWorkspaces; j++) {\n      const docs = [];\n      for (let k = 0; k < numDocs; k++) {\n        const docIndex = (i * numWorkspaces * numDocs) + (j * numDocs) + k;\n        docs.push(`doc-${docIndex}`);\n      }\n      const workspaceIndex = (i * numWorkspaces) + j;\n      workspaces.push({\n        name: `ws-${workspaceIndex}`,\n        docs,\n      });\n    }\n    example.push({\n      name: `org-${i}`,\n      domain: `org-${i}`,\n      workspaces,\n    });\n  }\n  return example;\n}\n\n/**\n * To set up TYPEORM_* environment variables for testing, call this in a before() call of a test\n * suite, using setUpDB(this);\n */\nexport function setUpDB(context?: Context) {\n  if (!process.env.TYPEORM_DATABASE) {\n    process.env.TYPEORM_DATABASE = \":memory:\";\n  } else {\n    if (context) { context.timeout(60000); }\n  }\n}\n\nasync function main() {\n  const cmd = process.argv[2];\n  if (cmd === \"init\") {\n    await createInitialDb();\n    return;\n  } else if (cmd === \"benchmark\") {\n    const connection = await getOrCreateConnection();\n    await createInitialDb(connection, false);\n    await createBenchmarkDb(connection);\n    return;\n  } else if (cmd === \"migrate\") {\n    process.env.TYPEORM_LOGGING = \"true\";\n    const connection = await getOrCreateConnection();\n    await runMigrations(connection);\n    return;\n  } else if (cmd === \"revert\") {\n    process.env.TYPEORM_LOGGING = \"true\";\n    const connection = await getOrCreateConnection();\n    await undoLastMigration(connection);\n    return;\n  } else if (cmd === \"serve\") {\n    const home = await createServer(3000);\n    console.log(`Home API demo available at ${home.getOwnUrl()}`);\n    return;\n  }\n  console.log(\"Call with: init | migrate | revert | serve | benchmark\");\n}\n\nif (require.main === module) {\n  main().catch((e) => {\n    console.log(e);\n  });\n}\n"
  },
  {
    "path": "test/gen-server/testUtils.ts",
    "content": "import { GristLoadConfig } from \"app/common/gristUrls\";\nimport { BillingAccount } from \"app/gen-server/entity/BillingAccount\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Product } from \"app/gen-server/entity/Product\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { GristServer } from \"app/server/lib/GristServer\";\nimport { EmitNotifier } from \"app/server/lib/INotifier\";\n\nimport { AxiosRequestConfig } from \"axios\";\nimport { delay } from \"bluebird\";\n\nexport function configForApiKey(apiKey?: string): AxiosRequestConfig {\n  return {\n    responseType: \"json\",\n    validateStatus: (status: number) => true,\n    headers: {\n      \"X-Requested-With\": \"XMLHttpRequest\",\n      ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),\n    },\n  };\n}\n/**\n * Returns an AxiosRequestConfig, that identifies the user with `username` on a server running\n * against a database using `test/gen-server/seed.ts`. Also tells axios not to raise exception on\n * failed request.\n */\nexport function configForUser(username: string): AxiosRequestConfig {\n  const apiKey = username !== \"Anonymous\" ? `api_key_for_${username.toLowerCase()}` : undefined;\n\n  return configForApiKey(apiKey);\n}\n\n/**\n * Appends a permit key to the given config. Creates a new config object.\n */\nexport function configWithPermit(config: AxiosRequestConfig, permitKey: string): AxiosRequestConfig {\n  return {\n    ...config,\n    headers: {\n      ...config.headers,\n      Permit: permitKey,\n    },\n  };\n}\n\n/**\n * Create a new user and return their personal org.\n */\nexport async function createUser(dbManager: HomeDBManager, name: string): Promise<Organization> {\n  const username = name.toLowerCase();\n  const email = `${username}@getgrist.com`;\n  const user = await dbManager.getUserByLogin(email, { profile: { email, name } });\n  user.apiKey = `api_key_for_${username}`;\n  await user.save();\n  const userHome = (await dbManager.getOrg({ userId: user.id }, null)).data;\n  if (!userHome) { throw new Error(\"failed to create personal org\"); }\n  return userHome;\n}\n\n/**\n * Associate a given org with a given product.\n */\nexport async function setPlan(dbManager: HomeDBManager, org: { billingAccount?: { id: number } },\n  productName: string) {\n  const product = await dbManager.connection.manager.findOne(Product, { where: { name: productName } });\n  if (!product) { throw new Error(`cannot find product ${productName}`); }\n  if (!org.billingAccount) { throw new Error(\"must join billingAccount\"); }\n  await dbManager.connection.createQueryBuilder()\n    .update(BillingAccount)\n    .set({ product })\n    .where(\"id = :bid\", { bid: org.billingAccount.id })\n    .execute();\n}\n\n/**\n * Returns the window.gristConfig object extracted from the raw HTML of app.html page.\n */\nexport function getGristConfig(page: string): Partial<GristLoadConfig> {\n  const match = /window\\.gristConfig = ([^;]*)/.exec(page);\n  if (!match) { throw new Error(\"cannot find grist config\"); }\n  return JSON.parse(match[1]);\n}\n\n/**\n * Waits for all pending (back-end) notifications to complete.  Notifications are\n * started during request handling, but may not complete fully during it.\n */\nexport async function waitForAllNotifications(gristServer: GristServer, maxWait: number = 1000) {\n  const start = Date.now();\n  while (Date.now() - start < maxWait) {\n    if ((gristServer.getNotifier() as EmitNotifier).testPendingNotifications() === 0) { return; }\n    await delay(1);\n  }\n  throw new Error(\"waitForAllNotifications timed out\");\n}\n\n// count the number of rows in a table\nexport async function getRowCount(dbManager: HomeDBManager, tableName: string): Promise<number> {\n  const result = await dbManager.connection.query(`select count(*) as ct from ${tableName}`);\n  return parseInt(result[0].ct, 10);\n}\n\n// gather counts for all significant tables - handy as a sanity check on deletions\nexport async function getRowCounts(dbManager: HomeDBManager) {\n  return {\n    aclRules: await getRowCount(dbManager, \"acl_rules\"),\n    docs: await getRowCount(dbManager, \"docs\"),\n    groupGroups: await getRowCount(dbManager, \"group_groups\"),\n    groupUsers: await getRowCount(dbManager, \"group_users\"),\n    groups: await getRowCount(dbManager, \"groups\"),\n    logins: await getRowCount(dbManager, \"logins\"),\n    orgs: await getRowCount(dbManager, \"orgs\"),\n    users: await getRowCount(dbManager, \"users\"),\n    workspaces: await getRowCount(dbManager, \"workspaces\"),\n    billingAccounts: await getRowCount(dbManager, \"billing_accounts\"),\n    billingAccountManagers: await getRowCount(dbManager, \"billing_account_managers\"),\n    products: await getRowCount(dbManager, \"products\"),\n  };\n}\n"
  },
  {
    "path": "test/init-mocha-webdriver.js",
    "content": "/**\n * Settings that affect tests using mocha-webdriver. This module is imported by any run of mocha,\n * by being listed in package.json. (Keep in mind that it's imported by non-browser tests, such\n * as test/common, as well.)\n */\n\n\n// This determines when a failed assertion shows a diff with details or\n// \"expected [ Array(3) ] to deeply equal [ Array(3) ]\".\n// Increase the threshold since the default (of 40 characters) is often too low.\n// You can override it using CHAI_TRUNCATE_THRESHOLD env var; 0 disables it.\nrequire(\"chai\").config.truncateThreshold = process.env.CHAI_TRUNCATE_THRESHOLD ?\n  parseFloat(process.env.CHAI_TRUNCATE_THRESHOLD) : 4000;\n\n// Set an explicit window size (if not set by an external variable), to ensure that manully-run\n// and Jenkins-run tests, headless or not, use a consistent size. (Not that height is still not\n// identical between regular and headless browsers.)\n//\n// The size is picked to be on the small size, to ensure we test issues caused by constrained\n// space (e.g. scrolling when needed). 1024x640 is a slight increase over 900x600 we used before.\n// Note that https://www.hobo-web.co.uk/best-screen-size/ lists 1366×768 as most common desktop\n// size, so it's reasonable to assume a browser that takes up most but not all of such a screen.\nif (!process.env.MOCHA_WEBDRIVER_WINSIZE) {\n  process.env.MOCHA_WEBDRIVER_WINSIZE = \"1024x640\";\n}\n\n// Enable enhanced stacktraces by default. Disable by running with MOCHA_WEBDRIVER_STACKTRACES=\"\".\nif (process.env.MOCHA_WEBDRIVER_STACKTRACES === undefined) {\n  process.env.MOCHA_WEBDRIVER_STACKTRACES = \"1\";\n}\n\n// Default to chrome for mocha-webdriver testing. Override by setting SELENIUM_BROWSER, as usual.\nif (!process.env.SELENIUM_BROWSER) {\n  process.env.SELENIUM_BROWSER = \"chrome\";\n}\n\n// Don't fail on mismatched Chrome versions. Disable with MOCHA_WEBDRIVER_IGNORE_CHROME_VERSION=\"\".\nif (process.env.MOCHA_WEBDRIVER_IGNORE_CHROME_VERSION === undefined) {\n  process.env.MOCHA_WEBDRIVER_IGNORE_CHROME_VERSION = \"1\";\n}\n\n// don't show \"Chrome is controlled by...\" banner since at time of writing it can\n// swallow early clicks on page reload.\nif (process.env.MOCHA_WEBDRIVER_NO_CONTROL_BANNER === undefined) {\n  process.env.MOCHA_WEBDRIVER_NO_CONTROL_BANNER = \"1\";\n}\n\n// Detect whether there is an nbrowser test. If so,\n// set an environment variable that will be available\n// in individual processes if --parallel is enabled.\nfor (const arg of process.argv) {\n  if (arg.includes(\"/nbrowser/\")) {\n    process.env.MOCHA_WEBDRIVER = \"1\";\n  }\n}\n\n// If --parallel is enabled, and we are in an individual\n// worker process, set up mochaHooks. Watch out: at the\n// time of writing, there's no way to have hooks run at the\n// start and end of the worker process.\nif (process.env.MOCHA_WORKER_ID !== undefined &&\n    process.env.MOCHA_WEBDRIVER !== undefined) {\n  const {getMochaHooks} = require(\"mocha-webdriver\");\n  exports.mochaHooks = getMochaHooks();\n}\n"
  },
  {
    "path": "test/nbrowser/AccessRules1.ts",
    "content": "/**\n * Test of the UI for Granular Access Control, part 1.\n */\nimport { enterRulePart, findDefaultRuleSet, findDefaultRuleSetWait, findRuleSet,\n  findTable, startEditingAccessRules, triggerAutoComplete } from \"test/nbrowser/aclTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server } from \"test/nbrowser/testServer\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, stackWrapFunc } from \"mocha-webdriver\";\n\ndescribe(\"AccessRules1\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let docId: string;\n\n  before(async function() {\n    // Import a test document we've set up for this.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    docId = (await mainSession.tempDoc(cleanup, \"ACL-Test.grist\", { load: false })).id;\n\n    // Share it with a few users.\n    const api = mainSession.createHomeApi();\n    await api.updateDocPermissions(docId, { users: {\n      [gu.translateUser(\"user2\").email]: \"owners\",\n      [gu.translateUser(\"user3\").email]: \"editors\",\n    } });\n    return docId;\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  const getTableNamesToAddWidget = async function(): Promise<string[]> {\n    await gu.openAddWidgetToPage();\n    const options = await driver.findAll(\".test-wselect-table\", e => e.getText());\n    // Close add widget popup\n    await driver.sendKeys(Key.ESCAPE);\n\n    assert.equal(options[0], \"New Table\");\n    return options.slice(1);\n  };\n\n  const checkFullView = stackWrapFunc(async function(user: gu.TestUser) {\n    const session = await gu.session().teamSite.user(user).login();\n    await session.loadDoc(`/doc/${docId}`);\n\n    // Check that we can see and add widgets for FinancialsTable.\n    assert.deepEqual(await gu.getPageNames(), [\"ClientsTable\", \"FinancialsTable\"]);\n    assert.deepEqual(await getTableNamesToAddWidget(), [\"ClientsTable\", \"FinancialsTable\"]);\n    await gu.openPage(\"FinancialsTable\");\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"Year\", \"Income\", \"Expenses\"], rowNums: [1, 2, 3] }), [\n      \"2010\", \"$123.40\", \"$540,000.00\",\n      \"2011\", \"$1,234.50\", \"$640,000.00\",\n      \"2022\", \"$1,234,567.00\", \"$0.55\",\n    ]);\n\n    // Check that we can see RumorsColumn of ClientsTable.\n    await gu.openPage(\"ClientsTable\");\n    await gu.waitForServer();\n    assert.equal(await driver.findContent(\".column_name\", /RumorsColumn/).isPresent(), true);\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"RumorsColumn\"], rowNums: [1, 3, 9, 13] }), [\n      \"Secrets\", \"\", \"Dark rumors\", \"Buzz\",\n    ]);\n\n    // Check that we can all rows of ClientsTable\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"First Name\", \"Shared\"], rowNums: [1, 3, 9, 13] }), [\n      \"Deina\", \"false\",\n      \"Terrence\", \"true\",\n      \"Rachele\", \"true\",\n      \"Erny\", \"false\",\n    ]);\n  });\n\n  const checkLimitedView = stackWrapFunc(async function(user: gu.TestUser) {\n    const session = await gu.session().teamSite.user(user).login();\n    await session.loadDoc(`/doc/${docId}`);\n\n    assert.deepEqual(await gu.getPageNames(), [\"ClientsTable\"]);\n    assert.deepEqual(await getTableNamesToAddWidget(), [\"ClientsTable\"]);\n    await gu.openPage(\"ClientsTable\");\n    await gu.waitForServer();\n    assert.equal(await driver.findContent(\".column_name\", /RumorsColumn/).isPresent(), false);\n  });\n\n  const checkLimitedUpdateMemo = stackWrapFunc(async function(user: gu.TestUser, memo: string = \"\") {\n    const session = await gu.session().teamSite.user(user).login();\n    await session.loadDoc(`/doc/${docId}`);\n    await gu.openPage(\"ClientsTable\");\n    await gu.waitForServer();\n    await gu.getCell(0, 1).click();\n    await gu.sendKeys(\"Will it work?\", Key.ENTER);\n    await gu.waitForServer();\n    if (memo === \"\") {\n      assert.isFalse(await driver.find(\".test-notifier-toast-memo\").isPresent());\n    } else {\n      await driver.findContent(\".test-notifier-toast-memo\", memo);\n    }\n  });\n\n  it(\"should support rules for tables and columns\", async function() {\n    this.timeout(40000);\n    // Check that users 1 and 3 can see everything initially.\n    await checkFullView(\"user1\");\n    await checkFullView(\"user3\");\n\n    // Load Access Rules UI.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n\n    // Make FinancialsTable private to user1.\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /FinancialsTable/, 3000).click();\n    let ruleSet = findDefaultRuleSetWait(/FinancialsTable/);\n    await enterRulePart(ruleSet, 1, null, \"Deny all\");\n    await ruleSet.find(\".test-rule-part .test-rule-add\").click();\n    await enterRulePart(ruleSet, 1, `user.Email == '${gu.translateUser(\"user1\").email}'`, \"Allow all\");\n\n    // Make RumorsColumn of ClientsTable private to user1.\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /ClientsTable/).click();\n    await findTable(/ClientsTable/).findWait(\".test-rule-table-menu-btn\", 300).click();\n    await gu.findOpenMenuItem(\"li\", /Add column rule/).click();\n    ruleSet = findRuleSet(/ClientsTable/, 1);\n\n    await ruleSet.findWait(\".test-rule-resource .test-select-open\", 300).click();\n    assert.deepEqual(\n      await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\n        \"Agent_Email\",\n        \"Email\",\n        \"First_Name\",\n        \"Last_Name\",\n        \"Phone\",\n        \"RumorsColumn\",\n        \"Shared\",\n      ],\n    );\n    await gu.findOpenMenuItem(\"li\", \"RumorsColumn\").click();\n    await enterRulePart(ruleSet, 1, null, \"Deny all\");\n    await ruleSet.find(\".test-rule-part .test-rule-add\").click();\n    await enterRulePart(ruleSet, 1, `user.Email == '${gu.translateUser(\"user1\").email}'`, \"Allow all\");\n\n    // Make rows of ClientsTable only visible to the team if assigned to them, or marked as shared.\n    ruleSet = findDefaultRuleSet(/ClientsTable/);\n    await ruleSet.find(\".test-rule-part .test-rule-add\").click();\n    await ruleSet.find(\".test-rule-part .test-rule-add\").click();\n    await ruleSet.find(\".test-rule-part .test-rule-add\").click();\n    await enterRulePart(ruleSet, 1, `user.Email == '${gu.translateUser(\"user1\").email}'`, \"Allow all\");\n    await enterRulePart(ruleSet, 2, `rec.Shared`, { R: \"allow\" });\n    await enterRulePart(ruleSet, 3, `user.Email == rec.Agent_Email`, { R: \"allow\" });\n    await enterRulePart(ruleSet, 4, null, \"Deny all\");\n\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Check that user1 can see FinancialsTable, RumorsColumn, and all rows of ClientsTable.\n    await checkFullView(\"user1\");\n\n    // Check that user2 cannot see FinancialsTable or RumorsColumn.\n    await checkLimitedView(\"user2\");\n\n    // Check that user2 only sees certain rows of ClientsTable.\n    assert.deepEqual(await gu.getVisibleGridCells(\n      { cols: [\"First Name\", \"Agent Email\", \"Shared\"], rowNums: [1, 3, 6, 7, 8, 9, 10] },\n    ), [\n      // Everyone assigned to charon is visible.\n      \"Deina\", \"gristoid+charon@gmail.com\", \"false\",\n      \"Terrence\", \"gristoid+charon@gmail.com\", \"true\",\n      \"Wyndham\", \"gristoid+charon@gmail.com\", \"false\",\n      // And everyone with Shared = true is visible.\n      \"Rachele\", \"gristoid+chimpy@gmail.com\", \"true\",\n      \"Elianore\", \"gristoid+kiwi@gmail.com\", \"true\",\n      \"Ellsworth\", \"gristoid+kiwi@gmail.com\", \"true\",\n      \"\", \"\", \"\",\n    ]);\n\n    // Check that user3 cannot see FinancialsTable or RumorsColumn.\n    await checkLimitedView(\"user3\");\n\n    // Check that user3 only sees certain rows of ClientsTable.\n    assert.deepEqual(await gu.getVisibleGridCells(\n      { cols: [\"First Name\", \"Agent Email\", \"Shared\"], rowNums: [1, 2, 3, 4, 9, 11, 12] },\n    ), [\n      // And everyone with Shared = true is visible.\n      \"Siobhan\", \"gristoid+charon@gmail.com\", \"true\",\n      \"Terrence\", \"gristoid+charon@gmail.com\", \"true\",\n      \"Rachele\", \"gristoid+chimpy@gmail.com\", \"true\",\n      // Everyone assigned to kiwi is visible.\n      \"Kettie\", \"gristoid+kiwi@gmail.com\", \"false\",\n      \"Neely\", \"gristoid+kiwi@gmail.com\", \"false\",\n      \"Ellsworth\", \"gristoid+kiwi@gmail.com\", \"true\",\n      \"\", \"\", \"\",\n    ]);\n  });\n\n  it(\"should support rules with rec.id in formula\", async function() {\n    // This tests behavior that broke after a bug was introduced.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n    const ruleSet = findDefaultRuleSet(/FinancialsTable/);\n    await gu.scrollIntoView(ruleSet);\n    await enterRulePart(ruleSet, 1, `rec.id == 1`, \"Allow all\");\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    await gu.openPage(\"FinancialsTable\");\n    await gu.waitForServer();\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Expenses\", \"Income\", \"Year\"], rowNums: [1, 2] }),\n      [\n        \"$540,000.00\", \"$123.40\", \"2010\",\n        \"\",            \"\",        \"\",\n      ],\n    );\n    await gu.undo();\n  });\n\n  it(\"should support adding memos\", async function() {\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n\n    // Change the catch-all rule on ClientsTable to read-only permission, and give it a memo.\n    const ruleSet = findDefaultRuleSet(/ClientsTable/);\n    await enterRulePart(ruleSet, 4, null, \"Read only\", \"Sorry, this table is read-only.\");\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Check that user2 sees the memo when trying to update ClientsTable.\n    await checkLimitedUpdateMemo(\"user2\", \"Sorry, this table is read-only\");\n  });\n\n  it(\"should support removing memos\", async function() {\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n\n    // Delete the memo for the ClientsTable catch-all rule.\n    const ruleSet = findDefaultRuleSet(/ClientsTable/);\n    await ruleSet.find(\".test-rule-part-and-memo:nth-child(4) .test-rule-memo-remove\").click();\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Check that user2 no longer sees the memo.\n    await checkLimitedUpdateMemo(\"user2\");\n  });\n\n  it(\"should not produce unnecessary user actions when changing rules\", async function() {\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n\n    // Revert a rule that was modified in an earlier test.\n    let ruleSet = findDefaultRuleSet(/ClientsTable/);\n    await enterRulePart(ruleSet, 4, null, \"Deny all\");\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), false);\n\n    // Add/remove a rule; unchanged AccessRules should still show as Saved.\n    ruleSet = findDefaultRuleSet(/ClientsTable/);\n    await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-add\").click();\n    await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-remove\").click();\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), false);\n\n    // Add a rule part, remove another rule part, and save.\n    ruleSet = findDefaultRuleSet(/ClientsTable/);\n    await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-add\").click();\n    // Add a rule giving user2 (charon) access.\n    await enterRulePart(ruleSet, 1, `user.Email == '${gu.translateUser(\"user2\").email}'`, { R: \"allow\" });\n    // Remove the rule giving everyone access to Shared rows.\n    await ruleSet.find(\".test-rule-part-and-memo:nth-child(3) .test-rule-remove\").click();\n\n    // Check that the Save button is enabled, and save.\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), true);\n    await gu.userActionsCollect();\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    await gu.userActionsVerify([\n      [\"BulkRemoveRecord\", \"_grist_ACLRules\", [6]],\n      [\"BulkAddRecord\", \"_grist_ACLRules\", [-1], {\n        aclFormula: [\"user.Email == 'gristoid+charon@gmail.com'\"],\n        permissionsText: [\"+R\"],\n        resource: [5],\n        rulePos: [4.5],\n        memo: [\"\"],\n      }],\n    ]);\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), false);\n\n    // Check that the rule has an effect: user3 has limited access, and cannot see Shared rows except their own.\n    await checkLimitedView(\"user3\");\n    assert.deepEqual(await gu.getVisibleGridCells(\n      { cols: [\"First Name\", \"Agent Email\", \"Shared\"], rowNums: [1, 6, 8, 9] },\n    ), [\n      \"Kettie\", \"gristoid+kiwi@gmail.com\", \"false\",\n      \"Neely\", \"gristoid+kiwi@gmail.com\", \"false\",\n      \"Ellsworth\", \"gristoid+kiwi@gmail.com\", \"true\",\n      \"\", \"\", \"\",\n    ]);\n  });\n\n  it(\"should report errors when typed-in rule is invalid\", async function() {\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n    const ruleSet = findDefaultRuleSet(/ClientsTable/);\n    await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-add\").click();\n    await enterRulePart(ruleSet, 1, `user.Access === 'owners'`, { R: \"allow\" });\n    await gu.waitForServer();\n\n    // Check that an error is shown, and save button is disabled.\n    assert.match(await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-error\").getText(),\n      /^SyntaxError/);\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), false);\n    assert.equal(await driver.find(\".test-rules-non-save\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Invalid\");\n\n    // Check that invalid names are also detected.\n    await enterRulePart(ruleSet, 1, `fuser.Access == 'owners'`, { R: \"allow\" });\n    await gu.waitForServer();\n    assert.match(await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-error\").getText(),\n      /Unknown variable 'fuser'/);\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), false);\n    assert.equal(await driver.find(\".test-rules-non-save\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Invalid\");\n\n    // Check that invalid colIds are detected.\n    await enterRulePart(ruleSet, 1, `rec.Shared == True or rec.Sharedd == True`, { R: \"allow\" });\n    await gu.waitForServer();\n    assert.match(await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-error\").getText(),\n      /Invalid columns: Sharedd/);\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), false);\n    assert.equal(await driver.find(\".test-rules-non-save\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Invalid\");\n\n    // Check that saving is also disabled while checking rules.\n    await server.pauseUntil(async () => {\n      await enterRulePart(ruleSet, 1, `user.Access == 'owners'`, { R: \"allow\" });\n      assert.equal(await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-error\").isPresent(), false);\n      assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), false);\n      assert.equal(await driver.find(\".test-rules-non-save\").isDisplayed(), true);\n      assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Checking…\");\n    });\n\n    // Once rules are checked to be valid, the Save button is shown.\n    await gu.waitForServer();\n    assert.equal(await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-error\").isPresent(), false);\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-rules-non-save\").isDisplayed(), false);\n\n    // Revert the changes in this test case.\n    await driver.find(\".test-rules-revert\").click();\n    await gu.waitForServer();\n  });\n\n  it(\"should allow read control for columns with rec in formula\", async function() {\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n\n    // Add a column rule that uses rec but doesn't set read permission.\n    await findTable(/FinancialsTable/).find(\".test-rule-table-menu-btn\").click();\n    await gu.findOpenMenuItem(\"li\", /Add column rule/).click();\n    let ruleSet = findRuleSet(/FinancialsTable/, 1);\n    await ruleSet.findWait(\".test-rule-resource .test-select-open\", 300).click();\n    assert.deepEqual(\n      await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Expenses\", \"Income\", \"Year\"],\n    );\n    await gu.findOpenMenuItem(\"li\", \"Year\").click();\n    await enterRulePart(ruleSet, 1, 'rec.Year == \"yore\"', { U: \"deny\" });\n    await gu.waitForServer();\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Attempting to set read bit should no longer result in a notification.\n    await driver.findWait(\".test-rule-set\", 2000);\n    ruleSet = findRuleSet(/FinancialsTable/, 1);\n    await assert.isFulfilled(enterRulePart(ruleSet, 1, null, { R: \"deny\" }));\n    await gu.checkForErrors();\n\n    // Remove rule.\n    await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-remove\").click();\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n  });\n\n  it(\"should report possible order-dependencies\", async function() {\n    // Make a rule for FinancialsTable.Year and FinancialsTable.Income that denies something.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}/p/acl`, { wait: false });\n    await driver.findWait(\".test-rule-set\", 2000);\n    await findTable(/FinancialsTable/).find(\".test-rule-table-menu-btn\").click();\n    await gu.findOpenMenuItem(\"li\", /Add column rule/).click();\n    let ruleSet = findRuleSet(/FinancialsTable/, 1);\n    await ruleSet.findWait(\".test-rule-resource .test-select-open\", 300).click();\n    assert.deepEqual(\n      await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Expenses\", \"Income\", \"Year\"],\n    );\n    await gu.findOpenMenuItem(\"li\", \"Year\").click();\n    await ruleSet.findWait(\".test-rule-resource .test-select-open\", 300).click();\n    await gu.findOpenMenuItem(\"li\", \"Income\").click();\n    await enterRulePart(ruleSet, 1, 'user.Email == \"noone1\"', { R: \"deny\" });\n\n    // Make a rule for FinancialsTable.Year and FinancialsTable.Expenses that allows something.\n    await findTable(/FinancialsTable/).find(\".test-rule-table-menu-btn\").click();\n    await gu.findOpenMenuItem(\"li\", /Add column rule/).click();\n    ruleSet = findRuleSet(/FinancialsTable/, 2);\n    await ruleSet.findWait(\".test-rule-resource .test-select-open\", 300).click();\n    assert.deepEqual(\n      await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Expenses\", \"Income\", \"Year\"],\n    );\n    await gu.findOpenMenuItem(\"li\", \"Year\").click();\n    await ruleSet.findWait(\".test-rule-resource .test-select-open\", 300).click();\n    await gu.findOpenMenuItem(\"li\", \"Expenses\").click();\n    await enterRulePart(ruleSet, 1, 'user.Email == \"noone2\"', { R: \"allow\" });\n\n    // Check that trying to save throws an error.\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    await driver.findContentWait(\".test-notifier-toast-wrapper\",\n      /Column Year appears .* table FinancialsTable .* might be order-dependent/, 200);\n    await gu.wipeToasts();\n\n    // Tweak one rule to be compatible (no order dependency) with the other, and recheck.\n    await enterRulePart(ruleSet, 1, 'user.Email == \"noone2\"', { R: \"deny\" });\n    await gu.waitForServer();\n    assert.lengthOf(await gu.getToasts(), 0);\n  });\n\n  it(\"'Add Widget to Page' should be disabled\", async () => {\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}/p/acl`, { wait: false });\n    await driver.findWait(\".test-rule-set\", 2000);\n    await driver.find(\".test-dp-add-new\").doClick();\n    await gu.findOpenMenu();\n    assert.includeMembers(await driver.findAll(\".test-dp-add-new-menu > li.disabled\", imp => imp.getText()), [\n      \"Add widget to page\",\n    ]);\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.waitAppFocus();\n  });\n\n  it(\"should support dollar syntax in the editor\", async function() {\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}/p/acl`, { wait: false });\n    // Wait for ACL page to load.\n    await driver.findWait(\".test-rule-set\", 2000);\n    // Add rules for FinancialsTable.\n    const ruleSet = findRuleSet(/FinancialsTable/, 1);\n    // Use new syntax to hide Year 2010.\n\n    // First make sure we have autocomplete working.\n    await triggerAutoComplete(ruleSet, 1, \"$\");\n    await gu.waitToPass(async () => {\n      const completions = await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText());\n      assert.deepEqual(completions.map(c => c.split(\" \")[0].trim()), [\n        \"$\\nExpenses\",\n        \"$\\nid\",\n        \"$\\nIncome\",\n        \"$\\nYear\",\n      ]);\n    });\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Next test that column is understood.\n    await enterRulePart(ruleSet, 1, '1 == 1 and (rec.Year != \"2\" and $Year2 == \"2010\")', { R: \"deny\" });\n    await gu.waitForServer();\n    assert.match(await ruleSet.find(\".test-rule-part:nth-child(1) .test-rule-error\").getText(),\n      /Invalid columns: Year2/);\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), false);\n    assert.equal(await driver.find(\".test-rules-non-save\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Invalid\");\n\n    // Now apply the rule.\n    await enterRulePart(ruleSet, 1, '($Year == \"2010\" or rec.Year == \"2011\")', { R: \"deny\" });\n    await gu.waitForServer();\n    // Remove second rule that hides table for everyone.\n    await ruleSet.find(\".test-rule-part-and-memo:nth-child(2) .test-rule-remove\").click();\n\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    // Test that this rule works.\n    await gu.openPage(\"FinancialsTable\");\n    assert.deepEqual(await gu.getVisibleGridCells(\n      { cols: [\"Year\"], rowNums: [1, 2] },\n    ), [\n      \"2022\",\n      \"\",\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/AccessRules2.ts",
    "content": "/**\n * Test of the UI for Granular Access Control, part 2.\n */\nimport { enterRulePart, findDefaultRuleSet, findDefaultRuleSetWait, findRuleSet, findRuleSetWait, findTable,\n  findTableWait,\n  getRuleText,\n  startEditingAccessRules,\n} from \"test/nbrowser/aclTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport escapeRegExp from \"lodash/escapeRegExp\";\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\nasync function isChecked(el: WebElement): Promise<boolean> {\n  return await el.getAttribute(\"checked\") !== null;\n}\n\nasync function isDisabled(el: WebElement): Promise<boolean> {\n  return await el.getAttribute(\"disabled\") !== null;\n}\n\n// Just a shortcut.\nconst isShown = (el: WebElement) => el.isDisplayed();\n\ndescribe(\"AccessRules2\", function() {\n  this.timeout(40000);\n  const cleanup = setupTestSuite();\n  let docId: string;\n\n  before(async function() {\n    // Import a test document we've set up for this.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    docId = (await mainSession.tempDoc(cleanup, \"ACL-Test.grist\", { load: false })).id;\n\n    // Share it with a few users.\n    const api = mainSession.createHomeApi();\n    await api.updateDocPermissions(docId, { users: {\n      [gu.translateUser(\"user2\").email]: \"owners\",\n      [gu.translateUser(\"user3\").email]: \"editors\",\n    } });\n    return docId;\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  let viewAsUrl: string;\n\n  it(\"should list users with access to the document, plus examples\", async function() {\n    // Open AccessRules page, and click Users button.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n    await driver.findContentWait(\"button\", /View as/, 3000).click();\n    await gu.findOpenMenu();\n\n    const [email1, email2, email3] = [\"user1\", \"user2\", \"user3\"].map(u => gu.translateUser(u as any).email);\n    // All users the doc is shared with should be listed, with correct Access.\n    assert.equal(await driver.findContent(\".test-acl-user-item\", email1).isPresent(), false);\n    assert.equal(await driver.findContent(\".test-acl-user-item\", email2)\n      .find(\".test-acl-user-access\").getText(), \"(Owner)\");\n    assert.equal(await driver.findContent(\".test-acl-user-item\", email3)\n      .find(\".test-acl-user-access\").getText(), \"(Editor)\");\n\n    // Check examples are present.\n    assert.deepEqual(\n      (await driver.findAll(\".test-acl-user-item span\", e => e.getText()))\n        .filter(txt => txt.includes(\"@example\")),\n      [\"owner@example.com\", \"editor1@example.com\", \"editor2@example.com\", \"viewer@example.com\", \"unknown@example.com\"]);\n\n    // Add a user attribute table.\n    await mainSession.createHomeApi().applyUserActions(docId, [\n      [\"AddTable\", \"Zones\", [{ id: \"Email\" }, { id: \"City\" }]],\n      [\"AddRecord\", \"Zones\", null, { Email: email1, City: \"Seattle\" }],\n      [\"AddRecord\", \"Zones\", null, { Email: email2, City: \"Boston\" }],\n      [\"AddRecord\", \"Zones\", null, { Email: \"fast@speed.com\", City: \"Cambridge\" }],\n      [\"AddRecord\", \"Zones\", null, { Email: \"slow@speed.com\", City: \"Springfield\" }],\n    ]);\n    const records = await mainSession.createHomeApi().applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: 1, userAttributes: JSON.stringify({\n          name: \"Zone\",\n          tableId: \"Zones\",\n          charId: \"Email\",\n          lookupColId: \"Email\",\n        }),\n      }],\n    ]);\n\n    // Refresh list.\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n    await driver.findContentWait(\"button\", /View as/, 3000).click();\n    await gu.findOpenMenu();\n\n    // Check users from attribute table are present.\n    assert.deepEqual(\n      (await driver.findAll(\".test-acl-user-item span\", e => e.getText()))\n        .filter(txt => txt.includes(\"@speed\")),\n      [\"fast@speed.com\", \"slow@speed.com\"]);\n\n    // 'View As' is present, except for current user.\n    assert.equal(await driver.findContent(\".test-acl-user-item\", email1)\n      .isPresent(), false);\n    assert.equal(await driver.findContent(\".test-acl-user-item\", email2)\n      .isPresent(), true);\n\n    // Click \"View As\", and wait for doc to reload.\n    await driver.findContent(\".test-acl-user-item\", email3).click();\n    await gu.waitForUrl(/aclAsUser/);\n    await gu.waitForDocToLoad();\n    // Make sure we are not on ACL page.\n    viewAsUrl = await driver.getCurrentUrl();\n    assert.notMatch(viewAsUrl, /\\/p\\/acl/);\n\n    // Check for a tag in the doc header.\n    assert.equal(await driver.findWait(\".test-view-as-banner\", 2000).isPresent(), true);\n    assert.match(await driver.find(\".test-view-as-banner .test-select-open\").getText(),\n      new RegExp(gu.translateUser(\"user3\").name, \"i\"));\n    assert.equal(await driver.find(\".test-info-tooltip.test-view-as-help-tooltip\").isDisplayed(), true);\n\n    // check the aclAsUser parameter on the url persists after navigating to another page\n    await gu.getPageItem(\"FinancialsTable\").click();\n    await gu.waitForDocToLoad();\n    assert.equal(await gu.getActiveSectionTitle(), \"FINANCIALSTABLE\");\n    assert.match(await driver.getCurrentUrl(), /aclAsUser/);\n\n    // Revert.\n    await driver.find(\".test-view-as-banner .test-revert\").click();\n    await gu.waitForDocToLoad();\n\n    // Delete user attribute table.\n    await mainSession.createHomeApi().applyUserActions(docId, [\n      [\"RemoveRecord\", \"_grist_ACLRules\", records.retValues[0]],\n      [\"RemoveTable\", \"Zones\"],\n    ]);\n  });\n\n  it(\"should allow returning from view-as mode via View As banner\", async function() {\n    // Check that we can revert view-as and visit Access Rules page with the button next to the\n    // page item.\n    await driver.get(viewAsUrl);\n    await gu.waitForUrl(/aclAsUser/);\n    await gu.waitForDocToLoad();\n\n    await driver.find(\".test-view-as-banner .test-revert\").click();\n    await gu.waitForDocToLoad();\n    assert.equal(await driver.find(\".test-view-as-banner\").isPresent(), false);\n  });\n\n  it(\"should allow switching users in view-as mode\", async function() {\n    // open doc in view-as mode\n    await driver.get(viewAsUrl);\n    await gu.waitForUrl(/aclAsUser/);\n    await gu.waitForDocToLoad();\n\n    // check name\n    assert.match(await driver.find(\".test-view-as-banner .test-select-open\").getText(),\n      new RegExp(gu.translateUser(\"user3\").name, \"i\"));\n\n    // select other user\n    await driver.find(\".test-view-as-banner .test-select-open\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-acl-user-item\", gu.translateUser(\"user1\").name).click();\n\n    // check name changed\n    await gu.waitForDocToLoad();\n\n    // check name updated correctly\n    assert.match(await driver.find(\".test-view-as-banner .test-select-open\").getText(), /Chimpy/);\n  });\n\n  it(\"should make all tables/columns available to editor of ACL rules\", async function() {\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n\n    // Add table rules for ClientsTable.\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    const options = await gu.findOpenMenuAllItems(\"li\", e => e.getText());\n    assert.deepEqual(options, [\"ClientsTable\", \"ClientsTable [by Shared]\", \"FinancialsTable\"]);\n    await gu.findOpenMenuItem(\"li\", /ClientsTable/).click();\n\n    // Add rules hiding First_Name, Last_Name columns.\n    await findTableWait(/ClientsTable/).find(\".test-rule-table-menu-btn\").click();\n    await gu.findOpenMenuItem(\"li\", /Add column rule/).click();\n    let ruleSet = findRuleSetWait(/ClientsTable/, 1);\n    await ruleSet.findWait(\".test-rule-resource .test-select-open\", 300).click();\n    await gu.findOpenMenuItem(\"li\", \"First_Name\").click();\n    await ruleSet.findWait(\".test-rule-resource .test-select-open\", 300).click();\n    await gu.findOpenMenuItem(\"li\", \"Last_Name\").click();\n    await enterRulePart(ruleSet, 1, null, { R: \"deny\" });\n\n    // Add table rule entirely hiding FinancialsTable.\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /FinancialsTable/).click();\n    ruleSet = findDefaultRuleSetWait(/FinancialsTable/);\n    await enterRulePart(ruleSet, 1, null, { R: \"deny\" });\n\n    // Save and reload.\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    await driver.navigate().refresh();\n    await driver.findWait(\".test-rule-set\", 5000);\n\n    // Now this user isn't aware of the inaccessible columns and table, but ACL rules should\n    // still list them.\n\n    // Remove Last_Name column from being blocked. Check that it's now available in dropdown.\n    await driver.findWait(\".test-rule-set\", 2000);\n    ruleSet = findRuleSetWait(/ClientsTable/, 1);\n    await ruleSet.find(\".test-rule-resource\").click();\n    await ruleSet.findContent(\".test-acl-column\", \"Last_Name\").find(\".test-acl-col-remove\").click();\n    await ruleSet.find(\".test-rule-resource .test-select-open\").click();\n    assert.equal(await gu.findOpenMenuItem(\"li\", \"Last_Name\").isPresent(), true);\n    await driver.sendKeys(Key.ESCAPE);    // Close menu.\n    await gu.waitForMenuToClose();\n    await driver.sendKeys(Key.ESCAPE);    // Close editing of columns.\n    // Make sure we don't have any select visible.\n    await gu.waitToPass(async () => {\n      assert.isFalse(await ruleSet.find(\".test-rule-resource .test-select-open\").isDisplayed());\n    });\n\n    // Remove FinancialsTable from being blocked. Check that it's now available in dropdown.\n    ruleSet = findRuleSetWait(/FinancialsTable/, 1);\n    await ruleSet.find(\".test-rule-part-and-memo:nth-child(1) .test-rule-remove\").click();\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    assert.equal(await gu.findOpenMenuItem(\"li\", /FinancialsTable/).isPresent(), true);\n    assert.equal(await gu.findOpenMenuItem(\"li\", /FinancialsTable/).matches(\".disabled\"), false);\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Save\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Remove First_Name column from being blocked, and add back Last_Name column.\n    ruleSet = findRuleSetWait(/ClientsTable/, 1);\n    await ruleSet.find(\".test-rule-resource\").click();\n    await ruleSet.findContent(\".test-acl-column\", \"First_Name\").find(\".test-acl-col-remove\").click();\n    await ruleSet.find(\".test-rule-resource .test-select-open\").click();\n    await gu.findOpenMenuItem(\"li\", \"Last_Name\").click();\n\n    // Add back FinancialsTable to be blocked.\n    assert.equal(await findRuleSet(/FinancialsTable/, 1).isPresent(), false);\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /FinancialsTable/).click();\n    ruleSet = findDefaultRuleSetWait(/FinancialsTable/);\n\n    await enterRulePart(ruleSet, 1, null, { R: \"deny\" });\n\n    // Save and reload.\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    await driver.navigate().refresh();\n    await driver.findWait(\".test-rule-set\", 5000);\n\n    // Verify the results.\n    ruleSet = findRuleSetWait(/ClientsTable/, 1);\n    assert.deepEqual(await ruleSet.findAll(\".test-acl-column\", el => el.getText()), [\"Last_Name\"]);\n    assert.equal(await findDefaultRuleSetWait(/FinancialsTable/).isPresent(), true);\n\n    // Remove the installed \"Deny\" rules to restore the initial state.\n    await findRuleSet(/ClientsTable/, 1).find(\".test-rule-remove\").click();\n    await findRuleSet(/FinancialsTable/, 1).find(\".test-rule-remove\").click();\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n  });\n\n  it(\"should support user-attribute rules\", async function() {\n    // Add a table to user for a user-attribute rule. User the API for test simplicity.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    const api = mainSession.createHomeApi();\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"Access\", [{ id: \"Email\" }, { id: \"SharedOnly\", type: \"Bool\" }]],\n      [\"AddRecord\", \"Access\", null, { Email: gu.translateUser(\"user1\").email, SharedOnly: true }],\n      [\"AddRecord\", \"Access\", null, { Email: gu.translateUser(\"user2\").email, SharedOnly: true }],\n      [\"AddRecord\", \"Access\", null, { Email: gu.translateUser(\"user3\").email, SharedOnly: true }],\n    ]);\n\n    // Now use the UI to add a user-attribute rule.\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n    await driver.findContentWait(\"button\", /Add user attributes/, 2000).click();\n    const userAttrRule = await driver.find(\".test-rule-userattr\");\n    await userAttrRule.find(\".test-rule-userattr-name\").click();\n    await driver.sendKeys(\"MyAccess\", Key.ENTER);\n\n    // Type 'Email' into the attribute ace editor, which has 'user.' prefilled.\n    await userAttrRule.find(\".test-rule-userattr-attr\").click();\n    await driver.findContentWait(\".ace_autocomplete\", /Email/, 100);\n    await driver.sendKeys(\"Email\", Key.ENTER);\n    await gu.waitToPass(async () => assert.isFalse(await driver.find(\".ace_autocomplete\").isDisplayed()));\n    assert.equal(await userAttrRule.find(\".test-rule-userattr-attr\").getText(), \"user.Email\");\n\n    // Check that Table field offers a dropdown.\n    await userAttrRule.find(\".test-rule-userattr-table\").click();\n    assert.deepEqual(await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Access\", \"ClientsTable\", \"ClientsTable [by Shared]\", \"FinancialsTable\"]);\n\n    // Select a table and check that the Column field offers a dropdown.\n    await gu.findOpenMenuItem(\"li\", \"ClientsTable\").click();\n    await userAttrRule.find(\".test-rule-userattr-col\").click();\n    assert.includeMembers(await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Agent_Email\", \"Email\", \"First_Name\"]);\n\n    // Select a different table, and check that the Column field dropdown gets updated.\n    await userAttrRule.find(\".test-rule-userattr-table\").click();\n    await driver.sendKeys(\"Access\", Key.ENTER);\n    await userAttrRule.find(\".test-rule-userattr-col\").click();\n    assert.deepEqual(await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Email\", \"SharedOnly\", \"id\"]);\n    await driver.sendKeys(\"Email\", Key.ENTER);\n\n    // Remove ClientTable rules, and add a new one using the new UserAttribute.\n    if (await findTable(/ClientsTable/).isPresent()) {\n      await findTable(/ClientsTable/).find(\".test-rule-table-menu-btn\").click();\n      await gu.findOpenMenuItem(\"li\", /Delete Table Rules/).click();\n    }\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /ClientsTable/).click();\n    const ruleSet = findDefaultRuleSetWait(/ClientsTable/);\n    await ruleSet.find(\".test-rule-part .test-rule-add\").click();\n    // newRec term in the following does nothing, it is just there to test renaming later.\n    await enterRulePart(ruleSet, 1, `not user.MyAccess.SharedOnly or rec.Shared or newRec.Shared`,\n      { R: \"allow\" });\n    await enterRulePart(ruleSet, 2, null, \"Deny all\");\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitToPass(async () => {\n      await gu.openPage(\"ClientsTable\");\n      assert.equal(await gu.getGridRowCount(), 6);\n    });\n\n    // Now toggle the value in the Access table.\n    await gu.getPageItem(\"Access\").click();\n    await gu.waitToPass(async () => assert.match(await gu.getCell({ col: \"Email\", rowNum: 1 }).getText(), /chimpy/));\n    await gu.getCell({ col: \"SharedOnly\", rowNum: 1 }).find(\".widget_checkbox\").click();\n    await gu.waitToPass(async () => {\n      await gu.openPage(\"ClientsTable\");\n      assert.equal(await gu.getGridRowCount(), 20);\n    });\n  });\n\n  it('should allow adding an \"Everyone Else\" rule if one does not exist', async function() {\n    // Load the page with rules.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n\n    // ClientsTable rules, based on previous tests, includes an \"Everyone Else\" setting.\n    assert.isTrue(await findTable(/ClientsTable/).isPresent());\n    let ruleSet = findDefaultRuleSet(/ClientsTable/);\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-part\"), 2);\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-add\"), 2);\n    assert.equal(\n      await ruleSet.find(\".test-rule-part-and-memo:nth-child(2) .test-rule-acl-formula\").getText(),\n      \"Everyone Else\",\n    );\n    assert.equal(await ruleSet.find(\".test-rule-extra-add\").isPresent(), false);\n\n    // Delete it, and check that a plus button appears.\n    await ruleSet.find(\".test-rule-part-and-memo:nth-child(2) .test-rule-remove\").click();\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-part\"), 1);\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-add\"), 2);\n    assert.equal(await ruleSet.find(\".test-rule-extra-add\").isPresent(), true);\n\n    // Click \"+\" button, check that a rule appears.\n    await ruleSet.find(\".test-rule-extra-add .test-rule-add\").click();\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-part\"), 2);\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-add\"), 2);\n    assert.equal(\n      await ruleSet.find(\".test-rule-part-and-memo:nth-child(2) .test-rule-acl-formula\").getText(),\n      \"Everyone Else\",\n    );\n    assert.equal(await ruleSet.find(\".test-rule-extra-add\").isPresent(), false);\n\n    // Enter a condition into the new default rule.\n    await enterRulePart(ruleSet, 2, `True`, \"Deny all\");\n\n    // A new \"+\" button should appear.\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-part\"), 2);\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-add\"), 3);\n    assert.equal(\n      await ruleSet.find(\".test-rule-part-and-memo:nth-child(2) .test-rule-acl-formula\").getText(),\n      \"True\",\n    );\n    assert.equal(await ruleSet.find(\".test-rule-extra-add\").isPresent(), true);\n\n    // Save\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    await driver.findWait(\".test-rule-set\", 2000);\n\n    // Check that the final \"+\" still appears.\n    ruleSet = findDefaultRuleSet(/ClientsTable/);\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-part\"), 2);\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-add\"), 3);\n    assert.equal(\n      await ruleSet.find(\".test-rule-part-and-memo:nth-child(2) .test-rule-acl-formula\").getText(),\n      \"True\",\n    );\n    assert.equal(await ruleSet.find(\".test-rule-extra-add\").isPresent(), true);\n\n    await gu.undo();\n    await driver.findWait(\".test-rule-set\", 2000);\n\n    ruleSet = findDefaultRuleSet(/ClientsTable/);\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-part\"), 2);\n    assert.lengthOf(await ruleSet.findAll(\".test-rule-add\"), 2);\n    assert.equal(\n      await ruleSet.find(\".test-rule-part-and-memo:nth-child(2) .test-rule-acl-formula\").getText(),\n      \"Everyone Else\",\n    );\n    assert.equal(await ruleSet.find(\".test-rule-extra-add\").isPresent(), false);\n  });\n\n  it(\"should support renames and refresh rules when they get updated\", async function() {\n    // Prepare to use the API.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    const api = mainSession.createHomeApi();\n\n    // Load the page with rules.\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n\n    // After the previous test case, we have these rules:\n    // - UserAttribute rule \"MyAccess\" that looks up user.Email in ClientsTable.Email.\n    // - On ClientsTable, rule allowing read when \"not user.MyAccess.SharedOnly or rec.Shared ...\".\n    // - On ClientsTable, default rule to Deny All.\n\n    // Check that it's what we see.\n    assert.deepEqual(await driver.findAll(\".test-rule-userattr-attr\", getRuleText),\n      [\"user.Email\"]);\n    assert.deepEqual(await driver.findAll(\".test-rule-userattr .test-select-open\", el => el.getText()),\n      [\"Access\", \"Email\"]);\n    assert.match(await driver.find(\".test-rule-table-header\").getText(), / ClientsTable$/);\n    assert.lengthOf(await driver.findAll(\".test-rule-set\"), 2);\n    assert.deepEqual(await driver.find(\".test-rule-set\").findAll(\".test-rule-acl-formula\", getRuleText),\n      [\"not user.MyAccess.SharedOnly or rec.Shared or newRec.Shared\", \"Everyone Else\"]);\n\n    // Rename some tables (raw view sections) and columns.\n    await api.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Views_section\", 3, { title: \"CLIENT LIST\" }],\n      [\"UpdateRecord\", \"_grist_Views_section\", 10, { title: \"ACCESS2\" }],\n      [\"RenameColumn\", \"CLIENT_LIST\", \"Shared\", \"PUBLIC\"],\n      [\"RenameColumn\", \"ACCESS2\", \"Email\", \"EMAIL_ADDR\"],\n      [\"RenameColumn\", \"ACCESS2\", \"SharedOnly\", \"ONLY_SHARED\"],\n    ]);\n    await gu.waitForServer();\n\n    // Rules should auto-update.\n    assert.deepEqual(await driver.findAll(\".test-rule-userattr .test-select-open\", el => el.getText()),\n      [\"ACCESS2\", \"EMAIL_ADDR\"]);\n    assert.match(await driver.find(\".test-rule-table-header\").getText(), / CLIENT LIST/);\n    assert.lengthOf(await driver.findAll(\".test-rule-set\"), 2);\n    assert.deepEqual(await driver.find(\".test-rule-set\").findAll(\".test-rule-acl-formula\", getRuleText),\n      [\"not user.MyAccess.ONLY_SHARED or rec.PUBLIC or newRec.PUBLIC\", \"Everyone Else\"]);\n\n    // Table options should update\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    const options = await driver.findAll(\".grist-floating-menu li\", e => e.getText());\n    assert.deepEqual(options, [\"ACCESS2\", \"CLIENT LIST\", \"CLIENT LIST [by Shared]\", \"FinancialsTable\"]);\n    await driver.sendKeys(Key.ESCAPE);    // Close menu.\n\n    // Make an unsaved change to the rules, e.g. add a new one.\n    const ruleSet = findDefaultRuleSet(/CLIENT LIST/);\n    await ruleSet.find(\".test-rule-part .test-rule-add\").click();\n    await enterRulePart(ruleSet, 1, `True`, { R: \"allow\" });\n\n    // Partially undo the renames.\n    await api.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Views_section\", 3, { title: \"ClientsTable\" }],\n      [\"RenameColumn\", \"ClientsTable\", \"PUBLIC\", \"Shared\"],\n    ]);\n    await gu.waitForServer();\n\n    // Rules should NOT auto-update, but show a message instead.\n    assert.match(await driver.find(\".test-rule-table-header\").getText(), / CLIENT LIST/);\n    assert.match(await driver.find(\".test-access-rules-error\").getText(), /Access rules have changed/);\n\n    // Click \"Reset\" to update them.\n    await driver.find(\".test-rules-revert\").click();\n    await gu.waitForServer();\n\n    // Finish undoing the renames.  Fiddling with a user attribute table currently forces\n    // a reload.\n    await api.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Views_section\", 10, { title: \"Access\" }],\n      [\"RenameColumn\", \"Access\", \"EMAIL_ADDR\", \"Email\"],\n      [\"RenameColumn\", \"Access\", \"ONLY_SHARED\", \"SharedOnly\"],\n    ]);\n    await gu.waitForServer();\n\n    // Check results; it should be what we started with.\n    await gu.waitToPass(async () =>\n      assert.deepEqual(await driver.findAll(\".test-rule-userattr .test-select-open\", el => el.getText()),\n        [\"Access\", \"Email\"]));\n    assert.match(await driver.find(\".test-rule-table-header\").getText(), / ClientsTable$/);\n    assert.lengthOf(await driver.findAll(\".test-rule-set\"), 2);\n    assert.deepEqual(await driver.find(\".test-rule-set\").findAll(\".test-rule-acl-formula\", getRuleText),\n      [\"not user.MyAccess.SharedOnly or rec.Shared or newRec.Shared\", \"Everyone Else\"]);\n  });\n\n  it(\"should support special rules\", async function() {\n    // Prepare to use the API.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n\n    // Load the page with rules.\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n\n    // Check that the special checkboxes are unchecked, and advanced UI is hidden.\n    // Also the 'Restrict copying' checkbox is hidden (it plays no role without 'View Access Rules').\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isChecked), [false, false, false, false]);\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isShown), [true, true, true, false]);\n    assert.deepEqual(await driver.findAll(\".test-rule-special\", el => el.find(\".test-rule-set\").isPresent()),\n      [false, false, false, false]);\n\n    // Mark the 'Allow everyone to view Access Rules' checkbox and save.\n    await gu.scrollIntoView(driver.find(\".test-rule-special-AccessRules .test-rule-special-checkbox\")).click();\n\n    // It also causes the 'Restrict copying' permission to become visible.\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isChecked), [false, false, true, true]);\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isShown), [true, true, true, true]);\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Verify that it's checked after saving.\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isChecked), [false, false, true, true]);\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isShown), [true, true, true, true]);\n\n    // Expand advanced UI and delete the added rule there.\n    await gu.scrollIntoView(driver.find(\".test-rule-special-AccessRules .test-rule-special-expand\")).click();\n    await driver.find(\".test-rule-special-AccessRules .test-rule-part-and-memo:nth-child(1) .test-rule-remove\").click();\n\n    // The checkbox should now be unchecked.\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isChecked), [false, false, false, false]);\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isShown), [true, true, true, false]);\n\n    // Add a new non-standard rule\n    await driver.find(\".test-rule-special-AccessRules .test-rule-special-expand\").click();\n    const ruleSet = await driver.find(\".test-rule-special-AccessRules .test-rule-set\");\n    await ruleSet.find(\".test-rule-part .test-rule-add\").click();\n    await enterRulePart(ruleSet, 1, \"user.Access == EDITOR\", { R: \"allow\" });\n\n    // The checkbox should now be disabled.\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isChecked), [false, false, false, false]);\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isDisabled), [false, false, true, false]);\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isShown), [true, true, true, true]);\n\n    // Mark the checkbox for the other rule under \"Special rules for templates\" section.\n    await driver.find(\".test-special-rules-templates-expand\").click();\n    await gu.scrollIntoView(driver.find(\".test-rule-special-FullCopies .test-rule-special-checkbox\")).click();\n\n    // Save and reload the page.\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    await driver.navigate().refresh();\n    await driver.findWait(\".test-rule-set\", 5000);\n\n    // Verify the state of the checkboxes.\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isChecked),\n      [false, false, false, false, true]);\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isDisabled),\n      [false, false, true, false, false]);\n    assert.deepEqual(await driver.findAll(\".test-rule-special-checkbox\", isShown),\n      [true, true, true, true, true]);\n\n    // Check that the advanced UI is shown for only the non-standard rule.\n    assert.deepEqual(await driver.findAll(\".test-rule-special\", el => el.find(\".test-rule-set\").isPresent()),\n      [false, false, true, false, false]);\n  });\n\n  it(\"should allow opening rules when they refer to a deleted table\", async function() {\n    // After deleting a table, an invalid rule will remain. This test is NOT saying that this is\n    // the desired behavior; it only checks that in the presence of such an invalid rule, we can\n    // still open the Access Rules page.\n\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n    assert.match(await driver.find(\".test-rule-table-header\").getText(), / ClientsTable$/);\n\n    // TODO: Something seems wrong about being able to remove a table to which one has no\n    // update-record or delete-record access. On the other hand, in this situation, an undo of the\n    // delete does get blocked.\n    await gu.removePage(\"ClientsTable\", { withData: true });\n    await gu.waitForServer();\n    await gu.checkForErrors();\n    await driver.navigate().refresh();\n    await driver.findWait(\".test-rule-set\", 5000);\n    assert.match(await driver.find(\".test-rule-table-header\").getText(), / #Invalid \\(ClientsTable\\)$/);\n\n    // Remove access rule\n    await driver.findContent(\".test-rule-table-header\", / #Invalid \\(ClientsTable\\)$/)\n      .find(\".test-rule-table-menu-btn\").click();\n    await gu.findOpenMenuItem(\"li\", /Delete/).click();\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    assert.isTrue(await driver.find(\".test-rules-non-save\").isPresent());\n  });\n\n  it(\"should offer fixes for rules referring to deleted tables/columns\", async function() {\n    // Create some temporary tables.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await gu.addNewTable(\"TmpTable1\");\n    await gu.addNewTable(\"TmpTable2\");\n\n    // Add a user attribute referencing a column we will soon delete.\n    const api = mainSession.createHomeApi();\n    await api.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: 1, userAttributes: JSON.stringify({\n          name: \"Zig\",\n          tableId: \"TmpTable2\",\n          charId: \"Email\",\n          lookupColId: \"B\",\n        }),\n      }],\n    ]);\n\n    // Open access control rules.\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await startEditingAccessRules();\n\n    // Add a rule for TmpTable1.\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /TmpTable1/, 3000).click();\n    let ruleSet = findDefaultRuleSetWait(/TmpTable1/);\n    await enterRulePart(ruleSet, 1, null, \"Allow all\");\n\n    // Add a rule for columns of TmpTable2.\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /TmpTable2/, 3000).click();\n    ruleSet = findDefaultRuleSetWait(/TmpTable2/);\n    await enterRulePart(ruleSet, 1, null, \"Allow all\");\n    await findTable(/TmpTable2/).find(\".test-rule-table-menu-btn\").click();\n    await gu.findOpenMenuItem(\"li\", /Add column rule/).click();\n    ruleSet = findRuleSet(/TmpTable2/, 1);\n    await ruleSet.find(\".test-rule-resource .test-select-open\").click();\n    await gu.findOpenMenuItem(\"li\", \"A\").click();\n    await ruleSet.find(\".test-rule-resource .test-select-open\").click();\n    await gu.findOpenMenuItem(\"li\", \"B\").click();\n    await ruleSet.find(\".test-rule-resource .test-select-open\").click();\n    await gu.findOpenMenuItem(\"li\", \"C\").click();\n    await enterRulePart(ruleSet, 1, \"True\", { R: \"allow\" });\n\n    // Save the rules.\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Now remove TmpTable1 and some columns of TmpTable2.\n    await gu.removePage(\"TmpTable1\", { withData: true });\n    await api.applyUserActions(docId, [\n      [\"RemoveColumn\", \"TmpTable2\", \"B\"],\n      [\"RemoveColumn\", \"TmpTable2\", \"C\"],\n    ]);\n    await driver.find(\".test-tools-access-rules\").click();\n    await driver.findWait(\".test-rule-set\", 5000);\n    await driver.navigate().refresh();\n\n    // Check the list of rules looks plausible.\n    await driver.findWait(\".test-rule-set\", 5000);\n    assert.deepEqual(await driver.findAll(\".test-rule-table-header\", el => el.getText()),\n      [\"Rules for table #Invalid (TmpTable1)\",\n        \"Rules for table TmpTable2\",\n        \"Default rules\",\n        \"Special rules (expand each rule to customize who it applies to)\",\n      ]);\n\n    // Press the buttons we expect to be offered for clean-up.\n    await driver.findContentWait(\"button\", /Remove TmpTable1 rules/, 5000).click();\n    await driver.findContentWait(\"button\", /Remove column B from TmpTable2 rules/, 5000).click();\n    await driver.findContentWait(\"button\", /Remove column C from TmpTable2 rules/, 5000).click();\n    await driver.findContentWait(\"button\", /Remove Zig user attribute/, 5000).click();\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Check the list of rules looks cleaner.\n    assert.deepEqual(await driver.findAll(\".test-rule-table-header\", el => el.getText()),\n      [\"Rules for table TmpTable2\", \"Default rules\",\n        \"Special rules (expand each rule to customize who it applies to)\"]);\n\n    // Check only the remaining column is mentioned.\n    assert.deepEqual(await driver.findAll(\".test-acl-column\", el => el.getText()),\n      [\"A\"]);\n\n    // Remove TmpTable2.\n    await gu.removePage(\"TmpTable2\", { withData: true });\n    await driver.find(\".test-tools-access-rules\").click();\n    await driver.findWait(\".test-rule-set\", 5000);\n    await driver.navigate().refresh();\n\n    // Press the clean-up button we are offered, then check that \"reset\" works,\n    // then press and save.\n    await driver.findWait(\".test-rule-set\", 5000);\n    await driver.findContentWait(\"button\", /Remove TmpTable2 rules/, 5000).click();\n    await driver.find(\".test-rules-revert\").click();\n    await gu.waitForServer();\n    await driver.findContentWait(\"button\", /Remove TmpTable2 rules/, 5000).click();\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Check the list of rules looks cleaner.\n    assert.deepEqual(await driver.findAll(\".test-rule-table-header\", el => el.getText()),\n      [\"Default rules\",\n        \"Special rules (expand each rule to customize who it applies to)\"]);\n  });\n\n  it.skip(\"should prevent combination of Public Edit access with granular ACLs\", async function() {\n    // Share doc with everyone as viewer.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    const doc = await mainSession.tempDoc(cleanup, \"ACL-Test.grist\", { load: false });\n    const api = mainSession.createHomeApi();\n    await api.updateDocPermissions(doc.id, { users: { \"everyone@getgrist.com\": \"viewers\" } });\n\n    // Open rules. There should be no warning message.\n    await mainSession.loadDoc(`/doc/${doc.id}`);\n    await startEditingAccessRules();\n    assert.equal(await driver.find(\".test-access-rules-error\").getText(), \"\");\n\n    // Add a rule, and save.\n    await driver.find(\".test-rule-special-AccessRules .test-rule-special-checkbox\").click();\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Check that it worked.\n    assert.isTrue(await isChecked(await driver.find(\".test-rule-special-AccessRules .test-rule-special-checkbox\")));\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), false);\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Saved\");\n\n    // Try to change public access to editor. It should downgrade to viewer with a warning toast.\n    await driver.find(\".test-tb-share\").click();\n    await driver.findContent(\".test-tb-share-option\", /Manage users/).doClick();\n    await driver.findWait(\".test-um-public-access\", 3000).click();\n    assert.match(await driver.find(\".test-um-public-member .test-um-member-role\").getText(), /Viewer/);\n    await driver.find(\".test-um-public-member .test-um-member-role\").click();\n    await driver.findContent(\".test-um-role-option\", /Editor/).click();\n    await gu.saveAcls();\n    await gu.waitForServer();\n\n    const toast = driver.findWait(\".test-notifier-toast-wrapper\", 500);\n    assert.match(await toast.getText(), /incompatible.*Reduced to \"Viewer\"/i);\n    await toast.find(\".test-notifier-toast-close\").click();   // Close the toast.\n\n    // Remove the rule we added, and save.\n    await driver.find(\".test-rule-special-AccessRules .test-rule-special-checkbox\").click();\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    assert.isFalse(await isChecked(await driver.find(\".test-rule-special-AccessRules .test-rule-special-checkbox\")));\n\n    // Now change public access to editor. There should be no warning notifications.\n    await driver.find(\".test-tb-share\").click();\n    await driver.findContent(\".test-tb-share-option\", /Manage users/).doClick();\n    await driver.findWait(\".test-um-public-access\", 3000).click();\n    assert.match(await driver.find(\".test-um-public-member .test-um-member-role\").getText(), /Viewer/);\n    await driver.find(\".test-um-public-member .test-um-member-role\").click();\n    await driver.findContent(\".test-um-role-option\", /Editor/).click();\n    await gu.saveAcls();\n    await gu.waitForServer();\n    assert.lengthOf(await gu.getToasts(), 0);\n\n    // Open rules. There should be a warning message.\n    await driver.find(\".test-tools-access-rules\").click();\n    assert.match(await driver.find(\".test-access-rules-error\").getText(),\n      /Public \"Editor\".*incompatible.*remove it or reduce to \"Viewer\"/i);\n\n    // Try to add a rule. We should not be able to save.\n    await driver.find(\".test-rule-special-AccessRules .test-rule-special-checkbox\").click();\n    assert.equal(await driver.find(\".test-rules-save\").isDisplayed(), false);\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Invalid\");\n  });\n\n  it(\"Should update save button every 1 second when editing a formula\", async function() {\n    // Prepare to use the API.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n\n    // Load the page with rules.\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await driver.find(\".test-tools-access-rules\").click();\n    await driver.findWait(\".test-rule-set\", 2000);\n\n    // check the save button is in 'saved' state\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Saved\");\n\n    // add new rule\n    const ruleSet = findDefaultRuleSet(\"*\");\n    await ruleSet.find(\".test-rule-add\").click();\n\n    // check the save button is still in 'saved' state\n    // (nothing useful to save yet)\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Saved\");\n\n    // start typing invalid formula\n    const part = ruleSet.find(`.test-rule-part`);\n    await part.findWait(\".test-rule-acl-formula .ace_editor\", 500);\n    await part.find(\".test-rule-acl-formula\").doClick();\n    await driver.findWait(\".test-rule-acl-formula .ace_focus\", 500);\n    await gu.sendKeys(\"fdsa\");\n\n    // check save button is still in 'saved' state\n    // (UI hasn't caught up with user yet)\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Saved\");\n\n    // wait 1 second\n    await driver.sleep(1000);\n\n    // check the save button is now enabled\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Invalid\");\n\n    // click reset\n    await driver.find(\".test-rules-revert\").click();\n  });\n\n  it(\"should have a working dots menu\", async function() {\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}/p/3`);\n    let url = \"\";\n\n    // check page is correct\n    url = await driver.getCurrentUrl();\n    assert.isTrue(url.includes(\"p/3\"));\n    assert.equal(await gu.getCurrentPageName(), \"Access\");\n\n    await gu.openAccessRulesDropdown();\n\n    const [name1, name2, name3] = [\"user1\", \"user2\", \"user3\"].map(u => gu.translateUser(u as any).name)\n      .map(name => new RegExp(escapeRegExp(name), \"i\"));\n\n    // All users the doc is shared with should be listed, with correct Access.\n    assert.equal(await driver.findContent(\".grist-floating-menu a\", name1).isPresent(), false);\n    assert.include(await driver.findContent(\".grist-floating-menu a\", name2).getText(), \"(Owner)\");\n    assert.include(await driver.findContent(\".grist-floating-menu a\", name3).getText(), \"(Editor)\");\n\n    await driver.findContent(\".grist-floating-menu a\", name3).click();\n    await gu.waitForUrl(/aclAsUser/);\n    await gu.waitForDocToLoad();\n\n    // Check for a tag in the doc header.\n    assert.equal(await driver.findWait(\".test-view-as-banner\", 2000).isPresent(), true);\n\n    // check doc is still on same page\n    url = await driver.getCurrentUrl();\n    assert.isTrue(url.includes(\"p/3\"));\n    assert.equal(await gu.getCurrentPageName(), \"Access\");\n\n    // Revert.\n    await driver.find(\".test-view-as-banner .test-revert\").click();\n    await gu.waitForDocToLoad();\n  });\n\n  it(\"should keep dots menu in sync\", async function() {\n    const checkAccess = async (role: string) => {\n      await gu.openAccessRulesDropdown();\n      await gu.waitToPass(async () => {\n        assert.include(await driver.findContent(\".grist-floating-menu a\", /kiwi/i).getText(), role);\n      });\n      await driver.sendKeys(Key.ESCAPE);\n    };\n\n    // open doc\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n\n    // check kiwi matches (Editor)\n    await checkAccess(\"(Editor)\");\n\n    // update kiwi access to viewers\n    const api = mainSession.createHomeApi();\n    await api.updateDocPermissions(docId, { users: { [gu.translateUser(\"user3\").email]: \"viewers\" } });\n\n    // check kiwi matches (Viewer)\n    await checkAccess(\"(Viewer)\");\n  });\n\n  it(\"should hide dots menu for users without ACL access\", async function() {\n    // Login as user3 and open doc\n    const mainSession = gu.session().teamSite.user(\"user1\");\n    await mainSession.teamSite.user(\"user3\").login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n\n    // Check absence of dots menu\n    await driver.find(\".test-tools-access-rules\").mouseMove();\n    await gu.waitToPass(async () => {\n      assert.equal(await driver.find(\".test-tools-access-rules-trigger\").isPresent(), true);\n    });\n    assert.equal(await driver.find(\".test-tools-access-rules-trigger\").isDisplayed(), false);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/AccessRules3.ts",
    "content": "/**\n * Test of the UI for Granular Access Control, part 3.\n */\nimport { assertChanged, assertSaved, enterRulePart, findDefaultRuleSet,\n  findRuleSet, findRuleSetWait, findTableWait, getRules, hasExtraAdd, removeRules,\n  removeTable, startEditingAccessRules } from \"test/nbrowser/aclTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"AccessRules3\", function() {\n  this.timeout(40000);\n  const cleanup = setupTestSuite();\n  let docId: string;\n\n  before(async function() {\n    // Import a test document we've set up for this.\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    docId = (await mainSession.tempDoc(cleanup, \"ACL-Test.grist\", { load: false })).id;\n\n    // Share it with a few users.\n    const api = mainSession.createHomeApi();\n    await api.updateDocPermissions(docId, { users: {\n      [gu.translateUser(\"user2\").email]: \"owners\",\n      [gu.translateUser(\"user3\").email]: \"editors\",\n    } });\n    return docId;\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  describe(\"SeedRule special\", function() {\n    // When a tooltip is present, it introduces this extra text into getText() result.\n    const tooltipMarker = \"\\n?\";\n\n    it(\"can add initial rules based on SeedRule special\", async function() {\n      // Open Access Rules page.\n      const mainSession = await gu.session().teamSite.user(\"user1\").login();\n      await mainSession.loadDoc(`/doc/${docId}`);\n      await startEditingAccessRules();\n\n      // Save the initial rules.\n      await driver.find(\".test-rules-save\").click();\n      await gu.waitForServer();\n      await assertSaved();\n\n      // Check seed rule checkbox is unselected.\n      const seedRule = await driver.find(\"div.test-rule-special-SeedRule\");\n      const checkbox = seedRule.find(\"input[type=checkbox]\");\n      assert.equal(await checkbox.isSelected(), false);\n\n      // Expand and check there's an empty rule.\n      await driver.find(\".test-rule-special-SeedRule .test-rule-special-expand\").click();\n      await assertSaved();\n      await getRules(seedRule);\n      assert.deepEqual(await getRules(seedRule),\n        [{ formula: \"Everyone\", perm: \"\" }]);\n      assert.equal(await hasExtraAdd(seedRule), false);\n\n      // Adding rules for a new table/column should look the same as always.\n      await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n      await gu.findOpenMenuItem(\"li\", /FinancialsTable/, 3000).click();\n      await assertChanged();\n      let fin = findTableWait(/FinancialsTable/);\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"Everyone\", perm: \"\", res: \"All\" }]);\n      await fin.find(\".test-rule-table-menu-btn\").click();\n      await gu.findOpenMenuItem(\"li\", /Add column rule/).click();\n      await findRuleSetWait(/FinancialsTable/, 1).isPresent();\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"Everyone\", perm: \"\", res: \"[Add Column]\" },\n          { formula: \"Everyone\", perm: \"\", res: \"All\" + tooltipMarker }]);\n      await removeTable(/FinancialsTable/);\n      await assertSaved();\n\n      // Now check the box, and see we get the rule we expect.\n      await checkbox.click();\n      await gu.waitForServer();\n      await assertChanged();\n      assert.deepEqual(await getRules(seedRule),\n        [{ formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\" }]);\n      assert.equal(await hasExtraAdd(seedRule), true);\n\n      // New table rules should start off with that rule.\n      await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n      await gu.findOpenMenuItem(\"li\", /FinancialsTable/, 3000).click();\n      await gu.waitForMenuToClose();\n      fin = findTableWait(/FinancialsTable/);\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\", res: \"All\" },\n          { formula: \"Everyone Else\", perm: \"\", res: \"All\" }]);\n      assert.equal(await hasExtraAdd(fin), false);\n\n      // New column rules should start off with that rule.\n      await fin.find(\".test-rule-table-menu-btn\").click();\n      await gu.findOpenMenuItem(\"li\", /Add column rule/).click();\n      await gu.waitForMenuToClose();\n\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"user.Access in [OWNER]\", perm: \"+R+U\", res: \"[Add Column]\" },\n          { formula: \"Everyone Else\", perm: \"\", res: \"[Add Column]\" },\n          { formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\", res: \"All\" + tooltipMarker },\n          { formula: \"Everyone Else\", perm: \"\", res: \"All\" + tooltipMarker }]);\n\n      // Make sure that removing and re-adding default rules works as expected.\n      await removeRules(findDefaultRuleSet(/FinancialsTable/));\n      assert.equal(await findDefaultRuleSet(/FinancialsTable/).isPresent(), false);\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"user.Access in [OWNER]\", perm: \"+R+U\", res: \"[Add Column]\" },\n          { formula: \"Everyone Else\", perm: \"\", res: \"[Add Column]\" }]);\n      await fin.find(\".test-rule-table-menu-btn\").click();\n      await gu.findOpenMenuItem(\"li\", /Add table-wide rule/).click();\n      await gu.waitForMenuToClose();\n\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"user.Access in [OWNER]\", perm: \"+R+U\", res: \"[Add Column]\" },\n          { formula: \"Everyone Else\", perm: \"\", res: \"[Add Column]\" },\n          { formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\", res: \"All\" + tooltipMarker },\n          { formula: \"Everyone Else\", perm: \"\", res: \"All\" + tooltipMarker }]);\n      await removeTable(/FinancialsTable/);\n\n      // Check that we can tweak the seed rules if we want.\n      await seedRule.find(\".test-rule-part .test-rule-add\").click();\n      await enterRulePart(seedRule, 1, \"user.Access in [EDITOR]\", \"Deny all\", \"memo1\");\n      assert.equal(await checkbox.getAttribute(\"disabled\"), \"true\");\n\n      // New table rules should include the seed rules.\n      await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n      await gu.findOpenMenuItem(\"li\", /FinancialsTable/, 3000).click();\n      await gu.waitForMenuToClose();\n\n      fin = findTableWait(/FinancialsTable/);\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"user.Access in [EDITOR]\", perm: \"-R-U-C-D\", res: \"All\", memo: \"memo1\" },\n          { formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\", res: \"All\" },\n          { formula: \"Everyone Else\", perm: \"\", res: \"All\" }]);\n      assert.equal(await hasExtraAdd(fin), false);\n      await removeTable(/FinancialsTable/);\n      await gu.waitForServer();\n\n      // Check that returning to the single OWNER rule gets us back to an uncomplicated\n      // selected checkbox.\n      await assertChanged();\n      assert.equal(await checkbox.getAttribute(\"disabled\"), \"true\");\n      assert.equal(await checkbox.isSelected(), false);\n      await seedRule.find(\".test-rule-part .test-rule-remove\").click();\n      assert.equal(await checkbox.getAttribute(\"disabled\"), null);\n      assert.equal(await checkbox.isSelected(), true);\n\n      // Check that removing that rule deselected the checkbox and collapses rule list.\n      await seedRule.find(\".test-rule-part .test-rule-remove\").click();\n      assert.equal(await checkbox.getAttribute(\"disabled\"), null);\n      assert.equal(await checkbox.isSelected(), false);\n      await assertSaved();\n      assert.lengthOf(await seedRule.findAll(\".test-rule-set\"), 0);\n\n      // Expand again, and make sure we are back to default.\n      await driver.find(\".test-rule-special-SeedRule .test-rule-special-expand\").click();\n      assert.lengthOf(await seedRule.findAll(\".test-rule-set\"), 1);\n      assert.deepEqual(await getRules(seedRule),\n        [{ formula: \"Everyone\", perm: \"\" }]);\n      await assertSaved();\n    });\n\n    it(\"can have a SeedRule special that refers to columns\", async function() {\n      // Open Access Rules page.\n      const mainSession = await gu.session().teamSite.user(\"user1\").login();\n      await mainSession.loadDoc(`/doc/${docId}`);\n      await startEditingAccessRules();\n\n      // Check seed rule checkbox is unselected.\n      const seedRule = await driver.find(\"div.test-rule-special-SeedRule\");\n      const checkbox = seedRule.find(\"input[type=checkbox]\");\n      assert.equal(await checkbox.isSelected(), false);\n\n      // Now check the box, and see we get the default rule we expect.\n      await checkbox.click();\n      await assertChanged();\n      await driver.find(\".test-rule-special-SeedRule .test-rule-special-expand\").click();\n      assert.deepEqual(await getRules(seedRule),\n        [{ formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\" }]);\n      assert.equal(await hasExtraAdd(seedRule), true);\n\n      // Tweak the seed rule to refer to a column.\n      await seedRule.find(\".test-rule-part .test-rule-add\").click();\n      await enterRulePart(seedRule, 1, \"rec.Year == 1\", \"Deny all\", \"memo1\");\n      assert.equal(await checkbox.getAttribute(\"disabled\"), \"true\");\n\n      // New table rules should include the seed rule.\n      await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n      await gu.findOpenMenuItem(\"li\", /FinancialsTable/, 3000).click();\n      let fin = findTableWait(/FinancialsTable/);\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"rec.Year == 1\", perm: \"-R-U-C-D\", res: \"All\", memo: \"memo1\" },\n          { formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\", res: \"All\" },\n          { formula: \"Everyone Else\", perm: \"\", res: \"All\" }]);\n      assert.equal(await hasExtraAdd(fin), false);\n      await removeTable(/FinancialsTable/);\n\n      // Tweak the seed rule to refer to a column that won't exist.\n      await enterRulePart(seedRule, 1, \"rec.Unreal == 1\", \"Deny all\", \"memo1\");\n      assert.equal(await checkbox.getAttribute(\"disabled\"), \"true\");\n\n      // New table rules should include the seed rule, and show an error.\n      await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n      await gu.findOpenMenuItem(\"li\", /FinancialsTable/, 3000).click();\n      fin = findTableWait(/FinancialsTable/);\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"rec.Unreal == 1\", perm: \"-R-U-C-D\", res: \"All\", memo: \"memo1\",\n          error: \"Invalid columns: Unreal\" },\n        { formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\", res: \"All\" },\n        { formula: \"Everyone Else\", perm: \"\", res: \"All\" }]);\n      assert.equal(await hasExtraAdd(fin), false);\n      await removeTable(/FinancialsTable/);\n\n      // Check that returning to the single OWNER rule gets us back to an uncomplicated\n      // selected checkbox.\n      await assertChanged();\n      assert.equal(await checkbox.getAttribute(\"disabled\"), \"true\");\n      assert.equal(await checkbox.isSelected(), false);\n      await seedRule.find(\".test-rule-part .test-rule-remove\").click();\n      assert.equal(await checkbox.getAttribute(\"disabled\"), null);\n      assert.equal(await checkbox.isSelected(), true);\n\n      // Check that removing that rule deselected the checkbox and collapses rule list.\n      await seedRule.find(\".test-rule-part .test-rule-remove\").click();\n      assert.equal(await checkbox.getAttribute(\"disabled\"), null);\n      assert.equal(await checkbox.isSelected(), false);\n      await assertSaved();\n      assert.lengthOf(await seedRule.findAll(\".test-rule-set\"), 0);\n\n      // Expand again, and make sure we are back to default.\n      await driver.find(\".test-rule-special-SeedRule .test-rule-special-expand\").click();\n      assert.lengthOf(await seedRule.findAll(\".test-rule-set\"), 1);\n      assert.deepEqual(await getRules(seedRule),\n        [{ formula: \"Everyone\", perm: \"\" }]);\n      await assertSaved();\n    });\n\n    it(\"can save and reload SeedRule special\", async function() {\n      const mainSession = await gu.session().teamSite.user(\"user1\").login();\n      await mainSession.loadDoc(`/doc/${docId}`);\n      await startEditingAccessRules();\n\n      // Initially nothing is selected and all is saved.\n      let seedRule = await driver.find(\"div.test-rule-special-SeedRule\");\n      let checkbox = seedRule.find(\"input[type=checkbox]\");\n      assert.equal(await checkbox.isSelected(), false);\n      await assertSaved();\n\n      // Clicking the checkbox is immediately save-able.\n      await checkbox.click();\n      await assertChanged();\n\n      // Save, and check state is correctly persisted.\n      await driver.find(\".test-rules-save\").click();\n      await gu.waitForServer();\n      seedRule = await driver.findWait(\"div.test-rule-special-SeedRule\", 2000);\n      checkbox = seedRule.find(\"input[type=checkbox]\");\n      assert.equal(await checkbox.isSelected(), true);\n      await assertSaved();\n\n      // Expand and ensure we see the expected rule.\n      await driver.find(\".test-rule-special-SeedRule .test-rule-special-expand\").click();\n      assert.deepEqual(await getRules(seedRule),\n        [{ formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\" }]);\n      assert.equal(await hasExtraAdd(seedRule), true);\n\n      // Now unselect the checkbox, and make sure that we can save+reload.\n      await checkbox.click();\n      await assertChanged();\n      await driver.find(\".test-rules-save\").click();\n      await gu.waitForServer();\n      seedRule = await driver.findWait(\"div.test-rule-special-SeedRule\", 2000);\n      checkbox = seedRule.find(\"input[type=checkbox]\");\n      assert.equal(await checkbox.isSelected(), false);\n      await assertSaved();\n      await driver.find(\".test-rule-special-SeedRule .test-rule-special-expand\").click();\n      assert.deepEqual(await getRules(seedRule),\n        [{ formula: \"Everyone\", perm: \"\" }]);\n      assert.equal(await hasExtraAdd(seedRule), false);\n\n      // Select the checkbox again, and save. Then make a custom change.\n      await checkbox.click();\n      await assertChanged();\n      await driver.find(\".test-rules-save\").click();\n      await gu.waitForServer();\n      seedRule = await driver.findWait(\"div.test-rule-special-SeedRule\", 2000);\n      checkbox = seedRule.find(\"input[type=checkbox]\");\n      assert.equal(await checkbox.isSelected(), true);\n      await driver.find(\".test-rule-special-SeedRule .test-rule-special-expand\").click();\n      await seedRule.find(\".test-rule-part .test-rule-add\").click();\n      await enterRulePart(seedRule, 1, \"user.Access in [EDITOR]\", \"Deny all\", \"memo2\");\n      assert.equal(await checkbox.getAttribute(\"disabled\"), \"true\");\n      assert.deepEqual(await getRules(seedRule),\n        [{ formula: \"user.Access in [EDITOR]\", perm: \"-R-U-C-D\", memo: \"memo2\" },\n          { formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\" }]);\n      await assertChanged();\n\n      // Save the custom change, and make sure we can reload it.\n      await driver.find(\".test-rules-save\").click();\n      await gu.waitForServer();\n      seedRule = await driver.findWait(\"div.test-rule-special-SeedRule\", 2000);\n      checkbox = seedRule.find(\"input[type=checkbox]\");\n      assert.equal(await checkbox.isSelected(), false);\n      assert.equal(await checkbox.getAttribute(\"disabled\"), \"true\");\n      assert.deepEqual(await getRules(seedRule),\n        [{ formula: \"user.Access in [EDITOR]\", perm: \"-R-U-C-D\", memo: \"memo2\" },\n          { formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\" }]);\n      await assertSaved();\n\n      // Undo; should now again have the simple checked checkbox for seed rules.\n      await gu.undo();\n      seedRule = await driver.findWait(\"div.test-rule-special-SeedRule\", 2000);\n      checkbox = seedRule.find(\"input[type=checkbox]\");\n      assert.equal(await checkbox.isSelected(), true);\n    });\n\n    it(\"does not include unavailable bits when saving\", async function() {\n      // Open Access Rules page.\n      const mainSession = await gu.session().teamSite.user(\"user1\").login();\n      await mainSession.loadDoc(`/doc/${docId}`);\n      await startEditingAccessRules();\n\n      // Click the seed rule checkbox.\n      const seedRule = await driver.find(\"div.test-rule-special-SeedRule\");\n      assert.equal(await seedRule.find(\"input[type=checkbox]\").isSelected(), true);\n\n      // New table AND column rules should start off with that rule.\n      await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n      await gu.findOpenMenuItem(\"li\", /FinancialsTable/, 3000).click();\n      let fin = findTableWait(/FinancialsTable/);\n      await fin.find(\".test-rule-table-menu-btn\").click();\n      await gu.findOpenMenuItem(\"li\", /Add column rule/).click();\n      const ruleSet = findRuleSet(/FinancialsTable/, 1);\n      await ruleSet.find(\".test-rule-resource .test-select-open\").click();\n      await gu.findOpenMenuItem(\"li\", \"Year\").click();\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"user.Access in [OWNER]\", perm: \"+R+U\", res: \"Year\\n[Add Column]\" },\n          { formula: \"Everyone Else\", perm: \"\", res: \"Year\\n[Add Column]\" },\n          { formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\", res: \"All\" + tooltipMarker },\n          { formula: \"Everyone Else\", perm: \"\", res: \"All\" + tooltipMarker }]);\n\n      // Check that the Save button is enabled, and save.\n      await gu.userActionsCollect();\n      await assertChanged();\n      await driver.find(\".test-rules-save\").click();\n      await gu.waitForServer();\n\n      // This is the important check of this test: that for a column rule, we only save the \"read\"\n      // and \"update\" bits.\n      await gu.userActionsVerify([\n        [\"BulkAddRecord\", \"_grist_ACLResources\", [-1, -2], {\n          colIds: [\"Year\", \"*\"],\n          tableId: [\"FinancialsTable\", \"FinancialsTable\"],\n        }],\n        [\"BulkAddRecord\", \"_grist_ACLRules\", [-1, -2], {\n          resource: [-1, -2],\n          aclFormula: [\"user.Access in [OWNER]\", \"user.Access in [OWNER]\"],\n          // Specifically, we care that this permissionsText includes only RU bits for column rules.\n          permissionsText: [\"+RU\", \"+CRUD\"],\n          rulePos: [1 / 6, 1 / 3],\n          memo: [\"\", \"\"],\n        }],\n      ]);\n      await assertSaved();\n\n      // Rules still look correct after saving.\n      fin = findTableWait(/FinancialsTable/);\n      assert.deepEqual(await getRules(fin),\n        [{ formula: \"user.Access in [OWNER]\", perm: \"+R+U\", res: \"Year\" },\n          { formula: \"user.Access in [OWNER]\", perm: \"+R+U+C+D\", res: \"All\" + tooltipMarker }]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/AccessRules4.ts",
    "content": "/**\n * Test of the UI for Granular Access Control, part 3.\n */\nimport { ITestingHooks } from \"app/server/lib/ITestingHooks\";\nimport { assertChanged, assertSaved, startEditingAccessRules } from \"test/nbrowser/aclTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"AccessRules4\", function() {\n  this.timeout(process.env.DEBUG ? \"20m\" : \"20s\");\n  const cleanup = setupTestSuite();\n  let testingHooks: ITestingHooks;\n\n  afterEach(() => gu.checkForErrors());\n\n  before(async () => {\n    testingHooks = await server.getTestingHooks();\n  });\n\n  it(\"allows editor to toggle a column\", async function() {\n    const ownerSession = await gu.session().teamSite.user(\"user1\").login();\n    const docId = await ownerSession.tempNewDoc(cleanup, undefined, { load: false });\n\n    // Create editor for this document.\n    const api = ownerSession.createHomeApi();\n    await api.updateDocPermissions(docId, { users: {\n      [gu.translateUser(\"user2\").email]: \"editors\",\n    } });\n\n    await api.applyUserActions(docId, [\n      // Now create a structure.\n      [\"RemoveTable\", \"Table1\"],\n      [\"AddTable\", \"Table1\", [\n        { id: \"Toggle\", type: \"Bool\" },\n        { id: \"Another\", type: \"Text\" },\n        { id: \"User_Access\", type: \"Text\", formula: \"user.Email\", isFormula: false },\n      ]],\n      // Now add access rules for Table2\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      // Owner can do anything\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access == OWNER\", permissionsText: \"all\",\n      }],\n      // User with an his email address in the User_Access column can do anything\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Email == rec.User_Access\", permissionsText: \"all\",\n      }],\n      // Otherwise no access\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"none\",\n      }],\n    ]);\n    await ownerSession.loadDoc(`/doc/${docId}`);\n\n    // Make sure we can edit this as an owner.\n    await gu.sendCommand(\"insertRecordAfter\");\n\n    assert.isEmpty(await gu.getCell(\"Another\", 1).getText());\n    assert.equal(await gu.getCell(\"User_Access\", 1).getText(), gu.translateUser(\"user1\").email);\n    assert.isFalse(await gu.getCell(\"Toggle\", 1).find(\".widget_checkmark\").isDisplayed());\n\n    await gu.getCell(\"Another\", 1).click();\n    await gu.enterCell(\"owner\");\n    await gu.getCell(\"Toggle\", 1).mouseMove();\n    await gu.getCell(\"Toggle\", 1).find(\".widget_checkbox\").click();\n    await gu.waitForServer();\n\n    assert.equal(await gu.getCell(\"Another\", 1).getText(), \"owner\");\n    assert.equal(await gu.getCell(\"User_Access\", 1).getText(), gu.translateUser(\"user1\").email);\n    assert.isTrue(await gu.getCell(\"Toggle\", 1).find(\".widget_checkmark\").isDisplayed());\n\n    // Now login as user2.\n    const userSession = await gu.session().teamSite.user(\"user2\").login();\n    await userSession.loadDoc(`/doc/${docId}`);\n\n    // Make sure we can edit this as an user2\n    await gu.sendCommand(\"insertRecordAfter\");\n\n    assert.isEmpty(await gu.getCell(\"Another\", 1).getText());\n    assert.equal(await gu.getCell(\"User_Access\", 1).getText(), gu.translateUser(\"user2\").email);\n    assert.isFalse(await gu.getCell(\"Toggle\", 1).find(\".widget_checkmark\").isDisplayed());\n\n    await gu.getCell(\"Another\", 1).click();\n    await gu.enterCell(\"user2\");\n    await gu.getCell(\"Toggle\", 1).mouseMove();\n    await gu.getCell(\"Toggle\", 1).find(\".widget_checkbox\").click();\n    await gu.waitForServer();\n\n    assert.equal(await gu.getCell(\"Another\", 1).getText(), \"user2\");\n    assert.equal(await gu.getCell(\"User_Access\", 1).getText(), gu.translateUser(\"user2\").email);\n    assert.isTrue(await gu.getCell(\"Toggle\", 1).find(\".widget_checkmark\").isDisplayed());\n  });\n\n  it(\"pretends that example user does not exist\", async function() {\n    const session = await gu.session().personalSite.user(\"user1\").login();\n    await session.tempNewDoc(cleanup);\n\n    // Create a user with that email address.\n    const email = \"john@example.com\";\n    const db = await server.getDatabase();\n    const john = await db.getUserByLogin(email);\n\n    // Add user table with this user.\n    await gu.sendActions([\n      [\"AddTable\", \"Users\", [\n        { id: \"Email\", type: \"Text\" },\n      ]],\n      [\"AddRecord\", \"Users\", -1, { Email: email }],\n    ]);\n\n    await gu.openPage(\"Users\");\n    assert.deepEqual(await gu.getSectionTitles(), [\"USERS\"]);\n\n    // Add this table as an attribute.\n    await startEditingAccessRules();\n    await driver.findContentWait(\"button\", /Add user attributes/, 2000).click();\n    const userAttrRule = await driver.findWait(\".test-rule-userattr\", 200);\n    await userAttrRule.find(\".test-rule-userattr-name\").click();\n    await driver.sendKeys(\"Custom\", Key.ENTER);\n    await userAttrRule.find(\".test-rule-userattr-attr\").click();\n    await driver.sendKeys(\"Email\", Key.ENTER);\n    await userAttrRule.find(\".test-rule-userattr-table\").click();\n    await gu.findOpenMenuItem(\"li\", \"Users\").click();\n    await userAttrRule.find(\".test-rule-userattr-col\").click();\n    await gu.findOpenMenu();\n    await driver.sendKeys(\"Email\", Key.ENTER);\n    await assertChanged();\n    await driver.find(\".test-rules-save\").click();\n    await gu.checkForErrors();\n    await gu.waitForServer();\n    await assertSaved();\n    await gu.openPage(\"Users\");\n\n    // Login as john\n    await testingHooks.flushAuthorizerCache();\n    await gu.reloadDoc();\n    await viewAs(\"john (Editor)\");\n\n    // Now we should see a table, even though John has no access to the document.\n    assert.deepEqual(await gu.getSectionTitles(), [\"USERS\"]);\n\n    // Remove this user.\n    await db.deleteUser({ userId: john.id }, john.id);\n  });\n\n  it(\"unknown access defaults to public\", async function() {\n    const session = await gu.session().personalSite.user(\"user1\").login();\n    await session.tempNewDoc(cleanup);\n\n    // Make this document public.\n    await driver.find(\".test-tb-share\").click();\n    await driver.findContentWait(\".test-tb-share-option\", /Manage users/, 100).doClick();\n    await driver.findWait(\".test-um-public-access\", 3000).click();\n    await driver.findContentWait(\".test-um-public-option\", \"On\", 100).click();\n    await gu.saveAcls();\n    await testingHooks.flushAuthorizerCache();\n    await gu.reloadDoc();\n\n    // Now view as as Unknown User.\n    await viewAs(\"Unknown User\");\n\n    // And make sure we can see the document.\n    await gu.openPage(\"Table1\");\n\n    // There should be a proper role in the banner.\n    assert.equal(\n      await driver.find(\".test-view-as-banner .test-select-open\").getText(),\n      \"Unknown User(Viewer)\",\n    );\n\n    await driver.find(\".test-view-as-banner .test-revert\").click();\n    await gu.waitForDocToLoad();\n\n    // Now make the public editor.\n    await driver.find(\".test-tb-share\").click();\n    await driver.findContentWait(\".test-tb-share-option\", /Manage users/, 100).doClick();\n    await driver.findWait(\".test-um-public-member .test-um-member-role\", 100).click();\n    await driver.findContentWait(\".test-um-role-option\", /Editor/, 100).click();\n    await gu.saveAcls();\n    await gu.openPage(\"Table1\");\n    await testingHooks.flushAuthorizerCache();\n    await gu.reloadDoc();\n\n    // Now view as as Unknown User.\n    await viewAs(\"Unknown User\");\n\n    assert.equal(\n      await driver.find(\".test-view-as-banner .test-select-open\").getText(),\n      \"Unknown User(Editor)\",\n    );\n\n    // And try to add a new record.\n    await gu.openPage(\"Table1\");\n    await gu.sendActions([[\"AddRecord\", \"Table1\", -1, { A: \"New record\" }]]);\n    assert.equal(await gu.getCell(\"A\", 1).getText(), \"New record\");\n  });\n});\n\nasync function viewAs(user: string) {\n  await gu.openAccessRulesDropdown();\n  // Menu is loaded asynchronously, and we often get a stale element reference error.\n  await gu.waitToPass(() => driver.findContentWait(\".grist-floating-menu a\", user, 100).click());\n  await gu.waitForDocToLoad();\n  await driver.findWait(\".test-view-as-banner\", 1000);\n}\n"
  },
  {
    "path": "test/nbrowser/AccessRulesAttrs.ts",
    "content": "/**\n * Test handling of attributes in condition formulas (e.g. \"user.Email.upper()\"), including\n * autocomplete suggestions and errors.\n */\nimport { UserAPI } from \"app/common/UserAPI\";\nimport { enterRulePart, findDefaultRuleSetWait, removeRules,\n  startEditingAccessRules, triggerAutoComplete } from \"test/nbrowser/aclTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"AccessRulesAttrs\", function() {\n  this.timeout(\"20s\");\n  const cleanup = setupTestSuite();\n  let mainSession: gu.Session;\n  let docId: string;\n  let api: UserAPI;\n\n  afterEach(() => gu.checkForErrors());\n\n  before(async function() {\n    mainSession = await gu.session().teamSite.user(\"user1\").login();\n    docId = await mainSession.tempNewDoc(cleanup, \"AccessRulesAttr\", { load: false });\n    api = mainSession.createHomeApi();\n    await api.applyUserActions(docId, [\n      [\"RemoveTable\", \"Table1\"],\n      [\"AddTable\", \"TableFoo\", [\n        { id: \"SomeText\", type: \"Text\" },\n        { id: \"SomeDate\", type: \"Date\" },\n      ]],\n      [\"BulkAddRecord\", \"TableFoo\", [null, null, null], { SomeText: [\"foo\", \"FoO\", \"Bar\"] }],\n    ]);\n  });\n\n  it(\"supports upper/lower on text columns\", async function() {\n    await mainSession.loadDoc(`/doc/${docId}/p/acl`, { wait: false });\n    await startEditingAccessRules();\n\n    // Check API results before any rules are added.\n    assert.deepEqual(await api.getDocAPI(docId).getRecords(\"TableFoo\"), [\n      { id: 1, fields: { SomeText: \"foo\", SomeDate: null } },\n      { id: 2, fields: { SomeText: \"FoO\", SomeDate: null } },\n      { id: 3, fields: { SomeText: \"Bar\", SomeDate: null } },\n    ]);\n\n    // Add rules for TableFoo.\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /TableFoo/, 3000).click();\n    let ruleSet = findDefaultRuleSetWait(/TableFoo/);\n\n    // Add a rule that only allows reading row with \"foo\" in it (all lowercase).\n    await enterRulePart(ruleSet, 1, '$SomeText == \"foo\"', \"Allow all\");\n    await ruleSet.find(\".test-rule-extra-add .test-rule-add\").click();\n    await enterRulePart(ruleSet, 2, null, \"Deny all\");\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Check API results with the new rule.\n    assert.deepEqual(await api.getDocAPI(docId).getRecords(\"TableFoo\"), [\n      { id: 1, fields: { SomeText: \"foo\", SomeDate: null } },\n    ]);\n\n    // Now try a rule that lowercases the text value.\n    ruleSet = findDefaultRuleSetWait(/TableFoo/);\n    await enterRulePart(ruleSet, 1, '$SomeText.lower() == \"foo\"', \"Allow all\");\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n    assert.deepEqual(await api.getDocAPI(docId).getRecords(\"TableFoo\"), [\n      { id: 1, fields: { SomeText: \"foo\", SomeDate: null } },\n      { id: 2, fields: { SomeText: \"FoO\", SomeDate: null } },\n    ]);\n\n    // Try uppercase, with no matches.\n    ruleSet = findDefaultRuleSetWait(/TableFoo/);\n    await enterRulePart(ruleSet, 1, '$SomeText.upper() == \"foo\"', \"Allow all\");\n    await driver.findWait(\".test-rules-save\", 500).click();\n    await gu.waitForServer();\n    assert.deepEqual(await api.getDocAPI(docId).getRecords(\"TableFoo\"), []);\n  });\n\n  it(\"should show autocomplete suggestions for text values\", async function() {\n    await mainSession.loadDoc(`/doc/${docId}/p/acl`, { wait: false });\n    await startEditingAccessRules();\n    const ruleSet = findDefaultRuleSetWait(/TableFoo/);\n    await triggerAutoComplete(ruleSet, 1, \"$SomeText.\");\n    await checkCompletions([\"$SomeText.lower()\", \"$SomeText.upper()\"]);\n\n    // Works too if we start with rec.\n    await gu.waitToPass(async () => {\n      await driver.sendKeys(Key.ESCAPE);\n      await triggerAutoComplete(ruleSet, 1, \"rec.SomeText.upp\");\n      await checkCompletions([\"rec.SomeText.upper()\"]);\n    });\n\n    // No autocomplete for Date columns.\n    await gu.waitToPass(async () => {\n      await driver.sendKeys(Key.ESCAPE);\n      await triggerAutoComplete(ruleSet, 1, \"$SomeDate.\");\n      await checkNoCompletions();\n    });\n\n    // Yes autocomplete for user.Email\n    await gu.waitToPass(async () => {\n      await driver.sendKeys(Key.ESCAPE);\n      await triggerAutoComplete(ruleSet, 1, \"$SomeDate == user.Email.\");\n      await checkCompletions([\"user.Email.lower()\", \"user.Email.upper()\"]);\n    });\n\n    // No autocomplete for user.Access (it has type Choice, not Text, string methods not useful).\n    await gu.waitToPass(async () => {\n      await driver.sendKeys(Key.ESCAPE);\n      await triggerAutoComplete(ruleSet, 1, \"user.Access.\");\n      await checkNoCompletions();\n    });\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should show errors for invalid attributes\", async function() {\n    await mainSession.loadDoc(`/doc/${docId}/p/acl`, { wait: false });\n    await startEditingAccessRules();\n    const ruleSet = findDefaultRuleSetWait(/TableFoo/);\n\n    // Invalid\n    await enterRulePart(ruleSet, 1, \"$SomeText.lowercase() == user.Email.uper()\", \"Allow all\");\n    await checkError(ruleSet, 1, /Not a function: 'rec.SomeText.lowercase'/);\n\n    await enterRulePart(ruleSet, 1, \"$SomeText.lower() == user.Email.uper()\", \"Allow all\");\n    await checkError(ruleSet, 1, /Not a function: 'user.Email.uper'/);\n\n    await enterRulePart(ruleSet, 1, \"$SomeDate.lower() == user.Email.uper()\", \"Allow all\");\n    await checkError(ruleSet, 1, /Not a function: 'rec.SomeDate.lower'/);\n\n    // Valid\n    await enterRulePart(ruleSet, 1, \"$SomeText.lower() == user.Email.upper()\", \"Allow all\");\n    await checkError(ruleSet, 1, null);\n  });\n\n  it(\"should show toast on actions when rule does not apply\", async function() {\n    // Add another Text column. We'll change it to non-Text later, to see what happens to a\n    // predicate that uses a text method.\n    await api.applyUserActions(docId, [\n      [\"AddVisibleColumn\", \"TableFoo\", \"OtherText\", { type: \"Text\", isFormula: true, formula: \"$SomeText or $id\" }],\n    ]);\n\n    await mainSession.loadDoc(`/doc/${docId}/p/acl`, { wait: false });\n    await startEditingAccessRules();\n    let ruleSet = findDefaultRuleSetWait(/TableFoo/);\n    if (await ruleSet.isPresent()) {\n      await removeRules(ruleSet);\n      await driver.find(\".test-rules-save\").click();\n      await gu.waitForServer();\n    }\n\n    // While this column is Text, we can use \"lower()\" method, and everything works.\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /TableFoo/, 3000).click();\n    ruleSet = findDefaultRuleSetWait(/TableFoo/);\n    // This rule won't match anything, which is fine, we are only interested in its validity.\n    await enterRulePart(ruleSet, 1, 'newRec.OtherText.lower() == \"blah\"', { U: \"deny\", C: \"deny\" });\n    await ruleSet.find(\".test-rule-extra-add .test-rule-add\").click();\n    await enterRulePart(ruleSet, 2, null, \"Allow all\");\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // The table looks normal (no rules restricting access).\n    await mainSession.loadDoc(`/doc/${docId}`);\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"SomeText\", \"OtherText\"], rowNums: [2] }), [\"FoO\", \"FoO\"]);\n    // We can edit data (rule restricting edits is valid because type is Text, and doesn't match).\n    await gu.getCell({ rowNum: 2, col: \"SomeText\" }).click();\n    await gu.enterCell(Key.DELETE);\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"SomeText\", \"OtherText\"], rowNums: [2] }), [\"\", \"2\"]);\n    await gu.checkForErrors();\n    await gu.undo();\n\n    // Now change the type of the column used in the rule.\n    await api.applyUserActions(docId, [[\"ModifyColumn\", \"TableFoo\", \"OtherText\", { type: \"Any\" }]]);\n\n    // The rule preventing edits will still work for text values, but should show error for\n    // non-text values.\n    await gu.getCell({ rowNum: 2, col: \"SomeText\" }).click();\n    await gu.enterCell(\"ciao\");\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"SomeText\", \"OtherText\"], rowNums: [2] }), [\"ciao\", \"ciao\"]);\n    await gu.checkForErrors();\n    await gu.enterCell(Key.DELETE);\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"SomeText\", \"OtherText\"], rowNums: [2] }), [\"ciao\", \"ciao\"]);\n    assert.match((await gu.getToasts())[0], /Rule.*has an error: Not a function: 'newRec.OtherText.lower'/);\n    await gu.undo();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"SomeText\", \"OtherText\"], rowNums: [2] }), [\"FoO\", \"FoO\"]);\n  });\n});\n\nasync function checkCompletions(expected: string[]) {\n  await gu.waitToPass(async () => {\n    const completions = await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText());\n    const removeWhitespace = (text: string) => text.replace(/\\s+/g, \"\");\n    assert.deepEqual(completions.map(removeWhitespace), expected);\n  });\n}\n\nasync function checkNoCompletions(msec = 250) {\n  // Check there are no completions, and they don't appear within a small time.\n  const elem = driver.find(\".ace_autocomplete\");\n  assert.equal(await elem.isPresent() && await elem.isDisplayed(), false);\n  await driver.sleep(msec);\n  assert.equal(await elem.isPresent() && await elem.isDisplayed(), false);\n}\n\nasync function checkError(ruleSet: WebElement, partNum: number, errorRegExp: RegExp | null) {\n  await gu.waitForServer();\n  const elem = ruleSet.find(`.test-rule-part:nth-child(${partNum}) .test-rule-error`);\n  if (errorRegExp) {\n    assert.match(await elem.getText(), errorRegExp);\n  } else {\n    assert.equal(await elem.isPresent(), false);\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/AccessRulesIntro.ts",
    "content": "/**\n * Test the intro screen of access rules, and how rules are first enabled and disabled.\n */\nimport { UserAPI } from \"app/common/UserAPI\";\nimport { assertChanged, assertSaved, enterRulePart, findDefaultRuleSetWait } from \"test/nbrowser/aclTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"AccessRulesIntro\", function() {\n  this.timeout(\"20s\");\n  const cleanup = setupTestSuite();\n  let mainSession: gu.Session;\n  let docId: string;\n  let ownerApi: UserAPI;\n  let editorApi: UserAPI;\n\n  afterEach(() => gu.checkForErrors());\n\n  before(async function() {\n    const editorSession = await gu.session().teamSite.user(\"user2\").login();\n    editorApi = editorSession.createHomeApi();\n\n    mainSession = await gu.session().teamSite.user(\"user1\").login();\n    ownerApi = mainSession.createHomeApi();\n    docId = await mainSession.tempNewDoc(cleanup, \"AccessRulesIntro\", { load: false });\n\n    // Share it with another user.\n    await ownerApi.updateDocPermissions(docId, { users: {\n      [gu.translateUser(\"user2\").email]: \"editors\",\n    } });\n  });\n\n  it(\"shows intro screen when there are no rules\", async function() {\n    await mainSession.loadDoc(`/doc/${docId}`);\n\n    // Open the 'Access rules' page.\n    await driver.findWait(\".test-tools-access-rules\", 1000).click();\n\n    // A loading spinner is OK, wait for it to go away.\n    await driver.wait(async () => !(await driver.find(\".test-access-rules-loading\").isPresent()), 2000);\n\n    // We expect to see an intro screen with an \"Enable\" button and some helpful text.\n    assert.equal(await driver.find(\".test-access-rules-intro\").isPresent(), true);\n    assert.match(await driver.find(\".test-access-rules-intro\").getText(),\n      /Access Rules.*For more granular control/s);\n    assert.match(await driver.find('.test-access-rules-intro a[href*=\"/access-rules\"]').getText(),\n      /Learn more/);\n    const enableButton = driver.find(\".test-enable-access-rules\");\n    assert.equal(await enableButton.isPresent(), true);\n\n    // Go on and click 'Enable' the rules.\n    await enableButton.click();\n\n    // We get a dialog, and can cancel or confirm.\n    assert.match(await driver.findWait(\".test-modal-title\", 200).getText(), /Enable Access Rules/);\n    await driver.findWait(\".test-modal-cancel\", 200).click();         // Cancel\n    await driver.findWait(\".test-enable-access-rules\", 200).click();  // Open again\n    await driver.findWait(\".test-modal-confirm\", 200).click();        // Confirm\n\n    // Now we should see the access rules page.\n    await driver.findWait(\".test-rule-set\", 200);\n\n    // Wait for validity checking to complete.\n    await gu.waitForServer();\n  });\n\n  const getChecked = (elem: WebElement) => elem.find(\".test-rule-special-checkbox\").getAttribute(\"checked\");\n\n  it(\"should show useful special rules by default\", async function() {\n    // Continuing from the previous page, we are on a brand new access rules page; unsaved.\n\n    // We expect to see 3 checkboxes for special rules, one of them isn't yet shown.\n    assert.lengthOf(await driver.findAll(\".test-rule-special\"), 4);\n    assert.equal(await driver.find(\".test-rule-special-SeedRule\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-rule-special-SchemaEdit\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-rule-special-AccessRules\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-rule-special-DocCopies\").isDisplayed(), false);\n\n    // All of them should be unchecked.\n    assert.equal(await getChecked(driver.find(\".test-rule-special-SeedRule\")), null);\n    assert.equal(await getChecked(driver.find(\".test-rule-special-SchemaEdit\")), null);\n    assert.equal(await getChecked(driver.find(\".test-rule-special-AccessRules\")), null);\n    assert.equal(await getChecked(driver.find(\".test-rule-special-DocCopies\")), null);\n\n    // Check that \"Special rules for template\" start collapsed by default, but can be shown.\n    assert.match(await driver.find(\".test-special-rules-templates\").getText(),\n      /Special rules for templates/);\n    assert.equal(await driver.find(\".test-rule-special-FullCopies\").isPresent(), false);\n    await driver.find(\".test-special-rules-templates-expand\").click();\n    assert.equal(await driver.find(\".test-rule-special-FullCopies\").isPresent(), true);\n\n    // Once shown this rule is also unchecked initially.\n    assert.equal(await getChecked(driver.find(\".test-rule-special-FullCopies\")), null);\n\n    // Save the rules (they start out unsaved).\n    await saveRules();\n\n    // Now reload and check that we still see the same thing.\n    await driver.navigate().refresh();\n    await driver.findWait(\".test-rule-set\", 5000);\n    assert.equal(await getChecked(driver.find(\".test-rule-special-SeedRule\")), null);\n    assert.equal(await getChecked(driver.find(\".test-rule-special-SchemaEdit\")), null);\n    assert.equal(await getChecked(driver.find(\".test-rule-special-AccessRules\")), null);\n    assert.equal(await getChecked(driver.find(\".test-rule-special-DocCopies\")), null);\n    await driver.find(\".test-special-rules-templates-expand\").click();\n    assert.equal(await getChecked(driver.find(\".test-rule-special-FullCopies\")), null);\n  });\n\n  it(\"should expand the special rules for templates if any are set\", async function() {\n    // Toggle the \"FullCopies\" rule to ON\n    await gu.scrollIntoView(driver.find(\".test-rule-special-FullCopies .test-rule-special-checkbox\")).click();\n\n    // Save and reload.\n    await saveRules();\n    await driver.navigate().refresh();\n    await driver.findWait(\".test-rule-set\", 5000);\n\n    // Check that the special template rules are expanded and the rule we turned on is visibly on.\n    assert.equal(await driver.find(\".test-rule-special-FullCopies\").isPresent(), true);\n    assert.equal(await getChecked(driver.find(\".test-rule-special-FullCopies\")), \"true\");\n\n    // Undo.\n    await gu.undo();\n\n    // Check that it's off now.\n    if (!await driver.find(\".test-rule-special-FullCopies\").isPresent()) {\n      await driver.find(\".test-special-rules-templates-expand\").click();\n    }\n    assert.equal(await getChecked(driver.find(\".test-rule-special-FullCopies\")), null);\n    await assertSaved();\n  });\n\n  it(\"should show the rule restricting copying when access rules are allowed\", async function() {\n    // Check that DocCopies isn't visible. We hide it because it has no effect when Access Rules\n    // permission is denied.\n    assert.equal(await driver.find(\".test-rule-special-DocCopies\").isDisplayed(), false);\n\n    // Check that initially, once access rules are present, only owner can download a document.\n    await assert.isFulfilled((await ownerApi.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isRejected((await editorApi.getWorkerAPI(docId)).downloadDoc(docId), /Forbidden/);\n\n    // Toggle the \"Access Rules\" permission to ON.\n    assert.equal(await getChecked(driver.find(\".test-rule-special-AccessRules\")), null);\n    await driver.find(\".test-rule-special-AccessRules .test-rule-special-checkbox\").click();\n    assert.equal(await getChecked(driver.find(\".test-rule-special-AccessRules\")), \"true\");\n\n    // Now the DocCopies rule should become visible.\n    assert.equal(await driver.find(\".test-rule-special-DocCopies\").isDisplayed(), true);\n\n    // And it should immediately be checked (the checkbox here is a negative, it represents that\n    // copies and downloads are restricted).\n    assert.equal(await getChecked(driver.find(\".test-rule-special-DocCopies\")), \"true\");\n\n    // Save the changes.\n    await saveRules();\n\n    // Though editors can now see all data AND can see access rules, there is still a restriction\n    // on copies.\n    await assert.isFulfilled((await ownerApi.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isRejected((await editorApi.getWorkerAPI(docId)).downloadDoc(docId), /Forbidden/);\n\n    // Now remove the restriction.\n    await driver.find(\".test-rule-special-DocCopies .test-rule-special-checkbox\").click();\n    assert.equal(await getChecked(driver.find(\".test-rule-special-DocCopies\")), null);\n    await saveRules();\n\n    // Now finally editors can also download the doc.\n    await assert.isFulfilled((await ownerApi.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await editorApi.getWorkerAPI(docId)).downloadDoc(docId));\n\n    // Undo and check that editors cannot download again.\n    await gu.undo();\n    await assert.isFulfilled((await ownerApi.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isRejected((await editorApi.getWorkerAPI(docId)).downloadDoc(docId), /Forbidden/);\n  });\n\n  it(\"should show Disable button if only checkbox rules are shown\", async function() {\n    // We only have checkbox rules; in this case the \"Disable\" button should be shown.\n    assert.equal(await driver.find(\".test-disable-access-rules\").isPresent(), true);\n    assert.equal(await driver.find(\".test-disable-access-rules\").isDisplayed(), true);\n\n    // Add a regular rule. The \"Disable\" button should disappear. The idea is: there are other\n    // \"trash\" icons visible to delete other rules, and deleting them en masse is very risky.\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /Table1/, 500).click();\n    await enterRulePart(findDefaultRuleSetWait(/Table1/), 1, \"True\", { C: \"deny\" });\n\n    // The \"Disable\" button should disappear.\n    assert.equal(await driver.find(\".test-disable-access-rules\").isPresent(), false);\n\n    // Save and check the button is still not shown.\n    await saveRules();\n    assert.equal(await driver.find(\".test-disable-access-rules\").isPresent(), false);\n\n    // Delete the table rule. The \"Disable\" button should appear again.\n    await findDefaultRuleSetWait(/Table1/).find(\".test-rule-remove\").click();\n    assert.equal(await driver.find(\".test-disable-access-rules\").isPresent(), true);\n    assert.equal(await driver.find(\".test-disable-access-rules\").isDisplayed(), true);\n\n    // Save\n    await saveRules();\n    assert.equal(await driver.find(\".test-disable-access-rules\").isDisplayed(), true);\n  });\n\n  it(\"should allow using Disable button to remove all rules after confirmation\", async function() {\n    // As a reminder, when we get here, editors aren't allowed to download the full doc.\n    await assert.isRejected((await editorApi.getWorkerAPI(docId)).downloadDoc(docId), /Forbidden/);\n\n    // Click the \"Disable Access Rules\" button.\n    await driver.find(\".test-disable-access-rules\").click();\n\n    // We should get a dialog, and can cancel or confirm.\n    assert.match(await driver.findWait(\".test-modal-title\", 200).getText(), /Disable Access Rules/);\n    await driver.findWait(\".test-modal-cancel\", 200).click();         // Cancel\n    await driver.findWait(\".test-disable-access-rules\", 200).click();  // Open again\n    await driver.findWait(\".test-modal-confirm\", 200).click();        // Confirm\n    await gu.waitForServer();\n\n    // Check that we end up back on the intro screen.\n    assert.equal(await driver.findWait(\".test-access-rules-intro\", 500).isPresent(), true);\n    assert.match(await driver.find(\".test-access-rules-intro\").getText(),\n      /Access Rules.*For more granular control/s);\n\n    // Last we checked, editors could not download the document in full. With all rules cleared,\n    // full downloads should be allowed to all collaborators again.\n    await assert.isFulfilled((await editorApi.getWorkerAPI(docId)).downloadDoc(docId));\n  });\n});\n\nasync function saveRules() {\n  await assertChanged();\n  await driver.find(\".test-rules-save\").click();\n  await gu.waitForServer();\n  await assertSaved();\n}\n"
  },
  {
    "path": "test/nbrowser/AccessRulesSchemaEdit.ts",
    "content": "/**\n * Test of the UI for the SchemaEdit permission in Granular Access Control.\n */\nimport { TableRecordValue } from \"app/common/DocActions\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport { assertChanged, assertSaved, enterRulePart,\n  findDefaultRuleSet, findTable, getRules, startEditingAccessRules } from \"test/nbrowser/aclTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport pick from \"lodash/pick\";\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"AccessRulesSchemaEdit\", function() {\n  this.timeout(40000);\n  const cleanup = setupTestSuite();\n  let docId: string;\n  let mainSession: gu.Session;\n  let api: UserAPI;\n  let editorApi: UserAPI;\n\n  before(async function() {\n    const editorSession = await gu.session().teamSite.user(\"user2\").login();\n    editorApi = editorSession.createHomeApi();\n\n    // Import a test document we've set up for this.\n    mainSession = await gu.session().teamSite.user(\"user1\").login();\n    docId = await mainSession.tempNewDoc(cleanup, \"ACL-SchemaEdit\", { load: false });\n\n    // Share it with a few users.\n    api = mainSession.createHomeApi();\n    await api.updateDocPermissions(docId, { users: {\n      [gu.translateUser(\"user2\").email]: \"editors\",\n    } });\n    return docId;\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should allow disabling non-owner schemaEdit via checkbox\", async function() {\n    const mainDocApi = api.getDocAPI(docId);\n\n    // Open Access Rules page.\n    await loadAccessRulesPage(mainSession, docId);\n\n    // With the introduction flow, once access rules are shown, the schemaEdit checkbox is\n    // unchecked (editors disallowed, which is the recommended state).\n    assert.equal(await getSchemaEditCheckbox().isSelected(), false);\n\n    // No warning, we should be in a good state.\n    assert.equal(await driver.find(\".test-rule-schema-edit-warning\").isPresent(), false);\n\n    // Check that default rules don't show the schemaEdit bit.\n    assert.deepEqual((await getRules(findTable(\"*\")))[0],\n      { res: \"All\", formula: \"user.Access in [EDITOR, OWNER]\", perm: \"+R+U+C+D\" });\n\n    // Check that an editor CAN make structure changes: behavior is unchanged FOR NOW because we\n    // haven't saved yet.\n    await assert.isFulfilled(editorApi.applyUserActions(docId, [[\"RenameTable\", \"Table1\", \"Renamed1\"]]));\n\n    // Save the rules as recommended.\n    await saveRules();\n\n    // Check that an editor CANNOT make structure changes (default behavior changed by enabling // rules).\n    await assert.isRejected(editorApi.applyUserActions(docId, [[\"RenameTable\", \"Renamed1\", \"Table1\"]]),\n      /Blocked by table structure access rules/);\n\n    // Check that after reload, the box is still unchecked and still no warning.\n    await reloadAccessRulesPage();\n    assert.equal(await getSchemaEditCheckbox().isSelected(), false);\n    assert.equal(await driver.find(\".test-rule-schema-edit-warning\").isPresent(), false);\n    assert.equal(await getSchemaEditRuleSet().isPresent(), false);\n\n    // Check what the rules are on the default resource.\n    const rules = await mainDocApi.getRecords(\"_grist_ACLRules\");\n    const defaultResourceRef = (await getDefaultResourceRec(api, docId))!.id;\n    assert.deepEqual(rules.map(r => pick(r.fields, \"resource\", \"aclFormula\", \"permissionsText\")),\n      [{ resource: defaultResourceRef, aclFormula: \"user.Access != OWNER\", permissionsText: \"-S\" }]);\n\n    // Check the box.\n    await getSchemaEditCheckbox().click();\n    assert.equal(await getSchemaEditCheckbox().isSelected(), true);\n\n    // Check that a warning is present.\n    assert.equal(await driver.find(\".test-rule-schema-edit-warning\").isDisplayed(), true);\n    await assertChanged();\n\n    // Save the changes. We happen to end up back on the intro page, since access rules now look\n    // like they've never been enabled. (This isn't a very intentional behavior but acceptable.)\n    await getSaveButton().click();\n    await gu.waitForServer();\n\n    // Now an editor CAN make structure changes.\n    await assert.isFulfilled(editorApi.applyUserActions(docId, [[\"RenameTable\", \"Renamed1\", \"Table1\"]]));\n\n    // Check there are no rules left.\n    assert.lengthOf(await mainDocApi.getRecords(\"_grist_ACLRules\"), 0);\n  });\n\n  it(\"should allow dismissing the warning\", async function() {\n    const mainDocApi = api.getDocAPI(docId);\n    await loadAccessRulesPage(mainSession, docId);\n\n    // Check we are in the expected default state: checkbox is UNchecked and no warning.\n    assert.equal(await getSchemaEditCheckbox().isSelected(), false);\n    assert.equal(await driver.find(\".test-rule-schema-edit-warning\").isPresent(), false);\n\n    // Enable editors to make structure changes.\n    await getSchemaEditCheckbox().click();\n    assert.equal(await driver.findWait(\".test-rule-schema-edit-warning\", 200).isDisplayed(), true);\n\n    // We show a \"Dismiss\" link on the warning. This isn't really needed for the new flow,\n    // but is still appropriate for those who created access rules previously.\n    assert.equal(await driver.findContent(\".test-rule-schema-edit-warning a\", /Dismiss/).isPresent(), true);\n\n    // Click \"Dismiss\", and save.\n    await driver.findContent(\".test-rule-schema-edit-warning a\", /Dismiss/).click();\n    await assertChanged();\n    await saveRules();\n\n    // Check that an editor can make structure changes.\n    await assert.isFulfilled(editorApi.applyUserActions(docId, [[\"RenameTable\", \"Table1\", \"Renamed2\"]]));\n\n    // Check that after reload, the box is checked and warning is gone.\n    await reloadAccessRulesPage();\n    assert.equal(await getSchemaEditCheckbox().isSelected(), true);\n    assert.equal(await driver.find(\".test-rule-schema-edit-warning\").isPresent(), false);\n    assert.equal(await getSchemaEditRuleSet().isPresent(), false);\n\n    // Check what the rules are on the default resource.\n    const rules = await mainDocApi.getRecords(\"_grist_ACLRules\");\n    const defaultResourceRef = (await getDefaultResourceRec(api, docId))!.id;\n    assert.deepEqual(rules.map(r => pick(r.fields, \"resource\", \"aclFormula\", \"permissionsText\")),\n      [{ resource: defaultResourceRef, aclFormula: \"user.Access == EDITOR\", permissionsText: \"+S\" }]);\n\n    // Revert the rule change; wait for page to reload.\n    await gu.undo();\n\n    // We should be back to seeing an intro to enable access rules. Check that now. (Note that\n    // reverting to the intro screen is fine here, but a different behavior would be fine too.)\n    assert.equal(await driver.findWait(\".test-enable-access-rules\", 500).isPresent(), true);\n\n    // Check that there are no more access rules on the document.\n    assert.lengthOf(await mainDocApi.getRecords(\"_grist_ACLRules\"), 0);\n\n    // Let's revert also the table rename, to keep test cases independent.\n    await assert.isFulfilled(editorApi.applyUserActions(docId, [[\"RenameTable\", \"Renamed2\", \"Table1\"]]));\n  });\n\n  it(\"should handle existing rules that mix schemaEdit and other permissions\", async function() {\n    const editorEmailAddr = gu.translateUser(\"user2\").email;\n    const customAclFormula = `user.Email == \"${editorEmailAddr}\"`;\n\n    // Use the API to add a mixed rule, with both a 'schemaEdit' and a 'delete' permission.\n    const defaultResourceRef = (await getDefaultResourceRec(api, docId))!.id;\n    await api.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: defaultResourceRef,\n        aclFormula: customAclFormula,\n        permissionsText: \"-DS\",\n        memo: \"Memo MIXED\",\n      }],\n    ]);\n\n    // Load the Access Rules page.\n    await loadAccessRulesPage(mainSession, docId);\n\n    // The new rule should be visible in both the default section, and in SchemaEdit section\n    // (which should be expanded).\n    assert.deepEqual((await getRules(findTable(\"*\")))[0],\n      { formula: customAclFormula, perm: \"-D\", memo: \"Memo MIXED\", res: \"All\" });\n\n    assert.equal(await getSchemaEditRuleSet().isDisplayed(), true);\n    assert.deepEqual((await getRules(driver.find(\".test-rule-special-SchemaEdit\")))[0],\n      { formula: customAclFormula, perm: \"-S\", memo: \"Memo MIXED\" });\n\n    // Check that the checkbox is disabled (since non-standard state).\n    assert.equal(await getSchemaEditCheckbox().getAttribute(\"disabled\"), \"true\");\n\n    // Check that the rule works.\n    let error = await editorApi.applyUserActions(docId, [[\"RenameTable\", \"Table1\", \"Renamed3\"]])\n      .then(() => null).catch(err => err);\n    assert.match(error?.message, /Blocked by table structure access rules/);\n    assert.deepInclude(error?.details, { memos: [\"Memo MIXED\"] });\n\n    // Change the memos on both copies of the rule.\n    await enterRulePart(findDefaultRuleSet(\"*\"), 1, null, {}, \"Memo DDD\");\n    await enterRulePart(getSchemaEditRuleSet(), 1, null, {}, \"Memo SSS\");\n\n    // Save.\n    await saveRules();\n\n    // The rules should look as before, only the memo is different.\n    assert.deepEqual((await getRules(findTable(\"*\")))[0],\n      { formula: customAclFormula, perm: \"-D\", memo: \"Memo DDD\", res: \"All\" });\n    assert.deepEqual((await getRules(driver.find(\".test-rule-special-SchemaEdit\")))[0],\n      { formula: customAclFormula, perm: \"-S\", memo: \"Memo SSS\" });\n\n    // Check that the changed rule works.\n    error = await editorApi.applyUserActions(docId, [[\"RenameTable\", \"Table1\", \"Renamed3\"]])\n      .then(() => null).catch(err => err);\n    assert.match(error?.message, /Blocked by table structure access rules/);\n    assert.deepInclude(error?.details, { memos: [\"Memo SSS\"] });\n\n    // Check what the rules are on the default resource.\n    const mainDocApi = api.getDocAPI(docId);\n    const rules = await mainDocApi.getRecords(\"_grist_ACLRules\");\n    assert.sameDeepMembers(rules.map(r => pick(r.fields, \"resource\", \"aclFormula\", \"permissionsText\")), [\n      { resource: defaultResourceRef, aclFormula: customAclFormula, permissionsText: \"-S\" },\n      { resource: defaultResourceRef, aclFormula: customAclFormula, permissionsText: \"-D\" },\n    ]);\n  });\n});\n\nfunction getSchemaEditCheckbox() {\n  return driver.find(\".test-rule-special-SchemaEdit input[type=checkbox]\");\n}\nfunction getSchemaEditRuleSet() {\n  return driver.find(\".test-rule-special-SchemaEdit .test-rule-set\");\n}\nfunction getSaveButton() {\n  return driver.find(\".test-rules-save\");\n}\nasync function saveRules() {\n  await getSaveButton().click();\n  await gu.waitForServer();\n  await assertSaved();\n}\nasync function loadAccessRulesPage(session: gu.Session, docId: string) {\n  await session.loadRelPath(`/doc/${docId}/p/acl`);\n  await startEditingAccessRules();\n}\nasync function reloadAccessRulesPage() {\n  await driver.navigate().refresh();\n  await driver.findWait(\".test-rule-set\", 5000);\n}\nasync function getDefaultResourceRec(api: UserAPI, docId: string): Promise<TableRecordValue | undefined> {\n  const records = await api.getDocAPI(docId).getRecords(\"_grist_ACLResources\", { filters: { tableId: [\"*\"] } });\n  return records[0];\n}\n"
  },
  {
    "path": "test/nbrowser/AccessibilityModal.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, By, driver, Key } from \"mocha-webdriver\";\n\nconst ACCESSIBILITY_SHORTCUT_KEY = \"F4\";\nconst ACCESSIBILITY_SHORTCUT_LABEL = \"Accessibility\";\n\nconst getA11yShortcut = async () => {\n  return await driver.find(\".test-accessibility-shortcut\");\n};\n\nconst assertShowsA11yShortcut = async () => {\n  const shortcut = await getA11yShortcut();\n  assert.isTrue(await shortcut.isDisplayed(), \"Accessibility shortcut button should be visible\");\n\n  assert.isTrue(\n    (await shortcut.getText()).includes(ACCESSIBILITY_SHORTCUT_LABEL),\n    \"Accessibility shortcut button should contain a text label\",\n  );\n\n  const keys = await shortcut.findContent(\".test-accessibility-shortcut-keys\", new RegExp(ACCESSIBILITY_SHORTCUT_KEY));\n  assert.isTrue(await keys.isDisplayed(), \"Accessibility shortcut button should show its keyboard shortcut\");\n};\n\nconst assertA11yModalShown = async () => {\n  const modal = await driver.findWait(\".test-modal-dialog\", 1000);\n  await modal.findContent(\".test-accessibility-modal-title\", /Accessibility/);\n};\n\nconst assertA11yModalClosed = async () => {\n  await gu.waitToPass(async () => {\n    assert.isFalse(await driver.find(\".test-modal-dialog\").isPresent());\n  });\n};\n\ndescribe(\"AccessibilityModal\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n\n  let session: gu.Session;\n\n  it(\"should always show a shortcut to open the accessibility modal\", async function() {\n    // Sign out and check homepage\n    session = await gu.session().personalSite.anon.login();\n    await session.loadDocMenu(\"/\");\n    await assertShowsA11yShortcut();\n\n    // Sign in and check homepage\n    session = await gu.session().teamSite.user(\"user1\").login();\n    await session.loadDocMenu(\"/\");\n    await assertShowsA11yShortcut();\n\n    // Go to the profile page and check:\n    // this page has a narrow left panel, make sure the button (and its keyboard shortcut info)\n    // is also visible in that case.\n    await gu.openProfileSettingsPage();\n    await assertShowsA11yShortcut();\n  });\n\n  it(\"should open the accessibility modal on click\", async function() {\n    const shortcut = await getA11yShortcut();\n    await shortcut.click();\n    await assertA11yModalShown();\n  });\n\n  it(\"should show the main sections in the modal\", async function() {\n    await driver.findContent(\".test-modal-dialog\", /High contrast theme/);\n    await driver.findContent(\".test-modal-dialog\", /Keyboard navigation/);\n  });\n\n  it(\"should show a button to switch to high contrast theme\", async function() {\n    const htmlEl = await driver.findElement(By.css(\"html[data-grist-theme]\"));\n\n    // we should first be on the default light theme\n    assert.equal(await htmlEl.getAttribute(\"data-grist-theme\"), \"GristLight\");\n\n    // a high contrast button should be there\n    const highContrastThemeButton = await driver.find(\".test-accessibility-modal-high-contrast-theme-button\");\n\n    // click it, and we should now be on the high contrast theme\n    await highContrastThemeButton.click();\n    await gu.waitForServer();\n    await driver.findContent(\".test-modal-dialog\", /You are currently using the high contrast theme/);\n    assert.equal(await htmlEl.getAttribute(\"data-grist-theme\"), \"HighContrastLight\");\n  });\n\n  it(\"should close the modal when clicking the close button\", async function() {\n    await driver.find(\".test-accessibility-modal-confirm\").click();\n    await assertA11yModalClosed();\n  });\n\n  it(\"should open the modal when pressing F4\", async function() {\n    await gu.sendKeys(Key.F4);\n    await assertA11yModalShown();\n  });\n\n  it(\"should close the modal when pressing escape\", async function() {\n    await gu.sendKeys(Key.ESCAPE);\n    await assertA11yModalClosed();\n\n    // cleanup: reset the theme configuration\n    await gu.setGristTheme({ themeName: \"GristLight\", syncWithOS: true });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ActionLog.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, WebElement, WebElementPromise } from \"mocha-webdriver\";\n\ndescribe(\"ActionLog\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n  let docId: string;\n\n  afterEach(() => gu.checkForErrors());\n\n  async function getActionUndoState(limit: number): Promise<string[]> {\n    const state = await driver.findAll(\".action_log .action_log_item\", el => el.getAttribute(\"class\"));\n    return state.slice(0, limit).map(s => s.replace(/action_log_item/, \"\").trim());\n  }\n\n  function getActionLogItems(): Promise<WebElement[]> {\n    // Use a fancy negation of style selector to exclude hidden log entries.\n    return driver.findAll(\".action_log .action_log_item:not([style*='display: none'])\");\n  }\n\n  function getActionLogItem(index: number): WebElementPromise {\n    return new WebElementPromise(driver, getActionLogItems().then(elems => elems[index]));\n  }\n\n  before(async function() {\n    session = await gu.session().user(\"user1\").login();\n    docId = (await session.tempDoc(cleanup, \"Hello.grist\")).id;\n    await gu.dismissWelcomeTourIfNeeded();\n  });\n\n  after(async function() {\n    // If were are debugging the browser won't be reloaded, so we need to close the right panel.\n    if (process.env.NO_CLEANUP) {\n      await driver.find(\".test-right-tool-close\").click();\n    }\n  });\n\n  it(\"should block history if access is not full\", async function() {\n    const api = session.createHomeApi();\n    const result = await api.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"True\", permissionsText: \"-R\",\n      }],\n    ]);\n\n    // Open the action-log tab.\n    await driver.findWait(\".test-tools-log\", 1000).click();\n    await gu.waitToPass(() =>   // Click might not work while panel is sliding out to open.\n      driver.findContentWait(\".test-doc-history-tabs .test-select-button\", \"Activity\", 500).click());\n\n    // Make sure history is blocked.\n    await driver.findContentWait(\"p\", /History blocked/, 1000);\n\n    // Remove rule.\n    await api.applyUserActions(docId, [\n      [\"RemoveRecord\", \"_grist_ACLRules\", result.retValues[1]],\n    ]);\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n  });\n\n  it(\"should attribute websocket actions to user\", async function() {\n    // Open the action-log tab.\n    await driver.findWait(\".test-tools-log\", 1000).click();\n    await gu.waitToPass(() =>   // Click might not work while panel is sliding out to open.\n      driver.findContentWait(\".test-doc-history-tabs .test-select-button\", \"Activity\", 500).click());\n\n    // Perform an actions normally, i.e. it gets sent via the websocket.\n    await gu.enterGridRows({ rowNum: 1, col: 0 }, [[\"foo1\"]]);\n\n    // Check that we see the correct user email, and the correct action.\n    const item = await driver.find(\".action_log .action_log_item\");\n    assert.equal(await item.find(\".action_log_cell_add\").getText(), \"foo1\");\n    assert.equal(await item.find(\".action_info_user\").getText(), gu.translateUser(\"user1\").email);\n    await gu.undo();\n  });\n\n  it(\"should attribute api actions to user\", async function() {\n    // Perform an action via the API.\n    const api = session.createHomeApi().getDocAPI(docId);\n    await api.updateRows(\"Table1\", { id: [1], A: [\"bar2\"] });\n\n    // Check that we see the correct user email, and the correct action.\n    const item = await driver.find(\".action_log .action_log_item\");\n    assert.equal(await item.find(\".action_log_cell_add\").getText(), \"bar2\");\n    assert.equal(await item.find(\".action_info_user\").getText(), gu.translateUser(\"user1\").email);\n  });\n\n  it(\"should cross out undone actions\", async function() {\n    // Perform some actions and check that they all appear as default.\n    await gu.enterGridRows({ rowNum: 1, col: 0 }, [[\"a\"], [\"b\"], [\"c\"], [\"d\"]]);\n\n    assert.deepEqual(await getActionUndoState(4), [\"default\", \"default\", \"default\", \"default\"]);\n\n    // Undo and check that the most recent action is crossed out.\n    await gu.undo();\n    assert.deepEqual(await getActionUndoState(4), [\"undone\", \"default\", \"default\", \"default\"]);\n\n    await gu.undo(2);\n    assert.deepEqual(await getActionUndoState(4), [\"undone\", \"undone\", \"undone\", \"default\"]);\n    await gu.redo(2);\n    assert.deepEqual(await getActionUndoState(4), [\"undone\", \"default\", \"default\", \"default\"]);\n  });\n\n  it(\"should indicate that actions that cannot be redone are buried\", async function() {\n    // Add an item after the undo actions and check that they get buried.\n    await gu.getCell({ rowNum: 1, col: 0 }).click();\n    await gu.enterCell(\"e\");\n    assert.deepEqual(await getActionUndoState(4), [\"default\", \"buried\", \"default\", \"default\"]);\n\n    // Check that undos skip the buried actions.\n    await gu.undo(2);\n    assert.deepEqual(await getActionUndoState(4), [\"undone\", \"buried\", \"undone\", \"default\"]);\n\n    // Check that burying around already buried actions works.\n    await gu.enterCell(\"f\");\n    await gu.waitForServer();\n    assert.deepEqual(await getActionUndoState(5), [\"default\", \"buried\", \"buried\", \"buried\", \"default\"]);\n  });\n\n  it(\"should properly rebuild the action log on refresh\", async function() {\n    // Undo past buried actions to add complexity to the current state of the log\n    // and refresh.\n    await gu.undo(2);\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n    // Dismiss forms announcement popup, if present.\n    await gu.dismissBehavioralPrompts();\n    // refreshing browser will restore position on last cell\n    // switch active cell to the first cell in the first row\n    await gu.getCell(0, 1).click();\n    await driver.findWait(\".test-tools-log\", 1000).click();\n    await driver.findContentWait(\".test-doc-history-tabs .test-select-button\", \"Activity\", 500).click();\n    await gu.waitForServer();\n    assert.deepEqual(await getActionUndoState(6), [\"undone\", \"buried\", \"buried\", \"buried\", \"undone\", \"default\"]);\n  });\n\n  it(\"should indicate to the user when they cannot undo or redo\", async function() {\n    assert.equal(await driver.find(\".test-undo\").matches(\"[class*=-disabled]\"), false);\n    assert.equal(await driver.find(\".test-redo\").matches(\"[class*=-disabled]\"), false);\n\n    // Undo and check that undo button gets disabled.\n    await gu.undo();\n    assert.equal(await driver.find(\".test-undo\").matches(\"[class*=-disabled]\"), true);\n    assert.equal(await driver.find(\".test-redo\").matches(\"[class*=-disabled]\"), false);\n\n    // Redo to the top of the log and check that redo button gets disabled.\n    await gu.redo(3);\n    assert.equal(await driver.find(\".test-undo\").matches(\"[class*=-disabled]\"), false);\n    assert.equal(await driver.find(\".test-redo\").matches(\"[class*=-disabled]\"), true);\n  });\n\n  it(\"should show clickable tabular diffs\", async function() {\n    const item0 = await getActionLogItem(0);\n    assert.equal(await item0.find(\"table caption\").getText(), \"Table1 >\");\n    assert.equal(await item0.find(\"table th:nth-child(2)\").getText(), \"A\");\n    assert.equal(await item0.find(\"table td:nth-child(2)\").getText(), \"f\");\n    assert.equal(await gu.getActiveCell().getText(), \"a\");\n    await item0.find(\"table td:nth-child(2)\").click();\n    assert.equal(await gu.getActiveCell().getText(), \"f\");\n  });\n\n  it(\"clickable tabular diffs should work across renames\", async function() {\n    // Add another table just to mix things up a bit.\n    await gu.addNewTable();\n    // Rename our old table.\n    await gu.renameTable(\"Table1\", \"Table1Renamed\");\n    await gu.getPageItem(\"Table1Renamed\").click();\n    await gu.renameColumn({ col: \"A\" }, \"ARenamed\");\n\n    // Check that it's still usable. (It doesn't reflect the new names in the content of prior\n    // actions though -- e.g. the action below still mentions 'A' for column name -- and it's\n    // unclear if it should.)\n    const item2 = await getActionLogItem(2);\n    assert.equal(await item2.find(\"table caption\").getText(), \"Table1 >\");\n    assert.equal(await item2.find(\"table td:nth-child(2)\").getText(), \"f\");\n    await gu.getCell({ rowNum: 1, col: 0 }).click();\n    assert.notEqual(await gu.getActiveCell().getText(), \"f\");\n    await item2.find(\"table td:nth-child(2)\").click();\n    assert.equal(await gu.getActiveCell().getText(), \"f\");\n\n    // Delete Table1Renamed.\n    await gu.removeTable(\"Table1Renamed\", { dismissTips: true });\n    await driver.findContent(\".action_log label\", /All tables/).find(\"input\").click();\n\n    const item4 = await getActionLogItem(4);\n    await gu.scrollIntoView(item4);\n    await item4.find(\"table td:nth-child(2)\").click();\n    assert.include(await driver.findWait(\".test-notifier-toast-wrapper\", 1000).getText(),\n      \"Table1Renamed was subsequently removed\");\n    await driver.find(\".test-notifier-toast-wrapper .test-notifier-toast-close\").click();\n    await driver.findContent(\".action_log label\", /All tables/).find(\"input\").click();\n  });\n\n  it(\"should filter cell changes and renames by table\", async function() {\n    // Have Table2, now add some more\n    // We are at Raw Data view now (since we deleted a table).\n    assert.match(await driver.getCurrentUrl(), /p\\/data$/);\n    await gu.getPageItem(\"Table2\").click();\n    await gu.enterGridRows({ rowNum: 1, col: 0 }, [[\"2\"]]);\n    await gu.addNewTable();  // Table1\n    await gu.enterGridRows({ rowNum: 1, col: 0 }, [[\"1\"]]);\n    await gu.addNewTable();  // Table3\n    await gu.enterGridRows({ rowNum: 1, col: 0 }, [[\"3\"]]);\n    await gu.getPageItem(\"Table1\").click();\n\n    assert.lengthOf(await getActionLogItems(), 2);\n\n    assert.equal(await getActionLogItem(0).find(\"table:not([style*='display: none']) caption\").getText(), \"Table1 >\");\n    assert.equal(await getActionLogItem(1).find(\".action_log_rename\").getText(), \"Add Table1\");\n    await gu.renameTable(\"Table1\", \"Table1Renamed\");\n    assert.equal(await getActionLogItem(0).find(\".action_log_rename\").getText(),\n      \"Rename Table1 to Table1Renamed\");\n\n    await gu.renameColumn({ col: \"A\" }, \"ARenamed\");\n    assert.equal(await getActionLogItem(0).find(\".action_log_rename\").getText(),\n      \"Rename Table1Renamed.A to ARenamed\");\n    await gu.getPageItem(\"Table2\").click();\n    assert.equal(await getActionLogItem(0).find(\"table:not([style*='display: none']) caption\").getText(), \"Table2 >\");\n    await gu.getPageItem(\"Table3\").click();\n    assert.equal(await getActionLogItem(0).find(\"table:not([style*='display: none']) caption\").getText(), \"Table3 >\");\n\n    // Now show all tables and make sure the result is a longer (visible) log.\n    const filteredCount = (await getActionLogItems()).length;\n    await driver.findContent(\".action_log label\", /All tables/).find(\"input\").click();\n    const fullCount = (await getActionLogItems()).length;\n    assert.isAbove(fullCount, filteredCount);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ActiveUserList.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport { driver, Key } from \"mocha-webdriver\";\n\ninterface Window {\n  user: gu.TestUser,\n  handle: string;\n}\n\nconst UserOwner: gu.TestUser = \"user1\";\nconst User2: gu.TestUser = \"user2\";\nconst User3: gu.TestUser = \"user3\";\nconst USER_PRESENCE_MAX_USERS = 6;\n\ndescribe(\"ActiveUserList\", async function() {\n  this.timeout(\"120s\");\n  let envSnapshot: EnvironmentSnapshot;\n  let extraWindows: Window[] = [];\n\n  // Needs to be registered before 'setupTestSuite' to ensure windows are closed before its afterAll hook\n  after(async () => {\n    for (const window of extraWindows) {\n      await closeWindow(window);\n    }\n    // Makes sure the correct window is selected for the cleanup scripts in setupTestSuite\n    await switchToWindow(mainWindow);\n    envSnapshot.restore();\n  });\n\n  const cleanup = setupTestSuite();\n  let mainWindow: Window;\n  let docId: string;\n  let ownerSession: gu.Session;\n  let ownerApi: UserAPI;\n  const windowsByUser = new Map<gu.TestUser, Window[]>();\n\n  before(async () => {\n    envSnapshot = new EnvironmentSnapshot();\n    process.env.GRIST_USER_PRESENCE_MAX_USERS = USER_PRESENCE_MAX_USERS.toFixed(0);\n    // Allows the same user to have many user presence sessions - needed to make enough icons show.\n    process.env.GRIST_USER_PRESENCE_ICON_PER_TAB = \"true\";\n    await server.restart();\n\n    mainWindow = {\n      user: UserOwner,\n      handle: await driver.getWindowHandle(),\n    };\n\n    ownerSession = await gu.session().user(UserOwner).teamSite.login();\n    ownerApi = ownerSession.createHomeApi();\n\n    docId = await ownerSession.tempNewDoc(cleanup, \"ActiveUserList\");\n    await ownerApi.updateDocPermissions(docId, {\n      users: {\n        [gu.translateUser(User2).email]: \"editors\",\n        [gu.translateUser(User3).email]: \"viewers\",\n      },\n    });\n\n    await openDocWindowWithUser(docId, User2);\n    await openDocWindowWithUser(docId, User3);\n    await switchToWindow(mainWindow);\n  });\n\n  it(\"shows other active users\", async function() {\n    // Wait ensures presence icons have time to load\n    await gu.waitToPass(async () => {\n      const userIcons = await driver.findAll(\".test-aul-container .test-aul-user-icon\");\n      assert.equal(userIcons.length, 2);\n    }, 5000);\n  });\n\n  it(\"shows name and email on hover\", async function() {\n    await driver.find(\".test-aul-container .test-aul-user-icon\").mouseMove();\n    const tooltipText = await driver.findWait(\".test-tooltip\", 1000).getText();\n    const user = gu.translateUser(User3);\n    assert.include(tooltipText, user.name, \"name not in tooltip\");\n    assert.include(tooltipText, user.email, \"email not in tooltip\");\n  });\n\n  it(\"does not show a remaining users icon at 4 users\", async function() {\n    const desiredUsers = 4;\n    const windowsToOpen = desiredUsers - extraWindows.length;\n    for (let i = 0; i < windowsToOpen; i++) {\n      await openDocWindowWithUser(docId, User3);\n    }\n    await switchToWindow(mainWindow);\n    await gu.waitToPass(async () => {\n      const userIcons = await driver.findAll(\".test-aul-container .test-aul-user-icon\");\n      assert.equal(userIcons.length, 4);\n      assert.isFalse(await driver.find(\".test-aul-all-users-button\").isPresent());\n    }, 5000);\n  });\n\n  it(\"shows a remaining users icon at 5 users\", async function() {\n    const desiredUsers = 5;\n    const windowsToOpen = desiredUsers - extraWindows.length;\n    for (let i = 0; i < windowsToOpen; i++) {\n      await openDocWindowWithUser(docId, User3);\n    }\n    await switchToWindow(mainWindow);\n    await gu.waitToPass(async () => {\n      const userIcons = await driver.findAll(\".test-aul-container .test-aul-user-icon\");\n      assert.equal(userIcons.length, 3);\n      assert.equal(await driver.find(\".test-aul-all-users-button\").getText(), \"+2\");\n    }, 5000);\n  });\n\n  it(\"shows a list of all users when button is clicked\", async function() {\n    await driver.find(\".test-aul-all-users-button\").click();\n    const menuItemTexts = await gu.findOpenMenuAllItems(\n      \".test-aul-user-name\", async item => item.getText(),\n    );\n    assert.equal(menuItemTexts.length, 5, \"wrong number of users in user list\");\n    // There should be several copies of Kiwi here, but I don't think counting them improves anything\n    assert.includeMembers(menuItemTexts, [gu.translateUser(User2).name, gu.translateUser(User3).name]);\n\n    let iconVisibility = await driver.findAll(\".test-aul-container .test-aul-user-icon\", el => el.isDisplayed());\n    assert.isTrue(iconVisibility.every(visible => visible === false));\n    await gu.sendKeys(Key.ESCAPE);\n    iconVisibility = await driver.findAll(\".test-aul-container .test-aul-user-icon\", el => el.isDisplayed());\n    assert.isTrue(iconVisibility.every(visible => visible === true));\n  });\n\n  it(\"keeps the user list open when a new user appears\", async function() {\n    await driver.find(\".test-aul-all-users-button\").click();\n    const getMenuItems = async () =>  await gu.findOpenMenuAllItems(\n      \".test-aul-user-name\", async item => item,\n    );\n    await driver.switchTo().window(mainWindow.handle);\n    const currentMenuItemCount = (await getMenuItems()).length;\n    assert(currentMenuItemCount > 0, \"menu does not exist\");\n\n    await openDocWindowWithUser(docId, User3);\n    await driver.switchTo().window(mainWindow.handle);\n\n    await gu.waitToPass(async () => {\n      const menuItems = await getMenuItems();\n      assert.equal(menuItems.length, currentMenuItemCount + 1, \"incorrect number of users in list\");\n    }, 35000);\n\n    await driver.sleep(5000);\n  });\n\n  it(\"enforces max users displayed\", async function() {\n    // Open enough windows to exceed USER_PRESENCE_MAX_USERS\n    const windowsToOpen = (USER_PRESENCE_MAX_USERS - extraWindows.length) + 3;\n    for (let i = 0; i < windowsToOpen; i++) {\n      await openDocWindowWithUser(docId, User3);\n    }\n    await driver.switchTo().window(mainWindow.handle);\n    const menuItems = await gu.findOpenMenuAllItems(\".test-aul-user-name\", async item => item);\n    assert.equal(menuItems.length, USER_PRESENCE_MAX_USERS, \"max users not enforced\");\n  });\n\n  async function openDocWindowWithUser(docId: string, user: gu.TestUser): Promise<Window> {\n    await driver.switchTo().newWindow(\"tab\");\n    const session = await gu.session().user(user).teamSite.addLogin();\n    await session.loadDoc(`/${docId}`);\n    const window = {\n      user,\n      handle: await driver.getWindowHandle(),\n    };\n    let windowList = windowsByUser.get(user);\n    if (!windowList) {\n      windowList = [];\n      windowsByUser.set(user, windowList);\n    }\n    windowList.push(window);\n    extraWindows.push(window);\n    return window;\n  }\n\n  async function closeWindow(window: Window) {\n    const currentWindow = await driver.getWindowHandle();\n    await driver.switchTo().window(window.handle);\n    await driver.close();\n    // Remove window from window lists\n    const userWindowList = windowsByUser.get(window.user) ?? [];\n    windowsByUser.set(window.user, userWindowList.filter(w => w !== window));\n    extraWindows = extraWindows.filter(w => w !== window);\n\n    if (currentWindow !== window.handle) {\n      await driver.switchTo().window(currentWindow);\n    } else {\n      await driver.switchTo().window(mainWindow.handle);\n    }\n  }\n});\n\nasync function switchToWindow(window: Window): Promise<void> {\n  await driver.switchTo().window(window.handle);\n}\n"
  },
  {
    "path": "test/nbrowser/AdminPanel.ts",
    "content": "import { TelemetryLevel } from \"app/common/Telemetry\";\nimport { currentVersion, isEnabled, toggleItem, withExpandedItem } from \"test/nbrowser/AdminPanelTools\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { FakeUpdateServer, startFakeUpdateServer } from \"test/server/customUtil\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"AdminPanel\", function() {\n  this.timeout(300000);\n  setupTestSuite();\n\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  let session: gu.Session;\n  let fakeServer: FakeUpdateServer;\n\n  afterEach(() => gu.checkForErrors());\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = \"core\";\n    process.env.GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING = \"true\";\n    // Set admin email, but make it non-canonical casing as an extra test.\n    process.env.GRIST_DEFAULT_EMAIL = gu.session().email.toUpperCase();\n    fakeServer = await startFakeUpdateServer();\n    process.env.GRIST_TEST_VERSION_CHECK_URL = `${fakeServer.url()}/version`;\n    await server.restart(true);\n  });\n\n  after(async function() {\n    await fakeServer.close();\n    oldEnv.restore();\n    await server.restart(true);\n  });\n\n  it(\"should show an explanation to non-managers\", async function() {\n    session = await gu.session().user(\"user2\").personalSite.login();\n    await session.loadDocMenu(\"/\");\n\n    await gu.openAccountMenu();\n    assert.equal(await driver.find(\".test-usermenu-admin-panel\").isPresent(), false);\n    await driver.sendKeys(Key.ESCAPE);\n    assert.equal(await driver.find(\".test-dm-admin-panel\").isPresent(), false);\n\n    // Try loading the URL directly.\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    assert.equal(await driver.find(\".test-admin-panel\").isDisplayed(), true);\n    assert.match(await driver.find(\".test-admin-panel\").getText(), /Administrator Panel Unavailable/);\n  });\n\n  it(\"should be shown to managers\", async function() {\n    session = await gu.session().personalSite.login();\n    await session.loadDocMenu(\"/\");\n    assert.equal(await driver.find(\".test-dm-admin-panel\").isDisplayed(), true);\n    assert.match(await driver.find(\".test-dm-admin-panel\").getAttribute(\"href\"), /\\/admin$/);\n    await gu.openAccountMenu();\n    assert.equal(await driver.find(\".test-usermenu-admin-panel\").isDisplayed(), true);\n    assert.match(await driver.find(\".test-usermenu-admin-panel\").getAttribute(\"href\"), /\\/admin$/);\n    await driver.find(\".test-usermenu-admin-panel\").click();\n    assert.equal(await gu.waitForAdminPanel().isDisplayed(), true);\n  });\n\n  it(\"should include support-grist section\", async function() {\n    assert.match(\n      await driver.findWait(\".test-admin-panel-item-sponsor\", 3000).getText(),\n      /Support Grist Labs on GitHub/,\n    );\n    await withExpandedItem(\"sponsor\", async () => {\n      const button = await driver.find(\".test-support-grist-page-sponsorship-section\");\n      assert.equal(await button.isDisplayed(), true);\n      assert.match(await button.getText(), /You can support Grist open-source/);\n    });\n  });\n\n  it(\"supports opting in to telemetry from the page\", async function() {\n    await assertTelemetryLevel(\"off\");\n\n    let toggle = driver.find(\".test-admin-panel-item-value-telemetry .test-toggle-switch\");\n    assert.equal(await isEnabled(toggle), false);\n\n    await withExpandedItem(\"telemetry\", async () => {\n      assert.isFalse(await driver.find(\".test-support-grist-page-telemetry-section-message\").isPresent());\n      await driver.findContentWait(\n        \".test-support-grist-page-telemetry-section button\", /Opt in to Telemetry/, 2000).click();\n      await driver.findContentWait(\".test-support-grist-page-telemetry-section button\", /Opt out of Telemetry/, 2000);\n      assert.equal(\n        await driver.find(\".test-support-grist-page-telemetry-section-message\").getText(),\n        \"You have opted in to telemetry. Thank you! 🙏\",\n      );\n      assert.equal(await isEnabled(toggle), true);\n    });\n\n    // Check it's still on after collapsing.\n    assert.equal(await isEnabled(toggle), true);\n\n    // Reload the page and check that the Grist config indicates telemetry is set to \"limited\".\n    await driver.navigate().refresh();\n    await gu.waitForAdminPanel();\n    toggle = driver.find(\".test-admin-panel-item-value-telemetry .test-toggle-switch\");\n    assert.equal(await isEnabled(toggle), true);\n    await toggleItem(\"telemetry\");\n    await driver.findContentWait(\".test-support-grist-page-telemetry-section button\", /Opt out of Telemetry/, 2000);\n    assert.equal(\n      await driver.findWait(\".test-support-grist-page-telemetry-section-message\", 2000).getText(),\n      \"You have opted in to telemetry. Thank you! 🙏\",\n    );\n    await assertTelemetryLevel(\"limited\");\n  });\n\n  it(\"supports opting out of telemetry from the page\", async function() {\n    await driver.findContent(\".test-support-grist-page-telemetry-section button\", /Opt out of Telemetry/).click();\n    await driver.findContentWait(\".test-support-grist-page-telemetry-section button\", /Opt in to Telemetry/, 2000);\n    assert.isFalse(await driver.find(\".test-support-grist-page-telemetry-section-message\").isPresent());\n    let toggle = driver.find(\".test-admin-panel-item-value-telemetry .test-toggle-switch\");\n    assert.equal(await isEnabled(toggle), false);\n\n    // Reload the page and check that the Grist config indicates telemetry is set to \"off\".\n    await driver.navigate().refresh();\n    await gu.waitForAdminPanel();\n    await toggleItem(\"telemetry\");\n    await driver.findContentWait(\".test-support-grist-page-telemetry-section button\", /Opt in to Telemetry/, 2000);\n    assert.isFalse(await driver.find(\".test-support-grist-page-telemetry-section-message\").isPresent());\n    await assertTelemetryLevel(\"off\");\n    toggle = driver.find(\".test-admin-panel-item-value-telemetry .test-toggle-switch\");\n    assert.equal(await isEnabled(toggle), false);\n  });\n\n  it(\"supports toggling telemetry from the toggle in the top line\", async function() {\n    const toggle = driver.find(\".test-admin-panel-item-value-telemetry .test-toggle-switch\");\n    assert.equal(await isEnabled(toggle), false);\n    await toggle.click();\n    await gu.waitForServer();\n    assert.equal(await isEnabled(toggle), true);\n    assert.match(await driver.find(\".test-support-grist-page-telemetry-section-message\").getText(),\n      /You have opted in/);\n    await toggle.click();\n    await gu.waitForServer();\n    assert.equal(await isEnabled(toggle), false);\n    await withExpandedItem(\"telemetry\", async () => {\n      assert.equal(await driver.find(\".test-support-grist-page-telemetry-section-message\").isPresent(), false);\n    });\n  });\n\n  it(\"shows telemetry opt-in status even when set via environment variable\", async function() {\n    // Set the telemetry level to \"limited\" via environment variable and restart the server.\n    process.env.GRIST_TELEMETRY_LEVEL = \"limited\";\n    await server.restart();\n\n    // Check that the Support Grist page reports telemetry is enabled.\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    const toggle = driver.find(\".test-admin-panel-item-value-telemetry .test-toggle-switch\");\n    assert.equal(await isEnabled(toggle), true);\n    await toggleItem(\"telemetry\");\n    assert.equal(\n      await driver.findWait(\".test-support-grist-page-telemetry-section-message\", 2000).getText(),\n      \"You have opted in to telemetry. Thank you! 🙏\",\n    );\n    assert.isFalse(await driver.findContent(\".test-support-grist-page-telemetry-section button\",\n      /Opt out of Telemetry/).isPresent());\n\n    // Now set the telemetry level to \"off\" and restart the server.\n    process.env.GRIST_TELEMETRY_LEVEL = \"off\";\n    await server.restart();\n\n    // Check that the Support Grist page reports telemetry is disabled.\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    await toggleItem(\"telemetry\");\n    assert.equal(\n      await driver.findWait(\".test-support-grist-page-telemetry-section-message\", 2000).getText(),\n      \"You have opted out of telemetry.\",\n    );\n    assert.isFalse(await driver.findContent(\".test-support-grist-page-telemetry-section button\",\n      /Opt in to Telemetry/).isPresent());\n  });\n\n  it(\"should show version\", async function() {\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    await gu.waitToPass(async () => {\n      assert.equal(await driver.find(\".test-admin-panel-item-version\").isDisplayed(), true);\n      assert.match(await driver.find(\".test-admin-panel-item-value-version\").getText(), /^Version \\d+\\./);\n    }, 3000);\n  });\n\n  it(\"should show admin accounts\", async function() {\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    const adminAccounts = await driver.findWait(\".test-admin-panel-item-admins\", 2000);\n    assert.equal(await adminAccounts.isDisplayed(), true);\n    const adminDisplay = await driver.find(\".test-admin-panel-admin-accounts-display\");\n\n    assert.equal(\"1 admin account\", await adminDisplay.getText());\n\n    await toggleItem(\"admins\");\n\n    const adminsList = await driver.find(\".test-admin-panel-admin-accounts-list\");\n    assert.equal(await adminsList.isDisplayed(), true);\n\n    const names = await adminAccounts.findAll(\".test-admin-panel-admin-account-name\");\n    assert.equal(names.length, 1);\n\n    const emails = await adminAccounts.findAll(\".test-admin-panel-admin-account-email\");\n    assert.equal(names.length, 1);\n\n    assert.equal(await emails[0].getText(), gu.session().email);\n  });\n\n  it(\"should show sandbox\", async function() {\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    assert.equal(await driver.find(\".test-admin-panel-item-sandboxing\").isDisplayed(), true);\n    await gu.waitToPass(\n      // unknown for grist-saas, unconfigured for grist-core.\n      async () => assert.match(await driver.find(\".test-admin-panel-item-value-sandboxing\").getText(),\n        /^((Error: unknown)|(unconfigured))/),\n      3000,\n    );\n    // It would be good to test other scenarios, but we are using\n    // a multi-server setup on grist-saas and the sandbox test isn't\n    // useful there yet.\n  });\n\n  it(\"should show various self checks\", async function() {\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    await gu.waitToPass(\n      async () => {\n        assert.equal(await driver.find(\".test-admin-panel-item-name-probe-reachable\").isDisplayed(), true);\n        assert.match(await driver.find(\".test-admin-panel-item-value-probe-reachable\").getText(), /✅/);\n      },\n      3000,\n    );\n    assert.equal(await driver.find(\".test-admin-panel-item-name-probe-system-user\").isDisplayed(), true);\n    await gu.waitToPass(\n      async () => assert.match(await driver.find(\".test-admin-panel-item-value-probe-system-user\").getText(), /✅/),\n      3000,\n    );\n  });\n\n  const upperCheckNow = () => driver.find(\".test-admin-panel-updates-upper-check-now\");\n  const lowerCheckNow = () => driver.find(\".test-admin-panel-updates-lower-check-now\");\n  const autoCheckToggle = () => driver.find(\".test-admin-panel-updates-auto-check\");\n  const autoCheckToggleDisabled = () => driver.find(\".test-admin-panel-updates-auto-check-disabled\");\n  const updateMessage = () => driver.find(\".test-admin-panel-updates-message\");\n  const versionBox = () => driver.find(\".test-admin-panel-updates-version\");\n  function waitForStatus(message: RegExp) {\n    return gu.waitToPass(async () => {\n      assert.match(await updateMessage().getText(), message);\n    });\n  }\n\n  it(\"should check for updates\", async function() {\n    // Clear any cached settings.\n    await driver.executeScript(\"window.sessionStorage.clear(); window.localStorage.clear();\");\n    await driver.navigate().refresh();\n    await gu.waitForAdminPanel();\n\n    // By default don't have any info.\n    await waitForStatus(/No information available/);\n\n    // We see upper check-now button.\n    assert.isTrue(await upperCheckNow().isDisplayed());\n\n    // We can expand.\n    await toggleItem(\"updates\");\n\n    // We see a toggle to update automatically, enabled by default\n    assert.isTrue(await autoCheckToggle().isDisplayed());\n    assert.isTrue(await isEnabled(autoCheckToggle()));\n\n    // We can click it twice, Grist will do a check right away.\n    fakeServer.pause();\n    await autoCheckToggle().click();\n    assert.isFalse(await isEnabled(autoCheckToggle()));\n    await autoCheckToggle().click();\n    assert.isTrue(await isEnabled(autoCheckToggle()));\n\n    // It will first show \"Checking for updates\" message.\n    // (Request is blocked by fake server, so it will not complete until we resume it.)\n    await waitForStatus(/Checking for updates/);\n\n    // Upper check now button is removed.\n    assert.isFalse(await upperCheckNow().isPresent());\n\n    // Resume server and respond.\n    fakeServer.resume();\n\n    // It will show \"New version available\" message.\n    await waitForStatus(/Newer version available/);\n    // And a version number.\n    assert.isTrue(await versionBox().isDisplayed());\n    assert.match(await versionBox().getText(), new RegExp(`Version ${fakeServer.latestVersion}`));\n\n    // Disable auto-checks.\n    assert.isTrue(await isEnabled(autoCheckToggle()));\n    await autoCheckToggle().click();\n    assert.isFalse(await isEnabled(autoCheckToggle()));\n    // We remember that a newer version is available\n    await waitForStatus(/Newer version available/);\n    assert.isTrue(await versionBox().isDisplayed());\n    assert.equal(await versionBox().getText(), `Version ${fakeServer.latestVersion}`);\n\n    // Refresh to see if we are disabled.\n    fakeServer.pause();\n    await driver.navigate().refresh();\n    await gu.waitForAdminPanel();\n    await waitForStatus(/Newer version available/);\n    fakeServer.resume();\n    // Expand and see if the toggle is off.\n    await toggleItem(\"updates\");\n    assert.isFalse(await isEnabled(autoCheckToggle()));\n  });\n\n  it(\"shows up-to-date message\", async function() {\n    // Restart the server to clear cached version check\n    await server.restart(true);\n    session = await gu.session().personalSite.login();\n    await session.loadDocMenu(\"/\");\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n\n    fakeServer.latestVersion = await currentVersion();\n\n    // Click upper check now.\n    await upperCheckNow().click();\n    await waitForStatus(/Grist is up to date/);\n\n    // Update version once again.\n    fakeServer.bumpVersion();\n    // Click lower check now.\n    fakeServer.pause();\n    await toggleItem(\"updates\");\n    await lowerCheckNow().click();\n    await waitForStatus(/Checking for updates/);\n    fakeServer.resume();\n    await waitForStatus(/Newer version available/);\n\n    // Make sure we see the new version.\n    assert.isTrue(await versionBox().isDisplayed());\n    assert.match(await versionBox().getText(), new RegExp(`Version ${fakeServer.latestVersion}`));\n\n    // Make sure that checking it twice also works when a newer version is available\n    await autoCheckToggle().click();\n    assert.isFalse(await isEnabled(autoCheckToggle()));\n    await autoCheckToggle().click();\n    assert.isTrue(await isEnabled(autoCheckToggle()));\n    await waitForStatus(/Newer version available/);\n  });\n\n  it(\"shows error message\", async function() {\n    fakeServer.failNext = true;\n    fakeServer.pause();\n    await lowerCheckNow().click();\n    await waitForStatus(/Checking for updates/);\n    fakeServer.resume();\n    await waitForStatus(/Error checking for updates/);\n    assert.match((await gu.getToasts())[0], /some error/);\n    await gu.wipeToasts();\n  });\n\n  it(\"should send telemetry data\", async function() {\n    assert.deepEqual({ ...fakeServer.payload, installationId: \"test\" }, {\n      installationId: \"test\",\n      deploymentType: \"core\",\n      currentVersion: await currentVersion(),\n    });\n    assert.isNotEmpty(fakeServer.payload.installationId);\n  });\n\n  it(\"should show a message if automatic version checking is missing\", async function() {\n    process.env.GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING = \"false\";\n    await server.restart(true);\n    session = await gu.session().personalSite.login();\n    await session.loadDocMenu(\"/\");\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    await toggleItem(\"updates\");\n    // The message should be there.\n    assert.isTrue(await autoCheckToggleDisabled().isDisplayed());\n  });\n\n  it(\"should survive APP_HOME_URL misconfiguration\", async function() {\n    process.env.APP_HOME_URL = \"http://misconfigured.invalid\";\n    process.env.GRIST_BOOT_KEY = \"zig\";\n    await server.restart(true);\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n  });\n\n  it(\"should honor GRIST_BOOT_KEY fallback\", async function() {\n    await gu.removeLogin();\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    assert.equal(await driver.find(\".test-admin-panel\").isDisplayed(), true);\n    assert.match(await driver.find(\".test-admin-panel\").getText(), /Administrator Panel Unavailable/);\n\n    process.env.GRIST_BOOT_KEY = \"zig\";\n    await server.restart(true);\n    await driver.get(`${server.getHost()}/admin?boot-key=zig`);\n    await gu.waitForAdminPanel();\n    assert.equal(await driver.find(\".test-admin-panel\").isDisplayed(), true);\n    assert.notMatch(await driver.find(\".test-admin-panel\").getText(), /Administrator Panel Unavailable/);\n    await driver.get(`${server.getHost()}/admin?boot-key=zig-wrong`);\n    await gu.waitForAdminPanel();\n    assert.equal(await driver.find(\".test-admin-panel\").isDisplayed(), true);\n    assert.match(await driver.find(\".test-admin-panel\").getText(), /Administrator Panel Unavailable/);\n  });\n\n  it(\"should show no admins if `GRIST_DEFAULT_EMAIL` is unset\", async function() {\n    // If the env var is unset, core and SaaS handle the situation\n    // differently. Core will supply a hardcoded default of you@example.com.\n    //\n    // For the purpose of this test, let's instead set it to the empty\n    // string.\n    process.env.GRIST_DEFAULT_EMAIL = \"\";\n    await server.restart(true);\n    await driver.get(`${server.getHost()}/admin?boot-key=zig`);\n    await gu.waitForAdminPanel();\n\n    const adminAccounts = await driver.findWait(\".test-admin-panel-item-admins\", 2000);\n    assert.equal(await adminAccounts.isDisplayed(), true);\n    const adminDisplay = await driver.findWait(\".test-admin-panel-admin-accounts-display\", 1000);\n\n    assert.equal(\"no admin accounts\", await adminDisplay.getText());\n\n    await toggleItem(\"admins\");\n\n    const adminsList = await driver.find(\".test-admin-panel-admin-accounts-list\");\n    assert.equal(await adminsList.isDisplayed(), true);\n\n    const names = await adminAccounts.findAll(\".test-admin-panel-admin-accounts-list-item\");\n    assert.equal(names.length, 1);\n\n    assert.equal(await names[0].getText(), \"Admin account not found\\n\" +\n    \"Missing admin account because GRIST_ADMIN_EMAIL and GRIST_DEFAULT_EMAIL are not set\");\n  });\n});\n\nasync function assertTelemetryLevel(level: TelemetryLevel) {\n  const telemetryLevel = await driver.executeScript(\"return window.gristConfig.telemetry?.telemetryLevel\");\n  assert.equal(telemetryLevel, level);\n}\n"
  },
  {
    "path": "test/nbrowser/AdminPanelTools.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\n\nimport { driver, WebElement } from \"mocha-webdriver\";\n\nexport function itemElement(itemId: string) {\n  return driver.findWait(`.test-admin-panel-item-${itemId}`, 1000);\n}\n\nexport async function toggleItem(itemId: string) {\n  const header = itemElement(itemId).find(`.test-admin-panel-item-name-${itemId}`);\n  await header.click();\n  await driver.sleep(500);    // Time to expand or collapse.\n  return header;\n}\n\nexport function itemValue(itemId: string) {\n  return driver.findWait(`.test-admin-panel-item-value-${itemId}`, 100).getText();\n}\n\n/**\n * Returns an object to get the text and status of a section value.\n */\nexport function sectionValue(sectionId: string) {\n  return {\n    text: () => itemValue(sectionId),\n    status: async () => {\n      const item = await driver.findWait(`.test-admin-panel-item-value-${sectionId}`, 100);\n      if (await item.find(\".test-admin-panel-value-label-success\").isPresent()) {\n        return \"success\";\n      } else if (await item.find(\".test-admin-panel-value-label-danger\").isPresent()) {\n        return \"danger\";\n      } else if (await item.find(\".test-admin-panel-value-label-error\").isPresent()) {\n        return \"error\";\n      } else {\n        return null;\n      }\n    },\n  };\n}\n\nexport async function withExpandedItem(itemId: string, callback: () => Promise<void>) {\n  const header = await toggleItem(itemId);\n  await callback();\n  await header.click();\n  await driver.sleep(500);    // Time to collapse.\n}\n\nexport async function clickSwitch(name: string) {\n  const toggle = driver.find(`.test-admin-panel-item-value-${name} .test-toggle-switch`);\n  await toggle.click();\n  await gu.waitForServer();\n}\n\nexport async function isEnabled(switchElem: WebElement | string) {\n  if (typeof switchElem === \"string\") {\n    switchElem = driver.find(`.test-admin-panel-item-value-${switchElem} .test-toggle-switch`);\n  }\n  return (await switchElem.find(\"input\").getAttribute(\"checked\")) === null ? false : true;\n}\n\nexport async function currentVersion() {\n  const currentVersionText = await driver.find(\".test-admin-panel-item-value-version\").getText();\n  const currentVersion = currentVersionText.match(/Version (.+)/)![1];\n  return currentVersion;\n}\n"
  },
  {
    "path": "test/nbrowser/AirtableImport.ts",
    "content": "/**\n * Test Airtable imports, mocking the networking of Airtable's own APIs.\n *\n * This test starts a helper server used for a couple of purposes:\n * 1. It replaces Airtable's OAuth API endpoints, and we tell the node server to use those.\n * 2. It simulates Airtable's endpoint to fetch bases, and we tell the browser to use those.\n */\nimport { listenPromise } from \"app/server/lib/serverUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport http from \"node:http\";\nimport { AddressInfo } from \"node:net\";\n\nimport express from \"express\";\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"AirtableImport\", function() {\n  this.timeout(\"20s\");\n  const cleanup = setupTestSuite();\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  let ownerSession: gu.Session;\n  let editorSession: gu.Session;\n  let docId: string;\n  let otherDocId: string;\n  let testHelperServer: http.Server;\n  let testHelperServerUrl: string;\n  let cancelNextOAuthRequest: boolean = false;\n\n  async function openAirtableDocImporter(context: \"home\" | \"doc\" = \"doc\") {\n    // Make Airtable API calls go to our helper server.\n    await driver.executeScript((baseUrl: string) => {\n      (window as any).testAirtableImportBaseUrlOverride = baseUrl;\n    }, testHelperServerUrl);\n\n    const prefix = context === \"home\" ? \"dm\" : \"dp\";\n    await driver.findWait(`.test-${prefix}-add-new`, 2000).click();\n    if (context === \"home\") {\n      await driver.findWait(\".test-dm-import-from-airtable\", 500).click();\n    } else {\n      await driver.findContentWait(\".test-dp-import-option\", /Import from Airtable/i, 500).click();\n    }\n    await driver.findWait(\".test-modal-dialog\", 2000);\n  }\n\n  describe(\"when configured\", function() {\n    before(async function() {\n      testHelperServer = await startTestHelperServer();\n      const port = (testHelperServer.address() as AddressInfo).port;\n      testHelperServerUrl = `http://localhost:${port}`;\n      oldEnv = new testUtils.EnvironmentSnapshot();\n      process.env.GRIST_TEST_LOGIN = \"1\";\n      process.env.OAUTH2_GRIST_HOST = server.getHost();\n      process.env.OAUTH2_AIRTABLE_CLIENT_ID = \"test-client\";\n      process.env.OAUTH2_AIRTABLE_CLIENT_SECRET = \"test-secret\";\n      process.env.TEST_GRIST_OAUTH2_CLIENTS_OVERRIDES = JSON.stringify({\n        airtable: {\n          issuerMetadata: {\n            authorization_endpoint: new URL(\"/authorize\", testHelperServerUrl).href,\n            token_endpoint: new URL(\"/token\", testHelperServerUrl).href,\n          },\n        },\n      });\n      await server.restart(false);\n    });\n\n    after(async function() {\n      if (!process.env.NO_CLEANUP) {\n        oldEnv.restore();\n        await server.restart(false);\n        testHelperServer?.close();\n        testHelperServer?.closeAllConnections();\n      }\n    });\n\n    async function startTestHelperServer() {\n      const app = express();\n      app.use(express.urlencoded({ extended: false }));\n\n      /**\n       * Simulates an Airtable \"authorize\" endpoint, with a few checks that it's called as expected,\n       * and returning a similar format to what Airtable OAuth returns.\n       */\n      app.get(\"/authorize\", (req, res) => {\n        try {\n          assert.equal(req.query.response_type, \"code\", \"invalid response_type\");\n          assert.isOk(req.query.client_id, \"missing client_id\");\n          assert.isOk(req.query.redirect_uri, \"missing redirect_uri\");\n          assert.isOk(req.query.state, \"missing state\");\n          assert.isOk(req.query.code_challenge, \"missing code_challenge\");\n          assert.equal(req.query.code_challenge_method, \"S256\", \"invalid code_challenge_method\");\n\n          const location = new URL(req.query.redirect_uri as string);\n          if (cancelNextOAuthRequest) {\n            // If requested, simulate what Airtable returns when the user clicks \"Cancel\" on\n            // the authorization screen.\n            location.searchParams.set(\"error_description\", \"The user denied the request\");\n            location.searchParams.set(\"error\", \"access_denied\");\n            cancelNextOAuthRequest = false;\n          } else {\n            // Issue a deterministic authorization code.\n            location.searchParams.set(\"code\", \"TEST_AUTH_CODE\");\n          }\n          location.searchParams.set(\"state\", req.query.state as string);\n          res.setHeader(\"Location\", location.href);\n          return res.status(302).end();\n        } catch (e) {\n          return res.status(400).send(e.message);\n        }\n      });\n\n      /**\n       * Simulates an Airtable \"token\" endpoint, with a few checks that it's called as expected,\n       * and returning a similar format to what Airtable OAuth returns.\n       */\n      app.post(\"/token\", (req, res) => {\n        const expectedBasic = \"Basic \" + Buffer.from(\"test-client:test-secret\").toString(\"base64\");\n        try {\n          assert.equal(req.header(\"authorization\"), expectedBasic, \"invalid_client\");\n          assert.equal(req.body.grant_type, \"authorization_code\", \"unsupported_grant_type\");\n          assert.isOk(req.body.code && req.body.code_verifier, \"invalid_request\");\n\n          // Opaque token (constant to keep tests deterministic)\n          return res.status(200).json({\n            access_token: \"opaque-test-access-token\",\n            token_type: \"Bearer\",\n            expires_in: 3600,\n          });\n        } catch (e) {\n          return res.status(400).json({ error: e.message });\n        }\n      });\n\n      function allowCors(res: express.Response) {\n        res.header(\"Access-Control-Allow-Methods\", \"GET, PATCH, PUT, POST, DELETE, OPTIONS\");\n        res.header(\"Access-Control-Allow-Credentials\", \"true\");\n        res.header(\"Access-Control-Allow-Headers\",\n          \"Authorization, Content-Type, X-Requested-With, X-Airtable-User-Agent\");\n        res.header(\"Access-Control-Allow-Origin\", \"*\");\n      }\n\n      /**\n       * Simulates an Airtable \"fetch bases\" endpoint. It's called from the browser, so we relax\n       * CORS. That's something that I've manually checked is the case for Airtable's endpoints.\n       */\n      app.get(\"/v0/meta/bases\", (req, res) => {\n        allowCors(res);\n        if (req.headers.authorization !== \"Bearer opaque-test-access-token\") {\n          // This is the shape of a realistic Airtable response.\n          const msg = { error: { type: \"AUTHENTICATION_REQUIRED\", message: \"Authentication required\" } };\n          return res.status(403).json(msg);\n        }\n        return res.status(200).json(airtableBasesFixture);\n      });\n\n      /**\n       * Simulates an Airtable \"get base schema\" endpoint.\n       */\n      app.get(\"/v0/meta/bases/:baseId/tables\", (req, res) => {\n        allowCors(res);\n        if (req.headers.authorization !== \"Bearer opaque-test-access-token\") {\n          const msg = { error: { type: \"AUTHENTICATION_REQUIRED\", message: \"Authentication required\" } };\n          return res.status(403).json(msg);\n        }\n        const { baseId } = req.params;\n        const baseSchema = airtableBaseSchemaFixture[baseId as keyof typeof airtableBaseSchemaFixture];\n        if (!baseSchema) {\n          return res.status(404).json({ error: { type: \"NOT_FOUND\", message: \"Base not found\" } });\n        }\n        return res.status(200).json(baseSchema);\n      });\n\n      /**\n       * Simulates an Airtable \"list records\" endpoint.\n       */\n      app.get(\"/v0/:baseId/:tableId\", (req, res) => {\n        allowCors(res);\n        if (req.headers.authorization !== \"Bearer opaque-test-access-token\") {\n          const msg = { error: { type: \"AUTHENTICATION_REQUIRED\", message: \"Authentication required\" } };\n          return res.status(403).json(msg);\n        }\n        const { tableId } = req.params;\n        const records = airtableListRecordsFixture[tableId as keyof typeof airtableListRecordsFixture];\n        if (!records) {\n          return res.status(404).json({ error: { type: \"NOT_FOUND\", message: \"Table not found\" } });\n        }\n        return res.status(200).json(records);\n      });\n\n      app.use(\"/\", (req, res) => {\n        allowCors(res);\n        if (req.method === \"OPTIONS\") {\n          res.sendStatus(200);\n        } else {\n          console.warn(`NOT FOUND: ${req.originalUrl}`);\n          res.status(404).send({ error: `not found: ${req.originalUrl}` });\n        }\n      });\n\n      const helperServer = http.createServer(app);\n      await listenPromise(helperServer.listen(0, \"localhost\"));\n      return helperServer;\n    }\n\n    async function waitForModalToClose() {\n      await driver.wait(async () => !(await driver.find(\".test-modal-dialog\").isPresent()), 3000);\n    }\n\n    describe(\"and anonymous\", function() {\n      before(async function() {\n        ownerSession = await gu.session().anon.login();\n        await ownerSession.loadDocMenu(\"/\");\n        await driver.find(\".test-intro-create-doc\").click();\n        await gu.waitForDocToLoad();\n        await gu.dismissWelcomeTourIfNeeded();\n      });\n\n      it(\"should redirect to sign-in page\", async function() {\n        await gu.refreshDismiss({ ignore: true });\n        await driver.findWait(\".test-dp-add-new\", 2000).click();\n        await driver.findContentWait(\".test-dp-import-option\", /Import from Airtable/i, 500).click();\n\n        await gu.checkLoginPage();\n\n        await ownerSession.loadDocMenu(\"/\");\n        await driver.findWait(\".test-dm-add-new\", 2000).click();\n        await driver.findWait(\".test-dm-import-from-airtable\", 500).click();\n\n        await gu.checkLoginPage();\n      });\n    });\n\n    describe(\"and signed in\", function() {\n      before(async function() {\n        ownerSession = await gu.session().teamSite.user(\"user1\").login();\n        docId = await ownerSession.tempNewDoc(cleanup, \"AirtableImport\", { load: false });\n      });\n\n      it(\"should go through oauth2 flow and fetch bases\", async function() {\n        await ownerSession.loadDoc(`/doc/${docId}`);\n\n        await openAirtableDocImporter();\n\n        await driver.findWait(\".test-import-airtable-connect:not(:disabled)\", 2000).click();\n\n        const bases = await driver.findWait(\".test-import-airtable-bases\", 2000);\n        assert.deepEqual(await bases.findAll(\".test-import-airtable-name\", el => el.getText()),\n          [\"Product planning\", \"Sales CRM\"]);\n        assert.deepEqual(await bases.findAll(\".test-import-airtable-id\", el => el.getText()),\n          [\"appYovle0EAuu0OZE\", \"app04i02p1V0I1QvU\"]);\n\n        await driver.findContent(\".test-modal-dialog button\", /Cancel/).click();\n        await waitForModalToClose();\n      });\n\n      it(\"should reuse access_token on repeat invocation\", async function() {\n        await gu.reloadDoc();\n        await openAirtableDocImporter();\n        const bases = await driver.findWait(\".test-import-airtable-bases\", 2000);\n        assert.deepEqual(await bases.findAll(\".test-import-airtable-name\", el => el.getText()),\n          [\"Product planning\", \"Sales CRM\"]);\n        assert.deepEqual(await bases.findAll(\".test-import-airtable-id\", el => el.getText()),\n          [\"appYovle0EAuu0OZE\", \"app04i02p1V0I1QvU\"]);\n\n        await driver.findContent(\".test-modal-dialog button\", /Cancel/).click();\n        await waitForModalToClose();\n      });\n\n      it(\"should associate access_token with a user\", async function() {\n        editorSession = await gu.session().personalSite.user(\"user2\").addLogin();\n        otherDocId = await editorSession.tempNewDoc(cleanup, \"AirtableImport2\");\n\n        await openAirtableDocImporter();\n\n        await driver.findWait(\".test-import-airtable-connect:not(:disabled)\", 2000).click();\n        const bases = await driver.findWait(\".test-import-airtable-bases\", 2000);\n        assert.deepEqual(await bases.findAll(\".test-import-airtable-name\", el => el.getText()),\n          [\"Product planning\", \"Sales CRM\"]);\n        assert.deepEqual(await bases.findAll(\".test-import-airtable-id\", el => el.getText()),\n          [\"appYovle0EAuu0OZE\", \"app04i02p1V0I1QvU\"]);\n\n        await driver.findContent(\".test-modal-dialog button\", /Cancel/).click();\n        await waitForModalToClose();\n      });\n\n      it(\"should allow disconnecting\", async function() {\n        await ownerSession.loadDoc(`/doc/${docId}`);\n        await openAirtableDocImporter();\n        await driver.findWait(\".test-import-airtable-settings\", 2000).click();\n        await gu.findOpenMenuItem(\"li\", /Disconnect/).click();\n        await gu.waitForServer();\n\n        // The \"Connect\" button should show up again.\n        assert.equal(await driver.findWait(\".test-import-airtable-connect\", 2000).isPresent(), true);\n\n        // Reload the page. We should see the connect button again.\n        await gu.reloadDoc();\n        await openAirtableDocImporter();\n        assert.equal(await driver.findWait(\".test-import-airtable-connect\", 2000).isPresent(), true);\n\n        // Check that the other session is still connected.\n        await editorSession.loadDoc(`/doc/${otherDocId}`);\n        await openAirtableDocImporter();\n        const bases = await driver.findWait(\".test-import-airtable-bases\", 2000);\n        assert.deepEqual(await bases.findAll(\".test-import-airtable-name\", el => el.getText()),\n          [\"Product planning\", \"Sales CRM\"]);\n        assert.deepEqual(await bases.findAll(\".test-import-airtable-id\", el => el.getText()),\n          [\"appYovle0EAuu0OZE\", \"app04i02p1V0I1QvU\"]);\n\n        await driver.findContent(\".test-modal-dialog button\", /Cancel/).click();\n        await waitForModalToClose();\n      });\n\n      it(\"should show error if oauth2 is canceled\", async function() {\n        await ownerSession.loadDoc(`/doc/${docId}`);\n        await openAirtableDocImporter();\n        cancelNextOAuthRequest = true;\n        await driver.findWait(\".test-import-airtable-connect:not(:disabled)\", 2000).click();\n        assert.equal(await driver.findWait(\".test-import-airtable-error\", 2000).getText(),\n          \"access_denied (The user denied the request)\");   // This is not a very friendly error.\n\n        // Try again; this time, the request should succeed, and error should disappear.\n        cancelNextOAuthRequest = false;\n        await driver.findWait(\".test-import-airtable-connect:not(:disabled)\", 2000).click();\n        await gu.waitToPass(async () => {\n          assert.equal(await driver.find(\".test-import-airtable-error\").isPresent(), false);\n        });\n\n        await driver.findContentWait(\".test-modal-dialog button\", /Cancel/, 500).click();\n        await waitForModalToClose();\n      });\n\n      it(\"should list all tables from the selected base\", async function() {\n        // Switch to the team site for importing, enabling checks that the doc is in the right site/workspace.\n        await ownerSession.tempWorkspace(cleanup, \"Airtable\");\n        await ownerSession.loadDocMenu(\"/\");\n        await openAirtableDocImporter(\"home\");\n\n        const bases = await driver.findWait(\".test-import-airtable-bases\", 2000);\n        await bases.findContent(\".test-import-airtable-name\", \"Product planning\").click();\n        await driver.find(\".test-import-airtable-continue\").click();\n\n        await driver.findWait(\".test-import-airtable-mappings\", 2000).isDisplayed();\n        assert.deepEqual(await driver.findAll(\".test-import-airtable-table-name\", el => el.getText()), [\n          \"Products\",\n          \"Suppliers\",\n          \"Orders\",\n        ]);\n      });\n\n      it(\"should allow mapping Airtable tables to Grist tables\", async function() {\n      // Tables are imported as new tables by default.\n        assert.deepEqual(await driver.findAll(\".test-import-airtable-destination-label\", el => el.getText()), [\n          \"New table\",\n          \"New table\",\n          \"New table\",\n        ]);\n        assert.equal(await driver.find(\".test-import-airtable-import\").getText(), \"Import 3 tables\");\n\n        // Import Products (tbl79ux7qppckp8hr) as a new table.\n        await driver.find(\".test-import-airtable-table-tbl79ux7qppckp8hr-destination\").click();\n        assert.deepEqual(await gu.findOpenMenuAllItems(\"li\", el => el.getText()), [\n          \"New table\",\n          \"New table: structure only\",\n          \"Skip\",\n        ]);\n        await gu.findOpenMenuItem(\"li\", \"New table\").click();\n        assert.deepEqual(await driver.findAll(\".test-import-airtable-destination-label\", el => el.getText()), [\n          \"New table\",\n          \"New table\",\n          \"New table\",\n        ]);\n        assert.equal(await driver.find(\".test-import-airtable-import\").getText(), \"Import 3 tables\");\n\n        // Import Suppliers (tblbyte2tg72cbhhf) as a new table without data.\n        await driver.find(\".test-import-airtable-table-tblbyte2tg72cbhhf-destination\").click();\n        assert.deepEqual(await gu.findOpenMenuAllItems(\"li\", el => el.getText()), [\n          \"New table\",\n          \"New table: structure only\",\n          \"Skip\",\n        ]);\n        await gu.findOpenMenuItem(\"li\", \"New table: structure only\").click();\n        assert.deepEqual(await driver.findAll(\".test-import-airtable-destination-label\", el => el.getText()), [\n          \"New table\",\n          \"Structure only\",\n          \"New table\",\n        ]);\n        assert.equal(await driver.find(\".test-import-airtable-import\").getText(), \"Import 3 tables\");\n\n        // Skip importing Orders (tblfyhS37Hst5Hvsf).\n        await driver.find(\".test-import-airtable-table-tblfyhS37Hst5Hvsf-destination\").click();\n        assert.deepEqual(await gu.findOpenMenuAllItems(\"li\", el => el.getText()), [\n          \"New table\",\n          \"New table: structure only\",\n          \"Skip\",\n        ]);\n        await gu.findOpenMenuItem(\"li\", \"Skip\").click();\n        assert.deepEqual(await driver.findAll(\".test-import-airtable-destination-label\", el => el.getText()), [\n          \"New table\",\n          \"Structure only\",\n          \"Skip\",\n        ]);\n        assert.equal(await driver.find(\".test-import-airtable-import\").getText(), \"Import 2 tables\");\n      });\n\n      it(\"should import Airtable base to a new Grist document\", async function() {\n        await driver.find(\".test-import-airtable-import\").click();\n        await waitForModalToClose();\n        await gu.waitForDocToLoad();\n\n        assert.equal(await driver.find(\".test-bc-doc\").value(), \"Product planning\");\n        assert.deepEqual(await gu.getPageNames(), [\"Products\", \"Suppliers\"]);\n        assert.deepEqual(await gu.getColumnNames(), [\n          \"Airtable Id\",\n          \"Name\",\n          \"Price\",\n          \"Category\",\n          \"Suppliers\",\n        ]);\n        assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1, 2, 3, 4] }), [\n          \"reccaegwskzka7wi1\", \"Widget X\", \"99.99\", \"Electronics\", \"\",\n          \"recigwb4bc7vq2fhd\", \"Gadget Y\", \"149.99\", \"Electronics\", \"\",\n          \"\", \"\", \"\", \"\", \"\",\n        ]);\n\n        await gu.getPageItem(\"Suppliers\").click();\n        assert.deepEqual(await gu.getColumnNames(), [\n          \"Airtable Id\",\n          \"Name\",\n          \"Email\",\n          \"Phone\",\n        ]);\n        assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1], cols: [0, 1, 2, 3] }), [\n          \"\", \"\", \"\", \"\",\n        ]);\n      });\n\n      it(\"should import Airtable base into the current team site and workspace\", async function() {\n        // Team site should have been logged into when starting the import flow, just need to verify here.\n        // Check in the correct team site.\n        assert.equal(await driver.find(\".test-dm-orgname\").getText(), \"Test Grist\");\n        // Check correct workspace\n        assert.equal(await driver.find(\".test-bc-workspace\").getText(), \"Airtable\");\n      });\n\n      it(\"should import Airtable base to an existing Grist document\", async function() {\n        await gu.getPageItem(\"Products\").click();\n        await gu.sendKeys(await gu.selectAllKey(), Key.chord(await gu.modKey(), Key.DELETE));\n        await gu.confirm(true);\n        await gu.waitForServer();\n\n        await openAirtableDocImporter(\"doc\");\n\n        const bases = await driver.findWait(\".test-import-airtable-bases\", 2000);\n        await bases.findContent(\".test-import-airtable-name\", \"Product planning\").click();\n        await driver.find(\".test-import-airtable-continue\").click();\n        await driver.findWait(\".test-import-airtable-mappings\", 2000).isDisplayed();\n\n        // Import Products (tbl79ux7qppckp8hr) to the existing Products table.\n        await driver.find(\".test-import-airtable-table-tbl79ux7qppckp8hr-destination\").click();\n        assert.deepEqual(await gu.findOpenMenuAllItems(\"li\", el => el.getText()), [\n          \"New table\",\n          \"New table: structure only\",\n          \"Skip\",\n          \"Products\",\n          \"Suppliers\",\n        ]);\n        await gu.findOpenMenuItem(\"li\", \"Products\").click();\n        assert.deepEqual(await driver.findAll(\".test-import-airtable-destination-label\", el => el.getText()), [\n          \"Products\",\n          \"New table\",\n          \"New table\",\n        ]);\n        assert.equal(await driver.find(\".test-import-airtable-import\").getText(), \"Import 3 tables\");\n\n        // Import Suppliers (tblbyte2tg72cbhhf) as a new table without data.\n        await driver.find(\".test-import-airtable-table-tblbyte2tg72cbhhf-destination\").click();\n        assert.deepEqual(await gu.findOpenMenuAllItems(\"li\", el => el.getText()), [\n          \"New table\",\n          \"New table: structure only\",\n          \"Skip\",\n          \"Products\",\n          \"Suppliers\",\n        ]);\n        await gu.findOpenMenuItem(\"li\", \"Suppliers\").click();\n        assert.deepEqual(await driver.findAll(\".test-import-airtable-destination-label\", el => el.getText()), [\n          \"Products\",\n          \"Suppliers\",\n          \"New table\",\n        ]);\n        assert.equal(await driver.find(\".test-import-airtable-import\").getText(), \"Import 3 tables\");\n\n        await driver.find(\".test-import-airtable-import\").click();\n        await waitForModalToClose();\n\n        assert.deepEqual(await gu.getPageNames(), [\"Products\", \"Suppliers\", \"Orders\"]);\n        assert.deepEqual(await gu.getColumnNames(), [\n          \"Airtable Id\",\n          \"Name\",\n          \"Price\",\n          \"Category\",\n          \"Suppliers\",\n        ]);\n        assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1, 2, 3, 4] }), [\n          \"reccaegwskzka7wi1\", \"Widget X\", \"99.99\", \"Electronics\", \"Suppliers[1]\",\n          \"recigwb4bc7vq2fhd\", \"Gadget Y\", \"149.99\", \"Electronics\", \"Suppliers[2]\",\n          \"\", \"\", \"\", \"\", \"\",\n        ]);\n\n        await gu.getPageItem(\"Suppliers\").click();\n        assert.deepEqual(await gu.getColumnNames(), [\n          \"Airtable Id\",\n          \"Name\",\n          \"Email\",\n          \"Phone\",\n        ]);\n        assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1, 2, 3] }), [\n          \"recoa4mwyeytxu3fb\", \"Wow Widgets\", \"wowwidgets@example.com\", \"(123) 456-7890\",\n          \"recw7cwwskv1q5jck\", \"Grand Gadgets\", \"grandgadgets@example.com\", \"(111) 222-3333\",\n          \"\", \"\", \"\", \"\",\n        ]);\n\n        await gu.getPageItem(\"Orders\").click();\n        assert.deepEqual(await gu.getColumnNames(), [\n          \"Airtable Id\",\n          \"Order Number\",\n          \"Order Date\",\n          \"Products\",\n          \"Total Amount\",\n        ]);\n        assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1, 2, 3, 4] }), [\n          \"recjngmiw6qy39v53\", \"ord5q3rxaa95gyvrw\", \"01/05/2023\", \"Products[1]\", \"99.99\",\n          \"recua5n4ir46dn5t6\", \"ordx37praxl2m95wj\", \"01/06/2023\", \"Products[1]\\nProducts[2]\", \"249.98\",\n          \"\", \"\", \"\", \"\", \"\",\n        ]);\n      });\n\n      it(\"should allow editors to import to documents with access rules\", async function() {\n        docId = await ownerSession.tempNewDoc(cleanup, \"AirtableImportAccessRules\", { load: false });\n\n        const api = ownerSession.createHomeApi();\n        await api.updateDocPermissions(docId, { users: {\n          [editorSession.email]: \"editors\",\n        } });\n\n        // Add a hidden table.\n        await api.applyUserActions(docId, [\n          [\"AddEmptyTable\", \"Table2\"],\n          [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table2\", colIds: \"*\" }],\n          [\"AddRecord\", \"_grist_ACLRules\", null, {\n            resource: -1, aclFormula: \"\", permissionsText: \"-R\",\n          }],\n        ]);\n\n        editorSession = await gu.session().teamSite.user(\"user2\").login();\n        await editorSession.loadDoc(`/doc/${docId}`);\n\n        await openAirtableDocImporter(\"doc\");\n        await driver.findWait(\".test-import-airtable-connect:not(:disabled)\", 2000).click();\n        const bases = await driver.findWait(\".test-import-airtable-bases\", 2000);\n        await bases.findContent(\".test-import-airtable-name\", \"Product planning\").click();\n        await driver.find(\".test-import-airtable-continue\").click();\n\n        // Make sure Table2 isn't shown.\n        await driver.find(\".test-import-airtable-table-tbl79ux7qppckp8hr-destination\").click();\n        assert.deepEqual(await gu.findOpenMenuAllItems(\"li\", el => el.getText()), [\n          \"New table\",\n          \"New table: structure only\",\n          \"Skip\",\n          \"Table1\",\n        ]);\n        await gu.sendKeys(Key.ESCAPE);\n\n        await driver.findWait(\".test-import-airtable-import\", 2000).click();\n        await waitForModalToClose();\n\n        assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"Products\", \"Suppliers\", \"Orders\"]);\n\n        await gu.getPageItem(\"Products\").click();\n        assert.deepEqual(await gu.getColumnNames(), [\n          \"Airtable Id\",\n          \"Name\",\n          \"Price\",\n          \"Category\",\n          \"Suppliers\",\n        ]);\n        assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1, 2, 3, 4] }), [\n          \"reccaegwskzka7wi1\", \"Widget X\", \"99.99\", \"Electronics\", \"Suppliers[1]\",\n          \"recigwb4bc7vq2fhd\", \"Gadget Y\", \"149.99\", \"Electronics\", \"Suppliers[2]\",\n          \"\", \"\", \"\", \"\", \"\",\n        ]);\n\n        await gu.getPageItem(\"Suppliers\").click();\n        assert.deepEqual(await gu.getColumnNames(), [\n          \"Airtable Id\",\n          \"Name\",\n          \"Email\",\n          \"Phone\",\n        ]);\n        assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1, 2, 3] }), [\n          \"recoa4mwyeytxu3fb\", \"Wow Widgets\", \"wowwidgets@example.com\", \"(123) 456-7890\",\n          \"recw7cwwskv1q5jck\", \"Grand Gadgets\", \"grandgadgets@example.com\", \"(111) 222-3333\",\n          \"\", \"\", \"\", \"\",\n        ]);\n\n        await gu.getPageItem(\"Orders\").click();\n        assert.deepEqual(await gu.getColumnNames(), [\n          \"Airtable Id\",\n          \"Order Number\",\n          \"Order Date\",\n          \"Products\",\n          \"Total Amount\",\n        ]);\n        assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1, 2, 3, 4] }), [\n          \"recjngmiw6qy39v53\", \"ord5q3rxaa95gyvrw\", \"01/05/2023\", \"Products[1]\", \"99.99\",\n          \"recua5n4ir46dn5t6\", \"ordx37praxl2m95wj\", \"01/06/2023\", \"Products[1]\\nProducts[2]\", \"249.98\",\n          \"\", \"\", \"\", \"\", \"\",\n        ]);\n      });\n    });\n  });\n\n  describe(\"when oauth variables are unset\", function() {\n    before(async function() {\n      ownerSession = await gu.session().teamSite.user(\"user1\").login();\n      await ownerSession.loadDocMenu(\"/\");\n    });\n\n    it(\"should hide 'Connect with Airtable' button\", async function() {\n      await openAirtableDocImporter(\"home\");\n      assert.isFalse(await driver.find(\".test-import-airtable-connect\").isPresent());\n      assert.equal(\n        await driver.find(\".test-import-airtable-connect-hint\").getText(),\n        \"The more convenient ‘Connect with Airtable’ option can be configured by the installation \" +\n        \"administrator. Learn more.\",\n      );\n    });\n  });\n});\n\n// Sample response to GET https://api.airtable.com/v0/meta/bases\nconst airtableBasesFixture = {\n  bases: [{\n    id: \"appYovle0EAuu0OZE\",\n    name: \"Product planning\",\n    permissionLevel: \"create\",\n  }, {\n    id: \"app04i02p1V0I1QvU\",\n    name: \"Sales CRM\",\n    permissionLevel: \"create\",\n  }],\n};\n\n// Sample response to GET https://api.airtable.com/v0/meta/bases/{baseId}/tables\nconst airtableBaseSchemaFixture = {\n  appYovle0EAuu0OZE: {\n    tables: [{\n      id: \"tbl79ux7qppckp8hr\",\n      name: \"Products\",\n      primaryFieldId: \"fldc2scnky16ae07t\",\n      fields: [\n        {\n          id: \"fldc2scnky16ae07t\",\n          name: \"Name\",\n          type: \"singleLineText\",\n        },\n        {\n          id: \"fldov4y3i2tojpq9e\",\n          name: \"Price\",\n          type: \"number\",\n        },\n        {\n          id: \"fldh00nlbe0pbmh60\",\n          name: \"Category\",\n          type: \"singleLineText\",\n        },\n        {\n          id: \"fldgdanj899r6y0ua\",\n          name: \"Suppliers\",\n          type: \"multipleRecordLinks\",\n          options: {\n            linkedTableId: \"tblbyte2tg72cbhhf\",\n          },\n        },\n      ],\n    }, {\n      id: \"tblbyte2tg72cbhhf\",\n      name: \"Suppliers\",\n      primaryFieldId: \"fldh8fha8zrd88t3u\",\n      fields: [\n        {\n          id: \"fldh8fha8zrd88t3u\",\n          name: \"Name\",\n          type: \"singleLineText\",\n        },\n        {\n          id: \"fld43552lj107y510\",\n          name: \"Email\",\n          type: \"email\",\n        },\n        {\n          id: \"fldo0m0ozf0k5aatm\",\n          name: \"Phone\",\n          type: \"phoneNumber\",\n        },\n      ],\n    }, {\n      id: \"tblfyhS37Hst5Hvsf\",\n      name: \"Orders\",\n      primaryFieldId: \"fldrk0qj3lm70na2f\",\n      fields: [\n        {\n          id: \"fldrk0qj3lm70na2f\",\n          name: \"Order Number\",\n          type: \"singleLineText\",\n        },\n        {\n          id: \"fldctmhnpzgf98ly5\",\n          name: \"Order Date\",\n          type: \"date\",\n        },\n        {\n          id: \"fldjpzq93zncwwx2z\",\n          name: \"Products\",\n          type: \"multipleRecordLinks\",\n          options: {\n            linkedTableId: \"tbl79ux7qppckp8hr\",\n          },\n        },\n        {\n          id: \"fld5bepfz6vjdjnvq\",\n          name: \"Total Amount\",\n          type: \"number\",\n        },\n      ],\n    }],\n  },\n};\n\n// Sample response to GET https://api.airtable.com/v0/{baseId}/{tableIdOrName}\nconst airtableListRecordsFixture = {\n  tbl79ux7qppckp8hr: {\n    records: [{\n      id: \"reccaegwskzka7wi1\",\n      fields: {\n        Name: \"Widget X\",\n        Price: 99.99,\n        Category: \"Electronics\",\n        Suppliers: [\"recoa4mwyeytxu3fb\"],\n      },\n      createdTime: \"2023-01-01T00:00:00.000Z\",\n    }, {\n      id: \"recigwb4bc7vq2fhd\",\n      fields: {\n        Name: \"Gadget Y\",\n        Price: 149.99,\n        Category: \"Electronics\",\n        Suppliers: [\"recw7cwwskv1q5jck\"],\n      },\n      createdTime: \"2023-01-02T00:00:00.000Z\",\n    }],\n  },\n\n  tblbyte2tg72cbhhf: {\n    records: [{\n      id: \"recoa4mwyeytxu3fb\",\n      fields: {\n        Name: \"Wow Widgets\",\n        Email: \"wowwidgets@example.com\",\n        Phone: \"(123) 456-7890\",\n      },\n      createdTime: \"2023-01-01T00:00:00.000Z\",\n    }, {\n      id: \"recw7cwwskv1q5jck\",\n      fields: {\n        Name: \"Grand Gadgets\",\n        Email: \"grandgadgets@example.com\",\n        Phone: \"(111) 222-3333\",\n      },\n      createdTime: \"2023-01-02T00:00:00.000Z\",\n    }],\n  },\n\n  tblfyhS37Hst5Hvsf: {\n    records: [{\n      id: \"recjngmiw6qy39v53\",\n      fields: {\n        \"Order Number\": \"ord5q3rxaa95gyvrw\",\n        \"Order Date\": \"2023-01-05\",\n        \"Products\": [\"reccaegwskzka7wi1\"],\n        \"Total Amount\": 99.99,\n      },\n      createdTime: \"2023-01-05T10:00:00.000Z\",\n    }, {\n      id: \"recua5n4ir46dn5t6\",\n      fields: {\n        \"Order Number\": \"ordx37praxl2m95wj\",\n        \"Order Date\": \"2023-01-06\",\n        \"Products\": [\"reccaegwskzka7wi1\", \"recigwb4bc7vq2fhd\"],\n        \"Total Amount\": 249.98,\n      },\n      createdTime: \"2023-01-06T11:00:00.000Z\",\n    }],\n  },\n};\n"
  },
  {
    "path": "test/nbrowser/ApiConsole.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport difference from \"lodash/difference\";\nimport { assert, driver, WebElementPromise } from \"mocha-webdriver\";\n\ndescribe(\"ApiConsole\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    const session = await gu.session().user(\"user1\").login();\n    await session.tempDoc(cleanup, \"Hello.grist\");\n    await gu.dismissWelcomeTourIfNeeded();\n  });\n\n  it(\"confirms delete operations before doing them\", async function() {\n    const myTab = await gu.myTab();\n    await openApiConsolePage();\n\n    try {\n      // Start a DELETE operation but cancel it\n      await clickWithRetry(() => gu.scrollIntoView(driver.find(\"div#operations-orgs-deleteOrg\")));\n      await clickWithRetry(() => driver.findWait(\"button.try-out__btn\", 3000));\n      // Fill in the org name parameter\n      await driver.findWait(\"tr[data-param-name='name'] input[placeholder='name']\", 3000).sendKeys(\"Home\");\n      await clickWithRetry(() => driver.findWait(\"button.execute\", 3000));\n      assert.equal(await driver.findWait(\".test-modal-title\", 3000).getText(),\n        \"Confirm Deletion\");\n      await driver.findWait(\"button.test-modal-cancel\", 3000).click();\n      let toasts = await gu.getToasts();\n      assert.equal(toasts.length, 1);\n      assert.match(toasts[0], /Deletion was not confirmed/);\n      await gu.wipeToasts();\n\n      // Start a DELETE operation and confirm it without writing \"DELETE\"\n      await clickWithRetry(() => driver.findWait(\"button.try-out__btn.cancel\", 3000));\n      await clickWithRetry(() => driver.findWait(\"button.try-out__btn:not(.cancel)\", 3000));\n      await clickWithRetry(() => driver.findWait(\"button.execute\", 3000));\n      await driver.findWait(\"button.test-modal-confirm\", 3000).click();\n      toasts = await gu.getToasts();\n      assert.equal(toasts.length, 1);\n      assert.match(toasts[0], /Deletion was not confirmed/);\n      await gu.wipeToasts();\n\n      // Start a DELETE operation and confirm it without writing \"DELETE\" correctly\n      await clickWithRetry(() => driver.findWait(\"button.try-out__btn.cancel\", 3000));\n      await clickWithRetry(() => driver.findWait(\"button.try-out__btn:not(.cancel)\", 3000));\n      await driver.findWait(\"button.execute\", 3000).click();\n      await driver.findWait(\"input.test-modal-prompt\", 3000).sendKeys(\"DELETENO\");\n      await driver.findWait(\"button.test-modal-confirm\", 3000).click();\n      toasts = await gu.getToasts();\n      assert.equal(toasts.length, 1);\n      assert.match(toasts[0], /Deletion was not confirmed/);\n      await gu.wipeToasts();\n\n      // Start a DELETE operation and confirm it with \"DELETE\"\n      await clickWithRetry(() => driver.findWait(\"button.try-out__btn.cancel\", 3000));\n      await clickWithRetry(() => driver.findWait(\"button.try-out__btn:not(.cancel)\", 3000));\n      await driver.findWait(\"button.execute\", 3000).click();\n      await driver.findWait(\"input.test-modal-prompt\", 3000).sendKeys(\"DELETE\");\n      await driver.findWait(\"button.test-modal-confirm\", 3000).click();\n      toasts = await gu.getToasts();\n      assert.equal(toasts.length, 0);\n    } finally {\n      // There is an extra browser tab open for the api console.\n      await driver.close();\n      await myTab.open();\n    }\n  });\n});\n\nasync function openApiConsolePage() {\n  await gu.wipeToasts();\n  await gu.openDocumentSettings();\n  const tabs = await driver.getAllWindowHandles();\n  await gu.scrollIntoView(driver.findContentWait(\"a\", /API console/i, 3000)).click();\n  const newTab = difference(await driver.getAllWindowHandles(), tabs)[0];\n  assert.isDefined(newTab);\n  await driver.switchTo().window(newTab);\n  await driver.findContentWait(\"p\", /An API for manipulating Grist/, 3000);\n  // Swagger code is external, a little slow, a little async.\n  // Give it some time. Not obvious what to wait on.\n  await driver.sleep(2000);\n}\n\n// The swagger-ui code is external, a little slow, a little async.\n// Elements can flicker in and out of existence. Maybe there is a\n// smart way to deal with this, but in the absence of that, here's\n// a little retry.\nasync function clickWithRetry(finder: () => WebElementPromise) {\n  try {\n    await finder().click();\n  } catch (e) {\n    await finder().click();\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/AttachedCustomWidget.ts",
    "content": "import { ICustomWidget } from \"app/common/CustomWidget\";\nimport { getAppRoot } from \"app/server/lib/places\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { serveSomething } from \"test/server/customUtil\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport path from \"path\";\n\nimport { assert, By, driver } from \"mocha-webdriver\";\n\ndescribe(\"AttachedCustomWidget\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let oldEnv: EnvironmentSnapshot;\n  // Valid manifest url.\n  const manifestEndpoint = \"/manifest.json\";\n  // Valid widget url.\n  const widgetEndpoint = \"/widget\";\n  // Create some widgets:\n  const widget1: ICustomWidget = {\n    widgetId: \"@gristlabs/widget-calendar\",\n    name: \"Calendar\",\n    url: widgetEndpoint + \"?name=Calendar\",\n  };\n  let widgetServerUrl = \"\";\n  // Holds widgets manifest content.\n  let widgets: ICustomWidget[] = [];\n\n  async function buildWidgetServer() {\n    // Create simple widget server that serves manifest.json file, some widgets and some error pages.\n    const widgetServer = await serveSomething((app) => {\n      app.get(widgetEndpoint, (req, res) =>\n        res\n          .header(\"Content-Type\", \"text/html\")\n          .send('<html><head><script src=\"/grist-plugin-api.js\"></script></head><body>\\n' +\n            (req.query.name || req.query.access) + // send back widget name from query string or access level\n            \"</body>\" +\n            \"<script>grist.ready({requiredAccess: 'full', columns: [{name: 'Content', type: 'Text', optional: true}],\" +\n            \" onEditOptions(){}})</script>\" +\n            \"</html>\\n\")\n          .end(),\n      );\n      app.get(manifestEndpoint, (_, res) =>\n        res\n          .header(\"Content-Type\", \"application/json\")\n          // prefix widget endpoint with server address\n          .json(widgets.map(widget => ({ ...widget, url: `${widgetServerUrl}${widget.url}` })))\n          .end(),\n      );\n      app.get(\"/grist-plugin-api.js\", (_, res) =>\n        res.sendFile(\n          \"grist-plugin-api.js\", {\n            root: path.resolve(getAppRoot(), \"static\"),\n          }));\n    });\n\n    cleanup.addAfterAll(widgetServer.shutdown);\n    widgetServerUrl = widgetServer.url;\n\n    widgets = [widget1];\n  }\n\n  before(async function() {\n    await buildWidgetServer();\n    oldEnv = new EnvironmentSnapshot();\n    process.env.GRIST_WIDGET_LIST_URL = `${widgetServerUrl}${manifestEndpoint}`;\n    process.env.PERMITTED_CUSTOM_WIDGETS = \"calendar\";\n    await server.restart();\n    const session = await gu.session().login();\n    await session.tempDoc(cleanup, \"Hello.grist\");\n  });\n\n  after(async function() {\n    oldEnv.restore();\n    await server.restart();\n  });\n\n  it(\"should be able to attach Calendar Widget\", async () => {\n    await gu.openAddWidgetToPage();\n    const calendarElement = await driver.findContent(\".test-wselect-type\", /Calendar/);\n    assert.exists(calendarElement, \"Calendar widget is not found in the list of widgets\");\n  });\n\n  it(\"should not ask for permission\", async () => {\n    await gu.addNewSection(/Calendar/, /Table1/, { selectBy: /TABLE1/ });\n    await gu.getSection(\"TABLE1 Calendar\").click();\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n\n    await gu.waitForServer();\n\n    // Check if widget config panel is here\n    await driver.findWait(\".test-config-container\", 2000);\n\n    const widgetOptions = await driver.findWait(\".test-config-widget-open-configuration\", 2000);\n    const widgetMapping = await driver.find(\".test-config-widget-mapping-for-Content\");\n    const widgetSelection = await driver.findElements(By.css(\".test-config-widget-select\"));\n    const widgetPermission = await driver.findElements(By.css(\".test-wselect-permission\"));\n\n    assert.isEmpty(widgetSelection, \"Widget selection is not expected to be present\");\n    assert.isEmpty(widgetPermission, \"Widget permission is not expected to be present\");\n    assert.exists(widgetOptions, \"Widget options is expected to be present\");\n    assert.exists(widgetMapping, \"Widget mapping is expected to be present\");\n  });\n\n  it(\"should display the content of the widget\", async () => {\n    await gu.getSection(\"TABLE1 Calendar\").click();\n    try {\n      await driver.switchTo().frame(await driver.findWait(\".custom_view\", 1000));\n      const editor = await driver.findContentWait(\"body\", \"Calendar\", 1000);\n      assert.exists(editor);\n    } finally {\n      await driver.switchTo().defaultContent();\n    }\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/AttachmentsLinking.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { Session } from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert } from \"mocha-webdriver\";\n\ndescribe(\"AttachmentsLinking\", function() {\n  this.timeout(20000);\n\n  const cleanup = setupTestSuite({ team: true });\n  let session: Session;\n  let docId: string;\n\n  before(async function() {\n    session = await gu.session().login({ retainExistingLogin: false });\n    docId = await session.tempNewDoc(cleanup, \"AttachmentColumns\", { load: false });\n\n    // Set up a table Src, and table Items which links to Src and has an Attachments column.\n    const api = session.createHomeApi();\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"Src\", [{ id: \"A\", type: \"Text\" }]],\n      [\"BulkAddRecord\", \"Src\", [null, null, null], { A: [\"a\", \"b\", \"c\"] }],\n      [\"AddTable\", \"Items\", [\n        { id: \"A\", type: \"Ref:Src\" },\n        { id: \"Att\", type: \"Attachments\" },\n      ]],\n      [\"BulkAddRecord\", \"Items\", [null, null, null], { A: [1, 1, 3] }],\n    ]);\n\n    await session.loadDoc(`/doc/${docId}`);\n\n    // Set up a page with linked widgets.\n    await gu.addNewPage(\"Table\", \"Src\");\n    await gu.addNewSection(\"Table\", \"Items\", { selectBy: /Src/i });\n  });\n\n  it(\"should fill in values determined by linking when uploading to the add row\", async function() {\n    // TODO Another good test case would be that dragging a file into a cell works, especially\n    // when that cell isn't in the selected widget. But this doesn't seem supported by webdriver.\n\n    // Selecting a cell in Src should show only linked values in Items.\n    await gu.getCell({ section: \"Src\", col: \"A\", rowNum: 1 }).click();\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Items\", cols: [\"A\", \"Att\"], rowNums: [1, 2, 3] }), [\n      \"Src[1]\", \"\",\n      \"Src[1]\", \"\",\n      \"\", \"\",\n    ]);\n\n    // Upload into an Attachments cell in the \"Add Row\" of Items.\n    assert.equal(await gu.getCell({ section: \"Items\", col: 0, rowNum: 4 }).isPresent(), false);\n\n    let cell = await gu.getCell({ section: \"Items\", col: \"Att\", rowNum: 3 });\n    await cell.click();\n    await gu.fileDialogUpload(\"uploads/file1.mov\", () => cell.find(\".test-attachment-icon\").click());\n    await gu.waitToPass(async () =>\n      assert.lengthOf(await gu.getCell({ section: \"Items\", col: \"Att\", rowNum: 3 }).findAll(\".test-pw-thumbnail\"), 1));\n\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Items\", cols: [\"A\", \"Att\"], rowNums: [1, 2, 3, 4] }), [\n      \"Src[1]\", \"\",\n      \"Src[1]\", \"\",\n      \"Src[1]\", \"MOV\",\n      \"\", \"\",\n    ]);\n\n    // Switch to another Src row; should see no attachments.\n    await gu.getCell({ section: \"Src\", col: \"A\", rowNum: 2 }).click();\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Items\", cols: [\"A\", \"Att\"], rowNums: [1] }), [\n      \"\", \"\",\n    ]);\n\n    cell = await gu.getCell({ section: \"Items\", col: \"Att\", rowNum: 1 });\n    await cell.click();\n    await gu.fileDialogUpload(\"uploads/htmlfile.html,uploads/file1.mov\",\n      () => cell.find(\".test-attachment-icon\").click());\n    await gu.waitToPass(async () =>\n      assert.lengthOf(await gu.getCell({ section: \"Items\", col: \"Att\", rowNum: 1 }).findAll(\".test-pw-thumbnail\"), 2));\n\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Items\", cols: [\"A\", \"Att\"], rowNums: [1, 2] }), [\n      \"Src[2]\", \"HTML\\nMOV\",\n      \"\", \"\",\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/AttachmentsTransfer.ts",
    "content": "import { AttachmentsArchiveParams, DocAPI } from \"app/common/UserAPI\";\nimport { enableExternalAttachmentsForTestSuite } from \"test/nbrowser/externalAttachmentsHelpers\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { fileDialogUpload, TestUser } from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { createTmpDir } from \"test/server/docTools\";\n\nimport fs from \"fs\";\nimport stream from \"node:stream\";\nimport path from \"path\";\n\nimport axios from \"axios\";\nimport fse from \"fs-extra\";\nimport { assert, driver, Key, WebElementPromise } from \"mocha-webdriver\";\n\ndescribe(\"AttachmentsTransfer\", function() {\n  this.timeout(\"6m\");\n  const cleanup = setupTestSuite();\n  let docId: string;\n  let session: gu.Session;\n  let tmpDownloadsFolder: string;\n  let reqHeaders: { Authorization: string } | undefined;\n  let api: DocAPI;\n\n  before(async function() {\n    tmpDownloadsFolder = await createTmpDir();\n\n    session = await gu.session().teamSite.login();\n    reqHeaders = {\n      Authorization: `Bearer ${session.getApiKey()}`,\n    };\n  });\n\n  afterEach(async function() {\n    await gu.checkForErrors();\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  describe(\"without external attachments enabled\", () => {\n    it(\"should show message that transfers are not configured\", async function() {\n      docId = await session.tempNewDoc(cleanup);\n      api = session.createHomeApi().getDocAPI(docId);\n\n      // Open document settings.\n      await gu.openDocumentSettings();\n\n      // We should see 1 message about no stores.\n      await gu.waitToPass(async () => assert.lengthOf(await messages(), 1));\n      assert.isTrue(await noStoresWarning().isDisplayed());\n    });\n\n    it(\"should hide section for non owner\", async function() {\n      // Now login as editor and viewer, and make sure section is hidden.\n      const homeApi = session.createHomeApi();\n      await homeApi.updateDocPermissions(docId, {\n        users: {\n          [gu.translateUser(\"user2\").email]: \"viewers\",\n          [gu.translateUser(\"user3\").email]: \"editors\",\n        },\n      });\n\n      async function checkFor(user: TestUser) {\n        const s = await gu.session().teamSite.user(user).login();\n        await s.loadRelPath(`/doc/${docId}`);\n        await gu.openDocumentSettings();\n        await driver.findWait(\".test-admin-panel-item-timezone\", 1000);\n        await waitForNotPresent(attachmentSection);\n      }\n\n      await checkFor(\"user2\");\n      await checkFor(\"user3\");\n\n      await session.login();\n      await session.loadRelPath(`/doc/${docId}`);\n    });\n  });\n\n  describe(\"with external attachments enabled\", () => {\n    const externalAttachments = enableExternalAttachmentsForTestSuite({ transferDelay: 500 });\n\n    /** Files will be stored in a folder inside the tmpFolder. Here is a helper that will get files names from it. */\n    const files = () => {\n      const tmpAttachmentsFolder = externalAttachments.getAttachmentsDir();\n      const isDir = (f: string) => fs.statSync(path.join(tmpAttachmentsFolder, f)).isDirectory();\n      const dirs = fs.readdirSync(tmpAttachmentsFolder).filter(isDir);\n      if (dirs.length === 0) { return []; }\n      if (dirs.length > 1) { throw new Error(\"Unexpected number of directories\"); }\n      return fs.readdirSync(path.join(tmpAttachmentsFolder, dirs[0]));\n    };\n\n    it(\"should show transfer menu\", async function() {\n      await session.loadRelPath(`/doc/${docId}`);\n\n      // Open document settings.\n      await gu.openDocumentSettings();\n\n      // Storage type should be set to Internal\n      assert.equal(await storageType.value(), \"Internal\");\n\n      // We should see Internal and External options in the storage type dropdown.\n      assert.deepEqual(await storageType.options(), [\"Internal\", \"External\"]);\n\n      // Now change to internal.\n      await storageType.select(\"External\");\n\n      // The value now should be External.\n      assert.equal(await storageType.value(), \"External\");\n\n      // We shouldn't see any info as there are no attachments yet.\n      assert.lengthOf(await messages(), 0);\n\n      // Go back to internal.\n      await storageType.select(\"Internal\");\n    });\n\n    it(\"should show actions when some attachments are added\", async function() {\n      // Upload four attachments.\n      await gu.openPage(\"Table1\");\n      await gu.selectColumn(\"A\");\n      await gu.setType(\"Attachment\");\n      await gu.toggleSidePanel(\"right\", \"close\");\n      await addRow();\n      const cell = await gu.getCell(\"A\", 1);\n      await gu.openUploadDialog(cell);\n      await gu.uploadFiles(\"uploads/file1.mov\", \"uploads/file2.mp3\", \"uploads/file3.zip\", \"uploads/simple_array.json\");\n      await gu.waitForAttachments(cell, 4);\n\n      // Now switch to external to test the copy.\n      await gu.openDocumentSettings();\n      await gu.toggleSidePanel(\"left\", \"close\");\n      await storageType.select(\"External\");\n\n      // We should see a message about attachments being still internal.\n      assert.lengthOf(await messages(), 1);\n      assert.isTrue(await internalCopy().isDisplayed());\n\n      // We should see start transfer button.\n      assert.isTrue(await startTransferButton().isDisplayed());\n\n      // When we switch back to internal, the message should be gone.\n      await storageType.select(\"Internal\");\n      assert.lengthOf(await messages(), 0);\n      assert.isFalse(await startTransferButton().isPresent());\n\n      // Now switch back to external.\n      await storageType.select(\"External\");\n      assert.lengthOf(await messages(), 1);\n      assert.isTrue(await internalCopy().isDisplayed());\n      assert.isTrue(await startTransferButton().isDisplayed());\n    });\n\n    it(\"should transfer files to external storage\", async function() {\n      // First make sure that the tmp folder is empty.\n      assert.lengthOf(files(), 0);\n\n      // Start transfer.\n      await startTransferButton().click();\n      await gu.waitForServer();\n\n      // We should see transfer spinner.\n      await waitForDisplay(transferSpinner);\n\n      // Wait for the spinner to disappear.\n      await waitForNotPresent(transferSpinner);\n\n      // We now should have those files transfer.\n      assert.lengthOf(files(), 4);\n\n      // We are not testing here if transfer works or not, just the correct number of files is enough.\n\n      // Make sure that transfer button is gone.\n      assert.isFalse(await startTransferButton().isPresent());\n\n      // And we don't have any messages.\n      assert.lengthOf(await messages(), 0);\n    });\n\n    it(\"warns users downloading the doc that attachments are external\", async function() {\n      try {\n        await driver.find(\".test-tb-share\").click();\n        await driver.findContentWait(\".test-tb-share-option\", /Download document/, 5000).click();\n        const attachmentsMsg = await driver.findWait(\".test-external-attachments-info\", 1000).getText();\n\n        assert.match(attachmentsMsg, /Attachments are external/, \"should be informed attachments aren't included\");\n\n        const downloadHref = await driver.find(\".test-external-attachments-info a\").getAttribute(\"href\");\n        const downloadUrl = new URL(downloadHref);\n        const idealUrl = new URL(api.getDownloadAttachmentsArchiveUrl({ format: \"tar\" }));\n        assert.equal(downloadUrl.pathname, idealUrl.pathname, \"wrong download link called\");\n        assert.equal(downloadUrl.search, idealUrl.search, \"wrong search parameters in url\");\n        // Ensures the page isn't modified / navigated away from by the link, as subsequent tests will fail.\n        await driver.find(\".test-external-attachments-info a\").click();\n      } finally {\n        // Try to close the modal to minimise the chances of other tests failing.\n        await driver.findContent(\"button\", \"Cancel\").click();\n      }\n    });\n\n    it(\"can download attachments\", async function() {\n      try {\n        await driver.find(\".test-tb-share\").click();\n        await driver.findContentWait(\".test-tb-share-option\", /Download attachments/, 5000).click();\n        const attachmentsStatus = await driver.findWait(\".test-attachments-external-message\", 1000).getText();\n\n        assert.match(attachmentsStatus, /in the \".tar\" format/, \"users should be advised to use .tar\");\n\n        const selectFormat = async (formatRegex: RegExp) => {\n          await driver.findWait(\".test-attachments-format-select\", 500).click();\n          await driver.findContentWait(\".test-attachments-format-options .test-select-row\", formatRegex, 500).click();\n        };\n\n        const testDownloadLink = async (params: AttachmentsArchiveParams) => {\n          const downloadUrl = new URL(await driver.find(\".test-download-attachments-button-link\").getAttribute(\"href\"));\n          const idealUrl = new URL(api.getDownloadAttachmentsArchiveUrl(params));\n          assert.equal(downloadUrl.pathname, idealUrl.pathname, \"wrong download link called\");\n          assert.equal(downloadUrl.search, idealUrl.search, \"wrong search parameters in url\");\n          const response = await axios.get(downloadUrl.toString(), {\n            headers: reqHeaders,\n            responseType: \"stream\",\n          });\n          // Download the file so we've got a copy available for uploading.\n          const fileName = `attachments.${params.format}`;\n          assert.notInclude(await fse.readdir(tmpDownloadsFolder), fileName, \"attachments file shouldn't exist yet\");\n          await stream.promises.pipeline(\n            response.data,\n            fs.createWriteStream(path.join(tmpDownloadsFolder, fileName)),\n          );\n          assert.include(await fse.readdir(tmpDownloadsFolder), fileName, \"attachments file wasn't downloaded\");\n        };\n\n        await selectFormat(/.tar/);\n        await gu.waitToPass(() => testDownloadLink({ format: \"tar\" }), 500);\n        await selectFormat(/.zip/);\n        await gu.waitToPass(() => testDownloadLink({ format: \"zip\" }), 500);\n      } finally {\n        // Try to close the modal to minimise the chances of other tests failing.\n        await driver.findContent(\"button\", \"Cancel\").click();\n      }\n    });\n\n    it(\"can upload attachments\", async function() {\n      const file = path.join(tmpDownloadsFolder, \"attachments.tar\");\n      await fileDialogUpload(file, async () => {\n        await driver.find(\".test-settings-upload-attachment-archive\").click();\n      });\n      assert.match(\n        await driver.findWait(\".test-notifier-toast-message\", 1000).getText(),\n        // Only care that the request is made, and that the UI behaves as expected.\n        // Don't care about checking attachments reconnect behaviour - API tests cover that.\n        /0 attachment files reconnected/,\n      );\n      await driver.findWait(\".test-notifier-toast-close\", 2000).click();\n    });\n\n    it(\"should transfer files to internal storage\", async function() {\n      // Switch to internal.\n      await storageType.select(\"Internal\");\n\n      // We should see new copy and transfer button.\n      assert.lengthOf(await messages(), 1);\n      assert.isFalse(await internalCopy().isPresent());\n      assert.isTrue(await externalCopy().isDisplayed());\n      assert.isTrue(await startTransferButton().isDisplayed());\n\n      // Switching back hides everything.\n      await storageType.select(\"External\");\n      assert.lengthOf(await messages(), 0);\n      assert.isFalse(await startTransferButton().isPresent());\n\n      // Switch back to internal.\n      await storageType.select(\"Internal\");\n\n      // Start transfer.\n      await startTransferButton().click();\n\n      // We should see transfer spinner.\n      assert.isTrue(await transferSpinner(WAIT).isDisplayed());\n\n      // Wait for the spinner to disappear.\n      await gu.waitToPass(async () => assert.isFalse(await transferSpinner().isPresent()));\n      await gu.waitForServer();\n\n      // We should see that internal storage is selected.\n      assert.equal(await storageType.value(), \"Internal\");\n\n      // And we don't have any messages here.\n      assert.lengthOf(await messages(), 0);\n\n      // Even after reload.\n      await driver.navigate().refresh();\n      await storageType.waitForDisplay();\n      assert.lengthOf(await messages(), 0);\n      assert.equal(await storageType.value(), \"Internal\");\n    });\n\n    // Here we do the same stuff but with the API calls, and we expect that the UI will react to it.\n    it(\"user should be able to observe background actions\", async function() {\n      // Sanity check.\n      assert.equal(await storageType.value(), \"Internal\");\n\n      // Set to external.\n      await api.setAttachmentStore(\"external\");\n\n      // The value should be changed.\n      await storageType.waitForValue(\"External\");\n\n      // We should see the message.\n      assert.lengthOf(await messages(), 1);\n      assert.isTrue(await internalCopy().isDisplayed());\n      // And the button to start transfer.\n      assert.isTrue(await startTransferButton().isDisplayed());\n\n      // Move back to internal and check that the message is gone.\n      await api.setAttachmentStore(\"internal\");\n      await storageType.waitForValue(\"Internal\");\n      assert.lengthOf(await messages(), 0);\n      assert.isFalse(await startTransferButton().isPresent());\n\n      // Set to external again.\n      await api.setAttachmentStore(\"external\");\n      await storageType.waitForValue(\"External\");\n      await waitForDisplay(startTransferButton);\n\n      // We are seeing that some files are internal.\n      assert.isTrue(await internalCopy().isDisplayed());\n\n      // The copy version is static.\n      assert.isTrue(await internalCopy().isStatic());\n\n      // And start transfer.\n      await api.transferAllAttachments();\n\n      // Wait for the spinner to be shown.\n      await waitForDisplay(transferSpinner);\n\n      // The internal copy should be changed during the transfer.\n      assert.isTrue(await internalCopy().inProgress());\n\n      // Wait for the spinner to disappear.\n      await waitForNotPresent(transferSpinner);\n\n      // Transfer button should also disappear\n      await waitForNotPresent(startTransferButton);\n\n      // And all messages should be gone.\n      assert.lengthOf(await messages(), 0);\n\n      // Now go back to internal.\n      await api.setAttachmentStore(\"internal\");\n      await storageType.waitForValue(\"Internal\");\n      assert.lengthOf(await messages(), 1);\n      assert.isTrue(await externalCopy().isDisplayed());\n      assert.isTrue(await externalCopy().isStatic());\n      assert.isTrue(await startTransferButton().isDisplayed());\n      assert.isFalse(await transferSpinner().isPresent());\n\n      // Start transfer and check components.\n      await api.transferAllAttachments();\n      await waitForDisplay(transferSpinner);\n      assert.isTrue(await externalCopy().inProgress());\n      await waitForNotPresent(transferSpinner);\n      await waitForNotPresent(startTransferButton);\n      assert.lengthOf(await messages(), 0);\n    });\n  });\n});\n\nconst storageType = gu.buildSelectComponent(\".test-settings-transfer-storage-select\");\n\nconst messages = () => driver.findAll(\".test-settings-transfer-message\", e => e.getText());\n\nconst copyWrapper = <T extends WebElementPromise>(el: T) => {\n  return Object.assign(el, {\n    inProgress() {\n      return el.matches(\".test-settings-transfer-message-in-progress\");\n    },\n    isStatic() {\n      return el.matches(\".test-settings-transfer-message-static\");\n    },\n  });\n};\n\nconst internalCopy = () => copyWrapper(driver.find(\".test-settings-transfer-still-internal-copy\"));\n\nconst externalCopy = () => copyWrapper(driver.find(\".test-settings-transfer-still-external-copy\"));\n\nconst noStoresWarning = () => driver.find(\".test-settings-transfer-no-stores-warning\");\n\nconst addRow = async () => {\n  await gu.sendKeys(Key.chord(await gu.modKey(), Key.ENTER));\n  await gu.waitForServer();\n};\n\nconst startTransferButton = () => driver.find(\".test-settings-transfer-start-button\");\n\nconst WAIT = true;\nconst transferSpinner = (wait = false) => wait ?\n  driver.findWait(\".test-settings-transfer-spinner\", 500) :\n  driver.find(\".test-settings-transfer-spinner\");\n\nasync function waitForDisplay(fn: () => WebElementPromise) {\n  await gu.waitToPass(async () => {\n    assert.isTrue(await fn().isDisplayed());\n  });\n}\n\nasync function waitForNotPresent(fn: () => WebElementPromise) {\n  await gu.waitToPass(async () => {\n    assert.isFalse(await fn().isPresent());\n  });\n}\n\nconst attachmentSection = () => driver.find(\".test-admin-panel-item-preferredStorage\");\n"
  },
  {
    "path": "test/nbrowser/AttachmentsWidget.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport {\n  assert,\n  driver,\n  Key,\n  stackWrapFunc,\n  WebElement,\n} from \"mocha-webdriver\";\nimport fetch from \"node-fetch\";\n\ndescribe(\"AttachmentsWidget\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let docId: string;\n  let session: gu.Session;\n\n  before(async function() {\n    session = await gu.session().user(\"user1\").teamSite.login();\n    docId = (await session.tempDoc(cleanup, \"Hello.grist\")).id;\n  });\n\n  afterEach(async function() {\n    await gu.checkForErrors();\n  });\n\n  after(async function() {\n    // Close any open cell/attachments editor, to avoid an unload alert that would interfere with\n    // the next test suite.\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  // Returns the 'title' attributes of all attachments in the given cell. These should be the\n  // names of the attached files.\n  const getCellThumbnailTitles = stackWrapFunc(async function(\n    cell: WebElement,\n  ) {\n    return await cell.findAll(\".test-pw-thumbnail\", el =>\n      el.getAttribute(\"title\"),\n    );\n  });\n\n  it(\"should include a functioning attachment widget\", async function() {\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n\n    // Move to first column\n    await gu.getCell(0, 1).click();\n\n    // Change type to Attachment.\n    await gu.setType(/Attachment/);\n    await driver.findWait(\".test-type-transform-apply\", 1000).click();\n    await gu.waitForServer();\n    assert.equal(\n      await gu.getCell(0, 2).find(\".test-attachment-widget\").isDisplayed(),\n      true,\n    );\n  });\n\n  it(\"should include a functioning upload button\", async function() {\n    // Put 'foo1' in a cell, then replace it immediately with 'foo2'.\n    // This is just setting up for testing undo behaviour below.\n    await gu.getCell(1, 2).click();\n    await driver.sendKeys(\"foo1\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.getCell(1, 2).click();\n    await driver.sendKeys(\"foo2\", Key.ENTER);\n    await gu.waitForServer();\n\n    await gu.getCell(0, 2).click();\n    await driver.sendKeys(Key.ENTER);\n\n    await gu.fileDialogUpload(\"uploads/sample.pdf,uploads/grist.png\", () =>\n      driver.find(\".test-pw-add\").click(),\n    );\n    await driver.findContentWait(\".test-pw-counter\", /of 2/, 3000);\n\n    const href: string = await driver\n      .findWait(\".test-pw-download\", 2000)\n      .getAttribute(\"href\");\n    assert.include(href.split(\"name=\")[1], \"sample.pdf\");\n    assert.equal(await driver.find(\".test-pw-counter\").getText(), \"1 of 2\");\n    await driver.find(\".test-modal-dialog .test-pw-close\").click();\n    await gu.waitForServer();\n\n    // Check that title attributes are set to file names.\n    assert.deepEqual(await getCellThumbnailTitles(gu.getCell(0, 2)), [\n      \"sample.pdf\",\n      \"grist.png\",\n    ]);\n\n    // Check that in the absence of a thumbnail we show the extension.\n    assert.deepEqual(\n      await gu\n        .getCell(0, 2)\n        .findAll(\".test-pw-thumbnail\", el => el.getText()),\n      [\"PDF\", \"\"],\n    );\n\n    async function checkState(expectedCells: string[], isSoftDeleted: boolean) {\n      assert.deepEqual(\n        await gu.getVisibleGridCells({ cols: [0, 1], rowNums: [2] }),\n        expectedCells,\n      );\n\n      // Previously, undo would remove the uploaded attachment metadata completely,\n      // which could lead to hard deleting the file data and leaving broken attachments after redo.\n      // Here we check that after checking for unused attachments and removing expired ones\n      // (as should happen automatically every hour)\n      // the metadata records (and thus files) are still there, but appropriately marked as soft deleted.\n\n      const headers = { Authorization: `Bearer ${session.getApiKey()}` };\n      const url = server.getUrl(session.orgDomain, `/api/docs/${docId}`);\n      let resp = await fetch(\n        url + \"/attachments/removeUnused?verifyfiles=1&expiredonly=1\",\n        { headers, method: \"POST\" },\n      );\n      assert.equal(resp.status, 200);\n      resp = await fetch(url + \"/tables/_grist_Attachments/records\", {\n        headers,\n      });\n      const data = await resp.json();\n      assert.lengthOf(data.records, 2);\n      for (const record of data.records) {\n        assert.equal(Boolean(record.fields.timeDeleted), isSoftDeleted);\n      }\n    }\n\n    // Check current state before testing undo/redo\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [0, 1], rowNums: [2] }),\n      [\"PDF\", \"foo2\"],\n    );\n    await checkState([\"PDF\", \"foo2\"], false);\n\n    // Check that undo once removes the attachments we just added to the cell\n    await gu.undo();\n    await checkState([\"\", \"foo2\"], true); // true: attachment metadata marked as soft deleted\n\n    // Check that undo again undoes the thing we did before attaching: changing foo1 to foo2\n    // (previously it would undo creating the attachment metadata, which was invisible)\n    await gu.undo();\n    await checkState([\"\", \"foo1\"], true);\n\n    // Check that redoing twice restores things as expected\n    await gu.redo();\n    await checkState([\"\", \"foo2\"], true);\n    await gu.redo();\n    await checkState([\"PDF\", \"foo2\"], false); // false: attachment metadata un-deleted\n  });\n\n  it(\"should allow resizing thumbnails\", async function() {\n    const slider = await driver.find(\".test-pw-thumbnail-size\");\n    assert.equal(\n      (await driver.findWait(\".test-pw-thumbnail:last-child\", 1000).getRect())\n        .height,\n      36,\n    );\n    await slider.sendKeys(\"a\"); // Focus the slider.\n    await driver.sleep(20);\n    for (let i = 0; i < 5; i++) {\n      await slider.sendKeys(Key.RIGHT);\n      await driver.sleep(20);\n      assert.equal(\n        (await driver.find(\".test-pw-thumbnail:last-child\").getRect()).height,\n        36 + i + 1,\n      );\n    }\n    await gu.waitForServer();\n    assert.equal(\n      (await driver.find(\".test-pw-thumbnail:last-child\").getRect()).height,\n      41,\n    );\n    for (let i = 0; i < 3; i++) {\n      await slider.sendKeys(Key.LEFT);\n      await driver.sleep(20);\n    }\n    await gu.waitForServer();\n    assert.equal(\n      (await driver.find(\".test-pw-thumbnail:last-child\").getRect()).height,\n      38,\n    );\n    // Wait to ensure the new setting is saved.\n    await gu.waitForServer();\n\n    // Thumbnail size setting should persist across refresh\n    await driver.navigate().refresh();\n    await gu.waitForServer(10000);\n    assert.equal(\n      (await driver.findWait(\".test-pw-thumbnail:last-child\", 1000).getRect())\n        .height,\n      38,\n    );\n  });\n\n  it(\"should get correct headers from the server\", async function() {\n    const cell: any = gu.getCell(0, 2);\n    await cell.click();\n    await driver.sendKeys(Key.ENTER);\n\n    const fetchOptions = {\n      headers: { Authorization: `Bearer ${session.getApiKey()}` },\n    };\n    const hrefDownload = await driver\n      .findWait(\".test-pw-download\", 500)\n      .getAttribute(\"href\");\n    const respDownload = await fetch(hrefDownload, fetchOptions);\n    assert.equal(\n      respDownload.headers.get(\"Content-Disposition\"),\n      'attachment; filename=\"sample.pdf\"',\n    );\n    assert.equal(\n      respDownload.headers.get(\"Content-Security-Policy\"),\n      \"sandbox; default-src: 'none'\",\n    );\n\n    const hrefInline = await driver\n      .find(\".test-pw-attachment-content\")\n      .getAttribute(\"data\");\n    const respInline = await fetch(hrefInline, fetchOptions);\n    assert.equal(\n      respInline.headers.get(\"Content-Disposition\"),\n      'inline; filename=\"sample.pdf\"',\n    );\n\n    // Attach an html file and ensure it doesn't get served inline.\n    await gu.fileDialogUpload(\"uploads/htmlfile.html\", () =>\n      driver.findWait(\".test-pw-add\", 500).click(),\n    );\n    await driver.findContentWait(\".test-pw-counter\", /of 3/, 3000);\n\n    const hrefLinkHtml = await driver\n      .findWait(\".test-pw-download\", 2000)\n      .getAttribute(\"href\");\n    const respLinkHtml = await fetch(hrefLinkHtml, fetchOptions);\n    // Note that the disposition here is NOT \"inline\" (that would be bad).\n    assert.equal(\n      respLinkHtml.headers.get(\"Content-Disposition\"),\n      'attachment; filename=\"htmlfile.html\"',\n    );\n    await driver.find(\".test-modal-dialog .test-pw-close\").click();\n  });\n\n  it(\"should allow editing the attachments list\", async function() {\n    let cell = gu.getCell(0, 2);\n    assert.deepEqual(await getCellThumbnailTitles(cell), [\n      \"sample.pdf\",\n      \"grist.png\",\n      \"htmlfile.html\",\n    ]);\n\n    // Open an image preview.\n    await driver.withActions(a =>\n      a.doubleClick(driver.find(\".test-pw-thumbnail\")),\n    );\n\n    assert.equal(\n      await driver.findWait(\".test-pw-counter\", 500).getText(),\n      \"1 of 3\",\n    );\n\n    // Assert that the attachment filename can be changed.\n    await driver\n      .find(\".test-pw-name\")\n      .doClick()\n      .sendKeys(\"renamed.pdf\", Key.ENTER);\n    await gu.waitForServer();\n    // Wait for doc name input to lose focus, indicating that the save call completed.\n    await driver.findWait(\".test-bc-doc:not(:focus)\", 2000);\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"renamed.pdf\");\n\n    // Assert that the attachment has the correct download link.\n    const href = await driver.find(\".test-pw-download\").getAttribute(\"href\");\n    assert.include(href, \"attId=1\");\n    assert.include(href, \"name=renamed.pdf\");\n\n    // Assert that other previews can be viewed without closing the modal.\n    await driver.find(\".test-pw-right\").click();\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"grist.png\");\n    await driver.find(\".test-pw-left\").click();\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"renamed.pdf\");\n    await driver.sendKeys(Key.RIGHT, Key.RIGHT);\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"htmlfile.html\");\n\n    // Assert that the attachment can be removed from the cell.\n    assert.equal(await driver.find(\".test-pw-counter\").getText(), \"3 of 3\");\n    await driver.find(\".test-pw-remove\").click();\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".test-pw-counter\").getText(), \"2 of 2\");\n    await driver.find(\".test-modal-dialog .test-pw-close\").click();\n    cell = gu.getCell(0, 2);\n    assert.deepEqual(await getCellThumbnailTitles(cell), [\n      \"renamed.pdf\",\n      \"grist.png\",\n    ]);\n  });\n\n  it(\"should allow uploading to the add row\", async function() {\n    assert.equal(await gu.getCell({ col: 0, rowNum: 6 }).isPresent(), false);\n    const cell = await gu.getCell({ col: 0, rowNum: 5 });\n\n    // First upload via the attachment icon.\n    await gu.fileDialogUpload(\"uploads/grist.png\", () =>\n      cell.find(\".test-attachment-icon\").click(),\n    );\n    await gu.waitToPass(async () =>\n      assert.lengthOf(\n        await gu.getCell({ col: 0, rowNum: 5 }).findAll(\".test-pw-thumbnail\"),\n        1,\n      ),\n    );\n    assert.deepEqual(\n      await getCellThumbnailTitles(gu.getCell({ col: 0, rowNum: 5 })),\n      [\"grist.png\"],\n    );\n    assert.equal(await gu.getCell({ col: 0, rowNum: 6 }).isPresent(), true);\n\n    // Then do it again via the attachment editor.\n    await gu.fileDialogUpload(\"uploads/grist.png\", async () => {\n      await gu.getCell({ col: 0, rowNum: 6 }).click();\n      await driver.sendKeys(Key.ENTER);\n      await driver.sleep(500);\n      await driver.find(\".test-pw-add\").click();\n    });\n    await gu.waitToPass(async () =>\n      assert.isTrue(\n        await driver.find(\".test-pw-attachment-content\").isDisplayed(),\n      ),\n    );\n    assert.isFalse(\n      await driver\n        .findContent(\".test-pw-attachment-content\", /Preview not available/)\n        .isPresent(),\n    );\n    await driver.find(\".test-pw-close\").click();\n    await gu.waitForServer();\n    await gu.waitToPass(async () =>\n      assert.deepEqual(\n        await getCellThumbnailTitles(gu.getCell({ col: 0, rowNum: 6 })),\n        [\"grist.png\"],\n      ),\n    );\n    assert.equal(await gu.getCell({ col: 0, rowNum: 7 }).isPresent(), true);\n  });\n\n  it(\"should not initialize as invalid when a row is added\", async function() {\n    // The first cell is invalid, just to check that the assert is correct.\n    let cell = gu.getCell({ col: 0, rowNum: 1 });\n    assert.equal(await cell.getText(), \"hello\");\n    assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), true);\n    await cell.click();\n    // Add a new row and ensure it's NOT invalid.\n    await driver\n      .find(\"body\")\n      .sendKeys(Key.chord(await gu.modKey(), Key.SHIFT, Key.ENTER));\n    await gu.waitForServer();\n    cell = gu.getCell({ col: 0, rowNum: 1 });\n    assert.equal(await cell.getText(), \"\");\n    assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), false);\n    await gu.undo();\n  });\n\n  it(\"should open preview to double-clicked attachment\", async function() {\n    const cell = gu.getCell({ col: 0, rowNum: 2 });\n    assert.deepEqual(await getCellThumbnailTitles(cell), [\n      \"renamed.pdf\",\n      \"grist.png\",\n    ]);\n\n    // Double-click the first attachment.\n    await driver.withActions(a =>\n      a.doubleClick(cell.find(\".test-pw-thumbnail[title*=pdf]\")),\n    );\n    assert.equal(\n      await driver.findWait(\".test-pw-counter\", 500).getText(),\n      \"1 of 2\",\n    );\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"renamed.pdf\");\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Double-click the second attachment.\n    await driver.withActions(a =>\n      a.doubleClick(cell.find(\".test-pw-thumbnail[title*=png]\")),\n    );\n    assert.equal(\n      await driver.findWait(\".test-pw-counter\", 500).getText(),\n      \"2 of 2\",\n    );\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"grist.png\");\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should render various types of files appropriately\", async function() {\n    const cell = gu.getCell({ col: 0, rowNum: 2 });\n    await cell.click();\n    assert.deepEqual(await getCellThumbnailTitles(cell), [\n      \"renamed.pdf\",\n      \"grist.png\",\n    ]);\n    await gu.fileDialogUpload(\n      \"uploads/file1.mov,uploads/file2.mp3,uploads/file3.zip,uploads/simple_array.json\",\n      () => cell.find(\".test-attachment-icon\").click(),\n    );\n    await gu.waitToPass(async () =>\n      assert.lengthOf(await cell.findAll(\".test-pw-thumbnail\"), 6),\n    );\n    await gu.waitForServer();\n    assert.deepEqual(await getCellThumbnailTitles(cell), [\n      \"renamed.pdf\",\n      \"grist.png\",\n      \"file1.mov\",\n      \"file2.mp3\",\n      \"file3.zip\",\n      \"simple_array.json\",\n    ]);\n    await driver.sendKeys(Key.ENTER);\n    assert.equal(\n      await driver.findWait(\".test-pw-counter\", 500).getText(),\n      \"1 of 6\",\n    );\n\n    // For various recognized file types, see that a suitable element is created.\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"renamed.pdf\");\n    assert.equal(\n      await driver.find(\".test-pw-attachment-content\").getTagName(),\n      \"object\",\n    );\n    assert.match(\n      await driver.find(\".test-pw-attachment-content\").getAttribute(\"data\"),\n      /name=renamed.pdf&rowId=2&colId=A&tableId=Table1&maybeNew=1&attId=1&inline=1/,\n    );\n    assert.equal(\n      await driver.find(\".test-pw-attachment-content\").getAttribute(\"type\"),\n      \"application/pdf\",\n    );\n\n    await driver.sendKeys(Key.RIGHT);\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"grist.png\");\n    assert.equal(\n      await driver.find(\".test-pw-attachment-content\").getTagName(),\n      \"img\",\n    );\n    assert.match(\n      await driver.find(\".test-pw-attachment-content\").getAttribute(\"src\"),\n      /name=grist.png&rowId=2&colId=A&tableId=Table1&maybeNew=1&attId=2/,\n    );\n\n    await driver.sendKeys(Key.RIGHT);\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"file1.mov\");\n    assert.equal(\n      await driver.find(\".test-pw-attachment-content\").getTagName(),\n      \"video\",\n    );\n    assert.match(\n      await driver.find(\".test-pw-attachment-content\").getAttribute(\"src\"),\n      /name=file1.mov&rowId=2&colId=A&tableId=Table1&maybeNew=1&attId=6&inline=1/,\n    );\n\n    await driver.sendKeys(Key.RIGHT);\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"file2.mp3\");\n    assert.equal(\n      await driver.find(\".test-pw-attachment-content\").getTagName(),\n      \"audio\",\n    );\n    assert.match(\n      await driver.find(\".test-pw-attachment-content\").getAttribute(\"src\"),\n      /name=file2.mp3&rowId=2&colId=A&tableId=Table1&maybeNew=1&attId=7&inline=1/,\n    );\n\n    // Test that for an unsupported file, the extension is shown along with a message.\n    await driver.sendKeys(Key.RIGHT);\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"file3.zip\");\n    assert.equal(\n      await driver.find(\".test-pw-attachment-content\").getTagName(),\n      \"object\",\n    );\n    assert.match(\n      await driver.find(\".test-pw-attachment-content\").getAttribute(\"data\"),\n      /name=file3.zip&rowId=2&colId=A&tableId=Table1&maybeNew=1&attId=8&inline=1/,\n    );\n    assert.equal(\n      await driver.find(\".test-pw-attachment-content\").getText(),\n      \"ZIP\\nPreview not available.\",\n    );\n\n    // Test the same for a text/json file that we also don't currently render.\n    await driver.sendKeys(Key.RIGHT);\n    assert.equal(\n      await driver.find(\".test-pw-name\").value(),\n      \"simple_array.json\",\n    );\n    assert.equal(\n      await driver.find(\".test-pw-attachment-content\").getTagName(),\n      \"div\",\n    );\n    assert.equal(\n      await driver.find(\".test-pw-attachment-content\").getText(),\n      \"JSON\\nPreview not available.\",\n    );\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Undo.\n    await gu.undo();\n    assert.deepEqual(await getCellThumbnailTitles(cell), [\n      \"renamed.pdf\",\n      \"grist.png\",\n    ]);\n  });\n\n  const checkClosing = async function(\n    shouldSave: boolean,\n    trigger: () => Promise<void>,\n  ) {\n    let cell = gu.getCell({ col: 1, rowNum: 2 });\n    await cell.click(); // Click on the right column and move using keyboard, otherwise me might click on the thumbnail.\n    await driver.sendKeys(Key.ARROW_LEFT);\n    cell = gu.getCell({ col: 0, rowNum: 2 });\n    assert.deepEqual(await getCellThumbnailTitles(cell), [\n      \"renamed.pdf\",\n      \"grist.png\",\n    ]);\n\n    // Open attachments editor.\n    await driver.sendKeys(Key.ENTER);\n    await driver.findWait(\".test-pw-attachment-content\", 500);\n\n    // Close using the given trigger. No actions should be emitted.\n    await gu.userActionsCollect();\n    await trigger();\n    await gu.waitAppFocus();\n    await gu.userActionsVerify([]);\n\n    // Open editor and delete a file.\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitAppFocus(false);\n    await driver.findWait(\".test-pw-attachment-content\", 500);\n    await driver.find(\".test-pw-remove\").click();\n    await gu.waitForServer();\n\n    // Close using the given trigger.\n    await gu.userActionsCollect();\n    await trigger();\n    await gu.waitAppFocus();\n    await gu.waitForServer();\n\n    await ensureDialogIsClosed();\n    cell = gu.getCell({ col: 0, rowNum: 2 });\n    if (shouldSave) {\n      // If shouldSave is set, files should reflect the change. Check it and undo.\n      await gu.userActionsCollect(false);\n      assert.deepEqual(await getCellThumbnailTitles(cell), [\"grist.png\"]);\n      await gu.undo();\n    } else {\n      // If shouldSave is false, there should be no actions.\n      await gu.userActionsVerify([]);\n    }\n    assert.deepEqual(await getCellThumbnailTitles(cell), [\n      \"renamed.pdf\",\n      \"grist.png\",\n    ]);\n    await ensureDialogIsClosed();\n  };\n\n  it(\"should not save on Escape\", async function() {\n    await checkClosing(false, () => driver.sendKeys(Key.ESCAPE));\n  });\n\n  it(\"should save on Enter\", async function() {\n    await checkClosing(true, () => driver.sendKeys(Key.ENTER));\n  });\n\n  it(\"should save on close button\", async function() {\n    await checkClosing(true, () =>\n      driver.find(\".test-modal-dialog .test-pw-close\").click(),\n    );\n  });\n\n  it(\"should preview images properly\", async function() {\n    const cell = await gu.getCell({ col: 0, rowNum: 2 });\n    await cell.click();\n    await gu.fileDialogUpload(\"uploads/image_with_script.svg\", () =>\n      cell.find(\".test-attachment-icon\").click(),\n    );\n    await driver.withActions(a => a.doubleClick(cell));\n    await driver.findWait(\".test-pw-attachment-content\", 1000);\n    assert.isFalse(await gu.isAlertShown());\n    await gu.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should show a loading indicator when uploading via the attachment icon\", async function() {\n    const cell = await gu.getCell({ col: 0, rowNum: 3 });\n    await driver.executeScript(\"window.testGrist = {fakeSlowUploads: true}\");\n    const thumbnailsCount = (await cell.findAll(\".test-pw-thumbnail\"))?.length || 0;\n\n    await cell.click();\n    await gu.fileDialogUpload(\"uploads/image_with_script.svg\", () =>\n      cell.find(\".test-attachment-icon\").click(),\n    );\n\n    // the spinner should show up after a small delay, wait for 1 second tops\n    await driver.findWait(\".test-attachment-spinner\", 1000);\n    // then wait for the spinner to disappear\n    await driver.wait(async () => !(await cell.find(\".test-attachment-spinner\").isPresent()), 2000);\n\n    // check the upload was successful by comparing the number of thumbnails\n    const newThumbnailsCount = (await cell.findAll(\".test-pw-thumbnail\"))?.length || 0;\n    assert.equal(newThumbnailsCount, thumbnailsCount + 1);\n    await gu.clearTestState();\n  });\n\n  it(\"should show a loading indicator when uploading via the attachment editor\", async function() {\n    const cell = await gu.getCell({ col: 0, rowNum: 3 });\n    await driver.executeScript(\"window.testGrist = {fakeSlowUploads: true}\");\n    await gu.fileDialogUpload(\"uploads/grist.png\", async () => {\n      await cell.click();\n      await driver.sendKeys(Key.ENTER);\n      await driver.sleep(500);\n      await driver.find(\".test-pw-add\").click();\n    });\n\n    // the spinner should show up directly\n    await driver.findWait(\".test-pw-spinner\", 500);\n    // wait for the spinner to disappear\n    await driver.wait(async () => !(await cell.find(\".test-pw-spinner\").isPresent()), 2000);\n\n    // check the upload was successful by checking the final counter\n    await driver.findContentWait(\".test-pw-counter\", /of 2/, 3000);\n    // exit the editor\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.clearTestState();\n  });\n\n  it(\"should allow uploading from card view\", async function() {\n    // This was a little broken - the click event on the upload icon would\n    // trigger an edit action on the field if the field had focus prior\n    // to the click, causing both the file picker and the editor to be\n    // shown at the same time.\n    const cell = await gu.getCell({ col: 0, rowNum: 2 });\n    await cell.click();\n    await gu.changeWidget(\"Card\");\n    const field = await gu.getCardCell(\"A\");\n    await field.click();\n    await gu.fileDialogUpload(\"uploads/grist.png\", () =>\n      field.mouseMove().find(\".test-attachment-icon\").click(),\n    );\n    await gu.waitToPass(async () =>\n      assert.lengthOf(\n        await gu.getCardCell(\"A\").findAll(\".test-pw-thumbnail\"),\n        4,\n      ),\n    );\n    assert.deepEqual(await getCellThumbnailTitles(gu.getCardCell(\"A\")), [\n      \"renamed.pdf\",\n      \"grist.png\",\n      \"image_with_script.svg\",\n      \"grist.png\",\n    ]);\n  });\n\n  // Test that if we have a formula column that is attachment, it supports the same things as\n  // a readonly attachment column. Only when user types something or press F2/Enter it opens the formula\n  // editor\n  it(\"should show attachments editor for attachment formula column\", async function() {\n    const revert = await gu.begin();\n    await gu.addNewPage(\"Table\", \"Table1\");\n    await gu.sendActions([\n      ...[\"B\", \"C\", \"D\", \"E\"].map(col => [\"RemoveColumn\", \"Table1\", col]),\n      [\"AddVisibleColumn\", \"Table1\", \"B\", {\n        isFormula: true,\n        formula: \"$A\",\n        type: \"Attachments\",\n      }],\n    ]);\n\n    // Open the second attachment in preview in column B.\n    const cell = gu.getCell({ col: \"B\", rowNum: 2 });\n    await driver.withActions(a =>\n      a.doubleClick(cell.find(\".test-pw-thumbnail[title*=png]\")),\n    );\n    assert.equal(\n      await driver.findWait(\".test-pw-counter\", 500).getText(),\n      \"2 of 4\",\n    );\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"grist.png\");\n\n    // Make sure we don't see add or remove buttons\n    assert.isFalse(await driver.find(\".test-pw-add\").isPresent());\n    assert.isFalse(await driver.find(\".test-pw-remove\").isPresent());\n\n    // Close the preview\n    await driver.sendKeys(Key.ESCAPE);\n    await ensureDialogIsClosed();\n\n    // Now double click on the empty cell in row 4, we should see \"No attachments\" message\n    const emptyCell = gu.getCell({ col: \"B\", rowNum: 4 });\n    await gu.dbClick(emptyCell);\n    assert.equal(await driver.findWait(\".test-pw-attachment-content\", 1000).getText(), \"No attachments\");\n    // Close the preview\n    await driver.sendKeys(Key.ESCAPE);\n    await ensureDialogIsClosed();\n\n    // Now press F2 to edit the formula\n    await gu.sendKeys(Key.F2);\n    await gu.checkFormulaEditor(\"$A\");\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitAppFocus();\n\n    // Now do the same with Enter key\n    await gu.sendKeys(Key.ENTER);\n    await gu.checkFormulaEditor(\"$A\");\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitAppFocus();\n\n    // Now the same with any key\n    await gu.sendKeys(\"hello\");\n    await gu.checkFormulaEditor(\"hello\");\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitAppFocus();\n\n    await revert();\n  });\n});\n\nasync function ensureDialogIsClosed() {\n  await gu.waitToPass(async () => {\n    assert.equal(\n      await driver.find(\".test-pw-close\").isPresent(),\n      false,\n    );\n  }, 10000);\n}\n"
  },
  {
    "path": "test/nbrowser/AuthProvider.ts",
    "content": "import { itemValue, toggleItem } from \"test/nbrowser/AdminPanelTools\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { serveSomething, Serving } from \"test/server/customUtil\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport * as express from \"express\";\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"AuthProvider\", function() {\n  this.timeout(\"2m\");\n  setupTestSuite();\n  gu.bigScreen();\n\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  let serving: Serving;\n  const user = gu.translateUser(\"user1\");\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    process.env.GRIST_DEFAULT_EMAIL = user.email;\n    process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = \"core\";\n    await server.restart();\n\n    serving = await serveSomething((app) => {\n      app.use(express.json());\n      app.get(\"/.well-known/openid-configuration\", (req, res) => {\n        res.json({\n          issuer: serving.url + \"?provider=getgrist.com\",\n        });\n      });\n      app.use((req) => {\n        assert.fail(`Unexpected request to test OIDC server: ${req.method} ${req.url}`);\n      });\n    });\n  });\n\n  after(async function() {\n    oldEnv.restore();\n    await server.restart(true); // clear database changes\n    await serving?.shutdown();\n  });\n\n  it(\"should show some providers\", async function() {\n    await server.simulateLogin(user.name, user.email, \"docs\");\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    await toggleItem(\"authentication\");\n\n    // Be default we should see \"no authentication\" value, as no provider is configured and the default\n    // fallback to MinimalProvider is used, which Grists reports as \"no authentication\".\n    await gu.waitToPass(async () => {\n      assert.equal(await itemValue(\"authentication\"), \"no authentication\");\n    }, 500);\n\n    // We should see couple of providers, including \"OIDC and SAML\".\n    await driver.findWait(\".test-admin-auth-provider-row\", 2000); // wait for it to appear\n    const providerItems = await driver.findAll(\".test-admin-auth-provider-row\");\n    assert.isAtLeast(providerItems.length, 3); // We expect to see OIDC provider as well.\n\n    assert.match(await providerItems[0].getText(), /Sign in with getgrist\\.com/);\n    assert.match(await providerItems[1].getText(), /OIDC/);\n    assert.match(await providerItems[2].getText(), /SAML/);\n    assert.match(await providerItems[3].getText(), /Forwarded headers/);\n    // And some others, depending on the build we are in.\n  });\n\n  it(\"all providers should not be configured by default\", async function() {\n    const providerRows = await driver.findAll(\".test-admin-auth-provider-row\");\n    assert.isAtLeast(providerRows.length, 1);\n\n    // Check that none of the providers have any badges\n    for (const row of providerRows) {\n      const badges = await row.findAll(\".test-admin-auth-badge\");\n      assert.lengthOf(badges, 0);\n    }\n  });\n\n  it(\"all providers should have `configure` buttons`\", async function() {\n    const providerRows = await driver.findAll(\".test-admin-auth-provider-row\");\n\n    for (const row of providerRows) {\n      const configureButtons = await row.findAll(\".test-admin-auth-configure-button\");\n      assert.lengthOf(configureButtons, 1);\n\n      await configureButtons[0].click();\n\n      const modalHeader = await driver.findWait(\".test-admin-auth-modal-header\", 2000);\n      assert.isTrue(await modalHeader.isDisplayed());\n\n      const cancelButton = await driver.find(\".test-admin-auth-modal-cancel\");\n      await cancelButton.click();\n\n      await gu.checkForErrors();\n\n      await driver.wait(async () => {\n        const modals = await driver.findAll(\".test-admin-auth-modal-header\");\n        return modals.length === 0;\n      }, 100);\n    }\n  });\n\n  async function restartAdmin() {\n    await server.restart();\n    await server.simulateLogin(user.name, user.email, \"docs\");\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    await toggleItem(\"authentication\");\n  }\n\n  it(\"should detect misconfigured oidc configuration\", async function() {\n    // This is minimal thing to make Grist think that OIDC is configured.\n    process.env.GRIST_OIDC_IDP_ISSUER = \"invalid-url\";\n    // Now after restarting, Grist should noticed that we attempted to configure OIDC, but failed.\n    await restartAdmin();\n\n    // Now check the badge of the OIDC provider, it should be misconfigured.\n    await gu.waitToPass(async () => {\n      assert.deepEqual(await badges(\"OIDC\"), [\"ACTIVE\", \"ERROR\"]);\n      // And a warning message should be present in the first row about one of the required env vars.\n      assert.include(await errorMessage(\"OIDC\").getText(), \"GRIST_OIDC_\");\n    }, 1000);\n\n    // The label says \"auth error\" now (as no valid login is possible).\n    assert.equal(await itemValue(\"authentication\"), \"auth error\");\n\n    // There should be no 'Set as active method' button.\n    assert.isFalse(await activeButton(\"OIDC\").isPresent());\n\n    // Also check other 2 providers we know about.\n    assert.deepEqual(await badges(\"SAML\"), []);\n    assert.isFalse(await activeButton(\"SAML\").isPresent());\n\n    assert.deepEqual(await badges(\"Forwarded headers\"), []);\n    assert.isFalse(await activeButton(\"Forwarded headers\").isPresent());\n  });\n\n  it(\"should detect properly configured oidc provider\", async function() {\n    // Configure OIDC provider properly with all required environment variables, it will\n    // fail during initialization phase, but from the UI perspective it is properly configured.\n    process.env.GRIST_OIDC_IDP_ISSUER = \"https://maybe.valid.issu.er\";\n    process.env.GRIST_OIDC_IDP_CLIENT_ID = \"test-client-id\";\n    process.env.GRIST_OIDC_IDP_CLIENT_SECRET = \"test-client-secret\";\n    process.env.GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT = \"true\";\n    process.env.GRIST_OIDC_SP_HOST = \"localhost\";\n\n    // Restart to pick up the new configuration.\n    await restartAdmin();\n\n    // We now see that there is some auth error, as OIDC appears to be configured, nothing is selected by the\n    // user so this was picked as the active method, but the test OIDC server is not responding properly.\n    await gu.waitToPass(async () => {\n      assert.equal(await itemValue(\"authentication\"), \"auth error\");\n    });\n\n    // We see 3 badges on OIDC provider: CONFIGURED, ACTIVE ON RESTART, and ERROR\n    assert.deepEqual(await badges(\"OIDC\"), [\"CONFIGURED\", \"ACTIVE\", \"ERROR\"]);\n\n    // The 'Set as active method' button is not present, as it is already active.\n    assert.isFalse(await activeButton(\"OIDC\").isPresent(), \"Set as active method button should not be present\");\n\n    // Other providers should remain unchanged.\n    assert.deepEqual(await badges(\"SAML\"), []);\n    assert.isFalse(await activeButton(\"SAML\").isPresent());\n\n    assert.deepEqual(await badges(\"Forwarded headers\"), []);\n    assert.isFalse(await activeButton(\"Forwarded headers\").isPresent());\n  });\n\n  it(\"should offer to switch to other configured providers\", async function() {\n    // Now let's configure another provider (ForwardAuth is simpler than SAML)\n    process.env.GRIST_FORWARD_AUTH_HEADER = \"x-forwarded-user\";\n    process.env.GRIST_FORWARD_AUTH_LOGOUT_PATH = \"/logout\";\n\n    // Restart to pick up the new configuration\n    await restartAdmin();\n\n    // OIDC should still be active (but with error)\n    assert.deepEqual(await badges(\"OIDC\"), [\"CONFIGURED\", \"ACTIVE\", \"ERROR\"]);\n\n    // ForwardAuth should now be configured and offer to switch\n    await gu.waitToPass(async () => {\n      assert.deepEqual(await badges(\"Forwarded headers\"), [\"CONFIGURED\"]);\n    }, 1000);\n\n    // ForwardAuth should have \"Set as active method\" button since it's configured but not active\n    assert.isTrue(await activeButton(\"Forwarded headers\").isPresent());\n\n    // SAML should still be unconfigured\n    assert.deepEqual(await badges(\"SAML\"), []);\n    assert.isFalse(await activeButton(\"SAML\").isPresent());\n  });\n\n  it(\"should switch to ForwardAuth provider\", async function() {\n    const setActiveButton = await activeButton(\"Forwarded headers\");\n    await setActiveButton.click();\n\n    // Confirm in the modal\n    const confirmButton = await driver.findWait(\".test-modal-confirm\", 2000);\n    await confirmButton.click();\n    await gu.waitForServer();\n\n    // The \"Set as active method\" button should disappear\n    assert.isFalse(await activeButton(\"Forwarded headers\").isPresent());\n\n    // We should see \"Active on restart\" badge\n    const forwardAuthBadges = await badges(\"Forwarded headers\");\n    assert.includeMembers(forwardAuthBadges, [\"CONFIGURED\", \"ACTIVE ON RESTART\"]);\n\n    // OIDC should still be configured, and disabled on restart\n    const oidcBadges = await badges(\"OIDC\");\n    assert.includeMembers(oidcBadges, [\"CONFIGURED\", \"DISABLED ON RESTART\", \"ERROR\"]);\n\n    // But there should be a button to set it active again\n    assert.isTrue(await activeButton(\"OIDC\").isPresent());\n  });\n});\n\nconst providerRow = (text: string) => driver.findContentWait(\".test-admin-auth-provider-row\", text, 1000);\n\nconst badges = (text: string) => providerRow(text).findAll(\".test-admin-auth-badge\", e => e.getText());\n\nconst errorMessage = (text: string) => providerRow(text).find(\".test-admin-auth-error-message\");\n\nconst activeButton = (text: string) => providerRow(text).find(\".test-admin-auth-set-active-button\");\n"
  },
  {
    "path": "test/nbrowser/AuthProviderGetGrist.ts",
    "content": "import { Activation } from \"app/gen-server/entity/Activation\";\nimport { itemValue, toggleItem } from \"test/nbrowser/AdminPanelTools\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { Defer, serveSomething, Serving } from \"test/server/customUtil\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport * as express from \"express\";\nimport { observable } from \"grainjs\";\nimport { assert, driver, WebElementPromise } from \"mocha-webdriver\";\n\ndescribe(\"AuthProviderGetGrist\", function() {\n  this.timeout(\"2m\");\n  setupTestSuite();\n  gu.bigScreen();\n\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  let serving: Serving;\n  const currentRequest = observable(null as express.Request | null);\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    process.env.GRIST_DEFAULT_EMAIL = gu.translateUser(\"user1\").email;\n    process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = \"core\";\n    // Make sure no APP_HOME_URL is set, to use calculated one.\n    delete process.env.APP_HOME_URL;\n    await server.restart();\n\n    serving = await serveSomething((app) => {\n      app.use((req, res, next) => {\n        currentRequest.set(req);\n        next();\n      });\n      app.use(express.json());\n      app.get(\"/.well-known/openid-configuration\", (req, res) => {\n        res.json({\n          issuer: `${serving.url}?provider=getgrist.com`,\n          authorization_endpoint: `${serving.url}/authorize`,\n        });\n      });\n      app.get(\"/authorize\", (req, res) => {\n        res.sendStatus(200);\n      });\n      app.use((req) => {\n        console.warn(`Unexpected request to test OIDC server: ${req.method} ${req.url}`);\n      });\n    });\n  });\n\n  after(async function() {\n    oldEnv.restore();\n    await server.restart(true); // clear database changes\n    await serving?.shutdown();\n  });\n\n  it(\"should show providers with Grist Login\", async function() {\n    await server.simulateLogin(\"user1\", process.env.GRIST_DEFAULT_EMAIL!, \"docs\");\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    await toggleItem(\"authentication\");\n\n    await gu.waitToPass(async () => {\n      assert.equal(await itemValue(\"authentication\"), \"no authentication\");\n    }, 500);\n\n    // We should see couple of providers, including \"Sign in with Grist\".\n    await driver.findWait(\".test-admin-auth-provider-row\", 2000); // wait for it to appear\n    const providerItems = await driver.findAll(\".test-admin-auth-provider-row\");\n    assert.isAtLeast(providerItems.length, 2); // We expect to see OIDC provider as well.\n\n    // First one should be \"Sign in with Grist\".\n    assert.match(await providerItems[0].getText(), /Sign in with getgrist/);\n  });\n\n  it(\"should allow configuring getgrist.com provider\", async function() {\n    const configureButton = await providerRow().find(\".test-admin-auth-configure-button\");\n    await configureButton.click();\n\n    const textarea = await driver.findWait(\".test-admin-auth-config-key-textarea\", 2000);\n    const configureModalButton = await driver.find(\".test-admin-auth-modal-configure\");\n\n    // Button should be grayed out and disabled (should have 'disabled' attribute) when textarea is empty.\n    assert.isFalse(await configureModalButton.isEnabled());\n\n    // Click it while disabled.\n    await configureModalButton.click();\n    // Nothing should happen, modal should stay open.\n    await driver.sleep(100);\n    await gu.checkForErrors();\n\n    // Type some dummy invalid key.\n    await textarea.click();\n    await textarea.sendKeys(\"invalid-key\");\n    await configureModalButton.click();\n    await assertError(/Error configuring provider with the provided key/);\n\n    await textarea.clear();\n    await textarea.sendKeys(Buffer.from(JSON.stringify({ random: \"json\" })).toString(\"base64\"));\n    await configureModalButton.click();\n    await assertError(/Error configuring provider with the provided key/);\n\n    const validConfig = {\n      oidcClientId: \"some-id\",\n      oidcClientSecret: \"some-secret\",\n      oidcIssuer: serving.url + \"?provider=getgrist.com\",\n      oidcSkipEndSessionEndpoint: true,\n      owner: {\n        name: \"Chimpy\",\n        email: \"chimpy@getgrist.com\",\n      },\n    };\n    await textarea.clear();\n    await textarea.sendKeys(Buffer.from(JSON.stringify(validConfig)).toString(\"base64\"));\n    await configureModalButton.click();\n    await gu.waitForServer();\n    await waitForModalToClose();\n\n    // We should see two badges: Configured and Active on restart. GetGrist.com was picked by order.\n    const providerBadges = await badges();\n    assert.includeMembers(providerBadges, [\"CONFIGURED\", \"ACTIVE ON RESTART\"]);\n  });\n\n  it(\"should store config in database\", async function() {\n    const db = await server.getDatabase();\n    const activation = await db.connection.manager.findOne(Activation, { where: {} });\n    assert.isDefined(activation);\n    const json = activation!.prefs!.envVars;\n    assert.isDefined(json);\n    assert.isDefined(json!.GRIST_GETGRISTCOM_SECRET);\n  });\n\n  it(\"should use fake getgristlogin service\", async function() {\n    await server.restart();\n    await server.removeLogin();\n    await server.simulateLogin(\"user1\", process.env.GRIST_DEFAULT_EMAIL!, \"docs\");\n    await driver.get(`${server.getHost()}/admin`);\n    await gu.waitForAdminPanel();\n    await toggleItem(\"authentication\");\n    assert.equal(await itemValue(\"authentication\"), \"getgrist.com\");\n\n    // And check that we still see at least 2 providers, including getgrist.com and OIDC\n    const providerItems = await driver.findAll(\".test-admin-auth-provider-row\");\n    assert.isAtLeast(providerItems.length, 2);\n\n    // First one should be \"Sign in with getgrist.com\".\n    assert.match(await providerItems[0].getText(), /Sign in with getgrist/);\n\n    // Second one should be OIDC provider.\n    assert.match(await providerItems[1].getText(), /OIDC/);\n\n    // The getgrist.com provider should have Active badge and Configured badge\n    const getGristRow = providerItems[0];\n    const activeBadges = await getGristRow.findAll(\".test-admin-auth-badge-active\");\n    assert.lengthOf(activeBadges, 1);\n    const configuredBadges = await getGristRow.findAll(\".test-admin-auth-badge-configured\");\n    assert.lengthOf(configuredBadges, 1);\n\n    // Second one should have no badges\n    const oidcRow = providerItems[1];\n    const oidcBadges = await oidcRow.findAll(\".test-admin-auth-badge\");\n    assert.lengthOf(oidcBadges, 0);\n  });\n\n  it(\"should respect GRIST_GETGRISTCOM_SP_HOST env override\", async function() {\n    process.env.GRIST_GETGRISTCOM_SP_HOST = \"https://invalid-host.example.com\";\n    await server.restart();\n    await server.removeLogin();\n    await driver.get(`${server.getHost()}`);\n    await gu.waitForDocMenuToLoad();\n    currentRequest.set(null);\n    const redirectUrl = new Defer<string>();\n    currentRequest.addListener((val) => {\n      redirectUrl.resolve(val?.query.redirect_uri as string);\n    });\n    await driver.findWait(\".test-user-sign-in\", 2000).click();\n    assert.equal(await redirectUrl, \"https://invalid-host.example.com/oauth2/callback\");\n  });\n});\n\nasync function assertError(msg: RegExp) {\n  assert.match(\n    await driver.findWait(\".test-notifier-toast-message\", 1000).getText(),\n    msg,\n  );\n  await driver.findWait(\".test-notifier-toast-close\", 2000).click();\n}\n\nasync function waitForModalToClose() {\n  await driver.wait(async () => {\n    const modals = await driver.findAll(\".test-admin-auth-modal-header\");\n    return modals.length === 0;\n  }, 2000);\n}\n\nconst providerRow = (n = 0) => new WebElementPromise(driver,\n  driver.findAll(\".test-admin-auth-provider-row\").then(rows => rows[n]));\n\nconst badges = (n = 0) => providerRow(n).findAll(\".test-admin-auth-badge\", e => e.getText());\n"
  },
  {
    "path": "test/nbrowser/BehavioralPrompts.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"BehavioralPrompts\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite({ tutorial: true });\n\n  let session: gu.Session;\n  let docId: string;\n\n  gu.bigScreen();\n\n  before(async () => {\n    session = await gu.session().user(\"user1\").login({ showTips: true });\n    await gu.dismissCoachingCall();\n    docId = await session.tempNewDoc(cleanup, \"BehavioralPrompts\");\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  describe(\"when helpCenter is hidden\", function() {\n    gu.withEnvironmentSnapshot({ GRIST_HIDE_UI_ELEMENTS: \"helpCenter\" });\n\n    before(async () => {\n      const sessionNoHelpCenter = await gu.session().user(\"user3\").login({\n        isFirstLogin: false,\n        freshAccount: true,\n        showTips: true,\n      });\n      await gu.dismissCoachingCall();\n      await sessionNoHelpCenter.tempNewDoc(cleanup, \"BehavioralPromptsNoHelpCenter\");\n    });\n\n    it(\"should not be shown\", async function() {\n      await assertPromptTitle(null);\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await driver.find(\".test-right-tab-field\").click();\n      await driver.find(\".test-fbuilder-type-select\").click();\n      await assertPromptTitle(null);\n    });\n  });\n\n  describe(\"when anonymous\", function() {\n    before(async () => {\n      const anonymousSession = await gu.session().anon.login({\n        showTips: true,\n      });\n      await anonymousSession.loadDocMenu(\"/\");\n      await driver.find(\".test-intro-create-doc\").click();\n      await gu.waitForDocToLoad();\n    });\n\n    it(\"should not shown an announcement for forms\", async function() {\n      await assertPromptTitle(null);\n    });\n  });\n\n  it(\"should be shown when the column type select menu is opened\", async function() {\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    await driver.find(\".test-fbuilder-type-select\").click();\n    await gu.findOpenMenu();\n    await assertPromptTitle(\"Reference Columns\");\n  });\n\n  it(\"should be temporarily dismissed on click-away\", async function() {\n    await gu.getCell({ col: \"A\", rowNum: 1 }).click();\n    await assertPromptTitle(null);\n  });\n\n  it(\"should be shown again the next time the menu is opened\", async function() {\n    await driver.find(\".test-fbuilder-type-select\").click();\n    await gu.findOpenMenu();\n    await assertPromptTitle(\"Reference Columns\");\n  });\n\n  it('should be permanently dismissed when \"Got it\" is clicked', async function() {\n    await gu.dismissBehavioralPrompts();\n    await assertPromptTitle(null);\n\n    // Refresh the page and make sure the prompt isn't shown again.\n    await session.loadDoc(`/doc/${docId}`);\n    await driver.find(\".test-fbuilder-type-select\").click();\n    await gu.findOpenMenu();\n    await assertPromptTitle(null);\n    await gu.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should be shown after selecting a reference column type\", async function() {\n    await gu.setType(/Reference$/);\n    await assertPromptTitle(\"Reference Columns\");\n    await gu.undo();\n  });\n\n  it(\"should be shown after selecting a reference list column type\", async function() {\n    await gu.setType(/Reference List$/);\n    await assertPromptTitle(\"Reference Columns\");\n  });\n\n  it(\"should be shown when opening the Raw Data page\", async function() {\n    await driver.find(\".test-tools-raw\").click();\n    await assertPromptTitle(\"Raw Data page\");\n  });\n\n  it(\"should be shown when opening the filter menu\", async function() {\n    await gu.openPage(\"Table1\");\n    await gu.openColumnMenu(\"A\", \"Filter\");\n    await assertPromptTitle(\"Pinning Filters\");\n    await gu.dismissBehavioralPrompts();\n  });\n\n  it(\"should be shown when adding a second pinned filter\", async function() {\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n    await assertPromptTitle(null);\n    await gu.openColumnMenu(\"B\", \"Filter\");\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n    await assertPromptTitle(\"Nested Filtering\");\n  });\n\n  it(\"should be shown when opening the page widget picker\", async function() {\n    await gu.openAddWidgetToPage();\n    await assertPromptTitle(\"Selecting Data\");\n    await gu.dismissBehavioralPrompts();\n  });\n\n  it(\"should be shown when select by is an available option\", async function() {\n    await driver.findContent(\".test-wselect-table\", /Table1/).click();\n    await assertPromptTitle(\"Linking Widgets\");\n    await gu.dismissBehavioralPrompts();\n  });\n\n  it(\"should be shown when adding a card widget\", async function() {\n    await gu.selectWidget(\"Card\", /Table1/);\n    await assertPromptTitle(\"Editing Card Layout\");\n  });\n\n  it(\"should not be shown when adding a non-card widget\", async function() {\n    await gu.addNewPage(\"Table\", /Table1/);\n    await assertPromptTitle(null);\n  });\n\n  it(\"should be shown when adding a card list widget\", async function() {\n    await gu.addNewPage(\"Card List\", /Table1/);\n    await assertPromptTitle(\"Editing Card Layout\");\n  });\n\n  describe(\"for the Add New button\", function() {\n    it(\"should not be shown if site is empty\", async function() {\n      session = await gu.session().user(\"user4\").login({ showTips: true });\n      await session.loadDocMenu(\"/\");\n      await assertPromptTitle(null);\n    });\n\n    it(\"should be shown if site has documents\", async function() {\n      await session.tempNewDoc(cleanup, \"BehavioralPromptsAddNew\");\n      await driver.find(\".test-bc-workspace\").click();\n      await gu.waitForDocMenuToLoad();\n      await assertPromptTitle(\"Add new\");\n    });\n\n    it(\"should not be shown on the Trash page\", async function() {\n      // Load /p/trash and check that tip isn't initially shown.\n      await session.loadDocMenu(\"/p/trash\");\n      await assertPromptTitle(null);\n    });\n\n    it(\"should only be shown on the All Documents page if intro is hidden\", async function() {\n      await session.loadDocMenu(\"/\");\n      await assertPromptTitle(null);\n      await driver.find(\".test-welcome-menu\").click();\n      await driver.findWait(\".test-welcome-menu-only-show-documents\", 200).click();\n      await gu.waitForServer();\n      await assertPromptTitle(null);\n      await gu.loadDocMenu(\"/\");\n      await assertPromptTitle(\"Add new\");\n    });\n\n    it(\"should only be shown once on each visit\", async function() {\n      // Navigate to the home page for the first time; the tip should be shown.\n      await gu.loadDocMenu(\"/\");\n      await assertPromptTitle(\"Add new\");\n\n      // Switch to a different page; the tip should no longer be shown.\n      await driver.findContent(\".test-dm-workspace\", /Home/).click();\n      await gu.waitForDocMenuToLoad();\n      await assertPromptTitle(null);\n\n      // Reload the page; the tip should be shown again.\n      await driver.navigate().refresh();\n      await gu.waitForDocMenuToLoad();\n      await assertPromptTitle(\"Add new\");\n    });\n  });\n\n  it(`should stop showing tips if \"Don't show tips\" is checked`, async function() {\n    // Log in as a new user who hasn't seen any tips yet.\n    session = await gu.session().user(\"user2\").login({ showTips: true });\n    docId = await session.tempNewDoc(cleanup, \"BehavioralPromptsDontShowTips\");\n    await gu.loadDoc(`/doc/${docId}`);\n\n    // Check \"Don't show tips\" in the Reference Columns tip and dismiss it.\n    await gu.setType(/Reference$/);\n    await gu.scrollPanel(false);\n    await driver.findWait(\".test-behavioral-prompt-dont-show-tips\", 1000).click();\n    await gu.dismissBehavioralPrompts();\n\n    // Now visit Raw Data and check that its tip isn't shown.\n    await driver.find(\".test-tools-raw\").click();\n    await assertPromptTitle(null);\n  });\n\n  describe(\"when welcome tour is active\", function() {\n    before(async () => {\n      const welcomeTourSession = await gu.session().user(\"user3\").login({\n        isFirstLogin: false,\n        freshAccount: true,\n        showTips: true,\n      });\n      await welcomeTourSession.tempNewDoc(cleanup, \"BehavioralPromptsWelcomeTour\");\n    });\n\n    it(\"should not be shown\", async function() {\n      assert.isTrue(await driver.find(\".test-onboarding-close\").isDisplayed());\n      // The forms announcement is normally shown here.\n      await assertPromptTitle(null);\n    });\n  });\n\n  describe(\"when in a tutorial\", function() {\n    gu.withEnvironmentSnapshot({\n      GRIST_UI_FEATURES: \"tutorials\",\n      GRIST_TEMPLATE_ORG: \"templates\",\n      GRIST_ONBOARDING_TUTORIAL_DOC_ID: \"grist-basics\",\n    });\n\n    before(async () => {\n      const tutorialSession = await gu.session().user(\"user3\").login({\n        showTips: true,\n      });\n      await tutorialSession.loadDocMenu(\"/\");\n      await driver.find(\".test-dm-basic-tutorial\").click();\n      await gu.waitForDocToLoad();\n    });\n\n    it(\"should not be shown\", async function() {\n      // The comments announcement is normally shown here.\n      await assertPromptTitle(null);\n      await driver.find(\".test-doc-tutorial-popup-minimize-maximize\").click();\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await driver.find(\".test-right-tab-field\").click();\n      await driver.find(\".test-fbuilder-type-select\").click();\n      await gu.findOpenMenu();\n      await assertPromptTitle(null);\n    });\n  });\n\n  it(\"remembers that tips are dismissed after a reload\", async function() {\n    session = await gu.session().user(\"user1\").login({ showTips: true });\n    await gu.dismissCoachingCall();\n    docId = await session.tempNewDoc(cleanup, \"BehavioralPrompts\");\n\n    // Try to add a new page, which should show a tip.\n    await driver.findWait(\".test-dp-add-new\", 2000).doClick();\n    await driver.findWait(\".test-dp-add-new-page\", 2000).doClick();\n    await assertPromptTitle(\"Selecting Data\");\n\n    // Dismiss this tip.\n    await driver.findWait(\".test-behavioral-prompt-dont-show-tips\", 1000).click();\n    await gu.dismissBehavioralPrompts();\n    await gu.waitForServer();\n    await gu.sendKeys(Key.ESCAPE); // close the add page menu\n\n    // Reload the top model and check that the tip is not shown again.\n    await driver.executeScript(`gristApp.topAppModel.appObs.get().version = 1;`); // it wil be undefined when reloaded\n    assert.equal(await driver.executeScript(\"return gristApp.topAppModel.appObs.get().version\"), \"1\");\n    await driver.executeScript(`gristApp.topAppModel.reload()`);\n    await gu.waitToPass(async () => {\n      assert.isTrue(await driver.executeScript(`return gristApp.topAppModel.appObs.get().version === undefined`));\n    });\n\n    // Now try to add a new page again; the tip should not be shown.\n    await driver.findWait(\".test-dp-add-new\", 2000).doClick();\n    await driver.findWait(\".test-dp-add-new-page\", 2000).doClick();\n    // Wait a bit to be sure the tip doesn't appear.\n    await driver.sleep(200);\n    await assertPromptTitle(null);\n  });\n});\n\nasync function assertPromptTitle(title: string | null) {\n  if (title === null) {\n    await gu.waitToPass(async () => {\n      assert.equal(await driver.find(\".test-behavioral-prompt\").isPresent(), false);\n    });\n  } else {\n    await gu.waitToPass(async () => {\n      assert.equal(await driver.find(\".test-behavioral-prompt-title\").getText(), title);\n    });\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/Boot.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\n/**\n * The boot page functionality has been merged with the Admin Panel.\n * Check that it behaves as a boot page did now.\n */\ndescribe(\"Boot\", function() {\n  this.timeout(30000);\n  setupTestSuite();\n\n  let oldEnv: testUtils.EnvironmentSnapshot;\n\n  afterEach(() => gu.checkForErrors());\n\n  async function hasPrompt() {\n    // There is some glitchiness to when the text appears.\n    await gu.waitToPass(async () => {\n      assert.include(\n        await driver.findContentWait(\"pre\", /GRIST_BOOT_KEY/, 2000).getText(),\n        \"GRIST_BOOT_KEY=example-\");\n    }, 3000);\n  }\n\n  it(\"tells user about /admin\", async function() {\n    await driver.get(`${server.getHost()}/boot`);\n    assert.match(await driver.getPageSource(), /\\/admin/);\n    // Switch to a regular place to that gu.checkForErrors won't panic -\n    // it needs a Grist page.\n    await driver.get(`${server.getHost()}`);\n  });\n\n  it(\"gives prompt about how to enable boot page\", async function() {\n    await driver.get(`${server.getHost()}/admin`);\n    await hasPrompt();\n  });\n\n  describe(\"with a GRIST_BOOT_KEY\", function() {\n    before(async function() {\n      oldEnv = new testUtils.EnvironmentSnapshot();\n      process.env.GRIST_BOOT_KEY = \"lala\";\n      await server.restart();\n    });\n\n    after(async function() {\n      oldEnv.restore();\n      await server.restart();\n    });\n\n    it(\"gives prompt when key is missing\", async function() {\n      await driver.get(`${server.getHost()}/admin`);\n      await hasPrompt();\n    });\n\n    it(\"gives prompt when key is wrong\", async function() {\n      await driver.get(`${server.getHost()}/admin?boot-key=bilbo`);\n      await hasPrompt();\n    });\n\n    it(\"gives page when key is right\", async function() {\n      await driver.get(`${server.getHost()}/admin?boot-key=lala`);\n      await driver.findContentWait(\"div\", /Is home page available/, 2000);\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/BundleActions.ts",
    "content": "/**\n * Test for action bundling, e.g. when changing column type. Before the action is finalized, the\n * user has a chance to make further changes that are part of the bundle. If any change is\n * attempted that doesn't belong in the bundle, the bundle should be finalized before the change\n * is appled.\n */\n\nimport { SQLiteDB } from \"app/server/lib/SQLiteDB\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport fs from \"fs\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"BundleActions\", function() {\n  this.timeout(30000);\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    const mainSession = await gu.session().login();\n    await mainSession.tempNewDoc(cleanup, \"TransformBug\");\n\n    // Import a file\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n  });\n\n  it.skip(\"bug: bundle is properly finished\", async function() {\n    // There was a bug in the finalization of the bundle. There was previously a race condition\n    // as the finalizer was sending the `RemoveAction(column)` without waiting for the result.\n    // So sometimes column was added before removing the transform column. This was causing\n    // undo to fail (known bug in grist, adding and removing columns in two tabs and then undoing\n    // in one tab doesn't work, as `AddColumn` action is not idempotent, undoing and redoing can\n    // result with different PK).\n\n    // I don't have a good way to reproduce it. It can be done by brute force (a while loop)\n    // over the next test (on my machine it fails after 3 seconds). So this test is skipped and\n    // just a documentation of the bug for later use.\n\n    while (true) {\n      const rollback = await gu.begin();\n      // Start a transform.\n      await gu.getCell({ col: \"Name\", rowNum: 1 }).click();\n      // This does not include a click on the \"Apply\" button.\n      await gu.setType(/Reference/);\n      assert.equal(await gu.getCell({ col: \"Name\", rowNum: 1 }).matches(\".transform_field\"), true);\n\n      // Add a column while inside the transform.\n      await driver.find(\"body\").sendKeys(Key.chord(Key.ALT, Key.SHIFT, \"=\"));\n      await gu.waitForServer();\n      const file = fs.readdirSync(server.testDocDir)[0];\n      const fullPath = server.testDocDir + \"/\" + file;\n      // This is sqlite file, open it and read the last id in the _grist_Tables_column.\n      const db = await SQLiteDB.openDBRaw(fullPath);\n      const rows = await db.get(\"SELECT id FROM _grist_Tables_column ORDER BY id DESC LIMIT 1\");\n      await db.close();\n      const lastId = rows?.id as number;\n\n      await gu.sendKeys(Key.ESCAPE);\n\n      // if the id == 11 break;\n      if (lastId != 9) {\n        throw new Error(\"The RemoveColumn in the ColumnTransform._doFinalize was processed before adding the column\");\n        // So in the test below, the redo used to fail.\n      }\n      await rollback();\n    }\n  });\n\n  it(\"should complete transform if column is added during it\", async function() {\n    // Start a transform.\n    await gu.getCell({ col: \"Name\", rowNum: 1 }).click();\n    // This does not include a click on the \"Apply\" button.\n    await gu.setType(/Reference/);\n    assert.equal(await gu.getCell({ col: \"Name\", rowNum: 1 }).matches(\".transform_field\"), true);\n\n    // Add a column while inside the transform.\n    await driver.find(\"body\").sendKeys(Key.chord(Key.ALT, Key.SHIFT, \"=\"));\n    await gu.waitForServer();\n\n    // Check there are no user-visible errors at this point.\n    assert.equal(await driver.find(\".test-notifier-toast-message\").isPresent(), false);\n    await gu.checkForErrors();\n\n    // Close column-rename textbox.\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Check the Name column is no longer being transformed. Cells are invalid because it's now an\n    // invalid reference.\n    let cell = gu.getCell({ col: \"Name\", rowNum: 1 });\n    assert.equal(await cell.matches(\".transform_field\"), false);\n    assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), true);\n\n    // Do something with the new column.\n    await driver.sendKeys(\"HELLO\", Key.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ col: \"A\", rowNums: [1, 2, 3] }), [\"HELLO\", \"\", \"\"]);\n    await gu.enterFormula(\"str($Name).upper()\");\n    assert.deepEqual(await gu.getVisibleGridCells({ col: \"A\", rowNums: [1, 2, 3] }), [\"LILY\", \"KATHY\", \"KAREN\"]);\n\n    await gu.checkForErrors();\n\n    // Undo the column changes and the column addition.\n    await gu.undo(3);\n\n    // The transform result is still applied\n    cell = gu.getCell({ col: \"Name\", rowNum: 1 });\n    assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), true);\n    assert.equal(await cell.matches(\".transform_field\"), false);\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Reference\");\n\n    // Undo the transform now.\n    await gu.undo();\n\n    cell = gu.getCell({ col: \"Name\", rowNum: 1 });\n    assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), false);\n    assert.equal(await cell.matches(\".transform_field\"), false);\n    assert.deepEqual(await gu.getVisibleGridCells({ col: \"Name\", rowNums: [1, 2, 3] }), [\"Lily\", \"Kathy\", \"Karen\"]);\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Text\");\n    await gu.checkForErrors();\n\n    // For good measure, check that REDO works too.\n    await gu.redo(4);\n    assert.deepEqual(await gu.getVisibleGridCells({ col: \"A\", rowNums: [1, 2, 3] }), [\"LILY\", \"KATHY\", \"KAREN\"]);\n    cell = gu.getCell({ col: \"Name\", rowNum: 1 });\n    assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), true);\n    assert.equal(await cell.matches(\".transform_field\"), false);\n    await cell.click();\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Reference\");\n    await gu.checkForErrors();\n\n    // And back to where we started.\n    await gu.undo(4);\n  });\n\n  it(\"should complete transform if a page widget is added during it\", async function() {\n    // Start a transform.\n    await gu.getCell({ col: \"Name\", rowNum: 1 }).click();\n    await gu.setType(/Reference/);     // This does not include a click on the \"Apply\" button.\n    assert.equal(await gu.getCell({ col: \"Name\", rowNum: 1 }).matches(\".transform_field\"), true);\n\n    await gu.addNewSection(/Table/, /New Table/);\n\n    // Check there are no user-visible errors at this point.\n    assert.equal(await driver.find(\".test-notifier-toast-message\").isPresent(), false);\n    await gu.checkForErrors();\n\n    // Check that we see two sections.\n    assert.deepEqual(await gu.getSectionTitles(), [\"UPLOADEDDATA1\", \"TABLE2\"]);\n    await gu.getCell({ col: \"Name\", rowNum: 1, section: \"UPLOADEDDATA1\" }).click();\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Reference\");\n\n    // Undo both actions.\n    await gu.undo(2);\n    assert.deepEqual(await gu.getSectionTitles(), [\"UPLOADEDDATA1\"]);\n    await gu.getCell({ col: \"Name\", rowNum: 1, section: \"UPLOADEDDATA1\" }).click();\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Text\");\n\n    assert.equal(await driver.find(\".test-notifier-toast-message\").isPresent(), false);\n    await gu.checkForErrors();\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/CardView.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"CardView\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n  let docId: string;\n\n  before(async function() {\n    session = await gu.session().login();\n    docId = (await session.tempDoc(cleanup, \"Favorite_Films.grist\")).id;\n\n    await gu.toggleSidePanel(\"right\");\n    await driver.find(\".test-config-data\").click();\n  });\n\n  it(\"should not show card view controls when section is scroll-linked\", async function() {\n    // Select the card section.\n    await driver.findContent(\".test-treeview-itemHeader\", /All/).click();\n    await driver.find(\".detailview_single\").click();\n\n    // Assert that the controls are initially displayed.\n    await assertCardViewControls(true);\n\n    // Change the section to be scroll-linked.\n    await driver.find(\".test-right-select-by\").click();\n    await driver.findContent(\".test-select-menu li\", /Performances record/).click();\n    await gu.waitForServer();\n\n    // Assert that the controls are now not displayed.\n    await assertCardViewControls(false);\n\n    // Change the section to be filter-linked.\n    await driver.find(\".test-right-select-by\").click();\n    await driver.findContent(\".test-select-menu li\", /Performances record • Film/).click();\n    await gu.waitForServer();\n\n    // Assert that the controls are displayed again.\n    await assertCardViewControls(true);\n\n    // Reset linking.\n    await driver.find(\".test-right-select-by\").click();\n    await driver.findContent(\".test-select-menu li\", /Select widget/).click();\n    await gu.waitForServer();\n\n    // Assert that the controls are still displayed.\n    await assertCardViewControls(true);\n\n    // Now let's change a section to be column scroll-linked (not just section scroll-linked)\n    // and check that the controls are not displayed.\n    await gu.getSection(\"Films record\").click();\n    await driver.find(\".test-pwc-editDataSelection\").click();\n    await driver.findContentWait(\".test-wselect-type\", /Card/, 100).click();\n    await driver.find(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n    await driver.find(\".test-right-select-by\").click();\n    await driver.findContent(\".test-select-menu li\", /Performances record • Film/).click();\n    await gu.waitForServer();\n\n    // Assert that the controls are not displayed.\n    await assertCardViewControls(false);\n\n    // Reset linking and section type.\n    await driver.find(\".test-right-select-by\").click();\n    await driver.findContent(\".test-select-menu li\", /Select widget/).click();\n    await driver.find(\".test-pwc-editDataSelection\").click();\n    await driver.findContentWait(\".test-wselect-type\", /Table/, 100).click();\n    await driver.find(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n  });\n\n  it(\"should save theme changes\", async function() {\n    // Change the theme and check that it persists across refresh.\n    await gu.getSection(\"Performances detail\").click();\n    await driver.find(\".test-config-widget\").click();\n    await driver.find(\".test-vconfigtab-detail-theme\").click();\n    await driver.findContentWait(\".test-select-row\", /Compact/, 100).click();\n    await gu.waitForServer();\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n    await gu.getSection(\"Performances detail\").click();\n    await driver.find(\".test-config-widget\").click();\n    const themeSelect = await driver.findWait(\".test-vconfigtab-detail-theme\", 10000);\n    assert(await themeSelect.getText(), \"Compact\");\n\n    // Change it back.\n    await themeSelect.click();\n    await driver.findContentWait(\".test-select-row\", /Form/, 100).click();\n    await gu.waitForServer();\n  });\n\n  it(\"should render widgets with reasonably consistent heights\", async function() {\n    // Add a few more fields of different types.\n    const api = session.createHomeApi();\n    await api.applyUserActions(docId, [\n      [\"AddColumn\", \"Performances\", \"Files\", { type: \"Attachments\" }],\n      [\"AddColumn\", \"Performances\", \"Choice\", { type: \"Choice\" }],\n      [\"AddColumn\", \"Performances\", \"ChoiceList\", { type: \"ChoiceList\" }],\n      [\"AddColumn\", \"Performances\", \"Bool\", { type: \"Bool\" }],\n    ]);\n    await gu.getSection(\"Performances detail\").click();\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-config-widget\").click();\n\n    // Show all fields in the one Card section we have.\n    while (true) {\n      try {\n        await driver.find(\".test-vfc-hidden-fields .kf_draggable\").mouseMove().find(\".test-vfc-hide\").click();\n        await gu.waitForServer();\n      } catch (e) {\n        if (e.name === \"NoSuchElementError\") { break; }\n        throw e;\n      }\n    }\n\n    // Get the heights all the fields in our section.\n    await gu.getSection(\"Performances detail\").click();\n    // Actor and Film are wrapped\n    await driver.find(\".test-right-tab-field\").click();\n    await gu.getDetailCell(\"Actor\", 1).click();\n    await driver.find(\".test-tb-wrap-text\").click();\n    await gu.getDetailCell(\"Film\", 1).click();\n    await driver.find(\".test-tb-wrap-text\").click();\n    const cols = [\"Actor\", \"Film\", \"Character\", \"Files\", \"Choice\", \"ChoiceList\", \"Bool\"];\n    const fields = await Promise.all(cols.map(col =>\n      gu.getVisibleDetailCells({ col, rowNums: [1], mapper: e => e.getRect() })));\n\n    // Make sure the heights are close to each other.\n    const heights = fields.map(f => f[0].height);\n    const minHeight = Math.min(...heights);\n    const maxHeight = Math.max(...heights);\n    assert.isAtLeast(minHeight, maxHeight - 1, \"Too wide a range of heights\");\n  });\n});\n\nasync function assertCardViewControls(visible: boolean) {\n  const section = await driver.find(\".active_section\");\n  assert.equal(await section.find(\".grist-single-record__menu .detail-left\").isPresent(), visible);\n  assert.equal(await section.find(\".grist-single-record__menu .detail-right\").isPresent(), visible);\n  assert.equal(await section.find(\".grist-single-record__menu .detail-add-btn\").isPresent(), visible);\n  assert.equal(await section.find(\".grist-single-record__menu .grist-single-record__menu__count\").isPresent(), visible);\n}\n"
  },
  {
    "path": "test/nbrowser/CellColor.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"CellColor\", function() {\n  this.timeout(20000);\n  gu.bigScreen();\n  const cleanup = setupTestSuite();\n  let doc: string;\n\n  before(async () => {\n    // Create a new document\n    const mainSession = await gu.session().login();\n    doc = await mainSession.tempNewDoc(cleanup, \"CellColor\");\n    // add records\n    await gu.enterCell(\"a\");\n    await gu.enterCell(\"b\");\n    await gu.enterCell(\"c\");\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n  });\n\n  it(\"should save by clicking away\", async function() {\n    await gu.getCell(\"A\", 1).click();\n    // open color picker\n    await gu.openCellColorPicker();\n    await gu.setFillColor(\"red\");\n    await gu.setTextColor(\"blue\");\n    // Save by clicking B column\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitForServer();\n    // Make sure the color is saved.\n    await gu.assertCellFillColor(\"A\", 1, \"red\");\n    await gu.assertCellTextColor(\"A\", 1, \"blue\");\n    await gu.assertCellFillColor(\"B\", 1, \"transparent\");\n    await gu.assertCellTextColor(\"B\", 1, \"black\");\n    // Make sure it sticks after reload.\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n    await gu.assertCellFillColor(\"A\", 1, \"red\");\n    await gu.assertCellTextColor(\"A\", 1, \"blue\");\n    await gu.assertCellFillColor(\"B\", 1, \"transparent\");\n    await gu.assertCellTextColor(\"B\", 1, \"black\");\n  });\n\n  it(\"should undo and redo colors when clicked away\", async function() {\n    await gu.getCell(\"A\", 1).click();\n    // open color picker\n    await gu.openCellColorPicker();\n    await gu.setFillColor(\"red\");\n    await gu.setTextColor(\"blue\");\n    // Save by clicking B column\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitForServer();\n    // Make sure the color is saved.\n    await gu.assertCellFillColor(\"A\", 1, \"red\");\n    await gu.assertCellTextColor(\"A\", 1, \"blue\");\n    await gu.assertCellFillColor(\"B\", 1, \"transparent\");\n    await gu.assertCellTextColor(\"B\", 1, \"black\");\n    // Make sure then undoing works.\n    await gu.undo();\n    await gu.assertCellFillColor(\"A\", 1, \"transparent\");\n    await gu.assertCellTextColor(\"A\", 1, \"black\");\n    await gu.assertCellFillColor(\"B\", 1, \"transparent\");\n    await gu.assertCellTextColor(\"B\", 1, \"black\");\n    await gu.redo();\n    await gu.assertCellFillColor(\"A\", 1, \"red\");\n    await gu.assertCellTextColor(\"A\", 1, \"blue\");\n    await gu.assertCellFillColor(\"B\", 1, \"transparent\");\n    await gu.assertCellTextColor(\"B\", 1, \"black\");\n  });\n\n  it(\"should work correctly on Grid view\", async function() {\n    let clip: WebElement;\n    let cell = gu.getCell(\"A\", 1);\n    clip = cell.find(\".field_clip\");\n\n    await cell.click();\n    // open color picker\n    await gu.openCellColorPicker();\n\n    // set cell colors\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(0, 255, 0)\");\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(0, 0, 255)\");\n\n    // press enter to close color picker\n    await driver.sendKeys(Key.ENTER);\n\n    // check cell colors\n\n    // first for the field_clip.\n    assert.equal(await clip.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    // now for the whole cell.\n    assert.equal(await clip.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n\n    // Check background color for the add new row.\n    cell = gu.getCell(\"A\", 4);\n    clip = cell.find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 0, 0)\");\n    assert.equal(await clip.getCssValue(\"background-color\"), \"rgba(0, 0, 0, 0)\");\n    assert.equal(await clip.findClosest(\".record\").getCssValue(\"background-color\"), \"rgba(246, 246, 255, 1)\");\n  });\n\n  it(\"should work correctly in Detail view\", async function() {\n    // Add a card list widget of Table1\n    await gu.addNewSection(/Card List/, /Table1/);\n\n    // check cell colors\n    let cell: WebElement;\n    cell = gu.getDetailCell(\"A\", 1).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n\n    // check colors of the add new row\n    cell = gu.getDetailCell(\"A\", 4).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(246, 246, 255, 1)\");\n  });\n\n  it(\"should work correctly on Grid view in comparison mode\", async function() {\n    // First let's add an Hyperlink column\n    await gu.getSection(\"TABLE1\").click();\n    let cell = await gu.getCell(\"B\", 1).doClick();\n    await gu.enterCell(\"foo\");\n    await gu.setFieldWidgetType(\"HyperLink\");\n\n    // check default color of hyperlink\n    cell = await gu.getCell(\"B\", 1).find(\".field_clip\");\n    const ligthGreen = gu.hexToRgb(\"#16B378\");\n    assert.equal(await cell.getCssValue(\"color\"), ligthGreen);\n    assert.equal(await cell.find(\".test-tb-link-icon\").getCssValue(\"background-color\"), ligthGreen);\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 0, 0)\");\n\n    // Make a fork of the document with a change\n    const mainSession = await gu.session().login();\n    await mainSession.loadDoc(`/doc/${doc}/m/fork`);\n\n    // Change active section\n    await gu.getSection(\"TABLE1\").click();\n\n    // Add new record\n    await gu.getCell(\"A\", 1).click();\n    await gu.enterCell(\"e\");\n    await gu.getCell(\"A\", 4).click();\n    await gu.enterCell(\"f\", Key.TAB);\n    await gu.enterCell(\"foo\");\n\n    // get the forkid\n    const forkId = await gu.getCurrentUrlId();\n\n    // Compare with the original\n    await mainSession.loadDoc(`/doc/${doc}?compare=${forkId}`, { skipAlert: true });\n\n    // check that colors for diffing cells are ok\n    cell = gu.getCell(\"A\", 1).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 0, 0, 1)\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 0, 0)\");\n\n    // check colors for a normal row\n    cell = gu.getCell(\"A\", 2).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n\n    // check colors of the added records is ok\n    cell = gu.getCell(\"A\", 4).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 0, 0, 1)\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(175, 255, 175, 1)\");\n    assert.equal(await cell.findClosest(\".record\").getCssValue(\"background-color\"), \"rgba(175, 255, 175, 1)\");\n\n    // check colors of the hyperlink\n    cell = gu.getCell(\"B\", 4).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 0, 0, 1)\");\n    assert.equal(await cell.find(\".test-tb-link-icon\").getCssValue(\"background-color\"), \"rgba(0, 0, 0, 1)\");\n\n    // check colors of the add new row\n    cell = gu.getCell(\"A\", 5).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 0, 0)\");\n    assert.equal(await cell.findClosest(\".record\").getCssValue(\"background-color\"), \"rgba(246, 246, 255, 1)\");\n  });\n\n  it(\"should work correctly on Detail view in comparison mode\", async function() {\n    // Change active section\n    await gu.getSection(\"TABLE1 Card List\").click();\n\n    let cell: WebElement;\n    // check color of diffing cell is are ok\n    cell = gu.getDetailCell(\"A\", 1).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 0, 0, 1)\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 0, 0)\");\n\n    // check colors of normal row is set\n    cell = gu.getDetailCell(\"A\", 2).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n\n    // check colors of the added records is ok\n    cell = gu.getDetailCell(\"A\", 4).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 0, 0, 1)\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(175, 255, 175, 1)\");\n    assert.equal(await cell.findClosest(\".g_record_detail\").getCssValue(\"background-color\"), \"rgba(175, 255, 175, 1)\");\n\n    // check colors of the add new row\n    cell = gu.getDetailCell(\"A\", 5).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(246, 246, 255, 1)\");\n  });\n\n  it(\"should work for HyperLinkTextbox\", async function() {\n    // Load original document\n    const mainSession = await gu.session().login();\n    await mainSession.loadDoc(`/doc/${doc}`);\n\n    // select the grid widget\n    await gu.getSection(\"TABLE1\").click();\n\n    // change widget to hyper link\n    await gu.setFieldWidgetType(\"HyperLink\");\n    const cell = gu.getCell(\"A\", 1).find(\".field_clip\");\n\n    // check cell show hyperlink\n    assert.equal(await cell.find(\"a\").isPresent(), true);\n\n    // check color and background are ok\n    assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n    assert.equal(await cell.find(\".test-tb-link-icon\").getCssValue(\"background-color\"), \"rgba(0, 255, 0, 1)\");\n  });\n\n  it(\"should work with Attachment type column\", async function() {\n    // change widget to Attachment\n    await gu.setType(/Attachment/);\n    await driver.findWait(\".test-type-transform-apply\", 1000).click();\n    await gu.waitForServer();\n\n    // empty cell\n    let cell = await gu.getCell(\"A\", 1).find(\".field_clip\").doClick();\n    await gu.enterCell(Key.DELETE);\n\n    // check cell type\n    cell = await gu.getCell(\"A\", 1).find(\".field_clip\");\n    assert.equal(await cell.matches(\".test-attachment-widget\"), true);\n\n    // open color picker\n    await gu.openCellColorPicker();\n\n    // set and check cellColor\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(0, 255, 0)\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    });\n\n    // set and check fillColor\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(0, 0, 255)\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n    });\n\n    // press enter to close color picker\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n  });\n\n  it(\"should work with Toggle type column\", async function() {\n    // change widget to Toggle type\n    await gu.setType(/Toggle/);\n    await driver.findWait(\".test-type-transform-apply\", 1000).click();\n    await gu.waitForServer();\n\n    // check cell has changed type\n    const cell = await gu.getCell(\"A\", 1).find(\".field_clip\");\n    assert.equal(await cell.find(\".widget_checkbox\").isPresent(), true);\n\n    // open color picker\n    await gu.openCellColorPicker();\n\n    // set and check cell color\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(0, 255, 0)\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    });\n\n    // set and check fill color\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(0, 0, 255)\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n    });\n\n    // press enter to close color picker\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n  });\n\n  it(\"should work with DateTime column\", async function() {\n    // change widget to Date type\n    await gu.setType(/Date/);\n    await driver.findWait(\".test-type-transform-apply\", 1000).click();\n    await gu.waitForServer();\n    const cell = gu.getCell(\"A\", 1);\n\n    // Empty cell to clear error from converting toggle to date\n    await cell.click();\n    await driver.sendKeys(Key.DELETE);\n    await gu.waitAppFocus();\n\n    // open color picker\n    await gu.openCellColorPicker();\n\n    // set and check cell color\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(0, 255, 0)\");\n    await gu.waitToPass(async () => {\n      const clip = cell.find(\".field_clip\");\n      assert.equal(await clip.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    });\n\n    // set and check fill color\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(0, 0, 255)\");\n    await gu.waitToPass(async () => {\n      const clip = cell.find(\".field_clip\");\n      assert.equal(await clip.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n    });\n\n    // press enter to close color picker\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n  });\n\n  it(\"should work with Reference column\", async function() {\n    // change widget to Reference type\n    await gu.setType(/Reference/);\n    await driver.findWait(\".test-type-transform-apply\", 1000).click();\n    await gu.waitForServer();\n    const cell = gu.getCell(\"A\", 1).find(\".field_clip\");\n\n    // open color picker\n    await gu.openCellColorPicker();\n\n    // set and check cell color\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(0, 255, 0)\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    });\n\n    // set and check fill color\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(0, 0, 255)\");\n    await gu.waitToPass(async () => {\n      // check color and background are of\n      assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n    });\n\n    // press enter to close color picker\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n  });\n\n  it(\"should work with Choice type column\", async function() {\n    // change widget to Choice type\n    await gu.setType(/Choice/);\n    await driver.findWait(\".test-type-transform-apply\", 1000).click();\n    await gu.waitForServer();\n\n    // empty cell\n    await gu.getCell(\"A\", 1).click();\n    await gu.enterCell(Key.DELETE);\n\n    // open color picker\n    await gu.openCellColorPicker();\n\n    // set and check cell color\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(0, 255, 0)\");\n    const cell = await gu.getCell(\"A\", 1).find(\".field_clip\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    });\n\n    // set and check fill color\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(0, 0, 255)\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n    });\n\n    // press enter to close color picker\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n  });\n\n  it(\"should work well with error cell\", async function() {\n    // change to numeric type\n    await gu.setType(/Numeric/);\n    await driver.findWait(\".test-type-transform-apply\", 1000).click();\n    await gu.waitForServer();\n\n    // enter text\n    await gu.getCell(\"A\", 1).click();\n    await gu.enterCell(\"foo\");\n\n    // open color picker\n    await gu.openCellColorPicker();\n\n    // set and check cell color\n    const cell = await gu.getCell(\"A\", 1).find(\".field_clip\");\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(0, 255, 0)\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 0, 0, 1)\");\n    });\n\n    // set and check fill color\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(0, 0, 255)\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(255, 182, 193, 1)\");\n    });\n\n    // press enter to close color picker\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n  });\n\n  it(\"should persist when changing cell format\", async function() {\n    // change to text type\n    await gu.setType(/Text/);\n    await driver.findWait(\".test-type-transform-apply\", 1000).click();\n    await gu.waitForServer();\n    let cell = gu.getCell(\"A\", 1).find(\".field_clip\");\n\n    // open color picker\n    await gu.openCellColorPicker();\n\n    // set and check cell color\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(0, 255, 0)\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    });\n\n    // set and check fill color\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(0, 0, 255)\");\n    await gu.waitToPass(async () => {\n      assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n    });\n\n    // press enter to close color picker\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n\n    // change format to hyperlink\n    await gu.setFieldWidgetType(\"HyperLink\");\n\n    // check color is still ok\n    cell = gu.getCell(\"A\", 1).find(\".field_clip\");\n    assert.equal(await cell.getCssValue(\"color\"), \"rgba(0, 255, 0, 1)\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n  });\n\n  const toggleDefaultColor = \"rgba(96, 96, 96, 1)\";\n  const switchDefaultColor = \"rgba(0, 144, 88, 1)\";\n  const getPickerCurrentTextColor = () => driver.find(\".test-text-color-square\").getCssValue(\"background-color\");\n\n  it(\"should handle correctly default text color\", async function() {\n    // Create new checkbox column\n    await driver.find(\".mod-add-column\").click();\n    await driver.findWait(\".test-new-columns-menu-add-new\", 100).click();\n    await gu.waitForServer();\n    await gu.setType(/Toggle/);\n\n    // open the color picker\n    await gu.openCellColorPicker();\n\n    // check color preview is correct\n    assert.equal(await driver.find(\".test-text-hex\").value(), \"default\");\n    assert.equal(await getPickerCurrentTextColor(), toggleDefaultColor);\n\n    // close color picker\n    await driver.sendKeys(Key.ENTER);\n\n    // Change widget to Switch\n    await driver.find(\".test-fbuilder-widget-select\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /Switch/).click();\n    await gu.waitForServer();\n\n    // open the color picker\n    await gu.openCellColorPicker();\n\n    // check color preview is correct\n    assert.equal(await driver.find(\".test-text-hex\").value(), \"default\");\n    assert.equal(await getPickerCurrentTextColor(), switchDefaultColor);\n\n    // close picker\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should allow reverting to default text color\", async function() {\n    // Continuing on the previous test case, change Switch widget from default to an explicit color.\n    await gu.openCellColorPicker();\n    assert.equal(await driver.find(\".test-text-hex\").value(), \"default\");\n    assert.equal(await getPickerCurrentTextColor(), switchDefaultColor);\n    await gu.setTextColor(\"rgb(255, 0, 0)\");\n    assert.equal(await driver.find(\".test-text-hex\").value(), \"#FF0000\");\n    assert.equal(await getPickerCurrentTextColor(), \"rgba(255, 0, 0, 1)\");\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n\n    // Change widget to Toggle\n    await driver.find(\".test-fbuilder-widget-select\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /CheckBox/).click();\n    await gu.waitForServer();\n\n    // Check the saved color applies.\n    await gu.openCellColorPicker();\n    assert.equal(await driver.find(\".test-text-hex\").value(), \"#FF0000\");\n    assert.equal(await getPickerCurrentTextColor(), \"rgba(255, 0, 0, 1)\");\n\n    // Revert to default; the new widget has a different default.\n    await driver.find(\".test-text-empty\").click();\n    assert.equal(await driver.find(\".test-text-hex\").value(), \"default\");\n    assert.equal(await getPickerCurrentTextColor(), toggleDefaultColor);\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n  });\n\n  it(\"should not save default color\", async function() {\n    // This test catch a bug that used to save the default color to server when changing widget format\n\n    // create a new checkbox column\n    await driver.find(\".mod-add-column\").click();\n    await driver.findWait(\".test-new-columns-menu-add-new\", 100).click();\n\n    await gu.waitForServer();\n    await gu.setType(/Toggle/);\n\n    // make sure the view pane is scrolled all the way left\n    await gu.sendKeys(Key.ARROW_LEFT);\n\n    // enter 'true'\n    await gu.getCell(\"E\", 1).click();\n    await gu.enterCell(\"true\");\n\n    // check the color\n    const cell = () => gu.getCell(\"E\", 1).find(\".field_clip\");\n    assert.equal(await cell().find(\".checkmark_stem\").getCssValue(\"background-color\"), gu.hexToRgb(\"#606060\"));\n\n    // open the color picker and press ESC\n    await gu.openCellColorPicker();\n    await driver.wait(() => driver.find(\".test-fill-palette\").isPresent(), 3000);\n    await driver.sendKeys(Key.ESCAPE);\n    await driver.wait(async () => !(await driver.find(\".test-fill-palette\").isPresent()), 3000);\n\n    // switch format to switch\n    await driver.find(\".test-fbuilder-widget-select\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /Switch/).click();\n    await gu.waitForServer();\n\n    // check the switch's color\n    assert.equal(\n      await cell().find(\".test-toggle-switch-slider\").getCssValue(\"background-color\"), gu.hexToRgb(\"#009058\"),\n    );\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/CellFormat.ts",
    "content": "import { DocCreationInfo } from \"app/common/DocListAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"CellFormat\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let session: gu.Session, doc: DocCreationInfo, api;\n\n  // Checks that a bug where alignment settings did not survive doc reload is gone.\n  it(\"saves alignment settings\", async function() {\n    session = await gu.session().login();\n    doc = await session.tempDoc(cleanup, \"Hello.grist\");\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.findWait(\".test-right-tab-field\", 3000).click();\n\n    // Alignment should be left.\n    assert.equal(await driver.find(`.test-alignment-select .test-select-button:first-child`)\n      .matches(\"[class*=-selected]\"), true);\n\n    // Click on center aligmment.\n    await (await driver.findAll(\".test-alignment-select .test-select-button\"))[1].click();\n    await gu.waitForServer();\n\n    // Alignment should no longer be left.\n    assert.equal(await driver.find(`.test-alignment-select .test-select-button:first-child`)\n      .matches(\"[class*=-selected]\"), false);\n\n    // Reload document.\n    await session.loadDocMenu(\"/\");\n    await session.loadDoc(`/doc/${doc.id}`);\n\n    // Alignment should still not be left.\n    assert.equal(await driver.find(`.test-alignment-select .test-select-button:first-child`)\n      .matches(\"[class*=-selected]\"), false);\n  });\n\n  it(\"should open hyperlinks in new tabs only when needed\", async function() {\n    api = session.createHomeApi();\n    const currentUrl = await driver.getCurrentUrl();\n    const urls = [\n      // Different origin, must open in new tab.\n      // The driver waits for the page to load so something that loads quickly is needed.\n      \"about:blank\",\n\n      // Same origin, but still needs a new tab because it's not the current document\n      server.getUrl(session.settings.orgDomain, \"/\"),\n\n      // Same URL but with a link key, needs a new tab\n      currentUrl + \"?foo_=bar\",\n\n      // Shouldn't open a new tab\n      currentUrl,\n    ];\n\n    // Create and open a new table in the same document containing the above URLs\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Links\",\n        [{ id: \"Link\", type: \"Text\" }]],\n      ...urls.map(url => [\"AddRecord\", \"Links\", null, { Link: url }]),\n    ]);\n    await gu.getPageItem(/Links/).click();\n\n    // Confirm that we are on a different page from before (i.e. `currentUrl`)\n    // which we will be returning to\n    const newUrl = await driver.getCurrentUrl();\n    assert.isTrue(newUrl.endsWith(\"/p/2\"));\n    assert.isFalse(currentUrl.endsWith(\"/p/2\"));\n\n    // Convert the column to hyperlink format\n    await gu.getCell({ rowNum: 1, col: 0 }).click();\n    await gu.setFieldWidgetType(\"HyperLink\");\n\n    // There should only be one tab open for the following checks to make sense\n    assert.equal((await driver.getAllWindowHandles()).length, 1);\n\n    async function checkExternalLink(rowNum: number) {\n      const cell = gu.getCell({ rowNum, col: 0 });\n      const url = await cell.getText();\n      await cell.find(\".test-tb-link\").click();\n\n      // Check that we opened the URL in the cell in a new tab\n      const handles = await driver.getAllWindowHandles();\n      assert.equal(handles.length, 2);\n      // Use gu.switchToWindow to handle occasional selenium flakage here.\n      await gu.switchToWindow(handles[1]);\n      assert.equal(await driver.getCurrentUrl(), url);\n      assert.equal(urls[rowNum - 1], url);\n      await driver.close();\n\n      // Return to the original tab with our document\n      const [originalWindow] = await driver.getAllWindowHandles();\n      await driver.switchTo().window(originalWindow);\n    }\n\n    await checkExternalLink(1);\n    await checkExternalLink(2);\n    await checkExternalLink(3);\n\n    const cell = gu.getCell({ rowNum: 4, col: 0 });\n    const url = await cell.getText();\n    await cell.find(\".test-tb-link\").click();\n    const handles = await driver.getAllWindowHandles();\n\n    // This time no new tab should have opened,\n    // but we're back to the previous page\n    assert.equal(handles.length, 1);\n    assert.equal(await driver.getCurrentUrl(), url);\n    assert.equal(currentUrl, url);\n  });\n\n  it(\"can display Markdown-formatted text\", async function() {\n    await gu.getCell(0, 1).click();\n    await gu.setFieldWidgetType(\"TextBox\");\n    await gu.sendKeys(\n      Key.ENTER,\n      await gu.selectAllKey(),\n      Key.DELETE,\n      \"# Heading\",\n      Key.chord(Key.SHIFT, Key.ENTER),\n      Key.chord(Key.SHIFT, Key.ENTER),\n      \"## Subheading\",\n      Key.chord(Key.SHIFT, Key.ENTER),\n      Key.chord(Key.SHIFT, Key.ENTER),\n      \"1. Item 1\",\n      Key.chord(Key.SHIFT, Key.ENTER),\n      \"2. Item 2\",\n      Key.chord(Key.SHIFT, Key.ENTER),\n      Key.chord(Key.SHIFT, Key.ENTER),\n      \"A paragraph with **bold** and *italicized* text.\",\n      Key.chord(Key.SHIFT, Key.ENTER),\n      Key.chord(Key.SHIFT, Key.ENTER),\n      \"[Link with label](https://example.com/#1)\",\n      Key.chord(Key.SHIFT, Key.ENTER),\n      Key.chord(Key.SHIFT, Key.ENTER),\n      \"Link: https://example.com/#2\",\n      Key.chord(Key.SHIFT, Key.ENTER),\n      Key.chord(Key.SHIFT, Key.ENTER),\n      'HTML is <span style=\"color: red;\">escaped</span>.',\n      Key.chord(Key.SHIFT, Key.ENTER),\n      Key.chord(Key.SHIFT, Key.ENTER),\n      \"![Images too](https://example.com)\",\n      Key.ENTER,\n    );\n    await gu.waitForServer();\n    assert.equal(\n      await gu.getCell(0, 1).getText(),\n      `# Heading\n\n## Subheading\n\n1. Item 1\n2. Item 2\n\nA paragraph with **bold** and *italicized* text.\n\n[Link with label](\n)\n\nLink: \\nhttps://example.com/#2\n\nHTML is <span style=\"color: red;\">escaped</span>.\n\n![Images too](\n)`,\n    );\n    assert.isFalse(await gu.getCell(0, 1).findContent(\"h1\", \"Heading\").isPresent());\n    assert.isFalse(await gu.getCell(0, 1).findContent(\"h2\", \"Subheading\").isPresent());\n    assert.isFalse(await gu.getCell(0, 1).findContent(\"ol\", \"Item 1\").isPresent());\n    assert.isFalse(await gu.getCell(0, 1).findContent(\"strong\", \"bold\").isPresent());\n    assert.isFalse(await gu.getCell(0, 1).findContent(\"em\", \"italicized\").isPresent());\n    assert.isFalse(await gu.getCell(0, 1).findContent(\"a + span\", \"Link with label\").isPresent());\n    assert.isTrue(await gu.getCell(0, 1).find('a[href=\"https://example.com/#2\"]').isDisplayed());\n    assert.isFalse(await gu.getCell(0, 1).findContent(\"span\", \"escaped\").isPresent());\n    assert.isFalse(await gu.getCell(0, 1).find(\"img\").isPresent());\n\n    await gu.setFieldWidgetType(\"Markdown\");\n    await driver.find(\".test-tb-wrap-text\").click();\n    await gu.waitForServer();\n    assert.equal(\n      await gu.getCell(0, 1).getText(),\n      `Heading\nSubheading\nItem 1\nItem 2\nA paragraph with bold and italicized text.\nLink with label\nLink:\nhttps://example.com/#2\nHTML is <span style=\"color: red;\">escaped</span>.\n![Images too](https://example.com)`,\n    );\n    assert.isTrue(await gu.getCell(0, 1).findContent(\"h1\", \"Heading\").isDisplayed());\n    assert.isTrue(await gu.getCell(0, 1).findContent(\"h2\", \"Subheading\").isDisplayed());\n    assert.isTrue(await gu.getCell(0, 1).findContent(\"ol\", \"Item 1\").isDisplayed());\n    assert.isTrue(await gu.getCell(0, 1).findContent(\"strong\", \"bold\").isDisplayed());\n    assert.isTrue(await gu.getCell(0, 1).findContent(\"em\", \"italicized\").isDisplayed());\n    assert.isTrue(await gu.getCell(0, 1)\n      .findContent('a[href=\"https://example.com/#1\"] + span', \"Link with label\").isDisplayed());\n    assert.isTrue(await gu.getCell(0, 1).find('a[href=\"https://example.com/#2\"]').isDisplayed());\n    assert.isFalse(await gu.getCell(0, 1).findContent(\"span\", \"escaped\").isPresent());\n    assert.isFalse(await gu.getCell(0, 1).find(\"img\").isPresent());\n\n    await gu.sendKeys(\n      Key.ENTER,\n      \"> Editing works the same way as TextBox and HyperLink\",\n      Key.ENTER,\n    );\n    await gu.waitForServer();\n    assert.equal(\n      await gu.getCell(0, 2).getText(),\n      \"Editing works the same way as TextBox and HyperLink\",\n    );\n\n    await gu.setFieldWidgetType(\"TextBox\");\n    assert.equal(\n      await gu.getCell(0, 1).getText(),\n      `# Heading\n\n## Subheading\n\n1. Item 1\n2. Item 2\n\nA paragraph with **bold** and *italicized* text.\n\n[Link with label](\n)\n\nLink: \\nhttps://example.com/#2\n\nHTML is <span style=\"color: red;\">escaped</span>.\n\n![Images too](\n)`,\n    );\n    assert.equal(\n      await gu.getCell(0, 2).getText(),\n      \"> Editing works the same way as TextBox and HyperLink\",\n    );\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ChartView1.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport { addYAxis, checkAxisConfig, checkAxisRange, findYAxis, getAxisTitle, getChartData,\n  removeYAxis, selectChartType, selectXAxis,\n  setSplitSeries } from \"test/nbrowser/chartViewTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"ChartView1\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let api: UserAPI;\n  let doc: any;\n\n  before(async function() {\n    const session = await gu.session().teamSite.login();\n    doc = await session.tempDoc(cleanup, \"ChartData.grist\");\n    api = session.createHomeApi();\n  });\n\n  gu.bigScreen();\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should treat text as category\", async function() {\n    const revert = await gu.begin();\n    await gu.sendActions([\n      [\"AddTable\", \"Text\", [\n        { id: \"X\", type: \"Int\" },\n        { id: \"Y\", type: \"Int\" },\n      ]],\n      [\"AddRecord\", \"Text\", null, { X: 1, Y: 1 }],\n      [\"AddRecord\", \"Text\", null, { X: 100, Y: 2 }],\n      [\"AddRecord\", \"Text\", null, { X: 101, Y: 3 }],\n      [\"AddRecord\", \"Text\", null, { X: 102, Y: 4 }],\n    ]);\n    await gu.openPage(\"Text\");\n    await gu.addNewSection(\"Chart\", \"Text\");\n\n    const layout = () => getChartData().then(d => d.layout);\n\n    await gu.waitToPass(async () => {\n      // Check to make sure initial values are correct.\n      assert.deepEqual((await layout()).xaxis.type, \"linear\");\n    });\n\n    // Now convert X to text.\n    await gu.sendActions([\n      [\"ModifyColumn\", \"Text\", \"X\", { type: \"Text\" }],\n    ]);\n    assert.deepEqual((await layout()).xaxis.type, \"category\");\n\n    // Invert the chart.\n    await gu.selectSectionByTitle(\"Text Chart\");\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-chart-orientation .test-select-open\").click();\n    await gu.findOpenMenuItem(\"li\", \"Horizontal\").click();\n    await gu.waitForServer();\n    assert.deepEqual((await layout()).yaxis.type, \"category\");\n    assert.deepEqual((await layout()).xaxis.type, \"linear\");\n\n    await revert();\n    await gu.toggleSidePanel(\"right\", \"close\");\n  });\n\n  it(\"should allow adding and removing chart viewsections\", async function() {\n    // Starting out with one section\n    assert.lengthOf(await driver.findAll(\".test-gristdoc .view_leaf\"), 1);\n\n    // Add a new chart section\n    await gu.addNewSection(/Chart/, /ChartData/);\n\n    // Check that there are now two sections\n    assert.lengthOf(await driver.findAll(\".test-gristdoc .view_leaf\"), 2);\n\n    // Delete the newly added one\n    await gu.openSectionMenu(\"viewLayout\", \"CHARTDATA Chart\");\n    await driver.find(\".test-section-delete\").click();\n    await gu.waitForServer();\n\n    // Check that there is now only one section\n    assert.lengthOf(await driver.findAll(\".test-gristdoc .view_leaf\"), 1);\n  });\n\n  it(\"should display a bar chart by default\", async function() {\n    // Add a new chart section, and make sure it has focus\n    await gu.addNewSection(/Chart/, /ChartData/);\n    const section = await gu.getSection(\"CHARTDATA Chart\");\n    assert.equal(await section.matches(\".active_section\"), true);\n\n    const chartDom = await section.find(\".test-chart-container\");\n    assert.equal(await chartDom.isDisplayed(), true);\n\n    const data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"bar\");\n    assert.deepEqual(data[0].x, [6, 5, 4, 3, 2, 1]);\n    assert.deepEqual(data[0].y, [1, 2, 3, 4, 5, 6]);\n  });\n\n  it(\"should allow viewing raw data underlying chart\", async function() {\n    // No raw data overlay at first\n    assert.isFalse(await driver.find(\".test-raw-data-overlay\").isPresent());\n\n    // Show raw data overlay\n    await gu.openSectionMenu(\"viewLayout\");\n    await driver.find(\".test-show-raw-data\").click();\n\n    // Test that overlay is showed.\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n\n    // Test that the widget menu doesn't have the raw data option any more\n    await gu.openSectionMenu(\"viewLayout\");\n    assert.isTrue(await gu.findOpenMenuItem(\"li\", \"Print widget\", 100).isDisplayed());\n    assert.isFalse(await driver.findContent(\".grist-floating-menu li\", \"Show raw data\").isPresent());\n\n    // Go back and confirm that the overlay is gone again\n    await driver.find(\".test-raw-data-close-button\").click();\n    assert.isFalse(await driver.find(\".test-raw-data-overlay\").isPresent());\n\n    // Open once again and close by escaping.\n    await gu.openSectionMenu(\"viewLayout\");\n    await driver.find(\".test-show-raw-data\").click();\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n    await gu.sendKeys(Key.ESCAPE);\n    assert.isFalse(await driver.find(\".test-raw-data-overlay\").isPresent());\n  });\n\n  it(\"should update as the underlying data changes\", async function() {\n    await gu.getCell({ section: \"ChartData\", col: 0, rowNum: 1 }).click();\n    await driver.sendKeys(Key.ENTER, \"1\", Key.ENTER);   // Change from 6 to 61\n    await gu.waitForServer();\n\n    const chartDom = await driver.find(\".test-chart-container\");\n    let data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"bar\");\n    assert.deepEqual(data[0].x, [61, 5, 4, 3, 2, 1]);\n    assert.deepEqual(data[0].y, [1, 2, 3, 4, 5, 6]);\n\n    await gu.getCell({ section: \"ChartData\", col: 1, rowNum: 1 }).click();\n    await driver.sendKeys(Key.ENTER, \"6\", Key.ENTER);              // Change from 1 to 16\n    await gu.waitForServer();\n\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"bar\");\n    assert.deepEqual(data[0].x, [61, 5, 4, 3, 2, 1]);\n    assert.deepEqual(data[0].y, [16, 2, 3, 4, 5, 6]);\n  });\n\n  it(\"should skip empty points\", async function() {\n    const chartDom = await driver.find(\".test-chart-container\");\n    let data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].x, [61, 5, 4, 3, 2, 1]);\n    assert.deepEqual(data[0].y, [16, 2, 3, 4, 5, 6]);\n\n    // Enter some blank values and a zero. The zero should be included in the plot, but blanks\n    // should not.\n    await gu.getCell({ col: 1, rowNum: 1 }).click();\n    await driver.sendKeys(Key.DELETE);\n    await gu.getCell({ col: 1, rowNum: 4 }).click();\n    await driver.sendKeys(Key.DELETE);\n    await gu.getCell({ col: 1, rowNum: 6 }).click();\n    await driver.sendKeys(\"0\", Key.ENTER);\n    await gu.waitForServer();\n\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].x, [5, 4, 2, 1]);\n    assert.deepEqual(data[0].y, [2, 3, 5, 0]);\n\n    // Undo and verify that the range is restored.\n    await gu.undo(3);\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].x, [61, 5, 4, 3, 2, 1]);\n    assert.deepEqual(data[0].y, [16, 2, 3, 4, 5, 6]);\n  });\n\n  it(\"should update chart when new columns are included\", async function() {\n    const chartDom = await driver.find(\".test-chart-container\");\n    // Check to make sure initial values are correct.\n    let data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"bar\");\n    assert.deepEqual(data[0].x, [61, 5, 4, 3, 2, 1]);\n    assert.deepEqual(data[0].y, [16, 2, 3, 4, 5, 6]);\n\n    // Check that the intial scales are correct for the dataset.\n    checkAxisRange(await getChartData(chartDom), 0.5, 61.5, 0, 16.5);\n\n    // Open the view config pane for the Chart section.\n    await gu.getSection(\"ChartData chart\").find(\".viewsection_title\").click();\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await driver.find(\".test-config-widget\").click();\n\n    // Check intial visible fields.\n    await checkAxisConfig({\n      xaxis: \"label\",\n      yaxis: [\"value\"],\n    });\n\n    // Adds 'largeValue'\n    await driver.find(\".test-chart-add-y-axis\").click();\n    await gu.findOpenMenuItem(\"li\", \"largeValue\").click();\n    await gu.waitForServer();\n\n    // Check axis are correct\n    await checkAxisConfig({\n      xaxis: \"label\",\n      yaxis: [\"value\", \"largeValue\"],\n    });\n\n    // Move 'largeValue' above 'value'. Scroll it into view first, since dragging is a bit messed\n    // up when it causes the pane to scroll.\n    await gu.scrollIntoView(findYAxis(\"largeValue\"));\n    await driver.withActions(actions => actions.dragAndDrop(findYAxis(\"largeValue\"), findYAxis(\"value\")));\n    await gu.waitForServer();\n\n    await checkAxisConfig({\n      xaxis: \"label\",\n      yaxis: [\"largeValue\", \"value\"],\n    });\n\n    // Make sure only y axis updates to the new column of data\n    await driver.sleep(50);\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"bar\");\n    assert.deepEqual(data[0].x, [61, 5, 4, 3, 2, 1]);\n    assert.deepEqual(data[0].y, [22, 33, 11, 44, 22, 55]);\n    assert.deepEqual(data[1].type, \"bar\");\n    assert.deepEqual(data[1].x, [61, 5, 4, 3, 2, 1]);\n    assert.deepEqual(data[1].y, [16, 2, 3, 4, 5, 6]);\n\n    // Check that the scales are correct for the new y values.\n    checkAxisRange(await getChartData(chartDom), 0.5, 61.5, 0, 57);\n\n    // select 'largeValue' as x axis\n    await selectXAxis(\"largeValue\");\n\n    // check x-axis is correct\n    await checkAxisConfig({\n      xaxis: \"largeValue\",\n      yaxis: [\"value\"], // note: 'largeValue' was correctly removed from y-axis\n    });\n\n    // adds 'label' as y axis\n    await addYAxis(\"label\");\n\n    // check axis are correct\n    await checkAxisConfig({\n      xaxis: \"largeValue\",\n      yaxis: [\"value\", \"label\"],\n    });\n\n    // Reverse the order of the columns and make sure the data updates to reflect that.\n    await driver.sleep(50);\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"bar\");\n    assert.deepEqual(data[0].x, [22, 33, 11, 44, 55]);\n    assert.deepEqual(data[0].y, [16, 2, 3, 4, 6]);\n    assert.deepEqual(data[1].type, \"bar\");\n    assert.deepEqual(data[1].x, [22, 33, 11, 44, 55]);\n    assert.deepEqual(data[1].y, [61, 5, 4, 3, 1]);\n\n    // Check that the scales are correct for the new values.\n    checkAxisRange(await getChartData(chartDom), 5.5, 60.5, 0, 61);\n\n    // select 'label' as x axis\n    await selectXAxis(\"label\");\n\n    // adds 'largeValue' as y axis\n    await addYAxis(\"largeValue\");\n\n    // moves 'largeValue' above 'value'\n    await driver.withActions(actions => actions.dragAndDrop(findYAxis(\"largeValue\"), findYAxis(\"value\")));\n    await gu.waitForServer();\n\n    // check axis correctness\n    await checkAxisConfig({\n      xaxis: \"label\",\n      yaxis: [\"largeValue\", \"value\"],\n    });\n  });\n\n  it(\"should be able to render different types of charts\", async function() {\n    const chartDom = await driver.find(\".test-chart-container\");\n\n    await selectChartType(\"Pie chart\");\n    let data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"pie\");\n    assert.equal(await driver.find(\".test-chart-first-field-label\").getText(), \"LABEL\");\n    await selectChartType(\"Line chart\");\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"scatter\");\n    // Make sure we are not grouping (which would produce names like \"1 · value\")\n    assert.equal(data[0].name, \"largeValue\");\n    assert.equal(data[1].name, \"value\");\n    assert.equal(await driver.find(\".test-chart-first-field-label\").getText(), \"X-AXIS\");\n\n    await selectChartType(\"Area chart\");\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"scatter\");\n    assert.deepEqual(data[0].line!.shape, \"spline\");\n    assert.deepEqual(data[0].fill, \"tozeroy\");\n    assert.equal(await driver.find(\".test-chart-type\").getText(), \"Area chart\");\n\n    // Make sure first field of scatter plot is marked label, not x-axis.\n    await selectChartType(\"Scatter plot\");\n    assert.equal(await driver.find(\".test-chart-first-field-label\").getText(), \"LABEL\");\n\n    // Make sure first field of Kaplan-Meier plot is marked label, not x-axis.\n    await selectChartType(\"Kaplan-Meier plot\");\n    assert.equal(await driver.find(\".test-chart-first-field-label\").getText(), \"LABEL\");\n\n    // Return to Area chart.\n    await selectChartType(\"Area chart\");\n  });\n\n  it(\"should render pie charts with a single series, or counts\", async function() {\n    await selectChartType(\"Pie chart\");\n\n    // select 'person' for x axis\n    await selectXAxis(\"person\");\n\n    // adds 'label' and move to be first y axis\n    await addYAxis(\"label\");\n    await driver.withActions(actions => actions.dragAndDrop(findYAxis(\"label\"), findYAxis(\"largeValue\")));\n    await gu.waitForServer();\n\n    // check axis\n    await checkAxisConfig({\n      xaxis: \"person\",\n      yaxis: [\"label\", \"largeValue\", \"value\"],\n    });\n\n    const chartDom = await driver.find(\".test-chart-container\");\n    let data = (await getChartData(chartDom)).data;\n    // Only the first series of values is included.\n    assert.deepEqual(data[0].values, [61, 4, 2, 5, 3, 1]);\n    assert.lengthOf(data, 1);\n\n    // When no series is included, just counts are used.\n    await removeYAxis(\"largeValue\");\n    await removeYAxis(\"label\");\n    await removeYAxis(\"value\");\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].values, [1, 1, 1, 1, 1, 1]);\n    assert.lengthOf(data, 1);\n\n    await gu.undo(7);\n\n    // check axis\n    await checkAxisConfig({\n      xaxis: \"label\",\n      yaxis: [\"largeValue\", \"value\"],\n    });\n\n    // check chart type\n    assert.equal(await driver.find(\".test-chart-type\").getText(), \"Area chart\");\n  });\n\n  it(\"should support Y-axis options\", async function() {\n    const chartDom = await driver.find(\".test-chart-container\");\n    await selectChartType(\"Bar chart\");\n    checkAxisRange(await getChartData(chartDom), 0.5, 61.5, 0, 57);\n\n    await driver.findContent(\"label\", /Invert Y-axis/).find(\"input\").click();\n    await gu.waitForServer();\n    checkAxisRange(await getChartData(chartDom), 0.5, 61.5, 57, 0);\n\n    await driver.findContent(\"label\", /Invert Y-axis/).find(\"input\").click();\n    await driver.findContent(\"label\", /Log scale Y-axis/).find(\"input\").click();\n    await gu.waitForServer();\n    checkAxisRange(await getChartData(chartDom), 0.5, 61.5, 0.22, 1.82);\n\n    await gu.undo(4);\n    // check axis\n    await checkAxisConfig({\n      xaxis: \"label\",\n      yaxis: [\"largeValue\", \"value\"],\n    });\n    // check chart type\n    assert.equal(await driver.find(\".test-chart-type\").getText(), \"Area chart\");\n  });\n\n  it(\"should be able to render multiseries line charts\", async function() {\n    const chartDom = await driver.find(\".test-chart-container\");\n\n    // switch type to line chart\n    await selectChartType(\"Line chart\");\n\n    // pick 'largeValue' as the x axis\n    await selectXAxis(\"largeValue\");\n\n    // set 'label' as the groupby column\n    await setSplitSeries(\"label\");\n\n    let { data, layout } = await getChartData(chartDom);\n    assert.deepEqual(data[0].type, \"scatter\");\n    assert.deepEqual(data.map(d => d.name), [\"1\", \"2\", \"3\", \"4\", \"5\", \"61\"]);\n    assert.equal(getAxisTitle(layout.xaxis), \"largeValue\");\n    assert.equal(getAxisTitle(layout.yaxis), \"value\");\n\n    // Select person for grouping by column\n    await setSplitSeries(\"person\");\n\n    await checkAxisConfig({\n      groupingByColumn: \"person\",\n      xaxis: \"largeValue\",\n      yaxis: [\"value\"],\n    });\n\n    ({ data, layout } = await getChartData(chartDom));\n    assert.deepEqual(data[0].type, \"scatter\");\n    assert.deepEqual(data.map(d => d.name), [\"Alice\", \"Bob\"]);\n    assert.equal(getAxisTitle(layout.xaxis), \"largeValue\");\n    assert.equal(getAxisTitle(layout.yaxis), \"value\");\n\n    // Add a second series. If we have more than one, its name should be included into the series\n    // names rather than in the yaxis.title.\n    await addYAxis(\"label\");\n\n    await checkAxisConfig({\n      groupingByColumn: \"person\",\n      xaxis: \"largeValue\",\n      yaxis: [\"value\", \"label\"],\n    });\n\n    ({ data, layout } = await getChartData(chartDom));\n    assert.deepEqual(data[0].type, \"scatter\");\n    assert.deepEqual(data.map(d => d.name), [\"Alice • value\", \"Alice • label\", \"Bob • value\", \"Bob • label\"]);\n    assert.equal(getAxisTitle(layout.xaxis), \"largeValue\");\n    assert.equal(getAxisTitle(layout.yaxis), undefined);\n\n    await gu.undo(5);\n    await checkAxisConfig({\n      groupingByColumn: false,\n      xaxis: \"label\",\n      yaxis: [\"largeValue\", \"value\"],\n    });\n    // check chart type\n    assert.equal(await driver.find(\".test-chart-type\").getText(), \"Area chart\");\n  });\n\n  it(\"should get options for SPLIT SERIES and X AXIS in sync when table changes\", async function() {\n    // click change widget\n    await driver.findContent(\"button\", \"Change widget\").click();\n\n    // click sum symbol\n    await driver.findContent(\".test-wselect-table\", \"People\").click();\n\n    // click save\n    await driver.find(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n\n    // click Split series\n    await driver.findContent(\"label\", \"Split series\").click();\n\n    // open split series options\n    await driver.find(\".test-chart-group-by-column\").click();\n\n    // check group-data options\n    assert.deepEqual(\n      await gu.findOpenMenuAllItems(\"li\", e => e.getText()),\n      [\"Pick a column\", \"Name\", \"B\"],\n    );\n\n    // send ESCAPE to close menu\n    await driver.sendKeys(Key.ESCAPE);\n\n    // open x axis options\n    await driver.find(\".test-chart-x-axis\").click();\n\n    // check x axis options\n    assert.deepEqual(\n      await gu.findOpenMenuAllItems(\"li\", e => e.getText()),\n      [\"Name\", \"B\"],\n    );\n\n    // send ESCAPE to close menu\n    await driver.sendKeys(Key.ESCAPE);\n\n    // undo\n    await gu.undo(1);\n  });\n\n  it(\"should get series name right when grouped column has '' values\", async function() {\n    // remove series 'value'\n    await removeYAxis(\"value\");\n\n    // add a row with person left as blank\n    const { retValues } = await api.applyUserActions(doc.id, [\n      [\"AddRecord\", \"ChartData\", 7, { largeValue: 44 }],\n    ]);\n    await setSplitSeries(\"person\");\n\n    // check that series name is correct\n    const data = (await getChartData()).data;\n    assert.deepEqual(data.map(d => d.name), [\"[Blank]\", \"Alice\", \"Bob\"]);\n\n    // remove row\n    await api.applyUserActions(doc.id, [\n      [\"RemoveRecord\", \"ChartData\", retValues[0]],\n    ]);\n\n    // undo\n    await gu.undo(2);\n  });\n\n  it(\"should disabled split series option for pie charts\", async function() {\n    // start with line chart type\n    await selectChartType(\"Line chart\");\n\n    // check the split series option is present\n    assert.equal(await driver.findContent(\"label\", /Split series/).isPresent(), true);\n    assert.equal(await driver.find(\".test-chart-group-by-column\").isPresent(), true);\n\n    // select 'person' as the split series column\n    await setSplitSeries(\"person\");\n\n    // check split series option\n    assert.equal(await driver.findContent(\"label\", /Split series/).isPresent(), true);\n    assert.equal(await driver.find(\".test-chart-group-by-column\").isPresent(), true);\n\n    // check axis\n    await checkAxisConfig({\n      groupingByColumn: \"person\",\n      xaxis: \"label\",\n      yaxis: [\"largeValue\", \"value\"],\n    });\n\n    // select pie chart type\n    await selectChartType(\"Pie chart\");\n\n    // check that the split series option is not present\n    assert.equal(await driver.findContent(\"label\", /Split series/).isPresent(), false);\n    assert.equal(await driver.find(\".test-chart-group-by-column\").isPresent(), false);\n\n    // check axis\n    await checkAxisConfig({\n      groupingByColumn: false,\n      xaxis: \"label\",\n      yaxis: [\"largeValue\", \"value\"],\n    });\n    assert.equal(await driver.find(\".test-chart-type\").getText(), \"Pie chart\");\n\n    // undo\n    await gu.undo(2);\n    await checkAxisConfig({\n      groupingByColumn: false,\n      xaxis: \"label\",\n      yaxis: [\"largeValue\", \"value\"],\n    });\n    assert.equal(await driver.find(\".test-chart-type\").getText(), \"Line chart\");\n  });\n\n  it(\"should render dates properly on X-axis\", async function() {\n    await gu.getSection(\"ChartData\").find(\".viewsection_title\").click();\n\n    // Add a new first column.\n    await gu.getCell({ col: 0, rowNum: 1 }).click();\n    // driver.sendKeys() doesn't support key combinations, but elem.sendKeys() does.\n    await driver.find(\"body\").sendKeys(Key.chord(Key.ALT, Key.SHIFT, \"=\"));\n    await gu.waitForServer();\n    await driver.find(\".test-column-title-label\").sendKeys(\"MyDate\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Convert it to Date\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    await gu.setType(/Date/);\n    await gu.waitForServer();\n\n    // Enter some values.\n    await gu.enterGridRows({ col: 0, rowNum: 1 }, [\n      [\"2018-01-15\"], [\"2018-01-31\"], [\"2018-02-14\"], [\"2018-03-04\"], [\"2018-03-14\"], [\"2018-03-26\"],\n    ]);\n\n    // Open the view config pane for the Chart section.\n    await gu.getSection(\"ChartData chart\").find(\".viewsection_title\").click();\n    await driver.find(\".test-right-tab-pagewidget\").click();\n\n    // select MyDate for x axis\n    await selectXAxis(\"MyDate\");\n\n    const chartDom = await driver.find(\".test-chart-container\");\n    const { data, layout } = await getChartData(chartDom);\n    // This check helps understand Plotly's actual interpretation of the dates. E.g. if the range\n    // endpoints are like '2018-03-25 20:00', plotly is misinterpreting the timezone.\n    assert.deepEqual(layout.xaxis.range, [\"2018-01-15\", \"2018-03-26\"]);\n    assert.deepEqual(data[0].type, \"scatter\");\n    assert.deepEqual(data[0].name, \"largeValue\");\n    assert.deepEqual(data[0].x, [\n      \"2018-01-15T00:00:00.000Z\", \"2018-01-31T00:00:00.000Z\", \"2018-02-14T00:00:00.000Z\",\n      \"2018-03-04T00:00:00.000Z\", \"2018-03-14T00:00:00.000Z\", \"2018-03-26T00:00:00.000Z\",\n    ]);\n    assert.deepEqual(data[0].y, [22, 33, 11, 44, 22, 55]);\n    assert.deepEqual(data[1].type, \"scatter\");\n    assert.deepEqual(data[1].name, \"value\");\n    assert.deepEqual(data[0].x, [\n      \"2018-01-15T00:00:00.000Z\", \"2018-01-31T00:00:00.000Z\", \"2018-02-14T00:00:00.000Z\",\n      \"2018-03-04T00:00:00.000Z\", \"2018-03-14T00:00:00.000Z\", \"2018-03-26T00:00:00.000Z\",\n    ]);\n    assert.deepEqual(data[1].y, [16, 2, 3, 4, 5, 6]);\n  });\n\n  it(\"should support error bars\", async function() {\n    // We start with a line chart with MyDate on X-axis, and two series: largeValue and value.\n    await selectChartType(\"Line chart\");\n    await checkAxisConfig({ xaxis: \"MyDate\", yaxis: [\"largeValue\", \"value\"] });\n\n    // Symmetric error bars should leave only the largeValue series, with 'value' for error bars.\n    await driver.find(\".test-chart-error-bars .test-select-open\").click();\n    await gu.findOpenMenuItem(\"li\", /Symmetric/).click();\n    await gu.waitForServer();\n\n    const chartDom = await driver.find(\".test-chart-container\");\n    let data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"scatter\");\n    assert.deepEqual(data[0].name, \"largeValue\");\n    assert.deepEqual(data[0].y, [22, 33, 11, 44, 22, 55]);\n    assert.deepEqual((data[0].error_y as any).array, [16, 2, 3, 4, 5, 6]);\n    assert.deepEqual(data[0].error_y!.symmetric, true);\n    assert.lengthOf(data, 1);\n\n    // Using separate error bars for above+below will leave just the \"above\" error bars.\n    await driver.find(\".test-chart-error-bars .test-select-open\").click();\n    await gu.findOpenMenuItem(\"li\", /Above.*Below/).click();\n    await gu.waitForServer();\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].y, [22, 33, 11, 44, 22, 55]);\n    assert.deepEqual((data[0].error_y as any).array, [16, 2, 3, 4, 5, 6]);\n    assert.deepEqual((data[0].error_y as any).arrayminus, null);\n    assert.deepEqual(data[0].error_y!.symmetric, false);\n    assert.lengthOf(data, 1);\n\n    // If we add another line, it'll be used for \"below\" error bars.\n    await addYAxis(\"label\");\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].y, [22, 33, 11, 44, 22, 55]);\n    assert.deepEqual((data[0].error_y as any).array, [16, 2, 3, 4, 5, 6]);\n    assert.deepEqual((data[0].error_y as any).arrayminus, [61, 5, 4, 3, 2, 1]);\n    assert.deepEqual(data[0].error_y!.symmetric, false);\n    assert.lengthOf(data, 1);\n\n    // Should work also for bar charts\n    await selectChartType(\"Bar chart\");\n    data = (await getChartData(chartDom)).data;\n    assert.deepEqual(data[0].type, \"bar\");\n    assert.deepEqual(data[0].y, [22, 33, 11, 44, 22, 55]);\n    assert.deepEqual((data[0].error_y as any).array, [16, 2, 3, 4, 5, 6]);\n    assert.deepEqual((data[0].error_y as any).arrayminus, [61, 5, 4, 3, 2, 1]);\n    assert.deepEqual(data[0].error_y!.symmetric, false);\n    assert.lengthOf(data, 1);\n    await gu.undo(1);\n\n    await gu.undo(3);\n  });\n\n  it(\"should fetch data for tables not yet loaded\", async function() {\n    // Create a Page that only has a Chart, no other sections.\n    await gu.addNewPage(/Chart/, /ChartData/);\n\n    let chartDom = await driver.findWait(\".test-chart-container\", 1000);\n    assert.equal(await chartDom.isDisplayed(), true);\n    let data = (await getChartData(chartDom)).data;\n    assert.lengthOf(data, 1);\n    assert.deepEqual(data[0].type, \"bar\");\n    assert.deepEqual(data[0].y, [61, 5, 4, 3, 2, 1]);\n\n    // Reload the page and test that the chart loaded.\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    await driver.sleep(1000);\n    chartDom = await driver.findWait(\".test-chart-container\", 1000);\n    assert.equal(await chartDom.isDisplayed(), true);\n    data = (await getChartData(chartDom)).data;\n    assert.lengthOf(data, 1);\n    assert.deepEqual(data[0].type, \"bar\");\n    assert.deepEqual(data[0].y, [61, 5, 4, 3, 2, 1]);\n  });\n\n  it(\"should resize chart when side panels open or close\", async function() {\n    // Open a document with some chart data.\n    const session = await gu.session().teamSite.login();\n    doc = await session.tempDoc(cleanup, \"ChartData.grist\");\n    await gu.toggleSidePanel(\"right\", \"close\");\n\n    // Add a chart section.\n    await gu.addNewSection(/Chart/, /ChartData/);\n    const chart = await driver.findWait(\".viewsection_content .svg-container\", 1000);\n    const initialRect = await chart.getRect();\n    // We expect the left panel open initially.\n    assert.equal(await gu.isSidePanelOpen(\"left\"), true);\n\n    // Open the RightPanel, check that chart's width was reduced.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.wait(async () => (await chart.getRect()).width < initialRect.width, 1000);\n\n    // Close the panel and check the chart went back to initial size.\n    await gu.toggleSidePanel(\"right\", \"close\");\n    await driver.wait(async () => (await chart.getRect()).width === initialRect.width, 1000);\n    assert.deepEqual(await chart.getRect(), initialRect);\n\n    // Close the left panel, and check that chart width was increased.\n    await gu.toggleSidePanel(\"left\", \"close\");\n    await driver.wait(async () => (await chart.getRect()).width > initialRect.width, 1000);\n\n    // Reopen the left panel and check the chart went back to initial size.\n    await gu.toggleSidePanel(\"left\", \"open\");\n    await driver.wait(async () => (await chart.getRect()).width === initialRect.width, 1000);\n    assert.deepEqual(await chart.getRect(), initialRect);\n  });\n\n  // Tests a bug where js errors would be thrown when fewer than 2 series were visible\n  // and any chart settings were changed.\n  it(\"should not throw errors when no y-axis are set\", async function() {\n    // Open the RightPanel and hide both series.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await removeYAxis(\"value\");\n\n    // Invert the y-axis. (This is meant to trigger js errors if the bug is present)\n    await driver.findContent(\"label\", /Invert Y-axis/).find(\"input\").click();\n    await gu.waitForServer();\n\n    // Group by the first column. (This is meant to trigger js errors if the bug is present)\n    await setSplitSeries(\"value\");\n\n    // Disable groupby column\n    await setSplitSeries(false);\n\n    // Revert changes.\n    await gu.undo(3);\n  });\n\n  // Tests a bug where hitting enter would try to edit a non-existent cell for summary charts.\n  it(\"should not throw errors when pressing enter on summary charts\", async function() {\n    // Click the section and press 'Enter'.\n    await gu.getSection(\"ChartData chart\").click();\n    await driver.sendKeys(Key.ENTER);\n    await gu.checkForErrors();\n  });\n\n  it(\"should not throw errors when switching to a chart page\", async function() {\n    await gu.getPageItem(\"People\").click();\n    await gu.waitForServer();\n    await gu.getPageItem(\"ChartData\").click();\n    await gu.waitForServer();\n    const chartDom = await gu.getSection(\"ChartData chart\").find(\".test-chart-container\");\n    assert.equal(await chartDom.isDisplayed(), true);\n    await gu.checkForErrors();\n  });\n\n  it(\"should not throw errors when summarizing or un-summarizing underlying table\", async function() {\n    // activate the chart widget\n    await gu.getSection(\"ChartData chart\").click();\n\n    // open widget option\n    await gu.openSectionMenu(\"viewLayout\");\n    await gu.findOpenMenuItem(\"li\", \"Widget options\").click();\n\n    // open the page widget picker\n    await driver.findContent(\".test-right-panel button\", \"Change widget\").click();\n\n    // click the summarize button\n    await driver.findContent(\".test-wselect-table\", \"ChartData\").find(\".test-wselect-pivot\").click();\n\n    // click save\n    await driver.find(\".test-wselect-addBtn\").click();\n\n    // wait for server\n    await gu.waitForServer();\n\n    // wait for chart to be changed\n    await gu.waitToPass(async () => {\n      assert.equal(\n        await gu.getActiveSectionTitle(),\n        \"CHARTDATA [Totals] Chart\",\n      );\n    });\n\n    // check for error\n    await gu.checkForErrors();\n\n    // undo 1\n    await gu.undo(1);\n  });\n\n  it(\"should sort x-axis values\", async function() {\n    // Import a small table of numbers to test this.\n    await gu.importFileDialog(\"uploads/ChartData-Sort_Test.csv\");\n\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n\n    // Add a chart of this data, and configure it first to just show X and Y1, Y2 series.\n    await gu.addNewSection(/Chart/, /ChartData-Sort_Test/);\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await selectChartType(\"Line chart\");\n\n    // Show series X, Y1, Y2, grouped by Group.\n    await selectXAxis(\"X\");\n    await setSplitSeries(\"Group\");\n    await addYAxis(\"Y1\");\n    await addYAxis(\"Y2\");\n\n    const chartDom = await driver.findWait(\".test-chart-container\", 1000);\n    let { data } = await getChartData(chartDom);\n    assert.lengthOf(data, 4);\n    assert.deepInclude(data[0], { type: \"scatter\", name: \"Bar • Y1\" });\n    assert.deepInclude(data[1], { type: \"scatter\", name: \"Bar • Y2\" });\n    assert.deepInclude(data[2], { type: \"scatter\", name: \"Foo • Y1\" });\n    assert.deepInclude(data[3], { type: \"scatter\", name: \"Foo • Y2\" });\n    assert.deepEqual(data[0].x, [1.5, 2.5, 3.5, 4.5, 5.5]);\n    assert.deepEqual(data[0].y, [1.5, 1, 3.5, 2.5, 4]);\n    assert.deepEqual(data[1].x, [1.5, 2.5, 3.5, 4.5, 5.5]);\n    assert.deepEqual(data[1].y, [6.9, 6, 4.9, 5, 7]);\n    assert.deepEqual(data[2].x, [1, 2, 3, 4, 5]);\n    assert.deepEqual(data[2].y, [1.5, 1, 3.5, 2.5, 4]);\n    assert.deepEqual(data[3].x, [1, 2, 3, 4, 5]);\n    assert.deepEqual(data[3].y, [6.9, 6, 4.9, 5, 7]);\n\n    // Now show series ungrouped.\n    await setSplitSeries(false);\n\n    ({ data } = await getChartData(chartDom));\n    assert.lengthOf(data, 2);\n    assert.deepInclude(data[0], { type: \"scatter\", name: \"Y1\" });\n    assert.deepInclude(data[1], { type: \"scatter\", name: \"Y2\" });\n    assert.deepEqual(data[0].x, [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5]);\n    assert.deepEqual(data[0].y, [1.5, 1.5, 1, 1, 3.5, 3.5, 2.5, 2.5, 4, 4]);\n    assert.deepEqual(data[1].x, [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5]);\n    assert.deepEqual(data[1].y, [6.9, 6.9, 6, 6, 4.9, 4.9, 5, 5, 7, 7]);\n  });\n\n  it(\"should not throw when picking the grouping by column for the x-axis\", async function() {\n    await checkAxisConfig({ xaxis: \"X\", yaxis: [\"Y1\", \"Y2\"] });\n    await setSplitSeries(\"Group\");\n    await checkAxisConfig({ xaxis: \"X\", yaxis: [\"Y1\", \"Y2\"], groupingByColumn: \"Group\" });\n    await selectXAxis(\"Group\");\n    await checkAxisConfig({ xaxis: \"Group\", yaxis: [\"Y1\", \"Y2\"] });\n    await gu.checkForErrors();\n    await gu.undo(2);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Choice.ts",
    "content": "import { DocAPI, UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, Key } from \"mocha-webdriver\";\n\ndescribe(\"Choice\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  let docId: string;\n  let api: UserAPI;\n  let docApi: DocAPI;\n\n  before(async () => {\n    const session = await gu.session().teamSite.login();\n    docId = await session.tempNewDoc(cleanup, \"Choice\");\n    api = session.createHomeApi();\n    docApi = api.getDocAPI(docId);\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should set cell value to empty string when editor text is blank\", async () => {\n    // Add a few records to Table1.\n    await api.applyUserActions(docId, [\n      [\"BulkAddRecord\", \"Table1\", [null, null, null], {}],\n    ]);\n\n    // Change column A's type to Choice and check its values default to empty string.\n    await api.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Table1\", \"A\", {\n        type: \"Choice\",\n      }],\n    ]);\n    assert.deepEqual(await docApi.getRecords(\"Table1\"), [\n      { id: 1, fields: { A: \"\", C: null, B: null } },\n      { id: 2, fields: { A: \"\", C: null, B: null } },\n      { id: 3, fields: { A: \"\", C: null, B: null } },\n    ]);\n\n    // Start editing a cell in column A and click away to close the editor.\n    await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n    await gu.sendKeys(Key.ENTER);\n    await gu.getCell({ rowNum: 1, col: \"C\" }).click();\n    await gu.waitForServer();\n\n    // Check that the values in column A are unchanged.\n    assert.deepEqual(await docApi.getRecords(\"Table1\"), [\n      { id: 1, fields: { A: \"\", C: null, B: null } },\n      { id: 2, fields: { A: \"\", C: null, B: null } },\n      { id: 3, fields: { A: \"\", C: null, B: null } },\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ChoiceList.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, stackWrapFunc, WebElement } from \"mocha-webdriver\";\n\nconst normalFont = { bold: false, underline: false, italic: false, strikethrough: false };\nconst bold = true;\nconst underline = true;\nconst italic = true;\nconst strikethrough = true;\n\nfunction getEditorTokens() {\n  return driver.findAll(\".cell_editor .test-tokenfield .test-tokenfield-token\", el => el.getText());\n}\n\nfunction getEditorTokenStyles() {\n  return driver.findAll(\n    \".cell_editor .test-tokenfield .test-tokenfield-token\",\n    async (el) => {\n      const classList = await el.getAttribute(\"class\");\n      return {\n        fillColor: await el.getCssValue(\"background-color\"),\n        textColor: await el.getCssValue(\"color\"),\n        boxShadow: await el.getCssValue(\"box-shadow\"),\n        bold: classList.includes(\"font-bold\"),\n        italic: classList.includes(\"font-italic\"),\n        underline: classList.includes(\"font-underline\"),\n        strikethrough: classList.includes(\"font-strikethrough\"),\n      };\n    },\n  );\n}\n\nfunction getCellTokens(cell: WebElement) {\n  return cell.getText();\n}\n\nfunction getCellTokenStyles(cell: WebElement) {\n  return cell.findAll(\n    \".test-choice-list-cell-token\",\n    async (el) => {\n      const classList = await el.getAttribute(\"class\");\n      return {\n        fillColor: await el.getCssValue(\"background-color\"),\n        textColor: await el.getCssValue(\"color\"),\n        boxShadow: await el.getCssValue(\"box-shadow\"),\n        bold: classList.includes(\"font-bold\"),\n        italic: classList.includes(\"font-italic\"),\n        underline: classList.includes(\"font-underline\"),\n        strikethrough: classList.includes(\"font-strikethrough\"),\n      };\n    },\n  );\n}\n\nfunction getChoiceLabels() {\n  return driver.findAll(\".test-right-panel .test-choice-list-entry-label\", el => el.getText());\n}\n\nfunction getChoiceColors() {\n  return driver.findAll(\n    \".test-right-panel .test-choice-list-entry-color\",\n    el => el.getCssValue(\"background-color\"),\n  );\n}\n\nfunction getEditModeChoiceLabels() {\n  return driver.findAll(\".test-right-panel .test-tokenfield-token input\", el => el.value());\n}\n\nfunction getEditModeFillColors() {\n  return driver.findAll(\n    \".test-right-panel .test-tokenfield-token .test-color-button\",\n    el => el.getCssValue(\"background-color\"),\n  );\n}\n\nfunction getEditModeTextColors() {\n  return driver.findAll(\n    \".test-right-panel .test-tokenfield-token .test-color-button\",\n    el => el.getCssValue(\"color\"),\n  );\n}\n\nfunction getEditModeFontOptions() {\n  return driver.findAll(\n    \".test-right-panel .test-tokenfield-token .test-color-button\",\n    async (el) => {\n      const classes = await el.getAttribute(\"class\");\n      const options: any = {};\n      if (classes.includes(\"font-bold\")) {\n        options.bold = true;\n      }\n      if (classes.includes(\"font-underline\")) {\n        options.underline = true;\n      }\n      if (classes.includes(\"font-italic\")) {\n        options.italic = true;\n      }\n      if (classes.includes(\"font-strikethrough\")) {\n        options.strikethrough = true;\n      }\n      return options;\n    },\n  );\n}\n\nfunction getEditorTokensIsInvalid() {\n  return driver.findAll(\".cell_editor .test-tokenfield .test-tokenfield-token\", el => el.matches(\"[class*=-invalid]\"));\n}\n\nfunction getEditorInput() {\n  return driver.find(\".cell_editor .test-tokenfield .test-tokenfield-input\");\n}\n\nasync function editChoiceEntries() {\n  await driver.find(\".test-choice-list-entry\").click();\n  await gu.waitAppFocus(false);\n}\n\nasync function renameEntry(from: string, to: string) {\n  await clickEntry(from);\n  await gu.sendKeys(to);\n  await gu.sendKeys(Key.ENTER);\n}\n\nasync function clickEntry(label: string) {\n  const entry = await driver.findWait(`.test-choice-list-entry .test-token-label[value='${label}']`, 100);\n  await entry.click();\n}\n\nasync function saveChoiceEntries() {\n  await driver.find(\".test-choice-list-entry-save\").click();\n  await gu.waitForServer();\n}\n\ndescribe(\"ChoiceList\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  const clipboard = gu.getLockableClipboard();\n\n  const WHITE_FILL = \"rgba(255, 255, 255, 1)\";\n  const UNSET_FILL = WHITE_FILL;\n  const INVALID_FILL = WHITE_FILL;\n  const DEFAULT_FILL = \"rgba(232, 232, 232, 1)\";\n  const GREEN_FILL = \"rgba(225, 254, 222, 1)\";\n  const DARK_GREEN_FILL = \"rgba(18, 110, 14, 1)\";\n  const BLUE_FILL = \"rgba(204, 254, 254, 1)\";\n  const BLACK_FILL = \"rgba(0, 0, 0, 1)\";\n  const BLACK_TEXT = \"rgba(0, 0, 0, 1)\";\n  const WHITE_TEXT = \"rgba(255, 255, 255, 1)\";\n  const APRICOT_FILL = \"rgba(254, 204, 129, 1)\";\n  const APRICOT_TEXT = \"rgba(70, 13, 129, 1)\";\n  const DEFAULT_TEXT = BLACK_TEXT;\n  const INVALID_TEXT = BLACK_TEXT;\n  const VALID_CHOICE = {\n    boxShadow: \"none\",\n    ...normalFont,\n  };\n  const INVALID_CHOICE = {\n    fillColor: INVALID_FILL,\n    textColor: INVALID_TEXT,\n    boxShadow: \"rgb(208, 2, 27) 0px 0px 0px 1px inset\",\n    ...normalFont,\n  };\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should support basic editing\", async () => {\n    const mainSession = await gu.session().teamSite.login();\n    const api = mainSession.createHomeApi();\n    const docId = await mainSession.tempNewDoc(cleanup, \"FormulaCounts\", { load: true });\n\n    // Make a ChoiceList column and add some data.\n    await api.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Table1\", \"B\", {\n        type: \"ChoiceList\",\n        widgetOptions: JSON.stringify({\n          choices: [\"Green\", \"Blue\", \"Black\"],\n          choiceOptions: {\n            Green: {\n              fillColor: \"#e1fede\",\n              textColor: \"#000000\",\n              fontBold: true,\n            },\n            Blue: {\n              fillColor: \"#ccfefe\",\n              textColor: \"#000000\",\n            },\n            Black: {\n              fillColor: \"#000000\",\n              textColor: \"#ffffff\",\n            },\n          },\n        }),\n      }],\n      [\"BulkAddRecord\", \"Table1\", [null, null, null], {}],\n    ]);\n\n    // Enter by typing into an empty cell: valid value, invalue value, then check the editor.\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await driver.sendKeys(\"Gre\", Key.ENTER);\n    await driver.sendKeys(\"fake\", Key.ENTER);\n    assert.deepEqual(await getEditorTokens(), [\"Green\", \"fake\"]);\n    assert.deepEqual(await getEditorTokensIsInvalid(), [false, true]);\n    assert.deepEqual(\n      await getEditorTokenStyles(),\n      [\n        { fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold },\n        INVALID_CHOICE,\n      ],\n    );\n\n    // Escape to cancel; check nothing got saved.\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), false);\n    assert.equal(await gu.getCell({ rowNum: 1, col: \"B\" }).getText(), \"\");\n\n    // Type invalid value, then select from dropdown valid\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await driver.sendKeys(\"fake\", Key.ENTER);\n    await getEditorInput().click();\n    const blueChoice = await driver.findContent(\".test-autocomplete li\", /Blue/);\n    assert.equal(\n      await blueChoice.find(\".test-choice-list-editor-item-label\").getCssValue(\"background-color\"),\n      BLUE_FILL,\n    );\n    assert.equal(\n      await blueChoice.find(\".test-choice-list-editor-item-label\").getCssValue(\"color\"),\n      BLACK_TEXT,\n    );\n    await blueChoice.click();\n\n    // Type another valid, check what's in editor\n    await driver.sendKeys(\"black\", Key.ENTER);\n    assert.deepEqual(await getEditorTokens(), [\"fake\", \"Blue\", \"Black\"]);\n    assert.deepEqual(await getEditorTokensIsInvalid(), [true, false, false]);\n    assert.deepEqual(\n      await getEditorTokenStyles(),\n      [\n        INVALID_CHOICE,\n        { fillColor: BLUE_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE },\n        { fillColor: BLACK_FILL, textColor: WHITE_TEXT, ...VALID_CHOICE },\n      ],\n    );\n\n    // Enter to save; check values got saved.\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), false);\n    assert.equal(await getCellTokens(await gu.getCell({ rowNum: 1, col: \"B\" })), \"fake\\nBlue\\nBlack\");\n    assert.deepEqual(\n      await getCellTokenStyles(await gu.getCell({ rowNum: 1, col: \"B\" })),\n      [\n        INVALID_CHOICE,\n        { fillColor: BLUE_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE },\n        { fillColor: BLACK_FILL, textColor: WHITE_TEXT, ...VALID_CHOICE },\n      ],\n    );\n\n    // Enter to edit. Enter token, remove two tokens, with a key and with an x-click.\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await driver.sendKeys(Key.ENTER);\n    assert.deepEqual(await getEditorTokens(), [\"fake\", \"Blue\", \"Black\"]);\n    await driver.sendKeys(\"Gre\", Key.TAB);\n    assert.deepEqual(await getEditorTokens(), [\"fake\", \"Blue\", \"Black\", \"Green\"]);\n    await driver.sendKeys(Key.LEFT, Key.LEFT, Key.BACK_SPACE);\n    assert.deepEqual(await getEditorTokens(), [\"fake\", \"Blue\", \"Green\"]);\n    const tok1 = driver.findContent(\".cell_editor .test-tokenfield .test-tokenfield-token\", /fake/);\n    await tok1.mouseMove();\n    await tok1.find(\".test-tokenfield-delete\").click();\n    assert.deepEqual(await getEditorTokens(), [\"Blue\", \"Green\"]);\n\n    // Enter to save; check values got saved.\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), false);\n    assert.equal(await getCellTokens(gu.getCell({ rowNum: 1, col: \"B\" })), \"Blue\\nGreen\");\n\n    // Start typing to replace content with a token; check values.\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await driver.sendKeys(\"foo\");\n    assert.deepEqual(await getEditorTokens(), []);\n    assert.equal(await getEditorInput().value(), \"foo\");\n    await driver.sendKeys(Key.TAB);\n    assert.deepEqual(await getEditorTokens(), [\"foo\"]);\n\n    // Escape to cancel; check nothing got saved.\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), false);\n    assert.equal(await gu.getCell({ rowNum: 1, col: \"B\" }).getText(), \"Blue\\nGreen\");\n\n    // Double-click to open dropdown and select a token.\n    await driver.withActions(a => a.doubleClick(gu.getCell({ rowNum: 1, col: \"B\" })));\n    await driver.findContent(\".test-autocomplete li\", /Black/).click();\n    assert.deepEqual(await getEditorTokens(), [\"Blue\", \"Green\", \"Black\"]);\n\n    // Click away to save: new token should be added.\n    await gu.getCell({ rowNum: 2, col: \"B\" }).click();\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), false);\n    assert.equal(await gu.getCell({ rowNum: 1, col: \"B\" }).getText(), \"Blue\\nGreen\\nBlack\");\n\n    // Starting to type names without accents should match the actual choices\n    await gu.addColumn(\"Accents\");\n    await api.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Table1\", \"Accents\", {\n        type: \"ChoiceList\",\n        widgetOptions: JSON.stringify({\n          choices: [\"Adélaïde\", \"Adèle\", \"Agnès\", \"Amélie\"],\n        }),\n      }],\n    ]);\n    await gu.getCell({ rowNum: 1, col: \"Accents\" }).click();\n    await driver.sendKeys(\"Ade\", Key.ENTER);\n    await driver.sendKeys(\"Agne\", Key.ENTER);\n    await driver.sendKeys(\"Ame\", Key.ENTER);\n    assert.deepEqual(await getEditorTokens(), [\"Adélaïde\", \"Agnès\", \"Amélie\"]);\n  });\n\n  it(\"should be visible in formulas\", async () => {\n    // Add a formula that returns tokens reversed\n    await gu.getCell({ rowNum: 1, col: \"C\" }).click();\n    await gu.enterFormula('\":\".join($B)');\n\n    // Check value\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"B\", \"C\"] }), [\n      \"Blue\\nGreen\\nBlack\", \"Blue:Green:Black\",\n      \"\", \"\",\n      \"\", \"\",\n    ]);\n\n    // Hit enter, click to delete a token, save.\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await driver.sendKeys(Key.ENTER);\n    await driver.sendKeys(Key.BACK_SPACE);\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n\n    // Check formula got updated\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1], cols: [\"B\", \"C\"] }), [\n      \"Blue\\nGreen\", \"Blue:Green\",\n    ]);\n\n    // Type a couple new tokens.\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await driver.sendKeys(Key.ENTER);\n    await driver.sendKeys(\"fake\", Key.TAB, \"Bla\", Key.TAB);\n\n    // Enter to save; check formula got updated\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1], cols: [\"B\", \"C\"] }), [\n      \"Blue\\nGreen\\nfake\\nBlack\", \"Blue:Green:fake:Black\",\n    ]);\n\n    // Hit delete. ChoiceList cell and formula should clear.\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await driver.sendKeys(Key.DELETE);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"B\", \"C\"] }), [\n      \"\", \"\",\n      \"\", \"\",\n      \"\", \"\",\n    ]);\n  });\n\n  it(\"should allow adding new values\", async () => {\n    // Check what choices are configured.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    assert.deepEqual(await getChoiceLabels(), [\"Green\", \"Blue\", \"Black\"]);\n    assert.deepEqual(\n      await getChoiceColors(),\n      [GREEN_FILL, BLUE_FILL, BLACK_FILL],\n    );\n\n    // Select a token from autocomplete\n    const cell = gu.getCell({ rowNum: 1, col: \"B\" });\n    assert.equal(await cell.getText(), \"\");\n    await cell.click();\n    await driver.sendKeys(Key.ENTER);\n    await driver.findContent(\".test-autocomplete li\", /Green/).click();\n    assert.deepEqual(await getEditorTokens(), [\"Green\"]);\n\n    // Type token that's not in autocomplete\n    await driver.sendKeys(\"Orange\");\n\n    // Enter should add invalid token.\n    await driver.sendKeys(Key.ENTER);\n    assert.deepEqual(await getEditorTokens(), [\"Green\", \"Orange\"]);\n    assert.deepEqual(await getEditorTokensIsInvalid(), [false, true]);\n\n    // Type another token, and click the \"+\" button in autocomplete. New token should be valid.\n    await driver.sendKeys(\"Apricot\");\n    const newChoice = await driver.find(\".test-autocomplete .test-choice-list-editor-new-item\");\n    assert.equal(await newChoice.getText(), \"Apricot\");\n    assert.equal(\n      await newChoice.find(\".test-choice-list-editor-item-label\").getCssValue(\"background-color\"),\n      DEFAULT_FILL,\n    );\n    assert.equal(\n      await newChoice.find(\".test-choice-list-editor-item-label\").getCssValue(\"color\"),\n      DEFAULT_TEXT,\n    );\n    await driver.find(\".test-autocomplete .test-choice-list-editor-new-item\").click();\n    assert.deepEqual(await getEditorTokens(), [\"Green\", \"Orange\", \"Apricot\"]);\n    assert.deepEqual(await getEditorTokensIsInvalid(), [false, true, false]);\n    assert.deepEqual(\n      await getEditorTokenStyles(),\n      [\n        { fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold },\n        INVALID_CHOICE,\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n      ],\n    );\n\n    // Save: check tokens\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    assert.equal(await getCellTokens(gu.getCell({ rowNum: 1, col: \"B\" })), \"Green\\nOrange\\nApricot\");\n    assert.deepEqual(\n      await getCellTokenStyles(gu.getCell({ rowNum: 1, col: \"B\" })),\n      [\n        { fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold },\n        INVALID_CHOICE,\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n      ],\n    );\n\n    // New option should be listed in config.\n    assert.deepEqual(await getChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\"]);\n    assert.deepEqual(\n      await getChoiceColors(),\n      [GREEN_FILL, BLUE_FILL, BLACK_FILL, UNSET_FILL],\n    );\n  });\n\n  const convertColumn = stackWrapFunc(async function(typeRe: RegExp) {\n    await gu.setType(typeRe);\n    await gu.waitForServer();\n    await driver.findContent(\".test-type-transform-apply\", /Apply/).click();\n    await gu.waitForServer();\n  });\n\n  it(\"should allow reasonable conversions between ChoiceList and other types\", async function() {\n    await gu.enterGridRows({ rowNum: 1, col: \"A\" },\n      [[\"Hello\"], [\"World\"], ['Foo,Bar;Baz!,\"Qux, quux corge\", \"80\\'s\",']]);\n    await testTextChoiceListConversions();\n  });\n\n  it(\"should allow ChoiceList conversions for column used in summary\", async function() {\n    // Add a widget with a summary on column A.\n    await gu.addNewSection(/Table/, /Table1/, { dismissTips: true, summarize: [/^A$/] });\n    await testTextChoiceListConversions();\n    await gu.undo();\n  });\n\n  async function testTextChoiceListConversions() {\n    await gu.getCell({ section: \"TABLE1\", rowNum: 3, col: \"A\" }).click();\n\n    // Convert this text column to ChoiceList.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    await convertColumn(/Choice List/);\n\n    // Check that choices got populated.\n    await driver.find(\".test-right-tab-field\").click();\n    assert.deepEqual(await getChoiceLabels(), [\"Hello\", \"World\", \"Foo\", \"Bar;Baz!\", \"Qux, quux corge\", \"80's\"]);\n    assert.deepEqual(\n      await getChoiceColors(),\n      [UNSET_FILL, UNSET_FILL, UNSET_FILL, UNSET_FILL, UNSET_FILL, UNSET_FILL],\n    );\n\n    // Check that the result contains the right tags.\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"A\"] }), [\n      \"Hello\",\n      \"World\",\n      \"Foo\\nBar;Baz!\\nQux, quux corge\\n80's\",\n    ]);\n    await gu.checkForErrors();\n\n    // Check that the result contains the right colors.\n    for (const rowNum of [1, 2]) {\n      assert.deepEqual(\n        await getCellTokenStyles(await gu.getCell({ rowNum, col: \"A\" })),\n        [{ fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE }],\n      );\n    }\n    assert.deepEqual(\n      await getCellTokenStyles(await gu.getCell({ rowNum: 3, col: \"A\" })),\n      [\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n      ],\n    );\n\n    // Open a cell to see the actual tags.\n    await gu.getCell({ rowNum: 3, col: \"A\" }).click();\n    await driver.sendKeys(Key.ENTER);\n    assert.deepEqual(await getEditorTokens(), [\"Foo\", \"Bar;Baz!\", \"Qux, quux corge\", \"80's\"]);\n    assert.deepEqual(await getEditorTokensIsInvalid(), [false, false, false, false]);\n    assert.deepEqual(\n      await getEditorTokenStyles(),\n      [\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n      ],\n    );\n    await driver.sendKeys(\"hooray\", Key.TAB, Key.ENTER);\n    await gu.waitForServer();\n    await gu.checkForErrors();\n\n    // Convert back to text.\n    await convertColumn(/Text/);\n\n    // Check that values turn into comma-separated values.\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"A\"] }), [\n      \"Hello\",\n      \"World\",\n      'Foo, Bar;Baz!, \"Qux, quux corge\", 80\\'s, hooray',\n    ]);\n\n    // Undo the cell change and both conversions (back to ChoiceList, back to Text), and check\n    // that UNDO also works correctly and without errors.\n    await gu.undo(3);\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"A\"] }), [\n      \"Hello\",\n      \"World\",\n      'Foo,Bar;Baz!,\"Qux, quux corge\", \"80\\'s\",',   // That's the text originally entered into this Text cell.\n    ]);\n  }\n\n  it(\"should keep choices when converting between Choice and ChoiceList\", async function() {\n    // Column B starts off as ChoiceList with the following choices.\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await driver.find(\".test-right-tab-field\").click();\n    assert.deepEqual(await getChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\"]);\n\n    // Add some more values to this columm.\n    await gu.getCell({ rowNum: 2, col: \"B\" }).click();\n    await driver.sendKeys(\"Black\", Key.ENTER, Key.ENTER);\n    await gu.waitForServer();\n    await driver.sendKeys(\"Green\", Key.ENTER, Key.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"B\"] }), [\n      \"Green\\nOrange\\nApricot\",\n      \"Black\",\n      \"Green\",\n    ]);\n\n    // Convert to Choice. Configured Choices should stay the same.\n    await convertColumn(/^Choice$/);\n    assert.deepEqual(await getChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\"]);\n    assert.deepEqual(await getChoiceColors(), [GREEN_FILL, BLUE_FILL, BLACK_FILL, UNSET_FILL]);\n\n    // Cells which contain multiple choices become CSVs.\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"B\"] }), [\n      \"Green, Orange, Apricot\",\n      \"Black\",\n      \"Green\",\n    ]);\n    const cell1 = gu.getCell(\"B\", 1);\n    assert.equal(await cell1.find(\".field_clip\").matches(\".invalid\"), false);\n    assert.equal(await cell1.find(\".test-choice-token\").getCssValue(\"background-color\"), INVALID_FILL);\n    assert.equal(await cell1.find(\".test-choice-token\").getCssValue(\"color\"), INVALID_TEXT);\n    const cell2 = gu.getCell(\"B\", 2);\n    assert.equal(await cell2.find(\".field_clip\").matches(\".invalid\"), false);\n    assert.equal(await cell2.find(\".test-choice-token\").getCssValue(\"background-color\"), BLACK_FILL);\n    assert.equal(await cell2.find(\".test-choice-token\").getCssValue(\"color\"), WHITE_TEXT);\n\n    // Convert back to ChoiceList. Choices should stay the same.\n    await convertColumn(/Choice List/);\n    assert.deepEqual(await getChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\"]);\n    assert.deepEqual(await getChoiceColors(), [GREEN_FILL, BLUE_FILL, BLACK_FILL, UNSET_FILL]);\n\n    // Cell and editor data should be restored too.\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"B\"] }), [\n      \"Green\\nOrange\\nApricot\",\n      \"Black\",\n      \"Green\",\n    ]);\n    assert.deepEqual(\n      await getCellTokenStyles(await gu.getCell({ rowNum: 1, col: \"B\" })),\n      [\n        { fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold },\n        INVALID_CHOICE,\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n      ],\n    );\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await driver.sendKeys(Key.ENTER);\n    assert.deepEqual(await getEditorTokens(), [\"Green\", \"Orange\", \"Apricot\"]);\n    assert.deepEqual(await getEditorTokensIsInvalid(), [false, true, false]);\n    assert.deepEqual(\n      await getEditorTokenStyles(),\n      [\n        { fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE,  bold },\n        INVALID_CHOICE,\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n      ],\n    );\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should allow setting choice style\", async function() {\n    // Open the choice editor.\n    await driver.find(\".test-choice-list-entry\").click();\n    await gu.waitAppFocus(false);\n\n    // Change 'Apricot' to a light shade of orange with purple text.\n    const [greenColorBtn, , , apricotColorBtn] = await driver\n      .findAll(\".test-tokenfield .test-color-button\");\n    await apricotColorBtn.click();\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(254, 204, 129)\");\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(70, 13, 129)\");\n    await gu.setFont(\"bold\", true);\n    await gu.setFont(\"italic\", true);\n    await driver.sendKeys(Key.ENTER);\n\n    // Change 'Green' to a darker shade with white text.\n    await greenColorBtn.click();\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(18, 110, 14)\");\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(255, 255, 255)\");\n    await gu.setFont(\"strikethrough\", true);\n    await gu.setFont(\"underline\", true);\n    await driver.sendKeys(Key.ENTER);\n\n    // Check that the old colors are still being used in the grid\n    assert.deepEqual(\n      await getCellTokenStyles(await gu.getCell({ rowNum: 1, col: \"B\" })),\n      [\n        { fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold },\n        INVALID_CHOICE,\n        { fillColor: DEFAULT_FILL, textColor: DEFAULT_TEXT, ...VALID_CHOICE },\n      ],\n    );\n    assert.deepEqual(\n      await getCellTokenStyles(await gu.getCell({ rowNum: 3, col: \"B\" })),\n      [\n        { fillColor: GREEN_FILL, textColor: BLACK_TEXT, ...VALID_CHOICE, bold },\n      ],\n    );\n\n    // Click save, and check that the new colors are now used in the grid\n    await driver.find(\".test-choice-list-entry-save\").click();\n    await gu.waitForServer();\n    assert.deepEqual(\n      await getCellTokenStyles(await gu.getCell({ rowNum: 1, col: \"B\" })),\n      [\n        { fillColor: DARK_GREEN_FILL, textColor: WHITE_TEXT, ...VALID_CHOICE, strikethrough, underline, bold },\n        INVALID_CHOICE,\n        { fillColor: APRICOT_FILL, textColor: APRICOT_TEXT, ...VALID_CHOICE, bold, italic },\n      ],\n    );\n    assert.deepEqual(\n      await getCellTokenStyles(await gu.getCell({ rowNum: 3, col: \"B\" })),\n      [\n        { fillColor: DARK_GREEN_FILL, textColor: WHITE_TEXT, ...VALID_CHOICE,\n          strikethrough, underline, bold },\n      ],\n    );\n  });\n\n  it(\"should discard changes on cancel\", async function() {\n    for (const method of [\"button\", \"shortcut\"]) {\n      // Open the editor.\n      await driver.find(\".test-choice-list-entry\").click();\n      await gu.waitAppFocus(false);\n\n      // Delete 'Apricot', then cancel the change.\n      await gu.sendKeys(Key.BACK_SPACE);\n      assert.deepEqual(await getEditModeChoiceLabels(), [\"Green\", \"Blue\", \"Black\"]);\n      if (method === \"button\") {\n        await driver.find(\".test-choice-list-entry-cancel\").click();\n      } else {\n        await gu.sendKeys(Key.ESCAPE);\n      }\n\n      // Check that 'Apricot' is still there and the change wasn't saved.\n      assert.deepEqual(await getChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\"]);\n    }\n  });\n\n  it(\"should support undo/redo shortcuts in the choice config editor\", async function() {\n    // Open the choice editor.\n    await driver.find(\".test-choice-list-entry\").click();\n    await gu.waitAppFocus(false);\n\n    // Add a few choices.\n    await driver.sendKeys(\"Foo\", Key.ENTER, \"Bar\", Key.ENTER, \"Baz\", Key.ENTER);\n    assert.deepEqual(await getEditModeChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\", \"Foo\", \"Bar\", \"Baz\"]);\n\n    // Undo, verifying the contents of the choice config editor are correct after each invocation.\n    const modKey = await gu.modKey();\n    await gu.sendKeys(Key.chord(modKey, \"z\"));\n    assert.deepEqual(await getEditModeChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\", \"Foo\", \"Bar\"]);\n    await gu.sendKeys(Key.chord(modKey, \"z\"));\n    assert.deepEqual(await getEditModeChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\", \"Foo\"]);\n    await gu.sendKeys(Key.chord(modKey, \"z\"));\n    assert.deepEqual(await getEditModeChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\"]);\n\n    // Redo, then undo, verifying at each step.\n    await gu.sendKeys(Key.chord(Key.CONTROL, \"y\"));\n    assert.deepEqual(await getEditModeChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\", \"Foo\"]);\n    await gu.sendKeys(Key.chord(modKey, \"z\"));\n    assert.deepEqual(await getEditModeChoiceLabels(), [\"Green\", \"Blue\", \"Black\", \"Apricot\"]);\n\n    // Change the color of 'Apricot' to white with black text, and modify font options\n    const [, , , apricotColorBtn] = await driver\n      .findAll(\".test-tokenfield .test-color-button\");\n    await apricotColorBtn.click();\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(255, 255, 255)\");\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(0, 0, 0)\");\n    await gu.setFont(\"bold\", false);\n    await gu.setFont(\"italic\", false);\n    await gu.setFont(\"underline\", true);\n\n    await driver.sendKeys(Key.ENTER);\n    assert.deepEqual(await getEditModeFillColors(), [DARK_GREEN_FILL, BLUE_FILL,  BLACK_FILL, WHITE_FILL]);\n    assert.deepEqual(await getEditModeTextColors(), [WHITE_TEXT,      BLACK_TEXT, WHITE_TEXT, BLACK_TEXT]);\n    assert.deepEqual(await getEditModeFontOptions(), [{ bold, underline, strikethrough }, {}, {}, { underline }]);\n\n    // Undo, then re-do, verifying after each invocation\n    await driver.find(\".test-choice-list-entry .test-tokenfield .test-tokenfield-input\").click();\n    await gu.sendKeys(Key.chord(modKey, \"z\"));\n    assert.deepEqual(await getEditModeFillColors(), [DARK_GREEN_FILL, BLUE_FILL, BLACK_FILL, APRICOT_FILL]);\n    assert.deepEqual(await getEditModeTextColors(), [WHITE_TEXT, BLACK_TEXT, WHITE_TEXT, APRICOT_TEXT]);\n    assert.deepEqual(await getEditModeFontOptions(), [{ bold, underline, strikethrough }, {}, {}, { bold, italic }]);\n    await gu.sendKeys(Key.chord(Key.CONTROL, \"y\"));\n    assert.deepEqual(await getEditModeFillColors(), [DARK_GREEN_FILL, BLUE_FILL, BLACK_FILL, WHITE_FILL]);\n    assert.deepEqual(await getEditModeTextColors(), [WHITE_TEXT, BLACK_TEXT, WHITE_TEXT, BLACK_TEXT]);\n    assert.deepEqual(await getEditModeFontOptions(), [{ bold, underline, strikethrough }, {}, {}, { underline }]);\n  });\n\n  it(\"should support rich copy/paste in the choice config editor\", async function() {\n    // Remove all choices\n    const modKey = await gu.modKey();\n    await gu.sendKeys(Key.chord(modKey, \"a\"), Key.BACK_SPACE);\n\n    // Add a few new choices\n    await gu.sendKeys(\"Choice 1\", Key.ENTER, \"Choice 2\", Key.ENTER, \"Choice 3\", Key.ENTER);\n\n    // Copy all the choices\n    await gu.sendKeys(await gu.selectAllKey());\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n\n      // Delete all the choices, then paste them back\n      await driver.sendKeys(Key.BACK_SPACE);\n      assert.deepEqual(await getEditModeChoiceLabels(), []);\n      await cb.paste();\n    });\n\n    // Verify no data was lost\n    assert.deepEqual(await getEditModeChoiceLabels(), [\"Choice 1\", \"Choice 2\", \"Choice 3\"]);\n\n    // In Jenkins, clipboard contents are pasted from the system clipboard, which only copies\n    // choices as newline-separated labels. For this reason, we can't check that the color\n    // information also got pasted, because the data is stored elsewhere. In actual use, the\n    // workflow above would copy all the choice data as well, and use it for pasting in the editor.\n  });\n\n  it(\"should save and close the choice config editor on focusout\", async function() {\n    // Click outside of the editor.\n    await driver.find(\".test-gristdoc\").click();\n    await gu.waitAppFocus();\n\n    // Check that the changes were saved.\n    assert.deepEqual(await getChoiceLabels(), [\"Choice 1\", \"Choice 2\", \"Choice 3\"]);\n\n    await gu.undo();\n  });\n\n  it(\"should add a new element on a fresh ChoiceList column\", async function() {\n    await gu.addColumn(\"ChoiceList\");\n    await gu.setType(gu.exactMatch(\"Choice List\"));\n    const cell = await gu.getCell(\"ChoiceList\", 1);\n    await cell.click();\n    await gu.sendKeys(\"foo\");\n    const plus = await driver.findWait(\".test-choice-list-editor-new-item\", 100);\n    await plus.click();\n    await gu.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.getText(), \"foo\");\n  });\n\n  it(\"should add a new element on a fresh Choice column\", async function() {\n    await gu.addColumn(\"Choice\");\n    await gu.setType(gu.exactMatch(\"Choice\"));\n    const cell = await gu.getCell(\"Choice\", 1);\n    await cell.click();\n    await gu.sendKeys(\"foo\");\n    const plus = await driver.findWait(\".test-choice-editor-new-item\", 100);\n    await plus.click();\n    await gu.waitForServer();\n    assert.equal(await cell.getText(), \"foo\");\n  });\n\n  for (const columnName of [\"ChoiceList\", \"Choice\"]) {\n    it(`should allow renaming tokens on ${columnName} column`, gu.revertChanges(async function() {\n      // Helper that converts ChoiceList to choice-list\n      const editorDashedName = columnName.toLowerCase().replace(/list/, \"-list\");\n      // Add two new options: one, two.\n      await gu.getCell(columnName, 2).click();\n      await gu.sendKeys(\"one\");\n      await driver.findWait(`.test-${editorDashedName}-editor-new-item`, 300).click();\n      if (columnName === \"ChoiceList\") {\n        await gu.sendKeys(Key.ENTER);\n      }\n      await gu.waitForServer();\n      await gu.getCell(columnName, 3).click();\n      await gu.sendKeys(\"two\");\n      await driver.findWait(`.test-${editorDashedName}-editor-new-item`, 300).click();\n      if (columnName === \"ChoiceList\") {\n        await gu.sendKeys(Key.ENTER);\n      }\n      await gu.waitForServer();\n\n      // Make sure right panel is open and has right focus.\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await driver.find(\".test-right-tab-field\").click();\n      // Rename one to three.\n      await editChoiceEntries();\n      await renameEntry(\"one\", \"three\");\n      await saveChoiceEntries();\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [columnName] }), [\n        \"foo\",\n        \"three\",\n        \"two\",\n      ]);\n\n      // Rename foo to bar, two to four, and three to eight, press undo 3 times\n      // and make sure nothing changes.\n      await editChoiceEntries();\n      await renameEntry(\"foo\", \"bar\");\n      await renameEntry(\"three\", \"eight\");\n      await renameEntry(\"two\", \"four\");\n      assert.deepEqual(await getEditModeChoiceLabels(), [\"bar\", \"eight\", \"four\"]);\n      const undoKey = Key.chord(await gu.modKey(), \"z\");\n      await gu.sendKeys(undoKey);\n      await gu.sendKeys(undoKey);\n      await gu.sendKeys(undoKey);\n      assert.deepEqual(await getEditModeChoiceLabels(), [\"foo\", \"three\", \"two\"]);\n\n      // Make sure we can copy and paste without adding new item\n      await clickEntry(\"foo\");\n      await clipboard.lockAndPerform(async (cb) => {\n        await cb.cut();\n        await cb.paste();\n        await cb.paste();\n      });\n      await gu.sendKeys(Key.ENTER);\n      await clickEntry(\"two\");\n      await clipboard.lockAndPerform(async (cb) => {\n        await cb.copy();\n        await gu.sendKeys(Key.ARROW_RIGHT);\n        await cb.paste();\n      });\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await getEditModeChoiceLabels(), [\"foofoo\", \"three\", \"twotwo\"]);\n      await saveChoiceEntries();\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [columnName] }), [\n        \"foofoo\",\n        \"three\",\n        \"twotwo\",\n      ]);\n\n      // Rename to bar, four and eight and do the change.\n      await editChoiceEntries();\n      await renameEntry(\"foofoo\", \"bar\");\n      await renameEntry(\"twotwo\", \"four\");\n      await renameEntry(\"three\", \"eight\");\n      await saveChoiceEntries();\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [columnName] }), [\n        \"bar\",\n        \"eight\",\n        \"four\",\n      ]);\n\n      // Add color to bar, save it, change to foo and make sure the color is still there.\n      await editChoiceEntries();\n      const [barColor] = await driver.findAll(\".test-tokenfield .test-color-button\");\n      await barColor.click();\n      await gu.setColor(driver.find(\".test-text-input\"), \"rgb(70, 13, 129)\");\n      await driver.sendKeys(Key.ENTER);\n      await renameEntry(\"bar\", \"foo\");\n      await saveChoiceEntries();\n      await editChoiceEntries();\n      const [fooColorText] = await getEditModeTextColors();\n      assert.equal(fooColorText, \"rgba(70, 13, 129, 1)\");\n\n      // Start renaming, but cancel out of the editor with two presses of the Escape key;\n      // a previous bug caused focus to be lost after the first Escape, making it impossible\n      // to close the editor with a subsequent press of Escape.\n      await editChoiceEntries();\n      await clickEntry(\"foo\");\n      await gu.sendKeys(\"food\");\n      await gu.sendKeys(Key.ESCAPE, Key.ESCAPE);\n      assert.isFalse(await driver.find(\".test-choice-list-entry-save\").isPresent());\n    },\n    // Test if the column is reverted to state before the test\n    () => gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [columnName] })));\n  }\n\n  it(\"should allow renaming multiple tokens on ChoiceList\", gu.revertChanges(async function() {\n    // Work on ChoiceList column, add one new option \"one\"\n    await gu.getCell(\"ChoiceList\", 2).click();\n    await gu.sendKeys(\"one\");\n    await driver.findWait(`.test-choice-list-editor-new-item`, 300).click();\n    await gu.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    await gu.getCell(\"ChoiceList\", 3).click();\n    await gu.sendKeys(\"one\", Key.ENTER, \"foo\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Make sure right panel is open and has right focus.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n\n    // rename one to three\n    await editChoiceEntries();\n    await renameEntry(\"one\", \"three\");\n    await renameEntry(\"foo\", \"four\");\n    await saveChoiceEntries();\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"ChoiceList\"] }), [\n      \"four\",\n      \"three\",\n      \"three\\nfour\",\n    ]);\n  },\n  // Test if the column is reverted to state before the test\n  () => gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"ChoiceList\"] })));\n\n  it(\"should rename saved filters\", gu.revertChanges(async function() {\n    // Make sure right panel is open and has focus.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n\n    // Work on ChoiceList column, add two new options: one, two\n    await gu.getCell(\"ChoiceList\", 2).click();\n    await gu.sendKeys(\"one\");\n    await driver.findWait(`.test-choice-list-editor-new-item`, 300).click();\n    await gu.sendKeys(Key.ENTER);\n    await gu.getCell(\"ChoiceList\", 3).click();\n    await gu.sendKeys(\"one\", Key.ENTER, \"foo\", Key.ENTER, Key.ENTER);\n    await gu.waitForServer();\n    // Make sure column looks like this:\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4], cols: [\"ChoiceList\"] }), [\n      \"foo\",\n      \"one\",\n      \"one\\nfoo\",\n      \"\", // add row\n    ]);\n\n    // Filter by single value and save.\n    await gu.filterBy(\"ChoiceList\", true, [/one/]);\n\n    // Duplicate page, to make sure filters are also renamed in a new section.\n    await gu.openPageMenu(\"Table1\");\n    await driver.find(\".test-docpage-duplicate\").click();\n    await driver.find(\".test-modal-confirm\").click();\n    await driver.findContentWait(\".test-docpage-label\", /copy/, 2000);\n    await gu.waitForServer();\n\n    // Go back to Table1\n    await gu.getPageItem(\"Table1\").click();\n    // Make sure grid is filtered\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"ChoiceList\"] }), [\n      \"one\",\n      \"one\\nfoo\",\n      \"\", // new row\n    ]);\n    // Rename one to five, foo to bar\n    await editChoiceEntries();\n    await renameEntry(\"one\", \"five\");\n    await renameEntry(\"foo\", \"bar\");\n    await saveChoiceEntries();\n    // Make sure that there are still two records - filter should be changed to new values.\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"ChoiceList\"] }), [\n      \"five\",\n      \"five\\nbar\",\n      \"\", // new row\n    ]);\n    // Make sure that it also renamed filters in diffrent section.\n    await gu.getPageItem(\"Table1 (copy)\").click();\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"ChoiceList\"] }), [\n      \"five\",\n      \"five\\nbar\",\n      \"\", // new row\n    ]);\n    // Go back to previous names, filter still should work.\n    await gu.undo();\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"ChoiceList\"] }), [\n      \"one\",\n      \"one\\nfoo\",\n      \"\", // new row\n    ]);\n  },\n  // Test if the column is reverted to state before the test\n  () => gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [\"ChoiceList\"] })));\n});\n"
  },
  {
    "path": "test/nbrowser/ClientUnitTests.ntest.js",
    "content": "import { driver } from \"mocha-webdriver\";\nimport { $, gu, server, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"ClientUnitTests.ntest\", function() {\n  test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    var timingTests = process.env.ENABLE_TIMING_TESTS ? 1 : 0;\n    await driver.get(server.getHost() + \"/v/gtag/test.html?timing=\" + timingTests);\n  });\n\n  it(\"should reach 100% with no failures\", async function() {\n    this.timeout(30000);  // You've got 30 seconds\n\n    await $(\"#mocha-status:contains(DONE)\").wait();\n\n    const failures = await driver.executeScript(\"return mocha.failedTests;\");\n    if (failures.length > 0) {\n      var listing = failures.map(fail => fail.title + \": \" + fail.error).join(\"\\n\");\n      throw new Error(\"Browser returned \" + failures.length + \" failed tests:\\n\" + listing);\n    }\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/CodeEditor.ntest.js",
    "content": "/* global window */\n\nimport { assert, driver } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"CodeEditor.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"../uploads/CodeEditor.test.csv\", true);\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"Should activate on click of `Code View` button\", async function() {\n    await gu.openSidePane(\"code\");\n    assert.match(await $(\".g-code-viewer\").wait().getText(),\n      /class CodeEditor_test:[^]*A = grist.Text\\(\\)[^]*B = grist.Numeric\\(\\)/);\n  });\n\n  it(\"Should update to reflect changes in schema\", async function() {\n    await gu.actions.selectTabView(\"CodeEditor.test\");\n    // open the side menu\n    await gu.openSidePane(\"field\");\n\n    await gu.getCellRC(0, 0).click();\n    await $(\".test-field-label\").wait(assert.isDisplayed);\n    await $(\".test-field-label\").sendNewText(\"foo\");\n    await gu.waitForServer();\n\n    await gu.getCellRC(0, 1).click();\n    await $(\".test-field-label\").sendNewText(\"bar\");\n    await gu.waitForServer(); // Must wait for colId change to finish\n\n    await gu.setType(\"Reference\");\n    await gu.applyTypeConversion();\n    await gu.setVisibleCol(\"foo\");\n    await gu.waitForServer();\n\n    // Check that type conversion worked correctly.\n    assert.equal(await gu.getCellRC(1, 1).text(), \"Bob\");\n\n    await gu.openSidePane(\"code\");\n    assert.match(await $(\".g-code-viewer\").wait().getText(),\n      /foo = grist.Text\\(\\)[^]*bar = grist.Reference\\('CodeEditor_test'\\)/);\n  });\n\n  it(\"should filter out helper columns\", async function() {\n    assert.notInclude(await $(\".g-code-viewer\").wait().getText(), \"gristHelper\");\n  });\n\n  it(\"should allow text selection\", async function() {\n    const textElem = $(\".hljs-title:contains(CodeEditor)\");\n    await textElem.click();\n    await driver.withActions(a => a.doubleClick(textElem.elem()));\n    assert.equal(await driver.executeScript(() => window.getSelection().toString()), \"CodeEditor_test\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ColumnFilterMenu.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { addToRepl, assert, driver, Key } from \"mocha-webdriver\";\n\nconst limitShown = 500;\n\n// Sum all of the counts directly on the browser using `driver.executeScript(...)`. There could me\n// over 500 of them and using the classic driver.findAll(...) approach makes it too slow and causes\n// the test to crash (timeout).\nfunction getCount() {\n  return driver.executeScript(`\n  return Array.from(document.querySelectorAll('.test-filter-menu-count'), e => e.innerText)\n    .map(s => s.split(',').join(''))\n    .map(Number)\n    .reduce((acc, v) => acc + v, 0);\n`);\n}\n\n// find a filter value by name\nfunction findByName(regex: RegExp | string) {\n  return driver.findContent(\".test-filter-menu-list label\", regex);\n}\n\ndescribe(\"ColumnFilterMenu\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  addToRepl(\"findByName\", findByName);\n  let doc: any;\n  let api: UserAPI;\n\n  it(\"should handle empty lists consistently\", async function() {\n    // A formula returning an empty RecordSet in a RefList columns results in storing [] instead of null.\n    // This previously caused a bug where the empty list was 'flattened' and the cell not appearing in filters at all.\n    const session = await gu.session().teamSite.login();\n    const api = session.createHomeApi();\n    const docId = await session.tempNewDoc(cleanup, \"FilterEmptyLists\", { load: false });\n\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"Table2\", [\n        {\n          id: \"A\", type: \"RefList:Table2\", isFormula: true,\n          // This means that the first cell will contain [] while the second will contain null.\n          // The test asserts that both end up being treated the same.\n          formula: 'if $id == 1: return table.lookupRecords(B=\"foobar\")',\n        },\n        { id: \"B\" },\n      ]],\n      [\"BulkAddRecord\", \"Table2\", [null, null], { B: [1, 2] }],\n    ]);\n\n    await session.loadDoc(`/doc/${docId}/p/2`);\n\n    await gu.rightClick(gu.getCell({ rowNum: 1, col: \"A\" }));\n    await gu.findOpenMenuItem(\"li\", \"Filter by this value\").click();\n\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"A\", \"B\"], rowNums: [1, 2, 3] }),\n      [\n        \"\", \"1\",\n        \"\", \"2\",\n        \"\", \"\",\n      ],\n    );\n\n    await gu.openColumnMenu(\"A\", \"Filter\");\n\n    assert.deepEqual(\n      await driver.findAll(\".test-filter-menu-list .test-filter-menu-count\", e => e.getText()),\n      [\"2\"],\n    );\n  });\n\n  it(\"should show only first 500\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"World.grist\");\n\n    // check row count is > 4000\n    const total = await gu.getGridRowCount() - 1;\n    assert.equal(total, 4079);\n\n    // scroll back to top\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.UP));\n\n    // open filter menu for first column\n    await gu.openColumnMenu(\"Name\", \"Filter\");\n\n    // check ther are 500 entry shown\n    assert.lengthOf(await driver.findAll(\".test-filter-menu-list label\"), limitShown);\n\n    // check `Other` summary is present\n    assert.deepEqual(\n      await driver.findAll(\".test-filter-menu-summary\", e => e.find(\"label\").getText()),\n      [\"Other values (3,501)\", \"Future values\"],\n    );\n\n    // check counts add up\n    assert.equal(await getCount(), total);\n\n    // type 'A' to search\n    await gu.sendKeys(\"A\");\n\n    // check summary has `Other matching` and Other Non-matching`\n    assert.deepEqual(\n      await driver.findAll(\".test-filter-menu-summary\", e => e.find(\"label\").getText()),\n      [\"Other Matching (2,493)\", \"Other Non-Matching (1,008)\"],\n    );\n\n    // check count adds up\n    assert.equal(await getCount(), total);\n\n    // clear search input\n    await gu.sendKeys(Key.BACK_SPACE);\n\n    // Click All Except / Other Matching / Other NOn-Matching\n    await driver.findContent(\".test-filter-menu-bulk-action\", /None/).click();\n\n    // click Aba and Abadan\n    await driver.findContent(\".test-filter-menu-list label\", /Aba/).click();\n    await driver.findContent(\".test-filter-menu-list label\", /Abadan/).click();\n\n    // Apply filter\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // check grid contains aba and abadan\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\"], rowNums: [1, 2, 3] }),\n      [\n        \"Aba\",\n        \"Abadan\",\n        \"\",\n      ],\n    );\n  });\n\n  it(\"should uncheck 'Other values' checkbox when user clicks 'None'\", async () => {\n    // open the Name filter\n    await gu.openColumnMenu(\"Name\", \"Filter\");\n\n    // click None\n    await driver.findContent(\".test-filter-menu-bulk-action\", /None/).click();\n\n    // check Other values was propertly unchecked\n    assert.equal(\n      await driver.findContent(\".test-filter-menu-summary\", /Other values/).find(\"input\").matches(\":checked\"),\n      false,\n    );\n\n    assert.equal(\n      await driver.findContent(\".test-filter-menu-summary\", /Future values/).find(\"input\").matches(\":checked\"),\n      false,\n    );\n  });\n\n  it(\"should take other filters into account\", async () => {\n    const session = await gu.session().teamSite.login();\n    doc = await session.tempDoc(cleanup, \"SortFilterIconTest.grist\");\n    api = session.createHomeApi();\n\n    // check table content\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\", \"Count\"], rowNums: [1, 2, 3, 4, 5, 6] }),\n      [\"Apples\", \"1\",\n        \"Oranges\", \"3\",\n        \"Bananas\", \"2\",\n        \"Grapes\", \"-1\",\n        \"Grapefruit\", \"n/a\",\n        \"Clementines\", \"5\",\n      ]);\n\n    // add Name Filter\n    await gu.openColumnMenu(\"Name\", \"Filter\");\n\n    // Click Oranges\n    await findByName(\"Oranges\").click();\n\n    // Click Apply\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // add Count filters\n    await driver.find(\".test-add-filter-btn\").click();\n    await gu.findOpenMenuItem(\"li\", /Count/).click();\n\n    // Check that there's only 5 values left ('3' is missing)\n    assert.deepEqual(await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()),\n      [\"n/a\", \"-1\", \"1\", \"2\", \"5\"]);\n\n    // Check `Others` shows unique count\n    assert.equal(await driver.find(\".test-filter-menu-summary\").getText(),\n      \"Others (1)\");\n\n    // Check `Others` is checked\n    assert.equal(await driver.find(\".test-filter-menu-summary\").find(\"input\").matches(\":checked\"), true);\n\n    // Click `Other`\n    await driver.find(\".test-filter-menu-summary\").find(\"input\").click();\n\n    // Click '1'\n    await findByName(/^1/).click();\n\n    // Click Apply\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // Open the Name menu filter\n    await driver.findContent(\".test-filter-field\", /Name/).click();\n\n    // Check there's only 4 values left\n    assert.deepEqual(await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()),\n      [\"Bananas\", \"Clementines\", \"Grapefruit\", \"Grapes\"]);\n\n    // check `Others` shows 2 unique values\n    assert.equal(await driver.find(\".test-filter-menu-summary\").getText(),\n      \"Others (2)\");\n\n    // check `Others` is in indeterminate state\n    assert.equal(await driver.find(\".test-filter-menu-summary\").find(\"input\").matches(\":checked\"), false);\n    assert.equal(await driver.find(\".test-filter-menu-summary\").find(\"input\").matches(\":indeterminate\"), true);\n\n    // Click `Others`\n    await driver.find(\".test-filter-menu-summary\").find(\"input\").click();\n\n    // check `Others` is checked\n    assert.equal(await driver.find(\".test-filter-menu-summary\").find(\"input\").matches(\":checked\"), true);\n    assert.equal(await driver.find(\".test-filter-menu-summary\").find(\"input\").matches(\":indeterminate\"), false);\n\n    // Click `Others`\n    await driver.find(\".test-filter-menu-summary\").find(\"input\").click();\n\n    // check `Others` is checked\n    assert.equal(await driver.find(\".test-filter-menu-summary\").find(\"input\").matches(\":checked\"), false);\n    assert.equal(await driver.find(\".test-filter-menu-summary\").find(\"input\").matches(\":indeterminate\"), false);\n\n    // Click Apply\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // open Count filter menu\n    await driver.findContent(\".test-filter-field\", /Count/).click();\n\n    // Click all and click Apply\n    await driver.findContent(\".test-filter-menu-bulk-action\", /All/).click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // open Name filter menu\n    await driver.findContent(\".test-filter-field\", /Name/).click();\n\n    // Check Apples and Oranges are unchecked\n    assert.deepEqual(await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()),\n      [\"Apples\", \"Bananas\", \"Clementines\", \"Grapefruit\", \"Grapes\", \"Oranges\"]);\n    assert.equal(await findByName(\"Apples\").find(\"input\").matches(\":checked\"), false);\n    assert.equal(await findByName(\"Oranges\").find(\"input\").matches(\":checked\"), false);\n\n    // click Apply\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // Open count Filter menu\n    await driver.findContent(\".test-filter-field\", /Count/).click();\n\n    // Check there's only 4 values left\n    assert.deepEqual(await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()),\n      [\"n/a\", \"-1\", \"2\", \"5\"]);\n\n    // Click Others\n    await driver.find(\".test-filter-menu-summary\").click();\n\n    // click Apply\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // Open Name filter menu\n    await driver.findContent(\".test-filter-field\", /Name/).click();\n\n    // Check Others is unchecked\n    assert.equal(await driver.find(\".test-filter-menu-summary\").find(\"input\").matches(\":checked\"), false);\n    assert.equal(await driver.find(\".test-filter-menu-summary\").find(\"input\").matches(\":indeterminate\"), false);\n\n    // Click Others\n    await driver.find(\".test-filter-menu-summary\").find(\"input\").click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // Open count filter\n    await driver.findContent(\".test-filter-field\", /Count/).click();\n\n    // Click All and click apply\n    await driver.findContent(\".test-filter-menu-bulk-action\", /All/).click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // open Name filter menu\n    await driver.findContent(\".test-filter-field\", /Name/).click();\n\n    // Check both apples and orages are not checked\n    assert.equal(await findByName(\"Apples\").find(\"input\").matches(\":checked\"), false);\n    assert.equal(await findByName(\"Oranges\").find(\"input\").matches(\":checked\"), false);\n\n    // Revert to all\n    await driver.findContent(\".test-filter-menu-bulk-action\", /All/).click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // Open Count filter menu and click All\n    await driver.findContent(\".test-filter-field\", /Count/).click();\n    await driver.findContent(\".test-filter-menu-bulk-action\", /All/).click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n  });\n\n  it(\"should show count of unique values next to summaries\", async () => {\n    // add another Apples\n    await driver.find(\".record-add .field\").click();\n    await driver.sendKeys(\"Apples\", Key.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\", \"Count\"], rowNums: [1, 2, 3, 4, 5, 6, 7] }),\n      [\"Apples\", \"1\",\n        \"Oranges\", \"3\",\n        \"Bananas\", \"2\",\n        \"Grapes\", \"-1\",\n        \"Grapefruit\", \"n/a\",\n        \"Clementines\", \"5\",\n        \"Apples\", \"0\",\n      ]);\n\n    // open the Count filter\n    await driver.findContent(\".test-filter-field\", /Count/).click();\n\n    // uncheck 0 and 1\n    await findByName(/^0/).click();\n    await findByName(/^1/).click();\n\n    // Click Apply\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // open the Name filter\n    await driver.findContent(\".test-filter-field\", /Name/).click();\n\n    // check Apples is missing\n    assert.deepEqual(await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()),\n      [\"Bananas\", \"Clementines\", \"Grapefruit\", \"Grapes\", \"Oranges\"]);\n\n    // check count is (1)\n    assert.deepEqual(\n      await driver.findAll(\".test-filter-menu-summary\", e => e.find(\"label\").getText()),\n      [\"Others (1)\"],\n    );\n\n    // close filter\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should show a working range filter for numeric columns\", async function() {\n    // open the Count filter\n    await driver.findContent(\".test-filter-field\", /Count/).click();\n\n    // set min to '2'\n    await gu.setRangeFilterBound(\"min\", \"2\");\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // check values\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\", \"Count\"], rowNums: [1, 2, 3, 4] }),\n      [\"Oranges\", \"3\",\n        \"Bananas\", \"2\",\n        \"Clementines\", \"5\",\n        \"\", \"\",\n      ],\n    );\n\n    // reopen the filter\n    await driver.findContent(\".test-filter-field\", /Count/).click();\n\n    // set max to '4'\n    await gu.setRangeFilterBound(\"max\", \"4\");\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\", \"Count\"], rowNums: [1, 2, 3, 4] }),\n      [\"Oranges\", \"3\",\n        \"Bananas\", \"2\",\n        \"\", \"\",\n        undefined, undefined,\n      ],\n    );\n\n    // remove both min and max\n    await driver.findContent(\".test-filter-field\", /Count/).click();\n    await gu.setRangeFilterBound(\"min\", null);\n    await gu.setRangeFilterBound(\"max\", null);\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // check all values are there\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\", \"Count\"], rowNums: [1, 2, 3, 4, 5, 6, 7] }),\n      [\"Apples\", \"1\",\n        \"Oranges\", \"3\",\n        \"Bananas\", \"2\",\n        \"Grapes\", \"-1\",\n        \"Grapefruit\", \"n/a\",\n        \"Clementines\", \"5\",\n        \"Apples\", \"0\",\n      ]);\n  });\n\n  it(\"should remove new filters when Cancel is clicked in a new filter\", async function() {\n    // Create a new Date filter.\n    await gu.openColumnMenu(\"Date\", \"Filter\");\n    assert.deepEqual(\n      [\n        { checked: true, value: \"n/a\", count: 1 },\n        { checked: true, value: \"\", count: 2 },\n        { checked: true, value: \"2019-07-15\", count: 1 },\n        { checked: true, value: \"2019-07-16\", count: 1 },\n        { checked: true, value: \"2019-07-17\", count: 1 },\n        { checked: true, value: \"2019-07-18\", count: 1 },\n      ],\n      await gu.getFilterMenuState(),\n    );\n\n    // Check that the Date filter is pinned.\n    assert.deepEqual(\n      [\n        { name: \"Name\", hasUnsavedChanges: true },\n        { name: \"Count\", hasUnsavedChanges: true },\n        { name: \"Date\", hasUnsavedChanges: true },\n      ],\n      await gu.getPinnedFilters(),\n    );\n\n    // Set a min filter of '2019-07-16'.\n    await gu.setRangeFilterBound(\"min\", \"2019-07-16\");\n\n    // Click Cancel, and check that the filter is no longer applied to the table data.\n    await gu.waitToPass(async () => {\n      await driver.find(\".test-filter-menu-cancel-btn\").click();\n      assert.isFalse(await driver.find(\".test-filter-menu-wrapper\").isPresent());\n    });\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\", \"Count\"], rowNums: [1, 2, 3, 4, 5, 6, 7] }),\n      [\"Apples\", \"1\",\n        \"Oranges\", \"3\",\n        \"Bananas\", \"2\",\n        \"Grapes\", \"-1\",\n        \"Grapefruit\", \"n/a\",\n        \"Clementines\", \"5\",\n        \"Apples\", \"0\",\n      ],\n    );\n\n    // Check that the Date filter was removed.\n    await gu.openSectionMenu(\"sortAndFilter\");\n    assert.isFalse(await driver.findContent(\".test-filter-config-filter\", /Date/).isPresent());\n    await gu.sendKeys(Key.ESCAPE);\n    assert.deepEqual(\n      [\n        { name: \"Name\", hasUnsavedChanges: true },\n        { name: \"Count\", hasUnsavedChanges: true },\n      ],\n      await gu.getPinnedFilters(),\n    );\n  });\n\n  it(\"should revert to open state when Cancel is clicked in an existing filter\", async function() {\n    // Open the Count filter.\n    await driver.findContent(\".test-filter-field\", /Count/).click();\n\n    // Filter out 1 and 2.\n    await driver.findContent(\".test-filter-menu-list label\", /1/).click();\n    await driver.findContent(\".test-filter-menu-list label\", /2/).click();\n\n    // Unpin the filter.\n    await driver.find(\".test-filter-menu-pin-btn\").click();\n\n    // Click Cancel, and check that the filter is no longer applied to the table data.\n    await driver.find(\".test-filter-menu-cancel-btn\").click();\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\", \"Count\"], rowNums: [1, 2, 3, 4, 5, 6, 7] }),\n      [\"Apples\", \"1\",\n        \"Oranges\", \"3\",\n        \"Bananas\", \"2\",\n        \"Grapes\", \"-1\",\n        \"Grapefruit\", \"n/a\",\n        \"Clementines\", \"5\",\n        \"Apples\", \"0\",\n      ],\n    );\n\n    // Check that Count is still pinned to the filter bar.\n    assert.deepEqual(\n      [\n        { name: \"Name\", hasUnsavedChanges: true },\n        { name: \"Count\", hasUnsavedChanges: true },\n      ],\n      await gu.getPinnedFilters(),\n    );\n\n    // Check the filter menu state of Count.\n    await driver.findContent(\".test-filter-field\", /Count/).click();\n    assert.deepEqual(\n      [\n        { checked: true, value: \"n/a\", count: 1 },\n        { checked: true, value: \"-1\", count: 1 },\n        { checked: true, value: \"0\", count: 1 },\n        { checked: true, value: \"1\", count: 1 },\n        { checked: true, value: \"2\", count: 1 },\n        { checked: true, value: \"3\", count: 1 },\n        { checked: true, value: \"5\", count: 1 },\n      ],\n      await gu.getFilterMenuState(),\n    );\n\n    await gu.sendKeys(Key.ESCAPE);\n  });\n\n  async function testDateLikeColumn(colId: \"Date\" | \"DateTime\") {\n    const timeChunk = colId === \"DateTime\" ? \" 12:00am\" : \"\";\n    const colRegex = new RegExp(colId + \"\\\\b\");\n\n    // add Date Filter\n    await driver.find(\".test-add-filter-btn\").click();\n    await gu.findOpenMenuItem(\"li\", colRegex).click();\n\n    // set min to '2019-07-16'\n    await gu.setRangeFilterBound(\"min\", \"2019-07-16\");\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n    await gu.waitAppFocus();\n\n    // check values\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\", colId], rowNums: [1, 2, 3, 4] }),\n      [\"Apples\", \"2019-07-17\" + timeChunk,\n        \"Oranges\", \"2019-07-16\" + timeChunk,\n        \"Bananas\", \"2019-07-18\" + timeChunk,\n        \"\", \"\",\n      ],\n    );\n\n    // reopen the filter\n    await driver.findContent(\".test-filter-field\", colRegex).click();\n\n    // set max to '2019-07-17'\n    await gu.setRangeFilterBound(\"max\", \"2019-07-17\");\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n    await gu.waitAppFocus();\n\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\", colId], rowNums: [1, 2, 3, 4] }),\n      [\"Apples\", \"2019-07-17\" + timeChunk,\n        \"Oranges\", \"2019-07-16\" + timeChunk,\n        \"\", \"\",\n        undefined, undefined,\n      ],\n    );\n\n    // remove both min and max\n    await driver.findContent(\".test-filter-field\", colRegex).click();\n    await gu.setRangeFilterBound(\"min\", null);\n    await gu.setRangeFilterBound(\"max\", null);\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n    await gu.waitAppFocus();\n\n    // check all values are there\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\", colId], rowNums: [1, 2, 3, 4, 5, 6, 7] }),\n      [\"Apples\",      \"2019-07-17\" + timeChunk,\n        \"Oranges\",     \"2019-07-16\" + timeChunk,\n        \"Bananas\",     \"2019-07-18\" + timeChunk,\n        \"Grapes\",      \"\",\n        \"Grapefruit\",  \"2019-07-15\" + timeChunk,\n        \"Clementines\", \"n/a\",\n        \"Apples\", \"\",\n      ]);\n  }\n\n  it(\"should show a working range filter for Date column\", async function() {\n    await testDateLikeColumn(\"Date\");\n  });\n\n  it(\"should show a working range filter for DateTime column\", async function() {\n    // adds a DateTime column\n    await api.applyUserActions(doc.id, [\n      [\"AddVisibleColumn\", \"Table1\", \"DateTime\", {\n        type: \"DateTime:UTC\", widgetOptions: '{\"dateFormat\": \"YYYY-MM-DD\", \"timeFormat\": \"h:mma\"}',\n      }],\n      [\"BulkUpdateRecord\", \"Table1\", [1, 2, 3, 4, 5, 6], {\n        DateTime: [\n          // TODO: fix timezone\n          \"2019-07-17T00:00Z\",\n          \"2019-07-16T00:00Z\",\n          \"2019-07-18T00:00Z\",\n          \"\",\n          \"2019-07-15T00:00Z\",\n          \"n/a\",\n        ],\n      }],\n    ]);\n\n    await testDateLikeColumn(\"DateTime\");\n  });\n\n  it(\"should have working date range filter also when column is hidden\", async function() {\n    // hide Date column\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await gu.moveToHidden(\"Date\");\n\n    // add Date filter\n    await driver.findContent(\".test-filter-field\", \"Date\").click();\n\n    // start typing date in min bounds and send TAB\n    await driver.find(\".test-filter-menu-min\").click();\n    await gu.sendKeys(\"2019-07-14\", Key.TAB);\n\n    // check min is set to a valid date\n    assert.equal(await driver.find(\".test-filter-menu-min input\").value(), \"2019-07-14\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ColumnFilterMenu2.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\nfunction getItems() {\n  return driver.findAll(\".test-filter-menu-list label\", async e => ({\n    checked: await e.find(\"input\").isSelected(),\n    label: await e.find(\".test-filter-menu-value\").getText(),\n    count: await e.find(\".test-filter-menu-count\").getText(),\n  }));\n}\n\ndescribe(\"ColumnFilterMenu2\", function() {\n  this.timeout(\"30s\");\n  const cleanup = setupTestSuite();\n  let mainSession: gu.Session;\n  let docId: string;\n  let api: any;\n\n  before(async function() {\n    mainSession = await gu.session().teamSite.user(\"user1\").login();\n    docId = await mainSession.tempNewDoc(cleanup, \"ColumnFilterMenu2.grist\", { load: false });\n    api = mainSession.createHomeApi();\n    // Prepare a table with some interestingly-formatted columns, and some data.\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"Test\", []],\n      [\"AddVisibleColumn\", \"Test\", \"Bool\", {\n        type: \"Bool\", widgetOptions: JSON.stringify({ widget: \"TextBox\" }),\n      }],\n      [\"AddVisibleColumn\", \"Test\", \"Choice\", {\n        type: \"Choice\", widgetOptions: JSON.stringify({ choices: [\"foo\", \"bar\"] }),\n      }],\n      [\"AddVisibleColumn\", \"Test\", \"ChoiceList\", {\n        type: \"ChoiceList\", widgetOptions: JSON.stringify({ choices: [\"foo\", \"bar\"] }),\n      }],\n      [\"AddVisibleColumn\", \"Test\", \"Marked\", {\n        type: \"Text\", widgetOptions: JSON.stringify({ widget: \"Markdown\" }),\n      }],\n\n      [\"AddVisibleColumn\", \"Test\", \"Nr\", { type: \"Int\" }],\n\n      [\"AddRecord\", \"Test\", null, {\n        Bool: true, Choice: \"foo\", ChoiceList: [\"L\", \"foo\"],\n        Marked: \"[Some link](http://example.com)\",\n      }],\n    ]);\n    return docId;\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should show all options for Bool columns\", async () => {\n    await mainSession.loadDoc(`/doc/${docId}/p/2`);\n\n    await gu.openColumnMenu(\"Bool\", \"Filter\");\n    assert.deepEqual(await getItems(), [\n      { checked: true, label: \"false\", count: \"0\" },\n      { checked: true, label: \"true\", count: \"1\" },\n    ]);\n\n    // click false\n    await driver.findContent(\".test-filter-menu-list label\", \"false\").click();\n    assert.deepEqual(await getItems(), [\n      { checked: false, label: \"false\", count: \"0\" },\n      { checked: true, label: \"true\", count: \"1\" },\n    ]);\n\n    // add new record with Bool=false\n    const { retValues } = await api.applyUserActions(docId, [\n      [\"AddRecord\", \"Test\", null, { Bool: false }],\n    ]);\n\n    // check record is not shown on screen\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Bool\", \"Choice\", \"ChoiceList\"], rowNums: [1, 2] }),\n      [\"true\", \"foo\", \"foo\",\n        \"\", \"\", \"\",\n      ] as any,\n    );\n\n    // remove added record\n    await api.applyUserActions(docId, [\n      [\"RemoveRecord\", \"Test\", retValues[0]],\n    ]);\n  });\n\n  it(\"should show all options for Choice/ChoiceList columns\", async () => {\n    await gu.openColumnMenu(\"Choice\", \"Filter\");\n    assert.deepEqual(await getItems(), [\n      { checked: true, label: \"bar\", count: \"0\" },\n      { checked: true, label: \"foo\", count: \"1\" },\n    ]);\n\n    // click bar\n    await driver.findContent(\".test-filter-menu-list label\", \"bar\").click();\n    assert.deepEqual(await getItems(), [\n      { checked: false, label: \"bar\", count: \"0\" },\n      { checked: true, label: \"foo\", count: \"1\" },\n    ]);\n\n    // add new record with Choice=bar\n    const { retValues } = await api.applyUserActions(docId, [\n      [\"AddRecord\", \"Test\", null, { Choice: \"bar\" }],\n    ]);\n\n    // check record is not shown on screen\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Bool\", \"Choice\", \"ChoiceList\"], rowNums: [1, 2] }),\n      [\"true\", \"foo\", \"foo\",\n        \"\", \"\", \"\",\n      ] as any,\n    );\n\n    // remove added record\n    await api.applyUserActions(docId, [\n      [\"RemoveRecord\", \"Test\", retValues[0]],\n    ]);\n\n    // check ChoiceList filter offeres all options\n    await gu.openColumnMenu(\"ChoiceList\", \"Filter\");\n    assert.deepEqual(await getItems(), [\n      { checked: true, label: \"bar\", count: \"0\" },\n      { checked: true, label: \"foo\", count: \"1\" },\n    ]);\n    await gu.sendKeys(Key.ESCAPE);\n    await driver.find(\".test-section-menu-small-btn-revert\").click();\n  });\n\n  it(\"should strip markdown content for Text columns\", async () => {\n    /** Gets labels rendered in the filter menu */\n    const labels = async () => {\n      await gu.openColumnMenu(\"Marked\", \"Filter\");\n      const list = (await getItems()).map(item => item.label);\n      await gu.sendKeys(Key.ESCAPE);\n      return list;\n    };\n    /** Replaced all rows */\n    const replace = (vals: [number, string, string][]) => gu.sendActions([\n      [\"ReplaceTableData\", \"Test\", [], {}],\n      [\"BulkAddRecord\", \"Test\", vals.map(() => null), {\n        Nr: vals.map(([nr]) => nr),\n        Marked: vals.map(([, marked]) => marked),\n      }],\n    ]);\n\n    // Whole test case.\n    const test = async (data: [number, string, string][]) => {\n      // First replace table with new data.\n      await replace(data);\n\n      // Then make sure that filter shows plain text labels.\n      assert.deepEqual(\n        await labels(),\n        data.map(([, , expected]) => expected).sort(), // Filter menu sorts labels.\n      );\n      // Now filter by each label and check that it works.\n\n      for (const [nr, , strippedMarkdown] of data) {\n        // Open the filter menu.\n        const f = await gu.openColumnFilter(\"Marked\");\n\n        // Type the stripped markdown in the search box.\n        await f.search(strippedMarkdown);\n\n        // Make sure we only see the typed text, there is only one value that matches it.\n        assert.deepEqual(await f.labels(), [strippedMarkdown]);\n\n        // Filter out everything else.\n        await f.allShown();\n\n        // Close the filter.\n        await f.close();\n\n        // Check only the Nr column, as the markdown is converted to html and it is hard to\n        // look for.\n        assert.deepEqual(\n          await gu.getVisibleGridCells(\"Nr\", [1]), [String(nr)],\n          `Failed to filter by ${strippedMarkdown}`,\n        );\n\n        assert.equal(await gu.getGridRowCount(), 1 + 1 /* add row */);\n\n        await gu.sendKeys(Key.ESCAPE);\n      }\n      await driver.find(\".test-section-menu-small-btn-revert\").click();\n    };\n\n    await test([\n      // Nr , Markdown with a link, Same markdown but without a link,\n      // Note: Nr is needed as we check if the row was filtered correctly, but in the grid it is converted\n      // to html, so its harder to find. With Nr column we can just check if the row is there.\n      [1, \"[link](http://example.com) at start\", \"link at start\"],\n      [2, \"link at the [end](http://example.com)\", \"link at the end\"],\n      [3, \"link in the [middle](http://example.com) of text\", \"link in the middle of text\"],\n      [4, \"**bold** text with [link](http://example.com)\", \"**bold** text with link\"],\n      [5, \"[**label** in bold](http://example.com) with text\", \"**label** in bold with text\"],\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ColumnFilterMenu3.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\nvoid (driver);\nvoid (Key);\n\nasync function getValues() {\n  return driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText());\n}\n\ndescribe(\"ColumnFilterMenu3\", function() {\n  this.timeout(30000);\n  const cleanup = setupTestSuite();\n  let mainSession: gu.Session;\n  let docId: string;\n  before(async () => {\n    mainSession = await gu.session().teamSite.user(\"user1\").login();\n    docId = await mainSession.tempNewDoc(cleanup, \"Search3.grist\", { load: false });\n    const api = mainSession.createHomeApi();\n    // Prepare a table with some interestingly-formatted columns, and some data.\n    const { retValues } = await api.applyUserActions(docId, [\n      [\"AddTable\", \"Test\", []],\n      [\"AddVisibleColumn\", \"Test\", \"Date\", { type: \"Date\", widgetOptions: '{\"dateFormat\":\"DD-MM-YYYY\"}' }],\n      [\"AddVisibleColumn\", \"Test\", \"Numeric\", { type: \"Numeric\" }],\n      [\"AddVisibleColumn\", \"Test\", \"Int\", { type: \"Int\" }],\n      [\"AddVisibleColumn\", \"Test\", \"Ref\", { type: \"Ref:Test\" }],\n      [\"AddVisibleColumn\", \"Test\", \"RefList\", { type: \"RefList:Test\" }],\n    ]);\n    await api.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Tables_column\", retValues[4].colRef, { visibleCol: retValues[1].colRef }],\n      [\"UpdateRecord\", \"_grist_Tables_column\", retValues[5].colRef, { visibleCol: retValues[1].colRef }],\n      [\"SetDisplayFormula\", \"Test\", null, retValues[4].colRef, \"$Ref.Date\"],\n      [\"SetDisplayFormula\", \"Test\", null, retValues[5].colRef, \"$RefList.Date\"],\n      [\"AddRecord\", \"Test\", null, { Date: \"22-12-2011\", Numeric: 2,  Int: 2,  Ref: 1,\n        RefList: [\"L\", 1, 2] }],\n      [\"AddRecord\", \"Test\", null, { Date: \"20-12-2021\", Numeric: 22, Int: 22, Ref: 2,\n        RefList: [\"L\", 1] }],\n      [\"AddRecord\", \"Test\", null, { Date: \"20-12-2011\", Numeric: 3,  Int: 3,  Ref: 3,\n        RefList: [\"L\", 1, 2, 3] }],\n    ]);\n    await mainSession.loadDoc(`/doc/${docId}/p/2`);\n  });\n\n  afterEach(async () => {\n    // close menu if one was opened\n    if (await driver.find(\".grist-floating-menu\").isPresent()) {\n      await driver.sendKeys(Key.ESCAPE);\n    }\n    if (await driver.find(\".test-filter-menu-wrapper\").isPresent()) {\n      await driver.sendKeys(Key.ESCAPE);\n    }\n  });\n\n  it(\"should correctly focus between inputs in Numeric columns\", async () => {\n    // A bug was introduced where the search input could no longer be focused if either range\n    // input had focus.\n    await gu.openColumnMenu(\"Numeric\", \"Filter\");\n\n    const assertSearchCanBeFocused = async () => {\n      await driver.find(\".test-filter-menu-search-input\").click();\n      assert.equal(\n        await driver.switchTo().activeElement().getId(),\n        await driver.find(\".test-filter-menu-search-input\").getId(),\n      );\n    };\n\n    await driver.find(\".test-filter-menu-min\").click();\n    await assertSearchCanBeFocused();\n    await driver.find(\".test-filter-menu-max\").click();\n    await assertSearchCanBeFocused();\n  });\n\n  it(\"should have correct order for Numeric column\", async () => {\n    await gu.openColumnMenu(\"Numeric\", \"Filter\");\n    assert.deepEqual(await getValues(), [\"2\", \"3\", \"22\"]);\n  });\n\n  it(\"should have correct order for Integer column\", async () => {\n    await gu.openColumnMenu(\"Int\", \"Filter\");\n    assert.deepEqual(await getValues(), [\"2\", \"3\", \"22\"]);\n    await driver.find(\".test-filter-menu-apply-btn\");\n  });\n\n  it(\"should have correct order for Date column\", async () => {\n    await gu.openColumnMenu(\"Date\", \"Filter\");\n    assert.deepEqual(await getValues(), [\"20-12-2011\", \"22-12-2011\", \"20-12-2021\"]);\n  });\n\n  describe(\"Ref\", function() {\n    it(\"should have correct order for Numeric column\", async () => {\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await gu.openColumnMenu(\"Ref\", \"Options\");\n      await gu.setRefShowColumn(\"Numeric\");\n      await gu.openColumnMenu(\"Ref\", \"Filter\");\n      assert.deepEqual(await getValues(), [\"2\", \"3\", \"22\"]);\n    });\n    it(\"should have correct order for Integer column\", async () => {\n      await gu.setRefShowColumn(\"Int\");\n      await gu.openColumnMenu(\"Ref\", \"Filter\");\n      assert.deepEqual(await getValues(), [\"2\", \"3\", \"22\"]);\n    });\n    it(\"should have correct order for Date column\", async () => {\n      await gu.setRefShowColumn(\"Date\");\n      await gu.openColumnMenu(\"Ref\", \"Filter\");\n      assert.deepEqual(await getValues(), [\"20-12-2011\", \"22-12-2011\", \"20-12-2021\"]);\n    });\n  });\n\n  describe(\"RefList\", function() {\n    it(\"should have correct order for Numeric column\", async () => {\n      await gu.openColumnMenu(\"RefList\", \"Options\");\n      await gu.setRefShowColumn(\"Numeric\");\n      await gu.openColumnMenu(\"RefList\", \"Filter\");\n      assert.deepEqual(await getValues(), [\"2\", \"3\", \"22\"]);\n    });\n    it(\"should have correct order for Integer column\", async () => {\n      await gu.setRefShowColumn(\"Int\");\n      await gu.openColumnMenu(\"RefList\", \"Filter\");\n      assert.deepEqual(await getValues(), [\"2\", \"3\", \"22\"]);\n    });\n    it(\"should have correct order for Date column\", async () => {\n      await gu.setRefShowColumn(\"Date\");\n      await gu.openColumnMenu(\"RefList\", \"Filter\");\n      assert.deepEqual(await getValues(), [\"20-12-2011\", \"22-12-2011\", \"20-12-2021\"]);\n    });\n  });\n\n  describe(\"id mismatch\", function() {\n    // This test intent to replicate a bug that happened with filters. For the bug to happen we need\n    // to have a view field row id (here view field of col B) that matches the row id of another\n    // column (here col A). When this happen, and when col A is hidden, and when users open the\n    // column menu for B, the filter apply mistakingly to column A values as well, which could\n    // intail unexpected result depending on the values of A.\n\n    let docId2: string;\n    before(async () => {\n      docId2 = await mainSession.tempNewDoc(cleanup, \"ColumnFilterMenu3IdMismatch.grist\", { load: false });\n      const api = mainSession.createHomeApi();\n      await api.applyUserActions(docId2, [\n        [\"BulkAddRecord\", \"Table1\", [null, null, null], { A: [1, 3, 3], B: [1, 1, 3] }],\n        [\"RemoveRecord\", \"_grist_Views_section_field\", 1], // Hide 'A' column\n      ]);\n    });\n    it(\"filters should work correctly\", async function() {\n      await mainSession.loadDoc(`/doc/${docId2}/p/1`);\n\n      // filter B by {max: 2}\n      await gu.openColumnMenu(\"B\", \"Filter\");\n      await gu.setRangeFilterBound(\"max\", \"2\");\n      await driver.find(\".test-filter-menu-apply-btn\").click();\n\n      // check filter does not behaves in-correctly (here mostly to show what the problem looked\n      // like)\n      assert.notDeepEqual(\n        await gu.getVisibleGridCells({ cols: [\"B\"], rowNums: [1, 2, 3] }),\n        [\"1\", \"\", undefined],\n      );\n\n      // check filter does behave correctly\n      assert.deepEqual(\n        await gu.getVisibleGridCells({ cols: [\"B\"], rowNums: [1, 2, 3] }),\n        [\"1\", \"1\", \"\"],\n      );\n    });\n  });\n\n  describe(\"empty choice columns\", function() {\n    // Previously, a bug would cause an error to be thrown when filtering an empty\n    // choice or choice list column. This suite replicates that scenario.\n\n    async function assertEmptyRowCount(count: number) {\n      assert.deepEqual(\n        await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()),\n        [\"\"],\n      );\n      assert.deepEqual(\n        await driver.findAll(\".test-filter-menu-list .test-filter-menu-count\", e => e.getText()),\n        [count.toString()],\n      );\n    }\n\n    async function assertEmptyColumnIsFilterable(\n      columnType: \"Choice\" | \"Choice List\" | \"Reference List\",\n    ) {\n      const columnLabel = `Empty ${columnType}`;\n      await gu.addColumn(columnLabel);\n      await gu.setType(new RegExp(`${columnType}$`));\n      await gu.openColumnMenu(columnLabel, \"Filter\");\n      await assertEmptyRowCount(2);\n      await gu.sendKeys(Key.ESCAPE);\n    }\n\n    afterEach(() => gu.checkForErrors());\n\n    it(\"should not throw an error when filtering empty choice columns\", async function() {\n      await assertEmptyColumnIsFilterable(\"Choice\");\n    });\n\n    it(\"should not throw an error when filtering empty choice list columns\", async function() {\n      await assertEmptyColumnIsFilterable(\"Choice List\");\n    });\n\n    it(\"should not throw an error when filtering empty reference list columns\", async function() {\n      // Note: this wasn't impacted by the aforementioned bug; this test is only included for\n      // completeness.\n      await assertEmptyColumnIsFilterable(\"Reference List\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ColumnOps.ntest.js",
    "content": "\"use strict\";\nimport { assert, driver } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\nconst colHeaderScrollOpts = {block: \"start\", inline: \"end\"};\n\ndescribe(\"ColumnOps.ntest\", function() {\n  gu.bigScreen();\n  const cleanup = test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"World.grist\", true);\n    await gu.toggleSidePanel(\"left\", \"close\");\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should allow adding and deleting columns\", async function() {\n    await gu.clickColumnMenuItem(\"Name\", \"Insert column to the right\");\n    await $(\".test-new-columns-menu-add-new\").wait().click();\n    await gu.waitForServer();\n    // Newly created columns labels become editable automatically.  The next line checks that the\n    // label is editable and then closes the editor.\n    await gu.userActionsCollect(true);\n    await gu.getOpenEditingLabel(await gu.getColumnHeader(\"A\")).wait().sendKeys($.ENTER);\n    // Verify that no UserActions were actually sent.\n    await gu.waitForServer();\n    await gu.userActionsVerify([]);\n    await gu.userActionsCollect(false);\n    await gu.waitAppFocus();\n    await shouldHaveColumnHeader(\"A\");\n    await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader(\"A\")), false);\n\n    await gu.clickColumnMenuItem(\"A\", \"Delete column\");\n    await gu.waitForServer();\n    await assert.isPresent(gu.getColumnHeader(\"A\"), false);\n  });\n\n  it(\"should allow adding columns with new column menu\", async function() {\n    await gu.userActionsCollect(true);\n    await assert.isPresent(gu.getColumnHeader(\"A\"), false);\n    await $(\".mod-add-column\").scrollIntoView(true);\n    await $(\".mod-add-column\").click();\n    await gu.actions.selectFloatingOption(\"Add column\");\n    await gu.waitToPass(() => gu.getColumnHeader(\"A\"));\n    await gu.getOpenEditingLabel(await gu.getColumnHeader(\"A\")).wait().sendKeys($.ENTER);\n    await gu.waitForServer();\n    await gu.userActionsVerify([\n      [\"AddColumn\", \"City\", null, {\"_position\":null}],\n      [\"AddRecord\", \"_grist_Views_section_field\", null,\n        {\"colRef\":43, \"parentId\":4, \"parentPos\":null}]]);\n    await gu.userActionsCollect(false);\n    await gu.waitAppFocus();\n    await shouldHaveColumnHeader(\"A\");\n    await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader(\"A\")), false);\n    assert.deepEqual(await gu.getGridLabels(\"City\"),\n      [\"Name\", \"Country\", \"District\", \"Population\", \"A\"]);\n  });\n\n  it(\"should allow hiding columns\", async function() {\n    await shouldHaveColumnHeader(\"Name\");\n    await gu.getColumnHeader(\"Name\").scrollIntoView(colHeaderScrollOpts);\n    await gu.clickColumnMenuItem(\"Name\", \"Hide column\");\n    await gu.waitForServer();\n    await assert.isPresent(gu.getColumnHeader(\"Name\"), false);\n\n    await $(\".test-undo\").click();\n    await gu.waitForServer();\n    await shouldHaveColumnHeader(\"Name\");\n  });\n\n  it(\"[+] button should allow showing hidden columns\", async function() {\n    // Hide a column first\n    await shouldHaveColumnHeader(\"Name\");\n    await gu.getColumnHeader(\"Name\").scrollIntoView(colHeaderScrollOpts);\n    await gu.clickColumnMenuItem(\"Name\", \"Hide column\");\n    await gu.waitForServer();\n    await assert.isPresent(gu.getColumnHeader(\"Name\"), false);\n\n    // Then show it using the Add column menu\n    await $(\".mod-add-column\").scrollIntoView(true);\n    await $(\".mod-add-column\").click();\n    await showColumn(\"Name\");\n    await gu.waitForServer();\n    await shouldHaveColumnHeader(\"Name\");\n  });\n\n  it(\"[+] button show Add column directly if no hidden columns\", async function() {\n    await $(\".mod-add-column\").scrollIntoView(true);\n    await $(\".mod-add-column\").click();\n    await showColumn(\"Pop\");\n    await gu.waitForServer();\n    await shouldHaveColumnHeader(\"Pop. '000\");\n\n    await assert.isPresent(gu.getColumnHeader(\"B\"), false);\n    await $(\".mod-add-column\").scrollIntoView(true);\n    await $(\".mod-add-column\").click();\n    await $(\".test-new-columns-menu-add-new\").wait().click();\n    await gu.waitToPass(() => gu.getColumnHeader(\"B\"));\n    await gu.getOpenEditingLabel(await gu.getColumnHeader(\"B\")).wait().sendKeys($.ENTER);\n    await gu.waitForServer();\n    await gu.waitAppFocus();\n    await shouldHaveColumnHeader(\"B\");\n  });\n\n  it(\"should allow renaming columns\", async function() {\n    await gu.getColumnHeader(\"Name\").scrollIntoView(colHeaderScrollOpts);\n    await gu.clickColumnMenuItem(\"Name\", \"Rename column\");\n    await gu.getOpenEditingLabel(await gu.getColumnHeader(\"Name\")).sendKeys(\"Renamed\", $.ENTER);\n    await gu.waitForServer();\n    await shouldHaveColumnHeader(\"Renamed\");\n    assert.deepEqual(await gu.getGridValues({\n      cols: [\"Renamed\"],\n      rowNums: [3, 4, 5],\n    }), [\"A Coruña (La Coruña)\", \"Aachen\", \"Aalborg\"]);\n\n    // Verify that undo/redo works with renaming\n    await gu.undo();\n    await shouldHaveColumnHeader(\"Name\");\n    assert.deepEqual(await gu.getGridValues({\n      cols: [\"Name\"],\n      rowNums: [3, 4, 5],\n    }), [\"A Coruña (La Coruña)\", \"Aachen\", \"Aalborg\"]);\n    await gu.redo();\n    await shouldHaveColumnHeader(\"Renamed\");\n    assert.deepEqual(await gu.getGridValues({\n      cols: [\"Renamed\"],\n      rowNums: [3, 4, 5],\n    }), [\"A Coruña (La Coruña)\", \"Aachen\", \"Aalborg\"]);\n\n    // Refresh the page and check that the rename sticks.\n    await driver.navigate().refresh();\n    assert.equal(await $(\".active_section .test-viewsection-title\").wait().text(), \"CITY\");\n    await gu.waitForServer(5000);\n    await gu.clickCellRC(1, 4);\n    await shouldHaveColumnHeader(\"Renamed\");\n\n    // Check that it is renamed in the sidepane\n    await gu.openSidePane(\"field\");\n    assert.equal(await $(\".test-field-label\").val(), \"Renamed\");\n\n    // Check that both the label and the Id are changed when label and Id are linked\n    let deriveIdCheckbox = $(\".test-field-derive-id\");\n    assert.isTrue(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n    await deriveIdCheckbox.click();\n    await gu.waitForServer();\n    assert.isFalse(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n    assert(await $(\".test-field-col-id\").val(), \"Renamed\");\n\n    // Check that just the label is changed when label and Id are unlinked\n    await gu.getColumnHeader(\"Renamed\").scrollIntoView(colHeaderScrollOpts);\n    await gu.clickColumnMenuItem(\"Renamed\", \"Rename column\");\n    await gu.getOpenEditingLabel(await gu.getColumnHeader(\"Renamed\")).wait().sendKeys(\"foo\", $.ENTER);\n    await gu.waitForServer();\n    await shouldHaveColumnHeader(\"foo\");\n    await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader(\"foo\")), false);\n    assert.equal(await $(\".test-field-label\").val(), \"foo\");\n    assert(await $(\".test-field-col-id\").val(), \"Renamed\");\n\n    // Saving an identical column label should still close the input.\n    await gu.getColumnHeader(\"foo\").scrollIntoView(colHeaderScrollOpts);\n    await gu.clickColumnMenuItem(\"foo\", \"Rename column\");\n    await gu.userActionsCollect(true);\n    await gu.getOpenEditingLabel(await gu.getColumnHeader(\"foo\")).wait().sendKeys(\"foo\", $.ENTER);\n    await gu.waitForServer();\n    await gu.userActionsVerify([]);\n    await gu.userActionsCollect(false);\n    await assert.isPresent(gu.getOpenEditingLabel(gu.getColumnHeader(\"foo\")), false);\n    await gu.waitAppFocus();\n\n    // Bug T384: Should save the column name after cancelling a rename earlier.\n    await shouldHaveColumnHeader(\"A\");\n    await gu.getColumnHeader(\"A\").scrollIntoView(colHeaderScrollOpts);\n    await gu.clickColumnMenuItem(\"A\", \"Rename column\");\n    await gu.getOpenEditingLabel(await gu.getColumnHeader(\"A\")).wait().sendKeys(\"C\", $.ESCAPE);\n    await gu.waitForServer();\n    await shouldHaveColumnHeader(\"A\");\n    await gu.clickColumnMenuItem(\"A\", \"Rename column\");\n    await gu.getOpenEditingLabel(await gu.getColumnHeader(\"A\")).sendKeys(\"C\", $.TAB);\n    await gu.waitForServer();\n    await shouldHaveColumnHeader(\"C\");\n  });\n\n  it(\"should allow renaming columns with a click\", async function() {\n    // Go to a non-target column first\n    await gu.getColumnHeader(\"Population\").scrollIntoView(colHeaderScrollOpts);\n    await gu.getColumnHeader(\"Population\").click();\n    // Now select the column of interest\n    await gu.getColumnHeader(\"foo\").scrollIntoView(colHeaderScrollOpts);\n    await gu.getColumnHeader(\"foo\").click();\n    // And click one more time to rename\n    await gu.getColumnHeader(\"foo\").click();\n    await gu.getOpenEditingLabel(await gu.getColumnHeader(\"foo\")).sendKeys(\"foot\", $.ENTER);\n    await gu.waitForServer();\n    await shouldHaveColumnHeader(\"foot\");\n    await assert.isPresent(gu.getColumnHeader(\"foo\"), false);\n    // Click to rename back again\n    await gu.getColumnHeader(\"foot\").click();\n    await gu.getOpenEditingLabel(await gu.getColumnHeader(\"foot\")).sendKeys(\"foo\", $.ENTER);\n    await gu.waitForServer();\n    await shouldHaveColumnHeader(\"foo\");\n    await assert.isPresent(gu.getColumnHeader(\"foot\"), false);\n  });\n\n  it(\"should allow deleting multiple columns\", async function() {\n    await gu.actions.selectTabView(\"Country\");\n\n    // delete shortcut should delete all selected columns\n    await gu.selectGridArea([1, 0], [1, 1]);\n    await gu.sendKeys([$.ALT, \"-\"]);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getGridLabels(\"Country\"),\n      [\"Continent\", \"Region\", \"SurfaceArea\", \"IndepYear\", \"Population\", \"LifeExpectancy\",\n        \"GNP\", \"GNPOld\", \"LocalName\", \"GovernmentForm\", \"HeadOfState\", \"Capital\", \"Code2\"]);\n    // Undo to restore changes\n    await gu.undo(1, 5000);\n\n    // delete menu item should delete all selected columns\n    await gu.selectGridArea([1, 2], [1, 8]);\n    await gu.clickColumnMenuItem(\"SurfaceArea\", \"Delete\", true);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getGridLabels(\"Country\"),\n      [\"Code\", \"Name\", \"GNPOld\", \"LocalName\", \"GovernmentForm\", \"HeadOfState\", \"Capital\", \"Code2\"]);\n    // Undo to restore changes\n    await gu.undo(1, 5000);\n\n    // the delete shortcut should delete all columns in a cell selection as well\n    await gu.clickCellRC(2, 5);\n    await gu.sendKeys([$.SHIFT, $.RIGHT, $.RIGHT]);\n    await gu.sendKeys([$.ALT, \"-\"]);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getGridLabels(\"Country\"),\n      [\"Code\", \"Name\", \"Continent\", \"Region\", \"SurfaceArea\", \"GNP\", \"GNPOld\", \"LocalName\",\n        \"GovernmentForm\", \"HeadOfState\", \"Capital\", \"Code2\"]);\n    // Undo to restore changes\n    await gu.undo(1, 5000);\n\n    // Nudge first few columns back into view if they've drifted out of it.\n    await gu.toggleSidePanel(\"right\", \"close\");\n    await gu.sendKeys($.LEFT, $.LEFT, $.LEFT);\n\n    // opening a column menu outside the selection should move the selection\n    await gu.clickCellRC(2, 2);\n    await gu.sendKeys([$.SHIFT, $.RIGHT]);\n    await gu.clickColumnMenuItem(\"IndepYear\", \"Delete\", true);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getGridLabels(\"Country\"),\n      [\"Code\", \"Name\", \"Continent\", \"Region\", \"SurfaceArea\", \"Population\",\n        \"LifeExpectancy\", \"GNP\", \"GNPOld\", \"LocalName\", \"GovernmentForm\", \"HeadOfState\",\n        \"Capital\", \"Code2\"]);\n    // Undo to restore changes\n    await gu.undo();\n  });\n\n  it(\"should allow hiding multiple columns\", async function() {\n    await gu.actions.selectTabView(\"Country\");\n    await gu.openSidePane(\"view\");\n\n    // hide menu item should hide all selected columns\n    await gu.selectGridArea([1, 2], [1, 8]);\n    await gu.clickColumnMenuItem(\"SurfaceArea\", \"Hide 7 columns\", true);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getGridLabels(\"Country\"),\n      [\"Code\", \"Name\", \"GNPOld\", \"LocalName\", \"GovernmentForm\", \"HeadOfState\", \"Capital\", \"Code2\"]);\n    assert.deepEqual(await $(\".test-vfc-visible-fields .kf_draggable_content\").array().text(),\n      [\"Code\", \"Name\", \"GNPOld\", \"LocalName\", \"GovernmentForm\", \"HeadOfState\", \"Capital\", \"Code2\"]);\n    assert.deepEqual(await $(\".test-vfc-hidden-fields .kf_draggable_content\").array().text(),\n      [\"Name\", \"Continent\", \"Region\", \"SurfaceArea\", \"IndepYear\", \"Population\",\n        \"LifeExpectancy\", \"GNP\", \"Self\"]);\n    // Undo to restore changes\n    await gu.undo(1, 5000);\n\n    assert.deepEqual(await gu.getGridLabels(\"Country\"),\n      [\"Code\", \"Name\", \"Continent\", \"Region\", \"SurfaceArea\", \"IndepYear\", \"Population\",\n        \"LifeExpectancy\", \"GNP\", \"GNPOld\", \"LocalName\", \"GovernmentForm\", \"HeadOfState\",\n        \"Capital\", \"Code2\"]);\n\n  });\n});\n\nasync function showColumn(name) {\n  await gu.waitForServer();\n  await $(`.test-new-columns-menu-hidden-column-inlined:contains(${name})`).click();\n}\n\nasync function shouldHaveColumnHeader(name) {\n  await gu.waitToPass(() => gu.getColumnHeader(name));\n  await assert.isPresent(gu.getColumnHeader(name), true);\n}\n"
  },
  {
    "path": "test/nbrowser/ColumnTransform.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"ColumnTransform\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    const session = await gu.session().login();\n    await session.tempNewDoc(cleanup, \"Transform\");\n  });\n\n  it(\"should disable changing name of a column during transformation\", async function() {\n    await gu.sendActions([\n      [\"AddRecord\", \"Table1\", null, { A: \"Test\" }],\n    ]);\n    await gu.selectColumn(\"A\");\n    await gu.openColumnPanel();\n\n    await gu.setType(\"Numeric\", {\n      apply: false,\n    });\n\n    // Now try to change the column name using the UI.\n    await gu.openColumnMenu(\"A\");\n    await gu.findOpenMenu();\n\n    // The 'Rename' option should be disabled.\n    assert.isTrue(await driver.findContentWait(\"li\", /Rename column/, 1000).matches(\".disabled\"));\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n\n    // Try to rename using shortcut (cmd+M on Mac, ctrl+M otherwise).\n    await gu.sendKeys(Key.chord(await gu.modKey(), \"m\"));\n    await driver.sleep(50);\n    // Menu should not be opened, we can't rename column during transformation.\n    assert.isFalse(await driver.find(\".test-column-title-popup\").isPresent());\n\n    // Try to rename by dbl click on column header.\n    await gu.dbClick(gu.getColumnHeader(\"A\"));\n    await driver.sleep(50);\n    // Inline editor should not appear.\n    assert.isFalse(await driver.find(\".test-column-title-popup\").isPresent());\n\n    // Cancel the transformation.\n    await driver.find(\".test-type-transform-cancel\").click();\n    await gu.waitForServer();\n\n    // Now renaming should be possible.\n    await gu.selectColumn(\"A\");\n    await gu.sendKeys(Key.chord(await gu.modKey(), \"m\"));\n    await driver.sleep(50);\n    assert.isTrue(await driver.find(\".test-column-title-popup\").isPresent());\n    await gu.sendKeys(Key.ESCAPE);\n    await driver.sleep(50);\n    assert.isFalse(await driver.find(\".test-column-title-popup\").isPresent());\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Comments.ts",
    "content": "import { TableColValues } from \"app/common/DocActions\";\nimport { UserAPIImpl } from \"app/common/UserAPI\";\nimport { arrayRepeat } from \"app/plugin/gutil\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { modKey, Session } from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\nlet docId: string;\nlet MODKEY: string;\nlet session: Session;\nlet currentApi: UserAPIImpl;\nlet ownerApi: UserAPIImpl;\n\ndescribe(\"Comments\", function() {\n  this.timeout(\"8m\");\n  const cleanup = setupTestSuite();\n  afterEach(() => gu.checkForErrors());\n  const chimpy = gu.translateUser(\"user1\");\n  const kiwi = gu.translateUser(\"user3\");\n  const notification = \".test-draft-notification\";\n  gu.bigScreen(\"big\");\n\n  before(async function() {\n    session = await gu.session().teamSite.login();\n    MODKEY = await modKey();\n    currentApi = session.createHomeApi();\n    ownerApi = currentApi;\n  });\n\n  it(\"should not render markdown on edits\", async function() {\n    // Create a new document and add a comment.\n    docId = (await session.tempShortDoc(cleanup, \"Hello.grist\")).id;\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForInput();\n\n    const typeSomeMarkdown = async () => {\n      await gu.sendKeys(\"# Heading\"); // This is block level markdown, so it should be rendered as h1.\n      await shiftEnter();\n      await gu.sendKeys(\"**bold**\");\n      await shiftEnter();\n      await gu.sendKeys(\"*italic*\");\n      await shiftEnter();\n      await gu.sendKeys(\"[Grist](https://gristlabs.com)\");\n\n      // And mention someone.\n      await shiftEnter();\n      await shiftEnter();\n\n      await gu.sendKeys(\"Hello @\");\n      await selectUser(/Chimpy/);\n    };\n\n    // Save the comment.\n    await typeSomeMarkdown();\n    await enter();\n\n    // Make sure we see the comment rendered properly.\n\n    const assertRenderedOk = async (comment: WebElement) => {\n      // First just plain text.\n      assert.equal(\n        await comment.find(\".test-discussion-comment-text\").getText(),\n        \"Heading\\nbold italic\\nGrist\\nHello @Chimpy\",\n      );\n\n      // Second, the dom is actually rendered as html.\n      assert.equal(await comment.find(\"h1\").getText(), \"Heading\");\n      assert.equal(await comment.find(\"strong\").getText(), \"bold\");\n      assert.equal(await comment.find(\"em\").getText(), \"italic\");\n      assert.equal(await comment.find(\"a.grist-mention\").getText(), \"@Chimpy\");\n      assert.equal(await comment.find(\"a:not(.grist-mention)\").getAttribute(\"href\"), `https://gristlabs.com/`);\n      assert.equal(await comment.find(\"a:not(.grist-mention)+span\").getText(), \"Grist\");\n    };\n\n    await assertRenderedOk(await findComment(0));\n    // Now edit that comment\n    await openCommentMenu(0);\n    await clickMenuItem(\"Edit\");\n    await waitForInput(\"edit\");\n\n    // And make sure we see markdown text, but the mention is actually a link rendered as before.\n    assert.equal(\n      await getEditorText(\"edit\"),\n      \"# Heading\\n**bold**\\n*italic*\\n[Grist](https://gristlabs.com)\\n\\nHello @Chimpy\",\n    );\n    assert.equal(await getEditor(\"edit\").find(\"a.grist-mention\").getText(), \"@Chimpy\");\n    // Make sure we don't have h1, strong or em tags there.\n    for (const tag of [\"h1\", \"strong\", \"em\"]) {\n      assert.isFalse(await getEditor(\"edit\").find(tag).isPresent(), `Should not have ${tag} tag in the editor`);\n    }\n\n    // Save it once again and make sure it is rendered the same way.\n    await enter();\n    await assertRenderedOk(await findComment(0));\n\n    // Sanity check that replies also work the same way.\n    await waitForInput(\"add\");\n    await typeSomeMarkdown();\n    await enter();\n    await assertRenderedOk(await getReply(0, 0));\n  });\n\n  it(\"should open the popup by using anchor link\", async function() {\n    // Create a new document and add a comment.\n    docId = (await session.tempShortDoc(cleanup, \"Hello.grist\")).id;\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForInput();\n    await gu.sendKeys(\"Hello from Chimpy\");\n    await enter();\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n\n    // Copy the anchor link to this cell.\n    let anchor = await gu.getAnchor();\n\n    // Change its type to the a4 (reserved now for the comments popup).\n    anchor = anchor.replace(/#a\\d+/, \"#a4\");\n\n    // Now open this link.\n    await driver.get(anchor);\n\n    // We should see the comments popup.\n    await gu.waitForAnchor();\n    await waitForPopup(\"filled\");\n\n    // Make sure we see the comment.\n    assert.equal(await readComment(0), \"Hello from Chimpy\");\n\n    // Make sure it also works via navigation.\n    await session.loadDocMenu(\"/o/docs\");\n    await driver.get(anchor);\n    await gu.waitForAnchor();\n    await waitForPopup(\"filled\");\n    assert.equal(await readComment(0), \"Hello from Chimpy\");\n  });\n\n  it(\"should open the popup by using anchor link without p\", async function() {\n    // Create a new document and add a comment.\n    docId = (await session.tempShortDoc(cleanup, \"Hello.grist\")).id;\n\n    // Add new table, to make it the first one.\n    await gu.addNewTable(\"Apples\");\n    // Add new page with Table2, it will be the second one.\n    await gu.addNewPage(\"Table\", \"Table1\");\n    // Remove the first page.\n    await gu.removePage(\"Table1\");\n\n    // Now copy the anchor link\n    await gu.openPage(\"New page\");\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForInput();\n    await gu.sendKeys(\"Hello from Chimpy\");\n    await enter();\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n\n    // Copy the anchor link to this cell.\n    let anchor = await gu.getAnchor();\n\n    // Change its type to the a4 (reserved now for the comments popup).\n    anchor = anchor.replace(/#a\\d+/, \"#a4\");\n    // Remove the 'p' parameter.\n    anchor = anchor.replace(/\\/p\\/\\d+/, \"\");\n\n    // Now open this link.\n    await driver.get(\"about:blank\"); // to make sure we are not reusing the same page\n    await driver.sleep(100);\n    await driver.get(anchor);\n\n    // We should see the comments popup.\n    await gu.waitForAnchor();\n    await gu.checkForErrors();\n    await waitForPopup(\"filled\");\n\n    // Make sure we see the comment.\n    assert.equal(await readComment(0), \"Hello from Chimpy\");\n  });\n\n  it(\"viewers can see read-only comments\", async function() {\n    // Create a new document and add a comment.\n    docId = (await session.tempShortDoc(cleanup, \"Hello.grist\")).id;\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForInput();\n    await gu.sendKeys(\"Hello from Chimpy\");\n    await enter();\n\n    // Share the document with Kiwi as a viewer.\n    await ownerApi.updateDocPermissions(docId, {\n      users: {\n        [kiwi.email]: \"viewers\",\n      },\n    });\n\n    // Now login as viewer and check that we can see the comment.\n    await asUser(kiwi);\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"filled\");\n    assert.equal(await readComment(0), \"Hello from Chimpy\");\n\n    // Make sure we don't see editor.\n    assert.isFalse(await driver.find(\".test-discussion-comment-input\").isPresent());\n\n    // Open the menu make sure we don't see edit button.\n    await openCommentMenu(0);\n    assert.deepEqual(await menuOptions(), [\"Copy link\", \"Resolve\", \"Remove thread\", \"Edit\"]);\n    assert.deepEqual(await disabledMenuOptions(), [\"Resolve\", \"Remove thread\", \"Edit\"]);\n\n    // Click on B,1\n    await gu.getCell(\"B\", 1).click();\n    // Try to open comments with shortcut.\n    await openCommentsWithKey();\n    // Wait a bit (as this is async).\n    await driver.sleep(100);\n    // Make sure menu is not opened.\n    await assertNoPopup();\n\n    // Open the panel and check that we see the comment.\n    await openPanel();\n    assert.equal(await commentCount(\"panel\"), 1);\n    assert.equal(await readComment(0, \"panel\"), \"Hello from Chimpy\");\n\n    // Make sure we don't see the reply button.\n    assert.isFalse(await driver.find(\".test-discussion-panel .test-discussion-comment-reply-button\").isPresent());\n    await asOwner();\n  });\n\n  it(\"should restore long comment\", async function() {\n    docId = (await session.tempShortDoc(cleanup, \"Hello.grist\")).id;\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForInput(\"start\");\n    const longText = \"This is long comment more than 20 letters\";\n    await gu.sendKeys(longText);\n    // Click away for the discard popup to be showing\n    await gu.getCell(\"B\", 1).click();\n    await assertNoPopup();\n    // We should see the discard notification.\n    assert.isTrue(await driver.findWait(notification, 100).isDisplayed());\n    // Click it to undo the discard.\n    await driver.find(notification).click();\n    // We should see the popup once again.\n    await waitForInput(\"start\");\n    // With a text in it.\n    assert.equal(await getEditorText(\"start\"), longText);\n    // Notification should be gone.\n    assert.isFalse(await driver.find(notification).isPresent());\n    // Send this comment.\n    await enter();\n\n    // Now reply to this comment.\n    const longText2 = \"2\" + longText;\n    await waitForInput(\"add\");\n    await gu.sendKeys(longText2);\n\n    // Click away and test that we see the reply restored.\n    await gu.getCell(\"C\", 1).click();\n    await assertNoPopup();\n    assert.isTrue(await driver.findWait(notification, 100).isDisplayed());\n    await driver.find(notification).click();\n    await waitForInput(\"add\");\n    assert.equal(await getEditorText(\"add\"), longText2);\n    assert.isFalse(await driver.find(notification).isPresent());\n\n    // Make sure that the cursor is on the A,1.\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 1, col: 0 });\n  });\n\n  it(\"should strip html from comment\", async function() {\n    docId = (await session.tempShortDoc(cleanup, \"Hello.grist\")).id;\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForInput(\"start\");\n    await gu.sendKeys(\"Hello from Chimpy\");\n    await enter();\n\n    // Now replace the content using API.\n    await gu.sendActions([\n      [\"UpdateRecord\", \"_grist_Cells\", 1, { content: JSON.stringify({ text: \"<b>bolded</b>\" }) }],\n    ]);\n\n    // Make sure we see literal text, not html.\n    assert.equal(await readComment(0), \"<b>bolded</b>\");\n  });\n\n  it(\"should add a new line by pressing shift enter\", async function() {\n    docId = (await session.tempShortDoc(cleanup, \"Hello.grist\")).id;\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForInput(\"start\");\n    // Pressing shift enter should'n send the comment, and shouldn't add new line in rendered comment.\n    await gu.sendKeys(\"Hello from\");\n    await shiftEnter();\n    await gu.sendKeys(\"Chimpy!\");\n    await enter();\n\n    // We are rendering comments like markdown, so shift enter doesn't work here. This is consistent with\n    // how cell content is rendered.\n    assert.equal(await readComment(0), \"Hello from Chimpy!\");\n\n    // Now add a new line using shift+enter\n    await waitForInput(\"add\");\n    await gu.sendKeys(\"Hello from\");\n    await shiftEnter();\n    await shiftEnter();\n    await gu.sendKeys(\"Chimpy!\");\n    await enter();\n    assert.equal(await readReply(0, 0), \"Hello from\\nChimpy!\");\n  });\n\n  it(\"should close mention box on click away\", async function() {\n    docId = (await session.tempShortDoc(cleanup, \"Hello.grist\")).id;\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForInput(\"start\");\n    await gu.sendKeys(\"Testing testing @\");\n    await waitForMentionList();\n\n    // The only way to accept the mention is by clicking on the item or pressing enter, everything else\n    // should result with cancellation.\n    await pressSend();\n    // We should see exactly the same text.\n    await gu.waitForMenuToClose();\n    assert.equal(await readComment(0), \"Testing testing @\");\n    assert.lengthOf(await getMentions(await findComment(0)), 0);\n\n    // Do the same in the reply box.\n    await waitForInput(\"add\");\n    await gu.sendKeys(\"Replying to @\");\n    await waitForMentionList();\n    await pressSend();\n    await gu.waitForMenuToClose();\n\n    assert.equal(await readReply(0, 0), \"Replying to @\");\n    assert.lengthOf(await getMentions(await getReply(0, 0)), 0);\n\n    // Do the same in the edit box.\n    await openCommentMenu(0);\n    await clickMenuItem(\"Edit\");\n    await waitForInput(\"edit\");\n\n    await clearEditor(\"edit\");\n    await gu.sendKeys(\"Editing Editing @\");\n    await waitForMentionList();\n    await pressButton(\"Save\");\n    assert.equal(await readComment(0), \"Editing Editing @\");\n    assert.lengthOf(await getMentions(await findComment(0)), 0);\n    await gu.waitForMenuToClose();\n\n    // Now click away and make sure the mention box is closed.\n    await waitForInput(\"add\");\n    await gu.sendKeys(\"Testing2 Testing2 @\");\n    await waitForMentionList();\n\n    // Click on the first comment to simulate clicking away.\n    await (await findComment(0)).click();\n    await gu.waitForMenuToClose();\n    assert.equal(await getEditorText(\"add\"), \"Testing2 Testing2 @\");\n\n    // Now test comments panel.\n    await openPanel();\n    // Start replying to the first comment.\n    await pressReply(0, \"panel\");\n    await waitForInput(\"reply\");\n    await gu.sendKeys(\"Replying to @\");\n    await waitForMentionList();\n    await gu.getCell(\"A\", 1).click(); // click away\n    await gu.waitForMenuToClose();\n    assert.equal(await getEditorText(\"reply\"), \"Replying to @\");\n  });\n\n  it(\"should properly show my threads\", async function() {\n    // Invite Chimpy to the org.\n    await ownerApi.updateOrgPermissions(\"current\", {\n      users: {\n        [kiwi.email]: \"owners\",\n      },\n    });\n    docId = (await session.tempShortDoc(cleanup, \"Hello.grist\")).id;\n\n    // Add a comment to cell A,1\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForInput(\"start\");\n    await gu.sendKeys(\"Hello from Chimpy\");\n    await enter();\n\n    // Now as Kiwi do the same for cell A,2\n    await asUser(kiwi);\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    await waitForInput(\"start\");\n    await gu.sendKeys(\"Hello from Kiwi\");\n    await enter();\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n\n    // And in A,3, but here mention Chimpy.\n    await gu.getCell(\"A\", 3).click();\n    await openCommentsWithKey();\n    await waitForInput(\"start\");\n    await gu.sendKeys(\"Kiwi mentions @Ch\");\n    await selectUser(/Chimpy/);\n    await enter();\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n\n    // Now switch my threads option, we shouldn't see Chimpy's comment\n    await openPanel();\n    await panelOptions({ my: true });\n\n    assert.equal(await commentCount(\"panel\"), 2);\n    // Make sure we see Kiwi's comment.\n    assert.equal(await readComment(0, \"panel\"), \"Hello from Kiwi\");\n    assert.equal(await readComment(1, \"panel\"), \"Kiwi mentions @Chimpy\");\n\n    // Now switch to Chimpy and check this setting again.\n    await asUser(chimpy);\n    await openPanel();\n    await panelOptions({ my: true });\n    assert.equal(await commentCount(\"panel\"), 2);\n    assert.equal(await readComment(0, \"panel\"), \"Hello from Chimpy\");\n    assert.equal(await readComment(1, \"panel\"), \"Kiwi mentions @Chimpy\");\n    await session.resetSite();\n  });\n\n  it(\"allows to mention users\", async function() {\n    // Chimpy is the owner of the org.\n    docId = await session.tempNewDoc(cleanup, \"Hello\", { load: false });\n\n    // Create another doc in this workspace.\n    const homeWs = await ownerApi.getOrgWorkspaces(session.teamSite.orgDomain)\n      .then(list => list.find(w => w.name === \"Home\")?.id ?? null);\n    const secondDoc = await session.forWorkspace(\"Home\").tempNewDoc(cleanup, \"Hello2\", { load: false });\n\n    // Add an Charon as an Owner of the Home workspace\n    const charon = gu.translateUser(\"user2\");\n    await ownerApi.updateOrgPermissions(session.teamSite.orgDomain, {\n      users: {\n        [charon.email]: \"editors\",\n      },\n    });\n    await ownerApi.updateWorkspacePermissions(homeWs!, {\n      maxInheritedRole: null,\n      users: {\n        [charon.email]: \"owners\",\n      },\n    });\n\n    // Add Kiwi as a guest to the second document (he is now guest in the Home workspace, and doesn't have any\n    // access to the first document).\n    await ownerApi.updateDocPermissions(secondDoc, {\n      users: {\n        [kiwi.email]: \"editors\",\n      },\n    });\n\n    // Break the inheritance for this document, and share it with Ham as a guest. Ham will see only collaborators, but\n    // Charon and Chimpy will see other users also. Charon will see Kiwi - who is guest in this workspace, and Chimpy\n    // will see Support user, who is editor in the Org (but doesn't have access to this document).\n    const ham = gu.translateUser(\"user4\");\n    await ownerApi.updateDocPermissions(docId, {\n      maxInheritedRole: null,\n      users: {\n        [ham.email]: \"editors\",\n        [charon.email]: \"editors\",\n      },\n    });\n\n    // Login as all users, to make sure names are stored.\n    await asUser(ham);\n    await asUser(kiwi, false);\n\n    // Now we have this situation:\n    // For org:\n    // - Chimpy - owner\n    // - Support - editor\n    // - Kiwi - guest\n    // - Charon - editor\n    // - Ham - guest\n    // For Home workspace:\n    // - Chimpy - team owner (forced)\n    // - Support - no access (workspace doesn't inherit from org)\n    // - Kiwi - guest (editor on the second document)\n    // - Charon - owner\n    // - Ham - guest - (editor on first document)\n    // For first document:\n    // - Chimpy - owner (forced access by being owner of the org)\n    // - Support - no access\n    // - Kiwi - no access\n    // - Charon - editor (and owner of parent resource)\n    // - Ham - editor (and guest in ws)\n    // For second document:\n    // - Kiwi - editor (guest in ws)\n    // - Chimpy - owner (forced by being owner of the org)\n    // - Charon - owner (inherited from workspace)\n    // - Ham - no access\n    // - Support - no access\n\n    // Now login as charon - owner of the workspace, org's guest, and document editor.\n    await asUser(charon);\n\n    // Start adding a comment to first cell.\n    await gu.sendActions([\n      [\"AddRecord\", \"Table1\", null, {}],\n    ]);\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForInput();\n    // Send keys to mention someone\n    await gu.sendKeys(\"Hello @\");\n\n    // Charon should see Chimpy, Kiwi, Charon, and Ham\n    // - Chimpy as he is owner of the org\n    // - Kiwi as he is guest in the Home workspace (but is disabled)\n    // - Ham as he is an editor.\n    assert.deepEqual(await readUsers(), [charon.name, kiwi.name, chimpy.name, ham.name].sort());\n    // No one is disabled.\n    assert.deepEqual(await disabledList(), [kiwi.name]);\n    await selectUser(/Ham/);\n    await waitForInput();\n    await gu.sendKeys(\"in this doc\");\n    await enter();\n\n    // Do the same for Chimpy in reply box.\n    await waitForInput();\n    await gu.sendKeys(\"Hello @\");\n    await selectUser(/Chimpy/);\n    await gu.sendKeys(\"!!\");\n    await enter();\n\n    // And check if we see the comment.\n    assert.equal(await readComment(0), \"Hello @Ham in this doc\");\n    assert.equal(await readReply(0, 0), \"Hello @Chimpy !!\");\n\n    // Clean up.\n    await asOwner();\n    await session.resetSite();\n  });\n\n  it(\"should be able to delete tables and columns as editors\", async function() {\n    docId = await session.tempNewDoc(cleanup, \"Hello3\");\n    await ownerApi.updateOrgPermissions(\"current\", {\n      users: {\n        [gu.translateUser(\"support\").email]: \"editors\",\n      },\n    });\n\n    // It wasn't possible to delete columns or tables with comments owned by other users (as non owner).\n    // Steps:\n    // 1. Add comment as an owner to cell A,1\n    // 2. Login as editor, and try to delete column A.\n    // 3. Try to delete table.\n\n    await gu.addNewTable(\"Table2\");\n    await gu.getCell(\"A\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"1\");\n    await gu.getCell(\"A\", 1).click();\n\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await waitForInput();\n    await gu.sendKeys(\"Owners comment\");\n    await enter();\n    await assertNoPopup();\n\n    await asSupport();\n    await gu.openPage(\"Table2\");\n    await gu.getCell(\"A\", 1).click();\n    // Remove this column.\n    await gu.deleteColumn(\"A\");\n    await gu.checkForErrors();\n    assert.deepEqual(await gu.getColumnNames(), [\"B\", \"C\"]);\n\n    // Remove this table.\n    await gu.removeTable(\"Table2\");\n    await gu.checkForErrors();\n\n    // Check it was deleted from raw data.\n    await driver.find(\".test-tools-raw\").click();\n    await driver.findWait(\".test-raw-data-list\", 2000);\n    await gu.waitForServer();\n    assert.deepEqual(\n      await driver.findAll(\".test-raw-data-table-id\", e => e.getText()),\n      [\"Table1\"],\n    );\n\n    await asOwner();\n  });\n\n  it(\"should support basic comments operation\", async function() {\n    docId = (await session.tempDoc(cleanup, \"Hello.grist\", { load: false })).id;\n    await asOwner();\n    await gu.getCell(\"A\", 3).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await waitForInput();\n    await gu.sendKeys(\"This is first comment\");\n    await enter();\n    assert.equal(await commentCount(), 1);\n    const comments = await getCommentsData();\n    assert.equal(comments.length, 1);\n    assert.equal(comments[0].text, \"This is first comment\");\n    assert.equal(comments[0].nick, \"Chimpy\");\n    assert.equal(comments[0].time, \"a few seconds ago\");\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    assert.equal(await commentCount(), 0);\n    await waitForInput();\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n  });\n\n  it(\"should read comment as a different user\", async function() {\n    await asSupport();\n    await gu.getCell(\"A\", 3).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"filled\");\n    const comments = await getCommentsData();\n    assert.equal(comments.length, 1);\n    assert.equal(comments[0].text, \"This is first comment\");\n    assert.equal(comments[0].nick, \"Chimpy\");\n    assert.equal(comments[0].time, \"a few seconds ago\");\n    await gu.getCell(\"A\", 1).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await waitForInput(\"start\");\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n  });\n\n  it(\"should reply as a different user\", async function() {\n    await gu.getCell(\"A\", 3).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"filled\");\n    await waitForInput();\n    await gu.sendKeys(\"Replying from support\");\n    await enter();\n    const replies = await getRepliesData(0);\n    assert.equal(replies.length, 1);\n    assert.equal(replies[0].text, \"Replying from support\");\n    assert.equal(replies[0].nick, \"Support\");\n    assert.equal(replies[0].time, \"a few seconds ago\");\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n  });\n\n  it(\"should read reply from a different user\", async function() {\n    await asOwner();\n    await gu.getCell(\"A\", 3).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"filled\");\n    const replies = await getRepliesData(0);\n    assert.equal(replies.length, 1);\n    assert.equal(replies[0].text, \"Replying from support\");\n    assert.equal(replies[0].nick, \"Support\");\n    assert.equal(replies[0].time, \"a few seconds ago\");\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n  });\n\n  it(\"should support all editor buttons on empty comment editor\", async function() {\n    await asSupport();\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    // Sending escape should close the popup\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n\n    // Pressing escape with filled entry should also close the popup.\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await gu.sendKeys(\"Random comment\");\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n\n    // Text should be empty after pressing escape.\n    await gu.waitAppFocus();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    assert.isEmpty(await getEditorText(\"start\"));\n\n    // Cancel button should work\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await pressCancel();\n    await assertNoPopup();\n\n    // Clicking away should close the popup\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await gu.getCell(\"A\", 1).click();\n    await assertNoPopup();\n\n    // Clicking away with text filled in should close without saving.\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await waitForInput(\"start\");\n    await gu.sendKeys(\"Random comment\");\n    await gu.getCell(\"A\", 1).click();\n    await assertNoPopup();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    assert.isEmpty(await getEditorText(\"start\"));\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Clicking comment should not send the empty comment.\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    assert.isTrue(await isSendDisabled());\n    await pressSend();\n    // Empty popup should be still there.\n    await waitForPopup(\"empty\");\n    // And button should be still disabled\n    assert.isTrue(await isSendDisabled());\n    // And we should not see any comments\n    // Writing something should enable the button.\n    await driver.find(\".test-discussion-comment-input\").click();\n    await gu.sendKeys(\"Random comment\");\n    assert.isFalse(await isSendDisabled());\n    // Button to send should work\n    await pressSend();\n    await waitForPopup(\"filled\");\n    assert.equal(await commentCount(), 1);\n    assert.equal((await getCommentsData())[0].text, \"Random comment\");\n    // Undo last comment, should be empty again.\n    await gu.undo();\n    await gu.getCell(\"A\", 2).click();\n    await assertNoPopup();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await gu.redo();\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"filled\");\n    assert.equal(await commentCount(), 1);\n    assert.equal((await getCommentsData())[0].text, \"Random comment\");\n    await gu.undo();\n    await assertNoPopup();\n  });\n\n  it(\"should support all editor buttons on filled comment editor\", async function() {\n    await asOwner();\n\n    // Add some comment\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await waitForInput(\"start\");\n    await gu.sendKeys(\"Sample comment\");\n    await enter();\n    await waitForPopup(\"filled\");\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n\n    // Make sure button is disabled by default\n    await openCommentsWithKey();\n    await waitForPopup(\"filled\");\n    await waitForInput();\n    assert.isTrue(await isSendDisabled());\n    await gu.sendKeys(\"Some text\");\n    assert.isFalse(await isSendDisabled());\n    await clearEditor(\"add\");\n    assert.isTrue(await isSendDisabled());\n    // Pressing it shouldn't do anything.\n    await pressSend();\n    assert.isTrue(await isSendDisabled());\n    assert.equal(await commentCount(), 1);\n    // Sending spaces should be not allowed\n    await clearEditor(\"add\");\n    await gu.sendKeys(\"        \");\n    assert.isTrue(await isSendDisabled());\n    await pressSend();\n    assert.equal(await commentCount(), 1);\n\n    // Escape should close the popup even when not in text area\n    await driver.find(\".test-discussion-comment-nick\").click();\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n  });\n\n  it(\"should cancel comments edit\", async function() {\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    const editComment = async () => {\n      await openCommentMenu(0);\n      await clickMenuItem(\"Edit\");\n      await waitForEditor(\"edit\");\n      assert.equal(await getEditorText(\"edit\"), \"Sample comment\");\n      await clearEditor(\"edit\");\n      await gu.sendKeys(\"Edited\");\n    };\n    const checkNoChange = async () => {\n      await assertNoEditor(\"edit\");\n      assert.equal(await readComment(0), \"Sample comment\");\n    };\n    // Now test if cancel works.\n    await editComment();\n    await pressCancel();\n    await checkNoChange();\n\n    // Now test that escape works\n    await editComment();\n    await gu.sendKeys(Key.ESCAPE);\n    await checkNoChange();\n\n    // Do it once again but with click away\n    await editComment();\n    await gu.getCell(\"A\", 1).click();\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    await waitForPopup();\n    await checkNoChange();\n    await gu.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should support comments edit\", async function() {\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"filled\");\n    await openCommentMenu(0);\n    await clickMenuItem(\"Edit\");\n    await waitForEditor(\"edit\");\n    assert.equal(await getEditorText(\"edit\"), \"Sample comment\");\n    await clearEditor(\"edit\");\n    await gu.sendKeys(\"Edited\");\n    await enter();\n    assert.equal(await readComment(0), \"Edited\");\n  });\n\n  it(\"should allow removing comment and reply\", async function() {\n    await asSupport();\n    // Currently we have a single comment in A,2\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"filled\");\n    assert.equal(await commentCount(), 1);\n    // Add a reply to the edited comment.\n    await waitForInput();\n    await gu.sendKeys(\"Reply to edited comment\");\n    await enter();\n    // Remove reply\n    const revert = await gu.begin();\n    await openReplyMenu(0, 0);\n    await clickMenuItem(\"Remove\");\n    assert.equal(await replyCount(0), 0);\n    await revert();\n    await asOwner();\n    // Now remove comment\n    await gu.getCell(\"A\", 2).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"filled\");\n    assert.equal(await commentCount(), 1);\n    assert.equal(await replyCount(0), 1);\n    await openCommentMenu(0);\n    await clickMenuItem(\"Remove\"); // can remove as this is my thread.\n    await waitForPopup(\"empty\");\n    await gu.checkForErrors();\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n  });\n\n  it(\"should mark cells with a triangle\", async function() {\n    await gu.getCell(\"A\", 2).click();\n    assert.isTrue(await hasComment(\"A\", 3));\n    assert.isFalse(await hasComment(\"A\", 2));\n    assert.isFalse(await hasComment(\"A\", 1));\n  });\n\n  it(\"should resolve comments\", async function() {\n    await gu.getCell(\"A\", 3).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"filled\");\n    assert.equal(await commentCount(), 1);\n    await openCommentMenu(0);\n    await clickMenuItem(\"Resolve\");\n    await waitForPopup(\"empty\");\n  });\n\n  it(\"show comments on panel\", async function() {\n    await openPanel();\n    await panelOptions({ resolved: false });\n    // We should not see any comments (there are 2 resolved);\n    assert.equal(await commentCount(\"panel\"), 0);\n    assert.equal(await countText(), \"0 comments\");\n    // Show resolved comments\n    await panelOptions({ resolved: true });\n    assert.equal(await commentCount(\"panel\"), 1);\n    assert.equal(await countText(), \"1 comment\");\n    assert.isTrue(await isCommentResolved(0, \"panel\"));\n    // Add another one\n    await gu.getCell(\"B\", 2).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await waitForInput();\n    await gu.sendKeys(\"B comment\");\n    await enter();\n    assert.equal(await commentCount(\"panel\"), 2);\n    assert.equal(await countText(), \"2 comments\");\n    assert.isTrue(await isCommentResolved(0, \"panel\"));\n    assert.isFalse(await isCommentResolved(1, \"panel\"));\n  });\n\n  it(\"allows to open last resolved comment\", async function() {\n    // The first comment in the panel is resolved and we don't see option to open it.\n    await openCommentMenu(0, \"panel\");\n    assert.deepEqual(await menuOptions(), [\"Remove thread\"]);\n    // The second comment is not resolved and we see normal options.\n    await openCommentMenu(1, \"panel\");\n    assert.deepEqual(await menuOptions(), [\"Resolve\", \"Remove thread\", \"Edit\"]);\n    const revert = await gu.begin();\n    // Resolve the second comment and check that we can see open option.\n    await clickMenuItem(\"Resolve\");\n    assert.isTrue(await isCommentResolved(0, \"panel\"));\n    assert.isTrue(await isCommentResolved(1, \"panel\"));\n\n    await openCommentMenu(0, \"panel\");\n    assert.deepEqual(await menuOptions(), [\"Remove thread\"]);\n    await openCommentMenu(1, \"panel\");\n    assert.deepEqual(await menuOptions(), [\"Open\", \"Remove thread\"]);\n    // Open the second comment\n    await clickMenuItem(\"Open\");\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n\n    assert.isTrue(await isCommentResolved(0, \"panel\"));\n    assert.isFalse(await isCommentResolved(1, \"panel\"));\n\n    await revert();\n  });\n\n  it(\"should support basic operations on panel\", async function() {\n    await openPanel();\n    // Edit second comment for B\n    await openCommentMenu(1, \"panel\");\n    await clickMenuItem(\"Edit\");\n    await waitForInput();\n    assert.equal(await getEditorText(\"edit\"), \"B comment\");\n    await clearEditor(\"edit\");\n    await gu.sendKeys(\"B edited\");\n    await enter();\n    await assertNoEditor(\"edit\");\n    assert.equal(await readComment(1, \"panel\"), \"B edited\");\n\n    // Remove comment\n    await openCommentMenu(1, \"panel\");\n    await clickMenuItem(\"Remove\");\n    assert.equal(await commentCount(\"panel\"), 1);\n    assert.equal(await countText(), \"1 comment\");\n    await gu.undo();\n    assert.equal(await commentCount(\"panel\"), 2);\n    assert.equal(await countText(), \"2 comments\");\n\n    // Reply to second comment\n    await pressReply(1, \"panel\");\n    await waitForEditor(\"reply\");\n    await waitForInput();\n    await gu.sendKeys(\"Reply to B\");\n    await enter();\n    assert.equal(await commentCount(\"panel\"), 2);\n    assert.equal(await countText(), \"2 comments\");\n    assert.equal(await replyCount(1, \"panel\"), 1);\n    assert.equal(await readReply(1, 0, \"panel\"), \"Reply to B\");\n\n    // Can edit reply\n    await openReplyMenu(1, 0, \"panel\");\n    await clickMenuItem(\"Edit\");\n    await waitForEditor(\"edit\");\n    await waitForInput();\n    await clearEditor(\"edit\");\n    await gu.sendKeys(\"Reply to B edited\");\n    await enter();\n    assert.equal(await readReply(1, 0, \"panel\"), \"Reply to B edited\");\n\n    // Can resolve comment\n    await openCommentMenu(1, \"panel\");\n    await clickMenuItem(\"Resolve\");\n    assert.isTrue(await isCommentResolved(0, \"panel\"));\n    assert.isTrue(await isCommentResolved(1, \"panel\"));\n    // And we don' see replies\n    assert.equal(await replyCount(1, \"panel\"), 0);\n    await gu.undo();\n\n    // Can remove reply\n    assert.equal(await replyCount(1, \"panel\"), 1);\n    await openReplyMenu(1, 0, \"panel\");\n    await clickMenuItem(\"Remove\");\n    assert.equal(await replyCount(1, \"panel\"), 0);\n    await gu.undo();\n\n    // Can remove comment\n    assert.equal(await commentCount(\"panel\"), 2);\n    await openCommentMenu(1, \"panel\");\n    await clickMenuItem(\"Remove\");\n    assert.equal(await commentCount(\"panel\"), 1);\n    await gu.undo();\n  });\n\n  it(\"should remove comments with columns and rows\", async function() {\n    // Add another comment in A3 (there is a resolved comment here, so we can start a new discussion)\n    await gu.getCell(\"A\", 3).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await waitForInput(\"start\");\n    await gu.sendKeys(\"This is second comment\");\n    await enter();\n    await waitForInput(\"add\");\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n\n    // We have 3 comments in total, 2 in A,3 and 1 in B,2\n    const hasAComment = async () => {\n      assert.equal(await readComment(0, \"panel\"), \"This is first comment\");\n      assert.equal(await commentCount(\"panel\"), 3);\n    };\n    const onlyB = async () => {\n      assert.equal(await commentCount(\"panel\"), 1);\n      assert.equal(await countText(), \"1 comment\");\n      assert.isFalse(await isCommentResolved(0, \"panel\"));\n      assert.equal(await readComment(0, \"panel\"), \"B edited\");\n      assert.equal(await readReply(0, 0, \"panel\"), \"Reply to B edited\");\n    };\n    const test = async () => {\n      await hasAComment();\n      await gu.deleteColumn(\"A\");\n      await gu.checkForErrors();\n      await onlyB();\n      await gu.undo();\n      await gu.checkForErrors();\n      await hasAComment();\n      await gu.removeRow(3);\n      await gu.checkForErrors();\n      await onlyB();\n      await gu.undo();\n      await gu.checkForErrors();\n      await hasAComment();\n    };\n    // Owner can remove all comments when removing columns/rows.\n    await asOwner();\n    await panelOptions({ resolved: true });\n    await test();\n    await asSupport();\n    await panelOptions({ resolved: true });\n\n    // We still can remove owner's comments, because we can remove row.\n    await hasAComment();\n    await gu.removeRow(3);\n    await gu.checkForErrors();\n    await onlyB();\n    await gu.undo();\n\n    // But we can't remove only the comment.\n    await hasAComment();\n    await assertThrows(() => currentApi.applyUserActions(docId, [\n      [\"BulkRemoveRecord\", \"_grist_Cells\", [1]],\n    ]));\n    await hasAComment();\n  });\n\n  it(\"should remove comments with tables\", async function() {\n    await asOwner();\n    await panelOptions({ resolved: true });\n    assert.equal(await commentCount(\"panel\"), 3);\n    await panelOptions({ page: false });\n    await gu.addNewTable(\"Table2\");\n    await addRow();\n    await addRow();\n    await addRow();\n    // Still we see 3 comments\n    assert.equal(await commentCount(\"panel\"), 3);\n    await gu.removeTable(\"Table1\");\n    assert.equal(await commentCount(\"panel\"), 0);\n    await gu.undo();\n    assert.equal(await commentCount(\"panel\"), 3);\n    await gu.openPage(\"Table2\");\n  });\n\n  it(\"should navigate between tables and rows\", async function() {\n    // We have 3 comments on Table1.\n    // Add one on Table2\n    await gu.openPage(\"Table2\");\n    await gu.getCell(\"B\", 3).click();\n    await openCommentsWithKey();\n    await waitForPopup(\"empty\");\n    await waitForInput(\"start\");\n    await gu.sendKeys(\"Table2,B,3\");\n    await enter();\n    assert.equal(await commentCount(\"panel\"), 4);\n    // We are at Table2, now click first comment that should navigate to Table1\n    assert.equal(await gu.getActiveSectionTitle(), \"TABLE2\");\n    await clickComment(0, \"panel\");\n    // We should land at Table1,A,3\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 3, col: 0 });\n    assert.equal(await gu.getActiveSectionTitle(), \"TABLE1\");\n    // Now click second comment to navigate to Table1,B,2\n    await clickComment(1, \"panel\");\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2, col: 1 });\n    assert.equal(await gu.getActiveSectionTitle(), \"TABLE1\");\n    // Now click last comment to navigate to Table2,B,3\n    await clickComment(3, \"panel\");\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 3, col: 1 });\n    assert.equal(await gu.getActiveSectionTitle(), \"TABLE2\");\n  });\n\n  it(\"should disable Comment option on add-row\", async function() {\n    await gu.openPage(\"Table2\");\n    await gu.rightClick(await gu.getCell(\"B\", 2));\n    assert.equal(await gu.findOpenMenuItem(\"li\", /Comment/).matches(\".disabled\"), false);\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.rightClick(await gu.getCell(\"B\", 4));\n    assert.equal(await gu.findOpenMenuItem(\"li\", /Comment/).matches(\".disabled\"), true);\n  });\n\n  it(\"should offer menu option to copy anchor link\", async function() {\n    // Add first comment to second table\n    await gu.openPage(\"Table2\");\n    await addComment(\"B\", 2, \"Testing anchor link\");\n    await openCommentsWithMouse(\"B\", 2);\n    await waitForPopup(\"filled\");\n    await openCommentMenu(0);\n    await gu.findOpenMenuItem(\"li\", /Copy link/).click();\n\n    await driver.findContentWait(\".test-notifier-toast-message\", /Link copied/, 500);\n    const anchor = (await gu.getTestState()).clipboard!;\n    assert.isOk(anchor);\n    await gu.onNewTab(async () => {\n      await driver.get(anchor);\n      await gu.waitForDocToLoad();\n      await waitForPopup(\"filled\");\n      assert.equal(await commentCount(), 1);\n      assert.equal((await getCommentsData())[0].text, \"Testing anchor link\");\n    });\n  });\n\n  it(\"should hide comments from hidden columns\", async function() {\n    // Start with a fresh doc.\n    docId = (await session.tempNewDoc(cleanup, \"Hello.grist\", { load: false }));\n    await asOwner();\n    // Add 3 rows to it with numbers 1,2,3.\n    await currentApi.applyUserActions(docId, [[\"BulkAddRecord\", \"Table1\", arrayRepeat(3, null), {\n      A: [1, 2, 3],\n    }]]);\n    const revert = await gu.beginAclTran(ownerApi, docId);\n    // Make column a visible only to owner.\n    await currentApi.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"A\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"none\",\n      }],\n    ]);\n    await session.loadDoc(`/doc/${docId}`); // we are forced reload, so make sure it is over.\n    // Add one comment int in column A, 1.\n    await panelOptions({ page: false, resolved: true });\n    await addComment(\"A\", 1);\n    await addComment(\"B\", 2);\n    assert.equal(await commentCount(\"panel\"), 2);\n    // See if we see comments as editor\n    await asSupport();\n    assert.equal(await commentCount(\"panel\"), 1);\n    // Go to owner and see if we see all comments\n    await asOwner();\n    assert.equal(await commentCount(\"panel\"), 2);\n    await asSupport();\n    // Make sure client don't see hidden comments.\n    assert.deepEqual(await readClientComments(), [\n      \"CENSORED\",\n      \"B,2\",\n    ]);\n    assert.deepEqual(await readApiComments(), [\n      \"CENSORED\",\n      \"B,2\",\n    ]);\n    await asOwner();\n    await revert();\n  });\n\n  it(\"should hide comments from hidden tables\", async function() {\n    const revert = await gu.beginAclTran(ownerApi, docId);\n    // Make column a visible only to owner.\n    await currentApi.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"none\",\n      }],\n      [\"AddEmptyTable\", \"Public\"],\n      [\"BulkAddRecord\", \"Public\", arrayRepeat(3, null), {}],\n    ]);\n    await session.loadDoc(`/doc/${docId}`); // we are forced reload, so make sure it is over.\n    await panelOptions({ page: false, resolved: true });\n    // Add 2 comments to public table.\n    await gu.openPage(\"Public\");\n    await gu.waitAppFocus();\n    await addComment(\"C\", 2);\n    await gu.openPage(\"Table1\");\n    await gu.waitAppFocus();\n    // Owner sees 3 comments.\n    assert.equal(await commentCount(\"panel\"), 3);\n    await asSupport();\n    // Editor sees 1 comment\n    assert.equal(await commentCount(\"panel\"), 1);\n    assert.equal(await readComment(0, \"panel\"), \"C,2\");\n    assert.deepEqual(await readClientComments(), [\n      \"CENSORED\",\n      \"CENSORED\",\n      \"C,2\",\n    ]);\n    assert.deepEqual(await readApiComments(), [\n      \"CENSORED\",\n      \"CENSORED\",\n      \"C,2\",\n    ]);\n    await asOwner();\n    await revert();\n    // Make sure we see all comments once again.\n    await asSupport();\n    await panelOptions({ page: false, resolved: true });\n    assert.equal(await commentCount(\"panel\"), 3);\n    await asOwner();\n  });\n\n  it(\"should hide comments from hidden rows\", async function() {\n    const revert = await gu.beginAclTran(ownerApi, docId);\n    // Hide first row from table1.\n    await currentApi.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER and rec.A == 1\", permissionsText: \"none\",\n      }],\n    ]);\n    await session.loadDoc(`/doc/${docId}`); // we are forced reload, so make sure it is over.\n    // Owner sees 3 comments.\n    await panelOptions({ resolved: true, page: false, my: false });\n    assert.equal(await commentCount(\"panel\"), 3);\n    await asSupport();\n    await panelOptions({ resolved: true, page: false, my: false });\n    // Editor sees 2 comment\n    assert.equal(await commentCount(\"panel\"), 2);\n    assert.equal(await readComment(0, \"panel\"), \"B,2\");\n    assert.equal(await readComment(1, \"panel\"), \"C,2\");\n    await assertClientComments([\n      \"CENSORED\",\n      \"B,2\",\n      \"C,2\",\n    ]);\n    // Hide B comment (it is on Table1)\n    await gu.getCell(\"A\", 1).click();\n    await gu.enterCell(\"1\");\n    // We should see only 1 comment as editor (since second row is hidden now)\n    assert.equal(await commentCount(\"panel\"), 1);\n    assert.equal(await readComment(0, \"panel\"), \"C,2\");\n    await assertClientComments([\n      \"CENSORED\",\n      \"CENSORED\",\n      \"C,2\",\n    ]);\n    // Undo last row, so we should see 2 comments again.\n    await ownerApi.applyUserActions(docId, [[\n      \"UpdateRecord\", \"Table1\", 2, { A: 2 },\n    ]]);\n    await gu.waitForServer();\n    assert.equal(await commentCount(\"panel\"), 2);\n    assert.equal(await readComment(0, \"panel\"), \"B,2\");\n    assert.equal(await readComment(1, \"panel\"), \"C,2\");\n    await assertClientComments([\n      \"CENSORED\",\n      \"B,2\",\n      \"C,2\",\n    ]);\n    await asOwner();\n    await revert();\n    // Make sure we see all comments once again.\n    await asSupport();\n    assert.equal(await commentCount(\"panel\"), 3);\n    await asOwner();\n  });\n\n  it(\"should hide for censored cells\", async function() {\n    // Clear all comments first\n    await clearComments();\n    const revert = await gu.beginAclTran(ownerApi, docId);\n    // Censor B if A == 0 for everyone\n    await currentApi.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"B\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"rec.A == 0\", permissionsText: \"none\",\n      }],\n    ]);\n    await session.loadDoc(`/doc/${docId}`); // we are forced reload, so make sure it is over.\n    // Make sure we don't see any comments yet.\n    assert.equal(await commentCount(\"panel\"), 0);\n    await addComment(\"B\", 1, \"First\");\n    await addComment(\"B\", 1, \"Second\");\n    await addComment(\"B\", 2, \"Visible\");\n    assert.equal(await commentCount(\"panel\"), 2);\n    await assertClientComments([\n      \"First\",\n      \"Second\",\n      \"Visible\",\n    ]);\n    // Now censor column B in the first row.\n    await gu.getCell(\"A\", 1).click();\n    await gu.enterCell(\"0\");\n    await gu.getCell(\"B\", 1).click();\n    // Test that all comments from B,1 are hidden.\n    assert.equal(await commentCount(\"panel\"), 1);\n    await assertClientComments([\n      \"CENSORED\",\n      \"CENSORED\",\n      \"Visible\",\n    ]);\n    // Make sure also that we don't see triangle.\n    assert.isFalse(await hasComment(\"B\", 1));\n    assert.isTrue(await hasComment(\"B\", 2));\n    // Try to open comments popup for this cell.\n    await openCommentsWithKey();\n    await assertNoPopup();\n    await openCommentsWithMouse(\"B\", 1);\n    await assertNoPopup();\n\n    // Now show them\n    await gu.getCell(\"A\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"2\");\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitAppFocus();\n    assert.equal(await commentCount(\"panel\"), 2);\n    await assertClientComments([\n      \"First\",\n      \"Second\",\n      \"Visible\",\n    ]);\n    assert.isTrue(await hasComment(\"B\", 1));\n    assert.isTrue(await hasComment(\"B\", 2));\n    // Make sure that popup works\n    await assertNoPopup();\n    await openCommentsWithKey();\n    await waitForPopup(\"any\");\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n    await openCommentsWithMouse(\"B\", 1);\n    await waitForPopup(\"any\");\n    // Read comments from popup\n    assert.equal(await commentCount(\"popup\"), 1);\n    // Make sure text for comments are ok.\n    assert.equal(await readComment(0, \"popup\"), \"First\");\n    assert.equal(await readReply(0, 0, \"popup\"), \"Second\");\n    await gu.sendKeys(Key.ESCAPE);\n    await asOwner();\n    await revert();\n  });\n\n  it(\"should not send uncensored comments\", async function() {\n    // Clear all comments first\n    await clearComments();\n    const revertAcl = await gu.beginAclTran(ownerApi, docId);\n    // Censor B if A == 1 for everyone\n    await currentApi.applyUserActions(docId, [\n      // Censor B column for editor\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"B\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'rec.B == \"secret\" and user.Access != OWNER', permissionsText: \"-R\",\n      }],\n      // Hide rows when A === 0 for non owners\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"rec.A == 0 and user.Access != OWNER\", permissionsText: \"-R\",\n      }],\n    ]);\n    await session.loadDoc(`/doc/${docId}`); // we are forced reload, so make sure it is over.\n    // Make sure we don't see any comments yet.\n    assert.equal(await commentCount(\"panel\"), 0);\n    await addComment(\"B\", 1, \"Secret\");\n    assert.equal(await commentCount(\"panel\"), 1);\n    assert.equal(await gu.getGridRowCount(), 3 + 1);\n    await asSupport();\n\n    // We see this comment for now\n    assert.equal(await commentCount(\"panel\"), 1);\n    assert.equal(await readComment(0, \"panel\"), \"Secret\");\n\n    // Now censor column B.\n    await ownerApi.applyUserActions(docId, [[\n      \"UpdateRecord\", \"Table1\", 1, { B: \"secret\" },\n    ]]);\n    await gu.waitForServer();\n    assert.equal(await commentCount(\"panel\"), 0);\n    await assertClientComments([\n      \"CENSORED\",\n    ]);\n\n    // Now hide row 1 for non owners\n    await ownerApi.applyUserActions(docId, [[\n      \"UpdateRecord\", \"Table1\", 1, { A: 0 },\n    ]]);\n    await gu.waitForServer();\n    assert.equal(await commentCount(\"panel\"), 0);\n    await assertClientComments([\n      \"CENSORED\",\n    ]);\n    assert.equal(await gu.getGridRowCount(), 2 + 1);\n\n    // Now reveal row 1\n    await ownerApi.applyUserActions(docId, [[\n      \"UpdateRecord\", \"Table1\", 1, { A: 1 },\n    ]]);\n    await gu.waitForServer();\n    assert.equal(await commentCount(\"panel\"), 0);\n    await assertClientComments([\n      \"CENSORED\",\n    ]);\n    assert.equal(await gu.getGridRowCount(), 3 + 1);\n    // And make cell in column B visible\n\n    await ownerApi.applyUserActions(docId, [[\n      \"UpdateRecord\", \"Table1\", 1, { B: \"visible\" },\n    ]]);\n    await gu.waitForServer();\n    assert.equal(await commentCount(\"panel\"), 1);\n    await assertClientComments([\n      \"Secret\",\n    ]);\n    await asOwner();\n    await revertAcl();\n  });\n\n  it(\"should filter comments from actions\", async function() {\n    // Clear all comments first\n    await clearComments();\n    const revertAcl = await gu.beginAclTran(ownerApi, docId);\n    // Censor B if A == 1 for everyone\n    await currentApi.applyUserActions(docId, [\n      // Censor B column for editor when A === 0\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"B\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"rec.A == 0 and user.Access != OWNER\", permissionsText: \"-R\",\n      }],\n    ]);\n    // Open document as editor\n    await asOwner();\n    // Add some comments as owner for column B\n    await ownerApi.applyUserActions(docId, [\n      [\"BulkUpdateRecord\", \"Table1\", [1, 2, 3], { A: [0, 0, 0] }],\n      [\"BulkAddRecord\", \"_grist_Cells\", arrayRepeat(3, null), {\n        tableRef: [1, 1, 1],\n        rowId: [1, 2, 3],\n        colRef: [3, 3, 3],\n        type: arrayRepeat(3, 1),\n        root: arrayRepeat(3, true),\n        // userRef is set automatically by the data engine\n        content: [1, 2, 3].map(x => JSON.stringify({ text: `B,${x}`, userName: \"Owner\" })),\n      }],\n    ]);\n    await gu.waitForServer();\n    assert.isTrue(await hasComment(\"B\", 1));\n    assert.isTrue(await hasComment(\"B\", 2));\n    assert.isTrue(await hasComment(\"B\", 3));\n    await assertClientComments([\n      \"B,1\",\n      \"B,2\",\n      \"B,3\",\n    ]);\n    await asSupport();\n    await assertClientComments([\n      \"CENSORED\",\n      \"CENSORED\",\n      \"CENSORED\",\n    ]);\n    await ownerApi.applyUserActions(docId, [\n      [\"BulkAddRecord\", \"_grist_Cells\", arrayRepeat(3, null), {\n        tableRef: [1, 1, 1],\n        rowId: [1, 2, 3],\n        colRef: [3, 3, 3],\n        // userRef is set automatically by the data engine\n        content: [1, 2, 3].map(x => JSON.stringify({ text: `B,${x}`, userName: \"Owner\" })),\n      }],\n    ]);\n    await assertClientComments([\n      \"CENSORED\",\n      \"CENSORED\",\n      \"CENSORED\",\n      \"CENSORED\",\n      \"CENSORED\",\n      \"CENSORED\",\n    ]);\n    await asOwner();\n    await revertAcl();\n  });\n\n  it(\"should filter api comments from actions\", async function() {\n    // Clear all comments first\n    await clearComments();\n    const revertAcl = await gu.beginAclTran(ownerApi, docId);\n    // Censor B if A == 1 for everyone\n    await currentApi.applyUserActions(docId, [\n      // Hide Table1 for non owners\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"-R\",\n      }],\n      // Hide rows when A === 99 for non owners on Table2\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Public\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"rec.A == 99 and user.Access != OWNER\", permissionsText: \"-R\",\n      }],\n      // Hide column C on Table2 for non owners\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Public\", colIds: \"C\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -3, aclFormula: \"user.Access != OWNER\", permissionsText: \"-R\",\n      }],\n    ]);\n    // Add some initial comments\n    await gu.openPage(\"Table1\");\n    await addComment(\"C\", 1, \"First\");\n    await gu.openPage(\"Public\");\n    await addComment(\"B\", 1, \"Second\");\n    await addComment(\"C\", 1, \"Third\");\n    assert.deepEqual(await readApiComments(ownerApi), [\n      \"First\",\n      \"Second\",\n      \"Third\",\n    ]);\n    // Switch to editor\n    await asSupport();\n    await assertClientComments([\n      \"CENSORED\", // Table1 is hidden completely\n      \"Second\",\n      \"CENSORED\", // Column C is hidden for non owners\n    ]);\n    await ownerApi.applyUserActions(docId, [\n      [\"AddRecord\", \"Table1\", null, {}], // add row with id 4 for Table1\n      await addCommentAction(\"Table1\", \"A\", 4, \"Third2\"),\n    ]);\n    await assertClientComments([\n      \"CENSORED\",\n      \"Second\",\n      \"CENSORED\",\n      \"CENSORED\", // Table1,A,4\n    ]);\n    await ownerApi.applyUserActions(docId, [\n      [\"AddRecord\", \"Public\", null, {}],\n      await addCommentAction(\"Public\", \"A\", 4, \"Forth\"),\n      await addCommentAction(\"Public\", \"A\", 4, \"Fifth\"),\n    ]);\n    await gu.waitForServer();\n    await assertClientComments([\n      \"CENSORED\",\n      \"Second\",\n      \"CENSORED\",\n      \"CENSORED\",\n      \"Forth\", // New comment added just now\n      \"Fifth\", // New comment added just now\n    ]);\n    await ownerApi.applyUserActions(docId, [\n      [\"BulkUpdateRecord\", \"_grist_Cells\", [5, 6], {\n        content: [\n          JSON.stringify({ text: `Forth-updated`, userName: \"Owner\" }),\n          JSON.stringify({ text: `Fifth-updated`, userName: \"Owner\" }),\n        ],\n      }],\n    ]);\n    await gu.waitForServer();\n    await assertClientComments([\n      \"CENSORED\",\n      \"Second\",\n      \"CENSORED\",\n      \"CENSORED\",\n      \"Forth-updated\",\n      \"Fifth-updated\",\n    ]);\n    // Add some comments to hidden column\n    await ownerApi.applyUserActions(docId, [\n      [\"AddRecord\", \"Public\", null, {}], // add row with id 5 for Public\n      await addCommentAction(\"Public\", \"C\", 5, \"HiddenC\"),\n    ]);\n    await gu.waitForServer();\n    await assertClientComments([\n      \"CENSORED\",\n      \"Second\",\n      \"CENSORED\",\n      \"CENSORED\",\n      \"Forth-updated\",\n      \"Fifth-updated\",\n      \"CENSORED\", // HiddenC\n    ]);\n    // Hide 4th row, by setting it to 0\n    await gu.getCell(\"A\", 4).click();\n    await gu.enterCell(\"99\");\n    await assertClientComments([\n      \"CENSORED\",\n      \"Second\",\n      \"CENSORED\",\n      \"CENSORED\",\n      \"CENSORED\", // those comments are censored now\n      \"CENSORED\", // because 1st row is == 0\n      \"CENSORED\", // HiddenC\n    ]);\n    // Reveal it using owner API.\n    await ownerApi.applyUserActions(docId, [[\n      \"UpdateRecord\", \"Public\", 4, { A: 3 },\n    ]]);\n    await gu.waitForServer();\n    await assertClientComments([\n      \"CENSORED\",\n      \"Second\",\n      \"CENSORED\",\n      \"CENSORED\",\n      \"Forth-updated\",\n      \"Fifth-updated\",\n      \"CENSORED\", // HiddenC\n    ]);\n    // Check that in database everything is ok.\n    assert.deepEqual(await readApiComments(ownerApi), [\n      \"First\",\n      \"Second\",\n      \"Third\",\n      \"Third2\",\n      \"Forth-updated\",\n      \"Fifth-updated\",\n      \"HiddenC\",\n    ]);\n    await asOwner();\n    await revertAcl();\n  });\n\n  it(\"should reject updates to censored comments\", async function() {\n    // Clear all comments first\n    await clearComments();\n    const revertAcl = await gu.beginAclTran(ownerApi, docId);\n    await currentApi.applyUserActions(docId, [\n      // Hide Table1 rows with A == 1 for editors\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"rec.A == 1 and user.Access != OWNER\", permissionsText: \"-R\",\n      }],\n      // Hide column C for non owners\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Public\", colIds: \"C\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"user.Access != OWNER\", permissionsText: \"-R\",\n      }],\n      // Take schema access from editors.\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -3, aclFormula: \"user.Access == EDITOR\", permissionsText: \"-S\",\n      }],\n    ]);\n    // Add first comment to first table\n    await gu.openPage(\"Table1\");\n    await addComment(\"A\", 1, \"From owner\");\n    await gu.getCell(\"A\", 1).click();\n    await gu.waitAppFocus(true);\n    await gu.enterCell(\"1\");\n    // Switch to editor\n    await asSupport();\n    await assertClientComments([\n      \"CENSORED\",\n    ]);\n    // Try various ways to update/remove hidden comment.\n    await assertThrows(() => currentApi.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Cells\", 1, { content: JSON.stringify({ text: \"Updated\", userName: \"Editor\" }) }],\n    ]));\n    await assertThrows(() => currentApi.applyUserActions(docId, [\n      [\"BulkUpdateRecord\", \"_grist_Cells\", [1], { content: [JSON.stringify({ text: \"Updated\", userName: \"Editor\" })] }],\n    ]));\n    await assertThrows(() => currentApi.applyUserActions(docId, [\n      [\"BulkRemoveRecord\", \"_grist_Cells\", [1]],\n    ]));\n    await assertThrows(async () => await currentApi.applyUserActions(docId, [\n      await addCommentAction(\"Public\", \"C\", 1, \"HiddenC\"),\n    ]));\n    await assertClientComments([\n      \"CENSORED\",\n    ]);\n    await asOwner();\n    await revertAcl();\n  });\n\n  it(\"should reject removals from someone else\", async function() {\n    // Clear all comments first\n    await clearComments();\n    // Add first comment to first table\n    await gu.openPage(\"Table1\");\n    await addComment(\"A\", 1, \"From owner\");\n    // Switch to editor\n    await asSupport();\n    await assertClientComments([\n      \"From owner\",\n    ]);\n    // Try to remove this comment.\n    await openCommentMenu(0, \"panel\");\n    await clickMenuItem(\"Remove\");\n    // This will be a noop since button is disabled.\n    await assertClientComments([\n      \"From owner\",\n    ]);\n    // Can't remove comments (we are not owner)\n    await assertThrows(() => currentApi.applyUserActions(docId, [\n      [\"RemoveRecord\", \"_grist_Cells\", 1],\n    ]));\n  });\n\n  it(\"should allow owner to remove any comment\", async function() {\n    // Clear all comments first\n    await clearComments();\n    await gu.openPage(\"Table1\");\n\n    // Editor adds a comment\n    await asSupport();\n    await addComment(\"A\", 1, \"From editor\");\n    await assertClientComments([\n      \"From editor\",\n    ]);\n\n    // Switch to owner and verify they can see the comment\n    await asOwner();\n    assert.equal(await commentCount(\"panel\"), 1);\n    assert.equal(await readComment(0, \"panel\"), \"From editor\");\n\n    // Owner should be able to remove editor's comment via UI\n    await openCommentMenu(0, \"panel\");\n    await clickMenuItem(\"Remove thread\");\n    await gu.waitForServer();\n\n    // Verify comment is deleted\n    assert.equal(await commentCount(\"panel\"), 0);\n    await assertClientComments([]);\n  });\n\n  it(\"should allow owner to resolve any thread\", async function() {\n    // Clear all comments first\n    await clearComments();\n    await gu.openPage(\"Table1\");\n\n    // Editor adds a comment\n    await asSupport();\n    await addComment(\"A\", 1, \"Thread from editor\");\n    await assertClientComments([\n      \"Thread from editor\",\n    ]);\n\n    // Switch to owner and verify they can see the comment\n    await asOwner();\n    await openPanel();\n    await panelOptions({ resolved: false });\n    assert.equal(await commentCount(\"panel\"), 1);\n    assert.equal(await readComment(0, \"panel\"), \"Thread from editor\");\n\n    // Owner should be able to resolve editor's thread via UI\n    await openCommentMenu(0, \"panel\");\n    await clickMenuItem(\"Resolve\");\n    await gu.waitForServer();\n\n    // Verify comment is resolved (not visible by default with resolved: false)\n    assert.equal(await commentCount(\"panel\"), 0);\n\n    // Enable showing resolved comments to verify it's still there\n    await panelOptions({ resolved: true });\n\n    // Now we should see the resolved comment\n    assert.equal(await commentCount(\"panel\"), 1);\n    assert.equal(await readComment(0, \"panel\"), \"Thread from editor\");\n\n    // Verify the comment is marked as resolved\n    assert.isTrue(await isCommentResolved(0, \"panel\"));\n  });\n});\n\nasync function assertClientComments(comments: string[]) {\n  assert.deepEqual(await readClientComments(), comments);\n  assert.deepEqual(await readApiComments(), comments);\n}\n\nasync function clearComments() {\n  const dList = await ownerApi.getTable(docId, \"_grist_Cells\");\n  await ownerApi.applyUserActions(docId, [\n    [\"BulkRemoveRecord\", \"_grist_Cells\", dList.id],\n  ]);\n  await gu.waitForServer();\n}\n\nasync function addComment(col: string, row: number, text?: string) {\n  await gu.getCell(col, row).click();\n  await openCommentsWithKey();\n  await waitForPopup(\"any\");\n  await waitForInput();\n  await gu.sendKeys(text ?? `${col},${row}`);\n  await enter();\n  await gu.waitForServer();\n  await gu.sendKeys(Key.ESCAPE);\n  await assertNoPopup();\n}\n\nasync function addCommentAction(tableId: string, col: string, row: number, text?: string) {\n  const tables = await ownerApi.getTable(docId, \"_grist_Tables\");\n  const tableRef = tables.id[tables.tableId.findIndex(id => id === tableId)];\n  const columns = await ownerApi.getTable(docId, \"_grist_Tables_column\");\n  const colRef = columns.id[columns.colId.findIndex(\n    (val, idx) => val === col && tableRef === columns.parentId[idx])\n  ];\n  return [\"AddRecord\", \"_grist_Cells\", null, {\n    tableRef,\n    rowId: row,\n    type: 1,\n    root: true,\n    colRef,\n    content: JSON.stringify({ text: text ?? `${tableId},${col},${row}`, userName: \"Owner\" }),\n  }];\n}\n\nasync function countText() {\n  return await driver.find(\".test-discussion-comment-count\").getText();\n}\n\nasync function panelOptions(options: {\n  my?: boolean,\n  page?: boolean,\n  resolved?: boolean\n}) {\n  async function sync(state: boolean, el: WebElement) {\n    if (state && !await el.getAttribute(\"checked\")) {\n      await el.click();\n    } else if (!state && await el.getAttribute(\"checked\")) {\n      await el.click();\n    }\n  }\n  await driver.findWait(\".test-discussion-panel-menu\", 1000).click();\n  await driver.findWait(\".grist-floating-menu\", 100);\n  if (options.my !== undefined) {\n    await sync(options.my, await driver.find(`.test-discussion-my-threads`));\n  }\n  if (options.page !== undefined) {\n    await sync(options.page, await driver.find(`.test-discussion-only-page`));\n  }\n  if (options.resolved !== undefined) {\n    await sync(options.resolved, await driver.find(`.test-discussion-show-resolved`));\n  }\n  await gu.sendKeys(Key.ESCAPE);\n}\n\nasync function hasComment(col: string, row: number) {\n  return (await gu.getCell(col, row).getAttribute(\"class\")).includes(\"field-with-comments\");\n}\n\nasync function openPanel() {\n  await driver.findWait(\".test-open-discussion\", 1000).click();\n  await driver.sleep(500);\n}\n\nfunction _mapCommentsToText(data: any) {\n  return (data).sort((a: any, b: any) => a.id - b.id).map((r: any) => {\n    if (Array.isArray(r.content)) {\n      assert.equal(r.userRef, \"\");\n      return \"CENSORED\";\n    }\n    return r.content ? JSON.parse(r.content).text : \"\";\n  });\n}\n\nasync function readClientComments(): Promise<string[]> {\n  return _mapCommentsToText(await readClientRecords(\"_grist_Cells\"));\n}\n\nasync function readApiComments(userApi?: UserAPIImpl): Promise<string[]> {\n  function records(rows: TableColValues) {\n    // Convert column representation to record representation\n    const list = [];\n    for (let i = 0; i < rows.id.length; i++) {\n      const record: any = {};\n      for (const key of Object.keys(rows)) {\n        record[key] = rows[key][i];\n      }\n      list.push(record);\n    }\n    return list;\n  }\n  const docApi = (userApi ?? currentApi).getDocAPI(docId);\n  const rows = await docApi.getRows(\"_grist_Cells\");\n  return _mapCommentsToText(records(rows));\n}\n\nasync function readClientRecords(tableId: string): Promise<any[]> {\n  const data = await driver.executeScript(\n    function(tab: any) {\n      return (window as any).gristDocPageModel.gristDoc.get()\n        .docData.getMetaTable(tab).getRecords();\n    }, tableId);\n  return data as any[];\n}\n\nfunction waitForEditor(which: EditorType) {\n  const container = driver.findWait(`.test-discussion-editor-${which}`, 100);\n  return container;\n}\n\nasync function menuOptions() {\n  return await gu.findOpenMenuAllItems(\"li\", e => e.getText());\n}\n\n/**\n * Gets disabled items from mention list.\n */\nasync function disabledMenuOptions() {\n  const menuItems = await gu.findOpenMenuAllItems(\"li\", async e =>\n    await e.matches(\".disabled\") ? await e.getText() : \"\",\n  );\n  return menuItems.filter(Boolean);\n}\n\nasync function clickMenuItem(command: \"Edit\" | \"Remove\" | \"Remove thread\" | \"Reply\" | \"Resolve\" | \"Open\") {\n  const menu = await driver.findWait(\".grist-floating-menu\", 100);\n  const item = await menu.findContent(\"li\", command);\n  await item.click();\n  await gu.waitForServer();\n}\n\nasync function openCommentMenu(commentIndex: number, where: Place = \"popup\") {\n  const menu = await (await findComment(commentIndex, where)).find(\".test-discussion-comment-menu\");\n  await menu.click();\n  await driver.findWait(\".grist-floating-menu\", 100);\n}\n\nasync function getReply(commentIndex: number, replyIndex: number, where: Place = \"popup\") {\n  const comment = await findComment(commentIndex, where);\n  const reply = await comment.findAll(`.test-discussion-reply`);\n  return reply[replyIndex];\n}\n\nasync function readReply(commentIndex: number, replyIndex: number, where: Place = \"popup\") {\n  const data = await getRepliesData(commentIndex, where);\n  const reply = data[replyIndex];\n  return reply.text;\n}\n\nasync function openReplyMenu(commentIndex: number, replyIndex: number, where: Place = \"popup\") {\n  const comment = await getReply(commentIndex, replyIndex, where);\n  const menu = await comment.find(\".test-discussion-comment-menu\");\n  await menu.click();\n  await driver.findWait(\".grist-floating-menu\", 100);\n}\n\nasync function clearEditor(which: EditorType) {\n  const editor = await waitForEditor(which);\n  await editor.find(\".test-discussion-textarea\").click();\n  await gu.clearInput();\n}\n\nasync function pressCancel() {\n  await driver.find(\".test-discussion-button-Cancel\").click();\n}\n\nasync function isSendDisabled() {\n  const value = await driver.find(\".test-discussion-button-send\").getAttribute(\"disabled\");\n  return value === \"true\";\n}\n\nasync function pressSend() {\n  await driver.find(\".test-discussion-button-send\").click();\n  await gu.waitForServer();\n}\n\nasync function pressButton(text: string) {\n  await driver.findContent(\".test-discussion-popup button\", text).click();\n  await gu.waitForServer();\n}\n\ntype EditorType = \"start\" | \"edit\" | \"reply\" | \"add\";\n\nfunction getEditor(where: EditorType) {\n  return waitForEditor(where).find(\".test-discussion-textarea\");\n}\n\nasync function getEditorText(where: EditorType) {\n  return await getEditor(where).getText();\n}\n\nasync function assertNoEditor(which: EditorType) {\n  assert.isFalse(await driver.find(`.test-discussion-editor-${which}`).isPresent());\n}\n\ntype Place = \"popup\" | \"panel\";\n\nasync function pressReply(index: number, where: Place = \"popup\") {\n  await (await findComment(index, where)).find(\".test-discussion-comment-reply-button\").click();\n}\n\nasync function isCommentResolved(index: number, where: Place = \"popup\") {\n  const comment = await findComment(index, where);\n  return await comment.find(\".test-discussion-comment-resolved\").isPresent();\n}\n\nfunction waitForPopup(state: \"empty\" | \"filled\" | \"any\" = \"any\") {\n  if (state === \"empty\") {\n    return driver.findWait(\".test-discussion-popup .test-discussion-topic-empty\", 100);\n  } else if (state === \"filled\") {\n    return driver.findWait(\".test-discussion-popup .test-discussion-topic-filled\", 100);\n  } else {\n    return driver.findWait(\".test-discussion-popup .test-discussion-topic\", 100);\n  }\n}\n\nasync function openCommentsWithKey() {\n  await gu.sendKeys(Key.chord(MODKEY, Key.ALT, \"m\"));\n}\n\nasync function openCommentsWithMouse(col: string, row: number) {\n  await gu.rightClick(await gu.getCell(col, row));\n  await gu.findOpenMenuItem(\".test-cmd-name\", /Comment/).click();\n}\n\ninterface Comment {\n  text: string;\n  time: string;\n  nick: string;\n}\n\nasync function getCommentsData(where: \"popup\" | \"panel\" = \"popup\") {\n  return await extractData(await findComments(where));\n}\n\nasync function extractData(elements: WebElement[]) {\n  const comments: Comment[] = [];\n  for (const element of elements) {\n    const text = await element.find(\".test-discussion-comment-text\");\n    const time = await element.find(\".test-discussion-comment-time\");\n    const nick = await element.find(\".test-discussion-comment-nick\");\n    comments.push({\n      text: await text.getText(),\n      time: await time.getText(),\n      nick: await nick.getText(),\n    });\n  }\n  return comments;\n}\n\nasync function readComment(index: number, where: \"popup\" | \"panel\" = \"popup\") {\n  return (await getCommentsData(where))[index].text;\n}\n\nasync function getMentions(comment: WebElement) {\n  const mentionElements = await comment.findAll(\"span.mention\", e => e.getText());\n  return mentionElements.sort();\n}\n\nasync function replyCount(index: number, where: \"popup\" | \"panel\" = \"popup\") {\n  const replies = await getRepliesData(index, where);\n  return replies.length;\n}\n\nasync function getRepliesData(index: number, where: \"popup\" | \"panel\" = \"popup\") {\n  const commentElements = await findComments(where);\n  const comment = commentElements[index];\n  if (!comment) {\n    throw new Error(`Comment ${index} not found`);\n  }\n  const replyElements = await comment.findAll(\".test-discussion-reply\");\n  return await extractData(replyElements);\n}\n\nasync function waitForComment(where: \"popup\" | \"panel\" = \"popup\") {\n  const container = where === \"popup\" ? \".test-discussion-popup\" : \".test-discussion-panel\";\n  await driver.findWait(`${container} .test-discussion-comment`, 1000);\n}\n\nasync function findComments(where: \"popup\" | \"panel\" = \"popup\") {\n  const container = where === \"popup\" ? \".test-discussion-popup\" : \".test-discussion-panel\";\n  const commentElements = await driver.findAll(`${container} .test-discussion-comment`);\n  return commentElements;\n}\n\nasync function findComment(index: number, where: \"popup\" | \"panel\" = \"popup\") {\n  await waitForComment(where);\n  const commentElements = await findComments(where);\n  const comment = commentElements[index];\n  if (!comment) {\n    throw new Error(`Comment ${index} not found`);\n  }\n  return comment;\n}\n\nasync function clickComment(index: number, where: \"popup\" | \"panel\" = \"popup\") {\n  return (await findComment(index, where)).click();\n}\n\nasync function commentCount(where: \"popup\" | \"panel\" = \"popup\") {\n  return (await findComments(where)).length;\n}\n\nasync function enter() {\n  await gu.sendKeys(Key.ENTER);\n  await gu.waitForServer();\n}\n\nasync function shiftEnter() {\n  await gu.sendKeys(Key.chord(Key.SHIFT, Key.ENTER));\n}\n\nasync function asSupport() {\n  await asUser(\"support\");\n}\n\nasync function asUser(data: gu.TestUser | gu.UserData, loadDoc = true) {\n  let user: gu.TestUser = typeof data === \"string\" ? data : data.name.toLowerCase() as any;\n  // If data is object, then we need to translate it to id from the enum.\n  if (typeof data === \"object\") {\n    user = Object.entries(gu.TestUserEnum).find(([k, v]) => v === data.name.toLowerCase())?.[0] as gu.TestUser;\n  }\n  session = await gu.session().teamSite.user(user).login();\n  currentApi = session.createHomeApi();\n  if (loadDoc) {\n    await session.loadDoc(`/doc/${docId}`);\n  }\n}\n\nasync function asOwner() {\n  session = await gu.session().teamSite.login();\n  await session.loadDoc(`/doc/${docId}`);\n  currentApi = session.createHomeApi();\n  ownerApi = currentApi;\n  void ownerApi;\n}\n\nasync function addRow() {\n  await gu.sendKeys(Key.chord(MODKEY, Key.ENTER));\n  await gu.waitForServer();\n}\n\nasync function assertThrows(test: () => Promise<any>) {\n  try {\n    await test();\n  } catch (err) {\n    assert.match(err.message, /Cannot access cell/);\n    return;\n  }\n  assert.fail(\"Should have thrown\");\n}\n\nasync function waitForInput(which?: EditorType) {\n  // Waits for the .test-comments-textarea to be displayed and active element.\n  await gu.waitToPass(async () => {\n    const input = await (which ? waitForEditor(which) : driver).find(\".test-discussion-textarea\");\n    assert.isTrue(await input.isDisplayed());\n    assert.isTrue(await input.hasFocus());\n    // Wait for the access to be ready.\n    assert.isTrue(await input.matches(\".test-mention-textbox-ready\"));\n  }, 1000);\n}\n\nasync function assertNoPopup() {\n  await gu.waitToPass(async () => {\n    assert.isFalse(await driver.find(\".test-comments-popup\").isPresent());\n  });\n}\n\nasync function readUsers() {\n  await waitForMentionList();\n  return (await driver.findAll(\".test-mention-textbox-acitem-text\", e => e.getText())).sort();\n}\n\nasync function waitForMentionList() {\n  await gu.findOpenMenu();\n  await driver.findWait(\".test-mention-textbox-acitem\", 1000);\n}\n\n/**\n * Gets disabled items from mention list.\n */\nasync function disabledList() {\n  await gu.findOpenMenu();\n  const list = await driver.findAll(\n    \".test-mention-textbox-acitem.test-mention-textbox-disabled .test-mention-textbox-acitem-text\",\n    e => e.getText());\n  return list;\n}\n\nasync function selectUser(usr: string | RegExp) {\n  await gu.findOpenMenu();\n  await driver.findContent(\".test-mention-textbox-acitem-text\", usr).click();\n  await gu.waitForMenuToClose();\n}\n"
  },
  {
    "path": "test/nbrowser/Comparison.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { cleanupExtraWindows, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { identity } from \"lodash\";\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\nconst EMPTY_MARK = \"\\u2205\";\n\ndescribe(\"Comparison\", function() {\n  this.timeout(20000);\n  cleanupExtraWindows();\n  const cleanup = setupTestSuite();\n\n  it(\"can access comparison details\", async function() {\n    // Create a document owned by default user.\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"Hello.grist\", { load: false });\n\n    // Check that comparing document with itself results in 'same.'\n    await mainSession.loadDoc(`/doc/${doc.id}?compare=${doc.id}`);\n    await gu.waitToPass(async () => {\n      assert.equal((await gu.getComparison())?.summary, \"same\");\n    }, 3000);\n\n    // Make a fork of the document with a change.\n    await mainSession.loadDoc(`/doc/${doc.id}/m/fork`);\n    await gu.getCell({ rowNum: 1, col: 0 }).click();\n    await gu.enterCell(\"123\");\n    await gu.waitForServer();\n    assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"123\");\n    const forkId = await gu.getCurrentUrlId();\n\n    // Check that comparing original with changed doc results in a difference.\n    await mainSession.loadDoc(`/doc/${doc.id}?compare=${forkId}`, { skipAlert: true });\n    const comp = await gu.getComparison();\n    assert.equal(comp?.summary, \"right\");\n    assert.deepEqual(comp?.details?.rightChanges, {\n      tableDeltas: {\n        Table1: {\n          addRows: [],\n          removeRows: [],\n          updateRows: [1],\n          columnDeltas: {\n            A: {\n              [1]: [[\"hello\"], [\"123\"]],\n            },\n            E: {\n              [1]: [[\"HELLO\"], [\"123\"]],\n            },\n          },\n          columnRenames: [],\n        },\n      },\n      tableRenames: [],\n    });\n\n    // Check that the one change we made is rendered sensibly.\n    const cell = await gu.getCell(0, 1);\n    assert.equal(await cell.getText(), \"hello123\");\n    assert.equal(await cell.find(\".diff-remote\").getText(), \"123\");\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"hello\");\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n\n    // Check that context menu works.\n    await gu.rightClick(cell);\n    await gu.checkForErrors();\n    await gu.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"can render changes in remote doc\", async function() {\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"Favorite_Films.grist\", { load: false });\n\n    // Make a fork of the document with several changes.\n    await mainSession.loadDoc(`/doc/${doc.id}/m/fork`);\n\n    // Change a cell\n    await gu.getCell({ rowNum: 1, col: 1 }).click();\n    await gu.enterCell(\"800\");\n    await gu.waitForServer();\n\n    // Remove a row\n    await gu.removeRow(4);\n\n    // Add a row\n    await (await gu.openRowMenu(5)).findContent(\"li\", /Insert row above/).click();\n    await gu.waitForServer();\n    await gu.enterCell(\"Unicorny\");\n    await gu.waitForServer();\n\n    const forkId = await gu.getCurrentUrlId();\n\n    // Check that comparing original with changed doc results in a difference.\n    await mainSession.loadDoc(`/doc/${doc.id}?compare=${forkId}`, { skipAlert: true });\n\n    let cell = await gu.getCell({ rowNum: 1, col: 1 });\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"30.0\");\n    assert.equal(await cell.find(\".diff-remote\").getText(), \"800.0\");\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n\n    assert.match(await driver.find(\".record.diff-remote-remove\").getText(), /Avatar/);\n\n    assert.match(await driver.find(\".record.diff-remote-add\").getText(), /Unicorny/);\n\n    // Now change some references.\n    await mainSession.loadDoc(`/doc/${forkId}`);\n    await gu.getPageItem(\"Performances\").click();\n    await gu.getCell({ rowNum: 1, col: \"Film\" }).click();\n    await gu.enterCell(\"The Avengers\");\n    await gu.waitForServer();\n    await gu.getCell({ rowNum: 2, col: \"Film\" }).click();\n    await gu.enterCell(\"Unicorny\");\n    await gu.waitForServer();\n\n    // Check that reference changes are visible, including one as an effect of\n    // a removed row.\n    await mainSession.loadDoc(`/doc/${doc.id}?compare=${forkId}`, { skipAlert: true });\n    await gu.getPageItem(\"Performances\").click();\n    cell = gu.getCell({ rowNum: 1, col: \"Film\" });\n    assert.equal(await cell.find(\".diff-remote\").getText(), \"The Avengers\");\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"Toy Story\");\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n    cell = gu.getCell({ rowNum: 2, col: \"Film\" });\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"Toy Story\");\n    assert.equal(await cell.find(\".diff-remote\").getText(), \"Unicorny\");\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n    cell = gu.getCell({ rowNum: 7, col: \"Film\" });\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"Avatar\");\n    assert.equal(await cell.find(\".diff-remote\").getText(), EMPTY_MARK);\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n  });\n\n  describe(\"mixed local and remote changes\", function() {\n    let mainSession: gu.Session;\n    let comparisonPath: string;\n\n    it(\"can render changes in table view\", async function() {\n      mainSession = await gu.session().teamSite.login();\n      const doc = await mainSession.tempDoc(cleanup, \"Favorite_Films.grist\", { load: false });\n\n      // Make a fork of the document with several changes.\n      await mainSession.loadDoc(`/doc/${doc.id}/m/fork`);\n\n      // Change a cell\n      await gu.getCell({ rowNum: 1, col: 1 }).click();\n      await gu.enterCell(\"800\");\n      await gu.waitForServer();\n\n      // Remove a row\n      await gu.removeRow(4);\n\n      // Add a row\n      await (await gu.openRowMenu(5)).findContent(\"li\", /Insert row above/).click();\n      await gu.waitForServer();\n      await gu.enterCell(\"Unicorny\");\n      await gu.waitForServer();\n\n      // Now change some references.\n      await gu.getPageItem(\"Performances\").click();\n      await gu.getCell({ rowNum: 1, col: \"Film\" }).click();\n      await gu.enterCell(\"The Avengers\");\n      await gu.waitForServer();\n      await gu.getCell({ rowNum: 2, col: \"Film\" }).click();\n      await gu.enterCell(\"Unicorny\");\n      await gu.waitForServer();\n\n      const forkId = await gu.getCurrentUrlId();\n\n      // Now return to original and make some changes there too.\n      await mainSession.loadDoc(`/doc/${doc.id}`, { skipAlert: true });\n\n      // Change a cell\n      await gu.getCell({ rowNum: 2, col: 1 }).click();\n      await gu.enterCell(\"400\");\n      await gu.waitForServer();\n\n      // Remove a row\n      await gu.removeRow(3);\n\n      // Add a row\n      await (await gu.openRowMenu(2)).findContent(\"li\", /Insert row above/).click();\n      await gu.waitForServer();\n      await gu.enterCell(\"Pegasusy\");\n      await gu.waitForServer();\n\n      // Now change some references.\n      await gu.getPageItem(\"Performances\").click();\n      await gu.getCell({ rowNum: 4, col: \"Film\" }).click();\n      await gu.enterCell(\"Pegasusy\");\n      await gu.waitForServer();\n      await gu.removeRow(3);\n      await (await gu.openRowMenu(10)).findContent(\"li\", /Insert row above/).click();\n      await gu.waitForServer();\n      await gu.getCell({ rowNum: 10, col: \"Film\" }).click();\n      await gu.enterCell(\"The Dark Knight\");\n      await gu.waitForServer();\n\n      // Check that comparing original with changed doc results in a difference.\n      comparisonPath = `/doc/${doc.id}?compare=${forkId}`;\n      await mainSession.loadDoc(comparisonPath);\n\n      let cell = await gu.getCell({ rowNum: 1, col: 1 });\n      assert.equal(await cell.find(\".diff-parent\").getText(), \"30.0\");\n      assert.equal(await cell.find(\".diff-remote\").getText(), \"800.0\");\n      assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n\n      cell = await gu.getCell({ rowNum: 3, col: 1 });\n      assert.equal(await cell.find(\".diff-parent\").getText(), \"55.0\");\n      assert.equal(await cell.find(\".diff-local\").getText(), \"400.0\");\n      assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n\n      assert.match(await driver.find(\".record.diff-local-add\").getText(), /Pegasusy/);\n      assert.match(await driver.find(\".record.diff-remote-add\").getText(), /Unicorny/);\n      assert.match(await driver.find(\".record.diff-remote-remove\").getText(), /Avatar/);\n      assert.match(await driver.find(\".record.diff-local-remove\").getText(), /Alien/);\n\n      // Check that reference changes are visible, including one as an effect of\n      // a removed row.\n      await gu.getPageItem(\"Performances\").click();\n      cell = gu.getCell({ rowNum: 1, col: \"Film\" });\n      assert.equal(await cell.find(\".diff-remote\").getText(), \"The Avengers\");\n      assert.equal(await cell.find(\".diff-parent\").getText(), \"Toy Story\");\n      assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n      cell = gu.getCell({ rowNum: 2, col: \"Film\" });\n      assert.equal(await cell.find(\".diff-parent\").getText(), \"Toy Story\");\n      assert.equal(await cell.find(\".diff-remote\").getText(), \"Unicorny\");\n      assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n      cell = gu.getCell({ rowNum: 4, col: \"Film\" });\n      assert.equal(await cell.find(\".diff-parent\").getText(), \"Forrest Gump\");\n      assert.equal(await cell.find(\".diff-local\").getText(), \"Pegasusy\");\n      assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n      cell = gu.getCell({ rowNum: 6, col: \"Film\" });\n      assert.equal(await cell.find(\".diff-parent\").getText(), \"Alien\");\n      assert.equal(await cell.find(\".diff-local\").getText(), EMPTY_MARK);\n      assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n      cell = gu.getCell({ rowNum: 7, col: \"Film\" });\n      assert.equal(await cell.find(\".diff-parent\").getText(), \"Avatar\");\n      assert.equal(await cell.find(\".diff-remote\").getText(), EMPTY_MARK);\n      assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n      assert.match(await driver.find(\".record.diff-local-remove\").getText(), /Don Rickles/);\n      assert.match(await driver.find(\".record.diff-local-add\").getText(), /The Dark Knight/);\n    });\n\n    it(\"can render changes in card view\", async function() {\n      // Card view should work like table view for cells, and have record removal/addition\n      // styling applied to full card.\n\n      // Open a card view.\n      await mainSession.loadDoc(comparisonPath);\n      await gu.getPageItem(\"All\").click();\n      const section = \"Performances detail\";\n      await gu.getSection(section).click();\n\n      // Check that regular cells and diff cells are present as expected.\n      assert.deepEqual(await gu.getVisibleDetailCells({ col: \"Actor\", rowNums: [1], section }), [\"Tom Hanks\"]);\n      const cell = (await gu.getVisibleDetailCells<WebElement>({\n        col: \"Film\", rowNums: [1], mapper: identity, section,\n      }))[0];\n      assert.equal(await cell.find(\".diff-remote\").getText(), \"The Avengers\");\n      assert.equal(await cell.find(\".diff-parent\").getText(), \"Toy Story\");\n      assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n\n      // Check that a locally-deleted record has expected styling.\n      const sectionEl = await driver.find(\".active_section\");\n      await sectionEl.find(\".grist-single-record__menu .detail-right\").click();\n      await sectionEl.find(\".grist-single-record__menu .detail-right\").click();\n      assert.deepEqual(await gu.getVisibleDetailCells({ col: \"Actor\", rowNums: [1] }), [\"Don Rickles\"]);\n      assert.match(await sectionEl.find(\".g_record_detail.diff-local-remove\").getText(), /Don Rickles/);\n    });\n\n    it(\"can render changes in card list view\", async function() {\n      // Card view should work like table view for cells, and have record removal/addition\n      // styling applied to individual cards.\n\n      await mainSession.loadDoc(comparisonPath);\n      await gu.getPageItem(\"All\").click();\n\n      // Delete existing card view, to avoid accidentally ambiguous tests passing.\n      await gu.openSectionMenu(\"viewLayout\", \"Performances detail\");\n      await driver.findWait(\".test-section-delete\", 500).click();\n      await gu.waitForServer();\n\n      // Add a card list view.\n      await gu.addNewSection(/List/, /Performances/);\n      const section = gu.getSection(\"PERFORMANCES Card List\");\n      await section.click();\n\n      // Check that regular cells and diff cells are present as expected.\n      assert.deepEqual(await gu.getVisibleDetailCells({ col: \"Actor\", rowNums: [1, 3], section }),\n        [\"Tom Hanks\", \"Don Rickles\"]);\n      const cell = (await gu.getVisibleDetailCells<WebElement>({\n        col: \"Film\", rowNums: [1], mapper: identity, section,\n      }))[0];\n      assert.equal(await cell.find(\".diff-remote\").getText(), \"The Avengers\");\n      assert.equal(await cell.find(\".diff-parent\").getText(), \"Toy Story\");\n      assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n\n      // Check that a locally-deleted record has expected styling.\n      assert.equal(await section.find(\".g_record_detail.diff-local-remove\").getText(),\n        [\"3\", \"Actor\", \"Don Rickles\", \"Film\", \"Toy Story\", \"Character\", \"Mr. Potato Head\"].join(\"\\n\"));\n\n      // Check that context menu works.\n      await gu.rightClick(cell);\n      await gu.checkForErrors();\n      await gu.sendKeys(Key.ESCAPE);\n    });\n  });\n\n  it(\"can render cell-level conflicts\", async function() {\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"Hello.grist\");\n\n    // Set some cells.\n    for (let rowNum = 1; rowNum <= 4; rowNum++) {\n      await gu.getCell({ rowNum, col: 1 }).click();\n      await gu.enterCell(\"V0\");\n    }\n    await gu.waitForServer();\n\n    // Make a fork of the document.\n    await mainSession.loadDoc(`/doc/${doc.id}/m/fork`);\n\n    // Change two of the cells.\n    await gu.getCell({ rowNum: 1, col: 1 }).click();\n    await gu.enterCell(\"V1\");\n    await gu.getCell({ rowNum: 2, col: 1 }).click();\n    await gu.enterCell(\"V1\");\n    await gu.waitForServer();\n\n    // Change two cells in trunk.\n    const forkId = await gu.getCurrentUrlId();\n    await mainSession.loadDoc(`/doc/${doc.id}`, { skipAlert: true });\n    await gu.getCell({ rowNum: 2, col: 1 }).click();\n    await gu.enterCell(\"V2\");\n    await gu.getCell({ rowNum: 3, col: 1 }).click();\n    await gu.enterCell(\"V2\");\n    await gu.waitForServer();\n\n    // Load comparison, and sanity-check it.\n    await mainSession.loadDoc(`/doc/${doc.id}?compare=${forkId}`);\n\n    let cell = await gu.getCell({ rowNum: 1, col: 1 });\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"V0\");\n    assert.equal(await cell.find(\".diff-remote\").getText(), \"V1\");\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n\n    cell = await gu.getCell({ rowNum: 2, col: 1 });\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"V0\");\n    assert.equal(await cell.find(\".diff-remote\").getText(), \"V1\");\n    assert.equal(await cell.find(\".diff-local\").getText(), \"V2\");\n\n    cell = await gu.getCell({ rowNum: 3, col: 1 });\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"V0\");\n    assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-local\").getText(), \"V2\");\n\n    cell = await gu.getCell({ rowNum: 4, col: 1 });\n    assert.equal(await cell.find(\".diff-parent\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n  });\n\n  it(\"can distill long changes\", async function() {\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"Hello.grist\");\n\n    // Set a cell to some text.\n    await gu.getCell({ rowNum: 1, col: 1 }).click();\n    await gu.enterCell(\"This is some cell content\");\n    await gu.waitForServer();\n\n    // Wrap text to make it all visible.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    await driver.find(\".test-tb-wrap-text\").click();\n    await gu.waitForServer();\n\n    // Make a fork of the document.\n    await mainSession.loadDoc(`/doc/${doc.id}/m/fork`);\n\n    // Change cell a bit more.\n    await gu.getCell({ rowNum: 1, col: 1 }).click();\n    await gu.enterCell(\"This is some cell content!\");\n    await gu.waitForServer();\n\n    const forkId = await gu.getCurrentUrlId();\n\n    // Check that comparing original with changed doc results in a difference.\n    await mainSession.loadDoc(`/doc/${doc.id}?compare=${forkId}`, { skipAlert: true });\n\n    const cell = await gu.getCell({ rowNum: 1, col: 1 });\n    assert.equal(await cell.find(\".diff-common\").getText(), \"This is some cell content\");\n    assert.equal(await cell.find(\".diff-remote\").getText(), \"!\");\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n  });\n\n  it(\"can tolerate table renames\", async function() {\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"Hello.grist\");\n\n    // Set a cell to some text.\n    await gu.getCell({ rowNum: 1, col: 1 }).click();\n    await gu.enterCell(\"test1\");\n    await gu.waitForServer();\n\n    // Make a fork of the document.\n    await mainSession.loadDoc(`/doc/${doc.id}/m/fork`);\n\n    // Change cell a bit more.\n    await gu.getCell({ rowNum: 1, col: 1 }).click();\n    await gu.enterCell(\"test2\");\n    await gu.waitForServer();\n\n    // And rename the table.\n    await gu.renamePage(/Table1/, \"Zap\");\n\n    const forkId = await gu.getCurrentUrlId();\n\n    // Check that comparing original with changed doc results in a difference.\n    await mainSession.loadDoc(`/doc/${doc.id}?compare=${forkId}`, { skipAlert: true });\n    let cell = await gu.getCell({ rowNum: 1, col: 1 });\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"test1\");\n    assert.equal(await cell.find(\".diff-remote\").getText(), \"test2\");\n\n    // Return to trunk, and rename table there too.\n    await mainSession.loadDoc(`/doc/${doc.id}`);\n    await gu.renamePage(/Table1/, \"Zip\");\n\n    // Check that cell difference is still recognized.\n    await mainSession.loadDoc(`/doc/${doc.id}?compare=${forkId}`);\n    cell = await gu.getCell({ rowNum: 1, col: 1 });\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"test1\");\n    assert.equal(await cell.find(\".diff-remote\").getText(), \"test2\");\n  });\n\n  it(\"can show a change among many rows\", async function() {\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"World.grist\", { load: false });\n\n    // Make a fork of the document.\n    await mainSession.loadDoc(`/doc/${doc.id}/m/fork`);\n\n    // Pick cell with \"Dublin\" in it, replace it with Irish version\n    // of name.\n    await gu.search(\"Dublin\");\n    await gu.waitToPass(async () => assert.equal(await driver.find(\".field_clip.has_cursor\").getText(), \"Dublin\"));\n    await driver.sendKeys(Key.ESCAPE);\n    await driver.sleep(500);\n    await gu.enterCell(\"Baile Átha Cliath\");\n    await gu.waitForServer();\n\n    await driver.find(\".test-tb-share\").click();\n    await driver.findWait(\".test-compare-original\", 5000).click();\n\n    // Switch to the new tab, and wait for the doc to load.\n    const windowHandles = await driver.getAllWindowHandles();\n    await gu.switchToWindow(windowHandles[1]);\n    await gu.waitForDocToLoad();\n\n    assert.deepEqual(\n      await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]), [\n        \"[San Cristóbal de] la Laguna\",   // first row retained\n        \"´s-Hertogenbosch\",               // context row 1\n        \"A Coruña (La Coruña)\",           // context row 2\n        \"...\",                     // ... skip ...\n        \"Drobeta-Turnu Severin\",   // context row -2\n        \"Dubai\",                   // context row -1\n        \"DublinBaile Átha Cliath\", // diff\n        \"Dudley\",                  // context row 1\n        \"Duisburg\",                // context row 2\n        \"...\",                     // ... skip ...\n        \"Zwolle\",                  // context row -2\n        \"Zytomyr\",                 // context row -1\n        \"\",                        // last row retained ('new' row in this case)\n        undefined,                  // past end of rows\n      ]);\n\n    // Close the new tab and switch back to the original one.\n    await driver.close();\n    await driver.switchTo().window(windowHandles[0]);\n  });\n\n  // checks for a specific bug we used to have.\n  it(\"can show an isolated row removal\", async function() {\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"World.grist\", { load: false });\n\n    // Make a fork of the document.\n    await mainSession.loadDoc(`/doc/${doc.id}/m/fork`);\n\n    // Delete a row.\n    await gu.removeRow(9);\n\n    // Request comparison.\n    await driver.find(\".test-tb-share\").click();\n    await driver.findWait(\".test-compare-original\", 5000).click();\n\n    // Switch to the new tab, and wait for the doc to load.\n    await gu.waitToPass(async () => {\n      assert.lengthOf(await driver.getAllWindowHandles(), 2);\n    });\n    const windowHandles = await driver.getAllWindowHandles();\n    await gu.switchToWindow(windowHandles[1]);\n    await gu.waitForDocToLoad();\n\n    // Check comparison includes missing row.\n    assert.deepEqual(\n      await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]), [\n        \"[San Cristóbal de] la Laguna\",   // first row retained\n        \"´s-Hertogenbosch\",               // context row 1\n        \"A Coruña (La Coruña)\",           // context row 2\n        \"...\",                     // ... skip ...\n        \"Abadan\",                  // context row -2\n        \"Abaetetuba\",              // context row -1\n        \"Abakan\",                  // diff\n        \"Abbotsford\",              // context row 1\n        \"Abeokuta\",                // context row 2\n        \"...\",                     // ... skip ...\n        \"Zwolle\",                  // context row -2\n        \"Zytomyr\",                 // context row -1\n        \"\",                        // last row retained ('new' row in this case)\n        undefined,                  // past end of rows\n      ]);\n\n    // Now remove a row from the original document, and reload comparision.\n    const api = mainSession.createHomeApi();\n    await api.getDocAPI(doc.id).removeRows(\"City\", [20]);\n    await driver.executeScript(\"window.location.reload()\");\n    await gu.waitForDocToLoad();\n\n    // Spot check that both removed rows are present in the diff.\n    assert.deepEqual(\n      await gu.getVisibleGridCells(0, [8, 2]),\n      [\"Abakan\", \"´s-Hertogenbosch\"]);\n\n    // Close the new tab and switch back to the original one.\n    await driver.close();\n    await driver.switchTo().window(windowHandles[0]);\n  });\n\n  it(\"disables selection summary\", async function() {\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"World.grist\", { load: false });\n    await mainSession.loadDoc(`/doc/${doc.id}/m/fork`);\n    await gu.removeRow(9);\n    await driver.find(\".test-tb-share\").click();\n    await driver.findWait(\".test-compare-original\", 5000).click();\n    await gu.waitToPass(async () => {\n      assert.lengthOf(await driver.getAllWindowHandles(), 2);\n    });\n    const windowHandles = await driver.getAllWindowHandles();\n    await gu.switchToWindow(windowHandles[1]);\n    await gu.waitForDocToLoad();\n    await gu.getCell(0, 1).click();\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.ARROW_RIGHT));\n    assert.isFalse(await driver.find(\".test-selection-summary-count\").isPresent());\n    await gu.checkForErrors();\n    await driver.close();\n    await driver.switchTo().window(windowHandles[0]);\n  });\n\n  it(\"can do a simple live edit (update cell, add row)\", async function() {\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"Hello.grist\");\n\n    // Set a cell to some text.\n    await gu.getCell({ rowNum: 1, col: 0 }).click();\n    await gu.waitAppFocus(true);\n    await gu.enterCell(\"zeep\");\n    await gu.waitForServer();\n    await gu.getCell({ rowNum: 1, col: 1 }).click();\n    await gu.waitAppFocus(true);\n    await gu.enterCell(\"meep\");\n    await gu.waitForServer();\n\n    // Make a fork of the document.\n    await mainSession.loadDoc(`/doc/${doc.id}/m/fork`);\n    await gu.waitForDocToLoad();\n\n    // Set a cell to some text so we are definitely on a fork.\n    await gu.getCell({ rowNum: 1, col: 1 }).click();\n    await gu.waitAppFocus(true);\n    await gu.enterCell(\"moop\");\n    await gu.waitForServer();\n\n    // Compare with original\n    await driver.find(\".test-tb-share\").click();\n    await driver.findWait(\".test-compare-original\", 5000).click();\n\n    // Switch to the new tab, and wait for the doc to load.\n    await gu.waitToPass(async () => {\n      assert.lengthOf(await driver.getAllWindowHandles(), 2);\n    });\n    const windowHandles = await driver.getAllWindowHandles();\n    await gu.switchToWindow(windowHandles[1]);\n    await gu.waitForDocToLoad();\n\n    let cell = await gu.getCell({ rowNum: 1, col: 1 });\n    assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"meep\");\n    assert.equal(await cell.find(\".diff-local\").getText(), \"moop\");\n\n    cell = await gu.getCell({ rowNum: 1, col: 0 });\n    await cell.click();\n    assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-parent\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n    await gu.waitAppFocus(true);\n    await gu.enterCell(\"zoop\");\n    await gu.waitForServer();\n    assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"zeep\");\n    assert.equal(await cell.find(\".diff-local\").getText(), \"zoop\");\n\n    // Undo/redo should work.\n    await gu.undo();\n    assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-parent\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n    await gu.redo();\n    assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"zeep\");\n    assert.equal(await cell.find(\".diff-local\").getText(), \"zoop\");\n\n    const count = await gu.getGridRowCount();\n\n    // Go to end and add a new record.\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.DOWN));\n    const rowNum = await gu.getSelectedRowNum();\n    await gu.waitAppFocus(true);\n    await gu.enterCell(\"newrecord\");\n    await gu.waitForServer();\n\n    assert.equal(await gu.getGridRowCount(), count + 1);\n\n    cell = await gu.getCell({ rowNum, col: 0 });\n    assert.equal(await cell.find(\".diff-remote\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-parent\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-local\").getText(), \"newrecord\");\n\n    assert.include(await cell.findClosest(\".record\").getAttribute(\"class\"), \"diff-local-add\");\n\n    await gu.undo();\n    assert.equal(await gu.getGridRowCount(), count);\n    await gu.redo();\n    assert.equal(await gu.getGridRowCount(), count + 1);\n\n    // Close the new tab and switch back to the original one.\n    await driver.close();\n    await driver.switchTo().window(windowHandles[0]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/CopyPaste.ts",
    "content": "/**\n * Test for copy-pasting Grist data.\n *\n * TODO Most of the testing for copy-pasting lives in test/nbrowser/CopyPaste.ntest.js.\n * This file just has some more recent additions to these test.\n */\n\nimport { arrayRepeat } from \"app/common/gutil\";\nimport { serveStatic } from \"test/nbrowser/customUtil\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport * as path from \"path\";\n\nimport * as _ from \"lodash\";\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"CopyPaste\", function() {\n  this.timeout(90000);\n  const cleanup = setupTestSuite();\n  const clipboard = gu.getLockableClipboard();\n  afterEach(() => gu.checkForErrors());\n  gu.bigScreen();\n\n  after(async function() {\n    await driver.executeScript(removeDummyTextArea);\n  });\n\n  it(\"should not fail when columns are trimmed\", async function() {\n    const session = await gu.session().login();\n    await session.tempNewDoc(cleanup, \"CopyPaste\");\n    const docUrl = await driver.getCurrentUrl();\n\n    // Remove all columns except the first one.\n    await gu.sendActions([\n      [\"RemoveColumn\", \"Table1\", \"B\"],\n      [\"RemoveColumn\", \"Table1\", \"C\"],\n    ]);\n\n    // Now paste selected text to the first cell, it will be trimmed to fit the single column.\n    await clipboard.lockAndPerform(async (cb) => {\n      await copyTestData(cb);\n      await driver.get(docUrl);\n      await gu.waitForDocToLoad();\n      await gu.getCell({ col: \"A\", rowNum: 1 }).click();\n      await gu.waitAppFocus();\n      await cb.paste();\n    });\n\n    // Make sure we have the expected data.\n    await gu.waitForServer();\n    await gu.checkForErrors();\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4], cols: [\"A\"] }), [\n      \"a\",\n      \"c\",\n      \"d\",\n      \"f\",\n    ]);\n\n    // And we can open the row menu. Before the fix, the logic for pasting was ignoring size overflow, but the\n    // CellSelector was not, what caused an error when opening the row menu.\n\n    await gu.openRowMenu(1);\n    await gu.checkForErrors();\n  });\n\n  it(\"should allow pasting merged cells\", async function() {\n    // Test that we can paste uneven data, i.e. containing merged cells.\n\n    await clipboard.lockAndPerform(async (cb) => {\n      await copyTestData(cb);\n      const session = await gu.session().login();\n      await session.tempNewDoc(cleanup, \"CopyPaste\");\n\n      await gu.getCell({ col: \"A\", rowNum: 1 }).click();\n      await gu.waitAppFocus();\n      await cb.paste();\n    });\n    await gu.waitForServer();\n\n    await gu.checkForErrors();\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4], cols: [\"A\", \"B\"] }), [\n      \"a\", \"b\",\n      \"c\", \"\",\n      \"d\", \"e\",\n      \"f\", \"\",\n    ]);\n  });\n\n  it(\"should parse pasted numbers\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"PasteParsing.grist\");\n    await driver.executeScript(createDummyTextArea);\n\n    await copyAndCheck(clipboard, [\n      \"$1\",        \"1\",\n      \"(2)\",       \"-2\",\n      \"3e4\",       \"30000\",\n      \"5,678.901\", \"5678.901\",\n      \"23%\",       \"0.23\",\n      \"45 678\",    \"45678\",\n\n      // . is a decimal separator in this locale (USA) so this can't be parsed\n      \"1.234.567\", \"1.234.567 INVALID\",\n\n      // Doesn't match the default currency of the document, whereas $ above does\n      \"€89\",       \"€89 INVALID\",\n    ], true);\n\n    // Open the side panel for the numeric column.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n\n    // Switch to currency mode, and check the result.\n    await driver.findContent(\".test-numeric-mode .test-select-button\", /\\$/).click();\n\n    // Same data, just formatted differently\n    await checkGridCells([\n      \"$1\",        \"$1.00\",\n      \"(2)\",       \"-$2.00\",\n      \"3e4\",       \"$30,000.00\",\n      \"5,678.901\", \"$5,678.90\",\n      \"23%\",       \"$0.23\",\n      \"45 678\",    \"$45,678.00\",\n      \"1.234.567\", \"1.234.567 INVALID\",\n      \"€89\",       \"€89 INVALID\",\n    ]);\n\n    // Check that currency is set to 'Default currency' by default (where the default is local currency).\n    assert.equal(await driver.find(\".test-currency-autocomplete input\").value(), \"Default currency (USD)\");\n\n    // Change column setting for currency to Euros\n    await driver.findWait(\".test-currency-autocomplete\", 500).click();\n    await driver.sendKeys(\"eur\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Same data, just formatted differently\n    await checkGridCells([\n      \"$1\",        \"€1.00\",\n      \"(2)\",       \"-€2.00\",\n      \"3e4\",       \"€30,000.00\",\n      \"5,678.901\", \"€5,678.90\",\n      \"23%\",       \"€0.23\",\n      \"45 678\",    \"€45,678.00\",\n      \"1.234.567\", \"1.234.567 INVALID\",\n      \"€89\",       \"€89 INVALID\",\n    ]);\n\n    // Copy the numbers column into itself.\n    // Values which were already parsed remain parsed since it copies the underlying numbers.\n    await clipboard.lockAndPerform(async (cb) => {\n      await copy(cb, \"Parsed\");\n    });\n    await checkGridCells([\n      \"$1\",        \"€1.00\",\n      \"(2)\",       \"-€2.00\",\n      \"3e4\",       \"€30,000.00\",\n      \"5,678.901\", \"€5,678.90\",\n      \"23%\",       \"€0.23\",\n      \"45 678\",    \"€45,678.00\",\n      \"1.234.567\", \"1.234.567 INVALID\",\n\n      // This was invalid before, so it was copied as text.\n      // This time it parsed successfully because the currency matches.\n      \"€89\",       \"€89.00\",\n    ]);\n\n    await copyAndCheck(clipboard, [\n      // Now we're copying from the text column so everything is parsed again.\n      // $ can no longer be parsed now the currency is euros.\n      \"$1\",        \"$1 INVALID\",\n\n      \"(2)\",       \"-€2.00\",\n      \"3e4\",       \"€30,000.00\",\n      \"5,678.901\", \"€5,678.90\",\n      \"23%\",       \"€0.23\",\n      \"45 678\",    \"€45,678.00\",\n      \"1.234.567\", \"1.234.567 INVALID\",\n      \"€89\",       \"€89.00\",\n    ], true);\n\n    // Change the document locale\n    await gu.openDocumentSettings();\n    await driver.findWait(\".test-settings-locale-autocomplete\", 500).click();\n    await driver.sendKeys(\"Germany\", Key.ENTER);\n    await gu.waitForServer();\n    await driver.navigate().back();\n\n    // Same data, just formatted differently\n    // Currency sign has moved to the end\n    // Decimal separator is now ','\n    // Digit group separator is now '.'\n    await checkGridCells([\n      \"$1\",        \"$1 INVALID\",\n      \"(2)\",       \"-2,00 €\",\n      \"3e4\",       \"30.000,00 €\",\n      \"5,678.901\", \"5.678,90 €\",\n      \"23%\",       \"0,23 €\",\n      \"45 678\",    \"45.678,00 €\",\n      \"1.234.567\", \"1.234.567 INVALID\",\n      \"€89\",       \"89,00 €\",\n    ]);\n\n    // Copy the numbers column into itself.\n    // Values which were already parsed don't change since it copies the underlying numbers.\n    await clipboard.lockAndPerform(async (cb) => {\n      await copy(cb, \"Parsed\");\n    });\n    await checkGridCells([\n      \"$1\",        \"$1 INVALID\",\n      \"(2)\",       \"-2,00 €\",\n      \"3e4\",       \"30.000,00 €\",\n      \"5,678.901\", \"5.678,90 €\",\n      \"23%\",       \"0,23 €\",\n      \"45 678\",    \"45.678,00 €\",\n\n      // This can be parsed for the first time now that '.'\n      // is seen as a digit group separator\n      \"1.234.567\", \"1.234.567,00 €\",\n\n      \"€89\",       \"89,00 €\",\n    ]);\n\n    await copyAndCheck(clipboard, [\n      \"$1\",        \"$1 INVALID\",\n      \"(2)\",       \"-2,00 €\",\n      \"3e4\",       \"30.000,00 €\",\n\n      // Now we're copying from the text column so everything is parsed again.\n      // The result in this case is not good:\n      // '.' was simply removed because we don't check where it is\n      // ',' is the decimal separator\n      // So this is parsed as 5.678901\n      // which rounds to 5.68 to two decimal places for the currency format\n      \"5,678.901\", \"5,68 €\",\n\n      \"23%\",       \"0,23 €\",\n      \"45 678\",    \"45.678,00 €\",\n      \"1.234.567\", \"1.234.567,00 €\",\n      \"€89\",       \"89,00 €\",\n    ], true);\n  });\n\n  it(\"should parse pasted dates\", async function() {\n    await gu.getPageItem(\"Dates\").click();\n\n    await copyAndCheck(clipboard, [\n      \"01-02-03\",   \"01-02-2003\",\n      \"01 02 2003\", \"01-02-2003\",\n      \"1/02/03\",    \"01-02-2003\",\n      \"01/2/03\",    \"01-02-2003\",\n      \"1/2/03\",     \"01-02-2003\",\n      \"1/2/3\",      \"1/2/3 INVALID\",\n      \"20/10/03\",   \"20-10-2003\",\n      \"10/20/03\",   \"10/20/03 INVALID\",\n    ]);\n\n    await gu.getCell({ col: \"Parsed\", rowNum: 1 }).click();\n    assert.equal(await gu.getDateFormat(), \"DD-MM-YYYY\");\n    await gu.setDateFormat(\"MM-DD-YYYY\");\n\n    // Same data, just formatted differently\n    await checkGridCells([\n      \"01-02-03\",   \"02-01-2003\",\n      \"01 02 2003\", \"02-01-2003\",\n      \"1/02/03\",    \"02-01-2003\",\n      \"01/2/03\",    \"02-01-2003\",\n      \"1/2/03\",     \"02-01-2003\",\n      \"1/2/3\",      \"1/2/3 INVALID\",\n      \"20/10/03\",   \"10-20-2003\",\n      \"10/20/03\",   \"10/20/03 INVALID\",\n    ]);\n\n    // Copy the parsed column into itself.\n    // Values which were already parsed don't change since it copies the underlying values.\n    await clipboard.lockAndPerform(async (cb) => {\n      await copy(cb, \"Parsed\");\n    });\n\n    await checkGridCells([\n      \"01-02-03\",   \"02-01-2003\",\n      \"01 02 2003\", \"02-01-2003\",\n      \"1/02/03\",    \"02-01-2003\",\n      \"01/2/03\",    \"02-01-2003\",\n      \"1/2/03\",     \"02-01-2003\",\n      \"1/2/3\",      \"1/2/3 INVALID\",\n      \"20/10/03\",   \"10-20-2003\",\n      \"10/20/03\",   \"10-20-2003\",  // can be parsed now\n    ]);\n\n    // Copy from the text column again, things get re-parsed\n    await copyAndCheck(clipboard, [\n      \"01-02-03\",   \"01-02-2003\",\n      \"01 02 2003\", \"01-02-2003\",\n      \"1/02/03\",    \"01-02-2003\",\n      \"01/2/03\",    \"01-02-2003\",\n      \"1/2/03\",     \"01-02-2003\",\n      \"1/2/3\",      \"1/2/3 INVALID\",\n      \"20/10/03\",   \"20/10/03 INVALID\",  // newly invalid\n      \"10/20/03\",   \"10-20-2003\",\n    ]);\n  });\n\n  // Note that these tests which reference other tables\n  // assume that the previous tests have run.\n  it(\"should parse pasted references\", async function() {\n    await gu.getPageItem(\"References\").click();\n    await gu.getCell({ col: \"Parsed\", rowNum: 1 }).click();\n    assert.equal(await gu.getRefTable(), \"Dates\");\n    assert.equal(await gu.getRefShowColumn(), \"Text\");\n\n    // Initially the References.Parsed column is displaying Dates.Text\n    // No date parsing happens, we just see which strings exist in that column\n    await copyAndCheck(clipboard, [\n      \"20/10/03\", \"20/10/03\",\n      \"10/20/03\", \"10/20/03\",\n      \"1/2/3\",    \"1/2/3\",\n      \"foo\",      \"foo INVALID\",\n      \"3\",        \"3 INVALID\",\n      \"-2\",       \"-2 INVALID\",\n      \"$1\",       \"$1 INVALID\",\n      \"€89\",      \"€89 INVALID\",\n    ], true);\n\n    await gu.setRefShowColumn(\"Parsed\");\n\n    // // Same data, just formatted differently\n    await checkGridCells([\n      // In the Parsed column, only the second value was parsed as an actual date\n      // The others look invalid in the Dates table, but here they're valid references\n      \"20/10/03\", \"20/10/03\",\n      \"10/20/03\", \"10-20-2003\",\n      \"1/2/3\",    \"1/2/3\",\n\n      \"foo\",      \"foo INVALID\",\n      \"3\",        \"3 INVALID\",\n      \"-2\",       \"-2 INVALID\",\n      \"$1\",       \"$1 INVALID\",\n      \"€89\",      \"€89 INVALID\",\n    ]);\n\n    await copyAndCheck(clipboard, [\n      \"20/10/03\", \"20/10/03\",\n      \"10/20/03\", \"10-20-2003\",\n      \"1/2/3\",    \"1/2/3\",\n      \"foo\",      \"foo INVALID\",\n      \"3\",        `3 INVALID`,\n      \"-2\",       `-2 INVALID`,\n      \"$1\",       `$1 INVALID`,\n      \"€89\",      \"€89 INVALID\",\n    ]);\n\n    await gu.setRefShowColumn(\"Row ID\");\n\n    // Same data, just formatted differently\n    await checkGridCells([\n      \"20/10/03\", \"Dates[5]\",\n      \"10/20/03\", \"Dates[6]\",\n      \"1/2/3\",    \"Dates[4]\",\n      \"foo\",      \"foo INVALID\",\n      \"3\",        `3 INVALID`,\n      \"-2\",       `-2 INVALID`,\n      \"$1\",       `$1 INVALID`,\n      \"€89\",      \"€89 INVALID\",\n    ]);\n\n    await copyAndCheck(clipboard, [\n      \"20/10/03\", \"20/10/03 INVALID\",\n      \"10/20/03\", \"10/20/03 INVALID\",\n      \"1/2/3\",    \"1/2/3 INVALID\",\n      \"foo\",      \"foo INVALID\",\n      \"3\",        \"Dates[3]\",  // 3 is the only valid Row ID\n      \"-2\",       \"-2 INVALID\",\n      \"$1\",       \"$1 INVALID\",\n      \"€89\",      \"€89 INVALID\",\n    ]);\n\n    await gu.setRefTable(\"Numbers\");\n\n    // These checks run with References.Parsed as both a Reference and Reference List column.\n    async function checkRefsToNumbers() {\n      await gu.setRefShowColumn(\"Row ID\");\n\n      await copyAndCheck(clipboard, [\n        \"20/10/03\", \"20/10/03 INVALID\",\n        \"10/20/03\", \"10/20/03 INVALID\",\n        \"1/2/3\",    \"1/2/3 INVALID\",\n        \"foo\",      \"foo INVALID\",\n        \"3\",        \"Numbers[3]\",\n        \"-2\",       \"-2 INVALID\",\n        \"$1\",       \"$1 INVALID\",\n        \"€89\",      \"€89 INVALID\",\n      ], true);\n\n      await gu.setRefShowColumn(\"Text\");\n\n      await copyAndCheck(clipboard, [\n        \"20/10/03\", \"20/10/03 INVALID\",\n        \"10/20/03\", \"10/20/03 INVALID\",\n        \"1/2/3\",    \"1/2/3 INVALID\",\n        \"foo\",      \"foo INVALID\",\n        \"3\",        \"3 INVALID\",\n        \"-2\",       \"-2 INVALID\",\n        // These are the only strings that appear in Numbers.Text verbatim\n        \"$1\",       \"$1\",\n        \"€89\",      \"€89\",\n      ]);\n\n      await gu.setRefShowColumn(\"Parsed\");\n\n      // Same data, just formatted differently\n      await checkGridCells([\n        \"20/10/03\", \"20/10/03 INVALID\",\n        \"10/20/03\", \"10/20/03 INVALID\",\n        \"1/2/3\",    \"1/2/3 INVALID\",\n        \"foo\",      \"foo INVALID\",\n        \"3\",        \"3 INVALID\",\n        \"-2\",       \"-2 INVALID\",\n        \"$1\",       \"$1\",\n        \"€89\",      \"89,00 €\",\n      ]);\n\n      await copyAndCheck(clipboard, [\n        \"20/10/03\", \"20/10/03 INVALID\",\n        \"10/20/03\", \"10/20/03 INVALID\",\n        \"1/2/3\",    \"1/2/3 INVALID\",\n        \"foo\",      \"foo INVALID\",\n        \"3\",        \"3 INVALID\",  // parsed, but not a valid reference\n        \"-2\",       \"-2,00 €\",\n        \"$1\",       \"$1\",  // invalid in Numbers.parsed, but a valid reference\n        \"€89\",      \"89,00 €\",\n      ]);\n    }\n\n    await checkRefsToNumbers();\n\n    // Copy the Parsed column into the same column in a forked document.\n    // Because it's a different document, it uses the display values instead of the raw values (row IDs)\n    // to avoid referencing the wrong rows.\n    await clipboard.lockAndPerform(async (cb) => {\n      await copy(cb, \"Parsed\");\n      await driver.get(await driver.getCurrentUrl() + \"/m/fork\");\n      await gu.waitForDocToLoad();\n      await driver.executeScript(createDummyTextArea);\n      await gu.setRefShowColumn(\"Text\");\n      await paste(cb);\n    });\n    await checkGridCells([\n      \"20/10/03\", \"20/10/03 INVALID\",\n      \"10/20/03\", \"10/20/03 INVALID\",\n      \"1/2/3\",    \"1/2/3 INVALID\",\n      \"foo\",      \"foo INVALID\",\n      \"3\",        \"3 INVALID\",\n      \"-2\",       \"-2,00 € INVALID\",\n      \"$1\",       \"$1\",\n      \"€89\",      \"89,00 € INVALID\",\n    ]);\n\n    // Test the main copies with the Numbers table data not loaded in the browser\n    // so the lookups get done in the data engine.\n    await checkRefsToNumbers();\n\n    // Now test that pasting the same values into a Reference List column\n    // produces the same result (reflists containing a single reference)\n    await gu.setType(/Reference List/, { apply: true });\n\n    // Clear the Parsed column. Make sure we don't edit the column header.\n    await gu.getCell({ col: \"Parsed\", rowNum: 1 }).click();\n    await gu.getColumnHeader({ col: \"Parsed\" }).click();\n    await gu.sendKeys(Key.BACK_SPACE);\n    await gu.waitForServer();\n\n    await checkRefsToNumbers();\n  });\n\n  it(\"should parse pasted reference lists containing multiple values\", async function() {\n    async function checkMultiRefs() {\n      await gu.setRefShowColumn(\"Row ID\");\n\n      await copyAndCheck(clipboard, [\n        '\"(2)\",$1',     '\"(2)\",$1 INVALID',\n        \"$1,(2),22\",    \"$1,(2),22 INVALID\",\n        '[\"$1\",-2]',    '[\"$1\",-2] INVALID',\n        \"1,-2\",         \"1,-2 INVALID\",\n        \"3,5\",          \"Numbers[3]\\nNumbers[5]\",  // only valid row IDs\n        \"-2,30000\",     \"-2,30000 INVALID\",\n        \"7,0\",          \"7,0 INVALID\",  // 0 is not a valid row ID\n        \"\",             \"\",\n      ]);\n\n      await gu.setRefShowColumn(\"Text\");\n\n      await copyAndCheck(clipboard, [\n        '\"(2)\",$1',     \"(2)\\n$1\",  // only verbatim text\n        \"$1,(2),22\",    \"$1,(2),22 INVALID\",  // 22 is invalid so whole thing fails\n        '[\"$1\",-2]',    '[\"$1\",-2] INVALID',  // -2 is invalid because this is text, not parsed\n        \"1,-2\",         \"1,-2 INVALID\",\n        \"3,5\",          \"3,5 INVALID\",\n        \"-2,30000\",     \"-2,30000 INVALID\",\n        \"7,0\",          \"7,0 INVALID\",\n        \"\",             \"\",\n      ]);\n\n      await gu.setRefShowColumn(\"Parsed\");\n\n      await copyAndCheck(clipboard, [\n        '\"(2)\",$1',     \"-2,00 €\\n$1\",\n        \"$1,(2),22\",    \"$1,(2),22 INVALID\",\n        '[\"$1\",-2]',    \"$1\\n-2,00 €\",\n        \"1,-2\",         \"1,-2 INVALID\",\n        \"3,5\",          \"3,5 INVALID\",\n        \"-2,30000\",     \"-2,00 €\\n30.000,00 €\",\n        \"7,0\",          \"7,0 INVALID\",\n        \"\",             \"\",\n      ], true);\n    }\n\n    await gu.getPageItem(\"Multi-References\").click();\n    await gu.waitForServer();\n    await gu.getCell({ col: \"Parsed\", rowNum: 1 }).click();\n\n    await checkMultiRefs();\n\n    // Load the Numbers table data in the browser and check again\n    await gu.getPageItem(\"Numbers\").click();\n    await gu.getPageItem(\"Multi-References\").click();\n    await gu.waitForServer();\n    await checkMultiRefs();\n  });\n\n  it(\"should parse pasted choice lists\", async function() {\n    await gu.getPageItem(\"ChoiceLists\").click();\n    await gu.waitForServer();\n\n    await copyAndCheck(clipboard, [\n      \"\",                            \"\",\n      \"a\",                           \"a\",\n\n      // On the left, \\n in text affects parsing and separates choices\n      // On the right, \\n is how choices are separated in .getText()\n      // So the newlines on the two sides match, but also \"e,f\" -> \"e\\nf\"\n      \"a b\\nc d\\ne,f\",               \"a b\\nc d\\ne\\nf\",\n\n      // CSVs\n      \"a,b  \",                       \"a\\nb\",\n      '  \"a  \", b,\"a,b  \"  ',        \"a\\nb\\na,b\",\n\n      // JSON. Empty strings and null are removed\n      ' [\"a\",\"b\",\"a,b\", null] ',     \"a\\nb\\na,b\",\n\n      // Nested JSON is formatted as JSON or CSV depending on nesting level\n      '[\"a\",\"b\",[\"a,b\"], [[\"a,b\"]], [[\"a\", \"b\"], \"c\", \"d\"], \"\", \"  \"]',\n      'a\\nb\\n\"a,b\"\\n[[\"a,b\"]]\\n[[\"a\", \"b\"], \"c\", \"d\"]',\n\n      \"[]\",                          \"\",\n    ], true);\n  });\n\n  it(\"should parse pasted datetimes\", async function() {\n    await gu.getPageItem(\"DateTimes\").click();\n    await gu.waitForServer();\n\n    await copyAndCheck(clipboard, [\n      \"2021-11-12 22:57:17+03:00\", \"12-11-2021 21:57 SAST\",  // note the 1-hour difference\n      \"2021-11-12 22:57:17+02:00\", \"12-11-2021 22:57 SAST\",\n      \"12-11-2021 22:57:17 SAST\",  \"12-11-2021 22:57 SAST\",\n      \"12-11-2021 22:57:17\",       \"12-11-2021 22:57 SAST\",\n      \"12-11-2021 22:57:17 UTC\",   \"13-11-2021 00:57 SAST\",  // note the 2-hour difference\n      \"12-11-2021 22:57:17 Z\",     \"13-11-2021 00:57 SAST\",  // note the 2-hour difference\n      // EST doesn't match the current timezone so it's rejected\n      \"12-11-2021 22:57:17 EST\",   \"12-11-2021 22:57:17 EST INVALID\",\n      // Date without time is allowed\n      \"12-11-2021\",                \"12-11-2021 00:00 SAST\",\n    ]);\n  });\n});\n\n// mapper for getVisibleGridCells to get both text and whether the cell is invalid (pink).\n// Invalid cells mean text that was not parsed to the column type.\nasync function mapper(el: WebElement) {\n  let text = await el.getText();\n  if (await el.find(\".field_clip\").matches(\".invalid\")) {\n    text += \" INVALID\";\n  }\n  return text;\n}\n\n// Checks that the full grid is equal to the given argument\n// The first column never changes, it's only included for readability of the test\nasync function checkGridCells(expected: string[]) {\n  const actual = await gu.getVisibleGridCells({ rowNums: _.range(1, 9), cols: [\"Text\", \"Parsed\"], mapper });\n  assert.deepEqual(actual, expected);\n}\n\n// Paste whatever's in the clipboard into the Parsed column\nasync function paste(cb: gu.IClipboard) {\n  // Click the first cell rather than the column header so that it doesn't try renaming the column\n  await gu.getCell({ col: \"Parsed\", rowNum: 1 }).click();\n  await cb.paste();\n  await gu.waitForServer();\n  await gu.checkForErrors();\n}\n\n// Copy the contents of fromCol into the Parsed column\nasync function copy(cb: gu.IClipboard, fromCol: \"Text\" | \"Parsed\") {\n  await gu.getColumnHeader({ col: fromCol }).click();\n  await cb.copy();\n  await paste(cb);\n}\n\nasync function copyAndCheck(\n  clipboard: gu.ILockableClipboard,\n  expected: string[],\n  extraChecks: boolean = false,\n) {\n  await clipboard.lockAndPerform(async (cb) => {\n    // Copy Text cells into the Parsed column\n    await copy(cb, \"Text\");\n    await checkGridCells(expected);\n\n    // Tests some extra features of parsing that don't really depend on the column\n    // type and so don't need to be checked with every call to copyAndCheck\n    if (extraChecks) {\n      // With the text cells still in the clipboard, convert the clipboard from\n      // rich data (cells) to plain text and confirm that it gets parsed the same way.\n      // The cells are still selected, clear them all.\n      await gu.sendKeys(Key.BACK_SPACE);\n      await gu.waitForServer();\n      assert.deepEqual(\n        await gu.getVisibleGridCells({ rowNums: _.range(1, 9), cols: [\"Parsed\"] }),\n        arrayRepeat(8, \"\"),\n      );\n\n      // Paste the text cells to the dummy textarea.\n      await driver.find(\"#dummyText\").click();\n      await gu.waitAppFocus(false);\n      await cb.paste();\n    }\n  });\n\n  if (extraChecks) {\n    await gu.sendKeys(await gu.selectAllKey());\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.sendKeys(Key.BACK_SPACE);\n\n      // Paste the now plain text and confirm that the resulting data is still the same.\n      await gu.getCell({ col: \"Text\", rowNum: 1 }).click();\n      await gu.waitAppFocus();\n      await paste(cb);\n    });\n    await checkGridCells(expected);\n\n    // Check that copying from the Parsed column back into itself doesn't change anything.\n    await clipboard.lockAndPerform(async (cb) => {\n      await copy(cb, \"Parsed\");\n    });\n    await checkGridCells(expected);\n  }\n}\n\nexport function createDummyTextArea() {\n  const textarea = document.createElement(\"textarea\");\n  textarea.style.position = \"absolute\";\n  textarea.style.top = \"0\";\n  textarea.style.height = \"2rem\";\n  textarea.style.width = \"16rem\";\n  textarea.id = \"dummyText\";\n  window.document.body.appendChild(textarea);\n}\n\nexport function removeDummyTextArea() {\n  const textarea = document.getElementById(\"dummyText\");\n  if (textarea) {\n    window.document.body.removeChild(textarea);\n  }\n}\n\nasync function copyTestData(cb: gu.IClipboard) {\n  const serving = await serveStatic(path.join(gu.fixturesRoot, \"sites/paste\"));\n  try {\n    await driver.get(`${serving.url}/paste.html`);\n    // Select everything in our little page.\n    await driver.executeScript(`\n      let range = document.createRange();\n      range.selectNodeContents(document.querySelector('table'));\n      let sel = window.getSelection();\n      sel.removeAllRanges();\n      sel.addRange(range);\n    `);\n    await cb.copy();\n  } finally {\n    await serving?.shutdown();\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/CopyPaste2.ntest.js",
    "content": "/* global window */\n\nimport { assert, driver } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\n// Helper that returns the cell text prefixed by \"+\" if the cell is selected, \"-\" if not.\nasync function selText(cell) {\n  const isSelected = await cell.hasClass(\"selected\");\n  const text = await cell.getAttribute(\"textContent\");\n  return (isSelected ? \"+\" : \"-\") + text;\n}\n\ndescribe(\"CopyPaste2.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n  const clipboard = gu.getLockableClipboard();\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"CopyPaste2.grist\", true);\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should highlight correct cells after paste\", async function() {\n    // After paste, the right cells should be highlighted (there was a bug with it when cursor was\n    // not in the top-left corner of the destination selection).\n\n    // Select a 3x2 rectangle, and check that the data and selection is as we expect.\n    await gu.clickCell({rowNum: 3, col: 0});\n    await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN, $.DOWN]);\n    assert.deepEqual(await gu.getGridValues({rowNums: [3, 4, 5, 6, 7], cols: [0, 1, 2], mapper: selText}), [\n      \"+A3\", \"+B3\", \"-C3\",  // rowNum 3\n      \"+A4\", \"+B4\", \"-C4\",  // rowNum 4\n      \"+A5\", \"+B5\", \"-C5\",  // rowNum 5\n      \"-A6\", \"-B6\", \"-C6\",  // rowNum 6\n      \"-A7\", \"-B7\", \"-C7\",  // rowNum 7\n    ]);\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n\n      // For destination, select rows 5-6, but with cursor in the bottom-right corner of them.\n      await gu.clickCell({rowNum: 6, col: 1});\n      await gu.sendKeys([$.SHIFT, $.LEFT], [$.SHIFT, $.UP]);\n      await cb.paste();\n    });\n    await gu.waitForServer();\n\n    // The result should have 3 rows selected starting from row 5, col 0.\n    assert.deepEqual(await gu.getCursorPosition(), {rowNum: 6, col: 1});\n    assert.deepEqual(await gu.getGridValues({rowNums: [3, 4, 5, 6, 7], cols: [0, 1, 2], mapper: selText}), [\n      \"-A3\", \"-B3\", \"-C3\",  // rowNum 3\n      \"-A4\", \"-B4\", \"-C4\",  // rowNum 4\n      \"+A3\", \"+B3\", \"-C5\",  // rowNum 5\n      \"+A4\", \"+B4\", \"-C6\",  // rowNum 6\n      \"+A5\", \"+B5\", \"-C7\",  // rowNum 7\n    ]);\n\n    await gu.undo();    // Go back to initial state.\n  });\n\n  it(\"should allow paste into sorted grids\", async function() {\n    // Sort by column A.\n    await gu.clickCell({rowNum: 1, col: 0});\n    await gu.openColumnMenu(\"A\");\n    await $(\".grist-floating-menu .test-sort-asc\").click();\n    await gu.clickCell({rowNum: 1, col: 0});\n\n    // Check the initial state. Refer to this when trying to understand the results of each step.\n    assert.deepEqual(await gu.getGridValues({rowNums: [3, 4, 5, 6, 7], cols: [0, 1, 2], mapper: selText}), [\n      \"-A3\", \"-B3\", \"-C3\",  // rowNum 3\n      \"-A4\", \"-B4\", \"-C4\",  // rowNum 4\n      \"-A5\", \"-B5\", \"-C5\",  // rowNum 5\n      \"-A6\", \"-B6\", \"-C6\",  // rowNum 6\n      \"-A7\", \"-B7\", \"-C7\",  // rowNum 7\n    ]);\n\n    // First test pasting columns B,C: order of rows is not affected.\n    await gu.clickCell({rowNum: 3, col: 1});\n    await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN, $.DOWN]);\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.clickCell({rowNum: 5, col: 1});\n      await cb.paste();\n    });\n    await gu.waitForServer();\n\n    // Check values, and also that the selection is in the paste destination.\n    assert.deepEqual(await gu.getCursorPosition(), {rowNum: 5, col: 1});\n    assert.deepEqual(await gu.getGridValues({rowNums: [3, 4, 5, 6, 7], cols: [0, 1, 2], mapper: selText}), [\n      \"-A3\", \"-B3\", \"-C3\",  // rowNum 3\n      \"-A4\", \"-B4\", \"-C4\",  // rowNum 4\n      \"-A5\", \"+B3\", \"+C3\",  // rowNum 5\n      \"-A6\", \"+B4\", \"+C4\",  // rowNum 6\n      \"-A7\", \"+B5\", \"+C5\",  // rowNum 7\n    ]);\n\n    await gu.undo();    // Go back to initial state.\n\n    // Now test pasting columns A,B. First a single row: it jumps but cursor should stay in it.\n    await gu.clickCell({rowNum: 7, col: 0});\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.clickCell({rowNum: 3, col: 0});\n      await cb.paste();\n    });\n    await gu.waitForServer();\n\n    // Check values, and also that the selection is in the paste destination.\n    assert.deepEqual(await gu.getCursorPosition(), {rowNum: 6, col: 0});\n    assert.deepEqual(await gu.getGridValues({rowNums: [3, 4, 5, 6, 7], cols: [0, 1, 2], mapper: selText}), [\n      \"-A4\", \"-B4\", \"-C4\",  // rowNum 3\n      \"-A5\", \"-B5\", \"-C5\",  // rowNum 4\n      \"-A6\", \"-B6\", \"-C6\",  // rowNum 5\n      \"-A7\", \"-B3\", \"-C3\",  // rowNum 6\n      \"-A7\", \"-B7\", \"-C7\",  // rowNum 7\n    ]);\n\n    await gu.undo();    // Go back to initial state.\n\n    // Now multiple rows / columns, including adding records.\n    await gu.clickCell({rowNum: 3, col: 0});\n    await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN, $.DOWN]);\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.clickCell({rowNum: 6, col: 0});\n      await cb.paste();\n    });\n    await gu.waitForServer();\n\n    // Cursor should be in the row which used to be row 6 (has C5 in it); selection is lost\n    // because rows are no longer contiguous (and better behavior is not yet implemented).\n    assert.deepEqual(await gu.getCursorPosition(), {rowNum: 4, col: 0});\n    assert.deepEqual(await gu.getGridValues({rowNums: [3, 4, 5, 6, 7, 8], cols: [0, 1, 2], mapper: selText}), [\n      \"-A3\", \"-B3\", \"-C3\",  // rowNum 3\n      \"-A3\", \"-B3\", \"-C6\",  // rowNum 4\n      \"-A4\", \"-B4\", \"-C4\",  // rowNum 5\n      \"-A4\", \"-B4\", \"-C7\",  // rowNum 6\n      \"-A5\", \"-B5\", \"-C5\",  // rowNum 7\n      \"-A5\", \"-B5\", \"-\",    // rowNum 8\n    ]);\n\n    await gu.undo();    // Go back to initial state.\n\n    // Now B/C column into A/B column, with a row shift. This happens to keep destination rows\n    // together, so we check that the selection is maintained.\n    await gu.clickCell({rowNum: 3, col: 1});\n    await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN]);\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.clickCell({rowNum: 5, col: 0});\n      await cb.paste();\n    });\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getCursorPosition(), {rowNum: 6, col: 0});\n    assert.deepEqual(await gu.getGridValues({rowNums: [3, 4, 5, 6, 7], cols: [0, 1, 2], mapper: selText}), [\n      \"-A3\", \"-B3\", \"-C3\",  // rowNum 3\n      \"-A4\", \"-B4\", \"-C4\",  // rowNum 4\n      \"-A7\", \"-B7\", \"-C7\",  // rowNum 5\n      \"+B3\", \"+C3\", \"-C5\",  // rowNum 6\n      \"+B4\", \"+C4\", \"-C6\",  // rowNum 7\n    ]);\n\n    await gu.undo();    // Go back to initial state.\n\n    // Undo the sorting.\n    $(\".test-section-menu-small-btn-revert\").click();\n  });\n\n  it.skip(\"should copy formatted values to clipboard\", async function() {\n    // Formatted values should be copied to the clipboard as the user sees them (particularly for\n    // Dates and Reference columns).\n    //\n    // FIXME: this test currently fails in headless environments, seemingly due to changes to\n    // clipboard behavior in recent versions of chromedriver.\n\n    // Select a 3x2 rectangle, and check that the data and selection is as we expect.\n    await gu.clickCell({rowNum: 3, col: 2});\n    await gu.sendKeys([$.SHIFT, $.RIGHT, $.RIGHT, $.RIGHT, $.RIGHT, $.RIGHT], [$.SHIFT, $.DOWN, $.DOWN]);\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [2, 3, 4, 5, 6, 7], mapper: selText}), [\n      \"-C1\", \"-17.504\",   \"-02/29/16\", \"-2016-02-29 9:30am\", \"-April 13, 1743\",    \"-Jefferson\",\n      \"-C2\", \"--3.222\",   \"-03/31/16\", \"-2016-03-31 9:30am\", \"-March 16, 1751\",    \"-Madison\",\n      \"+C3\", \"+-4.018\",   \"+04/30/16\", \"+2016-04-30 9:30am\", \"+October 30, 1735\",  \"+Adams\",\n      \"+C4\", \"+1829.324\", \"+05/31/16\", \"+2016-05-31 9:30am\", \"+February 22, 1732\", \"+Washington\",\n      \"+C5\", \"+9402.556\", \"+06/30/16\", \"+2016-06-30 9:30am\", \"+\",                   \"+\",\n      \"-C6\", \"-12.000\",   \"-07/31/16\", \"-2016-07-31 9:30am\", \"-February 22, 1732\", \"-Washington\",\n      \"-C7\", \"-0.001\",    \"-08/31/16\", \"-2016-08-31 9:30am\", \"-April 13, 1743\",    \"-Jefferson\",\n    ]);\n\n    // Paste data as the text into the open editor of top-left cell, and save.\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.clickCell({rowNum: 1, col: 0});\n      await gu.sendKeys($.ENTER, $.SELECT_ALL);\n      await cb.paste();\n    });\n    await gu.sendKeys($.ENTER);\n    await gu.waitForServer();\n\n    // Note how all values are formatted in the same way as above.\n    assert.deepEqual(await gu.getCell({rowNum: 1, col: 0}).getAttribute(\"textContent\"),\n      \"C3\\t-4.018\\t04/30/16\\t2016-04-30 9:30am\\tOctober 30, 1735\\tAdams\\n\" +\n      \"C4\\t1829.324\\t05/31/16\\t2016-05-31 9:30am\\tFebruary 22, 1732\\tWashington\\n\" +\n      \"C5\\t9402.556\\t06/30/16\\t2016-06-30 9:30am\\t\\t\");\n    await gu.undo();    // Go back to initial state.\n  });\n\n  it.skip(\"should copy properly in the presence of special characters\", async function() {\n    // If we copy multiple cells (generating text/html to clipboard) and the cells contain special\n    // html characters (such as angle brackets), those should be escaped.\n    //\n    // FIXME: this test currently fails in headless environments, seemingly due to changes to\n    // clipboard behavior in recent versions of chromedriver.\n\n    await gu.clickCell({rowNum: 1, col: 1});\n    await gu.sendKeys($.ENTER, $.SELECT_ALL, \"<tag> for\", [$.SHIFT, $.ENTER], \"you & me;\", $.ENTER);\n    await gu.waitForServer();\n\n    // Add a listener that will save the prepared clipboard data, so that we can examine it.\n    await driver.executeScript(function() {\n      window.gristCopyHandler = ev => {\n        window.copiedClipboardData = {};\n        for (let t of ev.clipboardData.types) {\n          window.copiedClipboardData[t] = ev.clipboardData.getData(t);\n        }\n      };\n      window.addEventListener(\"copy\", window.gristCopyHandler);\n    });\n\n    try {\n      // Now copy a multi-cell selection including this cell.\n      await gu.clickCell({rowNum: 1, col: 0});\n      await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN]);\n      assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [0, 1, 2], mapper: selText}), [\n        \"+A1\", \"+<tag> for\\nyou & me;\", \"-C1\",\n        \"+A2\", \"+B2\",                   \"-C2\",\n      ]);\n\n      await clipboard.lockAndPerform(async (cb) => {\n        await cb.copy();\n\n        // Firefox and Chrome actually produce slightly different html, so we just check the part that\n        // matters: that angle brackets and ampersand got escaped.\n        assert.include(await driver.executeScript(() => window.copiedClipboardData[\"text/html\"]),\n          \"<td>A1</td><td>&lt;tag&gt; for\\nyou &amp; me;</td>\");\n\n        // Check the contents of text that got copied to the clipboard\n        assert.equal(await driver.executeScript(() => window.copiedClipboardData[\"text/plain\"]),\n          'A1\\t\"<tag> for\\nyou & me;\"\\n' +\n                    \"A2\\tB2\"\n        );\n\n        // We can check that we also accept such text correctly by pasting as text inside a cell, and\n        // then copy-pasting from there.\n        await gu.clickCell({rowNum: 3, col: 0});\n        await gu.sendKeys($.ENTER, $.SELECT_ALL);\n        await cb.paste();\n      });\n      await gu.sendKeys($.ENTER);\n      await gu.waitForServer();\n\n      await gu.clickCell({rowNum: 3, col: 0});\n      await gu.sendKeys($.ENTER, $.SELECT_ALL);\n      await clipboard.lockAndPerform(async (cb) => {\n        await cb.copy();\n        await gu.sendKeys($.ESCAPE);\n        await gu.clickCell({rowNum: 4, col: 0});\n        await cb.paste();\n      });\n\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5], cols: [0, 1, 2], mapper: selText}), [\n        \"-A1\", \"-<tag> for\\nyou & me;\", \"-C1\",\n        \"-A2\", \"-B2\",                   \"-C2\",\n        '-A1\\t\"<tag> for\\nyou & me;\"\\nA2\\tB2', \"-B3\", \"-C3\",\n        \"+A1\", \"+<tag> for\\nyou & me;\", \"-C4\",\n        \"+A2\", \"+B2\",                   \"-C5\",\n      ]);\n\n      await gu.undo(3);    // Go back to initial state.\n    } finally {\n      await driver.executeScript(function() {\n        window.removeEventListener(\"copy\", window.gristCopyHandler);\n      });\n    }\n  });\n\n  it(\"should paste correctly when values contain commas\", async function() {\n    // When pasting, split only on tabs, not on commas. (We used to split on both, or guess what\n    // to split on, which resulted in unexpected and unpleasant surprises when a legitimate value\n    // contained a comma.)\n\n    // Create a value with commas.\n    await gu.clickCell({rowNum: 1, col: 0});\n    await gu.sendKeys($.ENTER, $.SELECT_ALL, \"this,is,a,test\", $.ENTER);\n    await gu.waitForServer();\n\n    // Copy a single value, and paste to another cell.\n    await gu.clickCell({rowNum: 1, col: 0});\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.clickCell({rowNum: 2, col: 0});\n      await cb.paste();\n    });\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [0, 1, 2], mapper: selText}), [\n      \"-this,is,a,test\", \"-B1\", \"-C1\",\n      \"-this,is,a,test\", \"-B2\", \"-C2\",\n    ]);\n\n    // Now copy multiple values, and paste to other cells.\n    await gu.sendKeys([$.SHIFT, $.UP], [$.SHIFT, $.RIGHT]);\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.clickCell({rowNum: 1, col: 1});\n      await cb.paste();\n    });\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [0, 1, 2], mapper: selText}), [\n      \"-this,is,a,test\", \"+this,is,a,test\", \"+B1\",\n      \"-this,is,a,test\", \"+this,is,a,test\", \"+B2\",\n    ]);\n\n    await gu.undo(3);    // Go back to initial state.\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/CopyPasteColumnOptions.ts",
    "content": "/**\n * Test for copy-pasting from a Grist column into a blank column, which should copy the options.\n */\nimport { safeJsonParse } from \"app/common/gutil\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert } from \"mocha-webdriver\";\n\ndescribe(\"CopyPasteColumnOptions\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  const clipboard = gu.getLockableClipboard();\n  afterEach(() => gu.checkForErrors());\n  gu.bigScreen();\n\n  it(\"should copy column options into blank columns\", async function() {\n    const session = await gu.session().login();\n    const doc = await session.tempDoc(cleanup, \"CopyOptions.grist\");\n    const api = session.createHomeApi().getDocAPI(doc.id);\n    const data1 = await api.getRows(\"Table1\");\n    const data2 = await api.getRows(\"Table2\");\n\n    assert.deepEqual(data1, {\n      id: [1],\n      manualSort: [1],\n      A: [1041465600],\n      B: [1044057600],\n      C: [1],\n      D: [[GristObjCode.List, 1, 1]],\n      E: [\"01/02/03\"],\n      F: [[GristObjCode.List, \"01/02/03\"]],\n      G: [\"01/02/03\"],\n      gristHelper_Display: [[GristObjCode.Date, 1041465600]],\n      gristHelper_Display2: [[GristObjCode.List, [GristObjCode.Date, 1044057600], [GristObjCode.Date, 1044057600]]],\n      gristHelper_ConditionalRule: [true],\n    });\n\n    // Initially Table2 is completely empty, all the columns are blank and of type Any\n    assert.deepEqual(data2, {\n      id: [],\n      manualSort: [],\n      A: [],\n      B: [],\n      C2: [],\n      D2: [],\n      E: [],\n      F: [],\n      G: [],\n    });\n\n    // Copy all the data from Table1 to Table2, which will copy the column options\n    await gu.getCell({ section: \"TABLE1\", col: 0, rowNum: 1 }).click();\n    await gu.sendKeys(await gu.selectAllKey());\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.getCell({ section: \"TABLE2\", col: 0, rowNum: 1 }).click();\n      await cb.paste();\n    });\n    await gu.waitForServer();\n\n    // Now Table2 contains essentially the same data as Table1\n    // Table2 just has slightly different column names to test display formulas,\n    // and conditional formatting is not copied at the moment.\n    data1.C2 = data1.C;\n    data1.D2 = data1.D;\n    delete data1.C;\n    delete data1.D;\n    delete data1.gristHelper_ConditionalRule;\n    // Actual difference: G is a Text column, so its type was guessed as Date and the string was parsed\n    data1.G = [981158400];\n    assert.deepEqual(await api.getRows(\"Table2\"), data1);\n\n    // Second check that the data is the same, and also that it's formatted the same\n    const cols1 = [\"A\", \"B\", \"C\", \"D\", \"E\", \"F\", \"G\"];\n    const cols2 = [\"A\", \"B\", \"C2\", \"D2\", \"E\", \"F\", \"G\"];\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: cols1, rowNums: [1], section: \"TABLE1\" }),\n      await gu.getVisibleGridCells({ cols: cols2, rowNums: [1], section: \"TABLE2\" }),\n    );\n\n    // Check that the column options are essentially the same in both tables\n    const cols = await api.getRecords(\"_grist_Tables_column\");\n    const cleanCols = cols.map(\n      ({\n        id,\n        fields: {\n          parentId,\n          colId,\n          type,\n          visibleCol,\n          displayCol,\n          rules,\n          widgetOptions,\n          formula,\n        },\n      }) => ({\n        id,\n        parentId,\n        colId,\n        type,\n        visibleCol,\n        displayCol,\n        rules,\n        formula,\n        widgetOptions: safeJsonParse(widgetOptions as string, \"\"),\n      }));\n\n    assert.deepEqual(cleanCols, [\n      {\n        id: 1,\n        parentId: 1,\n        colId: \"manualSort\",\n        type: \"ManualSortPos\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: \"\",\n      }, {\n        id: 2,\n        parentId: 1,\n        colId: \"A\",\n        type: \"Date\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: {\n          widget: \"TextBox\",\n          dateFormat: \"MM/DD/YY\",\n          isCustomDateFormat: false,\n          alignment: \"left\",\n        },\n      }, {\n        id: 3,\n        parentId: 1,\n        colId: \"B\",\n        type: \"Date\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: {\n          widget: \"TextBox\",\n          dateFormat: \"DD/MM/YY\",\n          isCustomDateFormat: true,\n          alignment: \"center\",\n        },\n      }, {\n        id: 4,\n        parentId: 1,\n        colId: \"C\",\n        type: \"Ref:Table1\",\n        visibleCol: 2,\n        displayCol: 5,\n        rules: null,\n        formula: \"\",\n        widgetOptions: { widget: \"Reference\", alignment: \"left\", fillColor: \"#FECC81\" },\n      }, {\n        id: 5,\n        parentId: 1,\n        colId: \"gristHelper_Display\",\n        type: \"Any\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"$C.A\",\n        widgetOptions: \"\",\n      }, {\n        id: 6,\n        parentId: 1,\n        colId: \"D\",\n        type: \"RefList:Table1\",\n        visibleCol: 3,\n        displayCol: 7,\n        rules: null,\n        formula: \"\",\n        widgetOptions: { widget: \"Reference\", alignment: \"left\", rulesOptions: [], wrap: true },\n      }, {\n        id: 7,\n        parentId: 1,\n        colId: \"gristHelper_Display2\",\n        type: \"Any\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"$D.B\",\n        widgetOptions: \"\",\n      }, {\n        id: 8,\n        parentId: 1,\n        colId: \"E\",\n        type: \"Choice\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: { widget: \"TextBox\", alignment: \"left\", choices: [\"01/02/03\"], choiceOptions: {} },\n      }, {\n        id: 9,\n        parentId: 1,\n        colId: \"F\",\n        type: \"ChoiceList\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: [GristObjCode.List, 21],  // Not copied into the new table\n        formula: \"\",\n        widgetOptions: {\n          widget: \"TextBox\",\n          choices: [\"01/02/03\", \"foo\"],\n          choiceOptions: {},\n          alignment: \"left\",\n          rulesOptions: [{ fillColor: \"#BC77FC\", textColor: \"#000000\" }],  // Not copied into the new table\n        },\n      }, {\n        id: 10,\n        parentId: 1,\n        colId: \"G\",\n        type: \"Text\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: { widget: \"TextBox\", alignment: \"left\", rulesOptions: [] },\n      },\n\n      /////////////////\n      /// // Table2 starts here. Most of the column options are now the same.\n      /////////////////\n      {\n        id: 13,\n        parentId: 2,\n        colId: \"manualSort\",\n        type: \"ManualSortPos\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: \"\",\n      }, {\n        id: 14,\n        parentId: 2,\n        colId: \"A\",\n        type: \"Date\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: {\n          widget: \"TextBox\",\n          dateFormat: \"MM/DD/YY\",\n          isCustomDateFormat: false,\n          alignment: \"left\",\n        },\n      }, {\n        id: 15,\n        parentId: 2,\n        colId: \"B\",\n        type: \"Date\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: {\n          widget: \"TextBox\",\n          dateFormat: \"DD/MM/YY\",\n          isCustomDateFormat: true,\n          alignment: \"center\",\n        },\n      }, {\n        id: 16,\n        parentId: 2,\n        colId: \"C2\",\n        type: \"Ref:Table1\",\n        visibleCol: 2,\n        displayCol: 22,\n        rules: null,\n        formula: \"\",\n        widgetOptions: { widget: \"Reference\", alignment: \"left\", fillColor: \"#FECC81\" },\n      }, {\n        id: 17,\n        parentId: 2,\n        colId: \"D2\",\n        type: \"RefList:Table1\",\n        visibleCol: 3,\n        displayCol: 23,\n        rules: null,\n        formula: \"\",\n        widgetOptions: { widget: \"Reference\", alignment: \"left\", wrap: true },\n      }, {\n        id: 18,\n        parentId: 2,\n        colId: \"E\",\n        type: \"Choice\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: { widget: \"TextBox\", alignment: \"left\", choices: [\"01/02/03\"], choiceOptions: {} },\n      }, {\n        id: 19,\n        parentId: 2,\n        colId: \"F\",\n        type: \"ChoiceList\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: { widget: \"TextBox\", choices: [\"01/02/03\", \"foo\"], choiceOptions: {}, alignment: \"left\" },\n      }, {\n        // Actual difference: the original 'G' is a Text column, so in the new column the type was guessed as Date\n        id: 20,\n        parentId: 2,\n        colId: \"G\",\n        type: \"Date\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"\",\n        widgetOptions: {\n          timeFormat: \"\",\n          isCustomTimeFormat: true,\n          isCustomDateFormat: true,\n          dateFormat: \"YY/MM/DD\",\n        },\n      }, {\n        id: 21,\n        // This is in Table1, it's here because it was created in the fixture after Table2\n        // No similar column is in Table2 because conditional formatting is not copied\n        parentId: 1,\n        colId: \"gristHelper_ConditionalRule\",\n        type: \"Any\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"True\",\n        widgetOptions: \"\",\n      }, {\n        id: 22,\n        parentId: 2,\n        colId: \"gristHelper_Display\",\n        type: \"Any\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"$C2.A\",  // Correctly 'renamed' from $C.A\n        widgetOptions: \"\",\n      }, {\n        id: 23,\n        parentId: 2,\n        colId: \"gristHelper_Display2\",\n        type: \"Any\",\n        visibleCol: 0,\n        displayCol: 0,\n        rules: null,\n        formula: \"$D2.B\",  // Correctly 'renamed' from $D.A\n        widgetOptions: \"\",\n      }],\n    );\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/CopyPasteFiles.ts",
    "content": "/**\n * Test for copy-pasting file data into Attachments columns.\n */\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { fixturesRoot } from \"test/server/testUtils\";\n\nimport fs from \"fs/promises\";\nimport * as path from \"path\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"CopyPasteFiles\", function() {\n  this.timeout(90000);\n  const cleanup = setupTestSuite();\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should not fail when columns are trimmed\", async function() {\n    const session = await gu.session().login();\n    const docId = await session.tempNewDoc(cleanup, \"CopyPaste\", { load: false });\n\n    // Ensure there is both an Attachments column and a non-Attachments one.\n    const api = session.createHomeApi();\n    await api.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Table1\", \"A\", { label: \"Name\" }],\n      [\"ModifyColumn\", \"Table1\", \"B\", { label: \"Photo\" }],\n      [\"ModifyColumn\", \"Table1\", \"Photo\", { type: \"Attachments\" }],\n    ]);\n\n    await gu.loadDoc(`/doc/${docId}`);\n\n    const samplePngContent = await fs.readFile(path.resolve(fixturesRoot, \"uploads/flower.png\"));\n    const samplePdfContent = await fs.readFile(path.resolve(fixturesRoot, \"uploads/sample.pdf\"));\n\n    await gu.getCell({ rowNum: 1, col: \"Photo\" }).click();\n    await driver.executeScript(syntheticPasteFile,\n      [{ content: samplePngContent.toString(\"base64\"), name: \"flower.png\", type: \"image/png\" }]);\n    await gu.waitToPass(async () =>\n      assert.deepEqual(await getCellThumbTitles(gu.getCell({ rowNum: 1, col: \"Photo\" })), [\"flower.png\"]));\n\n    // Add a couple more records\n    await api.applyUserActions(docId, [\n      [\"BulkAddRecord\", \"Table1\", [null, null], { Name: [\"Alice\", \"Bob\"] }],\n    ]);\n\n    await gu.selectGridArea([2, 1], [3, 2]);\n    await driver.executeScript(syntheticPasteFile, [\n      { content: samplePngContent.toString(\"base64\"), name: \"flower.png\", type: \"image/png\" },\n      { content: samplePdfContent.toString(\"base64\"), name: \"sample.pdf\", type: \"application/pdf\" },\n    ]);\n\n    assert.deepEqual(await getCellThumbTitles(gu.getCell({ rowNum: 1, col: \"Name\" })), []);\n    assert.deepEqual(await getCellThumbTitles(gu.getCell({ rowNum: 1, col: \"Photo\" })), [\"flower.png\"]);\n    assert.deepEqual(await getCellThumbTitles(gu.getCell({ rowNum: 2, col: \"Photo\" })), [\"flower.png\", \"sample.pdf\"]);\n    assert.deepEqual(await getCellThumbTitles(gu.getCell({ rowNum: 3, col: \"Photo\" })), [\"flower.png\", \"sample.pdf\"]);\n    assert.deepEqual(await getCellThumbTitles(gu.getCell({ rowNum: 4, col: \"Photo\" })), []);\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], col: \"Name\" }), [\"\", \"Alice\", \"Bob\"]);\n\n    // Check in more detail the content in one of the cells.\n    await gu.getCell({ rowNum: 2, col: \"Photo\" }).click();\n    await driver.sendKeys(Key.ENTER);\n    assert.equal(await driver.findWait(\".test-pw-counter\", 500).getText(), \"1 of 2\");\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"flower.png\");\n    assert.equal(await driver.find(\".test-pw-attachment-content\").getTagName(), \"img\");\n    assert.match(await driver.find(\".test-pw-attachment-content\").getAttribute(\"src\"), /name=flower.png&/);\n\n    await driver.sendKeys(Key.RIGHT);\n    assert.equal(await driver.find(\".test-pw-name\").value(), \"sample.pdf\");\n    assert.equal(await driver.find(\".test-pw-attachment-content\").getTagName(), \"object\");\n    assert.match(await driver.find(\".test-pw-attachment-content\").getAttribute(\"data\"), /name=sample.pdf&/);\n    assert.equal(await driver.find(\".test-pw-attachment-content\").getAttribute(\"type\"), \"application/pdf\");\n  });\n});\n\nfunction getCellThumbTitles(cell: WebElement): Promise<string[]> {\n  return cell.findAll(\".test-pw-thumbnail\", el => el.getAttribute(\"title\"));\n}\n\n// Creates a synthetic paste of a file, not from the real clipboard but by dispatching within the\n// browser an event we construct ourselves.\nfunction syntheticPasteFile(contentList: { content: string, name: string, type: string }[]) {\n  const dt = new DataTransfer();\n  for (const { content, name, type } of contentList) {\n    const bytes = Uint8Array.from(atob(content), c => c.charCodeAt(0));\n    dt.items.add(new File([bytes], name, { type }));\n  }\n  const evt = new Event(\"paste\", { bubbles: true, cancelable: true });\n  Object.defineProperty(evt, \"clipboardData\", { value: dt });\n  (document.activeElement || document.body).dispatchEvent(evt);\n}\n"
  },
  {
    "path": "test/nbrowser/CopyPasteLinked.ts",
    "content": "/**\n * Test for pasting into a linked GridView.\n *\n * In particular, when multiple rows are selected in GridView, on switching to a different linked\n * record, the selection should be cleared, or else paste will misbehave.\n */\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, Key, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"CopyPasteLinked\", function() {\n  this.timeout(30000);\n  const cleanup = setupTestSuite();\n  const clipboard = gu.getLockableClipboard();\n\n  it(\"should clear internal selection when link record changes\", async function() {\n    const mainSession = await gu.session().login();\n    await mainSession.tempDoc(cleanup, \"Landlord.grist\");\n    await gu.getPageItem(/Current Signers/).click();\n    await gu.waitForServer();\n\n    let cell: WebElement;\n\n    // Select a cell.\n    cell = await gu.getCell({ section: \"Tenants\", col: \"Tenant\", rowNum: 1 });\n    await cell.click();\n    assert.equal(await cell.getText(), \"John Malik\");\n\n    await clipboard.lockAndPerform(async (cb) => {\n      // Copy the cell's value to the clipboard.\n      await cb.copy();\n\n      // Now select multiple cells.\n      await gu.sendKeys(Key.chord(Key.SHIFT, Key.DOWN), Key.chord(Key.SHIFT, Key.DOWN));\n\n      // Check that 3 cells are indeed selected.\n      assert.deepEqual(await gu.getVisibleGridCells({ col: \"Tenant\", rowNums: [1, 2, 3, 4],\n        mapper: el => el.matches(\".selected\") }),\n      [true, true, true, false]);\n\n      // Switch to a different Apartments row that drives the filtering in the Tenants section.\n      await gu.getCell({ section: \"Apartments\", col: 0, rowNum: 2 }).click();\n      cell = await gu.getCell({ section: \"Tenants\", col: \"Tenant\", rowNum: 1 });\n      await cell.click();\n      assert.equal(await cell.getText(), \"Fred Brown\");\n\n      // Paste the copied value. It doesn't work reliably in a test, so try until it works. (The\n      // reasons seems to be that 'body' has focus briefly, rather than Clipboard component.)\n      await gu.waitAppFocus();\n      await cb.paste();\n    });\n    await gu.waitForServer();\n\n    // Check that only one value was copied, and that there are not multiple cells selected.\n    assert.deepEqual(await gu.getVisibleGridCells({ col: \"Tenant\", rowNums: [1, 2, 3, 4] }),\n      [\"John Malik\", \"Fred Brown\", \"Susan Sharp\", \"Owen Sharp\"]);\n    assert.deepEqual(await gu.getVisibleGridCells({ col: \"Tenant\", rowNums: [1, 2, 3, 4],\n      mapper: el => el.matches(\".selected\") }),\n    [false, false, false, false]);\n\n    await gu.checkForErrors();\n    await gu.undo();\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/CopyWithHeaders.ts",
    "content": "/**\n * Test for copying Grist data with headers.\n */\n\nimport { createDummyTextArea, removeDummyTextArea } from \"test/nbrowser/CopyPaste\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"CopyWithHeaders\", function() {\n  this.timeout(90000);\n  const cleanup = setupTestSuite();\n  const clipboard = gu.getLockableClipboard();\n  afterEach(() => gu.checkForErrors());\n  gu.bigScreen();\n\n  after(async function() {\n    await driver.executeScript(removeDummyTextArea);\n  });\n\n  it(\"should copy headers\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"Hello.grist\");\n    await driver.executeScript(createDummyTextArea);\n\n    await clipboard.lockAndPerform(async (cb) => {\n      // Select all\n      await gu.sendKeys(Key.chord(Key.CONTROL, \"a\"));\n      await gu.rightClick(gu.getCell({ rowNum: 1, col: \"A\" }));\n      await gu.findOpenMenuItem(\"li\", \"Copy with headers\").click();\n\n      await pasteAndCheck(cb, [\"A\", \"B\", \"C\", \"D\", \"E\"], 5);\n    });\n\n    await clipboard.lockAndPerform(async (cb) => {\n      // Select a single cell.\n      await gu.getCell({ rowNum: 2, col: \"D\" }).click();\n      await gu.rightClick(gu.getCell({ rowNum: 2, col: \"D\" }));\n      await gu.findOpenMenuItem(\"li\", \"Copy with headers\").click();\n\n      await pasteAndCheck(cb, [\"D\"], 2);\n    });\n  });\n});\n\nasync function pasteAndCheck(cb: gu.IClipboard, headers: string[], rows: number) {\n  // Paste into the dummy textarea.\n  await driver.find(\"#dummyText\").click();\n  await gu.waitAppFocus(false);\n  await cb.paste();\n\n  const textarea = await driver.find(\"#dummyText\");\n  const text = await textarea.getAttribute(\"value\");\n  const lines = text.split(\"\\n\");\n  const regex = new RegExp(`^${headers.join(\"\\\\s+\")}$`);\n  assert.match(lines[0], regex);\n  assert.equal(lines.length, rows);\n  await textarea.clear();\n}\n"
  },
  {
    "path": "test/nbrowser/CursorSaving.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { cleanupExtraWindows, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"CursorSaving\", function() {\n  this.timeout(20000);\n  cleanupExtraWindows();\n  const cleanup = setupTestSuite();\n  const clipboard = gu.getLockableClipboard();\n  afterEach(() => gu.checkForErrors());\n\n  describe(\"WithRefLists\", function() {\n    before(async function() {\n      const session = await gu.session().login();\n      await session.tempDoc(cleanup, \"CursorWithRefLists1.grist\");\n    });\n\n    it(\"should remember positions when record is linked from multiple source records\", async function() {\n      // Select Tag 'a' (row 1), and Item 'Apples' (row 1), which has tags 'b' and 'a'.\n      await clickAndCheck({ section: \"Tags\", rowNum: 1, col: 0 }, \"a\");\n      await clickAndCheck({ section: \"Items\", rowNum: 1, col: 0 }, \"Apple\");\n\n      await gu.reloadDoc();\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 1, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"Items\"), { rowNum: 1, col: 0 });\n      assert.equal(await gu.getCardCell(\"Name\", \"Items Card\").getText(), \"Apple\");\n\n      // Now select a different Tag, but the same Item.\n      await clickAndCheck({ section: \"Tags\", rowNum: 2, col: 0 }, \"b\");\n      await clickAndCheck({ section: \"Items\", rowNum: 1, col: 0 }, \"Apple\");\n\n      await gu.reloadDoc();\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 2, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"Items\"), { rowNum: 1, col: 0 });\n      assert.equal(await gu.getCardCell(\"Name\", \"Items Card\").getText(), \"Apple\");\n\n      // Try the third section.\n      await clickAndCheck({ section: \"Items\", rowNum: 3, col: 0 }, \"Orange\");\n      await clickAndCheckCard({ section: \"ITEMS Card\", col: \"Name\", rowNum: 1 }, \"Orange\");\n\n      await gu.reloadDoc();\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 2, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"Items\"), { rowNum: 3, col: 0 });\n      assert.equal(await gu.getActiveSectionTitle(), \"ITEMS Card\");\n      assert.equal(await gu.getCardCell(\"Name\", \"ITEMS Card\").getText(), \"Orange\");\n\n      // Try getting to the same card via different selections.\n      await clickAndCheck({ section: \"Tags\", rowNum: 1, col: 0 }, \"a\");\n      await clickAndCheck({ section: \"Items\", rowNum: 2, col: 0 }, \"Orange\");\n      await clickAndCheckCard({ section: \"ITEMS Card\", col: \"Name\", rowNum: 1 }, \"Orange\");\n\n      await gu.reloadDoc();\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 1, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"Items\"), { rowNum: 2, col: 0 });\n      assert.equal(await gu.getActiveSectionTitle(), \"ITEMS Card\");\n      assert.equal(await gu.getCardCell(\"Name\", \"ITEMS Card\").getText(), \"Orange\");\n    });\n\n    it('should remember positions when \"new\" row is involved', async function() {\n      // Try a position when when the parent record is on a \"new\" row.\n      await clickAndCheck({ section: \"Tags\", rowNum: 2, col: 0 }, \"b\");\n      await clickAndCheck({ section: \"Items\", rowNum: 4, col: 0 }, \"\");\n      await clickAndCheckCard({ section: \"ITEMS Card\", col: \"Tags\", rowNum: 1 }, \"\");\n\n      await gu.reloadDoc();\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 2, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"Items\"), { rowNum: 4, col: 0 });\n      assert.equal(await gu.getActiveSectionTitle(), \"ITEMS Card\");\n      assert.equal(await gu.getCardCell(\"Tags\", \"ITEMS Card\").getText(), \"\");\n\n      // Try a position when when the grandparent parent record is on a \"new\" row.\n      await clickAndCheck({ section: \"Tags\", rowNum: 5, col: 0 }, \"\");\n      assert.match(await gu.getSection(\"Items\").find(\".disable_viewpane\").getText(), /No row selected/);\n      await clickAndCheckCard({ section: \"ITEMS Card\", col: \"Tags\", rowNum: 1 }, \"\");\n\n      await gu.reloadDoc();\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 5, col: 0 });\n      assert.match(await gu.getSection(\"Items\").find(\".disable_viewpane\").getText(), /No row selected/);\n      assert.equal(await gu.getActiveSectionTitle(), \"ITEMS Card\");\n      assert.equal(await gu.getCardCell(\"Tags\", \"ITEMS Card\").getText(), \"\");\n    });\n\n    it(\"should create anchor links that preserve row positions in linking sources\", async function() {\n      await clickAndCheck({ section: \"Tags\", rowNum: 1, col: 0 }, \"a\");\n      await clickAndCheck({ section: \"Items\", rowNum: 1, col: 0 }, \"Apple\");\n      await gu.openRowMenu(1);\n\n      const anchorLinks: string[] = [];\n      anchorLinks.push(await getAnchorLink());\n\n      // Now select a different Tag, but the same Item.\n      await clickAndCheck({ section: \"Tags\", rowNum: 2, col: 0 }, \"b\");\n      await clickAndCheck({ section: \"Items\", rowNum: 1, col: 0 }, \"Apple\");\n      anchorLinks.push(await getDifferentAnchorLink(anchorLinks.at(-1)));\n\n      // Try the third section.\n      await clickAndCheck({ section: \"Items\", rowNum: 3, col: 0 }, \"Orange\");\n      await clickAndCheckCard({ section: \"ITEMS Card\", col: \"Name\", rowNum: 1 }, \"Orange\");\n      anchorLinks.push(await getDifferentAnchorLink(anchorLinks.at(-1)));\n\n      // A different way to get to the same value in third section.\n      await clickAndCheck({ section: \"Tags\", rowNum: 1, col: 0 }, \"a\");\n      await clickAndCheck({ section: \"Items\", rowNum: 2, col: 0 }, \"Orange\");\n      await gu.getCardCell(\"Name\", \"ITEMS Card\").click();\n      anchorLinks.push(await getDifferentAnchorLink(anchorLinks.at(-1)));\n\n      // Now go through the anchor links, and make sure each gets us to the expected point.\n      await navigateToAnchor(anchorLinks[0]);\n      // It can take a small amount of time for the cursor positions to update\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 1, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"Items\"), { rowNum: 1, col: 0 });\n      assert.equal(await gu.getCardCell(\"Name\", \"Items Card\").getText(), \"Apple\");\n\n      await navigateToAnchor(anchorLinks[1]);\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 2, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"Items\"), { rowNum: 1, col: 0 });\n      assert.equal(await gu.getCardCell(\"Name\", \"Items Card\").getText(), \"Apple\");\n\n      await navigateToAnchor(anchorLinks[2]);\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 2, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"Items\"), { rowNum: 3, col: 0 });\n      assert.equal(await gu.getActiveSectionTitle(), \"ITEMS Card\");\n      assert.equal(await gu.getCardCell(\"Name\", \"ITEMS Card\").getText(), \"Orange\");\n\n      await navigateToAnchor(anchorLinks[3]);\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 1, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"Items\"), { rowNum: 2, col: 0 });\n      assert.equal(await gu.getActiveSectionTitle(), \"ITEMS Card\");\n      assert.equal(await gu.getCardCell(\"Name\", \"ITEMS Card\").getText(), \"Orange\");\n    });\n\n    it('should handle anchor links when \"new\" row is involved', async function() {\n      const anchorLinks: string[] = [];\n\n      // Try a position when when the parent record is on a \"new\" row.\n      await clickAndCheck({ section: \"Tags\", rowNum: 2, col: 0 }, \"b\");\n      await clickAndCheck({ section: \"Items\", rowNum: 4, col: 0 }, \"\");\n      await clickAndCheckCard({ section: \"ITEMS Card\", col: \"Tags\", rowNum: 1 }, \"\");\n      anchorLinks.push(await getDifferentAnchorLink(\"\"));\n\n      // Try a position when when the grandparent parent record is on a \"new\" row.\n      await clickAndCheck({ section: \"Tags\", rowNum: 5, col: 0 }, \"\");\n      assert.match(await gu.getSection(\"Items\").find(\".disable_viewpane\").getText(), /No row selected/);\n      await clickAndCheckCard({ section: \"ITEMS Card\", col: \"Tags\", rowNum: 1 }, \"\");\n\n      anchorLinks.push(await getDifferentAnchorLink(anchorLinks.at(-1)));\n\n      await navigateToAnchor(anchorLinks[0]);\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 2, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"Items\"), { rowNum: 4, col: 0 });\n      assert.equal(await gu.getActiveSectionTitle(), \"ITEMS Card\");\n      assert.equal(await gu.getCardCell(\"Tags\", \"ITEMS Card\").getText(), \"\");\n\n      await navigateToAnchor(anchorLinks[1]);\n      assert.deepEqual(await gu.getCursorPosition(\"Tags\"), { rowNum: 5, col: 0 });\n      assert.match(await gu.getSection(\"Items\").find(\".disable_viewpane\").getText(), /No row selected/);\n      assert.equal(await gu.getActiveSectionTitle(), \"ITEMS Card\");\n      assert.equal(await gu.getCardCell(\"Tags\", \"ITEMS Card\").getText(), \"\");\n    });\n  });\n\n  describe(\"WithRefs\", function() {\n    // This is a similar test to the above, but without RefLists. In particular it checks that\n    // when a cursor is in the \"new\" row, enough is remembered to restore positions.\n\n    before(async function() {\n      const session = await gu.session().login();\n      const doc = await session.tempDoc(cleanup, \"World.grist\", { load: false });\n      await session.loadDoc(`/doc/${doc.id}/p/5`, { wait: true });\n    });\n\n    it(\"should remember row positions in linked sections\", async function() {\n      // Select a country and a city within it.\n      await clickAndCheck({ section: \"Country\", rowNum: 2, col: 0 }, \"AFG\");\n      await clickAndCheck({ section: \"City\", rowNum: 4, col: 1 }, \"Balkh\");\n      await gu.reloadDoc();\n      assert.deepEqual(await gu.getCursorPosition(\"Country\"), { rowNum: 2, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"City\"), { rowNum: 4, col: 1 });\n\n      // Now select a country, and the \"new\" row in the linked City widget.\n      await clickAndCheck({ section: \"Country\", rowNum: 3, col: 0 }, \"AGO\");\n      await clickAndCheck({ section: \"City\", rowNum: 6, col: 1 }, \"\");\n      await gu.reloadDoc();\n      assert.deepEqual(await gu.getCursorPosition(\"Country\"), { rowNum: 3, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"City\"), { rowNum: 6, col: 1 });\n    });\n\n    it(\"should create anchor links that preserve row positions in linked sections\", async function() {\n      const anchorLinks: string[] = [];\n\n      // Select a country and a city within it.\n      await clickAndCheck({ section: \"Country\", rowNum: 2, col: 0 }, \"AFG\");\n      await clickAndCheck({ section: \"City\", rowNum: 4, col: 1 }, \"Balkh\");\n      anchorLinks.push(await getAnchorLink());\n\n      // Now select a country, and the \"new\" row in the linked City widget.\n      await clickAndCheck({ section: \"Country\", rowNum: 3, col: 0 }, \"AGO\");\n      await clickAndCheck({ section: \"City\", rowNum: 6, col: 1 }, \"\");\n\n      anchorLinks.push(await getDifferentAnchorLink(anchorLinks.at(-1)));\n\n      await navigateToAnchor(anchorLinks[0]);\n      assert.deepEqual(await gu.getCursorPosition(\"Country\"), { rowNum: 2, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"City\"), { rowNum: 4, col: 1 });\n\n      await navigateToAnchor(anchorLinks[1]);\n      assert.deepEqual(await gu.getCursorPosition(\"Country\"), { rowNum: 3, col: 0 });\n      assert.deepEqual(await gu.getCursorPosition(\"City\"), { rowNum: 6, col: 1 });\n    });\n  });\n\n  describe(\"When switching between pages\", function() {\n    before(async function() {\n      const session = await gu.session().login();\n      await session.tempDoc(cleanup, \"CursorWithRefLists1.grist\");\n    });\n\n    it(\"should show the first row if the saved cursor is invalid\", async function() {\n      // Prime the saved cursor positions of each view section with rowIds that won't be valid when\n      // the filter-linked reference changes.\n      await gu.openPage(\"Selector\");\n      await gu.openPage(\"Selector copy\");\n\n      await gu.openPage(\"Selector\");\n      await clickAndCheck({ section: \"Selector\", rowNum: 1, col: 0 }, \"a\");\n      // Set the reference from \"a\" to \"d\", whose only valid item is \"Papaya\".\n      // The other tags all reference other items, so the stored cursor position will be invalid.\n      await gu.enterCell(Key.DELETE, Key.ENTER);\n      await driver.findContentWait(\".test-ref-editor-item\", \"d\", 1000).click();\n      await clickAndCheck({ section: \"Selector\", rowNum: 1, col: 0 }, \"d\");\n\n      // Open the previously primed page and check that it shows \"Papaya\" (the first and only row)\n      // instead of \"Row is unavailable\".\n      await gu.openPage(\"Selector copy\");\n      const name = await gu.getDetailCell({ section: \"ITEMS Card\", rowNum: 1, col: 1 }).getText();\n      assert.equal(name, \"Papaya\");\n    });\n  });\n\n  async function getAnchorLink() {\n    return await clipboard.lockAndPerform(async () => gu.getAnchor());\n  }\n\n  async function getDifferentAnchorLink(oldAnchorLink?: string) {\n    let anchor: string = \"\";\n    await gu.waitToPass(async () => {\n      anchor = await getAnchorLink();\n      assert.notEqual(anchor, oldAnchorLink);\n    });\n    return anchor;\n  }\n});\n\nasync function navigateToAnchor(anchorLink: string) {\n  await gu.clearTestState();\n  await driver.get(anchorLink);\n  await gu.waitForAnchor();\n}\n\nasync function clickAndCheck(options: gu.ICellSelect, expectedValue: string) {\n  const cell = gu.getCell(options);\n  await cell.click();\n  assert.equal(await cell.getText(), expectedValue);\n}\n\nasync function clickAndCheckCard(options: gu.ICellSelect, expectedValue: string) {\n  const cell = gu.getDetailCell(options);\n  await cell.click();\n  assert.equal(await cell.getText(), expectedValue);\n}\n"
  },
  {
    "path": "test/nbrowser/CustomView.ts",
    "content": "import { safeJsonParse } from \"app/common/gutil\";\nimport { serveCustomViews, Serving, setAccess } from \"test/nbrowser/customUtil\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport * as chai from \"chai\";\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\nchai.config.truncateThreshold = 5000;\n\ndescribe(\"CustomView\", function() {\n  this.timeout(\"30s\");\n  gu.bigScreen();\n  const cleanup = setupTestSuite();\n\n  let serving: Serving;\n\n  before(async function() {\n    if (server.isExternalServer()) {\n      this.skip();\n    }\n    serving = await serveCustomViews();\n  });\n\n  after(async function() {\n    if (serving && !gu.noCleanup) {\n      await serving.shutdown();\n    }\n  });\n\n  it(\"disabled navigation in custom view\", async function() {\n    // Start a new doc.\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n\n    // Add some data to the table so we can test navigation.\n    await gu.sendActions([\n      [\"AddRecord\", \"Table1\", null, { A: \"row1\" }],\n      [\"AddRecord\", \"Table1\", null, { A: \"row2\" }],\n      [\"AddRecord\", \"Table1\", null, { A: \"row3\" }],\n    ]);\n\n    // Go to the first row.\n    await gu.getCell({ section: \"TABLE1\", col: 0, rowNum: 1 }).click();\n\n    // Add a custom widget.\n    await gu.addNewSection(\"Custom\", \"Table1\");\n    await gu.setCustomWidgetUrl(`${serving.url}/readout`, { openGallery: false });\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await gu.openWidgetPanel();\n    await setAccess(\"read table\");\n    await gu.waitForServer();\n\n    // Click on the custom widget to focus it.\n    const iframe = gu.getSection(\"TABLE1 custom\").find(\"iframe\");\n\n    // Helper to get rowId from the custom widget.\n    async function getRowId() {\n      await driver.switchTo().frame(iframe);\n      const rowId = await driver.find(\"#rowId\").getText();\n      await driver.switchTo().defaultContent();\n      return rowId;\n    }\n\n    // Helper to click inside the custom widget.\n    async function clickInside() {\n      await driver.switchTo().frame(iframe);\n      await driver.find(\"body\").click();\n      await driver.switchTo().defaultContent();\n    }\n\n    await clickInside();\n\n    // Wait for the custom section to be active.\n    await gu.waitToPass(async () => assert.equal(await gu.getActiveSectionTitle(), \"TABLE1 Custom\"), 200);\n\n    // Check the initial rowId in the custom widget.\n    const initialRowId = await getRowId();\n    assert.equal(initialRowId, \"1\");\n\n    // Try to navigate down with arrow keys - rowId should not change.\n    await gu.sendKeys(Key.ARROW_DOWN, Key.ARROW_DOWN, Key.ARROW_DOWN);\n    await driver.sleep(100); // There are a lot of setTimeouts there.\n    assert.equal(await getRowId(), \"1\");\n\n    // Try to navigate up with arrow keys - rowId should still not change.\n    await gu.sendKeys(Key.ARROW_UP, Key.ARROW_UP, Key.ARROW_UP);\n    await driver.sleep(100); // There are a lot of setTimeouts there.\n    assert.equal(await getRowId(), \"1\");\n\n    // Expand the custom widget.\n    await gu.expandSection(\"TABLE1 Custom\");\n\n    // Click inside the expanded custom widget.\n    await clickInside();\n\n    // Try arrow keys down - rowId should not change.\n    await gu.sendKeys(Key.ARROW_DOWN, Key.ARROW_DOWN, Key.ARROW_DOWN);\n    await driver.sleep(100); // There are a lot of setTimeouts there.\n    assert.equal(await getRowId(), \"1\");\n\n    // Try arrow keys up - rowId should not change.\n    await gu.sendKeys(Key.ARROW_UP, Key.ARROW_UP, Key.ARROW_UP);\n    await driver.sleep(100); // There are a lot of setTimeouts there.\n    assert.equal(await getRowId(), \"1\");\n\n    // Close the expanded view.\n    await driver.find(\".test-viewLayout-overlay .test-close-button\").click();\n    await gu.waitForServer();\n\n    // Custom widget should still be active after closing the overlay.\n    await gu.waitToPass(async () =>\n      assert.equal(await gu.getActiveSectionTitle(), \"TABLE1 Custom\"));\n\n    // Try arrow keys down one more time - rowId still should not change.\n    await gu.sendKeys(Key.ARROW_DOWN, Key.ARROW_DOWN, Key.ARROW_DOWN);\n    await driver.sleep(100); // There are a lot of setTimeouts there.\n    assert.equal(await getRowId(), \"1\");\n\n    // Try arrow keys up one more time - rowId still should not change.\n    await gu.sendKeys(Key.ARROW_UP, Key.ARROW_UP, Key.ARROW_UP);\n    await driver.sleep(100); // There are a lot of setTimeouts there.\n    assert.equal(await getRowId(), \"1\");\n  });\n\n  // This tests if test id works. Feels counterintuitive to \"test the test\" but grist-widget repository test suite\n  // depends on this.\n  it(\"informs about ready called\", async () => {\n    // Add a custom widget to a new doc.\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n    await gu.addNewSection(\"Custom\", \"Table1\");\n\n    // Point to a widget that doesn't immediately call ready.\n    await gu.setCustomWidgetUrl(`${serving.url}/deferred-ready`, { openGallery: false });\n    await gu.toggleSidePanel(\"right\", \"open\");\n\n    // We should have a single iframe.\n    assert.equal(await driver.findAll(\"iframe\").then(f => f.length), 1);\n\n    // But without test ready class.\n    assert.isFalse(await driver.find(\"iframe.test-custom-widget-ready\").isPresent());\n\n    // Now invoke ready.\n    await inFrame(async () => {\n      await driver.find(\"button\").click();\n    });\n\n    // And we should have a test ready class.\n    assert.isTrue(await driver.findWait(\"iframe.test-custom-widget-ready\", 100).isDisplayed());\n  });\n\n  for (const access of [\"none\", \"read table\", \"full\"] as const) {\n    function withAccess(obj: any, fallback: any) {\n      return ((access !== \"none\") && obj) || fallback;\n    }\n\n    function readJson(txt: string) {\n      return safeJsonParse(txt, null);\n    }\n\n    describe(`with access level ${access}`, function() {\n      before(async function() {\n        if (server.isExternalServer()) {\n          this.skip();\n        }\n        const mainSession = await gu.session().teamSite.login();\n        await mainSession.tempDoc(cleanup, \"Favorite_Films.grist\");\n        if (!await gu.isSidePanelOpen(\"right\")) {\n          await gu.toggleSidePanel(\"right\");\n        }\n        await driver.find(\".test-config-data\").click();\n      });\n\n      it(\"gets appropriate notification of row set changes\", async function() {\n        // Link a section on the \"All\" page of Favorite Films demo\n        await driver.findContent(\".test-treeview-itemHeader\", /All/).click();\n        await gu.getSection(\"Friends record\").click();\n        await driver.find(\".test-pwc-editDataSelection\").click();\n        await driver.findWait(\".test-wselect-addBtn\", 500).click();\n        await gu.waitForServer();\n        await driver.find(\".test-right-select-by\").click();\n        await gu.findOpenMenuItem(\"li\", /Performances record • Film/).click();\n        await gu.waitForServer();\n        await driver.find(\".test-pwc-editDataSelection\").click();\n        await driver.findContentWait(\".test-wselect-type\", /Custom/, 500).click();\n        await driver.find(\".test-wselect-addBtn\").click();\n        await gu.waitForServer();\n\n        // Replace the widget with a custom widget that just reads out the data\n        // as JSON.\n        await gu.setCustomWidgetUrl(`${serving.url}/readout`, { openGallery: false });\n        await gu.openWidgetPanel();\n        await setAccess(access);\n        await gu.waitForServer();\n\n        // Check that the data looks right.\n        const iframe = gu.getSection(\"Friends record\").find(\"iframe\");\n        await driver.switchTo().frame(iframe);\n        assert.deepEqual(readJson(await driver.find(\"#placeholder\").getText()),\n          withAccess({ Name: [\"Tom\"],\n            Favorite_Film: [\"Toy Story\"],\n            Age: [\"25\"],\n            id: [2] }, null));\n        assert.equal(await driver.find(\"#rowId\").getText(), withAccess(\"2\", \"\"));\n        assert.equal(await driver.find(\"#tableId\").getText(), withAccess(\"Friends\", \"\"));\n        assert.deepEqual(readJson(await driver.find(\"#records\").getText()),\n          withAccess([{ Name: \"Tom\",  // not a list!\n            Favorite_Film: \"Toy Story\",\n            Age: \"25\",\n            id: 2 }], null));\n        await driver.switchTo().defaultContent();\n\n        // Switch row in source section, and see if data updates correctly.\n        await gu.getCell({ section: \"Performances record\", col: 0, rowNum: 5 }).click();\n        await driver.switchTo().frame(iframe);\n        assert.deepEqual(readJson(await driver.find(\"#placeholder\").getText()),\n          withAccess({ Name: [\"Roger\", \"Evan\"],\n            Favorite_Film: [\"Forrest Gump\", \"Forrest Gump\"],\n            Age: [\"22\", \"35\"],\n            id: [1, 5] }, null));\n        assert.equal(await driver.find(\"#rowId\").getText(), withAccess(\"1\", \"\"));\n        assert.equal(await driver.find(\"#tableId\").getText(), withAccess(\"Friends\", \"\"));\n        assert.deepEqual(readJson(await driver.find(\"#records\").getText()),\n          withAccess([{ Name: \"Roger\",\n            Favorite_Film: \"Forrest Gump\",\n            Age: \"22\",\n            id: 1 },\n          { Name: \"Evan\",\n            Favorite_Film: \"Forrest Gump\",\n            Age: \"35\",\n            id: 5 }], null));\n        await driver.switchTo().defaultContent();\n      });\n\n      it(\"gets notification of row changes and content changes\", async function() {\n        // Add a custom view linked to Friends\n        await driver.findContentWait(\".test-treeview-itemHeader\", /Friends/, 500).click();\n        await gu.openAddWidgetToPage();\n        await driver.findContent(\".test-wselect-type\", /Custom/).click();\n        await driver.findContent(\".test-wselect-table\", /Friends/).doClick();\n        await driver.find(\".test-wselect-selectby\").doClick();\n        await driver.findContent(\".test-wselect-selectby option\", /FRIENDS/).doClick();\n        await driver.find(\".test-wselect-addBtn\").click();\n        await gu.waitForServer();\n\n        // Choose the custom view that just reads out data as json\n        await gu.setCustomWidgetUrl(`${serving.url}/readout`, { openGallery: false });\n        await gu.openWidgetPanel();\n        await setAccess(access);\n        await gu.waitForServer();\n\n        // Check that data and cursor looks right\n        const iframe = gu.getSection(\"Friends custom\").find(\"iframe\");\n        await driver.switchTo().frame(iframe);\n        assert.deepEqual(readJson(await driver.find(\"#placeholder\").getText())?.Name,\n          withAccess([\"Roger\", \"Tom\", \"Sydney\", \"Bill\", \"Evan\", \"Mary\"], undefined));\n        assert.equal(await driver.find(\"#rowId\").getText(), withAccess(\"1\", \"\"));\n        assert.equal(await driver.find(\"#tableId\").getText(), withAccess(\"Friends\", \"\"));\n        assert.equal(readJson(await driver.find(\"#record\").getText())?.Name,\n          withAccess(\"Roger\", undefined));\n        assert.deepEqual(readJson(await driver.find(\"#records\").getText())?.[0]?.Name,\n          withAccess(\"Roger\", undefined));\n\n        // Change row in Friends\n        await driver.switchTo().defaultContent();\n        await gu.getCell({ section: \"FRIENDS\", col: 0, rowNum: 2 }).click();\n\n        // Check that rowId is updated\n        await driver.switchTo().frame(iframe);\n        assert.deepEqual(readJson(await driver.find(\"#placeholder\").getText())?.Name,\n          withAccess([\"Roger\", \"Tom\", \"Sydney\", \"Bill\", \"Evan\", \"Mary\"], undefined));\n        assert.equal(await driver.find(\"#rowId\").getText(), withAccess(\"2\", \"\"));\n        assert.equal(await driver.find(\"#tableId\").getText(), withAccess(\"Friends\", \"\"));\n        assert.equal(readJson(await driver.find(\"#record\").getText())?.Name,\n          withAccess(\"Tom\", undefined));\n        assert.deepEqual(readJson(await driver.find(\"#records\").getText())?.[0]?.Name,\n          withAccess(\"Roger\", undefined));\n        await driver.switchTo().defaultContent();\n\n        // Change a cell in Friends\n        await gu.getCell({ section: \"FRIENDS\", col: 0, rowNum: 1 }).click();\n        await gu.enterCell(\"Rabbit\");\n        await gu.waitForServer();\n        // Return to the cell after automatically going to next row.\n        await gu.getCell({ section: \"FRIENDS\", col: 0, rowNum: 1 }).click();\n\n        // Check the data in view updates\n        await driver.switchTo().frame(iframe);\n        assert.deepEqual(readJson(await driver.find(\"#placeholder\").getText())?.Name,\n          withAccess([\"Rabbit\", \"Tom\", \"Sydney\", \"Bill\", \"Evan\", \"Mary\"], undefined));\n        assert.equal(await driver.find(\"#rowId\").getText(), withAccess(\"1\", \"\"));\n        assert.equal(await driver.find(\"#tableId\").getText(), withAccess(\"Friends\", \"\"));\n        assert.equal(readJson(await driver.find(\"#record\").getText())?.Name,\n          withAccess(\"Rabbit\", undefined));\n        assert.deepEqual(readJson(await driver.find(\"#records\").getText())?.[0]?.Name,\n          withAccess(\"Rabbit\", undefined));\n        await driver.switchTo().defaultContent();\n\n        // Select new row and test if custom view has noticed it.\n        await gu.getCell({ section: \"FRIENDS\", col: 0, rowNum: 7 }).click();\n        await driver.switchTo().frame(iframe);\n        await gu.waitToPass(async () => {\n          assert.equal(await driver.find(\"#rowId\").getText(), withAccess(\"new\", \"\"));\n          assert.equal(await driver.find(\"#record\").getText(), withAccess(\"new\", \"\"));\n        });\n        await driver.switchTo().defaultContent();\n        await gu.getCell({ section: \"FRIENDS\", col: 0, rowNum: 1 }).click();\n        await driver.switchTo().frame(iframe);\n        await gu.waitToPass(async () => {\n          assert.equal(await driver.find(\"#rowId\").getText(), withAccess(\"1\", \"\"));\n        });\n        assert.equal(readJson(await driver.find(\"#record\").getText())?.Name, withAccess(\"Rabbit\", undefined));\n        await driver.switchTo().defaultContent();\n\n        // Revert the cell change\n        await gu.undo();\n      });\n\n      const undoTestTitle = access === \"full\" ?\n        \"allows undo/redo via keyboard\" :\n        \"does not allow undo/redo via keyboard\";\n      it(undoTestTitle, async function() {\n        const iframe = gu.getSection(\"Friends custom\").find(\"iframe\");\n        await driver.switchTo().frame(iframe);\n        await driver.findWait(\"body\", 500).click();\n\n        await gu.sendKeys(Key.chord(Key.CONTROL, \"y\"));\n        const expected = access === \"full\" ?\n          withAccess([\"Rabbit\", \"Tom\", \"Sydney\", \"Bill\", \"Evan\", \"Mary\"], undefined) :\n          withAccess([\"Roger\", \"Tom\", \"Sydney\", \"Bill\", \"Evan\", \"Mary\"], undefined);\n        await gu.waitToPass(async () => {\n          assert.deepEqual(readJson(await driver.find(\"#placeholder\").getText())?.Name, expected);\n        }, 1000);\n\n        await gu.sendKeys(Key.chord(await gu.modKey(), \"z\"));\n        await gu.waitToPass(async () => {\n          assert.deepEqual(readJson(await driver.find(\"#placeholder\").getText())?.Name,\n            withAccess([\"Roger\", \"Tom\", \"Sydney\", \"Bill\", \"Evan\", \"Mary\"], undefined));\n        }, 1000);\n\n        await driver.switchTo().defaultContent();\n      });\n\n      it(\"allows switching to custom section by clicking inside it\", async function() {\n        await gu.getCell({ section: \"FRIENDS\", col: 0, rowNum: 1 }).click();\n        assert.equal(await gu.getActiveSectionTitle(), \"FRIENDS\");\n        assert.equal(await driver.find(\".test-config-widget-open-custom-widget-gallery\").isPresent(), false);\n\n        const iframe = gu.getSection(\"Friends custom\").find(\"iframe\");\n        await driver.switchTo().frame(iframe);\n        await driver.find(\"body\").click();\n\n        // Check that the right section is active, and its settings visible in the side panel.\n        await driver.switchTo().defaultContent();\n        await gu.waitToPass(async () =>\n          assert.equal(await gu.getActiveSectionTitle(), \"FRIENDS Custom\"), 200);\n        assert.equal(await driver.find(\".test-config-widget-open-custom-widget-gallery\").isPresent(), true);\n\n        // Switch back.\n        await gu.getCell({ section: \"FRIENDS\", col: 0, rowNum: 1 }).click();\n        assert.equal(await gu.getActiveSectionTitle(), \"FRIENDS\");\n        assert.equal(await driver.find(\".test-config-widget-open-custom-widget-gallery\").isPresent(), false);\n      });\n\n      it(\"deals correctly with requests that require full access\", async function() {\n        // Choose a custom widget that tries to replace all cells in all user tables with 'zap'.\n        await gu.getSection(\"Friends Custom\").click();\n        await gu.setCustomWidgetUrl(`${serving.url}/zap`);\n        await gu.openWidgetPanel();\n        await setAccess(access);\n        await gu.waitForServer();\n\n        // Wait for widget to finish its work.\n        const iframe = gu.getSection(\"Friends custom\").find(\"iframe\");\n        await driver.switchTo().frame(iframe);\n        await gu.waitToPass(async () => {\n          assert.match(await driver.find(\"#placeholder\").getText(), /zap/);\n        }, 10000);\n        const outcome = await driver.find(\"#placeholder\").getText();\n        await driver.switchTo().defaultContent();\n\n        const cell = await gu.getCell({ section: \"FRIENDS\", col: 0, rowNum: 1 }).getText();\n        if (access === \"full\") {\n          assert.equal(cell, \"zap\");\n          assert.match(outcome, /zap succeeded/);\n        } else {\n          assert.notEqual(cell, \"zap\");\n          assert.match(outcome, /zap failed/);\n        }\n      });\n    });\n  }\n\n  async function createTypeEncodingDocWithCustomWidget(accessLevel: \"read table\" | \"full\", widgetUrl: string) {\n    const mainSession = await gu.session().teamSite.login();\n    await mainSession.tempDoc(cleanup, \"TypeEncoding.grist\");\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await gu.waitForServer();\n    await driver.find(\".test-config-data\").click();\n\n    // The test doc already has a Custom View widget. It just needs to\n    // have a URL set.\n    await gu.getSection(\"TYPES custom\").click();\n    await gu.setCustomWidgetUrl(widgetUrl);\n    // If we needed to change widget to Custom URL, make sure access is read table.\n    await setAccess(accessLevel);\n    await gu.waitForServer();\n  }\n\n  it(\"should receive friendly types when reading data from Grist\", async function() {\n    // TODO The same decoding should probably apply to calls like fetchTable() which are satisfied\n    // by the server.\n    await createTypeEncodingDocWithCustomWidget(\"read table\", `${serving.url}/types`);\n\n    const iframe = gu.getSection(\"TYPES custom\").find(\"iframe\");\n    await driver.switchTo().frame(iframe);\n    await driver.findContentWait(\"#record\", /AnyDate/, 10_000);\n    let lines = (await driver.find(\"#record\").getText()).split(\"\\n\");\n\n    // The first line has regular old values.\n    assert.deepEqual(lines, [\n      \"AnyAttachment: _grist_Attachments[[1]] [typeof=object] [name=ReferenceList]\",\n      \"AnyDate: 2020-07-02 [typeof=object] [name=GristDate] [date=2020-07-02T00:00:00.000Z]\",\n      \"AnyDateTime: 1990-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=1990-08-21T17:19:40.705Z]\",\n      \"AnyRef: Types[2] [typeof=object] [name=Reference]\",\n      \"AnyRefList: Types[[2,24]] [typeof=object] [name=ReferenceList]\",\n      // The \"Attachments\" representation is poor and indistinguishable from \"ReferenceList\". But\n      // changing it for this API can break existing widgets.\n      \"Attachments: 1 [typeof=object] [name=Array]\",\n      \"Bool: true [typeof=boolean]\",\n      \"Date: 2020-07-01 [typeof=object] [name=GristDate] [date=2020-07-01T00:00:00.000Z]\",\n      \"DateTime: 2020-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=2020-08-21T17:19:40.705Z]\",\n      \"Numeric: 17.25 [typeof=number]\",\n      \"RECORD: [object Object] [typeof=object] [name=Object]\",\n      \"  AnyAttachment: _grist_Attachments[[1]] [typeof=object] [name=ReferenceList]\",\n      \"  AnyDate: 2020-07-02 [typeof=object] [name=GristDate] [date=2020-07-02T00:00:00.000Z]\",\n      \"  AnyDateTime: 1990-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=1990-08-21T17:19:40.705Z]\",\n      \"  AnyRef: Types[2] [typeof=object] [name=Reference]\",\n      \"  AnyRefList: Types[[2,24]] [typeof=object] [name=ReferenceList]\",\n      \"  Attachments: _grist_Attachments[[1]] [typeof=object] [name=ReferenceList]\",\n      \"  Bool: true [typeof=boolean]\",\n      \"  Date: 2020-07-01 [typeof=object] [name=GristDate] [date=2020-07-01T00:00:00.000Z]\",\n      \"  DateTime: 2020-08-21T17:19:40.705Z [typeof=object] [name=GristDateTime] [date=2020-08-21T17:19:40.705Z]\",\n      \"  Numeric: 17.25 [typeof=number]\",\n      \"  Reference: Types[2] [typeof=object] [name=Reference]\",\n      \"  ReferenceList: Types[[2,24]] [typeof=object] [name=ReferenceList]\",\n      \"  Text: Hello! [typeof=string]\",\n      \"  id: 24 [typeof=number]\",\n      \"Reference: Types[2] [typeof=object] [name=Reference]\",\n      // The \"ReferenceList\" representation is poor and indistinguishable from \"Attachments\". But\n      // changing it for this API can break existing widgets.\n      \"ReferenceList: 2,24 [typeof=object] [name=Array]\",\n      \"Text: Hello! [typeof=string]\",\n      \"id: 24 [typeof=number]\",\n    ]);\n\n    // #match tells us if onRecords() returned the same representation for this record.\n    assert.equal(await driver.find(\"#match\").getText(), \"true\");\n\n    // Switch to the next row, which has blank values.\n    await driver.switchTo().defaultContent();\n    await gu.getCell({ section: \"TYPES\", col: 0, rowNum: 2 }).click();\n    await driver.switchTo().frame(iframe);\n    await driver.findContentWait(\"#record\", /AnyDate: null/, 1000);\n    lines = (await driver.find(\"#record\").getText()).split(\"\\n\");\n    assert.deepEqual(lines, [\n      \"AnyAttachment: _grist_Attachments[[]] [typeof=object] [name=ReferenceList]\",\n      \"AnyDate: null [typeof=object]\",\n      \"AnyDateTime: null [typeof=object]\",\n      \"AnyRef: Types[0] [typeof=object] [name=Reference]\",\n      \"AnyRefList: Types[[]] [typeof=object] [name=ReferenceList]\",\n      \"Attachments: null [typeof=object]\",\n      \"Bool: false [typeof=boolean]\",\n      \"Date: null [typeof=object]\",\n      \"DateTime: null [typeof=object]\",\n      \"Numeric: 0 [typeof=number]\",\n      \"RECORD: [object Object] [typeof=object] [name=Object]\",\n      \"  AnyAttachment: _grist_Attachments[[]] [typeof=object] [name=ReferenceList]\",\n      \"  AnyDate: null [typeof=object]\",\n      \"  AnyDateTime: null [typeof=object]\",\n      \"  AnyRef: Types[0] [typeof=object] [name=Reference]\",\n      \"  AnyRefList: Types[[]] [typeof=object] [name=ReferenceList]\",\n      \"  Attachments: _grist_Attachments[[]] [typeof=object] [name=ReferenceList]\",\n      \"  Bool: false [typeof=boolean]\",\n      \"  Date: null [typeof=object]\",\n      \"  DateTime: null [typeof=object]\",\n      \"  Numeric: 0 [typeof=number]\",\n      \"  Reference: Types[0] [typeof=object] [name=Reference]\",\n      \"  ReferenceList: Types[[]] [typeof=object] [name=ReferenceList]\",\n      \"  Text:  [typeof=string]\",\n      \"  id: 1 [typeof=number]\",\n      \"Reference: Types[0] [typeof=object] [name=Reference]\",\n      \"ReferenceList: null [typeof=object]\",\n      \"Text:  [typeof=string]\",\n      \"id: 1 [typeof=number]\",\n    ]);\n\n    // #match tells us if onRecords() returned the same representation for this record.\n    assert.equal(await driver.find(\"#match\").getText(), \"true\");\n\n    // Switch to the next row, which has various error values.\n    await driver.switchTo().defaultContent();\n    await gu.getCell({ section: \"TYPES\", col: 0, rowNum: 3 }).click();\n    await driver.switchTo().frame(iframe);\n    await driver.findContentWait(\"#record\", /AnyDate: null/, 1000);\n    lines = (await driver.find(\"#record\").getText()).split(\"\\n\");\n\n    assert.deepEqual(lines, [\n      \"AnyAttachment: #AssertionError [typeof=object] [name=RaisedException]\",\n      \"AnyDate: #Invalid Date: Not-a-Date [typeof=object] [name=RaisedException]\",\n      \"AnyDateTime: #Invalid DateTime: Not-a-DateTime [typeof=object] [name=RaisedException]\",\n      \"AnyRef: #AssertionError [typeof=object] [name=RaisedException]\",\n      \"AnyRefList: #AssertionError [typeof=object] [name=RaisedException]\",\n      \"Attachments: No-Att [typeof=string]\",\n      \"Bool: true [typeof=boolean]\",\n      \"Date: Not-a-Date [typeof=string]\",\n      \"DateTime: Not-a-DateTime [typeof=string]\",\n      \"Numeric: Not-a-Number [typeof=string]\",\n      \"RECORD: [object Object] [typeof=object] [name=Object]\",\n      \"  AnyAttachment: null [typeof=object]\",\n      \"  AnyDate: null [typeof=object]\",\n      \"  AnyDateTime: null [typeof=object]\",\n      \"  AnyRef: null [typeof=object]\",\n      \"  AnyRefList: null [typeof=object]\",\n      \"  Attachments: No-Att [typeof=string]\",\n      \"  Bool: true [typeof=boolean]\",\n      \"  Date: Not-a-Date [typeof=string]\",\n      \"  DateTime: Not-a-DateTime [typeof=string]\",\n      \"  Numeric: Not-a-Number [typeof=string]\",\n      \"  Reference: No-Ref [typeof=string]\",\n      \"  ReferenceList: No-RefList [typeof=string]\",\n      \"  Text: Errors [typeof=string]\",\n      \"  _error_: [object Object] [typeof=object] [name=Object]\",\n      \"    AnyAttachment: AssertionError:  [typeof=string]\",\n      \"    AnyDate: InvalidTypedValue: Invalid Date: Not-a-Date [typeof=string]\",\n      \"    AnyDateTime: InvalidTypedValue: Invalid DateTime: Not-a-DateTime [typeof=string]\",\n      \"    AnyRef: AssertionError:  [typeof=string]\",\n      \"    AnyRefList: AssertionError:  [typeof=string]\",\n      \"  id: 2 [typeof=number]\",\n      \"Reference: No-Ref [typeof=string]\",\n      \"ReferenceList: No-RefList [typeof=string]\",\n      \"Text: Errors [typeof=string]\",\n      \"id: 2 [typeof=number]\",\n    ]);\n\n    // #match tells us if onRecords() returned the same representation for this record.\n    assert.equal(await driver.find(\"#match\").getText(), \"true\");\n  });\n\n  it(\"should encode data in the same format as 'Any' columns with cellFormat=typed\", async function() {\n    await createTypeEncodingDocWithCustomWidget(\"full\", `${serving.url}/types-rest-api`);\n\n    async function waitForMatchingRecordRepr(regexp: RegExp): Promise<string> {\n      const iframe = gu.getSection(\"TYPES custom\").find(\"iframe\");\n      return gu.doInIframe(iframe, () => driver.findContentWait(\"#record\", regexp, 1000).getText());\n    }\n\n    let recordJson = await waitForMatchingRecordRepr(/\"id\": 24/);\n    let result = JSON.parse(recordJson);\n    const expectedValue1 = {\n      id: 24,\n      AnyAttachment: { tableId: \"_grist_Attachments\", rowIds: [1] },\n      AnyDate: \"2020-07-02T00:00:00.000Z\",\n      AnyDateTime: \"1990-08-21T17:19:40.705Z\",\n      AnyRef: { tableId: \"Types\", rowId: 2 },\n      AnyRefList: { tableId: \"Types\", rowIds: [2, 24] },\n      Attachments: { tableId: \"_grist_Attachments\", rowIds: [1] },\n      Bool: true,\n      Date: \"2020-07-01T00:00:00.000Z\",\n      DateTime: \"2020-08-21T17:19:40.705Z\",\n      Numeric: 17.25,\n      Reference: { tableId: \"Types\", rowId: 2 },\n      ReferenceList: { tableId: \"Types\", rowIds: [2, 24] },\n      Text: \"Hello!\",\n      RECORD: {\n        id: 24,\n        AnyAttachment: { tableId: \"_grist_Attachments\", rowIds: [1] },\n        AnyDate: \"2020-07-02T00:00:00.000Z\",\n        AnyDateTime: \"1990-08-21T17:19:40.705Z\",\n        AnyRef: { tableId: \"Types\", rowId: 2 },\n        AnyRefList: { tableId: \"Types\", rowIds: [2, 24] },\n        Attachments: { tableId: \"_grist_Attachments\", rowIds: [1] },\n        Bool: true,\n        Date: \"2020-07-01T00:00:00.000Z\",\n        DateTime: \"2020-08-21T17:19:40.705Z\",\n        Numeric: 17.25,\n        Reference: { tableId: \"Types\", rowId: 2 },\n        ReferenceList: { tableId: \"Types\", rowIds: [2, 24] },\n        Text: \"Hello!\",\n      },\n    };\n    assert.deepEqual(result.onRecordVersion, expectedValue1);\n    assert.deepEqual(result.fetchSelectedVersion, expectedValue1);\n    assert.deepEqual(result.restApiVersion, expectedValue1);\n\n    await gu.getCell({ section: \"TYPES\", col: 0, rowNum: 2 }).click();\n    recordJson = await waitForMatchingRecordRepr(/\"id\": 1/);\n    result = JSON.parse(recordJson);\n\n    const expectedValueEmpty = {\n      id: 1,\n      AnyAttachment: { tableId: \"_grist_Attachments\", rowIds: [] },\n      AnyDate: null,\n      AnyDateTime: null,\n      AnyRef: { tableId: \"Types\", rowId: 0 },\n      AnyRefList: { tableId: \"Types\", rowIds: [] },\n      Attachments: { tableId: \"_grist_Attachments\", rowIds: [] },\n      Bool: false,\n      Date: null,\n      DateTime: null,\n      Numeric: 0,\n      Reference: { tableId: \"Types\", rowId: 0 },\n      ReferenceList: { tableId: \"Types\", rowIds: [] },\n      Text: \"\",\n      RECORD: {\n        id: 1,\n        AnyAttachment: { tableId: \"_grist_Attachments\", rowIds: [] },\n        AnyDate: null,\n        AnyDateTime: null,\n        AnyRef: { tableId: \"Types\", rowId: 0 },\n        AnyRefList: { tableId: \"Types\", rowIds: [] },\n        Attachments: { tableId: \"_grist_Attachments\", rowIds: [] },\n        Bool: false,\n        Date: null,\n        DateTime: null,\n        Numeric: 0,\n        Reference: { tableId: \"Types\", rowId: 0 },\n        ReferenceList: { tableId: \"Types\", rowIds: [] },\n        Text: \"\",\n      },\n    };\n    assert.deepEqual(result.onRecordVersion, expectedValueEmpty);\n    assert.deepEqual(result.fetchSelectedVersion, expectedValueEmpty);\n    assert.deepEqual(result.restApiVersion, expectedValueEmpty);\n\n    await gu.getCell({ section: \"TYPES\", col: 0, rowNum: 3 }).click();\n    recordJson = await waitForMatchingRecordRepr(/\"id\": 2/);\n    result = JSON.parse(recordJson);\n\n    const expectedValueErrors = {\n      id: 2,\n      AnyAttachment: { name: \"AssertionError\" },\n      AnyDate: { name: \"InvalidTypedValue\", message: \"Date\", details: \"Not-a-Date\" },\n      AnyDateTime: { name: \"InvalidTypedValue\", message: \"DateTime\", details: \"Not-a-DateTime\" },\n      AnyRef: { name: \"AssertionError\" },\n      AnyRefList: { name: \"AssertionError\" },\n      Attachments: \"No-Att\",\n      Bool: true,\n      Date: \"Not-a-Date\",\n      DateTime: \"Not-a-DateTime\",\n      Numeric: \"Not-a-Number\",\n      Reference: \"No-Ref\",\n      ReferenceList: \"No-RefList\",\n      Text: \"Errors\",\n      RECORD: {\n        id: 2,\n        AnyAttachment: null,\n        AnyDate: null,\n        AnyDateTime: null,\n        AnyRef: null,\n        AnyRefList: null,\n        Attachments: \"No-Att\",\n        Bool: true,\n        Date: \"Not-a-Date\",\n        DateTime: \"Not-a-DateTime\",\n        Numeric: \"Not-a-Number\",\n        Reference: \"No-Ref\",\n        ReferenceList: \"No-RefList\",\n        Text: \"Errors\",\n        _error_: {\n          AnyDate: \"InvalidTypedValue: Invalid Date: Not-a-Date\",\n          AnyDateTime: \"InvalidTypedValue: Invalid DateTime: Not-a-DateTime\",\n          AnyRef: \"AssertionError: \",\n          AnyAttachment: \"AssertionError: \",\n          AnyRefList: \"AssertionError: \",\n        },\n      },\n    };\n    assert.deepEqual(result.onRecordVersion, expectedValueErrors);\n    assert.deepEqual(result.fetchSelectedVersion, expectedValueErrors);\n    assert.deepEqual(result.restApiVersion, expectedValueErrors);\n  });\n\n  it(\"should not expand refs when expandRefs is false\", async function() {\n    await createTypeEncodingDocWithCustomWidget(\"read table\", `${serving.url}/types-raw-refs`);\n\n    const iframe = gu.getSection(\"TYPES custom\").find(\"iframe\");\n    await driver.switchTo().frame(iframe);\n    await driver.findContentWait(\"#record\", /AnyDate/, 10_000);\n    let record = await driver.find(\"#record\").getText();\n\n    // The first line has regular old values.\n    assert.deepEqual(JSON.parse(record), rowsWithoutExpandedRefs[0]);\n\n    // #match tells us if onRecords() returned the same representation for this record.\n    assert.equal(await driver.find(\"#match\").getText(), \"true\");\n\n    // Switch to the next row, which has blank values.\n    await driver.switchTo().defaultContent();\n    await gu.getCell({ section: \"TYPES\", col: 0, rowNum: 2 }).click();\n    await driver.switchTo().frame(iframe);\n    await driver.findContentWait(\"#record\", /\"AnyDate\":null/, 1000);\n    record = await driver.find(\"#record\").getText();\n    assert.deepEqual(JSON.parse(record), rowsWithoutExpandedRefs[1]);\n\n    // #match tells us if onRecords() returned the same representation for this record.\n    assert.equal(await driver.find(\"#match\").getText(), \"true\");\n\n    // Switch to the next row, which has various error values.\n    await driver.switchTo().defaultContent();\n    await gu.getCell({ section: \"TYPES\", col: 0, rowNum: 3 }).click();\n    await driver.switchTo().frame(iframe);\n    await driver.findContentWait(\"#record\", /\"AnyDate\":null/, 1000);\n    record = await driver.find(\"#record\").getText();\n\n    assert.deepEqual(JSON.parse(record), rowsWithoutExpandedRefs[2]);\n\n    // #match tells us if onRecords() returned the same representation for this record.\n    assert.equal(await driver.find(\"#match\").getText(), \"true\");\n  });\n\n  it(\"respect access rules\", async function() {\n    // Create a Favorite Films copy, with access rules on columns, rows, and tables.\n    const mainSession = await gu.session().teamSite.login();\n    const api = mainSession.createHomeApi();\n    const doc = await mainSession.tempDoc(cleanup, \"Favorite_Films.grist\", { load: false });\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Opinions\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"Opinions\", null, { A: \"do not zap plz\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Performances\", colIds: \"Actor\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Films\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Opinions\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access == OWNER\", permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"rec.id % 2 == 0\", permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -3, aclFormula: \"\", permissionsText: \"none\",\n      }],\n    ]);\n\n    // Open it up and add a new linked section.\n    await mainSession.loadDoc(`/doc/${doc.id}`);\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-config-data\").click();\n    await driver.findContent(\".test-treeview-itemHeader\", /All/).click();\n    await gu.getSection(\"Friends record\").click();\n    await driver.find(\".test-pwc-editDataSelection\").click();\n    await driver.findWait(\".test-wselect-addBtn\", 500).click();\n    await gu.waitForServer();\n    await driver.find(\".test-right-select-by\").click();\n    await gu.findOpenMenuItem(\"li\", /Performances record • Film/).click();\n    await gu.waitForServer();\n    await driver.find(\".test-pwc-editDataSelection\").click();\n    await driver.findContentWait(\".test-wselect-type\", /Custom/, 500).click();\n    await driver.find(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n\n    // Select a custom widget that tries to replace all cells in all user tables with 'zap'.\n    await gu.setCustomWidgetUrl(`${serving.url}/zap`, { openGallery: false });\n    await gu.openWidgetPanel();\n    await setAccess(\"full\");\n    await gu.waitForServer();\n\n    // Wait for widget to finish its work.\n    const iframe = gu.getSection(\"Friends record\").find(\"iframe\");\n    await driver.switchTo().frame(iframe);\n    await gu.waitToPass(async () => {\n      assert.match(await driver.find(\"#placeholder\").getText(), /zap/);\n    }, 10000);\n    await driver.switchTo().defaultContent();\n\n    // Now leave the page and remove all access rules.\n    await mainSession.loadDocMenu(\"/\");\n    await api.applyUserActions(doc.id, [\n      [\"BulkRemoveRecord\", \"_grist_ACLRules\", [2, 3, 4]],\n    ]);\n\n    // Check that the expected cells got zapped.\n\n    // In performances table, all but Actor column should have been zapped.\n    const performances = await api.getDocAPI(doc.id).getRows(\"Performances\");\n    let keys = Object.keys(performances);\n    for (let i = 0; i < performances.id.length; i++) {\n      for (const key of keys) {\n        if (key !== \"Actor\" && key !== \"id\" && key !== \"manualSort\") {\n          // use match since zap may be embedded in an error, e.g. if inserted in ref column.\n          assert.match(String(performances[key][i]), /zap/);\n        }\n        assert.notMatch(String(performances.Actor[i]), /zap/);\n      }\n    }\n\n    // In films table, every second row should have been zapped.\n    const films = await api.getDocAPI(doc.id).getRows(\"Films\");\n    keys = Object.keys(films);\n    for (let i = 0; i < films.id.length; i++) {\n      for (const key of keys) {\n        if (key !== \"id\" && key !== \"manualSort\") {\n          assert.equal(films[key][i] === \"zap\", films.id[i] % 2 === 1);\n        }\n      }\n    }\n\n    // Opinions table should be untouched.\n    const opinions = await api.getDocAPI(doc.id).getRows(\"Opinions\");\n    assert.equal(opinions.A[0], \"do not zap plz\");\n  });\n\n  it(\"allows custom options for fetching data\", async function() {\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"FetchSelectedOptions.grist\", { load: false });\n    await mainSession.loadDoc(`/doc/${doc.id}`);\n\n    await gu.getSection(\"TABLE1 Custom\").click();\n    await gu.setCustomWidgetUrl(`${serving.url}/fetchSelectedOptions`);\n    await gu.openWidgetPanel();\n    await setAccess(\"full\");\n    await gu.waitForServer();\n\n    const expected = {\n      default: {\n        fetchSelectedTable: {\n          id: [1, 2],\n          A: [[\"a\", \"b\"], [\"c\", \"d\"]],\n        },\n        fetchSelectedRecord: {\n          id: 1,\n          A: [\"a\", \"b\"],\n        },\n        // The viewApi methods don't decode data by default, hence the \"L\" prefixes.\n        viewApiFetchSelectedTable: {\n          id: [1, 2],\n          A: [[\"L\", \"a\", \"b\"], [\"L\", \"c\", \"d\"]],\n        },\n        viewApiFetchSelectedRecord: {\n          id: 2,\n          A: [\"L\", \"c\", \"d\"],\n        },\n        // onRecords returns rows by default, not columns.\n        onRecords: [\n          { id: 1, A: [\"a\", \"b\"] },\n          { id: 2, A: [\"c\", \"d\"] },\n        ],\n        onRecord: {\n          id: 1,\n          A: [\"a\", \"b\"],\n        },\n      },\n      options: {\n        // This is the result of calling the same methods as above,\n        // but with the values of `keepEncoded` and `format` being the opposite of their defaults.\n        // `includeColumns` is also set to either 'normal' or 'all' instead of the default 'shown',\n        // which means that the 'B' column is included in all the results,\n        // and the 'manualSort' columns is included in half of them.\n        fetchSelectedTable: [\n          { id: 1, manualSort: 1, A: [\"L\", \"a\", \"b\"], B: 1 },\n          { id: 2, manualSort: 2, A: [\"L\", \"c\", \"d\"], B: 2 },\n        ],\n        fetchSelectedRecord: {\n          id: 1,\n          A: [\"L\", \"a\", \"b\"],\n          B: 1,\n        },\n        viewApiFetchSelectedTable: [\n          { id: 1, manualSort: 1, A: [\"a\", \"b\"], B: 1 },\n          { id: 2, manualSort: 2, A: [\"c\", \"d\"], B: 2 },\n        ],\n        viewApiFetchSelectedRecord: {\n          id: 2,\n          A: [\"c\", \"d\"],\n          B: 2,\n        },\n        onRecords: {\n          id: [1, 2],\n          manualSort: [1, 2],\n          A: [[\"L\", \"a\", \"b\"], [\"L\", \"c\", \"d\"]],\n          B: [1, 2],\n        },\n        onRecord: {\n          id: 1,\n          A: [\"L\", \"a\", \"b\"],\n          B: 1,\n        },\n      },\n    };\n\n    async function getData(shown: number) {\n      await driver.findContentWait(\"#data\", `\"shown\": ${shown}`, 1000);\n      const data = await driver.find(\"#data\").getText();\n      const result = JSON.parse(data);\n      assert.equal(result.shown, shown);\n      delete result.shown;\n      return result;\n    }\n\n    await inFrame(async () => {\n      await gu.waitToPass(async () => {\n        const parsed = await getData(12);\n        assert.deepEqual(parsed, expected);\n      }, 1000);\n    });\n\n    // Change the access level away from 'full'.\n    await setAccess(\"read table\");\n    await gu.waitForServer();\n\n    await inFrame(async () => {\n      // onRecord(s) with custom includeColumns without full access will fail\n      // with an error that we can't catch and display,\n      // so only wait for 10 results instead of 12.\n      const parsed = await getData(10);\n\n      // The default options don't require full access, so the result is the same.\n      assert.deepEqual(parsed.default, expected.default);\n\n      // The alternative options all set includeColumns to 'normal' or 'all',\n      // which requires full access.\n      assert.deepEqual(parsed.options, {\n        fetchSelectedTable:\n          \"Error: Setting includeColumns to all requires full access. Current access level is read table\",\n        fetchSelectedRecord:\n          \"Error: Setting includeColumns to normal requires full access. Current access level is read table\",\n        viewApiFetchSelectedTable:\n          \"Error: Setting includeColumns to all requires full access. Current access level is read table\",\n        viewApiFetchSelectedRecord:\n          \"Error: Setting includeColumns to normal requires full access. Current access level is read table\",\n      });\n    });\n  });\n});\n\nasync function inFrame(op: () => Promise<void>)  {\n  await driver.switchTo().frame(driver.find(\"iframe\"));\n  await op();\n  await driver.switchTo().defaultContent();\n}\n\nconst rowsWithoutExpandedRefs = [\n  {\n    id: 24,\n    Reference: { tableId: \"Types\", rowId: 2 },\n    AnyDateTime: \"1990-08-21T17:19:40.705Z\",\n    AnyRef: { tableId: \"Types\", rowId: 2 },\n    AnyDate: \"2020-07-02T00:00:00.000Z\",\n    AnyAttachment: { tableId: \"_grist_Attachments\", rowIds: [1] },\n    AnyRefList: { tableId: \"Types\", rowIds: [2, 24] },\n    // The \"Attachments\" and \"RefList\" representation are poor and indistinguishable from each other. But\n    // changing it for this API can break existing widgets.\n    Attachments: [1],\n    ReferenceList: [2, 24],\n    RECORD: {\n      AnyDate: \"2020-07-02T00:00:00.000Z\",\n      AnyDateTime: \"1990-08-21T17:19:40.705Z\",\n      AnyRef: { tableId: \"Types\", rowId: 2 },\n      AnyAttachment: { tableId: \"_grist_Attachments\", rowIds: [1] },\n      AnyRefList: { tableId: \"Types\", rowIds: [2, 24] },\n      Attachments: { tableId: \"_grist_Attachments\", rowIds: [1] },\n      Bool: true,\n      Date: \"2020-07-01T00:00:00.000Z\",\n      DateTime: \"2020-08-21T17:19:40.705Z\",\n      Numeric: 17.25,\n      Reference: { tableId: \"Types\", rowId: 2 },\n      ReferenceList: { tableId: \"Types\", rowIds: [2, 24] },\n      Text: \"Hello!\",\n      id: 24,\n    },\n    Bool: true,\n    Date: \"2020-07-01T00:00:00.000Z\",\n    DateTime: \"2020-08-21T17:19:40.705Z\",\n    Numeric: 17.25,\n    Text: \"Hello!\",\n  },\n  {\n    id: 1,\n    Reference: { tableId: \"Types\", rowId: 0 },\n    AnyDateTime: null,\n    AnyRef: { tableId: \"Types\", rowId: 0 },\n    AnyDate: null,\n    AnyAttachment: { tableId: \"_grist_Attachments\", rowIds: [] },\n    AnyRefList: { tableId: \"Types\", rowIds: [] },\n    Attachments: null,\n    ReferenceList: null,\n    RECORD: {\n      AnyDate: null,\n      AnyDateTime: null,\n      AnyRef: { tableId: \"Types\", rowId: 0 },\n      AnyAttachment: { tableId: \"_grist_Attachments\", rowIds: [] },\n      AnyRefList: { tableId: \"Types\", rowIds: [] },\n      Attachments: { tableId: \"_grist_Attachments\", rowIds: [] },\n      Bool: false,\n      Date: null,\n      DateTime: null,\n      Numeric: 0,\n      Reference: { tableId: \"Types\", rowId: 0 },\n      ReferenceList: { tableId: \"Types\", rowIds: [] },\n      Text: \"\",\n      id: 1,\n    },\n    Bool: false,\n    Date: null,\n    DateTime: null,\n    Numeric: 0,\n    Text: \"\",\n  },\n  {\n    id: 2,\n    Reference: \"No-Ref\",\n    AnyDateTime: {\n      name: \"InvalidTypedValue\",\n      message: \"DateTime\",\n      details: \"Not-a-DateTime\",\n    },\n    AnyRef: { name: \"AssertionError\" },\n    AnyDate: { name: \"InvalidTypedValue\", message: \"Date\", details: \"Not-a-Date\" },\n    AnyAttachment: { name: \"AssertionError\" },\n    AnyRefList: { name: \"AssertionError\" },\n    Attachments: \"No-Att\",\n    ReferenceList: \"No-RefList\",\n    RECORD: {\n      AnyDate: null,\n      AnyDateTime: null,\n      AnyRef: null,\n      AnyAttachment: null,\n      AnyRefList: null,\n      Attachments: \"No-Att\",\n      Bool: true,\n      Date: \"Not-a-Date\",\n      DateTime: \"Not-a-DateTime\",\n      Numeric: \"Not-a-Number\",\n      Reference: \"No-Ref\",\n      ReferenceList: \"No-RefList\",\n      Text: \"Errors\",\n      _error_: {\n        AnyDate: \"InvalidTypedValue: Invalid Date: Not-a-Date\",\n        AnyDateTime: \"InvalidTypedValue: Invalid DateTime: Not-a-DateTime\",\n        AnyRef: \"AssertionError: \",\n        AnyAttachment: \"AssertionError: \",\n        AnyRefList: \"AssertionError: \",\n      },\n      id: 2,\n    },\n    Bool: true,\n    Date: \"Not-a-Date\",\n    DateTime: \"Not-a-DateTime\",\n    Numeric: \"Not-a-Number\",\n    Text: \"Errors\",\n  },\n];\n"
  },
  {
    "path": "test/nbrowser/CustomWidgets.ts",
    "content": "import { AccessLevel, ICustomWidget } from \"app/common/CustomWidget\";\nimport { AccessTokenResult } from \"app/plugin/GristAPI\";\nimport { TableOperations } from \"app/plugin/TableOperations\";\nimport { getAppRoot } from \"app/server/lib/places\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { serveSomething } from \"test/server/customUtil\";\nimport { createTmpDir } from \"test/server/docTools\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport * as path from \"path\";\n\nimport * as fse from \"fs-extra\";\nimport { assert, driver, Key } from \"mocha-webdriver\";\nimport fetch from \"node-fetch\";\n\n// Valid manifest url.\nconst manifestEndpoint = \"/manifest.json\";\n// Valid widget url.\nconst widgetEndpoint = \"/widget\";\n// Custom URL label in selectbox.\nconst CUSTOM_URL = \"Custom URL\";\n\n// Create some widgets:\nconst widget1: ICustomWidget = {\n  widgetId: \"1\",\n  name: \"W1\",\n  url: widgetEndpoint + \"?name=W1\",\n  description: \"Widget 1 description\",\n  authors: [\n    {\n      name: \"Developer 1\",\n    },\n    {\n      name: \"Developer 2\",\n    },\n  ],\n  isGristLabsMaintained: true,\n  lastUpdatedAt: \"2024-07-30T00:13:31-04:00\",\n};\nconst widget2: ICustomWidget = {\n  widgetId: \"2\",\n  name: \"W2\",\n  url: widgetEndpoint + \"?name=W2\",\n};\nconst widgetWithTheme: ICustomWidget = {\n  widgetId: \"3\",\n  name: \"WithTheme\",\n  url: widgetEndpoint + \"?name=WithTheme\",\n  isGristLabsMaintained: true,\n};\nconst widgetNoPluginApi: ICustomWidget = {\n  widgetId: \"4\",\n  name: \"NoPluginApi\",\n  url: widgetEndpoint + \"?name=NoPluginApi\",\n  isGristLabsMaintained: true,\n};\nconst fromAccess = (level: AccessLevel): ICustomWidget => ({\n  widgetId: level,\n  name: level,\n  url: widgetEndpoint,\n  accessLevel: level,\n  isGristLabsMaintained: true,\n});\nconst widgetNone = fromAccess(AccessLevel.none);\nconst widgetRead = fromAccess(AccessLevel.read_table);\nconst widgetFull = fromAccess(AccessLevel.full);\n\n// Holds widgets manifest content.\nlet widgets: ICustomWidget[] = [];\n\n// Helper function to get iframe with custom widget.\nfunction getCustomWidgetFrame() {\n  return driver.findWait(\"iframe\", 500);\n}\n\ndescribe(\"CustomWidgets\", function() {\n  this.timeout(20000);\n  gu.bigScreen();\n  const cleanup = setupTestSuite();\n\n  let oldEnv: EnvironmentSnapshot;\n\n  // Holds url for sample widget server.\n  let widgetServerUrl = \"\";\n\n  // Switches widget manifest url\n  async function useManifest(url: string) {\n    await server.testingHooks.setWidgetRepositoryUrl(url ? `${widgetServerUrl}${url}` : \"\");\n  }\n\n  async function reloadWidgets() {\n    await driver.executeAsyncScript(\n      (done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done(),\n    );\n  }\n\n  before(async function() {\n    if (server.isExternalServer()) {\n      this.skip();\n    }\n\n    // Create simple widget server that serves manifest.json file, some widgets and some error pages.\n    const widgetServer = await serveSomething((app) => {\n      app.get(\"/404\", (_, res) => res.sendStatus(404).end()); // not found\n      app.get(\"/500\", (_, res) => res.sendStatus(500).end()); // internal error\n      app.get(\"/200\", (_, res) => res.sendStatus(200).end()); // valid response with OK\n      app.get(\"/401\", (_, res) => res.sendStatus(401).end()); // unauthorized\n      app.get(\"/403\", (_, res) => res.sendStatus(403).end()); // forbidden\n      app.get(widgetEndpoint, (req, res) =>\n        res\n          .header(\"Content-Type\", \"text/html\")\n          .send(\"<html><head>\" +\n            (req.query.name === \"NoPluginApi\" ? \"\" : '<script src=\"/grist-plugin-api.js\"></script>') +\n            (req.query.name === \"WithTheme\" ? \"<script>grist.ready();</script>\" : \"\") +\n            \"</head><body>\\n\" +\n            (req.query.name === \"WithTheme\" ? '<span style=\"color: var(--grist-theme-text);\">' : \"\") +\n            (req.query.name || req.query.access) + // send back widget name from query string or access level\n            (req.query.name === \"WithTheme\" ? \"</span>\" : \"\") +\n            \"</body></html>\\n\")\n          .end(),\n      );\n      app.get(manifestEndpoint, (_, res) =>\n        res\n          .header(\"Content-Type\", \"application/json\")\n          // prefix widget endpoint with server address\n          .json(widgets.map(widget => ({ ...widget, url: `${widgetServerUrl}${widget.url}` })))\n          .end(),\n      );\n      app.get(\"/grist-plugin-api.js\", (_, res) =>\n        res.sendFile(\n          \"grist-plugin-api.js\", {\n            root: path.resolve(getAppRoot(), \"static\"),\n          }));\n    });\n\n    cleanup.addAfterAll(widgetServer.shutdown);\n    widgetServerUrl = widgetServer.url;\n\n    oldEnv = new EnvironmentSnapshot();\n    process.env.GRIST_WIDGET_LIST_URL = `${widgetServerUrl}${manifestEndpoint}`;\n    await server.restart();\n\n    // Start with 2 widgets.\n    widgets = [widget1, widget2];\n\n    // Use a fresh profile, to avoid affecting profile of user used in other tests.\n    const session = await gu.session().user(\"fresh\").login({\n      freshAccount: true, isFirstLogin: false, showTips: false, showGristTour: false,\n    });\n    await session.tempDoc(cleanup, \"Hello.grist\");\n\n    // Add custom section.\n    await gu.addNewSection(/Custom/, /Table1/, { customWidget: /Custom URL/, selectBy: /TABLE1/ });\n  });\n\n  after(async function() {\n    await server.testingHooks.setWidgetRepositoryUrl(\"\");\n    oldEnv.restore();\n    await server.restart();\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  // Get available widgets from widget gallery (must be first opened).\n  const galleryWidgets = () => driver.findAll(\".test-custom-widget-gallery-widget-name\", e => e.getText());\n\n  // Get rendered content from custom section.\n  const content = async () => {\n    return gu.doInIframe(await getCustomWidgetFrame(), async () => {\n      const text = await driver.find(\"body\").getText();\n      return text;\n    });\n  };\n\n  async function execute(\n    op: (table: TableOperations) => Promise<any>,\n    tableSelector: (grist: any) => TableOperations = grist => grist.selectedTable,\n  ) {\n    return gu.doInIframe(await getCustomWidgetFrame(), async () => {\n      const harness = async (done: any) => {\n        const grist = (window as any).grist;\n        grist.ready();\n        const table = tableSelector(grist);\n        try {\n          let result = await op(table);\n          if (result === undefined) {\n            result = \"__undefined__\";\n          }\n          done(result);\n        } catch (e) {\n          done(String(e.message || e));\n        }\n      };\n      const cmd =\n        \"const done = arguments[arguments.length - 1];\\n\" +\n        \"const op = \" + op.toString() + \";\\n\" +\n        \"const tableSelector = \" + tableSelector.toString() + \";\\n\" +\n        \"const harness = \" + harness.toString() + \";\\n\" +\n        \"harness(done);\\n\";\n      const result = await driver.executeAsyncScript(cmd);\n      // done callback will return null instead of undefined\n      return result === \"__undefined__\" ? undefined : result;\n    });\n  }\n  // Get first error message from error toasts.\n  const getErrorMessage = async () => (await gu.getToasts())[0];\n  // Changes active section to recreate creator panel.\n  async function recreatePanel() {\n    await gu.getSection(\"TABLE1\").click();\n    await gu.getSection(\"TABLE1 Custom\").click();\n    await gu.waitForServer();\n  }\n  // Gets or sets access level\n  async function access(level?: AccessLevel) {\n    const text = {\n      [AccessLevel.none]: \"No document access\",\n      [AccessLevel.read_table]: \"Read selected table\",\n      [AccessLevel.full]: \"Full document access\",\n    };\n    if (!level) {\n      const currentAccess = await driver.find(\".test-config-widget-access .test-select-open\").getText();\n      return Object.entries(text).find(e => e[1] === currentAccess)![0];\n    } else {\n      await driver.find(\".test-config-widget-access .test-select-open\").click();\n      await gu.findOpenMenuItem(\"li\", text[level]).click();\n      await gu.waitForServer();\n    }\n  }\n\n  // Checks if access prompt is visible.\n  const hasPrompt = () => driver.find(\".test-config-widget-access-accept\").isPresent();\n  // Accepts new access level.\n  const accept = () => driver.find(\".test-config-widget-access-accept\").click();\n  // Rejects new access level.\n  const reject = () => driver.find(\".test-config-widget-access-reject\").click();\n\n  async function enableWidgetsAndShowPanel() {\n    // We need to be sure that widget configuration panel is open all the time.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await recreatePanel();\n    await gu.retryOnStale(() => driver.findWait(\".test-right-tab-pagewidget\", 100).click());\n  }\n\n  describe(\"RightWidgetMenu\", () => {\n    beforeEach(enableWidgetsAndShowPanel);\n\n    afterEach(() => gu.checkForErrors());\n\n    it(\"should show button to open gallery\", async () => {\n      const button = await driver.find(\".test-config-widget-open-custom-widget-gallery\");\n      assert.equal(await button.getText(), \"Custom URL\");\n      await button.click();\n      assert.isTrue(await driver.find(\".test-custom-widget-gallery-container\").isDisplayed());\n      await gu.sendKeys(Key.ESCAPE, Key.ESCAPE);\n      assert.isFalse(await driver.find(\".test-custom-widget-gallery-container\").isPresent());\n    });\n\n    it(\"should switch between widgets\", async () => {\n      // Test Custom URL.\n      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);\n      assert.isTrue((await content()).startsWith(\"Custom widget\"));\n      await gu.setCustomWidgetUrl(`${widgetServerUrl}/200`);\n      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);\n      assert.equal(await content(), \"OK\");\n\n      // Test first widget.\n      await gu.setCustomWidget(widget1.name);\n      assert.equal(await gu.getCustomWidgetName(), widget1.name);\n      assert.equal(await gu.getCustomWidgetInfo(\"description\"), widget1.description);\n      assert.equal(await gu.getCustomWidgetInfo(\"developer\"), widget1.authors?.[0].name);\n      assert.equal(await gu.getCustomWidgetInfo(\"last-updated\"), \"July 30, 2024\");\n      assert.equal(await content(), widget1.name);\n\n      // Test second widget.\n      await gu.setCustomWidget(widget2.name);\n      assert.equal(await gu.getCustomWidgetName(), widget2.name);\n      assert.equal(await gu.getCustomWidgetInfo(\"description\"), \"\");\n      assert.equal(await gu.getCustomWidgetInfo(\"developer\"), \"\");\n      assert.equal(await gu.getCustomWidgetInfo(\"last-updated\"), \"\");\n      assert.equal(await content(), widget2.name);\n\n      // Go back to Custom URL.\n      await gu.setCustomWidget(CUSTOM_URL);\n      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);\n      assert.isTrue((await content()).startsWith(\"Custom widget\"));\n      await gu.setCustomWidgetUrl(`${widgetServerUrl}/200`);\n      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);\n      assert.equal(await content(), \"OK\");\n\n      // Clear url and test if message page is shown.\n      await gu.setCustomWidgetUrl(\"\");\n      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);\n      assert.isTrue((await content()).startsWith(\"Custom widget\"));\n\n      await recreatePanel();\n      assert.equal(await gu.getCustomWidgetName(), CUSTOM_URL);\n      await gu.undo(6);\n    });\n\n    it(\"should support theme variables\", async () => {\n      widgets = [widgetWithTheme];\n      await reloadWidgets();\n      await recreatePanel();\n      await gu.setCustomWidget(widgetWithTheme.name);\n      assert.equal(await gu.getCustomWidgetName(), widgetWithTheme.name);\n      assert.equal(await content(), widgetWithTheme.name);\n\n      const getWidgetColor = async () => {\n        const iframe = driver.find(\"iframe\");\n        await driver.switchTo().frame(iframe);\n        const color = await driver.find(\"span\").getCssValue(\"color\");\n        await driver.switchTo().defaultContent();\n        return color;\n      };\n\n      // Check that the widget is using the text color from the GristLight theme.\n      assert.equal(await getWidgetColor(), \"rgba(38, 38, 51, 1)\");\n\n      // Switch the theme to GristDark.\n      await gu.setGristTheme({ themeName: \"GristDark\", syncWithOS: false });\n      await driver.navigate().back();\n      await gu.waitForDocToLoad();\n\n      // Check that the span is using the text color from the GristDark theme.\n      assert.equal(await getWidgetColor(), \"rgba(239, 239, 239, 1)\");\n\n      // Switch back to GristLight.\n      await gu.setGristTheme({ themeName: \"GristLight\", syncWithOS: false });\n      await driver.navigate().back();\n      await gu.waitForDocToLoad();\n\n      // Check that the widget is back to using the GristLight text color.\n      assert.equal(await getWidgetColor(), \"rgba(38, 38, 51, 1)\");\n    });\n\n    it(\"should support widgets that don't use the plugin api\", async () => {\n      widgets = [widgetNoPluginApi];\n      await reloadWidgets();\n      await recreatePanel();\n      await gu.setCustomWidget(widgetNoPluginApi.name);\n      assert.equal(await gu.getCustomWidgetName(), widgetNoPluginApi.name);\n\n      // Check that the widget loaded and its iframe is visible.\n      assert.equal(await content(), widgetNoPluginApi.name);\n      assert.isTrue(await driver.find(\"iframe\").isDisplayed());\n\n      // Revert to original configuration.\n      widgets = [widget1, widget2];\n      await reloadWidgets();\n      await recreatePanel();\n    });\n\n    it(\"should show error message for invalid widget url list\", async () => {\n      const testError = async (url: string, error: string) => {\n        // Switch section to rebuild the creator panel.\n        await useManifest(url);\n        await reloadWidgets();\n        await recreatePanel();\n        assert.include(await getErrorMessage(), error);\n        await gu.wipeToasts();\n        // Gallery should only contain the Custom URL widget.\n        await gu.openCustomWidgetGallery();\n        assert.deepEqual(await galleryWidgets(), [CUSTOM_URL]);\n        await gu.wipeToasts();\n        await gu.sendKeys(Key.ESCAPE);\n      };\n\n      await testError(\"/404\", \"Remote widget list not found\");\n      await testError(\"/500\", \"Remote server returned an error\");\n      await testError(\"/401\", \"Remote server returned an error\");\n      await testError(\"/403\", \"Remote server returned an error\");\n      // Invalid content in a response.\n      await testError(\"/200\", \"Error reading widget list\");\n\n      // Reset to valid manifest.\n      await useManifest(manifestEndpoint);\n      await reloadWidgets();\n      await recreatePanel();\n    });\n\n    /**\n     * Need to think about whether this is desirable?\n     * The document could be on a different Grist installation to the\n     * one where it was created.\n     */\n    it.skip(\"should show widget when it was removed from list\", async () => {\n      // Select widget1 and then remove it from the list.\n      await gu.setCustomWidget(widget1.name);\n      widgets = [widget2];\n      // Invalidate cache.\n      await reloadWidgets();\n      // Toggle sections to reset creator panel and fetch list of available widgets.\n      await recreatePanel();\n      // But still should be selected with a correct url.\n      assert.equal(await gu.getCustomWidgetName(), widget1.name);\n      assert.equal(await content(), widget1.name);\n      await gu.undo(1);\n    });\n\n    it(\"should switch access level to none on new widget\", async () => {\n      widgets = [widget1, widget2];\n      await recreatePanel();\n      await gu.setCustomWidget(widget1.name);\n      assert.equal(await access(), AccessLevel.none);\n      await access(AccessLevel.full);\n      assert.equal(await access(), AccessLevel.full);\n\n      await gu.setCustomWidget(widget2.name);\n      assert.equal(await access(), AccessLevel.none);\n      await access(AccessLevel.full);\n      assert.equal(await access(), AccessLevel.full);\n\n      await gu.setCustomWidget(CUSTOM_URL);\n      assert.equal(await access(), AccessLevel.none);\n      await access(AccessLevel.full);\n      assert.equal(await access(), AccessLevel.full);\n\n      await gu.setCustomWidget(widget2.name);\n      assert.equal(await access(), AccessLevel.none);\n      await access(AccessLevel.full);\n      assert.equal(await access(), AccessLevel.full);\n\n      await gu.undo(8);\n    });\n\n    it(\"should prompt for access change\", async () => {\n      widgets = [widget1, widget2, widgetFull, widgetNone, widgetRead];\n      await reloadWidgets();\n      await recreatePanel();\n\n      const test = async (w: ICustomWidget) => {\n        // Select widget without desired access level\n        await gu.setCustomWidget(widget1.name);\n        assert.isFalse(await hasPrompt());\n        assert.equal(await access(), AccessLevel.none);\n\n        // Select one with desired access level\n        await gu.setCustomWidget(w.name);\n\n        // Access level should be still none (test by content which will display access level from query string)\n        assert.equal(await content(), AccessLevel.none);\n        assert.equal(await access(), AccessLevel.none);\n        assert.isTrue(await hasPrompt());\n\n        // Accept, and test if prompt is hidden, and level stays\n        await accept();\n        assert.isFalse(await hasPrompt());\n        assert.equal(await access(), w.accessLevel);\n\n        // Do the same, but this time reject\n        await gu.setCustomWidget(widget1.name);\n        assert.isFalse(await hasPrompt());\n        assert.equal(await access(), AccessLevel.none);\n\n        await gu.setCustomWidget(w.name);\n        assert.isTrue(await hasPrompt());\n        assert.equal(await content(), AccessLevel.none);\n\n        await reject();\n        assert.isFalse(await hasPrompt());\n        assert.equal(await access(), AccessLevel.none);\n        assert.equal(await content(), AccessLevel.none);\n      };\n\n      await test(widgetFull);\n      await test(widgetRead);\n    });\n\n    it(\"should auto accept none access level\", async () => {\n      // Select widget without access level\n      await gu.setCustomWidget(widget1.name);\n      assert.isFalse(await hasPrompt());\n      assert.equal(await access(), AccessLevel.none);\n\n      // Switch to one with none access level\n      await gu.setCustomWidget(widgetNone.name);\n      assert.isFalse(await hasPrompt());\n      assert.equal(await access(), AccessLevel.none);\n      assert.equal(await content(), AccessLevel.none);\n    });\n\n    it(\"should show prompt when user switches sections\", async () => {\n      // Select widget without access level\n      await gu.setCustomWidget(widget1.name);\n      assert.isFalse(await hasPrompt());\n      assert.equal(await access(), AccessLevel.none);\n\n      // Switch to one with full access level\n      await gu.setCustomWidget(widgetFull.name);\n      assert.isTrue(await hasPrompt());\n\n      // Switch section, and test if prompt is hidden\n      await recreatePanel();\n      assert.isTrue(await hasPrompt());\n      assert.equal(await access(), AccessLevel.none);\n      assert.equal(await content(), AccessLevel.none);\n    });\n\n    it(\"should hide prompt when user switches widget\", async () => {\n      // Select widget without access level\n      await gu.setCustomWidget(widget1.name);\n      assert.isFalse(await hasPrompt());\n      assert.equal(await access(), AccessLevel.none);\n\n      // Switch to one with full access level\n      await gu.setCustomWidget(widgetFull.name);\n      assert.isTrue(await hasPrompt());\n\n      // Switch to another level.\n      await gu.setCustomWidget(widget1.name);\n      assert.isFalse(await hasPrompt());\n      assert.equal(await access(), AccessLevel.none);\n    });\n\n    it(\"should hide prompt when manually changes access level\", async () => {\n      // Select widget with no access level\n      const selectNone = async () => {\n        await gu.setCustomWidget(widgetNone.name);\n        assert.isFalse(await hasPrompt());\n        assert.equal(await access(), AccessLevel.none);\n        assert.equal(await content(), AccessLevel.none);\n      };\n\n      // Selects widget with full access level\n      const selectFull = async () => {\n        await gu.setCustomWidget(widgetFull.name);\n        assert.isTrue(await hasPrompt());\n        assert.equal(await content(), AccessLevel.none);\n        assert.equal(await content(), AccessLevel.none);\n      };\n\n      await selectNone();\n      await selectFull();\n\n      // Select the same level.\n      await access(AccessLevel.full);\n      assert.isFalse(await hasPrompt());\n      assert.equal(await access(), AccessLevel.full);\n      assert.equal(await content(), AccessLevel.full);\n\n      await selectNone();\n      await selectFull();\n\n      // Select the normal level, prompt should be still there, as widget needs a higher permission.\n      await access(AccessLevel.read_table);\n      assert.isTrue(await hasPrompt());\n      assert.equal(await access(), AccessLevel.read_table);\n      assert.equal(await content(), AccessLevel.read_table);\n\n      await selectNone();\n      await selectFull();\n\n      // Select the none level.\n      await access(AccessLevel.none);\n      assert.isTrue(await hasPrompt());\n      assert.equal(await access(), AccessLevel.none);\n      assert.equal(await content(), AccessLevel.none);\n    });\n  });\n\n  describe(\"gallery\", () => {\n    afterEach(() => gu.checkForErrors());\n\n    it(\"should show available widgets\", async () => {\n      await gu.openCustomWidgetGallery();\n      assert.deepEqual(\n        await driver.findAll(\".test-custom-widget-gallery-widget-name\", el => el.getText()),\n        [\"Custom URL\", \"full\", \"none\", \"read table\", \"W1\", \"W2\"],\n      );\n    });\n\n    it(\"should show available metadata\", async () => {\n      assert.deepEqual(\n        await driver.findAll(\".test-custom-widget-gallery-widget\", el =>\n          el.matches(\".test-custom-widget-gallery-widget-custom\")),\n        [true, false, false, false, false, false],\n      );\n      assert.deepEqual(\n        await driver.findAll(\".test-custom-widget-gallery-widget\", el =>\n          el.matches(\".test-custom-widget-gallery-widget-grist\")),\n        [false, true, true, true, true, false],\n      );\n      assert.deepEqual(\n        await driver.findAll(\".test-custom-widget-gallery-widget\", el =>\n          el.matches(\".test-custom-widget-gallery-widget-community\")),\n        [false, false, false, false, false, true],\n      );\n      assert.deepEqual(\n        await driver.findAll(\".test-custom-widget-gallery-widget-description\", el => el.getText()),\n        [\n          \"Add a widget from outside this gallery.\",\n          \"(Missing info)\",\n          \"(Missing info)\",\n          \"(Missing info)\",\n          \"Widget 1 description\",\n          \"(Missing info)\",\n        ],\n      );\n      assert.deepEqual(\n        await driver.findAll(\".test-custom-widget-gallery-widget-developer\", el => el.getText()),\n        [\n          \"(Missing info)\",\n          \"(Missing info)\",\n        ],\n      );\n      assert.deepEqual(\n        await driver.findAll(\".test-custom-widget-gallery-widget-last-updated\", el => el.getText()),\n        [\n          \"(Missing info)\",\n          \"(Missing info)\",\n          \"(Missing info)\",\n          \"July 30, 2024\",\n          \"(Missing info)\",\n        ],\n      );\n    });\n\n    it(\"should filter widgets on search\", async () => {\n      await driver.find(\".test-custom-widget-gallery-search\").click();\n      await gu.sendKeys(\"Custom\");\n      await gu.waitToPass(async () => {\n        assert.deepEqual(\n          await driver.findAll(\".test-custom-widget-gallery-widget-name\", el => el.getText()),\n          [\"Custom URL\"],\n        );\n      }, 200);\n      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE);\n      await gu.waitToPass(async () => {\n        assert.deepEqual(\n          await driver.findAll(\".test-custom-widget-gallery-widget-name\", el => el.getText()),\n          [\"Custom URL\", \"full\", \"none\", \"read table\", \"W1\", \"W2\"],\n        );\n      }, 200);\n      await gu.sendKeys(\"W\");\n      await gu.waitToPass(async () => {\n        assert.deepEqual(\n          await driver.findAll(\".test-custom-widget-gallery-widget-name\", el => el.getText()),\n          [\"Custom URL\", \"W1\", \"W2\"],\n        );\n      }, 200);\n      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, \"tab\");\n      await gu.waitToPass(async () => {\n        assert.deepEqual(\n          await driver.findAll(\".test-custom-widget-gallery-widget-name\", el => el.getText()),\n          [\"read table\"],\n        );\n      }, 200);\n      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, \"Markdown\");\n      await gu.waitToPass(async () => {\n        assert.deepEqual(\n          await driver.findAll(\".test-custom-widget-gallery-widget-name\", el => el.getText()),\n          [],\n        );\n      }, 200);\n      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, \"Developer 1\");\n      await gu.waitToPass(async () => {\n        assert.deepEqual(\n          await driver.findAll(\".test-custom-widget-gallery-widget-name\", el => el.getText()),\n          [\"W1\"],\n        );\n      }, 200);\n    });\n\n    it(\"should only show Custom URL widget when repository is disabled\", async () => {\n      await gu.sendKeys(Key.ESCAPE);\n      await driver.executeScript(\"window.gristConfig.enableWidgetRepository = false;\");\n      await driver.executeAsyncScript(\n        (done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done(),\n      );\n      await gu.openCustomWidgetGallery();\n      assert.deepEqual(\n        await driver.findAll(\".test-custom-widget-gallery-widget-name\", el => el.getText()),\n        [\"Custom URL\"],\n      );\n      await gu.sendKeys(Key.ESCAPE);\n      await driver.executeScript(\"window.gristConfig.enableWidgetRepository = true;\");\n      await driver.executeAsyncScript(\n        (done: any) => (window as any).gristApp?.topAppModel.testReloadWidgets().then(done).catch(done) || done(),\n      );\n    });\n\n    it(\"allows picking the same widget\", async () => {\n      await gu.setCustomWidget(/W1/);\n      assert.equal(await gu.getCustomWidgetName(), \"W1\");\n      await gu.setCustomWidget(/W1/);\n      assert.equal(await gu.getCustomWidgetName(), \"W1\");\n    });\n\n    it(\"should only allow adding a Custom URL widget with an empty or valid url\", async () => {\n      await gu.openCustomWidgetGallery();\n      await driver.findContent(\".test-custom-widget-gallery-widget-name\", \"Custom URL\").click();\n\n      const saveBtn = \".test-custom-widget-gallery-save\";\n      const urlInput = \".test-custom-widget-gallery-custom-url\";\n\n      // empty url should work without showing a warning modal\n      await driver.find(urlInput).click();\n      await gu.clearInput();\n      await driver.find(saveBtn).click();\n      await gu.waitForServer();\n      assert.isTrue((await content()).startsWith(\"Custom widget\"));\n      await gu.undo(1);\n      await gu.waitForServer();\n\n      // non-url text should not work: when submitting, we should still be in the gallery, no risk modal shown\n      await gu.openCustomWidgetGallery();\n      await driver.findContent(\".test-custom-widget-gallery-widget-name\", \"Custom URL\").click();\n      await driver.find(urlInput).click();\n      await gu.clearInput();\n      await gu.sendKeys(\"not a url\");\n      await driver.find(saveBtn).click();\n      assert.equal(await driver.find(\".test-modal-title\").isPresent(), false);\n      assert.isTrue(await driver.find(\".test-custom-widget-gallery-container\").isDisplayed());\n\n      // url should work: when submitting, the risk modal should be shown\n      await driver.find(urlInput).click();\n      await gu.clearInput();\n      await gu.sendKeys(\"https://grist-dummy-custom-widget.com\");\n      await driver.find(saveBtn).click();\n      assert.equal(await driver.find(\".test-modal-title\").isDisplayed(), true);\n\n      // cleanup after test: close modal and gallery\n      await driver.find(\".test-modal-cancel\").click();\n      await driver.find(\".test-custom-widget-gallery-cancel\").click();\n    });\n\n    it(\"should show a modal explaining the risks when adding a Custom URL widget\", async () => {\n      await gu.openCustomWidgetGallery();\n      await driver.find(\".test-custom-widget-gallery-custom-url\").click();\n      await gu.clearInput();\n      await gu.sendKeys(\"https://grist-dummy-custom-widget.com\");\n      await driver.find(\".test-custom-widget-gallery-save\").click();\n\n      assert.equal(await driver.findContent(\n        \".test-modal-title\",\n        /Be careful with unknown custom widgets/,\n      ).isDisplayed(), true);\n      assert.equal(await driver.find(\".test-custom-widget-warning-modal-confirm-checkbox\").isDisplayed(), true);\n\n      assert.equal(await driver.find(\".test-modal-cancel\").isDisplayed(), true);\n      assert.equal(await driver.find(\".test-modal-confirm\").isDisplayed(), true);\n\n      // cleanup after test: close modal and gallery\n      await driver.find(\".test-modal-cancel\").click();\n      await driver.find(\".test-custom-widget-gallery-cancel\").click();\n    });\n\n    it(\"should allow adding a Custom URL widget only when accepting the risks\", async () => {\n      await gu.openCustomWidgetGallery();\n      await driver.find(\".test-custom-widget-gallery-custom-url\").click();\n      await gu.clearInput();\n      await gu.sendKeys(\"https://grist-dummy-custom-widget.com\");\n      await driver.find(\".test-custom-widget-gallery-save\").click();\n\n      const confirmCb = \".test-custom-widget-warning-modal-confirm-checkbox\";\n      const saveBtn = \".test-modal-confirm\";\n\n      // make sure confirm checkbox and confirm modal button are linked:\n      // should be both unchecked/disabled at first, then toggle together\n      assert.equal(await driver.find(confirmCb).isSelected(), false);\n      assert.equal(await driver.find(saveBtn).isEnabled(), false);\n\n      await driver.find(confirmCb).click();\n      assert.equal(await driver.find(saveBtn).isEnabled(), true);\n\n      await driver.find(confirmCb).click();\n      assert.equal(await driver.find(saveBtn).isEnabled(), false);\n\n      await driver.find(confirmCb).click();\n      assert.equal(await driver.find(saveBtn).isEnabled(), true);\n\n      await driver.find(saveBtn).click();\n      await gu.openWidgetPanel();\n      await gu.waitForServer();\n      await gu.undo(1);\n    });\n  });\n\n  describe(\"gristApiSupport\", async () => {\n    beforeEach(async function() {\n      // We need to be sure that widget configuration panel is open all the time.\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await recreatePanel();\n      await gu.retryOnStale(() => driver.findWait(\".test-right-tab-pagewidget\", 100).click());\n    });\n\n    afterEach(() => gu.checkForErrors());\n\n    it(\"should set language in widget url\", async () => {\n      function languageMenu() {\n        return gu.currentDriver().find(\".test-account-page-language .test-select-open\");\n      }\n      async function language() {\n        return await gu.doInIframe(await getCustomWidgetFrame(), async () => {\n          const urlText = await driver.executeScript<string>(\"return document.location.href\");\n          const url = new URL(urlText);\n          return url.searchParams.get(\"language\");\n        });\n      }\n\n      async function switchLanguage(lang: string) {\n        await gu.openProfileSettingsPage();\n        await gu.waitForServer();\n        await languageMenu().click();\n        await gu.findOpenMenuItem(\"li\",  lang, 100).click();\n        await gu.waitForServer();\n        await driver.navigate().back();\n        await gu.waitForServer();\n      }\n\n      widgets = [widget1];\n      await reloadWidgets();\n      await gu.openWidgetPanel();\n      await gu.setCustomWidget(widget1.name);\n      // Switch language to Polish\n      await switchLanguage(\"Polski\");\n      // Check if widgets have \"pl\" in url\n      assert.equal(await language(), \"pl\");\n      // Switch back to English\n      await switchLanguage(\"English\");\n      // Check if widgets have \"en\" in url\n      assert.equal(await language(), \"en\");\n    });\n\n    it(\"should support grist.selectedTable\", async () => {\n      // Open a custom widget with full access.\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await driver.find(\".test-config-widget\").click();\n      await gu.waitForServer();\n      await access(AccessLevel.full);\n\n      // Check an upsert works.\n      await execute(async (table) => {\n        await table.upsert({\n          require: { A: \"hello\" },\n          fields: { A: \"goodbye\" },\n        });\n      });\n      await gu.waitToPass(async () => {\n        assert.equal(await gu.getCell({ section: \"TABLE1\", rowNum: 1, col: 0 }).getText(), \"goodbye\");\n      });\n\n      // Check an update works.\n      await execute(async (table) => {\n        return table.update({\n          id: 2,\n          fields: { A: \"farewell\" },\n        });\n      });\n      await gu.waitToPass(async () => {\n        assert.equal(await gu.getCell({ section: \"TABLE1\", rowNum: 2, col: 0 }).getText(), \"farewell\");\n      });\n\n      // Check options are passed along.\n      await execute(async (table) => {\n        return table.upsert({\n          require: {},\n          fields: { A: \"goodbyes\" },\n        }, { onMany: \"all\", allowEmptyRequire: true });\n      });\n      await gu.waitToPass(async () => {\n        assert.equal(await gu.getCell({ section: \"TABLE1\", rowNum: 1, col: 0 }).getText(), \"goodbyes\");\n        assert.equal(await gu.getCell({ section: \"TABLE1\", rowNum: 2, col: 0 }).getText(), \"goodbyes\");\n      });\n\n      // Check a create works.\n      const { id } = await execute(async (table) => {\n        return table.create({\n          fields: { A: \"partA\", B: \"partB\" },\n        });\n      }) as { id: number };\n      assert.equal(id, 5);\n      await gu.waitToPass(async () => {\n        assert.equal(await gu.getCell({ section: \"TABLE1\", rowNum: id, col: 0 }).getText(), \"partA\");\n        assert.equal(await gu.getCell({ section: \"TABLE1\", rowNum: id, col: 1 }).getText(), \"partB\");\n      });\n\n      // Check a destroy works.\n      let result = await execute(async (table) => {\n        await table.destroy(1);\n      });\n      assert.isUndefined(result);\n      await gu.waitToPass(async () => {\n        assert.equal(await gu.getCell({ section: \"TABLE1\", rowNum: id - 1, col: 0 }).getText(), \"partA\");\n      });\n      result = await execute(async (table) => {\n        await table.destroy([2]);\n      });\n      assert.isUndefined(result);\n      await gu.waitToPass(async () => {\n        assert.equal(await gu.getCell({ section: \"TABLE1\", rowNum: id - 2, col: 0 }).getText(), \"partA\");\n      });\n\n      // Check errors are friendly.\n      const errMessage = await execute(async (table) => {\n        await table.create({ fields: { ziggy: 1 } });\n      });\n      assert.equal(errMessage, 'Invalid column \"ziggy\"');\n    });\n\n    it(\"should support grist.getTable\", async () => {\n      // Check an update on an existing table works.\n      await execute(async (table) => {\n        return table.update({\n          id: 3,\n          fields: { A: \"back again\" },\n        });\n      }, grist => grist.getTable(\"Table1\"));\n      await gu.waitToPass(async () => {\n        assert.equal(await gu.getCell({ section: \"TABLE1\", rowNum: 1, col: 0 }).getText(), \"back again\");\n      });\n\n      // Check an update on a nonexistent table fails.\n      assert.match(String(await execute(async (table) => {\n        return table.update({\n          id: 3,\n          fields: { A: \"back again\" },\n        });\n      }, grist => grist.getTable(\"Table2\"))), /Table not found/);\n    });\n\n    it(\"should support grist.getAccessTokens\", async () => {\n      return await gu.doInIframe(await getCustomWidgetFrame(), async () => {\n        const tokenResult: AccessTokenResult = await driver.executeAsyncScript(\n          (done: any) => (window as any).grist.getAccessToken().then(done),\n        );\n        assert.sameMembers(Object.keys(tokenResult), [\"ttlMsecs\", \"token\", \"baseUrl\"]);\n        const result = await fetch(tokenResult.baseUrl + `/tables/Table1/records?auth=${tokenResult.token}`);\n        assert.sameMembers(Object.keys(await result.json()), [\"records\"]);\n      });\n    });\n  });\n\n  describe(\"Bundling\", function() {\n    let oldEnv: EnvironmentSnapshot;\n\n    before(async function() {\n      oldEnv = new EnvironmentSnapshot();\n    });\n\n    afterEach(async function() {\n      await gu.checkForErrors();\n      oldEnv.restore();\n      await server.restart();\n      await gu.reloadDoc();\n    });\n\n    for (const variant of [\"flat\", \"nested\"] as const) {\n      it(`can add widgets via plugins (${variant} layout)`, async function() {\n        // Double-check that using one external widget, we see\n        // just that widget listed.\n        widgets = [widget1];\n        await reloadWidgets();\n        await enableWidgetsAndShowPanel();\n        await gu.openCustomWidgetGallery();\n        assert.deepEqual(await galleryWidgets(), [\n          CUSTOM_URL, widget1.name,\n        ]);\n\n        // Get a temporary directory that will be cleaned up,\n        // and populated it as follows ('flat' variant)\n        //   plugins/\n        //     my-widgets/\n        //       manifest.yml   # a plugin manifest, listing widgets.json\n        //       widgets.json   # a widget set manifest, grist-widget style\n        //       p1.html        # one of the widgets in widgets.json\n        //       p2.html        # another of the widgets in widgets.json\n        //       grist-plugin-api.js   # a dummy api file, to check it is overridden\n        // In 'nested' variant, widgets.json and the files it refers to are in\n        // a subdirectory.\n        const dir = await createTmpDir();\n        const pluginDir = path.join(dir, \"plugins\", \"my-widgets\");\n        const widgetDir = variant === \"nested\" ? path.join(pluginDir, \"nested\") : pluginDir;\n        await fse.mkdirp(pluginDir);\n        await fse.mkdirp(widgetDir);\n\n        // A plugin, with some widgets in it.\n        await fse.writeFile(\n          path.join(pluginDir, \"manifest.yml\"),\n          `name: My Widgets\\n` +\n          `components:\\n` +\n          `  widgets: ${variant === \"nested\" ? \"nested/\" : \"\"}widgets.json\\n`,\n        );\n\n        // A list of a pair of custom widgets, with the widget\n        // source in the same directory.\n        await fse.writeFile(\n          path.join(widgetDir, \"widgets.json\"),\n          JSON.stringify([\n            {\n              widgetId: \"p1\",\n              name: \"P1\",\n              url: \"./p1.html\",\n            },\n            {\n              widgetId: \"p2\",\n              name: \"P2\",\n              url: \"./p2.html\",\n            },\n            {\n              widgetId: \"p3\",\n              name: \"P3\",\n              url: \"./p3.html\",\n              published: false,\n            },\n          ]),\n        );\n\n        // The first widget - just contains the text P1.\n        await fse.writeFile(\n          path.join(widgetDir, \"p1.html\"),\n          \"<html><body>P1</body></html>\",\n        );\n\n        // The second widget. This contains the text P2\n        // if grist is defined after loading grist-plugin-api.js\n        // (but the js bundled with the widget just throws an\n        // alert).\n        await fse.writeFile(\n          path.join(widgetDir, \"p2.html\"),\n          `\n          <html>\n          <head><script src=\"./grist-plugin-api.js\"></script></head>\n          <body>\n          <div id=\"readout\"></div>\n          <script>\n            if (typeof grist !== 'undefined') {\n              document.getElementById('readout').innerText = 'P2';\n            }\n          </script>\n          </body>\n          </html>\n          `,\n        );\n\n        // The third widget - just contains the text P3.\n        await fse.writeFile(\n          path.join(widgetDir, \"p3.html\"),\n          \"<html><body>P3</body></html>\",\n        );\n\n        // A dummy grist-plugin-api.js - hopefully the actual\n        // js for the current version of Grist will be served in\n        // its place.\n        await fse.writeFile(\n          path.join(widgetDir, \"grist-plugin-api.js\"),\n          'alert(\"Error: built in api version used\");',\n        );\n\n        // Restart server and reload doc now plugins are in place.\n        process.env.GRIST_USER_ROOT = dir;\n        await server.restart();\n        await gu.reloadDoc();\n\n        // Continue using one external widget.\n        await reloadWidgets();\n        await enableWidgetsAndShowPanel();\n\n        // Check we see one external widget and two bundled ones.\n        await gu.openCustomWidgetGallery();\n        assert.deepEqual(await galleryWidgets(), [\n          CUSTOM_URL, \"P1 (My Widgets)\", \"P2 (My Widgets)\", widget1.name,\n        ]);\n\n        // Prepare to check content of widgets.\n        async function getWidgetText(): Promise<string> {\n          return gu.doInIframe(await getCustomWidgetFrame(), () => {\n            return driver.executeScript(\n              () => document.body.innerText,\n            );\n          });\n        }\n\n        // Check built-in P1 works as expected.\n        await gu.setCustomWidget(/P1/, { openGallery: false });\n        assert.equal(await gu.getCustomWidgetName(), \"P1 (My Widgets)\");\n        await gu.waitToPass(async () => {\n          assert.equal(await getWidgetText(), \"P1\");\n        });\n\n        // Check external W1 works as expected.\n        await gu.setCustomWidget(/W1/);\n        assert.equal(await gu.getCustomWidgetName(), \"W1\");\n        await gu.waitToPass(async () => {\n          assert.equal(await getWidgetText(), \"W1\");\n        });\n\n        // Check build-in P2 works as expected.\n        await gu.setCustomWidget(/P2/);\n        assert.equal(await gu.getCustomWidgetName(), \"P2 (My Widgets)\");\n        await gu.waitToPass(async () => {\n          assert.equal(await getWidgetText(), \"P2\");\n        });\n\n        // Make sure widget setting is sticky.\n        await gu.reloadDoc();\n        await gu.waitToPass(async () => {\n          assert.equal(await getWidgetText(), \"P2\");\n        });\n      });\n    }\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/CustomWidgetsConfig.ts",
    "content": "import { AccessLevel } from \"app/common/CustomWidget\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { addStatic, serveSomething } from \"test/server/customUtil\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { addToRepl, assert, driver, Key } from \"mocha-webdriver\";\n\n// Valid manifest url.\nconst manifestEndpoint = \"/manifest.json\";\n\nlet docId = \"\";\n\n// Tester widget name.\nconst TESTER_WIDGET = \"Tester\";\nconst NORMAL_WIDGET = \"Normal\";\nconst READ_WIDGET = \"Read\";\nconst FULL_WIDGET = \"Full\";\nconst COLUMN_WIDGET = \"COLUMN_WIDGET\";\nconst REQUIRED_WIDGET = \"REQUIRED_WIDGET\";\nconst INVALID_URL_WIDGET = \"INVALID_URL_WIDGET\";\n// Custom URL label.\nconst CUSTOM_URL = \"Custom URL\";\n// Holds url for sample widget server.\nlet widgetServerUrl = \"\";\n\n// Creates url for Config Widget passing ready arguments in URL. This is not builtin method, Config Widget understands\n// this parameter and is using it as an argument for the ready method.\nfunction createConfigUrl(ready?: any) {\n  return ready ? `${widgetServerUrl}/config?ready=` + encodeURI(JSON.stringify(ready)) : `${widgetServerUrl}/config`;\n}\n\nconst click = (selector: string) => driver.find(`${selector}`).click();\nconst toggleDrop = (selector: string) => click(`${selector} .test-select-open`);\nconst getOptions = () => gu.findOpenMenuAllItems(\"li\", el => el.getText());\nconst clickOption = async (text: string | RegExp) => {\n  await gu.findOpenMenuItem(\"li\", text).click();\n  await gu.waitForServer();\n};\n// Persists custom options.\nconst persistOptions = () => click(\".test-section-menu-small-btn-save\");\n\n// Helpers to create test ids for column pickers\nconst pickerLabel = (name: string) => `.test-config-widget-label-for-${name}`;\nconst pickerDrop = (name: string) => `.test-config-widget-mapping-for-${name}`;\nconst pickerAdd = (name: string) => `.test-config-widget-add-column-for-${name}`;\n\n// Helpers to work with menus\nasync function clickMenuItem(name: string) {\n  await gu.findOpenMenuItem(\"li\", name).click();\n  await gu.waitForServer();\n}\nconst getMenuOptions = async () => {\n  await gu.findOpenMenu();\n  return await driver.findAll(\".grist-floating-menu li\", el => el.getText());\n};\nasync function getListItems(col: string) {\n  return await driver\n    .findAll(`.test-config-widget-map-list-for-${col} .test-config-widget-ref-select-label`, el => el.getText());\n}\n\nasync function refresh() {\n  await driver.navigate().refresh();\n  await gu.waitForDocToLoad();\n  // Switch section and enable config\n  await gu.selectSectionByTitle(\"Table\");\n  await gu.selectSectionByTitle(\"Widget\");\n}\n\n// Checks if active section has option in the menu to open configuration\nasync function hasSectionOption() {\n  const menu = await gu.openSectionMenu(\"viewLayout\");\n  const has = 1 === (await menu.findAll(\".test-section-open-configuration\")).length;\n  await driver.sendKeys(Key.ESCAPE);\n  return has;\n}\n\nasync function saveMenu() {\n  await driver.findWait(\".active_section .test-section-menu-small-btn-save\", 100).click();\n  await gu.waitForServer();\n}\n\nasync function revertMenu() {\n  await driver.findWait(\".active_section .test-section-menu-small-btn-revert\", 100).click();\n}\n\nasync function clearOptions() {\n  await gu.openSectionMenu(\"sortAndFilter\");\n  await driver.findWait(\".test-section-menu-btn-remove-options\", 100).click();\n  await driver.sendKeys(Key.ESCAPE);\n}\n\n// Check if the Sort menu is in correct state\nasync function checkSortMenu(state: \"empty\" | \"modified\" | \"customized\" | \"emptyNotSaved\") {\n  // for modified and emptyNotSaved menu should be greyed and buttons should be hidden\n  if (state === \"modified\" || state === \"emptyNotSaved\") {\n    assert.isTrue(await driver.find(\".active_section .test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n  } else {\n    assert.isFalse(await driver.find(\".active_section .test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n  }\n  // open menu\n  await gu.openSectionMenu(\"sortAndFilter\");\n  // for modified state, there should be buttons save and revert\n  if (state === \"modified\" || state === \"emptyNotSaved\") {\n    assert.isTrue(await driver.find(\".test-section-menu-btn-save\").isPresent());\n  } else {\n    assert.isFalse(await driver.find(\".test-section-menu-btn-save\").isPresent());\n  }\n  const text = await driver.find(\".test-section-menu-custom-options\").getText();\n  if (state === \"empty\" || state === \"emptyNotSaved\") {\n    assert.equal(text, \"(empty)\");\n  } else if (state === \"modified\") {\n    assert.equal(text, \"(modified)\");\n  } else if (state === \"customized\") {\n    assert.equal(text, \"(customized)\");\n  }\n  // there should be option to delete custom options\n  if (state === \"empty\" || state === \"emptyNotSaved\") {\n    assert.isFalse(await driver.find(\".test-section-menu-btn-remove-options\").isPresent());\n  } else {\n    assert.isTrue(await driver.find(\".test-section-menu-btn-remove-options\").isPresent());\n  }\n  await driver.sendKeys(Key.ESCAPE);\n}\n\ndescribe(\"CustomWidgetsConfig\", function() {\n  this.timeout(\"60s\");\n  const cleanup = setupTestSuite();\n  let mainSession: gu.Session;\n  gu.bigScreen();\n\n  let oldEnv: EnvironmentSnapshot;\n\n  addToRepl(\"getOptions\", getOptions);\n\n  before(async function() {\n    if (server.isExternalServer()) {\n      this.skip();\n    }\n\n    oldEnv = new EnvironmentSnapshot();\n    // Set to an unused URL so that the client reports that widgets are available.\n    process.env.GRIST_WIDGET_LIST_URL = \"unused\";\n    await server.restart();\n\n    // Create simple widget server that serves manifest.json file, some widgets and some error pages.\n    const widgetServer = await serveSomething((app) => {\n      app.get(\"/manifest.json\", (_, res) => {\n        res.json([\n          {\n            // Main Custom Widget with onEditOptions handler.\n            name: TESTER_WIDGET,\n            url: createConfigUrl({ onEditOptions: true }),\n            widgetId: \"tester1\",\n          },\n          {\n            // Widget without ready options.\n            name: NORMAL_WIDGET,\n            url: createConfigUrl(),\n            widgetId: \"tester2\",\n          },\n          {\n            // Widget requesting read access.\n            name: READ_WIDGET,\n            url: createConfigUrl({ requiredAccess: AccessLevel.read_table }),\n            widgetId: \"tester3\",\n          },\n          {\n            // Widget requesting full access.\n            name: FULL_WIDGET,\n            url: createConfigUrl({ requiredAccess: AccessLevel.full }),\n            widgetId: \"tester4\",\n          },\n          {\n            // Widget with column mapping\n            name: COLUMN_WIDGET,\n            url: createConfigUrl({\n              requiredAccess: AccessLevel.read_table, columns: [{ name: \"Column\", optional: true }],\n            }),\n            widgetId: \"tester5\",\n          },\n          {\n            // Widget with required column mapping\n            name: REQUIRED_WIDGET,\n            url: createConfigUrl({\n              requiredAccess: AccessLevel.read_table, columns: [{ name: \"Column\", optional: false }],\n            }),\n            widgetId: \"tester6\",\n          },\n          {\n            name: INVALID_URL_WIDGET,\n            url: \"ftp://getgrist.com/path\",\n            widgetId: \"tester7\",\n          },\n        ]);\n      });\n      addStatic(app);\n    });\n    cleanup.addAfterAll(widgetServer.shutdown);\n    widgetServerUrl = widgetServer.url;\n    await server.testingHooks.setWidgetRepositoryUrl(`${widgetServerUrl}${manifestEndpoint}`);\n\n    mainSession = await gu.session().login();\n    const doc = await mainSession.tempDoc(cleanup, \"CustomWidget.grist\");\n    docId = doc.id;\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await gu.selectSectionByTitle(\"Widget\");\n  });\n\n  after(async function() {\n    if (gu.noCleanup) { return; }\n    await server.testingHooks.setWidgetRepositoryUrl(\"\");\n    oldEnv.restore();\n    await server.restart();\n  });\n\n  beforeEach(async () => {\n    // Before each test, we will switch to Custom Url (to cleanup the widget)\n    // and then back to the Tester widget.\n    if ((await gu.getCustomWidgetName()) !== CUSTOM_URL) {\n      await gu.setCustomWidget(CUSTOM_URL);\n    }\n    await gu.setCustomWidget(TESTER_WIDGET);\n    await widget.waitForFrame();\n  });\n\n  it(\"should show better message when mapped columns are hidden\", async () => {\n    // Set widget with mappings requirements.\n    await widget.resetWidget();\n    await gu.setCustomWidget(REQUIRED_WIDGET);\n    await gu.acceptAccessRequest();\n\n    // Add hidden column to Table1.\n    await gu.sendActions([\n      [\"AddVisibleColumn\", \"Table1\", \"Hidden\", { type: \"Text\" }],\n    ]);\n\n    assert.include(await driver.findWait(\".test-custom-widget-not-mapped\", 2000).getText(),\n      \"Some required columns aren't mapped\");\n\n    // Now map it.\n    await toggleDrop(pickerDrop(\"Column\"));\n    await clickOption(\"Hidden\");\n\n    // And make sure widget is rendered.\n    assert.isTrue(await driver.findWait(\".test-custom-widget-ready\", 250).isDisplayed());\n\n    const api = mainSession.createHomeApi();\n    const revert = await gu.beginAclTran(api, docId);\n    await api.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"Hidden\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"-R\",\n      }],\n    ]);\n    // Web page will be reloaded, but it is hard to wait for it, so do it manually.\n    await gu.reloadDoc();\n\n    // Now we should see a warning placeholder that columns are not mapped.\n    assert.isTrue(await driver.find(\".test-custom-widget-not-mapped\").isDisplayed());\n    assert.include(await driver.findWait(\".test-custom-widget-not-mapped\", 2000).getText(),\n      \"Some required columns are hidden by access rules\");\n\n    await revert();\n    await gu.reloadDoc();\n\n    // Remove the hidden column.\n    await gu.sendActions([\n      [\"RemoveColumn\", \"Table1\", \"Hidden\"],\n    ]);\n  });\n\n  it(\"should hide widget when some columns are not mapped\", async () => {\n    // Reset the widget to the one that has a column mapping requirements.\n    await widget.resetWidget();\n\n    // Since the widget was reset, we don't have .test-custom-widget-ready element.\n    assert.isFalse(await driver.find(\".test-custom-widget-ready\").isPresent());\n\n    // Now select the widget that requires a column.\n    await gu.setCustomWidget(REQUIRED_WIDGET);\n    await gu.acceptAccessRequest();\n\n    // The widget iframe should be covered with a text explaining that the widget is not configured.\n    assert.isTrue(await driver.findWait(\".test-custom-widget-not-mapped\", 1000).isDisplayed());\n\n    // The content should at least have those words:\n    assert.include(await driver.find(\".test-custom-widget-not-mapped\").getText(),\n      \"Some required columns aren't mapped\");\n\n    // Make sure that the iframe is not displayed.\n    assert.isFalse(await driver.find(\".test-custom-widget-ready\").isDisplayed());\n\n    // Now map the column.\n    await toggleDrop(pickerDrop(\"Column\"));\n\n    // Map it to A.\n    await clickOption(\"A\");\n\n    // Make sure that the text is gone.\n    await gu.waitToPass(async () => {\n      assert.isFalse(await driver.find(\".test-config-widget-not-mapped\").isPresent());\n    });\n\n    // Make sure the widget is now visible.\n    assert.isTrue(await driver.findWait(\".test-custom-widget-ready\", 1000).isDisplayed());\n\n    // And we see widget with info about mapped columns, Column to A.\n    assert.deepEqual(await widget.onRecordsMappings(), { Column: \"A\" });\n  });\n\n  it(\"should hide mappings when there is no good column\", async () => {\n    await gu.setCustomWidgetUrl(\n      createConfigUrl({\n        columns: [{ name: \"M2\", type: \"Date\", optional: true }],\n        requiredAccess: \"read table\",\n      }),\n    );\n\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n    await widget.waitForPendingRequests();\n\n    // Get the drop for M2 mappings.\n    const mappingsForM2 = () => driver.find(pickerDrop(\"M2\"));\n\n    // Make sure it is disabled.\n    assert.isTrue(await mappingsForM2().matches(\".test-config-widget-disabled\"));\n    // And the text is:\n    assert.equal(await mappingsForM2().getText(), \"No date columns in table.\");\n\n    // Now add Date column.\n    await gu.sendActions([[\"AddVisibleColumn\", \"Table1\", \"NewCol\", { type: \"Date\" }]]);\n\n    // Now drop should be enabled.\n    assert.isFalse(await mappingsForM2().matches(\".test-config-widget-disabled\"));\n    assert.isTrue(await mappingsForM2().matches(\".test-config-widget-enabled\"));\n\n    // And the text is:\n    assert.equal(await mappingsForM2().getText(), \"Pick a date column\");\n\n    // Expand it and make sure we have NewCol there.\n    await toggleDrop(pickerDrop(\"M2\"));\n    assert.deepEqual(await getOptions(), [\"NewCol\"]);\n\n    // Select that column.\n    await clickOption(\"NewCol\");\n\n    // Now expand the drop again and make sure we can't clear it.\n    await toggleDrop(pickerDrop(\"M2\"));\n    assert.deepEqual(await getOptions(), [\"NewCol\", \"Clear selection\"]);\n\n    // Now remove the column, and make sure that the drop is disabled again.\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.sendActions([[\"RemoveColumn\", \"Table1\", \"NewCol\"]]);\n\n    // Make sure it is disabled.\n    assert.isTrue(await mappingsForM2().matches(\".test-config-widget-disabled\"));\n    assert.isFalse(await mappingsForM2().matches(\".test-config-widget-enabled\"));\n    assert.equal(await mappingsForM2().getText(), \"No date columns in table.\");\n  });\n\n  it(\"should clear optional mapping\", async () => {\n    const revert = await gu.begin();\n    await gu.setCustomWidgetUrl(\n      createConfigUrl({\n        columns: [{ name: \"M2\", type: \"Date\", optional: true }],\n        requiredAccess: \"read table\",\n      }),\n    );\n\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n    await widget.waitForPendingRequests();\n\n    // Get the drop for M2 mappings.\n    const mappingsForM2 = () => driver.find(pickerDrop(\"M2\"));\n\n    // Make sure it is disabled.\n    assert.isTrue(await mappingsForM2().matches(\".test-config-widget-disabled\"));\n    // Now add Date column.\n    await gu.sendActions([[\"AddVisibleColumn\", \"Table1\", \"NewCol\", { type: \"Date\" }]]);\n\n    // Expand it and make sure we have NewCol there.\n    await toggleDrop(pickerDrop(\"M2\"));\n    assert.deepEqual(await getOptions(), [\"NewCol\"]);\n\n    // Select that column.\n    await clickOption(\"NewCol\");\n\n    // Make sure widget sees the mapping.\n    assert.deepEqual(await widget.onRecordsMappings(), { M2: \"NewCol\" });\n\n    // Now expand the drop again and make sure we can clear it.\n    await toggleDrop(pickerDrop(\"M2\"));\n    assert.deepEqual(await getOptions(), [\"NewCol\", \"Clear selection\"]);\n\n    // Now clear the mapping.\n    await clickOption(\"Clear selection\");\n    assert.equal(await mappingsForM2().getText(), \"Pick a date column\");\n\n    // Make sure widget sees the mapping.\n    assert.deepEqual(await widget.onRecordsMappings(), { M2: null });\n    await revert();\n  });\n\n  it(\"should render columns mapping\", async () => {\n    const revert = await gu.begin();\n    assert.isTrue(await driver.find(\".test-vfc-visible-fields-select-all\").isPresent());\n    await gu.setCustomWidget(COLUMN_WIDGET);\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n    await widget.waitForPendingRequests();\n    // Visible columns section should be hidden.\n    assert.isFalse(await driver.find(\".test-vfc-visible-fields-select-all\").isPresent());\n    // Record event should be fired.\n    assert.deepEqual(await widget.onRecords(), [\n      { id: 1, A: \"A\" },\n      { id: 2, A: \"B\" },\n      { id: 3, A: \"C\" },\n    ]);\n    // Mappings should null at first.\n    assert.isNull(await widget.onRecordsMappings());\n    // We should see a single Column picker.\n    assert.isTrue(await driver.find(\".test-config-widget-label-for-Column\").isPresent());\n    // With single column to map.\n    await toggleDrop(pickerDrop(\"Column\"));\n    assert.deepEqual(await getOptions(), [\"A\"]);\n    await clickOption(\"A\");\n    await widget.waitForPendingRequests();\n    // Widget should receive mappings\n    assert.deepEqual(await widget.onRecordsMappings(), { Column: \"A\" });\n    await revert();\n  });\n\n  it(\"should render multiple mappings\", async () => {\n    const revert = await gu.begin();\n    // This is not standard way of creating widgets. The widgets in this test is reading this parameter\n    // and is using it to invoke the ready method.\n    await gu.setCustomWidgetUrl(\n      createConfigUrl({\n        columns: [\"M1\", { name: \"M2\", optional: true }, { name: \"M3\", title: \"T3\" }, { name: \"M4\", type: \"Text\" }],\n        requiredAccess: \"read table\",\n      }),\n    );\n    await gu.acceptAccessRequest();\n    await widget.waitForPlaceholder();\n    // We should see 4 pickers\n    assert.isTrue(await driver.find(pickerLabel(\"M1\")).isPresent());\n    assert.isTrue(await driver.find(pickerLabel(\"M2\")).isPresent());\n    assert.isTrue(await driver.find(pickerLabel(\"M3\")).isPresent());\n    assert.isTrue(await driver.find(pickerLabel(\"M4\")).isPresent());\n    assert.equal(await driver.find(pickerLabel(\"M1\")).getText(), \"M1\");\n    assert.equal(await driver.find(pickerLabel(\"M2\")).getText(), \"M2 (optional)\");\n    // Label for picker M3 should have alternative text;\n    assert.equal(await driver.find(pickerLabel(\"M3\")).getText(), \"T3\");\n    assert.equal(await driver.find(pickerLabel(\"M4\")).getText(), \"M4\");\n    // All picker should show \"Pick a column\" except M4, which should say \"Pick a text column\"\n    assert.equal(await driver.find(pickerDrop(\"M1\")).getText(), \"Pick a column\");\n    assert.equal(await driver.find(pickerDrop(\"M2\")).getText(), \"Pick a column\");\n    assert.equal(await driver.find(pickerDrop(\"M3\")).getText(), \"Pick a column\");\n    assert.equal(await driver.find(pickerDrop(\"M4\")).getText(), \"Pick a text column\");\n    // Should be able to select column A for all options\n    await toggleDrop(pickerDrop(\"M1\"));\n    await clickOption(\"A\");\n    await toggleDrop(pickerDrop(\"M2\"));\n    await clickOption(\"A\");\n    await toggleDrop(pickerDrop(\"M3\"));\n    await clickOption(\"A\");\n    await toggleDrop(pickerDrop(\"M4\"));\n    await clickOption(\"A\");\n    await widget.waitForFrame();\n    await widget.waitForPendingRequests();\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: \"A\", M2: \"A\", M3: \"A\", M4: \"A\" });\n    // Single record should also receive update.\n    assert.deepEqual(await widget.onRecordMappings(), { M1: \"A\", M2: \"A\", M3: \"A\", M4: \"A\" });\n    // Undo should revert mappings - there should be only 3 operations to revert to first mapping.\n    await gu.undo(3);\n    await widget.waitForPlaceholder();\n    // Add another columns, numeric B and any C.\n    await gu.selectSectionByTitle(\"Table\");\n    await gu.addColumn(\"B\");\n    await gu.getCell(\"B\", 1).click();\n    await gu.enterCell(\"99\");\n    await gu.addColumn(\"C\");\n    await gu.selectSectionByTitle(\"Widget\");\n    // Column M1 should be mappable to all 3, column M4 only to A and C\n    await toggleDrop(pickerDrop(\"M1\"));\n    assert.deepEqual(await getOptions(), [\"A\", \"B\", \"C\"]);\n    await toggleDrop(pickerDrop(\"M4\"));\n    assert.deepEqual(await getOptions(), [\"A\", \"C\"]);\n    await revert();\n  });\n\n  it(\"should clear mappings on widget switch\", async () => {\n    const revert = await gu.begin();\n\n    await gu.setCustomWidget(COLUMN_WIDGET);\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n    await widget.waitForPendingRequests();\n\n    // Make sure columns are there to pick.\n\n    // Visible column section is hidden.\n    assert.isFalse(await driver.find(\".test-vfc-visible-fields-select-all\").isPresent());\n    // We should see a single Column picker.\n    assert.isTrue(await driver.find(\".test-config-widget-label-for-Column\").isPresent());\n\n    // Pick first column\n    await toggleDrop(pickerDrop(\"Column\"));\n    await clickOption(\"A\");\n\n    // Now change to a widget without columns\n    await gu.setCustomWidget(NORMAL_WIDGET);\n\n    // Picker should disappear and column mappings should be visible\n    assert.isTrue(await driver.find(\".test-vfc-visible-fields-select-all\").isPresent());\n    assert.isFalse(await driver.find(\".test-config-widget-label-for-Column\").isPresent());\n\n    await gu.changeWidgetAccess(AccessLevel.read_table);\n    // Widget should receive full records.\n    assert.deepEqual(await widget.onRecords(), [\n      { id: 1, A: \"A\" },\n      { id: 2, A: \"B\" },\n      { id: 3, A: \"C\" },\n    ]);\n    // Now go back to the widget with mappings.\n    await gu.setCustomWidget(COLUMN_WIDGET);\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n    await widget.waitForPendingRequests();\n    assert.equal(await driver.find(pickerDrop(\"Column\")).getText(), \"Pick a column\");\n    assert.isFalse(await driver.find(\".test-vfc-visible-fields-select-all\").isPresent());\n    assert.isTrue(await driver.find(\".test-config-widget-label-for-Column\").isPresent());\n    await revert();\n  });\n\n  it(\"should render multiple options\", async () => {\n    const revert = await gu.begin();\n    await gu.setCustomWidgetUrl(\n      createConfigUrl({\n        columns: [\n          { name: \"M1\", allowMultiple: true, optional: true },\n          { name: \"M2\", type: \"Text\", allowMultiple: true, optional: true },\n        ],\n        requiredAccess: \"read table\",\n      }),\n    );\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n    // Add some columns, numeric B and any C.\n    await gu.selectSectionByTitle(\"Table\");\n    await gu.addColumn(\"B\");\n    await gu.getCell(\"B\", 1).click();\n    await gu.enterCell(\"99\");\n    await gu.addColumn(\"C\");\n    await gu.selectSectionByTitle(\"Widget\");\n    await widget.waitForPendingRequests();\n    // Make sure we have no mappings\n    assert.deepEqual(await widget.onRecordsMappings(), null);\n    // Map all columns to M1\n    await click(pickerAdd(\"M1\"));\n    assert.deepEqual(await getMenuOptions(), [\"A\", \"B\", \"C\"]);\n    await clickMenuItem(\"A\");\n    await click(pickerAdd(\"M1\"));\n    await clickMenuItem(\"B\");\n    await click(pickerAdd(\"M1\"));\n    await clickMenuItem(\"C\");\n    await widget.waitForPendingRequests();\n    const empty = { M1: [], M2: [] };\n    assert.deepEqual(await widget.onRecordsMappings(), { ...empty, M1: [\"A\", \"B\", \"C\"] });\n    // Map A and C to M2\n    await click(pickerAdd(\"M2\"));\n    assert.deepEqual(await getMenuOptions(), [\"A\", \"C\"]);\n    // There should be information that column B is hidden (as it is not text)\n    assert.equal(await driver.find(\".test-config-widget-map-message-M2\").getText(), \"1 non-text column is not shown\");\n    await clickMenuItem(\"A\");\n    await click(pickerAdd(\"M2\"));\n    await clickMenuItem(\"C\");\n    await widget.waitForPendingRequests();\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: [\"A\", \"B\", \"C\"], M2: [\"A\", \"C\"] });\n    function dragItem(column: string, item: string) {\n      return driver.findContent(`.test-config-widget-map-list-for-${column} .kf_draggable`, item);\n    }\n    // Should support reordering, reorder - move A after C\n    await driver.withActions(actions =>\n      actions\n        .move({ origin: dragItem(\"M1\", \"A\") })\n        .move({ origin: dragItem(\"M1\", \"A\").find(\".test-dragger\") })\n        .press()\n        .move({ origin: dragItem(\"M1\", \"C\"), y: 1 })\n        .release(),\n    );\n    await gu.waitForServer();\n    await widget.waitForPendingRequests();\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: [\"B\", \"C\", \"A\"], M2: [\"A\", \"C\"] });\n    // Should support removing\n    const removeButton = (column: string, item: string) => {\n      return dragItem(column, item).mouseMove().find(\".test-config-widget-ref-select-remove\");\n    };\n    await removeButton(\"M1\", \"B\").click();\n    await gu.waitForServer();\n    await widget.waitForPendingRequests();\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: [\"C\", \"A\"], M2: [\"A\", \"C\"] });\n    // Should undo removing\n    await gu.undo();\n    await widget.waitForPendingRequests();\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: [\"B\", \"C\", \"A\"], M2: [\"A\", \"C\"] });\n    await removeButton(\"M1\", \"B\").click();\n    await gu.waitForServer();\n    await removeButton(\"M1\", \"C\").click();\n    await gu.waitForServer();\n    await removeButton(\"M2\", \"C\").click();\n    await gu.waitForServer();\n    await widget.waitForPendingRequests();\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: [\"A\"], M2: [\"A\"] });\n    await revert();\n  });\n\n  it(\"should support multiple types in mappings\", async () => {\n    const revert = await gu.begin();\n    await gu.setCustomWidgetUrl(\n      createConfigUrl({\n        columns: [\n          { name: \"M1\", type: \"Date,DateTime\", optional: true },\n          { name: \"M2\", type: \"Date, DateTime \", allowMultiple: true, optional: true },\n        ],\n        requiredAccess: \"read table\",\n      }),\n    );\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n    // Add B=Date, C=DateTime, D=Numeric\n    await gu.sendActions([\n      [\"AddVisibleColumn\", \"Table1\", \"B\", { type: \"Any\" }],\n      [\"AddVisibleColumn\", \"Table1\", \"C\", { type: \"Date\" }],\n      [\"AddVisibleColumn\", \"Table1\", \"D\", { type: \"DateTime\" }],\n      [\"AddVisibleColumn\", \"Table1\", \"E\", { type: \"Numeric\" }],\n      // Add sample record.\n      [\"UpdateRecord\", \"Table1\", 1, { C: \"2019-01-01\", D: \"2019-01-01 12:00\", E: 1 }],\n    ]);\n\n    await gu.selectSectionByTitle(\"Widget\");\n    await widget.waitForPendingRequests();\n    // Make sure we have no mappings\n    assert.deepEqual(await widget.onRecordsMappings(), null);\n    // Now see what we are offered for M1.\n    await toggleDrop(pickerDrop(\"M1\"));\n    assert.deepEqual(await getOptions(), [\"B\", \"C\", \"D\"]);\n    // Make sure they work. First select C.\n    await clickOption(\"B\");\n    // Make sure onRecord and onRecordMappings looks legit.\n    assert.deepEqual(await widget.onRecord(), { id: 1, B: null });\n    assert.deepEqual(await widget.onRecordMappings(), { M1: \"B\", M2: [] });\n    // Now select C.\n    await toggleDrop(pickerDrop(\"M1\"));\n    await clickOption(\"C\");\n    assert.deepEqual(await widget.onRecord(), { id: 1, C: \"2019-01-01T00:00:00.000Z\" });\n    assert.deepEqual(await widget.onRecordMappings(), { M1: \"C\", M2: [] });\n    // Now select D.\n    await toggleDrop(pickerDrop(\"M1\"));\n    await clickOption(\"D\");\n    assert.deepEqual(await widget.onRecord(), { id: 1, D: \"2019-01-01T17:00:00.000Z\" });\n    assert.deepEqual(await widget.onRecordMappings(), { M1: \"D\", M2: [] });\n\n    // Make sure we can select multiple columns for M2 with Date and DateTime.\n    await click(pickerAdd(\"M2\"));\n    assert.deepEqual(await getMenuOptions(), [\"B\", \"C\", \"D\"]);\n    await clickMenuItem(\"B\");\n\n    assert.deepEqual(await widget.onRecordMappings(), { M1: \"D\", M2: [\"B\"] });\n    await click(pickerAdd(\"M2\"));\n    await clickMenuItem(\"C\");\n    assert.deepEqual(await widget.onRecordMappings(), { M1: \"D\", M2: [\"B\", \"C\"] });\n\n    await revert();\n  });\n\n  it(\"should support strictType setting\", async () => {\n    const revert = await gu.begin();\n    await gu.setCustomWidgetUrl(\n      createConfigUrl({\n        columns: [\n          { name: \"Any\", type: \"Any\", strictType: true, optional: true },\n          { name: \"Date_Numeric\", type: \"Date, Numeric\", strictType: true, optional: true },\n          { name: \"Date_Any\", type: \"Date, Any\", strictType: true, optional: true },\n          { name: \"Date\", type: \"Date\", strictType: true, optional: true },\n        ],\n        requiredAccess: \"read table\",\n      }),\n    );\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n    await gu.sendActions([\n      [\"AddVisibleColumn\", \"Table1\", \"Any\", { type: \"Any\" }],\n      [\"AddVisibleColumn\", \"Table1\", \"Date\", { type: \"Date\" }],\n      [\"AddVisibleColumn\", \"Table1\", \"Numeric\", { type: \"Numeric\" }],\n    ]);\n\n    await gu.selectSectionByTitle(\"Widget\");\n    await widget.waitForPendingRequests();\n\n    // Make sure we have no mappings\n    assert.deepEqual(await widget.onRecordsMappings(), null);\n\n    await toggleDrop(pickerDrop(\"Date\"));\n    assert.deepEqual(await getOptions(), [\"Date\"]);\n    await gu.sendKeys(Key.ESCAPE);  // To ensure the open dropdown doesn't cover the next option we test\n\n    await toggleDrop(pickerDrop(\"Date_Any\"));\n    assert.deepEqual(await getOptions(), [\"Any\", \"Date\"]);\n    await gu.sendKeys(Key.ESCAPE);\n\n    await toggleDrop(pickerDrop(\"Date_Numeric\"));\n    assert.deepEqual(await getOptions(), [\"Date\", \"Numeric\"]);\n    await gu.sendKeys(Key.ESCAPE);\n\n    await toggleDrop(pickerDrop(\"Any\"));\n    assert.deepEqual(await getOptions(), [\"Any\"]);\n    await gu.sendKeys(Key.ESCAPE);\n\n    await revert();\n  });\n\n  it(\"should react to widget options change\", async () => {\n    const revert = await gu.begin();\n    await gu.setCustomWidgetUrl(\n      createConfigUrl({\n        columns: [\n          { name: \"Choice\", type: \"Choice\", strictType: true, optional: true },\n        ],\n        requiredAccess: \"read table\",\n      }),\n    );\n\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n\n    const widgetOptions = {\n      choices: [\"A\"],\n      choiceOptions: { A: { textColor: \"red\" } },\n    };\n    await gu.sendActions([\n      [\"AddVisibleColumn\", \"Table1\", \"Choice\", { type: \"Choice\", widgetOptions: JSON.stringify(widgetOptions) }],\n    ]);\n    await gu.selectSectionByTitle(\"Widget\");\n    await widget.waitForPendingRequests();\n\n    await toggleDrop(pickerDrop(\"Choice\"));\n    await clickOption(\"Choice\");\n    await widget.waitForPendingRequests();\n\n    // Clear logs\n    await widget.clearLog();\n    assert.isEmpty(await widget.log());\n\n    // Now update options in that one column;\n    widgetOptions.choiceOptions.A.textColor = \"blue\";\n    await gu.sendActions([\n      [\"ModifyColumn\", \"Table1\", \"Choice\", { widgetOptions: JSON.stringify(widgetOptions) }],\n    ]);\n\n    await gu.waitToPass(async () => {\n      // Make sure widget sees that mapping are changed.\n      assert.equal(await widget.log(), '{\"tableId\":\"Table1\",\"rowId\":1,\"dataChange\":true,\"mappingsChange\":true}');\n    });\n\n    await revert();\n  });\n\n  it(\"should remove mapping when column is deleted\", async () => {\n    const revert = await gu.begin();\n    // Prepare mappings for single and multiple columns\n    await gu.setCustomWidgetUrl(\n      createConfigUrl({\n        columns: [{ name: \"M1\", optional: true }, { name: \"M2\", allowMultiple: true, optional: true }],\n        requiredAccess: \"read table\",\n      }),\n    );\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n    // Add some columns, to remove later\n    await gu.selectSectionByTitle(\"Table\");\n    await gu.addColumn(\"B\");\n    await gu.addColumn(\"C\");\n    await gu.selectSectionByTitle(\"Widget\");\n    await widget.waitForPendingRequests();\n    // Make sure we have no mappings\n    assert.deepEqual(await widget.onRecordsMappings(), null);\n    // Map B to M1\n    await toggleDrop(pickerDrop(\"M1\"));\n    await clickOption(\"B\");\n    await widget.waitForPendingRequests();\n    // Map all columns to M2\n    for (const col of [\"A\", \"B\", \"C\"]) {\n      await click(pickerAdd(\"M2\"));\n      await clickMenuItem(col);\n      await widget.waitForPendingRequests();\n    }\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: \"B\", M2: [\"A\", \"B\", \"C\"] });\n    assert.deepEqual(await widget.onRecords(), [\n      { id: 1, B: null, A: \"A\", C: null },\n      { id: 2, B: null, A: \"B\", C: null },\n      { id: 3, B: null, A: \"C\", C: null },\n    ]);\n    const removeColumn = async (col: string) => {\n      await gu.selectSectionByTitle(\"Table\");\n      await gu.openColumnMenu(col, \"Delete column\");\n      await gu.waitForServer();\n      await widget.waitForPendingRequests();\n      await gu.selectSectionByTitle(\"Widget\");\n    };\n    // Remove B column\n    await removeColumn(\"B\");\n    await widget.waitForPendingRequests();\n    // Mappings should be updated\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: null, M2: [\"A\", \"C\"] });\n    // Records should not have B column\n    assert.deepEqual(await widget.onRecords(), [\n      { id: 1, A: \"A\", C: null },\n      { id: 2, A: \"B\", C: null },\n      { id: 3, A: \"C\", C: null },\n    ]);\n    // Should be able to add B once more\n\n    // Add B as a new column\n    await gu.selectSectionByTitle(\"Table\");\n    await gu.addColumn(\"B\");\n    await gu.selectSectionByTitle(\"Widget\");\n    await widget.waitForPendingRequests();\n    // Adding the same column should not add it to mappings or records (as this is a new Id)\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: null, M2: [\"A\", \"C\"] });\n    assert.deepEqual(await widget.onRecords(), [\n      { id: 1, A: \"A\", C: null },\n      { id: 2, A: \"B\", C: null },\n      { id: 3, A: \"C\", C: null },\n    ]);\n\n    // Add B column as a new one.\n    await toggleDrop(pickerDrop(\"M1\"));\n    // Make sure it is there to select.\n    assert.deepEqual(await getOptions(), [\"A\", \"C\", \"B\", \"Clear selection\"]);\n    await clickOption(\"B\");\n    await widget.waitForPendingRequests();\n    await click(pickerAdd(\"M2\"));\n    assert.deepEqual(await getMenuOptions(), [\"B\"]); // multiple selection will only show not selected columns\n    await clickMenuItem(\"B\");\n    await widget.waitForPendingRequests();\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: \"B\", M2: [\"A\", \"C\", \"B\"] });\n    assert.deepEqual(await widget.onRecords(), [\n      { id: 1, B: null, A: \"A\", C: null },\n      { id: 2, B: null, A: \"B\", C: null },\n      { id: 3, B: null, A: \"C\", C: null },\n    ]);\n    await revert();\n  });\n\n  it(\"should remove mapping when column type is changed\", async () => {\n    const revert = await gu.begin();\n    // Prepare mappings for single and multiple columns\n    await gu.setCustomWidgetUrl(\n      createConfigUrl({\n        columns: [\n          { name: \"M1\", type: \"Text\", optional: true },\n          { name: \"M2\", type: \"Text\", allowMultiple: true, optional: true },\n        ],\n        requiredAccess: \"read table\",\n      }),\n    );\n    await widget.waitForFrame();\n    await gu.acceptAccessRequest();\n    await widget.waitForPendingRequests();\n    assert.deepEqual(await widget.onRecordsMappings(), null);\n    assert.deepEqual(await widget.onRecords(), [\n      { id: 1, A: \"A\" },\n      { id: 2, A: \"B\" },\n      { id: 3, A: \"C\" },\n    ]);\n    await toggleDrop(pickerDrop(\"M1\"));\n    await clickOption(\"A\");\n    await click(pickerAdd(\"M2\"));\n    await clickMenuItem(\"A\");\n    assert.equal(await driver.find(pickerDrop(\"M1\")).getText(), \"A\");\n    assert.deepEqual(await getListItems(\"M2\"), [\"A\"]);\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: \"A\", M2: [\"A\"] });\n    assert.deepEqual(await widget.onRecords(), [\n      { id: 1, A: \"A\" },\n      { id: 2, A: \"B\" },\n      { id: 3, A: \"C\" },\n    ]);\n    // Change column type to numeric\n    await gu.selectSectionByTitle(\"Table\");\n    await gu.getCell(\"A\", 1).click();\n    await gu.setType(/Numeric/);\n    await gu.selectSectionByTitle(\"Widget\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await gu.waitForServer();\n    await widget.waitForPendingRequests();\n    // Drop should be empty,\n    await driver.wait(async () =>\n      await driver.find(pickerDrop(\"M1\")).getText() == \"No text columns in table.\", 1000);\n    assert.isEmpty(await getListItems(\"M2\"));\n    // And drop is disabled.\n    assert.isTrue(await driver.find(pickerDrop(\"M1\")).matches(\".test-config-widget-disabled\"));\n    // The same for M2\n    assert.isTrue(await driver.find(pickerAdd(\"M2\")).matches(\".test-config-widget-disabled\"));\n    assert.deepEqual(await widget.onRecordsMappings(), { M1: null, M2: [] });\n    assert.deepEqual(await widget.onRecords(), [\n      { id: 1 },\n      { id: 2 },\n      { id: 3 },\n    ]);\n    await revert();\n  });\n\n  it(\"should not display options on grid, card, card list, chart\", async () => {\n    // Add Empty Grid\n    await gu.addNewSection(/Table/, /Table1/);\n    assert.isFalse(await hasSectionOption());\n    await gu.undo();\n\n    // Add Card view\n    await gu.addNewSection(/Card/, /Table1/);\n    assert.isFalse(await hasSectionOption());\n    await gu.undo();\n\n    // Add Card List view\n    await gu.addNewSection(/Card List/, /Table1/);\n    assert.isFalse(await hasSectionOption());\n    await gu.undo();\n\n    // Add Card List view\n    await gu.addNewSection(/Chart/, /Table1/);\n    assert.isFalse(await hasSectionOption());\n    await gu.undo();\n\n    // Add Custom - no section option by default\n    await gu.addNewSection(/Custom/, /Table1/, { customWidget: /Custom URL/ });\n    assert.isFalse(await hasSectionOption());\n    await gu.setCustomWidget(TESTER_WIDGET);\n    assert.isTrue(await hasSectionOption());\n    await gu.undo(2);\n  });\n\n  it(\"should indicate current state\", async () => {\n    // Save button is available under Filter/Sort menu.\n    // For this custom widget it has four states:\n    // - Empty: no options are saved\n    // - Modified: options were set but are not saved yet\n    // - Customized: options are saved\n    // - Empty not saved: options are cleared but not saved\n    // This test test all the available transitions between those four states\n\n    const options = { test: 1 } as const;\n    const options2 = { test: 2 } as const;\n    // From the start we should be in empty state\n    await checkSortMenu(\"empty\");\n    // Make modification\n    await widget.setOptions(options);\n    // State should be modified\n    await checkSortMenu(\"modified\");\n    assert.deepEqual(await widget.onOptions(), options);\n    // Revert, should end up with empty state.\n    await revertMenu();\n    await checkSortMenu(\"empty\");\n    assert.equal(await widget.onOptions(), null);\n\n    // Update once again and save.\n    await widget.setOptions(options);\n    await saveMenu();\n    await checkSortMenu(\"customized\");\n    // Now test if undo works.\n    await gu.undo();\n    await checkSortMenu(\"empty\");\n    assert.equal(await widget.onOptions(), null);\n\n    // Update once again and save.\n    await widget.setOptions(options);\n    await saveMenu();\n    // Modify and check the state - should be modified\n    await widget.setOptions(options2);\n    await checkSortMenu(\"modified\");\n    assert.deepEqual(await widget.onOptions(), options2);\n    await saveMenu();\n\n    // Now clear options.\n    await clearOptions();\n    await checkSortMenu(\"emptyNotSaved\");\n    assert.equal(await widget.onOptions(), null);\n    // And revert\n    await revertMenu();\n    await checkSortMenu(\"customized\");\n    assert.deepEqual(await widget.onOptions(), options2);\n    // Clear once again and save.\n    await clearOptions();\n    await saveMenu();\n    assert.equal(await widget.onOptions(), null);\n    await checkSortMenu(\"empty\");\n    // And check if undo goes to customized\n    await gu.undo();\n    await checkSortMenu(\"customized\");\n    assert.deepEqual(await widget.onOptions(), options2);\n  });\n\n  for (const access of [\"none\", \"read table\", \"full\"] as const) {\n    describe(`with ${access} access`, function() {\n      before(function() {\n        if (server.isExternalServer()) {\n          this.skip();\n        }\n      });\n      it(`should get null options`, async () => {\n        await gu.changeWidgetAccess(access);\n        await widget.waitForFrame();\n        assert.equal(await widget.onOptions(), null);\n        assert.equal(await widget.access(), access);\n        assert.isFalse(await widget.readonly());\n      });\n\n      it(`should save config options and inform about it the main widget`, async () => {\n        await gu.changeWidgetAccess(access);\n        await widget.waitForFrame();\n        // Save config and check if normal widget received new configuration\n        const config = { key: 1 } as const;\n        // save options through config,\n        await widget.setOptions(config);\n        // make sure custom widget got options,\n        assert.deepEqual(await widget.onOptions(), config);\n        await persistOptions();\n        // and make sure it will get it once again,\n        await refresh();\n        assert.deepEqual(await widget.onOptions(), config);\n        // and can read it on demand\n        assert.deepEqual(await widget.getOptions(), config);\n      });\n\n      it(`should save and read options`, async () => {\n        await gu.changeWidgetAccess(access);\n        await widget.waitForFrame();\n        // Make sure get options returns null.\n        assert.equal(await widget.getOptions(), null);\n        // Invoke setOptions, should return undefined (no error).\n        assert.equal(await widget.setOptions({ key: \"any\" }), null);\n        // Once again get options, and see if it was saved.\n        assert.deepEqual(await widget.getOptions(), { key: \"any\" });\n        await widget.clearOptions();\n      });\n\n      it(`should save and read options by keys`, async () => {\n        await gu.changeWidgetAccess(access);\n        await widget.waitForFrame();\n        // Should support key operations\n        const set = async (key: string, value: any) => {\n          assert.equal(await widget.setOption(key, value), undefined);\n          assert.deepEqual(await widget.getOption(key), value);\n        };\n        await set(\"one\", 1);\n        await set(\"two\", 2);\n        assert.deepEqual(await widget.getOptions(), { one: 1, two: 2 });\n        const json = { n: null, json: { value: [1, { val: \"a\", bool: true }] } };\n        await set(\"json\", json);\n        assert.equal(await widget.clearOptions(), undefined);\n        assert.equal(await widget.getOptions(), null);\n        await set(\"one\", 1);\n        assert.equal(await widget.setOptions({ key: \"any\" }), undefined);\n        assert.deepEqual(await widget.getOptions(), { key: \"any\" });\n        await widget.clearOptions();\n      });\n\n      it(`should call configure method`, async () => {\n        await gu.changeWidgetAccess(access);\n        await widget.waitForFrame();\n        // Make sure configure wasn't called yet.\n        assert.isFalse(await widget.wasConfigureCalled());\n        // Open configuration through the creator panel\n        await driver.find(\".test-config-widget-open-configuration\").click();\n        assert.isTrue(await widget.wasConfigureCalled());\n\n        // Refresh, and call through the menu.\n        await refresh();\n        await gu.waitForDocToLoad();\n        await widget.waitForFrame();\n        // Make sure configure wasn't called yet.\n        assert.isFalse(await widget.wasConfigureCalled());\n        // Click through the menu.\n        const menu = await gu.openSectionMenu(\"viewLayout\", \"Widget\");\n        await menu.find(\".test-section-open-configuration\").click();\n        assert.isTrue(await widget.wasConfigureCalled());\n      });\n    });\n  }\n\n  it(\"should show options action button\", async () => {\n    // Select widget without options\n    await gu.setCustomWidget(NORMAL_WIDGET);\n    assert.isFalse(await hasSectionOption());\n    // Select widget with options\n    await gu.setCustomWidget(TESTER_WIDGET);\n    assert.isTrue(await hasSectionOption());\n    // Select widget without options\n    await gu.setCustomWidget(NORMAL_WIDGET);\n    assert.isFalse(await hasSectionOption());\n  });\n\n  it(\"should prompt user for correct access level\", async () => {\n    // Select widget that requests read access.\n    await gu.setCustomWidget(READ_WIDGET);\n    await widget.waitForFrame();\n    assert.isTrue(await gu.hasAccessPrompt());\n    assert.equal(await gu.widgetAccess(), AccessLevel.none);\n    assert.equal(await widget.access(), AccessLevel.none);\n    await gu.acceptAccessRequest();\n    await widget.waitForPendingRequests();\n    assert.equal(await gu.widgetAccess(), AccessLevel.read_table);\n    assert.equal(await widget.access(), AccessLevel.read_table);\n    // Select widget that requests full access.\n    await gu.setCustomWidget(FULL_WIDGET);\n    await widget.waitForFrame();\n    assert.isTrue(await gu.hasAccessPrompt());\n    assert.equal(await gu.widgetAccess(), AccessLevel.none);\n    assert.equal(await widget.access(), AccessLevel.none);\n    await gu.acceptAccessRequest();\n    await widget.waitForPendingRequests();\n    assert.equal(await gu.widgetAccess(), AccessLevel.full);\n    assert.equal(await widget.access(), AccessLevel.full);\n    await gu.undo(4);\n  });\n\n  it(\"should pass readonly mode to custom widget\", async () => {\n    const api = mainSession.createHomeApi();\n    await api.updateDocPermissions(docId, { users: { \"support@getgrist.com\": \"viewers\" } });\n\n    const viewer = await gu.session().user(\"support\").login();\n    await viewer.loadDoc(`/doc/${docId}`);\n\n    // Make sure that widget knows about readonly mode.\n    assert.isTrue(await widget.readonly());\n\n    // Log back\n    await mainSession.login();\n    await mainSession.loadDoc(`/doc/${docId}`);\n    await refresh();\n  });\n\n  it(\"should display the empty widget page if the URL is invalid\", async function() {\n    await gu.setCustomWidget(INVALID_URL_WIDGET);\n    await widget.waitForFrame();\n    assert.match(await widget.url(), /custom-widget\\.html$/);\n  });\n});\n\n// Poor man widget rpc. Class that invokes various parts in the tester widget.\nconst widget = {\n  async waitForPlaceholder() {\n    assert.isTrue(await driver.findWait(\".test-custom-widget-not-mapped\", 1000).isDisplayed());\n  },\n  // Wait for a frame.\n  async waitForFrame() {\n    await driver.findWait(`iframe.test-custom-widget-ready`, 1000);\n    await driver.wait(async () => await driver.find(\"iframe\").isDisplayed(), 1000);\n    await widget.waitForPendingRequests();\n  },\n  async waitForPendingRequests() {\n    await this._inWidgetIframe(async () => {\n      await driver.executeScript(\"grist.testWaitForPendingRequests();\");\n    });\n  },\n  async url() {\n    return await driver.find(\"iframe\").getAttribute(\"src\");\n  },\n  async content() {\n    return await this._read(\"body\");\n  },\n  async readonly() {\n    const text = await this._read(\"#readonly\");\n    return text === \"true\";\n  },\n  async access() {\n    const text = await this._read(\"#access\");\n    return text as AccessLevel;\n  },\n  async onRecordMappings() {\n    const text = await this._read(\"#onRecordMappings\");\n    return JSON.parse(text || \"null\");\n  },\n  async onRecords() {\n    const text = await this._read(\"#onRecords\");\n    return JSON.parse(text || \"null\");\n  },\n  async onRecord() {\n    const text = await this._read(\"#onRecord\");\n    return JSON.parse(text || \"null\");\n  },\n  /**\n   * Reads last mapping parameter received by the widget as part of onRecords call.\n   */\n  async onRecordsMappings() {\n    const text = await this._read(\"#onRecordsMappings\");\n    return JSON.parse(text || \"null\");\n  },\n  async log() {\n    const text = await this._read(\"#log\");\n    return text || \"\";\n  },\n  // Wait for frame to close.\n  async waitForClose() {\n    await driver.wait(async () => !(await driver.find(\"iframe\").isPresent()), 3000);\n  },\n  // Wait for the onOptions event, and return its value.\n  async onOptions() {\n    const text = await this._inWidgetIframe(async () => {\n      // Wait for options to get filled, initially this div is empty,\n      // as first message it should get at least null as an options.\n      await driver.wait(async () => await driver.find(\"#onOptions\").getText(), 3000);\n      return await driver.find(\"#onOptions\").getText();\n    });\n    return JSON.parse(text);\n  },\n  async wasConfigureCalled() {\n    const text = await this._read(\"#configure\");\n    return text === \"called\";\n  },\n  async setOptions(options: any) {\n    return await this.invokeOnWidget(\"setOptions\", [options]);\n  },\n  async setOption(key: string, value: any) {\n    return await this.invokeOnWidget(\"setOption\", [key, value]);\n  },\n  async getOption(key: string) {\n    return await this.invokeOnWidget(\"getOption\", [key]);\n  },\n  async clearOptions() {\n    return await this.invokeOnWidget(\"clearOptions\");\n  },\n  async getOptions() {\n    return await this.invokeOnWidget(\"getOptions\");\n  },\n  async mappings() {\n    return await this.invokeOnWidget(\"mappings\");\n  },\n  async clearLog() {\n    return await this.invokeOnWidget(\"clearLog\");\n  },\n  // Invoke method on a Custom Widget.\n  // Each method is available as a button with content that is equal to the method name.\n  // It accepts single argument, that we pass by serializing it to #input textbox. Widget invokes\n  // the method and serializes its return value to #output div. When there is an error, it is also\n  // serialized to the #output div.\n  async invokeOnWidget(name: string, input?: any[]) {\n    // Switch to frame.\n    const iframe = driver.find(\"iframe\");\n    await driver.switchTo().frame(iframe);\n    // Clear input box that holds arguments.\n    await driver.find(\"#input\").click();\n    await gu.clearInput();\n    // Serialize argument to the textbox (or leave empty).\n    if (input !== undefined) {\n      await driver.sendKeys(JSON.stringify(input));\n    }\n    // Find button that is responsible for invoking method.\n    await driver.findContent(\"button\", gu.exactMatch(name)).click();\n    // Wait for the #output div to be filled with a result. Custom Widget will set it to\n    // \"waiting...\" before invoking the method.\n    await driver.wait(async () => (await driver.find(\"#output\").getText()) !== \"waiting...\");\n    // Read the result.\n    const text = await driver.find(\"#output\").getText();\n    // Switch back to main window.\n    await driver.switchTo().defaultContent();\n    // If the method was a void method, the output will be \"undefined\".\n    if (text === \"undefined\") {\n      return; // Simulate void method.\n    }\n    // Result will always be parsed json.\n    const parsed = JSON.parse(text);\n    // All exceptions will be serialized to { error : <<Error.message>> }\n    if (parsed?.error) {\n      // Rethrow the error.\n      throw new Error(parsed.error);\n    } else {\n      // Or return result.\n      return parsed;\n    }\n  },\n  async _read(selector: string) {\n    return this._inWidgetIframe(() => driver.find(selector).getText());\n  },\n  async _inWidgetIframe<T>(callback: () => Promise<T>) {\n    const iframe = driver.find(\"iframe\");\n    await driver.switchTo().frame(iframe);\n    const retVal = await callback();\n    await driver.switchTo().defaultContent();\n    return retVal;\n  },\n  /**\n   * Resets the widget by first selecting Custom URL option from the menu, which clearOptions\n   * any existing widget state (even if the Custom URL was already selected).\n   */\n  async resetWidget() {\n    await gu.setCustomWidget(CUSTOM_URL);\n  },\n};\n"
  },
  {
    "path": "test/nbrowser/DateEditor.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport escapeRegExp from \"lodash/escapeRegExp\";\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\nasync function setCustomDateFormat(format: string) {\n  await gu.setDateFormat(\"Custom\");\n  await driver.find(\"[data-test-id=Widget_dateCustomFormat]\").click();\n  await gu.selectAll();\n  await driver.sendKeys(format, Key.ENTER);\n}\n\nasync function testDateFormat(initialDateStr: string, newDay: string, finalDateStr: string) {\n  const cell = await gu.getCell({ col: \"A\", rowNum: 1 });\n  await cell.click();\n  assert.equal(await cell.getText(), initialDateStr);\n\n  // Open the date for editing, and check that we see it in the new format.\n  await driver.sendKeys(Key.ENTER);\n  await gu.checkTextEditor(new RegExp(escapeRegExp(initialDateStr)));\n\n  // Pick a new date in the editor; check that it's shown in the new format.\n  await driver.findContent(\"td.day\", newDay).click();\n  await gu.checkTextEditor(new RegExp(escapeRegExp(finalDateStr)));\n  await driver.sendKeys(Key.ENTER);\n  await gu.waitForServer();\n  assert.equal(await gu.getCell({ col: \"A\", rowNum: 1 }).getText(), finalDateStr);\n\n  // Reopen the editor, check that our previously-selected date is still selected.\n  await gu.getCell({ col: \"A\", rowNum: 1 }).click();\n  await driver.sendKeys(Key.ENTER);\n  await gu.checkTextEditor(new RegExp(escapeRegExp(finalDateStr)));\n  assert.isTrue(await driver.findContent(\"td.day\", newDay).matches(\".active\"));\n  await driver.sendKeys(Key.ESCAPE);\n  await gu.waitAppFocus();\n  await gu.undo();\n}\n\ndescribe(\"DateEditor\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n\n  afterEach(() => gu.checkForErrors());\n\n  before(async function() {\n    session = await gu.session().login();\n    await session.tempNewDoc(cleanup, \"DateEditor\");\n    await gu.waitForServer();\n    await driver.executeAsyncScript(async (done: () => unknown) => {\n      await (window as any).loadScript(\"sinon.js\");\n      window.sinon.useFakeTimers({\n        now: 1580568300000,  // Sat Feb 01 2020 14:45:00 UTC\n        shouldAdvanceTime: true,\n      });\n      done();\n    });\n  });\n\n  it(\"should allow editing dates in standard format\", async function() {\n    await gu.getCell({ col: \"A\", rowNum: 1 }).click();\n    await gu.setType(/Date/);\n    assert.equal(await gu.getDateFormat(), \"YYYY-MM-DD\");\n\n    // Use shortcut to populate today's date, mainly to ensure that our date-mocking is working.\n    await gu.getCell({ col: \"A\", rowNum: 1 }).click();\n    await gu.sendKeys(Key.chord(await gu.modKey(), \";\"));\n    await gu.waitForServer();\n    assert.equal(await gu.getCell({ col: \"A\", rowNum: 1 }).getText(), \"2020-02-01\");\n\n    // Change the format and check that date gets updated.\n    await gu.setDateFormat(\"MMMM Do, YYYY\");\n    await testDateFormat(\"February 1st, 2020\", \"18\", \"February 18th, 2020\");\n  });\n\n  it(\"should allow editing dates in rarer formats\", async function() {\n    await setCustomDateFormat(\"MMM Do, 'YY\");\n    await testDateFormat(\"Feb 1st, '20\", \"18\", \"Feb 18th, '20\");\n\n    await setCustomDateFormat(\"YYYY-MM-DD dd\");\n    await testDateFormat(\"2020-02-01 Sa\", \"18\", \"2020-02-18 Tu\");\n  });\n\n  it(\"should allow editing invalid alt-text\", async function() {\n    let cell = await gu.getCell({ col: \"A\", rowNum: 2 });\n    await cell.click();\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitAppFocus(false);\n\n    // Enter an invalid date.\n    await driver.sendKeys(\"2020-03-14pi\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Check that it's saved, and shows up as invalid.\n    cell = await gu.getCell({ col: \"A\", rowNum: 2 });\n    assert.equal(await cell.getText(), \"2020-03-14pi\");\n    assert.isTrue(await cell.find(\".field_clip\").matches(\".invalid\"));\n\n    // Open for editing, and check that the invalid value is present in the editor.\n    await cell.click();\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitAppFocus(false);\n    await gu.checkTextEditor(/2020-03-14pi/);\n\n    // Edit it down to something valid, save, and check.\n    await driver.sendKeys(Key.BACK_SPACE, Key.BACK_SPACE, Key.ENTER);\n    await gu.waitForServer();\n    cell = await gu.getCell({ col: \"A\", rowNum: 2 });\n    assert.equal(await cell.getText(), \"2020-03-14 Sa\");\n    assert.isFalse(await cell.find(\".field_clip\").matches(\".invalid\"));\n  });\n\n  async function openCellEditor(cell: WebElement) {\n    await cell.click();\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitAppFocus(false);\n  }\n\n  it(\"should respect locale for datepicker\", async function() {\n    let cell = await gu.getCell({ col: \"A\", rowNum: 1 });\n    await cell.click();\n    await gu.setDateFormat(\"YYYY-MM-DD\");\n\n    assert.equal(await cell.getText(), \"2020-02-01\");\n    await openCellEditor(cell);\n\n    // Check that the date input contains the correct date.\n    assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"2020-02-01\");\n\n    // Wait for datepicker, and check that it's showing the expected (default English) locale.\n    await driver.findWait(\".datepicker\", 200);\n    assert.equal(await driver.find(\".datepicker .datepicker-days .datepicker-switch\").getText(), \"February 2020\");\n    assert.deepEqual(await driver.findAll(\".datepicker .datepicker-days .dow\", el => el.getText()),\n      [\"Su\", \"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\"]);\n\n    // Check that it works to click into it to change date.\n    await driver.findContent(\".datepicker .day\", \"19\").click();\n    assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"2020-02-19\");\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.getText(), \"2020-02-19\");\n    assert.equal(await driver.find(\".datepicker\").isPresent(), false);\n\n    // Change locale to something quite different.\n    const api = session.createHomeApi();\n    await api.updateUserLocale(\"fr-CA\");\n    cleanup.addAfterEach(() => api.updateUserLocale(null));   // Restore after this test case.\n    await gu.reloadDoc();\n\n    // Check that the datepicker now opens to show the new language.\n    cell = await gu.getCell({ col: \"A\", rowNum: 1 });\n    await openCellEditor(cell);\n    await driver.findWait(\".datepicker\", 200);\n    assert.equal(await driver.find(\".datepicker .datepicker-days .datepicker-switch\").getText(),\n      \"février 2020\");\n    assert.deepEqual(await driver.findAll(\".datepicker .datepicker-days .dow\", el => el.getText()),\n      [\"l\", \"ma\", \"me\", \"j\", \"v\", \"s\", \"d\"]);\n\n    // Check that it can still be used to pick a new date.\n    await driver.findContent(\".datepicker .day\", \"26\").click();\n    assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"2020-02-26\");\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.getText(), \"2020-02-26\");\n    assert.equal(await driver.find(\".datepicker\").isPresent(), false);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Dates.ntest.js",
    "content": "import { assert, driver } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"Dates.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n  let doc;\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    doc = await gu.useFixtureDoc(cleanup, \"Hello.grist\", true);\n    await gu.toggleSidePanel(\"left\", \"close\");\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should allow correct datetime reformatting\", async function() {\n    await gu.openSidePane(\"field\");\n\n    var cell = await gu.getCellRC(0, 0);\n\n    // Move to the first column\n    await cell.click();\n    await gu.sendKeys(\"2008-01-10 9:20pm\", $.ENTER);\n\n    // Change type to 'DateTime'\n    await gu.setType(\"DateTime\");\n    await $(\".test-tz-autocomplete\").wait().click();\n    await gu.sendKeys($.DELETE, \"UTC\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"2008-01-10 9:20pm\");\n    await $(\".test-type-transform-apply\").wait().click();\n    await gu.waitForServer();\n\n    // Change timezone to 'America/Los_Angeles' and check that the date is correct\n    await $(\".test-tz-autocomplete\").wait().click();\n    await gu.sendKeys(\"Los An\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await $(\".test-tz-autocomplete input\").val(), \"America/Los_Angeles\");\n    assert.equal(await cell.text(), \"2008-01-10 1:20pm\");\n\n    // Change format and check that date is reformatted\n    await gu.dateFormat(\"MMMM Do, YYYY\");\n    await gu.timeFormat(\"HH:mm:ss z\");\n    assert.equal(await gu.getCellRC(0, 0).text(), \"January 10th, 2008 13:20:00 PST\");\n\n    // Change to custom format and check that the date is reformatted\n    await gu.dateFormat(\"Custom\");\n\n    await $(\"$Widget_dateCustomFormat .kf_text\").click();\n    await gu.sendKeys($.SELECT_ALL, \"dddd\", $.ENTER);\n    await gu.timeFormat(\"Custom\");\n    await $(\"$Widget_timeCustomFormat .kf_text\").click();\n    await gu.sendKeys($.SELECT_ALL, \"Hmm\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"Thursday 1320\");\n  });\n\n  it(\"should include a functioning datetime editor\", async function() {\n    var cell = await gu.getCellRC(0, 0);\n\n    // DateTime editor should open, separate date and time, and replace incomplete format\n    // with YYYY-MM-DD\n    await cell.click();\n    await gu.sendKeys($.ENTER);\n    assert.equal(await $(\".celleditor_text_editor\").first().val(), \"2008-01-10\");\n\n    // Date should be changable by clicking the calendar dates\n    await $(\".celleditor_text_editor\").first().sendKeys($.DOWN);   // Opens date picker even if window has no focus.\n    await $(\".datepicker .day:contains(19)\").wait().click();\n    await gu.sendKeys($.ENTER);\n    assert.equal(await cell.text(), \"Saturday 1320\");\n\n    // Date editor should convert Moment formats to datepicker safe formats\n    // Date editor should allow tabbing between date and time entry boxes\n    await gu.dateFormat(\"MMMM Do, YYYY\");\n    await gu.timeFormat(\"h:mma\");\n    await cell.click();\n    await gu.sendKeys($.ENTER);\n    assert.deepEqual(await $(\".celleditor_text_editor\").array().val(),\n      [\"January 19th, 2008\", \"1:20pm\"]);\n    await gu.sendKeys($.SELECT_ALL, \"February 20th, 2009\", $.TAB, \"8:15am\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"February 20th, 2009 8:15am\");\n\n    // DateTime editor should close and save value when the user clicks away\n    await cell.click();\n    await gu.sendKeys($.ENTER, $.SELECT_ALL, $.DELETE);\n    await gu.getCellRC(0, 3).click(); // click away\n    await gu.waitForServer();\n    // Since only the date value was removed, the cell should give AltText of the time value\n    assert.equal(await cell.text(), \"8:15am\");\n    assert.hasClass(await cell.find(\".field_clip\"), \"invalid\");\n\n    // DateTime editor should close and revert value when the user presses escape\n    await cell.click();\n    await gu.sendKeys($.ENTER, \"April 2, 1993\", $.ESCAPE);\n    assert.equal(await cell.text(), \"8:15am\");\n  });\n\n  it(\"should allow correct date reformatting\", async function() {\n    var cell = await gu.getCellRC(0, 1);\n\n    // Move to the first column\n    await cell.click();\n    await gu.sendKeys(\"2016-01-08\", $.ENTER);\n\n    // Change type to 'Date'\n    await gu.setType(\"Date\");\n    await $(\".test-type-transform-apply\").wait().click();\n    await gu.waitForServer(); // Make sure type is set\n\n    // Check that the date is correct\n    await $(\"$Widget_dateFormat\").wait();\n    assert.equal(await cell.text(), \"2016-01-08\");\n\n    // Change format and check that date is reformatted\n    await gu.dateFormat(\"MMMM Do, YYYY\");\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"January 8th, 2016\");\n\n    // Try another format\n    await gu.dateFormat(\"DD MMM YYYY\");\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"08 Jan 2016\");\n\n    // Change to custom format and check that the date is reformatted\n    await gu.dateFormat(\"Custom\");\n    await $(\"$Widget_dateCustomFormat .kf_text\").click();\n    await gu.sendKeys($.SELECT_ALL, \"dddd\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"Friday\");\n  });\n\n  it(\"should include a functioning date editor\", async function() {\n    var cell = await gu.getCellRC(0, 1);\n\n    // Date editor should open and replace incomplete format with YYYY-MM-DD\n    await cell.click();\n    await gu.sendKeys($.ENTER);\n    assert.equal(await $(\".celleditor_text_editor\").val(), \"2016-01-08\");\n\n    // Date should be changable by clicking the calendar dates\n    await $(\".celleditor_text_editor\").sendKeys($.DOWN);   // Opens date picker even if window has no focus.\n    await $(\".datepicker .day:contains(19)\").wait().click();\n    await gu.sendKeys($.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"Tuesday\");\n\n    // Date editor should convert Moment formats to datepicker safe formats\n    // Date editor should save the date on enter press\n    await gu.dateFormat(\"MMMM Do, YYYY\");\n    await cell.click();\n    await gu.sendKeys($.ENTER);\n    assert.equal(await $(\".celleditor_text_editor\").val(), \"January 19th, 2016\");\n    await gu.sendKeys($.SELECT_ALL, \"February 20th, 2016\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"February 20th, 2016\");\n\n    // Date editor should close and save value when the user clicks away\n    await cell.click();\n    await gu.sendKeys($.ENTER, $.SELECT_ALL, $.DELETE);\n    await gu.getCellRC(0, 3).click(); // click away\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"\");\n\n    // Date editor should close and revert value when the user presses escape\n    await cell.click();\n    await gu.sendKeys($.ENTER, \"April 2, 1993\", $.ESCAPE);\n    assert.equal(await cell.text(), \"\");\n  });\n\n  it(\"should reload values correctly after reopen\", async function() {\n    await gu.getCellRC(0, 0).click();\n    await gu.sendKeys(\"February 20th, 2009\", $.TAB, \"8:15am\", $.ENTER);\n    await gu.getCellRC(0, 1).click();\n    await gu.sendKeys(\"January 19th, 1968\", $.ENTER);\n    await gu.getCellRC(1, 1).click();\n    await gu.sendKeys($.DELETE);\n    await gu.waitForServer();\n    await gu.getCellRC(0, 2).click();\n    await gu.waitAppFocus();\n    await gu.sendKeys(\"=\");\n    await $(\".test-editor-tooltip-convert\").click();\n    await gu.sendKeys(\"$A\", $.ENTER);\n    await gu.waitForServer();\n    await gu.waitAppFocus();\n    await gu.getCellRC(0, 3).click();\n    await gu.sendKeys(\"=\");\n    await gu.waitAppFocus(false);\n    await gu.sendKeys(\"$B\", $.ENTER);\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [\"A\", \"B\", \"C\", \"D\"]}), [\n      \"February 20th, 2009 8:15am\",\n      \"January 19th, 1968\",\n      \"2009-02-20 08:15:00-08:00\",\n      \"1968-01-19\",\n      \"\", \"\", \"\", \"\"\n    ]);\n\n    // We don't have a quick way to shutdown a document and reopen from scratch. So instead, we'll\n    // make a copy of the document, and open that to test that values got saved correctly.\n    // TODO: it would be good to add a way to reload document from scratch, perhaps by reloading\n    // with a special URL fragment.\n    await gu.copyDoc(doc.id, true);\n\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [\"A\", \"B\", \"C\", \"D\"]}), [\n      \"February 20th, 2009 8:15am\",\n      \"January 19th, 1968\",\n      \"2009-02-20 08:15:00-08:00\",\n      \"1968-01-19\",\n      \"\", \"\", \"\", \"\"\n    ]);\n  });\n\n  it(\"should support shortcuts to insert date/time\", async function() {\n    await gu.openSidePane(\"field\");\n    // Check the types of the first two columns.\n    await gu.clickCellRC(0, 0);\n    await gu.assertType(\"DateTime\");\n    await gu.clickCellRC(0, 1);\n    await gu.assertType(\"Date\");\n    // Insert a few more columns: empty, Text, Numeric.\n    await addColumn();\n    await addColumn();\n    await addColumn();\n    await gu.clickCellRC(0, 3);\n    await gu.setType(\"Numeric\");\n    await gu.clickCellRC(0, 4);\n    await gu.setType(\"Text\");\n\n    // Override Date.now() and timezone in the current browser page to return a consistent value,\n    // used e.g. for the default for the year and month.\n    await driver.executeScript(\n      \"Date.now = () => 1477548296087; \" +      // This value is 2016-10-27 02:04:56.087 EST\n      \"exposeModulesForTests().then(() => { \" +\n        \"window.exposedModules.moment.tz.setDefault('America/New_York');\" +\n      \"});\"\n    );\n\n    async function fillWithShortcuts() {\n      await gu.toggleSidePanel(\"right\", \"close\");\n\n      // Type the Date-only shortcut into each cell in the second row.\n      await gu.clickCellRC(1, 0);\n      for (var i = 0; i < 6; i++) {\n        await gu.sendKeys([$.MOD, \";\"], $.TAB);\n      }\n\n      // Type the Date-Time shortcut into each cell in the third row.\n      await gu.clickCellRC(2, 0);\n      for (i = 0; i < 6; i++) {\n        await gu.sendKeys([$.MOD, $.SHIFT, \";\"], $.TAB);\n      }\n    }\n\n    // Change document timezone to US/Hawaii (3 hours behind LA, which is TZ of the first column).\n    // We check that shortcuts for Text/Any columns use the document timezone.\n    await setGlobalTimezone(\"US/Hawaii\");\n    await fillWithShortcuts();\n    // Compare the values. NOTE: this assumes EST timezone for the browser's local time.\n    assert.deepEqual(await gu.getGridValues({rowNums: [2, 3], cols: [0, 1, 2, 3, 4]}), [\n      // Note that column A has Los_Angeles timezone set, so time differs from Hawaii.\n      // Note that Date column gets the date in Hawaii, not local or UTC (both 2016-10-27).\n      // The originally empty column had its type guessed as Date when the current date was first entered,\n      // hence \"2016-10-26\" appears in both rows.\n      \"October 26th, 2016 11:04pm\", \"October 26th, 2016\", \"2016-10-26\", \"0\", \"2016-10-26\",\n      \"October 26th, 2016 11:04pm\", \"October 26th, 2016\", \"2016-10-26\", \"0\", \"2016-10-26 20:04:56\",\n    ]);\n\n    // Undo the 8 cells we actually filled in, and check that the empty column reverted to Any\n    await gu.undo(8);\n    await gu.clickCellRC(1, 2);\n    await gu.assertType(\"Any\");\n\n    // Change document timezone back to America/New_York.\n    await setGlobalTimezone(\"America/New_York\");\n    await fillWithShortcuts();\n    // Compare the values. NOTE: this assumes EST timezone for the browser's local time.\n    assert.deepEqual(await gu.getGridValues({rowNums: [2, 3], cols: [0, 1, 2, 3, 4]}), [\n      // Note that column A has Los_Angeles timezone set, so date differs by one from New_York.\n      \"October 26th, 2016 11:04pm\", \"October 27th, 2016\", \"2016-10-27\", \"0\", \"2016-10-27\",\n      \"October 26th, 2016 11:04pm\", \"October 27th, 2016\", \"2016-10-27\", \"0\", \"2016-10-27 02:04:56\",\n    ]);\n  });\n\n  it(\"should allow navigating the datepicker with the keyboard\", async function() {\n    // Change the date using the datepicker.\n    let cell = await gu.getCellRC(0, 1);\n    await cell.scrollIntoView({inline: \"end\"}).click();\n    await gu.sendKeys($.ENTER);\n    await gu.waitAppFocus(false);\n    await gu.sendKeys($.UP, $.UP, $.LEFT, $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"January 11th, 1968\");\n\n    // Do the same in the datetime editor.\n    cell = await gu.getCellRC(1, 0);\n    await cell.click();\n    await gu.sendKeys($.ENTER);\n    await gu.waitAppFocus(false);\n    await gu.sendKeys($.UP, $.RIGHT, $.RIGHT, $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"October 28th, 2016 11:04pm\");\n\n    // Start navigating the datepicker, then start typing to return to using the cell editor.\n    cell = await gu.getCellRC(1, 1);\n    await cell.click();\n    // The first backspace should return to cell edit mode, then the following keys should\n    // change the year to 2009.\n    await gu.sendKeys($.ENTER);\n    await gu.waitAppFocus(false);\n    await gu.sendKeys($.DOWN, $.RIGHT, $.BACK_SPACE, \"9\", $.LEFT, $.BACK_SPACE, \"0\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"October 27th, 2009\");\n  });\n\n  // NOTE: This addresses a bug where typical date entry formats were not recognized.\n  // See https://phab.getgrist.com/T308\n  it(\"should allow using common formats to enter the date\", async function() {\n    let cell = await gu.getCellRC(2, 1);\n    await cell.click();\n    await gu.sendKeys(\"April 2 1993\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"April 2nd, 1993\");\n\n    cell = await gu.getCellRC(1, 0);\n    await cell.click();\n    await gu.sendKeys(\"December\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), `December 1st, 2016 11:04pm`);\n\n    cell = await gu.getCellRC(0, 1);\n    await cell.click();\n    await gu.sendKeys(\"7-Sep\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), `September 7th, 2016`);\n\n    await cell.click();\n    await gu.sendKeys(\"6/8\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), `June 8th, 2016`);\n\n    // The selected format should take precedence over the default format when\n    // parsing the date. Entering the same thing as before (6/8) will yield a different\n    // result after changing the format.\n    await gu.openSidePane(\"field\");\n    cell = await gu.getCellRC(1, 1);\n    await cell.click();\n    await gu.dateFormat(\"DD-MM-YYYY\");\n    await cell.click();\n    await gu.sendKeys(\"6/8\", $.ENTER);\n    await gu.waitForServer();\n    await gu.dateFormat(\"MMMM Do, YYYY\");\n    assert.equal(await cell.text(), `August 6th, 2016`);\n\n    cell = await gu.getCellRC(2, 1);\n    await cell.click();\n    await gu.sendKeys(\"1937\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), `January 1st, 1937`);\n  });\n\n  it(\"should not attempt to parse non-dates\", async function() {\n    // Should allow AltText\n    let cell = await gu.getCellRC(2, 1);\n    await cell.click();\n    await gu.sendKeys(\"Applesauce\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"Applesauce\");\n    await assert.hasClass(cell.find(\".field_clip\"), \"invalid\");\n    // Should allow AltText even of numbers that cannot be parsed as dates.\n    // Manually entered numbers should not be read as timestamps.\n    cell = await gu.getCellRC(1, 0);\n    await cell.click();\n    await gu.sendKeys(\"100000\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"100000 11:04pm\");\n    await assert.hasClass(cell.find(\".field_clip\"), \"invalid\");\n    // Should give AltText if just the time is entered but not the date.\n    cell = await gu.getCellRC(1, 0);\n    await cell.click();\n    await gu.sendKeys($.ENTER, $.TAB, \"3\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"100000 11:04pm 3\");\n    await assert.hasClass(cell.find(\".field_clip\"), \"invalid\");\n  });\n\n  it(\"should allow working with naive date object\", async function() {\n    await gu.clickCellRC(0, 1);\n    await gu.sendKeys([$.ALT, \"=\"]);\n    await gu.waitForServer();\n    await gu.waitAppFocus(false);\n    await gu.sendKeys(\"Diff\", $.ENTER);\n    await gu.waitForServer();\n    await gu.sendKeys(\"=\");\n    await gu.waitAppFocus(false);\n    await gu.sendKeys(\"($A-DTIME($B)).total_seconds()\", $.ENTER);\n    await gu.waitForServer();\n    await gu.waitAppFocus();\n    assert.deepEqual(await gu.getCellRC(0, 2).text(), \"-230211900\");\n\n    // change global timezone should recompute formula\n    await setGlobalTimezone(\"Paris\");\n    assert.deepEqual(await gu.getCellRC(0, 2).text(), \"-230190300\");\n  });\n\n  // NOTE: This tests a specific bug where AltText values in a column that has been coverted\n  // to a date column do not respond to updates until refresh. This bug was exposed via the\n  // error dom in FieldBuilder not being re-evaluated after a column transform.\n  it(\"should allow deleting AltText values in a newly changed Date column\", async function() {\n    // Change the type to text and enter a text value.\n    await gu.clickCellRC(0, 1);\n    await gu.setType(\"Text\");\n    await gu.applyTypeConversion();\n    await gu.clickCellRC(2, 1);\n    await gu.sendKeys(\"banana\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(2, 1).text(), \"banana\");\n\n    // Change back to Date and try to remove the text.\n    await gu.setType(\"Date\");\n    await $(\".test-type-transform-apply\").wait().click();\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(2, 1).text(), \"banana\");\n    await gu.clickCellRC(2, 1);\n    await gu.sendKeys($.BACK_SPACE);\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(2, 1).text(), \"\");\n    await gu.undo();\n  });\n\n  it(\"should report informative error when AltText is used for date\", async function() {\n    // Enter a formula column that uses a date.\n    await gu.clickCellRC(0, 1);\n    await gu.sendKeys([$.ALT, \"=\"]);\n    await gu.waitForServer();\n    await gu.sendKeys(\"Month\", $.ENTER);\n    await gu.waitForServer();\n    await gu.sendKeys(\"=$B.month\", $.ENTER);\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [\"B\", \"Month\"]}), [\n      \"June 8th, 2016\",     \"6\",\n      \"August 6th, 2016\",   \"8\",\n      \"banana\",             \"#Invalid Date: banana\",\n      \"\",                   \"#AttributeError\",\n    ]);\n  });\n\n  it(\"should default timezone to document's timezone\", async function() {\n    // add a DateTime column\n    await addDateTimeColumn();\n    await gu.timeFormat(\"HH:mm:ss\");\n    // BUG: it is required to click somewhere after setting the type of a column for the shortcut to\n    // work\n    // TODO: removes gu.getCellRC(1, 3).click() below when its fixed\n    await gu.getCellRC(1, 3).click();\n    // get the current date\n    await gu.sendKeys([$.MOD, $.SHIFT, \";\"]);\n    await gu.waitForServer();\n    const date1 = await gu.getCellRC(1, 3).text();\n    // check default timezone\n    assert.equal(await $(\".test-tz-autocomplete input\").val(), \"Europe/Paris\");\n    // set global document timezone to 'Europe/Paris'\n    await setGlobalTimezone(\"America/Los_Angeles\");\n    // add another DateTime column\n    await addDateTimeColumn();\n    await gu.timeFormat(\"HH:mm:ss\");\n    // todo: same as for gu.getCellRC(1, 3).click();\n    await gu.getCellRC(1, 4).click();\n    // get the current date\n    await gu.sendKeys([$.MOD, $.SHIFT, \";\"]);\n    await gu.waitForServer();\n    const date2 = await gu.getCellRC(1, 4).text();\n    // check default timezone\n    assert.equal(await $(\".test-tz-autocomplete input\").val(), \"America/Los_Angeles\");\n    // check that the delta between date1 and date2 is coherent with the delta between\n    // 'Europe/Paris' and 'America/Los_Angeles' timezones.\n    const delta = (new Date(date1) - new Date(date2)) / 1000 / 60 / 60;\n    assert.isAbove(delta, 6);\n    assert.isBelow(delta, 12);\n  });\n});\n\nasync function addDateTimeColumn() {\n  await addColumn();\n  return gu.setType(\"DateTime\");\n}\n\nasync function addColumn() {\n  await gu.sendKeys([$.ALT, \"=\"]);\n  await gu.waitForServer();\n  return gu.sendKeys($.ESCAPE);\n}\n\nasync function setGlobalTimezone(name) {\n  await $(\".test-user-icon\").click();   // open the user menu\n  await $(\".test-dm-doc-settings\").click();\n  await $(\".test-tz-autocomplete\").click();\n  await $(`.test-acselect-dropdown li:contains(${name})`).click();\n  await gu.waitForServer();\n  await driver.navigate().back();\n}\n"
  },
  {
    "path": "test/nbrowser/DeleteColumnsUndo.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"DeleteColumnsUndo\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n\n  before(async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const doc = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"DeleteColumnsUndo.grist\", false);\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}/p/2`);\n    await gu.waitForDocToLoad();\n  });\n\n  it(\"should be able to delete multiple columns and undo without errors\", async function() {\n    const revert = await gu.begin();\n    assert.deepEqual(await gu.getColumnNames(), [\"A\", \"B\", \"C\", \"D\"]);\n    await gu.getColumnHeader({ col: \"A\" }).click();\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));\n    const selectedCols = await driver.findAll(\".column_name.selected\");\n    assert.lengthOf(selectedCols, 3);\n    await gu.openColumnMenu(\"A\", \"Delete 3 columns\");\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getColumnNames(), [\"D\"]);\n    await revert();\n    await gu.checkForErrors();\n    assert.deepEqual(await gu.getColumnNames(), [\"A\", \"B\", \"C\", \"D\"]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/DescriptionColumn.ts",
    "content": "import { UserAPIImpl } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"DescriptionColumn\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n\n  before(async () => {\n    session = await gu.session().login();\n    await session.tempNewDoc(cleanup);\n  });\n\n  it(\"should allow editing description in creator panel\", async function() {\n    const revert = await gu.begin();\n    await gu.openColumnPanel(\"A\");\n\n    async function checkState(expect: \"link\" | \"preview\" | \"editor\") {\n      // Note: exactly one of the \"expect\" comparisons below will be true; the others false.\n      assert.equal(await getDescriptionAddLink().isPresent(), expect === \"link\", \"expected link\");\n      assert.equal(await getDescriptionPreview().isPresent(), expect === \"preview\", \"expected preview\");\n      assert.equal(await getDescriptionInput().isPresent(), expect === \"editor\", \"expected editor\");\n    }\n\n    // Initially, only the \"Set description\" link is present.\n    await checkState(\"link\");\n\n    // We can click it to start typing a description.\n    await getDescriptionAddLink().click();\n    await checkState(\"editor\");\n    assert.equal(await getDescriptionInput().hasFocus(), true);\n    await gu.sendKeys(\"hello\");\n\n    // Escape goes back to initial state.\n    await gu.sendKeys(Key.ESCAPE);\n    await checkState(\"link\");\n\n    // Try again, but now save.\n    await getDescriptionAddLink().click();\n    await checkState(\"editor\");\n    assert.equal(await getDescriptionInput().hasFocus(), true);\n    await gu.sendKeys(\"hello world\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Now should have only the preview.\n    await checkState(\"preview\");\n    assert.equal(await getDescriptionPreview().getText(), \"hello world\");\n\n    // We can click it just like we could click the link, to edit.\n    await getDescriptionPreview().click();\n    await checkState(\"editor\");\n    assert.equal(await getDescriptionInput().hasFocus(), true);\n    await gu.sendKeys(\"goodbye\");\n\n    // Escape goes back to previous state.\n    await gu.sendKeys(Key.ESCAPE);\n    await checkState(\"preview\");\n    assert.equal(await getDescriptionPreview().getText(), \"hello world\");\n\n    // Edit from preview again, but now save.\n    await getDescriptionPreview().click();\n    await checkState(\"editor\");\n    assert.equal(await getDescriptionInput().hasFocus(), true);\n    await gu.sendKeys(\"goodbye world\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Should've gotten saved.\n    await checkState(\"preview\");\n    assert.equal(await getDescriptionPreview().getText(), \"goodbye world\");\n\n    // Finally test deleting the description.\n    await getDescriptionPreview().click();\n    await checkState(\"editor\");\n    assert.equal(await getDescriptionInput().hasFocus(), true);\n    assert.equal(await getDescriptionInput().value(), \"goodbye world\");\n    await gu.sendKeys(Key.DELETE, Key.ENTER);\n    await gu.waitForServer();\n\n    // We are back to only showing a link.\n    await checkState(\"link\");\n\n    await revert();\n  });\n\n  it(\"should allow to edit description on summary table\", async () => {\n    const revert = await gu.begin();\n    await gu.toggleSidePanel(\"left\", \"close\");\n    // Add summary table.\n    await gu.addNewSection(\"Table\", \"Table1\", { summarize: [\"A\"] });\n    await gu.sendActions([\n      [\"AddRecord\", \"Table1\", null, { A: 1 }],\n    ]);\n\n    // Set description on A column and count column.\n    await gu.openColumnPanel(\"A\");\n    await getDescriptionAddLink().click();\n    await getDescriptionInput().sendKeys(\"testA\");\n    await gu.openColumnPanel(\"count\");\n    await gu.waitForServer();\n    await gu.checkForErrors();\n    await getDescriptionAddLink().click();\n    await getDescriptionInput().sendKeys(\"testCount\");\n    await gu.openColumnPanel(\"A\");\n    await gu.waitForServer();\n    await gu.checkForErrors();\n    assert.equal(await getDescriptionPreview().getText(), \"testA\");\n\n    await gu.reloadDoc();\n    await gu.openColumnPanel(\"A\");\n    assert.equal(await getDescriptionPreview().getText(), \"testA\");\n    await gu.openColumnPanel(\"count\");\n    assert.equal(await getDescriptionPreview().getText(), \"testCount\");\n    await gu.undo(2);\n\n    // Now add description through the modal.\n    await doubleClickHeader(\"A\", null);\n    assert.isTrue(await popupVisible());\n\n    // Name should be disabled.\n    assert.equal(await getLabel().getAttribute(\"disabled\"), \"true\");\n\n    // We see add description button.\n    await addDescriptionIsVisible();\n\n    // We have tooltip explaining what it is.\n    await getLabel().mouseMove();\n\n    // Wait for hover tooltip to show up.\n    await gu.waitToPass(\n      async () => assert.isTrue(await driver.find(\".test-tooltip\").isDisplayed()),\n      500,\n    );\n    assert.equal(await driver.find(\".test-tooltip\").getText(), \"Column options are limited in summary tables.\");\n\n    // It works.\n    await clickAddDescription();\n\n    // Now we see description field.\n    await descriptionIsVisible();\n\n    // Type something.\n    await gu.sendKeys(\"ColumnA description\");\n\n    // Save it.\n    await pressSave();\n\n    // Make sure it is saved.\n    await clickTooltip(\"A\");\n    await gu.waitToPass(async () =>\n      assert.equal(await driver.find(\".test-column-info-tooltip-popup\").getText(), \"ColumnA description\"));\n    await revert();\n  });\n\n  it(\"should switch between close and save\", async () => {\n    // Add new column.\n    await addColumn();\n\n    // We should have popup at column D.\n    await popupIsAt(\"D\");\n\n    // Close button should be visible.\n    assert.isTrue(await closeVisible());\n    assert.isFalse(await saveVisible());\n    assert.isFalse(await cancelVisible());\n\n    // Change something in the name.\n    await gu.sendKeys(\"DD\");\n    // Save button should be visible.\n    assert.isFalse(await closeVisible());\n    assert.isTrue(await saveVisible());\n    assert.isFalse(await saveDisabled());\n    assert.isTrue(await cancelVisible());\n\n    // Restore name.\n    await gu.sendKeys(Key.BACK_SPACE);\n    // Close button should be visible.\n    assert.isTrue(await closeVisible());\n    assert.isFalse(await saveVisible());\n    assert.isFalse(await cancelVisible());\n\n    // Add description.\n    await clickAddDescription();\n    await waitForFocus(\"description\");\n\n    // Still close button should be visible.\n    assert.isTrue(await closeVisible());\n    assert.isFalse(await saveVisible());\n    assert.isFalse(await cancelVisible());\n\n    // Type something.\n    await gu.sendKeys(\"D\");\n    // Save button should be visible.\n    assert.isFalse(await closeVisible());\n    assert.isTrue(await saveVisible());\n    assert.isFalse(await saveDisabled());\n    assert.isTrue(await cancelVisible());\n\n    // Clear and move to label.\n    await gu.sendKeys(Key.BACK_SPACE);\n    await gu.sendKeys(Key.ARROW_UP);\n    await waitForFocus(\"label\");\n    assert.isTrue(await closeVisible());\n    assert.isFalse(await saveVisible());\n    assert.isFalse(await cancelVisible());\n\n    // Clear label completely, we have change, but we can't save.\n    await gu.sendKeys(Key.BACK_SPACE);\n    assert.isEmpty(await getLabelText());\n    assert.isFalse(await closeVisible());\n    assert.isTrue(await saveVisible());\n    // But save button is disabled.\n    assert.isTrue(await saveDisabled());\n    assert.isTrue(await cancelVisible());\n\n    // Add description.\n    await gu.sendKeys(Key.ARROW_DOWN);\n    await waitForFocus(\"description\");\n    await gu.sendKeys(\"D\");\n\n    // Still can't save.\n    assert.isFalse(await closeVisible());\n    assert.isTrue(await saveVisible());\n    assert.isTrue(await saveDisabled());\n    assert.isTrue(await cancelVisible());\n\n    // Clear description completely, restore label and press close.\n    await gu.sendKeys(Key.BACK_SPACE);\n    await gu.sendKeys(Key.ARROW_UP);\n    await waitForFocus(\"label\");\n    await gu.sendKeys(\"D\");\n    await pressClose();\n\n    // Make sure popup is gone.\n    assert.isFalse(await popupVisible());\n    // Make sure column D exists.\n    assert.isTrue(await gu.getColumnHeader({ col: \"D\" }).isDisplayed());\n    await gu.undo();\n    assert.isFalse(await gu.getColumnHeader({ col: \"D\" }).isPresent());\n  });\n\n  it(\"shows links in the column description\", async () => {\n    const revert = await gu.begin();\n\n    // Add a column and add a description with a link.\n    await addColumn();\n    await clickAddDescription();\n    await gu.sendKeys(\"First line\");\n    await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL);\n    await gu.sendKeys(\"Second line https://example.com\");\n    await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL);\n    await gu.sendKeys(\"Third line\");\n    await pressSave();\n\n    const header = await gu.getColumnHeader({ col: \"D\" });\n    // Make sure it has a tooltip.\n    assert.isTrue(await header.find(\".test-column-info-tooltip\").isDisplayed());\n    // Click the tooltip.\n    await header.find(\".test-column-info-tooltip\").click();\n\n    // Make sure we have a link there.\n    const testTooltip = async () => {\n      const tooltip = driver.find(\".test-tooltip\");\n      assert.equal(await tooltip.find(\".test-text-link a\").getAttribute(\"href\"), \"https://example.com/\");\n      assert.equal(await tooltip.find(\".test-text-link\").getText(), \"https://example.com\");\n      assert.equal(await tooltip.getText(), \"First line\\nSecond line \\nhttps://example.com\\nThird line\");\n    };\n    await testTooltip();\n\n    // Convert it to a card view.\n    await gu.changeWidget(\"Card\");\n    await openCardColumnTooltip(\"D\");\n    await testTooltip();\n\n    await revert();\n  });\n\n  it(\"should close popup by enter and escape\", async () => {\n    // Add another column, make sure that enter and escape work.\n    await addColumn();\n    await popupIsAt(\"D\");\n    await gu.sendKeys(Key.ESCAPE);\n    assert.isFalse(await popupVisible());\n    // Column D is still there.\n    assert.isTrue(await gu.getColumnHeader({ col: \"D\" }).isDisplayed());\n    await gu.undo();\n    assert.isFalse(await gu.getColumnHeader({ col: \"D\" }).isPresent());\n\n    await addColumn();\n    await popupIsAt(\"D\");\n    await gu.sendKeys(Key.ENTER);\n    assert.isFalse(await popupVisible());\n    assert.isTrue(await gu.getColumnHeader({ col: \"D\" }).isDisplayed());\n    await gu.undo();\n  });\n\n  it(\"should show info tooltip in a Grid View\", async () => {\n    await session.tempDoc(cleanup, \"Hello.grist\");\n    await gu.dismissWelcomeTourIfNeeded();\n\n    // Start renaming col A.\n    await doubleClickHeader(\"A\");\n    await gu.sendKeys(\"ColumnA\");\n    // Check that description is not visible.\n    await descriptionIsVisible(false);\n    await addDescriptionIsVisible(true);\n    // Press add description.\n    await clickAddDescription();\n    // Check that description is visible.\n    await descriptionIsVisible(true);\n    await addDescriptionIsVisible(false);\n    // Wait for focus in the description input\n    await waitForFocus(\"description\");\n\n    // Measure the height of the description input\n    const rBefore = await driver.find(`.test-column-title-description`).getRect();\n\n    // Send some multiline text (with more than three lines to test if it auto grows).\n    await gu.sendKeys(\"Line1\");\n    await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL);\n    await gu.sendKeys(\"Line2\");\n    await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL);\n    await gu.sendKeys(\"Line3\");\n    await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL);\n    await gu.sendKeys(\"Line4\");\n    await gu.sendKeys(Key.SHIFT, Key.ENTER, Key.NULL);\n\n    // Measure the height of the description input again\n    const rAfter = await driver.find(`.test-column-title-description`).getRect();\n    // Make sure it is at least 13 pixel taller (default font height).\n    assert.isTrue(rAfter.height >= rBefore.height + 13);\n\n    // Press save\n    await pressSave();\n\n    // Make sure column is renamed.\n    let header = await gu.getColumnHeader({ col: \"ColumnA\" }, { waitMs: 100 });\n\n    // Make sure it has a tooltip.\n    assert.isTrue(await header.find(\".test-column-info-tooltip\").isDisplayed());\n\n    // Click the tooltip.\n    await header.find(\".test-column-info-tooltip\").click();\n\n    // Make sure we see the popup.\n    await waitForTooltip();\n\n    // With a proper text.\n    assert.equal(await driver.find(\".test-column-info-tooltip-popup\").getText(), \"Line1\\nLine2\\nLine3\\nLine4\");\n\n    // Undo one (those renames should be bundled).\n    await gu.undo();\n\n    // Make sure column is renamed back.\n    header = await gu.getColumnHeader({ col: \"A\" });\n\n    // And there is no tooltip.\n    assert.isFalse(await header.find(\".test-column-info-tooltip\").isPresent());\n  });\n\n  const saveTest = async (save: () => Promise<void>) => {\n    const revert = await gu.begin();\n    // Start renaming col A.\n    await doubleClickHeader(\"B\");\n    await gu.sendKeys(\"ColumnB\");\n    // Press enter.\n    await save();\n    await gu.waitForServer();\n    // Make sure it is renamed.\n    await gu.getColumnHeader({ col: \"ColumnB\" }, { waitMs: 100 });\n\n    // Change description by clicking save.\n    await doubleClickHeader(\"ColumnB\");\n    await clickAddDescription();\n    await waitForFocus(\"description\");\n\n    await gu.sendKeys(\"ColumnB description\");\n    await save();\n    await gu.waitForServer();\n    // Make sure tooltip is shown.\n    await clickTooltip(\"ColumnB\");\n    await gu.waitToPass(async () => {\n      assert.equal(await driver.findWait(\".test-column-info-tooltip-popup\", 300).getText(), \"ColumnB description\");\n    });\n    await gu.sendKeys(Key.ESCAPE);\n    await revert();\n  };\n\n  it(\"should support saving by clicking save\", async () => {\n    await saveTest(pressSave);\n  });\n\n  it(\"should support saving by clicking away\", async () => {\n    await saveTest(() => gu.getCell(\"E\", 5).click());\n  });\n\n  it(\"should support saving by clicking Ctrl+Enter\", async () => {\n    await saveTest(async () => await gu.sendKeys(Key.chord(await gu.modKey(), Key.ENTER)));\n  });\n\n  it(\"should support saving by enter\", async () => {\n    const revert = await gu.begin();\n    // Start renaming col A.\n    await doubleClickHeader(\"B\");\n    await gu.sendKeys(\"ColumnB\");\n\n    // Make description.\n    await clickAddDescription();\n    await gu.sendKeys(\"ColumnB description\");\n\n    // Go to label.\n    await gu.sendKeys(Key.ARROW_UP);\n    await gu.sendKeys(Key.ARROW_UP);\n    await waitForFocus(\"label\");\n\n    // Save by pressing enter.\n    await gu.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    // Make sure tooltip is shown.\n    await clickTooltip(\"ColumnB\");\n    await gu.waitToPass(async () => {\n      assert.equal(await driver.findWait(\".test-column-info-tooltip-popup\", 300).getText(), \"ColumnB description\");\n    });\n    await gu.sendKeys(Key.ESCAPE);\n    await revert();\n  });\n\n  it(\"should support saving by tab\", async () => {\n    await saveTest(() => gu.sendKeys(Key.TAB));\n    await saveTest(() => gu.sendKeys(Key.SHIFT, Key.TAB, Key.NULL));\n  });\n\n  const cancelTest = async (makeCancel: () => Promise<void>) => {\n    // Rename column A.\n    await doubleClickHeader(\"A\");\n    await gu.sendKeys(\"ColumnA\");\n    await makeCancel();\n    await gu.waitForServer();\n    // Make sure we see column A.\n    await gu.getColumnHeader({ col: \"A\" });\n\n    // Check the same for description.\n    await doubleClickHeader(\"A\");\n    await clickAddDescription();\n    await gu.sendKeys(\"ColumnA description\");\n    await makeCancel();\n    await gu.waitForServer();\n    // Make sure that there is no tooltip.\n    assert.isFalse(await gu.getColumnHeader({ col: \"A\" }).find(\".test-column-info-tooltip\").isPresent());\n  };\n\n  it(\"should support canceling by cancel\", async () => {\n    await cancelTest(pressCancel);\n  });\n\n  it(\"should support canceling by Escape\", async () => {\n    await cancelTest(() => gu.sendKeys(Key.ESCAPE));\n  });\n\n  it(\"should add description by pressing arrow down\", async () => {\n    await doubleClickHeader(\"A\");\n    await addDescriptionIsVisible(true);\n    await descriptionIsVisible(false);\n    await gu.sendKeys(Key.ARROW_DOWN);\n    await waitForFocus(\"description\");\n    await addDescriptionIsVisible(false);\n    await descriptionIsVisible(true);\n    // Type something.\n    await gu.sendKeys(\"ColumnA description\", Key.ENTER);\n    await gu.sendKeys(\"ColumnA description\");\n    // Now press 2 times the up key.\n    await gu.sendKeys(Key.ARROW_UP);\n    await gu.sendKeys(Key.ARROW_UP);\n    // We should still be in the description field.\n    await waitForFocus(\"description\");\n    // Now press down key and test if that works.\n    await gu.sendKeys(Key.ARROW_DOWN);\n    await driver.wait(() => driver.executeScript(() => ((document as any).activeElement.selectionEnd === 39)), 500);\n\n    // Now press it 3 times, we should be back in the label field.\n    await gu.sendKeys(Key.ARROW_UP);\n    await gu.sendKeys(Key.ARROW_UP);\n    await gu.sendKeys(Key.ARROW_UP);\n\n    // We should be focused back in the label field.\n    await waitForFocus(\"label\");\n    await pressCancel();\n  });\n\n  it(\"should tab to other columns and save\", async () => {\n    const revert = await gu.begin();\n    // Start renaming col A.\n    await doubleClickHeader(\"B\");\n    await gu.sendKeys(\"ColumnB\");\n    // Press tab.\n    await gu.sendKeys(Key.TAB);\n    await gu.waitForServer();\n\n    // Make sure it is renamed.\n    await gu.getColumnHeader({ col: \"ColumnB\" }, { waitMs: 100 });\n    // Make sure we are now at column C.\n    await popupIsAt(\"C\");\n\n    // Rename column C.\n    await gu.sendKeys(\"ColumnC\");\n\n    // Add description.\n    await driver.find(\".test-column-title-add-description\").click();\n    await waitForFocus(\"description\");\n\n    // Rename description.\n    await gu.sendKeys(\"ColumnC description\");\n\n    // Go back to column B from description by pressing shift tab\n    await gu.sendKeys(Key.SHIFT, Key.TAB, Key.NULL);\n    await gu.waitForServer();\n    // Make sure we are now at column B.\n    await popupIsAt(\"ColumnB\");\n    // Make sure the label has focus.\n    await waitForFocus(\"label\");\n    // Go to column C and from the label.\n    await gu.sendKeys(Key.TAB);\n    await driver.sleep(10);\n    // Make sure we are now at column C.\n    await popupIsAt(\"ColumnC\");\n    // Just quick test that shift tab will work.\n    await gu.sendKeys(Key.SHIFT, Key.TAB, Key.NULL);\n    await driver.sleep(10);\n    // Make sure we are now at column B.\n    await popupIsAt(\"ColumnB\");\n    // Go to column C and test if the description was saved.\n    await gu.sendKeys(Key.TAB);\n    await driver.sleep(10);\n    // Make sure we are now at column C.\n    await popupIsAt(\"ColumnC\");\n    // And it has proper description.\n    assert.equal(await driver.find(\".test-column-title-description\").getAttribute(\"value\"), \"ColumnC description\");\n    // Close by pressing escape.\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForServer();\n\n    await revert();\n  });\n\n  it(\"should reopen editor when adding new column\", async () => {\n    // This partially worked before - there was a bug where if you pressed tab on\n    // the last column, and then clicked Add Column, the editor wasn't shown, and the\n    // auto-generated column name was used.\n    const revert = await gu.begin();\n    await doubleClickHeader(\"E\");\n    await gu.sendKeys(Key.TAB);\n    assert.isFalse(await popupVisible());\n\n    await addColumn();\n    assert.isTrue(await popupVisible());\n\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForServer();\n    await revert();\n  });\n\n  it(\"should support basic edition on CardList\", async () => {\n    const mainSession = await gu.session().teamSite.login();\n    const api = mainSession.createHomeApi();\n    const doc = await mainSession.tempDoc(cleanup, \"CardView.grist\", { load: true });\n    const docId = doc.id;\n\n    // Make more room for switching between columns.\n    await gu.toggleSidePanel(\"left\", \"close\");\n    await gu.openColumnPanel();\n\n    await addColumnDescription(api, docId, \"B\");\n\n    // Column description editable in right panel\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    assert.equal(await getDescriptionPreview().getText(), \"This is the column description It is in two lines\");\n    await getDescriptionPreview().click();\n    assert.equal(await getDescriptionInput().value(), \"This is the column description\\nIt is in two lines\");\n\n    await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n    assert.equal(await getDescriptionPreview().isPresent(), false);\n    assert.equal(await getDescriptionAddLink().isDisplayed(), true);\n\n    // Remove the description\n    await api.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Table1\", \"B\", {\n        description: \"\",\n      }],\n    ]);\n\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    assert.equal(await getDescriptionPreview().isPresent(), false);\n    assert.equal(await getDescriptionAddLink().isDisplayed(), true);\n    await gu.toggleSidePanel(\"left\", \"open\");\n  });\n\n  it(\"should show info tooltip only if there is a description\", async () => {\n    const mainSession = await gu.session().teamSite.login();\n    const api = mainSession.createHomeApi();\n    const doc = await mainSession.tempDoc(cleanup, \"CardView.grist\", { load: true });\n    const docId = doc.id;\n\n    await addColumnDescription(api, docId, \"B\");\n\n    await gu.changeWidget(\"Card\");\n\n    const detailUndescribedColumnFirstRow = await gu.getDetailCell(\"A\", 1);\n    assert.isFalse(\n      await detailUndescribedColumnFirstRow\n        .findClosest(\".g_record_detail_el\")\n        .find(\".test-column-info-tooltip\")\n        .isPresent(),\n    );\n\n    await openCardColumnTooltip(\"B\");\n\n    // Check the content of the tooltip\n    const descriptionTooltip = await driver\n      .find(\".test-column-info-tooltip-popup\");\n    assert.equal(await descriptionTooltip.getText(), \"This is the column description\\nIt is in two lines\");\n  });\n});\n\nasync function clickTooltip(col: string) {\n  await gu.getColumnHeader({ col }, { waitMs: 100 }).find(\".test-column-info-tooltip\").click();\n}\n\nasync function addDescriptionIsVisible(visible = true) {\n  if (visible) {\n    assert.isTrue(await driver.find(\".test-column-title-add-description\").isDisplayed());\n  } else {\n    assert.isFalse(await driver.find(\".test-column-title-add-description\").isPresent());\n  }\n}\n\nasync function descriptionIsVisible(visible = true) {\n  if (visible) {\n    assert.isTrue(await driver.find(\".test-column-title-description\").isDisplayed());\n  } else {\n    assert.isFalse(await driver.find(\".test-column-title-description\").isPresent());\n  }\n}\n\nasync function addColumnDescription(api: UserAPIImpl, docId: string, columnName: string) {\n  await api.applyUserActions(docId, [\n    [\"ModifyColumn\", \"Table1\", columnName, {\n      description: \"This is the column description\\nIt is in two lines\",\n    }],\n  ]);\n}\n\nconst getDescriptionAddLink = () => driver.find(\".test-right-panel .test-description-add\");\nconst getDescriptionPreview = () => driver.find(\".test-right-panel .test-description-preview\");\n\nfunction getDescriptionInput() {\n  return driver.find(\".test-right-panel .test-column-description\");\n}\n\nfunction getLabelText() {\n  return getLabel().getAttribute(\"value\");\n}\n\nfunction getLabel() {\n  return driver.findWait(\".test-column-title-label\", 1000);\n}\n\nasync function popupVisible() {\n  try {\n    return await driver.findWait(\".test-column-title-popup\", 50).isDisplayed();\n  } catch {\n    return false;\n  }\n}\n\nasync function popupIsAt(col: string) {\n  // Make sure we are now at column.\n  assert.equal(await getLabelText(), col);\n  // Make sure that popup is near the column.\n  const headerCRect = await gu.getColumnHeader({ col }).getRect();\n  const popup = await driver.find(\".test-column-title-popup\").getRect();\n  assert.isAtLeast(popup.x, headerCRect.x - 2);\n  assert.isBelow(popup.x, headerCRect.x + 2);\n  assert.isAtLeast(popup.y, headerCRect.y + headerCRect.height - 2);\n  assert.isBelow(popup.y, headerCRect.y + headerCRect.height + 2);\n}\n\nasync function doubleClickHeader(col: string, focus: \"label\" | \"description\" | null = \"label\") {\n  const header = await gu.getColumnHeader({ col });\n  await header.click();\n  await header.click();\n  if (focus) {\n    await waitForFocus(focus);\n  }\n}\n\nasync function waitForFocus(field: \"label\" | \"description\") {\n  await gu.waitToPass(async () => assert.isTrue(\n    await driver.find(`.test-column-title-${field}`).hasFocus(), `${field} doesn't have focus`), 200);\n}\n\nasync function waitForTooltip() {\n  await gu.waitToPass(async () => {\n    assert.isTrue(await driver.find(\".test-column-info-tooltip-popup\").isDisplayed());\n  });\n}\n\nasync function pressSave() {\n  await driver.find(\".test-column-title-save\").click();\n  await gu.waitForServer();\n}\n\nasync function pressClose() {\n  await driver.find(\".test-column-title-close\").click();\n  await gu.waitForServer();\n}\n\nasync function saveDisabled() {\n  const value = await driver.find(\".test-column-title-save\").getAttribute(\"disabled\");\n  return value === \"true\";\n}\n\nasync function pressCancel() {\n  await driver.find(\".test-column-title-cancel\").click();\n  await gu.waitForServer();\n}\n\nasync function clickAddDescription() {\n  await driver.find(\".test-column-title-add-description\").click();\n  await waitForFocus(\"description\");\n}\n\nasync function addColumn() {\n  await driver.find(\".mod-add-column\").click();\n  await driver.findWait(\".test-new-columns-menu-add-new\", 100).click();\n  await gu.waitForServer();\n}\n\nasync function closeVisible() {\n  return await driver.find(\".test-column-title-close\").isDisplayed();\n}\n\nasync function saveVisible() {\n  return await driver.find(\".test-column-title-save\").isDisplayed();\n}\n\nasync function cancelVisible() {\n  return await driver.find(\".test-column-title-cancel\").isDisplayed();\n}\n\nasync function openCardColumnTooltip(col: string) {\n  const detailDescribedColumnFirstRow = await gu.getDetailCell(col, 1);\n  const toggle = await detailDescribedColumnFirstRow\n    .findClosest(\".g_record_detail_el\")\n    .find(\".test-column-info-tooltip\");\n  // The toggle to show the description is present if there is a description\n  assert.isTrue(await toggle.isPresent());\n  // Open the tooltip\n  await toggle.click();\n  await waitForTooltip();\n}\n"
  },
  {
    "path": "test/nbrowser/DescriptionWidget.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"DescriptionWidget\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  before(async () => {\n    const mainSession = await gu.session().teamSite.login();\n    await mainSession.tempDoc(cleanup, \"CardView.grist\", { load: true });\n    await gu.openWidgetPanel();\n  });\n\n  const getDescriptionAddLink = () => driver.find(\".test-right-panel .test-description-add\");\n  const getDescriptionPreview = () => driver.find(\".test-right-panel .test-description-preview\");\n  const getDescriptionInput = () => driver.find(\".test-right-panel .test-right-widget-description\");\n\n  it(\"should support basic edition in right panel\", async () => {\n    const newWidgetDesc = \"This is the widget description\\nIt is in two lines\";\n    await getDescriptionAddLink().click();\n    await driver.sleep(10);\n    assert.equal(await getDescriptionInput().hasFocus(), true);\n    await gu.sendKeys(newWidgetDesc.split(\"\\n\")[0]);\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.ENTER));\n    await gu.sendKeys(newWidgetDesc.split(\"\\n\")[1]);\n    // Click on other input to unselect descriptionInput\n    await driver.find(\".test-right-panel .test-right-widget-title\").click();\n    await gu.waitForServer();\n    assert.equal(await getDescriptionAddLink().isPresent(), false);\n    assert.equal(await getDescriptionPreview().isPresent(), true);\n    assert.equal(await getDescriptionInput().isPresent(), false);\n\n    await checkDescValueInWidgetTooltip(\"Table\", newWidgetDesc);\n  });\n\n  it(\"should support clearing description in right panel\", async function() {\n    // Should support clearing the description after reload (there was a bug with that at one point).\n    await gu.reloadDoc();\n    assert.equal(await getDescriptionPreview().isPresent(), true);\n    await getDescriptionPreview().click();\n    await getDescriptionInput().sendKeys(Key.BACK_SPACE, Key.ENTER);\n    await gu.waitForServer();\n\n    assert.equal(await getDescriptionAddLink().isPresent(), true);\n    assert.equal(await getDescriptionPreview().isPresent(), false);\n    assert.equal(await getDescriptionInput().isPresent(), false);\n  });\n\n  it(\"should support basic edition in widget popup\", async () => {\n    const widgetName = \"Table\";\n    const newWidgetDescFirstLine = \"First line of the description\";\n    const newWidgetDescSecondLine = \"Second line of the description\";\n\n    await addWidgetDescription(widgetName, newWidgetDescFirstLine, newWidgetDescSecondLine);\n    await checkDescValueInWidgetTooltip(widgetName, `${newWidgetDescFirstLine}\\n${newWidgetDescSecondLine}`);\n  });\n\n  it(\"should show info tooltip only if there is a description\", async () => {\n    const newWidgetDesc = \"New description for widget Table\";\n\n    await addWidgetDescription(\"Table\", newWidgetDesc);\n\n    assert.isFalse(await getWidgetTooltip(\"Single card\").isPresent());\n    assert.isTrue(await getWidgetTooltip(\"Table\").isPresent());\n\n    await checkDescValueInWidgetTooltip(\"Table\", newWidgetDesc);\n  });\n\n  it(\"shows link in a description\", async () => {\n    await addWidgetDescription(\"Table\", \"Some text with a https://www.grist.com link\");\n\n    assert.isFalse(await getWidgetTooltip(\"Single card\").isPresent());\n    assert.isTrue(await getWidgetTooltip(\"Table\").isPresent());\n\n    await getWidgetTooltip(\"Table\").click();\n    await waitForTooltip();\n    const descriptionTooltip = await driver\n      .find(\".test-widget-info-tooltip-popup\");\n    assert.equal(await descriptionTooltip.getText(), \"Some text with a \\nhttps://www.grist.com link\");\n    assert.equal(await descriptionTooltip.find(\".test-text-link a\").getAttribute(\"href\"), \"https://www.grist.com/\");\n    assert.equal(await descriptionTooltip.find(\".test-text-link\").getText(), \"https://www.grist.com\");\n  });\n});\n\nasync function waitForEditPopup() {\n  await gu.waitToPass(async () => {\n    assert.isTrue(await driver.find(\".test-widget-title-popup\").isDisplayed());\n  });\n}\n\nasync function waitForTooltip() {\n  await gu.waitToPass(async () => {\n    assert.isTrue(await driver.find(\".test-widget-info-tooltip-popup\").isDisplayed());\n  });\n}\n\nfunction getWidgetTitle(widgetName: string) {\n  return driver.findContent(\".test-widget-title-text\", `${widgetName}`);\n}\n\nfunction getWidgetTooltip(widgetName: string) {\n  return getWidgetTitle(widgetName).findClosest(\".test-viewsection-title\").find(\".test-widget-info-tooltip\");\n}\n\nasync function addWidgetDescription(widgetName: string, desc: string, descSecondLine: string = \"\") {\n  // Click on the title and open the edition popup\n  await getWidgetTitle(widgetName).click();\n  await waitForEditPopup();\n  const widgetEditPopup = await driver.find(\".test-widget-title-popup\");\n  const widgetDescInput = await widgetEditPopup.find(\".test-widget-title-section-description-input\");\n\n  // Edit the description of the widget inside the popup\n  await widgetDescInput.click();\n  await gu.clearInput();\n  await widgetDescInput.sendKeys(desc);\n  if (descSecondLine !== \"\") {\n    await widgetDescInput.sendKeys(Key.ENTER, descSecondLine);\n  }\n  await widgetDescInput.sendKeys(Key.CONTROL, Key.ENTER);\n  await gu.waitForServer();\n}\n\nasync function checkDescValueInWidgetTooltip(widgetName: string, desc: string) {\n  await getWidgetTooltip(widgetName).click();\n  await waitForTooltip();\n  const descriptionTooltip = await driver\n    .find(\".test-widget-info-tooltip-popup\");\n  assert.equal(await descriptionTooltip.getText(), desc);\n}\n"
  },
  {
    "path": "test/nbrowser/DetailView.ntest.js",
    "content": "import { By, assert, driver } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"DetailView.ntest\", function () {\n  const cleanup = test.setupTestSuite(this);\n  const clipboard = gu.getLockableClipboard();\n  gu.bigScreen();\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"Favorite_Films.grist\", true);\n\n    // Open the view tab.\n    await gu.openSidePane(\"view\");\n\n    // Open the 'All' view\n    await gu.actions.selectTabView(\"All\");\n\n    // close the side pane\n    await gu.toggleSidePanel(\"left\", \"close\");\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  describe(\"DetailView.selection\", function () {\n\n    before(async function() {\n      // Open the 'Performances' view\n      await gu.actions.viewSection(\"Performances detail\").selectSection();\n      await $(\".test-right-panel button:contains(Change widget)\").click();\n      await $(\".test-wselect-type:contains(Card List)\").click();\n      await $(\".test-wselect-addBtn\").click();\n      await gu.waitForServer();\n      await gu.openSelectByForSection(\"Performances detail\");\n      await gu.findOpenMenuItem(\".test-select-row\", /Performances record$/).click();\n    });\n\n    it(\"should mark detail-view row as selected when its out of focus\", async function() {\n      // Focus on Performances record, second row\n      await gu.actions.viewSection(\"Performances record\").selectSection();\n      await gu.getCell({col: \"Film\", rowNum: 2}).click();\n\n      //Check if only the second card in detail view is having selection class\n      const elements = await driver.findElements(By.css(\".detailview_record_detail\"));\n      const secondElement = await elements[1].getAttribute(\"class\");\n      assert.isTrue(secondElement.includes(\"selected\"));\n\n      const firstElement = await elements[0].getAttribute(\"class\");\n      assert.isFalse(firstElement.includes(\"selected\"));\n    });\n  });\n\n  it(\"should allow switching between card and detail view\", async function() {\n    await gu.actions.viewSection(\"Performances detail\").selectSection();\n\n    // Check that the detail cells have the correct values.\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Actor\", [1]), [\"Tom Hanks\"]);\n    await $(\".grist-single-record__menu .detail-right\").click();\n    // rowNum is always 1 for detail cells now.\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Actor\", [1]), [\"Tim Allen\"]);\n\n    // Swap to Card List view, check values.\n    await $(\".test-right-panel button:contains(Change widget)\").click();\n    await $(\".test-wselect-type:contains(Card List)\").click();\n    await $(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Actor\", [1, 2]),\n      [\"Tom Hanks\", \"Tim Allen\"]);\n\n    // Swap back to Card view, re-check values.\n    await $(\".test-right-panel button:contains(Change widget)\").click();\n    await $(\".test-wselect-type:contains(Card)\").click();\n    await $(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Actor\", [1]), [\"Tim Allen\"]);\n    await $(\".grist-single-record__menu .detail-left\").click();\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Actor\", [1]), [\"Tom Hanks\"]);\n  });\n\n  it(\"should allow editing cells\", async function() {\n    // Updates should be reflected in the detail floating rowModel cell.\n    await gu.sendKeys(\"Roger Federer\", $.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Actor\", [1]), [\"Roger Federer\"]);\n\n    // Undo updates should be reflected as well.\n    await gu.sendKeys([$.MOD, \"z\"]);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Actor\", [1]), [\"Tom Hanks\"]);\n  });\n\n  // Note: This is a test of a specific bug related to the detail rowModel being resized after\n  // being unset.\n  it(\"should allow row resize operations after switching section type\", async function() {\n    // Switch to Card List view and enter a formula. This should cause the scrolly to resize all rows.\n    // If the detail view rowModel is wrongly resized, the action will fail.\n    await $(\".test-right-panel button:contains(Change widget)\").click();\n    await $(\".test-wselect-type:contains(Card List)\").click();\n    await $(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n    await gu.sendKeys(\"=\");\n    await $(\".test-editor-tooltip-convert\").click();      // Convert to a formula\n    await gu.sendKeys(\"100\", $.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Actor\", [1, 2, 3, 4]),\n      [\"100\", \"100\", \"100\", \"100\"]);\n  });\n\n  //FIXME: This test is constanly failing on phab build pipeline. need to be fixed\n  it.skip(\"should include an add record row\", async function() {\n    // Should include an add record row which works in card view and detail view.\n    // Check that adding 'Jurassic Park' to the card view add record row adds it as a row.\n    await $(\".g_record_detail:nth-child(14) .field_clip\").eq(1).wait().click();\n    await gu.sendKeys(\"Jurassic Park\", $.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Film\", [14]), [\"Jurassic Park\"]);\n    // Check that adding 'Star Wars' to the detail view add record row adds it as a row.\n    await $(\".test-right-panel button:contains(Change widget)\").click();\n    await $(\".test-wselect-type:contains(Card)\").click();\n    await $(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n    await $(\".detail-add-btn\").wait().click();\n    // Card view, so rowNum is now 1\n    await gu.getDetailCell(\"Film\", 1).click();\n    await gu.sendKeys(\"Star Wars\", $.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Film\", [1]), [\"Star Wars\"]);\n\n    // Should allow pasting into the add record row.\n    await gu.getDetailCell(\"Actor\", 1).click();\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await $(\".detail-add-btn\").click();\n      await gu.waitForServer();\n      // Paste '100' into the last field of the row and check that it is added as its own row.\n      await gu.getDetailCell(\"Character\", 1).click();\n      await cb.paste();\n    });\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getDetailCell(\"Character\", 1).text(), \"100\");\n\n    // Should not throw errors when deleting the add record row.\n    await $(\".detail-add-btn\").click();\n    await gu.sendKeys([$.MOD, $.DELETE]);\n    // Errors will be detected in afterEach.\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/DetailView.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { cleanupExtraWindows, server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"DetailView\", function() {\n  this.timeout(20000);\n  cleanupExtraWindows();\n  const cleanup = setupTestSuite();\n\n  // Before create new document.\n  before(async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n\n    await gu.sendActions([\n      [\"AddRecord\", \"Table1\", null, { A: \"some text\", B: server.getHost() }],\n    ]);\n    await gu.addNewSection(\"Card\", \"Table1\");\n  });\n\n  it(\"opens cell for editing when clicked\", async () => {\n    const fieldA = await gu.getDetailCell(\"A\", 1);\n\n    // Make sure the cell is not in edit mode.\n    assert.equal(await fieldA.getText(), \"some text\");\n    assert.equal(await driver.find(\".test-widget-text-editor\").isPresent(), false);\n\n    // Now click on the cell and make sure it is in edit mode.\n    await fieldA.click(); // first is to select it\n    await fieldA.click(); // second is to edit it\n    assert.equal(await driver.find(\".test-widget-text-editor\").isPresent(), true);\n    await gu.checkTextEditor(\"some text\");\n  });\n\n  it(\"does not opens cell for editing when clicked on link\", async () => {\n    const fieldB = await gu.getDetailCell(\"B\", 1);\n\n    // First select the cell.\n    await fieldB.click();\n    // Now click on the link and make sure it is not in edit mode.\n    await fieldB.find(\".test-tb-link-icon\").click();\n\n    assert.equal(await fieldB.getText(), server.getHost());\n    await driver.sleep(100); // This click is ignored, so wait for a bit.\n    assert.equal(await driver.find(\".test-widget-text-editor\").isPresent(), false);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/DocTour.ts",
    "content": "import { DocCreationInfo } from \"app/common/DocListAPI\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server } from \"test/nbrowser/testServer\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"DocTour\", function() {\n  this.timeout(30000);\n  const cleanup = setupTestSuite();\n\n  let api: UserAPI;\n  let doc: DocCreationInfo;\n\n  before(async function() {\n    api = gu.createHomeApi(\"chimpy\", \"nasa\");\n    doc = await gu.importFixturesDoc(\n      \"chimpy\", \"nasa\", \"Horizon\", \"doctour.grist\", false,\n    );\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n  });\n\n  after(async function() {\n    // Revert changes to document access.\n    await api.updateDocPermissions(doc.id, { users: { \"kiwi@getgrist.com\": null, \"charon@getgrist.com\": null } });\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  /*\n  To see what this document tour is like:\n  1. Open the doctour.grist fixture\n  2. Click the links under 'Location' to see which cells they point to\n  3. Open the link in the last column to conveniently open the document with #repeat-doc-tour at the end.\n     Apparently this doesn't work without reloading so I suggest Ctrl+click or middle click to open in a new tab.\n\n  The rows in the table are not in the same order as their row IDs, i.e. the table tests that\n  using manualSort works correctly to create popups in the order the user sees in the table.\n\n  The last row has no title or body so no popup is created for it.\n   */\n\n  it(\"should create correct popups from a GristDocTour table\", async () => {\n    await gu.loadDoc(`/o/nasa/doc/${doc.id}#repeat-doc-tour`);\n    await gu.waitForDocToLoad();\n\n    const docTour = await driver.executeScript(\"return window._gristDocTour()\");\n    assert.deepEqual(docTour, [\n      {\n        body: \"General Kenobi!\",\n        placement: \"bottom\",\n        selector: \".active_cursor\",\n        showHasModal: false,\n        title: \"Hello there!\",\n        urlState: { colRef: 2, rowId: 2, sectionId: 1 },\n      },\n      {\n        body: \"no title\",\n        placement: \"auto\",\n        selector: \".active_cursor\",\n        showHasModal: true,\n        title: \"\",\n        urlState: null,\n      },\n      {\n        body: \"<div>\" +\n          \"<p></p>\" +\n          '<p><div class=\"_grainXXX_\">' +\n          '<a href=\"https://www.getgrist.com/\" target=\"_blank\" class=\"_grainXXX_ _grainXXX_\">' +\n          '<div class=\"_grainXXX_ _grainXXX_\" style=\"mask-image: var(--icon-Page);\"></div>' +\n          \"A link with an icon\" +\n          \"</a>\" +\n          \"</div></p>\" +\n          \"</div>\",\n        placement: \"auto\",\n        selector: \".active_cursor\",\n        showHasModal: false,\n        title: \"no body\",\n        urlState: { colRef: 4, rowId: 4, sectionId: 1 },\n      },\n      {\n        body: \"<div>\" +\n          \"<p>Good riddance</p>\" +\n          '<p><div class=\"_grainXXX_\">' +\n          '<a href=\"https://xkcd.com/\" target=\"_blank\" class=\"_grainXXX_ _grainXXX_\">' +\n          \"Test link here\" +\n          \"</a>\" +\n          \"</div></p>\" +\n          \"</div>\",\n        placement: \"auto\",\n        selector: \".active_cursor\",\n        showHasModal: true,\n        title: \"Bye\",\n        urlState: null,\n      },\n    ]);\n  });\n\n  it(\"should not show GristDocTour table unless specifically requested\", async () => {\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\"]);\n\n    // Check that there is no tree item for GristDocTour (we used to show \"-\").\n    assert.lengthOf(await driver.findAll(\".test-treeview-label\"), 1);\n\n    await gu.loadDoc(`/o/nasa/doc/${doc.id}/p/GristDocTour`);\n    await gu.waitForDocToLoad();\n    assert.deepEqual(await gu.getPageNames(), [\"GristDocTour\", \"Table1\"]);\n    assert.lengthOf(await driver.findAll(\".test-treeview-label\"), 2);\n  });\n\n  it(\"should allow owners to delete tours from the left panel\", async () => {\n    // Check that Chimpy (the owner) can see the remove tour button.\n    assert(await driver.find(\".test-tools-remove-doctour\").isPresent());\n    assert(await driver.find(\".test-tools-doctour\").isPresent());\n\n    // Chimpy invites Kiwi to view this document as an editor.\n    await api.updateDocPermissions(doc.id, { users: { \"kiwi@getgrist.com\": \"editors\" } });\n\n    // Kiwi logs in and loads the document.\n    await server.simulateLogin(\"kiwi\", \"kiwi@getgrist.com\", \"nasa\");\n    await gu.loadDoc(`/o/nasa/doc/${doc.id}`);\n\n    // Check that Kiwi sees the onboarding tour.\n    assert(await driver.findWait(\".test-onboarding-popup\", 1000).isPresent());\n\n    // Check that Kiwi can't see the remove tour button.\n    assert.isFalse(await driver.find(\".test-tools-remove-doctour\").isPresent());\n    assert(await driver.find(\".test-tools-doctour\").isPresent());\n\n    // Switch back to Chimpy and remove the tour.\n    await driver.find(\".test-onboarding-close\").click();\n    await driver.find(\".test-user-icon\").click();\n    await driver.findContentWait(\".test-usermenu-other-email\", /chimpy@getgrist.com/, 1000).click();\n    await driver.wait(async () => (await gu.getEmail() === \"chimpy@getgrist.com\"), 500);\n    await driver.findWait(\".test-onboarding-close\", 1000).click();\n    await driver.find(\".test-tools-remove-doctour\").click();\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n\n    // Check that the 'Tour of this Document' button is now gone.\n    assert.isFalse(await driver.find(\".test-tools-doctour\").isPresent());\n\n    // Chimpy invites Charon to view this document as an editor.\n    await api.updateDocPermissions(doc.id, { users: { \"charon@getgrist.com\": \"editors\" } });\n\n    // Charon logs in and loads the document.\n    await server.simulateLogin(\"Charon\", \"charon@getgrist.com\", \"nasa\");\n    await gu.loadDoc(`/o/nasa/doc/${doc.id}`);\n\n    // Wait a moment after doc loads, and then check that a tour isn't shown.\n    await driver.sleep(1000);\n    assert.isFalse(await driver.find(\".test-onboarding-popup\").isPresent());\n  });\n\n  async function checkDocTourPresent() {\n    // Check the expected message.\n    assert.match(await driver.findWait(\".test-onboarding-popup\", 500).getText(), /General Kenobi/);\n    // Check that there is only one popup, and no errors.\n    assert.lengthOf(await driver.findAll(\".test-onboarding-popup\"), 1);\n    await gu.checkForErrors();\n\n    // Click next once, and check the next expected popup in the same way.\n    await driver.find(\".test-onboarding-next\").click();\n    assert.match(await driver.findWait(\".test-onboarding-popup\", 500).getText(), /no title/);\n    assert.lengthOf(await driver.findAll(\".test-onboarding-popup\"), 1);\n    await gu.checkForErrors();\n  }\n\n  // Check that there's no tour, not even a welcome tour.\n  async function checkTourNotPresent() {\n    await driver.sleep(200);    // Wait a bit to be sure.\n    assert.isFalse(await driver.find(\".test-onboarding-popup\").isPresent());\n  }\n\n  it(\"should show doctour for a new anon user, error-free, without welcome tour\", async () => {\n    // Create a doc with a tour on a personal site, and share it with with everyone as editor.\n    const ownerSession = gu.session().user(\"user1\").personalSite;\n    await ownerSession.login();\n    const docId = (await ownerSession.tempDoc(cleanup, \"doctour.grist\", { load: false })).id;\n    await ownerSession.createHomeApi().updateDocPermissions(docId, {\n      users: { \"everyone@getgrist.com\": \"editors\" },\n    });\n\n    // Doc tour normally triggers for a first visit from an anon user\n    const anonSession = gu.session().anon.personalSite;\n    await anonSession.login({ isFirstLogin: undefined });\n    await anonSession.loadDoc(`/doc/${docId}/`);\n    await checkDocTourPresent();\n\n    // Embedded mode shouldn't show a tour.\n    await anonSession.loadDoc(`/doc/${docId}/?embed=1`);\n    await checkTourNotPresent();\n\n    // Reload the doc without embedding. We didn't dismiss the tour, so it should show up again.\n    await anonSession.loadDoc(`/doc/${docId}/`);\n    await checkDocTourPresent();\n\n    // Dismiss the tour.\n    await driver.find(\".test-onboarding-close\").click();\n    await checkTourNotPresent();\n\n    // Reload the page, the tour should stay dismissed.\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n    await checkTourNotPresent();\n\n    // Trigger the tour manually.\n    await driver.find(\".test-tools-doctour\").click();\n    await checkDocTourPresent();\n  });\n\n  it.skip(\"should not show doctour if opening an anchor link encoded with rr\", async () => {\n    // Create a doc with a tour on a personal site.\n    const session = await gu.session().user(\"user1\").personalSite.login({ showTips: true });\n    const docId = (await session.tempDoc(cleanup, \"doctour.grist\", { load: false })).id;\n\n    // Load the doc with an anchor link containing an easter egg (\"rr\" instead of \"r\").\n    await session.loadDoc(`/doc/${docId}/doctour/p/1#a1.s1.rr1.c2`);\n\n    // Check that the doc tour isn't shown on doc load.\n    await checkTourNotPresent();\n\n    // Dismiss the tip about anchor links, and check that the easter egg is present.\n    await gu.dismissBehavioralPrompts();\n    await driver.wait(async () =>\n      // Loading the YouTube API from tests can sometimes be slow.\n      await driver.find(\".test-gristdoc-stop-rick-rowing\").isPresent(), 15000);\n    await gu.assertIsRickRowing(true);\n\n    // Stop playing the easter egg.\n    await driver.find(\".test-gristdoc-stop-rick-rowing\").click();\n    await gu.assertIsRickRowing(false);\n\n    // Check that tours can still be started manually.\n    await driver.find(\".test-tools-doctour\").click();\n    await checkDocTourPresent();\n  });\n\n  it(\"should not show doctour if opening an anchor link\", async () => {\n    // Create a doc with a tour on a personal site.\n    const session = await gu.session().user(\"user1\").personalSite.login({ showTips: true });\n    const docId = (await session.tempDoc(cleanup, \"doctour.grist\", { load: false })).id;\n\n    // Load the doc with an anchor link.\n    await session.loadDoc(`/doc/${docId}/doctour/p/1#a1.s1.r1.c2`);\n\n    // Check that the doc tour isn't shown on doc load.\n    await checkTourNotPresent();\n\n    // Check that tours can still be started manually.\n    await driver.find(\".test-tools-doctour\").click();\n    await checkDocTourPresent();\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/DocTutorial.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"DocTutorial\", function() {\n  this.timeout(60000);\n\n  gu.bigScreen(\"big\");\n\n  let api: UserAPI;\n  let ownerSession: gu.Session;\n  let editorSession: gu.Session;\n  let viewerSession: gu.Session;\n\n  setupTestSuite({ samples: true, tutorial: true, team: true });\n\n  gu.withEnvironmentSnapshot({\n    GRIST_UI_FEATURES: \"tutorials\",\n    GRIST_TEMPLATE_ORG: \"templates\",\n    GRIST_ONBOARDING_TUTORIAL_DOC_ID: \"grist-basics\",\n    GRIST_TEST_LOGIN: \"1\",\n  });\n\n  before(async () => {\n    ownerSession = await gu.session().customTeamSite(\"templates\").user(\"support\").login();\n    api = ownerSession.createHomeApi();\n    await api.updateDocPermissions(\"grist-basics\", { users: {\n      [gu.translateUser(\"user1\").email]: \"editors\",\n    } });\n  });\n\n  describe(\"when logged out\", function() {\n    before(async () => {\n      viewerSession = await gu.session().anon.login();\n    });\n\n    it(\"shows a tutorial card\", async function() {\n      await viewerSession.loadDocMenu(\"/\");\n      assert.isTrue(await driver.find(\".test-intro-tutorial\").isDisplayed());\n      assert.equal(await driver.find(\".test-intro-tutorial-percent-complete\").getText(), \"0%\");\n    });\n\n    it(\"shows a link to tutorial\", async function() {\n      assert.isTrue(await driver.find(\".test-dm-basic-tutorial\").isDisplayed());\n    });\n\n    it(\"redirects user to log in\", async function() {\n      await driver.find(\".test-dm-basic-tutorial\").click();\n      await gu.checkLoginPage();\n    });\n  });\n\n  describe(\"when logged in\", function() {\n    let forkUrl: string;\n\n    before(async () => {\n      editorSession = await gu.session().customTeamSite(\"templates\").user(\"user1\").login({ showTips: true });\n      await editorSession.loadDocMenu(\"/\");\n      await driver.executeScript(\"resetDismissedPopups();\");\n      await gu.waitForServer();\n    });\n\n    afterEach(() => gu.checkForErrors());\n\n    it(\"shows a tutorial card\", async function() {\n      assert.isTrue(await driver.find(\".test-intro-tutorial\").isDisplayed());\n      await gu.waitToPass(async () =>\n        assert.equal(await driver.find(\".test-intro-tutorial-percent-complete\").getText(), \"0%\"),\n      2000,\n      );\n    });\n\n    it(\"creates a fork the first time the document is opened\", async function() {\n      await driver.find(\".test-dm-basic-tutorial\").click();\n      await driver.wait(async () => {\n        forkUrl = await driver.getCurrentUrl();\n        return forkUrl.includes(\"~\");\n      });\n    });\n\n    it(\"shows a popup containing slides generated from the GristDocTutorial table\", async function() {\n      assert.isTrue(await driver.findWait(\".test-doc-tutorial-popup\", 2000).isDisplayed());\n      assert.equal(await driver.find(\".test-doc-tutorial-popup-header\").getText(), \"Grist Basics\");\n      assert.equal(\n        await driver.findWait(\".test-doc-tutorial-popup h1\", 2000).getText(),\n        \"Intro\",\n      );\n      assert.match(\n        await driver.find(\".test-doc-tutorial-popup\").getText(),\n        /Welcome to the Grist Basics tutorial/,\n      );\n    });\n\n    const move = async (pos: { x?: number, y?: number }) => driver.withActions(actions => actions\n      .move({ origin: driver.find(\".test-doc-tutorial-popup-move-handle\") })\n      .press()\n      .move({ origin: driver.find(\".test-doc-tutorial-popup-move-handle\"), ...pos })\n      .release(),\n    );\n\n    const resize = async (handle: string, pos: { x?: number; y?: number }) =>\n      driver.withActions(actions =>\n        actions\n          .move({\n            origin: driver.find(\n              `.test-doc-tutorial-popup .ui-resizable-${handle}`,\n            ),\n          })\n          .press()\n          .move({\n            origin: driver.find(`.test-doc-tutorial-popup .ui-resizable-${handle}`),\n            ...pos,\n          })\n          .release(),\n      );\n\n    it(\"can be moved around and minimized\", async function() {\n      // Get the initial position of the popup.\n      const initialDims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n\n      // Move it a little bit down.\n      await move({ y: 100, x: -10 });\n\n      // Check it is moved but not shrinked.\n      let dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.equal(dims.height, initialDims.height);\n      // And moving down.\n      assert.equal(dims.y, initialDims.y + 100);\n      assert.equal(dims.x, initialDims.x - 10);\n\n      // Now move it a little up and test it doesn't grow.\n      await move({ y: -100 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.equal(dims.height, initialDims.height);\n      assert.equal(dims.y, initialDims.y);\n\n      // Resize it in steps.\n      await resize(\"n\", { y: 10 });\n      await resize(\"n\", { y: 10 });\n      await resize(\"n\", { y: 10 });\n      await resize(\"n\", { y: 10 });\n      await resize(\"n\", { y: 10 });\n      await resize(\"n\", { y: 50 });\n\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.equal(dims.height, initialDims.height - 100);\n      assert.equal(dims.y, initialDims.y + 100);\n\n      // Resize back (in steps, to simulate user actions)\n      await resize(\"n\", { y: -20 });\n      await resize(\"n\", { y: -20 });\n      await resize(\"n\", { y: -20 });\n      await resize(\"n\", { y: -40 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.equal(dims.height, initialDims.height);\n      assert.equal(dims.y, initialDims.y);\n\n      // Now resize it to the minimum size.\n      await resize(\"n\", { y: 700 });\n\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.equal(dims.height, 300);\n\n      // Get window inner size.\n      const windowHeight: any = await driver.executeScript(\"return window.innerHeight\");\n      const windowWidth: any = await driver.executeScript(\"return window.innerWidth\");\n      assert.equal(dims.y + dims.height, windowHeight - 16);\n\n      // Now we'll test moving the window outside the viewport. Selenium throws when\n      // the mouse exceeds the bounds of the viewport, so we can't test every scenario.\n      // Start by moving the window as low as possible.\n      await move({ y: windowHeight - dims.y - 32 });\n\n      // Make sure it is still visible.\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.equal(dims.y, windowHeight - 32); // 32px is a header size\n      assert.isBelow(dims.x, windowWidth - (32 * 4) + 1); // 120px is the right overflow value.\n\n      // Now move it to the right as far as possible.\n      await move({ x: windowWidth - dims.x - 10 });\n\n      // Make sure it is still visible.\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.equal(dims.y, windowHeight - 32);\n      assert.equal(dims.x, windowWidth - (32 * 4) + (2 * 4));\n\n      // Now move it to the left as far as possible.\n      await move({ x: -windowWidth + (32 * 4) - (2 * 4) });\n\n      // Make sure it is still visible.\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.equal(dims.x, 0);\n      assert.isAbove(dims.x + dims.width, 30);\n\n      // Now move it to the top as far as possible.\n      // Move it a little right, so that we don't end up on the logo. Driver is clicking logo sometimes.\n      await move({ y: -windowHeight + 16, x: 100 });\n\n      // Make sure it is still visible.\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.equal(dims.y, 16);\n      assert.equal(dims.x, 100);\n    });\n\n    it(\"can be resized\", async function() {\n      await resize(\"sw\", { x: 10, y: 10 });\n      let dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 110, y: 16, width: 426, height: 310 });\n      await resize(\"ne\", { x: -5, y: -15 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 110, y: 16, width: 421, height: 310 });\n      await resize(\"se\", { x: -25, y: -25 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 110, y: 16, width: 396, height: 300 });\n      await resize(\"s\", { x: 25, y: 100 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 110, y: 16, width: 396, height: 400 });\n      await resize(\"se\", { x: 50, y: 10 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 110, y: 16, width: 446, height: 410 });\n      await resize(\"w\", { x: 25, y: 5 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 135, y: 16, width: 421, height: 410 });\n      await resize(\"w\", { x: -25 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 110, y: 16, width: 446, height: 410 });\n      await resize(\"nw\", { x: -5, y: -5 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 105, y: 16, width: 451, height: 410 });\n      await resize(\"ne\", { x: 5, y: 5 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 105, y: 21, width: 456, height: 405 });\n      await resize(\"e\", { x: 20, y: 20 });\n      dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 105, y: 21, width: 476, height: 405 });\n    });\n\n    it(\"saves last open position and size\", async function() {\n      await move({ x: -84, y: 200 });\n      const dims = await driver.find(\".test-doc-tutorial-popup\").getRect();\n      assert.deepEqual(dims, { x: 21, y: 221, width: 476, height: 405 });\n      await driver.navigate().refresh();\n      await gu.waitForDocToLoad();\n      assert.deepEqual(dims, await driver.find(\".test-doc-tutorial-popup\").getRect());\n    });\n\n    it(\"is visible on all pages\", async function() {\n      for (const page of [\"access-rules\", \"raw\", \"code\", \"settings\"]) {\n        await driver.find(`.test-tools-${page}`).click();\n        if ([\"access-rules\", \"code\"].includes(page)) { await gu.waitForServer(); }\n        assert.isTrue(await driver.find(\".test-doc-tutorial-popup\").isDisplayed());\n      }\n    });\n\n    it(\"does not break navigation via browser history\", async function() {\n      // Navigating via browser history was partially broken at one point; if you\n      // started a tutorial, and wanted to leave via the browser's back button, you\n      // couldn't.\n      for (const page of [\"code\", \"data\", \"acl\"]) {\n        await driver.navigate().back();\n        const currentUrl = await driver.getCurrentUrl();\n        assert.match(currentUrl, new RegExp(`/p/${page}$`));\n      }\n\n      await driver.navigate().back();\n      await driver.navigate().back();\n      await driver.findWait(\".test-dm-doclist\", 2000);\n\n      await driver.navigate().forward();\n      await gu.waitForDocToLoad();\n      assert.isTrue(await driver.findWait(\".test-doc-tutorial-popup\", 2000).isDisplayed());\n    });\n\n    it(\"shows the GristDocTutorial page and table to editors\", async function() {\n      assert.deepEqual(await gu.getPageNames(), [\"Page 1\", \"Page 2\", \"GristDocTutorial\"]);\n      await driver.find(\".test-tools-raw\").click();\n      await driver.findWait(\".test-raw-data-list\", 1000);\n      await gu.waitForServer();\n      assert.isTrue(await driver.findContent(\".test-raw-data-table-id\",\n        /GristDocTutorial/).isPresent());\n    });\n\n    it(\"does not show the GristDocTutorial page or table to non-editors\", async function() {\n      viewerSession = await gu.session().customTeamSite(\"templates\").user(\"user2\").login();\n      await viewerSession.loadDoc(`/doc/grist-basics`);\n      assert.deepEqual(await gu.getPageNames(), [\"Page 1\", \"Page 2\"]);\n      await driver.find(\".test-tools-raw\").click();\n      await driver.findWait(\".test-raw-data-list\", 1000);\n      await gu.waitForServer();\n      assert.isFalse(await driver.findContent(\".test-raw-data-table-id\",\n        /GristDocTutorial/).isPresent());\n    });\n\n    it(\"does not show behavioral tips\", async function() {\n      await gu.openPage(\"Page 1\");\n      await gu.openAddWidgetToPage();\n      assert.equal(await driver.find(\".test-behavioral-prompt\").isPresent(), false);\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"only allows users access to their own forks\", async function() {\n      await driver.navigate().to(forkUrl);\n      assert.match(await driver.findWait(\".test-error-header\", 2000).getText(), /Access denied/);\n      await viewerSession.loadDoc(`/doc/grist-basics`);\n      let otherForkUrl: string;\n      await driver.wait(async () => {\n        otherForkUrl = await driver.getCurrentUrl();\n        return forkUrl.includes(\"~\");\n      });\n      editorSession = await gu.session().customTeamSite(\"templates\").user(\"user1\").login();\n      await driver.navigate().to(otherForkUrl!);\n      assert.match(await driver.findWait(\".test-error-header\", 2000).getText(), /Access denied/);\n      await driver.navigate().to(forkUrl);\n      await gu.waitForDocToLoad();\n    });\n\n    it(\"supports navigating to the next or previous slide\", async function() {\n      await driver.findWait(\".test-doc-tutorial-popup\", 2000);\n      assert.isTrue(await driver.findWait(\".test-doc-tutorial-popup-next\", 2000).isDisplayed());\n      assert.isFalse(await driver.find(\".test-doc-tutorial-popup-previous\").isDisplayed());\n      await driver.find(\".test-doc-tutorial-popup-next\").click();\n      assert.equal(\n        await driver.find(\".test-doc-tutorial-popup h1\").getText(),\n        \"Pages\",\n      );\n      assert.equal(\n        await driver.find(\".test-doc-tutorial-popup h1 + p\").getText(),\n        \"On the left-side panel is a list of pages, which are views of your data. \" +\n        \"Right now, there are two pages: Page 1 and Page 2. You are looking at Page 1.\",\n      );\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-next\").isDisplayed());\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-previous\").isDisplayed());\n\n      await driver.find(\".test-doc-tutorial-popup-previous\").click();\n      assert.equal(\n        await driver.find(\".test-doc-tutorial-popup h1\").getText(),\n        \"Intro\",\n      );\n      assert.match(\n        await driver.find(\".test-doc-tutorial-popup\").getText(),\n        /Welcome to the Grist Basics tutorial/,\n      );\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-next\").isDisplayed());\n      assert.isFalse(await driver.find(\".test-doc-tutorial-popup-previous\").isDisplayed());\n    });\n\n    it(\"supports navigating to a specific slide\", async function() {\n      const slide3 = await driver.find(\".test-doc-tutorial-popup-slide-3\");\n      await slide3.mouseMove();\n      await gu.waitToPass(\n        async () => assert.isTrue(await driver.find(\".test-tooltip\").isDisplayed()),\n        500,\n      );\n      assert.equal(await driver.find(\".test-tooltip\").getText(), \"Adding Columns and Rows\");\n      await slide3.click();\n      await driver.find(\".test-doc-tutorial-popup-slide-3\").click();\n      assert.equal(\n        await driver.find(\".test-doc-tutorial-popup h1\").getText(),\n        \"Adding Columns and Rows\",\n      );\n      assert.equal(\n        await driver.find(\".test-doc-tutorial-popup p\").getText(),\n        \"Let's start with the basics of working with spreadsheet data — columns and rows.\",\n      );\n\n      const slide1 = await driver.find(\".test-doc-tutorial-popup-slide-1\");\n      await slide1.mouseMove();\n      await gu.waitToPass(\n        async () => assert.isTrue(await driver.find(\".test-tooltip\").isDisplayed()),\n        500,\n      );\n      assert.equal(await driver.find(\".test-tooltip\").getText(), \"Intro\");\n      await slide1.click();\n      assert.equal(\n        await driver.find(\".test-doc-tutorial-popup h1\").getText(),\n        \"Intro\",\n      );\n      assert.match(\n        await driver.find(\".test-doc-tutorial-popup\").getText(),\n        /Welcome to the Grist Basics tutorial/,\n      );\n    });\n\n    it(\"can open images in a lightbox\", async function() {\n      await driver.find(\".test-doc-tutorial-popup img\").click();\n      assert.isTrue(await driver.find(\".test-doc-tutorial-lightbox\").isDisplayed());\n      assert.equal(\n        await driver.find(\".test-doc-tutorial-lightbox-image\").getAttribute(\"src\"),\n        \"https://www.getgrist.com/wp-content/uploads/2023/11/Row-1-Intro.png\",\n      );\n      await driver.find(\".test-doc-tutorial-lightbox-close\").click();\n      assert.isFalse(await driver.find(\".test-doc-tutorial-lightbox\").isPresent());\n    });\n\n    it(\"can be minimized and maximized by clicking a button in the header\", async function() {\n      await driver.find(\".test-doc-tutorial-popup-minimize-maximize\").click();\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-header\").isDisplayed());\n      assert.isFalse(await driver.find(\".test-doc-tutorial-popup-body\").isDisplayed());\n      assert.isFalse(await driver.find(\".test-doc-tutorial-popup-footer\").isDisplayed());\n\n      await driver.find(\".test-doc-tutorial-popup-minimize-maximize\").click();\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-header\").isDisplayed());\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-body\").isDisplayed());\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-footer\").isDisplayed());\n    });\n\n    it(\"can be minimized and maximized by double clicking the header\", async function() {\n      await driver.withActions(a => a.doubleClick(driver.find(\".test-doc-tutorial-popup-title\")));\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-header\").isDisplayed());\n      assert.isFalse(await driver.find(\".test-doc-tutorial-popup-body\").isDisplayed());\n      assert.isFalse(await driver.find(\".test-doc-tutorial-popup-footer\").isDisplayed());\n\n      await driver.withActions(a => a.doubleClick(driver.find(\".test-doc-tutorial-popup-title\")));\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-header\").isDisplayed());\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-body\").isDisplayed());\n      assert.isTrue(await driver.find(\".test-doc-tutorial-popup-footer\").isDisplayed());\n    });\n\n    it(\"does not play an easter egg when opening an anchor link encoded with rr\", async function() {\n      await gu.getCell({ rowNum: 1, col: 0 }).click();\n      const link = await gu.getAnchor();\n      const easterEggLink = link.replace(\".r1\", \".rr1\");\n      await driver.get(easterEggLink);\n      await gu.waitForAnchor();\n      assert.isFalse(await driver.find(\".test-behavioral-prompt\").isPresent());\n      await gu.assertIsRickRowing(false);\n    });\n\n    it(\"remembers the last slide the user had open\", async function() {\n      await driver.find(\".test-doc-tutorial-popup-slide-3\").click();\n      // There's a 1000ms debounce in place when updating tutorial progress.\n      await driver.sleep(1000 + 250);\n      await gu.waitForServer();\n      await driver.navigate().refresh();\n      await gu.waitForDocToLoad();\n      await driver.findWait(\".test-doc-tutorial-popup\", 2000);\n      assert.equal(\n        await driver.findWait(\".test-doc-tutorial-popup h1\", 2000).getText(),\n        \"Adding Columns and Rows\",\n      );\n      assert.equal(\n        await driver.find(\".test-doc-tutorial-popup p\").getText(),\n        \"Let's start with the basics of working with spreadsheet data — columns and rows.\",\n      );\n    });\n\n    it(\"always opens the same fork whenever the document is opened\", async function() {\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0], rowNums: [1] }), [\"Zane Rails\"]);\n      await gu.getCell(0, 1).click();\n      await gu.sendKeys(\"Redacted\", Key.ENTER);\n      await gu.waitForServer();\n      await editorSession.loadDoc(`/doc/grist-basics`);\n      let currentUrl: string;\n      await driver.wait(async () => {\n        currentUrl = await driver.getCurrentUrl();\n        return forkUrl.includes(\"~\");\n      });\n      assert.equal(currentUrl!, forkUrl);\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0], rowNums: [1] }), [\"Redacted\"]);\n    });\n\n    it(\"tracks completion percentage\", async function() {\n      await driver.find(\".test-doc-tutorial-popup-end-tutorial\").click();\n      await gu.waitForServer();\n      await gu.waitForDocMenuToLoad();\n      await gu.waitToPass(async () =>\n        assert.equal(await driver.find(\".test-intro-tutorial-percent-complete\").getText(), \"15%\"),\n      2000,\n      );\n      await driver.find(\".test-dm-basic-tutorial\").click();\n      await gu.waitForDocToLoad();\n    });\n\n    it(\"skips starting or resuming a tutorial if the open mode is set to default\", async function() {\n      ownerSession = await gu.session().customTeamSite(\"templates\").user(\"support\").login();\n      await ownerSession.loadDoc(`/doc/grist-basics/m/default`);\n      assert.deepEqual(await gu.getPageNames(), [\"Page 1\", \"Page 2\", \"GristDocTutorial\"]);\n      await driver.find(\".test-tools-raw\").click();\n      await gu.waitForServer();\n      assert.isTrue(await driver.findContentWait(\".test-raw-data-table-id\",\n        /GristDocTutorial/, 2000).isPresent());\n      assert.isFalse(await driver.find(\".test-doc-tutorial-popup\").isPresent());\n    });\n\n    it(\"can restart tutorials\", async function() {\n      // Update the tutorial as the owner.\n      await driver.find(\".test-bc-doc\").doClick();\n      await driver.sendKeys(\"DocTutorial V2\", Key.ENTER);\n      await gu.waitForServer();\n      await gu.addNewTable();\n\n      // Switch back to the editor's fork of the tutorial.\n      editorSession = await gu.session().customTeamSite(\"templates\").user(\"user1\").login();\n      await driver.navigate().to(forkUrl);\n      await gu.waitForDocToLoad();\n      await driver.findWait(\".test-doc-tutorial-popup\", 2000);\n\n      // Check that the new table isn't in the fork.\n      assert.deepEqual(await gu.getPageNames(), [\"Page 1\", \"Page 2\", \"GristDocTutorial\"]);\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0], rowNums: [1] }), [\"Redacted\"]);\n\n      // Restart the tutorial.\n      await driver.find(\".test-doc-tutorial-popup-restart\").click();\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n      await driver.findWait(\".test-doc-tutorial-popup\", 2000);\n\n      // Check that progress was reset.\n      assert.equal(\n        await driver.findWait(\".test-doc-tutorial-popup h1\", 2000).getText(),\n        \"Intro\",\n      );\n      assert.match(\n        await driver.find(\".test-doc-tutorial-popup\").getText(),\n        /Welcome to the Grist Basics tutorial/,\n      );\n\n      // Check that edits were reset.\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0], rowNums: [1] }), [\"Zane Rails\"]);\n\n      // Check that changes made to the tutorial since it was last started are included.\n      assert.equal(await driver.find(\".test-doc-tutorial-popup-header\").getText(),\n        \"DocTutorial V2\");\n      assert.deepEqual(await gu.getPageNames(), [\"Page 1\", \"Page 2\", \"GristDocTutorial\", \"Table1\"]);\n    });\n\n    it(\"allows owners to replace original\", async function() {\n      ownerSession = await gu.session().customTeamSite(\"templates\").user(\"support\").login();\n      await ownerSession.loadDoc(`/doc/grist-basics`);\n\n      // Make an edit to one of the tutorial slides.\n      await gu.openPage(\"GristDocTutorial\");\n      await gu.getCell(1, 1).click();\n      await gu.sendKeys(\n        \"# Intro\",\n        Key.chord(Key.SHIFT, Key.ENTER),\n        Key.chord(Key.SHIFT, Key.ENTER),\n        \"Welcome to the Grist Basics tutorial V2.\",\n        Key.ENTER,\n      );\n      await gu.waitForServer();\n\n      // Check that the update is immediately reflected in the tutorial popup.\n      assert.equal(\n        await driver.findWait(\".test-doc-tutorial-popup p\", 2000).getText(),\n        \"Welcome to the Grist Basics tutorial V2.\",\n      );\n\n      // Replace the original via the Share menu.\n      await driver.find(\".test-tb-share\").click();\n      await driver.find(\".test-replace-original\").click();\n      await driver.findWait(\".test-modal-confirm\", 3000).click();\n      await gu.waitForServer();\n\n      // Switch to another user and restart the tutorial.\n      viewerSession = await gu.session().customTeamSite(\"templates\").user(\"user2\").login();\n      await viewerSession.loadDoc(`/doc/grist-basics`);\n      await driver.findWait(\".test-doc-tutorial-popup-restart\", 2000).click();\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n      await driver.findWait(\".test-doc-tutorial-popup\", 2000);\n\n      // Check that the changes we made earlier are included.\n      assert.equal(\n        await driver.findWait(\".test-doc-tutorial-popup p\", 2000).getText(),\n        \"Welcome to the Grist Basics tutorial V2.\",\n      );\n    });\n\n    it(\"redirects to the last visited site when finished\", async function() {\n      const otherSiteSession = await gu.session().personalSite.user(\"user1\").addLogin();\n      await otherSiteSession.loadDocMenu(\"/\");\n      await ownerSession.loadDoc(`/doc/grist-basics`);\n      await driver.findWait(\".test-doc-tutorial-popup-slide-13\", 2000).click();\n      await driver.find(\".test-doc-tutorial-popup-next\").click();\n      await gu.waitForDocMenuToLoad();\n      assert.match(await driver.getCurrentUrl(), /o\\/docs\\/$/);\n      await gu.waitToPass(async () =>\n        assert.equal(await driver.find(\".test-intro-tutorial-percent-complete\").getText(), \"0%\"),\n      2000,\n      );\n      await ownerSession.loadDocMenu(\"/\");\n      await gu.waitToPass(async () =>\n        assert.equal(await driver.find(\".test-intro-tutorial-percent-complete\").getText(), \"100%\"),\n      2000,\n      );\n    });\n  });\n\n  describe(\"without tutorial flag set\", function() {\n    before(async () => {\n      await api.updateDoc(\"grist-basics\", { type: null });\n      ownerSession = await gu.session().customTeamSite(\"templates\").user(\"support\").login();\n      await ownerSession.loadDoc(`/doc/grist-basics`);\n    });\n\n    afterEach(() => gu.checkForErrors());\n\n    it(\"shows the GristDocTutorial page and table\", async function() {\n      assert.deepEqual(await gu.getPageNames(),\n        [\"Page 1\", \"Page 2\", \"GristDocTutorial\", \"Table1\"]);\n      await gu.openPage(\"GristDocTutorial\");\n      assert.deepEqual(\n        await gu.getVisibleGridCells({ cols: [1], rowNums: [1] }),\n        [\n          \"# Intro\\n\\nWelcome to the Grist Basics tutorial V2.\",\n        ],\n      );\n      await driver.find(\".test-tools-raw\").click();\n      await gu.waitForServer();\n      assert.isTrue(await driver.findContentWait(\".test-raw-data-table-id\",\n        /GristDocTutorial/, 2000).isPresent());\n    });\n\n    it(\"does not show the tutorial popup\", async function() {\n      assert.isFalse(await driver.find(\".test-doc-tutorial-popup\").isPresent());\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/DocTypeConversion.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport { Button, button, element, label, option } from \"test/nbrowser/elementUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, until } from \"mocha-webdriver\";\n\ntype TypeLabels = \"Regular\" | \"Template\" | \"Tutorial\";\n\ndescribe(\"DocTypeConversion\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  let userApi: UserAPI;\n  let docId: string;\n  let session: gu.Session;\n\n  before(async () => {\n    session = await gu.session().teamSite.login();\n    docId = await session.tempNewDoc(cleanup);\n    userApi = session.createHomeApi();\n  });\n\n  async function assertExistsButton(button: Button, text: string) {\n    await gu.waitToPass(async () => {\n      assert.equal(await button.element().getText(), text);\n    });\n    assert.isTrue(await button.visible());\n  }\n\n  async function checkDisplayedLabel(typeLabel: TypeLabels) {\n    // Wait in case of page reload\n    await displayedLabel.wait();\n\n    assert.equal(await displayedLabel.element().getText(), typeLabel);\n  }\n\n  async function convert(from: TypeLabels, to: TypeLabels) {\n    await gu.openDocumentSettings();\n\n    // Ensure that initial document type is the expected one.\n    const displayedLabelElement = displayedLabel.element();\n\n    // Check initially displayed label value\n    await checkDisplayedLabel(from);\n\n    // Click to open the modal\n    await editButton.click();\n\n    // Wait for modal.\n    await modal.wait();\n\n    // Select the desired Document type\n    await optionByLabel[to].click();\n\n    assert.isTrue(await optionByLabel[to]?.checked());\n\n    // Confirm the choice\n    await modalConfirm.click();\n\n    // Wait for the page to be unloaded\n    await driver.wait(until.stalenessOf(displayedLabelElement), 3000);\n\n    // checks that the displayedLabel is now equal to convert destination\n    await checkDisplayedLabel(to);\n  }\n\n  async function isRegular() {\n    assert.isFalse(await saveCopyButton.present());\n    assert.isFalse(await fiddleTag.present());\n  }\n\n  async function isTemplate() {\n    await assertExistsButton(saveCopyButton, \"Save copy\");\n    assert.isTrue(await fiddleTag.visible());\n  }\n\n  async function isTutorial() {\n    await assertExistsButton(saveCopyButton, \"Save copy\");\n    assert.isFalse(await fiddleTag.present());\n  }\n\n  it(\"should display the modal with only the current type selected\", async function() {\n    await gu.openDocumentSettings();\n    // Make sure we see the Edit button of document type conversion.\n    await assertExistsButton(editButton, \"Edit\");\n\n    // Check that Document type is Regular before any conversion was ever apply to It.\n    assert.equal(await displayedLabel.element().getText(), \"Regular\");\n\n    await editButton.click();\n\n    // Wait for modal.\n    await modal.wait();\n\n    // We have three options.\n    assert.isTrue(await optionRegular.visible());\n    assert.isTrue(await optionTemplate.visible());\n    assert.isTrue(await optionTutorial.visible());\n\n    // Regular is selected cause its the current mode.\n    assert.isTrue(await optionRegular.checked());\n    assert.isFalse(await optionTemplate.checked());\n    assert.isFalse(await optionTutorial.checked());\n\n    // check that cancel works\n    await modalCancel.click();\n    assert.isFalse(await modal.present());\n  });\n\n  // If the next six tests succeed so each document type can properly be converted to every other\n  it(\"should convert from Regular to Template\", async function() {\n    await convert(\"Regular\", \"Template\");\n    await isTemplate();\n  });\n\n  it(\"should convert from Template to Tutorial\", async function() {\n    await convert(\"Template\", \"Tutorial\");\n    await isTutorial();\n  });\n\n  it(\"should convert from Tutorial to Regular\", async function() {\n    await convert(\"Tutorial\", \"Regular\");\n    await isRegular();\n  });\n\n  it(\"should convert from Regular to Tutorial\", async function() {\n    await convert(\"Regular\", \"Tutorial\");\n    await isTutorial();\n  });\n\n  it(\"should convert from Tutorial to Template\", async function() {\n    await convert(\"Tutorial\", \"Template\");\n    await isTemplate();\n  });\n\n  it(\"should convert from Template to Regular\", async function() {\n    await convert(\"Template\", \"Regular\");\n    await isRegular();\n  });\n\n  it(\"should be disabled for non-owners\", async function() {\n    await userApi.updateDocPermissions(docId, { users: {\n      [gu.translateUser(\"user2\").email]: \"editors\",\n    } });\n\n    const session = await gu.session().teamSite.user(\"user2\").login();\n    await session.loadDoc(`/doc/${docId}`);\n    await driver.sleep(500);\n    await gu.openDocumentSettings();\n\n    const start = driver.find(\".test-settings-doctype-edit\");\n    assert.equal(await start.isPresent(), true);\n\n    // Check that we have an informative tooltip.\n    await start.mouseMove();\n    // Note that .test-tooltip may appear blank a first time,\n    // hence the necessity to use waitToPass instead of findWait.\n    await gu.waitToPass(async () => {\n      assert.match(await driver.find(\".test-tooltip\").getText(), /Only available to document owners/);\n    });\n\n    // Nothing should happen on click. We click the location rather than the element, since the\n    // element isn't actually clickable.\n    await start.mouseMove();\n    await driver.withActions(a => a.press().release());\n    await driver.sleep(100);\n\n    assert.equal(await driver.find(\".test-settings-doctype-modal\").isPresent(), false);\n  });\n});\n\nconst editButton = button(\".test-settings-doctype-edit\");\nconst saveCopyButton = button(\".test-tb-share-action\");\nconst displayedLabel = label(\".test-settings-doctype-value\");\nconst modal = element(\".test-settings-doctype-modal\");\nconst optionRegular = option(\".test-settings-doctype-modal-option-regular\");\nconst optionTemplate = option(\".test-settings-doctype-modal-option-template\");\nconst optionTutorial = option(\".test-settings-doctype-modal-option-tutorial\");\nconst optionByLabel = {\n  Tutorial: optionTutorial,\n  Template: optionTemplate,\n  Regular: optionRegular,\n};\nconst modalConfirm = button(\".test-settings-doctype-modal-confirm\");\nconst modalCancel = button(\".test-settings-doctype-modal-cancel\");\nconst fiddleTag = element(\".test-fiddle-tag\");\n"
  },
  {
    "path": "test/nbrowser/DocUsageTracking.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport {\n  enableExternalAttachmentsForTestSuite,\n} from \"test/nbrowser/externalAttachmentsHelpers\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server } from \"test/nbrowser/testServer\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\nimport fetch from \"node-fetch\";\n\ndescribe(\"DocUsageTracking\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  const ownerUser = \"user1\";\n  let api: UserAPI;\n  let session: gu.Session;\n\n  enableExternalAttachmentsForTestSuite({\n    thresholdMb: 0.5,\n  });\n\n  async function makeSessionAndLogin() {\n    session = await gu.session().user(ownerUser).login();\n    api = session.createHomeApi();\n  }\n\n  before(async function() {\n    await makeSessionAndLogin();\n  });\n\n  it(\"shows usage stats on the raw data page\", async function() {\n    await session.tempNewDoc(cleanup, \"EmptyUsageDoc\");\n\n    // Check that the Usage section exists.\n    await goToDocUsage();\n    assert.equal(await driver.find(\".test-doc-usage-heading\").getText(), \"Usage\");\n    await assertUsageMessage(null);\n\n    // Check that usage is at 0.\n    await assertRowCount(\"0\");\n    await assertDataSize(\"0.00\");\n    await assertAttachmentsSize(\"0.00\");\n\n    // Check that banners aren't shown on the raw data page.\n    await gu.assertBannerText(null);\n  });\n\n  function testAttachmentsUsage(getDocId: () => string, options: { external: boolean }) {\n    it(\"updates attachments size usage when uploading attachments\", async function() {\n      const docId = getDocId();\n      // Add a new 'Attachments' column of type Attachment to Table1.\n      await gu.sendActions([[\"AddEmptyTable\", \"AttachmentsTable\"]]);\n      await gu.getPageItem(\"AttachmentsTable\").click();\n      await gu.addColumn(\"Attachments\", \"Attachment\");\n\n      // Focus out of the creator panel\n      await driver.sendKeys(Key.ESCAPE, Key.ESCAPE);\n\n      // Upload some files into the first row. (We're putting Grist docs in a Grist doc!)\n      await driver.sendKeys(Key.ENTER);\n      await gu.fileDialogUpload(\n        \"docs/Covid-19.grist,docs/World-v0.grist,docs/World-v1.grist,docs/World-v3.grist,\" +\n        \"docs/Landlord.grist,docs/ImportReferences.grist,docs/WorldUndo.grist,\" +\n        \"docs/Ref-List-AC-Test.grist,docs/PasteParsing.grist\",\n        () => driver.find(\".test-pw-add\").click(),\n      );\n      // Check all 9 attachments have uploaded.\n      await driver.findContentWait(\".test-pw-counter\", /of 9/, 4000);\n      await driver.find(\".test-modal-dialog .test-pw-close\").click();\n      await gu.waitForServer();\n\n      // Navigate back to the raw data page, and check that attachments size updated.\n      await goToDocUsage();\n      await assertDataSize(\"0.00\");\n      await assertAttachmentsSize(\"0.01\");\n\n      // Check nudges appeared if not using external storage.\n      if (!options.external) {\n        await driver.findWait(\".test-top-panel\", 100);\n        assert.equal(await driver.find(\".test-external-attachment-banner-text\").isDisplayed(), true);\n        assert.match(await driver.find(\".test-doc-usage-message-text\").getText(), /Set the document to use external/);\n        // The link in the banner is implemented as a span.\n        await driver.find(\".test-external-attachment-banner-text span\").click();\n        // Make sure preferredStorage is present now and get its classes.\n        const classes = await driver.findWait(\"#preferredStorage\", 1000).getAttribute(\"class\");\n        // Check that element has a -flash class, meaning it was highlighted.\n        assert.equal(classes.includes(\"-flash\"), true);\n        await driver.navigate().back();\n      } else {\n        await driver.findWait(\".test-top-panel\", 100);\n        assert.equal(await driver.find(\".test-external-attachment-banner-text\").isPresent(), false);\n        assert.equal(await driver.find(\".test-doc-usage-message-text\").isPresent(), false);\n      }\n\n      // Delete the 'Attachments' column; usage should not immediately update.\n      await api.applyUserActions(docId, [[\"RemoveColumn\", \"AttachmentsTable\", \"Attachments\"]]);\n      await assertDataSize(\"0.00\");\n      await assertAttachmentsSize(\"0.01\");\n\n      // Remove unused attachments via API and check that size automatically updates to 0.\n      await removeUnusedAttachments(api, docId);\n      await assertDataSize(\"0.00\");\n      await assertAttachmentsSize(\"0.00\");\n    });\n  }\n\n  describe(\"attachment usage without external attachments\", function() {\n    let docId: string;\n\n    before(async () => {\n      docId = await session.tempNewDoc(cleanup, `AttachmentUsageTestDoc - internal`);\n    });\n\n    testAttachmentsUsage(() => docId, { external: false });\n  });\n\n  describe(\"attachment usage with external attachments\", function() {\n    let docId: string;\n\n    before(async () => {\n      docId = await session.tempNewDoc(cleanup, `AttachmentUsageTestDoc - external`);\n      const docApi = api.getDocAPI(docId);\n      await docApi.setAttachmentStore(\"external\");\n      assert.equal((await docApi.getAttachmentStore()).type, \"external\");\n    });\n\n    testAttachmentsUsage(() => docId, { external: true });\n  });\n});\n\nasync function goToDocUsage() {\n  await driver.findWait(\".test-tools-raw\", 2000).click();\n\n  // Check that the Usage section exists.\n  await waitForDocUsage();\n}\n\nasync function assertUsageMessage(text: string | null) {\n  if (text === null) {\n    assert.isFalse(await driver.find(\".test-doc-usage-message\").isPresent());\n  } else {\n    assert.equal(await driver.findWait(\".test-doc-usage-message-text\", 2000).getText(), text);\n  }\n}\n\nasync function assertRowCount(currentValue: string, maximumValue?: string) {\n  await gu.waitToPass(async () => {\n    const rowUsage = await driver.find(\".test-doc-usage-rows .test-doc-usage-value\").getText();\n    const [, foundValue, foundMax] = rowUsage.match(/([0-9,]+) (?:of ([0-9,]+) )?rows/) || [];\n    assert.equal(foundValue, currentValue);\n    if (maximumValue) {\n      assert.equal(foundMax, maximumValue);\n    }\n  });\n}\n\nasync function assertDataSize(currentValue: string, maximumValue?: string) {\n  await gu.waitToPass(async () => {\n    const dataUsage = await driver.find(\".test-doc-usage-data-size .test-doc-usage-value\").getText();\n    const [, foundValue, foundMax] = dataUsage.match(/([0-9,.]+) (?:of ([0-9,.]+) )?MB/) || [];\n    assert.equal(foundValue, currentValue);\n    if (maximumValue) {\n      assert.equal(foundMax, maximumValue);\n    }\n  });\n}\n\nasync function assertAttachmentsSize(currentValue: string, maximumValue?: string) {\n  await gu.waitToPass(async () => {\n    const attachmentUsage = await driver.find(\".test-doc-usage-attachments-size .test-doc-usage-value\").getText();\n    const [, foundValue, foundMax] = attachmentUsage.match(/([0-9,.]+) (?:of ([0-9,.]+) )?GB/) || [];\n    assert.equal(foundValue, currentValue);\n    if (maximumValue) {\n      assert.equal(foundMax, maximumValue);\n    }\n  });\n}\n\nasync function waitForDocUsage() {\n  await driver.findWait(\".test-doc-usage-container\", 8000);\n  await gu.waitToPass(async () => {\n    return assert.isFalse(await driver.find(\".test-doc-usage-loading\").isPresent());\n  });\n}\n\nasync function removeUnusedAttachments(api: UserAPI, docId: string) {\n  const headers = { Authorization: `Bearer ${await api.fetchApiKey()}` };\n  const url = server.getUrl(\"docs\", `/api/docs/${docId}`);\n  await fetch(url + \"/attachments/removeUnused?verifyfiles=0&expiredonly=0\", {\n    headers,\n    method: \"POST\",\n  });\n}\n"
  },
  {
    "path": "test/nbrowser/DropdownConditionEditor.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport { startEditingAccessRules } from \"test/nbrowser/aclTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"DropdownConditionEditor\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let api: UserAPI;\n  let docId: string;\n\n  before(async () => {\n    const session = await gu.session().user(\"user1\").login();\n    api = session.createHomeApi();\n    docId = (await session.tempDoc(cleanup, \"DropdownCondition.grist\")).id;\n    await api.updateDocPermissions(docId, { users: {\n      [gu.translateUser(\"user2\").email]: \"editors\",\n    } });\n    await addUserAttributes();\n    await gu.openPage(\"Employees\");\n    await gu.openColumnPanel();\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  async function addUserAttributes() {\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"Roles\", [{ id: \"Email\" }, { id: \"Admin\", type: \"Bool\" }]],\n      [\"AddRecord\", \"Roles\", null, { Email: gu.translateUser(\"user1\").email, Admin: true }],\n      [\"AddRecord\", \"Roles\", null, { Email: gu.translateUser(\"user2\").email, Admin: false }],\n    ]);\n    await startEditingAccessRules();\n    await driver.findContentWait(\"button\", /Add user attributes/, 2000).click();\n    const userAttrRule = await driver.find(\".test-rule-userattr\");\n    await userAttrRule.find(\".test-rule-userattr-name\").click();\n    await driver.sendKeys(\"Roles\", Key.ENTER);\n    await userAttrRule.find(\".test-rule-userattr-attr\").click();\n    await driver.sendKeys(\"Email\", Key.ENTER);\n    await userAttrRule.find(\".test-rule-userattr-table\").click();\n    await gu.findOpenMenuItem(\"li\", \"Roles\").click();\n    await userAttrRule.find(\".test-rule-userattr-col\").click();\n    await driver.sendKeys(\"Email\", Key.ENTER);\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n  }\n\n  describe(`in choice columns`, function() {\n    before(async () => {\n      const session = await gu.session().user(\"user1\").login();\n      await session.loadDoc(`/doc/${docId}`);\n    });\n\n    it(\"creates dropdown conditions\", async function() {\n      await gu.getCell(1, 1).click();\n      await driver.find(\".test-field-dropdown-condition\").click();\n      await gu.waitAppFocus(false);\n      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, \"c\");\n      await gu.waitToPass(async () => {\n        const completions = await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText());\n        assert.deepEqual(completions, [\n          \"c\\nhoice\\n \",\n          \"re\\nc\\n.Name\\n \",\n          \"re\\nc\\n.Role\\n \",\n          \"re\\nc\\n.Supervisor\\n \",\n          \"user.A\\nc\\ncess\\n \",\n        ]);\n      });\n      await gu.sendKeysSlowly(\"hoice not in \");\n      // Attempts to reduce test flakiness by delaying input of $. Not guaranteed to do anything.\n      await driver.sleep(100);\n      await gu.sendKeys(\"$\");\n      await gu.waitToPass(async () => {\n        // This test is sometimes flaky here. It will consistently return the wrong value, usually an array of\n        // empty strings. The running theory is it's an issue in Ace editor.\n        const completions = await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText());\n        assert.deepEqual(completions, [\n          \"$\\nName\\n \",\n          \"$\\nRole\\n \",\n          \"$\\nSupervisor\\n \",\n        ]);\n      });\n      await gu.sendKeys(\"Role\", Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(\n        await driver.find(\".test-field-dropdown-condition\").getText(),\n        \"choice not in $Role\",\n      );\n\n      // Check that autocomplete values are filtered.\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Supervisor\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n      await gu.getCell(1, 4).click();\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Trainee\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n      await gu.getCell(1, 6).click();\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Trainee\",\n        \"Supervisor\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Change the column type to Choice List and check values are still filtered.\n      await gu.setType(\"Choice List\", { apply: true });\n      assert.equal(\n        await driver.find(\".test-field-dropdown-condition\").getText(),\n        \"choice not in $Role\",\n      );\n      await gu.getCell(1, 4).click();\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Trainee\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"removes dropdown conditions\", async function() {\n      await driver.find(\".test-field-dropdown-condition\").click();\n      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, Key.ENTER);\n      await gu.waitForServer();\n\n      // Check that autocomplete values are no longer filtered.\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Trainee\",\n        \"Supervisor\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Change the column type back to Choice and check values are still no longer filtered.\n      await gu.setType(\"Choice\", { apply: true });\n      assert.isFalse(await driver.find(\".test-field-dropdown-condition\").isPresent());\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Supervisor\",\n        \"Trainee\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"reports errors\", async function() {\n      // Check syntax errors are reported, but not saved.\n      await driver.find(\".test-field-set-dropdown-condition\").click();\n      await gu.sendKeys(\"!@#$%^\", Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(\n        await driver.findWait(\".test-field-dropdown-condition-error\", 500).getText(),\n        \"SyntaxError invalid syntax on line 1 col 1\",\n      );\n      await gu.reloadDoc();\n      assert.isFalse(await driver.find(\".test-field-dropdown-condition-error\").isPresent());\n\n      // Check compilation errors are reported and saved.\n      await driver.find(\".test-field-set-dropdown-condition\").click();\n      await gu.sendKeys(\"foo\", Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(\n        await driver.find(\".test-field-dropdown-condition-error\").getText(),\n        \"Unknown variable 'foo'\",\n      );\n      await gu.reloadDoc();\n      assert.equal(\n        await driver.find(\".test-field-dropdown-condition-error\").getText(),\n        \"Unknown variable 'foo'\",\n      );\n\n      // Check that the autocomplete dropdown also reports an error.\n      await gu.sendKeys(Key.ENTER);\n      assert.equal(\n        await driver.find(\".test-autocomplete-no-items-message\").getText(),\n        \"Error in dropdown condition\",\n      );\n      await gu.sendKeys(Key.ESCAPE);\n    });\n  });\n\n  describe(`in reference columns`, function() {\n    before(async () => {\n      const session = await gu.session().user(\"user1\").login();\n      await session.loadDoc(`/doc/${docId}`);\n    });\n\n    it(\"creates dropdown conditions\", async function() {\n      await gu.getCell(2, 1).click();\n      assert.isFalse(await driver.find(\".test-field-dropdown-condition\").isPresent());\n      await driver.find(\".test-field-set-dropdown-condition\").click();\n      await gu.waitAppFocus(false);\n      await gu.sendKeysSlowly(\"choice\");\n      await gu.waitToPass(async () => {\n        const completions = await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText());\n        assert.deepEqual(completions, [\n          \"choice\\n \",\n          \"choice\\n.id\\n \",\n          \"choice\\n.Name\\n \",\n          \"choice\\n.Role\\n \",\n          \"choice\\n.Supervisor\\n \",\n        ]);\n      });\n      await gu.sendKeys('.Role == \"Supervisor\" and $Role != \"Supervisor\" and $id != 2', Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(\n        await driver.find(\".test-field-dropdown-condition .ace_line\").getAttribute(\"textContent\"),\n        'choice.Role == \"Supervisor\" and $Role != \"Supervisor\" and $id != 2\\n',\n      );\n\n      // Check that autocomplete values are filtered.\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Pavan Madilyn\",\n        \"Marie Ziyad\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Should be no options on row 2 because of $id != 2 part of condition.\n      await gu.getCell(2, 2).click();\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Row 3 should be like row 1.\n      await gu.getCell(2, 3).click();\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Pavan Madilyn\",\n        \"Marie Ziyad\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n\n      await gu.getCell(2, 4).click();\n      await gu.sendKeys(Key.ENTER);\n      assert.isEmpty(await driver.findAll(\".test-autocomplete li\", el => el.getText()));\n      await gu.sendKeys(Key.ESCAPE);\n      await gu.getCell(2, 6).click();\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Marie Ziyad\",\n        \"Pavan Madilyn\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Change the column type to Reference List and check values are still filtered.\n      await gu.setType(\"Reference List\", { apply: true });\n      assert.equal(\n        await driver.find(\".test-field-dropdown-condition .ace_line\").getAttribute(\"textContent\"),\n        'choice.Role == \"Supervisor\" and $Role != \"Supervisor\" and $id != 2\\n',\n      );\n      await gu.getCell(2, 4).click();\n      await gu.sendKeys(Key.ENTER);\n      assert.isEmpty(await driver.findAll(\".test-autocomplete li\", el => el.getText()));\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"removes dropdown conditions\", async function() {\n      await driver.find(\".test-field-dropdown-condition\").click();\n      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, Key.ENTER);\n      await gu.waitForServer();\n\n      // Check that autocomplete values are no longer filtered.\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Emma Thamir\",\n        \"Holger Klyment\",\n        \"Marie Ziyad\",\n        \"Olivier Bipin\",\n        \"Pavan Madilyn\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Change the column type back to Reference and check values are still no longer filtered.\n      await gu.setType(\"Reference\", { apply: true });\n      assert.isFalse(await driver.find(\".test-field-dropdown-condition\").isPresent());\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n        \"Emma Thamir\",\n        \"Holger Klyment\",\n        \"Marie Ziyad\",\n        \"Olivier Bipin\",\n        \"Pavan Madilyn\",\n      ]);\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"reports errors\", async function() {\n      // Check syntax errors are reported, but not saved.\n      await driver.find(\".test-field-set-dropdown-condition\").click();\n      await gu.sendKeys(\"!@#$%^\", Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(\n        await driver.find(\".test-field-dropdown-condition-error\").getText(),\n        \"SyntaxError invalid syntax on line 1 col 1\",\n      );\n      await gu.reloadDoc();\n      assert.isFalse(await driver.find(\".test-field-dropdown-condition-error\").isPresent());\n\n      // Check compilation errors are reported and saved.\n      await driver.find(\".test-field-set-dropdown-condition\").click();\n      await driver.findWait(\".cell_editor\", 100);\n      await gu.sendKeys(\"foo\", Key.ENTER);\n      await gu.waitForServer();\n\n      assert.equal(\n        await driver.find(\".test-field-dropdown-condition-error\").getText(),\n        \"Unknown variable 'foo'\",\n      );\n      await gu.reloadDoc();\n      assert.equal(\n        await driver.find(\".test-field-dropdown-condition-error\").getText(),\n        \"Unknown variable 'foo'\",\n      );\n\n      // Check that the autocomplete dropdown also reports an error.\n      await gu.sendKeys(Key.ENTER);\n\n      assert.equal(\n        await driver.findWait(\".test-autocomplete-no-items-message\", 100).getText(),\n        \"Error in dropdown condition\",\n      );\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Check evaluation errors are also reported in the dropdown.\n      await driver.find(\".test-field-dropdown-condition\").click();\n      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, \"[].upper() not in 5\", Key.ENTER);\n      await gu.waitForServer();\n      await gu.sendKeys(Key.ENTER);\n\n      assert.equal(\n        await driver.findWait(\".test-autocomplete-no-items-message\", 100).getText(),\n        \"Error in dropdown condition\",\n      );\n      await gu.sendKeys(Key.ESCAPE);\n    });\n  });\n\n  it(\"supports user variable\", async function() {\n    // Filter dropdown values based on a user attribute.\n    await gu.getCell(1, 1).click();\n    await driver.find(\".test-field-set-dropdown-condition\").click();\n    await gu.waitAppFocus(false);\n    await gu.sendKeysSlowly(\"user.\");\n    await gu.waitToPass(async () => {\n      const completions = await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText());\n      assert.deepEqual(completions, [\n        \"user.\\nAccess\\n \",\n        \"user.\\nEmail\\n \",\n        \"user.\\nIsLoggedIn\\n \",\n        \"user.\\nLinkKey.\\n \",\n        \"user.\\nName\\n \",\n        \"user.\\nOrigin\\n \",\n        \"user.\\nRoles.Admin\\n \",\n        \"user.\\nRoles.Email\\n \",\n        \"\",\n      ]);\n    });\n    await gu.sendKeys(\"Roles.Admin == True\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Check that user1 (who is an admin) can see dropdown values.\n    await gu.sendKeys(Key.ENTER);\n    await waitForDropdown();\n\n    assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), [\n      \"Trainee\",\n      \"Supervisor\",\n    ]);\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Switch to user2 (who is not an admin), and check that they can't see any dropdown values.\n    const session = await gu.session().user(\"user2\").login();\n    await session.loadDoc(`/doc/${docId}`);\n    await gu.getCell(1, 1).click();\n    await gu.sendKeys(Key.ENTER);\n    assert.deepEqual(await driver.findAll(\".test-autocomplete li\", el => el.getText()), []);\n    await gu.sendKeys(Key.ESCAPE);\n  });\n});\n\nasync function waitForDropdown() {\n  await driver.findWait(\".test-autocomplete li\", 100);\n}\n"
  },
  {
    "path": "test/nbrowser/DuplicateDocument.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"DuplicateDocument\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite({ team: true });\n\n  it(\"should duplicate a document with the Duplicate-Document option\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"Hello.grist\");\n    await session.tempWorkspace(cleanup, \"Test Workspace\");\n\n    // Open the share menu and click item to work on a copy.\n    await driver.find(\".test-tb-share\").click();\n    await driver.find(\".test-save-copy\").click();\n\n    // Should not allow saving a copy to an empty name.\n    await driver.findWait(\".test-modal-dialog\", 1000);\n    const nameElem = await driver.findWait(\".test-copy-dest-name:focus\", 200);\n    await nameElem.sendKeys(Key.DELETE);\n    assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n    await nameElem.sendKeys(\"  \");\n    assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n    // As soon as the textbox is non-empty, the Save button should become enabled.\n    await nameElem.sendKeys(\"a\");\n    await driver.findWait(\".test-modal-confirm:not(:disabled)\", 5000);\n\n    // Save a copy with a proper name.\n    await gu.completeCopy({ destName: \"DuplicateTest1\", destWorkspace: \"Test Workspace\" });\n\n    // check the breadcrumbs reflect new document name, and the doc is not empty.\n    assert.equal(await driver.find(\".test-bc-doc\").value(), \"DuplicateTest1\");\n    assert.equal(await gu.getCell({ col: \"A\", rowNum: 1 }).getText(), \"hello\");\n  });\n\n  it(\"should create a fork with Work-on-a-Copy option\", async function() {\n    // Main user logs in and import a document\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"Hello.grist\");\n\n    // Open the share menu and click item to work on a copy.\n    await driver.find(\".test-tb-share\").click();\n    await driver.find(\".test-work-on-copy\").click();\n\n    await gu.waitForUrl(/~/);\n    await gu.waitForDocToLoad();\n\n    // check document is a fork\n    assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), true);\n  });\n\n  // The URL ID of a document copy (named \"DuplicateTest2\") which we create below and then use in\n  // several subsequent test cases.\n  let urlId: string;\n\n  it(\"should allow saving the fork as a new copy\", async function() {\n    // Make a change to the fork to ensure it's saved.\n    await gu.getCell({ col: \"A\", rowNum: 1 }).click();\n    await driver.sendKeys(\"hello to duplicates\", Key.ENTER);\n\n    await driver.find(\".test-tb-share\").click();\n    await driver.find(\".test-save-copy\").click();\n    await gu.completeCopy({ destName: \"DuplicateTest2\", destWorkspace: \"Test Workspace\" });\n    urlId = (await gu.getCurrentUrlId())!;\n\n    // check the breadcrumbs reflect new document name, and the doc contains our change.\n    assert.equal(await driver.find(\".test-bc-doc\").value(), \"DuplicateTest2\");\n    assert.equal(await gu.getCell({ col: \"A\", rowNum: 1 }).getText(), \"hello to duplicates\");\n  });\n\n  it(\"should offer a choice of orgs when user is owner\", async function() {\n    if (server.isExternalServer()) {\n      this.skip();\n    }\n    await driver.find(\".test-tb-share\").click();\n    await driver.find(\".test-save-copy\").click();\n    await driver.findWait(\".test-modal-dialog\", 1000);\n    assert.equal(await driver.find(\".test-copy-dest-name\").value(), \"DuplicateTest2 (copy)\");\n    assert.equal(await driver.find(\".test-copy-dest-org\").isPresent(), true);\n    await driver.find(\".test-copy-dest-org .test-select-open\").click();\n    assert.includeMembers(await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Personal\", \"Test Grist\", \"Test2 Grist\"]);\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Check the list of workspaces in org\n    await driver.findWait(\".test-copy-dest-workspace .test-select-open\", 1000).click();\n    assert.includeMembers(await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Home\", \"Test Workspace\"]);\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Switch the org and check that workspaces get updated.\n    await driver.find(\".test-copy-dest-org .test-select-open\").click();\n    await gu.findOpenMenuItem(\"li\", \"Test2 Grist\").click();\n    await driver.findWait(\".test-copy-dest-workspace .test-select-open\", 1000).click();\n    assert.sameMembers(await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Home\"]);\n    await driver.sendKeys(Key.ESCAPE);\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should not offer a choice of org when user is not owner\", async function() {\n    const api = gu.session().teamSite.createHomeApi();\n    const session2 = gu.session().teamSite.user(\"user2\");\n    await api.updateDocPermissions(urlId, { users: {\n      [session2.email]: \"viewers\",\n    } });\n\n    await session2.login();\n    await session2.loadDoc(`/doc/${urlId}`);\n\n    await driver.find(\".test-tb-share\").click();\n    await driver.find(\".test-save-copy\").click();\n    await driver.findWait(\".test-modal-dialog\", 1000);\n    assert.equal(await driver.find(\".test-copy-dest-name\").value(), \"DuplicateTest2 (copy)\");\n    // No choice of orgs\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".test-copy-dest-org\").isPresent(), false);\n    // We don't happen to have write access to any workspace either.\n    assert.equal(await driver.find(\".test-copy-dest-workspace\").isPresent(), false);\n    assert.match(await driver.find(\".test-copy-warning\").getText(),\n      /You do not have write access to this site/);\n    assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should offer a choice of orgs when doc is public\", async function() {\n    if (server.isExternalServer()) {\n      this.skip();\n    }\n    const session = await gu.session().teamSite.login();\n    const api = session.createHomeApi();\n    // But if the doc is public, then users can copy it out.\n    await api.updateDocPermissions(urlId, { users: {\n      \"everyone@getgrist.com\": \"viewers\",\n    } });\n    const session2 = gu.session().teamSite.user(\"user2\");\n    await gu.session().teamSite2.createHomeApi().updateOrgPermissions(\"current\", { users: {\n      [session2.email]: \"owners\",\n    } });\n\n    // Reset tracking of the last visited site. We seem to need this now to get consistent\n    // behavior across Jenkins and local test runs. (May have something to do with newer\n    // versions of chromedriver and headless Chrome.)\n    await driver.executeScript(\"window.sessionStorage.clear();\");\n\n    await session2.login();\n    await session2.loadDoc(`/doc/${urlId}`);\n\n    await driver.find(\".test-tb-share\").click();\n    await driver.find(\".test-save-copy\").click();\n    await driver.findWait(\".test-modal-dialog\", 1000);\n    assert.equal(await driver.find(\".test-copy-dest-name\").value(), \"DuplicateTest2 (copy)\");\n\n    // We can now switch between orgs.\n    assert.equal(await driver.find(\".test-copy-dest-org\").isPresent(), true);\n\n    // But we still don't have any writable workspaces on the current site.\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".test-copy-dest-workspace\").isPresent(), false);\n    assert.match(await driver.find(\".test-copy-warning\").getText(),\n      /You do not have write access to this site/);\n    assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n\n    // We see some good orgs.\n    await driver.find(\".test-copy-dest-org .test-select-open\").click();\n    assert.includeMembers(await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Personal\", \"Test Grist\", \"Test2 Grist\"]);\n\n    // Switching to an accessible regular org shows workspaces.\n    await gu.findOpenMenuItem(\"li\", \"Test2 Grist\").click();\n    await gu.waitForServer();\n    await driver.find(\".test-copy-dest-workspace .test-select-open\").click();\n    assert.sameMembers(await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n      [\"Home\"]);\n    assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), null);\n\n    // And saving to another org actually works.\n    await gu.completeCopy();\n    assert.equal(await driver.find(\".test-dm-org\").getText(), \"Test2 Grist\");\n    assert.equal(await driver.find(\".test-bc-doc\").value(), \"DuplicateTest2 (copy)\");\n    assert.equal(await driver.find(\".test-bc-workspace\").getText(), \"Home\");\n    assert.equal(await gu.getCell({ col: \"A\", rowNum: 1 }).getText(), \"hello to duplicates\");\n  });\n\n  it(\"should allow saving a public doc to the personal org\", async function() {\n    if (server.isExternalServer()) {\n      this.skip();\n    }\n    const session2 = gu.session().teamSite.user(\"user2\");\n    await session2.login();\n    await session2.loadDoc(`/doc/${urlId}`);\n\n    // Open the \"Duplicate Document\" dialog.\n    await driver.find(\".test-tb-share\").click();\n    await driver.find(\".test-save-copy\").click();\n    await driver.findWait(\".test-modal-dialog\", 1000);\n    assert.equal(await driver.find(\".test-copy-dest-name\").value(), \"DuplicateTest2 (copy)\");\n\n    // Switching to personal org shows no workspaces but no errors either.\n    await driver.find(\".test-copy-dest-org .test-select-open\").click();\n    await gu.findOpenMenuItem(\"li\", \"Personal\").click();\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".test-copy-warning\").isPresent(), false);\n    assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), null);\n\n    // Save; it should succeed and open a same-looking document in alternate user's personal org.\n    const name = session2.name;\n    await gu.completeCopy({ destName: `DuplicateTest2 ${name} Copy` });\n    assert.equal(await driver.find(\".test-dm-org\").getText(), `@${name}`);\n    assert.equal(await driver.find(\".test-bc-doc\").value(), `DuplicateTest2 ${name} Copy`);\n    assert.equal(await driver.find(\".test-bc-workspace\").getText(), \"Home\");\n    assert.equal(await gu.getCell({ col: \"A\", rowNum: 1 }).getText(), \"hello to duplicates\");\n    assert.notEqual(await gu.getCurrentUrlId(), urlId);\n\n    // Remove document\n    await driver.find(\".test-bc-workspace\").click();\n    await gu.removeDoc(`DuplicateTest2 ${name} Copy`);\n  });\n\n  it(\"should not auto-start tour if a document with a tour is copied as a template\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"doctour.grist\");\n    await session.tempWorkspace(cleanup, \"Test Workspace\");\n    assert.isTrue(await driver.findWait(\".test-onboarding-popup\", 1000).isPresent());\n    await driver.find(\".test-onboarding-close\").click();\n    await gu.waitForServer();\n\n    await driver.find(\".test-tb-share\").click();\n    await driver.find(\".test-save-copy\").click();\n    await driver.findWait(\".test-modal-dialog\", 1000);\n    await driver.find(\".test-save-as-template\").click();\n    await gu.completeCopy({ destName: \"DuplicateTest3\", destWorkspace: \"Test Workspace\" });\n\n    // Give it a second, just to be sure the tour doesn't appear.\n    await driver.sleep(1000);\n    assert.isFalse(await driver.find(\".test-onboarding-popup\").isPresent());\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/DuplicatePage.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"DuplicatePage\", async function() {\n  this.timeout(\"30s\");\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n\n  before(async () => {\n    session = await gu.session().teamSite.login();\n  });\n\n  it(\"bug: should fix broken layout before duplicating a page\", async function() {\n    await session.tempNewDoc(cleanup);\n\n    await gu.renameActiveSection(\"Tab1\");\n\n    // Add 2 sections, one table and second card.\n    await gu.addNewSection(\"Table\", \"Table1\");\n    await gu.renameActiveSection(\"Tab2\");\n    await gu.addNewSection(\"Card\", \"Table1\");\n    await gu.renameActiveSection(\"Card1\");\n\n    // Now move this card section somewhere else (it will trigger layout save).\n    const handle = await gu.detachFromLayout();\n    await handle.moveTo(\"Tab1\", { x: 200, y: 40 });\n    await driver.findWait(\".layout_editor_drop_targeter\", 100);\n    await handle.release();\n    await handle.waitForSave();\n\n    // And after layout was saved, remove it. Grist should be able to restore the layout properly.\n    await gu.deleteWidget(await gu.getActiveSectionTitle());\n    await gu.waitForServer();\n\n    // And now duplicate this page.\n    await gu.duplicatePage(await gu.getCurrentPageName());\n\n    // We should see 2 sections. There was a bug here, and Tab1 was seen twice.\n    assert.deepEqual(await gu.getSectionTitles(), [\"Tab1\", \"Tab2\"]);\n  });\n\n  it(\"should allow duplicating a page\", async function() {\n    await session.tempDoc(cleanup, \"World.grist\");\n\n    // check pages and content\n    assert.deepEqual(await gu.getPageNames(), [\"City\", \"Country\", \"CountryLanguage\"]);\n    assert.deepEqual(\n      await driver.findAll(\".test-treeview-itemHeader.selected .test-docpage-label\", e => e.getText()),\n      [\"City\"],\n    );\n\n    // duplicate 'Country'\n    await gu.openPageMenu(\"Country\");\n    await driver.find(\".test-docpage-duplicate\").click();\n    await driver.find(\".test-modal-confirm\").click();\n    await driver.findContentWait(\".test-docpage-label\", /copy/, 2000);\n    await gu.waitForServer();\n\n    // check pages\n    assert.deepEqual(await gu.getPageNames(), [\"City\", \"Country\", \"CountryLanguage\", \"Country (copy)\"]);\n\n    // check copy has focus\n    assert.deepEqual(\n      await driver.findAll(\".test-treeview-itemHeader.selected .test-docpage-label\", e => e.getText()),\n      [\"Country (copy)\"],\n    );\n\n    // check layout is correct\n    assert.deepEqual(\n      await driver.find(\".layout_hbox\").findAll(\".test-viewsection-title\", e => e.getText()),\n      [\"COUNTRY\"],\n    );\n    assert.deepEqual(\n      await driver.find(\".layout_hbox:nth-child(2)\").findAll(\".test-viewsection-title\", e => e.getText()),\n      [\"COUNTRY Card List\", \"CITY\", \"COUNTRYLANGUAGE\"],\n    );\n\n    // check country language view fields are correct\n    await gu.selectSectionByTitle(\"COUNTRYLANGUAGE\");\n    await gu.toggleSidePanel(\"right\", \"open\");\n    assert.deepEqual(\n      await driver.findAll(\".test-vfc-visible-fields .kf_draggable\", e => e.getText()),\n      [\"Language\", \"IsOfficial\", \"Percentage\"]);\n    assert.deepEqual(\n      await driver.findAll(\".test-vfc-hidden-fields .kf_draggable\", e => e.getText()),\n      [\"Country\"]);\n    await gu.toggleSidePanel(\"right\", \"close\");\n\n    // check detail view is linked\n    await gu.selectSectionByTitle(\"COUNTRY\");\n    await gu.getCell(0, 1).click();\n    assert.deepEqual(await gu.getDetailCell(\"Name\", 1, \"COUNTRY Card List\").getText(), \"Aruba\");\n    await gu.getCell(0, 2).click();\n    assert.deepEqual(await gu.getDetailCell(\"Name\", 1, \"COUNTRY Card List\").getText(), \"Afghanistan\");\n\n    // check undo works has expected\n    await gu.undo();\n    assert.deepEqual(await gu.getPageNames(), [\"City\", \"Country\", \"CountryLanguage\"]);\n    assert.deepEqual(\n      await driver.findAll(\".test-treeview-itemHeader.selected .test-docpage-label\", e => e.getText()),\n      [\"City\"],\n    );\n\n    // sort CITY\n    await gu.openColumnMenu({ col: \"Country\", section: \"CITY\" });\n    await driver.find(\".grist-floating-menu\").find(\".test-sort-asc\").click();\n    await gu.openSectionMenu(\"sortAndFilter\");\n    await driver.find(\".grist-floating-menu .test-section-menu-btn-save\").click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells(\"Country\", [1, 5, 6]), [\"Afghanistan\", \"Albania\", \"Algeria\"]);\n\n    // duplicate CITY\n    await gu.openPageMenu(\"City\");\n    await driver.find(\".test-docpage-duplicate\").click();\n    await driver.find(\".test-modal-confirm\").click();\n    await driver.findContentWait(\".test-docpage-label\", /copy/, 1000);\n    await gu.waitForServer();\n\n    // check sort is has expected\n    assert.deepEqual(await gu.getVisibleGridCells(\"Country\", [1, 5, 6]), [\"Afghanistan\", \"Albania\", \"Algeria\"]);\n\n    // check layout Card List is correct\n    assert.deepEqual(\n      await driver.find(\".g_record_detail\")\n        .findAll(\".layout_hbox\", hbox => hbox\n          .findAll(\".g_record_detail_label\", e => e.getText())),\n      [[\"Name\", \"Country\", \"Pop. '000\"], [\"District\", \"Population\"]],\n    );\n  });\n\n  it(\"should copy filters\", async () => {\n    const clickPlus = () => driver.find(\".active_section .test-add-filter-btn\").click();\n    const openFilter = (name: string) => driver.findContent(\".active_section .test-filter-field\", name).click();\n    const selectedFilters = async () =>\n      (\n        await driver.findAll(\".test-filter-menu-list label\",\n          async e => (await e.find(\"input\").matches(\":checked\")) ?\n            (await e.find(\".test-filter-menu-value\").getText()) :\n            \"\")\n      ).filter(x => x);\n    const selectColumn = (name: string) => driver.findContent(\".grist-floating-menu li\", name).click();\n    const clickNone = () => driver.findContent(\".test-filter-menu-bulk-action\", /None/).click();\n    const clickFilterValue = (name: string) => driver.findContent(\".test-filter-menu-list label\", name).click();\n    const othersSelected = () =>\n      driver\n        .findContent(\".test-filter-menu-summary label\", /Others/)\n        .find(\"input\")\n        .matches(\":checked\");\n    const futureSelected = () =>\n      driver\n        .findContent(\".test-filter-menu-summary label\", /Future values/)\n        .find(\"input\")\n        .matches(\":checked\");\n    const apply = () => driver.find(\".test-filter-menu-apply-btn\").click();\n    const save = async () => {\n      await driver.find(\".active_section .test-section-menu-small-btn-save\").click();\n      await gu.waitForServer();\n    };\n    const filters = () => driver.findAll(\".active_section .test-filter-bar .test-filter-field\", e => e.getText());\n\n    // Filter Country Section.\n    await gu.getPageItem(\"Country\").click();\n    await gu.selectSectionByTitle(\"COUNTRY\");\n    await gu.openColumnMenu(\"Continent\", \"Filter\");\n    await clickNone();\n    await clickFilterValue(\"South America\");\n    await apply();\n    await save();\n\n    await clickPlus();\n    await selectColumn(\"Code\");\n    await clickNone();\n    await clickFilterValue(\"ARG\");\n    await clickFilterValue(\"BRA\");\n    await clickFilterValue(\"BOL\");\n    await apply();\n    await save();\n\n    // Add filter, but don't apply, should not be copied.\n    await openFilter(\"Code\");\n    await clickFilterValue(\"COL\");\n    await apply();\n\n    // Select third row (BRA) - to filter COUNTRYLANGUAGE\n    await gu.getCell(0, 3).click();\n\n    // Filter COUNTRYLANGUAGE, by Language, no future\n    await gu.selectSectionByTitle(\"COUNTRYLANGUAGE\");\n    await gu.openColumnMenu(\"Language\", \"Filter\");\n    await clickNone();\n    await clickFilterValue(\"German\");\n    await clickFilterValue(\"Italian\");\n    await apply();\n    await save();\n\n    // duplicate 'Country'\n    await gu.openPageMenu(\"Country\");\n    await driver.find(\".test-docpage-duplicate\").click();\n    // Input will select text on focus, which can alter the text we enter,\n    // so make sure we type correct value.\n    await gu.waitToPass(async () => {\n      const input = driver.find(\".test-modal-dialog input\");\n      await input.click();\n      await gu.selectAll();\n      await driver.sendKeys(\"Filtered\");\n      assert.equal(await input.value(), \"Filtered\");\n    });\n    await driver.find(\".test-modal-confirm\").click();\n    await driver.findContentWait(\".test-docpage-label\", /Filtered/, 2000);\n    await gu.waitForServer();\n    await gu.getPageItem(\"Filtered\").click(); // click to make sure it was duplicated\n    await gu.checkForErrors();\n\n    // Check Code and Continent are pinned to the filter bar\n    await gu.selectSectionByTitle(\"COUNTRY\");\n    assert.deepEqual(await filters(), [\"Code\", \"Continent\"]);\n    await openFilter(\"Code\");\n    assert.deepEqual(await selectedFilters(), [\"ARG\", \"BOL\", \"BRA\"]);\n    assert.isFalse(await othersSelected());\n    await driver.sendKeys(Key.ESCAPE);\n\n    await openFilter(\"Continent\");\n    assert.deepEqual(await selectedFilters(), [\"South America\"]);\n    assert.isFalse(await othersSelected());\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Select third row\n    await gu.getCell(0, 3).click();\n\n    await gu.selectSectionByTitle(\"COUNTRYLANGUAGE\");\n    assert.deepEqual(await filters(), [\"Language\"]);\n    await openFilter(\"Language\");\n    assert.deepEqual(await selectedFilters(), [\"German\", \"Italian\"]);\n    assert.isFalse(await futureSelected());\n    await driver.sendKeys(Key.ESCAPE);\n\n    await gu.selectSectionByTitle(\"CITY\");\n    assert.deepEqual(await selectedFilters(), []);\n\n    await gu.undo();\n    assert.deepEqual(await gu.getPageNames(), [\"City\", \"Country\", \"CountryLanguage\", \"City (copy)\"]);\n    await gu.checkForErrors();\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Export.ntest.js",
    "content": "import { assert, driver } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\nconst fse = require(\"fs-extra\");\nconst path = require(\"path\");\nconst axios = require(\"axios\");\n\n// Authentication headers to include into axios requests.\nconst headers = {Authorization: \"Bearer api_key_for_userz\"};\n\ndescribe(\"Export.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n  const pathsExpected = {\n    base: path.resolve(gu.fixturesRoot, \"export-csv\", \"CCTransactions.csv\"),\n    sorted: path.resolve(gu.fixturesRoot, \"export-csv\", \"CCTransactions-DBA-desc.csv\")\n  };\n  let dataExpected = {};\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"CCTransactions.grist\", true);\n\n    // Read the expected contents before the test case starts, to simplify the promises there.\n    // (don't really need that simplification any more though).\n    for (const [key, fname] of Object.entries(pathsExpected)) {\n      dataExpected[key] = await fse.readFile(fname, {encoding: \"utf8\"});\n    }\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should export correct data\", async function() {\n    await $(\".test-tb-share\").click();\n    // Once the menu opens, get the href of the link.\n    await $(\".grist-floating-menu\").wait();\n    const submenu = $(\".test-tb-share-option:contains(Export as...)\");\n    await driver.withActions(a => a.move({origin: submenu.elem()}));\n    const href = await $(\".grist-floating-menu a:contains(Comma Separated Values)\").wait()\n      .getAttribute(\"href\");\n    // Download the data at the link and compare to expected.\n    const resp = await axios.get(href, {responseType: \"text\", headers});\n    assert.equal(resp.headers[\"content-disposition\"],\n      'attachment; filename=\"CCTransactions.csv\"');\n    assert.equal(resp.data, dataExpected.base);\n    await $(\".test-tb-share\").click();\n  });\n\n  it(\"should respect active sort\", async function() {\n    await gu.openColumnMenu(\"Doing Business As\");\n    await $(\".grist-floating-menu .test-sort-dsc\").click();\n    await $(\".test-tb-share\").click();\n    // Once the menu opens, get the href of the link.\n    await $(\".grist-floating-menu\").wait();\n    const submenu = $(\".test-tb-share-option:contains(Export as...)\");\n    await driver.withActions(a => a.move({origin: submenu.elem()}));\n    const href = await $(\".grist-floating-menu a:contains(Comma Separated Values)\").wait()\n      .getAttribute(\"href\");\n    // Download the data at the link and compare to expected.\n    const resp = await axios.get(href, {responseType: \"text\", headers});\n    assert.equal(resp.data, dataExpected.sorted);\n  });\n\n  // TODO: We should have a test case with multiple sections on the screen, that checks that\n  // export runs for the currently selected section.\n});\n"
  },
  {
    "path": "test/nbrowser/ExportSection.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, stackWrapFunc } from \"mocha-webdriver\";\n\ndescribe(\"ExportSection\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n\n  afterEach(() => gu.checkForErrors());\n\n  // trims and makes sure that lines ends with \\n\n  function trim(text: string) {\n    return text.replace(/[\\r\\n]/g, \"\\n\").trim();\n  }\n\n  // filters column by excluding values\n  const unfilter = stackWrapFunc(async (col: string, values: string[]) => {\n    await driver.findWait(\".test-filter-config-add-filter-btn\", 1000).click();\n    await driver.findContentWait(\".grist-floating-menu .test-sd-searchable-list-item\", gu.exactMatch(col), 200).click();\n    await driver.findWait(\".test-filter-menu-list\", 1000);\n    for (const v of values) {\n      await driver.findContent(\".test-filter-menu-list .test-filter-menu-value\", gu.exactMatch(v)).click();\n    }\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n    await gu.waitForServer();\n  });\n\n  before(async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"SortFilterIconTest.grist\");\n  });\n\n  it(\"should export unsaved filtered data\", async function() {\n    // open filters section\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.findWait(\".test-right-tab-pagewidget\", 1000).click();\n    await driver.findWait(\".test-config-sortAndFilter\", 1000).click();\n\n    // filter out records to leave only 2 rows\n    await unfilter(\"Name\", [\"Grapefruit\", \"Grapes\"]);\n    await unfilter(\"Count\", [\"3\", \"5\"]);\n\n    // download csv and compare\n    const csv = await gu.downloadSectionCsv(\"TABLE1\");\n    const expected = `\nName,Count,Date\nApples,1,2019-07-17\nBananas,2,2019-07-18`;\n\n    assert.equal(trim(csv), trim(expected));\n\n    // save filters - for next test\n    await driver.findContent(\".test-sort-filter-config-save-btns button\", /Save/).click();\n    await gu.waitForServer();\n  });\n\n  it(\"should export saved filtered data\", async function() {\n    // we will reuse results from previous test here\n\n    // refresh the browser to reload everything\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // open filter panel and leave only one record - but don't save the filter\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.findWait(\".test-right-tab-pagewidget\", 1000).click();\n    await driver.findWait(\".test-config-sortAndFilter\", 1000).click();\n    await unfilter(\"Date\", [\"2019-07-18\"]);\n\n    // download section and compare\n    const csv = await gu.downloadSectionCsv(\"TABLE1\");\n    const expected = `\nName,Count,Date\nApples,1,2019-07-17`;\n    assert.equal(trim(csv), trim(expected));\n  });\n\n  it(\"should respect filters on hidden columns\", async function() {\n    // Refresh the browser to reload everything.\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // Open menu for 'Count' column and hide the column.\n    await gu.openColumnMenu(\"Count\", \"Hide column\");\n    await gu.waitForServer();\n\n    // Download section and check that 'Count' column isn't included, but filter is in effect.\n    const csv = await gu.downloadSectionCsv(\"TABLE1\");\n    const expected = `\nName,Date\nApples,2019-07-17\nBananas,2019-07-18`;\n    assert.equal(trim(csv), trim(expected));\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Features.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"Features\", function() {\n  this.timeout(20000);\n  setupTestSuite({ samples: true });\n\n  let session: gu.Session;\n  let oldEnv: EnvironmentSnapshot;\n\n  before(async function() {\n    oldEnv = new EnvironmentSnapshot();\n    session = await gu.session().teamSite.login();\n  });\n\n  after(async function() {\n    oldEnv.restore();\n    await server.restart();\n  });\n\n  it(\"can be enabled with the GRIST_UI_FEATURES env variable\", async function() {\n    process.env.GRIST_TEMPLATE_ORG = \"templates\";\n    process.env.GRIST_UI_FEATURES = \"helpCenter,templates\";\n    await server.restart();\n    await session.loadDocMenu(\"/\");\n    assert.isTrue(await driver.find(\".test-dm-templates-page\").isDisplayed());\n    assert.isTrue(await driver.find(\".test-left-feedback\").isDisplayed());\n    assert.isFalse(await driver.find(\".test-dm-basic-tutorial\").isDisplayed());\n  });\n\n  it(\"can be disabled with the GRIST_HIDE_UI_ELEMENTS env variable\", async function() {\n    process.env.GRIST_UI_FEATURES = \"helpCenter,tutorials\";\n    process.env.GRIST_HIDE_UI_ELEMENTS = \"templates\";\n    process.env.GRIST_ONBOARDING_TUTORIAL_DOC_ID = \"tutorialDocId\";\n    await server.restart();\n    await session.loadDocMenu(\"/\");\n    assert.isTrue(await driver.find(\".test-left-feedback\").isDisplayed());\n    assert.isTrue(await driver.find(\".test-dm-basic-tutorial\").isDisplayed());\n    assert.isFalse(await driver.find(\".test-dm-templates-page\").isDisplayed());\n  });\n\n  it(\"that are disabled take precedence over those that are also enabled\", async function() {\n    process.env.GRIST_UI_FEATURES = \"tutorials,templates\";\n    process.env.GRIST_HIDE_UI_ELEMENTS = \"helpCenter,templates\";\n    await server.restart();\n    await session.loadDocMenu(\"/\");\n    assert.isTrue(await driver.find(\".test-dm-basic-tutorial\").isDisplayed());\n    assert.isFalse(await driver.find(\".test-left-feedback\").isPresent());\n    assert.isFalse(await driver.find(\".test-dm-templates-page\").isDisplayed());\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/FieldConfigTab.ntest.js",
    "content": "import { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"FieldConfigTab.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"Hello.grist\", true);\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should stay open when switching between columns or views\", async function() {\n    // Add another table to the document.\n    await gu.actions.addNewTable();\n\n    await gu.actions.selectTabView(\"Table1\");\n    await gu.openSidePane(\"field\");\n    var fieldLabel = await $(\".test-field-label\").wait(assert.isDisplayed).elem();\n\n    await assert.isDisplayed(fieldLabel);\n    assert.equal(await fieldLabel.val(), \"A\");\n\n    // Move cursor to a different column.\n    await $(\"$GridView_columnLabel:nth-child(2)\").click();\n    assert.equal(await fieldLabel.val(), \"B\");\n\n    // Switch to another view. The first column should be selected.\n    await gu.actions.selectTabView(\"Table2\");\n\n    fieldLabel = $(\".test-field-label\").elem();\n    await assert.isDisplayed(fieldLabel);\n    assert.equal(await fieldLabel.val(), \"A\");\n  });\n\n  it(\"should support changing the column label and id together\", async function() {\n    await gu.actions.selectTabView(\"Table1\");\n    var fieldLabel = await $(\".test-field-label\").elem();\n    await gu.clickCellRC(0, 0); // Move back to the first cell.\n    assert.equal(await fieldLabel.val(), \"A\");\n    await $(\".test-field-label\").sendNewText(\"foo\");\n    await gu.waitForServer();\n\n    // Check that both the label and colId changed in the side pane.\n    assert.equal(await fieldLabel.val(), \"foo\");\n    await $(\".test-field-col-id\").wait(async function(el) { return assert.equal(await el.val(), \"$foo\"); });\n\n    // Check that the label changed among column headers.\n    assert.equal(await $(\"$GridView_columnLabel:nth-child(1)\").text(), \"foo\");\n  });\n\n  it(\"should support changing the column label and id separately\", async function() {\n    await gu.actions.selectTabView(\"Table1\");\n    await $(\"$GridView_columnLabel:nth-child(2)\").click();\n    var fieldLabel = $(\".test-field-label\");\n    assert.equal(await fieldLabel.val(), \"B\");\n\n    // Uncheck the \"derive id\" checkbox.\n    var deriveIdCheckbox = $(\".test-field-derive-id\");\n    assert.isTrue(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n    await deriveIdCheckbox.click();\n    await gu.waitForServer();\n    assert.isFalse(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n\n    // Check that only the label changed in the side pane.\n    await fieldLabel.sendNewText(\"bar\");\n    await gu.waitForServer();\n    assert.equal(await fieldLabel.val(), \"bar\");\n    await $(\"$GridView_columnLabel:nth-child(2)\").wait(async function(el) { return assert.equal(await el.text(), \"bar\"); });\n\n    // Id should be unchanged, but we should be able to change it now.\n    assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2, 3, 4], cols: [1] }),\n      [\"\", \"world\", \"\", \"\"]);\n    assert(await $(\".test-field-col-id\").val(), \"B\");\n    await $(\".test-field-col-id\").sendNewText(\"baz\");\n    assert(await $(\".test-field-col-id\").val(), \"baz\");\n    assert.equal(await fieldLabel.val(), \"bar\");\n    assert.equal(await $(\"$GridView_columnLabel:nth-child(1)\").text(), \"foo\");\n    assert.equal(await $(\"$GridView_columnLabel:nth-child(2)\").text(), \"bar\");\n\n    // Make sure the changing Ids does not effect the data in the column\n    assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2, 3, 4], cols: [1] }),\n      [\"\", \"world\", \"\", \"\"]);\n    await assert.hasClass(gu.getCell(0, 1).find(\".field_clip\"), \"invalid\", false);\n  });\n\n  describe(\"Duplicate Labels\", async function() {\n    let fieldLabel, deriveIdCheckbox;\n\n    beforeEach(() => {\n      fieldLabel = $(\".test-field-label\");\n      deriveIdCheckbox = $(\".test-field-derive-id\");\n    });\n\n    it(\"should allow duplicate labels with underived colIds\", async function() {\n      // Change column 4 to have the same label as column 1\n      await $(\"$GridView_columnLabel:nth-child(4)\").click();\n      assert.equal(await fieldLabel.val(), \"D\");\n      assert.isTrue(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n      await deriveIdCheckbox.click();\n      await gu.waitForServer();\n      assert.isFalse(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n      await fieldLabel.sendNewText(\"foo\");\n      // Columns 1 and 4 should both be named foo\n      await $(\"$GridView_columnLabel:nth-child(1)\").wait(async function(el) { return assert.equal(await el.text(), \"foo\"); });\n      await $(\"$GridView_columnLabel:nth-child(4)\").wait(async function(el) { return assert.equal(await el.text(), \"foo\"); });\n      // But colId should be unchanged\n      assert(await $(\".test-field-col-id\").val(), \"D\");\n    });\n\n    it(\"should allow duplicate labels with derived colIds\", async function() {\n      // Now clicking the derive box should be leave the labels the same\n      // but the conflicting Id should be sanitized\n      await deriveIdCheckbox.click();\n      await gu.waitForServer();\n      assert.isTrue(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n      await deriveIdCheckbox.click();\n      await gu.waitForServer();\n      assert.isFalse(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n      await $(\"$GridView_columnLabel:nth-child(1)\").scrollIntoView({inline: \"end\"}).click();\n      await $(\"$GridView_columnLabel:nth-child(1)\").wait(async function(el) { return assert.equal(await el.text(), \"foo\"); });\n      assert(await $(\".test-field-col-id\").val(), \"foo\");\n      await $(\"$GridView_columnLabel:nth-child(4)\").click();\n      await $(\"$GridView_columnLabel:nth-child(4)\").wait(async function(el) { return assert.equal(await el.text(), \"foo\"); });\n      assert(await $(\".test-field-col-id\").val(), \"foo2\");\n    });\n\n    it(\"should not change the derived id unnecessarly\", async function() {\n      // Toggling the box should not change the derived Id\n      await deriveIdCheckbox.click();\n      await gu.waitForServer();\n      assert.isTrue(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n      await deriveIdCheckbox.click();\n      await gu.waitForServer();\n      assert.isFalse(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n      assert(await $(\".test-field-col-id\").val(), \"foo2\");\n    });\n\n    it(\"should not automatically modify the derived checkbox\", async function() {\n      // When derived labels are changed to an existing Id, the derived box should remain checked\n      // even if the id and label are different\n      await $(\"$GridView_columnLabel:nth-child(1)\").scrollIntoView({inline: \"end\"}).click();\n      await $(\"$GridView_columnLabel:nth-child(1)\").wait(async function(el) { return assert.equal(await el.text(), \"foo\"); });\n      assert(await $(\".test-field-col-id\").val(), \"foo\");\n      assert.isTrue(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n      await fieldLabel.sendNewText(\"foo2\");\n      await gu.waitForServer();\n      assert.equal(await fieldLabel.val(), \"foo2\");\n      assert(await $(\".test-field-col-id\").val(), \"foo2_2\");\n      assert.isTrue(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n    });\n\n    it(\"should allow out of sync colIds to still derive from labels\", async function() {\n      // Entering a new label should still sync the Id\n      await fieldLabel.sendNewText(\"foobar\");\n      await gu.waitForServer();\n      assert.isTrue(await deriveIdCheckbox.is(\"[class*=-selected]\"));\n      await deriveIdCheckbox.click();\n      assert(await $(\".test-field-col-id\").val(), \"foobar\");\n    });\n  });\n\n  it(\"should allow editing column data after column rename\", async function() {\n    await gu.actions.selectTabView(\"Table1\");\n    await $(\"$GridView_columnLabel:nth-child(3)\").click();\n    assert.equal(await $(\".test-field-label\").val(), \"C\");\n\n    // Switch type to numeric. This makes it easier to tell whether the value actually gets\n    // processed by the server.\n    await gu.setType(\"Numeric\");\n    await $(\".test-type-transform-apply\").wait().click();\n    await gu.waitForServer();\n    var cell = await gu.getCellRC(0, 2);\n    await cell.click();     // row index 0, column index 2\n    await gu.sendKeys(\"17\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"17\");\n    await assert.hasClass(cell.find(\".field_clip\"), \"invalid\", false);\n\n    // Rename the column, make sure we can still type into it, and get results from the server.\n    await $(\".test-field-label\").sendNewText(\"c2\");\n    await gu.waitForServer();\n    assert.equal(await $(\"$GridView_columnLabel:nth-child(3)\").text(), \"c2\");\n    await gu.waitForServer();\n    cell = await gu.getCellRC(0, 2);\n    await cell.click();     // row index 0, column index 2\n    await gu.sendKeys(\"23\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await cell.text(), \"23\");\n    await assert.hasClass(cell.find(\".field_clip\"), \"invalid\", false);\n  });\n\n});\n"
  },
  {
    "path": "test/nbrowser/FieldEditor.ts",
    "content": "import { DocCreationInfo } from \"app/common/DocListAPI\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"FieldEditor\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  gu.bigScreen();\n\n  afterEach(() => gu.checkForErrors());\n  let doc: DocCreationInfo;\n  let api: UserAPI;\n\n  before(async () => {\n    // Log in and open a sample doc\n    const mainSession = await gu.session().teamSite.login();\n    doc = await mainSession.tempDoc(cleanup, \"Favorite_Films.grist\");\n    api = mainSession.createHomeApi();\n  });\n\n  it(\"should close editor without saving on any column\", async function() {\n    // This tests a bug, when the editor is opened and closed on the any column (without typing),\n    // the column is converted to a Text Column (value is updated with an empty string).\n    await gu.addNewTable();\n    await (await gu.openRowMenu(1)).findContent(\"li\", /Insert row above/).click();\n    await gu.waitForServer();\n    await gu.getCell(0, 1).click();\n    await gu.sendKeys(Key.ENTER);\n    await gu.checkTextEditor(gu.exactMatch(\"\"));\n    await gu.getCell(2, 1).click(); // Click away to save\n    await gu.waitForServer(); // Server receives an empty string, which isn't enough to change type to Text\n    await gu.getCell(0, 1).click();\n    // Make sure it is still Any column.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Any\");\n    await gu.undo(3);\n  });\n\n  it(\"should close field editor on switching page\", async function() {\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Films/);\n\n    // Open a cell in 'add-row' for editing, and start typing.\n    await driver.find(\"body\").sendKeys(Key.chord(await gu.modKey(), Key.DOWN));\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 7, col: 0 });\n    await driver.sendKeys(\"FieldEditor1\");\n    assert.equal(await driver.find(\".cell_editor\").isDisplayed(), true);\n\n    // Click another page.\n    await driver.findContent(\".test-treeview-itemHeader\", /Friends/).doClick();\n    await gu.waitForServer();\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Friends/);\n\n    // Cell editor should close.\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), false);\n    await gu.waitForServer();\n\n    // Enter a value into a Reference column; we add a non-existent value using the \"+\" button in\n    // the auto-complete dropdown since that triggers the harder case with two async actions.\n    await gu.getCell({ rowNum: 2, col: 1 }).click();\n    assert.equal(await gu.getCell({ rowNum: 2, col: 1 }).getText(), \"Toy Story\");\n    await driver.sendKeys(\"FieldEditor-New\");\n    await driver.sendKeys(Key.UP);      // Select the last \"+\" item.\n    assert.equal(await driver.find(\".test-ref-editor-new-item\").matches(\".selected\"), true);\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), true);\n\n    // Switch back; check that the previous value got saved.\n    await driver.findContent(\".test-treeview-itemHeader\", /Films/).doClick();\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), false);\n    assert.equal(await gu.getCell({ rowNum: 7, col: 0 }).getText(), \"FieldEditor1\");\n    assert.equal(await gu.getCell({ rowNum: 8, col: 0 }).getText(), \"FieldEditor-New\");\n\n    // Switch and check that the Reference value got saved.\n    await driver.findContent(\".test-treeview-itemHeader\", /Friends/).doClick();\n    assert.equal(await gu.getCell({ rowNum: 2, col: 1 }).getText(), \"FieldEditor-New\");\n\n    await gu.undo(3);\n  });\n\n  it(\"should close field editor when code viewer is opened\", async function() {\n    await driver.findContent(\".test-treeview-itemHeader\", /Friends/).doClick();\n    assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"Roger\");\n    await gu.getCell({ rowNum: 1, col: 0 }).click();\n    await gu.sendKeys(\"FieldEditor2\");\n    assert.equal(await driver.find(\".cell_editor\").isDisplayed(), true);\n\n    // Switch to code editor page.\n    await driver.find(\".test-tools-code\").doClick();\n    assert.equal(await driver.find(\".test-bc-page\").value(), \"code\");\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), false);\n    await gu.waitForServer();\n\n    // When we go back, we should see the value in the cell saved.\n    await driver.findContent(\".test-treeview-itemHeader\", /Friends/).doClick();\n\n    // Check that the value got saved.\n    assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"FieldEditor2\");\n    await gu.undo(1);\n  });\n\n  it(\"should prompt when navigating away with editor open\", async function() {\n    await driver.findContent(\".test-treeview-itemHeader\", /Films/).doClick();\n    assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"Toy Story\");\n    await gu.getCell({ rowNum: 1, col: 0 }).click();\n\n    await gu.sendKeys(\"FieldEditor3\");\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), true);\n\n    // Navigating away, we should get a prompt. Dismiss it to stay on the page.\n    await driver.get(\"about:blank\");\n    await driver.switchTo().alert().dismiss();\n    assert.equal(await driver.findWait(\".cell_editor\", 500).isPresent(), true);\n\n    // Once saved, we can navigate away.\n    await driver.sendKeys(Key.ENTER);\n    await driver.get(\"about:blank\");\n    assert.equal(await driver.find(\".cell_editor\").isPresent(), false);\n\n    // Navigate back to see the value saved.\n    await driver.navigate().back();\n    await gu.waitForDocToLoad();\n    assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"FieldEditor3\");\n  });\n\n  describe(\"should scroll when editor is activated\", () => {\n    before(async () => {\n      const records = new Array(200).fill([\"AddRecord\", \"Table1\", null, { A: \"Text\", B: \"Text\" }]);\n      await api.applyUserActions(doc.id, [\n        [\"AddTable\", \"Table1\", [{ id: \"A\", type: \"Text\" }, { id: \"B\", type: \"Text\" }]],\n        ...records,\n      ]);\n      await gu.waitForServer();\n      await driver.findContent(\".test-treeview-itemHeader\", /Table1/).click();\n      // Add Card List view\n      await gu.openAddWidgetToPage();\n      await driver.findContentWait(\".test-wselect-type\", /Card List/, 500).click();\n      // Select Table1\n      await driver.findContent(\".test-wselect-table\", /Table1/).click();\n      // Press add\n      await driver.find(\".test-wselect-addBtn\").click();\n      await gu.waitForServer();\n    });\n\n    // Some long text, to make sure it is all typed in.\n    const sampleText = \"Test0000000000000000000000000000000000000000000123\";\n    // Scroll 2 rows, to hide the first row. By scrolling only two rows, the active row is still rendered.\n    // Then scroll 150 (arbitrary big number) rows, which removes the active record from the\n    // dom (replaces it with another). Actually it would be enough to scroll by 5 records only.\n    for (const rowCount of [2, 150]) {\n      it(`in a GridView scrolled by ${rowCount} rows `, async function() {\n        await gu.getCell(0, 1, \"TABLE1\").click();\n        // Scroll\n        await gu.scrollActiveView(0, 23 * rowCount); // 23 is row height.\n        await driver.sendKeys(sampleText);\n        await gu.checkTextEditor(gu.exactMatch(sampleText));\n        await driver.sendKeys(Key.ESCAPE);\n        await gu.waitAppFocus(true);\n        // Second cell should be clickable - this means view was scrolled to the active record.\n        await gu.getCell(1, 1).click();\n        await gu.waitForServer();\n        await gu.checkForErrors();\n      });\n\n      it(`in a Detail view scrolled by ${rowCount} cards`, async function() {\n        await gu.getDetailCell(\"A\", 1, \"TABLE1 Card List\").click();\n        // Scroll (for CardList it will not scroll exactly, but close enough for the test).\n        await gu.scrollActiveView(0, 100 * rowCount); // 100 is card height.\n        await driver.sendKeys(sampleText);\n        await gu.checkTextEditor(gu.exactMatch(sampleText));\n        await driver.sendKeys(Key.ESCAPE);\n        await gu.waitAppFocus(true);\n        // Second field should be clickable - this means view was scrolled to the active card.\n        await gu.getDetailCell(\"B\", 1).click();\n        await gu.waitForServer();\n        await gu.checkForErrors();\n      });\n    }\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/FieldSettings.ntest.js",
    "content": "/**\n * When a field is present in multiple views, the different copies of it may use common or\n * separate settings. This test verifies these behaviors and switching between them.\n */\n\nimport { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"FieldSettings.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n  gu.bigScreen();\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"FieldSettings.grist\", true);\n\n    await gu.actions.selectTabView(\"Rates\");\n    await gu.waitForServer();\n    await gu.openSidePane(\"field\");\n  });\n\n  afterEach(async function() {\n    await gu.userActionsCollect(false);\n    return gu.checkForErrors();\n  });\n\n  async function checkSections(position, settingsFunc, expectedBySection) {\n    await gu.waitForServer();\n    for (let sectionName in expectedBySection) {\n      let [cellText, settingsValue] = expectedBySection[sectionName];\n      const cell = await gu.getCell(Object.assign({section: sectionName}, position));\n      await gu.clickCell(cell);\n      assert.equal(await cell.text(), cellText);\n      assert.equal(await settingsFunc(), settingsValue);\n    }\n  }\n\n\n  it(\"should respect common settings for regular options\", async function() {\n    await gu.userActionsCollect(true);\n\n    // Sections 'A' and 'B' use common settings, and 'C' uses separate.\n    // Check that changing the setting in A affects B, but does not affect C.\n    await gu.clickCell({section: \"A\", rowNum: 1, col: 1});\n    assert.equal(await gu.dateFormat(), \"YYYY-MM-DD\");\n    await gu.dateFormat(\"MM/DD/YYYY\");\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"01/02/2012\", \"MM/DD/YYYY\"],\n      B: [\"01/02/2012\", \"MM/DD/YYYY\"],\n      C: [\"2012-01-02\", \"YYYY-MM-DD\"],\n    });\n\n    // Check that changing C does not affect A or B.\n    await gu.dateFormat(\"MMMM Do, YYYY\");\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"01/02/2012\", \"MM/DD/YYYY\"],\n      B: [\"01/02/2012\", \"MM/DD/YYYY\"],\n      C: [\"January 2nd, 2012\", \"MMMM Do, YYYY\"],\n    });\n\n    // Verify actions emitted. These are obtained from pasting the output, but the important thing\n    // about them is that it's one action for each change, one for the table, one for the field.\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Tables_column\", 15, {\"widgetOptions\":\n        '{\"widget\":\"TextBox\",\"dateFormat\":\"MM/DD/YYYY\",\"isCustomDateFormat\":false,\"alignment\":\"left\"}'}],\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 145, {\"widgetOptions\":\n        '{\"widget\":\"TextBox\",\"dateFormat\":\"MMMM Do, YYYY\",\"isCustomDateFormat\":false,\"alignment\":\"left\"}'}],\n    ]);\n\n    // Undo, checking that the 2 actions only require 2 undos, and verify.\n    await gu.undo(2);\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      B: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      C: [\"2012-01-02\", \"YYYY-MM-DD\"],\n    });\n  });\n\n\n  it(\"should respect common settings for visibleCol\", async function() {\n    // Same as above but for changing \"visibleCol\", which involves extra actions to update the\n    // display helper column.\n    await gu.userActionsCollect(true);\n    await gu.clickCell({section: \"A\", rowNum: 1, col: 0});\n    assert.equal(await $(\".test-fbuilder-ref-col-select .test-select-row\").text(), \"Full Name\");\n    await gu.setVisibleCol(\"Last Name\");\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"Klein\", \"Last Name\"],\n      B: [\"Klein\", \"Last Name\"],\n      C: [\"Klein, Cordelia\", \"Full Name\"],\n    });\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Tables_column\", 12, {\"visibleCol\":3}],\n      [\"SetDisplayFormula\", \"Rates\", null, 12, \"$Person.Last_Name\"],\n    ]);\n\n    await gu.clickCell({section: \"C\", rowNum: 1, col: 0});\n    await gu.setVisibleCol(\"First Name\");\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"Klein\", \"Last Name\"],\n      B: [\"Klein\", \"Last Name\"],\n      C: [\"Cordelia\", \"First Name\"],\n    });\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 141, {\"visibleCol\":2}],\n      [\"SetDisplayFormula\", \"Rates\", 141, null, \"$Person.First_Name\"],\n    ]);\n\n    // Same for changing \"visibleCol\" to the special \"RowID\" value.\n    await gu.clickCell({section: \"A\", rowNum: 1, col: 0});\n    await gu.setVisibleCol(\"Row ID\");\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"People[14]\", \"Row ID\"],\n      B: [\"People[14]\", \"Row ID\"],\n      C: [\"Cordelia\", \"First Name\"],\n    });\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Tables_column\", 12, {\"visibleCol\":0}],\n      [\"SetDisplayFormula\", \"Rates\", null, 12, \"\"],\n    ]);\n\n    // Undo here so we can verify that per-field \"Row ID\" choice overrides per-column choice.\n    await gu.undo();\n\n    await gu.userActionsCollect(true);\n    await gu.clickCell({section: \"C\", rowNum: 1, col: 0});\n    await gu.setVisibleCol(\"Row ID\");\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"Klein\", \"Last Name\"],\n      B: [\"Klein\", \"Last Name\"],\n      C: [\"People[14]\", \"Row ID\"],\n    });\n\n    // Verify actions emitted.\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 141, {\"visibleCol\":0}],\n      [\"SetDisplayFormula\", \"Rates\", 141, null, \"\"],\n    ]);\n\n    // Undo; we made 4 actions, but already ran one undo earlier.\n    await gu.undo(3);\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"Klein, Cordelia\", \"Full Name\"],\n      B: [\"Klein, Cordelia\", \"Full Name\"],\n      C: [\"Klein, Cordelia\", \"Full Name\"],\n    });\n  });\n\n\n  it(\"should allow switching to separate settings\", async function() {\n    // Switch B to use separate settings.\n    await gu.userActionsCollect(true);\n    await gu.clickCell({section: \"B\", rowNum: 1, col: 1});\n    await gu.fieldSettingsUseSeparate();\n\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 140, {\"widgetOptions\":\n        '{\"widget\":\"TextBox\",\"dateFormat\":\"YYYY-MM-DD\",\"isCustomDateFormat\":false,\"alignment\":\"left\"}'}],\n    ]);\n\n    // Verify that options are preserved.\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      B: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      C: [\"2012-01-02\", \"YYYY-MM-DD\"],\n    });\n\n    // Change option in B and see that A and C are not affected.\n    await gu.clickCell({section: \"B\", rowNum: 1, col: 1});\n    await gu.dateFormat(\"MM/DD/YYYY\");\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      B: [\"01/02/2012\", \"MM/DD/YYYY\"],\n      C: [\"2012-01-02\", \"YYYY-MM-DD\"],\n    });\n\n    // Change option in A and see that B is not affected.\n    await gu.clickCell({section: \"A\", rowNum: 1, col: 1});\n    await gu.dateFormat(\"MMMM Do, YYYY\");\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"January 2nd, 2012\", \"MMMM Do, YYYY\"],\n      B: [\"01/02/2012\", \"MM/DD/YYYY\"],\n      C: [\"2012-01-02\", \"YYYY-MM-DD\"],\n    });\n\n    await gu.undo(3);\n  });\n\n\n  it(\"should allow switching to separate settings for visibleCol\", async function() {\n    // Same as above for changing 'visibleCol' option; after separating, try changing B, then A.\n    await gu.userActionsCollect(true);\n    await gu.clickCell({section: \"B\", rowNum: 2, col: 0});\n    await gu.fieldSettingsUseSeparate();\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 136, {\"widgetOptions\":'{\"widget\":\"Reference\"}'}],\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 136, {\"visibleCol\":4}],\n      [\"SetDisplayFormula\", \"Rates\", 136, null, \"$Person.Full_Name\"],\n    ]);\n\n    await gu.setVisibleCol(\"First Name\");\n    await gu.clickCell({section: \"A\", rowNum: 2, col: 0});\n    await gu.setVisibleCol(\"Last Name\");\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"Klein\", \"Last Name\"],\n      B: [\"Cordelia\", \"First Name\"],\n      C: [\"Klein, Cordelia\", \"Full Name\"],\n    });\n    await gu.undo(3);\n  });\n\n\n  it(\"should allow reverting to common settings\", async function() {\n    // Change column in C to use different settings from A.\n    await gu.clickCell({section: \"C\", rowNum: 1, col: 1});\n    await gu.dateFormat(\"MMMM Do, YYYY\");\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      B: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      C: [\"January 2nd, 2012\", \"MMMM Do, YYYY\"],\n    });\n\n    // Revert C to use common settings. Check that it matches A.\n    await gu.userActionsCollect(true);\n    await gu.fieldSettingsRevertToCommon();\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      B: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      C: [\"2012-01-02\", \"YYYY-MM-DD\"],\n    });\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 145, {\"widgetOptions\":\"\"}],\n    ]);\n    await gu.undo(2);\n  });\n\n\n  it(\"should allow reverting to common settings for visibleCol\", async function() {\n    // Same as above for reverting 'visiblecCol'.\n    await gu.clickCell({section: \"C\", rowNum: 2, col: 0});\n    await gu.setVisibleCol(\"Last Name\");\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"Klein, Cordelia\", \"Full Name\"],\n      B: [\"Klein, Cordelia\", \"Full Name\"],\n      C: [\"Klein\", \"Last Name\"],\n    });\n    await gu.userActionsCollect(true);\n    await gu.fieldSettingsRevertToCommon();\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"Klein, Cordelia\", \"Full Name\"],\n      B: [\"Klein, Cordelia\", \"Full Name\"],\n      C: [\"Klein, Cordelia\", \"Full Name\"],\n    });\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 141, {\"widgetOptions\":\"\"}],\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 141, {\"visibleCol\":0}],\n      [\"SetDisplayFormula\", \"Rates\", 141, null, \"\"],\n    ]);\n    await gu.undo(2);\n  });\n\n\n  it(\"should allow saving separate settings as common\", async function() {\n    // Change column C to use different settings from A.\n    await gu.clickCell({section: \"C\", rowNum: 1, col: 1});\n    await gu.dateFormat(\"MMMM Do, YYYY\");\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      B: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      C: [\"January 2nd, 2012\", \"MMMM Do, YYYY\"],\n    });\n\n    // Save C settings as common settings. Check that A and B now match.\n    await gu.userActionsCollect(true);\n    await gu.fieldSettingsSaveAsCommon();\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"January 2nd, 2012\", \"MMMM Do, YYYY\"],\n      B: [\"January 2nd, 2012\", \"MMMM Do, YYYY\"],\n      C: [\"January 2nd, 2012\", \"MMMM Do, YYYY\"],\n    });\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Tables_column\", 15, {\"widgetOptions\":\n        '{\"widget\":\"TextBox\",\"dateFormat\":\"MMMM Do, YYYY\",\"isCustomDateFormat\":false,\"alignment\":\"left\"}'}],\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 145, {\"widgetOptions\":\"\"}],\n    ]);\n    await gu.undo(2);\n    await checkSections({rowNum: 1, col: 1}, () => gu.dateFormat(), {\n      A: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      B: [\"2012-01-02\", \"YYYY-MM-DD\"],\n      C: [\"2012-01-02\", \"YYYY-MM-DD\"],\n    });\n  });\n\n\n  it(\"should allow saving separate settings as common for visibleCol\", async function() {\n    // Same as above for saving 'visiblecCol'.\n    await gu.clickCell({section: \"C\", rowNum: 2, col: 0});\n    await gu.setVisibleCol(\"Last Name\");\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"Klein, Cordelia\", \"Full Name\"],\n      B: [\"Klein, Cordelia\", \"Full Name\"],\n      C: [\"Klein\", \"Last Name\"],\n    });\n    await gu.userActionsCollect(true);\n    await gu.fieldSettingsSaveAsCommon();\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"Klein\", \"Last Name\"],\n      B: [\"Klein\", \"Last Name\"],\n      C: [\"Klein\", \"Last Name\"],\n    });\n    await gu.userActionsVerify([\n      [\"UpdateRecord\", \"_grist_Tables_column\", 12, {\"visibleCol\":3}],\n      [\"SetDisplayFormula\", \"Rates\", null, 12, \"$Person.Last_Name\"],\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 141, {\"widgetOptions\":\"\"}],\n      [\"UpdateRecord\", \"_grist_Views_section_field\", 141, {\"visibleCol\":0}],\n      [\"SetDisplayFormula\", \"Rates\", 141, null, \"\"],\n    ]);\n    await gu.undo(2);\n    await checkSections({rowNum: 2, col: 0}, () => $(\".test-fbuilder-ref-col-select .test-select-row\").text(), {\n      A: [\"Klein, Cordelia\", \"Full Name\"],\n      B: [\"Klein, Cordelia\", \"Full Name\"],\n      C: [\"Klein, Cordelia\", \"Full Name\"],\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/FieldSettings2.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"FieldSettings2\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should allow separate field settings for a new field\", async function() {\n    const session = await gu.session().teamSite.user(\"user1\").login();\n    const docId = (await session.tempNewDoc(cleanup, \"FieldSettings2A\", { load: false }));\n    const api = session.createHomeApi();\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"TestTable\", [{ id: \"Num\", type: \"Numeric\" }]],\n      [\"BulkAddRecord\", \"TestTable\", [null, null, null], { Num: [\"5\", \"10\", \"15\"] }],\n    ]);\n\n    await session.loadDoc(`/doc/${docId}/p/2`);\n\n    // Add a second widget of the same table to the page.\n    await gu.openAddWidgetToPage();\n    await gu.selectWidget(/Table/, /TestTable/);\n    await gu.renameSection(\"TESTTABLE\", \"T1\");\n    await gu.renameSection(\"TESTTABLE\", \"T2\");\n\n    // Change Num field to \"Separate\"\n    await gu.getCell({ section: \"T1\", rowNum: 1, col: \"Num\" }).click();\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    await fieldSettingsUseSeparate();\n\n    // Now change background color of this column.\n    await gu.openCellColorPicker();\n    await gu.setFillColor(\"blue\");\n    await driver.find(\".test-colors-save\").click();\n    await gu.waitForServer();\n\n    // Check that only one of the two views changed.\n    const cell1 = await gu.getCell({ section: \"T1\", rowNum: 1, col: \"Num\" });\n    const cell2 = await gu.getCell({ section: \"T2\", rowNum: 1, col: \"Num\" });\n    await gu.assertFillColor(cell1, \"blue\");\n    await gu.assertFillColor(cell2, \"transparent\");\n\n    // Saving as common updates the other view.\n    await fieldSettingsSaveAsCommon();\n    await gu.assertFillColor(cell1, \"blue\");\n    await gu.assertFillColor(cell2, \"blue\");\n\n    // Undo; then reverting reverts the saved change.\n    await gu.undo();\n    await gu.assertFillColor(cell1, \"blue\");\n    await gu.assertFillColor(cell2, \"transparent\");\n    await fieldSettingsRevertToCommon();\n    await gu.assertFillColor(cell1, \"transparent\");\n    await gu.assertFillColor(cell2, \"transparent\");\n  });\n});\n\nconst getFieldSettingsButton = () => driver.find(\".fieldbuilder_settings_button\");\nconst switchFieldSettings = async (fromLabel: string, option: string, toLabel: string) => {\n  assert.include(await getFieldSettingsButton().getText(), fromLabel);\n  await getFieldSettingsButton().click();\n  await gu.findOpenMenuItem(\"li\", option).click();\n  await gu.waitForServer();\n  assert.include(await getFieldSettingsButton().getText(), toLabel);\n};\nconst fieldSettingsUseSeparate = () => switchFieldSettings(\"Common\", \"Use separate\", \"Separate\");\nconst fieldSettingsSaveAsCommon = () => switchFieldSettings(\"Separate\", \"Save as common\", \"Common\");\nconst fieldSettingsRevertToCommon = () => switchFieldSettings(\"Separate\", \"Revert to common\", \"Common\");\n"
  },
  {
    "path": "test/nbrowser/FillLinkedRecords.ntest.js",
    "content": "import { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\n/**\n * This test verifies that when a section is auto-filtered using section-linking, newly added\n * records automatically get assigned the filter value.\n */\ndescribe(\"FillLinkedRecords.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n  const clipboard = gu.getLockableClipboard();\n\n  gu.bigScreen();\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"Favorite_Films.grist\", true);\n    await gu.toggleSidePanel(\"left\", \"close\");\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should auto-fill values when typing into add-row\", async function() {\n    await gu.openSidePane(\"view\");\n    await $(\".test-config-data\").click();\n    await gu.actions.selectTabView(\"All\");\n\n    // Link the sections first since the sample document start with no links.\n    // Connect Friends -> Films\n    await gu.actions.viewSection(\"Films record\").selectSection();\n    await $(\".test-right-select-by\").click();\n    await $(\".test-select-row:contains(Friends record)\").wait().click();\n    await gu.waitForServer();\n\n    // Connect Films -> Performances grid\n    await gu.actions.viewSection(\"Performances record\").selectSection();\n    await $(\".test-right-select-by\").click();\n    await $(\".test-select-row:contains(Films record)\").wait().click();\n    await gu.waitForServer();\n\n    // Connect Films -> Performances detail\n    await gu.actions.viewSection(\"Performances detail\").selectSection();\n    await $(\".test-right-select-by\").click();\n    await $(\".test-select-row:contains(Films record)\").wait().click();\n    await gu.waitForServer();\n\n    // Now pick a movie, and select the Performances grid.\n    await gu.clickCell({section: \"Films record\", col: 0, rowNum: 2});\n    await gu.actions.viewSection(\"Performances record\").selectSection();\n\n    // It should have just two records initially, with an Add-New row.\n    assert.equal(await gu.getGridLastRowText(), \"3\");\n    assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [2, 3]}), [\n      \"Robin Wright\", \"Forrest Gump\",\n      \"\", \"\"]);\n\n    // Add a record, and ensure it shows up, and has Film auto-filled in.\n    await gu.userActionsCollect(true);\n    await gu.addRecord([\"Rebecca Williams\"]);\n    await gu.userActionsVerify([\n      [\"AddRecord\", \"Performances\", null, {\"Actor\": \"Rebecca Williams\", \"Film\": 2}]\n    ]);\n    assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [2, 3]}), [\n      \"Robin Wright\", \"Forrest Gump\",\n      \"Rebecca Williams\", \"Forrest Gump\"]);\n    assert.equal(await gu.getGridLastRowText(), \"4\");\n  });\n\n  it(\"should auto-fill values when inserting records\", async function() {\n    // Click another movie, and check the values we see.\n    await gu.clickCell({section: \"Films record\", col: 0, rowNum: 5});\n    await gu.actions.viewSection(\"Performances record\").selectSection();\n    assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [1, 2]}), [\n      \"Christian Bale\", \"The Dark Knight\",\n      \"Heath Ledger\",   \"The Dark Knight\"\n    ]);\n    assert.equal(await gu.getGridLastRowText(), \"3\");\n\n    // Add a couple of records in Performances grid using keyboard shortcuts.\n    await gu.clickCell({col: 0, rowNum: 3});\n    await gu.sendKeys([$.MOD, $.SHIFT, $.ENTER]);\n    await gu.clickCell({col: 0, rowNum: 1});\n    await gu.sendKeys([$.MOD, $.ENTER]);\n    await gu.waitForServer();\n\n    // Verify they are shown where expected with Film filled in.\n    assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [1, 2, 3, 4]}), [\n      \"Christian Bale\", \"The Dark Knight\",\n      \"\",               \"The Dark Knight\",\n      \"Heath Ledger\",   \"The Dark Knight\",\n      \"\",               \"The Dark Knight\",\n    ]);\n    assert.equal(await gu.getGridLastRowText(), \"5\");\n\n    // Add a record in Performances detail using keyboard shortcuts.\n    await gu.actions.viewSection(\"Performances detail\").selectSection();\n    assert.deepEqual(await gu.getDetailValues({cols: [\"Actor\", \"Film\"], rowNums: [1]}),\n      [\"Christian Bale\", \"The Dark Knight\"]);\n    await gu.sendKeys([$.MOD, $.ENTER]);\n    await gu.waitForServer();\n\n    // Verify the record is shown with Film filled in, and added to the grid section too.\n    // Note: rowNum needs to be 1 now for card views without row numbers shown.\n    assert.deepEqual(await gu.getDetailValues({cols: [\"Actor\", \"Film\"], rowNums: [1]}),\n      [\"\", \"The Dark Knight\"]);\n\n    await gu.actions.viewSection(\"Performances record\").selectSection();\n    assert.deepEqual(await gu.getGridValues({cols: [0, 1], rowNums: [1, 2, 3, 4, 5]}), [\n      \"Christian Bale\", \"The Dark Knight\",\n      \"\",               \"The Dark Knight\",\n      \"\",               \"The Dark Knight\",\n      \"Heath Ledger\",   \"The Dark Knight\",\n      \"\",               \"The Dark Knight\",\n    ]);\n    assert.equal(await gu.getGridLastRowText(), \"6\");\n\n    // Undo the record insertions.\n    await gu.undo(3);\n  });\n\n  it(\"should auto-fill when pasting data\", async function() {\n    // Click a movie, and check the values we expect to start with.\n    await gu.clickCell({section: \"Films record\", col: 0, rowNum: 6});\n    await gu.actions.viewSection(\"Performances record\").selectSection();\n    assert.deepEqual(await gu.getGridValues({cols: [0, 1, 2], rowNums: [1, 4]}), [\n      \"Chris Evans\",        \"The Avengers\", \"Steve Rogers\",\n      \"Scarlett Johansson\", \"The Avengers\", \"Natasha Romanoff\",\n    ]);\n    assert.equal(await gu.getGridLastRowText(), \"5\");\n\n    // Copy a range of three values, and paste them into the Add-New row.\n    await gu.clickCell({col: 2, rowNum: 1});\n    await gu.sendKeys([$.SHIFT, $.DOWN, $.DOWN]);\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.clickCell({col: 2, rowNum: 5});\n      await cb.paste();\n    });\n    await gu.waitForServer();\n\n    // Verify that three new rows now show up, with Film auto-filled.\n    assert.deepEqual(await gu.getGridValues({cols: [0, 1, 2], rowNums: [1, 4, 5, 6, 7]}), [\n      \"Chris Evans\",        \"The Avengers\", \"Steve Rogers\",\n      \"Scarlett Johansson\", \"The Avengers\", \"Natasha Romanoff\",\n      \"\",                   \"The Avengers\", \"Steve Rogers\",\n      \"\",                   \"The Avengers\", \"Tony Stark\",\n      \"\",                   \"The Avengers\", \"Bruce Banner\",\n    ]);\n    assert.equal(await gu.getGridLastRowText(), \"8\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/FillSelectionDown.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\nfunction shiftClick(el: WebElement) {\n  return driver.withActions(actions => actions.keyDown(Key.SHIFT).click(el).keyUp(Key.SHIFT));\n}\n\ndescribe(\"FillSelectionDown\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  it(\"should fill down selection that includes formulas\", async function() {\n    const session = await gu.session().personalSite.login();\n    const api = session.createHomeApi();\n    const docId = await session.tempNewDoc(cleanup, \"FillSelectionDown\", { load: false });\n\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"Table2\", [\n        { id: \"A\", type: \"Text\", isFormula: false },\n        { id: \"B\", type: \"Text\", isFormula: true, formula: \"$A.upper()\" },\n        { id: \"C\", type: \"Numeric\", isFormula: false },\n        { id: \"D\", type: \"Numeric\", isFormula: false },\n      ]],\n      [\"BulkAddRecord\", \"Table2\", [null, null, null], {\n        A: [\"foo\", \"bar\", \"baz\"],\n        C: [10, 20, 30],\n        D: [100, 200, 300],\n      }],\n    ]);\n    await session.loadDoc(`/doc/${docId}/p/2`);\n\n    // Initial data.\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"A\", \"B\", \"C\", \"D\"], rowNums: [1, 2, 3, 4] }), [\n      \"foo\", \"FOO\", \"10\", \"100\",\n      \"bar\", \"BAR\", \"20\", \"200\",\n      \"baz\", \"BAZ\", \"30\", \"300\",\n      \"\", \"\", \"\", \"\",\n    ]);\n\n    const revert = await gu.begin();\n    try {\n      await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n      await shiftClick(gu.getCell({ rowNum: 2, col: \"C\" }));\n      await gu.sendKeys(Key.chord(await gu.modKey(), \"d\"));\n      await gu.waitForServer();\n\n      // Filled in 2 rows.\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"A\", \"B\", \"C\", \"D\"], rowNums: [1, 2, 3, 4] }), [\n        \"foo\", \"FOO\", \"10\", \"100\",\n        \"foo\", \"FOO\", \"10\", \"200\",\n        \"baz\", \"BAZ\", \"30\", \"300\",\n        \"\", \"\", \"\", \"\",\n      ]);\n      await gu.checkForErrors();\n    } finally {\n      await revert();\n    }\n  });\n\n  it(\"should fill down selection that includes the add-new row\", async function() {\n    const revert = await gu.begin();\n    try {\n      // Now try to fill in throw and including the \"new\" row, and check that it works and ignores\n      // the \"new\" row silently.\n      await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n      await shiftClick(gu.getCell({ rowNum: 4, col: \"C\" }));\n      await gu.sendKeys(Key.chord(await gu.modKey(), \"d\"));\n      await gu.waitForServer();\n\n      // Filled in 3 rows.\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"A\", \"B\", \"C\", \"D\"], rowNums: [1, 2, 3, 4] }), [\n        \"foo\", \"FOO\", \"10\", \"100\",\n        \"foo\", \"FOO\", \"10\", \"200\",\n        \"foo\", \"FOO\", \"10\", \"300\",\n        \"\", \"\", \"\", \"\",\n      ]);\n\n      await gu.checkForErrors();\n    } finally {\n      await revert();\n    }\n  });\n\n  it(\"should avoid sending no-op actions\", async function() {\n    const revert = await gu.begin();\n    await gu.userActionsCollect(true);\n    try {\n      // Try to fill in a single row, which is a no-op.\n      await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n      await shiftClick(gu.getCell({ rowNum: 1, col: \"C\" }));\n      await gu.sendKeys(Key.chord(await gu.modKey(), \"d\"));\n      await gu.userActionsVerify([]);\n\n      // Try to fill in a single row + the add-new row, which is still a no-op.\n      await gu.getCell({ rowNum: 3, col: \"A\" }).click();\n      await shiftClick(gu.getCell({ rowNum: 4, col: \"C\" }));\n      await gu.sendKeys(Key.chord(await gu.modKey(), \"d\"));\n      await gu.userActionsVerify([]);\n\n      // Try to fill in a single formula column, which is still a no-op.\n      await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n      await shiftClick(gu.getCell({ rowNum: 3, col: \"B\" }));\n      await gu.sendKeys(Key.chord(await gu.modKey(), \"d\"));\n      await gu.userActionsVerify([]);\n\n      await gu.checkForErrors();\n\n      // Check that the ending data is unchanged.\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"A\", \"B\", \"C\", \"D\"], rowNums: [1, 2, 3, 4] }), [\n        \"foo\", \"FOO\", \"10\", \"100\",\n        \"bar\", \"BAR\", \"20\", \"200\",\n        \"baz\", \"BAZ\", \"30\", \"300\",\n        \"\", \"\", \"\", \"\",\n      ]);\n    } finally {\n      await gu.userActionsCollect(false);\n      await revert();\n    }\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/FilterLinkChain.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert } from \"mocha-webdriver\";\n\ndescribe(\"FilterLinkChain\", function() {\n  this.timeout(10000);\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"FilterLinkChain.grist\");\n  });\n\n  it(\"should work with chains of filter links\", async function() {\n    async function checkCells(sectionName: string, cols: string[], expected: string[]) {\n      assert.deepEqual(\n        await gu.getVisibleGridCells({ section: sectionName, cols, rowNums: [1, 2] }),\n        expected,\n      );\n\n      // Sanity-check the selectors in checkSectionEmpty() below.\n      const section = gu.getSection(sectionName);\n      assert.isEmpty(await section.findAll(\".disable_viewpane\"));\n      assert.isNotEmpty(await section.findAll(\".gridview_row .gridview_data_row_num\"));\n      assert.isNotEmpty(await section.findAll(\".gridview_row .record\"));\n      assert.isNotEmpty(await section.findAll(\".gridview_row .field\"));\n    }\n\n    async function checkTopCells(expected: string[]) {\n      await checkCells(\"TOP\", [\"Top\"], expected);\n    }\n\n    async function checkMiddleCells(expected: string[]) {\n      await checkCells(\"MIDDLE\", [\"Top\", \"Middle\"], expected);\n    }\n\n    async function checkBottomCells(expected: string[]) {\n      await checkCells(\"BOTTOM\", [\"Top\", \"Middle\", \"Bottom\"], expected);\n    }\n\n    async function checkSectionEmpty(sectionName: string, text: string) {\n      const section = gu.getSection(sectionName);\n      assert.equal(await section.find(\".disable_viewpane\").getText(), text);\n      assert.isEmpty(await section.findAll(\".gridview_row .gridview_data_row_num\"));\n      assert.isEmpty(await section.findAll(\".gridview_row .record\"));\n      assert.isEmpty(await section.findAll(\".gridview_row .field\"));\n    }\n\n    async function checkBottomEmpty() {\n      await checkSectionEmpty(\"BOTTOM\", \"No row selected in MIDDLE\");\n    }\n\n    async function checkMiddleEmpty() {\n      await checkSectionEmpty(\"MIDDLE\", \"No row selected in TOP\");\n    }\n\n    // The initially visible data.\n    // The bottom section is selected by the middle section,\n    // which is selected by the top section.\n    await checkTopCells([\n      \"A\",  // selected initially\n      \"B\",\n    ]);\n    // Filtered to 'A'\n    await checkMiddleCells([\n      \"A\", \"A1\",  // selected initially\n      \"A\", \"A2\",\n    ]);\n    // Filtered to 'A1'\n    await checkBottomCells([\n      \"A\", \"A1\", \"1\",\n      \"A\", \"A1\", \"2\",\n    ]);\n\n    // Select 'A2'\n    await gu.getCell({ section: \"MIDDLE\", col: \"Middle\", rowNum: 2 }).click();\n    await checkBottomCells([\n      \"A\", \"A2\", \"3\",\n      \"A\", \"A2\", \"4\",\n    ]);\n\n    // Select the 'new' row\n    await gu.getCell({ section: \"MIDDLE\", col: \"Middle\", rowNum: 3 }).click();\n    await checkSectionEmpty(\"BOTTOM\", \"No row selected in MIDDLE\");\n\n    // Select 'B'\n    await gu.getCell({ section: \"TOP\", col: \"Top\", rowNum: 2 }).click();\n    await checkMiddleCells([\n      \"B\", \"B1\",  // selected initially\n      \"B\", \"B2\",\n    ]);\n    // Filtered to 'B1'\n    await checkBottomCells([\n      \"B\", \"B1\", \"5\",\n      \"B\", \"B1\", \"6\",\n    ]);\n\n    // Select 'B2'\n    await gu.getCell({ section: \"MIDDLE\", col: \"Middle\", rowNum: 2 }).click();\n    await checkBottomCells([\n      \"B\", \"B2\", \"7\",\n      \"B\", \"B2\", \"8\",\n    ]);\n\n    // Select the 'new' row, making the bottom empty\n    await gu.getCell({ section: \"MIDDLE\", col: \"Middle\", rowNum: 3 }).click();\n    await checkBottomEmpty();\n\n    // Select the 'new' in the top section, which makes middle empty, which means bottom stays empty.\n    await gu.getCell({ section: \"TOP\", col: \"Top\", rowNum: 3 }).click();\n    await checkMiddleEmpty();\n    await checkBottomEmpty();\n\n    // Double-check: make all sections show some data again,\n    // and then make both the middle and bottom empty in one click instead of one at a time.\n    await gu.getCell({ section: \"TOP\", col: \"Top\", rowNum: 2 }).click();\n    await checkMiddleCells([\n      \"B\", \"B1\",  // selected initially\n      \"B\", \"B2\",\n    ]);\n    await checkBottomCells([\n      \"B\", \"B1\", \"5\",\n      \"B\", \"B1\", \"6\",\n    ]);\n\n    await gu.getCell({ section: \"TOP\", col: \"Top\", rowNum: 3 }).click();\n    await checkMiddleEmpty();\n    await checkBottomEmpty();\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/FilteringBugs.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, Key } from \"mocha-webdriver\";\n\ndescribe(\"FilteringBugs\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n\n  afterEach(() => gu.checkForErrors());\n\n  before(async function() {\n    session = await gu.session().login();\n  });\n\n  it(\"should not filter records edited from another view\", async function() {\n    await session.tempDoc(cleanup, \"ExemptFromFilterBug.grist\");\n\n    // Change column A of the first record from foo to bar; bar should still be\n    // visible, even though it's excluded by a filter.\n    await gu.getCell({ section: \"TABLE2 Filtered\", rowNum: 1, col: \"A\" }).click();\n    await gu.sendKeys(Key.ENTER, Key.ARROW_DOWN, Key.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ section: \"TABLE2 Filtered\", col: \"A\", rowNums: [1, 2, 3] }),\n      [\"bar\", \"foo\", \"\"],\n    );\n\n    // With the same record selected, change column C in the linked card section.\n    await gu.getCell({ section: \"TABLE2 Filtered\", rowNum: 1, col: \"A\" }).click();\n    await gu.getCardCell(\"C\", \"TABLE2 Card\").click();\n    await gu.sendKeys(\"2\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Check that the record is still visible in the first section. Previously, a\n    // regression caused it to be filtered out.\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ section: \"TABLE2 Filtered\", col: \"A\", rowNums: [1, 2, 3] }),\n      [\"bar\", \"foo\", \"\"],\n    );\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Fork.ts",
    "content": "import { DocCreationInfo } from \"app/common/DocListAPI\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\nimport { v4 as uuidv4 } from \"uuid\";\n\ndescribe(\"Fork\", function() {\n  // this is a relatively slow test in staging.\n  this.timeout(60000);\n  const cleanup = setupTestSuite();\n\n  let doc: DocCreationInfo;\n  let api: UserAPI;\n  let personal: gu.Session;\n  let team: gu.Session;\n\n  before(async function() {\n    personal = gu.session().personalSite;\n    team = gu.session().teamSite;\n  });\n\n  async function makeDocIfAbsent() {\n    if (doc?.id) { return; }\n    doc = await team.tempDoc(cleanup, \"Hello.grist\", { load: false });\n  }\n\n  async function getForks(api: UserAPI) {\n    const wss = await api.getOrgWorkspaces(\"current\");\n    const forks = wss\n      .find(ws => ws.name === team.workspace)\n      ?.docs\n      .find(d => d.id === doc.id)\n      ?.forks;\n    return forks ?? [];\n  }\n\n  async function testForksOfForks(isLoggedIn: boolean = true) {\n    const session = isLoggedIn ? await team.login() : await team.anon.login();\n    await session.loadDoc(`/doc/${doc.id}/m/fork`);\n    await gu.getCell({ rowNum: 1, col: 0 }).click();\n    await gu.enterCell(\"1\");\n    await gu.waitForServer();\n    assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"1\");\n    const forkUrl = await driver.getCurrentUrl();\n    assert.match(forkUrl, isLoggedIn ? /^[^~]*~[^~]+~\\d+$/ : /^[^~]*~[^~]*$/);\n    await gu.refreshDismiss();\n    await session.loadDoc((new URL(forkUrl)).pathname + \"/m/fork\");\n    await gu.getCell({ rowNum: 1, col: 0 }).click();\n    await gu.enterCell(\"2\");\n    await gu.waitForServer();\n    await driver.findContentWait(\n      \".test-notifier-toast-wrapper\",\n      /Cannot fork a document that's already a fork/s,\n      2000,\n    );\n    assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"1\");\n    assert.equal(await driver.getCurrentUrl(), `${forkUrl}/m/fork`);\n    await gu.wipeToasts();\n    await gu.refreshDismiss();\n  }\n\n  // Run tests with both regular docId and a custom urlId in URL, to make sure\n  // ids are kept straight during forking.\n\n  for (const idType of [\"urlId\", \"docId\"] as (\"docId\" | \"urlId\")[]) {\n    describe(`with ${idType} in url`, function() {\n      before(async function() {\n        // Chimpy imports a document\n        await team.login();\n        await makeDocIfAbsent();\n\n        // Chimpy invites anon to view this document as a viewer, and charon as an owner\n        api = team.createHomeApi();\n        const user2 = gu.session().user(\"user2\").email;\n        const user3 = gu.session().user(\"user3\").email;\n        await api.updateDocPermissions(doc.id, { users: { \"anon@getgrist.com\": \"viewers\",\n          [user3]: \"viewers\",\n          [user2]: \"owners\" } });\n\n        // Optionally set a urlId\n        if (idType === \"urlId\") {\n          await api.updateDoc(doc.id, { urlId: \"doc-ula\" });\n        }\n      });\n\n      afterEach(() => gu.checkForErrors());\n\n      for (const mode of [\"anonymous\", \"logged in\"]) {\n        for (const content of [\"empty\", \"imported\"]) {\n          it(`can create an ${content} unsaved document when ${mode}`, async function() {\n            let visitedSites: string[];\n            if (mode === \"anonymous\") {\n              visitedSites = [\"@Guest\"];\n              await personal.anon.login();\n            } else {\n              visitedSites = [\"Test Grist\", `@${personal.name}`];\n              await personal.login();\n            }\n            const anonApi = personal.anon.createHomeApi();\n            const activeApi = (mode === \"anonymous\") ? anonApi : api;\n            const id = await (content === \"empty\" ? activeApi.newUnsavedDoc() :\n              activeApi.importUnsavedDoc(Buffer.from(\"A,B\\n999,2\\n\"),\n                { filename: \"foo.csv\" }));\n            await personal.loadDoc(`/doc/${id}`);\n            await gu.dismissWelcomeTourIfNeeded();\n            await gu.toggleSidePanel(\"left\", \"open\");\n            // check that the tag is there\n            assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), true);\n            // check that the org name area is showing one of the last visited sites. this is\n            // an imprecise check; doing an assert.equal instead is possible, but would require\n            // changing this test significantly.\n            assert.include(visitedSites, await driver.find(\".test-dm-org\").getText());\n            if (content === \"imported\") {\n              assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"999\");\n            } else {\n              assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"\");\n            }\n            // editing should work\n            await gu.getCell({ rowNum: 1, col: 0 }).click();\n            await gu.enterCell(\"123\");\n            await gu.waitForServer();\n            assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"123\");\n            await gu.refreshDismiss();\n            // edits should persist across reloads\n            await personal.loadDoc(`/doc/${id}`);\n            assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"123\");\n            // edits should still work\n            await gu.getCell({ rowNum: 1, col: 0 }).click();\n            await gu.enterCell(\"234\");\n            await gu.waitForServer();\n            assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"234\");\n            await gu.refreshDismiss();\n\n            if (mode !== \"anonymous\") {\n              // if we log out, access should now be denied\n              const anonSession = await personal.anon.login();\n              await anonSession.loadDoc(`/doc/${id}`, { wait: false });\n              assert.match(await driver.findWait(\".test-error-header\", 2000).getText(), /Access denied/);\n\n              // if we log in as a different user, access should be denied\n              const altSession = await personal.user(\"user2\").login();\n              await altSession.loadDoc(`/doc/${id}`, { wait: false });\n              assert.match(await driver.findWait(\".test-error-header\", 2000).getText(), /Access denied/);\n            }\n          });\n        }\n      }\n\n      it(\"allows a document to be forked anonymously\", async function() {\n        // Anon loads the document, tries to modify, and fails - no write access\n        const anonSession = await team.anon.login();\n        await anonSession.loadDoc(`/doc/${doc.id}`);\n        await gu.getCell({ rowNum: 1, col: 0 }).click();\n        await gu.enterCell(\"123\");\n        await gu.waitForServer();\n        assert.notEqual(await gu.getCell({ rowNum: 1, col: 0 }).value(), \"123\");\n        // check that there is no tag\n        assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), false);\n\n        // Anon forks the document, tries to modify, and succeeds\n        await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);\n        await gu.getCell({ rowNum: 1, col: 0 }).click();\n        await gu.enterCell(\"123\");\n\n        // Check that we show some indicators of what's happening to the user in notification toasts\n        // (but allow for possibility that things are changing fast).\n        await driver.findContentWait(\".test-notifier-toast-message\",\n          /(Preparing your copy)|(You are now.*your own copy)/, 2000);\n        await gu.waitForServer();\n        await driver.findContentWait(\".test-notifier-toast-message\", /You are now.*your own copy/, 2000);\n        assert.equal(await driver.findContent(\".test-notifier-toast-message\", /Preparing/).isPresent(), false);\n\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"123\");\n        await gu.getCell({ rowNum: 2, col: 0 }).click();\n        await gu.enterCell(\"234\");\n        await gu.waitForServer();\n        assert.equal(await gu.getCell({ rowNum: 2, col: 0 }).getText(), \"234\");\n\n        // The url of the doc should now be that of a fork\n        const forkUrl = await driver.getCurrentUrl();\n        assert.match(forkUrl, /~/);\n        // check that the tag is there\n        assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), true);\n        await gu.refreshDismiss();\n\n        // Open the original url and make sure the change we made is not there\n        await anonSession.loadDoc(`/doc/${doc.id}`);\n        assert.notEqual(await gu.getCell({ rowNum: 1, col: 0 }).value(), \"123\");\n        assert.notEqual(await gu.getCell({ rowNum: 2, col: 0 }).value(), \"234\");\n        assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), false);\n\n        // Open the fork url and make sure the change we made is persisted there\n        await anonSession.loadDoc((new URL(forkUrl)).pathname);\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"123\");\n        assert.equal(await gu.getCell({ rowNum: 2, col: 0 }).getText(), \"234\");\n        assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), true);\n      });\n\n      it(\"allows a document to be forked anonymously multiple times\", async function() {\n        // Anon forks the document, tries to modify, and succeeds\n        const anonSession = await team.anon.login();\n        await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);\n        await gu.getCell({ rowNum: 1, col: 0 }).click();\n        await gu.enterCell(\"1\");\n        await gu.waitForServer();\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"1\");\n        const fork1 = await driver.getCurrentUrl();\n        await gu.refreshDismiss();\n\n        await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);\n        await gu.getCell({ rowNum: 1, col: 0 }).click();\n        await gu.enterCell(\"2\");\n        await gu.waitForServer();\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"2\");\n        const fork2 = await driver.getCurrentUrl();\n        await gu.refreshDismiss();\n\n        await anonSession.loadDoc((new URL(fork1)).pathname);\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"1\");\n\n        await anonSession.loadDoc((new URL(fork2)).pathname);\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"2\");\n      });\n\n      it(\"does not allow an anonymous fork to be forked\", () => testForksOfForks(false));\n\n      it(\"shows the right page item after forking\", async function() {\n        const anonSession = await team.anon.login();\n        await anonSession.loadDoc(`/doc/${doc.id}/m/fork`);\n        assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Table1/);\n\n        // Add a new page; this immediately triggers a fork, AND selects the new page in it.\n        await gu.addNewPage(/Table/, /New Table/, { dismissTips: true });\n        const urlId1 = await gu.getCurrentUrlId();\n        assert.match(urlId1!, /~/);\n        assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Table2/);\n        await gu.refreshDismiss();\n      });\n\n      for (const user of [{ access: \"viewers\", name: \"user3\" },\n        { access: \"editors\", name: \"user2\" }]) {\n        it(`allows a logged in user with ${user.access} permissions to fork`, async function() {\n          const userSession = await gu.session().teamSite.user(user.name as any).login();\n          await userSession.loadDoc(`/doc/${doc.id}/m/fork`);\n          assert.equal(await gu.getEmail(), userSession.email);\n          assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), false);\n          // Dismiss forms announcement popup, if present.\n          await gu.dismissBehavioralPrompts();\n          await gu.getCell({ rowNum: 1, col: 0 }).click();\n          await gu.enterCell(\"123\");\n          await gu.waitForServer();\n          assert.equal(await driver.findWait(\".test-unsaved-tag\", 4000).isPresent(), true);\n          await gu.getCell({ rowNum: 2, col: 0 }).click();\n          await gu.enterCell(\"234\");\n          await gu.waitForServer();\n          assert.equal(await gu.getCell({ rowNum: 2, col: 0 }).getText(), \"234\");\n\n          // The url of the doc should now be that of a fork\n          const forkUrl = await driver.getCurrentUrl();\n          assert.match(forkUrl, /~/);\n\n          // Check that the fork was saved to the home db\n          const api = userSession.createHomeApi();\n          const forks = await getForks(api);\n          const forkId = forkUrl.match(/\\/[\\w-]+~(\\w+)(?:~\\w)?/)?.[1];\n          const fork = forks.find(f => f.id === forkId);\n          assert.isDefined(fork);\n          assert.equal(fork!.trunkId, doc.id);\n          await gu.refreshDismiss();\n\n          // Open the original url and make sure the change we made is not there\n          await userSession.loadDoc(`/doc/${doc.id}`);\n          assert.notEqual(await gu.getCell({ rowNum: 1, col: 0 }).value(), \"123\");\n          assert.notEqual(await gu.getCell({ rowNum: 2, col: 0 }).value(), \"234\");\n\n          // Open the fork url and make sure the change we made is persisted there\n          await userSession.loadDoc((new URL(forkUrl)).pathname);\n          assert.notEqual(await gu.getCell({ rowNum: 2, col: 0 }).value(), \"234\");\n          assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"123\");\n          assert.equal(await gu.getCell({ rowNum: 2, col: 0 }).getText(), \"234\");\n\n          // Check we still have editing rights\n          await gu.getCell({ rowNum: 1, col: 0 }).click();\n          await gu.enterCell(\"1234\");\n          await gu.waitForServer();\n          await gu.refreshDismiss();\n          await userSession.loadDoc((new URL(forkUrl)).pathname);\n          assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"1234\");\n\n          // Check others with write access to trunk can view but not edit our\n          // fork\n          await team.login();\n          await team.loadDoc((new URL(forkUrl)).pathname);\n          assert.equal(await gu.getEmail(), team.email);\n          assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"1234\");\n          await gu.getCell({ rowNum: 1, col: 0 }).click();\n          await gu.enterCell(\"12345\");\n          await gu.waitForServer();\n          await gu.refreshDismiss();\n          await team.loadDoc((new URL(forkUrl)).pathname);\n          assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"1234\");\n\n          const anonSession = await team.anon.login();\n          await anonSession.loadDoc((new URL(forkUrl)).pathname);\n          assert.equal(await gu.getEmail(), \"anon@getgrist.com\");\n          assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"1234\");\n          await gu.getCell({ rowNum: 1, col: 0 }).click();\n          await gu.enterCell(\"12345\");\n          await gu.waitForServer();\n          await gu.refreshDismiss();\n          await anonSession.loadDoc((new URL(forkUrl)).pathname);\n          assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"1234\");\n        });\n      }\n\n      it(\"controls access to forks of a logged in user correctly\", async function() {\n        await gu.removeLogin();\n        const doc2 = await team.tempDoc(cleanup, \"Hello.grist\", { load: false });\n        await api.updateDocPermissions(doc2.id, { maxInheritedRole: null });\n        await team.login();\n        await team.loadDoc(`/doc/${doc2.id}/m/fork`);\n        await gu.getCell({ rowNum: 1, col: 0 }).click();\n        await gu.enterCell(\"123\");\n        await gu.waitForServer();\n\n        // The url of the doc should now be that of a fork\n        const forkUrl = await driver.getCurrentUrl();\n        assert.match(forkUrl, /~/);\n        await gu.refreshDismiss();\n\n        // Open the original url and make sure the change we made is not there\n        await team.loadDoc(`/doc/${doc2.id}`);\n        assert.notEqual(await gu.getCell({ rowNum: 1, col: 0 }).value(), \"123\");\n\n        // Open the fork url and make sure the change we made is persisted there\n        await team.loadDoc((new URL(forkUrl)).pathname);\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"123\");\n\n        // Check we still have editing rights\n        await gu.getCell({ rowNum: 1, col: 0 }).click();\n        await gu.enterCell(\"1234\");\n        await gu.waitForServer();\n        await gu.refreshDismiss();\n        await team.loadDoc((new URL(forkUrl)).pathname);\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"1234\");\n\n        // Check others without view access to trunk cannot see fork\n        await team.user(\"user2\").login();\n        await driver.get(forkUrl);\n        assert.match(await driver.findWait(\".test-error-header\", 2000).getText(), /Access denied/);\n        assert.equal(await driver.find(\".test-dm-logo\").isDisplayed(), true);\n\n        await server.removeLogin();\n        await driver.get(forkUrl);\n        assert.match(await driver.findWait(\".test-error-header\", 2000).getText(), /Access denied/);\n        assert.equal(await driver.find(\".test-dm-logo\").isDisplayed(), true);\n      });\n\n      it(\"fails to create forks with inconsistent user id\", async function() {\n        // Note: this test is also valuable for triggering an error during openDoc flow even though\n        // the initial page load succeeds, and checking how such an error is shown.\n\n        const forkId = `${idType}fork${uuidv4()}`;\n        await team.login();\n        const userId = await team.getUserId();\n        const altSession = await team.user(\"user2\").login();\n        await altSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, { wait: false });\n\n        assert.equal(await driver.findWait(\".test-modal-dialog\", 2000).isDisplayed(), true);\n        assert.match(await driver.find(\".test-modal-dialog\").getText(), /Document fork not found/);\n\n        // A new doc cannot be created either (because of access\n        // mismatch - for forks of the doc used in these tests, user2\n        // would have some access to fork via acls on trunk, but for a\n        // new doc user2 has no access granted via the doc, or\n        // workspace, or org).\n        await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, { wait: false });\n        assert.match(await driver.findWait(\".test-error-header\", 2000).getText(), /Access denied/);\n        assert.equal(await driver.find(\".test-dm-logo\").isDisplayed(), true);\n\n        // Same, but as an anonymous user.\n        const anonSession = await altSession.anon.login();\n        await anonSession.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, { wait: false });\n        assert.equal(await driver.findWait(\".test-modal-dialog\", 2000).isDisplayed(), true);\n        assert.match(await driver.find(\".test-modal-dialog\").getText(), /Document fork not found/);\n\n        // A new doc cannot be created either (because of access mismatch).\n        await altSession.loadDoc(`/doc/new~${forkId}~${userId}`, { wait: false });\n        assert.match(await driver.findWait(\".test-error-header\", 2000).getText(), /Access denied/);\n        assert.equal(await driver.find(\".test-dm-logo\").isDisplayed(), true);\n\n        // Now as a user who *is* allowed to create the fork.\n        // But doc forks cannot be casually created this way anymore, so it still doesn't work.\n        await team.login();\n        await team.loadDoc(`/doc/${doc.id}~${forkId}~${userId}`, { wait: false });\n        assert.equal(await driver.findWait(\".test-modal-dialog\", 2000).isDisplayed(), true);\n        assert.match(await driver.find(\".test-modal-dialog\").getText(), /Document fork not found/);\n\n        // New document can no longer be casually created this way anymore either.\n        await team.loadDoc(`/doc/new~${forkId}~${userId}`, { wait: false });\n        assert.equal(await driver.findWait(\".test-modal-dialog\", 2000).isDisplayed(), true);\n        assert.match(await driver.find(\".test-modal-dialog\").getText(), /Document fork not found/);\n        await gu.wipeToasts();\n      });\n\n      it(\"should include the unsaved tags\", async function() {\n        await team.login();\n        // open a document\n        const trunk = await team.tempDoc(cleanup, \"World.grist\");\n\n        // make a fork\n        await team.loadDoc(`/doc/${trunk.id}/m/fork`);\n        await gu.getCell({ rowNum: 1, col: 0 }).click();\n        await gu.enterCell(\"123\");\n        await gu.waitForServer();\n        const forkUrl = await driver.getCurrentUrl();\n        assert.match(forkUrl, /~/);\n        await gu.refreshDismiss();\n\n        // check that there is no tag on trunk\n        await team.loadDoc(`/doc/${trunk.id}`);\n        assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), false);\n\n        // open same document with the fork bit in the URL\n        await team.loadDoc((new URL(forkUrl)).pathname);\n\n        // check that the tag is there\n        assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), true);\n      });\n\n      it(\"handles url history correctly\", async function() {\n        await team.login();\n        await makeDocIfAbsent();\n        await team.loadDoc(`/doc/${doc.id}/m/fork`);\n        const initialUrl = await driver.getCurrentUrl();\n        assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), false);\n        await gu.getCell({ rowNum: 1, col: 0 }).click();\n        await gu.enterCell(\"2\");\n        await gu.waitForServer();\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"2\");\n        const forkUrl = await driver.getCurrentUrl();\n        assert.equal(await driver.findWait(\".test-unsaved-tag\", 4000).isPresent(), true);\n\n        await driver.executeScript(\"history.back()\");\n        await gu.waitForUrl(/\\/m\\/fork/);\n        assert.equal(await driver.getCurrentUrl(), initialUrl);\n        await gu.waitForDocToLoad();\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"hello\");\n        assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), false);\n\n        await driver.executeScript(\"history.forward()\");\n        await gu.waitForUrl(/~/);\n        assert.equal(await driver.getCurrentUrl(), forkUrl);\n        await gu.waitForDocToLoad();\n        assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"2\");\n        assert.equal(await driver.find(\".test-unsaved-tag\").isPresent(), true);\n        await gu.refreshDismiss();\n      });\n\n      it(\"disables document renaming for forks\", async function() {\n        await team.login();\n        await team.loadDoc(`/doc/${doc.id}/m/fork`);\n        assert.equal(await driver.find(\".test-bc-doc\").getAttribute(\"disabled\"), null);\n        await gu.getCell({ rowNum: 1, col: 0 }).click();\n        await gu.enterCell(\"2\");\n        await gu.waitForServer();\n        await gu.waitToPass(async () => {\n          assert.equal(await driver.find(\".test-bc-doc\").getAttribute(\"disabled\"), \"true\");\n        });\n        await gu.refreshDismiss();\n      });\n\n      it(\"navigating browser history play well with the add new menu\", async function() {\n        await team.login();\n        await makeDocIfAbsent();\n        await team.loadDoc(`/doc/${doc.id}/m/fork`);\n        const count = await getAddNewEntryCount();\n\n        // edit one cell\n        await gu.getCell({ rowNum: 1, col: 0 }).click();\n        await gu.enterCell(\"2\");\n        await gu.waitForServer();\n\n        // check we're on a fork\n        await gu.waitForUrl(/~/);\n\n        // navigate back history\n        await driver.navigate().back();\n        await gu.waitForDocToLoad();\n\n        // check number of entries in add new menu are the same\n        assert.equal(await getAddNewEntryCount(), count);\n\n        // helper that get the number of items in the add new menu\n        async function getAddNewEntryCount() {\n          await driver.find(\".test-dp-add-new\").click();\n          const items = await driver.findAll(\".grist-floating-menu li\", e => e.getText());\n          assert.include(items, \"Import from file\");\n          await driver.sendKeys(Key.ESCAPE);\n          return items.length;\n        }\n      });\n\n      it(\"can replace a trunk document with a fork via api\", async function() {\n        await team.login();\n        await makeDocIfAbsent();\n        await team.loadDoc(`/doc/${doc.id}/m/fork`);\n\n        // edit one cell\n        await gu.getCell({ rowNum: 2, col: 0 }).click();\n        const v1 = await gu.getCell({ rowNum: 2, col: 0 }).getText();\n        const v2 = `${v1}_tweaked`;\n        await gu.enterCell(v2);\n        await gu.waitForServer();\n\n        // check we're on a fork\n        await gu.waitForUrl(/~/);\n        const urlId = await gu.getCurrentUrlId();\n        await gu.refreshDismiss();\n\n        // open trunk again, to test that page is reloaded after replacement\n        await team.loadDoc(`/doc/${doc.id}`);\n\n        // replace the trunk with the fork\n        assert.notEqual(urlId, doc.id);\n        assert.equal(await gu.getCell({ rowNum: 2, col: 0 }).getText(), v1);\n        const docApi = team.createHomeApi().getDocAPI(doc.id);\n        await docApi.replace({ sourceDocId: urlId! });\n\n        // check that replacement worked (giving a little time for page reload)\n        await gu.waitToPass(async () => {\n          assert.equal(await gu.getCell({ rowNum: 2, col: 0 }).getText(), v2);\n        }, 4000);\n\n        // have a different user make a doc we don't have access to\n        const altSession = await personal.user(\"user2\").login();\n        const altDoc = await altSession.tempDoc(cleanup, \"Hello.grist\");\n        await gu.dismissWelcomeTourIfNeeded();\n        await gu.getCell({ rowNum: 2, col: 0 }).click();\n        await gu.enterCell(\"altDoc\");\n        await gu.waitForServer();\n\n        // replacement should fail for document not found\n        // (error is \"not found\" for document in a different team site\n        // or team site vs personal site)\n        await assert.isRejected(docApi.replace({ sourceDocId: altDoc.id }), /not found/);\n        // replacement should fail for document not accessible\n        await assert.isRejected(personal.createHomeApi().getDocAPI(doc.id).replace({ sourceDocId: altDoc.id }),\n          /access denied/);\n\n        // check cell content does not change\n        await altSession.loadDoc(`/doc/${altDoc.id}`);\n        assert.equal(await gu.getCell({ rowNum: 2, col: 0 }).getText(), \"altDoc\");\n        await team.login();\n        await team.loadDoc(`/doc/${doc.id}`);\n        assert.equal(await gu.getCell({ rowNum: 2, col: 0 }).getText(), v2);\n      });\n\n      it(\"can replace a trunk document with a fork via UI\", async function() {\n        await team.login();\n        await makeDocIfAbsent();\n        await team.loadDoc(`/doc/${doc.id}/m/fork`);\n\n        // edit one cell.\n        await gu.getCell({ rowNum: 2, col: 0 }).click();\n        const v1 = await gu.getCell({ rowNum: 2, col: 0 }).getText();\n        const v2 = `${v1}_tweaked`;\n        await gu.enterCell(v2);\n        await gu.waitForServer();\n\n        // check we're on a fork.\n        await gu.waitForUrl(/~/);\n        const forkUrlId = await gu.getCurrentUrlId();\n        assert.equal(await driver.findWait(\".test-unsaved-tag\", 4000).isPresent(), true);\n\n        // check Replace Original gives expected button, and press it.\n        await driver.find(\".test-tb-share\").click();\n        await driver.find(\".test-replace-original\").click();\n        let confirmButton = driver.findWait(\".test-modal-confirm\", 3000);\n        assert.equal(await confirmButton.getText(), \"Update\");\n        await confirmButton.click();\n\n        // check we're no longer on a fork, but still have the change made on the fork.\n        await gu.waitForUrl(/^[^~]*$/, 6000);\n        await gu.waitForDocToLoad();\n        assert.equal(await gu.getCell({ rowNum: 2, col: 0 }).getText(), v2);\n\n        // edit the cell again.\n        await gu.getCell({ rowNum: 2, col: 0 }).click();\n        const v3 = `${v2}_tweaked`;\n        await gu.enterCell(v3);\n        await gu.waitForServer();\n\n        // revisit the fork.\n        await team.loadDoc(`/doc/${forkUrlId}`);\n        assert.equal(await driver.findWait(\".test-unsaved-tag\", 4000).isPresent(), true);\n        // check Replace Original gives a scarier button, and press it anyway.\n        await driver.find(\".test-tb-share\").click();\n        await driver.find(\".test-replace-original\").click();\n        confirmButton = driver.findWait(\".test-modal-confirm\", 3000);\n        assert.equal(await confirmButton.getText(), \"Overwrite\");\n        await confirmButton.click();\n\n        // check we're no longer on a fork, but have the fork's content.\n        await gu.waitForUrl(/^[^~]*$/, 6000);\n        await gu.waitForDocToLoad();\n        assert.equal(await gu.getCell({ rowNum: 2, col: 0 }).getText(), v2);\n\n        // revisit the fork.\n        await team.loadDoc(`/doc/${forkUrlId}`);\n        assert.equal(await driver.findWait(\".test-unsaved-tag\", 4000).isPresent(), true);\n        // check Replace Original mentions that the document is the same as the trunk.\n        await driver.find(\".test-tb-share\").click();\n        await driver.find(\".test-replace-original\").click();\n        confirmButton = driver.findWait(\".test-modal-confirm\", 3000);\n        assert.equal(await confirmButton.getText(), \"Update\");\n        assert.match(await driver.find(\".test-modal-dialog\").getText(),\n          /already identical/);\n        await gu.refreshDismiss();\n      });\n\n      it(\"gives an error when replacing without write access via UI\", async function() {\n        await team.login();\n        await makeDocIfAbsent();\n\n        // Give view access to a friend.\n        const altSession = await team.user(\"user2\").login();\n        await api.updateDocPermissions(doc.id, { users: { [altSession.email]: \"viewers\" } });\n        try {\n          await team.loadDoc(`/doc/${doc.id}/m/fork`);\n\n          // edit one cell.\n          await gu.getCell({ rowNum: 2, col: 0 }).click();\n          const v1 = await gu.getCell({ rowNum: 2, col: 0 }).getText();\n          const v2 = `${v1}_tweaked`;\n          await gu.enterCell(v2);\n          await gu.waitForServer();\n\n          // check we're on a fork.\n          await gu.waitForUrl(/~/);\n          await gu.waitForDocToLoad();\n          assert.equal(await driver.findWait(\".test-unsaved-tag\", 4000).isPresent(), true);\n\n          // check Replace Original does not let us proceed because we don't have\n          // editing rights on trunk.\n          await driver.find(\".test-tb-share\").click();\n          assert.equal(await driver.find(\".test-replace-original\").matches(\".disabled\"), true);\n          // Clicking the disabled element does nothing.\n          await driver.find(\".test-replace-original\").click();\n          assert.equal(await gu.findOpenMenu().isDisplayed(), true);\n          await assert.isRejected(driver.findWait(\".test-modal-dialog\", 500), /Waiting for element/);\n          await gu.refreshDismiss();\n        } finally {\n          await api.updateDocPermissions(doc.id, { users: { [altSession.email]: null } });\n        }\n      });\n\n      it(\"does not allow a fork to be forked\", testForksOfForks);\n    });\n  }\n});\n"
  },
  {
    "path": "test/nbrowser/FormView1.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport {\n  arrow,\n  clickMenu,\n  drops,\n  element,\n  elementCount,\n  elements,\n  hiddenColumn,\n  hiddenColumns,\n  isSelected,\n  labels,\n  plusButton,\n  question,\n  questionDrag,\n  questionType,\n  selectedLabel,\n} from \"test/nbrowser/formTools\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { setupExternalSite } from \"test/server/customUtil\";\nimport { EnvironmentSnapshot, fixturesRoot } from \"test/server/testUtils\";\n\nimport path from \"path\";\n\nimport { addToRepl, assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"FormView1\", function() {\n  this.timeout(20_000);   // Default for each test or hook.\n  gu.bigScreen(\"medium\");\n\n  let api: UserAPI;\n  let adminApi: UserAPI;\n  let docId: string;\n\n  const oldEnv = new EnvironmentSnapshot();\n  const cleanup = setupTestSuite();\n\n  addToRepl(\"question\", question);\n  addToRepl(\"labels\", labels);\n  addToRepl(\"questionType\", questionType);\n  const clipboard = gu.getLockableClipboard();\n\n  before(async function() {\n    process.env.GRIST_DEFAULT_EMAIL = gu.translateUser(\"support\").email;\n    // Disable doc auth cache, because we're going to mess with\n    // documents being disabled. We don't want the cache to delay\n    // application of disabled permissions.\n    process.env.GRIST_TEST_DOC_AUTH_CACHE_TTL = \"0\";\n    await server.restart(true);\n  });\n\n  after(async function() {\n    if (!gu.noCleanup) {\n      oldEnv.restore();\n      await server.restart(true);\n    }\n  });\n  afterEach(() => gu.checkForErrors());\n\n  async function createFormWith(\n    type: string,\n    options: {\n      redirectUrl?: string;\n    } = {},\n  ) {\n    const { redirectUrl } = options;\n\n    await gu.addNewSection(\"Form\", \"Table1\");\n\n    if (redirectUrl) {\n      await gu.openWidgetPanel();\n      await driver.find(\".test-config-submission\").click();\n      await driver.find(\".test-form-redirect\").click();\n      await gu.waitForServer();\n      await driver.find(\".test-form-redirect-url\").click();\n      await gu.sendKeys(redirectUrl, Key.ENTER);\n      await gu.waitForServer();\n    }\n\n    // Make sure column D is not there.\n    assert.isUndefined(await api.getTable(docId, \"Table1\").then(t => t.D));\n\n    // Add a text question\n    await plusButton().click();\n    if (\n      [\n        \"Integer\",\n        \"Toggle\",\n        \"Choice List\",\n        \"Reference\",\n        \"Reference List\",\n        \"Attachment\",\n      ].includes(type)\n    ) {\n      await clickMenu(\"More\");\n    }\n    await clickMenu(type);\n    await gu.waitForServer();\n\n    // Make sure we see this new question (D).\n    assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\"]);\n\n    await driver.find(\".test-forms-publish\").click();\n    if (await driver.find(\".test-modal-confirm\").isPresent()) {\n      await driver.find(\".test-modal-confirm\").click();\n    }\n    await gu.waitForServer();\n\n    // Now open the form in external window.\n    await clipboard.lockAndPerform(async (cb) => {\n      const shareButton = await driver.find(`.test-forms-share`);\n      await gu.scrollIntoView(shareButton);\n      await shareButton.click();\n      await gu.waitForServer();\n      await driver.findWait(\".test-forms-copy-link\", 1000).click();\n      await gu.waitToPass(async () => assert.match(\n        await driver.find(\".test-tooltip\").getText(), /Link copied to clipboard/), 1000);\n      await driver.find(\"#clipboardText\").click();\n      await gu.selectAll();\n      await cb.paste();\n    });\n\n    // Select it\n    await question(\"D\").click();\n\n    return await driver.find(\"#clipboardText\").value();\n  }\n\n  async function removeForm() {\n    // Remove this section.\n    await gu.openSectionMenu(\"viewLayout\");\n    await driver.find(\".test-section-delete\").click();\n    await gu.waitForServer();\n\n    // Remove record.\n    await gu.sendActions([\n      [\"RemoveRecord\", \"Table1\", 1],\n      [\"RemoveColumn\", \"Table1\", \"D\"],\n    ]);\n  }\n\n  async function waitForConfirm() {\n    await gu.waitForServer();\n    await gu.waitToPass(async () => {\n      assert.isTrue(await driver.findWait(\".test-form-success-page\", 2000).isDisplayed());\n      assert.equal(\n        await driver.find(\".test-form-success-page-text\").getText(),\n        \"Thank you! Your response has been recorded.\",\n      );\n      assert.equal(await driver.getTitle(), \"Form Submitted - Grist\");\n    });\n  }\n\n  async function expectSingle(value: any) {\n    assert.deepEqual(await api.getTable(docId, \"Table1\").then(t => t.D), [value]);\n  }\n\n  async function expectInD(values: any[]) {\n    assert.deepEqual(await api.getTable(docId, \"Table1\").then(t => t.D), values);\n  }\n\n  async function assertSubmitOnEnterIsDisabled() {\n    await gu.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    assert.isFalse(await driver.find(\".test-form-success-page\").isPresent());\n  }\n\n  describe(\"on personal site\", async function() {\n    before(async function() {\n      const adminSession = await gu.session().user(\"support\").login();\n      adminApi = adminSession.createHomeApi();\n      const session = await gu.session().login();\n      docId = await session.tempNewDoc(cleanup);\n      api = session.createHomeApi();\n    });\n\n    gu.withClipboardTextArea();\n\n    const externalSite = setupExternalSite(\"Dolphins are cool.\");\n\n    it(\"updates creator panel when navigated away\", async function() {\n      // Add 2 new pages.\n      await gu.addNewPage(\"Form\", \"New Table\", { tableName: \"TabA\" });\n      await gu.renamePage(\"TabA\");\n      await gu.addNewPage(\"Form\", \"New Table\", { tableName: \"TabB\" });\n\n      // Open the creator panel on field tab\n      await gu.openColumnPanel();\n\n      // Select A column\n      await question(\"A\").click();\n\n      // Make sure it is selected.\n      assert.equal(await selectedLabel(), \"A\");\n\n      // And creator panel reflects it.\n      assert.equal(await driver.find(\".test-field-label\").value(), \"A\");\n\n      // Now switch to page TabA.\n      await gu.openPage(\"TabA\");\n\n      // And select B column.\n      await question(\"B\").click();\n      assert.equal(await selectedLabel(), \"B\");\n\n      // Make sure creator panel reflects it (it didn't).\n      assert.equal(await driver.find(\".test-field-label\").value(), \"B\");\n\n      await gu.undo(2); // There was a bug with second undo.\n      await gu.undo();\n    });\n\n    it(\"triggers trigger formulas\", async function() {\n      const formUrl = await createFormWith(\"Text\");\n      // Add a trigger formula for this column.\n      await gu.showRawData();\n      await gu.getCell(\"D\", 1).click();\n      await gu.openColumnPanel();\n      await driver.find(\".test-field-set-trigger\").click();\n      await gu.waitAppFocus(false);\n      await gu.sendKeys('\"Hello from trigger\"', Key.ENTER);\n      await gu.waitForServer();\n      await gu.closeRawTable();\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('button[type=\"submit\"]', 2000).click();\n        await waitForConfirm();\n      });\n      await expectSingle(\"Hello from trigger\");\n      await removeForm();\n    });\n\n    it(\"attributes changes to the anonymous user\", async function() {\n      const formUrl = await createFormWith(\"Text\");\n\n      // Add a trigger formula with `user` and check it gets evaluated as the anonymous user.\n      await gu.showRawData();\n      await gu.getCell(\"D\", 1).click();\n      await gu.openColumnPanel();\n      await driver.find(\".test-field-set-trigger\").click();\n      await gu.waitAppFocus(false);\n      await gu.sendKeys('f\"{user.Email} {user.Name}\"', Key.ENTER);\n      await gu.waitForServer();\n      await gu.closeRawTable();\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('button[type=\"submit\"]', 2000).click();\n        await waitForConfirm();\n      });\n      const { email, name } = gu.translateUser(\"anon\");\n      const expectedCellValue = `${email} ${name}`;\n      await expectSingle(expectedCellValue);\n\n      // Check Document History also shows the action as originating from an anonymous user.\n      await driver.findWait(\".test-tools-log\", 1000).click();\n      await gu.waitToPass(() =>\n        driver.findContentWait(\".test-doc-history-tabs .test-select-button\", \"Activity\", 500).click());\n      const item = await driver.find(\".action_log .action_log_item\");\n      assert.equal(await item.find(\".action_log_cell_add\").getText(), expectedCellValue);\n      assert.equal(await item.find(\".action_info_user\").getText(), email);\n      await driver.find(\".test-right-tool-close\").click();\n\n      await removeForm();\n    });\n\n    it(\"forbids form access to disabled documents\", async function() {\n      const formUrl = await createFormWith(\"Text\");\n\n      await adminApi.disableDoc(docId);\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await gu.waitForServer();\n        const accessError = await driver.find(\".test-form-error-page-text\");\n        assert.equal(await accessError.getText(), \"You don't have access to this form.\");\n      });\n\n      await adminApi.enableDoc(docId);\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await gu.waitForServer();\n        await assert.isRejected(driver.find(\".test-form-error-page-text\"));\n      });\n\n      await removeForm();\n    });\n\n    it(\"has global markup correctly setup for screen reader users\", async function() {\n      const formUrl = await createFormWith(\"Text\");\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        // check we have main section, footer section, and \"powered by grist\" alt text is present\n        assert.isTrue(await driver.findWait(\"main\", 2000).isDisplayed());\n        assert.isTrue(await driver.findWait(\"footer\", 2000).isDisplayed());\n        assert.isTrue(await driver.findWait('[aria-label=\"Powered by Grist\"]', 2000).isDisplayed());\n\n        await gu.sendKeys(\"Hello\");\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      await removeForm();\n    });\n\n    it(\"can submit a form with single-line Text field\", async function() {\n      const formUrl = await createFormWith(\"Text\");\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        // click on the label: this implictly tests if the label is correctly associated with the input\n        await driver.findWait('label[for=\"D\"]', 2000).click();\n        await gu.sendKeys(\"Hello\");\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"Hello\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"\");\n        await driver.find('input[name=\"D\"]').click();\n        await gu.sendKeys(\"Hello World\");\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      // Make sure we see the new record.\n      await expectSingle(\"Hello World\");\n      await removeForm();\n    });\n\n    it(\"max length for single-line Text field\", async function() {\n      const formUrl = await createFormWith(\"Text\");\n      await gu.openColumnPanel();\n      await gu.waitForSidePanel();\n      const constraintInput = await driver.find(\".test-tb-form-field-constraint input\");\n      await constraintInput.sendKeys(5);\n      await constraintInput.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        // click on the label: this implictly tests if the label is correctly associated with the input\n        await driver.findWait('label[for=\"D\"]', 2000).click();\n        await gu.sendKeys(\"Hello\");\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"Hello\");\n        assert.equal(await driver.find(\".test-form-text-constraint\").getText(), \"(5 / 5)\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"\");\n        await driver.find('input[name=\"D\"]').click();\n        await gu.sendKeys(\"Hello World\");\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      // Make sure we see the new record.\n      await expectSingle(\"Hello\");\n      await removeForm();\n    });\n\n    it(\"can submit a form with multi-line Text field\", async function() {\n      const formUrl = await createFormWith(\"Text\");\n      await gu.openColumnPanel();\n      await gu.waitForSidePanel();\n      await driver.findContent(\".test-tb-form-field-format .test-select-button\", /Multi line/).click();\n      await gu.waitForServer();\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        // click on the label: this implictly tests if the label is correctly associated with the textarea\n        await driver.findWait('label[for=\"D\"]', 2000).click();\n        await gu.sendKeys(\"Hello\");\n        assert.equal(await driver.find('textarea[name=\"D\"]').value(), \"Hello\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('textarea[name=\"D\"]').value(), \"\");\n        await driver.find('textarea[name=\"D\"]').click();\n        await gu.sendKeys(\"Hello,\", Key.ENTER, \"World\");\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      // Make sure we see the new record.\n      await expectSingle(\"Hello,\\nWorld\");\n      await removeForm();\n    });\n\n    it(\"max length for multi-line Text field\", async function() {\n      const formUrl = await createFormWith(\"Text\");\n      await gu.openColumnPanel();\n      await gu.waitForSidePanel();\n      await driver.findContent(\".test-tb-form-field-format .test-select-button\", /Multi line/).click();\n      const constraintInput = await driver.find(\".test-tb-form-field-constraint input\");\n      await constraintInput.sendKeys(7);\n      await constraintInput.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        // click on the label: this implictly tests if the label is correctly associated with the textarea\n        await driver.findWait('label[for=\"D\"]', 2000).click();\n        await gu.sendKeys(\"Hello\");\n        assert.equal(await driver.find('textarea[name=\"D\"]').value(), \"Hello\");\n        assert.equal(await driver.find(\".test-form-text-constraint\").getText(), \"(5 / 7)\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('textarea[name=\"D\"]').value(), \"\");\n        await driver.find('textarea[name=\"D\"]').click();\n        await gu.sendKeys(\"Hello,\", Key.ENTER, \"World\");\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      // Make sure we see the new record.\n      await expectSingle(\"Hello,\\n\");\n      await removeForm();\n    });\n\n    it(\"can submit a form with text Numeric field\", async function() {\n      const formUrl = await createFormWith(\"Numeric\");\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        // click on the label: this implictly tests if the label is correctly associated with the input\n        await driver.findWait('label[for=\"D\"]', 2000).click();\n        await gu.sendKeys(\"1983\");\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1983\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"\");\n        await driver.find('input[name=\"D\"]').click();\n        await gu.sendKeys(\"1984\");\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      // Make sure we see the new record.\n      await expectSingle(1984);\n      await removeForm();\n    });\n\n    it(\"can submit a form with spinner Numeric field\", async function() {\n      const formUrl = await createFormWith(\"Numeric\");\n      await driver.findContent(\".test-numeric-form-field-format .test-select-button\", /Spinner/).click();\n      await gu.waitForServer();\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        // click on the label: this implictly tests if the label is correctly associated with the input\n        await driver.findWait('label[for=\"D\"]', 2000).click();\n        await gu.sendKeys(\"1983\");\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1983\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"\");\n        await driver.find('input[name=\"D\"]').click();\n        await gu.sendKeys(\"1984\", Key.ARROW_UP);\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1985\");\n        await gu.sendKeys(Key.ARROW_DOWN);\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1984\");\n        await driver.find(\".test-numeric-spinner-increment\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1985\");\n        await driver.find(\".test-numeric-spinner-decrement\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1984\");\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      // Make sure we see the new record.\n      await expectSingle(1984);\n      await removeForm();\n    });\n\n    it(\"can submit a form with Date field\", async function() {\n      const formUrl = await createFormWith(\"Date\");\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        // click on the label: this implictly tests if the label is correctly associated with the input\n        await driver.findWait('label[for=\"D\"]', 2000).click();\n        await gu.sendKeys(\"01011999\");\n        assert.equal(await driver.find('input[name=\"D\"]').getAttribute(\"value\"), \"1999-01-01\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').getAttribute(\"value\"), \"\");\n        await driver.find('input[name=\"D\"]').click();\n        await gu.sendKeys(\"01012000\");\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      // Make sure we see the new record.\n      await expectSingle(/* 2000-01-01 */946684800);\n      await removeForm();\n    });\n\n    it(\"can submit a form with select Choice field\", async function() {\n      const formUrl = await createFormWith(\"Choice\");\n      // Add some options.\n      await gu.choicesEditor.edit();\n      await gu.choicesEditor.add(\"Foo\");\n      await gu.choicesEditor.add(\"Bar\");\n      await gu.choicesEditor.add(\"Baz\");\n      await gu.choicesEditor.save();\n\n      // We need to press view, as form is not saved yet.\n      await gu.scrollActiveViewTop();\n      await gu.waitToPass(async () => {\n        assert.isTrue(await driver.find(\".test-forms-view\").isDisplayed());\n      });\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('select[name=\"D\"]', 2000);\n        await driver.findWait('label[for=\"D\"]', 2000);\n        // Make sure options are there.\n        assert.deepEqual(\n          await driver.findAll('select[name=\"D\"] option', e => e.getText()), [\"Select...\", \"Foo\", \"Bar\", \"Baz\"],\n        );\n        await driver.find(\".test-form-search-select\").click();\n        await gu.waitToPass(async () =>\n          assert.deepEqual(\n            await driver.findAll(\".test-sd-searchable-list-item\", e => e.getText()), [\"Foo\", \"Bar\", \"Baz\"],\n          ),\n        500);\n        await gu.sendKeys(\"Baz\", Key.ENTER);\n        assert.equal(await driver.find('select[name=\"D\"]').value(), \"Baz\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('select[name=\"D\"]').value(), \"\");\n        await driver.find(\".test-form-search-select\").click();\n        await driver.findContentWait(\".test-sd-searchable-list-item\", \"Bar\", 2000).click();\n        await driver.findWait(\".test-form-search-select-clear-btn\", 2000).click();\n        assert.equal(\n          await driver.find(\".test-form-search-select\").getText(),\n          \"Select...\",\n          'The \"Clear\" button should have cleared the selection',\n        );\n        await driver.find(\".test-form-search-select\").click();\n        await driver.findContentWait(\".test-sd-searchable-list-item\", \"Bar\", 2000).click();\n        // Check keyboard shortcuts work.\n        assert.equal(await driver.find(\".test-form-search-select\").getText(), \"Bar\");\n        await driver.sleep(50);\n        await gu.sendKeys(Key.BACK_SPACE);\n        await gu.waitToPass(async () =>\n          assert.equal(await driver.find(\".test-form-search-select\").getText(), \"Select...\"), 500);\n        await gu.sendKeys(Key.ENTER);\n        await driver.findContentWait(\".test-sd-searchable-list-item\", \"Bar\", 2000).click();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      await expectSingle(\"Bar\");\n      await removeForm();\n    });\n\n    it(\"can submit a form with radio Choice field\", async function() {\n      const formUrl = await createFormWith(\"Choice\");\n      await driver.findContent(\".test-form-field-format .test-select-button\", /Radio/).click();\n      await gu.waitForServer();\n      await gu.choicesEditor.edit();\n      await gu.choicesEditor.add(\"Foo\");\n      await gu.choicesEditor.add(\"Bar\");\n      await gu.choicesEditor.add(\"Baz\");\n      await gu.choicesEditor.save();\n      await gu.scrollActiveViewTop();\n      await gu.waitToPass(async () => {\n        assert.isTrue(await driver.find(\".test-forms-view\").isDisplayed());\n      });\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n\n        // items should be wrapped in a labelled group for better screen reader support\n        const firstItem = await driver.findWait('input[name=\"D\"]', 2000);\n        const container = await firstItem.findClosest('[aria-labelledby=\"D-label\"]');\n        assert.isTrue(await container.isDisplayed());\n        assert.isTrue(await container.find(\"#D-label\").isDisplayed());\n        assert.equal(await container.getAttribute(\"role\"), \"group\");\n\n        assert.deepEqual(\n          await driver.findAll('label:has(input[name=\"D\"])', e => e.getText()), [\"Foo\", \"Bar\", \"Baz\"],\n        );\n        await driver.find('input[name=\"D\"][value=\"Baz\"]').click();\n        assert.equal(await driver.find('input[name=\"D\"][value=\"Baz\"]').getAttribute(\"checked\"), \"true\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"][value=\"Baz\"]').getAttribute(\"checked\"), null);\n        await driver.find('input[name=\"D\"][value=\"Bar\"]').click();\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      await expectSingle(\"Bar\");\n      await removeForm();\n    });\n\n    it(\"can submit a form with text Integer field\", async function() {\n      const formUrl = await createFormWith(\"Integer\");\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        // click on the label: this implictly tests if the label is correctly associated with the input\n        await driver.findWait('label[for=\"D\"]', 2000).click();\n        await gu.sendKeys(\"1983\");\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1983\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"\");\n        await driver.find('input[name=\"D\"]').click();\n        await gu.sendKeys(\"1984\");\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      // Make sure we see the new record.\n      await expectSingle(1984);\n      await removeForm();\n    });\n\n    it(\"can submit a form with spinner Integer field\", async function() {\n      const formUrl = await createFormWith(\"Integer\");\n      await driver.findContent(\".test-numeric-form-field-format .test-select-button\", /Spinner/).click();\n      await gu.waitForServer();\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        // click on the label: this implictly tests if the label is correctly associated with the input\n        await driver.findWait('label[for=\"D\"]', 2000).click();\n        await gu.sendKeys(\"1983\");\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1983\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"\");\n        await driver.find('input[name=\"D\"]').click();\n        await gu.sendKeys(\"1984\", Key.ARROW_UP);\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1985\");\n        await gu.sendKeys(Key.ARROW_DOWN);\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1984\");\n        await driver.find(\".test-numeric-spinner-increment\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1985\");\n        await driver.find(\".test-numeric-spinner-decrement\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').value(), \"1984\");\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      // Make sure we see the new record.\n      await expectSingle(1984);\n      await removeForm();\n    });\n\n    it(\"can submit a form with switch Toggle field\", async function() {\n      const formUrl = await createFormWith(\"Toggle\");\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('input[name=\"D\"]', 2000).findClosest(\"label\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').getAttribute(\"checked\"), \"true\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').getAttribute(\"checked\"), null);\n        await driver.find('input[name=\"D\"]').findClosest(\"label\").click();\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      await expectSingle(true);\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('button[type=\"submit\"]', 2000).click();\n        await waitForConfirm();\n      });\n      await expectInD([true, false]);\n\n      // Remove the additional record added just now.\n      await gu.sendActions([\n        [\"RemoveRecord\", \"Table1\", 2],\n      ]);\n      await removeForm();\n    });\n\n    it(\"can submit a form with checkbox Toggle field\", async function() {\n      const formUrl = await createFormWith(\"Toggle\");\n      await driver.findContent(\".test-toggle-form-field-format .test-select-button\", /Checkbox/).click();\n      await gu.waitForServer();\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('input[name=\"D\"]', 2000).findClosest(\"label\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').getAttribute(\"checked\"), \"true\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"]').getAttribute(\"checked\"), null);\n        await driver.find('input[name=\"D\"]').findClosest(\"label\").click();\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      await expectSingle(true);\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('button[type=\"submit\"]', 2000).click();\n        await waitForConfirm();\n      });\n      await expectInD([true, false]);\n\n      // Remove the additional record added just now.\n      await gu.sendActions([\n        [\"RemoveRecord\", \"Table1\", 2],\n      ]);\n      await removeForm();\n    });\n\n    it(\"can submit a form with ChoiceList field\", async function() {\n      const formUrl = await createFormWith(\"Choice List\");\n      // Add some options.\n      await gu.openColumnPanel();\n\n      await gu.choicesEditor.edit();\n      await gu.choicesEditor.add(\"Foo\");\n      await gu.choicesEditor.add(\"Bar\");\n      await gu.choicesEditor.add(\"Baz\");\n      await gu.choicesEditor.save();\n      await gu.toggleSidePanel(\"right\", \"close\");\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n\n        // items should be wrapped in a labelled group for better screen reader support\n        const firstItem = await driver.findWait('input[name=\"D[]\"]', 2000);\n        const container = await firstItem.findClosest('[aria-labelledby=\"D-label\"]');\n        assert.isTrue(await container.isDisplayed());\n        assert.isTrue(await container.find(\"#D-label\").isDisplayed());\n        assert.equal(await container.getAttribute(\"role\"), \"group\");\n\n        await driver.findWait('input[name=\"D[]\"][value=\"Bar\"]', 2000).click();\n        assert.equal(await driver.find('input[name=\"D[]\"][value=\"Bar\"]').getAttribute(\"checked\"), \"true\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D[]\"][value=\"Bar\"]').getAttribute(\"checked\"), null);\n        await driver.find('input[name=\"D[]\"][value=\"Foo\"]').click();\n        await driver.find('input[name=\"D[]\"][value=\"Baz\"]').click();\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      await expectSingle([\"L\", \"Foo\", \"Baz\"]);\n\n      await removeForm();\n    });\n\n    it(\"can limit choices in a form with ChoiceList field\", async function() {\n      const choices = Array.from({ length: 35 }, (_, i) => `Option ${i + 1}`);\n      const formUrl = await createFormWith(\"Choice List\");\n\n      // Set 35 choices without a limit.\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Table1\", \"D\", {\n          widgetOptions: JSON.stringify({ choices }),\n        }],\n      ]);\n\n      // Open the config panel and set Options Limit to 5 via the UI.\n      await gu.openColumnPanel();\n      const limitInput = await driver.findWait(\".test-form-field-options-limit\", 2000);\n      await limitInput.click();\n      await gu.selectAll();\n      await driver.sendKeys(\"5\", Key.TAB);\n      await gu.waitForServer();\n\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('input[name=\"D[]\"]', 2000);\n        const items = await driver.findAll('input[name=\"D[]\"]');\n        assert.equal(items.length, 5);\n      });\n\n      // Raise the limit to 35 via the UI.\n      const limitInput2 = await driver.findWait(\".test-form-field-options-limit\", 2000);\n      await limitInput2.click();\n      await gu.selectAll();\n      await driver.sendKeys(\"35\", Key.TAB);\n      await gu.waitForServer();\n\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('input[name=\"D[]\"]', 2000);\n        const items = await driver.findAll('input[name=\"D[]\"]');\n        assert.equal(items.length, 35);\n      });\n\n      // Clear the limit via the UI — should fall back to the default of 30.\n      const limitInput3 = await driver.findWait(\".test-form-field-options-limit\", 2000);\n      await limitInput3.click();\n      await gu.selectAll();\n      await driver.sendKeys(Key.DELETE, Key.TAB);\n      await gu.waitForServer();\n\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('input[name=\"D[]\"]', 2000);\n        const items = await driver.findAll('input[name=\"D[]\"]');\n        assert.equal(items.length, 30);\n      });\n\n      await removeForm();\n    });\n\n    it(\"can submit a form with select Ref field\", async function() {\n      const formUrl = await createFormWith(\"Reference\");\n      // Add some options.\n      await gu.openColumnPanel();\n      await gu.setRefShowColumn(\"A\");\n      // Add 3 records to this table (it is now empty).\n      await gu.sendActions([\n        [\"AddRecord\", \"Table1\", null, { A: \"Foo\" }], // id 1\n        [\"AddRecord\", \"Table1\", null, { A: \"Bar\" }], // id 2\n        [\"AddRecord\", \"Table1\", null, { A: \"Baz\" }], // id 3\n      ]);\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('select[name=\"D\"]', 2000);\n        await driver.findWait('label[for=\"D\"]', 2000);\n        assert.deepEqual(\n          await driver.findAll('select[name=\"D\"] option', e => e.getText()),\n          [\"Select...\", \"Foo\", \"Bar\", \"Baz\"],\n        );\n        assert.deepEqual(\n          await driver.findAll('select[name=\"D\"] option', e => e.value()),\n          [\"\", \"1\", \"2\", \"3\"],\n        );\n        await driver.find(\".test-form-search-select\").click();\n        assert.deepEqual(\n          await driver.findAll(\".test-sd-searchable-list-item\", e => e.getText()), [\"Foo\", \"Bar\", \"Baz\"],\n        );\n        await gu.sendKeys(\"Baz\", Key.ENTER);\n        assert.equal(await driver.find('select[name=\"D\"]').value(), \"3\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('select[name=\"D\"]').value(), \"\");\n        await driver.find(\".test-form-search-select\").click();\n        await driver.findContentWait(\".test-sd-searchable-list-item\", \"Bar\", 2000).click();\n        await driver.findWait(\".test-form-search-select-clear-btn\", 2000).click();\n        assert.equal(\n          await driver.find(\".test-form-search-select\").getText(),\n          \"Select...\",\n          'The \"Clear\" button should have cleared the selection',\n        );\n        await driver.find(\".test-form-search-select\").click();\n        await driver.findContentWait(\".test-sd-searchable-list-item\", \"Bar\", 2000).click();\n        // Check keyboard shortcuts work.\n        assert.equal(await driver.find(\".test-form-search-select\").getText(), \"Bar\");\n        await gu.sendKeys(Key.BACK_SPACE);\n        assert.equal(await driver.find(\".test-form-search-select\").getText(), \"Select...\");\n        await gu.sendKeys(Key.ENTER);\n        await driver.findContentWait(\".test-sd-searchable-list-item\", \"Bar\", 2000).click();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      await expectInD([0, 0, 0, 2]);\n\n      // Remove 3 records.\n      await gu.sendActions([\n        [\"BulkRemoveRecord\", \"Table1\", [1, 2, 3, 4]],\n      ]);\n\n      await removeForm();\n    });\n\n    it(\"can search in a Ref field selection box\", async function() {\n      const formUrl = await createFormWith(\"Reference\");\n      // Add some options.\n      await gu.openColumnPanel();\n      await gu.setRefShowColumn(\"A\");\n      const alpha = Array.from({ length: 26 }, (_, i) => String.fromCharCode(\"a\".charCodeAt(0) + i));\n      // Add records with values 'aa', 'ab', ..., 'zz' for the column A\n      const twoLettersCombination = alpha.flatMap(firstLetter =>\n        alpha.map(secondLetter => firstLetter + secondLetter),\n      );\n      await gu.sendActions(\n        twoLettersCombination.map(twoLetters => [\"AddRecord\", \"Table1\", null, { A: twoLetters }]),\n      );\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('select[name=\"D\"]', 2000);\n        await driver.findWait('label[for=\"D\"]', 2000);\n        await driver.find(\".test-form-search-select\").click();\n        assert.deepEqual(\n          await driver.findAll(\".test-sd-searchable-list-item\", e => e.getText()),\n          twoLettersCombination.slice(0, 100),\n          \"should show only the 100 first elements\",\n        );\n        assert.deepEqual(\n          await driver.findAll(\".test-sd-searchable-list-item\", e => e.getText()),\n          twoLettersCombination.slice(0, 100),\n          \"should show only the 100 first elements\",\n        );\n        assert.match(\n          await driver.find(\".test-sd-truncated-message\").getText(),\n          new RegExp(`Showing 100 of ${twoLettersCombination.length}`, \"i\"),\n          \"should show only the 100 first elements\",\n        );\n        await driver.find(\".test-sd-search\").click();\n        await driver.find(\".test-sd-search input\").sendKeys(\"zz\");\n        assert.deepEqual(\n          (await driver.findAll(\".test-sd-searchable-list-item\", e => e.getText())).slice(0, 3),\n          [\"zz\", \"za\", \"zb\"],\n          \"should order the results given the search criteria\",\n        );\n      });\n      // Remove all records.\n      await gu.sendActions([\n        [\"BulkRemoveRecord\", \"Table1\", twoLettersCombination.map((_, i) => i + 1)],\n      ]);\n      await removeForm();\n    });\n\n    it(\"can submit a form with radio Ref field\", async function() {\n      const formUrl = await createFormWith(\"Reference\");\n      await driver.findContent(\".test-form-field-format .test-select-button\", /Radio/).click();\n      await gu.waitForServer();\n      await gu.setRefShowColumn(\"A\");\n      await gu.sendActions([\n        [\"AddRecord\", \"Table1\", null, { A: \"Foo\" }],\n        [\"AddRecord\", \"Table1\", null, { A: \"Bar\" }],\n        [\"AddRecord\", \"Table1\", null, { A: \"Baz\" }],\n      ]);\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n\n        // items should be wrapped in a labelled group for better screen reader support\n        const firstItem = await driver.findWait('input[name=\"D\"]', 2000);\n        const container = await firstItem.findClosest('[aria-labelledby=\"D-label\"]');\n        assert.isTrue(await container.isDisplayed());\n        assert.isTrue(await container.find(\"#D-label\").isDisplayed());\n        assert.equal(await container.getAttribute(\"role\"), \"group\");\n\n        assert.deepEqual(\n          await driver.findAll('label:has(input[name=\"D\"])', e => e.getText()), [\"Foo\", \"Bar\", \"Baz\"],\n        );\n        assert.equal(await driver.find('label:has(input[name=\"D\"][value=\"3\"])').getText(), \"Baz\");\n        await driver.find('input[name=\"D\"][value=\"3\"]').click();\n        assert.equal(await driver.find('input[name=\"D\"][value=\"3\"]').getAttribute(\"checked\"), \"true\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D\"][value=\"3\"]').getAttribute(\"checked\"), null);\n        assert.equal(await driver.find('label:has(input[name=\"D\"][value=\"2\"])').getText(), \"Bar\");\n        await driver.find('input[name=\"D\"][value=\"2\"]').click();\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      await expectInD([0, 0, 0, 2]);\n\n      // Remove 3 records.\n      await gu.sendActions([\n        [\"BulkRemoveRecord\", \"Table1\", [1, 2, 3, 4]],\n      ]);\n\n      await removeForm();\n    });\n\n    it(\"can submit a form with RefList field\", async function() {\n      const formUrl = await createFormWith(\"Reference List\");\n      // Add some options.\n      await gu.setRefShowColumn(\"A\");\n      // Add 3 records to this table (it is now empty).\n      await gu.sendActions([\n        [\"AddRecord\", \"Table1\", null, { A: \"Foo\" }], // id 1\n        [\"AddRecord\", \"Table1\", null, { A: \"Bar\" }], // id 2\n        [\"AddRecord\", \"Table1\", null, { A: \"Baz\" }], // id 3\n      ]);\n      await gu.toggleSidePanel(\"right\", \"close\");\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n\n        // items should be wrapped in a labelled group for better screen reader support\n        const firstItem = await driver.findWait('input[name=\"D[]\"]', 2000);\n        const container = await firstItem.findClosest('[aria-labelledby=\"D-label\"]');\n        assert.isTrue(await container.isDisplayed());\n        assert.isTrue(await container.find(\"#D-label\").isDisplayed());\n        assert.equal(await container.getAttribute(\"role\"), \"group\");\n\n        assert.equal(await driver.findWait('label:has(input[name=\"D[]\"][value=\"1\"])', 2000).getText(), \"Foo\");\n        assert.equal(await driver.find('label:has(input[name=\"D[]\"][value=\"2\"])').getText(), \"Bar\");\n        assert.equal(await driver.find('label:has(input[name=\"D[]\"][value=\"3\"])').getText(), \"Baz\");\n        await driver.find('input[name=\"D[]\"][value=\"1\"]').click();\n        assert.equal(await driver.find('input[name=\"D[]\"][value=\"1\"]').getAttribute(\"checked\"), \"true\");\n        await driver.find(\".test-form-reset\").click();\n        await driver.find(\".test-modal-confirm\").click();\n        assert.equal(await driver.find('input[name=\"D[]\"][value=\"1\"]').getAttribute(\"checked\"), null);\n        await driver.find('input[name=\"D[]\"][value=\"1\"]').click();\n        await driver.find('input[name=\"D[]\"][value=\"2\"]').click();\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      await expectInD([null, null, null, [\"L\", 1, 2]]);\n\n      // Remove 3 records.\n      await gu.sendActions([\n        [\"BulkRemoveRecord\", \"Table1\", [1, 2, 3, 4]],\n      ]);\n\n      await removeForm();\n    });\n\n    it(\"can limit references in a form with RefList field\", async function() {\n      const formUrl = await createFormWith(\"Reference List\");\n      await gu.openColumnPanel();\n      await gu.setRefShowColumn(\"A\");\n      // Add 10 records.\n      await gu.sendActions(\n        Array.from({ length: 10 }, (_, i) => [\"AddRecord\", \"Table1\", null, { A: `Item ${i + 1}` }]),\n      );\n\n      // Set a limit of 3 via the config UI.\n      const limitInput = await driver.findWait(\".test-form-field-options-limit\", 2000);\n      await limitInput.click();\n      await gu.selectAll();\n      await driver.sendKeys(\"3\", Key.TAB);\n      await gu.waitForServer();\n\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('input[name=\"D[]\"]', 2000);\n        const items = await driver.findAll('input[name=\"D[]\"]');\n        assert.equal(items.length, 3);\n      });\n\n      // Raise the limit to 10 via the config UI.\n      const limitInput2 = await driver.findWait(\".test-form-field-options-limit\", 2000);\n      await limitInput2.click();\n      await gu.selectAll();\n      await driver.sendKeys(\"10\", Key.TAB);\n      await gu.waitForServer();\n\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('input[name=\"D[]\"]', 2000);\n        const items = await driver.findAll('input[name=\"D[]\"]');\n        assert.equal(items.length, 10);\n      });\n\n      // Clean up records and form.\n      await gu.sendActions([\n        [\"BulkRemoveRecord\", \"Table1\", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]],\n      ]);\n      await removeForm();\n    });\n\n    it(\"redirects to valid URLs on submission\", async function() {\n      const url = await createFormWith(\"Text\", {\n        redirectUrl: externalSite.getUrl().href,\n      });\n      await gu.onNewTab(async () => {\n        await driver.get(url);\n        await driver.findWait('button[type=\"submit\"]', 2000).click();\n        await gu.waitForUrl(/localtest\\.datagrist\\.com/);\n      });\n      await removeForm();\n    });\n\n    it(\"does not redirect to invalid URLs on submission\", async function() {\n      const url = await createFormWith(\"Text\", {\n        redirectUrl: \"javascript:alert()\",\n      });\n      await gu.onNewTab(async () => {\n        await driver.get(url);\n        await driver.findWait('button[type=\"submit\"]', 2000).click();\n        await waitForConfirm();\n        assert.isFalse(await gu.isAlertShown());\n      });\n      await removeForm();\n    });\n\n    it(\"excludes formula fields from forms\", async function() {\n      const formUrl = await createFormWith(\"Text\");\n\n      // Temporarily make A a formula column.\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Table1\", \"A\", { formula: '\"hello\"', isFormula: true }],\n      ]);\n\n      // Check that A is hidden in the form editor.\n      await gu.waitToPass(async () => assert.deepEqual(await labels(), [\"B\", \"C\", \"D\"]));\n      await gu.openWidgetPanel(\"widget\");\n      assert.deepEqual(\n        await driver.findAll(\".test-vfc-visible-field\", e => e.getText()),\n        [\"B\", \"C\", \"D\"],\n      );\n      assert.deepEqual(\n        await driver.findAll(\".test-vfc-hidden-field\", e => e.getText()),\n        [],\n      );\n\n      // Check that A is excluded from the published form.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('input[name=\"D\"]', 2000).click();\n        await gu.sendKeys(\"Hello World\");\n        assert.isFalse(await driver.find('input[name=\"A\"]').isPresent());\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n\n      // Make sure we see the new record.\n      await expectInD([\"Hello World\"]);\n\n      // And check that A was not modified.\n      assert.deepEqual(await api.getTable(docId, \"Table1\").then(t => t.A), [\"hello\"]);\n\n      // Revert A and check that it's visible again in the editor.\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Table1\", \"A\", { formula: \"\", isFormula: false }],\n      ]);\n      await gu.waitToPass(async () => assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\"]));\n      assert.deepEqual(\n        await driver.findAll(\".test-vfc-visible-field\", e => e.getText()),\n        [\"A\", \"B\", \"C\", \"D\"],\n      );\n      assert.deepEqual(\n        await driver.findAll(\".test-vfc-hidden-field\", e => e.getText()),\n        [],\n      );\n\n      await removeForm();\n    });\n\n    it(\"can submit a form with file input\", async function() {\n      const formUrl = await createFormWith(\"Attachment\");\n\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n\n        const attachmentInput = await driver.findWait('input[name=\"D\"]', 2000);\n        await driver.findWait('label[for=\"D\"]', 2000);\n\n        const paths = [\n          path.resolve(fixturesRoot, \"uploads/grist.png\"),\n          path.resolve(fixturesRoot, \"uploads/names.json\"),\n        ].map(f => path.resolve(fixturesRoot, f)).join(\"\\n\");\n        await attachmentInput.sendKeys(paths);\n\n        await assertSubmitOnEnterIsDisabled();\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n\n      // Expects the 2 attachments have been uploaded and have been associated\n      const expectedUploadIds = [1, 2];\n      await expectInD([[\"L\", ...expectedUploadIds]]);\n\n      const docApi = api.getDocAPI(docId);\n      const url = `${docApi.getBaseUrl()}/attachments`;\n      const headers = { Authorization: `Bearer ${await api.fetchApiKey()}` };\n      const response = await fetch(url, {\n        headers,\n        method: \"GET\",\n      }).then(data => data.json());\n\n      assert.lengthOf(response.records, 2);\n      assert.equal(response.records[0].fields.fileName, \"grist.png\");\n      assert.isAbove(response.records[0].fields.fileSize, 0);\n      assert.equal(response.records[1].fields.fileName, \"names.json\");\n      assert.isAbove(response.records[1].fields.fileSize, 0);\n\n      await removeForm();\n    });\n\n    it(\"can unpublish forms\", async function() {\n      const formUrl = await createFormWith(\"Text\");\n      await driver.find(\".test-forms-unpublish\").click();\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        assert.isTrue(await driver.findWait(\".test-form-error-page\", 2000).isDisplayed());\n        assert.equal(\n          await driver.find(\".test-form-error-page-text\").getText(),\n          \"Oops! This form is no longer published.\",\n        );\n      });\n\n      // Republish the form and check that the same URL works again.\n      await driver.find(\".test-forms-publish\").click();\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('input[name=\"D\"]', 2000);\n      });\n    });\n\n    it(\"can stop showing warning when publishing or unpublishing\", async function() {\n      // Click \"Don't show again\" in both modals and confirm.\n      await driver.find(\".test-forms-unpublish\").click();\n      await driver.find(\".test-modal-dont-show-again\").click();\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n      await driver.find(\".test-forms-publish\").click();\n      await driver.find(\".test-modal-dont-show-again\").click();\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n\n      // Check that the modals are no longer shown when publishing or unpublishing.\n      await driver.find(\".test-forms-unpublish\").click();\n      await gu.waitForServer();\n      assert.isFalse(await driver.find(\".test-modal-title\").isPresent());\n      await driver.find(\".test-forms-publish\").click();\n      await gu.waitForServer();\n      assert.isFalse(await driver.find(\".test-modal-title\").isPresent());\n    });\n\n    it(\"can create a form for a blank table\", async function() {\n      // Add new page and select form.\n      await gu.addNewPage(\"Form\", \"New Table\", {\n        tableName: \"Form\",\n      });\n\n      // Make sure we see a form editor.\n      assert.isTrue(await driver.find(\".test-forms-editor\").isDisplayed());\n\n      // With 3 questions A, B, C.\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n\n      // And a submit button.\n      assert.isTrue(await driver.findContent(\".test-forms-submit\", gu.exactMatch(\"Submit\")).isDisplayed());\n    });\n\n    it(\"doesn't generate fields when they are added\", async function() {\n      await gu.sendActions([\n        [\"AddVisibleColumn\", \"Form\", \"Choice\",\n          { type: \"Choice\", widgetOption: JSON.stringify({ choices: [\"A\", \"B\", \"C\"] }) }],\n      ]);\n\n      // Make sure we see a form editor.\n      assert.isTrue(await driver.find(\".test-forms-editor\").isDisplayed());\n      await driver.sleep(100);\n      assert.isFalse(\n        await driver.findContent(\".test-forms-question-choice .test-forms-label\", gu.exactMatch(\"Choice\")).isPresent(),\n      );\n    });\n\n    it(\"supports basic drag and drop\", async function() {\n      // Make sure the order is right.\n      assert.deepEqual(\n        await labels(), [\"A\", \"B\", \"C\"],\n      );\n\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"B\") })\n          .press()\n          .move({ origin: questionDrag(\"A\") })\n          .release(),\n      );\n\n      await gu.waitForServer();\n\n      // Make sure the order is right.\n      assert.deepEqual(\n        await labels(), [\"B\", \"A\", \"C\"],\n      );\n\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"C\") })\n          .press()\n          .move({ origin: questionDrag(\"B\") })\n          .release(),\n      );\n\n      await gu.waitForServer();\n\n      // Make sure the order is right.\n      assert.deepEqual(\n        await labels(), [\"C\", \"B\", \"A\"],\n      );\n\n      // Now move A on A and make sure nothing changes.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"A\") })\n          .press()\n          .move({ origin: questionDrag(\"A\"), x: 50 })\n          .release(),\n      );\n\n      await gu.waitForServer();\n      assert.deepEqual(await labels(), [\"C\", \"B\", \"A\"]);\n    });\n\n    it(\"can undo drag and drop\", async function() {\n      await gu.undo();\n      assert.deepEqual(await labels(), [\"B\", \"A\", \"C\"]);\n\n      await gu.undo();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n    });\n\n    it(\"adds new question at the end\", async function() {\n      // We should see single drop zone.\n      assert.equal((await drops()).length, 1);\n\n      // Move the A over there.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"A\") })\n          .press()\n          .move({ origin: plusButton().drag() })\n          .release(),\n      );\n\n      await gu.waitForServer();\n      assert.deepEqual(await labels(), [\"B\", \"C\", \"A\"]);\n      await gu.undo();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n\n      // Now add a new question.\n      await plusButton().click();\n\n      await clickMenu(\"Text\");\n      await gu.waitForServer();\n\n      // We should have new column D or type text.\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\"]);\n      assert.equal(await questionType(\"D\"), \"Text\");\n\n      await gu.undo();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n    });\n\n    it(\"adds question in the middle\", async function() {\n      await driver.withActions(a => a.contextClick(question(\"B\")));\n      await clickMenu(\"Insert question above\");\n      await gu.waitForServer();\n      assert.deepEqual(await labels(), [\"A\", \"D\", \"B\", \"C\"]);\n\n      // Now below C.\n      await driver.withActions(a => a.contextClick(question(\"B\")));\n      await clickMenu(\"Insert question below\");\n      await gu.waitForServer();\n      assert.deepEqual(await labels(), [\"A\", \"D\", \"B\", \"E\", \"C\"]);\n\n      // Make sure they are draggable.\n      // Move D infront of C.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"D\") })\n          .press()\n          .move({ origin: questionDrag(\"C\") })\n          .release(),\n      );\n\n      await gu.waitForServer();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"E\", \"D\", \"C\"]);\n\n      // Remove 3 times.\n      await gu.undo(3);\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n    });\n\n    it(\"selection works\", async function() {\n      // Click on A.\n      await question(\"A\").click();\n\n      // Now A is selected.\n      assert.equal(await selectedLabel(), \"A\");\n\n      // Click B.\n      await question(\"B\").click();\n\n      // Now B is selected.\n      assert.equal(await selectedLabel(), \"B\");\n\n      // Click the blank space above the submit button.\n      await driver.find(\".test-forms-error\").click();\n\n      // Now nothing is selected.\n      assert.isFalse(await isSelected(), \"Something is selected\");\n\n      // When we add new question, it is automatically selected.\n      await plusButton().click();\n      await clickMenu(\"Text\");\n      await gu.waitForServer();\n      // Now D is selected.\n      assert.equal(await selectedLabel(), \"D\");\n\n      await gu.undo();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n      await question(\"A\").click();\n    });\n\n    it(\"hiding and revealing works\", async function() {\n      await gu.toggleSidePanel(\"left\", \"close\");\n      await gu.openWidgetPanel();\n\n      // We have only one hidden column.\n      assert.deepEqual(await hiddenColumns(), [\"Choice\"]);\n\n      // Make sure we see it in the menu.\n      await plusButton().click();\n\n      // We have 1 unmapped menu item.\n      await driver.findWait(\".test-forms-menu-unmapped\", 200);\n      const unmappedMenuItemCount = (await driver.findAll(\".test-forms-menu-unmapped\")).length;\n      assert.equal(unmappedMenuItemCount, 1);\n\n      // Now move it to the form on B\n      await driver.withActions(a =>\n        a.move({ origin: hiddenColumn(\"Choice\") })\n          .press()\n          .move({ origin: questionDrag(\"B\") })\n          .release(),\n      );\n      await gu.waitForServer();\n\n      // It should be after A.\n      await gu.waitToPass(async () => {\n        assert.deepEqual(await labels(), [\"A\", \"Choice\", \"B\", \"C\"]);\n      }, 500);\n\n      // Undo to make sure it is bundled.\n      await gu.undo();\n\n      // It should be hidden again.\n      assert.deepEqual(await hiddenColumns(), [\"Choice\"]);\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n\n      // And redo.\n      await gu.redo();\n      assert.deepEqual(await labels(), [\"A\", \"Choice\", \"B\", \"C\"]);\n      assert.deepEqual(await hiddenColumns(), []);\n\n      // Now hide it using menu.\n      await question(\"Choice\").rightClick();\n      await clickMenu(\"Hide\");\n      await gu.waitForServer();\n\n      // It should be hidden again.\n      assert.deepEqual(await hiddenColumns(), [\"Choice\"]);\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n\n      // And undo.\n      await gu.undo();\n      assert.deepEqual(await labels(), [\"A\", \"Choice\", \"B\", \"C\"]);\n      assert.deepEqual(await hiddenColumns(), []);\n\n      // And redo.\n      await gu.redo();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n      assert.deepEqual(await hiddenColumns(), [\"Choice\"]);\n\n      // Now unhide it using menu.\n      await plusButton().click();\n      await driver.find(\".test-forms-menu-unmapped\").click();\n      await gu.waitForServer();\n\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"Choice\"]);\n      assert.deepEqual(await hiddenColumns(), []);\n\n      // Now hide it using Delete key.\n      await question(\"Choice\").click();\n      await gu.sendKeys(Key.DELETE);\n      await gu.waitForServer();\n\n      // It should be hidden again.\n      assert.deepEqual(await hiddenColumns(), [\"Choice\"]);\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n    });\n\n    it(\"changing field types works\", async function() {\n      await gu.openColumnPanel();\n      assert.equal(await questionType(\"A\"), \"Any\");\n      await question(\"A\").click();\n      await gu.setType(\"Text\");\n      assert.equal(await questionType(\"A\"), \"Text\");\n      await gu.sendActions([[\"AddRecord\", \"Form\", null, { A: \"Foo\" }]]);\n      await question(\"A\").click();\n      await gu.setType(\"Numeric\", { apply: true });\n      assert.equal(await questionType(\"A\"), \"Numeric\");\n      await gu.sendActions([[\"RemoveRecord\", \"Form\", 1]]);\n      await gu.undo(2);\n      await gu.toggleSidePanel(\"right\", \"close\");\n    });\n\n    it(\"basic keyboard navigation works\", async function() {\n      await question(\"A\").click();\n      assert.equal(await selectedLabel(), \"A\");\n\n      // Move down.\n      await arrow(Key.ARROW_DOWN);\n      assert.equal(await selectedLabel(), \"B\");\n\n      // Move up.\n      await arrow(Key.ARROW_UP);\n      assert.equal(await selectedLabel(), \"A\");\n\n      // Move down to C.\n      await arrow(Key.ARROW_DOWN, 2);\n      assert.equal(await selectedLabel(), \"C\");\n\n      // Move down we should be at A (past the submit button, and titles and sections).\n      await arrow(Key.ARROW_DOWN, 7);\n      assert.equal(await selectedLabel(), \"A\");\n\n      // Do the same with Left and Right.\n      await arrow(Key.ARROW_RIGHT);\n      assert.equal(await selectedLabel(), \"B\");\n      await arrow(Key.ARROW_LEFT);\n      assert.equal(await selectedLabel(), \"A\");\n      await arrow(Key.ARROW_RIGHT, 2);\n      assert.equal(await selectedLabel(), \"C\");\n    });\n\n    it(\"cutting works\", async function() {\n      const revert = await gu.begin();\n      await question(\"A\").click();\n      // Send copy command.\n      await clipboard.lockAndPerform(async (cb) => {\n        await cb.cut();\n        await gu.sendKeys(Key.ARROW_DOWN); // Focus on B.\n        await gu.sendKeys(Key.ARROW_DOWN); // Focus on C.\n        await cb.paste();\n      });\n      await gu.waitForServer();\n      await gu.waitToPass(async () => {\n        assert.deepEqual(await labels(), [\"B\", \"A\", \"C\"]);\n      });\n      await gu.undo();\n      await gu.waitToPass(async () => {\n        assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n      });\n\n      // To the same for paragraph.\n      await plusButton().click();\n      await clickMenu(\"Paragraph\");\n      await gu.waitForServer();\n      await element(\"Paragraph\", 5).click();\n      await clipboard.lockAndPerform(async (cb) => {\n        await cb.cut();\n        // Go over A and paste there.\n        await gu.sendKeys(Key.ARROW_UP); // Focus on C.\n        await gu.sendKeys(Key.ARROW_UP); // Focus on B.\n        await gu.sendKeys(Key.ARROW_UP); // Focus on A.\n        await cb.paste();\n      });\n      await gu.waitForServer();\n\n      // Paragraph should be the first one now.\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n      let elements = await driver.findAll(\".test-forms-element\");\n      assert.isTrue(await elements[0].matches(\".test-forms-Paragraph\"));\n      assert.isTrue(await elements[1].matches(\".test-forms-Paragraph\"));\n      assert.isTrue(await elements[2].matches(\".test-forms-Section\"));\n      assert.isTrue(await elements[3].matches(\".test-forms-Paragraph\"));\n      assert.isTrue(await elements[4].matches(\".test-forms-Paragraph\"));\n      assert.isTrue(await elements[5].matches(\".test-forms-Paragraph\"));\n\n      // Put it back using undo.\n      await gu.undo();\n      elements = await driver.findAll(\".test-forms-element\");\n      assert.isTrue(await elements[5].matches(\".test-forms-Field\"));\n      // 0 - A, 1 - B, 2 - C, 3 - submit button.\n      assert.isTrue(await elements[8].matches(\".test-forms-Paragraph\"));\n\n      await revert();\n    });\n\n    const checkInitial = async () => assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n    const checkNewCol = async () => {\n      assert.equal(await selectedLabel(), \"D\");\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\"]);\n      await gu.undo();\n      await checkInitial();\n    };\n    const checkFieldsAtFirstLevel = (menuText: string) => {\n      it(`can add ${menuText} elements from the menu`, async function() {\n        await plusButton().click();\n        await clickMenu(menuText);\n        await gu.waitForServer();\n        await checkNewCol();\n      });\n    };\n\n    checkFieldsAtFirstLevel(\"Text\");\n    checkFieldsAtFirstLevel(\"Numeric\");\n    checkFieldsAtFirstLevel(\"Date\");\n    checkFieldsAtFirstLevel(\"Choice\");\n\n    const checkFieldInMore = (menuText: string) => {\n      it(`can add ${menuText} elements from the menu`, async function() {\n        await plusButton().click();\n        await clickMenu(\"More\");\n        await clickMenu(menuText);\n        await gu.waitForServer();\n        await checkNewCol();\n      });\n    };\n\n    checkFieldInMore(\"Integer\");\n    checkFieldInMore(\"Toggle\");\n    checkFieldInMore(\"DateTime\");\n    checkFieldInMore(\"Choice List\");\n    checkFieldInMore(\"Reference\");\n    checkFieldInMore(\"Reference List\");\n\n    const testStruct = (type: string, existing = 0) => {\n      async function doTestStruct(menuLabel?: string) {\n        assert.equal(await elementCount(type), existing);\n        await plusButton().click();\n        await clickMenu(menuLabel ?? type);\n        await gu.waitForServer();\n        assert.equal(await elementCount(type), existing + 1);\n        await gu.undo();\n        assert.equal(await elementCount(type), existing);\n      }\n\n      it(`can add structure ${type} element`, async function() {\n        if (type === \"Section\") {\n          await doTestStruct(\"Insert section above\");\n          await doTestStruct(\"Insert section below\");\n        } else {\n          await doTestStruct();\n        }\n      });\n    };\n\n    testStruct(\"Section\", 1);\n    testStruct(\"Columns\");\n    testStruct(\"Paragraph\", 4);\n\n    it(\"basic section\", async function() {\n      const revert = await gu.begin();\n\n      assert.equal(await elementCount(\"Section\"), 1);\n\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n\n      // There is a drop in that section, click it to add a new question.\n      await element(\"Section\", 1).element(\"plus\").click();\n      await clickMenu(\"Text\");\n      await gu.waitForServer();\n\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\"]);\n\n      // And the question is inside a section.\n      assert.equal(await element(\"Section\", 1).element(\"label\", 4).getText(), \"D\");\n\n      // Make sure we can move that question around.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"D\") })\n          .press()\n          .move({ origin: questionDrag(\"B\") })\n          .release(),\n      );\n\n      await gu.waitForServer();\n      assert.deepEqual(await labels(), [\"A\", \"D\", \"B\", \"C\"]);\n\n      await gu.undo();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\"]);\n      assert.equal(await element(\"Section\", 1).element(\"label\", 4).getText(), \"D\");\n\n      // Check that we can't delete a section if it's the only one.\n      await element(\"Section\").element(\"Paragraph\", 1).click();\n      await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE);\n      await gu.waitForServer();\n      assert.equal(await elementCount(\"Section\"), 1);\n\n      // Add a new section below it.\n      await plusButton().click();\n      await clickMenu(\"Insert section below\");\n      await gu.waitForServer();\n      assert.equal(await elementCount(\"Section\"), 2);\n      await plusButton(element(\"Section\", 2)).click();\n      await clickMenu(\"Text\");\n      await gu.waitForServer();\n\n      // Now check that we can delete the first section.\n      await element(\"Section\", 1).element(\"Paragraph\", 1).click();\n      await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE);\n      await gu.waitForServer();\n      assert.equal(await elementCount(\"Section\"), 1);\n\n      // Make sure that deleting the section also hides its fields and unmaps them.\n      assert.deepEqual(await labels(), [\"E\"]);\n      await gu.openWidgetPanel();\n      assert.deepEqual(await hiddenColumns(), [\"A\", \"B\", \"C\", \"Choice\", \"D\"]);\n\n      await gu.undo();\n      assert.equal(await elementCount(\"Section\"), 2);\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\", \"E\"]);\n      assert.deepEqual(await hiddenColumns(), [\"Choice\"]);\n\n      await revert();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n    });\n\n    it(\"basic columns work\", async function() {\n      const revert = await gu.begin();\n\n      // Open the creator panel to make sure it works.\n      await gu.openColumnPanel();\n\n      await plusButton().click();\n      await clickMenu(\"Columns\");\n      await gu.waitForServer();\n\n      // We have two placeholders for free.\n      assert.equal(await elementCount(\"Placeholder\", element(\"Columns\")), 2);\n\n      // We can add another placeholder\n      await element(\"add\").click();\n      await gu.waitForServer();\n\n      // Now we have 3 placeholders.\n      assert.equal(await elementCount(\"Placeholder\", element(\"Columns\")), 3);\n\n      // We can click the middle one, and add a question.\n      await element(\"Columns\").element(`Placeholder`, 2).click();\n      await clickMenu(\"Text\");\n      await gu.waitForServer();\n\n      // Now we have 2 placeholders\n      assert.equal(await elementCount(\"Placeholder\", element(\"Columns\")), 2);\n      // And 4 questions.\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\"]);\n\n      // The question D is in the columns.\n      assert.equal(await element(\"Columns\").element(\"label\").getText(), \"D\");\n\n      // We can move it around.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"D\") })\n          .press()\n          .move({ origin: questionDrag(\"B\") })\n          .release(),\n      );\n      await gu.waitForServer();\n      assert.deepEqual(await labels(), [\"A\", \"D\", \"B\", \"C\"]);\n\n      // And move it back.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"D\") })\n          .press()\n          .move({ origin: element(\"Columns\").element(`Placeholder`, 2).find(`.test-forms-drag`) })\n          .release(),\n      );\n      await gu.waitForServer();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\"]);\n\n      assert.equal(await elementCount(\"column\"), 3);\n      assert.equal(await element(\"column\", 1).type(), \"Placeholder\");\n      assert.equal(await element(\"column\", 2).type(), \"Field\");\n      assert.equal(await element(\"column\", 2).element(\"label\").getText(), \"D\");\n      assert.equal(await element(\"column\", 3).type(), \"Placeholder\");\n\n      // Check that we can remove the question.\n      await question(\"D\").rightClick();\n      await clickMenu(\"Hide\");\n      await gu.waitForServer();\n\n      // Now we have 3 placeholders.\n      assert.equal(await elementCount(\"Placeholder\", element(\"Columns\")), 3);\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n\n      // Undo and check it goes back at the right place.\n      await gu.undo();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\"]);\n\n      assert.equal(await elementCount(\"column\"), 3);\n      assert.equal(await element(\"column\", 1).type(), \"Placeholder\");\n      assert.equal(await element(\"column\", 2).type(), \"Field\");\n      assert.equal(await element(\"column\", 2).element(\"label\").getText(), \"D\");\n      assert.equal(await element(\"column\", 3).type(), \"Placeholder\");\n\n      // Add a second question column.\n      await element(\"Columns\").element(`Placeholder`, 1).click();\n      await clickMenu(\"Text\");\n      await gu.waitForServer();\n\n      // Delete the column and make sure both questions get deleted.\n      await element(\"Columns\").element(\"Field\", 1).click();\n      await gu.sendKeys(Key.ESCAPE, Key.UP, Key.DELETE);\n      await gu.waitForServer();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n      await gu.openWidgetPanel();\n      assert.deepEqual(await hiddenColumns(), [\"Choice\", \"D\", \"E\"]);\n\n      // Undo and check everything reverted correctly.\n      await gu.undo();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"E\", \"D\"]);\n      assert.equal(await elementCount(\"column\"), 3);\n      assert.equal(await element(\"column\", 1).type(), \"Field\");\n      assert.equal(await element(\"column\", 1).element(\"label\").getText(), \"E\");\n      assert.equal(await element(\"column\", 2).type(), \"Field\");\n      assert.equal(await element(\"column\", 2).element(\"label\").getText(), \"D\");\n      assert.equal(await element(\"column\", 3).type(), \"Placeholder\");\n      assert.deepEqual(await hiddenColumns(), [\"Choice\"]);\n      await gu.undo();\n\n      // There was a bug with paragraph and columns.\n      // Add a paragraph to first placeholder.\n      await element(\"Columns\").element(`Placeholder`, 1).click();\n      await clickMenu(\"Paragraph\");\n      await gu.waitForServer();\n\n      // Now click this paragraph.\n      await element(\"Columns\").element(`Paragraph`, 1).click();\n      // And make sure there aren't any errors.\n      await gu.checkForErrors();\n\n      await revert();\n      assert.lengthOf(await driver.findAll(\".test-forms-column\"), 0);\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n    });\n\n    it(\"drags and drops on columns properly\", async function() {\n      const revert = await gu.begin();\n      // Open the creator panel to make sure it works.\n      await gu.openColumnPanel();\n\n      await plusButton().click();\n      await clickMenu(\"Columns\");\n      await gu.waitForServer();\n\n      // Make sure that dragging columns on its placeholder doesn't do anything.\n      await driver.withActions(a =>\n        a.move({ origin: element(\"Columns\").element(`Placeholder`, 1).find(`.test-forms-drag`) })\n          .press()\n          .move({ origin: element(\"Columns\").element(`Placeholder`, 2).find(`.test-forms-drag`) })\n          .release(),\n      );\n      await gu.waitForServer();\n      await gu.checkForErrors();\n\n      // Make sure we see form correctly.\n      const testNothingIsMoved = async () => {\n        assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n        assert.deepEqual(await elements(), [\n          \"Paragraph\",\n          \"Paragraph\",\n          \"Section\",\n          \"Paragraph\",\n          \"Paragraph\",\n          \"Field\",\n          \"Field\",\n          \"Field\",\n          \"Columns\",\n          \"Placeholder\",\n          \"Placeholder\",\n        ]);\n      };\n\n      await testNothingIsMoved();\n\n      // Now do the same but move atop the + placeholder.\n      await driver.withActions(a =>\n        a.move({ origin: element(\"Columns\").element(`Placeholder`, 1).find(`.test-forms-drag`) })\n          .press()\n          .move({ origin: driver.find(\".test-forms-Columns .test-forms-add\") })\n          .release(),\n      );\n      await gu.waitForServer();\n      await gu.checkForErrors();\n      await testNothingIsMoved();\n\n      // Now move C column into first column.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"C\") })\n          .press()\n          .move({ origin: element(\"Columns\").element(`Placeholder`, 1).find(`.test-forms-drag`) })\n          .release(),\n      );\n      await gu.waitForServer();\n      await gu.checkForErrors();\n\n      // Check that it worked.\n      assert.equal(await element(\"column\", 1).type(), \"Field\");\n      assert.equal(await element(\"column\", 1).element(\"label\").getText(), \"C\");\n      assert.equal(await element(\"column\", 2).type(), \"Placeholder\");\n\n      // Try to move B over C.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"B\") })\n          .press()\n          .move({ origin: questionDrag(\"C\") })\n          .release(),\n      );\n      await gu.waitForServer();\n      await gu.checkForErrors();\n\n      // Make sure it didn't work.\n      assert.equal(await element(\"column\", 1).type(), \"Field\");\n      assert.equal(await element(\"column\", 1).element(\"label\").getText(), \"C\");\n\n      // And B is still there.\n      assert.equal(await element(\"Field\", 2).element(\"label\").getText(), \"B\");\n\n      // Now move B on the empty placholder.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"B\") })\n          .press()\n          .move({ origin: element(\"column\", 2).drag() })\n          .release(),\n      );\n      await gu.waitForServer();\n      await gu.checkForErrors();\n\n      // Make sure it worked.\n      assert.equal(await element(\"column\", 1).type(), \"Field\");\n      assert.equal(await element(\"column\", 1).element(\"label\").getText(), \"C\");\n      assert.equal(await element(\"column\", 2).type(), \"Field\");\n      assert.equal(await element(\"column\", 2).element(\"label\").getText(), \"B\");\n\n      // Now swap them moving C over B.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"C\") })\n          .press()\n          .move({ origin: questionDrag(\"B\") })\n          .release(),\n      );\n      await gu.waitForServer();\n      await gu.checkForErrors();\n      assert.equal(await element(\"column\", 1).element(\"label\").getText(), \"B\");\n      assert.equal(await element(\"column\", 2).element(\"label\").getText(), \"C\");\n\n      // And swap them back.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"B\") })\n          .press()\n          .move({ origin: questionDrag(\"C\") })\n          .release(),\n      );\n      await gu.waitForServer();\n      await gu.checkForErrors();\n      assert.equal(await element(\"column\", 1).element(\"label\").getText(), \"C\");\n      assert.equal(await element(\"column\", 2).element(\"label\").getText(), \"B\");\n\n      // Make sure we still have two columns only.\n      assert.lengthOf(await driver.findAll(\".test-forms-column\"), 2);\n\n      // Make sure draggin column on the add button doesn't add column.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"B\") })\n          .press()\n          .move({ origin: driver.find(\".test-forms-Columns .test-forms-add\") })\n          .release(),\n      );\n      await gu.waitForServer();\n      await gu.checkForErrors();\n\n      // Make sure we still have two columns only.\n      assert.lengthOf(await driver.findAll(\".test-forms-column\"), 2);\n      assert.equal(await element(\"column\", 1).element(\"label\").getText(), \"C\");\n      assert.equal(await element(\"column\", 2).element(\"label\").getText(), \"B\");\n\n      // Now move A over the + button to add a new column.\n      await driver.withActions(a =>\n        a.move({ origin: questionDrag(\"A\") })\n          .press()\n          .move({ origin: driver.find(\".test-forms-Columns .test-forms-add\") })\n          .release(),\n      );\n      await gu.waitForServer();\n      await gu.checkForErrors();\n      assert.lengthOf(await driver.findAll(\".test-forms-column\"), 3);\n      assert.equal(await element(\"column\", 1).element(\"label\").getText(), \"C\");\n      assert.equal(await element(\"column\", 2).element(\"label\").getText(), \"B\");\n      assert.equal(await element(\"column\", 3).element(\"label\").getText(), \"A\");\n\n      await revert();\n    });\n\n    it(\"changes type of a question\", async function() {\n      // Add text question as D column.\n      await plusButton().click();\n      await clickMenu(\"Text\");\n      await gu.waitForServer();\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\", \"D\"]);\n\n      // Make sure it is a text question.\n      assert.equal(await questionType(\"D\"), \"Text\");\n\n      // Now change it to a choice, from the backend (as the UI is not clear here).\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Form\", \"D\", { type: \"Choice\", widgetOptions: JSON.stringify({ choices: [\"A\", \"B\", \"C\"] }) }],\n      ]);\n\n      // Make sure it is a choice question.\n      await gu.waitToPass(async () => {\n        assert.equal(await questionType(\"D\"), \"Choice\");\n      });\n\n      // Now change it back to a text question.\n      await gu.undo();\n      await gu.waitToPass(async () => {\n        assert.equal(await questionType(\"D\"), \"Text\");\n      });\n\n      await gu.redo();\n      await gu.waitToPass(async () => {\n        assert.equal(await questionType(\"D\"), \"Choice\");\n      });\n\n      await gu.undo(2);\n      await gu.waitToPass(async () => {\n        assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n      });\n    });\n  });\n\n  describe(\"on team site\", async function() {\n    const cleanup = setupTestSuite();\n\n    before(async function() {\n      const session = await gu.session().teamSite.login();\n      docId = await session.tempNewDoc(cleanup);\n      api = session.createHomeApi();\n    });\n\n    gu.withClipboardTextArea();\n\n    it(\"can submit a form\", async function() {\n      // A bug was preventing this by forcing a login redirect from the public form URL.\n      const formUrl = await createFormWith(\"Text\");\n      await gu.removeLogin();\n      // We are in a new window.\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        await driver.findWait('input[name=\"D\"]', 2000).click();\n        await gu.sendKeys(\"Hello World\");\n        await driver.find('button[type=\"submit\"]').click();\n        await waitForConfirm();\n      });\n      // Make sure we see the new record.\n      const session = await gu.session().teamSite.login();\n      await session.loadDoc(`/doc/${docId}`);\n      await expectSingle(\"Hello World\");\n      await removeForm();\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/FormView2.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport { element, FormElement, formSchema, labels, question, questionType } from \"test/nbrowser/formTools\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { getSection, waitToPass } from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { addToRepl, assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"FormView2\", function() {\n  this.timeout(\"4m\");\n  gu.bigScreen();\n\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n\n  let api: UserAPI;\n\n  addToRepl(\"question\", question);\n  addToRepl(\"labels\", labels);\n  addToRepl(\"questionType\", questionType);\n\n  gu.withClipboardTextArea();\n\n  describe(\"general\", function() {\n    before(async function() {\n      session = await gu.session().login();\n      api = session.createHomeApi();\n      await session.tempNewDoc(cleanup);\n      await gu.addNewPage(\"Form\", \"Table1\");\n    });\n\n    it(\"properly maps fields in the creator panel\", async function() {\n      assert.deepEqual(await labels(), [\"A\", \"B\", \"C\"]);\n\n      // Check A column and use `Unmap fields` button to check it works properly.\n      await gu.toggleVisibleColumn(\"A\");\n      await gu.toggleVisibleColumn(\"B\");\n      await gu.hideColumns();\n\n      // Check that the field is no longer in the form.\n      assert.deepEqual(await labels(), [\"C\"]);\n\n      // Now map them back.\n      await gu.toggleHiddenColumn(\"A\");\n      await gu.toggleHiddenColumn(\"B\");\n      await gu.showColumns();\n\n      // Check that the fields are back.\n      assert.deepEqual(await labels(), [\"C\", \"A\", \"B\"]);\n    });\n  });\n\n  describe(\"form borders\", function() {\n    before(async function() {\n      session = await gu.session().login();\n      api = session.createHomeApi();\n    });\n\n    it(\"should not allow html to escape the border\", async function() {\n      // Add simple HTML to paragraph with fixed layout that should cover the whole screen.\n      await session.tempNewDoc(cleanup);\n      await gu.addNewPage(\"Form\", \"Table1\");\n\n      // Publish it.\n      await publish.wait();\n      await publish.click();\n      if (await confirm.isPresent()) {\n        await confirm.click();\n      }\n      await gu.waitForServer();\n\n      // Get the link to the form.\n      await share.click();\n      await gu.waitForServer();\n      const link = await driver.findWait(\".test-forms-link\", 100).getAttribute(\"value\");\n\n      await gu.dbClick(await element(\"Paragraph\", 1));\n      // Wait for the text area to appear.\n      const textArea = await element(\"Paragraph\", 1).findWait(\"textarea\", 1000);\n      await textArea.click();\n      // Add some HTML that should not escape the border.\n      await gu.sendKeys(\n        '<div style=\"width: 100vw; height: 100vh; background-color: red; inset: 0; position: fixed;\" />',\n      );\n      await gu.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n\n      // Open the form.\n      await driver.get(link);\n      await form.wait();\n\n      // Make sure we can click the reset button.\n      await driver.find(\".test-form-reset\").click();\n    });\n\n    it(\"shows a border around a rendered form\", async function() {\n      await session.tempNewDoc(cleanup);\n      await gu.addNewPage(\"Form\", \"Table1\");\n      // Publish it.\n      await publish.click();\n      if (await confirm.isPresent()) {\n        await confirm.click();\n      }\n      await gu.waitForServer();\n      await unpublish.wait();\n\n      // Open the form.\n      await share.click();\n      await gu.waitForServer();\n      const link = await driver.findWait(\".test-forms-link\", 100).getAttribute(\"value\");\n      await driver.get(link);\n\n      // By default, the form framing should be set to 'border'.\n      await form.wait();\n      assert.equal(await framing(), \"border\", \"Form framing should be set to border\");\n\n      // Update the environment variable to turn off the border restriction.\n      const snap = new EnvironmentSnapshot();\n      process.env.GRIST_FEATURE_FORM_FRAMING = \"minimal\";\n      try {\n        await session.loadDocMenu(\"/\");\n        await server.restart();\n        await driver.get(link);\n        await form.wait();\n\n        // Verify that the form framing is now set to 'minimal'.\n        assert.equal(await framing(), \"minimal\", \"Form framing should be set to minimal\");\n      } finally {\n        // Restore the environment variable to its original state.\n        snap.restore();\n        await server.restart();\n      }\n      await driver.get(link);\n      await form.wait();\n\n      // Verify that the form framing is back to 'border'.\n      assert.equal(await framing(), \"border\", \"Form framing should be set to border\");\n    });\n  });\n\n  describe(\"duplicating forms\", function() {\n    before(async function() {\n      session = await gu.session().teamSite.login();\n      api = session.createHomeApi();\n    });\n\n    const originalWidgetTitle = \"Original Widget\";\n    const clonedWidgetTitle = \"Cloned Widget\";\n\n    async function duplicateForm(formWidgetTitle: string, pageTitle: string | undefined, newWidgetTitle: string) {\n      await gu.duplicateWidget(formWidgetTitle, pageTitle);\n      await gu.waitAppFocus();\n      await gu.selectSectionByIndex(-1);\n      await gu.renameActiveSection(newWidgetTitle);\n      // Reduces flakiness, where the next operation can sometimes fail to get the widget by title\n      await waitToPass(async () => { await getSection(newWidgetTitle); });\n    }\n\n    it(\"duplicates default form\", async function() {\n      await session.tempNewDoc(cleanup);\n      // Create a default form for an empty table.\n      await gu.addNewPage(\"Form\", \"Table1\");\n      // Go to this page, to make sure we wait for it.\n      await gu.openPage(\"New page\");\n      await gu.renamePage(\"New page\", \"Original\");\n      await gu.renameActiveSection(originalWidgetTitle);\n      // Reduces flakiness, where the next operation can sometimes fail to get the widget by title\n      await waitToPass(async () => { await getSection(originalWidgetTitle); });\n      const revert = await gu.begin();\n\n      // Read the schema overall.\n      const origStruct1 = await formSchema();\n      // Now duplicate it.\n      await duplicateForm(originalWidgetTitle, undefined, clonedWidgetTitle);\n      await gu.selectSectionByTitle(clonedWidgetTitle);\n      // Read the schema again.\n      const cloned = await formSchema();\n      assert.deepEqual(origStruct1, cloned);\n\n      // Make sure that when changed the original form isn't changed.\n      // Hide columns A and B\n      assert.equal(cloned[2].children.length, 5);\n      assert.deepEqual(allLabelsInFormSection(cloned[2]), [\"A\", \"B\", \"C\"]);\n      await question(\"A\").hover();\n      await question(\"A\").remove();\n      await gu.waitForServer();\n      await question(\"B\").hover();\n      await question(\"B\").remove();\n      await gu.waitForServer();\n      // Read the schema again.\n      const clonedWithoutBC = await formSchema();\n      assert.notDeepEqual(origStruct1, clonedWithoutBC);\n      // Make sure we don't see those fields there.\n      assert.deepEqual(allLabelsInFormSection(clonedWithoutBC[2]), [\"C\"]);\n\n      // Now go to the original page and make sure it still has all fields.\n      await gu.selectSectionByTitle(originalWidgetTitle);\n      const origStruct2 = await formSchema();\n      assert.deepEqual(origStruct1, origStruct2);\n      // Now remove column C here, to make sure duplicate is not affected.\n      await question(\"C\").hover();\n      await question(\"C\").remove();\n      await gu.waitForServer();\n\n      // Check that the new page has the same form.\n      await gu.selectSectionByTitle(clonedWidgetTitle);\n      const cloneAfterRemoval2 = await formSchema();\n      assert.deepEqual(clonedWithoutBC, cloneAfterRemoval2);\n\n      await revert();\n    });\n\n    it(\"duplicates modified form\", async function() {\n      const revert = await gu.begin();\n\n      await gu.selectSectionByTitle(originalWidgetTitle);\n      // Hide column A\n      await question(\"A\").hover();\n      await question(\"A\").remove();\n      await gu.waitForServer();\n      // Read schema\n      const origBC = await formSchema();\n      // Sanity check.\n      assert.deepEqual(allLabelsInFormSection(origBC[2]), [\"B\", \"C\"]);\n      // Now duplicate it.\n      await duplicateForm(originalWidgetTitle, undefined, clonedWidgetTitle);\n      // Check that the new widget is the same form.\n      const clonedBC = await formSchema();\n      assert.deepEqual(origBC, clonedBC);\n      // Sanity check.\n      assert.deepEqual(allLabelsInFormSection(clonedBC[2]), [\"B\", \"C\"]);\n\n      // Now remove column B from original, and make sure clone is not affected.\n      await gu.selectSectionByTitle(originalWidgetTitle);\n      await question(\"B\").hover();\n      await question(\"B\").remove();\n      await gu.waitForServer();\n      const origC = await formSchema();\n      // Sanity check.\n      assert.deepEqual(allLabelsInFormSection(origC[2]), [\"C\"]);\n      // Make sure clone is not affected.\n      await gu.selectSectionByTitle(clonedWidgetTitle);\n      assert.deepEqual(clonedBC, await formSchema());\n\n      // Cloned still has B and C, remove the C to make sure original is not affected.\n      await question(\"C\").hover();\n      await question(\"C\").remove();\n      await gu.waitForServer();\n\n      // Make sure original still has C\n      await gu.selectSectionByTitle(originalWidgetTitle);\n      assert.deepEqual(origC, await formSchema());\n      await revert();\n    });\n\n    it(\"clones default form without publishing\", async function() {\n      // Original form is not yet changed.\n      // Publish it.\n      await publishForm();\n      await duplicateForm(originalWidgetTitle, undefined, clonedWidgetTitle);\n\n      // Make sure we have publish button.\n      assert.isTrue(await publish.isDisplayed());\n      assert.isFalse(await unpublish.isPresent());\n\n      await gu.selectSectionByTitle(clonedWidgetTitle);\n      // Now publish the clone also.\n      await publishForm();\n\n      // And unpublish the clone to make sure the original is still published.\n      await unpublishForm();\n\n      // Check original, should still be published.\n      await gu.selectSectionByTitle(originalWidgetTitle);\n      assert.isFalse(await publish.isPresent());\n      assert.isTrue(await unpublish.isDisplayed());\n    });\n\n    it(\"can submit a form\", async function() {\n      // Publish the clone and open the form.\n      await gu.selectSectionByTitle(clonedWidgetTitle);\n      await publishForm();\n\n      await share.click();\n      await gu.waitForServer();\n      const link = await driver.findWait(\".test-forms-link\", 100).getAttribute(\"value\");\n      await driver.get(link);\n\n      // Submit a record\n      await driver.findWait('input[name=\"A\"]', 2000).click();\n      await driver.findWait('input[name=\"A\"]', 100).sendKeys(\"Hello\");\n      await driver.findWait('button[type=\"submit\"]', 1000).click();\n      await driver.findWait(\".test-form-success-page-text\", 1000);\n\n      // Check that the record was added to the table.\n      await driver.navigate().back();\n      await gu.waitForDocToLoad();\n      await gu.openPage(\"Table1\");\n      assert.deepEqual(await gu.getVisibleGridCellsFast(\"A\", [1]), [\"Hello\"]);\n    });\n  });\n\n  describe(\"form deletion\", function() {\n    before(async function() {\n      session = await gu.session().teamSite.login();\n      api = session.createHomeApi();\n    });\n\n    it(\"does not load preview if doc is soft-deleted\", async function() {\n      const docId = await session.tempNewDoc(cleanup);\n      await gu.addNewPage(\"Form\", \"Table1\");\n      const formUrl = await driver.find(\".test-forms-preview\").getAttribute(\"href\");\n      await session.loadDocMenu(\"/\");\n      await api.softDeleteDoc(docId);\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        assert.isTrue(await driver.findWait(\".test-form-error-page\", 2000).isDisplayed());\n        assert.equal(\n          await driver.find(\".test-form-error-page-text\").getText(),\n          \"Oops! The form you're looking for doesn't exist.\",\n        );\n      });\n      await api.undeleteDoc(docId);\n    });\n\n    it(\"does not load published form if doc is soft-deleted\", async function() {\n      const docId = await session.tempNewDoc(cleanup);\n      await session.loadDoc(`/doc/${docId}`);\n      await gu.addNewPage(\"Form\", \"Table1\");\n      await publishForm();\n      await share.click();\n      await gu.waitForServer();\n      const formUrl = await driver.findWait(\".test-forms-link\", 100).getAttribute(\"value\");\n      await session.loadDocMenu(\"/\");\n      await api.softDeleteDoc(docId);\n      await gu.onNewTab(async () => {\n        await driver.get(formUrl);\n        assert.isTrue(await driver.findWait(\".test-form-error-page\", 2000).isDisplayed());\n        assert.equal(\n          await driver.find(\".test-form-error-page-text\").getText(),\n          \"Oops! The form you're looking for doesn't exist.\",\n        );\n      });\n      await api.undeleteDoc(docId);\n    });\n  });\n});\n\nfunction isField(e: FormElement) {\n  return e.type === \"Field\";\n}\n\nfunction label(e: FormElement) {\n  return e.label;\n}\n\nfunction allLabelsInFormSection(e: FormElement) {\n  return e.children.filter(isField).map(label);\n}\n\nfunction widget(selector: string) {\n  return {\n    async click() {\n      await driver.findWait(selector, 1000).click();\n    },\n    async wait() {\n      await driver.findWait(selector, 5000);\n    },\n    async isDisplayed() {\n      return await driver.find(selector).isDisplayed();\n    },\n    async isPresent() {\n      return await driver.find(selector).isPresent();\n    },\n  };\n}\n\nconst publish = widget(\".active_section .test-forms-publish\");\nconst unpublish = widget(\".active_section .test-forms-unpublish\");\nconst confirm = widget(\".test-modal-confirm\");\nconst share = widget(\".test-forms-share\");\nconst form = widget(\".test-form-page\");\n\nasync function publishForm() {\n  await publish.wait();\n  await publish.click();\n  await confirm.wait();\n  await confirm.click();\n  await gu.waitForServer();\n  await unpublish.wait();\n}\n\nasync function unpublishForm() {\n  await unpublish.wait();\n  await unpublish.click();\n  await confirm.wait();\n  await confirm.click();\n  await gu.waitForServer();\n  await publish.wait();\n}\n\nasync function framing() {\n  const frame = await driver.find(\".test-form-framing\");\n  if (await frame.matches(\"[class*=-border]\")) {\n    return \"border\";\n  }\n  if (await frame.matches(\"[class*=-minimal]\")) {\n    return \"minimal\";\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/FormsUrlValues.ts",
    "content": "/**\n * Test for acceptance of URL values in forms.\n */\nimport { UserAPI } from \"app/common/UserAPI\";\nimport { plusButton, question } from \"test/nbrowser/formTools\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"FormsUrlValues\", function() {\n  this.timeout(60000);\n  const cleanup = setupTestSuite();\n  afterEach(() => gu.checkForErrors());\n  let docId: string;\n  let api: UserAPI;\n  let formLink: string;\n\n  const choices = { choices: [\"Foo Choice\", \"Bar Choice\", \"Baz Choice\"] };\n  const spinner = { formNumberFormat: \"spinner\" };\n  const radio = { formSelectFormat: \"radio\" };\n\n  const sampleUrlParameters = new URLSearchParams([\n    [\"Field_Text\", \"url text\"],\n    [\"Field_Numeric\", \"17\"],\n    [\"Field_Spinner\", \"5\"],\n    [\"Field_Bool\", \"yes\"],\n    [\"Field_Date\", \"2025-10-03\"],\n    [\"Field_DateTime\", \"2025-10-03 17:17:00\"],\n    [\"Field_Choice\", \"Bar Choice\"],\n    [\"Field_Choice_Radio\", \"Baz Choice\"],\n    [\"Field_ChoiceList\", \"Foo Choice\"],\n    [\"Field_ChoiceList\", \"Baz Choice\"],   // Note how we set two values for a choice list.\n    [\"Field_Ref\", \"Alice\"],\n    [\"Field_Ref_Radio\", \"Bob\"],\n    [\"Field_RefList\", \"Bob\"],\n    [\"Field_RefList\", \"Carol\"],           // Note how we set multiple values of a ref list.\n  ]);\n\n  it(\"setup\", async function() {\n    const session = await gu.session().login();\n\n    // Create a document with a table that has most types of fields.\n    docId = await session.tempNewDoc(cleanup, \"FormsUrlValues\", { load: false });\n    api = session.createHomeApi();\n    await api.applyUserActions(docId, [\n      [\"BulkAddRecord\", \"Table1\", [null, null, null], { A: [\"Alice\", \"Bob\", \"Carol\"] }],\n      [\"AddTable\", \"FormTest\", [\n        { id: \"Field_Text\", type: \"Text\", isFormula: false },\n        { id: \"Field_Numeric\", type: \"Numeric\", isFormula: false },\n        { id: \"Field_Spinner\", type: \"Numeric\", isFormula: false, widgetOptions: JSON.stringify(spinner) },\n        { id: \"Field_Bool\", type: \"Bool\", isFormula: false },\n        { id: \"Field_Date\", type: \"Date\", isFormula: false },\n        { id: \"Field_DateTime\", type: \"DateTime\", isFormula: false },\n        { id: \"Field_Choice\", type: \"Choice\", isFormula: false, widgetOptions: JSON.stringify(choices) },\n        { id: \"Field_Choice_Radio\", type: \"Choice\", isFormula: false,\n          widgetOptions: JSON.stringify({ ...choices, ...radio }) },\n        { id: \"Field_ChoiceList\", type: \"ChoiceList\", isFormula: false, widgetOptions: JSON.stringify(choices) },\n        { id: \"Field_Ref\", type: \"Ref:Table1\", isFormula: false },\n        { id: \"Field_Ref_Radio\", type: \"Ref:Table1\", isFormula: false, widgetOptions: JSON.stringify(radio) },\n        { id: \"Field_RefList\", type: \"RefList:Table1\", isFormula: false },\n      ]],\n    ]);\n\n    // Load the document, and switch to the FormTest table.\n    await gu.loadDoc(`/doc/${docId}`);\n    await gu.openPage(\"FormTest\");\n\n    // Set a better \"Show column\" for reference fields, easier to do via UI.\n    await gu.openColumnPanel();\n    for (const col of [\"Field_Ref\", \"Field_Ref_Radio\", \"Field_RefList\"]) {\n      await gu.getCell({ col, rowNum: 1 }).click();\n      await gu.setRefShowColumn(\"A\");\n    }\n\n    // Add a form that includes most types of fields.\n    await gu.addNewPage(\"Form\", \"FormTest\");\n\n    // The first 9 fields are added automatically. Add the remaining ones manually.\n    async function addUnmapped(label: string) {\n      await gu.waitToPass(() => plusButton().click(), 500);\n      await gu.findOpenMenuItem(\".test-forms-menu-unmapped\", label).click();\n      await gu.waitForServer();\n    }\n    await addUnmapped(\"Field_Ref\");\n    await addUnmapped(\"Field_Ref_Radio\");\n    await addUnmapped(\"Field_RefList\");\n\n    // Publish the form.\n    await driver.find(\".test-forms-publish\").click();\n    if (await driver.findWait(\".test-modal-confirm\", 200).isPresent()) {\n      await driver.find(\".test-modal-confirm\").click();\n    }\n    await gu.waitForServer();\n    await driver.find(`.test-forms-share`).click();\n    formLink = await driver.findWait(\".test-forms-link\", 200).getAttribute(\"value\");\n  });\n\n  it(\"should not be affected by url values that are not enabled\", async function() {\n    // Construct a form URL with some parameters filled in. They shouldn't work yet.\n    const formUrl = new URL(formLink);\n    formUrl.search = sampleUrlParameters.toString();\n\n    // Open the form with some URL parameters in a new tab.\n    await gu.onNewTabForUrl(formUrl.href, async () => {\n      // Check that fields are empty.\n      assert.deepEqual(await getFieldValue(\"Field_Text\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Numeric\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Spinner\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Bool\"), \"0\");\n      assert.deepEqual(await getFieldValue(\"Field_Date\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_DateTime\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Choice\"), \"\");\n      assert.deepEqual(await getCheckedFields(\"Field_Choice_Radio\"), []);\n      assert.deepEqual(await getCheckedFields(\"Field_ChoiceList[]\"), []);\n      assert.deepEqual(await getFieldValue(\"Field_Ref\"), \"\");\n      assert.deepEqual(await getCheckedFields(\"Field_Ref_Radio\"), []);\n      assert.deepEqual(await getCheckedFields(\"Field_RefList[]\"), []);\n\n      // Submit.\n      await driver.findWait('button[type=\"submit\"]', 500).click();\n      await driver.findWait(\".test-form-success-page-text\", 2000);\n    });\n\n    // Check that submitted values are empty.\n    let records = await api.getDocAPI(docId).getRecords(\"FormTest\");\n    let lastRow = records[records.length - 1];\n    assert.deepEqual(lastRow.fields, {\n      Field_Text: \"\",\n      Field_Numeric: 0,\n      Field_Spinner: 0,\n      Field_Bool: false,\n      Field_Date: null,\n      Field_DateTime: null,\n      Field_Choice: \"\",\n      Field_Choice_Radio: \"\",\n      Field_ChoiceList: null,\n      Field_Ref: 0,\n      Field_Ref_Radio: 0,\n      Field_RefList: null,\n    });\n\n    // Open the form with some URL parameters again.\n    await gu.onNewTabForUrl(formUrl.href, async () => {\n      await driver.get(formUrl.href);\n      // Fill in some fields and submit.\n      await setFieldValue(\"Field_Text\", \"my text\");\n      await setFieldValue(\"Field_Spinner\", \"1000\");\n      await setFieldValue(\"Field_Date\", \"03/20/2022\");\n      await setSelectValue(\"Field_Choice\", \"Foo Choice\");\n      await toggleCheckedValue(\"Field_ChoiceList[]\", \"Baz Choice\");\n      await toggleCheckedValue(\"Field_Ref_Radio\", \"3\");  // Value is the rowId of the reference.\n\n      // Submit.\n      await driver.findWait('button[type=\"submit\"]', 500).click();\n      await driver.findWait(\".test-form-success-page-text\", 2000);\n    });\n\n    // Check that values are submitted correctly.\n    records = await api.getDocAPI(docId).getRecords(\"FormTest\");\n    lastRow = records[records.length - 1];\n    assert.deepEqual(lastRow.fields, {\n      Field_Text: \"my text\",\n      Field_Numeric: 0,\n      Field_Spinner: 1000,\n      Field_Bool: false,\n      Field_Date: Date.parse(\"2022-03-20\") / 1000,\n      Field_DateTime: null,\n      Field_Choice: \"Foo Choice\",\n      Field_Choice_Radio: \"\",\n      Field_ChoiceList: [\"L\", \"Baz Choice\"] as any,\n      Field_Ref: 0,\n      Field_Ref_Radio: 3,\n      Field_RefList: null,\n    });\n  });\n\n  it(\"should accept url values that are enabled\", async function() {\n    // Enable half the fields to accept submissions.\n    for (const field of [\n      \"Field_Text\", \"Field_Numeric\", \"Field_Spinner\", \"Field_Bool\", \"Field_Date\", \"Field_DateTime\",\n    ]) {\n      await toggleFieldConfigCheckbox(field, \".test-form-field-accept-from-url\");\n    }\n\n    // Check that we are showing the field ID in the hint text.\n    assert.match(await driver.findWait(\".test-form-field-url-hint\", 250).getText(), /Field_DateTime/);\n\n    // Open the form with some URL parameters.\n    const formUrl = new URL(formLink);\n    formUrl.search = sampleUrlParameters.toString();\n    await gu.onNewTabForUrl(formUrl.href, async () => {\n      // Check that the expected half of the fields are non-empty.\n      assert.deepEqual(await getFieldValue(\"Field_Text\"), \"url text\");\n      assert.deepEqual(await getFieldValue(\"Field_Numeric\"), \"17\");\n      assert.deepEqual(await getFieldValue(\"Field_Spinner\"), \"5\");\n      assert.deepEqual(await getFieldValue(\"Field_Bool\"), \"1\");\n      assert.deepEqual(await getFieldValue(\"Field_Date\"), \"2025-10-03\");\n      assert.deepEqual(await getFieldValue(\"Field_DateTime\"), \"2025-10-03T17:17\");\n      assert.deepEqual(await getFieldValue(\"Field_Choice\"), \"\");\n      assert.deepEqual(await getCheckedFields(\"Field_Choice_Radio\"), []);\n      assert.deepEqual(await getCheckedFields(\"Field_ChoiceList[]\"), []);\n      assert.deepEqual(await getFieldValue(\"Field_Ref\"), \"\");\n      assert.deepEqual(await getCheckedFields(\"Field_Ref_Radio\"), []);\n      assert.deepEqual(await getCheckedFields(\"Field_RefList[]\"), []);\n\n      // Submit.\n      await driver.findWait('button[type=\"submit\"]', 500).click();\n      await driver.findWait(\".test-form-success-page-text\", 2000);\n    });\n\n    // Check that submitted values are as expected.\n    let records = await api.getDocAPI(docId).getRecords(\"FormTest\");\n    let lastRow = records[records.length - 1];\n    assert.deepEqual(lastRow.fields, {\n      Field_Text: \"url text\",\n      Field_Numeric: 17,\n      Field_Spinner: 5,\n      Field_Bool: true,\n      Field_Date: Date.parse(\"2025-10-03\") / 1000,\n      Field_DateTime: Date.parse(\"2025-10-03 17:17:00Z\") / 1000,\n      Field_Choice: \"\",\n      Field_Choice_Radio: \"\",\n      Field_ChoiceList: null,\n      Field_Ref: 0,\n      Field_Ref_Radio: 0,\n      Field_RefList: null,\n    });\n\n    // Switch which fields accept submissions.\n    for (const field of [\n      // These were off, and will get toggled on.\n      \"Field_Text\", \"Field_Numeric\", \"Field_Spinner\", \"Field_Bool\", \"Field_Date\", \"Field_DateTime\",\n      // These were on, and will get toggled off.\n      \"Field_Choice\", \"Field_Choice_Radio\", \"Field_ChoiceList\", \"Field_Ref\", \"Field_Ref_Radio\",\n      \"Field_RefList\",\n    ]) {\n      await toggleFieldConfigCheckbox(field, \".test-form-field-accept-from-url\");\n    }\n\n    // Open the form with some URL parameters again.\n    await gu.onNewTabForUrl(formUrl.href, async () => {\n      // The first half of the fields should now be empty (url value ignored).\n      assert.deepEqual(await getFieldValue(\"Field_Text\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Numeric\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Spinner\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Bool\"), \"0\");\n      assert.deepEqual(await getFieldValue(\"Field_Date\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_DateTime\"), \"\");\n      // Check that the second half of the fields (accepting URL values) is non-empty.\n      assert.deepEqual(await getFieldValue(\"Field_Choice\"), \"Bar Choice\");\n      assert.deepEqual(await getCheckedFields(\"Field_Choice_Radio\"), [\"Baz Choice\"]);\n      assert.deepEqual(await getCheckedFields(\"Field_ChoiceList[]\"), [\"Foo Choice\", \"Baz Choice\"]);\n      assert.deepEqual(await getFieldValue(\"Field_Ref\"), \"1\");\n      assert.deepEqual(await getCheckedFields(\"Field_Ref_Radio\"), [\"2\"]);\n      assert.deepEqual(await getCheckedFields(\"Field_RefList[]\"), [\"2\", \"3\"]);\n\n      // Fill in a few fields and submit.\n      await setFieldValue(\"Field_Text\", \"my text\");\n      await setFieldValue(\"Field_Spinner\", \"1000\");\n      await setFieldValue(\"Field_Date\", \"03/20/2022\");\n\n      // Submit.\n      await driver.findWait('button[type=\"submit\"]', 500).click();\n      await driver.findWait(\".test-form-success-page-text\", 2000);\n    });\n\n    // Check that values are submitted correctly.\n    records = await api.getDocAPI(docId).getRecords(\"FormTest\");\n    lastRow = records[records.length - 1];\n    assert.deepEqual(lastRow.fields, {\n      Field_Text: \"my text\",\n      Field_Numeric: 0,\n      Field_Spinner: 1000,\n      Field_Bool: false,\n      Field_Date: Date.parse(\"2022-03-20\") / 1000,\n      Field_DateTime: null,\n      Field_Choice: \"Bar Choice\",\n      Field_Choice_Radio: \"Baz Choice\",\n      Field_ChoiceList: [\"L\", \"Foo Choice\", \"Baz Choice\"] as any,\n      Field_Ref: 1,\n      Field_Ref_Radio: 2,\n      Field_RefList: [\"L\", 2, 3] as any,\n    });\n  });\n\n  it(\"should allow hiding fields and still accept url values in them\", async function() {\n    // Hide half of the fields in the form.\n    for (const field of [\n      // Fields where we don't accept URL values (from last test case).\n      \"Field_Numeric\", \"Field_Bool\", \"Field_DateTime\",\n      // Fields where we do accept URL values (from last test case).\n      \"Field_Choice_Radio\", \"Field_Ref\", \"Field_RefList\",\n    ]) {\n      await toggleFieldConfigCheckbox(field, \".test-form-field-hidden\");\n    }\n\n    // Open the form with some URL parameters.\n    const formUrl = new URL(formLink);\n    formUrl.search = sampleUrlParameters.toString();\n    await gu.onNewTabForUrl(formUrl.href, async () => {\n      // We expect precisely the same values as in the previous test case.\n      assert.deepEqual(await getFieldValue(\"Field_Text\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Numeric\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Spinner\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Bool\"), \"0\");\n      assert.deepEqual(await getFieldValue(\"Field_Date\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_DateTime\"), \"\");\n      assert.deepEqual(await getFieldValue(\"Field_Choice\"), \"Bar Choice\");\n      assert.deepEqual(await getCheckedFields(\"Field_Choice_Radio\"), [\"Baz Choice\"]);\n      assert.deepEqual(await getCheckedFields(\"Field_ChoiceList[]\"), [\"Foo Choice\", \"Baz Choice\"]);\n      assert.deepEqual(await getFieldValue(\"Field_Ref\"), \"1\");\n      assert.deepEqual(await getCheckedFields(\"Field_Ref_Radio\"), [\"2\"]);\n      assert.deepEqual(await getCheckedFields(\"Field_RefList[]\"), [\"2\", \"3\"]);\n\n      // But check also that the fields are hidden (not displayed).\n      assert.equal(await isFieldDisplayed(\"Field_Text\"), true);\n      assert.equal(await isFieldDisplayed(\"Field_Numeric\"), false);\n      assert.equal(await isFieldDisplayed(\"Field_Spinner\"), true);\n      assert.equal(await isFieldDisplayed(\"Field_Bool\"), false);\n      assert.equal(await isFieldDisplayed(\"Field_Date\"), true);\n      assert.equal(await isFieldDisplayed(\"Field_DateTime\"), false);\n      assert.equal(await isFieldDisplayed(\"Field_Choice\"), true);\n      assert.equal(await isFieldDisplayed(\"Field_Choice_Radio\"), false);\n      assert.equal(await isFieldDisplayed(\"Field_ChoiceList[]\"), true);\n      assert.equal(await isFieldDisplayed(\"Field_Ref\"), false);\n      assert.equal(await isFieldDisplayed(\"Field_Ref_Radio\"), true);\n      assert.equal(await isFieldDisplayed(\"Field_RefList[]\"), false);\n\n      // Submit.\n      await driver.findWait('button[type=\"submit\"]', 500).click();\n      await driver.findWait(\".test-form-success-page-text\", 2000);\n    });\n\n    // Check that submitted values are as expected.\n    const records = await api.getDocAPI(docId).getRecords(\"FormTest\");\n    const lastRow = records[records.length - 1];\n    assert.deepEqual(lastRow.fields, {\n      Field_Text: \"\",\n      Field_Numeric: 0,\n      Field_Spinner: 0,\n      Field_Bool: false,\n      Field_Date: null,\n      Field_DateTime: null,\n      Field_Choice: \"Bar Choice\",\n      Field_Choice_Radio: \"Baz Choice\",\n      Field_ChoiceList: [\"L\", \"Foo Choice\", \"Baz Choice\"] as any,\n      Field_Ref: 1,\n      Field_Ref_Radio: 2,\n      Field_RefList: [\"L\", 2, 3] as any,\n    });\n  });\n});\n\n// Various helpers.\n\nfunction getFieldValue(name: string) {\n  return driver.findWait(`input[name=\"${name}\"], select[name=\"${name}\"]`, 500).value();\n}\n\nfunction getCheckedFields(name: string) {\n  return driver.findAll(`input[name=\"${name}\"]:checked`, el => el.value());\n}\n\nasync function setFieldValue(name: string, value: string) {\n  await driver.findWait(`input[name=\"${name}\"]`, 500).click();\n  await gu.sendKeys(value);\n}\n\nasync function setSelectValue(name: string, value: string) {\n  await driver.findWait(`select[name=\"${name}\"] ~ .test-form-search-select`, 500).click();\n  await driver.findContentWait(\".test-sd-searchable-list-item\", value, 500).click();\n}\n\nasync function toggleCheckedValue(name: string, value: string) {\n  await driver.findWait(`input[name=\"${name}\"][value=\"${value}\"]`, 500).click();\n}\n\nasync function toggleFieldConfigCheckbox(field: string, selector: string) {\n  await question(field).click();\n  await gu.openColumnPanel();\n  await driver.findWait(selector, 250).click();\n  await gu.waitForServer();\n}\n\nfunction isFieldDisplayed(name: string) {\n  return driver.findWait(`input[name=\"${name}\"], select[name=\"${name}\"]`, 500).isDisplayed();\n}\n"
  },
  {
    "path": "test/nbrowser/FormulaAutocomplete.ts",
    "content": "import { arrayRepeat } from \"app/common/gutil\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { withoutSandboxing } from \"test/server/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"FormulaAutocomplete\", function() {\n  withoutSandboxing();\n\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  afterEach(async function() {\n    // In case of an error, close any open autocomplete and cell editor, to ensure we don't\n    // interfere with subsequent tests by unsaved value triggering an alert on unload.\n    await driver.sendKeys(Key.ESCAPE, Key.ESCAPE);\n  });\n\n  let docId: string;\n  let api: UserAPI;\n\n  before(async function() {\n    const mainSession = await gu.session().teamSite.login();\n    const doc = await mainSession.tempDoc(cleanup, \"Favorite_Films.grist\");\n    api = mainSession.createHomeApi();\n    docId = doc.id;\n  });\n\n  async function getCompletionLines() {\n    const lines = await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText());\n    return lines.map(line => line.replace(/\\n/g, \"\").trim());\n  }\n\n  async function check(keys: string, expectedCompletions: string[]) {\n    // Send the keys separately. (Sending them together seems to cause the\n    // autocomplete to not show up from time to time.)\n    await gu.sendKeys(40, keys);\n    await gu.waitForServer(); // Wait for the server to respond to the autocomplete request.\n    await gu.waitToPass(async () => {\n      assert.deepEqual(await getCompletionLines(), expectedCompletions);\n    });\n  }\n\n  it(\"should show full variable names\", async function() {\n    // Test for a bug.\n    // For very long example values, the variable name was squashed to a single `$` sign.\n    await gu.addNewTable(\"LongNames\");\n    await gu.sendActions([\n      [\"RemoveColumn\", \"LongNames\", \"A\"],\n      [\"RemoveColumn\", \"LongNames\", \"B\"],\n      [\"RemoveColumn\", \"LongNames\", \"C\"],\n      [\"AddVisibleColumn\", \"LongNames\", \"Very long name for the first column\", {\n        type: \"RefList:LongNames\",\n        isFormula: true, formula: \"LongNames.lookupRecords(Long_column=1, Long_column_2=2)\",\n      }],\n      [\"AddVisibleColumn\", \"LongNames\", \"Long_column\", {}],\n      [\"AddVisibleColumn\", \"LongNames\", \"Long_column_2\", {}],\n      [\"AddVisibleColumn\", \"LongNames\", \"C\", {\n        isFormula: true, formula: \"1\",\n      }],\n      [\"AddRecord\", \"LongNames\", null, {}],\n    ]);\n    await gu.getCell({ rowNum: 1, col: \"C\" }).click();\n    await driver.sendKeys(\"=\");\n    await gu.waitAppFocus(false);\n    await driver.sendKeys(`$V`);\n\n    // Make sure that `ace_` element has some width.\n    await gu.waitToPass(async () => {\n      // Need to repeat it as this might be stale, as ACE editor is doing some async work.\n      assert.isAbove(\n        await driver.findContent(\".ace_\", /ery_long_name/).getRect().then(s => s.width),\n        40,\n      );\n    }, 100);\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitAppFocus(true);\n    await gu.sendActions([[\"RemoveTable\", \"LongNames\"]]);\n  });\n\n  it(\"shows example values with proper padding\", async function() {\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"LongColumns\", [\n        { id: \"aaa\", type: \"Ref:LongColumns\" },\n        { id: \"bbbbbb\", type: \"Numeric\" },\n        { id: \"ccccccccc\", type: \"Numeric\" },\n        { id: \"dddddddddddd\", type: \"Numeric\" },\n        { id: \"formula\", isFormula: true },\n      ]],\n      [\"AddRecord\", \"LongColumns\", null, { aaa: 1, bbbbbb: 2, ccccccccc: 3, dddddddddddd: 4 }],\n    ],\n    );\n    await gu.waitForServer();\n    await gu.getPageItem(\"LongColumns\").click();\n    await gu.getCell(\"formula\", 1).click();\n    await startFormulaAutocomplete(\"$\");\n\n    // First three checks are very similar\n    await check(\"\", [\n      \"$aaa                 LongColumns[1]\",\n      \"$bbbbbb              2.0\",\n      \"$ccccccccc           3.0\",\n      \"$dddddddddddd        4.0\",\n      \"$formula             None\",\n      \"$id                  1\",\n    ]);\n    await check(\"aaa.aaa.\", [\n      \"$aaa.aaa.aaa                 LongColumns[1]\",\n      \"$aaa.aaa.bbbbbb              2.0\",\n      \"$aaa.aaa.ccccccccc           3.0\",\n      \"$aaa.aaa.dddddddddddd        4.0\",\n      \"$aaa.aaa.formula             None\",\n      \"$aaa.aaa.id                  1\",\n    ]);\n    await check(\"aaa.aaa.aaa.aaa.\", [\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa                 LongColumns[1]\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.bbbbbb              2.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.ccccccccc           3.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.dddddddddddd        4.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.formula             None\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.id                  1\",\n    ]);\n\n    await check(\"aaa.\", [\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa                LongColumns[1]\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.bbbbbb             2.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.ccccccccc          3.0\",\n      // Here we hit the limit `MAX_ABSOLUTE_SHARED_PADDING = 40`,\n      // because this suggestion is 41 characters long.\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.dddddddddddd        4.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.formula            None\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.id                 1\",\n    ]);\n\n    await check(\"aaa.\", [\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa            LongColumns[1]\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.bbbbbb         2.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.ccccccccc        3.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.dddddddddddd        4.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.formula        None\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.id             1\",\n    ]);\n\n    // No more shared padding\n    await check(\"aaa.aaa.\", [\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa        LongColumns[1]\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.bbbbbb        2.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.ccccccccc        3.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.dddddddddddd        4.0\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.formula        None\",\n      \"$aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.aaa.id        1\",\n    ]);\n\n    await gu.sendKeys(Key.ESCAPE, Key.ESCAPE);\n\n    await gu.addColumn(\"very_long_column_name\");\n    await gu.addColumn(\"very_very_long_column_name\");\n    await startFormulaAutocomplete(\"$\");\n    // Now there's more shared padding than the first case,\n    // but it reaches the limit imposed by `MAX_RELATIVE_SHARED_PADDING = 15`.\n    // The actual shared padding here is 15+3=18 because `$id` has 3 characters.\n    // `$very_long_column_name` has 22 characters, hence its alignment is off by 22-18=4 spaces.\n    await check(\"\", [\n      \"$aaa                      LongColumns[1]\",\n      \"$bbbbbb                   2.0\",\n      \"$ccccccccc                3.0\",\n      \"$dddddddddddd             4.0\",\n      \"$formula                  None\",\n      \"$id                       1\",\n      \"$very_long_column_name        None\",\n      \"$very_very_long_column_name        None\",\n    ]);\n  });\n\n  it(\"shows autocomplete suggestions after a parenthesis\", async function() {\n    await startFormulaAutocomplete(\"UPPER(\");\n    // What we are checking here isn't the particular list, but the fact that it's the same list\n    // as aboce, when the text isn't preceded by \"UPPER(\".\n    await check(\"$\", [\n      \"$aaa                      LongColumns[1]\",\n      \"$bbbbbb                   2.0\",\n      \"$ccccccccc                3.0\",\n      \"$dddddddddddd             4.0\",\n      \"$formula                  None\",\n      \"$id                       1\",\n      \"$very_long_column_name        None\",\n      \"$very_very_long_column_name        None\",\n    ]);\n  });\n\n  it(\"refreshes autocomplete after typing a period when one colId is a prefix of another\", async function() {\n    await gu.getPageItem(\"Films\").click();\n\n    // Add a new column 'T' of type Text\n    await gu.addColumn(\"T\");\n    await gu.getCell({ rowNum: 1, col: \"T\" }).click();\n    await driver.sendKeys(\"abc\");\n    await driver.sendKeys(Key.ENTER);\n\n    // Write a new formula starting with `$Title.` and check that the autocomplete options are correct\n    await gu.addColumn(\"A\");\n    await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n    await startFormulaAutocomplete(\"$Title.\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.isAtLeast(completions.length, 5);\n      assert.isTrue(completions.every(c => !c || c.startsWith(\"$Title.\")));\n    });\n\n    // Now delete 'itle.' and add '.' again so that the formula starts with `$T.`\n    // Previously this would continue showing the autocomplete options for `$Title.`\n    // because the characters in `$T.` are contained in those options.\n    // Now the options are refreshed after typing '.'\n    await driver.sendKeys(Key.BACK_SPACE, Key.BACK_SPACE, Key.BACK_SPACE, Key.BACK_SPACE, Key.BACK_SPACE);\n    await driver.sendKeys(\".\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.isAtLeast(completions.length, 5);\n      assert.isTrue(completions.every(c => !c || c.startsWith(\"$T.\")));\n    });\n    await driver.sendKeys(Key.ESCAPE, Key.ESCAPE);\n    await driver.find(\".test-notifier-toast-close\").click();\n  });\n\n  it(\"handles references and visible columns correctly\", async function() {\n    // Make 'A' a Reference column to use in a new formula in 'B'\n    await gu.setType(/Reference/);\n    await gu.addColumn(\"B\");\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await startFormulaAutocomplete(\"$A\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.lengthOf(completions, 3);\n      // Initially 'A' has no visible column, so these are the only options\n      assert.isTrue(completions[0].startsWith(\"$A\"));\n      assert.isTrue(completions[1].startsWith(\"$Release_Date\"));\n      assert.isTrue(completions[2].startsWith(\"$T\"));\n    });\n    await driver.sendKeys(\".\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.lengthOf(completions, 7);\n      assert.isTrue(completions[0].startsWith(\"$A.A\"));\n      assert.isTrue(completions[1].startsWith(\"$A.B\"));\n      assert.isTrue(completions[2].startsWith(\"$A.Budget_millions\"));\n      assert.isTrue(completions[3].startsWith(\"$A.id\"));\n      assert.isTrue(completions[4].startsWith(\"$A.Release_Date\"));\n      assert.isTrue(completions[5].startsWith(\"$A.T\"));\n      assert.isTrue(completions[6].startsWith(\"$A.Title\"));\n    });\n    await driver.sendKeys(Key.ENTER);  // don't ESCAPE because then an 'undo discard' toast gets in the way\n    // Now give 'A' a visible column and check how the options change\n    await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n    await gu.setRefShowColumn(\"Title\");\n    await gu.getCell({ rowNum: 1, col: \"B\" }).click();\n    await startFormulaAutocomplete(\"$A\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.lengthOf(completions, 4);\n      // The important new option is `$A.Title` so that users understand that this is different from just `$A`.\n      assert.isTrue(completions[0].startsWith(\"$A\"));\n      assert.isTrue(completions[1].startsWith(\"$A.Title\"));\n      assert.isTrue(completions[2].startsWith(\"$Release_Date\"));\n      assert.isTrue(completions[3].startsWith(\"$T\"));\n    });\n\n    await driver.sendKeys(\".\");  // formula is now `$A.`\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.isAtLeast(completions.length, 8);\n      assert.isTrue(completions[0].startsWith(\"$A.A\"));\n      assert.isTrue(completions[1].startsWith(\"$A.A.Title\"));\n      assert.isTrue(completions[2].startsWith(\"$A.B\"));\n      assert.isTrue(completions[3].startsWith(\"$A.Budget_millions\"));\n      assert.isTrue(completions[4].startsWith(\"$A.id\"));\n      assert.isTrue(completions[5].startsWith(\"$A.Release_Date\"));\n      assert.isTrue(completions[6].startsWith(\"$A.T\"));\n      assert.isTrue(completions[7].startsWith(\"$A.Title\"));\n    });\n\n    await driver.sendKeys(\"A.\");  // formula is now `$A.A.`\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.isAtLeast(completions.length, 8);\n      assert.isTrue(completions[0].startsWith(\"$A.A.A\"));\n      assert.isTrue(completions[1].startsWith(\"$A.A.A.Title\"));\n      assert.isTrue(completions[2].startsWith(\"$A.A.B\"));\n      assert.isTrue(completions[3].startsWith(\"$A.A.Budget_millions\"));\n      assert.isTrue(completions[4].startsWith(\"$A.A.id\"));\n      assert.isTrue(completions[5].startsWith(\"$A.A.Release_Date\"));\n      assert.isTrue(completions[6].startsWith(\"$A.A.T\"));\n      assert.isTrue(completions[7].startsWith(\"$A.A.Title\"));\n    });\n\n    await driver.sendKeys(\"T.\");  // formula is now `$A.A.T.`\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.isAtLeast(completions.length, 2);\n      assert.isTrue(completions.every(c => !c || c.startsWith(\"$A.A.T.\")));\n    });\n    await driver.sendKeys(Key.ENTER);\n  });\n\n  it(\"handles lookup methods correctly\", async function() {\n    await startFormulaAutocomplete(\"Films\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      // Sometimes FIXED(number, decimals=2, no_commas=False) is present, sometimes\n      // not? TODO: figure out why.\n      assert.isAtLeast(completions.length, 4);\n      assert.equal(completions[0], \"Films\");\n      // Typing in only the table ID (or even just part of it) immediately gives a few lookup options\n      assert.isTrue(completions[1].startsWith(\"Films.lookupOne(colName=<value>, ...)\"));\n      assert.isTrue(completions[2].startsWith(\"Films.lookupRecords(A=$id)\"));\n      assert.isTrue(completions[3].startsWith(\"Films.lookupRecords(colName=<value>, ...)\"));\n    });\n\n    // Having a '.' after `Films` means that all attributes are now shown\n    await driver.sendKeys(\".\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.lengthOf(completions, 6);\n      assert.isTrue(completions[0].startsWith(\"Films.all\"));\n      assert.isTrue(completions[1].startsWith(\"Films.lookupOne(colName=<value>, ...)\"));\n      assert.isTrue(completions[2].startsWith(\"Films.lookupRecords(A=$id)\"));\n      assert.isTrue(completions[3].startsWith(\"Films.lookupRecords(colName=<value>, ...)\"));\n      assert.isTrue(completions[4].startsWith(\"Films.Record\"));\n      assert.isTrue(completions[5].startsWith(\"Films.RecordSet\"));\n    });\n\n    // Type in the full method name but leave out the `(`. This just narrows down the existing options.\n    await driver.sendKeys(\"lookupRecords\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.lengthOf(completions, 2);\n      assert.isTrue(completions[0].startsWith(\"Films.lookupRecords(A=$id)\"));\n      assert.isTrue(completions[1].startsWith(\"Films.lookupRecords(colName=<value>, ...)\"));\n    });\n\n    // Adding a `(` now shows arguments for the method, as well as more exotic reference lookups like `(A=$A)`\n    await driver.sendKeys(\"(\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.isTrue(completions[0].startsWith(\"Films.lookupRecords(A=\"));\n      assert.isTrue(completions[1].startsWith(\"Films.lookupRecords(A=$A)\"));\n      assert.isTrue(completions[2].startsWith(\"Films.lookupRecords(A=$id)\"));\n      assert.isTrue(completions[3].startsWith(\"Films.lookupRecords(B=\"));\n      assert.isTrue(completions[4].startsWith(\"Films.lookupRecords(Budget_millions=\"));\n    });\n\n    // Repeat a similar test,\n    // but the `(` should be inserted by selecting an autocomplete option and pressing ENTER\n    await gu.sendKeys(40, \") + Films\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.isTrue(completions[1].startsWith(\"Films.lookupOne(colName=<value>, ...)\"));\n    });\n    await gu.sendKeys(40, Key.DOWN, Key.DOWN, Key.ENTER);\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.isTrue(completions[0].startsWith(\"Films.lookupOne(A=\"));\n      assert.isTrue(completions[1].startsWith(\"Films.lookupOne(A=$A)\"));\n      assert.isTrue(completions[2].startsWith(\"Films.lookupOne(A=$id)\"));\n      assert.isTrue(completions[3].startsWith(\"Films.lookupOne(B=\"));\n      assert.isTrue(completions[4].startsWith(\"Films.lookupOne(Budget_millions=\"));\n    });\n\n    // Same as the previous test, but with TAB instead of ENTER\n    await gu.sendKeys(40, \") + Friends\");\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.isTrue(completions[1].startsWith(\"Friends.lookupOne(colName=<value>, ...)\"));\n    });\n    await gu.sendKeys(40, Key.DOWN, Key.DOWN, Key.TAB);\n    await gu.waitToPass(async () => {\n      const completions = await getCompletionLines();\n      assert.isTrue(completions[0].startsWith(\"Friends.lookupOne(Age=\"));\n      assert.isTrue(completions[1].startsWith(\"Friends.lookupOne(Favorite_Film=\"));\n      assert.isTrue(completions[2].startsWith(\"Friends.lookupOne(Favorite_Film=$A)\"));\n      assert.isTrue(completions[3].startsWith(\"Friends.lookupOne(Favorite_Film=$id)\"));\n      assert.isTrue(completions[4].startsWith(\"Friends.lookupOne(id=\"));\n      assert.isTrue(completions[5].startsWith(\"Friends.lookupOne(Name=\"));\n    });\n  });\n\n  it('appends a \"(\" when completing function or method calls', async function() {\n    await startFormulaAutocomplete(\"SUM\");\n    await waitForAutocomplete();\n    await gu.sendKeys(40, Key.DOWN, Key.ENTER);\n    assert.equal(await driver.findWait(\".ace_editor\", 1000).getText(), \"SUM(\");\n\n    await gu.sendKeys(40,\n      \"1, 2)\",\n      ...arrayRepeat(6, Key.ARROW_LEFT),\n      ...arrayRepeat(3, Key.BACK_SPACE),\n      \"AVERAGE\",\n    );\n    await waitForAutocomplete();\n    await gu.sendKeys(40, Key.DOWN, Key.DOWN, Key.ENTER);\n    assert.equal(await driver.findWait(\".ace_editor\", 1000).getText(), \"AVERAGE(1, 2)\");\n\n    await gu.sendKeys(40, Key.END, \" Films.lookupOne\");\n    await waitForAutocomplete();\n    await gu.sendKeys(40, Key.DOWN, Key.ENTER);\n    assert.equal(\n      await driver.findWait(\".ace_editor\", 1000).getText(),\n      \"AVERAGE(1, 2) Films.lookupOne(\",\n    );\n\n    await gu.sendKeys(40,\n      'A=\"foo\")',\n      ...arrayRepeat(9, Key.ARROW_LEFT),\n      ...arrayRepeat(3, Key.BACK_SPACE),\n      \"Records\",\n    );\n    await waitForAutocomplete();\n    await driver.sendKeys(Key.DOWN, Key.DOWN, Key.ENTER);\n    assert.equal(\n      await driver.findWait(\".ace_editor\", 1000).getText(),\n      'AVERAGE(1, 2) Films.lookupRecords(A=\"foo\")',\n    );\n  });\n});\n\nasync function startFormulaAutocomplete(formula: string) {\n  await gu.waitAppFocus();\n  await driver.sendKeys(\"=\");\n  await gu.waitAppFocus(false);\n  // Send the keys separately. (Sending them together seems to cause the\n  // autocomplete to not show up from time to time.)\n  await gu.sendKeys(40, formula);\n}\n\nasync function waitForAutocomplete() {\n  await driver.findWait(\".ace_autocomplete .ace_line\", 1000);\n}\n"
  },
  {
    "path": "test/nbrowser/Formulas.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\nasync function checkHasLinkStyle(elem: WebElement, yesNo: boolean) {\n  assert.equal(await elem.getCssValue(\"text-decoration-line\"), yesNo ? \"underline\" : \"none\");\n}\n\ndescribe(\"Formulas\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  let session: gu.Session;\n  let docId: string;\n\n  after(async function() {\n    // In case of an error, close any open autocomplete and cell editor, to ensure we don't\n    // interfere with subsequent tests by unsaved value triggering an alert on unload.\n    await driver.sendKeys(Key.ESCAPE);\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  before(async function() {\n    session = await gu.session().login();\n    docId = (await session.tempDoc(cleanup, \"Favorite_Films.grist\")).id;\n  });\n\n  it(\"should highlight column in full edit mode\", async function() {\n    await gu.addColumn(\"A\");\n    await gu.addColumn(\"B\");\n    await gu.addColumn(\"C\");\n    // Make sure we are not in edit mode, finding column C is enough.\n    await gu.getColumnHeader({ col: \"C\" });\n    await driver.sendKeys(\"=\");\n    await gu.waitAppFocus(false);\n    if (await driver.find(\".test-editor-tooltip-convert\").isPresent()) {\n      await driver.find(\".test-editor-tooltip-convert\").click();\n    }\n    await driver.sendKeys(\" \");\n    // Make sure we are now in edit mode.\n    await gu.getColumnHeader({ col: \"$C\" });\n    // Move mouse over other column, and make sure it is highlighted\n    const hoverOver = async (col: string) =>\n      await driver.withActions(actions => (\n        actions\n          .move({ origin: gu.getCell(col, 1) })\n          .move({ origin: gu.getCell(col, 2) })\n      ));\n    const tooltipId = \".test-column-formula-tooltip\";\n    // Helper to test if hover is on right column.\n    const isHoverOn = async (col: string) => {\n      // Make sure we have only 1 tooltip.\n      assert.equal(1, (await driver.findAll(tooltipId)).length);\n      // Make sure column has hover class.\n      assert.isTrue(await gu.getColumnHeader({ col }).matches(\".hover-column\"));\n      // Make sure first row has hover class.\n      assert.isTrue(await gu.getCell(col, 1).matches(\".hover-column\"));\n      // Make sure tooltip shows correct text.\n      assert.equal(`Click to insert ${col}`, await driver.find(tooltipId).getText());\n    };\n    // Helper to test that no column is in hover state.\n    const noHoverAtAll = async () => {\n      // No tooltip is present.\n      assert.equal(0, (await driver.findAll(tooltipId)).length);\n      // Make sure no column has hover class.\n      assert.equal(0, (await driver.findAll(\".hover-column\")).length);\n    };\n    // Helper to test that column is not in hover state\n    const noHoverOn = async (col: string) => {\n      // Header doesn't have hover class\n      assert.isFalse(await gu.getColumnHeader({ col }).matches(\".hover-column\"));\n      // Fields don't have hover class\n      assert.isFalse(await gu.getCell(col, 1).matches(\".hover-column\"));\n      // If there is a tooltip, it doesn't have text with this column\n      if ((await driver.findAll(tooltipId)).length) {\n        assert.notEqual(`Click to insert ${col}`, await driver.find(tooltipId).getText());\n      }\n    };\n    await hoverOver(\"$A\");\n    await isHoverOn(\"$A\");\n    await noHoverOn(\"$B\");\n    // Make sure tooltip is closed and opened on another column.\n    await hoverOver(\"$B\");\n    await isHoverOn(\"$B\");\n    await noHoverOn(\"$A\");\n\n    // Make sure it is closed when leaving rows from the corners:\n    // - First moving on the row number\n    await hoverOver(\"$A\");\n    await isHoverOn(\"$A\");\n    await driver.withActions(actions => actions.move({ origin: driver.find(\".gridview_data_row_num\") }));\n    await noHoverAtAll();\n    // - Moving over add button\n    await hoverOver(\"$A\");\n    await isHoverOn(\"$A\");\n    await driver.withActions(actions => actions.move({ origin: driver.find(\".mod-add-column\") }));\n    await noHoverAtAll();\n    // - Moving below last row\n    await hoverOver(\"$A\");\n    await isHoverOn(\"$A\");\n    await driver.withActions(actions =>\n      actions\n        .move({ origin: gu.getCell(\"$A\", 7) })\n        .move({ origin: gu.getCell(\"$A\", 7), y: 22 + 1 }),\n    );\n    await noHoverAtAll();\n    // - Moving right after last column\n    await hoverOver(\"$A\");\n    await isHoverOn(\"$A\");\n    // First move to the last cell,\n    await driver.withActions(actions =>\n      actions\n        .move({ origin: gu.getCell(\"$C\", 7) }), // move add row on last column\n    );\n    await isHoverOn(\"$C\");\n    await noHoverOn(\"$A\");\n    // and then a little bit to the right (100px is width of the field)\n    await driver.withActions(actions =>\n      actions\n        .move({ origin: gu.getCell(\"$C\", 7), x: 100 + 1 }),\n    );\n    await noHoverAtAll();\n\n    // - Moving mouse on top of the grid.\n    await hoverOver(\"$A\");\n    await isHoverOn(\"$A\");\n    // move on the A header,\n    await driver.withActions(actions => actions.move({ origin: gu.getColumnHeader({ col: \"$A\" }) }));\n    // still hover should be on A column,\n    await isHoverOn(\"$A\");\n    // and now jump out of the grid (22 is height of the row)\n    await driver.withActions(actions => actions.move({ origin: gu.getColumnHeader({ col: \"$A\" }), y: -22 - 3 }));\n    await noHoverAtAll();\n    // undo adding 3 columns\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.undo(3);\n    await gu.checkForErrors();\n  });\n\n  it(\"should evaluate formulas requiring lazy-evaluation\", async function() {\n    await gu.renameColumn({ col: \"Budget (millions)\" }, \"Budget\");\n\n    await gu.addColumn(\"A\");\n    await gu.enterFormula('IFERROR($Invalid if $Budget > 50 else $Budget, \"X\")');\n    assert.deepEqual(await gu.getVisibleGridCells(\"A\", [1, 2, 3]), [\"30\", \"X\", \"10\"]);\n\n    await gu.addColumn(\"B\");\n    // This formula triggers an error for one cell, AltText for another.\n    await gu.enterFormula('($Budget - 30) / ($Budget - 10) or \"hello\"');\n    await gu.setType(/Numeric/);\n    assert.deepEqual(await gu.getVisibleGridCells(\"B\", [1, 2, 3]), [\"hello\", \"0.5555555556\", \"#DIV/0!\"]);\n\n    // ISERROR considers exceptions and AltText values.\n    await gu.addColumn(\"C\");\n    await gu.enterFormula(\"ISERROR($B)\");\n    assert.deepEqual(await gu.getVisibleGridCells(\"C\", [1, 2, 3]), [\"true\", \"false\", \"true\"]);\n\n    // ISERR considers exceptions but not AltText values.\n    await gu.addColumn(\"D\");\n    await gu.enterFormula(\"(ISERR($B)\");\n    assert.deepEqual(await gu.getVisibleGridCells(\"D\", [1, 2, 3]), [\"false\", \"false\", \"true\"]);\n  });\n\n  it(\"should support formulas returning unmarshallable or weird values\", async function() {\n    // Formulas can return strange values, and Grist should do a reasonable job displaying them.\n    // In particular, this verifies a fix to a bug where some values could cause an error that\n    // looked like a crash of the data engine.\n    await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n\n    // Our goal is to test output of formulas, so skip the slow and flaky typing in of a long\n    // multi-line formula, use API to set it instead.\n    const api = session.createHomeApi();\n    await api.applyUserActions(docId, [[\"ModifyColumn\", \"Films\", \"A\", {\n      isFormula: true,\n      formula: `\\\nimport enum\nclass Int(int):\n  pass\nclass Float(float):\n  pass\nclass Text(str):\n  pass\nclass MyEnum(enum.IntEnum):\n  ONE = 1\nclass FussyFloat(float):\n  def __float__(self):\n    raise TypeError(\"Cannot cast FussyFloat to float\")\n\nif $id > 1:\n  return None\nreturn [\n  -17, 0.0, 12345678901234567890, 1e-20, True,\n  Int(5), MyEnum.ONE, Float(3.3), Text('Hello'),\n  datetime.date(2024, 9, 2), datetime.datetime(2024, 9, 2, 3, 8, 21),\n  FussyFloat(17.0), [Float(6), '', MyEnum.ONE]\n]\n`,\n    }]]);\n\n    // Wait for the row we expect to become empty, to ensure the formula got processed.\n    await gu.waitToPass(async () => assert.equal(await gu.getCell({ rowNum: 2, col: \"A\" }).getText(), \"\"));\n    // Check the result of the formula: normal return, values correspond to what we asked.\n    const expected = `\\\n[-17, 0, 12345678901234567890, 1e-20, true, \\\n5, 1, 3.3, \"Hello\", \\\n2024-09-02, 2024-09-02T03:08:21.000Z, \\\n17.0, [6, \"\", 1]]`;\n    assert.deepEqual(await gu.getVisibleGridCells(\"A\", [1, 2, 3]), [expected, \"\", \"\"]);\n  });\n\n  it(\"should strip out leading equal-sign users might think is needed\", async function() {\n    await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n    await gu.enterFormula(\"$Budget*10\");\n    assert.deepEqual(await gu.getVisibleGridCells(\"A\", [1, 2, 3]), [\"300\", \"550\", \"100\"]);\n    await gu.enterFormula(\"= $Budget*100\");\n    assert.deepEqual(await gu.getVisibleGridCells(\"A\", [1, 2, 3]), [\"3000\", \"5500\", \"1000\"]);\n\n    await gu.sendKeys(Key.ENTER);\n    assert.equal(await gu.getFormulaText(), \" $Budget*100\");\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.undo(2);\n  });\n\n  it(\"should not fail when formulas have valid indent or leading whitespace\", async function() {\n    await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n\n    await gu.enterFormula(\"  $Budget * 10\");\n    assert.deepEqual(await gu.getVisibleGridCells(\"A\", [1, 2, 3]), [\"300\", \"550\", \"100\"]);\n\n    await driver.sendKeys(\"=\");\n    await gu.waitAppFocus(false);\n    // A single long string often works, but sometimes fails, so break up into multiple.\n    await gu.sendKeysSlowly(`  if $Budget > 50:${Key.chord(Key.SHIFT, Key.ENTER)}`);\n    await driver.sleep(50);\n    // The next line should get auto-indented.\n    await gu.sendKeysSlowly(`return 'Big'${Key.chord(Key.SHIFT, Key.ENTER)}`);\n    await driver.sleep(50);\n    // In the next line, we want to remove one level of indent.\n    await gu.sendKeysSlowly(`${Key.BACK_SPACE}return 'Small'`);\n    await gu.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n\n    await gu.sendKeys(Key.ENTER);\n    assert.equal(await gu.getFormulaText(), \"  if $Budget > 50:\\n    return 'Big'\\n  return 'Small'\");\n    await gu.sendKeys(Key.ESCAPE);\n\n    assert.deepEqual(await gu.getVisibleGridCells(\"A\", [1, 2, 3]), [\"Small\", \"Big\", \"Small\"]);\n\n    await gu.undo(2);\n  });\n\n  it(\"should support autocompletion from lowercase values\", async function() {\n    await gu.toggleSidePanel(\"right\", \"close\");\n    await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n    await driver.sendKeys(\"=\");\n    await gu.waitAppFocus(false);\n\n    // Type in \"me\", and expect uppercase completions like \"MEDIAN\".\n    await driver.sendKeys(\"me\");\n    await gu.waitToPass(async () =>\n      assert.includeMembers(await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText()), [\n        \"ME\\nDIAN\\n(value, *more_values)\\n \",\n        \"me\\nmoryview(\\n \",\n      ]),\n    );\n\n    // Using a completion of a function with signature should only insert an appropriate snippet.\n    await driver.sendKeys(Key.DOWN);\n    await driver.sendKeys(Key.ENTER);\n    await driver.findContentWait(\".ace_content\", /^MEDIAN\\($/, 1000);\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.waitAppFocus();\n\n    // Check that this works also for table names (\"fri\" finds \"Friends\")\n    await driver.sendKeys(\"=\");\n    await gu.waitAppFocus(false);\n    await driver.sendKeys(\"fri\");\n    await gu.waitToPass(async () => {\n      const completions = await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText());\n      assert.isTrue(completions[0].startsWith(\"Fri\\nends\"));\n      assert.isTrue(completions[1].startsWith(\"Fri\\nends.\\nlookupOne\\n(colName=<value>, ...)\"));\n      assert.isTrue(completions[2].startsWith(\"Fri\\nends.\\nlookupRecords\\n(colName=<value>, ...)\"));\n      assert.isTrue(completions[3].startsWith(\"Fri\\nends.lookupRecords(Favorite_Film=$id)\"));\n    });\n    await driver.sendKeys(Key.DOWN, Key.ENTER);\n\n    // Check that completing a table's method suggests lookup methods with signatures.\n    await driver.sendKeys(\".\");\n    await gu.waitToPass(async () => {\n      const completions = await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText());\n      assert.isTrue(completions[0].startsWith(\"Friends.\\nall\"));\n      assert.isTrue(completions[1].startsWith(\"Friends.\\nlookupOne\\n(colName=<value>, ...)\"));\n      assert.isTrue(completions[2].startsWith(\"Friends.\\nlookupRecords\\n(colName=<value>, ...)\"));\n      assert.isTrue(completions[3].startsWith(\"Friends.\\nlookupRecords(Favorite_Film=$id)\"));\n      assert.isTrue(completions[4].startsWith(\"Friends.\\nRecord\"));\n      assert.isTrue(completions[5].startsWith(\"Friends.\\nRecordSet\"));\n    });\n\n    // Check that selecting a table method inserts an appropriate snippet.\n    await driver.sendKeys(Key.DOWN, Key.DOWN, Key.ENTER);\n    await driver.findContentWait(\".ace_content\", /^Friends\\.lookupOne\\($/, 1000);\n    await driver.sendKeys(Key.ESCAPE, Key.ESCAPE);\n    await gu.waitAppFocus();\n\n    // Check that some built-in values are recognized in lowercase.\n    async function testBuiltin(typedText: string, expectedCompletion: string) {\n      await driver.sendKeys(\"=\");\n      await gu.waitAppFocus(false);\n      await driver.sendKeys(typedText);\n      await gu.waitToPass(async () =>\n        assert.include(await driver.findAll(\".ace_autocomplete .ace_line\", el => el.getText()), expectedCompletion));\n      await driver.sendKeys(Key.ESCAPE, Key.ESCAPE);\n      await gu.waitAppFocus();\n    }\n    await testBuiltin(\"tr\", \"Tr\\nue\\n \");\n    await testBuiltin(\"fa\", \"Fa\\nlse\\n \");\n    await testBuiltin(\"no\", \"No\\nne\\n \");\n  });\n\n  it(\"should link some suggested functions to their documentation\", async function() {\n    await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n    await driver.sendKeys(\"=\");\n    await gu.waitAppFocus(false);\n    await driver.sendKeys(\"me\");\n\n    await gu.waitToPass(async () => {\n      const completions = await driver.findAll(\".ace_autocomplete .ace_line\");\n      assert.include(await completions[0].getText(), \"ME\\nDIAN\\n(value, *more_values)\\n \");\n      assert.include(await completions[1].getText(), \"me\\nmoryview(\\n \");\n\n      // Check that the link is rendered with an underline.\n      await checkHasLinkStyle(completions[0].findContent(\"span\", /ME/), true);\n      await checkHasLinkStyle(completions[0].findContent(\"span\", /DIAN/), true);\n      await checkHasLinkStyle(completions[0].findContent(\"span\", /value/), false);\n    });\n\n    // Click the link part: it should open a new tab to a documentation URL.\n    await driver.findContent(\".ace_autocomplete .ace_line span\", /DIAN/).click();\n    // Switch to the new tab, and wait for the page to load.\n    let handles = await driver.getAllWindowHandles();\n    await driver.switchTo().window(handles[1]);\n    await gu.waitForUrl(\"support.getgrist.com\");\n    assert.equal(await driver.getCurrentUrl(), \"https://support.getgrist.com/functions/#median\");\n    await driver.close();\n    await driver.switchTo().window(handles[0]);\n\n    // Click now a part of the completion that's not the link. It should insert the suggestion.\n    await driver.findContent(\".ace_autocomplete .ace_line span\", /value/).click();\n    await driver.findContentWait(\".ace_content\", /^MEDIAN\\($/, 1000);\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.waitAppFocus();\n\n    // Check that this works also for table names (\"fri\" finds \"Friends\")\n    await driver.sendKeys(\"=\");\n    await gu.waitAppFocus(false);\n    await driver.sendKeys(\"Friends.\");\n    // Formula autocompletions in Ace editor are flaky (particularly when on a busy machine where\n    // setTimeout of 0 may take longer than expected). If the completion didn't work the first\n    // time, re-type the last character to trigger it again. This seems reliable.\n    if (!await driver.findContentWait(\".ace_autocomplete .ace_line\", \"Friends.\\nRecord\\n \", 500).catch(() => false)) {\n      await driver.sendKeys(Key.BACK_SPACE, \".\");\n    }\n\n    await gu.waitToPass(async () => {\n      const completions = await driver.findAll(\".ace_autocomplete .ace_line\");\n      assert.include(await completions[0].getText(), \"Friends.\\nall\\n \");\n      assert.include(await completions[1].getText(), \"Friends.\\nlookupOne\\n(colName=<value>, ...)\\n\");\n      assert.include(await completions[2].getText(), \"Friends.\\nlookupRecords\\n(colName=<value>, ...)\\n\");\n      assert.include(await completions[3].getText(), \"Friends.\\nlookupRecords(Favorite_Film=$id)\\n \");\n      assert.include(await completions[4].getText(), \"Friends.\\nRecord\\n \");\n      assert.include(await completions[5].getText(), \"Friends.\\nRecordSet\\n \");\n\n      await checkHasLinkStyle(completions[1].findContent(\"span\", /Friends/), false);\n      await checkHasLinkStyle(completions[1].findContent(\"span\", /lookupOne/), true);\n      await checkHasLinkStyle(completions[1].findContent(\"span\", \"(\"), false);\n      await checkHasLinkStyle(completions[2].findContent(\"span\", /Friends/), false);\n      await checkHasLinkStyle(completions[2].findContent(\"span\", /lookupRecords/), true);\n    }, 4000);\n\n    // Again, click the link part.\n    await driver.findContent(\".ace_autocomplete .ace_line span\", /lookupRecords/).click();\n    handles = await driver.getAllWindowHandles();\n    await driver.switchTo().window(handles[1]);\n    await gu.waitForUrl(\"support.getgrist.com\");\n    assert.equal(await driver.getCurrentUrl(), \"https://support.getgrist.com/functions/#lookuprecords\");\n    await driver.close();\n    await driver.switchTo().window(handles[0]);\n\n    // Now click the non-link part.\n    await driver.findContent(\".ace_autocomplete .ace_line\", /lookupRecords/).findContent(\"span\", /Friends/).click();\n    await driver.findContentWait(\".ace_content\", /^Friends\\.lookupRecords\\($/, 1000);\n    await driver.sendKeys(Key.ESCAPE, Key.ESCAPE);\n    await gu.waitAppFocus();\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/GridOptions.ntest.js",
    "content": "/**\n * NOTE: This test is migrated to new UI as test/nbrowser/GridOptions.ts.\n * Remove this version once old UI is no longer supported.\n */\n\n\nimport { assert, driver } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"GridOptions.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n\n\n  // ====== Some Helpers ======\n\n  let secNames = [\"COUNTRY\", \"CITY\", \"COUNTRYLANGUAGE\"];\n  let switchTo = (i) =>\n    gu.actions.viewSection(secNames[i]).selectSection();\n\n  /* Test that styles on the given section match the specified flags\n   * sec: index into secNames\n   * hor/vert/zebra: boolean flags\n   */\n  async function assertHVZ(sec, hor, vert, zebra) {\n    let testClasses =\n      [\"record-hlines\", \"record-vlines\", \"record-zebra\"];\n    let flags = [hor, vert, zebra];\n\n    let cell = await gu.getCell({rowNum: 1, col: 0, section: secNames[sec]});\n    let row = await cell.findClosest(\".record\");\n    const rowClasses = await row.classList();\n    testClasses.forEach( (cls, i) => {\n      if(flags[i])  { assert.include(rowClasses, cls);} else          { assert.notInclude(rowClasses, cls); }\n    });\n  }\n\n\n  // ====== Prepare Document ======\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"World-v10.grist\", true);\n    await $(\".test-gristdoc\").wait();\n    await gu.hideBanners();\n  });\n\n  beforeEach(async function() {\n    //Prepare consistent view\n    await gu.actions.selectTabView(\"Country\");\n    await gu.openSidePane(\"view\");\n    await $(\".test-grid-options\").wait(assert.isDisplayed);\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n\n  // ====== MAIN TESTS ======\n\n\n  it(\"should only be visible on grid view/summary view\", async function() {\n\n    let getOptions = () => $(\".test-grid-options\");\n    await assert.isPresent(getOptions());\n\n    // check that it doesnt show up in detail view\n    await gu.actions.viewSection(\"COUNTRY Card List\").selectSection();\n    await assert.isPresent(getOptions(), false);\n\n    // check that it shows up on the grid-views\n    await gu.actions.viewSection(\"COUNTRY\").selectSection();\n    await assert.isDisplayed(getOptions());\n    await gu.actions.viewSection(\"CITY\").selectSection();\n    await assert.isDisplayed(getOptions());\n    await gu.actions.viewSection(\"COUNTRYLANGUAGE\").selectSection();\n    await assert.isDisplayed(getOptions());\n\n  });\n\n  it(\"should set and persist styles on a grid\", async function() {\n\n    // get handles on elements\n    let h = \".test-h-grid-button input\";\n    let v = \".test-v-grid-button input\";\n    let z = \".test-zebra-stripe-button input\";\n\n    // should start with v+h gridlines, no zebra\n    await assertHVZ(0, true, true, false);\n\n    // change values on all the sections\n    await switchTo(0);\n    await $(z).scrollIntoView().click();\n\n    await switchTo(1);\n    await $(h).click();\n    await $(v).click();\n\n    await switchTo(2);\n    await $(h).click(); // turn off\n    await $(z).click(); // turn on\n    await gu.waitForServer();\n\n    await assertHVZ(0, true, true, true);     // all on\n    await assertHVZ(1, false, false, false);  // all off\n    await assertHVZ(2, false, true, true);    // -h +v +z\n\n    // ensure that values persist after reload\n    await driver.navigate().refresh();\n    //await $.injectIntoPage();\n    await gu.waitForDocToLoad();\n    await gu.hideBanners();\n    await assertHVZ(0, true, true, true);     // all on\n    await assertHVZ(1, false, false, false);  // all off\n    await assertHVZ(2, false, true, true);    // -h +v +z\n  });\n\n\n  it(\"should set .record-even on even-numbered rows\", async function() {\n    let rowClasses = row =>\n      gu.getCell({rowNum: row, col: 0}).closest(\".record\").classList();\n\n    await switchTo(0);\n    assert.notInclude(await rowClasses(1), \"record-even\", \"row 1 should be odd\");\n    assert.include(await rowClasses(2), \"record-even\", \"row 2 should be even\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/GridViewBugs.ts",
    "content": "import { DocCreationInfo } from \"app/common/DocListAPI\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"GridViewBugs\", function() {\n  this.timeout(\"20s\");\n  const cleanup = setupTestSuite();\n  let session: gu.Session, doc: DocCreationInfo, api: UserAPI;\n\n  before(async function() {\n    session = await gu.session().login();\n    doc = await session.tempDoc(cleanup, \"Hello.grist\");\n    api = session.createHomeApi();\n  });\n\n  it(\"should rename valid column when clicked away\", async function() {\n    await gu.openColumnPanel(\"A\");\n\n    // Rename column A to Dummy\n    await toggleDerived();\n    await colId().click();\n    await gu.clearInput();\n    await colId().sendKeys(\"$Dummy\");\n\n    // Now click away, it used to rename the new column to Dummy\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitForServer();\n\n    // Now make sure that column A was renamed to $Dummy\n    await gu.getCell(\"A\", 1).click();\n    assert.equal(await colId().value(), \"$Dummy\");\n\n    // And that column B is still named $B.\n    await gu.getCell(\"B\", 1).click();\n    assert.equal(await colId().value(), \"$B\");\n    await gu.undo();\n\n    async function toggleDerived() {\n      await driver.find(\".test-field-derive-id\").click();\n      await gu.waitForServer();\n    }\n\n    function colId() {\n      return driver.find(\".test-field-col-id\");\n    }\n  });\n\n  // This test is for a bug where hiding multiple columns at once would cause an error in the menu.\n  // Selection wasn't updated and the column index was out of bounds.\n  it(\"should hide multiple columns without an error\", async function() {\n    await gu.selectColumnRange(\"A\", \"B\");\n    await gu.openColumnMenu(\"B\", \"Hide 2 columns\");\n    await gu.waitForServer();\n    await gu.selectColumnRange(\"C\", \"D\");\n    await gu.openColumnMenu(\"D\", \"Hide 2 columns\");\n    await gu.waitForServer();\n    await gu.openColumnMenu(\"E\");\n    await gu.checkForErrors();\n  });\n\n  it(\"should show tables with no columns without errors\", async function() {\n    // Create and open a new table with no columns\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Empty\", []],\n    ]);\n    await gu.getPageItem(/Empty/).click();\n\n    // The only 'column' should be the button to add a column\n    const columnNames = await driver.findAll(\".column_name\", e => e.getText());\n    assert.deepEqual(columnNames, [\"+\"]);\n\n    // There should be no errors\n    assert.lengthOf(await driver.findAll(\".test-notifier-toast-wrapper\"), 0);\n  });\n\n  // When a grid is scrolled, and then data is changed (due to click in a linked section), some\n  // records are not rendered or the position of the scroll container is corrupted.\n  it(\"should render list with wrapped choices correctly\", async function() {\n    await session.tempDoc(cleanup, \"Teams.grist\");\n    await gu.selectSectionByTitle(\"PROJECTS\");\n    await gu.getCell(0, 1).click();\n    await gu.selectSectionByTitle(\"TODO\");\n    await gu.scrollActiveView(0, 300);\n    await gu.selectSectionByTitle(\"PROJECTS\");\n    await gu.getCell(0, 2).click();\n    await gu.selectSectionByTitle(\"TODO\");\n    // This throws an error, as the cell is not rendered.\n    assert.equal(await gu.getCell(0, 2).getText(), \"2021-09-27 Mo\\n2021-10-04 Mo\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/GridViewNewColumnMenu.ts",
    "content": "import { UserAPIImpl } from \"app/common/UserAPI\";\nimport {\n  addRefListLookup,\n  AVERAGE,\n  checkTypeAndFormula,\n  clickAddColumn,\n  closeAddColumnMenu,\n  collapsedHiddenColumns,\n  hasAddNewColumMenu,\n  hasLookupMenu,\n  hasShortcuts,\n  isMenuPresent,\n  MAX,\n  MIN,\n  PERCENT,\n  revertEach,\n  revertThis,\n} from \"test/nbrowser/GridViewNewColumnMenuUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert } from \"chai\";\nimport { driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"GridViewNewColumnMenu\", function() {\n  const STANDARD_WAITING_TIME = 1000;\n  this.timeout(\"3m\");\n  const cleanup = setupTestSuite();\n  gu.bigScreen();\n  let api: UserAPIImpl;\n  let docId: string;\n  let session: gu.Session;\n\n  before(async function() {\n    session = await gu.session().login({ showTips: true });\n    api = session.createHomeApi();\n    docId = await session.tempNewDoc(cleanup, \"ColumnMenu\");\n    await gu.dismissBehavioralPrompts();\n\n    // Add a table that will be used for lookups.\n    await gu.sendActions([\n      [\"AddTable\", \"Person\", [\n        { id: \"Name\" },\n        { id: \"Age\", type: \"Numeric\" },\n        { id: \"Hobby\", type: \"ChoiceList\", widgetOptions: JSON.stringify({ choices: [\"Books\", \"Cars\"] }) },\n        { id: \"Employee\", type: \"Choice\", widgetOptions: JSON.stringify({ choices: [\"Y\", \"N\"] }) },\n        { id: \"Birthday date\", type: \"Date\", label: \"Birthday date\" },\n        { id: \"Member\", type: \"Bool\" },\n        { id: \"SeenAt\", type: \"DateTime:UTC\" },\n        { id: \"Photo\", type: \"Attachments\" },\n        { id: \"Fun\", type: \"Any\", formula: \"44\" },\n        { id: \"Parent\", type: \"Ref:Person\" },\n        { id: \"Children\", type: \"RefList:Person\" },\n      ]],\n      [\"AddRecord\", \"Person\", null, { Name: \"Bob\", Age: 12 }],\n      [\"AddRecord\", \"Person\", null, { Name: \"Robert\", Age: 34, Parent: 1 }],\n    ]);\n  });\n\n  describe(\"sections\", function() {\n    revertEach();\n\n    it(\"looks ok for an empty document\", async function() {\n      await clickAddColumn();\n      await hasAddNewColumMenu();\n      await hasShortcuts();\n      await closeAddColumnMenu();\n    });\n\n    it(\"has lookup columns\", async function() {\n      await gu.sendActions([\n        // Create a table that we can reference to.\n        [\"AddTable\", \"Reference\", [\n          { id: \"Name\" },\n          { id: \"Age\" },\n          { id: \"City\" },\n        ]],\n        // Add some data to the table.\n        [\"AddRecord\", \"Reference\", null, { Name: \"Bob\", Age: 12, City: \"New York\" }],\n        [\"AddRecord\", \"Reference\", null, { Name: \"Robert\", Age: 34, City: \"Łódź\" }],\n        // And a Ref column in the main table to that table.\n        [\"AddColumn\", \"Table1\", \"Reference\", { type: \"Ref:Reference\" }],\n      ]);\n\n      await clickAddColumn();\n      await hasAddNewColumMenu();\n      await hasShortcuts();\n      await hasLookupMenu(\"Reference\");\n      await closeAddColumnMenu();\n    });\n  });\n\n  describe(\"column creation\", function() {\n    revertEach();\n\n    it(\"should show rename menu after a new column click\", async function() {\n      await clickAddColumn();\n      await driver.findWait(\".test-new-columns-menu-add-new\", STANDARD_WAITING_TIME).click();\n      await gu.waitForServer();\n      await driver.findWait(\".test-column-title-popup\", STANDARD_WAITING_TIME, \"rename menu is not present\");\n      await closeAddColumnMenu();\n    });\n\n    it(\"should create a new column\", async function() {\n      await clickAddColumn();\n      await driver.findWait(\".test-new-columns-menu-add-new\", STANDARD_WAITING_TIME).click();\n      await gu.waitForServer();\n      // discard rename menu\n      await driver.findWait(\".test-column-title-close\", STANDARD_WAITING_TIME).click();\n      // check if new column is present\n      const columns = await gu.getColumnNames();\n      assert.include(columns, \"D\", \"new column is not present\");\n      assert.lengthOf(columns, 4, \"wrong number of columns\");\n\n      // check that single undo removes new column\n      await gu.undo();\n\n      const columns2 = await gu.getColumnNames();\n      assert.notInclude(columns2, \"D\", \"new column is still present\");\n      assert.lengthOf(columns2, 3, \"wrong number of columns\");\n    });\n\n    it(\"should support inserting before selected column\", async function() {\n      await gu.openColumnMenu(\"A\", \"Insert column to the left\");\n      await driver.findWait(\".test-new-columns-menu\", STANDARD_WAITING_TIME);\n      await gu.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      await driver.findWait(\".test-column-title-close\", STANDARD_WAITING_TIME).click();\n      const columns = await gu.getColumnNames();\n      assert.deepEqual(columns, [\"D\", \"A\", \"B\", \"C\"]);\n    });\n\n    it(\"should support inserting after selected column\", async function() {\n      await gu.openColumnMenu(\"A\", \"Insert column to the right\");\n      await driver.findWait(\".test-new-columns-menu\", STANDARD_WAITING_TIME);\n      await gu.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      await driver.findWait(\".test-column-title-close\", STANDARD_WAITING_TIME).click();\n      const columns = await gu.getColumnNames();\n      assert.deepEqual(columns, [\"A\", \"D\", \"B\", \"C\"]);\n    });\n\n    it(\"should support inserting after the last visible column\", async function() {\n      await gu.openColumnMenu(\"C\", \"Insert column to the right\");\n      await driver.findWait(\".test-new-columns-menu\", STANDARD_WAITING_TIME);\n      await gu.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      await driver.findWait(\".test-column-title-close\", STANDARD_WAITING_TIME).click();\n      const columns = await gu.getColumnNames();\n      assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"D\"]);\n    });\n\n    it(\"should skip showing menu when inserting with keyboard shortcuts\", async function() {\n      await gu.sendKeys(Key.chord(Key.ALT, \"=\"));\n      await gu.waitForServer();\n      assert.isFalse(await driver.find(\".test-new-columns-menu\").isPresent());\n      await gu.sendKeys(Key.ENTER);\n      let columns = await gu.getColumnNames();\n      assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"D\"]);\n      await gu.sendKeys(Key.chord(Key.SHIFT, Key.ALT, \"=\"));\n      await gu.waitForServer();\n      assert.isFalse(await driver.find(\".test-new-columns-menu\").isPresent());\n      await gu.sendKeys(Key.ENTER);\n      columns = await gu.getColumnNames();\n      assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"E\", \"D\"]);\n    });\n  });\n\n  describe(\"create column with type\", function() {\n    revertThis();\n    const columnsThatShouldTriggerSideMenu = [\n      \"Reference\",\n      \"Reference List\",\n    ];\n\n    const optionsToBeDisplayed = [\n      \"Text\",\n      \"Numeric\",\n      \"Integer\",\n      \"Toggle\",\n      \"Date\",\n      \"DateTime\",\n      \"Choice\",\n      \"Choice List\",\n      \"Reference\",\n      \"Reference List\",\n      \"Attachment\",\n    ].map(option => ({ type: option, testClass: option.toLowerCase().replace(\" \", \"-\") }));\n\n    describe(\"on desktop\", function() {\n      gu.bigScreen();\n\n      it('should show \"Add Column With type\" option', async function() {\n        // open add new colum menu\n        await clickAddColumn();\n        // check if \"Add Column With type\" option is present\n        const addWithType = await driver.findWait(\n          \".test-new-columns-menu-add-with-type\",\n          100,\n          \"Add Column With Type is not present\");\n        assert.equal(await addWithType.getText(), \"Add column with type\");\n      });\n\n      it(\"should display reference column popup when opened for the first time\", async function() {\n        await gu.enableTips(session.email);\n        // open add new colum menu\n        await clickAddColumn();\n        // select \"Add Column With type\" option\n        await driver.findWait(\".test-new-columns-menu-add-with-type\", STANDARD_WAITING_TIME).click();\n        // wait for submenu to appear\n        await driver.findWait(\".test-new-columns-menu-add-with-type-submenu\", STANDARD_WAITING_TIME);\n        // check if popup is showed\n        await driver.findWait(\".test-behavioral-prompt\",\n          STANDARD_WAITING_TIME,\n          \"Reference column popup is not present\");\n        // close popup\n        await gu.dismissBehavioralPrompts();\n        // close menu\n        await closeAddColumnMenu();\n        // open it again\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-add-with-type\", STANDARD_WAITING_TIME).click();\n        await driver.findWait(\".test-new-columns-menu-add-with-type-submenu\", STANDARD_WAITING_TIME);\n        // popup should not be showed\n        assert.isFalse(await driver.find(\".test-behavioral-prompt\").isPresent());\n        await closeAddColumnMenu();\n      });\n\n      for (const option of optionsToBeDisplayed) {\n        it(`should allow to select column type ${option.type}`, async function() {\n          // open add new colum menu\n          await clickAddColumn();\n          // select \"Add Column With type\" option\n          await driver.findWait(\".test-new-columns-menu-add-with-type\", STANDARD_WAITING_TIME).click();\n          // wait for submenu to appear\n          await driver.findWait(\".test-new-columns-menu-add-with-type-submenu\", STANDARD_WAITING_TIME);\n          // check if it is present in the menu\n          const element = await driver.findWait(\n            `.test-new-columns-menu-add-${option.testClass}`.toLowerCase(),\n            100,\n            `${option.type} option is not present`);\n          // click on the option and check if column is added with a proper type\n          await element.click();\n          await gu.waitForServer();\n          // discard rename menu\n          await driver.findWait(\".test-column-title-close\", STANDARD_WAITING_TIME).click();\n          // check if new column is present\n          await gu.selectColumn(\"D\");\n          await gu.openColumnPanel();\n          const type = await gu.getType();\n          assert.equal(type, option.type);\n\n          await gu.undo(1);\n        });\n      }\n\n      for (const optionsTriggeringMenu of optionsToBeDisplayed.filter(option =>\n        columnsThatShouldTriggerSideMenu.includes(option.type))) {\n        it(`should open Right Menu on Column section after choosing ${optionsTriggeringMenu.type}`, async function() {\n          await gu.enableTips(session.email);\n          // close right panel just in case.\n          await gu.toggleSidePanel(\"right\", \"close\");\n          // open add new colum menu\n          await clickAddColumn();\n          // select \"Add Column With type\" option\n          await driver.findWait(\".test-new-columns-menu-add-with-type\", STANDARD_WAITING_TIME).click();\n          // wait for submenu to appear\n          await driver.findWait(\".test-new-columns-menu-add-with-type-submenu\", STANDARD_WAITING_TIME);\n          // check if it is present in the menu\n          const element = await driver.findWait(\n            `.test-new-columns-menu-add-${optionsTriggeringMenu.testClass}`.toLowerCase(),\n            STANDARD_WAITING_TIME,\n            `${optionsTriggeringMenu.type} option is not present`);\n          // click on the option and check if column is added with a proper type\n          await element.click();\n          await gu.waitForServer();\n          // discard rename menu\n          await driver.findWait(\".test-column-title-close\", STANDARD_WAITING_TIME).click();\n          // Wait for the side panel animation.\n          await gu.waitForSidePanel();\n          // check if right menu is opened on column section\n          await gu.waitForSidePanel();\n          assert.isTrue(await driver.find(\".test-right-tab-field\").isDisplayed());\n          await gu.toggleSidePanel(\"right\", \"close\");\n          await gu.undo(1);\n        });\n\n        it(`should show referenceColumnsConfig in right Column section\n         when ${optionsTriggeringMenu.type} type is chosen`,\n        async function() {\n          // close right panel just in case.\n          await gu.toggleSidePanel(\"right\", \"close\");\n          await gu.enableTips(session.email);\n          await driver.executeScript(\"resetDismissedPopups()\");\n          // open add new colum menu\n          await clickAddColumn();\n          // select \"Add Column With type\" option\n          await driver.findWait(\".test-new-columns-menu-add-with-type\", STANDARD_WAITING_TIME).click();\n          // wait for submenu to appear\n          await driver.findWait(\".test-new-columns-menu-add-with-type-submenu\", STANDARD_WAITING_TIME);\n          // check if it is present in the menu\n          const element = await driver.findWait(\n            `.test-new-columns-menu-add-${optionsTriggeringMenu.testClass}`.toLowerCase(),\n            STANDARD_WAITING_TIME,\n            `${optionsTriggeringMenu.type} option is not present`);\n          // click on the option and check if column is added with a proper type\n          await element.click();\n          await gu.waitForServer();\n          // discard rename menu\n          await driver.findWait(\".test-column-title-close\", STANDARD_WAITING_TIME).click();\n          // check if referenceColumnsConfig is present\n          await gu.waitToPass(async () => assert.isTrue(\n            await driver.findContentWait(\n              \".test-behavioral-prompt-title\",\n              \"Reference Columns\",\n              STANDARD_WAITING_TIME * 2,\n            ).isDisplayed(),\n          ), 5000);\n          await gu.dismissBehavioralPrompts();\n          await gu.toggleSidePanel(\"right\", \"close\");\n          await gu.undo(1);\n        });\n      }\n    });\n\n    describe(\"on mobile\", function() {\n      gu.narrowScreen();\n      for (const optionsTriggeringMenu of optionsToBeDisplayed.filter(option =>\n        columnsThatShouldTriggerSideMenu.includes(option.type))) {\n        it(\"should not show Right Menu when user is on the mobile/narrow screen\", async function() {\n          await gu.enableTips(session.email);\n          // close right panel just in case.\n          await gu.toggleSidePanel(\"right\", \"close\");\n          // open add new colum menu\n          await clickAddColumn();\n          // select \"Add Column With type\" option\n          await driver.findWait(\".test-new-columns-menu-add-with-type\", STANDARD_WAITING_TIME).click();\n          // wait for submenu to appear\n          await driver.findWait(\".test-new-columns-menu-add-with-type-submenu\", STANDARD_WAITING_TIME);\n          // check if it is present in the menu\n          const element = await driver.findWait(\n            `.test-new-columns-menu-add-${optionsTriggeringMenu.testClass}`.toLowerCase(),\n            STANDARD_WAITING_TIME,\n            `${optionsTriggeringMenu.type} option is not present`);\n          // click on the option and check if column is added with a proper type\n          await element.click();\n          await gu.waitForServer();\n          // discard rename menu\n          await driver.findWait(\".test-column-title-close\", STANDARD_WAITING_TIME).click();\n          // check if right menu is opened on column section\n          assert.isFalse(await driver.find(\".test-right-tab-field\").isPresent());\n          await gu.toggleSidePanel(\"right\", \"close\");\n          await gu.undo(1);\n        });\n      }\n    });\n  });\n\n  describe(\"create formula column\", function() {\n    revertThis();\n\n    before(async function() {\n      // Previous test runs in a smaller screen. It restores the window, but it's hard to know\n      // when all the resizing has taken effect, and easier to just reload the doc.\n      await gu.reloadDoc();\n    });\n\n    it('should show \"create formula column\" option with tooltip', async function() {\n      // open add new colum menu\n      await clickAddColumn();\n      // check if \"create formula column\" option is present\n      const addWithType = await driver.findWait(\".test-new-columns-menu-add-formula\", STANDARD_WAITING_TIME,\n        \"Add formula column is not present\");\n      // check if it has a tooltip button\n      const tooltip = await addWithType.findWait(\".test-info-tooltip\", STANDARD_WAITING_TIME,\n        \"Tooltip button is not present\");\n      // check if tooltip is show after hovering\n      await tooltip.mouseMove();\n      const tooltipContainer = await driver.findWait(\".test-info-tooltip-popup\",\n        100,\n        \"Tooltip is not shown\");\n      // check if tooltip is showing valid message\n      const tooltipText = await tooltipContainer.getText();\n      assert.include(tooltipText,\n        \"Formulas support many Excel functions and full Python syntax.\",\n        \"Tooltip is showing wrong message\");\n      // check if link in tooltip has a proper href\n      const hrefAddress = await tooltipContainer.findWait(\"a\",\n        100,\n        \"Tooltip link is not present\");\n      assert.equal(await hrefAddress.getText(), \"Learn more.\");\n      assert.equal(await hrefAddress.getAttribute(\"href\"),\n        \"https://support.getgrist.com/formulas\",\n        \"Tooltip link has wrong href\");\n    });\n\n    it(\"should allow to select formula column\", async function() {\n      // open column panel - we will need it later\n      await gu.openColumnPanel();\n      // open add new colum menu\n      await clickAddColumn();\n      // select \"create formula column\" option\n      await driver.findWait(\".test-new-columns-menu-add-formula\", STANDARD_WAITING_TIME).click();\n      // check if new column is present\n      await gu.waitForServer();\n      // there should not be a rename popup\n      assert.isFalse(await driver.find(\"test-column-title-popup\").isPresent());\n      // check if editor popup is opened\n      await driver.findWait(\".test-floating-editor-popup\", 200, \"Editor popup is not present\");\n      // write some formula\n      await gu.sendKeys(\"1+1\");\n      await driver.find(\".test-formula-editor-save-button\").click();\n      await gu.waitForServer();\n      // check if column is created with a proper type\n      const type = await gu.columnBehavior();\n      assert.equal(type, \"Formula column\");\n    });\n  });\n\n  describe(\"hidden columns\", function() {\n    revertThis();\n\n    it(\"hides hidden column section from < 5 columns\", async function() {\n      await gu.sendActions([\n        [\"AddVisibleColumn\", \"Table1\", \"New1\", { type: \"Any\" }],\n        [\"AddVisibleColumn\", \"Table1\", \"New2\", { type: \"Any\" }],\n        [\"AddVisibleColumn\", \"Table1\", \"New3\", { type: \"Any\" }],\n      ]);\n      await gu.openWidgetPanel();\n      await clickAddColumn();\n      assert.isFalse(await driver.find(\".new-columns-menu-hidden-columns\").isPresent(), \"hidden section is present\");\n      await closeAddColumnMenu();\n    });\n\n    describe(\"inline menu section\", function() {\n      revertEach();\n\n      it(\"shows hidden section as inlined for 1 to 5 hidden columns\", async function() {\n        // Check that the hidden section is present and has the expected columns.\n        const checkSection = async (...columns: string[]) => {\n          await clickAddColumn();\n          await driver.findWait(\".test-new-columns-menu-hidden-columns-header\",\n            STANDARD_WAITING_TIME,\n            \"hidden section is not present\");\n          for (const column of columns) {\n            assert.isTrue(\n              await driver.findContent(\".test-new-columns-menu-hidden-column-inlined\", column).isPresent(),\n              `column ${column} is not present`,\n            );\n          }\n          await closeAddColumnMenu();\n        };\n\n        await gu.moveToHidden(\"A\");\n        await checkSection(\"A\");\n        await gu.moveToHidden(\"B\");\n        await gu.moveToHidden(\"C\");\n        await gu.moveToHidden(\"New1\");\n        await gu.moveToHidden(\"New2\");\n        await checkSection(\"A\", \"B\", \"C\", \"New1\", \"New2\");\n      });\n\n      it(\"should add hidden column at the end\", async function() {\n        let columns = await gu.getColumnNames();\n        assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"New1\", \"New2\", \"New3\"]);\n\n        // Hide 'A' and add it back.\n        await gu.moveToHidden(\"A\");\n        await clickAddColumn();\n        await driver.findContent(\".test-new-columns-menu-hidden-column-inlined\", \"A\").click();\n        await gu.waitForServer();\n\n        // Now check that the column was added at the end.\n        columns = await gu.getColumnNames();\n        assert.deepEqual(columns, [\"B\", \"C\", \"New1\", \"New2\", \"New3\", \"A\"]);\n      });\n    });\n\n    describe(\"submenu section\", function() {\n      before(async function() {\n        await gu.sendActions([\n          [\"AddVisibleColumn\", \"Table1\", \"New4\", { type: \"Any\" }],\n        ]);\n      });\n\n      after(async function() {\n        await gu.sendActions([\n          [\"RemoveColumn\", \"Table1\", \"New4\"],\n        ]);\n      });\n\n      it(\"more than 5 hidden columns, section should be in submenu\", async function() {\n        // Hide all columns except A.\n        const columns = await gu.getColumnNames();\n        for (const column of columns.slice(1)) {\n          await gu.moveToHidden(column);\n        }\n\n        // Make sure they are hidden.\n        assert.deepEqual(await gu.getColumnNames(), [\"A\"]);\n\n        // Now make sure we see all of them in the submenu.\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-hidden-columns-menu\",\n          STANDARD_WAITING_TIME,\n          \"hidden section is not present\");\n        assert.isFalse(await driver.find(\".test-new-columns-menu-hidden-columns-header\").isPresent());\n\n        // We don't see any hidden columns in the main menu.\n        assert.isFalse(await driver.find(\".test-new-columns-menu-hidden-column-inlined\").isPresent());\n\n        // Now expand the submenu and check that we see all the hidden columns.\n        await driver.find(\".test-new-columns-menu-hidden-columns-menu\").click();\n\n        // And we should see all the hidden columns.\n        for (const column of columns.slice(1)) {\n          assert.isTrue(\n            await driver.findContentWait(\".test-new-columns-menu-hidden-column-collapsed\",\n              column,\n              STANDARD_WAITING_TIME).isDisplayed(),\n            `column ${column} is not present`,\n          );\n        }\n\n        // Add B column.\n        await driver.findContent(\".test-new-columns-menu-hidden-column-collapsed\", \"B\").click();\n        await gu.waitForServer();\n\n        // Now check that the column was added at the end.\n        const columns2 = await gu.getColumnNames();\n        assert.deepEqual(columns2, [\"A\", \"B\"]);\n\n        // Hide it again.\n        await gu.undo();\n      });\n\n      it(\"submenu should be searchable\", async function() {\n        await clickAddColumn();\n        await driver.find(\".test-new-columns-menu-hidden-columns-menu\").click();\n        await driver.findWait(\".test-searchable-menu-input\", STANDARD_WAITING_TIME).click();\n        await gu.sendKeys(\"New\");\n        await checkResult([\"New1\", \"New2\", \"New3\", \"New4\"]);\n\n        await gu.sendKeys(\"2\");\n        await checkResult([\"New2\"]);\n\n        await gu.sendKeys(\"dummy\");\n        await checkResult([]);\n\n        await gu.clearInput();\n        await checkResult([\"B\", \"C\", \"New1\", \"New2\", \"New3\", \"New4\"]);\n\n        await gu.sendKeys(Key.ESCAPE);\n        assert.isFalse(await isMenuPresent());\n\n        // Show it once again and add B and C.\n        await clickAddColumn();\n        await driver.find(\".test-new-columns-menu-hidden-columns-menu\").click();\n        await driver.findContentWait(\".test-new-columns-menu-hidden-column-collapsed\",\n          \"B\",\n          STANDARD_WAITING_TIME).click();\n        await gu.waitForServer();\n\n        await clickAddColumn();\n        // Now this column is inlined.\n        await driver.findContentWait(\".test-new-columns-menu-hidden-column-inlined\",\n          \"C\",\n          STANDARD_WAITING_TIME).click();\n        await gu.waitForServer();\n\n        // Make sure they are added at the end.\n        const columns = await gu.getColumnNames();\n        assert.deepEqual(columns, [\"A\", \"B\", \"C\"]);\n\n        async function checkResult(cols: string[]) {\n          await gu.waitToPass(async () => {\n            assert.deepEqual(\n              await collapsedHiddenColumns(),\n              cols,\n            );\n          }, STANDARD_WAITING_TIME);\n        }\n      });\n    });\n  });\n\n  const COLUMN_LABELS = [\n    \"Name\", \"Age\", \"Hobby\", \"Employee\", \"Birthday date\", \"Member\", \"SeenAt\", \"Photo\", \"Fun\", \"Parent\", \"Children\",\n  ];\n\n  describe(\"lookups from Reference columns\", function() {\n    revertThis();\n\n    before(async function() {\n      await gu.sendActions([\n        [\"AddVisibleColumn\", \"Table1\", \"Person\", { type: \"Ref:Person\" }],\n        [\"AddVisibleColumn\", \"Table1\", \"Employees\", { type: \"RefList:Person\" }],\n      ]);\n      await gu.openColumnPanel();\n\n      // Add color to the name column to make sure it is not added to the lookup menu.\n      await gu.openPage(\"Person\");\n      await gu.getCell(\"Name\", 1).click();\n      await gu.openCellColorPicker();\n      await gu.setFillColor(\"#FD8182\");\n      await driver.find(\".test-colors-save\").click();\n      await gu.waitForServer();\n\n      // And add conditional rule here. We will test if style rules are not copied over.\n      await gu.addInitialStyleRule();\n      await gu.openStyleRuleFormula(0);\n      await gu.sendKeys(\"True\");\n      await gu.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n\n      await gu.openCellColorPicker(0);\n      await gu.setFillColor(\"#FD8182\");\n      await driver.find(\".test-colors-save\").click();\n      await gu.waitForServer();\n\n      await gu.openPage(\"Table1\");\n    });\n\n    it(\"should show only 2 reference columns\", async function() {\n      await clickAddColumn();\n      await gu.waitToPass(async () => {\n        const labels =  await driver.findAll(\".test-new-columns-menu-lookup\", el => el.getText());\n        assert.deepEqual(\n          labels,\n          [\"Person [Person]\", \"Person [Employees]\"],\n        );\n      });\n      await closeAddColumnMenu();\n    });\n\n    it(\"should suggest to add every column from a reference\", async function() {\n      await clickAddColumn();\n      await driver.findWait(\".test-new-columns-menu-lookup-Person\", STANDARD_WAITING_TIME).click();\n      await gu.waitToPass(async () => {\n        const allColumns = await driver.findAll(\".test-new-columns-menu-lookup-column\", el => el.getText());\n        assert.deepEqual(allColumns, COLUMN_LABELS);\n      });\n      await closeAddColumnMenu();\n    });\n\n    // Now add each column and make sure it is added with a proper name.\n    for (const column of COLUMN_LABELS) {\n      it(`should insert ${column} with a proper name and type from a Ref column`, async function() {\n        const revert = await gu.begin();\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-lookup-Person\", STANDARD_WAITING_TIME).click();\n        await driver.findContentWait(`.test-new-columns-menu-lookup-column`, column, STANDARD_WAITING_TIME).click();\n        await gu.waitForServer();\n\n        const columns = await gu.getColumnNames();\n        assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"Person\", \"Employees\", `Person_${column}`]);\n\n        // This should be a formula column.\n        assert.equal(await gu.columnBehavior(), \"Formula column\");\n\n        // And the formula should be correct.\n        await driver.find(\".formula_field_sidepane\").click();\n        assert.equal(await gu.getFormulaText(), `$Person.${column.replace(\" \", \"_\")}`);\n        await gu.sendKeys(Key.ESCAPE);\n\n        switch (column) {\n          case \"Name\":\n            // This should be a text column.\n            assert.equal(await gu.getType(), \"Text\");\n            // We should have color but no rules.\n            await gu.openCellColorPicker();\n            assert.equal(await driver.find(\".test-fill-hex\").value(), \"#FD8182\");\n            await driver.find(\".test-colors-cancel\").click();\n            assert.equal(0, await gu.styleRulesCount());\n            break;\n          case \"Age\":\n            // This should be a numeric column.\n            assert.equal(await gu.getType(), \"Numeric\");\n            break;\n          case \"Hobby\": {\n            // This should be a choice column.\n            assert.equal(await gu.getType(), \"Choice List\");\n            // And the choices should be correct.\n            const labels = await driver.findAll(\".test-choice-list-entry-label\", el => el.getText());\n            assert.deepEqual(labels, [\"Books\", \"Cars\"]);\n            break;\n          }\n          case \"Employee\": {\n            // This should be a choice column.\n            assert.equal(await gu.getType(), \"Choice\");\n            // And the choices should be correct.\n            const labels = await driver.findAll(\".test-choice-list-entry-label\", el => el.getText());\n            assert.deepEqual(labels, [\"Y\", \"N\"]);\n            break;\n          }\n          case \"Birthday date\":\n            // This should be a date column.\n            assert.equal(await gu.getType(), \"Date\");\n            break;\n          case \"Member\":\n            // This should be a boolean column.\n            assert.equal(await gu.getType(), \"Toggle\");\n            break;\n          case \"SeenAt\":\n            // This should be a datetime column.\n            assert.equal(await gu.getType(), \"DateTime\");\n            assert.equal(await driver.find(\".test-tz-autocomplete input\").value(), \"UTC\");\n            break;\n          case \"Photo\":\n            // This should be an attachment column.\n            assert.equal(await gu.getType(), \"Attachment\");\n            break;\n          case \"Fun\":\n            // This should be an any column.\n            assert.equal(await gu.getType(), \"Any\");\n            break;\n          case \"Parent\":\n            // This should be a ref column.\n            assert.equal(await gu.getType(), \"Reference\");\n            // With a proper table.\n            assert.equal(await gu.getRefTable(), \"Person\");\n            // And a proper column.\n            assert.equal(await gu.getRefShowColumn(), \"Row ID\");\n            break;\n          case \"Children\":\n            // This should be a ref list column.\n            assert.equal(await gu.getType(), \"Reference List\");\n            // With a proper table.\n            assert.equal(await gu.getRefTable(), \"Person\");\n            // And a proper column.\n            assert.equal(await gu.getRefShowColumn(), \"Row ID\");\n            break;\n        }\n\n        await revert();\n      });\n    }\n\n    it(\"should suggest aggregations for RefList column\", async function() {\n      await clickAddColumn();\n      await driver.findWait(\".test-new-columns-menu-lookup-Employees\", STANDARD_WAITING_TIME).click();\n      // Wait for the menu to appear.\n      await driver.findWait(\".test-new-columns-menu-lookup-column\", STANDARD_WAITING_TIME);\n      // First check items (so columns we can add which don't have menu)\n      const items = await driver.findAll(\".test-new-columns-menu-lookup-column\", el => el.getText());\n      assert.deepEqual(items, [\n        \"Name\\nlist\",\n        \"Hobby\\nlist\",\n        \"Employee\\nlist\",\n        \"Photo\\nlist\",\n        \"Fun\\nlist\",\n        \"Parent\\nlist\",\n        \"Children\\nlist\",\n      ]);\n\n      const menus = await driver.findAll(\".test-new-columns-menu-lookup-submenu\", el => el.getText());\n      assert.deepEqual(menus, [\n        \"Age\\nsum\",\n        \"Birthday date\\nlist\",\n        \"Member\\ncount\",\n        \"SeenAt\\nlist\",\n      ]);\n\n      // Make sure that clicking on a column adds it with a default aggregation.\n      await driver.find(\".test-new-columns-menu-lookup-column-Name\").click();\n      await gu.waitForServer();\n      const columns = await gu.getColumnNames();\n      assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"Person\", \"Employees\", \"Employees_Name\"]);\n      await checkTypeAndFormula(\"Any\", `$Employees.Name`);\n\n      await gu.undo();\n    });\n\n    // Now test each aggregation.\n    for (const column of [\"Age\", \"Member\", \"Birthday date\", \"SeenAt\"]) {\n      it(`should insert ${column} with a proper name and type from a RefList column`, async function() {\n        const colId = column.replace(\" \", \"_\");\n\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-lookup-Employees\", STANDARD_WAITING_TIME).click();\n        await driver.findWait(`.test-new-columns-menu-lookup-submenu-${colId}`, STANDARD_WAITING_TIME).mouseMove();\n\n        // Wait for the menu to show up.\n        await driver.findWait(\".test-new-columns-menu-lookup-submenu-function\", STANDARD_WAITING_TIME);\n\n        // Make sure the list of function is accurate.\n        const suggestedFunctions =\n          await driver.findAll(\".test-new-columns-menu-lookup-submenu-function\", el => el.getText());\n\n        switch (column) {\n          case \"Age\":\n            assert.deepEqual(suggestedFunctions, [\"sum\", \"average\", \"min\", \"max\"]);\n            break;\n          case \"Birthday date\":\n            assert.deepEqual(suggestedFunctions, [\"list\", \"min\", \"max\"]);\n            break;\n          case \"Member\":\n            assert.deepEqual(suggestedFunctions, [\"count\", \"percent\"]);\n            break;\n          case \"SeenAt\":\n            assert.deepEqual(suggestedFunctions, [\"list\", \"min\", \"max\"]);\n            break;\n        }\n\n        // Now pick the default function.\n        await driver.findWait(`.test-new-columns-menu-lookup-submenu-${colId}`, STANDARD_WAITING_TIME).click();\n        await gu.waitForServer();\n\n        const columns = await gu.getColumnNames();\n        assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"Person\", \"Employees\", `Employees_${column}`]);\n\n        // This should be a formula column.\n        assert.equal(await gu.columnBehavior(), \"Formula column\");\n\n        // And the formula should be correct.\n        switch (column) {\n          case \"Age\":\n            await checkTypeAndFormula(\"Numeric\", `SUM($Employees.${colId})`);\n\n            // For this column test other aggregations as well.\n            await gu.undo();\n            await addRefListLookup(\"Employees\", column, \"average\");\n            await checkTypeAndFormula(\"Numeric\", AVERAGE(\"$Employees\", colId));\n\n            await gu.undo();\n            await addRefListLookup(\"Employees\", column, \"min\");\n            await checkTypeAndFormula(\"Numeric\", MIN(\"$Employees\", colId));\n\n            await gu.undo();\n            await addRefListLookup(\"Employees\", column, \"max\");\n            await checkTypeAndFormula(\"Numeric\", MAX(\"$Employees\", colId));\n\n            break;\n          case \"Member\":\n            await checkTypeAndFormula(\"Integer\", `SUM($Employees.${colId})`);\n            // Here we also test that the formula is correct for percent.\n            await gu.undo();\n            await addRefListLookup(\"Employees\", column, \"percent\");\n            await checkTypeAndFormula(\"Numeric\", PERCENT(\"$Employees\", colId));\n            assert.isTrue(\n              await driver.findContent(\".test-numeric-mode .test-select-button\", /%/).matches(\"[class*=-selected]\"));\n            break;\n          case \"SeenAt\":\n            await checkTypeAndFormula(\"Any\", `$Employees.${colId}`);\n            await gu.undo();\n            await addRefListLookup(\"Employees\", column, \"min\");\n            await checkTypeAndFormula(\"DateTime\", MIN(\"$Employees\", colId));\n            assert.equal(await driver.find(\".test-tz-autocomplete input\").value(), \"UTC\");\n\n            await gu.undo();\n            await addRefListLookup(\"Employees\", column, \"max\");\n            await checkTypeAndFormula(\"DateTime\", MAX(\"$Employees\", colId));\n            assert.equal(await driver.find(\".test-tz-autocomplete input\").value(), \"UTC\");\n            break;\n          default:\n            await checkTypeAndFormula(\"Any\", `$Employees.${colId}`);\n            break;\n        }\n\n        await gu.undo();\n      });\n    }\n  });\n\n  describe(\"reverse lookups\", function() {\n    revertThis();\n\n    before(async function() {\n      // Reference the Person table once more\n      await gu.sendActions([\n        [\"AddVisibleColumn\", \"Person\", \"Item\", { type: \"Ref:Table1\" }],\n        [\"AddVisibleColumn\", \"Person\", \"Items\", { type: \"RefList:Table1\" }],\n      ]);\n    });\n\n    it(\"should show reverse lookups in the menu\", async function() {\n      await clickAddColumn();\n      // Wait for any menu to show up.\n      await driver.findWait(\".test-new-columns-menu-revlookup\", STANDARD_WAITING_TIME);\n      // We should see two rev lookups.\n      assert.deepEqual(await driver.findAll(\".test-new-columns-menu-revlookup\", el => el.getText()), [\n        \"Person [← Item]\",\n        \"Person [← Items]\",\n      ]);\n    });\n\n    it(\"should show same list from Ref and RefList\", async function() {\n      await driver.findContent(\".test-new-columns-menu-revlookup\", \"Person [← Item]\").mouseMove();\n      // Wait for any menu to show up.\n      await driver.findWait(\".test-new-columns-menu-revlookup-column\", STANDARD_WAITING_TIME);\n\n      const columns = await driver.findAll(\".test-new-columns-menu-revlookup-column\", el => el.getText());\n      const submenus = await driver.findAll(\".test-new-columns-menu-revlookup-submenu\", el => el.getText());\n\n      // Now open the other submenu and make sure list is the same.\n      await driver.findContent(\".test-new-columns-menu-revlookup\", \"Person [← Items]\").mouseMove();\n      // Wait for any menu to show up.\n      await driver.findWait(\".test-new-columns-menu-revlookup-column\", STANDARD_WAITING_TIME);\n\n      const columns2 = await driver.findAll(\".test-new-columns-menu-revlookup-column\", el => el.getText());\n      const submenus2 = await driver.findAll(\".test-new-columns-menu-revlookup-submenu\", el => el.getText());\n\n      assert.deepEqual(columns, columns2);\n      assert.deepEqual(submenus, submenus2);\n\n      assert.deepEqual(columns, [\n        \"Name\\nlist\",\n        \"Hobby\\nlist\",\n        \"Employee\\nlist\",\n        \"Photo\\nlist\",\n        \"Fun\\nlist\",\n        \"Parent\\nlist\",\n        \"Children\\nlist\",\n        \"Item\\nlist\",\n        \"Items\\nlist\",\n      ]);\n      assert.deepEqual(submenus, [\n        \"Age\\nsum\",\n        \"Birthday date\\nlist\",\n        \"Member\\ncount\",\n        \"SeenAt\\nlist\",\n      ]);\n\n      // Make sure that clicking one of the columns adds it with a default aggregation.\n      await driver.findContent(\".test-new-columns-menu-revlookup-column\", \"Name\").click();\n      await gu.waitForServer();\n\n      const columns3 = await gu.getColumnNames();\n      assert.deepEqual(columns3, [\"A\", \"B\", \"C\", \"Person_Name\"]);\n      assert.equal(await gu.columnBehavior(), \"Formula column\");\n      assert.equal(await gu.getType(), \"Any\");\n      await driver.find(\".formula_field_sidepane\").click();\n      assert.equal(await gu.getFormulaText(), `Person.lookupRecords(Items=CONTAINS($id)).Name`);\n      await gu.sendKeys(Key.ESCAPE);\n      await gu.undo();\n    });\n\n    describe(\"reverse lookups from Ref column\", function() {\n      for (const column of [\"Age\", \"Member\", \"Birthday date\", \"SeenAt\"]) {\n        it(`should properly add reverse lookup for ${column}`, async function() {\n          await clickAddColumn();\n          await driver.findContentWait(\".test-new-columns-menu-revlookup\",\n            \"Person [← Item]\",\n            STANDARD_WAITING_TIME,\n          ).mouseMove();\n\n          // This is submenu so expand it.\n          await driver.findContentWait(\".test-new-columns-menu-revlookup-submenu\",\n            new RegExp(\"^\" + column),\n            STANDARD_WAITING_TIME * 3,\n          ).mouseMove();\n\n          // Wait for any function to appear.\n          await driver.findWait(\".test-new-columns-menu-revlookup-column-function\",\n            STANDARD_WAITING_TIME);\n\n          // Make sure we see proper list.\n          const functions = await driver.findAll(\".test-new-columns-menu-revlookup-column-function\",\n            el => el.getText());\n          switch (column) {\n            case \"Age\":\n              assert.deepEqual(functions, [\"sum\", \"average\", \"min\", \"max\"]);\n              break;\n            case \"Member\":\n              assert.deepEqual(functions, [\"count\", \"percent\"]);\n              break;\n            case \"Birthday date\":\n            case \"SeenAt\":\n              assert.deepEqual(functions, [\"list\", \"min\", \"max\"]);\n              break;\n          }\n\n          // Now add each function and make sure it is added with a proper name.\n          await gu.sendKeys(Key.ESCAPE);\n          switch (column) {\n            case \"Age\":\n              await addRevLookup(\"sum\");\n              await checkTypeAndFormula(\"Numeric\", `SUM(Person.lookupRecords(Item=$id).Age)`);\n\n              assert.deepEqual(await gu.getColumnNames(),\n                [\"A\", \"B\", \"C\", \"Person_Age\"]);\n\n              await gu.undo();\n              await addRevLookup(\"average\");\n              await checkTypeAndFormula(\"Numeric\", AVERAGE(`Person.lookupRecords(Item=$id)`, \"Age\"));\n\n              await gu.undo();\n              await addRevLookup(\"min\");\n              await checkTypeAndFormula(\"Numeric\", MIN(`Person.lookupRecords(Item=$id)`, \"Age\"));\n\n              await gu.undo();\n              await addRevLookup(\"max\");\n              await checkTypeAndFormula(\"Numeric\", MAX(`Person.lookupRecords(Item=$id)`, \"Age\"));\n              break;\n            case \"Member\":\n              await addRevLookup(\"count\");\n              await checkTypeAndFormula(\"Integer\", `SUM(Person.lookupRecords(Item=$id).Member)`);\n\n              await gu.undo();\n              await addRevLookup(\"percent\");\n              await checkTypeAndFormula(\"Numeric\", PERCENT(`Person.lookupRecords(Item=$id)`, column));\n              break;\n            case \"Birthday date\":\n              await addRevLookup(\"list\");\n              await checkTypeAndFormula(\"Any\", `Person.lookupRecords(Item=$id).Birthday_date`);\n\n              await gu.undo();\n              await addRevLookup(\"min\");\n              await checkTypeAndFormula(\"Date\", MIN(\"Person.lookupRecords(Item=$id)\", \"Birthday_date\"));\n\n              await gu.undo();\n              await addRevLookup(\"max\");\n              await checkTypeAndFormula(\"Date\", MAX(\"Person.lookupRecords(Item=$id)\", \"Birthday_date\"));\n\n              assert.deepEqual(await gu.getColumnNames(),\n                [\"A\", \"B\", \"C\", \"Person_Birthday date\"]);\n\n              break;\n            case \"SeenAt\":\n              await addRevLookup(\"max\");\n              await checkTypeAndFormula(\"DateTime\", MAX(\"Person.lookupRecords(Item=$id)\", \"SeenAt\"));\n              // Here check the timezone.\n              assert.equal(await driver.find(\".test-tz-autocomplete input\").value(), \"UTC\");\n              break;\n          }\n\n          await gu.undo();\n\n          async function addRevLookup(func: string) {\n            await clickAddColumn();\n            await driver.findContentWait(\n              \".test-new-columns-menu-revlookup\",\n              \"Person [← Item]\",\n              STANDARD_WAITING_TIME,\n            ).mouseMove();\n            await driver.findContentWait(\n              \".test-new-columns-menu-revlookup-submenu\",\n              new RegExp(\"^\" + column),\n              STANDARD_WAITING_TIME,\n            ).mouseMove();\n            await driver.findContentWait(\n              \".test-new-columns-menu-revlookup-column-function\",\n              func,\n              STANDARD_WAITING_TIME,\n            ).click();\n            await gu.waitForServer();\n          }\n        });\n      }\n    });\n\n    describe(\"reverse lookups from RefList column\", function() {\n      for (const column of [\"Age\", \"Member\", \"Birthday date\", \"SeenAt\"]) {\n        it(`should properly add reverse lookup for ${column}`, async function() {\n          await clickAddColumn();\n          await driver.findContentWait(\n            \".test-new-columns-menu-revlookup\",\n            \"Person [← Items]\",\n            STANDARD_WAITING_TIME,\n          ).mouseMove();\n\n          // This is submenu so expand it.\n          await driver.findContentWait(\n            \".test-new-columns-menu-revlookup-submenu\",\n            new RegExp(\"^\" + column),\n            STANDARD_WAITING_TIME,\n          ).mouseMove();\n\n          // Wait for any function to appear.\n          await driver.findWait(\n            \".test-new-columns-menu-revlookup-column-function\",\n            STANDARD_WAITING_TIME,\n          );\n\n          // Make sure we see proper list.\n          const functions = await driver.findAll(\".test-new-columns-menu-revlookup-column-function\",\n            el => el.getText());\n          switch (column) {\n            case \"Age\":\n              assert.deepEqual(functions, [\"sum\", \"average\", \"min\", \"max\"]);\n              break;\n            case \"Member\":\n              assert.deepEqual(functions, [\"count\", \"percent\"]);\n              break;\n            case \"Birthday date\":\n            case \"SeenAt\":\n              assert.deepEqual(functions, [\"list\", \"min\", \"max\"]);\n              break;\n          }\n\n          // Now add each function and make sure it is added with a proper name.\n          await gu.sendKeys(Key.ESCAPE);\n          switch (column) {\n            case \"Age\":\n              await addRevLookup(\"sum\");\n              await checkTypeAndFormula(\"Numeric\", `SUM(Person.lookupRecords(Items=CONTAINS($id)).Age)`);\n\n              await gu.undo();\n              await addRevLookup(\"average\");\n              await checkTypeAndFormula(\"Numeric\", AVERAGE(`Person.lookupRecords(Items=CONTAINS($id))`, \"Age\"));\n\n              await gu.undo();\n              await addRevLookup(\"min\");\n              await checkTypeAndFormula(\"Numeric\", MIN(`Person.lookupRecords(Items=CONTAINS($id))`, \"Age\"));\n\n              await gu.undo();\n              await addRevLookup(\"max\");\n              await checkTypeAndFormula(\"Numeric\", MAX(`Person.lookupRecords(Items=CONTAINS($id))`, \"Age\"));\n              break;\n            case \"Member\":\n              await addRevLookup(\"count\");\n              await checkTypeAndFormula(\"Integer\", `SUM(Person.lookupRecords(Items=CONTAINS($id)).Member)`);\n\n              await gu.undo();\n              await addRevLookup(\"percent\");\n              await checkTypeAndFormula(\"Numeric\", PERCENT(`Person.lookupRecords(Items=CONTAINS($id))`, column));\n              break;\n            case \"Birthday date\":\n              await addRevLookup(\"list\");\n              await checkTypeAndFormula(\"Any\", `Person.lookupRecords(Items=CONTAINS($id)).Birthday_date`);\n\n              await gu.undo();\n              await addRevLookup(\"min\");\n              await checkTypeAndFormula(\"Date\", MIN(\"Person.lookupRecords(Items=CONTAINS($id))\", \"Birthday_date\"));\n\n              await gu.undo();\n              await addRevLookup(\"max\");\n              await checkTypeAndFormula(\"Date\", MAX(\"Person.lookupRecords(Items=CONTAINS($id))\", \"Birthday_date\"));\n              break;\n            case \"SeenAt\":\n              await addRevLookup(\"max\");\n              await checkTypeAndFormula(\"DateTime\", MAX(\"Person.lookupRecords(Items=CONTAINS($id))\", \"SeenAt\"));\n              // Here check the timezone.\n              assert.equal(await driver.find(\".test-tz-autocomplete input\").value(), \"UTC\");\n              break;\n          }\n\n          await gu.undo();\n\n          async function addRevLookup(func: string) {\n            await clickAddColumn();\n            await driver.findContentWait(\n              \".test-new-columns-menu-revlookup\",\n              \"Person [← Items]\",\n              STANDARD_WAITING_TIME,\n            ).mouseMove();\n            await driver.findContentWait(\n              \".test-new-columns-menu-revlookup-submenu\",\n              new RegExp(\"^\" + column),\n              STANDARD_WAITING_TIME,\n            ).mouseMove();\n            await driver.findContentWait(\n              \".test-new-columns-menu-revlookup-column-function\",\n              func,\n              STANDARD_WAITING_TIME,\n            ).click();\n            await gu.waitForServer();\n          }\n        });\n      }\n    });\n  });\n\n  describe(\"shortcuts\", function() {\n    describe(\"Timestamp\", function() {\n      revertEach();\n\n      it(\"created at - should create new column with date triggered on create\", async function() {\n        await gu.openColumnPanel();\n\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-timestamp\", STANDARD_WAITING_TIME).mouseMove();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-timestamp-new\", STANDARD_WAITING_TIME).click();\n        await gu.waitForServer();\n\n        // Make sure we have Created At column at the end.\n        const columns = await gu.getColumnNames();\n        assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"Created at\"]);\n\n        // Make sure this is the column that is selected.\n        assert.equal(await driver.find(\".test-field-label\").value(), \"Created at\");\n        assert.equal(await driver.find(\".test-field-col-id\").value(), \"$Created_at\");\n\n        // Check behavior - this is trigger formula\n        assert.equal(await gu.columnBehavior(), \"Data column\");\n        assert.isTrue(await driver.findContent(\"div\", \"TRIGGER FORMULA\").isDisplayed());\n\n        // It applies to new records only.\n        assert.equal(await driver.find(\".test-field-formula-apply-to-new\").getAttribute(\"checked\"), \"true\");\n        assert.equal(await driver.find(\".test-field-formula-apply-on-changes\").getAttribute(\"checked\"), null);\n\n        // Make sure type and formula are correct.\n        await checkTypeAndFormula(\"DateTime\", \"NOW()\");\n        assert.isNotEmpty(await driver.find(\".test-tz-autocomplete input\").value());\n      });\n\n      it(\"modified at - should create new column with date triggered on change\", async function() {\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-timestamp\", STANDARD_WAITING_TIME).mouseMove();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-timestamp-change\", STANDARD_WAITING_TIME).click();\n        await gu.waitForServer();\n\n        // Make sure we have this column at the end.\n        const columns = await gu.getColumnNames();\n        assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"Last updated at\"]);\n\n        // Make sure this is the column that is selected.\n        assert.equal(await driver.find(\".test-field-label\").value(), \"Last updated at\");\n        assert.equal(await driver.find(\".test-field-col-id\").value(), \"$Last_updated_at\");\n\n        // Check behavior - this is trigger formula\n        assert.equal(await gu.columnBehavior(), \"Data column\");\n        assert.isTrue(await driver.findContent(\"div\", \"TRIGGER FORMULA\").isDisplayed());\n\n        // It applies to new records only and if anything changes.\n        assert.equal(await driver.find(\".test-field-formula-apply-to-new\").getAttribute(\"checked\"), \"true\");\n        assert.equal(await driver.find(\".test-field-formula-apply-on-changes\").getAttribute(\"checked\"), \"true\");\n        assert.equal(await driver.find(\".test-field-triggers-select\").getText(), \"Any field\");\n\n        // Make sure type and formula are correct.\n        await checkTypeAndFormula(\"DateTime\", \"NOW()\");\n        assert.isNotEmpty(await driver.find(\".test-tz-autocomplete input\").value());\n      });\n    });\n\n    describe(\"Authorship\", function() {\n      revertEach();\n\n      it(\"created by - should create new column with author name triggered on create\", async function() {\n        await gu.openColumnPanel();\n\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-author\", STANDARD_WAITING_TIME).mouseMove();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-author-new\", STANDARD_WAITING_TIME).click();\n        await gu.waitForServer();\n\n        // Make sure we have this column at the end.\n        const columns = await gu.getColumnNames();\n        assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"Created by\"]);\n\n        // Make sure this is the column that is selected.\n        assert.equal(await driver.find(\".test-field-label\").value(), \"Created by\");\n        assert.equal(await driver.find(\".test-field-col-id\").value(), \"$Created_by\");\n\n        // Check behavior - this is trigger formula\n        assert.equal(await gu.columnBehavior(), \"Data column\");\n        assert.isTrue(await driver.findContent(\"div\", \"TRIGGER FORMULA\").isDisplayed());\n\n        // It applies to new records only.\n        assert.equal(await driver.find(\".test-field-formula-apply-to-new\").getAttribute(\"checked\"), \"true\");\n        assert.equal(await driver.find(\".test-field-formula-apply-on-changes\").getAttribute(\"checked\"), null);\n\n        // Make sure type and formula are correct.\n        await checkTypeAndFormula(\"Text\", \"user.Name\");\n      });\n\n      it(\"modified by - should create new column with author name triggered on change\", async function() {\n        await gu.openColumnPanel();\n\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-author\", STANDARD_WAITING_TIME).mouseMove();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-author-change\", STANDARD_WAITING_TIME).click();\n        await gu.waitForServer();\n\n        // Make sure we have this column at the end.\n        const columns = await gu.getColumnNames();\n        assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"Last updated by\"]);\n\n        // Make sure this is the column that is selected.\n        assert.equal(await driver.find(\".test-field-label\").value(), \"Last updated by\");\n        assert.equal(await driver.find(\".test-field-col-id\").value(), \"$Last_updated_by\");\n\n        // Check behavior - this is trigger formula\n        assert.equal(await gu.columnBehavior(), \"Data column\");\n        assert.isTrue(await driver.findContent(\"div\", \"TRIGGER FORMULA\").isDisplayed());\n\n        // It applies to new records only and if anything changes.\n        assert.equal(await driver.find(\".test-field-formula-apply-to-new\").getAttribute(\"checked\"), \"true\");\n        assert.equal(await driver.find(\".test-field-formula-apply-on-changes\").getAttribute(\"checked\"), \"true\");\n        assert.equal(await driver.find(\".test-field-triggers-select\").getText(), \"Any field\");\n\n        // Make sure type and formula are correct.\n        await checkTypeAndFormula(\"Text\", \"user.Name\");\n      });\n    });\n\n    describe(\"Detect Duplicates in...\", function() {\n      it(\"should show columns in a searchable sub-menu\", async function() {\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-duplicates\", STANDARD_WAITING_TIME).mouseMove();\n        await gu.waitToPass(async () => {\n          assert.deepEqual(\n            await driver.findAll(\".test-searchable-menu li\", el => el.getText()),\n            [\"A\", \"B\", \"C\"],\n          );\n        }, 500);\n        await driver.find(\".test-searchable-menu-input\").click();\n        await gu.sendKeys(\"A\");\n        await gu.waitToPass(async () => {\n          assert.deepEqual(\n            await driver.findAll(\".test-searchable-menu li\", el => el.getText()),\n            [\"A\"],\n          );\n        }, STANDARD_WAITING_TIME);\n\n        await gu.sendKeys(\"BC\");\n        await gu.waitToPass(async () => {\n          assert.deepEqual(\n            await driver.findAll(\".test-searchable-menu li\", el => el.getText()),\n            [],\n          );\n        }, STANDARD_WAITING_TIME);\n\n        await gu.clearInput();\n        await gu.waitToPass(async () => {\n          assert.deepEqual(\n            await driver.findAll(\".test-searchable-menu li\", el => el.getText()),\n            [\"A\", \"B\", \"C\"],\n          );\n        }, STANDARD_WAITING_TIME);\n      });\n\n      it(\"should create new column that checks for duplicates in the specified column\", async function() {\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-duplicates\", STANDARD_WAITING_TIME).mouseMove();\n        await driver.findContentWait(\".test-searchable-menu li\", \"A\", 500).click();\n        await gu.waitForServer();\n        await gu.sendKeys(Key.ENTER);\n\n        // Just checking the formula looks plausible - correctness is best left to a python test.\n        assert.equal(\n          await driver.find(\".test-formula-editor\").getText(),\n          '$A != \"\" and $A is not None and len(Table1.lookupRecords(A=$A)) > 1',\n        );\n        await gu.sendKeys(Key.ESCAPE);\n        let columns = await gu.getColumnNames();\n        assert.deepEqual(columns, [\"A\", \"B\", \"C\", \"Duplicate in A\"]);\n        await gu.undo();\n\n        // Try it with list-based columns; the formula should look a little different.\n        for (const [label, type] of [[\"Choice\", \"Choice List\"], [\"Ref\", \"Reference List\"]]) {\n          await gu.addColumn(label, type);\n          await clickAddColumn();\n          await driver.findWait(\".test-new-columns-menu-shortcuts-duplicates\", STANDARD_WAITING_TIME).mouseMove();\n          await driver.findContentWait(\".test-searchable-menu li\", label, 500).click();\n          await gu.waitForServer();\n          await gu.sendKeys(Key.ENTER);\n          assert.equal(\n            await driver.find(\".test-formula-editor\").getText(),\n            `any([len(Table1.lookupRecords(${label}=CONTAINS(x))) > 1 for x in $${label}])`,\n          );\n          await gu.sendKeys(Key.ESCAPE);\n          columns = await gu.getColumnNames();\n          assert.deepEqual(columns, [\"A\", \"B\", \"C\", label, `Duplicate in ${label}`]);\n          await gu.undo(4);\n        }\n      });\n    });\n\n    describe(\"UUID\", function() {\n      it(\"should create new column that generates a UUID on new record\", async function() {\n        await gu.getCell(2, 1).click();\n        await gu.sendKeys(\"A\", Key.ENTER);\n        await gu.waitForServer();\n        await clickAddColumn();\n        await driver.findWait(\".test-new-columns-menu-shortcuts-uuid\", STANDARD_WAITING_TIME).click();\n        await gu.waitForServer();\n        const cells1 = await gu.getVisibleGridCells({ col: \"UUID\", rowNums: [1, 2] });\n        assert.match(cells1[0], /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);\n        assert.equal(cells1[1], \"\");\n        await gu.getCell(2, 2).click();\n        await gu.sendKeys(\"B\", Key.ENTER);\n        await gu.waitForServer();\n        const cells2 = await gu.getVisibleGridCells({ col: \"UUID\", rowNums: [1, 2, 3] });\n        assert.match(cells2[0], /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);\n        assert.match(cells2[1], /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);\n        assert.equal(cells2[2], \"\");\n        assert.equal(cells1[0], cells2[0]);\n        await gu.undo(3);\n      });\n    });\n  });\n\n  describe(\"bug fixes\", function() {\n    it(\"should not show hidden Ref columns\", async function() {\n      // Duplicate current tab and start transforming a column.\n      const mainTab = await gu.myTab();\n      const transformTab = await gu.duplicateTab();\n\n      // Start transforming a column.\n      await gu.sendActions([\n        [\"AddRecord\", \"Table1\", null, { A: 1, B: 2, C: 3 }],\n      ]);\n      await gu.getCell(\"A\", 1).click();\n      await gu.setType(\"Reference\", { apply: false });\n      await gu.waitForServer();\n      await gu.setRefTable(\"Person\");\n      await gu.waitForServer();\n\n      // Now we have two hidden columns present.\n      let columns = await api.getTable(docId, \"Table1\");\n      assert.includeMembers(Object.keys(columns), [\n        \"gristHelper_Converted\",\n        \"gristHelper_Transform\",\n      ]);\n\n      // Now on the main tab, make sure we don't see those references in lookup menu.\n      await mainTab.open();\n      await clickAddColumn();\n      await driver.findWait(\".test-new-columns-menu-lookups-none\", STANDARD_WAITING_TIME);\n\n      // Now test RefList columns.\n      await transformTab.open();\n      await driver.find(\".test-type-transform-cancel\").click();\n      await gu.waitForServer();\n      // Make sure hidden columns are removed.\n      columns = await api.getTable(docId, \"Table1\");\n      assert.notIncludeMembers(Object.keys(columns), [\n        \"gristHelper_Converted\",\n        \"gristHelper_Transform\",\n      ]);\n      await gu.setType(\"Reference List\", { apply: false });\n      await gu.setRefTable(\"Person\");\n      await gu.waitForServer();\n      columns = await api.getTable(docId, \"Table1\");\n      assert.includeMembers(Object.keys(columns), [\n        \"gristHelper_Converted\",\n        \"gristHelper_Transform\",\n      ]);\n\n      // Now on the main make sure we still don't see those references in lookup menu.\n      await mainTab.open();\n      await clickAddColumn();\n      await driver.findWait(\".test-new-columns-menu-lookups-none\", STANDARD_WAITING_TIME);\n\n      // Now test reverse lookups.\n      await gu.openPage(\"Person\");\n      await clickAddColumn();\n\n      // Wait for any menu to show up.\n      await driver.findWait(\".test-new-columns-menu-lookup\", STANDARD_WAITING_TIME);\n\n      // Now make sure we don't have helper columns\n      assert.isEmpty(await driver.findAll(\".test-new-columns-menu-revlookup\", e => e.getText()));\n\n      await gu.sendKeys(Key.ESCAPE);\n      await gu.scrollActiveView(-1000, 0);\n\n      await transformTab.open();\n      await driver.find(\".test-type-transform-cancel\").click();\n      await gu.waitForServer();\n      await transformTab.close();\n      await mainTab.open();\n      await gu.openPage(\"Table1\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/GridViewNewColumnMenuDateHelpers.ts",
    "content": "import {\n  checkTypeAndFormula,\n  clickAddColumn,\n  closeAddColumnMenu,\n  hasShortcuts,\n  isDisplayed,\n  revertEach,\n} from \"test/nbrowser/GridViewNewColumnMenuUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert } from \"chai\";\nimport { driver } from \"mocha-webdriver\";\n\ndescribe(\"GridViewNewColumnMenuDateHelpers\", function() {\n  const STANDARD_WAITING_TIME = 1000;\n  this.timeout(\"4m\");\n  const cleanup = setupTestSuite();\n  gu.bigScreen();\n  let session: gu.Session;\n\n  before(async function() {\n    session = await gu.session().login({ showTips: true });\n    await session.tempNewDoc(cleanup, \"ColumnMenu\");\n    await gu.dismissBehavioralPrompts();\n\n    // Add a table that will be used for lookups.\n    await gu.sendActions([\n      [\"AddRecord\", \"Table1\", null, {}],\n      [\"RemoveColumn\", \"Table1\", \"B\"],\n      [\"RemoveColumn\", \"Table1\", \"C\"],\n    ]);\n    await gu.openColumnPanel();\n  });\n\n  describe(\"menu\", function() {\n    afterEach(async function() {\n      // Leave all columns except A, B, C\n      const allColumns = await gu.getColumnNames();\n      const id = (x: string) => x.replace(/[^0-9a-zA-Z_]/g, \"_\");\n      await gu.sendActions([\n        ...allColumns.filter(colLabel => ![\"A\", \"B\", \"C\"].includes(colLabel))\n          .map(c => [\"RemoveColumn\", \"Table1\", id(c)] as any),\n      ]);\n    });\n\n    const testCases = [\n      // Quick Picks\n      {\n        type: \"Date\", menu: \"Quick Picks -> Year\", column: \"Integer\", columnName: \"EventDate Year\",\n        formula: \"YEAR($EventDate) if $EventDate else None\",\n        expected: \"2012\",\n      },\n      {\n        type: \"Date\", menu: \"Quick Picks -> Month\", column: \"Text\", columnName: \"EventDate Month\",\n        formula: '$EventDate.strftime(\"%Y-%m\") if $EventDate else None',\n        expected: \"2012-12\",\n      },\n      {\n        type: \"Date\", menu: \"Quick Picks -> Quarter\", column: \"Text\", columnName: \"EventDate Quarter\",\n        formula: '\"{}-Q{}\".format(YEAR($EventDate), (MONTH($EventDate) - 1) // 3 + 1) if $EventDate else None',\n        expected: \"2012-Q4\",\n      },\n      {\n        type: \"Date\", menu: \"Quick Picks -> Day of week\", column: \"Text\", columnName: \"EventDate Day of week\",\n        formula: '$EventDate.strftime(\"%A\") if $EventDate else None',\n        expected: \"Wednesday\",\n      },\n      // Calendar -> Year\n      {\n        type: \"Date\", menu: \"Calendar -> Year\", column: \"Integer\", columnName: \"EventDate Year\",\n        formula: \"YEAR($EventDate) if $EventDate else None\",\n        expected: \"2012\",\n      },\n      // Calendar -> Quarter variations\n      {\n        type: \"Date\", menu: \"Calendar -> Quarter -> Default\", column: \"Text\", columnName: \"EventDate Quarter\",\n        formula: '\"Q{}\".format((MONTH($EventDate) - 1) // 3 + 1) + \" \" + str(YEAR($EventDate)) if $EventDate else None',\n        expected: \"Q4 2012\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Quarter -> Sortable\", column: \"Text\", columnName: \"EventDate Quarter\",\n        formula: '\"{}-Q{}\".format(YEAR($EventDate), (MONTH($EventDate) - 1) // 3 + 1) if $EventDate else None',\n        expected: \"2012-Q4\",\n      },\n      // Calendar -> Month variations\n      {\n        type: \"Date\", menu: \"Calendar -> Month -> Full name with year\", column: \"Text\", columnName: \"EventDate Month\",\n        formula: '$EventDate.strftime(\"%B %Y\") if $EventDate else None',\n        expected: \"December 2012\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Month -> Sortable\", column: \"Text\", columnName: \"EventDate Month\",\n        formula: '$EventDate.strftime(\"%Y-%m\") if $EventDate else None',\n        expected: \"2012-12\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Month -> Short with year\", column: \"Text\", columnName: \"EventDate Month\",\n        formula: '$EventDate.strftime(\"%b %Y\") if $EventDate else None',\n        expected: \"Dec 2012\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Month -> Name only\", column: \"Text\", columnName: \"EventDate Month\",\n        formula: '$EventDate.strftime(\"%B\") if $EventDate else None',\n        expected: \"December\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Month -> Number only\", column: \"Integer\", columnName: \"EventDate Month\",\n        formula: \"MONTH($EventDate) if $EventDate else None\",\n        expected: \"12\",\n      },\n      // Calendar -> Week variations\n      {\n        type: \"Date\", menu: \"Calendar -> Week of year -> Default\", column: \"Text\", columnName: \"EventDate Week\",\n        formula: '\"Week {}\".format(WEEKNUM($EventDate)) if $EventDate else None',\n        expected: \"Week 50\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Week of year -> Sortable\", column: \"Text\", columnName: \"EventDate Week\",\n        formula: '\"{}-W{:02d}\".format(YEAR($EventDate), WEEKNUM($EventDate)) if $EventDate else None',\n        expected: \"2012-W50\",\n      },\n      // Calendar -> Day variations\n      {\n        type: \"Date\", menu: \"Calendar -> Day -> Day of month\", column: \"Integer\", columnName: \"EventDate Day of month\",\n        formula: \"DAY($EventDate) if $EventDate else None\",\n        expected: \"12\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Day -> Full date\", column: \"Date\", columnName: \"EventDate Full date\",\n        formula: \"DATE(YEAR($EventDate), MONTH($EventDate), DAY($EventDate)) if $EventDate else None\",\n        expected: \"2012-12-12\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Day -> Day of week (full)\", column: \"Text\",\n        columnName: \"EventDate Day of week\",\n        formula: '$EventDate.strftime(\"%A\") if $EventDate else None',\n        expected: \"Wednesday\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Day -> Day of week (abbrev)\", column: \"Text\",\n        columnName: \"EventDate Day of week\",\n        formula: '$EventDate.strftime(\"%a\") if $EventDate else None',\n        expected: \"Wed\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Day -> Day of week (numeric)\", column: \"Integer\",\n        columnName: \"EventDate Day of week\",\n        formula: \"WEEKDAY($EventDate, 2) if $EventDate else None\",\n        expected: \"3\",\n      },\n      {\n        type: \"Date\", menu: \"Calendar -> Day -> Is weekend?\", column: \"Toggle\", columnName: \"EventDate Is weekend?\",\n        formula: \"WEEKDAY($EventDate, 2) >= 6 if $EventDate else None\",\n        expected: \"\",\n      },\n      // Intervals -> Start of\n      {\n        type: \"Date\", menu: \"Intervals -> Start of -> Week\", column: \"Date\", columnName: \"EventDate Start of Week\",\n        formula: \"DATEADD($EventDate, days=-WEEKDAY($EventDate, 3)) if $EventDate else None\",\n        expected: \"2012-12-10\",\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> Start of -> Month\", column: \"Date\", columnName: \"EventDate Start of Month\",\n        formula: \"DATE(YEAR($EventDate), MONTH($EventDate), 1) if $EventDate else None\",\n        expected: \"2012-12-01\",\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> Start of -> Quarter\", column: \"Date\",\n        columnName: \"EventDate Start of Quarter\",\n        formula: \"DATE(YEAR($EventDate), ((MONTH($EventDate)-1)//3)*3 + 1, 1) if $EventDate else None\",\n        expected: \"2012-10-01\",\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> Start of -> Year\", column: \"Date\", columnName: \"EventDate Start of Year\",\n        formula: \"DATE(YEAR($EventDate), 1, 1) if $EventDate else None\",\n        expected: \"2012-01-01\",\n      },\n      // Intervals -> End of\n      {\n        type: \"Date\", menu: \"Intervals -> End of -> Week\", column: \"Date\", columnName: \"EventDate End of Week\",\n        formula: \"DATEADD($EventDate, days=7-WEEKDAY($EventDate, 3)-1) if $EventDate else None\",\n        expected: \"2012-12-16\",\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> End of -> Month\", column: \"Date\", columnName: \"EventDate End of Month\",\n        formula: \"EOMONTH($EventDate, 0) if $EventDate else None\",\n        expected: \"2012-12-31\",\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> End of -> Quarter\", column: \"Date\",\n        columnName: \"EventDate End of Quarter\",\n        formula: \"EOMONTH(DATE(YEAR($EventDate), ((MONTH($EventDate)-1)//3)*3 + 3, 1), 0) if $EventDate else None\",\n        expected: \"2012-12-31\",\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> End of -> Year\", column: \"Date\", columnName: \"EventDate End of Year\",\n        formula: \"DATE(YEAR($EventDate), 12, 31) if $EventDate else None\",\n        expected: \"2012-12-31\",\n      },\n      // Intervals -> Relative (no examples here, as this has TODAY, and tests can be flaky).\n      {\n        type: \"Date\", menu: \"Intervals -> Relative -> Days since\", column: \"Integer\",\n        columnName: \"EventDate Days since\",\n        formula: 'DATEDIF($EventDate, TODAY(), \"D\") if $EventDate else None',\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> Relative -> Days until\", column: \"Integer\",\n        columnName: \"EventDate Days until\",\n        formula: 'DATEDIF(TODAY(), $EventDate, \"D\") if $EventDate else None',\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> Relative -> Months since\", column: \"Integer\",\n        columnName: \"EventDate Months since\",\n        formula: 'DATEDIF($EventDate, TODAY(), \"M\") if $EventDate else None',\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> Relative -> Months until\", column: \"Integer\",\n        columnName: \"EventDate Months until\",\n        formula: 'DATEDIF(TODAY(), $EventDate, \"M\") if $EventDate else None',\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> Relative -> Years since\", column: \"Integer\",\n        columnName: \"EventDate Years since\",\n        formula: 'DATEDIF($EventDate, TODAY(), \"Y\") if $EventDate else None',\n      },\n      {\n        type: \"Date\", menu: \"Intervals -> Relative -> Years until\", column: \"Integer\",\n        columnName: \"EventDate Years until\",\n        formula: 'DATEDIF(TODAY(), $EventDate, \"Y\") if $EventDate else None',\n      },\n      // Time section (only for DateTime columns)\n      {\n        type: \"DateTime:UTC\", menu: \"Time -> Hour -> 24-hour format\", column: \"Text\",\n        columnName: \"EventDate Hour\",\n        formula: '$EventDate.strftime(\"%H\") if $EventDate else None',\n        expected: \"00\",\n      },\n      {\n        type: \"DateTime:UTC\", menu: \"Time -> Hour -> 12-hour format\", column: \"Text\",\n        columnName: \"EventDate Hour\",\n        formula: '$EventDate.strftime(\"%I %p\").lstrip(\"0\") if $EventDate else None',\n        expected: \"12 AM\",\n      },\n      {\n        type: \"DateTime:UTC\", menu: \"Time -> Hour -> Time bucket\", column: \"Text\", columnName: \"EventDate Hour\",\n        formula: \"if not $EventDate:\\n  return None\\n\" +\n          \"hour = HOUR($EventDate)\\n\" +\n          'if hour < 12:\\n  return \"Morning\"\\n' +\n          'if hour < 18:\\n  return \"Afternoon\"\\n' +\n          'return \"Evening\"',\n        expected: \"Morning\",\n      },\n      {\n        type: \"DateTime:UTC\", menu: \"Time -> Minute\", column: \"Integer\", columnName: \"EventDate Minute\",\n        formula: \"MINUTE($EventDate) if $EventDate else None\",\n        expected: \"0\",\n      },\n      {\n        type: \"DateTime:UTC\", menu: \"Time -> AM/PM\", column: \"Text\", columnName: \"EventDate AM/PM\",\n        formula: '$EventDate.strftime(\"%p\") if $EventDate else None',\n        expected: \"AM\",\n      },\n    ];\n\n    for (const testCase of testCases) {\n      it(`has working ${testCase.menu} menu`, async function() {\n        // Add test column with proper type.\n        await gu.sendActions([\n          [\"AddVisibleColumn\", \"Table1\", \"EventDate\", {\n            type: testCase.type,\n            isFormula: true,\n            formula: \"DATE(2012, 12, 12)\",\n          }],\n          [\"ModifyColumn\", \"Table1\", \"EventDate\", {\n            isFormula: false,\n          }],\n        ]);\n\n        // Click add column.\n        await clickAddColumn();\n        // Open date helpers submenu.\n        await driver.findWait(\".test-new-columns-menu-date-helpers\", STANDARD_WAITING_TIME).click();\n        // Select our new column.\n        await driver.findWait(\".test-date-helpers-column-EventDate\", STANDARD_WAITING_TIME).click();\n\n        // Click the menu item.\n        await selectDatePart(testCase.menu, testCase.expected);\n        await gu.waitForServer();\n\n        // Check column was created with correct name\n        const columns = await gu.getColumnNames();\n        assert.include(columns, testCase.columnName);\n\n        // Check that value is ok, same as example.\n        const cellText = await gu.getCell(testCase.columnName, 1).getText();\n        if (testCase.expected) {\n          assert.equal(cellText, testCase.expected);\n        }\n\n        // Now clear the value in the cell and make sure that our helper column is also empty.\n        await gu.sendActions([\n          [\"UpdateRecord\", \"Table1\", 1, { EventDate: null }],\n        ]);\n        const cellTextAfterClear = await gu.getCell(testCase.columnName, 1).getText();\n        assert.equal(cellTextAfterClear, \"\", \"Helper column should be empty after clearing source date\");\n\n        // Check type and formula\n        await gu.getCell(testCase.columnName, 1).click();\n        await gu.waitToPass(async () => {\n          // Make sure that the create panel can keep up.\n          assert.equal(await driver.find(\".test-field-label\").value(), testCase.columnName);\n        });\n        await checkTypeAndFormula(testCase.column, testCase.formula);\n      });\n    }\n  });\n\n  describe(\"general\", function() {\n    revertEach();\n\n    it(\"should not show date helpers menu when no Date/DateTime columns exist\", async function() {\n      await gu.openColumnPanel();\n      await clickAddColumn();\n      await hasShortcuts();\n      // Date helpers menu should not be present when no date columns exist\n      assert.isFalse(await driver.find(\".test-new-columns-menu-date-helpers\").isPresent());\n      await closeAddColumnMenu();\n    });\n\n    it(\"should show date helpers menu when Date columns exist\", async function() {\n      // Add a Date column first\n      await gu.sendActions([\n        [\"AddVisibleColumn\", \"Table1\", \"EventDate\", { type: \"Date\" }],\n      ]);\n\n      await clickAddColumn();\n      await hasShortcuts();\n      // Date helpers menu should be present\n      await isDisplayed(\".test-new-columns-menu-date-helpers\", \"date helpers section is not present\");\n\n      // Check the menu opens and shows the date column\n      await driver.findWait(\".test-new-columns-menu-date-helpers\", STANDARD_WAITING_TIME).click();\n      await driver.findWait(\".test-date-helpers-column-EventDate\", STANDARD_WAITING_TIME);\n\n      await closeAddColumnMenu();\n    });\n\n    it(\"should show date helpers menu when DateTime columns exist\", async function() {\n      // Add a DateTime column\n      await gu.sendActions([\n        [\"AddVisibleColumn\", \"Table1\", \"Timestamp\", { type: \"DateTime:UTC\" }],\n      ]);\n\n      await clickAddColumn();\n      await driver.findWait(\".test-new-columns-menu-date-helpers\", STANDARD_WAITING_TIME).click();\n      await driver.findWait(\".test-date-helpers-column-Timestamp\", STANDARD_WAITING_TIME);\n\n      await closeAddColumnMenu();\n    });\n\n    it(\"should work with multiple date columns\", async function() {\n      await gu.sendActions([\n        [\"AddVisibleColumn\", \"Table1\", \"StartDate\", { type: \"Date\" }],\n        [\"AddVisibleColumn\", \"Table1\", \"EndDate\", { type: \"DateTime:UTC\" }],\n      ]);\n\n      await clickAddColumn();\n      await driver.findWait(\".test-new-columns-menu-date-helpers\", STANDARD_WAITING_TIME).click();\n\n      // Should show both date columns\n      await driver.findWait(\".test-date-helpers-column-StartDate\", STANDARD_WAITING_TIME);\n      await driver.findWait(\".test-date-helpers-column-EndDate\", STANDARD_WAITING_TIME);\n\n      // Create year from StartDate\n      await driver.findWait(\".test-date-helpers-column-StartDate\", STANDARD_WAITING_TIME).click();\n      await selectDatePart(\"Quick Picks -> Year\");\n\n      const columns = await gu.getColumnNames();\n      assert.include(columns, \"StartDate Year\");\n\n      await closeAddColumnMenu();\n    });\n  });\n});\n\nasync function selectDatePart(text: string, example?: string) {\n  const parts = text.split(\"->\").map(s => s.trim());\n  const section = parts[0];\n  const firstLevel = parts[1];\n  const secondLevel = parts[2];\n\n  // Helper to create test ID (matches the implementation exactly)\n  const makeTestPart = (s: string) => s.toLowerCase().replace(/\\s+/g, \"-\").replace(/[^0-9a-z-]/g, \"\");\n\n  if (secondLevel) {\n    // Three levels: section -> submenu -> item\n    const submenuTestId = `date-helpers-item-${makeTestPart(section)}-${makeTestPart(firstLevel)}`;\n    const itemTestId = `${submenuTestId}-${makeTestPart(secondLevel)}`;\n\n    // First hover over the submenu to open it\n    await driver.findWait(`.test-${submenuTestId}`, 500).mouseMove();\n\n    // If we have example to check\n    if (example) {\n      const textContent = await driver.findWait(`.test-${itemTestId} .test-date-helpers-item-example`, 500).getText();\n      assert.equal(textContent, example, `Unexpected example for ${text}`);\n    }\n\n    // Then click the item\n    await driver.findWait(`.test-${itemTestId}`, 500).click();\n  } else {\n    // Two levels: section -> item (direct item, no submenu)\n    const itemTestId = `date-helpers-item-${makeTestPart(section)}-${makeTestPart(firstLevel)}`;\n    await driver.findWait(`.test-${itemTestId}`, 500).click();\n  }\n\n  await gu.waitForServer();\n}\n"
  },
  {
    "path": "test/nbrowser/GridViewNewColumnMenuUtils.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\n\nimport { assert } from \"chai\";\nimport { driver, Key } from \"mocha-webdriver\";\n\nexport const STANDARD_WAITING_TIME = 1000;\n\nexport async function clickAddColumn() {\n  const isMenuPresent = await driver.find(\".test-new-columns-menu\").isPresent();\n  if (!isMenuPresent) {\n    await driver.findWait(\".mod-add-column\", STANDARD_WAITING_TIME).click();\n  }\n  await driver.findWait(\".test-new-columns-menu\", STANDARD_WAITING_TIME);\n}\n\nexport async function isMenuPresent() {\n  return await driver.find(\".test-new-columns-menu\").isPresent();\n}\n\nexport async function closeAddColumnMenu() {\n  await driver.sendKeys(Key.ESCAPE);\n  await gu.waitToPass(async () => assert.isFalse(await isMenuPresent(), \"menu is still present\"));\n}\n\nexport async function hasAddNewColumMenu() {\n  await isDisplayed(\".test-new-columns-menu-add-new\", \"add new column menu is not present\");\n}\n\nexport async function isDisplayed(selector: string, message: string) {\n  assert.isTrue(await driver.findWait(selector, STANDARD_WAITING_TIME, message).isDisplayed(), message);\n}\n\nexport async function hasShortcuts() {\n  await isDisplayed(\".test-new-columns-menu-shortcuts\", \"shortcuts section is not present\");\n  await isDisplayed(\".test-new-columns-menu-shortcuts-timestamp\", \"timestamp shortcuts section is not present\");\n  await isDisplayed(\".test-new-columns-menu-shortcuts-author\", \"authorship shortcuts section is not present\");\n}\n\nexport async function hasLookupMenu(colId: string) {\n  await isDisplayed(\".test-new-columns-menu-lookup\", \"lookup section is not present\");\n  await isDisplayed(`.test-new-columns-menu-lookup-${colId}`, `lookup section for ${colId} is not present`);\n}\n\nexport async function collapsedHiddenColumns() {\n  return await driver.findAll(\".test-new-columns-menu-hidden-column-collapsed\", el => el.getText());\n}\n\nexport function revertEach() {\n  let revert: () => Promise<void>;\n  beforeEach(async function() {\n    revert = await gu.begin();\n  });\n\n  gu.afterEachCleanup(async function() {\n    if (await isMenuPresent()) {\n      await closeAddColumnMenu();\n    }\n    await revert();\n  });\n}\n\nexport function revertThis() {\n  let revert: () => Promise<void>;\n  before(async function() {\n    revert = await gu.begin();\n  });\n\n  gu.afterCleanup(async function() {\n    if (await isMenuPresent()) {\n      await closeAddColumnMenu();\n    }\n    await revert();\n  });\n}\n\nexport async function addRefListLookup(refListId: string, colId: string, func: string) {\n  await clickAddColumn();\n  await driver.findWait(`.test-new-columns-menu-lookup-${refListId}`, STANDARD_WAITING_TIME).click();\n  await driver.findWait(`.test-new-columns-menu-lookup-submenu-${colId}`, STANDARD_WAITING_TIME).mouseMove();\n  await driver.findWait(`.test-new-columns-menu-lookup-submenu-function-${func}`, STANDARD_WAITING_TIME).click();\n  await gu.waitForServer();\n}\n\nexport async function checkTypeAndFormula(type: string, formula: string) {\n  assert.equal(await gu.getType(), type);\n  await driver.find(\".formula_field_sidepane\").click();\n  const actual = await gu.getFormulaText(false).then(s => s.trim());\n  if (!actual) {\n    throw new Error(\"Formula field is empty\");\n  }\n  assert.equal(actual, formula);\n  await gu.sendKeys(Key.ESCAPE);\n}\n\nexport const PERCENT = (ref: string, col: string) => `ref = ${ref}\\nAVERAGE(map(int, ref.${col})) if ref else None`;\nexport const AVERAGE = (ref: string, col: string) => `ref = ${ref}\\nAVERAGE(ref.${col}) if ref else None`;\nexport const MIN = (ref: string, col: string) => `ref = ${ref}\\nMIN(ref.${col}) if ref else None`;\nexport const MAX = (ref: string, col: string) => `ref = ${ref}\\nMAX(ref.${col}) if ref else None`;\n"
  },
  {
    "path": "test/nbrowser/HeaderColor.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\nconst defaultHeaderBackgroundColor = \"#f7f7f7\";\n\ndescribe(\"HeaderColor\", function() {\n  this.timeout(\"20s\");\n  const cleanup = setupTestSuite();\n\n  before(async () => {\n    // Create a new document\n    const mainSession = await gu.session().login();\n    await mainSession.tempNewDoc(cleanup, \"HeaderColor\");\n  });\n\n  it(\"allows setting header colors in summary table\", async function() {\n    const revert = await gu.begin();\n    await gu.toggleSidePanel(\"left\", \"close\");\n    // Add summary table.\n    await gu.addNewSection(\"Table\", \"Table1\", { summarize: [\"A\"] });\n    await gu.sendActions([\n      [\"AddRecord\", \"Table1\", null, { A: 1 }],\n    ]);\n    await gu.toggleSidePanel(\"right\", \"open\");\n    const testStyle = async () => {\n      await gu.openHeaderColorPicker();\n      await gu.setFillColor(\"red\");\n      await gu.setTextColor(\"blue\");\n      await gu.applyStyle();\n      await gu.checkForErrors();\n      await gu.assertHeaderFillColor(await gu.getSelectedColumn(), \"red\");\n      await gu.assertHeaderTextColor(await gu.getSelectedColumn(), \"blue\");\n    };\n    await gu.openColumnPanel(\"A\");\n    await testStyle();\n    await gu.openColumnPanel(\"count\");\n    await testStyle();\n\n    await gu.waitForServer();\n    await revert();\n  });\n\n  it(\"should save by clicking away\", async function() {\n    // add records\n    await gu.enterCell(\"a\");\n    await gu.enterCell(\"b\");\n    await gu.enterCell(\"c\");\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n\n    await gu.getCell(\"A\", 1).click();\n    // open color picker\n    await gu.openHeaderColorPicker();\n    await gu.setFillColor(\"red\");\n    await gu.setTextColor(\"blue\");\n    // Save by clicking B column\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitForServer();\n    // Make sure the color is saved.\n    await gu.assertHeaderFillColor(\"A\", \"red\");\n    await gu.assertHeaderTextColor(\"A\", \"blue\");\n    await gu.assertHeaderFillColor(\"B\", defaultHeaderBackgroundColor);\n    await gu.assertHeaderTextColor(\"B\", \"black\");\n    // Make sure it sticks after reload.\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n    await gu.assertHeaderFillColor(\"A\", \"red\");\n    await gu.assertHeaderTextColor(\"A\", \"blue\");\n    await gu.assertHeaderFillColor(\"B\", defaultHeaderBackgroundColor);\n    await gu.assertHeaderTextColor(\"B\", \"black\");\n  });\n\n  it(\"should undo and redo colors when clicked away\", async function() {\n    await gu.getCell(\"A\", 1).click();\n    // open color picker\n    await gu.openHeaderColorPicker();\n    await gu.setFillColor(\"red\");\n    await gu.setTextColor(\"blue\");\n    // Save by clicking B column\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitForServer();\n    // Make sure the color is saved.\n    await gu.assertHeaderFillColor(\"A\", \"red\");\n    await gu.assertHeaderTextColor(\"A\", \"blue\");\n    await gu.assertHeaderFillColor(\"B\", defaultHeaderBackgroundColor);\n    await gu.assertHeaderTextColor(\"B\", \"black\");\n    // Make sure then undoing works.\n    await gu.undo();\n    await gu.assertHeaderFillColor(\"A\", defaultHeaderBackgroundColor);\n    await gu.assertHeaderTextColor(\"A\", \"black\");\n    await gu.assertHeaderFillColor(\"B\", defaultHeaderBackgroundColor);\n    await gu.assertHeaderTextColor(\"B\", \"black\");\n    await gu.redo();\n    await gu.assertHeaderFillColor(\"A\", \"red\");\n    await gu.assertHeaderTextColor(\"A\", \"blue\");\n    await gu.assertHeaderFillColor(\"B\", defaultHeaderBackgroundColor);\n    await gu.assertHeaderTextColor(\"B\", \"black\");\n  });\n\n  it(\"should work correctly on Grid view\", async function() {\n    const columnHeader = gu.getColumnHeader(\"C\");\n\n    await columnHeader.click();\n    // open color picker\n    await gu.openHeaderColorPicker();\n\n    // set header colors\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(255, 0, 0)\");\n    await gu.setColor(driver.find(\".test-fill-input\"), \"rgb(0, 0, 255)\");\n\n    // press enter to close color picker\n    await driver.sendKeys(Key.ENTER);\n\n    // check header colors\n    assert.equal(await columnHeader.getCssValue(\"color\"), \"rgba(255, 0, 0, 1)\");\n    assert.equal(await columnHeader.getCssValue(\"background-color\"), \"rgba(0, 0, 255, 1)\");\n  });\n\n  it(\"should not exist in Detail view\", async function() {\n    // Color the A column in Grid View\n    const columnHeader = gu.getColumnHeader(\"A\");\n    await columnHeader.click();\n    await gu.openHeaderColorPicker();\n    await gu.setColor(driver.find(\".test-text-input\"), \"rgb(255, 0, 0)\");\n    await driver.sendKeys(Key.ENTER);\n\n    // Add a card list widget of Table1\n    await gu.addNewSection(/Card List/, /Table1/);\n\n    // check header colors\n    const detailHeader = await driver.findContent(\".g_record_detail_label\", gu.exactMatch(\"A\"));\n    assert.equal(await detailHeader.getCssValue(\"color\"), \"rgba(146, 146, 153, 1)\");\n\n    // There is no header color picker\n    assert.isFalse(await driver.find(\".test-header-color-select .test-color-select\").isPresent());\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Health.ntest.js",
    "content": "import { assert, driver } from \"mocha-webdriver\";\nimport { gu, server, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"Health.ntest\", function() {\n  test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n  });\n\n  it(\"make sure the health check endpoint returns something\", async function() {\n    await driver.get(server.getHost() + \"/status\");\n    const txt = await driver.getPageSource();\n    assert.match(txt, /Grist .* is alive/);\n  });\n\n});\n"
  },
  {
    "path": "test/nbrowser/HomeIntro.ts",
    "content": "/**\n * Test the HomeIntro screen for empty orgs and the special rendering of Examples & Templates\n * page, both for anonymous and logged-in users.\n */\n\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, stackWrapFunc, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"HomeIntro\", function() {\n  this.timeout(40000);\n  setupTestSuite({ samples: true, tutorial: true });\n  gu.withEnvironmentSnapshot({\n    GRIST_UI_FEATURES: \"templates,tutorials\",\n    GRIST_TEMPLATE_ORG: \"templates\",\n    GRIST_ONBOARDING_TUTORIAL_DOC_ID: \"grist-basics\",\n  });\n\n  describe(\"Anonymous on merged-org\", function() {\n    it(\"should show welcome for anonymous user\", async function() {\n      // Sign out\n      const session = await gu.session().personalSite.anon.login();\n\n      // Open doc-menu\n      await session.loadDocMenu(\"/\");\n\n      // Check message specific to anon\n      assert.equal(await driver.find(\".test-welcome-title\").getText(), \"Welcome to Grist!\");\n    });\n\n    it(\"should show intro screen for anon\", () => testIntroScreen({ anon: true, team: false }));\n    it(\"should set correct meta tags\", testMetaTags);\n    it(\"should not show Other Sites section\", testOtherSitesSection);\n    it(\"should allow create/import from intro screen\", testCreateImport.bind(null, false));\n    it(\"should link to examples page from the intro\", testExamplesPage);\n    it(\"should render selected Examples workspace specially\", testSelectedExamplesPage);\n  });\n\n  describe(\"Logged-in on merged-org\", function() {\n    it(\"should show welcome for logged-in user\", async function() {\n      // Sign in as a new user who has no docs.\n      const session = gu.session().personalSite.user(\"user3\");\n      await session.login({\n        isFirstLogin: false,\n        freshAccount: true,\n      });\n\n      // Open doc-menu and skip onboarding\n      await session.loadDocMenu(\"/\", \"skipOnboarding\");\n\n      // Reload the doc-menu and dismiss the coaching call popup\n      await session.loadDocMenu(\"/\");\n      await gu.dismissCardPopups();\n\n      // Check message specific to logged-in user\n      assert.match(await driver.find(\".test-welcome-title\").getText(), new RegExp(`Welcome.* ${session.name}`));\n    });\n\n    it(\"should not show Other Sites section\", testOtherSitesSection);\n    it(\"should show intro screen for empty org\", () => testIntroScreen({ anon: false, team: false }));\n    it(\"should allow create/import from intro screen\", testCreateImport.bind(null, true));\n    it(\"should link to examples page from the intro\", testExamplesPage);\n    it(\"should allow copying examples\", testCopyingExamples.bind(null, undefined));\n    it(\"should render selected Examples workspace specially\", testSelectedExamplesPage);\n    it(\"should show empty workspace info\", testEmptyWorkspace);\n  });\n\n  describe(\"Logged-in on team site\", function() {\n    it(\"should show welcome for logged-in user\", async function() {\n      // Sign in as to a team that has no docs.\n      const session = await gu.session().teamSite.user(\"user1\").login();\n      await session.loadDocMenu(\"/\");\n      await session.resetSite();\n\n      // Open doc-menu\n      await session.loadDocMenu(\"/\");\n\n      // Check message specific to logged-in user and an empty team site.\n      assert.match(await driver.find(\".test-welcome-title\").getText(), new RegExp(`Welcome.* ${session.orgName}`));\n    });\n\n    it(\"should not show Other Sites section\", testOtherSitesSection);\n    it(\"should show intro screen for empty org\", () => testIntroScreen({ anon: false, team: true }));\n    it(\"should allow create/import from intro screen\", testCreateImport.bind(null, true));\n    it(\"should link to examples page from the intro\", testExamplesPage);\n    it(\"should allow copying examples\", testCopyingExamples.bind(null, gu.session().teamSite.orgName));\n    it(\"should render selected Examples workspace specially\", testSelectedExamplesPage);\n    it(\"should show empty workspace info\", testEmptyWorkspace);\n  });\n\n  async function testOtherSitesSection() {\n    // Check that the Other Sites section is not shown.\n    assert.isFalse(await driver.find(\".test-dm-other-sites-header\").isPresent());\n  }\n\n  async function testIntroScreen(options: { anon: boolean; team: boolean }) {\n    // TODO There is no longer a thumbnail + video link on an empty site, but it's a good place to\n    // check for the presence and functionality of the planned links that open an intro video.\n\n    assert.isTrue(await driver.find(\".test-intro-cards\").isDisplayed());\n    assert.isTrue(await driver.find(\".test-intro-video-tour\").isDisplayed());\n    assert.isTrue(await driver.find(\".test-intro-create-doc\").isDisplayed());\n    assert.isTrue(await driver.find(\".test-intro-import-doc\").isDisplayed());\n    assert.isTrue(await driver.find(\".test-intro-templates\").isDisplayed());\n    assert.include(await driver.find(\".test-intro-webinars\").getAttribute(\"href\"),\n      \"www.getgrist.com/webinars\");\n    assert.include(await driver.find(\".test-intro-help-center\").getAttribute(\"href\"),\n      \"support.getgrist.com\");\n\n    if (options.team) {\n      assert.equal(await driver.find(\".test-topbar-manage-team\").getText(), \"Manage team\");\n    } else {\n      assert.equal(await driver.find(\".test-topbar-manage-team\").isPresent(), false);\n    }\n\n    if (options.anon) {\n      assert.isFalse(await driver.find(\".test-welcome-menu\").isPresent());\n    } else {\n      await driver.find(\".test-welcome-menu\").click();\n      await driver.find(\".test-welcome-menu-only-show-documents\").click();\n      await gu.waitForServer();\n      assert.isFalse(await driver.find(\".test-intro-cards\").isPresent());\n      await driver.navigate().refresh();\n      await gu.waitForDocMenuToLoad();\n      assert.isFalse(await driver.find(\".test-intro-cards\").isPresent());\n      await driver.find(\".test-welcome-menu\").click();\n      await driver.find(\".test-welcome-menu-only-show-documents\").click();\n      await gu.waitForServer();\n      assert.isTrue(await driver.find(\".test-intro-cards\").isDisplayed());\n    }\n  }\n\n  async function testMetaTags() {\n    assert.equal(await driver.find('meta[name=\"robots\"]').getAttribute(\"content\"), \"noindex\");\n\n    const expectedTitle = \"Grist, the evolution of spreadsheets\";\n    assert.equal(await driver.find('meta[name=\"twitter:title\"]').getAttribute(\"content\"), expectedTitle);\n    assert.equal(await driver.find('meta[property=\"og:title\"]').getAttribute(\"content\"), expectedTitle);\n\n    const expectedDescription = \"A modern, open source spreadsheet that goes beyond the grid\";\n    assert.equal(await driver.find('meta[name=\"description\"]').getAttribute(\"content\"), expectedDescription);\n    assert.equal(await driver.find('meta[name=\"twitter:description\"]').getAttribute(\"content\"), expectedDescription);\n    assert.equal(await driver.find('meta[property=\"og:description\"]').getAttribute(\"content\"), expectedDescription);\n\n    const gristIconFileName = \"opengraph-preview-image.png\";\n    assert.include(await driver.find('meta[name=\"thumbnail\"]').getAttribute(\"content\"), gristIconFileName);\n    assert.include(await driver.find('meta[name=\"twitter:image\"]').getAttribute(\"content\"), gristIconFileName);\n    assert.include(await driver.find('meta[property=\"og:image\"]').getAttribute(\"content\"), gristIconFileName);\n  }\n\n  async function testCreateImport(isLoggedIn: boolean) {\n    await checkIntroButtons(isLoggedIn);\n\n    // Check that add-new menu has enabled Create Empty and Import Doc items.\n    await driver.find(\".test-dm-add-new\").doClick();\n    await gu.findOpenMenu();\n\n    assert.equal(await driver.findWait(\".test-dm-new-doc\", 100).matches(\"[class*=-disabled]\"), false);\n    assert.equal(await driver.find(\".test-dm-import\").matches(\"[class*=-disabled]\"), false);\n\n    // Create doc from add-new menu\n    await driver.find(\".test-dm-new-doc\").doClick();\n    await checkDocAndRestore(isLoggedIn, async () => {\n      await gu.dismissWelcomeTourIfNeeded();\n      assert.equal(await gu.getCell(\"A\", 1).getText(), \"\");\n      if (!isLoggedIn) {\n        assert.equal(await driver.find(\".test-tb-share-action\").getText(), \"Save Document\");\n        await driver.find(\".test-tb-share\").click();\n        assert.equal(await driver.find(\".test-save-copy\").isPresent(), true);\n        // There is no original of this document.\n        assert.equal(await driver.find(\".test-open-original\").isPresent(), false);\n      } else {\n        assert.equal(await driver.find(\".test-tb-share-action\").isPresent(), false);\n      }\n    });\n\n    // Import doc from add-new menu\n    await gu.docMenuImport(\"uploads/FileUploadData.csv\");\n    await checkDocAndRestore(isLoggedIn, async () => assert.equal(await gu.getCell(\"fname\", 1).getText(), \"george\"));\n  }\n\n  // Wait for image to load (or fail), then check naturalWidth to ensure it loaded successfully.\n  const checkImageLoaded = stackWrapFunc(async function(img: WebElement) {\n    await driver.wait(() => img.getAttribute(\"complete\"), 10000);\n    assert.isAbove(Number(await img.getAttribute(\"naturalWidth\")), 0);\n  });\n\n  async function testExamplesPage() {\n    // Make sure we can get to the templates page from the home page.\n    await driver.find(\".test-intro-templates\").click();\n    await gu.waitForDocMenuToLoad();\n    assert(gu.testCurrentUrl(/p\\/templates/));\n\n    // Check titles.\n    assert.includeMembers(await driver.findAll(\".test-dm-pinned-doc-name\", el => el.getText()),\n      [\"Lightweight CRM\"]);\n\n    // Check images.\n    const docItem = await driver.findContent(\".test-dm-pinned-doc\", /Lightweight CRM/);\n    assert.equal(await docItem.find(\"img\").isPresent(), true);\n    await checkImageLoaded(docItem.find(\"img\"));\n\n    // Both the image and the doc title link to the doc.\n    const imgHref = await docItem.find(\"img\").findClosest(\"a\").getAttribute(\"href\");\n    const docHref = await docItem.find(\".test-dm-pinned-doc-name\").findClosest(\"a\").getAttribute(\"href\");\n    assert.match(docHref, /lightweight-crm/i);\n    assert.equal(imgHref, docHref);\n\n    // Open the example.\n    await docItem.find(\".test-dm-pinned-doc-name\").click();\n    await gu.waitForDocToLoad();\n    assert.match(await gu.getCell(\"Company\", 1).getText(), /Sporer/);\n    assert.match(await driver.find(\".test-bc-doc\").value(), /Lightweight CRM/);\n    await driver.navigate().back();\n    await gu.waitForDocMenuToLoad();\n  }\n\n  async function testCopyingExamples(destination?: string) {\n    // Open the example and make a full copy of it.\n    await driver.find(\".test-dm-templates-page\").click();\n    await gu.waitForDocMenuToLoad();\n    await driver.findContent(\".test-dm-pinned-doc-name\", /Lightweight CRM/).click();\n    await gu.waitForDocToLoad();\n    await driver.findWait(\".test-tb-share-action\", 500).click();\n    await gu.completeCopy({ destName: \"LCRM Copy\", destOrg: destination ?? \"Personal\" });\n    await checkDocAndRestore(true, async () => {\n      assert.match(await gu.getCell(\"Company\", 1).getText(), /Sporer/);\n      assert.match(await driver.find(\".test-bc-doc\").value(), /LCRM Copy/);\n    }, 3);\n\n    // Make a template copy of the example.\n    await driver.find(\".test-dm-templates-page\").click();\n    await gu.waitForDocMenuToLoad();\n    await driver.findContent(\".test-dm-pinned-doc-name\", /Lightweight CRM/).click();\n    await gu.waitForDocToLoad();\n    await driver.findWait(\".test-tb-share-action\", 500).click();\n    await driver.findWait(\".test-save-as-template\", 1000).click();\n    await gu.completeCopy({ destName: \"LCRM Template Copy\", destOrg: destination ?? \"Personal\" });\n    await checkDocAndRestore(true, async () => {\n      // No data, because the file was copied as a template.\n      assert.equal(await gu.getCell(0, 1).getText(), \"\");\n      assert.match(await driver.find(\".test-bc-doc\").value(), /LCRM Template Copy/);\n    }, 3);\n  }\n\n  async function testSelectedExamplesPage() {\n    // Click Examples & Templates in left panel.\n    await driver.find(\".test-dm-templates-page\").click();\n    await gu.waitForDocMenuToLoad();\n\n    // Check Featured Templates are shown at the top of the page.\n    assert.equal(await driver.findWait(\".test-dm-featured-templates-header\", 500).getText(), \"Featured\");\n    assert.includeMembers(\n      await driver.findAll(\".test-dm-pinned-doc-list .test-dm-pinned-doc-name\", el => el.getText()),\n      [\"Lightweight CRM\"]);\n    assert.includeMembers(\n      await driver.findAll(\".test-dm-pinned-doc-list .test-dm-pinned-doc-desc\", el => el.getText()),\n      [\"CRM template and example for linking data, and creating productive layouts.\"],\n    );\n\n    // External servers may have additional templates beyond the 3 above, so stop here.\n    if (server.isExternalServer()) { return; }\n\n    // Check the CRM and Invoice sections are shown below Featured Templates.\n    assert.includeMembers(\n      await driver.findAll(\".test-dm-templates-header\", el => el.getText()),\n      [\"CRM\", \"Other\"]);\n\n    // Check that each section has the correct templates (title and description).\n    const [crmSection, otherSection] = await driver.findAll(\".test-dm-templates\");\n    assert.includeMembers(\n      await crmSection.findAll(\".test-dm-pinned-doc-name\", el => el.getText()),\n      [\"Lightweight CRM\"]);\n    assert.includeMembers(\n      await otherSection.findAll(\".test-dm-pinned-doc-name\", el => el.getText()),\n      [\"Afterschool Program\", \"Investment Research\"]);\n    assert.includeMembers(\n      await crmSection.findAll(\".test-dm-pinned-doc-desc\", el => el.getText()),\n      [\"CRM template and example for linking data, and creating productive layouts.\"]);\n    assert.includeMembers(\n      await otherSection.findAll(\".test-dm-pinned-doc-desc\", el => el.getText()),\n      [\n        \"Example for how to model business data, use formulas, and manage complexity.\",\n        \"Example for analyzing and visualizing with summary tables and linked charts.\",\n      ]);\n\n    const docItem = await driver.findContent(\".test-dm-pinned-doc\", /Lightweight CRM/);\n    assert.equal(await docItem.find(\"img\").isPresent(), true);\n    await checkImageLoaded(docItem.find(\"img\"));\n  }\n});\n\nasync function testEmptyWorkspace() {\n  await gu.openWorkspace(\"Home\");\n  assert.isTrue(await driver.findWait(\".test-dm-no-docs-message\", 400).isDisplayed());\n  // Create doc and check it is created.\n  await driver.find(\".test-dm-add-new\").click();\n  await gu.findOpenMenu();\n  await driver.find(\".test-dm-new-doc\").click();\n  await waitAndDismiss();\n  await emptyDocChecker();\n  // Check that we don't see the \"No documents...\" message anymore.\n  await driver.navigate().back();\n  await gu.waitForDocMenuToLoad();\n  await gu.dismissBehavioralPrompts();\n  assert.isFalse(await driver.find(\".test-dm-no-docs-message\").isPresent());\n  // Remove created document, it also checks that document is visible.\n  await deleteFirstDoc();\n  assert.isTrue(await driver.findWait(\".test-dm-no-docs-message\", 400).isDisplayed());\n}\n\n// Wait for doc to load, check it, then return to home page, and remove the doc so that we\n// can see the intro again.\nasync function checkDocAndRestore(\n  isLoggedIn: boolean,\n  docChecker: () => Promise<void>,\n  stepsBackToDocMenu: number = 1) {\n  await waitAndDismiss();\n  await docChecker();\n  for (let i = 0; i < stepsBackToDocMenu; i++) {\n    await driver.navigate().back();\n    if (await gu.isAlertShown()) { await gu.acceptAlert(); }\n  }\n  await gu.waitForDocMenuToLoad();\n  // If not logged in, we create docs \"unsaved\" and don't see them in doc-menu.\n  if (isLoggedIn) {\n    // Delete the first doc we find. We expect exactly one to exist.\n    await deleteFirstDoc();\n  }\n  assert.equal(await driver.find(\".test-dm-doc\").isPresent(), false);\n}\n\nasync function waitAndDismiss() {\n  await gu.waitForDocToLoad();\n  await gu.dismissWelcomeTourIfNeeded();\n}\n\nasync function deleteFirstDoc() {\n  assert.equal(await driver.find(\".test-dm-doc\").isPresent(), true);\n  await driver.find(\".test-dm-doc\").mouseMove().find(\".test-dm-doc-options\").click();\n  await driver.find(\".test-dm-delete-doc\").click();\n  await driver.find(\".test-modal-confirm\").click();\n  await gu.waitForServer();\n  await driver.wait(async () => !(await driver.find(\".test-modal-dialog\").isPresent()), 3000);\n}\n\nasync function checkIntroButtons(isLoggedIn: boolean) {\n  // Create doc from intro button\n  await checkCreateDocButton(isLoggedIn);\n\n  // Import doc from intro button\n  await checkImportDocButton(isLoggedIn);\n}\n\nasync function checkImportDocButton(isLoggedIn: boolean) {\n  await gu.fileDialogUpload(\"uploads/FileUploadData.csv\", () => driver.find(\".test-intro-import-doc\").click());\n  await checkDocAndRestore(isLoggedIn, async () => assert.equal(await gu.getCell(\"fname\", 1).getText(), \"george\"));\n}\n\nasync function checkCreateDocButton(isLoggedIn: boolean) {\n  await driver.find(\".test-intro-create-doc\").click();\n  await checkDocAndRestore(isLoggedIn, emptyDocChecker);\n}\n\nasync function emptyDocChecker() {\n  assert.equal(await gu.getCell(\"A\", 1).getText(), \"\");\n}\n"
  },
  {
    "path": "test/nbrowser/HomeIntroWithoutPlaygound.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"HomeIntroWithoutPlayground\", function() {\n  this.timeout(20000);\n  setupTestSuite({ samples: true });\n  gu.withEnvironmentSnapshot({ GRIST_ANON_PLAYGROUND: false });\n\n  describe(\"Anonymous on merged-org\", function() {\n    it(\"should show welcome page\", async function() {\n      const session = await gu.session().personalSite.anon.login();\n      await session.loadDocMenu(\"/\");\n      assert.equal(await driver.find(\".test-welcome-title\").getText(), \"Welcome to Grist!\");\n    });\n\n    it(\"should not allow creating new documents\", async function() {\n      const session = await gu.session().personalSite.anon.login();\n      await session.loadDocMenu(\"/\");\n\n      // Check that the Add New button is disabled.\n      assert.equal(await driver.find(\".test-dm-add-new\").matches(\"[class*=-disabled]\"), true);\n      await driver.find(\".test-dm-add-new\").doClick();\n      assert.equal(await driver.find(\".test-dm-new-doc\").isPresent(), false);\n\n      // Check that the intro buttons are also disabled.\n      assert.equal(await driver.find(\".test-intro-create-doc\").getAttribute(\"disabled\"), \"true\");\n      assert.equal(await driver.find(\".test-intro-import-doc\").getAttribute(\"disabled\"), \"true\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ImportReferences.ts",
    "content": "/**\n * Parsing strings as references when importing into an existing table\n */\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { openSource as openSourceMenu, waitForColumnMapping } from \"test/nbrowser/importerTestUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"ImportReferences\", function() {\n  this.timeout(30000);\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    // Log in and import a sample document.\n    const session = await gu.session().teamSite.user(\"user1\").login();\n    await session.tempDoc(cleanup, \"ImportReferences.grist\");\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should convert strings to references\", async function() {\n    // Import a CSV file containing strings representing references\n    await gu.importFileDialog(\"./uploads/name_references.csv\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n\n    // Change the destination to the existing table\n    await driver.findContent(\".test-importer-target-existing-table\", /Table1/).click();\n    await gu.waitForServer();\n\n    // Finish import, and verify the import succeeded.\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n\n    // Verify data was imported to Names correctly.\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4, 5], cols: [0, 1, 2] }),\n      [\n        // Previously existing data in the fixture document\n        \"Alice\",   \"\",      \"\",\n        \"Bob\",     \"\",      \"\",\n\n        // Imported data from the CSV file\n        // The second column is references which have been successfully parsed from strings\n        // The third column is a formula equal to the second column to demonstrate the references\n        \"Charlie\", \"Alice\", \"Table1[1]\",\n        \"Dennis\",  \"Bob\",   \"Table1[2]\",\n\n        // 'add new' row\n        \"\",        \"\",      \"\",\n      ],\n    );\n\n    // TODO this test relies on the imported data referring to names (Alice,Bob)\n    //   already existing in the table before the import, and not being changed by the import\n  });\n\n  it(\"should support importing into any reference columns and show preview\", async function() {\n    // Switch to page showing Projects and Tasks.\n    await gu.getPageItem(\"Projects\").click();\n    await gu.waitForServer(); // wait for table load\n\n    // Load up a CSV file that matches the structure of the Tasks table.\n    await gu.importFileDialog(\"./uploads/ImportReferences-Tasks.csv\");\n\n    // The default import into \"New Table\" just shows the content of the file.\n    assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4, 5, 6], [1, 2, 3, 4], mapper), [\n      \"Foo2\", \"Clean\",  \"1000\", \"1,000\", \"27 Mar 2023\", \"\",             \"0\",\n      \"Bar2\", \"Wash\",   \"3000\", \"2,000\", \"\",            \"Projects[2]\",  \"2\",\n      \"Baz2\", \"Build2\", \"\",     \"2\",     \"20 Mar 2023\", \"Projects[1]\",  \"1\",\n      \"Zoo2\", \"Clean\",  \"2000\", \"4,000\", \"24 Apr 2023\", \"Projects[3]\",  \"3\",\n    ]);\n\n    await driver.findContent(\".test-importer-target-existing-table\", /Tasks/).click();\n    await gu.waitForServer();\n\n    // See that preview works, and cells that should be valid are valid.\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [\n      // Label, PName,   PIndex,   PDate,          PRowID\n      \"Foo2\", \"Clean\",   \"1,000\",  \"27 Mar 2023\",  \"\",\n      \"Bar2\", \"Wash\",    \"3,000\",  \"\",             \"!Projects[2]\",\n      \"Baz2\", \"!Build2\", \"\",       \"!2023-03-20\",  \"!Projects[1]\",\n      \"Zoo2\", \"Clean\",   \"2,000\",  \"24 Apr 2023\",  \"!Projects[3]\",\n    ]);\n\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n\n    // Verify data was imported to Tasks correctly.\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ section: \"TASKS\", cols: [0, 1, 2, 3, 4], rowNums: [4, 5, 6, 7, 8, 9], mapper }), [\n      // Label, PName,   PIndex,   PDate,          PRowID\n      // Previous data in the fixture, in row 4\n        \"Zoo\",  \"Clean\",   \"2,000\",  \"27 Mar 2023\",  \"Projects[3]\",\n        // New rows (values like \"!Project[2]\" are invalid, which may be fixed in the future).\n        \"Foo2\", \"Clean\",   \"1,000\",  \"27 Mar 2023\",  \"\",\n        \"Bar2\", \"Wash\",    \"3,000\",  \"\",             \"!Projects[2]\",\n        \"Baz2\", \"!Build2\", \"\",       \"!2023-03-20\",  \"!Projects[1]\",\n        \"Zoo2\", \"Clean\",   \"2,000\",  \"24 Apr 2023\",  \"!Projects[3]\",\n        // 'Add New' row\n        \"\", \"\", \"\", \"\", \"\",\n      ]);\n\n    await gu.undo();\n  });\n\n  it(\"should support importing numeric columns as lookups or rowIDs\", async function() {\n    // Load up the same CSV file again, with Tasks as the destination.\n    await gu.importFileDialog(\"./uploads/ImportReferences-Tasks.csv\");\n    await driver.findContent(\".test-importer-target-existing-table\", /Tasks/).click();\n    await gu.waitForServer();\n    await waitForColumnMapping();\n\n    // Check that preview works, and cells are valid.\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [\n      // Label, PName,   PIndex,   PDate,          PRowID\n      \"Foo2\", \"Clean\",   \"1,000\",  \"27 Mar 2023\",  \"\",\n      \"Bar2\", \"Wash\",    \"3,000\",  \"\",             \"!Projects[2]\",\n      \"Baz2\", \"!Build2\", \"\",       \"!2023-03-20\",  \"!Projects[1]\",\n      \"Zoo2\", \"Clean\",   \"2,000\",  \"24 Apr 2023\",  \"!Projects[3]\",\n    ]);\n\n    // Check that dropdown for Label does not include \"(as row ID)\" entries, but the dropdown for\n    // PName (a reference column) does.\n    await openSourceMenu(\"Label\");\n    assert.equal(await findColumnMenuItem(\"PIndex\").isPresent(), true);\n    assert.equal(await driver.findContent(\".test-importer-column-match-menu-item\", /as row ID/).isPresent(), false);\n    await driver.sendKeys(Key.ESCAPE);\n\n    await openSourceMenu(\"PName\");\n    assert.equal(await findColumnMenuItem(\"PIndex\").isPresent(), true);\n    assert.equal(await findColumnMenuItem(\"PIndex (as row ID)\").isPresent(), true);\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Change PIndex column from lookup to row ID.\n    await openSourceMenu(\"PIndex\");\n    await findColumnMenuItem(\"PIndex (as row ID)\").click();\n    await gu.waitForServer();\n\n    // The values become invalid because there are no such rowIDs.\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [\n      // Label, PName,   PIndex,   PDate,          PRowID\n      \"Foo2\", \"Clean\",   \"!1000\",  \"27 Mar 2023\",  \"\",\n      \"Bar2\", \"Wash\",    \"!3000\",  \"\",             \"!Projects[2]\",\n      \"Baz2\", \"!Build2\", \"\",       \"!2023-03-20\",  \"!Projects[1]\",\n      \"Zoo2\", \"Clean\",   \"!2000\",  \"24 Apr 2023\",  \"!Projects[3]\",\n    ]);\n\n    // Try a lookup using PIndex2. It is differently formatted, one value is invalid, and one is a\n    // valid row ID (but shouldn't be seen as a rowID for a lookup)\n    await openSourceMenu(\"PIndex\");\n    await findColumnMenuItem(\"PIndex2\").click();\n    await gu.waitForServer();\n\n    // Note: two PIndex values are different, and two are invalid.\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [\n      // Label, PName,   PIndex,   PDate,          PRowID\n      \"Foo2\", \"Clean\",   \"1,000\",   \"27 Mar 2023\",  \"\",\n      \"Bar2\", \"Wash\",    \"2,000\",   \"\",             \"!Projects[2]\",\n      \"Baz2\", \"!Build2\", \"!2.0\",    \"!2023-03-20\",  \"!Projects[1]\",\n      \"Zoo2\", \"Clean\",   \"!4000.0\", \"24 Apr 2023\",  \"!Projects[3]\",\n    ]);\n\n    // Change PRowID column to use \"PID (as row ID)\". It has 3 valid rowIDs.\n    await openSourceMenu(\"PRowID\");\n    await findColumnMenuItem(\"PID (as row ID)\").click();\n    await gu.waitForServer();\n\n    // Note: PRowID values are now valid.\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4], mapper), [\n      // Label, PName,   PIndex,   PDate,          PRowID\n      \"Foo2\", \"Clean\",   \"1,000\",   \"27 Mar 2023\",  \"\",\n      \"Bar2\", \"Wash\",    \"2,000\",   \"\",             \"Projects[2]\",\n      \"Baz2\", \"!Build2\", \"!2.0\",    \"!2023-03-20\",  \"Projects[1]\",\n      \"Zoo2\", \"Clean\",   \"!4000.0\", \"24 Apr 2023\",  \"Projects[3]\",\n    ]);\n\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n\n    // Verify data was imported to Tasks correctly.\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ section: \"TASKS\", cols: [0, 1, 2, 3, 4], rowNums: [4, 5, 6, 7, 8, 9], mapper }), [\n      // Label, PName,   PIndex,   PDate,          PRowID\n      // Previous data in the fixture, in row 4\n        \"Zoo\",  \"Clean\",   \"2,000\",  \"27 Mar 2023\",  \"Projects[3]\",\n        // New rows; PRowID values are valid.\n        \"Foo2\", \"Clean\",   \"1,000\",   \"27 Mar 2023\",  \"\",\n        \"Bar2\", \"Wash\",    \"2,000\",   \"\",             \"Projects[2]\",\n        \"Baz2\", \"!Build2\", \"!2.0\",    \"!2023-03-20\",  \"Projects[1]\",\n        \"Zoo2\", \"Clean\",   \"!4000.0\", \"24 Apr 2023\",  \"Projects[3]\",\n        // 'Add New' row\n        \"\", \"\", \"\", \"\", \"\",\n      ]);\n\n    await gu.undo();\n  });\n});\n\n// mapper for getVisibleGridCells and getPreviewContents to get both text and whether the cell is\n// invalid (pink). Invalid cells prefixed with \"!\".\nasync function mapper(el: WebElement) {\n  let text = await el.getText();\n  if (await el.find(\".field_clip\").matches(\".invalid\")) {\n    text = \"!\" + text;\n  }\n  return text;\n}\n\nfunction findColumnMenuItem(label: RegExp | string) {\n  return driver.findContentWait(\".test-importer-column-match-menu-item\", label, 100);\n}\n"
  },
  {
    "path": "test/nbrowser/Importer.ts",
    "content": "/**\n * Test of the Importer dialog (part 1), for imports inside an open doc.\n * (See Import.ts for tests from the DocMenu page.)\n */\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { getColumnMatchingRows, getParseOptionInput, getPreviewDiffCellValues,\n  openTableMapping, waitForColumnMapping, waitForDiffPreviewToLoad } from \"test/nbrowser/importerTestUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"Importer\", function() {\n  this.timeout(70000); // Imports can take some time, especially in tests that import larger files.\n  const cleanup = setupTestSuite();\n\n  let docUrl: string | undefined;\n  let session: gu.Session;\n\n  beforeEach(async function() {\n    // Log in and import a sample document. If this is already done, we can skip these tests, to\n    // have tests go faster. Each successful test case should leave the document unchanged.\n    if (!docUrl || !await gu.testCurrentUrl(docUrl)) {\n      session = await gu.session().teamSite.login();\n      // TODO: tests check colors literally, so need to be in\n      // light theme - but calling gu.setGristTheme results in\n      // some problems so right now if you are a dev you just\n      // need to run these tests in light mode, sorry.\n      await session.tempDoc(cleanup, \"Hello.grist\");\n      docUrl = await driver.getCurrentUrl();\n    }\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should show correct preview\", async function() {\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n    assert.lengthOf(await driver.findAll(\".test-importer-source\"), 1);\n\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2], [1, 2, 3]),\n      [\"Lily\", \"Jones\", \"director\",\n        \"Kathy\", \"Mills\", \"student\",\n        \"Karen\", \"Gold\", \"professor\"]);\n\n    // Check that the preview table cannot be edited by double-clicking a cell or via keyboard.\n    const cell = await (await gu.getPreviewCell(0, 1)).doClick();\n    await driver.withActions(a => a.doubleClick(cell));\n    assert(await driver.find(\".default_editor.readonly_editor\").isPresent());\n    await gu.sendKeys(Key.ESCAPE);\n    assert.isFalse(await driver.find(\".default_editor.readonly_editor\").isPresent());\n    await gu.sendKeys(Key.DELETE);\n    await gu.waitForServer();\n    assert.equal(await cell.getText(), \"Lily\");\n\n    // Check that the column matching section is not shown for new tables.\n    assert.isFalse(await driver.find(\".test-importer-column-match-options\").isPresent());\n\n    // Check that the preview table doesn't show formula icons in cells.\n    assert.isFalse(await cell.find(\".formula_field\").isPresent());\n\n    // Check that we have \"Import Options\" link and click it.\n    assert.equal(await driver.find(\".test-importer-options-link\").isPresent(), true);\n    await driver.find(\".test-importer-options-link\").click();\n\n    // Check that initially we see a button \"Close\" (nothing to update)\n    assert.equal(await driver.findWait(\".test-parseopts-back\", 500).getText(), \"Close\");\n    assert.equal(await driver.find(\".test-parseopts-update\").isPresent(), false);\n\n    // After a change to parse options, button should change to 'Update Preview'\n    await getParseOptionInput(/Field separator/).doClear().sendKeys(\"|\");\n    assert.equal(await driver.findWait(\".test-parseopts-update\", 500).getText(), \"Update preview\");\n    assert.equal(await driver.find(\".test-parseopts-back\").isPresent(), false);\n\n    // Changing the parse option back to initial state reverts the button back too.\n    await getParseOptionInput(/Field separator/).doClear().sendKeys(\",\");\n    assert.equal(await driver.findWait(\".test-parseopts-back\", 500).getText(), \"Close\");\n    assert.equal(await driver.find(\".test-parseopts-update\").isPresent(), false);\n\n    // ensure that option 'First row contains headers' is checked if headers were guessed\n    let useHeaders = await getParseOptionInput(/First row/);\n    assert.equal(await useHeaders.getAttribute(\"checked\"), \"true\");\n\n    // Uncheck the option and update the preview.\n    await useHeaders.click();\n    assert.equal(await useHeaders.getAttribute(\"checked\"), null);\n    await driver.find(\".test-parseopts-update\").click();\n    await gu.waitForServer();\n\n    // Ensure that column names become the first row in preview data.\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2], [1, 2, 3, 4]),\n      [\"Name\", \"Phone\", \"Title\",\n        \"Lily\", \"Jones\", \"director\",\n        \"Kathy\", \"Mills\", \"student\",\n        \"Karen\", \"Gold\", \"professor\"]);\n\n    // Check the option again and update the preview.\n    await driver.find(\".test-importer-options-link\").click();\n    useHeaders = await getParseOptionInput(/First row/);\n    assert.equal(await useHeaders.getAttribute(\"checked\"), null);\n    await useHeaders.click();\n    await driver.find(\".test-parseopts-update\").click();\n    await gu.waitForServer();\n\n    // Ensure that column names are used as headers again.\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2], [1, 2, 3]),\n      [\"Lily\", \"Jones\", \"director\",\n        \"Kathy\", \"Mills\", \"student\",\n        \"Karen\", \"Gold\", \"professor\"]);\n\n    // Right-click a column header, to ensure we don't get a JS error in this case.\n    const colHeader = await driver.findContent(\".test-importer-preview .column_name\", /Name/);\n    await driver.withActions(actions => actions.contextClick(colHeader));\n    await gu.checkForErrors();\n\n    // Change Field separator and update the preview.\n    await driver.find(\".test-importer-options-link\").click();\n    await getParseOptionInput(/Field separator/).doClick().sendKeys(\"|\");\n    assert.equal(await getParseOptionInput(/Field separator/).value(), \"|\");\n    assert.equal(await getParseOptionInput(/Line terminator/).value(), \"\\\\n\");\n    await driver.find(\".test-parseopts-update\").click();\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getPreviewContents([0], [1, 2, 3]),\n      [\"Lily,Jones,director\",\n        \"Kathy,Mills,student\",\n        \"Karen,Gold,professor\"]);\n\n    // Close the dialog.\n    await driver.find(\".test-modal-cancel\").click();\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".test-importer-dialog\").isPresent(), false);\n\n    // No new pages should be present.\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\"]);\n  });\n\n  it(\"should show correct preview for multiple tables\", async function() {\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv,./uploads/UploadedData2.csv\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 8000).isPresent(), true);\n    assert.lengthOf(await driver.findAll(\".test-importer-source\"), 2);\n    assert.equal(await driver.find(\".test-importer-source-selected .test-importer-from\").getText(),\n      \"UploadedData1.csv\");\n\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2], [1, 2, 3]),\n      [\"Lily\", \"Jones\", \"director\",\n        \"Kathy\", \"Mills\", \"student\",\n        \"Karen\", \"Gold\", \"professor\"]);\n\n    // Select another table\n    await driver.findContent(\".test-importer-from\", /UploadedData2/).click();\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".test-importer-source-selected .test-importer-from\").getText(),\n      \"UploadedData2.csv\");\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6]),\n      [\"BUS100\",      \"Intro to Business\",   \"\",                    \"01/13/2021\",      \"\",\n        \"BUS102\",      \"Business Law\",        \"Nathalie Patricia\",   \"01/13/2021\",      \"\",\n        \"BUS300\",      \"Business Operations\", \"Michael Rian\",        \"01/14/2021\",      \"\",\n        \"BUS301\",      \"History of Business\", \"Mariyam Melania\",     \"01/14/2021\",      \"\",\n        \"BUS500\",      \"Ethics and Law\",      \"Filip Andries\",       \"01/13/2021\",      \"\",\n        \"BUS540\",      \"Capstone\",            \"\",                    \"01/13/2021\",      \"\"]);\n\n    // Check that changing a parse option (Field Separator to \"|\") affects both tables.\n    await driver.find(\".test-importer-options-link\").click();\n    await getParseOptionInput(/Field separator/).doClick().sendKeys(\"|\");\n    await driver.find(\".test-parseopts-update\").click();\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getPreviewContents([0], [1, 2, 3]),\n      [\"Lily,Jones,director\",\n        \"Kathy,Mills,student\",\n        \"Karen,Gold,professor\"]);\n\n    await driver.findContent(\".test-importer-from\", /UploadedData2/).click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getPreviewContents([0], [1, 2, 3, 4, 5, 6]),\n      [\"BUS100,Intro to Business,,01/13/2021,false\",\n        \"BUS102,Business Law,Nathalie Patricia,01/13/2021,false\",\n        \"BUS300,Business Operations,Michael Rian,01/14/2021,false\",\n        \"BUS301,History of Business,Mariyam Melania,01/14/2021,false\",\n        \"BUS500,Ethics and Law,Filip Andries,01/13/2021,false\",\n        \"BUS540,Capstone,,01/13/2021,true\"]);\n\n    // Close the dialog.\n    await driver.find(\".test-modal-cancel\").click();\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".test-importer-dialog\").isPresent(), false);\n  });\n\n  it(\"should not show preview for single empty file\", async function() {\n    await gu.importFileDialog(\"./uploads/UploadedDataEmpty.csv\");\n    assert.match(await driver.findWait(\".test-importer-error\", 1000).getText(),\n      /Import failed: No data was imported/);\n\n    await driver.find(\".test-modal-cancel\").click();\n    await gu.waitForServer();\n  });\n\n  it(\"should not show preview for empty file when importing with non empty files\", async function() {\n    await gu.importFileDialog(\n      \"./uploads/UploadedData1.csv,./uploads/UploadedData2.csv,./uploads/UploadedDataEmpty.csv\");\n\n    assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n\n    // Ensure that there are no empty tables shown.\n    assert.deepEqual(await driver.findAll(\".test-importer-from\", el => el.getText()),\n      [\"UploadedData1.csv\", \"UploadedData2.csv\"]);\n\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2], [1, 2, 3]),\n      [\"Lily\", \"Jones\", \"director\",\n        \"Kathy\", \"Mills\", \"student\",\n        \"Karen\", \"Gold\", \"professor\"]);\n\n    await driver.findContent(\".test-importer-from\", /UploadedData2/).click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6]),\n      [\"BUS100\",      \"Intro to Business\",   \"\",                    \"01/13/2021\",      \"\",\n        \"BUS102\",      \"Business Law\",        \"Nathalie Patricia\",   \"01/13/2021\",      \"\",\n        \"BUS300\",      \"Business Operations\", \"Michael Rian\",        \"01/14/2021\",      \"\",\n        \"BUS301\",      \"History of Business\", \"Mariyam Melania\",     \"01/14/2021\",      \"\",\n        \"BUS500\",      \"Ethics and Law\",      \"Filip Andries\",       \"01/13/2021\",      \"\",\n        \"BUS540\",      \"Capstone\",            \"\",                    \"01/13/2021\",      \"\"]);\n\n    await driver.find(\".test-modal-cancel\").click();\n    await gu.waitForServer();\n  });\n\n  it(\"should finish import into an existing table\", async function() {\n    // First import the file into a new table, which is the default import action.\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4], cols: [0, 1, 2] }),\n      [\"Lily\", \"Jones\", \"director\",\n        \"Kathy\", \"Mills\", \"student\",\n        \"Karen\", \"Gold\", \"professor\",\n        \"\", \"\", \"\"]);\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"UploadedData1\"]);\n\n    // Now import the same file again, choosing the same table as the first time.\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n    await driver.findContent(\".test-importer-target-existing-table\", /UploadedData1/).click();\n    await gu.waitForServer();\n\n    // The preview content should be the same, since all columns match.\n    assert.deepEqual(await gu.getPreviewContents([0, 1, 2], [1, 2, 3]),\n      [\"Lily\", \"Jones\", \"director\",\n        \"Kathy\", \"Mills\", \"student\",\n        \"Karen\", \"Gold\", \"professor\"]);\n\n    await waitForColumnMapping();\n    assert.deepEqual(await getColumnMatchingRows(), [\n      { destination: \"Name\", source: \"Name\" },\n      { destination: \"Phone\", source: \"Phone\" },\n      { destination: \"Title\", source: \"Title\" },\n    ]);\n\n    // Complete this second import.\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2] }),\n      [\"Lily\", \"Jones\", \"director\",\n        \"Kathy\", \"Mills\", \"student\",\n        \"Karen\", \"Gold\", \"professor\",\n        \"Lily\", \"Jones\", \"director\",\n        \"Kathy\", \"Mills\", \"student\",\n        \"Karen\", \"Gold\", \"professor\",\n        \"\", \"\", \"\"]);\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"UploadedData1\"]);\n\n    // Undo the import\n    await gu.undo(2);\n\n    // Ensure that imported table is removed, and we are back to the original one.\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\"]);\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1], cols: [0, 1, 2] }),\n      [\"hello\", \"\", \"\"]);\n  });\n\n  it(\"should finish import multiple files\", async function() {\n    // Import two files together.\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv,./uploads/UploadedData2.csv\");\n    await driver.findWait(\".test-modal-confirm\", 2000).click();\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"UploadedData1\", \"UploadedData2\"]);\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3] }),\n      [\"Lily\", \"Jones\", \"director\",\n        \"Kathy\", \"Mills\", \"student\",\n        \"Karen\", \"Gold\", \"professor\"]);\n\n    await gu.getPageItem(\"UploadedData2\").click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2, 3, 4], rowNums: [1, 2, 3, 4, 5, 6] }),\n      [\"BUS100\",      \"Intro to Business\",   \"\",                    \"01/13/2021\",      \"\",\n        \"BUS102\",      \"Business Law\",        \"Nathalie Patricia\",   \"01/13/2021\",      \"\",\n        \"BUS300\",      \"Business Operations\", \"Michael Rian\",        \"01/14/2021\",      \"\",\n        \"BUS301\",      \"History of Business\", \"Mariyam Melania\",     \"01/14/2021\",      \"\",\n        \"BUS500\",      \"Ethics and Law\",      \"Filip Andries\",       \"01/13/2021\",      \"\",\n        \"BUS540\",      \"Capstone\",            \"\",                    \"01/13/2021\",      \"\"]);\n\n    // Undo and check that we are back to the original state.\n    await gu.undo();\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\"]);\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1], cols: [0, 1, 2] }),\n      [\"hello\", \"\", \"\"]);\n  });\n\n  it(\"should import empty dates\", async function() {\n    await gu.importFileDialog(\"./uploads/EmptyDate.csv\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n\n    // Finish import and check that the dialog gets closed.\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".test-importer-dialog\").isPresent(), false);\n\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1] }),\n      [\"Bob\", \"2018-01-01\",\n        \"Alice\", \"\",\n        \"Carol\", \"2017-01-01\"]);\n\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"EmptyDate\"]);\n\n    // Add a new column, with a formula to examine the first.\n    await gu.openColumnMenu(\"Birthday\", \"Insert column to the right\");\n    await driver.findWait(\".test-new-columns-menu-add-new\", 100).click();\n    await gu.waitForServer();\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.getCell({ col: 2, rowNum: 1 }).click();\n    await gu.waitAppFocus();\n    await driver.sendKeys(\"=type($Birthday).__name__\", Key.ENTER);\n    await gu.waitForServer();\n    // Ensure that there is no ValueError in second row\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1, 2] }),\n      [\"Bob\",    \"2018-01-01\",   \"date\",\n        \"Alice\",  \"\",             \"NoneType\",\n        \"Carol\",  \"2017-01-01\",   \"date\"]);\n  });\n\n  it(\"should finish import xlsx file\", async function() {\n    await gu.importFileDialog(\"./uploads/homicide_rates.xlsx\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 5000).isPresent(), true);\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer(5000);\n    assert.equal(await driver.find(\".test-importer-dialog\").isPresent(), false);\n    // Look at a small subset of the imported table.\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1, 2] }),\n      [\"Africa\", \"Eastern Africa\", \"Burundi\",\n        \"Africa\", \"Eastern Africa\", \"Burundi\",\n        \"Africa\", \"Eastern Africa\", \"Comoros\"]);\n  });\n\n  it(\"should import correctly in prefork mode\", async function() {\n    await driver.get(`${docUrl}/m/fork`);\n    await gu.waitForDocToLoad();\n\n    await gu.importFileDialog(\"./uploads/homicide_rates.xlsx\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 5000).isPresent(), true);\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer(5000);\n    assert.equal(await driver.find(\".test-importer-dialog\").isPresent(), false);\n    // Look at a small subset of the imported table.\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0, 1, 2] }),\n      [\"Africa\", \"Eastern Africa\", \"Burundi\",\n        \"Africa\", \"Eastern Africa\", \"Burundi\",\n        \"Africa\", \"Eastern Africa\", \"Comoros\"]);\n    await driver.get(`${docUrl}`);\n    await gu.acceptAlert({ ignore: true });\n    await gu.waitForDocToLoad();\n  });\n\n  it(\"should support importing into on-demand tables\", async function() {\n    // On demand tables are deprecated now and not available by default from UI.\n    await gu.getPageItem(\"EmptyDate\").click();\n    await gu.waitForServer();\n    await gu.toggleSidePanel(\"right\", \"open\");\n    const api = session.createHomeApi().getDocAPI(await gu.getDocId());\n    const [table] = await api.getRecords(\"_grist_Tables\", {\n      filters: { tableId: [\"EmptyDate\"] },\n    });\n    await gu.sendActions([\n      [\"UpdateRecord\", \"_grist_Tables\", table.id, { onDemand: true }],\n    ]);\n    await api.forceReload();\n    await gu.reloadDoc();\n\n    // Import EmptyDate.csv into EmptyDate and check the import was successful.\n    await gu.importFileDialog(\"./uploads/EmptyDate.csv\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 5000).isPresent(), true);\n    await driver.findContent(\".test-importer-target-existing-table\", /EmptyDate/).click();\n    await gu.waitForServer();\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer(5000);\n    assert.equal(await driver.find(\".test-importer-dialog\").isPresent(), false);\n\n    // Check that the imported file contents were added to the end of EmptyDate.\n    assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [4, 5, 6], cols: [0, 1] }),\n      [\"Bob\", \"2018-01-01\",\n        \"Alice\", \"\",\n        \"Carol\", \"2017-01-01\"]);\n    assert.equal(await gu.getGridRowCount(), 7);\n  });\n\n  describe(\"when updating existing records\", async function() {\n    it(\"should populate merge columns/fields menu with columns from preview\", async function() {\n      // First import a file into a new table, so that we have a base for merging.\n      await gu.importFileDialog(\"./uploads/UploadedData1.csv\");\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n\n      // Now import the same file again.\n      await gu.importFileDialog(\"./uploads/UploadedData1.csv\");\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n\n      // Check that the 'Update existing records' checkbox is not visible (since destination is 'New Table').\n      assert.isNotTrue(await driver.find(\".test-importer-update-existing-records\").isPresent());\n      assert.isNotTrue(await driver.find(\".test-importer-merge-fields-select\").isPresent());\n      assert.isNotTrue(await driver.find(\".test-importer-merge-fields-message\").isPresent());\n\n      // Change the destination to the table we created earlier ('UploadedData1').\n      await driver.findContent(\".test-importer-target-existing-table\", /UploadedData1/).click();\n      await gu.waitForServer();\n\n      // Check that the 'Update existing records' checkbox is now visible and unchecked.\n      assert(await driver.find(\".test-importer-update-existing-records\").isPresent());\n      assert.isNotTrue(await driver.find(\".test-importer-merge-fields-select\").isPresent());\n      assert.isNotTrue(await driver.find(\".test-importer-merge-fields-message\").isPresent());\n\n      // Click 'Update existing records' and verify that additional merge options are shown.\n      await waitForColumnMapping();\n      await driver.find(\".test-importer-update-existing-records\").click();\n      assert.equal(\n        await driver.find(\".test-importer-merge-fields-message\").getText(),\n        \"Merge rows that match these fields:\",\n      );\n      assert.equal(\n        await driver.find(\".test-importer-merge-fields-select\").getText(),\n        \"Select fields to match on\",\n      );\n\n      // Open the field select menu and check that all the preview table columns are available options.\n      await driver.find(\".test-importer-merge-fields-select\").click();\n      await driver.findWait(\".test-multi-select-menu .test-multi-select-menu-option\", 100);\n\n      assert.deepEqual(\n        await driver.findAll(\".test-multi-select-menu .test-multi-select-menu-option-text\", e => e.getText()),\n        [\"Name\", \"Phone\", \"Title\"],\n      );\n\n      // Close the field select menu.\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"should display an error when clicking Import with no merge fields selected\", async function() {\n      // No merge fields are currently selected. Click Import and check that nothing happened.\n      await driver.find(\".test-modal-confirm\").click();\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n\n      // Check that the merge field select button has a red outline.\n      assert.match(\n        await driver.find(\".test-importer-merge-fields-select\").getCssValue(\"border\"),\n        /solid rgb\\(208, 2, 27\\)/,\n      );\n\n      // Select a merge field, and check that the red outline is gone.\n      await driver.find(\".test-importer-merge-fields-select\").click();\n      await driver.findContentWait(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Name/,\n        100,\n      ).click();\n      assert.match(\n        await driver.find(\".test-importer-merge-fields-select\").getCssValue(\"border\"),\n        /solid rgb\\(217, 217, 217\\)/,\n      );\n      // Hide dropdown\n      await gu.sendKeys(Key.ESCAPE);\n\n      await gu.checkForErrors();\n    });\n\n    it(\"should not throw an error when a column in the preview is clicked\", async function() {\n      // A bug was previously causing an error to be thrown whenever a column header was\n      // clicked while merge columns were set.\n      await driver.findContent(\".test-importer-preview .column_name\", /Name/).click();\n      await gu.checkForErrors();\n    });\n\n    it(\"should merge fields of matching records when Import is clicked\", async function() {\n      // The 'Name' field is selected as the only merge field. Click Import.\n      assert.equal(await driver.find(\".test-importer-merge-fields-select\").getText(), \"Name\");\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n\n      // Check that the destination table is unchanged since we imported the same file.\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4], cols: [0, 1, 2] }),\n        [\"Lily\", \"Jones\", \"director\",\n          \"Kathy\", \"Mills\", \"student\",\n          \"Karen\", \"Gold\", \"professor\",\n          \"\", \"\", \"\",\n        ],\n      );\n\n      // Undo the import, and check that the destination table is still unchanged.\n      await gu.undo();\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4], cols: [0, 1, 2] }),\n        [\"Lily\", \"Jones\", \"director\",\n          \"Kathy\", \"Mills\", \"student\",\n          \"Karen\", \"Gold\", \"professor\",\n          \"\", \"\", \"\",\n        ],\n      );\n\n      // Import from another file containing some duplicates (with new values).\n      await gu.importFileDialog(\"./uploads/UploadedData1Extended.csv\");\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n      await driver.findContent(\".test-importer-target-existing-table\", /UploadedData1/).click();\n      await gu.waitForServer();\n\n      // Set the merge fields to 'Name' and 'Phone'.\n      await waitForColumnMapping();\n      await driver.find(\".test-importer-update-existing-records\").click();\n      await driver.find(\".test-importer-merge-fields-select\").click();\n      await driver.findContentWait(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Name/,\n        100,\n      ).click();\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Phone/,\n      ).click();\n\n      // Close the merge fields menu.\n      await gu.sendKeys(Key.ESCAPE);\n      assert.equal(await driver.find(\".test-importer-merge-fields-select\").getText(), \"Name, Phone\");\n\n      // Check the preview shows a diff of the changes importing will make.\n      await waitForDiffPreviewToLoad();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2], [1, 2, 3, 4, 5, 6]),\n        [\"Lily\", \"Jones\", [\"director\", \"student\", undefined],\n          \"Kathy\", \"Mills\", [\"student\", \"professor\", undefined],\n          \"Karen\", \"Gold\", [\"professor\", \"director\", undefined],\n          [undefined, \"Michael\", undefined], [undefined, \"Smith\", undefined], [undefined, \"student\", undefined],\n          [undefined, \"Lily\", undefined], [undefined, \"James\", undefined], [undefined, \"student\", undefined],\n          \"\", \"\", \"\",\n        ],\n      );\n\n      // Complete the import, and verify that incoming data was merged into matching records in UploadedData1.\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4, 5, 6], cols: [0, 1, 2] }),\n        [\"Lily\", \"Jones\", \"student\",\n          \"Kathy\", \"Mills\", \"professor\",\n          \"Karen\", \"Gold\", \"director\",\n          \"Michael\", \"Smith\", \"student\",\n          \"Lily\", \"James\", \"student\",\n          \"\", \"\", \"\",\n        ],\n      );\n\n      // Undo the import, and check the table is back to how it was pre-import.\n      await gu.undo();\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4], cols: [0, 1, 2] }),\n        [\"Lily\", \"Jones\", \"director\",\n          \"Kathy\", \"Mills\", \"student\",\n          \"Karen\", \"Gold\", \"professor\",\n          \"\", \"\", \"\",\n        ],\n      );\n    });\n\n    it(\"should support merging multiple CSV files into multiple tables\", async function() {\n      // Import a second table, so we have 2 destinations to incrementally import into.\n      await gu.importFileDialog(\"./uploads/UploadedData2.csv\");\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n\n      // Now import new versions of both files together.\n      await gu.importFileDialog(\"./uploads/UploadedData1Extended.csv,./uploads/UploadedData2Extended.csv\");\n\n      // For UploadedData1Extended.csv, check 'Update existing records', but don't pick any merge fields yet.\n      await driver.findContent(\".test-importer-target-existing-table\", /UploadedData1/).click();\n\n      await gu.waitForServer();\n      await waitForColumnMapping();\n      await driver.find(\".test-importer-update-existing-records\").click();\n\n      // Try to click on UploadedData2.csv.\n      await driver.findContent(\".test-importer-source\", /UploadedData2Extended.csv/).click();\n\n      // Check that it failed, and that the merge fields select button is outlined in red.\n      assert.match(\n        await driver.find(\".test-importer-merge-fields-select\").getCssValue(\"border\"),\n        /solid rgb\\(208, 2, 27\\)/,\n      );\n      assert.equal(\n        await driver.find(\".test-importer-source-selected .test-importer-from\").getText(),\n        \"UploadedData1Extended.csv\",\n      );\n\n      // Now pick the merge fields, and check that the preview diff looks correct.\n      await driver.find(\".test-importer-merge-fields-select\").click();\n      await driver.findWait(\".test-multi-select-menu .test-multi-select-menu-option\", 100);\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Name/,\n      ).click();\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Phone/,\n      ).click();\n      await gu.sendKeys(Key.ESCAPE);\n\n      await waitForDiffPreviewToLoad();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2], [1, 2, 3, 4, 5, 6]),\n        [\"Lily\", \"Jones\", [\"director\", \"student\", undefined],\n          \"Kathy\", \"Mills\", [\"student\", \"professor\", undefined],\n          \"Karen\", \"Gold\", [\"professor\", \"director\", undefined],\n          [undefined, \"Michael\", undefined], [undefined, \"Smith\", undefined], [undefined, \"student\", undefined],\n          [undefined, \"Lily\", undefined], [undefined, \"James\", undefined], [undefined, \"student\", undefined],\n          \"\", \"\", \"\",\n        ],\n      );\n\n      // Check that clicking UploadedData2 now works.\n      await driver.findContent(\".test-importer-source\", /UploadedData2Extended.csv/).click();\n      await gu.waitForServer();\n      await driver.findContent(\".test-importer-target-existing-table\", /UploadedData2/).click();\n      await gu.waitForServer();\n      assert.equal(\n        await driver.find(\".test-importer-source-selected .test-importer-from\").getText(),\n        \"UploadedData2Extended.csv\",\n      );\n\n      // Set the merge fields for UploadedData2 to 'CourseId'.\n      await waitForColumnMapping();\n      await driver.find(\".test-importer-update-existing-records\").click();\n      await driver.find(\".test-importer-merge-fields-select\").click();\n      await driver.findWait(\".test-multi-select-menu .test-multi-select-menu-option\", 100);\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /CourseId/,\n      ).click();\n\n      // Close the merge fields menu.\n      await gu.sendKeys(Key.ESCAPE);\n\n      assert.equal(await driver.find(\".test-importer-merge-fields-select\").getText(), \"CourseId\");\n\n      // Check that the preview diff looks correct for UploadedData2.\n      await waitForDiffPreviewToLoad();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6, 7, 8, 9]),\n        [\"BUS100\",      \"Intro to Business\",   [undefined, \"Mariyam Melania\", undefined], \"01/13/2021\", \"\",\n          \"BUS102\",      \"Business Law\",        \"Nathalie Patricia\",   \"01/13/2021\",       \"\",\n          \"BUS300\",      \"Business Operations\", \"Michael Rian\",        \"01/14/2021\",       \"\",\n          \"BUS301\",      \"History of Business\", \"Mariyam Melania\",     \"01/14/2021\",       \"\",\n          \"BUS500\",      [undefined, undefined, \"Ethics and Law\"],     \"Filip Andries\",    \"01/13/2021\", \"\",\n          [undefined, \"BUS501\", undefined], [undefined, \"Marketing\", undefined], [undefined, \"Michael Rian\", undefined],\n          [undefined, \"01/13/2021\", undefined], [undefined, \"false\", undefined],\n          [undefined, \"BUS539\", undefined], [undefined, \"Independent Study\", undefined],   \"\",\n          [undefined, \"01/13/2021\", undefined], [undefined, \"true\", undefined],\n          \"BUS540\",      \"Capstone\",            \"\",                    \"01/13/2021\",      [\"true\", \"false\", undefined],\n          \"\", \"\", \"\", \"\", \"\",\n        ],\n      );\n\n      // Complete the import, and verify that incoming data was merged into both UploadedData1 and UploadedData2.\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getPageNames(), [\n        \"Table1\",\n        \"EmptyDate\",\n        \"Homicide counts and rates (2000\",\n        \"Sheet1\",\n        \"UploadedData1\",\n        \"UploadedData2\",\n      ]);\n\n      // Check the contents of UploadedData1.\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4, 5, 6], cols: [0, 1, 2] }),\n        [\"Lily\", \"Jones\", \"student\",\n          \"Kathy\", \"Mills\", \"professor\",\n          \"Karen\", \"Gold\", \"director\",\n          \"Michael\", \"Smith\", \"student\",\n          \"Lily\", \"James\", \"student\",\n          \"\", \"\", \"\",\n        ],\n      );\n\n      // Check the contents of UploadedData2.\n      await gu.getPageItem(\"UploadedData2\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2, 3, 4], rowNums: [1, 2, 3, 4, 5, 6, 7, 8, 9] }),\n        [\"BUS100\",      \"Intro to Business\",   \"Mariyam Melania\",     \"01/13/2021\",      \"\",\n          \"BUS102\",      \"Business Law\",        \"Nathalie Patricia\",   \"01/13/2021\",      \"\",\n          \"BUS300\",      \"Business Operations\", \"Michael Rian\",        \"01/14/2021\",      \"\",\n          \"BUS301\",      \"History of Business\", \"Mariyam Melania\",     \"01/14/2021\",      \"\",\n          \"BUS500\",      \"Ethics and Law\",      \"Filip Andries\",       \"01/13/2021\",      \"\",\n          \"BUS540\",      \"Capstone\",            \"\",                    \"01/13/2021\",      \"\",\n          \"BUS501\",      \"Marketing\",           \"Michael Rian\",        \"01/13/2021\",      \"\",\n          \"BUS539\",      \"Independent Study\",   \"\",                    \"01/13/2021\",      \"\",\n          \"\",            \"\",                    \"\",                    \"\",                \"\"]);\n    });\n\n    it(\"should support merging multiple Excel sheets into multiple tables\", async function() {\n      this.timeout(90000);\n\n      // Import an Excel file with multiple sheets into new tables.\n      await gu.importFileDialog(\"./uploads/World-v0.xlsx\");\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer(10_000);\n\n      // Now import a new version of the Excel file with updated data.\n      await gu.importFileDialog(\"./uploads/World-v1.xlsx\");\n\n      // For sheet Table1, don't pick any merge fields and import into the existing table (Table1_2).\n      await driver.findContent(\".test-importer-target-existing-table\", /Table1_2/).click();\n\n      await gu.waitForServer();\n\n      // For sheet City, merge on Name, District and Country.\n      await driver.findContent(\".test-importer-source\", /City/).click();\n      await gu.waitForServer();\n      await driver.findContent(\".test-importer-target-existing-table\", /City/).click();\n      await gu.waitForServer();\n      await waitForColumnMapping();\n      await driver.find(\".test-importer-update-existing-records\").click();\n      await driver.find(\".test-importer-merge-fields-select\").click();\n      await driver.findWait(\".test-multi-select-menu .test-multi-select-menu-option\", 100);\n\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Name/,\n      ).click();\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /District/,\n      ).click();\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Country/,\n      ).click();\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Check the preview diff of City. The population should have doubled in every row.\n      await waitForDiffPreviewToLoad();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2, 3, 4], [1, 2, 3, 4, 5]),\n        [\n          \"Kabul\", \"Kabol\", [\"1780000\", \"3560000\", undefined], \"2\", [\"1780\", \"3560\", undefined],\n          \"Qandahar\", \"Qandahar\", [\"237500\", \"475000\", undefined], \"2\", [\"237.5\", \"475\", undefined],\n          \"Herat\", \"Herat\", [\"186800\", \"373600\", undefined], \"2\", [\"186.8\", \"373.6\", undefined],\n          \"Mazar-e-Sharif\", \"Balkh\", [\"127800\", \"255600\", undefined], \"2\", [\"127.8\", \"255.6\", undefined],\n          \"Amsterdam\", \"Noord-Holland\", [\"731200\", \"1462400\", undefined], \"159\",  [\"731.2\", \"1462.4\", undefined],\n        ],\n      );\n\n      // For sheet Country, merge on Code.\n      await driver.findContent(\".test-importer-source\", /Country/).click();\n      await gu.waitForServer();\n      await driver.findContent(\".test-importer-target-existing-table\", /Country/).click();\n      await gu.waitForServer();\n      await waitForColumnMapping();\n      await driver.find(\".test-importer-update-existing-records\").click();\n      await driver.find(\".test-importer-merge-fields-select\").click();\n      await driver.findWait(\".test-multi-select-menu .test-multi-select-menu-option\", 100);\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Code/,\n      ).click();\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Check the preview diff of Country. The population should have doubled in every row.\n      await waitForDiffPreviewToLoad();\n      await gu.sendKeys(Key.chord(await gu.modKey(), Key.UP));\n      await driver.findContentWait(\".field_clip\", \"ABW\", 100);\n\n      assert.deepEqual(\n        await getPreviewDiffCellValues([0, 6], [1, 2, 3, 4, 5]),\n        [\"ABW\", [\"103000\", \"206000\", undefined],\n          \"AFG\", [\"22720000\", \"45440000\", undefined],\n          \"AGO\", [\"12878000\", \"25756000\", undefined],\n          \"AIA\", [\"8000\", \"16000\", undefined],\n          \"ALB\", [\"3401200\", \"6802400\", undefined],\n        ],\n      );\n\n      // For sheet CountryLanguage, merge on Country and Language.\n      await driver.findContent(\".test-importer-source\", /CountryLanguage/).click();\n      await gu.waitForServer();\n      await driver.findContent(\".test-importer-target-existing-table\", /CountryLanguage/).click();\n      await gu.waitForServer();\n\n      await waitForColumnMapping();\n      await driver.find(\".test-importer-update-existing-records\").click();\n      await driver.find(\".test-importer-merge-fields-select\").click();\n      await driver.findWait(\".test-multi-select-menu .test-multi-select-menu-option\", 100);\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Country/,\n      ).click();\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Language/,\n      ).click();\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Check the preview diff of CountryLanguage. The first few percentages should be slightly different.\n      await waitForDiffPreviewToLoad();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2, 3], [1, 2, 3, 4, 5]),\n        [\"Dutch\", [\"5.3\", \"5.5\", undefined], \"ABW\", \"\",\n          \"English\", [\"9.5\", \"9.3\", undefined], \"ABW\", \"\",\n          \"Papiamento\", [\"76.7\", \"76.3\", undefined], \"ABW\", \"\",\n          \"Spanish\", [\"7.4\", \"7.8\", undefined], \"ABW\", \"\",\n          \"Balochi\", [\"0.9\", \"1.1\", undefined], \"AFG\", \"\",\n        ],\n      );\n\n      // Complete the import, and verify that incoming data was merged correctly.\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getPageNames(), [\n        \"Table1\",\n        \"EmptyDate\",\n        \"Homicide counts and rates (2000\",\n        \"Sheet1\",\n        \"UploadedData1\",\n        \"UploadedData2\",\n        \"Table1\",\n        \"City\",\n        \"Country\",\n        \"CountryLanguage\",\n      ]);\n\n      // Check the contents of Table1; it should have duplicates of the original 2 rows.\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4, 5], cols: [0, 1, 2, 3, 4] }),\n        [\n          \"hello\", \"\", \"\", \"\", \"HELLO\",\n          \"\", \"world\", \"\", \"\", \"\",\n          \"hello\", \"\", \"\", \"\", \"HELLO\",\n          \"\", \"world\", \"\", \"\", \"\",\n          \"\", \"\", \"\", \"\", \"\",\n        ],\n      );\n\n      // Check the contents of City. The population should have doubled in every row.\n      await gu.getPageItem(\"City\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2, 3, 4], rowNums: [1, 2, 3, 4, 5] }),\n        [\n          \"Kabul\", \"Kabol\", \"3560000\", \"2\", \"3560\",\n          \"Qandahar\", \"Qandahar\", \"475000\", \"2\", \"475\",\n          \"Herat\", \"Herat\", \"373600\", \"2\", \"373.6\",\n          \"Mazar-e-Sharif\", \"Balkh\", \"255600\", \"2\", \"255.6\",\n          \"Amsterdam\", \"Noord-Holland\", \"1462400\", \"159\", \"1462.4\",\n        ],\n      );\n\n      // Check that no new rows were added to City.\n      assert.equal(await gu.getGridRowCount(), 4080);\n\n      // Check the contents of Country. The population should have doubled in every row.\n      await gu.getPageItem(\"Country\").click();\n      await gu.waitForServer();\n      assert.deepEqual(\n        await gu.getVisibleGridCells({\n          cols: [0, 6],\n          rowNums: [1, 2, 3, 4, 5],\n        }),\n        [\"ABW\", \"206000\",\n          \"AFG\", \"45440000\",\n          \"AGO\", \"25756000\",\n          \"AIA\", \"16000\",\n          \"ALB\", \"6802400\",\n        ],\n      );\n\n      // Check that no new rows were added to Country.\n      assert.equal(await gu.getGridRowCount(), 240);\n\n      // Check the contents of CountryLanguage. The first few percentages should be slightly different.\n      await gu.getPageItem(\"CountryLanguage\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2, 3], rowNums: [1, 2, 3, 4, 5] }),\n        [\"Dutch\", \"5.5\", \"ABW\", \"\",\n          \"English\", \"9.3\", \"ABW\", \"\",\n          \"Papiamento\", \"76.3\", \"ABW\", \"\",\n          \"Spanish\", \"7.8\", \"ABW\", \"\",\n          \"Balochi\", \"1.1\", \"AFG\", \"\",\n        ],\n      );\n\n      // Check that no new rows were added to CountryLanguage.\n      assert.equal(await gu.getGridRowCount(), 985);\n    });\n\n    it(\"should show diff of changes in preview\", async function() {\n      // Import UploadedData2.csv again, and change the destination to UploadedData2.\n      await gu.importFileDialog(\"./uploads/UploadedData2.csv\");\n      await driver.findContent(\".test-importer-target-existing-table\", /UploadedData2/).click();\n      await gu.waitForServer();\n      await waitForColumnMapping();\n\n      // Click 'Update existing records', and check the preview does not yet show a diff.\n      await driver.find(\".test-importer-update-existing-records\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6, 7]),\n        [\"BUS100\",      \"Intro to Business\",   \"\",                    \"01/13/2021\",      \"\",\n          \"BUS102\",      \"Business Law\",        \"Nathalie Patricia\",   \"01/13/2021\",      \"\",\n          \"BUS300\",      \"Business Operations\", \"Michael Rian\",        \"01/14/2021\",      \"\",\n          \"BUS301\",      \"History of Business\", \"Mariyam Melania\",     \"01/14/2021\",      \"\",\n          \"BUS500\",      \"Ethics and Law\",      \"Filip Andries\",       \"01/13/2021\",      \"\",\n          \"BUS540\",      \"Capstone\",            \"\",                    \"01/13/2021\",      \"\",\n          \"\",            \"\",                    \"\",                    \"\",                \"\"]);\n\n      // Select 'CourseId' as the merge column, and check that the preview now contains a diff of old/new values.\n      await driver.find(\".test-importer-merge-fields-select\").click();\n      await driver.findWait(\".test-multi-select-menu .test-multi-select-menu-option\", 100);\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /CourseId/,\n      ).click();\n      await gu.sendKeys(Key.ESCAPE);\n      await gu.waitForServer();\n      await waitForDiffPreviewToLoad();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6, 7]),\n        [\"BUS100\",      \"Intro to Business\",   [undefined, undefined, \"Mariyam Melania\"], \"01/13/2021\", \"\",\n          \"BUS102\",      \"Business Law\",        \"Nathalie Patricia\",   \"01/13/2021\",      \"\",\n          \"BUS300\",      \"Business Operations\", \"Michael Rian\",        \"01/14/2021\",      \"\",\n          \"BUS301\",      \"History of Business\", \"Mariyam Melania\",     \"01/14/2021\",      \"\",\n          \"BUS500\",      \"Ethics and Law\",      \"Filip Andries\",       \"01/13/2021\",      \"\",\n          \"BUS540\",      \"Capstone\",            \"\",                    \"01/13/2021\",      [\"false\", \"true\", undefined],\n          \"\",            \"\",                    \"\",                    \"\",                \"\"]);\n\n      // Uncheck 'Update existing records', and check that the preview no longer shows a diff.\n      await driver.find(\".test-importer-update-existing-records\").click();\n      await waitForDiffPreviewToLoad();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6, 7]),\n        [\"BUS100\",      \"Intro to Business\",   \"\",                    \"01/13/2021\",      \"\",\n          \"BUS102\",      \"Business Law\",        \"Nathalie Patricia\",   \"01/13/2021\",      \"\",\n          \"BUS300\",      \"Business Operations\", \"Michael Rian\",        \"01/14/2021\",      \"\",\n          \"BUS301\",      \"History of Business\", \"Mariyam Melania\",     \"01/14/2021\",      \"\",\n          \"BUS500\",      \"Ethics and Law\",      \"Filip Andries\",       \"01/13/2021\",      \"\",\n          \"BUS540\",      \"Capstone\",            \"\",                    \"01/13/2021\",      \"\",\n          \"\",            \"\",                    \"\",                    \"\",                \"\"]);\n\n      // Check that the column matching section is correct.\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { destination: \"CourseId\", source: \"CourseId\" },\n        { destination: \"CourseName\", source: \"CourseName\" },\n        { destination: \"Instructor\", source: \"Instructor\" },\n        { destination: \"StartDate\", source: \"StartDate\" },\n        { destination: \"PassFail\", source: \"PassFail\" },\n      ]);\n\n      // Click 'Update existing records' again, and edit the formula for CourseId to append a suffix.\n      await driver.find(\".test-importer-update-existing-records\").click();\n      await waitForDiffPreviewToLoad();\n      await driver.findContent(\".test-importer-column-match-source-destination\", /CourseId/)\n        .find(\".test-importer-column-match-formula\").click();\n      await driver.findWait(\".test-importer-apply-formula\", 100).click();\n      await gu.sendKeys(' + \"-NEW\"');\n\n      // Before saving the formula, check that the preview isn't showing the hidden helper column ids.\n      assert.deepEqual(\n        await driver.find(\".test-importer-preview\").findAll(\".g-column-label\", el => el.getText()),\n        [\"CourseId\", \"CourseName\", \"Instructor\", \"StartDate\", \"PassFail\"],\n      );\n      await gu.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n\n      // Check that the preview diff was updated and now shows that all 6 rows are new rows.\n      await waitForDiffPreviewToLoad();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6, 7]),\n        [\n          [undefined, \"BUS100-NEW\", undefined], [undefined, \"Intro to Business\", undefined], \"\",\n          [undefined, \"01/13/2021\", undefined], [undefined, \"false\", undefined],\n          [undefined, \"BUS102-NEW\", undefined], [undefined, \"Business Law\", undefined],\n          [undefined, \"Nathalie Patricia\", undefined], [undefined, \"01/13/2021\", undefined],\n          [undefined, \"false\", undefined],\n          [undefined, \"BUS300-NEW\", undefined], [undefined, \"Business Operations\", undefined],\n          [undefined, \"Michael Rian\", undefined], [undefined, \"01/14/2021\", undefined],\n          [undefined, \"false\", undefined],\n          [undefined, \"BUS301-NEW\", undefined], [undefined, \"History of Business\", undefined],\n          [undefined, \"Mariyam Melania\", undefined], [undefined, \"01/14/2021\", undefined],\n          [undefined, \"false\", undefined],\n          [undefined, \"BUS500-NEW\", undefined], [undefined, \"Ethics and Law\", undefined],\n          [undefined, \"Filip Andries\", undefined], [undefined, \"01/13/2021\", undefined],\n          [undefined, \"false\", undefined],\n          [undefined, \"BUS540-NEW\", undefined], [undefined, \"Capstone\", undefined], \"\",\n          [undefined, \"01/13/2021\", undefined], [undefined, \"true\", undefined],\n          \"\", \"\", \"\", \"\", \"\",\n        ],\n      );\n\n      // Check the column mapping section updated with the new formula.\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { destination: \"CourseId\", source: '$CourseId + \"-NEW\"\\n' },\n        { destination: \"CourseName\", source: \"CourseName\" },\n        { destination: \"Instructor\", source: \"Instructor\" },\n        { destination: \"StartDate\", source: \"StartDate\" },\n        { destination: \"PassFail\", source: \"PassFail\" },\n      ]);\n\n      // Change the destination back to new table, and check that the preview no longer shows a diff.\n      await openTableMapping();\n      await driver.find(\".test-importer-target-new-table\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6, 7]),\n        [\"BUS100\",      \"Intro to Business\",   \"\",                    \"01/13/2021\",      \"\",\n          \"BUS102\",      \"Business Law\",        \"Nathalie Patricia\",   \"01/13/2021\",      \"\",\n          \"BUS300\",      \"Business Operations\", \"Michael Rian\",        \"01/14/2021\",      \"\",\n          \"BUS301\",      \"History of Business\", \"Mariyam Melania\",     \"01/14/2021\",      \"\",\n          \"BUS500\",      \"Ethics and Law\",      \"Filip Andries\",       \"01/13/2021\",      \"\",\n          \"BUS540\",      \"Capstone\",            \"\",                    \"01/13/2021\",      \"\",\n          \"\",            \"\",                    \"\",                    \"\",                \"\"]);\n\n      // Close the dialog.\n      await driver.find(\".test-modal-cancel\").click();\n      await gu.waitForServer();\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Importer2.ts",
    "content": "/**\n * Test of the Importer dialog (part 2), for imports inside an open doc.\n */\nimport { DocCreationInfo } from \"app/common/DocListAPI\";\nimport { DocAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { getColumnMatchingRows, getPreviewDiffCellValues, openSource as openSourceFor,\n  openTableMapping, waitForColumnMapping, waitForDiffPreviewToLoad } from \"test/nbrowser/importerTestUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport * as _ from \"lodash\";\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"Importer2\", function() {\n  this.timeout(60000);\n  gu.bigScreen();\n  const cleanup = setupTestSuite();\n  let doc: DocCreationInfo;\n  let api: DocAPI;\n\n  before(async function() {\n    // Log in and import a sample document.\n    const session = await gu.session().teamSite.login();\n    doc = await session.tempDoc(cleanup, \"Hello.grist\");\n    api = session.createHomeApi().getDocAPI(doc.id);\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should close formula editor when switching sources or closing importer\", async function() {\n    await gu.importFileDialog(\"./uploads/World-v0.xlsx\");\n    assert.equal(await driver.findWait(\".test-importer-preview\", 5000).isPresent(), true);\n\n    // Double-click a preview cell to open the formula editor in the preview grid.\n    await gu.dbClick(await gu.getPreviewCell(0, 1));\n    await waitForFormulaEditor();\n\n    // Click away (on a configuration control) to remove focus and close the editor.\n    await driver.find(\".test-importer-target-new-table\").click();\n    await gu.waitForServer();\n    await waitForFormulaEditorToClose();\n\n    // Open the editor again in the preview grid.\n    await gu.dbClick(await gu.getPreviewCell(1, 1));\n    await waitForFormulaEditor();\n\n    // Switching source tables should also close any open editor.\n    await driver.findContent(\".test-importer-source\", /City/).click();\n    await gu.waitForServer();\n    await waitForFormulaEditorToClose();\n\n    // Re-open once more, then cancel the importer and verify cleanup on close.\n    await driver.findContent(\".test-importer-source\", /Table1/).click();\n    await gu.waitForServer();\n    await gu.dbClick(await gu.getPreviewCell(2, 2));\n    await waitForFormulaEditor();\n\n    // Cancel the import to verify that the formula editor is closed.\n    await driver.find(\".test-modal-cancel\").click();\n    await gu.waitAppFocus();\n    await waitForFormulaEditorToClose();\n  });\n\n  it(\"should import new tables losslessly\", async function() {\n    // Import mixed_dates.csv into a new table\n    await gu.importFileDialog(\"./uploads/mixed_dates.csv\");\n    await waitForDiffPreviewToLoad();\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitAppFocus();\n\n    // Import the same file again into the same table\n    await gu.importFileDialog(\"./uploads/mixed_dates.csv\");\n    await driver.findContent(\".test-importer-target-existing-table\", /Mixed_dates/).click();\n    await waitForDiffPreviewToLoad();\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitAppFocus();\n\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [0], rowNums: _.range(1, 21) }),\n      [\n        // mixed_dates.csv contains 10 dates. The first 9 are YYYY-MM-DD so that's the guessed date format.\n        // The last date '01/02/03' doesn't fit this format.\n        // Since 90% of the values fit the guessed format, the column is guessed to have type Date.\n        // The dates are parsed by DateGuesser which uses moment's strict parsing directly, not parseDate.\n        // So '01/02/03' isn't parsed and remains a string, and the column is imported losslessly,\n        // i.e. converting it back to text yields the original strings in the file unchanged.\n        \"2020-03-04\",\n        \"2020-03-05\",\n        \"2020-03-06\",\n        \"2020-03-04\",\n        \"2020-03-05\",\n        \"2020-03-06\",\n        \"2020-03-04\",\n        \"2020-03-05\",\n        \"2020-03-06\",\n        \"01/02/03\",\n\n        // When the file is imported again into the same table, things go differently.\n        // The intermediate hidden table goes through the same process and stores '01/02/03' as a string.\n        // But for existing tables we set parseStrings to true when applying the final BulkAddRecord.\n        // So '01/02/03' is parsed by parseDate according to the existing column's date format which gives 2001-02-03.\n        \"2020-03-04\",\n        \"2020-03-05\",\n        \"2020-03-06\",\n        \"2020-03-04\",\n        \"2020-03-05\",\n        \"2020-03-06\",\n        \"2020-03-04\",\n        \"2020-03-05\",\n        \"2020-03-06\",\n        \"2001-02-03\",\n      ],\n    );\n\n    await gu.undo(2);\n  });\n\n  it(\"should set widget options for formatted numbers\", async function() {\n    // Import formatted_numbers.csv into a new table\n    await gu.importFileDialog(\"./uploads/formatted_numbers.csv\");\n    await waitForDiffPreviewToLoad();\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitAppFocus();\n\n    // Numbers appear formatted as in the CSV file\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [0, 1, 2, 3, 4], rowNums: [1] }),\n      [\"$1.00\", \"1.20E3\", \"2,000,000\", \"43%\", \"(56)\"],\n    );\n\n    const records = await api.getRecords(\"Formatted_numbers\");\n    const cols = await api.getRecords(\"_grist_Tables_column\");\n\n    // Actual data has correct values, e.g. 43% -> 0.43\n    assert.deepEqual(records, [{\n      id: 1,\n      fields: {\n        fn_currency: 1,\n        fn_scientific: 1200,\n        fn_decimal: 2000000,\n        fn_percent: 0.43,\n        fn_parens: -56,\n      },\n    }]);\n\n    // Get the fields we care about describing the columns to allow comparison.\n    // All column names in the CSV file start with \"fn_\"\n    const colFields = cols.map(\n      ({ fields: { colId, type, widgetOptions } }) =>\n        ({ colId, type, widgetOptions: JSON.parse(widgetOptions as string || \"{}\") }),\n    ).filter(f => (f.colId as string).startsWith(\"fn_\"));\n\n    // All the columns are numeric and have some kind of formatting\n    assert.deepEqual(colFields, [\n      {\n        colId: \"fn_currency\",\n        type: \"Numeric\",\n        widgetOptions: { decimals: 2, numMode: \"currency\" },\n      },\n      {\n        colId: \"fn_scientific\",\n        type: \"Numeric\",\n        widgetOptions: { decimals: 2, numMode: \"scientific\" },\n      },\n      {\n        colId: \"fn_decimal\",\n        type: \"Numeric\",\n        widgetOptions: { numMode: \"decimal\" },\n      },\n      {\n        colId: \"fn_percent\",\n        type: \"Numeric\",\n        widgetOptions: { numMode: \"percent\" },\n      },\n      {\n        colId: \"fn_parens\",\n        type: \"Numeric\",\n        widgetOptions: { numSign: \"parens\" },\n      },\n    ]);\n\n    // Remove the imported table\n    await gu.undo();\n  });\n\n  it(\"should not show skip option for single table\", async function() {\n    async function noSkip() {\n      await waitForDiffPreviewToLoad();\n      assert.isFalse(await driver.find(\".test-importer-target-skip\").isPresent());\n      await driver.sendKeys(Key.ESCAPE);\n      await driver.find(\".test-modal-cancel\").click();\n      await gu.waitAppFocus();\n    }\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv\");\n    await noSkip();\n    await gu.importFileDialog(\"./uploads/BooleanData.xlsx\");\n    await noSkip();\n  });\n\n  it(\"should show skip option for multiple tables\", async function() {\n    async function hasSkip() {\n      await waitForDiffPreviewToLoad();\n      assert.isTrue(await driver.find(\".test-importer-target-skip\").isDisplayed());\n      await driver.find(\".test-importer-source-not-selected\").click();\n      assert.isTrue(await driver.find(\".test-importer-target-skip\").isDisplayed());\n      await driver.find(\".test-modal-cancel\").click();\n      await gu.waitAppFocus();\n    }\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv,./uploads/UploadedData2.csv\");\n    await hasSkip();\n    await gu.importFileDialog(\"./uploads/homicide_rates.xlsx\");\n    await hasSkip();\n  });\n\n  it(\"should skip importing\", async function() {\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv,./uploads/UploadedData2.csv\");\n    await waitForDiffPreviewToLoad();\n    // Skip the first table.\n    await driver.find(\".test-importer-target-skip\").click();\n    // Make sure preview is grayed out.\n    assert.isTrue(await driver.find(\".test-importer-preview-overlay\").isPresent());\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitAppFocus();\n    // Make sure only second table is visible.\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"UploadedData2\"]);\n    // And data is valid.\n    await gu.getPageItem(\"UploadedData2\").click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2, 3, 4], rowNums: [1, 2, 3, 4, 5, 6] }),\n      [\"BUS100\",      \"Intro to Business\",   \"\",                    \"01/13/2021\",      \"\",\n        \"BUS102\",      \"Business Law\",        \"Nathalie Patricia\",   \"01/13/2021\",      \"\",\n        \"BUS300\",      \"Business Operations\", \"Michael Rian\",        \"01/14/2021\",      \"\",\n        \"BUS301\",      \"History of Business\", \"Mariyam Melania\",     \"01/14/2021\",      \"\",\n        \"BUS500\",      \"Ethics and Law\",      \"Filip Andries\",       \"01/13/2021\",      \"\",\n        \"BUS540\",      \"Capstone\",            \"\",                    \"01/13/2021\",      \"\"]);\n    await gu.undo();\n  });\n\n  it(\"should clean mapping when skipped\", async function() {\n    // Import UploadedData2 to have a destination table.\n    await gu.importFileDialog(\"./uploads/UploadedData2.csv\");\n    await waitForDiffPreviewToLoad();\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitAppFocus();\n\n    // Reimport\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv,./uploads/UploadedData2.csv\");\n    await waitForDiffPreviewToLoad();\n\n    // Skip first table\n    await driver.find(\".test-importer-target-skip\").click();\n\n    // Select second table and add mapping to update existing records.\n    await driver.find(\".test-importer-source-not-selected\").click();\n    await driver.findContent(\".test-importer-target-existing-table\", /UploadedData2/).click();\n\n    await waitForDiffPreviewToLoad();\n    await waitForColumnMapping();\n    await driver.find(\".test-importer-update-existing-records\").click();\n    await driver.find(\".test-importer-merge-fields-select\").click();\n    await driver.findWait(\".test-multi-select-menu .test-multi-select-menu-option\", 100);\n    await driver.findContent(\n      \".test-multi-select-menu .test-multi-select-menu-option\",\n      /CourseId/,\n    ).click();\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForServer();\n\n    // Now skip and make sure options are hidden\n    await openTableMapping();\n    await driver.find(\".test-importer-target-skip\").click();\n\n    // And unskip, and make sure options are back, but not filled\n    await driver.findContent(\".test-importer-target-existing-table\", /UploadedData2/).click();\n    await waitForDiffPreviewToLoad();\n\n    await waitForColumnMapping();\n    assert.isTrue(await driver.find(\".test-importer-update-existing-records\").isPresent());\n    assert.isTrue(await driver.find(\".test-importer-merge-fields-select\").isPresent());\n    assert.isTrue(await driver.find(\".test-importer-merge-fields-message\").isPresent());\n    assert.equal(await driver.find(\".test-importer-merge-fields-select\").getText(),\n      \"Select fields to match on\");\n\n    await driver.find(\".test-modal-cancel\").click();\n    await gu.waitAppFocus();\n    await gu.undo(2); // Press two times, as we cancelled and import hasn't cleaned temps.\n  });\n\n  it(\"should disable import button when all tables are skipped\", async function() {\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv,./uploads/UploadedData2.csv\");\n    await waitForDiffPreviewToLoad();\n    // Make sure both previews are available\n    for (const source of await driver.findAll(\".test-importer-source\")) {\n      await source.click();\n      assert.isFalse(await driver.find(\".test-importer-preview-overlay\").isPresent());\n    }\n    const sources = await driver.findAll(\".test-importer-source\");\n    // Skip both tables.\n    for (const source of sources) {\n      await source.click();\n      await gu.waitForServer();\n      await driver.find(\".test-importer-target-skip\").click();\n      await gu.waitForServer();\n    }\n    assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n    // Make sure both previews are grayed out\n    for (const source of sources) {\n      await source.click();\n      assert.isTrue(await driver.find(\".test-importer-preview-overlay\").isPresent());\n    }\n\n    // Enable first, and test if one is grayed out and the second is not.\n    await sources[0].click();\n    await gu.waitForServer();\n    await driver.find(\".test-importer-target-new-table\").click();\n    await gu.waitForServer();\n    await waitForDiffPreviewToLoad();\n    assert.isFalse(await driver.find(\".test-importer-preview-overlay\").isPresent());\n    assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), null);\n\n    // Second should be still grayed out\n    await sources[1].click();\n    assert.isTrue(await driver.find(\".test-importer-preview-overlay\").isPresent());\n\n    await driver.find(\".test-modal-cancel\").click();\n    await gu.waitAppFocus();\n  });\n\n  describe(\"when importing JSON\", async function() {\n    // A previous bug caused an error to be thrown when finishing importing a nested JSON file.\n    it(\"should import successfully to new tables\", async function() {\n      // Import a nested JSON file.\n      await gu.importFileDialog(\"./uploads/names.json\");\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n\n      // Check that two preview tables were created.\n      assert.lengthOf(await driver.findAll(\".test-importer-source\"), 2);\n      assert.equal(\n        await driver.find(\".test-importer-source[class*=-selected] .test-importer-from\").getText(),\n        \"names - names.json\",\n      );\n      assert.deepEqual(\n        await driver.findAll(\".test-importer-source .test-importer-from\", e => e.getText()),\n        [\"names - names.json\", \"names_name - names.json\"],\n      );\n\n      // Check that the first table looks ok.\n      assert.deepEqual(await gu.getPreviewContents([0], [1, 2, 3]), [\"[1]\", \"[2]\", \"\"]);\n\n      // Check that the second table looks ok.\n      await driver.findContent(\".test-importer-source\", /names_name/).click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getPreviewContents([0], [1, 2, 3]), [\"Bob\", \"Alice\", \"\"]);\n\n      // Finish import, and verify the import succeeded.\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getPageNames(), [\n        \"Table1\",\n        \"names\",\n        \"names_name\",\n      ]);\n\n      // Verify data was imported to Names correctly.\n      assert.deepEqual(\n        await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0] }),\n        [\"Names_name[1]\", \"Names_name[2]\", \"\"],\n      );\n\n      // Open the side panel and check that the column type for 'name' is Reference (pointing to 'first').\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await driver.find(\".test-right-tab-field\").click();\n      assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Reference\");\n      assert.equal(await gu.getRefTable(), \"names_name\");\n      assert.equal(await gu.getRefShowColumn(), \"Row ID\");\n\n      // Verify data was imported to Names_name correctly.\n      await gu.getPageItem(\"names_name\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3], cols: [0] }), [\"Bob\", \"Alice\", \"\"]);\n    });\n\n    it(\"should import successfully to existing tables with references\", async function() {\n      // Import the same nested JSON file again.\n      await gu.importFileDialog(\"./uploads/names.json\");\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n\n      // Change the destination of both source tables to the existing destination ones.\n      await driver.findContent(\".test-importer-target-existing-table\", /Names/).click();\n      await gu.waitForServer();\n      // Now on the second tab.\n      await driver.findContent(\".test-importer-source\", /names_name/).click();\n      await driver.findContent(\".test-importer-target-existing-table\", /Names_name/).click();\n      await gu.waitForServer();\n\n      // Finish import, and verify the import succeeded.\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getPageNames(), [\n        \"Table1\",\n        \"names\",\n        \"names_name\",\n      ]);\n\n      // Verify data was imported to Names correctly.\n      assert.deepEqual(\n        await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4, 5], cols: [0] }),\n        [\"Names_name[1]\", \"Names_name[2]\", \"Names_name[1]\", \"Names_name[2]\", \"\"],\n      );\n\n      // Open the side panel and check that the column type for 'name' is Reference (pointing to 'first').\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await driver.find(\".test-right-tab-field\").click();\n      assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Reference\");\n      assert.equal(await gu.getRefTable(), \"names_name\");\n      assert.equal(await gu.getRefShowColumn(), \"Row ID\");\n\n      // Verify data was imported to Names_name correctly.\n      await gu.getPageItem(\"names_name\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getVisibleGridCells(\n        { rowNums: [1, 2, 3, 4, 5], cols: [0] }),\n      [\"Bob\", \"Alice\", \"Bob\", \"Alice\", \"\"],\n      );\n\n      // Undo the last 2 imports.\n      await gu.undo(2);\n    });\n  });\n\n  describe(\"when matching columns\", async function() {\n    it(\"should not display column matching section for new destinations\", async function() {\n      // Import an Excel file with multiple sheets.\n      await gu.importFileDialog(\"./uploads/World-v0.xlsx\");\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n\n      // Check that the column matching section is not shown.\n      assert.isFalse(await driver.find(\".test-importer-column-match-options\").isPresent());\n    });\n\n    it(\"should display column matching section for existing destinations\", async function() {\n      // From the previous test: finish importing World-v1.xlsx so we have tables to import to.\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer(10_000);\n\n      // Import the same file again.\n      await gu.importFileDialog(\"./uploads/World-v0.xlsx\");\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n\n      // Change the destination of the selected sheet to the table created earlier.\n      await driver.findContent(\".test-importer-target-existing-table\", /Table1_2/).click();\n      await gu.waitForServer();\n\n      await waitForColumnMapping();\n      // Check that source and destination are populated for each column from the first sheet.\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { destination: \"a\", source: \"a\" },\n        { destination: \"b\", source: \"b\" },\n        { destination: \"c\", source: \"c\" },\n        { destination: \"d\", source: \"d\" },\n        { destination: \"E\", source: \"E\" },\n      ]);\n      assert.isFalse(await driver.find(\".test-importer-unmatched-fields\").isPresent());\n\n      // Switch to the City sheet, and check that the column matching section is no longer shown.\n      await driver.findContent(\".test-importer-source\", /City/).click();\n      await gu.waitForServer();\n      assert.isFalse(await driver.find(\".test-importer-column-match-options\").isPresent());\n\n      // Change the destination to 'City', and now check that the section is shown.\n      await driver.findContent(\".test-importer-target-existing-table\", /City/).click();\n      await gu.waitForServer(10_000);\n\n      await waitForColumnMapping();\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { destination: \"Name\", source: \"Name\" },\n        { destination: \"District\", source: \"District\" },\n        { destination: \"Population\", source: \"Population\" },\n        { destination: \"Country\", source: \"Country\" },\n        { destination: \"Pop. '000\", source: \"Pop. '000\" },\n      ]);\n      assert.isFalse(await driver.find(\".test-importer-unmatched-fields\").isPresent());\n    });\n\n    it(\"should allow skipping importing columns\", async function() {\n      // Starting from the City sheet, open the menu for \"Pop. '000\".\n      await driver.findContent(\".test-importer-column-match-source\", /Pop\\. '000/).click();\n\n      // Check that the menu contains only the selected source column, plus a 'Skip' option.\n      const menu = gu.findOpenMenu();\n      assert.deepEqual(\n        await menu.findAll(\".test-importer-column-match-menu-item\", el => el.getText()),\n        [\"Skip\", \"Pop. '000\"],\n      );\n\n      // Click 'Skip', and check that the column mapping section and preview both updated.\n      await menu.findContentWait(\".test-importer-column-match-menu-item\", /Skip/, 100).click();\n      await gu.waitForServer();\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { destination: \"Name\", source: \"Name\" },\n        { destination: \"District\", source: \"District\" },\n        { destination: \"Population\", source: \"Population\" },\n        { destination: \"Country\", source: \"Country\" },\n        { destination: \"Pop. '000\", source: \"Skip\" },\n      ]);\n      assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6]), [\n        \"Kabul\",  \"Kabol\",  \"1780000\",  \"2\",  \"0\",\n        \"Qandahar\",  \"Qandahar\",  \"237500\",  \"2\",  \"0\",\n        \"Herat\",  \"Herat\",  \"186800\",  \"2\",  \"0\",\n        \"Mazar-e-Sharif\",  \"Balkh\",  \"127800\",  \"2\",  \"0\",\n        \"Amsterdam\",  \"Noord-Holland\",  \"731200\",  \"159\",  \"0\",\n        \"Rotterdam\",  \"Zuid-Holland\",  \"593321\",  \"159\",  \"0\",\n      ]);\n\n      // Check that a message is now shown about there being 1 unmapped field.\n      assert.equal(\n        await driver.find(\".test-importer-unmatched-fields\").getText(),\n        \"1 unmatched field in import:\\nPop. '000\",\n      );\n\n      // Click Country in the column mapping section, and clear the formula.\n      await driver.findContent(\".test-importer-column-match-source\", /Country/).click();\n      await driver.findWait(\".test-importer-apply-formula\", 100).click();\n      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, Key.ENTER);\n      await gu.waitForServer();\n\n      // Check that the column mapping section and preview now show that it will be skipped.\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { destination: \"Name\", source: \"Name\" },\n        { destination: \"District\", source: \"District\" },\n        { destination: \"Population\", source: \"Population\" },\n        { destination: \"Country\", source: \"Skip\" },\n        { destination: \"Pop. '000\", source: \"Skip\" },\n      ]);\n      assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6]), [\n        \"Kabul\",  \"Kabol\",  \"1780000\",  \"0\",  \"0\",\n        \"Qandahar\",  \"Qandahar\",  \"237500\",  \"0\",  \"0\",\n        \"Herat\",  \"Herat\",  \"186800\",  \"0\",  \"0\",\n        \"Mazar-e-Sharif\",  \"Balkh\",  \"127800\",  \"0\",  \"0\",\n        \"Amsterdam\",  \"Noord-Holland\",  \"731200\",  \"0\",  \"0\",\n        \"Rotterdam\",  \"Zuid-Holland\",  \"593321\",  \"0\",  \"0\",\n      ]);\n      assert.equal(\n        await driver.find(\".test-importer-unmatched-fields\").getText(),\n        \"2 unmatched fields in import:\\nCountry, Pop. '000\",\n      );\n    });\n\n    it(\"should autocomplete formula in source\", async function() {\n      // Starting from the City sheet, open the menu for \"Pop. '000\".\n      await openSourceFor(/Pop\\. '000/);\n\n      // We want to map the same column twice, which is not possible through the menu, so we will\n      // use the formula.\n      await driver.findWait(\".test-importer-apply-formula\", 100).click();\n      await gu.sendKeys(await gu.selectAllKey(), Key.DELETE, \"$Population\", Key.ENTER);\n      await gu.waitForServer();\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { source: \"Name\", destination: \"Name\" },\n        { source: \"District\", destination: \"District\" },\n        { source: \"Population\", destination: \"Population\" },\n        { source: \"Skip\", destination: \"Country\" },\n        { source: \"$Population\\n\", destination: \"Pop. '000\" },\n      ]);\n      assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6]), [\n        \"Kabul\",  \"Kabol\",  \"1780000\",  \"0\",  \"1780000\",\n        \"Qandahar\",  \"Qandahar\",  \"237500\",  \"0\",  \"237500\",\n        \"Herat\",  \"Herat\",  \"186800\",  \"0\",  \"186800\",\n        \"Mazar-e-Sharif\",  \"Balkh\",  \"127800\",  \"0\",  \"127800\",\n        \"Amsterdam\",  \"Noord-Holland\",  \"731200\",  \"0\",  \"731200\",\n        \"Rotterdam\",  \"Zuid-Holland\",  \"593321\",  \"0\",  \"593321\",\n      ]);\n      assert.equal(\n        await driver.find(\".test-importer-unmatched-fields\").getText(),\n        \"1 unmatched field in import:\\nCountry\",\n      );\n\n      // Click Country (with formula 'Skip') in the column mapping section, and start typing a formula.\n      await openSourceFor(/Country/);\n      await driver.findWait(\".test-importer-apply-formula\", 100).click();\n      await gu.sendKeys(\"$\");\n      await gu.waitForServer();\n\n      // Wait until the Ace autocomplete menu is shown.\n      await driver.wait(() => driver.find(\"div.ace_autocomplete\").isDisplayed(), 2000);\n\n      // Check that the autocomplete is suggesting column ids from the imported table.\n      const completions = await driver.findAll(\n        \"div.ace_autocomplete div.ace_line\", async el => (await el.getText()).split(\" \")[0],\n      );\n      await gu.waitToPass(async () => {\n        assert.deepEqual(\n          completions.slice(0, 6),\n          [\n            \"$\\nCountry\",\n            \"$\\nDistrict\",\n            \"$\\nid\",\n            \"$\\nName\",\n            \"$\\nPop_000\",\n            \"$\\nPopulation\",\n          ],\n        );\n      }, 2000);\n\n      // Set a constant value for the formula.\n      await gu.sendKeys(Key.BACK_SPACE, \"123\", Key.ENTER);\n      await gu.waitForServer();\n\n      // Check that the formula code is shown, as well as the evaluation result in the preview.\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { source: \"Name\", destination: \"Name\" },\n        { source: \"District\", destination: \"District\" },\n        { source: \"Population\", destination: \"Population\" },\n        { source: \"123\\n\", destination: \"Country\" },\n        { source: \"$Population\\n\", destination: \"Pop. '000\" },\n      ]);\n\n      assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6]), [\n        \"Kabul\",  \"Kabol\",  \"1780000\",  \"123\",  \"1780000\",\n        \"Qandahar\",  \"Qandahar\",  \"237500\",  \"123\",  \"237500\",\n        \"Herat\",  \"Herat\",  \"186800\",  \"123\",  \"186800\",\n        \"Mazar-e-Sharif\",  \"Balkh\",  \"127800\",  \"123\",  \"127800\",\n        \"Amsterdam\",  \"Noord-Holland\",  \"731200\",  \"123\",  \"731200\",\n        \"Rotterdam\",  \"Zuid-Holland\",  \"593321\",  \"123\",  \"593321\",\n      ]);\n      assert.isFalse(await driver.find(\".test-importer-unmatched-fields\").isPresent());\n    });\n\n    it(\"should reflect mappings when import to new table is finished\", async function() {\n      // Skip 'Population', so that we can test imports with skipped columns.\n      await openSourceFor(/Population/);\n      await driver.findContentWait(\".test-importer-column-match-menu-item\", \"Skip\", 100).click();\n      await gu.waitForServer();\n\n      // Finish importing, and check that the destination tables have the correct data.\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer(10_000);\n      assert.deepEqual(await gu.getPageNames(), [\n        \"Table1\",\n        \"Table1\",\n        \"City\",\n        \"Country\",\n        \"CountryLanguage\",\n        \"Country\",\n        \"CountryLanguage\",\n      ]);\n\n      // Check the contents of Table1; it should have duplicates of the original 2 rows.\n      assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [1, 2, 3, 4, 5], cols: [0, 1, 2, 3, 4] }),\n        [\n          \"hello\", \"\", \"\", \"\", \"HELLO\",\n          \"\", \"world\", \"\", \"\", \"\",\n          \"hello\", \"\", \"\", \"\", \"HELLO\",\n          \"\", \"world\", \"\", \"\", \"\",\n          \"\", \"\", \"\", \"\", \"\",\n        ],\n      );\n\n      await gu.getPageItem(\"City\").click();\n      await gu.waitForServer();\n\n      // The first half should be the original imported rows.\n      assert.deepEqual(await gu.getVisibleGridCells(\n        { cols: [0, 1, 2, 3, 4], rowNums: [1, 2, 3, 4, 5, 6] }),\n      [\n        \"Kabul\", \"Kabol\", \"1780000\", \"2\", \"1780\",\n        \"Qandahar\", \"Qandahar\", \"237500\", \"2\", \"237.5\",\n        \"Herat\", \"Herat\", \"186800\", \"2\", \"186.8\",\n        \"Mazar-e-Sharif\", \"Balkh\", \"127800\", \"2\", \"127.8\",\n        \"Amsterdam\", \"Noord-Holland\", \"731200\", \"159\", \"731.2\",\n        \"Rotterdam\", \"Zuid-Holland\", \"593321\", \"159\", \"593.321\",\n      ],\n      );\n\n      // The second half should be the newly imported rows with custom mappings.\n      assert.equal(await gu.getGridRowCount(), 8159);\n      assert.deepEqual(await gu.getVisibleGridCells(\n        { cols: [0, 1, 2, 3, 4], rowNums: [8152, 8153, 8154, 8155, 8156, 8157] }),\n      [\n        \"Gweru\", \"Midlands\", \"0\", \"123\", \"128037\",\n        \"Gaza\", \"Gaza\", \"0\", \"123\", \"353632\",\n        \"Khan Yunis\", \"Khan Yunis\", \"0\", \"123\", \"123175\",\n        \"Hebron\", \"Hebron\", \"0\", \"123\", \"119401\",\n        \"Jabaliya\", \"North Gaza\", \"0\", \"123\", \"113901\",\n        \"Nablus\", \"Nablus\", \"0\", \"123\", \"100231\",\n      ],\n      );\n    });\n\n    it(\"should reflect mappings in previews of incremental imports\", async function() {\n      // Delete the first row of the Country column. (Needed for a later assertion.)\n      await gu.sendKeys(Key.chord(await gu.modKey(), Key.UP));\n      await gu.getCell(3, 1).click();\n      await gu.sendKeys(Key.DELETE);\n      await gu.waitForServer();\n\n      // Import a CSV file containing city data, with column names that differ from the City table.\n      await gu.importFileDialog(\"./uploads/Cities.csv\");\n      assert.equal(await driver.findWait(\".test-importer-preview\", 2000).isPresent(), true);\n\n      // Change the destination to City, and check that column mapping defaults to skipping all columns.\n      await driver.findContent(\".test-importer-target-existing-table\", /City/).click();\n      await gu.waitForServer();\n      await waitForColumnMapping();\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { source: \"Skip\", destination: \"Name\" },\n        { source: \"Skip\", destination: \"District\" },\n        { source: \"Skip\", destination: \"Population\" },\n        { source: \"Skip\", destination: \"Country\" },\n        { source: \"Skip\", destination: \"Pop. '000\" },\n      ]);\n\n      assert.equal(\n        await driver.find(\".test-importer-unmatched-fields\").getText(),\n        \"5 unmatched fields in import:\\nName, District, Population, Country, Pop. '000\",\n      );\n\n      // Set formula for 'Name' to 'city_name' by typing in the formula.\n      await openSourceFor(/Name/);\n      await driver.findWait(\".test-importer-apply-formula\", 100).click();\n      await gu.sendKeys(\"$city_name\", Key.ENTER);\n      await gu.waitForServer();\n\n      // Map 'District' to 'city_district' via the column mapping menu.\n      await openSourceFor(\"District\");\n      const menu = gu.findOpenMenu();\n      await menu.findContentWait(\".test-importer-column-match-menu-item\", /city_district/, 100).click();\n      await gu.waitForServer();\n\n      // Check the column mapping section and preview both updated correctly.\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { source: \"$city_name\\n\", destination: \"Name\" },\n        { source: \"city_district\", destination: \"District\" },\n        { source: \"Skip\", destination: \"Population\" },\n        { source: \"Skip\", destination: \"Country\" },\n        { source: \"Skip\", destination: \"Pop. '000\" },\n      ]);\n      assert.deepEqual(await gu.getPreviewContents([0, 1, 2, 3, 4], [1, 2, 3, 4, 5, 6]), [\n        \"Kabul\",  \"Kabol\",  \"0\",  \"0\",  \"0\",\n        \"Qandahar\",  \"Qandahar\",  \"0\",  \"0\",  \"0\",\n        \"Herat\",  \"Herat\",  \"0\",  \"0\",  \"0\",\n        \"Mazar-e-Sharif\",  \"Balkh\",  \"0\",  \"0\",  \"0\",\n        \"Amsterdam\",  \"Noord-Holland\",  \"0\",  \"0\",  \"0\",\n        \"Rotterdam\",  \"Zuid-Holland\",  \"0\",  \"0\",  \"0\",\n      ]);\n      assert.equal(\n        await driver.find(\".test-importer-unmatched-fields\").getText(),\n        \"3 unmatched fields in import:\\nPopulation, Country, Pop. '000\",\n      );\n\n      // Now toggle 'Update existing records', and merge on 'Name' and 'District'.\n      await driver.find(\".test-importer-update-existing-records\").click();\n      await driver.find(\".test-importer-merge-fields-select\").click();\n      await driver.findWait(\".test-multi-select-menu .test-multi-select-menu-option\", 100);\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Name/,\n      ).click();\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /District/,\n      ).click();\n      await gu.sendKeys(Key.ESCAPE);\n      await gu.waitForServer();\n\n      // Check that the column mapping section and preview updated correctly.\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { source: \"$city_name\\n\", destination: \"Name\" },\n        { source: \"city_district\", destination: \"District\" },\n        { source: \"Skip\", destination: \"Population\" },\n        { source: \"Skip\", destination: \"Country\" },\n        { source: \"Skip\", destination: \"Pop. '000\" },\n      ]);\n      await waitForDiffPreviewToLoad();\n      await driver.findContentWait(\".test-importer-preview .field_clip\", \"Kabul\", 100);\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2, 3, 4], [1, 2, 3, 4, 5]), [\n        \"Kabul\", \"Kabol\", [undefined, undefined, \"1780000\"], \"\", [undefined, undefined, \"1780\"],\n        \"Qandahar\", \"Qandahar\", [undefined, undefined, \"237500\"], [undefined, undefined, \"2\"],\n        [undefined, undefined, \"237.5\"],\n        \"Herat\", \"Herat\", [undefined, undefined, \"186800\"], [undefined, undefined, \"2\"],\n        [undefined, undefined, \"186.8\"],\n        \"Mazar-e-Sharif\", \"Balkh\", [undefined, undefined, \"127800\"], [undefined, undefined, \"2\"],\n        [undefined, undefined, \"127.8\"],\n        \"Amsterdam\", \"Noord-Holland\", [undefined, undefined, \"731200\"], [undefined, undefined, \"159\"],\n        [undefined, undefined, \"731.2\"],\n      ]);\n      assert.equal(\n        await driver.find(\".test-importer-unmatched-fields\").getText(),\n        \"3 unmatched fields in import:\\nPopulation, Country, Pop. '000\",\n      );\n\n      // Map the remaining columns, except \"Country\"; we'll leave it skipped to check that\n      // we don't overwrite any values in the destination table. (A previous bug caused non-text\n      // skipped columns to overwrite data with default values, like 0.)\n      await openSourceFor(/Population/);\n      await driver.findWait(\".test-importer-apply-formula\", 100).click();\n      await gu.sendKeys(\"$city_pop\", Key.ENTER);\n      await gu.waitForServer();\n\n      // For \"Pop. '000\", deliberately map a duplicate column (so we can later check if import succeeded).\n      await openSourceFor(/Pop\\. '000/);\n      await driver.findWait(\".test-importer-apply-formula\", 100).click();\n      await gu.waitAppFocus(false);\n      await gu.sendKeys(\"$city_pop\", Key.ENTER);\n      await gu.waitForServer();\n\n      assert.deepEqual(await getColumnMatchingRows(), [\n        { source: \"$city_name\\n\",      destination: \"Name\" },\n        { source: \"city_district\",   destination: \"District\" },\n        { source: \"$city_pop\\n\",       destination: \"Population\" },\n        { source: \"Skip\",            destination: \"Country\" },\n        { source: \"$city_pop\\n\",       destination: \"Pop. '000\" },\n      ]);\n\n      await waitForDiffPreviewToLoad();\n      assert.deepEqual(await getPreviewDiffCellValues([0, 1, 2, 3, 4], [1, 2, 3, 4, 5]), [\n        // Kabul's Country column should appear blank, since we deleted it earlier.\n        \"Kabul\", \"Kabol\", [\"1780000\", \"3560000\", undefined], \"\", [\"1780\", \"3560000\", undefined],\n        \"Qandahar\", \"Qandahar\", [\"237500\", \"475000\", undefined], [undefined, undefined, \"2\"],\n        [\"237.5\", \"475000\", undefined],\n        \"Herat\", \"Herat\", [\"186800\", \"373600\", undefined], [undefined, undefined, \"2\"],\n        [\"186.8\", \"373600\", undefined],\n        \"Mazar-e-Sharif\", \"Balkh\", [\"127800\", \"255600\", undefined], [undefined, undefined, \"2\"],\n        [\"127.8\", \"255600\", undefined],\n        \"Amsterdam\", \"Noord-Holland\", [\"731200\", \"1462400\", undefined], [undefined, undefined, \"159\"],\n        [\"731.2\", \"1462400\", undefined],\n      ]);\n      assert.equal(\n        await driver.find(\".test-importer-unmatched-fields\").getText(),\n        \"1 unmatched field in import:\\nCountry\",\n      );\n    });\n\n    it(\"should reflect mappings when incremental import is finished\", async function() {\n      // Finish importing, and check that the destination table has the correct data.\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer(10_000);\n      assert.deepEqual(await gu.getPageNames(), [\n        \"Table1\",\n        \"Table1\",\n        \"City\",\n        \"Country\",\n        \"CountryLanguage\",\n        \"Country\",\n        \"CountryLanguage\",\n      ]);\n\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2, 3, 4], rowNums: [1, 2, 3, 4, 5] }),\n        [\n          // Kabul's Country column should still be blank, since we skipped it earlier.\n          \"Kabul\", \"Kabol\", \"3560000\", \"\", \"3560000\",\n          \"Qandahar\", \"Qandahar\", \"475000\", \"2\", \"475000\",\n          \"Herat\", \"Herat\", \"373600\", \"2\", \"373600\",\n          \"Mazar-e-Sharif\", \"Balkh\", \"255600\", \"2\", \"255600\",\n          \"Amsterdam\", \"Noord-Holland\", \"1462400\", \"159\", \"1462400\",\n        ],\n      );\n    });\n  });\n});\n\n// Wait until the formula editor is open or closed.\nasync function checkFormulaEditor(open: boolean, timeout: number = 2000): Promise<void> {\n  await gu.waitToPass(async () => {\n    assert.equal(await driver.find(\".test-formula-editor\").isPresent(), open);\n  }, timeout);\n}\n\nconst waitForFormulaEditor = checkFormulaEditor.bind(null, true);\nconst waitForFormulaEditorToClose = checkFormulaEditor.bind(null, false);\n"
  },
  {
    "path": "test/nbrowser/LanguageSettings.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, createDriver, driver, WebDriver } from \"mocha-webdriver\";\n\ndescribe(\"LanguageSettings\", function() {\n  this.timeout(\"50s\");\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    if (server.isExternalServer()) {\n      this.skip();\n    }\n  });\n\n  // List of languages that chrome supports https://developer.chrome.com/docs/webstore/i18n/#localeTable\n  const locales = [ // [language to set in the browser, country code detected, language name detected]\n    [\"fr\", \"FR\", \"Français\"],\n    [\"te\", \"US\", \"English\"], // Telugu is not supported yet, so Grist should fallback to English (US).\n    [\"en\", \"US\", \"English\"], // This is a default language for Grist.\n    [\"pt-BR\", \"BR\", \"Português (Brasil)\"],\n  ];\n\n  for (const [locale, countryCode, language] of locales) {\n    describe(`correctly detects browser language ${locale}`, () => {\n      // Change the language to the one we want to test.\n      const skipStatus = withLang(locale);\n      before(async function() {\n        if (skipStatus.skipped) { return; }\n        const session = await gu.session().personalSite.anon.login();\n        await session.loadRelPath(\"/\");\n        await gu.waitForDocMenuToLoad();\n      });\n      it(\"shows correct language from browser settings\", async () => {\n        // Find the button to switch the language.\n        const button = await langButton();\n        assert.isTrue(await button.isDisplayed());\n        // Make sure correct flag is shown.\n        const flag = await button.find(\".test-language-button-icon\").getCssValue(\"background-image\");\n        assert.isTrue(flag.endsWith(countryCode + '.svg\")'), `Flag is ${flag} search for ${countryCode}`);\n        // Make sure we see the all languages in the menu.\n        await button.click();\n        const menu = await gu.currentDriver().findWait(\".grist-floating-menu\", 200);\n        const allLangues = (await menu.findAll(\"li\", e => e.getText())).map(l => l.toLowerCase());\n        for (const [, , language] of locales) {\n          assert.include(allLangues, language.toLowerCase());\n        }\n        // Make sure that this language is selected.\n        assert.equal(await selectedLang(), language.toLowerCase());\n        // Smoke test that we see the correct language.\n        const welcomeText = await gu.currentDriver().find(\".test-welcome-title\").getText();\n        if (locale === \"en\") {\n          assert.equal(welcomeText, \"Welcome to Grist!\");\n        } else if (locale === \"fr\") {\n          assert.equal(welcomeText, \"Bienvenue sur Grist !\");\n        }\n      });\n    });\n  }\n\n  describe(\"for Anonymous\", function() {\n    before(async function() {\n      const session = await gu.session().personalSite.anon.login();\n      await session.loadRelPath(\"/\");\n      await gu.waitForDocMenuToLoad();\n    });\n    it(\"allows anonymous user to switch a language\", async () => {\n      await langButton().click();\n      // By default we have English (US) selected.\n      assert.equal(await selectedLang(), \"english\");\n      // Change to French.\n      await gu.currentDriver().findWait(\".test-language-lang-fr\", 200).click();\n      // We will be reloaded, so wait until we see the new language.\n      await waitForLangButton(\"fr\");\n      // Now we have a cookie with the language selected, so reloading the page should keep it.\n      await gu.currentDriver().navigate().refresh();\n      await gu.waitForDocMenuToLoad();\n      await waitForLangButton(\"fr\");\n      assert.equal(await languageInCookie(), \"fr\");\n      // Switch to German.\n      await langButton().click();\n      await gu.currentDriver().findWait(\".test-language-lang-de\", 200).click();\n      await waitForLangButton(\"de\");\n      // Make sure we see new cookie.\n      assert.equal(await languageInCookie(), \"de\");\n      // Remove the cookie and reload.\n      await clearCookie();\n      await gu.currentDriver().navigate().refresh();\n      await gu.waitForDocMenuToLoad();\n      // Make sure we see the default language.\n      await waitForLangButton(\"en\");\n      // Test if changing the cookie is reflected in the UI. This cookie is available for javascript.\n      await setCookie(\"fr\");\n      await gu.currentDriver().navigate().refresh();\n      await gu.waitForDocMenuToLoad();\n      await waitForLangButton(\"fr\");\n      assert.equal(await languageInCookie(), \"fr\");\n      // Go back to English.\n      await clearCookie();\n      await gu.currentDriver().navigate().refresh();\n      await gu.waitForDocMenuToLoad();\n    });\n    it(\"when user is logged in the language is still taken from the cookie\", async () => {\n      await langButton().click();\n      // By default we have English (US) selected ()\n      assert.equal(await selectedLang(), \"english\");\n\n      // Now login to the account. Make sure it's a fresh one, in case previous test suites\n      // changed any preferences.\n      const user = await gu.session().personalSite.user(\"fresh\").login({ freshAccount: true });\n      await user.loadRelPath(\"/\");\n      await gu.waitForDocMenuToLoad();\n      // Language should still be english.\n      await waitForHiddenButton(\"en\");\n      // And we should not have a cookie.\n      assert.isNull(await languageInCookie());\n\n      // Go back to anonymous.\n      const anonym = await gu.session().personalSite.anon.login();\n      await anonym.loadRelPath(\"/\");\n      await gu.waitForDocMenuToLoad();\n      assert.isNull(await languageInCookie());\n\n      // Change language to french.\n      await langButton().click();\n      await driver.findWait(\".test-language-lang-fr\", 200).click();\n      await waitForLangButton(\"fr\");\n      assert.equal(await languageInCookie(), \"fr\");\n\n      // Login as user.\n      await user.login();\n      await anonym.loadRelPath(\"/\");\n      await gu.waitForDocMenuToLoad();\n      // But now we should have a cookie (cookie is reused).\n      assert.equal(await languageInCookie(), \"fr\");\n\n      // Language should still be french.\n      await waitForHiddenButton(\"fr\");\n      await clearCookie();\n    });\n  });\n\n  describe(\"for logged in user with nb-NO\", function() {\n    const skipStatus = withLang(\"de\");\n    let session: gu.Session;\n    before(async function() {\n      if (skipStatus.skipped) { return; }\n      session = await gu.session().login();\n      await session.loadRelPath(\"/\");\n      await gu.waitForDocMenuToLoad();\n    });\n    after(async function() {\n      if (skipStatus.skipped) { return; }\n      await clearCookie();\n      const api = session.createHomeApi();\n      await api.updateUserLocale(null);\n    });\n    it(\"profile page detects correct language\", async () => {\n      const driver = gu.currentDriver();\n      // Make sure we don't have a cookie yet.\n      assert.isNull(await languageInCookie());\n      // Or a saved setting.\n      let gristConfig: any = await driver.executeScript(\"return window.gristConfig\");\n      assert.isNull(gristConfig.userLocale);\n      await gu.openProfileSettingsPage();\n      // Make sure we see the correct language.\n      assert.equal(await languageMenu().getText(), \"Deutsch\");\n      // Make sure we see hidden indicator.\n      await waitForHiddenButton(\"de\");\n      // Change language to nb-.NO\n      await languageMenu().click();\n      await gu.findOpenMenuItem(\"li\",  \"Norsk bokmål (Norge)\", 100).click();\n      // This is api call and we will be reloaded, so wait for the hidden indicator.\n      await waitForHiddenButton(\"nb-NO\");\n      // Now we should have a cookie.\n      assert.equal(await languageInCookie(), \"nb-NO\");\n      // And the gristConfig should have this language.\n      gristConfig = await driver.executeScript(\"return window.gristConfig\");\n      assert.equal(gristConfig.userLocale, \"nb-NO\");\n      // If we remove the cookie, we should still use the gristConfig.\n      await clearCookie();\n      await driver.navigate().refresh();\n      await waitForHiddenButton(\"nb-NO\");\n      // If we set a different cookie, we should still use the saved setting.\n      await setCookie(\"de\");\n      await driver.navigate().refresh();\n      await waitForHiddenButton(\"nb-NO\");\n      // Make sure this works on the document, by adding a new doc and smoke testing the Add New button.\n      await session.tempNewDoc(cleanup, \"Test\");\n      assert.equal(await driver.findWait(\".test-dp-add-new\", 3000).getText(), \"Legg til ny\");\n    });\n  });\n});\n\nfunction languageMenu() {\n  return gu.currentDriver().find(\".test-account-page-language .test-select-open\");\n}\n\nasync function clearCookie() {\n  await gu.currentDriver().executeScript(\n    \"document.cookie = 'grist_user_locale=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';\");\n}\n\nasync function setCookie(locale: string) {\n  await gu.currentDriver().executeScript(\n    `document.cookie = 'grist_user_locale=${locale}; expires=Thu, 01 Jan 2970 00:00:00 UTC; path=/;';`);\n}\n\nasync function waitForLangButton(locale: string) {\n  await gu.waitToPass(async () =>\n    assert.isTrue(await gu.currentDriver().findWait(`.test-language-current-${locale}`, 1000).isDisplayed()), 4000);\n}\n\nasync function waitForHiddenButton(locale: string) {\n  await gu.waitToPass(async () =>\n    assert.isTrue(await gu.currentDriver().findWait(`input.test-language-current-${locale}`, 1000).isPresent()), 4000);\n}\n\nasync function languageInCookie(): Promise<string | null> {\n  const cookie2: string = await gu.currentDriver().executeScript(\"return document.cookie\");\n  return cookie2.match(/grist_user_locale=([^;]+)/)?.[1] ?? null;\n}\n\nfunction withLang(locale: string): { skipped: boolean } {\n  let customDriver: WebDriver;\n  let oldLanguage: string | undefined;\n  const skipStatus = { skipped: false };\n  before(async function() {\n    // On Mac we can't change the language (except for English), so skip the test.\n    if (await gu.isMac() && locale !== \"en\") { skipStatus.skipped = true; return this.skip(); }\n    oldLanguage = process.env.LANGUAGE;\n    // How to run chrome with a different language:\n    // https://developer.chrome.com/docs/extensions/reference/i18n/#how-to-set-browsers-locale\n    process.env.LANGUAGE = locale;\n    customDriver = await createDriver({\n      extraArgs: [\n        \"lang=\" + locale,\n        ...(process.env.MOCHA_WEBDRIVER_HEADLESS ? [`headless=chrome`] : []),\n      ],\n    });\n    server.setDriver(customDriver);\n    gu.setDriver(customDriver);\n    const session = await gu.session().personalSite.anon.login();\n    await session.loadRelPath(\"/\");\n    await gu.waitForDocMenuToLoad();\n  });\n  after(async function() {\n    if (skipStatus.skipped) { return; }\n    gu.setDriver();\n    server.setDriver();\n    await customDriver.quit();\n    process.env.LANGUAGE = oldLanguage;\n  });\n  return skipStatus;\n}\n\nfunction langButton() {\n  return gu.currentDriver().findWait(\".test-language-button\", 500);\n}\n\nasync function selectedLang() {\n  const menu = gu.currentDriver().findWait(\".grist-floating-menu\", 100);\n  return (await menu.find(\".test-language-selected\").findClosest(\"li\").getText()).toLowerCase();\n}\n"
  },
  {
    "path": "test/nbrowser/LazyLoad.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupCleanup, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert } from \"mocha-webdriver\";\n\ndescribe(\"LazyLoad\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n  let api: UserAPI;\n\n  before(async () => {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    api = gu.createHomeApi(\"Chimpy\", \"nasa\");\n  });\n\n  const cleanup = setupCleanup();\n\n  // NOTE: This test used to test that \"Loading...\" placeholder is shown in formula columns while\n  // they are being calculated on load. Now that we load formula columns from the database, it\n  // tests that the values are immediately shown even though calculation hasn't finished.\n  it(\"can start showing table even if its row data has not arrived yet\", async () => {\n    const wsId = await api.newWorkspace({ name: \"work\" }, \"current\");\n    // Clean up at the end of the suite, to avoid affecting other tests.\n    cleanup.addAfterAll(() => api.deleteWorkspace(wsId));\n\n    const docId = await api.newDoc({ name: \"testdoc\" }, wsId);\n    // formula takes 1.5 seconds to evaluate.\n    const formula =\n      \"import time\\n\" +\n      \"time.sleep(1.5)\\n\" +\n      'return \"42:%s\" % int(time.time())';\n    await api.applyUserActions(docId, [[\"AddTable\", \"Foo\", [\n      { id: \"B\", type: \"Any\", formula, isFormula: true },\n    ]]]);\n    await api.applyUserActions(docId, [[\"AddTable\", \"Bar\", [\n      { id: \"B\", type: \"Any\" },\n      // This formula returns 5, 0, \"X\" for rowIds 1, 2, 3.\n      { id: \"C\", type: \"Numeric\", formula: '[5, 0, \"X\"][$id - 1]', isformula: true },\n    ]]]);\n    // This action takes 1.5 sec because waits for the formula.\n    await api.applyUserActions(docId, [[\"AddRecord\", \"Foo\", 1, {}]]);\n    await api.applyUserActions(docId, [[\"BulkAddRecord\", \"Bar\", [1, 2, 3], { B: [33, 34, 35] }]]);\n    await api.getDocAPI(docId).forceReload();\n    await gu.loadDoc(`/o/nasa/doc/${docId}/p/2`);\n\n    // Metadata about Foo table is known, and it is shown, AND formula data is already available\n    // because it is now loaded from the database.\n    const fooValue1 = await gu.getCell(0, 1, \"FOO\").getText();\n    assert.match(fooValue1, /^42:\\d+$/);\n\n    // We can switch to Bar table, and plain data is available.\n    await gu.openPage(/Bar/);\n    assert.equal(await gu.getCell(0, 1, \"BAR\").getText(), \"33\");\n\n    // Plain data AND formula data are already there.\n    assert.deepEqual(await gu.getVisibleGridCells({ col: \"B\", rowNums: [1, 2, 3], section: \"BAR\" }),\n      [\"33\", \"34\", \"35\"]);\n    assert.deepEqual(await gu.getVisibleGridCells({ col: \"C\", rowNums: [1, 2, 3], section: \"BAR\" }),\n      [\"5\", \"0\", \"X\"]);\n\n    await gu.openPage(/Foo/);\n\n    // The data in the slow-formula cell should still be old, it can't have been 1.5s yet.\n    assert.equal(await gu.getCell(0, 1, \"FOO\").getText(), fooValue1);\n\n    // The new value should be there after the time to load data into the data engine + 1.5s for the formula.\n    await gu.waitToPass(async () => {\n      const fooValue2 = await gu.getCell(0, 1, \"FOO\").getText();\n      assert.match(fooValue2, /^42:\\d+$/);\n      // We test that the value changed after the calculation. This isn't necessarily what we\n      // want (maybe we should NOT update formula values on open), but for now it serves to show the\n      // recalculated values do show up on load.\n      assert.notEqual(fooValue2, fooValue1);\n    }, 1500 + 1500);    // Give it 1.5 to load into the data engine and 1.5 for the formula.\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/LeftPanel.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"LeftPanel\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let mainSession: gu.Session;\n  let docId: string;\n\n  before(async function() {\n    mainSession = await gu.session().teamSite.user(\"user1\").login();\n    docId = await mainSession.tempNewDoc(cleanup, \"LeftPanel.grist\", { load: false });\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should not update session storage when auto-expanding\", async function() {\n    await mainSession.loadDoc(`/doc/${docId}/p/1`);\n\n    // make sure panel is closed\n    await gu.toggleSidePanel(\"left\", \"close\");\n\n    // move mouse in and wait for full expansion\n    await driver.find(\".test-left-panel\").mouseMove();\n    await driver.sleep(500 + 450);\n\n    // check panel is open\n    assert.equal(await gu.isSidePanelOpen(\"left\"), true);\n\n    // move away the cursor to prevent auto-expanding after reload\n    await driver.find(\".test-top-header\").mouseMove();\n\n    // refresh\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // check panel is closed\n    assert.equal(await gu.isSidePanelOpen(\"left\"), false);\n  });\n\n  it('should not show \"Templates\" button if templates org is unset', async function() {\n    await gu.toggleSidePanel(\"left\", \"open\");\n    assert.isFalse(await driver.find(\".test-dm-templates-page\").isPresent());\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/LinkingBidirectional.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"LinkingBidirectional\", function() {\n  this.timeout(\"60s\");\n\n  const cleanup = setupTestSuite({ team: true });\n  let session: gu.Session;\n\n  afterEach(() => gu.checkForErrors());\n\n  before(async function() {\n    session = await gu.session().login();\n    await session.tempDoc(cleanup, \"Class Enrollment.grist\");\n  });\n\n  it(\"should update cursor when sections cursor-linked together\", async function() {\n    // Setup new page with \"Classes1\"\n    await gu.addNewPage(\"Table\", \"Classes\", {});\n    await gu.renameActiveSection(\"Classes1\");\n\n    // Set its cursor to row 2\n    await gu.getCell(\"Semester\", 2, \"Classes1\").click();\n\n    // Add Classes2 & 3\n    await gu.addNewSection(\"Table\", \"Classes\", { selectBy: \"Classes1\" });\n    await gu.renameActiveSection(\"Classes2\");\n    await gu.addNewSection(\"Table\", \"Classes\", { selectBy: \"Classes2\" });\n    await gu.renameActiveSection(\"Classes3\");\n\n    // Make sure that linking put them all on row 2\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: null, cursor: { rowNum: 2, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 2,    cursor: { rowNum: 2, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 2,    cursor: { rowNum: 2, col: 0 } });\n  });\n\n  it(\"should propagate cursor-links downstream\", async function() {\n    // Cursor link from upstream should drive a given section, but we can still move the downstream cursor\n    // To other positions. The downstream linking should use whichever of its ancestors was most recently clicked\n\n    // Set cursors: Sec1 on row 1, Sec2 on row 2\n    await gu.getCell(\"Semester\", 1, \"Classes1\").click();\n    await gu.getCell(\"Semester\", 2, \"Classes2\").click();\n\n    // Sec1 should be on row 1. Sec2 should be on 2 even though link is telling it to be on 1. Sec3 should be on 2\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: null, cursor: { rowNum: 1, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 1,    cursor: { rowNum: 2, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 2,    cursor: { rowNum: 2, col: 0 } });\n\n    // click on Sec3 row 3\n    await gu.getCell(\"Semester\", 3, \"Classes3\").click();\n\n    // Cursors should be on 1, 2, 3, with arrows lagging 1 step behind\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: null, cursor: { rowNum: 1, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 1,    cursor: { rowNum: 2, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 2,    cursor: { rowNum: 3, col: 0 } });\n\n    // When clicking on oldest ancestor, it should now take priority over all downstream cursors again\n    // Click on S1 row 4\n    await gu.getCell(\"Semester\", 4, \"Classes1\").click();\n    // assert that all are on 4\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: null, cursor: { rowNum: 4, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 4,    cursor: { rowNum: 4, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 4,    cursor: { rowNum: 4, col: 0 } });\n  });\n\n  it(\"should work correctly when linked into a cycle\", async function() {\n    // Link them all into a cycle.\n    await gu.selectBy(\"Classes3\");\n\n    // Now, any section should drive all the other sections\n\n    // Click on row 1 in Sec1\n    await gu.getCell(\"Semester\", 1, \"Classes1\").click();\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 1,   cursor: { rowNum: 1, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 1,   cursor: { rowNum: 1, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 1,   cursor: { rowNum: 1, col: 0 } });\n\n    // Click on row 2 in Sec2\n    await gu.getCell(\"Semester\", 2, \"Classes2\").click();\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 2,   cursor: { rowNum: 2, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 2,   cursor: { rowNum: 2, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 2,   cursor: { rowNum: 2, col: 0 } });\n\n    // Click on row 3 in Sec3\n    await gu.getCell(\"Semester\", 3, \"Classes3\").click();\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 3,   cursor: { rowNum: 3, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 3,   cursor: { rowNum: 3, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 3,   cursor: { rowNum: 3, col: 0 } });\n  });\n\n  it(\"should keep position after refresh\", async function() {\n    // Nothing should change after a refresh\n    await gu.reloadDoc();\n\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n  });\n\n  it(\"should update linking when filters change\", async function() {\n    // Let's filter out the current row (\"Spring 2019\") from Classes2\n    await gu.selectSectionByTitle(\"Classes2\");\n    await gu.sortAndFilter()\n      .then(x => x.addColumn())\n      .then(x => x.clickColumn(\"Semester\"))\n      .then(x => x.close())\n      .then(x => x.click());\n\n    // Open the pinned filter, and filter out the date.\n    await gu.openPinnedFilter(\"Semester\")\n      .then(x => x.toggleValue(\"Spring 2019\"))\n      .then(x => x.close());\n\n    // Classes2 got its cursor moved when we filtered out its current row, so all sections should now\n    // have moved to row 1\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 1,   cursor: { rowNum: 1, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 1,   cursor: { rowNum: 1, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 1,   cursor: { rowNum: 1, col: 0 } });\n  });\n\n  it(\"should propagate cursor linking even through a filtered-out section\", async function() {\n    // Row 3 (from Classes1 and Classes3) is no longer present in Classes2\n    // Make sure it can still get the value through the link from Classes1 -> 2 -> 3\n\n    // Click on row 3 in Sec1\n    await gu.getCell(\"Semester\", 3, \"Classes1\").click();\n\n    // Classes1 and 3 should be on row3\n    // Classes2 should have no cursor as the row doesn't exist in the section.\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n    await expectNoArrowNoCursor(\"Classes2\");\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n  });\n\n  it(\"should keep position after refresh when section is filtered out\", async function() {\n    // Save the filter in Classes2\n    await gu.selectSectionByTitle(\"Classes2\");\n    await gu.sortAndFilter()\n      .then(x => x.save());\n\n    // Go to Classes1, and refresh (its cursor will be restored).\n    await gu.selectSectionByTitle(\"Classes1\");\n    await gu.reloadDoc();\n\n    // Nothing should change after a refresh\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n    await expectNoArrowNoCursor(\"Classes2\");\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n  });\n\n  it(\"should navigate to correct cells and sections\", async function() {\n    await gu.getCell(\"Semester\", 3, \"Classes1\").click();\n    const anchor = await gu.getAnchor();\n    await gu.wipeToasts();\n\n    await gu.onNewTab(async () => {\n      await driver.get(anchor);\n      await gu.waitForDocToLoad();\n\n      assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n      await expectNoArrowNoCursor(\"Classes2\");\n      assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n    });\n\n    await gu.getCell(\"Semester\", 3, \"Classes3\").click();\n    const anchor2 = await gu.getAnchor();\n    await gu.wipeToasts();\n\n    await gu.onNewTab(async () => {\n      await driver.get(anchor2);\n      await gu.waitForDocToLoad();\n\n      assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n      await expectNoArrowNoCursor(\"Classes2\");\n      assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 3,    cursor: { rowNum: 3, col: 0 } });\n    });\n  });\n\n  it(\"should update cursor-linking when clicking on a cell, even if the position doesn't change\", async function() {\n    // Classes2 shouldn't have any row selected, but the other 2 sections have their cursors on row 3\n    // If we click on Classes2, even if the cursor position doesn't change, we should still record\n    // Classes2 as now being the most recently-clicked section and have it drive the linking of the other 2 sections\n    // (i.e. they should change to match it)\n\n    // Click on row 1 in Classes2 (it's got filtered-out-rows, and so is desynced from the other 2)\n    await gu.selectSectionByTitle(\"Classes2\");\n    await gu.scrollActiveViewTop();\n    await gu.getCell(\"Semester\", 1, \"Classes2\").click();\n\n    // All sections should now jump to join it\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 1,   cursor: { rowNum: 1, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 1,   cursor: { rowNum: 1, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 1,   cursor: { rowNum: 1, col: 0 } });\n  });\n\n  it(\"should update cursor linking correctly when the selected row is deleted\", async function() {\n    // When deleting a row, the current section moves down 1 row (preserving the cursorpos), but other\n    // sections with the same table jump to row 1\n\n    // If we're cursor-linked, make sure that doesn't cause a desync.\n\n    // Click on row 2 in Classes1 and delete it\n    await gu.getCell(\"Semester\", 2, \"Classes1\").click();\n    await gu.removeRow(2);\n\n    // Classes1 and Classes3 should both have moved to the next row (previously rowNum3, but now is rowNum 2)\n    // Classes2 has this row filtered out, so it should have no row selected (desynced from the others)\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 2,    cursor: { rowNum: 2, col: 0 } });\n    await expectNoArrowNoCursor(\"Classes2\");\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 2,    cursor: { rowNum: 2, col: 0 } });\n\n    // Undo the row-deletion\n    await gu.undo();\n\n    // The first 2 sections will still be on row2, but verify that Classes2 also joins them\n    assert.deepEqual(await getCursorAndArrow(\"Classes1\"), { arrow: 2,   cursor: { rowNum: 2, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes2\"), { arrow: 2,   cursor: { rowNum: 2, col: 0 } });\n    assert.deepEqual(await getCursorAndArrow(\"Classes3\"), { arrow: 2,   cursor: { rowNum: 2, col: 0 } });\n  });\n\n  it(\"should support custom filters\", async function() {\n    // Add a new page with a table and custom widget.\n    await gu.addNewPage(\"Table\", \"Classes\", {});\n    await gu.renameActiveSection(\"Data\");\n    await gu.addNewSection(\"Custom\", \"Classes\", { customWidget: /Custom URL/, selectBy: \"Data\" });\n    await gu.renameActiveSection(\"Custom\");\n\n    // Make sure it can be used as a filter.\n    await gu.changeWidgetAccess(\"read table\");\n    // Tell grist that we can be linked to.\n    await gu.customCode(grist => grist.sectionApi.configure({ allowSelectBy: true }));\n\n    // Now link them together.\n    await gu.selectSectionByTitle(\"Data\");\n    await gu.selectBy(\"Custom\");\n\n    // And now lets filter some rows in the Data by using custom widget API.\n    await gu.selectSectionByTitle(\"Custom\");\n    await gu.customCode(grist => grist.setSelectedRows([1, 2, 3, 4]).catch(() => {}));\n\n    // Check for the error. It was easy to create an infinite loop with the call above, and checking\n    // for errors here was catching it.\n    await gu.checkForErrors();\n\n    // This link was not valid, so custom section wasn't filtered, but it managed to filter Data.\n    // But in the process, the configuration was cleared.\n\n    // Switch section, so that UI is refreshed.\n    await gu.selectSectionByTitle(\"Data\");\n    await gu.selectSectionByTitle(\"Custom\");\n\n    // Make sure we can't select Data by Custom anymore.\n    await gu.openSelectByForSection(\"Custom\");\n\n    // Make sure it is empty.\n    assert.deepEqual(await gu.findOpenMenuAllItems(\".test-select-row\", e => e.getText()), [\"Select widget\"]);\n    await gu.sendKeys(Key.ESCAPE);\n  });\n});\n\ninterface CursorArrowInfo {\n  arrow: null | number;\n  cursor: { rowNum: number, col: number };\n}\n\n// NOTE: this differs from getCursorSelectorInfo in LinkingSelector.ts\n// That function only gives the cursorPos if that section is actively selected (false otherwise), whereas this\n// function returns it always\nasync function getCursorAndArrow(sectionName: string): Promise<CursorArrowInfo> {\n  return {\n    arrow: await gu.getArrowPosition(sectionName),\n    cursor: await gu.getCursorPosition(sectionName),\n  };\n}\n\nasync function expectNoArrowNoCursor(sectionName: string) {\n  assert.isFalse(await gu.isCursorPresent(sectionName));\n  assert.isNull(await gu.getArrowPosition(sectionName));\n}\n"
  },
  {
    "path": "test/nbrowser/LinkingErrors.ts",
    "content": "/**\n * Test various error situations with linking page widgets.\n */\nimport { toTableDataAction } from \"app/common/DocActions\";\nimport { schema } from \"app/common/schema\";\nimport { TableData } from \"app/common/TableData\";\nimport { DocAPI, UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"LinkingErrors\", function() {\n  this.timeout(20000);\n\n  // Sandboxing disrupts timing a bit.\n  testUtils.withoutSandboxing();\n\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n  let api: UserAPI;\n  let docId: string;\n\n  afterEach(() => gu.checkForErrors());\n\n  before(async function() {\n    session = await gu.session().teamSite.login();\n    api = session.createHomeApi();\n  });\n\n  it(\"should maintain links after reload\", async function() {\n    await session.tempNewDoc(cleanup);\n\n    // Recreate the bug.\n    await gu.sendActions([\n      [\"AddEmptyTable\", \"Table2\"], // NOTICE: The order here matters, the bug was all about ordering.\n      [\"AddEmptyTable\", \"Table0\"],\n      [\"ModifyColumn\", \"Table1\", \"B\", { type: \"Ref:Table0\" }],\n      [\"ModifyColumn\", \"Table2\", \"A\", { type: \"Ref:Table1\" }],\n\n      [\"AddRecord\", \"Table0\", null, { A: \"A\" }],\n      [\"AddRecord\", \"Table0\", null, { A: \"B\" }],\n      [\"AddRecord\", \"Table1\", null, { A: \"a\", B: 1 }],\n      [\"AddRecord\", \"Table1\", null, { A: \"b\", B: 1 }],\n      [\"AddRecord\", \"Table1\", null, { A: \"c\", B: 1 }],\n      [\"AddRecord\", \"Table1\", null, { A: \"d\", B: 1 }],\n      [\"AddRecord\", \"Table2\", null, { A: 2, B: 1 }],\n      [\"AddRecord\", \"Table2\", null, { A: 2, B: 2 }],\n      [\"AddRecord\", \"Table2\", null, { A: 4, B: 3 }],\n      [\"AddRecord\", \"Table2\", null, { A: 4, B: 4 }],\n      [\"AddRecord\", \"Table2\", null, { A: 1, B: 5 }],\n      [\"AddRecord\", \"Table2\", null, { A: 1, B: 6 }],\n      [\"AddRecord\", \"Table2\", null, { A: 3, B: 7 }],\n      [\"AddRecord\", \"Table2\", null, { A: 3, B: 8 }],\n    ]);\n\n    // Now add those two tables to a page, and link them.\n    // Pay attention to order.\n    await gu.addNewPage(\"Table\", \"Table1\");\n    await gu.getCell(\"B\", 1).click();\n    await gu.openColumnPanel();\n    await gu.setRefShowColumn(\"A\");\n\n    await gu.addNewSection(\"Table\", \"Table2\", { selectBy: \"TABLE1\" });\n    await gu.getCell(\"A\", 1).click();\n    await gu.openColumnPanel();\n    await gu.setRefShowColumn(\"A\");\n\n    await gu.addNewSection(\"Table\", \"Table0\");\n    await gu.selectSectionByTitle(\"TABLE1\");\n    await gu.openWidgetPanel(\"data\");\n    await gu.selectBy(\"TABLE0\");\n\n    // Place cursor on the first row of Table0\n    await gu.getCell({ section: \"Table1\", rowNum: 1, col: \"A\" }).click();\n\n    const checkLink = async () => {\n      // This should show the linked rows in Table1\n      assert.deepEqual(await gu.getVisibleGridCells({ section: \"Table1\", col: \"A\", rowNums: [1, 2, 3, 4] }),\n        [\"a\", \"b\", \"c\", \"d\"]);\n\n      // This should show the linked rows in Table2\n      assert.deepEqual(await gu.getVisibleGridCells({ section: \"Table2\", col: \"B\", rowNums: [1, 2] }),\n        [\"5\", \"6\"]);\n    };\n\n    await checkLink();\n\n    // After reloading, we should see the same thing.\n    await gu.reloadDoc();\n    await gu.waitToPass(async () => {\n      // Linking is done asynchronously, so make sure it is loaded first.\n      assert.equal(await gu.selectedBy(), \"TABLE0\");\n      await checkLink();\n    });\n  });\n\n  it(\"should link correctly in the normal case\", async function() {\n    docId = await session.tempNewDoc(cleanup, \"LinkingErrors1\", { load: false });\n\n    // Make a table with some data.\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"Planets\", [{ id: \"PlanetName\" }]],\n      // Negative IDs allow referrnig to added records in the same action bundle.\n      [\"AddRecord\", \"Planets\", -1, { PlanetName: \"Earth\" }],\n      [\"AddRecord\", \"Planets\", -2, { PlanetName: \"Mars\" }],\n      [\"AddTable\", \"Moons\", [{ id: \"MoonName\" }, { id: \"Planet\", type: \"Ref:Planets\" }]],\n      [\"AddRecord\", \"Moons\", null, { MoonName: \"Phobos\", Planet: -2 }],\n      [\"AddRecord\", \"Moons\", null, { MoonName: \"Deimos\", Planet: -2 }],\n      [\"AddRecord\", \"Moons\", null, { MoonName: \"Moon\", Planet: -1 }],\n    ]);\n\n    // Load the document.\n    await session.loadDoc(`/doc/${docId}`);\n\n    await gu.getPageItem(\"Planets\").click();\n    await gu.waitForDocToLoad();\n    await gu.addNewSection(/Table/, /Moons/, { selectBy: /PLANETS/ });\n\n    await checkLinking();\n  });\n\n  it(\"should unset linking setting when changing the data table for a widget\", async function() {\n    await gu.getCell({ section: \"Moons\", rowNum: 1, col: \"MoonName\" }).click();\n    await gu.openWidgetPanel();\n    await driver.findContent(\".test-right-panel button\", /Change widget/).click();\n\n    assert.equal(await driver.find(\".test-wselect-table-label[class*=-selected]\").getText(), \"Moons\");\n    await driver.findContent(\".test-wselect-table\", /Planets/).click();\n    assert.match(await driver.find(\".test-wselect-selectby\").value(), /Select widget/);\n\n    await driver.find(\".test-wselect-addBtn\").doClick();\n    await gu.waitForServer();\n\n    // Check that the two sections on the page are now for the same table, and link settings are\n    // cleared.\n    const tables = await getTableData(api.getDocAPI(docId), \"_grist_Tables\");\n    const sections = await getTableData(api.getDocAPI(docId), \"_grist_Views_section\");\n    const planetsTable = tables.filterRecords({ tableId: \"Planets\" })[0];\n    assert.isOk(planetsTable);\n    const planetsSections = sections.filterRecords({ tableRef: planetsTable.id });\n    assert.lengthOf(planetsSections, 4);\n    assert.equal(planetsSections[0].parentId, planetsSections[3].parentId);\n    assert.deepEqual(planetsSections.map(s => s.linkTargetColRef), [0, 0, 0, 0]);\n    assert.deepEqual(planetsSections.map(s => s.linkSrcSectionRef), [0, 0, 0, 0]);\n    assert.deepEqual(planetsSections.map(s => s.linkSrcColRef), [0, 0, 0, 0]);\n\n    // Switch to another page and back and check that there are no errors.\n    await gu.getPageItem(\"Moons\").click();\n    await gu.checkForErrors();\n    await gu.getPageItem(\"Planets\").click();\n    await gu.checkForErrors();\n\n    // Now undo.\n    await gu.undo();\n\n    // Still should have no errors, and linking should be restored.\n    await gu.checkForErrors();\n    await checkLinking();\n  });\n\n  it(\"should fail to link gracefully when linking settings are wrong\", async function() {\n    // Fetch link settings, then mess them up.\n    const columns = await getTableData(api.getDocAPI(docId), \"_grist_Tables_column\");\n    const sections = await getTableData(api.getDocAPI(docId), \"_grist_Views_section\");\n    const planetRefCol = columns.filterRecords({ colId: \"Planet\" })[0];       // In table Moons\n    const planetNameCol = columns.filterRecords({ colId: \"PlanetName\" })[0];  // In table Planets\n    assert.isOk(planetRefCol);\n    assert.isOk(planetNameCol);\n    const planetSec = sections.filterRecords({ linkTargetColRef: planetRefCol.id })[0];\n    assert.isOk(planetSec);\n\n    // Set invalid linking. The column we are setting is for the wrong table. It used to happen\n    // occasionally due to other bugs, here we check that we ignore such invalid settings.\n    await api.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Views_section\", planetSec.id, { linkTargetColRef: planetNameCol.id }],\n    ]);\n\n    // Reload the page.\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // Expect no errors, and expect to see data, although it's no longer linked.\n    await gu.checkForErrors();\n    await gu.getCell({ section: \"PLANETS\", rowNum: 1, col: \"PlanetName\" }).click();\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"MOONS\", col: \"MoonName\", rowNums: [1, 2, 3, 4] }),\n      [\"Phobos\", \"Deimos\", \"Moon\", \"\"]);\n\n    // Reverting to correct settings should make the data linked again.\n    await api.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Views_section\", planetSec?.id, { linkTargetColRef: planetRefCol.id }],\n    ]);\n    await gu.checkForErrors();\n    await checkLinking();\n  });\n\n  it(\"should recreate page with undo\", async function() {\n    const tempDoc = await session.tempNewDoc(cleanup, \"LinkingErrors2\", { load: false });\n    // This tests the bug: When removing page with linked sections and then undoing, there are two JS errors raised:\n    // - flexSize is not a function\n    // - getter is not a function\n\n    // To recreate create a simple document:\n    // - Table1 with default columns\n    // - Table2 with A being reference to Table1\n    // - For Table1 page add:\n    // -- Single card view selected by Table1\n    // -- Table2 view selected by Table1\n    // And make some updates that will cause a bug (without it undoing works).\n    // Modify the layout for page Table1, this makes the first JS bug (flexSize ...) when undoing.\n    // And add some records, which makes the second JS bug (getter is not a function).\n    const actions = [\n      [\"CreateViewSection\", 1, 1, \"single\", null, null],\n      [\"AddEmptyTable\", null],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 6, { type: \"Ref:Table1\" }],\n      [\"CreateViewSection\", 2, 1, \"record\", null, null],\n      [\"UpdateRecord\", \"_grist_Views_section\", 4, { linkSrcSectionRef: 1, linkSrcColRef: 0, linkTargetColRef: 0 }],\n      [\"UpdateRecord\", \"_grist_Views_section\", 8, { linkSrcSectionRef: 1, linkSrcColRef: 0, linkTargetColRef: 6 }],\n      [\n        \"UpdateRecord\",\n        \"_grist_Views\",\n        1,\n        { layoutSpec: '{\"children\":[{\"children\":[{\"leaf\":1},{\"children\":[{\"leaf\":2},{\"leaf\":4}]}]}]}' },\n      ],\n      [\"AddRecord\", \"Table1\", null, { A: \"1\" }],\n      [\"AddRecord\", \"Table2\", null, { A: 1, B: \"2\" }],\n    ];\n    await api.applyUserActions(tempDoc, actions);\n    // Load the document.\n    await session.loadDoc(`/doc/${tempDoc}`);\n    const revert = await gu.begin();\n    // Remove the first page (and Table1).\n    await gu.removePage(\"Table1\", { withData: true });\n    await gu.waitForServer();\n    // And do undo\n    await revert();\n    await gu.checkForErrors();\n  });\n});\n\nasync function getTableData(docApi: DocAPI, tableId: string): Promise<TableData> {\n  const colValues = await docApi.getRows(tableId);\n  const tableAction = toTableDataAction(tableId, colValues);\n  return new TableData(tableId, tableAction, (schema as any)[tableId]);\n}\n\n// Check that normal linking works.\nasync function checkLinking() {\n  await gu.getCell({ section: \"PLANETS\", rowNum: 1, col: \"PlanetName\" }).click();\n  assert.deepEqual(await gu.getVisibleGridCells({ section: \"MOONS\", col: \"MoonName\", rowNums: [1, 2] }),\n    [\"Moon\", \"\"]);\n  await gu.getCell({ section: \"PLANETS\", rowNum: 2, col: \"PlanetName\" }).click();\n  assert.deepEqual(await gu.getVisibleGridCells({ section: \"MOONS\", col: \"MoonName\", rowNums: [1, 2, 3] }),\n    [\"Phobos\", \"Deimos\", \"\"]);\n}\n"
  },
  {
    "path": "test/nbrowser/LinkingSelector.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"LinkingSelector\", function() {\n  this.timeout(20000);\n\n  const cleanup = setupTestSuite({ team: true });\n  let session: gu.Session;\n  let docId: string;\n\n  afterEach(() => gu.checkForErrors());\n\n  before(async function() {\n    session = await gu.session().login();\n    docId = (await session.tempDoc(cleanup, \"Class Enrollment.grist\")).id;\n  });\n\n  it(\"should update linked grid view on filter change\", async function() {\n    // Add new page with summarized classes by Start_Date.\n    await gu.addNewPage(\"Table\", \"Classes\", {\n      summarize: [\"Start_Date\"],\n    });\n\n    // Add a new linked widget that shows classes by this date.\n    await gu.addNewSection(\"Table\", \"Classes\", {\n      selectBy: \"CLASSES [by Start_Date]\",\n    });\n\n    // Click on the first date.\n    await gu.getCell(\"Start_Date\", 1, \"CLASSES [by Start_Date]\").click();\n    // Make sure we know which row is selected.\n    assert.equal(await gu.getCell(\"Start_Date\", 1).getText(), \"2018-09-13\");\n\n    // Show only Start_Date in the linked section.\n    await gu.selectSectionByTitle(\"CLASSES\");\n    await gu.openWidgetPanel();\n    await gu.selectAllVisibleColumns();\n    await gu.toggleVisibleColumn(\"Start_Date\");\n    await gu.hideColumns();\n\n    // Make sure we see the same date.\n    await gu.getCell(\"Start_Date\", 1, \"CLASSES\").click();\n    assert.equal(await gu.getCell(\"Start_Date\", 1).getText(), \"2018-09-13\");\n\n    // Now filter the summary table to not show the selected date.\n    await gu.selectSectionByTitle(\"CLASSES [by Start_Date]\");\n    await gu.filterBy(\"Start_Date\", false, [\"2018-09-14\"]);\n    // Make sure this is filtered out.\n    assert.equal(await gu.getCell(\"Start_Date\", 1, \"CLASSES [by Start_Date]\").getText(), \"2018-09-14\");\n    // Make sure the linked section is updated.\n    assert.equal(await gu.getCell(\"Start_Date\", 1, \"CLASSES\").getText(), \"2018-09-14\");\n  });\n\n  it(\"should update linked card on filter change with same record linking\", async function() {\n    // Test the same for the card view and same record linking (it uses different code).\n    // Add a list and a card view of the same table and link them together.\n    await gu.addNewPage(\"Table\", \"Classes\");\n\n    // Hide all columns in the list view.\n    await gu.openWidgetPanel();\n    await gu.selectAllVisibleColumns();\n    await gu.toggleVisibleColumn(\"Start_Date\");\n    await gu.hideColumns();\n\n    // Rename it to list.\n    await gu.renameActiveSection(\"List\");\n\n    // Now add a card view.\n    await gu.addNewSection(\"Card\", \"Classes\", {\n      selectBy: \"List\",\n    });\n    await gu.renameActiveSection(\"Card\");\n    await gu.selectAllVisibleColumns();\n    await gu.toggleVisibleColumn(\"Start_Date\");\n    await gu.hideColumns();\n\n    // Select the second row.\n    await gu.selectSectionByTitle(\"List\");\n    await gu.getCell(\"Start_Date\", 2).click();\n    // Make sure we know the second row is selected.\n    assert.equal(await gu.getCell(\"Start_Date\", 2).getText(), \"2018-09-14\");\n    assert.equal((await gu.getCursorPosition())?.rowNum, 2);\n\n    // Make sure it was also updated in the card view.\n    await gu.selectSectionByTitle(\"Card\");\n    assert.equal(await gu.getDetailCell(\"Start_Date\", 1).getText(), \"2018-09-14\");\n\n    // Now filter it out, using pinned filters (to not alter the cursor position).\n    await gu.selectSectionByTitle(\"List\");\n    // Pin the filter to the panel by just adding it (it is pinned by default).\n    await gu.sortAndFilter()\n      .then(x => x.addColumn())\n      .then(x => x.clickColumn(\"Start_Date\"))\n      .then(x => x.close())\n      .then(x => x.click());\n\n    // Open the pinned filter, and filter out the date.\n    await gu.openPinnedFilter(\"Start_Date\")\n      .then(x => x.toggleValue(\"2018-09-14\"))\n      .then(x => x.close());\n\n    // Make sure we see it as the first row\n    assert.equal(await gu.getCell(\"Start_Date\", 1).getText(), \"2018-09-13\");\n    assert.equal(await gu.getCell(\"Start_Date\", 2).getText(), \"2019-01-27\");\n    // And cursor was moved to the first row.\n    assert.equal((await gu.getCursorPosition())?.rowNum, 1);\n    // Make sure the card view is updated.\n    await gu.selectSectionByTitle(\"Card\");\n    assert.equal(await gu.getDetailCell(\"Start_Date\", 1).getText(), \"2018-09-13\");\n  });\n\n  it(\"should update linked section for the first row\", async function() {\n    // There was a bug here. First row wasn't somehow triggering the linked section to update itself,\n    // when it was filtered out, for self-linking.\n    await gu.selectSectionByTitle(\"List\");\n    await gu.removeFilters();\n\n    // Select first row.\n    await gu.getCell(\"Start_Date\", 1).click();\n    // Make sure we know what we selected.\n    assert.equal(await gu.getCell(\"Start_Date\", 1, \"List\").getText(), \"2018-09-13\");\n    // Make sure that card reflects it.\n    assert.equal(await gu.getDetailCell(\"Start_Date\", 1, \"Card\").getText(), \"2018-09-13\");\n    // Now unfilter it.\n    await gu.openColumnFilter(\"Start_Date\")\n      .then(x => x.toggleValue(\"2018-09-13\"))\n      .then(x => x.close());\n    // Make sure that List is updated in the first row.\n    assert.equal(await gu.getCell(\"Start_Date\", 1, \"List\").getText(), \"2018-09-14\");\n    // Make sure that Card is updated accordingly.\n    assert.equal(await gu.getDetailCell(\"Start_Date\", 1, \"Card\").getText(), \"2018-09-14\");\n  });\n\n  it(\"should mark selected row used for linking\", async function() {\n    await session.loadDoc(`/doc/${docId}/p/7`);\n\n    const families = gu.getSection(\"FAMILIES\");\n    const students = gu.getSection(\"STUDENTS\");\n    const enrollments = gu.getSection(\"ENROLLMENTS\");\n\n    // Initially FAMILIES first row should be selected and marked as selector.\n    assert.deepEqual(await getCursorSelectorInfo(families), { linkSelector: 1, cursor: { rowNum: 1, col: 0 } });\n    assert.deepEqual(await gu.getActiveCell().getText(), \"Fin\");\n\n    // STUDENTS shows appropriate records.\n    assert.deepEqual(await gu.getVisibleGridCells({ section: students, col: \"First_Name\", rowNums: [1, 2, 3, 4] }),\n      [\"Brockie\", \"Care\", \"Alfonso\", \"\"]);\n\n    // STUDENTS also has a selector row, but no active cursor.\n    assert.deepEqual(await getCursorSelectorInfo(students), { linkSelector: 1, cursor: null });\n    assert.deepEqual(await getCursorSelectorInfo(enrollments), { linkSelector: false, cursor: null });\n\n    // Select a different Family\n    await gu.getCell({ section: families, rowNum: 3, col: \"First_Name\" }).click();\n    assert.deepEqual(await gu.getActiveCell().getText(), \"Pat\");\n    assert.deepEqual(await getCursorSelectorInfo(families), { linkSelector: 3, cursor: { rowNum: 3, col: 0 } });\n\n    // STUDENTS shows new values, has a new selector row\n    assert.deepEqual(await gu.getVisibleGridCells({ section: students, col: \"First_Name\", rowNums: [1, 2, 3] }),\n      [\"Mordy\", \"Noam\", \"\"]);\n    assert.deepEqual(await getCursorSelectorInfo(students), { linkSelector: 1, cursor: null });\n\n    // STUDENTS Card shows appropriate value\n    assert.deepEqual(await gu.getVisibleDetailCells(\n      { section: \"STUDENTS Card\", cols: [\"First_Name\", \"Policy_Number\"], rowNums: [1] }),\n    [\"Mordy\", \"468617\"]);\n\n    // Select another student\n    await gu.getCell({ section: students, rowNum: 2, col: \"Last_Name\" }).click();\n    assert.deepEqual(await getCursorSelectorInfo(students), { linkSelector: 2, cursor: { rowNum: 2, col: 1 } });\n    assert.deepEqual(await gu.getVisibleDetailCells(\n      { section: \"STUDENTS Card\", cols: [\"First_Name\", \"Policy_Number\"], rowNums: [1] }),\n    [\"Noam\", \"663208\"]);\n\n    // There is no longer a cursor in FAMILIES, but still a link-selector.\n    assert.deepEqual(await getCursorSelectorInfo(families), { linkSelector: 3, cursor: null });\n\n    // Enrollments is linked to the selected student, but still shows no cursor or selector.\n    assert.deepEqual(await getCursorSelectorInfo(enrollments), { linkSelector: false, cursor: null });\n    assert.deepEqual(await gu.getVisibleGridCells({ section: enrollments, col: \"Class\", rowNums: [1, 2, 3] }),\n      [\"2019F-Yoga\", \"2019S-Yoga\", \"\"]);\n\n    // Click into an enrollment; it will become the only section with a cursor.\n    await gu.getCell({ section: enrollments, rowNum: 2, col: \"Status\" }).click();\n    assert.deepEqual(await getCursorSelectorInfo(enrollments), { linkSelector: false, cursor: { rowNum: 2, col: 2 } });\n    assert.deepEqual(await getCursorSelectorInfo(students), { linkSelector: 2, cursor: null });\n    assert.deepEqual(await getCursorSelectorInfo(families), { linkSelector: 3, cursor: null });\n  });\n\n  it(\"should show correct state on reload after cursors are positioned\", async function() {\n    await gu.reloadDoc();\n    const families = gu.getSection(\"FAMILIES\");\n    const students = gu.getSection(\"STUDENTS\");\n    const enrollments = gu.getSection(\"ENROLLMENTS\");\n    assert.deepEqual(await getCursorSelectorInfo(enrollments), { linkSelector: false, cursor: { rowNum: 2, col: 2 } });\n    assert.deepEqual(await getCursorSelectorInfo(students), { linkSelector: 2, cursor: null });\n    assert.deepEqual(await getCursorSelectorInfo(families), { linkSelector: 3, cursor: null });\n  });\n});\n\ninterface CursorSelectorInfo {\n  linkSelector: false | number;\n  cursor: null | { rowNum: number, col: number };\n}\n\nasync function getCursorSelectorInfo(section: WebElement): Promise<CursorSelectorInfo> {\n  const hasActiveCursor = await gu.isCursorPresent(section, \"active\");\n  return {\n    linkSelector: await gu.getSelectorPosition(section).then(r => r ?? false),\n    cursor: hasActiveCursor ? await gu.getCursorPosition(section) : null,\n  };\n}\n"
  },
  {
    "path": "test/nbrowser/Localization.ts",
    "content": "import { getAppRoot } from \"app/server/lib/places\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport fs from \"fs\";\nimport os from \"os\";\nimport path from \"path\";\n\nimport { assert, driver } from \"mocha-webdriver\";\nimport fetch from \"node-fetch\";\n\n// We only support those formats for now:\n// en.client.json\n// en_US.client.json\n// en_US.server.json\n// zh_Hant.client.json\n// {lang code (+ maybe with underscore and country code}.{namespace}.json\n//\n// Only this format was tested and is known to work.\n\nconst VALID_LOCALE_FORMAT = /^[a-z]{2,}(_\\w+)?\\.(\\w+)\\.json$/;\n\ndescribe(\"Localization\", function() {\n  this.timeout(60000);\n  setupTestSuite();\n\n  before(async function() {\n    const session = await gu.session().personalSite.anon.login();\n    await session.loadRelPath(\"/\");\n  });\n\n  it(\"uses default options for English language\", async function() {\n    // Currently, there is not much translated, so test just what we have.\n    assert.equal(await driver.findWait(\".test-welcome-title\", 3000).getText(), \"Welcome to Grist!\");\n    // Grist config should contain the list of supported languages;\n    const gristConfig: any = await driver.executeScript(\"return window.gristConfig\");\n\n    // client and en is required.\n    assert.isTrue(gristConfig.namespaces.includes(\"client\"));\n    assert.isTrue(gristConfig.supportedLngs.includes(\"en\"));\n  });\n\n  it(\"loads all files from resource folder\", async function() {\n    if (server.isExternalServer()) {\n      this.skip();\n    }\n    // Grist config should contain the list of supported languages;\n    const gristConfig: any = await driver.executeScript(\"return window.gristConfig\");\n    // Should report all supported languages and namespaces.\n    const localeDirectory = path.join(getAppRoot(), \"static\", \"locales\");\n    // Read all file names from localeDirectory\n    const langs = new Set<string>();\n    const namespaces = new Set<string>();\n    for (const file of fs.readdirSync(localeDirectory)) {\n      // Make sure we see only valid files.\n      assert.match(file, VALID_LOCALE_FORMAT);\n      const langRaw = file.split(\".\")[0];\n      const lang = langRaw?.replace(/_/g, \"-\");\n      const ns = file.split(\".\")[1];\n      const clientFile = path.join(localeDirectory,\n        `${langRaw}.client.json`);\n      const clientText = fs.readFileSync(clientFile, { encoding: \"utf8\" });\n      if (!clientText.includes(\"Translators: please translate this only when\")) {\n        // Translation not ready if this key is not present.\n        continue;\n      }\n      langs.add(lang);\n      namespaces.add(ns);\n    }\n    assert.deepEqual(gristConfig.supportedLngs.sort(), [...langs].sort());\n    assert.deepEqual(gristConfig.namespaces.sort(), [...namespaces].sort());\n    assert.isAbove(gristConfig.supportedLngs.length, 9);\n  });\n\n  // Now make a uz-UZ language file, and test that it is used.\n  describe(\"with uz-UZ language file\", function() {\n    let oldEnv: testUtils.EnvironmentSnapshot;\n    let tempLocale: string;\n    let existingLocales: string[];\n    before(async function() {\n      if (server.isExternalServer()) {\n        this.skip();\n      }\n      const gristConfig: any = await driver.executeScript(\"return window.gristConfig\");\n      existingLocales = gristConfig.supportedLngs;\n      oldEnv = new testUtils.EnvironmentSnapshot();\n      // Add another language to the list of supported languages.\n      tempLocale = makeCopy();\n      createLanguage(tempLocale, \"uz\");\n      process.env.GRIST_LOCALES_DIR = tempLocale;\n      await server.restart();\n    });\n\n    after(async () => {\n      oldEnv.restore();\n      await server.restart();\n    });\n\n    it(\"detects correct language from client headers\", async function() {\n      const homeUrl = `${server.getHost()}/o/docs`;\n      // Read response from server, and check that it contains the correct language.\n      const enResponse = await (await fetch(homeUrl)).text();\n      const uzResponse = await (await fetch(homeUrl, { headers: { \"Accept-Language\": \"uz-UZ,uz;q=1\" } })).text();\n      const ptResponse = await (await fetch(homeUrl, { headers: { \"Accept-Language\": \"pt-PR,pt;q=1\" } })).text();\n      // We have file with nb_NO code, but still this should be preloaded.\n      const noResponse = await (await fetch(homeUrl, { headers: { \"Accept-Language\": \"nb-NO,nb;q=1\" } })).text();\n\n      function present(response: string, ...langs: string[]) {\n        for (const lang of langs) {\n          assert.include(response, `href=\"locales/${lang}.client.json\"`);\n        }\n      }\n\n      function notPresent(response: string, ...langs: string[]) {\n        for (const lang of langs) {\n          assert.notInclude(response, `href=\"locales/${lang}.client.json\"`);\n        }\n      }\n\n      // English locale is preloaded always.\n      present(enResponse, \"en\");\n      present(uzResponse, \"en\");\n      present(ptResponse, \"en\");\n      present(noResponse, \"en\");\n\n      // Other locales are not preloaded for English.\n      notPresent(enResponse, \"uz\", \"un-UZ\", \"en-US\");\n\n      // For uz-UZ we have additional uz locale.\n      present(uzResponse, \"uz\");\n      // But only uz code is preloaded.\n      notPresent(uzResponse, \"uz-UZ\");\n\n      notPresent(ptResponse, \"pt-PR\", \"uz\", \"en-US\");\n\n      // For no-NO we have nb_NO file.\n      present(noResponse, \"nb_NO\");\n    });\n\n    it(\"loads correct languages from file system\", async function() {\n      modifyByCode(tempLocale, \"en\", { HomeIntro: { \"Welcome to Grist!\": \"TestMessage\" } });\n      await driver.navigate().refresh();\n      assert.equal(await driver.findWait(\".test-welcome-title\", 3000).getText(), \"TestMessage\");\n      const gristConfig: any = await driver.executeScript(\"return window.gristConfig\");\n      assert.sameDeepMembers(gristConfig.supportedLngs, [...existingLocales, \"uz\"]);\n    });\n  });\n\n  it(\"breaks the server if something is wrong with resource files\", async function() {\n    if (server.isExternalServer()) {\n      this.skip();\n    }\n    const oldEnv = new testUtils.EnvironmentSnapshot();\n    try {\n      // Wrong path to locales.\n      process.env.GRIST_LOCALES_DIR = __filename;\n      await assert.isRejected(server.restart(false, true));\n      // Empty folder.\n      const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), \"grist_test_\"));\n      process.env.GRIST_LOCALES_DIR = tempDirectory;\n      await assert.isRejected(server.restart(false, true));\n      // Wrong file format.\n      fs.writeFileSync(path.join(tempDirectory, \"dummy.json\"), \"invalid json\");\n      await assert.isRejected(server.restart(false, true));\n    } finally {\n      oldEnv.restore();\n      await server.restart();\n    }\n  });\n\n  /**\n   * Creates a new language by coping existing \"en\" resources.\n   */\n  function createLanguage(localesPath: string, code: string) {\n    for (const file of fs.readdirSync(localesPath)) {\n      if (file.startsWith(\"en.\")) {\n        const newFile = file.replace(\"en\", code);\n        fs.copyFileSync(path.join(localesPath, file), path.join(localesPath, newFile));\n      }\n    }\n  }\n\n  /**\n   * Makes a copy of all resource files and returns path to the temporary directory.\n   */\n  function makeCopy() {\n    const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), \"grist_test_\"));\n    const localeDirectory = path.join(getAppRoot(), \"static\", \"locales\");\n    // Copy all files from localeDirectory to tempDirectory.\n    fs.readdirSync(localeDirectory).forEach((file) => {\n      fs.copyFileSync(path.join(localeDirectory, file), path.join(tempDirectory, file));\n    });\n    return tempDirectory;\n  }\n\n  function modifyByCode(localeDir: string, code: string, obj: any) {\n    // Read current client localization file.\n    const filePath = path.join(localeDir, `${code}.client.json`);\n    const resources = JSON.parse(fs.readFileSync(filePath).toString());\n    const newResource = Object.assign(resources, obj);\n    fs.writeFileSync(filePath, JSON.stringify(newResource));\n  }\n});\n"
  },
  {
    "path": "test/nbrowser/MultiColumn.ts",
    "content": "import { UserAPIImpl } from \"app/common/UserAPI\";\nimport { arrayRepeat } from \"app/plugin/gutil\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { ColumnType } from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\nlet api: UserAPIImpl;\nlet doc: string;\n\nconst transparent = \"rgba(0, 0, 0, 0)\";\nconst blue = \"#0000FF\";\nconst red = \"#FF0000\";\nconst types: ColumnType[] = [\n  \"Any\", \"Text\", \"Integer\", \"Numeric\", \"Toggle\", \"Date\", \"DateTime\", \"Choice\", \"Choice List\",\n  \"Reference\", \"Reference List\", \"Attachment\",\n];\n\ndescribe(\"MultiColumn\", function() {\n  this.timeout(80000);\n  const cleanup = setupTestSuite();\n  before(async function() {\n    const session = await gu.session().login();\n    doc = await session.tempNewDoc(cleanup, \"MultiColumn\", { load: false });\n    api = session.createHomeApi();\n    await api.applyUserActions(doc, [\n      [\"BulkAddRecord\", \"Table1\", arrayRepeat(2, null), {}],\n    ]);\n    // Leave only A column which will have AnyType. We don't need it, but\n    // table must have at least one column and we will be removing all columns\n    // that we test.\n    await api.applyUserActions(doc, [\n      [\"RemoveColumn\", \"Table1\", \"B\"],\n      [\"RemoveColumn\", \"Table1\", \"C\"],\n    ]);\n    await session.loadDoc(\"/doc/\" + doc);\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n  });\n\n  describe(\"behavior tests\", function() {\n    let revertEach: () => Promise<void>;\n    let revertAll: () => Promise<void>;\n    let failed = false;\n    before(async function() {\n      revertAll = await gu.begin();\n      await addAnyColumn(\"Test1\");\n      await addAnyColumn(\"Test2\");\n      await addAnyColumn(\"Test3\");\n    });\n    after(async function() {\n      if (!failed) {\n        await revertAll();\n      }\n    });\n\n    beforeEach(async () => {\n      revertEach = await gu.begin();\n    });\n    afterEach(async function() {\n      if (this.currentTest?.state !== \"failed\") {\n        await revertEach();\n      } else {\n        failed = true;\n      }\n    });\n\n    it(\"should not work on card view\", async () => {\n      await gu.changeWidget(\"Card\");\n      await gu.openColumnPanel();\n      assert.notEqual(await gu.getType(), \"Mixed types\");\n      await gu.openColumnPanel();\n      // Should be able to change type.\n      await gu.getDetailCell(\"Test1\", 1);\n      await gu.enterCell(\"aa\");\n      await gu.setType(\"Integer\", { apply: true });\n      assert.equal(await gu.getType(), \"Integer\");\n    });\n\n    it(\"should undo color change\", async () => {\n      // This is test for a bug, colors were not saved when \"click outside\" was done by clicking\n      // one of the cells.\n      await selectColumns(\"Test1\", \"Test2\");\n      await gu.setType(\"Reference\");\n      await gu.getCell(\"Test1\", 1).click();\n      await gu.enterCell(\"Table1\", Key.ENTER);\n      await gu.getCell(\"Test2\", 3).click();\n      await gu.enterCell(\"Table1\", Key.ENTER);\n      await selectColumns(\"Test1\", \"Test2\");\n      await gu.openCellColorPicker();\n      await gu.setFillColor(blue);\n      // Clicking on one of the cell caused that the color was not saved.\n      await gu.getCell(\"Test2\", 1).click();\n      // Test if color is set.\n      await gu.assertFillColor(await gu.getCell(\"Test1\", 1), blue);\n      await gu.assertFillColor(await gu.getCell(\"Test2\", 1), blue);\n      // Press undo\n      await gu.undo();\n      await gu.assertFillColor(await gu.getCell(\"Test1\", 1), transparent);\n      await gu.assertFillColor(await gu.getCell(\"Test2\", 1), transparent);\n    });\n\n    for (const type of [\"Choice\", \"Text\", \"Reference\", \"Numeric\"] as ColumnType[]) {\n      it(`should reset all columns to first column type for ${type}`, async () => {\n        // We start with empty columns, then we will change first one\n        // to a data column, select all and then change all other to the same type.\n        // This tests if creator panel is enabled properly, and we can change\n        // all columns to the type of the first selected columns (it was a bug).\n        await selectColumns(\"Test1\");\n        await gu.setType(type);\n        await selectColumns(\"Test1\", \"Test3\");\n        assert.equal(await gu.getType(), \"Mixed types\");\n        await gu.setType(type);\n        assert.equal(await gu.getType(), type);\n        await selectColumns(\"Test1\");\n        assert.equal(await gu.getType(), type);\n        await selectColumns(\"Test2\");\n        assert.equal(await gu.getType(), type);\n        await selectColumns(\"Test3\");\n        assert.equal(await gu.getType(), type);\n        await gu.undo();\n        await selectColumns(\"Test1\");\n        assert.equal(await gu.getType(), type);\n        await selectColumns(\"Test2\");\n        assert.equal(await gu.getType(), \"Any\");\n        await selectColumns(\"Test3\");\n        assert.equal(await gu.getType(), \"Any\");\n      });\n    }\n\n    it(\"should show proper behavior label\", async () => {\n      await selectColumns(\"Test1\");\n      assert.equal(await gu.columnBehavior(), \"Empty column\");\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.equal(await gu.columnBehavior(), \"Empty columns\");\n\n      // Change first to be data column.\n      await selectColumns(\"Test1\");\n      await driver.find(\".test-field-set-data\").click();\n      await gu.waitForServer();\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.equal(await gu.columnBehavior(), \"Mixed Behavior\");\n\n      // Change second to be a data column\n      await selectColumns(\"Test2\");\n      await driver.find(\".test-field-set-data\").click();\n      await gu.waitForServer();\n      await selectColumns(\"Test1\", \"Test2\");\n      assert.equal(await gu.columnBehavior(), \"Data columns\");\n      // Now make them all formulas\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Table1\", \"Test1\", { formula: \"1\", isFormula: true }],\n        [\"ModifyColumn\", \"Table1\", \"Test2\", { formula: \"1\", isFormula: true }],\n        [\"ModifyColumn\", \"Table1\", \"Test3\", { formula: \"1\", isFormula: true }],\n      ]);\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.equal(await gu.columnBehavior(), \"Formula columns\");\n\n      // Make one of them data column and test that the mix is recognized.\n      await selectColumns(\"Test1\");\n      await gu.changeBehavior(\"Convert column to data\");\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.equal(await gu.columnBehavior(), \"Mixed Behavior\");\n    });\n\n    it(\"should reset multiple columns\", async () => {\n      // Now make them all formulas\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Table1\", \"Test1\", { formula: \"1\", isFormula: true }],\n        [\"ModifyColumn\", \"Table1\", \"Test2\", { formula: \"1\", isFormula: true }],\n        [\"ModifyColumn\", \"Table1\", \"Test3\", { formula: \"1\", isFormula: true }],\n      ]);\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.equal(await gu.columnBehavior(), \"Formula columns\");\n      await alignment(\"center\");\n      assert.equal(await alignment(), \"center\");\n\n      // Reset all of them\n      assert.deepEqual(await gu.availableBehaviorOptions(), [\"Convert columns to data\", \"Clear and reset\"]);\n      await gu.changeBehavior(\"Clear and reset\");\n      assert.equal(await gu.columnBehavior(), \"Empty columns\");\n      assert.equal(await alignment(), \"left\");\n\n      // Make them all data columns\n      await gu.getCell(\"Test1\", 1).click(); await gu.enterCell(\"a\");\n      await gu.getCell(\"Test2\", 1).click(); await gu.enterCell(\"a\");\n      await gu.getCell(\"Test3\", 1).click(); await gu.enterCell(\"a\");\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.equal(await gu.columnBehavior(), \"Data columns\");\n      await selectColumns(\"Test1\");\n      assert.equal(await gu.columnBehavior(), \"Data column\");\n\n      // Reset all of them\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.deepEqual(await gu.availableBehaviorOptions(), [\"Clear and reset\"]);\n      await gu.changeBehavior(\"Clear and reset\");\n      assert.equal(await gu.columnBehavior(), \"Empty columns\");\n      await selectColumns(\"Test1\");\n      assert.equal(await gu.columnBehavior(), \"Empty column\");\n      assert.equal(await gu.getCell(\"Test1\", 1).getText(), \"\");\n      assert.equal(await gu.getCell(\"Test2\", 1).getText(), \"\");\n      assert.equal(await gu.getCell(\"Test3\", 1).getText(), \"\");\n    });\n\n    it(\"should convert to data multiple columns\", async () => {\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.equal(await gu.columnBehavior(), \"Empty columns\");\n      assert.deepEqual(await gu.availableBehaviorOptions(), [\"Convert columns to data\", \"Clear and reset\"]);\n      await gu.changeBehavior(\"Convert columns to data\");\n      assert.equal(await gu.columnBehavior(), \"Data columns\");\n      await selectColumns(\"Test1\");\n      assert.equal(await gu.columnBehavior(), \"Data column\");\n\n      // Now make them all formula columns\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Table1\", \"Test1\", { formula: \"1\", isFormula: true }],\n        [\"ModifyColumn\", \"Table1\", \"Test2\", { formula: \"2\", isFormula: true }],\n        [\"ModifyColumn\", \"Table1\", \"Test3\", { formula: \"3\", isFormula: true }],\n      ]);\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.equal(await gu.columnBehavior(), \"Formula columns\");\n\n      // Convert them to data\n      assert.deepEqual(await gu.availableBehaviorOptions(), [\"Convert columns to data\", \"Clear and reset\"]);\n      await gu.changeBehavior(\"Convert columns to data\");\n      assert.equal(await gu.columnBehavior(), \"Data columns\");\n      await selectColumns(\"Test1\");\n      assert.equal(await gu.columnBehavior(), \"Data column\");\n      // Test that data stays.\n      assert.equal(await gu.getCell(\"Test1\", 1).getText(), \"1\");\n      assert.equal(await gu.getCell(\"Test2\", 1).getText(), \"2\");\n      assert.equal(await gu.getCell(\"Test3\", 1).getText(), \"3\");\n    });\n\n    it(\"should disable formula editor for multiple columns\", async () => {\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Table1\", \"Test1\", { formula: \"1\", isFormula: true }],\n      ]);\n      await selectColumns(\"Test1\");\n      assert.isFalse(await formulaEditorDisabled());\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.isTrue(await formulaEditorDisabled());\n      await selectColumns(\"Test1\");\n      assert.isFalse(await formulaEditorDisabled());\n    });\n\n    it(\"should disable column id and other unique options\", async () => {\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.isTrue(await colIdDisabled());\n      assert.isTrue(await deriveDisabled());\n      assert.isTrue(await labelDisabled());\n      assert.isTrue(await transformSectionDisabled());\n      assert.isTrue(await setTriggerDisabled());\n      assert.isTrue(await setDataDisabled());\n      assert.isTrue(await setFormulaDisabled());\n      assert.isTrue(await addConditionDisabled());\n      assert.isFalse(await columnTypeDisabled());\n\n      await selectColumns(\"Test1\");\n      assert.isTrue(await colIdDisabled());\n      assert.isFalse(await deriveDisabled());\n      assert.isFalse(await labelDisabled());\n      assert.isFalse(await setTriggerDisabled());\n      assert.isTrue(await transformSectionDisabled());\n      assert.isFalse(await addConditionDisabled());\n      assert.isFalse(await columnTypeDisabled());\n\n      // Make one column a data column, to disable type selector.\n      await selectColumns(\"Test1\");\n      await gu.changeBehavior(\"Convert column to data\");\n      assert.isFalse(await transformSectionDisabled());\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.isTrue(await columnTypeDisabled());\n\n      // Make sure that a colId disabled state is not altered accidentally.\n      await selectColumns(\"Test1\");\n      assert.isTrue(await colIdDisabled());\n      await toggleDerived();\n      assert.isFalse(await colIdDisabled());\n      await selectColumns(\"Test1\", \"Test2\");\n      assert.isTrue(await colIdDisabled());\n      await selectColumns(\"Test1\");\n      assert.isFalse(await colIdDisabled());\n      await toggleDerived();\n      assert.isTrue(await colIdDisabled());\n    });\n\n    it(\"should change column type for mixed behaviors\", async () => {\n      // For empty columns\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.isFalse(await columnTypeDisabled());\n      // Check every column type\n      for (const type of types) {\n        await gu.setType(type);\n        await gu.checkForErrors();\n        await selectColumns(\"Test1\");\n        assert.equal(await gu.getType(), type);\n        await selectColumns(\"Test1\", \"Test3\");\n        assert.equal(await gu.getType(), type);\n      }\n      // For mix of empty and formulas\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Table1\", \"Test2\", { formula: \"2\", isFormula: true }],\n      ]);\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.isFalse(await columnTypeDisabled());\n      for (const type of types) {\n        await gu.setType(type);\n        await gu.checkForErrors();\n        await selectColumns(\"Test1\");\n        assert.equal(await gu.getType(), type);\n        await selectColumns(\"Test1\", \"Test3\");\n        assert.equal(await gu.getType(), type);\n      }\n\n      // For mix of empty and formulas and data\n      await gu.sendActions([\n        // We are changing first column, so the selection will start from data column.\n        [\"ModifyColumn\", \"Table1\", \"Test1\", { type: \"Choice\" }],\n      ]);\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.isFalse(await columnTypeDisabled());\n      for (const type of types) {\n        await gu.setType(type);\n        await gu.checkForErrors();\n        await selectColumns(\"Test1\");\n        assert.equal(await gu.getType(), type);\n        await selectColumns(\"Test1\", \"Test3\");\n        assert.equal(await gu.getType(), type);\n      }\n\n      // Shows proper label for mixed types\n      await selectColumns(\"Test1\");\n      await gu.setType(\"Numeric\");\n      await selectColumns(\"Test2\");\n      await gu.setType(\"Toggle\");\n      await selectColumns(\"Test1\", \"Test3\");\n      assert.equal(await gu.getType(), \"Mixed types\");\n    });\n  });\n\n  describe(\"color tests\", function() {\n    before(async function() {\n      await addAnyColumn(\"Test1\");\n      await addAnyColumn(\"Test2\");\n    });\n    after(async function() {\n      await removeColumn(\"Test1\");\n      await removeColumn(\"Test2\");\n    });\n    it(\"should change cell background for multiple columns\", async () => {\n      await selectColumns(\"Test1\", \"Test2\");\n      assert.equal(await cellColorLabel(), \"Default cell style\");\n      await gu.openCellColorPicker();\n      await gu.setFillColor(blue);\n      await gu.assertFillColor(await gu.getCell(\"Test1\", 1).find(\".field_clip\"), blue);\n      await gu.assertFillColor(await gu.getCell(\"Test2\", 1).find(\".field_clip\"), blue);\n      await driver.sendKeys(Key.ESCAPE);\n      await gu.assertFillColor(await gu.getCell(\"Test1\", 1).find(\".field_clip\"), transparent);\n      await gu.assertFillColor(await gu.getCell(\"Test2\", 1).find(\".field_clip\"), transparent);\n      assert.equal(await cellColorLabel(), \"Default cell style\");\n\n      // Change one cell to red\n      await selectColumns(\"Test1\");\n      await gu.openCellColorPicker();\n      await gu.setFillColor(red);\n      await driver.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      await gu.assertFillColor(await gu.getCell(\"Test1\", 1).find(\".field_clip\"), red);\n      await gu.assertFillColor(await gu.getCell(\"Test2\", 1).find(\".field_clip\"), transparent);\n\n      // Check label and colors for multicolumn selection.\n      await selectColumns(\"Test1\", \"Test2\");\n      assert.equal(await cellColorLabel(), \"Mixed style\");\n      // Try to change to blue, but press escape.\n      await gu.openCellColorPicker();\n      await gu.setFillColor(blue);\n      await gu.assertFillColor(await gu.getCell(\"Test1\", 1).find(\".field_clip\"), blue);\n      await gu.assertFillColor(await gu.getCell(\"Test2\", 1).find(\".field_clip\"), blue);\n      await driver.sendKeys(Key.ESCAPE);\n\n      await gu.assertFillColor(await gu.getCell(\"Test1\", 1).find(\".field_clip\"), red);\n      await gu.assertFillColor(await gu.getCell(\"Test2\", 1).find(\".field_clip\"), transparent);\n\n      // Change both colors.\n      await gu.openCellColorPicker();\n      await gu.setFillColor(blue);\n      await driver.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(await cellColorLabel(), \"Default cell style\");\n      await gu.assertFillColor(await gu.getCell(\"Test1\", 1).find(\".field_clip\"), blue);\n      await gu.assertFillColor(await gu.getCell(\"Test2\", 1).find(\".field_clip\"), blue);\n\n      // Make sure they stick.\n      await driver.navigate().refresh();\n      await gu.waitForDocToLoad();\n      assert.equal(await cellColorLabel(), \"Default cell style\");\n      await gu.assertFillColor(await gu.getCell(\"Test1\", 1).find(\".field_clip\"), blue);\n      await gu.assertFillColor(await gu.getCell(\"Test2\", 1).find(\".field_clip\"), blue);\n    });\n\n    it(\"should change header background for multiple columns\", async () => {\n      const defaultHeaderFillColor = \"rgba(247, 247, 247, 1)\";\n      await selectColumns(\"Test1\", \"Test2\");\n      assert.equal(await headerColorLabel(), \"Default header style\");\n      await gu.openHeaderColorPicker();\n      await gu.setFillColor(blue);\n      await gu.assertHeaderFillColor(\"Test1\", blue);\n      await gu.assertHeaderFillColor(\"Test2\", blue);\n      await driver.sendKeys(Key.ESCAPE);\n      await gu.assertHeaderFillColor(\"Test1\", defaultHeaderFillColor);\n      await gu.assertHeaderFillColor(\"Test2\", defaultHeaderFillColor);\n      assert.equal(await headerColorLabel(), \"Default header style\");\n\n      // Change one header to red\n      await selectColumns(\"Test1\");\n      await gu.openHeaderColorPicker();\n      await gu.setFillColor(red);\n      await driver.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      await gu.assertHeaderFillColor(\"Test1\", red);\n      await gu.assertHeaderFillColor(\"Test2\", defaultHeaderFillColor);\n\n      // Check label and colors for multicolumn selection.\n      await selectColumns(\"Test1\", \"Test2\");\n      assert.equal(await headerColorLabel(), \"Mixed style\");\n      // Try to change to blue, but press escape.\n      await gu.openHeaderColorPicker();\n      await gu.setFillColor(blue);\n      await gu.assertHeaderFillColor(\"Test1\", blue);\n      await gu.assertHeaderFillColor(\"Test2\", blue);\n      await driver.sendKeys(Key.ESCAPE);\n\n      await gu.assertHeaderFillColor(\"Test1\", red);\n      await gu.assertHeaderFillColor(\"Test2\", defaultHeaderFillColor);\n\n      // Change both colors.\n      await gu.openHeaderColorPicker();\n      await gu.setFillColor(blue);\n      await driver.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(await headerColorLabel(), \"Default header style\");\n      await gu.assertHeaderFillColor(\"Test1\", blue);\n      await gu.assertHeaderFillColor(\"Test2\", blue);\n\n      // Make sure they stick.\n      await driver.navigate().refresh();\n      await gu.waitForDocToLoad();\n      assert.equal(await headerColorLabel(), \"Default header style\");\n      await gu.assertHeaderFillColor(\"Test1\", blue);\n      await gu.assertHeaderFillColor(\"Test2\", blue);\n    });\n  });\n\n  describe(`test for Integer column`, function() {\n    beforeEach(async () => {\n      await gu.addColumn(\"Left\", \"Integer\");\n    });\n    afterEach(async function() {\n      if (this.currentTest?.state === \"passed\") {\n        await removeColumn(\"Left\");\n        await removeColumn(\"Right\");\n      }\n    });\n    for (const right of types) {\n      it(`should work with ${right} column`, async function() {\n        await gu.addColumn(\"Right\", right);\n        await selectColumns(\"Left\", \"Right\");\n        if ([\"Toggle\", \"Date\", \"DateTime\", \"Attachment\"].includes(right)) {\n          assert.equal(await wrapDisabled(), true);\n        } else {\n          assert.equal(await wrapDisabled(), false);\n          assert.equal(await wrap(), false);\n        }\n        if ([\"Toggle\", \"Attachment\"].includes(right)) {\n          assert.equal(await alignmentDisabled(), true);\n        } else {\n          assert.equal(await alignmentDisabled(), false);\n        }\n        if ([\"Integer\", \"Numeric\"].includes(right)) {\n          assert.equal(await alignment(), \"right\");\n        } else if ([\"Toggle\", \"Attachment\"].includes(right)) {\n          // With toggle, alignment is unset.\n        } else {\n          assert.equal(await alignment(), null);\n        }\n        if ([\"Toggle\", \"Attachment\"].includes(right)) {\n          // omit tests for alignment\n        } else {\n          await testAlignment();\n        }\n        if ([\"Toggle\", \"Date\", \"DateTime\", \"Attachment\"].includes(right)) {\n          // omit tests for wrap\n        } else if ([\"Choice\"].includes(right)) {\n          // Choice column doesn't support wrapping.\n          await testSingleWrapping();\n        } else {\n          await testWrapping();\n        }\n        await selectColumns(\"Left\", \"Right\");\n        if ([\"Integer\", \"Numeric\"].includes(right)) {\n          // Test number formatting, be default nothing should be set.\n          assert.isFalse(await numberFormattingDisabled());\n          assert.isNull(await numMode());\n\n          for (const mode of [\"decimal\", \"currency\", \"percent\", \"exp\"]) {\n            await selectColumns(\"Left\", \"Right\");\n            await numMode(mode as any);\n            assert.equal(await numMode(), mode);\n            await selectColumns(\"Left\");\n            assert.equal(await numMode(), mode);\n            await selectColumns(\"Right\");\n            assert.equal(await numMode(), mode);\n            await selectColumns(\"Left\", \"Right\");\n            assert.equal(await numMode(), mode);\n          }\n          await selectColumns(\"Left\", \"Right\");\n          await numMode(\"decimal\");\n\n          const decimalsProps = [minDecimals, maxDecimals];\n          for (const decimals of decimalsProps) {\n            await selectColumns(\"Left\", \"Right\");\n            await decimals(5);\n            assert.equal(await decimals(), 5);\n            await selectColumns(\"Left\");\n            assert.equal(await decimals(), 5);\n            await selectColumns(\"Right\");\n            assert.equal(await decimals(), 5);\n            // Set different decimals for left and right.\n            await selectColumns(\"Left\");\n            await decimals(2);\n            await selectColumns(\"Right\");\n            await decimals(4);\n            await selectColumns(\"Left\", \"Right\");\n            assert.isNaN(await decimals()); // default value that is empty\n            // Setting it will reset both.\n            await decimals(8);\n            await selectColumns(\"Left\");\n            assert.equal(await decimals(), 8);\n            await selectColumns(\"Right\");\n            assert.equal(await decimals(), 8);\n          }\n\n          // Clearing will clear both, but only for Numeric columns, Integer\n          // has a default value of 0, that will be set when element is cleared.\n          // TODO: This looks like a buggy behavior, and should be fixed.\n          await selectColumns(\"Left\", \"Right\");\n          await minDecimals(null);\n          await selectColumns(\"Left\");\n          assert.equal(await minDecimals(), 0);\n          await selectColumns(\"Right\");\n          if (right === \"Numeric\") {\n            assert.isNaN(await minDecimals());\n          } else {\n            assert.equal(await minDecimals(), 0);\n          }\n\n          // Clearing max value works as expected.\n          await selectColumns(\"Left\", \"Right\");\n          await maxDecimals(null);\n          await selectColumns(\"Left\");\n          assert.isNaN(await maxDecimals()); // default value that is empty\n          await selectColumns(\"Right\");\n          assert.isNaN(await maxDecimals()); // default value that is empty\n        } else {\n          assert.isTrue(await numberFormattingDisabled());\n        }\n      });\n    }\n  });\n\n  for (const left of [\"Choice\", \"Choice List\"]) {\n    describe(`test for ${left} column`, function() {\n      beforeEach(async () => {\n        await gu.addColumn(\"Left\", left);\n      });\n      afterEach(async function() {\n        if (this.currentTest?.state === \"passed\") {\n          await removeColumn(\"Left\");\n          await removeColumn(\"Right\");\n        }\n      });\n      for (const right of types) {\n        it(`should work with ${right} column`, async function() {\n          await gu.addColumn(\"Right\", right);\n          await selectColumns(\"Left\", \"Right\");\n          if ([\"Choice\", \"Choice List\"].includes(right)) {\n            await testChoices();\n          } else {\n            assert.isTrue(await choiceEditorDisabled());\n          }\n\n          if (left === \"Choice List\") {\n            if ([\"Toggle\", \"Date\", \"DateTime\", \"Attachment\"].includes(right)) {\n              assert.equal(await wrapDisabled(), true);\n            } else {\n              assert.equal(await wrapDisabled(), false);\n              assert.equal(await wrap(), false);\n            }\n          }\n\n          if ([\"Toggle\", \"Attachment\"].includes(right)) {\n            assert.equal(await alignmentDisabled(), true);\n          } else {\n            assert.equal(await alignmentDisabled(), false);\n          }\n          if ([\"Integer\", \"Numeric\"].includes(right)) {\n            assert.equal(await alignment(), null);\n          } else if ([\"Toggle\", \"Attachment\"].includes(right)) {\n            // With toggle, alignment is unset.\n          } else {\n            assert.equal(await alignment(), \"left\");\n          }\n          if ([\"Toggle\", \"Attachment\"].includes(right)) {\n            // omit tests for alignment\n          } else {\n            await testAlignment();\n          }\n\n          // Choice doesn't support wrapping.\n          if (left === \"Choice List\") {\n            if ([\"Toggle\", \"Date\", \"DateTime\", \"Attachment\"].includes(right)) {\n              // omit tests for wrap\n            } else if ([\"Choice\"].includes(right)) {\n              // Choice column doesn't support wrapping.\n              await testSingleWrapping();\n            } else {\n              await testWrapping();\n            }\n          }\n        });\n      }\n    });\n  }\n\n  for (const left of [\"Reference\", \"Reference List\"]) {\n    describe(`test for ${left} column`, function() {\n      beforeEach(async () => {\n        await gu.addColumn(\"Left\", left);\n      });\n      afterEach(async function() {\n        if (this.currentTest?.state === \"passed\") {\n          await removeColumn(\"Left\");\n          await removeColumn(\"Right\");\n        }\n      });\n      // Test for types that matter (have different set of defaults).\n      for (const right of [\"Any\", \"Reference\", \"Reference List\", \"Toggle\", \"Integer\"]) {\n        it(`should work with ${right} column`, async function() {\n          await gu.addColumn(\"Right\", right);\n          await selectColumns(\"Left\", \"Right\");\n          assert.isTrue(await refControlsDisabled(), \"Reference controls should be disabled\");\n          await commonTestsForAny(right);\n        });\n      }\n    });\n  }\n\n  describe(`test for Date column`, function() {\n    beforeEach(async () => {\n      await gu.addColumn(\"Left\", \"Date\");\n    });\n    afterEach(async function() {\n      if (this.currentTest?.state === \"passed\") {\n        await removeColumn(\"Left\");\n        await removeColumn(\"Right\");\n      }\n    });\n    for (const right of types) {\n      it(`should work with ${right} column`, async function() {\n        await gu.addColumn(\"Right\", right);\n        await selectColumns(\"Left\", \"Right\");\n        if ([\"Date\", \"DateTime\"].includes(right)) {\n          assert.isFalse(await dateFormatDisabled());\n        } else {\n          assert.isTrue(await dateFormatDisabled());\n        }\n        if ([\"Toggle\", \"Attachment\"].includes(right)) {\n          assert.equal(await alignmentDisabled(), true);\n        } else {\n          assert.equal(await alignmentDisabled(), false);\n        }\n        if ([\"Integer\", \"Numeric\"].includes(right)) {\n          assert.equal(await alignment(), null);\n        } else if ([\"Toggle\", \"Attachment\"].includes(right)) {\n          // With toggle, alignment is unset.\n        } else {\n          assert.equal(await alignment(), \"left\");\n        }\n        if ([\"Toggle\", \"Attachment\"].includes(right)) {\n          // omit tests for alignment\n        } else {\n          await testAlignment();\n        }\n      });\n      if ([\"Date\", \"DateTime\"].includes(right)) {\n        it(`should change format with ${right} column`, async function() {\n          await gu.addColumn(\"Right\", right);\n          await selectColumns(\"Left\", \"Right\");\n          assert.isFalse(await dateFormatDisabled());\n          // Test for mixed format.\n          await selectColumns(\"Left\");\n          await dateFormat(\"MM/DD/YY\");\n          await selectColumns(\"Left\", \"Right\");\n          assert.equal(await dateFormat(), \"Mixed format\");\n          // Test that both change when format is changed.\n          for (const mode of [\"MM/DD/YY\", \"DD-MM-YYYY\"]) {\n            await dateFormat(mode);\n            await selectColumns(\"Left\");\n            assert.equal(await dateFormat(), mode);\n            await selectColumns(\"Right\");\n            assert.equal(await dateFormat(), mode);\n            await selectColumns(\"Left\", \"Right\");\n            assert.equal(await dateFormat(), mode);\n          }\n          // Test that custom format works\n          await gu.setCustomDateFormat(\"MM\");\n          await selectColumns(\"Left\");\n          assert.equal(await gu.getDateFormat(), \"MM\");\n          await selectColumns(\"Right\");\n          assert.equal(await gu.getDateFormat(), \"MM\");\n          await selectColumns(\"Left\", \"Right\");\n          assert.equal(await gu.getDateFormat(), \"MM\");\n          // Test that we can go back to normal format.\n          await gu.setDateFormat(\"MM/DD/YY\");\n          assert.isFalse(await customDateFormatVisible());\n          await selectColumns(\"Left\");\n          assert.isFalse(await customDateFormatVisible());\n          assert.equal(await gu.getDateFormat(), \"MM/DD/YY\");\n          await selectColumns(\"Right\");\n          assert.isFalse(await customDateFormatVisible());\n          assert.equal(await gu.getDateFormat(), \"MM/DD/YY\");\n        });\n      }\n    }\n  });\n\n  describe(`test for Toggle column`, function() {\n    beforeEach(async () => {\n      await gu.addColumn(\"Left\", \"Toggle\");\n    });\n    afterEach(async function() {\n      if (this.currentTest?.state === \"passed\") {\n        await removeColumn(\"Left\");\n        await removeColumn(\"Right\");\n      }\n    });\n    for (const right of types) {\n      it(`should work with ${right} column`, async function() {\n        await gu.addColumn(\"Right\", right);\n        // There is not match to test\n        if (right === \"Toggle\") {\n          await selectColumns(\"Left\", \"Right\");\n          assert.isFalse(await widgetTypeDisabled());\n          // Test for mixed format.\n          await selectColumns(\"Left\");\n          await gu.setFieldWidgetType(\"TextBox\");\n          await selectColumns(\"Right\");\n          await gu.setFieldWidgetType(\"CheckBox\");\n          await selectColumns(\"Left\", \"Right\");\n          assert.equal(await gu.getFieldWidgetType(), \"Mixed format\");\n          // Test that both change when format is changed.\n          for (const mode of [\"TextBox\", \"CheckBox\", \"Switch\"]) {\n            await gu.setFieldWidgetType(mode);\n            await selectColumns(\"Left\");\n            assert.equal(await gu.getFieldWidgetType(), mode);\n            await selectColumns(\"Right\");\n            assert.equal(await gu.getFieldWidgetType(), mode);\n            await selectColumns(\"Left\", \"Right\");\n            assert.equal(await gu.getFieldWidgetType(), mode);\n          }\n        } else {\n          await selectColumns(\"Left\", \"Right\");\n          assert.isTrue(await widgetTypeDisabled());\n        }\n      });\n    }\n  });\n\n  // Any and Text column are identical in terms of formatting.\n  for (const left of [\"Text\", \"Any\"]) {\n    describe(`test for ${left} column`, function() {\n      beforeEach(async () => {\n        await gu.addColumn(\"Left\", left);\n      });\n      afterEach(async function() {\n        if (this.currentTest?.state === \"passed\") {\n          await removeColumn(\"Left\");\n          await removeColumn(\"Right\");\n        }\n      });\n      for (const right of types) {\n        it(`should work with ${right} column`, async function() {\n          await gu.addColumn(\"Right\", right);\n          await selectColumns(\"Left\", \"Right\");\n          if (left === \"Text\") {\n            if (right === \"Text\") {\n              assert.isFalse(await widgetTypeDisabled());\n            } else {\n              assert.isTrue(await widgetTypeDisabled());\n            }\n          }\n          await commonTestsForAny(right);\n        });\n      }\n    });\n  }\n\n  describe(`test for Attachment column`, function() {\n    beforeEach(async () => {\n      await gu.addColumn(\"Left\", \"Attachment\");\n    });\n    afterEach(async function() {\n      if (this.currentTest?.state === \"passed\") {\n        await removeColumn(\"Left\");\n        await removeColumn(\"Right\");\n      }\n    });\n    // Test for types that matter (have different set of defaults).\n    for (const right of [\"Any\", \"Attachment\"]) {\n      it(`should work with ${right} column`, async function() {\n        await gu.addColumn(\"Right\", right);\n        await selectColumns(\"Left\", \"Right\");\n        if (right !== \"Attachment\") {\n          assert.isTrue(await sliderDisabled());\n        } else {\n          assert.isFalse(await sliderDisabled());\n          // Test it works as expected\n          await slider(16); // min value\n          assert.equal(await slider(), 16);\n          await selectColumns(\"Left\");\n          assert.equal(await slider(), 16);\n          await selectColumns(\"Right\");\n          assert.equal(await slider(), 16);\n          // Set max for Right column, left still has minium\n          await slider(96); // max value\n          await selectColumns(\"Left\", \"Right\");\n          // When mixed, slider is in between.\n          assert.equal(await slider(), (96 - 16) / 2 + 16);\n        }\n      });\n    }\n  });\n});\n\nasync function numModeDisabled() {\n  return await hasDisabledSuffix(\".test-numeric-mode\");\n}\n\nasync function numSignDisabled() {\n  return await hasDisabledSuffix(\".test-numeric-sign\");\n}\n\nasync function decimalsDisabled() {\n  const min = await hasDisabledSuffix(\".test-numeric-min-decimals\");\n  const max = await hasDisabledSuffix(\".test-numeric-max-decimals\");\n  return min && max;\n}\n\nasync function numberFormattingDisabled() {\n  return (await numModeDisabled()) && (await numSignDisabled()) && (await decimalsDisabled());\n}\n\nasync function testWrapping(colA: string = \"Left\", colB: string = \"Right\") {\n  await selectColumns(colA, colB);\n  await wrap(true);\n  assert.isTrue(await wrap());\n  assert.isTrue(await colWrap(colA), `${colA} should be wrapped`);\n  assert.isTrue(await colWrap(colB), `${colB} should be wrapped`);\n  await wrap(false);\n  assert.isFalse(await wrap());\n  assert.isFalse(await colWrap(colA), `${colA} should not be wrapped`);\n  assert.isFalse(await colWrap(colB), `${colB} should not be wrapped`);\n\n  // Test common wrapping.\n  await selectColumns(colA);\n  await wrap(true);\n  await selectColumns(colB);\n  await wrap(false);\n  await selectColumns(colA, colB);\n  assert.isFalse(await wrap());\n  await selectColumns(colB);\n  await wrap(true);\n  assert.isTrue(await wrap());\n}\n\nasync function testSingleWrapping(colA: string = \"Left\", colB: string = \"Right\") {\n  await selectColumns(colA, colB);\n  await wrap(true);\n  assert.isTrue(await wrap());\n  assert.isTrue(await colWrap(colA), `${colA} should be wrapped`);\n  await wrap(false);\n  assert.isFalse(await wrap());\n  assert.isFalse(await colWrap(colA), `${colA} should not be wrapped`);\n}\n\nasync function testChoices(colA: string = \"Left\", colB: string = \"Right\") {\n  await selectColumns(colA, colB);\n  assert.equal(await choiceEditor.label(), \"No choices configured\");\n\n  // Add two choices elements.\n  await choiceEditor.edit();\n  await choiceEditor.add(\"one\");\n  await choiceEditor.add(\"two\");\n  await choiceEditor.save();\n\n  // Check that both column have them.\n  await selectColumns(colA);\n  assert.deepEqual(await choiceEditor.read(), [\"one\", \"two\"]);\n  await selectColumns(colB);\n  assert.deepEqual(await choiceEditor.read(), [\"one\", \"two\"]);\n  // Check that they are shown normally and not as mixed.\n  await selectColumns(colA, colB);\n  assert.deepEqual(await choiceEditor.read(), [\"one\", \"two\"]);\n\n  // Modify only one.\n  await selectColumns(colA);\n  await choiceEditor.edit();\n  await choiceEditor.add(\"three\");\n  await choiceEditor.save();\n\n  // Test that we now have a mix.\n  await selectColumns(colA, colB);\n  assert.equal(await choiceEditor.label(), \"Mixed configuration\");\n  // Edit them, but press cancel.\n  await choiceEditor.reset();\n  await choiceEditor.cancel();\n  // Test that we still have a mix.\n  assert.equal(await choiceEditor.label(), \"Mixed configuration\");\n  await selectColumns(colA);\n  assert.deepEqual(await choiceEditor.read(), [\"one\", \"two\", \"three\"]);\n  await selectColumns(colB);\n  assert.deepEqual(await choiceEditor.read(), [\"one\", \"two\"]);\n\n  // Reset them back and add records to the table.\n  await selectColumns(colA, colB);\n  await choiceEditor.reset();\n  await choiceEditor.add(\"one\");\n  await choiceEditor.add(\"two\");\n  await choiceEditor.save();\n  await gu.getCell(colA, 1).click();\n  await gu.sendKeys(\"one\", Key.ENTER);\n  // If this is choice list we need one more enter.\n  if (await getColumnType() === \"Choice List\") {\n    await gu.sendKeys(Key.ENTER);\n  }\n  await gu.waitForServer();\n  await gu.getCell(colB, 1).click();\n  await gu.sendKeys(\"one\", Key.ENTER);\n  if (await getColumnType() === \"Choice List\") {\n    await gu.sendKeys(Key.ENTER);\n  }\n  await gu.waitForServer();\n  // Rename one of the choices.\n  await selectColumns(colA, colB);\n  const undo = await gu.begin();\n  await choiceEditor.edit();\n  await choiceEditor.rename(\"one\", \"one renamed\");\n  await choiceEditor.save();\n  await gu.waitForServer();\n  // Test if grid is ok.\n  assert.equal(await gu.getCell(colA, 1).getText(), \"one renamed\");\n  assert.equal(await gu.getCell(colB, 1).getText(), \"one renamed\");\n  await undo();\n  assert.equal(await gu.getCell(colA, 1).getText(), \"one\");\n  assert.equal(await gu.getCell(colB, 1).getText(), \"one\");\n\n  // Test that colors are also treated as different.\n  await selectColumns(colA, colB);\n  assert.deepEqual(await choiceEditor.read(), [\"one\", \"two\"]);\n  await selectColumns(colA);\n  await choiceEditor.edit();\n  await choiceEditor.color(\"one\", red);\n  await choiceEditor.save();\n  await selectColumns(colA, colB);\n  assert.equal(await choiceEditor.label(), \"Mixed configuration\");\n}\n\nconst choiceEditor = {\n  async hasReset() {\n    return (await driver.find(\".test-choice-list-entry-edit\").getText()) === \"Reset\";\n  },\n  async reset() {\n    await driver.find(\".test-choice-list-entry-edit\").click();\n  },\n  async label() {\n    return await driver.find(\".test-choice-list-entry-row\").getText();\n  },\n  async add(label: string) {\n    await driver.find(\".test-tokenfield-input\").click();\n    await driver.find(\".test-tokenfield-input\").clear();\n    await gu.sendKeys(label, Key.ENTER);\n  },\n  async rename(label: string, label2: string) {\n    const entry = await driver.findWait(`.test-choice-list-entry .test-token-label[value='${label}']`, 100);\n    await entry.click();\n    await gu.sendKeys(label2);\n    await gu.sendKeys(Key.ENTER);\n  },\n  async color(token: string, color: string) {\n    const label = await driver.findWait(`.test-choice-list-entry .test-token-label[value='${token}']`, 100);\n    await label.findClosest(\".test-tokenfield-token\").find(\".test-color-button\").click();\n    await gu.setFillColor(color);\n    await gu.sendKeys(Key.ENTER);\n  },\n  async read() {\n    return await driver.findAll(\".test-choice-list-entry-label\", e => e.getText());\n  },\n  async edit() {\n    await this.reset();\n  },\n  async save() {\n    await driver.find(\".test-choice-list-entry-save\").click();\n    await gu.waitForServer();\n  },\n  async cancel() {\n    await driver.find(\".test-choice-list-entry-cancel\").click();\n  },\n};\n\nasync function testAlignment(colA: string = \"Left\", colB: string = \"Right\") {\n  await selectColumns(colA, colB);\n  await alignment(\"left\");\n  assert.equal(await colAlignment(colA), \"left\", `${colA} alignment should be left`);\n  assert.equal(await colAlignment(colB), \"left\", `${colB} alignment should be left`);\n  assert.equal(await alignment(), \"left\", \"Alignment should be left\");\n  await alignment(\"center\");\n  assert.equal(await colAlignment(colA), \"center\", `${colA} alignment should be center`);\n  assert.equal(await colAlignment(colB), \"center\", `${colB} alignment should be center`);\n  assert.equal(await alignment(), \"center\", \"Alignment should be center\");\n  await alignment(\"right\");\n  assert.equal(await colAlignment(colA), \"right\", `${colA} alignment should be right`);\n  assert.equal(await colAlignment(colB), \"right\", `${colB} alignment should be right`);\n  assert.equal(await alignment(), \"right\", \"Alignment should be right\");\n\n  // Now align first column to left, and second to right.\n  await selectColumns(colA);\n  await alignment(\"left\");\n  await selectColumns(colB);\n  await alignment(\"right\");\n  // And test we don't have alignment set.\n  await selectColumns(colA, colB);\n  assert.isNull(await alignment());\n\n  // Now change alignment of first column to right, so that we have common alignment.\n  await selectColumns(colA);\n  await alignment(\"right\");\n  await selectColumns(colA, colB);\n  assert.equal(await alignment(), \"right\");\n}\n\nasync function colWrap(col: string) {\n  const cell = await gu.getCell(col, 1).find(\".field_clip\");\n  let hasTextWrap = await cell.matches(\"[class*=text_wrapping]\");\n  if (!hasTextWrap) {\n    // We can be in a choice column, where wrapping is done differently.\n    hasTextWrap = await cell.matches(\"[class*=-wrap]\");\n  }\n  return hasTextWrap;\n}\n\nasync function colAlignment(col: string) {\n  // TODO: unify how widgets are aligned.\n  let cell = await gu.getCell(col, 1).find(\".field_clip\");\n  let style = await cell.getAttribute(\"style\");\n  if (!style) {\n    // We might have a choice column, use flex attribute of first child;\n    cell = await gu.getCell(col, 1).find(\".field_clip > div\");\n    style = await cell.getAttribute(\"style\");\n    // Get justify-content style\n    const match = style.match(/justify-content: ([\\w-]+)/);\n    if (!match) { return null; }\n    switch (match[1]) {\n      case \"left\": return \"left\";\n      case \"center\": return \"center\";\n      case \"flex-end\": return \"right\";\n    }\n  }\n  let match = style.match(/text-align: (\\w+)/);\n  if (!match) {\n    // We might be in a choice list column, so check if we have a flex attribute.\n    match = style.match(/justify-content: ([\\w-]+)/);\n  }\n  if (!match) { return null; }\n  return match[1] === \"flex-end\" ? \"right\" : match[1];\n}\n\nasync function wrap(state?: boolean) {\n  const buttons = await driver.findAll(\".test-tb-wrap-text .test-select-button\");\n  if (buttons.length !== 1) {\n    assert.isUndefined(state, \"Can't set wrap\");\n    return undefined;\n  }\n  if (await buttons[0].matches(\"[class*=-selected]\")) {\n    if (state === false) {\n      await buttons[0].click();\n      await gu.waitForServer();\n      return false;\n    }\n    return true;\n  }\n  if (state === true) {\n    await buttons[0].click();\n    await gu.waitForServer();\n    return true;\n  }\n  return false;\n}\n\n// Many controls works the same as any column for wrapping and alignment.\nasync function commonTestsForAny(right: string) {\n  await selectColumns(\"Left\", \"Right\");\n  if ([\"Toggle\", \"Date\", \"DateTime\", \"Attachment\"].includes(right)) {\n    assert.equal(await wrapDisabled(), true);\n  } else {\n    assert.equal(await wrapDisabled(), false);\n    assert.equal(await wrap(), false);\n  }\n  if ([\"Toggle\", \"Attachment\"].includes(right)) {\n    assert.equal(await alignmentDisabled(), true);\n  } else {\n    assert.equal(await alignmentDisabled(), false);\n  }\n  if ([\"Integer\", \"Numeric\"].includes(right)) {\n    assert.equal(await alignment(), null);\n  } else if ([\"Toggle\", \"Attachment\"].includes(right)) {\n    // With toggle, alignment is unset.\n  } else {\n    assert.equal(await alignment(), \"left\");\n  }\n  if ([\"Toggle\", \"Attachment\"].includes(right)) {\n    // omit tests for alignment\n  } else {\n    await testAlignment();\n  }\n  if ([\"Toggle\", \"Date\", \"DateTime\", \"Attachment\"].includes(right)) {\n    // omit tests for wrap\n  } else if ([\"Choice\"].includes(right)) {\n    // Choice column doesn't support wrapping.\n    await testSingleWrapping();\n  } else {\n    await testWrapping();\n  }\n}\n\nasync function selectColumns(col1: string, col2?: string) {\n  // Clear selection in grid.\n  await driver.executeScript(\"gristDocPageModel.gristDoc.get().currentView.get().clearSelection();\");\n  if (col2 === undefined) {\n    await gu.selectColumn(col1);\n  } else {\n    // First make sure we start with col1 selected.\n    await gu.selectColumnRange(col1, col2);\n  }\n}\n\nasync function alignmentDisabled() {\n  return await hasDisabledSuffix(\".test-alignment-select\");\n}\n\nasync function choiceEditorDisabled() {\n  return await hasDisabledSuffix(\".test-choice-list-entry\");\n}\n\nasync function alignment(value?: \"left\" | \"right\" | \"center\") {\n  const buttons = await driver.findAll(\".test-alignment-select .test-select-button\");\n  if (buttons.length !== 3) {\n    assert.isUndefined(value, \"Can't set alignment\");\n    return undefined;\n  }\n  if (value) {\n    if (value === \"left\") {\n      await buttons[0].click();\n    }\n    if (value === \"center\") {\n      await buttons[1].click();\n    }\n    if (value === \"right\") {\n      await buttons[2].click();\n    }\n    await gu.waitForServer();\n    return;\n  }\n  if (await buttons[0].matches(\"[class*=-selected]\")) {\n    return \"left\";\n  }\n  if (await buttons[1].matches(\"[class*=-selected]\")) {\n    return \"center\";\n  }\n  if (await buttons[2].matches(\"[class*=-selected]\")) {\n    return \"right\";\n  }\n  return null;\n}\n\nasync function dateFormatDisabled() {\n  const format = await driver.find(\"[data-test-id=Widget_dateFormat]\");\n  return await format.matches(\".disabled\");\n}\n\nasync function customDateFormatVisible() {\n  const control = driver.find(\"[data-test-id=Widget_dateCustomFormat]\");\n  return await control.isPresent();\n}\n\nasync function dateFormat(format?: string) {\n  if (!format) {\n    return await gu.getDateFormat();\n  }\n  await driver.find(\"[data-test-id=Widget_dateFormat]\").click();\n  await gu.findOpenMenuItem(\"li\", gu.exactMatch(format)).click();\n  await gu.waitForServer();\n}\n\nasync function widgetTypeDisabled() {\n  // Maybe we have selectbox\n  const selectbox = await driver.findAll(\".test-fbuilder-widget-select .test-select-open\");\n  if (selectbox.length === 1) {\n    return await selectbox[0].matches(\".disabled\");\n  }\n  const buttons = await driver.findAll(\".test-fbuilder-widget-select > div\");\n  const allDisabled = await Promise.all(buttons.map(button => button.matches(\"[class*=-disabled]\")));\n  return allDisabled.every(disabled => disabled) && allDisabled.length > 0;\n}\n\nasync function labelDisabled() {\n  return (await driver.find(\".test-field-label\").getAttribute(\"readonly\")) === \"true\";\n}\n\nasync function colIdDisabled() {\n  return (await driver.find(\".test-field-col-id\").getAttribute(\"readonly\")) === \"true\";\n}\n\nasync function hasDisabledSuffix(selector: string) {\n  return (await driver.find(selector).matches(\"[class*=-disabled]\"));\n}\n\nasync function hasDisabledClass(selector: string) {\n  return (await driver.find(selector).matches(\".disabled\"));\n}\n\nasync function deriveDisabled() {\n  return await hasDisabledSuffix(\".test-field-derive-id\");\n}\n\nasync function toggleDerived() {\n  await driver.find(\".test-field-derive-id\").click();\n  await gu.waitForServer();\n}\n\nasync function wrapDisabled() {\n  return (await driver.find(\".test-tb-wrap-text > div\").matches(\"[class*=disabled]\"));\n}\n\nasync function columnTypeDisabled() {\n  return await hasDisabledClass(\".test-fbuilder-type-select .test-select-open\");\n}\n\nasync function getColumnType() {\n  return await driver.find(\".test-fbuilder-type-select\").getText();\n}\n\nasync function setFormulaDisabled() {\n  return (await driver.find(\".test-field-set-formula\").getAttribute(\"disabled\")) === \"true\";\n}\n\nasync function formulaEditorDisabled() {\n  return await hasDisabledSuffix(\".formula_field_sidepane\");\n}\n\nasync function setTriggerDisabled() {\n  return (await driver.find(\".test-field-set-trigger\").getAttribute(\"disabled\")) === \"true\";\n}\n\nasync function refControlsDisabled() {\n  return (await hasDisabledClass(\".test-fbuilder-ref-table-select .test-select-open\")) &&\n    (await hasDisabledClass(\".test-fbuilder-ref-col-select .test-select-open\"));\n}\n\nasync function setDataDisabled() {\n  return (await driver.find(\".test-field-set-data\").getAttribute(\"disabled\")) === \"true\";\n}\n\nasync function transformSectionDisabled() {\n  return (await driver.find(\".test-fbuilder-edit-transform\").getAttribute(\"disabled\")) === \"true\";\n}\n\nasync function addConditionDisabled() {\n  return (await driver.find(\".test-widget-style-add-conditional-style\").getAttribute(\"disabled\")) === \"true\";\n}\n\nasync function addAnyColumn(name: string) {\n  await gu.sendActions([\n    [\"AddVisibleColumn\", \"Table1\", name, {}],\n  ]);\n  await gu.waitForServer();\n}\n\nasync function removeColumn(...names: string[]) {\n  await gu.sendActions([\n    ...names.map(name => ([\"RemoveColumn\", \"Table1\", name])),\n  ]);\n  await gu.waitForServer();\n}\n\nfunction maxDecimals(value?: number | null) {\n  return modDecimals(\".test-numeric-max-decimals input\", value);\n}\n\nfunction minDecimals(value?: number | null) {\n  return modDecimals(\".test-numeric-min-decimals input\", value);\n}\n\nasync function modDecimals(selector: string, value?: number | null) {\n  const element = await driver.find(selector);\n  if (value === undefined) {\n    return parseInt(await element.value());\n  } else {\n    await element.click();\n    if (value !== null) {\n      await element.sendKeys(value.toString());\n    } else {\n      await element.doClear();\n    }\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n  }\n}\n\nasync function numMode(value?: \"currency\" | \"percent\" | \"exp\" | \"decimal\") {\n  const mode = await driver.findAll(\".test-numeric-mode\");\n  if (value !== undefined) {\n    if (mode.length === 0) {\n      assert.fail(\"No number format\");\n    }\n    if (value === \"currency\") {\n      if (await numMode() !== \"currency\") {\n        await driver.findContent(\".test-numeric-mode .test-select-button\", /\\$/).click();\n      }\n    } else if (value === \"percent\") {\n      if (await numMode() !== \"percent\") {\n        await driver.findContent(\".test-numeric-mode .test-select-button\", /%/).click();\n      }\n    } else if (value === \"decimal\") {\n      if (await numMode() !== \"decimal\") {\n        await driver.findContent(\".test-numeric-mode .test-select-button\", /,/).click();\n      }\n    } else if (value === \"exp\") {\n      if (await numMode() !== \"exp\") {\n        await driver.findContent(\".test-numeric-mode .test-select-button\", /Exp/).click();\n      }\n    }\n    await gu.waitForServer();\n  }\n  if (mode.length === 0) {\n    return undefined;\n  }\n  const curr = await driver.findContent(\".test-numeric-mode .test-select-button\", /\\$/).matches(\"[class*=-selected]\");\n  if (curr) {\n    return \"currency\";\n  }\n  const decimal = await driver.findContent(\".test-numeric-mode .test-select-button\", /,/).matches(\"[class*=-selected]\");\n  if (decimal) {\n    return \"decimal\";\n  }\n  const percent = await driver.findContent(\".test-numeric-mode .test-select-button\", /%/).matches(\"[class*=-selected]\");\n  if (percent) {\n    return \"percent\";\n  }\n  const exp = await driver.findContent(\".test-numeric-mode .test-select-button\", /Exp/).matches(\"[class*=-selected]\");\n  if (exp) {\n    return \"exp\";\n  }\n  return null;\n}\n\nasync function sliderDisabled() {\n  return (await driver.find(\".test-pw-thumbnail-size\").getAttribute(\"disabled\")) === \"true\";\n}\n\nasync function slider(value?: number) {\n  if (value !== undefined) {\n    await driver.executeScript(`\n    document.querySelector('.test-pw-thumbnail-size').value = '${value}';\n    document.querySelector('.test-pw-thumbnail-size').dispatchEvent(new Event('change'));\n    `);\n    await gu.waitForServer();\n  }\n  return parseInt(await driver.find(\".test-pw-thumbnail-size\").getAttribute(\"value\"));\n}\n\nasync function cellColorLabel() {\n  // Text actually contains T symbol before.\n  const label = await driver.find(\".test-cell-color-select .test-color-select\").getText();\n  return label.replace(/^T/, \"\").trim();\n}\n\nasync function headerColorLabel() {\n  // Text actually contains T symbol before.\n  const label = await driver.find(\".test-header-color-select .test-color-select\").getText();\n  return label.replace(/^T/, \"\").trim();\n}\n"
  },
  {
    "path": "test/nbrowser/NewDocument.ntest.js",
    "content": "/* global window */\n\nimport { assert, driver } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"NewDocument.ntest\", function() {\n  test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should create new Untitled document\", async function() {\n    this.timeout(10000);\n    await gu.actions.createNewDoc(\"Untitled\");\n    assert.equal(await gu.actions.getDocTitle(), \"Untitled\");\n\n    const expectedTitle = \"Untitled - Grist\";\n    assert.equal(await driver.getTitle(), expectedTitle);\n    assert.equal(await driver.find('meta[name=\"twitter:title\"]').getAttribute(\"content\"), expectedTitle);\n    assert.equal(await driver.find('meta[property=\"og:title\"]').getAttribute(\"content\"), expectedTitle);\n\n    const expectedDescription = \"A modern, open source spreadsheet that goes beyond the grid\";\n    assert.equal(await driver.find('meta[name=\"description\"]').getAttribute(\"content\"), expectedDescription);\n    assert.equal(await driver.find('meta[name=\"twitter:description\"]').getAttribute(\"content\"), expectedDescription);\n    assert.equal(await driver.find('meta[property=\"og:description\"]').getAttribute(\"content\"), expectedDescription);\n\n    const gristIconFileName = \"opengraph-preview-image.png\";\n    assert.include(await driver.find('meta[name=\"thumbnail\"]').getAttribute(\"content\"), gristIconFileName);\n    assert.include(await driver.find('meta[name=\"twitter:image\"]').getAttribute(\"content\"), gristIconFileName);\n    assert.include(await driver.find('meta[property=\"og:image\"]').getAttribute(\"content\"), gristIconFileName);\n\n    assert.equal(await $(\".active_section .test-viewsection-title\").wait().text(), \"TABLE1\");\n    await gu.waitForServer();\n  });\n\n  it(\"should start with a 1x3 grid\", async function() {\n    await $(\".record.record-add\").wait();\n    assert.lengthOf(await $(\".grid_view_data .record:not(.column_names)\").array(), 1, 'should have 1 row (\"add\" row)');\n    assert.lengthOf(await $(\".column_names .column_name\").array(), 4, 'should have 3 columns and 1 \"add\" column');\n  });\n\n  it(\"should have first cell selected\", async function() {\n    assert.isDisplayed(await gu.getCellRC(0, 0).find(\".active_cursor\"));\n  });\n\n  it(\"should open notify toasts on errors\", async function() {\n    // Verify that uncaught exceptions and errors from server cause the notifications box to open.\n\n    // For a plain browser error, we attach an error-throwing handler to click-on-logo.\n    await driver.executeScript(\n      'setTimeout(() => window.gristApp.testTriggerError(\"Our fake error\"))', 0);\n\n    // Wait for the notifications window to open and check it has the error we expect.\n    await $(\".test-notifier-toast-message\").wait(1, assert.isDisplayed);\n    assert.match(await $(\".test-notifier-toast-message\").last().text(), /Our fake error/);\n\n    // Close the notifications window.\n    await $(\".test-notifier-toast-close\").click();\n    await assert.isPresent($(\".test-notifier-toast-message\"), false);\n\n    // Try a server command that should fail. We need a reasonble timeout for executeAsyncScript.\n    await driver.manage().setTimeouts({script: 500});\n    let result = await driver.executeAsyncScript(() => {\n      var cb = arguments[arguments.length - 1];\n      window.gristApp.comm.getDocList()\n        .then(\n          newName => cb(\"unexpected success\"),\n          err => { cb(err.toString()); throw err; }\n        );\n    });\n    assert.match(result, /Unknown method getDocList/);\n\n    // Now make sure the notifications window is open and has the error we expect.\n    await assert.isDisplayed($(\".test-notifier-toast-message\"));\n    assert.match(await $(\".test-notifier-toast-message\").last().text(), /Unknown method getDocList/);\n\n    // Close the notifications window.\n    await $(\".test-notifier-toast-close\").click();\n    await assert.isPresent($(\".test-notifier-toast-message\"), false);\n\n    assert.deepEqual(await driver.executeScript(() => window.getAppErrors()),\n      [\"Our fake error\", \"Unknown method getDocList\"]);\n    await driver.executeScript(\n      \"setTimeout(() => window.gristApp.topAppModel.notifier.clearAppErrors())\");\n  });\n\n  describe(\"Cell editing\", function() {\n\n    it(\"should add rows on entering new data\", async function() {\n      assert.equal(await gu.getGridRowCount(), 1);\n      await gu.getCellRC(0, 0).click();\n      await gu.sendKeys(\"hello\", $.ENTER);\n      await gu.waitForServer();\n      await gu.getCellRC(1, 1).click();\n      await gu.sendKeys(\"world\", $.ENTER);\n      await gu.waitForServer();\n      assert.equal(await gu.getGridRowCount(), 3);\n    });\n\n    it(\"should edit on Enter, cancel on Escape, save on Enter\", async function() {\n      var cell_1_b = gu.getCellRC(0, 1);\n      assert.equal(await cell_1_b.text(), \"\");\n      await cell_1_b.click();\n\n      await gu.sendKeys($.ENTER);\n      await $(\".test-widget-text-editor\").wait();\n      await gu.sendKeys(\"foo\", $.ESCAPE);\n      await gu.waitForServer();\n      assert.equal(await cell_1_b.text(), \"\");\n\n      await gu.sendKeys($.ENTER);\n      await $(\".test-widget-text-editor\").wait();\n      await gu.sendKeys(\"bar\", $.ENTER);\n      await gu.waitForServer();\n      assert.equal(await cell_1_b.text(), \"bar\");\n    });\n\n    it(\"should append to cell with content on Enter\", async function() {\n      var cell_1_a = gu.getCellRC(0, 0);\n      assert.equal(await cell_1_a.text(), \"hello\");\n      await cell_1_a.click();\n\n      await gu.sendKeys($.ENTER);\n      await $(\".test-widget-text-editor\").wait();\n      assert.equal(await $(\".test-widget-text-editor textarea\").val(), \"hello\");\n      await gu.sendKeys(\", world!\", $.ENTER);\n      await gu.waitForServer();\n\n      assert.equal(await cell_1_a.text(), \"hello, world!\");\n    });\n\n    it(\"should clear data in selected cells on Backspace and Delete\", async function() {\n      let testDelete = async function(delKey) {\n        // should clear a single cell\n        var cell_1_a = gu.getCellRC(0, 0);\n        await cell_1_a.click();\n        await gu.sendKeys(\"A1\", $.ENTER);\n        await gu.waitForServer();\n        assert.equal(await cell_1_a.text(), \"A1\");\n        await cell_1_a.click();\n        await gu.sendKeys(delKey);\n        await gu.waitForServer();\n        assert.equal(await cell_1_a.text(), \"\");\n\n        // should clear a selection of cells\n        await gu.enterGridValues(0, 0, [[\"A1\", \"A2\"], [\"B1\", \"B2\"]]);\n        await gu.waitForServer();\n        assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1] }), [\"A1\", \"B1\", \"A2\", \"B2\"]);\n        await cell_1_a.click();\n        await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN], delKey);\n        await gu.waitForServer();\n        assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1] }), [\"\", \"\", \"\", \"\"]);\n\n        // should clear a selection of cells with a formula column\n        await gu.enterGridValues(0, 0, [[\"A1\", \"A2\"], [\"B1\", \"B2\"]]);\n        await gu.clickCellRC(0, 2);\n        await gu.sendKeys(\"=\", \"$A\", $.ENTER);\n        await gu.waitForServer();\n        assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1, 2] }),\n          [\"A1\", \"B1\", \"A1\", \"A2\", \"B2\", \"A2\"]);\n        await gu.clickCellRC(0, 1);\n        await gu.sendKeys([$.SHIFT, $.RIGHT], [$.SHIFT, $.DOWN], delKey);\n        await gu.waitForServer();\n        assert.deepEqual(await gu.getGridValues({ rowNums: [1, 2], cols: [0, 1, 2] }),\n          [ \"A1\", \"\", \"A1\", \"A2\", \"\", \"A2\" ]);\n      };\n      await testDelete($.BACK_SPACE);\n      await testDelete($.DELETE);\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/NumericEditor.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\nimport type { UserAPI } from \"app/common/UserAPI\";\n\ndescribe(\"NumericEditor\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  afterEach(() => gu.checkForErrors());\n\n  interface Entry {\n    initial: string;\n    workaround?: () => Promise<void>;\n    expect: string;\n    exp0: string;\n    expPlain: string;\n    expFmt: string;\n  }\n  interface TestOpts {\n    localeCode: string;\n    locale: string;\n    entriesByColumn: Entry[];\n    valPlain: string;\n    valFmt: string;\n  }\n\n  describe(\"locale en-US\", testSuite({\n    localeCode: \"en-US\",\n    locale: \"United States (English)\",\n    entriesByColumn: [\n      { initial: \"17\",       expect: \"17\",      exp0: \"0\",     expPlain: \"-4.4\",   expFmt: \"-1234.56\"  },\n      { initial: \"17.500\",   expect: \"17.500\",  exp0: \"0.000\", expPlain: \"-4.400\", expFmt: \"-1234.560\" },\n      { initial: \"(1.2)\",    expect: \"(1.2)\",   exp0: \" 0 \",   expPlain: \"(4.4)\",  expFmt: \"(1234.56)\" },\n      { initial: \"1000.456\", expect: \"1000.456\", exp0: \"0\",    expPlain: \"-4.4\",   expFmt: \"-1234.56\"  },\n      { initial: \"1,000.0\",  expect: \"1,000.0\", exp0: \"0.0\",   expPlain: \"-4.4\",   expFmt: \"-1,234.56\" },\n      { initial: \"$5.00\",    expect: \"$5.00\",   exp0: \"$0.00\", expPlain: \"-$4.40\", expFmt: \"-$1,234.56\" },\n      { initial: \"4.5%\",     expect: \"4.5%\",    exp0: \"0%\",    expPlain: \"-440%\",  expFmt: \"-123,456%\" },\n    ],\n    valPlain: \"-4.4\",\n    valFmt: \"(1,234.56)\",\n  }));\n\n  describe(\"locale de-DE\", testSuite({\n    localeCode: \"de-DE\",\n    locale: \"Germany (German)\",\n    entriesByColumn: [\n      { initial: \"17\",       expect: \"17\",      exp0: \"0\",      expPlain: \"-4,4\",    expFmt: \"-1234,56\"    },\n      { initial: \"17,500\",   expect: \"17,500\",  exp0: \"0,000\",  expPlain: \"-4,400\",  expFmt: \"-1234,560\"   },\n      { initial: \"(1,2)\",    expect: \"(1,2)\",   exp0: \" 0 \",    expPlain: \"(4,4)\",   expFmt: \"(1234,56)\"   },\n      { initial: \"1000,456\", expect: \"1000,456\", exp0: \"0\",     expPlain: \"-4,4\",    expFmt: \"-1234,56\"    },\n      { initial: \"1.000,0\",  expect: \"1.000,0\", exp0: \"0,0\",    expPlain: \"-4,4\",    expFmt: \"-1.234,56\"  },\n      { initial: \"5,00€\",    expect: \"5,00 €\",  exp0: \"0,00 €\", expPlain: \"-4,40 €\", expFmt: \"-1.234,56 €\" },\n      { initial: \"4,5%\",     expect: \"4,5 %\",   exp0: \"0 %\",    expPlain: \"-440 %\",   expFmt: \"-123.456 %\" },\n    ],\n    valPlain: \"-4,4\",\n    valFmt: \"(1.234,56)\",\n  }));\n\n  function testSuite(options: TestOpts) {\n    const { entriesByColumn } = options;\n\n    return function() {\n      let docId: string;\n      let api: UserAPI;\n      let MODKEY: string;\n\n      before(async function() {\n        MODKEY = await gu.modKey();\n        const session = await gu.session().login();\n        docId = await session.tempNewDoc(cleanup, `NumericEditor-${options.localeCode}`);\n        api = session.createHomeApi();\n\n        await api.applyUserActions(docId, [\n          // Make sure there are as many columns as entriesByColumn.\n          ...entriesByColumn.slice(3).map(() => [\"AddVisibleColumn\", \"Table1\", \"\", {}]),\n        ]);\n\n        // Set locale for the document.\n        await gu.openDocumentSettings();\n        await driver.findWait(\".test-settings-locale-autocomplete\", 500).click();\n        await driver.sendKeys(options.locale, Key.ENTER);\n        await gu.waitForServer();\n        assert.equal(await driver.find(\".test-settings-locale-autocomplete input\").value(), options.locale);\n        await gu.openPage(\"Table1\");\n      });\n\n      beforeEach(async function() {\n        // Scroll grid to the left before each test case.\n        await gu.sendKeys(Key.HOME);\n      });\n\n      it(\"should create Numeric columns with suitable format\", async function() {\n        // Entering a value into an empty column should switch it to a suitably formatted Numeric.\n        for (const [i, entry] of Object.entries(entriesByColumn)) {\n          await gu.getCell({ rowNum: 1, col: Number(i) }).click();\n          await entry.workaround?.();\n          await gu.enterCell(entry.initial);\n          assert.equal(await gu.getCell({ rowNum: 1, col: Number(i) }).getText(), entry.expect);\n        }\n\n        // Add a new row, which should be filled with 0's. Check what formatted 0's look like.\n        await gu.sendKeys(Key.chord(MODKEY, Key.ENTER));\n        await gu.waitForServer();\n\n        // Check what 0's look like.\n        for (const [i, entry] of Object.entries(entriesByColumn)) {\n          assert.equal(await gu.getCell({ rowNum: 2, col: Number(i) }).getText(), entry.exp0);\n        }\n      });\n\n      it(\"should support entering plain numbers into a formatted column\", async function() {\n        const rowNum = 3;\n        const cols = entriesByColumn.map((_, i) => i);\n\n        await gu.enterGridRows({ rowNum, col: 0 },\n          [entriesByColumn.map(() => options.valPlain)]);\n\n        assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [rowNum], cols }),\n          entriesByColumn.map(entry => entry.expPlain));\n      });\n\n      it(\"should support entering formatted numbers into a formatted column\", async function() {\n        const rowNum = 4;\n        const cols = entriesByColumn.map((_, i) => i);\n\n        await gu.enterGridRows({ rowNum, col: 0 },\n          [entriesByColumn.map(() => options.valFmt)]);\n\n        assert.deepEqual(await gu.getVisibleGridCells({ rowNums: [rowNum], cols }),\n          entriesByColumn.map(entry => entry.expFmt));\n      });\n\n      it(\"should allow editing and saving a formatted value\", async function() {\n        for (const [i, entry] of Object.entries(entriesByColumn)) {\n          await gu.getCell({ rowNum: 1, col: Number(i) }).click();\n          await gu.sendKeys(Key.ENTER);\n          await gu.waitAppFocus(false);\n          // Save the value the way it's opened in the editor. It's important that it doesn't\n          // change interpretation (there was a bug related to this, when a value with \",\" decimal\n          // separator would open with a \".\" decimal separator, and get saved back incorrectly).\n          await gu.sendKeys(Key.ENTER);\n          await gu.waitForServer();\n          await gu.waitAppFocus();\n          assert.equal(await gu.getCell({ rowNum: 1, col: Number(i) }).getText(), entry.expect);\n        }\n      });\n    };\n  }\n});\n"
  },
  {
    "path": "test/nbrowser/OnDemand.ts",
    "content": "import { IClipboard } from \"test/nbrowser/gristUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\n// TODO: Assert that non-basic actions work on an onDemand table.\ndescribe(\"OnDemand\", function() {\n  this.timeout(\"1m\");\n  const clipboard = gu.getLockableClipboard();\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n\n  before(async function() {\n    session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"World.grist\");\n  });\n\n  afterEach(async function() {\n    await gu.checkForErrors();\n  });\n\n  it(\"should support marking table as on-demand\", async function() {\n    // Check a couple specific ones, including a reference column.\n    await gu.waitToPass(async () =>\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"Name\", \"Country\"], rowNums: [4, 10] }), [\n        \"Aachen\", \"Germany\",\n        \"Abbotsford\", \"Canada\",\n      ]));\n\n    // Check that we see a lot of cities.\n    await gu.waitToPass(async () => assert.equal(await gu.getGridRowCount(), 4080));\n\n    // Open view config side pane.\n    await gu.openWidgetPanel(\"data\");\n\n    // Make sure \"Advanced options\" is hidden.\n    assert.equal(await driver.find(\"[data-test-id=ViewConfig_advanced]\").isPresent(), false);\n\n    // Clear local storage - we don't want to restore latest position\n    await driver.executeScript(\"window.localStorage.clear();\");\n\n    // Make this table on demand using the api.\n    await makeOnDemand(\"City\");\n\n    // Check that the reference column shows countries\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"Name\", \"Country\"], rowNums: [4, 10] }), [\n      \"Aachen\", \"Germany\",\n      \"Abbotsford\", \"Canada\",\n    ]);\n    // Check that we still see all cities.\n    assert.equal(await gu.getGridRowCount(), 4080);\n\n    // Switch to another view; check that we see other data, including cities.\n    await gu.openPage(\"Country\");\n\n    await gu.getCell({ section: \"Country\", rowNum: 2, col: \"Name\" }).click();\n    await gu.selectSectionByTitle(\"CountryLanguage\");\n    assert.equal(await gu.getGridRowCount(), 6);\n    await gu.selectSectionByTitle(\"City\");\n    assert.equal(await gu.getGridRowCount(), 5);\n    // Linked onDemand table has correct data, but (unfortunately) an unsupported formula column.\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 3], rowNums: [1, 2, 3, 4] }), [\n      \"Kabul\",          \"#Formula not supported\",\n      \"Qandahar\",       \"#Formula not supported\",\n      \"Herat\",          \"#Formula not supported\",\n      \"Mazar-e-Sharif\", \"#Formula not supported\"]);\n\n    // Check that we can get details of the error.\n    await gu.getCell({ section: \"City\", rowNum: 1, col: 3 }).click();\n    await gu.waitAppFocus(true);\n    await gu.sendKeys(Key.ENTER);\n    await gu.waitAppFocus(false);\n    await gu.waitForServer();\n    await gu.waitToPass(async () => {\n      assert.match(await driver.find(\".test-formula-error-msg\").getText(),\n        /Formula not supported.*on-demand.*unmark/si);\n    });\n    await gu.sendKeys(Key.ESCAPE);    // Close the formula editor.\n    await gu.waitAppFocus(true);\n\n    // Unmark the table as \"on-demand\".\n    await gu.openWidgetPanel(\"data\");\n    await driver.findWait(\"[data-test-id=ViewConfig_advanced]\", 2000).click();\n    await driver.findWait(\"[data-test-id=ViewConfig_onDemandBtn]\", 2000).click();\n    await driver.findContentWait(\".test-modal-dialog button\", /Unmark/, 2000).click();\n\n    // Wait for the page to reload, i.e. \"confirm\" dialog to close, and then check wait for title\n    // to be present again.\n    await gu.waitForServer(4000);   // This could take longer, since it waits for doc to re-open\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // See that there are now countries and cities loaded in that view.\n    await gu.getCell({ section: \"Country\", rowNum: 2, col: \"Name\" }).click();\n    await gu.selectSectionByTitle(\"CountryLanguage\");\n    assert.equal(await gu.getGridRowCount(), 6);\n    await gu.selectSectionByTitle(\"City\");\n    assert.equal(await gu.getGridRowCount(), 5);\n    // Now that the table is regular, both data and formulas are correct.\n    await gu.waitToPass(async () =>\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 3], rowNums: [1, 2, 3, 4] }), [\n        \"Kabul\",          \"1780\",\n        \"Qandahar\",       \"238\",\n        \"Herat\",          \"187\",\n        \"Mazar-e-Sharif\", \"128\"]), 1000);\n  });\n\n  it(\"should allow add, update, remove and undo in an on-demand table\", async function() {\n    // Create a new table.\n    await gu.addNewTable(\"New\");\n\n    // Convert the new table to on-demand.\n    await makeOnDemand(\"New\");\n\n    // Add a record to column A of the new table. This also tests a bug with adding records to\n    // a previously empty formula column of an on-demand table.\n    await gu.sendKeys(\"hello\", Key.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1] }), [\n      \"hello\", \"\", \"\"]);\n\n    // Add/update a few more records in the table.\n    await gu.enterGridValues(1, 0, [[\"the\", \"quick\"]]);\n    await gu.enterGridValues(0, 1, [[\"brown\", \"fox\", \"jumped\"]]);\n    await gu.enterGridValues(3, 0, [[\"over\"]]);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3, 4] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\",\n      \"over\",  \"\",       \"\"]);\n\n    // Undo an add action.\n    await gu.undo();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\"]);\n\n    // Add multiple records to the table.\n    await gu.selectGridArea([2, 0], [3, 1]);\n    await clipboard.lockAndPerform(async (cb: IClipboard) => {\n      await cb.copy();\n      await gu.getCell(1, 4).click();\n      await cb.paste();\n    });\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3, 4, 5] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\",\n      \"\",      \"the\",    \"fox\",\n      \"\",      \"quick\",  \"jumped\"]);\n\n    // Undo bulk add action.\n    await gu.undo();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3, 4, 5] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\",\n      \"\",      \"\",       \"\",\n      undefined, undefined, undefined]);\n\n    // Update individual records in the table.\n    await gu.getCell(1, 2).click();\n    await gu.sendKeys(\"dog\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.getCell(0, 3).click();\n    await gu.sendKeys(\"lazy\", Key.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"dog\",    \"\",\n      \"lazy\",  \"jumped\", \"\"]);\n\n    // Undo individual update actions.\n    await gu.undo(2);\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\"]);\n\n    // Update multiple records in the table.\n    await gu.waitAppFocus(true);\n    await gu.selectGridArea([1, 0], [3, 0]);\n    await clipboard.lockAndPerform(async (cb: IClipboard) => {\n      await cb.copy();\n      await gu.getCell(1, 2).click();\n      await cb.paste();\n    });\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3, 4] }), [\n      \"hello\", \"brown\", \"\",\n      \"the\",   \"hello\", \"\",\n      \"quick\", \"the\",   \"\",\n      \"\",      \"quick\", \"\"]);\n\n    // Undo bulk update action.\n    await gu.undo();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\"]);\n\n    // Remove a row from the table.\n    await gu.getCell(0, 2).click();\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n    await gu.waitForServer();\n    await gu.confirm(true, true);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3] }), [\n      \"hello\", \"brown\",  \"\",\n      \"quick\", \"jumped\", \"\",\n      \"\",      \"\",       \"\"]);\n\n    // Undo single remove action.\n    await gu.undo();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\"]);\n\n    // Remove multiple rows from the table.\n    await gu.selectGridArea([1, 0], [2, 0]);\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2] }), [\n      \"quick\", \"jumped\", \"\",\n      \"\",    \"\",    \"\"]);\n\n    // Undo bulk remove action.\n    await gu.undo();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\"]);\n  });\n\n  it(\"should always add records at the end\", async function() {\n    // Add rows in a bunch of different locations and assert their positions.\n    await gu.getCell(0, 4).click();\n    await gu.sendKeys(\"1\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.sendKeys(\"2\", Key.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3, 4, 5] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\",\n      \"1\",     \"\",       \"\",\n      \"2\",     \"\",       \"\"]);\n\n    // Perform removal actions and assert that the rows are re-added to the correct places on undo.\n    await gu.getCell(0, 4).click();\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n    await gu.waitForServer();\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n    await gu.waitForServer();\n\n    await gu.undo();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3, 4] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\",\n      \"2\",     \"\",       \"\"]);\n\n    await gu.undo();\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3, 4, 5] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\",\n      \"1\",     \"\",       \"\",\n      \"2\",     \"\",       \"\"]);\n\n    // Redo all removals and assert that the table is as before the test.\n    await gu.redo(2);\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3] }), [\n      \"hello\", \"brown\",  \"\",\n      \"the\",   \"fox\",    \"\",\n      \"quick\", \"jumped\", \"\"]);\n  });\n\n  it(\"should allow adding functional columns\", async function() {\n    // Add a column.\n    await gu.getCell(2, 1).click();\n    await gu.waitAppFocus(true);\n    await gu.sendKeys(Key.chord(Key.ALT, \"=\"));\n    await gu.waitForServer();\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitAppFocus(true);\n    // Add values to the new columns and make sure no errors arise.\n    await gu.getCell(3, 1).click();\n    await gu.sendKeys(\"abcd\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.waitAppFocus(true);\n    await gu.sendKeys(\"defg\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.waitAppFocus(true);\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2, 3], rowNums: [1, 2, 3] }), [\n      \"hello\", \"brown\",  \"\", \"abcd\",\n      \"the\",   \"fox\",    \"\", \"defg\",\n      \"quick\", \"jumped\", \"\", \"\"]);\n    await gu.checkForErrors();\n  });\n\n  async function makeOnDemand(tableId: string) {\n    const api = session.createHomeApi().getDocAPI(await gu.getDocId());\n    const [table] = await api.getRecords(\"_grist_Tables\", {\n      filters: { tableId: [tableId] },\n    });\n    await gu.sendActions([\n      [\"UpdateRecord\", \"_grist_Tables\", table.id, { onDemand: true }],\n    ]);\n    await api.forceReload();\n    await gu.reloadDoc();\n  }\n});\n"
  },
  {
    "path": "test/nbrowser/Pages.ts",
    "content": "import { DocCreationInfo } from \"app/common/DocListAPI\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport { Session } from \"test/nbrowser/gristUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport values from \"lodash/values\";\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"Pages\", function() {\n  this.timeout(60000);\n  let doc: DocCreationInfo;\n  let api: UserAPI;\n  let session: Session;\n  const cleanup = setupTestSuite({ team: true });\n\n  before(async () => {\n    session = await gu.session().teamSite.login();\n    doc = await session.tempDoc(cleanup, \"Pages.grist\");\n    api = session.createHomeApi();\n    await api.updateDocPermissions(doc.id, {\n      users: {\n        [gu.translateUser(\"user2\").email]: \"viewers\",\n      },\n    });\n  });\n\n  it(\"should show censor pages\", async () => {\n    // Make a 3 level hierarchy.\n    assert.deepEqual(await gu.getPageTree(), [\n      {\n        label: \"Interactions\", children: [\n          { label: \"Documents\" },\n        ],\n      },\n      {\n        label: \"People\", children: [\n          { label: \"User & Leads\" },\n          { label: \"Overview\" },\n        ],\n      },\n    ]);\n    await insertPage(/Overview/, /User & Leads/);\n    assert.deepEqual(await gu.getPageTree(), [\n      {\n        label: \"Interactions\", children: [\n          { label: \"Documents\" },\n        ],\n      },\n      {\n        label: \"People\", children: [\n          { label: \"User & Leads\", children: [\n            { label: \"Overview\" }] },\n        ],\n      },\n    ]);\n    const revertAcl = await gu.beginAclTran(api, doc.id);\n    // Update ACL, hide Overview table from all users.\n    await hideTable(\"Overview\");\n    // We will be reloaded, but it's not easy to wait for it, so do the refresh manually.\n    await gu.reloadDoc();\n    assert.deepEqual(await gu.getPageTree(), [\n      {\n        label: \"Interactions\", children: [\n          { label: \"Documents\" },\n        ],\n      },\n      {\n        label: \"People\", children: [\n          { label: \"User & Leads\" },\n        ],\n      },\n    ]);\n\n    // Now hide User_Leads\n    await hideTable(\"User_Leads\");\n    await gu.reloadDoc();\n    assert.deepEqual(await gu.getPageTree(), [\n      {\n        label: \"Interactions\", children: [\n          { label: \"Documents\" },\n        ],\n      },\n      {\n        label: \"People\",\n      },\n    ]);\n\n    // Now hide People, and test that whole node is hidden.\n    await hideTable(\"People\");\n    await gu.reloadDoc();\n    assert.deepEqual(await gu.getPageTree(), [\n      {\n        label: \"Interactions\", children: [\n          { label: \"Documents\" },\n        ],\n      },\n    ]);\n\n    // Now hide Documents, this is a leaf, so it should be hidden from the start\n    await hideTable(\"Documents\");\n    await gu.reloadDoc();\n    assert.deepEqual(await gu.getPageTree(), [\n      {\n        label: \"Interactions\",\n      },\n    ]);\n\n    // Now hide Interactions, we should have a blank treeview\n    await hideTable(\"Interactions\");\n    // We can wait for doc to load, because it waits for section.\n    await driver.findWait(\".test-treeview-container\", 1000);\n    assert.deepEqual(await gu.getPageTree(), []);\n\n    // Rollback\n    await revertAcl();\n    await gu.reloadDoc();\n    assert.deepEqual(await gu.getPageTree(), [\n      {\n        label: \"Interactions\", children: [\n          { label: \"Documents\" },\n        ],\n      },\n      {\n        label: \"People\", children: [\n          { label: \"User & Leads\", children: [{ label: \"Overview\" }] },\n        ],\n      },\n    ]);\n    await gu.undo();\n  });\n\n  it(\"should list all pages in document\", async () => {\n    // check content of _girst_Pages and _grist_Views\n    assert.deepInclude(await api.getTable(doc.id, \"_grist_Pages\"), {\n      viewRef: [1, 2, 3, 4, 5],\n      pagePos: [1, 2, 1.5, 3, 4],\n      indentation: [0, 0, 1, 1, 1],\n    });\n    assert.deepInclude(await api.getTable(doc.id, \"_grist_Views\"), {\n      name: [\"Interactions\", \"People\", \"Documents\", \"User & Leads\", \"Overview\"],\n      id: [1, 2, 3, 4, 5],\n    });\n\n    // load page and check all pages are listed\n    await driver.get(`${server.getHost()}/o/test-grist/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n  });\n\n  it(\"should select correct page if /p/<docPage> in the url\", async () => {\n    // show page with viewRef 2\n    await gu.loadDoc(`/o/test-grist/doc/${doc.id}/p/2`);\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /People/);\n    assert.match(await gu.getActiveSectionTitle(), /People/i);\n  });\n\n  it(\"should select first page if /p/<docPage> is omitted in the url\", async () => {\n    await driver.get(`${server.getHost()}/o/test-grist/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n    assert.match(await driver.find(\".test-treeview-itemHeader\").getText(), /Interactions/);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Interactions/);\n    assert.match(await gu.getActiveSectionTitle(), /Interactions/i);\n\n    // Check also that this did NOT cause a redirect to include /p/ in the URL.\n    assert.notMatch(await driver.getCurrentUrl(), /\\/p\\//);\n  });\n\n  it(\"clicking page should set /p/<docPage> in the url\", async () => {\n    await driver.get(`${server.getHost()}/o/test-grist/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n\n    // Click on a page; check the URL, selected item, and the title of the view section.\n    await gu.openPage(/Documents/);\n    assert.match(await driver.getCurrentUrl(), /\\/p\\/3/);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Documents/);\n    assert.match(await gu.getActiveSectionTitle(), /Documents/i);\n\n    // Click on another page; check the URL, selected item, and the title of the view section.\n    await gu.openPage(/People/);\n    assert.match(await driver.getCurrentUrl(), /\\/p\\/2/);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /People/);\n    assert.match(await gu.getActiveSectionTitle(), /People/i);\n\n    // TODO: Add a check that open-in-new-tab works too.\n  });\n\n  it(\"should allow renaming table\", async () => {\n    // open dots menu and click rename\n    await gu.openPageMenu(\"People\");\n    await driver.find(\".test-docpage-rename\").doClick();\n\n    // do rename\n    await driver.find(\".test-docpage-editor\").sendKeys(\"PeopleRenamed\", Key.ENTER);\n    await gu.waitForServer();\n\n    assert.deepEqual(\n      await gu.getPageNames(),\n      [\"Interactions\", \"Documents\", \"PeopleRenamed\", \"User & Leads\", \"Overview\"],\n    );\n\n    // Test that we can delete after remove (there was a bug related to this).\n    await gu.removePage(\"PeopleRenamed\");\n\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"User & Leads\", \"Overview\"]);\n\n    // revert changes\n    await gu.undo(2);\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n  });\n\n  it(\"should allow renaming table when click on page selected label\", async () => {\n    // do rename\n    await gu.openPage(/People/);\n    await driver.findContent(\".test-treeview-label\", \"People\").doClick();\n    await driver.find(\".test-docpage-editor\").sendKeys(\"PeopleRenamed\", Key.ENTER);\n    await gu.waitForServer();\n\n    assert.deepEqual(\n      await gu.getPageNames(),\n      [\"Interactions\", \"Documents\", \"PeopleRenamed\", \"User & Leads\", \"Overview\"],\n    );\n\n    // revert changes\n    await gu.undo(2);\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n  });\n\n  it(\"should not allow blank page name\", async () => {\n    // Begin renaming of People page\n    await gu.openPageMenu(\"People\");\n    await driver.find(\".test-docpage-rename\").doClick();\n\n    // Delete page name and check editor's value equals ''\n    await driver.find(\".test-docpage-editor\").sendKeys(Key.DELETE);\n    assert.equal(await driver.find(\".test-docpage-editor\").value(), \"\");\n\n    // Save blank name\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n\n    // Check name is still People\n    assert.include(await gu.getPageNames(), \"People\");\n  });\n\n  it(\"should pull out emoji from page names\", async () => {\n    // A regular character is used as an initial AND kept in the name.\n    assert.deepEqual(await getInitialAndName(/People/), [\"P\", \"People\"]);\n\n    // It looks like our version of Chromedriver does not support sending emojis using sendKeys\n    // (issue mentioned here https://stackoverflow.com/a/59139690), so we'll use executeScript to\n    // rename pages.\n    async function renamePage(origName: string | RegExp, newName: string) {\n      await gu.openPageMenu(origName);\n      await driver.find(\".test-docpage-rename\").doClick();\n      const editor = await driver.find(\".test-docpage-editor\");\n      await driver.executeScript((el: HTMLInputElement, text: string) => { el.value = text; }, editor, newName);\n      await editor.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n    }\n\n    async function getInitialAndName(pageName: string | RegExp): Promise<[string, string]> {\n      return await driver.findContent(\".test-treeview-itemHeader\", pageName)\n        .findAll(\".test-docpage-initial, .test-docpage-label\", el => el.getText()) as [string,\n        string];\n    }\n\n    // An emoji is pulled into the initial, and is removed from the name.\n    await renamePage(\"People\", \"👥 People\");\n\n    assert.deepEqual(await getInitialAndName(/People/), [\"👥\", \"People\"]);\n\n    // Two complex emojis -- the first one is the pulled-out initial.\n    await renamePage(\"People\", \"👨‍👩‍👧‍👦👨‍👩‍👧Guest List\");\n    assert.deepEqual(await getInitialAndName(/Guest List/),\n      [\"👨‍👩‍👧‍👦\", \"👨‍👩‍👧Guest List\"]);\n\n    // Digits should not be considered emoji (even though they match /\\p{Emoji}/...)\n    await renamePage(/Guest List/, \"5Guest List\");\n    assert.deepEqual(await getInitialAndName(/Guest List/), [\"5\", \"5Guest List\"]);\n\n    await gu.undo(3);\n    assert.deepEqual(await getInitialAndName(/People/), [\"P\", \"People\"]);\n  });\n\n  it(\"should show tooltip for long page names on hover\", async () => {\n    await gu.openPageMenu(\"People\");\n    await driver.find(\".test-docpage-rename\").doClick();\n    await driver.find(\".test-docpage-editor\")\n      .sendKeys(\"People, Persons, Humans, Ladies & Gentlemen\", Key.ENTER);\n    await gu.waitForServer();\n\n    await driver.findContent(\".test-treeview-label\", /People, Persons, Humans, Ladies & Gentlemen/).mouseMove();\n    await driver.wait(() => driver.findWait(\".test-tooltip\", 1000).isDisplayed(), 3000);\n    assert.equal(await driver.find(\".test-tooltip\").getText(),\n      \"People, Persons, Humans, Ladies & Gentlemen\");\n\n    await gu.undo();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n\n    await driver.findContent(\".test-treeview-label\", /People/).mouseMove();\n    await driver.sleep(500);\n    assert.equal(await driver.find(\".test-tooltip\").isPresent(), false);\n  });\n\n  it(\"should not change page when clicking the input while renaming page\", async () => {\n    // check that initially People is selected\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /People/);\n\n    // start renaming Documents and click the input\n    await gu.openPageMenu(\"Documents\");\n    await driver.find(\".test-docpage-rename\").doClick();\n    await driver.find(\".test-docpage-editor\").click();\n\n    // check that People is still the selected page.\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /People/);\n\n    // abord renaming\n    await driver.find(\".test-docpage-editor\").sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should allow moving pages\", async () => {\n    // check initial state\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n\n    // move page\n    await movePage(/User & Leads/, { after: /Overview/ });\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"Overview\", \"User & Leads\"]);\n\n    // revert changes\n    await gu.undo();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n  });\n\n  it(\"moving a page should not extend collapsed page\", async () => {\n    /**\n     * Here what is really being tested is that TreeModelRecord correctly reuses TreeModelItem,\n     * because if it wasn't the case, TreeViewComponent would not be able to reuse dom and would\n     * rebuild dom for all pages causing all page to be expanded.\n     */\n\n    // let's collapse Interactions\n    await driver.findContent(\".test-treeview-itemHeader\", /Interactions/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"\", \"People\", \"User & Leads\", \"Overview\"]);\n\n    // let's move\n    await movePage(/User & Leads/, { after: /Overview/ });\n    await gu.waitForServer();\n\n    // check that pages has moved and Interactions remained collapsed\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"\", \"People\", \"Overview\", \"User & Leads\"]);\n\n    // revert changes\n    await gu.undo();\n    await driver.findContent(\".test-treeview-itemHeader\", /Interactions/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n  });\n\n  it(\"should allow saving collapsed state\", async () => {\n    // Collapse Interactions and save. It should remain collapsed on page reload.\n    await driver.findContent(\".test-treeview-itemHeader\", /Interactions/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"\", \"People\", \"User & Leads\", \"Overview\"]);\n    await gu.openPageMenu(\"Interactions\");\n    await gu.findOpenMenuItem(\".test-docpage-collapse-by-default\", \"Set default: Collapse\").click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"\", \"People\", \"User & Leads\", \"Overview\"]);\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"\", \"People\", \"User & Leads\", \"Overview\"]);\n\n    // Check state is saved for all users.\n    const otherSession = await gu.session().teamSite.user(\"user2\").login();\n    await otherSession.loadDoc(`/doc/${doc.id}`);\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"\", \"People\", \"User & Leads\", \"Overview\"]);\n    const mainSession = await gu.session().teamSite.user(\"user1\").login();\n    await mainSession.loadDoc(`/doc/${doc.id}`);\n\n    // Expand Interactions and save. It should remain expanded on page reload.\n    await driver.findContent(\".test-treeview-itemHeader\", /Interactions/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n    await gu.openPageMenu(\"Interactions\");\n    await gu.findOpenMenuItem(\".test-docpage-expand-by-default\", \"Set default: Expand\").click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n    assert.deepEqual(await gu.getPageNames(), [\"Interactions\", \"Documents\", \"People\", \"User & Leads\", \"Overview\"]);\n  });\n\n  it(\"should allow to cycle though pages using shortcuts\", async () => {\n    function nextPage() {\n      return driver.find(\"body\").sendKeys(Key.chord(Key.ALT, Key.DOWN));\n    }\n\n    function prevPage() {\n      return driver.find(\"body\").sendKeys(Key.chord(Key.ALT, Key.UP));\n    }\n\n    function selectedPage() {\n      return driver.find(\".test-treeview-itemHeader.selected\").getText();\n    }\n\n    // goto page 'Interactions'\n    await gu.openPage(/Interactions/);\n\n    // check selected page\n    assert.match(await selectedPage(), /Interactions/);\n\n    // prev page\n    await prevPage();\n\n    // check selecte page\n    assert.match(await selectedPage(), /Overview/);\n\n    // prev page\n    await prevPage();\n\n    // check selecte page\n    assert.match(await selectedPage(), /User & Leads/);\n\n    // next page\n    await nextPage();\n\n    // check selected page\n    assert.match(await selectedPage(), /Overview/);\n\n    // next page\n    await nextPage();\n\n    // check selected page\n    assert.match(await selectedPage(), /Interactions/);\n  });\n\n  it(\"undo/redo should update url\", async () => {\n    // goto page 'Interactions' and send keys\n    await gu.openPage(/Interactions/);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Interactions/);\n    await driver.findContentWait(\".gridview_data_row_num\", /1/, 2000);\n    await driver.sendKeys(Key.ENTER, \"Foo\", Key.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells(0, [1]), [\"Foo\"]);\n\n    // goto page 'People' and click undo\n    await gu.openPage(/People/);\n    await gu.waitForDocToLoad();\n    await gu.waitForUrl(/\\/p\\/2\\b/); // check that url match p/2\n\n    await gu.undo();\n    await gu.waitForDocToLoad();\n    await gu.waitForUrl(/\\/p\\/1\\b/); // check that url match p/1\n\n    // check that \"Interactions\" page is selected\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Interactions/);\n\n    // check that undo worked\n    assert.deepEqual(await gu.getVisibleGridCells(0, [1]), [\"\"]);\n\n    // Click on next test should not trigger renaming input\n    await driver.findContent(\".test-treeview-itemHeader\", /People/).doClick();\n  });\n\n  it(\"Add new page should update url\", async () => {\n    // goto page 'Interactions'  and check that url updated\n    await gu.openPage(/Interactions/);\n    await gu.waitForUrl(/\\/p\\/1\\b/);\n\n    // Add new Page, check that url updated and page is selected\n    await gu.addNewPage(/Table/, /New Table/);\n    await gu.waitForUrl(/\\/p\\/6\\b/);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Table1/);\n\n    // goto page 'Interactions' and check that url updated and page selectd\n    await gu.openPage(/Interactions/);\n    await gu.waitForUrl(/\\/p\\/1\\b/);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Interactions/);\n  });\n\n  it(\"Removing a page should work\", async () => {\n    // Create and open new document\n    const docId = await session.tempNewDoc(cleanup, \"test-page-removal\");\n    await driver.get(`${server.getHost()}/o/test-grist/doc/${docId}`);\n    await gu.waitForDocToLoad();\n    await gu.waitForUrl(\"test-page-removal\");\n\n    // Add a new page using Table1\n    await gu.addNewPage(/Table/, /Table1/);\n    assert.deepInclude(await api.getTable(docId, \"_grist_Tables\"), {\n      tableId: [\"Table1\"],\n      primaryViewId: [1],\n    });\n    assert.deepInclude(await api.getTable(docId, \"_grist_Views\"), {\n      name: [\"Table1\", \"New page\"],\n      id: [1, 2],\n    });\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"New page\"]);\n\n    // check that the new page is now selected\n    await gu.waitForUrl(/\\/p\\/2\\b/);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /New page/);\n\n    // remove new page\n    await gu.removePage(/New page/);\n\n    // check that url has no p/<...> and 'Table1' is now selected\n    await driver.wait(async () => !(await driver.getCurrentUrl()).includes(\"/p/\"), 2000);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Table1/);\n\n    // check that corresponding view is removed\n    assert.deepInclude(await api.getTable(docId, \"_grist_Tables\"), {\n      tableId: [\"Table1\"],\n      primaryViewId: [1],\n    });\n    assert.deepInclude(await api.getTable(docId, \"_grist_Views\"), {\n      name: [\"Table1\"],\n      id: [1],\n    });\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\"]);\n\n    // create table Foo and 1 new page using Foo\n    await api.applyUserActions(docId, [[\"AddTable\", \"Foo\", [{ id: null, isFormula: true }]]]);\n    await driver.findContentWait(\".test-treeview-itemHeader\", /Foo/, 2000);\n    await gu.addNewPage(/Table/, /Foo/);\n    assert.deepInclude(await api.getTable(docId, \"_grist_Tables\"), {\n      tableId: [\"Table1\", \"Foo\"],\n      primaryViewId: [1, 2],\n    });\n    assert.deepInclude(await api.getTable(docId, \"_grist_Views\"), {\n      name: [\"Table1\", \"Foo\", \"New page\"],\n      id: [1, 2, 3],\n    });\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"Foo\", \"New page\"]);\n\n    // check that last page is now selected\n    await gu.waitForUrl(/\\/p\\/3\\b/);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /New page/);\n\n    // remove table and make sure pages are also removed.\n    await gu.removeTable(\"Foo\");\n\n    // check that Foo and page are removed\n    assert.deepInclude(await api.getTable(docId, \"_grist_Tables\"), {\n      tableId: [\"Table1\"],\n      primaryViewId: [1],\n    });\n    assert.deepInclude(await api.getTable(docId, \"_grist_Views\"), {\n      name: [\"Table1\"],\n      id: [1],\n    });\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\"]);\n  });\n\n  it(\"Remove should be disabled for last page\", async () => {\n    // check that Remove is disabled on Table1\n    assert.isFalse(await gu.canRemovePage(\"Table1\"));\n\n    // Adds a new page using Table1\n    await gu.addNewPage(/Table/, /Table1/);\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"New page\"]);\n\n    // Add a new table too.\n    await gu.addNewTable();\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"New page\", \"Table2\"]);\n\n    // The \"Remove\" options should now be available on all three items.\n    assert.isTrue(await gu.canRemovePage(\"Table1\"));\n    assert.isTrue(await gu.canRemovePage(\"New page\"));\n    assert.isTrue(await gu.canRemovePage(\"Table2\"));\n\n    // Add Table2 to \"New page\" (so that it can remain after Table1 is removed below).\n    await gu.getPageItem(\"New page\").click();\n    await gu.addNewSection(/Table/, /Table2/);\n\n    // Now remove Table1.\n    await gu.removeTable(\"Table1\");\n    assert.deepEqual(await gu.getPageNames(), [\"New page\", \"Table2\"]);\n\n    // Both pages should be removable still.\n    assert.isTrue(await gu.canRemovePage(\"New page\"));\n    assert.isTrue(await gu.canRemovePage(\"Table2\"));\n\n    // Remove New Page\n    await gu.removePage(\"New page\");\n\n    // Now Table2 should not be removable (since it is the last page).\n    assert.isFalse(await gu.canRemovePage(\"Table2\"));\n  });\n\n  it(\"should not throw JS errors when removing the current page without a slug\", async () => {\n    // Create and open new document\n    const docId = await session.tempNewDoc(cleanup, \"test-page-removal-js-error\");\n    await driver.get(`${server.getHost()}/o/test-grist/doc/${docId}`);\n    await gu.waitForDocToLoad();\n\n    // Add two additional tables\n    await gu.addNewTable();\n    await gu.addNewTable();\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"Table2\", \"Table3\"]);\n\n    // Open the default page (no p/<...> in the URL)\n    await driver.get(`${server.getHost()}/o/test-grist/doc/${docId}`);\n    await gu.waitForDocToLoad();\n\n    // Check that Table1 is now selected\n    await driver.findContentWait(\".test-treeview-itemHeader.selected\", /Table1/, 2000);\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Table1/);\n\n    // Remove page Table1\n    await gu.removePage(\"Table1\");\n    assert.deepEqual(await gu.getPageNames(), [\"Table2\", \"Table3\"]);\n\n    // Now check that Table2 is selected\n    assert.match(await driver.find(\".test-treeview-itemHeader.selected\").getText(), /Table2/);\n\n    // Remove page Table2\n    await gu.removePage(\"Table2\");\n    assert.deepEqual(await gu.getPageNames(), [\"Table3\"]);\n\n    // Check that Table3 is the only page remaining\n    assert.deepInclude(await api.getTable(docId, \"_grist_Views\"), {\n      name: [\"Table3\"],\n      id: [3],\n    });\n\n    // Check that no JS errors were thrown\n    await gu.checkForErrors();\n  });\n\n  it(\"should offer a way to delete last tables\", async () => {\n    // Create and open new document\n    const docId = await session.tempNewDoc(cleanup, \"prompts\");\n    await driver.get(`${server.getHost()}/o/test-grist/doc/${docId}`);\n    await gu.waitForDocToLoad();\n\n    // Add two additional tables, with custom names.\n    await gu.addNewTable(\"Table B\");\n    await gu.addNewTable(\"Table C\");\n    await gu.addNewTable(\"Table Last\");\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"Table B\", \"Table C\", \"Table Last\"]);\n    await gu.getPageItem(\"Table C\").click();\n\n    // In Table C add Table D (a new one) and Table1 widget (existing);\n    await gu.addNewSection(/Table/, /New Table/, { tableName: \"Table D\" });\n    await gu.addNewSection(/Table/, \"Table1\");\n    // New table should not be added as a page\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"Table B\", \"Table C\", \"Table Last\"]);\n    // Make sure we see proper sections.\n    assert.deepEqual(await gu.getSectionTitles(), [\"TABLE C\", \"TABLE D\", \"TABLE1\"]);\n\n    const revert = await gu.begin();\n    // Now removing Table1 page should be done without a prompt (since it is also on Table C)\n    await gu.removePage(\"Table1\", { expectPrompt: false });\n    assert.deepEqual(await gu.getPageNames(), [\"Table B\", \"Table C\", \"Table Last\"]);\n\n    // Removing Table B should show prompt (since it is last page)\n    await gu.removePage(\"Table B\", { expectPrompt: true, tables: [\"Table B\"] });\n    assert.deepEqual(await gu.getPageNames(), [\"Table C\", \"Table Last\"]);\n\n    // Removing page Table C should also show prompt (it is last page for Table1,Table D and TableC)\n    await gu.getPageItem(\"Table Last\").click();\n    await gu.getPageItem(\"Table C\").click();\n    assert.deepEqual(await gu.getSectionTitles(), [\"TABLE C\", \"TABLE D\", \"TABLE1\"]);\n    await gu.removePage(\"Table C\", { expectPrompt: true, tables: [\"Table D\", \"Table C\", \"Table1\"] });\n    assert.deepEqual(await gu.getPageNames(), [\"Table Last\"]);\n    await revert();\n\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"Table B\", \"Table C\", \"Table Last\"]);\n    assert.deepEqual(await gu.getSectionTitles(), [\"TABLE C\", \"TABLE D\", \"TABLE1\"]);\n  });\n\n  async function hideTable(tableId: string) {\n    await api.applyUserActions(doc.id, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId, colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"-R\",\n      }],\n    ]);\n  }\n});\n\nasync function movePage(page: RegExp, target: { before: RegExp } | { after: RegExp } | { into: RegExp }) {\n  const targetReg = values(target)[0];\n  await driver.withActions(actions => actions\n    .move({ origin: driver.findContent(\".test-treeview-itemHeader\", page) })\n    .move({ origin: driver.findContent(\".test-treeview-itemHeaderWrapper\", page)\n      .find(\".test-treeview-handle\") })\n    .press()\n    .move({ origin: driver.findContent(\".test-treeview-itemHeader\", targetReg),\n      y: \"after\" in target ? 1 : -1,\n    })\n    .release());\n}\n\nasync function insertPage(page: RegExp, into: RegExp) {\n  await driver.withActions(actions => actions\n    .move({ origin: driver.findContent(\".test-treeview-itemHeader\", page) })\n    .move({ origin: driver.findContent(\".test-treeview-itemHeaderWrapper\", page)\n      .find(\".test-treeview-handle\") })\n    .press()\n    .move({ origin: driver.findContent(\".test-treeview-itemHeader\", into),\n      y: 5,\n    })\n    .pause(1500) // wait for a target to be highlighted\n    .release(),\n  );\n  await gu.waitForServer();\n}\n"
  },
  {
    "path": "test/nbrowser/Printing.ts",
    "content": "/**\n * Testing printing using selenium webdriver is tricky.\n *\n * 1. The `--kiosk-printing` option may be set on chromedriver to cause printing to go to a pdf\n *    without a confirmation. But it doesn't work in headless mode.\n *\n * 2. As long as we use `setTimeout(() => window.print(), 0)` instead of plain `window.print()`,\n *    it is possible to interact with the print dialog in chrome (although next steps are\n *    unclear), e.g.:\n *    ```\n *    const windowHandles = await driver.getAllWindowHandles();\n *    await driver.switchTo().window(windowHandles[1]);\n *    driver.sendKeys(Key.ENTER);\n *    ```\n *    This, however, doesn't work in headless either.\n *\n * 3. There is a command `Emulation.setEmulatedMedia`, can do the equivalent of dev console's\n *    simulation of `@media print`. That's what we use here. We don't get to see anything about\n *    pagination, but we can at least check whether various elements are visible for printing.\n */\nimport { serveCustomViews, Serving } from \"test/nbrowser/customUtil\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\nfunction emulateMediaPrint(print: boolean) {\n  return (driver as any).sendDevToolsCommand(\"Emulation.setEmulatedMedia\", { media: print ? \"print\" : \"screen\" });\n}\n\nasync function checkPrintSection(sectionName: string, checkFunc: () => Promise<void>) {\n  const numTabs = (await driver.getAllWindowHandles()).length;\n  await driver.executeScript(\"window.debugPrinting = 1\");\n  await gu.openSectionMenu(\"viewLayout\", sectionName);\n  await driver.findWait(\".test-print-section\", 500).click();\n  await driver.sleep(100);    // Just to be sure we don't continue before setTimeout(0), used in printing.\n  try {\n    await emulateMediaPrint(true);\n    await checkFunc();\n  } finally {\n    // Ensure the dialog's window (if it ever opened, only non-headless) is gone.\n    await gu.waitToPass(async () => assert.lengthOf(await driver.getAllWindowHandles(), numTabs), 5000);\n\n    // Ensure that `afterprint` callback gets triggered, needed for mac.\n    await gu.waitToPass(() => driver.executeScript(\"window.afterPrintCallback?.()\"));\n\n    await emulateMediaPrint(false);\n    await gu.waitToPass(() => driver.executeScript(\"window.finishPrinting()\"));\n    await driver.executeScript(\"window.debugPrinting = 0\");\n  }\n}\n\ndescribe(\"Printing\", function() {\n  this.timeout(30000);\n  const cleanup = setupTestSuite();\n  let serving: Serving;\n  let mainSession: gu.Session;\n  let docId: string;\n\n  before(async function() {\n    serving = await serveCustomViews();\n    mainSession = await gu.session().login();\n    docId = (await mainSession.tempDoc(cleanup, \"Countries-Print.grist\", { load: false })).id;\n  });\n\n  after(async function() {\n    await serving?.shutdown();\n  });\n\n  let originalTab: string;\n  let newTab: string;\n\n  beforeEach(async function() {\n    // Open a new tab for each test case, to work around what seems to be a bug: in headless\n    // chrome, window.print() works the first time in a tab, but not again.\n    originalTab = await driver.getWindowHandle();\n    await driver.executeScript(\"window.open('about:blank', '_blank')\");\n    const tabs = await driver.getAllWindowHandles();\n    newTab = tabs[tabs.length - 1];\n    await driver.switchTo().window(newTab);\n\n    // Load the doc in the new tab.\n    await mainSession.loadDoc(`/doc/${docId}`);\n  });\n\n  gu.afterEachCleanup(async function() {\n    const newCurrentTab = await driver.getWindowHandle();\n    assert.equal(newCurrentTab, newTab);\n    await driver.close();\n    await driver.switchTo().window(originalTab);\n  });\n\n  it(\"should include all rows when printing tables\", async function() {\n    await checkPrintSection(\"COUNTRIES\", async () => {\n      // All rows (near the beginning and far) are displayed.\n      assert.isTrue(await driver.findContent(\".print-all-rows .field_clip\", /Bulgaria/).isDisplayed());\n      assert.isTrue(await driver.findContent(\".print-all-rows .field_clip\", /Polska/).isDisplayed());\n      assert.isTrue(await driver.findContent(\".print-all-rows .field_clip\", /Vanuatu/).isDisplayed());\n      assert.isTrue(await driver.find(\".print-row:last-child\").isDisplayed());\n\n      // Check the text in last row to be sure what's included.\n      assert.match(await driver.find(\".print-row:last-child\").getText(), /Zimbabwe.*Eastern Africa/s);\n    });\n  });\n\n  it(\"should include all rows when printing card list\", async function() {\n    await gu.getPageItem(\"Cards and Chart\").click();\n    await checkPrintSection(\"COUNTRIES Card List\", async () => {\n      // Only the selected cards are displayed.\n      assert.isTrue(await driver.findContent(\".print-all-rows .field_clip\", /Aruba/).isDisplayed());\n      assert.isTrue(await driver.findContent(\".print-all-rows .field_clip\", /Grenadines/).isDisplayed());\n      assert.isTrue(await driver.findContent(\".print-all-rows .field_clip\", /North America/).isDisplayed());\n      assert.isTrue(await driver.find(\".print-row:last-child\").isDisplayed());\n      // Other countries are not displayed.\n      assert.isFalse(await driver.findContent(\".print-widget .field_clip\", /Aremenia/).isPresent());\n      assert.isFalse(await driver.findContent(\".print-widget .field_clip\", /Africa/).isPresent());\n      assert.isFalse(await driver.findContent(\".print-widget .field_clip\", /Albania/).isPresent());\n      // Check some text to see what's included.\n      assert.match(await driver.find(\".print-row:first-child\").getText(), /Aruba.*Caribbean/s);\n      assert.match(await driver.find(\".print-row:last-child\").getText(), /Virgin Islands.*Caribbean/s);\n    });\n  });\n\n  it(\"should display charts when printing\", async function() {\n    await gu.getPageItem(\"Cards and Chart\").click();\n    await checkPrintSection(\"COUNTRIES [By Continent] Chart\", async () => {\n      await gu.waitToPass(async () => {\n        // Expect to see all Continents listed, by population, excluding the filtered-out Antarctica.\n        assert.deepEqual(await driver.findAll(\".print-widget .legendtext\", el => el.getText()),\n          [\"Africa\", \"Asia\", \"Europe\", \"North America\", \"Oceania\", \"South America\"]);\n      }, 5000);\n    });\n  });\n\n  it(\"should not display link icon when printing\", async function() {\n    await gu.getPageItem(\"Countries\").click();\n    await gu.getCell(0, 1).click();\n    await gu.enterCell(\"http://getgrist.com\");\n    await gu.waitForServer();\n\n    const checkLinkIsDisplayed = async (expected: boolean) => {\n      assert.equal(await driver.findContent(\"span\", \"http://getgrist.com\").isPresent(), true);\n      assert.equal(await driver.find('a[href*=\"http://getgrist.com\"]').isDisplayed(), expected);\n    };\n\n    await checkLinkIsDisplayed(true);\n\n    await checkPrintSection(\"COUNTRIES\", async () => {\n      await gu.waitToPass(async () => {\n        await checkLinkIsDisplayed(false);\n      }, 5000);\n    });\n  });\n\n  it(\"should render markdown cells when printing\", async function() {\n    await gu.getPageItem(\"Countries\").click();\n    await gu.openColumnPanel(\"Name\");\n    await gu.setFieldWidgetType(\"Markdown\");\n    await gu.getCell({ rowNum: 1, col: \"Name\" }).click();\n    await gu.enterCell(\"[Aruba](https://getgrist.com/#aruba)\");\n\n    const link = driver.findContentWait(\".test-text-link\", \"Aruba\", 1000);\n    assert.equal(await link.isDisplayed(), true);\n    // There is also the link itself, shown as an icon just before the text.\n    assert.equal(await link.find(\"a\").getAttribute(\"href\"), \"https://getgrist.com/#aruba\");\n    assert.equal(await link.find(\"a\").isDisplayed(), true);\n\n    await checkPrintSection(\"COUNTRIES\", async () => {\n      const link = driver.findContent(\".print-all-rows .test-text-link\", \"Aruba\");\n      assert.equal(await link.isDisplayed(), true);\n      // In print view, the link icon is hidden.\n      assert.equal(await link.find(\"a\").isDisplayed(), false);\n    });\n  });\n\n  // NOTE: the test doc includes a custom section (`${serving.url}/readout` would do), but the\n  // media-print emulation doesn't help to test it, since custom sections are printed by\n  // triggering window.print() within the widget, with no other style manipulation to test.\n});\n"
  },
  {
    "path": "test/nbrowser/Properties.ntest.js",
    "content": "import { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"Properties.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"Hello.grist\", true);\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"webdriver should handle parens and other keys\", async function() {\n    // This isn't a test of properties really, but a test of Selenium: speficically that the\n    // workaround for Selenium bugs in gu.sendKeys actually works.\n    await $(\"$GridView_columnLabel\").first().click();\n\n    // We'll undo afterwards, and verify that we got the same text back.\n    var text = await gu.getCellRC(0, 0).text();\n\n    var specialChars = \"()[]{}~!@#$%^&*-_=+/?><.,'\\\";:\";\n    await gu.sendKeys(specialChars + specialChars, $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(0, 0).text(), specialChars + specialChars);\n\n    // Undo and compare to previous value.\n    await gu.sendKeys([$.MOD, \"z\"]);\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(0, 0).text(), text);\n  });\n\n  it(\"cells should indicate when conversion fails for a value\", async function() {\n    await $(\"$GridView_columnLabel:nth-child(2)\").click();\n\n    // Fill in a column of values, some numeric, some not.\n    await gu.enterGridValues(0, 1, [[\"17\", \"foo\", \"\", \"-100\"]]);\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2, 3, 4]),\n      [\"17\", \"foo\", \"\", \"-100\"]);\n    await $(\"$GridView_columnLabel:nth-child(2)\").click();\n\n    // Convert the column to Numeric.\n    await gu.openSidePane(\"field\");\n    assert.equal(await $(\".test-field-label\").val(), \"B\");\n    await gu.setType(\"Numeric\");\n    await $(\".test-type-transform-apply\").wait().click();\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2, 3, 4]),\n      [\"17\", \"foo\", \"\", \"-100\"]);\n\n    // Undo of conversion should restore old values.\n    await gu.undo();\n    await $(\".test-fbuilder-type-select .test-select-row:contains(Text)\").wait();\n    assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2, 3, 4]),\n      [\"17\", \"foo\", \"\", \"-100\"]);\n\n    // Redo should work too.\n    await gu.redo();\n    await $(\".test-fbuilder-type-select .test-select-row:contains(Numeric)\").wait();\n    assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2, 3, 4]),\n      [\"17\", \"foo\", \"\", \"-100\"]);\n  });\n\n  it(\"cells should indicate when new value is wrong type\", async function() {\n    // Go to column \"c\", and change type to Numeric.\n    await $(\"$GridView_columnLabel:nth-child(3)\").click();\n    assert.equal(await $(\".test-field-label\").val(), \"C\");\n    await gu.setType(\"Numeric\", {apply: true});\n\n    // Remove focus from FieldBuilder type dropdown, so that sentKeys go to the main app.\n    await $(\"body\").click();\n\n    // Fill in a column of values, some numeric, some not.\n    await gu.enterGridValues(0, 2, [[\"25\", \"\", \"bar\", \"-123\"]]);\n    await gu.waitForServer();\n\n    // TODO: The 0.00 might be wrong behavior: we probably want an empty cell here, although when\n    // converting an empty text cell to numeric, we want it to become 0. In other words, not all\n    // conversions are the same.\n    assert.deepEqual(await gu.getVisibleGridCells(2, [1, 2, 3, 4]),\n      [\"25\", \"\", \"bar\", \"-123\"]);\n    assert.deepEqual(await gu.getVisibleGridCells({\n      col: 2,\n      rowNums: [1, 2, 3, 4],\n      mapper: e => e.find(\".field_clip\").hasClass(\"invalid\"),\n    }), [false, false, true, false]);\n\n    // Select the column again, and type in values in a different order. Ensure the cells change\n    // appropriately.\n    await $(\"$GridView_columnLabel:nth-child(3)\").click();\n    await gu.enterGridValues(0, 2, [[\"\", \"bar\", \"-123\", \"25\"]]);\n    await gu.waitForServer();\n\n    // TODO: The first cell might be wrong behavior; we probably want an empty cell after DELETE.\n    assert.deepEqual(await gu.getVisibleGridCells(2, [1, 2, 3, 4]),\n      [\"\", \"bar\", \"-123\", \"25\"]);\n    assert.deepEqual(await gu.getVisibleGridCells({\n      col: 2,\n      rowNums: [1, 2, 3, 4],\n      mapper: e => e.find(\".field_clip\").hasClass(\"invalid\")\n    }), [false, true, false, false]);\n  });\n\n  it(\"formula errors should be indicated\", async function() {\n    // Go to column \"E\", and change formula to eval column \"D\".\n    await $(\"$GridView_columnLabel:nth-child(5)\").click();\n    await gu.sendKeys(\"eval($D)\", $.ENTER);\n    // Fill in a bunch of formula text for the \"eval\" formula to try. This is a way to get a whole\n    // bunch of different errors in one columns.\n    await $(\"$GridView_columnLabel:nth-child(4)\").click();\n    assert.equal(await $(\".test-field-label\").val(), \"D\");\n    await gu.setType(\"Text\");\n\n    await gu.enterGridValues(0, 3, [[\n      \"25\",\n      \"\",\n      \"asdf\",\n      \"ValueError()\",\n      \"__import__('sys').exit(3)\",\n      'u\"résumé 三\"',\n      \"12/(2-1-1)\",\n      \"[1,2,3]\"]]);\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getVisibleGridCells(4, [1, 2, 3, 4, 5, 6, 7, 8]), [\n      \"25\",\n      \"#SyntaxError\",\n      \"#NameError\",\n      \"ValueError()\",\n      \"#SystemExit\",\n      \"résumé 三\",\n      \"#DIV/0!\",\n      \"1, 2, 3\",\n    ]);\n\n    assert.deepEqual(\n      await gu.getVisibleGridCells({\n        col: 4,\n        rowNums: [1, 2, 3, 4, 5, 6, 7, 8],\n        mapper: e => e.find(\".field_clip\").hasClass(\"invalid\")\n      }),\n      // Last one (list) is valid because lists are a supported type of value.\n      [false, true, true, true, true, false, true, false]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ProposedChangesPage.ts",
    "content": "import { getReferencedTableId } from \"app/common/gristTypes\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"ProposedChangesPage\", function() {\n  this.timeout(60000);\n  const cleanup = setupTestSuite();\n\n  // Currently this page exists only on a document where accepting\n  // proposals is turned on.\n  it(\"can be enabled for a document\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"Hello.grist\");\n    await driver.find(\".test-tools-settings\").click();\n\n    // Check the accepting proposals checkbox is visible.\n    assert.match(\n      await driver.findWait(\"#admin-panel-item-description-acceptProposals\", 2000).getText(),\n      /Allow others to suggest changes/);\n    // But it shouldn't be checked yet.\n    assert.equal(\n      await driver.find(\"input.test-settings-accept-proposals\").getAttribute(\"checked\"),\n      null,\n    );\n    // Now check it.\n    await driver.find(\"input.test-settings-accept-proposals\").click();\n    // A new page should appear in the toolbox.\n    await driver.findWait(\".test-tools-proposals\", 2000);\n    // The flag should be checked now.\n    assert.equal(\n      await driver.find(\"input.test-settings-accept-proposals\").getAttribute(\"checked\"),\n      \"true\",\n    );\n  });\n\n  it(\"can make and apply a simple proposed change\", async function() {\n    // Load a test document.\n    const session = await gu.session().teamSite.login();\n    const doc = await session.tempDoc(cleanup, \"Hello.grist\");\n\n    // Turn on feature.\n    const api = session.createHomeApi();\n    await api.updateDoc(doc.id, {\n      options: {\n        proposedChanges: {\n          acceptProposals: true,\n        },\n      },\n    });\n\n    // Put something known in the first cell.\n    await gu.getCell(\"A\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"test1\");\n\n    // Work on a copy.\n    await driver.find(\".test-tb-share\").click();\n    await driver.findWait(\".test-work-on-copy\", 2000).click();\n    await gu.waitForServer();\n    await gu.waitForDocToLoad();\n\n    // Change the content of the first cell.\n    await gu.getCell(\"A\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"test2\");\n\n    // Go to the propose-changes page.\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (1)\");\n    await driver.find(\".test-tools-proposals\").click();\n\n    // Make sure the expected change is shown.\n    await driver.findContentWait(\".test-main-content\", /Suggest changes/, 2000);\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n    assert.deepEqual(await getColumns(\"TABLE1\"), [\"A\", \"E\"]);\n    assert.deepEqual(await getRowValues(\"TABLE1\", 0), [\"test1test2\", \"TEST1TEST2\"]);\n    assert.deepEqual(await getChangeType(\"TABLE1\", 0), \"→\");\n\n    // Check that expanding context works (at least, that it does something).\n    await expand(\"TABLE1\");\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n    assert.deepEqual(await getColumns(\"TABLE1\"), [\"id\", \"A\", \"B\", \"C\", \"D\", \"E\"]);\n    assert.deepEqual(await getRowValues(\"TABLE1\", 0), [\"1\", \"test1test2\", \"\", \"\", \"\", \"TEST1TEST2\"]);\n    assert.deepEqual(await getChangeType(\"TABLE1\", 0), \"→\");\n\n    await collapse(\"TABLE1\");\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n    assert.deepEqual(await getColumns(\"TABLE1\"), [\"A\", \"E\"]);\n    assert.deepEqual(await getRowValues(\"TABLE1\", 0), [\"test1test2\", \"TEST1TEST2\"]);\n    assert.deepEqual(await getChangeType(\"TABLE1\", 0), \"→\");\n\n    // Check a \"Suggest\" button is present, and click it.\n    assert.match(await driver.find(\".test-proposals-propose\").getText(), /Suggest/);\n    await driver.find(\".test-proposals-propose\").click();\n\n    // Once proposed, there should be a status line, and the \"Suggest\"\n    // button should be absent.\n    await driver.findContentWait(\".test-proposals-status\", /Suggestion/, 2000);\n    assert.equal(await driver.find(\".test-proposals-propose\").isPresent(), false);\n\n    // Try retracting the proposal. The status should become \"retracted\"\n    // and the proposal button should be back to its original state.\n    await driver.findWait(\".test-proposals-retract\", 2000).click();\n    await driver.findContentWait(\".test-proposals-status\", /Retracted/, 2000);\n    assert.match(await driver.find(\".test-proposals-propose\").getText(), /Suggest/);\n    await driver.find(\".test-proposals-propose\").click();\n    await driver.findContentWait(\".test-proposals-status\", /Suggest/, 2000);\n\n    // Click on the \"original document\" to see how things are there now.\n    await driver.findContentWait(\"span\", /original document/, 2000).click();\n\n    // The wording on the changes page is slightly different now (Proposed\n    // Changes versus Propose Changes)\n    assert.match(\n      await driver.findContentWait(\".test-proposals-header\", /#1/, 2000).getText(),\n      /Suggestion/,\n    );\n\n    // There should be exactly one proposal.\n    assert.lengthOf(await driver.findAll(\".test-proposals-header\"), 1);\n\n    // The proposal should basically be to change something to \"test2\".\n    // Click on that part.\n    await gu.dbClick(driver.findContent(\".diff-remote\", /test2/));\n\n    // It should bring us to a cell that is currently at \"test1\".\n    await driver.findContentWait(\".test-widget-title-text\", /TABLE1/, 2000);\n    assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"test1\");\n\n    // Go back to the changes page, and click \"Accept\".\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggestions\");\n    await driver.find(\".test-tools-proposals\").click();\n    await driver.findWait(\".test-proposals-apply\", 2000).click();\n    await gu.waitForServer();\n\n    // Now go back and see the cell is now filled with \"test2\".\n    await gu.dbClick(driver.findContent(\".diff-remote\", /test2/));\n    await driver.findContentWait(\".test-widget-title-text\", /TABLE1/, 2000);\n    assert.equal(await gu.getCell({ rowNum: 1, col: 0 }).getText(), \"test2\");\n  });\n\n  it(\"can make and apply multiple proposed changes\", async function() {\n    const { doc, api } = await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    await workOnCopy(url);\n\n    // Make a change.\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Bird\");\n\n    await proposeChange();\n\n    // Work on another copy and propose a different change.\n    await workOnCopy(url);\n    await gu.getCell(\"B\", 2).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Mammal\");\n    await proposeChange();\n\n    // Work on another copy and propose a different change.\n    await workOnCopy(url);\n    await gu.getCell(\"B\", 3).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"SpaceDuck\");\n    await proposeChange();\n\n    // Click on the \"original document\" to see how things are there now.\n    await driver.findContentWait(\"span\", /original document/, 2000).click();\n\n    // There should be exactly three proposals, newest first.\n    await driver.findWait(\".test-proposals-header\", 2000);\n    assert.lengthOf(await driver.findAll(\".test-proposals-header\"), 3);\n    await driver.findWait(\".diff-remote\", 2000);\n    assert.deepEqual(\n      await driver.findAll(\".diff-remote\", e => e.getText()),\n      [\"SpaceDuck\", \"Mammal\", \"Bird\"],\n    );\n\n    // Apply the second one and check that it has an effect.\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Life\")).B,\n      [\"Fish\", \"Primate\"]);\n    await driver.find(\".test-proposals-patch:nth-child(2)\")\n      .find(\".test-proposals-apply\").click();\n    await gu.waitForServer();\n    assert.match(\n      await driver.findContent(\".test-proposals-header\", /#2/).getText(),\n      /Accepted/,\n    );\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Life\")).B,\n      [\"Fish\", \"Mammal\"]);\n\n    // Now the third one.\n    await driver.find(\".test-proposals-patch:nth-child(3)\")\n      .find(\".test-proposals-apply\").click();\n    await gu.waitForServer();\n    assert.match(\n      await driver.findContent(\".test-proposals-header\", /#1/).getText(),\n      /Accepted/,\n    );\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Life\")).B,\n      [\"Bird\", \"Mammal\"]);\n\n    // Now the first one.\n    await driver.find(\".test-proposals-patch:nth-child(1)\")\n      .find(\".test-proposals-apply\").click();\n    await gu.waitForServer();\n    assert.match(\n      await driver.findContent(\".test-proposals-header\", /#3/).getText(),\n      /Accepted/,\n    );\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Life\")).B,\n      [\"Bird\", \"Mammal\", \"SpaceDuck\"]);\n  });\n\n  it(\"can apply a proposed change after a trunk change\", async function() {\n    const { api, doc } = await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    await workOnCopy(url);\n\n    // Make a change.\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Bird\");\n\n    await proposeChange();\n\n    // Click on the \"original document\".\n    await driver.findContentWait(\"span\", /original document/, 2000).click();\n\n    // There should be exactly one proposal.\n    await driver.findWait(\".test-proposals-header\", 2000);\n    assert.lengthOf(await driver.findAll(\".test-proposals-header\"), 1);\n\n    // Make sure the expected change is shown.\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n    assert.deepEqual(await getColumns(\"LIFE\"), [\"B\"]);\n    assert.deepEqual(await getRowValues(\"LIFE\", 0), [\"FishBird\"]);\n    assert.deepEqual(await getChangeType(\"LIFE\", 0), \"→\");\n\n    // Change column and table name.\n    await api.applyUserActions(doc.id, [\n      [\"RenameColumn\", \"Life\", \"B\", \"BB\"],\n    ]);\n    await api.applyUserActions(doc.id, [\n      [\"RenameTable\", \"Life\", \"Vie\"],\n    ]);\n    await driver.sleep(500);\n    // Check that expanding context works (at least, that it does something).\n    await expand(\"LIFE\");\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n    assert.deepEqual(await getColumns(\"VIE\"), [\"id\", \"A\", \"BB\"]);\n    assert.deepEqual(await getRowValues(\"VIE\", 0), [\"1\", \"10\", \"FishBird\"]);\n    assert.deepEqual(await getChangeType(\"VIE\", 0), \"→\");\n\n    // Apply and check that it has an effect.\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Vie\")).BB,\n      [\"Fish\", \"Primate\"]);\n    await driver.find(\".test-proposals-patch\")\n      .find(\".test-proposals-apply\").click();\n    await gu.waitForServer();\n    assert.match(\n      await driver.findContent(\".test-proposals-header\", /#1/).getText(),\n      /Accepted/,\n    );\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Vie\")).BB,\n      [\"Bird\", \"Primate\"]);\n  });\n\n  it(\"can show a count of changes to add to suggestion\", async function() {\n    await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    await workOnCopy(url);\n    const forkUrl = await driver.getCurrentUrl();\n\n    // Make a change.\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Bird\");\n\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (1)\");\n\n    // Make another change.\n    await gu.getCell(\"A\", 2).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"15\");\n\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (2)\");\n\n    await gu.undo();\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (1)\");\n\n    await gu.redo();\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (2)\");\n\n    await gu.refreshDismiss({ ignore: true });\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (2)\");\n\n    assert.notInclude(await driver.find(\".test-undo\").getAttribute(\"class\"), \"-disable\");\n\n    await proposeChange();\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes\");\n\n    assert.include(await driver.find(\".test-undo\").getAttribute(\"class\"), \"-disable\");\n\n    await gu.openPage(\"Life\");\n    await gu.getCell(\"A\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"13\");\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (1)\");\n    assert.notInclude(await driver.find(\".test-undo\").getAttribute(\"class\"), \"-disable\");\n    await proposeChange();\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes\");\n    assert.include(await driver.find(\".test-undo\").getAttribute(\"class\"), \"-disable\");\n\n    await driver.findContentWait(\"span\", /original document/, 2000).click();\n    await driver.findWait(\".test-proposals-header\", 2000);\n    await gu.openPage(\"Life\");\n    await gu.getCell(\"A\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"1\");\n\n    await driver.get(forkUrl);\n    await gu.getCell(\"A\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"99\");\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (1)\");\n    await proposeChange();\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes\");\n    await gu.openPage(\"Life\");\n    await gu.getCell(\"A\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"999\");\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (1)\");\n    await proposeChange();\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes\");\n\n    await returnToTrunk(url);\n  });\n\n  it(\"shows correct action count when viewer auto-forks by typing\", async function() {\n    const { api } = await makeLifeDoc();\n\n    // Duplicate the document via the \"Save Copy\" button, so that\n    // it has a non-trivial baseAction.\n    await driver.find(\".test-tb-share\").click();\n    await driver.findWait(\".test-save-copy\", 2000).click();\n    await gu.completeCopy({ destName: \"LifeCopy\" });\n\n    // Get the copy's doc ID.\n    const copyId = (await gu.getCurrentUrlId())!;\n\n    // Add some extra actions to create action history. This is\n    // important because the bug is clearest when the document has\n    // significant history that would be miscounted if baseAction\n    // isn't updated after forking.\n    for (let i = 0; i < 5; i++) {\n      await api.applyUserActions(copyId, [\n        [\"AddRecord\", \"Life\", null, { A: 100 + i, B: \"Spam\" }],\n      ]);\n    }\n\n    // Turn on feature.\n    await api.updateDoc(copyId, {\n      options: {\n        proposedChanges: {\n          acceptProposals: true,\n        },\n      },\n    });\n\n    // Share the copy with user3 as viewer.\n    const user3Email = gu.session().user(\"user3\").email;\n    await api.updateDocPermissions(copyId, { users: { [user3Email]: \"viewers\" } });\n\n    // Login as user3 (a viewer) and load the copy.\n    const user3Session = await gu.session().teamSite.user(\"user3\").login();\n    await user3Session.loadDoc(`/doc/${copyId}`);\n    await gu.openPage(\"Life\");\n\n    // Dismiss any popups.\n    await gu.dismissBehavioralPrompts();\n\n    // Type into a cell. Since user3 is a viewer and proposals are\n    // enabled, this triggers an auto-fork.\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Bird\");\n    await gu.waitForServer();\n\n    // Wait for the fork to be created (URL changes to include ~).\n    await driver.wait(async () => (await driver.getCurrentUrl()).includes(\"~\"), 5000);\n\n    // The action count should show exactly 1 change. Without the fix,\n    // the count would be wrong because baseAction from the copy (not\n    // the fork) would be used.\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (1)\");\n\n    // Make a second change and verify count updates.\n    await gu.getCell(\"A\", 2).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"99\");\n\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (2)\");\n\n    // Undo and verify count goes back down.\n    await gu.undo();\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (1)\");\n  });\n\n  it(\"can make and apply a proposed change affecting two tables\", async function() {\n    const { api, doc } = await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    // Add a second table.\n    // Create and delete a table around it for that \"aged document\" feel\n    // (there was a bug where an offset in table row ids caused a problem).\n    await gu.sendActions([\n      [\"AddTable\", \"OldPlants\", [{ id: \"Name\", type: \"Text\" }, { id: \"Type\", type: \"Text\" }]],\n      [\"AddTable\", \"Plants\", [{ id: \"Name\", type: \"Text\" }, { id: \"Type\", type: \"Text\" }]],\n      [\"RemoveTable\", \"OldPlants\"],\n      [\"AddRecord\", \"Plants\", 1, { Name: \"Oak\", Type: \"Tree\" }],\n      [\"AddRecord\", \"Plants\", 2, { Name: \"Rose\", Type: \"Flower\" }],\n    ]);\n\n    await workOnCopy(url);\n\n    // Make a change to the Life table.\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Bird\");\n\n    // Make a change to the Plants table.\n    await gu.openPage(\"Plants\");\n    await gu.getCell(\"Type\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Deciduous Tree\");\n\n    // Check that the count shows 2 changes\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (2)\");\n\n    await proposeChange();\n\n    // Click on the \"original document\" to see the proposal.\n    await driver.findContentWait(\"span\", /original document/, 2000).click();\n\n    // There should be exactly one proposal with changes to both tables.\n    await driver.findWait(\".test-proposals-header\", 2000);\n    assert.lengthOf(await driver.findAll(\".test-proposals-header\"), 1);\n\n    // Verify changes are shown for both tables\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n\n    // Check Life table changes\n    assert.deepEqual(await getColumns(\"LIFE\"), [\"B\"]);\n    assert.deepEqual(await getRowValues(\"LIFE\", 0), [\"FishBird\"]);\n    assert.deepEqual(await getChangeType(\"LIFE\", 0), \"→\");\n\n    // Check Plants table changes\n    assert.deepEqual(await getColumns(\"PLANTS\"), [\"Type\"]);\n    assert.deepEqual(await getRowValues(\"PLANTS\", 0), [\"Deciduous Tree\"]);\n    assert.deepEqual(await getChangeType(\"PLANTS\", 0), \"→\");\n\n    // Apply the proposal\n    await driver.find(\".test-proposals-apply\").click();\n    await gu.waitForServer();\n\n    // Verify both changes were applied\n    assert.match(\n      await driver.findContent(\".test-proposals-header\", /#1/).getText(),\n      /Accepted/,\n    );\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Life\")).B,\n      [\"Bird\", \"Primate\"]);\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Plants\")).Type,\n      [\"Deciduous Tree\", \"Flower\"]);\n  });\n\n  it(\"can make and apply a proposed change to a Reference column\", async function() {\n    const { api, doc } = await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    // Add a table to refer to\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Habitat\", [{ id: \"Name\", type: \"Text\" }]],\n      [\"AddRecord\", \"Habitat\", 1, { Name: \"Ocean\" }],\n      [\"AddRecord\", \"Habitat\", 2, { Name: \"Forest\" }],\n      [\"AddRecord\", \"Habitat\", 3, { Name: \"Desert\" }],\n    ]);\n\n    // Add a Reference column to Life table\n    await api.applyUserActions(doc.id, [\n      [\"AddVisibleColumn\", \"Life\", \"Habitat\", { type: \"Ref:Habitat\" }],\n      [\"UpdateRecord\", \"Life\", 1, { Habitat: 1 }], // Fish -> Ocean\n      [\"UpdateRecord\", \"Life\", 2, { Habitat: 2 }], // Primate -> Forest\n    ]);\n\n    // Show what we want\n    await setReferenceDisplayColumn(api, doc.id, \"Life\", \"Habitat\", \"Name\");\n\n    await workOnCopy(url);\n\n    // There's occasionally some race condition, maybe references not\n    // loading fast enough?, if we don't look at them in client. So we\n    // just briefly visit the Habitat page.\n    await gu.openPage(\"Habitat\");\n    await gu.openPage(\"Life\");\n\n    // Change the reference for Fish from Ocean to Desert\n    await gu.getCell(\"Habitat\", 1).click();\n\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Desert\");\n\n    // Check that the count shows 1 change\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (1)\");\n\n    await proposeChange();\n\n    // Click on the \"original document\" to see the proposal.\n    await driver.findContentWait(\"span\", /original document/, 2000).click();\n\n    // There should be exactly one proposal.\n    await driver.findWait(\".test-proposals-header\", 2000);\n    assert.lengthOf(await driver.findAll(\".test-proposals-header\"), 1);\n\n    // Verify the reference change is shown\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n    assert.deepEqual(await getColumns(\"LIFE\"), [\"Habitat\"]);\n    assert.deepEqual(await getRowValues(\"LIFE\", 0), [\"OceanDesert\"]);\n    assert.deepEqual(await getChangeType(\"LIFE\", 0), \"→\");\n\n    // Apply the proposal\n    await driver.find(\".test-proposals-apply\").click();\n    await gu.waitForServer();\n\n    // Verify the change was applied (reference should now point to Desert, which has id 3)\n    assert.match(\n      await driver.findContent(\".test-proposals-header\", /#1/).getText(),\n      /Accepted/,\n    );\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Life\")).Habitat,\n      [3, 2]); // Desert (id 3), Forest (id 2)\n\n    await returnToTrunk(url);\n  });\n\n  it(\"can make and apply a proposed change to a Reference List column\", async function() {\n    const { api, doc } = await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Habitat\", [{ id: \"Name\", type: \"Text\" }]],\n      [\"AddRecord\", \"Habitat\", 1, { Name: \"Ocean\" }],\n      [\"AddRecord\", \"Habitat\", 2, { Name: \"Forest\" }],\n      [\"AddRecord\", \"Habitat\", 3, { Name: \"Desert\" }],\n      [\"AddRecord\", \"Habitat\", 4, { Name: \"Arctic\" }],\n    ]);\n\n    // Add a Reference List column to Life table\n    await api.applyUserActions(doc.id, [\n      [\"AddVisibleColumn\", \"Life\", \"Habitats\", { type: \"RefList:Habitat\" }],\n      [\"UpdateRecord\", \"Life\", 1, { Habitats: [\"L\", 1, 2] }], // Fish -> Ocean, Forest\n      [\"UpdateRecord\", \"Life\", 2, { Habitats: [\"L\", 2] }], // Primate -> Forest\n    ]);\n\n    // Show what we want\n    await setReferenceDisplayColumn(api, doc.id, \"Life\", \"Habitats\", \"Name\");\n\n    await workOnCopy(url);\n\n    await gu.openPage(\"Habitat\");\n    await gu.openPage(\"Life\");\n\n    // Change the reference list for Fish: remove Forest, add Desert and Arctic\n    await gu.getCell(\"Habitats\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Ocean\");\n    await gu.enterCell(\"Desert\");\n    await gu.enterCell(\"Arctic\");\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n\n    // Check that the count shows 1 change\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (1)\");\n\n    await proposeChange();\n\n    // Click on the \"original document\" to see the proposal.\n    await driver.findContentWait(\"span\", /original document/, 2000).click();\n\n    // There should be exactly one proposal.\n    await driver.findWait(\".test-proposals-header\", 2000);\n    assert.lengthOf(await driver.findAll(\".test-proposals-header\"), 1);\n\n    // Verify the reference list change is shown\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n    assert.deepEqual(await getColumns(\"LIFE\"), [\"Habitats\"]);\n    assert.deepEqual(await getRowValues(\"LIFE\", 0), [\"Ocean, ForestDesert, Arctic\"]);\n    assert.deepEqual(await getChangeType(\"LIFE\", 0), \"→\");\n\n    // Apply the proposal\n    await driver.find(\".test-proposals-apply\").click();\n    await gu.waitForServer();\n\n    // Verify the change was applied (reference list should now be [Ocean, Desert, Arctic] = [1, 3, 4])\n    assert.match(\n      await driver.findContent(\".test-proposals-header\", /#1/).getText(),\n      /Accepted/,\n    );\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Life\")).Habitats,\n      [[\"L\" as GristObjCode, 1, 3, 4], [\"L\" as GristObjCode, 2]]); // [Ocean, Desert, Arctic], [Forest]\n\n    await returnToTrunk(url);\n  });\n\n  it(\"can make and apply a proposed change that creates a new Reference\", async function() {\n    const { api, doc } = await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    // Add a table to refer to\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Habitat\", [{ id: \"Name\", type: \"Text\" }]],\n      [\"AddRecord\", \"Habitat\", 1, { Name: \"Ocean\" }],\n      [\"AddRecord\", \"Habitat\", 2, { Name: \"Forest\" }],\n    ]);\n\n    // Add a Reference column to Life table\n    await api.applyUserActions(doc.id, [\n      [\"AddVisibleColumn\", \"Life\", \"Habitat\", { type: \"Ref:Habitat\" }],\n      [\"UpdateRecord\", \"Life\", 1, { Habitat: 1 }], // Fish -> Ocean\n      [\"UpdateRecord\", \"Life\", 2, { Habitat: 2 }], // Primate -> Forest\n    ]);\n\n    // Show what we want\n    await setReferenceDisplayColumn(api, doc.id, \"Life\", \"Habitat\", \"Name\");\n\n    await workOnCopy(url);\n\n    await gu.openPage(\"Habitat\");\n    await gu.openPage(\"Life\");\n\n    // Create a new reference by typing a new value and clicking \"add new\"\n    await gu.getCell(\"Habitat\", 1).click();\n    await gu.waitAppFocus();\n    await driver.sendKeys(\"Mountain\");\n\n    // Click the \"add new\" item. The new value should be saved.\n    await driver.find(\".test-ref-editor-new-item\").click();\n    await gu.waitForServer();\n\n    // Check that the count shows 2 changes.\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (2)\");\n\n    await proposeChange();\n\n    // Click on the \"original document\" to see the proposal.\n    await driver.findContentWait(\"span\", /original document/, 2000).click();\n\n    // There should be exactly one proposal.\n    await driver.findWait(\".test-proposals-header\", 2000);\n    assert.lengthOf(await driver.findAll(\".test-proposals-header\"), 1);\n\n    // Verify the reference change is shown (Ocean -> Mountain)\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n    assert.deepEqual(await getColumns(\"LIFE\"), [\"Habitat\"]);\n    assert.deepEqual(await getRowValues(\"LIFE\", 0), [\"OceanMountain\"]);\n    assert.deepEqual(await getChangeType(\"LIFE\", 0), \"→\");\n\n    // Apply the proposal\n    await driver.find(\".test-proposals-apply\").click();\n    await gu.waitForServer();\n\n    // Verify the change was applied\n    assert.match(\n      await driver.findContent(\".test-proposals-header\", /#1/).getText(),\n      /Accepted/,\n    );\n\n    // The new reference should have been created in the Habitat table\n    const habitats = await api.getDocAPI(doc.id).getRows(\"Habitat\");\n    assert.deepEqual(habitats.Name, [\"Ocean\", \"Forest\", \"Mountain\"]);\n\n    // Fish should now reference Mountain (id 3)\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Life\")).Habitat,\n      [3, 2]); // Mountain (id 3), Forest (id 2)\n\n    await returnToTrunk(url);\n  });\n\n  it(\"can make and apply a proposed change that creates new Reference List items\", async function() {\n    const { api, doc } = await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Habitat\", [{ id: \"Name\", type: \"Text\" }]],\n      [\"AddRecord\", \"Habitat\", 1, { Name: \"Ocean\" }],\n      [\"AddRecord\", \"Habitat\", 2, { Name: \"Forest\" }],\n    ]);\n\n    // Add a Reference List column to Life table\n    await api.applyUserActions(doc.id, [\n      [\"AddVisibleColumn\", \"Life\", \"Habitats\", { type: \"RefList:Habitat\" }],\n      [\"UpdateRecord\", \"Life\", 1, { Habitats: [\"L\", 1] }], // Fish -> Ocean\n      [\"UpdateRecord\", \"Life\", 2, { Habitats: [\"L\", 2] }], // Primate -> Forest\n    ]);\n\n    // Show what we want\n    await setReferenceDisplayColumn(api, doc.id, \"Life\", \"Habitats\", \"Name\");\n\n    await workOnCopy(url);\n\n    await gu.openPage(\"Habitat\");\n    await gu.openPage(\"Life\");\n\n    // Add new references to the list by typing new values\n    await gu.getCell(\"Habitats\", 1).click();\n    await gu.waitAppFocus();\n\n    // Add existing reference\n    await gu.enterCell(\"Ocean\");\n\n    // Add a new reference by typing and clicking \"add new\"\n    await driver.sendKeys(\"Desert\");\n    await driver.find(\".test-ref-editor-new-item\").click();\n    await gu.waitForServer();\n\n    // Add another new reference\n    await driver.sendKeys(\"Tundra\");\n    await driver.find(\".test-ref-editor-new-item\").click();\n    await gu.waitForServer();\n\n    // Close the editor\n    await driver.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n\n    // Check that the count shows 2 changes.\n    assert.equal(await driver.find(\".test-tools-proposals\").getText(),\n      \"Suggest changes (2)\");\n\n    await proposeChange();\n\n    // Click on the \"original document\" to see the proposal.\n    await driver.findContentWait(\"span\", /original document/, 2000).click();\n\n    // There should be exactly one proposal.\n    await driver.findWait(\".test-proposals-header\", 2000);\n    assert.lengthOf(await driver.findAll(\".test-proposals-header\"), 1);\n\n    // Verify the reference list change is shown\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n    assert.deepEqual(await getColumns(\"LIFE\"), [\"Habitats\"]);\n    assert.deepEqual(await getRowValues(\"LIFE\", 0), [\"Ocean, Desert, Tundra\"]);\n    assert.deepEqual(await getChangeType(\"LIFE\", 0), \"→\");\n\n    // Apply the proposal\n    await driver.find(\".test-proposals-apply\").click();\n    await gu.waitForServer();\n\n    // Verify the change was applied\n    assert.match(\n      await driver.findContent(\".test-proposals-header\", /#1/).getText(),\n      /Accepted/,\n    );\n\n    // The new references should have been created in the Habitat table\n    const habitats = await api.getDocAPI(doc.id).getRows(\"Habitat\");\n    assert.deepEqual(habitats.Name, [\"Ocean\", \"Forest\", \"Desert\", \"Tundra\"]);\n\n    // Fish should now reference Ocean, Desert, and Tundra\n    assert.deepEqual((await api.getDocAPI(doc.id).getRows(\"Life\")).Habitats,\n      [[\"L\" as GristObjCode, 1, 3, 4], [\"L\" as GristObjCode, 2]]); // [Ocean, Desert, Tundra], [Forest]\n\n    await returnToTrunk(url);\n  });\n\n  it(\"can make changes on a doc with conditional formatting\", async function() {\n    const { doc, api } = await makeLifeDoc();\n    // const url = await driver.getCurrentUrl();\n    await addConditionalFormatting(api, doc.id, \"Life\", \"B\", \"'s' in $B\", {\n      fillColor: \"#f00\",\n    });\n    await gu.waitForServer();\n\n    // Check if the cell has the expected content background color\n    let cell = await gu.getCell(\"B\", 1);\n    assert.equal(await cell.getText(), \"Fish\");\n    assert.equal(await cell.getCssValue(\"background-color\"), \"rgba(255, 0, 0, 1)\");\n\n    // Work on a copy.\n    await driver.find(\".test-tb-share\").click();\n    await driver.findWait(\".test-work-on-copy\", 2000).click();\n    await gu.waitForServer();\n    await gu.waitForDocToLoad();\n\n    // Change the content of the first cell.\n    await gu.openPage(\"Life\");\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Fizh\");\n\n    // Go to the propose-changes page.\n    await driver.find(\".test-tools-proposals\").click();\n\n    // Make sure the expected change is shown.\n    await driver.findContentWait(\".test-main-content\", /Suggest changes/, 2000);\n    await driver.findWait(\".test-actionlog-tabular-diffs .field_clip\", 2000);\n    assert.deepEqual(await getColumns(\"LIFE\"), [\"B\"]);\n    assert.deepEqual(await getRowValues(\"LIFE\", 0), [\"FishFizh\"]);\n    assert.deepEqual(await getChangeType(\"LIFE\", 0), \"→\");\n\n    await driver.find(\".test-proposals-propose\").click();\n    // Click on the \"original document\" to see how things are there now.\n    await driver.findContentWait(\".test-proposals-status\", /Suggestion/, 2000);\n    await driver.findContentWait(\"span\", /original document/, 2000).click();\n\n    await driver.findWait(\".test-proposals-apply\", 2000).click();\n    await gu.waitForServer();\n\n    await gu.dbClick(driver.findContent(\".diff-remote\", /Fizh/));\n    await driver.findContentWait(\".test-widget-title-text\", /LIFE/, 2000);\n    cell = await gu.getCell(\"B\", 1);\n    assert.equal(await cell.getText(), \"Fizh\");\n    assert.notEqual(await cell.getCssValue(\"background-color\"), \"rgba(255, 0, 0, 1)\");\n  });\n\n  it(\"highlights edited cells in suggestion mode\", async function() {\n    await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    await workOnCopy(url);\n\n    // Verify comparison is active with details.\n    const comparison = await gu.getComparison();\n    assert.isNotNull(comparison);\n    assert.property(comparison!, \"details\");\n\n    // Verify the diff-emphasize-local class is present on the content pane.\n    const hasEmphasis = await driver.executeScript<boolean>(\n      () => !!document.querySelector(\".diff-emphasize-local\"),\n    );\n    assert.isTrue(hasEmphasis);\n\n    // Edit cell B row 1 from \"Fish\" to \"Cat\".\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Cat\");\n\n    // Verify the cell shows diff highlighting.\n    let cell = await gu.getCell(\"B\", 1);\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"Fish\");\n    assert.equal(await cell.find(\".diff-local\").getText(), \"Cat\");\n\n    // Undo and verify highlighting is gone.\n    await gu.undo();\n    cell = await gu.getCell(\"B\", 1);\n    assert.equal(await cell.find(\".diff-parent\").isPresent(), false);\n    assert.equal(await cell.find(\".diff-local\").isPresent(), false);\n\n    // Redo and verify highlighting returns.\n    await gu.redo();\n    cell = await gu.getCell(\"B\", 1);\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"Fish\");\n    assert.equal(await cell.find(\".diff-local\").getText(), \"Cat\");\n\n    await returnToTrunk(url);\n  });\n\n  it(\"highlights cells after reload in suggestion mode\", async function() {\n    await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    await workOnCopy(url);\n\n    // Edit cell B row 1.\n    await gu.getCell(\"B\", 1).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Cat\");\n\n    // Verify highlighting before reload.\n    let cell = await gu.getCell(\"B\", 1);\n    assert.equal(await cell.find(\".diff-local\").getText(), \"Cat\");\n\n    // Reload the page.\n    await gu.refreshDismiss({ ignore: true });\n\n    // Verify the previously edited cell still shows diff highlighting.\n    cell = await gu.getCell(\"B\", 1);\n    assert.equal(await cell.find(\".diff-parent\").getText(), \"Fish\");\n    assert.equal(await cell.find(\".diff-local\").getText(), \"Cat\");\n\n    await returnToTrunk(url);\n  });\n\n  it(\"highlights added rows in suggestion mode\", async function() {\n    await makeLifeDoc();\n    const url = await driver.getCurrentUrl();\n\n    await workOnCopy(url);\n\n    // Navigate to the last row and add a new record.\n    await gu.getCell(\"B\", 3).click();\n    await gu.waitAppFocus();\n    await gu.enterCell(\"Whale\");\n\n    // Verify the new row has diff-local-add class.\n    const cell = await gu.getCell(\"B\", 3);\n    const record = await cell.findClosest(\".record\");\n    assert.include(await record.getAttribute(\"class\"), \"diff-local-add\");\n\n    // Verify the cell has diff-local content.\n    assert.equal(await cell.find(\".diff-local\").getText(), \"Whale\");\n\n    await returnToTrunk(url);\n  });\n\n  async function makeLifeDoc() {\n    // Load a test document.\n    const session = await gu.session().teamSite.login();\n    const doc = await session.tempDoc(cleanup, \"Hello.grist\");\n\n    // Turn on feature.\n    const api = session.createHomeApi();\n    await api.updateDoc(doc.id, {\n      options: {\n        proposedChanges: {\n          acceptProposals: true,\n        },\n      },\n    });\n\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Life\", [\n        { id: \"A\", type: \"Int\" },\n        { id: \"B\", type: \"Text\" },\n      ]],\n      [\"AddRecord\", \"Life\", 1, { A: 10, B: \"Fish\" }],\n      [\"AddRecord\", \"Life\", 2, { A: 20, B: \"Primate\" }],\n    ]);\n\n    await gu.openPage(\"Life\");\n    return { session, doc, api };\n  }\n\n  // Work on a copy.\n  async function workOnCopy(url: string) {\n    await driver.get(url);\n    if (await gu.isAlertShown()) { await gu.acceptAlert(); }\n    await gu.waitForDocToLoad();\n    await driver.findWait(\".test-tb-share\", 2000).click();\n    await driver.findWait(\".test-work-on-copy\", 2000).click();\n    await gu.waitForServer();\n    await gu.openPage(\"Life\");\n  }\n\n  async function returnToTrunk(url: string) {\n    await driver.get(url);\n    if (await gu.isAlertShown()) { await gu.acceptAlert(); }\n    await gu.waitForDocToLoad();\n  }\n\n  // Propose a change.\n  async function proposeChange() {\n    assert.match(await driver.find(\".test-tools-proposals\").getText(),\n      /Suggest changes/);\n    await driver.find(\".test-tools-proposals\").click();\n    await driver.findWait(\".test-proposals-propose\", 2000).click();\n    await gu.waitForServer();\n  }\n});\n\nasync function getColumns(section: string): Promise<string[]> {\n  const title = await driver.findContentWait(\".test-viewsection-title\", section, 2000);\n  const parent = await title.findClosest(\".viewsection_content\");\n  return await parent.findAll(\".test-column-title-text\", e => e.getText());\n}\n\nasync function getRowValues(section: string, rowIndex: number): Promise<string[]> {\n  const title = await driver.findContentWait(\".test-viewsection-title\", section, 2000);\n  const parent = await title.findClosest(\".viewsection_content\");\n  await parent.findWait(\".record\", 2000);\n  const row = (await parent.findAll(\".gridview_row .record\"))[rowIndex];\n  return await row.findAll(\".field_clip\", e => e.getText());\n}\n\nasync function getChangeType(section: string, rowIndex: number): Promise<string> {\n  const title = await driver.findContentWait(\".test-viewsection-title\", section, 2000);\n  const parent = await title.findClosest(\".viewsection_content\");\n  await parent.findWait(\".gridview_data_row_num\", 2000);\n  const row = (await parent.findAll(\".gridview_data_row_num\"))[rowIndex];\n  return await row.getText();\n}\n\nasync function expand(section: string) {\n  const title = await driver.findContentWait(\".test-viewsection-title\", section, 2000);\n  const parent = await title.findClosest(\".viewsection_content\");\n  const button = await parent.find(\".test-proposals-expand\");\n  await button.click();\n}\n\nasync function collapse(section: string) {\n  const title = await driver.findContentWait(\".test-viewsection-title\", section, 2000);\n  const parent = await title.findClosest(\".viewsection_content\");\n  const button = await parent.find(\".test-proposals-collapse\");\n  await button.click();\n}\n\n/**\n * Based on Dmitry's comment at:\n *   https://github.com/gristlabs/grist-core/issues/970#issuecomment-2102933747\n */\nasync function setReferenceDisplayColumn(\n  api: UserAPI, docId: string, tableId: string, refColId: string, showColId: string,\n) {\n  const docApi = api.getDocAPI(docId);\n\n  // Get column metadata to find the numeric IDs and string IDs\n  const columns = await docApi.getRecords(\"_grist_Tables_column\");\n  const tables = await docApi.getRecords(\"_grist_Tables\");\n\n  const table = tables.find(t => t.fields.tableId === tableId);\n  if (!table) {\n    throw new Error(`Table ${tableId} not found`);\n  }\n\n  const refColumn = columns.find(c =>\n    c.fields.parentId === table.id && c.fields.colId === refColId,\n  );\n\n  if (!refColumn) {\n    throw new Error(`Column ${refColId} not found`);\n  }\n\n  const targetTableId = getReferencedTableId(refColumn.fields.type as string);\n  if (!targetTableId) {\n    throw new Error(`Column ${refColId} is not a reference column`);\n  }\n\n  const targetTable = tables.find(t => t.fields.tableId === targetTableId);\n\n  if (!targetTable) {\n    throw new Error(`Target table ${targetTableId} not found`);\n  }\n\n  const showColumn = columns.find(c =>\n    c.fields.parentId === targetTable.id && c.fields.colId === showColId,\n  );\n\n  if (!showColumn) {\n    throw new Error(`Column ${showColId} not found in target table`);\n  }\n\n  // Apply both actions together:\n  // 1. Update visibleCol in metadata\n  // 2. Set the display formula\n  await docApi.applyUserActions([\n    [\"UpdateRecord\", \"_grist_Tables_column\", refColumn.id, { visibleCol: showColumn.id }],\n    [\"SetDisplayFormula\", tableId, null, refColumn.id, `$${refColId}.${showColId}`],\n  ]);\n}\n\n/**\n * Set conditional formatting on a column. There's probably\n * a slightly smoother way, this is patched together from just\n * hacking.\n */\nasync function addConditionalFormatting(\n  api: UserAPI,\n  docId: string,\n  tableId: string,\n  colId: string,\n  formula: string,\n  options?: {\n    textColor?: string;\n    fillColor?: string;\n  },\n) {\n  const docApi = api.getDocAPI(docId);\n\n  // Get column metadata to find the numeric IDs and string IDs\n  const columns = await docApi.getRecords(\"_grist_Tables_column\");\n  const tables = await docApi.getRecords(\"_grist_Tables\");\n\n  const table = tables.find(t => t.fields.tableId === tableId);\n  if (!table) {\n    throw new Error(`Table ${tableId} not found`);\n  }\n\n  const column = columns.find(c =>\n    c.fields.parentId === table.id && c.fields.colId === colId,\n  );\n\n  if (!column) {\n    throw new Error(`Column ${colId} not found`);\n  }\n\n  // Add an empty rule\n  await docApi.applyUserActions([\n    [\"AddEmptyRule\", tableId, 0, column.id],\n  ]);\n\n  // Fetch the updated column to get the new rules array\n  const updatedColumns = await docApi.getRecords(\"_grist_Tables_column\");\n  const updatedColumn = updatedColumns.find(c => c.id === column.id);\n\n  if (!updatedColumn) {\n    throw new Error(\"Failed to fetch updated column\");\n  }\n\n  // The rules field is a RefList - ['L', id1, id2, ...]\n  const rules = updatedColumn.fields.rules as any[];\n  if (!Array.isArray(rules) || rules.length < 2 || rules[0] !== \"L\") {\n    throw new Error(\"Unexpected rules format\");\n  }\n\n  // Get the last rule column ID (the one we just created)\n  const ruleColumnId = rules[rules.length - 1];\n  const ruleIndex = rules.length - 2; // Index in the array (skip the 'L' marker)\n\n  // Update the rule column's formula\n  await docApi.applyUserActions([\n    [\"UpdateRecord\", \"_grist_Tables_column\", ruleColumnId, { formula }],\n  ]);\n\n  // Now update the data column's widgetOptions with the styling for this rule\n  if (options) {\n    const currentWidgetOptions = updatedColumn.fields.widgetOptions as string || \"{}\";\n    const widgetOptions = JSON.parse(currentWidgetOptions);\n\n    if (!widgetOptions.rulesOptions) {\n      widgetOptions.rulesOptions = [];\n    }\n\n    // Ensure the array is large enough\n    while (widgetOptions.rulesOptions.length <= ruleIndex) {\n      widgetOptions.rulesOptions.push({});\n    }\n\n    // Set the options for this rule at the correct index\n    widgetOptions.rulesOptions[ruleIndex] = options;\n\n    await docApi.applyUserActions([\n      [\"UpdateRecord\", \"_grist_Tables_column\", column.id, {\n        widgetOptions: JSON.stringify(widgetOptions),\n      }],\n    ]);\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/RawData.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"RawData\", function() {\n  this.timeout(30000);\n  let api: UserAPI;\n  let doc: string;\n  // We will stress undo here and will try to undo all tests that were using RAW DATA views.\n  // At the time of writing this test, undo was basically not possible and was throwing all sort\n  // of exceptions (related to summary tables).\n  let revertAll: () => Promise<void>;\n  setupTestSuite();\n  gu.bigScreen();\n  afterEach(() => gu.checkForErrors());\n  before(async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const docInfo = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"World.grist\");\n    doc = docInfo.id;\n    api = gu.createHomeApi(\"Chimpy\", \"nasa\");\n    await openRawData();\n    revertAll = await gu.begin();\n  });\n\n  it(\"shows all tables\", async function() {\n    const uiTables = await getRawTableIds();\n    const data = await api.getTable(doc, \"_grist_Tables\");\n    const tables: string[] = data.tableId as string[];\n    tables.sort();\n    uiTables.sort();\n    assert.deepEqual(uiTables, tables);\n  });\n\n  it(\"shows blank creator panel\", async function() {\n    await gu.toggleSidePanel(\"right\", \"open\");\n    assert.isEmpty(await driver.find(\".test-right-panel\").getText());\n    await gu.toggleSidePanel(\"right\", \"close\");\n  });\n\n  it(\"shows row counts of all tables\", async function() {\n    assert.deepEqual(await getRawTableRows(), [\n      \"4,079\",\n      \"239\",\n      \"984\",\n      \"4\",\n    ]);\n  });\n\n  it(\"shows new table name\", async function() {\n    await gu.renameTable(\"City\", \"Town\");\n    const uiTables = await getRawTableIds();\n    const data = await api.getTable(doc, \"_grist_Tables\");\n    const tables: string[] = data.tableId as string[];\n    tables.sort();\n    uiTables.sort();\n    assert.deepEqual(uiTables, tables);\n  });\n\n  it(\"shows table preview\", async function() {\n    // Open modal with grid\n    await driver.findContent(\".test-raw-data-table-title\", \"Country\").click();\n    await gu.waitForServer();\n    // Test that overlay is showed.\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n    // Test proper table is selected.\n    assert.equal(await gu.getSectionTitle(), \"Country\");\n    // Test we have some data.\n    assert.deepEqual(await gu.getVisibleGridCells(\"Code\", [1, 2], \"Country\"), [\"ABW\", \"AFG\"]);\n    // Test we can close by button.\n    await gu.closeRawTable();\n    assert.isFalse(await driver.find(\".test-raw-data-overlay\").isPresent());\n\n    // Test we can close by pressing escape.\n    await driver.findContent(\".test-raw-data-table-title\", \"Country\").click();\n    assert.isTrue(await driver.find(\".test-raw-data-overlay\").isDisplayed());\n    await driver.sendKeys(Key.ESCAPE);\n    assert.isFalse(await driver.find(\".test-raw-data-overlay\").isPresent());\n\n    // Test we can't close by pressing escape when there is a selection,\n    await driver.findContent(\".test-raw-data-table-title\", \"Country\").click();\n    assert.isTrue(await driver.find(\".test-raw-data-overlay\").isDisplayed());\n    await driver.find(\".gridview_data_corner_overlay\").doClick();\n    await driver.sendKeys(Key.ESCAPE);\n    assert.isTrue(await driver.find(\".test-raw-data-overlay\").isDisplayed());\n    // Press ESCAPE one more time to close.\n    await driver.sendKeys(Key.ESCAPE);\n    assert.isFalse(await driver.find(\".test-raw-data-overlay\").isPresent());\n\n    // Test we can close by clicking on overlay.\n    await driver.findContent(\".test-raw-data-table-title\", \"Country\").click();\n    assert.isTrue(await driver.find(\".test-raw-data-overlay\").isDisplayed());\n    await driver.find(\".test-raw-data-close-button\").mouseMove();\n    await driver.mouseMoveBy({ y: 100 }); // move 100px below (not negative value)\n    await driver.withActions(a => a.click());\n    assert.isFalse(await driver.find(\".test-raw-data-overlay\").isPresent());\n  });\n\n  it(\"hides or disables inapplicable menu items on raw view sections\", async function() {\n    await openRawData();\n    await driver.findContent(\".test-raw-data-table-title\", \"Country\").click();\n    await gu.waitForServer();\n    await gu.openSectionMenu(\"viewLayout\");\n    await gu.findOpenMenu();\n    assert.equal(await gu.findOpenMenuItem(\"li\", /Widget options/).isDisplayed(), true);\n    assert.equal(await gu.findOpenMenuItem(\"li\", /Create a form/).isDisplayed(), false);\n    assert.equal(await gu.findOpenMenuItem(\"li\", /Collapse widget/).matches(\".disabled\"), true);\n    assert.equal(await gu.findOpenMenuItem(\"li\", /Duplicate widget/).matches(\".disabled\"), true);\n    assert.equal(await gu.findOpenMenuItem(\"li\", /Delete widget/).matches(\".disabled\"), true);\n    await driver.sendKeys(Key.ESCAPE);    // Close the menu\n    await driver.sendKeys(Key.ESCAPE);    // Close the preview popup\n  });\n\n  it(\"should rename table from modal window\", async function() {\n    // Open Country table.\n    await driver.findContent(\".test-raw-data-table-title\", \"Country\").click();\n    await gu.waitForServer();\n    // Rename section to Empire\n    await gu.renameActiveTable(\"Empire\");\n    // Close and test that it was renamed\n    await gu.closeRawTable();\n    const tables = await getRawTableIds();\n    const titles = await driver.findAll(\".test-raw-data-table-title\", e => e.getText());\n    tables.sort();\n    titles.sort();\n    // Title should also be renamed for now. In follow-up diff those\n    // two will be separate.\n    assert.deepEqual(titles, [\"Town\", \"Empire\", \"CountryLanguage\", \"Table1\"].sort());\n    assert.deepEqual(tables, [\"Town\", \"Empire\", \"CountryLanguage\", \"Table1\"].sort());\n  });\n\n  it(\"should show table description\", async function() {\n    // Give Empire table a description.\n    await gu.renameRawTable(\"Empire\", undefined, \"My raw data table description.\");\n\n    // Check that a description icon tooltip is shown next to the title.\n    await driver.findContent(\".test-raw-data-table-title\", \"Empire\")\n      .findWait(\".test-widget-info-tooltip\", 100)\n      .click();\n    await gu.waitToPass(async () => {\n      assert.isTrue(await driver.find(\".test-widget-info-tooltip-popup\").isDisplayed());\n    });\n    assert.equal(\n      await driver.find(\".test-widget-info-tooltip-popup\").getText(),\n      \"My raw data table description.\",\n    );\n\n    // Open Empire table and check that the tooltip is shown there as well.\n    await driver.findContent(\".test-raw-data-table-title\", \"Empire\").click();\n    await gu.waitForServer();\n    await driver.find(\".test-viewsection-title .test-widget-info-tooltip\").click();\n    await gu.waitToPass(async () => {\n      assert.isTrue(await driver.find(\".test-widget-info-tooltip-popup\").isDisplayed());\n    });\n    assert.equal(\n      await driver.find(\".test-widget-info-tooltip-popup\").getText(),\n      \"My raw data table description.\",\n    );\n    await gu.closeRawTable();\n  });\n\n  it(\"should remove table\", async function() {\n    // Open menu for Town\n    await openMenu(\"Town\");\n    // Click delete.\n    await clickRemove();\n    // Confirm.\n    await clickConfirm();\n    await gu.waitForServer();\n    const tables = await getRawTableIds();\n    const titles = await driver.findAll(\".test-raw-data-table-title\", e => e.getText());\n    tables.sort();\n    titles.sort();\n    // Title should also be renamed for now. In a follow-up diff those\n    // two will be separate.\n    assert.deepEqual(titles, [\"Empire\", \"CountryLanguage\", \"Table1\"].sort());\n    assert.deepEqual(tables, [\"Empire\", \"CountryLanguage\", \"Table1\"].sort());\n  });\n\n  it(\"should duplicate table\", async function() {\n    await openMenu(\"Empire\");\n    await clickDuplicateTable();\n    await driver.find(\".test-duplicate-table-name\").click();\n    await gu.sendKeys(\"Empire Copy\");\n\n    // Before clicking the Copy All Data checkbox, check that no warning about ACLs is shown.\n    assert.isFalse(await driver.find(\".test-duplicate-table-acl-warning\").isPresent());\n\n    // Now click the Copy All Data checkbox, and check that the warning is shown.\n    await driver.find(\".test-duplicate-table-copy-all-data\").click();\n    assert.isTrue(await driver.find(\".test-duplicate-table-acl-warning\").isPresent());\n\n    await clickConfirm();\n    await gu.waitForServer();\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n    assert.equal(await gu.getSectionTitle(), \"Empire Copy\");\n    assert.deepEqual(await gu.getVisibleGridCells(\"Code\", [1, 2], \"Empire Copy\"), [\"ABW\", \"AFG\"]);\n\n    await driver.sendKeys(Key.ESCAPE);\n    const tables = await getRawTableIds();\n    const titles = await driver.findAll(\".test-raw-data-table-title\", e => e.getText());\n    tables.sort();\n    titles.sort();\n    assert.deepEqual(titles, [\"Empire\", \"Empire Copy\", \"CountryLanguage\", \"Table1\"].sort());\n    assert.deepEqual(tables, [\"Empire\", \"Empire_Copy\", \"CountryLanguage\", \"Table1\"].sort());\n  });\n\n  it(\"should restore position when browser is refreshed\", async function() {\n    await driver.findContent(\".test-raw-data-table-title\", \"Empire\").click();\n    await gu.waitForServer();\n    await gu.getCell(3, 2).click();\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n    assert.deepEqual(await gu.getCursorPosition(), { col: 3, rowNum: 2 });\n    // Close overlay.\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should restore last edit position when browser is refreshed\", async function() {\n    await driver.findContent(\".test-raw-data-table-title\", \"Empire\").click();\n    await gu.waitForServer();\n    await gu.getCell(2, 9).click();\n    await driver.sendKeys(\"123456789\");\n    await gu.refreshDismiss();\n    await gu.waitForDocToLoad();\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n    await gu.checkTextEditor(gu.exactMatch(\"123456789\"));\n    // Close editor.\n    await driver.sendKeys(Key.ESCAPE);\n    assert.deepEqual(await gu.getCursorPosition(), { col: 2, rowNum: 9 });\n    // Close overlay.\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should copy anchor link and restore\", async function() {\n    await driver.findContent(\".test-raw-data-table-title\", \"Empire\").click();\n    await gu.waitForServer();\n    await (await gu.openRowMenu(10)).findContent(\"li\", /Copy anchor link/).click();\n    await driver.findContentWait(\".test-notifier-toast-message\", /Link copied to clipboard/, 2000);\n    await driver.find(\".test-notifier-toast-close\").click();\n    const anchor = (await gu.getTestState()).clipboard!;\n    await gu.getCell(3, 2).click();\n    await gu.onNewTab(async () => {\n      await driver.get(anchor);\n      await gu.waitForAnchor();\n      assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n      assert.deepEqual(await gu.getCursorPosition(), { col: 0, rowNum: 10 });\n    });\n    // Close overlay.\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should copy table name\", async function() {\n    await driver.findContentWait(\".test-raw-data-table-id\", \"Empire\", 1000).click();\n    await gu.waitToPass(async () => {\n      assert.equal((await gu.getTestState()).clipboard, \"Empire\");\n    }, 500);\n    // Currently tooltip is not dismissible, so let's refresh the page.\n    await driver.navigate().refresh();\n    await waitForRawData();\n  });\n\n  it(\"shows summary tables under Raw Data Tables\", async function() {\n    // Add a few summary tables: 1 with no group-by columns, and 2 that\n    // share the same group-by columns.\n    for (let i = 0; i <= 2; i++) {\n      await gu.addNewPage(/Table/, /CountryLanguage/, {\n        summarize: i === 0 ? [] : [\"Country\"],\n      });\n    }\n\n    // Check that the added summary tables are listed at the end.\n    await openRawData();\n    assert.deepEqual(await getRawTableTitles(), [\n      \"CountryLanguage\",\n      \"Empire\",\n      \"Empire Copy\",\n      \"Table1\",\n      \"CountryLanguage [Totals]\",\n      \"CountryLanguage [by Country]\",\n    ]);\n    assert.deepEqual(await getRawTableIds(), [\n      \"CountryLanguage\",\n      \"Empire\",\n      \"Empire_Copy\",\n      \"Table1\",\n      \"CountryLanguage_summary\",\n      \"CountryLanguage_summary_Country\",\n    ]);\n  });\n\n  it(\"shows Record Card button for all non-summary tables\", async function() {\n    const displayed = await getRawTableRecordCardButtonsIsDisplayed();\n    assert.deepEqual(displayed, [\n      true,\n      true,\n      true,\n      true,\n      false,\n      false,\n    ]);\n    const enabled = await getRawTableRecordCardButtonsIsEnabled();\n    assert.deepEqual(enabled, [\n      true,\n      true,\n      true,\n      true,\n      false,\n      false,\n    ]);\n  });\n\n  it(\"shows preview of summary table when clicked\", async function() {\n    // Open a summary table.\n    await driver.findContent(\".test-raw-data-table-title\", \"CountryLanguage [by Country]\").click();\n    await gu.waitForServer();\n\n    // Check that an overlay is shown.\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n\n    // Check that the right section title is shown.\n    assert.equal(await gu.getSectionTitle(), \"COUNTRYLANGUAGE [by Country]\");\n\n    // Make sure the data looks correct.\n    assert.deepEqual(\n      await gu.getVisibleGridCells(\"Country\", [1, 2, 3, 4, 5], \"CountryLanguage [by Country]\"),\n      [\"ABW\", \"AFG\", \"AGO\", \"AIA\", \"ALB\"],\n    );\n\n    // Close the overlay.\n    await gu.closeRawTable();\n    assert.isFalse(await driver.find(\".test-raw-data-overlay\").isPresent());\n  });\n\n  it(\"removes summary table when all sections referencing it are removed\", async function() {\n    // CountryLanguage [Totals] and CountryLanguage [by Country] respectively.\n    await gu.removePage(\"New page\");\n    await gu.removePage(\"New page\");\n\n    // Check that the table summarizing by country wasn't removed, since there is still\n    // one more view for it.\n    assert.deepEqual(await getRawTableTitles(), [\n      \"CountryLanguage\",\n      \"Empire\",\n      \"Empire Copy\",\n      \"Table1\",\n      \"CountryLanguage [by Country]\",\n    ]);\n  });\n\n  it(\"removes summary table when source table is removed\", async function() {\n    await removeRawTable(\"CountryLanguage\");\n    assert.deepEqual(await getRawTableTitles(), [\n      \"Empire\",\n      \"Empire Copy\",\n      \"Table1\",\n    ]);\n    await gu.undo();\n    assert.deepEqual(await getRawTableTitles(), [\n      \"CountryLanguage\",\n      \"Empire\",\n      \"Empire Copy\",\n      \"Table1\",\n      \"CountryLanguage [by Country]\",\n    ]);\n  });\n\n  it('removes summary table when \"Remove\" menu item is clicked', async function() {\n    const tableIds = await getRawTableIds();\n    await removeRawTable(tableIds[tableIds.length - 1]);\n\n    const titles = await getRawTableTitles();\n    assert.deepEqual(titles, [\n      \"CountryLanguage\",\n      \"Empire\",\n      \"Empire Copy\",\n      \"Table1\",\n    ]);\n  });\n\n  it(\"should stay on a page when undoing summary table\", async function() {\n    // Undoing after converting a table to a summary table doesn't know\n    // where to navigate, as section is removed and recreated during navigation\n    // and it is not connected to any view for a brief moment - which makes that\n    // section look like a Raw Data View (section without a view).\n\n    // This tests that the section is properly identified and Grist will not navigate\n    // to the Raw Data view.\n    await gu.addNewTable();\n    const url = await driver.getCurrentUrl();\n    assert.isTrue(url.endsWith(\"p/8\"));\n    await convertToSummary();\n    assert.equal(url, await driver.getCurrentUrl());\n    await gu.undo();\n    assert.equal(url, await driver.getCurrentUrl());\n    await gu.redo();\n    // Reverting actually went to a bare document url (without a page id)\n    // This was old buggy behavior that is now fixed.\n\n    assert.equal(url, await driver.getCurrentUrl());\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 1, col: 0 });\n\n    // Switching pages was producing error after undoing summary table.\n    await gu.openPage(\"Empire\");\n    await gu.checkForErrors();\n    await gu.openPage(\"Table2\");\n    await gu.checkForErrors();\n  });\n\n  it(\"should remove all tables except one (including referenced summary table)\", async function() {\n    // First we will add a new summary table for CountryLanguage table.\n    // This table has a reference to the Country table, and Grist had a bug that\n    // didn't allow to delete those tables - so here we will test if this is fixed.\n    await gu.addNewPage(\"Table\", \"CountryLanguage\", {\n      summarize: [\"Country\"],\n    });\n\n    await openRawData();\n\n    const allTables = await getRawTableIds();\n\n    // Now we are ready to test deletion.\n    const beforeDeleteCheckpoint = await gu.begin();\n\n    // First remove that table without a raw section, to see if that works.\n    await removeRawTable(\"Table1\");\n    await gu.checkForErrors();\n    assert.isFalse((await getRawTableIds()).includes(\"Table1\"));\n\n    // Now try to remove Country (now Empire) table - here we had a bug\n    await removeRawTable(\"Empire\");\n    await gu.checkForErrors();\n    assert.isFalse((await getRawTableIds()).includes(\"Empire\"));\n\n    // Now revert and remove all until remove is disabled\n    await beforeDeleteCheckpoint();\n    await openRawData();\n\n    while (allTables.length > 1) {\n      await removeRawTable(allTables.pop()!);\n    }\n\n    // We should have only one table\n    assert.deepEqual(await getRawTableIds(), allTables);\n\n    // The last table should have disabled remove button.\n    await openMenu(allTables[0]);\n    assert.isTrue(await driver.find(\".test-raw-data-menu-remove-table.disabled\").isDisplayed());\n    await gu.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should allow removing GristHidden* pages\", async () => {\n    // Add a table named GristHidden_test, to test when such tables are left over after an incomplete import.\n\n    // Prepare two tables to test\n    await gu.addNewTable();\n    // Remove last old table we have\n    await openRawData();\n    await removeRawTable(\"CountryLanguage\");\n\n    await gu.addNewTable();\n    assert.deepEqual(await gu.getPageNames(), [\"Table1\", \"Table2\"]);\n\n    // Rename Table2 page to GristHidden_test, it should be still visible, as the table\n    // id is diffrent (not hidden).\n    await gu.renamePage(\"Table1\", \"GristHidden_test\");\n    assert.deepEqual(await gu.getPageNames(), [\"GristHidden_test\", \"Table2\"]);\n    // Make sure all pages can be removed\n    for (const page of await gu.getPageNames()) {\n      assert.isTrue(await gu.canRemovePage(page));\n    }\n    await gu.removePage(\"Table2\");\n    assert.deepEqual(await gu.getPageNames(), [\"GristHidden_test\"]);\n    assert.isFalse(await gu.canRemovePage(\"GristHidden_test\"));\n    await gu.undo();\n\n    await gu.removePage(\"GristHidden_test\");\n    assert.deepEqual(await gu.getPageNames(), [\"Table2\"]);\n    assert.isFalse(await gu.canRemovePage(\"Table2\"));\n    await gu.undo();\n    assert.deepEqual(await gu.getPageNames(), [\"GristHidden_test\", \"Table2\"]);\n  });\n\n  it(\"should allow removing hidden tables\", async () => {\n    // Rename Table1 table to a simulate hidden table\n    await openRawData();\n    await gu.renameRawTable(\"Table2\", \"GristHidden_import\");\n    assert.deepEqual(await getRawTableIds(), [\"GristHidden_import\", \"Table1\"]);\n    // Page should be hidden now\n    assert.deepEqual(await gu.getPageNames(), [\"GristHidden_test\"]);\n    assert.isFalse(await gu.canRemovePage(\"GristHidden_test\"));\n    // We should be able to remove hidden table, but not user table (as this can be last table that will\n    // be auto-removed).\n    assert.isTrue(await isRemovable(\"GristHidden_import\"));\n    assert.isFalse(await isRemovable(\"Table1\"));\n\n    // Rename back\n    await gu.renameRawTable(\"GristHidden_import\", \"Table2\");\n    // Page should be visible again\n    assert.deepEqual(await gu.getPageNames(), [\"GristHidden_test\", \"Table2\"]);\n    for (const page of await gu.getPageNames()) {\n      assert.isTrue(await gu.canRemovePage(page));\n    }\n    assert.isTrue(await isRemovable(\"Table2\"));\n    assert.isTrue(await isRemovable(\"Table1\"));\n\n    // Rename once again and test if it can be actually removed.\n    await gu.renameRawTable(\"Table2\", \"GristHidden_import\");\n    assert.isTrue(await isRemovable(\"GristHidden_import\"));\n    await removeRawTable(\"GristHidden_import\");\n    await gu.checkForErrors();\n    assert.deepEqual(await getRawTableIds(), [\"Table1\"]);\n    assert.isFalse(await isRemovable(\"Table1\"));\n  });\n\n  it(\"should revert all without errors\", async function() {\n    // Revert internally checks errors.\n    await revertAll();\n  });\n\n  it(\"should open raw data as a popup\", async () => {\n    // We are at City table, in first row/first cell.\n    // Send some keys, to make sure we have focus on active section.\n    // RawData popup is manipulating what section has focus, so we need to make sure that\n    // focus is properly restored.\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 1, col: 0 });\n    await gu.getCell(0, 2).click();\n    await gu.sendKeys(\"abc\");\n    await gu.checkTextEditor(\"abc\");\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.showRawData();\n    assert.equal(await gu.getActiveSectionTitle(), \"City\");\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 20, col: 0 }); // raw popup is not sorted\n    await gu.sendKeys(\"abc\");\n    await gu.checkTextEditor(\"abc\");\n    await gu.sendKeys(Key.ESCAPE);\n    // Click on another cell, check page hasn't changed (there was a bug about that)\n    await gu.getCell({ rowNum: 21, col: 1 }).click();\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 21, col: 1 });\n    assert.equal(await gu.getCurrentPageName(), \"City\");\n\n    // Close by hitting escape.\n    await gu.sendKeys(Key.ESCAPE);\n    await assertNoPopup();\n    // Make sure we see CITY, and everything is where it should be.\n    assert.equal(await gu.getActiveSectionTitle(), \"CITY\");\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2, col: 0 });\n    await gu.sendKeys(\"abc\");\n    await gu.checkTextEditor(\"abc\");\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Now open popup again, but close it by clicking on the close button.\n    await gu.showRawData();\n    await gu.closeRawTable();\n    await assertNoPopup();\n    assert.equal(await gu.getActiveSectionTitle(), \"CITY\");\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2, col: 0 });\n    await gu.sendKeys(\"abc\");\n    await gu.checkTextEditor(\"abc\");\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Now do the same, but close by clicking on a diffrent page\n    await gu.showRawData();\n    await gu.getPageItem(\"Country\").click();\n    await assertNoPopup();\n    assert.equal(await gu.getActiveSectionTitle(), \"COUNTRY\");\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 1, col: 0 });\n    await gu.sendKeys(\"abc\");\n    await gu.checkTextEditor(\"abc\");\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Now make sure that raw data is available for card view.\n    await gu.selectSectionByTitle(\"COUNTRY Card List\");\n    assert.equal(await gu.getActiveSectionTitle(), \"COUNTRY Card List\");\n    await gu.showRawData();\n    assert.equal(await gu.getActiveSectionTitle(), \"Country\");\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 1, col: 1 });\n    await gu.sendKeys(\"abc\");\n    await gu.checkTextEditor(\"abc\");\n    await gu.sendKeys(Key.ESCAPE);\n    // Make sure we see a grid\n    assert.isTrue(await driver.find(\".grid_view_data\").isDisplayed());\n    await gu.sendKeys(Key.ESCAPE);\n  });\n\n  // This is not documented feature at this moment, and tailored for raw data\n  // view, but it should work for any kind of section.\n  it(\"should open detail section as a popup\", async () => {\n    // We are at the Country page\n    await gu.getDetailCell(\"Code\", 1).click();\n    let anchorLink = replaceAnchor(await gu.getAnchor(), { a: \"2\" });\n    const testResult = async () => {\n      await waitForAnchorPopup(anchorLink);\n      assert.equal(await gu.getActiveSectionTitle(), \"COUNTRY Card List\");\n      assert.deepEqual(await gu.getCursorPosition(), { rowNum: 1, col: \"Code\" });\n      await gu.sendKeys(\"abc\");\n      await gu.checkTextEditor(\"abc\");\n      await gu.sendKeys(Key.ESCAPE);\n      // Close by hitting escape.\n      await gu.sendKeys(Key.ESCAPE);\n      // Make sure we are on correct page\n      assert.equal(await gu.getCurrentPageName(), \"City\");\n    };\n    // Switch page and use only hash, otherwise it will just maximize the section on the current page.\n    await gu.getPageItem(\"City\").click();\n    anchorLink = (await driver.getCurrentUrl()) + \"#\" + anchorLink.split(\"#\")[1];\n    await testResult();\n  });\n\n  it(\"should open chart section as a popup\", gu.revertChanges(async () => {\n    // We are at the Country page\n    await gu.getPageItem(\"Country\").click();\n    await gu.selectSectionByTitle(\"COUNTRY Card List\");\n    await gu.getDetailCell(\"Code\", 1).click();\n    await gu.addNewSection(/Chart/, /CountryLanguage/);\n    // s22 is the new section id, we also strip row/column.\n    let chartLink = replaceAnchor(await gu.getAnchor(), { s: \"22\", a: \"2\" });\n    await gu.getPageItem(\"City\").click();\n    chartLink = (await driver.getCurrentUrl()) + \"#\" + chartLink.split(\"#\")[1];\n    await waitForAnchorPopup(chartLink);\n    assert.isTrue(await driver.find(\".test-raw-data-overlay .test-chart-container\").isDisplayed());\n    await gu.sendKeys(Key.ESCAPE);\n  }));\n\n  it(\"should handle edge cases when table/section is removed\", async () => {\n    await gu.getPageItem(\"Country\").click();\n    await gu.selectSectionByTitle(\"COUNTRY Card List\");\n    await gu.getDetailCell(\"Code\", 1).click();\n    let anchorLink = replaceAnchor(await gu.getAnchor(), { a: \"2\" });\n    await gu.getPageItem(\"City\").click();\n    anchorLink = (await driver.getCurrentUrl()) + \"#\" + anchorLink.split(\"#\")[1];\n    await waitForAnchorPopup(anchorLink);\n\n    assert.equal(await gu.getActiveSectionTitle(), \"COUNTRY Card List\");\n    // Now remove the section using api, popup should be closed.\n    const sectionId = parseInt(getAnchorParams(anchorLink).s);\n    await api.applyUserActions(doc, [[\n      \"RemoveRecord\", \"_grist_Views_section\", sectionId,\n    ]]);\n    await gu.waitForServer();\n    await gu.checkForErrors();\n    await assertNoPopup();\n    // Now open plain raw data for City table.\n    await gu.selectSectionByTitle(\"CITY\");\n    assert.equal(await gu.getActiveSectionTitle(), \"CITY\"); // CITY is viewSection title\n    await gu.showRawData();\n    assert.equal(await gu.getActiveSectionTitle(), \"City\"); // City is now a table title\n    // Now remove the table.\n    await api.applyUserActions(doc, [[\n      \"RemoveTable\", \"City\",\n    ]]);\n    await gu.waitForServer();\n    await gu.checkForErrors();\n    await assertNoPopup();\n  });\n\n  it(\"can edit a table's Record Card\", async () => {\n    // Open the Record Card for the Country table.\n    await openRawData();\n    await editRecordCard(\"Country\");\n\n    // Check that the Record Card is shown.\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n\n    // Check that layout editing is toggled by default.\n    assert.isTrue(await driver.find(\".test-edit-layout-controls\").isDisplayed());\n\n    // Check that the title is correct. Note that it's initially obscured by the layout\n    // editing buttons; it becomes visible after the layout is saved.\n    assert.equal(await gu.getSectionTitle(), \"COUNTRY Card\");\n\n    // Modify the layout and theme.\n    await gu.openWidgetPanel(\"widget\");\n    assert.isTrue(\n      await driver.findContent(\".active_section .g_record_detail_inner .g_record_detail_label\",\n        gu.exactMatch(\"Continent\")).isPresent(),\n    );\n    await driver.findContent(\".test-edit-layout-controls button\", \"Cancel\").click();\n    await gu.moveToHidden(\"Continent\");\n    assert.isFalse(\n      await driver.findContent(\".active_section .g_record_detail_inner .g_record_detail_label\",\n        gu.exactMatch(\"Continent\")).isPresent(),\n    );\n    await driver.find(\".test-vconfigtab-detail-theme\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /Blocks/).click();\n    await gu.waitForServer();\n    await gu.checkForErrors();\n\n    // Close the overlay.\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Re-open the Record Card and check that the new layout and theme persisted.\n    await editRecordCard(\"Country\");\n    assert.isFalse(\n      await driver.findContent(\".active_section .g_record_detail_inner .g_record_detail_label\",\n        gu.exactMatch(\"Continent\")).isPresent(),\n    );\n    assert.equal(\n      await driver.find(\".test-vconfigtab-detail-theme\").getText(),\n      \"Blocks\",\n    );\n    await gu.sendKeys(Key.ESCAPE, Key.ESCAPE);\n\n    // Open the Record Card from outside the Raw Data page and check that the\n    // new layout and theme is used.\n    await gu.openPage(\"Country\");\n    await (await gu.openRowMenu(1)).findContent(\"li\", /View as card/).click();\n    assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n    assert.isFalse(\n      await driver.findContent(\".active_section .g_record_detail_inner .g_record_detail_label\",\n        gu.exactMatch(\"Continent\")).isPresent(),\n    );\n    assert.equal(\n      await driver.find(\".test-vconfigtab-detail-theme\").getText(),\n      \"Blocks\",\n    );\n    await gu.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"can disable a table's Record Card\", async () => {\n    // Disable the Record Card for the Country table.\n    await openRawData();\n    await disableRecordCard(\"Country\");\n\n    // Check that the button to edit the Record Card is disabled.\n    assert.isFalse(await isRecordCardEnabled(\"Country\"));\n    await editRecordCard(\"Country\");\n    assert.isFalse(await driver.find(\".test-raw-data-overlay\").isPresent());\n\n    // Check that the Edit Record Card menu item still works though.\n    await openMenu(\"Country\");\n    await driver.find(\".test-raw-data-menu-edit-record-card\").click();\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n    assert.equal(await gu.getSectionTitle(), \"COUNTRY Card\");\n\n    // Stop editing the layout and close the overlay.\n    await gu.sendKeys(Key.ESCAPE, Key.ESCAPE);\n\n    // Check that it's no longer possible to open a Record Card from outside\n    // the Raw Data page, even with the keyboard shortcut.\n    await gu.openPage(\"Country\");\n    await (await gu.openRowMenu(1)).findContent(\"li.disabled\", /View as card/);\n    await gu.sendKeys(Key.ESCAPE, Key.SPACE);\n    assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n\n    // Check that clicking the icon in Reference and Reference List columns also\n    // doesn't open a Record Card.\n    await gu.openPage(\"CountryLanguage\");\n    await gu.getCell(0, 1).find(\".test-ref-link-icon\").click();\n    assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n    await gu.wipeToasts();  // notification build-up can cover setType button.\n    await gu.setType(\"Reference List\", { apply: true });\n    await gu.getCell(0, 1).find(\".test-ref-list-link-icon\").click();\n    assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n  });\n\n  it(\"can enable a table's Record Card\", async () => {\n    // Enable the Record Card for the Country table.\n    await openRawData();\n    await enableRecordCard(\"Country\");\n\n    // Check that the button to edit the Record Card is enabled again.\n    assert.isTrue(await isRecordCardEnabled(\"Country\"));\n    await editRecordCard(\"Country\");\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n    assert.equal(await gu.getSectionTitle(), \"COUNTRY Card\");\n\n    // Check that it's possible again to open the Record Card from outside\n    // the Raw Data page.\n    await gu.openPage(\"Country\");\n    await (await gu.openRowMenu(1)).findContent(\"li\", /View as card/).click();\n    assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n    await gu.sendKeys(Key.ESCAPE);\n    assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n    await gu.sendKeys(Key.SPACE);\n    assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n\n    // Check that clicking the icon in Reference and Reference List columns opens a\n    // Record Card again.\n    await gu.openPage(\"CountryLanguage\");\n    await gu.getCell(0, 1).find(\".test-ref-list-link-icon\").click();\n    assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n    await gu.sendKeys(Key.ESCAPE);\n    assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n    await gu.setType(\"Reference\", { apply: true });\n    await gu.getCell(0, 1).find(\".test-ref-link-icon\").click();\n    assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n  });\n});\n\nconst anchorRegex = /#a(\\d+)\\.s(\\d+)\\.r(\\d+)\\.c(\\d+)/gm;\n\nfunction getAnchorParams(link: string) {\n  const match = anchorRegex.exec(link);\n  if (!match) { throw new Error(\"Invalid link\"); }\n  const [, a, s, r, c] = match;\n  return { a, s, r, c };\n}\n\nfunction replaceAnchor(link: string, values: {\n  a?: string,\n  s?: string,\n  r?: string,\n  c?: string,\n}) {\n  const { a, s, r, c } = getAnchorParams(link);\n  return link.replace(anchorRegex, `#a${values.a || a}.s${values.s || s}.r${values.r || r}.c${values.c || c}`);\n}\n\nasync function openRawData() {\n  await driver.find(\".test-tools-raw\").click();\n  await waitForRawData();\n}\n\nasync function clickConfirm() {\n  await driver.find(\".test-modal-confirm\").click();\n}\n\nasync function clickDuplicateTable() {\n  await driver.find(\".test-raw-data-menu-duplicate-table\").click();\n}\n\nasync function clickRemove() {\n  await driver.find(\".test-raw-data-menu-remove-table\").click();\n}\n\nasync function removeRawTable(tableId: string) {\n  await openMenu(tableId);\n  await clickRemove();\n  await clickConfirm();\n  await gu.waitForServer();\n}\n\nasync function convertToSummary(...groupByColumns: string[]) {\n  // Convert table to a summary table\n  await gu.toggleSidePanel(\"right\", \"open\");\n  // Creator Panel > Table\n  await driver.find(\".test-right-tab-pagewidget\").click();\n  // Tab [Data]\n  await driver.find(\".test-config-data\").click();\n  // Edit Data Selection\n  await driver.find(\".test-pwc-editDataSelection\").click();\n  // Σ\n  await driver.find(\".test-wselect-pivot\").click();\n  // Select Group-By Columns\n  for (const c of groupByColumns) {\n    await driver.findContent(\".test-wselect-column\", c).click();\n  }\n  // Save\n  await driver.find(\".test-wselect-addBtn\").click();\n  await gu.waitForServer();\n}\n\nasync function getRawTableTitles() {\n  return await driver.findAll(\".test-raw-data-table-title\", e => e.getText());\n}\n\nasync function getRawTableIds() {\n  return await driver.findAll(\".test-raw-data-table-id\", e => e.getText());\n}\n\nasync function getRawTableRows() {\n  return await driver.findAll(\".test-raw-data-table-rows\", e => e.getText());\n}\n\nasync function getRawTableRecordCardButtonsIsDisplayed() {\n  return await driver.findAll(\".test-raw-data-table-record-card\", e => e.isDisplayed());\n}\n\nasync function getRawTableRecordCardButtonsIsEnabled() {\n  return await driver.findAll(\".test-raw-data-table-record-card\", async (e) => {\n    const isDisplayed = await e.isDisplayed();\n    const className = await e.getAttribute(\"class\");\n    return isDisplayed && !className.includes(\"-disabled\");\n  });\n}\n\nasync function openMenu(tableId: string) {\n  const allTables = await getRawTableIds();\n  const tableIndex = allTables.indexOf(tableId);\n  assert.isTrue(tableIndex >= 0, `No raw table with id ${tableId}`);\n  const menus = await driver.findAll(\".test-raw-data-table .test-raw-data-table-menu\");\n  assert.equal(menus.length, allTables.length);\n  await menus[tableIndex].click();\n}\n\nasync function waitForRawData() {\n  await driver.findWait(\".test-raw-data-list\", 2000);\n  await gu.waitForServer();\n}\n\nasync function isRemovable(tableId: string) {\n  await openMenu(tableId);\n  const disabledItems = await driver.findAll(\".test-raw-data-menu-remove-table.disabled\");\n  await gu.sendKeys(Key.ESCAPE);\n  return disabledItems.length === 0;\n}\n\nasync function editRecordCard(tableId: string, wait = true) {\n  await driver.findContent(\".test-raw-data-table-title\", tableId)\n    .findClosest(\".test-raw-data-table\")\n    .find(\".test-raw-data-table-record-card\")\n    .click();\n  if (wait) {\n    await gu.waitForServer();\n  }\n}\n\nasync function disableRecordCard(tableId: string) {\n  await openMenu(tableId);\n  await driver.find(\".test-raw-data-menu-disable-record-card\").click();\n  await gu.waitForServer();\n}\n\nasync function enableRecordCard(tableId: string) {\n  await openMenu(tableId);\n  await driver.find(\".test-raw-data-menu-enable-record-card\").click();\n  await gu.waitForServer();\n}\n\nasync function isRecordCardEnabled(tableId: string) {\n  const recordCard = await driver.findContent(\".test-raw-data-table-title\", tableId)\n    .findClosest(\".test-raw-data-table\")\n    .find(\".test-raw-data-table-record-card\");\n  const isDisplayed = await recordCard.isDisplayed();\n  const className = await recordCard.getAttribute(\"class\");\n  return isDisplayed && !className.includes(\"-disabled\");\n}\n\nasync function waitForPopup() {\n  assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n}\n\nasync function assertNoPopup() {\n  assert.isFalse(await driver.find(\".test-raw-data-overlay\").isPresent());\n}\n\nasync function waitForAnchorPopup(link: string) {\n  await driver.get(link);\n  await gu.waitForAnchor();\n  await waitForPopup();\n}\n"
  },
  {
    "path": "test/nbrowser/RecordCards.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"RecordCards\", function() {\n  this.timeout(30000);\n  let api: UserAPI;\n  let docId: string;\n  let session: gu.Session;\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    session = await gu.session().teamSite.login();\n    docId = (await session.tempDoc(cleanup, \"World-v39.grist\")).id;\n    api = session.createHomeApi();\n    await gu.openPage(\"Country\");\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  describe(\"RowContextMenu\", function() {\n    it(\"opens popup when keyboard shortcut is pressed\", async function() {\n      await gu.sendKeys(Key.SPACE);\n      assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n      assert.equal(\n        await driver.find(\".test-record-card-popup-wrapper .test-widget-title-text\").getText(),\n        \"COUNTRY Card\",\n      );\n      assert.equal(await gu.getCardCell(\"Code\").getText(), \"ALB\");\n      assert.isFalse(await driver.find(\".grist-single-record__menu\").isPresent());\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"opens popup when menu item is clicked\", async function() {\n      await (await gu.openRowMenu(2)).findContent(\"li\", /View as card/).click();\n      assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n      assert.equal(\n        await driver.find(\".test-record-card-popup-wrapper .test-widget-title-text\").getText(),\n        \"COUNTRY Card\",\n      );\n      assert.equal(await gu.getCardCell(\"Code\").getText(), \"AND\");\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"closes popup when record is deleted\", async function() {\n      await api.applyUserActions(docId, [\n        [\"RemoveRecord\", \"Country\", 1],\n      ]);\n      await gu.waitToPass(async () => {\n        assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n      }, 2000);\n\n      await (await gu.openRowMenu(1)).findContent(\"li\", /View as card/).click();\n      assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n      await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n      await gu.confirm(true);\n      await gu.waitForServer();\n      assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n    });\n\n    it(\"hides option to open popup if more than 1 row is selected\", async function() {\n      await gu.sendKeys(Key.chord(Key.SHIFT, Key.DOWN));\n      assert.isFalse(await (await gu.openRowMenu(1)).findContent(\"li\", /View as card/).isPresent());\n      await gu.sendKeys(Key.ESCAPE, Key.SPACE);\n      assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n    });\n\n    it('disables option to open popup in \"add new\" row', async function() {\n      await gu.sendKeys(Key.chord(await gu.modKey(), Key.DOWN));\n      assert.isTrue(await (await gu.openRowMenu(120)).findContent(\"li.disabled\", /View as card/).isPresent());\n      await gu.sendKeys(Key.ESCAPE, Key.SPACE);\n      assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n    });\n  });\n\n  describe(\"Reference\", function() {\n    before(async function() {\n      await gu.openPage(\"CountryLanguage\");\n    });\n\n    it(\"opens popup when reference icon is clicked\", async function() {\n      await gu.getCell(0, 4).find(\".test-ref-link-icon\").click();\n      assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n      assert.equal(\n        await driver.find(\".test-record-card-popup-wrapper .test-widget-title-text\").getText(),\n        \"COUNTRY Card\",\n      );\n      assert.equal(await gu.getCardCell(\"Code\").getText(), \"AFG\");\n      assert.isFalse(await driver.find(\".grist-single-record__menu\").isPresent());\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"updates popup when reference icon is clicked within Record Card popup\", async function() {\n      await gu.getCell(0, 4).find(\".test-ref-text\").click();\n      await gu.sendKeys(Key.SPACE);\n      assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n      assert.equal(\n        await driver.find(\".test-record-card-popup-wrapper .test-widget-title-text\").getText(),\n        \"COUNTRYLANGUAGE Card\",\n      );\n      assert.equal(await gu.getCardCell(\"Country\").getText(), \"AFG\");\n      await gu.getCardCell(\"Country\").find(\".test-ref-link-icon\").click();\n      assert.equal(\n        await driver.find(\".test-record-card-popup-wrapper .test-widget-title-text\").getText(),\n        \"COUNTRY Card\",\n      );\n      assert.equal(await gu.getCardCell(\"Code\").getText(), \"AFG\");\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"does not open popup if cell is empty\", async function() {\n      await gu.getCell(0, 4).find(\".test-ref-text\").click();\n      await driver.sendKeys(Key.DELETE);\n      await gu.waitForServer();\n      await gu.getCell(0, 4).find(\".test-ref-link-icon\").click();\n      assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n      await gu.undo();\n    });\n\n    it('does not open popup in \"add new\" row', async function() {\n      await gu.sendKeys(Key.chord(await gu.modKey(), Key.DOWN));\n      await gu.getCell(0, 747).find(\".test-ref-link-icon\").click();\n      assert.isFalse(await driver.find(\".test-record-card-popup-overlay\").isPresent());\n    });\n\n    it(\"shows an unavailable message if the row is blocked by ACL rules\", async function() {\n      await api.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Country\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1,\n          aclFormula: 'rec.Code == \"ZWE\"',\n          permissionsText: \"-R\",\n          memo: \"Block ZWE records\",\n        }],\n      ]);\n\n      await gu.reloadDoc();\n\n      await gu.getCell(0, 745).find(\".test-ref-link-icon\").click();\n\n      assert.include(\n        await driver.find(\".test-record-card-popup-wrapper\").getText(),\n        \"This row is unavailable or does not exist\",\n      );\n\n      await gu.sendKeys(Key.ESCAPE);\n    });\n  });\n\n  describe(\"ReferenceList\", function() {\n    before(async function() {\n      await gu.sendKeys(Key.chord(await gu.modKey(), Key.UP));\n      await gu.setType(\"Reference List\", { apply: true });\n    });\n\n    it(\"opens popup when reference icon is clicked\", async function() {\n      await gu.getCell(0, 4).find(\".test-ref-list-link-icon\").click();\n      assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n      assert.equal(\n        await driver.find(\".test-record-card-popup-wrapper .test-widget-title-text\").getText(),\n        \"COUNTRY Card\",\n      );\n      assert.equal(await gu.getCardCell(\"Code\").getText(), \"AFG\");\n      assert.isFalse(await driver.find(\".grist-single-record__menu\").isPresent());\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"updates popup when reference icon is clicked within Record Card popup\", async function() {\n      await gu.getCell(0, 4).click();\n      await gu.sendKeys(Key.SPACE);\n      assert.isTrue(await driver.findWait(\".test-record-card-popup-overlay\", 100).isDisplayed());\n      assert.equal(\n        await driver.find(\".test-record-card-popup-wrapper .test-widget-title-text\").getText(),\n        \"COUNTRYLANGUAGE Card\",\n      );\n      assert.equal(await gu.getCardCell(\"Country\").getText(), \"AFG\");\n      await gu.getCardCell(\"Country\").find(\".test-ref-list-link-icon\").click();\n      assert.equal(\n        await driver.find(\".test-record-card-popup-wrapper .test-widget-title-text\").getText(),\n        \"COUNTRY Card\",\n      );\n      assert.equal(await gu.getCardCell(\"Code\").getText(), \"AFG\");\n      await gu.sendKeys(Key.ESCAPE);\n    });\n  });\n\n  describe(\"RawData\", function() {\n    before(async function() {\n      await driver.find(\".test-tools-raw\").click();\n      await driver.findWait(\".test-raw-data-list\", 2000);\n      await gu.waitForServer();\n    });\n\n    it(\"opens popup when reference icon is clicked\", async function() {\n      await driver.findContent(\".test-raw-data-table-title\", \"City\").click();\n      await gu.waitForServer();\n      await gu.getCell(1, 5).find(\".test-ref-link-icon\").click();\n      assert.equal(\n        await driver.find(\".test-raw-data-overlay .test-widget-title-text\").getText(),\n        \"COUNTRY Card\",\n      );\n      assert.equal(await gu.getCardCell(\"Code\").getText(), \"NLD\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/RecordLayout.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, until, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"RecordLayout\", function() {\n  this.timeout(30000);\n  const cleanup = setupTestSuite();\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should disallow hiding columns from the creator panel\", async () => {\n    const session = await gu.session().login();\n    await session.tempNewDoc(cleanup);\n    // Open creator panel.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    // Change the widget to Card view.\n    await driver.findContent(\"button\", \"Change widget\").click();\n    await gu.selectWidget(\"Card\");\n    // Edit card layout.\n    await driver.find(\".test-vconfigtab-detail-edit-layout\").click();\n    assert.equal(await hasField(\"A\"), true);\n    assert.equal(await hasField(\"B\"), true);\n    assert.equal(await hasField(\"C\"), true);\n    // Checking hiding columns from the creator panel is disabled.\n    const row = await driver.findContent(\".test-vfc-visible-fields .kf_draggable_content\", \"A\");\n    await row.mouseMove();\n    assert.isNotNull(await row.find(\".test-vfc-hide\").getAttribute(\"disabled\"));\n  });\n\n  it(\"should allow deleting cells\", async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"World.grist\", \"newui\");\n\n    // Select the right section and check that we have Cards with expected fields.\n    await gu.getDetailCell({ col: \"District\", rowNum: 1, section: \"CITY Card List\" }).click();\n    assert.equal(await hasField(\"District\"), true);\n    assert.equal(await hasField(\"Population\"), true);\n    assert.equal(await hasField(\"Country\"), true);\n\n    // Open ViewConfigTab, and click to edit layout.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await driver.find(\".test-config-widget\").click();\n\n    await driver.sleep(100);\n    const thirdRow = () => driver.find(\".active_section .g_record_detail:nth-child(2)\");\n    const thirdRowLocOriginal = await thirdRow().rect();\n\n    // Start editing layout.\n    let editLayoutMenu = await editLayoutAndGetMenu();\n\n    // Find and delete 'District' field.\n    await deleteBox(gu.getDetailCell({ col: \"District\", rowNum: 1 }));\n\n    // Check that 'District' field is now present in Add Field dropdown.\n    await driver.sleep(100); // Sleep needed to avoid test flakiness.\n    const addFieldBtn = await editLayoutMenu.findContent(\"button\", \"Add field\");\n    await addFieldBtn.click();\n    await driver.findWait(\".test-edit-layout-add-menu\", 500);\n    assert.equal(await driver.findContent(\".test-edit-layout-add-menu li\", /District/).isDisplayed(), true);\n    assert.equal(await driver.findContent(\".test-edit-layout-add-menu li\", /Population/).isPresent(), false);\n    await addFieldBtn.click();\n\n    // Find and delete 'Population' field.\n    await deleteBox(gu.getDetailCell({ col: \"Population\", rowNum: 1 }));\n    await driver.sleep(100); // Sleep needed to avoid test flakiness.\n\n    // Check that 'Population' field is now present in Add Field dropdown.\n    await addFieldBtn.click();\n    await driver.findWait(\".test-edit-layout-add-menu\", 500);\n    assert.equal(await driver.findContent(\".test-edit-layout-add-menu li\", /District/).isDisplayed(), true);\n    assert.equal(await driver.findContent(\".test-edit-layout-add-menu li\", /Population/).isDisplayed(), true);\n    await addFieldBtn.click();\n\n    // Cancel and make sure deleted fields are still there, and row positions correct.\n    await editLayoutMenu.findContent(\"button\", /Cancel/).click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleDetailCells(\"District\", [1, 2, 3]),\n      [\"Maharashtra\", \"Seoul\", \"São Paulo\"]);\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Population\", [1, 2, 3]),\n      [\"10500000\", \"9981619\", \"9968485\"]);\n    assert.equal((await thirdRow().rect()).top, thirdRowLocOriginal.top);\n\n    // Do the whole thing again, and save.\n    editLayoutMenu = await editLayoutAndGetMenu();\n\n    await deleteBox(gu.getDetailCell({ col: \"District\", rowNum: 1 }));\n    await driver.sleep(100); // Sleep needed to avoid test flakiness.\n\n    await deleteBox(gu.getDetailCell({ col: \"Population\", rowNum: 1 }));\n    await driver.sleep(100); // Sleep needed to avoid test flakiness.\n\n    await editLayoutMenu.findContent(\"button\", /Save/).click();\n    await gu.waitForServer(5000);\n\n    // Wait for re-rendered records to appear.\n    await driver.wait(async () => (await driver.findAll(\".active_section .g_record_detail\")).length > 3);\n\n    // Make sure the deleted fields are gone, and row positions got adjusted.\n    assert.equal(await hasField(\"District\"), false);\n    assert.equal(await hasField(\"Population\"), false);\n    assert.equal(await hasField(\"Country\"), true);\n    assert.isBelow((await thirdRow().rect()).top, thirdRowLocOriginal.top);\n  });\n\n  it(\"should allow inserting fields\", async function() {\n    // We assume we are where the previous test case left off: CITY Card List, 2 fields removed.\n    const thirdRow = () => driver.find(\".active_section .g_record_detail:nth-child(2)\");\n    const thirdRowLocOriginal = await thirdRow().rect();\n\n    // Start editing layout.\n    const editLayoutMenu = await editLayoutAndGetMenu();\n\n    // Remove \"Pop. '000\" field.\n    await deleteBox(await gu.getDetailCell(\"Pop. '000\", 1));\n\n    // Re-add \"Pop. '000\" field to the bottom.\n    await editLayoutMenu.findContent(\"button\", \"Add field\").click();\n    await driver.findContentWait(\".test-edit-layout-add-menu li\", /Pop. '000/, 500).click();\n    await driver.wait(() => gu.getDetailCell(\"Pop. '000\", 1).isPresent(), 2000);\n\n    // Add 'District' field, and drag to be a new column on the left. This changes the layout\n    // root, which is an important case to test (there used to be a bug in this case).\n    await editLayoutMenu.findContent(\"button\", \"Add field\").click();\n    await driver.findContentWait(\".test-edit-layout-add-menu li\", /District/, 500).click();\n    await driver.wait(() => gu.getDetailCell(\"District\", 1).isPresent(), 2000);\n    assert.deepEqual(await getFields(), [\"Name\", \"Country\", \"Pop. '000\", \"District\"]);\n    await dragInsertLayoutBox(gu.getDetailCell(\"District\", 1), gu.getDetailCell(\"Name\", 1), \"left\", 2);\n    await driver.wait(() => gu.getDetailCell(\"District\", 1).isPresent(), 2000);\n    assert.deepEqual(await getFields(), [\"District\", \"Name\", \"Country\", \"Pop. '000\"]);\n\n    // Add a new field below the District field.\n    await editLayoutMenu.findContent(\"button\", \"Add field\").click();\n    await driver.findContentWait(\".test-edit-layout-add-menu li\", /Create new field/, 500).click();\n    await driver.wait(() => gu.getDetailCell(\"New_Field\", 1).isPresent(), 2000);\n    assert.deepEqual(await getFields(), [\"District\", \"Name\", \"Country\", \"Pop. '000\", \"New_Field\"]);\n    await dragInsertLayoutBox(gu.getDetailCell(\"New_Field\", 1), gu.getDetailCell(\"District\", 1), \"bottom\", -18);\n    await driver.wait(() => gu.getDetailCell(\"New_Field\", 1).isPresent(), 2000);\n    assert.deepEqual(await getFields(), [\"District\", \"New_Field\", \"Name\", \"Country\", \"Pop. '000\"]);\n\n    // Delete the newly-added District field.\n    await deleteBox(gu.getDetailCell(\"District\", 1));\n\n    // Save the edited layout.\n    await editLayoutMenu.findContent(\"button\", /Save/).click();\n    await gu.waitForServer(5000);\n\n    await driver.sleep(2000);\n\n    // Wait for re-rendered records to appear.\n    await driver.wait(async () => (await driver.findAll(\".active_section .g_record_detail\")).length > 3);\n\n    // Check that \"Pop. '000\" is included, \"District\" is still not included, and a new column \"A\"\n    // is present.\n    assert.equal(await hasField(\"District\"), false);\n    assert.equal(await hasField(\"Population\"), false);\n    assert.equal(await hasField(\"Pop. '000\"), true);\n    assert.equal(await hasField(\"A\"), true);\n\n    // Check that \"Pop. '000\" got re-added the way it was (with 0 decimal places, i.e. no changes\n    // to widget options).\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Pop. '000\", [1, 2, 3]), [\"10500\", \"9982\", \"9968\"]);\n\n    // Check that the new column is included. The values are blank, but if the column were\n    // missing, we'd get NoSuchElementErrors.\n    assert.deepEqual(await gu.getVisibleDetailCells(\"A\", [1, 2, 3]), [\"\", \"\", \"\"]);\n\n    // Check that rows have gotten resized.\n    assert.isAbove((await thirdRow().rect()).top, thirdRowLocOriginal.top);\n  });\n\n  it(\"should allow changes to be undone in one step\", async function() {\n    // Undo and check that everything is as before editing.\n    const thirdRow = () => driver.find(\".active_section .g_record_detail:nth-child(2)\");\n    const thirdRowLocOriginal = await thirdRow().rect();\n    await gu.undo();\n    await driver.wait(async () => !await hasField(\"A\"));\n\n    assert.equal(await hasField(\"District\"), false);\n    assert.equal(await hasField(\"Population\"), false);\n    assert.equal(await hasField(\"Pop. '000\"), true);\n    assert.equal(await hasField(\"A\"), false);\n\n    assert.isBelow((await thirdRow().rect()).top, thirdRowLocOriginal.top);\n\n    // Redo and check that everything is as after editing.\n    await gu.redo(1, 4000);\n    await driver.wait(() => hasField(\"A\"));\n\n    assert.equal(await hasField(\"District\"), false);\n    assert.equal(await hasField(\"Population\"), false);\n    assert.equal(await hasField(\"Pop. '000\"), true);\n    assert.equal(await hasField(\"A\"), true);\n    assert.deepEqual(await gu.getVisibleDetailCells(\"Pop. '000\", [1, 2, 3]), [\"10500\", \"9982\", \"9968\"]);\n    assert.deepEqual(await gu.getVisibleDetailCells(\"A\", [1, 2, 3]), [\"\", \"\", \"\"]);\n    assert.equal((await thirdRow().rect()).top, thirdRowLocOriginal.top);\n  });\n\n  it(\"should allow inserting multiple fields\", async function() {\n    // Start editing layout.\n    const editLayoutMenu = await editLayoutAndGetMenu();\n\n    // Add a new field below the District field.\n    await editLayoutMenu.findContent(\"button\", \"Add field\").click();\n    await driver.findContentWait(\".test-edit-layout-add-menu li\", /Create new field/, 500).click();\n    await editLayoutMenu.findContent(\"button\", \"Add field\").click();\n    await driver.findContentWait(\".test-edit-layout-add-menu li\", /Create new field/, 500).click();\n    await driver.wait(() => gu.getDetailCell(\"New_Field\", 1).isPresent(), 2000);\n    assert.deepEqual(await getFields(), [\"A\", \"Name\", \"Country\", \"Pop. '000\", \"New_Field\", \"New_Field\"]);\n\n    // Save the edited layout.\n    await editLayoutMenu.findContent(\"button\", /Save/).click();\n    await gu.waitForServer(5000);\n\n    // Wait for re-rendered records to appear.\n    await driver.wait(async () => (await driver.findAll(\".active_section .g_record_detail\")).length > 3);\n\n    // Check that 2 new columns (B and C) are present (A already existed from an earlier test case)\n    assert.equal(await hasField(\"A\"), true);\n    assert.equal(await hasField(\"B\"), true);\n    assert.equal(await hasField(\"C\"), true);\n\n    // Undo the additions, and check that the undo worked.\n    await gu.undo();\n    await driver.wait(async () => !await hasField(\"B\"));\n    assert.equal(await hasField(\"A\"), true);\n    assert.equal(await hasField(\"B\"), false);\n    assert.equal(await hasField(\"C\"), false);\n  });\n\n  it(\"should allow rearranging fields\", async function() {\n    const editLayoutMenu = await editLayoutAndGetMenu();\n\n    // Drag new a field to the left of the \"Pop. '000\" field, into the same row.\n    await dragInsertLayoutBox(gu.getDetailCell(\"A\", 1), await gu.getDetailCell(\"Pop. '000\", 1), \"left\", 18);\n    await editLayoutMenu.findContent(\"button\", /Save/).click();\n    await gu.waitForServer(5000);\n\n    // Wait for re-rendered records to appear.\n    await driver.wait(async () => (await driver.findAll(\".active_section .g_record_detail\")).length > 3);\n\n    function hasCursor(cell: WebElement) { return cell.find(\".selected_cursor\").isDisplayed(); }\n\n    // Ensure that the tab order in a different cell reflects the new order of fields.\n    await gu.getDetailCell(\"Name\", 2).click();\n    assert.equal(await hasCursor(gu.getDetailCell(\"Name\", 2)), true);\n    await driver.sendKeys(Key.RIGHT);\n    assert.equal(await hasCursor(gu.getDetailCell(\"Country\", 2)), true);\n    await driver.sendKeys(Key.RIGHT);\n    assert.equal(await hasCursor(gu.getDetailCell(\"A\", 2)), true);\n    await driver.sendKeys(Key.RIGHT);\n    assert.equal(await hasCursor(gu.getDetailCell(\"Pop. '000\", 2)), true);\n  });\n\n  it(\"editing layout should not mess with fields cursor\", async function() {\n    // Select Mumbai in the Card List view\n    await gu.getDetailCell(\"Name\", 1, \"CITY Card List\").click();\n\n    // start editing layout and cancel\n    const editLayoutMenu = await editLayoutAndGetMenu();\n    await editLayoutMenu.findContent(\"button\", \"Cancel\").click();\n\n    // check Mumbai is still selected\n    assert.equal(await gu.getActiveCell().getText(), \"Mumbai (Bombay)\");\n\n    // open editor for Mumbai name\n    await driver.sendKeys(Key.ENTER);\n    try {\n      // check that the editor position is correct\n      const editorRect = await driver.find(\".cell_editor\").getRect();\n      const cellRect = await gu.getActiveCell().getRect();\n      assert.equal(editorRect.x, cellRect.x);\n      assert.equal(editorRect.y, cellRect.y);\n      assert.equal(editorRect.height, cellRect.height);\n      assert.isAtLeast(editorRect.width, cellRect.width);\n    } finally {\n      // on error make sure editor is closed\n      await driver.sendKeys(Key.ESCAPE);\n    }\n  });\n\n  // Helper to delete a layout box using the on-hover \"x\" circle.\n  async function deleteBox(cell: WebElement) {\n    await cell.mouseMove();\n    const containingBox = await cell.findClosest(\".layout_box\");\n    const deleteIcon = cell.findClosest(\".g_record_layout_leaf\").find(\".g_record_delete_field\");\n    await driver.wait(() => deleteIcon.isDisplayed(), 1000);\n    await deleteIcon.click();\n    await driver.wait(until.stalenessOf(containingBox), 2000);\n  }\n\n  // Helper that drags `elem` to the layout box containing `destElem`, specifically to its `edge`\n  // (\"top\", \"right\", \"bottom\" or \"left\") plus the `edgeOffset` (in pixels).\n  async function dragInsertLayoutBox(\n    elem: WebElement, destElem: WebElement, edge: \"top\" | \"right\" | \"bottom\" | \"left\", edgeOffset: number,\n  ) {\n    const box = await destElem.findClosest(\".layout_box\");\n    await driver.wait(async () => await elem.isDisplayed() && await box.isDisplayed(), 1000);\n    const size = await box.rect();\n    const boxX = (edge === \"left\" ? edgeOffset : (edge === \"right\" ? size.width + edgeOffset : size.width / 2));\n    const boxY = (edge === \"top\" ? edgeOffset : (edge === \"bottom\" ? size.height + edgeOffset : size.height / 2));\n    // To move the mouse to boxX, boxY in box coordinates, we actually move it relative to body,\n    // using coordinates relative to body center. This is a workaround for a bug with webdriver\n    // (at least in some environments): it fiddles with coordinates, which then get ignored if\n    // they end up fractional. This problem happens less if we use rounding relative to body.\n    const body = await driver.find(\"body\");\n    const bodyRect = await body.rect();\n    const bodyCenterX = bodyRect.left + bodyRect.width / 2;\n    const bodyCenterY = bodyRect.top + bodyRect.height / 2;\n    const offset = {\n      x: Math.round(size.left + boxX - bodyCenterX),\n      y: Math.round(size.top + boxY - bodyCenterY),\n    };\n    await elem.mouseMove();\n    await driver.mouseDown();\n    await body.mouseMove(offset);\n    await driver.mouseUp();\n    await driver.sleep(1000);\n    await driver.wait(async () =>\n      (await destElem.findClosest(\".layout_root\").getAttribute(\"data-useredit\")) === \"stop\", 5000);\n  }\n\n  async function editLayoutAndGetMenu() {\n    // Assuming an expanded side-pane, start editing the layout, wait for the layout editor, and\n    // return the menu containing layout editor controls.\n    await driver.find(\".test-vconfigtab-detail-edit-layout\").click();\n    return driver.findWait(\".active_section .test-edit-layout-controls\", 1000);\n  }\n\n  // Whether any Card in the active section has a field by the given name.\n  function hasField(field: string) {\n    return driver.findContent(\".active_section .g_record_detail_inner .g_record_detail_label\",\n      gu.exactMatch(field)).isPresent();\n  }\n\n  // List of field labels for the first record in active section (while editing, it's the record\n  // being edited).\n  function getFields() {\n    return driver.find(\".active_section .g_record_detail\").findAll(\".g_record_detail_label\", el => el.getText());\n  }\n});\n"
  },
  {
    "path": "test/nbrowser/RefNumericChange.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"RefNumericChange\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should allow converting a ref column to numeric and undoing it\", async function() {\n    // We had a bug with Ref -> Numeric conversion when starting with a Ref column that showed a\n    // numeric display col.\n    const session = await gu.session().teamSite.user(\"user1\").login();\n    const docId = (await session.tempNewDoc(cleanup, \"RefNumericChange1\", { load: false }));\n    const api = session.createHomeApi();\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"TestTable\", [{ id: \"Num\", type: \"Numeric\" }, { id: \"Ref\", type: \"Ref:TestTable\" }]],\n      [\"BulkAddRecord\", \"TestTable\", [null, null, null, null], { Num: [\"5\", \"10\", \"15\"], Ref: [3, 2, 0, \"17.0\"] }],\n    ]);\n\n    await session.loadDoc(`/doc/${docId}/p/2`);\n\n    // Change TestTable.Ref column (of type Ref:TestTable) to use TestTable.Num as \"SHOW COLUMN\".\n    await gu.getCell({ section: \"TestTable\", rowNum: 1, col: \"Ref\" }).click();\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    await driver.find(\".test-fbuilder-ref-col-select\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /Num/).click();\n    await gu.waitForServer();\n\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Reference\");\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"TestTable\", rowNums: [1, 2, 3, 4], col: \"Ref\" }),\n      [\"15\", \"10\", \"\", \"17.0\"]);\n\n    // Change type of column Ref to Numeric.\n    await gu.getCell({ section: \"TestTable\", rowNum: 1, col: \"Ref\" }).click();\n    await gu.setType(\"Numeric\");\n    await driver.findContent(\".type_transform_prompt button\", /Apply/).click();\n    await gu.waitForServer();\n\n    await gu.checkForErrors();\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Numeric\");\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"TestTable\", rowNums: [1, 2, 3, 4], col: \"Ref\" }),\n      [\"15\", \"10\", \"0\", \"17\"]);\n\n    // Revert.\n    await gu.undo();\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Reference\");\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"TestTable\", rowNums: [1, 2, 3, 4], col: \"Ref\" }),\n      [\"15\", \"10\", \"\", \"17.0\"]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/RefTransforms.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"RefTransforms\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should work when transformed column serves as a display column for another reference\", async function() {\n    // Make a special doc for testing this.\n    const session = await gu.session().teamSite.user(\"user1\").login();\n    const docId = (await session.tempNewDoc(cleanup, \"RefTransforms1\", { load: false }));\n    const api = session.createHomeApi();\n    await api.applyUserActions(docId, [\n      // Table1 contains foo,bar, to be transformed (using UI) into a Reference or ReferenceList\n      // pointing to Table2.\n      [\"AddTable\", \"Table1\", [{ id: \"A\", type: \"Text\" }]],\n      [\"BulkAddRecord\", \"Table1\", [null, null], {\n        A: [\"foo\", \"bar\"],\n      }],\n      // Table2 contains bar,foo (for Table1 to point to when it gets converted), and also a\n      // Reference back to Table1. This will be set to SHOW Table1.A. When Table1.A itself\n      // becomes a ReferenceList, we've had a bug manifesting as \"unmarshallable object\".\n      [\"AddTable\", \"Table2\", [{ id: \"A\", type: \"Text\" }, { id: \"B\", type: \"Ref:Table1\" }]],\n      [\"BulkAddRecord\", \"Table2\", [null, null], {\n        A: [\"bar\", \"foo\"],\n        B: [1, 2],\n      }],\n    ]);\n\n    await session.loadDoc(`/doc/${docId}`);\n    await gu.addNewSection(/Table/, /Table2/);\n\n    // Change Table2.B column (of type Ref:Table1) to use Table1.A as \"SHOW COLUMN\".\n    await gu.getCell({ section: \"Table2\", rowNum: 1, col: \"B\" }).click();\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    await driver.find(\".test-fbuilder-ref-col-select\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /A/).click();\n    await gu.waitForServer();\n\n    // Change type of Table1 to be Ref:Table2.\n    await gu.getCell({ section: \"Table1\", rowNum: 1, col: \"A\" }).click();\n    await gu.setType(/Reference$/);\n    await driver.findContent(\".type_transform_prompt button\", /Apply/).click();\n    await gu.waitForServer();\n\n    await gu.checkForErrors();\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Reference\");\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Table1\", rowNums: [1, 2], col: \"A\" }),\n      [\"foo\", \"bar\"]);\n\n    // Revert.\n    await gu.undo();\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Text\");\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Table1\", rowNums: [1, 2], col: \"A\" }),\n      [\"foo\", \"bar\"]);\n\n    // Now change type of Table1 to be RefList:Table2.\n    await gu.getCell({ section: \"Table1\", rowNum: 1, col: \"A\" }).click();\n    await gu.setType(/Reference List/);\n    await driver.find(\".test-fbuilder-ref-table-select\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /Table2/).click();\n    await gu.waitForServer();\n    await driver.find(\".test-fbuilder-ref-col-select\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /A/).click();\n    await gu.waitForServer();\n    await driver.findContent(\".type_transform_prompt button\", /Apply/).click();\n    await gu.waitForServer();\n\n    await gu.checkForErrors();\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Reference List\");\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Table1\", rowNums: [1, 2], col: \"A\" }),\n      [\"foo\", \"bar\"]);\n\n    // Revert.\n    await gu.undo();\n    assert.equal(await driver.find(\".test-fbuilder-type-select\").getText(), \"Text\");\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Table1\", rowNums: [1, 2], col: \"A\" }),\n      [\"foo\", \"bar\"]);\n  });\n\n  it(\"should allow changing the table of a ref list\", async function() {\n    // An old bug made it impossible to change the value of \"DATA FROM TABLE\" for a reference list.\n    await gu.getCell({ section: \"Table1\", rowNum: 1, col: \"B\" }).click();\n    await gu.setType(/Reference List/);\n    await driver.find(\".test-fbuilder-ref-col-select\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /A/).click();\n    await gu.waitForServer();\n\n    // Add some references to values in the same table.\n    await gu.sendKeys(Key.ESCAPE); // First ESCAPE to get out of select focus.\n    await gu.sendKeys(Key.ENTER, \"foo\", Key.ENTER, \"bar\", Key.ENTER, Key.ENTER);\n    await gu.waitForServer();\n\n    // Now change the table to Table2. (This previously failed and left the table unchanged.)\n    await driver.find(\".test-fbuilder-ref-table-select\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /Table2/).click();\n    await gu.waitForServer();\n    assert.equal(await driver.find(\".test-fbuilder-ref-table-select\").getText(), \"Table2\");\n    await driver.find(\".test-fbuilder-ref-col-select\").click();\n    await gu.findOpenMenuItem(\".test-select-row\", /A/).click();\n    await gu.waitForServer();\n\n    // Finish transforming and make sure it completed successfully.\n    await driver.findContent(\".type_transform_prompt button\", /Apply/).click();\n    await gu.waitForServer();\n    await gu.checkForErrors();\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Table1\", rowNums: [1, 2], col: \"B\" }),\n      [\"foo\\nbar\", \"\"]);\n\n    // Make sure new references are added to Table2.\n    await gu.sendKeys(\"baz\", Key.ARROW_UP, Key.ENTER, Key.ENTER);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Table2\", rowNums: [1, 2, 3], col: \"A\" }),\n      [\"bar\", \"foo\", \"baz\"]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ReferenceColumns.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { Session } from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, stackWrapFunc } from \"mocha-webdriver\";\n\ndescribe(\"ReferenceColumns\", function() {\n  this.timeout(20000);\n  gu.bigScreen();\n  let session: Session;\n  const cleanup = setupTestSuite({ team: true });\n\n  describe(\"rendering\", function() {\n    before(async function() {\n      session = await gu.session().teamSite.login();\n      await session.tempDoc(cleanup, \"Favorite_Films.grist\");\n\n      await gu.toggleSidePanel(\"right\");\n      await driver.find(\".test-config-data\").click();\n    });\n\n    it(\"should render Row ID values as TableId[RowId]\", async function() {\n      await driver.find(\".test-right-tab-field\").click();\n      await driver.find(\".mod-add-column\").click();\n      await driver.findWait(\".test-new-columns-menu-add-new\", 100).click();\n      await gu.waitForServer();\n      await gu.setType(/Reference/);\n      await gu.waitForServer();\n      await gu.enterGridRows({ col: 3, rowNum: 1 }, [[\"1\"], [\"2\"], [\"3\"], [\"4\"]]);\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Films[1]\", \"Films[2]\", \"Films[3]\", \"Films[4]\", \"\", \"\"]);\n      await driver.find(\".test-fbuilder-ref-table-select\").click();\n      await gu.findOpenMenuItem(\".test-select-row\", /Friends/).click();\n      await gu.waitForServer();\n\n      // These are now invalid cells containing AltText such as 'Films[1]'\n      // We don't simply convert Films[1] -> Friends[1]\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Films[1]\", \"Films[2]\", \"Films[3]\", \"Films[4]\", \"\", \"\"]);\n\n      await driver.find(\".test-type-transform-apply\").click();\n      await gu.waitForServer();\n      await driver.find(\".test-fbuilder-ref-col-select\").click();\n      await gu.findOpenMenuItem(\".test-select-row\", /Name/).click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Films[1]\", \"Films[2]\", \"Films[3]\", \"Films[4]\", \"\", \"\"]);\n      await gu.getCell(3, 5).click();\n      await driver.sendKeys(\"Roger\");\n      await driver.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n\n      // 'Roger' is an actual reference\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Films[1]\", \"Films[2]\", \"Films[3]\", \"Films[4]\", \"Roger\", \"\"]);\n\n      await driver.find(\".test-fbuilder-ref-col-select\").click();\n      await gu.findOpenMenuItem(\".test-select-row\", /Row ID/).click();\n      await gu.waitForServer();\n\n      // 'Friends[1]' is an actual reference, the rest are invalid\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Films[1]\", \"Films[2]\", \"Films[3]\", \"Films[4]\", \"Friends[1]\", \"\"]);\n\n      await driver.find(\".test-fbuilder-ref-col-select\").click();\n      await gu.findOpenMenuItem(\".test-select-row\", /Name/).click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Films[1]\", \"Films[2]\", \"Films[3]\", \"Films[4]\", \"Roger\", \"\"]);\n\n      await gu.undo();\n    });\n\n    it(\"should allow entering numeric id before target table is loaded\", async function() {\n      if (server.isExternalServer()) {\n        this.skip();\n      }\n      // Refresh the document.\n      await driver.navigate().refresh();\n      await gu.waitForDocToLoad();\n\n      // Now pause the server\n      const cell = gu.getCell({ col: \"A\", rowNum: 1 });\n      await server.pauseUntil(async () => {\n        assert.equal(await cell.getText(), \"Films[1]\");\n        await cell.click();\n        await gu.sendKeys(\"5\");\n        // Check that the autocomplete has no items yet.\n        assert.isEmpty(await driver.findAll(\".test-autocomplete .test-ref-editor-new-item\"));\n        await gu.sendKeys(Key.ENTER);\n      });\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Friends[5]\");\n\n      await gu.undo();\n      assert.equal(await cell.getText(), \"Films[1]\");\n\n      // Once server is responsive, a valid value should not offer a \"new item\".\n      await cell.click();\n      await gu.sendKeys(\"5\");\n      await driver.findWait(\".test-ref-editor-item\", 500);\n      assert.isFalse(await driver.find(\".test-ref-editor-new-item\").isPresent());\n      await gu.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Friends[5]\");\n    });\n\n    it(`should show '[Blank]' if the referenced item is blank`, async function() {\n      // Open the All page.\n      await driver.findContentWait(\".test-treeview-itemHeader\", /All/, 2000).click();\n      await gu.waitForDocToLoad();\n\n      // Clear the cells in Films record containing Avatar and Alien.\n      await gu.getCell(\"Title\", 3, \"Films record\").doClick();\n      await gu.sendKeys(Key.BACK_SPACE);\n      await gu.waitForServer();\n      await gu.sendKeys(Key.ARROW_DOWN, Key.BACK_SPACE);\n      await gu.waitForServer();\n\n      // Check that all references to Avatar and Alien now show '[Blank]'.\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          \"Forrest Gump\",\n          \"Toy Story\",\n          \"[Blank]\",\n          \"The Dark Knight\",\n          \"Forrest Gump\",\n          \"[Blank]\",\n        ],\n      );\n\n      // Check that '[Blank]' is not shown when the reference editor is open.\n      await gu.getCell(\"Favorite Film\", 3, \"Friends record\").doClick();\n      await gu.sendKeys(Key.ENTER);\n      assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"\");\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Undo (twice), and check that it shows Avatar and Alien again.\n      await gu.undo(2);\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          \"Forrest Gump\",\n          \"Toy Story\",\n          \"Avatar\",\n          \"The Dark Knight\",\n          \"Forrest Gump\",\n          \"Alien\",\n        ],\n      );\n    });\n  });\n\n  describe(\"autocomplete\", function() {\n    const getACOptions = stackWrapFunc(async (limit?: number) => {\n      await driver.findWait(\".test-ref-editor-item\", 1000);\n      return (await driver.findAll(\".test-ref-editor-item\", el => el.getText())).slice(0, limit);\n    });\n\n    before(async function() {\n      await session.tempDoc(cleanup, \"Ref-AC-Test.grist\");\n      await gu.toggleSidePanel(\"right\", \"close\");\n    });\n\n    it(\"should open to correct item selected, and leave it unchanged on Enter\", async function() {\n      const checkRefCell = stackWrapFunc(async (col: string, rowNum: number, expValue: string) => {\n        // Click cell and open for editing.\n        const cell = await gu.getCell({ section: \"References\", col, rowNum })\n          .find(\".test-ref-text\").doClick();\n        assert.equal(await cell.getText(), expValue);\n        await driver.sendKeys(Key.ENTER);\n        // Wait for expected value to appear in the list; check that it's selected.\n        const match = await driver.findContentWait(\".test-ref-editor-item\", expValue, 1000);\n        assert.equal(await match.matches(\".selected\"), true);\n        // Save the value.\n        await driver.sendKeys(Key.ENTER);\n        await gu.waitForServer();\n        assert.equal(await cell.getText(), expValue);\n        // Assert that the undo is disabled, i.e. no action was generated.\n        assert.equal(await driver.find(\".test-undo\").matches(\"[class*=-disabled]\"), true);\n      });\n      await checkRefCell(\"Color\", 1, \"Dark Slate Blue\");\n      await checkRefCell(\"ColorCode\", 2, \"#808080\");\n      await checkRefCell(\"XNum\", 3, \"2019-11-05\");\n      await checkRefCell(\"School\", 1, \"TECHNOLOGY, ARTS AND SCIENCES STUDIO\");\n    });\n\n    it(\"should render first items when opening empty cell\", async function() {\n      await driver.sendKeys(Key.HOME);\n\n      let cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 4 }).doClick();\n      assert.equal(await cell.getText(), \"\");\n      await driver.sendKeys(Key.ENTER);\n      // Check the first few items.\n      assert.deepEqual(await getACOptions(3), [\"Alice Blue\", \"Añil\", \"Aqua\"]);\n      // No item is selected.\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n      await driver.sendKeys(Key.ESCAPE);\n\n      cell = await gu.getCell({ section: \"References\", col: \"School\", rowNum: 6 }).doClick();\n      assert.equal(await cell.getText(), \"\");\n      await driver.sendKeys(Key.ENTER);\n      // Check the first few items; should be sorted alphabetically.\n      assert.deepEqual(await getACOptions(3),\n        [\"2 SCHOOL\", \"4 SCHOOL\", \"47 AMER SIGN LANG & ENG LOWER \"]);\n      // No item is selected.\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n      await driver.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"should save correct item on click\", async function() {\n      await driver.sendKeys(Key.HOME);\n\n      // Edit a cell by double-clicking.\n      let cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 2 }).doClick();\n      await driver.withActions(a => a.doubleClick(cell));\n      assert.equal(await driver.findWait(\".test-ref-editor-item.selected\", 1000).getText(), \"Red\");\n\n      // Scroll to another item and click it.\n      let item = driver.findContent(\".test-ref-editor-item\", \"Rosy Brown\");\n      await gu.scrollIntoView(item);\n      await item.click();\n\n      // It should get saved; and undo should restore the previous value.\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Rosy Brown\");\n      await gu.undo();\n      assert.equal(await cell.getText(), \"Red\");\n\n      // Edit another cell by starting to type.\n      cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 4 }).doClick();\n      await driver.sendKeys(\"gr\");\n      await driver.findWait(\".test-ref-editor-item\", 1000);\n      item = driver.findContent(\".test-ref-editor-item\", \"Medium Sea Green\");\n      await gu.scrollIntoView(item);\n      await item.click();\n\n      // It should get saved; and undo should restore the previous value.\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Medium Sea Green\");\n      await gu.undo();\n      assert.equal(await cell.getText(), \"\");\n    });\n\n    it(\"should save correct item after selecting with arrow keys\", async function() {\n      // Same as the previous test, but instead of clicking items, select item using arrow keys.\n\n      // Edit a cell by double-clicking.\n      let cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 2 }).doClick();\n      await driver.withActions(a => a.doubleClick(cell));\n      assert.equal(await driver.findWait(\".test-ref-editor-item.selected\", 1000).getText(), \"Red\");\n\n      // Move to another item and hit Enter\n      await driver.sendKeys(Key.DOWN, Key.DOWN, Key.DOWN, Key.DOWN, Key.DOWN);\n      assert.equal(await driver.findWait(\".test-ref-editor-item.selected\", 1000).getText(), \"Pale Violet Red\");\n      await driver.sendKeys(Key.ENTER);\n\n      // It should get saved; and undo should restore the previous value.\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Pale Violet Red\");\n      await gu.undo();\n      assert.equal(await cell.getText(), \"Red\");\n\n      // Edit another cell by starting to type.\n      cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 4 }).doClick();\n      await driver.sendKeys(\"gr\");\n      await driver.findWait(\".test-ref-editor-item\", 1000);\n      await driver.sendKeys(Key.UP, Key.UP, Key.UP, Key.UP, Key.UP);\n      assert.equal(await driver.findWait(\".test-ref-editor-item.selected\", 1000).getText(), \"Chocolate\");\n      await driver.sendKeys(Key.ENTER);\n\n      // It should get saved; and undo should restore the previous value.\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Chocolate\");\n      await gu.undo();\n      assert.equal(await cell.getText(), \"\");\n    });\n\n    it(\"should return to text-as-typed when nothing is selected\", async function() {\n      const cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 2 }).doClick();\n      await driver.sendKeys(\"da\");\n      assert.deepEqual(await getACOptions(2), [\"Dark Blue\", \"Dark Cyan\"]);\n\n      // Check that the first item is highlighted by default.\n      assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"da\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").getText(), \"Dark Blue\");\n\n      // Select second item. Both the textbox and the dropdown show the selection.\n      await driver.sendKeys(Key.DOWN);\n      assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"Dark Cyan\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").getText(), \"Dark Cyan\");\n\n      // Move back to no-selection state.\n      await driver.sendKeys(Key.UP, Key.UP);\n      assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"da\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n\n      // Clear the typed-in text temporarily. Something changed in a recent version of Chrome,\n      // causing the wrong item to be moused over below when the \"Add New\" option is visible.\n      await driver.sendKeys(Key.BACK_SPACE, Key.BACK_SPACE);\n\n      // Mouse over an item.\n      await driver.findContent(\".test-ref-editor-item\", /Dark Gray/).mouseMove();\n      assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"Dark Gray\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").getText(), \"Dark Gray\");\n\n      // Mouse back out of the dropdown\n      await driver.find(\".celleditor_text_editor\").mouseMove();\n      assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n\n      // Re-enter the typed-in text and click away to save it.\n      await driver.sendKeys(\"da\", Key.UP);\n      await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 1 }).doClick();\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"da\");\n      assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), true);\n\n      await gu.undo();\n      assert.equal(await cell.getText(), \"Red\");\n      assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), false);\n    });\n\n    it(\"should save text as typed when nothing is selected\", async function() {\n      const cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 1 }).doClick();\n      await driver.sendKeys(\"lavender \", Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Lavender\");\n      await gu.undo();\n      assert.equal(await cell.getText(), \"Dark Slate Blue\");\n    });\n\n    it(\"should offer an add-new option when no good match\", async function() {\n      const cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 2 }).doClick();\n      await driver.sendKeys(\"pinkish\");\n      // There are inexact matches.\n      assert.deepEqual(await getACOptions(3),\n        [\"Pink\", \"Deep Pink\", \"Hot Pink\"]);\n      // Nothing is selected, and the \"add new\" item is present.\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n      assert.equal(await driver.find(\".test-ref-editor-new-item\").getText(), \"pinkish\");\n\n      // Click the \"add new\" item. The new value should be saved, and should not appear invalid.\n      await driver.find(\".test-ref-editor-new-item\").click();\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"pinkish\");\n      assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), false);\n\n      // Requires 2 undos, because adding the \"pinkish\" record is a separate action. TODO these\n      // actions should be bundled.\n      await gu.undo(2);\n      assert.equal(await cell.getText(), \"Red\");\n    });\n\n    it(\"should offer an add-new option when opening alt-text\", async function() {\n      const cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 2 }).doClick();\n\n      // Enter and invalid value and save without clicking \"add new\".\n      await driver.sendKeys(\"super pink\", Key.ENTER);\n\n      // It should be saved but appear invalid (as alt-text).\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"super pink\");\n      assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), true);\n\n      // Open the cell again. The \"Add New\" option should be there.\n      await driver.withActions(a => a.doubleClick(cell));\n      assert.equal(await driver.find(\".test-ref-editor-new-item\").getText(), \"super pink\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n\n      // Select \"add new\" (this time with arrow keys), and save.\n      await driver.sendKeys(Key.UP);\n      assert.equal(await driver.find(\".test-ref-editor-new-item\").matches(\".selected\"), true);\n      await driver.sendKeys(Key.ENTER);\n\n      // Once \"add new\" is clicked, the \"super pink\" no longer appears as invalid.\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"super pink\");\n      assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), false);\n\n      await gu.undo(3);\n      assert.equal(await cell.getText(), \"Red\");\n    });\n\n    it(\"should not offer an add-new option when target is a formula\", async function() {\n      // Click on an alt-text cell.\n      const cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 3 }).doClick();\n      assert.equal(await cell.getText(), \"hello\");\n      assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), true);\n\n      await driver.sendKeys(Key.ENTER);\n      assert.equal(await driver.find(\".test-ref-editor-new-item\").getText(), \"hello\");\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Change the visible column to the formula column \"C2\".\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await driver.find(\".test-right-tab-field\").click();\n      await driver.find(\".test-fbuilder-ref-col-select\").click();\n      await gu.findOpenMenuItem(\".test-select-row\", /C2/).click();\n      await gu.waitForServer();\n\n      // Check that for the same cell, the dropdown no longer has an \"add new\" option.\n      await cell.click();\n      await driver.sendKeys(Key.ENTER);\n      assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"hello\");\n      await driver.findWait(\".test-ref-editor-item\", 1000);\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n      assert.equal(await driver.find(\".test-ref-editor-new-item\").isPresent(), false);\n      await driver.sendKeys(Key.ESCAPE);\n\n      await gu.undo();\n      await gu.toggleSidePanel(\"right\", \"close\");\n    });\n\n    it(\"should offer items ordered by best match\", async function() {\n      let cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 1 }).doClick();\n      assert.equal(await cell.getText(), \"Dark Slate Blue\");\n      await driver.sendKeys(Key.ENTER);\n      assert.deepEqual(await getACOptions(4),\n        [\"Dark Slate Blue\", \"Dark Slate Gray\", \"Slate Blue\", \"Medium Slate Blue\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Starting to type Añil with the accent\n      await driver.sendKeys(\"añ\");\n      assert.deepEqual(await getACOptions(2),\n        [\"Añil\", \"Alice Blue\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Starting to type Añil without the accent should work too\n      await driver.sendKeys(\"an\");\n      assert.deepEqual(await getACOptions(2),\n        [\"Añil\", \"Alice Blue\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      await driver.sendKeys(\"blac\");\n      assert.deepEqual(await getACOptions(6),\n        [\"Black\", \"Blanched Almond\", \"Blue\", \"Blue Violet\", \"Alice Blue\", \"Cadet Blue\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 3 }).doClick();\n      assert.equal(await cell.getText(), \"hello\");    // Alt-text\n      await driver.sendKeys(Key.ENTER);\n      assert.deepEqual(await getACOptions(2),\n        [\"Honeydew\", \"Hot Pink\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      cell = await gu.getCell({ section: \"References\", col: \"ColorCode\", rowNum: 2 }).doClick();\n      assert.equal(await cell.getText(), \"#808080\");\n      await driver.sendKeys(Key.ENTER);\n      assert.deepEqual(await getACOptions(5),\n        [\"#808080\", \"#808000\", \"#800000\", \"#800080\", \"#87CEEB\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      cell = await gu.getCell({ section: \"References\", col: \"XNum\", rowNum: 2 }).doClick();\n      assert.equal(await cell.getText(), \"2019-04-29\");\n      await driver.sendKeys(Key.ENTER);\n      assert.deepEqual(await getACOptions(4),\n        [\"2019-04-29\", \"2020-04-29\", \"2019-11-05\", \"2020-04-28\"]);\n      await driver.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"should update choices as user types into textbox\", async function() {\n      let cell = await gu.getCell({ section: \"References\", col: \"School\", rowNum: 1 })\n        .find(\".test-ref-text\").doClick();\n      assert.equal(await cell.getText(), \"TECHNOLOGY, ARTS AND SCIENCES STUDIO\");\n      await driver.sendKeys(Key.ENTER);\n      assert.deepEqual(await getACOptions(3), [\n        \"TECHNOLOGY, ARTS AND SCIENCES STUDIO\",\n        \"SCIENCE AND TECHNOLOGY ACADEMY\",\n        \"SCHOOL OF SCIENCE AND TECHNOLOGY\",\n      ]);\n      await driver.sendKeys(Key.ESCAPE);\n      cell = await gu.getCell({ section: \"References\", col: \"School\", rowNum: 2 }).doClick();\n      await driver.sendKeys(\"stuy\");\n      assert.deepEqual(await getACOptions(3), [\n        \"STUYVESANT HIGH SCHOOL\",\n        \"BEDFORD STUY COLLEGIATE CHARTER SCH\",\n        \"BEDFORD STUY NEW BEGINNINGS CHARTER\",\n      ]);\n      await driver.sendKeys(Key.BACK_SPACE);\n      assert.deepEqual(await getACOptions(3), [\n        \"STUART M TOWNSEND MIDDLE SCHOOL\",\n        \"STUDIO SCHOOL (THE)\",\n        \"STUYVESANT HIGH SCHOOL\",\n      ]);\n      await driver.sendKeys(\" bre\");\n      assert.equal(await driver.find(\".celleditor_text_editor\").value(), \"stu bre\");\n      assert.deepEqual(await getACOptions(3), [\n        \"ST BRENDAN SCHOOL\",\n        \"BRONX STUDIO SCHOOL-WRITERS-ARTISTS\",\n        \"BROOKLYN STUDIO SECONDARY SCHOOL\",\n      ]);\n\n      await driver.sendKeys(Key.DOWN, Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"ST BRENDAN SCHOOL\");\n      await gu.undo();\n      assert.equal(await cell.getText(), \"\");\n    });\n\n    it(\"should highlight matching parts of items\", async function() {\n      await driver.sendKeys(Key.HOME);\n\n      let cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 2 })\n        .find(\".test-ref-text\").doClick();\n      assert.equal(await cell.getText(), \"Red\");\n      await driver.sendKeys(Key.ENTER);\n      await driver.findWait(\".test-ref-editor-item\", 1000);\n      assert.deepEqual(\n        await driver.findContent(\".test-ref-editor-item\", /Dark Red/).findAll(\"span\", e => e.getText()),\n        [\"Red\"]);\n      assert.deepEqual(\n        await driver.findContent(\".test-ref-editor-item\", /Rebecca Purple/).findAll(\"span\", e => e.getText()),\n        [\"Re\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      cell = await gu.getCell({ section: \"References\", col: \"School\", rowNum: 1 })\n        .find(\".test-ref-text\").doClick();\n      await driver.sendKeys(\"br tech\");\n      assert.deepEqual(\n        await driver.findContentWait(\".test-ref-editor-item\", /BROOKLYN TECH/, 1000).findAll(\"span\", e => e.getText()),\n        [\"BR\", \"TECH\"]);\n      assert.deepEqual(\n        await driver.findContent(\".test-ref-editor-item\", /BUFFALO.*TECHNOLOGY/).findAll(\"span\", e => e.getText()),\n        [\"B\", \"TECH\"]);\n      assert.deepEqual(\n        await driver.findContent(\".test-ref-editor-item\", /ENERGY TECH/).findAll(\"span\", e => e.getText()),\n        [\"TECH\"]);\n      await driver.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"should reflect changes to the target column\", async function() {\n      await driver.sendKeys(Key.HOME);\n\n      const cell = await gu.getCell({ section: \"References\", col: \"Color\", rowNum: 4 }).doClick();\n      assert.equal(await cell.getText(), \"\");\n      await driver.sendKeys(Key.ENTER);\n      assert.deepEqual(await getACOptions(2), [\"Alice Blue\", \"Añil\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Change a color\n      await gu.getCell({ section: \"Colors\", col: \"Color Name\", rowNum: 1 }).doClick();\n      await driver.sendKeys(\"HAZELNUT\", Key.ENTER);\n      await gu.waitForServer();\n\n      // See that the old value is gone from the autocomplete, and the new one is present.\n      await cell.click();\n      await driver.sendKeys(Key.ENTER);\n      assert.deepEqual(await getACOptions(2), [\"Añil\", \"Aqua\"]);\n      await driver.sendKeys(\"H\");\n      assert.deepEqual(await getACOptions(2), [\"HAZELNUT\", \"Honeydew\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Delete a row.\n      await gu.getCell({ section: \"Colors\", col: \"Color Name\", rowNum: 1 }).doClick();\n      await driver.find(\"body\").sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n      await gu.confirm(true, true);\n      await gu.waitForServer();\n\n      // See that the value is gone from the autocomplete.\n      await cell.click();\n      await driver.sendKeys(\"H\");\n      assert.deepEqual(await getACOptions(2), [\"Honeydew\", \"Hot Pink\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Add a row.\n      await gu.getCell({ section: \"Colors\", col: \"Color Name\", rowNum: 1 }).doClick();\n      await driver.find(\"body\").sendKeys(Key.chord(await gu.modKey(), Key.ENTER));\n      await gu.waitForServer();\n      await driver.sendKeys(\"HELIOTROPE\", Key.ENTER);\n      await gu.waitForServer();\n\n      // See that the new value is visible in the autocomplete.\n      await cell.click();\n      await driver.sendKeys(\"H\");\n      assert.deepEqual(await getACOptions(2), [\"HELIOTROPE\", \"Honeydew\"]);\n      await driver.sendKeys(Key.BACK_SPACE);\n      assert.deepEqual(await getACOptions(2), [\"Añil\", \"Aqua\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Undo all the changes.\n      await gu.undo(4);\n\n      await cell.click();\n      await driver.sendKeys(\"H\");\n      assert.deepEqual(await getACOptions(2), [\"Honeydew\", \"Hot Pink\"]);\n      await driver.sendKeys(Key.BACK_SPACE);\n      assert.deepEqual(await getACOptions(2), [\"Alice Blue\", \"Añil\"]);\n      await driver.sendKeys(Key.ESCAPE);\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ReferenceList.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { Session } from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert, driver, Key, stackWrapFunc } from \"mocha-webdriver\";\n\ndescribe(\"ReferenceList\", function() {\n  this.timeout(60000);\n\n  // Sandboxing disrupts timing a bit.\n  testUtils.withoutSandboxing();\n\n  let session: Session;\n  const cleanup = setupTestSuite({ team: true });\n\n  before(async function() {\n    session = await gu.session().teamSite.login();\n  });\n\n  describe(\"other\", function() {\n    it(\"fix: changing ref list with a new referenced row was not bundled\", async function() {\n      // When user added a new referenced row through the RefList editor, the UI sent two separate\n      // actions. One for adding the new row, and another for updating the RefList column.\n      await session.tempNewDoc(cleanup);\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Table1\", \"B\", { type: \"RefList:Table1\" }],\n        [\"AddRecord\", \"Table1\", null, { A: \"a\" }],\n      ]);\n      await gu.openColumnPanel();\n      await gu.getCell(\"B\", 1).doClick();\n      await gu.setRefShowColumn(\"A\");\n\n      await gu.getCell(\"B\", 1).click();\n      await gu.sendKeys(Key.ENTER, \"b\");\n      await gu.waitToPass(async () => {\n        await driver.findWait(\".test-ref-editor-new-item\", 100).click();\n      });\n      await gu.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n\n      // Check the data - use waitToPass helper, as previously it might have failed\n      // as 2 separate actions were sent.\n      await gu.waitToPass(async () => {\n        assert.deepEqual(await gu.getVisibleGridCells(\"A\", [1, 2]), [\"a\", \"b\"]);\n        assert.deepEqual(await gu.getVisibleGridCells(\"B\", [1, 2]), [\"b\", \"\"]);\n        assert.equal(await gu.getGridRowCount(), 3);\n      });\n\n      // Now press undo once, and check that the new row is removed and the RefList is updated.\n      await gu.undo();\n      assert.deepEqual(await gu.getVisibleGridCells(\"A\", [1]), [\"a\"]);\n      assert.deepEqual(await gu.getVisibleGridCells(\"B\", [1]), [\"\"]);\n      assert.equal(await gu.getGridRowCount(), 2);\n    });\n\n    it(\"fix: doesnt break when table is renamed\", async function() {\n      // There was a bug in this scenario:\n      // 1. Create a Ref column that targets itself\n      // 2. Fill it up\n      // 3. Change it to RefList\n      // 4. Rename the table\n      // Previously, this would cause an error, cells were displaying Errors instead of values.\n\n      // Create a table with a Ref column that targets itself.\n      await session.tempNewDoc(cleanup);\n      await gu.sendActions([\n        [\"ModifyColumn\", \"Table1\", \"B\", { type: \"Ref:Table1\" }],\n        [\"AddRecord\", \"Table1\", null, { A: \"a\", B: 1 }],\n        [\"AddRecord\", \"Table1\", null, { A: \"b\", B: 2 }],\n        [\"AddRecord\", \"Table1\", null, { A: \"c\", B: 3 }],\n      ]);\n      await gu.openColumnPanel();\n      await gu.getCell(\"B\", 1).doClick();\n      await gu.setRefShowColumn(\"A\");\n\n      // Now convert it to RefList.\n      await gu.setType(\"Reference List\", { apply: true });\n\n      // Make sure we see the values.\n      assert.deepEqual(await gu.getVisibleGridCells(\"B\", [1, 2, 3]), [\"a\", \"b\", \"c\"]);\n\n      // Rename the table using widget in the section.\n      await gu.renameTable(\"Table1\", \"Table2\");\n\n      // Make sure we still see the values.\n      assert.deepEqual(await gu.getVisibleGridCells(\"B\", [1, 2, 3]), [\"a\", \"b\", \"c\"]);\n    });\n\n    it(\"fix: allows to delete document with self reference\", async function() {\n      const docId = await session.tempNewDoc(cleanup);\n      await gu.sendActions([\n        [\"AddEmptyTable\", \"Table2\"],\n        [\"ModifyColumn\", \"Table1\", \"B\", { type: \"RefList:Table1\" }],\n        [\"AddRecord\", \"Table1\", null, { A: \"a\" }],\n        [\"AddRecord\", \"Table1\", null, { A: \"b\", B: [\"L\", 1] }],\n        [\"AddRecord\", \"Table1\", null, { A: \"c\", B: [\"L\", 2] }],\n      ]);\n\n      // Now try to delete the table.\n      await gu.removeTable(\"Table1\");\n      await gu.checkForErrors();\n\n      // Make sure table is deleted. Previously it ended with an engine error\n      // in the 'a' row which has NULL instead of a list of ids.\n      const api = session.createHomeApi().getDocAPI(docId);\n      const tables = await api.getRows(\"_grist_Tables\");\n      assert.deepEqual(tables.tableId, [\"Table2\"]);\n    });\n  });\n\n  describe(\"transforms\", function() {\n    before(async function() {\n      await session.tempDoc(cleanup, \"Favorite_Films.grist\");\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await driver.find(\".test-right-tab-pagewidget\").click();\n      await driver.find(\".test-config-data\").click();\n    });\n\n    afterEach(() => gu.checkForErrors());\n\n    it(\"should correctly transform references to reference lists\", async function() {\n      // Open the Friends page.\n      await driver.findContentWait(\".test-treeview-itemHeader\", /Friends/, 2000).click();\n      await gu.waitForDocToLoad();\n\n      // Change the column type of Favorite Film to Reference List.\n      await gu.getCell({ col: \"Favorite Film\", rowNum: 1 }).doClick();\n      await gu.setType(/Reference List/);\n\n      // Check that the column preview shows valid reference lists.\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6, 7]),\n        [\n          \"Forrest Gump\",\n          \"Toy Story\",\n          \"Avatar\",\n          \"The Dark Knight\",\n          \"Forrest Gump\",\n          \"Alien\",\n          \"\",\n        ],\n      );\n\n      // Apply the conversion.\n      await driver.findContent(\".type_transform_prompt button\", /Apply/).click();\n      await gu.waitForServer();\n\n      // Check that Favorite Film now contains reference lists of length 1.\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6, 7]),\n        [\n          \"Forrest Gump\",\n          \"Toy Story\",\n          \"Avatar\",\n          \"The Dark Knight\",\n          \"Forrest Gump\",\n          \"Alien\",\n          \"\",\n        ],\n      );\n    });\n  });\n\n  describe(\"rendering\", function() {\n    afterEach(() => gu.checkForErrors());\n\n    it(\"should reflect the current values from the referenced column\", async function() {\n      // Open the All page.\n      await driver.findContentWait(\".test-treeview-itemHeader\", /All/, 2000).click();\n      await gu.waitForDocToLoad();\n\n      // Add additional favorite films to a few rows in Friends.\n      await gu.getCell(\"Favorite Film\", 1, \"Friends record\").doClick();\n      await gu.sendKeys(Key.ENTER, \"Alien\", Key.ENTER, Key.ENTER);\n      await gu.sendKeys(Key.ENTER, \"Avatar\", Key.ENTER, \"The Avengers\", Key.ENTER, Key.ENTER);\n      await gu.sendKeys(Key.ARROW_DOWN, Key.ENTER, \"The Avengers\", Key.ENTER, Key.ENTER);\n\n      // Check that the cells are rendered correctly.\n      await gu.resizeColumn({ col: \"Favorite Film\" }, 100);\n      assert.deepEqual(await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6]),\n        [\n          \"Forrest Gump\\nAlien\",\n          \"Toy Story\\nAvatar\\nThe Avengers\",\n          \"Avatar\",\n          \"The Dark Knight\\nThe Avengers\",\n          \"Forrest Gump\",\n          \"Alien\",\n        ],\n      );\n\n      // Change a few of the film titles.\n      await gu.getCell(\"Title\", 1, \"Films record\").doClick();\n      await gu.sendKeys(\"Toy Story 2\", Key.ENTER);\n      await gu.sendKeys(Key.ARROW_DOWN, \"Aliens\", Key.ENTER);\n      await gu.sendKeys(Key.ARROW_DOWN, \"The Dark Knight Rises\", Key.ENTER);\n\n      // Check that the Favorite Film column reflects the new titles.\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          \"Forrest Gump\\nAliens\",\n          \"Toy Story 2\\nAvatar\\nThe Avengers\",\n          \"Avatar\",\n          \"The Dark Knight Rises\\nThe Avengers\",\n          \"Forrest Gump\",\n          \"Aliens\",\n        ],\n      );\n    });\n\n    it(`should show '[Blank]' if the referenced item is blank`, async function() {\n      // Clear the cell in Films record containing Avatar.\n      await gu.getCell(\"Title\", 4, \"Films record\").doClick();\n      await gu.sendKeys(Key.BACK_SPACE);\n      await gu.waitForServer();\n\n      // Check that all references to Avatar now show '[Blank]'.\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          \"Forrest Gump\\nAliens\",\n          \"Toy Story 2\\n[Blank]\\nThe Avengers\",\n          \"[Blank]\",\n          \"The Dark Knight Rises\\nThe Avengers\",\n          \"Forrest Gump\",\n          \"Aliens\",\n        ],\n      );\n\n      // Check that a '[Blank]' token is shown when the reference list editor is open.\n      await gu.getCell(\"Favorite Film\", 2, \"Friends record\").doClick();\n      await gu.sendKeys(Key.ENTER);\n      assert.deepEqual(\n        await driver.findAll(\".cell_editor .test-tokenfield .test-tokenfield-token\", el => el.getText()),\n        [\"Toy Story 2\", \"[Blank]\", \"The Avengers\"],\n      );\n      await gu.sendKeys(Key.ESCAPE);\n\n      // Undo, and check that it shows Avatar again.\n      await gu.undo();\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          \"Forrest Gump\\nAliens\",\n          \"Toy Story 2\\nAvatar\\nThe Avengers\",\n          \"Avatar\",\n          \"The Dark Knight Rises\\nThe Avengers\",\n          \"Forrest Gump\",\n          \"Aliens\",\n        ],\n      );\n\n      // Now delete the row containing Avatar.\n      await gu.getCell(\"Title\", 4, \"Films record\").doClick();\n      await gu.removeRow(4);\n\n      // Check that all references to Avatar are deleted.\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          \"Forrest Gump\\nAliens\",\n          \"Toy Story 2\\nThe Avengers\",\n          \"\",\n          \"The Dark Knight Rises\\nThe Avengers\",\n          \"Forrest Gump\",\n          \"Aliens\",\n        ],\n      );\n\n      await gu.undo();\n    });\n\n    it(\"should still work after renaming visible column\", async function() {\n      // Check that we have a Ref:Films column displaying Title.\n      await gu.getCell({ section: \"Friends record\", col: \"Favorite Film\", rowNum: 2 }).doClick();\n      assert.equal(await driver.find(\".test-fbuilder-ref-table-select .test-select-row\").getText(), \"Films\");\n      assert.equal(await driver.find(\".test-fbuilder-ref-col-select .test-select-row\").getText(), \"Title\");\n\n      // Rename the Title column in Films, to TitleX.\n      // In browser tests, first record is hidden, we need to scroll first.\n      await gu.selectSectionByTitle(\"Films record\");\n      await gu.scrollActiveView(0, -100);\n      await gu.getCell({ section: \"Films record\", col: \"Title\", rowNum: 1 }).doClick();\n      await driver.find(\".test-field-label\").click();\n      await gu.sendKeys(await gu.selectAllKey(), \"TitleX\", Key.ENTER);\n      await gu.waitForServer();\n\n      // Check that the Ref:Films column shows TitleX and is still correct.\n      await gu.getCell({ section: \"Friends record\", col: \"Favorite Film\", rowNum: 2 }).doClick();\n      await driver.find(\".test-fbuilder-ref-table-select\").click();\n      assert.equal(await driver.find(\".test-fbuilder-ref-table-select .test-select-row\").getText(), \"Films\");\n      assert.equal(await driver.find(\".test-fbuilder-ref-col-select .test-select-row\").getText(), \"TitleX\");\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6]),\n        [\n          \"Forrest Gump\\nAliens\",\n          \"Toy Story 2\\nAvatar\\nThe Avengers\",\n          \"Avatar\",\n          \"The Dark Knight Rises\\nThe Avengers\",\n          \"Forrest Gump\",\n          \"Aliens\",\n        ],\n      );\n\n      // Undo and verify again.\n      await gu.undo();\n      await gu.getCell({ section: \"Friends record\", col: \"Favorite Film\", rowNum: 2 }).doClick();\n      assert.equal(await driver.find(\".test-fbuilder-ref-col-select .test-select-row\").getText(), \"Title\");\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6]),\n        [\n          \"Forrest Gump\\nAliens\",\n          \"Toy Story 2\\nAvatar\\nThe Avengers\",\n          \"Avatar\",\n          \"The Dark Knight Rises\\nThe Avengers\",\n          \"Forrest Gump\",\n          \"Aliens\",\n        ],\n      );\n    });\n\n    it(\"should switch to rowId if the selected visible column is deleted\", async function() {\n      // Delete the Title column from Films.\n      await gu.getCell({ section: \"Films record\", col: \"Title\", rowNum: 1 }).doClick();\n      await gu.sendKeys(Key.chord(Key.ALT, \"-\"));\n      await gu.waitForServer();\n\n      // Check that Favorite Film switched to showing RowID.\n      await gu.getCell({ section: \"Friends record\", col: \"Favorite Film\", rowNum: 2 }).doClick();\n      assert.equal(await driver.find(\".test-fbuilder-ref-table-select .test-select-row\").getText(), \"Films\");\n      assert.equal(await driver.find(\".test-fbuilder-ref-col-select .test-select-row\").getText(), \"Row ID\");\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6]),\n        [\n          \"Films[2]\\nFilms[3]\",\n          \"Films[1]\\nFilms[4]\\nFilms[6]\",\n          \"Films[4]\",\n          \"Films[5]\\nFilms[6]\",\n          \"Films[2]\",\n          \"Films[3]\",\n        ],\n      );\n\n      await gu.undo();\n    });\n\n    it(\"should render Row ID values as TableId[RowId]\", async function() {\n      await driver.findContentWait(\".test-treeview-itemHeader\", /Friends/, 2000).click();\n      await gu.waitForDocToLoad();\n\n      // Create a new Reference List column.\n      await driver.find(\".test-right-tab-field\").click();\n      await driver.find(\".mod-add-column\").click();\n      await driver.findWait(\".test-new-columns-menu-add-new\", 100).click();\n\n      await gu.waitForServer();\n      await gu.setType(/Reference List/);\n      await gu.waitForServer();\n\n      // Populate the first few rows of the new column with some references.\n      await gu.getCell({ rowNum: 1, col: \"A\" }).click();\n      await driver.sendKeys(\"1\", Key.ENTER, \"2\", Key.ENTER, Key.ENTER);\n      await driver.sendKeys(\"2\", Key.ENTER, Key.ENTER);\n      await driver.sendKeys(\"3\", Key.ENTER, \"4\", Key.ENTER, \"5\", Key.ENTER, Key.ENTER);\n\n      // Check that the cells render their tokens as TableId[RowId].\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Friends[1]\\nFriends[2]\", \"Friends[2]\", \"Friends[3]\\nFriends[4]\\nFriends[5]\", \"\", \"\", \"\"]);\n\n      // Check that switching Shown Column to Name works correctly.\n      await driver.find(\".test-fbuilder-ref-col-select\").click();\n      await gu.findOpenMenuItem(\".test-select-row\", /Name/).click();\n      await gu.waitForServer();\n      await gu.resizeColumn({ col: \"A\" }, 100);\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Roger\\nTom\", \"Tom\", \"Sydney\\nBill\\nEvan\", \"\", \"\", \"\"]);\n\n      // Add a new reference.\n      await gu.getCell(3, 5).click();\n      await driver.sendKeys(\"Roger\");\n      await driver.sendKeys(Key.ENTER, Key.ENTER);\n      await gu.waitForServer();\n\n      // Check that switching between Row ID and Name still works correctly.\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Roger\\nTom\", \"Tom\", \"Sydney\\nBill\\nEvan\", \"\", \"Roger\", \"\"]);\n      await driver.find(\".test-fbuilder-ref-col-select\").click();\n      await gu.findOpenMenuItem(\".test-select-row\", /Row ID/).click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Friends[1]\\nFriends[2]\", \"Friends[2]\", \"Friends[3]\\nFriends[4]\\nFriends[5]\", \"\", \"Friends[1]\", \"\"]);\n      await driver.find(\".test-fbuilder-ref-col-select\").click();\n      await gu.findOpenMenuItem(\".test-select-row\", /Name/).click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),\n        [\"Roger\\nTom\", \"Tom\", \"Sydney\\nBill\\nEvan\", \"\", \"Roger\", \"\"]);\n\n      await gu.undo();\n    });\n\n    it(\"should allow entering numeric id before target table is loaded\", async function() {\n      if (server.isExternalServer()) {\n        this.skip();\n      }\n      // Refresh the document.\n      await driver.navigate().refresh();\n      await gu.waitForDocToLoad();\n\n      // Now pause the server.\n      const cell = gu.getCell({ col: \"A\", rowNum: 1 });\n      await server.pauseUntil(async () => {\n        assert.equal(await cell.getText(), \"Friends[1]\\nFriends[2]\");\n        await gu.clickReferenceListCell(cell);\n        await gu.sendKeys(\"5\");\n        // Check that the autocomplete has no items yet.\n        assert.isEmpty(await driver.findAll(\".test-autocomplete .test-ref-editor-new-item\"));\n        await gu.sendKeys(Key.ENTER, Key.ENTER);\n      });\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Friends[5]\");\n\n      await gu.undo();\n      assert.equal(await cell.getText(), \"Friends[1]\\nFriends[2]\");\n\n      // Once server is responsive, a valid value should not offer a \"new item\".\n      await gu.clickReferenceListCell(cell);\n      await gu.sendKeys(\"5\");\n      await driver.findWait(\".test-ref-editor-item\", 500);\n      assert.isFalse(await driver.find(\".test-ref-editor-new-item\").isPresent());\n      await gu.sendKeys(Key.ENTER, Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Friends[5]\");\n    });\n  });\n\n  describe(\"sorting\", function() {\n    afterEach(() => gu.checkForErrors());\n\n    it(\"should sort by the display values of the referenced column\", async function() {\n      this.timeout(10000);\n      await driver.findContentWait(\".test-treeview-itemHeader\", /All/, 2000).click();\n      await gu.waitForDocToLoad();\n      await gu.getCell(\"Favorite Film\", 1, \"Friends record\").doClick();\n\n      await driver.find(\".test-right-tab-pagewidget\").click();\n      await driver.find(\".test-config-sortAndFilter\").click();\n\n      // Sort the Favorite Film column.\n      await gu.addColumnToSort(\"Favorite Film\");\n      await gu.saveSortConfig();\n\n      // Check that the records are sorted by display value.\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          \"Aliens\",\n          \"Avatar\",\n          \"Forrest Gump\",\n          \"Forrest Gump\\nAliens\",\n          \"The Dark Knight Rises\\nThe Avengers\",\n          \"Toy Story 2\\nAvatar\\nThe Avengers\",\n        ],\n      );\n    });\n\n    it(\"should update sort when display column is changed\", async function() {\n      // Change a film title to cause the sort order to change.\n      await gu.getCell(\"Title\", 5, \"Films record\").doClick();\n      await gu.sendKeys(\"Batman Begins\", Key.ENTER);\n      await gu.waitForServer();\n\n      // Check that the updated sort order is correct.\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          \"Aliens\",\n          \"Avatar\",\n          \"Batman Begins\\nThe Avengers\",\n          \"Forrest Gump\",\n          \"Forrest Gump\\nAliens\",\n          \"Toy Story 2\\nAvatar\\nThe Avengers\",\n        ],\n      );\n\n      // Clear a film title to cause the sort order to change.\n      await gu.getCell(\"Title\", 2, \"Films record\").doClick();\n      await gu.sendKeys(Key.BACK_SPACE);\n      await gu.waitForServer();\n\n      // Check that the updated sort order is correct.\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          \"[Blank]\",\n          \"[Blank]\\nAliens\",\n          \"Aliens\",\n          \"Avatar\",\n          \"Batman Begins\\nThe Avengers\",\n          \"Toy Story 2\\nAvatar\\nThe Avengers\",\n        ],\n      );\n\n      // Clear a film reference to cause the sort order to change.\n      await gu.getCell(\"Favorite Film\", 4, \"Friends record\").doClick();\n      await gu.sendKeys(Key.BACK_SPACE);\n      await gu.waitForServer();\n\n      // Check that the updated sort order is correct.\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          \"\",\n          \"[Blank]\",\n          \"[Blank]\\nAliens\",\n          \"Aliens\",\n          \"Batman Begins\\nThe Avengers\",\n          \"Toy Story 2\\nAvatar\\nThe Avengers\",\n        ],\n      );\n    });\n\n    it(\"should sort consistently when column contains AltText\", async function() {\n      // Enter an invalid reference in Favorite Film.\n      await gu.getCell(\"Favorite Film\", 4, \"Friends record\").doClick();\n      await gu.sendKeys(\"Aliens 4\", Key.ENTER, Key.ENTER);\n      await gu.waitForServer();\n\n      // Check that the updated sort order is correct.\n      // Accept '[u\\'Aliens 4\\']' as a py2 variant of '[\\'Aliens 4\\']'\n      const variant = await gu.getCell(\"Favorite Film\", 1, \"Friends record\").getText();\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Favorite Film\", [1, 2, 3, 4, 5, 6], \"Friends record\"),\n        [\n          variant.startsWith(\"[u\") ? \"[u'Aliens 4']\" : \"['Aliens 4']\",\n          \"\",\n          \"[Blank]\",\n          \"[Blank]\\nAliens\",\n          \"Batman Begins\\nThe Avengers\",\n          \"Toy Story 2\\nAvatar\\nThe Avengers\",\n        ],\n      );\n    });\n  });\n\n  describe(\"autocomplete\", function() {\n    const getACOptions = stackWrapFunc(async (limit?: number) => {\n      await driver.findWait(\".test-ref-editor-item\", 1000);\n      return (await driver.findAll(\".test-ref-editor-item\", el => el.getText())).slice(0, limit);\n    });\n\n    before(async function() {\n      await session.tempDoc(cleanup, \"Ref-List-AC-Test.grist\");\n      await gu.toggleSidePanel(\"right\", \"close\");\n    });\n\n    afterEach(() => gu.checkForErrors());\n\n    it(\"should render first items when opening empty cell\", async function() {\n      await driver.sendKeys(Key.HOME);\n\n      let cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 4 }).doClick();\n      assert.equal(await cell.getText(), \"\");\n      await driver.sendKeys(Key.ENTER);\n      // Check the first few items.\n      assert.deepEqual(await getACOptions(3), [\"Alice Blue\", \"Añil\", \"Aqua\"]);\n      // No item is selected.\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n      await driver.sendKeys(Key.ESCAPE);\n\n      cell = await gu.getCell({ section: \"References\", col: \"Schools\", rowNum: 6 }).doClick();\n      assert.equal(await cell.getText(), \"\");\n      await driver.sendKeys(Key.ENTER);\n      // Check the first few items; should be sorted alphabetically.\n      assert.deepEqual(await getACOptions(3),\n        [\"2 SCHOOL\", \"4 SCHOOL\", \"47 AMER SIGN LANG & ENG LOWER \"]);\n      // No item is selected.\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n      await driver.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"should save correct item on click\", async function() {\n      await driver.sendKeys(Key.HOME);\n\n      // Edit a cell by double-clicking.\n      let cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 2 }).doClick();\n      await driver.withActions(a => a.doubleClick(cell));\n\n      // Scroll to another item and click it.\n      await gu.sendKeys(\"ro\");\n      let item = driver.findContent(\".test-ref-editor-item\", \"Rosy Brown\");\n      await gu.scrollIntoView(item);\n      await item.click();\n\n      // It should get added; and undo should revert adding it.\n      assert.deepEqual(\n        await driver.findAll(\".cell_editor .test-tokenfield .test-tokenfield-token\", el => el.getText()),\n        [\"Red\", \"Rosy Brown\"],\n      );\n      await gu.sendKeys(Key.chord(await gu.modKey(), \"z\"));\n      assert.deepEqual(\n        await driver.findAll(\".cell_editor .test-tokenfield .test-tokenfield-token\", el => el.getText()),\n        [\"Red\"],\n      );\n      await gu.sendKeys(Key.ESCAPE);\n      assert.equal(await cell.getText(), \"Red\");\n\n      // Edit another cell by starting to type.\n      cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 4 }).doClick();\n      await driver.sendKeys(\"gr\");\n      await driver.findWait(\".test-ref-editor-item\", 1000);\n      item = driver.findContent(\".test-ref-editor-item\", \"Medium Sea Green\");\n      await gu.scrollIntoView(item);\n      await item.click();\n      await gu.sendKeys(Key.ENTER);\n\n      // It should get saved; and undo should restore the previous value.\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Medium Sea Green\");\n      await gu.undo();\n      assert.equal(await cell.getText(), \"\");\n    });\n\n    it(\"should save correct item after selecting with arrow keys\", async function() {\n      // Same as the previous test, but instead of clicking items, select item using arrow keys.\n\n      // Edit a cell by double-clicking.\n      let cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 2 }).doClick();\n      await driver.withActions(a => a.doubleClick(cell));\n\n      // Move to another item and hit Enter\n      await gu.sendKeys(\"pa\");\n      await driver.sendKeys(Key.DOWN, Key.DOWN, Key.DOWN);\n      assert.equal(await driver.findWait(\".test-ref-editor-item.selected\", 1000).getText(), \"Pale Violet Red\");\n      await driver.sendKeys(Key.ENTER);\n\n      // It should get added; and undo should revert adding it.\n      assert.deepEqual(\n        await driver.findAll(\".cell_editor .test-tokenfield .test-tokenfield-token\", el => el.getText()),\n        [\"Red\", \"Pale Violet Red\"],\n      );\n      await gu.sendKeys(Key.chord(await gu.modKey(), \"z\"));\n      assert.deepEqual(\n        await driver.findAll(\".cell_editor .test-tokenfield .test-tokenfield-token\", el => el.getText()),\n        [\"Red\"],\n      );\n      await gu.sendKeys(Key.ESCAPE);\n      assert.equal(await cell.getText(), \"Red\");\n\n      // Edit another cell by starting to type.\n      cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 4 }).doClick();\n      await driver.sendKeys(\"gr\");\n      await driver.findWait(\".test-ref-editor-item\", 1000);\n      await driver.sendKeys(Key.UP, Key.UP, Key.UP, Key.UP, Key.UP);\n      assert.equal(await driver.findWait(\".test-ref-editor-item.selected\", 1000).getText(), \"Chocolate\");\n      await driver.sendKeys(Key.ENTER, Key.ENTER);\n\n      // It should get saved; and undo should restore the previous value.\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Chocolate\");\n      await gu.undo();\n      assert.equal(await cell.getText(), \"\");\n    });\n\n    it(\"should return to text-as-typed when nothing is selected\", async function() {\n      const cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 2 }).doClick();\n      await driver.sendKeys(\"da\");\n      assert.deepEqual(await getACOptions(2), [\"Dark Blue\", \"Dark Cyan\"]);\n\n      // Check that the first item is highlighted by default.\n      assert.equal(await driver.find(\".cell_editor .test-tokenfield .test-tokenfield-input\").value(), \"da\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").getText(), \"Dark Blue\");\n\n      // Select second item. Both the textbox and the dropdown show the selection.\n      await driver.sendKeys(Key.DOWN);\n      assert.equal(await driver.find(\".cell_editor .test-tokenfield .test-tokenfield-input\").value(), \"Dark Cyan\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").getText(), \"Dark Cyan\");\n\n      // Move back to no-selection state.\n      await driver.sendKeys(Key.UP, Key.UP);\n      assert.equal(await driver.find(\".cell_editor .test-tokenfield .test-tokenfield-input\").value(), \"da\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n\n      // Clear the typed-in text temporarily. Something changed in a recent version of Chrome,\n      // causing the wrong item to be moused over below when the \"Add New\" option is visible.\n      await driver.sendKeys(Key.BACK_SPACE, Key.BACK_SPACE);\n\n      // Mouse over an item.\n      await driver.findContent(\".test-ref-editor-item\", /Dark Gray/).mouseMove();\n      assert.equal(await driver.find(\".cell_editor .test-tokenfield .test-tokenfield-input\").value(), \"Dark Gray\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").getText(), \"Dark Gray\");\n\n      // Mouse back out of the dropdown\n      await driver.find(\".cell_editor .test-tokenfield .test-tokenfield-input\").mouseMove();\n      assert.equal(await driver.find(\".cell_editor .test-tokenfield .test-tokenfield-input\").value(), \"\");\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n\n      // Re-enter the typed-in text and click away. Check the cell is now empty since\n      // no reference items were added.\n      await driver.sendKeys(\"da\", Key.UP);\n      await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 1 }).doClick();\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"\");\n      assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), false);\n\n      await gu.undo();\n      assert.equal(await cell.getText(), \"Red\");\n      assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), false);\n    });\n\n    it(\"should save text as typed when nothing is selected\", async function() {\n      const cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 1 }).doClick();\n      await driver.sendKeys(\"lavender \", Key.ENTER, Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"Lavender\");\n      await gu.undo();\n      assert.equal(await cell.getText(), \"Dark Slate Blue\");\n    });\n\n    it(\"should offer an add-new option when no good match\", async function() {\n      const cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 2 }).doClick();\n      await driver.sendKeys(\"pinkish\");\n      // There are inexact matches.\n      assert.deepEqual(await getACOptions(3),\n        [\"Pink\", \"Deep Pink\", \"Hot Pink\"]);\n      // Nothing is selected, and the \"add new\" item is present.\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n      assert.equal(await driver.find(\".test-ref-editor-new-item\").getText(), \"pinkish\");\n\n      // Click the \"add new\" item. The new value should be added, and should not appear invalid.\n      await driver.find(\".test-ref-editor-new-item\").click();\n      assert.deepEqual(\n        await driver.findAll(\".cell_editor .test-tokenfield .test-tokenfield-token\", el => el.getText()),\n        [\"pinkish\"],\n      );\n      assert.deepEqual(\n        await driver.findAll(\n          \".cell_editor .test-tokenfield .test-tokenfield-token\",\n          el => el.matches(\"[class*=-invalid]\"),\n        ),\n        [false],\n      );\n\n      // Add another new item (with the keyboard), and check that it also appears correctly.\n      await driver.sendKeys(\"almost pink\", Key.ARROW_UP, Key.ENTER);\n      assert.deepEqual(\n        await driver.findAll(\".cell_editor .test-tokenfield .test-tokenfield-token\", el => el.getText()),\n        [\"pinkish\", \"almost pink\"],\n      );\n      assert.deepEqual(\n        await driver.findAll(\n          \".cell_editor .test-tokenfield .test-tokenfield-token\",\n          el => el.matches(\"[class*=-invalid]\"),\n        ),\n        [false, false],\n      );\n\n      // Save the changes to the cell.\n      await gu.sendKeys(Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"pinkish\\nalmost pink\");\n\n      // Check that the referenced table now has \"pinkish\" and \"almost pink\".\n      await driver.findContentWait(\".test-treeview-itemHeader\", /Colors/, 2000).click();\n      await gu.waitForDocToLoad();\n      await gu.sendKeys(Key.chord(await gu.modKey(), Key.ARROW_DOWN));\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"Color Name\", [146, 147]),\n        [\"pinkish\", \"almost pink\"],\n      );\n      assert.deepEqual(\n        await gu.getVisibleGridCells(\"C2\", [146, 147]),\n        [\"pinkish\", \"almost pink\"],\n      );\n\n      // Requires 2 undos, because adding the \"pinkish\" and \"almost pink\" records is a separate action. TODO these\n      // actions should be bundled.\n      await gu.undo(2);\n      assert.equal(await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 2 }).getText(), \"Red\");\n    });\n\n    it(\"should not offer an add-new option when target is a formula\", async function() {\n      // Click on an alt-text cell.\n      const cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 3 }).doClick();\n      assert.equal(await cell.getText(), \"hello\");\n      assert.equal(await cell.find(\".field_clip\").matches(\".invalid\"), true);\n\n      await driver.sendKeys(Key.ENTER, \"hello\");\n      assert.equal(await driver.find(\".test-ref-editor-new-item\").getText(), \"hello\");\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Change the visible column to the formula column \"C2\".\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await driver.find(\".test-right-tab-field\").click();\n      await driver.find(\".test-fbuilder-ref-col-select\").click();\n      await gu.findOpenMenuItem(\".test-select-row\", /C2/).click();\n      await gu.waitForServer();\n\n      // Check that for the same cell, the dropdown no longer has an \"add new\" option.\n      await cell.click();\n      await driver.sendKeys(Key.ENTER, \"hello\");\n      await driver.findWait(\".test-ref-editor-item\", 1000);\n      assert.equal(await driver.find(\".test-ref-editor-item.selected\").isPresent(), false);\n      assert.equal(await driver.find(\".test-ref-editor-new-item\").isPresent(), false);\n      await driver.sendKeys(Key.ESCAPE);\n\n      await gu.undo();\n      await gu.toggleSidePanel(\"right\", \"close\");\n    });\n\n    it(\"should offer items ordered by best match\", async function() {\n      let cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 1 }).doClick();\n      assert.equal(await cell.getText(), \"Dark Slate Blue\");\n      await driver.sendKeys(Key.ENTER, \"Dark Slate Blue\");\n      assert.deepEqual(await getACOptions(4),\n        [\"Dark Slate Blue\", \"Dark Slate Gray\", \"Slate Blue\", \"Medium Slate Blue\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Starting to type Añil with the accent\n      await driver.sendKeys(\"añ\");\n      assert.deepEqual(await getACOptions(2),\n        [\"Añil\", \"Alice Blue\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Starting to type Añil without the accent should work too\n      await driver.sendKeys(\"an\");\n      assert.deepEqual(await getACOptions(2),\n        [\"Añil\", \"Alice Blue\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      await driver.sendKeys(\"blac\");\n      assert.deepEqual(await getACOptions(6),\n        [\"Black\", \"Blanched Almond\", \"Blue\", \"Blue Violet\", \"Alice Blue\", \"Cadet Blue\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 3 }).doClick();\n      assert.equal(await cell.getText(), \"hello\");    // Alt-text\n      await driver.sendKeys(\"hello\");\n      assert.deepEqual(await getACOptions(2),\n        [\"Honeydew\", \"Hot Pink\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      cell = await gu.getCell({ section: \"References\", col: \"ColorCodes\", rowNum: 2 }).doClick();\n      assert.equal(await cell.getText(), \"#808080\");\n      await driver.sendKeys(\"#808080\");\n      assert.deepEqual(await getACOptions(5),\n        [\"#808080\", \"#808000\", \"#800000\", \"#800080\", \"#87CEEB\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      cell = await gu.getCell({ section: \"References\", col: \"XNums\", rowNum: 2 }).doClick();\n      assert.equal(await cell.getText(), \"2019-04-29\");\n      await driver.sendKeys(\"2019-04-29\");\n      assert.deepEqual(await getACOptions(4),\n        [\"2019-04-29\", \"2020-04-29\", \"2019-11-05\", \"2020-04-28\"]);\n      await driver.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"should update choices as user types into textbox\", async function() {\n      let cell = await gu.getCell({ section: \"References\", col: \"Schools\", rowNum: 1 });\n      await gu.clickReferenceListCell(cell);\n      assert.equal(await cell.getText(), \"TECHNOLOGY, ARTS AND SCIENCES STUDIO\");\n      await driver.sendKeys(\"TECHNOLOGY, ARTS AND SCIENCES STUDIO\");\n      assert.deepEqual(await getACOptions(3), [\n        \"TECHNOLOGY, ARTS AND SCIENCES STUDIO\",\n        \"SCIENCE AND TECHNOLOGY ACADEMY\",\n        \"SCHOOL OF SCIENCE AND TECHNOLOGY\",\n      ]);\n      await driver.sendKeys(Key.ESCAPE);\n      cell = await gu.getCell({ section: \"References\", col: \"Schools\", rowNum: 2 });\n      await gu.clickReferenceListCell(cell);\n      await driver.sendKeys(\"stuy\");\n      assert.deepEqual(await getACOptions(3), [\n        \"STUYVESANT HIGH SCHOOL\",\n        \"BEDFORD STUY COLLEGIATE CHARTER SCH\",\n        \"BEDFORD STUY NEW BEGINNINGS CHARTER\",\n      ]);\n      await driver.sendKeys(Key.BACK_SPACE);\n      assert.deepEqual(await getACOptions(3), [\n        \"STUART M TOWNSEND MIDDLE SCHOOL\",\n        \"STUDIO SCHOOL (THE)\",\n        \"STUYVESANT HIGH SCHOOL\",\n      ]);\n      await driver.sendKeys(\" bre\");\n      assert.equal(await driver.find(\".cell_editor .test-tokenfield .test-tokenfield-input\").value(), \"stu bre\");\n      assert.deepEqual(await getACOptions(3), [\n        \"ST BRENDAN SCHOOL\",\n        \"BRONX STUDIO SCHOOL-WRITERS-ARTISTS\",\n        \"BROOKLYN STUDIO SECONDARY SCHOOL\",\n      ]);\n\n      await driver.sendKeys(Key.DOWN, Key.ENTER, Key.ENTER);\n      await gu.waitForServer();\n      assert.equal(await cell.getText(), \"ST BRENDAN SCHOOL\");\n      await gu.undo();\n      assert.equal(await cell.getText(), \"\");\n    });\n\n    it(\"should highlight matching parts of items\", async function() {\n      await driver.sendKeys(Key.HOME);\n\n      let cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 2 });\n      await gu.clickReferenceListCell(cell);\n      assert.equal(await cell.getText(), \"Red\");\n      await driver.sendKeys(Key.ENTER, \"Red\");\n      await driver.findWait(\".test-ref-editor-item\", 1000);\n      assert.deepEqual(\n        await driver.findContent(\".test-ref-editor-item\", /Dark Red/).findAll(\"span\", e => e.getText()),\n        [\"Red\"]);\n      assert.deepEqual(\n        await driver.findContent(\".test-ref-editor-item\", /Rebecca Purple/).findAll(\"span\", e => e.getText()),\n        [\"Re\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      cell = await gu.getCell({ section: \"References\", col: \"Schools\", rowNum: 1 });\n      await gu.clickReferenceListCell(cell);\n      await driver.sendKeys(\"br tech\");\n      assert.deepEqual(\n        await driver.findContentWait(\".test-ref-editor-item\", /BROOKLYN TECH/, 1000).findAll(\"span\", e => e.getText()),\n        [\"BR\", \"TECH\"]);\n      assert.deepEqual(\n        await driver.findContent(\".test-ref-editor-item\", /BUFFALO.*TECHNOLOGY/).findAll(\"span\", e => e.getText()),\n        [\"B\", \"TECH\"]);\n      assert.deepEqual(\n        await driver.findContent(\".test-ref-editor-item\", /ENERGY TECH/).findAll(\"span\", e => e.getText()),\n        [\"TECH\"]);\n      await driver.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"should reflect changes to the target column\", async function() {\n      await driver.sendKeys(Key.HOME);\n\n      const cell = await gu.getCell({ section: \"References\", col: \"Colors\", rowNum: 4 });\n      await gu.clickReferenceListCell(cell);\n      assert.equal(await cell.getText(), \"\");\n      await driver.sendKeys(Key.ENTER);\n      assert.deepEqual(await getACOptions(2), [\"Alice Blue\", \"Añil\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Change a color\n      await gu.clickReferenceListCell(await gu.getCell({ section: \"Colors\", col: \"Color Name\", rowNum: 1 }));\n      await driver.sendKeys(\"HAZELNUT\", Key.ENTER, Key.ENTER);\n      await gu.waitForServer();\n\n      // See that the old value is gone from the autocomplete, and the new one is present.\n      await gu.clickReferenceListCell(cell);\n      await driver.sendKeys(Key.ENTER);\n      assert.deepEqual(await getACOptions(2), [\"Añil\", \"Aqua\"]);\n      await driver.sendKeys(\"H\");\n      assert.deepEqual(await getACOptions(2), [\"HAZELNUT\", \"Honeydew\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Delete a row.\n      await gu.clickReferenceListCell(await gu.getCell({ section: \"Colors\", col: \"Color Name\", rowNum: 1 }));\n      await gu.removeRow(1);\n\n      // See that the value is gone from the autocomplete.\n      await gu.clickReferenceListCell(cell);\n      await driver.sendKeys(\"H\");\n      assert.deepEqual(await getACOptions(2), [\"Honeydew\", \"Hot Pink\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Add a row.\n      await gu.getCell({ section: \"Colors\", col: \"Color Name\", rowNum: 1 }).doClick();\n      await driver.find(\"body\").sendKeys(Key.chord(await gu.modKey(), Key.ENTER));\n      await gu.waitForServer();\n      await driver.sendKeys(\"HELIOTROPE\", Key.ENTER);\n      await gu.waitForServer();\n\n      // See that the new value is visible in the autocomplete.\n      await gu.clickReferenceListCell(cell);\n      await driver.sendKeys(\"H\");\n      assert.deepEqual(await getACOptions(2), [\"HELIOTROPE\", \"Honeydew\"]);\n      await driver.sendKeys(Key.BACK_SPACE);\n      assert.deepEqual(await getACOptions(2), [\"Añil\", \"Aqua\"]);\n      await driver.sendKeys(Key.ESCAPE);\n\n      // Undo all the changes.\n      await gu.undo(4);\n\n      await gu.clickReferenceListCell(cell);\n      await driver.sendKeys(\"H\");\n      assert.deepEqual(await getACOptions(2), [\"Honeydew\", \"Hot Pink\"]);\n      await driver.sendKeys(Key.BACK_SPACE);\n      assert.deepEqual(await getACOptions(2), [\"Alice Blue\", \"Añil\"]);\n      await driver.sendKeys(Key.ESCAPE);\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/RegionFocusSwitcher.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { describe } from \"mocha\";\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\n// Check that the focus is on the clipboard element, with a short wait in case it's not entirely\n// synchronous. You may set waitMs to 0.\nconst expectClipboardFocus = (yesNo: boolean, waitMs: number = 100) => {\n  return gu.waitForFocus(\"textarea.copypaste.mousetrap\", yesNo, waitMs);\n};\n\nconst isNormalElementFocused = async (containerSelector?: string) => {\n  const activeElement = await driver.switchTo().activeElement();\n  const isException = await activeElement.matches(\n    \".test-left-panel, .test-top-header, .test-right-panel, .test-main-content, body, textarea.copypaste.mousetrap\",\n  );\n  const isInContainer = containerSelector ?\n    await activeElement.matches(`${containerSelector} *`) :\n    true;\n  return !isException && isInContainer;\n};\n\n/**\n * tab twice: if we managed to focus things we consider \"normal elements\", we assume we can use tab to navigate\n */\nconst assertTabToNavigate = async (containerSelector?: string) => {\n  await driver.sendKeys(Key.TAB);\n  assert.isTrue(await isNormalElementFocused(containerSelector));\n\n  await driver.sendKeys(Key.TAB);\n  assert.isTrue(await isNormalElementFocused(containerSelector));\n};\n\nconst cycle = async (dir: \"forward\" | \"backward\" = \"forward\") => {\n  const modKey = await gu.modKey();\n  const shortcut = dir === \"forward\" ?\n    Key.chord(modKey, \"o\") :\n    Key.chord(modKey, Key.SHIFT, \"O\");\n\n  await gu.sendKeys(shortcut);\n};\n\nconst toggleCreatorPanelFocus = async () => {\n  const modKey = await gu.modKey();\n  await gu.sendKeys(Key.chord(modKey, Key.ALT, \"o\"));\n};\n\nconst panelMatchs = {\n  left: \".test-left-panel\",\n  top: \".test-top-header\",\n  right: \".test-right-panel\",\n  main: \".test-main-content\",\n};\nconst assertPanelFocus = async (panel: \"left\" | \"top\" | \"right\" | \"main\", expected: boolean = true) => {\n  assert.equal(await gu.hasFocus(panelMatchs[panel]), expected);\n};\n\nconst assertSectionFocus = async (sectionId: number, expected: boolean = true) => {\n  await expectClipboardFocus(expected);\n  assert.equal(await gu.getSectionId() === sectionId, expected);\n};\n\n/**\n * check if we can do a full cycle through regions with nextRegion/prevRegion commands\n *\n * `sections` is the number of view sections currently on the page.\n */\nconst assertCycleThroughRegions = async ({ sections = 1 }: { sections?: number } = {}) => {\n  await cycle();\n  await assertPanelFocus(\"left\");\n\n  await cycle();\n  await assertPanelFocus(\"top\");\n\n  if (sections) {\n    let sectionsCount = 0;\n    while (sectionsCount < sections) {\n      await cycle();\n      await expectClipboardFocus(true);\n      sectionsCount++;\n    }\n  } else {\n    await cycle();\n    await assertPanelFocus(\"main\");\n  }\n\n  await cycle();\n  await assertPanelFocus(\"left\");\n\n  if (sections) {\n    let sectionsCount = 0;\n    while (sectionsCount < sections) {\n      await cycle(\"backward\");\n      await expectClipboardFocus(true);\n      sectionsCount++;\n    }\n  } else {\n    await cycle(\"backward\");\n    await assertPanelFocus(\"main\");\n  }\n\n  await cycle(\"backward\");\n  await assertPanelFocus(\"top\");\n\n  await cycle(\"backward\");\n  await assertPanelFocus(\"left\");\n};\n\ndescribe(\"RegionFocusSwitcher\", function() {\n  this.timeout(60000);\n  const cleanup = setupTestSuite();\n\n  it(\"should tab though elements in non-document pages\", async () => {\n    const session = await gu.session().teamSite.login();\n\n    await session.loadDocMenu(\"/\");\n    await assertTabToNavigate();\n\n    await gu.openProfileSettingsPage();\n    await assertTabToNavigate();\n  });\n\n  it(\"should keep the active section focused at document page load\", async () => {\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"Hello.grist\");\n\n    await expectClipboardFocus(true, 0);\n    assert.equal(await gu.getActiveCell().getText(), \"hello\");\n    await driver.sendKeys(Key.TAB);\n    // after pressing tab once, we should be on the [first row, second column]-cell\n    const secondCellText = await gu.getCell(1, 1).getText();\n    const activeCellText = await gu.getActiveCell().getText();\n    assert.equal(activeCellText, secondCellText);\n    await expectClipboardFocus(true, 0);\n  });\n\n  it(\"should cycle through regions with (Shift+)Ctrl+O\", async () => {\n    const session = await gu.session().teamSite.login();\n    await session.loadDocMenu(\"/\");\n\n    await assertCycleThroughRegions({ sections: 0 });\n\n    await session.tempNewDoc(cleanup);\n    await assertCycleThroughRegions({ sections: 1 });\n\n    await gu.addNewSection(/Card List/, /Table1/);\n    await gu.reloadDoc();\n    await assertCycleThroughRegions({ sections: 2 });\n  });\n\n  it(\"should toggle creator panel with Alt+Ctrl+O\", async () => {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n\n    const firstSectionId = await gu.getSectionId();\n\n    // test if shortcut works with one view section:\n    // press the shortcut two times to focus creator panel, then focus back the view section\n    await toggleCreatorPanelFocus();\n    await assertPanelFocus(\"right\");\n\n    await toggleCreatorPanelFocus();\n    await assertSectionFocus(firstSectionId);\n\n    // add a new section, make sure it's the active section/focus after creation\n    await gu.addNewSection(/Card List/, /Table1/);\n    const secondSectionId = await gu.getSectionId();\n    await assertSectionFocus(secondSectionId);\n\n    // toggle creator panel again: make sure it goes back to the new section\n    await toggleCreatorPanelFocus();\n    await assertPanelFocus(\"right\");\n\n    await toggleCreatorPanelFocus();\n    await assertSectionFocus(secondSectionId);\n\n    // combine with cycle shortcut: when focus is on a panel, toggling creator panel focuses back the current view\n    await cycle();\n    await assertPanelFocus(\"left\");\n\n    await toggleCreatorPanelFocus();\n    await assertPanelFocus(\"right\");\n\n    await toggleCreatorPanelFocus();\n    await assertSectionFocus(secondSectionId);\n\n    // cycle to previous section and make sure all focus is good\n    await cycle(\"backward\");\n    await assertSectionFocus(firstSectionId);\n\n    await toggleCreatorPanelFocus();\n    await assertPanelFocus(\"right\");\n\n    await toggleCreatorPanelFocus();\n    await assertSectionFocus(firstSectionId);\n\n    await toggleCreatorPanelFocus();\n    await assertPanelFocus(\"right\");\n\n    await cycle();\n    await assertSectionFocus(secondSectionId);\n\n    await toggleCreatorPanelFocus();\n    await toggleCreatorPanelFocus();\n    await assertSectionFocus(secondSectionId);\n  });\n\n  it(\"should tab through elements when inside a region\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n\n    await cycle();\n    await assertTabToNavigate(\".test-left-panel\");\n\n    await cycle();\n    await assertTabToNavigate(\".test-top-header\");\n\n    await toggleCreatorPanelFocus();\n    await assertTabToNavigate(\".test-right-panel\");\n\n    await toggleCreatorPanelFocus();\n    await driver.sendKeys(Key.TAB);\n    await expectClipboardFocus(true);\n  });\n\n  it(\"should exit from a region when pressing Esc\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n\n    await cycle();\n    await driver.sendKeys(Key.ESCAPE);\n    await assertPanelFocus(\"left\", false);\n    await expectClipboardFocus(true);\n  });\n\n  it(\"should remember the last focused element in a panel\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n\n    await cycle();\n    await driver.sendKeys(Key.TAB);\n    assert.isTrue(await isNormalElementFocused(\".test-left-panel\"));\n\n    await cycle(); // top\n    await cycle(); // main\n    await cycle(); // back to left\n    assert.isTrue(await isNormalElementFocused(\".test-left-panel\"));\n\n    // when pressing escape in that case, first focus back to the panel…\n    await driver.sendKeys(Key.ESCAPE);\n    await assertPanelFocus(\"left\");\n\n    // … then reset the kb focus as usual\n    await driver.sendKeys(Key.ESCAPE);\n    await expectClipboardFocus(true);\n  });\n\n  it(\"should focus a panel-region when clicking an input child element\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n\n    // Click on an input on the top panel\n    await driver.find(\".test-bc-doc\").click();\n    await driver.sendKeys(Key.TAB);\n    assert.isTrue(await isNormalElementFocused(\".test-top-header\"));\n\n    // in that case (mouse click) when pressing esc, we directly focus back to view section\n    await driver.sendKeys(Key.ESCAPE);\n    await assertPanelFocus(\"top\", false);\n    await expectClipboardFocus(true, 0);\n  });\n\n  it(\"should focus a section-region when clicking on it\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n\n    await cycle(); // left\n    await driver.sendKeys(Key.TAB);\n    assert.isTrue(await isNormalElementFocused(\".test-left-panel\"));\n\n    await gu.getActiveCell().click();\n\n    await assertPanelFocus(\"left\", false);\n    await expectClipboardFocus(true, 0);\n  });\n\n  it(\"should keep the active section focused when clicking a link or button of a panel-region\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n\n    await gu.enterCell(\"test\");\n    await driver.find(\".test-undo\").click();\n    await assertPanelFocus(\"top\", false);\n    await expectClipboardFocus(true, 0);\n  });\n\n  afterEach(() => gu.checkForErrors());\n});\n"
  },
  {
    "path": "test/nbrowser/RemoveTransformColumns.ts",
    "content": "import { DocAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"RemoveTransformColumns\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n\n  let docAPI: DocAPI;\n\n  it(\"should remove transform columns when the doc shuts down\", async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const doc = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"RemoveTransformColumns.grist\", false);\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n\n    assert.deepEqual(await gu.getVisibleGridCells({ col: \"B\", rowNums: [1] }), [\n      \"manualSort, A, B, C, \" +\n      \"gristHelper_Converted, gristHelper_Transform, \" +\n      \"gristHelper_Converted2, gristHelper_Transform2\",\n    ]);\n\n    const userAPI = gu.createHomeApi(\"chimpy\", \"nasa\");\n    await userAPI.applyUserActions(doc.id, [[\"Calculate\"]]);  // finish loading fully\n    await userAPI.getDocAPI(doc.id).forceReload();\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n\n    assert.deepEqual(await gu.getVisibleGridCells({ col: \"B\", rowNums: [1] }), [\n      \"manualSort, A, B, C\",\n    ]);\n\n    await gu.checkForErrors();\n  });\n\n  it(\"should remove temporary tables when the doc shuts down\", async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const doc = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"Hello.grist\", false);\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n\n    const userAPI = gu.createHomeApi(\"chimpy\", \"nasa\");\n    docAPI = userAPI.getDocAPI(doc.id);\n\n    // Create temporary tables and non-matching tables\n    await userAPI.applyUserActions(doc.id, [\n      // Tables that should be removed.\n      [\"AddTable\", \"GristHidden_import1\", [\n        { id: \"A\", type: \"Text\", isFormula: false },\n      ]],\n      [\"AddTable\", \"GristHidden_import2\", [\n        { id: \"B\", type: \"Numeric\", isFormula: false },\n      ]],\n      [\"AddTable\", \"GristHidden_importSuffix\", [\n        { id: \"D\", type: \"Text\", isFormula: false },\n      ]],\n      // Tables that look ok, and won't be removed.\n      [\"AddTable\", \"GristHidden_something\", [\n        { id: \"E\", type: \"Text\", isFormula: false },\n      ]],\n      [\"AddTable\", \"Hidden_import\", [\n        { id: \"F\", type: \"Numeric\", isFormula: false },\n      ]],\n      [\"AddTable\", \"RegularTable\", [\n        { id: \"C\", type: \"Text\", isFormula: false },\n      ]],\n    ]);\n\n    // Verify all tables exist before doc restart\n    const expectedTablesBeforeRestart = [\n      \"GristHidden_import1\", \"GristHidden_import2\", \"GristHidden_importSuffix\",\n      \"GristHidden_something\", \"Hidden_import\", \"RegularTable\", \"Table1\",\n    ];\n    assert.deepEqual(await allTables(), expectedTablesBeforeRestart.sort());\n\n    // Finish loading fully and force reload to trigger document shutdown/restart\n    await userAPI.getDocAPI(doc.id).forceReload();\n    await gu.waitForDocToLoad();\n\n    // Verify only temporary tables with GristHidden_import prefix were removed during shutdown\n    const expectedTablesAfterRestart = [\n      \"GristHidden_something\", \"Hidden_import\", \"RegularTable\", \"Table1\",\n    ];\n    assert.deepEqual(await allTables(), expectedTablesAfterRestart.sort());\n\n    await gu.checkForErrors();\n  });\n\n  it(\"should remove temporary tables after failed import\", async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const doc = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"Hello.grist\", false);\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n\n    const userAPI = gu.createHomeApi(\"chimpy\", \"nasa\");\n    docAPI = userAPI.getDocAPI(doc.id);\n\n    // Start an import to create temporary tables\n    await gu.importFileDialog(\"./uploads/UploadedData1.csv\");\n\n    // Wait for the import dialog to show, indicating temporary tables have been created\n    await driver.findWait(\".test-importer-preview\", 5000);\n\n    // Verify the temporary table exists before we simulate failure\n    assert.equal((await tempTables()).length, 1);\n\n    // Simulate a failure by refreshing the page.\n    await gu.reloadDoc();\n\n    // We still have one temporary table left.\n    assert.equal((await tempTables()).length, 1);\n\n    // Now reload the document and check that the temporary tables are gone.\n    await userAPI.getDocAPI(doc.id).forceReload();\n    await gu.reloadDoc();\n\n    // Verify temporary tables are removed before shutdown.\n    assert.equal((await tempTables()).length, 0);\n  });\n\n  async function allTables() {\n    const rows = await docAPI.getRows(\"_grist_Tables\");\n    return (rows.tableId as string[]).sort();\n  }\n\n  async function tempTables() {\n    return (await allTables()).filter(id =>\n      id && typeof id === \"string\" && id.startsWith(\"GristHidden_import\"),\n    ).sort();\n  }\n});\n"
  },
  {
    "path": "test/nbrowser/RightPanel.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"RightPanel\", function() {\n  this.timeout(60000);\n  const cleanup = setupTestSuite();\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should focus on the creator panel when chart/custom section is added\", async () => {\n    const mainSession = await gu.session().teamSite.login();\n    await mainSession.tempNewDoc(cleanup);\n\n    // Reset prefs.\n    await driver.executeScript(\"resetDismissedPopups();\");\n    await gu.waitForServer();\n\n    // Refresh for a clean start.\n    await gu.reloadDoc();\n\n    // Close panel and make sure it stays closed.\n    await gu.toggleSidePanel(\"right\", \"close\");\n\n    // Add a chart section.\n    await gu.addNewSection(\"Chart\", \"Table1\", { dismissTips: true });\n    assert.isFalse(await gu.isSidePanelOpen(\"right\"));\n    await gu.undo();\n\n    // Add a chart page.\n    await gu.addNewPage(\"Chart\", \"Table1\");\n    assert.isFalse(await gu.isSidePanelOpen(\"right\"));\n    await gu.undo();\n\n    // Add a custom section.\n    await gu.addNewSection(\"Custom\", \"Table1\", { customWidget: /Custom URL/ });\n    assert.isFalse(await gu.isSidePanelOpen(\"right\"));\n    await gu.undo();\n\n    // Add a custom page.\n    await gu.addNewPage(\"Custom\", \"Table1\", { customWidget: /Custom URL/ });\n    assert.isFalse(await gu.isSidePanelOpen(\"right\"));\n    await gu.undo();\n\n    // Now open the panel on the column tab.\n    await gu.openColumnPanel();\n\n    // Add a chart section.\n    await gu.addNewSection(\"Chart\", \"Table1\");\n    assert.isTrue(await gu.isSidePanelOpen(\"right\"));\n    assert.isTrue(await driver.find(\".test-right-widget-title\").isDisplayed());\n    await gu.undo();\n\n    await gu.openColumnPanel();\n\n    // Add a chart page.\n    await gu.addNewPage(\"Chart\", \"Table1\");\n    assert.isTrue(await gu.isSidePanelOpen(\"right\"));\n    assert.isTrue(await driver.find(\".test-right-widget-title\").isDisplayed());\n    await gu.undo();\n\n    await gu.openColumnPanel();\n\n    // Add a custom section.\n    await gu.addNewSection(\"Custom\", \"Table1\", { customWidget: /Custom URL/ });\n    assert.isTrue(await gu.isSidePanelOpen(\"right\"));\n    assert.isTrue(await driver.find(\".test-right-widget-title\").isDisplayed());\n    await gu.undo();\n\n    await gu.openColumnPanel();\n\n    // Add a custom page.\n    await gu.addNewPage(\"Custom\", \"Table1\", { customWidget: /Custom URL/ });\n    assert.isTrue(await gu.isSidePanelOpen(\"right\"));\n    assert.isTrue(await driver.find(\".test-right-widget-title\").isDisplayed());\n    await gu.undo();\n  });\n\n  it(\"should open/close panel, and reflect the current section\", async function() {\n    // Open a document with multiple views and multiple sections.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const doc = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"World.grist\", false);\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`);\n\n    // Check current view and section name.\n    assert.equal(await gu.getActiveSectionTitle(6000), \"CITY\");\n    assert.equal(await driver.find(\".test-bc-page\").getAttribute(\"value\"), \"City\");\n\n    // Open side pane, and check it shows the right section.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-config-widget\").click();\n    assert.equal(await gu.isSidePanelOpen(\"right\"), true);\n\n    assert.equal(await driver.find(\".test-right-widget-title\").value(), \"CITY\");\n\n    // Check that the tab's name reflects suitable text\n    assert.equal(await driver.find(\".test-right-tab-pagewidget\").getText(), \"Table\");\n    assert.equal(await driver.find(\".test-right-tab-field\").getText(), \"Column\");\n\n    // Switch to Field tab, check that it shows the right field.\n    await driver.find(\".test-right-tab-field\").click();\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Name\");\n\n    // Check to a different field, check a different field is shown.\n    await driver.sendKeys(Key.RIGHT);\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Country\");\n\n    // Click to a different section, check a different field is shown.\n    await gu.getSection(\"CITY Card List\").find(\".detail_row_num\").click();\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Name\");\n\n    // Check that the tab's name reflects suitable text\n    assert.equal(await driver.find(\".test-right-tab-pagewidget\").getText(), \"Card List\");\n    assert.equal(await driver.find(\".test-right-tab-field\").getText(), \"Field\");\n\n    // Close panel, check it's hidden.\n    await gu.toggleSidePanel(\"right\");\n    assert.equal(await gu.isSidePanelOpen(\"right\"), false);\n    assert.equal(await driver.find(\".config_item\").isPresent(), false);\n\n    // Reopen panel, check it's still right.\n    await gu.toggleSidePanel(\"right\");\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Name\");\n\n    // Switch to the section tab, check the new section is reflected.\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    assert.equal(await driver.find(\".test-right-widget-title\").value(), \"CITY Card List\");\n\n    // Switch to a different view, check the new section is reflected.\n    await driver.findContent(\".test-treeview-itemHeader\", /Country/).click();\n    assert.equal(await driver.find(\".test-right-widget-title\").value(), \"COUNTRY\");\n\n    // Switch to field tab; check the new field is reflected.\n    await driver.find(\".test-right-tab-field\").click();\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Code\");\n  });\n\n  it(\"should not cause errors when switching pages with Field tab open\", async () => {\n    // There was an error (\"this.calcSize is not a function\") switching between pages when the\n    // active section changes type and Field tab is open, triggered by an unnecessary rebuilding\n    // of FieldConfigTab.\n\n    // Check that the active field tab is called \"Column\" (since the active section is \"Table\")\n    // and is open to column \"Code\".\n    assert.equal(await driver.find(\".test-right-tab-field\").getText(), \"Column\");\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Code\");\n\n    // Switch to the \"City\" page. Check that the tab is now called \"Field\" (since the active section is of\n    // type \"CardList\"), and open to the field \"Name\".\n    await driver.findContent(\".test-treeview-itemHeader\", /City/).click();\n    assert.equal(await driver.find(\".test-right-tab-field\").getText(), \"Field\");\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Name\");\n\n    // Check that this did not cause client-side errors.\n    await gu.checkForErrors();\n\n    // Now switch back, and check for errors again.\n    await driver.findContent(\".test-treeview-itemHeader\", /Country/).click();\n    await gu.checkForErrors();\n  });\n\n  it(\"should show tools when requested\", async function() {\n    // Select specific view/section/field. Close side-pane.\n    await gu.getCell({ col: \"Name\", rowNum: 3 }).click();\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Name\");\n    await gu.toggleSidePanel(\"right\");\n    assert.equal(await gu.isSidePanelOpen(\"right\"), false);\n\n    // Click Activity Log.\n    assert.equal(await driver.find(\".action_log\").isPresent(), false);\n    await driver.find(\".test-tools-log\").click();\n    await gu.waitToPass(() =>   // Click might not work while panel is sliding out to open.\n      driver.findContentWait(\".test-doc-history-tabs .test-select-button\", \"Activity\", 500).click());\n\n    // Check that panel is shown, and correct.\n    assert.equal(await gu.isSidePanelOpen(\"right\"), true);\n    assert.equal(await driver.find(\".test-right-tab-field\").isPresent(), false);\n    assert.equal(await driver.find(\".action_log\").isDisplayed(), true);\n\n    // Click \"x\", Check expected section config shown.\n    await driver.find(\".test-right-tool-close\").click();\n    assert.equal(await driver.find(\".test-right-tab-field\").getText(), \"Column\");\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Name\");\n\n    // TODO: polish data validation and then uncomment\n    /*\n    // Click Validations. Check it's shown and correct.\n    await driver.find('.test-tools-validate').click();\n    assert.equal(await driver.findContent('.config_item', /Validations/).isDisplayed(), true);\n\n    // Close panel. Switch to another view.\n    await gu.toggleSidePanel('right');\n    assert.equal(await gu.isSidePanelOpen('right'), false);\n    assert.equal(await driver.findContent('.config_item', /Validations/).isPresent(), false);\n    await driver.findContent('.test-treeview-itemHeader', /Country/).click();\n\n    // Open panel. Check Validations are still shown.\n    await gu.toggleSidePanel('right');\n    assert.equal(await driver.findContent('.config_item', /Validations/).isDisplayed(), true);\n    await driver.find('.test-right-tool-close').click();\n    */\n  });\n\n  it(\"should keep panel state on reload\", async function() {\n    // Check the panel is currently open and showing Field options.\n    assert.equal(await gu.isSidePanelOpen(\"right\"), true);\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Name\");\n\n    // Reload the page, and click the same cell as before.\n    await driver.navigate().refresh();\n    assert.equal(await gu.getActiveSectionTitle(3000), \"COUNTRY\");\n    await gu.waitForServer();\n    await gu.getCell({ col: \"Name\", rowNum: 3 }).click();\n\n    // Check the panel is still open and showing the same Field options.\n    assert.equal(await gu.isSidePanelOpen(\"right\"), true);\n    assert.equal(await driver.find(\".test-field-label\").value(), \"Name\");\n  });\n\n  it(\"'SELECTOR FOR' should work correctly\", async function() {\n    // open the Data tab\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await driver.find(\".test-config-data\").click();\n\n    // open a page that has linked section\n    await driver.findContent(\".test-treeview-itemHeader\", /Country/).click();\n\n    // wait for data to load\n    assert(await gu.getActiveSectionTitle(3000));\n    await gu.waitForServer();\n\n    // select a view section that does not select other section\n    await gu.getSection(\"COUNTRY Card List\").click();\n\n    // check that selector-for is not present\n    assert.equal(await driver.find(\".test-selector-for\").isPresent(), false);\n\n    // select a view section that does select other section\n    await gu.getSection(\"COUNTRY\").click();\n\n    // check that selector-of is present and that all selected section are listed\n    assert.equal(await driver.find(\".test-selector-for\").isPresent(), true);\n    assert.deepEqual(await driver.findAll(\".test-selector-for-entry\", e => e.getText().then(s => s.split(\"\\n\")[0])), [\n      \"CITY\",\n      \"COUNTRYLANGUAGE\",\n      \"COUNTRY Card List\",\n    ]);\n  });\n\n  it(\"'Edit Data Selection' should allow to change link\", async () => {\n    // select COUNTRY DETAIL\n    await gu.getSection(\"CITY\").click();\n\n    // open page widget picker\n    await driver.find(\".test-pwc-editDataSelection\").click();\n\n    // remove link\n    await driver.find(\".test-wselect-selectby\").doClick();\n    await driver.findContent(\".test-wselect-selectby option\", /Select widget/).doClick();\n\n    // click save\n    await driver.find(\".test-wselect-addBtn\").doClick();\n    await gu.waitForServer();\n\n    // Go to the first record.\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.UP));\n\n    // Check that link was removed, by going to Aruba.\n    await gu.getSection(\"COUNTRY\").click();\n    await gu.getCell(0, 1).click();\n    // City section should stay where it was\n    assert.equal(await gu.getCell(0, 1, \"CITY\").getText(), \"Kabul\");\n\n    // re-set the link\n    await gu.getSection(\"CITY\").click();\n    await driver.find(\".test-pwc-editDataSelection\").click();\n    await driver.find(\".test-wselect-selectby\").click();\n    await driver.findContent(\".test-wselect-selectby option\", /Country$/).click();\n    await driver.find(\".test-wselect-addBtn\").doClick();\n    await gu.waitForServer();\n\n    // check link is set\n    await gu.getSection(\"COUNTRY\").click();\n    await gu.getCell(0, 1).click();\n    assert.equal(await gu.getCell(0, 1, \"CITY\").getText(), \"Oranjestad\");\n  });\n\n  it(\"should not cause errors when switching pages with Table tab open\", async () => {\n    // There were an error doing eigher one of 1) switching to `Code View`, or 2) removing the\n    // active page, when the Table tab was open, because: both caused the activeView to be set to an\n    // empty model causes some computed property of the ViewSectionRec to fail. This is what this\n    // test is aiming at catching.\n\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-config-widget\").click();\n\n    assert.deepEqual(await gu.getPageNames(), [\"City\", \"Country\", \"CountryLanguage\"]);\n\n    // adds a new page\n    await gu.addNewPage(/Table/, /City/);\n\n    assert.deepEqual(await gu.getPageNames(), [\"City\", \"Country\", \"CountryLanguage\", \"New page\"]);\n\n    // remove that page\n    await gu.openPageMenu(/New page/);\n    await driver.find(\".grist-floating-menu .test-docpage-remove\").click();\n    await gu.waitForServer();\n\n    // check pages were removed and nothing break\n    assert.deepEqual(await gu.getPageNames(), [\"City\", \"Country\", \"CountryLanguage\"]);\n    await gu.checkForErrors();\n\n    // now switch to `Code View`\n    await driver.find(\".test-tools-code\").click();\n    assert.equal(await driver.findWait(\".g-code-viewer\", 1000).isPresent(), true);\n\n    // check nothing broke\n    await gu.checkForErrors();\n\n    // switch back to City\n    await gu.getPageItem(/City/).click();\n  });\n\n  it(\"should not cause errors when editing summary table with `Change Widget` button\", async () => {\n    // Changing the grouped by columns using the `Change Widget` used to throw `TypeError: Cannot\n    // read property `toUpperCase` of undefined`. The goal of this test is to prevent future\n    // regression.\n\n    // Create a summary table of City groupbed by country\n    await gu.addNewPage(/Table/, /City/, { summarize: [/Country/] });\n\n    // open right panel Widget\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await driver.find(\".test-config-widget\").click();\n\n    // click `Change widget`\n    await driver.findContent(\".test-right-panel button\", /Change widget/).click();\n\n    // remove column `Country` and save\n    await gu.selectWidget(/Table/, /City/, { summarize: [] });\n\n    // check there were no error\n    await gu.checkForErrors();\n  });\n\n  it(\"should not raise errors when opening with table's `Widget Options`\", async function() {\n    // Open right panel and select 'Column'\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n\n    // Close the right panel\n    await gu.toggleSidePanel(\"right\", \"close\");\n\n    // Open the right panel using the table's `Widget option`\n    await gu.openSectionMenu(\"viewLayout\");\n    await driver.find(\".test-widget-options\").click();\n\n    await gu.checkForErrors();\n  });\n\n  it(\"should allow keyboard navigation through main tabs\", async function() {\n    await gu.toggleCreatorPanelFocus();\n\n    // Here, focus should be on the creator panel wrapping dom element.\n    // After pressing tab once, focus should be on the first tab, and only its tabcontent should be visible\n    await gu.sendKeys(Key.TAB);\n    assert.isTrue(await gu.hasFocus(\".test-right-tab-pagewidget\"));\n    await driver.find('.test-right-tabpanel-pagewidget[aria-hidden=\"false\"]');\n    await driver.find('.test-right-tabpanel-field[aria-hidden=\"true\"]');\n\n    // pressing an arrow key should change current tab\n    await gu.sendKeys(Key.ARROW_RIGHT);\n    assert.isTrue(await gu.hasFocus(\".test-right-tab-field\"));\n    await driver.find('.test-right-tabpanel-pagewidget[aria-hidden=\"true\"]');\n    await driver.find('.test-right-tabpanel-field[aria-hidden=\"false\"]');\n\n    // test remaining arrow keys: left, then left again to test if looping works\n    await gu.sendKeys(Key.ARROW_LEFT);\n    assert.isTrue(await gu.hasFocus(\".test-right-tab-pagewidget\"));\n    await driver.find('.test-right-tabpanel-pagewidget[aria-hidden=\"false\"]');\n    await driver.find('.test-right-tabpanel-field[aria-hidden=\"true\"]');\n\n    await gu.sendKeys(Key.ARROW_LEFT);\n    assert.isTrue(await gu.hasFocus(\".test-right-tab-field\"));\n    await driver.find('.test-right-tabpanel-pagewidget[aria-hidden=\"true\"]');\n    await driver.find('.test-right-tabpanel-field[aria-hidden=\"false\"]');\n\n    // go back on the widget tab\n    await gu.sendKeys(Key.ARROW_LEFT);\n  });\n\n  it(\"should allow keyboard navigation through subtabs\", async function() {\n    // pressing Tab now should focus the things after the main tabs: the subtabs\n    await gu.sendKeys(Key.TAB);\n\n    assert.isTrue(await gu.hasFocus(\".test-config-widget\"));\n    await driver.find('.test-right-subtabpanel-widget[aria-hidden=\"false\"]');\n    await driver.find('.test-right-subtabpanel-sortAndFilter[aria-hidden=\"true\"]');\n    await driver.find('.test-right-subtabpanel-data[aria-hidden=\"true\"]');\n\n    // arrow key should behave like the main tabs previously tested\n    await gu.sendKeys(Key.ARROW_RIGHT);\n    assert.isTrue(await gu.hasFocus(\".test-config-sortAndFilter\"));\n    await driver.find('.test-right-subtabpanel-widget[aria-hidden=\"true\"]');\n    await driver.find('.test-right-subtabpanel-sortAndFilter[aria-hidden=\"false\"]');\n    await driver.find('.test-right-subtabpanel-data[aria-hidden=\"true\"]');\n\n    // pressing Tab should focus the next thing after subtabs\n    await gu.sendKeys(Key.TAB);\n    assert.isTrue(await gu.hasFocus(\".test-sort-config-add\"));\n\n    // click on the first cell on the grid: make sure keyboard nav is reset\n    await gu.getCell({ col: 0, rowNum: 1 }).click();\n    await gu.sendKeys(Key.TAB);\n    await gu.waitAppFocus();\n  });\n\n  it(\"should not disable tabbing in active widget when clicking on tabs\", async function() {\n    // the keyboard-handling code is a bit tricky; we might end up creating buttons that wrongfully\n    // stay as document.activeElement after clicking on them. Verify that doesn't happen with the tabs or subtabs.\n    await driver.find(\".test-config-widget\").click();\n    await gu.sendKeys(Key.TAB);\n    await gu.waitAppFocus();\n\n    await driver.find(\".test-right-tab-field\").click();\n    await gu.sendKeys(Key.TAB);\n    await gu.waitAppFocus();\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/RightPanelSelectBy.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { addToRepl, assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"RightPanelSelectBy\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n  addToRepl(\"gu2\", gu);\n\n  async function setup() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const doc = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"Favorite_Films_With_Linked_Ref.grist\", false);\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n  }\n\n  it(\"should allow linking section with same table\", async function() {\n    await setup();\n\n    // open page `All`\n    await driver.findContentWait(\".test-treeview-itemHeader\", /All/, 2000).click();\n    await gu.waitForDocToLoad();\n\n    await gu.openSelectByForSection(\"PERFORMANCES DETAIL\");\n\n    // the dollar in /...record$/ makes sure we match against the table main node and not a ref\n    // columns such as '...record.Film'\n    assert.equal(await driver.findContent(\".test-select-row\", /Performances record$/).isPresent(), true);\n  });\n\n  it(\"should not allow linking same section\", async function() {\n    assert.equal(await driver.findContent(\".test-select-row\", /Performances detail/i).isPresent(), false);\n  });\n\n  it(\"should allow linking to/from a ref column\", async function() {\n    // Performance record.Film links both from a ref column and to a ref column\n    assert.equal(await driver.findContent(\".test-select-row\", /Performances record.*Film/i).isPresent(), true);\n  });\n\n  it(\"should successfully link on select\", async function() {\n    // select a link\n    await gu.findOpenMenuItem(\".test-select-row\", /Performances record$/).click();\n    await gu.waitForServer();\n\n    // Check that selections in 1st section are mirrored by the 2nd section.\n    await gu.getSection(\"PERFORMANCES RECORD\").click();\n    await gu.getCell(0, 3).click();\n    assert.equal(await driver.find(\".g_record_detail_value\").getText(), \"Don Rickles\");\n  });\n\n  it(\"should allow to remove link\", async function() {\n    await gu.openSelectByForSection(\"PERFORMANCES DETAIL\");\n    await gu.findOpenMenuItem(\".test-select-row\", /Select widget/).click();\n    await gu.waitForServer();\n\n    // Check that selections in 1st section are NOT mirrored by the 2nd section.\n    await gu.getSection(\"PERFORMANCES RECORD\").click();\n    await gu.getCell(0, 1).click();\n    assert.equal(await driver.find(\".g_record_detail_value\").getText(), \"Don Rickles\");\n\n    // undo, link is expected to be set for next test\n    await gu.undo();\n  });\n\n  it(\"should disallow creating cycles if not cursor-linked\", async function() {\n    // Link \"films record\" by \"performances record\"\n    await gu.openSelectByForSection(\"FILMS RECORD\");\n    await gu.findOpenMenuItem(\".test-select-row\", /Performances record.*Film/i).click();\n    await gu.waitForServer();\n\n    // this link should no longer be present, since it would create a cycle with a filter link in it\n    await gu.openSelectByForSection(\"PERFORMANCES RECORD\");\n    assert.equal(await driver.findContent(\".test-select-row\", /Performances record.*Film/i).isPresent(), false);\n  });\n\n  it(\"should allow creating cursor-linked-cycles\", async function() {\n    assert.equal(await driver.findContent(\".test-select-row\", /Performances detail/).isPresent(), true);\n\n    // undo, the operation from the previous test; link is expected to be unset for next test\n    await gu.undo();\n  });\n\n  it(\"should not allow selecting from a chart or custom sections\", async function() {\n    // open the 'Films' page\n    await driver.findContent(\".test-treeview-itemHeader\", /Films/).click();\n    await gu.waitForDocToLoad();\n\n    // Adds a chart widget\n    await gu.addNewSection(/Chart/, /Films/);\n\n    // open `SELECT BY`\n    await gu.openSelectByForSection(\"FILMS\");\n\n    // check that there is a chart and we cannot link from it\n    assert.equal(await gu.getSection(\"FILMS CHART\").isPresent(), true);\n    assert.equal(await driver.findContent(\".test-select-row\", /Films chart/).isPresent(), false);\n\n    // undo\n    await gu.undo();\n  });\n\n  it(\"should update filter-linking tied to reference when value changes\", async function() {\n    // Add a filter-linked section (of Performances) tied to a Ref column (FRIENDS.Favorite_Film).\n    await gu.getPageItem(\"Friends\").click();\n    await gu.waitForServer();\n    await gu.addNewSection(/Table/, /Performances/);\n    await gu.openSelectByForSection(\"Performances\");\n    assert.equal(await driver.findContent(\".test-select-row\", /FRIENDS.*Favorite Film/).isPresent(), true);\n    await gu.findOpenMenuItem(\".test-select-row\", /FRIENDS.*Favorite Film/).click();\n    await gu.waitForServer();\n\n    // Select a row in FRIENDS.\n    const cell = await gu.getCell({ section: \"Friends\", col: \"Favorite Film\", rowNum: 6 });\n    assert.equal(await cell.getText(), \"Alien\");\n    await cell.click();\n\n    // Check that the linked table reflects the selected row.\n    assert.deepEqual(await gu.getVisibleGridCells(\n      { section: \"Performances\", cols: [\"Actor\", \"Film\"], rowNums: [1, 2] }), [\n      \"Sigourney Weaver\", \"Alien\",\n      \"\", \"\",\n    ]);\n\n    // Change a value in FRIENDS.Favorite_Film column.\n    await gu.sendKeys(\"Toy\");\n    await driver.findContent(\".test-ref-editor-item\", /Toy Story/).click();\n    await gu.waitForServer();\n\n    // Check that the linked table of Performances got updated.\n    assert.deepEqual(await gu.getVisibleGridCells(\n      { section: \"Performances\", cols: [\"Actor\", \"Film\"], rowNums: [1, 2, 3, 4] }), [\n      \"Tom Hanks\", \"Toy Story\",\n      \"Tim Allen\", \"Toy Story\",\n      \"Don Rickles\", \"Toy Story\",\n      \"\", \"\",\n    ]);\n\n    await gu.undo(2);\n  });\n\n  it(\"should update cursor-linking tied to reference when value changes\", async function() {\n    // Add a cursor-linked card widget (of Films) tied to a Ref column (FRIENDS.Favorite_Film).\n    await gu.getPageItem(\"Friends\").click();\n    await gu.waitForServer();\n    await gu.addNewSection(/Card/, /Films/);\n    await gu.openSelectByForSection(\"Films Card\");\n    assert.equal(await driver.findContent(\".test-select-row\", /FRIENDS.*Favorite Film/).isPresent(), true);\n    await gu.findOpenMenuItem(\".test-select-row\", /FRIENDS.*Favorite Film/).click();\n    await gu.waitForServer();\n\n    // Select a row in FRIENDS.\n    const cell = await gu.getCell({ section: \"Friends\", col: \"Favorite Film\", rowNum: 6 });\n    assert.equal(await cell.getText(), \"Alien\");\n    await cell.click();\n\n    // Check that the linked card reflects the selected row.\n    assert.equal(await driver.find(\".g_record_detail_value\").getText(), \"Alien\");\n    assert.equal(await driver.findContent(\".g_record_detail_value\", /19/).getText(), \"May 25th, 1979\");\n\n    // Change the value in FRIENDS.Favorite_Film column.\n    await gu.sendKeys(\"Toy\");\n    await driver.findContent(\".test-ref-editor-item\", /Toy Story/).click();\n    await gu.waitForServer();\n\n    // Check that the linked card of Films got updated.\n    assert.equal(await driver.find(\".g_record_detail_value\").getText(), \"Toy Story\");\n    assert.equal(await driver.findContent(\".g_record_detail_value\", /19/).getText(), \"November 22nd, 1995\");\n\n    // Select the 'new' row in FRIENDS.\n    const newCell = await gu.getCell({ section: \"Friends\", col: \"Favorite Film\", rowNum: 7 });\n    assert.equal(await newCell.getText(), \"\");\n    await newCell.click();\n\n    // Card should have also moved to the 'new' record\n    const cardFields = await driver.findAll(\".g_record_detail_value\");\n    for (const cardField of cardFields) {\n      assert.equal(await cardField.getText(), \"\");\n    }\n\n    await gu.undo(2);\n  });\n\n  it(\"should have linked card for friends\", async () => {\n    // Open the All page.\n    await driver.findContentWait(\".test-treeview-itemHeader\", /Linked Friends/, 2000).click();\n    await gu.waitForDocToLoad();\n\n    await driver.findContentWait(\".field_clip\", /Mary/, 2000).click();\n    await gu.waitForServer();\n    await driver.findContentWait(\".g_record_detail_label\", /Title/, 2000).click();\n    assert.equal(await gu.getActiveCell().getText(), \"Alien\");\n\n    await driver.findContentWait(\".field_clip\", /Jarek/, 2000).click();\n    await gu.waitForServer();\n    await driver.findContentWait(\".g_record_detail_label\", /Title/, 2000).click();\n    assert.equal(await gu.getActiveCell().getText(), \"\");\n  });\n\n  xit(\"should list options following the order of the section in the view layout\", async function() {\n    // TODO\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/RowHeights.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"RowHeights\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  afterEach(() => gu.checkForErrors());\n\n  it(\"should allow configuring row heights in grid views\", async function() {\n    const session = await gu.session().teamSite.user(\"user1\").login();\n    const docId = (await session.tempNewDoc(cleanup, \"RowHeights1\", { load: false }));\n    const api = session.createHomeApi();\n\n    // Create a fixture with a bunch of long cells.\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"TestTable\", [\n        { id: \"Num\", type: \"Numeric\" },\n        { id: \"State\", type: \"Choice\" },\n        { id: \"SignupDate\", type: \"Date\" },\n        { id: \"Address\", type: \"Text\" },\n        { id: \"Notes\", type: \"Text\" },\n      ]],\n      [\"BulkAddRecord\", \"TestTable\", [null, null, null, null], {\n        Num: [10, 20, 30, 40],\n        State: [\"ZZ\", \"YY\", \"\", \"AQ\"],\n        SignupDate: [\"2023-04-01\", \"2022-11-17\", \"2024-01-05\", \"2021-06-21\"],\n        Address: [\n          \"123 Fictional Rd\\nNowhere, ZZ 00000\",\n          \"Apt. 9¾\\n221B Bagel St\\nSnaxville, YY\",\n          \"Space\",\n          \"PO Box 8675309\\nSomewhere Cold\\nAntarctica, AQ 00001\",\n        ],\n        Notes: [\n          \"Jeff once microwaved a fork. He now teaches Kitchen Do's and Don'ts\",\n          \"No soup for you!\",\n          \"\",\n          (\"Note: Writing a self-help book:\\n\\\"How to Fix Things with Tape & Optimism\\\".\\n\" +\n            \"Currently stuck in Chapter 1: \\\"Find Tape\\\".\"),\n        ],\n      }],\n    ]);\n\n    await session.loadDoc(`/doc/${docId}/p/2`);\n\n    // Set Notes column to wrap.\n    await gu.openColumnPanel(\"Notes\");\n    await driver.findWait(\".test-tb-wrap-text\", 500).click();\n\n    // Check what it shows for \"Row heights\".\n    assert.equal(await driver.find(\".test-row-height-label\").getText(), \"auto\");\n\n    // A different column should show the same thing.\n    await gu.getCell({ col: \"State\", rowNum: 1 }).click();\n    assert.equal(await driver.find(\".test-row-height-label\").getText(), \"auto\");\n\n    // Click the \"change\" link. This should take us to the table config.\n    await driver.find(\".test-row-height-change-link\").click();\n    assert.equal(await driver.find(\".test-right-tab-pagewidget\").matches('[aria-selected=\"true\"]'), true);\n\n    // Check what we see in the row-config spinner.\n    assert.equal(await driver.find(\".test-row-height-max input\").value(), \"\");\n    assert.equal(await driver.find(\".test-row-height-max input\").getAttribute(\"placeholder\"), \"auto\");\n    await checkHeights([5, 3, 1, 9]);\n\n    // Change it using the spinner.\n    await driver.find(\".test-numeric-spinner-increment\").click();\n    assert.equal(await driver.find(\".test-row-height-max input\").value(), \"1\");\n    await checkHeights([1, 1, 1, 1]);\n\n    // Revert to 'auto' using the spinner.\n    await driver.find(\".test-numeric-spinner-decrement\").click();\n    assert.equal(await driver.find(\".test-row-height-max input\").value(), \"\");\n    await checkHeights([5, 3, 1, 9]);\n\n    // Change it by typing a value in.\n    await driver.find(\".test-row-height-max input\").click();\n    await gu.sendKeys(\"5\", Key.ENTER);\n    assert.equal(await driver.find(\".test-row-height-max input\").value(), \"5\");\n    await checkHeights([5, 3, 1, 5]);\n\n    // Try the \"Expand rows\" option.\n    assert.equal(await driver.find(\".test-row-height-expand\").getAttribute(\"checked\"), null);\n    await driver.find(\".test-row-height-expand\").click();\n    assert.equal(await driver.find(\".test-row-height-expand\").getAttribute(\"checked\"), \"true\");\n    await checkHeights([5, 5, 5, 5]);\n\n    // Test that a reload keeps these values, i.e. they've been saved\n    await gu.reloadDoc();\n    assert.equal(await driver.find(\".test-row-height-max input\").value(), \"5\");\n    assert.equal(await driver.find(\".test-row-height-expand\").getAttribute(\"checked\"), \"true\");\n    await checkHeights([5, 5, 5, 5]);\n\n    // Try spinner again.\n    await driver.find(\".test-numeric-spinner-decrement\").click();\n    await driver.find(\".test-numeric-spinner-decrement\").click();\n    assert.equal(await driver.find(\".test-row-height-max input\").value(), \"3\");\n    await checkHeights([3, 3, 3, 3]);\n\n    // Uncheck the \"expand\" button.\n    await driver.find(\".test-row-height-expand\").click();\n    assert.equal(await driver.find(\".test-row-height-expand\").getAttribute(\"checked\"), null);\n    await checkHeights([3, 3, 1, 3]);\n\n    // Check what's shown in the column options.\n    await gu.openColumnPanel(\"Notes\");\n    assert.equal(await driver.find(\".test-row-height-label\").getText(), \"3\");\n\n    // Reset back to auto.\n    await gu.openWidgetPanel();\n    await driver.find(\".test-row-height-max input\").click();\n    await gu.sendKeys(Key.DELETE);\n    await gu.getCell({ col: \"State\", rowNum: 1 }).click();    // Click away.\n    assert.equal(await driver.find(\".test-row-height-max input\").value(), \"\");\n    await checkHeights([5, 3, 1, 9]);\n  });\n\n  async function checkHeights(expectedRowHeights: number[]) {\n    const heights = await gu.getVisibleGridCells({ col: \"Num\", rowNums: [1, 2, 3, 4],\n      mapper: async el => (await el.getRect()).height,\n    });\n    // Each line is 18px, and we get rid of remainder by rounding down.\n    const heightsInLines = heights.map(h => Math.floor(h / 18));\n    assert.deepEqual(heightsInLines, expectedRowHeights);\n  }\n\n  it(\"should not offer row height option in other views\", async function() {\n    // While it could be useful for cards, it would need an adjusted design, and isn't currently\n    // supported.\n\n    // Add card widget.\n    await gu.addNewSection(\"Card\", \"TestTable\");\n    await gu.getDetailCell({ col: \"Notes\", rowNum: 1 }).click();\n\n    // Check that there are no row-height options in column or table levels.\n    await gu.openColumnPanel();\n    await driver.sleep(500);\n    assert.equal(await driver.find(\".test-row-height-label\").isPresent(), false);\n    await gu.openWidgetPanel();\n    await driver.sleep(500);\n    assert.equal(await driver.find(\".test-row-height-max\").isPresent(), false);\n    assert.equal(await driver.find(\".test-row-height-expand\").isPresent(), false);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/RowMenu.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"RowMenu\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  async function rightClick(el: WebElement) {\n    await driver.withActions(actions => actions.contextClick(el));\n  }\n\n  async function assertRowMenuOpensAndCloses() {\n    const firstRow = await gu.getRow(1);\n    // make sure that toggle is there\n    assert.isTrue(await firstRow.find(\".test-row-menu-trigger\").isPresent());\n    // but is hidden\n    assert.isFalse(await firstRow.find(\".test-row-menu-trigger\").isDisplayed());\n    // hover the row\n    await firstRow.mouseMove();\n    // make sure toggle is visible\n    assert.isTrue(await firstRow.find(\".test-row-menu-trigger\").isDisplayed());\n    // make sure that clicking on it opens up the menu\n    await firstRow.find(\".test-row-menu-trigger\").click();\n    assert.isTrue(await gu.findOpenMenu(1000).isDisplayed());\n    // close the menu\n    await driver.sendKeys(Key.ESCAPE);\n    // make sure the menu is closed\n    assert.lengthOf(await driver.findAll(\".grist-floating-menu\"), 0);\n  }\n\n  async function assertRowMenuOpensWithRightClick() {\n    const firstRow = await gu.getRow(1);\n    // make sure right click opens up the menu\n    const toggle = await firstRow.find(\".test-row-menu-trigger\");\n    await rightClick(toggle);\n    assert.isTrue(await gu.findOpenMenu(1000).isDisplayed());\n    // close the menu by clicking the toggle\n    await toggle.click();\n    // make sure the menu is closed\n    assert.lengthOf(await driver.findAll(\".grist-floating-menu\"), 0);\n  }\n\n  before(async () => {\n    const session = await gu.session().login();\n    await session.tempDoc(cleanup, \"CardView.grist\");\n  });\n\n  it(\"should show row toggle\", async function() {\n    await assertRowMenuOpensAndCloses();\n    await assertRowMenuOpensWithRightClick();\n  });\n\n  it(\"should hide row toggle when mouse moves away\", async function() {\n    const [firstRow, secondRow] = [await gu.getRow(1), await gu.getRow(2)];\n    await secondRow.mouseMove();\n    assert.isTrue(await firstRow.find(\".test-row-menu-trigger\").isPresent());\n    assert.isFalse(await firstRow.find(\".test-row-menu-trigger\").isDisplayed());\n  });\n\n  it(\"should support right click anywhere on the row\", async function() {\n    // rigth click a cell in a row\n    await rightClick(await gu.getCell(0, 1));\n\n    // check that the context menu shows\n    assert.isTrue(await gu.findOpenMenu(1000).isDisplayed());\n\n    // send ESC to close the menu\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check that the context menu is gone\n    assert.isFalse(await driver.find(\".grist-floating-menu\").isPresent());\n  });\n\n  it(\"can rename headers from the selected line\", async function() {\n    assert.notEqual(await gu.getColumnHeader({ col: 0 }).getText(), await gu.getCell(0, 1).getText());\n    assert.notEqual(await gu.getColumnHeader({ col: 1 }).getText(), await gu.getCell(1, 1).getText());\n    await (await gu.openRowMenu(1)).findContent(\"li\", /Use as table headers/).click();\n    await gu.waitForServer();\n    assert.equal(await gu.getColumnHeader({ col: 0 }).getText(), await gu.getCell(0, 1).getText());\n    assert.equal(await gu.getColumnHeader({ col: 1 }).getText(), await gu.getCell(1, 1).getText());\n  });\n\n  it(\"should work even when no columns are visible\", async function() {\n    // Previously, a bug would cause an error to be thrown instead.\n    await gu.openColumnMenu({ col: 0 }, \"Hide column\");\n    // After hiding the first column, the second one will be the new first column.\n    await gu.openColumnMenu({ col: 0 }, \"Hide column\");\n    await assertRowMenuOpensAndCloses();\n    await assertRowMenuOpensWithRightClick();\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SavePosition.ntest.js",
    "content": "import { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"SavePosition.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n  const clipboard = gu.getLockableClipboard();\n\n  before(async function() {\n    this.timeout(Math.max(this.timeout(), 20000)); // Long-running test, unfortunately\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"World.grist\", true);\n    await gu.hideBanners();\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should maintain cursor and scroll positions when switching between views\", async function() {\n    var recordSection = await gu.actions.viewSection(\"City\");\n    var cardSection = await gu.actions.viewSection(\"City Card List\");\n    var cardScrollPane = $(\".detailview_scroll_pane\");\n\n    // Set up scroll linking between the two sections.\n    await gu.openSidePane(\"view\");\n    await $(\".test-config-data\").click();\n\n    // Connect CITY -> CITY Card List\n    await gu.actions.viewSection(\"CITY Card List\").selectSection();\n    await $(\".test-right-select-by\").click();\n    await $(\".test-select-row:contains(CITY)\").wait().click();\n    await gu.waitForServer();\n    await gu.closeSidePane();\n\n    await recordSection.selectSection();\n\n    // Click on the District cell with row number 8.\n    await gu.clickCellRC(7, 2);\n    // Scroll to the Population cell with row number 22.\n    await gu.getCellRC(21, 3).scrollIntoView();\n\n    // Switch to card section, make a cursor selection and scroll selection.\n    await cardSection.selectSection();\n\n    var desiredCard = await cardScrollPane.findOldTimey(\".g_record_detail .detail_row_num:contains(3150)\").parent().elem();\n    var desiredField = await desiredCard.findOldTimey(\".g_record_detail_label:contains(Country)\").parent().parent();\n    await desiredField.click();\n    await cardScrollPane.findOldTimey(\".g_record_detail .detail_row_num:contains(3142)\").scrollIntoView();\n\n    // Switch tabs back and forth.\n    await gu.actions.selectTabView(\"Country\");\n    await gu.actions.selectTabView(\"City\");\n\n    // Assert that the cursor position in the card section is the same.\n    desiredCard = await cardScrollPane.findOldTimey(\".g_record_detail .detail_row_num:contains(3150)\").parent().elem();\n    desiredField = await desiredCard.findOldTimey(\".g_record_detail_label:contains(Country)\").parent().parent();\n    await assert.hasClass(desiredField.find(\".selected_cursor\"), \"active_cursor\");\n\n    // Assert that the element that was scrolled into view is still displayed.\n    await assert.isDisplayed(cardScrollPane.findOldTimey(\".g_record_detail .detail_row_num:contains(3142)\"));\n\n    await recordSection.selectSection();\n\n    // Assert that the scroll position in the grid section is unchanged.\n    await assert.isDisplayed(gu.getCellRC(21, 3));\n\n    // Assert that the cursor position in the grid section is the same.\n    await gu.scrollActiveViewTop();\n    await gu.getCellRC(0, 0).wait(assert.isDisplayed);\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 8, col: 2 });\n  });\n\n  it(\"should maintain cursor with linked sections\", async function() {\n    // Switch to view 'Country' (which has several linked sections).\n    await gu.actions.selectTabView(\"Country\");\n\n    // Select a position to the cursor in each section.\n    await gu.getCell({col: 1, rowNum: 9, section: \"Country\"}).click();\n    await gu.getCell({col: 0, rowNum: 6, section: \"City\"}).click();\n    await gu.getCell({col: 2, rowNum: 2, section: \"CountryLanguage\"}).click();\n    await gu.getDetailCell({col: \"IndepYear\", rowNum: 1, section: \"Country Card List\"}).click();\n\n    // Switch tabs back and forth.\n    await gu.actions.selectTabView(\"City\");\n    await gu.actions.selectTabView(\"Country\");\n\n    // Verify the cursor positions.\n    assert.deepEqual(await gu.getCursorPosition(\"Country\"),\n      {rowNum: 9, col: 1});\n    assert.deepEqual(await gu.getCursorPosition(\"City\"),\n      {rowNum: 6, col: 0});\n    assert.deepEqual(await gu.getCursorPosition(\"CountryLanguage\"),\n      {rowNum: 2, col: 2});\n    assert.deepEqual(await gu.getCursorPosition(\"Country Card List\"),\n      {rowNum: 1, col: \"IndepYear\"});\n  });\n\n  it(\"should paste into saved position\", async function() {\n    await gu.getCell({col: 1, rowNum: 9, section: \"Country\"}).click();\n    await gu.actions.selectTabView(\"City\");\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.actions.selectTabView(\"Country\");\n      await cb.paste();\n    });\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells(1, [8, 9, 10]),\n      [\"United Arab Emirates\", \"Pará\", \"Armenia\"]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Search.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { addToRepl, assert, driver, Key } from \"mocha-webdriver\";\n\nasync function getActiveCellPos() {\n  return [\n    await driver.find(\".gridview_data_row_num.selected\").getText(),\n    await driver.find(\".column_name.selected\").getText(),\n  ];\n}\n\ndescribe(\"Search\", function() {\n  this.timeout(\"25s\");\n  setupTestSuite();\n  addToRepl(\"gu.searchIsOpened\", gu.searchIsOpened);\n  gu.bigScreen(\"big\");\n\n  it(\"should support basic search\", async function() {\n    // Log in and open the doc 'World'.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"World.grist\");\n\n    // Check the initial cursor position.\n    assert.deepEqual(await gu.getCursorPosition(), { col: 0, rowNum: 1 });\n\n    // Open the search input and enter a search term.\n    await gu.search(\"que\");\n\n    // Check that Albequerque is found.\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 103, col: 0 });\n    assert.include(await gu.getActiveCell().getText(), \"Albuquerque\");\n\n    // Search forward.\n    await gu.searchNext();\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 382, col: 1 });\n    assert.include(await gu.getActiveCell().getText(), \"Mozambique\");\n\n    // Typing more characters searches incrementally.\n    await driver.sendKeys(\"tz\");    // The search term is now \"quetz\".\n    // Sleep for search debounce time\n    await driver.sleep(120);\n\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2922, col: 0 });\n    assert.include(await gu.getActiveCell().getText(), \"Quetzaltenango\");\n\n    // Search forward by clicking\n    await gu.searchNext();\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2922, col: 2 });\n    assert.include(await gu.getActiveCell().getText(), \"Quetzaltenango\");\n\n    // Search backward by clicking\n    await gu.searchPrev();\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2922, col: 0 });\n    assert.include(await gu.getActiveCell().getText(), \"Quetzaltenango\");\n\n    // Search forward with keyboard\n    await driver.sendKeys(Key.ENTER);\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2922, col: 2 });\n\n    // Search backward with keyboard. Need to focus on the search text input.\n    await driver.find(\".test-tb-search-input > input\").sendKeys(Key.chord(Key.SHIFT, Key.ENTER));\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2922, col: 0 });\n  });\n\n  it(\"should support `Mod+f`, `Mod+g`, `Mod+Shift+G` shortcuts\", async () => {\n    // send ESC to close the search\n    await gu.closeSearch();\n\n    // check that the search bar is closed\n    await gu.searchIsClosed();\n\n    // send Mode+UP to move to the first row\n    await driver.find(\"body\").sendKeys(Key.chord(await gu.modKey(), Key.UP));\n\n    // set cursor on the first cell\n    await gu.getCell({ col: 0, rowNum: 1 }).click();\n\n    // Send Mod+f\n    await driver.find(\"body\").sendKeys(Key.chord(await gu.modKey(), \"f\"));\n    await driver.sleep(500);\n\n    // check that search bar is opened\n    await gu.searchIsOpened();\n\n    // type `que`\n    await gu.selectAll();\n    await driver.sendKeys(\"que\");\n    await driver.sleep(120);\n\n    // check that Albuquerque is selected\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 103, col: 0 });\n    assert.include(await gu.getActiveCell().getText(), \"Albuquerque\");\n\n    // type Mod+g to search forward\n    const searchInputInput = await driver.find(\".test-tb-search-input input\");\n    await searchInputInput.sendKeys(Key.chord(await gu.modKey(), \"g\"));\n    await driver.sleep(120);\n\n    // check that Mozambique is found\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 382, col: 1 });\n    assert.include(await gu.getActiveCell().getText(), \"Mozambique\");\n\n    // send Mod + shift + G to search backward\n    await searchInputInput.sendKeys(Key.chord(await gu.modKey(), Key.SHIFT, \"g\"));\n    await driver.sleep(120);\n\n    // check that Albuquerque is found\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 103, col: 0 });\n    assert.include(await gu.getActiveCell().getText(), \"Albuquerque\");\n  });\n\n  it(\"should support option to search only current page\", async () => {\n    // select all\n    await gu.selectAll();\n\n    // enter 'Aruba'\n    await driver.sendKeys(\"Aruba\");\n\n    // check 'Aruba' is selected\n    await gu.waitToPass(async () => (\n      assert.include(await gu.getActiveCell().getText(), \"Aruba\")\n    ), 200);\n\n    // check page is 'City' and section is 'CITY'\n    assert.equal(await gu.getCurrentPageName(), \"City\");\n    assert.equal(await gu.getActiveSectionTitle(), \"CITY\");\n\n    // check that search bar is opened\n    await gu.searchIsOpened();\n\n    // check search all pages option is unchecked\n    assert.equal(await driver.find(\".test-tb-search-option-all-pages input\").matches(\":checked\"), false);\n\n    // click next\n    await gu.searchNext();\n\n    // check page is 'City' and section is 'CITY'\n    assert.equal(await gu.getCurrentPageName(), \"City\");\n    assert.equal(await gu.getActiveSectionTitle(), \"CITY\");\n\n    // check active cell is\n    assert.deepEqual(await getActiveCellPos(), [\"2614\", \"Country\"]);\n\n    // click next again and check cursor did not move\n    assert.equal(await gu.getCurrentPageName(), \"City\");\n    assert.equal(await gu.getActiveSectionTitle(), \"CITY\");\n    assert.deepEqual(await getActiveCellPos(), [\"2614\", \"Country\"]);\n\n    await gu.closeTooltip();\n\n    // click option 'search all pages'\n    await driver.find(\".test-tb-search-option-all-pages\").click();\n\n    // check search all pages option is checked\n    assert.equal(await driver.find(\".test-tb-search-option-all-pages input\").matches(\":checked\"), true);\n\n    // make sure tooltip is gone\n    await gu.closeTooltip();\n\n    // click next\n    await gu.searchNext();\n\n    // check page is 'Country' and section is 'COUNTRY'\n    await gu.waitToPass(async () => {\n      assert.equal(await gu.getCurrentPageName(), \"Country\");\n      assert.equal(await gu.getActiveSectionTitle(), \"COUNTRY\");\n      assert.deepEqual(await getActiveCellPos(), [\"1\", \"Name\"]);\n    }, 1000);\n  });\n\n  it(\"should allow to find other hits when user turns the multipage option ON\", async () => {\n    // switch to page CountryLanguage\n    await gu.getPageItem(/CountryLanguage/).click();\n    await gu.waitForDocToLoad();\n\n    // open the search input\n    await gu.waitToPass(async () => {\n      await gu.searchIsClosed();\n      await driver.find(\".test-tb-search-icon\").doClick();\n      await gu.waitToPass(gu.searchIsOpened, 500);\n    }, 1500);\n\n    // click the multipage option and check it is unchecked\n    await gu.waitToPass(async () => {\n      await driver.find(\".test-tb-search-option-all-pages\").click();\n      assert.equal(await driver.find(\".test-tb-search-option-all-pages input\").matches(\":checked\"), false);\n    });\n\n    // type in Aruba\n    await gu.selectAll();\n    await driver.sendKeys(\"Aruba\");\n\n    // check matches 'No results'\n    await gu.hasNoResult();\n\n    // click the multipage option\n    await driver.find(\".test-tb-search-option-all-pages\").click();\n\n    // check 'No results' is gone\n    await gu.hasSomeResult();\n\n    // click next btn\n    await gu.searchNext();\n\n    // check finds hits on next page\n    await gu.waitToPass(async () => {\n      assert.equal(await gu.getCurrentPageName(), \"City\");\n      assert.equal(await gu.getActiveSectionTitle(), \"CITY\");\n      assert.deepEqual(await getActiveCellPos(), [\"2614\", \"Country\"]);\n    }, 100);\n  });\n\n  it(\"should allow to find other hits when user switch pages\", async () => {\n    // clear tooltip\n    await gu.closeTooltip();\n\n    // uncheck the multipage option\n    await gu.toggleSearchAll();\n\n    // check it is unchecked\n    assert.equal(await driver.find(\".test-tb-search-option-all-pages input\").matches(\":checked\"), false);\n\n    // switch to page country language\n    await gu.getPageItem(/CountryLanguage/).click();\n    await gu.waitForDocToLoad();\n    await gu.waitToPass(gu.searchIsClosed);\n    await driver.sleep(100);\n\n    // open search bar\n    await gu.waitToPass(async () => {\n      await gu.searchIsClosed();\n      await driver.find(\".test-tb-search-icon\").doClick();\n      await gu.waitToPass(gu.searchIsOpened, 500);\n    }, 1500);\n\n    // type in aruba\n    await gu.selectAll();\n    await driver.sendKeys(\"Aruba\");\n\n    // check there are no results\n    await gu.hasNoResult();\n\n    // switch to page City\n    await gu.getPageItem(/City/).click();\n    await gu.waitForDocToLoad();\n\n    // open search bar\n    await gu.waitToPass(async () => {\n      await gu.searchIsClosed();\n      await driver.find(\".test-tb-search-icon\").doClick();\n      await gu.waitToPass(gu.searchIsOpened, 500);\n    }, 1500);\n\n    // click next\n    await gu.waitToPass(() => driver.find(\".test-tb-search-next\").click());\n\n    // check it found match\n    await gu.waitToPass(async () => {\n      assert.equal(await gu.getCurrentPageName(), \"City\");\n      assert.equal(await gu.getActiveSectionTitle(), \"CITY\");\n      assert.deepEqual(await getActiveCellPos(), [\"2614\", \"Country\"]);\n    }, 100);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Search2.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"Search2\", async function() {\n  this.timeout(20000);\n  setupTestSuite();\n  gu.bigScreen();\n  let closedSize: ClientRect;\n  const waitForClose = async () => {\n    await driver.wait(async () => {\n      const currentSize = await driver.find(\".test-tb-search-wrapper\").rect();\n      return currentSize.width === closedSize.width;\n    });\n  };\n\n  before(async function() {\n    // Log in and open the doc 'World'.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"World.grist\");\n    await gu.waitForDocToLoad();\n    closedSize = await driver.find(\".test-tb-search-wrapper\").rect();\n  });\n\n  it(\"should handle sending Cmd+f repeatedly\", async () => {\n    await gu.getPageItem(/City/).click();\n    await gu.waitForServer();\n\n    // open search\n    await driver.find(\".test-tb-search-icon\").doClick();\n    await driver.sleep(500);\n\n    // click multiPage options\n    await driver.find(\".test-tb-search-option-all-pages\").click();\n\n    // type in 'gorane'\n    await driver.find(\".test-tb-search-input\").doClick();\n    await driver.sendKeys(\"gorane\");\n\n    // repeteadly send Cmd+G\n    for (let i = 0; i < 10; ++i) {\n      await driver.find(\"body\").sendKeys(Key.chord(await gu.modKey(), \"g\"));\n    }\n\n    // unclick multiPage options\n    await driver.find(\".test-tb-search-icon\").doClick();\n    await driver.sleep(500);\n    await driver.find(\".test-tb-search-option-all-pages\").click();\n    await driver.sendKeys(Key.ESCAPE);\n    await waitForClose();\n\n    // check for any js errors\n    await gu.checkForErrors();\n  });\n\n  it(\"should update linked sections\", async () => {\n    // Link City sections\n    await gu.getPageItem(/City/).click();\n    await gu.waitForServer();\n    await gu.getSection(\"CITY Card List\").click();\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.findContent(\".test-right-panel button\", /Change widget/).click();\n    await driver.find(\".test-wselect-selectby\").doClick();\n    await driver.findContent(\".test-wselect-selectby option\", /CITY/).doClick();\n    await driver.find(\".test-wselect-addBtn\").doClick();\n    await gu.waitForServer();\n    await gu.getSection(\"CITY\").click();\n    await gu.getCell(\"Name\", 1).click();\n\n    // Search for \"CHN\"\n    await gu.search(\"CHN\");\n\n    // Leave search\n    await driver.sendKeys(Key.ESCAPE);\n    await waitForClose();\n\n    // Make sure linked sections ended up where we'd expect, and consistent\n    await gu.selectSectionByTitle(\"CITY Card List\");\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 4, col: \"Country\" });\n    await gu.selectSectionByTitle(\"CITY\");\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 3293, col: 0 });\n  });\n\n  it(\"should scroll to the active element\", async () => {\n    await gu.getPageItem(/Country/).click();\n    await gu.waitForServer();\n    // Select a first row\n    await gu.getCell(0, 1).click();\n    const test = async (rowCount: number) => {\n      // Scroll\n      await gu.scrollActiveView(0, 22 * rowCount); // 22 is a row height\n      // Search for Aruba.\n      await gu.search(\"Aruba\");\n      // Leave search\n      await driver.sendKeys(Key.ESCAPE);\n      await waitForClose();\n      // First row should be scrolled into, and we should be able to click it.\n      await gu.getCell(0, 1).click();\n      // check for any js errors\n      await gu.checkForErrors();\n    };\n    // Scroll 2 rows, to hide the first row with Aruba.\n    // By scrolling only two rows, the active row is still rendered.\n    await test(2);\n    // Now scroll 100 rows, which removes the active record from the dom (replaces it with another).\n    await test(100);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Search3.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"Search3\", async function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let mainSession: gu.Session;\n  let docId: string;\n\n  before(async function() {\n    mainSession = await gu.session().teamSite.user(\"user1\").login();\n    docId = await mainSession.tempNewDoc(cleanup, \"Search3.grist\", { load: false });\n    const api = mainSession.createHomeApi();\n    // Prepare a table with some interestingly-formatted columns, and some data.\n    const { retValues } = await api.applyUserActions(docId, [\n      [\"AddTable\", \"Test\", []],\n      [\"AddVisibleColumn\", \"Test\", \"Date\", { type: \"Date\", widgetOptions: '{\"dateFormat\":\"YY-MM-DD dd\"}' }],\n      [\"AddVisibleColumn\", \"Test\", \"Ref\", { type: \"Ref:Test\" }],\n      [\"AddVisibleColumn\", \"Test\", \"RefList\", { type: \"RefList:Test\" }],\n    ]);\n    await api.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Tables_column\", retValues[2].colRef, { visibleCol: retValues[1].colRef }],\n      [\"UpdateRecord\", \"_grist_Tables_column\", retValues[3].colRef, { visibleCol: retValues[1].colRef }],\n      [\"SetDisplayFormula\", \"Test\", null, retValues[2].colRef, \"$Ref.Date\"],\n      [\"SetDisplayFormula\", \"Test\", null, retValues[3].colRef, \"$RefList.Date\"],\n      [\"AddRecord\", \"Test\", null, { Date: \"2021-12-20\", Ref: 2, RefList: [\"L\", 1, 2] }],\n      [\"AddRecord\", \"Test\", null, { Date: \"2021-12-17\", Ref: 1, RefList: null }],\n\n    ]);\n    return docId;\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  async function assertSearchPosition(position: { rowNum: number, col: number }) {\n    await gu.waitToPass(async () => {\n      assert.deepEqual(await gu.getCursorPosition(), position);\n    }, 500);\n  }\n\n  it(\"should search after toggling columns\", async () => {\n    await mainSession.loadDoc(`/doc/${docId}/p/1`);\n    await gu.getCell(\"A\", 1).click();\n    await gu.enterCell(\"a\", Key.ENTER);\n    await gu.openWidgetPanel();\n    await gu.moveToHidden(\"B\");\n\n    await gu.search(\"a\");\n    await gu.moveToVisible(\"B\");\n\n    await driver.find(\".test-tb-search-icon\").click();\n    await driver.sleep(500);\n    await driver.find(\".test-tb-search-next\").click();\n    await gu.checkForErrors();\n  });\n\n  it(\"should handle searching in Ref/RefList columns with dates\", async () => {\n    await mainSession.loadDoc(`/doc/${docId}/p/2`);\n    await gu.search(\"12-\");\n\n    await assertSearchPosition({ rowNum: 1, col: 0 });\n    await gu.searchNext();\n    await assertSearchPosition({ rowNum: 1, col: 1 });\n    await gu.searchNext();\n    await assertSearchPosition({ rowNum: 1, col: 2 });\n    await gu.searchNext();\n    await assertSearchPosition({ rowNum: 2, col: 0 });\n  });\n\n  it(\"should search on raw data\", async () => {\n    await mainSession.tempDoc(cleanup, \"World.grist\");\n    await driver.find(\".test-tools-raw\").click();\n    await gu.search(\"Aruba\");\n    await gu.hasSomeResult();\n    // This gets raw table name from the overlay, so it tests if raw view is visible.\n    assert.equal(\"City\", await gu.getActiveRawTableName());\n    await assertSearchPosition({ rowNum: 129, col: 1 });\n    await gu.searchNext();\n    await assertSearchPosition({ rowNum: 129, col: 1 });\n    assert.equal(\"City\", await gu.getActiveRawTableName());\n    await gu.toggleSearchAll();\n\n    await gu.searchNext();\n    await assertSearchPosition({ rowNum: 1, col: 0 });\n    assert.equal(\"Country\", await gu.getActiveRawTableName());\n\n    await gu.searchNext();\n    await assertSearchPosition({ rowNum: 1, col: 2 });\n    assert.equal(\"Country\", await gu.getActiveRawTableName());\n\n    await gu.searchNext();\n    await assertSearchPosition({ rowNum: 1, col: 11 });\n    assert.equal(\"Country\", await gu.getActiveRawTableName());\n\n    await gu.searchNext();\n    await assertSearchPosition({ rowNum: 129, col: 1 });\n    assert.equal(\"City\", await gu.getActiveRawTableName());\n\n    await gu.searchPrev();\n    await assertSearchPosition({ rowNum: 1, col: 11 });\n    assert.equal(\"Country\", await gu.getActiveRawTableName());\n\n    await gu.searchIsOpened();\n\n    // Clicking on any page, should hide the search bar.\n    await gu.getPageItem(\"City\").click();\n    await gu.searchIsClosed();\n\n    // Clicking on raw section should close the search bar.\n    await gu.search(\"Aruba\");\n    await gu.searchIsOpened();\n    await driver.find(\".test-tools-raw\").click();\n    await gu.searchIsClosed();\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SearchBar.ntest.ts",
    "content": "import { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\nimport bluebird from \"bluebird\";\nimport { assert, driver } from \"mocha-webdriver\";\n\nfunction currentSectionDesc() {\n  return $(\".active_section .test-viewsection-title\").text();\n}\n\n// Wrapping in bluebird allows an exception stack to report the line in the calling function too.\nconst checkMatch = bluebird.method(\n  async function(sectionName: string, rowNum: number, col: number | string, value: string) {\n    await $.wait(1000, async () => assert.deepEqual(await gu.getCursorPosition(), { rowNum, col }));\n    assert.equal(await currentSectionDesc(), sectionName);\n    assert.equal(await gu.getActiveCell().text(), value);\n  });\n\ndescribe(\"SearchBar.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"World.grist\", true);\n  });\n\n  it(\"should support basic search\", async function() {\n    await $.wait(1000, async () => {\n      assert.deepEqual(await gu.getCursorPosition(), { col: 0, rowNum: 1 });\n    });\n\n    await $(\".test-tb-search-icon\").click();\n    await driver.sleep(500);\n    await $(\".test-tb-search-option-all-pages\").click();\n    await $(\".test-tb-search-input > input\").click().sendKeys(\"que\");\n    assert.equal(await $(\".test-tb-search-input > input\").val(), \"que\");\n\n    await $.wait(1000, async () => assert.include(await gu.getActiveCell().text(), \"que\"));\n    await checkMatch(\"CITY\", 103, 0, \"Albuquerque\");\n\n    // Hitting Enter scans forward.\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"CITY\", 382, 1, \"Mozambique\");\n\n    // Typing more characters searches incrementally.\n    await gu.sendKeys(\"tz\");    // The search term is now \"quetz\".\n    await checkMatch(\"CITY\", 2922, 0, \"Quetzaltenango\");\n\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"CITY\", 2922, 2, \"Quetzaltenango\");\n\n    // Shortcut to search forward\n    await gu.sendKeys([$.MOD, \"g\"]);\n    await checkMatch(\"CITY Card List\", 3911, \"Name\", \"Quetzaltenango\");\n\n    // Shortcut to search backward\n    await gu.sendKeys([$.MOD, $.SHIFT, \"g\"]);\n    await checkMatch(\"CITY\", 2922, 2, \"Quetzaltenango\");\n  });\n\n  it(\"should release focus on escape, and select on focus\", async function() {\n    // Our starting position.\n    await checkMatch(\"CITY\", 2922, 2, \"Quetzaltenango\");\n\n    // Hit Escape while focused in search; it should lose focus.\n    const searchBox = $(\".test-tb-search-input > input\");\n\n    assert.equal(await searchBox.elem().hasFocus(), true);\n    assert.equal(await searchBox.val(), \"quetz\");\n    await gu.sendKeys($.ESCAPE);\n    await gu.waitToPass(async () => {\n      assert.equal(await searchBox.isDisplayed(), false);\n    });\n    assert.equal(await searchBox.elem().hasFocus(), false);\n    assert.equal(await gu.getActiveCell().text(), \"Quetzaltenango\");\n\n    // Typing should open a regular cell editor now.\n    assert.equal(await $(\".test-widget-text-editor\").isPresent(), false);\n    await gu.sendKeys(\"q\");\n    await gu.waitAppFocus(false);\n    assert.equal(await $(\".test-widget-text-editor\").isPresent(), true);\n    assert.equal(await $(\".test-widget-text-editor textarea\").val(), \"q\");\n\n    // Escape should close cell editor without saving.\n    await gu.sendKeys($.ESCAPE);\n    await checkMatch(\"CITY\", 2922, 2, \"Quetzaltenango\");\n\n    // Cmd-F should open the search box with old search term still available.\n    await gu.sendKeys([$.MOD, \"f\"]);\n    // Wait a tiny bit for the search bar to open to prevent sometimes hitting Enter too early\n    await driver.sleep(200);\n    assert.equal(await searchBox.val(), \"quetz\");\n\n    // Hitting Enter should resume search.\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"CITY Card List\", 3911, \"Name\", \"Quetzaltenango\");\n\n    // Search term should be selected, so new typing should override it. Type \"iquel\"\n    await gu.sendKeys(\"iquel\");\n    await checkMatch(\"COUNTRY\", 196, 1, \"Saint Pierre and Miquelon\");\n  });\n\n  it(\"should search across tabs\", async function() {\n    // Go through a bunch more matches\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"COUNTRY\", 196, 10, \"Saint-Pierre-et-Miquelon\");\n\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"COUNTRY Card List\", 1, \"Name\", \"Saint Pierre and Miquelon\");\n\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"COUNTRY Card List\", 1, \"LocalName\", \"Saint-Pierre-et-Miquelon\");\n\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"COUNTRYLANGUAGE\", 352, 1, \"Cakchiquel\");\n\n    // Now go back one to see that we switch back across a tab.\n    await gu.sendKeys([$.MOD, $.SHIFT, \"g\"]);\n    await checkMatch(\"COUNTRY Card List\", 1, \"LocalName\", \"Saint-Pierre-et-Miquelon\");\n\n    // And forward again.\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"COUNTRYLANGUAGE\", 352, 1, \"Cakchiquel\");\n\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"CITY\", 3072, 1, \"Saint Pierre and Miquelon\");\n\n    // Until we finally come to where we started.\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"COUNTRY\", 196, 1, \"Saint Pierre and Miquelon\");\n\n    // And we can continue cycling.\n    await gu.sendKeys($.ENTER);\n    await checkMatch(\"COUNTRY\", 196, 10, \"Saint-Pierre-et-Miquelon\");\n  });\n\n  it(\"should hide next/previous buttons if no match\", async function() {\n    // Check that next/previous buttons are enabled.\n    await gu.waitToPass(async () => {\n      assert.equal(await $(\".test-tb-search-next\").isPresent(), true);\n    });\n    assert.equal(await $(\".test-tb-search-prev\").isPresent(), true);\n\n    // Check that next/previous buttons work.\n    await $(\".test-tb-search-prev\").click();   // previous\n    await checkMatch(\"COUNTRY\", 196, 1, \"Saint Pierre and Miquelon\");\n    await $(\".test-tb-search-next\").click();   // previous\n    await checkMatch(\"COUNTRY\", 196, 10, \"Saint-Pierre-et-Miquelon\");\n\n    // Type 'x', to make nonexistent value in the search box.\n    await gu.sendKeys([$.MOD, \"f\"], $.RIGHT);\n    await gu.sendKeys(\"x\");\n\n    // wait for buttons to be hidden\n    await gu.waitToPass(async () => {\n      assert.equal(await $(\".test-tb-search-next\").isPresent(), false);\n    });\n    assert.equal(await $(\".test-tb-search-prev\").isPresent(), false);\n    assert.equal(await $(\".test-tb-search-input > input\").val(), \"iquelx\");\n\n    // Check position is unchanged, and buttons don't work.\n    await checkMatch(\"COUNTRY\", 196, 10, \"Saint-Pierre-et-Miquelon\");\n    await gu.sendKeys([$.MOD, $.SHIFT, \"g\"]);  // previous\n    await checkMatch(\"COUNTRY\", 196, 10, \"Saint-Pierre-et-Miquelon\");\n\n    // check \"no results\" is shown\n    assert.match(await $(\".test-tb-search-input\").text(), /No results/);\n\n    // Make an existent value again, wait for buttons to become shown.\n    await gu.sendKeys([$.MOD, \"f\"], $.RIGHT);\n    await gu.sendKeys($.BACK_SPACE);\n    await gu.waitToPass(async () => {\n      assert.equal(await $(\".test-tb-search-next\").isPresent(), true);\n    });\n    assert.equal(await $(\".test-tb-search-prev\").isPresent(), true);\n    assert.equal(await $(\".test-tb-search-input > input\").val(), \"iquel\");\n\n    // Check position is unchanged but buttons do work.\n    await checkMatch(\"COUNTRY\", 196, 10, \"Saint-Pierre-et-Miquelon\");\n    await $(\".test-tb-search-prev\").click();   // previous\n    await checkMatch(\"COUNTRY\", 196, 1, \"Saint Pierre and Miquelon\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SectionFilter.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, until } from \"mocha-webdriver\";\n\ndescribe(\"SectionFilter\", function() {\n  this.timeout(60000);\n  const cleanup = setupTestSuite();\n\n  describe(\"Core tests\", function() {\n    before(async function() {\n      this.timeout(10000);\n      const session = await gu.session().teamSite.login();\n      await session.tempNewDoc(cleanup);\n    });\n\n    it(\"should be able to open / close filter menu\", async () => {\n      const menu = await gu.openColumnMenu(\"A\", \"Filter\");\n      assert.equal(await menu.find(\".test-filter-menu-list\").getText(), \"No matching values\");\n      await driver.sendKeys(Key.ESCAPE);\n      await driver.wait(until.stalenessOf(menu));\n    });\n\n    it(\"should filter out records in response to filter menu selections\", async () => {\n      this.timeout(10000);\n\n      await gu.enterGridRows({ col: \"A\", rowNum: 1 }, [\n        [\"Apples\",  \"1\"],\n        [\"Oranges\", \"2\"],\n        [\"Bananas\", \"1\"],\n        [\"Apples\",  \"2\"],\n        [\"Bananas\", \"1\"],\n        [\"Apples\",  \"2\"],\n      ]);\n\n      const menu = await gu.openColumnMenu(\"A\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"Apples\", count: 3 },\n        { checked: true, value: \"Bananas\", count: 2 },\n        { checked: true, value: \"Oranges\", count: 1 },\n      ]);\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6]),\n        [\"Apples\", \"Oranges\", \"Bananas\", \"Apples\", \"Bananas\", \"Apples\"]);\n\n      await menu.findContent(\"label\", /Apples/).click();\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: false, value: \"Apples\", count: 3 },\n        { checked: true, value: \"Bananas\", count: 2 },\n        { checked: true, value: \"Oranges\", count: 1 },\n      ]);\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3]),\n        [\"Oranges\", \"Bananas\", \"Bananas\"]);\n\n      await menu.findContent(\"label\", /Apples/).click();\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"Apples\", count: 3 },\n        { checked: true, value: \"Bananas\", count: 2 },\n        { checked: true, value: \"Oranges\", count: 1 },\n      ]);\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6]),\n        [\"Apples\", \"Oranges\", \"Bananas\", \"Apples\", \"Bananas\", \"Apples\"]);\n\n      await driver.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"should undo filter changes on cancel\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6]),\n        [\"Apples\", \"Oranges\", \"Bananas\", \"Apples\", \"Bananas\", \"Apples\"]);\n\n      const menu = await gu.openColumnMenu(\"A\", \"Filter\");\n\n      await menu.findContent(\"label\", /Apples/).click();\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: false, value: \"Apples\", count: 3 },\n        { checked: true, value: \"Bananas\", count: 2 },\n        { checked: true, value: \"Oranges\", count: 1 },\n      ]);\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3]),\n        [\"Oranges\", \"Bananas\", \"Bananas\"]);\n\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6]),\n        [\"Apples\", \"Oranges\", \"Bananas\", \"Apples\", \"Bananas\", \"Apples\"]);\n    });\n\n    it(\"should display new/updated rows even when only certain values are filtered in\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6]),\n        [\"Apples\", \"Oranges\", \"Bananas\", \"Apples\", \"Bananas\", \"Apples\"]);\n\n      let menu = await gu.openColumnMenu(\"A\", \"Filter\");\n\n      // Put the filter into the \"inclusion\" state, with nothing selected initially.\n      assert.deepEqual(\n        await driver.findAll('.test-filter-menu-bulk-action:not([aria-disabled=\"true\"])', e => e.getText()),\n        [\"None\"]);\n      await driver.findContent(\".test-filter-menu-bulk-action\", /None/).click();\n      assert.deepEqual(\n        await driver.findAll('.test-filter-menu-bulk-action:not([aria-disabled=\"true\"])', e => e.getText()),\n        [\"All\"]);\n\n      // Include only \"Apples\".\n      await menu.findContent(\"label\", /Apples/).click();\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"Apples\", count: 3 },\n        { checked: false, value: \"Bananas\", count: 2 },\n        { checked: false, value: \"Oranges\", count: 1 },\n      ]);\n\n      await driver.find(\".test-filter-menu-apply-btn\").click();\n\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4]),\n        [\"Apples\", \"Apples\", \"Apples\", \"\"]);\n\n      // Update first row to Oranges; it should remain shown.\n      await gu.getCell(0, 1).click();\n      await gu.enterCell(\"Oranges\");\n\n      // Enter a new row using a keyboard shortcut.\n      await driver.find(\"body\").sendKeys(Key.chord(await gu.modKey(), Key.ENTER));\n\n      // Enter a new row by typing in a value into the \"add-row\".\n      await driver.find(\".gridview_row .record-add .field\").click();\n      await gu.enterCell(\"Bananas\");\n\n      // Ensure all 3 changes are visible.\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6]),\n        [\"Oranges\", \"Apples\", \"\", \"Apples\", \"Bananas\", \"\"]);\n\n      // Check that the filter menu looks as expected.\n      menu = await gu.openColumnMenu(\"A\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: false, value: \"\", count: 1 },\n        { checked: true, value: \"Apples\", count: 2 },\n        { checked: false, value: \"Bananas\", count: 3 },\n        { checked: false, value: \"Oranges\", count: 2 },\n      ]);\n\n      // Apply the filter to make it only-Apples again.\n      await menu.find(\".test-filter-menu-apply-btn\").click();\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3]),\n        [\"Apples\", \"Apples\", \"\"]);\n\n      // Reset the filter\n      menu = await gu.openColumnMenu(\"A\", \"Filter\");\n      assert.deepEqual(\n        await driver.findAll(\".test-filter-menu-bulk-action:not([class*=-disabled])\", e => e.getText()),\n        [\"All\", \"None\"]);\n      await driver.findContent(\".test-filter-menu-bulk-action\", /All/).click();\n      await menu.find(\".test-filter-menu-apply-btn\").click();\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"Oranges\", \"Oranges\", \"Bananas\", \"Apples\", \"Bananas\", \"\", \"Apples\", \"Bananas\"]);\n\n      // Restore changes of this test case.\n      await gu.undo(3);\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6]),\n        [\"Apples\", \"Oranges\", \"Bananas\", \"Apples\", \"Bananas\", \"Apples\"]);\n    });\n\n    it(\"should display new/updated rows even when filtered, but refilter on menu changes\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6]),\n        [\"Apples\", \"Oranges\", \"Bananas\", \"Apples\", \"Bananas\", \"Apples\"]);\n\n      let menu = await gu.openColumnMenu(\"A\", \"Filter\");\n\n      await menu.findContent(\"label\", /Apples/).click();\n      await driver.find(\".test-filter-menu-apply-btn\").click();\n\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3]),\n        [\"Oranges\", \"Bananas\", \"Bananas\"]);\n\n      // Update Oranges to Apples and make sure it's not filtered out\n      await (await gu.getCell(0, 1)).click();\n      await gu.enterCell(\"Apples\");\n\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3]),\n        [\"Apples\", \"Bananas\", \"Bananas\"]);\n\n      // Set back to Oranges and make sure it stays\n      await driver.sendKeys(Key.UP);\n      await gu.enterCell(\"Oranges\");\n\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3]),\n        [\"Oranges\", \"Bananas\", \"Bananas\"]);\n\n      // Enter two new rows and make sure they're also not filtered out\n      await driver.find(\".gridview_row .record-add .field\").click();\n      await gu.enterCell(\"Apples\");\n      await gu.enterCell(\"Bananas\");\n\n      // Enter a new row using a keyboard shortcut.\n      await driver.find(\"body\").sendKeys(Key.chord(await gu.modKey(), Key.ENTER));\n      await gu.waitForServer();\n\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6]),\n        [\"Oranges\", \"Bananas\", \"Bananas\", \"Apples\", \"Bananas\", \"\"]);\n\n      menu = await gu.openColumnMenu(\"A\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"\", count: 1 },\n        { checked: false, value: \"Apples\", count: 4 },\n        { checked: true, value: \"Bananas\", count: 3 },\n        { checked: true, value: \"Oranges\", count: 1 },\n      ]);\n\n      await menu.findContent(\"label\", /Apples/).click();\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"Apples\", \"Oranges\", \"Bananas\", \"Apples\", \"Bananas\", \"Apples\", \"Apples\", \"Bananas\"]);\n      await menu.findContent(\"label\", /Apples/).click();\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4]),\n        [\"Oranges\", \"Bananas\", \"Bananas\", \"Bananas\"]);\n      await driver.sendKeys(Key.ESCAPE);\n    });\n  });\n\n  describe(\"Type tests\", function() {\n    before(async function() {\n      const session = await gu.session().teamSite.login();\n      await session.tempDoc(cleanup, \"FilterTest.grist\");\n    });\n\n    it(\"should properly filter strings\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"Foo\", \"Bar\", \"1\", \"2.0\", \"2016-01-01\", \"5+6\", \"\", \"\"]);\n\n      const menu = await gu.openColumnMenu(\"Text\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"\", count: 1 },\n        { checked: true, value: \"1\", count: 1 },\n        { checked: true, value: \"2.0\", count: 1 },\n        { checked: true, value: \"5+6\", count: 1 },\n        { checked: true, value: \"2016-01-01\", count: 1 },\n        { checked: true, value: \"Bar\", count: 1 },\n        { checked: true, value: \"Foo\", count: 1 },\n      ]);\n      await menu.findContent(\"label .test-filter-menu-value\", /^$/).click();\n      await menu.findContent(\"label\", /Bar/).click();\n      assert.deepEqual(await gu.getVisibleGridCells(0, [1, 2, 3, 4, 5, 6, 7]),\n        [\"Foo\", \"1\", \"2.0\", \"2016-01-01\", \"5+6\", \"\", undefined]);\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n\n    it(\"should properly filter numbers\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"5.00\", \"6.00\", \"7.00\", \"-1.00\", \"foo\", \"0.00\", \"\", \"\"]);\n\n      const menu = await gu.openColumnMenu(\"Number\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"\", count: 1 },\n        { checked: true, value: \"foo\", count: 1 },\n        { checked: true, value: \"-1.00\", count: 1 },\n        { checked: true, value: \"0.00\", count: 1 },\n        { checked: true, value: \"5.00\", count: 1 },\n        { checked: true, value: \"6.00\", count: 1 },\n        { checked: true, value: \"7.00\", count: 1 },\n      ]);\n      await menu.findContent(\"label .test-filter-menu-value\", /^$/).click();\n      await menu.findContent(\"label\", /7/).click();\n      await menu.findContent(\"label\", /foo/).click();\n      assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2, 3, 4, 5, 6]),\n        [\"5.00\", \"6.00\", \"-1.00\", \"0.00\", \"\", undefined]);\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n\n    it(\"should properly filter dates\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(2, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"2019-06-03\", \"2019-06-07\", \"2019-06-05\", \"bar\", \"2019-06-123\", \"0\", \"\", \"\"]);\n\n      const menu = await gu.openColumnMenu(\"Date\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"\", count: 1 },\n        { checked: true, value: \"2019-06-123\", count: 1 },\n        { checked: true, value: \"bar\", count: 1 },\n        { checked: true, value: \"0\", count: 1 },\n        { checked: true, value: \"2019-06-03\", count: 1 },\n        { checked: true, value: \"2019-06-05\", count: 1 },\n        { checked: true, value: \"2019-06-07\", count: 1 },\n      ]);\n      await menu.findContent(\"label .test-filter-menu-value\", /^$/).click();\n      await menu.findContent(\"label\", /2019-06-05/).click();\n      await menu.findContent(\"label\", /bar/).click();\n      assert.deepEqual(await gu.getVisibleGridCells(2, [1, 2, 3, 4, 5, 6]),\n        [\"2019-06-03\", \"2019-06-07\", \"2019-06-123\", \"0\", \"\", undefined]);\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n\n    it(\"should properly search through list of date to filter\", async () => {\n      const menu = await gu.openColumnMenu(\"Date\", \"Filter\");\n      assert.lengthOf(await gu.getFilterMenuState(), 7);\n      await driver.sendKeys(\"07\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"2019-06-07\", count: 1 },\n      ]);\n      assert.deepEqual(\n        await menu.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()),\n        [\"2019-06-07\"],\n      );\n      await menu.findContent(\".test-filter-menu-bulk-action\", /All shown/).click();\n      assert.deepEqual(\n        await gu.getVisibleGridCells(2, [1, 2]),\n        [\"2019-06-07\", \"\"],\n      );\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n\n    it(\"should properly filter formulas\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"25\", \"36\", \"49\", \"1\", \"#TypeError\", \"0\", \"#TypeError\", \"\"]);\n\n      const menu = await gu.openColumnMenu(\"Formula\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"#TypeError\", count: 2 },\n        { checked: true, value: \"0\", count: 1 },\n        { checked: true, value: \"1\", count: 1 },\n        { checked: true, value: \"25\", count: 1 },\n        { checked: true, value: \"36\", count: 1 },\n        { checked: true, value: \"49\", count: 1 },\n      ]);\n\n      await menu.findContent(\"label\", /0/).click();\n      await menu.findContent(\"label\", /#TypeError/).click();\n      await menu.findContent(\"label\", /25/).click();\n\n      assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5]),\n        [\"36\", \"49\", \"1\", \"\", undefined]);\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n\n    it(\"should properly filter references\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(4, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"alice\", \"carol\", \"bob\", \"denis\", \"0\", \"denis\", \"\", \"\"]);\n\n      const menu = await gu.openColumnMenu(\"Reference\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"\", count: 1 },\n        { checked: true, value: \"#Invalid Ref: 0\", count: 1 },\n        { checked: true, value: \"#Invalid Ref: denis\", count: 2 },\n        { checked: true, value: \"alice\", count: 1 },\n        { checked: true, value: \"bob\", count: 1 },\n        { checked: true, value: \"carol\", count: 1 },\n      ]);\n\n      await menu.findContent(\"label .test-filter-menu-value\", /^$/).click();\n      await menu.findContent(\"label\", /#Invalid Ref: denis/).click();\n      await menu.findContent(\"label\", /bob/).click();\n\n      assert.deepEqual(await gu.getVisibleGridCells(4, [1, 2, 3, 4, 5]),\n        [\"alice\", \"carol\", \"0\", \"\", undefined]);\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n\n    it(\"should properly filter choice lists\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(5, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"Foo\\nBar\\nBaz\", \"Foo\\nBar\", \"Foo\", \"InvalidChoice\", \"Baz\\nBaz\\nBaz\", \"Bar\\nBaz\", \"\", \"\"]);\n\n      const menu = await gu.openColumnMenu(\"ChoiceList\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"\", count: 1 },\n        { checked: true, value: \"Bar\", count: 3 },\n        { checked: true, value: \"Baz\", count: 5 },\n        { checked: true, value: \"Foo\", count: 3 },\n        { checked: true, value: \"InvalidChoice\", count: 1 },\n      ]);\n\n      // Check that all the choices are rendered in the right colors.\n      const choiceColors = await menu.findAll(\n        \"label .test-filter-menu-choice-token\",\n        async c => [await c.getCssValue(\"background-color\"), await c.getCssValue(\"color\")],\n      );\n\n      assert.deepEqual(\n        choiceColors,\n        [\n          [\"rgba(254, 204, 129, 1)\", \"rgba(0, 0, 0, 1)\"],\n          [\"rgba(53, 253, 49, 1)\", \"rgba(0, 0, 0, 1)\"],\n          [\"rgba(204, 254, 254, 1)\", \"rgba(0, 0, 0, 1)\"],\n          [\"rgba(255, 255, 255, 1)\", \"rgba(0, 0, 0, 1)\"],\n        ],\n      );\n\n      // Check that Foo is rendered with font options.\n      const boldFonts = await menu.findAll(\n        \"label .test-filter-menu-choice-token.font-italic.font-bold\",\n        c => c.getText(),\n      );\n\n      assert.deepEqual(boldFonts, [\"Foo\"]);\n\n      await menu.findContent(\"label .test-filter-menu-value\", /^$/).click();\n      await menu.findContent(\"label\", /Bar/).click();\n      await menu.findContent(\"label\", /Baz/).click();\n\n      assert.deepEqual(await gu.getVisibleGridCells(5, [1, 2, 3, 4, 5]),\n        [\"Foo\\nBar\\nBaz\", \"Foo\\nBar\", \"Foo\", \"InvalidChoice\", \"\"]);\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n\n    it(\"should properly filter errors in choice lists\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(6, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"25.0\", \"36.0\", \"49.0\", \"1.0\", \"#TypeError\", \"\", \"#TypeError\", \"\"]);\n\n      await gu.scrollIntoView(gu.getColumnHeader(\"ChoiceListErrors\"));\n      const menu = await gu.openColumnMenu(\"ChoiceListErrors\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"\", count: 1 },\n        { checked: true, value: \"#TypeError\", count: 2 },\n        { checked: true, value: \"1.0\", count: 1 },\n        { checked: true, value: \"25.0\", count: 1 },\n        { checked: true, value: \"36.0\", count: 1 },\n        { checked: true, value: \"49.0\", count: 1 },\n        { checked: true, value: \"A\", count: 0 },\n        { checked: true, value: \"B\", count: 0 },\n        { checked: true, value: \"C\", count: 0 },\n        { checked: true, value: \"D\", count: 0 },\n      ]);\n\n      await menu.findContent(\"label .test-filter-menu-value\", /^$/).click();\n      await menu.findContent(\"label\", /#TypeError/).click();\n      await menu.findContent(\"label\", /25\\.0/).click();\n      await menu.findContent(\"label\", /36\\.0/).click();\n      await menu.findContent(\"label\", /49\\.0/).click();\n\n      assert.deepEqual(await gu.getVisibleGridCells(6, [1, 2]),\n        [\"1.0\", \"\"]);\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n\n    it(\"should properly filter choices\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(7, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"Red\", \"Orange\", \"Yellow\", \"InvalidChoice\", \"\", \"Red\", \"\", \"\"]);\n\n      const menu = await gu.openColumnMenu(\"Choice\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"\", count: 2 },\n        { checked: true, value: \"InvalidChoice\", count: 1 },\n        { checked: true, value: \"Orange\", count: 1 },\n        { checked: true, value: \"Red\", count: 2 },\n        { checked: true, value: \"Yellow\", count: 1 },\n      ]);\n\n      // Check that all the choices are rendered in the right colors.\n      const choiceColors = await menu.findAll(\n        \"label .test-filter-menu-choice-token\",\n        async c => [await c.getCssValue(\"background-color\"), await c.getCssValue(\"color\")],\n      );\n\n      assert.deepEqual(\n        choiceColors,\n        [\n          [\"rgba(255, 255, 255, 1)\", \"rgba(0, 0, 0, 1)\"],\n          [\"rgba(254, 204, 129, 1)\", \"rgba(0, 0, 0, 1)\"],\n          [\"rgba(252, 54, 59, 1)\", \"rgba(255, 255, 255, 1)\"],\n          [\"rgba(255, 250, 205, 1)\", \"rgba(0, 0, 0, 1)\"],\n        ],\n      );\n\n      // Check that Red is rendered with font options.\n      const withFonts = await menu.findAll(\n        \"label .test-filter-menu-choice-token.font-underline.font-strikethrough\",\n        c => c.getText(),\n      );\n\n      assert.deepEqual(withFonts, [\"Red\"]);\n\n      await menu.findContent(\"label\", /InvalidChoice/).click();\n      await menu.findContent(\"label\", /Orange/).click();\n      await menu.findContent(\"label\", /Yellow/).click();\n\n      assert.deepEqual(await gu.getVisibleGridCells(7, [1, 2, 3, 4, 5]),\n        [\"Red\", \"\", \"Red\", \"\", \"\"]);\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n\n    it(\"should properly filter reference lists\", async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(8, [1, 2, 3, 4, 5, 6, 7, 8]),\n        [\"alice\\ncarol\", \"bob\", \"carol\\nbob\\nalice\", \"[u'denis']\", \"[u'0']\", \"[u'denis', u'edward']\", \"\", \"\"]);\n\n      const menu = await gu.openColumnMenu(\"ReferenceList\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"\", count: 1 },\n        { checked: true, value: \"#Invalid RefList: [u'0']\", count: 1 },\n        {\n          checked: true,\n          value: \"#Invalid RefList: [u'denis', u'edward']\",\n          count: 1,\n        },\n        {\n          checked: true,\n          value: \"#Invalid RefList: [u'denis']\",\n          count: 1,\n        },\n        { checked: true, value: \"alice\", count: 2 },\n        { checked: true, value: \"bob\", count: 2 },\n        { checked: true, value: \"carol\", count: 2 },\n      ]);\n\n      await menu.findContent(\"label .test-filter-menu-value\", /^$/).click();\n      await menu.findContent(\"label\", /bob/).click();\n      await menu.findContent(\"label\", /#Invalid RefList: \\[u'0'\\]/).click();\n\n      assert.deepEqual(await gu.getVisibleGridCells(8, [1, 2, 3, 4, 5]),\n        [\"alice\\ncarol\", \"carol\\nbob\\nalice\", \"[u'denis']\", \"[u'denis', u'edward']\", \"\"]);\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n\n    it(\"should reflect the section show column setting in the filter menu\", async () => {\n      // Scroll col 3 into view to make sure col 4 is clickable\n      await gu.scrollIntoView(gu.getCell(3, 1));\n\n      // Change the show column setting of the Reference column to 'color'.\n      await gu.getCell(4, 1).click();\n      await gu.toggleSidePanel(\"right\", \"open\");\n      await driver.find(\".test-right-tab-field\").click();\n      await gu.setRefShowColumn(\"color\");\n\n      // Open the filter menu for Reference, and check that the values are now from 'color'.\n      const menu = await gu.openColumnMenu(\"Reference\", \"Filter\");\n      assert.deepEqual(await gu.getFilterMenuState(), [\n        { checked: true, value: \"\", count: 1 },\n        { checked: true, value: \"#Invalid Ref: 0\", count: 1 },\n        { checked: true, value: \"#Invalid Ref: denis\", count: 2 },\n        { checked: true, value: \"blue\", count: 1 },\n        { checked: true, value: \"green\", count: 1 },\n        { checked: true, value: \"red\", count: 1 },\n      ]);\n\n      await menu.find(\".test-filter-menu-cancel-btn\").click();\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SelectBy.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport mapValues from \"lodash/mapValues\";\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"SelectBy\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n  let doc: any;\n\n  function formatOption(main: string, srcColumn?: string, tgtColumn?: string) {\n    let ret = main;\n    ret += srcColumn ? \" \\u2022 \" + srcColumn : \"\";\n    ret += tgtColumn ? \" \\u2192 \" + tgtColumn : \"\";\n    return ret;\n  }\n\n  it(\"should offer correct options\", async () => {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    doc = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"selectBy.grist\", false);\n\n    // check tables\n    const api = gu.createHomeApi(\"Chimpy\", \"nasa\");\n    assert.deepInclude(await api.getTable(doc.id, \"_grist_Tables\"), {\n      id: [1, 2, 3, 4],\n      tableId: [\"Table1\", \"Table2\", \"Table3\", \"Table3_summary_A\"],\n      summarySourceTable: [0, 0, 0, 3],\n    });\n\n    // check visible columns (no manualSort columns)\n    const allColumns = await api.getTable(doc.id, \"_grist_Tables_column\");\n    const visibleColumns = mapValues(allColumns, vals => vals.filter((v, i) => allColumns.colId[i] !== \"manualSort\"));\n    assert.deepInclude(visibleColumns, {\n      id: [2, 3, 6, 10, 12, 13, 14, 15, 16],\n      colId: [\"table2_ref\", \"table3_ref\", \"table3_ref\", \"A\", \"A\", \"table3_ref_2\", \"A\", \"group\", \"count\"],\n      parentId: [1, 1, 2, 3, 1, 1, 4, 4, 4],\n      type: [\"Ref:Table2\", \"Ref:Table3\", \"Ref:Table3\", \"Numeric\", \"Text\", \"Ref:Table3\", \"Numeric\",\n        \"RefList:Table3\", \"Int\"],\n      label: [\"table2_ref\", \"table3_ref\", \"table3_ref\", \"A\", \"A\", \"table3_ref_2\", \"A\", \"group\", \"count\"],\n    });\n\n    // open document\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n\n    // create a new page with table1 and table2 as 2 tables\n    await gu.addNewPage(/Table/, /Table1/);\n    await gu.addNewSection(/Table/, /Table2/);\n\n    // beginning adding a new widget to page\n    await driver.findWait(\".test-dp-add-new\", 2000).doClick();\n    await driver.findWait(\".test-dp-add-widget-to-page\", 2000).doClick();\n\n    // select /Table/ /Table1/ and check options of `SELECT BY` drop down\n    await driver.findContent(\".test-wselect-table\", /Table1/).doClick();\n    await driver.findContent(\".test-wselect-type\", /Table/).doClick();\n    await driver.find(\".test-wselect-selectby\").doClick();\n    assert.deepEqual(\n      // let's ignore the first option which is an internal added by grainjs\n      await driver.findAll(\".test-wselect-selectby option:not(:first-of-type)\", e => e.getText()), [\n        // note: this is a very contrived example to test various possible links. Real world use\n        // cases are expected to be simpler, resulting in simpler list of options that are easier to\n        // navigate for the user than this one (in particular the `->` separator might rarely show\n        // up).\n        formatOption(\"Select widget\"),\n        formatOption(\"TABLE1\"),\n        formatOption(\"TABLE1\", \"table2_ref\"),\n        formatOption(\"TABLE1\", \"table3_ref\", \"table3_ref\"),\n        formatOption(\"TABLE1\", \"table3_ref\", \"table3_ref_2\"),\n        formatOption(\"TABLE1\", \"table3_ref_2\", \"table3_ref\"),\n        formatOption(\"TABLE1\", \"table3_ref_2\", \"table3_ref_2\"),\n        formatOption(\"TABLE2\"),\n        formatOption(\"TABLE2\", \"table3_ref\", \"table3_ref\"),\n        formatOption(\"TABLE2\", \"table3_ref\", \"table3_ref_2\"),\n      ],\n    );\n\n    // select Table2 and check options of `SELECT BY` drop down\n    await driver.findContent(\".test-wselect-table\", /Table2/).doClick();\n    await driver.find(\".test-wselect-selectby\").doClick();\n    assert.deepEqual(\n      await driver.findAll(\".test-wselect-selectby option:not(:first-of-type)\", e => e.getText()), [\n        formatOption(\"Select widget\"),\n        formatOption(\"TABLE1\", \"table2_ref\"),\n        formatOption(\"TABLE1\", \"table3_ref\"),\n        formatOption(\"TABLE1\", \"table3_ref_2\"),\n        formatOption(\"TABLE2\"),\n        formatOption(\"TABLE2\", \"table3_ref\"),\n      ],\n    );\n\n    // Selecting \"New Table\" should show no options.\n    await driver.findContent(\".test-wselect-table\", /New Table/).doClick();\n    assert.equal(await driver.find(\".test-wselect-selectby\").isPresent(), false);\n    assert.lengthOf(await driver.findAll(\".test-wselect-selectby option\"), 0);\n    // Selecting a regular table should show options again.\n    await driver.findContent(\".test-wselect-table\", /Table2/).doClick();\n    assert.equal(await driver.find(\".test-wselect-selectby\").isPresent(), true);\n    assert.lengthOf(await driver.findAll(\".test-wselect-selectby option\"), 7);\n\n    // Create a page with with charts and custom widget and then check that no linking is offered\n    await gu.addNewPage(/Chart/, /Table1/);\n    await gu.addNewSection(/Custom/, /Table2/, { customWidget: /Custom URL/ });\n\n    // open add widget to page\n    await driver.findWait(\".test-dp-add-new\", 2000).doClick();\n    await driver.findWait(\".test-dp-add-widget-to-page\", 2000).doClick();\n\n    // select /Table/ /Table1/ and check no options are available\n    await driver.findContent(\".test-wselect-table\", /Table1/).doClick();\n    await driver.findContent(\".test-wselect-type\", /Table/).doClick();\n    assert.equal(await driver.find(\".test-wselect-selectby\").isPresent(), false);\n\n    // select Table2 and check no options are available\n    await driver.findContent(\".test-wselect-table\", /Table2/).doClick();\n    assert.equal(await driver.find(\".test-wselect-selectby\").isPresent(), false);\n  });\n\n  it(\"should handle summary table correctly\", async () => {\n    // Notice that table of view 4 is a summary of Table3\n    const api = gu.createHomeApi(\"Chimpy\", \"nasa\");\n    assert.deepInclude((await api.getTable(doc.id, \"_grist_Tables\")), {\n      id: [1, 2, 3, 4],\n      tableId: [\"Table1\", \"Table2\", \"Table3\", \"Table3_summary_A\"],\n      summarySourceTable: [0, 0, 0, 3],\n    });\n\n    // open Summary page\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}/p/4`);\n    await gu.waitForDocToLoad();\n\n    // add new widget to page\n    await driver.findWait(\".test-dp-add-new\", 2000).doClick();\n    await driver.findWait(\".test-dp-add-widget-to-page\", 2000).doClick();\n\n    // select Table3 and summarize\n    await driver.findContent(\".test-wselect-table\", /Table3/).find(\".test-wselect-pivot\").doClick();\n\n    // check selectBy options\n    assert.deepEqual(\n      await driver.findAll(\".test-wselect-selectby option:not(:first-of-type)\", e => e.getText()),\n      [],\n    );\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should show nav buttons for card view linked to its summary\", async function() {\n    // Still on the page with summary of Table3, add a new Card widget linked to the summary\n    await gu.addNewSection(/Card$/, /Table3/, { selectBy: /TABLE3.*by A/ });\n\n    // Check that we have a card view.\n    await gu.getCell({ section: \"TABLE3 [by A]\", rowNum: 1, col: \"A\" }).click();\n    const section = await gu.getSection(\"TABLE3 Card\");\n    assert.equal(await gu.getDetailCell({ section, rowNum: 1, col: \"A\" }).getText(), \"1\");\n\n    // Check there are nav buttons in the card view.\n    assert.equal(await section.find(\".detail-button.detail-left\").isPresent(), true);\n    assert.equal(await section.find(\".detail-button.detail-right\").isPresent(), true);\n    assert.equal(await section.find(\".grist-single-record__menu__count\").getText(), \"1 OF 1\");\n\n    // Now add a record to the source table using the card view.\n    await section.find(\".detail-button.detail-add-btn\").click();\n    assert.equal(await gu.getDetailCell({ section, rowNum: 1, col: \"A\" }).getText(), \"\");\n    await gu.getDetailCell({ section, rowNum: 1, col: \"A\" }).click();\n    await gu.sendKeys(\"1\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Check that this group now has 2 records.\n    assert.equal(await section.find(\".grist-single-record__menu__count\").getText(), \"2 OF 2\");\n\n    // There is another group that still has one record.\n    await gu.getCell({ section: \"TABLE3 [by A]\", rowNum: 2, col: \"A\" }).click();\n    assert.equal(await section.find(\".grist-single-record__menu__count\").getText(), \"1 OF 1\");\n  });\n\n  it(\"should save link correctly\", async () => {\n    // create new page with table2 as a table\n    await gu.addNewPage(/Table/, /Table2/);\n\n    // begin adding table1 as a table to page\n    await driver.findWait(\".test-dp-add-new\", 2000).doClick();\n    await driver.findWait(\".test-dp-add-widget-to-page\", 2000).doClick();\n    await driver.findContent(\".test-wselect-table\", /Table1/).doClick();\n\n    // select link\n    await driver.find(\".test-wselect-selectby\").doClick();\n    await driver.findContent(\".test-wselect-selectby option\", /Table2/i).doClick();\n\n    // click `add to page` btn\n    await driver.find(\".test-wselect-addBtn\").doClick();\n    await gu.waitForServer();\n\n    // check new section added and check content\n    assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2]), [\"a\", \"b\"]);\n\n    // select other row in selector section\n    await gu.getSection(\"Table2\").doClick();\n    await gu.getCell({ col: 0, rowNum: 2 }).doClick();\n\n    // check that linked section was filterd\n    await gu.getSection(\"Table1\").doClick();\n    assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2]), [\"c\", \"d\"]);\n\n    // check that an single undo remove the section\n    await gu.undo();\n    assert.equal(await gu.getSection(\"Table1\").isPresent(), false);\n\n    // check that a single redo add and link the section\n    await gu.redo();\n    await gu.getSection(\"Table1\").doClick();\n    assert.deepEqual(await gu.getVisibleGridCells(1, [1, 2]), [\"a\", \"b\"]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SelectByRefList.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport * as _ from \"lodash\";\nimport { addToRepl, assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"SelectByRefList\", function() {\n  this.timeout(90000);\n  setupTestSuite();\n  addToRepl(\"gu2\", gu);\n  gu.bigScreen();\n\n  async function setup() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const doc = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\",\n      \"SelectByRefList.grist\", false);\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n  }\n\n  it(\"should filter a table selected by ref and reflist columns\", async function() {\n    await setup();\n\n    /*\n     The fixture document contains the following tables:\n     - LINKTARGET is the table we 'select by' another table, filtering it.\n        It has 3 columns: rownum, ref, and reflist\n     - REFTARGET is the target of almost all ref/reflist columns in the doc,\n        especially ref and reflist in LINKTARGET.\n        It has 3 rows and 1 column, the values are just a, b, and c.\n     - INDIRECTREF has matching rows referencing the rows in REFTARGET.\n     - REFLISTS has 2 reflist columns:\n        - reflist points to REFTARGET, similar to LINKTARGET\n        - LinkTarget_reflist points to LINKTARGET\n\n     checkSelectingRecords selects each of the 3 records in a table, one at a time,\n     and checks that LINKTARGET is filtered to the corresponding subarray.\n\n     First we test selecting by the ref column of LINKTARGET (2nd column).\n     Selecting by REFTARGET or INDIRECTREF should give the same result.\n     Because the values of REFTARGET are [a,b,c], selecting those rows\n     gives the rows in LINKTARGET with `a` and then `b` in the 2nd column in the first two subarrays.\n     LINKTARGET doesn't have any references to the last row of REFTARGET (c)\n     so the last group is empty.\n     All these groups include the new record row at the end.\n    */\n\n    let sourceData = [\n      [\n        \"1\", \"a\", \"a\",\n        \"2\", \"a\", \"b\",\n        \"\", \"\", \"\",\n      ],\n      [\n        \"3\", \"b\", \"a\\nb\",\n        \"4\", \"b\", \"\",\n        \"\", \"\", \"\",\n      ],\n      [\n        \"\", \"\", \"\",\n      ],\n    ];\n    // The last row selected has value `c`, so that's the default value for the ref column\n    let newRow = [\"99\", \"c\", \"\"];\n    await checkSelectingRecords(\"INDIRECTREF • A → ref\", sourceData, newRow);\n    await checkSelectingRecords(\"REFTARGET → ref\", sourceData, newRow);\n\n    // Now selecting based on the reflist column (3rd column)\n    // gives groups where that column *contains* `a`, then contains `b`, then\n    // nothing because again LINKTARGET doesn't have references to `c`\n    sourceData = [\n      [\n        \"1\", \"a\", \"a\",\n        \"3\", \"b\", \"a\\nb\",\n        \"\", \"\", \"\",\n      ],\n      [\n        \"2\", \"a\", \"b\",\n        \"3\", \"b\", \"a\\nb\",\n        \"\", \"\", \"\",\n      ],\n      [\n        \"\", \"\", \"\",\n      ],\n    ];\n    // The last row selected has value `c`, so that's the default value for the reflist column\n    newRow = [\"99\", \"\", \"c\"];\n    await checkSelectingRecords(\"INDIRECTREF • A → reflist\", sourceData, newRow);\n    await checkSelectingRecords(\"REFTARGET → reflist\", sourceData, newRow);\n\n    // This case is quite simple and direct: LINKTARGET should show the rows listed\n    // in the REFLISTS.LinkTarget_reflist column. The values there are [1], [2], and [3, 4],\n    // which you can see in the first column below.\n    sourceData = [\n      [\n        \"1\", \"a\", \"a\",\n        \"\", \"\", \"\",\n      ],\n      [\n        \"2\", \"a\", \"b\",\n        \"\", \"\", \"\",\n      ],\n      [\n        \"3\", \"b\", \"a\\nb\",\n        \"4\", \"b\", \"\",\n        \"\", \"\", \"\",\n      ],\n    ];\n    // LINKTARGET is being filtered by the `id` column\n    // There's no column to set a default value for.\n    // TODO should we be appending the new row ID to the reflist in the source table?\n    newRow = [\"99\", \"\", \"\"];\n    await checkSelectingRecords(\"REFLISTS • LinkTarget_reflist\", sourceData, newRow);\n\n    // Similar to the above but indirect. We connect LINKTARGET.ref and REFLISTS.reflist,\n    // which both point to REFTARGET. This gives rows where LINKTARGET.ref is contained in REFLISTS.reflist\n    // (in contrast to LINKTARGET.row_id contained in REFLISTS.LinkTarget_reflist).\n    // The values of REFLISTS.reflist are [a], [b], and [a, b],\n    // so the values in the second column must be in there.\n    sourceData = [\n      [\n        \"1\", \"a\", \"a\",\n        \"2\", \"a\", \"b\",\n        \"\", \"\", \"\",\n      ],\n      [\n        \"3\", \"b\", \"a\\nb\",\n        \"4\", \"b\", \"\",\n        \"\", \"\", \"\",\n      ],\n      [\n        \"1\", \"a\", \"a\",\n        \"2\", \"a\", \"b\",\n        \"3\", \"b\", \"a\\nb\",\n        \"4\", \"b\", \"\",\n        \"\", \"\", \"\",\n      ],\n    ];\n    // The last row selected has value [a,b] in REFLISTS.reflist\n    // LINKTARGET.ref can only take one reference, so it defaults to the first\n    newRow = [\"99\", \"a\", \"\"];\n    await checkSelectingRecords(\"REFLISTS • reflist → ref\", sourceData, newRow);\n\n    // Taking it one step further, connect LINKTARGET.reflist and REFLISTS.reflist.\n    // Gives rows where the two reflists *intersect*.\n    // The values of REFLISTS.reflist are [a], [b], and [a, b],\n    // so the values in the third column must be in there.\n    sourceData = [\n      [\n        \"1\", \"a\", \"a\",\n        \"3\", \"b\", \"a\\nb\",\n        \"\", \"\", \"\",\n      ],\n      [\n        \"2\", \"a\", \"b\",\n        \"3\", \"b\", \"a\\nb\",\n        \"\", \"\", \"\",\n      ],\n      [\n        \"1\", \"a\", \"a\",\n        \"2\", \"a\", \"b\",\n        \"3\", \"b\", \"a\\nb\",\n        \"\", \"\", \"\",\n      ],\n    ];\n    // The last row selected has value [a,b] in REFLISTS.reflist\n    // LINKTARGET.reflist gets that as a default value\n    newRow = [\"99\", \"\", \"a\\nb\"];\n    await checkSelectingRecords(\"REFLISTS • reflist → reflist\", sourceData, newRow);\n  });\n});\n\n/**\n * Makes LINKTARGET select by selectBy.\n * Asserts that clicking each row in the driving table filters LINKTARGET\n * to the corresponding subarray of sourceData.\n * Then creates a new row in LINKTARGET and asserts that it has values equal to newRow.\n * The values will depend on the link and the last row selected in the driving table.\n */\nasync function checkSelectingRecords(selectBy: string, sourceData: string[][], newRow: string[]) {\n  await gu.openSelectByForSection(\"LINKTARGET\");\n  await gu.findOpenMenuItem(\".test-select-row\", new RegExp(selectBy + \"$\")).click();\n  await gu.waitForServer();\n\n  const selectByTable = selectBy.split(\" \")[0];\n  const cell = await gu.getCell({ section: selectByTable, col: 0, rowNum: 3 });\n  if (selectByTable === \"REFLISTS\") {\n    await gu.clickReferenceListCell(cell);\n  } else {\n    await cell.click();\n  }\n\n  let numSourceRows = 0;\n\n  async function checkSourceGroup(sourceRowIndex: number) {\n    const sourceGroup = sourceData[sourceRowIndex];\n    numSourceRows = sourceGroup.length / 3;\n    assert.deepEqual(\n      await gu.getVisibleGridCells({\n        section: \"LINKTARGET\",\n        cols: [\"rownum\", \"ref\", \"reflist\"],\n        rowNums: _.range(1, numSourceRows + 1),\n      }),\n      sourceGroup,\n    );\n    const csvCells = await gu.downloadSectionCsvGridCells(\"LINKTARGET\");\n    const expectedCsvCells = sourceGroup.slice(0, -3)  // remove 'add new' row of empty strings\n      // visible cells text uses newlines to separate list items,\n      // CSV export uses commas\n      .map(s => s.replace(\"\\n\", \", \"));\n    assert.deepEqual(csvCells, expectedCsvCells);\n  }\n\n  for (let i = 0; i < sourceData.length; i++) {\n    const cell = await gu.getCell({ section: selectByTable, col: 0, rowNum: i + 1 });\n    if (selectByTable === \"REFLISTS\") {\n      await gu.clickReferenceListCell(cell);\n    } else {\n      await cell.click();\n    }\n    await checkSourceGroup(i);\n  }\n\n  // Create a new record with rownum=99\n  await gu.getCell({ section: \"LINKTARGET\", col: \"rownum\", rowNum: numSourceRows }).click();\n  await gu.enterCell(\"99\");\n\n  assert.deepEqual(\n    await gu.getVisibleGridCells({\n      section: \"LINKTARGET\",\n      cols: [\"rownum\", \"ref\", \"reflist\"],\n      rowNums: [numSourceRows],\n    }),\n    newRow,\n  );\n\n  await gu.undo();\n\n  // Check recursiveMoveToCursorPos\n  // TODO row number 4 is not tested because sometimes there are no matching source records\n  //   to move the cursor to and we don't have a solution for that case yet\n  for (let rowNum = 1; rowNum <= 3; rowNum++) {\n    // Click an anchor link\n    const anchorCell = gu.getCell({ section: \"Anchors\", rowNum, col: 1 });\n    await driver.withActions(a => a.click(anchorCell.find(\".test-tb-link\")));\n\n    // Check that navigation to the link target worked\n    assert.equal(await gu.getActiveSectionTitle(), \"LINKTARGET\");\n    assert.equal(await gu.getActiveCell().getText(), String(rowNum));\n\n    // Check that the link target is still filtered correctly by the link source,\n    // which should imply that the link source cursor is in the right place\n    await gu.selectSectionByTitle(selectByTable);\n    const srcRowNum = await gu.getSelectedRowNum();\n    await checkSourceGroup(srcRowNum - 1);\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/SelectByRightPanel.ts",
    "content": "import { getColValues } from \"app/common/DocActions\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\n/**\n * This is test for a bug that was on the Right Panel. [Select by] dropdown wasn't updated\n * properly when summary tables (or linking in general) were updated.\n */\n\ndescribe(\"SelectByRightPanel\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n  let docId: string;\n  let api: UserAPI;\n\n  before(async () => {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    docId = await gu.createNewDoc(\"chimpy\", \"nasa\", \"Horizon\", \"Test22.grist\");\n    api = gu.createHomeApi(\"Chimpy\", \"nasa\");\n    await driver.get(`${server.getHost()}/o/nasa/doc/${docId}`);\n    await gu.waitForDocToLoad();\n    await api.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Tables_column\", 2, { label: \"Company\" }],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 3, { label: \"Category\" }],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 4, { label: \"Month\" }],\n      [\"AddVisibleColumn\", \"Table1\", \"Date\", {}],\n      [\"AddVisibleColumn\", \"Table1\", \"Value\", {}],\n    ]);\n    // Add some dummy data.\n    await api.applyUserActions(docId, [\n      [\"BulkAddRecord\", \"Table1\", new Array(7).fill(null), getColValues([\n        { Company: \"Mic\", Category: \"Sales\", Month: 1, Date: 1, Value: 100 },\n        { Company: \"Mic\", Category: \"Sales\", Month: 1, Date: 2, Value: 100 },\n        { Company: \"Mic\", Category: \"Cloud\", Month: 1, Date: 3, Value: 300 },\n        { Company: \"Gog\", Category: \"Sales\", Month: 4, Date: 4, Value: 100 },\n        { Company: \"Gog\", Category: \"Adv\",   Month: 4, Date: 4, Value: 100 },\n        { Company: \"Gog\", Category: \"Adv\",   Month: 3, Date: 5, Value: 100 },\n        { Company: \"Tes\", Category: \"Sales\", Month: 2, Date: 6, Value: 100 },\n      ])],\n    ]);\n  });\n\n  it(\"selects by right panel for\", async () => {\n    // Add first summary table by Company\n    await gu.addNewSection(\"Table\", \"Table1\", { summarize: [\"Company\"] });\n    // Add second one by Category, we will update selection later using data selection on right panel\n    await gu.addNewSection(\"Table\", \"Table1\", { summarize: [\"Category\"] });\n    // Add Company to this table, select by should be filled with the new option.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await driver.find(\".test-config-data\").click();\n    await driver.find(\".test-pwc-editDataSelection\").click();\n    await driver.findContent(\".test-wselect-column\", /Company/).doClick();\n    await driver.find(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n    // Test that we have new option.\n    await driver.find(\".test-right-select-by\").click();\n    await gu.findOpenMenuItem(\"li\",  \"TABLE1 [by Company]\", 200).click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells(\"Company\", [1, 2]), [\"Mic\", \"Mic\"]);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Category\", [1, 2]), [\"Sales\", \"Cloud\"]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SelectBySummary.ts",
    "content": "import { enterRulePart, findDefaultRuleSetWait, startEditingAccessRules } from \"test/nbrowser/aclTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport * as _ from \"lodash\";\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"SelectBySummary\", function() {\n  this.timeout(50000);\n  const cleanup = setupTestSuite();\n  let headers: Record<string, string>;\n  gu.bigScreen(\"big\");\n\n  before(async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"SelectBySummary.grist\");\n    headers = {\n      Authorization: `Bearer ${session.getApiKey()}`,\n    };\n  });\n\n  it(\"should filter a source table selected by a summary table (first option)\", async function() {\n    await checkSelectingRecords(\n      headers,\n      [\"onetwo\"],\n      [\n        \"1\", \"16\",\n        \"2\", \"20\",\n      ],\n      [\n        [\n          \"1\", \"a\", \"1\",\n          \"1\", \"b\", \"3\",\n          \"1\", \"a\\nb\", \"5\",\n          \"1\", \"\", \"7\",\n        ],\n        [\n          \"2\", \"a\", \"2\",\n          \"2\", \"b\", \"4\",\n          \"2\", \"a\\nb\", \"6\",\n          \"2\", \"\", \"8\",\n        ],\n      ],\n    );\n  });\n\n  it(\"should filter a source table selected by a summary table (second option)\", async function() {\n    await checkSelectingRecords(\n      headers,\n      [\"choices\"],\n      [\n        \"a\", \"14\",\n        \"b\", \"18\",\n        \"\", \"15\",\n      ],\n      [\n        [\n          \"1\", \"a\", \"1\",\n          \"2\", \"a\", \"2\",\n          \"1\", \"a\\nb\", \"5\",\n          \"2\", \"a\\nb\", \"6\",\n        ],\n        [\n          \"1\", \"b\", \"3\",\n          \"2\", \"b\", \"4\",\n          \"1\", \"a\\nb\", \"5\",\n          \"2\", \"a\\nb\", \"6\",\n        ],\n        [\n          \"1\", \"\", \"7\",\n          \"2\", \"\", \"8\",\n        ],\n      ],\n    );\n  });\n\n  it(\"should filter a source table selected by a summary table (both options)\", async function() {\n    await checkSelectingRecords(\n      headers,\n      [\"onetwo\", \"choices\"],\n      [\n        \"1\", \"a\", \"6\",\n        \"2\", \"a\", \"8\",\n        \"1\", \"b\", \"8\",\n        \"2\", \"b\", \"10\",\n        \"1\", \"\", \"7\",\n        \"2\", \"\", \"8\",\n      ],\n      [\n        [\n          \"1\", \"a\", \"1\",\n          \"1\", \"a\\nb\", \"5\",\n        ],\n        [\n          \"2\", \"a\", \"2\",\n          \"2\", \"a\\nb\", \"6\",\n        ],\n        [\n          \"1\", \"b\", \"3\",\n          \"1\", \"a\\nb\", \"5\",\n        ],\n        [\n          \"2\", \"b\", \"4\",\n          \"2\", \"a\\nb\", \"6\",\n        ],\n        [\n          \"1\", \"\", \"7\",\n        ],\n        [\n          \"2\", \"\", \"8\",\n        ],\n      ],\n    );\n  });\n\n  it(\"should create new rows in the source table (link target) with correct default values\",\n    gu.revertChanges(async function() {\n      // Select the record with ['2', 'a'] in the summary table\n      // so those values will be used as defaults in the source table\n      await gu.getCell({ section: \"TABLE1 [by onetwo, choices]\", col: \"rownum\", rowNum: 2 }).click();\n\n      // Create new records with rownum = 99 and 100\n      await gu.getCell({ section: \"TABLE1\", col: \"rownum\", rowNum: 3 }).click();\n      await gu.waitAppFocus();\n      await gu.enterCell(\"99\");\n      await gu.enterCell(\"100\");\n      await driver.sleep(100); // there is some setTimeout in Grist somewhere here.\n\n      assert.deepEqual(\n        await gu.getVisibleGridCells({\n          section: \"TABLE1\",\n          cols: [\"onetwo\", \"choices\", \"rownum\"],\n          rowNums: [1, 2, 3, 4, 5],\n        }),\n        [\n          \"2\", \"a\", \"2\",\n          \"2\", \"a\\nb\", \"6\",\n          // The two rows we just added.\n          // The filter link sets the default value 'a'.\n          // It can't set a default value for 'onetwo' because that's a formula column.\n          // This first row doesn't match the filter link, but it still shows temporarily.\n          \"1\", \"a\", \"99\",\n          \"2\", \"a\", \"100\",\n          \"\", \"\", \"\",  // new row\n        ],\n      );\n\n      // Select a different record in the summary table, sanity check the linked table.\n      await gu.getCell({ section: \"TABLE1 [by onetwo, choices]\", col: \"rownum\", rowNum: 3 }).click();\n      assert.deepEqual(\n        await gu.getVisibleGridCells({\n          section: \"TABLE1\",\n          cols: [\"onetwo\", \"choices\", \"rownum\"],\n          rowNums: [1, 2, 3],\n        }),\n        [\n          \"1\", \"b\", \"3\",\n          \"1\", \"a\\nb\", \"5\",\n          \"\", \"\", \"\",  // new row\n        ],\n      );\n\n      // Now go back to the previously selected summary table row.\n      await gu.getCell({ section: \"TABLE1 [by onetwo, choices]\", col: \"rownum\", rowNum: 2 }).click();\n      assert.deepEqual(\n        await gu.getVisibleGridCells({\n          section: \"TABLE1\",\n          cols: [\"onetwo\", \"choices\", \"rownum\"],\n          rowNums: [1, 2, 3, 4],\n        }),\n        [\n          \"2\", \"a\", \"2\",\n          \"2\", \"a\\nb\", \"6\",\n          // The row ['1', 'a', '99'] is now filtered out as normal.\n          \"2\", \"a\", \"100\",\n          \"\", \"\", \"\",  // new row\n        ],\n      );\n    }),\n  );\n\n  it(\"should filter a summary table selected by a less detailed summary table\", async function() {\n    // Delete the Table1 widget so that we can hide the table in ACL without hiding the whole page.\n    await gu.deleteWidgetWithData(\"TABLE1\");\n\n    // Open the ACL UI\n    await startEditingAccessRules();\n\n    // Deny all access to Table1.\n    await driver.findContentWait(\"button\", /Add table rules/, 2000).click();\n    await gu.findOpenMenuItem(\"li\", /Table1/, 3000).click();\n    const ruleSet = findDefaultRuleSetWait(/Table1/);\n    await enterRulePart(ruleSet, 1, null, \"Deny all\");\n    await driver.find(\".test-rules-save\").click();\n    await gu.waitForServer();\n\n    // Go back to the main page.\n    await gu.getPageItem(\"Table1\").click();\n\n    // Now check filter linking, but with the detailed summary 'TABLE1 [by onetwo, choices]' as the target,\n    // selecting by the two less detailed summaries.\n    // There was a bug previously that this would not work while the summary source table (Table1) was hidden.\n    await checkSelectingRecords(\n      headers,\n      [\"onetwo\"],\n      [\n        \"1\", \"16\",\n        \"2\", \"20\",\n      ],\n      [\n        [\n          \"1\", \"a\", \"6\",\n          \"1\", \"b\", \"8\",\n          \"1\", \"\", \"7\",\n        ],\n        [\n          \"2\", \"a\", \"8\",\n          \"2\", \"b\", \"10\",\n          \"2\", \"\", \"8\",\n        ],\n      ],\n      // This argument was not used in the previous test, as TABLE1 is the default.\n      \"TABLE1 [by onetwo, choices]\",\n    );\n\n    await checkSelectingRecords(\n      headers,\n      [\"choices\"],\n      [\n        \"a\", \"14\",\n        \"b\", \"18\",\n        \"\", \"15\",\n      ],\n      [\n        [\n          \"1\", \"a\", \"6\",\n          \"2\", \"a\", \"8\",\n        ],\n        [\n          \"1\", \"b\", \"8\",\n          \"2\", \"b\", \"10\",\n        ],\n        [\n          \"1\", \"\", \"7\",\n          \"2\", \"\", \"8\",\n        ],\n      ],\n      \"TABLE1 [by onetwo, choices]\",\n    );\n  });\n});\n\n/**\n * Makes `targetSection` select by the existing summary table grouped by groubyColumns.\n * Asserts that the summary table has the data summaryData under groubyColumns and rownum.\n * Asserts that clicking each row in the summary table filters the target section\n * to the corresponding subarray of `targetData`.\n */\nasync function checkSelectingRecords(\n  headers: Record<string, string>,\n  groubyColumns: string[],\n  summaryData: string[],\n  targetData: string[][],\n  targetSection: string = \"TABLE1\",\n) {\n  const summarySection = `TABLE1 [by ${groubyColumns.join(\", \")}]`;\n\n  await gu.openSelectByForSection(targetSection);\n  await gu.findOpenMenuItem(\".test-select-row\", summarySection).click();\n  await gu.waitForServer();\n\n  assert.deepEqual(\n    await gu.getVisibleGridCells({\n      section: summarySection,\n      cols: [...groubyColumns, \"rownum\"],\n      rowNums: _.range(1, targetData.length + 1),\n    }),\n    summaryData,\n  );\n\n  async function checkTargetGroup(targetGroupIndex: number) {\n    const targetGroup = targetData[targetGroupIndex];\n    const countCell = await gu.getCell({ section: summarySection, col: \"count\", rowNum: targetGroupIndex + 1 });\n    const numTargetRows = targetGroup.length / 3;\n    await countCell.click();\n    assert.deepEqual(\n      await gu.getVisibleGridCells({\n        section: targetSection,\n        cols: [\"onetwo\", \"choices\", \"rownum\"],\n        rowNums: _.range(1, numTargetRows + 1),\n      }),\n      targetGroup,\n    );\n    if (targetSection === \"TABLE1\") {\n      assert.equal(await countCell.getText(), numTargetRows.toString());\n      const csvCells = await gu.downloadSectionCsvGridCells(targetSection, headers);\n      // visible cells text uses newlines to separate list items, CSV export uses commas\n      const expectedCsvCells = targetGroup.map(s => s.replace(\"\\n\", \", \"));\n      assert.deepEqual(csvCells, expectedCsvCells);\n    }\n  }\n\n  for (let i = 0; i < targetData.length; i++) {\n    await checkTargetGroup(i);\n  }\n\n  if (targetSection === \"TABLE1\") {\n    // Check recursiveMoveToCursorPos\n    for (let rowNum = 1; rowNum <= 8; rowNum++) {\n      // Click an anchor link\n      const anchorCell = gu.getCell({ section: \"Anchors\", rowNum, col: 1 });\n      await driver.withActions(a => a.click(anchorCell.find(\".test-tb-link\")));\n\n      // Check that navigation to the link target worked\n      await gu.waitToPass(async () =>\n        assert.equal(await gu.getActiveSectionTitle(), \"TABLE1\"));\n      assert.equal(await gu.getActiveCell().getText(), String(rowNum));\n\n      // Check that the link target is still filtered correctly by the link source,\n      // which should imply that the link source cursor is in the right place\n      await gu.selectSectionByTitle(summarySection);\n      const summaryRowNum = await gu.getSelectedRowNum();\n      await checkTargetGroup(summaryRowNum - 1);\n    }\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/SelectBySummaryRef.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { addToRepl, assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"SelectBySummaryRef\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n  addToRepl(\"gu2\", gu);\n\n  before(async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const doc = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\",\n      \"SelectBySummaryRef.grist\", false);\n    await driver.get(`${server.getHost()}/o/nasa/doc/${doc.id}`);\n    await gu.waitForDocToLoad();\n  });\n\n  it(\"should give correct options when linking with a summary table with ref/reflist columns\", async () => {\n    /*\n    The doc has 3 tables on one page with these columns:\n    1. Source:\n      - 'Other ref' is a reflist to 'Other'\n    2. Summary (a summary table of Source):\n      - 'Other ref' is the groupby column, so now it's a *ref* to 'Other', hence the column name in Source\n      - 'Source ref' is a ref to Source\n      - 'Source reflist' is a reflist to Source\n      - 'group' is the usual group column in summary tables (a reflist to Source) which is hidden from the options.\n    3. Other:\n      - 'Text' which won't be mentioned again since it's not a reference or anything.\n      - 'Source ref' is a ref to Source\n     */\n\n    const sourceOptions = [\n      \"Other\",\n      \"Other • Source ref\",\n      \"Summary\",\n      \"Summary • Other ref\",\n      \"Summary • Source ref\",\n      \"Summary • Source reflist\",\n    ];\n    const summaryOptions = [\n      \"Source → Source ref\",\n      \"Source → Source reflist\",\n      \"Source • Other ref\",\n      \"Other\",\n      \"Other • Source ref → Source ref\",\n      \"Other • Source ref → Source reflist\",\n    ];\n    const otherOptions = [\n      \"Source\",\n      \"Source • Other ref\",\n      \"Summary • Other ref\",\n      \"Summary → Source ref\",\n      \"Summary • Source ref\",\n      \"Summary • Source reflist\",\n    ];\n    await checkRightPanelSelectByOptions(\"Source\", sourceOptions);\n    await checkRightPanelSelectByOptions(\"Other\", otherOptions);\n    await checkRightPanelSelectByOptions(\"Summary\", summaryOptions);\n\n    // Detach the summary table\n    await driver.find(\".test-detach-button\").click();\n    await gu.waitForServer();\n\n    // Each widget now has an option to select by the `group` reflist column of Summary\n    // in place of selecting by 'summaryness'.\n    const sourceOptionsWithGroup = [...sourceOptions, \"Summary • group\"];\n    assert.deepEqual(sourceOptionsWithGroup.splice(2, 1), [\"Summary\"]);\n\n    const otherOptionsWithGroup = [...otherOptions, \"Summary • group\"];\n    assert.deepEqual(otherOptionsWithGroup.splice(3, 1), [\"Summary → Source ref\"]);\n\n    // The summary table has also gained new options to select by the group column.\n    // There were no corresponding 'summaryness' options before because a summary table can't select by its source table\n    // (based purely on summaryness), only the other way around.\n    // Same for selecting by a reference to the source table.\n    // Such options are theoretically possible but are disabled because they're a bit weird,\n    // usually filter linking to a single row when cursor linking would make more sense and still not be very useful.\n    const summaryOptionsWithGroup = [...summaryOptions, \"Other • Source ref → group\"];\n    summaryOptionsWithGroup.splice(2, 0, \"Source → group\");\n\n    await checkRightPanelSelectByOptions(\"Source\", sourceOptionsWithGroup);\n    await checkRightPanelSelectByOptions(\"Other\", otherOptionsWithGroup);\n    await checkRightPanelSelectByOptions(\"Summary\", summaryOptionsWithGroup);\n\n    // Undo detaching the summary table\n    await gu.undo();\n  });\n\n  it(\"should give correct options when adding a new summary table\", async () => {\n    // Go to the second page in the document, which only has a widget for the 'Other' table\n    await gu.getPageItem(\"Other\").click();\n\n    await gu.openAddWidgetToPage();\n\n    // Sanity check for the select by options of the plain table\n    await gu.selectWidget(\"Table\", \"Other\", { dontAdd: true });\n    await checkAddWidgetSelectByOptions([\n      \"Other\",\n      \"Other • Source ref\",\n    ]);\n\n    // Select by options for summary tables of Other only exist when grouping by Source ref\n    await gu.selectWidget(\"Table\", \"Other\", { dontAdd: true, summarize: [] });\n    await checkAddWidgetSelectByOptions(null);\n    await gu.selectWidget(\"Table\", \"Other\", { dontAdd: true, summarize: [\"Text\"] });\n    await checkAddWidgetSelectByOptions(null);\n    await gu.selectWidget(\"Table\", \"Other\", { dontAdd: true, summarize: [\"Source ref\"] });\n    // Note that in this case we are inferring options for a table that doesn't exist anywhere yet\n    await checkAddWidgetSelectByOptions([\n      \"Other • Source ref\",\n    ]);\n\n    // Actually add the summary table in the last case above, selected by the only option\n    await gu.selectWidget(\"Table\", \"Other\",\n      { selectBy: \"Other • Source ref\", summarize: [\"Source ref\"] });\n\n    // Check that the link is actually there in the right panel and that the options are the same as when adding.\n    await checkCurrentSelectBy(\"Other • Source ref\");\n    await checkRightPanelSelectByOptions(\"OTHER [by Source ref]\", [\n      \"Other • Source ref\",\n    ]);\n\n    // Undo adding the summary table\n    await gu.undo();\n  });\n\n  it(\"should give correct options when adding an existing summary table\", async () => {\n    // Go to the second page in the document, which only has a widget for the 'Other' table\n    await gu.getPageItem(\"Other\").click();\n\n    await gu.openAddWidgetToPage();\n\n    // Sanity check for the select by options of the plain table\n    await gu.selectWidget(\"Table\", \"Source\", { dontAdd: true });\n    await checkAddWidgetSelectByOptions([\n      \"Other\",\n      \"Other • Source ref\",\n    ]);\n\n    // No select by options for summary table without groupby columns\n    await gu.selectWidget(\"Table\", \"Source\", { dontAdd: true, summarize: [] });\n    await checkAddWidgetSelectByOptions(null);\n\n    // This summary table already exists on the first page.\n    // '→ Source ref' and '→ Source reflist' refer to formula columns in the summary table that\n    // don't exist by default.\n    await gu.selectWidget(\"Table\", \"Source\", { dontAdd: true, summarize: [\"Other ref\"] });\n    await checkAddWidgetSelectByOptions([\n      \"Other\",\n      \"Other • Source ref → Source ref\",\n      \"Other • Source ref → Source reflist\",\n    ]);\n\n    // Actually add the summary table in the last case above, selected by the second option\n    await gu.selectWidget(\"Table\", \"Source\",\n      { selectBy: \"Other • Source ref → Source ref\", summarize: [\"Other ref\"] });\n\n    // Check that the link is actually there in the right panel and that the options are the same as when adding.\n    await checkCurrentSelectBy(\"Other • Source ref → Source ref\");\n    await checkRightPanelSelectByOptions(\"SOURCE [by Other ref]\", [\n      \"Other\",\n      \"Other • Source ref → Source ref\",\n      \"Other • Source ref → Source reflist\",\n    ]);\n  });\n});\n\n// Check that the 'Select by' menu in the right panel for the section has the expected options\nasync function checkRightPanelSelectByOptions(section: string, expected: string[]) {\n  await gu.openSelectByForSection(section);\n\n  const actual = await gu.findOpenMenuAllItems(\".test-select-row\", e => e.getText());\n  assert.deepEqual(actual, [\"Select widget\", ...expected]);\n  await gu.sendKeys(Key.ESCAPE);\n}\n\nasync function checkAddWidgetSelectByOptions(expected: string[] | null) {\n  const actual = await driver.findAll(\".test-wselect-selectby option\", e => e.getText());\n  assert.deepEqual(actual, expected === null ? [] : [\"\", \"Select widget\", ...expected]);\n}\n\nasync function checkCurrentSelectBy(expected: string) {\n  const actual = await driver.find(\".test-right-select-by\").getText();\n  assert.equal(actual, expected);\n}\n"
  },
  {
    "path": "test/nbrowser/SelectionSummary.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\ninterface CellPosition {\n  /** 0-based column index. */\n  col: number;\n  /** 0-based row index. */\n  row: number;\n}\n\ninterface SelectionSummary {\n  dimensions: string;\n  count: number | null;\n  sum: string | null;\n}\n\ndescribe(\"SelectionSummary\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  gu.bigScreen(\"big\");\n\n  before(async function() {\n    const session = await gu.session().personalSite.login();\n    await session.tempDoc(cleanup, \"SelectionSummary.grist\");\n  });\n\n  async function assertSelectionSummary(summary: SelectionSummary | null) {\n    if (!summary) {\n      assert.isFalse(await driver.find(\".test-selection-summary-dimensions\").isPresent());\n      assert.isFalse(await driver.find(\".test-selection-summary-count\").isPresent());\n      assert.isFalse(await driver.find(\".test-selection-summary-sum\").isPresent());\n      return;\n    }\n\n    const { dimensions, count, sum } = summary;\n    await gu.waitToPass(async () => assert.equal(\n      await driver.find(\".test-selection-summary-dimensions\").getText(),\n      dimensions,\n    ), 500);\n    if (count === null) {\n      assert.isFalse(await driver.find(\".test-selection-summary-count\").isPresent());\n    } else {\n      await gu.waitToPass(async () => assert.equal(\n        await driver.find(\".test-selection-summary-count\").getText(),\n        `COUNT ${count}`,\n      ), 500);\n    }\n    if (sum === null) {\n      assert.isFalse(await driver.find(\".test-selection-summary-sum\").isPresent());\n    } else {\n      await gu.waitToPass(async () => assert.equal(\n        await driver.find(\".test-selection-summary-sum\").getText(),\n        `SUM ${sum}`,\n      ), 500);\n    }\n  }\n\n  function shiftClick(el: WebElement) {\n    return driver.withActions(actions => actions.keyDown(Key.SHIFT).click(el).keyUp(Key.SHIFT));\n  }\n\n  async function selectAndAssert(start: CellPosition, end: CellPosition, summary: SelectionSummary | null) {\n    const { col: startCol, row: startRow } = start;\n    await gu.getCell(startCol, startRow + 1).click();\n    const { col: endCol, row: endRow } = end;\n    await shiftClick(await gu.getCell(endCol, endRow + 1));\n    await assertSelectionSummary(summary);\n  }\n\n  it(\"does not display anything if only a single cell is selected\", async function() {\n    for (const [col, row] of [[0, 1], [2, 3]]) {\n      await gu.getCell(col, row).click();\n      await assertSelectionSummary(null);\n    }\n  });\n\n  it(\"displays sum if the selection contains numbers\", async function() {\n    await selectAndAssert({ col: 0, row: 0 }, { col: 0, row: 6 }, {\n      dimensions: \"7⨯1\",\n      count: null,\n      sum: \"$135,692,590\",\n    });\n    await selectAndAssert({ col: 0, row: 3 }, { col: 0, row: 6 }, {\n      dimensions: \"4⨯1\",\n      count: null,\n      sum: \"$135,679,011\",\n    });\n\n    await selectAndAssert({ col: 4, row: 0 }, { col: 4, row: 6 }, {\n      dimensions: \"7⨯1\",\n      count: null,\n      sum: \"135692590\",\n    });\n    await selectAndAssert({ col: 0, row: 0 }, { col: 4, row: 6 }, {\n      dimensions: \"7⨯5\",\n      count: null,\n      sum: \"$271,385,168.02\",\n    });\n  });\n\n  it(\"uses formatter of the first (leftmost) numeric column\", async function() {\n    // Column 0 is U.S. currency, while column 1 is just a plain decimal number.\n    await selectAndAssert({ col: 0, row: 0 }, { col: 1, row: 6 }, {\n      dimensions: \"7⨯2\",\n      count: null,\n      sum: \"$135,692,578.02\",\n    });\n    await selectAndAssert({ col: 1, row: 0 }, { col: 1, row: 6 }, {\n      dimensions: \"7⨯1\",\n      count: null,\n      sum: \"-11.98\",\n    });\n    // The entire selection (spanning 6 columns) uses the formatter of column 0.\n    await selectAndAssert({ col: 0, row: 0 }, { col: 5, row: 6 }, {\n      dimensions: \"7⨯6\",\n      count: null,\n      sum: \"$271,385,156.04\",\n    });\n  });\n\n  it(\"displays count if the selection doesn't contain numbers\", async function() {\n    await selectAndAssert({ col: 2, row: 0 }, { col: 2, row: 6 }, {\n      dimensions: \"7⨯1\",\n      count: 5,\n      sum: null,\n    });\n    await selectAndAssert({ col: 2, row: 3 }, { col: 2, row: 6 }, {\n      dimensions: \"4⨯1\",\n      count: 2,\n      sum: null,\n    });\n\n    // Scroll horizontally to the end of the table.\n    await gu.sendKeys(Key.END);\n\n    await selectAndAssert({ col: 7, row: 0 }, { col: 10, row: 4 }, {\n      dimensions: \"5⨯4\",\n      count: 7,\n      sum: null,\n    });\n    await selectAndAssert({ col: 10, row: 0 }, { col: 12, row: 6 }, {\n      dimensions: \"7⨯3\",\n      count: 5,\n      sum: null,\n    });\n  });\n\n  it(\"does not count false values\", async function() {\n    // False values in boolean columns should not be included in count\n    await selectAndAssert({ col: 2, row: 0 }, { col: 3, row: 5 }, {\n      dimensions: \"6⨯2\",\n      count: 9,\n      sum: null,\n    });\n  });\n\n  it(\"uses the show column of reference columns for computations\", async function() {\n    // Column 6 is a Reference column pointing to column 0.\n    await gu.sendKeys(Key.HOME);\n    await selectAndAssert({ col: 6, row: 0 }, { col: 6, row: 6 }, {\n      dimensions: \"7⨯1\",\n      count: null,\n      sum: \"-$123,456\",\n    });\n\n    // Column 7 is a Reference List column pointing to column 0. At this time, it\n    // only displays counts (but flattening sums also seems like intuitive behavior).\n    await gu.sendKeys(Key.END);\n    await selectAndAssert({ col: 7, row: 0 }, { col: 7, row: 6 }, {\n      dimensions: \"7⨯1\",\n      count: 2,\n      sum: null,\n    });\n  });\n\n  it(\"updates whenever the selection changes\", async function() {\n    // Scroll horizontally to the beginning of the table.\n    await gu.sendKeys(Key.HOME);\n\n    // Select a region of the table.\n    await selectAndAssert({ col: 0, row: 2 }, { col: 0, row: 6 }, {\n      dimensions: \"5⨯1\",\n      count: null,\n      sum: \"$135,691,356\",\n    });\n\n    // Without de-selecting, use keyboard shortcuts to grow the selection to the right.\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.ARROW_RIGHT));\n\n    // Check that the selection summary was updated.\n    await assertSelectionSummary({\n      dimensions: \"5⨯2\",\n      count: null,\n      sum: \"$135,691,368.5\",\n    });\n  });\n\n  it(\"displays correct sum when all rows/columns are selected\", async function() {\n    await driver.find(\".gridview_data_corner_overlay\").click();\n    await assertSelectionSummary({\n      dimensions: \"7⨯14\",\n      count: null,\n      sum: \"$271,261,700.04\",\n    });\n  });\n\n  describe(\"on narrow screens\", function() {\n    gu.narrowScreen();\n\n    it(\"is not visible\", async function() {\n      await assertSelectionSummary(null);\n      await selectAndAssert({ col: 0, row: 0 }, { col: 0, row: 6 }, null);\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ShiftSelection.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\ninterface CellSelection {\n  /** 0-based column index. */\n  colStart: number;\n\n  /** 0-based column index. */\n  colEnd: number;\n\n  /** 0-based row index. */\n  rowStart: number;\n\n  /** 0-based row index. */\n  rowEnd: number;\n}\n\ninterface SelectionRange {\n  /** 0-based index. */\n  start: number;\n\n  /** 0-based index. */\n  end: number;\n}\n\ndescribe(\"ShiftSelection\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  gu.bigScreen();\n\n  before(async function() {\n    const session = await gu.session().personalSite.login();\n    await session.tempDoc(cleanup, \"ShiftSelection.grist\");\n  });\n\n  async function getSelectionRange(parent: WebElement, selector: string): Promise<SelectionRange | undefined> {\n    let start, end;\n\n    let selectionActive = false;\n    const elements = await parent.findAll(selector);\n    for (let i = 0; i < elements.length; i++) {\n      const element = elements[i];\n      const isSelected = await element.matches(\".selected\");\n\n      if (isSelected && !selectionActive) {\n        start = i;\n        selectionActive = true;\n        continue;\n      }\n\n      if (!isSelected && selectionActive) {\n        end = i - 1;\n        break;\n      }\n    }\n\n    if (start === undefined) {\n      return undefined;\n    }\n\n    if (end === undefined) {\n      end = elements.length - 1;\n    }\n\n    return {\n      start: start,\n      end: end,\n    };\n  }\n\n  async function getSelectedCells(): Promise<CellSelection | undefined> {\n    const activeSection = await driver.find(\".active_section\");\n\n    const colSelection = await getSelectionRange(activeSection, \".column_names .column_name\");\n    if (!colSelection) {\n      return undefined;\n    }\n\n    const rowSelection = await getSelectionRange(activeSection, \".gridview_row .gridview_data_row_num\");\n    if (!rowSelection) {\n      // Edge case if only a cell in the \"new\" row is selected\n      // Not relevant for our tests\n      return undefined;\n    }\n\n    return {\n      colStart: colSelection.start,\n      colEnd: colSelection.end,\n      rowStart: rowSelection.start,\n      rowEnd: rowSelection.end,\n    };\n  }\n\n  async function assertCellSelection(expected: CellSelection | undefined) {\n    const currentSelection = await getSelectedCells();\n    assert.deepEqual(currentSelection, expected);\n  }\n\n  it(\"Shift+Up extends the selection up\", async function() {\n    await gu.getCell(1, 2).click();\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.UP));\n    await assertCellSelection({ colStart: 1, colEnd: 1, rowStart: 0, rowEnd: 1 });\n  });\n\n  it(\"Shift+Down extends the selection down\", async function() {\n    await gu.getCell(1, 2).click();\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.DOWN));\n    await assertCellSelection({ colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 2 });\n  });\n\n  it(\"Shift+Left extends the selection left\", async function() {\n    await gu.getCell(1, 2).click();\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));\n    await assertCellSelection({ colStart: 0, colEnd: 1, rowStart: 1, rowEnd: 1 });\n  });\n\n  it(\"Shift+Right extends the selection right\", async function() {\n    await gu.getCell(1, 2).click();\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));\n    await assertCellSelection({ colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 1 });\n  });\n\n  it(\"Shift+Right + Shift+Left leads to the initial selection\", async function() {\n    await gu.getCell(1, 2).click();\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));\n    await assertCellSelection({ colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 1 });\n  });\n\n  it(\"Shift+Up + Shift+Down leads to the initial selection\", async function() {\n    await gu.getCell(1, 2).click();\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.UP));\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.DOWN));\n    await assertCellSelection({ colStart: 1, colEnd: 1, rowStart: 1, rowEnd: 1 });\n  });\n\n  it(\"Ctrl+Shift+Up extends the selection blockwise up\", async function() {\n    await gu.getCell(5, 7).click();\n\n    const ctrlKey = await gu.modKey();\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP));\n    await assertCellSelection({ colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 6 });\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP));\n    await assertCellSelection({ colStart: 5, colEnd: 5, rowStart: 2, rowEnd: 6 });\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP));\n    await assertCellSelection({ colStart: 5, colEnd: 5, rowStart: 0, rowEnd: 6 });\n  });\n\n  it(\"Ctrl+Shift+Down extends the selection blockwise down\", async function() {\n    await gu.getCell(5, 5).click();\n\n    const ctrlKey = await gu.modKey();\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN));\n    await assertCellSelection({ colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 6 });\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN));\n    await assertCellSelection({ colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 8 });\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.DOWN));\n    await assertCellSelection({ colStart: 5, colEnd: 5, rowStart: 4, rowEnd: 10 });\n  });\n\n  it(\"Ctrl+Shift+Left extends the selection blockwise left\", async function() {\n    await gu.getCell(6, 5).click();\n\n    const ctrlKey = await gu.modKey();\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT));\n    await assertCellSelection({ colStart: 4, colEnd: 6, rowStart: 4, rowEnd: 4 });\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT));\n    await assertCellSelection({ colStart: 2, colEnd: 6, rowStart: 4, rowEnd: 4 });\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.LEFT));\n    await assertCellSelection({ colStart: 0, colEnd: 6, rowStart: 4, rowEnd: 4 });\n  });\n\n  it(\"Ctrl+Shift+Right extends the selection blockwise right\", async function() {\n    await gu.getCell(4, 5).click();\n\n    const ctrlKey = await gu.modKey();\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT));\n    await assertCellSelection({ colStart: 4, colEnd: 6, rowStart: 4, rowEnd: 4 });\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT));\n    await assertCellSelection({ colStart: 4, colEnd: 8, rowStart: 4, rowEnd: 4 });\n\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.RIGHT));\n    await assertCellSelection({ colStart: 4, colEnd: 10, rowStart: 4, rowEnd: 4 });\n  });\n\n  it(\"Ctrl+Shift+* extends the selection until all the next cells are empty\", async function() {\n    await gu.getCell(3, 7).click();\n\n    const ctrlKey = await gu.modKey();\n\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP));\n    await assertCellSelection({ colStart: 3, colEnd: 4, rowStart: 2, rowEnd: 6 });\n\n    await gu.getCell(4, 7).click();\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));\n    await gu.sendKeys(Key.chord(ctrlKey, Key.SHIFT, Key.UP));\n    await assertCellSelection({ colStart: 3, colEnd: 4, rowStart: 4, rowEnd: 6 });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Smoke.ts",
    "content": "/**\n *\n * This is a minimal test to make sure documents can be created, edited, and\n * reopened.  Grist has a very extensive test set that has not yet been ported\n * to the grist-core.\n *\n */\n\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\nasync function openMainPage() {\n  await driver.get(`${server.getHost()}`);\n  while (true) {\n    try {\n      if (await driver.find(\".test-intro-create-doc\").isPresent()) {\n        return;\n      }\n    } catch (e) {\n      // don't worry about transients.\n    }\n    await driver.sleep(10);\n  }\n}\n\ndescribe(\"Smoke\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n\n  it(\"can create, edit, and reopen a document\", async function() {\n    this.timeout(20000);\n    await openMainPage();\n    await driver.find(\".test-intro-create-doc\").click();\n    await gu.waitForDocToLoad(20000);\n    await gu.dismissWelcomeTourIfNeeded();\n    await gu.getCell(\"A\", 1).click();\n\n    // Shouldn't be necessary, but an attempt to reduce flakiness that has shown up about opening the cell for editing.\n    await gu.sendKeys(Key.ENTER);\n\n    await gu.enterCell(\"123\");\n    await gu.refreshDismiss({ ignore: true });\n    assert.equal(await gu.getCell(\"A\", 1).getText(), \"123\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SortDates.ntest.js",
    "content": "import { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\n// Helper that returns the cell text prefixed by \"!\" if the cell is invalid.\nasync function valText(cell) {\n  const isInvalid = await cell.find(\".field_clip\").hasClass(\"invalid\");\n  const text = await cell.getText();\n  return (isInvalid ? \"!\" : \"\") + text;\n}\n\nasync function clickColumnMenuSort(colName, itemText) {\n  // Scroll into view doesn't work on Grid because the first column\n  // will always be hidden behind row number element. So we will always\n  // move to the first column before opening menu, as scrolling right\n  // does work (there are no absolute positioned elements there).\n  await gu.sendKeys($.HOME);\n  await gu.openColumnMenu(colName);\n  const dir = (itemText === \"Sort ascending\") ? \"asc\" : \"dsc\";\n  return $(`.grist-floating-menu .test-sort-${dir}`).click();\n}\n\ndescribe(\"SortDates.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"SortDates.grist\", true);\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should display calculated DateTimes as valid\", async function() {\n    // Check that Dates and DateTimes returned from 'Any' formulas are displayed\n    // as valid (rather than pink error values).\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2, 3], mapper: valText}), [\n      \"2017-04-11\", \"2017-04-11 9:30am\",  \"2017-04-12\",         \"2017-04-12 09:30:00-04:00\",\n      \"2017-07-13\", \"2017-07-13 4:00am\",  \"2017-07-14\",         \"2017-07-14 04:00:00-04:00\",\n      \"!invalid1\",  \"!invalid2\",          \"!#TypeError\",        \"!#TypeError\",\n      \"2017-05-01\", \"2017-05-01 7:00am\",  \"2017-05-02\",         \"2017-05-02 07:00:00-04:00\",\n      \"2017-04-21\", \"2017-04-21 12:00pm\", \"2017-04-22\",         \"2017-04-22 12:00:00-04:00\",\n      \"\",           \"\",                   \"\",                   \"\",\n      \"2017-03-16\", \"2017-03-16 4:00pm\",  \"2017-03-17\",         \"2017-03-17 16:00:00-04:00\",\n    ]);\n  });\n\n  it(\"should sort correctly by Date or DateTime\", async function() {\n    // Check that we sort by the Date and DateTime column works as expected, even\n    // when blanks or AltText is present.\n    await gu.openSidePane(\"view\");\n    await gu.toggleSidePanel(\"left\", \"close\");\n    await $(\".test-config-sortAndFilter\").click();\n\n    // Sort by a special column first to rearrange. It's specially chosen to trigger some\n    // previously incorrect comparisons that may cause wrong order. (The actual bug only existed\n    // at the time of writing in the test case for Any formula columns returning Dates/DateTimes.)\n    await clickColumnMenuSort(\"Order\", \"Sort ascending\");\n    let orderRow = await $(\".test-sort-config-row:contains(Order)\").wait().elem();\n    await assert.isPresent(orderRow);\n    await assert.isPresent(orderRow.find(\".test-sort-config-sort-order-asc\"));\n    await gu.getColumnHeader(\"Date\").scrollIntoView({inline: \"end\"});\n    await clickColumnMenuSort(\"Date\", \"Sort ascending\");\n    const dateRow = await $(\".test-sort-config-row:contains(Date)\").wait().elem();\n    await assert.isPresent(dateRow);\n    await assert.isPresent(dateRow.find(\".test-sort-config-sort-order-asc\"));\n\n    // Check that the data is now sorted.\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2, 3], mapper: valText}), [\n      \"2017-03-16\", \"2017-03-16 4:00pm\",  \"2017-03-17\",         \"2017-03-17 16:00:00-04:00\",\n      \"2017-04-11\", \"2017-04-11 9:30am\",  \"2017-04-12\",         \"2017-04-12 09:30:00-04:00\",\n      \"2017-04-21\", \"2017-04-21 12:00pm\", \"2017-04-22\",         \"2017-04-22 12:00:00-04:00\",\n      \"2017-05-01\", \"2017-05-01 7:00am\",  \"2017-05-02\",         \"2017-05-02 07:00:00-04:00\",\n      \"2017-07-13\", \"2017-07-13 4:00am\",  \"2017-07-14\",         \"2017-07-14 04:00:00-04:00\",\n      \"\",           \"\",                   \"\",                   \"\",\n      \"!invalid1\",  \"!invalid2\",          \"!#TypeError\",        \"!#TypeError\",\n    ]);\n\n    await clickColumnMenuSort(\"Order\", \"Sort ascending\");\n    orderRow = await $(\".test-sort-config-row:contains(Order)\").wait().elem();\n    await assert.isPresent(orderRow);\n    await assert.isPresent(orderRow.find(\".test-sort-config-sort-order-asc\"));\n    await clickColumnMenuSort(\"DTime\", \"Sort descending\");\n    const dtimeRow = await $(\".test-sort-config-row:contains(DTime)\").wait().elem();\n    await assert.isPresent(dtimeRow);\n    await assert.isPresent(dtimeRow.find(\".test-sort-config-sort-order-desc\"));\n\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2, 3], mapper: valText}), [\n      \"!invalid1\",  \"!invalid2\",          \"!#TypeError\",        \"!#TypeError\",\n      \"\",           \"\",                   \"\",                   \"\",\n      \"2017-07-13\", \"2017-07-13 4:00am\",  \"2017-07-14\",         \"2017-07-14 04:00:00-04:00\",\n      \"2017-05-01\", \"2017-05-01 7:00am\",  \"2017-05-02\",         \"2017-05-02 07:00:00-04:00\",\n      \"2017-04-21\", \"2017-04-21 12:00pm\", \"2017-04-22\",         \"2017-04-22 12:00:00-04:00\",\n      \"2017-04-11\", \"2017-04-11 9:30am\",  \"2017-04-12\",         \"2017-04-12 09:30:00-04:00\",\n      \"2017-03-16\", \"2017-03-16 4:00pm\",  \"2017-03-17\",         \"2017-03-17 16:00:00-04:00\",\n    ]);\n  });\n\n  it(\"should sort correctly by Any returning Date or DateTime\", async function() {\n    // Formulas of type 'Any' returning a Date or DateTime involve comparison of complex values\n    // (arrays) when sorting. Check that it works even in the presence of error values.\n\n    await clickColumnMenuSort(\"Order\", \"Sort ascending\");\n    let orderRow = await $(\".test-sort-config-row:contains(Order)\").wait().elem();\n    await assert.isPresent(orderRow);\n    await assert.isPresent(orderRow.find(\".test-sort-config-sort-order-asc\"));\n    await clickColumnMenuSort(\"CalcDate\", \"Sort ascending\");\n    let calcDateRow = await $(\".test-sort-config-row:contains(CalcDate)\").wait().elem();\n    await assert.isPresent(calcDateRow);\n    await assert.isPresent(calcDateRow.find(\".test-sort-config-sort-order-asc\"));\n\n    // Check that the data is now sorted.\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2, 3], mapper: valText}), [\n      \"\",           \"\",                   \"\",                   \"\",\n      \"2017-03-16\", \"2017-03-16 4:00pm\",  \"2017-03-17\",         \"2017-03-17 16:00:00-04:00\",\n      \"2017-04-11\", \"2017-04-11 9:30am\",  \"2017-04-12\",         \"2017-04-12 09:30:00-04:00\",\n      \"2017-04-21\", \"2017-04-21 12:00pm\", \"2017-04-22\",         \"2017-04-22 12:00:00-04:00\",\n      \"2017-05-01\", \"2017-05-01 7:00am\",  \"2017-05-02\",         \"2017-05-02 07:00:00-04:00\",\n      \"2017-07-13\", \"2017-07-13 4:00am\",  \"2017-07-14\",         \"2017-07-14 04:00:00-04:00\",\n      \"!invalid1\",  \"!invalid2\",          \"!#TypeError\",        \"!#TypeError\",\n    ]);\n\n    await clickColumnMenuSort(\"Order\", \"Sort ascending\");\n    orderRow = await $(\".test-sort-config-row:contains(Order)\").wait().elem();\n    await assert.isPresent(orderRow);\n    await assert.isPresent(orderRow.find(\".test-sort-config-sort-order-asc\"));\n    await clickColumnMenuSort(\"CalcDTime\", \"Sort descending\");\n    calcDateRow = await $(\".test-sort-config-row:contains(CalcDTime)\").wait().elem();\n    await assert.isPresent(calcDateRow);\n    await assert.isPresent(calcDateRow.find(\".test-sort-config-sort-order-desc\"));\n\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0, 1, 2, 3], mapper: valText}), [\n      \"!invalid1\",  \"!invalid2\",          \"!#TypeError\",        \"!#TypeError\",\n      \"2017-07-13\", \"2017-07-13 4:00am\",  \"2017-07-14\",         \"2017-07-14 04:00:00-04:00\",\n      \"2017-05-01\", \"2017-05-01 7:00am\",  \"2017-05-02\",         \"2017-05-02 07:00:00-04:00\",\n      \"2017-04-21\", \"2017-04-21 12:00pm\", \"2017-04-22\",         \"2017-04-22 12:00:00-04:00\",\n      \"2017-04-11\", \"2017-04-11 9:30am\",  \"2017-04-12\",         \"2017-04-12 09:30:00-04:00\",\n      \"2017-03-16\", \"2017-03-16 4:00pm\",  \"2017-03-17\",         \"2017-03-17 16:00:00-04:00\",\n      \"\",           \"\",                   \"\",                   \"\",\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SortEditSave.ntest.js",
    "content": "import { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"SortEditSave.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"Hello.grist\", true);\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should not jump to next row when an updated field jumps in a sorted section\", async function() {\n    // Enter numbers and sort by them\n    await gu.enterGridValues(0, 1, [[\"1\", \"2\", \"3\", \"4\"]]);\n    await gu.clickCellRC(0, 1);\n    await gu.setType(\"Numeric\");\n    await $(\".test-type-transform-apply\").click();\n    await gu.openColumnMenu(\"B\");\n    await $(\".grist-floating-menu .test-sort-asc\").click();\n\n    // Edit one of the numbers so that it doesn't get re-sorted. Assert that the cursor\n    // moves down one cell\n    await gu.clickCellRC(1, 1);\n    await gu.sendKeys(\"2.5\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await $(\".field_clip.has_cursor\").text(), \"3\");\n\n    // Edit one of the numbers so that it gets re-sorted. Assert that the cursor stays\n    // on the cell\n    await gu.clickCellRC(1, 1);\n    await gu.sendKeys(\"3.5\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await $(\".field_clip.has_cursor\").text(), \"3.5\");\n  });\n\n  it(\"should not jump to next row when a formula update causes the field to jump\", async function() {\n    // Enter a formula in the next column, and sort by the column\n    await gu.clickCellRC(0, 2);\n    await gu.sendKeys(\"=\");\n    await $(\".test-editor-tooltip-convert\").click();      // Convert to a formula\n    await gu.sendKeys(\"$B\", $.ENTER);\n    await gu.openColumnMenu(\"C\");\n    await $(\".grist-floating-menu .test-sort-asc\").click();\n\n    // Edit the formula so that the row stays in the same place. Assert that the cursor\n    // does NOT move down (since editing a column-wide formula, not doing data entry).\n    await gu.clickCellRC(0, 2);\n    await gu.sendKeys($.ENTER, [$.MOD, \"a\"], \"$B+5\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await $(\".field_clip.has_cursor\").text(), \"6\");\n\n    // Edit the formula so that the row moves. Assert that the cursor says on the cell\n    // in this case too.\n    await gu.clickCellRC(0, 2);\n    await gu.sendKeys($.ENTER, [$.MOD, \"a\"], \"10-$B\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await $(\".field_clip.has_cursor\").text(), \"9\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SortFilterSectionOptions.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key, until } from \"mocha-webdriver\";\n\ndescribe(\"SortFilterSectionOptions\", function() {\n  this.timeout(60000);\n  setupTestSuite();\n\n  async function assertFilterBarPinnedFilters(expected: gu.PinnedFilter[]) {\n    const actual = await gu.getPinnedFilters();\n    assert.deepEqual(actual, expected);\n  }\n\n  async function assertSectionMenuPinnedFilters(expected: gu.PinnedFilter[]) {\n    await driver.findWait(\".test-section-menu-heading-sort\", 100);\n    const allFilters = await driver.findAll(\".grist-floating-menu .test-filter-config-filter\", async (el) => {\n      const column = await el.find(\".test-filter-config-column\");\n      const pinButton = await el.find(\".test-filter-config-pin-filter\");\n      const pinButtonClass = await pinButton.getAttribute(\"class\");\n      const filterIcon = await el.find(\".test-filter-config-filter-icon\");\n      const filterIconClass = await filterIcon.getAttribute(\"class\");\n      return {\n        name: await column.getText(),\n        isPinned: /\\b\\w+-pinned\\b/.test(pinButtonClass),\n        hasUnsavedChanges: /\\b\\w+-accent\\b/.test(filterIconClass),\n      };\n    });\n    const pinnedFilters = allFilters.filter(({ isPinned }) => isPinned);\n    const actual = pinnedFilters.map(({ name, hasUnsavedChanges }) => ({ name, hasUnsavedChanges }));\n    assert.deepEqual(actual, expected);\n  }\n\n  async function assertPinnedFilters(expected: gu.PinnedFilter[]) {\n    await assertFilterBarPinnedFilters(expected);\n    await assertSectionMenuPinnedFilters(expected);\n  }\n\n  before(async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"SortFilterIconTest.grist\", \"newui\");\n    await driver.findContentWait(\".field_clip\", /Apples/, 6000);\n  });\n\n  it(\"should display the unsaved icon on filter changes\", async () => {\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Apples\", \"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"]);\n    await assertFilterBarPinnedFilters([]);\n\n    // Verify that filter icon is present and has no -any class\n    assert.isTrue(await driver.find(\".test-section-menu-filter-icon\").isPresent());\n    assert.isFalse(await driver.find(\".test-section-menu-filter-icon\").matches(\"[class*=-any]\"));\n\n    // Open the filter menu and uncheck an item\n    let menu = await gu.openColumnMenu(\"Name\", \"Filter\");\n    await menu.findContent(\"label\", /Apples/).find(\"input:checked\").click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // Verify that the view got filtered\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5]),\n      [\"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"]);\n    await assertFilterBarPinnedFilters([{ name: \"Name\", hasUnsavedChanges: true }]);\n\n    // Section header should now display the filter icon in the unsaved state\n    assert.isTrue(await driver.find(\".test-section-menu-wrapper[class*=-unsaved] .test-section-menu-filter-icon\")\n      .isPresent());\n\n    // check filter icon is displayed with -any class\n    assert.isTrue(await driver.find(\".test-section-menu-filter-icon\").matches(\"[class*=-any]\"));\n\n    // Open the filter menu and check the previously unchecked item\n    menu = await gu.openColumnMenu(\"Name\", \"Filter\");\n    await menu.findContent(\"label\", /Apples/).find(\"input:not(checked)\").click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // Verify that rows are no longer filtered\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Apples\", \"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"]);\n\n    // Remove the filter\n    await gu.openSectionMenu(\"sortAndFilter\");\n    await driver.findContent(\".test-filter-config-filter\", /Name/)\n      .find(\".test-filter-config-remove-filter\").click();\n\n    // Verify that section header no longer class that match -any\n    assert.isFalse(await driver.find(\".test-section-menu-filter-icon\").matches(\"[class*=-any]\"));\n    assert.isFalse(await driver.find(\".test-section-menu-wrapper[class*=-unsaved] .test-section-menu-filter-icon\")\n      .isPresent());\n    await assertPinnedFilters([]);\n\n    await gu.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should display dropdown menu when there is a filter present\", async () => {\n    let menu = await gu.openSectionMenu(\"sortAndFilter\");\n    // Verify that sort and filter are in default state\n    assert.deepEqual(await menu.findAll(\".test-sort-config-row\"), []);\n    assert.deepEqual(await menu.findAll(\".test-filter-config-filter\"), []);\n    await assertSectionMenuPinnedFilters([]);\n\n    // Close menu\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Activate a filter\n    await (await gu.openColumnMenu(\"Name\", \"Filter\")).findContent(\"label\", /Apples/).click();\n\n    // Open the section menu by clicking \"All filters\"\n    await driver.find(\".test-filter-menu-all-filters-btn\").click();\n\n    // Verify that section menu displays the filtered column\n    menu = await driver.findWait(\".grist-floating-menu\", 100);\n    assert.deepEqual(\n      await menu.findAll(\".test-filter-config-filter\", el => el.getText()),\n      [\"Name\"],\n    );\n    await assertPinnedFilters([{ name: \"Name\", hasUnsavedChanges: true }]);\n\n    const btnSave = await driver.find(\".test-section-menu-btn-save\");\n    const btnRevert = await driver.find(\".test-section-menu-btn-revert\");\n    assert.equal(await btnSave.getText(), \"Save\");\n    assert.equal(await btnRevert.getText(), \"Revert\");\n\n    // Remove the filter\n    await driver\n      .findContent(\".test-filter-config-filter\", /Name/)\n      .find(\".test-filter-config-remove-filter\")\n      .click();\n\n    // Verify that the filter options are back to default\n    await driver.wait(until.stalenessOf(btnSave));\n    await driver.wait(until.stalenessOf(btnRevert));\n    await assertPinnedFilters([]);\n\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should allow saving of filters\", async () => {\n    // Verify that filter icon has not class matching -any and and nothing is filtered\n    assert.isFalse(await driver.find(\".test-section-menu-filter-icon\").matches(\"[class*=-any]\"));\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Apples\", \"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"]);\n\n    // Apply a filter\n    await (await gu.openColumnMenu(\"Name\", \"Filter\")).findContent(\"label\", /Apples/).click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // Verify that unsaved filter icon is display and has class matching -any\n    await driver.findWait(\".test-section-menu-wrapper[class*=-unsaved] .test-section-menu-filter-icon\", 100);\n    assert.isTrue(await driver.find(\".test-section-menu-filter-icon\").matches(\"[class*=-any]\"));\n\n    // Click save to view\n    await gu.openSectionMenu(\"sortAndFilter\");\n    await driver.find(\".test-section-menu-btn-save\").click();\n    await gu.waitForServer();\n\n    // Verify that the wrapper has no -unsaved class\n    assert.isFalse(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5]),\n      [\"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"]);\n    await assertFilterBarPinnedFilters([{ name: \"Name\", hasUnsavedChanges: false }]);\n\n    // Reload page\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // Verify that rows are still filtered and icon is present\n    assert.isFalse(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5]),\n      [\"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"]);\n    await assertFilterBarPinnedFilters([{ name: \"Name\", hasUnsavedChanges: false }]);\n\n    // Remove the filter\n    await gu.openSectionMenu(\"sortAndFilter\");\n    await assertSectionMenuPinnedFilters([{ name: \"Name\", hasUnsavedChanges: false }]);\n    await driver\n      .findContent(\".test-filter-config-filter\", /Name/)\n      .find(\".test-filter-config-remove-filter\")\n      .click();\n\n    // Verify that unsaved icon is displayed\n    assert.isTrue(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Apples\", \"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"]);\n    await assertPinnedFilters([]);\n\n    // Click to save again\n    await driver.find(\".test-section-menu-btn-save\").click();\n    await gu.waitForServer();\n\n    // Verify that icon has not class matching -any\n    assert.isFalse(await driver.find(\".test-section-menu-filter-icon\").matches(\"[class*=-any]\"));\n    await assertFilterBarPinnedFilters([]);\n\n    // Reload page\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // Verify that rows are not filtered and icon has not class matchin -any\n    assert.isFalse(await driver.find(\".test-section-menu-filter-icon\").matches(\"[class*=-any]\"));\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Apples\", \"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"]);\n    await assertFilterBarPinnedFilters([]);\n  });\n\n  it(\"should allow changing direction and removing sort from menu\", async () => {\n    // Verify that sort/filter icon has not class matching -any\n    assert.isFalse(await driver.find(\".test-section-menu-filter-icon\").matches(\"[class*=-any]\"));\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Apples\", \"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"]);\n\n    // Add a sort\n    await (await gu.openColumnMenu(\"Name\")).findContent(\"li\", \"Sort\").findContent(\"div\", \"A-Z\").click();\n\n    // Verify that unsaved icon is displayed\n    assert.isTrue(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n    // Verify that the column has been sorted\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Apples\", \"Bananas\", \"Clementines\", \"Grapefruit\", \"Grapes\", \"Oranges\"]);\n    // Verify that proper sort is listed in menu and highlighted\n    await gu.openSectionMenu(\"sortAndFilter\");\n    let sortColumn = await driver.findContent(\".test-sort-config-column\", \"Name\");\n    let sortIcon = await sortColumn.find(\".test-sort-config-order\");\n    assert.isTrue((await sortIcon.getAttribute(\"class\")).split(\" \").some(c => c.endsWith(\"-asc\")),\n      \"should include -asc class\");\n\n    // Change sort direction in menu\n    await sortColumn.click();\n\n    // Verify that the icon changed\n    sortColumn = await driver.findContent(\".test-sort-config-column\", \"Name\");\n    sortIcon = await sortColumn.find(\".test-sort-config-order\");\n    assert.isTrue((await sortIcon.getAttribute(\"class\")).split(\" \").some(c => c.endsWith(\"-desc\")),\n      \"should include -desc class\");\n\n    // Verify that the column direction has been changed\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Oranges\", \"Grapes\", \"Grapefruit\", \"Clementines\", \"Bananas\", \"Apples\"]);\n\n    // Save to view\n    await driver.find(\".test-section-menu-btn-save\").click();\n\n    await gu.waitForServer();\n\n    // re-open the sort&filter menu\n    await gu.openSectionMenu(\"sortAndFilter\");\n\n    // Verify that saved icon is displayed\n    assert.isFalse(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n\n    // Verify that sort column direction was saved\n    sortColumn = await driver.findContent(\".test-sort-config-column\", \"Name\");\n    sortIcon = await sortColumn.find(\".test-sort-config-order\");\n    assert.isTrue((await sortIcon.getAttribute(\"class\")).split(\" \").some(c => c.endsWith(\"-desc\")),\n      \"should include a -desc class\");\n\n    // Reload\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // Verify that wrapper has not -unsaved class\n    assert.isFalse(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n\n    // Verify that sort is listed in menu\n    await gu.openSectionMenu(\"sortAndFilter\");\n    sortColumn = await driver.findContent(\".test-sort-config-column\", \"Name\");\n    sortIcon = await sortColumn.find(\".test-sort-config-order\");\n    assert.isTrue((await sortIcon.getAttribute(\"class\")).split(\" \").some(c => c.endsWith(\"-desc\")),\n      \"should include a -desc class\");\n\n    // Verify that column is properly sorted\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Oranges\", \"Grapes\", \"Grapefruit\", \"Clementines\", \"Bananas\", \"Apples\"]);\n\n    // Change the sort direction\n    await sortIcon.click();\n\n    // Verify that unsaved icon is displayed\n    assert.isTrue(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n    // Verify that sort icon direction is changed\n    sortColumn = await driver.findContent(\".test-sort-config-column\", \"Name\");\n    sortIcon = await sortColumn.find(\".test-sort-config-order\");\n    assert.isTrue((await sortIcon.getAttribute(\"class\")).split(\" \").some(c => c.endsWith(\"-asc\")),\n      \"should include a -asc class\");\n\n    // Change the sort direction again\n    await sortIcon.click();\n\n    // Verify that wrapper has not class -unsaved\n    assert.isFalse(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n    // Verify that sort icon direction is changed\n    sortColumn = await driver.findContent(\".test-sort-config-column\", \"Name\");\n    sortIcon = await sortColumn.find(\".test-sort-config-order\");\n    assert.isTrue((await sortIcon.getAttribute(\"class\")).split(\" \").some(c => c.endsWith(\"-desc\")),\n      \"should include a -desc class\");\n\n    // Click remove sort\n    await driver.findContent(\".test-sort-config-row\", \"Name\")\n      .find(\".test-sort-config-remove\").click();\n\n    // Verify that sort column is gone\n    await driver.wait(until.stalenessOf(sortIcon));\n    assert.isEmpty(await driver.findAll(\".test-sort-config-row\"));\n    // Verify that column is not sorted\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Apples\", \"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"]);\n    // Verify that unsaved icon is displayed\n    assert.isTrue(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"));\n    // Click save to view\n    await driver.find(\".test-section-menu-btn-save\").click();\n    await gu.waitForServer();\n    // re-open the view section menu\n    await gu.openSectionMenu(\"sortAndFilter\");\n    // Verify that no sort column is listed in menu\n    assert.isEmpty(await driver.findAll(\".test-sort-config-row\"));\n    // Verify that sort/filter icon has not class matching -any\n    assert.isFalse(await driver.find(\".test-section-menu-filter-icon\").matches(\"[class*=-any]\"));\n\n    // Close the menu\n    await driver.find(\".active_section .test-section-menu-wrapper\").click();\n  });\n\n  it(\"should reflect filter state after hiding/unhiding a column\", async function() {\n    // Hide a column and undo to unhide it.\n    await gu.openColumnMenu(\"Name\", \"Hide\");\n    await gu.waitForServer();\n    await gu.undo();\n\n    // The section-filter button should not be highlighted.\n    assert.equal(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"), false);\n\n    // Filter this column and apply.\n    await gu.openColumnMenu(\"Name\", \"Filter\");\n    await driver.findContent(\".test-filter-menu-wrapper .test-filter-menu-value\", /Apples/).click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // The section-filter button should highlight, and menu should show the filtered column.\n    assert.equal(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"), true);\n    const menu = await gu.openSectionMenu(\"sortAndFilter\");\n    assert.deepEqual(await menu.findAll(\".test-filter-config-filter\", f => f.getText()), [\"Name\"]);\n\n    // Revert the filters, the button should un-highlight again.\n    await menu.find(\".test-section-menu-btn-revert\").click();\n    await driver.sendKeys(Key.ESCAPE);\n    assert.equal(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"), false);\n  });\n\n  it(\"should not change filter state when a filtered column is hidden\", async function() {\n    // Filter a column and apply.\n    await gu.openColumnMenu(\"Name\", \"Filter\");\n    await driver.findContent(\".test-filter-menu-wrapper .test-filter-menu-value\", /Apples/).click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // Check that the section-filter button is highlighted.\n    assert.equal(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"), true);\n\n    // Hide the filtered column.\n    await gu.openColumnMenu(\"Name\", \"Hide\");\n    await gu.waitForServer();\n\n    // Check that the section-filter button is still highlighted.\n    assert.equal(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"), true);\n\n    // Open the section-filter menu and check that it still shows the previously applied filter.\n    let menu = await gu.openSectionMenu(\"sortAndFilter\");\n    assert.equal(await menu.find(\".test-filter-config-filter\").getText(), \"Name\");\n\n    // Close the menu.\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Undo to unhide the column.\n    await gu.undo();\n\n    // Check that the section-filter button and menu still indicate the filter is applied.\n    assert.equal(await driver.find(\".test-section-menu-wrapper\").matches(\"[class*=-unsaved]\"), true);\n    menu = await gu.openSectionMenu(\"sortAndFilter\");\n    assert.equal(await menu.find(\".test-filter-config-filter\").getText(), \"Name\");\n\n    // Close the menu.\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should be able to undo saved changes correctly\", async function() {\n    // add new page to start with no filter and no sort\n    await gu.addNewPage(/Table/, /Table1/);\n\n    // Verify that column is not filtered, not sorted\n    assert.deepEqual(\n      await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Apples\", \"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"],\n    );\n    await assertFilterBarPinnedFilters([]);\n\n    // Activate a filter\n    await (await gu.openColumnMenu(\"Name\", \"Filter\")).findContent(\"label\", /Apples/).click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // Add a sort\n    await (await gu.openColumnMenu(\"Name\")).findContent(\"li\", \"Sort\").findContent(\"div\", \"A-Z\").click();\n\n    // save changes\n    await gu.openSectionMenu(\"sortAndFilter\");\n    await driver.find(\".test-section-menu-btn-save\").click();\n    await gu.waitForServer();\n\n    // Verify that column is filtered and sorted\n    assert.deepEqual(\n      await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5]),\n      [\"Bananas\", \"Clementines\", \"Grapefruit\", \"Grapes\", \"Oranges\"],\n    );\n    await assertFilterBarPinnedFilters([{ name: \"Name\", hasUnsavedChanges: false }]);\n\n    // undo\n    await gu.undo();\n\n    // check there is no filter, no sort and no pinned filters\n    assert.deepEqual(\n      await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4, 5, 6]),\n      [\"Apples\", \"Oranges\", \"Bananas\", \"Grapes\", \"Grapefruit\", \"Clementines\"],\n    );\n    await assertFilterBarPinnedFilters([]);\n  });\n\n  it(\"should not break when changing filter from the section menu\", async () => {\n    // open Name column menu\n    let menu = await gu.openColumnMenu(\"Name\", \"Filter\");\n\n    // add filter\n    await menu.findContent(\"label\", /Apples/).find(\"input:checked\").click();\n\n    // open section menu\n    menu = await gu.openSectionMenu(\"sortAndFilter\");\n\n    // open the Name filter by clicking on it\n    await menu.findContent(\".test-filter-config-column\", /Name/).click();\n\n    // uncheck Bananas\n    const menu2 = await driver.find(\".test-filter-menu-wrapper\");\n    await menu2.findContent(\".test-filter-menu-value\", /Bananas/).click();\n    await menu2.find(\".test-filter-menu-apply-btn\").click();\n\n    // check the grid is filtered correclty\n    assert.deepEqual(\n      await gu.getVisibleGridCells(\"Name\", [1, 2, 3, 4]),\n      [\"Oranges\", \"Grapes\", \"Grapefruit\", \"Clementines\"],\n    );\n\n    // check there is no error\n    await gu.checkForErrors();\n\n    // click revert\n    await driver.find(\".test-section-menu-small-btn-revert\").click();\n\n    // check menu is closed\n    await gu.waitToPass(async () => assert.isFalse(await driver.find(\".grist-floating-menu\").isPresent()));\n  });\n\n  it(\"should allow to add filter\", async () => {\n    // check that there are no pinned filters\n    await assertFilterBarPinnedFilters([]);\n\n    // open the section menu\n    const menu = await gu.openSectionMenu(\"sortAndFilter\");\n\n    // check filter list is empty\n    assert.deepEqual(\n      await driver.findAll(\".test-section-menu-filter-col\", e => e.getText()),\n      []);\n\n    // click the add filter button\n    await driver.find(\".test-filter-config-add-filter-btn\").click();\n\n    // check all columns are listed\n    assert.deepEqual(\n      await driver.findAll(\".test-sd-searchable-list-item\", e => e.getText()),\n      [\"Name\", \"Count\", \"Date\"],\n    );\n\n    // click Name\n    await driver.findContent(\".grist-floating-menu li\", /Name/).click();\n\n    // check the filter menu is shown\n    assert.isTrue(\n      await driver.find(\".test-filter-menu-wrapper\").isPresent(),\n    );\n\n    // check a filter was added and pinned\n    await assertPinnedFilters([{ name: \"Name\", hasUnsavedChanges: true }]);\n\n    // click Apple\n    await driver.findContent(\".test-filter-menu-list .test-filter-menu-value\", /Apples/).click();\n\n    // click Apply\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // check filter list and pinned filters\n    assert.deepEqual(\n      await driver.findAll(\".test-filter-config-filter\", e => e.getText()),\n      [\"Name\"]);\n    await assertPinnedFilters([{ name: \"Name\", hasUnsavedChanges: true }]);\n\n    // check data is correctly filtered\n    assert.deepEqual(\n      await gu.getVisibleGridCells({ cols: [\"Name\"], rowNums: [1, 2] }),\n      [\"Oranges\", \"Bananas\"],\n    );\n\n    // remove filter\n    await driver.findContent(\".test-filter-config-filter\", /Name/)\n      .find(\".test-filter-config-remove-filter\").click();\n\n    // closes menu\n    await menu.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should close sort&filter menu when clicking Save/Revert\", async () => {\n    // open the sort&filter dropdown\n    const menu = await gu.openSectionMenu(\"sortAndFilter\");\n\n    // add Name/Apples filter\n    await driver.find(\".test-filter-config-add-filter-btn\").click();\n    await driver.findContent(\".grist-floating-menu li\", /Name/).click();\n    await driver.findContentWait(\".test-filter-menu-list .test-filter-menu-value\", /Apples/, 100).click();\n\n    // click apply\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n\n    // click the Save button\n    await driver.find(\".test-section-menu-btn-save\").click();\n    await gu.waitForServer();\n\n    // check the menu is gone\n    assert.equal(await menu.isPresent(), false);\n\n    // undo\n    await gu.undo();\n  });\n\n  it(\"should allow pinning filters\", async () => {\n    // check that there are no pinned filters\n    await assertFilterBarPinnedFilters([]);\n\n    // open the section menu\n    const menu = await gu.openSectionMenu(\"sortAndFilter\");\n\n    // add a filter and check that it's pinned by default\n    await driver.find(\".test-filter-config-add-filter-btn\").click();\n    await driver.findContent(\".grist-floating-menu li\", /Name/).click();\n    await driver.findContent(\".test-filter-menu-list .test-filter-menu-value\", /Apples/).click();\n    await driver.find(\".test-filter-menu-apply-btn\").click();\n    await assertPinnedFilters([{ name: \"Name\", hasUnsavedChanges: true }]);\n\n    // unpin the filter and check that it worked\n    await driver.findContent(\".test-filter-config-filter\", /Name/)\n      .find(\".test-filter-config-pin-filter\").click();\n    await assertPinnedFilters([]);\n\n    // pin the filter and check that it worked\n    await driver.findContent(\".test-filter-config-filter\", /Name/)\n      .find(\".test-filter-config-pin-filter\").click();\n    await assertPinnedFilters([{ name: \"Name\", hasUnsavedChanges: true }]);\n\n    // remove the filter and close the menu\n    await driver.findContent(\".test-filter-config-filter\", /Name/)\n      .find(\".test-filter-config-remove-filter\").click();\n    await menu.sendKeys(Key.ESCAPE);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SortPositions.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"SortPositions\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"CC_Statement.grist\");\n  });\n\n  afterEach(() => gu.checkForErrors());\n\n  async function dragRows(rowNumFirst: number, rowNumSecond: number, rowNumTo: number) {\n    const rowHeaderFirst = await driver.findContent(\".gridview_data_row_num\", new RegExp(`^${rowNumFirst}$`));\n    const rowHeaderSecond = await driver.findContent(\".gridview_data_row_num\", new RegExp(`^${rowNumSecond}$`));\n    const rowHeaderTo = await driver.findContent(\".gridview_data_row_num\", new RegExp(`^${rowNumTo}$`));\n    await driver.sendKeys(Key.ESCAPE);    // Ensure there is no row selection to begin with.\n    await driver.withActions(actions => actions\n      .move({ origin: rowHeaderFirst }).press()\n      .move({ origin: rowHeaderSecond }).release()\n      .move({ origin: rowHeaderFirst }).press()\n      .move({ origin: rowHeaderTo }).release(),\n    );\n    await gu.waitForServer();\n  }\n\n  it(\"should allow rearranging rows in regular unsorted tables\", async function() {\n    // First check that we CAN rearrange rows in a regular table.\n    // Check the contents of the first 5 rows\n    assert.deepEqual(await gu.getVisibleGridCells({\n      section: \"Sheet1 record\", cols: [0, 1, 2], rowNums: [1, 2, 3, 4, 5],\n    }), [\n      \"2015-01-12\", \"Howard Washington\", \"-1745.53\",\n      \"2015-01-17\", \"Howard Washington\", \"382.06\",\n      \"2015-01-20\", \"Nyssa O'Neil\", \"4011\",\n      \"2015-01-21\", \"Howard Washington\", \"77.3\",\n      \"2015-01-31\", \"Howard Washington\", \"-19.02\",\n    ]);\n\n    // Drag row 2 to below row 4 by pressing mouse on the header of row 2, and dragging to row 4.\n    await dragRows(2, 2, 4);\n\n    // Check the updated contents of first 5 rows.\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3, 4, 5] }), [\n      \"2015-01-12\", \"Howard Washington\", \"-1745.53\",\n      \"2015-01-20\", \"Nyssa O'Neil\", \"4011\",\n      \"2015-01-21\", \"Howard Washington\", \"77.3\",\n      \"2015-01-17\", \"Howard Washington\", \"382.06\",\n      \"2015-01-31\", \"Howard Washington\", \"-19.02\",\n    ]);\n    // Check that the row that got moved (now row 4) is now selected.\n    assert.deepEqual(await driver.findAll(\".active_section .gridview_data_row_num.selected\",\n      el => el.getText()), [\"4\"]);\n\n    // Now move rows 4-5 to just before row 2.\n    await dragRows(4, 5, 2);\n\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3, 4, 5] }), [\n      \"2015-01-12\", \"Howard Washington\", \"-1745.53\",\n      \"2015-01-17\", \"Howard Washington\", \"382.06\",\n      \"2015-01-31\", \"Howard Washington\", \"-19.02\",\n      \"2015-01-20\", \"Nyssa O'Neil\", \"4011\",\n      \"2015-01-21\", \"Howard Washington\", \"77.3\",\n    ]);\n    assert.deepEqual(await driver.findAll(\".active_section .gridview_data_row_num.selected\",\n      el => el.getText()), [\"2\", \"3\"]);\n  });\n\n  it(\"should allow updating sort positions in regular sorted tables\", async function() {\n    // TODO column options look weird if a multi-column range is initially selected.\n    await gu.getCell({ col: \"Date\", rowNum: 1 }).click();\n\n    // Sort by date, and check that Update Data button is shown.\n    await gu.openColumnMenu(\"Date\", \"sort-asc\");\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await driver.find(\".test-config-sortAndFilter\").click();\n\n    assert.deepEqual(await driver.findAll(\".test-sort-config-column\", el => el.getText()),\n      [\"Date\"]);\n    assert.equal(await driver.find(\".test-sort-config-update\").isDisplayed(), true);\n\n    // Click Update Data, and check position of first 5 rows.\n    await gu.updateRowsBySort();\n\n    // We are back to having no sort columns, but data is ordered by date.\n    assert.deepEqual(await driver.findAll(\".test-sort-config-column\", el => el.getText()), []);\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1, 2], rowNums: [1, 2, 3, 4, 5] }), [\n      \"2015-01-12\", \"Howard Washington\", \"-1745.53\",\n      \"2015-01-17\", \"Howard Washington\", \"382.06\",\n      \"2015-01-20\", \"Nyssa O'Neil\", \"4011\",\n      \"2015-01-21\", \"Howard Washington\", \"77.3\",\n      \"2015-01-31\", \"Howard Washington\", \"-19.02\",\n    ]);\n  });\n\n  it(\"should not allow rearranging rows in unsorted summary tables\", async function() {\n    // Add a summary table.\n    await gu.addNewSection(/Table/, /Sheet1/, { summarize: [/Card_Member/] });\n\n    // Check the first few cells.\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1], rowNums: [1, 2, 3] },\n    ), [\n      \"Howard Washington\", \"58\",\n      \"Nyssa O'Neil\", \"14\",\n      \"Callum Wilson\", \"12\",\n    ]);\n\n    // Summary tables don't include a manualSort column and don't support rearranging rows.\n    // Try to drag row 2 to row 3, but it should only select the rows.\n    const section = await gu.getSection(\"SHEET1 (RAW) [by Card_Member]\");\n    const row2Header = await section.findContent(\".gridview_data_row_num\", /^2$/);\n    const row3Header = await section.findContent(\".gridview_data_row_num\", /^3$/);\n    await row2Header.click();\n    await driver.withActions(actions => actions\n      .move({ origin: row2Header }).press()\n      .move({ origin: row3Header }).release(),\n    );\n    await gu.waitForServer();\n\n    // There should be no errors.\n    await gu.checkForErrors();\n\n    // The rows haven't changed.\n    assert.deepEqual(await gu.getVisibleGridCells({ cols: [0, 1], rowNums: [1, 2, 3] }), [\n      \"Howard Washington\", \"58\",\n      \"Nyssa O'Neil\", \"14\",\n      \"Callum Wilson\", \"12\",\n    ]);\n  });\n\n  it(\"should not allow updating sort positions in sorted summary tables\", async function() {\n    // Summary tables don't include a manualSort column and should not show \"Update Data\" button.\n\n    // Sort by a column.\n    await gu.getCell({ col: \"Card_Member\", rowNum: 1 }).click();\n    await gu.openColumnMenu(\"Card_Member\", \"sort-asc\");\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await driver.find(\".test-config-sortAndFilter\").click();\n\n    // Check that \"Update Data\" button is not shown.\n    assert.deepEqual(await driver.findAll(\".test-sort-config-column\", el => el.getText()),\n      [\"Card_Member\"]);\n    assert.equal(await driver.find(\".test-sort-config-update\").isDisplayed(), false);\n  });\n\n  describe(\"Dragging\", function() {\n    let mainSession: gu.Session;\n    let docId: string;\n\n    before(async function() {\n      mainSession = await gu.session().teamSite.user(\"user1\").login();\n      docId = await mainSession.tempNewDoc(cleanup, \"SortPositions.grist\", { load: false });\n      const api = mainSession.createHomeApi();\n      await api.applyUserActions(docId, [\n        [\"BulkAddRecord\", \"Table1\", [1, 2, 3, 4], { A: [\"a\", \"b\", \"c\", \"d\"] }],\n      ]);\n    });\n\n    it(\"should keep working when manualSort values get too close to each other\", async function() {\n      // After much rearranging, manualSort values can get so very close to each other. The data\n      // engine then spreads them out automatically. This test checks that the frontend lets it\n      // happen (a bug was causing it to send incorrect values in this situation).\n\n      // Create a situation with close manualSort values: first two are so close that the average\n      // of A and B is equal to A (because of imprecision of floats).\n      await mainSession.createHomeApi().applyUserActions(docId, [\n        [\"ApplyDocActions\", [\n          [\"BulkUpdateRecord\", \"Table1\", [1, 2], { manualSort: [0.006602524127749098, 0.006602524127749099] }],\n        ]],\n      ]);\n      await mainSession.loadDoc(`/doc/${docId}`);\n\n      // Check that the initial data is as we set it up.\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0], rowNums: [1, 2, 3, 4] }),\n        [\"a\", \"b\", \"c\", \"d\"]);\n\n      // Drag row 3 ('c') to between 1 ('a') and 2 ('b').\n      await dragRows(3, 3, 2);\n\n      // Check that it worked.\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0], rowNums: [1, 2, 3, 4] }),\n        [\"a\", \"c\", \"b\", \"d\"]);\n\n      // Check that the repositioned row is the one selected\n      assert.deepEqual(await driver.findAll(\".active_section .gridview_data_row_num.selected\",\n        el => el.getText()), [\"2\"]);\n    });\n\n    it(\"should keep rearranged rows next to its neighbor even in the presence of filters\", async function() {\n      // When data is filtered, dragging a row should place it immediately before its following\n      // neighbor; it should stick there even when the filter is removed.\n\n      // Set regular old manualSort values.\n      await mainSession.createHomeApi().applyUserActions(docId, [\n        [\"ApplyDocActions\", [\n          [\"BulkUpdateRecord\", \"Table1\", [1, 2, 3, 4], { manualSort: [1, 2, 3, 4] }],\n        ]],\n      ]);\n      await mainSession.loadDoc(`/doc/${docId}`);\n\n      await gu.openColumnMenu(\"A\", \"Filter\");\n      assert.deepEqual(await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()),\n        [\"a\", \"b\", \"c\", \"d\"]);\n      await driver.findContent(\".test-filter-menu-list .test-filter-menu-value\", /a/).click();\n      await driver.findContent(\".test-filter-menu-list .test-filter-menu-value\", /b/).click();\n      await driver.find(\".test-filter-menu-apply-btn\").click();\n\n      // Check that the data is filtered\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0], rowNums: [1, 2, 3] }), [\"c\", \"d\", \"\"]);\n\n      // Drag 'd' to before 'c'\n      await dragRows(2, 2, 1);\n\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0], rowNums: [1, 2, 3] }), [\"d\", \"c\", \"\"]);\n\n      // Reset the filters.\n      await driver.find(\".test-section-menu-small-btn-revert\").click();\n\n      // Check that 'd' is still immediately before 'c'.\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [0], rowNums: [1, 2, 3, 4] }), [\"a\", \"b\", \"d\", \"c\"]);\n    });\n  });\n\n  describe(\"OrderBug\", function() {\n    it(\"should update sort correctly after some noncontiguous updates\", async function() {\n      // Tests the fix of a bug that could prevent the rows in a sorted view from re-sorting\n      // correctly.\n      const mainSession = await gu.session().teamSite.user(\"user1\").login();\n      const docId = await mainSession.tempNewDoc(cleanup, \"SortPositions_Bug.grist\", { load: false });\n      const api = mainSession.createHomeApi();\n      await api.applyUserActions(docId, [\n        [\"BulkAddRecord\", \"Table1\", [1, 2, 3], { A: [10, 30, 20] }],\n      ]);\n\n      await mainSession.loadDoc(`/doc/${docId}`);\n\n      // Sort by column A: it now show rowIds 1, 3, 2.\n      await gu.openColumnMenu(\"A\", \"sort-asc\");\n      assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"A\"], rowNums: [1, 2, 3] }), [\"10\", \"20\", \"30\"]);\n\n      // Update rows 1 and 2 (first and last) in a way that keeps the newly-first row (2) in the\n      // right place relative to its neighbor.\n      await api.applyUserActions(docId, [\n        [\"BulkUpdateRecord\", \"Table1\", [1, 2], { A: [25, 24] }],\n      ]);\n\n      await gu.waitToPass(async () =>\n        assert.deepEqual(await gu.getVisibleGridCells({ cols: [\"A\"], rowNums: [1, 2, 3] }), [\"20\", \"24\", \"25\"]));\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Summaries.ntest.js",
    "content": "/**\n * This test suite is partially duplicated as `test/nbrowser/Summaries.ts`.\n */\n\nimport { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"Summaries.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n\n  gu.bigScreen();\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"CC_Summaries.grist\", true);\n    await gu.toggleSidePanel(\"left\", \"open\");\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should contain two summary tables\", async function() {\n    // Switch to Summaries view.\n    await gu.actions.selectTabView(\"Summaries\");\n    await gu.getVisibleGridCells(0, [1]);\n\n    // Check a few numbers from 'By Category' section.\n    assert.deepEqual(await gu.getGridValues({section: \"By Category\", rowNums: [2, 10], cols: [0, 2]}),\n      [ \"Business Services-Mailing & Shipping\",      \"341.84\",\n        \"Merchandise & Supplies-Internet Purchase\",  \"1023.47\" ]);\n\n    // Check a few numbers from 'Credit/Debit By Category' section.\n    assert.deepEqual(await gu.getGridValues({section: \"Credit/Debit By Category\", rowNums: [3, 9],\n      cols: [1, 3]}),\n    [ \"Fees & Adjustments-Fees & Adjustments\", \"-472.03\",\n      \"Business Services-Office Supplies\",     \"526.45\" ]);\n    assert.deepEqual(await gu.getGridValues({section: \"Credit/Debit By Category\", rowNums: [3, 9],\n      cols: [0], mapper: e => e.find(\".widget_checkmark\").getCssValue(\"display\") }),\n    [ \"block\",\n      \"none\" ]);\n  });\n\n\n  it(\"should allow updating summary group-by columns\", async function() {\n    // Open side-pane.\n    await gu.openSidePane(\"view\");\n    await $(\".test-config-data\").click();\n    await gu.actions.viewSection(\"By Category\").selectSection();\n\n    // Check some values in the data.\n    assert.deepEqual(await gu.getGridValues({rowNums: [2, 12], cols: [0, 1, 2]}),\n      [ \"Business Services-Mailing & Shipping\", \"6\", \"341.84\",\n        \"Merchandise & Supplies-Pharmacies\",    \"4\", \"42.19\" ]);\n\n    // Verify that multiselect only shows \"Category\".\n    assert.deepEqual(await $(\".test-pwc-groupedBy-col\").array().text(), [\"Category\"]);\n\n    // Add another field, \"Date\".\n    await $(\".test-pwc-editDataSelection\").click();\n    await $(`.test-wselect-column:contains(Date)`).click();\n\n    // Cancel, and verify contents of multiselect.\n    await gu.sendKeys($.ESCAPE);\n    assert.deepEqual(await $(\".test-pwc-groupedBy-col\").array().text(), [\"Category\"]);\n\n    // Add another field, \"Date\", again.\n    await $(\".test-pwc-editDataSelection\").click();\n    await $(`.test-wselect-column:contains(Date)`).click();\n\n    // Save, and verify contents of multiselect.\n    await $(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n\n    // Verify contents of multiselect.\n    assert.deepEqual(await $(\".test-pwc-groupedBy-col\").array().text(), [\"Date\", \"Category\"]);\n\n    // Wait for data to load, and verify the data.\n    assert.deepEqual(await gu.getGridValues({rowNums: [2, 12], cols: [0, 1, 2, 3]}),\n      [ \"2015-02-12\", \"\",                                     \"1\", \"-4462.48\",\n        \"2015-02-13\", \"Business Services-Mailing & Shipping\", \"1\", \"147.00\" ]);\n\n    // Remove both \"Date\" and \"Category\", and save.\n    await $(\".test-pwc-editDataSelection\").click();\n    await $(\".test-wselect-column[class*=-selected]:contains(Date)\").click();\n    await $(\".test-wselect-column[class*=-selected]:contains(Category)\").click();\n    await $(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n\n    // Verify contents of multiselect.\n    assert.deepEqual(await $(\".test-pwc-groupedBy-col\").array().text(), []);\n\n    // Wait for data to load, and verify the data (a single line of totals).\n    assert.deepEqual(await gu.getGridValues({rowNums: [1], cols: [0, 1]}),\n      [\"208\", \"3540.60\"]);\n\n    // Undo, and verify contents of multiselect.\n    await gu.undo();\n    assert.deepEqual(await $(\".test-pwc-groupedBy-col\").array().text(), [\"Date\", \"Category\"]);\n\n    // Undo, and verify contents of multiselect.\n    await gu.undo();\n    assert.deepEqual(await $(\".test-pwc-groupedBy-col\").array().text(), [\"Category\"]);\n\n    // Verify that contents is what we started with.\n    assert.deepEqual(await gu.getGridValues({rowNums: [2, 12], cols: [0, 1, 2]}),\n      [ \"Business Services-Mailing & Shipping\", \"6\", \"341.84\",\n        \"Merchandise & Supplies-Pharmacies\",    \"4\", \"42.19\" ]);\n  });\n\n  // This test has been migrated to `test/nbrowser/Summaries.ts`\n  it(\"should allow detaching a summary table\", async function() {\n    // Detach a summary section, make sure it shows correct data, and has live formulas, but\n    // doesn't auto-add rows. Then undo and make sure we go back to a summary table.\n\n    await gu.actions.viewSection(\"By Category\").selectSection();\n    assert.deepEqual(await gu.actions.getTabs().array().text(), [\"Summaries\", \"Sheet1\"]);\n\n    await $(\".test-detach-button\").click();\n    await gu.waitForServer();\n    await assert.equal(await $(\".test-pwc-groupedBy\").isDisplayed(), false);\n\n    // Verify that the title of the section has changed.\n    assert.equal(await $(\".active_section .test-viewsection-title\").parent().text(),\n      \"By Category\");\n    assert.deepEqual(await gu.actions.getTabs().array().text(), [\"Summaries\", \"Sheet1\", \"Table1\"]);\n\n    // Verify that contents of the section.\n    assert.deepEqual(await gu.getGridValues({rowNums: [2, 12], cols: [0, 1, 2]}),\n      [ \"Business Services-Mailing & Shipping\", \"6\", \"341.84\",\n        \"Merchandise & Supplies-Pharmacies\",    \"4\", \"42.19\" ]);\n\n    // See what the last row number is.\n    await gu.sendKeys([$.MOD, $.DOWN]);\n    assert.equal(await $(\".active_section .gridview_data_row_num\").last().text(), \"19\");\n\n    // Change a category in Transactions; it should affect formulas in existing rows of the\n    // detached table, but should not produce new rows.\n    await gu.clickCell({rowNum: 9, col: 2, section: \"Transactions\"});\n    await gu.sendKeys(\"Hello\", $.ENTER);\n    await gu.waitForServer();\n\n    // Check that number of rows is unchanged, but that formulas got updated in the affected row.\n    await gu.actions.viewSection(\"By Category\").selectSection();\n    assert.equal(await $(\".active_section .gridview_data_row_num\").last().text(), \"19\");\n    await gu.sendKeys([$.MOD, $.UP]);\n    assert.deepEqual(await gu.getGridValues({rowNums: [2, 12], cols: [0, 1, 2]}),\n      [ \"Business Services-Mailing & Shipping\", \"5\", \"194.84\",\n        \"Merchandise & Supplies-Pharmacies\",    \"4\", \"42.19\" ]);\n\n    // Undo everything. Make sure we have our summary table back.\n    await gu.undo(3);\n    assert.equal(await $(\".active_section .test-viewsection-title\").parent().text(),\n      \"By Category\");\n    assert.deepEqual(await $(\".test-pwc-groupedBy-col\").array().text(), [\"Category\"]);\n    assert.deepEqual(await gu.actions.getTabs().array().text(), [\"Summaries\", \"Sheet1\"]);\n  });\n\n\n  it(\"should allow adding summaries by date\", async function() {\n    // Add Summary table by Date column.\n    await gu.actions.viewSection(\"By Category\").selectSection();\n    await gu.actions.addNewSummarySection(\"Sheet1\", [\"Date\", \"Category\"], \"Table\", \"By Date/Category\");\n\n    // Check a couple of values.\n    await gu.actions.viewSection(\"By Date/Category\").selectSection();\n    await gu.sendKeys([$.MOD, $.DOWN]);  // Go to the end.\n    assert.deepEqual(await gu.getGridValues({section: \"By Date/Category\", rowNums:[151], cols:[0, 1, 3]}),\n      [ \"2015-12-04\", \"Travel-Lodging\", \"3021.54\" ]);\n  });\n\n\n  it(\"should update summary values when values change\", async function() {\n    // Change a value in Transactions, and check that numbers changed.\n    await gu.actions.viewSection(\"Transactions\").selectSection();\n    await gu.getCell(1, 9).click();\n    await gu.waitAppFocus();\n    await gu.sendKeys(\"947.00\", $.ENTER);     // Change 147.00 -> 947.00\n    assert.equal(await gu.getCell(1, 9).text(), \"947.00\");\n    await gu.sendKeys([$.MOD, $.DOWN], $.UP); // Go to the last row (but not the \"add row\").\n    await gu.sendKeys(\"677.40\", $.ENTER);     // Change 177.40 -> 677.40\n    await gu.waitForServer();\n\n    // Check changes in the two affected sections.\n    assert.deepEqual(await gu.getGridValues({section: \"By Category\", rowNums: [2, 10], cols: [0, 2]}),\n      [ \"Business Services-Mailing & Shipping\",      \"1141.84\",       // <--- this changes\n        \"Merchandise & Supplies-Internet Purchase\",  \"1023.47\" ]);\n    assert.deepEqual(await gu.getGridValues({section: \"By Date/Category\", rowNums:[151], cols:[0, 1, 3]}),\n      [ \"2015-12-04\", \"Travel-Lodging\", \"3521.54\" ]);\n\n    // Undo both changes, and check that summarized values got restored.\n    await $(\".test-undo\").click();\n    await $(\".test-undo\").click();\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getGridValues({section: \"By Category\", rowNums: [2, 10], cols: [0, 2]}),\n      [ \"Business Services-Mailing & Shipping\",      \"341.84\",\n        \"Merchandise & Supplies-Internet Purchase\",  \"1023.47\" ]);\n    assert.deepEqual(await gu.getGridValues({section: \"By Date/Category\", rowNums:[151], cols:[0, 1, 3]}),\n      [ \"2015-12-04\", \"Travel-Lodging\", \"3021.54\" ]);\n  });\n\n\n  it(\"should update summary values when key columns change\", async function() {\n    // Change a category in Transactions, and check that numbers changed.\n    await gu.actions.viewSection(\"Transactions\").selectSection();\n    await gu.sendKeys([$.MOD, $.DOWN]);  // Go to the end.\n    await gu.getCell(2, 208).click();\n    await gu.waitAppFocus();\n    await gu.sendKeys(\"Merchandise & Supplies-Internet Purchase\", $.ENTER);\n    assert.equal(await gu.getCell(2, 208).text(), \"Merchandise & Supplies-Internet Purchase\");\n    await gu.waitForServer();\n\n    // Check that numbers changed in two affected summary tables.\n    assert.deepEqual(await gu.getGridValues({section: \"By Category\", rowNums: [2, 10], cols: [0, 2]}),\n      [ \"Business Services-Mailing & Shipping\",      \"341.84\",\n        \"Merchandise & Supplies-Internet Purchase\",  \"1200.87\" ]);              // Up by 177.40\n    assert.deepEqual(await gu.getGridValues({section: \"By Date/Category\",\n      rowNums:[151, 152], cols:[0, 1, 3]}),\n    [ \"2015-12-04\", \"Travel-Lodging\",                          \"2844.14\",    // Down by 177.40\n      \"2015-12-04\", \"Merchandise & Supplies-Internet Purchase\", \"177.40\" ]);  // New row\n\n    // Undo and check that summarized values got restored.\n    await $(\".test-undo\").click();\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getGridValues({section: \"By Category\", rowNums: [2, 10], cols: [0, 2]}),\n      [ \"Business Services-Mailing & Shipping\",      \"341.84\",\n        \"Merchandise & Supplies-Internet Purchase\",  \"1023.47\" ]);\n    assert.deepEqual(await gu.getGridValues({section: \"By Date/Category\", rowNums:[151], cols:[0, 1, 3]}),\n      [ \"2015-12-04\", \"Travel-Lodging\", \"3021.54\" ]);\n\n    // Check that the newly-added row is gone.\n    await gu.actions.viewSection(\"By Date/Category\").selectSection();\n    await assert.equal(await gu.getGridLastRowText(), \"151\");\n  });\n\n\n  it(\"should update summary values when records get added\", async function() {\n    // Add a record.\n    await gu.actions.viewSection(\"Transactions\").selectSection();\n    await gu.addRecord([\"2016-01-01\", \"100\", \"Business Services-Office Supplies\"]);\n    await gu.waitForServer();\n    assert.equal(await gu.getGridLastRowText(), \"210\");\n    assert.deepEqual(await gu.getGridValues({cols: [0, 1, 2], rowNums: [209]}),\n      [\"2016-01-01\", \"100.00\", \"Business Services-Office Supplies\"]);\n\n    // Check that numbers have changed.\n    assert.deepEqual(await gu.getGridValues({section: \"Credit/Debit By Category\", rowNums: [2, 3, 9],\n      cols: [1, 3]}),\n    [ \"Business Services-Office Supplies\",    \"-4.56\",      // <-- no change\n      \"Fees & Adjustments-Fees & Adjustments\", \"-472.03\",\n      \"Business Services-Office Supplies\",     \"626.45\" ]);  // <-- does change\n    assert.deepEqual(await gu.getGridValues({section: \"Credit/Debit By Category\", rowNums: [2, 3, 9],\n      cols: [0], mapper: e => e.find(\".widget_checkmark\").getCssValue(\"display\")}),\n    [ \"block\",\n      \"block\",\n      \"none\"]);  // <-- does change\n\n    // Go to last data record.\n    await gu.sendKeys([$.MOD, $.UP]);\n    await gu.sendKeys([$.MOD, $.DOWN]);\n    await gu.sendKeys([$.UP]);\n\n    // Delete the new record, and check that values are the same as before.\n    await gu.sendKeys([$.MOD, $.DELETE]);\n\n    await gu.confirm(true, true); // confirm and remember.\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getGridValues({section: \"Credit/Debit By Category\", rowNums: [2, 3, 9],\n      cols: [1, 3]}),\n    [ \"Business Services-Office Supplies\",    \"-4.56\",\n      \"Fees & Adjustments-Fees & Adjustments\", \"-472.03\",\n      \"Business Services-Office Supplies\",     \"526.45\" ]);\n    assert.deepEqual(await gu.getGridValues({section: \"Credit/Debit By Category\", rowNums: [2, 3, 9],\n      cols: [0], mapper: e => e.find(\".widget_checkmark\").getCssValue(\"display\")}),\n    [ \"block\",\n      \"block\",\n      \"none\" ]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/SupportGrist.ts",
    "content": "import { GristLoadConfig } from \"app/common/gristUrls\";\nimport { TelemetryLevel } from \"app/common/Telemetry\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\nconst sponsorshipUrl = \"https://github.com/sponsors/gristlabs\";\n\ndescribe(\"SupportGrist\", function() {\n  this.timeout(30000);\n  setupTestSuite();\n\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  let session: gu.Session;\n\n  afterEach(() => gu.checkForErrors());\n\n  after(async function() {\n    await server.restart();\n  });\n\n  describe(\"in grist-core\", function() {\n    before(async function() {\n      oldEnv = new testUtils.EnvironmentSnapshot();\n      process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = \"core\";\n      process.env.GRIST_DEFAULT_EMAIL = gu.session().email;\n      await server.restart();\n    });\n\n    after(async function() {\n      oldEnv.restore();\n    });\n\n    describe(\"when user is not a manager\", function() {\n      before(async function() {\n        session = await gu.session().user(\"user2\").personalSite.login();\n        await session.loadDocMenu(\"/\");\n      });\n\n      it(\"shows a button in the top bar\", async function() {\n        await assertSupportButtonShown(true, { isSponsorLink: true });\n      });\n\n      it(\"shows a link to the Support Grist page in the user menu\", async function() {\n        await gu.openAccountMenu();\n        await assertMenuHasAdminPanel(false);\n        await assertMenuHasSupportGrist(true);\n      });\n    });\n\n    describe(\"when user is a manager\", function() {\n      before(async function() {\n        session = await gu.session().personalSite.login();\n        await session.loadDocMenu(\"/\");\n      });\n\n      it(\"shows a button in the top bar\", async function() {\n        await assertSupportButtonShown(true, { isSponsorLink: false });\n\n        // Dismiss the button and check that it's now gone, even after reloading.\n        await driver.find(\".test-support-grist-button\").mouseMove();\n        await driver.find(\".test-support-grist-button-dismiss\").click();\n        await assertSupportButtonShown(false);\n        await session.loadDocMenu(\"/\");\n        await assertSupportButtonShown(false);\n      });\n\n      it(\"shows a link to Admin Panel and Support Grist in the user menu\", async function() {\n        await gu.openAccountMenu();\n        await assertMenuHasAdminPanel(true);\n        await assertMenuHasSupportGrist(true);\n      });\n\n      it(\"supports opting in to telemetry\", async function() {\n        // Reset all dismissed popups, which includes the telemetry button.\n        await driver.executeScript(\"resetDismissedPopups();\");\n        await gu.waitForServer();\n        await session.loadDocMenu(\"/\");\n\n        // Opt in to telemetry and reload the page.\n        await driver.find(\".test-support-grist-button\").click();\n        await driver.find(\".test-support-nudge-opt-in\").click();\n        await driver.findWait(\".test-support-nudge-close-button\", 1000).click();\n        await assertSupportButtonShown(false);\n        await session.loadDocMenu(\"/\");\n\n        // Check that the button is no longer shown and telemetry is set to \"limited\".\n        await assertSupportButtonShown(false);\n        await assertTelemetryLevel(\"limited\");\n      });\n\n      it(\"does not show the button if telemetry is enabled\", async function() {\n        await driver.executeScript(\"resetDismissedPopups();\");\n        await gu.waitForServer();\n\n        // Reload the doc menu and check that We show the \"Support Grist\" button linking to sponsorship page.\n        await session.loadDocMenu(\"/\");\n        await assertSupportButtonShown(true, { isSponsorLink: true });\n\n        // Disable telemetry from the Support Grist page.\n        await gu.openAccountMenu();\n        await driver.find(\".test-usermenu-admin-panel\").click();\n        await driver.findWait(\".test-admin-panel\", 2000);\n        await driver.find(\".test-admin-panel-item-name-telemetry\").click();\n        await driver.sleep(500);  // Wait for section to expand.\n        await driver.findContentWait(\n          \".test-support-grist-page-telemetry-section button\", /Opt out of Telemetry/, 2000).click();\n        await driver.findContentWait(\".test-support-grist-page-telemetry-section button\", /Opt in to Telemetry/, 2000);\n\n        // Reload the doc menu and check that the button is now shown.\n        await gu.loadDocMenu(\"/\");\n        await assertSupportButtonShown(true, { isSponsorLink: false });\n      });\n\n      it(\"shows sponsorship link when no telemetry nudge, and allows dismissing it\", async function() {\n        // Reset all dismissed popups, including the telemetry nudge.\n        await driver.executeScript(\"resetDismissedPopups();\");\n        await gu.waitForServer();\n\n        // Opt in to telemetry\n        const api = session.createHomeApi();\n        await api.testRequest(`${api.getBaseUrl()}/api/install/prefs`, {\n          method: \"patch\",\n          body: JSON.stringify({ telemetry: { telemetryLevel: \"limited\" } }),\n        });\n\n        await session.loadDocMenu(\"/\");\n        await assertTelemetryLevel(\"limited\");\n\n        // We still show the \"Support Grist\" button linking to sponsorship page.\n        await assertSupportButtonShown(true, { isSponsorLink: true });\n\n        // We can dismiss it.\n        await driver.find(\".test-support-grist-button\").mouseMove();\n        await driver.find(\".test-support-grist-button-dismiss\").click();\n        await assertSupportButtonShown(false);\n\n        // And this will get remembered.\n        await session.loadDocMenu(\"/\");\n        await assertSupportButtonShown(false);\n      });\n    });\n  });\n\n  describe(\"in grist-saas\", function() {\n    before(async function() {\n      oldEnv = new testUtils.EnvironmentSnapshot();\n      process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = \"saas\";\n      process.env.GRIST_DEFAULT_EMAIL = gu.session().email;\n      await server.restart();\n      session = await gu.session().personalSite.login();\n      await session.loadDocMenu(\"/\");\n    });\n\n    after(async function() {\n      oldEnv.restore();\n    });\n\n    it(\"does not show a button in the top bar\", async function() {\n      await assertSupportButtonShown(false);\n    });\n\n    it(\"shows Admin Panel but not Support Grist in the user menu for admin\", async function() {\n      await gu.openAccountMenu();\n      await assertMenuHasAdminPanel(true);\n      await assertMenuHasSupportGrist(false);\n    });\n\n    it(\"does not show Admin Panel or Support Grist in the user menu for non-admin\", async function() {\n      session = await gu.session().user(\"user2\").personalSite.login();\n      await session.loadDocMenu(\"/\");\n      await gu.openAccountMenu();\n      await assertMenuHasAdminPanel(false);\n      await assertMenuHasSupportGrist(false);\n    });\n  });\n\n  describe(\"in grist-enterprise\", function() {\n    before(async function() {\n      oldEnv = new testUtils.EnvironmentSnapshot();\n      process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = \"enterprise\";\n      process.env.GRIST_DEFAULT_EMAIL = gu.session().email;\n      await server.restart();\n      session = await gu.session().personalSite.login();\n      await session.loadDocMenu(\"/\");\n    });\n\n    after(async function() {\n      oldEnv.restore();\n    });\n\n    it(\"does not show a button in the top bar\", async function() {\n      await assertSupportButtonShown(false);\n    });\n\n    it(\"shows Admin Panel but not Support Grist page in the user menu\", async function() {\n      await gu.openAccountMenu();\n      await assertMenuHasAdminPanel(true);\n      await assertMenuHasSupportGrist(false);\n    });\n  });\n});\n\nasync function assertSupportButtonShown(isShown: false): Promise<void>;\nasync function assertSupportButtonShown(isShown: true, opts: { isSponsorLink: boolean }): Promise<void>;\nasync function assertSupportButtonShown(isShown: boolean, opts?: { isSponsorLink: boolean }) {\n  const button = driver.find(\".test-support-grist-button\");\n  assert.equal(await button.isPresent() && await button.isDisplayed(), isShown);\n  if (isShown) {\n    assert.equal(await button.getAttribute(\"href\"), opts?.isSponsorLink ? sponsorshipUrl : null);\n  }\n}\n\nasync function assertMenuHasAdminPanel(isShown: boolean) {\n  const elem = driver.find(\".test-usermenu-admin-panel\");\n  assert.equal(await elem.isPresent() && await elem.isDisplayed(), isShown);\n  if (isShown) {\n    assert.match(await elem.getAttribute(\"href\"), /.*\\/admin$/);\n  }\n}\n\nasync function assertMenuHasSupportGrist(isShown: boolean) {\n  const elem = driver.find(\".test-usermenu-support-grist\");\n  assert.equal(await elem.isPresent() && await elem.isDisplayed(), isShown);\n  if (isShown) {\n    assert.equal(await elem.getAttribute(\"href\"), sponsorshipUrl);\n  }\n}\n\nasync function assertTelemetryLevel(level: TelemetryLevel) {\n  const { telemetry }: GristLoadConfig = await driver.executeScript(\"return window.gristConfig\");\n  assert.equal(telemetry?.telemetryLevel, level);\n}\n"
  },
  {
    "path": "test/nbrowser/TermsOfService.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"Terms of service link\", function() {\n  this.timeout(20000);\n  setupTestSuite({ samples: true });\n\n  let session: gu.Session;\n  let oldEnv: EnvironmentSnapshot;\n\n  before(async function() {\n    oldEnv = new EnvironmentSnapshot();\n    session = await gu.session().teamSite.login();\n  });\n\n  after(async function() {\n    oldEnv.restore();\n    await server.restart();\n  });\n\n  it(\"is visible in home menu\", async function() {\n    process.env.GRIST_TERMS_OF_SERVICE_URL = \"https://example.com/tos\";\n    await server.restart();\n    await session.loadDocMenu(\"/\");\n    assert.isTrue(await driver.find(\".test-dm-tos\").isDisplayed());\n    assert.equal(await driver.find(\".test-dm-tos\").getAttribute(\"href\"), \"https://example.com/tos\");\n  });\n\n  it(\"is not visible when environment variable is not set\", async function() {\n    delete process.env.GRIST_TERMS_OF_SERVICE_URL;\n    await server.restart();\n    await session.loadDocMenu(\"/\");\n    assert.isFalse(await driver.find(\".test-dm-tos\").isPresent());\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/TextEditor.ntest.js",
    "content": "import { assert, driver } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"TextEditor.ntest\", function() {\n  test.setupTestSuite(this);\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.actions.createNewDoc();\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  async function autoCompleteSelect(options) {\n    await gu.sendKeys(options.input);\n    const values = await $(\".test-ref-editor-item\").array().text();\n    if (options.keys) {\n      await gu.sendKeys(...options.keys);\n      await $(\".test-ref-editor-item.selected\").wait(assert.isPresent, true);\n    } else if (options.click) {\n      await driver.findContent(\".test-ref-editor-item\", gu.exactMatch(options.click)).click();\n    }\n    return values;\n  }\n\n  async function autoCompleteWaitForSelection(text, selected) {\n    await $(\".test-ref-editor-item:contains(\"+ text +\")\").wait(assert.hasClass, \"selected\", selected);\n  }\n\n  it(\"should allow saving values into new Reference column\", async function() {\n    await gu.getCellRC(0, 0).wait().click();\n    await gu.sendKeys(\"foo\", $.ENTER);\n    await gu.waitForServer();\n    await gu.sendKeys(\"bar\", $.ENTER);\n    await gu.waitForServer();\n    await gu.sendKeys(\"baz\", $.ENTER);\n    await gu.waitForServer();\n\n    // Add a new section and switch to it.\n    await gu.actions.addNewSection(\"New\", \"Table\");\n    await gu.toggleSidePanel(\"left\", \"close\");\n    await $(\".viewsection_title:contains(TABLE2)\").click();\n    await gu.getCellRC(0, 0).click();\n    await gu.setType(\"Reference\");\n    await gu.setRefTable(\"Table1\");\n    await gu.setVisibleCol(\"A\");\n\n    // Populate some of the reference column.\n    await gu.getCellRC(0, 0).click();\n\n    // Select \"foo\" from autocomplete dropdown with keyboard.\n    await autoCompleteSelect({input: \"f\"});\n    await gu.sendKeys($.ENTER);\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(0, 0).text(), \"foo\");\n\n    // Select \"bar\" from autocomplete dropdown with the mouse.\n    await autoCompleteSelect({input: \"b\", click: \"bar\"});\n    await gu.waitForServer();\n    await gu.sendKeys($.DOWN);      // Selecting with the mouse saves without moving the cursor\n    assert.equal(await gu.getCellRC(1, 0).text(), \"bar\");\n\n    // Entering an existing value should reference it\n    await autoCompleteSelect({input: \"baz\"});\n    await gu.sendKeys($.ENTER);\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(2, 0).text(), \"baz\");\n\n    // Select \"foo\" from autocomplete dropdown with tab.\n    await autoCompleteSelect({input: \"foo\"});\n    await gu.sendKeys($.TAB);  // Select \"foo\" from autocomplete dropdown with tab.\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(3, 0).text(), \"foo\");\n\n    // Esc should Cancel.\n    await gu.getCellRC(4, 0).click();\n    await autoCompleteSelect({input: \"baz\"});\n    await gu.sendKeys($.ESCAPE);\n    assert.equal(await gu.getCellRC(4, 0).text(), \"\");\n  });\n\n  it(\"should allow adding new values from Reference column\", async function() {\n    // Select add new from autocomplete dropdown.\n    await autoCompleteSelect({input: \"foobar\", keys: [$.UP]});\n    await gu.sendKeys($.ENTER);\n    await gu.waitForServer();\n    await $(\".viewsection_title:contains(TABLE1)\").click();\n    assert.equal(await gu.getCellRC(3, 0).text(), \"foobar\");\n\n    // Add new by tab\n    await $(\".viewsection_title:contains(TABLE2)\").click();\n    await gu.getCellRC(4, 0).click();\n    await autoCompleteSelect({input: \"foobar1\", keys: [$.UP]});\n    await gu.sendKeys($.TAB);\n    await gu.waitForServer();\n    await $(\".viewsection_title:contains(TABLE1)\").click();\n    assert.equal(await gu.getCellRC(4, 0).text(), \"foobar1\");\n\n    // Add new by click\n    await $(\".viewsection_title:contains(TABLE2)\").click();\n    await gu.getCellRC(5, 0).click();\n    await autoCompleteSelect({input: \"foobar2\", click: \"foobar2\"});\n    await gu.waitForServer();\n    await $(\".viewsection_title:contains(TABLE1)\").click();\n    assert.equal(await gu.getCellRC(5, 0).text(), \"foobar2\");\n\n    // Cancel with escape\n    await $(\".viewsection_title:contains(TABLE2)\").click();\n    await gu.getCellRC(5, 0).click();\n    await autoCompleteSelect({input: \"foobar3\", keys: [$.UP]});\n    await gu.sendKeys($.ESCAPE);\n    await gu.waitForServer();\n    await gu.waitAppFocus();\n    await $(\".viewsection_title:contains(TABLE1)\").click();\n    assert.equal(await gu.getCellRC(6, 0).text(), \"\");\n\n    // Once add new is selected it should not be possible to change the input.\n    await $(\".viewsection_title:contains(TABLE2)\").click();\n    await gu.getCellRC(6, 0).click();\n    await autoCompleteSelect({input: \"foobar4\", keys: [$.UP]});\n    await gu.sendKeys(\"567\");\n    // Make sure add item loses selection\n    await autoCompleteWaitForSelection(\"foobar4\", false);\n    await gu.sendKeys($.ENTER);\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(6, 0).text(), \"foobar4567\");\n    await assert.hasClass(gu.getCellRC(6, 0).find(\".field_clip\"), \"invalid\");\n    await $(\".viewsection_title:contains(TABLE1)\").click();\n    assert.equal(await gu.getCellRC(6, 0).text(), \"\");\n  });\n\n  async function addColumnRightOf(index) {\n    // Add a column. We have to hover over the column header first.\n    await gu.openColumnMenu({col: index}, \"Insert column to the right\");\n    await driver.findWait(\".test-new-columns-menu-add-new\", 100).click();\n    await gu.waitForServer();\n    await gu.sendKeys($.ESCAPE);\n  }\n\n  it(\"should allow saving values into new Date column\", async function() {\n    // Add another column. We have to hover over the column header first.\n    await addColumnRightOf(0);\n    await gu.getCellRC(0, 1).click();\n\n    // Convert to Date. No need to \"Apply conversion\" since it's a new empty column.\n    await gu.setType(\"Date\");\n\n    // Enter a new value and check that it's parsed and shows correctly.\n    await gu.getCellRC(0, 1).click();\n    await gu.sendKeys(\"2016/04/20\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(0, 1).text(), \"2016-04-20\");\n  });\n\n  it(\"should show formatted values for ReferenceEditor autocomplete\", async function() {\n    // Set a Reference column to use a displayCol that's a Date, and ensure that properly\n    // formatted dates show in its autocomplete.\n\n    // First, fill in a few more dates into Table1.D\n    await gu.enterGridValues(1, 1, [[\"2014-03-14\", \"2017-05-01\", \"2016-12-31\", \"\", \"2011-07-15\"]]);\n\n    // Now switch to the section with the Reference column and switch its displayCol to Table1.D.\n    await gu.actions.viewSection(\"TABLE2\").selectSection();\n    await gu.clickCell({rowNum: 1, col: 0});\n    await gu.setVisibleCol(\"D\");\n\n    // Check that the values displayed are properly formatted.\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6, 7], cols: [0]}),\n      [\"2016-04-20\", \"2014-03-14\", \"2017-05-01\", \"2016-04-20\", \"[Blank]\", \"2011-07-15\", \"foobar4567\"]);\n\n    // Check that formatted values are shown in the auto-complete dropdown.\n    await gu.clickCell({rowNum: 3, col: 0});\n    assert.deepEqual(await autoCompleteSelect({input: \"2016\", keys: [$.DOWN]}),\n      [\"2016-04-20\", \"2016-12-31\", \"2011-07-15\", \"2014-03-14\", \"2017-05-01\", \"2016\"]);\n    await gu.sendKeys($.ENTER);\n    await gu.waitForServer();\n\n    // Check that after selection, the right value is saved, and that it's valid (not AltText).\n    let cell = await gu.getCell({rowNum: 3, col: 0});\n    assert.equal(await cell.text(), \"2016-12-31\");\n    await assert.hasClass(cell.find(\".field_clip\"), \"invalid\", false);\n\n    // Check that the formatted value is used to start the autocomplete lookup.\n    await gu.clickCell({rowNum: 3, col: 0});\n    assert.deepEqual(await autoCompleteSelect({input: $.ENTER}),\n      [\"2016-12-31\", \"2016-04-20\", \"2011-07-15\", \"2014-03-14\", \"2017-05-01\"]);\n    await gu.sendKeys($.SELECT_ALL, \"2017-05-01\", $.ENTER);\n    await gu.waitForServer();\n\n    // Check that after typing, the right value is saved, and that it's valid (not AltText).\n    cell = await gu.getCell({rowNum: 3, col: 0});\n    assert.equal(await cell.text(), \"2017-05-01\");\n    await assert.hasClass(cell.find(\".field_clip\"), \"invalid\", false);\n\n    // Switch back to the view section we started from.\n    await gu.actions.viewSection(\"TABLE1\").selectSection();\n  });\n\n\n  it(\"should allow saving values into new Checkbox column\", async function() {\n    await addColumnRightOf(1);\n    await gu.getCellRC(0, 2).click();\n\n    // Convert to Toggle. No need to \"Apply conversion\" since it's a new empty column.\n    await  gu.setType(\"Toggle\");\n\n    // Toggle a value in the new column.\n    await gu.getCellRC(1, 2).find(\".widget_checkbox\").click();\n    await gu.waitForServer();\n\n    // To ensure it got saved to the server, convert to text, and check the text.\n    await gu.setType(\"Text\");\n    await $(\".test-type-transform-apply\").wait().click();\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells(2, [1, 2, 3]),\n      [\"false\", \"true\", \"false\"]);\n  });\n\n  it(\"should allow saving values into a new row of a new column\", async function() {\n    await gu.getColumnHeader(\"A\").scrollIntoView({inline: \"end\"});\n    await addColumnRightOf(0);\n    await gu.getCellRC(0, 1).click();\n    await gu.setType(\"Date\");\n\n    assert.equal(await gu.getCellRC(6, 1).text(), \"\");    // Last \"add new\" row.\n    await assert.isPresent(gu.getCellRC(7, 1), false);    // Check that there is no next row.\n\n    await gu.getCellRC(0, 1).click();\n    await gu.sendKeys([$.MOD, $.DOWN]);    // Jump to last row.\n    await gu.sendKeys(\"2001/11/23\", $.ENTER);\n    await gu.waitForServer();\n\n    assert.equal(await gu.getCellRC(6, 1).text(), \"2001-11-23\");\n    await assert.isPresent(gu.getCellRC(7, 1), true);     // Check that there is now one more row.\n  });\n\n  it(\"should allow changing a Date column to/from formula\", async function() {\n    // What column D (index 1) start off with.\n    assert.equal(await gu.getCellRC(0, 1).text(), \"\");\n    assert.equal(await gu.getCellRC(6, 1).text(), \"2001-11-23\");\n\n    // Replace it with a formula that uses another date column B.\n    await gu.getCellRC(0, 1).click();\n    await gu.sendKeys(\"=\");\n    await $(\".test-editor-tooltip-convert\").click();      // Convert to a formula\n    await gu.sendKeys(\"$D and $D.replace(day=2)\", $.ENTER);\n    await gu.waitForServer();\n\n    // Check that it worked.\n    assert.equal(await gu.getCellRC(0, 1).text(), \"2016-04-02\");\n    assert.equal(await gu.getCellRC(6, 1).text(), \"\");\n\n    // Converting it to a data column.\n    await gu.clickColumnMenuItem(\"F\", \"Convert formula to data\");\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(0, 1).text(), \"2016-04-02\");\n    assert.equal(await gu.getCellRC(6, 1).text(), \"\");\n\n    // Enter a new value, make sure that works.\n    await gu.getCellRC(6, 1).click();\n    await gu.sendKeys(\"2016/05/01\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(0, 1).text(), \"2016-04-02\");\n    assert.equal(await gu.getCellRC(6, 1).text(), \"2016-05-01\");\n  });\n\n  // NOTE: This tests a specific bug which prevented moving the editor cursor via clicking.\n  // See https://phab.getgrist.com/T326\n  it(\"should allow moving cursor inside the editor via clicking\", async function() {\n    await gu.clickCellRC(0, 0);\n    await gu.sendKeys($.ENTER);\n    await gu.waitAppFocus(false);\n    // Double click the cell to select all the text. This will fail if the bug is active.\n    await driver.withActions(a => a.doubleClick($(\".celleditor_text_editor\").elem()));\n    // Since the text was selected, the new text will replace the old text.\n    await gu.sendKeys(\"abcd\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(0, 0).text(), \"abcd\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/Themes.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, By, ChromiumWebDriver, driver, Key } from \"mocha-webdriver\";\n\nconst findSyncWithOSCheckbox = () => driver.find(\".test-theme-config-sync-with-os\");\nconst appearanceSelectCssSelector = \".test-theme-config-appearance .test-select-open:not(.disabled)\";\nconst appearanceSelectDisabledCssSelector = \".test-theme-config-appearance .test-select-open.disabled\";\n/**\n * To verify current theme is correctly applied, we check:\n *\n * - the text color of breadcrumb first item, because it is different for each theme.\n * - the data attributes on the document element, because they are set by the theme.\n */\nconst assertAppliedTheme = async (expectedTheme: \"GristLight\" | \"GristDark\" | \"HighContrastLight\") => {\n  const theme = await driver.find(\".test-account-page-home\");\n  const color = await theme.getCssValue(\"color\");\n  let appliedTheme = null;\n  switch (color) {\n    case \"rgba(22, 179, 120, 1)\":\n      appliedTheme = \"GristLight\";\n      break;\n    case \"rgba(23, 179, 120, 1)\":\n      appliedTheme = \"GristDark\";\n      break;\n    case \"rgba(15, 123, 81, 1)\":\n      appliedTheme = \"HighContrastLight\";\n      break;\n  }\n  assert.equal(appliedTheme, expectedTheme);\n  assert.equal(\n    await driver.findElement(By.css(\"html[data-grist-theme]\")).getAttribute(\"data-grist-theme\"),\n    expectedTheme,\n  );\n  assert.equal(\n    await driver.findElement(By.css(\"[data-grist-appearance]\")).getAttribute(\"data-grist-appearance\"),\n    expectedTheme === \"GristDark\" ? \"dark\" : \"light\",\n  );\n};\n\ndescribe(\"Themes\", function() {\n  describe(\"with default env\", function() {\n    this.timeout(20000);\n\n    setupTestSuite();\n\n    beforeEach(async function() {\n      const session = await gu.session().teamSite.login();\n      await session.loadDocMenu(\"/\");\n      await gu.openProfileSettingsPage();\n    });\n\n    afterEach(async function() {\n      // reset to default state\n      await enableSyncWithOS();\n    });\n\n    it(\"should follow OS preferences by default\", async function() {\n      // verify that the sync with OS checkbox is checked\n      const syncWithOSCheckbox = await findSyncWithOSCheckbox();\n      assert.isTrue(await syncWithOSCheckbox.isSelected());\n\n      // then verify that the theme select is disabled\n      const activeAppearanceSelect = await driver.findElements(\n        By.css(appearanceSelectCssSelector),\n      );\n      const disabledAppearanceSelect = await driver.findElements(\n        By.css(appearanceSelectDisabledCssSelector),\n      );\n      assert.isEmpty(activeAppearanceSelect, \"Appearance select should be disabled\");\n      assert.exists(disabledAppearanceSelect[0], \"Appearance select should be disabled\");\n    });\n\n    it(\"should apply system appearance when sync with OS is enabled\", async function() {\n      await gu.openProfileSettingsPage();\n      const syncWithOSCheckbox = await findSyncWithOSCheckbox();\n      const isSelected = await syncWithOSCheckbox.isSelected();\n      if (!isSelected) {\n        await syncWithOSCheckbox.click();\n      }\n\n      // default test env has system light theme set\n      await assertAppliedTheme(\"GristLight\");\n\n      // if using chromium web driver, fake the system color scheme to dark and wait for the theme to change\n      if ((driver as ChromiumWebDriver).sendDevToolsCommand) {\n        await (driver as ChromiumWebDriver).sendDevToolsCommand(\"Emulation.setEmulatedMedia\", { features: [\n          { name: \"prefers-color-scheme\", value: \"dark\" },\n        ] });\n        await driver.sleep(500);\n        await assertAppliedTheme(\"GristDark\");\n\n        // reset back to default light system appearance\n        await (driver as ChromiumWebDriver).sendDevToolsCommand(\"Emulation.setEmulatedMedia\", { features: [\n          { name: \"prefers-color-scheme\", value: \"light\" },\n        ] });\n        await driver.sleep(500);\n        await assertAppliedTheme(\"GristLight\");\n      }\n    });\n\n    it(\"should allow user to select a theme when sync with OS is disabled\", async function() {\n      await shouldAllowThemeSelection();\n    });\n\n    it(\"should apply the selected theme\", async function() {\n      await shouldApplyThemes();\n    });\n  });\n\n  describe(\"with env APP_STATIC_INCLUDE_CUSTOM_CSS=true\", function() {\n    this.timeout(60000);\n    setupTestSuite();\n\n    gu.withEnvironmentSnapshot({\n      APP_STATIC_INCLUDE_CUSTOM_CSS: \"true\",\n    });\n\n    beforeEach(async function() {\n      const session = await gu.session().teamSite.login();\n      await session.loadDocMenu(\"/\");\n      await gu.openProfileSettingsPage();\n    });\n\n    afterEach(async function() {\n      // reset to default state\n      await enableSyncWithOS();\n    });\n\n    it(\"should have a link to the custom CSS file\", async function() {\n      const customCssTag = await driver.findElements(\n        By.css(\"link#grist-custom-css\"),\n      );\n      assert.exists(customCssTag[0], \"Custom CSS link html element should be present\");\n    });\n\n    it(\"should allow selecting a theme\", async function() {\n      await shouldAllowThemeSelection();\n    });\n\n    it(\"should apply the selected theme\", async function() {\n      await shouldApplyThemes();\n    });\n  });\n\n  describe(\"with env GRIST_HIDE_UI_ELEMENTS=themes\", function() {\n    this.timeout(60000);\n    setupTestSuite();\n\n    gu.withEnvironmentSnapshot({\n      GRIST_HIDE_UI_ELEMENTS: \"themes\",\n    });\n\n    beforeEach(async function() {\n      const session = await gu.session().teamSite.login();\n      await session.loadDocMenu(\"/\");\n      await gu.openProfileSettingsPage();\n    });\n\n    it(\"should not allow theme selection\", async function() {\n      await assertThemesAreDisabled();\n    });\n  });\n\n  describe(\"with both envs (custom css enabled + themes disabled)\", function() {\n    this.timeout(60000);\n    setupTestSuite();\n\n    gu.withEnvironmentSnapshot({\n      APP_STATIC_INCLUDE_CUSTOM_CSS: \"true\",\n      GRIST_HIDE_UI_ELEMENTS: \"themes\",\n    });\n\n    beforeEach(async function() {\n      const session = await gu.session().teamSite.login();\n      await session.loadDocMenu(\"/\");\n      await gu.openProfileSettingsPage();\n    });\n\n    it(\"should not allow theme selection\", async function() {\n      await assertThemesAreDisabled();\n    });\n  });\n});\n\nasync function enableSyncWithOS() {\n  const syncWithOSCheckbox = await findSyncWithOSCheckbox();\n  const isSelected = await syncWithOSCheckbox.isSelected();\n  if (!isSelected) {\n    await syncWithOSCheckbox.click();\n  }\n}\n\nasync function shouldAllowThemeSelection() {\n  await gu.openProfileSettingsPage();\n\n  const syncWithOSCheckbox = await findSyncWithOSCheckbox();\n  const isSelected = await syncWithOSCheckbox.isSelected();\n  if (isSelected) {\n    await syncWithOSCheckbox.click();\n  }\n  await gu.scrollIntoView(driver.findWait(appearanceSelectCssSelector, 500));\n  await driver.findWait(appearanceSelectCssSelector, 500).click();\n  assert.deepEqual(\n    await gu.findOpenMenuAllItems(\"li\", el => el.getText()),\n    [\"Light\", \"Dark\", \"Light (High Contrast)\"],\n  );\n  await driver.sendKeys(Key.ESCAPE);    // Close menu.\n}\n\nasync function shouldApplyThemes() {\n  await gu.setGristTheme({ themeName: \"GristLight\", syncWithOS: false });\n  await assertAppliedTheme(\"GristLight\");\n\n  await gu.setGristTheme({ themeName: \"GristDark\", syncWithOS: false });\n  await assertAppliedTheme(\"GristDark\");\n\n  await gu.setGristTheme({ themeName: \"HighContrastLight\", syncWithOS: false });\n  await assertAppliedTheme(\"HighContrastLight\");\n}\n\nasync function assertThemesAreDisabled() {\n  const appearanceSelect = await driver.findElements(\n    By.css(\".test-theme-config-appearance .test-select-open\"),\n  );\n  const syncWithOSCheckbox = await driver.findElements(\n    By.css(\".test-theme-config-sync-with-os\"),\n  );\n  assert.isEmpty(appearanceSelect, \"Appearance select should not be shown\");\n  assert.isEmpty(syncWithOSCheckbox, \"Sync with OS checkbox should not be shown\");\n}\n"
  },
  {
    "path": "test/nbrowser/Timing.ts",
    "content": "import { DocAPI, UserAPI } from \"app/common/UserAPI\";\nimport { button, element, label, option } from \"test/nbrowser/elementUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport difference from \"lodash/difference\";\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"Timing\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  let docApi: DocAPI;\n  let userApi: UserAPI;\n  let docId: string;\n  let session: gu.Session;\n\n  before(async () => {\n    session = await gu.session().teamSite.login();\n    docId = await session.tempNewDoc(cleanup);\n    userApi = session.createHomeApi();\n    docApi = userApi.getDocAPI(docId);\n  });\n\n  async function assertOn() {\n    await gu.waitToPass(async () => {\n      assert.equal(await timingText.text(), \"Timing is on...\");\n    });\n\n    assert.isTrue(await stopTiming.visible());\n    assert.isFalse(await startTiming.present());\n  }\n\n  async function assertOff() {\n    await gu.waitToPass(async () => {\n      assert.equal(await timingText.text(), \"Find slow formulas\");\n    });\n    assert.isTrue(await startTiming.visible());\n    assert.isFalse(await stopTiming.present());\n  }\n\n  it(\"should allow to start session\", async function() {\n    await gu.openDocumentSettings();\n    // Make sure we see the timing button.\n    await assertOff();\n\n    // Start timing.\n    await startTiming.click();\n\n    // Wait for modal.\n    await modal.wait();\n\n    // We have two options.\n    assert.isTrue(await optionStart.visible());\n    assert.isTrue(await optionReload.visible());\n\n    // Start is selected by default.\n    assert.isTrue(await optionStart.checked());\n    assert.isFalse(await optionReload.checked());\n\n    await modalConfirm.click();\n\n    await assertOn();\n  });\n\n  it(\"should reflect that in the API\", async function() {\n    assert.equal(await docApi.timing().then(x => x.status), \"active\");\n  });\n\n  it(\"should stop session from outside\", async function() {\n    await docApi.stopTiming();\n    await assertOff();\n  });\n\n  it(\"should start session from API\", async function() {\n    await docApi.startTiming();\n\n    // Add new record through the API (to trigger formula calculations).\n    await userApi.applyUserActions(docId, [\n      [\"AddRecord\", \"Table1\", null, {}],\n    ]);\n  });\n\n  it(\"should show result and stop session\", async function() {\n    // The stop button is actually stop and show results, and it will open new window in.\n    const myTab = await gu.myTab();\n    const tabs = await driver.getAllWindowHandles();\n    await stopTiming.click();\n\n    // Now new tab will be opened, and the timings will be stopped.\n    await gu.waitToPass(async () => {\n      assert.equal(await docApi.timing().then(x => x.status), \"disabled\");\n    });\n\n    // Find the new tab.\n    const newTab = difference(await driver.getAllWindowHandles(), tabs)[0];\n    assert.isDefined(newTab);\n    await driver.switchTo().window(newTab);\n\n    // Sanity check that we see some results.\n    assert.isTrue(await driver.findContentWait(\"div\", \"Formula timer\", 1000).isDisplayed());\n\n    await gu.waitToPass(async () => {\n      assert.equal(await gu.getCell(0, 1).getText(), \"Table1\");\n    });\n\n    // Switch back to the original tab.\n    await myTab.open();\n\n    // Make sure controls are back to the initial state.\n    await assertOff();\n\n    // Close the new tab.\n    await driver.switchTo().window(newTab);\n    await driver.close();\n    await myTab.open();\n  });\n\n  it(\"should allow to time the document load\", async function() {\n    await assertOff();\n\n    await startTiming.click();\n    await modal.wait();\n\n    // Check that cancel works.\n    await modalCancel.click();\n    assert.isFalse(await modal.present());\n    await assertOff();\n\n    // Open modal once again but this time select reload.\n    await startTiming.click();\n    await optionReload.click();\n    assert.isTrue(await optionReload.checked());\n    await modalConfirm.click();\n\n    // We will see spinner.\n    await gu.waitToPass(async () => {\n      await driver.findContentWait(\"div\", \"Loading timing data.\", 1000);\n    });\n\n    // We land on the timing page in the same tab.\n    await gu.waitToPass(async () => {\n      assert.isTrue(await driver.findContentWait(\"div\", \"Formula timer\", 1000).isDisplayed());\n      assert.equal(await gu.getCell(0, 1).getText(), \"Table1\");\n    });\n\n    // Refreshing this tab will move us to the settings page.\n    await driver.navigate().refresh();\n    await gu.waitForUrl(\"/settings\");\n  });\n\n  it(\"clears virtual table when navigated away\", async function() {\n    // Start timing and go to results.\n    await startTiming.click();\n    await modal.wait();\n    await optionReload.click();\n    await modalConfirm.click();\n\n    // Wait for the results page.\n    await gu.waitToPass(async () => {\n      assert.isTrue(await driver.findContentWait(\"div\", \"Formula timer\", 1000).isDisplayed());\n      assert.equal(await gu.getCell(0, 1).getText(), \"Table1\");\n    });\n\n    // Now go to the raw data page, and make sure we see only Table1.\n    await driver.find(\".test-tools-raw\").click();\n    await driver.findWait(\".test-raw-data-list\", 2000);\n    assert.deepEqual(await driver.findAll(\".test-raw-data-table-id\", e => e.getText()), [\"Table1\"]);\n  });\n\n  it(\"should be disabled for non-owners\", async function() {\n    await userApi.updateDocPermissions(docId, { users: {\n      [gu.translateUser(\"user2\").email]: \"editors\",\n    } });\n\n    const session = await gu.session().teamSite.user(\"user2\").login();\n    await session.loadDoc(`/doc/${docId}`);\n    await gu.openDocumentSettings();\n\n    const start = driver.find(\".test-settings-timing-start\");\n    assert.equal(await start.isPresent(), true);\n\n    // Check that we have an informative tooltip.\n    await start.mouseMove();\n    assert.match(await driver.findWait(\".test-tooltip\", 2000).getText(), /Only available to document owners/);\n\n    // Nothing should happen on click. We click the location rather than the element, since the\n    // element isn't actually clickable.\n    await start.mouseMove();\n    await driver.withActions(a => a.press().release());\n    await driver.sleep(100);\n    assert.equal(await driver.find(\".test-settings-timing-modal\").isPresent(), false);\n  });\n});\n\nconst startTiming = button(\".test-settings-timing-start\");\nconst stopTiming = button(\".test-settings-timing-stop\");\nconst timingText = label(\".test-settings-timing-desc\");\nconst modal = element(\".test-settings-timing-modal\");\nconst optionStart = option(\".test-settings-timing-modal-option-adhoc\");\nconst optionReload = option(\".test-settings-timing-modal-option-reload\");\nconst modalConfirm = button(\".test-settings-timing-modal-confirm\");\nconst modalCancel = button(\".test-settings-timing-modal-cancel\");\n"
  },
  {
    "path": "test/nbrowser/ToggleColumns.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { Session } from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"ToggleColumns\", function() {\n  this.timeout(20000);\n\n  const cleanup = setupTestSuite({ team: true });\n\n  let session: Session;\n  let docId: string;\n\n  before(async function() {\n    session = await gu.session().teamSite.login();\n    docId = await session.tempNewDoc(cleanup, \"ToggleColumns\", { load: false });\n\n    // Set up a table Src, and table Items which links to Src and has a boolean column.\n    const api = session.createHomeApi();\n    await api.applyUserActions(docId, [\n      [\"AddTable\", \"Src\", [{ id: \"A\", type: \"Text\" }]],\n      [\"BulkAddRecord\", \"Src\", [null, null, null], { A: [\"a\", \"b\", \"c\"] }],\n      [\"AddTable\", \"Items\", [\n        { id: \"A\", type: \"Ref:Src\" },\n        { id: \"Chk\", type: \"Bool\" },\n        // An extra text column reflects the boolean for simpler checking of the values.\n        { id: \"Chk2\", isFormula: true, formula: \"$Chk\" },\n      ]],\n      [\"BulkAddRecord\", \"Items\", [null, null, null], { A: [1, 1, 3] }],\n    ]);\n\n    await session.loadDoc(`/doc/${docId}`);\n\n    // Set up a page with linked widgets.\n    await gu.addNewPage(\"Table\", \"Src\");\n    await gu.addNewSection(\"Table\", \"Items\", { selectBy: /Src/i });\n  });\n\n  it(\"should fill in values determined by linking when checkbox is clicked\", async function() {\n    // Test the behavior with a checkbox.\n    await verifyToggleBehavior();\n  });\n\n  it(\"should fill in values determined by linking when switch widget is clicked\", async function() {\n    // Now switch the widget to the \"Switch\" widget, and test again.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    await gu.setFieldWidgetType(\"Switch\");\n    await verifyToggleBehavior();\n  });\n\n  async function verifyToggleBehavior() {\n    // Selecting a cell in Src should show only linked values in Items.\n    await gu.getCell({ section: \"Src\", col: \"A\", rowNum: 1 }).click();\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Items\", cols: [\"A\", \"Chk2\"], rowNums: [1, 2, 3] }), [\n      \"Src[1]\", \"false\",\n      \"Src[1]\", \"false\",\n      \"\", \"\",\n    ]);\n    // Click on the cell in the \"Add Row\" of Items. Because the checkbox is centered in the cell,\n    // the click should toggle it.\n    await gu.getCell({ section: \"Items\", col: \"Chk\", rowNum: 3 }).click();\n    await gu.waitForServer();\n\n    // Check that there is a new row, properly linked.\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Items\", cols: [\"A\", \"Chk2\"], rowNums: [1, 2, 3, 4] }), [\n      \"Src[1]\", \"false\",\n      \"Src[1]\", \"false\",\n      \"Src[1]\", \"true\",\n      \"\", \"\",\n    ]);\n\n    // Try another row of table Src. It should have its own Items (initially none).\n    await gu.getCell({ section: \"Src\", col: \"A\", rowNum: 2 }).click();\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Items\", cols: [\"A\", \"Chk2\"], rowNums: [1] }),\n      [\"\", \"\"]);\n\n    // Click checkbox in \"Add Row\" of Items again.\n    await gu.getCell({ section: \"Items\", col: \"Chk\", rowNum: 1 }).click();\n    await gu.waitForServer();\n\n    // Check that we see the new row, with the value determined by linking (column 'A') set correctly.\n    assert.deepEqual(await gu.getVisibleGridCells({ section: \"Items\", cols: [\"A\", \"Chk2\"], rowNums: [1, 2] }), [\n      \"Src[2]\", \"true\",\n      \"\", \"\",\n    ]);\n\n    await gu.undo(2);\n  }\n});\n"
  },
  {
    "path": "test/nbrowser/TokenField.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, Key } from \"mocha-webdriver\";\n\ndescribe(\"TokenField\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n\n  before(async function() {\n    session = await gu.session().login();\n    await session.tempDoc(cleanup, \"Favorite_Films.grist\");\n    await gu.toggleSidePanel(\"right\", \"open\");\n\n    // Prepare test table, as a base for following tests.\n    await gu.addNewPage(\"Table\", \"New Table\");\n    await gu.sendKeys(\"one\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.sendKeys(\"two\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.sendKeys(\"three\", Key.ENTER);\n    await gu.waitForServer();\n  });\n\n  it(\"should clear choice list on card view\", async function() {\n    // Test for a bug. In Card or Card List widgets, if the cursor is on a Ref List or Choice List field and you\n    // hit Backspace or Delete, the behavior was the same as hitting Enter (pulls up list of references/choices to add\n    // one more, does not clear cell).\n\n    // The bug was there because choice list editor and ref list editor didn't handle empty string as an edit value,\n    // which is a signal to clear the value. In a grid view, DELETE and BACKSPACE were handled by the grid itself.\n\n    // As of November 26, 2024, the behavior now matches GridView.\n    const revert = await gu.begin();\n    await gu.getCell(\"A\", 1).click();\n\n    // Convert A column to Choice List.\n    await gu.openColumnPanel();\n    await gu.setType(\"Choice List\", { apply: true });\n\n    // Add a second value to the first row.\n    await gu.getCell(\"A\", 1).click();\n    await gu.sendKeys(Key.ENTER);\n    await gu.sendKeys(\"two\", Key.ENTER);\n    await gu.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n    await gu.getCell(\"A\", 1).click();\n    assert.equal(await gu.getCell(\"A\", 1).getText(), \"one\\ntwo\");\n\n    // Change it to card view.\n    await gu.changeWidget(\"Card\");\n\n    // Test that DELETE clears the value.\n    // Clicking on the cell twice will put it in edit mode, so we will first click other cell.\n    await gu.getDetailCell(\"B\", 1).click();\n    await gu.getDetailCell(\"A\", 1).click();\n    await gu.sendKeys(Key.DELETE);\n    await gu.waitForServer();\n    assert.isEmpty(await gu.getDetailCell(\"A\", 1).getText());\n\n    // Now test BACKSPACE.\n    await gu.undo();\n    assert.equal(await gu.getDetailCell(\"A\", 1).getText(), \"one\\ntwo\");\n    await gu.getDetailCell(\"B\", 1).click();\n    await gu.getDetailCell(\"A\", 1).click();\n    await gu.sendKeys(Key.BACK_SPACE);\n    await gu.waitForServer();\n    assert.isEmpty(await gu.getDetailCell(\"A\", 1).getText());\n\n    // But ENTER works fine, it just opens the editor.\n    await gu.undo();\n    await gu.sendKeys(Key.ENTER);\n    await gu.checkTokenEditor(\"one\\ntwo\");\n    await gu.sendKeys(Key.ESCAPE);\n    // Any other key also works\n    await gu.sendKeys(\"a\");\n    await gu.checkTokenEditor(\"a\");\n    await gu.sendKeys(Key.ESCAPE);\n    await revert();\n  });\n\n  it(\"should clear ref list on card view\", async function() {\n    await gu.getCell(0, 1).click();\n    await gu.changeBehavior(\"Clear and reset\");\n    // This is an empty column, so no transformation is needed.\n    await gu.setType(\"Reference List\", { apply: false });\n    await gu.waitForServer();\n    await gu.setRefTable(\"Films\");\n    await gu.waitForServer();\n    await gu.setRefShowColumn(\"Title\");\n    await gu.waitForServer();\n\n    // Add two films.\n    await gu.sendKeys(Key.ESCAPE); // First ESCAPE to get out of select/panel focus.\n    await gu.sendKeys(Key.ENTER);\n    await gu.sendKeys(\"Toy\", Key.ENTER);\n    await gu.sendKeys(\"Alien\", Key.ENTER);\n    // Save.\n    await gu.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n\n    // Make sure it works in Grid view.\n    await gu.getCell(0, 1).click();\n    await gu.sendKeys(Key.DELETE);\n    await gu.waitForServer();\n    assert.equal(await gu.getCell(0, 1).getText(), \"\");\n    await gu.undo();\n    await gu.sendKeys(Key.BACK_SPACE);\n    await gu.waitForServer();\n    assert.equal(await gu.getCell(0, 1).getText(), \"\");\n    await gu.undo();\n\n    // Now make sure it works in Card view.\n    await gu.changeWidget(\"Card\");\n    assert.equal(await gu.getDetailCell(\"A\", 1).getText(), \"Toy Story\\nAlien\");\n    await gu.sendKeys(Key.DELETE);\n    await gu.waitForServer();\n    assert.isEmpty(await gu.getDetailCell(\"A\", 1).getText());\n    await gu.undo();\n    assert.equal(await gu.getDetailCell(\"A\", 1).getText(), \"Toy Story\\nAlien\");\n    await gu.sendKeys(Key.BACK_SPACE);\n    await gu.waitForServer();\n    assert.isEmpty(await gu.getDetailCell(\"A\", 1).getText());\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/TwoWayReference.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { Session } from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"TwoWayReference\", function() {\n  this.timeout(\"3m\");\n\n  // Sandboxing disrupts timing a bit.\n  testUtils.withoutSandboxing();\n\n  let session: Session;\n  let docId: string;\n  let revert: () => Promise<void>;\n  const cleanup = setupTestSuite();\n  afterEach(() => gu.checkForErrors());\n  before(async function() {\n    session = await gu.session().login();\n    docId = await session.tempNewDoc(cleanup);\n    await petsSetup();\n  });\n\n  async function petsSetup() {\n    await gu.sendActions([\n      [\"RenameColumn\", \"Table1\", \"A\", \"Name\"],\n      [\"ModifyColumn\", \"Table1\", \"Name\", { label: \"Name\" }],\n      [\"RemoveColumn\", \"Table1\", \"B\"],\n      [\"RemoveColumn\", \"Table1\", \"C\"],\n      [\"RenameTable\", \"Table1\", \"Owners\"],\n      [\"AddTable\", \"Pets\", [\n        { id: \"Name\", type: \"Text\" },\n        { id: \"Owner\", type: \"Ref:Owners\" },\n      ]],\n      [\"AddRecord\", \"Owners\", -1, { Name: \"Alice\" }],\n      [\"AddRecord\", \"Owners\", -2, { Name: \"Bob\" }],\n      [\"AddRecord\", \"Pets\", null, { Name: \"Rex\", Owner: -2 }],\n    ]);\n    await gu.addNewSection(\"Table\", \"Pets\");\n    await gu.openColumnPanel(\"Owner\");\n    await gu.setRefShowColumn(\"Name\");\n    await addReverseColumn();\n  }\n\n  it(\"works after reload\", async function() {\n    const revert = await gu.begin();\n    await gu.selectSectionByTitle(\"OWNERS\");\n    assert.deepEqual(await gu.getVisibleGridCells(\"Pets\", [1, 2]), [\"\", \"Rex\"]);\n    await session.createHomeApi().getDocAPI(docId).forceReload();\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n    // Change Rex owner to Alice.\n    await gu.selectSectionByTitle(\"PETS\");\n    await gu.getCell(\"Owner\", 1).click();\n    await gu.sendKeys(\"Alice\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.selectSectionByTitle(\"OWNERS\");\n    assert.deepEqual(await gu.getVisibleGridCells(\"Pets\", [1, 2]), [\"Rex\", \"\"]);\n    await revert();\n  });\n\n  it(\"creates proper names when labels are not standard\", async function() {\n    const revert = await gu.begin();\n    await gu.toggleSidePanel(\"left\", \"close\");\n\n    // Remove the reverse column, then rename the table to contain illegal characters\n    // in label, and add ref columns to it.\n    await gu.selectSectionByTitle(\"PETS\");\n    await gu.openColumnPanel(\"Owner\");\n    await removeTwoWay();\n    await removeModal.wait();\n    await removeModal.confirm();\n    await gu.waitForServer();\n\n    // Now add another Ref:Owners column to Pets table.\n    await gu.sendActions([\n      [\"AddVisibleColumn\", \"Pets\", \"Friend\", { type: \"Ref:Owners\" }],\n    ]);\n    await gu.selectColumn(\"Friend\");\n    await gu.setRefShowColumn(\"Name\");\n    await gu.getCell(\"Friend\", 1).click();\n    await gu.enterCell(\"Bob\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Now rename the Pets table to start with a number and contain a space + person emoji.\n    const LABEL = \"2 🧑 + 🐕\";\n    await gu.renameTable(\"Pets\", LABEL);\n\n    // Now create reverse column for Owner and Friend.\n    await gu.openColumnPanel(\"Owner\");\n    await addReverseColumn();\n    await gu.openColumnPanel(\"Friend\");\n    await addReverseColumn();\n\n    // Hide side panels.\n    await gu.toggleSidePanel(\"left\", \"close\");\n    await gu.toggleSidePanel(\"right\", \"close\");\n\n    // Make sure we see proper data.\n    await gu.assertGridData(LABEL, [\n      [0, \"Name\", \"Owner\", \"Friend\"],\n      [1, \"Rex\",  \"Alice\", \"Bob\"],\n    ]);\n\n    await gu.assertGridData(\"OWNERS\", [\n      [0, \"Name\", LABEL, `${LABEL}-Friend`],\n      [1, \"Alice\", \"Rex\", \"\"],\n      [2, \"Bob\",   \"\", \"Rex\"],\n    ]);\n\n    await gu.selectSectionByTitle(\"OWNERS\");\n    // Check that creator panel contains proper names.\n    await gu.openColumnPanel(LABEL);\n    assert.equal(await driver.find(\".test-field-col-id\").value(), \"$c2_\");\n\n    await revert();\n  });\n\n  it(\"properly reasings reflists\", async function() {\n    const revert = await gu.begin();\n\n    // Add two more dogs and move all of them to Alice\n    await gu.sendActions([\n      [\"AddRecord\", \"Pets\", null, { Name: \"Pluto\", Owner: 1 }],\n      [\"AddRecord\", \"Pets\", null, { Name: \"Azor\", Owner: 1 }],\n      [\"UpdateRecord\", \"Pets\", 1, { Owner: 1 }],\n    ]);\n\n    // Now reasign Azor to Bob using Owners table.\n    await gu.selectSectionByTitle(\"OWNERS\");\n    await gu.getCell(\"Pets\", 2).click();\n    await gu.sendKeys(Key.ENTER, \"Azor\", Key.ENTER, Key.ENTER);\n    await gu.waitForServer();\n\n    // Make sure we see it.\n    assert.deepEqual(await gu.getVisibleGridCells(\"Pets\", [1, 2]), [\"Rex\\nPluto\\nAzor\", \"\"]);\n\n    // We are now in a modal dialog.\n    assert.equal(\n      await driver.findWait(\".test-modal-dialog label\", 100).getText(),\n      'Reassign to Owners record \"Bob\".',\n    );\n\n    // Reassign it.\n    await driver.findWait(\".test-modal-dialog input\", 100).click();\n    await driver.findWait(\".test-modal-dialog button\", 100).click();\n    await gu.waitForServer();\n\n    // Make sure we see correct value.\n    assert.deepEqual(await gu.getVisibleGridCells(\"Pets\", [1, 2]), [\"Rex\\nPluto\", \"Azor\"]);\n\n    await revert();\n  });\n\n  it(\"deletes tables with 2 way references\", async function() {\n    const revert = await gu.begin();\n\n    const beforeRemove = await gu.begin();\n    await driver.find(\".test-tools-raw\").click();\n    const removeTable = async (tableId: string) => {\n      await driver.findWait(`.test-raw-data-table-menu-${tableId}`, 1000).click();\n      await driver.find(\".test-raw-data-menu-remove-table\").click();\n      await driver.find(\".test-modal-confirm\").click();\n      await gu.waitForServer();\n    };\n    await removeTable(\"Pets\");\n    await beforeRemove();\n    await removeTable(\"Owners\");\n    await gu.checkForErrors();\n    await revert();\n    await gu.toggleSidePanel(\"left\", \"open\");\n    await gu.openPage(\"Table1\");\n  });\n\n  it(\"detects new columns after modify\", async function() {\n    const revert = await gu.begin();\n\n    await gu.selectSectionByTitle(\"Owners\");\n    await gu.openColumnPanel(\"Pets\");\n    await gu.setType(\"Reference\", { apply: true });\n    await gu.setType(\"Reference List\", { apply: true });\n\n    await gu.selectSectionByTitle(\"Pets\");\n    await gu.getCell(\"Owner\", 1).click();\n    await gu.sendKeys(Key.DELETE);\n    await gu.waitForServer();\n\n    await gu.selectSectionByTitle(\"Owners\");\n    assert.deepEqual(await gu.getVisibleGridCells(\"Pets\", [1, 2]), [\"\", \"\"]);\n    await revert();\n  });\n\n  it(\"can delete reverse column without an error\", async function() {\n    const revert = await gu.begin();\n    // This can't be tested easily in python as it requries node server for type transformation.\n    await gu.toggleSidePanel(\"left\", \"close\");\n    await gu.toggleSidePanel(\"right\", \"close\");\n\n    await gu.assertGridData(\"OWNERS\", [\n      [0, \"Name\", \"Pets\"],\n      [1, \"Alice\", \"Rex\"],\n      [2, \"Bob\", \"\"],\n    ]);\n    await gu.assertGridData(\"PETS\", [\n      [0, \"Name\", \"Owner\"],\n      [1, \"Rex\",  \"Alice\"],\n    ]);\n\n    // Remove the reverse column.\n    await gu.selectSectionByTitle(\"OWNERS\");\n    await gu.deleteColumn(\"Pets\");\n    await gu.checkForErrors();\n\n    // Check data.\n    assert.deepEqual(await columns(), [\n      [\"Name\"],\n      [\"Name\", \"Owner\"],\n    ]);\n    await gu.assertGridData(\"PETS\", [\n      [0, \"Name\", \"Owner\"],\n      [1, \"Rex\",  \"Alice\"],\n    ]);\n    await gu.undo();\n\n    // Check data.\n    await gu.assertGridData(\"OWNERS\", [\n      [0, \"Name\", \"Pets\"],\n      [1, \"Alice\", \"Rex\"],\n      [2, \"Bob\", \"\"],\n    ]);\n    await gu.assertGridData(\"PETS\", [\n      [0, \"Name\", \"Owner\"],\n      [1, \"Rex\",  \"Alice\"],\n    ]);\n\n    // Check that connection works.\n\n    // Make sure we can change data.\n    await gu.selectSectionByTitle(\"PETS\");\n    await gu.getCell(\"Owner\", 1).click();\n    await gu.enterCell(\"Bob\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.checkForErrors();\n\n    // Check data.\n    await gu.assertGridData(\"OWNERS\", [\n      [0, \"Name\", \"Pets\"],\n      [1, \"Alice\", \"\"],\n      [2, \"Bob\", \"Rex\"],\n    ]);\n    await gu.assertGridData(\"PETS\", [\n      [0, \"Name\", \"Owner\"],\n      [1, \"Rex\",  \"Bob\"],\n    ]);\n\n    // Now delete Owner column, and redo it\n    await gu.selectSectionByTitle(\"Pets\");\n    await gu.deleteColumn(\"Owner\");\n    await gu.checkForErrors();\n    await gu.undo();\n    await gu.redo();\n    await gu.undo();\n    await gu.checkForErrors();\n\n    // Check data.\n    await gu.assertGridData(\"OWNERS\", [\n      [0, \"Name\", \"Pets\"],\n      [1, \"Alice\", \"\"],\n      [2, \"Bob\", \"Rex\"],\n    ]);\n    await gu.assertGridData(\"PETS\", [\n      [0, \"Name\", \"Owner\"],\n      [1, \"Rex\",  \"Bob\"],\n    ]);\n    await revert();\n  });\n\n  it(\"breaks connection after removing reverseCol\", async function() {\n    const revert = await gu.begin();\n\n    // Move Rex to Bob.\n    await gu.selectSectionByTitle(\"PETS\");\n    await gu.getCell(\"Owner\", 1).click();\n    await gu.enterCell(\"Bob\", Key.ENTER);\n    await gu.waitForServer();\n\n    // Make sure Rex is owned by Bob, in both tables.\n    await gu.assertGridData(\"OWNERS\", [\n      [0, \"Name\", \"Pets\"],\n      [1, \"Alice\", \"\"],\n      [2, \"Bob\",   \"Rex\"],\n    ]);\n    await gu.assertGridData(\"PETS\", [\n      [0, \"Name\", \"Owner\"],\n      [1, \"Rex\",  \"Bob\"],\n    ]);\n\n    // Now move Rex to Alice.\n    await gu.selectSectionByTitle(\"PETS\");\n    await gu.getCell(\"Owner\", 1).click();\n    await gu.enterCell(\"Alice\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.assertGridData(\"OWNERS\", [\n      [0, \"Name\", \"Pets\"],\n      [1, \"Alice\", \"Rex\"],\n      [2, \"Bob\", \"\"],\n    ]);\n\n    // Now remove connection using Owner column.\n    await gu.sendActions([[\"ModifyColumn\", \"Pets\", \"Owner\", { reverseCol: 0 }]]);\n    await gu.checkForErrors();\n\n    // And check that after moving Rex to Bob, it's not shown in the Owners table.\n    await gu.selectSectionByTitle(\"PETS\");\n    await gu.getCell(\"Owner\", 1).click();\n    await gu.enterCell(\"Bob\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.checkForErrors();\n\n    await gu.assertGridData(\"OWNERS\", [\n      [0, \"Name\", \"Pets\"],\n      [1, \"Alice\", \"Rex\"],\n      [2, \"Bob\", \"\"],\n    ]);\n    await gu.assertGridData(\"PETS\", [\n      [0, \"Name\", \"Owner\"],\n      [1, \"Rex\",  \"Bob\"],\n    ]);\n\n    // Check undo, it should restore the link.\n    await gu.undo(2);\n\n    // Rex is now in Alice again in both tables.\n    await gu.assertGridData(\"OWNERS\", [\n      [0, \"Name\", \"Pets\"],\n      [1, \"Alice\", \"Rex\"],\n      [2, \"Bob\", \"\"],\n    ]);\n    await gu.assertGridData(\"PETS\", [\n      [0, \"Name\", \"Owner\"],\n      [1, \"Rex\",  \"Alice\"],\n    ]);\n\n    // Move Rex to Bob again.\n    await gu.selectSectionByTitle(\"PETS\");\n    await gu.getCell(\"Owner\", 1).click();\n    await gu.enterCell(\"Bob\", Key.ENTER);\n    await gu.waitForServer();\n    await gu.checkForErrors();\n\n    // And check that connection works.\n    await gu.assertGridData(\"OWNERS\", [\n      [0, \"Name\", \"Pets\"],\n      [1, \"Alice\", \"\"],\n      [2, \"Bob\", \"Rex\"],\n    ]);\n    await gu.assertGridData(\"PETS\", [\n      [0, \"Name\", \"Owner\"],\n      [1, \"Rex\",  \"Bob\"],\n    ]);\n    await revert();\n  });\n\n  it(\"common setup\", async function() {\n    await gu.sendActions([\n      [\"AddTable\", \"Projects\", []],\n      [\"AddTable\", \"People\", []],\n\n      [\"AddVisibleColumn\", \"Projects\", \"Name\", { type: \"Text\" }],\n      [\"AddVisibleColumn\", \"Projects\", \"Owner\", { type: \"Ref:People\" }],\n\n      [\"AddVisibleColumn\", \"People\", \"Name\", { type: \"Text\" }],\n    ]);\n    await gu.addNewPage(\"Table\", \"Projects\");\n    await gu.addNewSection(\"Table\", \"People\");\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.openColumnPanel();\n    await gu.toggleSidePanel(\"left\", \"close\");\n    revert = await gu.begin();\n  });\n\n  it(\"clicking show on creates a new column\", async function() {\n    await gu.selectColumn(\"Owner\");\n    await addReverseColumn();\n    assert.deepEqual(await columns(), [\n      [\"Name\", \"Owner\"],\n      [\"Name\", \"Projects\"],\n    ]);\n\n    await gu.selectSectionByTitle(\"People\");\n    await gu.openColumnPanel(\"Projects\");\n    assert.equal(await configText(), \"Projects.Owner(Ref)\");\n  });\n\n  it(\"can remove two way reference\", async function() {\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.openColumnPanel(\"Owner\");\n    await removeTwoWay();\n    await removeModal.wait();\n    await removeModal.confirm();\n    await gu.waitForServer();\n    assert.deepEqual(await columns(), [\n      [\"Name\", \"Owner\"],\n      [\"Name\"],\n    ]);\n  });\n\n  it(\"right column looks ok\", async function() {\n    await addReverseColumn();\n    await gu.waitForServer();\n\n    await gu.selectSectionByTitle(\"People\");\n    await gu.openColumnPanel(\"Projects\");\n\n    assert.equal(await gu.getType(), \"Reference List\");\n    assert.equal(await gu.getRefTable(), \"Projects\");\n  });\n\n  it(\"right column has same options\", async function() {\n    await gu.openColumnPanel(\"Projects\");\n    assert.equal(await gu.getType(), \"Reference List\");\n    assert.equal(await configText(), \"Projects.Owner(Ref)\");\n  });\n\n  it(\"reloading the page keeps the options\", async function() {\n    await gu.reloadDoc();\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.openColumnPanel(\"Owner\");\n    assert.equal(await configText(), \"People.Projects(RefList)\");\n\n    await gu.selectSectionByTitle(\"People\");\n    await gu.openColumnPanel(\"Projects\");\n    assert.equal(await configText(), \"Projects.Owner(Ref)\");\n  });\n\n  it(\"relationship can be removed through the right column\", async function() {\n    await removeTwoWay();\n    await removeModal.confirm();\n    await gu.waitForServer();\n    assert.deepEqual(await columns(), [\n      [\"Name\"],\n      [\"Name\", \"Projects\"],\n    ]);\n  });\n\n  it(\"undo works\", async function() {\n    // First revert all changes.\n    await revert();\n    await gu.checkForErrors();\n    assert.deepEqual(await columns(), [\n      [\"Name\", \"Owner\"],\n      [\"Name\"],\n    ]);\n\n    // Now redo all changes.\n    await gu.redoAll();\n    await gu.checkForErrors();\n    assert.deepEqual(await columns(), [\n      [\"Name\"],\n      [\"Name\", \"Projects\"],\n    ]);\n\n    await revert();\n    await gu.checkForErrors();\n    assert.deepEqual(await columns(), [\n      [\"Name\", \"Owner\"],\n      [\"Name\"],\n    ]);\n\n    // And now check individual changes.\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.openColumnPanel(\"Owner\");\n    assert.isTrue(await canAddReverseColumn());\n\n    // Now add and do a single undo to make sure it is bundled.\n    await addReverseColumn();\n    assert.deepEqual(await columns(), [\n      [\"Name\", \"Owner\"],\n      [\"Name\", \"Projects\"],\n    ]);\n    await gu.undo(1);\n    assert.deepEqual(await columns(), [\n      [\"Name\", \"Owner\"],\n      [\"Name\"],\n    ]);\n  });\n\n  it(\"can delete left column\", async function() {\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.openColumnPanel(\"Owner\");\n    await addReverseColumn();\n    await gu.deleteColumn(\"Owner\");\n    await gu.checkForErrors();\n    assert.deepEqual(await columns(), [\n      [\"Name\"],\n      [\"Name\", \"Projects\"],\n    ]);\n    await gu.selectSectionByTitle(\"People\");\n    await gu.openColumnPanel(\"Projects\");\n    assert.isTrue(await canAddReverseColumn());\n    await gu.deleteColumn(\"Projects\");\n    await gu.checkForErrors();\n    await revert();\n    assert.deepEqual(await columns(), [\n      [\"Name\", \"Owner\"],\n      [\"Name\"],\n    ]);\n  });\n\n  it(\"can delete right column\", async function() {\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.openColumnPanel(\"Owner\");\n    await addReverseColumn();\n    await gu.selectSectionByTitle(\"People\");\n    await gu.openColumnPanel(\"Projects\");\n    await gu.deleteColumn(\"Projects\");\n    await gu.checkForErrors();\n    assert.deepEqual(await columns(), [\n      [\"Name\", \"Owner\"],\n      [\"Name\"],\n    ]);\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.openColumnPanel(\"Owner\");\n    assert.isFalse(await isConfigured());\n  });\n\n  it(\"syncs columns\", async function() {\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.openColumnPanel(\"Owner\");\n    await gu.setRefShowColumn(\"Name\");\n    await addReverseColumn();\n\n    // Show better names.\n    await gu.selectSectionByTitle(\"People\");\n    await gu.openColumnPanel(\"Projects\");\n    await gu.setRefShowColumn(\"Name\");\n\n    // Add two projects.\n    await gu.sendActions([\n      [\"AddRecord\", \"Projects\", null, { Name: \"Apps\" }],\n      [\"AddRecord\", \"Projects\", null, { Name: \"Backend\" }],\n    ]);\n    // Add two people.\n    await gu.sendActions([\n      [\"AddRecord\", \"People\", null, { Name: \"Alice\" }],\n      [\"AddRecord\", \"People\", null, { Name: \"Bob\" }],\n    ]);\n\n    // Now assign Bob to Backend and Alice to Apps.\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.getCell(\"Owner\", 1).click();\n    await gu.enterCell(\"Alice\");\n    await gu.getCell(\"Owner\", 2).click();\n    await gu.enterCell(\"Bob\");\n\n    // And now make sure the reverse reference is correct.\n    await gu.selectSectionByTitle(\"People\");\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2]), [\"Alice\", \"Bob\"]);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Projects\", [1, 2]), [\"Apps\", \"Backend\"]);\n  });\n\n  it(\"sync columns when edited from right\", async function() {\n    await gu.getCell(\"Projects\", 1).click();\n    // Remove the project from Alice.\n    await gu.sendKeys(Key.DELETE);\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getVisibleGridCells(\"Projects\", [1, 2], \"People\"), [\"\", \"Backend\"]);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Owner\", [1, 2], \"Projects\"), [\"\", \"Bob\"]);\n    // Single undo restores it.\n    await gu.undo(1);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Projects\", [1, 2], \"People\"), [\"Apps\", \"Backend\"]);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Owner\", [1, 2], \"Projects\"), [\"Alice\", \"Bob\"]);\n\n    await gu.redo(1);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Projects\", [1, 2], \"People\"), [\"\", \"Backend\"]);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Owner\", [1, 2], \"Projects\"), [\"\", \"Bob\"]);\n\n    await gu.undo(1);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Projects\", [1, 2], \"People\"), [\"Apps\", \"Backend\"]);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Owner\", [1, 2], \"Projects\"), [\"Alice\", \"Bob\"]);\n  });\n\n  it(\"honors relations from list to single\", async function() {\n    // Now make Alice owner of Backend project. Apps project should now have no owner,\n    // and Bob shouldn't be owner of Backend.\n\n    const checkInitial = async () => {\n      assert.deepEqual(await gu.getVisibleGridCells(\"Owner\", [1, 2], \"Projects\"), [\"Alice\", \"Bob\"]);\n      assert.deepEqual(await gu.getVisibleGridCells(\"Projects\", [1, 2], \"People\"), [\"Apps\", \"Backend\"]);\n    };\n    await checkInitial();\n\n    await gu.selectSectionByTitle(\"People\");\n    await gu.getCell(\"Projects\", 1).click();\n    await gu.sendKeys(\"Backend\");\n    await gu.sendKeys(Key.ENTER);\n    await gu.sendKeys(Key.ENTER);\n    await gu.waitForServer();\n\n    // We should see a modal dialog\n    await driver.findWait(\".test-modal-dialog\", 100);\n\n    // We should have an option there.\n    assert.equal(\n      await driver.findWait(\".test-modal-dialog label\", 100).getText(),\n      'Reassign to People record \"Alice\".',\n    );\n\n    // Reassign it.\n    await driver.findWait(\".test-modal-dialog input\", 100).click();\n    await driver.findWait(\".test-modal-dialog button\", 100).click();\n    await gu.waitForServer();\n\n    assert.deepEqual(await gu.getVisibleGridCells(\"Owner\", [1, 2], \"Projects\"), [\"\", \"Alice\"]);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Projects\", [1, 2], \"People\"), [\"Backend\", \"\"]);\n\n    // Single undo restores it.\n    await gu.undo(1);\n    await checkInitial();\n  });\n\n  it(\"creates proper names when added multiple times\", async function() {\n    const revert = await gu.begin();\n\n    // Add another reference to Projects from People.\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.addColumn(\"Tester\", \"Reference\");\n    await gu.setRefTable(\"People\");\n    await gu.setRefShowColumn(\"Name\");\n\n    // And now show it on People.\n    await addReverseColumn();\n\n    // We should now see 3 columns on People.\n    await gu.selectSectionByTitle(\"People\");\n    assert.deepEqual(await gu.getColumnNames(), [\"Name\", \"Projects\", \"Projects-Tester\"]);\n\n    // Add yet another one.\n    await gu.selectSectionByTitle(\"Projects\");\n    await gu.addColumn(\"PM\", \"Reference\");\n    await gu.setRefTable(\"People\");\n    await gu.setRefShowColumn(\"Name\");\n    await addReverseColumn();\n\n    // We should now see 4 columns on People.\n    await gu.selectSectionByTitle(\"People\");\n    assert.deepEqual(await gu.getColumnNames(), [\"Name\", \"Projects\", \"Projects-Tester\", \"Projects-PM\"]);\n\n    await revert();\n  });\n\n  it(\"works well for self reference\", async function() {\n    const revert = await gu.begin();\n\n    // Create a new table with task hierarchy and check if looks sane.\n    await gu.addNewPage(\"Table\", \"New Table\", {\n      tableName: \"Tasks\",\n    });\n    await gu.renameColumn(\"A\", \"Name\");\n    await gu.renameColumn(\"B\", \"Parent\");\n    await gu.sendActions([\n      [\"RemoveColumn\", \"Tasks\", \"C\"],\n    ]);\n    await gu.setType(\"Reference\");\n    await gu.setRefTable(\"Tasks\");\n    await gu.setRefShowColumn(\"Name\");\n    await gu.sendActions([\n      [\"AddRecord\", \"Tasks\", -1, { Name: \"Parent\" }],\n      [\"AddRecord\", \"Tasks\", null, { Name: \"Child\", Parent: -1 }],\n    ]);\n    await gu.openColumnPanel(\"Parent\");\n    await addReverseColumn();\n\n    // We should now see 3 columns on Tasks.\n    assert.deepEqual(await gu.getColumnNames(), [\"Name\", \"Parent\", \"Tasks\"]);\n\n    await gu.openColumnPanel(\"Tasks\");\n    await gu.setRefShowColumn(\"Name\");\n\n    // Check that data looks ok.\n    assert.deepEqual(await gu.getVisibleGridCells(\"Name\", [1, 2]), [\"Parent\", \"Child\"]);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Parent\", [1, 2]), [\"\", \"Parent\"]);\n    assert.deepEqual(await gu.getVisibleGridCells(\"Tasks\", [1, 2]), [\"Child\", \"\"]);\n\n    await revert();\n  });\n\n  it(\"converts from RefList to Ref without problems\", async function() {\n    await session.tempNewDoc(cleanup);\n    const revert = await gu.begin();\n    await gu.sendActions([\n      [\"AddTable\", \"People\", [\n        { id: \"Name\", type: \"Text\" },\n        { id: \"Supervisor\", type: \"Ref:People\" },\n      ]],\n      [\"AddRecord\", \"People\", 1, { Name: \"Alice\" }],\n      [\"AddRecord\", \"People\", 4, { Name: \"Bob\" }],\n      [\"UpdateRecord\", \"People\", 1, { Supervisor: 4 }],\n      [\"UpdateRecord\", \"People\", 3, { Supervisor: 0 }],\n    ]);\n\n    await gu.toggleSidePanel(\"left\", \"open\");\n    await gu.openPage(\"People\");\n    await gu.openColumnPanel(\"Supervisor\");\n    await gu.setRefShowColumn(\"Name\");\n\n    // Using the convert dialog caused an error, which wasn't raised when doing it manually.\n    await gu.setType(\"Reference List\", { apply: true });\n    await gu.setType(\"Reference\", { apply: true });\n    await gu.checkForErrors();\n\n    await revert();\n  });\n});\n\nconst canAddReverseColumn = async () => {\n  return await driver.findWait(\".test-add-reverse-columm\", 100).isPresent();\n};\n\nconst isConfigured = async () => {\n  if (!await driver.find(\".test-reverse-column-label\").isPresent()) {\n    return false;\n  }\n  return await driver.findWait(\".test-reverse-column-label\", 100).isDisplayed();\n};\n\nconst addReverseColumn = () => driver.findWait(\".test-add-reverse-columm\", 100)\n  .click().then(() => gu.waitForServer());\n\nconst removeTwoWay = () => driver.findWait(\".test-remove-reverse-column\", 100).click()\n  .then(() => gu.waitForServer());\n\nconst configText = async () => {\n  const text = await driver.findWait(\".test-reverse-column-label\", 100).getText();\n  return text.trim().split(\"\\n\").join(\"\").replace(\"COLUMN\", \".\").replace(\"TARGET TABLE\", \"\");\n};\n\nconst removeModal = {\n  wait: async () => assert.isTrue(await driver.findWait(\".test-modal-confirm\", 100).isDisplayed()),\n  confirm: () => driver.findWait(\".test-modal-confirm\", 100).click().then(() => gu.waitForServer()),\n  cancel: () => driver.findWait(\".test-modal-cancel\", 100).click(),\n  checkUnlink: () => driver.findWait(\".test-option-unlink\", 100).click(),\n  checkRemove: () => driver.findWait(\".test-option-remove\", 100).click(),\n};\n\n/**\n * Returns an array of column headers for each table in the document.\n */\nasync function columns() {\n  const headers: string[][] = [];\n\n  for (const table of await driver.findAll(\".gridview_stick-top\")) {\n    const cols = await table.findAll(\".g-column-label\", e => e.getText());\n    headers.push(cols);\n  }\n  return headers;\n}\n"
  },
  {
    "path": "test/nbrowser/TypeChange.ntest.js",
    "content": "import { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\n// Helper that returns the cell text prefixed by \"!\" if the cell is invalid.\nasync function valText(cell) {\n  const isInvalid = await cell.find(\".field_clip\").hasClass(\"invalid\");\n  const text = await cell.getText();\n  return (isInvalid ? \"!\" : \"\") + text;\n}\n\ndescribe(\"TypeChange.ntest\", function() {\n  const cleanup = test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"Hello.grist\", true);\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should not use transform to convert type for an empty column\", async function() {\n    await gu.openSidePane(\"field\");\n    await gu.getCellRC(0, 0).click();\n    await gu.sendKeys([$.ALT, \"=\"]);\n    await gu.waitForServer();\n    gu.sendKeys($.ESCAPE);\n\n    // Click on new column\n    await gu.getCellRC(0, 1).click();\n\n    // Change type\n    await gu.userActionsCollect();\n    await gu.setType(\"Numeric\");\n    await gu.userActionsVerify([[\"UpdateRecord\", \"_grist_Tables_column\", 7, {\"type\": \"Numeric\"}]]);\n\n    // Errors should not be present in converted column\n    assert.isFalse(await gu.getCellRC(0, 1).find(\".field_clip\").hasClass(\"invalid\"));\n\n    // Ensure that column transform is not occurring.\n    assert.isFalse(await $(\".type_transform_prompt\").isPresent());\n  });\n\n  it(\"should use transform to convert type for non-empty columns\", async function() {\n    // Enter text into numeric column\n    await gu.getCellRC(0, 1).click();\n    await gu.sendKeys(\"one\", $.ENTER);\n    await gu.waitForServer();\n    assert.hasClass(await gu.getCellRC(0, 1).find(\".field_clip\"), \"invalid\", true);\n\n    // Change numeric to text\n    await gu.userActionsCollect();\n    await gu.setType(\"Text\");\n\n    // Accept, check that column is text and has no errors\n    await gu.applyTypeConversion();\n    assert.hasClass(await gu.getCellRC(0, 1).find(\".field_clip\"), \"invalid\", false);\n    await gu.userActionsVerify([\n      [\"AddColumn\", \"Table1\", \"gristHelper_Converted\", { type: \"Any\" }],\n      [\"AddColumn\", \"Table1\", \"gristHelper_Transform\", { type: \"Any\" }],\n      [\"ModifyColumn\", \"Table1\", \"gristHelper_Converted\", {\n        \"formula\": \"\",\n        \"isFormula\": false,\n        \"type\": \"Text\",\n        \"visibleCol\": 0\n      }],\n      [\"ModifyColumn\", \"Table1\", \"gristHelper_Transform\", {\n        \"formula\": \"rec.gristHelper_Converted\",\n        \"isFormula\": true,\n        \"type\": \"Text\",\n        \"visibleCol\": 0\n      }],\n      [\"ConvertFromColumn\", \"Table1\", \"F\", \"gristHelper_Converted\", \"Text\", \"\", 0],\n\n      // Repeated conversion just before applying\n      [\"ConvertFromColumn\", \"Table1\", \"F\", \"gristHelper_Converted\", \"Text\", \"\", 0],\n\n      [\"CopyFromColumn\", \"Table1\", \"gristHelper_Transform\", \"F\",\n        \"{\\\"widget\\\":\\\"TextBox\\\",\\\"alignment\\\":\\\"left\\\"}\"],\n      [\"RemoveColumn\", \"Table1\", \"gristHelper_Transform\"],\n      [\"RemoveColumn\", \"Table1\", \"gristHelper_Converted\"],\n    ]);\n\n    // Check that selected reads text\n    await gu.assertType(\"Text\");\n  });\n\n  it(\"should allow cancelling type changes\", async function() {\n    // Enter bools into text column\n    await gu.getCellRC(0, 1).click();\n    await gu.sendKeys(\"false\", $.ENTER);\n    await gu.getCellRC(1, 1).click();\n    await gu.sendKeys(\"true\", $.ENTER);\n\n    // Change text to bool\n    await gu.setType(\"Toggle\");\n\n    // Check that column appears bool during transform\n    assert.isDisplayed(await gu.getCellRC(1, 1).find(\".widget_checkmark\").wait(), true);\n    assert.isDisplayed(await gu.getCellRC(0, 1).find(\".widget_checkmark\"), false);\n    assert.hasClass(await gu.getCellRC(0, 1).find(\".field_clip\"), \"invalid\", false);\n\n    // Cancel transform, check that column is still text\n    await $(\".test-type-transform-cancel\").wait().click();\n    assert.equal(await gu.getCellRC(0, 1).find(\".field_clip\").text(), \"false\");\n\n    // Check that selected reads text\n    await gu.assertType(\"Text\");\n  });\n\n  it(\"should allow revising type changes\", async function() {\n    // Change text to integer\n    await gu.setType(\"Integer\");\n\n    // Revise formula to get text length and accept\n    await $(\".test-type-transform-revise\").wait().click();\n    await $(\".test-type-transform-formula\").click();\n    await gu.waitAppFocus(false);\n    await gu.sendKeys($.SELECT_ALL, $.DELETE, \"return len($F) + 1\");\n\n    // Check that updating the type conversion works\n    await $(\".test-type-transform-update\").click();\n    await gu.waitForServer();\n    assert.equal(await gu.getCellRC(0, 1).find(\".field_clip\").text(), \"6\");\n\n    // Check that applying the type conversion without first updating works\n    // (the weird formula keeps other tests consistent with past behaviour)\n    await $(\".test-type-transform-formula\").click();\n    await gu.waitAppFocus(false);\n    await gu.sendKeys($.SELECT_ALL, $.DELETE, 'return len($F.replace(\"0\", \"0.0\"))');\n    await gu.waitForServer();\n    await gu.applyTypeConversion();\n\n    // Check that column is integer and has no errors\n    assert.equal(await gu.getCellRC(0, 1).find(\".field_clip\").text(), \"5\");\n    assert.isFalse(await gu.getCellRC(0, 1).find(\".field_clip\").hasClass(\"invalid\"));\n  });\n\n  it(\"should allow configuring reference changes\", async function() {\n    // Prepare new table and section\n    await gu.actions.addNewSection(\"New\", \"Table\");\n    await gu.waitForServer();\n    await $(\".test-viewlayout-section-6\").click();\n    await gu.addRecord([\"green\"]);\n    await gu.addRecord([\"blue\"]);\n\n    // Change type to reference column\n    await gu.actions.viewSection(\"Table1\").selectSection();\n    await gu.getCellRC(0, 3).click();\n    await gu.waitAppFocus();\n    await gu.sendKeys(\"blue\", $.ENTER);\n    await gu.getCellRC(1, 3).click();\n    await gu.sendKeys(\"green\", $.ENTER);\n    await gu.waitForServer();\n    await gu.userActionsCollect();\n    await gu.setType(\"Reference\");\n\n    // Assert the correct column is selected and that the formula matches the selected\n    assert.equal(await $(\".test-fbuilder-ref-table-select .test-select-row\").getText(), \"Table2\");\n    assert.equal(await $(\".test-fbuilder-ref-col-select .test-select-row\").getText(), \"A\");\n    await $(\".test-type-transform-revise\").click();\n    var aceText = await gu.getAceText($(\".test-type-transform-formula\").elem());\n    assert.equal(aceText, \"rec.gristHelper_Converted\");\n\n    // Apply transform and check that field is a reference\n    await gu.applyTypeConversion();\n    await gu.userActionsVerify([\n      [\"AddColumn\", \"Table1\", \"gristHelper_Converted\", { type: \"Any\" }],\n      [\"AddColumn\", \"Table1\", \"gristHelper_Transform\", { type: \"Any\" }],\n      [\"ModifyColumn\", \"Table1\", \"gristHelper_Converted\", {\n        \"formula\": \"\",\n        \"isFormula\": false,\n        \"type\": \"Ref:Table2\",\n        \"visibleCol\": 9\n      }],\n      [\"ModifyColumn\", \"Table1\", \"gristHelper_Transform\", {\n        \"formula\": \"rec.gristHelper_Converted\",\n        \"isFormula\": true,\n        \"type\": \"Ref:Table2\",\n        \"visibleCol\": 9\n      }],\n      [\"ConvertFromColumn\", \"Table1\", \"C\", \"gristHelper_Converted\", \"Ref:Table2\", \"\", 9],\n      // Set display formula for transform column.\n      [\"SetDisplayFormula\", \"Table1\", null, 13, \"$gristHelper_Transform.A\"],\n\n      // Repeated conversion just before applying\n      [\"ConvertFromColumn\", \"Table1\", \"C\", \"gristHelper_Converted\", \"Ref:Table2\", \"\", 9],\n\n      [\"CopyFromColumn\", \"Table1\", \"gristHelper_Transform\", \"C\", \"{\\\"widget\\\":\\\"Reference\\\",\\\"alignment\\\":\\\"left\\\"}\"],\n      // We used to unset field display formula, but we don't actually use it during transforms.\n      [\"RemoveColumn\", \"Table1\", \"gristHelper_Transform\"],\n      [\"RemoveColumn\", \"Table1\", \"gristHelper_Converted\"],\n    ]);\n\n    assert.hasClass(await gu.getCellRC(0, 3).find(\".field_clip div\"), \"test-ref-link-icon\");\n\n    // Check conversion back to text\n    await gu.setType(\"Text\");\n    await $(\".test-type-transform-revise\").click();\n    aceText = await gu.getAceText($(\".test-type-transform-formula\").elem());\n    assert.equal(aceText, \"rec.gristHelper_Converted\");\n    await gu.applyTypeConversion();\n    assert.equal(await gu.getCellRC(0, 3).find(\".field_clip\").getText(), \"blue\");\n  });\n\n  it(\"should allow configuring date and datetime changes\", async function() {\n    await gu.toggleSidePanel(\"left\", \"close\");\n    await gu.getCellRC(0, 2).scrollIntoView({inline: \"end\"}).click();\n    await gu.sendKeys(\"4/2/93\", $.ENTER);\n    await gu.getCellRC(1, 2).click();\n    await gu.sendKeys(\"4/26/16\", $.ENTER);\n\n    // Convert to Date\n    await gu.setType(\"Date\");\n    // Guessed date format M/D/YY\n    assert.equal(await gu.dateFormat(), \"Custom\");\n    assert.equal(await $(\"$Widget_dateCustomFormat input\").val(), \"M/D/YY\");\n    // Change manually to a more formal date format\n    await gu.dateFormat(\"MM/DD/YYYY\");\n    assert.equal(await gu.dateFormat(), \"MM/DD/YYYY\");\n    await gu.waitForServer();\n    // Check formula\n    await $(\".test-type-transform-revise\").wait().click();\n    var aceText = await gu.getAceText($(\".test-type-transform-formula\").elem());\n    assert.equal(aceText, \"rec.gristHelper_Converted\");\n\n    // Apply transform and check that field has correct value\n    await gu.applyTypeConversion();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [\n      \"04/02/1993\", \"04/26/2016\"\n    ]);\n\n    // Convert back to text\n    await gu.setType(\"Text\");\n    await gu.applyTypeConversion();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [\n      \"04/02/1993\", \"04/26/2016\"\n    ]);\n\n    await gu.getCellRC(0, 2).click();\n    await gu.sendKeys($.ENTER, \" 12:00am\");\n    await gu.getCellRC(1, 2).click();\n    await gu.sendKeys($.ENTER, \" 4:00am\", $.ENTER);\n\n    // Convert to DateTime and assert formula matches options\n    await gu.setType(\"DateTime\");\n    await $(\".test-tz-autocomplete\").click();\n    await gu.sendKeys(\"Los_Ang\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await $(\".test-tz-autocomplete input\").val(), \"America/Los_Angeles\");\n    assert.equal(await gu.dateFormat(), \"MM/DD/YYYY\");\n    assert.equal(await gu.timeFormat(), \"h:mma\");\n    await $(\".test-type-transform-revise\").click();\n    aceText = await gu.getAceText($(\".test-type-transform-formula\").elem());\n    assert.equal(aceText, \"rec.gristHelper_Converted\");\n\n    // Apply transform and check that field has correct value\n    await gu.applyTypeConversion();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [\n      \"04/02/1993 12:00am\", \"04/26/2016 4:00am\",\n    ]);\n    assert.equal(await $(\".test-tz-autocomplete input\").val(), \"America/Los_Angeles\");\n\n    // Convert DateTime to Date and check that we are getting the right date.\n    await gu.setType(\"Date\");\n    await gu.applyTypeConversion();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [\n      \"04/02/1993\", \"04/26/2016\"\n    ]);\n\n    // Convert Date to DateTime and check that we are getting midnight in selected timezone.\n    await gu.setType(\"DateTime\");\n    await gu.timeFormat(\"HH:mm z\");\n    await gu.waitForServer();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [\n      \"04/02/1993 00:00 EST\", \"04/26/2016 00:00 EDT\"\n    ]);\n    await $(\".test-tz-autocomplete\").click();\n    await gu.sendKeys(\"Los_Ang\", $.ENTER);\n    await gu.waitForServer();\n    assert.equal(await $(\".test-tz-autocomplete input\").val(), \"America/Los_Angeles\");\n    assert.equal(await gu.dateFormat(), \"MM/DD/YYYY\");\n    assert.equal(await gu.timeFormat(), \"HH:mm z\");\n    await gu.waitForServer();\n    await gu.applyTypeConversion();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2], cols: [2], mapper: valText}), [\n      \"04/02/1993 00:00 PST\", \"04/26/2016 00:00 PDT\"\n    ]);\n  });\n\n  it(\"should trigger a transform when reference table is changed\", async function() {\n    // Set up conditions for the test\n    await gu.actions.viewSection(\"Table1\").selectSection();\n    await gu.enterGridValues(2, 3, [[\"red\", \"yellow\"]]);\n    await gu.actions.addNewSection(\"New\", \"Table\");\n    await gu.actions.viewSection(\"TABLE3\").selectSection();\n    await gu.enterGridValues(0, 1, [[\"yellow\", \"red\", \"green\", \"blue\"]]);\n    await gu.actions.viewSection(\"Table1\").selectSection();\n    await gu.clickCellRC(0, 3);\n    await gu.openSidePane(\"field\");\n    await gu.setType(\"Reference\");\n    await gu.setRefTable(\"Table2\");\n    await gu.waitForServer();\n    await gu.setVisibleCol(\"A\");\n    await gu.applyTypeConversion();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3], mapper: valText}), [\n      \"blue\", \"green\", \"!red\", \"!yellow\"\n    ]);\n\n    // Check that row ids shows 2, 1, (AltText), (AltText)\n    await gu.setVisibleCol(\"Row ID\");\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}),\n      [\"Table2[2]\", \"Table2[1]\", \"red\", \"yellow\"]);\n    await gu.setVisibleCol(\"A\");\n\n    // Should trigger the transform\n    await gu.setRefTable(\"Table3\");\n    await gu.waitForServer();\n    await gu.setVisibleCol(\"B\");\n    await assert.isPresent($(\".type_transform_prompt\"));\n\n    // Transform should follow the format Ref:<oldTable> -> Text -> Ref:<newTable>\n    await gu.applyTypeConversion();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}),\n      [\"blue\", \"green\", \"red\", \"yellow\"]);\n    // Check that the cells are no longer invalid.\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3], mapper: valText}), [\n      \"blue\", \"green\", \"red\", \"yellow\"\n    ]);\n\n    // Check that row ids have changed, despite text remaining the same.\n    await gu.setVisibleCol(\"Row ID\");\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}),\n      [\"Table3[4]\", \"Table3[3]\", \"Table3[2]\", \"Table3[1]\"]);\n  });\n\n  it(\"should allow undoing a reference transform in one step\", async function() {\n    await gu.setVisibleCol(\"B\");\n    await gu.setType(\"Text\");\n    await gu.applyTypeConversion();\n    await gu.setType(\"Reference\");\n    await gu.applyTypeConversion();\n    await gu.undo();\n    // Undoing once should return the column to Text with the correct values.\n    await gu.assertType(\"Text\");\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}),\n      [\"blue\", \"green\", \"red\", \"yellow\"]);\n  });\n\n  it(\"should cancel an in-progress transformation on undo\", async function() {\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}),\n      [\"blue\", \"green\", \"red\", \"yellow\"]);\n    await gu.setType(\"Reference\");\n    await gu.assertType(\"Reference\");\n    await assert.isPresent($(\".test-type-transform-top\"), true);\n    await gu.undo();\n    await assert.isPresent($(\".test-type-transform-top\"), false);\n    await gu.assertType(\"Text\");\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [3]}),\n      [\"blue\", \"green\", \"red\", \"yellow\"]);\n  });\n\n  // NOTE: This tests a bug fix where integer values not present in the reference\n  //  column were mistaken for row ids and converted to row values instead of AltText values.\n  it(\"should properly convert from integer to reference\", async function() {\n    // Set up conditions for the test\n    await gu.actions.viewSection(\"TABLE3\").selectSection();\n    await gu.enterGridValues(0, 2, [[\"3\", \"3\", \"4\", \"1\"]]);\n    await gu.waitForServer();\n    await gu.setType(\"Integer\");\n    await gu.applyTypeConversion();\n\n    // Begin convert to reference.\n    await gu.setType(\"Reference\");\n    await gu.assertType(\"Reference\");\n    await assert.isPresent($(\".test-type-transform-top\"), true);\n\n    // Convert to a reference and check that the values are valid and as expected\n    // before and after the conversion. The last row should be invalid since there\n    // is no matching record in the destination col.\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [\n      \"3\", \"3\", \"4\", \"!1\"\n    ]);\n    await gu.applyTypeConversion();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [\n      \"3\", \"3\", \"4\", \"!1\"\n    ]);\n  });\n\n  // NOTE: This tests a bug fix where reference transforms to numeric types gave\n  //  error values by default.\n  it(\"should properly convert from reference to integer/numeric\", async function() {\n    await gu.clickCellRC(0, 2);\n\n    // Convert to an integer and check that the values are valid and as expected before\n    // and after the conversion. This ensures that AltText values can be cast back into ints.\n    await gu.setType(\"Integer\");\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [\n      \"3\", \"3\", \"4\", \"1\"\n    ]);\n    await gu.applyTypeConversion();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [\n      \"3\", \"3\", \"4\", \"1\"\n    ]);\n\n    // Switch back to a reference column\n    await gu.setType(\"Reference\");\n    await gu.assertType(\"Reference\");\n    await assert.isPresent($(\".test-type-transform-top\"), true);\n    await gu.applyTypeConversion();\n\n    // Convert to numeric and check the values are valid and as expected.\n    // This ensures that AltText values can be cast back into floats.\n    await gu.setType(\"Numeric\");\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [\n      \"3\", \"3\", \"4\", \"1\"\n    ]);\n    await gu.applyTypeConversion();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4], cols: [2], mapper: valText}), [\n      \"3\", \"3\", \"4\", \"1\"\n    ]);\n  });\n\n  // NOTE: This tests a bug fix where numeric types were not properly converted to\n  //  boolean values.\n  it(\"should properly convert from integer/numeric to boolean\", async function() {\n    // Update the Numeric column to include some falsy/truthy numbers and alttext.\n    await gu.clickCellRC(0, 2);\n    await gu.sendKeys(\"0\");\n    await gu.clickCellRC(4, 2);\n    await gu.sendKeys(\"False\", $.ENTER);\n    await gu.waitForServer();\n    await gu.sendKeys(\"true\", $.ENTER);\n    await gu.waitForServer();\n\n    // Assert that the values are set up properly.\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6], cols: [2], mapper: valText}), [\n      \"0\", \"3\", \"4\", \"1\", \"!False\", \"!true\"\n    ]);\n\n    // Convert the column to boolean. Assert all the values are valid and as expected.\n    await gu.setType(\"Toggle\");\n    await gu.applyTypeConversion();\n    await gu.setWidget(\"TextBox\");\n\n    // Check that the values have transformed without errors, and are as expected.\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6], cols: [2], mapper: valText}), [\n      \"false\", \"!3\", \"!4\", \"true\", \"false\", \"true\"\n    ]);\n    // Check that sorting by the column has the expected effect.\n    await gu.openColumnMenu(\"C\");\n    await $(`.grist-floating-menu .test-sort-asc`).click();\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6], cols: [2], mapper: valText}), [\n      \"false\", \"false\", \"true\", \"true\", \"!3\", \"!4\"\n    ]);\n\n    // Undo the widget option and type conversion and assert that the values are properly restored.\n    // (but still sorted)\n    await gu.undo(2);\n    assert.deepEqual(await gu.getGridValues({rowNums: [1, 2, 3, 4, 5, 6], cols: [2], mapper: valText}), [\n      \"0\", \"1\", \"3\", \"4\", \"!False\", \"!true\"\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/UndoJumps.ntest.js",
    "content": "import { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\nimport { withoutSandboxing } from \"test/server/testUtils\";\n\ndescribe(\"UndoJumps.ntest\", function() {\n  withoutSandboxing();\n\n  const cleanup = test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.useFixtureDoc(cleanup, \"WorldUndo.grist\", true);\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  async function clickCellAndCheck(pos, text) {\n    let cell = gu.getCell(pos);\n    await cell.click();\n    assert.equal(await cell.text(), text);\n  }\n\n  async function getSectionAndCursor() {\n    let section = await $(\".active_section .test-viewsection-title\").wait().text();\n    let cursorPos = await gu.getCursorPosition();\n    let text = await gu.getActiveCell().text();\n    return Object.assign({section, text}, cursorPos);\n  }\n\n  function beforePos(pos) { return Object.assign({}, pos, {text: pos.text[0]}); }\n  function afterPos(pos) { return Object.assign({}, pos, {text: pos.text[1]}); }\n\n  let positions = [];\n\n  it(\"test setup\", async function() {\n    // In this pseudo-testcase, we do a bunch of actions, whose undo will require jumping around.\n    // We store expected positions along the way, to make it easier to know what to expect.\n    this.timeout(Math.max(this.timeout(), 20000));    // Long-running test, unfortunately\n\n    // In City view, record section, change a cell on screen (row 3, col 1).\n    await gu.actions.selectTabView(\"City\");\n    positions.push({section: \"CITY\", rowNum: 3, col: 0, text: [\"Aalborg\", \"rec-update1\"]});\n    await clickCellAndCheck({section: \"City\", rowNum: 3, col: 0}, \"Aalborg\");\n    await gu.sendKeys(\"rec-update1\", $.ENTER);\n    await gu.waitForServer();\n\n    // Then scroll and change another cell off screen, near the bottom (row 4070, col 3).\n    await gu.sendKeys([$.MOD, $.DOWN]);\n    positions.push({section: \"CITY\", rowNum: 4070, col: 2, text: [\"Çorum\", \"upd-2\"]});\n    await clickCellAndCheck({section: \"CITY\", rowNum: 4070, col: 2}, \"Çorum\");\n    await gu.sendKeys(\"upd-2\", $.ENTER);\n    await gu.waitForServer();\n\n    // In City view, detail section, change a cell too (row 20, col 'Name').\n    await gu.actions.viewSection(\"CITY Card List\").selectSection();\n    await gu.sendKeys([$.MOD, $.UP]);\n    await gu.sendKeys([$.MOD, \"F\"], \"bogotá\", $.ESCAPE);\n    // discard notification\n    await $(\".test-notifier-toast-close\").wait(100).click();\n    positions.push({section: \"CITY Card List\", rowNum: 20, col: \"Name\",\n      text: [\"Santafé de Bogotá\", \"det-update\"]});\n    let cell = gu.getDetailCell(\"Name\", 20);\n    await cell.click();\n    assert.equal(await cell.text(), \"Santafé de Bogotá\");\n    await gu.sendKeys(\"det-update\", $.ENTER);\n    await gu.waitForServer();\n\n    // Switch to different view (Country), scroll to bottom and add a record (row 240, col 2).\n    await gu.actions.selectTabView(\"Country\");\n    await gu.sendKeys([$.MOD, $.DOWN], $.RIGHT);\n    positions.push({section: \"COUNTRY\", rowNum: 240, col: 1, text: [\"\", \"country-add\"]});\n    await gu.sendKeys(\"country-add\", $.ENTER);\n    await gu.waitForServer();\n\n    // Switch back to City view, and add a record by inserting before row 10.\n    await gu.actions.selectTabView(\"City\");\n    await gu.actions.viewSection(\"City\").selectSection();\n    await gu.sendKeys([$.MOD, $.UP]);\n    positions.push({section: \"CITY\", rowNum: 10, col: 1, text: [\"United Kingdom\", \"\"]});\n    await gu.clickCell({section: \"City\", rowNum: 10, col: 1});\n    await gu.sendKeys([$.MOD, $.SHIFT, $.ENTER]);\n    await gu.waitForServer();\n\n    // Switch back to Country view, delete a record (row 6)\n    await gu.actions.selectTabView(\"Country\");\n    await gu.sendKeys([$.MOD, $.UP]);\n    positions.push({section: \"COUNTRY\", rowNum: 5, col: 1, text: [\"Albania\", \"Andorra\"]});\n    await clickCellAndCheck({section: \"Country\", rowNum: 5, col: 1}, \"Albania\");\n    await gu.sendKeys([$.MOD, $.DELETE]);\n    await gu.confirm(true, true); // confirm and remember\n    await gu.waitForServer();\n\n    // Switch back to City view, place cursor onto (row 8, col 'District'), delete column.\n    await gu.actions.selectTabView(\"City\");\n    await gu.sendKeys([$.MOD, $.UP]);\n    positions.push({section: \"CITY\", rowNum: 7, col: 2, text: [\"Hakassia\", \"169200\"]});\n    await clickCellAndCheck({section: \"City\", rowNum: 7, col: 2}, \"Hakassia\");\n    await gu.sendKeys([$.ALT, \"-\"]);\n    await gu.waitForServer();\n\n    // Switch to Country view, and add a column.\n    await gu.actions.selectTabView(\"Country\");\n    positions.push({section: \"COUNTRY\", rowNum: 4, col: 2, text: [\"North America\", \"\"]});\n    await clickCellAndCheck({section: \"Country\", rowNum: 4, col: 2}, \"North America\");\n    await gu.sendKeys([$.ALT, $.SHIFT, \"=\"]);\n    await gu.waitForServer();\n    await gu.sendKeys($.ENTER);\n  });\n\n  async function check_undos() {\n    // Initial position, at the end of the setup (on a newly-added column).\n    assert.deepEqual(await getSectionAndCursor(),\n      {section: \"COUNTRY\", rowNum: 4, col: 2, text: \"\"});\n\n    // Move to a different place.\n    await gu.clickCell({section: \"CountryLanguage\", rowNum: 1, col: \"Percentage\"});\n\n    // Now call undo repeatedly, comparing positions recorded in the `positions` list.\n    for (let i = positions.length - 1; i >= 0; i--) {\n      await gu.undo();\n      assert.deepEqual(await getSectionAndCursor(), beforePos(positions[i]),\n        `Undo position #${i} doesn't match`);\n    }\n\n    // Just to make sure these checks actually ran, verify where we are.\n    assert.deepEqual(await getSectionAndCursor(),\n      {section: \"CITY\", rowNum: 3, col: 0, text: \"Aalborg\"});\n    assert.equal(positions.length, 8);\n  }\n\n  it(\"should jump to position of last action on undo\", async function() {\n    // Undo each action, verifying cursor position each time.\n    await check_undos();\n  });\n\n  it(\"should jump to position of last action on redo\", async function() {\n    // Redo each action, verifying cursor position each time.\n\n    // Move to a different view/place.\n    await gu.actions.selectTabView(\"Country\");\n    await gu.clickCell({section: \"Country\", rowNum: 239, col: \"Name\"});\n    await gu.clickCell({section: \"CountryLanguage\", rowNum: 1, col: \"Percentage\"});\n\n    // Now call redo repeatedly, verifying recorded positions.\n    for (let i = 0; i < positions.length; i++) {\n      await gu.redo();\n      assert.deepEqual(await getSectionAndCursor(), afterPos(positions[i]),\n        `Redo position #${i} doesn't match`);\n    }\n\n    // To make sure checks ran, verify where we are.\n    assert.deepEqual(await getSectionAndCursor(),\n      {section: \"COUNTRY\", rowNum: 4, col: 2, text: \"\"});\n    assert.equal(positions.length, 8);\n  });\n\n  it(\"should jump again on second undo after redo\", async function() {\n    // Undo again, it should work the same way.\n    await check_undos();\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/UploadLimits.ts",
    "content": "/**\n * Test of the importing logic in the DocMenu page.\n */\n\nimport { SQLiteDB } from \"app/server/lib/SQLiteDB\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { copyFixtureDoc } from \"test/server/testUtils\";\n\nimport * as fs from \"fs\";\nimport * as util from \"util\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\nimport * as tmp from \"tmp-promise\";\n\nconst write = util.promisify(fs.write);\n\ndescribe(\"UploadLimits\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  const cleanupCbs: (() => void)[] = [];\n\n  async function generateFile(postfix: string, size: number): Promise<string> {\n    const obj = await tmp.file({ postfix, mode: 0o644 });\n    await write(obj.fd, Buffer.alloc(size, \"t\"));\n    cleanupCbs.push(obj.cleanup);\n    return obj.path;\n  }\n\n  // Create a valid Grist file of at least the desired length.  The file may be\n  // slightly larger than requested.\n  async function generateGristFile(minSize: number): Promise<string> {\n    const obj = await tmp.file({ postfix: \".grist\", mode: 0o644 });\n    await copyFixtureDoc(\"Hello.grist\", obj.path);\n    const size = fs.statSync(obj.path).size;\n    const db = await SQLiteDB.openDBRaw(obj.path);\n    // Make a string that is long enough to push the doc over the required size.\n    const longString = \"x\".repeat(Math.max(1, minSize - size));\n    // Add the string somewhere in the doc.  For now we place it in a separate\n    // table - this may eventually become invalid, but it works for now.\n    // There'll be a little overhead so we'll overshoot the target length a bit,\n    // but that's fine.\n    await db.exec(\"CREATE TABLE _gristsys_extra(txt)\");\n    await db.run(\"INSERT INTO _gristsys_extra(txt) VALUES(?)\", [longString]);\n    await db.close();\n    const size2 = fs.statSync(obj.path).size;\n    if (size2 < minSize || size2 > minSize * 1.2) {\n      throw new Error(`generateGristFile size is off, wanted ${minSize}, got ${size2}`);\n    }\n    cleanupCbs.push(obj.cleanup);\n    return obj.path;\n  }\n\n  after(function() {\n    for (const cleanup of cleanupCbs) {\n      cleanup();\n    }\n  });\n\n  afterEach(async function() {\n    await gu.checkForErrors();\n  });\n\n  const maxImport = 1024 * 1024;            // See GRIST_MAX_UPLOAD_IMPORT_MB = 1 in testServer.ts\n  const maxAttachment = 2 * 1024 * 1024;    // See GRIST_MAX_UPLOAD_ATTACHMENT_MB = 2 in testServer.ts\n\n  it(\"should prevent large uploads for imports\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.loadDocMenu(\"/\");\n\n    // Generate and upload a large csv file. It should by blocked on the client side.\n    const largeFilePath = await generateFile(\".csv\", maxImport + 1000);\n    await gu.docMenuImport(largeFilePath);\n\n    // Ensure an error is shown.\n    assert.match(await driver.findWait(\".test-notifier-toast-message\", 1000).getText(),\n      /Imported files may not exceed 1.0MB/);\n\n    // Now try to import directly to server, and verify that the server enforces this limit too.\n    const p = gu.importFixturesDoc(\"Chimpy\", \"nasa\", \"Horizon\", largeFilePath, { load: false });\n    await assert.isRejected(p, /Payload Too Large/);\n    const err = await p.catch(e => e);\n    assert.equal(err.status, 413);\n    assert.isObject(err.details);\n    assert.match(err.details.userError, /Imported files must not exceed 1.0MB/);\n  });\n\n  it(\"should allow large uploads of .grist docs\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.loadDocMenu(\"/\");\n\n    // Generate and upload a large .grist file. It should not be subject to limits.\n    const largeFilePath = await generateGristFile(maxImport * 2 + 1000);\n    await gu.docMenuImport(largeFilePath);\n\n    await gu.waitForDocToLoad();\n    assert.equal(await gu.getCell(0, 1).getText(), \"hello\");\n  });\n\n  it(\"should prevent large uploads for attachments\", async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempDoc(cleanup, \"Hello.grist\");\n\n    // Clear the first cell.\n    await gu.getCell(0, 1).click();\n    await driver.sendKeys(Key.DELETE);\n    await gu.waitForServer();\n\n    // Change column to Attachments.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    await gu.setType(/Attachment/);\n    await driver.findWait(\".test-type-transform-apply\", 1000).click();\n    await gu.waitForServer();\n\n    // We can upload multiple smaller files (the limit is per-file here).\n    const largeFilePath1 = await generateFile(\".png\", maxAttachment - 1000);\n    const largeFilePath2 = await generateFile(\".jpg\", maxAttachment - 1000);\n    await gu.fileDialogUpload([largeFilePath1, largeFilePath2].join(\",\"),\n      () => gu.getCell(0, 1).find(\".test-attachment-icon\").click());\n    await gu.getCell(0, 1).findWait(\".test-attachment-widget > [class*=test-pw-]\", 2000);\n\n    // We don't expect any errors here.\n    assert.lengthOf(await driver.findAll(\".test-notifier-toast-wrapper\"), 0);\n\n    // Expect to find two attachments in the cell.\n    assert.lengthOf(await gu.getCell(0, 1).findAll(\".test-attachment-widget > [class*=test-pw-]\"), 2);\n\n    // But we can't upload larger files, even one at a time.\n    const largeFilePath3 = await generateFile(\".jpg\", maxAttachment + 1000);\n    await gu.fileDialogUpload(largeFilePath3,\n      () => gu.getCell(0, 2).find(\".test-attachment-icon\").click());\n    await driver.sleep(200);\n    await gu.waitForServer();\n\n    // Check that there is a warning and the cell hasn't changed.\n    assert.match(await driver.findWait(\".test-notifier-toast-message\", 1000).getText(),\n      /Attachments may not exceed 2.0MB/);\n    assert.lengthOf(await gu.getCell(0, 2).findAll(\".test-attachment-widget > [class*=test-pw-]\"), 0);\n\n    // TODO We should try to add attachment via API and verify that the server enforces the limit\n    // too, but at the moment we don't have an endpoint to add attachments via the API.\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/UserManager.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport * as path from \"path\";\n\nimport { fromPairs } from \"lodash\";\nimport { assert, driver, Key, stackWrapFunc, WebElementPromise } from \"mocha-webdriver\";\n\nconst { editOrgAcls, saveAcls } = gu;\n\n// Opens the doc acl edit menu for the given workspace.\nconst editWsAcls = stackWrapFunc(async function(wsName: string): Promise<void> {\n  // To prevent a common flakiness problem, wait for a potentially open modal dialog\n  // to close before attempting to open the account menu.\n  await driver.wait(async () => !(await driver.find(\".test-modal-dialog\").isPresent()), 3000);\n  await gu.openWsDropdown(wsName);\n  await driver.findWait(\".test-dm-workspace-access\", 1000).click();\n  await driver.wait(() => driver.find(\".test-um-members\").isPresent(), 3000);\n});\n\n// Opens the doc acl edit menu for the given doc.\nconst editDocAcls = stackWrapFunc(async function(docName: string): Promise<void> {\n  // To prevent a common flakiness problem, wait for a potentially open modal dialog\n  // to close before attempting to open the account menu.\n  await driver.wait(async () => !(await driver.find(\".test-modal-dialog\").isPresent()), 3000);\n  await gu.openDocDropdown(docName);\n  await driver.findWait(\".test-dm-doc-access\", 1000).click();\n  await driver.wait(() => driver.find(\".test-um-members\").isPresent(), 3000);\n});\n\n// Opens the doc acl edit menu for the currently open doc via the Share menu.\nconst editDocAclsShareMenu = stackWrapFunc(async function(): Promise<void> {\n  await driver.findWait(\".test-tb-share\", 2000).doClick();\n  await driver.findContentWait(\".test-tb-share-option\", /(Access Details)|(Manage Users)/, 1000).doClick();\n  await driver.wait(() => driver.find(\".test-um-members\").isPresent(), 3000);\n});\n\n// Asserts that the user's role in the open acl edit menu is as given. If the given roleLabel is undefined,\n// asserts that the user is not shown in the menu or was deleted.\nasync function assertRole(name: string, roleLabel: string | null, inherited: boolean | null) {\n  const row = driver.findContent(\".test-um-member\", new RegExp(`${name}@getgrist.com`));\n  if (!roleLabel) {\n    assert.isTrue(!(await row.isPresent()) || await row.find(\".test-um-member-undo\").isPresent());\n  } else {\n    assert.equal(await row.find(`.test-um-member-role`).getText(), roleLabel);\n    if (inherited !== null) {\n      await row.find(`.test-um-member-role`).click();\n      await gu.findOpenMenu();\n      const text = await driver.findContent(\".test-um-role-option\", roleLabel).getText();\n      assert.equal(text, inherited ? `${roleLabel} (inherited)` : roleLabel);\n      // Click away to close the menu\n      await driver.find(`.test-um-members`).click();\n    }\n  }\n}\n\n// Asserts the current user's own access details in the Access Details dialog.\nconst assertAccessDetails = stackWrapFunc(async function(name: string, role: string, annotation?: string) {\n  const row = driver.find(\".test-um-member\");\n  assert.equal(await row.find(\".test-um-member-name\").getText(), name);\n  assert.equal(await row.find(\".test-um-member-role\").getText(), role);\n  if (annotation) {\n    assert.equal(await row.find(\".test-um-member-annotation\").getText(), annotation);\n  } else {\n    assert.isFalse(await row.find(\".test-um-member-annotation\").isPresent());\n  }\n});\n\n// Asserts that the maxInheritedRole dropdown in the open acl edit menu is as given.\nconst assertMaxInheritedRole = stackWrapFunc(async function(inheritLabel: string): Promise<void> {\n  assert.equal(await driver.find(`.test-um-max-inherited-role`).getText(), inheritLabel);\n});\n\ndescribe(\"UserManager\", function() {\n  let envSnapshot: EnvironmentSnapshot;\n  this.timeout(20000);\n  gu.bigScreen();\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    envSnapshot = new EnvironmentSnapshot();\n\n    // Initially teamSite is created with users user1 (gristoid+chimpy@) and support@ as owners,\n    // but some tests call resetSite(), leaving it owned only by user1. This test assumes that\n    // support@ is among owners, so we ensure that by resetting and adding support@ manually.\n    const session = await gu.session().teamSite.user(\"user1\").login();\n    await session.resetSite();\n    await session.createHomeApi().updateOrgPermissions(session.settings.orgDomain, {\n      users: { \"support@getgrist.com\": \"owners\" },\n    });\n  });\n\n  afterEach(function() {\n    envSnapshot.restore();\n  });\n\n  it(\"allows updating org permissions\", async () => {\n    // Simulate Chimpy login.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n\n    assert.equal(await driver.findContent(\".test-dm-doc-name\", /Anch/).getText(), \"Anchovy\");\n    assert.equal(await driver.findContent(\".test-dm-doc-name\", /Time/).isPresent(), false);\n    assert.equal(await gu.getEmail(), \"chimpy@getgrist.com\");\n\n    // Open the UserManager\n    await editOrgAcls();\n\n    // Public access option should not be included for orgs (except with GRIST_SUPPORT_ANON=true,\n    // which is only set in single-org setup, and not set for these tests).\n    assert.equal(await driver.find(\".test-um-public-access\").isPresent(), false);\n\n    // Open Access Rules should not be shown (a previous bug caused this to always be shown).\n    assert.equal(await driver.find(\".test-um-open-access-rules\").isPresent(), false);\n\n    // Remove Charon, save and check that it persists\n    await driver.findContent(\".test-um-member\", /charon@getgrist.com/).find(\".test-um-member-delete\").click();\n    await saveAcls();\n    await editOrgAcls();\n    const emails = await driver.findAll(\".test-um-member-email\");\n    assert.equal(emails.length, 2);\n    await driver.find(\".test-um-cancel\").click();\n\n    // Check that Charon cannot view Fish.\n    await server.simulateLogin(\"Charon\", \"charon@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await driver.findContentWait(\".test-error-header\", /Access denied/, 3000);\n    assert.match(await driver.find(\".test-error-header\").getText(), /Access denied/);\n\n    // Simulate Chimpy login.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n\n    // Add Charon back as an editor.\n    await editOrgAcls();\n    await driver.find(\".test-um-member-new\").find(\"input\").click();\n    await driver.sendKeys(\"charon@getgrist.com\", Key.ENTER);\n    await driver.findContent(\".test-um-member\", /charon@getgrist.com/).find(\".test-um-member-role\").click();\n    await driver.findContentWait(\".test-um-role-option\", /Editor/, 100).click();\n    await saveAcls();\n\n    // Check that Charon can view Fish, but can't edit org permissions.\n    await server.simulateLogin(\"Charon\", \"charon@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    await driver.find(\".test-user-icon\").click();\n    await canEditAccess(driver.findWait(\".test-dm-org-access\", 1000), false);\n\n    // Charon should be able to remove themselves.\n    await driver.sendKeys(Key.ESCAPE);\n    await editOrgAcls();\n    assert.equal(await driver.find(\".test-um-member-new\").find(\"input\").isPresent(), false);\n    await findMember(\"charon@getgrist.com\").find(\".test-um-member-delete\").click();\n    await saveAcls({ clickRemove: true });\n    await driver.get(`${server.getHost()}/o/fish`);\n    await driver.findContentWait(\".test-error-header\", /Access denied/, 3000);\n\n    // Simulate Chimpy login.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n\n    // Add Charon back as an owner.\n    await editOrgAcls();\n    await driver.find(\".test-um-member-new\").find(\"input\").click();\n    await driver.sendKeys(\"charon@getgrist.com\", Key.ENTER);\n    await driver.findContentWait(\".test-um-member\", /charon@getgrist.com/, 1000).find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContentWait(\".test-um-role-option\", /Owner/, 100).click();\n    await saveAcls();\n\n    // Check that Charon can view Fish and edit org permissions.\n    await server.simulateLogin(\"Charon\", \"charon@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    await driver.find(\".test-user-icon\").click();\n    await canEditAccess(driver.findWait(\".test-dm-org-access\", 1000), true);\n  });\n\n  it(\"allows updating workspace permissions\", async () => {\n    // Simulate Chimpy login and open Small workspace ACL menu.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n\n    // Open workspace options and assert that the initial state is as expected.\n    await editWsAcls(\"Small\");\n    await assertMaxInheritedRole(\"In full\");\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"kiwi\", \"Editor\", true);\n    await assertRole(\"charon\", \"Owner\", true);\n\n    // Public access option should not be included for workspaces (except with GRIST_SUPPORT_ANON=true,\n    // which is only set in single-org setup, and not set for these tests).\n    assert.isFalse(await driver.find(\".test-um-public-access\").isPresent());\n\n    // Open Access Rules should not be shown (a previous bug caused this to always be shown).\n    assert.isFalse(await driver.find(\".test-um-open-access-rules\").isPresent());\n\n    // Lower max inherited role to Viewers and check that roles and inheritance tags are as expected.\n    // The active user Chimpy's role should remain owner.\n    // Kiwi and Charon should become an inherited viewers.\n    await driver.find(\".test-um-max-inherited-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /View only/).click();\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"kiwi\", \"Viewer\", true);\n    await assertRole(\"charon\", \"Viewer\", true);\n\n    // Save new max inherited role and check that it persists.\n    await saveAcls();\n    await editWsAcls(\"Small\");\n    await assertMaxInheritedRole(\"View only\");\n    await driver.find(\".test-um-cancel\").click();\n\n    // Simulate Charon login and assert that Charon can still see the workspace, but cannot\n    // edit it.\n    await server.simulateLogin(\"Charon\", \"charon@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    await gu.openWsDropdown(\"Small\");\n    assert(await driver.find(\".test-dm-rename-workspace\").matches(\".disabled\"));\n    assert(await driver.find(\".test-dm-delete-workspace\").matches(\".disabled\"));\n    await canEditAccess(driver.find(\".test-dm-workspace-access\"), false);\n\n    // Log in with Chimpy once again.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n\n    // Increase Charon's role to above the inherited role and check that the inherited tag is\n    // no longer visible.\n    await editWsAcls(\"Small\");\n    await driver.findContent(\".test-um-member\", /charon@getgrist.com/).find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /Owner/).click();\n    await saveAcls();\n    await editWsAcls(\"Small\");\n    await assertRole(\"chimpy\", \"Owner\", false);\n    await assertRole(\"charon\", \"Owner\", null);\n    await assertRole(\"kiwi\", \"Viewer\", true);\n    await driver.find(\".test-um-cancel\").click();\n\n    // Simulate log in with Charon and assert that Charon can now edit access to the doc.\n    await server.simulateLogin(\"Charon\", \"charon@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    await editWsAcls(\"Small\");\n\n    // Lower max inherited role to none (as Charon) and check that Kiwi's displayed inherited\n    // role has lowered to match. Note that the inherited tag is not displayed on users\n    // with no access. Charon's role should be unchanged.\n    await driver.find(\".test-um-max-inherited-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /None/).click();\n    await assertRole(\"chimpy\", \"Owner\", false);\n    await assertRole(\"kiwi\", null, null);\n    await assertRole(\"charon\", \"Owner\", false);\n\n    // Save new max inherited role and check that it persists.\n    await saveAcls();\n    await editWsAcls(\"Small\");\n    await assertMaxInheritedRole(\"None\");\n    await driver.find(\".test-um-cancel\").click();\n\n    // Simulate log in with Kiwi and assert the workspace is not visible.\n    await server.simulateLogin(\"Kiwi\", \"kiwi@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    assert.equal(await driver.findContent(\".test-dm-workspace\", /Small/).isPresent(), false);\n\n    // Log in with Chimpy again and return roles to the initial state.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    await editWsAcls(\"Small\");\n    await driver.find(\".test-um-max-inherited-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /In full/).click();\n    await driver.findContent(\".test-um-member\", /kiwi@getgrist.com/).find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /Editor/).click();\n\n    // Save roles and assert that it persists.\n    await saveAcls();\n    await editWsAcls(\"Small\");\n    await assertMaxInheritedRole(\"In full\");\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"kiwi\", \"Editor\", true);\n    await assertRole(\"charon\", \"Owner\", true);\n    await driver.find(\".test-um-cancel\").click();\n\n    // Assert that Chimpy's role cannot be edited since Chimpy is the active user.\n    await editWsAcls(\"Small\");\n    const chimpy = driver.findContent(\".test-um-member\", /chimpy@getgrist.com/);\n    await chimpy.find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    assert(await driver.findContent(\".test-um-role-option\", /Editor/).matches(\".disabled\"));\n    assert(await driver.findContent(\".test-um-role-option\", /Viewer/).matches(\".disabled\"));\n    // Click away to close the menu\n    await driver.find(`.test-um-members`).click();\n\n    // Assert that the user cannot delete themselves.\n    await chimpy.find(\".test-um-member-delete\").click();\n    assert.equal(await chimpy.find(\".test-um-member-undo\").isPresent(), false);\n    await assertRole(\"chimpy\", \"Owner\", null);\n\n    // Add a user and assert that as a new member to the workspace they do have a delete button.\n    await driver.find(\".test-um-member-new\").find(\"input\").click();\n    await driver.sendKeys(\"dummy@getgrist.com\", Key.ENTER);\n    const dummy = driver.findContent(\".test-um-member\", /dummy@getgrist.com/);\n    await dummy.find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /Editor/).click();\n    assert(await dummy.find(\".test-um-member-delete\").isPresent());\n    await driver.find(\".test-um-cancel\").click();\n  });\n\n  it(\"allows updating doc permissions\", async () => {\n    // Simulate Chimpy login and open Shark doc ACL menu.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n\n    // Open doc options and assert that the initial state is as expected.\n    await editDocAcls(\"Shark\");\n    await assertMaxInheritedRole(\"In full\");\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"kiwi\", \"Editor\", true);\n    await assertRole(\"charon\", \"Owner\", true);\n\n    // Public access option should be included for orgs (except with GRIST_SUPPORT_ANON=true,\n    // which is only set in single-org setup, and not set for these tests).\n    assert.equal(await driver.find(\".test-um-public-access\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-um-public-access\").getText(), \"Off\");\n\n    // Open Access Rules should not be shown (it's currently only shown if a document is open).\n    assert.isFalse(await driver.find(\".test-um-open-access-rules\").isPresent());\n\n    // Lower max inherited role to Editors and check that roles and inheritance tags are as expected.\n    // The active user Chimpy's role should remain owner but should no longer be inherited.\n    // Kiwi should remain an inherited editor. Charon should lower to an inherited editor.\n    await driver.find(\".test-um-max-inherited-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /View & edit/).click();\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"kiwi\", \"Editor\", true);\n    await assertRole(\"charon\", \"Editor\", true);\n\n    // Save new max inherited role and check that it persists.\n    await saveAcls();\n    await editDocAcls(\"Shark\");\n    await assertMaxInheritedRole(\"View & edit\");\n    await driver.find(\".test-um-cancel\").click();\n\n    // Simulate Charon login and assert that Charon can still edit the doc (but cannot edit\n    // access to the doc).\n    await server.simulateLogin(\"Charon\", \"charon@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    await gu.openDocDropdown(\"Shark\");\n    await canEditAccess(driver.find(\".test-dm-doc-access\"), false);\n\n    // Log in with Chimpy once again.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n\n    // Increase Kiwi's role to above the inherited role and check that the inherited tag is\n    // no longer visible.\n    await editDocAcls(\"Shark\");\n    await driver.findContent(\".test-um-member\", /kiwi@getgrist.com/).find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /Owner/).click();\n    await saveAcls();\n    await editDocAcls(\"Shark\");\n    await assertRole(\"kiwi\", \"Owner\", false);\n    await driver.find(\".test-um-cancel\").click();\n\n    // Simulate log in with Kiwi and assert that Kiwi can now edit access to the doc.\n    await server.simulateLogin(\"Kiwi\", \"kiwi@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    await editDocAcls(\"Shark\");\n\n    // Lower max inherited role to none (as Kiwi) and check that Charon's displayed inherited\n    // role has lowered to match. Note that the inherited tag is not displayed on users\n    // with no access. Kiwi's role should be unchanged.\n    await driver.find(\".test-um-max-inherited-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /None/).click();\n    await assertRole(\"chimpy\", \"Owner\", false);\n    await assertRole(\"kiwi\", \"Owner\", null);\n    await assertRole(\"charon\", null, false);\n\n    // Save new max inherited role and check that it persists.\n    await saveAcls();\n    await editDocAcls(\"Shark\");\n    await assertMaxInheritedRole(\"None\");\n    await driver.find(\".test-um-cancel\").click();\n\n    // Simulate log in with Charon and assert the doc is present, but cannot be visited.\n    await server.simulateLogin(\"Charon\", \"charon@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    assert.equal(await driver.findContent(\".test-dm-doc\", /Shark/).isPresent(), true);\n    await driver.findContent(\".test-dm-doc\", /Shark/).click();\n    assert.match(await driver.findWait(\".test-error-header\", 2000).getText(), /Access denied/);\n    assert.equal(await driver.find(\".test-dm-logo\").isDisplayed(), true);\n\n    // Log in with Chimpy again and return roles to the initial state.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    await editDocAcls(\"Shark\");\n    await driver.find(\".test-um-max-inherited-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContentWait(\".test-um-role-option\", /In full/, 100).click();\n    await driver.findContentWait(\".test-um-member\", /kiwi@getgrist.com/, 100).find(\".test-um-member-role\").click();\n    await driver.findContentWait(\".test-um-role-option\", /Editor/, 100).click();\n\n    // Save roles and assert that it persists.\n    await saveAcls();\n    await editDocAcls(\"Shark\");\n    await assertMaxInheritedRole(\"In full\");\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"kiwi\", \"Editor\", true);\n    await assertRole(\"charon\", \"Owner\", true);\n    await driver.find(\".test-um-cancel\").click();\n\n    // Assert that Chimpy's role cannot be edited since Chimpy is the active user.\n    await editDocAcls(\"Shark\");\n    const chimpy = driver.findContent(\".test-um-member\", /chimpy@getgrist.com/);\n    await chimpy.find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    assert(await driver.findContent(\".test-um-role-option\", /Editor/).matches(\".disabled\"));\n    assert(await driver.findContent(\".test-um-role-option\", /Viewer/).matches(\".disabled\"));\n    // Click away to close the menu\n    await driver.find(`.test-um-members`).click();\n\n    // Assert that the user cannot delete themselves.\n    await chimpy.find(\".test-um-member-delete\").click();\n    assert.equal(await chimpy.find(\".test-um-member-undo\").isPresent(), false);\n    await assertRole(\"chimpy\", \"Owner\", null);\n\n    // Add a user and assert that as a new member to the doc they do have a delete button.\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"dummy@getgrist.com\", Key.ENTER);\n    const dummy = driver.findContent(\".test-um-member\", /dummy@getgrist.com/);\n    await dummy.find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /Editor/).click();\n    assert(await dummy.find(\".test-um-member-delete\").isPresent());\n    await driver.find(\".test-um-cancel\").click();\n  });\n\n  it(\"allows sharing of personal docs\", async () => {\n    // open chimpy's personal org as chimpy\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"docs\");\n    await driver.get(`${server.getHost()}/o/docs`);\n    await gu.waitForDocMenuToLoad();\n    assert(await gu.testCurrentUrl(/\\/o\\/docs/));\n\n    // add zing as a user to a document (personal orgs can't be shared at the org level)\n    await editDocAcls(\"Timesheets\");\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"zing@getgrist.com\", Key.ENTER);\n    const zing = driver.findContent(\".test-um-member\", /zing@getgrist.com/);\n    await zing.find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /Editor/).click();\n    await saveAcls();\n\n    // zing can now access the shared doc in chimpy's personal workspace.\n    await server.simulateLogin(\"zing\", \"zing@getgrist.com\", \"docs\", { isFirstLogin: false });\n    await driver.get(`${server.getHost()}/o/docs`);\n    await gu.waitForDocMenuToLoad();\n    assert.equal(await driver.findContent(\".test-dm-doc-name\", /Time/).getText(), \"Timesheets\");\n  });\n\n  it(\"new user should have a personal org\", async () => {\n    // Simulate Chimpy login.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"fish\");\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n\n    // Add mysterio and mysterio2 as editors of an organization.\n    await editOrgAcls();\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"mysterio@getgrist.com\", Key.ENTER);\n    const mysterio = driver.findContent(\".test-um-member\", /mysterio@getgrist.com/);\n    await mysterio.find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /Editor/).click();\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"mysterio2@getgrist.com\", Key.ENTER);\n    const mysterio2 = driver.findContent(\".test-um-member\", /mysterio@getgrist.com/);\n    await mysterio2.find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /Editor/).click();\n    await saveAcls();\n\n    // Log in as Mr Mysterio for first time, and make sure we have a personal org.\n    await server.simulateLogin(\"Mr Mysterio\", \"mysterio@getgrist.com\", \"docs\", { isFirstLogin: false });\n    await driver.get(`${server.getHost()}/o/docs`);\n    await gu.waitForDocMenuToLoad();\n\n    // Check that our name was picked up when we logged in for the first time.\n    assert.equal(await gu.getName(), \"Mr Mysterio\");\n\n    // Log in as Mysterio2 for first time, without a name, and make sure our name is guessed\n    // from email.\n    await server.simulateLogin(\"\", \"mysterio2@getgrist.com\", \"fish\", { isFirstLogin: false });\n    await driver.get(`${server.getHost()}/o/fish`);\n    await gu.waitForDocMenuToLoad();\n    assert.equal(await gu.getName(), \"mysterio2\");\n  });\n\n  // NOTE: This tests a bug where the client would prevent editing access to orgs with existing\n  // guest users.\n  it(\"should allow adding users to orgs with guests\", async () => {\n    // Simulate Chimpy login.\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    await driver.get(`${server.getHost()}/o/nasa`);\n    await gu.waitForDocMenuToLoad();\n\n    // Add a new user.\n    await editOrgAcls();\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"mario@getgrist.com\", Key.ENTER);\n    const mario = driver.findContent(\".test-um-member\", /mario@getgrist.com/);\n    await mario.find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /Editor/).click();\n    await saveAcls();\n\n    // Check that the users are as expected.\n    await editOrgAcls();\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"mario\", \"Editor\", null);\n    // Charon is only a guest of the org, she should not be visible.\n    await assertRole(\"charon\", null, null);\n    await driver.find(\".test-um-cancel\").click();\n  });\n\n  it(\"should allow reverting removals of saved members\", async function() {\n    await editOrgAcls();\n    // Delete Mario and check that confirm is not disabled since changes have been made.\n    await driver.findContent(\".test-um-member\", /mario@getgrist.com/).find(\".test-um-member-delete\").click();\n    assert.equal(await driver.find(\".test-um-confirm\").getAttribute(\"disabled\"), null);\n    // await assert.isFalse($('.test-um-confirm').prop('disabled'));\n    // Revert the deletion and check that confirm is disabled since changes have not been made.\n    await driver.findContent(\".test-um-member\", /mario@getgrist.com/).find(\".test-um-member-undo\").click();\n    assert.equal(await driver.find(\".test-um-confirm\").getAttribute(\"disabled\"), \"true\");\n    await driver.find(\".test-um-cancel\").click();\n  });\n\n  it(\"should allow adding members via the add email button\", async function() {\n    await editOrgAcls();\n    await driver.find(\".test-um-member-new input\").click();\n    // Check that the button only appears once there is content in the input.\n    assert.equal(await driver.find(\".test-um-add-email\").isPresent(), false);\n    await driver.sendKeys(\"l\");\n    assert.equal(await driver.find(\".test-um-add-email\").isPresent(), true);\n    // Attempt to submit the email when it is invalid.\n    await driver.sendKeys(Key.DOWN, Key.ENTER);\n    // Assert that the value has not been submitted.\n    assert.equal(await driver.find(\".test-um-member-new\").find(\"input\").value(), \"l\");\n    await driver.sendKeys(\"uigi@getgrist.com\");\n    assert.equal(await driver.find(\".test-um-add-email\").isPresent(), true);\n    await driver.sendKeys(Key.DOWN, Key.ENTER);\n    // Assert that the value has been submitted\n    assert.equal(await driver.find(\".test-um-member-new\").find(\"input\").value(), \"\");\n    await driver.find(\".test-um-cancel\").click();\n  });\n\n  // Checks a bug where remove buttons would not be properly disabled for users that inherit access.\n  it(\"should disallow removing members whose access is inherited\", async function() {\n    await editDocAcls(\"Pluto\");\n    // Click the remove button for mario.\n    await driver.findContent(\".test-um-member\", /mario@getgrist.com/).find(\".test-um-member-delete\").click();\n    // Check that mario is not actually removed.\n    assert(await driver.findContent(\".test-um-member\", /mario@getgrist.com/).isPresent());\n    await driver.find(\".test-um-cancel\").click();\n  });\n\n  // Fixes a confusing scenario where existing but invisible users could not be re-added.\n  it(\"should allow re-adding users who are not visible due to inherited access\", async function() {\n    // Open doc options and assert that the initial state is as expected.\n    await editDocAcls(\"Beyond\");\n    await assertMaxInheritedRole(\"In full\");\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"mario\", \"Editor\", true);\n\n    // Lower max inherited role to none and check that mario is no longer visible.\n    await driver.find(\".test-um-max-inherited-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /None/).click();\n    await driver.find(\".test-um-member-new input\").click();\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"mario\", null, false);\n\n    // Re-add mario and check that mario is once again visible as a viewer.\n    await driver.sendKeys(\"mario@getgrist.com\", Key.ENTER);\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"mario\", \"Viewer\", false);\n    await driver.find(\".test-um-cancel\").click();\n\n    // Perform another more complex test where the user is not visible with full inheritance.\n    // Add luigi as a viewer of workspace Horizon\n    await editWsAcls(\"Horizon\");\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"luigi@getgrist.com\", Key.ENTER);\n    await assertRole(\"luigi\", \"Viewer\", false);\n    await saveAcls();\n\n    // Since luigi is a guest of the org, he exists as a no access member of all ws/doc ACLs\n    // Ensure that he can still be re-added to a Rover doc with access.\n    await editDocAcls(\"Apathy\");\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"luigi@getgrist.com\", Key.ENTER);\n    await assertRole(\"luigi\", \"Viewer\", false);\n    await saveAcls();\n\n    // Ensure that luigi can still be re-added to the org, where he is already a guest.\n    await editOrgAcls();\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"luigi@getgrist.com\", Key.ENTER);\n    await assertRole(\"luigi\", \"Viewer\", false);\n    await saveAcls();\n\n    // Remove luigi to revert the state\n    await editOrgAcls();\n    await driver.findContent(\".test-um-member\", /luigi@getgrist.com/).find(\".test-um-member-delete\").click();\n    await saveAcls();\n  });\n\n  // Fixes a small bug where removing would be unnecessarily disabled when maxInheritedRole was None.\n  it(\"should allow removing users when inheritance is set to none\", async function() {\n    // Open org options and add Luigi.\n    await editOrgAcls();\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"luigi@getgrist.com\", Key.ENTER);\n    await assertRole(\"luigi\", \"Viewer\", null);\n    await saveAcls();\n\n    // Open workspace options, lower max inherited role to None and re-add Luigi.\n    await editWsAcls(\"Horizon\");\n    await assertRole(\"luigi\", \"Viewer\", true);\n    await driver.find(\".test-um-max-inherited-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /None/).click();\n    await driver.find(\".test-um-member-new\").find(\"input\").click();\n    await driver.sendKeys(\"luigi@getgrist.com\", Key.ENTER);\n    await assertRole(\"luigi\", \"Viewer\", false);\n\n    // Check that Luigi is removable.\n    await driver.findContent(\".test-um-member\", /luigi@getgrist.com/).find(\".test-um-member-delete\").click();\n    await assertRole(\"luigi\", null, null);\n    await driver.find(\".test-um-cancel\").click();\n\n    // Remove luigi from the org to revert the state\n    await editOrgAcls();\n    await driver.findContent(\".test-um-member\", /luigi@getgrist.com/).find(\".test-um-member-delete\").click();\n    await saveAcls();\n  });\n\n  it(\"should allow adding org members with no default access\", async function() {\n    // Open org options and add Luigi with \"No Default Access\".\n    await editOrgAcls();\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"luigi@getgrist.com\", Key.ENTER);\n    await driver.findContent(\".test-um-member\", /luigi@getgrist.com/).find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /No Default Access/).click();\n    await saveAcls();\n\n    // Open workspace options and check that Luigi may be added.\n    await editWsAcls(\"Horizon\");\n    await driver.find(\".test-um-member-new input\").click();\n    await driver.sendKeys(\"luigi@getgrist.com\", Key.ENTER);\n    await assertRole(\"luigi\", \"Viewer\", false);\n    await saveAcls();\n\n    // Re-open org options and check that Luigi's member role has persisted.\n    await editOrgAcls();\n    await assertRole(\"luigi\", \"No Default Access\", false);\n    await driver.find(\".test-um-cancel\").click();\n\n    // Re-open workspace options and check that Luigi's viewership has persisted.\n    // Remove it to reset test to previous state.\n    await editWsAcls(\"Horizon\");\n    await assertRole(\"luigi\", \"Viewer\", false);\n    await driver.findContent(\".test-um-member\", /luigi@getgrist.com/).find(\".test-um-member-delete\").click();\n    await saveAcls();\n\n    // Remove Luigi from org to reset test to previous state.\n    await editOrgAcls();\n    await assertRole(\"luigi\", \"No Default Access\", false);\n    await driver.findContent(\".test-um-member\", /luigi@getgrist.com/).find(\".test-um-member-delete\").click();\n    await saveAcls();\n  });\n\n  it(\"should show correct role for active user, even when non-owner\", async function() {\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    await driver.get(`${server.getHost()}/o/nasa`);\n    await gu.waitForDocMenuToLoad();\n    await editDocAcls(\"Pluto\");\n    await assertRole(\"chimpy\", \"Owner\", null);\n    await assertRole(\"charon\", \"Viewer\", null);\n    await driver.findContent(\".test-um-member\", /charon/).find(\".test-um-member-role\").click();\n    await gu.findOpenMenu();\n    await driver.findContent(\".test-um-role-option\", /Owner/).click();\n    await saveAcls();\n\n    // Get the docId.\n    const docId = path.basename(await driver.findContent(\".test-dm-doc\", \"Pluto\")\n      .find(\".test-dm-doc-name\").getAttribute(\"href\"));\n\n    // Log in as Charon and load the doc menu.\n    await server.simulateLogin(\"Charon\", \"charon@getgrist.com\", \"nasa\");\n    await driver.get(`${server.getHost()}/o/nasa`);\n    await gu.waitForDocMenuToLoad();\n\n    // Lower Charon's access from outside the page. He can still open the UserManager, even though\n    // he is no longer an owner. That's fine, but check that the UserManager shows correct role.\n    // Update: Charon will now see a very limited UserManager, showing just their own info.\n    const api = gu.createHomeApi(\"Chimpy\", \"nasa\");\n    await api.updateDocPermissions(docId, { users: { \"charon@getgrist.com\": \"viewers\" } });\n\n    await editDocAcls(\"Pluto\");\n    await assertAccessDetails(\"Charon\", \"Viewer\", \"Outside collaborator\");\n\n    // Check title is specialized.\n    assert(await driver.find(\".test-um-header\").getText(), \"Your role for this document\");\n\n    // Check that inheritance information is suppressed.\n    assert.equal(await driver.find(\".test-um-max-inherited-role\").isPresent(), false);\n  });\n\n  it(\"should show correct role for users with public access to doc\", async function() {\n    // Context: A previous bug caused a blank User Manager dialog to be shown.\n    const session = await gu.session().teamSite.login();\n    const api = session.createHomeApi();\n    const { id } = await session.tempDoc(cleanup, \"Hello.grist\");\n    await api.updateDocPermissions(id, { users: {\n      // Make the temp doc public.\n      \"everyone@getgrist.com\": \"viewers\",\n    } });\n\n    // Check that a logged-in user sees an appropriate role in Access Details.\n    const session2 = await gu.session().teamSite.user(\"user2\").login();\n    await session2.loadDoc(`/doc/${id}`);\n    await editDocAclsShareMenu();\n    await assertAccessDetails(session2.name, \"Viewer\", \"Public access\");\n\n    // Check that an anonymous user sees an appropriate role in Access Details.\n    const anon = await gu.session().teamSite.anon.login();\n    await anon.loadDoc(`/doc/${id}`);\n    await editDocAclsShareMenu();\n    await assertAccessDetails(\"Anonymous\", \"Viewer\", \"Public access\");\n\n    // Add a collaborator to the doc and check that the \"Public access\" annotation is\n    // no longer shown.\n    await api.updateDocPermissions(id, { users: { [session2.email]: \"editors\" } });\n    await session2.login();\n    await session2.loadDoc(`/doc/${id}`);\n    await editDocAclsShareMenu();\n    await assertAccessDetails(session2.name, \"Editor\", \"Outside collaborator\");\n\n    // Close the dialog.\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Add a team member to the site and check that the \"Team member\" annotation is shown.\n    await api.updateOrgPermissions(\"current\", { users: { [session2.email]: \"members\" } });\n    await editDocAclsShareMenu();\n    await assertAccessDetails(session2.name, \"Editor\", \"Team member\");\n\n    // Turn off role inheriting in the doc, but leave public access intact.\n    await api.updateDocPermissions(id, {\n      maxInheritedRole: null,\n      users: { [session2.email]: null },\n    });\n\n    // Reload the page, since real access should have changed from Editor to Viewer.\n    await session2.loadDoc(`/doc/${id}`);\n\n    // Check that the team member now sees that they have public access in Access Details.\n    await editDocAclsShareMenu();\n    await assertAccessDetails(session2.name, \"Viewer\", \"Public access\");\n\n    // Finally, disable public access but add the team member to the doc explicitly.\n    await api.updateDocPermissions(id, { users: {\n      [session2.email]: \"editors\",\n      \"everyone@getgrist.com\": null,\n    } });\n\n    // Check that \"Public access\" is no longer shown.\n    await session2.loadDoc(`/doc/${id}`);\n    await editDocAclsShareMenu();\n    await assertAccessDetails(session2.name, \"Editor\", \"Team member\");\n  });\n\n  it(\"can remove user from team even if they have access to a soft-deleted doc\", async function() {\n    // Open a team site\n    const session = await gu.session().teamSite.login();\n    await session.loadDocMenu(\"/\");\n    const api = session.createHomeApi();\n    const users = [\"luigi\", \"mario\", \"silvio\"];\n\n    try {\n      // Open org options and add some users with \"No Default Access\".\n      await editOrgAcls();\n      for (const user of users) {\n        await driver.find(\".test-um-member-new input\").click();\n        await driver.sendKeys(`${user}@getgrist.com`, Key.ENTER);\n        await driver.findContent(\".test-um-member\", new RegExp(`${user}@getgrist.com`))\n          .find(\".test-um-member-role\").click();\n        await gu.findOpenMenu();\n        await driver.findContent(\".test-um-role-option\", /No Default Access/).click();\n      }\n      await saveAcls();\n\n      // Make a document and add some users.\n      const doc = await session.tempDoc(cleanup, \"Hello.grist\", { load: false });\n      await api.updateDocPermissions(doc.id, {\n        users: { \"luigi@getgrist.com\": \"viewers\", \"mario@getgrist.com\": \"owners\" },\n      });\n\n      // Soft-delete the document.\n      await api.softDeleteDoc(doc.id);\n\n      // Remove luigi from team.\n      await editOrgAcls();\n      await driver.findContent(\".test-um-member\", /luigi@getgrist.com/).find(\".test-um-member-delete\").click();\n      await saveAcls();\n\n      // Make sure luigi is removed from doc.\n      const docUsers = (await api.forRemoved().getDocAccess(doc.id)).users.map(u => u.email);\n      assert.includeMembers(docUsers, [\"mario@getgrist.com\"]);\n      assert.notInclude(docUsers, \"luigi@getgrist.com\");\n    } finally {\n      // Remove users we added.\n      await api.updateOrgPermissions(\"current\", {\n        users: fromPairs(users.map(u => [`${u}@getgrist.com`, null])),\n      });\n    }\n  });\n\n  it(`has a link to open Access Rules when managing users from an open doc`, async function() {\n    const session = await gu.session().teamSite.login();\n    await session.loadDocMenu(\"/\");\n\n    // Make a document, and start editing shares.\n    await session.tempDoc(cleanup, \"Hello.grist\", { load: true });\n    await driver.findWait(\".test-tb-share\", 2000).click();\n    await driver.findContentWait(\".test-tb-share-option\", /Manage users/, 1000).click();\n\n    // Check that Open Access Rules is shown.\n    const accessRulesLink = await driver.findWait(\".test-um-open-access-rules\", 2000);\n    assert(accessRulesLink.isPresent());\n\n    // Click it, and check that we are now on the Access Rules page.\n    await accessRulesLink.click();\n    await gu.testCurrentUrl(/^.+\\/p\\/acl$/);\n\n    // These days we see the access rules intro screen (since on this doc there aren't access\n    // rules yet).\n    assert.equal(await driver.findWait(\".test-enable-access-rules\", 1000).isDisplayed(), true);\n  });\n\n  it(\"should give access publicly with a confirmation dialog\", async function() {\n    const driver = gu.currentDriver();\n    // This test currently only works in grist-core because it relies on behavior\n    // unique to the minimal login system, which is the default when deployment type\n    // is core. Specifically, when a redirect to login happens, it automatically\n    // authenticates the user as the install admin (default: you@example.com).\n    process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = \"core\";\n    process.env.GRIST_WARN_BEFORE_SHARING_PUBLICLY = \"true\";\n    await server.restart();\n    const owner = gu.translateUser(\"owner\");\n    const anon = gu.translateUser(\"anon\");\n\n    const checkAnonAccessDenied = async (docId: string) => {\n      await gu.openDocAs(anon, docId, { wait: false });\n      await gu.currentDriver().findContentWait(\".test-error-header\", \"Access denied\", 1000);\n    };\n\n    const changePublicAccess = async (value: \"On\" | \"Off\") => {\n      await gu.editDocAcls();\n      const publicAccess = await driver.find(\".test-um-public-access\");\n      const sharingPublicly = value === \"On\";\n      // Check prior to the change that the previous value was the contrary.\n      assert.equal(await publicAccess.getText(), sharingPublicly ? \"Off\" : \"On\");\n      await publicAccess.click();\n      await driver.findContentWait(\".test-um-public-option\", value, 1000).click();\n      await gu.saveAcls({ sharePubliclyDialog: sharingPublicly });\n    };\n\n    const session = await gu.session().teamSite.login(owner);\n\n    // Make a document\n    const doc = await session.tempDoc(cleanup, \"Hello.grist\", { load: true });\n\n    await checkAnonAccessDenied(doc.id);\n    await gu.openDocAs(owner, doc.id);\n\n    await changePublicAccess(\"On\");\n\n    // Let's check whether anonymous user can access to the doc now\n    await gu.openDocAs(anon, doc.id, { wait: true });\n    await gu.openDocAs(owner, doc.id);\n\n    await changePublicAccess(\"Off\");\n    // Now anonymous access should be disabled again\n    await checkAnonAccessDenied(doc.id);\n  });\n});\n\nfunction findMember(email: string) {\n  return driver.findContent(\".member-email\", email).findClosest(\".test-um-member\");\n}\n\nasync function canEditAccess(element: WebElementPromise, canEdit: boolean = true) {\n  assert.match(await element.getText(), canEdit ? /Manage/ : /Access [Dd]etails/);\n}\n"
  },
  {
    "path": "test/nbrowser/UserManager2.ts",
    "content": "import { startEditingAccessRules } from \"test/nbrowser/aclTestUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { fromPairs } from \"lodash\";\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"UserManager2\", function() {\n  this.timeout(\"4m\");\n  const cleanup = setupTestSuite();\n  const chimpy = gu.translateUser(\"user1\");\n  const charon = gu.translateUser(\"user2\");\n  const kiwi = gu.translateUser(\"user3\");\n  const ham = gu.translateUser(\"user4\");\n  let hasMaxShares: boolean = false;\n\n  before(async function() {\n    // Initially teamSite is created with users user1 (gristoid+chimpy@) and support@ as owners,\n    // but some tests call resetSite(), leaving it owned only by user1. This test assumes that\n    // support@ is among owners, so we ensure that by resetting and adding support@ manually.\n    const session = await gu.session().teamSite.user(\"user1\").login();\n    await session.resetSite();\n    const api = session.createHomeApi();\n    await api.updateOrgPermissions(session.settings.orgDomain, {\n      users: { \"support@getgrist.com\": \"owners\" },\n    });\n    const org = await api.getOrg(\"current\");\n    // The teamSite may or may not have SaaS limits.\n    if (org.billingAccount?.product?.features?.maxSharesPerDoc) {\n      hasMaxShares = true;\n    }\n  });\n\n  for (const landingPage of [\"docmenu\", \"doc\"] as const) {\n    it(`can give useful annotations on team site for doc shares opened from ${landingPage}`, async function() {\n      if (!hasMaxShares) { this.skip(); }\n      // Open a team site\n      const session = await gu.session().teamSite.login();\n      await session.loadDocMenu(\"/\");\n      const api = session.createHomeApi();\n      const users = [\"zig\", \"zag\", \"zog\"];\n\n      try {\n        // Add a user to team.\n        await api.updateOrgPermissions(\"current\", {\n          users: { \"friend@getgrist.com\": \"members\" },\n        });\n\n        // Make a document, and start editing shares.\n        await session.tempDoc(cleanup, \"Hello.grist\", { load: landingPage === \"doc\",\n          newName: landingPage + \".grist\" });\n        const openUserManager = async () => {\n          if (landingPage === \"docmenu\") {\n            await driver.findContent(\".test-dm-workspace\", /Home/).click();\n            await gu.openDocDropdown(landingPage);\n            await driver.find(\".test-dm-doc-access\").click();\n          } else {\n            await openManageUsers();\n          }\n        };\n        await openUserManager();\n\n        // Check that Open Access Rules is shown in docs but hidden on the doc menu (a previous bug\n        // caused this to always be shown).\n        if (landingPage === \"doc\") {\n          assert(await driver.findWait(\".test-um-open-access-rules\", 2000).isPresent());\n        } else {\n          assert.isFalse(await driver.find(\".test-um-open-access-rules\").isPresent());\n        }\n\n        // Check that the support user is correctly annotated.\n        await gu.waitToPass(async () => {\n          const member = await findMember(\"support@getgrist.com\");\n          assert.match(await member.getText(), /Grist support/);\n        });\n\n        // Check that the owner is correctly annotated.\n        await gu.waitToPass(async () => {\n          const member = await findMember(session.email);\n          assert.match(await member.getText(), /Team member/);\n        });\n\n        // Add a member.\n        await driver.findWait(\".test-um-member-new\", 2000).find(\"input\").click();\n        await driver.sendKeys(\"friend@getgrist.com\", Key.ENTER);\n        // Check that the member is correctly annotated.\n        await gu.waitToPass(async () => {\n          const member = await findMember(\"friend@getgrist.com\");\n          assert.match(await member.getText(), /Team member/);\n        });\n\n        // Add a collaborator.\n        await driver.find(\".test-um-member-new\").find(\"input\").click();\n        await driver.sendKeys(\"zig@getgrist.com\", Key.ENTER);\n        // Check that the collaborator is correctly annotated.\n        await gu.waitToPass(async () => {\n          const member = await findMember(\"zig@getgrist.com\");\n          assert.match(await member.getText(), /1 of 2 guests/);\n          assert.match(await member.getText(), /Add member to your team/);\n        });\n\n        // Add a second collaborator.\n        await driver.find(\".test-um-member-new\").find(\"input\").click();\n        await driver.sendKeys(\"zag@getgrist.com\", Key.ENTER);\n        // Check that the collaborator is correctly annotated.\n        await gu.waitToPass(async () => {\n          const member = await findMember(\"zag@getgrist.com\");\n          assert.match(await member.getText(), /2 of 2 guests/);\n          assert.match(await member.getText(), /Add member to your team/);\n        });\n\n        // Add a third collaborator.\n        await driver.find(\".test-um-member-new\").find(\"input\").click();\n        await driver.sendKeys(\"zog@getgrist.com\", Key.ENTER);\n        // Check that the collaborator is correctly annotated.\n        await gu.waitToPass(async () => {\n          const member = await findMember(\"zog@getgrist.com\");\n          assert.match(await member.getText(), /Guest limit exceeded/);\n          assert.match(await member.getText(), /Add member to your team/);\n        });\n\n        // Check an 'Add member' link.\n        await findMember(\"zog@getgrist.com\").findContent(\"a\", /Add member/).click();\n\n        // Make sure we end up in team management dialog with email address automatically filled in.\n        await gu.waitToPass(async () => {\n          const txt = await findModal(/Manage members of team site/)\n            .findClosest(\".test-modal-dialog\")\n            .find(\".test-um-member-new\")\n            .find(\"input\")\n            .value();\n          assert.equal(txt, \"zog@getgrist.com\");\n        });\n        // Wait a moment before proceeding (animation is being shown). Unclear why test hangs without this.\n        await driver.sleep(100);\n\n        // Check that Open Access Rules is not shown (a previous bug caused this to always be shown).\n        assert.isFalse(await findModal(/Manage members of team site/).find(\".test-um-open-access-rules\").isPresent());\n\n        // Confirm the member addition.\n        await driver.findContent(\".test-um-add-email\", /email an invite to zog@getgrist.com/).click();\n        await findModal(/Manage members of team site/).findWait(\".test-um-confirm\", 3000).click();\n\n        // Check that the annotation is correctly updated.\n        await gu.waitToPass(async () => {\n          const member = await findMember(\"zog@getgrist.com\");\n          assert.match(await member.getText(), /Team member/);\n        });\n\n        // Add the users to the doc.\n        await driver.findWait(\".test-um-confirm\", 3000).click();\n\n        // Reopen dialog and do a sanity check.\n        await gu.waitToPass(async () => {\n          assert.equal(await driver.find(\".test-modal-dialog\").isPresent(), false);\n        });\n        await openUserManager();\n        let collaborator1 = await driver.findContentWait(\".test-um-member\", /1 of 2/, 2000)\n          .find(\".member-email\").getText();\n        let collaborator2 = await driver.findContentWait(\".test-um-member\", /2 of 2/, 2000)\n          .find(\".member-email\").getText();\n        // Order of members appears to be a bit arbitrary.\n        assert.sameMembers([collaborator1, collaborator2], [\"zig@getgrist.com\", \"zag@getgrist.com\"]);\n\n        // Remove a collaborator and check remaining annotation updates ok.\n        await findMember(collaborator1).find(\".test-um-member-delete\").click();\n        await gu.waitToPass(async () => {\n          const member = await findMember(collaborator2);\n          assert.match(await member.getText(), /1 of 2 guests/);\n        });\n\n        // Check that public access does not count towards guest limit (a bug previously counted it).\n        await driver.find(\".test-um-public-access\").click();\n        await gu.findOpenMenuItem(\".test-um-public-option\", \"On\").click();\n        await driver.find(\".test-um-member-new\").find(\"input\").click();\n        await driver.sendKeys(\"zod@getgrist.com\", Key.ENTER);\n        await driver.findWait(\".test-um-confirm\", 3000).click();\n        await gu.waitForServer();\n        await openUserManager();\n        collaborator1 = await driver.findContentWait(\".test-um-member\", /1 of 2/, 2000)\n          .find(\".member-email\").getText();\n        collaborator2 = await driver.findContentWait(\".test-um-member\", /2 of 2/, 2000)\n          .find(\".member-email\").getText();\n        assert.sameMembers([collaborator1, collaborator2], [\"zig@getgrist.com\", \"zod@getgrist.com\"]);\n      } finally {\n        // Remove users we added.\n        await api.updateOrgPermissions(\"current\", {\n          users: fromPairs(users.map(u => [`${u}@getgrist.com`, null])),\n        });\n      }\n    });\n  }\n\n  it(\"can give useful annotations on personal site\", async function() {\n    if (!hasMaxShares) { this.skip(); }\n    // Open personal site\n    const session = await gu.session().personalSite.login();\n    await session.loadDocMenu(\"/\");\n\n    // Make a document, and start editing shares.\n    await session.tempDoc(cleanup, \"Hello.grist\", { load: true });\n    await driver.findWait(\".test-tb-share\", 2000).click();\n    await driver.findContent(\".test-tb-share-option\", /Manage users/).click();\n\n    // Add a collaborator.\n    await driver.findWait(\".test-um-member-new\", 2000).find(\"input\").click();\n    await driver.sendKeys(\"zig@getgrist.com\", Key.ENTER);\n    // Check that the collaborator is correctly annotated.\n    await gu.waitToPass(async () => {\n      const member = await findMember(\"zig@getgrist.com\");\n      assert.match(await member.getText(), /1 of 2 free collaborators/);\n      assert.notMatch(await member.getText(), /Add member to your team/);\n    });\n\n    // Add a second collaborator.\n    await driver.find(\".test-um-member-new\").find(\"input\").click();\n    await driver.sendKeys(\"zag@getgrist.com\", Key.ENTER);\n    // Check that the collaborator is correctly annotated.\n    await gu.waitToPass(async () => {\n      const member = await findMember(\"zag@getgrist.com\");\n      assert.match(await member.getText(), /2 of 2 free collaborators/);\n      assert.match(await member.getText(), /Create a team to share with more people/);\n    });\n\n    // Add a third collaborator.\n    await driver.find(\".test-um-member-new\").find(\"input\").click();\n    await driver.sendKeys(\"zog@getgrist.com\", Key.ENTER);\n    // Check that the collaborator is correctly annotated.\n    await gu.waitToPass(async () => {\n      const member = await findMember(\"zog@getgrist.com\");\n      assert.match(await member.getText(), /Free collaborator limit exceeded/);\n      assert.match(await member.getText(), /Create a team to share with more people/);\n    });\n\n    // Remove first collaborator, make sure other annotations are updated correctly.\n    await findMember(\"zig@getgrist.com\").find(\".test-um-member-delete\").click();\n    await gu.waitToPass(async () => {\n      assert.match(await findMember(\"zag@getgrist.com\").getText(), /1 of 2/);\n      assert.match(await findMember(\"zog@getgrist.com\").getText(), /2 of 2/);\n    });\n  });\n\n  // There was a bug where annotations were confused by guests of other documents\n  it(\"handles guests correctly\", async function() {\n    if (!hasMaxShares) { this.skip(); }\n    // Open personal site\n    const session = await gu.session().personalSite.login();\n    await session.loadDocMenu(\"/\");\n\n    // Make a document, and start editing shares.\n    await session.tempDoc(cleanup, \"Hello.grist\", { load: true });\n    await driver.findWait(\".test-tb-share\", 2000).click();\n    await driver.findContent(\".test-tb-share-option\", /Manage users/).click();\n\n    // Add a collaborator.\n    await driver.findWait(\".test-um-member-new\", 2000).find(\"input\").click();\n    await driver.sendKeys(\"zag@getgrist.com\", Key.ENTER);\n    // Check that the collaborator is correctly annotated.\n    await gu.waitToPass(async () => {\n      const member = await findMember(\"zag@getgrist.com\");\n      assert.match(await member.getText(), /1 of 2 free collaborators/);\n      assert.notMatch(await member.getText(), /Add member to your team/);\n    });\n    // Add the user to the doc.\n    await driver.findWait(\".test-um-confirm\", 3000).click();\n\n    // Make a new document, and start editing shares.\n    await session.tempDoc(cleanup, \"Hello.grist\", { load: true });\n    await driver.findWait(\".test-tb-share\", 2000).click();\n    await driver.findContent(\".test-tb-share-option\", /Manage users/).click();\n\n    // Add same collaborator.\n    await driver.findWait(\".test-um-member-new\", 2000).find(\"input\").click();\n    await driver.sendKeys(\"zag@getgrist.com\", Key.ENTER);\n    // Check that the collaborator is still correctly annotated.\n    await gu.waitToPass(async () => {\n      const member = await findMember(\"zag@getgrist.com\");\n      assert.match(await member.getText(), /1 of 2 free collaborators/);\n      assert.notMatch(await member.getText(), /Add member to your team/);\n    });\n  });\n\n  // There was a bug where support was initially flagged as a collaborator\n  it(\"handles support correctly\", async function() {\n    if (!hasMaxShares) { this.skip(); }\n    const assertSupportAnnotation = async () => {\n      await gu.waitToPass(async () => {\n        const member = await findMember(\"support@getgrist.com\");\n        assert.match(await member.getText(), /Grist support/);\n        assert.notMatch(await member.getText(), /2 of 2 free collaborators/);\n      });\n    };\n\n    // Add the support user.\n    await driver.findWait(\".test-um-member-new\", 2000).find(\"input\").click();\n    await driver.sendKeys(\"support@getgrist.com\", Key.ENTER);\n\n    // Check that the support user is correctly annotated.\n    await assertSupportAnnotation();\n\n    // Add the support user to the doc.\n    await driver.findWait(\".test-um-confirm\", 3000).click();\n\n    // Re-open the User Manager and check that support is still correctly annotated.\n    await gu.waitToPass(async () => {\n      assert.equal(await driver.find(\".test-modal-dialog\").isPresent(), false);\n    });\n    await driver.findWait(\".test-tb-share\", 2000).click();\n    await driver.findContent(\".test-tb-share-option\", /Manage users/).click();\n    await assertSupportAnnotation();\n\n    // Now add another collaborator and check their annotation.\n    await driver.findWait(\".test-um-member-new\", 2000).find(\"input\").click();\n    await driver.sendKeys(\"not@support.com\", Key.ENTER);\n    const collaborator = await findMember(\"not@support.com\");\n    assert.match(await collaborator.getText(), /2 of 2 free collaborators/);\n    assert.notMatch(await collaborator.getText(), /Add member to your team/);\n  });\n\n  it(\"should not make view-as mode disappear on save\", async function() {\n    // Make a document\n    const session = await gu.session().personalSite.login();\n    await session.tempDoc(cleanup, \"Hello.grist\", { load: true });\n\n    // start view-as mode\n    await gu.openAccessRulesDropdown();\n    await gu.waitToPass(() => gu.findOpenMenuItem(\"a\", /Viewer/).click());\n    await gu.waitForUrl(/aclAsUser/);\n    await gu.waitForDocToLoad();\n\n    // check view-as banner is present\n    assert.isTrue(await driver.find(\".test-view-as-banner\").isPresent());\n\n    // open User Manager\n    await driver.findWait(\".test-tb-share\", 2000).click();\n    await driver.findContent(\".test-tb-share-option\", /Manage users/).click();\n\n    // Adds new user and save\n    await driver.findWait(\".test-um-member-new\", 2000).find(\"input\").click();\n    await driver.sendKeys(\"james007@bond.comd\", Key.ENTER);\n    await driver.find(\".test-um-confirm\").click();\n    await gu.waitForDocToLoad();\n\n    // check view-as banner is present\n    assert.isTrue(await driver.find(\".test-view-as-banner\").isPresent());\n  });\n\n  it(\"should offer view-as for temporary documents\", async function() {\n    // Login as anonymous user.\n    const session = await gu.session().anon.login({\n      showTips: false,\n      freshAccount: true,\n    });\n    await session.loadDocMenu(\"/\");\n    await driver.find(\".test-intro-create-doc\").click();\n    await gu.waitForDocToLoad();\n    await gu.dismissWelcomeTourIfNeeded();\n\n    const testViewAs = async () => {\n      // Open access rules\n      await startEditingAccessRules();\n      // Start view-as mode. (There was a bug here, where the the server returned an error, and\n      // this button wasn't shown to the user. Error was caused by server which was trying to\n      // get shared users from not persisted document).\n      await driver.findContentWait(\"button\", \"View as\", 3000).click();\n      await driver.findContentWait(\".test-acl-user-item\", \"viewer@example.com\", 1000).click();\n      await gu.isAlertShown().then(v => v ? gu.acceptAlert() : Promise.resolve());\n      await gu.waitForUrl(/aclAsUser/);\n      await gu.waitForDocToLoad();\n\n      // Check view-as banner is present\n      assert.isTrue(await driver.find(\".test-view-as-banner\").isPresent());\n\n      // Revert and login as user1\n      await driver.find(\".test-view-as-banner .test-revert\").click();\n      await gu.isAlertShown().then(v => v ? gu.acceptAlert() : Promise.resolve());\n      await gu.waitForDocToLoad();\n    };\n\n    // Test that view-as works for temporary document.\n    await testViewAs();\n\n    // Now we will login and return to this URL, Grist will remember this document (in cookie) and offer us\n    // a chance to save it (but the document still will be temporary).\n    const currentUrl = await driver.getCurrentUrl();\n    await gu.session().user(\"user1\").login();\n    await driver.navigate().refresh();\n    await driver.get(currentUrl);\n    await gu.waitForDocToLoad();\n\n    await testViewAs();\n  });\n\n  it(\"shows all resource members to resource owner on document access\", async function() {\n    const session = await gu.session().teamSite.user(\"user1\").login();\n    await session.resetSite();\n    const api = session.createHomeApi();\n    // This tests if an team/workspace owner see members and guests who don't have access to\n    // the document.\n    const { id: docId } = await session.tempShortDoc(cleanup, \"Hello.grist\", { load: false });\n    const homeId = await api.getOrgWorkspaces(\"current\").then(l => l[0].id);\n\n    // Break the inheritance of the document.\n    await session.createHomeApi().updateDocPermissions(docId, {\n      maxInheritedRole: null,\n    });\n\n    // Add Charon and Kiwi as org members (they won't inherit anything from the org).\n    await api.updateOrgPermissions(\"current\", {\n      users: {\n        [kiwi.email]: \"members\",\n        [charon.email]: \"members\",\n        [ham.email]: \"members\",\n      },\n    });\n\n    // Update those users names, as they might be empty if database was cleared by other tests.\n    await session.user(charon).login();\n    await session.user(kiwi).login();\n    await session.user(ham).login();\n\n    // Make Kiwi document editor and workspace owner, and Charon workspace member. Break the inheritance, to make\n    // it obvious that team members are not inheriting anything (they are members so they are not).\n    await api.updateWorkspacePermissions(homeId, {\n      maxInheritedRole: null,\n      users: {\n        [kiwi.email]: \"owners\",\n        [charon.email]: \"viewers\",\n      },\n    });\n    await api.updateDocPermissions(docId, {\n      users: {\n        [kiwi.email]: \"editors\",\n      },\n    });\n\n    // As Kiwi, we should be able to see ourselves and Chimpy as an owner. We also got some data about workspace\n    // guests but they are not shown in the UI.\n    const kiwiSession = await gu.session().teamSite.user(kiwi).login();\n    await kiwiSession.loadDoc(\"/doc/\" + docId);\n    await openAccessDetails();\n\n    const kiwiEl = await findMember(kiwi.email);\n    assert.match(await kiwiEl.getText(), /Team member/);\n    assert.equal(await getMemberRole(kiwiEl), \"Editor\");\n\n    const chimpyEl = await findMember(gu.translateUser(\"user1\").email);\n    assert.match(await chimpyEl.getText(), /Team member/);\n    assert.equal(await getMemberRole(chimpyEl), \"Owner\");\n\n    // chimpyEl is mentioned in the lower section, Kiwi is first in the list.\n    assert.isFalse(await kiwiEl.findClosest(\".test-um-access-overview\").isPresent());\n    assert.isTrue(await chimpyEl.findClosest(\".test-um-access-overview\").isDisplayed());\n\n    // Charon is not mentioned anywhere in the UI as we show only people with access, whole list is only\n    // useful for owners.\n    await api.updateDocPermissions(docId, {\n      users: {\n        [kiwi.email]: \"owners\",\n      },\n    });\n\n    // Now Kiwi, as a workspace owner, will see Charon (a workspace member), but won't see Ham (a team member).\n    await gu.reloadDoc();\n    await openManageUsers(); // this team we see `Manage Users` instead of `Access Details`.\n\n    // On the list we still see only Chimpy and Kiwi.\n    assert.deepEqual(await userEmails(), [chimpy.email, kiwi.email]);\n\n    // But the auto-complete will mention Charon.\n    await driver.findWait(\".test-um-member-new\", 2000).find(\"input\").click();\n    assert.deepEqual(await getACItems(), [charon.name, chimpy.name, kiwi.name].sort());\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Now login as Chimpy, he is also org owner, so will see everyone.\n    await gu.session().teamSite.user(chimpy).login();\n    await session.loadDoc(\"/doc/\" + docId);\n    await openManageUsers();\n    await driver.findWait(\".test-um-member-new\", 2000).find(\"input\").click();\n    assert.deepEqual(await getACItems(), [kiwi.name, chimpy.name, charon.name, ham.name].sort());\n    await gu.sendKeys(Key.ESCAPE);\n\n    await session.resetSite();\n  });\n});\n\nfunction findMember(email: string) {\n  return driver.findContent(\".member-email\", email).findClosest(\".test-um-member\");\n}\n\nfunction userEmails() {\n  return driver.findAll(\".test-um-member-email\", e => e.getText());\n}\n\nfunction findModal(title: string | RegExp) {\n  return driver.findContentWait(\".test-um-header\", title, 2000)\n    .findClosest(\".test-modal-dialog\");\n}\n\nasync function openAccessDetails() {\n  await driver.findWait(\".test-tb-share\", 2000).click();\n  await driver.findContent(\".test-tb-share-option\", /Access Details/).click();\n  await driver.findWait(\".test-um-header\", 2000);\n}\n\nasync function openManageUsers() {\n  await driver.findWait(\".test-tb-share\", 2000).click();\n  await driver.findContentWait(\".test-tb-share-option\", /Manage users/, 2000).click();\n  await driver.findWait(\".test-um-header\", 2000);\n}\n\nasync function getMemberRole(memberElem: WebElement): Promise<string | null> {\n  const roleElem = memberElem.find(\".test-um-member-role\");\n  const exists = await roleElem.isPresent();\n  return exists ? roleElem.getText() : null;\n}\n\nasync function getACItems() {\n  await driver.findWait(\".test-acselect-dropdown .test-um-member-name\", 2000);\n  return driver.findAll(\".test-acselect-dropdown .test-um-member-name\", el => el.getText()).then(l => l.sort());\n}\n"
  },
  {
    "path": "test/nbrowser/VersionUpdateBanner.ts",
    "content": "import { version as installedVersion } from \"app/common/version\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { FakeUpdateServer, startFakeUpdateServer } from \"test/server/customUtil\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"VersionUpdateBanner\", function() {\n  this.timeout(300000);\n  setupTestSuite();\n\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  let session: gu.Session;\n  let fakeServer: FakeUpdateServer;\n\n  afterEach(() => gu.checkForErrors());\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    process.env.GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING = \"true\";\n    process.env.GRIST_TEST_IMMEDIATE_VERSION_CHECK = \"true\";\n    process.env.GRIST_DEFAULT_EMAIL = gu.session().email;\n    fakeServer = await startFakeUpdateServer();\n    process.env.GRIST_TEST_VERSION_CHECK_URL = `${fakeServer.url()}/version`;\n  });\n\n  beforeEach(async function() {\n    fakeServer.payload = null;\n    await server.restart(true);\n    // Race conditions might happen when checking the grist version. Wait a tiny bit just in case\n    await driver.wait(\n      () => fakeServer.payload !== null,\n      100,\n      \"fake server should have received a version payload\",\n    );\n  });\n\n  after(async function() {\n    await fakeServer.close();\n    oldEnv.restore();\n    await server.restart();\n  });\n\n  it(\"should not be shown to non-managers\", async () => {\n    session = await gu.session().user(\"user2\").personalSite.login({ freshAccount: true });\n    await driver.executeScript(\"window.localStorage.clear();\");\n    await session.loadDocMenu(\"/\");\n\n    await driver.findWait(\".test-top-panel\", 100);\n    assert.equal((await driver.findAll(\".test-version-update-banner-text\")).length, 0);\n  });\n\n  it(\"should be shown to managers\", async () => {\n    session = await gu.session().personalSite.login({ freshAccount: true });\n    await driver.executeScript(\"window.localStorage.clear();\");\n    await session.loadDocMenu(\"/\");\n\n    await driver.findWait(\".test-top-panel\", 100);\n    const banner = await driver.find(\".test-version-update-banner-text\");\n    assert.equal(await banner.isDisplayed(), true);\n    assert.notMatch(await banner.getText(), /critical/);\n\n    // Should be dismissable\n    await driver.find(\".test-banner-close\").click();\n    assert.equal((await driver.findAll(\".test-version-update-banner-text\")).length, 0);\n\n    // Let's make sure it's still not there after being dismissed once\n    await driver.navigate().refresh();\n    await session.loadDocMenu(\"/\");\n    await driver.findWait(\".test-top-panel\", 100);\n    assert.equal((await driver.findAll(\".test-version-update-banner-text\")).length, 0);\n\n    // Update the version, the banner should come back\n    fakeServer.bumpVersion();\n    await server.restart(false);\n    session = await gu.session().personalSite.login({ freshAccount: false });\n    await session.loadDocMenu(\"/\");\n    assert.equal(await driver.find(\".test-version-update-banner-text\").isDisplayed(), true);\n  });\n\n  it(\"should highlight critical version updates to managers\", async () => {\n    fakeServer.isCritical = true;\n    await server.restart(true);\n    session = await gu.session().personalSite.login({ freshAccount: true });\n    await driver.executeScript(\"window.localStorage.clear();\");\n    await session.loadDocMenu(\"/\");\n\n    await driver.findWait(\".test-top-panel\", 100);\n    const banner = await driver.find(\".test-version-update-banner-text\");\n    assert.equal(await banner.isDisplayed(), true);\n    assert.match(await banner.getText(), /critical/);\n\n    await driver.sleep(10e3);\n\n    fakeServer.isCritical = false;\n  });\n\n  it(\"should not be shown to managers when there is no newer version\", async () => {\n    fakeServer.latestVersion = installedVersion;\n    await server.restart(true);\n    session = await gu.session().personalSite.login({ freshAccount: true });\n    await driver.executeScript(\"window.localStorage.clear();\");\n    await session.loadDocMenu(\"/\");\n\n    await driver.findWait(\".test-top-panel\", 100);\n    assert.equal((await driver.findAll(\".test-version-update-banner-text\")).length, 0);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ViewConfigTab.ntest.js",
    "content": "import { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"ViewConfigTab.ntest\", function() {\n  test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n\n    await gu.actions.createNewDoc();\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should set up a new Untitled document with one table\", async function() {\n    // Add another table to the document: click dropdown, select \"New Table\".\n    assert.deepEqual(await $(`.test-docpage-label`).array().text(), [\"Table1\"]);\n    await gu.actions.addNewTable();\n    assert.deepEqual(await $(`.test-docpage-label`).array().text(), [\"Table1\", \"Table2\"]);\n  });\n\n  it(\"should allow opening and closing view config pane\", async function() {\n    var viewNameInput = $(\".test-right-widget-title\");\n\n    // Open the tab, and check it becomes visible.\n    await gu.openSidePane(\"view\");\n    await assert.isDisplayed(viewNameInput.wait(assert.isDisplayed), true);\n\n    // Close the tab again.\n    await gu.toggleSidePanel(\"right\", \"toggle\");\n    await assert.isPresent(viewNameInput, false);\n  });\n\n  it(\"should keep view config pane open across views, with correct view name\", async function() {\n    await gu.openSidePane(\"view\");\n\n    var viewNameInput = $(\".test-right-widget-title\");\n    await assert.isDisplayed(viewNameInput.wait(assert.isDisplayed), true);\n    assert.equal(await viewNameInput.val(), \"TABLE2\");\n\n    // Switch to another view, and make sure the view config side pane is still visible.\n    await gu.actions.selectTabView(\"Table1\");\n    await assert.isDisplayed(viewNameInput);\n    assert.equal(await viewNameInput.val(), \"TABLE1\");\n\n    // Switch to a third view.\n    await gu.actions.selectTabView(\"Table2\");\n    await assert.isDisplayed(viewNameInput);\n    assert.equal(await viewNameInput.val(), \"TABLE2\");\n  });\n\n  it(\"should allow renaming the view from view config pane\", async function() {\n    await gu.actions.selectTabView(\"Table2\");\n\n    // Select the view name text box, select the text and replace with \"Hello\".\n    var viewNameInput = $(\".test-right-widget-title\");\n    await gu.renameTable(\"Table2\", \"Hello\");  // I think renaming happens differently now?\n\n    // Make sure there is now a view in Table named \"Hello\"\n    let tabs = await $(`.test-docpage-label`).array().text();\n    assert.deepEqual(tabs, [ \"Table1\", \"Hello\" ]);\n\n    // Switch to another view, and back to Hello, and see that the view name textbox is correct.\n    await gu.actions.selectTabView(\"Table1\");\n    assert.equal(await viewNameInput.val(), \"TABLE1\");\n    await gu.actions.selectTabView(\"Hello\");\n    assert.equal(await viewNameInput.val(), \"HELLO\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ViewLayout.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { addToRepl, assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"ViewLayout\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  let api: UserAPI;\n\n  it(\"should update when view section's type (.parentKey) changes\", async () => {\n    // create api\n    api = gu.createHomeApi(\"Chimpy\", \"nasa\");\n    addToRepl(\"api\", api, \"home api\");\n\n    // create and open new document\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const docId = await gu.createNewDoc(\"Chimpy\", \"nasa\", \"Horizon\", \"test-viewLayout\");\n    await gu.loadDoc(`/o/nasa/doc/${docId}`);\n\n    // check app shows table1 as a table\n    assert.equal(await gu.getActiveSectionTitle(), \"TABLE1\");\n    await gu.checkForErrors();\n\n    // using api changes parentKey to 'single' and check  view udpated\n    await api.applyUserActions(docId, [[\"UpdateRecord\", \"_grist_Views_section\", 1, { parentKey: \"detail\" }]]);\n    assert.equal(await gu.getActiveSectionTitle(), \"TABLE1 Card List\");\n    await gu.checkForErrors();\n\n    // change parentKey to 'detail' and check view udpated\n    await api.applyUserActions(docId, [[\"UpdateRecord\", \"_grist_Views_section\", 1, { parentKey: \"single\" }]]);\n    assert.equal(await gu.getActiveSectionTitle(), \"TABLE1 Card\");\n    await gu.checkForErrors();\n\n    // change parentKey to chart and check view updated\n    await api.applyUserActions(docId, [[\"UpdateRecord\", \"_grist_Views_section\", 1, { parentKey: \"chart\" }]]);\n    assert.equal(await gu.getActiveSectionTitle(), \"TABLE1 Chart\");\n    await gu.checkForErrors();\n\n    // change parent key back to grid and check\n    await api.applyUserActions(docId, [[\"UpdateRecord\", \"_grist_Views_section\", 1, { parentKey: \"record\" }]]);\n    assert.equal(await gu.getActiveSectionTitle(), \"TABLE1\");\n    await gu.checkForErrors();\n  });\n\n  it(\"should allow to rename a section\", async () => {\n    // rename `TABLE1`\n    await gu.selectSectionByTitle(\"TABLE1\");\n    await gu.renameActiveSection(\"renamed\");\n    assert.equal(await gu.getActiveSectionTitle(), \"renamed\");\n\n    // check new name persists across a doc reload\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // empty string should revert back to default\n    await gu.selectSectionByTitle(\"renamed\");\n    await gu.renameActiveSection(\"\");\n    assert.equal(await gu.getActiveSectionTitle(), \"TABLE1\");\n  });\n\n  it(\"should allow to cycle through sections using shortcuts\", async () => {\n    async function nextSection(count: number = 1) {\n      return gu.sendKeys(...Array(count).fill(Key.chord(await gu.modKey(), \"o\")));\n    }\n\n    async function prevSection(count: number = 1) {\n      return gu.sendKeys(...Array(count).fill(Key.chord(await gu.modKey(), Key.SHIFT, \"o\")));\n    }\n\n    // import World.grist\n    const doc = await gu.importFixturesDoc(\"chimpy\", \"nasa\", \"Horizon\", \"World.grist\", false);\n\n    // open the Country page\n    await gu.loadDoc(`/o/nasa/doc/${doc.id}/p/5`); // open Country\n\n    // check that the active section is COUNTRY\n    assert.equal(await gu.getActiveSectionTitle(), \"COUNTRY\");\n\n    // go to next section\n    await nextSection();\n\n    // check the active section is COUNTRY Card List\n    assert.equal(await gu.getActiveSectionTitle(), \"COUNTRY Card List\");\n\n    // go to previous section\n    await prevSection();\n\n    // check the active section is COUNTRY\n    assert.equal(await gu.getActiveSectionTitle(), \"COUNTRY\");\n\n    // select COUNTRYLANGUAGE (last in cycle)\n    await gu.getSection(\"COUNTRYLANGUAGE\").click();\n\n    // go to next section: now it is the left panel, then top panel, then the first section again.\n    await nextSection(3);\n\n    // check the active section is COUNTRY\n    assert.equal(await gu.getActiveSectionTitle(), \"COUNTRY\");\n\n    // go to previous section\n    await prevSection(3);\n\n    // check the active section is COUNTRYLANGUAGE\n    assert.equal(await gu.getActiveSectionTitle(), \"COUNTRYLANGUAGE\");\n  });\n\n  describe(\"NarrowScreen\", function() {\n    let oldDimensions: gu.WindowDimensions;\n    before(async () => { oldDimensions = await gu.getWindowDimensions(); });\n    after(async () => {\n      const { width, height } = oldDimensions;\n      await gu.setWindowDimensions(width, height);\n    });\n\n    it(\"should collapse inactive view sections\", async function() {\n      // Open document.\n      const session = await gu.session().login();\n      await session.tempDoc(cleanup, \"Favorite_Films.grist\");\n\n      // Check what view sections there are.\n      await gu.getPageItem(\"All\").click();\n      await gu.waitForServer();\n      assert.deepEqual(await gu.getSectionTitles(),\n        [\"Performances record\", \"Performances detail\", \"Films record\", \"Friends record\"]);\n\n      // Shrink window to small size (this is iPhone dimensions).\n      await gu.setWindowDimensions(375, 667);\n\n      // Check all view sections are still visible.\n      assert.deepEqual(await gu.getSectionTitles(),\n        [\"Performances record\", \"Performances detail\", \"Films record\", \"Friends record\"]);\n\n      // Check that the active is the only one whose content is shown.\n      assert.deepEqual(await driver.findAll(\".view_data_pane_container\", el => el.isDisplayed()),\n        [true, false, false, false]);\n\n      // Check that the inactive section are small.\n      await gu.waitToPass(async () => {\n        assert.deepInclude(await gu.getSection(\"Performances detail\").getRect(), { width: 32 });\n        assert.deepInclude(await gu.getSection(\"Films record\").getRect(), { height: 32 });\n        assert.deepInclude(await gu.getSection(\"Friends record\").getRect(), { height: 32 });\n\n        assert.isAbove((await gu.getSection(\"Performances record\").getRect()).height, 100);\n      });\n\n      // Click another section.\n      await gu.getSection(\"Friends record\").click();\n\n      // Check the clicked section is now the only one shown.\n      assert.deepEqual(await driver.findAll(\".view_data_pane_container\", el => el.isDisplayed()),\n        [false, false, false, true]);\n\n      // Check that the other sections are small.\n      await gu.waitToPass(async () => {\n        assert.deepInclude(await gu.getSection(\"Performances record\").getRect(), { height: 32 });\n        assert.deepInclude(await gu.getSection(\"Performances detail\").getRect(), { height: 32 });\n        assert.deepInclude(await gu.getSection(\"Films record\").getRect(), { width: 32 });\n\n        assert.isAbove((await gu.getSection(\"Friends record\").getRect()).height, 100);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/ViewLayoutCollapse.ts",
    "content": "import { AccessLevel } from \"app/common/CustomWidget\";\nimport { arrayRepeat } from \"app/plugin/gutil\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { getCollapsedSection, openCollapsedSectionMenu } from \"test/nbrowser/ViewLayoutUtils\";\nimport { addStatic, serveSomething } from \"test/server/customUtil\";\n\nimport { assert, driver, Key, WebElement, WebElementPromise } from \"mocha-webdriver\";\n\nconst GAP = 16;     // Distance between buttons representing collapsed widgets.\n\ndescribe(\"ViewLayoutCollapse\", function() {\n  this.timeout(\"50s\");\n  const cleanup = setupTestSuite();\n  gu.bigScreen();\n  let session: gu.Session;\n\n  before(async () => {\n    session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n  });\n\n  it(\"preserves collapsed state when summary widget is changed\", async function() {\n    // When there was a collapsed section, and one of the widget in the main area was changed, the\n    // collapsed section try was destroyed (putting all sections in the main area).\n    const revert = await gu.begin();\n\n    // Add new widget with new table to the page.\n    await gu.addNewSection(\"Table\", \"New Table\");\n\n    // Collapse it.\n    await collapseByMenu(\"Table2\");\n    assert.deepEqual(await mainSectionTitles(), [\"TABLE1\"]);\n    assert.deepEqual(await collapsedSectionTitles(), [\"TABLE2\"]);\n\n    // Now click the change widget button on in the creator panel.\n    await gu.openWidgetPanel(\"widget\");\n\n    // Try to change the widget type.\n    await driver.findContent(\".test-right-panel button\", /Change widget/).click();\n\n    // Click the summary icon next to the table name.\n    await driver.findContentWait(\".test-wselect-table\", /Table1/, 50).find(\".test-wselect-pivot\").doClick();\n\n    // But don't select any column, just click save.\n    await driver.find(\".test-wselect-addBtn\").click();\n    await gu.waitForServer();\n\n    // Now check how many sections we have in the main area.\n    assert.deepEqual(await mainSectionTitles(), [\"TABLE1 [Totals]\"]);\n    assert.deepEqual(await collapsedSectionTitles(), [\"TABLE2\"]);\n\n    await revert();\n  });\n\n  it(\"fix:copies collapsed sections properly\", async function() {\n    // When one of 2 widget was collapsed, the resulting widget can become a root section. Then,\n    // when a page was duplicated, the layout was duplicated incorrectly (with wrong collapsed\n    // section). This resulted in a bug, when the root section was deleted, as it was the last\n    // section in the saved layout, but not the last section on the visible layout.\n\n    // Add new page with new table.\n    await gu.addNewPage(\"Table\", \"New Table\", {\n      tableName: \"Broken\",\n    });\n\n    await gu.renameActiveSection(\"Collapsed\");\n\n    // Add section here (with the same table).\n    await gu.addNewSection(\"Table\", \"Broken\");\n\n    // Rename it so that it is easier to find.\n    await gu.renameActiveSection(\"NotCollapsed\");\n\n    // Now store the layout, by amending it (so move the collapsed widget below).\n    const { height } = await gu.getSection(\"NotCollapsed\").getRect();\n    await dragMain(\"Collapsed\");\n    await move(gu.getSection(\"NotCollapsed\"), { x: 50, y: height / 2 });\n    await driver.sleep(300);\n    await move(gu.getSection(\"NotCollapsed\"), { x: 100, y: height / 2 });\n    await driver.sleep(300);\n    await driver.withActions(actions => actions.release());\n    // Wait for the debounced save.\n    await driver.sleep(1500);\n    await gu.waitForServer();\n\n    // Now collapse it.\n    await collapseByMenu(\"Collapsed\");\n\n    // Now duplicate the page.\n    await gu.duplicatePage(\"Broken\", \"Broken2\");\n\n    // Now on this page we saw two uncollapsed sections (make sure this is not the case).\n    assert.deepEqual(await gu.getSectionTitles(), [\"NotCollapsed\"]);\n    // And one collapsed.\n    assert.deepEqual(await collapsedSectionTitles(), [\"Collapsed\"]);\n  });\n\n  it(\"fix:can delete root section\", async function() {\n    // But even if the layout spec was corrupted, we still should be able to delete the root section\n    // when replacing it with new one.\n\n    // Break the spec.\n    const specJson: string = await driver.executeScript(\n      \"return gristDocPageModel.gristDoc.get().docModel.views.rowModels[3].layoutSpec()\",\n    );\n\n    // To break the spec, we will replace id of the collapsed section, then viewLayout will try to fix it,\n    // by rendering the missing section without patching the layout spec (which is good, because this could\n    // happen on readonly doc or a snapshot).\n    const spec = JSON.parse(specJson);\n    spec.collapsed[0].leaf = -10;\n\n    await driver.executeScript(\n      `gristDocPageModel.gristDoc.get().docModel.views.rowModels[3].layoutSpec.setAndSave('${JSON.stringify(spec)}')`,\n    );\n\n    await gu.waitForServer();\n\n    // We now should see two sections.\n    assert.deepEqual(await gu.getSectionTitles(), [\"NotCollapsed\", \"Collapsed\"]);\n\n    // And we should be able to delete the top one (NotCollapsed).\n    await gu.openSectionMenu(\"viewLayout\", \"NotCollapsed\");\n    await driver.findContent(\".test-cmd-name\", \"Delete widget\").click();\n    await gu.waitForServer();\n\n    await gu.checkForErrors();\n  });\n\n  it(\"fix: custom widget should restart when added back after collapsing\", async function() {\n    await session.tempDoc(cleanup, \"Investment Research (smaller).grist\");\n    await gu.openPage(\"Overview\");\n\n    const revert = await gu.begin();\n\n    // Add custom section.\n    await gu.addNewPage(\"Table\", \"Companies\");\n    await gu.addNewSection(\"Custom\", \"Companies\", { selectBy: \"COMPANIES\" });\n\n    // Serve custom widget.\n    const widgetServer = await serveSomething((app) => {\n      addStatic(app);\n    });\n    cleanup.addAfterAll(widgetServer.shutdown);\n    await gu.setCustomWidgetUrl(widgetServer.url + \"/probe/index.html\", { openGallery: false });\n    await gu.openWidgetPanel();\n    await gu.widgetAccess(AccessLevel.full);\n\n    // Collapse it.\n    await collapseByMenu(\"COMPANIES Custom\");\n\n    // Now restore its position.\n    await addToMainByMenu(\"COMPANIES Custom\");\n\n    // Collapsed widget used to lost connection with Grist as it was disposed to early.\n    // Make sure that this widget can call the API.\n    await gu.doInIframe(async () => {\n      await gu.waitToPass(async () => {\n        assert.equal(await driver.find(\"#output\").getText(),\n          `[\"Companies\",\"Investments\",\"Companies_summary_category_code\",\"Investments_summary_funded_year\",` +\n          `\"Investments_summary_Company_category_code_funded_year\",\"Investments_summary_Company_category_code\"]`,\n        );\n      });\n    });\n\n    // Make sure we don't have an error.\n    await gu.checkForErrors();\n    await revert();\n  });\n\n  it(\"fix: custom widget should not throw errors when collapsed\", async function() {\n    const revert = await gu.begin();\n\n    // Add custom section.\n    await gu.addNewPage(\"Table\", \"Companies\");\n    await gu.addNewSection(\"Custom\", \"Companies\", { selectBy: \"COMPANIES\" });\n\n    // Serve custom widget.\n    const widgetServer = await serveSomething((app) => {\n      addStatic(app);\n    });\n    cleanup.addAfterAll(widgetServer.shutdown);\n    await gu.setCustomWidgetUrl(widgetServer.url + \"/probe/index.html\", { openGallery: false });\n    await gu.openWidgetPanel();\n    await gu.widgetAccess(AccessLevel.full);\n\n    // Collapse it.\n    await collapseByMenu(\"COMPANIES Custom\");\n\n    // Change cursor position in the active section.\n    await gu.getCell(2, 4).click();\n\n    // Put custom section in popup.\n    await openCollapsedSection(\"COMPANIES Custom\");\n\n    // Close it by pressing escape.\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Change cursor once again.\n    await gu.getCell(2, 5).click();\n\n    // Make sure we don't have an error (there was a bug here).\n    await gu.checkForErrors();\n\n    await revert();\n  });\n\n  it(\"fix: should resize other sections correctly when maximized and linked\", async function() {\n    const revert = await gu.begin();\n    // If there are two sections linked, but one is collapsed, and user is changing the row\n    // in the popup of the maximized section, the other section should resize correctly.\n\n    // Add two linked tables.\n    await gu.addNewTable(\"Table1\");\n    await gu.addNewSection(\"Table\", \"New Table\");\n\n    await gu.toggleSidePanel(\"right\", \"open\");\n\n    // Set A in Table2 to be linked in Table1, by ref column\n    await gu.openColumnMenu(\"A\", \"Column Options\");\n\n    // Change it to the Ref column of TABLE1\n    await gu.setType(\"Reference\");\n    await gu.setRefTable(\"Table1\");\n    await gu.setRefShowColumn(\"A\");\n\n    // Select it by Table1.\n    await gu.selectBy(\"TABLE1\");\n\n    // Now add 2 records with 'White' and 'Black' in Table1\n    await gu.sendActions([\n      [\"BulkAddRecord\", \"Table1\", arrayRepeat(2, null), { A: [\"White\", \"Black\"] }],\n      // And 30 records in Table2 that are connected to White.\n      [\"BulkAddRecord\", \"Table2\", arrayRepeat(30, null), { A: arrayRepeat(30, 1) }],\n      // And 30 records in Table2 that are connected to Black.\n      [\"BulkAddRecord\", \"Table2\", arrayRepeat(30, null), { A: arrayRepeat(30, 2) }],\n    ]);\n\n    // Now select White in Table1.\n    await gu.getCell(\"A\", 1, \"Table1\").click();\n\n    // Now expand Table1.\n    await gu.expandSection();\n\n    // Change to black.\n    await gu.getCell(\"A\", 2, \"Table1\").click();\n\n    // Close popup by sending ESCAPE\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Make sure we see 30 records in Table2.\n    await gu.waitToPass(async () => {\n      const count = await driver.executeScript(`\n        const section = Array.from(document.querySelectorAll('.test-widget-title-text'))\n                             .find(e => e.textContent === 'TABLE2')\n                             .closest('.viewsection_content');\n        return Array.from(section.querySelectorAll('.gridview_data_row_num')).length;\n      `);\n\n      assert.equal(count, 30 + 1);\n    }, 100);\n\n    await revert();\n  });\n\n  it(\"fix: should support searching\", async function() {\n    // Collapse Companies section (a first one).\n    await collapseByMenu(COMPANIES);\n\n    // Clear any saved position state.\n    await driver.executeScript(\"window.sessionStorage.clear(); window.localStorage.clear();\");\n\n    // Refresh.\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // Here we had a bug that the hidden section was active, and the search was not working as it was\n    // starting with the hidden section.\n\n    // Now search (for something in the INVESTMENTS section)\n    await gu.search(\"2006\");\n    await gu.closeSearch();\n\n    // Make sure we don't have an error.\n    await gu.checkForErrors();\n\n    assert.equal(await gu.getActiveSectionTitle(), INVESTMENTS);\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 8, col: 0 });\n\n    // Hide companies chart, and search for mobile (should show no results).\n    await collapseByMenu(COMPANIES_CHART);\n    await gu.search(\"mobile\");\n    await gu.hasNoResult();\n    await gu.closeSearch();\n\n    // Open companies in the popup.\n    await openCollapsedSection(COMPANIES);\n    // Search for 2006, there will be no results.\n    await gu.search(\"2006\");\n    await gu.hasNoResult();\n    // Now search for web.\n    await gu.closeSearch();\n    await gu.search(\"web\");\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2, col: 0 });\n\n    // Recreate document (can't undo).\n    await session.tempDoc(cleanup, \"Investment Research (smaller).grist\");\n  });\n\n  it(\"fix: should not dispose the instance when drag is cancelled\", async function() {\n    const revert = await gu.begin();\n\n    // Collapse a section.\n    await collapseByMenu(INVESTMENTS);\n\n    // Drag it and then cancel.\n    await dragCollapsed(INVESTMENTS);\n    const logo = driver.find(\".test-dm-logo\");\n    await move(logo, { y: 0 });\n    await move(logo, { y: -1 });\n    // Drop it here.\n    await driver.withActions(actions => actions.release());\n\n    // Now open it in the full view.\n    await openCollapsedSection(INVESTMENTS);\n\n    // And make sure we can move cursor.\n    await gu.getCell(1, 1).click();\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 1, col: 1 });\n    await gu.getCell(1, 2).click();\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2, col: 1 });\n\n    // Change its type, and check that it works.\n    await gu.changeWidget(\"Card List\");\n    // Undo it.\n    await gu.undo();\n    await gu.getCell(1, 3).click();\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 3, col: 1 });\n\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Move it back\n    await dragCollapsed(INVESTMENTS);\n\n    // Move back and drop.\n    await gu.getSection(COMPANIES_CHART).getRect();\n    await move(getDragElement(COMPANIES_CHART), { x: 50 });\n    await driver.sleep(100);\n    await move(getDragElement(COMPANIES_CHART), { x: 100 });\n    await driver.sleep(100);\n    await move(getDragElement(COMPANIES_CHART), { x: 200 });\n    await gu.waitToPass(async () => {\n      assert.lengthOf(await driver.findAll(\".layout_editor_drop_target.layout_hover\"), 1);\n    }, 1000);\n\n    await driver.withActions(actions => actions.release());\n    await driver.sleep(600);\n\n    // And make sure we can move cursor.\n    await gu.getCell(1, 1).click();\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 1, col: 1 });\n    await gu.getCell(1, 2).click();\n    assert.deepEqual(await gu.getCursorPosition(), { rowNum: 2, col: 1 });\n\n    await waitForSave();\n    await revert();\n  });\n\n  it(\"fix: should work when the page is refreshed\", async function() {\n    const revert = await gu.begin();\n\n    await gu.openPage(\"Companies\");\n    await gu.selectSectionByTitle(\"Companies\");\n    // Go to second row.\n    await gu.getCell(0, 2).click();\n\n    // Make sure we see correct company card.\n    assert.equal(await gu.getCardCell(\"name\", \"COMPANIES Card\").getText(), \"#NAME?\");\n\n    // Hide first section.\n    await collapseByMenu(\"Companies\");\n    await waitForSave();\n\n    // Refresh the page.\n    await driver.navigate().refresh();\n    await gu.waitForDocToLoad();\n\n    // Make sure card is still at the correct row.\n    await gu.waitToPass(async () => {\n      assert.equal(await gu.getCardCell(\"name\", \"COMPANIES Card\").getText(), \"#NAME?\");\n    });\n\n    await addToMainByMenu(\"Companies\");\n    await revert();\n  });\n\n  it(\"fix: should support anchor links\", async function() {\n    const revert = await gu.begin();\n\n    // Open 42Floors in Companies section.\n    assert.equal(await gu.getActiveSectionTitle(), \"COMPANIES\");\n    await gu.getCell(\"Link\", 11).click();\n    assert.equal(await gu.getActiveCell().getText(), \"42Floors\");\n\n    // Open 12 row (Alex Bresler, angel).\n    await gu.getCell(\"funding_round_type\", 12, \"Investments\").click();\n    assert.equal(await gu.getActiveCell().getText(), \"angel\");\n\n    // Copy anchor link.\n    const link = await gu.getAnchor();\n\n    // Collapse first section.\n    await collapseByMenu(\"COMPANIES\");\n\n    // Clear any saved position state.\n    await driver.executeScript(\"window.sessionStorage.clear(); window.localStorage.clear();\");\n\n    // Navigate to the home screen.\n    await gu.loadDocMenu(\"/o/docs\");\n\n    // Now go to the anchor.\n    await driver.get(link);\n    await gu.waitForAnchor();\n\n    const cursor = await gu.getCursorPosition();\n    assert.equal(cursor.rowNum, 12);\n    assert.equal(cursor.col, 1);\n    assert.equal(await gu.getActiveCell().getText(), \"angel\");\n    assert.equal(await gu.getActiveSectionTitle(), \"INVESTMENTS\");\n    assert.match(await driver.getCurrentUrl(), /Investment-Research-smaller\\/p\\/1$/);\n    await revert();\n  });\n\n  it(\"should not autoexpand the tray on a page with a single widget\", async () => {\n    await gu.openPage(\"Investments\");\n    assert.equal((await driver.findAll(\".viewsection_content\")).length, 1);\n\n    // Start drag the main section.\n    await dragMain(\"INVESTMENTS\");\n\n    // Move it over the logo, so that the tray thinks that it should expand.\n    const logo = driver.find(\".test-dm-logo\");\n    await move(logo, { y: 0 });\n    await move(logo, { y: -1 });\n    await driver.sleep(100);\n\n    // Make sure the tray was not tricked into expanding itself.\n    assert.isFalse(await layoutTray().isDisplayed());\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-empty-box\"), 0); // No empty box\n\n    // Drop it on the button, it should go back to where it was.\n    await driver.withActions(actions => actions.release());\n  });\n\n  it(\"should autoexpand the tray\", async () => {\n    await gu.openPage(\"Overview\");\n\n    // Get one of the sections and start dragging it.\n    await dragMain(COMPANIES_CHART);\n\n    // The tray should not be expanded.\n    assert.isFalse(await layoutTray().isDisplayed());\n\n    const logo = driver.find(\".test-dm-logo\");\n    // Now move it to the top, so that tray should be expanded.\n    await move(logo, { y: 0 });\n    await driver.sleep(100);\n\n    // Now the tray is visible\n    assert.isTrue(await layoutTray().isDisplayed());\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-empty-box\"), 1); // One empty box\n    assert.isTrue(await layoutEditor().matches(\"[class*=-is-active]\")); // Is active\n    assert.isFalse(await layoutEditor().matches(\"[class*=-is-target]\")); // Is not a target\n\n    // Drop it on the button, it should go back to where it was.\n    await driver.withActions(actions => actions.release());\n\n    // The tray should not be expanded.\n    assert.isFalse(await layoutTray().isDisplayed());\n\n    await gu.checkForErrors();\n  });\n\n  it(\"should drag onto main area\", async () => {\n    const revert = await gu.begin();\n    await collapseByMenu(COMPANIES);\n    await collapseByMenu(INVESTMENTS);\n\n    await dragCollapsed(COMPANIES);\n    const chartCords = await gu.getSection(COMPANIES_CHART).getRect();\n    await move(getDragElement(COMPANIES_CHART));\n    await driver.sleep(100);\n    await move(getDragElement(COMPANIES_CHART), { x: 10 });\n    await driver.sleep(300);\n\n    // We should have a drop target.\n    const dropTarget = await driver.find(\".layout_editor_drop_target.layout_hover\");\n    const dCords = await dropTarget.getRect();\n    // It should be more or less on the left of the chart.\n    assertDistance(dCords.x, chartCords.x, 20);\n    assertDistance(dCords.y, chartCords.y, 20);\n\n    // Move away from the drop target.\n    const addButton = driver.find(\".test-dp-add-new\");\n    await move(addButton);\n    await driver.sleep(300);\n\n    // Drop target should be gone.\n    assert.lengthOf(await driver.findAll(\".layout_editor_drop_target.layout_hover\"), 0);\n\n    // Move back and drop.\n    await move(getDragElement(COMPANIES_CHART));\n    await driver.sleep(100);\n    // Split the movement into two parts, to make sure layout sees the mouse move.\n    await move(getDragElement(COMPANIES_CHART), { x: 10 });\n    await driver.sleep(200);\n    assert.lengthOf(await driver.findAll(\".layout_editor_drop_target.layout_hover\"), 1);\n    await driver.withActions(actions => actions.release());\n    await driver.sleep(600); // This animation can be longer.\n\n    // Make sure it was dropped.\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-leaf-box\"), 1); // Only one collapsed box.\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-empty-box\"), 0); // No empty box.\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-target-box\"), 0); // No target box.\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS]); // Only investments is collapsed.\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES, COMPANIES_CHART, INVESTMENTS_CHART]);\n    // Check that it was dropped on the left top side.\n    const companiesCords = await gu.getSection(COMPANIES).getRect();\n    assertDistance(companiesCords.x, chartCords.x, 20);\n    assertDistance(companiesCords.y, chartCords.y, 20);\n    // It should be half as tall as the main layout.\n    const root = await driver.find(\".layout_root\").getRect();\n    assertDistance(companiesCords.height, root.height / 2, 30);\n    // And almost half as wide.\n    assertDistance(companiesCords.width, root.width / 2, 30);\n\n    // Now move it back to the tray. But first collapse another section (so we can test inbetween target).\n    await collapseByMenu(COMPANIES_CHART);\n    await dragMain(COMPANIES);\n\n    // Try to move it as the first element.\n    const firstLeafSize = await firstLeaf().getRect();\n    await move(firstLeaf(), { x: -firstLeafSize.width / 2 });\n    await driver.sleep(300);\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-target-box\"), 1);\n    // Make sure that the target is in right place.\n    let target = await layoutTray().find(\".test-layoutTray-target-box\").getRect();\n    assertDistance(target.x, firstLeafSize.x, 10);\n\n    // Now as the second element.\n    await move(firstLeaf(), { x: firstLeafSize.width / 2 + GAP });\n    await driver.sleep(300);\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-target-box\"), 1);\n    target = await layoutTray().find(\".test-layoutTray-target-box\").getRect();\n    assertDistance(target.x, firstLeafSize.x + firstLeafSize.width + GAP, 10);\n\n    // Move away to make sure the target is gone.\n    await move(addButton);\n    await driver.sleep(300);\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-target-box\"), 0);\n\n    // Move back and drop.\n    await move(firstLeaf(), { x: firstLeafSize.width / 2 + GAP });\n    await driver.sleep(300);\n    await driver.withActions(actions => actions.release());\n    await driver.sleep(600);\n\n    // Make sure it was dropped.\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-leaf-box\"), 3);\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-empty-box\"), 0);\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-target-box\"), 0);\n\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS, COMPANIES, COMPANIES_CHART]);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS_CHART]);\n\n    await waitForSave(); // Layout save is debounced 1s.\n\n    // Test couple of undo steps.\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS, COMPANIES_CHART]);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES, INVESTMENTS_CHART]);\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS]);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES, COMPANIES_CHART, INVESTMENTS_CHART]);\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES, INVESTMENTS]);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES_CHART, INVESTMENTS_CHART]);\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES]);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES_CHART, INVESTMENTS_CHART, INVESTMENTS]);\n\n    await gu.redo();\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES, INVESTMENTS]);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES_CHART, INVESTMENTS_CHART]);\n\n    await revert();\n    assert.deepEqual(await collapsedSectionTitles(), []);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES_CHART, COMPANIES, INVESTMENTS_CHART, INVESTMENTS]);\n    await gu.checkForErrors();\n  });\n\n  it(\"should reorder collapsed sections\", async () => {\n    const revert = await gu.begin();\n    await collapseByMenu(COMPANIES);\n    await collapseByMenu(INVESTMENTS);\n    await collapseByMenu(COMPANIES_CHART);\n\n    await dragCollapsed(COMPANIES);\n\n    // We should see the empty box in the layout.\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-empty-box\"), 1);\n    // The section is actually removed from the layout tray.\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-leaf-box\"), 2);\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS, COMPANIES_CHART]);\n\n    // Layout should be active and accepting.\n    assert.isTrue(await layoutEditor().matches(\"[class*=-is-active]\"));\n    assert.isTrue(await layoutEditor().matches(\"[class*=-is-target]\"));\n\n    // Move mouse somewhere else, layout should not by highlighted.\n    const addButton = driver.find(\".test-dp-add-new\");\n    await move(addButton);\n    assert.isTrue(await layoutEditor().matches(\"[class*=-is-active]\"));\n    assert.isFalse(await layoutEditor().matches(\"[class*=-is-target]\"));\n\n    // Move to the first leaf, and wait for the target to show up.\n    const first = await firstLeaf().getRect();\n    await move(firstLeaf(), { x: -first.width / 2 });\n    await driver.sleep(300);\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-target-box\"), 1);\n    // Make sure that the target is in right place.\n    let target = await layoutTray().find(\".test-layoutTray-target-box\").getRect();\n    assert.isBelow(Math.abs(target.x - first.x), 10);\n    assert.isBelow(Math.abs(target.y - first.y), 10);\n    assert.isBelow(Math.abs(target.height - first.height), 10);\n\n    // Move away and make sure the target is gone.\n    await move(addButton);\n    await driver.sleep(300);\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-target-box\"), 0);\n\n    // Move between first and second leaf.\n    await move(firstLeaf(), { x: first.width / 2 + GAP });\n    await driver.sleep(300);\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-target-box\"), 1);\n    target = await layoutTray().find(\".test-layoutTray-target-box\").getRect();\n    assert.isBelow(Math.abs(target.height - first.height), 2);\n    assert.isBelow(Math.abs(target.y - first.y), 2);\n    // Should be between first and second leaf.\n    assert.isBelow(Math.abs(target.x - (first.x + first.width + GAP)), 10);\n\n    // Drop here.\n    await driver.withActions(actions => actions.release());\n    await waitForSave(); // Wait for layout to be saved.\n    // Target is gone.\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-empty-box\"), 0);\n    // And we have 3 sections in the layout.\n    assert.lengthOf(await layoutTray().findAll(\".test-layoutTray-leaf-box\"), 3);\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS, COMPANIES, COMPANIES_CHART]);\n\n    // Undo.\n    await gu.undo();\n    // Order should be restored.\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES, INVESTMENTS, COMPANIES_CHART]);\n\n    await revert();\n    await gu.checkForErrors();\n  });\n\n  it(\"should collapse sections and expand using menu\", async () => {\n    await collapseByMenu(COMPANIES_CHART);\n    await gu.checkForErrors();\n\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES_CHART]);\n    // Make sure that other sections are not collapsed.\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES, INVESTMENTS_CHART, INVESTMENTS]);\n\n    await collapseByMenu(INVESTMENTS_CHART);\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES_CHART, INVESTMENTS_CHART]);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES, INVESTMENTS]);\n\n    await collapseByMenu(COMPANIES);\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES_CHART, INVESTMENTS_CHART, COMPANIES]);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS]);\n\n    // The last section is INVESTMENTS, which can't be collapsed.\n    await gu.openSectionMenu(\"viewLayout\", INVESTMENTS);\n    assert.equal(await driver.find(\".test-section-collapse\").matches(\"[class*=disabled]\"), true);\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Now expand them one by one and test.\n    await addToMainByMenu(COMPANIES_CHART);\n    await gu.checkForErrors();\n\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS_CHART, COMPANIES]);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS, COMPANIES_CHART]);\n\n    await addToMainByMenu(INVESTMENTS_CHART);\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES]);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS, COMPANIES_CHART, INVESTMENTS_CHART]);\n    await gu.checkForErrors();\n\n    await addToMainByMenu(COMPANIES);\n    assert.deepEqual(await collapsedSectionTitles(), []);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS, COMPANIES_CHART, INVESTMENTS_CHART, COMPANIES]);\n    await gu.checkForErrors();\n\n    // Now revert everything using undo but test each step.\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES]);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS, COMPANIES_CHART, INVESTMENTS_CHART]);\n    await gu.checkForErrors();\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS_CHART, COMPANIES]);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS, COMPANIES_CHART]);\n    await gu.checkForErrors();\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES_CHART, INVESTMENTS_CHART, COMPANIES]);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS]);\n    await gu.checkForErrors();\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES_CHART, INVESTMENTS_CHART]);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES, INVESTMENTS]);\n    await gu.checkForErrors();\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES_CHART]);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES, INVESTMENTS_CHART, INVESTMENTS]);\n    await gu.checkForErrors();\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), []);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES_CHART, COMPANIES, INVESTMENTS_CHART, INVESTMENTS]);\n    await gu.checkForErrors();\n  });\n\n  it(\"should remove sections from collapsed tray\", async () => {\n    const revert = await gu.begin();\n    // Collapse everything we can.\n    await collapseByMenu(COMPANIES_CHART);\n    await collapseByMenu(INVESTMENTS_CHART);\n    await collapseByMenu(COMPANIES);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS]);\n\n    // Now remove them using menu.\n    await removeMiniSection(COMPANIES_CHART);\n    await gu.checkForErrors();\n\n    // Check that the section is removed from the collapsed tray.\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS_CHART, COMPANIES]);\n    // Make sure it is stays removed when we move to the other page.\n    await gu.openPage(\"Investments\");\n    // Go back.\n    await gu.openPage(\"Overview\");\n    await gu.checkForErrors();\n\n    // Test if we see everything as it was.\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS_CHART, COMPANIES]);\n    // Make sure that visible sections are not affected.\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS]);\n\n    // Remove the other sections.\n    await removeMiniSection(INVESTMENTS_CHART);\n    await removeMiniSection(COMPANIES);\n    assert.deepEqual(await collapsedSectionTitles(), []);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS]);\n\n    // Now revert everything using undo but test each step.\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES]);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS]);\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [INVESTMENTS_CHART, COMPANIES]);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS]);\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES_CHART, INVESTMENTS_CHART, COMPANIES]);\n    assert.deepEqual(await mainSectionTitles(), [INVESTMENTS]);\n\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES_CHART, INVESTMENTS_CHART]);\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES, INVESTMENTS]);\n\n    // Ok, we are good, revert back to the original state.\n    await revert();\n    await gu.checkForErrors();\n  });\n\n  it(\"should prompt when last section is removed from tray\", async () => {\n    const revert = await gu.begin();\n\n    // Add brand new table and collapse it.\n    await gu.addNewSection(\"Table\", \"New Table\", { tableName: \"ToCollapse\" });\n    await collapseByMenu(\"ToCollapse\");\n\n    // Now try to remove it, we should see prompt.\n    await openCollapsedSectionMenu(\"ToCollapse\");\n    await driver.find(\".test-section-delete\").click();\n    assert.match(\n      await driver.find(\".test-modal-title\").getText(),\n      /Table ToCollapse will no longer be visible/,\n    );\n\n    // Select first option, to delete both table and widget.\n    await driver.find(\".test-option-deleteDataAndWidget\").click();\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n\n    // Make sure it is removed.\n    assert.deepEqual(await collapsedSectionTitles(), []);\n    assert.deepEqual(await visibleTables(), [\"Companies\", \"Investments\"]);\n    await gu.sendKeys(Key.ESCAPE);\n\n    // Single undo should add it back.\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [\"TOCOLLAPSE\"]);\n    assert.deepEqual(await visibleTables(), [\"Companies\", \"Investments\", \"ToCollapse\"]);\n\n    // Now do the same but, keep data.\n    await openCollapsedSectionMenu(\"ToCollapse\");\n    await driver.find(\".test-section-delete\").click();\n    await driver.findWait(\".test-modal-dialog\", 100);\n    await driver.find(\".test-option-deleteOnlyWidget\").click();\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n\n    // Make sure it is removed.\n    assert.deepEqual(await collapsedSectionTitles(), []);\n    assert.deepEqual(await visibleTables(), [\"Companies\", \"Investments\", \"ToCollapse\"]);\n\n    // Test single undo.\n    await gu.undo();\n    assert.deepEqual(await collapsedSectionTitles(), [\"TOCOLLAPSE\"]);\n    assert.deepEqual(await visibleTables(), [\"Companies\", \"Investments\", \"ToCollapse\"]);\n\n    // Uncollapse it, and do the same with normal section.\n    await addToMainByMenu(\"ToCollapse\");\n\n    // Now try to remove it, we should see prompt.\n    assert.include(\n      await driver.findAll(\".test-viewsection-title\", e => e.getText()), \"TOCOLLAPSE\");\n\n    await gu.openSectionMenu(\"viewLayout\", \"ToCollapse\");\n    await driver.find(\".test-section-delete\").click();\n    await driver.findWait(\".test-modal-dialog\", 100);\n    await driver.find(\".test-option-deleteOnlyWidget\").click();\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n    assert.notInclude(\n      await driver.findAll(\".test-viewsection-title\", e => e.getText()), \"TOCOLLAPSE\");\n    assert.deepEqual(await visibleTables(), [\"Companies\", \"Investments\", \"ToCollapse\"]);\n    // Test undo.\n    await gu.undo();\n    assert.include(\n      await driver.findAll(\".test-viewsection-title\", e => e.getText()), \"TOCOLLAPSE\");\n\n    // Do the same but delete data and widget.\n    await gu.openSectionMenu(\"viewLayout\", \"ToCollapse\");\n    await driver.find(\".test-section-delete\").click();\n    await driver.findWait(\".test-modal-dialog\", 100);\n    await driver.find(\".test-option-deleteDataAndWidget\").click();\n    await driver.find(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n\n    // Make sure it is removed.\n    assert.notInclude(\n      await driver.findAll(\".test-viewsection-title\", e => e.getText()), \"TOCOLLAPSE\");\n    assert.deepEqual(await visibleTables(), [\"Companies\", \"Investments\"]);\n\n    // Test undo.\n    await gu.undo();\n    assert.include(\n      await driver.findAll(\".test-viewsection-title\", e => e.getText()), \"TOCOLLAPSE\");\n    assert.deepEqual(await visibleTables(), [\"Companies\", \"Investments\", \"ToCollapse\"]);\n\n    await revert();\n  });\n\n  it(\"should switch active section when collapsed\", async () => {\n    const revert = await gu.begin();\n    await gu.selectSectionByTitle(gu.exactMatch(COMPANIES));\n    // Make sure we are active.\n    assert.equal(await gu.getActiveSectionTitle(), COMPANIES);\n    // Collapse it.\n    await collapseByMenu(COMPANIES);\n    // Make sure it is collapsed.\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES]);\n    // Make sure that now COMPANIES_CHART is active. (first one).\n    assert.equal(await gu.getActiveSectionTitle(), COMPANIES_CHART);\n    // Expand COMPANIES.\n    await addToMainByMenu(COMPANIES);\n    // Make sure that now it is active.\n    assert.equal(await gu.getActiveSectionTitle(), COMPANIES);\n    await revert();\n    await gu.checkForErrors();\n  });\n\n  it(\"should show section on popup when clicked\", async () => {\n    const revert = await gu.begin();\n    await collapseByMenu(COMPANIES);\n    await openCollapsedSection(COMPANIES);\n    // Make sure it is expanded.\n    assert.isTrue(await driver.find(\".test-viewLayout-overlay\").matches(\"[class*=-active]\"));\n    assert.equal(await gu.getActiveSectionTitle(), COMPANIES);\n    // Make sure that the panel shows it.\n    await gu.toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-config-widget\").click();\n    assert.equal(await driver.find(\".test-right-widget-title\").value(), COMPANIES);\n    // Make sure we see proper items in the menu.\n    await gu.openSectionMenu(\"viewLayout\", COMPANIES);\n    // Collapse widget menu item is disabled.\n    assert.equal(await driver.find(\".test-section-collapse\").matches(\"[class*=disabled]\"), true);\n    // Delete widget is enabled.\n    assert.equal(await driver.find(\".test-section-delete\").matches(\"[class*=disabled]\"), false);\n    await driver.sendKeys(Key.ESCAPE);\n    // Expand button is not visible\n    assert.lengthOf(await driver.findAll(\".active_section .test-section-menu-expandSection\"), 0);\n    // We can rename a section using the popup.\n    await gu.renameActiveSection(\"New name\");\n    assert.equal(await gu.getActiveSectionTitle(), \"New name\");\n    // Make sure the name is reflected in the collapsed tray.\n    await gu.sendKeys(Key.ESCAPE);\n    assert.deepEqual(await collapsedSectionTitles(), [\"New name\"]);\n    // Open it back.\n    await openCollapsedSection(\"New name\");\n    // Rename it back using undo.\n    await gu.undo();\n    assert.equal(await gu.getActiveSectionTitle(), COMPANIES);\n    // Now remove it.\n    await gu.openSectionMenu(\"viewLayout\", COMPANIES);\n    await driver.find(\".test-section-delete\").click();\n    await gu.waitForServer();\n    // Make sure it is closed.\n    assert.isFalse(await driver.find(\".test-viewLayout-overlay\").matches(\"[class*=-active]\"));\n    // Make sure it is removed.\n    assert.deepEqual(await collapsedSectionTitles(), []);\n    // Make sure it didn't reappear on the main area.\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES_CHART, INVESTMENTS_CHART, INVESTMENTS]);\n\n    await revert();\n    await gu.checkForErrors();\n  });\n\n  it(\"should collapse and expand charts without an error\", async () => {\n    const revert = await gu.begin();\n    await collapseByMenu(INVESTMENTS);\n    await dragMain(COMPANIES_CHART);\n    const firstRect = await firstLeaf().getRect();\n    await move(firstLeaf(), { x: firstRect.width / 2 + GAP });\n    await driver.sleep(300);\n    await driver.withActions(actions => actions.release());\n    await waitForSave(); // Resize is delayed.\n    await gu.checkForErrors();\n    await revert();\n  });\n\n  it(\"should drop on the empty space\", async () => {\n    const revert = await gu.begin();\n    // Get one of the sections and start dragging it.\n    await dragMain(COMPANIES_CHART);\n    // Move it over the logo to show the tray.\n    const logo = driver.find(\".test-dm-logo\");\n    await move(logo, { y: 0 });\n    await move(logo, { y: -20 });\n    await driver.sleep(100);\n    // Now the tray is visible\n    assert.isTrue(await layoutTray().isDisplayed());\n    // Move it on the empty space just after the empty box\n    const emptyBox = await layoutTray().find(\".test-layoutTray-empty-box\");\n    const emptyBoxCords = await emptyBox.getRect();\n    await move(emptyBox, { x: emptyBoxCords.width + 100 });\n    // Make sure that the empty box is not active.\n    assert.isFalse(await emptyBox.matches(\"[class*=-is-active]\"));\n    // Drop it here\n    await driver.withActions(actions => actions.release());\n    await driver.sleep(600); // Wait for animation to finish.\n    await waitForSave();\n    // The tray should stay expanded.\n    assert.isTrue(await layoutTray().isDisplayed());\n\n    // Check that the section was collapsed.\n    assert.deepEqual(await collapsedSectionTitles(), [COMPANIES_CHART]);\n    // And other sections are still there.\n    assert.deepEqual(await mainSectionTitles(), [COMPANIES, INVESTMENTS_CHART, INVESTMENTS]);\n    await gu.checkForErrors();\n    await revert();\n    await gu.checkForErrors();\n  });\n\n  it(\"should clear layout when dropped section is removed\", async () => {\n    await session.tempNewDoc(cleanup, \"CollapsedBug.grist\");\n    // Add a new section based on current table.\n    await gu.addNewSection(\"Table\", \"Table1\");\n    // It will have id 3 (1 is raw, 2 is visible).\n    // Collapse it.\n    await gu.renameActiveSection(\"ToDelete\");\n    await collapseByMenu(\"ToDelete\");\n    // Remove it from the tray.\n    await openCollapsedSectionMenu(\"ToDelete\");\n    await driver.find(\".test-section-delete\").click();\n    await gu.waitForServer();\n    await waitForSave();\n    // Now add another one, it will have the same id (3) and it used to be collapsed when added\n    // as the layout was not cleared.\n    await gu.addNewSection(\"Table\", \"Table1\");\n    // Make sure it is expanded.\n    assert.deepEqual(await mainSectionTitles(), [\"TABLE1\", \"TABLE1\"]);\n    assert.deepEqual(await collapsedSectionTitles(), []);\n  });\n});\n\nasync function addToMainByMenu(section: string) {\n  await openCollapsedSectionMenu(section);\n  await driver.find(\".test-section-expand\").click();\n  await gu.waitForServer();\n  await gu.checkForErrors();\n}\n\nasync function dragCollapsed(section: string) {\n  const handle = getCollapsedSection(section).find(\".draggable-handle\");\n  await driver.withActions(actions => actions\n    .move({ origin: handle })\n    .press());\n  await move(handle, { x: 10, y: 10 });\n  return handle;\n}\n\nasync function dragMain(section: string) {\n  const handle = gu.getSection(section).find(\".viewsection_drag_indicator\");\n  await driver.withActions(actions => actions\n    .move({ origin: handle }));\n  await driver.withActions(actions => actions\n    .move({ origin: handle, x: 1 }) // This is needed to show the drag element.\n    .press());\n  await move(handle, { x: 10, y: 10 });\n  return handle;\n}\n\nasync function openCollapsedSection(section: string) {\n  await getCollapsedSection(section).find(\".draggable-handle\").click();\n}\n\nasync function removeMiniSection(section: string) {\n  await openCollapsedSectionMenu(section);\n  await driver.find(\".test-section-delete\").click();\n  await gu.waitForServer();\n  await gu.checkForErrors();\n}\n\nasync function collapseByMenu(section: string) {\n  await gu.openSectionMenu(\"viewLayout\", section);\n  await driver.find(\".test-section-collapse\").click();\n  await gu.waitForServer();\n  await gu.checkForErrors();\n}\n\n// Returns the titles of all collapsed sections.\nasync function collapsedSectionTitles() {\n  return await layoutTray().findAll(\".test-layoutTray-leaf-box .test-collapsed-section-title\", e => e.getText());\n}\n\n// Returns titles of all sections in the view layout.\nasync function mainSectionTitles() {\n  return await driver.findAll(\".layout_root .test-viewsection-title\", e => e.getText());\n}\n\nasync function move(element: WebElementPromise | WebElement, offset: { x?: number, y?: number } = { x: 0, y: 0 }) {\n  // With current version of webdriver, a fractional values will get ignored, so round to nearest.\n  if (offset.x) { offset.x = Math.round(offset.x); }\n  if (offset.y) { offset.y = Math.round(offset.y); }\n  await driver.withActions(actions => actions.move({ origin: element, ...offset }));\n}\n\nfunction getDragElement(section: string) {\n  return gu.getSection(section).find(\".viewsection_drag_indicator\");\n}\n\nfunction layoutTray() {\n  return driver.find(\".test-layoutTray-layout\");\n}\n\nfunction firstLeaf() {\n  return layoutTray().find(\".test-layoutTray-leaf-box\");\n}\n\nfunction layoutEditor() {\n  return driver.find(\".test-layoutTray-editor\");\n}\n\nconst COMPANIES_CHART = \"COMPANIES [by category_code] Chart\";\nconst INVESTMENTS_CHART = \"INVESTMENTS [by funded_year] Chart\";\nconst COMPANIES = \"COMPANIES [by category_code]\";\nconst INVESTMENTS = \"INVESTMENTS [by funded_year]\";\n\nfunction assertDistance(left: number, right: number, max: number) {\n  return assert.isBelow(Math.abs(left - right), max);\n}\n\nasync function waitForSave() {\n  await gu.waitToPass(async () => {\n    const pending = await driver.findAll(\".test-viewLayout-save-pending\");\n    assert.isTrue(pending.length === 0);\n    await gu.waitForServer();\n  }, 3000);\n}\n\nasync function visibleTables() {\n  await driver.findWait(\".test-dp-add-new\", 2000).doClick();\n  await gu.findOpenMenu();\n  await driver.find(\".test-dp-add-new-page\").doClick();\n  await driver.findWait(\".test-wselect-heading\", 100);\n  const titles = await driver.findAll(\".test-wselect-table\", e => e.getText());\n  await gu.sendKeys(Key.ESCAPE);\n  return titles.map(x => x.trim()).filter(Boolean).filter(x => x !== \"New Table\");\n}\n"
  },
  {
    "path": "test/nbrowser/ViewLayoutUtils.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\nexport async function closeExpandedSection() {\n  await driver.find(\".test-viewLayout-overlay .test-close-button\").click();\n  await gu.waitToPass(async () => {\n    assert.isFalse(await driver.find(\".test-viewLayout-overlay\").matches(\"[class*=-active]\"));\n  });\n}\n\nexport async function sectionIsExpanded() {\n  // Check that we see the overlay\n  assert.isTrue(await driver.find(\".test-viewLayout-overlay\").matches(\"[class*=-active]\"));\n\n  // Visually check that the section is expanded.\n  assert.isTrue(await driver.find(\".active_section\").isDisplayed());\n  const section = await driver.find(\".active_section\").getRect();\n  const doc = await driver.find(\".test-gristdoc\").getRect();\n  assert.isTrue(Math.abs(section.height + 48 - doc.height) < 4);\n  assert.isTrue(Math.abs(section.width + 112 - doc.width) < 4);\n\n  // Get all other sections on the page and make sure they are hidden.\n  const otherSections = await driver.findAll(\".view_leaf:not(.active_section)\");\n  for (const otherSection of otherSections) {\n    assert.isFalse(await otherSection.isDisplayed());\n  }\n\n  // Make sure we see the close button.\n  assert.isTrue(await driver.find(\".test-viewLayout-overlay .test-close-button\").isDisplayed());\n}\n\n/**\n * Opens the section menu for a collapsed section.\n */\nexport async function openCollapsedSectionMenu(section: string | RegExp) {\n  await getCollapsedSection(section).find(`.test-section-menu-viewLayout`).click();\n  await gu.findOpenMenu(100);\n}\n\nexport function getCollapsedSection(section: string | RegExp) {\n  if (typeof section === \"string\") {\n    section = gu.exactMatch(section, \"i\");\n  }\n  return driver.findContentWait(\".test-collapsed-section .test-collapsed-section-title\", section, 100)\n    .findClosest(\".test-collapsed-section\");\n}\n"
  },
  {
    "path": "test/nbrowser/Views.ntest.js",
    "content": "import { assert } from \"mocha-webdriver\";\nimport { $, gu, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\ndescribe(\"Views.ntest\", function() {\n  test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await gu.actions.createNewDoc();\n  });\n\n  afterEach(function() {\n    return gu.checkForErrors();\n  });\n\n  it(\"should allow adding and removing viewsections\", async function() {\n    // Create two new viewsections\n    await gu.actions.addNewSection(\"Table1\", \"Table\");\n    var recordSection = $(\".test-gristdoc .view_leaf\").eq(0);\n    var gridSection = $(\".test-gristdoc .view_leaf\").eq(1);\n\n    // Check that the newest viewsection has focus\n    await assert.hasClass(gridSection, \"active_section\");\n\n    await gu.actions.addNewSection(\"Table1\", \"Card\");\n    var cardSection = $(\".test-gristdoc .view_leaf\").eq(2);\n    assert.lengthOf(await $(\".test-gristdoc .view_leaf\").array(), 3);\n\n    // Check that the newest viewsection has focus\n    await assert.hasClass(cardSection, \"active_section\");\n\n    // Click the second viewsection and check that it has focus\n    await gridSection.click();\n    await assert.hasClass(gridSection, \"active_section\");\n\n    // Check that viewsection titles are correct and editable\n    var recordTitle = recordSection.find(\".test-viewsection-title\");\n    assert.equal(await recordTitle.text(), \"TABLE1\");\n    await recordTitle.click();\n    await gu.renameActiveSection(\"foo\");\n    assert.equal(await recordTitle.text(), \"foo\");\n\n    // Delete the first viewsection and check that it`s gone\n    await gu.actions.viewSection(\"foo\").selectMenuOption(\"viewLayout\", \"Delete widget\");\n    await gu.waitForServer();\n\n    assert.lengthOf(await $(\".test-gristdoc .view_leaf\").array(), 2);\n  });\n\n  it(\"should allow creating a view section for a new table\", async function() {\n    assert.lengthOf(await $(\".test-gristdoc .view_leaf\").array(), 2);\n    await gu.actions.addNewSection(\"New\", \"Card List\");\n    var newTitle = $(\".test-gristdoc .test-viewsection-title\").eq(2).wait();\n    assert.equal(await newTitle.text(), \"TABLE2 Card List\");\n  });\n\n  it(\"should not create two views when creating a new view for a new table\", async function() {\n    // This is probably test for a bug. Currently adding new tables to an existing view\n    // doesn't produce new views.\n    assert.deepEqual(await $(`.test-docpage-label`).array().text(), [\"Table1\"]);\n    await gu.actions.addNewView(\"New\", \"Table\");\n    assert.deepEqual(await $(`.test-docpage-label`).array().text(), [\"Table1\", \"Table3\"]);\n  });\n\n  it(\"should switch to a valid default view when the active view is deleted\", async function() {\n    // This confirms a bug fix where the default view should change when the current\n    // default view is deleted\n    await gu.actions.selectTabView(\"Table1\");\n    assert.equal(await gu.actions.getActiveTab().text(), \"Table1\");\n    assert.deepEqual(await $(`.test-docpage-label`).array().text(), [\"Table1\", \"Table3\"]);\n    await gu.actions.tableView(\"Table1\").selectOption(\"Remove\");\n    await $(\".test-option-page\").click();\n    await $(\".test-modal-confirm\").click();\n    await gu.waitForServer();\n    assert.equal(await gu.actions.getActiveTab().text(), \"Table3\");\n    assert.deepEqual(await $(`.test-docpage-label`).array().text(), [\"Table3\"]);\n  });\n\n  it(\"should allow adding and removing summary view sections\", async function() {\n    await gu.removeTable(\"Table1\");\n    await gu.actions.addNewTable();\n    assert.equal(await gu.actions.getActiveTab().text(), \"Table1\");\n\n    await gu.enterGridValues(0, 0, [\n      [\"a\", \"a\", \"b\"],\n      [\"c\", \"d\", \"d\"],\n      [\"1\", \"2\", \"3\"]\n    ]);\n\n    await gu.actions.addNewSummarySection(\"Table1\", [\"A\"], \"Table\", \"Section Foo\");\n    assert.deepEqual(await gu.getGridValues({\n      section: \"Section Foo\",\n      rowNums: [1, 2],\n      cols: [0, 1, 2]\n    }), [\"a\", \"2\", \"3\", \"b\", \"1\", \"3\"]);\n\n    await gu.actions.viewSection(\"Section Foo\").selectMenuOption(\"viewLayout\", \"Delete widget\");\n    await gu.waitForServer();\n\n    await gu.actions.addNewSummarySection(\"Table1\", [\"B\"], \"Table\", \"Section Foo\");\n    assert.deepEqual(await gu.getGridValues({\n      section: \"Section Foo\",\n      rowNums: [1, 2],\n      cols: [0, 1, 2]\n    }), [\"c\", \"1\", \"1\", \"d\", \"2\", \"5\"]);\n  });\n\n  it(\"should switch to a valid default section when the active section is deleted\", async function() {\n    // This confirms a bug fix where the section should change when the active section is\n    // deleted, either directly or via the section's table being deleted.\n    // Reference: https://phab.getgrist.com/T327\n    await gu.actions.addNewSection(\"New\", \"Table\");\n    await gu.waitForServer();\n    await gu.actions.viewSection(\"TABLE4\").selectSection();\n    // Delete the section\n    await gu.deleteWidgetWithData(\"TABLE4\");\n    // Assert that the default section (Table1 record) is now active.\n    assert.equal(await $(\".active_section > .viewsection_title\").text(), \"TABLE1\");\n    // Assert that focus is returned to the deleted section on undo.\n    await gu.undo();\n    assert.equal(await $(\".active_section > .viewsection_title\").text(), \"TABLE4\");\n    // Add a sorted column to the new table. The reported bug shows a symptom of the problem is\n    // errors thrown when a sorted column exists in the deleted table.\n    await gu.clickCellRC(0, 0);\n    await gu.sendKeys(\"b\", $.ENTER);\n    await gu.waitForServer();\n    await gu.sendKeys(\"a\", $.ENTER);\n    await gu.waitForServer();\n    await gu.sendKeys(\"c\", $.ENTER);\n    await gu.waitForServer();\n    await gu.openColumnMenu(\"A\");\n    await $(`.grist-floating-menu .test-sort-asc`).click();\n    // Delete the table.\n    await gu.sendActions([[\"RemoveTable\", \"Table4\"]]);\n    await gu.actions.selectTabView(\"Table1\");\n    // Assert that the default section (Table1 record) is now active.\n    assert.equal(await $(\".active_section > .viewsection_title\").text(), \"TABLE1\");\n    // Again, assert that focus is returned to the deleted section on undo.\n    await gu.undo();\n    assert.equal(await $(\".active_section > .viewsection_title\").text(), \"TABLE4\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/VisibleFieldsConfig.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { addToRepl, assert, driver, stackWrapFunc } from \"mocha-webdriver\";\n\ndescribe(\"VisibleFieldsConfig\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n  addToRepl(\"findField\", findField);\n\n  function findField(state: \"visible\" | \"hidden\", content: RegExp) {\n    return driver.findContent(`.test-vfc-${state}-fields .kf_draggable`, content);\n  }\n\n  async function isSelected(state: \"visible\" | \"hidden\", content: RegExp): Promise<boolean> {\n    return Boolean(await findField(state, content).find(\"input\").getAttribute(\"checked\"));\n  }\n\n  it(\"should support hiding/revealing a column with single click\", async function() {\n    // create new document\n    await server.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n    const docId = await gu.createNewDoc(\"Chimpy\", \"nasa\", \"Horizon\", \"VisibleFieldsConfig_test\");\n    await gu.loadDoc(`/o/nasa/doc/${docId}`);\n\n    // check 'A', 'B', 'C' are visible\n    assert.deepEqual(await driver.findAll(\".g-column-label\", e => e.getText()), [\"A\", \"B\", \"C\"]);\n\n    // open right panel\n    await gu.toggleSidePanel(\"right\", \"open\");\n\n    // hide 'A'\n    await findField(\"visible\", /A/).mouseMove().find(\".test-vfc-hide\").click();\n    await gu.waitForServer();\n\n    // check 'B', 'C' are visible\n    assert.deepEqual(await driver.findAll(\".g-column-label\", e => e.getText()), [\"B\", \"C\"]);\n\n    // reveal 'A'\n    await findField(\"hidden\", /A/).mouseMove().find(\".test-vfc-hide\").click();\n    await gu.waitForServer();\n\n    // check 'B', 'C', 'A' are visible\n    assert.deepEqual(await driver.findAll(\".g-column-label\", e => e.getText()), [\"B\", \"C\", \"A\"]);\n  });\n\n  it(\"hiding should work even when the hidden fields are collapsed\", async function() {\n    // collapse hidden fields\n    await driver.find(\".test-vfc-collapse-hidden\").click();\n\n    // hide 'A'\n    await findField(\"visible\", /A/).mouseMove().find(\".test-vfc-hide\").click();\n    await gu.waitForServer();\n\n    // uncollapse hidden fields\n    await driver.find(\".test-vfc-collapse-hidden\").click();\n\n    // check 'A' is listed as hidden\n    assert.deepEqual(\n      await driver.findAll(`.test-vfc-hidden-fields .kf_draggable`, e => e.getText()),\n      [\"A\"],\n    );\n\n    // check 'A' is hidden\n    assert.deepEqual(await driver.findAll(\".g-column-label\", e => e.getText()), [\"B\", \"C\"]);\n\n    // undo\n    await gu.undo();\n  });\n\n  it(\"should support reordering with drag and drop\", async function() {\n    // Drag 'B' below 'C'\n    await driver.withActions(actions => (\n      actions\n        .move({ origin: findField(\"visible\", /B/) })\n        .move({ origin: findField(\"visible\", /B/).find(\".test-dragger\") })\n        .press()\n        .move({ origin: findField(\"visible\", /C/), y: 1 })\n        .release()\n    ));\n    await gu.waitForServer();\n\n    // check 'C', 'B', 'A' are visible\n    assert.deepEqual(await driver.findAll(\".g-column-label\", e => e.getText()), [\"C\", \"B\", \"A\"]);\n  });\n\n  it(\"should allow to hide multiple columns\", async function() {\n    // check that initally 'Hide ...' and 'Clear' buttons are hidden\n    assert.equal(await driver.find(\".test-vfc-visible-batch-buttons\").isPresent(), false);\n\n    // select 'A'\n    await findField(\"visible\", /A/).find(\"input\").click();\n\n    // check that buttons are visible\n    assert.equal(await driver.find(\".test-vfc-visible-batch-buttons\").isPresent(), true);\n\n    // un-select 'A'\n    await findField(\"visible\", /A/).find(\"input\").click();\n\n    // check that buttons are hidden\n    assert.equal(await driver.find(\".test-vfc-visible-batch-buttons\").isPresent(), false);\n\n    // select All\n    await driver.find(\".test-vfc-visible-fields-select-all\").click();\n\n    // check that buttons are visible\n    assert.equal(await driver.find(\".test-vfc-visible-batch-buttons\").isPresent(), true);\n\n    // click 'Clear'\n    await driver.find(\".test-vfc-visible-batch-buttons\").findContent(\"button\", /Clear/).click();\n\n    // check that buttons are hidden\n    assert.equal(await driver.find(\".test-vfc-visible-batch-buttons\").isPresent(), false);\n\n    // select All and unselect 'A'\n    await driver.find(\".test-vfc-visible-fields-select-all\").click();\n    await findField(\"visible\", /A/).find(\"input\").click();\n\n    // click 'Hide  ...' button\n    await driver.find(\".test-vfc-visible-batch-buttons\").findContent(\"button\", /Hide/).click();\n\n    // wait for server and check that 'A' is visible\n    await gu.waitForServer();\n    assert.deepEqual(await driver.findAll(\".g-column-label\", e => e.getText()), [\"A\"]);\n  });\n\n  it(\"should allow to 'Show' multiple column\", async function() {\n    // check that buttons are not present\n    assert.equal(await driver.find(\".test-vfc-hidden-batch-buttons\").isPresent(), false);\n\n    // select 'B', 'C'\n    await findField(\"hidden\", /B/).find(\"input\").click();\n    await findField(\"hidden\", /C/).find(\"input\").click();\n\n    // check that buttons are visible\n    assert.equal(await driver.find(\".test-vfc-hidden-batch-buttons\").isPresent(), true);\n\n    // click 'Show  ...' button and check that 'A', 'B', 'C' are visible\n    await driver.find(\".test-vfc-hidden-batch-buttons\").findContent(\"button\", /Show/).click();\n    await gu.waitForServer();\n    assert.deepEqual(await driver.findAll(\".g-column-label\", e => e.getText()), [\"A\", \"B\", \"C\"]);\n\n    // check that buttons are not present\n    assert.equal(await driver.find(\".test-vfc-hidden-batch-buttons\").isPresent(), false);\n  });\n\n  it(\"hidden fields should not lose selection when a field is hidden\", async function() {\n    // This test makes sure the state survives a dom rebuild, which does happens when hiding a field\n\n    // hide 'B'\n    await findField(\"visible\", /B/).mouseMove().find(\".test-vfc-hide\").click();\n    await gu.waitForServer();\n\n    // select 'B'\n    await findField(\"hidden\", /B/).find(\"input\").click();\n\n    // check 'B' is selected\n    assert.equal(await isSelected(\"hidden\", /B/), true);\n\n    // hide 'A'\n    await findField(\"visible\", /A/).mouseMove().find(\".test-vfc-hide\").click();\n    await gu.waitForServer();\n\n    // check 'B' is still selected\n    assert.equal(await isSelected(\"hidden\", /B/), true);\n\n    await gu.undo();\n    await gu.undo();\n  });\n\n  it(\"should be disabled while editing a card layout\", async function() {\n    const checkDisabled = async (disabled: boolean) => {\n      try {\n        await findField(\"visible\", /A/).find(\"input\").click();\n      } catch (e) {\n        if (!disabled) {\n          throw e;\n        }\n      }\n      assert.equal(await isSelected(\"visible\", /A/), !disabled);\n      assert.equal(await driver.find(\".test-vfc-visible-batch-buttons\").isPresent(), !disabled);\n      try {\n        await findField(\"hidden\", /B/).find(\"input\").click();\n      } catch (e) {\n        if (!disabled) {\n          throw e;\n        }\n      }\n      assert.equal(await isSelected(\"hidden\", /B/), !disabled);\n      assert.equal(await driver.find(\".test-vfc-hidden-batch-buttons\").isPresent(), !disabled);\n    };\n\n    await gu.addNewSection(\"Card\", \"Table1\");\n    await findField(\"visible\", /B/).mouseMove().find(\".test-vfc-hide\").click();\n    await gu.waitForServer();\n\n    await driver.find(\".test-vconfigtab-detail-edit-layout\").click();\n    await checkDisabled(true);\n    assert.isNotNull(await findField(\"visible\", /A/).mouseMove().find(\".test-vfc-hide\").getAttribute(\"disabled\"));\n    assert.isNotNull(await findField(\"hidden\", /B/).mouseMove().find(\".test-vfc-hide\").getAttribute(\"disabled\"));\n\n    await driver.findContent(\".test-edit-layout-controls button\", \"Cancel\").click();\n    await checkDisabled(false);\n    await findField(\"visible\", /A/).mouseMove().find(\".test-vfc-hide\").click();\n    await gu.waitForServer();\n    assert.deepEqual(\n      await driver.findAll(`.test-vfc-visible-fields .kf_draggable`, e => e.getText()),\n      [\"C\"],\n    );\n    await findField(\"hidden\", /B/).mouseMove().find(\".test-vfc-hide\").click();\n    await gu.waitForServer();\n    assert.deepEqual(\n      await driver.findAll(`.test-vfc-hidden-fields .kf_draggable`, e => e.getText()),\n      [\"A\"],\n    );\n\n    await gu.undo(4);\n  });\n\n  describe(\"multi selection\", () => {\n    // tests multi selection for both the list of visible fields and the list of hidden fields.\n\n    describe(\"visible fields\", function() {\n      stackWrapFunc(testMultiSelection)(\"visible\");\n    });\n\n    describe(\"hidden fields\", function() {\n      it(\"initialize test\", async function() {\n        // testMultiSelection expects all fields to be in the draggable under test, so we need to\n        // hide all fields.\n        await driver.find(`.test-vfc-visible-fields-select-all`).click();\n        await driver.find(\".test-vfc-visible-batch-buttons\").findContent(\"button\", /Hide/).click();\n        await gu.waitForServer();\n\n        // check all fields are present in the hidden draggable\n        assert.deepEqual(\n          await driver.findAll(`.test-vfc-hidden-fields .kf_draggable`, e => e.getText()),\n          [\"A\", \"B\", \"C\"],\n        );\n      });\n\n      stackWrapFunc(testMultiSelection)(\"hidden\");\n    });\n  });\n\n  function testMultiSelection(state: \"hidden\" | \"visible\") {\n    function findButtons() {\n      return driver.find(`.test-vfc-${state}-batch-buttons`);\n    }\n\n    function isFieldSelected(field: RegExp) {\n      return isSelected(state, field);\n    }\n\n    function toggle(field: RegExp) {\n      return findField(state, field).find(\"input\").click();\n    }\n\n    async function applyBatchAction() {\n      const content = state === \"hidden\" ? /Show/ : /Hide/;\n      await driver.find(`.test-vfc-${state}-batch-buttons`).findContent(\"button\", content).click();\n      await gu.waitForServer();\n    }\n\n    it(\"'Select All'should work correctly\", async function() {\n      // check 'A', 'B', 'C' are present\n      assert.deepEqual(\n        await driver.findAll(`.test-vfc-${state}-fields .kf_draggable`, e => e.getText()),\n        [\"A\", \"B\", \"C\"],\n      );\n\n      // click select All\n      await driver.find(`.test-vfc-${state}-fields-select-all`).click();\n\n      // check all checkbox are selected\n      assert.equal(await isFieldSelected(/A/), true);\n      assert.equal(await isFieldSelected(/B/), true);\n      assert.equal(await isFieldSelected(/C/), true);\n\n      // check buttons are present\n      assert.equal(await findButtons().isPresent(), true);\n\n      // apply action\n      await applyBatchAction();\n\n      // check 'A', 'B', 'C' are not present\n      assert.deepEqual(\n        await driver.findAll(`.test-vfc-${state}-fields .kf_draggable`, e => e.getText()),\n        [],\n      );\n\n      // check button are hidden\n      assert.equal(await findButtons().isPresent(), false);\n\n      // undo\n      await gu.undo();\n    });\n\n    it(\"'Clear' should work correctly\", async function() {\n      // select 'A', 'B'\n      await toggle(/A/);\n      await toggle(/B/);\n\n      // check 'A', 'B' is selected\n      assert.equal(await isFieldSelected(/A/), true);\n      assert.equal(await isFieldSelected(/B/), true);\n\n      // click Clear\n      await driver.find(`.test-vfc-${state}-batch-buttons`).findContent(\"button\", /Clear/).click();\n\n      // check 'A', 'B' is not selected\n      assert.equal(await isFieldSelected(/A/), false);\n      assert.equal(await isFieldSelected(/B/), false);\n    });\n\n    it(\"Buttons should show only when some are checked\", async function() {\n      // check button are not present,w\n      assert.equal(await findButtons().isPresent(), false);\n\n      // Select 'A'\n      await toggle(/A/);\n\n      // check buttons are present\n      assert.equal(await findButtons().isPresent(), true);\n\n      // Select 'B', unselect 'A'\n      await toggle(/A/);\n      await toggle(/B/);\n\n      // check buttons are still present\n      assert.equal(await findButtons().isPresent(), true);\n\n      // Hide 'B'\n      await findField(state, /B/).mouseMove().find(\".test-vfc-hide\").click();\n      await gu.waitForServer();\n\n      // check buttons are not present,\n      assert.equal(await findButtons().isPresent(), false);\n\n      // select 'A'\n      await toggle(/A/);\n\n      // apply batch action\n      await applyBatchAction();\n\n      // check buttons are not present\n      assert.equal(await findButtons().isPresent(), false);\n\n      // undo, undo\n      await gu.undo();\n      await gu.undo();\n\n      // select 'A', hide by clicking the icon and check button are not present\n      await toggle(/A/);\n      await findField(state, /A/).mouseMove().find(\".test-vfc-hide\").click();\n      await gu.waitForServer();\n      assert.equal(await findButtons().isPresent(), false);\n      await gu.undo();\n\n      // select A and hide it by clicking redo, and check button are not present\n      await toggle(/A/);\n      await gu.redo();\n      assert.equal(await findButtons().isPresent(), false);\n      await gu.undo();\n    });\n\n    it(\"'Select All' should not be visible when the list is empty\", async function() {\n      // click select All\n      await driver.find(`.test-vfc-${state}-fields-select-all`).click();\n\n      // apply batch action\n      await applyBatchAction();\n\n      // check that select all is not present\n      assert.equal(\n        await driver.find(`.test-vfc-${state}-fields-select-all`).isPresent(),\n        false,\n      );\n\n      // undo\n      await gu.undo();\n    });\n  }\n});\n"
  },
  {
    "path": "test/nbrowser/WebhookOverflow.ts",
    "content": "import { DocCreationInfo } from \"app/common/DocListAPI\";\nimport { WebhookFields } from \"app/common/Triggers\";\nimport { DocAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"WebhookOverflow\", function() {\n  this.timeout(30000);\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n  let oldEnv: EnvironmentSnapshot;\n  let doc: DocCreationInfo;\n  let docApi: DocAPI;\n  gu.bigScreen();\n\n  before(async function() {\n    oldEnv = new EnvironmentSnapshot();\n    process.env.ALLOWED_WEBHOOK_DOMAINS = \"*\";\n    process.env.GRIST_MAX_QUEUE_SIZE = \"4\";\n    await server.restart();\n    session = await gu.session().teamSite.login();\n    const api = session.createHomeApi();\n    doc = await session.tempDoc(cleanup, \"Hello.grist\");\n    docApi = api.getDocAPI(doc.id);\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Table2\", [{ id: \"A\" }, { id: \"B\" }, { id: \"C\" }, { id: \"D\" }, { id: \"E\" }]],\n      [\"AddRecord\", \"Table2\", null, {}],\n    ]);\n    const webhookDetails: WebhookFields = {\n      url: \"https://localhost/WrongWebhook\",\n      eventTypes: [\"update\"],\n      enabled: true,\n      name: \"test webhook\",\n      tableId: \"Table2\",\n      watchedColIds: [],\n    };\n    await docApi.addWebhook(webhookDetails);\n    await docApi.addWebhook(webhookDetails);\n  });\n\n  after(async function() {\n    oldEnv.restore();\n    await server.restart();\n  });\n\n  async function enterCellWithoutWaitingOnServer(...keys: string[]) {\n    const lastKey = keys[keys.length - 1];\n    if (![Key.ENTER, Key.TAB, Key.DELETE].includes(lastKey)) {\n      keys.push(Key.ENTER);\n    }\n    await driver.sendKeys(...keys);\n  }\n\n  async function getNumWaiting() {\n    const cells = await gu.getVisibleDetailCells({ col: \"Status\", rowNums: [1, 2] });\n    return cells.map((cell) => {\n      const status = JSON.parse(cell.replace(/\\n/g, \"\"));\n      return status.numWaiting;\n    });\n  }\n\n  async function overflowWebhook() {\n    await gu.openPage(\"Table2\");\n    await gu.getCell(\"A\", 1).click();\n    await gu.enterCell(new Date().toString());\n    await gu.getCell(\"B\", 1).click();\n    await enterCellWithoutWaitingOnServer(new Date().toString());\n    await gu.waitToPass(async () => {\n      const toast = await gu.getToasts();\n      assert.include(toast, \"New changes are temporarily suspended. Webhooks queue overflowed.\" +\n      \" Please check webhooks settings, remove invalid webhooks, and clean the queue.\\ngo to webhook settings\");\n    }, 4000);\n  }\n\n  async function overflowResolved() {\n    await gu.waitForServer();\n    await gu.waitToPass(async () => {\n      const toast = await gu.getToasts();\n      assert.notInclude(toast, \"New changes are temporarily suspended. Webhooks queue overflowed.\" +\n      \" Please check webhooks settings, remove invalid webhooks, and clean the queue.\\ngo to webhook settings\");\n    }, 12500);\n  }\n\n  it(\"should show a message when overflowed\", async function() {\n    await overflowWebhook();\n  });\n\n  it(\"message should disappear after clearing queue\", async function() {\n    await openWebhookPageWithoutWaitForServer();\n    assert.deepEqual(await getNumWaiting(), [2, 2]);\n    await driver.findContent(\"button\", /Clear queue/).click();\n    await overflowResolved();\n    assert.deepEqual(await getNumWaiting(), [0, 0]);\n  });\n\n  it(\"should clear a single webhook queue when that webhook is disabled\", async function() {\n    await overflowWebhook();\n    await openWebhookPageWithoutWaitForServer();\n    await gu.waitToPass(async () => {\n      assert.deepEqual(await getNumWaiting(), [2, 2]);\n    }, 4000);\n    await gu.getDetailCell({ col: \"Enabled\", rowNum: 1 }).click();\n    await overflowResolved();\n    assert.deepEqual(await getNumWaiting(), [0, 2]);\n  });\n});\n\nasync function openWebhookPageWithoutWaitForServer() {\n  await openDocumentSettings();\n  const button = await driver.findContentWait(\"a\", /Manage Webhooks/i, 3000);\n  await gu.scrollIntoView(button).click();\n  await waitForWebhookPage();\n}\n\nasync function waitForWebhookPage() {\n  await driver.findContentWait(\"button\", /Clear queue/, 3000);\n  // No section, so no easy utility for setting focus. Click on a random cell.\n  await gu.waitToPass(async () => {\n    await gu.getDetailCell({ col: \"Webhook Id\", rowNum: 1 }).click();\n  });\n}\n\nexport async function openAccountMenu() {\n  await driver.findWait(\".test-dm-account\", 1000).click();\n  // Since the AccountWidget loads orgs and the user data asynchronously, the menu\n  // can expand itself causing the click to land on a wrong button.\n  await driver.findWait(\".test-site-switcher-org\", 1000);\n  await driver.sleep(250);  // There's still some jitter (scroll-bar? other user accounts?)\n}\n\nexport async function openDocumentSettings() {\n  await openAccountMenu();\n  await driver.findContent(\".grist-floating-menu a\", \"Document settings\").click();\n  await gu.waitForUrl(/settings/, 5000);\n}\n"
  },
  {
    "path": "test/nbrowser/WebhookPage.ts",
    "content": "import { DocCreationInfo } from \"app/common/DocListAPI\";\nimport { DocAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"WebhookPage\", function() {\n  this.timeout(60000);\n  const cleanup = setupTestSuite();\n  const clipboard = gu.getLockableClipboard();\n\n  let session: gu.Session;\n  let oldEnv: EnvironmentSnapshot;\n  let docApi: DocAPI;\n  let doc: DocCreationInfo;\n  let host: string;\n\n  before(async function() {\n    oldEnv = new EnvironmentSnapshot();\n    host = new URL(server.getHost()).host;\n    process.env.ALLOWED_WEBHOOK_DOMAINS = \"*\";\n    await server.restart();\n    session = await gu.session().teamSite.login();\n    const api = session.createHomeApi();\n    doc = await session.tempDoc(cleanup, \"Hello.grist\");\n    docApi = api.getDocAPI(doc.id);\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Table2\", [{ id: \"A\" }, { id: \"B\" }, { id: \"C\" }, { id: \"D\" }, { id: \"E\" }]],\n    ]);\n    await api.applyUserActions(doc.id, [\n      [\"AddTable\", \"Table3\", [{ id: \"A\" }, { id: \"B\" }, { id: \"C\" }, { id: \"D\" }, { id: \"E\" }]],\n    ]);\n    await api.updateDocPermissions(doc.id, {\n      users: {\n        // for convenience, we'll be sending payloads to the document itself.\n        \"anon@getgrist.com\": \"editors\",\n        // check another owner's perspective.\n        [gu.session().user(\"user2\").email]: \"owners\",\n      },\n    });\n  });\n\n  after(async function() {\n    oldEnv.restore();\n  });\n\n  it(\"starts with an empty card\", async function() {\n    await openWebhookPage();\n    assert.equal(await gu.getCardListCount(), 1);  // includes empty card\n    assert.sameDeepMembers(await gu.getCardFieldLabels(), [\n      \"Name\",\n      \"Memo\",\n      \"Event Types\",\n      \"Table\",\n      \"Filter for changes in these columns (semicolon-separated ids)\",\n      \"Ready Column\",\n      \"URL\",\n      \"Header Authorization\",\n      \"Webhook Id\",\n      \"Enabled\",\n      \"Status\",\n    ]);\n  });\n\n  it(\"can create a persistent webhook\", async function() {\n    // Set up a webhook for Table1, and send it to Table2 (for ease of testing).\n    await openWebhookPage();\n    await setField(1, \"Event Types\", \"add\\nupdate\\n\");\n    await setField(1, \"URL\", `http://${host}/api/docs/${doc.id}/tables/Table2/records?flat=1`);\n    await setField(1, \"Table\", \"Table1\");\n    // Once event types, URL, and table are set, the webhook is created.\n    // Up until that point, nothing we've entered is actually persisted,\n    // there is no back end for it.\n    await gu.waitToPass(async () => {\n      assert.include(await getField(1, \"Webhook Id\"), \"-\");\n    });\n    const id = await getField(1, \"Webhook Id\");\n    // Reload and make sure the webhook id is still there.\n    await driver.navigate().refresh();\n    await waitForWebhookPage();\n    await gu.waitToPass(async () => {\n      assert.equal(await getField(1, \"Webhook Id\"), id);\n    });\n    // Now other fields like name, memo, watchColIds, and Header Auth are persisted.\n    await setField(1, \"Name\", \"Test Webhook\");\n    await setField(1, \"Memo\", \"Test Memo\");\n    await setField(1, \"Filter for changes in these columns (semicolon-separated ids)\", \"A; B\");\n    await gu.waitForServer();\n    await driver.navigate().refresh();\n    await waitForWebhookPage();\n    await gu.waitToPass(async () => {\n      assert.equal(await getField(1, \"Name\"), \"Test Webhook\");\n      assert.equal(await getField(1, \"Memo\"), \"Test Memo\");\n      assert.equal(await getField(1, \"Filter for changes in these columns (semicolon-separated ids)\"), \"A;B\");\n    });\n    // Make sure the webhook is actually working.\n    await docApi.addRows(\"Table1\", { A: [\"zig\"], B: [\"zag\"] });\n    // Make sure the data gets delivered, and that the webhook status is updated.\n    await gu.waitToPass(async () => {\n      assert.lengthOf((await docApi.getRows(\"Table2\")).A, 1);\n      assert.equal((await docApi.getRows(\"Table2\")).A[0], \"zig\");\n      assert.match(await getField(1, \"Status\"), /status...success/);\n    });\n    // Remove the webhook and make sure it is no longer listed.\n    assert.equal(await gu.getCardListCount(), 2);\n    await gu.getDetailCell({ col: \"Name\", rowNum: 1 }).click();\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n    await gu.confirm(true, true);\n    await gu.waitForServer();\n    assert.equal(await gu.getCardListCount(), 1);\n    await driver.navigate().refresh();\n    await waitForWebhookPage();\n    assert.equal(await gu.getCardListCount(), 1);\n    await docApi.removeRows(\"Table2\", [1]);\n    assert.lengthOf((await docApi.getRows(\"Table2\")).A, 0);\n  });\n\n  it(\"can create webhook with persistant header authorization\", async function() {\n    // The webhook won't work because the header auth doesn't match the api key of the current test user.\n    await openWebhookPage();\n    await setField(1, \"Event Types\", \"add\\nupdate\\n\");\n    await setField(1, \"URL\", `http://${host}/api/docs/${doc.id}/tables/Table2/records?flat=1`);\n    await setField(1, \"Table\", \"Table1\");\n    await gu.waitForServer();\n    await driver.navigate().refresh();\n    await waitForWebhookPage();\n    await setField(1, \"Header Authorization\", \"Bearer 1234\");\n    await gu.waitForServer();\n    await driver.navigate().refresh();\n    await waitForWebhookPage();\n    await gu.waitToPass(async () => {\n      assert.equal(await getField(1, \"Header Authorization\"), \"Bearer 1234\");\n    });\n    await gu.getDetailCell({ col: \"Header Authorization\", rowNum: 1 }).click();\n    await gu.sendKeys(Key.DELETE);\n    await gu.waitForServer();\n  });\n\n  it(\"can create two webhooks\", async function() {\n    await openWebhookPage();\n    await setField(1, \"Event Types\", \"add\\nupdate\\n\");\n    await setField(1, \"URL\", `http://${host}/api/docs/${doc.id}/tables/Table2/records?flat=1`);\n    await setField(1, \"Table\", \"Table1\");\n    await gu.waitForServer();\n    await setField(2, \"Event Types\", \"add\\n\");\n    await setField(2, \"URL\", `http://${host}/api/docs/${doc.id}/tables/Table3/records?flat=1`);\n    await setField(2, \"Table\", \"Table1\");\n    await gu.waitForServer();\n    await docApi.addRows(\"Table1\", { A: [\"zig2\"], B: [\"zag2\"] });\n    await gu.waitToPass(async () => {\n      assert.lengthOf((await docApi.getRows(\"Table2\")).A, 1);\n      assert.lengthOf((await docApi.getRows(\"Table3\")).A, 1);\n      assert.match(await getField(1, \"Status\"), /status...success/);\n      assert.match(await getField(2, \"Status\"), /status...success/);\n    });\n    await docApi.updateRows(\"Table1\", { id: [1], A: [\"zig3\"], B: [\"zag3\"] });\n    await gu.waitToPass(async () => {\n      assert.lengthOf((await docApi.getRows(\"Table2\")).A, 2);\n      assert.lengthOf((await docApi.getRows(\"Table3\")).A, 1);\n      assert.match(await getField(1, \"Status\"), /status...success/);\n    });\n    await driver.sleep(100);\n    // confirm that nothing shows up to Table3.\n    assert.lengthOf((await docApi.getRows(\"Table3\")).A, 1);\n    // Break everything down.\n    await gu.getDetailCell({ col: \"Name\", rowNum: 1 }).click();\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n    await gu.confirm(true, true);\n    await gu.waitForServer();\n    await gu.getDetailCell({ col: \"Memo\", rowNum: 1 }).click();\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n    await gu.waitForServer();\n    assert.equal(await gu.getCardListCount(), 1);\n    await driver.navigate().refresh();\n    await waitForWebhookPage();\n    assert.equal(await gu.getCardListCount(), 1);\n    await docApi.removeRows(\"Table2\", [1, 2]);\n    await docApi.removeRows(\"Table3\", [1]);\n    assert.lengthOf((await docApi.getRows(\"Table2\")).A, 0);\n    assert.lengthOf((await docApi.getRows(\"Table3\")).A, 0);\n  });\n\n  it(\"can create and repair a dud webhook\", async function() {\n    await openWebhookPage();\n    await setField(1, \"Event Types\", \"add\\nupdate\\n\");\n    await setField(1, \"URL\", `http://${host}/notathing`);\n    await setField(1, \"Table\", \"Table1\");\n    await gu.waitForServer();\n    await docApi.addRows(\"Table1\", { A: [\"dud1\"] });\n    await gu.waitToPass(async () => {\n      assert.match(await getField(1, \"Status\"), /status...failure/);\n      assert.match(await getField(1, \"Status\"), /numWaiting..1/);\n    });\n    await setField(1, \"URL\", `http://${host}/api/docs/${doc.id}/tables/Table2/records?flat=1`);\n    await driver.findContent(\"button\", /Clear queue/).click();\n    await gu.waitForServer();\n    await gu.waitToPass(async () => {\n      assert.match(await getField(1, \"Status\"), /numWaiting..0/);\n    });\n    assert.lengthOf((await docApi.getRows(\"Table2\")).A, 0);\n    await docApi.addRows(\"Table1\", { A: [\"dud2\"] });\n    await gu.waitToPass(async () => {\n      assert.lengthOf((await docApi.getRows(\"Table2\")).A, 1);\n      assert.match(await getField(1, \"Status\"), /status...success/);\n    });\n\n    // Break everything down.\n    await gu.getDetailCell({ col: \"Name\", rowNum: 1 }).click();\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n    await gu.confirm(true, true);\n    await gu.waitForServer();\n    await docApi.removeRows(\"Table2\", [1]);\n    assert.lengthOf((await docApi.getRows(\"Table2\")).A, 0);\n  });\n\n  it(\"can keep multiple sessions in sync\", async function() {\n    await openWebhookPage();\n\n    // Open another tab.\n    await driver.executeScript(\"window.open('about:blank', '_blank')\");\n    const [ownerTab, owner2Tab] = await driver.getAllWindowHandles();\n\n    await driver.switchTo().window(owner2Tab);\n    const otherSession = await gu.session().teamSite.user(\"user2\").login();\n    await otherSession.loadDoc(`/doc/${doc.id}`);\n    await openWebhookPage();\n    await setField(1, \"Event Types\", \"add\\nupdate\\n\");\n    await setField(1, \"URL\", `http://${host}/multiple`);\n    await setField(1, \"Table\", \"Table1\");\n    await gu.waitForServer();\n    await driver.switchTo().window(ownerTab);\n    await gu.waitToPass(async () => {\n      assert.match(await getField(1, \"URL\"), /multiple/);\n    });\n    assert.equal(await gu.getCardListCount(), 2);\n    await setField(1, \"Memo\", \"multiple memo\");\n    await driver.switchTo().window(owner2Tab);\n    await gu.waitToPass(async () => {\n      assert.match(await getField(1, \"Memo\"), /multiple memo/);\n    });\n\n    // Basic undo support.\n    await driver.switchTo().window(ownerTab);\n    await gu.undo();\n    await gu.waitToPass(async () => {\n      assert.equal(await getField(1, \"Memo\"), \"\");\n    });\n    await driver.switchTo().window(owner2Tab);\n    await gu.waitToPass(async () => {\n      assert.equal(await getField(1, \"Memo\"), \"\");\n    });\n\n    // Basic redo support.\n    await driver.switchTo().window(ownerTab);\n    await gu.redo();\n    await gu.waitToPass(async () => {\n      assert.match(await getField(1, \"Memo\"), /multiple memo/);\n    });\n    await driver.switchTo().window(owner2Tab);\n    await gu.waitToPass(async () => {\n      assert.match(await getField(1, \"Memo\"), /multiple memo/);\n    });\n\n    await gu.getDetailCell({ col: \"Name\", rowNum: 1 }).click();\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.DELETE));\n    await gu.confirm(true, true);\n    await driver.switchTo().window(ownerTab);\n    await gu.waitToPass(async () => {\n      assert.equal(await gu.getCardListCount(), 1);\n    });\n    await driver.switchTo().window(owner2Tab);\n    await driver.close();\n    await driver.switchTo().window(ownerTab);\n  });\n\n  /**\n   * Checks that a particular route to modifying cells in a virtual table\n   * is in place (previously it was not).\n   */\n  it(\"can paste into a cell without clicking into it\", async function() {\n    await openWebhookPage();\n    await setField(1, \"Name\", \"1234\");\n    await gu.waitForServer();\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await gu.getDetailCell({ col: \"Memo\", rowNum: 1 }).click();\n      await cb.paste();\n    });\n    await gu.waitForServer();\n    assert.equal(await getField(1, \"Memo\"), \"1234\");\n  });\n\n  it(\"does not allow adding webhooks to forks\", async function() {\n    // First, let's make sure it's there to begin with.\n    await gu.wipeToasts();\n    await gu.openDocumentSettings();\n    await gu.scrollIntoView(driver.findContentWait(\"a\", /API/i, 3000));\n    assert.equal(await driver.find(\".test-admin-panel-item-name-webhooks\").isPresent(), true);\n\n    // Now let's make sure it's not in a fork\n    await driver.find(\".test-tb-share\").click();\n    await driver.find(\".test-work-on-copy\").click();\n    await gu.waitForUrl(/~/);\n    await gu.waitForDocToLoad();\n    await gu.wipeToasts();\n    await gu.openDocumentSettings();\n    await gu.scrollIntoView(driver.findContentWait(\"a\", /API/i, 3000));\n    assert.equal((await driver.findAll(\".test-admin-panel-item-name-webhooks\")).length, 0);\n  });\n});\n\nasync function setField(rowNum: number, col: string, text: string) {\n  await gu.getDetailCell({ col, rowNum }).click();\n  await gu.enterCell(text);\n}\n\nasync function getField(rowNum: number, col: string) {\n  const cell = await gu.getDetailCell({ col, rowNum });\n  return cell.getText();\n}\n\nasync function openWebhookPage() {\n  await gu.wipeToasts();\n  await gu.openDocumentSettings();\n  await gu.scrollIntoView(driver.findContentWait(\"a\", /Manage Webhooks/i, 3000)).click();\n  await waitForWebhookPage();\n}\n\nasync function waitForWebhookPage() {\n  await driver.findContentWait(\"button\", /Clear queue/, 3000);\n  // No section, so no easy utility for setting focus. Click on a random cell.\n  await gu.getDetailCell({ col: \"Webhook Id\", rowNum: 1 }).click();\n}\n"
  },
  {
    "path": "test/nbrowser/aclTestUtils.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\n/**\n * Find .test-rule-table element for the given tableId.\n */\nexport function findTable(tableId: RegExp | \"*\"): WebElement {\n  const header = driver.findContent(\".test-rule-table-header\", tableId === \"*\" ? \"Default rules\" : tableId);\n  return header.findClosest(\".test-rule-table\");\n}\n\n/**\n * Wait for .test-rule-table element for the given tableId.\n */\nexport function findTableWait(tableId: RegExp | \"*\"): WebElement {\n  const header = driver.findContentWait(\".test-rule-table-header\", tableId === \"*\" ? \"Default rules\" : tableId, 100);\n  return header.findClosest(\".test-rule-table\");\n}\n\n/**\n * Remove any rules within a .test-rule-table element, by hitting the trash buttons.\n */\nexport async function removeTable(tableId: RegExp | \"*\"): Promise<void> {\n  const header = driver.findContent(\".test-rule-table-header\", tableId === \"*\" ? \"Default rules\" : tableId);\n  if (await header.isPresent()) {\n    const table = header.findClosest(\".test-rule-table\");\n    await removeRules(table);\n  }\n}\n\n/**\n * Remove any rules within an element, by hitting the trash button.\n */\nexport async function removeRules(el: WebElement): Promise<void> {\n  while (true) {\n    const remove = el.find(\".test-rule-remove\");\n    if (!await remove.isPresent()) { break; }\n    await remove.click();\n  }\n}\n\n/**\n * Find .test-rule-set for the default rule set of the given tableId.\n */\nexport function findDefaultRuleSet(tableId: RegExp | \"*\"): WebElement {\n  const table = findTable(tableId);\n  const cols = table.findContent(\".test-rule-resource\", /All/);\n  return cols.findClosest(\".test-rule-set\");\n}\n\nexport function findDefaultRuleSetWait(tableId: RegExp | \"*\"): WebElement {\n  const table = findTableWait(tableId);\n  const cols = table.findContent(\".test-rule-resource\", /All/);\n  return cols.findClosest(\".test-rule-set\");\n}\n\n/**\n * Find a .test-rule-set at the given 1-based index, among the rule sets for the given tableId.\n */\nexport function findRuleSet(tableId: RegExp | \"*\", ruleNum: number): WebElement {\n  const table = findTable(tableId);\n  // Add one to skip table header element.\n  return table.find(`.test-rule-set:nth-child(${ruleNum + 1})`);\n}\n\n/**\n * Find a .test-rule-set at the given 1-based index, among the rule sets for the given tableId.\n * Wait for the table to be present first.\n */\nexport function findRuleSetWait(tableId: RegExp | \"*\", ruleNum: number): WebElement {\n  const table = findTableWait(tableId);\n  // Add one to skip table header element.\n  return table.find(`.test-rule-set:nth-child(${ruleNum + 1})`);\n}\n\n/**\n * PartNum should be 1-based. Permissions is either the text of an option in the permission\n * widget's dropdown menu (e.g. \"Allow All\") or a mapping of single-character bit to desired\n * state, e.g. {R: 'deny', U: 'allow', C: ''}.\n */\nexport async function enterRulePart(\n  ruleSet: WebElement,\n  partNum: number,\n  aclFormula: string | null,\n  permissions: string | { [bit: string]: string },\n  memo?: string,\n) {\n  const part = ruleSet.findWait(`.test-rule-part-and-memo:nth-child(${partNum}) .test-rule-part`, 100);\n  if (aclFormula !== null) {\n    await part.findWait(\".test-rule-acl-formula .ace_editor\", 500);\n    await part.find(\".test-rule-acl-formula\").doClick();\n    await driver.findWait(\".test-rule-acl-formula .ace_focus\", 500);\n    await gu.clearInput();     // Clear formula\n    await gu.sendKeys(aclFormula, Key.ENTER);\n  }\n  if (typeof permissions === \"string\") {\n    await part.find(\".test-rule-permissions .test-permissions-dropdown\").click();\n    await gu.findOpenMenuItem(\"li\", permissions).click();\n  } else {\n    for (const [bit, desired] of Object.entries(permissions)) {\n      const elem = await part.findContentWait(\".test-rule-permissions div\", bit, 100);\n      if (!await elem.matches(`[class$=-${desired}]`)) {\n        await elem.click();\n        if (!await elem.matches(`[class$=-${desired}]`)) {\n          await elem.click();\n          if (!await elem.matches(`[class$=-${desired}]`)) {\n            throw new Error(`Can't set permission bit ${bit} to ${desired}`);\n          }\n        }\n      }\n    }\n  }\n  if (memo) {\n    const editorSelector = `.test-rule-part-and-memo:nth-child(${partNum}) .test-rule-memo-editor`;\n    const memoEditorPromise = ruleSet.find(editorSelector);\n    if (await memoEditorPromise.isPresent()) {\n      await memoEditorPromise.click();\n      await gu.clearInput();\n    } else {\n      await part.find(\".test-rule-memo-add\").click();\n      await ruleSet.findWait(editorSelector, 100).click();\n    }\n    await gu.sendKeys(memo, Key.ENTER);\n  }\n}\n\n/**\n * Enters formula in the ACL condition editor to trigger the autocomplete dropdown.\n * @param ruleSet Rule set dom (for a table or default)\n * @param partNum Index of the condition\n * @param aclFormula Formula to enter\n */\nexport async function triggerAutoComplete(\n  ruleSet: WebElement, partNum: number, aclFormula: string,\n) {\n  const part = ruleSet.find(`.test-rule-part-and-memo:nth-child(${partNum}) .test-rule-part`);\n  if (aclFormula !== null) {\n    await part.findWait(\".test-rule-acl-formula .ace_editor\", 500);\n    await part.find(\".test-rule-acl-formula\").doClick();\n    await driver.findWait(\".test-rule-acl-formula .ace_focus\", 500);\n    await gu.clearInput();    // Clear formula\n    await gu.sendKeysSlowly(aclFormula);\n    await driver.findWait(\".ace_completion-highlight\", 1000);\n  }\n}\n\n/**\n * Fetch rule text from an element.  Uses Ace text if that is non-empty, in order\n * to get complete text of long rules.  If Ace text is empty, returns any plain\n * text (e.g. \"Everyone Else\").\n */\nexport async function getRuleText(el: WebElement) {\n  const plainText = await el.getText();\n  const aceText = await gu.getAceText(el);\n  return aceText || plainText;\n}\n\n/**\n * Read the rules within an element in a format that is easy to\n * compare with.\n */\nexport async function getRules(el: WebElement): Promise<{\n  formula: string, perm: string,\n  res?: string,\n  memo?: string,\n  error?: string }[]> {\n  const ruleSets = await el.findAll(\".test-rule-set\");\n  const results: { formula: string, perm: string,\n    res?: string,\n    memo?: string }[] = [];\n  for (const ruleSet of ruleSets) {\n    const scope = ruleSet.find(\".test-rule-resource\");\n    const res = (await scope.isPresent()) ? (await scope.getText()) : undefined;\n    const parts = await ruleSet.findAll(\".test-rule-part-and-memo\");\n    for (const part of parts) {\n      const formula = await getRuleText(await part.find(\".test-rule-acl-formula\"));\n      const perms = await part.find(\".test-rule-permissions\").findAll(\"div\");\n      const permParts: string[] = [];\n      for (const perm of perms) {\n        const content = await perm.getText();\n        if (content.length !== 1) { continue; }\n        const classes = await perm.getAttribute(\"class\");\n        const prefix = classes.includes(\"-deny\") ? \"-\" :\n          (classes.includes(\"-allow\") ? \"+\" : \"\");\n        permParts.push(prefix ? (prefix + content) : \"\");\n      }\n      const hasMemo = await part.find(\".test-rule-memo\").isPresent();\n      const memo = hasMemo ? await part.find(\".test-rule-memo input\").value() : undefined;\n      const hasError = await part.find(\".test-rule-error\").isPresent();\n      const error = hasError ? await part.find(\".test-rule-error\").getText() : undefined;\n      results.push({ formula, perm: permParts.join(\"\"),\n        ...(memo ? { memo } : {}),\n        ...(res ? { res } : {}),\n        ...(error ? { error } : {}),\n      });\n    }\n  }\n  return results;\n}\n\n/**\n * Check if there is an extra \"add\" button compared to the number of rules\n * within an element.\n */\nexport async function hasExtraAdd(el: WebElement): Promise<boolean> {\n  const parts = await el.findAll(\".test-rule-part-and-memo\");\n  const adds = await el.findAll(\".test-rule-add\");\n  return adds.length === parts.length + 1;\n}\n\n/**\n * Assert that the Save button is currently disabled because the rules are\n * saved.\n */\nexport async function assertSaved() {\n  await gu.waitToPass(async () => {\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"Saved\");\n    assert.equal(await driver.find(\".test-rules-save\").getText(), \"\");\n  }, 200);\n}\n\n/**\n * Assert that the Save button is currently enabled because the rules have\n * changed.\n */\nexport async function assertChanged() {\n  await gu.waitToPass(async () => {\n    assert.equal(await driver.find(\".test-rules-save\").getText(), \"Save\");\n    assert.equal(await driver.find(\".test-rules-non-save\").getText(), \"\");\n  }, 200);\n}\n\n/**\n * Open the Access Rules page to start editing rules. Enables access rules first if needed.\n */\nexport async function startEditingAccessRules() {\n  // Open the 'Access rules' page.\n  await driver.findWait(\".test-tools-access-rules\", 1000).click();\n\n  // Wait for initialization fetch to complete by waiting for loading indicator to disappear.\n  await driver.wait(async () => !(await driver.find(\".test-access-rules-loading\").isPresent()), 2000);\n\n  // If we are seeing an intro to enable access rules, take that step now.\n  const enableButton = driver.find(\".test-enable-access-rules\");\n  if (await enableButton.isPresent()) {\n    await enableButton.click();\n    await driver.findWait(\".test-modal-confirm\", 200).click();\n  }\n  await driver.findWait(\".test-rule-set\", 200);\n  await gu.waitForServer();  // Assert also for any validity checking to complete.\n}\n"
  },
  {
    "path": "test/nbrowser/chartViewTestUtils.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\n\nimport isString from \"lodash/isString\";\nimport isUndefined from \"lodash/isUndefined\";\nimport { assert, driver, WebElement } from \"mocha-webdriver\";\nimport { Layout, LayoutAxis, PlotData } from \"plotly.js\";\n\nexport interface ChartData {\n  data: Partial<PlotData>[];\n  layout: Layout;\n}\n\nexport async function selectChartType(chartType: string) {\n  await driver.find(\".test-chart-type\").click();\n  await gu.findOpenMenuItem(\".test-select-row\", chartType).click();\n  return gu.waitForServer();\n}\n\nexport async function getChartData(chartElem?: WebElement | string): Promise<ChartData> {\n  if (isString(chartElem) || isUndefined(chartElem)) {\n    const section = isString(chartElem) ?\n      await gu.getSection(chartElem) :\n      await driver.findWait(\".active_section\", 4000);\n    chartElem = await section.findWait(\".test-chart-container.js-plotly-plot\", 5_000);\n  }\n  return driver.executeScript((el: any) => ({ data: el.data, layout: el.layout }), chartElem);\n}\n\nexport function checkAxisRange({ layout }: ChartData, xMin: number, xMax: number, yMin: number, yMax: number) {\n  assert.closeTo(layout.xaxis.range![0], xMin, xMin * 0.1);\n  assert.closeTo(layout.xaxis.range![1], xMax, xMax * 0.1);\n  assert.closeTo(layout.yaxis.range![0], yMin, yMin * 0.1);\n  assert.closeTo(layout.yaxis.range![1], yMax, yMax * 0.1);\n}\n\nexport function getAxisTitle(axis: Partial<LayoutAxis>): string | undefined {\n  return axis.title && (axis.title as any).text;\n}\n\nexport function findYAxis(name: string) {\n  return driver.findContent(\".test-chart-y-axis\", name);\n}\n\nexport async function removeYAxis(name: string) {\n  await findYAxis(name).mouseMove().find(\".test-chart-ref-select-remove\").click();\n  await gu.waitForServer();\n}\n\nexport async function checkAxisConfig(expected: { groupingByColumn?: string | false,\n  xaxis: string | undefined, yaxis: string[] }) {\n  const isGroupByPresent = await driver.find(\".test-chart-group-by-column\").isPresent();\n  let groupingByColumn = isGroupByPresent ? await driver.find(\".test-chart-group-by-column\").getText() : false;\n  if (groupingByColumn === \"Pick a column\") {\n    groupingByColumn = false;\n  }\n  const xaxis = await driver.find(\".test-chart-x-axis\").getText();\n  assert.deepEqual({\n    groupingByColumn,\n    xaxis: xaxis === \"Pick a column\" ? undefined : xaxis,\n    yaxis: await driver.findAll(\".test-chart-y-axis\", e => e.getText()),\n  }, { ...expected, groupingByColumn: expected.groupingByColumn || false });\n}\n\nexport async function setSplitSeries(name: string | false, section?: string) {\n  await gu.openSectionMenu(\"viewLayout\", section);\n  await gu.findOpenMenuItem(\"li\", \"Widget options\").click();\n\n  const isChecked = await driver.findContent(\"label\", /Split series/).find(\"input\").matches(\":checked\");\n  if (name === false && isChecked === true ||\n    name && isChecked === false) {\n    await driver.findContent(\"label\", /Split series/).click();\n  }\n  if (name) {\n    await driver.find(\".test-chart-group-by-column\").click();\n    await gu.findOpenMenuItem(\"li\", name || \"Pick a column\").click();\n  }\n  await gu.waitForServer();\n}\n\nexport async function selectXAxis(name: string, opt: { noWait?: boolean } = {}) {\n  await driver.find(\".test-chart-x-axis\").click();\n  await gu.findOpenMenuItem(\"li\", name).click();\n  if (!opt.noWait) {\n    await gu.waitForServer();\n  }\n}\n\nexport async function setYAxis(names: string[]) {\n  // let's first remove all yaxis and then add new ones\n  const toRemove = await driver.findAll(\".test-chart-y-axis\", e => e.getText());\n  for (const n of toRemove) { await removeYAxis(n); }\n  for (const n of names) { await addYAxis(n); }\n}\n\nexport async function addYAxis(name: string) {\n  await driver.find(\".test-chart-add-y-axis\").click();\n  await gu.findOpenMenuItem(\"li\", name).click();\n  await gu.waitForServer();\n}\n"
  },
  {
    "path": "test/nbrowser/customUtil.ts",
    "content": "export * from \"test/server/customUtil\";\nimport { gu } from \"test/nbrowser/gristUtil-nbrowser\";\n\nimport { driver } from \"mocha-webdriver\";\n\nexport async function setAccess(option: \"none\" | \"read table\" | \"full\") {\n  const text = {\n    \"none\": \"No document access\",\n    \"read table\": \"Read selected table\",\n    \"full\": \"Full document access\",\n  };\n  await driver.find(`.test-config-widget-access .test-select-open`).click();\n  await gu.findOpenMenuItem(\"li\", text[option]).click();\n}\n"
  },
  {
    "path": "test/nbrowser/disabledAt.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"disabledAt\", function() {\n  this.timeout(60000);\n\n  let oldEnv: EnvironmentSnapshot;\n  const cleanup = setupTestSuite({ team: true });\n\n  let ownerSession: gu.Session;\n  let adminSession: gu.Session;\n\n  const team = gu.session().teamSite;\n\n  let ownerApi: UserAPI;\n  let adminApi: UserAPI;\n\n  let docId: string;\n  let wsId: number;\n\n  before(async function() {\n    oldEnv = new EnvironmentSnapshot();\n    process.env.GRIST_DEFAULT_EMAIL = gu.translateUser(\"support\").email;\n    process.env.GRIST_TEST_DOC_AUTH_CACHE_TTL = \"0\";\n    await server.restart(false);\n\n    ownerSession = await team.user(\"user1\").login();\n    adminSession = await team.user(\"support\").login();\n    ownerApi = ownerSession.createHomeApi();\n    adminApi = adminSession.createHomeApi();\n\n    wsId = await ownerSession.tempWorkspace(cleanup, \"owner-ws\");\n\n    const inWorkspace = ownerSession.forWorkspace(\"owner-ws\");\n    const doc = await inWorkspace.tempDoc(cleanup, \"Hello.grist\", { load: false });\n    await inWorkspace.tempDoc(cleanup, \"Widgets.grist\", { load: false }); // second doc not used further\n    docId = doc.id;\n\n    const docInfo = await ownerApi.getDoc(docId);\n    assert.equal(docInfo.id, docId, \"owner should have access to created doc\");\n  });\n\n  after(async function() {\n    if (!gu.noCleanup) {\n      oldEnv.restore();\n      await server.restart(true);\n    }\n  });\n\n  it(\"prevents non-admin from disabling a document via API\", async function() {\n    await assert.isRejected(ownerApi.disableDoc(docId), /Access denied/);\n  });\n\n  it(\"lets admin disable a document via API\", async function() {\n    await adminApi.disableDoc(docId);\n    assert.typeOf((await adminApi.getDoc(docId)).disabledAt, \"string\");\n  });\n\n  it(\"blocks owner from moving, renaming, or accessing disabled doc\", async function() {\n    await assert403(ownerApi.moveDoc(docId, wsId));\n    await assert403(ownerApi.renameDoc(docId, \"A rose by any other name\"));\n    await assert403(ownerApi.getDocAPI(docId).getRecords(\"Table1\"));\n    await assert403((await ownerApi.getWorkerAPI(docId)).downloadDoc(docId));\n  });\n\n  it(\"should remove some UI on disabled doc in DocList UI for owner\", async function() {\n    ownerSession = await team.user(\"user1\").login();\n    await ownerSession.loadDocMenu(\"/\");\n    await gu.openWorkspace(\"owner-ws\");\n    await driver.findWait(\".test-component-tabs-list\", 5000);\n\n    const entries = await driver.findAll(\".test-dm-doc\");\n    assert.equal(entries.length, 2, \"All docs should still be visible\");\n    const disabledDoc = entries[0];\n    const enabledDoc = entries[1];\n\n    assert.isFalse(await enabledDoc.matches(\"[class*=-no-access]\"),\n      \"Enabled doc should not have -no-access css class\");\n    assert.isTrue(await disabledDoc.matches(\"[class*=-no-access]\"),\n      \"Disabled doc should have -no-access css class\");\n\n    await enabledDoc.findWait(\".test-dm-doc-options\", 500).click();\n    assert.isFalse(await gu.findOpenMenuItem(\"li\", /Move/).matches(\"[class*=disabled]\"));\n    assert.isFalse(await gu.findOpenMenuItem(\"li\", /Rename/).matches(\"[class*=disabled]\"));\n    assert.isFalse(await gu.findOpenMenuItem(\"li\", /Download/).matches(\"[class*=disabled]\"));\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n\n    await disabledDoc.findWait(\".test-dm-doc-options\", 500).click();\n    assert.isTrue(await gu.findOpenMenuItem(\"li\", /Move/).matches(\"[class*=disabled]\"));\n    assert.isTrue(await gu.findOpenMenuItem(\"li\", /Rename/).matches(\"[class*=disabled]\"));\n    assert.isTrue(await gu.findOpenMenuItem(\"li\", /Download/).matches(\"[class*=disabled]\"));\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n  });\n\n  it(\"allows owner to soft-delete and undelete disabled doc\", async function() {\n    await ownerApi.softDeleteDoc(docId);\n    await assert.isRejected(ownerApi.getDoc(docId), /not found/);\n\n    await ownerApi.undeleteDoc(docId);\n    const doc = await ownerApi.getDoc(docId);\n    assert.isUndefined(doc.removedAt);\n  });\n\n  it(\"prevents non-admin from enabling the document via API\", async function() {\n    await assert.isRejected(ownerApi.enableDoc(docId), /Access denied/);\n  });\n\n  it(\"lets admin enable the document via API\", async function() {\n    await adminApi.enableDoc(docId);\n    const doc = await adminApi.getDoc(docId);\n    assert.isUndefined(doc.disabledAt);\n  });\n\n  it(\"lets owner access and rename re-enabled document and see it in DocList\", async function() {\n    const docApi = ownerApi.getDocAPI(docId);\n    await assert.isFulfilled(docApi.getRecords(\"Table1\"));\n    await assert.isFulfilled(ownerApi.renameDoc(docId, \"A rose by any other name\"));\n    await assert.isFulfilled((await ownerApi.getWorkerAPI(docId)).downloadDoc(docId));\n\n    await ownerSession.loadDocMenu(\"/\");\n    const reEnabledDoc = await driver.find(\".test-dm-doc\");\n    await reEnabledDoc.findWait(\".test-dm-doc-options\", 500).click();\n    assert.isFalse(await gu.findOpenMenuItem(\"li\", /Move/).matches(\"[class*=disabled]\"));\n    assert.isFalse(await gu.findOpenMenuItem(\"li\", /Rename/).matches(\"[class*=disabled]\"));\n    assert.isFalse(await gu.findOpenMenuItem(\"li\", /Download/).matches(\"[class*=disabled]\"));\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n\n    const titleText = await reEnabledDoc.findWait(\".test-dm-doc-name\", 500).getText();\n    assert.include(titleText, \"A rose by any other name\");\n  });\n});\n\nasync function assert403<T>(testPromise: Promise<T>) {\n  let caughtErr: any = null;\n  try {\n    await testPromise;\n  } catch (err: any) {\n    caughtErr = err;\n  }\n  assert.equal(caughtErr?.status, 403);\n}\n"
  },
  {
    "path": "test/nbrowser/duplicateWidget.ts",
    "content": "import { StringUnion } from \"app/common/StringUnion\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert } from \"chai\";\nimport { driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"duplicateWidget\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  const testFormula = '\"Test\" == \"Test\"';\n  const testCellContent = \"Rubber duck\";\n  const testCellContent2 = \"Plastic boat\";\n\n  before(async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup, \"DuplicateWidget.grist\");\n  });\n\n  describe(\"duplicating a widget on the same page\", async function() {\n    it(\"can duplicate the widget\", async function() {\n      // Initial setup of the base widget\n      const allSections = await gu.getSectionTitles();\n      await gu.selectSectionByTitle(allSections[0]);\n\n      await gu.renameSection(allSections[0], \"Widget 1\");\n\n      await gu.getCell(\"A\", 1).click();\n      await gu.waitAppFocus();\n      await gu.enterCell(testCellContent);\n      await gu.enterCell(testCellContent2);\n\n      // Set as many properties on the widget as possible that we can check when duplicating.\n      // Filters happen before widget change, as the gristUtils helpers are better for tables.\n      const filterCtrl = await gu.openColumnFilter(\"A\");\n      await filterCtrl.toggleValue(testCellContent2);\n      await filterCtrl.save();\n\n      await gu.changeWidget(\"Card List\");\n      await setCardListTheme(\"Compact\");\n      await gu.moveToHidden(\"B\");\n\n      await gu.openWidgetPanel(\"sortAndFilter\");\n      await gu.addColumnToSort(\"A\");\n      await gu.saveSortConfig();\n\n      await gu.duplicateWidget(\"Widget 1\");\n      // Reduces flakiness by waiting until duplication is finished and it's okay to rename.\n      await gu.waitToPass(async () => assert.equal((await gu.getSectionTitles()).length, 2));\n      // Rename the first widget to 'Original', as `renameSection` finds the first one.\n      await renameLastWidget(\"Widget 2\");\n\n      assert.deepEqual(await gu.getSectionTitles(), [\"Widget 1\", \"Widget 2\"]);\n    });\n\n    it(\"preserves widget type and options\", async function() {\n      // This also verifies it's still a card list widget.\n      const cardListTheme = await getCardListTheme();\n      assert.equal(cardListTheme, \"Compact\", \"Widget options were not preserved\");\n    });\n\n    it(\"preserves visible columns\", async function() {\n      const visibleColumns = await gu.getVisibleColumns();\n      assert.deepEqual(visibleColumns, [\"A\", \"C\"]);\n\n      const hiddenColumns = await gu.getHiddenColumns();\n      assert.deepEqual(hiddenColumns, [\"B\"]);\n    });\n\n    it(\"can hide columns without affecting other widgets\", async function() {\n      await gu.openWidgetPanel(\"widget\");\n      await gu.moveToVisible(\"B\");\n      assert.includeMembers(await gu.getVisibleColumns(), [\"A\", \"B\", \"C\"]);\n      assert.deepEqual(await gu.getHiddenColumns(), []);\n\n      await gu.getSection(\"Widget 1\").click();\n      const widget1VisibleColumns = await gu.getVisibleColumns();\n      const widget1HiddenColumns = await gu.getHiddenColumns();\n      assert.includeMembers(widget1VisibleColumns, [\"A\", \"C\"]);\n      assert.notIncludeMembers(widget1VisibleColumns, [\"B\"]);\n\n      assert.includeMembers(widget1HiddenColumns, [\"B\"]);\n      assert.notIncludeMembers(widget1HiddenColumns, [\"A\", \"C\"]);\n    });\n\n    it(\"preserves saved sorts\", async function() {\n      await gu.openSectionMenu(\"sortAndFilter\", \"Widget 2\");\n      const sortColumns = await gu.getSortColumns();\n      assert.deepEqual(sortColumns, [{ column: \"A\", dir: \"asc\" }]);\n      // Close the sort menu - can overlap with filter options on CI.\n      await gu.closeSectionMenu(\"sortAndFilter\", \"Widget 2\");\n    });\n\n    it(\"preserves column filters\", async function() {\n      await gu.openPinnedFilter(\"A\");\n      const filterState = await gu.getFilterMenuState();\n      const isChecked = (text: string) => filterState.find(entry => entry.value === text)?.checked;\n      assert.isTrue(isChecked(testCellContent), `${testCellContent} should be included`);\n      assert.isFalse(isChecked(testCellContent2), `${testCellContent2} should be filtered out`);\n    });\n\n    it(\"can duplicate a widget with selectby and style rules\", async function() {\n      await gu.changeWidget(\"Table\");\n\n      await gu.addInitialStyleRule();\n      await gu.openStyleRuleFormula(0);\n      await driver.sendKeys(testFormula + Key.ENTER);\n      await gu.selectBy(\"Widget 1\");\n\n      await gu.duplicateWidget(\"Widget 2\");\n      // Reduces flakiness by waiting until duplication is finished and it's okay to rename.\n      await gu.waitToPass(async () => assert.equal((await gu.getSectionTitles()).length, 3));\n      await renameLastWidget(\"Widget 3\");\n    });\n\n    it(\"preserves style rules\", async function() {\n      await gu.getSection(\"Widget 3\").click();\n      await gu.openWidgetPanel(\"widget\");\n      const formula = await gu.getStyleRuleAt(0).find(\".formula_field_sidepane\").getText();\n      assert.equal(formula.trim(), testFormula);\n    });\n\n    it(\"preserves selectby\", async function() {\n      await gu.getSection(\"Widget 3\").click();\n      await gu.changeWidget(\"Card\");\n      assert.equal(await gu.selectedBy(), \"Widget 1\");\n      const text = await gu.getDetailCell(\"A\", 1).getText();\n      assert.equal(text, testCellContent);\n    });\n  });\n\n  describe(\"duplicating a widget to a different page\", async function() {\n    it(\"can duplicate the widget to another existing page\", async function() {\n      await gu.addNewPage(\"Table\", \"Table1\");\n      const newPageName = \"Page 2\";\n      await gu.renamePage(\"New page\", newPageName);\n      // Go back to the first page.\n      await gu.openPage((await gu.getPageNames())[0]);\n      await gu.duplicateWidget(\"Widget 2\", newPageName);\n      assert.equal(await gu.getCurrentPageName(), newPageName);\n      await renameLastWidget(\"Widget 4\");\n      // Ensure the 'select by' was cleared, as it was only valid on the same page.\n      assert.equal(await gu.selectedBy(), \"Select widget\");\n    });\n\n    it(\"can duplicate the widget to a new page\", async function() {\n      await gu.openPage((await gu.getPageNames())[0]);\n      await gu.duplicateWidget(\"Widget 2\", \"Create new page\");\n      assert.equal(await gu.getCurrentPageName(), \"New page\");\n    });\n  });\n});\n\nconst CardListTheme = StringUnion(\"Form\", \"Compact\", \"Block\");\nasync function setCardListTheme(theme: typeof CardListTheme.type) {\n  await gu.openWidgetPanel(\"widget\");\n  const select = gu.buildSelectComponent(\".test-vconfigtab-detail-theme\");\n  await select.select(theme);\n}\n\nasync function getCardListTheme(): Promise<typeof CardListTheme.type> {\n  await gu.openWidgetPanel(\"widget\");\n  const select = gu.buildSelectComponent(\".test-vconfigtab-detail-theme\");\n  return CardListTheme.check(await select.value());\n}\n\nasync function renameLastWidget(newName: string) {\n  // Can't use widget title to select, as the widgets have identical titles - need to use index\n  await gu.selectSectionByIndex(-1);\n  await gu.renameActiveSection(newName);\n  await gu.waitToPass(async () => { await gu.getSection(newName); });\n}\n"
  },
  {
    "path": "test/nbrowser/elementUtils.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\n\nimport { driver, WebElementPromise } from \"mocha-webdriver\";\n\nexport interface Button {\n  click(): Promise<void>;\n  element(): WebElementPromise;\n  wait(): Promise<void>;\n  visible(): Promise<boolean>;\n  present(): Promise<boolean>;\n}\n\nexport const element = (testId: string) => ({\n  element() {\n    return driver.find(testId);\n  },\n  async wait() {\n    await driver.findWait(testId, 10000);\n  },\n  async visible() {\n    return await this.element().isDisplayed();\n  },\n  async present() {\n    return await this.element().isPresent();\n  },\n});\n\nexport const label = (testId: string) => ({\n  ...element(testId),\n  async text() {\n    return this.element().getText();\n  },\n});\n\nexport const button = (testId: string): Button => ({\n  ...element(testId),\n  async click() {\n    await gu.scrollIntoView(this.element());\n    await this.element().click();\n  },\n});\n\nexport const option = (testId: string) => ({\n  ...button(testId),\n  async checked() {\n    return \"true\" === await this.element().findClosest(\"label\").find(\"input[type='checkbox']\").getAttribute(\"checked\");\n  },\n});\n"
  },
  {
    "path": "test/nbrowser/externalAttachmentsHelpers.ts",
    "content": "import { server } from \"test/nbrowser/testServer\";\nimport { createTmpDir } from \"test/server/docTools\";\n\nimport * as process from \"node:process\";\nimport path from \"path\";\n\nimport { mkdtemp } from \"fs-extra\";\n\n/**\n * Adds a before() hook that sets the environment variables for external attachments, then restarts\n * the Grist server. Preserves existing values of those environment variables, and restores them in\n * the after() hook.\n * @param {string} transferDelay - Extra time to add to attachment transfers\n * @returns {{envVars: Record<string, string>, getAttachmentsDir(): string}}\n */\nexport function enableExternalAttachmentsForTestSuite(options: {\n  thresholdMb?: number,\n  transferDelay?: number,\n}):\n{ envVars: Record<string, string>; getAttachmentsDir(): string; } {\n  const { thresholdMb, transferDelay } = options;\n  const envVars: Record<string, string> = {\n    GRIST_EXTERNAL_ATTACHMENTS_MODE: \"test\",\n    GRIST_TEST_ATTACHMENTS_DIR: \"\",\n  };\n\n  if (transferDelay) {\n    envVars.GRIST_TEST_TRANSFER_DELAY = String(transferDelay);\n  }\n  if (thresholdMb) {\n    envVars.GRIST_ATTACHMENTS_THRESHOLD_MB = String(thresholdMb);\n  }\n\n  let originalEnv: Record<string, string | undefined> = {};\n\n  before(async () => {\n    const tempFolder = await createTmpDir();\n    envVars.GRIST_TEST_ATTACHMENTS_DIR = await mkdtemp(path.join(tempFolder, \"attachments\"));\n\n    originalEnv = saveEnvVars(Object.keys(envVars));\n    setEnvVars(envVars);\n\n    await server.restart();\n  });\n\n  after(async () => {\n    setEnvVars(originalEnv);\n\n    await server.restart();\n  });\n\n  return {\n    envVars,\n    getAttachmentsDir() {\n      return envVars.GRIST_TEST_ATTACHMENTS_DIR;\n    },\n  };\n}\n\nfunction saveEnvVars(varNames: string[]) {\n  const originalEnvVars: Record<string, string | undefined> = {};\n  for (const envVar of varNames) {\n    originalEnvVars[envVar] = process.env[envVar];\n  }\n\n  return originalEnvVars;\n}\n\nfunction setEnvVars(vars: Record<string, string | undefined>) {\n  for (const [varName, varValue] of Object.entries(vars)) {\n    if (varValue === undefined) {\n      delete process.env[varName];\n    } else {\n      process.env[varName] = varValue;\n    }\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/formTools.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\n\nimport { escapeRegExp } from \"lodash\";\nimport { By, driver, WebElement, WebElementPromise } from \"mocha-webdriver\";\n\nexport function element(type: string, parent?: WebElement): ExtraElement;\nexport function element(type: string, index: number, parent?: WebElement): ExtraElement;\nexport function element(type: string, arg1?: number | WebElement, arg2?: WebElement): ExtraElement {\n  if (typeof arg1 === \"number\") {\n    if (arg1 === 1) {\n      return extra((arg2 ?? driver).find(`.active_section .test-forms-${type}`));\n    }\n    const nth = ((arg2 ?? driver).findAll(`.active_section .test-forms-${type}`)\n      .then(els => els[arg1 - 1]))\n      .then((el) => {\n        if (!el) { throw new Error(`No element of type ${type} at index ${arg1}`); }\n        return el;\n      });\n    return extra(new WebElementPromise(driver, nth));\n  } else {\n    return extra((arg1 ?? driver).find(`.active_section .test-forms-${type}`));\n  }\n}\n\nexport async function elementCount(type: string, parent?: WebElement) {\n  return await (parent ?? driver).findAll(`.active_section .test-forms-${type}`).then(els => els.length);\n}\n\nexport async function labels() {\n  return await driver.findAll(\".active_section .test-forms-question .test-forms-label\", el => el.getText());\n}\n\nexport function question(label: string) {\n  return extra(driver.findContent(`.active_section .test-forms-label`, new RegExp(\"^\" + escapeRegExp(label) + \"\\\\*?$\"))\n    .findClosest(\".test-forms-editor\"));\n}\n\nexport function questionDrag(label: string) {\n  return question(label).find(\".active_section .test-forms-drag\");\n}\n\nexport function questionType(label: string) {\n  return question(label).find(\".active_section .test-forms-type\").value();\n}\n\nexport function plusButton(parent?: WebElement) {\n  return element(\"plus\", parent);\n}\n\nexport function drops() {\n  return driver.findAll(\".active_section .test-forms-plus\");\n}\n\nexport async function clickMenu(label: string) {\n  await driver.findWait(\".grist-floating-menu\", 100);\n  // First try command as it will also contain the keyboard shortcut we need to discard.\n  if (await driver.findContent(\".grist-floating-menu li .test-cmd-name\", gu.exactMatch(label)).isPresent()) {\n    return driver.findContent(\".grist-floating-menu li .test-cmd-name\", gu.exactMatch(label)).click();\n  }\n  return driver.findContentWait(\".grist-floating-menu li\", gu.exactMatch(label), 100).click();\n}\n\nexport async function isSelected() {\n  const els = await driver.findAll(\".active_section .test-forms-field-editor-selected\");\n  return els.length > 0;\n}\n\nexport function selected() {\n  return driver.find(\".active_section .test-forms-field-editor-selected\");\n}\n\nexport function selectedLabel() {\n  return selected().find(\".active_section .test-forms-label-rendered\").getText();\n}\n\nexport function hiddenColumns() {\n  return driver.findAll(\".test-vfc-hidden-field\", e => e.getText());\n}\n\nexport function hiddenColumn(label: string) {\n  return driver.findContent(\".test-vfc-hidden-field\", gu.exactMatch(label));\n}\n\nexport type ExtraElement = WebElementPromise & {\n  rightClick: () => Promise<void>,\n  element: (type: string, index?: number) => ExtraElement,\n  /**\n   * A draggable element inside. This is 2x2px div to help with drag and drop.\n   */\n  drag: () => WebElementPromise,\n  type: () => Promise<string>,\n  remove: () => Promise<void>,\n  hover: () => Promise<void>;\n};\n\nexport function extra(el: WebElementPromise): ExtraElement {\n  const webElement: any = el;\n\n  webElement.rightClick = async function() {\n    await driver.withActions(a => a.contextClick(el));\n  };\n\n  webElement.element = function(type: string, index?: number) {\n    return element(type, index ?? 1, el);\n  };\n\n  webElement.drag = function() {\n    return el.find(\".test-forms-drag\");\n  };\n  webElement.type = async function() {\n    return await el.getAttribute(\"data-box-model\");\n  };\n  webElement.remove = async function() {\n    return await el.find(\".test-forms-remove\").click();\n  };\n  webElement.hover = function() {\n    return el.mouseMove();\n  };\n\n  return webElement;\n}\n\nexport async function arrow(key: string, times: number = 1) {\n  for (let i = 0; i < times; i++) {\n    await gu.sendKeys(key);\n  }\n}\n\nexport async function elements() {\n  return await driver.findAll(\".active_section .test-forms-element\", el => el.getAttribute(\"data-box-model\"));\n}\n\nexport interface FormElement {\n  type: string;\n  label?: string;\n  content?: string;\n  children: FormElement[];\n}\n\nexport async function formSchema(): Promise<FormElement[]> {\n  const topElement = await driver.find(\".active_section .test-forms-content\");\n  const topElements = await topElement.findElements(By.css(\":scope > .test-forms-element\"));\n  const list: FormElement[] = [];\n  for (const el of topElements) {\n    list.push(await inspect(el));\n  }\n  return list;\n\n  async function inspect(el: WebElement): Promise<FormElement> {\n    const type = await el.getAttribute(\"data-box-model\");\n    let label: string | undefined;\n    let content: string | undefined;\n\n    if (type === \"Field\") {\n      label = await el.find(\".test-forms-label\").getText();\n    } else {\n      content = await el.getText();\n    }\n\n    const children: FormElement[] = [];\n\n    if (await el.find(\".test-forms-content\").isPresent()) {\n      const innerList = await el.find(\".test-forms-content\").findElements(By.css(\":scope > .test-forms-element\"));\n      for (const innerEl of innerList) {\n        children.push(await inspect(innerEl));\n      }\n    }\n\n    return { type, label, content, children };\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/gristUtil-nbrowser.js",
    "content": "import * as _ from \"lodash\";\nimport { assert, driver, Key, stackWrapFunc, WebElement,\n  WebElementPromise } from \"mocha-webdriver\";\nimport { driverCompanion, findOldTimey, waitImpl,\n  webdriverjqWrapper } from \"test/nbrowser/webdriverjq-nbrowser\";\nimport * as guBase from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { server } from \"test/nbrowser/testServer\";\n\n// Simulate the old \"$\" object.\n\nconst _webdriverjqFactory = webdriverjqWrapper(driver);\nfunction $(key) {\n  if (typeof key !== \"string\") {\n    return key;\n  }\n  return _webdriverjqFactory(key);\n}\n\n// The \"$\" object needs some setup done asynchronously.\n// We do that later, during test initialization.\nasync function applyPatchesToJquerylikeObject($) {\n  $.MOD = await guBase.modKey();\n  $.SELECT_ALL = await guBase.selectAllKey();\n  $.getPage = async (url) => {\n    return driver.get(url);\n  };\n  $.wait = (timeoutMs, conditionFunc) => {\n    return waitImpl(timeoutMs, conditionFunc);\n  };\n  for (const key of Object.keys(Key)) {\n    $[key] = Key[key];\n  }\n  // We need to tweak driver object a bit too (really?)\n  driverCompanion.$ = $;\n  // driver.testHost = server.getHost();\n  // driver.waitImpl = waitImpl;\n}\n\n// Adapt common old setup.\nconst test = {\n  setupTestSuite(self, ...args) {\n    self.timeout(40000);\n    return setupTestSuite(...args);\n  },\n};\n\n// Add some methods to the grist utils that are used by old tests.\n// This could be cleaned up further, but translating to newer ways\n// of doing things or, if the method is really useful, adding the\n// method to the new grist utils.\n\nconst waitForServer = guBase.waitForServer;\nlet patchesApplied = false;\nlet session;\n\nconst gu = {\n  ...guBase,\n\n  // Apply all needed patches, async initialization, and log in.\n  async supportOldTimeyTestCode() {\n    if (!patchesApplied) {\n      applyPatchesToWebElements();\n      applyPatchesToAssert();\n      applyPatchesToJquerylikeObject($);\n    }\n    patchesApplied = true;\n    // Login as someone so old code doesn't have to be upgraded to do it.\n    session = await gu.session().user(\"userz\");\n    const dbManager = await server.getDatabase();\n    const profile = {email: session.email, name: session.name};\n    await dbManager.getUserByLogin(session.email, {profile});\n    await gu.setApiKey(session.name);\n    await session.login();\n  },\n\n  // getCell with old-timey arguments.\n  getCellRC(r, c) {\n    return gu.getCell(c, r + 1);\n  },\n\n  // clickCell with old-timey arguments.\n  async clickCellRC(r, c) {\n    const cell = gu.getCell(c, r + 1);\n    await cell.click();\n    return cell;\n  },\n\n  // sendKeys variant that accepts arrays in place of Key.chord.\n  sendKeys(...args) {\n    return guBase.sendKeys(...args.map(\n      a => Array.isArray(a) ? Key.chord(...a) : a\n    ));\n  },\n\n  /**\n   * When doing type conversion in the side pane, this clicks the 'Apply' button and waits for the\n   * conversion to complete.\n   */\n  async applyTypeConversion() {\n    await $(\".test-type-transform-apply\").wait().scrollIntoView({\n      block: \"nearest\",\n      inline: \"nearest\",\n    }).click();\n    await $(\".test-type-transform-apply\").waitDrop(assert.isPresent, false);\n    return gu.waitForServer();\n  },\n\n  async clickColumnMenuItem(colName, itemText, optRightClick) {\n    await gu.openColumnMenu(colName);\n    await gu.actions.selectFloatingOption(itemText);\n    // Add a little bit more determinism, if menu item\n    // triggers server action.\n    await gu.waitForServer();\n  },\n\n  getOpenEditingLabel(parentElem) {\n    return driver.find(\".test-column-title-label\");\n  },\n\n  enterGridValues(startRowIndex, startColIndex, dataMatrix) {\n    const transpose = dataMatrix[0].map(\n      (_, colIndex) => dataMatrix.map(row => row[colIndex])\n    );\n    return gu.enterGridRows({col: startColIndex,\n      rowNum: startRowIndex + 1}, transpose);\n  },\n\n  async openSidePane(tabName) {\n    if ([\"log\", \"validate\", \"repl\", \"code\"].includes(tabName)) {\n      await guBase.toggleSidePanel(\"right\", \"open\");\n      return $(`.test-tools-${tabName}`).wait().click();\n    } else if (tabName === \"field\") {\n      await guBase.toggleSidePanel(\"right\", \"open\");\n      return $(\".test-right-tab-field\").click();\n    } else if (tabName === \"view\") {\n      await guBase.toggleSidePanel(\"right\", \"open\");\n      return $(\".test-right-tab-pagewidget\").wait().click();\n    }\n  },\n\n  getGridValues(...options) {\n    return gu.getVisibleGridCells(...options);\n  },\n\n  /**\n   * Returns the text in the row header of the last row in a Grid section, scrolling to the\n   * bottom, but not moving the cursor.\n   * @param {String} options.section: Optional section name to use instead of the active section.\n   */\n  async getGridLastRowText(options) {\n    if (options?.section) {\n      await gu.actions.viewSection(options.section).selectSection();\n    }\n    return String(await gu.getGridRowCount());\n  },\n\n  getAddRowNumber() {\n    return gu.getGridRowCount();\n  },\n\n  async getGridLabels(sectionName) {\n    return gu.getSection(sectionName).findAll('[data-test-id=\"GridView_columnLabel\"] .test-column-title-text',\n      label => label.getText());\n  },\n\n  /**\n   * Given a cell in grist GridView, returns whether it contains the cursor. You may use it as\n   * hasCursor(getCell(...)) or getCell(...).waitFor(hasCursor, optTimeout).\n   */\n  hasCursor(cellElem) {\n    return cellElem.find(\".selected_cursor\").isDisplayed();\n  },\n\n  async useFixtureDoc(cleanup, fname, flag) {\n    return session.tempDoc(cleanup, fname);\n  },\n\n  async copyDoc(docId, flag) {\n    const result = await guBase.copyDoc(session.name, \"docs\", \"Home\", docId);\n    await session.loadDoc(`/doc/${result.id}`);\n    return result;\n  },\n\n  async clickCell(rowIndexOrPosOrCell, colIndex) {\n    if (typeof rowIndexOrPosOrCell === \"object\" && \"driver_\" in rowIndexOrPosOrCell) {\n      return rowIndexOrPosOrCell.click();\n    }\n    // Best just to force a rewrite of clickCell, e.g. to clickCellRC,\n    // since newer gristUtils interprets arguments entirely differently.\n    if (typeof rowIndexOrPosOrCell === \"number\") {\n      throw new Error(\"ambiguous row/col\");\n    }\n    const cell = guBase.getCell(rowIndexOrPosOrCell, colIndex);\n    await cell.click();\n  },\n\n  /**\n   * Sets the visibleCol of the currently selected field to value.\n   */\n  async setVisibleCol(value) {\n    await gu.openSidePane(\"field\");\n    await $(\".test-fbuilder-ref-col-select\").click();\n    await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click();\n    return waitForServer();\n  },\n\n  /**\n   * Asserts the type of the currently selected field.\n   */\n  async assertType(value) {\n    await gu.openSidePane(\"field\");\n    assert.equal(await $(\".test-fbuilder-type-select .test-select-row\").getText(), value);\n  },\n\n  closeSidePane() {\n    return gu.toggleSidePanel(\"right\", \"close\");\n  },\n\n  clickVisibleDetailCells(column, rowNums, section) {\n    return gu.getDetailCell(column, rowNums[0], section).click();\n  },\n\n  async clickRowMenuItem(rowNum, item) {\n    await (await gu.openRowMenu(rowNum)).findContent(\"li\", item).click();\n  },\n\n  /**\n   * Selects rows starting from rowStart and ending at rowEnd (1-based) by clicking and dragging or\n   * shift clicking (Defaults to dragging)\n   * @param {int} rowStart: 1-based row number\n   * @param {int} rowEnd: 1-based row number.\n   * @param {String} optMethod: if 'shift' then shift clicking is used to select rows otherwise it\n   * defaults to drag to select.\n   */\n  async selectRows(rowStart, rowEnd, optMethod) {\n    let start = await driver.findContent(\".active_section .gridview_data_row_num\", gu.exactMatch(rowStart.toString()));\n    let end = await driver.findContent(\".active_section .gridview_data_row_num\", gu.exactMatch(rowEnd.toString()));\n    if (optMethod === \"shift\") {\n      await driver.withActions(a => a.click(start).keyDown($.SHIFT).click(end).keyUp($.SHIFT));\n    } else {\n      await driver.withActions(a => a.move({origin: start}).press().move({origin: end}).release());\n    }\n  },\n\n  async _fieldSettingsClickOption(isCommonToSeparate, optionSubstring) {\n    assert.include(await $(\".fieldbuilder_settings_button\").text(), isCommonToSeparate ? \"Common\" : \"Separate\");\n    await $(\".fieldbuilder_settings_button\").click();\n    await gu.actions.selectFloatingOption(optionSubstring);\n    await waitForServer();\n    assert.include(await $(\".fieldbuilder_settings_button\").text(), isCommonToSeparate ? \"Separate\" : \"Common\");\n  },\n  fieldSettingsUseSeparate: () => gu._fieldSettingsClickOption(true, \"Use separate\"),\n  fieldSettingsSaveAsCommon: () => gu._fieldSettingsClickOption(false, \"Save as common\"),\n  fieldSettingsRevertToCommon: () => gu._fieldSettingsClickOption(false, \"Revert to common\"),\n\n  /**\n   * Changes date format for date and datetime editor or returns current format\n   * @param {string} value Date format\n   */\n  async dateFormat(value) {\n    if (!value) {\n      return $(\"$Widget_dateFormat .test-select-row\").text();\n    }\n    await $(\"$Widget_dateFormat\").wait().click();\n    await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click();\n  },\n\n  /**\n   * Changes time format for datetime editor or returns current format\n   * @param {string} value Time format\n   */\n  async timeFormat(value) {\n    if (!value) {\n      return $(\"$Widget_timeFormat .test-select-row\").getText();\n    }\n    await $(\"$Widget_timeFormat\").wait().click();\n    await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click();\n  },\n\n  async getDetailValues(...options) {\n    return gu.getVisibleDetailCells(...options);\n  },\n\n  /**\n  * Selects all cells in a GridView between and including startCell and endCell\n  * @param {Array} startCell:\n  *                         startCell[0]: 1-based row index.\n  *                         startCell[1]: 0-based column index.\n  * @param {Array} endCell:\n  *                         endCell[0]: 1-based row index.\n  *                         endCell[1]: 0-based column index.\n  **/\n  async selectGridArea(startCell, endCell) {\n    const [startRowNum, startCol] = startCell;\n    const [endRowNum, endCol] = endCell;\n    if (startRowNum === endRowNum && startCol === endCol) {\n      await gu.getCell({rowNum: endRowNum, col: endCol}).click();\n    } else {\n      const start = await gu.getCell({rowNum: startRowNum, col: startCol});\n      const end = await gu.getCell({rowNum: endRowNum, col: endCol});\n      await driver.withActions(a => a.click(start).keyDown($.SHIFT).click(end).keyUp($.SHIFT));\n    }\n  },\n\n  /**\n   * Returns text of the cells for the given rows and columns of a viewSection.\n   * @param {String} option.section: Optional section name instead of active.\n   * @param {Array<Number>} option.rowNums: Array of row numbers (1-based)\n   * @param {Array<Number>} option.cols: Array of column indices (0-based) or labels.\n   * @param [Number: Function] option.cellFunc: a function that returns cells given an array of\n   *      columns, rows and optionally a viewsection (defaults to the currently active section)\n   * @param [Number: Function] option.valueFunc: Optional function, or an object mapping column\n   *      index or label (as in options.cols) to function, with the function mapping a cell to\n   *      its value (by default, cell => cell.text()).\n   * @returns {Promise<Array>} Returns array of values for each requested cell, as all values from\n   *      the first row, followed by values from the second, etc.\n   */\n  async getSectionValues(options) {\n    var opts = { section: options.section };\n    var defaultValueFunc = (cell => cell.text());\n    var valueFunc;\n    if (options.valueFunc && !_.isFunction(options.valueFunc)) {\n      valueFunc = (col => options.valueFunc[col] || defaultValueFunc);\n    } else {\n      valueFunc = _.constant(options.valueFunc || defaultValueFunc);\n    }\n    const colValues = [];\n    for (const col of options.cols) {\n      const colValue = await valueFunc(col)(options.cellFunc(col, options.rowNums, opts).array());\n      colValues.push(colValue);\n    }\n    return _.flatten(_.zip.apply(_, colValues), true);\n  },\n\n  /**\n   * Asserts the widget of the currently selected field.\n   */\n  async assertWidget(value) {\n    await gu.openSidePane(\"field\");\n    assert.equal(await $(\".test-fbuilder-widget-select .test-select-row\").getText(), value);\n  },\n\n  /**\n   * Sets the widget of the currently selected field to value.\n   */\n  async setWidget(value) {\n    await gu.openSidePane(\"field\");\n    const selector = $(\".test-fbuilder-widget-select\");\n    const btnChildren = await selector.elem().findAll(\".test-select-button\");\n    if (btnChildren.length > 0) {\n      // This is a button select.\n      await selector.findOldTimey(`.test-select-button:contains(${value})`).click();\n    } else {\n      // This is a dropdown select.\n      await selector.click();\n      await $(`.test-select-menu .test-select-row:contains(${value})`).wait().click();\n    }\n    await gu.waitForServer();\n  },\n\n  /**\n   * Adds a new record to the grid. Takes an array of values that matches column positions.\n   */\n  async addRecord(values) {\n    await gu.sendKeys([$.MOD, $.UP]);\n    await gu.sendKeys([$.MOD, $.DOWN]);\n    await gu.sendKeys([$.LEFT]);\n    await gu.sendKeys([$.LEFT]);\n    await gu.sendKeys([$.LEFT]);\n    await gu.sendKeys([$.LEFT]);\n    await gu.sendKeys([$.LEFT]);\n    await driver.sleep(1000);\n    // For each value, type it, followed by Tab.\n    for (const [i, value] of values.entries()) {\n      await gu.waitAppFocus();\n      await gu.sendKeys(value, $.TAB);\n      await gu.waitForServer();\n      if (i === 0) {\n        // The very first value triggers add-record, but the creation of the new row isn't\n        // immediate, so give it a moment.\n        await driver.sleep(250);\n      }\n    }\n    // Return a promise that can be awaited; it will wait for all the previously queued ones.\n    return driver.sleep(0);\n  },\n\n  actions: {\n    createNewDoc: async (optDocName) => {\n      await gu.simulateLogin(\"Chimpy\", \"chimpy@getgrist.com\", \"nasa\");\n      const docId = await gu.createNewDoc(\"chimpy\", \"nasa\", \"Horizon\", optDocName || \"Untitled\");\n      await gu.loadDoc(`/o/nasa/doc/${docId}`);\n    },\n    getDocTitle: () => {\n      return $(\".test-bc-doc\").val();\n    },\n    getActiveTab: () => {\n      return $(\".test-treeview-itemHeader.selected .test-docpage-label\").wait();\n    },\n    getTabs: () => {\n      return $(\".test-docpage-label\");\n    },\n    renameDoc: (newName) => {\n      $(\".test-bc-doc\").click();\n      $.driver.sendKeys(newName, $.ENTER);\n      return $.wait(1000, () => $.driver.getTitle().startsWith(newName + \" - \"));\n    },\n    selectTabView: async (viewTitle) => {\n      const isOpen = await gu.isSidePanelOpen(\"left\");\n      if (!isOpen) {\n        await gu.toggleSidePanel(\"left\", \"open\");\n      }\n      await gu.openPage(viewTitle);\n      if (!isOpen) {\n        await gu.toggleSidePanel(\"left\", \"close\");\n      }\n    },\n    addNewTable: async () => {\n      await $(\".test-dp-add-new\").wait().click();\n      await $(\".test-dp-empty-table\").click();\n      // if we selected a new table, there will be a popup for a name\n      const prompts = await $(\".test-modal-prompt\");\n      const prompt = prompts[0];\n      if (prompt) {\n        await await $(\".test-modal-confirm\").click();\n      }\n      return gu.waitForServer();\n    },\n    addNewSection: (tableId, sectionType) => {\n      return gu.addNewSection(sectionType, tableId);\n    },\n    addNewSummarySection: async (tableId, groupByArr, sectionType, sectionName) => {\n      await gu.addNewSection(sectionType, tableId, {summarize: groupByArr});\n      await gu.waitForServer();\n      await gu.renameActiveSection(sectionName);\n      await gu.waitForServer();\n    },\n    addNewView: (tableId, sectionType) => {\n      return gu.addNewPage(sectionType, tableId);\n    },\n    selectFloatingOption: async (optionName) => {\n      // Sometimes the element is there but \"not interactable\". Work around that.\n      await gu.waitToPass(async () => {\n        await $(`.grist-floating-menu li:contains(${optionName})`).click();\n      });\n    },\n    /**\n     * Actions related to view section. To use, pass in the section name.\n     * @param {string} sectionName - Title of the view section\n     * @return Object<string, function>} Collection of methods for the view section.\n     *\n     * @example\n     * gu.actions.viewSection('Table1 record').selectMenuOption('Insert section');\n     */\n    viewSection: (sectionName) => {\n      let section = gu.getSection(sectionName);\n      return {\n        /**\n         * Clicks inside to make the current section active.\n         */\n        selectSection: function () {\n          return gu.selectSectionByTitle(sectionName);\n        },\n        /**\n         * Opens the view section drop-down menu.\n         * @param {string} which - Which menu to open, coud be: 'sortAndFilter' or 'viewLayout'\n         */\n        openMenu: async function (which) {\n          await driver.withActions(a => a.move({origin: section.find(\".viewsection_title\")})); // to display menu buttons on hover\n          const item = section.find(`.test-section-menu-${which}`);\n          await gu.waitToPass(() => item.click());\n        },\n        /**\n         * Opens the section drop-down menu and select option matching param.\n         * @param {string} which - Which menu to open, coud be: 'sortAndFilter' or 'viewLayout'\n         * @param {string} optionName\n         */\n        selectMenuOption: function (which, optionName) {\n          this.openMenu(which);\n          return gu.actions.selectFloatingOption(optionName);\n        }\n      };\n    },\n    tableView: (tableName, viewName) => {\n      return {\n        select: () => {\n          return gu.getPageItem(tableName).click();\n        },\n        selectOption: async optionName => {\n          await gu.openPageMenu(tableName);\n          return gu.actions.selectFloatingOption(optionName);\n        }\n      };\n    }\n  }\n};\n\n/**\n * This monkey-patches the WebElement class to make it look enough like\n * jquery that a lot of old test code can be used without modification.\n */\nfunction applyPatchesToWebElements() {\n\n  WebElement.prototype.wait = function(fn, ...args) {\n    if (fn) {\n      return gu.waitToPass(async () => {\n        return fn.apply(null, [this, ...args]);\n      }).then(() => true);\n    } else {\n      return new WebElementPromise(\n        driver,\n        gu.waitToPass(async () => {\n          if (!(await this.isPresent())) {\n            throw new Error(\"not present\");\n          }\n        }).then(() => this));\n    }\n  };\n\n  WebElement.prototype.selected = function(val) {\n    return driver.executeScript((elem, val) => {\n      elem.selected = val;\n    }, this, val);\n  };\n\n  WebElement.prototype.attr = function(key, val) {\n    if (val !== undefined) {\n      return driver.executeScript((elem, key, val) => {\n        elem.setAttribute(key, val);\n      }, this, key, val);\n    }\n    return this.getAttribute(key);\n  };\n\n  WebElement.prototype.classList = async function() {\n    return (await this.getAttribute(\"className\")).split(\" \");\n  };\n\n  // Lists of WebElements work differently - if we did a find() we\n  // already have just the first match.\n  WebElement.prototype.first = function() {\n    return this;\n  };\n\n  WebElement.prototype.text = function() {\n    return this.getText();\n  };\n\n  WebElement.prototype.val = function(newVal) {\n    if (newVal === undefined) {\n      return this.getAttribute(\"value\");\n    }\n    return gu.setValue(this, newVal);\n  };\n\n  WebElement.prototype.css = function(key, val) {\n    if (val === undefined) {\n      return this.getCssValue(key);\n    }\n    return new WebElementPromise(\n      driver,\n      driver.executeScript(elem => {\n        elem.style[key] = val;\n      }, this)\n    );\n  };\n\n  WebElement.prototype.is = function(selector) {\n    return this.matches(selector);\n  };\n\n  WebElement.prototype.hasClass = async function(className) {\n    return (await this.classList()).includes(className);\n  };\n\n  WebElement.prototype.scrollIntoView = function(opts) {\n    opts = opts || {behavior: \"auto\"};\n    return new WebElementPromise(\n      driver,\n      driver.executeScript((elem, opts) => elem.scrollIntoView(opts),\n        this, opts).then(() => this));\n  };\n\n  WebElement.prototype.parent = function() {\n    return new WebElementPromise(\n      driver,\n      driver.executeScript(elem => {\n        return elem.parentNode.closest(\"*\");\n      }, this)\n    );\n  };\n\n  WebElement.prototype.closest = function(key) {\n    return this.findClosest(key);\n  };\n\n  WebElement.prototype.children = async function(mapper) {\n    // Collect children.\n    let result = await driver.executeScript(elem => {\n      return [...elem.children].map(c => c.closest(\"*\"));\n    }, this);\n    // Fix up type.\n    result = result.map(v => new WebElementPromise(\n      driver,\n      Promise.resolve(v),\n    ));\n    // Apply mapper if available.\n    if (mapper) {\n      result = result.map(mapper);\n    }\n    // Result is a single promise.\n    return Promise.all(result);\n  };\n\n  WebElement.prototype.trimmedText = async function() {\n    const text = await this.getText();\n    return text.trim();\n  };\n\n  // A version of find() that supports some old timey syntax.\n  WebElement.prototype.findOldTimey = function(key) {\n    return findOldTimey(this, key);\n  };\n\n  WebElement.prototype.findAllOldTimey = function(key, mapper) {\n    return findOldTimey(this, key, true, mapper);\n  };\n}\n\n/**\n * This monkey-patches assert to add some methods that are very\n * commonly used.\n */\nfunction applyPatchesToAssert() {\n\n  assert.hasClass = stackWrapFunc(async function(elem, className, present) {\n    if (present === undefined) {\n      present = true;\n    }\n    const c = await elem.getAttribute(\"class\");\n    if (present) {\n      await assert.include(c.split(\" \"), className);\n    } else {\n      await assert.notInclude(c.split(\" \"), className);\n    }\n  });\n\n  assert.isPresent = stackWrapFunc(async function(elem, present) {\n    if (present === undefined) {\n      present = true;\n    }\n    let current = false;\n    try {\n      current = await elem.isPresent();\n    } catch (e) {\n      // $ object may fail if elem is non-existent.\n    }\n    await assert.equal(current, present);\n    return true;\n  });\n\n  assert.isDisplayed = stackWrapFunc(async function(elem, displayed) {\n    if (displayed === undefined) {\n      displayed = true;\n    }\n    await assert.equal(await elem.isDisplayed(), displayed);\n    return true;\n  });\n}\n\nexports.$ = $;\nexports.gu = gu;\nexports.server = server;\nexports.test = test;\n"
  },
  {
    "path": "test/nbrowser/gristUtils.ts",
    "content": "/**\n * Replicates functionality of test/nbrowser/gristUtils.ts for new-style tests.\n *\n * The helpers are themselves tested in TestGristUtils.ts.\n */\n\nimport { BaseAPI } from \"app/common/BaseAPI\";\nimport { csvDecodeRow } from \"app/common/csvFormat\";\nimport { AccessLevel } from \"app/common/CustomWidget\";\nimport { DocStateComparison } from \"app/common/DocState\";\nimport { decodeUrl } from \"app/common/gristUrls\";\nimport { FullUser, UserProfile } from \"app/common/LoginSessionAPI\";\nimport { resetOrg } from \"app/common/resetOrg\";\nimport { TestState } from \"app/common/TestState\";\nimport { Organization as APIOrganization,\n  UserAPI, UserAPIImpl, Workspace } from \"app/common/UserAPI\";\nimport { Organization } from \"app/gen-server/entity/Organization\";\nimport { Product } from \"app/gen-server/entity/Product\";\nimport * as PluginApi from \"app/plugin/grist-plugin-api\";\nimport { create } from \"app/server/lib/create\";\nimport { getAppRoot } from \"app/server/lib/places\";\nimport { GristWebDriverUtils, ICellSelect as _ICellSelect,\n  IColSelect, IColsSelect, noCleanup as _noCleanup, PageWidgetPickerOptions,\n  WindowDimensions as WindowDimensionsBase } from \"test/nbrowser/gristWebDriverUtils\";\nimport { APIConstructor, HomeUtil } from \"test/nbrowser/homeUtil\";\nimport { server } from \"test/nbrowser/testServer\";\nimport { fetchScreenshotAndLogs } from \"test/nbrowser/webdriverUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport * as path from \"path\";\n\nimport axios from \"axios\";\nimport * as fse from \"fs-extra\";\nimport escapeRegExp from \"lodash/escapeRegExp\";\nimport noop from \"lodash/noop\";\nimport startCase from \"lodash/startCase\";\nimport { stackWrapFunc, stackWrapOwnMethods, WebDriver } from \"mocha-webdriver\";\nimport { assert, By, driver as driverOrig, error, Key, WebElement, WebElementPromise } from \"mocha-webdriver\";\nimport { lock } from \"proper-lockfile\";\n\nimport type { AssertionError } from \"assert\";\nimport type { Cleanup } from \"test/server/testCleanup\";\n\n// Wrap in a namespace so that we can apply stackWrapOwnMethods to all the exports together.\nnamespace gristUtils {\n\n  // Re-export types imported from gristWebDriverUtils\n  export const noCleanup = _noCleanup;\n  export type ICellSelect = _ICellSelect;\n\n  // Allow overriding the global 'driver' to use in gristUtil.\n  let _driver: WebDriver | undefined;\n  const driver: WebDriver = new Proxy({} as any, {\n    get(_, prop) {\n      if (!_driver) {\n        return (driverOrig as any)[prop];\n      }\n      return (_driver as any)[prop];\n    },\n  });\n\n  export function currentDriver() { return driver; }\n\n  // Substitute a custom driver to use with gristUtils functions. Omit argument to restore to default.\n  export function setDriver(customDriver?: WebDriver) { _driver = customDriver; }\n\n  const homeUtil = new HomeUtil(testUtils.fixturesRoot, server);\n  const webdriverUtils = new GristWebDriverUtils(driver);\n\n  export const createNewDoc = homeUtil.createNewDoc.bind(homeUtil);\n  // importFixturesDoc has a custom implementation that supports 'load' flag.\n  export const uploadFixtureDoc = homeUtil.uploadFixtureDoc.bind(homeUtil);\n  export const getWorkspaceId = homeUtil.getWorkspaceId.bind(homeUtil);\n  export const listDocs = homeUtil.listDocs.bind(homeUtil);\n  export const createHomeApi = homeUtil.createHomeApi.bind(homeUtil);\n  export const createApi = homeUtil.createApi.bind(homeUtil);\n  export const getApiKey = homeUtil.getApiKey.bind(homeUtil);\n  export const simulateLogin = homeUtil.simulateLogin.bind(homeUtil);\n  export const removeLogin = homeUtil.removeLogin.bind(homeUtil);\n  export const enableTips = homeUtil.enableTips.bind(homeUtil);\n  export const disableTips = homeUtil.disableTips.bind(homeUtil);\n  export const setValue = homeUtil.setValue.bind(homeUtil);\n  export const isOnLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);\n  export const isOnGristLoginPage = homeUtil.isOnLoginPage.bind(homeUtil);\n  export const checkLoginPage = homeUtil.checkLoginPage.bind(homeUtil);\n  export const checkGristLoginPage = homeUtil.checkGristLoginPage.bind(homeUtil);\n  export const copyDoc = homeUtil.copyDoc.bind(homeUtil);\n\n  export const isSidePanelOpen = webdriverUtils.isSidePanelOpen.bind(webdriverUtils);\n  export const waitForServer = webdriverUtils.waitForServer.bind(webdriverUtils);\n  export const waitForSidePanel = webdriverUtils.waitForSidePanel.bind(webdriverUtils);\n  export const toggleSidePanel = webdriverUtils.toggleSidePanel.bind(webdriverUtils);\n  export const getWindowDimensions = webdriverUtils.getWindowDimensions.bind(webdriverUtils);\n  export const setWindowDimensions = webdriverUtils.setWindowDimensions.bind(webdriverUtils);\n  export const addNewSection = webdriverUtils.addNewSection.bind(webdriverUtils);\n  export const selectWidget = webdriverUtils.selectWidget.bind(webdriverUtils);\n  export const dismissBehavioralPrompts = webdriverUtils.dismissBehavioralPrompts.bind(webdriverUtils);\n  export const toggleSelectable = webdriverUtils.toggleSelectable.bind(webdriverUtils);\n  export const waitToPass = webdriverUtils.waitToPass.bind(webdriverUtils);\n  export const refreshDismiss = webdriverUtils.refreshDismiss.bind(webdriverUtils);\n  export const acceptAlert = webdriverUtils.acceptAlert.bind(webdriverUtils);\n  export const isAlertShown = webdriverUtils.isAlertShown.bind(webdriverUtils);\n  export const waitForDocToLoad = webdriverUtils.waitForDocToLoad.bind(webdriverUtils);\n  export const reloadDoc = webdriverUtils.reloadDoc.bind(webdriverUtils);\n  export const sendActions = webdriverUtils.sendActions.bind(webdriverUtils);\n  export const sendCommand = webdriverUtils.sendCommand.bind(webdriverUtils);\n  export const openAccountMenu = webdriverUtils.openAccountMenu.bind(webdriverUtils);\n  export const openProfileSettingsPage = webdriverUtils.openProfileSettingsPage.bind(webdriverUtils);\n  export const undo = webdriverUtils.undo.bind(webdriverUtils);\n  export const bigScreen = webdriverUtils.bigScreen.bind(webdriverUtils);\n  export const narrowScreen = webdriverUtils.narrowScreen.bind(webdriverUtils);\n  export const exactMatch = webdriverUtils.exactMatch.bind(webdriverUtils);\n  export const getSection = webdriverUtils.getSection.bind(webdriverUtils);\n  export const getVisibleGridCells = webdriverUtils.getVisibleGridCells.bind(webdriverUtils);\n  export const getCell = webdriverUtils.getCell.bind(webdriverUtils);\n  export const selectSectionByTitle = webdriverUtils.selectSectionByTitle.bind(webdriverUtils);\n  export const selectSectionByIndex = webdriverUtils.selectSectionByIndex.bind(webdriverUtils);\n\n  export const fixturesRoot: string = testUtils.fixturesRoot;\n\n  export type WindowDimensions = WindowDimensionsBase;\n\n  // Most test code uses simulateLogin through the server reference. Keep them to reduce unnecessary\n  // code changes.\n  server.simulateLogin = simulateLogin;\n  server.removeLogin = removeLogin;\n\n  export interface IColHeader {\n    col: number | string;\n    section?: string | WebElement;\n  }\n\n  /**\n * Helper function that creates a regular expression to match the beginning of the string.\n */\n  export function startsWith(value: string): RegExp {\n    return new RegExp(`^${escapeRegExp(value)}`);\n  }\n\n  /**\n * Helper function that creates a regular expression to match the anywhere in of the string.\n */\n  export function contains(value: string): RegExp {\n    return new RegExp(`${escapeRegExp(value)}`);\n  }\n\n  /**\n * Helper to scroll an element into view. Returns the passed-in element.\n */\n  export function scrollIntoView(elem: WebElement): WebElementPromise {\n    return new WebElementPromise(driver,\n      driver.executeScript((el: any) => el.scrollIntoView({ behavior: \"auto\" }), elem)\n        .then(() => elem));\n  }\n\n  /**\n * Returns the current user of gristApp in the currently-loaded page.\n */\n  export async function getUser(waitMs: number = 1000): Promise<FullUser> {\n    const user = await driver.wait(() => driver.executeScript(`\n    const appObs = window.gristApp && window.gristApp.topAppModel.appObs.get();\n    return appObs && appObs.currentUser;\n  `), waitMs) as FullUser;\n    if (!user) { throw new Error(\"could not find user\"); }\n    return user;\n  }\n\n  /**\n * Returns the current org of gristApp in the currently-loaded page.\n */\n  export async function getOrg(waitMs: number = 1000): Promise<APIOrganization> {\n    const org = await driver.wait(() => driver.executeScript(`\n    const appObs = window.gristApp && window.gristApp.topAppModel.appObs.get();\n    return appObs && appObs.currentOrg;\n  `), waitMs) as APIOrganization;\n    if (!org) { throw new Error(\"could not find org\"); }\n    return org;\n  }\n\n  /**\n * Returns the email of the current user of gristApp in the currently-loaded page.\n */\n  export async function getEmail(waitMs: number = 1000): Promise<string> {\n    return (await getUser(waitMs)).email;\n  }\n\n  /**\n * Returns the name of the current user of gristApp in the currently-loaded page.\n */\n  export async function getName(waitMs: number = 1000): Promise<string> {\n    return (await getUser(waitMs)).name;\n  }\n\n  /**\n * Returns any comparison information in the currently-loaded page.\n */\n  export async function getComparison(waitMs: number = 1000): Promise<DocStateComparison | null> {\n    const result = await driver.wait(() => driver.executeScript(`\n    return window.gristDocPageModel?.gristDoc?.get()?.comparison;\n  `), waitMs) as DocStateComparison;\n    return result || null;\n  }\n\n  export async function testCurrentUrl(pattern: RegExp | string) {\n    const url = await driver.getCurrentUrl();\n    return (typeof pattern === \"string\") ? url.includes(pattern) : pattern.test(url);\n  }\n\n  export async function getDocWorkerUrls(): Promise<string[]> {\n    const result = await driver.wait(() => driver.executeScript(`\n    return Array.from(window.gristApp.comm.listConnections().values());\n  `), 1000) as string[];\n    return result;\n  }\n\n  export async function getDocWorkerUrl(): Promise<string> {\n    const urls = await getDocWorkerUrls();\n    if (urls.length > 1) {\n      throw new Error(`Expected a single docWorker URL, received ${urls}`);\n    }\n    return urls[0] || \"\";\n  }\n\n  export async function waitForUrl(pattern: RegExp | string, waitMs: number = 2000) {\n    await driver.wait(() => testCurrentUrl(pattern), waitMs, `waiting for url ${pattern}`);\n  }\n\n  export async function dismissWelcomeTourIfNeeded() {\n    const elem = driver.find(\".test-onboarding-close\");\n    if (await elem.isPresent()) {\n      await elem.click();\n    }\n    await waitForServer();\n  }\n\n  // Selects all text when a text element is currently active.\n  export async function selectAll() {\n    await driver.executeScript(\"document.activeElement.select()\");\n  }\n\n  /**\n * Detaches section from the layout. Used for manipulating sections in the layout.\n */\n  export async function detachFromLayout(section?: string) {\n    section ??= await getActiveSectionTitle();\n    const handle = getSection(section).find(\".viewsection_drag_indicator\");\n    await driver.withActions(actions => actions\n      .move({ origin: handle }));\n    await driver.withActions(actions => actions\n      .move({ origin: handle, x: 1 }) // This is needed to show the drag element.\n      .press());\n    await driver.withActions(actions => actions.move({ origin: handle, ...{ x: 10, y: 10 } }));\n    return {\n    /** Moves this leave over another section + offset. */\n      async moveTo(otherSection: string, offset?: { x?: number, y?: number }) {\n        const otherSectionElement = await getSection(otherSection).find(\".viewsection_drag_indicator\");\n        await driver.withActions(actions => actions.move({\n          origin: otherSectionElement,\n          ...offset,\n        }));\n        return this;\n      },\n      /** Releases the dragged section. */\n      async release() {\n        await driver.withActions(actions => actions.release());\n        return this;\n      },\n      /**\n     * Waits for Grist to save this section. The save is debounced, so we need to wait\n     * for couple of events.\n     */\n      async waitForSave() {\n      // Wait for the test class that indicates we have pending save.\n        await driver.findWait(\".test-viewLayout-save-pending\", 100);\n        // Then wait for that class to be removed (which means Grist has started saving editor).\n        await waitToPass(async () => {\n          assert.isFalse(await driver.find(\".test-viewLayout-save-pending\").isPresent());\n        });\n        // And wait for the server to process.\n        await waitForServer();\n      },\n    };\n  }\n\n  export async function expandSection(title?: string) {\n    const select = title ?\n      driver.findContent(`.test-viewsection-title`, exactMatch(title)).findClosest(\".viewsection_title\") :\n      driver.find(\".active_section\");\n    await select.find(\".test-section-menu-expandSection\").click();\n    await driver.findWait(\".test-viewLayout-overlay .test-close-button\", 500);\n  }\n\n  export async function getSectionId() {\n    const classList = await driver.find(\".active_section\").getAttribute(\"class\");\n    const match = classList.match(/test-viewlayout-section-(\\d+)/);\n    if (!match) { throw new Error(\"Could not find section id\"); }\n    return parseInt(match[1]);\n  }\n\n  /**\n * Checks if visible section with a grid contains the given data.\n * Data is in form of:\n * [0, \"ColA\", \"ColB\"]\n * [1, \"Val\",  \"1\"] // cells are strings\n * [2, \"Val2\", \"2\"]\n */\n  export async function assertGridData(section: string, data: any[][]) {\n  // Data is in form of\n  // [0, \"ColA\", \"ColB\"]\n  // [1, \"Val\",  1]\n\n    const rowIndices = data.slice(1).map((row: number[]) => row[0]);\n    const columnNames = data[0]?.slice(1) ?? [];\n\n    for (const col of columnNames) {\n      const colIndex = columnNames.indexOf(col) + 1;\n      const colValues = data.slice(1).map((row: string[]) => row[colIndex]);\n      assert.deepEqual(\n        await getVisibleGridCells(col, rowIndices, section),\n        colValues,\n      );\n    }\n  }\n\n  /**\n * Experimental fast version of getVisibleGridCells that reads data directly from browser by\n * invoking javascript code.\n */\n  export async function getVisibleGridCellsFast(col: string, rowNums: number[]): Promise<string[]>;\n  export async function getVisibleGridCellsFast(options: { cols: string[], rowNums: number[] }): Promise<string[]>;\n  export async function getVisibleGridCellsFast(colOrOptions: any, rowNums?: number[]): Promise<string[]> {\n    if (rowNums) {\n      return getVisibleGridCellsFast({ cols: [colOrOptions], rowNums });\n    }\n    // Make sure we have active section.\n    await driver.findWait(\".active_section\", 4000);\n    const cols = colOrOptions.cols;\n    const rows = colOrOptions.rowNums;\n    const result = await driver.executeScript(`\n  const cols = arguments[0];\n  const rowNums = arguments[1];\n  // Read all columns and create object { ['ColName'] : index }\n  const columns = Object.fromEntries([...document.querySelectorAll(\".active_section .g-column-label\")]\n                      .map((col, index) => [col.innerText, index]))\n  const result = [];\n  // Read all rows and create object { [rowIndex] : RowNumberElement }\n  const rowNumElements = Object.fromEntries([...document.querySelectorAll(\".active_section .gridview_data_row_num\")]\n                            .map((row) => [Number(row.innerText), row]))\n  for(const r of rowNums) {\n    // If this is addRow, insert undefined x cols.length.\n    if (rowNumElements[r].parentElement.querySelector('.record-add')) {\n      result.push(...new Array(cols.length));\n      continue;\n    }\n    // Read all values from a row, and create an object { [cellIndex] : 'cell value' }\n    const values = Object.fromEntries([...rowNumElements[String(r)].parentElement.querySelectorAll('.field_clip')]\n                    .map((f, i) => [i, f.innerText]));\n    result.push(...cols.map(c => values[columns[c]]))\n  }\n  return result; `, cols, rows);\n    return result as string[];\n  }\n\n  /**\n * Returns the visible cells of the DetailView in the given field (using column name) at the\n * given row numbers (1-indexed). For example:\n *\n *    gu.getVisibleDetailCells({col: \"Name\", rowNums: [1, 2, 3]});\n *\n * Returns cell text by default. Mapper may be `identity` to return the cell objects.\n *\n * If rowNums are not shown (for single-card view), use rowNum of 1.\n */\n  export async function getVisibleDetailCells(\n    col: number | string, rows: number[], section?: string,\n  ): Promise<string[]>;\n  export async function getVisibleDetailCells<T = string>(options: IColSelect<T> | IColsSelect<T>): Promise<T[]>;\n  export async function getVisibleDetailCells<T>(\n    colOrOptions: number | string | IColSelect<T> | IColsSelect<T>, _rowNums?: number[], _section?: string,\n  ): Promise<T[]> {\n    if (typeof colOrOptions === \"object\" && \"cols\" in colOrOptions) {\n      const { rowNums, section, mapper } = colOrOptions;\n      const columns = await Promise.all(colOrOptions.cols.map(oneCol =>\n        getVisibleDetailCells({ col: oneCol, rowNums, section, mapper })));\n      // This zips column-wise data into a flat row-wise array of values.\n      return ([] as T[]).concat(...rowNums.map((r, i) => columns.map(c => c[i])));\n    }\n\n    const { col, rowNums, section, mapper = el => el.getText() }: IColSelect<any> = (\n      typeof colOrOptions === \"object\" ? colOrOptions :\n        { col: colOrOptions, rowNums: _rowNums!, section: _section }\n    );\n\n    const sectionElem = section ? await getSection(section) : await driver.findWait(\".active_section\", 4000);\n    const visibleRowNums: number[] = await sectionElem.findAll(\".detail_row_num\",\n      async el => parseInt((await el.getText()).replace(/^#/, \"\"), 10) || 1);\n\n    const colName = (typeof col === \"string\") ? col :\n      await (await sectionElem.find(\".g_record_detail_inner\").findAll(\".g_record_detail_label\"))[col].getText();\n\n    const records = await sectionElem.findAll(\".g_record_detail_inner\");\n    const selected = rowNums.map(n => records[visibleRowNums.indexOf(n)]);\n    return Promise.all(selected.map(el => mapper(\n      el.findContent(\".g_record_detail_label\", exactMatch(colName))\n        .findClosest(\".g_record_detail_el\").find(\".g_record_detail_value\"),\n    )));\n  }\n\n  /**\n * Returns a visible DetailView cell, for the given record and field.\n */\n  export function getDetailCell(col: string, rowNum: number, section?: string): WebElementPromise;\n  export function getDetailCell(options: ICellSelect): WebElementPromise;\n  export function getDetailCell(\n    colOrOptions: string | ICellSelect, rowNum?: number, section?: string,\n  ): WebElementPromise {\n    const mapper = async (el: WebElement) => el;\n    const options: IColSelect<WebElement> = (typeof colOrOptions === \"object\" ?\n      { col: colOrOptions.col, rowNums: [colOrOptions.rowNum], section: colOrOptions.section, mapper } :\n      { col: colOrOptions, rowNums: [rowNum!], section, mapper });\n    return new WebElementPromise(driver, getVisibleDetailCells(options).then(elems => elems[0]));\n  }\n\n  /**\n * Helper function for Toggle column to check if it is checked or not.\n */\n  export function isChecked(cell: WebElement) {\n    return cell.find(\".widget_checkmark\").isDisplayed();\n  }\n\n  /**\n * Gets a cell on a single card page.\n */\n  export function getCardCell(col: string, section?: string) {\n    return getDetailCell({ col, rowNum: 1, section });\n  }\n\n  /**\n * Returns the cell containing the cursor in the active section, works for both Grid and Detail.\n */\n  export function getActiveCell(): WebElementPromise {\n    return driver.find(\".active_section .selected_cursor\").findClosest(\".g_record_detail_value,.field\");\n  }\n\n  /**\n * Returns a visible GridView row from the active section.\n */\n  export function getRow(rowNum: number): WebElementPromise {\n    return driver.findContent(\".active_section .gridview_data_row_num\", String(rowNum));\n  }\n\n  /**\n * Get the numeric value from the row header of the first selected row. This would correspond to\n * the row with the cursor when a single rows is selected.\n */\n  export async function getSelectedRowNum(section?: string): Promise<number> {\n    const sectionElem = section ? await getSection(section) : await driver.find(\".active_section\");\n    const rowNum = await sectionElem.find(\".gridview_data_row_num.selected\").getText();\n    return parseInt(rowNum, 10);\n  }\n\n  /**\n * Returns the total row count in the grid that is the active section by scrolling to the bottom\n * and examining the last row number. The count includes the special \"Add Row\".\n */\n  export async function getGridRowCount(): Promise<number> {\n    await sendKeys(Key.chord(await modKey(), Key.DOWN));\n    const rowNum = await driver.find(\".active_cursor\")\n      .findClosest(\".gridview_row\").find(\".gridview_data_row_num\").getText();\n    return parseInt(rowNum, 10);\n  }\n\n  /**\n * Returns the total row count in the card list that is the active section by scrolling to the bottom\n * and examining the last row number. The count includes the special \"Add Row\".\n */\n  export async function getCardListCount(): Promise<number> {\n    await sendKeys(Key.chord(await modKey(), Key.DOWN));\n    const rowNum = await driver.find(\".active.detailview_record_detail .detail_row_num\").getText();\n    return parseInt(rowNum, 10);\n  }\n\n  /**\n * Returns the total row count in the card widget that is the active section by looking\n * at the displayed count in the section header. The count includes the special \"Add Row\".\n */\n  export async function getCardCount(): Promise<number> {\n    const section = await driver.findWait(\".active_section\", 4000);\n    const counter = await section.findAll(\".grist-single-record__menu__count\");\n    if (counter.length) {\n      const cardRow = (await counter[0].getText()).split(\" OF \")[1];\n      return  parseInt(cardRow) + 1;\n    }\n    return 1;\n  }\n\n  /**\n * Return the .column-name element for the specified column, which may be specified by full name\n * or index, and may include a section (or will use the active section by default).\n */\n  export function getColumnHeader(colOrColOptions: string | IColHeader, opts = { waitMs: 0 }): WebElementPromise {\n    const colOptions = typeof colOrColOptions === \"string\" ? { col: colOrColOptions } : colOrColOptions;\n    const { col, section } = colOptions;\n    const sectionElem = section ? getSection(section) : driver.findWait(\".active_section\", 4000);\n    return new WebElementPromise(driver, typeof col === \"number\" ?\n      sectionElem.find(`.column_name:nth-child(${col + 1})`) :\n      (opts?.waitMs ?\n        sectionElem.findContentWait(\".column_name .test-column-title-text\", exactMatch(col), opts.waitMs) :\n        sectionElem.findContent(\".column_name .test-column-title-text\", exactMatch(col))\n      ).findClosest(\".column_name\"));\n  }\n\n  export function getSelectedColumn() {\n    return driver.find(\".active_section .column_name.selected\");\n  }\n\n  export async function getColumnNames() {\n    const section = await driver.findWait(\".active_section\", 4000);\n    return (await section.findAll(\".column_name\", el => el.getText()))\n      .filter(name => name !== \"+\");\n  }\n\n  export async function getCardFieldLabels() {\n    const section = await driver.findWait(\".active_section\", 4000);\n    const firstCard = await section.find(\".g_record_detail\");\n    const labels = await firstCard.findAll(\".g_record_detail_label\", el => el.getText());\n    return labels;\n  }\n\n  /**\n * Resize the given grid column by a given number of pixels.\n */\n  export async function resizeColumn(colOptions: string | IColHeader, deltaPx: number) {\n    await getColumnHeader(colOptions).find(\".ui-resizable-handle\").mouseMove();\n    await driver.mouseDown();\n    await driver.mouseMoveBy({ x: deltaPx });\n    await driver.mouseUp();\n    await waitForServer();\n  }\n\n  /**\n * Checks the width of visible column.\n */\n  export async function assertColumnWidth(colOptions: string | IColHeader, width: number) {\n    assert.closeTo((await getColumnHeader(colOptions).rect()).width, width, 2);\n  }\n\n  /**\n * Moves one column onto another column (moving it to its right or left, depending on the order).\n * Simulates drag and drop of the column header.\n */\n  export async function moveColumn(which: string | IColHeader, where: string | IColHeader) {\n    await selectColumn(which);\n    await getColumnHeader(which).mouseMove({ y: 1 });\n    await driver.mouseDown();\n    await waitToPass(async () => {\n      assert.isTrue(await driver.find(\".active_section .col_indicator_line\").isDisplayed());\n    });\n    await getColumnHeader(where).mouseMove({ y: 1 });\n    await driver.mouseUp();\n    await waitToPass(async () => {\n      assert.isFalse(await driver.find(\".active_section .col_indicator_line\").isDisplayed());\n    });\n  }\n\n  export async function getColumnWidth(colOptions: string | IColHeader) {\n    return (await getColumnHeader(colOptions).rect()).width;\n  }\n\n  /**\n * Performs dbClick\n * @param cell Element to click\n */\n  export async function dbClick(cell: WebElement) {\n    await driver.withActions(a => a.doubleClick(cell));\n  }\n\n  export async function rightClick(cell: WebElement) {\n    await driver.withActions(actions => actions.contextClick(cell));\n  }\n\n  /**\n * Clicks a Reference List cell, taking care not to click the icon (which can\n * cause an unexpected Record Card popup to appear).\n */\n  export async function clickReferenceListCell(cell: WebElement) {\n    const tokens = await cell.findAll(\".test-ref-list-cell-token-label\");\n    if (tokens.length > 0) {\n      await tokens[0].click();\n    } else {\n      await cell.click();\n    }\n  }\n\n  /**\n * Gets the selector position in the Grid view section (or null if not present).\n * Selector is the black box around the row number.\n */\n  export async function getSelectorPosition(section?: WebElement | string) {\n    if (typeof section === \"string\") { section = await getSection(section); }\n    section = section ?? await driver.findWait(\".active_section\", 4000);\n    const hasSelector = await section.find(\".link_selector_row\").isPresent();\n    return hasSelector && Number(await section.find(\".link_selector_row .gridview_data_row_num\").getText());\n  }\n\n  /**\n * Gets the arrow position in the Grid view section (or null if no arrow is present).\n */\n  export async function getArrowPosition(section?: WebElement | string) {\n    if (typeof section === \"string\") { section = await getSection(section); }\n    section = section ?? await driver.findWait(\".active_section\", 4000);\n    const arrow = section.find(\".gridview_data_row_info.linked_dst\");\n    const hasArrow = await arrow.isPresent();\n    return hasArrow ? Number(\n      await arrow.findElement(By.xpath(\"./..\")) // Get its parent\n        .getText(),\n    ) : null;\n  }\n\n  /**\n * Returns {rowNum, col} object representing the position of the cursor in the active view\n * section. RowNum is a 1-based number as in the row headers, and col is a 0-based index for\n * grid view or field name for detail view.\n */\n  export async function getCursorPosition(section?: WebElement | string) {\n    return await retryOnStale(async () => {\n      if (typeof section === \"string\") { section = await getSection(section); }\n      section = section ?? await driver.findWait(\".active_section\", 4000);\n      const cursor = await section.findWait(\".selected_cursor\", 1000);\n      // Query assuming the cursor is in a GridView and a DetailView, then use whichever query data\n      // works out.\n      const [colIndex, rowIndex, rowNum, colName] = await Promise.all([\n        catchNoSuchElem(() => cursor.findClosest(\".field\").index()),\n        catchNoSuchElem(() => cursor.findClosest(\".gridview_row\").index()),\n        catchNoSuchElem(() => cursor.findClosest(\".g_record_detail\").find(\".detail_row_num\").getText()),\n        catchNoSuchElem(() => cursor.findClosest(\".g_record_detail_el\")\n          .find(\".g_record_detail_label\").getText()),\n      ]);\n      if (rowNum && colName) {\n      // This must be a detail view, and we just got the info we need.\n        return { rowNum: parseInt(rowNum, 10), col: colName };\n      } else {\n      // We might be on a single card record\n        const counter = await section.findAll(\".grist-single-record__menu__count\");\n        if (counter.length) {\n          const cardRow = (await counter[0].getText()).split(\" OF \")[0];\n          return { rowNum: parseInt(cardRow), col: colName };\n        }\n        // Otherwise, it's a grid view, and we need to use indices to look up the info.\n        const gridRows = await section.findAll(\".gridview_data_row_num\");\n        const gridRowNum = await gridRows[rowIndex].getText();\n        return { rowNum: parseInt(gridRowNum, 10), col: colIndex };\n      }\n    });\n  }\n\n  export async function isCursorPresent(section?: WebElement | string,\n    type: \"active\" | \"selected\" = \"selected\"): Promise<boolean> {\n    if (typeof section === \"string\") {\n      section = getSection(section);\n    } else {\n      section = section ?? driver.findWait(\".active_section\", 4000);\n    }\n    return section.find(type === \"active\" ? \".active_cursor\" : \".selected_cursor\").isPresent();\n  }\n\n  /**\n * Catches any NoSuchElementError in a query callback and returns null as the result instead.\n */\n  async function catchNoSuchElem(query: () => any) {\n    try {\n      return await query();\n    } catch (err) {\n      if (err instanceof error.NoSuchElementError) { return null; }\n      throw err;\n    }\n  }\n\n  export async function retryOnStale<T>(query: () => Promise<T>): Promise<T> {\n    try {\n      return await query();\n    } catch (err) {\n      if (err instanceof error.StaleElementReferenceError) { return await query(); }\n      throw err;\n    }\n  }\n\n  /**\n * Type keys in the currently active cell, then hit Enter to save, and wait for the server.\n * If the last key is TAB, DELETE, or ENTER, we assume the cell is already taken out of editing\n * mode, and don't send another ENTER.\n */\n  export async function enterCell(...keys: string[]) {\n    const lastKey = keys[keys.length - 1];\n    if (![Key.ENTER, Key.TAB, Key.DELETE].includes(lastKey)) {\n      keys.push(Key.ENTER);\n    }\n    await driver.sendKeys(...keys);\n    await waitForServer();    // Wait for the value to be saved\n  }\n\n  /**\n * Enter a formula into the currently selected cell.\n *\n * You can insert newlines by embedding `${Key.chord(Key.SHIFT, Key.ENTER)}` into the formula\n * text. Note that ACE editor adds some indentation automatically.\n */\n  export async function enterFormula(formula: string) {\n    await driver.sendKeys(\"=\");\n    await waitAppFocus(false);\n    if (await driver.find(\".test-editor-tooltip-convert\").isPresent()) {\n      await driver.find(\".test-editor-tooltip-convert\").click();\n    }\n    await sendKeys(formula, Key.ENTER);\n    await waitForServer();\n  }\n\n  /**\n * Check that formula editor is shown and returns its value.\n * By default returns only text that is visible to the user, pass false to get all text.\n */\n  export async function getFormulaText(onlyVisible = true): Promise<string> {\n    assert.equal(await driver.findWait(\".test-formula-editor\", 500).isDisplayed(), true);\n    if (onlyVisible) {\n      return await driver.find(\".code_editor_container\").getText();\n    } else {\n      return await driver.executeScript(\n        () => (document as any).querySelector(\".code_editor_container\").innerText,\n      );\n    }\n  }\n\n  /**\n * Check that formula editor is shown and its value matches the given regexp.\n */\n  export async function checkFormulaEditor(value: RegExp | string) {\n    const valueRe = typeof value === \"string\" ? exactMatch(value) : value;\n    assert.match(await getFormulaText(), valueRe);\n  }\n\n  /**\n * Check that plain text editor is shown and its value matches the given regexp.\n */\n  export async function checkTextEditor(value: RegExp | string) {\n    assert.equal(await driver.findWait(\".test-widget-text-editor\", 500).isDisplayed(), true);\n    const valueRe = typeof value === \"string\" ? exactMatch(value) : value;\n    assert.match(await driver.find(\".celleditor_text_editor\").value(), valueRe);\n  }\n\n  /**\n * Checks that token editor in a cell has a correct value. Converts all tokens to text including the input field\n * and joins them with newlines.\n */\n  export async function checkTokenEditor(value: RegExp | string) {\n    assert.equal(await driver.findWait(\".test-widget-text-editor\", 500).isDisplayed(), true);\n    const valueRe = typeof value === \"string\" ? exactMatch(value) : value;\n    const allTokens = await driver.findAll(\n      \".test-widget-text-editor .test-tokenfield .test-tokenfield-token\", e => e.getText());\n    const inputToken = await driver.find(\".test-widget-text-editor .test-tokenfield .test-tokenfield-input\").value();\n    const combined = [...allTokens, inputToken].join(\"\\n\").trim();\n    assert.match(combined, valueRe);\n  }\n\n  /**\n * Enter rows of values into a GridView, starting at the given cell. Values are specified as a\n * list of rows, for examples `[['foo'], ['bar']]` will enter two rows, with one value in each.\n */\n  export async function enterGridRows(cell: ICellSelect, rowsOfValues: string[][]) {\n    for (let i = 0; i < rowsOfValues.length; i++) {\n    // Click the first cell in the row\n      await getCell({ ...cell, rowNum: cell.rowNum + i }).click();\n      // Enter all values, advancing with a TAB\n      for (const value of rowsOfValues[i]) {\n        await enterCell(value || Key.DELETE, Key.TAB);\n      }\n    }\n  }\n\n  /**\n * Enters values into a grid starting at the specified row and column index.\n */\n  export async function enterGridValues(\n    startRowIndex: number,\n    startColIndex: number,\n    dataMatrix: any[][]): Promise<void> {\n    const transpose = dataMatrix[0].map((_, colIndex) => dataMatrix.map(row => row[colIndex]));\n    await enterGridRows({ col: startColIndex, rowNum: startRowIndex + 1 }, transpose);\n  }\n\n  /**\n * Selects all cells in a GridView between and including startCell and endCell\n * @param startCell - An array where:\n *                    startCell[0]: 1-based row index.\n *                    startCell[1]: 0-based column index.\n * @param endCell - An array where:\n *                  endCell[0]: 1-based row index.\n *                  endCell[1]: 0-based column index.\n */\n  export async function selectGridArea(startCell: [number, number], endCell: [number, number]) {\n    const [startRowNum, startCol] = startCell;\n    const [endRowNum, endCol] = endCell;\n\n    if (startRowNum === endRowNum && startCol === endCol) {\n      await getCell({ rowNum: endRowNum, col: endCol }).click();\n    } else {\n      const start = await getCell({ rowNum: startRowNum, col: startCol });\n      const end = await getCell({ rowNum: endRowNum, col: endCol });\n      await driver.withActions(a => a.click(start).keyDown(Key.SHIFT).click(end).keyUp(Key.SHIFT));\n    }\n  }\n\n  /**\n * Set api key for user.  User should exist before this is called.\n */\n  export async function setApiKey(username: string, apiKey?: string) {\n    apiKey = apiKey || `api_key_for_${username.toLowerCase()}`;\n    const dbManager = await server.getDatabase();\n    await dbManager.connection.query(`update users set api_key = $1 where name = $2`,\n      [apiKey, username]);\n    if (!await dbManager.getUserByKey(apiKey)) {\n      throw new Error(`setApiKey failed: user ${username} may not yet be in the database`);\n    }\n  }\n\n  /**\n * Reach into the DB to set the given org to use the given billing plan product.\n */\n  export async function updateOrgPlan(orgName: string, productName: string = \"team\") {\n    const dbManager = await server.getDatabase();\n    const db = dbManager.connection.manager;\n    const dbOrg = await db.findOne(Organization, { where: { name: orgName },\n      relations: [\"billingAccount\", \"billingAccount.product\"] });\n    if (!dbOrg) { throw new Error(`cannot find org ${orgName}`); }\n    const product = await db.findOne(Product, { where: { name: productName } });\n    if (!product) { throw new Error(\"cannot find product\"); }\n    dbOrg.billingAccount.product = product;\n    await dbOrg.billingAccount.save();\n  }\n\n  export interface ImportOpts {\n    load?: boolean;     // Defaults to true.\n    newName?: string;   // Import under an alternative name.\n    email?: string;      // Use api key associated with this email.\n  }\n\n  /**\n * Import a fixture doc into a workspace. Loads the document afterward unless `load` is false.\n *\n * Usage:\n *  > await importFixturesDoc('chimpy', 'nasa', 'Horizon', 'Hello.grist');\n */\n  // TODO New code should use {load: false} to prevent loading. The 'newui' value is now equivalent\n  // to the default ({load: true}), and should no longer be used in new code.\n  export async function importFixturesDoc(username: string, org: string, workspace: string,\n    filename: string, options: ImportOpts | false | \"newui\" = { load: true }) {\n    if (typeof options !== \"object\") {\n      options = { load: Boolean(options) };   // false becomes {load: false}, 'newui' becomes {load: true}\n    }\n    const doc = await homeUtil.importFixturesDoc(username, org, workspace, filename, options);\n    if (options.load !== false) {\n      await driver.get(server.getUrl(org, `/doc/${doc.id}`));\n      await waitForDocToLoad();\n    }\n    return doc;\n  }\n\n  /**\n * Load a doc at the given URL relative to server.getHost(), e.g. \"/o/ORG/doc/DOC_ID\", and wait\n * for the doc to load (unless wait set to false).\n */\n  export async function loadDoc(relPath: string, wait: boolean = true): Promise<void> {\n    await driver.get(`${server.getHost()}${relPath}`);\n    if (wait) { await waitForDocToLoad(); }\n  }\n\n  /**\n * Load a DocMenu on a site.\n *\n * If loading for a potentially first-time user, you may give 'skipOnboarding' for second\n * argument to skip the onboarding flow, if it gets shown.\n */\n  export async function loadDocMenu(relPath: string, wait: boolean | \"skipOnboarding\" = true): Promise<void> {\n    await driver.get(`${server.getHost()}${relPath}`);\n    if (wait === \"skipOnboarding\") {\n      const first = await Promise.race([\n        driver.findWait(\".test-onboarding-page\", 2000),\n        driver.findWait(\".test-dm-doclist\", 2000),\n      ]);\n      if (await first.matches(\".test-onboarding-page\")) {\n        await skipOnboarding();\n      }\n    }\n    if (wait) { await waitForDocMenuToLoad(); }\n  }\n\n  /**\n   * Open a document as the given user.\n   * @param user The user data (see translateUser)\n   * @param docId The ID of the document to open\n   * @param options The options to pass to Session.prototype.loadDoc\n   */\n  export async function openDocAs(user: UserData, docId: string, options?: { wait: boolean }) {\n    const mainSession = await session().teamSite.user(user).login();\n    return await mainSession.loadDoc(`/doc/${docId}`, options);\n  }\n\n  /**\n * Wait for the doc list to show, to know that workspaces are fetched, and imports enabled.\n */\n  export async function waitForDocMenuToLoad(): Promise<void> {\n    await driver.findWait(\".test-dm-doclist\", 8000); // postgres locally can be slow\n    await driver.wait(() => driver.find(\".test-dm-doclist\").isDisplayed(), 2000);\n  }\n\n  // Checks if we are configured to store docs in s3, and returns access to s3 if so.\n  // For this to be useful in tests against deployments, s3-related env variables should\n  // be set to match the deployment.\n  export function getStorage()  {\n    return create.ExternalStorage(\"doc\", \"\") || null;\n  }\n\n  /**\n * Add a handler on the browser to prevent default action on the next click of an element\n * matching the given selector (it doesn't have to exist at the time of the call).\n * This handler is removed after one call. Used by fileDialogUpload().\n */\n  async function preventDefaultClickAction(selector: string) {\n    function script(_selector: string) {\n      function handler(ev: any) {\n        if (ev.target.matches(_selector)) {\n          document.body.removeEventListener(\"click\", handler);\n          ev.preventDefault();\n        }\n      }\n      document.body.addEventListener(\"click\", handler);\n    }\n    await driver.executeScript(script, selector);\n  }\n\n  /**\n * Upload the given file after running the triggerDialogFunc() which should open the file dialog.\n * Relies on #file_dialog_input <input type=file> element being used to open the dialog.\n */\n  export async function fileDialogUpload(filePath: string, triggerDialogFunc: () => Promise<void>) {\n  // This is a bit of a hack to prevent the file dialog from opening (since the webdriver\n  // seems unable to ever close it).\n    await preventDefaultClickAction(\"#file_dialog_input\");\n    await triggerDialogFunc();\n\n    // Hack to upload multiple files, paths should be separated with '\\n'.\n    // It only seems to work with Chrome\n    const paths = filePath.split(\",\").map(f => path.resolve(fixturesRoot, f)).join(\"\\n\");\n    await driver.findWait(\"#file_dialog_input\", 100).sendKeys(paths);\n  }\n\n  /** Opens upload dialog for a cell */\n  export async function openUploadDialog(cell: WebElement): Promise<void>;\n  export async function openUploadDialog(col: string, row: number): Promise<void>;\n  export async function openUploadDialog(...args: any): Promise<void> {\n    const cell = args.length === 1 ? args[0] : getCell(args[0], args[1]);\n    await cell.click();\n    await preventDefaultClickAction(\"#file_dialog_input\");\n    await cell.find(\".test-attachment-icon\").click();\n  }\n\n  /** Returns a number attachments in a cell */\n  export async function numberOfAttachments(cell: WebElement): Promise<number>;\n  export async function numberOfAttachments(col: string, row: number): Promise<number>;\n  export async function numberOfAttachments(...args: any): Promise<number> {\n    const cell: WebElement = args.length === 1 ? args[0] : getCell(args[0], args[1]);\n    return (await cell.findAll(\".test-pw-thumbnail\")).length;\n  }\n\n  /** Waits for specific number of attachments in a cell */\n  export async function waitForAttachments(cell: WebElement, count: number): Promise<void>;\n  export async function waitForAttachments(col: string, row: number, count: number): Promise<void>;\n  export async function waitForAttachments(...args: any): Promise<void> {\n    const cell: WebElement = args.length === 3 ? getCell(args[0], args[1]) : args[0];\n    await waitToPass(async () => {\n      assert.equal(await numberOfAttachments(cell), args[args.length - 1]);\n    });\n  }\n\n  /** Uploads files to an attachment cell */\n  export async function uploadFiles(...files: string[]) {\n    const paths = files.map(f => path.resolve(fixturesRoot, f)).join(\"\\n\");\n    await driver.find(\"#file_dialog_input\").sendKeys(paths);\n    await waitForServer();\n  }\n\n  /**\n * From a document page, start import from a file, and wait for the import dialog to open.\n */\n  export async function importFileDialog(filePath: string): Promise<void> {\n    await fileDialogUpload(filePath, async () => {\n      await driver.wait(() => driver.find(\".test-dp-add-new\").isDisplayed(), 3000);\n      // Sometimes the button won't click straight off, I'm not sure why.\n      await waitToPass(async () => {\n        await driver.findWait(\".test-dp-add-new\", 1000).doClick();\n      }, 5000);\n      await findOpenMenuItem(\".test-dp-import-option\", /Import from file/i).doClick();\n    });\n    await driver.findWait(\".test-importer-dialog\", 5000);\n    await waitForServer(15_000);\n  }\n\n  /**\n * From a document page, start an import from a URL.\n */\n  export async function importUrlDialog(url: string): Promise<void> {\n    await driver.wait(() => driver.find(\".test-dp-add-new\").isDisplayed(), 3000);\n    await driver.findWait(\".test-dp-add-new\", 1000).doClick();\n    await driver.findContentWait(\".test-dp-import-option\", /Import from URL/i, 2000).doClick();\n    await driver.findWait(\".test-importer-dialog\", 5000);\n    await waitForServer();\n    const iframe = driver.find(\".test-importer-dialog\").find(\"iframe\");\n    await driver.switchTo().frame(iframe);\n    await setValue(await driver.findWait(\"#url\", 5000), url);\n    await driver.find(\"#ok\").doClick();\n    await driver.switchTo().defaultContent();\n  }\n\n  /**\n * Executed passed function in the context of given iframe, and then switching back to original context\n *\n */\n  export async function doInIframe<T>(func: () => Promise<T>): Promise<T>;\n  export async function doInIframe<T>(iframe: WebElement, func: () => Promise<T>): Promise<T>;\n  export async function doInIframe<T>(\n    frameOrFunc: WebElement | (() => Promise<T>), func?: () => Promise<T>,\n  ): Promise<T> {\n    try {\n      let iframe: WebElement;\n      if (!func) {\n        func = frameOrFunc as () => Promise<T>;\n        iframe = await driver.findWait(\"iframe\", 5000);\n      } else {\n        iframe = frameOrFunc as WebElement;\n      }\n      await driver.switchTo().frame(iframe);\n      return await func();\n    } finally {\n      await driver.switchTo().defaultContent();\n    }\n  }\n\n  /**\n * Starts or resets the collections of UserActions. This should be followed some time later by\n * a call to userActionsVerify() to check which UserActions were sent to the server. If the\n * argument is false, then stops the collection.\n */\n  export async function userActionsCollect(yesNo: boolean = true) {\n  // For determinism, wait for any pending server requests to complete.\n    await waitForServer();\n    return driver.executeScript(\"window.gristApp.comm.userActionsCollect(arguments[0])\", yesNo);\n  }\n\n  /**\n * Verifies the list of UserActions collected since the last call to userActionsCollect() or\n * userActionsVerify(). ExpectedUserActions should be a list of actions, with each action in the\n * format of [\"AddRecord\", args...].\n */\n  export async function userActionsVerify(expectedUserActions: unknown[]): Promise<void> {\n    try {\n    // For determinism, wait for any pending server requests to complete.\n      await waitForServer();\n      assert.deepEqual(\n        await driver.executeScript(\"return window.gristApp.comm.userActionsFetchAndReset()\"),\n        expectedUserActions);\n    } catch (err) {\n      const assertError = err as AssertionError;\n      if (!Array.isArray(assertError.actual)) {\n        throw new Error(\"userActionsVerify: no user actions, run userActionsCollect() first\");\n      }\n      if (!Array.isArray(assertError.expected)) {\n        throw new Error(\"userActionsVerify: no expected user actions\");\n      }\n\n      assertError.actual = assertError.actual.map((a: any) => JSON.stringify(a) + \",\").join(\"\\n\");\n      assertError.expected = assertError.expected.map((a: any) => JSON.stringify(a) + \",\").join(\"\\n\");\n      assert.deepEqual(assertError.actual, assertError.expected);\n      throw err;\n    }\n  }\n\n  /**\n * Helper to get the cells of the importer Preview section. The cell text is returned from the\n * requested rows and columns in row-wise order.\n */\n  export async function getPreviewContents<T = string>(cols: number[], rowNums: number[],\n    mapper?: (e: WebElement) => Promise<T>): Promise<T[]> {\n    await driver.findWait(\".test-importer-preview .gridview_row\", 1000);\n    const section = await driver.find(\".test-importer-preview\");\n    return getVisibleGridCells({ cols, rowNums, section, mapper });\n  }\n\n  /**\n * Helper to get a cell from the importer Preview section.\n */\n  export async function getPreviewCell(col: string | number, rowNum: number): Promise<WebElementPromise> {\n    await driver.findWait(\".test-importer-preview .gridview_row\", 1000);\n    const section = await driver.find(\".test-importer-preview\");\n    return getCell({ col, rowNum, section });\n  }\n\n  /**\n * Upload a file with the given path via the 'Add-New > Import' menu.\n */\n  export async function docMenuImport(filePath: string) {\n    await fileDialogUpload(filePath, async () => {\n      await driver.findWait(\".test-dm-add-new\", 1000).doClick();\n      await driver.findWait(\".test-dm-import\", 100).doClick();\n    });\n  }\n\n  export async function hasFocus(selector: string): Promise<boolean> {\n    return await driver.find(selector).hasFocus();\n  }\n\n  /**\n * Wait for the focus to return to the main application, i.e. the special .copypaste element that\n * normally has it (as opposed to an open cell editor, or a focus in some input or menu). Specify\n * `false` to wait for the focus to leave the main application.\n */\n  export async function waitAppFocus(yesNo: boolean = true): Promise<void> {\n    await driver.wait(async () => (await driver.find(\".copypaste\").hasFocus()) === yesNo, 5000);\n  }\n\n  /**\n * trigger the keyboard shortcut to keyboard-focus in/out the creator panel\n */\n  export async function toggleCreatorPanelFocus() {\n    await sendKeys(Key.chord(await modKey(), Key.ALT, \"o\"));\n  }\n\n  /**\n * Wait for the focus to be on the first element matching given selector.\n */\n  export async function waitForFocus(selector: string, yesNo: boolean = true, waitMs: number = 1000): Promise<void> {\n    await driver.wait(async () => (await hasFocus(selector) === yesNo), waitMs);\n  }\n\n  export async function waitForLabelInput(): Promise<void> {\n    await driver.wait(async () => (await driver.findWait(\".test-column-title-label\", 100).hasFocus()), 300);\n  }\n\n  export async function getDocId() {\n    const docId = await driver.wait(() => driver.executeScript(`\n    return window.gristDocPageModel.currentDocId.get()\n  `)) as string;\n    if (!docId) { throw new Error(\"could not find doc\"); }\n    return docId;\n  }\n\n  /**\n * Confirms dialog for removing rows. In the future, can be used for other dialogs.\n */\n  export async function confirm(save = true, remember = false) {\n    try {\n    // Wait a bit for this dialog to show up. This is equivalent of:\n    // driver.findWait('.test-confirm-save', 50).isPresent();\n    // Which doesn't work.\n      await driver.findWait(\".test-confirm-save\", 50);\n    } catch (err) {\n      return;\n    }\n    if (remember) {\n      await driver.find(\".test-confirm-remember\").click();\n    }\n    if (save) {\n      await driver.find(\".test-confirm-save\").click();\n    } else {\n      await driver.find(\".test-confirm-cancel\").click();\n    }\n  }\n\n  /** Hides all top banners by injecting css style */\n  export async function hideBanners() {\n    const style = `.test-banner-element { display: none !important; }`;\n    await driver.executeScript(`const style = document.createElement('style');\n    style.innerHTML = ${JSON.stringify(style)};\n    document.head.appendChild(style);`);\n  }\n\n  export async function assertBannerText(text: string | null | RegExp) {\n    if (text === null) {\n      assert.isFalse(await driver.find(\".test-banner-element\").isPresent());\n    } else {\n      assert.match(\n        await driver.findWait(\".test-doc-usage-banner-text\", 2000).getText(),\n        typeof text === \"string\" ? exactMatch(text) : text,\n      );\n    }\n  }\n\n  /**\n * Returns the left-panel item for the given page, given by a full string name, or a RegExp.\n * You may simply click it to switch to that page.\n */\n  export function getPageItem(pageName: string | RegExp): WebElementPromise {\n  // If pageName is a string, search for an exact match.\n    const matchName: RegExp = typeof pageName === \"string\" ? exactMatch(pageName) : pageName;\n    return driver.findContent(\".test-docpage-label\", matchName)\n      .findClosest(\".test-treeview-itemHeaderWrapper\");\n  }\n\n  export async function openPage(name: string | RegExp) {\n    await toggleSidePanel(\"left\", \"open\");\n    await driver.findContentWait(\".test-treeview-itemHeader\", name, 500).find(\".test-docpage-initial\").doClick();\n    await waitForServer(); // wait for table load\n  }\n\n  /**\n * Open the page menu for the specified page (by clicking the dots icon visible on hover).\n */\n  export async function openPageMenu(pageName: RegExp | string) {\n    await getPageItem(pageName).mouseMove()\n      .find(\".test-docpage-dots\").click();\n    // Wait for the menu to appear.\n    await driver.findWait(\".grist-floating-menu\", 100);\n  }\n\n  /**\n * Returns a promise that resolves with the list of all page names.\n */\n  export function getPageNames(): Promise<string[]> {\n    return driver.findAll(\".test-docpage-label\", e => e.getText());\n  }\n\n  export interface PageTree {\n    label: string;\n    children?: PageTree[];\n  }\n  /**\n * Returns a current page tree as a JSON object.\n */\n  export async function getPageTree(): Promise<PageTree[]> {\n    const allPages = await driver.findAll(\".test-docpage-label\");\n    const root: PageTree = { label: \"root\", children: [] };\n    const stack: PageTree[] = [root];\n    let current = 0;\n    for (const page of allPages) {\n      const label = await page.getText();\n      const offset = await page.findClosest(\".test-treeview-itemHeader\").find(\".test-treeview-offset\");\n      const level = parseInt((await offset.getCssValue(\"width\")).replace(\"px\", \"\")) / 10;\n      if (level === current) {\n        const parent = stack.pop()!;\n        parent.children ??= [];\n        parent.children.push({ label });\n        stack.push(parent);\n      } else if (level > current) {\n        current = level;\n        const child = { label };\n        const grandFather = stack.pop()!;\n        grandFather.children ??= [];\n        const father = grandFather.children[grandFather.children.length - 1];\n        father.children ??= [];\n        father.children.push(child);\n        stack.push(grandFather);\n        stack.push(father);\n      } else {\n        while (level < current) {\n          stack.pop();\n          current--;\n        }\n        const parent = stack.pop()!;\n        parent.children ??= [];\n        parent.children.push({ label });\n        stack.push(parent);\n      }\n    }\n    return root.children!;\n  }\n\n  /**\n * Adds a new empty table using the 'Add New' menu.\n */\n  export async function addNewTable(name?: string) {\n    await driver.findWait(\".test-dp-add-new\", 2000).click();\n    await findOpenMenu();\n    await driver.find(\".test-dp-empty-table\").click();\n    if (name) {\n      const prompt = await driver.find(\".test-modal-prompt\");\n      await prompt.doClear();\n      await prompt.click();\n      await driver.sendKeys(name);\n    }\n    await driver.find(\".test-modal-confirm\").click();\n    await waitForServer();\n  }\n\n  // Add a new page using the 'Add New' menu and wait for the new page to be shown.\n  export async function addNewPage(\n    typeRe: RegExp | \"Table\" | \"Card\" | \"Card List\" | \"Chart\" | \"Custom\" | \"Form\",\n    tableRe: RegExp | string,\n    options?: PageWidgetPickerOptions) {\n    const url = await driver.getCurrentUrl();\n\n    // Click the 'Page' entry in the 'Add New' menu\n    await driver.findWait(\".test-dp-add-new\", 2000).doClick();\n    await driver.findWait(\".test-dp-add-new-page\", 2000).doClick();\n\n    // add widget\n    await selectWidget(typeRe, tableRe, options);\n\n    // wait new page to be selected\n    await driver.wait(async () => (await driver.getCurrentUrl()) !== url, 2000);\n  }\n\n  export async function duplicatePage(name: string | RegExp, newName?: string) {\n    await openPageMenu(name);\n    await driver.findWait(\".test-docpage-duplicate\", 100).click();\n\n    if (newName) {\n    // Input will select text on focus, which can alter the text we enter,\n    // so make sure we type correct value.\n      await waitToPass(async () => {\n        const input = driver.find(\".test-modal-dialog input\");\n        await input.click();\n        await selectAll();\n        await driver.sendKeys(newName);\n        assert.equal(await input.value(), newName);\n      });\n    }\n\n    await driver.find(\".test-modal-confirm\").click();\n    await driver.findContentWait(\".test-docpage-label\", newName ?? /copy/, 6000);\n    await waitForServer();\n  }\n\n  export async function openAddWidgetToPage() {\n    await driver.findWait(\".test-dp-add-new\", 2000).doClick();\n    await driver.findWait(\".test-dp-add-widget-to-page\", 2000).doClick();\n    await driver.findWait(\".test-wselect-container\", 100);\n  }\n\n  export type WidgetType = \"Table\" | \"Card\" | \"Card List\" | \"Chart\" | \"Custom\";\n\n  export async function changeWidget(type: WidgetType) {\n    await openWidgetPanel();\n    await driver.findContent(\".test-right-panel button\", /Change widget/).click();\n    await selectWidget(type);\n    await waitForServer();\n  }\n\n  /**\n * Rename the given page to a new name. The oldName can be a full string name or a RegExp.\n */\n  export async function renamePage(oldName: string | RegExp, newName?: string) {\n    if (!newName && typeof oldName === \"string\") {\n      newName = oldName;\n      oldName = await getCurrentPageName();\n    }\n    if (newName === undefined) { throw new Error(\"newName must be specified\"); }\n    await openPageMenu(oldName);\n    await driver.find(\".test-docpage-rename\").click();\n    await driver.find(\".test-docpage-editor\").sendKeys(newName, Key.ENTER);\n    await waitForServer();\n  }\n\n  /**\n * Removes a page from the page menu, checks if the page is actually removable.\n * By default it will remove only page (handling prompt if necessary).\n */\n  export async function removePage(name: string | RegExp, options: {\n    expectPrompt?: boolean, // default undefined\n    withData?: boolean // default only page,\n    tables?: string[],\n    cancel?: boolean,\n  } = { }) {\n    await openPageMenu(name);\n    assert.equal(await driver.findWait(\".test-docpage-remove\", 100).matches(\".disabled\"), false);\n    await driver.find(\".test-docpage-remove\").click();\n    const popups = await driver.findAll(\".test-removepage-popup\");\n    if (options.expectPrompt === true) {\n      assert.lengthOf(popups, 1);\n    } else if (options.expectPrompt === false) {\n      assert.lengthOf(popups, 0);\n    }\n    if (popups.length) {\n      const popup = popups.shift()!;\n      if (options.tables) {\n        const popupTables = await driver.findAll(\".test-removepage-table\", e => e.getText());\n        assert.deepEqual(popupTables.sort(), options.tables.sort());\n      }\n      await popup.find(`.test-option-${options.withData ? \"data\" : \"page\"}`).click();\n      if (options.cancel) {\n        await driver.find(\".test-modal-cancel\").click();\n      } else {\n        await driver.find(\".test-modal-confirm\").click();\n      }\n    }\n    await waitForServer();\n  }\n\n  /**\n * Checks if a page can be removed.\n */\n  export async function canRemovePage(name: string | RegExp) {\n    await openPageMenu(name);\n    const isDisabled = await driver.find(\".test-docpage-remove\").matches(\".disabled\");\n    await driver.sendKeys(Key.ESCAPE);\n    return !isDisabled;\n  }\n\n  /**\n * Renames a table using exposed method from gristDoc. Use renameActiveTable to use the UI.\n */\n  export async function renameTable(tableId: string, newName: string) {\n    await driver.executeScript(`\n    return window.gristDocPageModel.gristDoc.get().testRenameTable(arguments[0], arguments[1]);\n  `, tableId, newName);\n    await waitForServer();\n  }\n\n  /**\n * Rename the given column.\n */\n  export async function renameColumn(col: IColHeader | string, newName: string) {\n    const header = await getColumnHeader(col);\n    await header.click();\n    await header.click();   // Second click opens the label for editing.\n    await driver.findWait(\".test-column-title-label\", 100).sendKeys(newName, Key.ENTER);\n    await waitForServer();\n  }\n\n  /**\n * Removes a table using RAW data view.\n */\n  export async function removeTable(tableId: string, options: { dismissTips?: boolean } = {}) {\n    await driver.find(\".test-tools-raw\").click();\n    if (options.dismissTips) { await dismissBehavioralPrompts(); }\n    const tableIdList = await driver.findAll(\".test-raw-data-table-id\", e => e.getText());\n    const tableIndex = tableIdList.indexOf(tableId);\n    assert.isTrue(tableIndex >= 0, `No raw table with id ${tableId}`);\n    const menus = await driver.findAll(\".test-raw-data-table .test-raw-data-table-menu\");\n    assert.equal(menus.length, tableIdList.length);\n    await menus[tableIndex].click();\n    await driver.findWait(\".test-raw-data-menu-remove-table\", 100).click();\n    await driver.findWait(\".test-modal-confirm\", 100).click();\n    await waitForServer();\n  }\n\n  /**\n * Returns a function to undo all user actions from a particular point in time.\n * Optionally accepts a function which should return the same result before and after the test.\n */\n  export async function begin(invariant: () => any = () => true) {\n    const undoStackPointer = () => driver.executeScript<number>(`\n    return window.gristDocPageModel.gristDoc.get()._undoStack._pointer;\n  `);\n    const start = await undoStackPointer();\n    const previous = await invariant();\n    return async () => {\n    // We will be careful here and await every time for the server and check js errors.\n      const count = await undoStackPointer() - start;\n      for (let i = 0; i < count; ++i) {\n        await undo();\n        await checkForErrors();\n      }\n      assert.deepEqual(await invariant(), previous);\n    };\n  }\n\n  /**\n * A hook that can be used to clear a state after suite is finished and current test passed.\n * If under debugging session and NO_CLEANUP env variable is set it will skip this cleanup and allow you\n * to examine the state of the database or browser.\n */\n  export function afterCleanup(test: () => void | Promise<void>) {\n    after(function() {\n      if (process.env.NO_CLEANUP) {\n        function anyTestFailed(suite: Mocha.Suite): boolean {\n          return suite.tests.some(t => t.state === \"failed\") || suite.suites.some(anyTestFailed);\n        }\n\n        if (this.currentTest?.parent && anyTestFailed(this.currentTest?.parent)) {\n          return;\n        }\n      }\n      return test();\n    });\n  }\n\n  /**\n * A hook that can be used to clear state after each test that has passed.\n * If under debugging session and NO_CLEANUP env variable is set it will skip this cleanup and allow you\n * to examine the state of the database or browser.\n */\n  export function afterEachCleanup(test: () => void | Promise<void>) {\n    afterEach(function() {\n      if (this.currentTest?.state !== \"passed\" && !this.currentTest?.pending && process.env.NO_CLEANUP) {\n        return;\n      }\n      return test();\n    });\n  }\n\n  /**\n * Simulates a transaction on the GristDoc. Use with cautions, as there is no guarantee it will undo correctly\n * in a case of failure.\n * Optionally accepts a function which should return the same result before and after the test.\n * Example:\n *\n * it('should ...', revertChanges(async function() {\n * ...\n * }));\n */\n  export function revertChanges(test: () => Promise<void>, invariant: () => any = () => false) {\n    return async function() {\n      const revert = await begin(invariant);\n      let wasError = false;\n      try {\n        await test();\n      } catch (e) {\n        wasError = true;\n        throw e;\n      } finally {\n        if (!(noCleanup && wasError)) {\n          await revert();\n        }\n      }\n    };\n  }\n\n  /**\n * Click the Redo button and wait for server. If optCount is given, click Redo that many times.\n */\n  export async function redo(optCount: number = 1, optTimeout?: number) {\n    await waitForServer(optTimeout);\n    for (let i = 0; i < optCount; ++i) {\n      await driver.find(\".test-redo\").doClick();\n      await waitForServer(optTimeout);\n    }\n    await waitForServer(optTimeout);\n  }\n\n  export async function redoAll() {\n    const isActive = () => driver.find(\".test-redo\").matches('[class*=\"disabled\"]').then(v => !v);\n    while (await isActive()) {\n      await redo();\n    }\n  }\n\n  /**\n * Asserts the absence of javascript errors.\n */\n  export async function checkForErrors() {\n    const errors = await driver.executeScript<string[]>(() => (window as any).getAppErrors());\n    assert.deepEqual(errors, []);\n  }\n\n  /**\n * Gets errors that were thrown by the app.\n */\n  export async function getAppErrors() {\n    return await driver.executeScript<string[]>(() => (window as any).getAppErrors());\n  }\n\n  /**\n * Opens a Creator Panel on Widget/Table settings tab.\n */\n  export async function openWidgetPanel(tab: \"widget\" | \"sortAndFilter\" | \"data\" = \"widget\") {\n    await toggleSidePanel(\"right\", \"open\");\n    await retryOnStale(() => driver.findWait(\".test-right-tab-pagewidget\", 100).click());\n    await driver.find(`.test-config-${tab}`).click();\n  }\n\n  /**\n * Opens a Creator Panel on Widget/Table settings tab.\n */\n  export async function openColumnPanel(col?: string | number) {\n    if (col !== undefined) {\n      await getColumnHeader({ col }).click();\n    }\n    await toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n  }\n\n  /**\n * Moves a column from a hidden to visible section.\n * Needs a visible Creator panel.\n */\n  export async function moveToVisible(col: string) {\n    const row = await driver.findContent(\".test-vfc-hidden-fields .kf_draggable_content\", exactMatch(col));\n    await row.mouseMove();\n    await row.find(\".test-vfc-hide\").click();\n    await waitForServer();\n  }\n\n  /**\n * Moves a column from a visible to hidden section.\n * Needs a visible Creator panel.\n */\n  export async function moveToHidden(col: string) {\n    const row = await driver.findContent(\".test-vfc-visible-fields .kf_draggable_content\", exactMatch(col));\n    await row.mouseMove();\n    await row.find(\".test-vfc-hide\").click();\n    await waitForServer();\n  }\n\n  /**\n * Clicks `Select All` in visible columns section.\n */\n  export async function selectAllVisibleColumns() {\n    await driver.find(\".test-vfc-visible-fields-select-all\").click();\n  }\n\n  /**\n * Toggle checkbox for a column in visible columns section.\n */\n  export async function toggleVisibleColumn(col: string) {\n    await openWidgetPanel(\"widget\");\n    const row = await driver.findContent(\".test-vfc-visible-field\", exactMatch(col));\n    await row.find(\"input\").click();\n  }\n\n  /**\n * Toggle checkbox for a column in hidden columns section.\n */\n  export async function toggleHiddenColumn(col: string) {\n    await openWidgetPanel(\"widget\");\n    const row = await driver.findContent(\".test-vfc-hidden-field\", exactMatch(col));\n    await row.find(\"input\").click();\n  }\n\n  /**\n * Lists all columns in the visible columns section.\n */\n  export async function getVisibleColumns() {\n    return await driver.findAll(\".test-vfc-visible-field\", async (row) => {\n      return row.getText();\n    });\n  }\n\n  /**\n * Lists all columns in the hidden columns section.\n */\n  export async function getHiddenColumns() {\n    return await driver.findAll(\".test-vfc-hidden-field\", async (row) => {\n      return row.getText();\n    });\n  }\n\n  /**\n * Clicks `Hide Columns` button in visible columns section.\n */\n  export async function hideColumns() {\n    await driver.find(\".test-vfc-visible-hide\").click();\n    await waitForServer();\n  }\n\n  export async function showColumns() {\n    await driver.find(\".test-vfc-hidden-show\").click();\n    await waitForServer();\n  }\n\n  export async function search(what: string) {\n    await driver.find(\".test-tb-search-icon\").click();\n    await driver.sleep(500);\n    await driver.find(\".test-tb-search-input input\").click();\n    await selectAll();\n    await driver.sendKeys(what);\n    // Sleep for search debounce time\n    await driver.sleep(120);\n  }\n\n  export async function toggleSearchAll() {\n    await closeTooltip();\n    const searchAllSelector = \".test-tb-search-option-all-pages\";\n    if (!await driver.find(searchAllSelector).isDisplayed()) {\n      await openSearch();\n    }\n    await driver.find(searchAllSelector).click();\n  }\n\n  export async function openSearch() {\n    await waitToPass(async () => {\n      await searchIsClosed();\n      await driver.find(\".test-tb-search-icon\").doClick();\n      await waitToPass(searchIsOpened, 500);\n    }, 1500);\n  }\n\n  export async function closeSearch() {\n    await driver.sendKeys(Key.ESCAPE);\n    await driver.sleep(500);\n  }\n\n  export async function closeTooltip() {\n    if (!await driver.find(\".test-tooltip\").isPresent()) { return; }\n\n    await driver.find(\".test-tooltip\").mouseMove();\n    await driver.mouseMoveBy({ x: 100, y: 100 });\n    await waitToPass(async () => {\n      assert.equal(await driver.find(\".test-tooltip\").isPresent(), false);\n    });\n  }\n\n  export async function searchNext() {\n    await closeTooltip();\n    await driver.find(\".test-tb-search-next\").click();\n  }\n\n  export async function searchPrev() {\n    await closeTooltip();\n    await driver.find(\".test-tb-search-prev\").click();\n  }\n\n  export function getCurrentPageName() {\n    return driver.find(\".test-treeview-itemHeader.selected\").find(\".test-docpage-label\").getText();\n  }\n\n  export async function getActiveRawTableName() {\n    return await driver.findWait(\".test-raw-data-overlay .test-viewsection-title\", 100).getText();\n  }\n\n  export function getSearchInput() {\n    return driver.find(\".test-tb-search-input\");\n  }\n\n  export async function hasNoResult() {\n    await waitToPass(async () => {\n      assert.match(await driver.find(\".test-tb-search-input\").getText(), /No results/);\n    });\n  }\n\n  export async function hasSomeResult() {\n    await waitToPass(async () => {\n      assert.notMatch(await driver.find(\".test-tb-search-input\").getText(), /No results/);\n    });\n  }\n\n  export async function searchIsOpened() {\n    await waitToPass(async () => {\n      assert.isAbove((await getSearchInput().rect()).width, 50);\n    }, 500);\n  }\n\n  export async function searchIsClosed() {\n    await waitToPass(async () => {\n      assert.equal((await getSearchInput().rect()).width, 0);\n    }, 500);\n  }\n\n  export async function openRawTable(tableId: string) {\n    await driver.find(`.test-raw-data-table .test-raw-data-table-id-${tableId}`).click();\n  }\n\n  export async function renameRawTable(tableId: string, newName?: string, newDescription?: string) {\n    await driver.find(`.test-raw-data-table .test-raw-data-table-id-${tableId}`)\n      .findClosest(\".test-raw-data-table\")\n      .find(\".test-raw-data-table-menu\")\n      .click();\n    await findOpenMenuItem(\"li\", \"Rename table\").click();\n    if (newName !== undefined) {\n      const input = await driver.findWait(\".test-widget-title-table-name-input\", 100);\n      await input.doClear();\n      await input.click();\n      await driver.sendKeys(newName);\n    }\n    if (newDescription !== undefined) {\n      const input = await driver.findWait(\".test-widget-title-section-description-input\", 100);\n      await input.doClear();\n      await input.click();\n      await driver.sendKeys(newDescription);\n    }\n    await driver.find(\".test-widget-title-save\").click();\n    await waitForServer();\n  }\n\n  export async function isRawTableOpened() {\n    return await driver.find(\".test-raw-data-close-button\").isPresent();\n  }\n\n  export async function closeRawTable() {\n    await driver.find(\".test-raw-data-close-button\").click();\n    await waitToPass(async () => assert.isFalse(await driver.find(\".test-raw-data-close-button\").isPresent()));\n  }\n\n  /**\n * Opens the section menu for a section, or the active section if no section is given.\n */\n  export async function openSectionMenu(which: \"sortAndFilter\" | \"viewLayout\", section?: string | WebElement) {\n    const sectionElem = section ? await getSection(section) : await driver.findWait(\".active_section\", 4000);\n    await sectionElem.find(`.test-section-menu-${which}`).click();\n    return await findOpenMenu(100);\n  }\n\n  /**\n * Closes the section menu for a section, or the active section if no section is given\n */\n  export async function closeSectionMenu(which: \"sortAndFilter\" | \"viewLayout\", section?: string | WebElement) {\n    const sectionElem = section ? await getSection(section) : await driver.findWait(\".active_section\", 4000);\n    await sectionElem.find(`.test-section-menu-${which}`).click();\n    return notPresent(`.grist-floating-menu`);\n  }\n\n  /**\n * Opens Raw data view for current section.\n */\n  export async function showRawData(section?: string | WebElement) {\n    await openSectionMenu(\"viewLayout\", section);\n    await driver.find(\".test-show-raw-data\").click();\n    assert.isTrue(await driver.findWait(\".test-raw-data-overlay\", 100).isDisplayed());\n  }\n\n  // Mapping from column menu option name to dom element selector to wait for, or null if no need to wait.\n  const ColumnMenuOption: { [id: string]: string; } = {\n    Filter: \".test-filter-menu-wrapper\",\n  };\n\n  async function openColumnMenuHelper(col: IColHeader | string, option?: string): Promise<WebElement> {\n    await getColumnHeader(typeof col === \"string\" ? { col } : col).mouseMove().find(\".g-column-main-menu\").click();\n    const menu = await findOpenMenu(100);\n    if (option) {\n      await menu.findContent(\"li\", option).click();\n      const waitForElem = ColumnMenuOption[option];\n      if (waitForElem) {\n        return await driver.findWait(ColumnMenuOption[option], 100);\n      }\n    }\n    return menu;\n  }\n\n  type SortOptions = \"sort-asc\" | \"sort-dsc\" | \"add-to-sort-asc\" | \"add-to-sort-dsc\";\n\n  /**\n * Open the given column's dropdown menu. If `option` is provided, finds and clicks it.\n * If `option` is present in ColumnMenuOption, also waits for the specified element.\n */\n  export function openColumnMenu(col: IColHeader | string, option: SortOptions): Promise<void>;\n  export function openColumnMenu(col: IColHeader | string, option?: string): WebElementPromise;\n  export function openColumnMenu(col: IColHeader | string, option?: string): WebElementPromise | Promise<void> {\n    if ([\"sort-asc\", \"sort-dsc\", \"add-to-sort-asc\", \"add-to-sort-dsc\"].includes(option || \"\")) {\n      return openColumnMenuHelper(col).then<void>(async (menu) => {\n        await menu.find(`.test-${option}`).click();\n        await waitForServer();\n      });\n    }\n    return new WebElementPromise(driver, openColumnMenuHelper(col, option));\n  }\n\n  export async function deleteColumn(col: IColHeader | string) {\n    await openColumnMenu(col, \"Delete column\");\n    await waitForServer();\n    await wipeToasts();\n  }\n\n  export type ColumnType =\n    \"Any\" | \"Text\" | \"Numeric\" | \"Integer\" | \"Toggle\" | \"Date\" | \"DateTime\" |\n    \"Choice\" | \"Choice List\" | \"Reference\" | \"Reference List\" | \"Attachment\";\n\n  /**\n * Sets the type of the currently selected field to value.\n * It a type change is already active and cancellable, cancel\n * that one first by default.\n */\n  export async function setType(\n    type: RegExp | ColumnType,\n    options: { skipWait?: boolean, apply?: boolean } = {},\n  ) {\n    if (await driver.find(\".test-type-transform-cancel\").isPresent()) {\n      await driver.find(\".test-type-transform-cancel\").click();\n      await waitForServer();\n    }\n\n    const { skipWait, apply } = options;\n    await toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-field\").click();\n    await driver.find(\".test-fbuilder-type-select\").click();\n    type = typeof type === \"string\" ? exactMatch(type) : type;\n    await findOpenMenuItem(\".test-select-row\", type, 500).click();\n    if (!skipWait || apply) { await waitForServer(); }\n    if (apply) {\n      await driver.findWait(\".test-type-transform-apply\", 1000).click();\n      await waitForServer();\n    }\n  }\n\n  /**\n * Gets the type of the currently selected field.\n */\n  export async function getType() {\n    return await driver.find(\".test-fbuilder-type-select\").getText();\n  }\n\n  /**\n * Get the field's widget type (e.g. \"CheckBox\" for a Toggle column) in the creator panel.\n */\n  export async function getFieldWidgetType(): Promise<string> {\n    return await driver.find(\".test-fbuilder-widget-select\").getText();\n  }\n\n  /**\n * Set the field's widget type (e.g. \"CheckBox\" for a Toggle column) in the creator panel.\n */\n  export async function setFieldWidgetType(type: string) {\n    await driver.find(\".test-fbuilder-widget-select\").click();\n    await findOpenMenuItem(\"li\", exactMatch(type)).click();\n    await waitForServer();\n  }\n\n  export async function applyTypeTransform() {\n    await driver.findContent(\".type_transform_prompt button\", /Apply/).click();\n  }\n\n  export async function isMac(): Promise<boolean> {\n    const platform = (await driver.getCapabilities()).getPlatform() ?? \"\";\n    return /Darwin|Mac|mac os x|iPod|iPhone|iPad/i.test(platform);\n  }\n\n  export async function modKey() {\n    return await isMac() ? Key.COMMAND : Key.CONTROL;\n  }\n\n  export async function selectAllKey() {\n    return await isMac() ? Key.chord(Key.COMMAND, \"a\") : Key.chord(Key.CONTROL, \"a\");\n  }\n\n  /**\n * Send keys, with support for Key.chord(), similar to driver.sendKeys(). Note that while\n * elem.sendKeys() supports Key.chord(...), driver.sendKeys() does not. This is a replacement.\n */\n  export async function sendKeys(...keys: string[]): Promise<void>;\n  /**\n * Send keys with a pause between each key.\n */\n  export async function sendKeys(interval: number, ...keys: string[]): Promise<void>;\n  export async function sendKeys(...args: (string | number)[]) {\n    let interval = 0;\n    if (typeof args[0] === \"number\") {\n      interval = args.shift() as number;\n    }\n    const keys = args as string[];\n    // Implementation follows the description of WebElement.sendKeys functionality at https://github.com/SeleniumHQ/selenium/blob/2f7727c314f943582f9f1b2a7e4d77ebdd64bdd3/javascript/node/selenium-webdriver/lib/webdriver.js#L2146\n    await driver.withActions((a) => {\n      const toRelease: string[] =  [];\n      for (const part of keys) {\n        for (const key of part) {\n          if ([Key.ALT, Key.CONTROL, Key.SHIFT, Key.COMMAND, Key.META].includes(key)) {\n            a.keyDown(key);\n            toRelease.push(key);\n          } else if (key === Key.NULL) {\n            toRelease.splice(0).reverse().forEach(k => a.keyUp(k));\n          } else {\n            a.sendKeys(key);\n          }\n          if (interval) {\n            a.pause(interval);\n          }\n        }\n      }\n    });\n  }\n\n  /**\n * An default ovveride for sendKeys that sends keys slowly, suitable for formula editor.\n */\n  export async function sendKeysSlowly(...keys: string[]) {\n    return await sendKeys(30, ...keys);\n  }\n\n  /**\n * Clears active input/textarea.\n */\n  export async function clearInput() {\n    return sendKeys(await selectAllKey(), Key.DELETE);\n  }\n\n  /**\n * Open ⋮ dropdown menu for named workspace.\n */\n  export async function openWsDropdown(wsName: string): Promise<void> {\n    const wsTab = await driver.findContentWait(\".test-dm-workspace\", wsName, 3000);\n    await wsTab.mouseMove();\n    await wsTab.find(\".test-dm-workspace-options\").mouseMove().click();\n  }\n\n  export async function openWorkspace(wsName: string): Promise<void> {\n    const wsTab = await driver.findContentWait(\".test-dm-workspace\", wsName, 3000);\n    await wsTab.click();\n    await waitForDocMenuToLoad();\n  }\n\n  /**\n * Open ⋮ dropdown menu for named document.\n */\n  export async function openDocDropdown(docNameOrRow: string | WebElement): Promise<void> {\n  // \"Pinned\" docs also get .test-dm-doc testId.\n    const docRow = (typeof docNameOrRow === \"string\") ?\n      await driver.findContentWait(\".test-dm-doc\", docNameOrRow, 3000) :\n      docNameOrRow;\n    await docRow.mouseMove();\n    await docRow.find(\".test-dm-doc-options,.test-dm-pinned-doc-options\").mouseMove().click();\n    await findOpenMenu();\n  }\n\n  /**\n  * Open ⋮ dropdown menu for doc access rules.\n  */\n  export async function openAccessRulesDropdown(): Promise<void> {\n    await driver.find(\".test-tools-access-rules\").mouseMove();\n    await driver.find(\".test-tools-access-rules-trigger\").mouseMove().click();\n    await findOpenMenu(1000);\n  }\n\n  /**\n * Open \"Select By\" area in creator panel.\n */\n  export async function openSelectByForSection(section: string) {\n    await toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-config-data\").click();\n    await getSection(section).click();\n    await driver.find(\".test-right-select-by\").click();\n  }\n\n  export async function editOrgAcls(): Promise<void> {\n  // To prevent a common flakiness problem, wait for a potentially open modal dialog\n  // to close before attempting to open the account menu.\n    await driver.wait(async () => !(await driver.find(\".test-modal-dialog\").isPresent()), 3000);\n    await driver.findWait(\".test-user-icon\", 3000).click();\n    await driver.findWait(\".test-dm-org-access\", 3000).click();\n    await driver.findWait(\".test-um-members\", 3000);\n  }\n\n  export async function editDocAcls(): Promise<void> {\n    // To prevent a common flakiness problem, wait for a potentially open modal dialog\n    // to close before attempting to open the account menu.\n    await driver.wait(async () => !(await driver.find(\".test-modal-dialog\").isPresent()), 3000);\n    await driver.findWait(\".test-tb-share\", 5000).click();\n    await driver.findWait(\".test-tb-share-option\", 3000).click();\n    await driver.findWait(\".test-um-members\", 3000);\n  }\n\n  export async function addUser(email: string | string[], role?: \"Owner\" | \"Viewer\" | \"Editor\"): Promise<void> {\n    await driver.findWait(\".test-user-icon\", 5000).click();\n    await driver.findWait(\".test-dm-org-access\", 200).click();\n    await driver.findWait(\".test-um-members\", 500);\n    const orgInput = await driver.find(\".test-um-member-new input\");\n\n    const emails = Array.isArray(email) ? email : [email];\n    for (const e of emails) {\n      await orgInput.sendKeys(e, Key.ENTER);\n      if (role && role !== \"Viewer\") {\n        await driver.findContentWait(\".test-um-member\", e, 1000).find(\".test-um-member-role\").click();\n        await driver.findContent(\".test-um-role-option\", role ?? \"Viewer\").click();\n      }\n    }\n    await driver.find(\".test-um-confirm\").click();\n    await driver.wait(async () => !await driver.find(\".test-um-members\").isPresent(), 500);\n  }\n\n  export async function removeUser(emails: string | string[]): Promise<void> {\n    await driver.findWait(\".test-user-icon\", 5000).click();\n    await driver.find(\".test-dm-org-access\").click();\n    await driver.findWait(\".test-um-members\", 500);\n    for (const email of (Array.isArray(emails) ? emails : [emails])) {\n      const userRow = await driver.findContent(\".test-um-member\", email);\n      await userRow.find(\".test-um-member-delete\").click();\n    }\n    await driver.find(\".test-um-confirm\").click();\n    await driver.wait(async () => !await driver.find(\".test-um-members\").isPresent(), 500);\n  }\n\n  /**\n * Click confirm on a user manager dialog. If clickRemove is set, then\n * any extra modal that pops up will be accepted. Returns true unless\n * clickRemove was set and no modal popped up.\n */\n  export async function saveAcls(\n    { sharePubliclyDialog = false, clickRemove = false}: { sharePubliclyDialog?: boolean, clickRemove?: boolean } = {},\n  ) {\n    await driver.findWait(\".test-um-confirm\", 3000).click();\n    let clickedRemove: boolean = false;\n    if (sharePubliclyDialog) {\n      await driver.findWait(\".test-modal-dialog\", 1000);\n      assert.include(await driver.find(\".test-modal-title\").getText(), \"sharing publicly\",\n        \"The modal alerting about sharing the doc public should have appeared\");\n      await driver.findWait(\".test-modal-confirm\", 3000).click();\n    }\n    await driver.wait(async () => {\n      if (clickRemove && !clickedRemove && await driver.find(\".test-modal-confirm\").isPresent()) {\n        await driver.find(\".test-modal-confirm\").click();\n        clickedRemove = true;\n      }\n      return !(await driver.find(\".test-um-members\").isPresent());\n    }, 3000);\n    return clickedRemove || !clickRemove;\n  }\n\n  /**\n * Opens the row menu for the row with the given row number (1-based, as in row headers).\n */\n  export function openRowMenu(rowNum: number) {\n    const row = driver.findContent(\".active_section .gridview_data_row_num\", String(rowNum));\n    return driver.withActions(actions => actions.contextClick(row))\n      .then(() => findOpenMenu(1000));\n  }\n\n  export async function removeRow(rowNum: number) {\n    await (await openRowMenu(rowNum)).findContent(\"li\", /Delete/).click();\n    await waitForServer();\n  }\n\n  export async function openCardMenu(rowNum: number) {\n    const section = await driver.find(\".active_section\");\n    const firstRow = await section.findContent(\".detail_row_num\", String(rowNum));\n    await firstRow.find(\".test-card-menu-trigger\").click();\n    return await findOpenMenu(1000);\n  }\n\n  /**\n * A helper to complete saving a copy of the document. Namely it is useful to call after clicking\n * either the `Copy As Template` or `Save Copy` (when on a forked document) button. Accept optional\n * `destName` and `destWorkspace` to change the default destination.\n */\n  export async function completeCopy(options: { destName?: string, destWorkspace?: string, destOrg?: string } = {}) {\n    await driver.findWait(\".test-modal-dialog\", 1000);\n    if (options.destName !== undefined) {\n      const nameElem = await driver.find(\".test-copy-dest-name\").doClick();\n      await setValue(nameElem, \"\");\n      await nameElem.sendKeys(options.destName);\n    }\n    if (options.destOrg !== undefined) {\n      await driver.find(\".test-copy-dest-org .test-select-open\").click();\n      await findOpenMenuItem(\"li\", options.destOrg).click();\n    }\n    if (options.destWorkspace !== undefined) {\n      await driver.findWait(\".test-copy-dest-workspace .test-select-open\", 1000).click();\n      await findOpenMenuItem(\"li\", options.destWorkspace).click();\n    }\n\n    await waitForServer();\n\n    // save the urlId\n    const urlId = await getCurrentUrlId();\n\n    await driver.wait(async () => (\n      await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\") == null));\n\n    // click the `Copy` button\n    await driver.find(\".test-modal-confirm\").click();\n\n    // wait for the doc id to change\n    await driver.wait(async () => (await getCurrentUrlId()) !== urlId);\n\n    await waitForDocToLoad();\n  }\n\n  /**\n * Removes document by name from the home page.\n */\n  export async function removeDoc(docName: string) {\n    await openDocDropdown(docName);\n    await driver.find(\".test-dm-delete-doc\").click();\n    await driver.find(\".test-modal-confirm\").click();\n    await driver.wait(async () => !(await driver.find(\".test-modal-dialog\").isPresent()), 3000);\n  }\n\n  /**\n * Helper to get the urlId of the current document. Resolves to undefined if called while not\n * on a document page.\n */\n  export async function getCurrentUrlId() {\n    return decodeUrl({}, new URL(await driver.getCurrentUrl())).doc;\n  }\n\n  export function getToasts(): Promise<string[]> {\n    return driver.findAll(\".test-notifier-toast-wrapper\", el => el.getText());\n  }\n\n  export async function wipeToasts(): Promise<void> {\n    await driver.executeScript(\"window.gristApp.topAppModel.notifier.clearAppErrors()\");\n    return driver.executeScript(\n      \"for (const e of document.getElementsByClassName('test-notifier-toast-wrapper')) { e.remove(); }\");\n  }\n\n  /**\n * Call this at suite level, to share the \"Examples & Templates\" workspace in before() and restore\n * it in after().\n *\n * TODO: Should remove once support workspaces are removed from backend.\n */\n  export function shareSupportWorkspaceForSuite() {\n    let api: UserAPIImpl | undefined;\n    let wss: Workspace[] | undefined;\n\n    before(async function() {\n    // test/gen-server/seed.ts creates a support user with a personal org and an \"Examples &\n    // Templates\" workspace, but doesn't share it (to avoid impacting the many existing tests).\n    // Share that workspace with @everyone and @anon, and clean up after this suite.\n      await addSupportUserIfPossible();\n      api = createHomeApi(\"Support\", \"docs\");  // this uses an api key, so no need to log in.\n      wss = await api.getOrgWorkspaces(\"current\");\n      await api.updateWorkspacePermissions(wss[0].id, { users: {\n        \"everyone@getgrist.com\": \"viewers\",\n        \"anon@getgrist.com\": \"viewers\",\n      } });\n    });\n\n    after(async function() {\n      if (api && wss) {\n        await api.updateWorkspacePermissions(wss[0].id, { users: {\n          \"everyone@getgrist.com\": null,\n          \"anon@getgrist.com\": null,\n        } });\n      }\n    });\n  }\n\n  export async function clearTestState() {\n    await driver.executeScript(\"window.testGrist = {}\");\n  }\n\n  export async function getTestState(): Promise<TestState> {\n    const state: TestState | undefined = await driver.executeScript(\"return window.testGrist\");\n    return state || {};\n  }\n\n  // Get the full text from an element containing an Ace editor.\n  export async function getAceText(el: WebElement): Promise<string> {\n    return driver.executeScript(\"return ace.edit(arguments[0]).getValue()\",\n      el.find(\".ace_editor\"));\n  }\n\n  // All users ('user1', etc.) that can be logged in using Session.user().\n  export enum TestUserEnum {\n    user1 = \"chimpy\",\n    user2 = \"charon\",\n    user3 = \"kiwi\",\n    user4 = \"ham\",\n    userz = \"userz\",    // a user for old tests, that doesn't overlap with others.\n    fresh = \"fresh\",    // user with no resources in seed.ts, safe to recreate as needed\n    // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values\n    owner = \"chimpy\",\n    anon = \"anon\",\n    support = \"support\",\n  }\n  export type TestUser = keyof typeof TestUserEnum;     // 'user1' | 'user2' | ...\n  export interface UserData { email: string, name: string }\n\n  // Get name and email for the given test user.\n  export function translateUser(userName: TestUser): UserData {\n    if (userName === \"anon\") {\n      return { email: \"anon@getgrist.com\", name: \"Anonymous\" };\n    }\n    if (userName === \"support\") {\n      return { email: \"support@getgrist.com\", name: \"Support\" };\n    }\n    const translatedUser = TestUserEnum[userName];\n    const email = `gristoid+${translatedUser}@gmail.com`;\n    const name = startCase(translatedUser);\n    return { email, name };\n  }\n\n  /**\n * A class representing a user on a particular site, with a default\n * workspaces.  Tests written using this class can be more\n * conveniently adapted to run locally, or against deployed versions\n * of grist.\n */\n  export class Session {\n  // private constructor - access sessions via session() or Session.default\n    private constructor(public settings: { email: string, orgDomain: string,\n      orgName: string, name: string,\n      workspace: string }) {\n    }\n\n    // Get a session configured for the personal site of a default user.\n    public static get default() {\n    // Start with an empty session, then fill in the personal site (typically docs, or docs-s\n    // in staging), and then fill in a default user (currently gristoid+chimpy@gmail.com).\n      return new Session({ name: \"\", email: \"\", orgDomain: \"\", orgName: \"\", workspace: \"Home\" }).personalSite.user();\n    }\n\n    // Return a session configured for the personal site of the current session's user.\n    public get personalSite() {\n      const orgName = this.settings.name ? `@${this.settings.name}` : \"\";\n      return this.customTeamSite(\"docs\", orgName);\n    }\n\n    // Return a session configured for a default team site and the current session's user.\n    public get teamSite() {\n      return this.customTeamSite(\"test-grist\", \"Test Grist\");\n    }\n\n    // Return a session configured for an alternative team site and the current session's user.\n    public get teamSite2() {\n      return this.customTeamSite(\"test2-grist\", \"Test2 Grist\");\n    }\n\n    // Return a session configured for a particular team site and the current session's user.\n    public customTeamSite(orgDomain: string = \"test-grist\", orgName = \"Test Grist\") {\n      const deployment = process.env.GRIST_ID_PREFIX;\n      if (deployment) {\n        orgDomain = `${orgDomain}-${deployment}`;\n      }\n      return new Session({ ...this.settings, orgDomain, orgName });\n    }\n\n    // Return a session configured to create and import docs in the given workspace.\n    public forWorkspace(workspace: string) {\n      return new Session({ ...this.settings, workspace });\n    }\n\n    // Wipe the current site.  The current user ends up being its only owner and manager.\n    public async resetSite() {\n      return resetOrg(this.createHomeApi(), this.settings.orgDomain);\n    }\n\n    // Return a session configured for the current session's site but a different user.\n    public user(userName?: TestUser): Session;\n    public user(user: UserData): Session;\n    public user(arg: TestUser | UserData = \"user1\") {\n      const data = typeof arg === \"string\" ? translateUser(arg) : arg;\n      return new Session({ ...this.settings, ...data });\n    }\n\n    // Return a session configured for the current session's site and anonymous access.\n    public get anon() {\n      return this.user(\"anon\");\n    }\n\n    public async addLogin() {\n      return this.login({ retainExistingLogin: true });\n    }\n\n    // Make sure we are logged in to the current session's site as the current session's user.\n    public async login(options?: { loginMethod?: UserProfile[\"loginMethod\"],\n      freshAccount?: boolean,\n      isFirstLogin?: boolean,\n      showTips?: boolean,\n      showGristTour?: boolean,\n      userName?: string,\n      email?: string,\n      retainExistingLogin?: boolean }) {\n      if (options?.userName) {\n        this.settings.name = options.userName;\n        this.settings.email = options.email || \"\";\n      }\n      // Optimize testing a little bit, so if we are already logged in as the expected\n      // user on the expected org, and there are no options set, we can just continue.\n      if (!options && await this.isLoggedInCorrectly()) { return this; }\n      if (!options?.retainExistingLogin) {\n        await removeLogin();\n        if (this.settings.email === \"anon@getgrist.com\") {\n          if (options?.showTips) {\n            await enableTips(this.settings.email);\n          } else {\n            await disableTips(this.settings.email);\n          }\n          return this;\n        }\n      }\n      await server.simulateLogin(this.settings.name, this.settings.email, this.settings.orgDomain,\n        { isFirstLogin: false, cacheCredentials: true, ...options });\n      return this;\n    }\n\n    // Check whether we are logged in to the current session's site as the current session's user.\n    public async isLoggedInCorrectly() {\n      let currentUser: FullUser | undefined;\n      let currentOrg: APIOrganization | undefined;\n      try {\n        currentOrg = await getOrg();\n      } catch (err) {\n      // ok, we may not be in a page associated with an org.\n      }\n      try {\n        currentUser = await getUser();\n      } catch (err) {\n      // ok, we may not be in a page associated with a user.\n      }\n      return currentUser && currentUser.email === this.settings.email &&\n        currentOrg && (currentOrg.name === this.settings.orgName ||\n      // This is an imprecise check for personal sites, but adequate for tests.\n          (currentOrg.owner && (this.settings.orgDomain.startsWith(\"docs\"))));\n    }\n\n    // Load a document on a site.\n    public async loadDoc(\n      relPath: string,\n      options: {\n        wait?: boolean,\n        skipAlert?: boolean,\n      } = {},\n    ) {\n      const { wait = true, skipAlert = false } = options;\n      await this.loadRelPath(relPath);\n      if (skipAlert && await isAlertShown()) { await acceptAlert(); }\n      if (wait) { await waitForDocToLoad(); }\n    }\n\n    // Load a DocMenu on a site.\n    // If loading for a potentially first-time user, you may give 'skipOnboarding' for second\n    // argument to skip the onboarding flow, if it gets shown.\n    public async loadDocMenu(relPath: string, wait: boolean | \"skipOnboarding\" = true) {\n      await this.loadRelPath(relPath);\n      if (wait === \"skipOnboarding\") {\n        const first = await Promise.race([\n          driver.findWait(\".test-onboarding-page\", 2000),\n          driver.findWait(\".test-dm-doclist\", 2000),\n        ]);\n        if (await first.matches(\".test-onboarding-page\")) {\n          await skipOnboarding();\n        }\n      }\n      if (wait) { await waitForDocMenuToLoad(); }\n    }\n\n    public async loadRelPath(relPath: string) {\n      const part = relPath.match(/^\\/o\\/([^/]*)(\\/.*)/);\n      if (part) {\n        if (part[1] !== this.settings.orgDomain) {\n          throw new Error(`org mismatch: ${this.settings.orgDomain} vs ${part[1]}`);\n        }\n        relPath = part[2];\n      }\n      await driver.get(server.getUrl(this.settings.orgDomain, relPath));\n    }\n\n    // Import a file into the current site + workspace.\n    public async importFixturesDoc(fileName: string, options: ImportOpts = { load: true }) {\n      return importFixturesDoc(this.settings.name, this.settings.orgDomain, this.settings.workspace, fileName,\n        { email: this.settings.email, ...options });\n    }\n\n    // As for importFixturesDoc, but delete the document at the end of testing.\n    public async tempDoc(cleanup: Cleanup, fileName: string, options: ImportOpts = { load: true }) {\n      const doc = await this.importFixturesDoc(fileName, options);\n      const api = this.createHomeApi();\n      if (!noCleanup) {\n        cleanup.addAfterAll(async () => {\n          await api.deleteDoc(doc.id).catch(noop);\n          doc.id = \"\";\n        });\n      }\n      return doc;\n    }\n\n    // As for importFixturesDoc, but delete the document at the end of each test.\n    public async tempShortDoc(cleanup: Cleanup, fileName: string, options: ImportOpts = { load: true }) {\n      const doc = await this.importFixturesDoc(fileName, options);\n      const api = this.createHomeApi();\n      if (!noCleanup) {\n        cleanup.addAfterEach(async () => {\n          if (doc.id) {\n            await api.deleteDoc(doc.id).catch(noop);\n          }\n          doc.id = \"\";\n        });\n      }\n      return doc;\n    }\n\n    public async tempNewDoc(cleanup: Cleanup, docName: string = \"\", { load } = { load: true }) {\n      docName ||= `Test${Date.now()}`;\n      const docId = await createNewDoc(this.settings.name, this.settings.orgDomain, this.settings.workspace,\n        docName, { email: this.settings.email });\n      if (load) {\n        await this.loadDoc(`/doc/${docId}`);\n      }\n      const api = this.createHomeApi();\n      if (!noCleanup) {\n        cleanup.addAfterAll(() => api.deleteDoc(docId).catch(noop));\n      }\n      return docId;\n    }\n\n    // Create a workspace that will be deleted at the end of testing.\n    public async tempWorkspace(cleanup: Cleanup, workspaceName: string, orgId: string = \"current\") {\n      const api = this.createHomeApi();\n      const workspaceId = await api.newWorkspace({ name: workspaceName }, orgId);\n      if (!noCleanup) {\n        cleanup.addAfterAll(async () => {\n          await api.deleteWorkspace(workspaceId).catch(noop);\n        });\n      }\n      return workspaceId;\n    }\n\n    // Get an appropriate home api object.\n    public createHomeApi() {\n      if (this.settings.email === \"anon@getgrist.com\") {\n        return createHomeApi(null, this.settings.orgDomain);\n      }\n      return createHomeApi(this.settings.name, this.settings.orgDomain, this.settings.email);\n    }\n\n    /**\n   * Creates a generic API object for the current user.\n   */\n    public createApi<T extends BaseAPI>(creator: APIConstructor<T>) {\n      if (this.settings.email === \"anon@getgrist.com\") {\n        return createApi(creator, null, this.settings.orgDomain);\n      }\n      return createApi(creator, this.settings.name, this.settings.orgDomain, this.settings.email);\n    }\n\n    public getApiKey(): string | null {\n      if (this.settings.email === \"anon@getgrist.com\") {\n        return getApiKey(null);\n      }\n      return getApiKey(this.settings.name, this.settings.email);\n    }\n\n    // Get the id of this user.\n    public async getUserId(): Promise<number> {\n      await this.login();\n      await this.loadDocMenu(\"/\");\n      const user = await getUser();\n      return user.id;\n    }\n\n    public get email() { return this.settings.email; }\n    public get name()  { return this.settings.name;  }\n    public get orgDomain()   { return this.settings.orgDomain; }\n    public get orgName()   { return this.settings.orgName; }\n    public get workspace()   { return this.settings.workspace; }\n\n    public async downloadDoc(fname: string, urlId?: string) {\n      urlId = urlId || await getCurrentUrlId();\n      const api = this.createHomeApi();\n      const doc = await api.getDoc(urlId!);\n      const workerApi = await api.getWorkerAPI(doc.id);\n      const response = await workerApi.downloadDoc(doc.id);\n      await fse.writeFile(fname, Buffer.from(await response.arrayBuffer()));\n    }\n  }\n\n  // Wrap the async methods of Session to include the stack of the caller in stack traces.\n  function stackWrapSession(sessionProto: any) {\n    for (const name of [\n      \"resetSite\", \"login\", \"isLoggedInCorrectly\", \"loadDoc\", \"loadDocMenu\", \"loadRelPath\",\n      \"importFixturesDoc\", \"tempDoc\", \"tempNewDoc\", \"tempWorkspace\", \"getUserId\",\n    ]) {\n      sessionProto[name] = stackWrapFunc(sessionProto[name]);\n    }\n  }\n  stackWrapSession(Session.prototype);\n\n  // Configure a session, for the personal site of a default user.\n  export function session(): Session {\n    return Session.default;\n  }\n\n  /**\n * Sets font style in opened color picker.\n */\n  export async function setFont(type: \"bold\" | \"underline\" | \"italic\" | \"strikethrough\", onOff: boolean | number) {\n    const optionToClass = {\n      bold: \".test-font-option-FontBold\",\n      italic: \".test-font-option-FontItalic\",\n      underline: \".test-font-option-FontUnderline\",\n      strikethrough: \".test-font-option-FontStrikethrough\",\n    };\n    async function clickFontOption() {\n      await driver.find(optionToClass[type]).click();\n    }\n    async function isFontOption() {\n      return (await driver.findAll(`${optionToClass[type]}[class*=-selected]`)).length === 1;\n    }\n    const current = await isFontOption();\n    if (onOff && !current || !onOff && current) {\n      await clickFontOption();\n    }\n  }\n\n  /**\n * Returns the rgb/hex representation of `color` if it's a name (e.g. red, blue, green, white, black, addRow, or\n * transparent), or `color` unchanged if it's not a name.\n */\n  export function nameToHex(color: string) {\n    switch (color) {\n      case \"red\": color = \"#FF0000\"; break;\n      case \"blue\": color = \"#0000FF\"; break;\n      case \"green\": color = \"#00FF00\"; break;\n      case \"white\": color = \"#FFFFFF\"; break;\n      case \"black\": color = \"#000000\"; break;\n      case \"transparent\": color = \"rgba(0, 0, 0, 0)\"; break;\n      case \"addRow\": color = \"rgba(246, 246, 255, 1)\"; break;\n    }\n    return color;\n  }\n\n  //  Set the value of an `<input type=\"color\">` element to `color` and trigger the `change`\n  //  event. Accepts `color` to be of following forms `rgb(120, 10, 3)` or '#780a03' or some predefined\n  //  values (red, green, blue, white, black, transparent)\n  export async function setColor(colorInputEl: WebElement, color: string) {\n    color = nameToHex(color);\n    if (color.startsWith(\"rgb(\")) {\n    // the `value` of an `<input type='color'>` element must be a rgb color in hexadecimal\n    // notation.\n      color = rgbToHex(color);\n    }\n    await driver.executeScript(() => {\n      const el = arguments[0];\n      el.value = arguments[1];\n      const evt = document.createEvent(\"HTMLEvents\");\n      evt.initEvent(\"input\", false, true);\n      el.dispatchEvent(evt);\n    }, colorInputEl, color);\n  }\n\n  export function setTextColor(color: string) {\n    return setColor(driver.find(\".test-text-input\"), color);\n  }\n\n  export function setFillColor(color: string) {\n    return setColor(driver.find(\".test-fill-input\"), color);\n  }\n\n  export async function applyStyle() {\n    await driver.find(\".test-colors-save\").click();\n    await waitForServer();\n  }\n\n  export function getStyleRuleAt(nr: number) {\n    return driver.find(`.test-widget-style-conditional-rule-${nr}`);\n  }\n\n  export async function styleRulesCount() {\n    const rules = await driver.findAll(\".test-widget-style-conditional-rule\");\n    return rules.length;\n  }\n\n  export async function addInitialStyleRule() {\n    await driver.find(\".test-widget-style-add-conditional-style\").click();\n    await waitForServer();\n  }\n\n  export async function removeStyleRuleAt(nr: number) {\n    await driver.find(`.test-widget-style-remove-rule-${nr}`).click();\n    await waitForServer();\n  }\n\n  export async function addAnotherStyleRule() {\n    await driver.find(\".test-widget-style-add-another-rule\").click();\n    await waitForServer();\n  }\n\n  export async function openStyleRuleFormula(nr: number) {\n    await driver\n      .findWait(`.test-widget-style-conditional-rule-${nr} .formula_field_sidepane`, 1000)\n      .click();\n    await waitAppFocus(false);\n  }\n\n  export async function clickAway() {\n    await driver.find(\".test-dm-account\").click();\n    await driver.sendKeys(Key.ESCAPE);\n  }\n\n  /**\n * Opens the header color picker.\n */\n  export async function openHeaderColorPicker() {\n    await driver.find(\".test-header-color-select .test-color-select\").click();\n    await findOpenMenu();\n  }\n\n  export async function assertHeaderTextColor(col: string | WebElement, color: string) {\n    const element = typeof col === \"string\" ? await getColumnHeader(col) : col;\n    await assertTextColor(element, color);\n  }\n\n  export async function assertHeaderFillColor(col: string | WebElement, color: string) {\n    const element = typeof col === \"string\" ? await getColumnHeader(col) : col;\n    await assertFillColor(element, color);\n  }\n\n  /**\n * Opens a cell color picker, either the default one or the one for a specific style rule.\n */\n  export async function openCellColorPicker(nr?: number) {\n    const selector = nr !== undefined ?\n      `.test-widget-style-conditional-rule-${nr} .test-color-select` :\n      \".test-cell-color-select .test-color-select\";\n    await driver.find(selector).click();\n    await findOpenMenu();\n  }\n\n  export async function assertCellTextColor(col: string, row: number, color: string) {\n    await assertTextColor(await getCell(col, row).find(\".field_clip\"), color);\n  }\n\n  export async function assertCellFillColor(col: string, row: number, color: string) {\n    await assertFillColor(await getCell(col, row), color);\n  }\n\n  export async function assertTextColor(cell: WebElement, color: string) {\n    color = nameToHex(color);\n    color = color.startsWith(\"#\") ? hexToRgb(color) : color;\n    const test = async () => {\n      const actual = await cell.getCssValue(\"color\");\n      assert.equal(actual, color);\n    };\n    await waitToPass(test, 500);\n  }\n\n  export async function assertFillColor(cell: WebElement, color: string) {\n    color = nameToHex(color);\n    color = color.startsWith(\"#\") ? hexToRgb(color) : color;\n    const test = async () => {\n      const actual = await cell.getCssValue(\"background-color\");\n      assert.equal(actual, color);\n    };\n    await waitToPass(test, 500);\n  }\n\n  // the rgbToHex function is from this conversation: https://stackoverflow.com/a/5624139/8728791\n  export function rgbToHex(color: string) {\n  // Next line extracts the 3 rgb components from a 'rgb(r, g, b)' string.\n    const [r, g, b] = color.split(/[,()rgba]/).filter(c => c).map(parseFloat);\n    return \"#\" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);\n  }\n\n  // Returns the `rgba( ... )` representation of a color given its hex representation `'#...'` . For\n  // instance `hexToRgb('#FFFFFF')` returns `'rgba( 255, 255, 255, 1)'`.\n  export function hexToRgb(hex: string) {\n    if (hex.length !== 7) { throw new Error(\"not an hex color #...\"); }\n    const aRgbHex = [hex[1] + hex[2], hex[3] + hex[4], hex[5] + hex[6]];\n    const [r, g, b] = [\n      parseInt(aRgbHex[0], 16),\n      parseInt(aRgbHex[1], 16),\n      parseInt(aRgbHex[2], 16),\n    ];\n    return `rgba(${r}, ${g}, ${b}, 1)`;\n  }\n\n  /**\n * Adds new column to the table.\n * @param name Name of the column\n */\n  export async function addColumn(name: string, type?: string) {\n    await scrollIntoView(await driver.find(\".active_section .mod-add-column\"));\n    await driver.find(\".active_section .mod-add-column\").click();\n    await findOpenMenu();\n    await driver.findWait(\".test-new-columns-menu-add-new\", 100).click();\n    await waitForMenuToClose();\n    await waitForServer();\n    await driver.findWait(\".test-column-title-popup\", 1000);\n    await waitAppFocus(false);\n    await driver.sendKeys(name);\n    await driver.sendKeys(Key.ENTER);\n    await waitForServer();\n    // Make sure the popup is gone.\n    await waitToPass(async () => {\n      assert.isFalse(await driver.find(\".test-column-title-popup\").isPresent());\n    });\n    if (type) {\n      await setType(exactMatch(type));\n    }\n  }\n\n  export async function showColumn(name: string) {\n    await scrollIntoView(await driver.find(\".active_section .mod-add-column\"));\n    await driver.find(\".active_section .mod-add-column\").click();\n    if (await driver.findContent(\".test-new-columns-menu-hidden-column-inlined\", `${name}`).isPresent()) {\n      await driver.findContent(\".test-new-columns-menu-hidden-column-inlined\", `${name}`).click();\n    } else {\n      await driver.findContent(\".test-new-columns-menu-hidden-column-collapsed\", `${name}`).click();\n    }\n    await waitForServer();\n  }\n\n  // Select a range of columns, clicking on col1 and dragging to col2.\n  export async function selectColumnRange(col1: string, col2: string) {\n    await getColumnHeader({ col: col1 }).mouseMove();\n    await driver.mouseDown();\n    await getColumnHeader({ col: col2 }).mouseMove();\n    await driver.mouseUp();\n  }\n\n  export async function selectGrid() {\n    await driver.find(\".gridview_data_corner_overlay\").click();\n  }\n\n  export async function selectColumn(col: string | IColHeader) {\n    await getColumnHeader(col).click();\n  }\n\n  export async function addSupportUserIfPossible() {\n    if (!server.isExternalServer() && process.env.TEST_SUPPORT_API_KEY) {\n    // Make sure we have a test support user.\n      const dbManager = await server.getDatabase();\n      const profile = { email: \"support@getgrist.com\", name: \"Support\" };\n      const user = await dbManager.getUserByLoginWithRetry(\"support@getgrist.com\", { profile });\n      if (!user) {\n        throw new Error(\"Failed to create test support user\");\n      }\n      if (!user.apiKey) {\n        user.apiKey = process.env.TEST_SUPPORT_API_KEY;\n        await user.save();\n      }\n    }\n  }\n\n  /**\n * Adds samples to the Examples & Templates page.\n */\n  async function addSamples(includeTutorial: boolean) {\n    await addSupportUserIfPossible();\n    const homeApi = createHomeApi(\"support\", \"docs\");\n\n    // Create the Grist Templates org.\n    await homeApi.newOrg({ name: \"Grist Templates\", domain: \"templates\" });\n\n    // Add 2 template workspaces.\n    const templatesApi = createHomeApi(\"support\", \"templates\");\n    await templatesApi.newWorkspace({ name: \"CRM\" }, \"current\");\n    await templatesApi.newWorkspace({ name: \"Other\" }, \"current\");\n\n    // Add a featured template to the CRM workspace.\n    const exampleDocId = (await importFixturesDoc(\"support\", \"templates\", \"CRM\",\n      \"video/Lightweight CRM.grist\", { load: false, newName: \"Lightweight CRM.grist\" })).id;\n    await templatesApi.updateDoc(\n      exampleDocId,\n      {\n        type: \"template\",\n        isPinned: true,\n        options: {\n          description: \"CRM template and example for linking data, and creating productive layouts.\",\n          icon: \"https://grist-static.com/icons/lightweight-crm.png\",\n          openMode: \"fork\",\n        },\n        urlId: \"lightweight-crm\",\n      },\n    );\n\n    // Add additional templates to the Other workspace.\n    const investmentDocId = (await importFixturesDoc(\"support\", \"templates\", \"Other\",\n      \"Investment Research.grist\", { load: false, newName: \"Investment Research.grist\" })).id;\n    await templatesApi.updateDoc(\n      investmentDocId,\n      {\n        type: \"template\",\n        isPinned: true,\n        options: {\n          description: \"Example for analyzing and visualizing with summary tables and linked charts.\",\n          icon: \"https://grist-static.com/icons/data-visualization.png\",\n          openMode: \"fork\",\n        },\n        urlId: \"investment-research\",\n      },\n    );\n    const afterschoolDocId = (await importFixturesDoc(\"support\", \"templates\", \"Other\",\n      \"video/Afterschool Program.grist\", { load: false, newName: \"Afterschool Program.grist\" })).id;\n    await templatesApi.updateDoc(\n      afterschoolDocId,\n      {\n        type: \"template\",\n        isPinned: true,\n        options: {\n          description: \"Example for how to model business data, use formulas, and manage complexity.\",\n          icon: \"https://grist-static.com/icons/business-management.png\",\n          openMode: \"fork\",\n        },\n        urlId: \"afterschool-program\",\n      },\n    );\n\n    for (const id of [exampleDocId, investmentDocId, afterschoolDocId]) {\n      await homeApi.updateDocPermissions(id, { users: {\n        \"everyone@getgrist.com\": \"viewers\",\n        \"anon@getgrist.com\": \"viewers\",\n      } });\n    }\n\n    if (includeTutorial) {\n      await templatesApi.newWorkspace({ name: \"Tutorials\" }, \"current\");\n      const tutorialDocId = (await importFixturesDoc(\"support\", \"templates\", \"Tutorials\",\n        \"Grist Basics.grist\", { load: false, newName: \"Grist Basics.grist\" })).id;\n      await templatesApi.updateDoc(\n        tutorialDocId,\n        {\n          type: \"tutorial\",\n          options: {\n            description: \"Learn Grist fast with a hands-on tutorial that covers the basics.\",\n            icon: \"https://grist-static.com/icons/grist-basics.png\",\n          },\n          urlId: \"grist-basics\",\n        },\n      );\n      await homeApi.updateDocPermissions(tutorialDocId, { users: {\n        \"everyone@getgrist.com\": \"viewers\",\n        \"anon@getgrist.com\": \"viewers\",\n      } });\n    }\n  }\n\n  /**\n * Removes the Grist Templates org.\n */\n  function removeTemplatesOrg() {\n    const homeApi = createHomeApi(\"support\", \"docs\");\n    return homeApi.deleteOrg(\"templates\");\n  }\n\n  /**\n * Call this at suite level to add sample documents to the\n * \"Examples & Templates\" page in before(), and remove added samples\n * in after().\n */\n  export function addSamplesForSuite(includeTutorial = false) {\n    before(async function() {\n      await addSamples(includeTutorial);\n    });\n\n    after(async function() {\n      await removeTemplatesOrg();\n    });\n  }\n\n  export async function openDocumentSettings() {\n    await openAccountMenu();\n    await driver.findContent(\".grist-floating-menu a\", \"Document settings\").click();\n    await waitForUrl(/settings/, 5000);\n  }\n\n  /**\n * Returns date format for date and datetime editor\n */\n  export async function getDateFormat(): Promise<string> {\n    const result = await driver.find(\"[data-test-id=Widget_dateFormat] .test-select-row\").getText();\n    if (result === \"Custom\") {\n      return driver.find(\"[data-test-id=Widget_dateCustomFormat] input\").value();\n    }\n    return result;\n  }\n\n  /**\n * Changes date format for date and datetime editor\n */\n  export async function setDateFormat(format: string | RegExp) {\n    await driver.find(\"[data-test-id=Widget_dateFormat]\").click();\n    await findOpenMenuItem(\".test-select-row\",\n      typeof format === \"string\" ? exactMatch(format) : format, 200).click();\n    await waitForServer();\n  }\n\n  export async function setCustomDateFormat(format: string) {\n    await setDateFormat(\"Custom\");\n    await driver.find(\"[data-test-id=Widget_dateCustomFormat]\").click();\n    await selectAll();\n    await driver.sendKeys(format, Key.ENTER);\n    await waitForServer();\n  }\n\n  /**\n * Returns time format for datetime editor\n */\n  export async function getTimeFormat(): Promise<string> {\n    return driver.find(\"[data-test-id=Widget_timeFormat] .test-select-row\").getText();\n  }\n\n  /**\n * Changes time format for datetime editor\n */\n  export async function setTimeFormat(format: string) {\n    await driver.find(\"[data-test-id=Widget_timeFormat]\").click();\n    await findOpenMenuItem(\".test-select-row\", format).click();\n    await waitForServer();\n  }\n\n  /**\n * Returns \"Show column\" setting value of a reference column.\n */\n  export async function getRefShowColumn(): Promise<string> {\n    return driver.find(\".test-fbuilder-ref-col-select\").getText();\n  }\n\n  /**\n * Changes \"Show column\" setting value of a reference column.\n */\n  export async function setRefShowColumn(col: string) {\n    await driver.find(\".test-fbuilder-ref-col-select\").click();\n    await findOpenMenuItem(\".test-select-row\", col, 100).click();\n    await waitForServer();\n  }\n\n  /**\n * Returns \"Data from table\" setting value of a reference column.\n */\n  export async function getRefTable(): Promise<string> {\n    return driver.find(\".test-fbuilder-ref-table-select\").getText();\n  }\n\n  /**\n * Changes \"Data from table\" setting value of a reference column.\n */\n  export async function setRefTable(table: string) {\n    await driver.find(\".test-fbuilder-ref-table-select\").click();\n    await findOpenMenuItem(\".test-select-row\", table).click();\n    await waitForServer();\n  }\n\n  /**\n * Changes \"Select by\" of the current section.\n */\n  export async function selectBy(table: string | RegExp) {\n    await toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await driver.find(\".test-config-data\").click();\n    await driver.find(\".test-right-select-by\").click();\n    table = typeof table === \"string\" ? exactMatch(table) : table;\n    await findOpenMenuItem(\"li\",  table, 200).click();\n    await waitForServer();\n  }\n\n  /**\n * Returns \"Select by\" of the current section.\n */\n  export async function selectedBy() {\n    await toggleSidePanel(\"right\", \"open\");\n    await driver.find(\".test-right-tab-pagewidget\").click();\n    await driver.find(\".test-config-data\").click();\n    return await driver.find(\".test-right-select-by\").getText();\n  }\n\n  // Add column to sort.\n  export async function addColumnToSort(colName: RegExp | string) {\n    await driver.find(\".test-sort-config-add\").click();\n    await findOpenMenuItem(\".test-sd-searchable-list-item\", colName).click();\n    await driver.findContentWait(\".test-sort-config-row\", colName, 100);\n  }\n\n  // Remove column from sort.\n  export async function removeColumnFromSort(colName: RegExp | string) {\n    await findSortRow(colName).find(\".test-sort-config-remove\").click();\n  }\n\n  // Toggle column sort order from ascending to descending, or vice-versa.\n  export async function toggleSortOrder(colName: RegExp | string) {\n    await findSortRow(colName).find(\".test-sort-config-order\").click();\n  }\n\n  // Reset the sort to the last saved sort.\n  export async function revertSortConfig() {\n    await driver.find(\".test-sort-filter-config-revert\").click();\n  }\n\n  // Save the sort.\n  export async function saveSortConfig() {\n    await driver.find(\".test-sort-filter-config-save\").click();\n    await waitForServer();\n  }\n\n  // Update the data positions to the given sort.\n  export async function updateRowsBySort() {\n    await driver.find(\".test-sort-config-update\").click();\n    await waitForServer(10000);\n  }\n\n  // Returns a WebElementPromise for the sort row of the given col name.\n  export function findSortRow(colName: RegExp | string) {\n    return driver.findContent(\".test-sort-config-row\", colName);\n  }\n\n  export async function getSortColumns() {\n    return await driver.findAll(\".grist-floating-menu .test-sort-config-column\", async (col) => {\n      const classes = await col.find(\".test-sort-config-order\").getAttribute(\"class\");\n      const match = classes.match(/test-sort-config-sort-order-(\\w+)/);\n      // Shouldn't happen - a sort should always have a direction.\n      if (!match) {\n        throw new Error(\"Sort element is missing direction\");\n      }\n      return {\n        column: await col.getText(),\n        dir: match[1],\n      };\n    });\n  }\n\n  // Opens more sort options menu\n  export async function openMoreSortOptions(colName: RegExp | string) {\n    const row = await findSortRow(colName);\n    await row.find(\".test-sort-config-options-icon\").click();\n    await findOpenMenu();\n  }\n\n  // Selects one of the options in the more options menu.\n  export async function toggleSortOption(option: SortOption) {\n    const label = await driver.find(`.test-sort-config-option-${option} label`);\n    await label.click();\n    await waitForServer();\n  }\n\n  // Closes more sort options menu.\n  export async function closeMoreSortOptionsMenu() {\n    await driver.sendKeys(Key.ESCAPE);\n  }\n\n  export type SortOption = \"naturalSort\" | \"emptyLast\" | \"orderByChoice\";\n  export const SortOptions: readonly SortOption[] = [\"orderByChoice\", \"emptyLast\", \"naturalSort\"];\n\n  // Returns checked sort options for current column. Assumes the menu is opened.\n  export async function getSortOptions(): Promise<SortOption[]> {\n    const options: SortOption[] = [];\n    for (const option of SortOptions) {\n      const list = await driver.findAll(`.test-sort-config-option-${option} input:checked`);\n      if (list.length) {\n        options.push(option);\n      }\n    }\n    options.sort();\n    return options;\n  }\n\n  // Returns enabled entries in sort menu. Assumes the menu is opened.\n  export async function getEnabledOptions(): Promise<SortOption[]> {\n    const options: SortOption[] = [];\n    for (const option of SortOptions) {\n      const list = await driver.findAll(`.test-sort-config-option-${option}:not(.disabled)`);\n      if (list.length) {\n        options.push(option);\n      }\n    }\n    options.sort();\n    return options;\n  }\n\n  /**\n * Runs action in a separate tab, closing the tab after.\n * In case of an error tab is not closed, consider using cleanupExtraWindows\n * on whole test suite if needed.\n *\n * If {test: this.test} is given in options, we will additionally record a screenshot and driver\n * logs, named using the test name, before opening the new tab, and before and after closing it.\n */\n  export async function onNewTab(action: () => Promise<void>, options?: { test?: Mocha.Runnable }) {\n    return onNewTabForUrl(\"about:blank\", action, options);\n  }\n\n  /**\n * Opens the given URL in a new tab, runs action() on that tab, and closes it after.\n *\n * See onNewTab for documentation of options.\n */\n  export async function onNewTabForUrl(url: string, action: () => Promise<void>, options?: { test?: Mocha.Runnable }) {\n    const currentTab = await driver.getWindowHandle();\n    await driver.executeScript((urlArg: string) => { window.open(urlArg, \"_blank\"); }, url);\n    const tabs = await driver.getAllWindowHandles();\n    const newTab = tabs[tabs.length - 1];\n    const test = options?.test;\n    let failed = false;\n    if (test) { await fetchScreenshotAndLogs(test); }\n    await driver.switchTo().window(newTab);\n    try {\n      await action();\n    } catch (e) {\n      console.warn(\"onNewTab error\", e);\n      failed = true;\n      throw e;\n    } finally {\n      if (test) { await fetchScreenshotAndLogs(test); }\n      const newCurrentTab = await driver.getWindowHandle();\n      if (newCurrentTab !== newTab) {\n        console.log(\"onNewTab not cleaning up because is not on expected tab\");\n      } else if (failed && process.env.NO_CLEANUP) {\n        console.log(\"onNewTab not cleaning up because failed with NO_CLEANUP set\");\n      } else {\n        await driver.close();\n        await driver.switchTo().window(currentTab);\n        console.log(\"onNewTab returned to original tab\");\n      }\n      if (test) { await fetchScreenshotAndLogs(test); }\n    }\n  }\n\n  /**\n * Returns a controller for the current tab.\n */\n  export async function myTab() {\n    const tabs = await driver.getAllWindowHandles();\n    const myTab = tabs[tabs.length - 1];\n    return {\n      open() {\n        return driver.switchTo().window(myTab);\n      },\n    };\n  }\n\n  /**\n * Duplicate current tab and return a controller for it. Assumes the current tab shows document.\n */\n  export async function duplicateTab() {\n    const url = await driver.getCurrentUrl();\n    await driver.executeScript(\"window.open('about:blank', '_blank')\");\n    const tabs = await driver.getAllWindowHandles();\n    const myTab = tabs[tabs.length - 1];\n    await driver.switchTo().window(myTab);\n    await driver.get(url);\n    await waitForDocToLoad();\n    return {\n      close() {\n        return driver.close();\n      },\n      open() {\n        return driver.switchTo().window(myTab);\n      },\n    };\n  }\n\n  /**\n * Scrolls active Grid or Card list view.\n */\n  export async function scrollActiveView(x: number, y: number) {\n    await driver.executeScript(function(x1: number, y1: number) {\n      const view = document.querySelector(\".active_section .grid_view_data\") ||\n        document.querySelector(\".active_section .detailview_scroll_pane\") ||\n        document.querySelector(\".active_section .test-forms-editor\");\n      view!.scrollBy(x1, y1);\n    }, x, y);\n    await driver.sleep(10); // wait a bit for the scroll to happen (this is async operation in Grist).\n  }\n\n  export async function scrollActiveViewTop() {\n    await driver.executeScript(function() {\n      const view = document.querySelector(\".active_section .grid_view_data\") ||\n        document.querySelector(\".active_section .detailview_scroll_pane\") ||\n        document.querySelector(\".active_section .test-forms-editor\");\n      view!.scrollTop = 0;\n    });\n    await driver.sleep(10); // wait a bit for the scroll to happen (this is async operation in Grist).\n  }\n\n  /**\n * Filters a column in a Grid using the filter menu.\n */\n  export async function filterBy(col: IColHeader | string, save: boolean, values: (string | RegExp)[]) {\n    const filter = await openColumnFilter(col);\n    await filter.none();\n    for (const value of values) {\n      await filter.toggleValue(value);\n    }\n    await filter.close();\n    if (save) {\n      await filter.save();\n    }\n  }\n\n  /**\n * Opens a filter menu for a column and returns a controller for it.\n */\n  export async function openColumnFilter(col: IColHeader | string) {\n    await openColumnMenu(col, \"Filter\");\n    return {\n      ...filterController,\n      open: () => openColumnMenu(col, \"Filter\"),\n    };\n  }\n\n  /**\n * Opens a filter menu for a column and returns a controller for it.\n */\n  export async function openPinnedFilter(col: string) {\n    const filterBar = driver.find(\".active_section .test-filter-bar\");\n    const pinnedFilter = filterBar.findContent(\".test-filter-field\", col);\n    await pinnedFilter.click();\n    await driver.findWait(\".test-filter-menu-wrapper\", 500);\n    return {\n      ...filterController,\n      open: () => openPinnedFilter(col),\n    };\n  }\n\n  const filterController = {\n    async toggleValue(value: string | RegExp) {\n      await driver.findContentWait(\".test-filter-menu-list label\", value, 100).click();\n      return this;\n    },\n    async none() {\n      await driver.findContent(\".test-filter-menu-bulk-action\", /None/).click();\n      return this;\n    },\n    async all() {\n      await driver.findContent(\".test-filter-menu-bulk-action\", /All/).click();\n      return this;\n    },\n    async close() {\n      await driver.find(\".test-filter-menu-apply-btn\").click();\n      return this;\n    },\n    async cancel() {\n      await driver.find(\".test-filter-menu-cancel-btn\").click();\n      return this;\n    },\n    async save() {\n      await driver.find(\".test-section-menu-small-btn-save\").click();\n      await waitForServer();\n      return this;\n    },\n    async search(text: string) {\n      await driver.find(\".test-filter-menu-search-input\").sendKeys(text);\n      return this;\n    },\n    async labels() {\n      return await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", el => el.getText());\n    },\n    async allShown() {\n      await driver.findContent(\".test-filter-menu-bulk-action\", /All shown/).click();\n    },\n  };\n\n  /**\n * Opens the filter menu in the current section, and removes all filters. Optionally saves it.\n */\n  export async function removeFilters(save = false) {\n    const sectionFilter = await sortAndFilter();\n    for (const filter of await sectionFilter.filters()) {\n      await filter.remove();\n    }\n    if (save) {\n      await sectionFilter.save();\n    } else {\n      await sectionFilter.click();\n    }\n  }\n\n  /**\n * Clicks on the filter icon in the current section, and returns a controller for it for interactions.\n */\n  export async function sortAndFilter() {\n    const ctrl = {\n      async addColumn() {\n        await driver.findWait(\".test-filter-config-add-filter-btn\", 1000).click();\n        return this;\n      },\n      async clickColumn(col: string) {\n        await driver.findContentWait(\".test-sd-searchable-list-item\", col, 1000).click();\n        return this;\n      },\n      async close() {\n        await driver.find(\".test-filter-menu-apply-btn\").click();\n        return this;\n      },\n      async save() {\n        await driver.findWait(\".test-section-menu-btn-save\", 1000).click();\n        await waitForServer();\n        return this;\n      },\n      /**\n     * Clicks the filter icon in the current section (can be used to close the filter menu or open it)\n     */\n      async click() {\n        await driver.find(\".active_section .test-section-menu-filter-icon\").click();\n        return this;\n      },\n      async filters() {\n        const items = await driver.findAll(\".test-filter-config-filter\");\n        return items.map(item => ({\n          async remove() {\n            await item.find(\".test-filter-config-remove-filter\").click();\n            return this;\n          },\n          async togglePin() {\n            await item.find(\".test-filter-config-pin-filter\").click();\n            return this;\n          },\n        }));\n      },\n    };\n    await ctrl.click();\n    return ctrl;\n  }\n\n  export interface PinnedFilter {\n    name: string;\n    hasUnsavedChanges: boolean;\n  }\n\n  /**\n * Returns a list of all pinned filters in the active section.\n */\n  export async function getPinnedFilters(): Promise<PinnedFilter[]> {\n    const filterBar = await driver.find(\".active_section .test-filter-bar\");\n    const allFilters = await filterBar.findAll(\".test-filter-field\", async (el) => {\n      const button = await el.find(\".test-btn\");\n      const buttonClass = await button.getAttribute(\"class\");\n      return {\n        name: await el.getText(),\n        isPinned: await el.getCssValue(\"display\") !== \"none\",\n        hasUnsavedChanges: !/\\b\\w+-grayed\\b/.test(buttonClass),\n      };\n    });\n    const pinnedFilters = allFilters.filter(({ isPinned }) => isPinned);\n    return pinnedFilters.map(({ name, hasUnsavedChanges }) => ({ name, hasUnsavedChanges }));\n  }\n\n  export interface FilterMenuValue {\n    checked: boolean;\n    value: string;\n    count: number;\n  }\n\n  /**\n * Returns a list of all values in the filter menu and their associated state.\n */\n  export async function getFilterMenuState(): Promise<FilterMenuValue[]> {\n    const items = await driver.findAll(\".test-filter-menu-list > *\");\n    return await Promise.all(items.map(async (item) => {\n      const checked = (await item.find(\"input\").getAttribute(\"checked\")) === null ? false : true;\n      const value = await item.find(\".test-filter-menu-value\").getText();\n      const count = parseInt(await item.find(\".test-filter-menu-count\").getText(), 10);\n      return { checked, value, count };\n    }));\n  }\n\n  /**\n * Dismisses coaching call if needed.\n */\n  export async function dismissCoachingCall() {\n    const selector = \".test-coaching-call .test-popup-close-button\";\n    if ((await driver.findAll(selector)).length) {\n      await driver.find(selector).click();\n    }\n  }\n\n  /**\n * Dismisses all card popups that are present.\n */\n  export async function dismissCardPopups(waitForServerTimeoutMs: number | null = 2000) {\n    let i = 0;\n    const max = 10;\n\n    // Keep dismissing popups until there are no more, up to a maximum of 10 times.\n    while (i < max && await driver.find(\".test-popup-card\").isPresent()) {\n      await driver.find(\".test-popup-close-button\").click();\n      if (waitForServerTimeoutMs) { await waitForServer(waitForServerTimeoutMs); }\n      i += 1;\n    }\n  }\n\n  /**\n * Confirms that anchor link was used for navigation.\n */\n  export async function waitForAnchor() {\n    await waitForDocToLoad();\n    await driver.wait(async () => (await getTestState()).anchorApplied, 2000);\n  }\n\n  export async function copyAnchor() {\n    await driver.find(\"body\").sendKeys(Key.chord(Key.SHIFT, await modKey(), \"a\"));\n\n    await waitToPass(async () => {\n      assert.isTrue(\n        await driver.findContentWait(\".test-notifier-toast-message\", /Link copied to clipboard/, 100).isDisplayed(),\n      );\n    });\n  }\n\n  export async function getAnchor() {\n    await copyAnchor();\n    return (await getTestState()).clipboard || \"\";\n  }\n\n  export async function getActiveSectionTitle(timeout?: number) {\n    return await driver.findWait(\".active_section .test-viewsection-title\", timeout ?? 0).getText();\n  }\n\n  export async function getSectionTitle(timeout?: number) {\n    return await driver.findWait(\".test-viewsection-title\", timeout ?? 0).getText();\n  }\n\n  export async function getSectionTitles() {\n    return await driver.findAll(\".test-viewsection-title\", el => el.getText());\n  }\n\n  export async function renameSection(sectionTitle: string, name: string) {\n    const renameWidget = driver.findContent(`.test-viewsection-title`, sectionTitle);\n    await renameWidget.find(\".test-widget-title-text\").click();\n    await doRenameSection(name);\n  }\n\n  export async function renameActiveSection(name: string) {\n    await driver.find(\".active_section .test-viewsection-title .test-widget-title-text\").click();\n    await doRenameSection(name);\n  }\n\n  async function doRenameSection(name: string) {\n    await driver.findWait(\".test-widget-title-popup\", 100);\n    await driver.find(\".test-widget-title-section-name-input\").click();\n    await selectAll();\n    await driver.sendKeys(name || Key.DELETE, Key.ENTER);\n    await waitForServer();\n    await notPresent(\".test-widget-title-section-name-input\");\n  }\n\n  /**\n * Renames active data table using widget title popup (from active section).\n */\n  export async function renameActiveTable(name: string) {\n    await driver.find(\".active_section .test-viewsection-title .test-widget-title-text\").click();\n    await driver.findWait(\".test-widget-title-popup\", 100);\n    await driver.find(\".test-widget-title-table-name-input\").click();\n    await selectAll();\n    await driver.sendKeys(name, Key.ENTER);\n    await waitAppFocus(true); // Wait for the editor to close so that waitForServer sees our request.\n    await waitForServer();\n  }\n\n  export async function getCustomWidgetName() {\n    await openWidgetPanel();\n    return await driver.find(\".test-config-widget-open-custom-widget-gallery\").getText();\n  }\n\n  export async function getCustomWidgetInfo(info: \"description\" | \"developer\" | \"last-updated\") {\n    await openWidgetPanel();\n    if (await driver.find(\".test-config-widget-show-custom-widget-details\").isPresent()) {\n      await driver.find(\".test-config-widget-show-custom-widget-details\").click();\n    }\n    if (!await driver.find(`.test-config-widget-custom-widget-${info}`).isPresent()) {\n      return \"\";\n    }\n\n    return await driver.find(`.test-config-widget-custom-widget-${info}`).getText();\n  }\n\n  export async function openCustomWidgetGallery() {\n    await openWidgetPanel();\n    await driver.find(\".test-config-widget-open-custom-widget-gallery\").click();\n    await waitForServer();\n  }\n\n  interface SetWidgetOptions {\n  /** Defaults to `true`. */\n    openGallery?: boolean;\n  }\n\n  export async function setCustomWidgetUrl(url: string, options: SetWidgetOptions = {}) {\n    const { openGallery = true } = options;\n    if (openGallery) { await openCustomWidgetGallery(); }\n    await driver.find(\".test-custom-widget-gallery-custom-url\").click();\n    await clearInput();\n    if (url) { await sendKeys(url); }\n    await sendKeys(Key.ENTER);\n    if (url) {\n      await driver.find(\".test-custom-widget-warning-modal-confirm-checkbox\").click();\n      await driver.find(\".test-modal-confirm\").click();\n    }\n    await waitForServer();\n  }\n\n  export async function setCustomWidget(content: string | RegExp, options: SetWidgetOptions = {}) {\n    if (content === \"Custom URL\") {\n      return setCustomWidgetUrl(\"\", options);\n    }\n    const { openGallery = true } = options;\n    if (openGallery) { await openCustomWidgetGallery(); }\n    await driver.findContent(\".test-custom-widget-gallery-widget\", content).click();\n    await driver.find(\".test-custom-widget-gallery-save\").click();\n    await waitForServer();\n  }\n\n  type BehaviorActions = \"Clear and reset\" | \"Convert column to data\" | \"Clear and make into formula\" |\n    \"Convert columns to data\";\n  /**\n * Opens a behavior menu and clicks one of the option.\n */\n  export async function changeBehavior(option: BehaviorActions | RegExp) {\n    await openColumnPanel();\n    await driver.find(\".test-field-behaviour\").click();\n    await findOpenMenuItem(\"li\", option).click();\n    await waitForServer();\n  }\n\n  export async function columnBehavior() {\n    return (await driver.find(\".test-field-behaviour\").getText());\n  }\n\n  /**\n * Gets all available options in the behavior menu.\n */\n  export async function availableBehaviorOptions() {\n    await driver.find(\".test-field-behaviour\").click();\n    await findOpenMenu();\n    const list = await driver.findAll(\".grist-floating-menu li\", el => el.getText());\n    await driver.sendKeys(Key.ESCAPE);\n    return list;\n  }\n\n  /**\n * Restarts the server ensuring that it is run with the given environment variables.\n * If variables are already set, the server is not restarted.\n *\n * Useful for local testing of features that depend on environment variables, as it avoids the need\n * to restart the server when those variables are already set.\n */\n  export function withEnvironmentSnapshot(vars: Record<string, any>) {\n    let oldEnv: testUtils.EnvironmentSnapshot | null = null;\n    before(async () => {\n    // Test if the vars are already set, and if so, skip.\n      if (Object.keys(vars).every(k => process.env[k] === vars[k])) { return; }\n      oldEnv = new testUtils.EnvironmentSnapshot();\n      for (const key of Object.keys(vars)) {\n        if (vars[key] === undefined || vars[key] === null) {\n          delete process.env[key];\n        } else {\n          process.env[key] = vars[key];\n        }\n      }\n      await server.restart();\n    });\n    after(async () => {\n      if (!oldEnv || noCleanup) { return; }\n      oldEnv.restore();\n      await server.restart();\n    });\n  }\n\n  /**\n * Helper to scroll creator panel top or bottom. By default bottom.\n */\n  export function scrollPanel(top = false): WebElementPromise {\n    return new WebElementPromise(driver,\n      driver.executeScript((top: number) => {\n        document.getElementsByClassName(\"test-config-container\")[0].scrollTop = top ? 0 : 10000;\n      }, top),\n    );\n  }\n\n  /**\n * Helper to revert ACL changes. It first saves the current ACL data, and\n * then removes everything and adds it back.\n */\n  export async function beginAclTran(api: UserAPI, docId: string) {\n    const oldRes = await api.getTable(docId, \"_grist_ACLResources\");\n    const oldRules = await api.getTable(docId, \"_grist_ACLRules\");\n\n    return async () => {\n      const newRes = await api.getTable(docId, \"_grist_ACLResources\");\n      const newRules = await api.getTable(docId, \"_grist_ACLRules\");\n      const restoreRes = { tableId: oldRes.tableId, colIds: oldRes.colIds };\n      const restoreRules = {\n        resource: oldRules.resource,\n        aclFormula: oldRules.aclFormula,\n        permissionsText: oldRules.permissionsText,\n      };\n      await api.applyUserActions(docId, [\n        [\"BulkRemoveRecord\", \"_grist_ACLRules\", newRules.id],\n        [\"BulkRemoveRecord\", \"_grist_ACLResources\", newRes.id],\n        [\"BulkAddRecord\", \"_grist_ACLResources\", oldRes.id, restoreRes],\n        [\"BulkAddRecord\", \"_grist_ACLRules\", oldRules.id, restoreRules],\n      ]);\n    };\n  }\n\n  /**\n * Helper to set the value of a column range filter bound. Helper also support picking relative date\n * from options for Date columns, simply pass {relative: '2 days ago'} as value.\n */\n  export async function setRangeFilterBound(minMax: \"min\" | \"max\", value: string | { relative: string } | null) {\n    await driver.find(`.test-filter-menu-${minMax}`).click();\n    if (typeof value === \"string\" || value === null) {\n      await selectAll();\n      await driver.sendKeys(value === null ? Key.DELETE : value);\n      // send TAB to trigger blur event, that will force call on the debounced callback\n      await driver.sendKeys(Key.TAB);\n    } else {\n      await waitToPass(async () => {\n      // makes sure the relative options is opened\n        if (!await driver.find(\".grist-floatin-menu\").isPresent()) {\n          await driver.find(`.test-filter-menu-${minMax}`).click();\n        }\n        await findOpenMenuItem(\"li\", value.relative).click();\n      });\n    }\n  }\n\n  /**\n * Skips the onboarding page that's shown to users on their first visit to the\n * doc menu.\n */\n  export async function skipOnboarding() {\n    await driver.findWait(\".test-onboarding-page\", 2000);\n    await waitForServer();\n    await driver.navigate().refresh();\n  }\n\n  /**\n * Asserts whether a video of Never Gonna Give You Up is playing in the background.\n */\n  export async function assertIsRickRowing(expected: boolean) {\n    assert.equal(await driver.find(\".test-gristdoc-stop-rick-rowing\").isPresent(), expected);\n    assert.equal(await driver.find(\".test-gristdoc-background-video\").isPresent(), expected);\n    assert.equal(await driver.find(\"iframe#youtube-player-dQw4w9WgXcQ\").isPresent(), expected);\n  }\n\n  export function produceUncaughtError(message: string) {\n  // Simply throwing an error from driver.executeScript() may produce a sanitized \"Script error\",\n  // depending on browser/webdriver version. This is a trick to ensure the uncaught error is\n  // considered same-origin by the main window.\n    return driver.executeScript((msg: string) => {\n      const script = document.createElement(\"script\");\n      script.type = \"text/javascript\";\n      script.innerText = \"setTimeout(() => { throw new Error(\" + JSON.stringify(msg) + \"); }, 0)\";\n      document.head.appendChild(script);\n    }, message);\n  }\n\n  export async function downloadSectionCsv(\n    section: string, headers: any = { Authorization: \"Bearer api_key_for_chimpy\" },\n  ) {\n    await openSectionMenu(\"viewLayout\", section);\n    const href = await driver.findWait(\".test-download-section\", 1000).getAttribute(\"href\");\n    await driver.sendKeys(Key.ESCAPE);  // Close section menu\n    const resp = await axios.get(href, { responseType: \"text\", headers });\n    return resp.data as string;\n  }\n\n  export async function downloadSectionCsvGridCells(\n    section: string, headers: any = { Authorization: \"Bearer api_key_for_chimpy\" },\n  ): Promise<string[]> {\n    const csvString = await downloadSectionCsv(section, headers);\n    const csvRows = csvString.split(\"\\n\").slice(1).map(csvDecodeRow);\n    return ([] as string[]).concat(...csvRows);\n  }\n\n  export async function setGristTheme(options: {\n    themeName: \"GristLight\" | \"GristDark\" | \"HighContrastLight\",\n    syncWithOS: boolean,\n    skipOpenSettingsPage?: boolean,\n  }) {\n    const { themeName, syncWithOS, skipOpenSettingsPage } = options;\n    if (!skipOpenSettingsPage) {\n      await openProfileSettingsPage();\n    }\n\n    await scrollIntoView(driver.find(\".test-theme-config-sync-with-os\"));\n    const isSyncWithOSChecked = await driver.find(\".test-theme-config-sync-with-os\").getAttribute(\"checked\") === \"true\";\n    if (syncWithOS !== isSyncWithOSChecked) {\n      await driver.find(\".test-theme-config-sync-with-os\").click();\n      await waitForServer();\n    }\n\n    if (!syncWithOS) {\n      await scrollIntoView(driver.find(\".test-theme-config-appearance .test-select-open\"));\n      await driver.find(\".test-theme-config-appearance .test-select-open\").click();\n      await findOpenMenuItem(\"li\", themeName === \"GristLight\" ?\n        \"Light\" : themeName === \"GristDark\" ?\n          \"Dark\" : \"Light (High Contrast)\")\n        .click();\n      await waitForServer();\n    }\n  }\n\n  /**\n * Executes custom code inside active custom widget.\n */\n  export async function customCode(fn: (grist: typeof PluginApi) => void) {\n    const section = await driver.findWait(\".active_section iframe\", 4000);\n    return await doInIframe(section, async () => {\n      return await driver.executeScript(`(${fn})(grist)`);\n    });\n  }\n\n  /**\n * Gets or sets widget access level (doesn't deal with prompts).\n */\n  export async function widgetAccess(level?: AccessLevel) {\n    const text = {\n      [AccessLevel.none]: \"No document access\",\n      [AccessLevel.read_table]: \"Read selected table\",\n      [AccessLevel.full]: \"Full document access\",\n    };\n    if (!level) {\n      const currentAccess = await driver.find(\".test-config-widget-access .test-select-open\").getText();\n      return Object.entries(text).find(e => e[1] === currentAccess)![0];\n    } else {\n      await driver.find(\".test-config-widget-access .test-select-open\").click();\n      await findOpenMenuItem(\"li\", text[level]).click();\n      await waitForServer();\n    }\n  }\n\n  /**\n * Checks if access prompt is visible.\n */\n  export async function hasAccessPrompt() {\n    return await driver.find(\".test-config-widget-access-accept\").isPresent();\n  }\n\n  /**\n * Accepts new access level.\n */\n  export async function acceptAccessRequest() {\n    await driver.findWait(\".test-config-widget-access-accept\", 1000).click();\n  }\n\n  /**\n * Rejects new access level.\n */\n  export async function rejectAccessRequest() {\n    await driver.find(\".test-config-widget-access-reject\").click();\n  }\n\n  /**\n * Sets widget access level (deals with requests).\n */\n  export async function changeWidgetAccess(access: \"read table\" | \"full\" | \"none\") {\n    await openWidgetPanel();\n\n    // if the current access is ok do nothing\n    if ((await widgetAccess()) === access) {\n    // unless we need to confirm it\n      if (await hasAccessPrompt()) {\n        await acceptAccessRequest();\n      }\n    } else {\n    // else switch access level\n      await widgetAccess(access as AccessLevel);\n    }\n  }\n\n  /**\n * Recently, driver.switchTo().window() has become a little flakey,\n * methods may fail if called immediately after switching to a\n * window. This method works around the problem by waiting for\n * driver.getCurrentUrl to succeed.\n *  https://github.com/SeleniumHQ/selenium/issues/12277\n */\n  export async function switchToWindow(target: string) {\n    await driver.switchTo().window(target);\n    for (let i = 0; i < 10; i++) {\n      try {\n        await driver.getCurrentUrl();\n        break;\n      } catch (e) {\n        console.log(\"switchToWindow retry after error:\", e);\n        await driver.sleep(250);\n      }\n    }\n  }\n\n  /**\n * Creates a temporary textarea to the document for pasting the contents of\n * the clipboard.\n */\n  export async function createClipboardTextArea() {\n    function createTextArea() {\n      const textArea = window.document.createElement(\"textarea\");\n      textArea.style.position = \"absolute\";\n      textArea.style.top = \"0\";\n      textArea.style.height = \"2rem\";\n      textArea.style.width = \"16rem\";\n      textArea.id = \"clipboardText\";\n      window.document.body.appendChild(textArea);\n    }\n\n    await driver.executeScript(createTextArea);\n  }\n\n  /**\n * Removes the temporary textarea added by `createClipboardTextArea`.\n */\n  export async function removeClipboardTextArea() {\n    function removeTextArea() {\n      const textArea = window.document.getElementById(\"clipboardText\");\n      if (textArea) {\n        window.document.body.removeChild(textArea);\n      }\n    }\n\n    await driver.executeScript(removeTextArea);\n  }\n\n  /**\n * Sets up a temporary textarea for pasting the contents of the clipboard,\n * removing it after all tests have run.\n */\n  export function withClipboardTextArea() {\n    before(async function() {\n      await createClipboardTextArea();\n    });\n\n    after(async function() {\n      await removeClipboardTextArea();\n    });\n  }\n\n  /*\n * Returns an instance of `LockableClipboard`, making sure to unlock it after\n * each test.\n *\n * Recommended for use in contexts where the system clipboard may be accessed by\n * multiple parallel processes, such as Mocha tests.\n */\n  export function getLockableClipboard() {\n    const cb = new LockableClipboard();\n\n    afterEach(async () => {\n      await cb.unlock();\n    });\n\n    return cb;\n  }\n\n  export interface ILockableClipboard {\n    lockAndPerform(callback: (clipboard: IClipboard) => Promise<void>): Promise<void>;\n    unlock(): Promise<void>;\n  }\n\n  class LockableClipboard implements ILockableClipboard {\n    private _unlock: (() => Promise<void>) | null = null;\n\n    constructor() {\n\n    }\n\n    public async lockAndPerform<T>(callback: (clipboard: IClipboard) => Promise<T>) {\n      this._unlock = await lock(path.resolve(getAppRoot(), \"test\"), {\n        lockfilePath: path.join(path.resolve(getAppRoot(), \"test\"), \".clipboard.lock\"),\n        retries: {\n        /* The clipboard generally isn't locked for long, so retry frequently. */\n          minTimeout: 200,\n          maxTimeout: 200,\n          retries: 100,\n        },\n      });\n      try {\n        return await callback(new Clipboard());\n      } finally {\n        await this.unlock();\n      }\n    }\n\n    public async unlock() {\n      await this._unlock?.();\n      this._unlock = null;\n    }\n  }\n\n  export type ClipboardAction = \"copy\" | \"cut\" | \"paste\";\n\n  export interface ClipboardActionOptions {\n    method?: \"keyboard\" | \"menu\";\n  }\n\n  export interface IClipboard {\n    copy(options?: ClipboardActionOptions): Promise<void>;\n    cut(options?: ClipboardActionOptions): Promise<void>;\n    paste(options?: ClipboardActionOptions): Promise<void>;\n  }\n\n  class Clipboard implements IClipboard {\n    constructor() {\n\n    }\n\n    public async copy(options: ClipboardActionOptions = {}) {\n      await this._performAction(\"copy\", options);\n    }\n\n    public async cut(options: ClipboardActionOptions = {}) {\n      await this._performAction(\"cut\", options);\n    }\n\n    public async paste(options: ClipboardActionOptions = {}) {\n      await this._performAction(\"paste\", options);\n    }\n\n    private async _performAction(action: ClipboardAction, options: ClipboardActionOptions) {\n      const { method = \"keyboard\" } = options;\n      switch (method) {\n        case \"keyboard\": {\n          await this._performActionWithKeyboard(action);\n          break;\n        }\n        case \"menu\": {\n          await this._performActionWithMenu(action);\n          break;\n        }\n      }\n    }\n\n    private async _performActionWithKeyboard(action: ClipboardAction) {\n      switch (action) {\n        case \"copy\": {\n          await sendKeys(Key.chord(await isMac() ? Key.COMMAND : Key.CONTROL, \"c\"));\n          break;\n        }\n        case \"cut\": {\n          await sendKeys(Key.chord(await isMac() ? Key.COMMAND : Key.CONTROL, \"x\"));\n          break;\n        }\n        case \"paste\": {\n          await sendKeys(Key.chord(await isMac() ? Key.COMMAND : Key.CONTROL, \"v\"));\n          break;\n        }\n      }\n    }\n\n    private async _performActionWithMenu(action: ClipboardAction) {\n      const field = await driver.find(\".active_section .field_clip.has_cursor\");\n      await driver.withActions((actions) => { actions.contextClick(field); });\n      await findOpenMenu(1000);\n      const menuItemName = action.charAt(0).toUpperCase() + action.slice(1);\n      await findOpenMenuItem(\"li\", menuItemName).click();\n    }\n  }\n\n  /**\n * Helper controller for choices list editor.\n */\n  export const choicesEditor = {\n    async hasReset() {\n      return (await driver.find(\".test-choice-list-entry-edit\").getText()) === \"Reset\";\n    },\n    async reset() {\n      await driver.findWait(\".test-choice-list-entry-edit\", 100).click();\n    },\n    async label() {\n      return await driver.find(\".test-choice-list-entry-row\").getText();\n    },\n    async add(label: string) {\n      await driver.find(\".test-tokenfield-input\").click();\n      await driver.find(\".test-tokenfield-input\").clear();\n      await sendKeys(label, Key.ENTER);\n    },\n    async rename(label: string, label2: string) {\n      const entry = await driver.findWait(`.test-choice-list-entry .test-token-label[value='${label}']`, 100);\n      await entry.click();\n      await sendKeys(label2);\n      await sendKeys(Key.ENTER);\n    },\n    async color(token: string, color: string) {\n      const label = await driver.findWait(`.test-choice-list-entry .test-token-label[value='${token}']`, 100);\n      await label.findClosest(\".test-tokenfield-token\").find(\".test-color-button\").click();\n      await setFillColor(color);\n      await sendKeys(Key.ENTER);\n    },\n    async read() {\n      return await driver.findAll(\".test-choice-list-entry-label\", e => e.getText());\n    },\n    async edit() {\n      await this.reset();\n    },\n    async save() {\n      await driver.find(\".test-choice-list-entry-save\").click();\n      await waitForServer();\n    },\n    async cancel() {\n      await driver.find(\".test-choice-list-entry-cancel\").click();\n    },\n  };\n\n  export async function switchUser(email: string) {\n    await driver.findWait(\".test-user-icon\", 1000).click();\n    await driver.findContentWait(\".test-usermenu-other-email\", exactMatch(email), 1000).click();\n    await waitForServer();\n  }\n\n  /**\n * Waits for the toast message with 'access denied' to appear.\n */\n  export async function waitForAccessDenied() {\n    await waitToPass(async () => {\n      assert.equal(\n        await driver.findWait(\".test-notifier-toast-message\", 100).getText(),\n        \"access denied\");\n    }, 500);\n  }\n\n  /**\n * Deletes a widget by title. Optionally confirms deletion only for the widget without the data.\n */\n  export async function deleteWidget(title: string) {\n    const menu = await openSectionMenu(\"viewLayout\", title);\n    await menu.findContent(\".test-cmd-name\", \"Delete widget\").click();\n    await waitForServer();\n  }\n\n  export async function deleteWidgetWithData(title?: string) {\n    title ??= await getActiveSectionTitle();\n    const menu = await openSectionMenu(\"viewLayout\", title);\n    await menu.findContent(\".test-cmd-name\", \"Delete widget\").click();\n    await driver.findWait(\".test-option-deleteOnlyWidget\", 100).click();\n    await driver.find(\".test-modal-confirm\").click();\n    await waitForServer();\n  }\n\n  export async function duplicateWidget(title?: string, targetPageTitle?: string) {\n    const menu = await openSectionMenu(\"viewLayout\", title);\n    await menu.findContent(\".test-cmd-name\", \"Duplicate widget\").click();\n\n    if (targetPageTitle) {\n      const select = buildSelectComponent(\".test-duplicate-widget-page-select\");\n      const option = (await select.options()).find(option => option.startsWith(targetPageTitle));\n      if (!option) {\n        await driver.find(\".test-modal-cancel\").click();\n        throw new Error(`Unable to find page ${targetPageTitle} when duplicating widget`);\n      }\n      await select.select(option);\n    }\n\n    await driver.find(\".test-modal-confirm\").click();\n    await waitForServer();\n  }\n\n  export async function waitForTrue(check: () => Promise<boolean>, timeMs: number = 4000) {\n    await waitToPass(async () => {\n      assert.isTrue(await check());\n    }, timeMs);\n  }\n\n  export const waitForAdminPanel = () => driver.findWait(\".test-admin-panel\", 2000);\n\n  /** Gets the value from the select component */\n  export async function getSelectValue(selector: string) {\n    return await driver.find(`${selector} .test-select-row`).getText();\n  }\n\n  /** Sets a value on the select component */\n  export async function setSelectValue(selector: string, value: string | RegExp) {\n    await driver.find(`${selector} .test-select-row`).click();\n    await findOpenMenuItem(\"li\", value).click();\n    await waitForServer();\n  }\n\n  /** Builds an interface for the select component  */\n  export function buildSelectComponent(selector: string) {\n    return {\n      selector,\n      element() {\n        return driver.find(selector);\n      },\n      /**\n     * Returns the currently selected value (text).\n     */\n      async value() {\n        return await getSelectValue(this.selector);\n      },\n      /**\n     * Waits for the select component to have the given value.\n     */\n      async waitForValue(value: string | RegExp) {\n        await waitToPass(async () => {\n          assert.equal(await getSelectValue(this.selector), value);\n        });\n      },\n      /**\n     * Selects the given value in the select component.\n     */\n      async select(value: string | RegExp) {\n        await setSelectValue(this.selector, value);\n      },\n      /**\n     * Returns the list of options in the select component (by opening the select menu).\n     */\n      async options() {\n        await driver.find(`${this.selector} .test-select-row`).click();\n        // Wait for the menu.\n        await findOpenMenu();\n        const options =  await findOpenMenuAllItems(\"li\", el => el.getText());\n        await driver.sendKeys(Key.ESCAPE);\n        return options;\n      },\n      /**\n     * Waits for the select component to be displayed.\n     */\n      async waitForDisplay() {\n        await waitToPass(async () => {\n          assert.isTrue(await driver.findWait(this.selector, 1000).isDisplayed());\n        });\n      },\n      /**\n     * Waits until the select component is umonuted from dom.\n     */\n      async waitForRemoval() {\n        await waitToPass(async () => {\n          assert.isFalse(await this.element().isPresent());\n        });\n      },\n    };\n  }\n\n  export function findOpenMenu(timeoutMsec = 100) {\n    return driver.findWait(\".grist-floating-menu\", timeoutMsec);\n  }\n\n  export function findOpenMenuItem(itemSelector: string, itemContentMatcher: string | RegExp,  timeoutMsec = 100) {\n    return driver.findContentWait(`.grist-floating-menu ${itemSelector}`, itemContentMatcher, timeoutMsec);\n  }\n\n  export async function findOpenMenuAllItems<T>(\n    itemSelector: string,\n    mapper: (e: WebElement) => Promise<T>,\n    timeoutMsec = 100,\n  ): Promise<T[]>   {\n  // Find at least one item to ensure the menu is open.\n    await driver.findWait(`.grist-floating-menu ${itemSelector}`, timeoutMsec);\n    return await driver.findAll(`.grist-floating-menu ${itemSelector}`, mapper);\n  }\n\n  /** Waits for the element to be not present in the dom */\n  export async function notPresent(selector: string) {\n    await waitToPass(async () => {\n      assert.isFalse(await driver.find(selector).isPresent());\n    }, 100);\n  }\n\n  export async function waitForContent(selector: string, text: string | RegExp) {\n    await waitToPass(async () => {\n      assert.equal(await driver.find(selector).getText(), text);\n    });\n  }\n\n  export async function waitForDisplay(selector: string) {\n    await waitToPass(async () => {\n      assert.isTrue(await driver.find(selector).isDisplayed());\n    });\n  }\n\n  export async function waitForMenuToClose() {\n    await notPresent(\".grist-floating-menu\");\n  }\n\n  /** Finds a tab by its name and clicks it */\n  export async function selectTab(name: string | RegExp) {\n    await driver.findContentWait(\".test-component-tabs-tab\", name, 100).click();\n  }\n\n  /**\n * Resize browser window more reliably by setting the viewport size.\n */\n  export async function setViewportDimensions(targetWidth: number, targetHeight: number) {\n  // Step 1: Set fixed size of the window to ensure consistent outer dimensions, and prevent maximized\n  // state which gives different outer dimensions.\n    await driver.manage().window().setRect({ width: 1000, height: 800 });\n\n    // Step 2: Get outer vs inner difference\n    const sizeDiff: { widthDiff: number, heightDiff: number } = await driver.executeScript(() => {\n      return {\n        widthDiff: window.outerWidth - window.innerWidth,\n        heightDiff: window.outerHeight - window.innerHeight,\n      };\n    });\n\n    const width = targetWidth + sizeDiff.widthDiff;\n    const height = targetHeight + sizeDiff.heightDiff;\n\n    // Step 3: Set outer window size to match desired viewport\n    await driver.manage().window().setRect({ width, height });\n  }\n\n  export async function getViewportDimensions(): Promise<WindowDimensions> {\n    return await driver.executeScript(() => {\n      return {\n        width: window.innerWidth,\n        height: window.innerHeight,\n      };\n    });\n  }\n\n} // end of namespace gristUtils\n\nstackWrapOwnMethods(gristUtils);\nexport = gristUtils;\n"
  },
  {
    "path": "test/nbrowser/gristWebDriverUtils.ts",
    "content": "/**\n * Utilities that simplify writing browser tests against Grist, which\n * have only mocha-webdriver as a code dependency. Separated out to\n * make easier to borrow for grist-widget repo.\n *\n * If you are seeing this code outside the grist-core repo, please don't\n * edit it, it is just a copy and local changes will prevent updating it\n * easily.\n */\n\nimport { CommandName } from \"app/client/components/commandList\";\nimport { DocAction, UserAction } from \"app/common/DocActions\";\n\nimport escapeRegExp from \"lodash/escapeRegExp\";\nimport { WebDriver, WebElement, WebElementPromise } from \"mocha-webdriver\";\n\ntype SectionTypes = \"Table\" | \"Card\" | \"Card List\" | \"Chart\" | \"Custom\" | \"Form\";\n\n// it is sometimes useful in debugging to turn off automatic cleanup of docs and workspaces.\nexport const noCleanup = Boolean(process.env.NO_CLEANUP);\n\nexport class GristWebDriverUtils {\n  public constructor(public driver: WebDriver) {\n  }\n\n  public isSidePanelOpen(which: \"right\" | \"left\"): Promise<boolean> {\n    return this.driver.find(`.test-${which}-panel`).matches(\"[class*=-open]\");\n  }\n\n  /**\n   * Waits for all pending comm requests from the client to the doc worker to complete. This taps into\n   * Grist's communication object in the browser to get the count of pending requests.\n   *\n   * Simply call this after some request has been made, and when it resolves, you know that request\n   * has been processed.\n   * @param optTimeout: Timeout in ms, defaults to 5000.\n   */\n  public async waitForServer(optTimeout: number = 5000) {\n    await this.driver.wait(() => this.driver.executeScript(\n      \"return window.gristApp && (!window.gristApp.comm || !window.gristApp.comm.hasActiveRequests())\" +\n      \" && window.gristApp.testNumPendingApiRequests() === 0\",\n    )\n      // The catch is in case executeScript() fails. This is rare but happens occasionally when\n      // browser is busy (e.g. sorting) and doesn't respond quickly enough. The timeout selenium\n      // allows for a response is short (and I see no place to configure it); by catching, we'll\n      // let the call fail until our intended timeout expires.\n      .catch((e) => { console.log(\"Ignoring executeScript error\", String(e)); }),\n    optTimeout,\n    \"Timed out waiting for server requests to complete\",\n    );\n  }\n\n  public async waitForSidePanel() {\n    // 0.4 is the duration of the transition setup in app/client/ui/PagePanels.ts for opening the\n  // side panes\n    const transitionDuration = 0.4;\n\n    // let's add an extra delay of 0.1 for even more robustness\n    const delta = 0.1;\n    await this.driver.sleep((transitionDuration + delta) * 1000);\n  }\n\n  /*\n   * Toggles (opens or closes) the right or left panel and wait for the transition to complete. An optional\n   * argument can specify the desired state.\n   */\n  public async toggleSidePanel(which: \"right\" | \"left\", goal: \"open\" | \"close\" | \"toggle\" = \"toggle\") {\n    if ((goal === \"open\" && await this.isSidePanelOpen(which)) ||\n      (goal === \"close\" && !await this.isSidePanelOpen(which))) {\n      return;\n    }\n\n    // Adds '-ns' when narrow screen\n    const suffix = (await this.getWindowDimensions()).width < 768 ? \"-ns\" : \"\";\n\n    // click the opener and wait for the duration of the transition\n    await this.driver.find(`.test-${which}-opener${suffix}`).doClick();\n    await this.waitForSidePanel();\n  }\n\n  /**\n   * Gets browser window dimensions.\n   */\n  public async getWindowDimensions(): Promise<WindowDimensions> {\n    const { width, height } = await this.driver.manage().window().getRect();\n    return { width, height };\n  }\n\n  /**\n   * Sets browser window dimensions.\n   */\n  public setWindowDimensions(width: number, height: number) {\n    return this.driver.manage().window().setRect({ width, height });\n  }\n\n  // Add a new widget to the current page using the 'Add New' menu.\n  public async addNewSection(\n    typeRe: RegExp | SectionTypes, tableRe: RegExp | string, options?: PageWidgetPickerOptions,\n  ) {\n    // Click the 'Add widget to page' entry in the 'Add New' menu\n    await this.driver.findWait(\".test-dp-add-new\", 2000).doClick();\n    await this.driver.findWait(\".test-dp-add-widget-to-page\", 500).doClick();\n\n    // add widget\n    await this.selectWidget(typeRe, tableRe, options);\n  }\n\n  // Select type and table that matches respectively typeRe and tableRe and save. The widget picker\n  // must be already opened when calling this function.\n  public async selectWidget(\n    typeRe: RegExp | string,\n    tableRe: RegExp | string = \"\",\n    options: PageWidgetPickerOptions = {},\n  ) {\n    const { customWidget, dismissTips, dontAdd, selectBy, summarize, tableName } = options;\n    const driver = this.driver;\n    if (dismissTips) { await this.dismissBehavioralPrompts(); }\n\n    // select right type\n    await driver.findContentWait(\".test-wselect-type\", typeRe, 500).doClick();\n\n    if (dismissTips) { await this.dismissBehavioralPrompts(); }\n\n    if (tableRe) {\n      const tableEl = driver.findContentWait(\".test-wselect-table\", tableRe, 100);\n\n      // unselect all selected columns\n      for (const col of (await driver.findAll(\".test-wselect-column[class*=-selected]\"))) {\n        await col.click();\n      }\n\n      // let's select table\n      await tableEl.click();\n\n      if (dismissTips) { await this.dismissBehavioralPrompts(); }\n\n      const pivotEl = tableEl.find(\".test-wselect-pivot\");\n      if (await pivotEl.isPresent()) {\n        await this.toggleSelectable(pivotEl, Boolean(summarize));\n      }\n\n      if (summarize) {\n        for (const columnEl of await driver.findAll(\".test-wselect-column\")) {\n          const label = await columnEl.getText();\n          // TODO: Matching cols with regexp calls for trouble and adds no value. I think function should be\n          // rewritten using string matching only.\n          const goal = Boolean(summarize.find(r => label.match(r)));\n          await this.toggleSelectable(columnEl, goal);\n        }\n      }\n\n      if (selectBy) {\n        // select link\n        await driver.findWait(\".test-wselect-selectby\", 100).doClick();\n        await driver.findContentWait(\".test-wselect-selectby option\", selectBy, 100).doClick();\n      }\n    }\n\n    if (dontAdd) { return; }\n\n    // add the widget\n    await driver.find(\".test-wselect-addBtn\").doClick();\n\n    // if we selected a new table, there will be a popup for a name\n    const prompts = await driver.findAll(\".test-modal-prompt\");\n    const prompt = prompts[0];\n    if (prompt) {\n      if (tableName) {\n        await prompt.doClear();\n        await prompt.click();\n        await driver.sendKeys(tableName);\n      }\n      await driver.find(\".test-modal-confirm\").click();\n    }\n\n    if (customWidget) {\n      await this.waitForServer();\n      await driver.findContent(\".test-custom-widget-gallery-widget-name\", customWidget).click();\n      await driver.find(\".test-custom-widget-gallery-save\").click();\n    }\n\n    await this.waitForServer();\n  }\n\n  /**\n   * Dismisses all behavioral prompts that are present.\n   */\n  public async dismissBehavioralPrompts() {\n    let i = 0;\n    const max = 10;\n\n    // Keep dismissing prompts until there are no more, up to a maximum of 10 times.\n    while (i < max && await this.driver.find(\".test-behavioral-prompt\").isPresent()) {\n      try {\n        await this.driver.findWait(\".test-behavioral-prompt-dismiss\", 100).click();\n      } catch (e) {\n        if (await this.driver.find(\".test-behavioral-prompt\").isPresent()) {\n          throw e;\n        }\n        break;\n      }\n      await this.waitForServer();\n      i += 1;\n    }\n  }\n\n  /**\n   * Toggle elem if not selected. Expects elem to be clickable and to have a class ending with\n   * -selected when selected.\n   */\n  public async toggleSelectable(elem: WebElement, goal: boolean) {\n    const isSelected = await elem.matches(\"[class*=-selected]\");\n    if (goal !== isSelected) {\n      await elem.click();\n    }\n  }\n\n  public async waitToPass(check: () => Promise<void>, timeMs: number = 4000) {\n    try {\n      let delay: number = 10;\n      await this.driver.wait(async () => {\n        try {\n          await check();\n        } catch (e) {\n          // Throttle operations a little bit.\n          await this.driver.sleep(delay);\n          if (delay < 50) { delay += 10; }\n          return false;\n        }\n        return true;\n      }, timeMs);\n    } catch (e) {\n      await check();\n    }\n  }\n\n  /**\n   * Refresh browser and dismiss alert that is shown (for refreshing during edits).\n   */\n  public async refreshDismiss({ ignore } = { ignore: false }) {\n    await this.driver.navigate().refresh();\n    await this.acceptAlert({ ignore });\n    await this.waitForDocToLoad();\n  }\n\n  /**\n   * Accepts an alert.\n   */\n  public async acceptAlert({ ignore } = { ignore: false }) {\n    try {\n      await (await this.driver.switchTo().alert()).accept();\n    } catch (e) {\n      if (!ignore) {\n        throw new Error(`Failed to accept alert: ${String(e)}`);\n      }\n      // If we are ignoring the alert, just log the error.\n      console.warn(`Ignoring alert accept error: ${String(e)}`);\n    }\n  }\n\n  /**\n   * Returns whether an alert is shown.\n   */\n  public async isAlertShown() {\n    try {\n      await this.driver.switchTo().alert();\n      return true;\n    } catch {\n      return false;\n    }\n  }\n\n  /**\n   * Wait for the doc to be loaded, to the point of finishing fetch for the data on the current\n   * page. If you navigate from a doc page, use e.g. waitForUrl() before waitForDocToLoad() to\n   * ensure you are checking the new page and not the old.\n   */\n  public async waitForDocToLoad(timeoutMs: number = 10000): Promise<void> {\n    await this.driver.findWait(\".viewsection_title\", timeoutMs);\n    await this.waitForServer();\n  }\n\n  public async reloadDoc() {\n    await this.driver.navigate().refresh();\n    await this.waitForDocToLoad();\n  }\n\n  /**\n   * Sends UserActions using client api from the browser.\n   */\n  public async sendActions(actions: (DocAction | UserAction)[], optTimeout: number = 5000) {\n    await this.driver.manage().setTimeouts({\n      script: optTimeout, /* milliseconds */\n    });\n\n    // Make quick test that we have a list of actions not just a single action, by checking\n    // if the first element is an array.\n    if (actions.length && !Array.isArray(actions[0])) {\n      throw new Error(\"actions argument should be a list of actions, not a single action\");\n    }\n\n    const result = await this.driver.executeAsyncScript(`\n    const done = arguments[arguments.length - 1];\n    const prom = gristDocPageModel.gristDoc.get().docModel.docData.sendActions(${JSON.stringify(actions)});\n    prom.then(() => done(null));\n    prom.catch((err) => done(String(err?.message || err)));\n  `);\n    if (result) {\n      throw new Error(result as string);\n    }\n    await this.waitForServer();\n  }\n\n  /**\n   * Runs a Grist command in the browser window.\n   */\n  public async sendCommand(name: CommandName, argument: any = null) {\n    await this.driver.executeAsyncScript((name: any, argument: any, done: any) => {\n      const result = (window as any).gristApp.allCommands[name].run(argument);\n      if (result?.finally) {\n        result.finally(done);\n      } else {\n        done();\n      }\n    }, name, argument);\n    await this.waitForServer();\n  }\n\n  public async openAccountMenu() {\n    await this.driver.findWait(\".test-dm-account\", 2000).click();\n    // Since the AccountWidget loads orgs and the user data asynchronously, the menu\n    // can expand itself causing the click to land on a wrong button.\n    await this.waitForServer();\n    await this.driver.findWait(\".test-site-switcher-org\", 2000);\n    await this.driver.sleep(250);  // There's still some jitter (scroll-bar? other user accounts?)\n  }\n\n  public async openProfileSettingsPage(): Promise<ProfileSettingsPage> {\n    await this.openAccountMenu();\n    await this.driver.find(\".grist-floating-menu .test-dm-account-settings\").click();\n    // close alert if it is shown\n    if (await this.isAlertShown()) {\n      await this.acceptAlert();\n    }\n    await this.driver.findWait(\".test-account-page-login-method\", 5000);\n    await this.waitForServer();\n    return new ProfileSettingsPage(this);\n  }\n\n  /**\n   * Click the Undo button and wait for server. If optCount is given, click Undo that many times.\n   */\n  public async undo(optCount: number = 1, optTimeout?: number) {\n    await this.waitForServer(optTimeout);\n    for (let i = 0; i < optCount; ++i) {\n      await this.driver.find(\".test-undo\").doClick();\n      await this.waitForServer(optTimeout);\n    }\n  }\n\n  /**\n   * Changes browser window dimensions for the duration of a test suite.\n   */\n  public resizeWindowForSuite(width: number, height: number) {\n    let oldDimensions: WindowDimensions;\n    before(async () => {\n      oldDimensions = await this.getWindowDimensions();\n      await this.setWindowDimensions(width, height);\n    });\n    after(async () => {\n      await this.setWindowDimensions(oldDimensions.width, oldDimensions.height);\n    });\n  }\n\n  /**\n   * Changes browser window dimensions to FullHd for a test suite.\n   */\n  public bigScreen(size: \"big\" | \"medium\" = \"medium\") {\n    // Note that the default (small) is 1024x640.\n    if (size === \"medium\") {\n      this.resizeWindowForSuite(1440, 900);\n    } else {\n      this.resizeWindowForSuite(1920, 1080);\n    }\n  }\n\n  /**\n   * Shrinks browser window dimensions to trigger mobile mode for a test suite.\n   */\n  public narrowScreen() {\n    this.resizeWindowForSuite(400, 750);\n  }\n\n  /**\n * Returns visible cells of the GridView from a single column and one or more rows. Options may be\n * given as arguments directly, or as an object.\n * - col: column name, or 0-based column index\n * - rowNums: array of 1-based row numbers, as visible in the row headers on the left of the grid.\n * - section: optional name of the section to use; will use active section if omitted.\n *\n * If given by an object, then an array of columns is also supported. In this case, the return\n * value is still a single array, listing all values from the first row, then the second, etc.\n *\n * Returns cell text by default. Mapper may be `identity` to return the cell objects.\n */\n  public async getVisibleGridCells(col: number | string, rows: number[], section?: string): Promise<string[]>;\n  public async getVisibleGridCells<T = string>(options: IColSelect<T> | IColsSelect<T>): Promise<T[]>;\n  public async getVisibleGridCells<T>(\n    colOrOptions: number | string | IColSelect<T> | IColsSelect<T>, _rowNums?: number[], _section?: string,\n  ): Promise<T[]> {\n    if (typeof colOrOptions === \"object\" && \"cols\" in colOrOptions) {\n      const { rowNums, section, mapper } = colOrOptions;\n      const columns = await Promise.all(colOrOptions.cols.map(oneCol =>\n        this.getVisibleGridCells({ col: oneCol, rowNums, section, mapper })));\n      // This zips column-wise data into a flat row-wise array of values.\n      return ([] as T[]).concat(...rowNums.map((_r, i) => columns.map(c => c[i])));\n    }\n\n    const { col, rowNums, section, mapper = el => el.getText() }: IColSelect<any> = (\n      typeof colOrOptions === \"object\" ? colOrOptions :\n        { col: colOrOptions, rowNums: _rowNums!, section: _section }\n    );\n\n    if (rowNums.includes(0)) {\n      // Row-numbers should be what the users sees: 0 is a mistake, so fail with a helpful message.\n      throw new Error(\"rowNum must not be 0\");\n    }\n\n    const sectionElem = section ? await this.getSection(section) : await this.driver.findWait(\".active_section\", 4000);\n    const colIndex = (typeof col === \"number\" ? col :\n      await sectionElem.findContent(\".column_name\", this.exactMatch(col)).index());\n\n    const visibleRowNums: number[] = await sectionElem.findAll(\".gridview_data_row_num\",\n      async el => parseInt(await el.getText(), 10));\n\n    const selector = `.gridview_data_scroll .record:not(.column_names) .field:nth-child(${colIndex + 1})`;\n    const fields = mapper ? await sectionElem.findAll(selector, mapper) : await sectionElem.findAll(selector);\n    return rowNums.map(n => fields[visibleRowNums.indexOf(n)]);\n  }\n\n  /**\n   * Returns a visible GridView cell. Options may be given as arguments directly, or as an object.\n   * - col: column name, or 0-based column index\n   * - rowNum: 1-based row numbers, as visible in the row headers on the left of the grid.\n   * - section: optional name of the section to use; will use active section if omitted.\n   */\n  public getCell(col: number | string, rowNum: number, section?: string): WebElementPromise;\n  public getCell(options: ICellSelect): WebElementPromise;\n  public getCell(colOrOptions: number | string | ICellSelect, rowNum?: number, section?: string): WebElementPromise {\n    const mapper = async (el: WebElement) => el;\n    const options: IColSelect<WebElement> = (typeof colOrOptions === \"object\" ?\n      { col: colOrOptions.col, rowNums: [colOrOptions.rowNum], section: colOrOptions.section, mapper } :\n      { col: colOrOptions, rowNums: [rowNum!], section, mapper });\n    return new WebElementPromise(this.driver, this.getVisibleGridCells(options).then(elems => elems[0]));\n  }\n\n  /**\n   * Returns a WebElementPromise for the .viewsection_content element for the section which contains\n   * the given text (case insensitive) content.\n   */\n  public getSection(sectionOrTitle: string | WebElement): WebElement | WebElementPromise {\n    if (typeof sectionOrTitle !== \"string\") { return sectionOrTitle; }\n    return this.driver.findContent(`.test-viewsection-title`, new RegExp(\"^\" + escapeRegExp(sectionOrTitle) + \"$\", \"i\"))\n      .findClosest(\".viewsection_content\");\n  }\n\n  /**\n   * Helper for exact string matches using interfaces that expect a RegExp. E.g.\n   *    driver.findContent('.selector', exactMatch(\"Foo\"))\n   *\n   * TODO It would be nice if mocha-webdriver allowed exact string match in findContent() (it now\n   * supports a substring match, but we still need a helper for an exact match).\n   */\n  public exactMatch(value: string, flags?: string): RegExp {\n    return new RegExp(`^${escapeRegExp(value)}$`, flags);\n  }\n\n  /**\n   * Click into a section without disrupting cursor positions.\n   */\n  public async selectSectionByTitle(title: string | RegExp) {\n    try {\n      if (typeof title === \"string\") {\n        title = new RegExp(\"^\" + escapeRegExp(title) + \"$\", \"i\");\n      }\n      // .test-viewsection is a special 1px width element added for tests only.\n      await this.driver.findContent(`.test-viewsection-title`, title).find(\".test-viewsection-blank\").click();\n    } catch (e) {\n      // We might be in mobile view.\n      await this.driver.findContent(`.test-viewsection-title`, title).findClosest(\".view_leaf\").click();\n    }\n  }\n\n  /**\n   * Click into a section without disrupting cursor positions.\n   */\n  public async selectSectionByIndex(index: number) {\n    const sections = await this.driver.findAll(\".test-viewsection-title\");\n    const section = sections.at(-1);\n    if (section === undefined) {\n      throw new Error(`No view section at index ${index}`);\n    }\n    try {\n      // .test-viewsection is a special 1px width element added for tests only.\n      await section.find(\".test-viewsection-blank\").click();\n    } catch (e) {\n      // We might be in mobile view.\n      await section.findClosest(\".view_leaf\").click();\n    }\n  }\n}\n\nexport interface WindowDimensions {\n  width: number;\n  height: number;\n}\n\nexport interface PageWidgetPickerOptions {\n  tableName?: string;\n  /** Optional pattern of SELECT BY option to pick. */\n  selectBy?: RegExp | string;\n  /** Optional list of patterns to match Group By columns. */\n  summarize?: (RegExp | string)[];\n  /** If true, configure the widget selection without actually adding to the page. */\n  dontAdd?: boolean;\n  /** If true, dismiss any tooltips that are shown. */\n  dismissTips?: boolean;\n  /** Optional pattern of custom widget name to select in the gallery. */\n  customWidget?: RegExp | string;\n}\n\nexport class ProfileSettingsPage {\n  private _driver: WebDriver;\n  private _gu: GristWebDriverUtils;\n\n  constructor(gu: GristWebDriverUtils) {\n    this._gu = gu;\n    this._driver = gu.driver;\n  }\n\n  public async setLanguage(language: string) {\n    await this._driver.findWait(\".test-account-page-language .test-select-open\", 100).click();\n    await this._driver.findContentWait(\".test-select-menu li\", language, 100).click();\n    await this._gu.waitForServer();\n  }\n}\n\nexport interface IColsSelect<T = WebElement> {\n  cols: (number | string)[];\n  rowNums: number[];\n  section?: string | WebElement;\n  mapper?: (e: WebElement) => Promise<T>;\n}\n\nexport interface IColSelect<T = WebElement> {\n  col: number | string;\n  rowNums: number[];\n  section?: string | WebElement;\n  mapper?: (e: WebElement) => Promise<T>;\n}\n\nexport interface ICellSelect {\n  col: number | string;\n  rowNum: number;\n  section?: string | WebElement;\n}\n"
  },
  {
    "path": "test/nbrowser/homeUtil.ts",
    "content": "/**\n * Contains some non-webdriver functionality needed by tests.\n */\n\nimport { BaseAPI, IOptions } from \"app/common/BaseAPI\";\nimport { normalizeEmail } from \"app/common/emails\";\nimport { UserProfile } from \"app/common/LoginSessionAPI\";\nimport { BehavioralPrompt, UserPrefs, WelcomePopup } from \"app/common/Prefs\";\nimport { DocWorkerAPI, UserAPI, UserAPIImpl } from \"app/common/UserAPI\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { TestingHooksClient } from \"app/server/lib/TestingHooks\";\n\nimport EventEmitter from \"events\";\nimport * as path from \"path\";\n\nimport FormData from \"form-data\";\nimport * as fse from \"fs-extra\";\nimport defaults from \"lodash/defaults\";\nimport { Key, WebDriver, WebElement } from \"mocha-webdriver\";\nimport fetch from \"node-fetch\";\nimport { authenticator } from \"otplib\";\n\nexport interface Server extends EventEmitter {\n  driver: WebDriver;\n  getTestingHooks(): Promise<TestingHooksClient>;\n  getHost(): string;\n  getUrl(team: string, relPath: string): string;\n  getDatabase(): Promise<HomeDBManager>;\n  isExternalServer(): boolean;\n}\n\nconst ALL_TIPS_ENABLED = {\n  behavioralPrompts: {\n    dontShowTips: false,\n    dismissedTips: [],\n  },\n  dismissedWelcomePopups: [],\n};\n\nconst ALL_TIPS_DISABLED = {\n  behavioralPrompts: {\n    dontShowTips: true,\n    dismissedTips: BehavioralPrompt.values,\n  },\n  dismissedWelcomePopups: WelcomePopup.values.map((id) => {\n    return {\n      id,\n      lastDismissedAt: 0,\n      nextAppearanceAt: null,\n      timesDismissed: 1,\n    };\n  }),\n};\n\nexport class HomeUtil {\n  // Cache api keys of test users.  It is often convenient to have various instances\n  // of the home api available while making browser tests.\n  private _apiKey = new Map<string, string>();\n\n  constructor(public fixturesRoot: string, public server: Server) {\n    server.on(\"stop\", () => {\n      this._apiKey.clear();\n    });\n  }\n\n  public get driver(): WebDriver { return this.server.driver; }\n\n  /**\n   * Set current session to a simulated login with the given name and email. Available options\n   * include:\n   *   - `loginMethod`: when provided will store in the database which method the\n   *     user nominally logged in with (e.g. 'Email + Password' or 'Google').\n   *   - `isFirstLogin`: when provided will cause user to be redirected or not to the\n   *     welcome pages.\n   *   - `freshAccount`: when true will cause the user account to be deleted and\n   *     recreated if it already existed.\n   *   - `cacheCredentials`: when true will result in the user's api key being stored\n   *     (after having been created if necessary), so that their home api can be later\n   *     instantiated without page loads.\n   * When testing against an external server, the simulated login is in fact genuine,\n   * done via the Grist login page.\n   */\n  public async simulateLogin(name: string, email: string, org: string = \"\", options: {\n    loginMethod?: UserProfile[\"loginMethod\"],\n    freshAccount?: boolean,\n    isFirstLogin?: boolean,\n    showGristTour?: boolean,\n    showTips?: boolean,\n    cacheCredentials?: boolean,\n  } = {}) {\n    const { loginMethod, isFirstLogin, showTips } = defaults(options, {\n      loginMethod: \"Email + Password\",\n      showTips: false,\n    });\n    const showGristTour = options.showGristTour ?? (options.freshAccount ?? isFirstLogin);\n\n    // For regular tests, we can log in through a testing hook.\n    if (!this.server.isExternalServer()) {\n      if (options.freshAccount) { await this._deleteUserByEmail(email); }\n      if (isFirstLogin !== undefined) { await this._setFirstLogin(email, isFirstLogin); }\n      if (showGristTour !== undefined) { await this._initShowGristTour(email, showGristTour); }\n      if (showTips) {\n        await this.enableTips(email);\n      } else {\n        await this.disableTips(email);\n      }\n      // TestingHooks communicates via JSON, so it's impossible to send an `undefined` value for org\n      // through it. Using the empty string happens to work though.\n      const testingHooks = await this.server.getTestingHooks();\n      const sid = await this.getGristSid();\n      if (!sid) { throw new Error(\"no session available\"); }\n      await testingHooks.setLoginSessionProfile(\n        sid,\n        { name, email, loginEmail: normalizeEmail(email), loginMethod },\n        org,\n      );\n    } else {\n      if (loginMethod && loginMethod !== \"Email + Password\") {\n        throw new Error(\"only Email + Password logins supported for external server tests\");\n      }\n      // Make sure we revisit page in case login is changing.\n      await this.driver.get(\"about:blank\");\n      await this._acceptAlertIfPresent();\n      // When running against an external server, we log in through the Grist login page.\n      await this.driver.get(this.server.getUrl(org, \"\"));\n      if (!await this.isOnLoginPage()) {\n        // Explicitly click Sign In button if necessary.\n        await this.driver.findWait(\".test-user-sign-in\", 4000).click();\n      }\n\n      // Fill the login form (either test or Grist).\n      if (await this.isOnTestLoginPage()) {\n        await this.fillTestLoginForm(email, name);\n      } else {\n        await this.fillGristLoginForm(email);\n      }\n\n      if (!await this.isWelcomePage() && (options.freshAccount || options.isFirstLogin)) {\n        await this._recreateCurrentUser(email, org, name);\n      }\n    }\n    if (options.freshAccount) {\n      this._apiKey.delete(email);\n    }\n    if (options.cacheCredentials && email !== \"anon@getgrist.com\") {\n      // Take this opportunity to cache access info.\n      if (!this._apiKey.has(email)) {\n        await this.driver.get(this.server.getUrl(org || \"docs\", \"\"));\n        const apiKey = await this._getApiKey();\n        this._apiKey.set(email, apiKey);\n      }\n    }\n  }\n\n  /**\n   * Remove any simulated login from the current session (for the given org, if specified).\n   * For testing against an external server, all logins are removed, since there's no way\n   * to be more nuanced.\n   */\n  public async removeLogin(org: string = \"\") {\n    // If cursor is on field editor, escape before remove login\n    await this.driver.sendKeys(Key.ESCAPE);\n    if (!this.server.isExternalServer()) {\n      const testingHooks = await this.server.getTestingHooks();\n      const sid = await this.getGristSid();\n      if (sid) { await testingHooks.setLoginSessionProfile(sid, null, org); }\n    } else {\n      await this.driver.get(`${this.server.getHost()}/logout`);\n      await this._acceptAlertIfPresent();\n    }\n  }\n\n  public async enableTips(email: string) {\n    await this._toggleTips(true, email);\n  }\n\n  public async disableTips(email: string) {\n    await this._toggleTips(false, email);\n  }\n\n  // Check if the url looks like a welcome page.  The check is weak, but good enough\n  // for testing.\n  public async isWelcomePage() {\n    const url = await this.driver.getCurrentUrl();\n    return Boolean(url.match(/\\/welcome\\//));\n  }\n\n  /**\n   * Fill the Grist test login page.\n   *\n   * TEST_ACCOUNT_PASSWORD must be set.\n   */\n  public async fillTestLoginForm(email: string, name?: string) {\n    const password = process.env.TEST_ACCOUNT_PASSWORD;\n    if (!password) { throw new Error(\"TEST_ACCOUNT_PASSWORD not set\"); }\n\n    const form = await this.driver.find(\"div.modal-content-desktop\");\n    await this.setValue(form.find('input[name=\"username\"]'), email);\n    if (name) { await this.setValue(form.find('input[name=\"name\"]'), name); }\n    await this.setValue(form.find('input[name=\"password\"]'), password);\n    await form.find('input[name=\"signInSubmitButton\"]').click();\n  }\n\n  /**\n   * Fill up the Grist login page form, and submit. If called with a user that\n   * has TOTP-based 2FA enabled, TEST_ACCOUNT_TOTP_SECRET must be set for a valid\n   * code to be submitted on the following form.\n   *\n   * Should be on the Grist login or sign-up page before calling this method. If\n   * `password` is not passed in, TEST_ACCOUNT_PASSWORD must be set.\n   */\n  public async fillGristLoginForm(email: string, password?: string) {\n    if (!password) {\n      password = process.env.TEST_ACCOUNT_PASSWORD;\n      if (!password) {\n        throw new Error(\"TEST_ACCOUNT_PASSWORD not set\");\n      }\n    }\n    await this.checkGristLoginPage();\n    if ((await this.driver.getCurrentUrl()).match(/signup\\?/)) {\n      await this.driver.findWait('a[href*=\"login?\"]', 4000).click();\n    }\n\n    await this.driver.findWait('input[name=\"email\"]', 4000).sendKeys(email);\n    await this.driver.find('input[name=\"password\"]').sendKeys(password);\n    await this.driver.find(\".test-lp-sign-in\").click();\n    await this.driver.wait(async () => !await this.isOnGristLoginPage(), 4000);\n    if (!await this.driver.findContent(\".test-mfa-title\", \"Almost there!\").isPresent()) {\n      return;\n    }\n\n    const secret = process.env.TEST_ACCOUNT_TOTP_SECRET;\n    if (!secret) { throw new Error(\"TEST_ACCOUNT_TOTP_SECRET not set\"); }\n\n    const code = authenticator.generate(secret);\n    await this.driver.find('input[name=\"verificationCode\"]').sendKeys(code);\n    await this.driver.find(\".test-mfa-submit\").click();\n    await this.driver.wait(\n      async () => {\n        return !await this.driver.findContent(\".test-mfa-title\", \"Almost there!\").isPresent();\n      },\n      4000,\n      \"Possible reason: verification code is invalid or expired (i.e. was recently used to log in)\",\n    );\n  }\n\n  /**\n   * Delete the currently logged in user.\n   */\n  public async deleteCurrentUser() {\n    const apiKey = await this._getApiKey();\n    const api = this._createHomeApiUsingApiKey(apiKey);\n    const info = await api.getSessionActive();\n    await api.deleteUser(info.user.id, info.user.name);\n  }\n\n  /**\n   * Returns the current Grist session-id (for the selenium browser accessing this server),\n   * or null if there is no session.\n   */\n  public async getGristSid(): Promise<string | null> {\n    // Load a cheap page on our server to get the session-id cookie from browser.\n    await this.driver.get(`${this.server.getHost()}/test/session`);\n    await this._acceptAlertIfPresent();\n    const cookie = await this.driver.manage().getCookie(process.env.GRIST_SESSION_COOKIE || \"grist_sid\");\n    if (!cookie) { return null; }\n    return decodeURIComponent(cookie.value);\n  }\n\n  /**\n   * Create a new document.\n   */\n  public async createNewDoc(username: string, org: string, workspace: string, docName: string,\n    options: { email?: string } = {}) {\n    const homeApi = this.createHomeApi(username, org, options.email);\n    const workspaceId = await this.getWorkspaceId(homeApi, workspace);\n    return await homeApi.newDoc({ name: docName }, workspaceId);\n  }\n\n  /**\n   * Import a fixture doc into a workspace.\n   */\n  public async importFixturesDoc(username: string, org: string, workspace: string,\n    filename: string, options: { newName?: string, email?: string } = {}) {\n    const homeApi = this.createHomeApi(username, org, options.email);\n    const docWorker = await homeApi.getWorkerAPI(\"import\");\n    const workspaceId = await this.getWorkspaceId(homeApi, workspace);\n    const uploadId = await this.uploadFixtureDoc(docWorker, filename, options.newName);\n    return docWorker.importDocToWorkspace(uploadId, workspaceId);\n  }\n\n  /**\n   * Create a copy of a doc. Similar to importFixturesDoc, but starts with an existing docId.\n   */\n  public async copyDoc(username: string, org: string, workspace: string,\n    docId: string, options: { newName?: string } = {}) {\n    const homeApi = this.createHomeApi(username, org);\n    const docWorker = await homeApi.getWorkerAPI(\"import\");\n    const workspaceId = await this.getWorkspaceId(homeApi, workspace);\n    const uploadId = await docWorker.copyDoc(docId);\n    return docWorker.importDocToWorkspace(uploadId, workspaceId);\n  }\n\n  // upload fixture document to the doc worker at url.\n  public async uploadFixtureDoc(docWorker: DocWorkerAPI, filename: string, newName: string = filename) {\n    const filepath = path.resolve(this.fixturesRoot, \"docs\", filename);\n    if (!await fse.pathExists(filepath)) {\n      throw new Error(`Can't find file: ${filepath}`);\n    }\n    const fileStream = fse.createReadStream(filepath);\n    // node-fetch can upload streams, although browser fetch can't\n    return docWorker.upload(fileStream as any, newName);\n  }\n\n  // A helper that find a workspace id by name for a given username and org.\n  public async getWorkspaceId(homeApi: UserAPIImpl, workspace: string): Promise<number> {\n    return (await homeApi.getOrgWorkspaces(\"current\")).find(w => w.name === workspace)!.id;\n  }\n\n  // A helper that returns the list of names of all documents within a workspace.\n  public async listDocs(homeApi: UserAPI, wid: number): Promise<string[]> {\n    const workspace = await homeApi.getWorkspace(wid);\n    return workspace.docs.map(d => d.name);\n  }\n\n  // A helper to create a UserAPI instance for a given useranme and org, that targets the home server\n  // Username can be null for anonymous access.\n  public createHomeApi(username: string | null, org: string, email?: string): UserAPIImpl {\n    return this.createApi(UserAPIImpl, username, org, email);\n  }\n\n  public createApi<T extends BaseAPI>(\n    creator: APIConstructor<T>,\n    username: string | null,\n    org: string,\n    email?: string,\n  ): T {\n    const apiKey = this.getApiKey(username, email);\n    return this._createApiUsingApiKey(creator, apiKey, org);\n  }\n\n  public getApiKey(username: string | null, email?: string): string | null {\n    const name = (username || \"\").toLowerCase();\n    const apiKey = username && ((email && this._apiKey.get(email)) || `api_key_for_${name}`);\n    return apiKey;\n  }\n\n  /**\n   * Set the value of an input element. This is to be used when the input element appears with its\n   * content already selected which can create some flakiness when using the normal approach based on\n   * `driver.sendKeys`. This is due to the fact that the implementation of such behaviour relies on a\n   * timeout that there is no easy way to listen to with selenium, so when sending keys, the\n   * `<element>.select()` could happens anytime on the client, which results in the value being\n   * truncated.\n   */\n  public async setValue(inputEl: WebElement, value: string) {\n    await this.driver.executeScript(\n      (input: HTMLInputElement, val: string) => { input.value = val; },\n      inputEl, value,\n    );\n  }\n\n  /**\n   * Returns whether we are currently on any login page (including the test page).\n   */\n  public async isOnLoginPage() {\n    return await this.isOnGristLoginPage() || await this.isOnTestLoginPage();\n  }\n\n  /**\n   * Returns whether we are currently on a Grist login page.\n   */\n  public async isOnGristLoginPage() {\n    const isOnSignupPage = await this.driver.find(\".test-sp-heading\").isPresent();\n    const isOnLoginPage = await this.driver.find(\".test-lp-heading\").isPresent();\n    return isOnSignupPage || isOnLoginPage;\n  }\n\n  /**\n   * Returns whether we are currently on the test login page.\n   */\n  public isOnTestLoginPage() {\n    return this.driver.findContent(\"h1\", \"A Very Credulous Login Page\").isPresent();\n  }\n\n  /**\n   * Waits for browser to navigate to any login page (including the test page).\n   */\n  public async checkLoginPage(waitMs: number = 2000) {\n    await this.driver.wait(this.isOnLoginPage.bind(this), waitMs);\n  }\n\n  /**\n   * Waits for browser to navigate to a Grist login page.\n   */\n  public async checkGristLoginPage(waitMs: number = 2000) {\n    await this.driver.wait(this.isOnGristLoginPage.bind(this), waitMs);\n  }\n\n  /**\n   * Delete and recreate the user, via the specified org.  The specified user must be\n   * currently logged in!\n   */\n  private async _recreateCurrentUser(email: string, org: string, name?: string) {\n    await this.deleteCurrentUser();\n    await this.removeLogin(org);\n    await this.driver.get(this.server.getUrl(org, \"\"));\n    await this.driver.findWait(\".test-user-sign-in\", 4000).click();\n    await this.checkLoginPage();\n    // Fill the login form (either test or Grist).\n    if (await this.isOnTestLoginPage()) {\n      await this.fillTestLoginForm(email, name);\n    } else {\n      await this.fillGristLoginForm(email);\n    }\n  }\n\n  private async _getApiKey(): Promise<string> {\n    return this.driver.wait(() => this.driver.executeAsyncScript<string>((done: (key: string) => void) => {\n      const app = (window as any).gristApp;\n      if (!app) { done(\"\"); return; }\n      const api: UserAPI = app.topAppModel.api;\n      return api.fetchApiKey().then((key) => {\n        if (key) { return key; }\n        return api.createApiKey();\n      }).then(done).catch(() => done(\"\"));\n    }), 4000);\n  }\n\n  // Delete a user using their email address.  Requires access to the database.\n  private async _deleteUserByEmail(email: string) {\n    if (this.server.isExternalServer()) { throw new Error(\"not supported\"); }\n    const dbManager = await this.server.getDatabase();\n    const user = await dbManager.getUserByLogin(email);\n    await dbManager.deleteUser({ userId: user.id }, user.id, user.name);\n  }\n\n  // Set whether this is the user's first time logging in.  Requires access to the database.\n  private async _setFirstLogin(email: string, isFirstLogin: boolean) {\n    if (this.server.isExternalServer()) { throw new Error(\"not supported\"); }\n    const dbManager = await this.server.getDatabase();\n    const user = await dbManager.getUserByLogin(email);\n    user.isFirstTimeUser = isFirstLogin;\n    await user.save();\n  }\n\n  private async _initShowGristTour(email: string, showGristTour: boolean) {\n    if (this.server.isExternalServer()) { throw new Error(\"not supported\"); }\n    const dbManager = await this.server.getDatabase();\n    const user = await dbManager.getUserByLogin(email);\n    if (user?.personalOrg) {\n      const userOrgPrefs = { showGristTour };\n      await dbManager.updateOrg({ userId: user.id }, user.personalOrg.id, { userOrgPrefs });\n    }\n  }\n\n  // Make a home api instance with the given api key, for the specified org.\n  // If no api key given, work anonymously.\n  private _createApiUsingApiKey<T extends BaseAPI>(\n    creator: APIConstructor<T>,\n    apiKey: string | null,\n    org?: string): T {\n    const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined;\n    return new creator(org ? this.server.getUrl(org, \"\") : this.server.getHost(), {\n      headers,\n      fetch: fetch as any,\n      newFormData: () => new FormData() as any,  // form-data isn't quite type compatible\n    });\n  }\n\n  // Make a home api instance with the given api key, for the specified org.\n  // If no api key given, work anonymously.\n  private _createHomeApiUsingApiKey(apiKey: string | null, org?: string): UserAPIImpl {\n    return this._createApiUsingApiKey(UserAPIImpl, apiKey, org);\n  }\n\n  private async _toggleTips(enabled: boolean, email: string) {\n    if (this.server.isExternalServer()) {\n      // Unsupported due to lack of access to the database.\n      return;\n    }\n\n    const dbManager = await this.server.getDatabase();\n    const user = await dbManager.getUserByLogin(email);\n\n    if (user.personalOrg) {\n      const org = await dbManager.getOrg({ userId: user.id }, user.personalOrg.id);\n      const userPrefs = (org.data as any)?.userPrefs ?? {};\n      const newUserPrefs: UserPrefs = {\n        ...userPrefs,\n        ...(enabled ? ALL_TIPS_ENABLED : ALL_TIPS_DISABLED),\n      };\n      await dbManager.updateOrg({ userId: user.id }, user.personalOrg.id, { userPrefs: newUserPrefs });\n    } else {\n      await this.driver.executeScript(`\n        const userPrefs = JSON.parse(localStorage.getItem('userPrefs:u=${user.id}') || '{}');\n        localStorage.setItem('userPrefs:u=${user.id}', JSON.stringify({\n          ...userPrefs,\n          ...${JSON.stringify(enabled ? ALL_TIPS_ENABLED : ALL_TIPS_DISABLED)},\n        }));\n      `);\n    }\n  }\n\n  private async _acceptAlertIfPresent() {\n    try {\n      await (await this.driver.switchTo().alert()).accept();\n    } catch {\n      /* There was no alert to accept. */\n    }\n  }\n}\n\nexport type APIConstructor<T extends BaseAPI> = new (homeUrl: string, options: IOptions) => T;\n"
  },
  {
    "path": "test/nbrowser/importerTestUtils.ts",
    "content": "/**\n * Testing utilities used in Importer test suites.\n */\n\nimport * as gu from \"test/nbrowser/gristUtils\";\n\nimport { driver, Key, stackWrapFunc, WebElementPromise } from \"mocha-webdriver\";\n\n// Helper to get the input of a matching parse option in the ParseOptions dialog.\nexport const getParseOptionInput = stackWrapFunc((labelRE: RegExp): WebElementPromise =>\n  driver.findContent(\".test-parseopts-opt\", labelRE).find(\"input\"));\n\ntype CellDiff = string | [string | undefined, string | undefined, string | undefined];\n\n/**\n * Returns preview diff cell values when the importer is updating existing records.\n *\n * If a cell has no diff, just the cell value is returned. Otherwise, a 3-tuple\n * containing the parent, remote, and common values (in that order) is returned.\n */\nexport const getPreviewDiffCellValues = stackWrapFunc(async (cols: number[], rowNums: number[]) => {\n  return gu.getPreviewContents<CellDiff>(cols, rowNums, async (cell) => {\n    const hasParentDiff = await cell.find(\".diff-parent\").isPresent();\n    const hasRemoteDiff = await cell.find(\".diff-remote\").isPresent();\n    const hasCommonDiff = await cell.find(\".diff-common\").isPresent();\n\n    const isDiff = hasParentDiff || hasRemoteDiff || hasCommonDiff;\n    return !isDiff ? await cell.getText() :\n      [\n        hasParentDiff ? await cell.find(\".diff-parent\").getText() : undefined,\n        hasRemoteDiff ? await cell.find(\".diff-remote\").getText() : undefined,\n        hasCommonDiff ? await cell.find(\".diff-common\").getText() : undefined,\n      ];\n  });\n});\n\n// Helper that waits for the diff preview to finish loading.\nexport const waitForDiffPreviewToLoad = async (): Promise<void> => {\n  await gu.waitForServer();\n  await driver.wait(() => driver.find(\".test-importer-preview\").isPresent(), 5000);\n  await driver.findWait(\".test-importer-preview .gridview_row\", 1000);\n\n  // Check if we can see row number 1\n  await driver.findContentWait(\".test-importer-preview .gridview_data_row_num\", \"1\", 5000);\n\n  // Click any cell that we see to set the focus, to try to make the\n  // state after waiting more deterministic.\n  // There is something odd occasionally, depending perhaps on scrolling,\n  // where the first row isn't clickable. Just working around it, and trying each\n  // of the first ten records, since this problem has persisted a very long time.\n  // TODO: find a more sensible fix.\n  const records = await driver.findAll(\".test-importer-preview .record-hlines\");\n  let success = false;\n  await gu.scrollIntoView(await records[0].find(\".field_clip\"));\n  for (const rec of records.slice(0, 10)) {\n    try {\n      const cell = await rec.find(\".field_clip\");\n      await cell.click();\n      // Wait for the focus to be set.\n      await driver.findWait(\".test-importer-preview .field_clip.has_cursor\", 100);\n      // Go to the first cell.\n      await gu.sendKeys(Key.chord(await gu.modKey(), Key.UP));\n      await gu.sendKeys(Key.HOME);\n      success = true;\n      break;\n    } catch (e) {\n      continue;\n    }\n  }\n  if (!success) {\n    throw Error(`tried cells without success`);\n  }\n};\n\n// Helper that gets the list of visible column matching rows to the left of the preview.\nexport const getColumnMatchingRows = stackWrapFunc(async (): Promise<{ source: string, destination: string }[]> => {\n  return await driver.findAll(\".test-importer-column-match-source-destination\", async (el) => {\n    const source = await el.find(\".test-importer-column-match-formula\").getAttribute(\"textContent\");\n    const destination = await el.find(\".test-importer-column-match-destination\").getText();\n    return { source, destination };\n  });\n});\n\nexport async function waitForColumnMapping() {\n  await driver.wait(() => driver.find(\".test-importer-column-match-options\").isDisplayed(), 300);\n}\n\nexport async function openColumnMapping() {\n  const selected = driver.find(\".test-importer-target-selected\");\n  await selected.find(\".test-importer-target-column-mapping\").click();\n  await driver.sleep(200); // animation\n  await waitForColumnMapping();\n}\n\nexport async function openTableMapping() {\n  await driver.find(\".test-importer-table-mapping\").click();\n  await driver.sleep(200); // animation\n  await driver.wait(() => driver.find(\".test-importer-target\").isDisplayed(), 300);\n}\n\n/**\n * Opens the menu for the destination column, by clicking the source.\n */\nexport async function openSource(text: string | RegExp) {\n  await driver.findContent(\".test-importer-column-match-destination\", text)\n    .findClosest(\".test-importer-column-match-source-destination\")\n    .find(\".test-importer-column-match-source\").click();\n}\n"
  },
  {
    "path": "test/nbrowser/links.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/nbrowser/testUtils\";\nimport { setupExternalSite } from \"test/server/customUtil\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"links\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n  let session: gu.Session;\n  let docId: string;\n  let urlId: string;\n\n  const externalSite = setupExternalSite(\"Dolphins are cool.\");\n\n  before(async function() {\n    session = await gu.session().login();\n    docId = await session.tempNewDoc(cleanup, \"links\");\n    urlId = (await gu.getCurrentUrlId())!;\n    await gu.openColumnPanel();\n    await gu.setType(\"Text\");\n  });\n\n  async function assertSameDocumentLink(value: string, expected: RegExp) {\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.ARROW_UP));\n    await gu.enterCell(value);\n    const link = await gu.getCell(0, 1).find(\"a\");\n    const href = await link.getAttribute(\"href\");\n    assert.match(href, expected);\n    const tabs = await driver.getAllWindowHandles();\n    await link.click();\n    assert.lengthOf(await driver.getAllWindowHandles(), tabs.length);\n    await gu.waitToPass(async () => {\n      assert.equal(await driver.getCurrentUrl(), href.split(\"#\")[0]);\n    }, 1000);\n    await driver.navigate().back();\n  }\n\n  async function assertNotSameDocumentLink(\n    value: string,\n    expected: RegExp | null,\n  ) {\n    await gu.sendKeys(Key.chord(await gu.modKey(), Key.ARROW_UP));\n    await gu.enterCell(value);\n    if ((await gu.getFieldWidgetType()) === \"TextBox\" && expected === null) {\n      assert.isFalse(await gu.getCell(0, 1).find(\"a\").isPresent());\n      return;\n    }\n\n    const link = await gu.getCell(0, 1).find(\"a\");\n    const href = await link.getAttribute(\"href\");\n    if (expected === null) {\n      assert.isNull(href);\n      return;\n    }\n\n    assert.match(href, expected);\n    const currentTab = await driver.getWindowHandle();\n    const tabs = await driver.getAllWindowHandles();\n    await link.click();\n    const newTabs = await driver.getAllWindowHandles();\n    assert.lengthOf(newTabs, tabs.length + 1);\n    await driver.switchTo().window(newTabs[newTabs.length - 1]);\n    try {\n      await gu.waitToPass(async () => {\n        assert.equal(await driver.getCurrentUrl(), href.split(\"#\")[0]);\n      }, 1000);\n    } finally {\n      await driver.close();\n      await driver.switchTo().window(currentTab);\n    }\n  }\n\n  for (const type of [\"TextBox\", \"HyperLink\", \"Markdown\"] as any) {\n    function makeLink(href: string) {\n      if (type === \"TextBox\") {\n        return href;\n      } else if (type === \"HyperLink\") {\n        return `Link ${href}`;\n      } else {\n        return `[Link](${href})`;\n      }\n    }\n\n    describe(`in ${type} cells`, function() {\n      before(async function() {\n        await gu.setFieldWidgetType(type);\n        await gu.getCell(0, 1).click();\n      });\n\n      beforeEach(async function() {\n        await gu.sendKeys(Key.chord(await gu.modKey(), Key.ARROW_UP));\n      });\n\n      it(\"have absolute URLs\", async function() {\n        // Previously, URLs in Markdown cells were treated as being relative to\n        // the document origin if they were missing a scheme. This was inconsistent\n        // with how HyperLink cells treated such URLs (with `http://` inferred).\n        await gu.enterCell(makeLink(\"google.com\"));\n        if (type !== \"TextBox\") {\n          assert.equal(\n            await gu.getCell(0, 1).find(\"a\").getAttribute(\"href\"),\n            \"https://google.com/\",\n          );\n        } else {\n          assert.isFalse(await gu.getCell(0, 1).find(\"a\").isPresent());\n        }\n      });\n\n      it(`have ${\n        type === \"Markdown\" ? \"a null\" : 'an \"about:blank\"'\n      } URL when invalid`, async function() {\n        await gu.enterCell(makeLink(\"javascript:alert()\"));\n        if (type !== \"TextBox\") {\n          assert.equal(\n            await gu.getCell(0, 1).find(\"a\").getAttribute(\"href\"),\n            type === \"Markdown\" ? null : \"about:blank\",\n          );\n        } else {\n          assert.isFalse(await gu.getCell(0, 1).find(\"a\").isPresent());\n        }\n      });\n\n      it(\"open without reloading if the URL is in the same document\", async function() {\n        await assertSameDocumentLink(\n          makeLink(server.getUrl(session.orgDomain, `/${urlId}/links/p/acl`)),\n          new RegExp(`\\\\/${urlId}\\\\/links\\\\/p\\\\/acl$`),\n        );\n        await assertSameDocumentLink(\n          makeLink(await gu.getAnchor()),\n          /links#a1\\.s1\\.r1\\.c2$/,\n        );\n        return;\n        await assertNotSameDocumentLink(\n          makeLink(externalSite.getUrl().href),\n          /localtest.datagrist.com/,\n        );\n        await assertNotSameDocumentLink(\n          makeLink(\"about:blank\"),\n          type !== \"HyperLink\" ? null : /about:blank$/,\n        );\n        await assertNotSameDocumentLink(\n          makeLink(\"somewhere\"),\n          type === \"TextBox\" ? null : /somewhere\\/$/,\n        );\n        await assertNotSameDocumentLink(\n          makeLink(server.getUrl(session.orgDomain, \"/docs/7pRKGiJGiuvZ\")),\n          /\\/docs\\/7pRKGiJGiuvZ$/,\n        );\n        await assertNotSameDocumentLink(\n          makeLink(\n            server.getUrl(session.orgDomain, `/${urlId}/links?Foo_=123`),\n          ),\n          new RegExp(`\\\\/${urlId}\\\\/links\\\\?Foo_=123$`),\n        );\n        await assertNotSameDocumentLink(\n          makeLink(server.getUrl(session.orgDomain, `/docs/${docId}`)),\n          new RegExp(`\\\\/docs\\\\/${docId}$`),\n        );\n      });\n\n      it(\"include aclAsUser when viewing a document as another user\", async function() {\n        await gu.openAccessRulesDropdown();\n        await gu.waitToPass(() => gu.findOpenMenuItem(\"a\", /Editor 1/, 500).click());\n        await gu.waitForDocToLoad();\n        await assertSameDocumentLink(\n          makeLink(server.getUrl(session.orgDomain, `/${urlId}/links/p/acl`)),\n          new RegExp(\n            `\\\\/${urlId}\\\\/links\\\\/p\\\\/acl\\\\?aclAsUser_=editor1%40example\\\\.com$`,\n          ),\n        );\n        await assertSameDocumentLink(\n          makeLink(await gu.getAnchor()),\n          /links\\?aclAsUser_=editor1%40example.com#a1\\.s1\\.r1\\.c2$/,\n        );\n        await assertNotSameDocumentLink(\n          makeLink(externalSite.getUrl().href),\n          /localtest.datagrist.com/,\n        );\n        await assertNotSameDocumentLink(\n          makeLink(\"about:blank\"),\n          type !== \"HyperLink\" ? null : /about:blank$/,\n        );\n        await assertNotSameDocumentLink(\n          makeLink(\"somewhere\"),\n          type === \"TextBox\" ? null : /somewhere\\/$/,\n        );\n        await assertNotSameDocumentLink(\n          makeLink(server.getUrl(session.orgDomain, \"/docs/7pRKGiJGiuvZ\")),\n          /\\/docs\\/7pRKGiJGiuvZ$/,\n        );\n        await assertNotSameDocumentLink(\n          makeLink(\n            server.getUrl(session.orgDomain, `/${urlId}/links?Foo_=123`),\n          ),\n          new RegExp(`\\\\/${urlId}\\\\/links\\\\?Foo_=123$`),\n        );\n        await assertNotSameDocumentLink(\n          makeLink(server.getUrl(session.orgDomain, `/docs/${docId}`)),\n          new RegExp(`\\\\/docs\\\\/${docId}$`),\n        );\n        await assertNotSameDocumentLink(\n          makeLink(\n            server.getUrl(\n              session.orgDomain,\n              `/docs/${docId}/links?aclAsUser_=editor2@example.com`,\n            ),\n          ),\n          /links\\?aclAsUser_=editor2@example.com$/,\n        );\n\n        await driver.find(\".test-revert\").click();\n        await gu.waitForDocToLoad();\n      });\n    });\n  }\n});\n"
  },
  {
    "path": "test/nbrowser/saveViewSection.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"saveViewSection\", function() {\n  this.timeout(20000);\n  setupTestSuite();\n  gu.bigScreen();\n\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup, \"test-updateViewSection\");\n  });\n\n  it(\"should work correctly when turning a table to 'summary'\", async () => {\n    // add new section\n    await gu.addNewSection(/Table/, /Table1/);\n\n    // change name and edit data of the 1st section (first found - both have the same name)\n    await gu.renameSection(\"TABLE1\", \"Foo\");\n\n    // open right panel\n    await gu.toggleSidePanel(\"right\");\n    await driver.find(\".test-config-data\").click();\n\n    // check there is no groupedBy\n    assert.equal(await driver.find(\".test-pwc-groupedBy\").isDisplayed(), false);\n\n    // click edit table data\n    await driver.find(\".test-pwc-editDataSelection\").doClick();\n\n    // summarize table by 'A' and save\n    await driver.findContent(\".test-wselect-table\", /Table1/).find(\".test-wselect-pivot\").doClick();\n    await driver.findContent(\".test-wselect-column\", /A/).doClick();\n    await driver.find(\".test-wselect-addBtn\").doClick();\n\n    // wait for server\n    await gu.waitForServer();\n\n    // check that new table is summarized\n    assert.equal(await driver.findWait(\".test-pwc-table\", 2000).getText(), \"Table1\");\n    assert.deepEqual(await driver.findAll(\".test-pwc-groupedBy-col\", e => e.getText()), [\"A\"]);\n\n    // check sections name did not change\n    assert.deepEqual(await gu.getSectionTitles(), [\"Foo\", \"TABLE1\"]);\n\n    // check 1st section is active\n    assert(await driver.find(\".viewsection_content\").matches(\".active_section\"));\n  });\n\n  it(\"should work correctly when changing table\", async () => {\n    // click edit table data\n    await driver.find(\".test-pwc-editDataSelection\").doClick();\n\n    // create a new table\n    await driver.findContent(\".test-wselect-table\", /New Table/).doClick();\n    await driver.find(\".test-wselect-addBtn\").doClick();\n\n    // wait for server\n    await gu.waitForServer();\n\n    // check that first section shows table2 with no grouped by cols\n    assert.equal(await driver.findWait(\".test-pwc-table\", 2000).getText(), \"Table2\");\n    assert.equal(await driver.find(\".test-pwc-groupedBy\").isDisplayed(), false);\n\n    // check sections name did not change\n    assert.deepEqual(await gu.getSectionTitles(), [\"Foo\", \"TABLE1\"]);\n\n    // check 1st section is active\n    assert(await driver.find(\".viewsection_content\").matches(\".active_section\"));\n\n    // revert to what it was\n    await gu.undo();\n  });\n\n  it(\"should work correctly when changing type\", async () => {\n    async function switchTypeAndAssert(t: string) {\n      // open page widget picker\n      await driver.find(\".test-pwc-editDataSelection\").doClick();\n\n      // select type t and save\n      await driver.findContent(\".test-wselect-type\", gu.exactMatch(t)).doClick();\n      await driver.find(\".test-wselect-addBtn\").doClick();\n      await gu.waitForServer();\n\n      // check section's type\n      await driver.find(\".test-pwc-editDataSelection\").doClick();\n      assert.equal(await driver.find(\".test-wselect-type[class*=-selected]\").getText(), t);\n\n      // close page widget picker\n      await driver.sendKeys(Key.ESCAPE);\n      await gu.checkForErrors();\n    }\n\n    // TODO: check what's shown by asserting data for each type\n    await switchTypeAndAssert(\"Card\");\n    await switchTypeAndAssert(\"Table\");\n    await switchTypeAndAssert(\"Chart\");\n  });\n\n  it(\"should work correctly when changing grouped by column\", async () => {\n    // open page widget picker\n    await driver.find(\".test-pwc-editDataSelection\").doClick();\n\n    // Select column B\n    await driver.findContent(\".test-wselect-column\", /B/).doClick();\n    await driver.find(\".test-wselect-addBtn\").doClick();\n    await gu.waitForServer();\n\n    // check grouped by is now A, B\n    assert.deepEqual(await driver.findAll(\".test-pwc-groupedBy-col\", e => e.getText()), [\"A\", \"B\"]);\n\n    await gu.undo();\n  });\n\n  it(\"should not hide any columns when changing to a summary table\", async () => {\n    // Previously, a bug when changing data selection would sometimes cause columns to be hidden.\n    // This test replicates a scenario that was used to reproduce the bug, and checks that it no\n    // longer occurs.\n\n    async function assertActiveSectionColumns(...expected: string[]) {\n      const activeSection = await driver.find(\".active_section\");\n      const actual = (await activeSection.findAll(\".column_name\", el => el.getText()))\n        .filter(name => name !== \"+\");\n      assert.deepEqual(actual, expected);\n    }\n\n    // Create a Places table with a single Place column.\n    await gu.addNewTable(\"Places\");\n    await gu.renameColumn({ col: 0 }, \"Place\");\n    await gu.sendKeys(Key.ARROW_RIGHT);\n    await gu.sendKeys(Key.chord(Key.ALT, \"-\"));\n    await gu.waitForServer();\n    await gu.sendKeys(Key.chord(Key.ALT, \"-\"));\n    await gu.waitForServer();\n\n    // Create an Orders table, and rename the last column to Test.\n    await gu.addNewTable(\"Orders\");\n    await gu.renameColumn({ col: 2 }, \"Test\");\n\n    // Duplicate the Places page.\n    await gu.openPageMenu(\"Places\");\n    await driver.find(\".test-docpage-duplicate\").click();\n    await driver.find(\".test-modal-confirm\").click();\n    await driver.findContentWait(\".test-docpage-label\", /copy/, 1000);\n    await gu.waitForServer();\n\n    // Change the duplicated page's data to summarize Orders, grouping by column Test.\n    await driver.find(\".test-pwc-editDataSelection\").doClick();\n    await driver.findContent(\".test-wselect-table\", /Orders/).find(\".test-wselect-pivot\").doClick();\n    await driver.findContent(\".test-wselect-column\", /Test/).doClick();\n    await driver.find(\".test-wselect-addBtn\").doClick();\n    await gu.waitForServer();\n\n    // Check all columns are visible.\n    await assertActiveSectionColumns(\"Test\", \"count\");\n  });\n\n  it(\"should disable summary when form type is selected\", async () => {\n    // select form type\n    await driver.find(\".test-dp-add-new\").doClick();\n    await driver.find(\".test-dp-add-new-page\").doClick();\n    await driver.findContent(\".test-wselect-type\", gu.exactMatch(\"Form\")).doClick();\n\n    // check that summary is disabled\n    assert.ok(await driver.find(\".test-wselect-pivot[class*=-disabled]\"));\n\n    // close page widget picker\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should disable form when summary is selected\", async () => {\n    // select table type then select summary for a Table\n    await driver.find(\".test-dp-add-new\").doClick();\n    await driver.find(\".test-dp-add-new-page\").doClick();\n    await driver.find(\".test-wselect-pivot\").doClick();\n\n    // check that form is disabled\n    assert.equal(await driver.find(\".test-wselect-type[class*=-disabled]\").getText(), \"Form\");\n\n    // close page widget picker\n    await driver.sendKeys(Key.ESCAPE);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser/testServer.ts",
    "content": "/**\n * NOTE: this server is also exposed via test/nbrowser/testUtils; it's only moved into its own\n * file to untangle dependencies between gristUtils and testUtils.\n *\n * Exports `server` to be used with mocha-webdriver's useServer(). This is normally set up using\n * `setupTestSuite` from test/nbrowser/testUtils.\n *\n * Includes server.testingHooks and some useful methods that rely on them.\n *\n * Run with VERBOSE=1 in the environment to see the server log on the console. Normally it goes\n * into a file whose path is printed when server starts.\n */\n\nimport { encodeUrl, IGristUrlState, parseSubdomain } from \"app/common/gristUrls\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport log from \"app/server/lib/log\";\nimport { getAppRoot } from \"app/server/lib/places\";\nimport { makeGristConfig } from \"app/server/lib/sendAppPage\";\nimport { exitPromise } from \"app/server/lib/serverUtils\";\nimport { connectTestingHooks, TestingHooksClient } from \"app/server/lib/TestingHooks\";\nimport { removeConnection } from \"test/gen-server/seed\";\nimport { HomeUtil } from \"test/nbrowser/homeUtil\";\nimport { getDatabase } from \"test/testUtils\";\n\nimport { ChildProcess, execFileSync, spawn } from \"child_process\";\nimport EventEmitter from \"events\";\nimport { tmpdir } from \"os\";\nimport * as path from \"path\";\n\nimport * as fse from \"fs-extra\";\nimport { driver, IMochaServer, WebDriver } from \"mocha-webdriver\";\nimport fetch from \"node-fetch\";\n\nexport class TestServerMerged extends EventEmitter implements IMochaServer {\n  public testDir: string;\n  public testDocDir: string;\n  public testingHooks: TestingHooksClient;\n\n  // These have been moved to HomeUtil, and get set here when HomeUtil is created.\n  public simulateLogin: HomeUtil[\"simulateLogin\"];\n  public removeLogin: HomeUtil[\"removeLogin\"];\n\n  private _serverUrl: string;\n  private _proxyUrl: string | null = null;\n  private _server: ChildProcess;\n  private _exitPromise: Promise<number | string>;\n  private _starts: number = 0;\n  private _dbManager?: HomeDBManager;\n  private _driver?: WebDriver;\n\n  // The name is used to name the directory for server logs and data.\n  constructor(private _name: string) {\n    super();\n  }\n\n  public async start() {\n    await this.restart(true);\n  }\n\n  /**\n   * Restart the server.  If reset is set, the database is cleared.  If reset is not set,\n   * the database is preserved, and the temporary directory is unchanged.\n   */\n  public async restart(reset: boolean = false, quiet = false) {\n    if (this.isExternalServer()) { return; }\n    if (this._starts > 0) {\n      this.resume();\n      await this.stop();\n    }\n    this._starts++;\n    const workerIdText = process.env.MOCHA_WORKER_ID || \"0\";\n    if (reset) {\n      // Make sure this test server doesn't keep using the DB that's about to disappear.\n      await this.closeDatabase();\n\n      if (process.env.TESTDIR) {\n        this.testDir = path.join(process.env.TESTDIR, workerIdText);\n      } else {\n        // Create a testDir of the form grist_test_{USER}_{SERVER_NAME}_{WORKER_ID}, removing any previous one.\n        const username = process.env.USER || \"nobody\";\n        this.testDir = path.join(tmpdir(), `grist_test_${username}_${this._name}_${workerIdText}`);\n        await fse.remove(this.testDir);\n      }\n    }\n    this.testDocDir = path.join(this.testDir, \"data\");\n    await fse.mkdirs(this.testDocDir);\n    log.warn(`Test logs and data are at: ${this.testDir}/`);\n\n    const nodeLogPath = path.join(this.testDir, \"node.log\");\n    const nodeLogFd = await fse.open(nodeLogPath, \"a\");\n\n    // The server isn't set up to close the testing socket cleanly and\n    // immediately.  It is simplest to use a diffent socket each time\n    // we restart.\n    const testingSocket = path.join(this.testDir, `testing-${this._starts}.socket`);\n    if (testingSocket.length >= 104) {\n      // Unix socket paths typically can't be longer than this. Who knew. Make the error obvious.\n      throw new Error(`Path of testingSocket too long: ${testingSocket.length} (${testingSocket})`);\n    }\n\n    const stubCmd = \"_build/stubs/app/server/server\";\n    const isCore = await fse.pathExists(stubCmd + \".js\");\n    const cmd = isCore ? stubCmd : \"_build/core/app/server/devServerMain\";\n    // If a proxy is set, use a single port - otherwise we'd need a lot of\n    // proxies.\n    const useSinglePort = this._proxyUrl !== null;\n\n    // The reason we fork a process rather than start a server within the same process is mainly\n    // logging. Server code uses a global logger, so it's hard to separate out (especially so if\n    // we ever run different servers for different tests).\n    const serverLog = process.env.VERBOSE ? \"inherit\" : nodeLogFd;\n    const workerId = parseInt(workerIdText, 10);\n    const corePort = String(8295 + workerId * 2);\n    const untrustedPort = String(8295 + workerId * 2 + 1);\n    const env: Record<string, string> = {\n      TYPEORM_DATABASE: this._getDatabaseFile(),\n      GRIST_DATA_DIR: this.testDocDir,\n      GRIST_INST_DIR: this.testDir,\n      // uses the test installed plugins folder as the user installed plugins.\n      GRIST_USER_ROOT: path.resolve(getAppRoot(), \"test/fixtures/plugins/browserInstalledPlugins/\"),\n      GRIST_TESTING_SOCKET: testingSocket,\n      // Set low limits for uploads, for testing.\n      GRIST_MAX_UPLOAD_IMPORT_MB: \"1\",\n      GRIST_MAX_UPLOAD_ATTACHMENT_MB: \"2\",\n      // The following line only matters for testing with non-localhost URLs, which some tests do.\n      GRIST_SERVE_SAME_ORIGIN: \"true\",\n      // Run with HOME_PORT, STATIC_PORT, DOC_PORT, DOC_WORKER_COUNT in the environment to override.\n      ...(useSinglePort ? {\n        // APP_HOME_URL needed if proxyUrl is set, otherwise can be omitted.\n        ...(this._proxyUrl ? {\n          APP_HOME_URL: this.getHost(),\n        } : undefined),\n        GRIST_SINGLE_PORT: \"true\",\n      } : (isCore ? {\n        HOME_PORT: corePort,\n        STATIC_PORT: corePort,\n        DOC_PORT: corePort,\n        DOC_WORKER_COUNT: \"1\",\n        PORT: corePort,\n        APP_UNTRUSTED_URL: `http://localhost:${untrustedPort}`,\n        GRIST_SERVE_PLUGINS_PORT: untrustedPort,\n      } : {\n        HOME_PORT: \"8095\",\n        STATIC_PORT: \"8096\",\n        DOC_PORT: \"8100\",\n        DOC_WORKER_COUNT: \"5\",\n        PORT: \"0\",\n        APP_UNTRUSTED_URL: \"http://localhost:18096\",\n      })),\n      // This skips type-checking when running server, but reduces startup time a lot.\n      TS_NODE_TRANSPILE_ONLY: \"true\",\n      ...process.env,\n      TEST_CLEAN_DATABASE: reset ? \"true\" : \"\",\n    };\n    if (!process.env.REDIS_URL) {\n      // Multiple doc workers only possible when redis is available.\n      log.warn(\"Running without redis and without multiple doc workers\");\n      delete env.DOC_WORKER_COUNT;\n    }\n    this._server = spawn(\"node\", [cmd], {\n      env: {\n        ...env,\n        ...(process.env.SERVER_NODE_OPTIONS ? { NODE_OPTIONS: process.env.SERVER_NODE_OPTIONS } : {}),\n      },\n      stdio: quiet ? \"ignore\" : [\"inherit\", serverLog, serverLog],\n    });\n    this._exitPromise = exitPromise(this._server);\n\n    const port = parseInt(env.HOME_PORT, 10);\n    this._serverUrl = `http://localhost:${port}`;\n    log.info(`Waiting for node server to respond at ${this._serverUrl}`);\n\n    // Try to be more helpful when server exits by printing out the tail of its log.\n    this._exitPromise.then((code) => {\n      if (this._server.killed || quiet) { return; }\n      log.error(\"Server died unexpectedly, with code\", code);\n      const output = execFileSync(\"tail\", [\"-30\", nodeLogPath]);\n      log.info(`\\n===== BEGIN SERVER OUTPUT ====\\n${output}\\n===== END SERVER OUTPUT =====`);\n    })\n      .catch(() => undefined);\n\n    await this.waitServerReady(60000);\n\n    // Prepare testingHooks for certain behind-the-scenes interactions with the server.\n    this.testingHooks = await connectTestingHooks(testingSocket);\n    this.emit(\"start\");\n  }\n\n  public async stop() {\n    if (this.isExternalServer()) { return; }\n    log.info(\"Stopping node server\");\n    this._server.kill();\n    if (this.testingHooks) {\n      this.testingHooks.close();\n    }\n    await this._exitPromise;\n    this.emit(\"stop\");\n  }\n\n  /**\n   * Set server on pause and call `callback()`. Callback must returned a promise and server will\n   * resume normal activity when that promise resolves. This is useful to test behavior when a\n   * request takes a long time.\n   */\n  public async pauseUntil(callback: () => Promise<void>) {\n    if (this.isExternalServer()) {\n      throw new Error(\"Can't pause external server\");\n    }\n    log.info(\"Pausing node server\");\n    this._server.kill(\"SIGSTOP\");\n    try {\n      await callback();\n    } finally {\n      log.info(\"Resuming node server\");\n      this.resume();\n    }\n  }\n\n  public resume() {\n    if (this.isExternalServer()) { return; }\n    this._server.kill(\"SIGCONT\");\n  }\n\n  public getHost(): string {\n    if (this.isExternalServer()) { return process.env.HOME_URL!; }\n    return this._proxyUrl || this._serverUrl;\n  }\n\n  public getUrl(team: string, relPath: string) {\n    if (!this.isExternalServer()) {\n      return `${this.getHost()}/o/${team}${relPath}`;\n    }\n    const state: IGristUrlState = { org: team };\n    const baseDomain = parseSubdomain(new URL(this.getHost()).hostname).base;\n    const gristConfig = makeGristConfig({\n      homeUrl: this.getHost(),\n      extra: {},\n      baseDomain,\n    });\n    const url = encodeUrl(gristConfig, state, new URL(this.getHost())).replace(/\\/$/, \"\");\n    return `${url}${relPath}`;\n  }\n\n  // Configure the server to be accessed via a proxy. You'll need to\n  // restart the server after changing this setting.\n  public updateProxy(proxyUrl: string | null) {\n    this._proxyUrl = proxyUrl;\n  }\n\n  /**\n   * Returns whether the server is up and responsive.\n   */\n  public async isServerReady(): Promise<boolean> {\n    try {\n      return (await fetch(`${this._serverUrl}/status/hooks`, { timeout: 1000 })).ok;\n    } catch (err) {\n      return false;\n    }\n  }\n\n  /**\n   * Wait for the server to be up and responsitve, for up to `ms` milliseconds.\n   */\n  public async waitServerReady(ms: number): Promise<void> {\n    await this.driver.wait(() => Promise.race([\n      this.isServerReady(),\n      this._exitPromise.then(() => { throw new Error(\"Server exited while waiting for it\"); }),\n    ]), ms);\n  }\n\n  /**\n   * Returns a connection to the database.\n   */\n  public async getDatabase(): Promise<HomeDBManager> {\n    if (!this._dbManager) {\n      this._dbManager = await getDatabase(this._getDatabaseFile());\n    }\n    return this._dbManager;\n  }\n\n  public async closeDatabase() {\n    this._dbManager = undefined;\n    await removeConnection();\n  }\n\n  public get driver() {\n    return this._driver || driver;\n  }\n\n  // substitute a custom driver\n  public setDriver(customDriver?: WebDriver) {\n    this._driver = customDriver;\n  }\n\n  public async getTestingHooks() {\n    return this.testingHooks;\n  }\n\n  public isExternalServer() {\n    return Boolean(process.env.HOME_URL);\n  }\n\n  /**\n   * Returns the path to the database.\n   */\n  private _getDatabaseFile(): string {\n    if (process.env.TYPEORM_TYPE === \"postgres\") {\n      const db = process.env.TYPEORM_DATABASE;\n      if (!db) { throw new Error(\"Missing TYPEORM_DATABASE\"); }\n      return db;\n    }\n    return path.join(this.testDir, \"landing.db\");\n  }\n}\n\nexport const server = new TestServerMerged(\"merged\");\n"
  },
  {
    "path": "test/nbrowser/testUtils.ts",
    "content": "/**\n * Exports `server`, set up to start using setupTestSuite(), e.g.\n *\n *    import {assert, driver} from 'mocha-webdriver';\n *    import {server, setupTestSuite} from 'test/nbrowser/testUtils';\n *\n *    describe(\"MyTest\", function() {\n *      this.timeout(20000);      // Needed because we wait for server for up to 15s.\n *      setupTestSuite();\n *    });\n *\n * Run with VERBOSE=1 in the environment to see the server log on the console. Normally it goes\n * into a file whose path is printed when server starts.\n *\n * Run `bin/mocha 'test/nbrowser/*.ts' -b --no-exit` to open a command-line prompt on\n * first-failure for debugging and quick reruns.\n */\nimport log from \"app/server/lib/log\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server } from \"test/nbrowser/testServer\";\nimport { setupCleanup } from \"test/server/testCleanup\";\n\nimport { addToRepl, assert, Capability, driver, enableDebugCapture, ITimeouts,\n  Key, setOptionsModifyFunc, useServer } from \"mocha-webdriver\";\n\n// Exports the server object with useful methods such as getHost(), waitServerReady(),\n// simulateLogin(), etc.\nexport { server, setupCleanup };\n\nsetOptionsModifyFunc(({ chromeOpts, firefoxOpts }) => {\n  if (process.env.TEST_CHROME_BINARY_PATH) {\n    chromeOpts.setChromeBinaryPath(process.env.TEST_CHROME_BINARY_PATH);\n  }\n\n  // Set \"kiosk\" printing that saves to PDF without offering any dialogs. This applies to regular\n  // (non-headless) Chrome. On headless Chrome, no dialog or output occurs regardless.\n  chromeOpts.addArguments(\"--kiosk-printing\");\n  // Latest chrome version 127, has started ignoring alerts and popups when controlled via a\n  // webdriver.\n  // https://github.com/SeleniumHQ/selenium/issues/14290\n  // According to the article above, popups and alerts are still shown in `BiDi` sessions. While we\n  // don't have latest webdriver library (where the new `enableBiDi` method is exposed), it can be\n  // toggled by using the `set` method in `capabilities` interface, as it is done here (long URL):\n\n  // https://github.com/shs96c/selenium/blob/ff82c4af6a493321d9eaec6ba8fa8589e4aa824d/javascript/node/selenium-webdriver/firefox.js#L415\n  chromeOpts.set(\"webSocketUrl\", true);\n  chromeOpts.set(Capability.UNHANDLED_PROMPT_BEHAVIOR, \"ignore\");\n\n  if (process.env.GRIST_TEST_FORCE_LIGHT_MODE) {\n    chromeOpts.addArguments(\n      \"--disable-features=WebContentsForceDark\",\n      \"--force-dark-mode=false\",\n    );\n  }\n\n  chromeOpts.setUserPreferences({\n    // Don't show popups to save passwords, which are shown when running against a deployment when\n    // we use a login form.\n    \"credentials_enable_service\": false,\n    \"profile\": {\n      content_settings: {\n        exceptions: {\n          clipboard: {\n            \"*\": {\n              // Grant access to the system clipboard. This applies to regular (non-headless)\n              // Chrome. On headless Chrome, this has no effect.\n              setting: 1,\n            },\n          },\n        },\n      },\n      // Don't show popups to save passwords.\n      password_manager_enabled: false,\n    },\n\n    // These preferences are my best effort to set up \"print to pdf\" that saves into the test's temp\n    // dir, based on discussion here: https://bugs.chromium.org/p/chromedriver/issues/detail?id=2821.\n    // On headless, it's ignored (no files are saved). When run manually, it would work EXCEPT with\n    // kiosk-printing (i.e. also ignored), so look for your downloaded PDFs elsewhere (perhaps\n    // ~/Downloads). Leaving it here in case it works better some day.\n    \"printing.default_destination_selection_rules\": JSON.stringify({\n      kind: \"local\",\n      namePattern: \"Save as PDF\",\n    }),\n    \"printing.print_preview_sticky_settings.appState\": JSON.stringify({\n      recentDestinations: [{\n        id: \"Save as PDF\",\n        origin: \"local\",\n        account: \"\",\n      }],\n      version: 2,\n    }),\n    \"download.default_directory\": server.testDir,\n    \"savefile.default_directory\": server.testDir,\n    \"autofill\": {\n      profile_enabled: false,\n      credit_card_enabled: false,\n    },\n  });\n});\n\ninterface TestSuiteOptions {\n  samples?: boolean;\n  tutorial?: boolean;\n  team?: boolean;\n\n  // If set, clear user preferences for all test users at the end of the suite. It should be used\n  // for suites that modify preferences. Not that it only works in dev, not in deployment tests.\n  clearUserPrefs?: boolean;\n\n  // Max milliseconds to wait for a page to finish loading. E.g. affects clicks that cause\n  // navigation, which wait for that. A navigation that takes longer will throw an exception.\n  pageLoadTimeout?: number;\n}\n\n// Sets up the test suite to use the Grist server, and also to record logs and screenshots after\n// failed tests (if MOCHA_WEBDRIVER_LOGDIR var is set).\n//\n// Returns a Cleanup instance as a convenience, for use scheduling any clean-up that would have\n// the same scope as the test suite.\nexport function setupTestSuite(options?: TestSuiteOptions) {\n  useServer(server);\n  enableDebugCapture();\n  addToRepl(\"gu\", gu, \"gristUtils, grist-specific helpers\");\n  addToRepl(\"Key\", Key, \"key values such as Key.ENTER\");\n  addToRepl(\"server\", server, \"test server\");\n\n  // After every suite, assert it didn't leave an alert open.\n  checkForAlerts();\n\n  // After every suite, assert it didn't leave new browser windows open.\n  checkForExtraWindows();\n\n  // After every suite, clear sessionStorage and localStorage to avoid affecting other tests.\n  if (!process.env.NO_CLEANUP) {\n    after(clearCurrentWindowStorage);\n  }\n  // Also, log out, to avoid logins interacting, unless NO_CLEANUP is requested (useful for\n  // debugging tests).\n  if (!process.env.NO_CLEANUP) {\n    after(() => server.removeLogin());\n  }\n\n  // If requested, clear user preferences for all test users after this suite.\n  if (options?.clearUserPrefs) {\n    after(clearTestUserPreferences);\n  }\n\n  // Though unlikely it is possible that the server was left paused by a previous test, so let's\n  // always call resume.\n  afterEach(() => server.resume());\n\n  // Close database until next test explicitly needs it, to avoid conflicts\n  // with tests that don't use the same server.\n  after(async () => server.closeDatabase());\n\n  if (process.env.GRIST_TEST_FORCE_LIGHT_MODE) {\n    before(async () => {\n      await (driver as any).sendDevToolsCommand(\n        \"Emulation.setEmulatedMedia\",\n        {\n          features: [\n            { name: \"prefers-color-scheme\", value: \"light\" },\n          ],\n        },\n      );\n    });\n  }\n\n  if (options?.pageLoadTimeout) {\n    setDriverTimeoutsForSuite({ pageLoad: options.pageLoadTimeout });\n  }\n\n  return setupRequirement({ team: true, ...options });\n}\n\n// Check for alerts after the test suite.\nfunction checkForAlerts() {\n  after(async () => {\n    const isAlertShown = await gu.isAlertShown();\n    try {\n      assert.isFalse(isAlertShown, \"Unexpected alert open after the test ended\");\n    } finally {\n      if (isAlertShown) {\n        await gu.acceptAlert();\n      }\n    }\n  });\n}\n\n// Clean up any browser windows after the test suite that didn't exist at its start.\nfunction checkForExtraWindows() {\n  let origHandles: string[];\n  before(async () => {\n    origHandles = await driver.getAllWindowHandles();\n  });\n  after(async () => {\n    assert.deepEqual(await driver.getAllWindowHandles(), origHandles);\n  });\n}\n\n// Clean up any browser windows after the test suite that didn't exist at its start.\n// Call this BEFORE setupTestSuite() when the test is expected to create new windows, so that they\n// may get cleaned up before the check for extraneous windows runs.\nexport function cleanupExtraWindows() {\n  let origHandles: string[];\n  before(async () => {\n    origHandles = await driver.getAllWindowHandles();\n  });\n  after(async () => {\n    const newHandles = await driver.getAllWindowHandles();\n    for (const w of newHandles) {\n      if (!origHandles.includes(w)) {\n        await driver.switchTo().window(w);\n        await driver.close();\n      }\n    }\n    await driver.switchTo().window(newHandles[0]);\n  });\n}\n\nasync function clearCurrentWindowStorage() {\n  if ((await driver.getCurrentUrl()).startsWith(\"http\")) {\n    try {\n      await driver.executeScript(\"window.sessionStorage.clear(); window.localStorage.clear();\");\n    } catch (err) {\n      log.info(\"Could not clear window storage after the test ended: %s\", err.message);\n    }\n  }\n}\n\nasync function clearTestUserPreferences() {\n  // After every suite, clear user preferences for all test users.\n  const dbManager = await server.getDatabase();\n  let emails = Object.keys(gu.TestUserEnum).map(user => gu.translateUser(user as any).email);\n  emails = [...new Set(emails)];    // Remove duplicates.\n  await dbManager.testClearUserPrefs(emails);\n}\n\nexport function setDriverTimeoutsForSuite(newTimeouts: ITimeouts) {\n  let prevTimeouts: ITimeouts | null = null;\n\n  before(async () => {\n    prevTimeouts = await driver.manage().getTimeouts();\n    await driver.manage().setTimeouts(newTimeouts);\n  });\n\n  after(async () => {\n    if (prevTimeouts) {\n      await driver.manage().setTimeouts(prevTimeouts);\n    }\n  });\n}\n\n/**\n * Implement some optional requirements for a test, such as having an example document\n * present, or a team site to run tests in.  These requirements should be automatically\n * satisfied by staging/prod deployments, and only need doing in self-contained tests\n * or tests against dev servers.\n *\n * Returns a Cleanup instance for any cleanup that would have the same scope as the\n * requirement.\n */\nexport function setupRequirement(options: TestSuiteOptions) {\n  const cleanup = setupCleanup();\n  const { samples, tutorial } = options;\n  if (samples || tutorial) {\n    if (process.env.TEST_ADD_SAMPLES || !server.isExternalServer()) {\n      gu.shareSupportWorkspaceForSuite(); // TODO: Remove after the support workspace is removed from the backend.\n      gu.addSamplesForSuite(tutorial);\n    }\n  }\n\n  before(async function() {\n    if (new URL(server.getHost()).hostname !== \"localhost\") {\n      // Non-dev servers should already meet the requirements; in any case we should not\n      // fiddle with them here.\n      return;\n    }\n\n    // Optionally ensure that a team site is available for tests.\n    if (options.team) {\n      await gu.addSupportUserIfPossible();\n      const api = gu.createHomeApi(\"support\", \"docs\");\n      for (const suffix of [\"\", \"2\"] as const) {\n        let orgName = `test${suffix}-grist`;\n        const deployment = process.env.GRIST_ID_PREFIX;\n        if (deployment) { orgName = `${orgName}-${deployment}`; }\n        let isNew: boolean = false;\n        try {\n          await api.newOrg({ name: `Test${suffix} Grist`, domain: orgName });\n          isNew = true;\n        } catch (e) {\n          // Assume the org already exists.\n        }\n        if (isNew) {\n          await api.updateOrgPermissions(orgName, {\n            users: {\n              \"gristoid+chimpy@gmail.com\": \"owners\",\n            },\n          });\n          // Recreate the api for the correct org, then update billing.\n          const api2 = gu.createHomeApi(\"support\", orgName);\n          const billing = api2.getBillingAPI();\n          try {\n            await billing.updateBillingManagers({\n              users: {\n                \"gristoid+chimpy@gmail.com\": \"managers\",\n              },\n            });\n          } catch (e) {\n            // ignore if no billing endpoint\n            if (!String(e).match(\"404: Not Found\")) {\n              throw e;\n            }\n          }\n        }\n      }\n    }\n  });\n  return cleanup;\n}\n"
  },
  {
    "path": "test/nbrowser/webdriverUtils.ts",
    "content": "import log from \"app/server/lib/log\";\n\nimport * as fs from \"fs/promises\";\nimport * as path from \"path\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\nexport async function fetchScreenshotAndLogs(test: Mocha.Runnable | undefined) {\n  const dir = process.env.MOCHA_WEBDRIVER_LOGDIR!;\n  assert.isOk(dir, \"driverLogging: MOCHA_WEBDRIVER_LOGDIR not set\");\n  const testName = test?.file ? path.basename(test.file, path.extname(test.file)) : \"unnamed\";\n  const logPath = path.resolve(dir, `${testName}-driverLogging.log`);\n  await fs.mkdir(dir, { recursive: true });\n  await driver.saveScreenshot(`${testName}-driverLoggingScreenshot-{N}.png`);\n  const messages = await driver.fetchLogs(\"driver\");\n  await fs.appendFile(logPath, messages.join(\"\\n\") + \"\\n\");\n}\n\nexport async function withDriverLogging(\n  test: Mocha.Runnable | undefined, periodMs: number, timeoutMs: number,\n  callback: () => Promise<void>,\n) {\n  let running = false;\n  async function repeat() {\n    if (running) {\n      log.warn(\"driverLogging: skipping because previous repeat still running\");\n      return;\n    }\n    running = true;\n    try {\n      await fetchScreenshotAndLogs(test);\n    } finally {\n      running = false;\n    }\n  }\n\n  const periodic = setInterval(repeat, periodMs);\n  const timeout = setTimeout(() => clearInterval(periodic), timeoutMs);\n  try {\n    return await callback();\n  } finally {\n    clearInterval(periodic);\n    clearTimeout(timeout);\n  }\n}\n"
  },
  {
    "path": "test/nbrowser/webdriverjq-nbrowser.js",
    "content": "/**\n * This is webdriverjq, modified to in fact no longer\n * use jquery, but rather to work in conjunction with\n * mocha-webdriver. Works in conjunction with\n * gristUtils-nbrowser.\n *\n * Not everything webdriverjq could do is supported,\n * just enough to make porting tests easier. The\n * promise manager mechanism selenium used to have\n * that old tests depends upon is gone (and good\n * riddance).\n *   https://github.com/SeleniumHQ/selenium/blob/trunk/javascript/node/selenium-webdriver/CHANGES.md#api-changes-2\n *\n * Changes are minimized here, no modernization unless\n * strictly needed, so diff with webdriverjq.js is\n * easier to read.\n */\n\nvar _ = require(\"underscore\");\nvar util = require(\"util\");\n\nimport { driver, error, stackWrapFunc,\n  WebElement, WebElementPromise } from \"mocha-webdriver\";\n\nexport const driverCompanion = {\n  $: null,\n};\n\nexport function webdriverjqWrapper(driver) {\n\n  /**\n   * Returns a new WebdriverJQ instance.\n   */\n  function $(selector) {\n    return new WebdriverJQ($, selector);\n  }\n\n  $.driver = driver;\n\n  $.getPage = function(url) {\n    return $.driver.get(url);\n  };\n\n  return $;\n}\n\n/**\n * The object obtained by $(...) calls. It supports nearly all the JQuery and WebElement methods,\n * and allows chaining whenever it makes sense.\n * @param {JQ} $: The $ object used to create this instance.\n * @param {String|WebElement} selector: A string selector, or a WebElement (or promise for one).\n */\nfunction WebdriverJQ($, selector) {\n  this.$ = $;\n  this.driver = $.driver;\n  this.selector = selector;\n  this._selectorDesc = selector;\n  this._callChain = [];\n}\n\n/**\n * $(...).method(...) invokes the corresponding method on the client side. Methods may be chained.\n * In reality, method calls aren't performed until the first non-chainable call. For example,\n * $(\".foo\").parent().toggleClass(\"active\").text() is translated to a single call.\n *\n * Note that a few methods are overridden later: click() and submit() are performed on the server\n * as WebDriver actions instead of on the client.\n *\n * The methods listed below are all the methods from http://api.jquery.com/.\n */\nvar JQueryMethodNames = [\n  \"add\", \"addBack\", \"addClass\", \"after\", \"ajaxComplete\", \"ajaxError\", \"ajaxSend\", \"ajaxStart\",\n  \"ajaxStop\", \"ajaxSuccess\", \"andSelf\", \"animate\", \"append\", \"appendTo\", \"attr\", \"before\", \"bind\",\n  \"blur\", \"change\", \"children\", \"clearQueue\", \"click\", \"clone\", \"closest\", \"contents\",\n  \"contextmenu\", \"css\", \"data\", \"dblclick\", \"delay\", \"delegate\", \"dequeue\", \"detach\", \"die\",\n  \"each\", \"empty\", \"end\", \"eq\", \"error\", \"fadeIn\", \"fadeOut\", \"fadeTo\", \"fadeToggle\", \"filter\",\n  \"find\", \"finish\", \"first\", \"focus\", \"focusin\", \"focusout\", \"get\", \"has\", \"hasClass\", \"height\",\n  \"hide\", \"hover\", \"html\", \"index\", \"innerHeight\", \"innerWidth\", \"insertAfter\", \"insertBefore\",\n  \"is\", \"keydown\", \"keypress\", \"keyup\", \"last\", \"live\", \"load\", \"load\", \"map\", \"mousedown\",\n  \"mouseenter\", \"mouseleave\", \"mousemove\", \"mouseout\", \"mouseover\", \"mouseup\", \"next\", \"nextAll\",\n  \"nextUntil\", \"not\", \"off\", \"offset\", \"offsetParent\", \"on\", \"one\", \"outerHeight\", \"outerWidth\",\n  \"parent\", \"parents\", \"parentsUntil\", \"position\", \"prepend\", \"prependTo\", \"prev\", \"prevAll\",\n  \"prevUntil\", \"promise\", \"prop\", \"pushStack\", \"queue\", \"ready\", \"remove\", \"removeAttr\",\n  \"removeClass\", \"removeData\", \"removeProp\", \"replaceAll\", \"replaceWith\", \"resize\", \"scroll\",\n  \"scrollLeft\", \"scrollTop\", \"select\", \"serialize\", \"serializeArray\", \"show\", \"siblings\",\n  \"slice\", \"slideDown\", \"slideToggle\", \"slideUp\", \"stop\", \"submit\", \"text\", \"toArray\", \"toggle\",\n  \"toggle\", \"toggleClass\", \"trigger\", \"triggerHandler\", \"unbind\", \"undelegate\", \"unload\",\n  \"unwrap\", \"val\", \"width\", \"wrap\", \"wrapAll\", \"wrapInner\",\n\n  // Extra methods (defined below, injected into client, available to these tests only).\n  \"trimmedText\", \"classList\", \"subset\", \"filterExactText\", \"filterText\", \"getAttribute\",\n\n  \"findOldTimey\", // nbrowser: added\n];\n\n// Maps method name to an array of argument types. If called with matching types, this method does\n// NOT return another JQuery object (i.e. it's not a chainable call).\nvar nonChainableMethods = {\n  \"attr\": [\"string\"],\n  \"css\": [\"string\"],\n  \"data\": [\"string\"],\n  \"get\": null,\n  \"hasClass\": [\"string\"],\n  \"height\": [],\n  \"html\": [],\n  \"index\": null,\n  \"innerHeight\": [],\n  \"innerWidth\": [],\n  \"is\": null,\n  \"offset\": [],\n  \"outerHeight\": [],\n  \"outerWidth\": [],\n  \"position\": [],\n  \"prop\": [\"string\"],\n  \"scrollLeft\": [],\n  \"scrollTop\": [],\n  \"serialize\": null,\n  \"serializeArray\": null,\n  \"text\": [],\n  \"toArray\": null,\n  \"val\": [],\n  \"width\": [],\n\n  \"trimmedText\": null,\n  \"classList\": null,\n  \"getAttribute\": null,\n};\n\nfunction isNonChainable(methodName, args) {\n  var argTypes = nonChainableMethods[methodName];\n  return argTypes === null ||\n    _.isEqual(argTypes, args.map(function(a) { return typeof a; }));\n}\n\nJQueryMethodNames.forEach(function(methodName) {\n  WebdriverJQ.prototype[methodName] = function() {\n    var args = Array.prototype.slice.call(arguments, 0);\n    var methodCallInfo = [methodName].concat(args);\n    var newObj = this.clone();\n    newObj._callChain.push(methodCallInfo);\n    if (isNonChainable(methodName, args)) {\n      return newObj.resolve();\n    } else {\n      return newObj;\n    }\n  };\n});\n\n// .length property is a special case.\nObject.defineProperty(WebdriverJQ.prototype, \"length\", {\n  configurable: false,\n  enumerable: false,\n  get: function() {\n    var newObj = this.clone();\n    newObj._callChain.push([\"length\"]);\n    return newObj.resolve();\n  }\n});\n\n\n/**\n * WebdriverJQ objects also support various WebDriver.WebElement methods, namely the ones\n * below. These operate on the first of the selected elements. Those without a meaningful return\n * value may be chained. E.g. $(\".foo\").click().val() will trigger a click on the selected\n * element, then return its 'value' property.\n */\n// This maps each supported method name to whether or not it should be chainable.\nvar WebElementMethodNames = {\n  \"getId\":        false,\n  \"getRawId\":     false,\n  \"click\":        true,\n  \"sendKeys\":     true,\n  \"getTagName\":   false,\n  \"getCssValue\":  false,\n  \"getText\":      false,\n  \"getSize\":      false,\n  \"getLocation\":  false,\n  \"isEnabled\":    false,\n  \"isSelected\":   false,\n  \"submit\":       true,\n  \"clear\":        true,\n  \"isDisplayed\":  false,\n  \"isPresent\":    false,  // nbrowser: added\n  \"getOuterHtml\": false,\n  \"getInnerHtml\": false,\n  \"scrollIntoView\": true,\n  \"sendNewText\":  true,   // Added below.\n};\n\nObject.keys(WebElementMethodNames).forEach(function(methodName) {\n  function runMethod(self, methodName, elem, ...argList) {\n    var result = elem[methodName].apply(elem, argList)\n      .then(function(value) {\n      // Chrome makes some values unprintable by including a bogus .toString property.\n        if (value && typeof value.toString !== \"function\") { delete value.toString; }\n        return value;\n      }, function(err) {\n        throw err;\n      });\n    return result;\n  }\n  const runThisMethod = stackWrapFunc(runMethod.bind(null, this, methodName));\n  WebdriverJQ.prototype[methodName] = function() {\n    const elem = this.elem();\n    const result = runThisMethod(elem, ...arguments);\n    if (WebElementMethodNames[methodName]) {\n      // If chainable, create a new WebdriverJQ object (that waits for the result).\n      return this._chain(() => elem, result);\n    } else {\n      // If not a chainable method, then we are done.\n      return result;\n    }\n  };\n});\n\n\n/**\n * Helper for chaining. Creates a new WebdriverJQ instance from the current one for the given\n * element, but which resolves only when the given promise resolves.\n */\nWebdriverJQ.prototype._chain = function(elemFn, optPromise) {\n  const getElemAndUpdateDesc = () => {\n    const elem = elemFn();\n    // Let the chained object start with the previous object's description, but once we have\n    // resolved the element, update it with the resolved element.\n    chainable._selectorDesc = this._selectorDesc + \" [pending]\";\n    elem.then(function(resolvedElem) {\n      chainable._selectorDesc = resolvedElem.toString();\n    }, function(err) {});\n    return elem;\n  };\n  var chainable = new WebdriverJQ(this.$,\n    optPromise ? optPromise.then(getElemAndUpdateDesc) : getElemAndUpdateDesc());\n\n  return chainable;\n};\n\n\n/**\n * Return a friendly string representation of this WebdriverJQ instance.\n * E.g. $('.foo').next() will be represented as \"$('.foo').next()\".\n */\nWebdriverJQ.prototype.toString = function() {\n  var sel = this._selectorDesc;\n  if (typeof sel === \"string\") {\n    sel = \"'\" + sel.replace(/'/g, \"\\\\'\")  + \"'\";\n  } else {\n    sel = sel.toString();\n  }\n  var desc = \"$(\" + sel + \")\";\n  desc += this._callChain.map(function(methodCallInfo) {\n    var method = methodCallInfo[0], args = util.inspect(methodCallInfo.slice(1));\n    return \".\" + method + \"(\" + args.slice(1, args.length - 1).trim() + \")\";\n  }).join(\"\");\n  return desc;\n};\n\n\n/**\n * Returns a copy of the WebdriverJQ object.\n */\nWebdriverJQ.prototype.clone = function() {\n  var newObj = new WebdriverJQ(this.$, this.selector);\n  newObj._selectorDesc = this._selectorDesc;\n  newObj._callChain = this._callChain.slice(0);\n  return newObj;\n};\n\n\n/**\n * Convert the matched elements to an array and apply all further chained calls to each elemet of\n * the array separately, so that the result will be an array.\n *\n * E.g. $(\".foo\").array().text() will return an array of text for each of the elements matching\n * \".foo\", and $(\".foo\").array().height() will return an array of heights (unlike\n * $(\".foo\").height() which would return the height of the first matching element).\n */\nWebdriverJQ.prototype.array = function() {\n  this._callChain.push([\"array\"]);\n  return this;\n};\n\n\n/**\n * Make the call to the browser, returning a promise for the values (typically an array) returned\n * by the browser.\n */\nWebdriverJQ.prototype.resolve = function() {\n  var self = this;\n  if (isPromise(this.selector)) {\n    // Update our selector description once we know what it resolves to.\n    this.selector.then(function(resolvedSelector) {\n      self._selectorDesc = resolvedSelector.toString();\n    }, function(err) {});\n\n    if (this._callChain.length === 0) {\n      // If the selector is a promise and there are no chained calls, there is no need to execute\n      // anything, we just need for the promise to resolve.\n      return this.selector.then(function(value) {\n        return Array.isArray(value) ? value : [value];\n      });\n    }\n  }\n\n  return executeChain(this.selector, this._callChain);\n};\n\n\n/**\n * Make a call to the browser now, expecting a single element returned, and returning\n * WebElementPromise for that element. If no elements match, the promise will be rejected.\n */\nWebdriverJQ.prototype.elem = function() {\n  const doElem = stackWrapFunc(() => {\n    var self = this;\n    // TODO: we could limit results to a single value for efficiency.\n    var result = this.resolve().then(function(elems) {\n      if (!elems[0]) { throw new Error(self + \" matched no element\"); }\n      return elems[0];\n    });\n    return result;\n  });\n  return new WebElementPromise(driver, doElem());\n};\n\n\n/**\n * Check if the element is considered stale by WebDriver. An element is considered stale once it\n * is removed from the DOM, or a new page has loaded.\n */\nWebdriverJQ.prototype.isStale = function() {\n  return this.getTagName().then(function() { return false; }, function(err) {\n    if (err instanceof error.StaleElementReferenceError) {\n      return true;\n    }\n    throw err;\n  });\n};\n\n/**\n * Helper that allows a WebdriverJQ to act as a promise, but really just forwarding calls to\n * this.resolve().then(...)\n */\nWebdriverJQ.prototype.then = function(success, failure) {\n  // In selenium-webdriver 2.46, it was important not to call this.resolve().then(...) directly,\n  // but to start a new promise chain, to avoid a deadlock in ControlFlow. In selenium-webdriver\n  // 2.48, starting a new promise chain is wrong since the webdriver doesn't wait for the new\n  // promise chain on errors, and fails without a chance to catch them.\n  return this.resolve().then(success, failure);\n};\n\n// webdriver.promise.Thenable.addImplementation(WebdriverJQ);\n\n\n/**\n * Wait for a condition, represented by func(elem) returning true. The function may use asserts to\n * indicate that the condition isn't met yet. E.g.\n *\n *    $(...).wait().click()   // Wait for a matching element to be present, then click it.\n *    $(...).wait(assert.isPresent).click()   // Equivalent to the previous line.\n *    $(...).wait(assert.isDisplayed)         // Wait for the matching element to be displayed.\n *    $(...).wait(assert.isDisplayed, false)  // Wait for the element to NOT be displayed.\n *    $(...).wait(assert.hasClass, 'foo')     // Wait for the element to have class 'foo'\n *    $(...).wait(assert.hasClass, 'foo', false)  // Wait for the element to NOT have class 'foo'\n *\n * @param [Number] optTimeoutMs: First numerical argument is interpreted as a timeout in seconds.\n *    Default is 10. You may pass in a longer timeout, but infinite wait isn't supported.\n * @param [Function] func: Optional condition function called as func(wjq, args...), where `wjq`\n *    is a WebdriverJQ instance. If a string is given, then `wjq[func](args...)` is called\n *    instead. If omitted, wait until the selector matches at least one element.\n *    The function must not return undefined, but may throw chai.AssertionError.\n * @param [Objects] args...: Optional additional arguments to pass to func.\n * @returns WebdriverJQ, which may be chained further.\n */\nWebdriverJQ.prototype.waitCore = function(chained, optTimeoutSec, func, ...extraArgs) {\n  var timeoutMs;\n  if (typeof optTimeoutSec === \"number\") {\n    timeoutMs = optTimeoutSec * 1000;\n    if (arguments.length === 2) { func = null; }\n  } else {\n    timeoutMs = 10000;\n    extraArgs.unshift(func);\n    func = (arguments.length === 1) ? null : optTimeoutSec;\n  }\n  if (_.isUndefined(func)) {\n    var failed = Promise.reject(\n      new Error(\"WebdriverJQ: wait called with undefined condition\"));\n    return this._chain(() => failed);\n  }\n  func = func || \"isPresent\";\n\n  var self = this;\n  async function conditionFunc() {\n    const result = await (typeof func === \"string\" ?\n      self[func].apply(self, extraArgs) :\n      func.apply(null, [self].concat(extraArgs)));\n    return result === undefined ? true : result;\n  }\n  var waitPromise = waitImpl(timeoutMs, conditionFunc);\n  return chained ? this._chain(() => this.resolve(), waitPromise) :\n    waitPromise;\n};\n\nWebdriverJQ.prototype.wait = function(...args) {\n  return this.waitCore(true, ...args);\n};\n\nWebdriverJQ.prototype.waitDrop = function(...args) {\n  return this.waitCore(false, ...args);\n};\n\n\n/**\n * Send keyboard keys to the element as if typed by the user. This allows the use of arrays as\n * arguments to scope modifier keys. The method allows chaining.\n */\nWebdriverJQ.prototype.sendKeys = function(varKeys) {\n  var keys = processKeys(Array.prototype.slice.call(arguments, 0));\n  var elem = this.elem();\n  return this._chain(() => elem, elem.sendKeys.apply(elem, keys));\n};\n\n/**\n * Replaces the value in a text <input> by sending the keys to select all text, type\n * the given string, and hit Enter.\n */\nWebdriverJQ.prototype.sendNewText = function(string) {\n  return this.sendKeys(this.$.SELECT_ALL || driverCompanion.$.SELECT_ALL,\n    string,\n    this.$.ENTER || driverCompanion.$.ENTER);\n};\n\n/**\n * Transform the given array of keys to prepare for sending to the browser:\n * (1) If an argument is an array, its elements are grouped using webdriver.Key.chord()\n *     (in other words, modifier keys present in the array are released after the array).\n * (2) Transforms certain keys to work around a selenium bug where they don't get processed\n *     properly.\n */\nfunction processKeys(keyArray) {\n  return keyArray.map(function(arg) {\n    if (Array.isArray(arg)) {\n      arg = driver.Key.chord.apply(driver.Key, arg);\n    }\n    return arg;\n  });\n}\n\n//----------------------------------------------------------------------\n// Enhancements to webdriver's own objects.\n//----------------------------------------------------------------------\nWebElement.prototype.toString = function() {\n  if (this._description) {\n    return \"<\" + this._description + \">\";\n  } else {\n    return \"<WebElement>\";\n  }\n};\n\n//----------------------------------------------------------------------\n// \"Client\"-side code.\n//----------------------------------------------------------------------\n\n// Run a command chain. Basically just find the initial element, and\n// apply methods to it. We try to match quirks of old system, which\n// are a bit hard to explain, and can't easily be matched exactly -\n// but well enough to cover a lot of test code it seems (and the\n// rest can just be rewritten).\nasync function executeChain(selector, callChain) {\n  const cc = callChain.map(c => c[0]);\n  let result = selector;\n  if (typeof selector === \"string\") {\n    result = await findOldTimey(driver, selector,\n      cc.includes(\"array\") ||\n                                cc.includes(\"length\") ||\n                                cc.includes(\"toArray\") ||\n                                cc.includes(\"eq\") ||\n                                cc.includes(\"last\"));\n  }\n  result = await applyCallChain(callChain, result);\n  if (result instanceof WebElement) {\n    result = [result];\n  }\n  return result;\n}\n\nasync function applyCallChain(callChain, value) {\n  value = await value;\n  for (var i = 0; i < callChain.length; i++) {\n    var method = callChain[i][0], args = callChain[i].slice(1).map(translateTestId);\n    if (method === \"toArray\") {\n      return Promise.all(value);\n    } else if (method === \"array\") {\n      return Promise.all(value.map(applyCallChain.bind(null, callChain.slice(i + 1))));\n    } else if (method === \"last\") {\n      return applyCallChain(callChain.slice(i + 1), value[value.length - 1]);\n    } else if (method === \"eq\") {\n      const idx = args[0];\n      return applyCallChain(callChain.slice(i + 1), value[idx]);\n    } else if (method === \"length\") {\n      return value.length;\n    } else {\n      if (!value[method] && value[0]?.[method]) {\n        value = value[0];\n      }\n      value = await value[method].apply(value, args);\n    }\n  }\n  return value;\n}\n\nfunction translateTestId(selector) {\n  return (typeof selector === \"string\" ?\n    selector.replace(/\\$(\\w+)/, '[data-test-id=\"$1\"]', \"g\") :\n    selector);\n}\n\nexport function findOldTimey(obj, key, multiple, multipleOptions) {\n  key = translateTestId(key);\n  const contains = key.split(\":contains\");\n  if (contains.length === 2) {\n    const content = contains[1].replace(/[\"'()]/g, \"\");\n    return obj.findContent(contains[0], content);\n  }\n  if (multiple) {\n    return obj.findAll(key, multipleOptions);\n  }\n  return obj.find(key);\n}\n\n// Similar to gu.waitToPass (copied to simplify import structure).\nexport async function waitImpl(timeoutMs, conditionFunc) {\n  try {\n    await driver.wait(async () => {\n      try {\n        return await conditionFunc();\n      } catch (e) {\n        return false;\n      }\n    }, timeoutMs);\n  } catch (e) {\n    await conditionFunc();\n  }\n}\n\nfunction isPromise(obj) {\n  if (typeof obj !== \"object\") {\n    return false;\n  }\n  if (typeof obj[\"then\"] !== \"function\") {\n    return false;\n  }\n  return true;\n}\n"
  },
  {
    "path": "test/nbrowser/webdriverjq.ntest.js",
    "content": "import { assert, driver } from \"mocha-webdriver\";\nimport { $, gu, server, test } from \"test/nbrowser/gristUtil-nbrowser\";\n\n/**\n * Not much of the fancy list support of webdriverjq has been supported.\n * Luckily not many of the tests needed it, and the parts that did have\n * been rewritten. So most of this test is turned off, and is kept just\n * for reference purposes.\n */\n\ndescribe(\"webdriverjq.ntest\", function() {\n  test.setupTestSuite(this);\n\n  before(async function() {\n    await gu.supportOldTimeyTestCode();\n    await driver.get(server.getHost() + \"/v/gtag/testWebdriverJQuery.html\");\n  });\n\n  it(\"should support basic jquery syntax\", async function() {\n    // toString should work properly.\n    assert.equal(\"\" + $(\"input[type='button']\"), \"$('input[type=\\\\'button\\\\']')\");\n\n    assert.equal(await $(\".foo\").trimmedText(), \"Hello world\");\n    assert.equal((await $(\".bar\").array()).length, 2);\n    assert.lengthOf(await $(\".bar\").toArray(), 2);\n    assert(await $(\".foo\").hasClass(\"bar\"));\n    assert.equal(await $(\".foo.bar .baz\").parent().getAttribute(\"className\"), \"foo bar\");\n    // Can't quite match old property-over-list behavior.\n    // assert.equal(await $(\".foo.bar\").find(\".baz\").parent().prop('className'), \"foo bar\");\n    // Parent behavior is not the same as it was.\n    // assert.equal(await $(\".baz\").parent().length, 2);\n    assert.equal(await $(\".baz input\").val(), \"Go\");\n    // There are two bazs, in new style need to specify which.\n    assert.equal(await $(\".baz\").eq(1).find('input[type=\"button\"]').val(), \"Go\");\n    await $(\"input[type='button']\").click();\n    assert.equal(await $(\".baz input\").val(), \"Goo\");\n    await $(\".baz input\").val(\"Go\").resolve();  // Revert the value to avoid affecting other test cases.\n    // toggleClass not supported anymore.\n    // assert.notInclude(await $(\".foo\").toggleClass(\"bar\").classList(), \"bar\");\n    // assert.include(await $(\".foo\").toggleClass(\"bar\").classList(), \"bar\");\n  });\n\n  it(\"should support .array()\", async function() {\n    assert.deepEqual(await $(\".bar\").getAttribute(\"className\"), \"foo bar\");\n    assert.deepEqual(await $(\".bar\").array().getAttribute(\"className\"), [\"foo bar\", \"bar\"]);\n    assert.deepEqual(await $(\".bar\").array().trimmedText(), [\"Hello world\", \"Good bye\"]);\n    assert.deepEqual(await $(\".bar\").eq(0).trimmedText(), \"Hello world\");\n    assert.deepEqual(await $(\".bar\").eq(1).trimmedText(), \"Good bye\");\n  });\n\n  it(\"should support WebElement methods and chaining\", async function() {\n    assert.equal(await $(\".baz\").getText(), \"Hello world\");\n    assert.equal(await $(\".baz\").getAttribute(\"class\"), \"baz\");\n    await $(\".baz\").click();\n\n    // Cannot chain with clicks anymore\n    // assert.equal($(\".baz\").click().getText(), \"Hello world\");\n    // assert.equal($(\".baz\").click().trimmedText(), \"Hello world\");\n    // assert.equal($(\".baz\").click().parent().prop(\"className\"), \"foo bar\");\n    // assert.equal($(\".baz\").click().parent().isDisplayed(), true);\n\n    // Errors are different.\n    // assert.equal(await $(\".nonexistent1\").text(), \"\");\n    await assert.isRejected($(\".nonexistent2\").getText(), /no such element/);\n    await assert.isRejected($(\".nonexistent3\").click(), /no such element/);\n    await assert.isRejected($(\".nonexistent4\").isDisplayed(), /no such element/);\n    await assert.isRejected($(\".nonexistent5\").click().parent().isDisplayed(), /no such element/);\n\n    await assert.isRejected($(\".foo\").click().find(\".bar\").elem(), /no such element/);\n\n    // cannot chain click anymore\n    // assert($(\".foo\").click().find(\".baz\").elem());\n    // assert.lengthOf($(\".foo\").click().find(\".baz\"), 1);\n    // assert.lengthOf($(\".foo\").click().find(\".bar\"), 0);\n    assert.lengthOf(await $(\".bar\").array(), 2);\n    await $(\".bar\").array().resolve().then(function(elems) { assert.lengthOf(elems, 2); });\n  });\n\n  function expectFailure(promise, regexp) {\n    throw new Error(\"not ported\");\n    /*\n    var stack = stacktrace.captureStackTrace(\"\", expectFailure);\n    return stacktrace.resolveWithStack(stack, promise.then(function(value) {\n      throw new Error(\"Expected failure but got \" + value);\n    }, function(err) {\n      assert.match(err.message, regexp);\n      // Also make sure that our filename is present in the stack trace.\n      assert.match(err.stack, /webdriverjq.test.js:\\d+/);\n    }));\n    */\n  }\n\n  // Custom asserts work, but error messages are different and not\n  // very interesting to maintain.\n  it.skip(\"should work with our custom asserts\", async function() {\n    await assert.hasClass($(\".foo\"), \"bar\");\n    await expectFailure(assert.hasClass($(\".foo\"), \"bar\", false), /hasClass/);\n\n    await assert.hasClass($(\".foo\"), \"xbar\", false);\n    await expectFailure(assert.hasClass($(\".foo\"), \"xbar\"), /hasClass/);\n\n    assert.isEnabled($(\"#btn\"));\n    expectFailure(assert.isEnabled($(\"#btn\"), false), /isEnabled/);\n\n    assert.isEnabled($(\"#btn\").prop(\"disabled\", true), false);\n    expectFailure(assert.isEnabled($(\"#btn\"), true), /isEnabled/);\n\n    assert.isEnabled($(\"#btn\").prop(\"disabled\", false), true);\n\n    assert.isPresent($(\"#btn\"));\n    expectFailure(assert.isPresent($(\"#btnx\")), /isPresent/);\n\n    assert.isPresent($(\"#btnx\"), false);\n    expectFailure(assert.isPresent($(\"#btn\"), false), /isPresent/);\n\n    assert.isDisplayed($(\"#btn\"));\n    expectFailure(assert.isDisplayed($(\"#btn\"), false), /isDisplayed/);\n\n    assert.isDisplayed($(\".baz\").css(\"display\", \"none\").find(\"#btn\"), false);\n    expectFailure(assert.isDisplayed($(\"#btn\")), /isDisplayed/);\n    expectFailure(assert.ok($(\"#btn\").click()), /not interactable/);\n\n    assert.isDisplayed($(\".baz\").css(\"display\", \"\").find(\"#btn\"), true);\n    expectFailure(assert.isDisplayed($(\"#btn\"), false), /isDisplayed/);\n  });\n\n  it.skip(\"should report good errors\", async function() {\n    await $(\".baz\").css(\"display\", \"none\").resolve();\n    expectFailure(assert.ok($(\"#btn\").click()), /not interactable/);\n    await $(\".baz\").css(\"display\", \"\").resolve();\n    assert.ok($(\"#btn\").click());\n    assert.equal($(\"#btn\").val(), \"Goo\");\n    await $(\"#btn\").val(\"Go\").resolve();  // Revert the value to avoid affecting other test cases.\n\n    expectFailure($(\".nonexistent1\").click(), /nonexistent1.* matched no element/);\n    expectFailure(assert.ok($(\".nonexistent2\").click()), /matched no element/);\n    expectFailure($(\".nonexistent3\").getText(), /matched no element/);\n    expectFailure(assert.ok($(\".nonexistent5\").elem()), /matched no element/);\n  });\n\n  // addClass not supported anymore.\n  it.skip(\"should wait for various conditions\", async function() {\n    assert.equal(await $(\".foo\").wait().trimmedText(), \"Hello world\");\n\n    // Test waits for functions of an existing element.\n    await driver.executeScript(function() {\n      setTimeout(function() { $(\".foo .baz\").addClass(\"later1\"); }, 300);\n      setTimeout(function() { $(\".foo .baz\").addClass(\"later2\"); }, 700);\n    });\n    assert.deepEqual(await $(\".foo .baz\").classList(), [\"baz\"]);\n    await assert.hasClass($(\".foo .baz\"), \"later2\", false);\n    assert.deepEqual(await $(\".foo .baz\").wait(assert.hasClass, \"later1\").classList(),\n      [\"baz\", \"later1\"]);\n    assert.deepEqual(await $(\".foo .baz\").wait(\"hasClass\", \"later2\").classList(),\n      [\"baz\", \"later1\", \"later2\"]);\n    assert.throws($(\".foo .baz\").wait(0.05, \"hasClass\", \"never\").classList(),\n      /Wait timed out/);\n\n    // Test waits for the presence of an element.\n    $.driver.executeScript(async function() {\n      await $(\".foo .baz\").removeClass(\"later1 later2\");\n      setTimeout(function() { $(\".foo .baz\").addClass(\"later1\"); }, 200);\n      setTimeout(function() { $(\".foo .baz\").addClass(\"later2\"); }, 500);\n      setTimeout(function() { $(\".foo .baz\").removeClass(\"later1 later2\"); }, 1000);\n    });\n    assert.lengthOf($(\".later1, .later2\"), 0);\n    assert.throws($(\".later1\").wait(0.05, \"isPresent\").classList(), /Wait timed out/);\n    assert.deepEqual($(\".later1\").wait().classList(), [\"baz\", \"later1\"]);\n    assert.deepEqual($(\".later2\").wait(1, assert.isPresent).classList(),\n      [\"baz\", \"later1\", \"later2\"]);\n\n    // The element is already present, so this should be true.\n    assert.equal($(\".later1\").wait(0.01, assert.isPresent, true).isPresent(), true);\n    // The following is equivalent to WebDrivers's until.stalenessOf.\n    assert.equal($(\".later1\").wait(1, assert.isPresent, false).isPresent(), false);\n\n    // Absent argument, or null, are OK, and mean \"isPresent\", but 'undefined' as an argument is a\n    // liability, since it would be silent and wrong on misspellings. So we catch it.\n    assert.equal($(\".foo\").wait(null).isPresent(), true);\n    assert.throws($(\".foo\").wait(assert.misspelled).isPresent(),\n      /called with undefined condition/);\n\n    // We should be able to chain beyond .wait() with actions and more.\n    $.driver.executeScript(function() {\n      setTimeout(function() { $(\"#btn\").addClass(\"later1\"); }, 200);\n      setTimeout(function() { $(\"#btn\").removeClass(\"later1\"); }, 800);\n    });\n    await $(\"#btn.later1\").wait().click();\n    assert.equal($(\"#btn\").val(), \"Goo\");\n    await $(\"#btn\").wait(assert.hasClass, \"later1\", false).click();\n    assert.equal($(\"#btn\").val(), \"Gooo\");\n    await $(\"#btn\").val(\"Go\").resolve();  // Revert the value to avoid affecting other test cases.\n  });\n\n  // behavior around lists changed.\n  it.skip(\"should support complicated promises\", async function() {\n    var elemA = $(\".foo .baz\").resolve().then(function(elem) {\n      return $(elem).parent();\n    }).then(function(elem) {\n      assert.deepEqual($(elem).classList(), [\"foo\", \"bar\"]);\n      return elem;\n    });\n\n    assert.deepEqual($(elemA).classList(), [\"foo\", \"bar\"]);\n    assert.isDisplayed($(elemA));\n    assert.isDisplayed($(elemA).find(\".baz\"));\n    assert.throws($(elemA).find(\".nonexistent\").click(), /<div\\.foo\\.bar.*matched no element/);\n\n    assert.deepEqual($(\n      await $(\".foo .baz\").resolve().then(function(elem) {\n        return $(elem).parent();\n      })\n    ).classList(), [\"foo\", \"bar\"]);\n  });\n});\n"
  },
  {
    "path": "test/nbrowser_with_stubs/CreateTeamSite.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { cleanupExtraWindows, setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"Create Team Site\", function() {\n  this.timeout(20000);\n  cleanupExtraWindows();\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n  });\n\n  async function openCreateTeamModal() {\n    await driver.findWait(\".test-dm-org\", 500).click();\n    assert.equal(await driver.find(\".test-site-switcher-create-new-site\").isPresent(), true);\n    await driver.find(\".test-site-switcher-create-new-site\").click();\n  }\n\n  async function fillCreateTeamModalInputs(name: string, domain: string) {\n    await driver.findWait(\".test-create-team-name\", 500).click();\n    await gu.sendKeys(name);\n    await gu.sendKeys(Key.TAB);\n    await gu.sendKeys(domain);\n  }\n\n  async function goToNewTeamSite() {\n    await driver.findWait(\".test-create-team-confirmation-link\", 500).click();\n  }\n\n  async function getTeamSiteName() {\n    return await driver.findWait(\".test-dm-orgname\", 500).getText();\n  }\n\n  it(\"should work using the createTeamModal\", async () => {\n    assert.equal(await driver.find(\".test-dm-org\").isPresent(), true);\n    const teamSiteName = await getTeamSiteName();\n    assert.equal(teamSiteName, \"Test Grist\");\n    await openCreateTeamModal();\n    assert.equal(await driver.find(\".test-create-team-creation-title\").isPresent(), true);\n\n    await fillCreateTeamModalInputs(\"Test Create Team Site\", \"testteamsite\");\n    await gu.sendKeys(Key.ENTER);\n    assert.equal(await driver.findWait(\".test-create-team-confirmation\", 500).isPresent(), true);\n    await goToNewTeamSite();\n    const newTeamSiteName = await getTeamSiteName();\n    assert.equal(newTeamSiteName, \"Test Create Team Site\");\n  });\n\n  it(\"should work only with unique domain\", async () => {\n    await openCreateTeamModal();\n    await fillCreateTeamModalInputs(\"Test Create Team Site 1\", \"same-domain\");\n    await gu.sendKeys(Key.ENTER);\n    await goToNewTeamSite();\n    await openCreateTeamModal();\n    await fillCreateTeamModalInputs(\"Test Create Team Site 2\", \"same-domain\");\n    await gu.sendKeys(Key.ENTER);\n    const errorMessage = await driver.findWait(\".test-notifier-toast-wrapper \", 500).getText();\n    assert.include(errorMessage, \"Domain already in use\");\n  });\n});\n"
  },
  {
    "path": "test/nbrowser_with_stubs/CustomWidgets.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { setupTestSuite } from \"test/nbrowser/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"CustomWidgets\", function() {\n  this.timeout(20000);\n  const cleanup = setupTestSuite();\n\n  before(async function() {\n    const session = await gu.session().teamSite.login();\n    await session.tempNewDoc(cleanup);\n  });\n\n  it(\"uses the Grist Labs widget repository by default\", async function() {\n    await gu.addNewSection(\"Custom\", \"Table1\");\n    const widgetNames = new Set(await driver.findAll(\".test-custom-widget-gallery-widget-name\", e => e.getText()));\n\n    assert.isTrue(widgetNames.has(\"Custom URL\"));\n    assert.isTrue(widgetNames.has(\"Calendar\"));\n    assert.isTrue(widgetNames.has(\"Notepad\"));\n    assert.isTrue(widgetNames.has(\"Purchase orders\"));\n  });\n});\n"
  },
  {
    "path": "test/projects/AccountWidget.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key, stackWrapFunc } from \"mocha-webdriver\";\n\ndescribe(\"AccountWidget\", function() {\n  this.timeout(60000);      // Set a longer default timeout.\n  setupTestSuite();\n\n  const testCase = stackWrapFunc(async function(org: string, selectedOrgs: boolean[]) {\n    // See the icon and open menu when loading plain DocMenu page.\n    await driver.get(`${server.getHost()}/DocMenu#org=${org}&user=santa`);\n\n    // The sign-in buttons shouldn't be shown on top.\n    assert.equal(await driver.find(\".test-user-sign-in\").isPresent(), false);\n    assert.equal(await driver.find(\".test-user-sign-up\").isPresent(), false);\n\n    await driver.find(\".test-user-icon\").click();   // open the menu\n    assert.equal(await driver.findWait(\".test-usermenu-email\", 100).getText(), \"santa@getgrist.com\");\n    assert.deepEqual(await driver.findAll(\".test-site-switcher-org-tick\", x => x.isDisplayed()),\n      selectedOrgs);\n    await driver.sendKeys(Key.ESCAPE);              // close the menu\n\n    // With an anonymous user, should see \"Sign In\" and \"Sign Up\", but NOT a user icon.\n    await driver.get(`${server.getHost()}/DocMenu#org=${org}&user=anon`);\n    assert.equal(await driver.find(\".test-user-sign-in\").getText(), \"Sign in\");\n    assert.equal(await driver.find(\".test-user-sign-up\").getText(), \"Sign up\");\n    assert.equal(await driver.find(\".test-user-icon\").isPresent(), false);\n\n    // Same with a null user.\n    await driver.get(`${server.getHost()}/DocMenu#org=${org}&user=null`);\n    assert.equal(await driver.find(\".test-user-sign-in\").getText(), \"Sign in\");\n    assert.equal(await driver.find(\".test-user-sign-up\").getText(), \"Sign up\");\n    assert.equal(await driver.find(\".test-user-icon\").isPresent(), false);\n  });\n\n  it(\"should show user icon and open menu when logged in\", async function() {\n    // The booleans are the expected selection status for orgs listed in user menu.\n    await testCase(\"chase\", [false, false, true, false, false]);\n  });\n\n  it(\"should show user icon in the same way for inaccessible orgs\", async function() {\n    // The booleans are the expected selection status for orgs listed in user menu.\n    await testCase(\"nonexistent\", [false, false, false, false, false]);\n  });\n});\n"
  },
  {
    "path": "test/projects/ApiKey.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport * as bluebird from \"bluebird\";\nimport { assert, driver } from \"mocha-webdriver\";\n\nconst delay = () => bluebird.delay(350);\n\ndescribe(\"ApiKeyWidget\", function() {\n  setupTestSuite();\n\n  before(async function() {\n    this.timeout(60000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/ApiKey`);\n  });\n\n  it(\"should show only the create button when the api key has not been created\", async function() {\n    assert.deepEqual(await driver.findAll(\".test-apikey-key\", e => e.getText()), []);\n    assert.deepEqual(await driver.findAll(\".test-apikey-container button\", e => e.getText()), [\"Create\"]);\n    assert.deepEqual(await driver.findAll(\".test-apikey-description\", e => e.getText()), [\n      \"By generating an API key, you will be able to make API calls for your own account.\"]);\n  });\n\n  it(\"click `create` button should call onCreate()\", async function() {\n    await driver.find(\".test-apikey-create\").click();\n    // button should be disabled\n    assert.deepEqual(await driver.findAll(\".test-apikey-container button\",\n      e => e.getAttribute(\"disabled\")), [\"true\"]);\n    await delay();\n    const apiKey = await driver.find(\".test-apikey-key\");\n    // should show Delete button the new api key\n    assert.equal(await apiKey.value(), \"e03ab513535137a7ec60978b40c9a896db6d8706\");\n    // should have type 'password' by default, causing the key to be hidden\n    assert.equal(await apiKey.getAttribute(\"type\"), \"password\");\n    assert.deepEqual(await driver.findAll(\".test-apikey-container button\",\n      e => e.getText()), [\"Remove\"]);\n    assert.deepEqual(await driver.findAll(\".test-apikey-container button\",\n      e => e.getAttribute(\"disabled\")), [null as any]);\n    assert.deepEqual(await driver.findAll(\".test-apikey-description\", e => e.getText()), [\n      `This API key can be used to access your account via the API. Don’t share your API key with anyone.`,\n    ]);\n  });\n\n  it(\"should show key when selected and hide when unselected\", async function() {\n    // Click the key, and check that the type is now 'text', causing it to be shown.\n    const apiKey = await driver.find(\".test-apikey-key\");\n    await apiKey.click();\n    assert.equal(await apiKey.getAttribute(\"type\"), \"text\");\n\n    // Click it again, just to make sure it's still shown on repeated clicks.\n    await apiKey.click();\n    assert.equal(await apiKey.getAttribute(\"type\"), \"text\");\n\n    // Cause the selection to be lost by clicking the description.\n    await driver.find(\".test-apikey-description\").click();\n\n    // Check that the key now has type 'password', and is hidden again.\n    assert.equal(await apiKey.getAttribute(\"type\"), \"password\");\n  });\n\n  it(\"click `delete` button should call onDelete()\", async function() {\n    await driver.find(\".test-apikey-delete\").click();\n\n    // should show confirmation message\n    assert.isTrue(await driver.find(\".test-modal-dialog\").isPresent());\n    assert.match(await driver.find(\".test-modal-dialog\").getText(), /Do you still want to delete?/);\n\n    // cancel should removes warning\n    await driver.find(\".test-modal-cancel\").click();\n    assert.deepEqual(await driver.findAll(\".test-apikey-warning\", e => e.getText()), []);\n\n    // let's Delete for good\n    await driver.find(\".test-apikey-delete\").click();\n    await driver.find(\".test-modal-confirm\").click();\n\n    // buttons should be disabled\n    assert.deepEqual(await driver.findAll(\".test-apikey-container button\",\n      e => e.getAttribute(\"disabled\")), [\"true\"]);\n    await delay();\n    // should show Create button and no api key\n    assert.deepEqual(await driver.findAll(\".test-apikey-key\", e => e.getText()), []);\n    assert.deepEqual(await driver.findAll(\".test-apikey-container button\", e => e.getText()), [\"Create\"]);\n    assert.deepEqual(await driver.findAll(\".test-apikey-container button\",\n      e => e.getAttribute(\"disabled\")), [null as any]);\n    assert.deepEqual(await driver.findAll(\".test-apikey-description\", e => e.getText()), [\n      \"By generating an API key, you will be able to make API calls for your own account.\"]);\n  });\n});\n"
  },
  {
    "path": "test/projects/ColorSelect.ts",
    "content": "import { swatches } from \"app/client/ui2018/ColorPalette\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { addToRepl, assert, driver, Key, stackWrapFunc, WebElement } from \"mocha-webdriver\";\n\nconst black = \"#000000\";\nconst white = \"#FFFFFF\";\nconst pink = \"#FF00FF\";\nconst red  = \"#FF0000\";\n\nconst bold = 1 << 3;\nconst underline = 1 << 2;\nconst italic = 1 << 1;\nconst strike = 1;\n\nconst rgbToHex = (color: string) => gu.rgbToHex(color).toUpperCase();\n\ndescribe(\"ColorSelect\", function() {\n  setupTestSuite();\n  addToRepl(\"setColor\", setColor);\n  addToRepl(\"rgbToHex\", rgbToHex);\n  this.timeout(20000);\n  gu.bigScreen();\n\n  before(async function() {\n    this.timeout(60000);\n    await driver.get(`${server.getHost()}/ColorSelect`);\n  });\n\n  beforeEach(async function() {\n    await driver.findWait(\".test-reset\", 100).click();\n  });\n\n  const checkSelectedColor = stackWrapFunc(async function(fill: string | null, text: string | null) {\n    // check Text selected color\n    assert.deepEqual(await driver.findAll(\".test-text-palette [class*=-selected]\", async e => (\n      rgbToHex(await e.getCssValue(\"background-color\"))\n    )), text ? [text] : []);\n\n    // check Fill selected color\n    assert.deepEqual(await driver.findAll(\".test-fill-palette [class*=-selected]\", async e => (\n      rgbToHex(await e.getCssValue(\"background-color\"))\n    )), fill ? [fill] : []);\n  });\n\n  const checkCurrentColor = stackWrapFunc(async function(fill: string, text: string) {\n    assert.equal(rgbToHex(await driver.find(\".test-client-cell\").getCssValue(\"color\")), text);\n    assert.equal(rgbToHex(await driver.find(\".test-client-cell\").getCssValue(\"background-color\")), fill);\n  });\n\n  async function checkCurrentOption(option: number) {\n    const isBold = (await driver.findAll(\".test-client-cell.font-bold\")).length === 1;\n    const isUnderline = (await driver.findAll(\".test-client-cell.font-underline\")).length === 1;\n    const isItalic = (await driver.findAll(\".test-client-cell.font-italic\")).length === 1;\n    const isStrikeThrough = (await driver.findAll(\".test-client-cell.font-strikethrough\")).length === 1;\n    const current =\n      (isBold ? bold : 0) |\n      (isUnderline ? underline : 0) |\n      (isItalic ? italic : 0) |\n      (isStrikeThrough ? strike : 0);\n    return assert.equal(current, option);\n  }\n\n  async function checkSelectedOption(option: number) {\n    const isBold = (await driver.findAll(\".test-font-option-FontBold[class*=-selected]\")).length === 1;\n    const isUnderline = (await driver.findAll(\".test-font-option-FontUnderline[class*=-selected]\")).length === 1;\n    const isItalic = (await driver.findAll(\".test-font-option-FontItalic[class*=-selected]\")).length === 1;\n    const isStrikeThrough = (await driver.findAll(\".test-font-option-FontStrikethrough[class*=-selected]\"))\n      .length === 1;\n    const selected =\n      (isBold ? bold : 0) |\n      (isUnderline ? underline : 0) |\n      (isItalic ? italic : 0) |\n      (isStrikeThrough ? strike : 0);\n    return assert.equal(selected, option);\n  }\n\n  async function clickApply() {\n    await driver.find(\".test-colors-save\").click();\n  }\n\n  async function clickCancel() {\n    await driver.find(\".test-colors-cancel\").click();\n  }\n\n  async function applyDisabled() {\n    assert.equal(await driver.findWait(\".test-colors-save\", 100).getAttribute(\"disabled\"), \"true\");\n  }\n\n  async function applyEnabled() {\n    assert.equal(await driver.findWait(\".test-colors-save\", 100).getAttribute(\"disabled\"), null);\n  }\n\n  it(\"should show buttons and disable apply\", async function() {\n    await driver.find(\".test-color-select\").click();\n    await driver.findWait(\".test-text-palette\", 100);\n\n    await applyDisabled();\n    await clickUnderline();\n    await applyEnabled();\n    await clickUnderline();\n    await applyDisabled();\n\n    await setColor(driver.findWait(\".test-text-input\", 100), white);\n    await applyEnabled();\n    await setColor(driver.findWait(\".test-text-input\", 100), black);\n    await applyDisabled();\n  });\n\n  it(\"should allow picking fill and text color and options\", async function() {\n    // check current color\n    await checkCurrentColor(white, black);\n    await checkCurrentOption(0);\n\n    // open picker and check selected color\n    await driver.find(\".test-color-select\").click();\n    await checkSelectedColor(white, black);\n    // and options\n    await checkSelectedOption(0);\n\n    // pick fill and text color\n    await driver.find(\".test-fill-palette div:nth-of-type(5)\").click();\n    await driver.find(\".test-text-palette div:nth-of-type(4)\").click();\n\n    // and options\n    await clickUnderline();\n    await clickStrikethrough();\n\n    // check current colors\n    await checkCurrentColor(swatches[4], swatches[3]);\n\n    // check selected color\n    await checkSelectedColor(swatches[4], swatches[3]);\n\n    // and options\n    await checkCurrentOption(underline | strike);\n    await checkSelectedOption(underline | strike);\n\n    // close picker and check a call was made\n    await driver.find(\".test-color-select\").mouseMove({ x: 300 });\n    await driver.withActions(a => a.click());\n    assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()),\n      [`Called: ${JSON.stringify(\n        { fill: swatches[4], text: swatches[3],\n          underline: true, strikethrough: true })}`]);\n  });\n\n  it(\"should allow to choose custom color\", async function() {\n    // open picker\n    await driver.find(\".test-color-select\").click();\n    await driver.findWait(\".test-text-palette\", 100);\n\n    // check selected color\n    await checkSelectedColor(white, black);\n\n    // select custom color\n    await setColor(driver.find(\".test-text-input\"), \"#FF0000\");\n\n    // check palette has no selected colors\n    await checkSelectedColor(white, null);\n\n    // check current colors\n    await checkCurrentColor(white, \"#FF0000\");\n  });\n\n  it(\"should save colors and options on `enter` and apply\", async function() {\n    // open picker\n    await driver.find(\".test-color-select\").click();\n    await driver.findWait(\".test-text-palette\", 100);\n\n    // select text color\n    await driver.find(\".test-text-palette div:nth-of-type(8)\").click();\n    await clickItalic();\n\n    // press enter\n    await driver.sendKeys(Key.ENTER);\n\n    // check logs\n    await gu.waitToPass(async () => {\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()),\n        [`Called: ${JSON.stringify({ fill: \"#FFFFFF\", text: swatches[7], italic: true })}`]);\n    });\n\n    await driver.find(\".test-reset\").click();\n    await driver.find(\".test-color-select\").click();\n    await driver.findWait(\".test-text-palette\", 100);\n\n    await driver.find(\".test-text-palette div:nth-of-type(8)\").click();\n    await clickItalic();\n    await clickApply();\n    await gu.waitToPass(async () => {\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()),\n        [`Called: ${JSON.stringify({ fill: \"#FFFFFF\", text: swatches[7], italic: true })}`]);\n    });\n  });\n\n  it(\"should not make a save call on Escape\", async function() {\n    // open picker\n    await driver.find(\".test-color-select\").click();\n    await driver.findWait(\".test-text-palette\", 100);\n\n    // select text color\n    await driver.find(\".test-text-palette div:nth-of-type(6)\").click();\n    await clickBold();\n\n    // press Escape\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check logs\n    const checkNoSave = async () => assert.deepEqual(\n      await driver.findAll(\".test-call-log li\", el => el.getText()), []);\n    await checkNoSave();\n\n    await driver.find(\".test-color-select\").click();\n    await driver.findWait(\".test-text-palette\", 100);\n\n    await clickBold();\n    await clickCancel();\n    await checkNoSave();\n  });\n\n  it(\"should revert on Escape\", async function() {\n    // prepare update form\n    await setColor(driver.find(\".test-fill-server-value input\"), \"#00FF00\");\n    await setColor(driver.find(\".test-text-server-value input\"), \"#FF0000\");\n    await setOption(driver.find(\".test-bold-server-value input\"), true);\n\n    // open picker\n    await driver.find(\".test-color-select\").click();\n    await driver.findWait(\".test-text-palette\", 100);\n\n    // check initial colors and options\n    await checkCurrentColor(white, black);\n    await checkSelectedColor(white, black);\n    await checkCurrentOption(0);\n    await checkSelectedOption(0);\n\n    // send ctrl+u\n    await driver.find(\".test-server-update\").sendKeys(Key.chord(Key.CONTROL, \"U\"));\n\n    // check colors and options changed\n    await checkCurrentColor(\"#00FF00\", \"#FF0000\");\n    await checkSelectedColor(null, null);\n    await checkCurrentOption(bold);\n    await checkSelectedOption(bold);\n\n    // pick color and option\n    await driver.find(\".test-fill-palette div:nth-of-type(5)\").click();\n    await driver.find(\".test-text-palette div:nth-of-type(4)\").click();\n    await driver.find(\".test-font-option-FontItalic\").click();\n\n    // check colors and options\n    await checkCurrentColor(swatches[4], swatches[3]);\n    await checkSelectedColor(swatches[4], swatches[3]);\n    await checkCurrentOption(bold | italic);\n    await checkSelectedOption(bold | italic);\n\n    // send ESC\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check panel is closed\n    assert.isFalse(await driver.find(\".test-text-palette\").isPresent());\n\n    // check current colors and options were reverted\n    await checkCurrentColor(\"#00FF00\", \"#FF0000\");\n    await checkCurrentOption(bold);\n  });\n\n  it(\"should revert on Escape (2)\", async function() {\n    // prepare update form\n    await setColor(driver.find(\".test-fill-server-value input\"), swatches[4]);\n    await setColor(driver.find(\".test-text-server-value input\"), swatches[3]);\n    await setOption(driver.find(\".test-bold-server-value input\"), true);\n    await setOption(driver.find(\".test-italic-server-value input\"), true);\n\n    // open picker\n    await driver.find(\".test-color-select\").click();\n    await driver.findWait(\".test-text-palette\", 100);\n\n    // check initial colors and options\n    await checkCurrentColor(white, black);\n    await checkSelectedColor(white, black);\n    await checkCurrentOption(0);\n    await checkSelectedOption(0);\n\n    // pick same colors and options as planned server update\n    await driver.find(\".test-fill-palette div:nth-of-type(5)\").click();\n    await driver.find(\".test-text-palette div:nth-of-type(4)\").click();\n    await clickBold();\n    await clickItalic();\n\n    // check colors and options changed\n    await checkCurrentColor(swatches[4], swatches[3]);\n    await checkSelectedColor(swatches[4], swatches[3]);\n    await checkCurrentOption(bold | italic);\n    await checkSelectedOption(bold | italic);\n\n    // send ctrl+u\n    await driver.find(\".test-server-update\").sendKeys(Key.chord(Key.CONTROL, \"U\"));\n\n    // check nothing changed\n    await checkCurrentColor(swatches[4], swatches[3]);\n    await checkSelectedColor(swatches[4], swatches[3]);\n    await checkCurrentOption(bold | italic);\n    await checkSelectedOption(bold | italic);\n\n    // send Escape\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check current colors and options are still the same\n    await checkCurrentColor(swatches[4], swatches[3]);\n    await checkCurrentOption(bold | italic);\n  });\n\n  it(\"should support lowercase hex color\", async function() {\n    // open picker\n    await driver.find(\".test-color-select\").click();\n    await driver.findWait(\".test-text-palette\", 100);\n\n    // set custom color in lowercase\n    const input = await driver.findWait(\".test-text-input\", 100);\n    await setColor(input, swatches[3].toLowerCase());\n\n    // check correct value was selected.\n    await checkSelectedColor(white, swatches[3]);\n  });\n\n  it(\"should truncate transparency bit\", async function() {\n    // set transparency to server value\n    await setColor(driver.find(\".test-fill-server-value input\"), \"#FF00FF00\");\n    await driver.find(\".test-server-update\").click();\n\n    // open picker\n    await driver.find(\".test-color-select\").click();\n    await driver.findWait(\".test-text-palette\", 100);\n\n    // check correct value selected\n    await checkSelectedColor(null, black);\n\n    // check correct color\n    await checkCurrentColor(\"#FF00FF\", black);\n    assert.equal(await driver.find(\".test-fill-hex\").value(), \"#FF00FF\");\n  });\n\n  describe(\"Editable hex\", async function() {\n    it(\"should allow typing in hex value\", async function() {\n      // open the picker\n      await driver.find(\".test-color-select\").click();\n      await driver.findWait(\".test-text-palette\", 100);\n\n      // click the hex value\n      await driver.findWait(\".test-text-hex\", 100).click();\n\n      await waitForSelection(\".test-text-hex\");\n\n      // start typing '#'\n      await driver.sendKeys(\"#\");\n\n      // check hex value is now '#'\n      assert.equal(await driver.find(\".test-text-hex\").value(), \"#\");\n\n      // type in '...FOO' (not a valid hex)\n      await driver.sendKeys(\"FOO\");\n      assert.equal(await driver.find(\".test-text-hex\").value(), \"#FOO\");\n\n      // press enter\n      await driver.sendKeys(Key.ENTER);\n\n      // check hex value reverted to #000000\n      assert.equal(await driver.find(\".test-text-hex\").value(), \"#000000\");\n\n      // click the hex value\n      await driver.find(\".test-text-hex\").click();\n      await waitForSelection(\".test-text-hex\");\n\n      // type in #FF00FF and press enter\n      await driver.sendKeys(pink, Key.ENTER);\n\n      // check value has changed\n      assert.equal(await driver.find(\".test-text-hex\").value(), pink);\n\n      // press enter again\n      await driver.sendKeys(Key.ENTER);\n\n      // check the picker is closed\n      assert.equal(await driver.find(\".test-text-hex\").isPresent(), false);\n\n      // check value was saved\n      await checkCurrentColor(white, pink);\n\n      // open the picker\n      await driver.find(\".test-color-select\").click();\n      await driver.findWait(\".test-text-palette\", 100);\n\n      // click the hex value\n      await driver.find(\".test-text-hex\").click();\n\n      // Wait for focus\n      await waitForSelection(\".test-text-hex\");\n\n      // type in #0000FF\n      await driver.sendKeys(red);\n\n      // check the value changed\n      assert.equal(await driver.find(\".test-text-hex\").value(), red);\n\n      // press Escape\n      await driver.sendKeys(Key.ESCAPE);\n\n      // check the value reverted\n      assert.equal(await driver.find(\".test-text-hex\").value(), pink);\n\n      // press Escape\n      await driver.sendKeys(Key.ESCAPE);\n\n      // check the picker is closed\n      assert.equal(await driver.find(\".test-text-hex\").isPresent(), false);\n\n      // check value was reverted to #FF00FF\n      await checkCurrentColor(white, pink);\n    });\n\n    it(\"should not change value on server update while typing\", async function() {\n      // prepare the update form\n      await setColor(driver.find(\".test-text-server-value input\"), red);\n\n      // open the picker\n      await driver.find(\".test-color-select\").click();\n      await driver.findWait(\".test-text-palette\", 100);\n\n      // click the hex value\n      await driver.find(\".test-text-hex\").click();\n      await waitForSelection(\".test-text-hex\");\n\n      // start typing '#FF'\n      await driver.sendKeys(\"#FF\");\n\n      // check the hex value changed\n      assert.equal(await driver.find(\".test-text-hex\").value(), \"#FF\");\n\n      // click button to update. We cannot send ctrl+U here, because it does cause picker to close\n      // here.\n      await driver.executeScript(\"triggerUpdate()\");\n\n      // check the value changed\n      await checkCurrentColor(white, red);\n\n      // check the hex value did not change (still '#FF')\n      assert.equal(await driver.find(\".test-text-hex\").value(), \"#FF\");\n    });\n  });\n});\n\nasync function setColor(colorInputEl: WebElement, color: string) {\n  await driver.executeScript(() => {\n    const el = arguments[0];\n    el.value = arguments[1];\n    const evt = document.createEvent(\"HTMLEvents\");\n    evt.initEvent(\"input\", false, true);\n    el.dispatchEvent(evt);\n  }, colorInputEl, color);\n}\n\nasync function setOption(colorInputEl: WebElement, value: boolean | undefined) {\n  await driver.executeScript(() => {\n    const el = arguments[0];\n    el.value = arguments[1];\n    const evt = document.createEvent(\"HTMLEvents\");\n    evt.initEvent(\"input\", false, true);\n    el.dispatchEvent(evt);\n  }, colorInputEl, optionToString(value));\n}\n\nfunction optionToString(value?: boolean) {\n  return value === undefined ? \"\" : String(value);\n}\n\nasync function clickBold() {\n  await driver.find(\".test-font-option-FontBold\").click();\n}\nasync function clickItalic() {\n  await driver.find(\".test-font-option-FontItalic\").click();\n}\nasync function clickUnderline() {\n  await driver.find(\".test-font-option-FontUnderline\").click();\n}\nasync function clickStrikethrough() {\n  await driver.find(\".test-font-option-FontStrikethrough\").click();\n}\n\n/**\n * Waits until all text in the given input element is selected and the element has focus.\n * @param inputSelector Selector for the input element to check selection on.\n */\nasync function waitForSelection(inputSelector: string) {\n  await gu.waitToPass(async () => {\n    assert.isTrue(await driver.find(inputSelector).hasFocus());\n    const allSelected = await driver.executeScript(() => {\n      const innerSelector = arguments[0];\n      const el = document.querySelector(innerSelector) as HTMLInputElement;\n      const sel = { start: el.selectionStart, end: el.selectionEnd };\n      const textLen = el.value.length;\n      return sel.start === 0 && sel.end === textLen;\n    }, inputSelector);\n    assert.isTrue(allSelected, \"Expected all text to be selected in the text input\");\n  });\n}\n"
  },
  {
    "path": "test/projects/ColumnFilterMenu.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key, until } from \"mocha-webdriver\";\n\ndescribe(\"ColumnFilterMenu\", function() {\n  setupTestSuite();\n  this.timeout(10000);\n\n  before(async function() {\n    this.timeout(60000);\n    await driver.get(`${server.getHost()}/ColumnFilterMenu`);\n    await driver.findWait(\".fixture-json\", 2000);\n  });\n\n  beforeEach(async () => {\n    await driver.find(\".fixture-reset\").click();\n  });\n\n  it(\"should update json and filter in response to stored filter updates\", async () => {\n    // Verify that everything is selected by default\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n    assert.isTrue((await driver.find(\".fixture-all-values\").getText()).includes(\"Apples, Bananas\"));\n    assert.isTrue((await driver.find(\".fixture-displayed-values\").getText()).includes(\"Apples, Bananas\"));\n\n    // Click on Apples, check that they get added to filter\n    await driver.findContent(\".fixture-stored-menu label\", /Apples/).click();\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [\"Apples\"] }));\n\n    // Check that array of values got filtered\n    assert.isTrue((await driver.find(\".fixture-all-values\").getText()).includes(\"Apples\"));\n    assert.isFalse((await driver.find(\".fixture-displayed-values\").getText()).includes(\"Apples\"));\n\n    // Click on Bananas, check that they get added to filterj\n    await driver.findContent(\".fixture-stored-menu label\", /Bananas/).click();\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [\"Apples\", \"Bananas\"] }));\n\n    // Check that array of values got filtered\n    assert.isTrue((await driver.find(\".fixture-all-values\").getText()).includes(\"Bananas\"));\n    assert.isFalse((await driver.find(\".fixture-displayed-values\").getText()).includes(\"Bananas\"));\n\n    // Click both again, check that array of values includes them\n    await driver.findContent(\".fixture-stored-menu label\", /Apples/).click();\n    await driver.findContent(\".fixture-stored-menu label\", /Bananas/).click();\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n    assert.isTrue((await driver.find(\".fixture-displayed-values\").getText()).includes(\"Apples, Bananas\"));\n  });\n\n  it(\"should have a working select all / none\", async () => {\n    // Check that everything is selected\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n    assert.equal(await driver.find(\".fixture-all-values\").getText(),\n      await driver.find(\".fixture-displayed-values\").getText());\n\n    // Check menu offers bulk-action\n    assert.deepEqual(await driver.findAll(\".test-filter-menu-bulk-action\", e => e.getText()), [\"All\", \"None\"]);\n\n    // Check only `All` is disabled\n    assert.deepEqual(await driver.findAll('.test-filter-menu-bulk-action[aria-disabled=\"true\"]', e => e.getText()),\n      [\"All\"]);\n\n    // Deselect apples\n    await driver.findContent(\".test-filter-menu-list label\", /Apples/).click();\n\n    // Check that there is no disabled\n    assert.deepEqual(\n      await driver.findAll('.test-filter-menu-bulk-action[aria-disabled=\"true\"]', e => e.getText()), [],\n    );\n\n    // Click 'Select all'\n    await driver.findContent(\".test-filter-menu-bulk-action\", /All/).click();\n\n    // Check that everything is back to being selected\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n\n    // Check that 'All' is disabled\n    assert.deepEqual(await driver.findAll('.test-filter-menu-bulk-action[aria-disabled=\"true\"]', e => e.getText()),\n      [\"All\"]);\n\n    // Click 'Select none', check that filter gets switched to inclusion\n    await driver.findContent(\".test-filter-menu-bulk-action\", /None/).click();\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ included: [] }));\n\n    // Check that only 'None' is disabled\n    assert.deepEqual(await driver.findAll('.test-filter-menu-bulk-action[aria-disabled=\"true\"]', e => e.getText()),\n      [\"None\"]);\n\n    // Verify that no values are display\n    assert.equal(await driver.find(\".fixture-displayed-values\").getText(), \"[]\");\n\n    // Select apples\n    await driver.findContent(\".test-filter-menu-list label\", /Apples/).click();\n\n    // Check that there is no disabled\n    assert.deepEqual(\n      await driver.findAll('.test-filter-menu-bulk-action[aria-disabled=\"true\"]', e => e.getText()), [],\n    );\n\n    // Verify that it's the only value included\n    assert.deepEqual(await driver.find(\".fixture-json\").getText(), JSON.stringify({ included: [\"Apples\"] }));\n    assert.equal(await driver.find(\".fixture-displayed-values\").getText(), \"[Apples]\");\n\n    // Select all, check values\n    await driver.findContent(\".test-filter-menu-bulk-action\", /All/).click();\n    assert.deepEqual(await driver.findAll('.test-filter-menu-bulk-action[aria-disabled=\"true\"]', e => e.getText()),\n      [\"All\"]);\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n    assert.equal(await driver.find(\".fixture-all-values\").getText(),\n      await driver.find(\".fixture-displayed-values\").getText());\n  });\n\n  it(\"should offer a working `Future Values` option\", async () => {\n    // Check `Future Values` is present\n    assert.equal(await driver.findContent(\".test-filter-menu-summary\", /Future values/).isPresent(),\n      true, \"Future Values not present\");\n\n    // Check all values are selected\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n\n    // Check `Future Values` is selected\n    assert.equal(await driver.findContent(\".test-filter-menu-summary\", /Future values/).find(\"input\").isSelected(),\n      true, \"Future values should be selected\");\n\n    // Uncheck `Apple`\n    await driver.findContent(\".test-filter-menu-list label\", /Apples/).click();\n\n    // Check the filter spec\n    assert.equal(await driver.find(\".fixture-json\").getText(),\n      JSON.stringify({ excluded: [\"Apples\"] }), \"Spec not correct\");\n\n    // Uncheck the `Future Values` checkbox\n    await driver.findContent(\".test-filter-menu-summary\", /Future values/).find(\"label\").click();\n\n    // check the filter spec is now an inclusion filter all values but 'Apple'\n    const spec = JSON.parse(await driver.find(\".fixture-json\").getText());\n    assert.notInclude(spec.included, \"Apple\", \"filter should not exclude apple\");\n    assert.equal(spec.included.length, 16, \"filter should have 16 excluded values\");\n\n    // Check `Future Values` is unselected\n    assert.equal(await driver.findContent(\".test-filter-menu-summary\", /Future values/).find(\"input\").isSelected(),\n      false);\n\n    // Check again the `Future Values`\n    await driver.findContent(\".test-filter-menu-summary\", /Future values/).find(\"label\").click();\n\n    // Check the filter spec is now an inclusion filter with only 'Apple' in it\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [\"Apples\"] }));\n\n    // Check `Future Values` is selected\n    assert.equal(await driver.findContent(\".test-filter-menu-summary\", /Future values/).find(\"input\").isSelected(),\n      true);\n  });\n\n  it(\"should update filter in response to filter menu\", async () => {\n    // Check that nothing is excluded\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n    const applesCheck = await driver.findContent(\".fixture-stored-menu label\", /Apples/).find(\"input\");\n    const bananasCheck = await driver.findContent(\".fixture-stored-menu label\", /Bananas/).find(\"input\");\n\n    // Open the popup menu, check that Apples and Bananas are selected\n    await driver.find(\".fixture-filter-menu-btn\").click();\n    await driver.findWait(\".grist-floating-menu\", 100);\n    assert.isTrue(await driver.findContent(\".grist-floating-menu label\", /Apples/).find(\"input\").isSelected());\n    assert.isTrue(await driver.findContent(\".grist-floating-menu label\", /Bananas/).find(\"input\").isSelected());\n    assert.isTrue(await applesCheck.isSelected());\n    assert.isTrue(await bananasCheck.isSelected());\n\n    // Deselect Apples\n    await driver.findContent(\".grist-floating-menu label\", /Apples/).click();\n\n    // Check that Apples got deselected in the stored menu\n    assert.isFalse(await driver.findContent(\".grist-floating-menu label\", /Apples/).find(\"input\").isSelected());\n    assert.isFalse(await applesCheck.isSelected());\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [\"Apples\"] }));\n\n    // Close the menu\n    await driver.find(\".fixture-filter-menu-btn\").click();\n\n    // Check that the stored filter stayed unchanges\n    assert.isFalse(await applesCheck.isSelected());\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [\"Apples\"] }));\n\n    // Deselect Bananas in the stored menu\n    await driver.findContent(\".fixture-stored-menu label\", /Bananas/).click();\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [\"Apples\", \"Bananas\"] }));\n\n    // Open the menu and check that it matches the values\n    await driver.find(\".fixture-filter-menu-btn\").click();\n    const openMenu = await driver.findWait(\".grist-floating-menu\", 100);\n    assert.isFalse(await driver.findContent(\".grist-floating-menu label\", /Apples/).find(\"input\").isSelected());\n    assert.isFalse(await driver.findContent(\".grist-floating-menu label\", /Bananas/).find(\"input\").isSelected());\n    assert.isFalse(await applesCheck.isSelected());\n    assert.isFalse(await bananasCheck.isSelected());\n\n    // Select all values in the open menu and check values\n    await driver.find(\".grist-floating-menu .test-filter-menu-bulk-action\").click();\n    assert.isTrue(await driver.findContent(\".grist-floating-menu label\", /Apples/).find(\"input\").isSelected());\n    assert.isTrue(await driver.findContent(\".grist-floating-menu label\", /Bananas/).find(\"input\").isSelected());\n    assert.isTrue(await applesCheck.isSelected());\n    assert.isTrue(await bananasCheck.isSelected());\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n\n    // Click apply in the open menu\n    await driver.find(\".grist-floating-menu .test-filter-menu-apply-btn\").click();\n    // Check that the menu closed\n    await driver.wait(until.stalenessOf(openMenu));\n\n    // Verify that the stored filter is saved\n    assert.isTrue(await applesCheck.isSelected());\n    assert.isTrue(await bananasCheck.isSelected());\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n  });\n\n  it(\"should reset filter to open state on cancel\", async () => {\n    // Check that nothing is excluded\n    let applesCheck = await driver.findContent(\".fixture-stored-menu label\", /Apples/).find(\"input\");\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n    assert.isTrue(await applesCheck.isSelected());\n\n    // Open the filter menu\n    await driver.find(\".fixture-filter-menu-btn\").click();\n    let openMenu = await driver.findWait(\".grist-floating-menu\", 100);\n\n    // Deselect Apples, check that stored menu is updated\n    await driver.findContent(\".grist-floating-menu label\", /Apples/).click();\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [\"Apples\"] }));\n    assert.isFalse(await applesCheck.isSelected());\n\n    // Click cancel, check that the menu closed\n    await driver.find(\".grist-floating-menu .test-filter-menu-cancel-btn\").click();\n    await driver.wait(until.stalenessOf(openMenu));\n\n    // Check that stored menu is back to initial state\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n    // Filter has been rebuilt, so need new reference to checkbox\n    applesCheck = await driver.findContent(\".fixture-stored-menu label\", /Apples/).find(\"input\");\n    assert.isTrue(await applesCheck.isSelected());\n\n    // Open the filter menu again, check that Apples is selected\n    await driver.find(\".fixture-filter-menu-btn\").click();\n    openMenu = await driver.findWait(\".grist-floating-menu\", 100);\n    assert.isTrue(await openMenu.findContent(\"label\", /Apples/).find(\"input\").isSelected());\n\n    // Deselect Apples again\n    await openMenu.findContent(\"label\", /Apples/).click();\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [\"Apples\"] }));\n\n    // Hit Escape, check that stored menu is back to initial state\n    await driver.sendKeys(Key.ESCAPE);\n    await driver.wait(until.stalenessOf(openMenu));\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n\n    // Select all\n    await driver.find(\".test-filter-menu-bulk-action\").click();\n  });\n\n  it(\"should filter items by search value\", async () => {\n    // Check that all items are displayed in the list\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n    assert.lengthOf(await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\"), 17);\n\n    // Enter 'App'\n    const searchInput = await driver.find(\".test-filter-menu-search-input\");\n    await searchInput.click();\n    assert.equal(await searchInput.value(), \"\");\n    await driver.sendKeys(\"App\");\n    assert.equal(await searchInput.value(), \"App\");\n\n    // Check that only Apples and Knapples are in the list\n    const elems = await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\");\n    assert.lengthOf(elems, 2);\n    assert.deepEqual(await Promise.all(elems.map(el => el.getText())), [\"Apples\", \"Knapples\"]);\n\n    // Deselect Apples, check that filter is updated\n    await driver.findContent(\".test-filter-menu-list label\", /Apples/).click();\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [\"Apples\"] }));\n  });\n\n  it(\"should show the total count of all values that don't match the search term\", async () => {\n    // click search input\n    await driver.find(\".test-filter-menu-search-input\").click();\n\n    // search `zzz`\n    await driver.sendKeys(\"zzz\");\n\n    // check there are no matching values\n    assert.match(await driver.find(\".test-filter-menu-list\").getText(), /No matching values/);\n\n    // check the summary label is showing 'Others'\n    assert.match(await driver.find(\".test-filter-menu-summary label\").getText(), /Others/);\n\n    // check count is 8157\n    assert.match(await driver.findContent(\".test-filter-menu-summary\", /Others/).getText(), /8,157/);\n\n    // search 'Oranges'\n    await driver.find(\".test-filter-menu-search-close\").click();\n    await driver.sendKeys(\"Oranges\");\n\n    // check only one matching value\n    assert.deepEqual(\n      await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()), [\"Oranges\"],\n    );\n\n    // check 'Oranges' count is 14\n    assert.deepEqual(await driver.findAll(\".test-filter-menu-list .test-filter-menu-count\", e => e.getText()),\n      [\"14\"]);\n\n    // check `others` count is 8143\n    assert.match(await driver.findContent(\".test-filter-menu-summary\", /Others/).getText(), /8,143/);\n\n    // check 8143 + 14 = 8157\n    assert.equal(8143 + 14, 8157);\n  });\n\n  it(\"should unselect all others when un-checking `others`\", async () => {\n    // click search input\n    await driver.find(\".test-filter-menu-search-input\").click();\n\n    // search 'App'\n    await driver.sendKeys(\"App\");\n\n    // check Apples and Knapples are visible\n    assert.deepEqual(\n      await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()), [\"Apples\", \"Knapples\"],\n    );\n\n    // check Others is checked\n    assert.isTrue(await driver.findContent(\".test-filter-menu-summary\", /Others/).find(\"input\").isSelected());\n\n    // click Others\n    await driver.findContent(\".test-filter-menu-summary\", /Others/).find(\"label\").click();\n\n    // check others is unchecked\n    assert.isFalse(await driver.findContent(\".test-filter-menu-summary\", /Others/).find(\"input\").isSelected());\n\n    // press Escape to clear the search box\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check only Apples and Knapples are selected\n    assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ included: [\"Apples\", \"Knapples\"] }));\n  });\n\n  it(\"should select all others when checking `others`\", async () => {\n    // click search input\n    await driver.find(\".test-filter-menu-search-input\").click();\n\n    // search 'App'\n    await driver.sendKeys(\"App\");\n\n    // check Apples and Knapples are visible\n    assert.deepEqual(\n      await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()), [\"Apples\", \"Knapples\"],\n    );\n\n    // check others is checked\n    assert.isTrue(await driver.findContent(\".test-filter-menu-summary\", /Others/).find(\"input\").isSelected());\n\n    // uncheck Apple\n    await driver.findContent(\".test-filter-menu-list label\", /Apples/).click();\n\n    // click others twice to uncheck and check again\n    await driver.findContent(\".test-filter-menu-summary\", /Others/).find(\"label\").click();\n    await driver.findContent(\".test-filter-menu-summary\", /Others/).find(\"label\").click();\n\n    // press escape to clear search box\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check Apples is unselected\n    const spec = JSON.parse(await driver.find(\".fixture-json\").getText());\n    assert.deepEqual(spec.excluded, [\"Apples\"]);\n  });\n\n  it(\"should clear the search box on Escape\", async () => {\n    await driver.find(\".test-filter-menu-search-input\").click();\n\n    await driver.sendKeys(\"App\");\n\n    // Check that searchbox is not empty\n    assert.equal(await driver.find(\".test-filter-menu-search-input\").value(), \"App\");\n\n    // Press escape\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check that search box is empty\n    assert.equal(await driver.find(\".test-filter-menu-search-input\").value(), \"\");\n  });\n\n  it(\"should clear the search box when clicking the `x`\", async () => {\n    // search for App\n    await driver.find(\".test-filter-menu-search-input\").click();\n\n    await driver.sendKeys(\"App\");\n\n    // check the search is not empty\n    assert.equal(await driver.find(\".test-filter-menu-search-input\").value(), \"App\");\n\n    // click the 'x'\n    await driver.find(\".test-filter-menu-search-close\").click();\n\n    // check search box is empty\n    assert.equal(await driver.find(\".test-filter-menu-search-input\").getText(), \"\");\n\n    // search for app: to check if search box still has focus\n    await driver.sendKeys(\"App\");\n\n    // check the search input is up to date\n    assert.equal(await driver.find(\".test-filter-menu-search-input\").value(), \"App\");\n  });\n\n  it(\"should give focus to the search input\", async () => {\n    // Open the filter menu\n    await driver.find(\".fixture-filter-menu-btn\").click();\n    const menu = await driver.findWait(\".grist-floating-menu\", 100);\n\n    // check search input has autofocus\n    assert.equal(await menu.findWait(\".test-filter-menu-search-input:focus\", 100).isPresent(), true);\n\n    // type in App\n    await driver.sendKeys(\"App\");\n\n    // check values are filtered\n    assert.deepEqual(\n      await menu.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()), [\"Apples\", \"Knapples\"],\n    );\n  });\n\n  it(\"should properly escape search and filter menu\", async () => {\n    // Open filter menu\n    await driver.find(\".fixture-filter-menu-btn\").click();\n    let menu = await driver.findWait(\".grist-floating-menu\", 100);\n\n    // Hit escape, check that menu closed\n    await driver.sendKeys(Key.ESCAPE);\n    await driver.wait(until.stalenessOf(menu));\n    assert.equal(await menu.isPresent(), false);\n\n    // open filter menu again\n    await driver.find(\".fixture-filter-menu-btn\").click();\n    menu = await driver.findWait(\".grist-floating-menu\", 100);\n\n    // search App\n    await menu.findWait(\".test-filter-menu-search-input\", 100).click();\n    await driver.sendKeys(\"App\");\n\n    // check search input is update to date\n    assert.equal(await menu.find(\".test-filter-menu-search-input\").value(), \"App\");\n\n    // hit escape and check search input is clear\n    await driver.sendKeys(Key.ESCAPE);\n    assert.equal(await menu.find(\".test-filter-menu-search-input\").value(), \"\");\n\n    // Hit escape again, check that menu has closed\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Check that menu has been removed\n    await driver.wait(until.stalenessOf(menu));\n  });\n\n  it(\"should update selection properly when clicking `All shown`\", async () => {\n    // search for App\n    await driver.find(\".test-filter-menu-search-input\").click();\n    await driver.sendKeys(\"App\");\n\n    // check All shown and All Except are visible\n    assert.deepEqual(\n      await driver.findAll(\".test-filter-menu-bulk-action\", e => e.getText()),\n      [\"All shown\", \"All except\"],\n    );\n\n    // click All shown\n    await driver.findContent(\".test-filter-menu-bulk-action\", /All shown/).click();\n\n    // send Escape to clear the search box\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check filter is inclusion filter with only Apples and Knapples\n    const spec = JSON.parse(await driver.find(\".fixture-json\").getText());\n    assert.deepEqual(spec.included, [\"Apples\", \"Knapples\"]);\n\n    // search for App again\n    await driver.sendKeys(\"App\");\n\n    // check App Shown is disabled\n    assert.deepEqual(\n      await driver.findAll('.test-filter-menu-bulk-action[aria-disabled=\"true\"]', e => e.getText()),\n      [\"All shown\"],\n    );\n  });\n\n  it(\"should update selection properly when clicking `All Except`\", async () => {\n    // search for App\n    await driver.find(\".test-filter-menu-search-input\").click();\n    await driver.sendKeys(\"App\");\n\n    // click App Except\n    await driver.findContent(\".test-filter-menu-bulk-action\", /All except/).click();\n\n    // send Escape to clear the search box\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check filter is exclusion filter with only Apples and Knapples excluded\n    const spec = JSON.parse(await driver.find(\".fixture-json\").getText());\n    assert.deepEqual(spec.excluded, [\"Apples\", \"Knapples\"]);\n\n    // search for App again\n    await driver.sendKeys(\"App\");\n\n    // check App Except is disabled\n    assert.deepEqual(\n      await driver.findAll('.test-filter-menu-bulk-action[aria-disabled=\"true\"]', e => e.getText()),\n      [\"All except\"],\n    );\n  });\n\n  it(\"should update selection property on ENTER\", async () => {\n    // search for App\n    await driver.find(\".test-filter-menu-search-input\").click();\n    await driver.sendKeys(\"App\");\n\n    // send ENTER\n    await driver.sendKeys(Key.ENTER);\n\n    // check filter is inclusion filter with only Apples and Knapples included\n    const spec = JSON.parse(await driver.find(\".fixture-json\").getText());\n    assert.deepEqual(spec.included, [\"Apples\", \"Knapples\"]);\n  });\n});\n"
  },
  {
    "path": "test/projects/ColumnFilterMenu2.ts",
    "content": "import { selectAllKey } from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\nasync function parseFilterState() {\n  const json = await driver.find(\".fixture-json\").getText();\n  return JSON.parse(json);\n}\n\nasync function parseAllValues() {\n  return (await driver.find(\".fixture-all-values\").getText()).split(\",\");\n}\n\ndescribe(\"ColumnFilterMenu2\", function() {\n  setupTestSuite();\n  this.timeout(10000);\n  let limitShown: number | undefined;\n\n  before(async function() {\n    this.timeout(60000);\n    await driver.get(`${server.getHost()}/ColumnFilterMenu`);\n  });\n\n  beforeEach(async () => {\n    // reset filter to all selected\n    await driver.find(\".fixture-limit-shown\").click();\n    await driver.find(\".fixture-limit-shown\").sendKeys(await selectAllKey(), Key.DELETE);\n    if (limitShown !== undefined) {\n      await driver.sendKeys(limitShown.toString());\n    }\n    await driver.find(\".fixture-reset\").click();\n  });\n\n  describe(\"opt.limitShown\", function() {\n    before(() => {\n      limitShown = 3;\n    });\n\n    it(\"should limit shown value to the first opt.limitShown\", async () => {\n      // check list has 3 items\n      assert.lengthOf(await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\"), 3);\n\n      // check Apple is included\n      assert.equal(await driver.findContent(\".test-filter-menu-list label\", /Apple/).find(\"input\").isSelected(), true);\n\n      // click 'Apple'\n      await driver.findContent(\".test-filter-menu-list label\", /Apple/).click();\n\n      // check Apple is excluded\n      assert.equal(await driver.findContent(\".test-filter-menu-list label\", /Apple/).find(\"input\").isSelected(), false);\n    });\n\n    it(\"should group values beyond\", async () => {\n      // check `Other values` is present\n      assert.deepEqual(await driver.findAll(\".test-filter-menu-summary\", e => e.find(\"label\").getText()),\n        [\"Other values (14)\", \"Future values\"]);\n\n      // check there are actually 17 unique values in total (where 17 is 14 unique other values\n      // added to the 3 the number of values shown);\n      assert.equal(\n        14 + 3,\n        (await parseAllValues()).length,\n      );\n\n      // check `Other values` is checked\n      assert.equal(await driver.findContent(\".test-filter-menu-summary\", /Other values/).find(\"input\").isSelected(),\n        true);\n\n      // check 'Date', 'Figs' and 'Rhubarb' are not shown\n      assert.notIncludeMembers(\n        await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()),\n        [\"Dates\", \"Figs\", \"Rhubarb\"]);\n\n      // click 'Other values'\n      await driver.findContent(\".test-filter-menu-summary\", /Other values/).find(\"label\").click();\n\n      // check 'Other values' is unchecked\n      assert.equal(await driver.findContent(\".test-filter-menu-summary\", /Other values/).find(\"input\").isSelected(),\n        false);\n\n      // check 'Date', 'Figs' and 'Rhubarb' are excluded\n      assert.includeMembers((await parseFilterState()).excluded, [\"Dates\", \"Figs\", \"Rhubarb\"]);\n    });\n\n    it(\"should maintain selection across shown item when clicking `Other values`\", async () => {\n      // unselect Apple\n      await driver.findContent(\".test-filter-menu-list label\", /Apple/).click();\n\n      // check Apple is not included\n      assert.equal(await driver.findContent(\".test-filter-menu-list label\", /Apple/).find(\"input\").isSelected(),\n        false);\n\n      // Click 'Other values'\n      await driver.findContent(\".test-filter-menu-summary\", /Other values/).find(\"label\").click();\n\n      // Check Apple is still not included\n      assert.equal(await driver.findContent(\".test-filter-menu-list label\", /Apple/).find(\"input\").isSelected(),\n        false);\n    });\n\n    it(\"should also have a working `Future Values`\", async () => {\n      // check Future Values is checked\n      assert.equal(await driver.findContent(\".test-filter-menu-summary\", /Future values/).find(\"input\").isSelected(),\n        true);\n\n      // check filter is an exclusion filter\n      assert.deepEqual(Object.keys(await parseFilterState()), [\"excluded\"]);\n\n      // Click Future Values\n      await driver.findContent(\".test-filter-menu-summary\", /Future values/).find(\"label\").click();\n\n      // check Future values is unchecked\n      assert.equal(await driver.findContent(\".test-filter-menu-summary\", /Future values/).find(\"input\").isSelected(),\n        false);\n\n      // Check filter is an inclusion filter\n      assert.deepEqual(Object.keys(await parseFilterState()), [\"included\"]);\n    });\n\n    describe(\"when searching\", function() {\n      it(\"should have a `Other Matching` group\", async () => {\n        // enter 'A'\n        await driver.sendKeys(\"A\");\n\n        // Check `Other Matching` is shown\n        assert.deepEqual(await driver.findAll(\".test-filter-menu-summary\", e => e.find(\"label\").getText()),\n          [\"Other Matching (6)\", \"Other Non-Matching (8)\"]);\n\n        // chech all values adds up (shown values) + (other matching) + (other non-matching)\n        assert.equal(3 + 6 + 8, (await parseAllValues()).length);\n\n        // Check Apples, Bananas are shown\n        assert.lengthOf(await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\"), 3);\n        assert.includeMembers(\n          await driver.findAll(\".test-filter-menu-list .test-filter-menu-value\", e => e.getText()),\n          [\"Apples\", \"Bananas\"]);\n\n        // check Dates, Knapples are not excluded\n        assert.deepEqual(await parseFilterState(), { excluded: [] });\n\n        // click `Other Matching`\n        await driver.findContent(\".test-filter-menu-summary\", /Other Matching/).find(\"label\").click();\n\n        // check `Other Matching` is unchecked\n        assert.equal(\n          await driver.findContent(\".test-filter-menu-summary\", /Other Matching/).find(\"input\").isSelected(),\n          false,\n        );\n\n        // check Dates, Knapples are NOT included\n        assert.isArray((await parseFilterState()).excluded);\n        assert.include((await parseFilterState()).excluded, \"Dates\");\n        assert.include((await parseFilterState()).excluded, \"Knapples\");\n\n        // click Other Matching\n        await driver.findContent(\".test-filter-menu-summary\", /Other Matching/).find(\"label\").click();\n\n        // check `Other Matching` is checked\n        assert.equal(\n          await driver.findContent(\".test-filter-menu-summary\", /Other Matching/).find(\"input\").isSelected(),\n          true,\n        );\n\n        // Check Dates, Knapples are included\n        assert.isArray((await parseFilterState()).excluded);\n        assert.notIncludeMembers((await parseFilterState()).excluded, [\"Dates\", \"Knapples\"]);\n      });\n\n      it(\"should maintain selection across shown item when click `Other Matching`\", async () => {\n        // enter 'A'\n        await driver.sendKeys(\"A\");\n\n        // click 'Apple'\n        await driver.findWait(\".test-filter-menu-list label\", 100);\n        await driver.findContent(\".test-filter-menu-list label\", /Apple/).click();\n\n        // check Apple is not included\n        assert.equal(\n          await driver.findContent(\".test-filter-menu-list label\", /Apple/).find(\"input\").isSelected(),\n          false);\n\n        // click 'Other Matching'\n        await driver.findContent(\".test-filter-menu-summary label\", /Other Matching/).click();\n\n        // check Apple is still not included\n        assert.equal(\n          await driver.findContent(\".test-filter-menu-list label\", /Apple/).find(\"input\").isSelected(),\n          false);\n      });\n\n      it(\"should also have a working `Other Non-Matching` group\", async () => {\n        // enter 'A'\n        await driver.sendKeys(\"A\");\n\n        await driver.findWait(\".test-filter-menu-summary label\", 100);\n        // check 'Other Non-Matching' is checked\n        assert.equal(\n          await driver.findContent(\".test-filter-menu-summary label\", /Other Non-Matching/).find(\"input\").isSelected(),\n          true,\n        );\n\n        // check filter is an exclusion filter\n        assert.equal(await driver.find(\".fixture-json\").getText(), JSON.stringify({ excluded: [] }));\n\n        // click 'Other Non-Matching'\n        await driver.findContent(\".test-filter-menu-summary label\", /Other Non-Matching/).click();\n\n        // check 'Other Non-Matching' is un-checked\n        assert.equal(\n          await driver.findContent(\".test-filter-menu-summary label\", /Other Non-Matching/).find(\"input\").isSelected(),\n          false,\n        );\n\n        // check filter is an inclusion filter\n        const spec = await parseFilterState();\n        assert.isArray(spec.included);\n        assert.include(spec.included, \"Apples\");\n        assert.include(spec.included, \"Bananas\");\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/projects/DateRangeFilter.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport * as fu from \"test/projects/filterUtils\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport range from \"lodash/range\";\nimport { addToRepl, assert, driver, Key } from \"mocha-webdriver\";\nimport moment from \"moment\";\n\nconst CURRENT_TIME = moment(\"2022-09-20T15:28:09.092Z\");\nconst now = () => moment(CURRENT_TIME);\n\ndescribe(\"DateRangeFilter\", function() {\n  setupTestSuite();\n  addToRepl(\"fu\", fu);\n\n  this.timeout(10000);\n\n  async function refresh() {\n    await driver.get(`${server.getHost()}/ColumnFilterMenu?filterType=Date` +\n      `&currentTime=${encodeURIComponent(now().toISOString())}`);\n    await gu.waitToPass(async () => {\n      assert(await driver.find(\".test-filter-menu-search-input\").hasFocus());\n    });\n  }\n\n  async function testBoundShowCalendar(minMax: \"min\" | \"max\") {\n    // check calendar is NOT present\n    assert.equal(await driver.find(\".datepicker-inline\").isPresent(), false);\n\n    // click min bound\n    await fu.findBound(minMax).click();\n\n    // check calendar is visible\n    assert.equal(await driver.find(\".datepicker-inline\").isPresent(), true);\n  }\n\n  beforeEach(async function() {\n    await refresh();\n  });\n\n  it(\"should switch to calendar view when clicking on min bound\", async function() {\n    await testBoundShowCalendar(\"min\");\n  });\n\n  it(\"should switch to calendar view when clicking on max bound\", async function() {\n    await testBoundShowCalendar(\"max\");\n  });\n\n  it(\"should switch back to default view when clicking on 'List View'\", async function() {\n    // click min bound\n    await fu.findBound(\"min\").click();\n\n    // check calendar is visible\n    assert.equal(await driver.find(\".datepicker-inline\").isPresent(), true);\n\n    // click List View\n    await driver.findContent(\".test-calendar-links button\", \"List view\").click();\n\n    // check calendar is not visible\n    assert.equal(await driver.find(\".datepicker-inline\").isPresent(), false);\n  });\n\n  async function testPickingBound(minMax: \"min\" | \"max\") {\n    // check min bound shows no border\n    assert.equal(await fu.findBound(minMax).matches(\".selected\"), false);\n\n    // check min bound shows 'Min' placeholder\n    assert.equal(await fu.getBoundText(minMax), minMax === \"min\" ? \"Start\" : \"End\");\n\n    // click min bound\n    await fu.findBound(minMax).click();\n\n    // check min bound shows border\n    assert.equal(await fu.findBound(minMax).matches(\".selected\"), true);\n\n    // pick a date (2022-09-18)\n    await driver.findContent(\".datepicker-inline td.day\", \"18\").click();\n\n    // check min bound shows 2022-09-18\n    assert.equal(await fu.getBoundText(minMax), \"2022-09-18\");\n  }\n\n  it(\"should update min bound when clicking date on calendar\", async function() {\n    await testPickingBound(\"min\");\n  });\n\n  it(\"should update max bound when clicking date on calendar\", async function() {\n    await testPickingBound(\"max\");\n  });\n\n  it(\"should show finite range correctly\", async function() {\n    // set min bound to 2022-09-18\n    await fu.setBound(\"min\", \"2022-09-18\");\n\n    // set max bound to 2022-09-22\n    await fu.setBound(\"max\", \"2022-09-22\");\n\n    // check 18th has .range-start class\n    assert.deepEqual(await fu.findCalendarDates(\".range-start\"), [\"18\"]);\n\n    // check 19 to 21 have .range class\n    assert.deepEqual(await fu.findCalendarDates(\".range\"), [\"19\", \"20\", \"21\"]);\n\n    // check 22 has .range-end class\n    assert.deepEqual(await fu.findCalendarDates(\".range-end\"), [\"22\"]);\n  });\n\n  it(\"should show infinite range when max is unbound\", async function() {\n    // set min bound to 2022-09-18\n    await fu.setBound(\"min\", \"2022-09-18\");\n\n    // check 18th has range-start class\n    await gu.waitToPass(async () => {\n      assert.deepEqual(await fu.findCalendarDates(\".range-start\"), [\"18\"]);\n    });\n\n    // check 19, 22, 31 have .range class\n    assert.deepEqual(await fu.findCalendarDates(\".range\"),\n      range(19, 31).concat([1, 2, 3, 4, 5, 6, 7, 8]).map(n => n.toString()));\n  });\n\n  it(\"should show infinite range when min is unbound\", async function() {\n    // set max bound to 2022-09-18\n    await fu.setBound(\"max\", \"2022-09-18\");\n\n    // check 18th has range-end class\n    await gu.waitToPass(async () => {\n      assert.deepEqual(await fu.findCalendarDates(\".range-end\"), [\"18\"]);\n    });\n\n    // check 1, 3, 8 have .range class\n    assert.deepEqual(await fu.findCalendarDates(\".range\"),\n      [28, 29, 30, 31].concat(range(1, 18)).map(n => n.toString()));\n  });\n\n  it(\"should allow to convert to relative date\", async function() {\n    // set min bound to 2022-09-18\n    await fu.setBound(\"min\", \"2022-09-18\");\n\n    // click '2 days ago' from menu\n    await fu.setBound(\"min\", { relative: \"2 days ago\" });\n\n    // check bound shows 2 days ago\n    assert.equal(await fu.getBoundText(\"min\"), \"2 days ago\");\n\n    // check range is still correct\n    assert.deepEqual(await fu.findCalendarDates(\".range-start\"), [\"18\"]);\n    assert.deepEqual(await fu.findCalendarDates(\".range\"),\n      range(19, 31).concat([1, 2, 3, 4, 5, 6, 7, 8]).map(n => n.toString()));\n\n    // check menus till offer 2 days ago\n    await fu.openRelativeOptionsMenu(\"min\");\n    assert.equal(await driver.findContent(\".grist-floating-menu li\", \"2 days ago\").isPresent(), true);\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should support relative date in future\", async function() {\n    // set min bound to 2022-09-24\n    await fu.setBound(\"min\", \"2022-09-24\");\n\n    // click '4 days from now' from menu\n    await fu.setBound(\"min\", { relative: \"4 days from now\" });\n\n    // check bound shows 4 days from now\n    assert.equal(await fu.getBoundText(\"min\"), \"4 days from now\");\n\n    // check range is still correct\n    assert.deepEqual(await fu.findCalendarDates(\".range-start\"), [\"24\"]);\n    assert.deepEqual(await fu.findCalendarDates(\".range\"),\n      range(25, 31).concat([1, 2, 3, 4, 5, 6, 7, 8]).map(n => n.toString()));\n\n    // check menus still offer 4 days from now\n    await fu.openRelativeOptionsMenu(\"min\");\n    assert.equal(await driver.findContent(\".grist-floating-menu li\", \"4 days from now\").isPresent(), true);\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should allow deleting of relative date\", async function() {\n    // set min bound to '3 days ago`\n    await fu.setBound(\"min\", { relative: \"3 days ago\" });\n\n    // check min bound shows `3 days ago`\n    assert.equal(await fu.getBoundText(\"min\"), \"3 days ago\");\n\n    // hover token and click the x button\n    await driver.withActions(\n      action => action\n        .move({ origin: fu.findBound(\"min\").find(\".test-filter-menu-tokenfield-token\") })\n        .move({ origin: driver.find(\".test-filter-menu-tokenfield-delete\") })\n        .click(),\n    );\n\n    // check min bound shows `Min`\n    assert.equal(await fu.getBoundText(\"min\"), \"Start\");\n  });\n\n  it(\"should delete relative date on keyboard Delete\", async function() {\n    // set min bound to '3 days ago`\n    await fu.setBound(\"min\", { relative: \"3 days ago\" });\n\n    // check min bound shows `3 days ago`\n    assert.equal(await fu.getBoundText(\"min\"), \"3 days ago\");\n\n    // press keyboard Delete\n    await driver.sendKeys(Key.BACK_SPACE);\n\n    // check min bound shows `Min`\n    assert.equal(await fu.getBoundText(\"min\"), \"Start\");\n  });\n\n  it(\"should allow to convert to absolute date\", async function() {\n    // set min bound to 2022-09-18\n    await fu.setBound(\"min\", \"2022-09-18\");\n    assert.equal(await fu.getBoundText(\"min\"), \"2022-09-18\");\n\n    // click '2 days ago' from menu\n    await fu.setBound(\"min\", { relative: \"2 days ago\" });\n    assert.equal(await fu.getBoundText(\"min\"), \"2 days ago\");\n\n    // click  2022-09-18 from menu\n    await fu.setBound(\"min\", { relative: \"2022-09-18\" });\n\n    // check min bound shows 2022-09-18\n    assert.equal(await fu.getBoundText(\"min\"), \"2022-09-18\");\n\n    // check range is still correct\n    assert.deepEqual(await fu.findCalendarDates(\".range-start\"), [\"18\"]);\n    assert.deepEqual(await fu.findCalendarDates(\".range\"),\n      range(19, 31).concat([1, 2, 3, 4, 5, 6, 7, 8]).map(n => n.toString()));\n  });\n\n  it(\"should update relative date\", async function() {\n    // set min bound to 2022-09-18\n    await fu.setBound(\"min\", \"2022-09-18\");\n\n    // click '2 days ago' from menu\n    await fu.setBound(\"min\", { relative: \"2 days ago\" });\n\n    // pick 2022-09-17 from calendar\n    await driver.findContent(\".datepicker-inline td.day\", \"17\").click();\n\n    // check min shows '3 days ago'\n    assert.equal(await fu.getBoundText(\"min\"), \"3 days ago\");\n  });\n\n  it(\"should support going back and forth between relative and absolute date\", async function() {\n    // set min bound to 2022-09-18\n    await fu.setBound(\"min\", \"2022-09-18\");\n\n    // click '2 days ago' from menu\n    await fu.setBound(\"min\", { relative: \"2 days ago\" });\n\n    // set min bound to 2022-09-18\n    await fu.setBound(\"min\", { relative: \"2022-09-18\" });\n\n    // pick 2022-09-17 from calendar\n    await driver.findContent(\".datepicker-inline td.day\", \"17\").click();\n\n    // check min shows 2022-09-17\n    assert.equal(await fu.getBoundText(\"min\"), \"2022-09-17\");\n  });\n\n  it(\"should select max when pressing Enter while on min\", async  function() {\n    await fu.findBound(\"min\").click();\n    assert.equal(await fu.getSelected(), \"min\");\n    await driver.sendKeys(Key.ARROW_DOWN, Key.ENTER);\n    assert.equal(await fu.getSelected(), \"max\");\n  });\n\n  it(\"should keep focus on max when pressing Tab\", async function() {\n    await fu.findBound(\"max\").click();\n    assert.equal(await fu.getSelected(), \"max\");\n    await driver.sendKeys(Key.TAB);\n    assert.equal(await fu.getSelected(), \"max\");\n  });\n\n  it(\"should select min when pressing sift+tab while on max\", async function() {\n    await fu.findBound(\"max\").click();\n    assert.equal(await fu.getSelected(), \"max\");\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.TAB));\n    assert.equal(await fu.getSelected(), \"min\");\n  });\n\n  it(\"should hide options on Escape\", async function() {\n    await fu.findBound(\"max\").click();\n    assert.equal(await fu.isOptionsVisible(), true);\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n    assert.equal(await fu.isOptionsVisible(), false);\n  });\n\n  it(\"should show relative dates options when value changes\", async function() {\n    await fu.findBound(\"min\").click();\n    await gu.findOpenMenu();\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n    assert.equal(await fu.isOptionsVisible(), false);\n    await fu.pickDateInCurrentMonth(\"18\");\n    await gu.findOpenMenu();\n    assert.equal(await fu.isOptionsVisible(), true);\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n  });\n\n  it(\"should show relative dates when selected bound changes\", async function() {\n    await fu.findBound(\"min\").click();\n    await gu.findOpenMenu();\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n    assert.equal(await fu.isOptionsVisible(), false);\n    await driver.sendKeys(Key.TAB);\n    await gu.findOpenMenu();\n    assert.equal(await fu.isOptionsVisible(), true);\n  });\n\n  it(\"should toggle relative dates on click\", async function() {\n    await fu.findBound(\"min\").click();\n    await gu.findOpenMenu();\n    assert.equal(await fu.isOptionsVisible(), true);\n    await fu.findBound(\"min\").click();\n    await gu.waitForMenuToClose();\n    assert.equal(await fu.isOptionsVisible(), false);\n  });\n\n  it(\"should show relative dates options when pressing Enter while the options are closed\", async function() {\n    await fu.findBound(\"min\").click();\n    await gu.findOpenMenu();\n    await driver.sendKeys(Key.ESCAPE); // Escape to close\n    await gu.waitForMenuToClose();\n    assert.equal(await fu.isOptionsVisible(), false);\n    await driver.sendKeys(Key.ENTER); // Enter to reopen\n    await gu.findOpenMenu();\n    assert.equal(await fu.isOptionsVisible(), true);\n    assert.equal(await fu.getSelected(), \"min\");\n  });\n\n  it(\"should switch to calendar view on click\", async function() {\n    assert.equal(await fu.getViewType(), \"Default\");\n    await fu.findBound(\"min\").click();\n    assert.equal(await fu.getViewType(), \"Calendar\");\n  });\n\n  it(\"should have working keyboard navigation after picking date from calendar\", async function() {\n    await fu.findBound(\"min\").click();\n    await gu.findOpenMenu();\n    assert.deepEqual(await fu.getSelectedOption(), []);\n    await fu.pickDateInCurrentMonth(\"18\");\n    assert.deepEqual(await fu.getSelectedOption(), [\"2022-09-18\"]);\n    await driver.sendKeys(Key.ARROW_DOWN);\n    assert.deepEqual(await fu.getSelectedOption(), [\"2 days ago\"]);\n  });\n\n  it(\"should select bounds on click\", async function() {\n    assert.equal(await fu.getSelected(), undefined);\n    await fu.findBound(\"min\").click();\n    assert.equal(await fu.getSelected(), \"min\");\n    await fu.findBound(\"max\").click();\n    assert.equal(await fu.getSelected(), \"max\");\n  });\n\n  it(\"should have working keyboard navigation after switching bounds using Enter\", async function() {\n    await fu.findBound(\"min\").click();\n    assert.equal(await fu.getSelected(), \"min\");\n    await driver.sendKeys(Key.ENTER);\n    assert.equal(await fu.getSelected(), \"max\");\n    assert.deepEqual(await fu.getSelectedOption(), []);\n    await driver.sendKeys(Key.ARROW_DOWN);\n    assert.deepEqual(await fu.getSelectedOption(), [\"Today\"]);\n  });\n\n  it(\"should not lose keyboard navigation when using Tab while Max is selected\", async function() {\n    await fu.findBound(\"min\").click();\n    await driver.sendKeys(Key.TAB);\n    assert.equal(await fu.getSelected(), \"max\");\n    assert.deepEqual(await fu.getSelectedOption(), []);\n    // check arrow navigation still working\n    await driver.sendKeys(Key.ARROW_DOWN);\n    assert.deepEqual(await fu.getSelectedOption(), [\"Today\"]);\n  });\n\n  it(\"should hide Future Values on calendar view\", async function() {\n    const getSummaries = () => (\n      driver.findAll(\".test-filter-menu-summary\", e => e.find(\"label\").getText())\n    );\n    assert.equal(await fu.getViewType(), \"Default\");\n    assert.deepEqual(await getSummaries(), [\"Future values\"]);\n    await fu.findBound(\"min\").click();\n    assert.equal(await fu.getViewType(), \"Calendar\");\n    assert.deepEqual(await getSummaries(), []);\n  });\n\n  it(\"should not show '[object Object]' string after a deleting a relative date\", async function() {\n    // The issue happened as follow: After selecting a relative date, if you start typing, the extra\n    // keypresses aren't visible (as expected), but once you hit Delete, you see an [object Object]\n    // string.\n\n    await fu.setBound(\"max\", { relative: \"3 days ago\" });\n    await gu.sendKeys(\"random keys\", Key.BACK_SPACE);\n    assert.equal(await fu.getBoundText(\"max\"), \"End\");\n  });\n\n  describe(\"default view\", function() {\n    it(\"should have working presets\", async function() {\n      // click Today\n      await driver.findContent(\".test-filter-menu-presets-links button\", \"Today\").click();\n\n      await gu.findOpenMenu();\n\n      // check min bounds shows 'today'\n      assert.equal(await fu.getBoundText(\"min\"), \"Today\");\n\n      // check max bound shows 'today'\n      assert.equal(await fu.getBoundText(\"max\"), \"Today\");\n\n      // click Last week\n      await driver.findContent(\".test-filter-menu-presets-links button\", \"More\").click();\n      await gu.findOpenMenu();\n      await driver.findContent(\".grist-floating-menu li\", \"Last week\").click();\n\n      // check min bounds shows '1st day of last week'\n      assert.equal(await fu.getBoundText(\"min\"), \"1st day of last week\");\n\n      // check max bound shows '7th day of last week'\n      assert.equal(await fu.getBoundText(\"max\"), \"Last day of last week\");\n    });\n\n    it(\"should open calendar view when picking a preset\", async function() {\n      assert.equal(await fu.getViewType(), \"Default\");\n      await driver.findContent(\".test-filter-menu-presets-links button\", \"Last 7 days\").click();\n      assert.equal(await fu.getViewType(), \"Calendar\");\n      assert.equal(await fu.getBoundText(\"min\"), \"7 days ago\");\n      assert.equal(await fu.getBoundText(\"max\"), \"Yesterday\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/projects/DocMenu.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key, stackWrapFunc } from \"mocha-webdriver\";\n\ndescribe(\"DocMenu\", function() {\n  this.timeout(60000);      // Set a longer default timeout.\n  gu.bigScreen();\n  setupTestSuite();\n\n  const openWorkspaceMenu = stackWrapFunc(async function(wsRegex: RegExp) {\n    await driver.findContent(\".test-dm-workspace\", wsRegex).mouseMove()\n      .find(\".test-dm-workspace-options\").click();\n  });\n\n  const openDocMenu = stackWrapFunc(async function(docRegex: RegExp) {\n    // Note that this matches all text of doc entry, including \"Edited ...\" text. It's a bit\n    // tricky to avoid that. If element is out of view, the first mouseMove() will scroll it into\n    // view. It seems that a second one is needed to actually move the mouse over it.\n    await driver.findContent(\".test-dm-doc\", docRegex).mouseMove().mouseMove()\n      .find(\".test-dm-doc-options\").click();\n  });\n\n  const getDocs = stackWrapFunc(async function(workspace?: string) {\n    let docs = await driver.findAll(\".test-dm-doc\");\n    if (workspace) {\n      const results = await Promise.all(\n        docs.map(\n          async d => (await d.find(\".test-dm-doc-workspace\").getText()) === \"Workspace\\n\" + workspace,\n        ),\n      );\n      docs = docs.filter((_, index) => results[index]);\n    }\n    return docs;\n  });\n\n  const getDocNames = stackWrapFunc(async function(workspace?: string) {\n    const docs = await getDocs(workspace);\n    const result = await Promise.all(\n      docs.map(d => d.find(\".test-dm-doc-name\").getText()),\n    );\n    return result;\n  });\n\n  const getDocTimes = stackWrapFunc(async function(workspace?: string) {\n    const docs = await getDocs(workspace);\n    return await Promise.all(\n      docs.map(d => d.find(\".test-dm-doc-edited-at\").getText()),\n    );\n  });\n\n  const getPinnedDocNames = stackWrapFunc(async function(workspace?: string) {\n    let docs = await getDocs(workspace);\n    const results = await Promise.all(\n      docs.map(async d => await d.find(\".test-dm-doc-pinned\").isPresent()),\n    );\n    docs = docs.filter((_, index) => results[index]);\n    return await Promise.all(\n      docs.map(d => d.find(\".test-dm-doc-name\").getText()),\n    );\n  });\n\n  before(async function() {\n    await driver.get(`${server.getHost()}/DocMenu`);\n    // Prevent the opening of new pages using using a UrlState hook. This prevents the testing of\n    // navigation, but allows for better testing of the DocMenu page itself.\n    await driver.executeScript(`window._urlStateLoadPage = () => {};`);\n    // Hide all popups; they interfere with some clicks.\n    await gu.dismissCardPopups(null);\n  });\n\n  it(\"should correctly detect initials in doc names\", async function() {\n    // We will work on document Doc22, which is single pinned document in the August\n    // workspace.\n\n    await gu.openWorkspace(\"August\");\n    await gu.selectTab(\"Pinned\");\n    assert.deepEqual(\n      await getDocNames(),\n      [\"Doc22\"],\n    );\n\n    await testName(\"A\", \"A\");\n    await testName(\"Aa\", \"A\");\n    await testName(\"A A\", \"AA\");\n    await testName(\"A B\", \"AB\");\n    await testName(\"A Bc\", \"AB\");\n    await testName(\"A B C\", \"AB\");\n    await testName(\" foo  bar cat\", \"FB\", \"foo bar cat\");\n    await testName(\" foo-bar cat\", \"FC\", \"foo-bar cat\");\n    await testName(\"foo-bar\", \"F\");\n    await testName(\"  Something\", \"S\", \"Something\");\n    await openDialog();\n    await typeName(\"      \");\n    await checkInitials(\"\", \"      \");\n    await cancel();\n  });\n\n  it(\"should correctly detect initials in doc names with emojis characters\", async function() {\n    await testName(\"😀\", \"😀\", \"\");\n    await testName(\"😀😀\", \"😀\", \"😀\");\n    await testName(\"😀 Hello\", \"😀\", \"Hello\");\n    await testName(\"😀😀 World\", \"😀\", \"😀 World\");\n    await testName(\"😀A\", \"😀\", \"A\");\n    await testName(\"😀 A\", \"😀\", \"A\");\n    await testName(\"😀-A\", \"😀\", \"-A\");\n    await testName(\"😀_A\", \"😀\", \"_A\");\n    await testName(\"😀123\", \"😀\", \"123\");\n    await testName(\"😀😀😀\", \"😀\", \"😀😀\");\n    await testName(\"😀😀 A\", \"😀\", \"😀 A\");\n    await testName(\"😀-😀\", \"😀\", \"-😀\");\n    await testName(\"😀_😀\", \"😀\", \"_😀\");\n\n    // Now emoji should be ignored as a second character, alongside any non letter/digit.\n    await testName(\"A😀A\", \"A\", \"A😀A\");\n    await testName(\"A😀\", \"A\", \"A😀\");\n    await testName(\"A😀😀\", \"A\", \"A😀😀\");\n    await testName(\"A😀 😀\", \"A\", \"A😀 😀\");\n    await testName(\"A😀-😀\", \"A\", \"A😀-😀\");\n    await testName(\"A😀 B\", \"AB\");\n    await testName(\"A😀-B\", \"A\");\n    await testName(\"A😀_B\", \"A\");\n    await testName(\"A😀123\", \"A\");\n    await testName(\"A😀😀 😀B\", \"A\");\n    await testName(\"A😀😀 😀B C\", \"A\");\n  });\n\n  it(\"should ignore digits in the front of the name\", async function() {\n    // Couple of more test cases with digits.\n    await testName(\"1😀 Foo\", \"1F\", \"1😀 Foo\");\n    await testName(\"1😀😀 Foo\", \"1F\", \"1😀😀 Foo\");\n    await testName(\"1😀 😀Foo\", \"1\",  \"1😀 😀Foo\"); // second word must start with a letter/digit\n    await testName(\"1😀 1Foo\", \"11\",  \"1😀 1Foo\"); // second word must start with a letter/digit\n    await testName(\"1😀 AFoo\", \"1A\",  \"1😀 AFoo\"); // second word must start with a letter/digit\n    await testName(\"A😀 1Foo\", \"A1\",  \"A😀 1Foo\"); // second word must start with a letter/digit\n  });\n\n  it(\"should not ignore spaces around emojis\", async function() {\n    await testName(\" 😀\", \"\",  \"😀\"); // with space in front, there is not icon at all.\n    await testName(\" 😀 \", \"\",  \"😀\");\n    await testName(\"😀 \", \"😀\",  \"\");\n    await testName(\" Foo Bar\", \"FB\",  \"Foo Bar\");\n    await testName(\" Foo.\", \"F\", \"Foo.\");\n    await testName(\" Foo (Copy)\", \"F\", \"Foo (Copy)\");\n    await testName(\" Foo\", \"F\", \"Foo\");\n    await testName(\" Foo 1bar\", \"F1\", \"Foo 1bar\");\n    await testName(\" 1bar foo\", \"1F\", \"1bar foo\");\n  });\n\n  it(\"should work with complex emojis\", async function() {\n    // Complex emojis on the left.\n    await testName(\"👨‍👩‍👧‍👦 Foo\", \"👨‍👩‍👧‍👦\", \"Foo\");\n    await testName(\"👨‍👩‍👧‍👦1 Foo\", \"👨‍👩‍👧‍👦\", \"1 Foo\");\n    await testName(\"1👨‍👩‍👧‍👦 Foo\", \"1F\", \"1👨‍👩‍👧‍👦 Foo\");\n\n    // Complex emojis on the right, display name should be the same,\n    await testName(\"Foo 👨‍👩‍👧‍👦\", \"F\", \"Foo 👨‍👩‍👧‍👦\");\n    await testName(\"Foo 👨‍👩‍👧‍👦1\", \"F\", \"Foo 👨‍👩‍👧‍👦1\");\n    await testName(\"Foo 1👨‍👩‍👧‍👦\", \"F1\", \"Foo 1👨‍👩‍👧‍👦\");\n\n    // Empty spaces.\n    await testName(\"Foo  👨‍👩‍👧‍👦\", \"F\", \"Foo 👨‍👩‍👧‍👦\"); // Spaces between are ignored when getting it from driver.\n    await testName(\"Foo  👨‍👩‍👧‍👦  \", \"F\", \"Foo 👨‍👩‍👧‍👦\");\n    await testName(\"  Foo  👨‍👩‍👧‍👦  \", \"F\", \"Foo 👨‍👩‍👧‍👦\");\n\n    // Complex sequence emojis.\n    await testName(\"👨‍👩‍👧‍👦👨‍👩‍👧‍👦\", \"👨‍👩‍👧‍👦\", \"👨‍👩‍👧‍👦\");\n    await testName(\"👨‍👩‍👧‍👦👨‍👩‍👧‍👦1\", \"👨‍👩‍👧‍👦\", \"👨‍👩‍👧‍👦1\");\n    await testName(\"1👨‍👩‍👧‍👦👨‍👩‍👧‍👦 Foo\", \"1F\", \"1👨‍👩‍👧‍👦👨‍👩‍👧‍👦 Foo\");\n    await testName(\"👨‍👩‍👧‍👦👨‍👩‍👧‍👦Foo Bar\", \"👨‍👩‍👧‍👦\", \"👨‍👩‍👧‍👦Foo Bar\");\n  });\n\n  it(\"should correctly detect initials in doc names with special symbols\", async function() {\n    // Only letters and digits all allowed in the initials.\n    await testName(\"A !A\", \"A\", \"A !A\");\n    await testName(\"A @A\", \"A\", \"A @A\");\n    await testName(\"A (Copy)\", \"A\", \"A (Copy)\");\n    await testName(\"A (Copy) B\", \"A\", \"A (Copy) B\");\n  });\n\n  it(\"should ignore emoji if icon is set\", async function() {\n    // Now special symbols like, they should be ignored if the second word starts with it.\n    await testNameWithIcon(\"Foo Bar\");\n    await testNameWithIcon(\"😀 Foo Bar\");\n    await testNameWithIcon(\"😀😀 Foo Bar\");\n\n    // Restore original name.\n    await openDialog();\n    await driver.find(\".test-dm-reset-icon\").click();\n    await confirm();\n    await testName(\"Doc22\", \"D\");\n  });\n\n  it(\"should filter the docs via the workspace sidepane\", async function() {\n    // Assert that initially all doc blocks are visible\n    await driver.find(\".test-dm-all-docs\").click();\n    await driver.findContent(\".test-dm-doclist .test-component-tabs-tab\", /All/).click();\n    assert.deepEqual(\n      await getDocNames(),\n      [\n        \"Doc01\",\n        \"Doc02\",\n        \"Doc03\",\n        \"Doc04\",\n        \"Doc05\",\n        \"Doc06\",\n        \"Doc07\",\n        \"Doc08\",\n        \"Doc09\",\n        \"Doc10\",\n        \"Doc11\",\n        \"Doc12\",\n        \"Doc13\",\n        \"Doc14\",\n        \"Doc15\",\n        \"Doc16\",\n        \"Doc18\",\n        \"Doc19\",\n        \"Doc20\",\n        \"Doc21\",\n        \"Doc22\",\n        \"Doc23\",\n        \"One doc to rule them all with a long name and a strong fist\",\n      ],\n    );\n\n    // Assert that clicking a workspace in the sidepane filters the doclist and the filtered\n    // docs are as expected.\n    await driver.findContent(\".test-dm-workspace\", /August/).doClick();\n    assert.deepEqual(await getDocNames(), [\"Doc22\", \"Doc23\"]);\n\n    // Assert that clicking back on the 'All Documents' buttons once again shows all the docs\n    await driver.find(\".test-dm-all-docs\").doClick();\n    await driver.findContent(\".test-dm-doclist .test-component-tabs-tab\", /All/).click();\n    assert.deepEqual(\n      await getDocNames(),\n      [\n        \"Doc01\",\n        \"Doc02\",\n        \"Doc03\",\n        \"Doc04\",\n        \"Doc05\",\n        \"Doc06\",\n        \"Doc07\",\n        \"Doc08\",\n        \"Doc09\",\n        \"Doc10\",\n        \"Doc11\",\n        \"Doc12\",\n        \"Doc13\",\n        \"Doc14\",\n        \"Doc15\",\n        \"Doc16\",\n        \"Doc18\",\n        \"Doc19\",\n        \"Doc20\",\n        \"Doc21\",\n        \"Doc22\",\n        \"Doc23\",\n        \"One doc to rule them all with a long name and a strong fist\",\n      ],\n    );\n  });\n\n  it(\"should allow adding, removing, and renaming docs\", async function() {\n    // Add a doc. Note that we prevent the loading of new pages in before().\n    await openAddNew();\n    await driver.find(\".test-dm-new-doc\").doClick();\n    assert.deepEqual(await getDocNames(\"August\"), [\"Doc22\", \"Doc23\", \"Untitled document\"]);\n\n    // Assert that the added doc modified time is as expected\n    assert.deepEqual(\n      await getDocTimes(\"August\"),\n      [\"Edited a few seconds ago\", \"Edited a few seconds ago\", \"Edited a few seconds ago\"],\n    );\n\n    // Rename the doc.\n    await openDocMenu(/Untitled document/);\n    await driver.findWait(\".test-dm-rename-doc\", 100).doClick();\n    await driver.findWait(\".test-modal-dialog\", 100);\n    await gu.waitForFocus(\"#name\");\n    await driver.sendKeys(\"NewDocRenamed\", Key.ENTER);\n    assert.deepEqual(await getDocNames(\"August\"), [\"Doc22\", \"Doc23\", \"NewDocRenamed\"]);\n\n    // Start to delete the doc, check that cancelling works.\n    await openDocMenu(/NewDocRenamed/);\n    await driver.findWait(\".test-dm-delete-doc\", 100).doClick();\n    await driver.findWait(\".test-modal-cancel\", 100).doClick();\n    assert.deepEqual(await getDocNames(\"August\"), [\"Doc22\", \"Doc23\", \"NewDocRenamed\"]);\n\n    // Delete the doc.\n    await openDocMenu(/NewDocRenamed/);\n    await driver.findWait(\".test-dm-delete-doc\", 100).doClick();\n    await driver.findWait(\".test-modal-confirm\", 100).doClick();\n    assert.deepEqual(await getDocNames(\"August\"), [\"Doc22\", \"Doc23\"]);\n  });\n\n  it(\"should allow adding, removing, and renaming workspaces\", async function() {\n    // Start adding a workspace, check that cancelling works.\n    await openAddNew();\n    await driver.findWait(\".test-dm-new-workspace\", 100).doClick();\n    await driver.sendKeys(Key.ESCAPE);\n    let wsNames = await driver.findAll(\".test-dm-workspace\", e => e.getText());\n    assert.deepEqual(wsNames, [\"August\", \"Personal\", \"Real estate\"]);\n\n    // Add a workspace.\n    await addWorkspace(\"October\");\n    wsNames = await driver.findAll(\".test-dm-workspace\", e => e.getText());\n    assert.deepEqual(wsNames, [\"August\", \"October\", \"Personal\", \"Real estate\"]);\n\n    // Rename the workspace.\n    await openWorkspaceMenu(/October/);\n    await driver.findWait(\".test-dm-rename-workspace\", 100).doClick();\n    await gu.waitForFocus(\".test-dm-ws-name-editor\");\n    await driver.find(\".test-dm-ws-name-editor\").sendKeys(\"WorkspaceRenamed\", Key.ENTER);\n    wsNames = await driver.findAll(\".test-dm-workspace\", e => e.getText());\n    assert.deepEqual(wsNames, [\"August\", \"Personal\", \"Real estate\", \"WorkspaceRenamed\"]);\n\n    // Start to delete the workspace, check that cancelling works.\n    await openWorkspaceMenu(/WorkspaceRenamed/);\n    await driver.findWait(\".test-dm-delete-workspace\", 100).doClick();\n    await driver.findWait(\".test-modal-cancel\", 100).click();\n    wsNames = await driver.findAll(\".test-dm-workspace\", e => e.getText());\n    assert.deepEqual(wsNames, [\"August\", \"Personal\", \"Real estate\", \"WorkspaceRenamed\"]);\n\n    await addWorkspace(\"Z1\");\n    await addWorkspace(\"Z2\");\n    await addWorkspace(\"Z3\");\n\n    // Delete not selected workspace.\n    const currentUrl = await driver.getCurrentUrl();\n    await deleteWorkspace(\"WorkspaceRenamed\");\n    wsNames = await driver.findAll(\".test-dm-workspace\", e => e.getText());\n    assert.deepEqual(wsNames, [\"August\", \"Personal\", \"Real estate\", \"Z1\", \"Z2\", \"Z3\"]);\n    // Make sure the URL is not changed.\n    assert.equal(await driver.getCurrentUrl(), currentUrl);\n\n    // Delete selected workspace.\n    await selectWs(\"Z2\");\n    await deleteWorkspace(\"Z2\");\n    assert.equal(await selectedWs(), \"Z3\");\n\n    // Delete last one, Real estate should be selected.\n    await selectWs(\"Z3\");\n    await deleteWorkspace(\"Z3\");\n    assert.equal(await selectedWs(), \"Z1\");\n    await deleteWorkspace(\"Z1\");\n\n    async function addWorkspace(name: string) {\n      await openAddNew();\n      await driver.findWait(\".test-dm-new-workspace\", 100).click();\n      await driver.findWait(\".test-dm-ws-name-editor\", 100).sendKeys(name, Key.ENTER);\n    }\n\n    async function deleteWorkspace(name: string) {\n      await openWorkspaceMenu(gu.exactMatch(name));\n      await driver.findWait(\".test-dm-delete-workspace\", 100).doClick();\n      await driver.findWait(\".test-modal-confirm\", 100).click();\n    }\n\n    async function selectWs(name: string) {\n      await driver.findContent(\".test-dm-workspace\", gu.exactMatch(name)).click();\n    }\n\n    async function selectedWs() {\n      if (!await driver.find(\".test-dm-workspace-selected\").isPresent()) { return null; }\n      return await driver.find(\".test-dm-workspace-selected\").getText();\n    }\n  });\n\n  it(\"should allow add/import options only with workspace edit access\", async function() {\n    // Select \"Real estate\" workspace. It's view-only.\n    await driver.findContent(\".test-dm-workspace\", /Real estate/).click();\n\n    // Open the Add menu; Create/Import options should be disabled, and not work.\n    await openAddNew();\n    assert.include((await driver.find(\".test-dm-new-doc\").getAttribute(\"className\")).split(/\\s+/), \"disabled\");\n    assert.include((await driver.find(\".test-dm-import\").getAttribute(\"className\")).split(/\\s+/), \"disabled\");\n    const docs1 = await getDocNames();\n    await driver.find(\".test-dm-new-doc\").click();\n    assert.deepEqual(await getDocNames(), docs1);\n\n    // Hit escape to close the menu\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Select August workspace. We are an owner of it.\n    await driver.findContent(\".test-dm-workspace\", /August/).click();\n\n    // Open the Add menu; the Create/Import option should be enabled, and should create a doc.\n    await openAddNew();\n    await gu.findOpenMenu();\n    assert.notInclude((await driver.find(\".test-dm-new-doc\").getAttribute(\"className\")).split(/\\s+/), \"disabled\");\n    assert.notInclude((await driver.find(\".test-dm-import\").getAttribute(\"className\")).split(/\\s+/), \"disabled\");\n    assert.deepEqual(await getDocNames(), [\"Doc22\", \"Doc23\"]);\n    await driver.find(\".test-dm-new-doc\").click();\n    assert.deepEqual(await getDocNames(), [\"Doc22\", \"Doc23\", \"Untitled document\"]);\n  });\n\n  it(\"should prevent rename and delete actions without access\", async function() {\n    // Try to rename a workspace with view only access.\n    await openWorkspaceMenu(/Real estate/);\n    await driver.findWait(\".test-dm-rename-workspace\", 100).doClick();\n    assert.equal(await driver.find(\".test-dm-ws-name-editor\").isPresent(), false);\n\n    // Click on a disabled item doesn't close the menu\n    assert.strictEqual(await driver.find(\".test-dm-rename-workspace\").isDisplayed(), true);\n\n    // Try to delete a workspace with view only access.\n    await driver.find(\".test-dm-delete-workspace\").click();\n    assert.equal(await driver.find(\".test-modal-cancel\").isPresent(), false);\n    await driver.find(\".test-dm-ws-label\").click();     // click-away to close menu\n\n    // Try to rename/delete a doc with view only access.\n    await driver.find(\".test-dm-all-docs\").click();\n    await openDocMenu(/Doc09/);\n    await driver.find(\".test-dm-rename-doc\").doClick();\n    assert.equal(await driver.find(\".test-modal-cancel\").isPresent(), false);\n    await driver.find(\".test-dm-delete-doc\").doClick();\n    assert.equal(await driver.find(\".test-modal-cancel\").isPresent(), false);\n    // Hit escape to close the menu\n    await driver.sendKeys(Key.ESCAPE);\n    await gu.notPresent(\".test-dm-rename-doc\");\n  });\n\n  it(\"should show pinned docs\", async function() {\n    // Initially 3 docs are pinned.\n    assert.deepEqual(\n      await getPinnedDocNames(),\n      [\"One doc to rule them all with a long name and a strong fist\", \"Doc22\", \"Doc13\"],\n    );\n\n    // Switch to each workspace and ensure that only that workspace's docs are shown pinned.\n    await driver.findContent(\".test-dm-workspace\", /August/).click();\n    assert.deepEqual(await getPinnedDocNames(), [\"Doc22\"]);\n\n    await driver.findContent(\".test-dm-workspace\", /Personal/).click();\n    assert.deepEqual(await getPinnedDocNames(),\n      [\"Doc13\", \"One doc to rule them all with a long name and a strong fist\"]);\n\n    await driver.findContent(\".test-dm-workspace\", /Real estate/).click();\n    assert.deepEqual(await getPinnedDocNames(), []);\n  });\n\n  it(\"should allow pinning/unpinning docs\", async function() {\n    // Switch to 'All Documents', unpin all docs.\n    await driver.find(\".test-dm-all-docs\").click();\n    await openDocMenu(/Doc13/);\n    await driver.find(\".test-dm-pin-doc\").click();\n    await openDocMenu(/Doc22/);\n    await driver.find(\".test-dm-pin-doc\").click();\n    await openDocMenu(/One doc/);\n    await driver.find(\".test-dm-pin-doc\").click();\n    assert.deepEqual(await getPinnedDocNames(), []);\n\n    // Pin a doc.\n    await openDocMenu(/Doc22/);\n    await driver.find(\".test-dm-pin-doc\").doClick();\n    assert.deepEqual(await getPinnedDocNames(), [\"Doc22\"]);\n\n    // Pin another doc.\n    await openDocMenu(/Doc11/);\n    await driver.find(\".test-dm-pin-doc\").doClick();\n    assert.deepEqual(await getPinnedDocNames(), [\"Doc22\", \"Doc11\"]);\n\n    // Check that a pinned doc can be fully removed.\n    await openDocMenu(/Doc11/);\n    await driver.find(\".test-dm-delete-doc\").doClick();\n    await driver.find(\".test-modal-confirm\").doClick();\n    assert.deepEqual(await getPinnedDocNames(), [\"Doc22\"]);\n\n    // Check that a pinned doc can be unpinned.\n    await openDocMenu(/Doc22/);\n    await driver.find(\".test-dm-pin-doc\").doClick();\n    assert.isEmpty(await getPinnedDocNames());\n  });\n\n  it(\"should allow downloading docs\", async function() {\n    await openDocMenu(/Doc13/);\n    await driver.findContent(\".test-dm-tb-share-option\", /Download document/).doClick();\n    await driver.findWait(\".test-option-full\", 2000).isDisplayed();\n    assert.match(await driver.find(\".test-download-button-link\").getAttribute(\"href\"), /\\/mock\\/download\\/url/);\n    await driver.sendKeys(Key.ESCAPE);\n  });\n\n  it(\"should allow moving docs\", async function() {\n    await openDocMenu(/Doc13/);\n    await driver.find(\".test-dm-move-doc\").doClick();\n\n    // Check that the destination workspace options are as expected.\n    let destinations = await driver.findAll(\".test-dm-dest-ws\");\n    assert.lengthOf(destinations, 3);\n    assert.equal(await destinations[0].getText(), \"August\");\n\n    // The last two should be disabled and contain explanations.\n    assert.equal(await destinations[1].getText(), \"Personal\\nCurrent workspace\");\n    assert.isTrue(await destinations[1].matches(\".test-dm-dest-ws[class*=-disabled]\"));\n    assert.equal(await destinations[2].getText(), \"Real estate\\nRequires edit permissions\");\n    assert.isTrue(await destinations[2].matches(\".test-dm-dest-ws[class*=-disabled]\"));\n\n    // Assert that the modal confirm button is also disabled before anything is selected.\n    assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n\n    // Select the only valid destination.\n    await destinations[0].doClick();\n    assert.isTrue(await destinations[0].matches(\".test-dm-dest-ws[class*=-selected]\"));\n    await driver.find(\".test-modal-confirm\").doClick();\n\n    // Check that the doc is now in August.\n    await driver.findContent(\".test-dm-workspace\", /August/).doClick();\n    assert.deepInclude(await getDocNames(), \"Doc13\");\n\n    // Move the doc back.\n    await openDocMenu(/Doc13/);\n    await driver.find(\".test-dm-move-doc\").doClick();\n\n    // Check that the destination workspace options are as expected.\n    destinations = await driver.findAll(\".test-dm-dest-ws\");\n    assert.lengthOf(destinations, 3);\n    assert.equal(await destinations[0].getText(), \"August\\nCurrent workspace\");\n    assert.isTrue(await destinations[0].matches(\".test-dm-dest-ws[class*=-disabled]\"));\n    assert.equal(await destinations[1].getText(), \"Personal\");\n    assert.equal(await destinations[2].getText(), \"Real estate\\nRequires edit permissions\");\n    assert.isTrue(await destinations[2].matches(\".test-dm-dest-ws[class*=-disabled]\"));\n\n    // Complete the move and check that the doc is back in Personal.\n    await destinations[1].doClick();\n    await driver.find(\".test-modal-confirm\").doClick();\n    await driver.findContent(\".test-dm-workspace\", /Personal/).doClick();\n    assert.deepInclude(await getDocNames(), \"Doc13\");\n  });\n});\n\n// Helper function to open the Add menu.\nasync function openAddNew() {\n  await driver.find(\".test-dm-add-new\").click();\n  await gu.findOpenMenu();\n}\n\nasync function openDialog() {\n  await gu.rightClick(driver.find(\".test-dm-doc\"));\n  await gu.findOpenMenuItem(\"li\", /Rename and set icon/).click();\n  assert.isTrue(await driver.findWait(\".test-modal-dialog\", 100).isDisplayed());\n}\n\nasync function typeName(name: string) {\n  await driver.find(\".test-modal-dialog #name\").click();\n  await gu.clearInput();\n  await gu.sendKeys(name);\n}\n\nasync function checkInitials(initials: string, name: string) {\n  await gu.waitToPass(async () => {\n    assert.equal(\n      await driver.find(\".test-dm-doc-icon-preview\").getText(),\n      initials,\n      `Expected \"${initials}\" for \"${name}\"`,\n    );\n  }, 100);\n}\n\nasync function confirm() {\n  await driver.find(\".test-modal-confirm\").click();\n  await gu.notPresent(\".test-modal-dialog\");\n}\n\nasync function cancel() {\n  await driver.find(\".test-modal-cancel\").click();\n  await gu.notPresent(\".test-modal-dialog\");\n}\n\nasync function checkDocName(name: string) {\n  assert.equal(\n    await driver.find(\".test-dm-doc-name\").getText(),\n    name,\n  );\n}\n\nasync function testName(enter: string, initials: string, docName?: string) {\n  await openDialog();\n  await typeName(enter);\n  await checkInitials(initials, enter);\n  await confirm();\n  assert.equal(\n    await driver.find(\".test-dm-doc-name\").getText(),\n    docName ?? enter,\n  );\n  await checkDocName(docName ?? enter);\n}\n\nasync function testNameWithIcon(docName: string) {\n  await openDialog();\n  await typeName(docName);\n  await driver.find(\".test-dm-choose-icon\").click();\n  // Find span with emoji 📋\n  await gu.waitForDisplay(\"em-emoji-picker\");\n  await driver.executeScript(() => {\n    const emojiPicker = document.querySelector(\"em-emoji-picker\")?.shadowRoot;\n    const emojiSpan = Array.from(emojiPicker?.querySelectorAll(\"span\") || [])\n      .find(el => el.textContent === \"📋\")!;\n    emojiSpan?.click();\n  });\n  await gu.waitForContent(\".test-dm-doc-icon-preview\", \"📋\");\n  await confirm();\n  await checkDocName(docName);\n}\n"
  },
  {
    "path": "test/projects/DocumentSettings.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"DocumentSettings\", () => {\n  setupTestSuite();\n\n  before(async function() {\n    this.timeout(60000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/DocumentSettings`);\n  });\n\n  it(\"should call save() with new settings when changing values\", async function() {\n    this.timeout(4000);\n\n    await driver.findWait(\".test-tz-autocomplete\", 500).click();\n    await driver.findContent(\".test-acselect-dropdown li\", /Europe\\/Paris/).click();\n    assert.equal(await driver.find(\".test-result-timezone\").value(), \"Europe/Paris\");\n\n    await driver.findWait(\".test-settings-locale-autocomplete\", 500).click();\n    await driver.findContent(\".test-acselect-dropdown li\", /Spain \\(Spanish\\)/).click();\n    assert.equal(await driver.find(\".test-result-locale\").value(), \"es-ES\");\n\n    await driver.findWait(\".test-currency-autocomplete\", 500).click();\n    await driver.findContent(\".test-acselect-dropdown li\", /Yen/).click();\n    assert.equal(await driver.find(\".test-result-currency\").value(), \"JPY\");\n  });\n\n  it(\"should reflect changes from server\", async function() {\n    // Set new value for timezone, then open the dialog.\n    await driver.find(\".test-result-timezone\").doClear().sendKeys(\"America/Los_Angeles\", Key.ENTER);\n\n    // Check that timezone has the new value.\n    assert.equal(await driver.find(\".test-tz-autocomplete input\").value(), \"America/Los_Angeles\");\n\n    // Check that locale and currency have the values from earlier.\n    assert.equal(await driver.find(\".test-settings-locale-autocomplete input\").value(), \"Spain (Spanish)\");\n    assert.equal(await driver.find(\".test-currency-autocomplete input\").value(), \"JPY\");\n  });\n});\n"
  },
  {
    "path": "test/projects/Icons.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"Icons\", () => {\n  setupTestSuite();\n\n  before(async function() {\n    this.timeout(90000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/Icons`);\n  });\n\n  it(\"should display all icons\", async function() {\n    const icons = await driver.findWait(\"#all_icons\", 5000).findAll(\"#all_icons > div > *\");\n    assert.isAtLeast(icons.length, 10);\n  });\n\n  it(\"should have correct icon size\", async function() {\n    const searchIcon = await driver.find(\"#search_icon > div\");\n    assert.equal((await searchIcon.rect()).width, 16);\n  });\n\n  it(\"should allow resizing icons\", async function() {\n    const bigSearchIcon = await driver.find(\"#big_search_icon > div\");\n    assert.equal((await bigSearchIcon.rect()).width, 32);\n  });\n});\n"
  },
  {
    "path": "test/projects/Mentions.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { addToRepl, assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"Mentions\", function() {\n  setupTestSuite();\n  addToRepl(\"getInput\", getInput);\n  addToRepl(\"getOutput\", getOutput);\n  addToRepl(\"click\", click);\n  addToRepl(\"type\", type);\n  addToRepl(\"clear\", clear);\n  addToRepl(\"Key\", Key);\n  this.timeout(\"30s\");\n\n  before(async function() {\n    await driver.get(`${server.getHost()}/Mentions`);\n    await getInput(10000);\n  });\n\n  afterEach(async function() {\n    // Clear the input after each test to avoid interference.\n    await click();\n    await waitForFocus();\n    await clear();\n    // Ensure the output is also cleared.\n    const output = await getOutput();\n    assert.equal(await output.getText(), \"\");\n  });\n\n  it(\"has always focus\", async function() {\n    await waitForFocus();\n    // Click away to ensure the input is focused.\n    await driver.find(\".test-away\").click();\n    await waitForFocus();\n    // Click the input again to ensure it remains focused.\n    await click();\n    await waitForFocus();\n    // Open the menu to ensure it doesn't lose focus.\n    await type(\"@\");\n    await gu.findOpenMenu();\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n    await waitForFocus();\n  });\n\n  it(\"works with plain text\", async function() {\n    const input = await getInput();\n    await input.click();\n    await input.sendKeys(\"Hello, this is a test.\");\n    const output = await getOutput();\n    assert.strictEqual(await output.getText(), \"Hello, this is a test.\");\n    await clear();\n    assert.strictEqual(await output.getText(), \"\");\n  });\n\n  it(\"works with mentions\", async function() {\n    await click();\n    await type(\"Hello @\");\n    await gu.findOpenMenu();\n    await type(Key.ARROW_DOWN);\n    await type(Key.ENTER);\n    await gu.waitForMenuToClose();\n    const output = await getOutput();\n    assert.strictEqual(await output.getText(), \"Hello [@Alice](user:alice)\");\n  });\n\n  async function typeWithAccept(accept: () => Promise<void>) {\n    await click();\n    await type(\"Hello @\");\n    await gu.findOpenMenu();\n    await type(\"Alice\");\n    await accept();\n    await gu.waitForMenuToClose();\n    const output = await getOutput();\n    assert.strictEqual(await output.getText(), \"Hello [@Alice](user:alice)\");\n  }\n\n  it(\"accepts by pressing enter\", async function() {\n    await typeWithAccept(async () => {\n      await type(Key.ENTER);\n    });\n    await click(); // Click to ensure the input is focused again.\n  });\n\n  it(\"accepts by pressing tab\", async function() {\n    await typeWithAccept(async () => {\n      await type(Key.TAB);\n    });\n    await click(); // Click to ensure the input is focused again.\n  });\n\n  it(\"accepts by clicking\", async function() {\n    await typeWithAccept(async () => {\n      await clickItem(\"Alice\");\n    });\n  });\n\n  it(\"removes span by pressing backspace\", async function() {\n    await click();\n    await type(\"Hello @Alice\");\n    await gu.findOpenMenu();\n    await type(...Array.from(\"Alice\").map(() => Key.BACK_SPACE));\n    await type(Key.BACK_SPACE);\n    await gu.waitForMenuToClose();\n    const output = await getOutput();\n    assert.strictEqual((await output.getText()).trim(), \"Hello\");\n  });\n\n  it(\"deletes span by pressing backspace\", async function() {\n    await click();\n    await type(\"Hello @Al\");\n    await gu.findOpenMenu();\n    await type(\"ice\");\n    await type(Key.ENTER);\n    await gu.waitForMenuToClose();\n\n    // Sanity check for the output.\n    const output = await getOutput();\n    assert.strictEqual(await output.getText(), \"Hello [@Alice](user:alice)\");\n  });\n\n  it(\"converts to plain text by clicking away\", async function() {\n    await click();\n    await type(\"Hello @Alice\");\n    await gu.findOpenMenu();\n    await driver.find(\".test-away\").click();\n    await gu.waitForMenuToClose();\n    const output = await getOutput();\n    assert.strictEqual((await output.getText()).trim(), \"Hello @Alice\");\n  });\n\n  it(\"converts to plain text by pressing escape\", async function() {\n    await click();\n    await type(\"Hello @Alice\");\n    await gu.findOpenMenu();\n    await type(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n    const output = await getOutput();\n    assert.strictEqual((await output.getText()).trim(), \"Hello @Alice\");\n  });\n\n  it(\"doesn't show html tags in input\", async function() {\n    await driver.executeScript(() => {\n      (window as any).initial.set(\"Hello <b>world</b>\");\n    });\n    assert.isFalse(await getInput().find(\"b\").isPresent());\n    assert.equal(await getInput().getText(), \"Hello <b>world</b>\");\n  });\n\n  it(\"parses user links in correct way\", async function() {\n    await driver.executeScript(() => {\n      (window as any).initial.set(\"Hello [@Alice](user:alice)\");\n    });\n    assert.isTrue(await getInput().find(\".grist-mention\").isDisplayed());\n    assert.equal(await getInput().find(\".grist-mention\").getText(), \"@Alice\");\n    assert.equal(await getInput().find(\".grist-mention\").getAttribute(\"data-userref\"), \"alice\");\n    assert.equal(await getInput().find(\".grist-mention\").getAttribute(\"contenteditable\"), \"false\");\n  });\n\n  it(\"starts picker only once\", async function() {\n    await click();\n    await type(\"Hello @\");\n    await gu.findOpenMenu();\n    for (let i = 0; i < 10; i++) {\n      await type(\"@\");\n    }\n    await driver.sleep(100);\n    // Count the number of open menus.\n    const menus = await driver.findAll(\".grist-floating-menu\");\n    assert.strictEqual(menus.length, 1, \"Picker should only be opened once\");\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n  });\n\n  it(\"trims spaces in markdown\", async function() {\n    await click();\n    await type(\"  \");\n    assert.equal(await getInput().getText(), \"  \");\n    assert.equal(await getOutput().getText(), \"\");\n    await clear();\n\n    await type(\"a  \");\n    assert.equal(await getInput().getText(), \"a  \");\n    assert.equal(await getOutput().getText(), \"a\");\n  });\n\n  it(\"shows loading information\", async function() {\n    await clearData();\n    await click();\n    await type(\"Hello @\");\n    await gu.findOpenMenu();\n\n    // Make sure it reports back that it is not ready.\n    assert.isFalse(await driver.find(\".test-mention-textbox-ready\").isPresent());\n    // And it shows loading state.\n    assert.isTrue(await driver.find(\".test-mention-textbox-loading\").isDisplayed());\n    // Try to click it, it should not do anything.\n    await driver.find(\".test-mention-textbox-loading\").click();\n    // Make sure we still have menu.\n    await gu.findOpenMenu();\n\n    // And editor still has @.\n    assert.equal(await getInput().getText(), \"Hello @\\n...loading\");\n\n    // Now load the data, simulating a delay.\n    await loadData();\n\n    // Wait for the loading to finish.\n    await gu.waitToPass(async () => {\n      assert.isFalse(await driver.find(\".test-mention-textbox-loading\").isPresent());\n      assert.isTrue(await driver.find(\".test-mention-textbox-ready\").isDisplayed());\n    });\n\n    // We now should see full list of users.\n    const items = await driver.findAll(\".test-mention-textbox-acitem\", e => e.getText());\n    assert.deepEqual(items.map(s => s.substring(2)), [\"Alice\", \"Bob\", \"Charlie\", \"Dave\"]);\n    await gu.sendKeys(Key.ESCAPE);\n    await gu.waitForMenuToClose();\n  });\n});\n\nasync function click() {\n  const input = await getInput();\n  await input.click();\n  assert.isTrue(await input.hasFocus(), \"Input should be focused\");\n}\n\nasync function type(...keys: string[]) {\n  await driver.sendKeys(...keys);\n}\n\nasync function waitForFocus() {\n  await gu.waitToPass(async () => {\n    assert.isTrue(await driver.find(\".test-input\").hasFocus(), \"Input should be focused\");\n  }, 100);\n}\n\nfunction getInput(ts = 1000) {\n  return driver.findWait(\".test-input\", ts);\n}\n\nfunction getOutput() {\n  return driver.find(\".test-output\");\n}\n\nfunction clickItem(item: string) {\n  return driver.findContent(\".test-mention-textbox-acitem-text\", item).click();\n}\n\nasync function clear() {\n  await gu.clearInput();\n}\n\nasync function clearData() {\n  await driver.executeScript(() => {\n    (window as any).clearData();\n  });\n}\n\nasync function loadData() {\n  await driver.executeScript(() => {\n    (window as any).loadData();\n  });\n}\n"
  },
  {
    "path": "test/projects/MultiSelector.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"MultiSelector\", () => {\n  setupTestSuite();\n\n  before(async function() {\n    this.timeout(60000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/MultiSelector`);\n  });\n\n  it(\"should start with no columns selected\", async function() {\n    const items = await driver.find(\".test-ms-list\").findAll(\".test-ms-item\");\n    assert.equal(items.length, 0);\n    assert.deepEqual(JSON.parse(await driver.find(\"pre\").getText()), []);\n  });\n\n  it(\"should allow adding a column with no option selected and set its value\", async function() {\n    await driver.find(\".test-ms-add-btn\").doClick();\n    const addItem = await driver.find(\".test-ms-list\").find(\".test-ms-add-item\");\n    assert.equal(await addItem.find(\"select\").value(), \"Select state\");\n    assert.deepEqual(JSON.parse(await driver.find(\"pre\").getText()), []);\n\n    // 50 states plus the empty select\n    assert.equal((await addItem.findAll(\"option\")).length, 51);\n\n    await addItem.find(\"select\").doClick();\n    await addItem.findContent(\"select > option\", /New York/).doClick();\n\n    const items = await driver.find(\".test-ms-list\").findAll(\".test-ms-item\");\n    assert.equal(items.length, 1);\n    assert.deepEqual(JSON.parse(await driver.find(\"pre\").getText()), [\n      { label: \"New York\", value: \"NY\" },\n    ]);\n  });\n\n  it(\"should allow adding a second column\", async function() {\n    await driver.find(\".test-ms-add-btn\").doClick();\n\n    assert.equal((await driver.find(\".test-ms-list\").findAll(\".test-ms-item\")).length, 1);\n\n    const addItem = await driver.find(\".test-ms-list\").find(\".test-ms-add-item\");\n    assert.equal(await addItem.find(\"select\").value(), \"Select state\");\n\n    await addItem.find(\"select\").doClick();\n    await addItem.findContent(\"select > option\", /Alaska/).doClick();\n\n    assert.equal((await driver.find(\".test-ms-list\").findAll(\".test-ms-item\")).length, 2);\n    assert.deepEqual(JSON.parse(await driver.find(\"pre\").getText()), [\n      { label: \"New York\", value: \"NY\" },\n      { label: \"Alaska\", value: \"AK\" },\n    ]);\n  });\n\n  it(\"should allow changing values\", async function() {\n    assert.deepEqual(JSON.parse(await driver.find(\"pre\").getText()), [\n      { label: \"New York\", value: \"NY\" },\n      { label: \"Alaska\", value: \"AK\" },\n    ]);\n\n    const items = await driver.find(\".test-ms-list\").findAll(\".test-ms-item\");\n    await items[0].find(\"select\").doClick();\n    await items[0].findContent(\"select > option\", /New Jersey/).doClick();\n    await items[1].find(\"select\").doClick();\n    await items[1].findContent(\"select > option\", /Rhode Island/).doClick();\n\n    assert.deepEqual(JSON.parse(await driver.find(\"pre\").getText()), [\n      { label: \"New Jersey\", value: \"NJ\" },\n      { label: \"Rhode Island\", value: \"RI\" },\n    ]);\n  });\n\n  it(\"should allow removing a column\", async function() {\n    assert.deepEqual(JSON.parse(await driver.find(\"pre\").getText()), [\n      { label: \"New Jersey\", value: \"NJ\" },\n      { label: \"Rhode Island\", value: \"RI\" },\n    ]);\n\n    const items = await driver.find(\".test-ms-list\").findAll(\".test-ms-item\");\n    await items[0].find(\".test-ms-remove-btn\").doClick();\n\n    assert.equal((await driver.find(\".test-ms-list\").findAll(\".test-ms-item\")).length, 1);\n    assert.deepEqual(JSON.parse(await driver.find(\"pre\").getText()), [\n      { label: \"Rhode Island\", value: \"RI\" },\n    ]);\n\n    await items[1].find(\".test-ms-remove-btn\").doClick();\n\n    assert.equal((await driver.find(\".test-ms-list\").findAll(\".test-ms-item\")).length, 0);\n    assert.deepEqual(JSON.parse(await driver.find(\"pre\").getText()), []);\n  });\n});\n"
  },
  {
    "path": "test/projects/NotifyBar.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, stackWrapFunc, until } from \"mocha-webdriver\";\n\ndescribe(\"NotifyBar\", function() {\n  setupTestSuite();\n  this.timeout(10000);      // Set a longer default timeout.\n\n  before(async function() {\n    this.timeout(90000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/ErrorNotify`);\n  });\n\n  describe(\"toasts\", function() {\n    it(\"should allow creating default user errors\", async function() {\n      assert.equal((await toasts()).length, 0);\n      await driver.find(\".user-error-default\").click();\n      assert.equal((await toasts()).length, 1);\n      const toast = await lastToast();\n      await driver.wait(until.elementIsVisible(toast), 1000);\n      await driver.wait(until.stalenessOf(toast), 3000);\n      assert.equal((await toasts()).length, 0);\n    });\n\n    it(\"should allow creating user errors with custom (2 sec) timeout\", async function() {\n      this.timeout(3000); // 3 seconds\n      assert.equal((await toasts()).length, 0);\n      await driver.find(\".user-error-2sec\").click(); // 2 seconds\n      assert.equal((await toasts()).length, 1);\n      const toast = await lastToast();\n      await driver.wait(until.elementIsVisible(toast), 1000);\n      await driver.wait(until.stalenessOf(toast), 4000);\n      assert.equal((await toasts()).length, 0);\n    });\n  });\n});\n\nconst toasts = stackWrapFunc(async () => await driver.findAll(\".test-notifier-toast-wrapper\"));\nconst lastToast = stackWrapFunc(async () => await driver.find(\".test-notifier-toast-wrapper:last-child\"));\n"
  },
  {
    "path": "test/projects/OnBoardingPopups.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, stackWrapFunc } from \"mocha-webdriver\";\n\nconst getLogs = stackWrapFunc(() => driver.findAll(\".test-logs\", e => e.getText()));\n\ndescribe(\"OnBoardingPopups\", function() {\n  setupTestSuite();\n\n  before(async function() {\n    this.timeout(60000);\n    await driver.get(`${server.getHost()}/OnBoardingPopups`);\n  });\n\n  it(\"should work correctly\", async () => {\n    // check there are no logs\n    assert.deepEqual(await getLogs(), []);\n\n    // check the popup is not there\n    assert.equal(await driver.find(\".test-onboarding-popup\").isPresent(), false);\n\n    // click start button\n    await driver.findContent(\"button\", /Start/).click();\n\n    // check the popup is there\n    assert.equal(await driver.find(\".test-onboarding-popup\").isPresent(), true);\n\n    // check the content is correct\n    assert.match(await driver.find(\".test-onboarding-popup\").getText(), /add new/);\n\n    // click next\n    await driver.find(\".test-onboarding-next\").click();\n\n    // check the content changed\n    assert.match(await driver.find(\".test-onboarding-popup\").getText(), /Export/);\n\n    // click close\n    await driver.find(\".test-onboarding-close\").click();\n\n    // check the popup has disappeared\n    assert.equal(await driver.find(\".test-onboarding-popup\").isPresent(), false);\n\n    // check the finish is logged\n    assert.deepEqual(await getLogs(), [\"On Boarding FINISHED!\"]);\n\n    // Clear logs\n    await driver.findContent(\"button\", /Reset logs/).click();\n  });\n\n  it(\"should disable next button on last message\", async () => {\n    // click start button\n    await driver.findContent(\"button\", /Start/).click();\n\n    // click next till the end\n    for (let i = 0; i < 4; i++) {\n      const button = await driver.find(\".test-onboarding-next\");\n      assert.equal(await button.getText(), \"Next\");\n      await button.click();\n    }\n\n    // check the content is correct\n    assert.match(await driver.find(\".test-onboarding-popup\").getText(), /Great tools/);\n\n    // check the next button says 'Finish'\n    const button = await driver.find(\".test-onboarding-next\");\n    assert.equal(await button.getText(), \"Finish\");\n\n    // click the finish button\n    await button.click();\n\n    // check finish has been logged\n    assert.deepEqual(await getLogs(), [\"On Boarding FINISHED!\"]);\n\n    // clear logs\n    await driver.findContent(\"button\", /Reset logs/).click();\n  });\n\n  it(\"should add an overlay to prevent from using the rest of the UI\", async () => {\n    // check logs are empty\n    assert.deepEqual(await getLogs(), []);\n\n    // Click Add New and check it added new logs\n    await driver.findContent(\"button\", /Add New/).click();\n    assert.deepEqual(await getLogs(), [\"CLICKED Add New!\"]);\n\n    // start on boarding\n    await driver.findContent(\"button\", /Start/).click();\n\n    // check the popup is present\n    assert.equal(await driver.find(\".test-onboarding-popup\").isPresent(), true);\n\n    // try click Add New\n    try {\n      await driver.findContent(\"button\", /Add New/).click();\n    } catch (e) {\n      assert.match(e.message, /element click intercepted/);\n    }\n\n    // check nore more logs added\n    assert.deepEqual(await getLogs(), [\"CLICKED Add New!\"]);\n\n    // click close\n    await driver.find(\".test-onboarding-close\").click();\n\n    // clear logs\n    await driver.findContent(\"button\", /Reset logs/).click();\n  });\n});\n"
  },
  {
    "path": "test/projects/PagePanels.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key, Origin, stackWrapFunc } from \"mocha-webdriver\";\n\nasync function checkLeftPanelIsCollapsed() {\n  assert.closeTo((await driver.find(\".test-pp-left-panel\").rect()).width, 50, 25);\n}\n\nasync function checkLeftPanelIsExpanded() {\n  assert.isAbove((await driver.find(\".test-pp-left-panel\").rect()).width, 120);\n}\n\nasync function checkLeftPanelIsOverlapping() {\n  assert.equal(\n    (await driver.find(\".test-pp-main-pane\").rect()).left -\n    (await driver.find(\".test-pp-left-panel\").rect()).left,\n    48);\n}\n\ndescribe(\"PagePanels\", function() {\n  setupTestSuite();\n  this.timeout(10000);      // Set a longer default timeout.\n\n  before(async function() {\n    this.timeout(60000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/PagePanels`);\n    // Wait for some element to load\n    await driver.findWait(\".test-pp-addNew\", 100);\n  });\n\n  function dragByX(x: number) {\n    return driver.withActions(a => a.press().move({ x, origin: Origin.POINTER }).release());\n  }\n\n  // Available test elements:\n  //    .test-pp-left-panel\n  //    .test-pp-left-resizer\n  //    .test-pp-left-opener\n  //    .test-pp-right-panel\n  //    .test-pp-right-resizer\n  //    .test-pp-right-opener\n  //    .test-pp-show-right (only in fixture)\n\n  it(\"should allow collapsing left panel\", async function() {\n    // Check that it has a reasonably large width, i.e. is open.\n    assert.isAbove((await driver.find(\".test-pp-left-panel\").rect()).width, 120);\n\n    // Close the panel, and wait for the transition.\n    // Clicking a flipped element in chrome misses the element, the browser works better.\n    await driver.executeScript(\"document.getElementsByClassName('test-pp-left-opener')[0].click()\");\n    await driver.sleep(500);\n    assert.closeTo((await driver.find(\".test-pp-left-panel\").rect()).width, 50, 25);\n\n    // Open the panel, and wait for the transition.\n    await driver.find(\".test-pp-left-opener\").click();\n    await driver.sleep(500);\n    assert.isAbove((await driver.find(\".test-pp-left-panel\").rect()).width, 120);\n  });\n\n  it(\"should allow resizing left panel\", async function() {\n    // Small change is exact (i.e. not limited).\n    const origWidth = (await driver.find(\".test-pp-left-panel\").rect()).width;\n    await driver.find(\".test-pp-left-resizer\").mouseMove();\n    await dragByX(20);\n    assert.equal((await driver.find(\".test-pp-left-panel\").rect()).width, origWidth + 20);\n\n    // Large change is limited.\n    await dragByX(300);\n    assert.isAbove((await driver.find(\".test-pp-left-panel\").rect()).width, origWidth + 20);\n    assert.isBelow((await driver.find(\".test-pp-left-panel\").rect()).width, origWidth + 320);\n\n    await driver.find(\".test-pp-left-resizer\").mouseMove();\n    await dragByX(-300);\n    assert.isBelow((await driver.find(\".test-pp-left-panel\").rect()).width, origWidth);\n    assert.isAbove((await driver.find(\".test-pp-left-panel\").rect()).width, 100);\n  });\n\n  it(\"should allow collapsing right panel if shown\", async function() {\n    // Check that it has a reasonably large width, i.e. is open.\n    assert.isTrue(await driver.find(\".test-pp-right-panel\").isDisplayed());\n    assert.isAbove((await driver.find(\".test-pp-right-panel\").rect()).width, 120);\n\n    // Close the panel, and wait for the transition.\n    // Clicking a flipped element in chrome misses the element, the browser works better.\n    await driver.executeScript(\"document.getElementsByClassName('test-pp-right-opener')[0].click()\");\n    await driver.sleep(500);\n    assert.equal(await driver.find(\".test-pp-right-panel\").isDisplayed(), false);\n\n    // Open the panel, and wait for the transition.\n    await driver.find(\".test-pp-right-opener\").click();\n    await driver.sleep(500);\n    assert.equal(await driver.find(\".test-pp-right-panel\").isDisplayed(), true);\n    assert.isAbove((await driver.find(\".test-pp-right-panel\").rect()).width, 120);\n\n    // If no right panel, it's not shown, and no collapse icon.\n    await driver.find(\".test-pp-show-right\").click();\n    assert.equal(await driver.find(\".test-pp-right-opener\").isPresent(), false);\n    assert.equal(await driver.find(\".test-pp-right-panel\").isPresent(), false);\n\n    await driver.find(\".test-pp-show-right\").click();\n    assert.equal(await driver.find(\".test-pp-right-opener\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-pp-right-panel\").isDisplayed(), true);\n  });\n\n  it(\"should allow resizing right panel if shown\", async function() {\n    // Small change is exact (i.e. not limited).\n    const origWidth = (await driver.find(\".test-pp-right-panel\").rect()).width;\n    await driver.find(\".test-pp-right-resizer\").mouseMove();\n    await dragByX(-20);\n    assert.equal((await driver.find(\".test-pp-right-panel\").rect()).width, origWidth + 20);\n\n    // Large change is limited.\n    await dragByX(-300);\n    assert.isAbove((await driver.find(\".test-pp-right-panel\").rect()).width, origWidth + 20);\n    assert.isBelow((await driver.find(\".test-pp-right-panel\").rect()).width, origWidth + 320);\n\n    await driver.find(\".test-pp-right-resizer\").mouseMove();\n    await dragByX(300);\n    assert.isAtMost((await driver.find(\".test-pp-right-panel\").rect()).width, origWidth);\n    assert.isAbove((await driver.find(\".test-pp-right-panel\").rect()).width, 100);\n\n    // No handle when panel not shown.\n    await driver.find(\".test-pp-show-right\").click();\n    assert.equal(await driver.find(\".test-pp-right-resizer\").isPresent(), false);\n    assert.equal(await driver.find(\".test-pp-right-panel\").isPresent(), false);\n\n    await driver.find(\".test-pp-show-right\").click();\n    assert.equal(await driver.find(\".test-pp-right-resizer\").isDisplayed(), true);\n    assert.equal(await driver.find(\".test-pp-right-panel\").isDisplayed(), true);\n  });\n\n  describe(\"optimized layout for narrow screen\", async function() {\n    let oldDimensions: gu.WindowDimensions;\n\n    before(async () => {\n      oldDimensions = await gu.getViewportDimensions();\n    });\n\n    after(async () => {\n      const { width, height } = oldDimensions;\n      await gu.setViewportDimensions(width, height);\n    });\n\n    it(\"should show bottom bar if narrow screen\", async function() {\n      // click optimizeNarrowScreen option and check that the bottom bar is not displayed\n      await driver.find(\".test-pp-optimize-narrow-screen\").click();\n      assert.equal(await driver.find(\".test-pp-bottom-footer\").isPresent(), false);\n\n      // shrink window <768px and check the bottom bar is displayed\n      await gu.setViewportDimensions(760, oldDimensions.height);\n      assert.equal(await driver.find(\".test-pp-bottom-footer\").isDisplayed(), true);\n\n      // check that only openers for narrow screen (-ns) shows.\n      assert.equal(await driver.find(\".test-pp-left-opener\").isDisplayed(), false);\n      assert.equal(await driver.find(\".test-pp-right-opener\").isDisplayed(), false);\n      assert.equal(await driver.find(\".test-pp-left-opener-ns\").isDisplayed(), true);\n      assert.equal(await driver.find(\".test-pp-right-opener-ns\").isDisplayed(), true);\n\n      // enlarge window >768px and check the bottom bar is not displayed\n      await gu.setViewportDimensions(770, oldDimensions.height);\n      assert.equal(await driver.find(\".test-pp-bottom-footer\").isPresent(), false);\n\n      // check that only regular openers shows.\n      assert.equal(await driver.find(\".test-pp-left-opener\").isDisplayed(), true);\n      assert.equal(await driver.find(\".test-pp-right-opener\").isDisplayed(), true);\n      assert.equal(await driver.find(\".test-pp-left-opener-ns\").isPresent(), false);\n      assert.equal(await driver.find(\".test-pp-right-opener-ns\").isPresent(), false);\n\n      // shrink window again\n      await gu.setViewportDimensions(760, oldDimensions.height);\n    });\n\n    it(\"should allow collapsing left panel\", async function() {\n      // When screen shrinks, panels get closed.\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n\n      // Open the panel\n      await driver.find(\".test-pp-left-opener-ns\").click();\n      await driver.sleep(500);\n      assert.equal(await isSidePanelOpen(\"left\"), true);\n\n      // Close the panel\n      await driver.find(\".test-pp-left-opener-ns\").click();\n      await driver.sleep(500);\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n    });\n\n    it(\"left panel should overlap main content\", async function() {\n      // Open left panel again.\n      await driver.find(\".test-pp-left-opener-ns\").click();\n      await driver.sleep(500);\n\n      // Check the position of the main content\n      assert.equal(\n        (await driver.find(\".test-pp-main-pane\").rect()).left,\n        (await driver.find(\".test-pp-left-panel\").rect()).left);\n\n      // Check that the overlay is present\n      assert.equal(await driver.find(\".test-pp-overlay\").isDisplayed(), true);\n\n      // resize window\n      await gu.setViewportDimensions(770, oldDimensions.height);\n\n      // Check that the overlay is not present\n      assert.equal(await driver.find(\".test-pp-overlay\").isDisplayed(), false);\n\n      // shrink window again\n      await gu.setViewportDimensions(760, oldDimensions.height);\n\n      // Panel should get closed, and overlay should be absent.\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n      assert.equal(await driver.find(\".test-pp-overlay\").isDisplayed(), false);\n\n      // Open the panel\n      await driver.find(\".test-pp-left-opener-ns\").click();\n      await driver.sleep(500);\n      assert.equal(await isSidePanelOpen(\"left\"), true);\n      assert.equal(await driver.find(\".test-pp-overlay\").isDisplayed(), true);\n    });\n\n    it(\"should not allow resizing left panel\", async function() {\n      assert.equal(await isSidePanelOpen(\"left\"), true);\n\n      // check that the resizer is not displayed\n      assert.equal(await driver.find(\".test-pp-left-resizer\").isDisplayed(), false);\n    });\n\n    it(\"should not allow to have the 2 panels opened\", async function() {\n      // check that left panel is opened:\n      assert.equal(await isSidePanelOpen(\"left\"), true);\n\n      // Open the right panel\n      await driver.find(\".test-pp-right-opener-ns\").click();\n      await driver.sleep(500);\n\n      // Check left is closed and right is opened\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n      assert.equal(await isSidePanelOpen(\"right\"), true);\n\n      // Close right panel\n      await driver.find(\".test-pp-right-opener-ns\").click();\n      await driver.sleep(500);\n\n      // Check left and right are closed\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n      assert.equal(await isSidePanelOpen(\"right\"), false);\n\n      // Open right panel\n      await driver.find(\".test-pp-right-opener-ns\").click();\n      await driver.sleep(500);\n\n      // Check left is closed and right is opened\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n      assert.equal(await isSidePanelOpen(\"right\"), true);\n\n      // Open the left panel\n      await driver.find(\".test-pp-left-opener-ns\").click();\n      await driver.sleep(500);\n\n      // Check left is opened and right is closed\n      assert.equal(await isSidePanelOpen(\"left\"), true);\n      assert.equal(await isSidePanelOpen(\"right\"), false);\n    });\n\n    it(\"should allow collapsing right panel if shown\", async function() {\n      // If no right panel, then no collapse icon.\n      await driver.find(\".test-pp-show-right\").click();\n      assert.equal(await driver.find(\".test-pp-right-opener-ns\").isPresent(), false);\n\n      await driver.find(\".test-pp-show-right\").click();\n      assert.equal(await driver.find(\".test-pp-right-opener-ns\").isDisplayed(), true);\n\n      // Open the right panel\n      await driver.find(\".test-pp-right-opener-ns\").click();\n      await driver.sleep(500);\n      assert.equal(await isSidePanelOpen(\"right\"), true);\n    });\n\n    it(\"right panel should overlap main content\", async function() {\n      // Check that left is closed and right is opened\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n      assert.equal(await isSidePanelOpen(\"right\"), true);\n\n      // Check the position of the main content\n      assert.equal(\n        (await driver.find(\".test-pp-main-pane\").rect()).right,\n        (await driver.find(\".test-pp-right-panel\").rect()).right);\n\n      // Check that the overlay is present\n      assert.equal(await driver.find(\".test-pp-overlay\").isDisplayed(), true);\n\n      // resize window and check that the overlay disappears\n      await gu.setViewportDimensions(770, oldDimensions.height);\n      assert.equal(await driver.find(\".test-pp-overlay\").isDisplayed(), false);\n\n      // shrink window again\n      await gu.setViewportDimensions(760, oldDimensions.height);\n\n      // Check that left and right are closed\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n      assert.equal(await isSidePanelOpen(\"right\"), false);\n\n      // check the overlay disappeard\n      assert.equal(await driver.find(\".test-pp-overlay\").isDisplayed(), false);\n\n      // Open the panel\n      await driver.find(\".test-pp-right-opener-ns\").click();\n      await driver.sleep(500);\n      assert.equal(await isSidePanelOpen(\"right\"), true);\n    });\n\n    it(\"should not allow resizing right panel if shown\", async function() {\n      assert.equal(await isSidePanelOpen(\"right\"), true);\n\n      // Check resize is not displayed\n      assert.equal(await driver.find(\".test-pp-right-resizer\").isDisplayed(), false);\n\n      // revert to old size\n      await gu.setViewportDimensions(oldDimensions.width, oldDimensions.height);\n\n      // Ensure right panel is open (it depends on its state when the window was large previously).\n      if (!await isSidePanelOpen(\"right\")) {\n        // Clicking a flipped element in chrome misses the element, the browser works better.\n        await driver.executeScript(\"document.getElementsByClassName('test-pp-right-opener')[0].click()\");\n        await driver.sleep(500);\n      }\n      assert.equal(await isSidePanelOpen(\"right\"), true);\n\n      // check resizer is back\n      assert.equal(await driver.find(\".test-pp-right-resizer\").isDisplayed(), true);\n    });\n\n    it(\"should closes side bars when tapping content area\", async function() {\n      // shrink window again\n      await gu.setViewportDimensions(760, oldDimensions.height);\n\n      await driver.find(\".test-pp-right-opener-ns\").click();\n      await driver.sleep(500);\n\n      // check that left is closed and right opened\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n      assert.equal(await isSidePanelOpen(\"right\"), true);\n\n      // click on the content area (the overlay)\n      await driver.find(\".test-pp-overlay\").click();\n\n      // check that the right panel is closed\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n      assert.equal(await isSidePanelOpen(\"right\"), false);\n\n      // open the left panel\n      await driver.find(\".test-pp-left-opener-ns\").click();\n\n      // check the left panel is open\n      assert.equal(await isSidePanelOpen(\"left\"), true);\n      assert.equal(await isSidePanelOpen(\"right\"), false);\n\n      // click on the content area\n      await driver.find(\".test-pp-overlay\").click();\n\n      // check the left panel is closed\n      assert.equal(await isSidePanelOpen(\"left\"), false);\n      assert.equal(await isSidePanelOpen(\"right\"), false);\n\n      // open the right panel for subsequent test\n      await driver.find(\".test-pp-right-opener-ns\").click();\n      await driver.sleep(500);\n      assert.equal(await isSidePanelOpen(\"right\"), true);\n    });\n\n    const isSidePanelOpen = stackWrapFunc(async function(which: \"left\" | \"right\"): Promise<boolean> {\n      return driver.find(`.test-pp-${which}-panel`).matches(\"[class*=-open]\");\n    });\n  });\n\n  describe(\"PageWidgetPicker\", () => {\n    const waitAssertPickerShown = stackWrapFunc(async function() {\n      assert.isTrue(await driver.findWait(\".test-wselect-data\", 100).isDisplayed());\n    });\n    const assertNoPicker = stackWrapFunc(async function() {\n      assert.isFalse(await driver.find(\".test-wselect-data\").isPresent());\n    });\n\n    it(\"should trigger properly from the add new menu\", async () => {\n      // open picker from the add new menu\n      await driver.find(\".test-pp-addNew\").doClick();\n      await gu.findOpenMenu();\n      await driver.find(\".test-pp-addNewPage\").doClick();\n\n      // check that the menu closed and the picker is visible\n      await waitAssertPickerShown();\n    });\n\n    it(\"should close on save\", async () => {\n      // assert `Add to ...` button is disabled\n      assert.equal(await driver.find(\".test-wselect-addBtn\").getAttribute(\"disabled\"), \"true\");\n\n      // click `Add to ...' button\n      await driver.find(\".test-wselect-addBtn\").doClick();\n\n      // picker still there,\n      await waitAssertPickerShown();\n\n      // select 'New Table'\n      await driver.findContent(\".test-wselect-table\", /New Table/).doClick();\n\n      // click `Add to ...' button\n      await driver.find(\".test-wselect-addBtn\").doClick();\n\n      // check that the picker is gone\n      await assertNoPicker();\n    });\n\n    it(\"should allow save on Enter, cancel on escape\", async () => {\n      // open Add new menu then set focus on 'Page' and press Enter\n      await driver.find(\".test-pp-addNew\").doClick();\n      await gu.findOpenMenu();\n      await driver.executeScript(`document.querySelector('.test-pp-addNewPage').focus();`);\n      await driver.sendKeys(Key.ENTER);\n\n      // check that the picker is open\n      await waitAssertPickerShown();\n\n      // press Escape\n      await driver.sendKeys(Key.ESCAPE);\n\n      // check that the picker is gone\n      await assertNoPicker();\n\n      // re-open the picker, select 'New Table' press enter\n      await driver.find(\".test-pp-addNew\").doClick();\n      await gu.findOpenMenu();\n      await driver.find(\".test-pp-addNewPage\").doClick();\n      await waitAssertPickerShown();\n      await driver.findContent(\".test-wselect-table\", /New Table/).doClick();\n      await driver.sendKeys(Key.ENTER);\n\n      // check that the picker is gone\n      await assertNoPicker();\n    });\n\n    it(\"should trigger properly from the basic button on the right pane\", async () => {\n      // click on the button\n      // await driver.find('.test-pp-editDataBtn').doClick();\n      await driver.executeScript(\"document.getElementsByClassName('test-pp-editDataBtn')[0].click()\");\n\n      // check that the picker is there\n      await waitAssertPickerShown();\n\n      // close the picker and check that it's gone\n      await driver.sendKeys(Key.ESCAPE);\n      await assertNoPicker();\n    });\n  });\n\n  describe(\"auto expanding left panel\", async function() {\n    it(\"should expand on mouse enter\", async function() {\n      await driver.find(\".test-pp-right-panel\").mouseMove();\n      await closeLeftPanel();\n      await driver.find(\".test-pp-left-panel\").mouseMove();\n      await driver.sleep(500 + 450);\n      await checkLeftPanelIsExpanded();\n\n      // check panel is overlaping\n      await checkLeftPanelIsOverlapping();\n    });\n\n    it(\"should collapsed on mouse leave\", async function() {\n      await driver.find(\".test-pp-right-panel\").mouseMove();\n      await driver.sleep(500);\n      await checkLeftPanelIsCollapsed();\n    });\n\n    it(\"should allow to retract for a fraction of a second\", async function() {\n      // move mouse in\n      await driver.find(\".test-pp-left-panel\").mouseMove();\n\n      // wait but not too long\n      await driver.sleep(100);\n\n      // check panel did not expand\n      await checkLeftPanelIsCollapsed();\n\n      // move mouse out\n      await driver.find(\".test-pp-main-pane\").mouseMove();\n\n      // check panel still collapsed\n      await checkLeftPanelIsCollapsed();\n\n      // wait another\n      await driver.sleep(500 + 350);\n\n      // check panel still collapsed\n      await checkLeftPanelIsCollapsed();\n    });\n\n    it(\"should show the vertical resizer correctly\", async function() {\n      // initially disbaled resizer is visible\n      assert.equal(await driver.find(\".test-pp-left-disabled-resizer\").isDisplayed(), true);\n      assert.equal(await driver.find(\".test-pp-left-resizer\").isDisplayed(), false);\n\n      // move mouse in\n      await driver.find(\".test-pp-left-panel\").mouseMove();\n      await driver.sleep(500);\n\n      // check disbaled resizer is visible\n      assert.equal(await driver.find(\".test-pp-left-disabled-resizer\").isDisplayed(), true);\n      assert.equal(await driver.find(\".test-pp-left-resizer\").isDisplayed(), false);\n\n      // leave mouse\n      await driver.find(\".test-pp-right-panel\").mouseMove();\n      await driver.sleep(500);\n\n      // check disbaled resizer is visible\n      assert.equal(await driver.find(\".test-pp-left-disabled-resizer\").isDisplayed(), true);\n      assert.equal(await driver.find(\".test-pp-left-resizer\").isDisplayed(), false);\n\n      // let's check resizers when clicking the left opener\n      await driver.executeScript(\"document.getElementsByClassName('test-pp-left-opener')[0].click()\");\n      await driver.sleep(500);\n\n      // check real resizer is visible\n      assert.equal(await driver.find(\".test-pp-left-disabled-resizer\").isDisplayed(), false);\n      assert.equal(await driver.find(\".test-pp-left-resizer\").isDisplayed(), true);\n\n      // click opener again\n      await driver.executeScript(\"document.getElementsByClassName('test-pp-left-opener')[0].click()\");\n      await driver.sleep(500);\n\n      // check real resizer is visible\n      assert.equal(await driver.find(\".test-pp-left-disabled-resizer\").isDisplayed(), true);\n      assert.equal(await driver.find(\".test-pp-left-resizer\").isDisplayed(), false);\n    });\n\n    it(\"should correctly overlap on this edge case\", async function() {\n      // move mouse in and wait for full expansion\n      await driver.find(\".test-pp-left-panel\").mouseMove();\n      await driver.sleep(500);\n\n      // briefly leave mouse and quickly move mouse back-in\n      await driver.find(\".test-pp-right-panel\").mouseMove();\n      await driver.sleep(100);\n      await driver.find(\".test-pp-left-panel\").mouseMove();\n\n      // wait for panel to expand again\n      await driver.sleep(500);\n\n      // check that panel is overlapping\n      await checkLeftPanelIsOverlapping();\n\n      // leave mouse and wait for full collapse\n      await driver.find(\".test-pp-right-panel\").mouseMove();\n      await driver.sleep(500);\n\n      // check panels state\n      await checkLeftPanelIsCollapsed();\n    });\n\n    it(\"should not collapse when a menu is expanded\", async function() {\n      // move mouse in and wait for full expansion\n      await driver.find(\".test-pp-left-panel\").mouseMove();\n      await driver.sleep(500 + 450);\n      await checkLeftPanelIsExpanded();\n\n      // open menu\n      await driver.find(\".test-pages-page\").mouseMove();\n      await driver.find(\".test-docpage-dots\").click();\n\n      // move mouse to the middle of the menu\n      await driver.find(\".grist-floating-menu\").mouseMove();\n      await driver.sleep(500);\n\n      // check panel is still expanded\n      await checkLeftPanelIsExpanded();\n\n      // move mouse outside\n      await driver.find(\".test-pp-right-panel\").mouseMove();\n      await driver.sleep(500);\n\n      // check panel is still expanded\n      await checkLeftPanelIsExpanded();\n\n      // check the menu is still present\n      assert.isTrue(await driver.find(\".grist-floating-menu\").isPresent());\n\n      // click outside\n      await driver.find(\".test-pp-right-panel\").click();\n      await driver.sleep(500);\n\n      // check panel is collapsed\n      await checkLeftPanelIsCollapsed();\n\n      // check the menu is closed\n      assert.isFalse(await driver.find(\".grist-floating-menu\").isPresent());\n    });\n\n    it(\"should not collapse when renaming page (or any other input has focus)\", async function() {\n      // move mouse in and wait for full expansion\n      await driver.find(\".test-pp-left-panel\").mouseMove();\n      await driver.sleep(500 + 450);\n      await checkLeftPanelIsExpanded();\n\n      // open 3-dot menu and click rename\n      await driver.find(\".test-pages-page\").mouseMove();\n      await driver.find(\".test-docpage-dots\").click();\n\n      // For reason I don't understand blur watch on the client does not work when triggering click\n      // using driver.findContent(...).click(). But it does when using a script call.\n      await driver.executeScript(\n        (el: any) => el.click(),\n        driver.findContent(\".grist-floating-menu li\", \"Rename\"),\n      );\n      await driver.sleep(20);\n\n      // move the mouse out\n      await driver.find(\".test-pp-right-panel\").mouseMove();\n      await driver.sleep(500);\n\n      // check the pane is expanded\n      await checkLeftPanelIsExpanded();\n\n      // check the transient input is present\n      assert.isTrue(await driver.find(\".test-docpage-editor\").isPresent());\n\n      // click outside\n      await driver.find(\".test-pp-right-panel\").click();\n      await driver.sleep(500);\n\n      // check the pane is collapsed\n      await checkLeftPanelIsCollapsed();\n\n      // the transient input is gone\n      assert.isFalse(await driver.find(\".test-docpage-editor\").isPresent());\n    });\n  });\n});\n\nasync function closeLeftPanel() {\n  if ((await driver.find(\".test-pp-left-panel\").rect()).width > 50) {\n    await driver.executeScript(\"document.getElementsByClassName('test-pp-left-opener')[0].click()\");\n    await driver.sleep(500);\n  }\n}\n"
  },
  {
    "path": "test/projects/PageWidgetPicker.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"PageWidgetPicker\", () => {\n  setupTestSuite();\n\n  async function setOption(options: { value?: string, isNewPage?: boolean }) {\n    // set value\n    const value = await driver.find(\".test-option-value\");\n    await value.sendKeys(Key.HOME, Key.chord(Key.SHIFT, Key.END), Key.DELETE, options.value || \"\", Key.ENTER);\n\n    // set isNewPage\n    const isNewPage = await driver.find(\".test-option-isNewPage\");\n    if ((await isNewPage.isSelected()) !== Boolean(options.isNewPage)) {\n      await isNewPage.click();\n    }\n  }\n\n  async function closePicker() {\n    await driver.sendKeys(Key.ESCAPE);\n  }\n\n  async function openPicker(options: { value?: string, isNewPage?: boolean } = {}) {\n    await driver.find(\".test-trigger\").click();\n    await driver.findWait(\".test-wselect-container\", 100);\n  }\n\n  before(async function() {\n    this.timeout(60000);\n    await driver.get(`${server.getHost()}/PageWidgetPicker`);\n  });\n\n  it(\"should reflect `.value` on open\", async function() {\n    // set value option to [`Card List`, 'Companies', ['company_id', 'city']]\n    await driver.find(\".test-option-value\").click();\n    await driver.findContent(\".test-wselect-type\", /Card List/).click();\n    await driver.findContent(\".test-wselect-table\", /History/).click();\n    await driver.findContent(\".test-wselect-table\", /History/).find(\".test-wselect-pivot\").click();\n    await driver.findContent(\".test-wselect-column\", /company_id/).doClick();\n    await driver.findContent(\".test-wselect-column\", /city/).doClick();\n    await driver.find(\".test-wselect-addBtn\").doClick();\n\n    // open picker\n    await openPicker();\n\n    // check `detail` is selected\n    assert.deepEqual(await findAllSelected(\"type\"), [\"Card List\"]);\n\n    // check `Companies` is selected\n    assert.deepEqual(await findAllSelected(\"table\"), [\"History\"]);\n\n    // check Group by panel is opened\n    assert.equal(await driver.findContent(\".test-wselect-heading\", /Group by/).isDisplayed(), true);\n\n    // check 'company_id' and 'City' are selected\n    assert.deepEqual(await findAllSelected(\"column\"), [\"company_id\", \"city\"]);\n\n    // close picker\n    await closePicker();\n\n    // remove .value and open picker\n    await driver.find(\".test-option-omit-value\").click();\n    await openPicker();\n\n    // check `Table` is selected\n    assert.deepEqual(await findAllSelected(\"type\"), [\"Table\"]);\n\n    // check no table is selected\n    assert.deepEqual(await findAllSelected(\"table\"), []);\n\n    // check Group by panel is closed\n    assert.equal(await driver.findContent(\".test-wselect-heading\", /Group by/).isDisplayed(), false);\n  });\n\n  it(\"should show 'Group by' pane when using summarized table\", async () => {\n    // check `Group by` panel is not visible\n    assert.deepEqual(await driver.findAll(\".test-wselect-heading\", e => e.getText()),\n      [\"Select widget\", \"Select data\", \"\"]);\n\n    // clicking pivot icon should show 'Group by'\n    await driver.findContent(\".test-wselect-table\", /History/).find(\".test-wselect-pivot\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-wselect-heading\", e => e.getText()),\n      [\"Select widget\", \"Select data\", \"Group by\"]);\n\n    // clicking icon again should hide\n    await driver.findContent(\".test-wselect-table\", /History/).find(\".test-wselect-pivot\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-wselect-heading\", e => e.getText()),\n      [\"Select widget\", \"Select data\", \"\"]);\n\n    // let's show it again\n    await driver.findContent(\".test-wselect-table\", /History/).find(\".test-wselect-pivot\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-wselect-heading\", e => e.getText()),\n      [\"Select widget\", \"Select data\", \"Group by\"]);\n\n    // clicking another table should hide\n    await driver.findContent(\".test-wselect-table\", /Companies/).doClick();\n    assert.deepEqual(await driver.findAll(\".test-wselect-heading\", e => e.getText()),\n      [\"Select widget\", \"Select data\", \"\"]);\n  });\n\n  it(\"should clear columns when hiding 'Group by' pane\", async () => {\n    // open 'Group by' for 'History'\n    await driver.findContent(\".test-wselect-table\", /History/).find(\".test-wselect-pivot\").doClick();\n\n    // initially no columns are selected\n    assert.deepEqual(await findAllSelected(\"column\"), []);\n\n    // let's click one field and see that it's selected\n    await driver.findContent(\".test-wselect-column\", /company_id/).doClick();\n    assert.deepEqual(await findAllSelected(\"column\"), [\"company_id\"]);\n\n    // click another field and see that both are selected\n    await driver.findContent(\".test-wselect-column\", /city/).doClick();\n    assert.deepEqual(await findAllSelected(\"column\"), [\"company_id\", \"city\"]);\n\n    // clicking a selected field deselect it\n    await driver.findContent(\".test-wselect-column\", /company_id/).doClick();\n    assert.deepEqual(await findAllSelected(\"column\"), [\"city\"]);\n\n    // switching to another table should clear the columns\n    await driver.findContent(\".test-wselect-table\", /Companies/).find(\".test-wselect-pivot\").doClick();\n    assert.deepEqual(await findAllSelected(\"column\"), []);\n    await driver.findContent(\".test-wselect-table\", /History/).find(\".test-wselect-pivot\").doClick();\n    assert.deepEqual(await findAllSelected(\"column\"), []);\n  });\n\n  it(\"should reflect changes\", async () => {\n    // select ['Table', 'Companies']\n    await driver.findContent(\".test-wselect-type\", /Table/).doClick();\n    await driver.findContent(\".test-wselect-table\", /Companies/).doClick();\n\n    // check values reflect selection\n    await driver.find(\".test-wselect-addBtn\").click();\n    assert.equal(\n      await driver.find(\".test-call-log:last-child .test-call-value\").getText(),\n      JSON.stringify(\n        { type: \"record\", table: 0, summarize: false, columns: [], link: \"[0,0,0]\", section: 0 }),\n    );\n\n    // resolve call and re-open picker\n    await driver.findContent(\".test-call-logs button\", \"Resolve\").click();\n    await openPicker();\n\n    // select ['Detail', 'History', ['Url', 'city']]\n    await driver.findContent(\".test-wselect-type\", /Card$/).doClick();\n    await driver.findContent(\".test-wselect-table\", /History/).find(\".test-wselect-pivot\").doClick();\n    await driver.findContent(\".test-wselect-column\", /URL/).doClick();\n    await driver.findContent(\".test-wselect-column\", /city/).doClick();\n\n    // check values reflect selection\n    await driver.find(\".test-wselect-addBtn\").click();\n    assert.equal(\n      await driver.find(\".test-call-log:last-child .test-call-value\").getText(),\n      JSON.stringify(\n        { type: \"single\", table: 1, summarize: true, columns: [2, 3], link: \"[0,0,0]\", section: 0 }),\n    );\n\n    // resolve call and re-open picker\n    await driver.findContent(\".test-call-log:last-child button\", \"Resolve\").click();\n    await openPicker();\n  });\n\n  it(\"should disable incompatible choices\", async function() {\n    // re-open picker\n    await driver.sendKeys(Key.ESCAPE);\n    await openPicker();\n\n    const addBtn = await driver.find(\".test-wselect-addBtn\");\n\n    // check no types and no tables are disabled\n    assert.deepEqual(await findAllDisabled(\"type\"), []);\n    assert.deepEqual(await findAllDisabled(\"table\"), []);\n\n    // check that addBtn is disabled\n    assert.equal(await addBtn.getAttribute(\"disabled\"), \"true\");\n\n    // select `Chart`\n    await driver.findContent(\".test-wselect-type\", /Chart/).doClick();\n\n    // check that `New Table` is disabled and addBtn is disabled\n    assert.deepEqual(await findAllDisabled(\"type\"), []);\n    assert.deepEqual(await findAllDisabled(\"table\"), [\"New Table\"]);\n    assert.equal(await addBtn.getAttribute(\"disabled\"), \"true\");\n\n    // click `New Table`\n    await driver.findContent(\".test-wselect-table\", /New Table/).click();\n\n    // check that no tables are selected\n    assert.deepEqual(await findAllSelected(\"table\"), []);\n\n    // select `Table` and check that no table are disabled\n    await driver.findContent(\".test-wselect-type\", /Table/).doClick();\n    assert.deepEqual(await findAllDisabled(\"type\"), []);\n    assert.deepEqual(await findAllDisabled(\"table\"), []);\n\n    // check that addBtn is still disabled\n    assert.equal(await addBtn.getAttribute(\"disabled\"), \"true\");\n\n    // select 'New Table'\n    await driver.findContent(\".test-wselect-table\", /New Table/).doClick();\n\n    // check that 'Chart' and 'Custom' are disabled\n    assert.deepEqual(await findAllDisabled(\"type\"), [\"Chart\", \"Custom\"]);\n    assert.deepEqual(await findAllDisabled(\"table\"), []);\n\n    // click 'Chart'\n    await driver.findContent(\".test-wselect-type\", /Chart/).doClick();\n\n    // check that Table is (still) selected\n    assert.deepEqual(await findAllSelected(\"type\"), [\"Table\"]);\n\n    // select 'Companies' and check that none are disabled\n    await driver.findContent(\".test-wselect-table\", /Companies/).doClick();\n    assert.deepEqual(await findAllDisabled(\"type\"), []);\n    assert.deepEqual(await findAllDisabled(\"table\"), []);\n\n    // check that addBtn is not disabled anymore\n    assert.equal(await addBtn.getAttribute(\"disabled\"), null);\n\n    // set option IsNewPage to true and reopen picker\n    await closePicker();\n    await setOption({ isNewPage: true });\n    await openPicker();\n\n    // select `Table` type\n    await driver.findContent(\".test-wselect-type\", /Table/).doClick();\n\n    // select 'New Table' and  check that 'single', 'detail', 'chart', 'custom' are disabled\n    await driver.findContent(\".test-wselect-table\", /New Table/).doClick();\n    assert.deepEqual(await findAllDisabled(\"type\"), [\"Card\", \"Card List\", \"Chart\", \"Custom\"]);\n    assert.deepEqual(await findAllDisabled(\"table\"), []);\n\n    // select 'Companies' and check that none are disabled\n    await driver.findContent(\".test-wselect-table\", /Companies/).doClick();\n    assert.deepEqual(await findAllDisabled(\"type\"), []);\n    assert.deepEqual(await findAllDisabled(\"table\"), []);\n\n    // select Card and check that 'New Table' is disabled\n    await driver.findContent(\".test-wselect-type\", /Card/).doClick();\n    assert.deepEqual(await findAllDisabled(\"type\"), []);\n    assert.deepEqual(await findAllDisabled(\"table\"), [\"New Table\"]);\n  });\n\n  it(\"should correctly show spinner on long call\", async function() {\n    // select Table, Companies\n    await driver.findContent(\".test-wselect-type\", /Table/).doClick();\n    await driver.findContent(\".test-wselect-table\", /Companies/).doClick();\n\n    // click addBtn\n    await driver.find(\".test-wselect-addBtn\").click();\n\n    // check spinner does show before 500ms delay\n    assert.equal(await driver.find(\".test-modal-spinner\").isPresent(), false);\n    await delay(500);\n    assert.equal(await driver.find(\".test-modal-spinner\").isPresent(), true);\n\n    // check spinner has correct title\n    assert.equal(await driver.find(\".test-modal-spinner-title\").getText(), \"Building Table widget\");\n\n    // check spinner hide on resolving the call\n    await driver.find(\".test-call-log:last-of-type .test-resolve\").click();\n    assert.equal(await driver.find(\".test-modal-spinner\").isPresent(), false);\n\n    // reopen picker\n    await openPicker();\n\n    // now select Card, Companies and click addBtn\n    await driver.findContent(\".test-wselect-type\", /Card/).doClick();\n    await driver.findContent(\".test-wselect-table\", /Companies/).doClick();\n    await driver.find(\".test-wselect-addBtn\").click();\n\n    // check spinner has correct title\n    await delay(500);\n    assert.equal(await driver.find(\".test-modal-spinner-title\").getText(), \"Building Card widget\");\n\n    await driver.find(\".test-call-log:last-of-type .test-resolve\").click();\n  });\n\n  it(\"should not show spinner on short call\",  async function() {\n    await openPicker();\n\n    // select Table, Companies\n    await driver.findContent(\".test-wselect-type\", /Table/).doClick();\n    await driver.findContent(\".test-wselect-table\", /Companies/).doClick();\n\n    // click addBtn\n    await driver.find(\".test-wselect-addBtn\").click();\n\n    // resolve the call\n    await driver.find(\".test-call-log:last-of-type .test-resolve\").click();\n\n    // wait a bit more than 500ms\n    await delay(700);\n\n    // check the spinner does not show within\n    assert.equal(await driver.find(\".test-modal-spinner\").isPresent(), false);\n  });\n});\n\nasync function findAllDisabled(cat: \"type\" | \"table\" | \"column\"): Promise<string[]> {\n  return await driver.findAll(`.test-wselect-${cat}[class*=-disabled]`, e => e.getText());\n}\n\nasync function findAllSelected(cat: \"type\" | \"table\" | \"column\" | \"pivot\"): Promise<string[]> {\n  if (cat === \"table\") {\n    return await driver.findAll(\".test-wselect-table .test-wselect-table-label[class*=-selected]\", e => e.getText());\n  }\n  if (cat === \"pivot\") {\n    return await driver.findAll(\".test-wselect-table .test-wselect-pivot[class*=-selected]\", e => e.getText());\n  }\n  return await driver.findAll(`.test-wselect-${cat}[class*=-selected]`, e => e.getText());\n}\n"
  },
  {
    "path": "test/projects/PagesComponent.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"PagesComponent\", function() {\n  setupTestSuite();\n  this.timeout(10000);      // Set a longer default timeout.\n\n  before(async function() {\n    this.timeout(60000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/PagesComponent`);\n  });\n\n  function findPage(name: RegExp) {\n    return driver.findContentWait(\".test-treeview-label\", name, 100);\n  }\n\n  async function beginRenaming(name: RegExp) {\n    await findPage(name).mouseMove().find(\".test-docpage-dots\").click();\n    await driver.find(\".test-docpage-rename\").doClick();\n    // A textbox should open and get selected: wait for it, since it's not immediate, and\n    // trying to type into it too soon may miss some initial characters.\n    await driver.wait(() => driver.executeScript(\n      () => ((document as any).activeElement.selectionStart != null)), 500);\n  }\n\n  it(\"should allow to manipulate text using mouse while renaming\", async function() {\n    // starts renaming Interactions\n    await beginRenaming(/Interactions/);\n\n    // click on the right part of the text input\n    await driver.find(\".test-docpage-editor\").click();\n\n    await driver.sendKeys(Key.END);  // move to the end\n\n    // enter 'Renamed'\n    await driver.sendKeys(\"Renamed\", Key.ENTER);\n\n    // new name should be InteractionsRenamed\n    assert.equal(await findPage(/Interactions/).find(\".test-docpage-label\").getText(), \"InteractionsRenamed\");\n\n    // rename to Interactions\n    await beginRenaming(/Interactions/);\n    await driver.sendKeys(\"Interactions\", Key.ENTER);\n    assert.equal(await findPage(/Interactions/).find(\".test-docpage-label\").getText(), \"Interactions\");\n  });\n\n  it(\"clicking text input should not select page\", async function() {\n    // starts renaming People\n    const page = driver.findContent(\".test-treeview-label\", /People/);\n    await page.mouseMove().find(\".test-docpage-dots\").click();\n    await driver.find(\".test-docpage-rename\").doClick();\n\n    // click on the text input\n    await driver.find(\".test-docpage-editor\").click();\n\n    // abord rename\n    await driver.sendKeys(Key.ESCAPE);\n\n    // Interactions should be selected\n    assert.equal(await driver.findContent(\".test-treeview-itemHeader\", /People/).matches(\".selected\"), false);\n    assert.equal(await driver.findContent(\".test-treeview-itemHeader\", /Interactions/).matches(\".selected\"), true);\n  });\n});\n"
  },
  {
    "path": "test/projects/RangeFilter.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport * as fu from \"test/projects/filterUtils\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\nfunction findItem(val: string) {\n  return driver.findContent(\".fixture-stored-menu label .test-filter-menu-value\", val).findClosest(\"label\");\n}\n\nfunction isSelected(val: string) {\n  return findItem(val).find(\"input\").isSelected();\n}\n\ndescribe(\"RangeFilter\", function() {\n  setupTestSuite();\n  fu.addFilterUtilsToRepl();\n  this.timeout(10000);\n  let filterType = \"Numeric\";\n\n  async function refresh() {\n    await driver.get(`${server.getHost()}/ColumnFilterMenu?filterType=${filterType}`);\n    await gu.waitToPass(async () => {\n      assert(await driver.find(\".test-filter-menu-search-input\").hasFocus());\n    });\n  }\n\n  async function setFilterType(type: string) {\n    filterType = type;\n    await refresh();\n  }\n\n  beforeEach(async () => {\n    await refresh();\n  });\n\n  it(\"should put focus on search (not the min bound...)\", async function() {\n    assert(await driver.find(\".test-filter-menu-search-input\").hasFocus());\n  });\n\n  it(\"should handle correctly [min] < []\", async function() {\n    // set min to 2\n    await fu.setBound(\"min\", \"2\");\n    assert.equal(await driver.find(\".fixture-json\").getText(), '{\"min\":2}');\n    assert.equal(await isSelected(\"1\"), false);\n    assert.equal(await isSelected(\"2\"), true);\n    assert.equal(await isSelected(\"3\"), true);\n\n    await fu.setBound(\"min\", \"3\");\n    assert.equal(await driver.find(\".fixture-json\").getText(), '{\"min\":3}');\n    assert.equal(await isSelected(\"1\"), false);\n    assert.equal(await isSelected(\"2\"), false);\n    assert.equal(await isSelected(\"3\"), true);\n  });\n\n  it(\"should handle correctly [] < [max]\", async function() {\n    await fu.setBound(\"max\", \"2\");\n    assert.equal(await driver.find(\".fixture-json\").getText(), '{\"max\":2}');\n    assert.equal(await isSelected(\"1\"), true);\n    assert.equal(await isSelected(\"2\"), true);\n    assert.equal(await isSelected(\"3\"), false);\n\n    await fu.setBound(\"max\", \"3\");\n    assert.equal(await driver.find(\".fixture-json\").getText(), '{\"max\":3}');\n    assert.equal(await isSelected(\"1\"), true);\n    assert.equal(await isSelected(\"2\"), true);\n    assert.equal(await isSelected(\"3\"), true);\n  });\n\n  it(\"should handle correctly [min] < [max]\", async function() {\n    await fu.setBound(\"min\", \"2\");\n    await fu.setBound(\"max\", \"3\");\n    assert.equal(await driver.find(\".fixture-json\").getText(), '{\"min\":2,\"max\":3}');\n    assert.equal(await isSelected(\"1\"), false);\n    assert.equal(await isSelected(\"2\"), true);\n    assert.equal(await isSelected(\"3\"), true);\n    assert.equal(await isSelected(\"7\"), false);\n  });\n\n  it(\"should switch to search when click a checkbox\", async function() {\n    await fu.setBound(\"min\", \"4\");\n    assert.equal(await driver.find(\".fixture-json\").getText(), '{\"min\":4}');\n    await findItem(\"2\").click();\n    assert.equal(await driver.find(\".fixture-json\").getText(), '{\"excluded\":[1,3]}');\n  });\n\n  it(\"should switch to search when clicking on None\", async function() {\n    await fu.setBound(\"min\", \"4\");\n    assert.equal(await driver.find(\".fixture-json\").getText(), '{\"min\":4}');\n    await driver.findContent(\".test-filter-menu-bulk-action\", \"None\").click();\n    assert.equal(await driver.find(\".fixture-json\").getText(), '{\"included\":[]}');\n  });\n\n  it(\"should leave all val selected when users delete last bounds\", async function() {\n    // set min bound to 4\n    await fu.setBound(\"min\", \"4\");\n\n    // delete min bound\n    await fu.setBound(\"min\", null);\n\n    // check couple values are selected\n    assert.equal(await isSelected(\"1\"), true);\n    assert.equal(await isSelected(\"1\"), true);\n  });\n\n  it(\"should show date and dropdown icons only for date column\", async function() {\n    assert.equal(await driver.find(\".test-filter-menu-min [style*=--icon-FieldDate]\").isPresent(), false);\n    await setFilterType(\"Date\");\n    assert.equal(await driver.find(\".test-filter-menu-min [style*=--icon-FieldDate]\").isPresent(), true);\n  });\n\n  it(\"should toggle relative options on click only for date column\", async function() {\n    await setFilterType(\"Numeric\");\n    assert.equal(await fu.isOptionsVisible(), false);\n    await fu.findBound(\"min\").click();\n    assert.equal(await fu.isOptionsVisible(), false);\n\n    await setFilterType(\"Date\");\n    assert.equal(await fu.isOptionsVisible(), false);\n    await fu.findBound(\"min\").click();\n    assert.equal(await fu.isOptionsVisible(), true);\n  });\n\n  it(\"should handle Date column correctly\", async function() {\n    await setFilterType(\"Date\");\n\n    // set min bound to 2022-04-05\n    await fu.setBound(\"min\", \"2022-04-05\");\n\n    // check state is {\"min\":1649116800}\n    function parseDate(s: string) { return Number(new Date(s)) / 1000; }\n    assert.equal(await driver.find(\".fixture-json\").getText(), `{\"min\":${parseDate(\"2022-04-05\")}}`);\n\n    // check checkboxes states\n    await fu.switchToDefaultView();\n    assert.equal(await isSelected(\"2022-01-05\"), false);\n    assert.equal(await isSelected(\"2022-04-05\"), true);\n    assert.equal(await isSelected(\"2022-05-05\"), true);\n\n    // set max bound to 2022-04-12\n    await fu.setBound(\"max\", \"2022-04-12\");\n\n    // check state is {\"min\":1649116800,\"max\"}\n    await fu.switchToDefaultView();\n    assert.equal(await driver.find(\".fixture-json\").getText(),\n      `{\"min\":${parseDate(\"2022-04-05\")},\"max\":${parseDate(\"2022-04-12\")}}`);\n\n    // check checkboxes state\n    assert.equal(await isSelected(\"2022-01-05\"), false);\n    assert.equal(await isSelected(\"2022-04-05\"), true);\n    assert.equal(await isSelected(\"2022-05-05\"), false);\n\n    // clear min\n    await fu.setBound(\"min\", null);\n\n    // check\n    await fu.switchToDefaultView();\n    assert.equal(await driver.find(\".fixture-json\").getText(),\n      `{\"max\":${parseDate(\"2022-04-12\")}}`);\n    assert.equal(await isSelected(\"2022-01-05\"), true);\n    assert.equal(await isSelected(\"2022-04-05\"), true);\n    assert.equal(await isSelected(\"2022-05-05\"), false);\n  });\n});\n"
  },
  {
    "path": "test/projects/TreeViewComponent.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { delay } from \"bluebird\";\nimport { addToRepl, assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"TreeViewComponent\", () => {\n  setupTestSuite();\n  addToRepl(\"findItem\", findItem);\n\n  before(async function() {\n    this.timeout(60000);\n    await driver.get(`${server.getHost()}/TreeViewComponent`);\n  });\n\n  it(\"should display correct tree view\", async function() {\n    // check pages shown in right order\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page3\", \"Page4\", \"Page5\", \"Page6\"]);\n    // check pages shown with right indentation\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper .test-treeview-offset\",\n      e => e.getCssValue(\"width\")),\n    [\"0px\", \"10px\", \"10px\", \"20px\", \"0px\", \"0px\"]);\n    // check pages shown with correct arrows\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper .test-treeview-itemArrow\",\n      async e => await e.getCssValue(\"visibility\") === \"visible\"),\n    [true, false, true, false, false, false]);\n  });\n\n  it(\"should reflect model update\", async function() {\n    // test insertion\n    await driver.find(\"input.insert\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page3\", \"Page4\", \"Page5\", \"Page6\", \"New Page\"]);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper .test-treeview-offset\",\n      e => e.getCssValue(\"width\")),\n    [\"0px\", \"10px\", \"10px\", \"20px\", \"0px\", \"0px\", \"0px\"]);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper .test-treeview-itemArrow\",\n      async e => await e.getCssValue(\"visibility\") === \"visible\"),\n    [true, false, true, false, false, false, false]);\n\n    // test insertion in a subfolder\n    await driver.find(\"input.subInsert\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page3\", \"Page4\", \"New Page 5\", \"Page5\", \"Page6\", \"New Page\"]);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper .test-treeview-offset\",\n      e => e.getCssValue(\"width\")),\n    [\"0px\", \"10px\", \"10px\", \"20px\", \"10px\", \"0px\", \"0px\", \"0px\"]);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper .test-treeview-itemArrow\",\n      async e => await e.getCssValue(\"visibility\") === \"visible\"),\n    [true, false, true, false, false, false, false, false]);\n\n    // removing the last of a group should remove the arrow of that group.\n    assert.deepEqual(await findItem(/Page3/).find(\".test-treeview-itemArrow\").getCssValue(\"visibility\"), \"visible\");\n    await driver.find(\"input.removePage4\").doClick();\n    assert.deepEqual(await findItem(/Page3/).find(\".test-treeview-itemArrow\").getCssValue(\"visibility\"), \"hidden\");\n\n    // reset tree\n    await driver.find(\"input.reset\").doClick();\n  });\n\n  it(\"should have a working handle\", async function() {\n    // hovering shows the handle\n    const handle = findItem(/Page2/).find(\".test-treeview-handle\");\n    assert.equal(await handle.isDisplayed(), false);\n    await findItem(/Page2/).mouseMove();\n    assert.equal(await handle.isDisplayed(), true);\n\n    // should slides when dragging\n    await startDrag(/Page2/);\n\n    // check that Page2 is being dragged\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper.dragged\", e => e.getText()), [\"Page2\"]);\n\n    // moving cursor few pixels up should moves handle same amount up\n    const oldTop = (await handle.rect()).top;\n    // 1px\n    await driver.mouseMoveBy({ y: 1 });\n    assert.closeTo((await handle.rect()).top - oldTop, 1, 1);\n    // 4px\n    await driver.mouseMoveBy({ y: 4 });\n    assert.closeTo((await handle.rect()).top - oldTop, 4, 1);\n\n    // moving cursor out should hide handle\n    await driver.mouseMoveBy({ x: 100 });\n    assert.equal(await handle.isDisplayed(), false);\n    await findItem(/Page2/).mouseMove();\n    assert.equal(await handle.isDisplayed(), true);\n\n    // releasing should snap handle\n    await driver.withActions(actions => actions.release());\n    assert.equal(await handle.getCssValue(\"top\"), \"0px\");\n  });\n\n  it(\"should show target and target's parent\", async function() {\n    const target = findTarget();\n\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), false);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n\n    await startDrag(/Page6/);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper.dragged\", e => e.getText()), [\"Page6\"]);\n\n    // target below Page5\n    await moveTo(/Page5/, { y: 1 });\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n    await assertTargetPos(await target.rect(), \"below\", await findItemRectangles(/Page5/));\n\n    // target above Page5\n    await moveTo(/Page5/, { y: -1 });\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n    await assertTargetPos(await target.rect(), \"above\", await findItemRectangles(/Page5/));\n\n    // target first child Page1\n    await moveTo(/Page1/, { y: 1 });\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), [\"Page1\"]);\n    await assertTargetPos(await target.rect(), \"above\", await findItemRectangles(/Page2/));\n\n    // target children of Page3 should update the parent target\n    await moveTo(/Page4/);\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), [\"Page3\"]);\n    await assertTargetPos(await target.rect(), \"above\", await findItemRectangles(/Page4/));\n\n    // leaving component hide targets\n    await driver.mouseMoveBy({ x: 300 });\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), false);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n\n    // reentering reveals targets\n    await moveTo(/Page4/);\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), [\"Page3\"]);\n    await assertTargetPos(await target.rect(), \"above\", await findItemRectangles(/Page4/));\n\n    // releases hides targets\n    await driver.actions().release().perform();\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), false);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n  });\n\n  it(\"should prevent dropping on it's own children\", async function() {\n    await startDrag(/Page1/);\n    await moveTo(/Page1/, { y: 1 });\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), false);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n\n    await moveTo(/Page2/);\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), false);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n\n    await driver.actions().release().perform();\n  });\n\n  it(\"should not be possible to drop above or below dragged item\", async function() {\n    await startDrag(/Page5/);\n    await moveTo(/Page5/, { y: 1 });\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), false);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n\n    await moveTo(/Page5/, { y: -1 });\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), false);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n\n    await driver.actions().release().perform();\n  });\n\n  it(\"should call right callback when dropping\", async function() {\n    this.timeout(6000);\n    await driver.find(\"input.clearLogs\").doClick();\n    await startDrag(/Page5/);\n    await moveTo(/Page2/, { y: 1 });\n    await driver.actions().release().perform();\n    assert.deepEqual(await driver.findAll(\".model-calls\", e => e.getText()), [\n      \"insert Page5 before Page3 in Page1\"]);\n\n    // check that dropping below the above item does nothing\n    await driver.find(\"input.clearLogs\").doClick();\n    await startDrag(/Page6/);\n    await moveTo(/Page5/, { y: 1 });\n    await stopDrag();\n    assert.deepEqual(await driver.findAll(\".model-calls\", e => e.getText()), []);\n\n    // check that dropping above the below item does nothing\n    await startDrag(/Page5/);\n    await moveTo(/Page6/, { y: -1 });\n    await stopDrag();\n    assert.deepEqual(await driver.findAll(\".model-calls\", e => e.getText()), []);\n\n    // check that do not call when dropping on dragged item\n    await startDrag(/Page5/);\n    await driver.mouseMoveBy({ x: -1 });\n    await stopDrag();\n    assert.deepEqual(await driver.findAll(\".model-calls\", e => e.getText()), []);\n  });\n\n  it(\"should support selection\", async function() {\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.selected`, e => e.getText()), []);\n\n    // select one item\n    await driver.findContent(\".test-treeview-label\", /Page1/).doClick();\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.selected`, e => e.getText()), [\"Page1\"]);\n\n    // select another item\n    await driver.findContent(\".test-treeview-label\", /Page4/).doClick();\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.selected`, e => e.getText()), [\"Page4\"]);\n  });\n\n  it(\"should support collapsing\", async function() {\n    // reset tree and check initial state\n    await driver.find(\"input.reset\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page3\", \"Page4\", \"Page5\", \"Page6\"]);\n    // let's collapse Page1\n    await findItem(/Page1/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"\", \"\", \"\", \"Page5\", \"Page6\"]);\n    // uncollapse\n    await findItem(/Page1/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page3\", \"Page4\", \"Page5\", \"Page6\"]);\n  });\n\n  it(\"highlighted item should show a solid border\", async function() {\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n    await startDrag(/Page6/);\n\n    await findItem(/Page4/).mouseMove();\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), [\"Page3\"]);\n  });\n\n  it(\"should support auto expansion\", async function() {\n    this.timeout(6000);\n    const target = await findTarget();\n    await driver.find(\"input.clearLogs\").doClick();\n\n    // let's collapse Page1\n    await findItem(/Page1/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"\", \"\", \"\", \"Page5\", \"Page6\"]);\n    await startDrag(/Page6/);\n    await findItem(/Page1/).mouseMove();\n    // Page1 not expanded yet and target is shown below Page1\n    await delay(800);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"\", \"\", \"\", \"Page5\", \"Page6\"]);\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n    await assertTargetPos(await target.rect(), \"above\", await findItemRectangles(/Page1/));\n    // moving cursor over Page1 should not delay expansion\n    await driver.mouseMoveBy({ x: 2 });\n    await delay(400);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page3\", \"Page4\", \"Page5\", \"Page6\"]);\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), [\"Page1\"]);\n    await assertTargetPos(await target.rect(), \"above\", await findItemRectangles(/Page2/));\n\n    // Check that there is no offset between cursor and the handle\n    assert.isBelow((await findItem(/Page6/).find(\".test-treeview-handle\").rect()).top, 50);\n\n    // moving cursor hover same item after expansion should not change the target\n    await driver.mouseMoveBy({ x: -2 });\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page3\", \"Page4\", \"Page5\", \"Page6\"]);\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), [\"Page1\"]);\n    await assertTargetPos(await target.rect(), \"above\", await findItemRectangles(/Page2/));\n\n    await stopDrag();\n\n    assert.deepEqual(await driver.findAll(\".model-calls\", e => e.getText()), [\n      \"insert Page6 before Page2 in Page1\"]);\n    assert.equal(await target.isDisplayed(), false);\n  });\n\n  it(\"should not auto expand when leaving item before timeout\", async function() {\n    this.timeout(4000);\n    // let's collapse Page1\n    await findItem(/Page1/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"\", \"\", \"\", \"Page5\", \"Page6\"]);\n    await startDrag(/Page6/);\n    await findItem(/Page1/).mouseMove();\n    await delay(900);\n    await findItem(/Page5/).mouseMove();\n    await delay(400);\n    // Page1 is still collapsed\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"\", \"\", \"\", \"Page5\", \"Page6\"]);\n    await stopDrag();\n  });\n\n  it(\"auto expand should not cause target to change when the item was already expanded\", async function() {\n    this.timeout(4000);\n    // let's expand Page1\n    await findItem(/Page1/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page3\", \"Page4\", \"Page5\", \"Page6\"]);\n    const target = findTarget();\n    await startDrag(/Page6/);\n    await findItem(/Page1/).mouseMove();\n    // target is above Page1\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n    await assertTargetPos(await target.rect(), \"above\", await findItemRectangles(/Page1/));\n    await delay(1200);\n    // target remains above Page1\n    assert.equal(await driver.find(\".test-treeview-target\").isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), []);\n    await assertTargetPos(await target.rect(), \"above\", await findItemRectangles(/Page1/));\n\n    await stopDrag();\n  });\n\n  it(\"auto expand should work on item with empty list of children\", async function() {\n    await driver.find(\"input.clearLogs\").doClick();\n    const target = findTarget();\n    await startDrag(/Page6/);\n    // page5 has an empty list of children\n    await findItem(/Page5/).mouseMove();\n    await delay(1200);\n    assert.equal(await target.isDisplayed(), true);\n    assert.deepEqual(await driver.findAll(`.test-treeview-itemHeader.highlight`, e => e.getText()), [\"Page5\"]);\n    await stopDrag();\n    assert.deepEqual(await driver.findAll(\".model-calls\", e => e.getText()), [\n      \"insert Page6 before null in Page5\"]);\n  });\n\n  it(\"should flatten tree if isOpen is false\", async function() {\n    await driver.find(\"input.reset\").doClick();\n\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper .test-treeview-offset\",\n      e => e.getCssValue(\"display\")),\n    [\"block\", \"block\", \"block\", \"block\", \"block\", \"block\"]);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper .test-treeview-itemArrow\",\n      e => e.getCssValue(\"display\")),\n    [\"flex\", \"flex\", \"flex\", \"flex\", \"flex\", \"flex\"]);\n\n    await driver.find(\"input.isOpen\").doClick();\n\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper .test-treeview-offset\",\n      e => e.getCssValue(\"display\")),\n    [\"none\", \"none\", \"none\", \"none\", \"none\", \"none\"]);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper .test-treeview-itemArrow\",\n      e => e.getCssValue(\"display\")),\n    [\"none\", \"none\", \"none\", \"none\", \"none\", \"none\"]);\n\n    // un-flatten the tree\n    await driver.find(\"input.isOpen\").doClick();\n  });\n\n  it(\"holding mouse down for a while should starts dragging\", async function() {\n    this.timeout(4000);\n\n    // let's press mouse\n    await driver.withActions(actions => actions\n      .move({ origin: findItem(/Page1/) })\n      .press());\n\n    // should not start dragging just yet\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper.dragged\", e => e.getText()), []);\n\n    // should start dragging after timeout expired\n    await delay(510);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper.dragged\", e => e.getText()), [\"Page1\"]);\n\n    // holding mouse down on the arrow should not start dragging\n    await driver.withActions(actions => actions\n      .release()\n      .move({ origin: findItem(/Page1/).find(\".test-treeview-itemArrow\") })\n      .press());\n    await delay(510);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper.dragged\", e => e.getText()), []);\n    await driver.withActions(actions => actions.release());\n\n    // click should not start dragging\n    await findItem(/Page1/).doClick();\n    await delay(510);\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper.dragged\", e => e.getText()), []);\n  });\n\n  it(\"should reuse dom for treeItem \", async function() {\n    /* Treeview should reuse dom when an item is removed from a tree node to be inserted into\n     * another one. So if we collapse Page3 and then move it before Page6 it should remain\n     * collapsed.\n     */\n    await driver.find(\"input.reset\").doClick();\n    // lets' check no pages are collapsed\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page3\", \"Page4\", \"Page5\", \"Page6\"]);\n\n    // let's collapse Page3.\n    await findItem(/Page3/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      // We can tell that Page3 is collapsed because '' shows instead of 'Page4'\n      [\"Page1\", \"Page2\", \"Page3\", \"\", \"Page5\", \"Page6\"]);\n\n    // let's move Page3 and check that it remained collapsed.\n    await driver.find(\"input.move\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      // Even though Page3 has moved we can tell it remained collapsed because '' still show in place of 'Page4'\n      [\"Page1\", \"Page2\", \"Page5\", \"Page3\", \"\", \"Page6\"]);\n\n    // let's expand Page3\n    await findItem(/Page3/).find(\".test-treeview-itemArrow\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page5\", \"Page3\", \"Page4\", \"Page6\"]);\n  });\n\n  it(\"should dispose element that are not reused\", async function() {\n    await driver.find(\"input.reset\").doClick();\n    await delay(100);\n    await driver.find(\"input.clearLogs\").doClick();\n    assert.deepEqual(await driver.findAll(\".disposed-items\", e => e.getText()), []);\n    await driver.find(\"input.remove\").doClick();\n    assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper\", e => e.getText()),\n      [\"Page5\", \"Page6\"]);\n    assert.deepEqual(await driver.findAll(\".disposed-items\", e => e.getText()),\n      [\"Page1\", \"Page2\", \"Page3\", \"Page4\"]);\n  });\n\n  describe(\"isReadonly mode\", function() {\n    it(\"should hide the handle\", async function() {\n      // reset\n      await driver.find(\"input.reset\").doClick();\n\n      // enable isReadonly mode\n      await driver.find(\".isReadonly\").click();\n\n      // hover on page Page1\n      await moveTo(/Page1/);\n\n      // check that the handle is not visible\n      assert.equal(await findItem(/Page1/).find(\".test-treeview-handle\").isDisplayed(), false);\n    });\n\n    it(\"should disable delayed dragging\", async function() {\n      this.timeout(4000);\n\n      // let's press mouse\n      await driver.withActions(actions => (\n        actions\n          .move({ origin: findItem(/Page1/) })\n          .press()\n      ));\n\n      // should not start dragging even after timeout expired\n      await delay(510);\n\n      assert.deepEqual(await driver.findAll(\".test-treeview-itemHeaderWrapper.dragged\", e => e.getText()), []);\n\n      // release the mouse\n      await driver.withActions(actions => (\n        actions\n          .release()\n      ));\n    });\n  });\n});\n\nfunction startDrag(item: RegExp) {\n  return driver.withActions(actions => actions\n    .move({ origin: findItem(item) })\n    .move({ origin: findItem(item).find(\".test-treeview-handle\") })\n    .press());\n}\n\nfunction stopDrag() {\n  return driver.withActions(actions => actions.release());\n}\n\nasync function moveTo(item: RegExp, opt: { y: number } = { y: 0 }) {\n  const el = await driver.findContent(\".test-treeview-itemHeaderWrapper\", item);\n  await el.mouseMove(opt);\n}\n\nasync function assertTargetPos(targetRect: ClientRect, zone: \"above\" | \"below\",\n  item: { header: ClientRect, label: ClientRect }) {\n  // on the left, the target should starts where the label starts\n  assert.closeTo(targetRect.left, item.label.left, 1, \"wrong left offset\");\n  // on the right, the target should end at the end of the header\n  assert.closeTo(targetRect.right, item.header.right, 1, \"wrong right\");\n  assert.closeTo(targetRect.top, zone === \"above\" ? item.header.top : item.header.bottom, 3);\n}\n\nfunction findItem(pattern: RegExp) {\n  return driver.findContent(\".test-treeview-itemHeaderWrapper\", pattern);\n}\n\nasync function findItemRectangles(pattern: RegExp) {\n  const item = findItem(pattern);\n  return { header: await item.find(\".test-treeview-itemHeader\").rect(),\n    label: await item.find(\".test-treeview-label\").rect() };\n}\n\nfunction findTarget() {\n  return driver.find(\".test-treeview-target\");\n}\n"
  },
  {
    "path": "test/projects/UI2018.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key, stackWrapFunc, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"UI2018\", () => {\n  setupTestSuite();\n\n  let actionText: WebElement;\n  let actionReset: WebElement;\n\n  before(async function() {\n    this.timeout(1200000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/UI2018`);\n    actionText = await driver.find(\"#action-text\");\n    actionReset = await driver.find(\"#action-reset\");\n  });\n\n  describe(\"buttons\", () => {\n    it(\"should support actions on click\", async function() {\n      const btns = await driver.findAll(\"#buttons > .elements > *\");\n      for (const btn of btns) {\n        await btn.doClick();\n        assert.equal(await actionText.getText(), await btn.getText());\n        await actionReset.doClick();\n      }\n    });\n  });\n\n  describe(\"editable labels\", () => {\n    it(\"should allow editing label and save on enter / tab / click\", async function() {\n      const label = await driver.find(\"#editable-label input\");\n      assert.equal(await label.value(), \"Hello\");\n\n      // Send new value and check that it updates and focus remains\n      await label.sendKeys(Key.END, \", world!\");\n      assert.equal(await label.value(), \"Hello, world!\");\n      assert.equal(await driver.switchTo().activeElement().getId(), await label.getId());\n\n      // Check that on Enter focus leaves but the value remains\n      await label.sendKeys(Key.ENTER);\n      assert.notEqual(await driver.switchTo().activeElement().getId(), await label.getId());\n      assert.equal(await label.value(), \"Hello, world!\");\n\n      // Check that on Tab focus leaves but the value remains\n      await label.sendKeys(Key.END, \" Foo\", Key.TAB);\n      assert.notEqual(await driver.switchTo().activeElement().getId(), await label.getId());\n      assert.equal(await label.value(), \"Hello, world! Foo\");\n\n      // Check that on click away focus leaves but the value remains\n      await label.sendKeys(Key.END, \", bar\");\n      await driver.find(\"#labels > h4\").click();\n      assert.notEqual(await driver.switchTo().activeElement().getId(), await label.getId());\n      assert.equal(await label.value(), \"Hello, world! Foo, bar\");\n\n      // Reset back to Hello\n      await label.sendKeys(Key.HOME, Key.chord(Key.SHIFT, Key.END), \"Hello\", Key.ENTER);\n      assert.equal(await label.value(), \"Hello\");\n    });\n\n    it(\"should allow Escape to cancel\", async function() {\n      const label = await driver.find(\"#editable-label input\");\n      assert.equal(await label.value(), \"Hello\");\n\n      await label.sendKeys(Key.END, \", wrong!\");\n      assert.equal(await label.value(), \"Hello, wrong!\");\n      assert.equal(await driver.switchTo().activeElement().getId(), await label.getId());\n      await label.sendKeys(Key.ESCAPE);\n      assert.notEqual(await driver.switchTo().activeElement().getId(), await label.getId());\n      assert.equal(await label.value(), \"Hello\");\n\n      // Check that clicking it still shows the right value\n      await label.click();\n      assert.equal(await label.value(), \"Hello\");\n      await label.sendKeys(Key.ESCAPE);\n    });\n\n    it(\"should revert when saving empty value\", async function() {\n      const label = await driver.find(\"#editable-label input\");\n      assert.equal(await label.value(), \"Hello\");\n\n      // Should cancel on Enter when empty value\n      await label.sendKeys(Key.HOME, Key.chord(Key.SHIFT, Key.END), Key.ENTER);\n      assert.equal(await label.value(), \"Hello\");\n      assert.notEqual(await driver.switchTo().activeElement().getId(), await label.getId());\n      // ... or Tab\n      await label.sendKeys(Key.HOME, Key.chord(Key.SHIFT, Key.END), Key.TAB);\n      assert.equal(await label.value(), \"Hello\");\n      assert.notEqual(await driver.switchTo().activeElement().getId(), await label.getId());\n      // ... or click away\n      await label.sendKeys(Key.HOME, Key.chord(Key.SHIFT, Key.END));\n      await driver.find(\"#labels > h4\").click();\n      assert.equal(await label.value(), \"Hello\");\n      assert.notEqual(await driver.switchTo().activeElement().getId(), await label.getId());\n      // And should reset on Escape\n      await label.sendKeys(Key.HOME, Key.chord(Key.SHIFT, Key.END), Key.ESCAPE);\n      assert.equal(await label.value(), \"Hello\");\n      assert.notEqual(await driver.switchTo().activeElement().getId(), await label.getId());\n    });\n  });\n\n  describe(\"search bar\", async function() {\n    it(\"should expand on click and collapse on x\", async function() {\n      const searchIcon = await driver.find(\".test-search-icon\");\n      await searchIcon.click();\n      await driver.sleep(500);\n      const searchInput = await driver.find(\".test-search-input\");\n      assert.isAbove((await searchInput.rect()).width, 50);\n\n      const searchClose = await driver.find(\".test-search-close\");\n      await searchClose.click();\n      await driver.sleep(500);\n      assert.equal((await searchInput.rect()).width, 0);\n    });\n\n    it(\"should collapse on blur\", async function() {\n      const searchIcon = await driver.find(\".test-search-icon\");\n      await searchIcon.click();\n      await driver.sleep(500);\n      const searchInput = await driver.find(\".test-search-input\");\n      assert.isAbove((await searchInput.rect()).width, 50);\n\n      await driver.find(\"#search h4\").click();\n      await driver.sleep(500);\n      assert.equal((await searchInput.rect()).width, 0);\n    });\n\n    it(\"should blur on close\", async function() {\n      const searchIcon = await driver.find(\".test-search-icon\");\n      const searchInput = await driver.find(\".test-search-input\");\n      const searchInputInput = await driver.find(\".test-search-input input\");\n\n      // click search bar icon\n      await searchIcon.click();\n      await driver.sleep(500);\n\n      // type in 'foo'\n      await driver.sendKeys(\"foo\");\n      assert.equal(await searchInputInput.value(), \"foo\");\n\n      // hit escape to close\n      await driver.sendKeys(Key.ESCAPE);\n      await driver.sleep(500);\n\n      // check searchBar is closed\n      assert.equal((await searchInput.rect()).width, 0);\n\n      // type in 'bar'\n      await driver.sendKeys(\"foo\");\n\n      // check that value is still 'foo'\n      assert.equal(await searchInputInput.value(), \"foo\");\n    });\n  });\n\n  describe(\"button select\", () => {\n    it(\"should update observable on click\", async function() {\n      // Select buttons and check that the observable is set to the correct value.\n      const alignmentValue = await driver.find(\"#buttonselect .alignment-value\");\n      const alignmentBtns = await driver.findAll(\"#buttonselect .alignment-select .test-select-button\");\n      await alignmentBtns[0].click();\n      assert.equal(await alignmentValue.getText(), \"left\");\n      await alignmentBtns[1].click();\n      assert.equal(await alignmentValue.getText(), \"center\");\n      await alignmentBtns[2].click();\n      assert.equal(await alignmentValue.getText(), \"right\");\n\n      const widgetValue = await driver.find(\"#buttonselect .widget-value\");\n      const widgetBtns = await driver.findAll(\"#buttonselect .widget-select .test-select-button\");\n      await widgetBtns[0].click();\n      assert.equal(await widgetValue.getText(), \"0\");\n      await widgetBtns[1].click();\n      assert.equal(await widgetValue.getText(), \"1\");\n\n      const chartValue = await driver.find(\"#buttonselect .chart-value\");\n      const chartBtns = await driver.findAll(\"#buttonselect .chart-select .test-select-button\");\n      await chartBtns[1].click();\n      assert.equal(await chartValue.getText(), \"pie\");\n      await chartBtns[4].click();\n      assert.equal(await chartValue.getText(), \"kaplan\");\n    });\n\n    it(\"should allow unsetting toggle observable\", async function() {\n      const chartValue = await driver.find(\"#buttonselect .chart-value\");\n      const chartBtns = await driver.findAll(\"#buttonselect .chart-select .test-select-button\");\n      await chartBtns[2].click();\n      assert.equal(await chartValue.getText(), \"area\");\n      // Click the same button and check that the value is null.\n      await chartBtns[2].click();\n      assert.equal(await chartValue.getText(), \"null\");\n    });\n  });\n\n  describe(\"tri state checkbox\", () => {\n    it(\"should work correctly\", async function() {\n      const checkState = stackWrapFunc(async function(isIndeterminate: boolean, isChecked: boolean) {\n        assert.equal(await driver.find(\".test-both-check\").matches(\":indeterminate\"), isIndeterminate);\n        assert.equal(await driver.find(\".test-both-check\").matches(\":checked\"), isChecked);\n      });\n\n      // check checkbox is in indeterminate state\n      await checkState(true, false);\n\n      // click checkbox\n      await driver.find(\".test-both-check\").click();\n\n      // check is in checked state\n      await checkState(false, true);\n\n      // click checkbox\n      await driver.find(\".test-both-check\").click();\n\n      // check it is unchecked state\n      await checkState(false, false);\n\n      // click checkbox\n      await driver.find(\".test-both-check\").click();\n\n      // check it is checked state\n      await checkState(false, true);\n\n      // click obsCheck1\n      await driver.find(\".test-check-1\").click();\n\n      // check it is indeterminate\n      await checkState(true, false);\n    });\n  });\n\n  describe(\"multi select\", () => {\n    it(\"should display placeholder text when nothing is selected\", async function() {\n      const buttonText = await driver.find(\"#menus .test-multi-select\").getText();\n      assert.equal(buttonText, \"Select column type\");\n    });\n\n    it(\"should display available options when clicked\", async function() {\n      // Click the multi select to open the menu.\n      await driver.find(\"#menus .test-multi-select\").click();\n\n      // Check that the correct available options are shown.\n      const availableOptions = await driver.findAll(\n        \".test-multi-select-menu .test-multi-select-menu-option-text\",\n        el => el.getText(),\n      );\n      assert.deepEqual(\n        availableOptions,\n        [\n          \"Text\",\n          \"Numeric\",\n          \"Integer\",\n          \"Toggle\",\n          \"Date\",\n          \"DateTime\",\n          \"Choice\",\n          \"Reference\",\n          \"Attachment\",\n          \"Any\",\n          \"A very very long fake label for a very fake type\",\n        ],\n      );\n\n      // Check that all checkboxes are unchecked.\n      const checkboxValues = await driver.findAll(\n        \".test-multi-select-menu .test-multi-select-menu-option-checkbox\",\n        el => el.getAttribute(\"checked\"),\n      );\n      assert.notInclude(checkboxValues, \"true\");\n    });\n\n    it(\"should update button text when selected options change\", async function() {\n      // Click the first option and check that the button text updated.\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Text/,\n      ).click();\n      assert.equal(await driver.find(\"#menus .test-multi-select\").getText(), \"Text\");\n\n      // Click the last option's text and check that the button text updated.\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /A very very long/,\n      ).find(\".test-multi-select-menu-option-text\").click();\n      assert.equal(\n        await driver.find(\"#menus .test-multi-select\").getText(),\n        \"Text, A very very long fake label for a very fake type\",\n      );\n\n      // Click the second option's checkbox and check that the button text updated.\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Numeric/,\n      ).find(\".test-multi-select-menu-option-checkbox\").click();\n      assert.equal(\n        await driver.find(\"#menus .test-multi-select\").getText(),\n        \"Text, Numeric, A very very long fake label for a very fake type\",\n      );\n\n      // Uncheck the first option ('Text') and check that the button text updated.\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Text/,\n      ).click();\n      assert.equal(\n        await driver.find(\"#menus .test-multi-select\").getText(),\n        \"Numeric, A very very long fake label for a very fake type\",\n      );\n\n      // Close the menu and check that the button text is still correct.\n      await driver.find(\"#menus > h4\").click();\n      assert.equal(\n        await driver.find(\"#menus .test-multi-select\").getText(),\n        \"Numeric, A very very long fake label for a very fake type\",\n      );\n    });\n\n    it(\"should change its outline to red when the error observable is true\", async function() {\n      // Check that the outline is currently not red.\n      assert.equal(\n        await driver.find(\"#menus .test-multi-select\").getCssValue(\"border\"),\n        \"1px solid rgb(217, 217, 217)\",\n      );\n\n      // Open the menu and check 2 more option, triggering the error observable from the fixture to be true.\n      await driver.find(\"#menus .test-multi-select\").click();\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Text/,\n      ).click();\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Date/,\n      ).click();\n\n      // Check that the outline is now red.\n      assert.equal(\n        await driver.find(\"#menus .test-multi-select\").getCssValue(\"border\"),\n        \"1px solid rgb(208, 2, 27)\",\n      );\n\n      // Uncheck an option and check that the outline is no longer red.\n      await driver.findContent(\n        \".test-multi-select-menu .test-multi-select-menu-option\",\n        /Text/,\n      ).click();\n      assert.equal(\n        await driver.find(\"#menus .test-multi-select\").getCssValue(\"border\"),\n        \"1px solid rgb(217, 217, 217)\",\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "test/projects/UserManager.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"UserManager\", () => {\n  setupTestSuite();\n  gu.bigScreen();\n\n  before(async function() {\n    this.timeout(60000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/UserManager`);\n  });\n\n  async function getMemberEmail(member: WebElement) {\n    // Try getting the email first; if we fail to find it, try getting the name\n    // (whose value may be an email).\n    try {\n      return await member.find(\".test-um-member-email\").getText();\n    } catch {\n      return await member.find(\".test-um-member-name\").getText();\n    }\n  }\n\n  async function getRenderedMembers(): Promise<[string, string | null][]> {\n    const members = await driver.findAll(\".test-um-member\");\n    return await Promise.all(members.map(m => Promise.all([\n      getMemberEmail(m),\n      getMemberRole(m),\n    ])));\n  }\n\n  async function getMemberRole(memberElem: WebElement): Promise<string | null> {\n    const roleElem = memberElem.find(\".test-um-member-role\");\n    const exists = await roleElem.isPresent();\n    return exists ? roleElem.getText() : null;\n  }\n\n  it(\"should render all emails and roles initially\", async function() {\n    await gu.waitToPass(async () => {\n      assert.deepEqual(await getRenderedMembers(), [\n        [\"foo@example.com\", \"Owner\"],\n        [\"bar@example.com\", \"Editor\"],\n        [\"team@example.com\", \"Viewer\"],\n        [\"guest@example.com\", \"Viewer\"],\n      ]);\n    }, 5000);\n    assert.deepEqual(JSON.parse(await driver.find(\".test-result\").getText()), {});\n  });\n\n  it(\"should reflect role changes\", async function() {\n    await driver.find(\".test-um-member .test-um-member-role\").doClick();\n    await driver.findContent(\".test-um-role-option\", /Editor/).doClick();\n    assert.deepEqual(await getRenderedMembers(), [\n      [\"foo@example.com\", \"Editor\"],\n      [\"bar@example.com\", \"Editor\"],\n      [\"team@example.com\", \"Viewer\"],\n      [\"guest@example.com\", \"Viewer\"],\n    ]);\n\n    // Save and check output.\n    await driver.find(\".test-save\").click();\n    assert.deepEqual(JSON.parse(await driver.find(\".test-result\").getText()), {\n      users: { \"foo@example.com\": \"editors\" },\n    });\n    await driver.find(\".test-reset\").click();\n  });\n\n  it(\"should allow adding emails\", async function() {\n    await driver.find(\".test-um-member-new input\").sendKeys(\"bob@bob.tail\", Key.ENTER);\n    await driver.find(\".test-um-member-new input\").sendKeys(\"alice@a.com\", Key.ENTER);\n    await driver.find(\".test-um-member-new input\").sendKeys(\"eve@a.com\", Key.ENTER);\n\n    await driver.findContent(\".test-um-member\", /eve@a\\.com/).find(\".test-um-member-role\").doClick();\n    await driver.findContentWait(\".test-um-role-option\", /Editor/, 100).doClick();\n    await driver.findContent(\".test-um-member\", /bob@bob\\.tail/).find(\".test-um-member-role\").doClick();\n    await driver.findContentWait(\".test-um-role-option\", /Editor/, 100).doClick();\n\n    assert.deepEqual(await getRenderedMembers(), [\n      [\"foo@example.com\", \"Owner\"],\n      [\"bar@example.com\", \"Editor\"],\n      [\"team@example.com\", \"Viewer\"],\n      [\"guest@example.com\", \"Viewer\"],\n      [\"bob@bob.tail\", \"Editor\"],\n      [\"alice@a.com\", \"Viewer\"],\n      [\"eve@a.com\", \"Editor\"],\n    ]);\n\n    // Save and check output.\n    await driver.find(\".test-save\").click();\n    assert.deepEqual(JSON.parse(await driver.find(\".test-result\").getText()), {\n      users: {\n        \"bob@bob.tail\": \"editors\",\n        \"alice@a.com\": \"viewers\",\n        \"eve@a.com\": \"editors\",\n      },\n    });\n    await driver.find(\".test-reset\").click();\n  });\n\n  it(\"should only suggest team members in autocomplete\", async function() {\n    await driver.find(\".test-um-member-new input\").sendKeys(\"t\");\n    assert.deepEqual(\n      await driver.findAll(\".test-acselect-dropdown .test-um-member-name\", el => el.getText()),\n      [\"Team Member\"],\n    );\n\n    await driver.find(\".test-um-member-new input\").doClear().sendKeys(\".com\");\n    assert.deepEqual(\n      await driver.findAll(\".test-acselect-dropdown .test-um-member-name\", el => el.getText()),\n      [\"Team Member\"],\n    );\n  });\n\n  it(\"should allow deleting newly-added emails\", async function() {\n    await driver.find(\".test-um-member-new input\").doClear().sendKeys(\"bob@bob.tail\", Key.ENTER);\n    await driver.findContent(\".test-um-member\", /bar@example\\.com/).find(\".test-um-member-delete\").doClick();\n    await driver.findContent(\".test-um-member\", /bob@bob\\.tail/).find(\".test-um-member-delete\").doClick();\n\n    assert.deepEqual(await getRenderedMembers(), [\n      [\"foo@example.com\", \"Owner\"],\n      [\"bar@example.com\", null],\n      [\"team@example.com\", \"Viewer\"],\n      [\"guest@example.com\", \"Viewer\"],\n    ]);\n\n    // Save and check output.\n    await driver.find(\".test-save\").click();\n    assert.deepEqual(JSON.parse(await driver.find(\".test-result\").getText()), {\n      users: { \"bar@example.com\": null },\n    });\n    await driver.find(\".test-reset\").click();\n  });\n\n  it(\"should allow resetting\", async function() {\n    await driver.find(\".test-um-member-new input\").sendKeys(\"alice@bobtail.com\", Key.ENTER);\n    await driver.findContent(\".test-um-member\", /foo@example\\.com/).find(\".test-um-member-delete\").doClick();\n    await driver.findContent(\".test-um-member\", /bar@example\\.com/).find(\".test-um-member-role\").doClick();\n    await driver.findContentWait(\".test-um-role-option\", /Owner/, 100).doClick();\n    await driver.findContent(\".test-um-member\", /alice@bobtail\\.com/).find(\".test-um-member-role\").doClick();\n    await driver.findContentWait(\".test-um-role-option\", /Owner/, 100).doClick();\n\n    assert.deepEqual(await getRenderedMembers(), [\n      [\"foo@example.com\", null],\n      [\"bar@example.com\", \"Owner\"],\n      [\"team@example.com\", \"Viewer\"],\n      [\"guest@example.com\", \"Viewer\"],\n      [\"alice@bobtail.com\", \"Owner\"],\n    ]);\n\n    // Output is unchanged at this point.\n    assert.deepEqual(JSON.parse(await driver.find(\".test-result\").getText()), {});\n\n    // Click the Reset button.\n    await driver.find(\".test-reset\").doClick();\n\n    // Check that everything is as at the start now.\n    assert.deepEqual(await getRenderedMembers(), [\n      [\"foo@example.com\", \"Owner\"],\n      [\"bar@example.com\", \"Editor\"],\n      [\"team@example.com\", \"Viewer\"],\n      [\"guest@example.com\", \"Viewer\"],\n    ]);\n    assert.deepEqual(JSON.parse(await driver.find(\".test-result\").getText()), {});\n  });\n\n  it(\"should show validation error for duplicate email\", async function() {\n    await driver.find(\".test-reset\").click();\n\n    // Entering an existing email produces a validation error.\n    await driver.find(\".test-um-member-new input\").sendKeys(\"foo@example.com\", Key.ENTER);\n    assert.match(await driver.find(\".test-um-member-new input\").getAttribute(\"validationMessage\"),\n      /already in the list/);\n\n    // Entering a new email does not.\n    await driver.find(\".test-um-member-new input\").doClear().sendKeys(\"foo2@example.com\", Key.ENTER);\n    assert.equal(await driver.find(\".test-um-member-new input\").getAttribute(\"validationMessage\"), \"\");\n    assert.deepEqual(await getRenderedMembers(), [\n      [\"foo@example.com\", \"Owner\"],\n      [\"bar@example.com\", \"Editor\"],\n      [\"team@example.com\", \"Viewer\"],\n      [\"guest@example.com\", \"Viewer\"],\n      [\"foo2@example.com\", \"Viewer\"],\n    ]);\n\n    // A newly-added email IS considered \"existing\".\n    await driver.find(\".test-um-member-new input\").doClear().sendKeys(\"foo2@example.com\", Key.ENTER);\n    assert.match(await driver.find(\".test-um-member-new input\").getAttribute(\"validationMessage\"),\n      /already in the list/);\n\n    // Without clearing the email, remove the conflicting member and try again.\n    await driver.findContent(\".test-um-member\", /foo2@example\\.com/).find(\".test-um-member-delete\").doClick();\n    await driver.find(\".test-um-member-new input\").doClick().sendKeys(Key.ENTER);\n    assert.equal(await driver.find(\".test-um-member-new input\").getAttribute(\"validationMessage\"), \"\");\n    assert.deepEqual(await getRenderedMembers(), [\n      [\"foo@example.com\", \"Owner\"],\n      [\"bar@example.com\", \"Editor\"],\n      [\"team@example.com\", \"Viewer\"],\n      [\"guest@example.com\", \"Viewer\"],\n      [\"foo2@example.com\", \"Viewer\"],\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/projects/contextMenu.ts",
    "content": "import { waitToPass } from \"test/nbrowser/gristUtils\";\nimport * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { addToRepl, assert, driver, Key } from \"mocha-webdriver\";\n\nasync function contextMenu(x?: number, y?: number) {\n  const rect = await driver.find(\"body\").getRect();\n  return driver.withActions((actions) => {\n    if (x !== undefined && y !== undefined) {\n      // passing {orign: 'viewport'} to `actions.move` does not work in bridge mode, so we need to\n      // adjust x, y to position relative to body\n      x = Math.ceil(x - rect.width * 0.5);\n      y = Math.ceil(y - rect.height * 0.5);\n      actions.move({ x, y, origin: driver.find(\"body\") });\n    }\n    actions.contextClick();\n  });\n}\n\ndescribe(\"contextMenu\", function() {\n  setupTestSuite();\n\n  before(async function() {\n    this.timeout(20000);\n    await driver.get(`${server.getHost()}/contextMenu`);\n    addToRepl(\"contextMenu\", contextMenu);\n  });\n\n  it(\"should open on contextmenu and work properly\", async function() {\n    await waitToPass(async () =>  {\n      await contextMenu(10, 10);\n      assert.equal(await driver.find(\".grist-floating-menu\").isPresent(), true);\n    });\n\n    // click Foo\n    await gu.findOpenMenuItem(\"li\", \"Foo\").click();\n\n    // check menu is gone\n    assert.equal(await driver.find(\".grist-floating-menu\").isPresent(), false);\n\n    // check action worked\n    assert.deepEqual(\n      await driver.findAll(\".test-logs\", e => e.getText()),\n      [\"foo added\"],\n    );\n\n    // click Bar\n    await contextMenu();\n\n    // check action worked\n    await gu.findOpenMenuItem(\"li\", \"Bar\").click();\n    assert.deepEqual(\n      await driver.findAll(\".test-logs\", e => e.getText()),\n      [\"foo added\", \"bar added\"],\n    );\n\n    // click Reset\n    await contextMenu();\n\n    // check action worked\n    await driver.findContentWait(\".grist-floating-menu li\", \"Reset\", 100).click();\n    assert.deepEqual(\n      await driver.findAll(\".test-logs\", e => e.getText()),\n      [],\n    );\n\n    // open context menu\n    await contextMenu();\n\n    // check menu is open\n    assert.equal(await driver.findWait(\".grist-floating-menu\", 100).isPresent(), true);\n\n    // send Escape\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check menu is closed\n    await gu.waitForMenuToClose();\n  });\n\n  it(\"should support arrow navigation\", async function() {\n    // check logs is empty\n    assert.deepEqual(\n      await driver.findAll(\".test-logs\", e => e.getText()),\n      [],\n    );\n\n    // open context menu\n    await contextMenu();\n\n    // send down arrow and ENTER\n    await driver.sendKeys(Key.DOWN, Key.ENTER);\n\n    // check foo was added\n    assert.deepEqual(\n      await driver.findAll(\".test-logs\", e => e.getText()),\n      [\"foo added\"],\n    );\n  });\n\n  it(\"should keep menu within viewport\", async function() {\n    // open menu\n    await contextMenu(10, 10);\n\n    // get viewport width and height\n    const width = await driver.executeScript(`return window.innerWidth`) as any;\n    const height = await driver.executeScript(`return window.innerHeight`) as any;\n\n    // reopen closer the edge of the window\n    await contextMenu(width - 10, 10);\n    await checkWithinViewport();\n\n    // reopen closer to the bottom of the window\n    await contextMenu(10, height - 10);\n    await checkWithinViewport();\n\n    async function checkWithinViewport() {\n      const rect = await driver.find(\".grist-floating-menu\").getRect();\n      assert.isAbove(rect.x, 0);\n      assert.isBelow(rect.x + rect.width, width);\n      assert.isAbove(rect.y, 0);\n      assert.isBelow(rect.y + rect.height, height);\n    }\n  });\n\n  it(\"should close on click anywhere outside content\", async function() {\n    // open context menu\n    await contextMenu(10, 10);\n\n    // check menu is open\n    assert.equal(await driver.find(\".grist-floating-menu\").isPresent(), true);\n\n    // click anywhere outside\n    await driver.mouseMoveBy({ x: -5, y: -5 });\n    await driver.withActions(actions => actions.click());\n\n    // check menu is closed\n    assert.equal(await driver.find(\".grist-floating-menu\").isPresent(), false);\n  });\n\n  it(\"context click inside menu should not do unexpected behaviour\", async function() {\n    // open context menu\n    await contextMenu(10, 10);\n\n    // context click on top of menu\n    await driver.find(\".grist-floating-menu\").mouseMove();\n    await driver.withActions(actions => actions.contextClick());\n\n    // check only one context menu open\n    assert.equal((await driver.findAll(\".grist-floating-menu\")).length, 1);\n\n    // send escape to close context menu\n    await driver.sendKeys(Key.ESCAPE);\n\n    // check no context menu\n    assert.equal((await driver.findAll(\".grist-floating-menu\")).length, 0);\n  });\n});\n"
  },
  {
    "path": "test/projects/editableLabel.ts",
    "content": "import { server } from \"test/fixtures/projects/webpack-test-server\";\n\nimport { assert, driver, Key, useServer, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"editableLabel\", function() {\n  useServer(server);\n\n  before(async function() {\n    await driver.get(`${server.getHost()}/editableLabel`);\n  });\n\n  beforeEach(async function() {\n    await driver.find(\".test-reset\").click();\n  });\n\n  // Webdriver has elem.clear(), but it doesn't seem to work with editableLabel. Simulate with a\n  // key combination (might be Mac-Chrome-specific).\n  async function clear(elem: WebElement) {\n    await elem.sendKeys(Key.HOME, Key.chord(Key.SHIFT, Key.END), Key.DELETE);\n  }\n\n  describe(\"test editableLabel component\", function() {\n    before(async function() {\n      await driver.findContent(\".test-select-component option\", \"editableLabel\").click();\n    });\n\n    testInput();\n\n    it(\"should select the full value on click\", async function() {\n      // Click the label, and enter text -- it should be the only text in it.\n      await driver.find(\".test-edit-label\").doClick().sendKeys(\"foo\");\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"foo\");\n      await driver.find(\".test-edit-label\").sendKeys(Key.ESCAPE);\n    });\n  });\n\n  describe(\"test textInput component\", function() {\n    before(async function() {\n      await driver.findContent(\".test-select-component option\", \"textInput\").click();\n    });\n\n    testInput();\n  });\n\n  function testInput() {\n    it(\"should save value on change\", async function() {\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"Hello\");\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"Hello\");\n\n      // Click, type a new value, and hit Enter.\n      // Ideally we'd use `.doClear()` here, but that loses focus\n      await clear(driver.find(\".test-edit-label\").doClick());\n      await driver.find(\".test-edit-label\").sendKeys(\"foo\", Key.ENTER);\n\n      // Update the value on the server and resolve the server call(s).\n      await driver.find(\".test-server-value\").doClear().sendKeys(\"foo\");\n      await driver.find(\".test-server-update\").click();\n      await driver.findAll(\".test-call-resolve\", el => el.click());\n\n      // We should have the new value in the editableLabel, and in the plain text box.\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"foo\");\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"foo\");\n    });\n\n    it(\"should make a single call to save\", async function() {\n      // Same as above, but verify that only one call is made to the server.\n      await clear(driver.find(\".test-edit-label\").doClick());\n      await driver.find(\".test-edit-label\").sendKeys(\"foo\", Key.ENTER);\n\n      await driver.find(\".test-server-value\").doClear().sendKeys(\"foo\");\n      await driver.find(\".test-server-update\").click();\n      await driver.findAll(\".test-call-resolve\", el => el.click());\n\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"foo\");\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"foo\");\n\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()),\n        [\"Called: foo\", \"Resolved\"]);\n    });\n\n    it(\"should save on blur\", async function() {\n      // Same as above, but verify that only one call is made to the server.\n      await clear(driver.find(\".test-edit-label\").doClick());\n      await driver.find(\".test-edit-label\").sendKeys(\"BlurTest\");\n\n      // At this point the textbox has the new value, but it's not yet saved.\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"BlurTest\");\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"Hello\");\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()), []);\n\n      // Click away (on a text label): a call should be made.\n      await driver.find(\".test-saved-value\").click();\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()), [\"Called: BlurTest\"]);\n\n      // Resolve the server call.\n      await driver.find(\".test-server-value\").doClear().sendKeys(\"BlurTest\");\n      await driver.find(\".test-server-update\").click();\n      await driver.findAll(\".test-call-resolve\", el => el.click());\n\n      // Check that both values are now updated.\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"BlurTest\");\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"BlurTest\");\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()),\n        [\"Called: BlurTest\", \"Resolved\"]);\n    });\n\n    it(\"should not make a save call on Escape\", async function() {\n      // Click, hit Escape.\n      await driver.find(\".test-edit-label\").doClick().sendKeys(Key.ESCAPE);\n\n      // Check that no calls are made.\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()),\n        []);\n    });\n\n    it(\"should revert on Escape\", async function() {\n      // Click, change value, hit Escape.\n      await driver.find(\".test-edit-label\").doClick().sendKeys(Key.END, \"-foo\", Key.ESCAPE);\n\n      // Value in editableLabel should revert to what it was.\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"Hello\");\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"Hello\");\n\n      // Check that no calls are made.\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()),\n        []);\n    });\n\n    it(\"should reflect the value on the server\", async function() {\n      // Update server value.\n      await driver.find(\".test-server-value\").doClear().sendKeys(\"foo\");\n      await driver.find(\".test-server-update\").click();\n\n      // Check that editableLabel reflects it.\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"foo\");\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"foo\");\n    });\n\n    it(\"should reflect changes to the server value after save\", async function() {\n      // Every test case starts with hello. Change it to something else and save.\n      await driver.find(\".test-edit-label\").doClick().sendKeys(\"Hola\", Key.ENTER);\n      await driver.find(\".test-server-value\").doClear().sendKeys(\"Hola\");\n      await driver.find(\".test-server-update\").click();\n      await driver.findAll(\".test-call-resolve\", el => el.click());\n\n      // Check that editableLabel reflects it.\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"Hola\");\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"Hola\");\n\n      // Update the value on the server, and check that editableLabel reflects it.\n      await driver.find(\".test-server-value\").doClear().sendKeys(\"World\");\n      await driver.find(\".test-server-update\").click();\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"World\");\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"World\");\n    });\n\n    it(\"should show the server value if different when save returns\", async function() {\n      // Click, type a new value, and hit Enter.\n      await clear(driver.find(\".test-edit-label\").doClick());\n      await driver.find(\".test-edit-label\").sendKeys(\"foo\", Key.ENTER);\n\n      // Check that we show the desired value while waiting for the save.\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"foo\");\n\n      // Update the value on the server to something else, and resolve.\n      await driver.find(\".test-server-value\").doClear().sendKeys(\"foo2\");\n      await driver.find(\".test-server-update\").click();\n      await driver.findAll(\".test-call-resolve\", el => el.click());\n\n      // We should have the server value in the editableLabel, and in the plain text box\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"foo2\");\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"foo2\");\n\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()),\n        [\"Called: foo\", \"Resolved\"]);\n    });\n\n    it(\"should show the server value if save failed\", async function() {\n      // Click, change value, hit Enter.\n      await clear(driver.find(\".test-edit-label\").doClick());\n      await driver.find(\".test-edit-label\").sendKeys(\"foo\", Key.ENTER);\n\n      // Reject the server call.\n      await driver.findAll(\".test-call-reject\", el => el.click());\n\n      // server value and editableLabel should have the previous server value.\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"Hello\");\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"Hello\");\n\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()),\n        [\"Called: foo\", \"Rejected: FakeError\"]);\n    });\n\n    it(\"should not reflect server changes while being edited\", async function() {\n      // Prepare a new server value.\n      await driver.find(\".test-server-value\").doClear().sendKeys(\"bar\");\n\n      // Click, start typing a new value.\n      await driver.find(\".test-edit-label\").doClick().sendKeys(Key.END, \"-foo\");\n\n      // Update the value on the server via keyboard shortcut to avoid changing focus.\n      await driver.find(\".test-edit-label\").sendKeys(Key.chord(Key.CONTROL, \"U\"));\n\n      // We should have the new value in the plain textbox, but not in editableLabel.\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"bar\");\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"Hello-foo\");\n\n      // Check that no calls are made.\n      assert.deepEqual(await driver.findAll(\".test-call-log li\", el => el.getText()), []);\n    });\n\n    it(\"should be disabled while a call is pending\", async function() {\n      // Click, change value, hit Enter.\n      await clear(driver.find(\".test-edit-label\").doClick());\n      await driver.find(\".test-edit-label\").sendKeys(\"foo\", Key.ENTER);\n\n      // editableLabel should now be disabled, and show the expected value.\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"foo\");\n      assert.equal(await driver.find(\".test-edit-label\").getAttribute(\"disabled\"), \"true\");\n\n      // Resolve the server call.\n      await driver.find(\".test-server-value\").doClear().sendKeys(\"foo\");\n      await driver.find(\".test-server-update\").click();\n      await driver.findAll(\".test-call-resolve\", el => el.click());\n\n      // editableLabel should now be enabled again.\n      assert.equal(await driver.find(\".test-edit-label\").getAttribute(\"disabled\"), null);\n\n      // We should have the new value in the editableLabel, and in the plain text box.\n      assert.equal(await driver.find(\".test-edit-label\").value(), \"foo\");\n      assert.equal(await driver.find(\".test-saved-value\").getText(), \"foo\");\n    });\n  }\n});\n"
  },
  {
    "path": "test/projects/errorPages.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"errorPages\", function() {\n  this.timeout(60000);      // Set a longer default timeout.\n  setupTestSuite();\n\n  it(\"should show forbidden page for inaccessible orgs\", async function() {\n    // No error loading plain DocMenu page.\n    await driver.get(`${server.getHost()}/DocMenu`);\n    assert.equal(await driver.find(\".test-error-header\").isPresent(), false);\n\n    // We are still user \"Santa\"\n    await driver.get(`${server.getHost()}/DocMenu#org=nonexistent`);\n    assert.equal(await driver.find(\".test-error-header\").getText(), \"Access denied\");\n    assert.equal(await driver.find(\".test-error-signin\").getText(), \"Add account\");\n\n    // Check the same for the Anonymous user.\n    await driver.get(`${server.getHost()}/DocMenu#org=nonexistent&user=anon`);\n    assert.equal(await driver.find(\".test-error-header\").getText(), \"Access denied\");\n    assert.equal(await driver.find(\".test-error-signin\").getText(), \"Sign in\");\n\n    // Check the same for missing user.\n    await driver.get(`${server.getHost()}/DocMenu#org=nonexistent&user=null`);\n    assert.equal(await driver.find(\".test-error-header\").getText(), \"Access denied\");\n    assert.equal(await driver.find(\".test-error-signin\").getText(), \"Sign in\");\n  });\n});\n"
  },
  {
    "path": "test/projects/filterUtils.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\n\nimport { addToRepl, driver, WebElementPromise } from \"mocha-webdriver\";\n\nexport async function openRelativeOptionsMenu(minMax: \"min\" | \"max\") {\n  if (!await driver.find(\".grist-floating-menu\").isPresent()) {\n    await driver.find(`.test-filter-menu-${minMax}`).click();\n  }\n}\n\nexport async function findCalendarDates(selector: string): Promise<string[]> {\n  let res: string[] = [];\n  await gu.waitToPass(async () => {\n    res = await driver.findAll(`.datepicker-inline td.day${selector}`, e => e.getText());\n  });\n  return res;\n}\n\nexport function isOptionsVisible() {\n  return driver.find(\".test-filter-menu-wrapper .grist-floating-menu\").isPresent();\n}\n\nexport async function isBoundSelected(minMax: \"min\" | \"max\") {\n  return driver.find(`.test-filter-menu-${minMax}.selected`).isPresent();\n}\n\nexport async function getSelected(): Promise<\"min\" | \"max\" | undefined> {\n  if (await isBoundSelected(\"min\")) { return \"min\"; }\n  if (await isBoundSelected(\"max\")) { return \"max\"; }\n}\n\nexport function pickDateInCurrentMonth(date: string) {\n  return driver.findContent(\".datepicker-inline td.day\", date).click();\n}\n\nexport async function getViewType() {\n  return await driver.findContent(\".test-calendar-links button\", \"List view\").isPresent() ? \"Calendar\" : \"Default\";\n}\n\nexport async function switchToDefaultView() {\n  await driver.findContent(\".test-calendar-links button\", \"List view\").click();\n}\n\nexport function getSelectedOption() {\n  return driver.findAll(\".grist-floating-menu li[class*=-sel]\", e => e.getText());\n}\n\nexport function findBound(minMax: \"min\" | \"max\") {\n  return new WebElementPromise(driver, driver.find(`.test-filter-menu-${minMax}`));\n}\n\nexport async function setBound(minMax: \"min\" | \"max\", value: string | { relative: string } | null) {\n  await gu.setRangeFilterBound(minMax, value);\n}\n\nexport async function getBoundText(minMax: \"min\" | \"max\") {\n  const bound = findBound(minMax);\n  return (await bound.getText()) ||\n    (await bound.find(\"input\").value()) ||\n    (await bound.find(\"input\").getAttribute(\"placeholder\")).trim();\n}\n\nexport function addFilterUtilsToRepl() {\n  addToRepl(\"gu\", gu);\n  addToRepl(\"findBound\", findBound);\n  addToRepl(\"getBountText\", getBoundText);\n  addToRepl(\"findCalendarDates\", findCalendarDates);\n  addToRepl(\"setBound\", setBound);\n  addToRepl(\"getSelected\", getSelected);\n  addToRepl(\"isOptionsVisible\", isOptionsVisible);\n  addToRepl(\"pickDateInCurrentMonth\", pickDateInCurrentMonth);\n  addToRepl(\"getViewType\", getViewType);\n  addToRepl(\"getSelectedOption\", getSelectedOption);\n}\n"
  },
  {
    "path": "test/projects/modals.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"modals\", function() {\n  setupTestSuite();\n\n  before(async function() {\n    this.timeout(20000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/modals`);\n  });\n\n  async function checkClosed() {\n    await driver.sleep(50);  // Give time for the closing animation to finish.\n    assert.equal(await driver.find(\".test-modal-dialog\").isPresent(), false);\n  }\n\n  async function checkOpen() {\n    assert.equal(await driver.findWait(\".test-modal-dialog\", 100).isPresent(), true);\n  }\n\n  it(\"should close on click-away, OK, Cancel, Escape, Enter\", async function() {\n    // Modal is initially reported as \"Cancelled\" and isn't present.\n    assert.match(await driver.findWait(\".testui-confirm-modal-text\", 100).getText(), /Cancelled/);\n    await checkClosed();\n\n    // Click on Cancel closes.\n    await driver.find(\".testui-confirm-modal-opener\").click();\n    await checkOpen();\n    await driver.find(\".test-modal-cancel\").click();\n    await checkClosed();\n    assert.match(await driver.find(\".testui-confirm-modal-text\").getText(), /Cancelled/);\n\n    // OK button closes and marks as Confirmed.\n    await driver.find(\".testui-confirm-modal-opener\").click();\n    await checkOpen();\n    await driver.find(\".test-modal-confirm\").click();\n    await checkClosed();\n    assert.match(await driver.find(\".testui-confirm-modal-text\").getText(), /Confirmed/);\n\n    // Click on modal's header does not close.\n    await driver.find(\".testui-confirm-modal-opener\").click();\n    await checkOpen();\n    await driver.findContent(\".test-modal-dialog div\", /Default modal header/).click();\n    await checkOpen();\n\n    // Click above the area of the modal closes.\n    await driver.findContent(\".test-modal-dialog div\", /Default modal header/).mouseMove();\n    await driver.mouseMoveBy({ y: -100 });\n    await driver.withActions(actions => actions.click());\n    await checkClosed();\n    assert.match(await driver.find(\".testui-confirm-modal-text\").getText(), /Cancelled/);\n\n    // Escape closes and marks Cancelled\n    await driver.find(\".testui-confirm-modal-opener\").click();\n    await checkOpen();\n    await driver.sendKeys(Key.ESCAPE);\n    await checkClosed();\n    assert.match(await driver.find(\".testui-confirm-modal-text\").getText(), /Cancelled/);\n\n    // Enter closes and marks Confirmed\n    await driver.find(\".testui-confirm-modal-opener\").click();\n    await checkOpen();\n    await driver.sendKeys(Key.ENTER);\n    await checkClosed();\n    assert.match(await driver.find(\".testui-confirm-modal-text\").getText(), /Confirmed/);\n  });\n\n  it(\"should dispose on close\", async function() {\n    assert.match(await driver.find(\".testui-custom-modal-text\").getText(), /Closed/);\n    await checkClosed();\n    await driver.find(\".testui-custom-modal-opener\").click();\n    await checkOpen();\n    assert.match(await driver.find(\".testui-custom-modal-text\").getText(), /Open/);\n\n    // Click button to close\n    await driver.find(\".testui-custom-modal-btn\").click();\n    await checkClosed();\n    assert.match(await driver.find(\".testui-custom-modal-text\").getText(), /Closed/);\n\n    await driver.find(\".testui-custom-modal-opener\").click();\n    assert.match(await driver.find(\".testui-custom-modal-text\").getText(), /Open/);\n\n    // Hit Escape to close\n    await driver.sendKeys(Key.ESCAPE);\n    await checkClosed();\n    assert.match(await driver.find(\".testui-custom-modal-text\").getText(), /Closed/);\n  });\n\n  describe(\"saveModal\", function() {\n    it(\"should support arbitrary dom arguments\", async () => {\n      // Title and saveLabel both include dynamic DOM; check that it works.\n      await driver.find(\".testui-save-modal-opener\").click();\n      await checkOpen();\n      assert.equal(await driver.find(\".test-modal-title\").getText(), \"Title [Hello] (saving=0)\");\n      assert.equal(await driver.find(\".test-modal-confirm\").getText(), \"Save [Hello]\");\n\n      // Type something else.\n      await driver.find(\".testui-save-modal-input\").doClear().sendKeys(\"foo\");\n      assert.equal(await driver.find(\".test-modal-title\").getText(), \"Title [foo] (saving=0)\");\n      assert.equal(await driver.find(\".test-modal-confirm\").getText(), \"Save [foo]\");\n    });\n\n    it(\"should disable save button when saveDisabled is set\", async () => {\n      await checkOpen();    // Still open from last test case.\n      assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), null);\n\n      // Clear the value; saveDisabled should turn to true, and the button should become disbled.\n      await driver.find(\".testui-save-modal-input\").clear();\n      assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n\n      // Clicking a disabled button does nothing.\n      await driver.find(\".test-modal-confirm\").click();\n      await checkOpen();\n\n      // Note: still have \"(saving=0)\" text, which confirms that saveFunc() is not pending.\n      assert.equal(await driver.find(\".test-modal-title\").getText(), \"Title [] (saving=0)\");\n\n      // Type something else; the button should be enabled again.\n      await driver.find(\".testui-save-modal-input\").sendKeys(\"Hello\");\n      assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), null);\n    });\n\n    it(\"should respect modalArgs argument\", async () => {\n      await checkOpen();    // Still open from last test case.\n      assert.equal(await driver.find(\".test-modal-dialog\").getCssValue(\"opacity\"), \"1\");\n\n      // Dialog is set up to change opacity when input has the text \"translucent\".\n      await driver.find(\".testui-save-modal-input\").doClear().sendKeys(\"translucent\");\n      assert.equal(await driver.find(\".test-modal-dialog\").getCssValue(\"opacity\"), \"0.5\");\n\n      await driver.find(\".testui-save-modal-input\").doClear().sendKeys(\"Hello\");\n      assert.equal(await driver.find(\".test-modal-dialog\").getCssValue(\"opacity\"), \"1\");\n    });\n\n    it(\"should disable Save button while saving\", async () => {\n      await checkOpen();    // Still open from last test case.\n\n      // Check that the Save button is enabled; then click it.\n      assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), null);\n      await driver.find(\".test-modal-confirm\").click();\n\n      // Check that there is a \"(saving=1)\" suffix in the title, and that the button is disabled.\n      assert.equal(await driver.find(\".test-modal-title\").getText(), \"Title [Hello] (saving=1)\");\n      assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n\n      // A second click does nothing, in particular, does not call saveFunc() again.\n      await driver.find(\".test-modal-confirm\").click();\n      assert.equal(await driver.find(\".test-modal-title\").getText(), \"Title [Hello] (saving=1)\");\n      assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n\n      // Neither does hitting Enter.\n      await driver.sendKeys(Key.ENTER);\n      assert.equal(await driver.find(\".test-modal-title\").getText(), \"Title [Hello] (saving=1)\");\n      assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n\n      // If rejected, dialog stays open and Save button reenabled. We reject by typing \"n\" key.\n      await driver.sendKeys(\"n\");\n      await checkOpen();\n      assert.equal(await driver.find(\".test-modal-title\").getText(), \"Title [Hello] (saving=0)\");\n      assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), null);\n\n      // Click Save again.\n      await driver.find(\".test-modal-confirm\").click();\n      assert.equal(await driver.find(\".test-modal-title\").getText(), \"Title [Hello] (saving=1)\");\n      assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n\n      // If fulfilled, the dialog closes. We fulfill by typing \"y\".\n      await driver.sendKeys(\"y\");\n      await checkClosed();\n\n      // Open again and save via Enter.\n      await driver.find(\".testui-save-modal-opener\").click();\n      await checkOpen();\n      await driver.sendKeys(Key.ENTER);\n      assert.equal(await driver.find(\".test-modal-title\").getText(), \"Title [Hello] (saving=1)\");\n      assert.equal(await driver.find(\".test-modal-confirm\").getAttribute(\"disabled\"), \"true\");\n      await driver.sendKeys(\"y\");\n      await checkClosed();\n    });\n\n    it(\"should run disposers associated with owner argument\", async () => {\n      await checkClosed();\n      assert.equal(await driver.find(\".testui-save-modal-is-open\").getText(), \"Modal Closed\");\n\n      // While open, the 'is-open' span turns to 'Open'.\n      await driver.find(\".testui-save-modal-opener\").click();\n      await checkOpen();\n      assert.equal(await driver.find(\".testui-save-modal-is-open\").getText(), \"Modal Open\");\n\n      // Close via Escape.\n      await driver.sendKeys(Key.ESCAPE);\n      assert.equal(await driver.find(\".testui-save-modal-is-open\").getText(), \"Modal Closed\");\n\n      // Open again.\n      await driver.find(\".testui-save-modal-opener\").click();\n      await checkOpen();\n      assert.equal(await driver.find(\".testui-save-modal-is-open\").getText(), \"Modal Open\");\n\n      // Close via Enter, and resolving the promise.\n      await driver.sendKeys(Key.ENTER);\n      await driver.sendKeys(\"y\");\n      assert.equal(await driver.find(\".testui-save-modal-is-open\").getText(), \"Modal Closed\");\n    });\n  });\n\n  describe(\"spinner modal\", async function() {\n    it(\"should show spinner until taks resolves\", async function() {\n      // open the modal\n      await driver.find(\".testui-spinner-modal-opener\").click();\n      await checkOpen();\n\n      // check modal is shown with a spinner\n      assert.equal(await driver.find(\".test-modal-spinner\").isPresent(), true);\n\n      // press Escape\n      await driver.sendKeys(Key.ESCAPE);\n\n      // check modal is still there\n      assert.equal(await driver.find(\".test-modal-spinner\").isPresent(), true);\n\n      // click the resolve button\n      await driver.find(\".testui-resolve-spinner-task\").click();\n\n      // check the modal is hidden\n      await checkClosed();\n\n      // check the after spinner message is shown\n      assert.equal(await driver.find(\".testui-after-spinner\").isPresent(), true);\n    });\n  });\n});\n"
  },
  {
    "path": "test/projects/mouseDrag.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver } from \"mocha-webdriver\";\n\ndescribe(\"mouseDrag\", () => {\n  setupTestSuite();\n\n  before(async function() {\n    this.timeout(60000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/mouseDrag`);\n  });\n\n  it(\"should trigger callbacks on mouse events\", async function() {\n    // We run through some motions several times to ensure that we are not getting extraneous\n    // events, e.g. if we were not properly removing listeners. Events are logged by single\n    // characters into the \"events\" property recorded into .test-results).\n    for (let i = 0; i < 3; i++) {\n      await driver.find(\".test-box\").mouseMove();\n      await driver.mouseDown();\n      await driver.sleep(10);\n      assert.deepEqual(JSON.parse(await driver.find(\".test-result\").getText()), {\n        status: \"started\",\n        events: \"s\",\n        start: { pageX: 175, pageY: 150 },\n      });\n\n      await driver.mouseMoveBy({ x: 100, y: 20 });\n      assert.deepEqual(JSON.parse(await driver.find(\".test-result\").getText()), {\n        status: \"moved\",\n        events: \"sm\",\n        start: { pageX: 175, pageY: 150 },\n        move: { pageX: 275, pageY: 170 },\n      });\n\n      // Test that the mouse can move over a different element, and be released there.\n      await driver.find(\".test-result\").mouseMove();\n      assert.deepEqual(JSON.parse(await driver.find(\".test-result\").getText()), {\n        status: \"moved\",\n        events: \"smm\",\n        start: { pageX: 175, pageY: 150 },\n        move: { pageX: 550, pageY: 150 },\n      });\n\n      await driver.mouseUp();\n      assert.deepEqual(JSON.parse(await driver.find(\".test-result\").getText()), {\n        status: \"stopped\",\n        events: \"smmS\",\n        start: { pageX: 175, pageY: 150 },\n        stop: { pageX: 550, pageY: 150 },\n      });\n    }\n  });\n});\n"
  },
  {
    "path": "test/projects/resizeHandle.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Origin } from \"mocha-webdriver\";\n\ndescribe(\"resizeHandle\", function() {\n  setupTestSuite();\n  this.timeout(10000);      // Set a longer default timeout.\n\n  before(async function() {\n    this.timeout(60000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/resizeHandle`);\n  });\n\n  function dragByX(x: number) {\n    return driver.withActions(a => a.press().move({ x, origin: Origin.POINTER }).release());\n  }\n  function reset() {\n    return driver.find(\".test-reset\").click();\n  }\n\n  it(\"should be possible to grab on either side of edge\", async function() {\n    // Make sure we can grab a bit to the right and to the left of the expected position.\n    await driver.find(\".test-left\").mouseMove({ x: 75 });   // right edge\n    await dragByX(-1);\n    assert.deepEqual(await driver.find(\".test-left\").getText(), `width 149`);\n    await reset();\n\n    await driver.find(\".test-left\").mouseMove({ x: 77 });   // 2px right of right edge\n    await dragByX(5);\n    assert.deepEqual(await driver.find(\".test-left\").getText(), `width 155`);\n    await reset();\n\n    await driver.find(\".test-left\").mouseMove({ x: 73 });   // 2px left of right edge\n    await dragByX(-10);\n    assert.deepEqual(await driver.find(\".test-left\").getText(), `width 140`);\n    await reset();\n\n    // Same for the right side.\n    await driver.find(\".test-right\").mouseMove({ x: -75 });   // left edge\n    await dragByX(-2);\n    assert.deepEqual(await driver.find(\".test-right\").getText(), `width 152`);\n    await reset();\n\n    await driver.find(\".test-right\").mouseMove({ x: -77 });   // 2px left of left edge\n    await dragByX(10);\n    assert.deepEqual(await driver.find(\".test-right\").getText(), `width 140`);\n    await reset();\n\n    await driver.find(\".test-right\").mouseMove({ x: -73 });   // 2px right of left edge\n    await dragByX(-25);\n    assert.deepEqual(await driver.find(\".test-right\").getText(), `width 175`);\n    await reset();\n  });\n\n  it(\"should resize and respect limits\", async function() {\n    // Left panel, limited to (50, 275) range\n    await driver.find(\".test-left\").mouseMove({ x: 75 });\n    await dragByX(-120);\n    assert.deepEqual(await driver.find(\".test-left\").getText(), `width 50`);\n    await reset();\n\n    await driver.find(\".test-left\").mouseMove({ x: 75 });\n    await dragByX(120);\n    assert.deepEqual(await driver.find(\".test-left\").getText(), `width 270`);\n    await dragByX(10);\n    assert.deepEqual(await driver.find(\".test-left\").getText(), `width 275`);\n    await reset();\n\n    // Right panel, limited to (50, 275) range\n    await driver.find(\".test-right\").mouseMove({ x: -75 });\n    await dragByX(120);\n    assert.deepEqual(await driver.find(\".test-right\").getText(), `width 50`);\n    await reset();\n\n    await driver.find(\".test-right\").mouseMove({ x: -75 });\n    await dragByX(-120);\n    assert.deepEqual(await driver.find(\".test-right\").getText(), `width 270`);\n    await dragByX(-10);\n    assert.deepEqual(await driver.find(\".test-right\").getText(), `width 275`);\n    await reset();\n  });\n});\n"
  },
  {
    "path": "test/projects/searchDropdown.ts",
    "content": "import { server } from \"test/fixtures/projects/webpack-test-server\";\n\nimport { assert, driver, Key, useServer } from \"mocha-webdriver\";\n\ndescribe(\"searchDropdown\", function() {\n  useServer(server);\n\n  beforeEach(async function() {\n    await driver.find(\".test-reset\").click();\n  });\n\n  before(async function() {\n    await driver.get(`${server.getHost()}/searchDropdown`);\n  });\n\n  function open() {\n    return driver.findContent(\"button\", \"Add column\").click();\n  }\n\n  function isOpened() {\n    return driver.find(\".grist-floating-menu\").isPresent();\n  }\n\n  function findItem(name: string) {\n    return driver.findContent(\"li\", name);\n  }\n\n  function getLogs() {\n    return driver.findAll(\".test-logs\", e => e.getText());\n  }\n\n  async function getOptions(count?: number) {\n    const opts = await  driver.findAll(\".test-sd-searchable-list-item\", e => e.getText());\n    return opts.slice(0, count);\n  }\n\n  async function getSelected() {\n    const sel = driver.find(\".test-sd-searchable-list-item[class*=-sel]\");\n    return (await sel.isPresent()) ? sel.getText() : \"\";\n  }\n\n  it(\"click should log item\", async function() {\n    await open();\n    await findItem(\"Santa\").click();\n    assert.deepEqual(await getLogs(), [\"click: Santa\"]);\n  });\n\n  it(\"typing should select first match\", async function() {\n    await open();\n    assert.equal(await driver.find(\".test-sd-searchable-list-item\").getText(), \"Foo\");\n    await driver.sendKeys(\"Rome\");\n    assert.equal(await driver.find(\".test-sd-searchable-list-item\").getText(), \"Romeo\");\n    assert.equal(await getSelected(), \"Romeo\");\n  });\n\n  it(\"enter should log selected\", async function() {\n    await open();\n    await driver.sendKeys(\"Rome\", Key.ENTER);\n    assert.deepEqual(await getLogs(), [\"click: Romeo\"]);\n  });\n\n  it(\"Escape should close\", async function() {\n    await open();\n    await driver.sendKeys(Key.ESCAPE);\n    assert.equal(await isOpened(), false);\n  });\n\n  it(\"Should support arrow navigation\", async function() {\n    await open();\n    assert.deepEqual(await getOptions(5), [\"Foo\", \"Bar\", \"Fusion\", \"Maya\", \"Santa\"]);\n    assert.equal(await getSelected(), \"\");\n    await driver.sendKeys(Key.ARROW_DOWN);\n    assert.equal(await getSelected(), \"Foo\");\n    await driver.sendKeys(Key.ARROW_DOWN);\n    assert.equal(await getSelected(), \"Bar\");\n    await driver.sendKeys(Key.ARROW_UP);\n    assert.equal(await getSelected(), \"Foo\");\n    await driver.sendKeys(Key.ARROW_UP);\n    assert.equal(await getSelected(), \"\");\n  });\n\n  it(\"Typing should always update search string\", async function() {\n    await open();\n    await driver.sendKeys(\"Rome\");\n    assert.equal(await driver.find(\".grist-floating-menu input\").value(), \"Rome\");\n    await driver.sendKeys(Key.ARROW_DOWN, \"ZZ\");\n    assert.equal(await driver.find(\".grist-floating-menu input\").value(), \"RomeZZ\");\n  });\n\n  it(\"Should support mouse selection\", async function() {\n    await open();\n    assert.equal(await getSelected(), \"\");\n    await findItem(\"Clara\").mouseMove();\n    assert.equal(await getSelected(), \"Clara\");\n\n    // should unselect on mouse leave\n    await driver.find(\".test-reset\").mouseMove();\n    assert.equal(await getSelected(), \"\");\n  });\n\n  it(\"Should trigger action on click\", async function() {\n    await open();\n    await findItem(\"Romeo\").doClick();\n    assert.deepEqual(await getLogs(), [\"click: Romeo\"]);\n  });\n\n  it(\"click on header shouldn't close popup\", async function() {\n    await open();\n    await driver.sendKeys(\"Rome\");\n    await driver.find(\"div[style*=icon-Search]\").click();\n    assert.equal(await isOpened(), true);\n    await driver.sendKeys(Key.ESCAPE);\n    assert.equal(await isOpened(), false);\n  });\n});\n"
  },
  {
    "path": "test/projects/sessionObs.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key } from \"mocha-webdriver\";\n\ndescribe(\"sessionObs\", function() {\n  setupTestSuite();\n  this.timeout(10000);      // Set a longer default timeout.\n\n  before(async function() {\n    this.timeout(60000);      // Set a longer default timeout.\n    await driver.get(`${server.getHost()}/sessionObs`);\n  });\n\n  it(\"should initially show default values\", async function() {\n    assert.equal(await driver.find(\".test-plain-obs\").value(), \"Hello\");\n    assert.equal(await driver.find(\".test-bool-obs\").value(), \"true\");\n    assert.equal(await driver.find(\".test-num-obs\").value(), \"100\");\n    assert.equal(await driver.find(\".test-fruit-obs\").value(), \"apples\");\n  });\n\n  it(\"should keep values across reload\", async function() {\n    await driver.find(\".test-plain-obs\").doClear().sendKeys(\"World\", Key.ENTER);\n    await driver.find(\".test-bool-obs\").doClear().sendKeys(\"false\", Key.ENTER);\n    await driver.find(\".test-num-obs\").doClear().sendKeys(\"451\", Key.ENTER);\n    await driver.find(\".test-fruit-obs\").doClear().sendKeys(\"melons\", Key.ENTER);\n    await driver.find(\".test-save\").click();\n\n    await driver.navigate().refresh();\n\n    // The first (plain) value reverts to default; the rest keep their new values.\n    assert.equal(await driver.findWait(\".test-plain-obs\", 1000).value(), \"Hello\");\n    assert.equal(await driver.find(\".test-bool-obs\").value(), \"false\");\n    assert.equal(await driver.find(\".test-num-obs\").value(), \"451\");\n    assert.equal(await driver.find(\".test-fruit-obs\").value(), \"melons\");\n  });\n\n  it(\"should treat invalid values as defaults\", async function() {\n    await driver.find(\".test-plain-obs\").doClear().sendKeys(\"foo\", Key.ENTER);\n    await driver.find(\".test-bool-obs\").doClear().sendKeys(\"foo\", Key.ENTER);\n    await driver.find(\".test-num-obs\").doClear().sendKeys(\"foo\", Key.ENTER);\n    await driver.find(\".test-fruit-obs\").doClear().sendKeys(\"foo\", Key.ENTER);\n    await driver.find(\".test-save\").click();\n\n    // Check current values. Some are transformed to \"invalid\" by the fixture..\n    assert.equal(await driver.find(\".test-plain-obs\").value(), \"foo\");\n    assert.equal(await driver.find(\".test-bool-obs\").value(), \"foo\");\n    assert.equal(await driver.find(\".test-num-obs\").value(), \"foo\");\n    assert.equal(await driver.find(\".test-fruit-obs\").value(), \"foo\");\n\n    await driver.navigate().refresh();\n\n    // The first (plain) value reverts to default because not saved;\n    // the rest revert to default because invalid.\n    assert.equal(await driver.findWait(\".test-plain-obs\", 1000).value(), \"Hello\");\n    assert.equal(await driver.find(\".test-bool-obs\").value(), \"true\");\n    assert.equal(await driver.find(\".test-num-obs\").value(), \"100\");\n    assert.equal(await driver.find(\".test-fruit-obs\").value(), \"apples\");\n  });\n});\n"
  },
  {
    "path": "test/projects/simpleList.ts",
    "content": "import { server } from \"test/fixtures/projects/webpack-test-server\";\n\nimport { assert, driver, Key,  useServer } from \"mocha-webdriver\";\n\ndescribe(\"simpleList\", function() {\n  useServer(server);\n\n  beforeEach(async function() {\n    await driver.get(`${server.getHost()}/simpleList`);\n  });\n\n  function getLogs() {\n    return driver.findAll(\".test-logs div\", e => e.getText());\n  }\n\n  function toggle() {\n    return driver.find(\"input\").click();\n  }\n\n  function getSelected() {\n    return driver.findAll(\".grist-floating-menu [class*=-sel]\", e => e.getText());\n  }\n\n  it(\"should support keyboard navigation without stealing focus\", async function() {\n    await toggle();\n    await driver.sendKeys(Key.ARROW_DOWN);\n    assert.deepEqual(await getSelected(), [\"foo\"]);\n    await driver.sendKeys(Key.ENTER);\n    assert.deepEqual(await getLogs(), [\"foo\"]);\n  });\n\n  it(\"should trigger action on click\", async function() {\n    await toggle();\n    await driver.findContent(\".grist-floating-menu li\", \"bar\").click();\n    assert.deepEqual(await getLogs(), [\"bar\"]);\n  });\n\n  it(\"should update selected on mouse hover\", async function() {\n    await toggle();\n    await driver.findContent(\".grist-floating-menu li\", \"bar\").mouseMove();\n    assert.deepEqual(await getSelected(), [\"bar\"]);\n  });\n});\n"
  },
  {
    "path": "test/projects/testUtils.ts",
    "content": "import { server } from \"test/fixtures/projects/webpack-test-server\";\n\nimport { addToRepl, driver, enableDebugCapture, Key, useServer } from \"mocha-webdriver\";\n\n// Exports the webpack-dev-server that we set up in setupTestSuite(), mainly for its getHost()\n// method, e.g.\n//\n//    await driver.get(`${server.getHost()}/MY-PAGE`);\n//\nexport { server };\n\n// Sets up the test suite to use the webpack-dev-server to serve test/fixtures/projects files, and\n// to record logs and screenshots after failed tests (if MOCHA_WEBDRIVER_LOGDIR var is set).\nexport function setupTestSuite() {\n  useServer(server);\n  enableDebugCapture();\n  addToRepl(\"Key\", Key, \"key values such as Key.ENTER\");\n\n  // After every suite, clear sessionStorage and localStorage to avoid affecting other tests.\n  after(clearCurrentWindowStorage);\n}\n\nasync function clearCurrentWindowStorage() {\n  if ((await driver.getCurrentUrl()).startsWith(\"http\")) {\n    try {\n      await driver.executeScript(\"window.sessionStorage.clear(); window.localStorage.clear();\");\n    } catch (err) {\n      console.log(\"Could not clear window storage after the test ended: %s\", err.message);\n    }\n  }\n}\n"
  },
  {
    "path": "test/projects/tokenfield.ts",
    "content": "import { server } from \"test/fixtures/projects/webpack-test-server\";\nimport * as gu from \"test/nbrowser/gristUtils\";\n\nimport { addToRepl, assert, driver, Key, stackWrapFunc, useServer, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"tokenfield\", function() {\n  useServer(server);\n  const clipboard = gu.getLockableClipboard();\n  let modKey: string;\n\n  before(async function() {\n    modKey = await gu.modKey();\n    addToRepl(\"gu\", gu, \"gristUtils, grist-specific helpers\");\n    addToRepl(\"Key\", Key, \"key values such as Key.ENTER\");\n    addToRepl(\"modKey\", modKey, \"COMMAND or CONTROL key, depending on OS\");\n  });\n\n  beforeEach(async function() {\n    await driver.get(`${server.getHost()}/tokenfield`);\n  });\n\n  type TFType = \"plain\" | \"ac\";\n\n  const checkTokenText = stackWrapFunc(async function(type: TFType, expectedValues: string[]) {\n    assert.deepEqual(await driver.findAll(`.test-tokenfield-${type} .test-tokenfield-token`, el => el.getText()),\n      expectedValues.map(v => v.split(\"=\")[0]));\n    assert.equal(await driver.find(`.test-tokenfield-${type} .test-json-value`).getText(),\n      JSON.stringify(expectedValues));\n  });\n\n  const getSelectedTokens = stackWrapFunc(async function(type: TFType): Promise<string[]> {\n    return await driver.findAll(`.test-tokenfield-${type} .test-tokenfield-token.selected`, el => el.getText());\n  });\n\n  const clickWithKey = stackWrapFunc(async function(key: string, elem: WebElement) {\n    await driver.withActions(a => a.keyDown(key).click(elem).keyUp(key));\n  });\n\n  const dragToken = stackWrapFunc(async function(type: TFType, dragLabel: string, destLabel: string | null) {\n    await driver.findContent(`.test-tokenfield-${type} .test-tokenfield-token`, dragLabel).mouseMove();\n    await driver.mouseDown();\n    if (destLabel) {\n      await driver.findContent(`.test-tokenfield-${type} .test-tokenfield-token`, destLabel).mouseMove();\n    } else {\n      await driver.find(`.test-tokenfield-${type} .test-tokenfield-input`).mouseMove();\n    }\n    await driver.mouseUp();\n  });\n\n  it(`should show initial values for tokenfield`, async function() {\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\"]);\n    await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\"]);\n  });\n\n  it(\"should add a token by typing into plain textbox\", async function() {\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    await driver.sendKeys(\"Pink Elephant\", Key.ENTER);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Pink Elephant=0\"]);\n  });\n\n  it(\"should add a token by using autocomplete\", async function() {\n    await driver.find(\".test-tokenfield-ac .test-tokenfield-input\").click();\n\n    // Check typing of an entry in the autocomplete.\n    await driver.sendKeys(\"Pa\");\n    assert.equal(await driver.find(\".test-autocomplete .selected\").getText(), \"Parakeet\");\n    await driver.sendKeys(Key.ENTER);\n    await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\", \"Parakeet=30\"]);\n\n    // Check selecting via dropdown in the autocomplete.\n    await driver.sendKeys(\"mon\");\n    await driver.findContentWait(\".test-autocomplete li\", /Golden Monkey/, 100).click();\n    await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\", \"Parakeet=30\", \"Golden Monkey=50\"]);\n  });\n\n  it(\"should support Tab to add a token\", async function() {\n    // In plain tokenfield, type something and hit Tab.\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    await driver.sendKeys(\"tiger\", Key.TAB);\n\n    // The token should be added.\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"tiger=0\"]);\n\n    // Hitting Tab again should move to next field.\n    assert.isTrue(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").hasFocus());\n    await driver.sendKeys(Key.TAB);\n    assert.isFalse(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").hasFocus());\n    assert.isTrue(await driver.find(\".test-tokenfield-ac .test-tokenfield-input\").hasFocus());\n\n    // In autocomplete, use dropdown and hit Tab.\n    await driver.sendKeys(Key.DOWN, Key.DOWN, Key.DOWN);    // Should get to Dog.\n    await driver.sendKeys(Key.TAB);\n\n    // The token should be added.\n    await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\", \"Dog=20\"]);\n\n    // Hitting Tab again should move to next field.\n    assert.isTrue(await driver.find(\".test-tokenfield-ac .test-tokenfield-input\").hasFocus());\n    await driver.sendKeys(Key.TAB);\n    assert.isFalse(await driver.find(\".test-tokenfield-ac .test-tokenfield-input\").hasFocus());\n  });\n\n  it(\"should allow deleting tokens by clicking their x button\", async function() {\n    await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Frog/)\n      .find(\".test-tokenfield-delete\").click();\n    await checkTokenText(\"plain\", [\"Cat=10\"]);\n    await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Cat/)\n      .find(\".test-tokenfield-delete\").click();\n    await checkTokenText(\"plain\", []);\n\n    // Try the same in the autocomplete one, but in a different order.\n    await driver.findContent(\".test-tokenfield-ac .test-tokenfield-token\", /Cat/)\n      .find(\".test-tokenfield-delete\").click();\n    await checkTokenText(\"ac\", [\"Frog=40\"]);\n    await driver.findContent(\".test-tokenfield-ac .test-tokenfield-token\", /Frog/)\n      .find(\".test-tokenfield-delete\").click();\n    await checkTokenText(\"ac\", []);\n  });\n\n  it(\"should support selecting multiple tokens using clicks\", async function() {\n    // Add a couple more tokens.\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    await driver.sendKeys(\"Mouse\", Key.ENTER);\n    await driver.sendKeys(\"Crab\", Key.ENTER);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Crab=0\"]);\n\n    assert.deepEqual(await getSelectedTokens(\"plain\"), []);\n    await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Cat/).click();\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Cat\"]);\n    await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Mouse/).click();\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Mouse\"]);\n\n    await clickWithKey(Key.SHIFT, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Cat/));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Cat\", \"Frog\", \"Mouse\"]);\n    await clickWithKey(Key.SHIFT, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Frog/));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Frog\", \"Mouse\"]);\n    await clickWithKey(Key.SHIFT, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Crab/));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Mouse\", \"Crab\"]);\n\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Cat/));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Cat\", \"Mouse\", \"Crab\"]);\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Crab/));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Cat\", \"Mouse\"]);\n    await clickWithKey(Key.SHIFT, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Crab/));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Mouse\", \"Crab\"]);\n    await clickWithKey(Key.SHIFT, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Cat/));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Cat\", \"Frog\", \"Mouse\"]);\n  });\n\n  it(\"should support selecting tokens using Ctrl+A\", async function() {\n    // Add another token.\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    await gu.sendKeys(\"Mouse\", Key.ENTER);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\"]);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), []);\n\n    // While there is input in the textbox, Ctrl+A selects the text there. (For this test, use a\n    // single character, because webdriver's Ctrl+A doesn't work for native textboxes -- we are\n    // not really testing that part, but typing in longer text would make it harder to clear.)\n    await gu.sendKeys(\"m\");\n    await gu.sendKeys(Key.chord(modKey, \"a\"));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), []);\n    assert.equal(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value(), \"m\");\n    await gu.sendKeys(Key.BACK_SPACE);\n    assert.equal(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value(), \"\");\n\n    // Select all. With empty textbox, it selects tokens.\n    await gu.sendKeys(Key.chord(modKey, \"a\"));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Cat\", \"Frog\", \"Mouse\"]);\n\n    // Unselect some.\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Cat/));\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Mouse/));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Frog\"]);\n\n    // Select all again, when focus is not inside the input element.\n    await gu.sendKeys(Key.chord(modKey, \"a\"));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Cat\", \"Frog\", \"Mouse\"]);\n\n    // Click into input, selection should be cleared.\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    assert.deepEqual(await getSelectedTokens(\"plain\"), []);\n  });\n\n  it(\"should delete last item on backspace with no selection\", async function() {\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    await gu.sendKeys(\"m\");\n    assert.equal(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value(), \"m\");\n    await gu.sendKeys(Key.BACK_SPACE);\n    assert.equal(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value(), \"\");\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\"]);\n\n    await gu.sendKeys(Key.BACK_SPACE);\n    assert.equal(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value(), \"\");\n    await checkTokenText(\"plain\", [\"Cat=10\"]);\n    await gu.sendKeys(Key.BACK_SPACE);\n    await checkTokenText(\"plain\", []);\n    await gu.sendKeys(Key.BACK_SPACE);\n    await checkTokenText(\"plain\", []);\n  });\n\n  it(\"should delete and update selection using Delete or Backspace\", async function() {\n    // Add a few more tokens.\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    await driver.sendKeys(\"Mouse\", Key.ENTER);\n    await driver.sendKeys(\"Crab\", Key.ENTER);\n    await driver.sendKeys(\"Ant\", Key.ENTER);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Crab=0\", \"Ant=0\"]);\n\n    // Select some tokens and press DELETE.\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Frog/));\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Crab/));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Frog\", \"Crab\"]);\n    await gu.sendKeys(Key.DELETE);\n\n    // The following token should be selected after deletion.\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Mouse=0\", \"Ant=0\"]);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Ant\"]);\n\n    // Hit DELETE again, and focus should return to the text input.\n    await gu.sendKeys(Key.DELETE);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Mouse=0\"]);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), []);\n    assert.isTrue(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").hasFocus());\n\n    // Add more elements, and repeat using BACKSPACE.\n    await driver.sendKeys(\"Sloth\", Key.ENTER);\n    await driver.sendKeys(\"Bat\", Key.ENTER);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Mouse=0\", \"Sloth=0\", \"Bat=0\"]);\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Mouse/));\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Bat/));\n    await gu.sendKeys(Key.BACK_SPACE);\n\n    // The preceding token should be selected after deletion.\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Sloth=0\"]);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Cat\"]);\n\n    // Hit backspace again, and focus should return to the text input.\n    await gu.sendKeys(Key.BACK_SPACE);\n    await checkTokenText(\"plain\", [\"Sloth=0\"]);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), []);\n    assert.isTrue(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").hasFocus());\n  });\n\n  it(\"should support arrow keys including with Shift\", async function() {\n    // Add some more tokens.\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    await driver.sendKeys(\"Mouse\", Key.ENTER);\n    await driver.sendKeys(\"Crab\", Key.ENTER);\n    await driver.sendKeys(\"Ant\", Key.ENTER);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Crab=0\", \"Ant=0\"]);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), []);\n\n    // Navigate around using arrows and shift+arrows.\n    await gu.sendKeys(Key.LEFT);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Ant\"]);\n    await gu.sendKeys(Key.LEFT);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Crab\"]);\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Mouse\", \"Crab\"]);\n    await gu.sendKeys(Key.LEFT);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Frog\"]);\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Cat\", \"Frog\"]);\n\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Frog\", \"Mouse\", \"Crab\"]);\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.LEFT));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Frog\", \"Mouse\"]);\n    await gu.sendKeys(Key.chord(Key.SHIFT, Key.RIGHT));\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Frog\", \"Mouse\", \"Crab\"]);\n    await gu.sendKeys(Key.RIGHT);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Ant\"]);\n    await gu.sendKeys(Key.RIGHT);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), []);\n    assert.isTrue(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").hasFocus());\n  });\n\n  it(\"should select a single item even when multiple equal ones present\", async function() {\n    await driver.find(\".test-tokenfield-ac .test-tokenfield-input\").click();\n    await driver.sendKeys(\"Cat\", Key.ENTER);\n    await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\", \"Cat=10\"]);\n\n    await driver.findContent(\".test-tokenfield-ac .test-tokenfield-token\", /Cat/).click();\n    assert.deepEqual(await getSelectedTokens(\"ac\"), [\"Cat\"]);\n    await clickWithKey(modKey,\n      driver.findContent(\".test-tokenfield-ac .test-tokenfield-token:not(:first-child)\", /Cat/));\n    assert.deepEqual(await getSelectedTokens(\"ac\"), [\"Cat\", \"Cat\"]);\n    await driver.findContent(\".test-tokenfield-ac .test-tokenfield-token\", /Cat/).click();\n    assert.deepEqual(await getSelectedTokens(\"ac\"), [\"Cat\"]);\n    await gu.sendKeys(Key.DELETE);\n    await checkTokenText(\"ac\", [\"Frog=40\", \"Cat=10\"]);\n    assert.deepEqual(await getSelectedTokens(\"ac\"), [\"Frog\"]);\n  });\n\n  it(\"should support cut-copy-paste\", async function() {\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\"]);\n    await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\"]);\n\n    // Copy-paste a token from one tokenfield to the other.\n    await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", /Cat/).click();\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await driver.find(\".test-tokenfield-ac .test-tokenfield-input\").click();\n      await cb.paste();\n    });\n    await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\", \"Cat=10\"]);\n\n    // Add another token, and select a range.\n    await driver.find(\".test-tokenfield-ac .test-tokenfield-input\").click();\n    await driver.sendKeys(\"Parakeet\", Key.ENTER);\n    await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\", \"Cat=10\", \"Parakeet=30\"]);\n    await driver.findContent(\".test-tokenfield-ac .test-tokenfield-token\", /Parakeet/).click();\n    await clickWithKey(Key.SHIFT, driver.findContent(\".test-tokenfield-ac .test-tokenfield-token\", /Frog/));\n    assert.deepEqual(await getSelectedTokens(\"ac\"), [\"Frog\", \"Cat\", \"Parakeet\"]);\n\n    // Copy-paste some tokens to a textbox.\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await driver.find(\".test-copypaste\").click();\n      await cb.paste();\n    });\n    assert.equal(await driver.find(\".test-copypaste\").value(), \"Frog, Cat, Parakeet\");\n    await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\", \"Cat=10\", \"Parakeet=30\"]);\n\n    // Clear the textbox and select the range of tokens again.\n    await driver.find(\".test-copypaste\").clear();\n    assert.equal(await driver.find(\".test-copypaste\").value(), \"\");\n    await driver.findContent(\".test-tokenfield-ac .test-tokenfield-token\", /Parakeet/).click();\n    await clickWithKey(Key.SHIFT, driver.findContent(\".test-tokenfield-ac .test-tokenfield-token\", /Frog/));\n    assert.deepEqual(await getSelectedTokens(\"ac\"), [\"Frog\", \"Cat\", \"Parakeet\"]);\n\n    // Cut-n-paste tokens into the textbox. They should be gone from the source.\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.cut();\n      await driver.find(\".test-copypaste\").click();\n      await cb.paste();\n    });\n    assert.equal(await driver.find(\".test-copypaste\").value(), \"Frog, Cat, Parakeet\");\n    await checkTokenText(\"ac\", [\"Cat=10\"]);\n  });\n\n  it(\"should support copy-pasting tokens with special characters\", async function() {\n    await driver.find(\".test-copypaste\").clear();\n\n    // Create some tokens with special characters.\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    await gu.sendKeys(\"With, comma\", Key.ENTER);\n    await gu.sendKeys('\"', Key.ENTER);\n    await gu.sendKeys(\"ωμέγα\", Key.ENTER);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"With, comma=0\", '\"=0', \"ωμέγα=0\"]);\n\n    // Select all and copy-paste to textbox.\n    await gu.sendKeys(Key.chord(modKey, \"a\"));\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await driver.find(\".test-copypaste\").click();\n      await cb.paste();\n    });\n    assert.equal(await driver.find(\".test-copypaste\").value(),\n      'Cat, Frog, \"With, comma\", \"\"\"\", ωμέγα');\n  });\n\n  it(\"should support pasting in from CSV text\", async function() {\n    // Enter new text into textbox, select and copy.\n    await driver.find(\".test-copypaste\").clear();\n    await driver.find(\".test-copypaste\").click();\n    await gu.sendKeys('Dog,Giraffe, \"Golden Monkey\", pájaro ');\n    await gu.sendKeys(await gu.selectAllKey());\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n\n      // Clear \"plain\" tokenfield and paste into there. All tokens should populate.\n      await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n      await cb.paste();\n      await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Dog=0\", \"Giraffe=0\", \"Golden Monkey=0\", \"pájaro=0\"]);\n\n      // Clear \"autocomplete\" tokenfield and paste into there. Only known tokens should populate.\n      await driver.find(\".test-tokenfield-ac .test-tokenfield-input\").click();\n      await cb.paste();\n    });\n    await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\", \"Dog=20\", \"Golden Monkey=50\"]);\n  });\n\n  it(\"should replace selected tokens when pasting over them\", async function() {\n    await driver.find(\".test-copypaste\").clear();\n    await driver.find(\".test-copypaste\").click();\n    await gu.sendKeys(' Dog,\"Golden Monkey\"');\n    await gu.sendKeys(await gu.selectAllKey());\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n\n      await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\"]);\n      await checkTokenText(\"ac\", [\"Cat=10\", \"Frog=40\"]);\n\n      // Paste over 'Cat' in the \"autocomplete\" tokenfield.\n      await driver.find(\".test-tokenfield-ac .test-tokenfield-input\").click();\n      await driver.findContent(\".test-tokenfield-ac .test-tokenfield-token\", /Cat/).click();\n      await cb.paste();\n      await checkTokenText(\"ac\", [\"Dog=20\", \"Golden Monkey=50\", \"Frog=40\"]);\n      assert.deepEqual(await getSelectedTokens(\"ac\"), [\"Dog\", \"Golden Monkey\"]);\n\n      // Paste over all tokens in the \"plain\" tokenfield.\n      await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n      await gu.sendKeys(await gu.selectAllKey());\n      await cb.paste();\n    });\n    await checkTokenText(\"plain\", [\"Dog=0\", \"Golden Monkey=0\"]);\n    assert.isTrue(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").hasFocus());\n  });\n\n  it(\"should allow dragging tokens to move them\", async function() {\n    this.timeout(4000);\n\n    // Add a few more tokens.\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    await driver.sendKeys(\"Mouse\", Key.ENTER);\n    await driver.sendKeys(\"Crab\", Key.ENTER);\n    await driver.sendKeys(\"Ant\", Key.ENTER);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Crab=0\", \"Ant=0\"]);\n\n    // Grab 'Ant', and move it backward.\n    await dragToken(\"plain\", \"Ant\", \"Mouse\");\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Ant=0\", \"Mouse=0\", \"Crab=0\"]);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Ant\"]);\n\n    // Move forward\n    await dragToken(\"plain\", \"Frog\", \"Crab\");\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Ant=0\", \"Mouse=0\", \"Frog=40\", \"Crab=0\"]);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Frog\"]);\n\n    // Move a multiple selection to the beginning\n    await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Mouse\").click();\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Frog\"));\n    await dragToken(\"plain\", \"Mouse\", \"Cat\");\n    await checkTokenText(\"plain\", [\"Mouse=0\", \"Frog=40\", \"Cat=10\", \"Ant=0\", \"Crab=0\"]);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Mouse\", \"Frog\"]);\n\n    // Move element to the end.\n    await dragToken(\"plain\", \"Cat\", null);\n    await checkTokenText(\"plain\", [\"Mouse=0\", \"Frog=40\", \"Ant=0\", \"Crab=0\", \"Cat=10\"]);\n\n    // Move a disjoint selection.\n    await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Ant\").click();\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Cat\"));\n    await dragToken(\"plain\", \"Cat\", \"Frog\");\n    await checkTokenText(\"plain\", [\"Mouse=0\", \"Ant=0\", \"Cat=10\", \"Frog=40\", \"Crab=0\"]);\n    assert.deepEqual(await getSelectedTokens(\"plain\"), [\"Ant\", \"Cat\"]);\n  });\n\n  it(\"should support undo/redo\", async function() {\n    this.timeout(6000);\n    const undoKey = Key.chord(modKey, \"z\");\n    // (modKey, Key.SHIFT, 'z') sent via webdriver doesn't get seen at all for some reason.\n    const redoKey = Key.chord(Key.CONTROL, \"y\");\n    // Add a few more tokens.\n    await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").click();\n    await driver.sendKeys(\"Mouse\", Key.ENTER);\n    await driver.sendKeys(\"Crab\", Key.ENTER);\n    await driver.sendKeys(\"Ant\", Key.ENTER);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Crab=0\", \"Ant=0\"]);\n\n    // Undo/redo the last one.\n    await gu.sendKeys(undoKey);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Crab=0\"]);\n    await gu.sendKeys(redoKey);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Crab=0\", \"Ant=0\"]);\n\n    // Delete one.\n    await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Crab\")\n      .find(\".test-tokenfield-delete\").click();\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Ant=0\"]);\n\n    // Undo/redo deletion.\n    await gu.sendKeys(undoKey);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Crab=0\", \"Ant=0\"]);\n    await gu.sendKeys(redoKey);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Ant=0\"]);\n\n    // Delete a selection.\n    await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Cat\").click();\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Mouse\"));\n    await gu.sendKeys(Key.BACK_SPACE);\n    await checkTokenText(\"plain\", [\"Frog=40\", \"Ant=0\"]);\n\n    // Undo/redo that deletion.\n    await gu.sendKeys(undoKey);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Ant=0\"]);\n    await gu.sendKeys(redoKey);\n    await checkTokenText(\"plain\", [\"Frog=40\", \"Ant=0\"]);\n\n    // Extra redos are harmless\n    await gu.sendKeys(redoKey);\n    await gu.sendKeys(redoKey);\n    await checkTokenText(\"plain\", [\"Frog=40\", \"Ant=0\"]);\n\n    // Undo to return to 5 tokens.\n    await gu.sendKeys(undoKey);\n    await gu.sendKeys(undoKey);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Crab=0\", \"Ant=0\"]);\n\n    // Copy-paste one set of tokens over another.\n    await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Cat\").click();\n    await clickWithKey(modKey, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Mouse\"));\n    await clipboard.lockAndPerform(async (cb) => {\n      await cb.copy();\n      await driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Mouse\").click();\n      await clickWithKey(Key.SHIFT, driver.findContent(\".test-tokenfield-plain .test-tokenfield-token\", \"Ant\"));\n      await cb.paste();\n    });\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Cat=0\", \"Mouse=0\"]);\n\n    // Type into input box. Redo should undo that typing.\n    await gu.sendKeys(\"m\");\n    assert.equal(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value(), \"m\");\n\n    // Undo shortcuts don't seem to work with webdriver for native elements on Mac, but do work on\n    // Linux. So try the shortcut, and if it doesn't work, just clear the input manually. (The\n    // emptiness of the input is what determines whether we intercept undo/redo.)\n    await gu.sendKeys(undoKey);\n    if ((await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value()) === \"m\") {\n      await gu.sendKeys(Key.BACK_SPACE);\n    }\n\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Cat=0\", \"Mouse=0\"]);\n    assert.equal(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value(), \"\");\n\n    // Subsequent undo should undo the last change.\n    await gu.sendKeys(undoKey);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Mouse=0\", \"Crab=0\", \"Ant=0\"]);\n\n    // Redos should redo the token change, then redo the text input change.\n    await gu.sendKeys(redoKey);\n    assert.equal(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value(), \"\");\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Cat=0\", \"Mouse=0\"]);\n\n    // Again, redo shortcuts may or may not work with webdriver for native elements. Try it, but\n    // be prepared for it not working.\n    await gu.sendKeys(redoKey);\n    if ((await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value()) === \"m\") {\n      await gu.sendKeys(undoKey);\n    }\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\", \"Cat=0\", \"Mouse=0\"]);\n    assert.equal(await driver.find(\".test-tokenfield-plain .test-tokenfield-input\").value(), \"\");\n\n    // Return to initial state.\n    await gu.sendKeys(undoKey);\n    await gu.sendKeys(undoKey);\n    await gu.sendKeys(undoKey);\n    await gu.sendKeys(undoKey);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\"]);\n\n    // Extra undos are harmless.\n    await gu.sendKeys(undoKey);\n    await checkTokenText(\"plain\", [\"Cat=10\", \"Frog=40\"]);\n  });\n});\n"
  },
  {
    "path": "test/projects/tooltips.ts",
    "content": "import * as gu from \"test/nbrowser/gristUtils\";\nimport { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key, Origin } from \"mocha-webdriver\";\n\nfunction waitEqual(func: () => Promise<boolean>, expected: boolean, waitMs: number) {\n  return gu.waitToPass(async () => assert.equal(await func(), expected), waitMs);\n}\n\nfunction waitDeepEqual(func: () => Promise<string[]>, expected: string[], waitMs: number) {\n  return gu.waitToPass(async () => assert.deepEqual(await func(), expected), waitMs);\n}\n\ndescribe(\"tooltips\", function() {\n  setupTestSuite();\n  this.timeout(20000);      // Set a longer default timeout.\n\n  before(async function() {\n    await driver.get(`${server.getHost()}/tooltips`);\n  });\n\n  it(\"should select text from the tooltip\", async function() {\n    await driver.find(\".test-visible .test-prefix-info-tooltip\").click();\n    await driver.find(\".test-tooltip-origin\").mouseMove();\n    await driver.withActions((actions) => {\n      // Move way beyond the tooltip.\n      actions.press().move({ origin: Origin.POINTER, x: 200, y: 50 }).release();\n    });\n    assert.equal(\n      await driver.executeScript(`return window.getSelection().toString()`),\n      `Multi line text\\nAnd a \\nhttps://link.to/page.html?with=filter in it`,\n    );\n    // It hides itself after ~2 seconds.\n    await waitEqual(() => driver.find(\".test-tooltip\").isPresent(), false, 3000);\n  });\n\n  it(\"should open on hover, close on mouseout\", async function() {\n    await driver.find(\".test-plain\").mouseMove();\n    await waitEqual(() => driver.find(\".test-tooltip\").isDisplayed(), true, 500);\n    assert.equal(await driver.find(\".test-tooltip\").getText(), \"Tooltip1\");\n    await driver.mouseMoveBy({ x: 200 });\n    await waitEqual(() => driver.find(\".test-tooltip\").isPresent(), false, 1000);\n\n    // If we move into the tooltip, it shouldn't close.\n    await driver.find(\".test-plain\").mouseMove();\n    await waitEqual(() => driver.find(\".test-tooltip\").isDisplayed(), true, 500);\n    await driver.find(\".test-tooltip\").mouseMove();\n    await driver.sleep(600);\n    assert.equal(await driver.find(\".test-tooltip\").isDisplayed(), true);\n    await driver.mouseMoveBy({ x: 200 });\n    await waitEqual(() => driver.find(\".test-tooltip\").isPresent(), false, 1000);\n  });\n\n  it(\"should open immediately on click if requested\", async function() {\n    await driver.find(\".test-fancy\").click();\n    await waitEqual(() => driver.find(\".test-tooltip\").isDisplayed(), true, 500);\n    assert.equal(await driver.find(\".test-tooltip\").getText(), \"Tooltip2\");\n    // This tooltip should auto-expire after 1s without moving mouse away.\n    await waitEqual(() => driver.find(\".test-tooltip\").isPresent(), false, 1500);\n  });\n\n  it(\"should close immediately on click if requested\", async function() {\n    await driver.find(\".test-close-on-click\").mouseMove();\n    await waitEqual(() => driver.find(\".test-tooltip\").isDisplayed(), true, 500);\n    await driver.find(\".test-close-on-click\").click();\n    assert.equal(await driver.find(\".test-tooltip\").isPresent(), false);\n  });\n\n  it(\"should allow a button that closes it\", async function() {\n    await driver.find(\".test-closable\").mouseMove();\n    await waitEqual(() => driver.find(\".test-tooltip\").isDisplayed(), true, 500);\n    assert.equal(await driver.find(\".test-tooltip\").getText(), \"Tooltip3\");\n    await driver.find(\".test-tooltip-close\").click();\n    assert.equal(await driver.find(\".test-tooltip\").isPresent(), false);\n\n    // It should continue working normally afterwards.\n    await driver.find(\".test-closable\").mouseMove();\n    await waitEqual(() => driver.find(\".test-tooltip\").isDisplayed(), true, 500);\n    assert.equal(await driver.find(\".test-tooltip\").getText(), \"Tooltip3\");\n    await driver.mouseMoveBy({ x: 200 });\n    await waitEqual(() => driver.find(\".test-tooltip\").isPresent(), false, 1000);\n  });\n\n  it(\"should close when trigger is disposed\", async function() {\n    await driver.find(\".test-dispose\").mouseMove();\n    await waitDeepEqual(() => driver.findAll(\".test-tooltip\", e => e.getText()), [\"Tooltip6\"], 550);\n\n    // should close after trigger get removed\n    await driver.findContent(\".test-dispose button\", /Hide/).click();\n    await waitEqual(() => driver.find(\".test-tooltip\").isPresent(), false, 500);\n\n    // unhide trigger\n    await driver.findContent(\"label\", /Show trigger/).find(\"[type=checkbox]\").click();\n  });\n\n  it(\"should close when trigger is disposed before the tooltip shows up\", async function() {\n    // hide trigger but before showing the tooltip this time\n    await driver.find(\".test-dispose\").mouseMove();\n    assert.deepEqual(await driver.findAll(\".test-tooltip\", e => e.getText()), []);\n    await driver.findContent(\".test-dispose button\", /Hide/).click();\n\n    // wait passed the openDelay (500ms) and check the tooltip did not showup\n    await driver.sleep(550);\n    await waitEqual(() => driver.find(\".test-tooltip\").isPresent(), false, 500);\n\n    // unhide trigger\n    await driver.findContent(\"label\", /Show trigger/).find(\"[type=checkbox]\").click();\n  });\n\n  it(\"should not show simultaneously several tooltips with same key\", async function() {\n    await driver.find(\".test-with-key\").mouseMove();\n    await waitDeepEqual(() => driver.findAll(\".test-tooltip\", e => e.getText()), [\"Tooltip4\"], 500);\n\n    // move to a tooltip with the same key, and check that it's immediately replaced.\n    await driver.find(\".test-with-same-key\").mouseMove();\n    assert.deepEqual(await driver.findAll(\".test-tooltip\", e => e.getText()), [\"Tooltip5\"]);\n\n    // check that the new tooltip still gets closed on mouseout.\n    await driver.find(\".test-none\").mouseMove();\n    await waitDeepEqual(() => driver.findAll(\".test-tooltip\", e => e.getText()), [], 500);\n\n    // let's do it again with returning back to the first trigger (this used to catch a triggy bug)\n    await driver.find(\".test-with-key\").mouseMove();\n    await waitDeepEqual(() => driver.findAll(\".test-tooltip\", e => e.getText()), [\"Tooltip4\"], 500);\n    await driver.find(\".test-with-same-key\").mouseMove();\n    assert.deepEqual(await driver.findAll(\".test-tooltip\", e => e.getText()), [\"Tooltip5\"]);\n    await driver.find(\".test-with-key\").mouseMove();\n    assert.deepEqual(await driver.findAll(\".test-tooltip\", e => e.getText()), [\"Tooltip4\"]);\n    await driver.find(\".test-none\").mouseMove();\n    await waitDeepEqual(() => driver.findAll(\".test-tooltip\", e => e.getText()), [], 500);\n  });\n\n  it(\"should allow attaching info tooltips to elements\", async function() {\n    async function assertPopupOpensAndCloses(close: () => Promise<void>) {\n      await tooltipIcon.click();\n      await waitDeepEqual(() => driver.findAll(\".test-info-tooltip-popup\", e => e.getText()),\n        [\"Link your new widget to an existing widget on this page.\\nLearn more.\"], 500);\n      await close();\n      await waitDeepEqual(() => driver.findAll(\".test-info-tooltip-popup\", e => e.getText()),\n        [], 500);\n    }\n\n    const element = await driver.find(\".test-info-click\");\n    const tooltipIcon = await element.find(\".test-info-tooltip\");\n\n    // Check that clicking the info icon button toggles the tooltip.\n    await assertPopupOpensAndCloses(async () => {\n      await tooltipIcon.click();\n    });\n\n    // Check that the tooltip can also be closed via the close button.\n    await assertPopupOpensAndCloses(async () => {\n      await driver.find(\".test-info-tooltip-close\").click();\n    });\n\n    // Check that pressing Enter or Escape also closed the tooltip.\n    await assertPopupOpensAndCloses(async () => {\n      await gu.sendKeys(Key.ENTER);\n    });\n    await assertPopupOpensAndCloses(async () => {\n      await gu.sendKeys(Key.ESCAPE);\n    });\n\n    // Check that clicking outside the tooltip also closed it.\n    await assertPopupOpensAndCloses(async () => {\n      await driver.find(\"body\").click();\n    });\n  });\n\n  it(\"should support a hover variant of info tooltips\", async function() {\n    const element = await driver.find(\".test-info-hover\");\n    const tooltipIcon = await element.find(\".test-info-tooltip\");\n\n    await tooltipIcon.mouseMove();\n    await waitDeepEqual(\n      () => driver.findAll(\".test-info-tooltip-popup\", e => e.getText()),\n      [\n        \"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.\\nLearn more.\",\n      ],\n      500,\n    );\n    await driver.find(\".test-none\").mouseMove();\n    await waitDeepEqual(() => driver.findAll(\".test-info-tooltip-popup\", e => e.getText()),\n      [], 500);\n  });\n});\n"
  },
  {
    "path": "test/projects/transitions.ts",
    "content": "import { server, setupTestSuite } from \"test/projects/testUtils\";\n\nimport { assert, driver, Key, stackWrapFunc, WebElement } from \"mocha-webdriver\";\n\ndescribe(\"transitions\", function() {\n  setupTestSuite();\n  this.timeout(60000);      // Set a longer default timeout.\n\n  // This is a constant for interval between operations, giving enough time for a few asserts to\n  // fit safely inside it.\n  const kDelayMs = 250;\n\n  let leftDiv: WebElement;\n  let countFinishedDiv: WebElement;\n\n  // Helper to check the state of the transitioning leftDiv and count of finished transitions.\n  // When a pair of numbers is given, it's the expected range (a closed interval).\n  const assertState = stackWrapFunc(async function(\n    expected: { width: number | [number, number], opacity: number | [number, number], finished: number },\n  ) {\n    const [widthStr, opacityStr, countStr] = await Promise.all([\n      leftDiv.getCssValue(\"width\"),\n      leftDiv.getCssValue(\"opacity\"),\n      countFinishedDiv.getText(),\n    ]);\n    if (Array.isArray(expected.width)) {\n      assert.isAbove(parseFloat(widthStr), expected.width[0]);\n      assert.isBelow(parseFloat(widthStr), expected.width[1]);\n    } else {\n      assert.equal(parseFloat(widthStr), expected.width);\n    }\n    if (Array.isArray(expected.opacity)) {\n      assert.isAbove(parseFloat(opacityStr), expected.opacity[0]);\n      assert.isBelow(parseFloat(opacityStr), expected.opacity[1]);\n    } else {\n      assert.equal(parseFloat(opacityStr), expected.opacity);\n    }\n    assert.equal(parseFloat(countStr), expected.finished);\n  });\n\n  it(\"should run callbacks and transition properties\", async function() {\n    await driver.get(`${server.getHost()}/transitions`);\n    await driver.find(\".test-duration\").doClear().doSendKeys(`${kDelayMs * 2}ms`, Key.ENTER);\n    leftDiv = driver.find(\".test-left\");\n    countFinishedDiv = driver.find(\".test-finished\");\n    await assertState({ width: 30, opacity: 1, finished: 0 });\n\n    // Start the transition and wait for its middle. Note that on click, width increases 30px to\n    // 470px, while opacity goes from 0 to 1.\n    await driver.find(\".test-toggle\").doClick();\n    await driver.sleep(kDelayMs);\n\n    // Assert that the transitioning properties are above the min and below the max\n    await assertState({ width: [35, 465], opacity: [0.05, 0.95], finished: 0 });\n\n    // Wait for it to end.\n    await driver.sleep(kDelayMs * 1.5);\n    await assertState({ width: 470, opacity: 1, finished: 1 });\n\n    // Toggle again, and watch the reverse transition.\n    await driver.find(\".test-toggle\").doClick();\n    await driver.sleep(kDelayMs);\n    await assertState({ width: [35, 465], opacity: [0.05, 1.95], finished: 1 });\n    await driver.sleep(kDelayMs * 1.5);\n    await assertState({ width: 30, opacity: 1, finished: 2 });\n  });\n\n  it(\"should handle interrupted transitions well\", async function() {\n    // Load the page fresh (for new counts) and give more time for this transition.\n    await driver.get(`${server.getHost()}/transitions`);\n    await driver.find(\".test-duration\").doClear().doSendKeys(`${kDelayMs * 4}ms`, Key.ENTER);\n\n    leftDiv = driver.find(\".test-left\");\n    countFinishedDiv = driver.find(\".test-finished\");\n    await assertState({ width: 30, opacity: 1, finished: 0 });\n\n    // What the test does is this.\n    // Initial\n    //        [|- - - - - - - - ]\n    // Full transition takes 4X time.\n    // Toggle and wait 3X time, get to here:\n    //        [ - - - - - -|- - ]\n    // Toggle again and wait 2X time:\n    //        [ - -|- - - - - - ]\n    // Toggle again and wait 3X+ time:\n    //        [ - - - - - - - -|]\n\n    // Start the transition (takes 4X time); at 3X time check, and toggle again to reverse it.\n    await driver.find(\".test-toggle\").doClick();\n    await driver.sleep(kDelayMs * 3);\n\n    // Check that the styles are transitioning but the transition hasn't ended.\n    await assertState({ width: [35, 465], opacity: [0.05, 1.95], finished: 0 });\n    await driver.find(\".test-toggle\").doClick();\n\n    // After 2X time more, check here that the transition still hasn't ended, despite duration of\n    // 4X and 5X time has now passed. Opacity value should have finished transitioning though.\n    await driver.sleep(kDelayMs * 2);\n    await assertState({ width: [35, 465], opacity: 1, finished: 0 });\n\n    // Toggle again, and wait 3X+ time more to finish the transition.\n    await driver.find(\".test-toggle\").doClick();\n    await driver.sleep(kDelayMs * 3.5);\n\n    // Assert that all properties transitioned and the count has updated.\n    await assertState({ width: 470, opacity: 1, finished: 1 });\n  });\n});\n"
  },
  {
    "path": "test/report-why-tests-hang.js",
    "content": "/**\n * Mocha 4 no longer forces exit of a process after tests end, so if a setTimeout() or anything\n * else is keeping node running, tests will hang after finishing.\n *\n * This helper module, always included via mocha.opts, ensures we print something if that happens.\n * We use why-is-node-running module to print something informative.\n */\n\n// --no-exit|-E flag is interpreted by mocha-webdriver library to start REPL on failure, and we\n// do NOT want to output a big dump about that.\nconst noexit = process.argv.includes(\"--no-exit\") || process.argv.includes(\"-E\");\n// Don't load why-is-node-running if we're not going to use it. It probably means that we're\n// in a debugging session, and this module creates async hooks that interfere with debugging.\nconst whyIsNodeRunning = noexit ? null : require(\"why-is-node-running\");\n\nfunction report() {\n  whyIsNodeRunning?.();\n  console.warn(\"*******************************************************\");\n  console.warn(\"Something above prevented node from exiting on its own.\");\n  console.warn(\"*******************************************************\");\n  // We want to exit, but process.exit(1) doesn't work, since mocha catches it and insists on\n  // exiting with the test status result (which may be 0, and we need to indicate failure).\n  process.kill(process.pid, \"SIGTERM\");\n}\n\nif (process.env.MOCHA_WORKER_ID === undefined) {\n  exports.mochaHooks = {\n    afterAll(done) {\n      if (noexit) {\n        console.log(\"report-why-tests-hang silenced with --no-exit flag\");\n      } else {\n        // If still hanging after 5s after tests finish, say something. Unref() ensures that THIS\n        // timeout doesn't itself keep node from exiting.\n        setTimeout(report, 5000).unref();\n      }\n      done();\n    }\n  };\n}\n"
  },
  {
    "path": "test/server/Comm.ts",
    "content": "import { Comm as ClientComm } from \"app/client/components/Comm\";\nimport { GristClientSocket, GristClientSocketOptions } from \"app/client/components/GristClientSocket\";\nimport { GristWSConnection, GristWSSettings } from \"app/client/components/GristWSConnection\";\nimport * as log from \"app/client/lib/log\";\nimport { CommClientConnect } from \"app/common/CommTypes\";\nimport { delay } from \"app/common/delay\";\nimport { isLongerThan } from \"app/common/gutil\";\nimport { Client, ClientMethod } from \"app/server/lib/Client\";\nimport { Comm } from \"app/server/lib/Comm\";\nimport { Hosts, RequestOrgInfo } from \"app/server/lib/extractOrg\";\nimport { fromCallback, listenPromise } from \"app/server/lib/serverUtils\";\nimport { Sessions } from \"app/server/lib/Sessions\";\nimport { TcpForwarder } from \"test/server/tcpForwarder\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport * as http from \"http\";\nimport { AddressInfo } from \"net\";\nimport * as path from \"path\";\n\nimport * as session from \"@gristlabs/express-session\";\nimport { Events as BackboneEvents } from \"backbone\";\nimport { promisifyAll } from \"bluebird\";\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\nimport * as tmp from \"tmp\";\n\n// eslint-disable-next-line @typescript-eslint/no-require-imports\nconst SQLiteStore = require(\"@gristlabs/connect-sqlite3\")(session);\npromisifyAll(SQLiteStore.prototype);\n\n// Just enough implementation of Hosts to be able to fake using a custom host.\nclass FakeHosts {\n  public isCustomHost = false;\n\n  public get asHosts() { return this as unknown as Hosts; }\n\n  public async addOrgInfo<T extends http.IncomingMessage>(req: T): Promise<T & RequestOrgInfo> {\n    return Object.assign(req, {\n      isCustomHost: this.isCustomHost,\n      org: \"example\",\n      url: req.url!,\n    });\n  }\n}\n\ndescribe(\"Comm\", function() {\n  testUtils.setTmpLogLevel(process.env.VERBOSE ? \"debug\" : \"warn\");\n\n  // Allow test cases to register afterEach callbacks here for easier cleanup.\n  const cleanup: (() => Promise<void>)[] = [];\n\n  let server: http.Server;\n  let sessions: Sessions;\n  let fakeHosts: FakeHosts;\n  let comm: Comm | null = null;\n  const sandbox = sinon.createSandbox();\n\n  before(async function() {\n    const sessionDB = tmp.fileSync();\n    const sessionStore = new SQLiteStore({\n      dir: path.dirname(sessionDB.name),\n      db: path.basename(sessionDB.name),\n      table: \"sessions\",\n    });\n    // Random string to use for the test session secret.\n    const sessionSecret = \"xkwriagasaqystubgkkbwhqtyyncwqjemyncnmetjpkiwtfzvllejpfneldmoyri\";\n    sessions = new Sessions(sessionSecret, sessionStore);\n  });\n\n  function startComm(methods: { [name: string]: ClientMethod }) {\n    server = http.createServer();\n    fakeHosts = new FakeHosts();\n    comm = new Comm(server, { sessions, hosts: fakeHosts.asHosts });\n    comm.registerMethods(methods);\n    return listenPromise(server.listen(0, \"localhost\"));\n  }\n\n  async function stopComm() {\n    comm?.destroyAllClients();\n    await comm?.testServerShutdown();\n    await fromCallback((cb) => {\n      server.close(cb);\n      server.closeAllConnections();\n    });\n  }\n\n  const assortedMethods: { [name: string]: ClientMethod } = {\n    methodSync: async function(client, x, y) {\n      return { x: x, y: y, name: \"methodSync\" };\n    },\n    methodError: async function(client, x, y) {\n      throw new Error(\"fake error\");\n    },\n    methodAsync: async function(client, x, y) {\n      await delay(20);\n      return { x: x, y: y, name: \"methodAsync\" };\n    },\n    methodSend: async function(client, docFD) {\n      void (client.sendMessage({ docFD, type: \"fooType\" as any, data: \"foo\" }));\n      void (client.sendMessage({ docFD, type: \"barType\" as any, data: \"bar\" }));\n    },\n  };\n\n  afterEach(async function() {\n    // Run the cleanup callbacks registered in cleanup().\n    await Promise.all(cleanup.splice(0).map(callback => callback()));\n\n    sandbox.restore();\n  });\n\n  function getMessages(ws: GristClientSocket, count: number): Promise<any[]> {\n    return new Promise((resolve, reject) => {\n      const messages: object[] = [];\n      ws.onerror = (err) => {\n        ws.onmessage = null;\n        reject(err);\n      };\n      ws.onmessage = (data: string) => {\n        messages.push(JSON.parse(data));\n        if (messages.length >= count) {\n          ws.onerror = null;\n          ws.onmessage = null;\n          resolve(messages);\n        }\n      };\n    });\n  }\n\n  /**\n   * Returns a promise for the connected websocket.\n   */\n  function connect(options?: GristClientSocketOptions): Promise<GristClientSocket> {\n    const ws = new GristClientSocket(\"ws://localhost:\" + (server.address() as AddressInfo).port, options);\n    return new Promise<GristClientSocket>((resolve, reject) => {\n      ws.onopen = () => {\n        ws.onerror = null;\n        resolve(ws);\n      };\n      ws.onerror = (err) => {\n        ws.onopen = null;\n        reject(err);\n      };\n    });\n  }\n\n  describe(\"server methods\", function() {\n    let ws: GristClientSocket;\n    beforeEach(async function() {\n      await startComm(assortedMethods);\n      ws = await connect();\n      await getMessages(ws, 1);  // consume a clientConnect message\n    });\n\n    afterEach(async function() {\n      await stopComm();\n    });\n\n    it(\"should return data for valid calls\", async function() {\n      ws.send(JSON.stringify({ reqId: 10, method: \"methodSync\", args: [\"hello\", \"world\"] }));\n      const messages = await getMessages(ws, 1);\n      const resp = messages[0];\n      assert.equal(resp.reqId, 10, `Messages received instead: ${JSON.stringify(messages)}`);\n      assert.deepEqual(resp.data, { x: \"hello\", y: \"world\", name: \"methodSync\" });\n    });\n\n    it(\"should work for async calls\", async function() {\n      ws.send(JSON.stringify({ reqId: 20, method: \"methodAsync\", args: [\"hello\", \"world\"] }));\n      const messages = await getMessages(ws, 1);\n      const resp = messages[0];\n      assert.equal(resp.reqId, 20);\n      assert.deepEqual(resp.data, { x: \"hello\", y: \"world\", name: \"methodAsync\" });\n    });\n\n    it(\"should work for out-of-order calls\", async function() {\n      ws.send(JSON.stringify({ reqId: 30, method: \"methodAsync\", args: [1, 2] }));\n      ws.send(JSON.stringify({ reqId: 31, method: \"methodSync\", args: [3, 4] }));\n      const messages = await getMessages(ws, 2);\n      assert.equal(messages[0].reqId, 31);\n      assert.deepEqual(messages[0].data, { x: 3, y: 4, name: \"methodSync\" });\n      assert.equal(messages[1].reqId, 30);\n      assert.deepEqual(messages[1].data, { x: 1, y: 2, name: \"methodAsync\" });\n    });\n\n    it(\"should return error when a call fails\", async function() {\n      const logMessages = await testUtils.captureLog(\"warn\", async () => {\n        ws.send(JSON.stringify({ reqId: 40, method: \"methodError\", args: [\"hello\"] }));\n        const messages = await getMessages(ws, 1);\n        const resp = messages[0];\n        assert.equal(resp.reqId, 40);\n        assert.equal(resp.data, undefined);\n        assert(resp.error.indexOf(\"fake error\") >= 0);\n      });\n      testUtils.assertMatchArray(logMessages, [\n        /^warn: Client.* Error: fake error[^]+at methodError/,\n        /^warn: Client.* responding to .* ERROR fake error/,\n      ]);\n    });\n\n    it(\"should return error for unknown methods\", async function() {\n      const logMessages  = await testUtils.captureLog(\"warn\", async () => {\n        ws.send(JSON.stringify({ reqId: 50, method: \"someUnknownMethod\", args: [] }));\n        const messages = await getMessages(ws, 1);\n        const resp = messages[0];\n        assert.equal(resp.reqId, 50);\n        assert.equal(resp.data, undefined);\n        assert(resp.error.indexOf(\"Unknown method\") >= 0);\n      });\n      testUtils.assertMatchArray(logMessages, [\n        /^warn: Client.* Unknown method.*someUnknownMethod/,\n      ]);\n    });\n\n    it(\"should only log warning for malformed JSON data\", async function() {\n      const logMessages  = await testUtils.captureLog(\"warn\", async () => {\n        ws.send(\"foobar\");\n      }, { waitForFirstLog: true });\n      testUtils.assertMatchArray(logMessages, [\n        /^warn: Client.* Unexpected token.*/,\n      ]);\n    });\n\n    it(\"should log warning when null value is passed\", async function() {\n      const logMessages  = await testUtils.captureLog(\"warn\", async () => {\n        ws.send(\"null\");\n      }, { waitForFirstLog: true });\n      testUtils.assertMatchArray(logMessages, [\n        /^warn: Client.*Cannot read properties of null*/,\n      ]);\n    });\n\n    it(\"should support app-level events correctly\", async function() {\n      comm!.broadcastMessage(\"fooType\" as any, \"hello\");\n      comm!.broadcastMessage(\"barType\" as any, \"world\");\n      const messages = await getMessages(ws, 2);\n      assert.equal(messages[0].type, \"fooType\");\n      assert.equal(messages[0].data, \"hello\");\n      assert.equal(messages[1].type, \"barType\");\n      assert.equal(messages[1].data, \"world\");\n    });\n\n    it(\"should support doc-level events\", async function() {\n      ws.send(JSON.stringify({ reqId: 60, method: \"methodSend\", args: [13] }));\n      const messages = await getMessages(ws, 3);\n      assert.equal(messages[0].type, \"fooType\");\n      assert.equal(messages[0].data, \"foo\");\n      assert.equal(messages[0].docFD, 13);\n      assert.equal(messages[1].type, \"barType\");\n      assert.equal(messages[1].data, \"bar\");\n      assert.equal(messages[1].docFD, 13);\n      assert.equal(messages[2].reqId, 60);\n      assert.equal(messages[2].data, undefined);\n      assert.equal(messages[2].error, undefined);\n    });\n  });\n\n  describe(\"reconnects\", function() {\n    const docId = \"docId_abc\";\n    this.timeout(10000);\n\n    // Helper to set up a Comm server, a Comm client, and a forwarder between them that allows\n    // simulating disconnects.\n    async function startManagedConnection(methods: { [name: string]: ClientMethod }) {\n      // Start the server Comm, providing a few methods.\n      await startComm(methods);\n      cleanup.push(() => stopComm());\n\n      // Create a forwarder, which we use to test disconnects.\n      const serverPort = (server.address() as AddressInfo).port;\n      const forwarder = new TcpForwarder(serverPort);\n      const forwarderPort = await forwarder.pickForwarderPort();\n      await forwarder.connect();\n      cleanup.push(() => forwarder.disconnect());\n\n      // To create a client-side Comm object, we need to trick GristWSConnection's check for\n      // whether there is a worker to connect to.\n      (global as any).window = undefined;\n      sandbox.stub(global as any, \"window\").value({ gristConfig: { getWorker: \"STUB\", assignmentId: docId } });\n\n      // We also need to get GristWSConnection to use a custom GristWSSettings object, and to\n      // connect to the forwarder's port.\n      const docWorkerUrl = `http://localhost:${forwarderPort}`;\n      const settings = getWSSettings(docWorkerUrl);\n      const stubGristWsCreate = sandbox.stub(GristWSConnection, \"create\").callsFake(function(this: any, owner) {\n        return (stubGristWsCreate as any).wrappedMethod.call(this, owner, settings);\n      });\n\n      // Cast with BackboneEvents to allow using cliComm.on().\n      const cliComm = ClientComm.create() as ClientComm & BackboneEvents;\n      cliComm.useDocConnection(docId);\n      cleanup.push(async () => cliComm.dispose());      // Dispose after this test ends.\n\n      return { cliComm, forwarder };\n    }\n\n    it(\"should forward calls on a normal connection\", async function() {\n      const { cliComm } = await startManagedConnection(assortedMethods);\n\n      // A couple of regular requests.\n      const resp1 = await cliComm._makeRequest(null, null, \"methodSync\", \"foo\", 1);\n      assert.deepEqual(resp1, { name: \"methodSync\", x: \"foo\", y: 1 });\n      const resp2 = await cliComm._makeRequest(null, null, \"methodAsync\", \"foo\", 2);\n      assert.deepEqual(resp2, { name: \"methodAsync\", x: \"foo\", y: 2 });\n\n      // Try calls that return out of order.\n      const [resp3, resp4] = await Promise.all([\n        cliComm._makeRequest(null, null, \"methodAsync\", \"foo\", 3),\n        cliComm._makeRequest(null, null, \"methodSync\", \"foo\", 4),\n      ]);\n      assert.deepEqual(resp3, { name: \"methodAsync\", x: \"foo\", y: 3 });\n      assert.deepEqual(resp4, { name: \"methodSync\", x: \"foo\", y: 4 });\n    });\n\n    it(\"should forward missed responses when a server send fails\", async function() {\n      await testMissedResponses(true);\n    });\n    it(\"should forward missed responses when a server send is queued\", async function() {\n      await testMissedResponses(false);\n    });\n\n    async function testMissedResponses(sendShouldFail: boolean) {\n      let failedSendCount = 0;\n\n      const { cliComm, forwarder } = await startManagedConnection({ ...assortedMethods,\n        // An extra method that simulates a lost connection on server side prior to response.\n        testDisconnect: async function(client, x, y) {\n          setTimeout(() => forwarder.disconnectServerSide(), 0);\n          if (!sendShouldFail) {\n            // Add a delay to let the 'close' event get noticed first.\n            await delay(20);\n          }\n          return { x: x, y: y, name: \"testDisconnect\" };\n        },\n      });\n\n      const resp1 = await cliComm._makeRequest(null, null, \"methodSync\", \"foo\", 1);\n      assert.deepEqual(resp1, { name: \"methodSync\", x: \"foo\", y: 1 });\n\n      if (sendShouldFail) {\n        // In Node 18, the socket is closed during the call to 'testDisconnect'.\n        // In prior versions of Node, the socket was still disconnecting.\n        // This test is sensitive to timing and only passes in the latter, unless we\n        // stub the method below to produce similar behavior in the former.\n        sandbox.stub(Client.prototype as any, \"_sendToWebsocket\")\n          .onFirstCall()\n          .callsFake(() => {\n            failedSendCount += 1;\n            throw new Error(\"WebSocket is not open\");\n          })\n          .callThrough();\n      }\n\n      // Make more calls, with a disconnect before they return. The server should queue up responses.\n      const resp2Promise = cliComm._makeRequest(null, null, \"testDisconnect\", \"foo\", 2);\n      const resp3Promise = cliComm._makeRequest(null, null, \"methodAsync\", \"foo\", 3);\n      assert.equal(await isLongerThan(resp2Promise, 250), true);\n\n      // Once we reconnect, the response should arrive.\n      await forwarder.connect();\n      assert.deepEqual(await resp2Promise, { name: \"testDisconnect\", x: \"foo\", y: 2 });\n      assert.deepEqual(await resp3Promise, { name: \"methodAsync\", x: \"foo\", y: 3 });\n\n      // Check that we saw the situation we were hoping to test.\n      assert.equal(failedSendCount, sendShouldFail ? 1 : 0, \"Expected to see a failed send\");\n    }\n\n    it(\"should receive all server messages (small) in order when send doesn't fail\", async function() {\n      await testSendOrdering({ noFailedSend: true, useSmallMsgs: true });\n    });\n\n    it(\"should receive all server messages (large) in order when send doesn't fail\", async function() {\n      await testSendOrdering({ noFailedSend: true });\n    });\n\n    it(\"should order server messages correctly with failedSend before close\", async function() {\n      await testSendOrdering({ closeHappensFirst: false });\n    });\n\n    it(\"should order server messages correctly with close before failedSend\", async function() {\n      await testSendOrdering({ closeHappensFirst: true });\n    });\n\n    async function testSendOrdering(\n      options: { noFailedSend?: boolean, closeHappensFirst?: boolean, useSmallMsgs?: boolean },\n    ) {\n      const eventsSeen: (\"failedSend\" | \"close\")[] = [];\n\n      // Server-side Client object.\n      let ssClient!: Client;\n\n      const { cliComm, forwarder } = await startManagedConnection(assortedMethods);\n\n      // Intercept the call to _onClose to know when it occurs, since we are trying to hit a\n      // situation where 'close' and 'failedSend' events happen in either order.\n      const stubOnClose: any = sandbox.stub(Client.prototype as any, \"_onClose\")\n        .callsFake(function(this: Client) {\n          eventsSeen.push(\"close\");\n          return stubOnClose.wrappedMethod.apply(this, arguments);\n        });\n\n      // Intercept calls to client.sendMessage(), to know when it fails, and possibly to delay the\n      // failures to hit a particular order in which 'close' and 'failedSend' events are seen by\n      // Client.ts. This is the only reliable way I found to reproduce this order of events.\n      const stubSendToWebsocket: any = sandbox.stub(Client.prototype as any, \"_sendToWebsocket\")\n        .callsFake(async function(this: Client) {\n          try {\n            return await stubSendToWebsocket.wrappedMethod.apply(this, arguments);\n          } catch (err) {\n            if (options.closeHappensFirst) { await delay(100); }\n            eventsSeen.push(\"failedSend\");\n            throw err;\n          }\n        });\n\n      // Watch the events received all the way on the client side.\n      const eventSpy = sinon.spy();\n      const clientConnectSpy = sinon.spy();\n      cliComm.on(\"docUserAction\", eventSpy);\n      cliComm.on(\"clientConnect\", clientConnectSpy);\n\n      // We need to simulate an important property of the browser client: when needReload is set\n      // in the clientConnect message, we are expected to reload the app. In the test, we replace\n      // the GristWSConnection.\n      cliComm.on(\"clientConnect\", async (msg: CommClientConnect) => {\n        ssClient = comm!.getClient(msg.clientId);\n        if (msg.needReload) {\n          await delay(0);\n          cliComm.releaseDocConnection(docId);\n          cliComm.useDocConnection(docId);\n        }\n      });\n\n      // Wait for a connect call, which we rely on to get access to the Client object (ssClient).\n      await waitForCondition(() => (clientConnectSpy.callCount > 0), 1000);\n\n      // Send large buffers, to fill up the socket's buffers to get it to block.\n      const data = \"x\".repeat(options.useSmallMsgs ? 100_000 : 10_000_000);\n      const makeMessage = (n: number) => ({ type: \"docUserAction\", n, data });\n\n      let n = 0;\n      const sendPromises: Promise<void>[] = [];\n      const sendNextMessage = () => sendPromises.push(ssClient.sendMessage(makeMessage(n++) as any));\n\n      await testUtils.captureLog(\"warn\", async () => {\n        // Make a few sends. These are big enough not to return immediately. Keep the first two\n        // successful (by awaiting them). And keep a few more that will fail. This is to test the\n        // ordering of successful and failed messages that may be missed.\n        sendNextMessage();\n        sendNextMessage();\n        sendNextMessage();\n        await sendPromises[0];\n        await sendPromises[1];\n\n        sendNextMessage();\n        sendNextMessage();\n\n        // Forcibly close the forwarder, so that the server sees a 'close' event. But first let\n        // some messages get to the client. In case we want all sends to succeed, let them all get\n        // forwarded before disconnect; otherwise, disconnect after 2 are fowarded.\n        const countToWaitFor = options.noFailedSend ? 5 : 2;\n        await waitForCondition(() => eventSpy.callCount >= countToWaitFor);\n\n        void (forwarder.disconnectServerSide());\n\n        // Wait less than the delay that we add for delayFailedSend, and send another message. There\n        // used to be a bug that such a message would get recorded into missedMessages out of order.\n        await delay(50);\n        sendNextMessage();\n\n        // Now reconnect, and collect the messages that the client sees.\n        clientConnectSpy.resetHistory();\n        await forwarder.connect();\n\n        // Wait until we get a clientConnect message that does not require a reload. (Except with\n        // noFailedSend, the first one would have needReload set; and after the reconnect, we should\n        // get one without.)\n        await waitForCondition(() =>\n          (clientConnectSpy.callCount > 0 && clientConnectSpy.lastCall.args[0].needReload === false),\n        3000);\n      });\n\n      // This test helper is used for 3 different situations. Check that we observed that\n      // situations we were trying to hit.\n      if (options.noFailedSend) {\n        if (options.useSmallMsgs) {\n          assert.deepEqual(eventsSeen, [\"close\"]);\n        } else {\n          // Make sure to have waited long enough for the 'close' event we may have delayed\n          await delay(20);\n\n          // Large messages now cause a send to fail, after filling up buffer, and close the socket.\n          assert.deepEqual(eventsSeen, [\"close\", \"close\"]);\n        }\n      } else if (options.closeHappensFirst) {\n        assert.equal(eventsSeen[0], \"close\");\n        assert.include(eventsSeen, \"failedSend\");\n      } else {\n        assert.equal(eventsSeen[0], \"failedSend\");\n        assert.include(eventsSeen, \"close\");\n      }\n\n      // After a successful reconnect, subsequent calls should work normally.\n      assert.deepEqual(await cliComm._makeRequest(null, null, \"methodSync\", 1, 2),\n        { name: \"methodSync\", x: 1, y: 2 });\n\n      // Check that all the received messages are in order.\n      const messageNums = eventSpy.getCalls().map(call => call.args[0].n);\n      assert.isAtLeast(messageNums.length, 2);\n      assert.deepEqual(messageNums, nrange(0, messageNums.length),\n        `Unexpected message sequence ${JSON.stringify(messageNums)}`);\n\n      // Subsequent messages should work normally too.\n      eventSpy.resetHistory();\n      sendNextMessage();\n      await waitForCondition(() => eventSpy.callCount > 0);\n      assert.deepEqual(eventSpy.getCalls().map(call => call.args[0].n), [n - 1]);\n    }\n  });\n\n  describe(\"Allowed Origin\", function() {\n    beforeEach(async function() {\n      await startComm(assortedMethods);\n    });\n\n    afterEach(async function() {\n      await stopComm();\n    });\n\n    async function checkOrigin(headers: { origin: string, host: string }, allowed: boolean) {\n      const promise = connect({ headers });\n      if (allowed) {\n        await assert.isFulfilled(promise, `${headers.host} should allow ${headers.origin}`);\n      } else {\n        await assert.isRejected(promise, /.*/, `${headers.host} should reject ${headers.origin}`);\n      }\n    }\n\n    it(\"origin should match base domain of host\", async () => {\n      await checkOrigin({ origin: \"https://www.toto.com\", host: \"worker.example.com\" }, false);\n      await checkOrigin({ origin: \"https://badexample.com\", host: \"worker.example.com\" }, false);\n      await checkOrigin({ origin: \"https://bad.com/example.com\", host: \"worker.example.com\" }, false);\n      await checkOrigin({ origin: \"https://front.example.com\", host: \"worker.example.com\" }, true);\n      await checkOrigin({ origin: \"https://front.example.com:3000\", host: \"worker.example.com\" }, true);\n      await checkOrigin({ origin: \"https://example.com\", host: \"example.com\" }, true);\n    });\n\n    it(\"with custom domains, origin should match the full hostname\", async () => {\n      fakeHosts.isCustomHost = true;\n\n      // For a request to a custom domain, the full hostname must match.\n      await checkOrigin({ origin: \"https://front.example.com\", host: \"worker.example.com\" }, false);\n      await checkOrigin({ origin: \"https://front.example.com\", host: \"front.example.com\" }, true);\n      await checkOrigin({ origin: \"https://front.example.com:3000\", host: \"front.example.com\" }, true);\n    });\n  });\n});\n\n// Waits for condFunc() to return true, for up to timeoutMs milliseconds, sleeping for stepMs\n// between checks. Returns if succeeded, throws if failed.\nasync function waitForCondition(condFunc: () => boolean, timeoutMs = 1000, stepMs = 10): Promise<void> {\n  const end = Date.now() + timeoutMs;\n  while (Date.now() < end) {\n    if (condFunc()) { return; }\n    await delay(stepMs);\n  }\n  throw new Error(`Condition not met after ${timeoutMs}ms: ${condFunc.toString()}`);\n}\n\n// Returns a range of count consecutive numbers starting with start.\nfunction nrange(start: number, count: number): number[] {\n  return Array.from(Array(count), (_, i) => start + i);\n}\n\n// Returns a GristWSSettings object, for use with GristWSConnection.\nfunction getWSSettings(docWorkerUrl: string): GristWSSettings {\n  let clientId: string = \"clientid-abc\";\n  let counter: number = 0;\n  return {\n    makeWebSocket(url: string): any { return new GristClientSocket(url); },\n    async getTimezone()         { return \"UTC\"; },\n    getPageUrl()                { return \"http://localhost\"; },\n    async getDocWorkerUrl()     { return docWorkerUrl; },\n    getClientId(did: any)       { return clientId; },\n    getUserSelector()           { return \"\"; },\n    updateClientId(did: string, cid: string) { clientId = cid; },\n    advanceCounter(): string    { return String(counter++); },\n    log()                       { (log as any).debug(...arguments); },\n    warn()                      { (log as any).warn(...arguments); },\n  };\n}\n"
  },
  {
    "path": "test/server/PyMomentTest.ts",
    "content": "import { createSandbox } from \"app/server/lib/NSandbox\";\nimport { setupCleanup } from \"test/server/testCleanup\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport * as moment from \"moment-timezone\";\n\ndescribe(\"PyMomentTest\", function() {\n  testUtils.setTmpLogLevel(\"warn\");\n  const cleanup = setupCleanup();\n\n  it(\"should use correct timezone data\", async function() {\n    this.timeout(5000);\n    const jsZones = moment.tz.names().map((name) => {\n      const z = moment.tz.zone(name)!;\n      return [z.name, z.abbrs, z.offsets, z.untils];\n    });\n\n    const sandbox = createSandbox(\"sandboxed\", {});\n    cleanup.addAfterEach(async () => { await sandbox.shutdown(); });\n\n    const pyZones = await sandbox.pyCall(\"test_tz_data\");\n    try {\n      assert.deepEqual(jsZones, pyZones);\n    } catch (e) {\n      console.log(\"Timezone data in sandbox/grist/tzdata does not match \" +\n        \"node_modules/moment-timezone/data/unpacked/latest.json\");\n      e.message += \": Perhaps re-run 'node sandbox/install_tz.js && ./build python'?\";\n      e.showDiff = false;\n      throw e;\n    }\n  });\n});\n"
  },
  {
    "path": "test/server/Sandbox.ts",
    "content": "import { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport { ISandbox } from \"app/server/lib/ISandbox\";\nimport { createSandbox, NSandbox } from \"app/server/lib/NSandbox\";\nimport { timeoutReached } from \"app/server/lib/serverUtils\";\nimport { createDocTools } from \"test/server/docTools\";\nimport { createTmpDir } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"Sandbox\", function() {\n  this.timeout(12000);\n\n  const docTools = createDocTools({ });\n  let seedTables: Buffer;\n  let seedColumns: Buffer;\n  let tmpDir: string;\n\n  const output: { stdout: string[], stderr: string[] } = {\n    stdout: [],\n    stderr: [],\n  };\n\n  function capture(level: string, msg: string) {\n    const match = /^Sandbox.*(stdout|stderr): (.*)$/s.exec(msg);\n    if (match && level === \"info\") {\n      const stream = match[1] as \"stdout\" | \"stderr\";\n      output[stream].push(match[2]);\n    }\n  }\n\n  function clear() {\n    output.stdout.length = 0;\n    output.stderr.length = 0;\n  }\n\n  testUtils.setTmpLogLevel(\"warn\", capture);\n\n  before(async function() {\n    tmpDir = await createTmpDir();\n    const doc = new ActiveDoc(docTools.getDocManager(), \"xxx\");\n    const session = makeExceptionalDocSession(\"system\");\n    await doc.loadDoc(session, {\n      forceNew: true,\n      skipInitialTable: false,\n      useExisting: false,\n    });\n    seedTables = await doc.docStorage.fetchTable(\"_grist_Tables\");\n    seedColumns = await doc.docStorage.fetchTable(\"_grist_Tables_column\");\n    await doc.shutdown();\n  });\n\n  async function prepareFormula(sandbox: ISandbox) {\n    await sandbox.pyCall(\n      \"load_meta_tables\", seedTables, seedColumns,\n    );\n    await sandbox.pyCall(\"apply_user_actions\", [\n      [\"UpdateRecord\", \"_grist_Tables_column\", 2, {\n        isFormula: true,\n        formula: \"$id\",\n      }],\n      [\"AddRecord\", \"Table1\", null, {}],\n    ]);\n  }\n\n  async function tryFormula(sandbox: ISandbox, formula: string): Promise<any> {\n    await sandbox.pyCall(\"apply_user_actions\", [\n      [\"UpdateRecord\", \"_grist_Tables_column\", 2, {\n        isFormula: true,\n        formula,\n      }],\n    ]);\n    const { result } = await sandbox.pyCall(\"evaluate_formula\", \"Table1\", \"A\", 1);\n    return result;\n  }\n\n  beforeEach(function() {\n    clear();\n  });\n\n  describe(\"Basic operation\", function() {\n    it(\"should echo hello world\", async function() {\n      const sandbox = createSandbox(\"sandboxed\", {});\n      try {\n        const result = await sandbox.pyCall(\n          \"test_echo\", \"Hello world\",\n        );\n        assert.equal(result, \"Hello world\");\n      } finally {\n        await sandbox.shutdown();\n      }\n    });\n\n    it(\"should handle exceptions\", async function() {\n      const sandbox = createSandbox(\"sandboxed\", {});\n      try {\n        await assert.isRejected(sandbox.pyCall(\n          \"test_fail\", \"Hello world\",\n        ), /Hello world/);\n        assert.deepEqual(output.stdout, []);\n        const stderr = output.stderr.join(\"\\n\");\n        assert.match(stderr, /Traceback \\(most recent call last\\):/);\n        assert.match(stderr, /Exception: Hello world/);\n      } finally {\n        await sandbox.shutdown();\n      }\n    });\n  });\n\n  describe(\"sandbox.pyCall\", function() {\n    it(\"should invoke python functions\", async function() {\n      const sandbox = createSandbox(\"sandboxed\", {});\n      // Startup can be noisy in logs, wait for it to be done\n      // and for the sandbox to be available, then clear the logs.\n      await sandbox.pyCall(\"test_echo\", \"1\");\n      clear();\n      try {\n        let value = await sandbox.pyCall(\"test_operation\", 0, \"uppercase\", \"hello\");\n        assert.equal(value, \"HELLO\");\n        value = await sandbox.pyCall(\"test_operation\", 0, \"triple\", 2.5);\n        assert.equal(value, 7.5);\n        assert.deepEqual(output.stdout, []);\n        assert.deepEqual(output.stderr, []);\n      } finally {\n        await sandbox.shutdown();\n      }\n    });\n\n    it(\"should fail when sandbox has exited\", async function() {\n      const sandbox = createSandbox(\"sandboxed\", {});\n      try {\n        // Normally pyCall should succeed.\n        const value = await sandbox.pyCall(\"test_operation\", 1.5, \"uppercase\", \"hello\");\n        assert.equal(value, \"HELLO\");\n\n        // Now kill the sandbox, and call again. In this case pyCall doesn't know yet that the\n        // sandbox has exited, so it should fail on reading the response.\n        const assertPromise = assert.isRejected(sandbox.pyCall(\"test_operation\", 1.5, \"uppercase\", \"shouldfail1\"),\n          /PipeFromSandbox is closed/,\n          \"When sandbox exits, pyCall should not succeed\");\n\n        await sandbox.shutdown();\n        assert.equal(await timeoutReached(1000, Promise.resolve(assertPromise)), false,\n          \"When sandbox exits, pyCall should not hang\");\n        await assertPromise;\n\n        // Now try to make another pyCall; it should fail too, but this time immediately.\n        await assert.isRejected(sandbox.pyCall(\"test_operation\", 1.5, \"uppercase\", \"shouldfail2\"),\n          /PipeToSandbox is closed/,\n          \"When sandbox has exited, pyCall should not succeed\");\n      } finally {\n        await sandbox.shutdown();\n      }\n    });\n\n    it(\"should get killed if sandbox refuses to exit\", async function() {\n      const sandbox = createSandbox(\"sandboxed\", {});\n      const expectedRejection = assert.isRejected(\n        sandbox.pyCall(\"test_operation\", 100, \"uppercase\", \"hello\"),\n        /PipeFromSandbox is closed/,\n      );\n      await sandbox.shutdown();\n      await expectedRejection;\n    });\n\n    it(\"should be reasonably quick with big data\", async function() {\n      const sandbox = createSandbox(\"sandboxed\", {});\n      const bigString = new Array(1000001).join(\"*\");\n      assert.equal(bigString.length, 1000000);\n\n      try {\n        let start: number;\n        // Make a small call to the sandbox, to ensure if finished startup, before we start timing.\n        let count = await sandbox.pyCall(\"test_operation\", 0, \"bigToSmall\", \"test\");\n        assert.equal(count, 4);\n        start = Date.now();\n        count = await sandbox.pyCall(\"test_operation\", 0, \"bigToSmall\", bigString);\n        let delta = Date.now() - start;\n        assert.equal(count, 1000000);\n        assert(delta < 100, \"Should really be around 20ms, but took \" + delta);\n\n        start = Date.now();\n        const value = await sandbox.pyCall(\"test_operation\", 0, \"smallToBig\", 1000000);\n        delta = Date.now() - start;\n        assert(delta < 100, \"Should really be around 20ms, but took \" + delta);\n        assert.equal(value, bigString);\n      } finally {\n        await sandbox.shutdown();\n      }\n    });\n  });\n\n  describe(\"sandbox restrictions\", function() {\n    let sandbox: NSandbox;\n    beforeEach(async function() {\n      sandbox = createSandbox(\"sandboxed\", {}) as NSandbox;\n      await prepareFormula(sandbox);\n    });\n\n    afterEach(function() {\n      return sandbox.shutdown();\n    });\n\n    it(\"should only have access to directories inside the sandbox root\", async function() {\n      const sandboxRoot = await sandbox.pyCall(\"test_get_sandbox_root\");\n      const sandboxDirs = await sandbox.pyCall(\"test_list_files\", sandboxRoot, false);\n      const hostDirs = getSubDirs(`${testUtils.appRoot}/sandbox/grist`, sandboxRoot);\n      assert.deepEqual(sandboxDirs.sort(), hostDirs.sort());\n\n      if (sandbox.getFlavor() === \"macSandboxExec\") {\n        // Mac sandbox doesn't allow this kind of tmpfs overlay. End\n        // this test early.\n        return;\n      }\n      const emptyTmp = await sandbox.pyCall(\"test_list_files\", \"/tmp\", true);\n      assert.deepEqual(emptyTmp, [\"/tmp\"]);\n    });\n\n    it(\"should have write access to some directories\", async function() {\n      if (sandbox.getFlavor() === \"macSandboxExec\") {\n        // Mac sandbox doesn't allow this kind of tmpfs overlay.\n        this.skip();\n      }\n      // gvisor mounts /tmp as a tmpfs, so it can be written to but is\n      // invisible to the host\n      const tmpFile = \"/tmp/grist-fake-file-does-not-exist\";\n      const testContents = \"chimpy + kiwi = <3\";\n      await sandbox.pyCall(\"test_write_file\", tmpFile, testContents);\n      const fileContents = await sandbox.pyCall(\"test_read_file\", tmpFile);\n      assert.equal(fileContents, testContents, \"File should be writable in the sandbox\");\n      assert.isFalse(fs.existsSync(tmpFile), \"File should not exist in the host OS\");\n    });\n\n    /**\n     * This test was added because the pyodide \"sandbox\" is built on emscripten\n     * and exposes spawnSync via os.system\n     *   https://github.com/emscripten-core/emscripten/blob/a6bc307592f189288215ea539ec0d1126a0daa72/src/lib/libcore.js#L355\n     * For pyodide, we would now expect the os.system to fail since pyodide is\n     * now run via deno without the --allow-run command. If it were to succeed,\n     * it shouldn't have file access since --allow-read and --allow-write permissions\n     * are dropped.\n     *\n     * Other sandboxes should also fail, although the details why will vary.\n     * gvisor will allow running a process, but it will be sandboxed.\n     */\n    it(\"should not have write access to tmpDir via os.system\", async function() {\n      const fname = path.join(tmpDir, \"test.txt\");\n      await tryFormula(sandbox, `import os; os.system('echo \"hello\" > ${fname}')`);\n      assert.throw(() => fs.statSync(fname));\n    });\n\n    it(\"should not have read access to tmpDir via os.system\", async function() {\n      const fname = path.join(tmpDir, \"test.txt\");\n      const secretContent = \"secret content\";\n      fs.writeFileSync(fname, secretContent);\n      const result = await tryFormula(sandbox, `import os; os.system('exit $(wc -c < ${fname})')`);\n      // The exit code from os.system is in the upper byte.\n      const count = result >> 8;\n      assert.notEqual(count, secretContent.length);\n    });\n\n    it(\"should not have write access to mapped directories\", async function() {\n      const sandboxRoot = await sandbox.pyCall(\"test_get_sandbox_root\");\n\n      // First, check we see matching files where we expect, using fs.\n      const mainPyPath = getSandboxPaths(sandboxRoot, \"main.py\");\n      const sandboxContent = await sandbox.pyCall(\"test_read_file\", mainPyPath.sandbox);\n      const hostContent = fs.readFileSync(mainPyPath.host, \"utf8\");\n      assert.equal(sandboxContent, hostContent);\n      assert.isAbove(sandboxContent.length, 10);\n\n      // Try to add a file using os.system.\n      const testPath = getSandboxPaths(sandboxRoot, \"test_write_via_os_system.txt\");\n      await tryFormula(sandbox, `import os; os.system('echo \"hello\" > ${testPath.sandbox}')`);\n\n      // Verify the file doesn't exist on the host in the obvious places.\n      assert.isFalse(fs.existsSync(testPath.host), \"File should not exist on host\");\n      assert.isFalse(fs.existsSync(testPath.sandbox), \"File should not even exist on confused host\");\n\n      // Try to add a file using fs.\n      await tryFormula(sandbox, `open(\"${testPath.sandbox}\", \"w\").write(\"hello\")`);\n\n      // Verify the file still doesn't exist.\n      assert.isFalse(fs.existsSync(testPath.host), \"File should not exist on host\");\n      assert.isFalse(fs.existsSync(testPath.sandbox), \"File should not even exist on confused host\");\n\n      // Try to change a file using os.system.\n      // Try both with sandbox and host paths.\n      await tryFormula(sandbox, `import os; os.system('echo \"hello\" > ${mainPyPath.sandbox}')`);\n      await tryFormula(sandbox, `import os; os.system('echo \"hello\" > ${mainPyPath.host}')`);\n\n      // Verify the change didn't take effect.\n      let hostContentRechecked = fs.readFileSync(mainPyPath.host, \"utf8\");\n      assert.equal(hostContent, hostContentRechecked);\n\n      // Try to change a file using fs.\n      await tryFormula(sandbox, `open(\"${mainPyPath.sandbox}\", \"w\").write(\"hello\")`);\n\n      // Verify the change didn't take effect.\n      hostContentRechecked = fs.readFileSync(mainPyPath.host, \"utf8\");\n      assert.equal(hostContent, hostContentRechecked);\n    });\n\n    it(\"should not see files created in tmpDir on host\", async function() {\n      const fname = \"host_created_file.txt\";\n      const testFile = path.join(tmpDir, fname);\n      const secretContent = \"secret\";\n\n      // Create a file on the host\n      fs.writeFileSync(testFile, secretContent);\n      try {\n        let sandboxContent = \"\";\n        try {\n          sandboxContent = await sandbox.pyCall(\"test_read_file\", path.join(\"/tmp\", fname));\n        } catch (e) {\n          // File not found or PermissionError is acceptable.\n          if (!String(e).match(/FileNotFoundError/) &&\n            !String(e).match(/PermissionError/)) {\n            throw e;\n          }\n        }\n        assert.lengthOf(sandboxContent, 0);\n      } finally {\n        fs.unlinkSync(testFile);\n      }\n    });\n\n    // Pyodide specific test. Should fail for pyodide if GRIST_PYODIDE_SKIP_DENO=1 is set.\n    // Should succeed with deno, and of course in all other sandboxes.\n    it(\"should not have write access via emscripten_run_script_string\", async function() {\n      const testFile = path.join(tmpDir, \"emscripten_test.txt\");\n\n      try {\n        // Try to write using emscripten_run_script_string\n        const result = await tryFormula(sandbox, `\nimport sys\nfrom ctypes import cdll, c_char_p\nlibc = cdll.LoadLibrary(None)\nlibc.emscripten_run_script_string.argtypes = [c_char_p]\nlibc.emscripten_run_script_string.restype = c_char_p\nlibc.emscripten_run_script_string(b\"require('fs').writeFileSync('${testFile}', 'hello')\")\nreturn 'done'\n`);\n        if (!result.match(\"done\") &&\n          !result.match(/undefined symbol: emscripten_run_script_string/) &&\n          !result.match(/symbol not found/)) {\n          throw new Error(\"unexpected result \" + String(result));\n        }\n      } catch (e) {\n        if (\n          // this is how pyodide sandbox should fail.\n          !String(e).match(/NotCapable: Requires write access/)\n        ) {\n          throw e;\n        }\n      }\n\n      // Verify the file doesn't exist\n      assert.isFalse(fs.existsSync(testFile), \"emscripten_run_script_string should not allow file writes\");\n    });\n\n    // Note: this test may modify the sandboxed main.py in place\n    it(\"pyodide writes to sandbox files should not survive outside the sandbox\", async function() {\n      if (sandbox.getFlavor() !== \"pyodide\") {\n        this.skip();\n      }\n      try {\n        const sandboxRoot = await sandbox.pyCall(\"test_get_sandbox_root\");\n        const mainFile = path.join(sandboxRoot, \"main.py\");\n\n        // pyodide works on a copy of the original files\n        await sandbox.pyCall(\"test_write_file\", mainFile, \"# A rambunctious little edit\");\n        const fileContentsInSandbox = await sandbox.pyCall(\"test_read_file\", mainFile);\n        assert.match(fileContentsInSandbox, /defines what sandbox functions are made available to the Node controller/);\n        assert.match(fileContentsInSandbox, /rambunctious/);\n\n        const fileContents = fs.readFileSync(`./sandbox/${mainFile}`).toString();\n        assert.match(fileContents, /defines what sandbox functions are made available to the Node controller/);\n        assert.notMatch(fileContents, /rambunctious/);\n      } catch (e) {\n        // Writes may fail entirely.\n        if (\n          !String(e).match(/NotCapable: Requires write access/)\n        ) {\n          throw e;\n        }\n      }\n    });\n\n    // Note: this test may modify the sandboxed main.py in place\n    it(\"gvisor and macSandboxExec should have no write access to sandbox files\", async function() {\n      if (![\"gvisor\", \"macSandboxExec\"].includes(sandbox.getFlavor())) {\n        this.skip();\n      }\n      const sandboxRoot = await sandbox.pyCall(\"test_get_sandbox_root\");\n      const mainFile = path.join(sandboxRoot, \"main.py\");\n\n      // gvisor mounts the sandbox files as read-only\n      await assert.isRejected(\n        sandbox.pyCall(\"test_write_file\", mainFile, \"# A rambunctious little edit\"),\n      );\n      const fileContents = await sandbox.pyCall(\"test_read_file\", mainFile);\n      assert.match(fileContents, /defines what sandbox functions are made available to the Node controller/);\n      assert.notMatch(fileContents, /rambunctious/);\n    });\n\n    it(\"gvisor and pyodide should fail after unreasonable number of calls to os.fork()\", async function() {\n      // TODO: This test fails in Jenkins CI when run with gvisor. It doesn't appear to be enforcing GVISOR_LIMIT_NPROC.\n      if (![/* \"gvisor\", */\"pyodide\"].includes(sandbox.getFlavor())) {\n        this.skip();\n      }\n\n      await assert.isRejected(\n        sandbox.pyCall(\"test_fork\", 64),\n        /BlockingIOError|OSError/,\n      );\n    });\n  });\n\n  describe(\"pyodide\", function() {\n    before(function() {\n      if (process.env.GRIST_SANDBOX_FLAVOR !== \"pyodide\") {\n        this.skip();\n      }\n    });\n\n    describe(\"execute via external node command\", function() {\n      let oldEnv: testUtils.EnvironmentSnapshot;\n      before(function() {\n        oldEnv = new testUtils.EnvironmentSnapshot();\n        process.env.GRIST_SANDBOX = process.execPath;\n        // Using node executable, not deno executable\n        process.env.GRIST_PYODIDE_SKIP_DENO = \"true\";\n      });\n\n      after(function() {\n        oldEnv?.restore();\n      });\n\n      it(\"can create a pyodide sandbox by spawning\", async function() {\n        const sandbox = createSandbox(\"sandboxed\", {});\n        try {\n          const result = await sandbox.pyCall(\n            \"test_echo\", \"Hello world\",\n          );\n          assert.equal(result, \"Hello world\");\n        } finally {\n          await sandbox.shutdown();\n        }\n      });\n    });\n  });\n});\n\nfunction getSubDirs(dir: string, root: string): string[] {\n  // Walk directories but replace the root with the given root\n  return [root, ...fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {\n    if (!entry.isDirectory()) {\n      return [];\n    }\n    const full = path.join(dir, entry.name);\n    const mapped = path.join(root, entry.name);\n    return getSubDirs(full, mapped);\n  })];\n}\n\nfunction getSandboxPaths(sandboxRoot: string, fname: string) {\n  return {\n    sandbox: path.join(sandboxRoot, fname),\n    host: path.join(testUtils.appRoot, \"sandbox/grist\", fname),\n  };\n}\n"
  },
  {
    "path": "test/server/customUtil.ts",
    "content": "import { version as installedVersion } from \"app/common/version\";\nimport { getAppRoot } from \"app/server/lib/places\";\nimport { fromCallback, listenPromise } from \"app/server/lib/serverUtils\";\nimport { fixturesRoot } from \"test/server/testUtils\";\n\nimport * as http from \"http\";\nimport { AddressInfo, Socket } from \"net\";\nimport * as path from \"path\";\n\nimport express from \"express\";\n\n// An alternative domain for localhost, to test links that look external. We have a record for\n// localtest.datagrist.com set up to point to localhost.\nconst TEST_GRIST_HOST = \"localtest.datagrist.com\";\n\nexport interface Serving {\n  url: string;\n  shutdown: () => Promise<void>;\n}\n\n// Adds static files from a directory.\n// By default exposes /fixture/sites\nexport function addStatic(app: express.Express, rootDir?: string) {\n  // mix in a copy of the plugin api\n  app.use(/^\\/(grist-plugin-api.js)$/, (req, res) =>\n    res.sendFile(req.params[0], { root:\n                                        path.resolve(getAppRoot(), \"static\") }));\n  app.use(express.static(rootDir || path.resolve(fixturesRoot, \"sites\"), {\n    setHeaders: (res: express.Response) => {\n      res.set(\"Access-Control-Allow-Origin\", \"*\");\n    },\n  }));\n}\n\n// Serve from a directory.\nexport async function serveStatic(rootDir: string): Promise<Serving> {\n  return serveSomething(app => addStatic(app, rootDir));\n}\n\n// Serve a string of html.\nexport async function serveSinglePage(html: string): Promise<Serving> {\n  return serveSomething((app) => {\n    app.get(\"\", (req, res) => res.send(html));\n  });\n}\n\nexport function serveCustomViews(): Promise<Serving> {\n  return serveStatic(path.resolve(fixturesRoot, \"sites\"));\n}\n\nexport async function serveSomething(setup: (app: express.Express) => void, port = 0): Promise<Serving> {\n  const app = express();\n  const server = http.createServer(app);\n  await listenPromise(server.listen(port));\n\n  const connections = new Set<Socket>();\n  server.on(\"connection\", (conn) => {\n    connections.add(conn);\n    conn.on(\"close\", () => connections.delete(conn));\n  });\n\n  async function shutdown() {\n    for (const conn of connections) { conn.destroy(); }\n    await fromCallback(cb => server.close(cb));\n  }\n\n  port = (server.address() as AddressInfo).port;\n  app.set(\"port\", port);\n  setup(app);\n  const url = `http://localhost:${port}`;\n  return { url, shutdown };\n}\n\n/**\n * Creates a promise like object that can be resolved from outside.\n */\nexport class Defer<T = void> {\n  private _resolve!: (val: T) => T;\n  private _reject!: (err: any) => void;\n  private _promise: Promise<T>;\n\n  constructor() {\n    this._promise = new Promise<T>((resolve, reject) => {\n      this._resolve = resolve as any;\n      this._reject = reject;\n    });\n  }\n\n  public get then() {\n    return this._promise.then.bind(this._promise);\n  }\n\n  public resolve(val: T) {\n    this._resolve(val);\n  }\n\n  public reject(err: any) {\n    this._reject(err);\n  }\n}\n\nexport async function startFakeUpdateServer() {\n  let mutex: Defer | null = null;\n  const API: FakeUpdateServer = {\n    latestVersion: bumpVersion(installedVersion),\n    isCritical: false,\n    failNext: false,\n    payload: null,\n    close: async () => {\n      mutex?.resolve();\n      mutex = null;\n      await server?.shutdown();\n      server = null;\n    },\n    pause: () => {\n      mutex = new Defer();\n    },\n    resume: () => {\n      mutex?.resolve();\n      mutex = null;\n    },\n    url: () => {\n      return server!.url;\n    },\n    bumpVersion: () => {\n      API.latestVersion = bumpVersion(API.latestVersion);\n    },\n  };\n\n  let server: Serving | null = await serveSomething((app) => {\n    app.use(express.json());\n    app.post(\"/version\", async (req, res, next) => {\n      API.payload = req.body;\n      try {\n        await mutex;\n        if (API.failNext) {\n          res.status(500).json({ error: \"some error\" });\n          API.failNext = false;\n          return;\n        }\n        res.json({\n          latestVersion: API.latestVersion,\n          isCritical: API.isCritical,\n        });\n      } catch (ex) {\n        next(ex);\n      }\n    });\n  });\n\n  return API;\n}\n\nfunction bumpVersion(version: string) {\n  const parts = version.split(\".\").map((part) => {\n    return Number(part.replace(/\\D/g, \"\"));\n  });\n  parts[parts.length - 1] += 1;\n  return parts.join(\".\");\n}\n\nexport interface FakeUpdateServer {\n  latestVersion: string;\n  isCritical: boolean;\n  failNext: boolean;\n  payload: any;\n  close: () => Promise<void>;\n  pause: () => void;\n  resume: () => void;\n  url: () => string;\n  bumpVersion: () => void;\n}\n\n/**\n * Call this in describe() to set up before/after hooks to serve some content on a non-Grist URL,\n * just so we can reliably open such URLs. (When we used \"example.com\", it was occasionally\n * unresponsive, causing tests to fail.)\n *\n * Any request just returns the provided content.\n */\nexport function setupExternalSite(content: string) {\n  let serving: Serving;\n  let servingUrl: URL;\n  before(async function() {\n    serving = await serveSinglePage(\"Dolphins are cool.\");\n    servingUrl = new URL(serving.url);\n    servingUrl.hostname = TEST_GRIST_HOST;\n  });\n  after(async function() {\n    if (serving) { await serving.shutdown(); }\n  });\n  return {\n    getUrl() { return servingUrl; },\n  };\n}\n"
  },
  {
    "path": "test/server/docTools.ts",
    "content": "import { Role } from \"app/common/roles\";\nimport { getDocWorkerMap } from \"app/gen-server/lib/DocWorkerMap\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { AttachmentStoreProvider, IAttachmentStoreProvider } from \"app/server/lib/AttachmentStoreProvider\";\nimport { create } from \"app/server/lib/create\";\nimport { DummyAuthorizer } from \"app/server/lib/DocAuthorizer\";\nimport { DocManager } from \"app/server/lib/DocManager\";\nimport { makeExceptionalDocSession, makeOptDocSession, OptDocSession } from \"app/server/lib/DocSession\";\nimport { createDummyGristServer, GristServer } from \"app/server/lib/GristServer\";\nimport { IDocStorageManager } from \"app/server/lib/IDocStorageManager\";\nimport { getAppRoot } from \"app/server/lib/places\";\nimport { PluginManager } from \"app/server/lib/PluginManager\";\nimport { createTmpDir as createTmpUploadDir, FileUploadInfo, globalUploadSet } from \"app/server/lib/uploads\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { tmpdir } from \"os\";\nimport * as path from \"path\";\n\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\nimport * as tmp from \"tmp-promise\";\n\ntmp.setGracefulCleanup();\n\n// it is sometimes useful in debugging to turn off automatic cleanup of docs and workspaces.\nconst noCleanup = Boolean(process.env.NO_CLEANUP);\n\n/**\n * Use from a test suite to get an object with convenient methods for creating ActiveDocs:\n *\n *  createDoc(docName): creates a new empty document.\n *  loadFixtureDoc(docName): loads a copy of a fixture document.\n *  loadDoc(docName): loads a given document, e.g. previously created with createDoc().\n *  createFakeSession(): creates a fake DocSession for use when applying user actions.\n *\n * Also available are accessors for the created \"managers\":\n *  getDocManager()\n *  getStorageManager()\n *  getPluginManager()\n *\n * It also takes care of cleaning up any created ActiveDocs.\n * @param persistAcrossCases Don't shut down created ActiveDocs between test cases.\n * @param useFixturePlugins Use the plugins in `test/fixtures/plugins`\n */\nexport function createDocTools(options: { persistAcrossCases?: boolean,\n  useFixturePlugins?: boolean,\n  storageManager?: IDocStorageManager,\n  server?: () => GristServer,\n  createAttachmentStoreProvider?: () => Promise<IAttachmentStoreProvider>\n} = {}) {\n  let tmpDir: string;\n  let docManager: DocManager;\n  let attachmentStoreProvider: IAttachmentStoreProvider;\n\n  async function doBefore() {\n    tmpDir = await createTmpDir();\n    const pluginManager = options.useFixturePlugins ? await createFixturePluginManager() : undefined;\n    attachmentStoreProvider = options.createAttachmentStoreProvider ?\n      (await options.createAttachmentStoreProvider()) :\n      new AttachmentStoreProvider([], \"TEST_INSTALL\");\n\n    docManager = await createDocManager({ tmpDir, pluginManager, storageManager: options.storageManager,\n      server: options.server?.(), attachmentStoreProvider });\n  }\n\n  async function doAfter() {\n    // Clean up at the end of the test suite (in addition to the optional per-test cleanup).\n    await testUtils.captureLog(\"info\", () => docManager.shutdownAll());\n    assert.equal(docManager.numOpenDocs(), 0);\n    await globalUploadSet.cleanupAll();\n\n    // Clean up the temp directory.\n    if (!noCleanup) {\n      await fse.remove(tmpDir);\n    }\n  }\n\n  // Allow using outside of mocha\n  if (typeof before !== \"undefined\") {\n    before(doBefore);\n    after(doAfter);\n\n    // Check after each test case that all ActiveDocs got shut down.\n    afterEach(async function() {\n      if (!options.persistAcrossCases) {\n        await docManager.shutdownAll();\n        assert.equal(docManager.numOpenDocs(), 0);\n      }\n    });\n  }\n\n  const systemSession = makeExceptionalDocSession(\"system\");\n  return {\n    /** create a fake session for use when applying user actions to a document */\n    createFakeSession(role: Role = \"editors\"): OptDocSession {\n      const docSession = makeOptDocSession(null);\n      docSession.authorizer = new DummyAuthorizer(role, \"doc\");\n      return docSession;\n    },\n\n    /** create a throw-away, empty document for testing purposes */\n    async createDoc(docName: string): Promise<ActiveDoc> {\n      return docManager.createNewEmptyDoc(systemSession, docName);\n    },\n\n    /** load a copy of a fixture document for testing purposes */\n    async loadFixtureDoc(docName: string): Promise<ActiveDoc> {\n      const copiedDocName = await testUtils.useFixtureDoc(docName, docManager.storageManager);\n      return this.loadDoc(copiedDocName);\n    },\n\n    /** load a copy of a local document at an arbitrary path on disk for testing purposes */\n    async loadLocalDoc(srcPath: string): Promise<ActiveDoc> {\n      const copiedDocName = await testUtils.useLocalDoc(srcPath, docManager.storageManager);\n      return this.loadDoc(copiedDocName);\n    },\n\n    /** like `loadFixtureDoc`, but lets you rename the document on disk */\n    async loadFixtureDocAs(docName: string, alias: string): Promise<ActiveDoc> {\n      const copiedDocName = await testUtils.useFixtureDoc(docName, docManager.storageManager, alias);\n      return this.loadDoc(copiedDocName);\n    },\n\n    /** Loads a given document, e.g. previously created with createDoc() */\n    async loadDoc(docName: string): Promise<ActiveDoc> {\n      return docManager.fetchDoc(systemSession, docName);\n    },\n\n    getDocManager() { return docManager; },\n    getStorageManager() { return docManager.storageManager; },\n    getPluginManager() { return docManager.pluginManager; },\n    getAttachmentStoreProvider() { return attachmentStoreProvider; },\n\n    /** Setup that needs to be done before using the tools, typically called by mocha */\n    before() { return doBefore(); },\n\n    /** Teardown that needs to be done after using the tools, typically called by mocha */\n    after() { return doAfter(); },\n  };\n}\n\n/**\n * Returns a DocManager for tests, complete with a PluginManager and DocStorageManager.\n * @param options.pluginManager The PluginManager to use; defaults to using a real global singleton\n *    that loads built-in modules.\n */\nexport async function createDocManager(\n  options: { tmpDir?: string, pluginManager?: PluginManager,\n    storageManager?: IDocStorageManager,\n    server?: GristServer,\n    attachmentStoreProvider?: IAttachmentStoreProvider,\n  } = {}): Promise<DocManager> {\n  // Set Grist home to a temporary directory, and wipe it out on exit.\n  const tmpDir = options.tmpDir || await createTmpDir();\n  const docStorageManager = options.storageManager || await create.createLocalDocStorageManager(tmpDir);\n  const pluginManager = options.pluginManager || await getGlobalPluginManager();\n  const attachmentStoreProvider = options.attachmentStoreProvider ?? new AttachmentStoreProvider([], \"TEST_INSTALL\");\n  const store = getDocWorkerMap();\n  const internalPermitStore = store.getPermitStore(\"1\");\n  const externalPermitStore = store.getPermitStore(\"2\");\n  return new DocManager(docStorageManager, pluginManager, null, attachmentStoreProvider, options.server || {\n    ...createDummyGristServer(),\n    getPermitStore() { return internalPermitStore; },\n    getExternalPermitStore() { return externalPermitStore; },\n    getStorageManager() { return docStorageManager; },\n  });\n}\n\nexport async function createTmpDir(): Promise<string> {\n  const tmpRootDir = path.resolve(process.env.TESTDIR || tmpdir());\n  await fse.mkdirs(tmpRootDir);\n  return (await tmp.dir({\n    tmpdir: tmpRootDir,\n    prefix: \"grist_test_\",\n    unsafeCleanup: true,\n    keep: noCleanup,\n  })).path;\n}\n\n/**\n * Creates a file with the given name (and simple dummy content) in dirPath, and returns\n * FileUploadInfo for it.\n */\nexport async function createFile(dirPath: string, name: string): Promise<FileUploadInfo> {\n  const absPath = path.join(dirPath, name);\n  await fse.outputFile(absPath, `${name}:${name}\\n`);\n  return {\n    absPath,\n    origName: name,\n    size: (await fse.stat(absPath)).size,\n    ext: path.extname(name),\n  };\n}\n\n/**\n * Creates an upload with the given filenames (containg simple dummy content), in the\n * globalUploadSet, and returns its uploadId. The upload is registered with the given accessId\n * (userId), and the same id must be used to retrieve it.\n */\nexport async function createUpload(fileNames: string[], accessId: string | null): Promise<number> {\n  const { tmpDir, cleanupCallback } = await createTmpUploadDir({});\n  const files = await Promise.all(fileNames.map(name => createFile(tmpDir, name)));\n  return globalUploadSet.registerUpload(files, tmpDir, cleanupCallback, accessId);\n}\n\nlet _globalPluginManager: PluginManager | null = null;\n\n// Helper to create a singleton PluginManager. This includes loading built-in plugins. Since most\n// tests don't make any use of it, it's fine to reuse a single one. For tests that need a custom\n// one, pass one into createDocManager().\nexport async function getGlobalPluginManager(): Promise<PluginManager> {\n  if (!_globalPluginManager) {\n    const appRoot = getAppRoot();\n    _globalPluginManager = new PluginManager(appRoot);\n    await _globalPluginManager.initialize();\n  }\n  return _globalPluginManager;\n}\n\n// Path to the folder where builtIn plugins leave in test/fixtures\nexport const builtInFolder = path.join(testUtils.fixturesRoot, \"plugins/builtInPlugins\");\n\n// Path to the folder where installed plugins leave in test/fixtures\nexport const installedFolder = path.join(testUtils.fixturesRoot, \"plugins/installedPlugins\");\n\n// Creates a plugin manager which loads the plugins in `test/fixtures/plugins`\nasync function createFixturePluginManager() {\n  const p = new PluginManager(builtInFolder, installedFolder);\n  p.appRoot =  getAppRoot();\n  await p.initialize();\n  return p;\n}\n"
  },
  {
    "path": "test/server/generateInitialDocSql.ts",
    "content": "import { getAppRoot } from \"app/server/lib/places\";\nimport { createTmpDir } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport * as childProcess from \"child_process\";\nimport * as path from \"path\";\nimport * as util from \"util\";\n\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\n\nconst execFile = util.promisify(childProcess.execFile);\n\ndescribe(\"generateInitialDocSql\", function() {\n  this.timeout(10000);\n\n  let tmpDir: string;\n\n  testUtils.setTmpLogLevel(\"fatal\");\n\n  before(async function() {\n    tmpDir = await createTmpDir();\n  });\n\n  it('confirms schema and sql files are up to date (run \"yarn run generate:schema:ts\" on failure)', async function() {\n    let root = getAppRoot();\n    if (await fse.pathExists(path.join(root, \"core\"))) {\n      root = path.join(root, \"core\");\n    }\n    const newSchemaTs = path.join(tmpDir, \"schema.ts\");\n    const newSqlTs = path.join(tmpDir, \"sql.ts\");\n    const currentSchemaTs = path.join(root, \"app/common/schema.ts\");\n    const currentSqlTs = path.join(root, \"app/server/lib/initialDocSql.ts\");\n    await execFile(path.join(getAppRoot(), \"buildtools/update_schema.sh\"), [\n      newSchemaTs, newSqlTs,\n    ], { env: process.env });\n\n    assert.equal(normaliseSQLiteInfinity((await fse.readFile(newSchemaTs)).toString()),\n      normaliseSQLiteInfinity((await fse.readFile(currentSchemaTs)).toString()));\n    assert.equal(normaliseSQLiteInfinity((await fse.readFile(newSqlTs)).toString()),\n      normaliseSQLiteInfinity((await fse.readFile(currentSqlTs)).toString()));\n  });\n});\n\nfunction normaliseSQLiteInfinity(sql: string) {\n  return sql.replace(/\\b1e\\+?999\\b/g, \"9.0e+999\");\n}\n"
  },
  {
    "path": "test/server/gristClient.ts",
    "content": "import { GristClientSocket } from \"app/client/components/GristClientSocket\";\nimport { ValidEvent } from \"app/common/CommTypes\";\nimport { DocAction } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { SchemaTypes } from \"app/common/schema\";\nimport { FlexServer } from \"app/server/lib/FlexServer\";\n\nimport axios from \"axios\";\nimport pick from \"lodash/pick\";\n\ninterface GristRequest {\n  reqId: number;\n  method: string;\n  args: any[];\n}\n\ninterface GristResponse {\n  reqId: number;\n  error?: string;\n  errorCode?: string;\n  data?: any;\n}\n\ninterface GristMessage {\n  type: ValidEvent;\n  docFD: number;\n  data: any;\n}\n\nexport class GristClient {\n  public messages: GristMessage[] = [];\n\n  private _requestId: number = 0;\n  private _pending: (GristResponse | GristMessage)[] = [];\n  private _docData?: DocData;  // accumulate tabular info like a real client.\n  private _consumer: () => void;\n  private _ignoreTrivialActions: boolean = false;\n  private _ignorePresenceUpdates: boolean = false;\n\n  constructor(public ws: GristClientSocket) {\n    ws.onmessage = (data: string) => {\n      const msg = pick(JSON.parse(data),\n        [\"reqId\", \"error\", \"errorCode\", \"data\", \"type\", \"docFD\"]);\n      if (this._ignorePresenceUpdates && msg.type === \"docUserPresenceUpdate\") {\n        return;\n      }\n      if (this._ignoreTrivialActions && msg.type === \"docUserAction\" &&\n        msg.data?.actionGroup?.internal === true &&\n        msg.data?.docActions?.length === 0) {\n        return;\n      }\n      this._pending.push(msg);\n      if (msg.data?.doc) {\n        this._docData = new DocData(() => {\n          throw new Error(\"no fetches\");\n        }, msg.data.doc);\n      }\n      if (this._docData && msg.type === \"docUserAction\") {\n        const docActions = msg.data?.docActions || [];\n        for (const docAction of docActions) {\n          this._docData.receiveAction(docAction);\n        }\n      }\n      if (this._consumer) { this._consumer(); }\n    };\n  }\n\n  // After a document is opened, the sandbox recomputes its formulas and sends any changes.\n  // The client will receive an update even if there are no changes. This may be useful in\n  // the future to know that the document is up to date. But for testing, this asynchronous\n  // message can be awkward. Call this method to ignore it.\n  public ignoreTrivialActions() {\n    this._ignoreTrivialActions = true;\n  }\n\n  public ignorePresenceUpdates() {\n    this._ignorePresenceUpdates = true;\n  }\n\n  public flush() {\n    this._pending = [];\n  }\n\n  public shift() {\n    return this._pending.shift();\n  }\n\n  public count() {\n    return this._pending.length;\n  }\n\n  public get docData() {\n    if (!this._docData) { throw new Error(\"no DocData\"); }\n    return this._docData;\n  }\n\n  public getMetaRecords(tableId: keyof SchemaTypes) {\n    return this.docData.getMetaTable(tableId).getRecords();\n  }\n\n  public async read(): Promise<any> {\n    for (;;) {\n      if (this._pending.length) {\n        return this._pending.shift();\n      }\n      await new Promise<void>(resolve => this._consumer = resolve);\n    }\n  }\n\n  public isOpen() {\n    return this.ws.isOpen();\n  }\n\n  public async readMessage(): Promise<GristMessage> {\n    const result = await this.read();\n    if (!result.type) {\n      throw new Error(`message looks wrong: ${JSON.stringify(result)}`);\n    }\n    return result;\n  }\n\n  public async readResponse(): Promise<GristResponse> {\n    this.messages = [];\n    for (;;) {\n      const result = await this.read();\n      if (result.reqId === undefined) {\n        this.messages.push(result);\n        continue;\n      }\n      if (result.reqId !== this._requestId) {\n        throw new Error(\"unexpected request id\");\n      }\n      return result;\n    }\n  }\n\n  public waitForServer() {\n    // send an arbitrary failing message and wait for response.\n    return this.send(\"ping\");\n  }\n\n  // Helper to read the next docUserAction ignoring anything else (e.g. a duplicate clientConnect).\n  public async readDocUserAction(): Promise<DocAction[]> {\n    while (true) {\n      const msg = await this.readMessage();\n      if (msg.type === \"docUserAction\") {\n        return msg.data.docActions;\n      }\n    }\n  }\n\n  public async send(method: string, ...args: any[]): Promise<GristResponse> {\n    const p = this.readResponse();\n    this._requestId++;\n    const req: GristRequest = {\n      reqId: this._requestId,\n      method,\n      args,\n    };\n    this.ws.send(JSON.stringify(req));\n    const result = await p;\n    return result;\n  }\n\n  public async close() {\n    this.ws.close();\n  }\n\n  public async openDocOnConnect(docId: string) {\n    const msg = await this.readMessage();\n    if (msg.type !== \"clientConnect\") { throw new Error(\"expected clientConnect\"); }\n    const openDoc = await this.send(\"openDoc\", docId);\n    if (openDoc.error) { throw new Error(`error in openDocOnConnect: ${openDoc.error}`, undefined); }\n    return openDoc;\n  }\n}\n\nexport async function openClient(server: FlexServer, email: string, org: string,\n  emailHeader?: string): Promise<GristClient> {\n  const headers: Record<string, string> = {};\n  if (!emailHeader) {\n    const resp = await axios.get(`${server.getOwnUrl()}/test/session`);\n    const cookie = resp.headers[\"set-cookie\"]![0];\n    if (email !== \"anon@getgrist.com\") {\n      const cid = decodeURIComponent(cookie.split(\"=\")[1].split(\";\")[0]);\n      const sessions = server.getSessions();\n      const sessionId = sessions.getSessionIdFromCookie(cid) as string;\n      const scopedSession = sessions.getOrCreateSession(sessionId, org);\n      const profile = { email, email_verified: true, name: \"Someone\" };\n      await scopedSession.updateUserProfile({} as any, profile);\n    }\n    headers.Cookie = cookie;\n  } else {\n    headers[emailHeader] = email;\n  }\n  const ws = new GristClientSocket(\"ws://localhost:\" + server.getOwnPort() + `/o/${org}`, {\n    headers,\n  });\n  const client = new GristClient(ws);\n  await new Promise(function(resolve, reject) {\n    ws.onopen = function() {\n      ws.onerror = null;\n      resolve(ws);\n    };\n    ws.onerror = function(err: Error) {\n      ws.onopen = null;\n      reject(err);\n    };\n  });\n  return client;\n}\n"
  },
  {
    "path": "test/server/lib/ACLFormula.ts",
    "content": "import { CellValue } from \"app/common/DocActions\";\nimport { CompiledPredicateFormula, compilePredicateFormula } from \"app/common/PredicateFormula\";\nimport { InfoView } from \"app/common/RecordView\";\nimport { User } from \"app/common/User\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport { createDocTools } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"ACLFormula\", function() {\n  this.timeout(10000);\n\n  // Turn off logging for this test, and restore afterwards.\n  testUtils.setTmpLogLevel(\"error\");\n\n  const docTools = createDocTools({ persistAcrossCases: true });\n  const fakeSession = makeExceptionalDocSession(\"system\");\n\n  function getInfoView(row: Record<string, CellValue>): InfoView {\n    return {\n      get: (colId: string) => row[colId],\n      keys: () => Object.keys(row),\n      toJSON: () => row,\n    };\n  }\n\n  const V = getInfoView;    // A shortcut.\n\n  type SetAndCompile = (aclFormula: string) => Promise<CompiledPredicateFormula>;\n  let setAndCompile: SetAndCompile;\n\n  before(async function() {\n    const docName = \"docdata1\";\n    const activeDoc1 = await docTools.createDoc(docName);\n\n    const resourceRef = (await activeDoc1.applyUserActions(fakeSession,\n      [[\"AddRecord\", \"_grist_ACLResources\", null, { tableId: \"*\", colIds: \"*\" }]])).retValues[0];\n    const ruleRef = (await activeDoc1.applyUserActions(fakeSession,\n      [[\"AddRecord\", \"_grist_ACLRules\", null, { resource: resourceRef }]])).retValues[0];\n\n    setAndCompile = async function setAndCompile(aclFormula) {\n      await activeDoc1.applyUserActions(fakeSession, [[\"UpdateRecord\", \"_grist_ACLRules\", ruleRef, { aclFormula }]]);\n      const { tableData } = await activeDoc1.fetchQuery(\n        fakeSession, { tableId: \"_grist_ACLRules\", filters: { id: [ruleRef] } });\n      assert(tableData[3].aclFormulaParsed, \"Expected aclFormulaParsed to be populated\");\n      const parsedFormula = String(tableData[3].aclFormulaParsed[0]);\n      return compilePredicateFormula(JSON.parse(parsedFormula));\n    };\n  });\n\n  it(\"should handle a comparison\", async function() {\n    const compiled = await setAndCompile(\"user.Email == 'X@'\");\n    assert.equal(compiled({ user: new User({ Email: \"X@\" }) }), true);\n    assert.equal(compiled({ user: new User({ Email: \"Y@\" }) }), false);\n    assert.equal(compiled({ user: new User({ Email: \"X\" }), rec: V({ Email: \"Y@\" }) }), false);\n    assert.equal(compiled({ user: new User({ Name: \"X@\" }) }), false);\n  });\n\n  it('should handle the \"in\" operator', async function() {\n    const compiled = await setAndCompile(\"user.Role in ('editors', 'owners')\");\n    assert.equal(compiled({ user: new User({ Role: \"editors\" }) }), true);\n    assert.equal(compiled({ user: new User({ Role: \"owners\" }) }), true);\n    assert.equal(compiled({ user: new User({ Role: \"viewers\" }) }), false);\n    assert.equal(compiled({ user: new User({ Role: null }) }), false);\n    assert.equal(compiled({ user: new User({}) }), false);\n  });\n\n  it('should handle the \"not in\" operator', async function() {\n    const compiled = await setAndCompile(\"user.Role not in ('editors', 'owners')\");\n    assert.equal(compiled({ user: new User({ Role: \"editors\" }) }), false);\n    assert.equal(compiled({ user: new User({ Role: \"owners\" }) }), false);\n    assert.equal(compiled({ user: new User({ Role: \"viewers\" }) }), true);\n    assert.equal(compiled({ user: new User({ Role: null }) }), true);\n    assert.equal(compiled({ user: new User({}) }), true);\n  });\n\n  [{\n    op: \"in\",\n  }, {\n    op: \"not in\",\n  }].forEach((ctx) => {\n    it(`should handle the \"${ctx.op}\" operator with a string RHS to check if substring exist`, async function() {\n      const compiled = await setAndCompile(`user.Name ${ctx.op} 'FooBar'`);\n      assert.equal(compiled({ user: new User({ Name: \"FooBar\" }) }), ctx.op === \"in\");\n      assert.equal(compiled({ user: new User({ Name: \"Foo\" }) }), ctx.op === \"in\");\n      assert.equal(compiled({ user: new User({ Name: \"Bar\" }) }), ctx.op === \"in\");\n      assert.equal(compiled({ user: new User({ Name: \"bar\" }) }), ctx.op === \"not in\");\n      assert.equal(compiled({ user: new User({ Name: \"qux\" }) }), ctx.op === \"not in\");\n      assert.equal(compiled({ user: new User({ Name: null }) }), ctx.op === \"not in\");\n    });\n  });\n\n  it('should handle the \"and\" operator', async function() {\n    const compiled = await setAndCompile(\"rec.office == 'Seattle' and user.email in ['sally@', 'xie@']\");\n    assert.throws(() => compiled({ user: new User({ email: \"xie@\" }) }), /Missing row data 'rec'/);\n    assert.equal(compiled({ user: new User({ email: \"xie@\" }), rec: V({}) }), false);\n    assert.equal(compiled({ user: new User({ email: \"xie@\" }), rec: V({ office: null }) }), false);\n    assert.equal(compiled({ user: new User({ email: \"xie@home\" }), rec: V({ office: \"Seattle\" }) }), false);\n    assert.equal(compiled({ user: new User({ email: \"xie@\" }), rec: V({ office: \"Seattle\" }) }), true);\n    assert.equal(compiled({ user: new User({ email: \"sally@\" }), rec: V({ office: \"Seattle\" }) }), true);\n    assert.equal(compiled({ user: new User({ email: \"sally@\" }), rec: V({ office: \"Chicago\" }) }), false);\n    assert.equal(compiled({ user: new User({ email: null }), rec: V({ office: null }) }), false);\n    assert.equal(compiled({ user: new User({}), rec: V({}) }), false);\n  });\n\n  it('should handle the \"or\" operator', async function() {\n    const compiled = await setAndCompile('user.Email==\"X@\" or user.Email is None');\n    assert.equal(compiled({ user: new User({ Email: \"X@\" }) }), true);\n    assert.equal(compiled({ user: new User({}) }), true);\n    assert.equal(compiled({ user: new User({ Email: \"Y@\" }) }), false);\n  });\n\n  it(\"should handle a complex combination of operators\", async function() {\n    // This is not particularly meaningful, but involves more combinations.\n    const compiled = await setAndCompile(\n      \"user.IsAdmin or rec.assigned is None or (not newRec.HasDuplicates and rec.StatusIndex <= newRec.StatusIndex)\");\n    assert.equal(compiled({ user: new User({ IsAdmin: true }) }), true);\n    assert.equal(compiled({ user: new User({ IsAdmin: 17 }) }), true);\n    assert.throws(() => compiled({ user: new User({ IsAdmin: 0.0 }) }), /Missing row data 'rec'/);\n    assert.throws(\n      () => compiled({ user: new User({ IsAdmin: 0.0 }), rec: V({ assigned: true }) }),\n      /Missing row data 'newRec'/,\n    );\n    assert.equal(compiled({ user: new User({ IsAdmin: 0.0 }), rec: V({}), newRec: V({}) }), false);\n    assert.equal(compiled({ user: new User({ IsAdmin: false }), rec: V({ assigned: 0 }), newRec: V({}) }), false);\n    assert.equal(compiled({ user: new User({ IsAdmin: false }), rec: V({ assigned: null }) }), true);\n    assert.equal(compiled({ user: new User({ IsAdmin: true }), rec: V({ assigned: \"never\" }) }), true);\n    assert.equal(compiled({ user: new User({ IsAdmin: false }), rec: V({ assigned: \"None\" }),\n      newRec: V({ HasDuplicates: 1 }) }), false);\n    assert.equal(compiled({ user: new User({ IsAdmin: false }), rec: V({ assigned: 1, StatusIndex: 1 }),\n      newRec: V({ HasDuplicates: false, StatusIndex: 1 }) }), true);\n    assert.equal(compiled({ user: new User({ IsAdmin: false }), rec: V({ assigned: 1, StatusIndex: 1 }),\n      newRec: V({ HasDuplicates: false, StatusIndex: 17 }) }), true);\n    assert.equal(compiled({ user: new User({ IsAdmin: false }), rec: V({ assigned: 1, StatusIndex: 1 }),\n      newRec: V({ StatusIndex: 17 }) }), true);\n    assert.equal(compiled({ user: new User({ IsAdmin: false }), rec: V({ assigned: 1, StatusIndex: 2 }),\n      newRec: V({ HasDuplicates: false, StatusIndex: 1 }) }), false);\n    assert.equal(compiled({ user: new User({ IsAdmin: false }), rec: V({ assigned: 1, StatusIndex: 1 }),\n      newRec: V({ HasDuplicates: true, StatusIndex: 17 }) }), false);\n  });\n\n  it(\"should handle arithmetic tests\", async function() {\n    const compiled = await setAndCompile(\n      \"rec.A <= rec.B + 1 and rec.A >= rec.B - 1 and rec.A < rec.C * 2.5 and rec.A > rec.C / 2.5 and rec.A % 2 != 0\");\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 3, B: 3, C: 3 }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 3, B: 4, C: 3 }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 3, B: 2, C: 3 }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 3, B: 4.001, C: 3 }) }), false);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 3, B: 1.999, C: 3 }) }), false);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 6, B: 6, C: 6 }) }), false);     // A can't be even.\n    // C of 3 establishes the range for A of (1.2 - 7.5).\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 1.2, B: 1, C: 3 }) }), false);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 1.3, B: 1, C: 3 }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 7.4, B: 7, C: 3 }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 7.5, B: 7, C: 3 }) }), false);\n  });\n\n  it('should handle \"is\" and \"is not\" operators', async function() {\n    const compiled = await setAndCompile(\n      \"rec.A is True or rec.B is not False\");\n    assert.equal(compiled({ user: new User({}), rec: V({ A: true }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 2 }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 2, B: false }) }), false);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 2, B: null }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: 0, B: null }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: null, B: true }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: null, B: 2 }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: null, B: false }) }), false);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: null, B: 0 }) }), true);\n  });\n\n  it(\"should handle the supported string methods\", async function() {\n    let compiled = await setAndCompile(\"rec.A.lower() == rec.B\");\n    assert.equal(compiled({ user: new User({}), rec: V({ A: \"foo\", B: \"foo\" }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: \"FoO\", B: \"foo\" }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: \"Foo\", B: \"foo\" }) }), true);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: \"bar\", B: \"foo\" }) }), false);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: \" foo \", B: \"foo\" }) }), false);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: \"foo\", B: \"Foo\" }) }), false);\n    assert.equal(compiled({ user: new User({}), rec: V({ A: \"Foo\", B: \"Foo\" }) }), false);\n\n    compiled = await setAndCompile(\"user.email.upper() in [rec.A.upper()]\");\n    assert.equal(compiled({ user: new User({ email: \"Foo@\" }), rec: V({ A: \"foo@\" }) }), true);\n    assert.equal(compiled({ user: new User({ email: \"fOo@\" }), rec: V({ A: \"FOo@\" }) }), true);\n    assert.equal(compiled({ user: new User({ email: \"foo@\" }), rec: V({ A: \"bar@\" }) }), false);\n    assert.equal(compiled({ user: new User({ email: \"foo@\" }), rec: V({ A: \"foo\" }) }), false);\n    assert.equal(compiled({ user: new User({ email: \"x1/Y2\" }), rec: V({ A: \"X1/y2\" }) }), true);\n    assert.equal(compiled({ user: new User({ email: \"\" }), rec: V({ A: \"foo\" }) }), false);\n  });\n\n  it(\"should show reasonable errors for unsupported methods and functions\", async function() {\n    let compiled = await setAndCompile(\"rec.lower() == rec.B\");\n    assert.throws(() => compiled({ user: new User({}), rec: V({ A: \"foo\", B: \"foo\" }) }),\n      /Not a function: 'rec.lower'/);\n\n    compiled = await setAndCompile(\"rec.A.capitalize() == rec.B\");\n    assert.throws(() => compiled({ user: new User({}), rec: V({ A: \"foo\", B: \"foo\" }) }),\n      /Not a function: 'rec.A.capitalize'/);\n\n    compiled = await setAndCompile(\"rec.A.lower() == rec.B\");\n    assert.throws(() => compiled({ user: new User({}), rec: V({ A: 1, B: \"foo\" }) }),\n      /Not a function: 'rec.A.lower'/);   // Because rec.A is not a string.\n\n    await assert.isRejected(setAndCompile(\"lower(rec.A) == rec.B\"),\n      /Unknown variable 'lower'/);\n\n    await assert.isRejected(setAndCompile(\"oldRec.B == 1\"),\n      /Unknown variable 'oldRec'/);\n\n    compiled = await setAndCompile(\"rec.get('A') == rec.B\");\n    assert.throws(() => compiled({ user: new User({}), rec: V({ A: \"foo\", B: \"foo\" }) }),\n      /Not a function: 'rec.get'/);   // only explicitly supported functions are supported\n\n    // Try to be tricky: if rec.A is a function, that's weird, make sure we don't call it blindly.\n    compiled = await setAndCompile(\"rec.A() == rec.B\");\n    assert.throws(() => compiled({ user: new User({}), rec: V({ A: (() => 1) as any, B: \"foo\" }) }),\n      /Not a function: 'rec.A'/);     // only explicitly supported functions are supported\n  });\n\n  it(\"should handle nested attribute lookups\", async function() {\n    const compiled = await setAndCompile('user.office.city == \"New York\"');\n    assert.equal(compiled({ user: new User({ office: V({ city: \"New York\" }) }) }), true);\n    assert.equal(compiled({ user: new User({ office: V({ city: \"Boston\" }) }) }), false);\n    assert.equal(compiled({ user: new User({ office: V({ city: null }) }) }), false);\n    assert.throws(() => compiled({ user: new User({}) }), /No value for 'user.office'/);\n    assert.equal(compiled({ user: new User({ office: 5 }) }), false);\n    assert.throws(() => compiled({ user: new User({ office: null }) }), /No value for 'user.office'/);\n  });\n\n  it(\"should not support unexpected attributes\", async function() {\n    let compiled = await setAndCompile(\"user.email.length == rec.A\");\n    assert.equal(compiled({ user: new User({ email: \"Foo\" }), rec: V({ A: 3 }) }), false);\n    assert.equal(compiled({ user: new User({ email: \"Foo\" }), rec: V({ A: null }) }), false);\n    assert.equal(compiled({ user: new User({ email: \"Foo\" }), rec: V({ A: \"\" }) }), false);\n    assert.equal(compiled({ user: new User({ email: \"Foo\" }), rec: V({ A: undefined as any }) }), true);\n    assert.equal(compiled({ user: new User({ email: {} }), rec: V({ A: undefined as any }) }), true);\n    assert.equal(compiled({ user: new User({ email: [] }), rec: V({ A: undefined as any }) }), true);\n    assert.equal(compiled({ user: new User({ email: 5 }), rec: V({ A: undefined as any }) }), true);\n    assert.equal(compiled({ user: new User({ email: { length: \"x\" } }), rec: V({ A: \"x\" }) }), true);\n\n    compiled = await setAndCompile(\"user.email.asdf == rec.A\");\n    assert.equal(compiled({ user: new User({ email: \"Foo\" }), rec: V({ A: undefined as any }) }), true);\n\n    compiled = await setAndCompile(\"user.email.toUpperCase.name == rec.A\");\n    assert.throws(() => compiled({ user: new User({ email: \"Foo\" }), rec: V({ A: \"\" }) }),\n      /No value for 'user.email.toUpperCase'/);\n  });\n\n  it('should handle \"in\" and \"not in\" when RHS is nullish', async function() {\n    let compiled = await setAndCompile(\"user.Email in rec.emails\");\n    const user = new User({ Email: \"X@\" });\n    assert.equal(compiled({ user, rec: V({ emails: null }) }), false);\n    assert.equal(compiled({ user, rec: V({ unrelated: \"X@\" }) }), false);\n    assert.equal(compiled({ user, rec: V({ emails: \"X@\" }) }), true);\n    compiled = await setAndCompile(\"user.Email not in rec.emails\");\n    assert.equal(compiled({ user, rec: V({ emails: null }) }), true);\n    assert.equal(compiled({ user, rec: V({ unrelated: \"X@\" }) }), true);\n    assert.equal(compiled({ user, rec: V({ emails: \"X@\" }) }), false);\n    compiled = await setAndCompile(\"(user.Email in rec.emails) == (user.Name in rec.emails)\");\n    assert.equal(compiled({ user, rec: V({ emails: null }) }), true);\n    assert.equal(compiled({ user, rec: V({ emails: \"X@\" }) }), false);\n\n    compiled = await setAndCompile('\"A\" in user.Office.Rooms');\n    assert.equal(compiled({ user: new User({ Office: V({ Rooms: null }) }) }), false);\n    assert.equal(compiled({ user: new User({ Office: V({ Rooms: [GristObjCode.List, \"A\"] }) }) }), true);\n    assert.equal(compiled({ user: new User({ Office: V({ Rooms: [GristObjCode.List, \"B\"] }) }) }), false);\n  });\n\n  it('should handle \"in\" and \"not in\" when RHS is not a list', async function() {\n    let compiled = await setAndCompile(\"user.Email in rec.emails\");\n    const user = new User({ Email: \"X@\" });\n    assert.equal(compiled({ user, rec: V({ emails: \"X@\" }) }), true);\n    assert.equal(compiled({ user, rec: V({ emails: 0 }) }), false);\n    assert.equal(compiled({ user, rec: V({ emails: 17.5 }) }), false);\n    assert.equal(compiled({ user, rec: V({ emails: undefined as any }) }), false);\n\n    // The substring behavior checked here isn't what we want necessarily, because of risk of\n    // misuse, but it's been kept so far for backward compatibility.\n    assert.equal(compiled({ user, rec: V({ emails: \"AliceX@Y\" }) }), true);\n    // In case of a list, \"in\" checks for membership, not substrings.\n    assert.equal(compiled({ user, rec: V({ emails: [GristObjCode.List, \"X@\"] }) }), true);\n    assert.equal(compiled({ user, rec: V({ emails: [GristObjCode.List, \"AliceX@Y\"] }) }), false);\n\n    compiled = await setAndCompile(\"user.Email not in rec.emails\");\n    assert.equal(compiled({ user, rec: V({ emails: \"X@\" }) }), false);\n    assert.equal(compiled({ user, rec: V({ emails: 0 }) }), true);\n    assert.equal(compiled({ user, rec: V({ emails: 17.5 }) }), true);\n    assert.equal(compiled({ user, rec: V({ emails: undefined as any }) }), true);\n  });\n\n  it('should decode cell values so that \"in\" is safe to use with lists', async function() {\n    const user = new User({ Email: \"L\" });\n\n    // A previous bug meant that the above user would always pass this formula,\n    // because an encoded list always starts with the 'L' type code,\n    // and encoded cell values were used in evaluating formulas.\n    let compiled = await setAndCompile(\"user.Email in rec.emails\");\n    assert.equal(compiled({ user, rec: V({ emails: [GristObjCode.List] }) }), false);\n    assert.equal(compiled({ user, rec: V({ emails: [GristObjCode.List, \"X\"] }) }), false);\n    assert.equal(compiled({ user, rec: V({ emails: [GristObjCode.List, \"L\"] }) }), true);\n\n    // This should never happen (nothing should be encoded as an empty list),\n    // this just shows what would happen.\n    assert.equal(compiled({ user, rec: V({ emails: [] as any }) }), false);\n    // This is also false because once decoded, the value isn't a list, but an UnknownValue.\n    assert.equal(compiled({ user, rec: V({ emails: [\"A\", \"L\"] as any }) }), false);\n\n    // List literals aren't decoded and work as expected.\n    compiled = await setAndCompile(\"user.Email in []\");\n    assert.equal(compiled({ user, rec: V({}) }), false);\n\n    compiled = await setAndCompile('user.Email in [\"X\"]');\n    assert.equal(compiled({ user, rec: V({}) }), false);\n\n    compiled = await setAndCompile('user.Email in [\"L\"]');\n    assert.equal(compiled({ user, rec: V({}) }), true);\n  });\n\n  it(\"should allow comparing dates\", async function() {\n    const user = new User({});\n\n    const compiled = await setAndCompile(\"rec.date1 < rec.date2\");\n    for (let i = 0; i < 150; i++) {\n      const date1 = i * 10000000000;\n      for (let j = 0; j < 150; j++) {\n        const date2 = j * 10000000000;\n        const rec = V({\n          date1: [GristObjCode.Date, date1],\n          date2: [GristObjCode.Date, date2],\n        });\n        assert.equal(compiled({ user, rec }), date1 < date2);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "test/server/lib/ACLRulesReader.ts",
    "content": "import { ACLRulesReader } from \"app/common/ACLRulesReader\";\nimport { DocData } from \"app/common/DocData\";\nimport { MetaRowRecord } from \"app/common/TableData\";\nimport { CellValue } from \"app/plugin/GristData\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport { createDocTools } from \"test/server/docTools\";\n\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"ACLRulesReader\", function() {\n  this.timeout(10000);\n\n  const docTools = createDocTools({ persistAcrossCases: true });\n  const fakeSession = makeExceptionalDocSession(\"system\");\n\n  let activeDoc: ActiveDoc;\n  let docData: DocData;\n\n  before(async function() {\n    activeDoc = await docTools.createDoc(\"ACLRulesReader\");\n    docData = activeDoc.docData!;\n  });\n\n  describe(\"without shares\", function() {\n    it(\"entries\", async function() {\n      // Check output of reading the resources and rules of an empty document.\n      for (const options of [undefined, { addShareRules: true }]) {\n        assertResourcesAndRules(new ACLRulesReader(docData, options), [\n          DEFAULT_UNUSED_RESOURCE_AND_RULE,\n        ]);\n      }\n\n      // Add some table and default rules and re-check output.\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Private\", [{ id: \"A\" }]],\n        [\"AddTable\", \"PartialPrivate\", [{ id: \"A\" }]],\n        [\"AddRecord\", \"PartialPrivate\", null, { A: 0 }],\n        [\"AddRecord\", \"PartialPrivate\", null, { A: 1 }],\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Private\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"*\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"PartialPrivate\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1,\n          aclFormula: 'user.Access == \"owners\"',\n          permissionsText: \"all\",\n          memo: \"owner check\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"\", permissionsText: \"none\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-S\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -3, aclFormula: 'user.Access != \"owners\" and rec.A > 0', permissionsText: \"none\",\n        }],\n        [\"AddTable\", \"Public\", [{ id: \"A\" }]],\n      ]);\n      for (const options of [undefined, { addShareRules: true }]) {\n        assertResourcesAndRules(new ACLRulesReader(docData, options), [\n          {\n            resource: { id: 2, tableId: \"Private\", colIds: \"*\" },\n            rules: [\n              {\n                aclFormula: 'user.Access == \"owners\"',\n                permissionsText: \"all\",\n              },\n              {\n                aclFormula: \"\",\n                permissionsText: \"none\",\n              },\n            ],\n          },\n          {\n            resource: { id: 3, tableId: \"*\", colIds: \"*\" },\n            rules: [\n              {\n                aclFormula: 'user.Access != \"owners\"',\n                permissionsText: \"-S\",\n              },\n            ],\n          },\n          {\n            resource: { id: 4, tableId: \"PartialPrivate\", colIds: \"*\" },\n            rules: [\n              {\n                aclFormula: 'user.Access != \"owners\" and rec.A > 0',\n                permissionsText: \"none\",\n              },\n            ],\n          },\n          DEFAULT_UNUSED_RESOURCE_AND_RULE,\n        ]);\n      }\n    });\n\n    it(\"getResourceById\", async function() {\n      for (const options of [undefined, { addShareRules: true }]) {\n        // Check output of valid resource ids.\n        assert.deepEqual(\n          new ACLRulesReader(docData, options).getResourceById(1),\n          { id: 1, tableId: \"\", colIds: \"\" },\n        );\n        assert.deepEqual(\n          new ACLRulesReader(docData, options).getResourceById(2),\n          { id: 2, tableId: \"Private\", colIds: \"*\" },\n        );\n        assert.deepEqual(\n          new ACLRulesReader(docData, options).getResourceById(3),\n          { id: 3, tableId: \"*\", colIds: \"*\" },\n        );\n        assert.deepEqual(\n          new ACLRulesReader(docData, options).getResourceById(4),\n          { id: 4, tableId: \"PartialPrivate\", colIds: \"*\" },\n        );\n\n        // Check output of non-existent resource ids.\n        assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(5));\n        assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(0));\n        assert.isUndefined(new ACLRulesReader(docData, options).getResourceById(-1));\n      }\n    });\n\n    it(\"throws if duplicate ACLResources are present\", async function() {\n      await assert.isRejected(\n        activeDoc.applyUserActions(fakeSession, [\n          [\n            \"AddRecord\",\n            \"_grist_ACLResources\",\n            -1,\n            { tableId: \"Private\", colIds: \"*\" },\n          ],\n        ]),\n        /Duplicate ACLResource 5: an ACLResource with the same tableId and colIds already exists/,\n      );\n    });\n  });\n\n  describe(\"with shares\", function() {\n    before(async function() {\n      sinon.stub(ActiveDoc.prototype as any, \"_getHomeDbManagerOrFail\").returns({\n        syncShares: () => Promise.resolve(),\n      });\n      activeDoc = await docTools.loadFixtureDoc(\"FilmsWithImages.grist\");\n      docData = activeDoc.docData!;\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"AddRecord\", \"_grist_Shares\", null, {\n          linkId: \"x\",\n          options: '{\"publish\": true}',\n        }],\n      ]);\n    });\n\n    after(function() {\n      sinon.restore();\n    });\n\n    it(\"entries\", async function() {\n      // Check output of reading the resources and rules of an empty document.\n      assertResourcesAndRules(new ACLRulesReader(docData), [\n        DEFAULT_UNUSED_RESOURCE_AND_RULE,\n      ]);\n\n      // Check output of reading the resources and rules of an empty document, with share rules.\n      assertResourcesAndRules(new ACLRulesReader(docData, { addShareRules: true }), [\n        {\n          resource: { id: -1, tableId: \"Films\", colIds: \"*\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is not None\",\n              permissionsText: \"-CRUDS\",\n            },\n          ],\n        },\n        {\n          resource: { id: -2, tableId: \"Friends\", colIds: \"*\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is not None\",\n              permissionsText: \"-CRUDS\",\n            },\n          ],\n        },\n        {\n          resource: { id: -3, tableId: \"Performances\", colIds: \"*\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is not None\",\n              permissionsText: \"-CRUDS\",\n            },\n          ],\n        },\n        {\n          resource: { id: -4, tableId: \"*\", colIds: \"*\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is not None\",\n              permissionsText: \"-S\",\n            },\n          ],\n        },\n        {\n          resource: { id: 1, tableId: \"\", colIds: \"\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is None and (True)\",\n              permissionsText: \"\",\n            },\n          ],\n        },\n      ]);\n\n      // Add some default, table, and column rules.\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"UpdateRecord\", \"_grist_Views_section\", 7,\n          { shareOptions: '{\"publish\": true, \"form\": true}' }],\n        [\"UpdateRecord\", \"_grist_Pages\", 2, { shareRef: 1 }],\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Films\", colIds: \"Title,Poster,PosterDup\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Films\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"*\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.access != OWNER\", permissionsText: \"-R\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"True\", permissionsText: \"all\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -3, aclFormula: \"True\", permissionsText: \"all\",\n        }],\n      ]);\n\n      // Re-check output without share rules.\n      assertResourcesAndRules(new ACLRulesReader(docData), [\n        {\n          resource: { id: 2, tableId: \"Films\", colIds: \"Title,Poster,PosterDup\" },\n          rules: [\n            {\n              aclFormula: \"user.access != OWNER\",\n              permissionsText: \"-R\",\n            },\n          ],\n        },\n        {\n          resource: { id: 3, tableId: \"Films\", colIds: \"*\" },\n          rules: [\n            {\n              aclFormula: \"True\",\n              permissionsText: \"all\",\n            },\n          ],\n        },\n        {\n          resource: { id: 4, tableId: \"*\", colIds: \"*\" },\n          rules: [\n            {\n              aclFormula: \"True\",\n              permissionsText: \"all\",\n            },\n          ],\n        },\n        DEFAULT_UNUSED_RESOURCE_AND_RULE,\n      ]);\n\n      // Re-check output with share rules.\n      assertResourcesAndRules(new ACLRulesReader(docData, { addShareRules: true }), [\n        {\n          resource: { id: -1, tableId: \"Friends\", colIds: \"*\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef == 1\",\n              permissionsText: \"+C\",\n            },\n            {\n              aclFormula: \"user.ShareRef == 1 and rec.id == 0\",\n              permissionsText: \"+R\",\n            },\n            {\n              aclFormula: \"user.ShareRef is not None\",\n              permissionsText: \"-CRUDS\",\n            },\n          ],\n        },\n        // Resource -2, -3, and -4, were split from resource 2.\n        {\n          resource: { id: -2, tableId: \"Films\", colIds: \"Title\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef == 1\",\n              permissionsText: \"+R\",\n            },\n            {\n              aclFormula: \"user.ShareRef is None and (user.access != OWNER)\",\n              permissionsText: \"-R\",\n            },\n          ],\n        },\n        {\n          resource: { id: 3, tableId: \"Films\", colIds: \"*\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is not None\",\n              permissionsText: \"-CRUDS\",\n            },\n            {\n              aclFormula: \"user.ShareRef is None and (True)\",\n              permissionsText: \"all\",\n            },\n          ],\n        },\n        {\n          resource: { id: -5, tableId: \"Performances\", colIds: \"*\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is not None\",\n              permissionsText: \"-CRUDS\",\n            },\n          ],\n        },\n        {\n          resource: { id: 4, tableId: \"*\", colIds: \"*\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is not None\",\n              permissionsText: \"-S\",\n            },\n            {\n              aclFormula: \"user.ShareRef is None and (True)\",\n              permissionsText: \"all\",\n            },\n          ],\n        },\n        // Resource -3 and -4 were split from resource 2.\n        {\n          resource: { id: -3, tableId: \"Films\", colIds: \"Poster\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is None and (user.access != OWNER)\",\n              permissionsText: \"-R\",\n            },\n          ],\n        },\n        {\n          resource: { id: -4, tableId: \"Films\", colIds: \"PosterDup\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is None and (user.access != OWNER)\",\n              permissionsText: \"-R\",\n            },\n          ],\n        },\n        {\n          resource: { id: 1, tableId: \"\", colIds: \"\" },\n          rules: [\n            {\n              aclFormula: \"user.ShareRef is None and (True)\",\n              permissionsText: \"\",\n            },\n          ],\n        },\n      ]);\n    });\n\n    it(\"getResourceById\", async function() {\n      // Check output of valid resource ids.\n      assert.deepEqual(\n        new ACLRulesReader(docData).getResourceById(1),\n        { id: 1, tableId: \"\", colIds: \"\" },\n      );\n      assert.deepEqual(\n        new ACLRulesReader(docData).getResourceById(2),\n        { id: 2, tableId: \"Films\", colIds: \"Title,Poster,PosterDup\" },\n      );\n      assert.deepEqual(\n        new ACLRulesReader(docData).getResourceById(3),\n        { id: 3, tableId: \"Films\", colIds: \"*\" },\n      );\n      assert.deepEqual(\n        new ACLRulesReader(docData).getResourceById(4),\n        { id: 4, tableId: \"*\", colIds: \"*\" },\n      );\n\n      // Check output of non-existent resource ids.\n      assert.isUndefined(new ACLRulesReader(docData).getResourceById(5));\n      assert.isUndefined(new ACLRulesReader(docData).getResourceById(0));\n      assert.isUndefined(new ACLRulesReader(docData).getResourceById(-1));\n\n      // Check output of valid resource ids (with share rules).\n      assert.deepEqual(\n        new ACLRulesReader(docData, { addShareRules: true }).getResourceById(1),\n        { id: 1, tableId: \"\", colIds: \"\" },\n      );\n      assert.isUndefined(new ACLRulesReader(docData, { addShareRules: true }).getResourceById(2));\n      assert.deepEqual(\n        new ACLRulesReader(docData, { addShareRules: true }).getResourceById(3),\n        { id: 3, tableId: \"Films\", colIds: \"*\" },\n      );\n      assert.deepEqual(\n        new ACLRulesReader(docData, { addShareRules: true }).getResourceById(4),\n        { id: 4, tableId: \"*\", colIds: \"*\" },\n      );\n      assert.deepEqual(\n        new ACLRulesReader(docData, { addShareRules: true }).getResourceById(-1),\n        { id: -1, tableId: \"Friends\", colIds: \"*\" },\n      );\n      assert.deepEqual(\n        new ACLRulesReader(docData, { addShareRules: true }).getResourceById(-2),\n        { id: -2, tableId: \"Films\", colIds: \"Title\" },\n      );\n      assert.deepEqual(\n        new ACLRulesReader(docData, { addShareRules: true }).getResourceById(-3),\n        { id: -3, tableId: \"Films\", colIds: \"Poster\" },\n      );\n      assert.deepEqual(\n        new ACLRulesReader(docData, { addShareRules: true }).getResourceById(-4),\n        { id: -4, tableId: \"Films\", colIds: \"PosterDup\" },\n      );\n      assert.deepEqual(\n        new ACLRulesReader(docData, { addShareRules: true }).getResourceById(-5),\n        { id: -5, tableId: \"Performances\", colIds: \"*\" },\n      );\n\n      // Check output of non-existent resource ids (with share rules).\n      assert.isUndefined(new ACLRulesReader(docData, { addShareRules: true }).getResourceById(5));\n      assert.isUndefined(new ACLRulesReader(docData, { addShareRules: true }).getResourceById(0));\n      assert.isUndefined(new ACLRulesReader(docData, { addShareRules: true }).getResourceById(-6));\n    });\n  });\n});\n\ninterface ACLResourceAndRules {\n  resource: MetaRowRecord<\"_grist_ACLResources\"> | undefined;\n  rules: { aclFormula: CellValue, permissionsText: CellValue }[];\n}\n\nfunction assertResourcesAndRules(\n  aclRulesReader: ACLRulesReader,\n  expected: ACLResourceAndRules[],\n) {\n  const actual: ACLResourceAndRules[] = [...aclRulesReader.entries()].map(([resourceId, rules]) => {\n    return {\n      resource: aclRulesReader.getResourceById(resourceId),\n      rules: rules.map(({ aclFormula, permissionsText }) => ({ aclFormula, permissionsText })),\n    };\n  });\n  assert.deepEqual(actual, expected);\n}\n\n/**\n * An unused resource and rule that's automatically included in every Grist document.\n *\n * See comment in `UserActions.InitNewDoc` (from `useractions.py`) for context.\n */\nconst DEFAULT_UNUSED_RESOURCE_AND_RULE: ACLResourceAndRules = {\n  resource: { id: 1, tableId: \"\", colIds: \"\" },\n  rules: [{ aclFormula: \"\", permissionsText: \"\" }],\n};\n"
  },
  {
    "path": "test/server/lib/AccessTokens.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport { AccessTokenResult } from \"app/plugin/GristAPI\";\nimport { Deps as AccessTokensDeps } from \"app/server/lib/AccessTokens\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { GristClient, openClient } from \"test/server/gristClient\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport fetch from \"node-fetch\";\nimport { RequestInit } from \"node-fetch\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"AccessTokens\", function() {\n  this.timeout(10000);\n  testUtils.withoutSandboxing();\n  let home: TestServer;\n  testUtils.setTmpLogLevel(\"error\");\n  let owner: UserAPI;\n  let docId: string;\n  let wsId: number;\n  let cliOwner: GristClient;\n  const sandbox = sinon.createSandbox();\n\n  before(async function() {\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n    const api = await home.createHomeApi(\"chimpy\", \"docs\", true);\n    await api.newOrg({ name: \"testy\", domain: \"testy\" });\n    owner = await home.createHomeApi(\"chimpy\", \"testy\", true);\n    wsId = await owner.newWorkspace({ name: \"ws\" }, \"current\");\n    await owner.updateWorkspacePermissions(wsId, {\n      users: {\n        \"kiwi@getgrist.com\": \"owners\",\n        \"charon@getgrist.com\": \"editors\",\n      },\n    });\n  });\n\n  after(async function() {\n    const api = await home.createHomeApi(\"chimpy\", \"docs\");\n    await api.deleteOrg(\"testy\");\n    await home.stop();\n  });\n\n  afterEach(async function() {\n    if (docId) {\n      for (const cli of [cliOwner]) {\n        try {\n          await cli.send(\"closeDoc\", 0);\n        } catch (e) {\n          // Do not worry if socket is already closed by the other side.\n          if (!String(e).match(/WebSocket is not open/)) {\n            throw e;\n          }\n        }\n        await cli.close();\n      }\n      docId = \"\";\n    }\n    sandbox.restore();\n  });\n\n  async function freshDoc() {\n    docId = await owner.newDoc({ name: \"doc\" }, wsId);\n    const who = await owner.getSessionActive();\n    cliOwner = await openClient(home.server, who.user.email, who.org?.domain || \"docs\");\n    await cliOwner.openDocOnConnect(docId);\n  }\n\n  it(\"honors access tokens\", async function() {\n    await freshDoc();\n\n    // Make tokens more short-lived for testing purposes.\n    sandbox.stub(AccessTokensDeps, \"TOKEN_TTL_MSECS\").value(2000);\n\n    // Check we can make a read only token for a document, and use it to read\n    // but not write, and that it expires.\n    let tokenResult: AccessTokenResult = (await cliOwner.send(\"getAccessToken\", 0, { readOnly: true })).data;\n    assert.equal(tokenResult.ttlMsecs, 2000);\n    let token = tokenResult.token;\n    const baseUrl: string = tokenResult.baseUrl;\n    let result = await fetch(baseUrl + `/tables/Table1/records?auth=${token}`);\n    assert.equal(result.status, 200);\n    assert.sameMembers(Object.keys(await result.json()), [\"records\"]);\n    const postOptions: RequestInit = {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({ records: [{}] }),\n    };\n    result = await fetch(baseUrl + `/tables/Table1/records?auth=${token}`, postOptions);\n    // POST not allowed since read-only.\n    assert.equal(result.status, 403);\n    assert.match((await result.json()).error, /No write access/);\n    await delay(3000);\n    result = await fetch(baseUrl + `/tables/Table1/records?auth=${token}`);\n    assert.equal(result.status, 401);\n    assert.match((await result.json()).error, /Token has expired/);\n\n    // Check we can make a token to write to a document.\n    tokenResult = (await cliOwner.send(\"getAccessToken\", 0, {})).data;\n    token = tokenResult.token;\n    result = await fetch(baseUrl + `/tables/Table1/records?auth=${token}`);\n    assert.equal(result.status, 200);\n    assert.sameMembers(Object.keys(await result.json()), [\"records\"]);\n    result = await fetch(baseUrl + `/tables/Table1/records?auth=${token}`, postOptions);\n    assert.equal(result.status, 200);\n    assert.sameMembers(Object.keys(await result.json()), [\"records\"]);\n\n    // Check that tokens for one document do not work on another.\n    const docId2 = await owner.newDoc({ name: \"doc2\" }, wsId);\n    tokenResult = (await cliOwner.send(\"getAccessToken\", 0, {})).data;\n    token = tokenResult.token;\n    result = await fetch(home.serverUrl + `/api/docs/${docId2}/tables/Table1/records?auth=${token}`);\n    assert.equal(result.status, 401);\n    result = await fetch(home.serverUrl + `/api/docs/${docId}/tables/Table1/records?auth=${token}`);\n    assert.equal(result.status, 200);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/ActionHistory.ts",
    "content": "import { LocalActionBundle } from \"app/common/ActionBundle\";\nimport { ActionGroup, MinimalActionGroup } from \"app/common/ActionGroup\";\nimport { DocState } from \"app/common/DocState\";\nimport { ActionGroupOptions, ActionHistory, ActionHistoryUndoInfo, asActionGroup,\n  asMinimalActionGroup } from \"app/server/lib/ActionHistory\";\nimport { ActionHistoryImpl, computeActionHash } from \"app/server/lib/ActionHistoryImpl\";\nimport { DocStorage } from \"app/server/lib/DocStorage\";\nimport { DocStorageManager } from \"app/server/lib/DocStorageManager\";\nimport { createDocTools } from \"test/server/docTools\";\nimport { assert } from \"test/server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport * as path from \"path\";\n\nimport * as tmp from \"tmp\";\n\n/**\n *\n * A toy in-memory implementation of ActionHistory interface as a reference point.\n *\n */\nclass ToyActionHistory implements ActionHistory {\n  private _storeLocalUnsent: LocalActionBundle[] = [];\n  private _storeLocalSent: LocalActionBundle[] = [];\n  private _storeShared: LocalActionBundle[] = [];\n  private _actionNum: number = 1;\n  private _localActionNum: number = 1;\n  private _actionUndoInfo = new Map<string, ActionHistoryUndoInfo>();\n\n  public async initialize(): Promise<void> {\n    return;\n  }\n\n  public isInitialized(): boolean {\n    return true;\n  }\n\n  public getNextHubActionNum(): number {\n    return this._actionNum;\n  }\n\n  public getNextLocalActionNum(): number {\n    return this._localActionNum;\n  }\n\n  public async skipActionNum(actionNum: number) {\n    this._localActionNum = this._actionNum = actionNum + 1;\n  }\n\n  public haveLocalUnsent(): boolean {\n    return this._storeLocalUnsent.length > 0;\n  }\n\n  public haveLocalSent(): boolean {\n    return this._storeLocalSent.length > 0;\n  }\n\n  public haveLocalActions(): boolean {\n    return this.haveLocalSent() || this.haveLocalUnsent();\n  }\n\n  public async fetchAllLocalUnsent(): Promise<LocalActionBundle[]> {\n    return [...this._storeLocalUnsent];\n  }\n\n  public async fetchAllLocal(): Promise<LocalActionBundle[]> {\n    return this._storeLocalSent.concat(this._storeLocalUnsent);\n  }\n\n  public async clearLocalActions(): Promise<void> {\n    this._storeLocalSent.length = 0;\n    this._storeLocalUnsent.length = 0;\n    this._localActionNum = this._actionNum;\n  }\n\n  public async markAsSent(actions: LocalActionBundle[]): Promise<void> {\n    for (const act of actions) {\n      if (this._storeLocalUnsent.length === 0) {\n        throw new Error(\"markAsSent() called but nothing local and unsent\");\n      }\n      const candidate = this._storeLocalUnsent[0];\n      // act and candidate must be one and the same\n      if (computeActionHash(act) !==\n        computeActionHash(candidate)) {\n        throw new Error(\"markAsSent() got an unexpected action\");\n      }\n      this._storeLocalSent.push(candidate);\n      this._storeLocalUnsent.shift();\n    }\n  }\n\n  public async getActions(actionNums: number[]): Promise<(LocalActionBundle | undefined)[]> {\n    return actionNums.map(n => undefined);\n  }\n\n  public async acceptNextSharedAction(actionHash: string | null): Promise<boolean> {\n    if (this._storeLocalSent.length === 0) {\n      return false;\n    }\n    const candidate = this._storeLocalSent[0];\n    if (actionHash != null) {\n      const candidateActionHash = computeActionHash(candidate);\n      if (candidateActionHash !== actionHash) {\n        return false;\n      }\n    }\n    this._storeLocalSent.shift();\n    this._storeShared.push(candidate);\n    this._noteSharedAction(candidate);\n    return true;\n  }\n\n  public async recordNextLocalUnsent(action: LocalActionBundle): Promise<void> {\n    this._storeLocalUnsent.push(action);\n    this._noteLocalAction(action);\n  }\n\n  public async recordNextShared(action: LocalActionBundle): Promise<void> {\n    this._storeShared.push(action);\n    this._noteSharedAction(action);\n  }\n\n  public async getRecentActions(maxActions?: number): Promise<LocalActionBundle[]> {\n    const actions = [...this._storeShared, ...this._storeLocalSent, ...this._storeLocalUnsent];\n    if (!maxActions) { return actions; }\n    return actions.slice(-maxActions);\n  }\n\n  public async getRecentActionGroups(maxActions: number, options: ActionGroupOptions): Promise<ActionGroup[]> {\n    const actions = await this.getRecentActions(maxActions);\n    return actions.map(a => asActionGroup(this, a, options));\n  }\n\n  public async getRecentMinimalActionGroups(maxActions: number, clientId?: string): Promise<MinimalActionGroup[]> {\n    const actions = await this.getRecentActions(maxActions);\n    return actions.map(a => asMinimalActionGroup(this,\n      { actionHash: a.actionHash!, actionNum: a.actionNum },\n      clientId));\n  }\n\n  public async getRecentStates(maxStates?: number): Promise<DocState[]> {\n    const actions = await this.getRecentActions(maxStates);\n    return actions.reverse().map(action => ({ n: action.actionNum, h: action.actionHash! }));\n  }\n\n  public setActionUndoInfo(actionHash: string, undoInfo: ActionHistoryUndoInfo): void {\n    this._actionUndoInfo.set(actionHash, undoInfo);\n  }\n\n  public getActionUndoInfo(actionHash: string): ActionHistoryUndoInfo | undefined {\n    return this._actionUndoInfo.get(actionHash);\n  }\n\n  public async deleteActions(keepN: number): Promise<void> {\n    throw new Error(\"not implemented\");\n  }\n\n  private _noteSharedAction(action: LocalActionBundle): void {\n    if (action.actionNum >= this._actionNum) {\n      this._actionNum = action.actionNum + 1;\n    }\n    this._noteLocalAction(action);\n  }\n\n  private _noteLocalAction(action: LocalActionBundle): void {\n    if (action.actionNum >= this._localActionNum) {\n      this._localActionNum = action.actionNum + 1;\n    }\n  }\n}\n\n/** create a Grist document for testing, and return an instance of DocStorage */\nasync function getDoc(fname: string) {\n  const manager = new DocStorageManager(\".\", \".\");\n  const storage = new DocStorage(manager, fname);\n  await storage.createFile();\n  return storage;\n}\n\nconst versions: { name: string,\n  createDoc: () => Promise<DocStorage | undefined>,\n  createHistory: (doc: DocStorage) => Promise<ActionHistory> }[] = [\n  {\n    name: \"ToyActionHistory\",\n    createDoc: () => Promise.resolve(undefined),\n    createHistory: async doc => new ToyActionHistory(),\n  },\n  {\n    name: \"ActionHistoryImplOnDisk\",\n    createDoc: () => {\n      const tmpDir = tmp.dirSync({ prefix: \"grist_action_history_test_\", unsafeCleanup: true });\n      const fname = path.resolve(tmpDir.name, \"actionhistory.tmp.sqlite\");\n      return getDoc(fname);\n    },\n    createHistory: async (doc) => {\n      const hist = new ActionHistoryImpl(doc);\n      await hist.wipe();\n      return hist;\n    },\n  },\n];\n\n/** set action.actionHash and action.parentActionHash as appropriate for the given actions */\nfunction branchify(actions: LocalActionBundle[]) {\n  let parentActionHash: string | null = null;\n  for (const action of actions) {\n    action.parentActionHash = parentActionHash;\n    parentActionHash = action.actionHash = computeActionHash(action);\n  }\n}\n\nfunction makeBundle(actionNum: number, desc: string): LocalActionBundle {\n  return {\n    actionNum,\n    envelopes: [],\n    info: [\n      0,\n      {\n        time: 0,\n        user: \"\",\n        inst: \"\",\n        desc,\n        otherId: 0,\n        linkId: 0,\n      },\n    ],\n    stored: [],\n    calc: [],\n    userActions: [],\n    undo: [],\n    parentActionHash: null,\n    actionHash: null,\n  } as LocalActionBundle;\n}\n\nfor (const version of versions) {\n  describe(version.name, function() {\n    // Comment this out to see debug-log output from PluginManager when debugging tests.\n    testUtils.setTmpLogLevel(\"error\");\n\n    let doc: DocStorage | undefined;\n    let history: ActionHistory;\n\n    beforeEach(async () => {\n      doc = await version.createDoc();\n      history = await version.createHistory(doc!);\n      await history.initialize();\n    });\n\n    async function getActions(maxActions?: number): Promise<number[]> {\n      return (await history.getRecentActions(maxActions)).map(bundle => bundle.actionNum);\n    }\n\n    const b1 = makeBundle(1, \"one\");\n    const b2 = makeBundle(2, \"two\");\n    const b3 = makeBundle(3, \"three\");\n\n    it(\"check actionNums increment\", async function() {\n      assert(history.isInitialized());\n      assert.equal(history.getNextHubActionNum(), 1);\n      assert.equal(history.getNextLocalActionNum(), 1);\n      await history.recordNextShared(b1);\n      assert.equal(history.getNextHubActionNum(), 2);\n      assert.equal(history.getNextLocalActionNum(), 2);\n      await history.recordNextLocalUnsent(b2);\n      assert.equal(history.getNextHubActionNum(), 2);\n      assert.equal(history.getNextLocalActionNum(), 3);\n    });\n\n    it(\"check path to acceptance\", async function() {           // [shared | sent | unsent]\n      await history.recordNextShared(b1);                       // [b1     |      |       ]\n      assert.equal(history.getNextHubActionNum(), 2);\n      await history.recordNextLocalUnsent(b2);                  // [b1     |      | b2    ]\n      assert.equal(history.getNextHubActionNum(), 2);\n      assert.equal(history.getNextLocalActionNum(), 3);\n      const lst = await history.fetchAllLocalUnsent();\n      branchify([b1, b2]);\n      assert.deepEqual(lst, [b2]);\n      assert(history.haveLocalActions());\n      await history.markAsSent(lst);                            // [b1     | b2   |       ]\n      assert.lengthOf(await history.fetchAllLocalUnsent(), 0);\n      branchify([b1, b2]);\n      assert.deepEqual(await history.fetchAllLocal(), [b2]);\n      assert(history.haveLocalActions());\n      const actionHash = computeActionHash(b2);\n      assert(await history.acceptNextSharedAction(actionHash)); // [b1 b2  |      |       ]\n      assert.lengthOf(await history.fetchAllLocal(), 0);\n      assert(!history.haveLocalActions());\n      assert.equal(history.getNextHubActionNum(), 3);\n      assert.equal(history.getNextLocalActionNum(), 3);\n    });\n\n    it(\"check reject disordered\", async function() {\n      await history.recordNextLocalUnsent(b1);\n      await history.recordNextLocalUnsent(b2);\n      assert.equal(history.getNextLocalActionNum(), 3);\n      await history.markAsSent(await history.fetchAllLocalUnsent());\n      const actionHash = computeActionHash(b2);\n      assert(!(await history.acceptNextSharedAction(actionHash)));\n      branchify([b1, b2]);\n      assert.deepEqual(await history.fetchAllLocal(), [b1, b2]);\n    });\n\n    it(\"markAsSent checks sanity\", async function() {\n      await assert.isRejected(history.markAsSent([b1]), /nothing local/);\n      await history.recordNextLocalUnsent(b1);\n      await assert.isRejected(history.markAsSent([b2]), /unexpected action/);\n    });\n\n    it(\"cleans local_unsent when local_sent is empty\", async function() {\n      await history.recordNextLocalUnsent(b1);\n      await history.recordNextLocalUnsent(b2);\n      assert.equal(history.getNextLocalActionNum(), 3);\n      assert(history.haveLocalActions());\n      assert.lengthOf(await history.fetchAllLocal(), 2);\n      await history.clearLocalActions();\n      assert(!history.haveLocalActions());\n      assert.lengthOf(await history.fetchAllLocal(), 0);\n      assert.equal(history.getNextHubActionNum(), 1);\n      assert.equal(history.getNextLocalActionNum(), 1);\n    });\n\n    it(\"cleans local_sent when local_unsent is empty\", async function() {\n      await history.recordNextLocalUnsent(b1);\n      await history.recordNextLocalUnsent(b2);\n      await history.markAsSent(await history.fetchAllLocalUnsent());\n      assert(history.haveLocalActions());\n      assert.lengthOf(await history.fetchAllLocal(), 2);\n      await history.clearLocalActions();\n      assert(!history.haveLocalActions());\n      assert.lengthOf(await history.fetchAllLocal(), 0);\n      assert.equal(history.getNextHubActionNum(), 1);\n      assert.equal(history.getNextLocalActionNum(), 1);\n    });\n\n    it(\"cleans local actions and continues correctly\", async function() {\n      await history.recordNextShared(b1);                       // [b1     |      |       ]\n      await history.recordNextLocalUnsent(b2);                  // [b1     |      | b2    ]\n      assert.deepEqual(await getActions(), [1, 2]);\n      await history.clearLocalActions();                        // [b1     |      |       ]\n      assert.deepEqual(await getActions(), [1]);\n      await history.recordNextLocalUnsent(b3);                  // [b1     |      | b3    ]\n      assert.deepEqual(await getActions(), [1, 3]);\n    });\n\n    it(\"handles non-trivial load\", async function() {\n      const target = 500;\n      async function addRecords() {\n        for (let i = 1; i <= target; i++) {\n          await history.recordNextLocalUnsent(makeBundle(i, \"action\"));\n        }\n      }\n      if (doc) {\n        await doc.execTransaction(addRecords);\n      } else {\n        await addRecords();\n      }\n      assert(history.haveLocalActions());\n      assert.lengthOf(await history.fetchAllLocal(), target);\n      await history.markAsSent(await history.fetchAllLocalUnsent());\n      assert.lengthOf(await history.fetchAllLocal(), target);\n      assert(await history.acceptNextSharedAction(null));\n      assert.lengthOf(await history.fetchAllLocal(), target - 1);\n    });\n\n    it(\"tracks ownership\", async function() {\n      await history.recordNextLocalUnsent(b1);\n      await history.recordNextLocalUnsent(b2);\n      const defaultUndoInfo = { linkId: 0, otherId: 0, isUndo: false, rowIdHint: 0 };\n      history.setActionUndoInfo(b1.actionHash!, { ...defaultUndoInfo, clientId: \"me\" });\n      history.setActionUndoInfo(b2.actionHash!, { ...defaultUndoInfo, clientId: \"you\" });\n      assert.equal(history.getActionUndoInfo(b1.actionHash!)?.clientId, \"me\");\n      assert.equal(history.getActionUndoInfo(b2.actionHash!)?.clientId, \"you\");\n    });\n\n    it(\"tracks recent actions\", async function() {\n      assert.deepEqual(await getActions(), []);\n      assert.deepEqual(await getActions(2), []);\n      await history.recordNextShared(b1);\n      assert.deepEqual(await getActions(), [1]);\n      assert.deepEqual(await getActions(2), [1]);\n      await history.recordNextLocalUnsent(b2);\n      assert.deepEqual(await getActions(), [1, 2]);\n      assert.deepEqual(await getActions(2), [1, 2]);\n      await history.recordNextLocalUnsent(b3);\n      assert.deepEqual(await getActions(), [1, 2, 3]);\n      assert.deepEqual(await getActions(2), [2, 3]);\n      await history.markAsSent(await history.fetchAllLocalUnsent());\n      assert.deepEqual(await getActions(), [1, 2, 3]);\n      assert.deepEqual(await getActions(2), [2, 3]);\n    });\n\n    it(\"can force next actionNum value\", async function() {\n      await history.skipActionNum(50);\n      assert.equal(history.getNextHubActionNum(), 51);\n      assert.equal(history.getNextLocalActionNum(), 51);\n      await history.recordNextLocalUnsent(makeBundle(51, \"51\"));\n      assert.equal(history.getNextHubActionNum(), 51);\n      assert.equal(history.getNextLocalActionNum(), 52);\n      await history.clearLocalActions();\n      await history.recordNextShared(makeBundle(51, \"51\"));\n      assert.equal(history.getNextHubActionNum(), 52);\n      assert.equal(history.getNextLocalActionNum(), 52);\n    });\n  });\n}\n\ndescribe(\"ActionHistoryImpl only\", function() {\n  // No sandboxing for quick tests.\n  testUtils.withoutSandboxing();\n\n  // Comment this out to see debug-log output from PluginManager when debugging tests.\n  testUtils.setTmpLogLevel(\"error\");\n\n  const docTools = createDocTools();\n  it(\"can persist actionNum across restarts\", async function() {\n    const doc = await docTools.createDoc(\"test.grist\");\n    const history = new ActionHistoryImpl(doc.docStorage);\n    await history.initialize();\n    await history.skipActionNum(50);\n    assert.equal(history.getNextHubActionNum(), 51);\n    await doc.shutdown();\n    const doc2 = await docTools.loadDoc(\"test.grist\");\n    const history2 = new ActionHistoryImpl(doc2.docStorage);\n    await history2.initialize();\n    assert.equal(history2.getNextHubActionNum(), 51);\n  });\n\n  it(\"can access actions by actionNum\", async function() {\n    async function getServerActionNums(actionNums: number[]): Promise<number[]> {\n      return (await history.getActions(actionNums))\n        .map(act => act ? act.actionNum : 0);\n    }\n    const doc = await docTools.createDoc(\"test.grist\");\n    const history = new ActionHistoryImpl(doc.docStorage);\n    await history.initialize();\n    await history.skipActionNum(50);\n    assert.equal(history.getNextHubActionNum(), 51);\n    assert.deepEqual(await getServerActionNums([50]), [50]);\n    await history.recordNextLocalUnsent(makeBundle(51, \"51\"));\n    await history.recordNextLocalUnsent(makeBundle(52, \"52\"));\n    await history.recordNextLocalUnsent(makeBundle(53, \"53\"));\n    assert.deepEqual(await getServerActionNums([]), []);\n    assert.deepEqual(await getServerActionNums([50]), [50]);\n    assert.deepEqual(await getServerActionNums([49, 50, 51, 52, 53, 54]), [0, 50, 51, 52, 53, 0]);\n    assert.deepEqual(await getServerActionNums([25]), [0]);\n  });\n\n  it(\"can automatically prune long history\", async function() {\n    const doc = await docTools.createDoc(\"test.grist\");\n    const history = new ActionHistoryImpl(doc.docStorage,\n      { maxRows: 2, maxBytes: 40000, graceFactor: 2,\n        checkPeriod: 1 });\n    await history.initialize();\n    await history.recordNextShared(makeBundle(2, \"action\"));\n    assert.lengthOf(await history.getRecentActions(), 2);\n    await history.recordNextShared(makeBundle(3, \"action\"));\n    assert.lengthOf(await history.getRecentActions(), 3);\n    await history.recordNextShared(makeBundle(4, \"action\"));\n    assert.lengthOf(await history.getRecentActions(), 4);\n    await history.recordNextShared(makeBundle(5, \"action\"));\n    assert.lengthOf(await history.getRecentActions(), 2);    // grace factor exceeded; pruned\n    await history.recordNextShared(makeBundle(6, \"action\"));\n    assert.lengthOf(await history.getRecentActions(), 3);\n    await history.recordNextShared(makeBundle(7, \"action\"));\n    assert.lengthOf(await history.getRecentActions(), 4);\n    await history.recordNextShared(makeBundle(8, \"action\"));\n    assert.lengthOf(await history.getRecentActions(), 2);    // grace factor exceeded; pruned\n    await history.recordNextShared(makeBundle(9, \"action\"));\n    assert.lengthOf(await history.getRecentActions(), 3);\n    const acts = await history.getRecentActions();\n    assert.equal(acts.pop()!.actionNum, 9);\n    await doc.shutdown();\n  });\n\n  it(\"can automatically prune bulky history\", async function() {\n    const doc = await docTools.createDoc(\"test.grist\");\n    // Set byte limit sufficiently low to dominate.\n    const history = new ActionHistoryImpl(doc.docStorage, { maxRows: 4, maxBytes: 1000,\n      graceFactor: 1.1, checkPeriod: 1 });\n    await history.initialize();\n    for (let i = 1; i <= 10; i++) {\n      await history.recordNextShared(makeBundle(i, \"action\"));\n    }\n    const acts = await history.getRecentActions();\n    assert.lengthOf(acts, 2);\n    assert.equal(acts.pop()!.actionNum, 10);\n    await doc.shutdown();\n  });\n});\n"
  },
  {
    "path": "test/server/lib/ActionHistoryMemory.ts",
    "content": "import { GristClientSocket } from \"app/client/components/GristClientSocket\";\nimport { UserAPIImpl } from \"app/common/UserAPI\";\nimport log from \"app/server/lib/log\";\nimport { exitPromise } from \"app/server/lib/serverUtils\";\nimport { GristClient } from \"test/server/gristClient\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { ChildProcess, execFileSync, spawn } from \"child_process\";\nimport { tmpdir } from \"os\";\nimport * as path from \"path\";\n\nimport axios from \"axios\";\nimport { delay } from \"bluebird\";\nimport { assert } from \"chai\";\nimport FormData from \"form-data\";\nimport * as fse from \"fs-extra\";\nimport fetch from \"node-fetch\";\n\n/**\n * This suite tests that when we start node with limited memory, and then call openDoc on a doc\n * with multiple large actions, node doesn't crash due to decoding all actions together.\n *\n * This test is pretty limited. We actually need to tune the number and size of the actions in\n * such a way that the actions on their own, and the doc data, are not enough to exceed memory\n * limit, but that ActionLog decoding puts it over the limit.\n */\ndescribe(\"ActionHistoryMemory\", function() {\n  this.timeout(120000);\n  testUtils.setTmpLogLevel(\"info\");\n\n  const MAX_SPACE = 110;     // Memory limit for node to use (in MB).\n  const RANGE = 200_000;    // Our \"big actions\" are formula calculations returning a python\n  // range() of this length\n\n  let serverProcess: ChildProcess;\n  let serverExitPromise: Promise<number | string>;\n  let serverUrl: string;\n  let serverPort: number;\n\n  before(async function() {\n    // Set up a server process that uses the following flag to reduce heap size available to node.\n    // This is not precise: node may get some amount more than requested.\n    // This flag is crucial to this test, or else it's not testing anything interesting.\n    const flags = [`--max-old-space-size=${MAX_SPACE}`];\n\n    const username = process.env.USER || \"nobody\";\n    const tmpDir = path.join(process.env.TESTDIR || tmpdir(), `grist_test_${username}_action_history_memory`);\n    const dataDir = path.join(tmpDir, `data`);\n\n    // Create the tmp dir removing any previous one\n    await fse.remove(tmpDir);\n    await fse.mkdirs(tmpDir);\n    await fse.mkdirs(dataDir);\n    log.warn(`Test logs and data are at: ${tmpDir}/`);\n\n    const nodeLogPath = path.join(tmpDir, \"node.log\");\n    const serverLog = process.env.VERBOSE ? \"inherit\" : await fse.open(nodeLogPath, \"a\");\n\n    const stubCmd = \"_build/stubs/app/server/server\";\n    const isCore = await fse.pathExists(stubCmd + \".js\");\n    const cmd = isCore ? stubCmd : \"_build/core/app/server/devServerMain\";\n\n    const env = {\n      TYPEORM_DATABASE: path.join(tmpDir, \"landing.db\"),\n      TEST_CLEAN_DATABASE: \"true\",\n      GRIST_DATA_DIR: dataDir,\n      GRIST_INST_DIR: tmpDir,\n      HOME_PORT: \"8110\",\n      GRIST_SINGLE_PORT: \"true\",\n      PORT: isCore ? \"8110\" : \"0\",\n      ...process.env,\n    };\n\n    // Start up the server with the special flag.\n    serverProcess = spawn(\"node\", [...flags, cmd], {\n      env,\n      stdio: [\"inherit\", serverLog, serverLog],\n    });\n\n    // Dump output if server dies unexpectedly.\n    serverExitPromise = exitPromise(serverProcess).then((code) => {\n      if (!serverProcess.killed) {\n        log.error(\"Server died unexpectedly, with code\", code);\n        const output = execFileSync(\"tail\", [\"-30\", nodeLogPath]);\n        log.warn(`\\n===== BEGIN SERVER OUTPUT ====\\n${output}\\n===== END SERVER OUTPUT =====`);\n        throw new Error(\"Server exited while waiting for it\");\n      }\n      return code;\n    });\n\n    serverPort = parseInt(env.HOME_PORT, 10);\n    serverUrl = `http://localhost:${serverPort}`;\n\n    // Check if the server is responsive and healthy and serving api requests.\n    // (/status endpoint is insufficient to check that last part).\n    async function isServerReady() {\n      return fetch(`${serverUrl}/api/orgs`, { timeout: 1000 }).then(r => r.ok).catch(() => false);\n    }\n\n    // Wait for server to become responsive, or fail if the server crashes while waiting.\n    async function waitForServer() {\n      for (let i = 0; i < 60; i++) {\n        const ok = await Promise.race([isServerReady(), serverExitPromise]);\n        if (ok) { break; }\n        await delay(1000);\n      }\n      if (!await isServerReady()) {\n        throw new Error(\"server not ready\");\n      }\n    }\n\n    await waitForServer();\n  });\n\n  after(async function() {\n    serverProcess.kill();\n    await serverExitPromise;\n  });\n\n  it(\"should not run out of memory from loading many ActionHistory entries\", async function() {\n    const api = new UserAPIImpl(`${serverUrl}/o/docs`, {\n      headers: { Authorization: \"Bearer api_key_for_chimpy\" },\n      fetch: fetch as any,\n      newFormData: () => new FormData() as any,\n    });\n\n    // Createa a doc.\n    const wsId = (await api.getOrgWorkspaces(\"current\")).find(w => w.name === \"Public\")!.id;\n    const docId = await api.newDoc({ name: \"large-actions\" }, wsId);\n\n    // Add a formula column returning a large value (so the .calc part of the action is large).\n    await api.applyUserActions(docId, [[\"AddRecord\", \"Table1\", null, { A: 0 }]]);\n    await api.applyUserActions(docId, [[\"ModifyColumn\", \"Table1\", \"B\",\n      { formula: `range(int($A), int($A) + ${RANGE})` }]]);\n\n    // Add a bunch of actions that each produce an equally large ActionBundle. Each action isn't\n    // too large on its own, but if openDoc collects full recent actions in memory, node (running\n    // with limited memory) would crash.\n    for (let i = 1; i < 10; i++) {\n      await api.applyUserActions(docId, [[\"UpdateRecord\", \"Table1\", 1, { A: i }]]);\n    }\n\n    // Make the doc public, to simplify auth for websocket below.\n    await api.updateDocPermissions(docId, { users: { \"everyone@getgrist.com\": \"viewers\" } });\n\n    // Connect a websocket. We do this in order to call the websocket openDoc() method: it is the\n    // only method that involves getRecentActions (unlike API calls like fetchTable).\n    const resp = await axios.get(`${serverUrl}/test/session`);\n    const cookie = resp.headers[\"set-cookie\"]![0];\n    const ws = new GristClientSocket(`ws://localhost:${serverPort}/o/docs`, { headers: { Cookie: cookie } });\n    await new Promise((resolve, reject) => {\n      ws.onopen = () => resolve(undefined);\n      ws.onerror = reject;\n    });\n    const cli = new GristClient(ws);\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n\n    // Once we have a connected websocket, call openDoc().\n    const openDocPromise = cli.send(\"openDoc\", docId);\n\n    // On failure, the 'fail' promise would reject first, aborting the test.\n    const fail = new Promise((resolve, reject) => {\n      ws.onerror = reject;\n      ws.onclose = () => reject(new Error(\"socket closed\"));\n    });\n    await Promise.race([openDocPromise, fail]);\n\n    // As long as it succeeds, we are good. If node takes too much memory in handling openDoc, it\n    // would crash and the call above would hang.\n    const openDoc = await openDocPromise;\n    assert.equal(openDoc.error, undefined);\n    assert.match(JSON.stringify(openDoc.data), /Table1/);\n    await cli.close();\n  });\n});\n"
  },
  {
    "path": "test/server/lib/ActionSummary.ts",
    "content": "import {\n  ActionSummaryOptions, concatenateSummaries, rebaseSummary, summarizeAction,\n} from \"app/common/ActionSummarizer\";\nimport { ActionSummary, asTabularDiffs, createEmptyTableDelta, LabelDelta, TableDelta } from \"app/common/ActionSummary\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { createDocTools } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { assert } from \"test/server/testUtils\";\n\nimport { cloneDeep, keyBy } from \"lodash\";\n\n/** get a summary of the last LocalActionBundle applied to a given document */\nasync function summarizeLastAction(doc: ActiveDoc, options?: ActionSummaryOptions) {\n  return summarizeAction((await doc.getRecentActionsDirect(1))[0], options);\n}\n\n/** Make a blank TableDelta object for testng */\nfunction makeTableDelta(name: string): TableDelta {\n  return {\n    updateRows: [],\n    removeRows: [],\n    addRows: [],\n    columnDeltas: {},\n    columnRenames: [],\n  };\n}\n\ndescribe(\"ActionSummary\", function() {\n  this.timeout(4000);\n\n  // Comment this out to see debug-log output when debugging tests.\n  testUtils.setTmpLogLevel(\"error\");\n\n  const docTools = createDocTools();\n\n  it(\"summarizes table-level changes\", async function() {\n    const session = docTools.createFakeSession();\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Ducks\", [{ id: \"species\" }, { id: \"color\" }, { id: \"place\" }]],\n      [\"AddTable\", \"Bricks\", [{ id: \"texture\" }, { id: \"length\" }]],\n    ]);\n    // add two tables, remove a table, rename a table\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Frogs\", [{ id: \"species\" }, { id: \"color\" }, { id: \"place\" }]],\n      [\"AddTable\", \"Moons\", [{ id: \"planet\" }, { id: \"radius\" }]],\n      [\"RemoveTable\", \"Ducks\"],\n      [\"RenameTable\", \"Bricks\", \"Blocks\"],\n    ]);\n    const sum = await summarizeLastAction(doc);\n    assert.sameDeepMembers(sum.tableRenames,\n      [[null, \"Frogs\"],\n        [null, \"Moons\"],\n        [\"Ducks\", null],\n        [\"Bricks\", \"Blocks\"]]);\n    // Last change touched content of Ducks, Frogs, and Moons.  Bricks was renamed but had\n    // no column or row changes.  Ducks was removed, so it is referred to as \"-Ducks\".\n    assert.sameDeepMembers(Object.keys(sum.tableDeltas).filter(name => !(name.startsWith(\"_\"))),\n      [\"-Ducks\", \"Frogs\", \"Moons\"]);\n  });\n\n  it(\"summarizes column-level changes\", async function() {\n    const session = docTools.createFakeSession();\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Ducks\", [{ id: \"species\" }, { id: \"color\" }, { id: \"place\" }]],\n    ]);\n    // add a column, remove a column, rename a column\n    await doc.applyUserActions(session, [\n      [\"AddColumn\", \"Ducks\", \"wings\", {}],\n      [\"RemoveColumn\", \"Ducks\", \"color\"],\n      [\"RenameColumn\", \"Ducks\", \"place\", \"location\"],\n    ]);\n    const sum = await summarizeLastAction(doc);\n    assert.sameDeepMembers(sum.tableDeltas.Ducks.columnRenames,\n      [[\"place\", \"location\"],\n        [null, \"wings\"],\n        [\"color\", null]]);\n  });\n\n  it(\"summarizes row-level changes\", async function() {\n    const session = docTools.createFakeSession();\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Frogs\", [{ id: \"species\" }, { id: \"color\" }, { id: \"place\" }]],\n      [\"AddRecord\", \"Frogs\", null, { species: \"yellers\", color: \"yellow\", place: \"Alaskers\" }],\n      [\"AddRecord\", \"Frogs\", null, { species: \"parrots\", color: \"green\", place: \"Jungletown\" }],\n    ]);\n    // add a row, remove a row, update a row\n    await doc.applyUserActions(session, [\n      [\"UpdateRecord\", \"Frogs\", 1, { place: \"Alaska\" }],\n      [\"AddRecord\", \"Frogs\", null, { species: \"gretons\", color: \"green\", place: \"Northern France\" }],\n      [\"RemoveRecord\", \"Frogs\", 2],\n    ]);\n    const sum = await summarizeLastAction(doc);\n    assert(sum.tableRenames.length === 0);\n    assert.deepEqual(sum, {\n      tableRenames: [],\n      tableDeltas: {\n        Frogs: {\n          columnRenames: [],\n          updateRows: [1],\n          removeRows: [2],\n          addRows: [3],\n          columnDeltas: {\n            manualSort: {\n              2: [[2], null],\n              3: [null, [3]],\n            },\n            species: {\n              2: [[\"parrots\"], null],\n              3: [null, [\"gretons\"]],\n            },\n            color: {\n              2: [[\"green\"], null],\n              3: [null, [\"green\"]],\n            },\n            place: {\n              1: [[\"Alaskers\"], [\"Alaska\"]],\n              2: [[\"Jungletown\"], null],\n              3: [null, [\"Northern France\"]],\n            },\n          },\n        },\n      },\n    });\n  });\n\n  it(\"produces reasonable tabular diffs\", async function() {\n    const session = docTools.createFakeSession();\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Frogs\", [{ id: \"species\" }, { id: \"color\" }, { id: \"place\" }]],\n      [\"AddRecord\", \"Frogs\", null, { species: \"yellers\", color: \"yellow\", place: \"Alaskers\" }],\n      [\"AddRecord\", \"Frogs\", null, { species: \"parrots\", color: \"green\", place: \"Jungletown\" }],\n    ]);\n    // add a row, remove a row, update a row\n    await doc.applyUserActions(session, [\n      [\"UpdateRecord\", \"Frogs\", 1, { place: \"Alaska\" }],\n      [\"AddRecord\", \"Frogs\", null, { species: \"gretons\", color: \"green\", place: \"Northern France\" }],\n      [\"RemoveRecord\", \"Frogs\", 2],\n    ]);\n    const sum = await summarizeLastAction(doc);\n    const tabularDiffs = asTabularDiffs(sum, {});\n    assert.sameDeepMembers(tabularDiffs.Frogs.header,\n      [\"species\", \"color\", \"place\"]);\n    assert.lengthOf(tabularDiffs.Frogs.cells, 3);\n    const rowTypes = tabularDiffs.Frogs.cells.map(row => row.type);\n    assert.sameDeepMembers(rowTypes, [\"+\", \"-\", \"→\"]);\n    const colsList = tabularDiffs.Frogs.header.map((name, idx) => [name, idx] as [string, number]);\n    const cols = new Map<string, number>(colsList);\n    const rows = keyBy(tabularDiffs.Frogs.cells, row => row.type);\n    assert.deepEqual(rows[\"+\"].cellDeltas[cols.get(\"species\")!], [null, [\"gretons\"]]);\n    assert.deepEqual(rows[\"→\"].cellDeltas[cols.get(\"place\")!], [[\"Alaskers\"], [\"Alaska\"]]);\n    assert.deepEqual(rows[\"-\"].cellDeltas[cols.get(\"species\")!], [[\"parrots\"], null]);\n  });\n\n  it(\"produces reasonable tabular diffs of simple bulk actions\", async function() {\n    const session = docTools.createFakeSession();\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Frogs\", [{ id: \"species\" }, { id: \"color\" }, { id: \"place\" }]],\n      [\"AddRecord\", \"Frogs\", null, { species: \"yellers\", color: \"yellow\", place: \"Alaskers\" }],\n      [\"AddRecord\", \"Frogs\", null, { species: \"parrots\", color: \"green\", place: \"Jungletown\" }],\n    ]);\n    const ids = Array.from(Array(100).keys()).map(x => x + 3);\n    // add many rows\n    await doc.applyUserActions(session, [\n      [\"BulkAddRecord\", \"Frogs\", ids,\n        {\n          species: ids.map(x => \"species \" + x),\n          color: ids.map(x => \"color \" + x),\n          place: ids.map(x => \"place \" + x),\n        }],\n    ]);\n    const sum = await summarizeLastAction(doc);\n    const tabularDiffs = asTabularDiffs(sum, {});\n    assert.sameDeepMembers(tabularDiffs.Frogs.header,\n      [\"species\", \"color\", \"place\"]);\n    assert(tabularDiffs.Frogs.cells.length < ids.length);\n    const rowTypes = tabularDiffs.Frogs.cells.map(row => row.type);\n    assert.equal(rowTypes.length - 1, rowTypes.filter(label => label === \"+\").length);\n    assert.equal(1, rowTypes.filter(label => label === \"...\").length);\n  });\n\n  it(\"produces tabular diffs that separate out reused rowIds\", async function() {\n    const sum: ActionSummary = {\n      tableRenames: [],\n      tableDeltas: {\n        Duck: {\n          addRows: [1],\n          removeRows: [1],\n          updateRows: [],\n          columnRenames: [],\n          columnDeltas: {\n            color: {\n              1: [[\"yellow\"], [\"red\"]],\n            },\n          },\n        },\n      },\n    };\n    const tabularDiffs = asTabularDiffs(sum, {});\n    assert.lengthOf(tabularDiffs.Duck.cells, 2);\n    assert.sameDeepMembers(tabularDiffs.Duck.cells,\n      [{ type: \"-\", rowId: 1, cellDeltas: [[[\"yellow\"], null]] },\n        { type: \"+\", rowId: 1, cellDeltas: [[null, [\"red\"]]] }]);\n  });\n\n  it(\"summarizes ReplaceTableData actions\", async function() {\n    const session = docTools.createFakeSession();\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Frogs\", [{ id: \"species\" }, { id: \"color\" }, { id: \"place\" }]],\n      [\"AddRecord\", \"Frogs\", null, { species: \"yellers\", color: \"yellow\", place: \"Alaskers\" }],\n      [\"AddRecord\", \"Frogs\", null, { species: \"parrots\", color: \"green\", place: \"Jungletown\" }],\n    ]);\n    await doc.applyUserActions(session, [\n      [\"ReplaceTableData\", \"Frogs\", [1],\n        { species: [\"bouncers\"], color: [\"blue\"], place: [\"Bouncy Castle\"] }],\n    ]);\n    const sum = await summarizeLastAction(doc);\n    assert.deepEqual(sum, {\n      tableRenames: [],\n      tableDeltas: {\n        Frogs: {\n          columnRenames: [],\n          updateRows: [],\n          removeRows: [1, 2],\n          addRows: [1],\n          columnDeltas: {\n            manualSort: {\n              1: [[1], [1]],\n              2: [[2], null],\n            },\n            species: {\n              1: [[\"yellers\"], [\"bouncers\"]],\n              2: [[\"parrots\"], null],\n            },\n            color: {\n              1: [[\"yellow\"], [\"blue\"]],\n              2: [[\"green\"], null],\n            },\n            place: {\n              1: [[\"Alaskers\"], [\"Bouncy Castle\"]],\n              2: [[\"Jungletown\"], null],\n            },\n          },\n        },\n      },\n    });\n  });\n\n  it(\"summarizes changes in sample documents\", async function() {\n    // The history of sample documents was crudely migrated from an older form,\n    // so we check that diffs are generated for it.\n    const doc = await docTools.loadFixtureDoc(\"Favorite_Films.grist\");\n    const session = docTools.createFakeSession();\n    const { actions } = await doc.getRecentActions(session, true);\n    assert(Object.keys(actions[0].actionSummary.tableDeltas).length > 0, \"some diff present\");\n\n    // Pick out a change where Captain America is replaced with Steve Rogers.\n    // Identifying this requires collating the action and undo information.\n    const history = doc.getActionHistory();\n    const [firstAction] = await history.getActions([118]);\n    const summary = summarizeAction(firstAction!);\n    assert.deepEqual(summary.tableDeltas.Performances.columnDeltas.Character[6],\n      [[\"Captain America\"], [\"Steve Rogers\"]]);\n  });\n\n  it(\"includes adequate information about table deletions\", async function() {\n    const session = docTools.createFakeSession();\n    const doc: ActiveDoc = await docTools.createDoc(\":memory:\");\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Frogs\", [{ id: \"species\" }, { id: \"color\" }, { id: \"place\" }]],\n      [\"AddRecord\", \"Frogs\", null, { species: \"yellers\", color: \"yellow\", place: \"Alaskers\" }],\n      [\"AddRecord\", \"Frogs\", null, { species: \"parrots\", color: \"green\", place: \"Jungletown\" }],\n    ]);\n    // add a row, remove a row, update a row\n    await doc.applyUserActions(session, [\n      [\"RemoveTable\", \"Frogs\"],\n    ]);\n    const sum = await summarizeLastAction(doc);\n    assert.include(Object.keys(sum.tableDeltas), \"-Frogs\");\n    assert.notInclude(Object.keys(sum.tableDeltas), \"Frogs\");\n    const columns = sum.tableDeltas[\"-Frogs\"].columnDeltas;\n    assert.sameDeepMembers(Object.keys(columns), [\"-manualSort\", \"-species\", \"-color\", \"-place\"]);\n    assert.deepEqual(columns[\"-color\"][1], [[\"yellow\"], null]);\n  });\n\n  it(\"can compose table renames\", async function() {\n    const summary1: ActionSummary = {\n      tableRenames: [[null, \"Frogs\"],        // created in summary1\n        [\"Spaces\", \"Spices\"],   // renamed in s1\n        [\"Dinosaurs\", null],    // removed in s1\n        [\"Fish\", \"Sharks\"],     // renamed in both\n        [null, \"Transients\"],   // created in s1, removed in s2\n        [\"Doppelganger\", null]], // removed in s1, same name created in s2\n      tableDeltas: {\n        \"Frogs\": makeTableDelta(\"Frogs\"),\n        \"Spices\": makeTableDelta(\"Spices\"),\n        \"Sharks\": makeTableDelta(\"Sharks\"),\n        \"Transients\": makeTableDelta(\"Transients\"),\n        \"-Dinosaurs\": makeTableDelta(\"-Dinosaurs\"),\n        \"-Doppelganger\": makeTableDelta(\"-Doppelganger\"),\n        \"Koalas\": makeTableDelta(\"Koalas\"),\n      },\n    };\n    const summary2: ActionSummary = {\n      tableRenames: [[null, \"Ducks\"],        // created in s2\n        [\"Colours\", \"Colors\"],  // renamed in s2\n        [\"Trilobytes\", null],   // removed in s2\n        [\"Sharks\", \"GreatWhites\"],  // renamed in both\n        [\"Transients\", null],   // created in s1, removed in s2\n        [null, \"Doppelganger\"], // removed in s1, same name created in s2\n        [\"Koalas\", \"Pajamas\"]],  // mentioned in s1, renamed here\n      tableDeltas: {\n        \"Ducks\": makeTableDelta(\"Ducks\"),\n        \"Colors\": makeTableDelta(\"Colors\"),\n        \"GreatWhites\": makeTableDelta(\"GreatWhites\"),\n        \"Doppelganger\": makeTableDelta(\"Doppelganger\"),\n        \"-Trilobytes\": makeTableDelta(\"-Trilobytes\"),\n        \"-Transients\": makeTableDelta(\"-Transients\"),\n      },\n    };\n    const summary3: ActionSummary = {\n      tableRenames: [[null, \"Doppelganger\"],\n        [null, \"Ducks\"],\n        [null, \"Frogs\"],\n        [\"Colours\", \"Colors\"],\n        [\"Dinosaurs\", null],\n        [\"Doppelganger\", null],\n        [\"Fish\", \"GreatWhites\"],\n        [\"Koalas\", \"Pajamas\"],\n        [\"Spaces\", \"Spices\"],\n        [\"Trilobytes\", null]],\n      tableDeltas: {\n        \"Frogs\": makeTableDelta(\"Frogs\"),\n        \"Ducks\": makeTableDelta(\"Ducks\"),\n        \"Colors\": makeTableDelta(\"Colors\"),\n        \"Spices\": makeTableDelta(\"Spices\"),\n        \"GreatWhites\": makeTableDelta(\"GreatWhites\"),\n        \"Doppelganger\": makeTableDelta(\"Doppelganger\"),\n        \"-Dinosaurs\": makeTableDelta(\"-Dinosaurs\"),\n        \"-Doppelganger\": makeTableDelta(\"-Doppelganger\"),\n        \"-Trilobytes\": makeTableDelta(\"-Trilobytes\"),\n        \"Pajamas\": makeTableDelta(\"Pajamas\"),\n      },\n    };\n    const result = concatenateSummariesCleanly([summary1, summary2]);\n    assert.deepEqual(result, summary3);\n  });\n\n  it(\"can compose column renames\", async function() {\n    const summary1: ActionSummary = {\n      tableRenames: [[\"Fish\", \"Sharks\"]],\n      tableDeltas: {\n        Sharks: {\n          updateRows: [],\n          removeRows: [],\n          addRows: [],\n          columnDeltas: {},\n          columnRenames: [[\"age\", \"years\"],\n            [null, \"color\"],\n            [\"depth\", null],\n            [null, \"transient\"]],\n        },\n      },\n    };\n    const summary2: ActionSummary = {\n      tableRenames: [[\"Sharks\", \"GreatWhites\"]],\n      tableDeltas: {\n        GreatWhites: {\n          updateRows: [],\n          removeRows: [],\n          addRows: [],\n          columnDeltas: {},\n          columnRenames: [[\"years\", \"minutes\"],\n            [null, \"weight\"],\n            [\"anger\", null],\n            [\"transient\", null]],\n        },\n      },\n    };\n    const summary3: ActionSummary = {\n      tableRenames: [[\"Fish\", \"GreatWhites\"]],\n      tableDeltas: {\n        GreatWhites: {\n          updateRows: [],\n          removeRows: [],\n          addRows: [],\n          columnDeltas: {},\n          columnRenames: [[null, \"color\"],\n            [null, \"weight\"],\n            [\"age\", \"minutes\"],\n            [\"anger\", null],\n            [\"depth\", null]],\n        },\n      },\n    };\n    const result = concatenateSummariesCleanly([summary1, summary2]);\n    assert.deepEqual(result, summary3);\n  });\n\n  it(\"can compose cell changes\", async function() {\n    const summary1: ActionSummary = {\n      tableRenames: [[\"Fish\", \"Sharks\"]],\n      tableDeltas: {\n        Sharks: {\n          updateRows: [1],\n          removeRows: [10],\n          addRows: [11, 12],\n          columnDeltas: {\n            \"years\": {\n              1: [[\"11\"], [\"111\"]],\n              11: [null, [\"15\"]],\n              12: [null, [\"99\"]],\n            },\n            \"-color\": {\n              1: [[\"gray\"], null],\n              11: [[\"gray\"], null],\n              12: [[\"gray\"], null],\n            },\n          },\n          columnRenames: [[\"age\", \"years\"], [\"color\", null]],\n        },\n      },\n    };\n    const summary2: ActionSummary = {\n      tableRenames: [[\"Sharks\", \"GreatWhites\"]],\n      tableDeltas: {\n        GreatWhites: {\n          updateRows: [2, 11],\n          removeRows: [9, 12],\n          addRows: [],\n          columnDeltas: {\n            minutes: {\n              2: [[\"22\"], [\"222\"]],\n              9: [[\"99\"], null],\n              11: [[\"15\"], [\"6000\"]],\n              12: [[\"99\"], null],\n            },\n          },\n          columnRenames: [[\"years\", \"minutes\"]],\n        },\n      },\n    };\n    const summary3: ActionSummary = {\n      tableRenames: [[\"Fish\", \"GreatWhites\"]],\n      tableDeltas: {\n        GreatWhites: {\n          updateRows: [1, 2],\n          removeRows: [9, 10],\n          addRows: [11],\n          columnDeltas: {\n            \"minutes\": {\n              1: [[\"11\"], [\"111\"]],\n              2: [[\"22\"], [\"222\"]],\n              9: [[\"99\"], null],\n              11: [null, [\"6000\"]],\n            },\n            \"-color\": {\n              1: [[\"gray\"], null],\n              11: [[\"gray\"], null],\n            },\n          },\n          columnRenames: [[\"age\", \"minutes\"], [\"color\", null]],\n        },\n      },\n    };\n    const result = concatenateSummariesCleanly([summary1, summary2]);\n    assert.deepEqual(result, summary3);\n  });\n\n  it(\"can work through full history of a test file\", async function() {\n    // At the time of writing, this fixture has 216 rows in its ActionHistory.\n    const doc = await docTools.loadFixtureDoc(\"Favorite_Films.grist\");\n    const history = doc.getActionHistory();\n    const actions = await history.getRecentActions();\n    const sums = actions.map(act => summarizeAction(act));\n    const renames = sums.map(s => s.tableRenames).filter(rn => rn.length > 0);\n    // Check the sequence of table renames recovered.\n    assert.deepEqual(renames,\n      [[[null, \"Table1\"]],\n        [[\"Table1\", \"Films\"]],\n        [[null, \"Table\"]],\n        [[\"Table\", \"Actors\"]],\n        [[null, \"Table\"]],\n        [[\"Table\", \"Friends\"]],\n        [[\"Actors\", \"Performances\"]],\n        [[\"Films\", \"Films_\"]],\n        [[\"Films_\", \"Films\"]],\n        [[\"Friends\", \"Friends_\"]],\n        [[\"Friends_\", \"Friends\"]],\n        [[\"Performances\", \"Performances2\"]],\n        [[\"Performances2\", \"Performances\"]]]);\n    const sum = concatenateSummariesCleanly(sums);\n    // at the end of history, we have three tables\n    assert.deepEqual(sum.tableRenames,\n      [[null, \"Films\"],\n        [null, \"Friends\"],\n        [null, \"Performances\"]]);\n    // all columns should be created, since nothing existed beforehand\n    assert.deepEqual(sum.tableDeltas.Films.columnRenames,\n      [[null, \"Budget_millions\"],\n        [null, \"Release_Date\"],\n        [null, \"Title\"]]);\n  });\n\n  it(\"summarizes partially uncached changes consistently\", async function() {\n    const summary1: ActionSummary = {\n      tableRenames: [[\"Fish\", \"Sharks\"]],\n      tableDeltas: {\n        Sharks: {\n          updateRows: [1, 13, 14, 15, 16],\n          removeRows: [10],\n          addRows: [11, 12],\n          columnDeltas: {\n            \"years\": {\n              1: [[\"11\"], [\"111\"]],\n              10: [[\"10\"], null],\n              11: [null, [\"15\"]],\n              // rows 12 + 13 + 14 happen not to be cached.\n              15: [[\"15\"], [\"115\"]],\n              16: [[\"16\"], [\"166\"]],\n            },\n            \"-color\": {\n              1: [[\"gray\"], null],\n              10: [[\"yellow\"], null],\n              11: [[\"gray\"], null],\n              // rows 12 + 13 + 14 happen not to be cached.\n              15: [[\"white\"], null],\n              16: [[\"black\"], null],\n            },\n          },\n          columnRenames: [[\"age\", \"years\"], [\"color\", null]],\n        },\n      },\n    };\n    const summary2: ActionSummary = {\n      tableRenames: [[\"Sharks\", \"GreatWhites\"]],\n      tableDeltas: {\n        GreatWhites: {\n          updateRows: [2, 11, 12, 14, 15],\n          removeRows: [9, 16],\n          addRows: [],\n          columnDeltas: {\n            minutes: {\n              2: [[\"22\"], [\"222\"]],\n              9: [[\"99\"], null],\n              11: [[\"15\"], [\"6000\"]],\n              12: [[\"99\"], [\"98\"]],\n              14: [[\"14\"], [\"55\"]],\n              // row 15 happens not to be cached.\n              // row 16 happens not to be cached.\n            },\n          },\n          columnRenames: [[\"years\", \"minutes\"]],\n        },\n      },\n    };\n    const summary3: ActionSummary = {\n      tableRenames: [[\"Fish\", \"GreatWhites\"]],\n      tableDeltas: {\n        GreatWhites: {\n          updateRows: [1, 2, 13, 14, 15],\n          removeRows: [9, 10, 16],\n          addRows: [11, 12],\n          columnDeltas: {\n            \"minutes\": {\n              1: [[\"11\"], [\"111\"]],\n              2: [[\"22\"], [\"222\"]],\n              9: [[\"99\"], null],\n              10: [[\"10\"], null],\n              11: [null, [\"6000\"]],\n              12: [null, [\"98\"]],\n              14: [\"?\", [\"55\"]],\n              15: [[\"15\"], \"?\"],\n              16: [[\"16\"], null],\n            },\n            \"-color\": {\n              1: [[\"gray\"], null],\n              10: [[\"yellow\"], null],\n              11: [[\"gray\"], null],\n              15: [[\"white\"], null],\n              16: [[\"black\"], null],\n            },\n          },\n          columnRenames: [[\"age\", \"minutes\"], [\"color\", null]],\n        },\n      },\n    };\n    const result = concatenateSummariesCleanly([summary1, summary2]);\n    assert.deepEqual(result, summary3);\n  });\n\n  it(\"recognizes bulk removal\", async function() {\n    const session = docTools.createFakeSession();\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Frogs\", [{ id: \"species\" }, { id: \"color\" }, { id: \"place\" }]],\n      [\"AddRecord\", \"Frogs\", null, { species: \"yellers\", color: \"yellow\", place: \"Alaskers\" }],\n      [\"AddRecord\", \"Frogs\", null, { species: \"parrots\", color: \"green\", place: \"Jungletown\" }],\n    ]);\n    await doc.applyUserActions(session, [\n      [\"BulkRemoveRecord\", \"Frogs\", [1, 2]],\n    ]);\n    const sum = await summarizeLastAction(doc);\n    assert.deepEqual(sum.tableDeltas.Frogs.removeRows, [1, 2]);\n    assert.deepEqual(sum.tableDeltas.Frogs.columnDeltas.species, {\n      1: [[\"yellers\"], null],\n      2: [[\"parrots\"], null],\n    });\n  });\n\n  it(\"can preserve all rows or specific columns entirely if requested\", async function() {\n    // Make a document, and then as the last action add many rows.\n    const session = docTools.createFakeSession();\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Frogs\", [{ id: \"species\" }, { id: \"color\" }, { id: \"place\" }]],\n      [\"AddRecord\", \"Frogs\", null, { species: \"yellers\", color: \"yellow\", place: \"Alaskers\" }],\n      [\"AddRecord\", \"Frogs\", null, { species: \"parrots\", color: \"green\", place: \"Jungletown\" }],\n    ]);\n    const ids = [3, 4, 5, 6, 7, 8];\n    await doc.applyUserActions(session, [\n      [\"BulkAddRecord\", \"Frogs\", ids,\n        {\n          species: ids.map(x => \"species \" + x),\n          color: ids.map(x => \"color \" + x),\n          place: ids.map(x => \"place \" + x),\n        }],\n    ]);\n\n    // Request a summarization with no row limit.\n    const sum = await summarizeLastAction(doc, { maximumInlineRows: Infinity });\n\n    // Check result is as expected, with no rows omitted.\n    assert.deepEqual(sum, {\n      tableRenames: [],\n      tableDeltas: {\n        Frogs: {\n          updateRows: [],\n          removeRows: [],\n          addRows: [3, 4, 5, 6, 7, 8],\n          columnDeltas: {\n            manualSort: {\n              3: [null, [3]],\n              4: [null, [4]],\n              5: [null, [5]],\n              6: [null, [6]],\n              7: [null, [7]],\n              8: [null, [8]],\n            },\n            species: {\n              3: [null, [\"species 3\"]],\n              4: [null, [\"species 4\"]],\n              5: [null, [\"species 5\"]],\n              6: [null, [\"species 6\"]],\n              7: [null, [\"species 7\"]],\n              8: [null, [\"species 8\"]],\n            },\n            color: {\n              3: [null, [\"color 3\"]],\n              4: [null, [\"color 4\"]],\n              5: [null, [\"color 5\"]],\n              6: [null, [\"color 6\"]],\n              7: [null, [\"color 7\"]],\n              8: [null, [\"color 8\"]],\n            },\n            place: {\n              3: [null, [\"place 3\"]],\n              4: [null, [\"place 4\"]],\n              5: [null, [\"place 5\"]],\n              6: [null, [\"place 6\"]],\n              7: [null, [\"place 7\"]],\n              8: [null, [\"place 8\"]],\n            },\n          },\n          columnRenames: [],\n        },\n      },\n    });\n\n    // Request a summarization with a row limit but full preservation of some columns.\n    const sum2 = await summarizeLastAction(doc, { alwaysPreserveColIds: [\"color\", \"species\"],\n      maximumInlineRows: 4 });\n\n    // Check result is as expected, with full color and species, but other columns curtailed.\n    sum.tableDeltas.Frogs.columnDeltas.manualSort = {\n      3: [null, [3]],\n      4: [null, [4]],\n      5: [null, [5]],\n      8: [null, [8]],\n    };\n    sum.tableDeltas.Frogs.columnDeltas.place = {\n      3: [null, [\"place 3\"]],\n      4: [null, [\"place 4\"]],\n      5: [null, [\"place 5\"]],\n      8: [null, [\"place 8\"]],\n    };\n    assert.deepEqual(sum2, sum);\n  });\n\n  describe(\"rebasing\", async function() {\n    function expand(deltas?: { [key: string]: Partial<TableDelta> }) {\n      const result: { [key: string]: TableDelta } = {};\n      if (!deltas) { return result; }\n      for (const [key, delta] of Object.entries(deltas)) {\n        result[key] = { ...empty, ...delta };\n      }\n      return result;\n    }\n    function assertRebase(options: {\n      trunk?: {\n        renames?: LabelDelta[],\n        deltas?: { [key: string]: Partial<TableDelta> },\n      },\n      fork?: {\n        renames?: LabelDelta[],\n        deltas?: { [key: string]: Partial<TableDelta> },\n      },\n      result?: {\n        renames?: LabelDelta[],\n        deltas?: { [key: string]: Partial<TableDelta> },\n      }\n    }) {\n      const ref: ActionSummary = {\n        tableRenames: options.trunk?.renames ?? [],\n        tableDeltas: expand(options.trunk?.deltas),\n      };\n      const target: ActionSummary = {\n        tableRenames: options.fork?.renames ?? [],\n        tableDeltas: expand(options.fork?.deltas),\n      };\n      const expected: ActionSummary = {\n        tableRenames: options.result?.renames ?? [],\n        tableDeltas: expand(options.result?.deltas),\n      };\n      rebaseSummary(ref, target);\n      assert.deepEqual(target, expected);\n    }\n    const empty = createEmptyTableDelta();\n    const something: TableDelta = {\n      ...createEmptyTableDelta(),\n      columnRenames: [[\"col1\", \"col2\"]],\n    };\n    it(\"leaves target untouched if empty\", async function() {\n      assertRebase({});\n      assertRebase({\n        trunk: { renames: [[\"table1\", \"table2\"]] },\n      });\n      assertRebase({\n        trunk: { renames: [[\"table1\", \"table2\"]],\n          deltas: { table2: empty } },\n      });\n    });\n\n    it(\"renames tables in target as needed\", async function() {\n      assertRebase({\n        trunk: { renames: [[\"table1\", \"table2\"]] },\n        fork: { deltas: { table1: empty, table3: empty } },\n        result: { deltas: { table2: empty, table3: empty } },\n      });\n      assertRebase({\n        trunk: { renames: [[\"table1\", \"table2\"], [\"table2\", \"table1\"]] },\n        fork: { deltas: { table1: empty, table2: something } },\n        result: { deltas: { table1: something, table2: empty } },\n      });\n    });\n\n    it(\"preserves table renames in target\", async function() {\n      assertRebase({\n        trunk: { renames: [[\"table1\", \"table2\"], [\"table2\", \"table1\"]] },\n        fork: {\n          renames: [[\"table2\", \"table3\"]],\n          deltas: { table1: empty, table3: something },\n        },\n        result: {\n          renames: [[\"table1\", \"table3\"]],\n          deltas: { table3: something, table2: empty },\n        },\n      });\n    });\n\n    it(\"respects table deletion in reference\", async function() {\n      assertRebase({\n        trunk: { renames: [[\"table1\", null]] },\n        fork: {\n          renames: [[\"table1\", \"table2\"], [\"table4\", \"table5\"]],\n          deltas: { table2: something, table3: empty },\n        },\n        result: {\n          renames: [[\"table4\", \"table5\"]],\n          deltas: { table3: empty },\n        },\n      });\n      assertRebase({\n        trunk: { renames: [[\"table1\", null]] },\n        fork: {\n          renames: [[\"table1\", null]],\n        },\n        result: {\n          renames: [],\n        },\n      });\n      assertRebase({\n        trunk: { renames: [[\"table1\", null]] },\n        fork: {\n          renames: [[\"table1\", \"table2\"]],\n        },\n        result: {\n          renames: [],\n        },\n      });\n      assertRebase({\n        trunk: { renames: [[\"table1\", null]] },\n        fork: {\n          renames: [[\"table1\", \"table2\"], [null, \"table1\"]],\n        },\n        result: {\n          renames: [[null, \"table1\"]],\n        },\n      });\n    });\n\n    it(\"handles column renames\", async function() {\n      assertRebase({\n        trunk: { deltas: { table1: { columnRenames: [[\"col1\", \"col2\"]] } } },\n      });\n      assertRebase({\n        trunk: { deltas: { table1: { columnRenames: [[\"col1\", \"col2\"]] } } },\n        fork: { renames: [[\"table1\", \"table2\"]] },\n        result: { renames: [[\"table1\", \"table2\"]] },\n      });\n      assertRebase({\n        trunk: { deltas: { table1: { columnRenames: [[\"col1\", \"col2\"]] } } },\n        fork: { deltas: { table1: { columnDeltas: { col1: { 1: [null, null] } } } } },\n        result: { deltas: { table1: { columnDeltas: { col2: { 1: [null, null] } } } } },\n      });\n      assertRebase({\n        trunk: { deltas: { table1: {\n          columnRenames: [[\"col1\", \"col2\"], [\"col2\", \"col1\"], [\"col3\", null]],\n        } } },\n        fork: { deltas: { table1: { columnDeltas: {\n          col1: { 1: [null, null] },\n          col2: { 2: [null, null] },\n          col3: { 3: [null, null] },\n        } } } },\n        result: { deltas: { table1: { columnDeltas: {\n          col1: { 2: [null, null] },\n          col2: { 1: [null, null] },\n        } } } },\n      });\n      assertRebase({\n        trunk: { deltas: { table1: {\n          columnRenames: [[\"col1\", \"col2\"], [\"col2\", \"col1\"], [\"col3\", null]],\n        } } },\n        fork: { deltas: { table1: {\n          columnRenames: [[\"col1\", \"col9\"]],\n          columnDeltas: {\n            col9: { 1: [null, null] },\n            col2: { 2: [null, null] },\n            col3: { 3: [null, null] },\n          } } } },\n        result: { deltas: { table1: {\n          columnRenames: [[\"col2\", \"col9\"]],\n          columnDeltas: {\n            col1: { 2: [null, null] },\n            col9: { 1: [null, null] },\n          } } } },\n      });\n    });\n  });\n});\n\nfunction concatenateSummariesCleanly(args: ActionSummary[]) {\n  const argsCopy = cloneDeep(args);\n  const result = concatenateSummaries(args);\n  for (let i = 0; i < args.length; i++) {\n    assert.deepEqual(args[i], argsCopy[i]);\n  }\n  return result;\n}\n"
  },
  {
    "path": "test/server/lib/ActiveDoc.ts",
    "content": "import { getEnvContent } from \"app/common/ActionBundle\";\nimport { ServerQuery } from \"app/common/ActiveDocAPI\";\nimport { delay } from \"app/common/delay\";\nimport { BulkColValues, CellValue, fromTableDataAction } from \"app/common/DocActions\";\nimport * as gristTypes from \"app/common/gristTypes\";\nimport { TableData } from \"app/common/TableData\";\nimport { CreatableArchiveFormats } from \"app/common/UserAPI\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { ActiveDoc, Deps } from \"app/server/lib/ActiveDoc\";\nimport { getDocPoolIdFromDocInfo } from \"app/server/lib/AttachmentStore\";\nimport {\n  AttachmentStoreProvider,\n  IAttachmentStoreProvider,\n} from \"app/server/lib/AttachmentStoreProvider\";\nimport { AuthSession } from \"app/server/lib/AuthSession\";\nimport { Client } from \"app/server/lib/Client\";\nimport { DummyAuthorizer } from \"app/server/lib/DocAuthorizer\";\nimport { makeExceptionalDocSession, makeOptDocSession, OptDocSession } from \"app/server/lib/DocSession\";\nimport { guessExt } from \"app/server/lib/guessExt\";\nimport log from \"app/server/lib/log\";\nimport { timeoutReached } from \"app/server/lib/serverUtils\";\nimport { Throttle } from \"app/server/lib/Throttle\";\nimport { createTmpDir as createTmpUploadDir, globalUploadSet } from \"app/server/lib/uploads\";\nimport { MemoryWritableStream } from \"app/server/utils/streams\";\nimport { createDocTools } from \"test/server/docTools\";\nimport { makeTestingFilesystemStoreConfig } from \"test/server/lib/FilesystemAttachmentStore\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport * as child_process from \"child_process\";\nimport * as stream from \"node:stream\";\nimport path, { resolve } from \"path\";\n\nimport { promisify } from \"bluebird\";\nimport { assert } from \"chai\";\nimport decompress from \"decompress\";\nimport * as fse from \"fs-extra\";\nimport * as _ from \"lodash\";\nimport * as sinon from \"sinon\";\nimport * as tmp from \"tmp\";\n\nconst execFileAsync = promisify(child_process.execFile);\n\nconst UNSUPPORTED_FORMULA: CellValue = [GristObjCode.Exception, \"Formula not supported\"];\n\ntmp.setGracefulCleanup();\n\ndescribe(\"ActiveDoc\", async function() {\n  this.timeout(10000);\n\n  // Turn off logging for this test, and restore afterwards.\n  testUtils.setTmpLogLevel(\"warn\");\n\n  const createAttachmentStoreProvider = async () => new AttachmentStoreProvider(\n    [await makeTestingFilesystemStoreConfig(\"filesystem\")],\n    \"TEST-INSTALLATION-UUID\",\n  );\n\n  const docTools = createDocTools({ createAttachmentStoreProvider });\n\n  const fakeSession = makeExceptionalDocSession(\"system\");\n\n  const sandbox = sinon.createSandbox();\n\n  async function fetchValues(activeDoc: ActiveDoc, tableId: any): Promise<BulkColValues> {\n    const { tableData } = await activeDoc.fetchTable(fakeSession, tableId, true);\n    return _.omit(tableData[3], \"manualSort\");\n  }\n\n  this.afterEach(() => {\n    sandbox.restore();\n  });\n\n  const allTypes = {\n    Any: \"Any\",\n    Attachments: \"Attachments\",\n    Blob: \"Blob\",\n    Bool: \"Bool\",\n    Choice: \"Choice\",\n    ChoiceList: \"ChoiceList\",\n    Date: \"Date\",\n    DateTime: \"DateTime\",\n    Int: \"Int\",\n    ManualSortPos: \"ManualSortPos\",\n    Numeric: \"Numeric\",\n    PositionNumber: \"PositionNumber\",\n    Ref: \"Ref:Defaults\",       // Ref columns must specify a valid target\n    RefList: \"RefList:Defaults\",\n    Text: \"Text\",\n  };\n\n  const allValues = [\n    null,\n    true,                     // Bool\n    false,                    // Bool\n    \"choice\",                 // Choice\n    1510012800.000,           // Date\n    1510074073.123,           // DateTime\n    17,                       // Reference, Int\n    123.456,                  // Numeric, PositionNumber, ManualSortPos\n    Number.POSITIVE_INFINITY, // Numeric\n    Number.NaN,               // Numeric\n    Number.MIN_VALUE,         // Numeric\n    0,                        // Int, Numeric\n    -0.0,                     // Numeric\n    Number.MAX_SAFE_INTEGER,  // Int, Numeric\n    Number.MIN_SAFE_INTEGER,  // Int, Numeric\n    [\"L\", 3, 5, 6],           // Attachments, ReferenceList\n    \"Hello!\",                 // Text\n    \"¡Aló!\",                  // Text containing non-ascii unicode\n    \"0\",                      // Text that looks like int\n    \"-1e4\",                   // Text that looks like float\n    \"true\",                   // Text that looks like bool\n    \"\",                       // Text that's empty.\n    [\"L\", [\"O\", { A: 1.0 }], [\"L\", 5, \"s\"]],  // other complex types\n    [\"L\", \"Foo\", \"Bar\"],      // ChoiceList\n    // TODO We are unable YET to support binary data in the sandbox properly because we don't\n    // distinguish in the sandbox between unicode text and binary. Once that's fixed, this should\n    // be made to work.\n    //   Uint8Array.from([0x00, 0x01, 0x02, 0x03]),    // Binary data\n    //   new Uint8Array(Buffer.from(\"¡Aló!\", 'utf8')), // Binary data that's valid utf8\n    //   new Uint8Array(0),                            // Binary data that's empty\n  ];\n\n  // Set the specified table to be onDemand/regular, and reload the document.\n  async function reloadOnDemand(activeDoc: ActiveDoc, tableId: string,\n    onDemand: boolean = true): Promise<ActiveDoc> {\n    // We can use fetchQuery() to look up a tableRef from a tableId.\n    const data = fromTableDataAction(await activeDoc.fetchQuery(fakeSession,\n      { tableId: \"_grist_Tables\", filters: { tableId: [tableId] } }));\n    assert.deepEqual(data.onDemand, [!onDemand]);\n    const tableRef = data.id[0];\n\n    await activeDoc.applyUserActions(fakeSession, [[\"UpdateRecord\", \"_grist_Tables\", tableRef, { onDemand }]]);\n    await activeDoc.shutdown();\n    const activeDoc2 = await docTools.loadDoc(activeDoc.docName);\n\n    // Check that the table is known to be onDemand.\n    const data2 = fromTableDataAction(await activeDoc2.fetchQuery(fakeSession,\n      { tableId: \"_grist_Tables\", filters: { tableId: [tableId] } }));\n    assert.deepEqual(data2.id, [tableRef]);\n    assert.deepEqual(data2.onDemand, [onDemand]);\n\n    // We don't update indexes on load at this time, which is awkward in tests.\n    await activeDoc2.testUpdateIndexes();\n\n    return activeDoc2;\n  }\n\n  describe(\"DocData\", function() {\n    function verifyTableData(t: TableData | undefined, colIdSubset: string[], data: CellValue[][]): void {\n      if (!t) { throw new Error(\"table could not be fetched\"); }\n      const idIndex = colIdSubset.indexOf(\"id\");\n      assert(idIndex !== -1, \"verifyTableData expects 'id' column\");\n      const rowIds: number[] = data.map(row => row[idIndex]) as number[];\n      assert.deepEqual(t.getSortedRowIds(), rowIds);\n      assert.deepEqual(rowIds.map(r => colIdSubset.map(c => t.getValue(r, c))), data);\n    }\n\n    it(\"should maintain up-to-date DocData\", async function() {\n      const docName = \"docdata1\";\n      const activeDoc1 = await docTools.createDoc(docName);\n\n      // ----------------------------------------\n      await activeDoc1.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Hello\", [{ id: \"city\", type: \"Text\" }, { id: \"state\", type: \"Text\" }]],\n        [\"BulkAddRecord\", \"Hello\", [1, 4], {\n          city: [\"New York\", \"Boston\"],\n          state: [\"NY\", \"MA\"],\n        }],\n      ]);\n\n      verifyTableData(activeDoc1.docData!.getTable(\"_grist_Tables\"), [\"id\", \"tableId\"], [\n        [1, \"Hello\"],\n      ]);\n      verifyTableData(activeDoc1.docData!.getTable(\"_grist_Tables_column\"), [\"id\", \"parentId\", \"colId\", \"type\"], [\n        [1, 1, \"manualSort\", \"ManualSortPos\"],\n        [2, 1, \"city\", \"Text\"],\n        [3, 1, \"state\", \"Text\"],\n      ]);\n      verifyTableData(activeDoc1.docData!.getTable(\"_grist_Views_section\"), [\"id\", \"tableRef\"], [\n        [1, 1],\n        [2, 1],\n        [3, 1],\n      ]);\n\n      // ----------------------------------------\n      await activeDoc1.applyUserActions(fakeSession, [\n        [\"RenameColumn\", \"Hello\", \"city\", \"ciudad\"],\n        [\"ModifyColumn\", \"Hello\", \"ciudad\", { type: \"Choice\" }],\n        [\"AddTable\", \"Foo\", [{ id: \"A\" }]],\n      ]);\n      verifyTableData(activeDoc1.docData!.getTable(\"_grist_Tables\"), [\"id\", \"tableId\"], [\n        [1, \"Hello\"],\n        [2, \"Foo\"],\n      ]);\n      verifyTableData(activeDoc1.docData!.getTable(\"_grist_Tables_column\"), [\"id\", \"parentId\", \"colId\", \"type\"], [\n        [1, 1, \"manualSort\", \"ManualSortPos\"],\n        [2, 1, \"ciudad\", \"Choice\"],\n        [3, 1, \"state\", \"Text\"],\n        [4, 2, \"manualSort\", \"ManualSortPos\"],\n        [5, 2, \"A\", \"Text\"],\n      ]);\n      verifyTableData(activeDoc1.docData!.getTable(\"_grist_Views_section\"), [\"id\", \"tableRef\"], [\n        [1, 1],\n        [2, 1],\n        [3, 1],\n        [4, 2],\n        [5, 2],\n        [6, 2],\n      ]);\n      verifyTableData(activeDoc1.docData!.getTable(\"_grist_Views_section_field\"), [\"id\", \"parentId\", \"colRef\"], [\n        [1, 1, 2],\n        [2, 1, 3],\n        [3, 2, 2],\n        [4, 2, 3],\n        [5, 3, 2],\n        [6, 3, 3],\n        [7, 4, 5],\n        [8, 5, 5],\n        [9, 6, 5],\n      ]);\n\n      // ----------------------------------------\n      await activeDoc1.shutdown();\n      const activeDoc2 = await docTools.loadDoc(docName);\n\n      verifyTableData(activeDoc2.docData!.getTable(\"_grist_Tables\"), [\"id\", \"tableId\"], [\n        [1, \"Hello\"],\n        [2, \"Foo\"],\n      ]);\n      verifyTableData(activeDoc2.docData!.getTable(\"_grist_Tables_column\"), [\"id\", \"parentId\", \"colId\", \"type\"], [\n        [1, 1, \"manualSort\", \"ManualSortPos\"],\n        [2, 1, \"ciudad\", \"Choice\"],\n        [3, 1, \"state\", \"Text\"],\n        [4, 2, \"manualSort\", \"ManualSortPos\"],\n        [5, 2, \"A\", \"Text\"],\n      ]);\n      verifyTableData(activeDoc2.docData!.getTable(\"_grist_Views_section\"), [\"id\", \"tableRef\"], [\n        [1, 1],\n        [2, 1],\n        [3, 1],\n        [4, 2],\n        [5, 2],\n        [6, 2],\n      ]);\n      verifyTableData(activeDoc2.docData!.getTable(\"_grist_Views_section_field\"), [\"id\", \"parentId\", \"colRef\"], [\n        [1, 1, 2],\n        [2, 1, 3],\n        [3, 2, 2],\n        [4, 2, 3],\n        [5, 3, 2],\n        [6, 3, 3],\n        [7, 4, 5],\n        [8, 5, 5],\n        [9, 6, 5],\n      ]);\n    });\n  });\n\n  describe(\"useQuerySet\", function() {\n    it(\"should support useQuerySet to fetch a subset of data\", async function() {\n      const docName = \"doc_use_query_set\";\n      const activeDoc1 = await docTools.createDoc(docName);\n      const res = await activeDoc1.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Bar\", [\n          { id: \"fname\", type: \"Text\", isFormula: false },\n          { id: \"lname\", type: \"Text\", isFormula: false },\n          { id: \"age\", type: \"Numeric\", isFormula: false },\n          { id: \"age2\", type: \"Numeric\", isFormula: true, formula: \"$age * 2\" },\n        ]],\n        [\"AddRecord\", \"Bar\", 1, { fname: \"Alice\",  lname: \"Johnson\", age: 28 }],\n        [\"AddRecord\", \"Bar\", 2, { fname: \"Bob\", lname: \"Upton\", age: 28 }],\n      ]);\n      const tableRef = res.retValues[0].id;\n\n      // Ensure that we now have a table with two records.\n      assert.deepEqual((await activeDoc1.fetchTable(fakeSession, \"Bar\")).tableData[2], [1, 2]);\n\n      // Test useQuerySet with this regular (NOT onDemand) table. We expect it to return all\n      // formula columns.\n      await testUseQuery(activeDoc1, false);\n\n      // Now change the table to be onDemand, and reload the data engine.\n      await activeDoc1.applyUserActions(fakeSession, [[\"UpdateRecord\", \"_grist_Tables\", tableRef, { onDemand: true }]]);\n      await activeDoc1.shutdown();\n      const activeDoc2 = await docTools.loadDoc(docName);\n\n      // fetchTable() now returns data too, coming straight from the database.\n      assert.deepEqual((await activeDoc2.fetchTable(fakeSession, \"Bar\")).tableData[2], [1, 2]);\n\n      // Test that useQuerySet still works as before, except for not including formula columns\n      // not supported with SQL.\n      await testUseQuery(activeDoc2, true);\n    });\n\n    // Implements the useQuerySet() test asserts, used unchanged for regular and onDemand tables.\n    async function testUseQuery(activeDoc: ActiveDoc, onDemand: boolean) {\n      // some formulas are not yet supported for on-demand tables.\n      const ifSupported = (...args: any[]) => args.map(v => onDemand ? UNSUPPORTED_FORMULA : v);\n\n      // Simple query matching one record.\n      const res1 = await activeDoc.useQuerySet(fakeSession, { tableId: \"Bar\", filters: { lname: [\"Johnson\"] } });\n      assert.deepEqual(res1.tableData,\n        [\"TableData\", \"Bar\", [1], {\n          fname: [\"Alice\"],  lname: [\"Johnson\"], age: [28], manualSort: [1],\n          age2: ifSupported(56),\n        }]);\n\n      // Simple query matching multiple records.\n      const res2 = await activeDoc.useQuerySet(fakeSession, { tableId: \"Bar\", filters: { age: [28] } });\n      assert.deepEqual(res2.tableData,\n        [\"TableData\", \"Bar\", [1, 2], {\n          fname: [\"Alice\", \"Bob\"],  lname: [\"Johnson\", \"Upton\"], age: [28, 28], manualSort: [1, 2],\n          age2: ifSupported(56, 56),\n        }]);\n\n      // Combination query matching no records.\n      const res3 = await activeDoc.useQuerySet(fakeSession,\n        { tableId: \"Bar\", filters: { age: [200], lname: [\"Johnson\"] } });\n      assert.deepEqual(res3.tableData, [\"TableData\", \"Bar\", [], {\n        fname: [],  lname: [], age: [], manualSort: [],\n        age2: [],\n      }]);\n\n      // Query with no filters should match all records.\n      const res4 = await activeDoc.useQuerySet(fakeSession, { tableId: \"Bar\", filters: {} });\n      assert.deepEqual(res4.tableData,\n        [\"TableData\", \"Bar\", [1, 2], {\n          fname: [\"Alice\", \"Bob\"],  lname: [\"Johnson\", \"Upton\"], age: [28, 28], manualSort: [1, 2],\n          age2: ifSupported(56, 56),\n        }]);\n\n      // Query with multiple values in the filter.\n      const res5 = await activeDoc.useQuerySet(fakeSession,\n        { tableId: \"Bar\", filters: { lname: [\"Johnson\", \"Upton\", 'Hacker\";\\';Bob'], age: [28] } });\n      assert.deepEqual(res5.tableData,\n        [\"TableData\", \"Bar\", [1, 2], {\n          fname: [\"Alice\", \"Bob\"],  lname: [\"Johnson\", \"Upton\"], age: [28, 28], manualSort: [1, 2],\n          age2: ifSupported(56, 56),\n        }]);\n\n      // Query with many values in the filter.\n      const lnames = [\"Johnson\", \"Upton\", 'Hacker\";\\';Bob'];\n      const ages = [28];\n      // add a lot of chaff\n      lnames.push(...[...Array(100000).keys()].map(i => `chaff-${i}`));\n      ages.push(...[...Array(100000).keys()].map(i => 1000 + i));\n      const res6 = await activeDoc.useQuerySet(fakeSession,\n        { tableId: \"Bar\", filters: { lname: lnames, age: ages } });\n      assert.deepEqual(res6.tableData,\n        [\"TableData\", \"Bar\", [1, 2], {\n          fname: [\"Alice\", \"Bob\"],  lname: [\"Johnson\", \"Upton\"], age: [28, 28], manualSort: [1, 2],\n          age2: ifSupported(56, 56),\n        }]);\n\n      // Query with an empty filter.\n      const res7 = await activeDoc.useQuerySet(fakeSession,\n        { tableId: \"Bar\", filters: { lname: [], age: [28] } });\n      assert.deepEqual(res7.tableData, [\"TableData\", \"Bar\", [], {\n        fname: [],  lname: [], age: [], manualSort: [],\n        age2: [],\n      }]);\n    }\n  });\n\n  describe(\"fetchQuery\", function() {\n    this.timeout(10000);\n\n    async function makeDoc(docName: string) {\n      const activeDoc = await docTools.createDoc(docName);\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Theme\", [\n          { id: \"name\", type: \"Text\", isFormula: false },\n          { id: \"volume\", type: \"Numeric\", isFormula: false },\n        ]],\n        [\"AddTable\", \"Animal\", [\n          { id: \"name\", type: \"Text\", isFormula: false },\n          { id: \"habitat\", type: \"Text\", isFormula: false },\n        ]],\n        [\"AddTable\", \"Bar\", [\n          { id: \"fname\", type: \"Text\", isFormula: false },\n          { id: \"lname\", type: \"Text\", isFormula: false },\n          { id: \"age\", type: \"Numeric\", isFormula: false },\n          { id: \"age2\", type: \"Numeric\", isFormula: true, formula: \"$age * 2\" },\n          { id: \"theme\", type: \"Ref:Theme\", isFormula: false },\n          { id: \"nightTheme\", type: \"Ref:Theme\", isFormula: false },\n          { id: \"volume\", type: \"Numeric\", isFormula: true, formula: \"$theme.volume\" },\n          { id: \"lname2\", type: \"Text\", isFormula: true, formula: \"$lname\" },\n          { id: \"nightVolume\", type: \"Numeric\", isFormula: true, formula: \"$nightTheme.volume\" },\n          { id: \"animal\", type: \"Ref:Animal\", isFormula: false },\n          { id: \"habitat\", type: \"Text\", isFormula: true, formula: \"$animal.habitat\" },\n        ]],\n        [\"AddTable\", \"Dupe\", [\n          { id: \"name\", type: \"Text\", isFormula: false },\n          { id: \"theme\", type: \"Ref:Theme\", isFormula: false },\n          { id: \"volume\", type: \"Numeric\", isFormula: true, formula: \"$theme.volume\" },\n        ]],\n        [\"AddRecord\", \"Theme\", 1, { name: \"Space\", volume: 15 }],\n        [\"AddRecord\", \"Theme\", 2, { name: \"Underwater\", volume: 3 }],\n        [\"AddRecord\", \"Animal\", 1, { name: \"Camel\", habitat: \"Desert\" }],\n        [\"AddRecord\", \"Animal\", 2, { name: \"Koala\", habitat: \"Australia\" }],\n        [\"AddRecord\", \"Bar\", 1, { fname: \"Alice\",  lname: \"Johnson\", age: 28, theme: 1 }],\n        [\"AddRecord\", \"Bar\", 2, { fname: \"Bob\", lname: \"Upton\", age: 28, theme: 1, animal: 2 }],\n        [\"AddRecord\", \"Bar\", 3, { fname: \"Bob\", lname: \"C\", age: 0, theme: 2, nightTheme: 1 }],\n        [\"SetDisplayFormula\", \"Bar\", null, 9, \"$theme.name\"],\n        [\"AddRecord\", \"Dupe\", 1, { name: \"Me\", theme: 2 }],\n      ]);\n      return activeDoc;\n    }\n\n    // Run queries on tables, either regular or on-demand.\n    async function commonQueries(activeDoc: ActiveDoc, onDemand: boolean) {\n      // some formulas are not yet supported for on-demand tables.\n      const ifSupported = (...args: any[]) => args.map(v => onDemand ? UNSUPPORTED_FORMULA : v);\n      // values via invalid references are not yet consistent.\n      const noNumeric = onDemand ? null : 0;\n      const noText = onDemand ? null : \"\";\n      // on-demand table can not yet be filtered by the output of a formula.\n      const ageFilter: { [key: string]: any[] } = onDemand ? { age: [28] } : { age2: [56] };\n\n      const query = async (s: OptDocSession, q: ServerQuery) => (await activeDoc.fetchQuery(s, q)).tableData;\n      assert.deepEqual(await query(fakeSession,\n        { tableId: \"Bar\", filters: { fname: [\"Bob\"], lname: [\"Upton\"] } }),\n      [\"TableData\", \"Bar\", [2], {\n        fname: [\"Bob\"], lname: [\"Upton\"], age: [28], age2: ifSupported(56), manualSort: [2],\n        theme: [1], gristHelper_Display: [\"Space\"],\n        nightTheme: [0],\n        volume: [15],\n        lname2: [\"Upton\"],\n        nightVolume: [noNumeric],\n        animal: [2], habitat: [\"Australia\"],\n      }]);\n\n      assert.deepEqual(await query(fakeSession,\n        { tableId: \"Bar\", filters: ageFilter }),\n      [\"TableData\", \"Bar\", [1, 2], {\n        fname: [\"Alice\", \"Bob\"], lname: [\"Johnson\", \"Upton\"], age: [28, 28],\n        age2: ifSupported(56, 56),\n        manualSort: [1, 2],\n        theme: [1, 1], gristHelper_Display: [\"Space\", \"Space\"],\n        nightTheme: [0, 0],\n        volume: [15, 15],\n        lname2: [\"Johnson\", \"Upton\"],\n        nightVolume: [noNumeric, noNumeric],\n        animal: [0, 2], habitat: [noText, \"Australia\"],\n      }]);\n\n      assert.deepEqual(await query(fakeSession,\n        { tableId: \"Bar\", filters: { fname: [\"Bob\"], ...ageFilter } }),\n      [\"TableData\", \"Bar\", [2], {\n        fname: [\"Bob\"], lname: [\"Upton\"], age: [28], age2: ifSupported(56),\n        manualSort: [2],\n        theme: [1], gristHelper_Display: [\"Space\"],\n        nightTheme: [0],\n        volume: [15],\n        lname2: [\"Upton\"],\n        nightVolume: [noNumeric],\n        animal: [2], habitat: [\"Australia\"],\n      }]);\n\n      assert.deepEqual(await query(fakeSession,\n        { tableId: \"Bar\", filters: { fname: [\"Bob\"] } }),\n      [\"TableData\", \"Bar\", [2, 3], {\n        fname: [\"Bob\", \"Bob\"], lname: [\"Upton\", \"C\"], age: [28, 0],\n        age2: ifSupported(56, 0),\n        manualSort: [2, 3],\n        theme: [1, 2], gristHelper_Display: [\"Space\", \"Underwater\"],\n        nightTheme: [0, 1],\n        volume: [15, 3],\n        lname2: [\"Upton\", \"C\"],\n        nightVolume: [noNumeric, 15],\n        animal: [2, 0], habitat: [\"Australia\", noText],\n      }]);\n\n      assert.deepEqual(await query(fakeSession,\n        { tableId: \"Bar\", filters: { fname: [\"Bob\"], age: [0] } }),\n      [\"TableData\", \"Bar\", [3], {\n        fname: [\"Bob\"], lname: [\"C\"], age: [0], age2: ifSupported(0), manualSort: [3],\n        theme: [2], gristHelper_Display: [\"Underwater\"],\n        nightTheme: [1],\n        volume: [3],\n        lname2: [\"C\"],\n        nightVolume: [15],\n        animal: [0], habitat: [noText],\n      }]);\n\n      await assert.isRejected(query(fakeSession, { tableId: \"Foo\", filters: {} }),\n        /Sandbox.*Foo/);\n    }\n\n    // Get a list of indexes on user tables of form [Table1.col1, Table1.col2, ...]\n    async function getIndexes(activeDoc: ActiveDoc) {\n      const indexes = await activeDoc.docStorage.testGetIndexes();\n      return indexes.map(idx => `${idx.tableId}.${idx.colId}`);\n    }\n\n    it(\"should support querying for regular tables\", async function() {\n      const docName = \"doc_fetch_query1\";\n      const activeDoc = await makeDoc(docName);\n      assert.lengthOf(await activeDoc.docStorage.testGetIndexes(), 0);\n      await commonQueries(activeDoc, false);\n    });\n\n    it(\"should support querying for on-demand tables\", async function() {\n      const docName = \"doc_fetch_query2\";\n      let activeDoc = await makeDoc(docName);\n      assert.lengthOf(await getIndexes(activeDoc), 0);\n      activeDoc = await reloadOnDemand(activeDoc, \"Bar\");\n\n      // Check we got indexes for reference columns\n      assert.sameMembers(await getIndexes(activeDoc), [\"Bar.animal\", \"Bar.nightTheme\", \"Bar.theme\"]);\n\n      // Make queries as before; this time the results have SQL-based formula evaluation.\n      await commonQueries(activeDoc, true);\n\n      // Duplicate column names should not be a problem for on-demand tables with references.\n      // There was previously an \"ambiguous column name\" problem for a table with a reference\n      // to another table with a column of the same name, where the query was filtered by\n      // that column name.\n      activeDoc = await reloadOnDemand(activeDoc, \"Dupe\");\n\n      assert.sameMembers(await getIndexes(activeDoc), [\"Bar.animal\", \"Bar.nightTheme\", \"Bar.theme\", \"Dupe.theme\"]);\n\n      assert.deepEqual((await activeDoc.fetchQuery(fakeSession,\n        { tableId: \"Dupe\", filters: { name: [\"Me\"] } })).tableData,\n      [\"TableData\", \"Dupe\", [1], {\n        manualSort: [1], name: [\"Me\"], theme: [2], volume: [3],\n      }]);\n\n      // Make Bar a regular table again, and check that its indexes go away.\n      activeDoc = await reloadOnDemand(activeDoc, \"Bar\", false);\n      assert.sameMembers(await getIndexes(activeDoc), [\"Dupe.theme\"]);\n\n      // Make Dupe a regular table again, and check that its indexes go away.\n      activeDoc = await reloadOnDemand(activeDoc, \"Dupe\", false);\n      assert.lengthOf(await getIndexes(activeDoc), 0);\n    });\n\n    it(\"should maintain indexes for on-demand tables across schema changes\", async function() {\n      const docName = \"doc_fetch_query3\";\n      const activeDoc = await reloadOnDemand(await makeDoc(docName), \"Dupe\");\n      assert.sameMembers(await getIndexes(activeDoc), [\"Dupe.theme\"]);\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"RenameColumn\", \"Dupe\", \"theme\", \"thematic\"],\n      ]);\n      assert.sameMembers(await getIndexes(activeDoc), [\"Dupe.thematic\"]);\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"RemoveColumn\", \"Dupe\", \"thematic\"],\n      ]);\n      assert.lengthOf(await getIndexes(activeDoc), 0);\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"AddColumn\", \"Dupe\", \"retheme\", { type: \"Ref:Theme\", isFormula: false }],\n      ]);\n      assert.sameMembers(await getIndexes(activeDoc), [\"Dupe.retheme\"]);\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"ModifyColumn\", \"Dupe\", \"retheme\", { label: \"retheme!\" }],\n      ]);\n      assert.sameMembers(await getIndexes(activeDoc), [\"Dupe.retheme_\"]);\n    });\n  });\n\n  describe(\"Data Types\", function() {\n    it(\"should load data with exact types as stored\", async function() {\n      const docName = \"all-types\";\n      const activeDoc1 = await docTools.createDoc(docName);\n      const rowIds = _.range(1, allValues.length + 1);\n\n      // Data maps each type to the array containing all values (the same for each column). So\n      // each row contains a single value copied across all column.\n      const data = _.fromPairs(_.map(allTypes, (type, colId) => [colId, _.clone(allValues)])) as BulkColValues;\n\n      await activeDoc1.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Types\", _.map(allTypes, (type, id) => ({ id, type, isFormula: false }))],\n        // Force lower-level DocActions to be applied rather than UserActions, to avoid all the\n        // smartness that sandbox might have (e.g. setting manualSort values).\n        [\"ApplyDocActions\", [[\"BulkAddRecord\", \"Types\", rowIds, data]]],\n      ]);\n\n      // We expect data ALMOST as stored, except that when we load 1/0 into a Bool column, they\n      // come out as true/false. This is, I think, acceptable.\n      const expectedData = _.clone(data);\n      expectedData.Bool = expectedData.Bool.map((x: any) => (x === 0 ? false : (x === 1 ? true : x)));\n\n      // Check that values from the sandbox are correct.\n      assert.deepEqual(await fetchValues(activeDoc1, \"Types\"), expectedData);\n\n      // Shut down the doc, re-load it from the database, and check again.\n      await activeDoc1.shutdown();\n      const activeDoc2 = await docTools.loadDoc(docName);\n      assert.deepEqual(await fetchValues(activeDoc2, \"Types\"), expectedData);\n\n      // Reload as an on-demand table to test how data comes out when read directly from DB.\n      const activeDoc3 = await reloadOnDemand(activeDoc2, \"Types\", true);\n      assert.deepEqual(await fetchValues(activeDoc3, \"Types\"), expectedData);\n    });\n\n    it(\"should not produce spurious Calculate actions with type conversions\", async function() {\n      // When we load formula results and recalculate them, we should find exactly equal values\n      // (and so, the recalculation should not produce any action to change the document).\n      const docName = \"formula-types\";\n      const activeDoc1 = await docTools.createDoc(docName);\n      const rowIds = _.range(1, allValues.length + 1);\n      await activeDoc1.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Types\", [\n          { id: \"value\", type: \"Any\", isFormula: false },\n          { id: \"valueRepr\", type: \"Any\", isFormula: true, formula: \"type($value)\" },\n          // Here we'll create a formula column of each type, each of which returns the various\n          // possible values in different rows.\n          ..._.map(allTypes, (type, id) => ({ id, type, isFormula: true, formula: \"$value\" })),\n\n          // Some values end up with identical representation after encoding to JSON and DB and\n          // loading from it. E.g. 5 and 5.0, or \"A\" and u\"A\". Typed columns make them uniform\n          // (this is checked by the columns above). A formula column of type 'Any' that evaluates\n          // to 5.0 will get loaded from DB as 5 (int). On Calculate, it will get corrected (to\n          // 5.0) -- so other Python code sees the precise values -- but should not emit any\n          // action, since there is no change as seen from outside the sandbox. This isn't covered\n          // by the columns above because both the formula column and its source are loaded in the\n          // same way. So test using another column that produces different value types.\n          { id: \"typeConv\", type: \"Any\", isFormula: true, formula:\n            \"(bool($value) if $value == 1 else\\n\" +\n            \" float($value) if isinstance($value, (int, bool)) else\\n\" +\n            \" int($value) if isinstance($value, float) else\\n\" +\n            \" unicode($value) if isinstance($value, str) else\\n\" +\n            \" $value)\",\n          },\n        ]],\n        // Force lower-level DocActions to be applied rather than UserActions, to avoid all the\n        // smartness that sandbox might have (e.g. setting manualSort values).\n        [\"ApplyDocActions\", [[\"BulkAddRecord\", \"Types\", rowIds, { value: allValues }]]],\n      ]);\n\n      // Get the data from the sandbox. Formulas convert their results to the column's type, so we\n      // don't expect them equal to the original allValues.\n      // TODO: I now think it's a poor approach; it would be better to keep the formula's result\n      // unchanged, and only use the column type to inform the UI on rendering, linking, etc.\n      const data1 = await fetchValues(activeDoc1, \"Types\");\n\n      // The 'Any' columns are easy to check.\n      assert.deepEqual(data1.value, allValues as CellValue[]);\n      assert.deepEqual(data1.Any, allValues as CellValue[]);\n\n      // There should just be the one UserAction that we created.\n      const actions1 = await activeDoc1.getRecentActionsDirect();\n      assert.deepEqual(actions1.map(a => a.userActions.map(ua => ua[0])),\n        [[], [\"AddTable\", \"ApplyDocActions\"]]);\n\n      await activeDoc1.shutdown();\n      const activeDoc2 = await docTools.loadDoc(docName);\n      const data2 = await fetchValues(activeDoc2, \"Types\");\n\n      // There should still just be the one UserAction, as before, and no new 'Calculate' action.\n      const actions2 = await activeDoc2.getRecentActionsDirect();\n      if (actions2[2]) {\n        // An extra action is a problem; add an assert that will print some details.\n        assert.deepEqual(getEnvContent(actions2[2].stored), []);\n      }\n      assert.deepEqual(actions2.map(a => a.userActions.map(ua => ua[0])),\n        [[], [\"AddTable\", \"ApplyDocActions\"]]);\n\n      assert.deepEqual(data2.value, allValues as CellValue[]);\n      assert.deepEqual(data2.Any, allValues as CellValue[]);\n      assert.deepEqual(data2, data1);\n    });\n\n    it(\"should produce correct defaults for all types\", async function() {\n      const docName = \"type-defaults\";\n      const activeDoc1 = await docTools.createDoc(docName);\n      await activeDoc1.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Defaults\", _.map(allTypes, (type, id) => ({ id, type, isFormula: false }))],\n        // Force lower-level DocActions to be applied rather than UserActions, to avoid all the\n        // smartness that sandbox might have (e.g. setting manualSort values).\n        [\"ApplyDocActions\", [[\"AddRecord\", \"Defaults\", 1, {}]]],\n      ]);\n\n      const expectedData = _.mapValues(allTypes, t => [gristTypes.getDefaultForType(t)]);\n\n      // Check that values from the sandbox are correct.\n      assert.deepEqual(await fetchValues(activeDoc1, \"Defaults\"), expectedData);\n\n      // Shut down the doc, re-load it from the database, and check again.\n      await activeDoc1.shutdown();\n      const activeDoc2 = await docTools.loadDoc(docName);\n      assert.deepEqual(await fetchValues(activeDoc2, \"Defaults\"), expectedData);\n\n      // Reload as an on-demand table to test how data comes out when read directly from DB.\n      const activeDoc3 = await reloadOnDemand(activeDoc2, \"Defaults\", true);\n      assert.deepEqual(await fetchValues(activeDoc3, \"Defaults\"), expectedData);\n    });\n\n    it(\"should produce correct defaults after a column conversion\", async function() {\n      const docName = \"defaults-conversions\";\n      const activeDoc1 = await docTools.createDoc(docName);\n      await activeDoc1.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Defaults\", _.map(allTypes, (type, id) => ({ id, type, isFormula: false }))],\n\n        // This isn't a normal conversion, but just the ModifyColumn docaction part.\n        [\"ModifyColumn\", \"Defaults\", \"Any\",           { type: \"Blob\" }],\n        [\"ModifyColumn\", \"Defaults\", \"Blob\",          { type: \"Text\" }],\n        [\"ModifyColumn\", \"Defaults\", \"Bool\",          { type: \"Int\" }],\n        [\"ModifyColumn\", \"Defaults\", \"Int\",           { type: \"Numeric\" }],\n        [\"ModifyColumn\", \"Defaults\", \"ManualSortPos\", { type: \"Ref:Defaults\" }],\n        [\"ModifyColumn\", \"Defaults\", \"Numeric\",       { type: \"Bool\" }],\n        [\"ModifyColumn\", \"Defaults\", \"Ref\",           { type: \"Attachments\" }],\n        [\"ModifyColumn\", \"Defaults\", \"Text\",          { type: \"Numeric\" }],\n\n        // Add a new record with all defaults. We'll check that we get correct defaults.\n        [\"ApplyDocActions\", [[\"AddRecord\", \"Defaults\", 1, {}]]],\n      ]);\n\n      // For all columns that we converted, expect the new default.\n      const expectedRow = _.mapValues(allTypes, t => gristTypes.getDefaultForType(t));\n      expectedRow.Any           = gristTypes.getDefaultForType(\"Blob\");\n      expectedRow.Blob          = gristTypes.getDefaultForType(\"Text\");\n      expectedRow.Bool          = gristTypes.getDefaultForType(\"Int\");\n      expectedRow.Int           = gristTypes.getDefaultForType(\"Numeric\");\n      expectedRow.ManualSortPos = gristTypes.getDefaultForType(\"Ref:Default\");\n      expectedRow.Numeric       = gristTypes.getDefaultForType(\"Bool\");\n      expectedRow.Ref           = gristTypes.getDefaultForType(\"Attachments\");\n      expectedRow.Text          = gristTypes.getDefaultForType(\"Numeric\");\n\n      const expectedData = _.mapValues(expectedRow, v => [v]);\n\n      assert.deepEqual(await fetchValues(activeDoc1, \"Defaults\"), expectedData);\n\n      // Shut down the doc, re-load it from the database, and check again.\n      await activeDoc1.shutdown();\n      const activeDoc2 = await docTools.loadDoc(docName);\n      assert.deepEqual(await fetchValues(activeDoc2, \"Defaults\"), expectedData);\n\n      // Reload as an on-demand table to test how data comes out when read directly from DB.\n      const activeDoc3 = await reloadOnDemand(activeDoc2, \"Defaults\", true);\n      assert.deepEqual(await fetchValues(activeDoc3, \"Defaults\"), expectedData);\n    });\n  });\n\n  describe(\"SQLite data\", function() {\n    it(\"should produce expected SQLite data\", async function() {\n      // This test is to allow us to verify what gets stored in SQLite for different data types.\n      // It checks that what's stored corresponds to test/fixtures/docs/ActiveDoc-sqlite.grist,\n      // so see THAT FILE for the expected data.\n      //\n      // If this test fails due to an expected difference, run the test with NO_CLEANUP=1 in the\n      // environment, using test/testrun.sh. This will leave the actual file produced in\n      // _testoutputs/server/testdir/grist_test_XXXXXX/actual-data.grist. Run `sqlite3 $file\n      // .dump` on the expected and actual files, and check differences. A good way is:\n      //\n      //    git diff --no-index --color-words='\\w+|[^[:space:]]' $dump1 $dump2\n      //\n      // If all as it should be, replace the text fixture.\n      //\n      // Some points of note:\n      // - Booleans are 0/1 in the Bool column, but marshalled values (X'46', X'54') elsewhere.\n      // - .dump represents Infinity as Inf, which is unusable to actually load data from it.\n      // - .dump represents -0.0 as 0.0, which is wrong (but reading DB returns it correctly).\n\n      const docName = \"actual-data\";\n      const activeDoc1 = await docTools.createDoc(docName);\n      const docPath = activeDoc1.docStorage.docPath;\n\n      const rowIds = _.range(1, allValues.length + 1);\n      const data = _.fromPairs(_.map(allTypes, (type, colId) => [colId, _.clone(allValues)]));\n      await activeDoc1.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Types\", _.map(allTypes, (type, id) => ({ id, type, isFormula: false }))],\n        // Force lower-level DocActions to be applied rather than UserActions, to avoid all the\n        // smartness that sandbox might have (e.g. setting manualSort values).\n        [\"ApplyDocActions\", [[\"BulkAddRecord\", \"Types\", rowIds, data]]],\n        [\"AddTable\", \"Defaults\", _.map(allTypes, (type, id) => ({ id, type, isFormula: false }))],\n        // Force lower-level DocActions to be applied rather than UserActions, to avoid all the\n        // smartness that sandbox might have (e.g. setting manualSort values).\n        [\"ApplyDocActions\", [[\"AddRecord\", \"Defaults\", 1, {}]]],\n      ]);\n      await activeDoc1.shutdown();\n\n      const stdout = await dumpTables(docPath);\n      const expectedDocPath = resolve(testUtils.fixturesRoot, \"docs\", \"ActiveDoc-sqlite.grist\");\n      const expectedStdout = await dumpTables(expectedDocPath);\n      assert.deepEqual(stdout, expectedStdout);\n    });\n  });\n\n  describe(\"ActionHistory\", function() {\n    it(\"should exist\", async function() {\n      const docName = \"tmp\";\n      const activeDoc1 = await docTools.createDoc(docName);\n      await activeDoc1.addInitialTable(fakeSession);\n      const { actions: actions1 } = await activeDoc1.getRecentActions(fakeSession, true);\n      assert.lengthOf(actions1, 2);\n      assert.equal(actions1[1].primaryAction, \"AddEmptyTable\");\n      assert.equal(actions1[1].actionNum, 2);\n      await activeDoc1.shutdown();\n\n      const activeDoc2 = await docTools.loadDoc(docName);\n      const { actions: actions2 } = await activeDoc2.getRecentActions(fakeSession, true);\n      assert.lengthOf(actions2, 2);\n      assert.equal(actions2[1].primaryAction, \"AddEmptyTable\");\n      assert.equal(actions2[1].actionNum, 2);\n      const action = actions2[1];\n      for (const key of [\"actionNum\", \"fromSelf\"]) {\n        assert.include(Object.keys(action), key);\n      }\n    });\n\n    it(\"should be sequential\", async function() {\n      const docName = \"tmp2\";\n      const activeDoc1 = await docTools.createDoc(docName);\n      await activeDoc1.addInitialTable(fakeSession);\n      await activeDoc1.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Hello\", [{ id: \"city\", type: \"Text\" }, { id: \"state\", type: \"Text\" }]],\n        [\"BulkAddRecord\", \"Hello\", [1, 4], {\n          city: [\"New York\", \"Boston\"],\n          state: [\"NY\", \"MA\"],\n        }],\n      ]);\n      async function checkDoc(doc: ActiveDoc) {\n        const { actions } = await doc.getRecentActions(fakeSession, true);\n        assert.lengthOf(actions, 3);\n        assert.equal(actions[1].primaryAction, \"AddEmptyTable\");\n        assert.equal(actions[1].actionNum, 2);\n        assert.equal(actions[2].primaryAction, \"AddTable\");\n        assert.equal(actions[2].actionNum, 3);\n        await doc.shutdown();\n      }\n      await checkDoc(activeDoc1);\n      const activeDoc2 = await docTools.loadDoc(docName);\n      await checkDoc(activeDoc2);\n    });\n  });\n\n  it(\"should not attribute Calculate actions to opening user\", async function() {\n    // Set up a fake test@test user session.\n    const docName = \"calculate-attribution\";\n\n    // Make a fake client with a particular fake user.\n    const authSession = AuthSession.fromUser({ id: 17, name: \"Test McTester\", email: \"test@test\" }, \"docs\");\n    const client = new Client(null as any, null as any, null!);\n    client.setConnection({ websocket: {} as any, req: null as any, counter: null, browserSettings: {}, authSession });\n    const userSession = makeOptDocSession(client);\n    userSession.authorizer = new DummyAuthorizer(\"owners\", docName);\n\n    // Make a document with a cell that is set to \"=NOW()\"\n    const activeDoc1 = await docTools.createDoc(docName);\n    await activeDoc1.applyUserActions(userSession, [\n      [\"AddTable\", \"Calc\", [\n        { id: \"tick\", type: \"Any\", isFormula: true, formula: \"NOW()\" },\n      ]],\n      [\"AddRecord\", \"Calc\", null, {}],\n    ]);\n\n    // Check we see the expected user actions.\n    await fetchValues(activeDoc1, \"Calc\");\n    const actions1 = await activeDoc1.getRecentActionsDirect();\n    assert.deepEqual(actions1.map(a => a.userActions.map(ua => ua[0])),\n      [[], [\"AddTable\", \"AddRecord\"]]);\n\n    // Close and reopen.\n    await activeDoc1.shutdown();\n    const activeDoc2 = await docTools.loadDoc(docName);\n\n    // Fetch table to make sure any calculation is complete.\n    await fetchValues(activeDoc2, \"Calc\");\n\n    // Check we see we have an extra Calculate action now, and its user is\n    // overridden to be \"grist\".\n    const actions2 = await activeDoc2.getRecentActionsDirect();\n    assert.deepEqual(actions2.map(a => a.userActions.map(ua => ua[0])),\n      [[], [\"AddTable\", \"AddRecord\"], [\"Calculate\"]]);\n    assert.equal(actions2[0].info[1].user, \"grist\");\n    assert.equal(actions2[1].info[1].user, \"test@test\");\n    assert.equal(actions2[2].info[1].user, \"grist\");\n  });\n\n  describe(\"applyUserActions\", function() {\n    it(\"should send user info to the sandbox\", async function() {\n      // Set up a fake user session.\n      const docName = \"user-info\";\n      const authSession = AuthSession.fromUser(\n        { id: 567, ref: \"randomString\", name: \"testUser\", email: \"test@test\" },\n        \"\",\n        \"u567\",\n      );\n      const client = new Client(null as any, null as any, null!);\n      client.setConnection({ websocket: {} as any, req: null as any, counter: null, browserSettings: {}, authSession });\n      const userSession = makeExceptionalDocSession(\"system\", { client });\n\n      // Spy on calls to the sandbox.\n      const rawPyCall = sandbox.spy(ActiveDoc.prototype, \"_rawPyCall\" as any);\n\n      // Make a document and add a table with some records.\n      const activeDoc = await docTools.createDoc(docName);\n      await activeDoc.applyUserActions(userSession, [\n        [\"AddTable\", \"Residences\",\n          [{ id: \"email\", type: \"Text\" }, { id: \"city\", type: \"Text\" }, { id: \"state\", type: \"Text\" }],\n        ],\n        [\"BulkAddRecord\", \"Residences\", [1, 4], {\n          email: [\"foo@getgrist.com\", \"test@test\"],\n          city: [\"New York\", \"Boston\"],\n          state: [\"NY\", \"MA\"],\n        }],\n      ]);\n\n      // Check that the last call to sandbox included correct user info.\n      assert.deepEqual(\n        rawPyCall.lastCall.args[2],\n        {\n          Access: \"owners\",\n          Email: \"test@test\",\n          IsLoggedIn: true,\n          LinkKey: {},\n          Origin: null,\n          Name: \"testUser\",\n          SessionID: \"u567\",\n          ShareRef: null,\n          UserID: 567,\n          UserRef: \"randomString\",\n          Type: null,\n        },\n      );\n\n      // Add another table, and set up the tables to be user attribute tables.\n      await activeDoc.applyUserActions(userSession, [\n        [\"AddTable\", \"Favorites\",\n          [{ id: \"email\", type: \"Text\" }, { id: \"color\", type: \"Text\" }, { id: \"food\", type: \"Text\" }],\n        ],\n        [\"BulkAddRecord\", \"Favorites\", [1, 2, 3], {\n          email: [\"foo@getgrist.com\", \"bar@getgrist.com\", \"\"],\n          color: [\"Red\", \"Green\", \"Blue\"],\n          food: [\"Pizza\", \"Pasta\", \"Soup\"],\n        }],\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Residences\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Favorites\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, userAttributes: JSON.stringify({\n            name: \"Residences\",\n            tableId: \"Residences\",\n            charId: \"Email\",\n            lookupColId: \"email\",\n          }),\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, userAttributes: JSON.stringify({\n            name: \"Favorites\",\n            tableId: \"Favorites\",\n            charId: \"Email\",\n            lookupColId: \"email\",\n          }),\n        }],\n      ]);\n\n      // Trigger another user action by adding one more record.\n      await activeDoc.applyUserActions(userSession, [\n        [\"BulkAddRecord\", \"Residences\", [3], {\n          email: [\"\"],\n          city: [\"Portland\"],\n          state: [\"OR\"],\n        }],\n      ]);\n\n      // Check that the correct attributes are included in the user info sent to the sandbox.\n      assert.deepEqual(\n        rawPyCall.lastCall.args[2],\n        {\n          Access: \"owners\",\n          Email: \"test@test\",\n          IsLoggedIn: true,\n          LinkKey: {},\n          Origin: null,\n          Name: \"testUser\",\n          SessionID: \"u567\",\n          ShareRef: null,\n          Type: null,\n          UserID: 567,\n          UserRef: `randomString`,\n          Residences: [\"Residences\", 4],\n          Favorites: null,\n        },\n      );\n\n      rawPyCall.restore();\n    });\n  });\n\n  describe(\"sandboxed python3\", async function() {\n    let oldEnv: EnvironmentSnapshot | undefined;\n\n    before(async function() {\n      // Skip this test if sandbox is not present.\n      if (!await canSandboxPython3()) { this.skip(); }\n      oldEnv = new EnvironmentSnapshot();\n      // Set environment variable that currently determines whether a sandbox choice is allowed.\n      process.env.GRIST_EXPERIMENTAL_PLUGINS = \"1\";\n      delete process.env.GRIST_SANDBOX_FLAVOR;\n    });\n\n    after(async function() {\n      oldEnv?.restore();\n    });\n\n    // Adds an Info table containing `sys.version`, and checks python\n    // is version 3.\n    async function checkPythonIs3(activeDoc: ActiveDoc) {\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Info\", [\n          { id: \"Version\", formula: \"import sys\\nsys.version\" },\n          { id: \"UUID\", formula: \"UUID()\" },\n        ]],\n        [\"AddRecord\", \"Info\", null, {}],\n      ]);\n      const version = String((await activeDoc.fetchTable(fakeSession, \"Info\", true)).tableData[3].Version[0]);\n      assert.match(version, /3\\./);\n      assert.notMatch(version, /2\\.7/);\n    }\n\n    async function makePython3Doc(docName: string) {\n      // Make a python3 document.\n      const activeDoc1 = await docTools.createDoc(docName);\n      await activeDoc1.applyUserActions(fakeSession, [\n        [\"UpdateRecord\", \"_grist_DocInfo\", 1, {\n          documentSettings: JSON.stringify({ engine: \"python3\" }),\n        }],\n      ]);\n      await activeDoc1.shutdown();\n      const activeDoc2 = await docTools.loadDoc(docName);\n      await checkPythonIs3(activeDoc2);\n      return activeDoc2;\n    }\n\n    it(\"can use python3 sandbox\", async function() {\n      await makePython3Doc(\"sandbox\");  // includes test for python3-ness\n    });\n\n    // There was a problem where checkpointed sandboxes had same seed.\n    // Checkpointing is currently used in running these tests. If that\n    // changes, this test would pass trivially.\n    it(\"can use randomness in python3 sandbox\", async function() {\n      const activeDoc = await makePython3Doc(\"randomness\");\n      const uuid1 = String((await activeDoc.fetchTable(fakeSession, \"Info\", true)).tableData[3].UUID[0]);\n      await activeDoc.shutdown();\n      const activeDoc2 = await docTools.loadDoc(\"randomness\");\n      const uuid2 = String((await activeDoc2.fetchTable(fakeSession, \"Info\", true)).tableData[3].UUID[0]);\n      assert.notEqual(uuid1, uuid2);\n    });\n\n    it(\"can throttle python3\", async function() {\n      this.timeout(60000);\n      process.env.GRIST_THROTTLE_CPU = \"1\";\n\n      let logMeta: log.ILogMeta = {};\n      sandbox.replace(Throttle.prototype, \"_log\" as any, async function(msg: string, meta: log.ILogMeta) {\n        log.rawWarn(msg, meta);  // Show something since this is a slow operation.\n        logMeta = meta;\n      });\n\n      const activeDoc = await makePython3Doc(\"throttle\");\n      activeDoc.applyUserActions(fakeSession, [\n        [\"AddTable\", \"SlowTable\", [{\n          id: \"Delay\",\n          formula: \"total = 0\\nfor x in range(0, 1000000000):\\n  total += x\\nreturn total\" }]],\n        [\"AddRecord\", \"SlowTable\", null, {}],\n      ]).catch(e => null);\n      // Make sure we can throttle down - broken throttling would leave throttledRate up\n      // near 100.\n      while (!logMeta?.throttle || logMeta.throttledRate > 60) {\n        await delay(250);\n      }\n    });\n\n    it(\"can limit memory use by python3\", async function() {\n      if (!process.env.GVISOR_LIMIT_MEMORY) { this.skip(); }\n\n      const activeDoc = await makePython3Doc(\"memory\");\n      // Add a table with a formula that uses a lot of memory during computation.\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"AddTable\", \"TestTable\", [{\n          id: \"Size\", type: \"Int\",\n        }, {\n          id: \"Test\",\n          formula: 'len(\"x\" * $Size)',\n        }]],\n        [\"AddRecord\", \"TestTable\", null, { Size: 1 }],\n      ]);\n\n      // Push a bit.\n      const MB = 1024 * 1024;\n      await assert.isFulfilled(activeDoc.applyUserActions(fakeSession, [\n        [\"UpdateRecord\", \"TestTable\", 1, { Size: 10 * MB }],\n      ]));\n\n      // Push a bit more.\n      await assert.isFulfilled(activeDoc.applyUserActions(fakeSession, [\n        [\"UpdateRecord\", \"TestTable\", 1, { Size: 100 * MB }],\n      ]));\n\n      // Push too much.\n      const tooMuch = parseInt(process.env.GVISOR_LIMIT_MEMORY, 10);\n      await assert.isRejected(activeDoc.applyUserActions(fakeSession, [\n        [\"UpdateRecord\", \"TestTable\", 1, { Size: tooMuch }],\n      ]), /MemoryError/);\n    });\n\n    it(\"can use python3 sandbox by default\", async function() {\n      const docName = \"sandbox-default\";\n      const activeDoc = await docTools.createDoc(docName);\n      await checkPythonIs3(activeDoc);\n    });\n  });\n\n  it(\"can access document before engine opens\", async function() {\n    // Create a document.\n    const docName = \"makeEngineTest\";\n    const activeDoc1 = await docTools.createDoc(docName);\n    await activeDoc1.addInitialTable(fakeSession);\n    await activeDoc1.applyUserActions(fakeSession, [\n      [\"AddTable\", \"Info\", [{ id: \"Version\", type: \"Int\" }]],\n      [\"AddRecord\", \"Info\", null, { Version: 10 }],\n    ]);\n\n    // Shut down document, then delay future engine creation by a second.\n    await activeDoc1.shutdown();\n    const makeEngineFn = (ActiveDoc.prototype as any)._makeEngine;\n    sandbox.replace(ActiveDoc.prototype, \"_makeEngine\" as any, async function(this: any) {\n      await delay(1000);\n      return makeEngineFn.apply(this);\n    });\n\n    // Check an immediate fetch sees data.\n    const activeDoc2 = await docTools.loadDoc(docName);\n    let version = (await activeDoc2.fetchTable(fakeSession, \"Info\", false)).tableData[3].Version[0];\n    assert.equal(version, 10);\n\n    // Start making a change - this will be blocked on engine availability.\n    const change = activeDoc2.applyUserActions(fakeSession, [\n      [\"UpdateRecord\", \"Info\", 1, { Version: 20 }],\n    ]).catch(e => console.error(e));\n\n    // Check a fetch after a half-second doesn't see a change.\n    await delay(500);\n    version = (await activeDoc2.fetchTable(fakeSession, \"Info\", false)).tableData[3].Version[0];\n    assert.equal(version, 10);\n\n    // Check a later fetch does see the change.\n    assert.isFalse(await timeoutReached(4000, change));\n    version = (await activeDoc2.fetchTable(fakeSession, \"Info\", false)).tableData[3].Version[0];\n    assert.equal(version, 20);\n  });\n\n  it(\"sandbox passes in docUrl\", async function() {\n    // Try with a valid docUrl and one with some extra stuff thrown in.\n    for (const docUrl of [\n      \"https://templates.getgrist.com/doc/lightweight-crm~8sJPiNkWZo68KFJkc5Ukbr~4\",\n      \"https://templates!.getgrist.com/doc/lightweight-crm 8sJPiNkWZo68KFJkc5Ukbr~4\",\n    ] as const) {\n      const activeDoc = new ActiveDoc(docTools.getDocManager(), \"docUrlTest\" + docUrl.length,\n        new AttachmentStoreProvider([], \"TEST-INSTALL-ID\"),\n        { docUrl });\n      await activeDoc.createEmptyDoc(fakeSession);\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Info\", [{ id: \"Url\", formula: \"SELF_HYPERLINK()\" }]],\n        [\"AddRecord\", \"Info\", null, {}],\n      ]);\n      const url = String((await activeDoc.fetchTable(fakeSession, \"Info\", true)).tableData[3].Url[0]);\n      assert.equal(url, docUrl);\n      await activeDoc.shutdown();\n    }\n  });\n\n  it(\"sandbox passes in truthy custom values\", async function() {\n    const env = new EnvironmentSnapshot();\n    try {\n      process.env.GRIST_TRUTHY_VALUES = \"meep\";\n      // If using GVisor, ignore any checkpoint prepared earlier (since\n      // env variable wasn't set then).\n      delete process.env.GRIST_CHECKPOINT;\n      const activeDoc = new ActiveDoc(docTools.getDocManager(), \"truthyTest\");\n      await activeDoc.createEmptyDoc(fakeSession);\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Info\", [{ id: \"Flag\", type: \"Bool\" }]],\n        [\"AddRecord\", \"Info\", null, { Flag: \"meep\" }],\n        [\"AddRecord\", \"Info\", null, { Flag: \"moop\" }],\n      ]);\n      const data = (await activeDoc.fetchTable(fakeSession, \"Info\", true)).tableData[3];\n      assert.deepEqual(data.Flag, [true, \"moop\"], \"Expected 'meep' to be truthy\");\n      await activeDoc.shutdown();\n    } finally {\n      env.restore();\n    }\n  });\n\n  it(\"sandbox passes in falsy custom values\", async function() {\n    const env = new EnvironmentSnapshot();\n    try {\n      process.env.GRIST_FALSY_VALUES = \"moop\";\n      delete process.env.GRIST_CHECKPOINT;\n      const activeDoc = new ActiveDoc(docTools.getDocManager(), \"falsyTest\");\n      await activeDoc.createEmptyDoc(fakeSession);\n      await activeDoc.applyUserActions(fakeSession, [\n        [\"AddTable\", \"Info\", [{ id: \"Flag\", type: \"Bool\" }]],\n        [\"AddRecord\", \"Info\", null, { Flag: \"meep\" }],\n        [\"AddRecord\", \"Info\", null, { Flag: \"moop\" }],\n      ]);\n      const data = (await activeDoc.fetchTable(fakeSession, \"Info\", true)).tableData[3];\n      assert.deepEqual(data.Flag, [\"meep\", false], \"Expected 'moop' to be falsy\");\n      await activeDoc.shutdown();\n    } finally {\n      env.restore();\n    }\n  });\n\n  describe(\"attachments\", async function() {\n    // Provides the fake userId `null`, so we can access uploaded files with hitting an\n    // authorization errors.\n    const fakeTransferSession = docTools.createFakeSession();\n\n    const testAttachments = [\n      {\n        name: \"Test.doc\",\n        contents: \"Hello world!\",\n      },\n      {\n        name: \"Test2.txt\",\n        contents: \"I am a test file!\",\n      },\n    ];\n\n    async function uploadAttachments(doc: ActiveDoc, files: { name: string, contents: string }[]) {\n      const { tmpDir, cleanupCallback } = await createTmpUploadDir({});\n\n      const uploadPromises = files.map(async (file) => {\n        const filePath = resolve(tmpDir, file.name);\n        const buffer = Buffer.from(file.contents, \"utf8\");\n        await fse.writeFile(path.join(tmpDir, file.name), buffer);\n        return {\n          absPath: filePath,\n          origName: file.name,\n          size: buffer.length,\n          ext: await guessExt(filePath, file.name, null),\n        };\n      });\n\n      const uploadedFiles = await Promise.all(uploadPromises);\n      const uploadId = globalUploadSet.registerUpload(uploadedFiles, tmpDir, cleanupCallback, null);\n      await doc.addAttachments(fakeTransferSession, uploadId);\n    }\n\n    async function assertArchiveContents(\n      archive: string | Buffer,\n      archiveType: string,\n      expectedFiles: { name: string; contents?: string }[],\n    ) {\n      const getFileName = (filePath: string) => filePath.substring(filePath.indexOf(\"_\") + 1);\n      const files = await decompress(archive);\n      for (const expectedFile of expectedFiles) {\n        const file = files.find(file => getFileName(file.path) === expectedFile.name);\n        assert(file, \"file not found in archive\");\n        if (expectedFile.contents) {\n          assert.equal(\n            file?.data.toString(), expectedFile.contents, `file contents in ${archiveType} archive don't match`);\n        }\n      }\n    }\n\n    it(\"can enforce internal attachments limit\", async function() {\n      // Add a tight limit, make sure adding attachments fails.\n      let stub = sandbox.stub(Deps, \"MAX_INTERNAL_ATTACHMENTS_BYTES\").value(10);\n      const activeDoc = await docTools.createDoc(\"enforceInternalLimit\");\n      try {\n        await assert.isRejected(\n          uploadAttachments(activeDoc, testAttachments),\n          /Exceeded internal attachments limit/,\n        );\n\n        // Ease off, make sure adding attachments succeeds.\n        stub.restore();\n        await assert.isFulfilled(\n          uploadAttachments(activeDoc, testAttachments),\n        );\n\n        // Add limit again, make sure it works, then set the doc for external\n        // storage and see if adding attachments works now.\n        stub = sandbox.stub(Deps, \"MAX_INTERNAL_ATTACHMENTS_BYTES\").value(10);\n        await assert.isRejected(\n          uploadAttachments(activeDoc, testAttachments),\n          /Exceeded internal attachments limit/,\n        );\n        await activeDoc.setAttachmentStore(\n          makeExceptionalDocSession(\"system\"),\n          docTools.getAttachmentStoreProvider().listAllStoreIds()[0],\n        );\n        await assert.isFulfilled(\n          uploadAttachments(activeDoc, testAttachments),\n        );\n\n        await activeDoc.startTransferringAllAttachmentsToDefaultStore();\n        await activeDoc.allAttachmentTransfersCompleted();\n        let transfer = await activeDoc.attachmentTransferStatus();\n        assert.equal(transfer.status.failures, 0);\n        assert.equal(transfer.status.successes, 2);\n        // Now transfer attachments back and see if limit is\n        // respected.\n        await activeDoc.setAttachmentStore(\n          makeExceptionalDocSession(\"system\"),\n          undefined,\n        );\n        await activeDoc.startTransferringAllAttachmentsToDefaultStore();\n        await activeDoc.allAttachmentTransfersCompleted();\n        transfer = await activeDoc.attachmentTransferStatus();\n        assert.equal(transfer.status.failures, 2);\n        assert.equal(transfer.status.successes, 0);\n      } finally {\n        stub.restore();\n        await activeDoc.shutdown();\n      }\n    });\n\n    it(\"can pack attachments into an archive\", async function() {\n      const docName = \"attachment-archive\";\n      const activeDoc1 = await docTools.createDoc(docName);\n\n      await uploadAttachments(activeDoc1, testAttachments);\n\n      for (const archiveType of CreatableArchiveFormats.values) {\n        const archive = await activeDoc1.getAttachmentsArchive(fakeTransferSession, archiveType);\n        const archiveMemoryStream = new MemoryWritableStream();\n        await archive.packInto(archiveMemoryStream);\n\n        await assertArchiveContents(archiveMemoryStream.getBuffer(), archiveType, testAttachments);\n      }\n    });\n\n    describe(\"restoring attachments\", () => {\n      let activeDoc: ActiveDoc;\n      let provider: IAttachmentStoreProvider;\n      let externalStoreId: string;\n\n      beforeEach(async function() {\n        activeDoc = await docTools.createDoc(this.currentTest?.title ?? \"restore-attachments\");\n        provider = docTools.getAttachmentStoreProvider();\n        externalStoreId = provider.listAllStoreIds()[0];\n\n        await activeDoc.setAttachmentStore(fakeSession, externalStoreId);\n\n        await uploadAttachments(activeDoc, testAttachments);\n      });\n\n      async function deleteAttachmentsFromStorage() {\n        const store = (await provider.getStore(externalStoreId))!;\n        // Purge any attachments related to this doc.\n        await store.removePool(getDocPoolIdFromDocInfo({ id: activeDoc.docName, trunkId: undefined }));\n      }\n\n      async function downloadAttachmentsTarArchive() {\n        const attachmentsArchive = await activeDoc.getAttachmentsArchive(fakeSession, \"tar\");\n        const attachmentsTarStream = new MemoryWritableStream();\n        await attachmentsArchive.packInto(attachmentsTarStream);\n        return attachmentsTarStream.getBuffer();\n      }\n\n      it(\"can import missing attachments from an archive\", async function() {\n        const attachmentsTar = await downloadAttachmentsTarArchive();\n        await deleteAttachmentsFromStorage();\n\n        const result1 = await activeDoc.addMissingFilesFromArchive(fakeSession, stream.Readable.from(attachmentsTar));\n        assert.equal(result1.added, testAttachments.length, \"all attachments should be added\");\n\n        const result2 = await activeDoc.addMissingFilesFromArchive(fakeSession, stream.Readable.from(attachmentsTar));\n        assert.equal(result2.added, 0, \"no attachments should be added\");\n        assert.equal(result2.unused, testAttachments.length, \"all attachments should be unused\");\n      });\n\n      it(\"updates the document's attachment usage on .tar upload\", async function() {\n        const systemSession = makeExceptionalDocSession(\"system\");\n\n        const attachmentsTar = await downloadAttachmentsTarArchive();\n        await deleteAttachmentsFromStorage();\n\n        const getAttachmentTableData = async () =>\n          (await activeDoc.fetchTable(systemSession, \"_grist_Attachments\")).tableData;\n\n        const rowIds = (await getAttachmentTableData())[2];\n\n        const getFileSizes = async () =>\n          (await getAttachmentTableData())[3].fileSize;\n\n        const originalFileSizes = await getFileSizes();\n        assert(originalFileSizes.every(size => size && size > 0), \"uploaded files should have non-zero sizes\");\n\n        // Sets all file sizes in _grist_Attachments to zero.\n        await activeDoc.applyUserActions(\n          systemSession,\n          [[\"BulkUpdateRecord\", \"_grist_Attachments\", rowIds, { fileSize: rowIds.map(() => 0) }]],\n        );\n\n        const zeroedFileSizes = await getFileSizes();\n        assert(zeroedFileSizes.every(size => size === 0), \"all file sizes should be 0\");\n\n        await activeDoc.addMissingFilesFromArchive(fakeSession, stream.Readable.from(attachmentsTar));\n        const restoredFileSizes = await getFileSizes();\n        assert.deepEqual(restoredFileSizes, originalFileSizes, \"restored file sizes should match originals\");\n      });\n    });\n\n    /*\n    it('can transfer attachments to a new store, with correct status reporting', async function() {\n      const docName = 'transfer status';\n      const activeDoc1 = await docTools.createDoc(docName);\n      await activeDoc1.applyUserActions(fakeSession, [\n        ['AddTable', 'MyAttachments', [{id: \"A\", type: allTypes.Attachments, isFormula: false}]],\n      ]);\n\n      const initialTransferStatus = activeDoc1.attachmentTransferStatus();\n      assert.isFalse(initialTransferStatus.isRunning);\n      assert.equal(initialTransferStatus.pendingTransferCount, 0);\n\n      const initialAttachmentsLocation = await activeDoc1.attachmentLocationSummary();\n      assert.equal(initialAttachmentsLocation, \"NO FILES\");\n\n      await uploadAttachments(activeDoc1, [{\n        name: \"A.txt\",\n        contents: \"Contents1\",\n      }]);\n\n      const postUploadAttachmentsLocation = await activeDoc1.attachmentLocationSummary();\n      assert.equal(postUploadAttachmentsLocation, \"INTERNAL\");\n\n      await activeDoc1.setAttachmentStore(fakeSession, attachmentStoreProvider.listAllStoreIds()[0]);\n      await activeDoc1.startTransferringAllAttachmentsToDefaultStore();\n\n      // These assertions should always be correct, as we don't await any promises here, so there's\n      // no time for the async transfers to run.\n      const transferStartedStatus = activeDoc1.attachmentTransferStatus();\n      assert.isTrue(transferStartedStatus.isRunning);\n      assert.isTrue(transferStartedStatus.pendingTransferCount > 0, \"at least one transfer should be pending\");\n\n      // Can't assert location here, as \"INTERNAL\", \"MIXED\" and \"EXTERNAL\" are all valid, depending\n      // on how the transfer status is going in the background.\n\n      await activeDoc1.allAttachmentTransfersCompleted();\n\n      const finalTransferStatus = activeDoc1.attachmentTransferStatus();\n      assert.isFalse(finalTransferStatus.isRunning);\n      assert.equal(finalTransferStatus.pendingTransferCount, 0);\n\n      const finalAttachmentsLocation = await activeDoc1.attachmentLocationSummary();\n      assert(finalAttachmentsLocation, \"INTERNAL\");\n    });\n    */\n  });\n});\n\nasync function dumpTables(path: string): Promise<string> {\n  return await execFileAsync(\"sqlite3\", [path, \".dump Types\", \".dump Defaults\"]);\n}\n\nasync function canSandboxPython3() {\n  return await fse.pathExists(\"/usr/bin/runsc\") ||  // linux sandbox\n    await fse.pathExists(\"/usr/local/bin/runsc\") ||\n    await fse.pathExists(\"/usr/bin/sandbox-exec\");  // mac sandbox\n}\n"
  },
  {
    "path": "test/server/lib/ActiveDocImport.js",
    "content": "const assert            = require(\"chai\").assert;\nconst fs                = require(\"fs\");\nconst path              = require(\"path\");\n\nconst {createDocTools} = require(\"test/server/docTools\");\nconst testUtils        = require(\"test/server/testUtils\");\nconst tmp              = require(\"tmp\");\nconst _                = require(\"lodash\");\nconst {DummyAuthorizer} = require(\"app/server/lib/DocAuthorizer\");\nconst {makeOptDocSession} = require(\"app/server/lib/DocSession\");\nconst {getFileUploadInfo, globalUploadSet, moveUpload} = require(\"app/server/lib/uploads\");\n\n\ntmp.setGracefulCleanup();\n\ndescribe(\"ActiveDocImport\", function() {\n  this.timeout(10000);\n\n  // Turn off logging for this test, and restore afterwards.\n  testUtils.setTmpLogLevel(\"warn\");\n\n  const docTools = createDocTools();\n\n  const docSession = docTools.createFakeSession();\n\n  const csvPath = fs.realpathSync(path.resolve(testUtils.fixturesRoot, \"uploads/FileUploadData.csv\"));\n  const csvPath1 = path.resolve(testUtils.fixturesRoot, \"uploads/UploadedData1.csv\");\n  const csvPath2 = path.resolve(testUtils.fixturesRoot, \"uploads/UploadedData2.csv\");\n  const extendedCsvPath2 = path.resolve(testUtils.fixturesRoot, \"uploads/UploadedData2Extended.csv\");\n  const csvPath3 = path.resolve(testUtils.fixturesRoot, \"uploads/UploadedData3.csv\");\n  const csvPathWithUnicodeHeaders = path.resolve(testUtils.fixturesRoot, \"uploads/unicode_headers.csv\");\n  const xlsxPath = path.resolve(testUtils.fixturesRoot, \"uploads/homicide_rates.xlsx\");\n  const xlsxPathWithUnicodeHeaders = path.resolve(testUtils.fixturesRoot, \"uploads/unicode_headers.xlsx\");\n  const xlsxEmpty = path.resolve(testUtils.fixturesRoot, \"uploads/empty_excel.xlsx\");\n  const jgristPath = path.resolve(testUtils.fixturesRoot, \"uploads/cities.jgrist\");\n  const jgristBrokenPath = path.resolve(testUtils.fixturesRoot, \"uploads/cities_broken.jgrist\");\n  const simpleArrayJsonPath = path.resolve(testUtils.fixturesRoot, \"uploads/simple_array.json\");\n  const moreComplexJsonPath = path.resolve(testUtils.fixturesRoot, \"uploads/spotifyGetSeveralAlbums.json\");\n  const jsonPathWithDirtyTableName = path.resolve(testUtils.fixturesRoot, \"uploads/dirtyNames.json\");\n  const emptyData = path.resolve(testUtils.fixturesRoot, \"uploads/empty_data.jgrist\");\n  const booleanData = path.resolve(testUtils.fixturesRoot, \"uploads/BooleanData.xlsx\");\n  const dateTimeData = path.resolve(testUtils.fixturesRoot, \"uploads/DateTimeData.xlsx\");\n\n  const expectedCommaSeparatedData = [ \"TableData\", \"GristHidden_import\", [ 1, 2, 3 ], {\n    manualSort: [ 1, 2, 3 ],\n    lname: [ \"washington\", \"adams\", \"jefferson\" ],\n    start_year: [ 1789, 1797, 1801 ],\n    end_year: [ 1797, 1801, 1809 ],\n    fname: [ \"george\", \"john\", \"thomas\" ],\n    gristHelper_Import_fname: [ \"george\", \"john\", \"thomas\" ],\n    gristHelper_Import_lname: [ \"washington\", \"adams\", \"jefferson\" ],\n    gristHelper_Import_start_year: [ 1789, 1797, 1801 ],\n    gristHelper_Import_end_year: [ 1797, 1801, 1809 ]\n  }];\n\n  const expectedNoHeadersData = [ \"TableData\", \"GristHidden_import\", [ 1, 2, 3 ], {\n    manualSort: [ 1, 2, 3 ],\n    A: [ \"milk\", \"egg\", \"butter\" ],\n    B: [ 1, 2, 4 ],\n    C: [ \"sold\", \"in stock\", \"sold\" ],\n    gristHelper_Import_A: [ \"milk\", \"egg\", \"butter\" ],\n    gristHelper_Import_B: [ 1, 2, 4 ],\n    gristHelper_Import_C: [ \"sold\", \"in stock\", \"sold\" ]\n  }];\n\n  const expectedHeadersFromFirstRowData = [ \"TableData\", \"GristHidden_import\", [ 1, 2 ], {\n    manualSort: [ 1, 2 ],\n    milk: [ \"egg\", \"butter\" ],\n    c1: [ 2, 4 ],\n    sold: [ \"in stock\", \"sold\" ],\n    gristHelper_Import_milk: [ \"egg\", \"butter\" ],\n    gristHelper_Import_c1: [ 2, 4 ],\n    gristHelper_Import_sold: [ \"in stock\", \"sold\" ]\n  }];\n\n  const expectedFinalCommaSeparatedData = [ \"TableData\", \"FileUploadData\", [ 1, 2, 3 ], {\n    manualSort: [ 1, 2, 3 ],\n    lname: [ \"washington\", \"adams\", \"jefferson\" ],\n    start_year: [ 1789, 1797, 1801 ],\n    end_year: [ 1797, 1801, 1809 ],\n    fname: [ \"george\", \"john\", \"thomas\" ]\n  }];\n\n  const expectedTransformedData = [ \"TableData\", \"GristHidden_import\", [ 1, 2, 3 ], {\n    manualSort: [ 1, 2, 3 ],\n    lname: [ \"washington\", \"adams\", \"jefferson\" ],\n    start_year: [ 1789, 1797, 1801 ],\n    end_year: [ 1797, 1801, 1809 ],\n    fname: [ \"george\", \"john\", \"thomas\" ],\n    gristHelper_Import_fname: [ \"George\", \"John\", \"Thomas\" ],\n    gristHelper_Import_lname: [ \"Washington\", \"Adams\", \"Jefferson\" ],\n    gristHelper_Import_start_year: [ 1789, 1797, 1801 ],\n    gristHelper_Import_end_year: [ 1797, 1801, 1809 ]\n  }];\n\n  const expectedFinalTransformedData = [ \"TableData\", \"FileUploadData\", [ 1, 2, 3 ], {\n    manualSort: [ 1, 2, 3 ],\n    lname: [ \"Washington\", \"Adams\", \"Jefferson\" ],\n    start_year: [ 1789, 1797, 1801 ],\n    end_year: [ 1797, 1801, 1809 ],\n    fname: [ \"George\", \"John\", \"Thomas\" ]\n  }];\n\n  const expectedPipeSeparatedData = [ \"TableData\", \"GristHidden_import\", [ 1, 2, 3 ], {\n    manualSort: [ 1, 2, 3 ],\n    fname_lname_start_year_end_year: [\n      \"george,washington,1789,1797\",\n      \"john,adams,1797,1801\",\n      \"thomas,jefferson,1801,1809\"\n    ],\n    gristHelper_Import_fname_lname_start_year_end_year: [\n      \"george,washington,1789,1797\",\n      \"john,adams,1797,1801\",\n      \"thomas,jefferson,1801,1809\" ],\n  }];\n\n  const expectedCommaSeparatedNoHeadersData = [ \"TableData\", \"GristHidden_import\", [ 1, 2, 3, 4 ], {\n    manualSort: [ 1, 2, 3, 4 ],\n    A: [ \"fname\", \"george\", \"john\", \"thomas\" ],\n    B: [ \"lname\", \"washington\", \"adams\", \"jefferson\" ],\n    C: [ \"start_year\", \"1789\", \"1797\", \"1801\" ],\n    D: [ \"end_year\", \"1797\", \"1801\", \"1809\" ],\n    gristHelper_Import_A: [ \"fname\", \"george\", \"john\", \"thomas\" ],\n    gristHelper_Import_B: [ \"lname\", \"washington\", \"adams\", \"jefferson\" ],\n    gristHelper_Import_C: [ \"start_year\", \"1789\", \"1797\", \"1801\" ],\n    gristHelper_Import_D: [ \"end_year\", \"1797\", \"1801\", \"1809\" ]\n  }];\n\n  const expectedDestinationData = [ \"TableData\", \"UploadedData1\", [ 1, 2, 3 ], {\n    manualSort: [ 1, 2, 3 ],\n    Name: [ \"Lily\", \"Kathy\", \"Karen\" ],\n    Phone: [ \"Jones\", \"Mills\", \"Gold\" ],\n    Title: [ \"director\", \"student\", \"professor\" ]\n  }];\n\n  const expectedFinalDestinationData = [ \"TableData\", \"UploadedData1\", [ 1, 2, 3, 4, 5, 6 ], {\n    manualSort: [ 1, 2, 3, 4, 5, 6 ],\n    Name: [ \"Lily\", \"Kathy\", \"Karen\", \"George\", \"John\", \"Thomas\" ],\n    Phone: [ \"Jones\", \"Mills\", \"Gold\", \"Washington\", \"Adams\", \"Jefferson\" ],\n    Title: [ \"director\", \"student\", \"professor\", \"\", \"\", \"\" ]\n  }];\n\n  const expectedDestinationData2 = [ \"TableData\", \"UploadedData2\", [ 1, 2, 3, 4, 5, 6 ], {\n    manualSort: [ 1, 2, 3, 4, 5, 6 ],\n    CourseId: [ \"BUS100\", \"BUS102\", \"BUS300\", \"BUS301\", \"BUS500\", \"BUS540\" ],\n    CourseName: [\n      \"Intro to Business\", \"Business Law\", \"Business Operations\",\n      \"History of Business\", \"Ethics and Law\", \"Capstone\"\n    ],\n    Instructor: [ \"\", \"Nathalie Patricia\", \"Michael Rian\", \"Mariyam Melania\", \"Filip Andries\", \"\" ],\n    StartDate: [ 1610496000, 1610496000, 1610582400, 1610582400, 1610496000, 1610496000 ],\n    PassFail: [ false, false, false, false, false, true ]\n  }];\n\n  const expectedFinalDestinationData2 = [ \"TableData\", \"UploadedData2\", [ 1, 2, 3, 4, 5, 6, 7, 8 ], {\n    manualSort: [ 1, 2, 3, 4, 5, 6, 7, 8 ],\n    CourseId: [ \"BUS100\", \"BUS102\", \"BUS300\", \"BUS301\", \"BUS500\", \"BUS540\", \"BUS501\", \"BUS539\" ],\n    CourseName: [\n      \"Intro to Business\", \"Business Law\", \"Business Operations\",\n      \"History of Business\", \"Ethics and Law\", \"Capstone\", \"Marketing\", \"Independent Study\"\n    ],\n    Instructor: [\n      \"Mariyam Melania\", \"Nathalie Patricia\", \"Michael Rian\", \"Mariyam Melania\",\n      \"Filip Andries\", \"\", \"Michael Rian\", \"\"\n    ],\n    StartDate: [ 1610496000, 1610496000, 1610582400, 1610582400, 1610496000, 1610496000, 1610496000, 1610496000 ],\n    PassFail: [ false, false, false, false, false, false, false, true ]\n  }];\n\n  const expectedComparisonData = {\n    left: {n: 0, h: \"\"},\n    right: {n: 0, h: \"\"},\n    parent: null,\n    summary: \"right\",\n    details: {\n      leftChanges: {\n        tableRenames: [],\n        tableDeltas: {}\n      },\n      rightChanges: {\n        tableRenames: [],\n        tableDeltas: {\n          GristHidden_import: {\n            removeRows: [],\n            updateRows: [1, 5, 6, 7, 8],\n            addRows: [],\n            columnRenames: [],\n            columnDeltas: {\n              gristHelper_Import_CourseId: {\n                \"6\": [[\"\"], [\"BUS501\"]], \"7\": [[\"\"], [\"BUS539\"]]\n              },\n              gristHelper_Import_CourseName: {\n                \"5\": [[\"Ethics and Law\"], [\"Ethics and Law\"]], // Same because source has a blank value and destination does not.\n                \"6\": [[\"\"], [\"Marketing\"]],\n                \"7\": [[\"\"], [\"Independent Study\"]]\n              },\n              gristHelper_Import_Instructor: { \"1\": [[\"\"], [\"Mariyam Melania\"]], \"6\": [[\"\"], [\"Michael Rian\"]], \"7\": [[\"\"], [\"\"]] }, gristHelper_Import_StartDate: { \"6\": [[\"\"], [1610496000]], \"7\": [[\"\"], [1610496000]] },\n              gristHelper_Import_PassFail: { \"6\": [[\"\"], [false]], \"7\": [[\"\"], [true]], \"8\": [[true], [false]] }\n            },\n          }\n        }\n      }\n    }\n  };\n\n  const expectedComparisonData2 = {\n    left: {n: 0, h: \"\"},\n    right: {n: 0, h: \"\"},\n    parent: null,\n    summary: \"right\",\n    details: {\n      leftChanges: {\n        tableRenames: [],\n        tableDeltas: {}\n      },\n      rightChanges: {\n        tableRenames: [],\n        tableDeltas: {\n          GristHidden_import: {\n            removeRows: [],\n            updateRows: [1, 2, 3, 4, 5, 6, 7, 8],\n            addRows: [],\n            columnRenames: [],\n            columnDeltas: {\n              gristHelper_Import_CourseId: { \"6\": [[\"\"], [\"BUS501\"]], \"7\": [[\"\"], [\"BUS539\"]] },\n              gristHelper_Import_CourseName: {\n                \"1\": [[\"Intro to Business\"], [\"INTRO TO BUSINESS\"]],\n                \"2\": [[\"Business Law\"], [\"BUSINESS LAW\"]],\n                \"3\": [[\"Business Operations\"], [\"BUSINESS OPERATIONS\"]],\n                \"4\": [[\"History of Business\"], [\"HISTORY OF BUSINESS\"]],\n                \"5\": [[\"Ethics and Law\"], [\"Ethics and Law\"]],\n                \"6\": [[\"\"], [\"MARKETING\"]],\n                \"7\": [[\"\"], [\"INDEPENDENT STUDY\"]],\n                \"8\": [[\"Capstone\"], [\"CAPSTONE\"]]\n              },\n              gristHelper_Import_Instructor: {\n                \"1\": [[\"\"], [\"mariyam melania\"]],\n                \"2\": [[\"Nathalie Patricia\"], [\"nathalie patricia\"]],\n                \"3\": [[\"Michael Rian\"], [\"michael rian\"]],\n                \"4\": [[\"Mariyam Melania\"], [\"mariyam melania\"]],\n                \"5\": [[\"Filip Andries\"], [\"filip andries\"]],\n                \"6\": [[\"\"], [\"michael rian\"]],\n                \"7\": [[\"\"], [\"\"]] },\n              gristHelper_Import_StartDate: { \"6\": [[\"\"], [1610496000]], \"7\": [[\"\"], [1610496000]] },\n              gristHelper_Import_PassFail: { \"6\": [[\"\"], [false]], \"7\": [[\"\"], [true]], \"8\": [[true], [false]] }\n            },\n          }\n        }\n      }\n    }\n  };\n\n  const fakeSession = makeOptDocSession(null);\n  fakeSession.authorizer = new DummyAuthorizer(\"editors\", \"doc\");\n\n  function assertDocTables(activeDoc, expectedTableIds) {\n    return activeDoc.fetchTable(docSession, \"_grist_Tables\")\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData[3].tableId, expectedTableIds));\n  }\n\n  function createDataSource(activeDoc, srcPath) {\n    return getFileUploadInfo(srcPath)\n      .then(fileUploadInfo => {\n        const uploadId = globalUploadSet.registerUpload([fileUploadInfo], null, _.noop, null);\n        return {uploadId, transforms: []};\n      });\n  }\n\n  it(\"should reimport files and remove all hidden tables if canceled or re imported\", () => {\n    let activeDoc;\n    let dataSource;\n    return docTools.createDoc(\"dummy\").then(adoc => { activeDoc = adoc; })\n      .then(() => createDataSource(activeDoc, csvPath))\n      .then(dataSrc => dataSource = dataSrc)\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {}, []))\n\n    // ensure that imported table has special name\n      .then(tableInfo => assert.deepEqual(tableInfo.tables, [\n        {\n          \"uploadFileIndex\": 0,\n          \"destTableId\": null,\n          \"hiddenTableId\": \"GristHidden_import\",\n          \"origTableName\": \"\",\n          \"transformSectionRef\": 4\n        }\n      ]\n      ))\n\n    // ensure that correct temporary hidden tables got created, and have correct data.\n      .then(() => assertDocTables(activeDoc, [\"GristHidden_import\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedCommaSeparatedData))\n\n    // Re-import from the same source data.\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {\"delimiter\": \"|\"},\n        [\"GristHidden_import\"]))\n\n    // check that after reimport the new temporary table was created with the same name because\n    // an old one was deleted\n      .then(tableInfo => assert.deepEqual(tableInfo.tables, [\n        {\n          \"uploadFileIndex\": 0,\n          \"destTableId\": null,\n          \"hiddenTableId\": \"GristHidden_import\",\n          \"origTableName\": \"\",\n          \"transformSectionRef\": 4\n        }\n      ]\n      ))\n\n    // checking that reimported table contains correct data, now parsed differently.\n      .then(() => assertDocTables(activeDoc, [\"GristHidden_import\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedPipeSeparatedData))\n\n    // Cancel import.\n      .then(() => activeDoc.cancelImportFiles(fakeSession, dataSource.uploadId, [\"GristHidden_import\"]))\n\n    // ensure that after canceling import temporary table was deleted\n      .then(() => assertDocTables(activeDoc, []));\n  });\n\n  it(\"should finish import files and remove all hidden tables on 'Import File'\", () => {\n    let activeDoc;\n    let dataSource;\n    return docTools.createDoc(\"temp\").then(adoc => { activeDoc = adoc; })\n      .then(() => createDataSource(activeDoc, csvPath))\n      .then(dataSrc => dataSource = dataSrc)\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {}, []))\n\n    // ensure that imported table has special name, and exists with correct data.\n      .then(tableInfo => assert.deepEqual(tableInfo.tables, [\n        {\n          \"uploadFileIndex\": 0,\n          \"destTableId\": null,\n          \"hiddenTableId\": \"GristHidden_import\",\n          \"origTableName\": \"\",\n          \"transformSectionRef\": 4\n        }\n      ]\n      ))\n      .then(() => assertDocTables(activeDoc, [\"GristHidden_import\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedCommaSeparatedData))\n\n    // Finish import\n      .then(() => activeDoc.finishImportFiles(fakeSession, dataSource, [\"GristHidden_import\"],\n        {\"parseOptions\": {\"delimiter\": \",\"}}))\n      .then(tableInfo => assert.deepEqual(tableInfo.tables, [\n        {\n          \"uploadFileIndex\": 0,\n          \"destTableId\": null,\n          \"hiddenTableId\": \"FileUploadData\",\n          \"origTableName\": \"\",\n          \"transformSectionRef\": -1 //TODO: FINISH IMPORT DOESNT MAKE TRANSFORM SECTION!!! is this ok?\n        }\n      ]\n      ))\n    // ensure that after finishing import temporary table was replaced with a new regular table.\n      .then(() => assertDocTables(activeDoc, [\"FileUploadData\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"FileUploadData\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedFinalCommaSeparatedData));\n  });\n\n  it(\"should apply transform rules and reimport files\", function() {\n    let activeDoc;\n    let dataSourceTransformed;\n    return docTools.createDoc(\"temp(7)\").then(adoc => { activeDoc = adoc; })\n      .then(() => createDataSource(activeDoc, csvPath))\n      .then(dataSrc => {\n        dataSourceTransformed = dataSrc;\n        dataSourceTransformed.transforms[0] = {\"\": {\n          destTableId: null,\n          destCols: [\n            {label: \"fname\",      colId: null, type: \"Text\", formula: \"$fname.capitalize()\"},\n            {label: \"lname\",      colId: null, type: \"Text\", formula: \"$lname.capitalize()\"},\n            {label: \"start_year\", colId: null, type: \"Int\", formula: \"$start_year\"},\n            {label: \"end_year\",   colId: null, type: \"Int\", formula: \"$end_year\"}],\n          sourceCols: [\"fname\", \"lname\", \"start_year\", \"end_year\"]\n        }};\n      })\n    // Import using transform rules\n      .then(() => activeDoc.importFiles(fakeSession, dataSourceTransformed, {}, []))\n    // Ensure that reimported table contains correct data and applied rules.\n      .then(() => assertDocTables(activeDoc, [\"GristHidden_import\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n\n      .then(tableData => assert.deepEqual(tableData, expectedTransformedData))\n    // Re-import again using transform rules\n      .then(() => activeDoc.importFiles(fakeSession, dataSourceTransformed, {}, [\"GristHidden_import\"]))\n    // Ensure that reimported table contains correct data and applied rules.\n      .then(() => assertDocTables(activeDoc, [\"GristHidden_import\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n\n      .then(tableData => assert.deepEqual(tableData, expectedTransformedData))\n\n    // Change delimiter which will change table schema and re-import\n      .then(() => activeDoc.importFiles(fakeSession, dataSourceTransformed, {delimiter: `|`}, [\"GristHidden_import\"]))\n      .then(() => assertDocTables(activeDoc, [\"GristHidden_import\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n    // Ensure that rules wasn't applied because schema was changed\n    // (reimpored table has only one column, rules have information about three columns)\n      .then(tableData => assert.deepEqual(tableData, expectedPipeSeparatedData))\n    // Cancel import.\n      .then(() => activeDoc.cancelImportFiles(\n        fakeSession, dataSourceTransformed.uploadId, [\"GristHidden_import\"])\n      );\n  });\n\n  it(\"should apply transform rules and finish import files into new table\", function() {\n    let activeDoc;\n    let dataSource;\n    let dataSourceTransformed;\n    return docTools.createDoc(\"temp(8)\").then(adoc => { activeDoc = adoc; })\n      .then(() => createDataSource(activeDoc, csvPath))\n      .then(dataSrc => {\n        dataSource = dataSrc;\n        dataSourceTransformed = dataSrc;\n        dataSourceTransformed.transforms[0] = {\"\": {\n          destTableId: null,\n          destCols: [\n            {label: \"fname\",      colId: null, type: \"Text\", formula: \"$fname.capitalize()\"},\n            {label: \"lname\",      colId: null, type: \"Text\", formula: \"$lname.capitalize()\"},\n            {label: \"start_year\", colId: null, type: \"Int\", formula: \"$start_year\"},\n            {label: \"end_year\",   colId: null, type: \"Int\", formula: \"$end_year\"}],\n          sourceCols: [\"fname\", \"lname\", \"start_year\", \"end_year\"]\n        }};\n      })\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {}, []))\n    // Re-import using transform rules\n      .then(() => activeDoc.finishImportFiles(fakeSession, dataSourceTransformed, [\"GristHidden_import\"], \"\"))\n    // checking that reimported table contains correct data, now applied rules.\n      .then(() => assertDocTables(activeDoc, [\"FileUploadData\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"FileUploadData\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedFinalTransformedData));\n  });\n\n  it(\"should apply transform rules and finish import files into existing table\", function() {\n    let activeDoc;\n    let dataSource;\n    let dataSourceTransformed;\n    return docTools.createDoc(\"temp(9)\").then(adoc => { activeDoc = adoc; })\n    // import destination table first\n      .then(() => createDataSource(activeDoc, csvPath1))\n      .then(ds => activeDoc.finishImportFiles(fakeSession, ds, [], {}))\n      .then(() => assertDocTables(activeDoc, [\"UploadedData1\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"UploadedData1\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedDestinationData))\n      .then(() => createDataSource(activeDoc, csvPath))\n      .then(dataSrc => {\n        dataSource = dataSrc;\n        dataSourceTransformed = dataSrc;\n        dataSourceTransformed.transforms[0] = {\"\": {\n          destTableId: \"UploadedData1\",\n          destCols: [{label: \"Name\",  colId: \"gristHelper_Import_Name\",  type: \"Text\", formula: \"$fname.capitalize()\"},\n            {label: \"Phone\", colId: \"gristHelper_Import_Phone\", type: \"Text\", formula: \"$lname.capitalize()\"},\n            {label: \"Title\", colId: \"gristHelper_Import_Title\", type: \"Text\", formula: \"\"}],\n          sourceCols: [\"fname\", \"lname\", \"start_year\", \"end_year\"]\n        }};\n      })\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {}, []))\n    // Re-import using transform rules\n      .then(() => activeDoc.finishImportFiles(fakeSession, dataSourceTransformed, [\"GristHidden_import\"], \"\"))\n    // checking that updated table contains correct data, now with new data.\n      .then(() => assertDocTables(activeDoc, [\"UploadedData1\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"UploadedData1\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedFinalDestinationData));\n  });\n\n  it(\"should apply merge options and update existing records in destination table\", function() {\n    let activeDoc;\n    let dataSource;\n    let dataSourceTransformed;\n    return docTools.createDoc(\"temp(10)\").then(adoc => { activeDoc = adoc; })\n    // Import destination table first.\n      .then(() => createDataSource(activeDoc, csvPath2))\n      .then(ds => activeDoc.finishImportFiles(fakeSession, ds, [], {}))\n      .then(() => assertDocTables(activeDoc, [\"UploadedData2\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"UploadedData2\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedDestinationData2))\n      .then(() => createDataSource(activeDoc, extendedCsvPath2))\n      .then(dataSrc => {\n        dataSource = dataSrc;\n        dataSourceTransformed = dataSrc;\n        dataSourceTransformed.transforms[0] = {\"\": {\n          destTableId: \"UploadedData2\",\n          destCols: [{label: \"CourseId\", colId: \"gristHelper_Import_CourseId\",  type: \"Text\", formula: \"$CourseId\"},\n            {label: \"CourseName\", colId: \"gristHelper_Import_CourseName\", type: \"Text\", formula: \"$CourseName\"},\n            {label: \"Instructor\", colId: \"gristHelper_Import_Instructor\", type: \"Text\", formula: \"$Instructor\"},\n            {label: \"StartDate\", colId: \"gristHelper_Import_StartDate\", type: \"Date\", formula: \"$StartDate\"},\n            {label: \"PassFail\", colId: \"gristHelper_Import_PassFail\", type: \"Bool\", formula: \"$PassFail\"}],\n          sourceCols: [\"CourseId\", \"CourseName\", \"Instructor\", \"StartDate\", \"PassFail\"]\n        }};\n      })\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {}, []))\n    // Import from extended version of file, matching on CourseId.\n      .then(() => activeDoc.finishImportFiles(fakeSession, dataSourceTransformed, [\"GristHidden_import\"], {\n        mergeOptionMaps: [\n          {\"\": {mergeCols: [\"gristHelper_Import_CourseId\"], mergeStrategy: {type: \"replace-with-nonblank-source\"}}}\n        ]\n      }))\n    // Check that records in UploadedData2 were updated correctly.\n      .then(() => assertDocTables(activeDoc, [\"UploadedData2\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"UploadedData2\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedFinalDestinationData2));\n  });\n\n  it(\"should include column names as headers and back using parse option (headers were guessed initially)\", function() {\n    let activeDoc;\n    let dataSource;\n    return docTools.createDoc(\"dummy(10)\")\n      .then(adoc => { activeDoc = adoc; })\n      .then(() => createDataSource(activeDoc, csvPath))\n      .then(dataSrc => dataSource = dataSrc)\n    // default flow, ensure that headers were guessed and used\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {}, []))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedCommaSeparatedData))\n    // ensure that after unchecking option 'include_col_names_as_headers' column names became part of the table data\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {\"include_col_names_as_headers\": false},\n        [\"GristHidden_import\"]))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedCommaSeparatedNoHeadersData))\n    // ensure that after checking option 'include_col_names_as_headers' column names were used as headers again\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {\"include_col_names_as_headers\": true},\n        [\"GristHidden_import\"]))\n      .then(tableInfo => assert.deepEqual(tableInfo.options.include_col_names_as_headers, true))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedCommaSeparatedData));\n  });\n\n  it(\"should include column names as headers and back using parse option (headers weren't guessed initially)\", function() {\n    let activeDoc;\n    let dataSource;\n    return docTools.createDoc(\"dummy(10)\")\n      .then(adoc => { activeDoc = adoc; })\n      .then(() => createDataSource(activeDoc, csvPath3))\n      .then(dataSrc => dataSource = dataSrc)\n    // default flow, ensure that headers weren't guessed\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {}, []))\n      .then(tableInfo => assert.deepEqual(tableInfo.options.include_col_names_as_headers, false))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedNoHeadersData))\n    // ensure that after checking option 'include_col_names_as_headers' column names were used as headers\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {\"include_col_names_as_headers\": true},\n        [\"GristHidden_import\"]))\n      .then(tableInfo => assert.deepEqual(tableInfo.options.include_col_names_as_headers, true))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedHeadersFromFirstRowData))\n    // ensure that after unchecking option 'include_col_names_as_headers' column names became part of the table data\n      .then(() => activeDoc.importFiles(fakeSession, dataSource, {\"include_col_names_as_headers\": false},\n        [\"GristHidden_import\"]))\n      .then(tableInfo => assert.deepEqual(tableInfo.options.include_col_names_as_headers, false))\n      .then(() => activeDoc.fetchTable(docSession, \"GristHidden_import\"))\n      .then(result => result.tableData)\n      .then(tableData => assert.deepEqual(tableData, expectedNoHeadersData));\n  });\n\n  // returns an object that map original table ids to fixed ids.\n  function getFixedTableIdMap(tables) {\n    return _(tables).keyBy(\"origTableName\").mapValues(\"hiddenTableId\").value();\n  }\n\n  it(\"should fix references\", function() {\n    let activeDoc;\n    return docTools.createDoc(\"\").then(adoc => {activeDoc = adoc;})\n      .then(() => createDataSource(activeDoc, jsonPathWithDirtyTableName))\n      .then(dataSource => activeDoc.finishImportFiles(fakeSession, dataSource, [], {}))\n      .then(result => {\n        const fixedTableId = getFixedTableIdMap(result.tables);\n        const tables = activeDoc.docData.getTables();\n        let table;\n\n        table = tables.get(fixedTableId.dirtyNames);\n        assert.equal(table.getColType(\"dirty_name_\"), \"Ref:DirtyNames__dirty_name_\");\n\n        table = tables.get(fixedTableId[\"dirtyNames_**dirty_name**\"]);\n        assert.equal(table.getColType(\"a\"), \"Ref:DirtyNames__dirty_name__a\");\n      });\n  });\n\n  it(\"should fix references as well in hidden tables\", function() {\n    let activeDoc;\n    return docTools.createDoc(\"\").then(adoc => {activeDoc = adoc;})\n      .then(() => createDataSource(activeDoc, jsonPathWithDirtyTableName))\n      .then(dataSource => activeDoc.importFiles(fakeSession, dataSource, {}, []))\n      .then(result => {\n        const fixedTableId = getFixedTableIdMap(result.tables);\n        const tables = activeDoc.docData.getTables();\n        let table;\n\n        table = tables.get(fixedTableId.dirtyNames);\n        assert.equal(table.getColType(\"dirty_name_\"), \"Ref:\" + fixedTableId[\"dirtyNames_**dirty_name**\"]);\n        assert.equal(table.getColType(\"gristHelper_Import_dirty_name_\"), \"Ref:\" + fixedTableId[\"dirtyNames_**dirty_name**\"]);\n\n        table = tables.get(fixedTableId[\"dirtyNames_**dirty_name**\"]);\n        assert.equal(table.getColType(\"a\"), \"Ref:\" + fixedTableId[\"dirtyNames_**dirty_name**_a\"]);\n        assert.equal(table.getColType(\"gristHelper_Import_a\"), \"Ref:\" + fixedTableId[\"dirtyNames_**dirty_name**_a\"]);\n      });\n  });\n\n\n  it(\"should allow empty data\", function() {\n    let activeDoc;\n    return docTools.createDoc(\"\").then(adoc => {activeDoc = adoc;})\n      .then(() => createDataSource(activeDoc, emptyData))\n      .then(dataSource => assert.isFulfilled(activeDoc.importFiles(fakeSession, dataSource, {}, [])));\n  });\n\n  describe(\"parsing\", function() {\n    const docTools = createDocTools({persistAcrossCases: true});\n    let activeDoc, tmpDir;\n\n    before(function() {\n      return docTools.createDoc(\"temp-parsing\").then(adoc => { activeDoc = adoc; })\n        .then(() => activeDoc.docPluginManager.tmpDir()).then(t => { tmpDir = t; });\n    });\n\n    // Returns absPath suitable for parsing (moved to the pluginManager's tmpDir.\n    function parseFile(path, origName=null) {\n      return createDataSource(activeDoc, path)\n        .then(dataSource => {\n          const upload = globalUploadSet.getUploadInfo(dataSource.uploadId, null);\n          return moveUpload(upload, tmpDir)\n            .then(() => upload.files[0]);\n        })\n        .then(file => activeDoc.docPluginManager.parseFile(file.absPath, origName || file.origName, {}));\n    }\n\n    it(\"should parse csv imports\", function() {\n      return parseFile(csvPath)\n        .then(result => {\n          const tables = result.tables;\n          assert.deepEqual(tables.map(t => t.table_name), [null]);\n          assert.deepEqual(tables[0].column_metadata.map(c => c.id),\n            [\"fname\", \"lname\", \"start_year\", \"end_year\"]);\n          // The Python CSV parsing doesn't parse numbers to strings, that happens later.\n          assert.deepEqual(tables[0].table_data[2],\n            [\"1789\", \"1797\", \"1801\"]);\n        });\n    });\n\n    it(\"should parse xlsx imports\", function() {\n      return parseFile(xlsxPath)\n        .then(result => {\n          const tables = result.tables;\n          assert.deepEqual(tables.map(t => t.table_name),\n            [\"Homicide counts and rates (2000\", \"Sheet1\"]);\n          assert.deepEqual(tables[0].column_metadata.map(c => c.id),\n            [\"Region\", \"Sub Region\", \"Country/ Territory\", \"Source\", \"Indicator\", \"'00\",\n              \"'01\", \"'02\", \"'03\", \"'04\", \"'05\", \"'06\", \"'07\", \"'08\", \"'09\", \"'10\", \"'11\",\n              \"'12\", \"'13\"]);\n          assert.deepEqual(tables[1].column_metadata.map(c => c.id), [\"Name\", \"Value\"]);\n          assert.deepEqual(tables[1].table_data, [[\"Test of xlsx\"], [-1.2]]);\n        });\n    });\n\n    it(\"should parse jgrist imports\", function() {\n      return parseFile(jgristPath)\n        .then(result => {\n          const tables = result.tables;\n          assert.deepEqual(tables.map(t => t.table_name), [\"city\"]);\n          assert.deepEqual(tables[0].column_metadata.map(c => c.id),\n            [\"id\", \"city\"]);\n          assert.deepEqual(tables[0].table_data[1],\n            [\"Berlin\", \"Tokyo\"]);\n        });\n    });\n\n    it(\"should reject broken jgrist imports\", function() {\n      return assert.isRejected(parseFile(jgristBrokenPath),\n        /Grist json format could not be parsed.*not a GristTable/);\n    });\n\n    it(\"should parse a simple json array\", function() {\n      return parseFile(simpleArrayJsonPath)\n        .then(result => {\n          const tables = result.tables;\n          assert.deepEqual(tables[0], {\n            table_name: \"simple_array\",\n            column_metadata: [{id: \"a\", type: \"Numeric\"}, {id: \"b\", type: \"Text\"}],\n            table_data: [[1, 4], [\"baba\", \"abab\"]]\n          });\n        });\n    });\n\n    it(\"should parse a more complex json file\", function() {\n      return parseFile(moreComplexJsonPath, \"my_spotify.json\")\n        .then(result => {\n          const tables = result.tables;\n          assert.deepEqual(tables.map(t => t.table_name).sort(), [\n            \"my_spotify\",\n            \"my_spotify_albums\",\n            \"my_spotify_albums_artists\",\n            \"my_spotify_albums_artists_external_urls\",\n            \"my_spotify_albums_available_markets\",\n            \"my_spotify_albums_external_ids\",\n            \"my_spotify_albums_copyrights\",\n            \"my_spotify_albums_external_urls\",\n            \"my_spotify_albums_images\",\n            \"my_spotify_albums_tracks\",\n            \"my_spotify_albums_tracks_items\",\n            \"my_spotify_albums_tracks_items_artists\", // todo: user should be able to merge this table into 'albums_artists'\n            \"my_spotify_albums_tracks_items_artists_external_urls\",\n            \"my_spotify_albums_tracks_items_available_markets\",\n            \"my_spotify_albums_tracks_items_external_urls\"].sort());\n        });\n    });\n\n    it(\"should parse a xlsx file with boolean data\", function() {\n      return parseFile(booleanData).then(result => {\n        const tables = result.tables;\n        assert.deepEqual(tables.map(t => t.table_name), [\"Book1\"]);\n        assert.deepEqual(tables[0].column_metadata.map(c => c.id),\n          [\"\"]);\n        assert.deepEqual(tables[0].table_data, [[5, 5, 1]]);\n      });\n    });\n\n    it(\"should preserve original column headers when importing a csv file\", function() {\n      return parseFile(csvPathWithUnicodeHeaders)\n        .then(result => {\n          const tables = result.tables;\n          assert.deepEqual(\n            tables[0].column_metadata.map(c => c.id),\n            [\n              \"Բարեւ աշխարհ\",\n              \"Γειά σου Κόσμε\",\n              \"123 test\",\n              \"สวัสดีชาวโลก\",\n              \"こんにちは世界\",\n              \"नमस्ते दुनिया\",\n              \"გამარჯობა მსოფლიო\",\n              \"你好世界\",\n              \"% test\",\n            ]\n          );\n        });\n    });\n\n    it(\"should preserve original column headers when importing a xlsx file\", function() {\n      return parseFile(xlsxPathWithUnicodeHeaders)\n        .then(result => {\n          const tables = result.tables;\n          assert.deepEqual(tables[0].column_metadata.map(c => c.id),\n            [\n              \"% test\",\n              \"你好世界\",\n              \"გამარჯობა მსოფლიო\",\n              \"नमस्ते दुनिया\",\n              \"Բարեւ աշխարհ\",\n              \"Γειά σου Κόσμε\",\n              \"123 test\",\n              \"สวัสดีชาวโลก\",\n              \"こんにちは世界\",\n            ]\n          );\n        });\n    });\n\n    it(\"should reject empty Excel files with a clear message\", function() {\n      return assert.isRejected(parseFile(xlsxEmpty),\n        /No tables found \\(1 empty tables skipped\\)/);\n    });\n\n    it(\"should add document timezone to DateTime columns when importing xlsx files\", async function() {\n      const activeDoc = await docTools.createDoc(\"temp(11)\");\n      await activeDoc.applyUserActions(fakeSession, [[\"UpdateRecord\", \"_grist_DocInfo\", 1, {\n        timezone: \"America/New_York\",\n      }]]);\n      const dataSource = await createDataSource(activeDoc, dateTimeData);\n      await activeDoc.importFiles(fakeSession, dataSource, {}, []);\n      const metaTables = await activeDoc.fetchMetaTables(docSession);\n      const columns = metaTables[\"_grist_Tables_column\"][3];\n      assert.deepEqual(columns.colId, [\n        \"manualSort\",\n        \"A\",\n        \"gristHelper_Import_A\",\n      ]);\n      assert.deepEqual(columns.type, [\n        \"ManualSortPos\",\n        \"DateTime:America/New_York\",\n        \"DateTime:America/New_York\",\n      ]);\n      await activeDoc.finishImportFiles(fakeSession, dataSource, [], {});\n      const result = await activeDoc.fetchTable(docSession, \"Sheet1\");\n      const tableData = result.tableData;\n      assert.deepEqual(tableData[3], {\n        A: [\n          1041487323,\n          1041573723,\n          1041660123,\n        ],\n        manualSort: [1, 2, 3],\n      });\n    });\n  });\n\n  describe(\"generateImportDiff\", function() {\n    it(\"should return comparison data containing the table delta pre-merge\", function() {\n      let activeDoc;\n      let transformRule;\n      let dataSource;\n      let dataSourceTransformed;\n      return docTools.createDoc(\"temp(12)\").then(adoc => { activeDoc = adoc; })\n      // Import destination table first.\n        .then(() => createDataSource(activeDoc, csvPath2))\n        .then(ds => activeDoc.finishImportFiles(fakeSession, ds, [], {}))\n        .then(() => assertDocTables(activeDoc, [\"UploadedData2\"]))\n        .then(() => activeDoc.fetchTable(docSession, \"UploadedData2\"))\n        .then(result => result.tableData)\n        .then(tableData => assert.deepEqual(tableData, expectedDestinationData2))\n        .then(() => createDataSource(activeDoc, extendedCsvPath2))\n        .then(dataSrc => {\n          dataSource = dataSrc;\n          dataSourceTransformed = dataSrc;\n          transformRule = {\n            destTableId: \"UploadedData2\",\n            destCols: [{label: \"CourseId\", colId: \"gristHelper_Import_CourseId\",  type: \"Text\", formula: \"$CourseId\"},\n              {label: \"CourseName\", colId: \"gristHelper_Import_CourseName\", type: \"Text\", formula: \"$CourseName\"},\n              {label: \"Instructor\", colId: \"gristHelper_Import_Instructor\", type: \"Text\", formula: \"$Instructor\"},\n              {label: \"StartDate\", colId: \"gristHelper_Import_StartDate\", type: \"Date\", formula: \"$StartDate\"},\n              {label: \"PassFail\", colId: \"gristHelper_Import_PassFail\", type: \"Bool\", formula: \"$PassFail\"}],\n            sourceCols: [\"CourseId\", \"CourseName\", \"Instructor\", \"StartDate\", \"PassFail\"]\n          };\n          dataSourceTransformed.transforms[0] = {\"\": transformRule};\n        })\n        .then(() => activeDoc.importFiles(fakeSession, dataSource, {}, []))\n      // Generate a diff of importing with merge column set to CourseId.\n        .then(() => activeDoc.generateImportDiff(fakeSession, \"GristHidden_import\", transformRule, {\n          mergeCols: [\"gristHelper_Import_CourseId\"], mergeStrategy: {type: \"replace-with-nonblank-source\"}\n        }))\n      // Check that the returned comparison data is correct.\n        .then(comparison => assert.deepEqual(comparison, expectedComparisonData));\n    });\n\n    it(\"should respect transform rule formulas when generating comparison data\", function() {\n      let activeDoc;\n      let transformRule;\n      let dataSource;\n      let dataSourceTransformed;\n      return docTools.createDoc(\"temp(13)\").then(adoc => { activeDoc = adoc; })\n      // Import destination table first.\n        .then(() => createDataSource(activeDoc, csvPath2))\n        .then(ds => activeDoc.finishImportFiles(fakeSession, ds, [], {}))\n        .then(() => assertDocTables(activeDoc, [\"UploadedData2\"]))\n        .then(() => activeDoc.fetchTable(docSession, \"UploadedData2\"))\n        .then(result => result.tableData)\n        .then(tableData => assert.deepEqual(tableData, expectedDestinationData2))\n        .then(() => createDataSource(activeDoc, extendedCsvPath2))\n        .then(dataSrc => {\n          dataSource = dataSrc;\n          dataSourceTransformed = dataSrc;\n          transformRule = {\n            destTableId: \"UploadedData2\",\n            destCols: [{label: \"CourseId\", colId: \"gristHelper_Import_CourseId\",  type: \"Text\", formula: \"$CourseId\"},\n              {label: \"CourseName\", colId: \"gristHelper_Import_CourseName\", type: \"Text\", formula: \"$CourseName.upper()\"},\n              {label: \"Instructor\", colId: \"gristHelper_Import_Instructor\", type: \"Text\", formula: \"$Instructor.lower()\"},\n              {label: \"StartDate\", colId: \"gristHelper_Import_StartDate\", type: \"Date\", formula: \"$StartDate\"},\n              {label: \"PassFail\", colId: \"gristHelper_Import_PassFail\", type: \"Bool\", formula: \"$PassFail\"}],\n            sourceCols: [\"CourseId\", \"CourseName\", \"Instructor\", \"StartDate\", \"PassFail\"]\n          };\n          dataSourceTransformed.transforms[0] = {\"\": transformRule};\n        })\n        .then(() => activeDoc.importFiles(fakeSession, dataSource, {}, []))\n      // Generate a diff of importing with merge column set to CourseId.\n        .then(() => activeDoc.generateImportDiff(fakeSession, \"GristHidden_import\", transformRule, {\n          mergeCols: [\"gristHelper_Import_CourseId\"], mergeStrategy: {type: \"replace-with-nonblank-source\"}\n        }))\n      // Check that the returned comparison data is correct.\n        .then(comparison => assert.deepEqual(comparison, expectedComparisonData2));\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/ActiveDocShutdown.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { Role } from \"app/common/roles\";\nimport { ParseFileResult } from \"app/plugin/FileParserAPI\";\nimport { ActionHistoryImpl } from \"app/server/lib/ActionHistoryImpl\";\nimport { ActiveDoc, Deps } from \"app/server/lib/ActiveDoc\";\nimport { AuthSession } from \"app/server/lib/AuthSession\";\nimport { Client } from \"app/server/lib/Client\";\nimport { DummyAuthorizer } from \"app/server/lib/DocAuthorizer\";\nimport { DocPluginManager } from \"app/server/lib/DocPluginManager\";\nimport { DocSession, DocSessionPrecursor, makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport { createDocTools, createUpload } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport * as sqlite3 from \"@gristlabs/sqlite3\";\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\n// This makes just enough of a Client to use with ActiveDoc.addClient() and ActiveDoc.closeDoc().\nfunction _makeFakeClient(): Client {\n  const addDocSession = sinon.stub().callsFake(function(adoc: ActiveDoc, optDocSession: DocSessionPrecursor) {\n    return new DocSession(optDocSession, adoc, 0);\n  });\n  const removeDocSession = sinon.spy();\n  const getLogMeta = sinon.spy();\n  const sendMessage = sinon.spy();\n  const sendMessageOrInterrupt = sinon.spy();\n  return {\n    addDocSession,\n    removeDocSession,\n    getLogMeta,\n    sendMessage,\n    sendMessageOrInterrupt,\n    authSession: AuthSession.unauthenticated(),\n  } as unknown as Client;\n}\n\ndescribe(\"ActiveDocShutdown\", function() {\n  testUtils.withoutSandboxing();\n  this.timeout(10000);\n\n  // Turn off logging for this test, and restore afterwards.\n  testUtils.setTmpLogLevel(process.env.VERBOSE ? \"debug\" : \"warn\");\n\n  const docTools = createDocTools();\n\n  // Reduce ActiveDoc timeout to shutdown to 0.5 sec, just for this test.\n  const sandbox = sinon.createSandbox();\n  const timeout = 500;\n  const tmpTimeoutSec = timeout / 1000;\n  beforeEach(function() {\n    sandbox.stub(Deps, \"ACTIVEDOC_TIMEOUT\").value(tmpTimeoutSec);\n  });\n\n  afterEach(function() {\n    sandbox.restore();\n    assert.isAbove(Deps.ACTIVEDOC_TIMEOUT, tmpTimeoutSec);   // Check that .restore() worked\n  });\n\n  it(\"should close ActiveDoc if there are no clients connected\", async function() {\n    const docName = \"active_doc_shutdown1\";\n    await docTools.createDoc(docName);\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n    await waitForIt(async () => assert.equal(docTools.getDocManager().numOpenDocs(), 0), 10 * timeout);\n  });\n\n  function makeDummySession(client: Client, role: Role | null, docId: string) {\n    const authorizer = new DummyAuthorizer(role, docId);\n    return new DocSessionPrecursor(client, authorizer, {});\n  }\n\n  it(\"should not close ActiveDoc while there are clients connected\", async function() {\n    const docName = \"active_doc_shutdown2\";\n    const adoc = await docTools.createDoc(docName);\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n\n    // Create and add one fake client.\n    const fakeClient1 = _makeFakeClient();\n    const docSession1 = adoc.addClient(fakeClient1, makeDummySession(fakeClient1, \"editors\", \"doc\"));\n    assert.equal((fakeClient1.addDocSession as sinon.SinonSpy).callCount, 1);\n\n    // Wait longer than the timeout and check that doc is still open.\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n    await delay(2 * timeout);\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n\n    // Create and add a second fake client.\n    const fakeClient2 = _makeFakeClient();\n    const docSession2 = adoc.addClient(fakeClient2, makeDummySession(fakeClient2, \"editors\", \"doc\"));\n    assert.equal((fakeClient2.addDocSession as sinon.SinonSpy).callCount, 1);\n\n    // \"Disconnect\" the first client.\n    await adoc.closeDoc(docSession1);\n    assert.equal((fakeClient1.removeDocSession as sinon.SinonSpy).callCount, 1);\n\n    // Wait longer than the timeout and check that doc is still open.\n    await delay(2 * timeout);\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n\n    // \"Disconnect\" the second client.\n    await adoc.closeDoc(docSession2);\n    assert.equal((fakeClient1.removeDocSession as sinon.SinonSpy).callCount, 1);\n\n    // The doc is still open for a while.\n    await delay(timeout / 2);\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n\n    // Check that doc eventually closes.\n    await waitForIt(async () => assert.equal(docTools.getDocManager().numOpenDocs(), 0), 10 * timeout);\n  });\n\n  it(\"should not close ActiveDoc while an import is pending\", async function() {\n    const _sandbox = sinon.createSandbox();\n    try {\n      // Stub parseFile(), which is used in the course of importing, with a function that returns\n      // an empty result but takes a long time. We check that ActiveDoc doesn't get closed\n      // meanwhile.\n      _sandbox.stub(DocPluginManager.prototype, \"parseFile\").callsFake(async function(): Promise<ParseFileResult> {\n        await delay(timeout * 2);\n        return { parseOptions: {}, tables: [] };\n      });\n\n      // The accessId only matters in having to be the same to create and retrieve the upload.\n      const userId = 17;\n      const accessId = docTools.getDocManager().makeAccessId(userId);\n      const uploadId = await createUpload([\"foo\", \"bar\"], accessId);\n\n      const start = Date.now();\n      await docTools.getDocManager().importDocWithFreshId(makeExceptionalDocSession(\"nascent\"), userId, uploadId);\n      // Check that we stubbed the right thing above, and that this import indeed took as long as\n      // we expected.\n      assert.isAbove(Date.now() - start, timeout * 2);\n      // But the doc should still be open.\n      assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n\n      // Check that doc eventually closes.\n      await waitForIt(async () => assert.equal(docTools.getDocManager().numOpenDocs(), 0), 10 * timeout);\n    } finally {\n      // Restore the stubbed method.\n      _sandbox.restore();\n    }\n  });\n\n  it(\"should not close ActiveDoc while loading\", async function() {\n    sandbox.stub(Deps, \"ACTIVEDOC_TIMEOUT\").value(0.001);\n    const adoc = await docTools.loadFixtureDoc(\"World.grist\");\n    const session = docTools.createFakeSession();\n    const { tableData } = await adoc.fetchTable(session, \"Country\", true);\n    assert.equal(tableData[0], \"TableData\");\n    assert.lengthOf(tableData[2], 239);   // There are 239 countries in this doc\n  });\n\n  it(\"should not close ActiveDoc while using API\", async function() {\n    const adoc = await docTools.loadFixtureDoc(\"World.grist\");\n    const session = docTools.createFakeSession();\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n\n    // Use an API method a few times, and make sure the doc hasn't gotten closed.\n    for (let i = 0; i < 4; i++) {\n      await delay(timeout / 2);\n      assert.lengthOf((await adoc.fetchTable(session, \"Country\", true)).tableData[2], 239);\n    }\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n\n    // Same with another method.\n    for (let i = 0; i < 4; i++) {\n      await delay(timeout / 2);\n      await adoc.applyUserActions(session, [[\"UpdateRecord\", \"Country\", 1, { Name: \"Hello\" }]]);\n    }\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n\n    // Check that doc eventually closes.\n    await waitForIt(async () => assert.equal(docTools.getDocManager().numOpenDocs(), 0), 10 * timeout);\n  });\n\n  const infiniteLoopFormula = `\\\nc = 0\nwhile True:\n  c += 1\nreturn c\n`;\n\n  it(\"should close ActiveDoc in infinite loop after timeout\", async function() {\n    // Reduce the timeouts that affect this test.\n    const inactivityTimerMsec = 1000;\n    sandbox.stub(Deps, \"ACTIVEDOC_TIMEOUT\").value(inactivityTimerMsec / 1000);\n    sandbox.stub(Deps, \"KEEP_DOC_OPEN_TIMEOUT_MS\").value(1000);\n    sandbox.stub(Deps, \"SHUTDOWN_ITEM_TIMEOUT_MS\").value(1000);\n    const adoc = await docTools.createDoc(\"ActiveDocShutdown-Loop-Shutdown\");\n    const session = docTools.createFakeSession();\n    await adoc.applyUserActions(session, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\" }, { id: \"B\" }, { id: \"C\" }]],\n      [\"AddRecord\", \"Table1\", 1, {}],\n    ]);\n\n    const start = Date.now();\n\n    // Start a infinite-loop action that will never finish on its own.\n    const actionResult = adoc.applyUserActions(session,\n      [[\"AddColumn\", \"Table1\", \"Loop\", { isFormula: true, formula: infiniteLoopFormula }]])\n      .catch(err => err);\n\n    // Check that the doc is open.\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n\n    // We expect it to close soon. We wait longer, then check how long it actually took.\n    // Capture log to suppress expected warnings in the test (e.g. about failing user action).\n    await testUtils.captureLog(\"warn\", () =>\n      waitForIt(async () => assert.equal(docTools.getDocManager().numOpenDocs(), 0),\n        10_000, // how long to wait\n        100,    // step between checks\n      ),\n    );\n    const totalMsec = Date.now() - start;\n\n    // The applyUserActions call should have failed once we killed the sandbox.\n    assert.match((await actionResult).message, /PipeFromSandbox is closed/);\n\n    // Check how long this took.\n    const expectedTime = Deps.KEEP_DOC_OPEN_TIMEOUT_MS +  // Max wait for hanging applyUserActions\n      Deps.SHUTDOWN_ITEM_TIMEOUT_MS +  // Timeout for the hanging cleanup actions on shutdown\n      inactivityTimerMsec +   // Time after which ActiveDoc decides to shut down\n      1000;   // Hard-coded extra time NSandbox takes to kill an unresponsive process\n    assert.closeTo(totalMsec, expectedTime, 500);\n  });\n\n  it(\"should close ActiveDoc even if timeout is longer than current time updates\", async function() {\n    // Reduce the timeouts that affect this test.\n    const inactivityTimerMsec = 1000;\n    const updateTimeMsec = 500;\n    sandbox.stub(Deps, \"ACTIVEDOC_TIMEOUT\").value(inactivityTimerMsec / 1000);\n    sandbox.stub(Deps, \"KEEP_DOC_OPEN_TIMEOUT_MS\").value(1000);\n    sandbox.stub(Deps, \"SHUTDOWN_ITEM_TIMEOUT_MS\").value(1000);\n    sandbox.stub(Deps, \"UPDATE_CURRENT_TIME_DELAY\").value({ delayMs: updateTimeMsec, varianceMs: 0 });\n\n    // Create a doc, see that it's open.\n    const adoc = await docTools.createDoc(\"ActiveDocShutdown-UpdateCurrentTime\");\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n    const session = docTools.createFakeSession();\n\n    // Add a NOW() formula.\n    const timeBeforeAction = Date.now();\n    await adoc.applyUserActions(session, [\n      [\"AddTable\", \"Table1\", [{ id: \"Time\", type: \"DateTime\", isFormula: true, formula: \"NOW()\" }]],\n      [\"AddRecord\", \"Table1\", 1, {}],\n    ]);\n    const timeAfterAction = Date.now();\n\n    // Helper to get the value of the one cell with the formula, as msec since epoch.\n    async function getTimeCell() {\n      const tableAction = await adoc.fetchTable(session, \"Table1\", true);\n      return (tableAction.tableData[3].Time[0] as number) * 1000;\n    }\n\n    // Check that the formula has the expected timestamp.\n    const timeInCell = await getTimeCell();\n    assert.isAbove(timeInCell, timeBeforeAction);\n    assert.isBelow(timeInCell, timeAfterAction);\n\n    // Wait enough to get a time update, to make sure those work.\n    await waitForIt(async () => assert.isAbove(await getTimeCell(), timeInCell + updateTimeMsec / 2),\n      updateTimeMsec * 1.5,   // max wait\n      updateTimeMsec / 2);    // wait step\n\n    // The fetch in getTimeCell() keeps the doc open, but it should close after another\n    // inactivityTimerMsec (even though time updates are happening frequently).\n    await waitForIt(async () => assert.equal(docTools.getDocManager().numOpenDocs(), 0),\n      inactivityTimerMsec * 1.5,\n      inactivityTimerMsec / 4);\n  });\n\n  it(\"should force-reload ActiveDoc quickly even while in infinite loop, with a scheduled task\", async function() {\n    // Reduce the timeouts that affect this test. Keep inactivity timeout high, since it should\n    // *not* affect reload.\n    const inactivityTimerMsec = 10_000;\n    sandbox.stub(Deps, \"ACTIVEDOC_TIMEOUT\").value(inactivityTimerMsec / 1000);\n    sandbox.stub(Deps, \"SHUTDOWN_ITEM_TIMEOUT_MS\").value(1000);\n    sandbox.stub(Deps, \"UPDATE_CURRENT_TIME_DELAY\").value({ delayMs: 1000, varianceMs: 0 });\n\n    const adoc = await docTools.createDoc(\"ActiveDocShutdown-Loop-Reload\");\n    const session = docTools.createFakeSession();\n    await adoc.applyUserActions(session, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\" }, { id: \"B\" }, { id: \"C\" }]],\n      [\"AddRecord\", \"Table1\", 1, {}],\n    ]);\n\n    // Start a infinite-loop action that will never finish on its own.\n    const actionResult = adoc.applyUserActions(session,\n      [[\"AddColumn\", \"Table1\", \"Loop\", { isFormula: true, formula: infiniteLoopFormula }]])\n      .catch(err => err);\n\n    // Wait enough to get a time update to trigger, since that used to cause hangs.\n    await delay(Deps.UPDATE_CURRENT_TIME_DELAY.delayMs * 1.5);\n\n    assert.equal(docTools.getDocManager().numOpenDocs(), 1);\n\n    const start = Date.now();\n    await testUtils.captureLog(\"warn\", async (messages) => {\n      await adoc.reloadDoc();\n\n      // Check that we did trigger a time update, just to be sure we haven't failed to test that.\n      // It should fail by reloadDoc()'s return, but it's asynchronous, so allow a little wait.\n      await waitForIt(() =>\n        assert.isTrue(messages.some(m => m.includes(\"ActiveDoc failed to update current time\"))),\n      500, 50);\n    });\n\n    const totalMsec = Date.now() - start;\n\n    assert.equal(docTools.getDocManager().numOpenDocs(), 0);\n\n    // The applyUserActions call should have failed once we killed the sandbox.\n    assert.match((await actionResult).message, /PipeFromSandbox is closed/);\n\n    // Check how long this took.\n    const expectedTime = Deps.SHUTDOWN_ITEM_TIMEOUT_MS +  // Timeout for the hanging UpdateCurrentTime call.\n      Deps.SHUTDOWN_ITEM_TIMEOUT_MS +  // Timeout for the hanging RemoveStaleObjects action on shutdown\n      1000;   // Hard-coded extra time NSandbox takes to kill an unresponsive process\n    assert.closeTo(totalMsec, expectedTime, 500);\n  });\n\n  describe(\"_onInactive\", function() {\n    async function prepareVacuumableDoc() {\n      const adoc = await docTools.loadFixtureDoc(\"World-v0.grist\");\n      const docSession = docTools.createFakeSession(\"owners\");\n\n      // Remove the tables\n      await adoc.applyUserActions(docSession, [\n        [\"RemoveTable\", \"City\"],\n        [\"RemoveTable\", \"CountryLanguage\"],\n        [\"RemoveTable\", \"Country\"],\n      ]);\n\n      const hist = new ActionHistoryImpl(adoc.docStorage);\n      // Don't use deleteActions and use .wipe() instead so no VACUUM is requested.\n      await hist.wipe();\n      await hist.clearLocalActions();\n\n      return adoc;\n    }\n\n    const LONG_QUERY = `\n      WITH recursive recur(n) AS (\n        SELECT 1\n        UNION ALL\n        SELECT n + 1\n        FROM recur where n < 1000000\n      )\n      SELECT n FROM recur;\n    `;\n\n    it(\"should VACUUM a document before closing it\", async function() {\n      const adoc = await prepareVacuumableDoc();\n      const storageManager = docTools.getStorageManager();\n      const sizeBeforeShrink = await storageManager.getFsFileSize(adoc.docName);\n      await storageManager.flushDoc(adoc.docName);\n\n      const markAsChangedSpy = sandbox.spy(storageManager, \"markAsChanged\");\n      await (adoc as any)._onInactive();\n      const sizeAfterShrink = await storageManager.getFsFileSize(adoc.docName);\n      assert.isBelow(sizeAfterShrink, sizeBeforeShrink / 2, \"The new size should have drastically decreased\");\n      sinon.assert.calledOnceWithExactly(markAsChangedSpy, adoc.docName);\n    });\n\n    it(\"should not mark as changed if VACUUM does not reduce size significantly\", async function() {\n      // Open a doc, do nothing particular and close it\n      const adoc = await docTools.loadFixtureDoc(\"World-v0.grist\");\n      const storageManager = docTools.getStorageManager();\n      const markAsChangedSpy = sandbox.spy(storageManager, \"markAsChanged\");\n      await (adoc as any)._onInactive();\n      sinon.assert.notCalled(markAsChangedSpy);\n    });\n\n    it(\"should close the document anyway if the VACUUM fails\", async function() {\n      const adoc = await docTools.loadFixtureDoc(\"World-v0.grist\");\n      const isDocOpen = async () => Boolean(await docTools.getDocManager().getActiveDoc(adoc.docName));\n      assert.isTrue(await isDocOpen(), \"doc should be open\");\n\n      const storageManager = docTools.getStorageManager();\n      const error = new Error(\"whatever\");\n      (error as any).code = \"ENOENT\";\n      const markAsChangedSpy = sandbox.spy(storageManager, \"markAsChanged\");\n      sandbox.stub(storageManager, \"getFsFileSize\").rejects(error);\n      const onInactivePromise = (adoc as any)._onInactive();\n      await testUtils.captureLog(\"warn\", (messages) => {\n        return waitForIt(() => testUtils.assertMatchArray(messages, [/Vacuum on inactive.*no longer available/]),\n          10_000, 100);\n      });\n      await onInactivePromise;\n\n      sinon.assert.notCalled(markAsChangedSpy);\n      assert.isFalse(await isDocOpen(), \"doc should be closed\");\n    });\n\n    it(\"should successfully vacuum when other long queries are still running\", async function() {\n      const adoc = await prepareVacuumableDoc();\n      const storageManager = docTools.getStorageManager();\n      await storageManager.flushDoc(adoc.docName);\n      void (adoc.docStorage.getDB().all(LONG_QUERY)); // let's run the long query without awaiting it to finish\n      const markAsChangedSpy = sandbox.spy(storageManager, \"markAsChanged\");\n      await (adoc as any)._onInactive();\n      sinon.assert.calledOnceWithExactly(markAsChangedSpy, adoc.docName);\n    });\n\n    it(\"should successfully vacuum when other long queries are running while setting limit\", async function() {\n      const adoc = await prepareVacuumableDoc();\n      const storageManager = docTools.getStorageManager();\n      await storageManager.flushDoc(adoc.docName);\n      const waitStub = sandbox.stub(sqlite3.Database.prototype as any, \"wait\");\n      waitStub.callsFake(function(this: sqlite3.Database, callback?: (param: null) => void) {\n        waitStub.wrappedMethod.call(this, callback);\n\n        // let's add the long query right after having invoked wait() and before configure()\n        void (adoc.docStorage.getDB().all(LONG_QUERY));\n        return this;\n      });\n      const markAsChangedSpy = sandbox.spy(storageManager, \"markAsChanged\");\n      await (adoc as any)._onInactive();\n      sinon.assert.calledOnceWithExactly(markAsChangedSpy, adoc.docName);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/AppSettings.ts",
    "content": "import { AppSettings } from \"app/server/lib/AppSettings\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"AppSettings\", () => {\n  let appSettings: AppSettings;\n  let env: EnvironmentSnapshot;\n  beforeEach(() => {\n    appSettings = new AppSettings(\"test\");\n    env = new EnvironmentSnapshot();\n  });\n\n  afterEach(() => {\n    env.restore();\n  });\n\n  describe(\"for integers\", () => {\n    function testIntMethod(method: \"readInt\" | \"requireInt\") {\n      it(\"should throw an error if the value is less than the minimum\", () => {\n        process.env.TEST = \"4\";\n        assert.throws(() => {\n          appSettings[method]({ envVar: \"TEST\", minValue: 5 });\n        }, \"value 4 is less than minimum 5\");\n      });\n\n      it(\"should throw an error if the value is greater than the maximum\", () => {\n        process.env.TEST = \"6\";\n        assert.throws(() => {\n          appSettings[method]({ envVar: \"TEST\", maxValue: 5 });\n        }, \"value 6 is greater than maximum 5\");\n      });\n\n      it(\"should throw if the value is NaN\", () => {\n        process.env.TEST = \"not a number\";\n        assert.throws(() => appSettings[method]({ envVar: \"TEST\" }), \"not a number does not look like a number\");\n      });\n\n      it(\"should throw if the default value is not finite\", () => {\n        assert.throws(\n          () => appSettings[method]({ envVar: \"TEST\", defaultValue: Infinity }),\n          \"Infinity does not look like a number\",\n        );\n      });\n\n      it(\"should throw if the default value is not within the range\", () => {\n        assert.throws(\n          () => appSettings[method]({\n            envVar: \"TEST\",\n            defaultValue: 6,\n            minValue: 7,\n            maxValue: 9,\n          }),\n          \"value 6 is less than minimum 7\",\n        );\n      });\n\n      it(\"should return the default value if it is within the range\", () => {\n        const result = appSettings[method]({\n          envVar: \"TEST\",\n          defaultValue: 5,\n          minValue: 5,\n          maxValue: 12,\n        });\n        assert.strictEqual(result, 5);\n      });\n\n      it(\"should return the value if it is within the range\", () => {\n        process.env.TEST = \"5\";\n        assert.strictEqual(appSettings[method]({ envVar: \"TEST\", minValue: 5 }), 5);\n      });\n\n      it(\"should return the integer value of a float\", () => {\n        process.env.TEST = \"5.9\";\n        assert.strictEqual(appSettings[method]({ envVar: \"TEST\" }), 5);\n      });\n    }\n\n    describe(\"readInt()\", () => {\n      testIntMethod(\"readInt\");\n\n      it(\"should return undefined when no value nor default value is passed\", () => {\n        const result = appSettings.readInt({ envVar: \"TEST\", maxValue: 5 });\n        assert.isUndefined(result);\n      });\n    });\n\n    describe(\"requireInt()\", () => {\n      testIntMethod(\"requireInt\");\n\n      it(\"should throw if env variable is not set and no default value is passed\", () => {\n        assert.throws(() => appSettings.requireInt({ envVar: \"TEST\" }), \"missing environment variable: TEST\");\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/Archive.ts",
    "content": "import {\n  Archive,\n  ArchiveEntry,\n  ArchivePackingOptions,\n  create_tar_archive,\n  create_zip_archive,\n} from \"app/server/lib/Archive\";\nimport { MemoryWritableStream } from \"app/server/utils/streams\";\n\nimport * as stream from \"node:stream\";\n\nimport { assert } from \"chai\";\nimport decompress from \"decompress\";\n\nconst testFiles = [\n  {\n    name: \"Test1.txt\",\n    contents: \"This is the contents of a test file.\",\n  },\n  {\n    name: \"Test2.txt\",\n    contents: \"This is some other file.\",\n  },\n];\n\nasync function* generateTestArchiveContents() {\n  for (const file of testFiles) {\n    const buffer = Buffer.from(file.contents);\n    yield {\n      name: file.name,\n      size: buffer.length,\n      data: stream.Readable.from(buffer),\n    };\n  }\n}\n\nasync function assertArchiveContainsTestFiles(archiveData: Buffer) {\n  const files = await decompress(archiveData);\n  assert.equal(files.length, testFiles.length);\n\n  for (const testFile of testFiles) {\n    const zippedFile = files.find(file => file.path === testFile.name);\n    assert.notEqual(zippedFile, undefined);\n    assert.deepEqual(zippedFile?.data.toString(), testFile.contents);\n  }\n}\n\ntype ArchiveCreator = (entries: AsyncIterable<ArchiveEntry>, options?: ArchivePackingOptions) => Archive;\n\nfunction testArchive(type: string, makeArchive: ArchiveCreator) {\n  it(`should create a ${type} archive`, async function() {\n    const archive = makeArchive(generateTestArchiveContents());\n    const output = new MemoryWritableStream();\n    await archive.packInto(output);\n    const archiveData = output.getBuffer();\n    await assertArchiveContainsTestFiles(archiveData);\n  });\n\n  it(\"errors in archive.completed if the generator errors\", async function() {\n    async function* throwErrorGenerator() {\n      throw new Error(\"Test error\");\n      yield { name: \"Test\", size: 0, data: Buffer.from([]) };\n    }\n\n    // Shouldn't error here - as this just starts the packing.\n    const output = new MemoryWritableStream();\n    const archive = makeArchive(throwErrorGenerator());\n    await assert.isRejected(archive.packInto(output), \"Test error\");\n  });\n\n  it('respects the \"endDestStream\" option', async function() {\n    async function* throwErrorGenerator() {\n      throw new Error(\"Test error\");\n      yield { name: \"Test\", size: 0, data: Buffer.from([]) };\n    }\n\n    const test = async (archive: Archive, end: boolean, name: string) => {\n      const output = new MemoryWritableStream();\n      try {\n        await archive.packInto(output, { endDestStream: end });\n      } catch (err) {\n        // Do nothing, don't care about this error.\n      } finally {\n        assert.equal(output.closed, end, `expected ${name} to be ${end ? \"closed\" : \"open\"}`);\n      }\n    };\n\n    await test(makeArchive(generateTestArchiveContents()), false, \"successful archive\");\n    await test(makeArchive(generateTestArchiveContents()), true, \"successful archive\");\n    await test(makeArchive(throwErrorGenerator()), false, \"erroring archive\");\n    await test(makeArchive(throwErrorGenerator()), true, \"erroring archive\");\n  });\n}\n\ndescribe(\"Archive\", function() {\n  describe(\"create_zip_archive\", function() {\n    const creator: ArchiveCreator = entries => create_zip_archive({ store: true }, entries);\n    testArchive(\"zip\", creator);\n  });\n  describe(\"create_tar_archive\", function() {\n    const creator: ArchiveCreator = entries => create_tar_archive(entries);\n    testArchive(\"tar\", creator);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/AttachmentFileManager.ts",
    "content": "import {\n  AttachmentFileManager,\n  AttachmentRetrievalError,\n  StoreNotAvailableError,\n  UnknownDocumentPoolError,\n} from \"app/server/lib/AttachmentFileManager\";\nimport {\n  AttachmentFile,\n  getDocPoolIdFromDocInfo,\n  IAttachmentStore,\n} from \"app/server/lib/AttachmentStore\";\nimport {\n  AttachmentStoreProvider,\n  IAttachmentStoreConfig,\n  IAttachmentStoreProvider,\n} from \"app/server/lib/AttachmentStoreProvider\";\nimport { DocStorage, FileInfo } from \"app/server/lib/DocStorage\";\nimport { makeTestingFilesystemStoreConfig } from \"test/server/lib/FilesystemAttachmentStore\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport * as stream from \"node:stream\";\n\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\n// Minimum features of doc storage that are needed to make AttachmentFileManager work.\ntype IMinimalDocStorage = Pick<DocStorage,\n  \"docName\" | \"getFileInfo\" | \"getFileInfoNoData\" | \"attachFileIfNew\" | \"attachOrUpdateFile\" |\n  \"listAllFiles\" | \"requestVacuum\"\n>;\n\n// Implements the minimal functionality needed for the AttachmentFileManager to work.\nclass DocStorageFake implements IMinimalDocStorage {\n  private _files: { [key: string]: FileInfo  } = {};\n\n  constructor(public docName: string) {\n  }\n\n  public async requestVacuum(): Promise<boolean> {\n    return true;\n  }\n\n  public async listAllFiles(): Promise<FileInfo[]> {\n    const fileInfoPromises = Object.keys(this._files).map(key => this.getFileInfo(key));\n    const fileInfo = await Promise.all(fileInfoPromises);\n\n    const isFileInfo = (item: FileInfo | null): item is FileInfo => item !== null;\n\n    return fileInfo.filter(isFileInfo);\n  }\n\n  public async getFileInfo(fileIdent: string): Promise<FileInfo | null> {\n    return this._files[fileIdent] ?? null;\n  }\n\n  public async getFileInfoNoData(fileIdent: string): Promise<FileInfo | null> {\n    return this.getFileInfo(fileIdent);\n  }\n\n  // Needs to match the semantics of DocStorage's implementation.\n  public async attachFileIfNew(\n    fileIdent: string, fileData: Buffer | undefined, storageId?: string,\n  ): Promise<boolean> {\n    if (fileIdent in this._files) {\n      return false;\n    }\n    this._setFileRecord(fileIdent, fileData, storageId);\n    return true;\n  }\n\n  public async attachOrUpdateFile(\n    fileIdent: string, fileData: Buffer | undefined, storageId?: string,\n  ): Promise<boolean> {\n    const exists = fileIdent in this._files;\n    this._setFileRecord(fileIdent, fileData, storageId);\n    return !exists;\n  }\n\n  private _setFileRecord(fileIdent: string, fileData: Buffer | undefined, storageId?: string) {\n    this._files[fileIdent] = {\n      ident: fileIdent,\n      data: fileData ?? Buffer.alloc(0),\n      storageId: storageId ?? null,\n    };\n  }\n}\n\nconst defaultTestDocName = \"1234\";\nconst defaultTestFileContent = \"Some content\";\nconst defaultTestFileBuffer = Buffer.from(defaultTestFileContent);\n\nfunction createDocStorageFake(docName: string): DocStorage {\n  return new DocStorageFake(docName) as unknown as DocStorage;\n}\n\ninterface TestAttachmentStore extends IAttachmentStore {\n  uploads(): number;\n  finish(): void;\n}\n\n/** Creates a fake storage provider that doesn't finish the upload */\nexport async function makeTestingControlledStore(\n  name: string = \"unfinished\",\n): Promise<IAttachmentStoreConfig> {\n  let resolve: () => void;\n  const createProm = () => new Promise<void>((_resolve) => { resolve = _resolve; });\n  let promise = createProm();\n  let uploads = 0;\n  const neverStore = {\n    id: name,\n    async exists() { return false; },\n    async upload() {\n      uploads++;\n      // Every time file is uploaded, we create a new promise that can be resolved to finish the upload.\n      resolve();\n      promise = createProm();\n      return promise;\n    },\n    async download(): Promise<AttachmentFile> {\n      return {\n        metadata: { size: 0 },\n        contentStream: stream.Readable.from([]),\n      };\n    },\n    async delete() { return; },\n    async removePool() { return; },\n    async close() { return; },\n    finish() { resolve(); },\n    uploads() { return uploads; },\n  };\n  return {\n    label: name,\n    spec: {\n      name: \"unfinished\",\n      create: async () => {\n        return neverStore;\n      },\n    },\n  };\n}\n\nconst UNFINISHED_STORE = \"TEST-INSTALLATION-UUID-unfinished\";\n\nasync function createFakeAttachmentStoreProvider(): Promise<IAttachmentStoreProvider> {\n  return new AttachmentStoreProvider(\n    [\n      await makeTestingFilesystemStoreConfig(\"filesystem\"),\n      await makeTestingFilesystemStoreConfig(\"filesystem-alt\"),\n      await makeTestingControlledStore(\"unfinished\"),\n    ],\n    \"TEST-INSTALLATION-UUID\",\n  );\n}\n\ndescribe(\"AttachmentFileManager\", function() {\n  let defaultProvider: IAttachmentStoreProvider;\n  let defaultDocStorageFake: DocStorage;\n  let defaultManager: AttachmentFileManager;\n  let defaultStoreId: string;\n  const defaultDocId = \"12345\";\n  const defaultDocInfo = { id: defaultDocId, trunkId: null };\n\n  this.timeout(\"4s\");\n\n  beforeEach(async function() {\n    defaultProvider = await createFakeAttachmentStoreProvider();\n    defaultDocStorageFake = createDocStorageFake(defaultTestDocName);\n    defaultManager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      defaultDocInfo,\n    );\n    defaultStoreId = defaultProvider.listAllStoreIds()[0];\n  });\n\n  async function deleteFileFromStore(docId: string, storeId: string, fileIdent: string,\n    provider: IAttachmentStoreProvider = defaultProvider) {\n    const store = await provider.getStore(storeId);\n    assert.isTrue(await store?.exists(docId, fileIdent));\n    await store?.delete(docId, fileIdent);\n    assert.isFalse(await store?.exists(docId, fileIdent));\n  }\n\n  it(\"should stop the loop when aborted\", async function() {\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      { id: \"Unimportant\", trunkId: null  },\n    );\n\n    // Add some file.\n    await manager.addFile(undefined, \".txt\", Buffer.from(\"first file\"));\n    await manager.addFile(undefined, \".txt\", Buffer.from(\"second file\"));\n    await manager.addFile(undefined, \".txt\", Buffer.from(\"third file\"));\n\n    // Move this file to a store that never finish uploads.\n    await manager.startTransferringAllFilesToOtherStore(defaultProvider.listAllStoreIds()[2]);\n\n    // Make sure we are running.\n    assert.isTrue(manager.transferStatus().isRunning);\n\n    // Get the store to test if the upload was called. The store is created only once.\n    const store = await defaultProvider.getStore(UNFINISHED_STORE).then(store => store as TestAttachmentStore);\n\n    // Wait for the first upload to be called.\n    await waitForIt(() => assert.equal(store.uploads(),  1));\n\n    // Finish the upload and go to the next one.\n    store.finish();\n\n    // Wait for the second one to be called.\n    await waitForIt(() => assert.equal(store.uploads(),  2));\n\n    // Now shutdown the manager.\n    const shutdownProm = manager.shutdown();\n\n    // Manager will wait for the loop to finish, so allow the second file to be uploaded.\n    store.finish();\n\n    // Now manager can finish.\n    await shutdownProm;\n\n    // Make sure we are not running.\n    assert.isFalse(manager.transferStatus().isRunning);\n\n    // And make sure that the upload was only called twice.\n    assert.equal(store.uploads(),  2);\n  });\n\n  it(\"should throw if uses an external store when no document pool id is available\", async function() {\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      undefined,\n    );\n\n    const storeId = defaultProvider.listAllStoreIds()[0];\n\n    await assert.isRejected(manager.addFile(storeId, \".txt\", Buffer.alloc(0)), UnknownDocumentPoolError);\n  });\n\n  it(\"should throw if it tries to add a file to an unavailable store\", async function() {\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      { id: \"Unimportant\", trunkId: null  },\n    );\n\n    await assert.isRejected(manager.addFile(\"BAD STORE ID\", \".txt\", Buffer.alloc(0)), StoreNotAvailableError);\n  });\n\n  it(\"should throw if it tries to get a file from an unavailable store\", async function() {\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      { id: \"Unimportant\", trunkId: null  },\n    );\n\n    const fileId = \"123456.png\";\n    await defaultDocStorageFake.attachFileIfNew(fileId, undefined, \"SOME-STORE-ID\");\n\n    await assert.isRejected(manager.getFileData(fileId), StoreNotAvailableError);\n  });\n\n  it(\"should add a file to local document storage if no store id is provided\", async function() {\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      { id: \"Unimportant\", trunkId: null  },\n    );\n\n    const result = await manager.addFile(undefined, \".txt\", Buffer.from(defaultTestFileContent));\n\n    // Checking the file is present in the document storage.\n    const fileInfo = await defaultDocStorageFake.getFileInfo(result.fileIdent);\n    assert.equal(fileInfo?.data.toString(), \"Some content\");\n  });\n\n  it(\"should add a file to an available attachment store\", async function() {\n    const docId = \"12345\";\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      { id: docId, trunkId: null  },\n    );\n\n    const storeId = defaultProvider.listAllStoreIds()[0];\n    const result = await manager.addFile(storeId, \".txt\", Buffer.from(defaultTestFileContent));\n\n    const store = await defaultProvider.getStore(storeId);\n    assert.isTrue(await store!.exists(docId, result.fileIdent), \"file does not exist in store\");\n  });\n\n  describe(\"isFileAvailable\", async function() {\n    it(\"should report a non-existent file as unavailable\", async function() {\n      assert.isFalse(await defaultManager.isFileAvailable(\"I don't exist!\"), \"file should not be available\");\n    });\n    it(\"should report an internal file as available\", async function() {\n      const addFileResult = await defaultManager.addFile(undefined, \".txt\", defaultTestFileBuffer);\n      assert.isTrue(await defaultManager.isFileAvailable(addFileResult.fileIdent));\n    });\n    it(\"should report an existing external file as available\", async function() {\n      const addFileResult = await defaultManager.addFile(defaultStoreId, \".txt\", defaultTestFileBuffer);\n      assert.isTrue(await defaultManager.isFileAvailable(addFileResult.fileIdent));\n    });\n    it(\"should report a missing external file as available\", async function() {\n      const addFileResult = await defaultManager.addFile(defaultStoreId, \".txt\", defaultTestFileBuffer);\n      await deleteFileFromStore(defaultDocId, defaultStoreId, addFileResult.fileIdent);\n      assert.isFalse(await defaultManager.isFileAvailable(addFileResult.fileIdent));\n    });\n    it(\"should report a file from an unavailable store as unavailable\", async function() {\n      const addFileResult = await defaultManager.addFile(defaultStoreId, \".txt\", defaultTestFileBuffer);\n\n      // Same settings as defaultManager, but with no stores.\n      const alternateManager = new AttachmentFileManager(\n        defaultDocStorageFake,\n        new AttachmentStoreProvider([], \"TEST-INSTALLATION-UUID\"),\n        defaultDocInfo,\n      );\n\n      assert(defaultDocStorageFake.getFileInfo(addFileResult.fileIdent), \"file info should still exist in storage\");\n      assert.isFalse(await alternateManager.isFileAvailable(addFileResult.fileIdent));\n    });\n  });\n\n  describe(\"adding missing files\", async function() {\n    let manager: AttachmentFileManager;\n    let defaultStoreId: string;\n\n    beforeEach(function() {\n      manager = new AttachmentFileManager(\n        defaultDocStorageFake,\n        defaultProvider,\n        defaultDocInfo,\n      );\n      defaultStoreId = defaultProvider.listAllStoreIds()[0];\n    });\n\n    async function addTestFiles() {\n      const addToExternalResult = await manager.addFile(defaultStoreId, \".txt\", defaultTestFileBuffer);\n      const addToInternalResult = await manager.addFile(undefined, \".txt\", defaultTestFileBuffer);\n\n      return {\n        internal: addToInternalResult,\n        external: addToExternalResult,\n      };\n    }\n\n    async function getManagerWithDifferentStores() {\n      const alternateProvider = new AttachmentStoreProvider(\n        [await makeTestingFilesystemStoreConfig(\"new-store\")],\n        \"ANOTHER-INSTALLATION-UUID\",\n      );\n\n      // Uses the same fake database + doc info, but with different stores.\n      const alternateManager = new AttachmentFileManager(\n        defaultDocStorageFake,\n        alternateProvider,\n        defaultDocInfo,\n      );\n\n      return {\n        provider: alternateProvider,\n        manager: alternateManager,\n      };\n    }\n\n    it(\"should do nothing if the file exists\", async function() {\n      const files = await addTestFiles();\n\n      const isExternalFileDataAdded =\n        await manager.addMissingFileData(\n          files.external.fileIdent, stream.Readable.from(defaultTestFileBuffer), undefined,\n        );\n      assert.isFalse(isExternalFileDataAdded, \"external file data should not have been added\");\n\n      const isInternalFileDataAdded =\n        await manager.addMissingFileData(\n          files.internal.fileIdent, stream.Readable.from(defaultTestFileBuffer), undefined,\n        );\n      assert.isFalse(isInternalFileDataAdded, \"internal file data should not have been added\");\n    });\n\n    it(\"should do nothing if the file doesn't exist\", async function() {\n      const isExternalFileDataAdded =\n        await manager.addMissingFileData(\"abcde.png\", stream.Readable.from(defaultTestFileBuffer), undefined);\n      assert.isFalse(isExternalFileDataAdded, \"external file data should not have been added\");\n      assert.isFalse(await manager.isFileAvailable(\"abcde.png\"));\n\n      const isInternalFileDataAdded =\n        await manager.addMissingFileData(\"abcde.png\", stream.Readable.from(defaultTestFileBuffer), undefined);\n      assert.isFalse(isInternalFileDataAdded, \"internal file data should not have been added\");\n    });\n\n    it(\"should replace external files if they're missing\", async function() {\n      const files = await addTestFiles();\n      await deleteFileFromStore(defaultDocId, defaultStoreId, files.external.fileIdent);\n      assert.isFalse(await manager.isFileAvailable(files.external.fileIdent));\n\n      const otherStoreId = defaultProvider.listAllStoreIds()[1];\n      assert.notEqual(otherStoreId, defaultStoreId, \"default store should be different for this test\");\n\n      const isExternalFileDataAdded =\n        await manager.addMissingFileData(\n          files.external.fileIdent, stream.Readable.from(defaultTestFileBuffer), otherStoreId,\n        );\n      assert.isTrue(isExternalFileDataAdded, \"external file data should have been added\");\n      assert.isTrue(await manager.isFileAvailable(files.external.fileIdent));\n      const fileDetails = await defaultDocStorageFake.getFileInfo(files.external.fileIdent);\n      assert.equal(fileDetails?.storageId, defaultStoreId, \"file should be restored to original store\");\n    });\n\n    it(\"should replace external files in the given store, if the original isn't available\", async function() {\n      const files = await addTestFiles();\n\n      const { manager: altManager, provider: altProvider } = await getManagerWithDifferentStores();\n\n      const newStoreId = altProvider.getStoreIdFromLabel(\"new-store\");\n      const isExternalFileDataAdded = await altManager.addMissingFileData(\n        files.external.fileIdent,\n        stream.Readable.from(defaultTestFileBuffer),\n        newStoreId,\n      );\n      assert.isTrue(isExternalFileDataAdded, \"external file data should have been added\");\n      assert.isTrue(await altManager.isFileAvailable(files.external.fileIdent));\n    });\n\n    it(\"shouldn't add files if their content is wrong\", async function() {\n      const files = await addTestFiles();\n\n      const { manager: altManager, provider: altProvider } = await getManagerWithDifferentStores();\n\n      const newStoreId = altProvider.getStoreIdFromLabel(\"new-store\");\n      const assertAddFailsWithHashError = async (storeIdToCheck: string | undefined) => {\n        await assert.isRejected(altManager.addMissingFileData(\n          files.external.fileIdent,\n          stream.Readable.from(Buffer.from(\"THIS IS WRONG\")),\n          storeIdToCheck,\n        ), /Hash.*is not correct/);\n        assert.isFalse(await altManager.isFileAvailable(files.external.fileIdent));\n      };\n\n      // Need to check adding file to external storage, and internal storage, as they have\n      // different hashing approaches.\n      await assertAddFailsWithHashError(newStoreId);\n      await assertAddFailsWithHashError(undefined);\n    });\n\n    it(\"should throw if neither store is available\", async function() {\n      const files = await addTestFiles();\n\n      const { manager: altManager } = await getManagerWithDifferentStores();\n\n      const newStoreId = \"Mysterious missing store!\";\n      await assert.isRejected(altManager.addMissingFileData(\n        files.external.fileIdent,\n        stream.Readable.from(Buffer.from(\"THIS IS WRONG\")),\n        newStoreId,\n      ), \"not a valid and available store\");\n      assert.isFalse(await altManager.isFileAvailable(files.external.fileIdent));\n    });\n  });\n\n  it(\"shouldn't do anything when trying to add an existing attachment to a new store\", async function() {\n    const docId = \"12345\";\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      { id: docId, trunkId: null  },\n    );\n\n    const allStoreIds = defaultProvider.listAllStoreIds();\n    const result1 = await manager.addFile(allStoreIds[0], \".txt\", Buffer.from(defaultTestFileContent));\n    const store1 = await defaultProvider.getStore(allStoreIds[0]);\n    assert.isTrue(await store1!.exists(docId, result1.fileIdent), \"file does not exist in store\");\n\n    const result2 = await manager.addFile(allStoreIds[1], \".txt\", Buffer.from(defaultTestFileContent));\n    const store2 = await defaultProvider.getStore(allStoreIds[1]);\n    // File shouldn't exist in the new store\n    assert.isFalse(await store2!.exists(docId, result2.fileIdent));\n\n    const fileInfo = await defaultDocStorageFake.getFileInfo(result2.fileIdent);\n    assert.equal(fileInfo?.storageId, allStoreIds[0], \"file record should not refer to the new store\");\n  });\n\n  it(\"should get a file from local storage\", async function() {\n    const docId = \"12345\";\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      { id: docId, trunkId: null  },\n    );\n\n    const result = await manager.addFile(undefined, \".txt\", Buffer.from(defaultTestFileContent));\n    const fileData = await manager.getFileData(result.fileIdent);\n\n    assert.equal(fileData?.toString(), defaultTestFileContent, \"downloaded file contents do not match original file\");\n  });\n\n  it(\"should get a file from an attachment store\", async function() {\n    const docId = \"12345\";\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      { id: docId, trunkId: null  },\n    );\n\n    const storeIds = defaultProvider.listAllStoreIds();\n    const result = await manager.addFile(storeIds[0], \".txt\", Buffer.from(defaultTestFileContent));\n    const fileData = await manager.getFileData(result.fileIdent);\n    const fileInfo = await defaultDocStorageFake.getFileInfo(result.fileIdent);\n    assert.equal(fileInfo?.storageId, storeIds[0]);\n    // Ideally this should be null, but the current fake returns an empty buffer.\n    assert.equal(fileInfo?.data.length, 0);\n    assert.equal(fileData?.toString(), defaultTestFileContent, \"downloaded file contents do not match original file\");\n  });\n\n  it(\"should detect existing files and not upload them\", async function() {\n    const docId = \"12345\";\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      { id: docId, trunkId: null  },\n    );\n\n    const storeId = defaultProvider.listAllStoreIds()[0];\n    const addFile = () => manager.addFile(storeId, \".txt\", Buffer.from(defaultTestFileContent));\n\n    const addFileResult1 = await addFile();\n    assert.isTrue(addFileResult1.isNewFile);\n\n    // Makes the store's upload method throw an error, so we can detect if it gets called.\n    const originalGetStore = defaultProvider.getStore.bind(defaultProvider);\n    sinon.replace(defaultProvider, \"getStore\", sinon.fake(\n      async function(...args: Parameters<IAttachmentStoreProvider[\"getStore\"]>) {\n        const store = (await originalGetStore(...args))!;\n        sinon.replace(store, \"upload\", () => { throw new Error(\"Upload should never be called\"); });\n        return store;\n      },\n    ));\n\n    const addFileResult2 = await addFile();\n    assert.isFalse(addFileResult2.isNewFile);\n  });\n\n  it(\"should check if an existing file is in the attachment store, and re-upload them if not\", async function() {\n    const docId = \"12345\";\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      { id: docId, trunkId: null  },\n    );\n\n    const storeId = defaultProvider.listAllStoreIds()[0];\n    const store = (await defaultProvider.getStore(storeId))!;\n    const addFile = () => manager.addFile(storeId, \".txt\", Buffer.from(defaultTestFileContent));\n\n    const addFileResult = await addFile();\n    // This might be overkill, but it works.\n    await store.removePool(docId);\n\n    await assert.isRejected(manager.getFileData(addFileResult.fileIdent), AttachmentRetrievalError);\n\n    await addFile();\n\n    const fileData = await manager.getFileData(addFileResult.fileIdent);\n    assert(fileData);\n    assert.equal(fileData.toString(), defaultTestFileContent);\n  });\n\n  async function testStoreTransfer(sourceStore?: string, destStore?: string) {\n    const docInfo = { id: \"12345\", trunkId: null  };\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      docInfo,\n    );\n\n    const fileAddResult = await manager.addFile(sourceStore, \".txt\", Buffer.from(defaultTestFileContent));\n    manager.startTransferringFileToOtherStore(fileAddResult.fileIdent, destStore);\n\n    await manager.allTransfersCompleted();\n\n    if (!destStore) {\n      await defaultDocStorageFake.getFileInfo(fileAddResult.fileIdent);\n      assert.equal(\n        (await defaultDocStorageFake.getFileInfo(fileAddResult.fileIdent))?.data?.toString(),\n        defaultTestFileContent,\n      );\n      return;\n    }\n\n    const store = (await defaultProvider.getStore(destStore))!;\n\n    assert(\n      await store.exists(getDocPoolIdFromDocInfo(docInfo), fileAddResult.fileIdent),\n      \"file does not exist in new store\",\n    );\n  }\n\n  it(\"can transfer a file from internal to external storage\", async function() {\n    await testStoreTransfer(undefined, defaultProvider.listAllStoreIds()[0]);\n  });\n\n  it(\"can transfer a file from external to internal storage\", async function() {\n    await testStoreTransfer(defaultProvider.listAllStoreIds()[0], undefined);\n  });\n\n  it(\"can transfer a file from external to a different external storage\", async function() {\n    await testStoreTransfer(defaultProvider.listAllStoreIds()[0], defaultProvider.listAllStoreIds()[1]);\n  });\n\n  it(\"throws an error if the downloaded file is corrupted\", async function() {\n    const docInfo = { id: \"12345\", trunkId: null  };\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      docInfo,\n    );\n\n    const sourceStoreId = defaultProvider.listAllStoreIds()[0];\n    const fileAddResult = await manager.addFile(sourceStoreId, \".txt\", Buffer.from(defaultTestFileContent));\n\n    const sourceStore = await defaultProvider.getStore(defaultProvider.listAllStoreIds()[0]);\n    const badData = stream.Readable.from(Buffer.from(\"I am corrupted\"));\n    await sourceStore?.upload(getDocPoolIdFromDocInfo(docInfo), fileAddResult.fileIdent, badData);\n\n    const transferPromise =\n      manager.transferFileToOtherStore(fileAddResult.fileIdent, defaultProvider.listAllStoreIds()[1]);\n    await assert.isRejected(transferPromise, AttachmentRetrievalError, \"checksum verification failed\");\n  });\n\n  it(\"transfers all files in the background\", async function() {\n    const docInfo = { id: \"12345\", trunkId: null  };\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      docInfo,\n    );\n\n    const allStoreIds = defaultProvider.listAllStoreIds();\n    const sourceStoreId = allStoreIds[0];\n    const fileAddResult1 = await manager.addFile(sourceStoreId, \".txt\", Buffer.from(\"A\"));\n    const fileAddResult2 = await manager.addFile(sourceStoreId, \".txt\", Buffer.from(\"B\"));\n    const fileAddResult3 = await manager.addFile(sourceStoreId, \".txt\", Buffer.from(\"C\"));\n\n    await manager.startTransferringAllFilesToOtherStore(allStoreIds[1]);\n    assert.isTrue(manager.transferStatus().isRunning);\n    await manager.allTransfersCompleted();\n    assert.isFalse(manager.transferStatus().isRunning);\n\n    const destStore = (await defaultProvider.getStore(allStoreIds[1]))!;\n    const poolId = getDocPoolIdFromDocInfo(docInfo);\n    assert.isTrue(await destStore.exists(poolId, fileAddResult1.fileIdent));\n    assert.isTrue(await destStore.exists(poolId, fileAddResult2.fileIdent));\n    assert.isTrue(await destStore.exists(poolId, fileAddResult3.fileIdent));\n  });\n\n  it(\"uses the most recent transfer destination\", async function() {\n    const docInfo = { id: \"12345\", trunkId: null  };\n    const manager = new AttachmentFileManager(\n      defaultDocStorageFake,\n      defaultProvider,\n      docInfo,\n    );\n\n    const allStoreIds = defaultProvider.listAllStoreIds();\n    const fileAddResult1 = await manager.addFile(allStoreIds[0], \".txt\", Buffer.from(\"A\"));\n\n    manager.startTransferringFileToOtherStore(fileAddResult1.fileIdent, allStoreIds[1]);\n    manager.startTransferringFileToOtherStore(fileAddResult1.fileIdent, allStoreIds[0]);\n    manager.startTransferringFileToOtherStore(fileAddResult1.fileIdent, allStoreIds[1]);\n    manager.startTransferringFileToOtherStore(fileAddResult1.fileIdent, allStoreIds[0]);\n    await manager.allTransfersCompleted();\n\n    const fileInfo = await defaultDocStorageFake.getFileInfo(fileAddResult1.fileIdent);\n    assert.equal(fileInfo?.storageId, allStoreIds[0], \"the file should be in the original store\");\n    // We can't assert on if the files exists in the store, as it might be transferred from A to B and back to A,\n    // and so exist in both stores.\n  });\n});\n"
  },
  {
    "path": "test/server/lib/AttachmentStoreProvider.ts",
    "content": "import { ObjMetadata, ObjSnapshot, ObjSnapshotWithMetadata } from \"app/common/DocSnapshot\";\nimport { ExternalStorageSupportingAttachments } from \"app/server/lib/AttachmentStore\";\nimport { AttachmentStoreProvider } from \"app/server/lib/AttachmentStoreProvider\";\nimport { CoreCreate } from \"app/server/lib/coreCreator\";\nimport { ExternalStorage, StreamDownloadResult, Unchanged } from \"app/server/lib/ExternalStorage\";\nimport { makeTestingFilesystemStoreConfig } from \"test/server/lib/FilesystemAttachmentStore\";\n\nimport * as stream from \"node:stream\";\n\nimport { assert } from \"chai\";\n\nconst testInstallationUUID = \"FAKE-UUID\";\n\nfunction expectedStoreId(label: string) {\n  return `${testInstallationUUID}-${label}`;\n}\n\ndescribe(\"AttachmentStoreProvider\", () => {\n  it(\"constructs stores using the installations UID and store type\", async () => {\n    const storesConfig = [\n      await makeTestingFilesystemStoreConfig(\"filesystem1\"),\n      await makeTestingFilesystemStoreConfig(\"filesystem2\"),\n    ];\n\n    const provider = new AttachmentStoreProvider(storesConfig, testInstallationUUID);\n    const allStores = await provider.getAllStores();\n    const ids = allStores.map(store => store.id);\n\n    assert.deepEqual(ids, [expectedStoreId(\"filesystem1\"), expectedStoreId(\"filesystem2\")]);\n\n    const allStoreIds = provider.listAllStoreIds();\n    assert.deepEqual(allStoreIds, [expectedStoreId(\"filesystem1\"), expectedStoreId(\"filesystem2\")]);\n  });\n\n  it(\"can retrieve a store if it exists\", async () => {\n    const storesConfig = [\n      await makeTestingFilesystemStoreConfig(\"filesystem1\"),\n    ];\n\n    const provider = new AttachmentStoreProvider(storesConfig, testInstallationUUID);\n\n    assert.isNull(await provider.getStore(\"doesn't exist\"), \"store shouldn't exist\");\n\n    assert(await provider.getStore(expectedStoreId(\"filesystem1\")), \"store not present\");\n  });\n\n  it(\"can check if a store exists\", async () => {\n    const storesConfig = [\n      await makeTestingFilesystemStoreConfig(\"filesystem1\"),\n    ];\n\n    const provider = new AttachmentStoreProvider(storesConfig, testInstallationUUID);\n\n    assert.isTrue(provider.storeExists(expectedStoreId(\"filesystem1\")));\n  });\n});\n\ndescribe(\"Snapshot attachment store option\", () => {\n  it(\"is not available if the external storage is undefined\", async () => {\n    const create = new CoreCreatorExternalStorageStub(() => undefined);\n    const isAvailable = await create.getAttachmentStoreOptions().snapshots.isAvailable();\n    assert.isFalse(isAvailable);\n  });\n\n  it(\"is not available if the external storage doesn't implement the right methods\", async () => {\n    const create = new CoreCreatorExternalStorageStub(() => new FakeExternalStorage());\n    const isAvailable = await create.getAttachmentStoreOptions().snapshots.isAvailable();\n    assert.isFalse(isAvailable);\n  });\n\n  it(\"is available if the external storage supports attachments\", async () => {\n    const create = new CoreCreatorExternalStorageStub(() => new FakeAttachmentExternalStorage());\n    const isAvailable = await create.getAttachmentStoreOptions().snapshots.isAvailable();\n    assert.isTrue(isAvailable);\n  });\n\n  it(\"throws if the external storage is being used but doesn't support attachments\", async () => {\n    const create = new CoreCreatorExternalStorageStub(() => new FakeExternalStorage());\n    await assert.isRejected(create.getAttachmentStoreOptions().snapshots.create(\"anything\"));\n  });\n\n  it(\"can be used if the external storage supports attachments\", async () => {\n    const create = new CoreCreatorExternalStorageStub(() => new FakeAttachmentExternalStorage());\n    const store = await create.getAttachmentStoreOptions().snapshots.create(\"anything\");\n    // Exact method checked doesn't matter - just that we get a valid instance.\n    assert.isFalse(await store.exists(\"pool1\", \"doc1\"));\n  });\n});\n\nclass FakeExternalStorage implements ExternalStorage {\n  public async exists(key: string, snapshotId?: string): Promise<boolean> {\n    return false;\n  }\n\n  public async head(key: string, snapshotId?: string): Promise<ObjSnapshotWithMetadata | null> {\n    return null;\n  }\n\n  public async upload(\n    key: string, fname: string, metadata?: ObjMetadata,\n  ): Promise<string | typeof Unchanged | null> {\n    return null;\n  }\n\n  public async download(key: string, fname: string, snapshotId?: string): Promise<string> {\n    return \"\";\n  }\n\n  public async remove(key: string, snapshotIds?: string[]): Promise<void> {\n    return;\n  }\n\n  public async versions(key: string): Promise<ObjSnapshot[]> {\n    return [];\n  }\n\n  public url(key: string): string {\n    return \"\";\n  }\n\n  public isFatalError(err: any): boolean {\n    return false;\n  }\n\n  public async close(): Promise<void> {}\n}\n\nclass FakeAttachmentExternalStorage extends FakeExternalStorage implements ExternalStorageSupportingAttachments {\n  public async uploadStream(\n    key: string, inStream: stream.Readable, size?: number, metadata?: ObjMetadata,\n  ): Promise<string | typeof Unchanged | null> {\n    return null;\n  }\n\n  public async downloadStream(key: string, snapshotId?: string): Promise<StreamDownloadResult> {\n    return {\n      metadata: { size: 0, snapshotId: \"\" },\n      contentStream: stream.Readable.from(Buffer.from([])),\n    };\n  }\n\n  public async removeAllWithPrefix(prefix: string): Promise<void> {\n    return;\n  }\n}\n\nclass CoreCreatorExternalStorageStub extends CoreCreate {\n  constructor(private _makeStore: () => ExternalStorage | undefined) {\n    super();\n  }\n\n  public override ExternalStorage(): ExternalStorage | undefined {\n    return this._makeStore();\n  }\n}\n"
  },
  {
    "path": "test/server/lib/Authorizer.ts",
    "content": "import { parseUrlId } from \"app/common/gristUrls\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { DocManager } from \"app/server/lib/DocManager\";\nimport { FlexServer } from \"app/server/lib/FlexServer\";\nimport { TestSession } from \"test/gen-server/apiUtils\";\nimport { createInitialDb, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport { configForUser, getGristConfig } from \"test/gen-server/testUtils\";\nimport { createDocTools } from \"test/server/docTools\";\nimport { openClient } from \"test/server/gristClient\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\nimport { toPairs } from \"lodash\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nlet serverUrl: string;\nlet server: FlexServer;\nlet dbManager: HomeDBManager;\n\nasync function activateServer(home: FlexServer, docManager: DocManager) {\n  await home.initHomeDBManager();\n  await home.addLoginMiddleware();\n  home.addHosts();\n  home.addDocWorkerMap();\n  home.addAccessMiddleware();\n  dbManager = home.getHomeDBManager();\n  await home.addLoginMiddleware();\n  home.addSessions();\n  home.addHealthCheck();\n  docManager.testSetHomeDbManager(dbManager);\n  home.testSetDocManager(docManager);\n  await home.start();\n  home.addAccessMiddleware();\n  home.addApiMiddleware();\n  home.addJsonSupport();\n  await home.addLandingPages();\n  home.addWidgetRepository();\n  home.addHomeApi();\n  home.addAuditLogger();\n  home.addScimApi();\n  await home.addTelemetry();\n  home.addAssistant();\n  await home.addDoc();\n  home.addApiErrorHandlers();\n  home.finalizeEndpoints();\n  await home.finalizePlugins(null);\n  home.setReady(true);\n  serverUrl = home.getOwnUrl();\n}\n\nconst chimpy = configForUser(\"Chimpy\");\nconst charon = configForUser(\"Charon\");\n\nconst fixtures: { [docName: string]: string | null } = {\n  Bananas: \"Hello.grist\",\n  Pluto: \"Hello.grist\",\n};\n\ndescribe(\"Authorizer\", function() {\n  this.timeout(3000);\n\n  testUtils.setTmpLogLevel(\"fatal\");\n\n  const docTools = createDocTools({ persistAcrossCases: true, useFixturePlugins: false,\n    server: () => (server = new FlexServer(0, \"test docWorker\")) });\n  const docs: { [name: string]: { id: string } } = {};\n\n  // Loads the fixtures documents so that they are available to the doc worker under the correct\n  // names.\n  async function loadFixtureDocs() {\n    for (const [docName, fixtureDoc] of toPairs(fixtures)) {\n      const docId = String(await dbManager.testGetId(docName));\n      if (fixtureDoc) {\n        await docTools.loadFixtureDocAs(fixtureDoc, docId);\n      } else {\n        await docTools.createDoc(docId);\n      }\n      docs[docName] = { id: docId };\n    }\n  }\n\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  before(async function() {\n    this.timeout(5000);\n    setUpDB(this);\n    await createInitialDb();\n    await activateServer(server, docTools.getDocManager());\n    await loadFixtureDocs();\n  });\n  beforeEach(function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n  });\n\n  after(async function() {\n    const messages = await testUtils.captureLog(\"warn\", async () => {\n      await server.close();\n      await removeConnection();\n    });\n    assert.lengthOf(messages, 0);\n  });\n  afterEach(function() {\n    oldEnv.restore();\n  });\n\n  // TODO XXX Is it safe to remove this support now?\n  // (It used to be implemented in getDocAccessInfo() in Authorizer.ts).\n  it.skip(\"viewer gets redirect by title\", async function() {\n    const resp = await axios.get(`${serverUrl}/o/pr/doc/Bananas`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(getGristConfig(resp.data).assignmentId, \"sampledocid_6\");\n    assert.match(resp.request.res.responseUrl, /\\/doc\\/sampledocid_6$/);\n    const resp2 = await axios.get(`${serverUrl}/o/nasa/doc/Pluto`, chimpy);\n    assert.equal(resp2.status, 200);\n    assert.equal(getGristConfig(resp2.data).assignmentId, \"sampledocid_2\");\n    assert.match(resp2.request.res.responseUrl, /\\/doc\\/sampledocid_2$/);\n  });\n\n  it(\"viewer loads document without slug in the URL\", async function() {\n    const docId = docs.Bananas.id;\n    const resp = await axios.get(`${serverUrl}/o/pr/${docId}`, chimpy);\n    assert.equal(resp.status, 200);\n  });\n\n  it(\"stranger gets consistent refusal regardless of title\", async function() {\n    const resp = await axios.get(`${serverUrl}/o/pr/doc/Bananas`, charon);\n    assert.equal(resp.status, 404);\n    assert.notMatch(resp.data, /sampledocid_6/);\n    const resp2 = await axios.get(`${serverUrl}/o/pr/doc/Bananas2`, charon);\n    assert.equal(resp2.status, 404);\n    assert.notMatch(resp.data, /sampledocid_6/);\n    assert.deepEqual(withoutTimestamp(resp.data),\n      withoutTimestamp(resp2.data));\n  });\n\n  it(\"viewer can access title\", async function() {\n    const resp = await axios.get(`${serverUrl}/o/pr/doc/sampledocid_6`, chimpy);\n    assert.equal(resp.status, 200);\n    const config = getGristConfig(resp.data);\n    assert.equal(config.getDoc![config.assignmentId!].name, \"Bananas\");\n  });\n\n  it(\"stranger cannot access title\", async function() {\n    const resp = await axios.get(`${serverUrl}/o/pr/doc/sampledocid_6`, charon);\n    assert.equal(resp.status, 403);\n    assert.notMatch(resp.data, /Bananas/);\n  });\n\n  it(\"viewer cannot access document from wrong org\", async function() {\n    const resp = await axios.get(`${serverUrl}/o/nasa/doc/sampledocid_6`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"websocket allows openDoc for viewer\", async function() {\n    const cli = await openClient(server, \"chimpy@getgrist.com\", \"pr\");\n    cli.ignoreTrivialActions();\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    const openDoc = await cli.send(\"openDoc\", \"sampledocid_6\");\n    assert.equal(openDoc.error, undefined);\n    assert.match(JSON.stringify(openDoc.data), /Table1/);\n    await cli.close();\n  });\n\n  it(\"websocket forbids openDoc for stranger\", async function() {\n    const cli = await openClient(server, \"charon@getgrist.com\", \"pr\");\n    cli.ignoreTrivialActions();\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    const openDoc = await cli.send(\"openDoc\", \"sampledocid_6\");\n    assert.match(openDoc.error!, /No view access/);\n    assert.equal(openDoc.data, undefined);\n    assert.match(openDoc.errorCode!, /AUTH_NO_VIEW/);\n    await cli.close();\n  });\n\n  it(\"websocket forbids applyUserActions for viewer\", async function() {\n    const cli = await openClient(server, \"charon@getgrist.com\", \"nasa\");\n    cli.ignoreTrivialActions();\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    const openDoc = await cli.openDocOnConnect(\"sampledocid_2\");\n    assert.equal(openDoc.error, undefined);\n    const nonce = uuidv4();\n    const applyUserActions = await cli.send(\"applyUserActions\",\n      0,\n      [[\"UpdateRecord\", \"Table1\", 1, { A: nonce }], {}]);\n    assert.lengthOf(cli.messages, 0);  // no user actions pushed to client\n    assert.match(applyUserActions.error!, /No write access/);\n    assert.match(applyUserActions.errorCode!, /AUTH_NO_EDIT/);\n    const fetchTable = await cli.send(\"fetchTable\", 0, \"Table1\");\n    assert.equal(fetchTable.error, undefined);\n    assert.notInclude(JSON.stringify(fetchTable.data), nonce);\n    await cli.close();\n  });\n\n  it(\"websocket allows applyUserActions for editor\", async function() {\n    const cli = await openClient(server, \"chimpy@getgrist.com\", \"nasa\");\n    cli.ignoreTrivialActions();\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    const openDoc = await cli.openDocOnConnect(\"sampledocid_2\");\n    assert.equal(openDoc.error, undefined);\n    const nonce = uuidv4();\n    const applyUserActions = await cli.send(\"applyUserActions\",\n      0,\n      [[\"UpdateRecord\", \"Table1\", 1, { A: nonce }]]);\n    // Skip messages with no actions (since docUsage may or may not appear by now)\n    const messagesWithActions = cli.messages.filter(m => m.data.docActions);\n    assert.lengthOf(messagesWithActions, 1);  // user actions pushed to client\n    assert.equal(applyUserActions.error, undefined);\n    const fetchTable = await cli.send(\"fetchTable\", 0, \"Table1\");\n    assert.equal(fetchTable.error, undefined);\n    assert.include(JSON.stringify(fetchTable.data), nonce);\n    await cli.close();\n  });\n\n  it(\"can keep different simultaneous clients of a doc straight\", async function() {\n    const editor = await openClient(server, \"chimpy@getgrist.com\", \"nasa\");\n    assert.equal((await editor.readMessage()).type, \"clientConnect\");\n    const viewer = await openClient(server, \"charon@getgrist.com\", \"nasa\");\n    assert.equal((await viewer.readMessage()).type, \"clientConnect\");\n    const stranger = await openClient(server, \"kiwi@getgrist.com\", \"nasa\");\n    assert.equal((await stranger.readMessage()).type, \"clientConnect\");\n\n    editor.ignoreTrivialActions();\n    viewer.ignoreTrivialActions();\n    stranger.ignoreTrivialActions();\n    assert.equal((await editor.send(\"openDoc\", \"sampledocid_2\")).error, undefined);\n    assert.equal((await viewer.send(\"openDoc\", \"sampledocid_2\")).error, undefined);\n    assert.match((await stranger.send(\"openDoc\", \"sampledocid_2\")).error!, /No view access/);\n\n    const action = [0, [[\"UpdateRecord\", \"Table1\", 1, { A: \"foo\" }]]];\n    assert.equal((await editor.send(\"applyUserActions\", ...action)).error, undefined);\n    assert.match((await viewer.send(\"applyUserActions\", ...action)).error!, /No write access/);\n    // Different message here because sending actions without a doc being open.\n    assert.match((await stranger.send(\"applyUserActions\", ...action)).error!, /Invalid/);\n  });\n\n  it(\"previewer has view access to docs\", async function() {\n    const cli = await openClient(server, \"thumbnail@getgrist.com\", \"nasa\");\n    cli.ignoreTrivialActions();\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    const openDoc = await cli.send(\"openDoc\", \"sampledocid_2\");\n    assert.equal(openDoc.error, undefined);\n    const nonce = uuidv4();\n    const applyUserActions = await cli.send(\"applyUserActions\",\n      0,\n      [[\"UpdateRecord\", \"Table1\", 1, { A: nonce }], {}]);\n    assert.lengthOf(cli.messages, 0);  // no user actions pushed to client\n    assert.match(applyUserActions.error!, /No write access/);\n    assert.match(applyUserActions.errorCode!, /AUTH_NO_EDIT/);\n    const fetchTable = await cli.send(\"fetchTable\", 0, \"Table1\");\n    assert.equal(fetchTable.error, undefined);\n    assert.notInclude(JSON.stringify(fetchTable.data), nonce);\n    await cli.close();\n  });\n\n  it(\"viewer can fork doc\", async function() {\n    const cli = await openClient(server, \"charon@getgrist.com\", \"nasa\");\n    cli.ignoreTrivialActions();\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    const openDoc = await cli.send(\"openDoc\", \"sampledocid_2\");\n    assert.equal(openDoc.error, undefined);\n    const result = await cli.send(\"fork\", 0);\n    assert.equal(result.data.docId, result.data.urlId);\n    const parts = parseUrlId(result.data.docId);\n    assert.equal(parts.trunkId, \"sampledocid_2\");\n    assert.isAbove(parts.forkId!.length, 4);\n    assert.equal(parts.forkUserId, await dbManager.testGetId(\"Charon\") as number);\n  });\n\n  it(\"anon can fork doc\", async function() {\n    // anon does not have access to doc initially\n    const cli = await openClient(server, \"anon@getgrist.com\", \"nasa\");\n    cli.ignoreTrivialActions();\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    let openDoc = await cli.send(\"openDoc\", \"sampledocid_2\");\n    assert.match(openDoc.error!, /No view access/);\n\n    // grant anon access to doc and retry\n    await dbManager.updateDocPermissions({\n      userId: await dbManager.testGetId(\"Chimpy\") as number,\n      urlId: \"sampledocid_2\",\n      org: \"nasa\",\n    }, { users: { \"anon@getgrist.com\": \"viewers\" } });\n    dbManager.flushDocAuthCache();\n    openDoc = await cli.send(\"openDoc\", \"sampledocid_2\");\n    assert.equal(openDoc.error, undefined);\n\n    // make a fork\n    const result = await cli.send(\"fork\", 0);\n    assert.equal(result.data.docId, result.data.urlId);\n    const parts = parseUrlId(result.data.docId);\n    assert.equal(parts.trunkId, \"sampledocid_2\");\n    assert.isAbove(parts.forkId!.length, 4);\n    assert.equal(parts.forkUserId, undefined);\n  });\n\n  for (const { method, getAuth } of [\n    { method: \"API key\", getAuth: async () => chimpy },\n    { method: \"session cookie\", getAuth: getChimpyCookie },\n  ]) {\n    it(`forbids access to disabled users via ${method}`, async function() {\n      const auth = await getAuth();\n      const docId = docs.Bananas.id;\n\n      // At first, Chimpy can get the bananas\n      let resp = await axios.get(`${serverUrl}/o/pr/${docId}`, auth);\n      assert.equal(resp.status, 200, \"bananas first acquired\");\n      resp = await axios.get(`${serverUrl}/o/pr/${docId}/records`, auth);\n      assert.equal(resp.status, 200, \"bananas first acquired in detail\");\n\n      // A non-document request is fine too.\n      resp = await axios.get(`${serverUrl}/`, auth);\n      assert.equal(resp.status, 200, \"home page visible\");\n\n      const sadChimpy = await dbManager.getUserByLogin(\"chimpy@getgrist.com\");\n      try {\n        // But, oh no! Chimpy has been getting too greedy with the bananas,\n        // so down comes the banhammer!\n        sadChimpy.disabledAt = new Date();\n        await sadChimpy.save();\n\n        // No more bananas!\n        resp = await axios.get(`${serverUrl}/o/pr/${docId}`, auth);\n        assert.equal(resp.status, 403, \"bananas denied!\");\n        resp = await axios.get(`${serverUrl}/o/pr/${docId}/records`, auth);\n        assert.equal(resp.status, 403, \"banana details denied!\");\n\n        // Not even the home page is allowed!\n        resp = await axios.get(`${serverUrl}/`, auth);\n        assert.equal(resp.status, 403, \"home page denied!\");\n      } finally {\n        // It's okay, chimpy, you learned your lesson\n        sadChimpy.disabledAt = null;\n        await sadChimpy.save();\n      }\n\n      // You can have your bananas back\n      resp = await axios.get(`${serverUrl}/o/pr/${docId}`, auth);\n      assert.equal(resp.status, 200, \"bananas granted again\");\n      resp = await axios.get(`${serverUrl}/o/pr/${docId}/records`, auth);\n      assert.equal(resp.status, 200, \"banana detailed granted again\");\n\n      // You can also look at stuff outside of a document.\n      resp = await axios.get(`${serverUrl}/`, auth);\n      assert.equal(resp.status, 200, \"home page visible again\");\n    });\n  }\n\n  it(\"can set user via GRIST_PROXY_AUTH_HEADER\", async function() {\n    // GRIST_PROXY_AUTH_HEADER now only affects requests directly when GRIST_IGNORE_SESSION is\n    // also set. (These variables are reset by our beforeEach/afterEach hooks.)\n    process.env.GRIST_PROXY_AUTH_HEADER = \"X-email\";\n    process.env.GRIST_IGNORE_SESSION = \"true\";\n    process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = \"enterprise\";\n    // We'll need a local setup for this test\n    const localServer = new FlexServer(0, \"test docWorker\");\n    await activateServer(localServer, docTools.getDocManager());\n\n    // User can access a doc by setting header.\n    const docUrl = `${localServer.getOwnUrl()}/o/pr/api/docs/sampledocid_6`;\n    const resp = await axios.get(docUrl, {\n      headers: { \"X-email\": \"chimpy@getgrist.com\" },\n    });\n    assert.equal(resp.data.name, \"Bananas\");\n\n    // Unknown user is denied.\n    await assert.isRejected(axios.get(docUrl, {\n      headers: { \"X-email\": \"notchimpy@getgrist.com\" },\n    }));\n\n    // User can access a doc via websocket by setting header.\n    let cli = await openClient(localServer, \"chimpy@getgrist.com\", \"pr\", \"X-email\");\n    cli.ignoreTrivialActions();\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    let openDoc = await cli.send(\"openDoc\", \"sampledocid_6\");\n    assert.equal(openDoc.error, undefined);\n    assert.match(JSON.stringify(openDoc.data), /Table1/);\n    await cli.close();\n\n    // Unknown user is denied.\n    cli = await openClient(localServer, \"notchimpy@getgrist.com\", \"pr\", \"X-email\");\n    cli.ignoreTrivialActions();\n    assert.equal((await cli.readMessage()).type, \"clientConnect\");\n    openDoc = await cli.send(\"openDoc\", \"sampledocid_6\");\n    assert.match(openDoc.error!, /No view access/);\n    assert.equal(openDoc.data, undefined);\n    assert.match(openDoc.errorCode!, /AUTH_NO_VIEW/);\n    await cli.close();\n    await localServer.close();\n  });\n});\n\nfunction withoutTimestamp(txt: string): string {\n  return txt.replace(/\"timestampMs\":[ 0-9]+/, '\"timestampMs\": NNNN');\n}\n\nasync function getChimpyCookie() {\n  const session = new TestSession(server);\n  return await session.getCookieLogin(\n    \"pr\", { email: \"chimpy@getgrist.com\", name: \"Chimpy\" },\n  );\n}\n"
  },
  {
    "path": "test/server/lib/BundleActions.ts",
    "content": "import { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { createDocTools } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport range from \"lodash/range\";\n\ndescribe(\"BundleActions\", function() {\n  // Comment this out to see debug-log output when debugging tests.\n  testUtils.setTmpLogLevel(\"error\");\n\n  const docTools = createDocTools();\n\n  it(\"should bundle actions emitted without waiting\", async function() {\n    this.timeout(4000);\n\n    const session = docTools.createFakeSession();\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n\n    // Try a few bundled actions, including some that don't wait for the previous one to complete.\n    doc.startBundleUserActions(session);\n    const actions1 = await Promise.all([\n      doc.applyUserActions(session, [[\"AddTable\", \"Lamps1\", [{ id: \"Color\" }, { id: \"Lumens\" }]]]),\n      doc.applyUserActions(session, [[\"AddColumn\", \"Lamps1\", \"Price\", { type: \"Numeric\" }]]),\n      doc.applyUserActions(session, [[\"AddColumn\", \"Lamps1\", \"Weight\", { type: \"Numeric\" }]]),\n    ]);\n    actions1.push(await doc.applyUserActions(session, [[\"AddColumn\", \"Lamps1\", \"Quantity\", { type: \"Int\" }]]));\n    doc.stopBundleUserActions(session);\n\n    const expectedActionNums1 = range(actions1[0].actionNum, actions1[0].actionNum + 4);\n    assert.deepEqual(actions1.map(a => a.actionNum), expectedActionNums1);\n\n    async function getRecentActions(count: number) {\n      return (await doc.getRecentActions(session, false)).actions.slice(-count);\n    }\n\n    assert.deepEqual((await getRecentActions(4)).map(a => a.actionNum), expectedActionNums1);\n    assert.deepEqual((await getRecentActions(4)).map(a => a.linkId), [0, ...expectedActionNums1.slice(0, -1)]);\n\n    // Try similar actions but unbundled.\n    const actions2 = await Promise.all([\n      doc.applyUserActions(session, [[\"AddTable\", \"Lamps2\", [{ id: \"Color\" }, { id: \"Lumens\" }]]]),\n      doc.applyUserActions(session, [[\"AddColumn\", \"Lamps2\", \"Price\", { type: \"Numeric\" }]]),\n      doc.applyUserActions(session, [[\"AddColumn\", \"Lamps2\", \"Weight\", { type: \"Numeric\" }]]),\n      doc.applyUserActions(session, [[\"AddColumn\", \"Lamps2\", \"Quantity\", { type: \"Int\" }]]),\n    ]);\n    const expectedActionNums2 = range(actions2[0].actionNum, actions2[0].actionNum + 4);\n    assert.deepEqual((await getRecentActions(4)).map(a => a.actionNum), expectedActionNums2);\n    assert.deepEqual((await getRecentActions(4)).map(a => a.linkId), [0, 0, 0, 0]);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/CommentAccess.ts",
    "content": "import { getSingleAction } from \"app/common/DocActions\";\nimport { DocData } from \"app/common/DocData\";\nimport { UserAPI } from \"app/common/UserAPI\";\nimport { CellData } from \"app/server/lib/CellDataAccess\";\nimport { DocManager } from \"app/server/lib/DocManager\";\nimport { GranularAccess } from \"app/server/lib/GranularAccess\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { GristClient, openClient } from \"test/server/gristClient\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"CommentAccess\", function() {\n  this.timeout(\"60s\");\n  let home: TestServer;\n  testUtils.setTmpLogLevel(\"error\");\n  let owner: UserAPI;\n  let editor: UserAPI;\n  let docId: string;\n  let wsId: number;\n  let cliOwner: GristClient;\n  let cliEditor: GristClient;\n  let ownerRef: string;\n  let editorRef: string;\n  let docManager: DocManager;\n\n  async function getWebsocket(api: UserAPI) {\n    const who = await api.getSessionActive();\n    return openClient(home.server, who.user.email, who.org?.domain || \"docs\");\n  }\n\n  async function getGranularAccess(): Promise<GranularAccess> {\n    const doc = await docManager.getActiveDoc(docId);\n    return (doc as any)._granularAccess;\n  }\n\n  function getColRef(docData: DocData, tableId: string, colId: string) {\n    const parentId = docData.getMetaTable(\"_grist_Tables\").findRow(\"tableId\", tableId);\n    return docData.getMetaTable(\"_grist_Tables_column\").findMatchingRowId({ parentId, colId });\n  }\n\n  const bulkMeta = async (ids: number[]) => {\n    const data  = await owner.getTable(docId, \"_grist_Cells\");\n    return {\n      // Assuming that ids are generated in order, so we can just map them to arrays.\n      timeCreated: ids.map(id => data.timeCreated[id - 1]),\n      timeUpdated: ids.map(id => data.timeUpdated[id - 1]),\n      resolved: ids.map(id => data.resolved[id - 1]),\n    };\n  };\n\n  const singleMeta = async (id: number) => {\n    const bulked = await bulkMeta([id]);\n    return {\n      timeCreated: bulked.timeCreated[0],\n      timeUpdated: bulked.timeUpdated[0],\n      resolved: bulked.resolved[0],\n    };\n  };\n\n  before(async function() {\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n    const api = await home.createHomeApi(\"chimpy\", \"docs\", true);\n    await api.newOrg({ name: \"testy\", domain: \"testy\" });\n    owner = await home.createHomeApi(\"chimpy\", \"testy\", true);\n    wsId = await owner.newWorkspace({ name: \"ws\" }, \"current\");\n    await owner.updateWorkspacePermissions(wsId, {\n      users: {\n        \"charon@getgrist.com\": \"editors\",\n      },\n    });\n    editor = await home.createHomeApi(\"charon\", \"testy\", true);\n    const who = await owner.getSessionActive();\n    ownerRef = who.user.ref || \"\";\n    docManager = (home.server as any)._docManager;\n    editorRef = (await editor.getSessionActive()).user.ref || \"\";\n  });\n\n  async function close(cli: GristClient) {\n    try {\n      await cli.send(\"closeDoc\", 0);\n    } catch (e) {\n      // Do not worry if socket is already closed by the other side.\n      if (!String(e).match(/WebSocket is not open/)) {\n        throw e;\n      }\n    }\n    await cli.close();\n  }\n\n  afterEach(async function() {\n    if (docId) {\n      for (const cli of [cliEditor, cliOwner]) {\n        await close(cli);\n      }\n      docId = \"\";\n    }\n  });\n\n  after(async function() {\n    const api = await home.createHomeApi(\"chimpy\", \"docs\");\n    await api.deleteOrg(\"testy\");\n    await home.stop();\n  });\n\n  async function freshDoc() {\n    docId = await owner.newDoc({ name: \"doc\" }, wsId);\n    cliEditor = await getWebsocket(editor);\n    cliOwner = await getWebsocket(owner);\n    await cliEditor.openDocOnConnect(docId);\n    await cliOwner.openDocOnConnect(docId);\n  }\n\n  it(\"creates proper snapshot\", async function() {\n    await testDoc();\n    const access = await getGranularAccess();\n    const docData: DocData = (access as any)._docData;\n\n    // We don't have comments yet, so we should have empty snapshot.\n    let snapshot = await access.createSnapshotWithCells([]);\n    assert.equal(snapshot.getTables().size, 27);\n    assert.equal(snapshot.getTable(\"_grist_Cells\")?.getRowIds().length, 0);\n    assert.equal(snapshot.getTable(\"Chat\")?.getRowIds().length, 0);\n    assert.equal(snapshot.getTable(\"_grist_Tables\")?.getRowIds().length, 3);\n    assert.equal(snapshot.getTable(\"_grist_Tables_column\")?.getRowIds().length, 10);\n\n    // Now simulate updating some rows.\n    snapshot = await access.createSnapshotWithCells([\n      [\"UpdateRecord\", \"Chat\", 1, { Censored: \"test1\" }],\n    ]);\n    const firstRowTest = () => {\n      assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [1], {\n        manualSort: [1],\n        Public: [0],\n        Private: [\"\"],\n        Censored: [\"\"],\n      }]);\n    };\n    firstRowTest();\n\n    // Simulate that row was just added.\n    snapshot = await access.createSnapshotWithCells([\n      [\"AddRecord\", \"Chat\", 1, {}],\n    ]);\n    assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [], {\n      manualSort: [],\n      Public: [],\n      Private: [],\n      Censored: [],\n    }]);\n\n    // Simulate row removal, we should have this row.\n    snapshot = await access.createSnapshotWithCells([\n      [\"RemoveRecord\", \"Chat\", 1],\n    ]);\n    firstRowTest();\n\n    // Now put some comments there, and check snapshot once again.\n    await send(owner, \"Public\", \"Message1\", 1);\n    await send(owner, \"Public\", \"Message2\", 2);\n\n    snapshot = await access.createSnapshotWithCells([\n      [\"UpdateRecord\", \"Chat\", 1, { Censored: \"test1\" }],\n    ]);\n    firstRowTest();\n    // We have all cells in snapshot\n    assert.deepEqual(\n      snapshot.getTable(\"_grist_Cells\")?.getTableDataAction(),\n      docData.getTable(\"_grist_Cells\")?.getTableDataAction(),\n    );\n    snapshot = await access.createSnapshotWithCells([\n      [\"AddRecord\", \"Chat\", 1, {}],\n    ]);\n    assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [], {\n      manualSort: [],\n      Public: [],\n      Private: [],\n      Censored: [],\n    }]);\n    assert.deepEqual(\n      snapshot.getTable(\"_grist_Cells\")?.getTableDataAction(),\n      docData.getTable(\"_grist_Cells\")?.getTableDataAction(),\n    );\n    snapshot = await access.createSnapshotWithCells([\n      [\"RemoveRecord\", \"Chat\", 1],\n    ]);\n    assert.deepEqual(\n      snapshot.getTable(\"_grist_Cells\")?.getTableDataAction(),\n      docData.getTable(\"_grist_Cells\")?.getTableDataAction(),\n    );\n\n    // Now simulate adding a comment, we should corresponding table row in snapshot.\n    snapshot = await access.createSnapshotWithCells([\n      [\"UpdateRecord\", \"_grist_Cells\", 1, {}],\n    ]);\n    firstRowTest();\n    snapshot = await access.createSnapshotWithCells([\n      [\"UpdateRecord\", \"_grist_Cells\", 2, {}],\n    ]);\n    assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [2], {\n      manualSort: [2],\n      Public: [0],\n      Private: [\"\"],\n      Censored: [\"\"],\n    }]);\n\n    snapshot = await access.createSnapshotWithCells([\n      [\"RemoveRecord\", \"_grist_Cells\", 2],\n    ]);\n    assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [2], {\n      manualSort: [2],\n      Public: [0],\n      Private: [\"\"],\n      Censored: [\"\"],\n    }]);\n\n    snapshot = await access.createSnapshotWithCells([\n      [\"BulkRemoveRecord\", \"_grist_Cells\", [1, 2]],\n    ]);\n    assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [1, 2], {\n      manualSort: [1, 2],\n      Public: [0, 0],\n      Private: [\"\", \"\"],\n      Censored: [\"\", \"\"],\n    }]);\n\n    // Now simulate adding a comment, that is detached (we are overusing it). Since this method is using current\n    // state, it will fetch comments data from the database, even though we are just adding them\n    // and they are detached.\n    snapshot = await access.createSnapshotWithCells([\n      [\"BulkAddRecord\", \"_grist_Cells\", [1, 2], {}],\n    ]);\n    assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [1, 2], {\n      manualSort: [1, 2],\n      Public: [0, 0],\n      Private: [\"\", \"\"],\n      Censored: [\"\", \"\"],\n    }]);\n    assert.deepEqual(\n      snapshot.getTable(\"_grist_Cells\")?.getTableDataAction(),\n      docData.getTable(\"_grist_Cells\")?.getTableDataAction(),\n    );\n\n    // Add comment in a proper way.\n    snapshot = await access.createSnapshotWithCells([\n      [\"AddRecord\", \"_grist_Cells\", 3, {\n        tableRef: await tableRef(\"Chat\"),\n        colRef: await colRef(\"Chat\", \"Public\"),\n        rowId: 1,\n        content: \"Message\",\n        type: 1,\n        root: 1,\n        userRef: ownerRef,\n      }],\n    ]);\n    assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [1], {\n      manualSort: [1],\n      Public: [0],\n      Private: [\"\"],\n      Censored: [\"\"],\n    }]);\n    assert.deepEqual(\n      snapshot.getTable(\"_grist_Cells\")?.getTableDataAction(),\n      docData.getTable(\"_grist_Cells\")?.getTableDataAction(),\n    );\n    // The snapshot doesn't have this comment - it still gets comments from the current state.\n    assert.isUndefined(snapshot.getTable(\"_grist_Cells\")?.getRecord(3));\n    assert.isDefined(snapshot.getTable(\"_grist_Cells\")?.getRecord(2));\n    assert.isDefined(snapshot.getTable(\"_grist_Cells\")?.getRecord(1));\n    assert.equal(snapshot.getTable(\"_grist_Cells\")?.getRecords()?.length, 2);\n\n    snapshot = await access.createSnapshotWithCells([\n      [\"AddRecord\", \"_grist_Cells\", 7, {\n        tableRef: await tableRef(\"Chat\"),\n        colRef: await colRef(\"Chat\", \"Public\"),\n        rowId: 2,\n        content: \"Message\",\n        type: 1,\n        root: 1,\n        userRef: ownerRef,\n      }],\n    ]);\n    assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [2], {\n      manualSort: [2],\n      Public: [0],\n      Private: [\"\"],\n      Censored: [\"\"],\n    }]);\n    assert.deepEqual(\n      snapshot.getTable(\"_grist_Cells\")?.getTableDataAction(),\n      docData.getTable(\"_grist_Cells\")?.getTableDataAction(),\n    );\n    assert.deepEqual(\n      snapshot.getTable(\"_grist_Cells\")?.getTableDataAction(),\n      docData.getTable(\"_grist_Cells\")?.getTableDataAction(),\n    );\n\n    snapshot = await access.createSnapshotWithCells([\n      [\"BulkUpdateRecord\", \"_grist_Cells\", [1, 2], {}],\n    ]);\n    assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [1, 2], {\n      manualSort: [1, 2],\n      Public: [0, 0],\n      Private: [\"\", \"\"],\n      Censored: [\"\", \"\"],\n    }]);\n\n    // Now simulate adding a comment to a nonexisting row.\n    // Method still should work, but it shouldn't get any data.\n    snapshot = await access.createSnapshotWithCells([\n      [\"BulkAddRecord\", \"_grist_Cells\", [8], {}],\n    ]);\n    assert.deepEqual(snapshot.getTable(\"Chat\")?.getTableDataAction(), [\"TableData\", \"Chat\", [], {\n      manualSort: [],\n      Public: [],\n      Private: [],\n      Censored: [],\n    }]);\n  });\n\n  // Tables look like:\n  // Chat\n  // id | Public | Private | Censored\n  // -----------------------------\n  //  1 |   0    |  None   |  None   |\n  //  2 |   0    |  None   |  None   |\n  //\n  // Column Private is only for owners, other users don't see it\n  // Column Censored is censored, owners see value, other see value only if Public > 1\n  //\n  // Public\n  // id | A\n  // ----------------\n  // (no rows)\n  async function testDoc() {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Chat\", [{ id: \"Public\", type: \"Int\" }, { id: \"Private\" }, { id: \"Censored\" }]],\n      [\"AddTable\", \"Public\", [{ id: \"A\", type: \"Text\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Chat\", colIds: \"Private\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Chat\", colIds: \"Censored\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access == \"owners\" # owner check', permissionsText: \"all\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-S\",  // drop schema rights\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -3, aclFormula: 'user.Access != \"owners\" and rec.Public <= 0', permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"Chat\", null, { Public: 0 }],\n      [\"AddRecord\", \"Chat\", null, { Public: 0 }],\n    ]);\n    cliEditor.flush();\n    cliOwner.flush();\n  }\n\n  it(\"should convert bulk actions to single actions\", async function() {\n    deepEqual(Array.from(getSingleAction([\n      \"BulkAddRecord\", \"Chat\", [1, 2], { Name: [\"First\", \"Second\"] },\n    ])), [\n      [\"AddRecord\", \"Chat\", 1, { Name: \"First\" }],\n      [\"AddRecord\", \"Chat\", 2, { Name: \"Second\" }],\n    ]);\n    deepEqual(Array.from(getSingleAction([\n      \"BulkUpdateRecord\", \"Chat\", [1, 2], { Name: [\"First\", \"Second\"] },\n    ])), [\n      [\"UpdateRecord\", \"Chat\", 1, { Name: \"First\" }],\n      [\"UpdateRecord\", \"Chat\", 2, { Name: \"Second\" }],\n    ]);\n    deepEqual(Array.from(getSingleAction([\n      \"BulkRemoveRecord\", \"Chat\", [2, 3],\n    ])), [\n      [\"RemoveRecord\", \"Chat\", 2], [\"RemoveRecord\", \"Chat\", 3],\n    ]);\n    deepEqual(Array.from(getSingleAction([\n      \"RemoveRecord\", \"Chat\", 1,\n    ])), [\n      [\"RemoveRecord\", \"Chat\", 1],\n    ]);\n    deepEqual(Array.from(getSingleAction([\n      \"AddRecord\", \"Chat\", 1, {},\n    ])), [\n      [\"AddRecord\", \"Chat\", 1, {}],\n    ]);\n    deepEqual(Array.from(getSingleAction([\n      \"RemoveRecord\", \"Chat\", 1,\n    ])), [\n      [\"RemoveRecord\", \"Chat\", 1],\n    ]);\n  });\n\n  it(\"should help with cell extractions\", async function() {\n    await testDoc();\n    await send(owner, \"Private\", \"First\");\n    await send(owner, \"Public\", \"Second\");\n    await send(owner, \"Censored\", \"Third\");\n    const access = await getGranularAccess();\n    const docData: DocData = (access as any)._docData;\n    const helper = new CellData(docData);\n    const chatPublicColRef = getColRef(docData, \"Chat\", \"Public\");\n\n    // First test some basic helpers.\n    deepEqual(helper.getCell(1), {\n      tableId: \"Chat\", colId: \"Private\", rowId: 1, userRef: ownerRef, id: 1,\n      content: \"First\", parentId: 0,\n    });\n    assert.isNull(helper.getCell(400));\n    assert.equal(helper.getColId(6), \"Public\");\n    assert.isUndefined(helper.getColId(20));\n    assert.equal(helper.getTableId(2), \"Chat\");\n    assert.isUndefined(helper.getTableId(20));\n    assert.isUndefined(helper.getTableRef(\"Chat2\"));\n    assert.equal(helper.getTableRef(\"Chat\"), 2);\n\n    const firstComment = {\n      tableId: \"Chat\", colId: \"Private\", rowId: 1, userRef: ownerRef, id: 1, content: \"First\",\n      parentId: 0,\n    };\n    const secondComment = {\n      tableId: \"Chat\", colId: \"Public\", rowId: 1, userRef: ownerRef, id: 2, content: \"Second\",\n      parentId: 0,\n    };\n\n    // Test method that converts docActions for _grist_Cells to a list of cells.\n    deepEqual(helper.convertToCells([\"RemoveColumn\", \"Table1\", \"Test\"]), []);\n    // Single cell extractions from docData\n    deepEqual(helper.convertToCells([\"UpdateRecord\", \"_grist_Cells\", 1, {}]),\n      [firstComment],\n    );\n    deepEqual(helper.convertToCells([\"AddRecord\", \"_grist_Cells\", 1, { tableRef: 10 }]),\n      [firstComment],\n    );\n    deepEqual(helper.convertToCells([\"RemoveRecord\", \"_grist_Cells\", 1]),\n      [firstComment],\n    );\n    deepEqual(helper.convertToCells([\"BulkRemoveRecord\", \"_grist_Cells\", [1]]),\n      [firstComment],\n    );\n    deepEqual(helper.convertToCells([\"BulkAddRecord\", \"_grist_Cells\", [1], {}]),\n      [firstComment],\n    );\n    deepEqual(helper.convertToCells([\"BulkUpdateRecord\", \"_grist_Cells\", [1], {}]),\n      [firstComment],\n    );\n    // Multiple doc extractions from docData\n    deepEqual(helper.convertToCells([\"BulkUpdateRecord\", \"_grist_Cells\", [1, 2], {}]),\n      [\n        firstComment,\n        secondComment,\n      ],\n    );\n    deepEqual(helper.convertToCells([\"BulkAddRecord\", \"_grist_Cells\", [1, 2], {}]),\n      [\n        firstComment,\n        secondComment,\n      ],\n    );\n    deepEqual(helper.convertToCells([\"BulkRemoveRecord\", \"_grist_Cells\", [1, 2]]),\n      [\n        firstComment,\n        secondComment,\n      ],\n    );\n    deepEqual(helper.convertToCells([\"BulkRemoveRecord\", \"_grist_Cells\", [1, 10]]),\n      [\n        firstComment,\n        // 10 is not a valid cell id\n      ],\n    );\n    deepEqual(helper.convertToCells([\"UpdateRecord\", \"_grist_Cells\", 10, {}]), []);\n    // Extract from docAction itself\n    deepEqual(helper.convertToCells([\"AddRecord\", \"_grist_Cells\", 44, {\n      tableRef: helper.getTableRef(\"Chat\")!,\n      rowId: 1,\n      colRef: chatPublicColRef,\n      userRef: ownerRef,\n    }]),\n    [\n      { tableId: \"Chat\", colId: \"Public\", rowId: 1, userRef: ownerRef, id: 44 },\n    ],\n    );\n    deepEqual(helper.convertToCells([\"UpdateRecord\", \"_grist_Cells\", 44, {\n      tableRef: helper.getTableRef(\"Chat\")!,\n      rowId: 1,\n      colRef: chatPublicColRef,\n      userRef: ownerRef,\n    }]),\n    [\n      { tableId: \"Chat\", colId: \"Public\", rowId: 1, userRef: ownerRef, id: 44 },\n    ],\n    );\n    deepEqual(helper.convertToCells([\"BulkUpdateRecord\", \"_grist_Cells\", [44], {\n      tableRef: [helper.getTableRef(\"Chat\")!],\n      rowId: [1],\n      colRef: [chatPublicColRef],\n      userRef: [ownerRef!],\n    }]),\n    [\n      { tableId: \"Chat\", colId: \"Public\", rowId: 1, userRef: ownerRef, id: 44 },\n    ],\n    );\n\n    // Test BulkUpdateRecord action generation from list of SingleCells.\n    deepEqual(helper.generateUpdate([]), null);\n    deepEqual(helper.generateUpdate([1]), [\n      \"UpdateRecord\", \"_grist_Cells\", 1, {\n        content: \"First\",\n        userRef: ownerRef,\n        ...await singleMeta(1),\n      },\n    ]);\n    deepEqual(helper.generateUpdate([1, 2]), [\n      \"BulkUpdateRecord\", \"_grist_Cells\", [1, 2], {\n        content: [\"First\", \"Second\"],\n        userRef: [ownerRef, ownerRef],\n        ...await bulkMeta([1, 2]),\n      },\n    ]);\n\n    // Test detection if docAction is enough to create a cell metadata row.\n    assert.equal(helper.hasCellInfo([\"AddRecord\", \"_grist_Cells\", 1, {\n      tableRef: helper.getTableRef(\"Chat\")!,\n      rowId: 1,\n      colRef: chatPublicColRef,\n      userRef: ownerRef,\n    }]), true);\n\n    assert.equal(helper.hasCellInfo([\"AddRecord\", \"_grist_Cells\", 1, {\n      rowId: 1,\n      colRef: chatPublicColRef,\n      userRef: ownerRef,\n    }]), false);\n\n    assert.equal(helper.hasCellInfo([\"AddRecord\", \"_grist_Cells\", 1, {\n      tableRef: helper.getTableRef(\"Chat\")!,\n      colRef: chatPublicColRef,\n      userRef: ownerRef,\n    }]), false);\n\n    assert.equal(helper.hasCellInfo([\"AddRecord\", \"_grist_Cells\", 1, {\n      tableRef: helper.getTableRef(\"Chat\")!,\n      rowId: 1,\n      userRef: ownerRef,\n    }]), false);\n\n    assert.equal(helper.hasCellInfo([\"AddRecord\", \"_grist_Cells\", 1, {\n      tableRef: helper.getTableRef(\"Chat\")!,\n      colRef: chatPublicColRef,\n      rowId: 1,\n    }]), false);\n\n    // Test conversion between MetaRecord and SingleCell.\n    deepEqual(helper.convertToCellInfo(helper.getCellRecord(1)!), helper.getCell(1)!);\n    deepEqual(helper.convertToCellInfo(helper.getCellRecord(2)!), helper.getCell(2)!);\n\n    deepEqual(helper.readCells(\"Chat\", new Set([1, 2])), [\n      helper.getCell(1)!,\n      helper.getCell(2)!,\n      helper.getCell(3)!,\n    ]);\n  });\n\n  it(\"should create proper cell metadata actions\", async function() {\n    await testDoc();\n    await send(owner, \"Private\", \"First\");\n    await send(owner, \"Public\", \"Second\");\n    await send(owner, \"Censored\", \"Third\");\n    const access = await getGranularAccess();\n    const docData: DocData = (access as any)._docData;\n    const helper = new CellData(docData);\n    const chatPublicColRef = getColRef(docData, \"Chat\", \"Public\");\n\n    deepEqual(helper.generatePatch([\n      [\"AddRecord\", \"_grist_Cells\", 1, {\n        tableRef: helper.getTableRef(\"Chat\")!,\n        rowId: 1,\n        colRef: chatPublicColRef,\n      }],\n      [\"AddRecord\", \"_grist_Cells\", 2, {\n        tableRef: helper.getTableRef(\"Chat\")!,\n        rowId: 1,\n        colRef: chatPublicColRef,\n      }],\n      [\"AddRecord\", \"Chat\", 3, {}],\n    ]), [\n      [\"BulkAddRecord\", \"_grist_Cells\", [1, 2], {\n        tableRef: [2, 2],\n        colRef: [7, 6],\n        type: [1, 1],\n        root: [true, true],\n        rowId: [1, 1],\n        parentId: [0, 0],\n        // Data is read from docData.\n        content: [\"First\", \"Second\"],\n        userRef: [ownerRef, ownerRef],\n        ...await bulkMeta([1, 2]),\n      }],\n    ]);\n\n    // Now we are removing row, and since metadata was added in the same bundle, nothing is sent.\n    deepEqual(helper.generatePatch([\n      [\"AddRecord\", \"_grist_Cells\", 1, {\n        tableRef: helper.getTableRef(\"Chat\")!,\n        rowId: 1,\n        colRef: chatPublicColRef,\n      }],\n      [\"AddRecord\", \"_grist_Cells\", 2, {\n        tableRef: helper.getTableRef(\"Chat\")!,\n        rowId: 1,\n        colRef: chatPublicColRef,\n      }],\n      [\"RemoveRecord\", \"Chat\", 1],\n      [\"RemoveRecord\", \"_grist_Cells\", 1],\n      [\"RemoveRecord\", \"_grist_Cells\", 2],\n    ]), null);\n\n    // Now we are removing row and adding one comment, existing comment will be removed.\n    deepEqual(helper.generatePatch([\n      [\"AddRecord\", \"_grist_Cells\", 1, {\n        tableRef: helper.getTableRef(\"Chat\")!,\n        rowId: 1,\n        colRef: chatPublicColRef,\n      }],\n      [\"RemoveRecord\", \"Chat\", 1],\n      [\"RemoveRecord\", \"_grist_Cells\", 1],\n      [\"RemoveRecord\", \"_grist_Cells\", 2],\n    ]), [\n      [\"RemoveRecord\", \"_grist_Cells\", 2],\n    ]);\n\n    // Now we are updating row, and expect patch to be sent, that updates content for all cells.\n    deepEqual(helper.generatePatch([\n      [\"UpdateRecord\", \"Chat\", 1, {}],\n    ]), [\n      [\"BulkUpdateRecord\", \"_grist_Cells\", [1, 2, 3], {\n        content: [\"First\", \"Second\", \"Third\"],\n        userRef: [ownerRef, ownerRef, ownerRef],\n        ...await bulkMeta([1, 2, 3]),\n      }],\n    ]);\n\n    // Now we are updating row and comments in the same bundle, all updates are sent.\n    deepEqual(helper.generatePatch([\n      [\"UpdateRecord\", \"Chat\", 1, {}],\n      [\"UpdateRecord\", \"_grist_Cells\", 1, {}],\n    ]), [\n      [\"BulkUpdateRecord\", \"_grist_Cells\", [1, 2, 3], {\n        content: [\"First\", \"Second\", \"Third\"],\n        userRef: [ownerRef, ownerRef, ownerRef],\n        ...await bulkMeta([1, 2, 3]),\n      }],\n    ]);\n\n    const first = helper.getCellRecord(1)!;\n    // Now we are adding a row, adding a comment to it, and updating it in the same bundle.\n    deepEqual(helper.generatePatch([\n      [\"AddRecord\", \"Chat\", 1, {}],\n      [\"AddRecord\", \"_grist_Cells\", 1, {\n        tableRef: first.tableRef,\n        rowId: 1,\n        colRef: first.colRef,\n      }],\n      [\"UpdateRecord\", \"Chat\", 1, {}],\n    ]), [\n      [\"AddRecord\", \"_grist_Cells\", 1, {\n        tableRef: first.tableRef,\n        rowId: 1,\n        colRef: first.colRef,\n        content: first.content,\n        root: true,\n        type: 1,\n        userRef: ownerRef,\n        parentId: 0,\n        ...await singleMeta(1),\n      }],\n      // Update is sent only for existing cells.\n      [\"BulkUpdateRecord\", \"_grist_Cells\", [2, 3], {\n        content: [\"Second\", \"Third\"],\n        userRef: [ownerRef, ownerRef],\n        ...await bulkMeta([2, 3]),\n      }],\n    ]);\n  });\n\n  it(\"should create proper patch with schema actions\", async function() {\n    await testDoc();\n    // We have single public comment, it should be returned when we modify the 1st row.\n    await send(owner, \"Public\", \"Message\", 1);\n    cliEditor.flush();\n    cliOwner.flush();\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Chat\", 1, { Censored: \"Updated\" }],\n    ]);\n\n    deepEqual(await cliEditor.readDocUserAction(), [\n      [\"UpdateRecord\", \"Chat\", 1, { Censored: [\"C\"] }],\n      [\"UpdateRecord\", \"_grist_Cells\", 1, { content: \"Message\", userRef: ownerRef, ...await singleMeta(1) }],\n    ]);\n    // Test if patch is created correctly when, records are updated\n    // before table was renamed.\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Chat\", 1, { Censored: \"Updated2\" }],\n      [\"RenameTable\", \"Chat\", \"Chat2\"],\n    ]);\n    deepEqual(await cliEditor.readDocUserAction(), [\n      [\"UpdateRecord\", \"Chat\", 1, { Censored: [\"C\"] }],\n      [\"RenameTable\", \"Chat\", \"Chat2\"],\n      [\"UpdateRecord\", \"_grist_Tables\", 2, { tableId: \"Chat2\" }],\n      [\"UpdateRecord\", \"_grist_Cells\", 1, { content: \"Message\", userRef: ownerRef, ...await singleMeta(1) }],\n    ]);\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Chat2\", 1, { Censored: \"Updated3\" }],\n      [\"RemoveTable\", \"Chat2\"],\n    ]);\n    deepEqual(await cliEditor.readDocUserAction(), [\n      [\"UpdateRecord\", \"Chat2\", 1, { Censored: [\"C\"] }],\n      [\"BulkRemoveRecord\", \"_grist_Views_section_field\", [10, 11, 12, 13, 14, 15, 16, 17, 18]],\n      [\"BulkRemoveRecord\", \"_grist_Views_section\", [4, 5, 6]],\n      [\"UpdateRecord\", \"_grist_Tables\", 2, { rawViewSectionRef: 0 }],\n      [\"UpdateRecord\", \"_grist_Tables\", 2, { recordCardViewSectionRef: 0 }],\n      [\"RemoveRecord\", \"_grist_TabBar\", 2],\n      [\"RemoveRecord\", \"_grist_Pages\", 2],\n      [\"RemoveRecord\", \"_grist_Views\", 2],\n      [\"UpdateRecord\", \"_grist_Tables\", 2, { primaryViewId: 0 }],\n      [\"BulkRemoveRecord\", \"_grist_Tables_column\", [5, 6, 7, 8]],\n      [\"RemoveRecord\", \"_grist_Tables\", 2],\n      [\"RemoveTable\", \"Chat2\"],\n      [\"RemoveRecord\", \"_grist_Cells\", 1], // we only see that the cell was removed, not updated.\n    ]);\n  });\n\n  it(\"respects private conversation\", async function() {\n    await testDoc();\n\n    await send(owner, \"Private\", \"First\");\n    await read(cliOwner, \"Private\", \"First\", ownerRef);\n    await read(cliEditor, \"Private\", [\"C\"], \"\");\n\n    await send(owner, \"Public\", \"Second\");\n    await read(cliOwner, \"Public\", \"Second\", ownerRef);\n    await read(cliEditor, \"Public\", \"Second\", ownerRef);\n\n    await send(owner, \"Censored\", \"Third\");\n    await read(cliOwner, \"Censored\", \"Third\", ownerRef);\n    await read(cliEditor, \"Censored\", [\"C\"], \"\");\n\n    // Now reveal the private conversation to the editor.\n    await censorChat(false);\n    deepEqual(await cliOwner.readDocUserAction(), [\n      [\"UpdateRecord\", \"Chat\", 1, { Public: 1 }],\n      [\"BulkUpdateRecord\", \"_grist_Cells\", [1, 2, 3], {\n        content: [\"First\", \"Second\", \"Third\"],\n        userRef: [ownerRef, ownerRef, ownerRef],\n        ...await bulkMeta([1, 2, 3]),\n      }],\n    ]);\n    deepEqual(await cliEditor.readDocUserAction(), [\n      [\"UpdateRecord\", \"Chat\", 1, { Public: 1 }],\n      [\"BulkUpdateRecord\", \"Chat\", [1], { Censored: [\"\"] }],\n      [\"BulkUpdateRecord\", \"_grist_Cells\", [1, 2, 3], {\n        content: [[\"C\"], \"Second\", \"Third\"],\n        userRef: [\"\", ownerRef, ownerRef],\n        ...await bulkMeta([1, 2, 3]),\n      }],\n    ]);\n\n    // Now hide it once again\n    await censorChat(true);\n    deepEqual(await cliOwner.readDocUserAction(), [\n      [\"UpdateRecord\", \"Chat\", 1, { Public: 0 }],\n      [\"BulkUpdateRecord\", \"_grist_Cells\", [1, 2, 3], {\n        content: [\"First\", \"Second\", \"Third\"],\n        userRef: [ownerRef, ownerRef, ownerRef],\n        ...await bulkMeta([1, 2, 3]),\n      }],\n    ]);\n    deepEqual(await cliEditor.readDocUserAction(), [\n      [\"UpdateRecord\", \"Chat\", 1, { Public: 0 }],\n      [\"BulkUpdateRecord\", \"Chat\", [1], { Censored: [[\"C\"]] }],\n      [\"BulkUpdateRecord\", \"_grist_Cells\", [1, 2, 3], {\n        content: [[\"C\"], \"Second\", [\"C\"]],\n        userRef: [\"\", ownerRef, \"\"],\n        ...await bulkMeta([1, 2, 3]),\n      }],\n    ]);\n  });\n\n  it(\"works across non-trivial bundles\", async function() {\n    await testDoc();\n    await send(owner, \"Censored\", \"Secret\");\n    await censorChat(true);\n    // We have one comment at Censored column, that is currently censored.\n    deepContains(await editor.getTable(docId, \"_grist_Cells\"), {\n      content: [[\"C\"]], userRef: [\"\"],\n    });\n    deepContains(await owner.getTable(docId, \"_grist_Cells\"), {\n      content: [\"Secret\"], userRef: [ownerRef],\n    });\n    cliEditor.flush(); cliOwner.flush();\n    // Now rename table, and trigger comments retrieval, by updating a cell.\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Chat\", 1, { Censored: \"test1\" }],\n      [\"RenameTable\", \"Chat\", \"Chat2\"],\n      [\"RenameColumn\", \"Chat2\", \"Censored\", \"Censored2\"],\n      [\"UpdateRecord\", \"Chat2\", 1, { Censored2: \"test2\" }],\n    ]);\n    deepEqual(await cliOwner.readDocUserAction(), [\n      [\"UpdateRecord\", \"Chat\", 1, { Censored: \"test1\" }],\n      [\"RenameTable\", \"Chat\", \"Chat2\"],\n      [\"UpdateRecord\", \"_grist_Tables\", 2, { tableId: \"Chat2\" }],\n      [\"BulkUpdateRecord\", \"_grist_ACLResources\", [2, 4], { tableId: [\"Chat2\", \"Chat2\"] }],\n      [\"RenameColumn\", \"Chat2\", \"Censored\", \"Censored2\"],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 8, { colId: \"Censored2\" }],\n      [\"UpdateRecord\", \"_grist_ACLResources\", 4, { colIds: \"Censored2\" }],\n      [\"UpdateRecord\", \"Chat2\", 1, { Censored2: \"test2\" }],\n      [\"UpdateRecord\", \"_grist_Cells\", 1, {\n        content: \"Secret\", userRef: ownerRef,\n        ...await singleMeta(1),\n      }],\n    ]);\n    deepEqual(await cliEditor.readDocUserAction(), [\n      [\"UpdateRecord\", \"Chat\", 1, { Censored: [\"C\"] }],\n      [\"RenameTable\", \"Chat\", \"Chat2\"],\n      [\"UpdateRecord\", \"_grist_Tables\", 2, { tableId: \"Chat2\" }],\n      [\"RenameColumn\", \"Chat2\", \"Censored\", \"Censored2\"],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 8, { colId: \"Censored2\" }],\n      [\"UpdateRecord\", \"Chat2\", 1, { Censored2: [\"C\"] }],\n      [\"UpdateRecord\", \"_grist_Cells\", 1, { content: [\"C\"], userRef: \"\", ...await singleMeta(1) }],\n    ]);\n\n    const ChatTable = 2, Censored = 8;\n\n    // Now test some things with column removals, and renames.\n    // TODO: this doesn't work currently - ACL doesn't work well when columns are removed and renamed.\n    // await owner.applyUserActions(docId, [\n    //   [\"RenameTable\", \"Chat2\", \"Chat\"],\n    //   [\"RenameColumn\", \"Chat\", \"Censored2\", \"Censored\"],\n    //   [\"RemoveColumn\", \"Chat\", \"Censored\"]\n    // ]);\n\n    // First make sure that we are censoring cells still.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, {\n        tableRef: ChatTable, colRef: Censored, rowId: 1, type: 1, root: true,\n        // userRef is set automatically by the data engine\n        content: \"New Secret\",\n      }],\n    ]);\n    deepEqual(await cliOwner.readDocUserAction(), [\n      [\"AddRecord\", \"_grist_Cells\", 2, {\n        colRef: Censored, content: \"New Secret\",\n        userRef: ownerRef, root: true, rowId: 1, tableRef: ChatTable, type: 1, parentId: 0,\n        ...await singleMeta(2),\n      }],\n    ]);\n    deepEqual(await cliEditor.readDocUserAction(), [\n      [\"AddRecord\", \"_grist_Cells\", 2, {\n        colRef: Censored, content: [\"C\"],\n        userRef: \"\", root: true, rowId: 1, tableRef: ChatTable, type: 1, parentId: 0,\n        ...await singleMeta(2),\n      }],\n    ]);\n\n    // And now add a comment, and remove a row in the same bundle.\n    // This is not trivial, as cell info is censored after the fact.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, {\n        tableRef: ChatTable, colRef: Censored, rowId: 1, type: 1, root: true,\n        // userRef is set automatically by the data engine\n        content: \"New Secret\",\n      }],\n      [\"RemoveRecord\", \"Chat2\", 1],\n    ]);\n    deepEqual(await cliOwner.readDocUserAction(), [\n      [\"RemoveRecord\", \"Chat2\", 1],\n      [\"BulkRemoveRecord\", \"_grist_Cells\", [1, 2]],\n    ]);\n    deepEqual(await cliEditor.readDocUserAction(), [\n      [\"RemoveRecord\", \"Chat2\", 1],\n      [\"BulkRemoveRecord\", \"_grist_Cells\", [1, 2]],\n    ]);\n  });\n\n  it(\"rejects updates when needed\", async function() {\n    await testDoc();\n    await send(owner, \"Censored\", \"Secret\");\n    await censorChat(true);\n    // We have one comment at Censored column, that is currently censored.\n    deepContains(await editor.getTable(docId, \"_grist_Cells\"), {\n      content: [[\"C\"]], userRef: [\"\"],\n    });\n    deepContains(await owner.getTable(docId, \"_grist_Cells\"), {\n      id: [1],\n      content: [\"Secret\"], userRef: [ownerRef],\n    });\n    cliEditor.flush(); cliOwner.flush();\n\n    // Check that editor can't update or remove owners comment.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Cells\", 1, { content: \"hack\" }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Cells\", 1, { userRef: editorRef }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Cells\", 1, { colRef: await colRef(\"Chat\", \"Public\") }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Cells\", 1, { tableRef: await tableRef(\"Public\") }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Cells\", 1, { rowId: 2 }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"RemoveRecord\", \"_grist_Cells\", 1],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"BulkRemoveRecord\", \"_grist_Cells\", [1]],\n    ]));\n\n    // Can add a comment to a Public column.\n    await send(owner, \"Public\", \"Message\"); // 2\n    await send(owner, \"Private\", \"Secret\"); // 3\n    await send(owner, \"Censored\", \"Secret\"); // 4\n    await send(editor, \"Public\", \"Public\"); // 5\n\n    // Can't add a comment to a Private or Censored column.\n    await assert.isRejected(send(editor, \"Private\", \"Secret\"));\n    await assert.isRejected(send(editor, \"Censored\", \"Secret\"));\n    deepContains(await owner.getTable(docId, \"_grist_Cells\"), {\n      id: [1, 2, 3, 4, 5],\n    });\n    // Rejects comments that is send in partial, but are attached to\n    // a cell.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, {\n        tableRef: await tableRef(\"Chat\"),\n        colRef: await colRef(\"Chat\", \"Private\"),\n        rowId: 1,\n        type: 1,\n        root: true,\n        content: \"test\",\n      }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, {\n        tableRef: await tableRef(\"Chat\"),\n        colRef: await colRef(\"Chat\", \"Private\"),\n        type: 1,\n        root: true,\n        content: \"test\",\n      }],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { rowId: 1 }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, {\n        tableRef: await tableRef(\"Chat\"),\n        type: 1, root: true, content: \"test\",\n      }],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { rowId: 1 }],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { colRef: await colRef(\"Chat\", \"Private\") }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, {\n        type: 1, root: true, content: \"test\",\n      }],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { rowId: 1 }],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { colRef: await colRef(\"Chat\", \"Private\") }],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { tableRef: await tableRef(\"Chat\") }],\n    ]));\n\n    // Those are partial actions, that will success, but they won't add any comments\n    // as data-engine will remove comments that are not attached.\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, {\n        type: 1, root: true, content: \"test\",\n      }],\n      // ['UpdateRecord', '_grist_Cells', 6, {rowId: 1}],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { colRef: await colRef(\"Chat\", \"Public\") }],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { tableRef: await tableRef(\"Chat\") }],\n    ]));\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, {\n        type: 1, root: true, content: \"test\",\n      }],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { rowId: 1 }],\n      // ['UpdateRecord', '_grist_Cells', 6, {colRef: await colRef(\"Chat\", \"Public\")}],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { tableRef: await tableRef(\"Chat\") }],\n    ]));\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, {\n        type: 1, root: true, content: \"test\",\n      }],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { rowId: 1 }],\n      [\"UpdateRecord\", \"_grist_Cells\", 6, { colRef: await colRef(\"Chat\", \"Public\") }],\n      // ['UpdateRecord', '_grist_Cells', 6, {tableRef: await tableRef(\"Chat\")}]\n    ]));\n    deepContains(await owner.getTable(docId, \"_grist_Cells\"), {\n      id: [1, 2, 3, 4, 5],\n      content: [\"Secret\", \"Message\", \"Secret\", \"Secret\", \"Public\"],\n    });\n    // Make sure that editor can update its own comments.\n    // 1 - owner comment, 5 editor comment.\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Cells\", 5, { content: \"ok\" }],\n    ]));\n    deepContains(await owner.getTable(docId, \"_grist_Cells\"), {\n      content: [\"Secret\", \"Message\", \"Secret\", \"Secret\", \"ok\"],\n    });\n    // Try to move comment to a private channel.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Cells\", 5, { colRef: await colRef(\"Chat\", \"Private\") }],\n    ]));\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"BulkRemoveRecord\", \"_grist_Cells\", [5]],\n    ]));\n    deepContains(await owner.getTable(docId, \"_grist_Cells\"), {\n      id: [1, 2, 3, 4],\n    });\n  });\n\n  async function censorChat(censor: boolean) {\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Chat\", 1, { Public: censor ? 0 : 1 }],\n    ]);\n  }\n\n  async function read(client: GristClient, chat: string, message: any, from: string) {\n    const cells = await owner.getTable(docId, \"_grist_Cells\");\n    deepEqual(await client.readDocUserAction(), [\n      [\"AddRecord\", \"_grist_Cells\", cells.id.length, {\n        content: message,\n        userRef: from,\n        colRef: await colRef(\"Chat\", chat),\n        tableRef: await tableRef(\"Chat\"),\n        rowId: 1,\n        root: true,\n        type: 1,\n        parentId: 0,\n        timeCreated: cells.timeCreated[cells.id.length - 1],\n        timeUpdated: cells.timeUpdated[cells.id.length - 1],\n        resolved: cells.resolved[cells.id.length - 1],\n      }],\n    ]);\n  }\n\n  async function send(api: UserAPI, chat: string, message: string, rowId = 1) {\n    await api.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, {\n        tableRef: await tableRef(\"Chat\"),\n        colRef: await colRef(\"Chat\", chat),\n        rowId,\n        type: 1,\n        root: true,\n        // userRef is set automatically by the data engine\n        content: message,\n      }],\n    ]);\n  }\n\n  function deepEqual(a: any, b: any) {\n    assert.deepEqual(a, b, `Expected \\n${JSON.stringify(a)} to equal \\n${JSON.stringify(b)}`);\n  }\n\n  function deepContains(a: any, b: any) {\n    a = { ...a };\n    Object.keys(a).filter(key => !(key in b)).forEach(key => delete a[key]);\n    assert.deepEqual(a, b, `Expected \\n${JSON.stringify(a)} to equal \\n${JSON.stringify(b)}`);\n  }\n\n  async function tableRef(tableId: string) {\n    const tables = await owner.getTable(docId, \"_grist_Tables\");\n    return tables.id[tables.tableId.findIndex(id => id === tableId)];\n  }\n\n  async function colRef(tableId: string, colId: string) {\n    const tRef = await tableRef(tableId);\n    const columns = await owner.getTable(docId, \"_grist_Tables_column\");\n    return columns.id[columns.colId.findIndex(\n      (val, idx) => val === colId && tRef === columns.parentId[idx])\n    ];\n  }\n});\n"
  },
  {
    "path": "test/server/lib/CommentAccess2.ts",
    "content": "import { UserAPI } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { GristClient, openClient } from \"test/server/gristClient\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\n/**\n * Test suite for comment ownership rules.\n *\n * These tests verify that the following rules are enforced:\n * - Author of comment can edit the text of the comment (OWNER of document isn't enough)\n * - Author of comment or OWNER of document can delete the comment\n * - Full thread is deleted when the cell it's attached to is deleted\n * - Author of first comment in thread or OWNER of document can resolve the thread\n * - Timestamps cannot be modified by anyone\n * - Author identifier (userRef) cannot be modified by anyone\n *\n * Note: OWNER of document can reupload modified document, so nothing is protected from the OWNER.\n */\ndescribe(\"CommentAccess2\", function() {\n  this.timeout(\"80s\");\n  let home: TestServer;\n  testUtils.setTmpLogLevel(\"error\");\n  let owner: UserAPI;\n  let editor: UserAPI;\n  let docId: string;\n  let wsId: number;\n  let cliOwner: GristClient;\n  let cliEditor: GristClient;\n  let oldEnv: testUtils.EnvironmentSnapshot;\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    process.env.GRIST_NOTIFIER = \"test\";\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n    const api = await home.createHomeApi(\"chimpy\", \"docs\", true);\n    await api.newOrg({ name: \"testy\", domain: \"testy\" });\n    owner = await home.createHomeApi(\"chimpy\", \"testy\", true);\n    wsId = await owner.newWorkspace({ name: \"ws\" }, \"current\");\n    await owner.updateWorkspacePermissions(wsId, {\n      users: {\n        \"charon@getgrist.com\": \"editors\",\n      },\n    });\n    editor = await home.createHomeApi(\"charon\", \"testy\", true);\n  });\n\n  beforeEach(async function() {\n    docId = await owner.newDoc({ name: \"doc\" }, wsId);\n    cliEditor = await getWebsocket(editor);\n    cliOwner = await getWebsocket(owner);\n    await cliEditor.openDocOnConnect(docId);\n    await cliOwner.openDocOnConnect(docId);\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"Table1\", null, {}],\n    ]);\n    cliEditor.flush();\n    cliOwner.flush();\n  });\n\n  afterEach(async function() {\n    if (docId) {\n      for (const cli of [cliEditor, cliOwner]) {\n        try {\n          await cli.send(\"closeDoc\", 0);\n        } catch (e) {\n          // Do not worry if socket is already closed by the other side.\n          if (!String(e).match(/WebSocket is not open/)) {\n            throw e;\n          }\n        }\n        await cli.close();\n      }\n      docId = \"\";\n    }\n  });\n\n  after(async function() {\n    const api = await home.createHomeApi(\"chimpy\", \"docs\");\n    await api.deleteOrg(\"testy\");\n    await home.stop();\n    oldEnv.restore();\n  });\n\n  it(\"allows only creator of the comment to edit it\", async function() {\n    // Add a comment as an editor\n    await comment(editor, \"This is editor's comment\");\n\n    // Update the comment as editor - should work\n    await updateComment(editor, 1, { text: \"Edited by editor\" });\n\n    // Now try to update it as a doc owner - should fail\n    await assert.isRejected(updateComment(owner, 1, { text: \"Owner edit\" }));\n\n    // Check if comment still has the editor's text\n    const commentData = await getComment(1);\n    assert.equal(commentData.text, \"Edited by editor\");\n  });\n\n  it(\"allows comment author to delete their own comment\", async function() {\n    // Add a comment as editor\n    await comment(editor, \"Editor's comment to delete\");\n\n    // Editor should be able to delete their own comment\n    await editor.applyUserActions(docId, [[\"RemoveRecord\", \"_grist_Cells\", 1]]);\n\n    // Verify comment is deleted\n    const cells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(cells.id.length, 0);\n  });\n\n  it(\"allows document owner to delete any comment\", async function() {\n    // Add a comment as editor\n    await comment(editor, \"Editor's comment\");\n\n    // Verify comment exists\n    let cells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(cells.id.length, 1);\n\n    // Owner should be able to delete editor's comment\n    await owner.applyUserActions(docId, [\n      [\"RemoveRecord\", \"_grist_Cells\", 1],\n    ]);\n\n    // Verify comment is deleted\n    cells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(cells.id.length, 0);\n  });\n\n  it(\"prevents non-author from deleting comment\", async function() {\n    // Add a comment as owner\n    await comment(owner, \"Owner's comment\");\n\n    // Editor should NOT be able to delete owner's comment\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"RemoveRecord\", \"_grist_Cells\", 1],\n    ]));\n\n    // Verify comment still exists\n    const cells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(cells.id.length, 1);\n    const content = JSON.parse(String(cells.content[0]));\n    assert.equal(content.text, \"Owner's comment\");\n  });\n\n  it(\"deletes full thread when cell is deleted as owner\", async function() {\n    // Add a root comment as editor\n    await comment(editor, \"Root comment\");\n\n    // Add a reply to the comment\n    await comment(owner, \"Reply to comment\", 1);\n\n    // Verify we have 2 comments (root + reply)\n    let cells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(cells.id.length, 2);\n\n    // Delete the first row (root comment)\n    await owner.applyUserActions(docId, [\n      [\"RemoveRecord\", \"Table1\", 1],\n    ]);\n\n    // Verify all comments are deleted\n    cells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(cells.id.length, 0);\n  });\n\n  it(\"allows thread author to resolve thread\", async function() {\n    // Add a comment as editor (thread author)\n    await comment(editor, \"Thread to resolve\");\n\n    // Editor should be able to update content to mark it resolved\n    await updateComment(editor, 1, { resolved: true });\n  });\n\n  it(\"allows document owner to resolve any thread\", async function() {\n    // Add a comment as editor\n    await comment(editor, \"Editor's thread\");\n\n    // Owner should be able to update to mark it resolved\n    await updateComment(owner, 1, { resolved: true });\n\n    // Verify thread is resolved\n    const flatComment = await getComment(1);\n    assert.isTrue(flatComment.resolved);\n  });\n\n  it(\"prevents non-author from resolving thread\", async function() {\n    // Add a comment as owner\n    await comment(owner, \"Owner's thread\");\n\n    // Editor should NOT be able to update owner's comment\n    await assert.isRejected(\n      updateComment(editor, 1, { resolved: true }),\n    );\n\n    // Verify thread is not resolved\n    const commentData = await getComment(1);\n    assert.isFalse(commentData.resolved);\n  });\n\n  it(\"prevents modification of timestamps in content\", async function() {\n    // Add a comment as editor\n    await comment(editor, \"Test comment\");\n\n    // Get the original timestamps\n    const originalComment = await getComment(1);\n    const originalTimeCreated = originalComment.timeCreated;\n\n    // Try to modify timestamps directly - should fail with error\n    await assert.isRejected(\n      editor.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_Cells\", 1, { timeCreated: 999999 }],\n      ]),\n      /Cannot modify timeCreated field directly/,\n    );\n    await assert.isRejected(\n      editor.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_Cells\", 1, { timeUpdated: 888888 }],\n      ]),\n      /Cannot modify timeUpdated field directly/,\n    );\n\n    // Verify timestamps haven't changed\n    const updatedComment = await getComment(1);\n    assert.equal(updatedComment.timeCreated, originalTimeCreated);\n  });\n\n  it(\"prevents modification of userRef\", async function() {\n    // Add a comment as editor\n    await comment(editor, \"Test comment\");\n\n    // Get the original userRef\n    const editorRef = (await editor.getSessionActive()).user.ref || \"\";\n    const ownerRef = (await owner.getSessionActive()).user.ref || \"\";\n    const originalComment = await getComment(1);\n    assert.equal(originalComment.userRef, editorRef);\n\n    // Try to change userRef as the author - should fail\n    await assert.isRejected(\n      editor.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_Cells\", 1, { userRef: ownerRef }],\n      ]),\n      /Cannot modify userRef field directly/,\n    );\n\n    // Verify userRef hasn't changed\n    let updatedComment = await getComment(1);\n    assert.equal(updatedComment.userRef, editorRef);\n\n    // Try to change userRef as the owner - should also fail\n    await assert.isRejected(\n      owner.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_Cells\", 1, { userRef: ownerRef }],\n      ]),\n      /Cannot modify userRef field directly/,\n    );\n\n    // Verify userRef still hasn't changed\n    updatedComment = await getComment(1);\n    assert.equal(updatedComment.userRef, editorRef);\n  });\n\n  it(\"allows replies to be edited by their authors only\", async function() {\n    // Add a root comment by owner\n    await comment(owner, \"Root comment\");\n\n    // Add a reply by editor\n    await comment(editor, \"Editor's reply\", 1);\n\n    // Editor can edit their reply\n    await updateComment(editor, 2, { text: \"Editor updated reply\" });\n\n    // Owner cannot edit editor's reply\n    await assert.isRejected(\n      updateComment(owner, 2, { text: \"Owner tries to update\" }),\n    );\n\n    // But owner can delete editor's reply (owner has delete permission)\n    await owner.applyUserActions(docId, [\n      [\"RemoveRecord\", \"_grist_Cells\", 2],\n    ]);\n\n    const finalCells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(finalCells.id.length, 1);\n  });\n\n  it(\"allows editor to delete thread with owner replies\", async function() {\n    // Add a root comment by editor\n    await comment(editor, \"Editor's root comment\");\n\n    // Add a reply by owner\n    await comment(owner, \"Owner's reply\", 1);\n\n    // Verify we have 2 comments (root + reply)\n    let cells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(cells.id.length, 2);\n\n    // Editor (author of root comment) should be able to delete the thread\n    await editor.applyUserActions(docId, [\n      [\"RemoveRecord\", \"_grist_Cells\", 1],\n    ]);\n\n    // All comments are now removed.\n    cells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(cells.id.length, 0);\n  });\n\n  it(\"allows owner to delete thread with editor replies\", async function() {\n    // Add a root comment by owner\n    await comment(owner, \"Owner's root comment\");\n\n    // Add a reply by editor\n    await comment(editor, \"Editor's reply\", 1);\n\n    // Verify we have 2 comments (root + reply)\n    let cells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(cells.id.length, 2);\n\n    // Owner should be able to delete the thread, as any other user, and the someone's else\n    // reply is auto deleted as part of the thread.\n    await owner.applyUserActions(docId, [\n      [\"RemoveRecord\", \"_grist_Cells\", 1],\n    ]);\n\n    // All comments are now removed.\n    cells = await owner.getTable(docId, \"_grist_Cells\");\n    assert.equal(cells.id.length, 0);\n  });\n\n  async function getWebsocket(api: UserAPI) {\n    const who = await api.getSessionActive();\n    return openClient(home.server, who.user.email, who.org?.domain || \"docs\");\n  }\n\n  async function comment(api: UserAPI, message: string, parentId?: number) {\n    const row: any = {\n      tableRef: await tableRef(\"Table1\"),\n      colRef: await colRef(\"Table1\", \"A\"),\n      rowId: 1,\n      type: 1,\n      root: parentId !== undefined ? false : true,\n      // userRef is set automatically by the data engine\n      content: JSON.stringify({\n        text: message,\n        mentions: [],\n        sectionId: 1,\n      }),\n    };\n    if (parentId !== undefined) {\n      row.parentId = parentId;\n    }\n    await api.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Cells\", null, row],\n    ]);\n  }\n\n  async function getComment(commentId: number) {\n    const cells = await owner.getTable(docId, \"_grist_Cells\");\n    const idx = cells.id.indexOf(commentId);\n    if (idx === -1) {\n      throw new Error(`Comment ${commentId} not found`);\n    }\n    const content = JSON.parse(String(cells.content[idx]));\n    return {\n      id: cells.id[idx],\n      userRef: cells.userRef[idx],\n      timeCreated: cells.timeCreated[idx],\n      timeUpdated: cells.timeUpdated[idx],\n      resolved: cells.resolved[idx],\n      text: content.text,\n    };\n  }\n\n  async function updateComment(api: UserAPI, commentId: number, updates: { text?: string, resolved?: boolean }) {\n    // Read current state\n    const cells = await owner.getTable(docId, \"_grist_Cells\");\n    const idx = cells.id.indexOf(commentId);\n    if (idx === -1) {\n      throw new Error(`Comment ${commentId} not found`);\n    }\n\n    const tableUpdates: any = {};\n\n    // Handle table-level fields\n    if (\"resolved\" in updates) {\n      tableUpdates.resolved = updates.resolved;\n    }\n\n    // Handle content field (text)\n    if (\"text\" in updates) {\n      const currentContent = JSON.parse(String(cells.content[idx]));\n      const newContent = { ...currentContent, text: updates.text };\n      tableUpdates.content = JSON.stringify(newContent);\n    }\n\n    // Apply the update\n    await api.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Cells\", commentId, tableUpdates],\n    ]);\n  }\n\n  async function tableRef(tableId: string) {\n    const tables = await owner.getTable(docId, \"_grist_Tables\");\n    return tables.id[tables.tableId.findIndex(id => id === tableId)];\n  }\n\n  async function colRef(tableId: string, colId: string) {\n    const tRef = await tableRef(tableId);\n    const columns = await owner.getTable(docId, \"_grist_Tables_column\");\n    return columns.id[columns.colId.findIndex(\n      (val, idx) => val === colId && tRef === columns.parentId[idx])\n    ];\n  }\n});\n"
  },
  {
    "path": "test/server/lib/ConfigBackendAPI.ts",
    "content": "import { AuthProvider } from \"app/common/ConfigAPI\";\nimport { AppSettings } from \"app/server/lib/AppSettings\";\nimport { _fillProviderInfo } from \"app/server/lib/ConfigBackendAPI\";\n\nimport { assert } from \"chai\";\n\nfunction createMockSettings(opts: {\n  loginSystemType?: string;\n  loginSystemTypeSource?: \"env\" | \"default\" | null;\n  active?: string;\n  error?: string;\n} = {}) {\n  const settings = new AppSettings(\"test\");\n\n  const loginSection = settings.section(\"login\");\n  const activeFlag = loginSection.flag(\"active\");\n  if (opts.active) {\n    activeFlag.set(opts.active);\n  }\n  const errorFlag = loginSection.flag(\"error\");\n  if (opts.error) {\n    errorFlag.set(opts.error);\n  }\n\n  if (opts.loginSystemType !== undefined || opts.loginSystemTypeSource !== undefined) {\n    const typeFlag = loginSection.flag(\"type\");\n    if (opts.loginSystemType) {\n      typeFlag.readString = () => opts.loginSystemType;\n      typeFlag.describe = () => ({ source: opts.loginSystemTypeSource ?? \"default\" } as any);\n    } else {\n      typeFlag.readString = () => undefined;\n      typeFlag.describe = () => ({ source: null } as any);\n    }\n  }\n\n  return settings;\n}\n\ndescribe(\"ConfigBackendAPI\", () => {\n  describe(\"fillProviderInfo\", () => {\n    it(\"should handle empty providers list\", () => {\n      const result = _fillProviderInfo({\n        newSettings: createMockSettings(),\n        currentSettings: createMockSettings(),\n        providers: [],\n      });\n      assert.deepEqual(result, []);\n    });\n\n    it(\"should mark active configured provider\", () => {\n      const providers: AuthProvider[] = [\n        { key: \"saml\", name: \"SAML\", isConfigured: false },\n        { key: \"oidc\", name: \"OIDC\", isConfigured: true },\n      ];\n      const result = _fillProviderInfo({\n        newSettings: createMockSettings(),\n        currentSettings: createMockSettings({ active: \"oidc\" }),\n        providers,\n      });\n      assert.deepEqual(result[0], {\n        key: \"saml\",\n        name: \"SAML\",\n      });\n      assert.deepEqual(result[1], {\n        key: \"oidc\",\n        name: \"OIDC\",\n        isConfigured: true,\n        isActive: true,\n      });\n    });\n\n    it(\"should detect change by configuration\", () => {\n      // Now both are configured, but oidc is current, so Grist will switch to saml as it is first in preference.\n      const providers: AuthProvider[] = [\n        { key: \"saml\", name: \"SAML\", isConfigured: true },\n        { key: \"oidc\", name: \"OIDC\", isConfigured: true },\n      ];\n      const result = _fillProviderInfo({\n        newSettings: createMockSettings(),\n        currentSettings: createMockSettings({ active: \"oidc\" }),\n        providers,\n      });\n      assert.deepEqual(result[0], {\n        key: \"saml\",\n        name: \"SAML\",\n        willBeActive: true,\n        isConfigured: true,\n      });\n      assert.deepEqual(result[1], {\n        key: \"oidc\",\n        name: \"OIDC\",\n        willBeDisabled: true,\n        isConfigured: true,\n        canBeActivated: true,\n      });\n    });\n\n    it(\"should respect selection by database\", () => {\n      const providers: AuthProvider[] = [\n        { key: \"saml\", name: \"SAML\", isConfigured: true },\n        { key: \"oidc\", name: \"OIDC\", isConfigured: true },\n      ];\n      const result = _fillProviderInfo({\n        newSettings: createMockSettings({ loginSystemType: \"oidc\" }),\n        currentSettings: createMockSettings({ active: \"saml\" }),\n        providers,\n      });\n      assert.deepEqual(result[0], {\n        key: \"saml\",\n        name: \"SAML\",\n        willBeDisabled: true,\n        canBeActivated: true,\n        isConfigured: true,\n      });\n      assert.deepEqual(result[1], {\n        key: \"oidc\",\n        name: \"OIDC\",\n        willBeActive: true,\n        isConfigured: true,\n      });\n    });\n\n    it(\"should respect selection by env variable and not offer to change the method\", () => {\n      const providers: AuthProvider[] = [\n        { key: \"saml\", name: \"SAML\", isConfigured: true },\n        { key: \"oidc\", name: \"OIDC\", isConfigured: true },\n      ];\n      const result = _fillProviderInfo({\n        newSettings: createMockSettings({ loginSystemType: \"saml\", loginSystemTypeSource: \"env\" }),\n        currentSettings: createMockSettings({ active: \"oidc\" }),\n        providers,\n      });\n      assert.deepEqual(result[0], {\n        key: \"saml\",\n        name: \"SAML\",\n        willBeActive: true,\n        isConfigured: true,\n        isSelectedByEnv: true,\n      });\n      assert.deepEqual(result[1], {\n        key: \"oidc\",\n        name: \"OIDC\",\n        willBeDisabled: true,\n        isConfigured: true,\n      });\n    });\n\n    it(\"should show config error and prevent activation\", () => {\n      const providers: AuthProvider[] = [\n        { key: \"oidc\", name: \"OIDC\", configError: \"config error\" },\n        { key: \"saml\", name: \"SAML\", isConfigured: true },\n      ];\n\n      // Currently nothing is selected - so Grist is using minimal system. But user tried\n      // to configure oidc but there were some errors.\n      const result = _fillProviderInfo({\n        newSettings: createMockSettings(),\n        currentSettings: createMockSettings(),\n        providers,\n      });\n\n      // OIDC has config error but will still be picked as \"next\" (first in list)\n      assert.deepEqual(result[0], {\n        key: \"oidc\",\n        name: \"OIDC\",\n        configError: \"config error\",\n        willBeActive: true, // because it is first in list and has config error\n      });\n\n      // SAML is configured and can be activated, but it is not selected as it is second in list.\n      assert.deepEqual(result[1], {\n        key: \"saml\",\n        name: \"SAML\",\n        isConfigured: true,\n        canBeActivated: true,\n      });\n    });\n\n    it(\"should show runtime error for active provider that is configured properly\", () => {\n      const providers: AuthProvider[] = [\n        { key: \"oidc\", name: \"OIDC\", isConfigured: true },\n        { key: \"saml\", name: \"SAML\", isConfigured: true },\n      ];\n      const result = _fillProviderInfo({\n        newSettings: createMockSettings(),\n        currentSettings: createMockSettings({ active: \"oidc\", error: \"Failed to initialize OIDC client\" }),\n        providers,\n      });\n\n      // Config error and runtime error are the same, so only runtimeError is shown\n      assert.deepEqual(result[0], {\n        key: \"oidc\",\n        name: \"OIDC\",\n        isActive: true,\n        isConfigured: true,\n        activeError: \"Failed to initialize OIDC client\",\n      });\n\n      // SAML is configured and can be activated\n      assert.deepEqual(result[1], {\n        key: \"saml\",\n        name: \"SAML\",\n        isConfigured: true,\n        canBeActivated: true,\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/DocSnapshots.ts",
    "content": "import { ObjSnapshot, ObjSnapshotWithMetadata } from \"app/common/DocSnapshot\";\nimport { SnapshotWindow } from \"app/common/Features\";\nimport { DocSnapshotPruner, IInventory } from \"app/server/lib/DocSnapshots\";\nimport { ExternalStorage } from \"app/server/lib/ExternalStorage\";\n\nimport { assert } from \"chai\";\nimport moment from \"moment\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"DocSnapshots\", async function() {\n  describe(\"DocSnapshotPruner\", async function() {\n    function makeStore(snapshots: ObjSnapshot[]): ExternalStorage {\n      return {\n        versions() { return Promise.resolve(snapshots); },\n        exists() { throw new Error(\"not implemented\"); },\n        head() { throw new Error(\"not implemented\"); },\n        upload() { throw new Error(\"not implemented\"); },\n        download() { throw new Error(\"not implemented\"); },\n        remove() { throw new Error(\"not implemented\"); },\n        url() { throw new Error(\"not implemented\"); },\n        isFatalError() { throw new Error(\"not implemented\"); },\n        close() { throw new Error(\"not implemented\"); },\n      };\n    }\n\n    // Crude estimation of how many versions to expect with current strategy.\n    function estimateVersionCount(snapshots: ObjSnapshot[]): number {\n      const diff = maxDiff([snapshots[0], snapshots[snapshots.length - 1]], \"days\");\n      let total: number = 5;                                    // first 5 versions saved\n      total += Math.min(25, Math.ceil(diff * 24));              // per-hour for first 25 hours\n      total += Math.min(31, Math.max(0, Math.ceil(diff) - 1));  // per-day for next 31 days\n      total += Math.min(8, Math.max(0, Math.ceil(diff / 7) - 4));   // per-week for next 8 weeks\n      total += Math.min(33, Math.max(0, Math.ceil(diff / 32) - 3)); // per-month for next 33 months\n      total += Math.max(0, Math.max(0, Math.ceil(diff / 365) - 3)); // per-year after first 3 years\n      return total;\n    }\n\n    function maxDiff(snapshots: ObjSnapshot[], scale: \"days\" | \"hours\"): number {\n      let result: number = 0;\n      for (const [index, snapshot] of snapshots.slice(1).entries()) {\n        const prev = snapshots[index];\n        const diff = moment(prev.lastModified).diff(snapshot.lastModified, scale, true);\n        assert.isAtLeast(diff, 0.0);\n        result = Math.max(result, diff);\n      }\n      return result;\n    }\n\n    /**\n     * given a string of form ['+time1', '+time2', '-time3', ....], check that\n     * with snapshots created at the specified times, pruning will end up keeping\n     * the ones marked with a '+' and removing the ones marked with a '-'.\n     */\n    async function checkDecisions(times: string[], options: { timezone?: string, window?: SnapshotWindow } = {}) {\n      const snapshotsWithMetadata: ObjSnapshotWithMetadata[] = times.map(t => ({\n        lastModified: moment(t.split(\" | \")[0].slice(1)).toISOString(),\n        snapshotId: t,\n        metadata: {\n          ...(t.includes(\"|\") ? { label: t.split(\" | \")[1] } : undefined),\n          ...options.timezone && { tz: options.timezone },\n        },\n      }));\n\n      // Check that versions are classified as we expect.\n      const inventory: IInventory = {\n        getSnapshotWindow: async () => options.window,\n        async versions() { return snapshotsWithMetadata; },\n        async remove() { /* nothing to do */ },\n      };\n      const pruner = new DocSnapshotPruner(inventory);\n      const versions = await pruner.classify(\"doc\");\n      // Versions are of form '+time1', '-time2', etc.\n      // If we take a version name, like '-time2', strip the first character to get 'time2'\n      // and then prefix a '+' or a '-' based on whether the version was kept, then we should\n      // end up back where we started - IF the expected classification was made.\n      assert.deepEqual(times, versions.map(v => (v.keep ? \"+\" : \"-\") + v.snapshot.snapshotId.slice(1)));\n\n      // Check that ext.remove is called with the versions we expect to be removed.\n      const remove = sinon.stub(inventory, \"remove\");\n      pruner.requestPrune(\"doc\");\n      await pruner.wait();\n      assert.deepEqual(remove.getCall(0).args[1], versions.filter(v => !v.keep).map(v => v.snapshot.snapshotId));\n    }\n\n    it(\"selects reasonable versions to prune in a 10 day history\", async function() {\n      // Create versions over 10 days in minute intervals\n      const snapshots: ObjSnapshot[] = [...Array(10 * 24 * 60).keys()].reverse().map((t, i) => ({\n        lastModified: new Date(1600000000 + t * 60 * 1000).toISOString(),\n        snapshotId: `v${i}`,\n      }));\n      // Prune versions\n      const pruner = new DocSnapshotPruner(makeStore(snapshots));\n      const versions = await pruner.classify(\"doc\");\n      const remaining = versions.filter(v => v.keep).map(v => v.snapshot);\n      // Check there are a sane number of versions\n      const count = estimateVersionCount(snapshots);\n      assert.isAtMost(remaining.length, count * 1.1);\n      assert.isAtLeast(remaining.length, count * 0.9);\n      assert.equal(remaining.length, 39);  // Here's what it is in tests.\n      // Check the maximum difference between successive versions is 1 day\n      assert.isAtMost(maxDiff(remaining, \"days\"), 1.0);\n      // Check that the newest versions match exactly\n      assert.equal(snapshots[0].snapshotId, remaining[0].snapshotId);\n      // Check that the oldest versions differ by at most a day\n      assert.isAtMost(maxDiff([remaining[remaining.length - 1],\n        snapshots[snapshots.length - 1]], \"days\"), 1.0);\n    });\n\n    it(\"selects reasonable versions to prune in a 100 day history\", async function() {\n      // Create versions over 100 days in hourly intervals\n      const snapshots: ObjSnapshot[] = [...Array(100 * 24).keys()].reverse().map((t, i) => ({\n        lastModified: new Date(1404040400 + t * 60 * 60 * 1000).toISOString(),\n        snapshotId: `v${i}`,\n      }));\n      // Prune versions\n      const pruner = new DocSnapshotPruner(makeStore(snapshots));\n      const versions = await pruner.classify(\"doc\");\n      const remaining = versions.filter(v => v.keep).map(v => v.snapshot);\n      // Check there are a sane number of versions\n      const count = estimateVersionCount(snapshots);\n      assert.isAtMost(remaining.length, count * 1.1);\n      assert.isAtLeast(remaining.length, count * 0.9);\n      assert.equal(remaining.length, 66);  // Here's what it is in tests.\n      // Check the maximum difference between successive versions is 1 month\n      assert.isAtMost(maxDiff(remaining, \"days\"), 31.0);\n      // Check that the newest versions match exactly\n      assert.equal(snapshots[0].snapshotId, remaining[0].snapshotId);\n      // Check that the oldest versions differ by at most a month\n      assert.isAtMost(maxDiff([remaining[remaining.length - 1],\n        snapshots[snapshots.length - 1]], \"days\"), 31.0);\n    });\n\n    it(\"selects versions that allow gaps\", async function() {\n      // Construct a test case where versions in different hours are scattered in bursts.\n      // Some preamble first:\n      const times = [\n        \"+2000-09-09 09:30Z\",  // most recent five versions will be kept\n        \"+2000-09-09 09:29Z\",\n        \"+2000-09-09 09:28Z\",\n        \"+2000-09-09 09:27Z\",\n        \"+2000-09-09 09:26Z\",\n        \"-2000-09-09 09:25Z\",  // dropped since same hour as a kept version\n        \"+2000-09-09 08:59Z\",  // kept since in a new hour - first use of this rule.\n        \"-2000-09-09 08:58Z\",  // dropped since same hour as a kept version\n      ];\n      // Twelve versions in different hours the day before should be preserved\n      for (let i = 0; i < 12; i++) {\n        const hour = String(23 - i).padStart(2, \"0\");\n        times.push(`+2000-09-08 ${hour}:15Z`);\n      }\n      // Twelve versions in different hours many days ago should be preserved\n      for (let i = 0; i < 12; i++) {\n        const hour = String(23 - i).padStart(2, \"0\");\n        times.push(`+2000-05-08 ${hour}:15Z`);\n      }\n      // But that's it!  We used up our quota of 25 hours.\n      times.push(`-2000-05-08 00:15Z`);\n      await checkDecisions(times);\n    });\n\n    it(\"selects versions that match human reading of rules on a test case\", async function() {\n      const times = [\n        // 2000-09-09 was a Saturday.\n        \"+2000-09-09 09:30Z\",\n        \"+2000-09-09 09:29Z\",\n        \"+2000-09-09 09:28Z\",\n        \"+2000-09-09 09:27Z\",\n        \"+2000-09-09 09:26Z\",\n        \"-2000-09-09 09:25Z\",\n        \"-2000-09-09 09:24Z\",\n        \"-2000-09-09 09:23Z\",\n        \"-2000-09-09 09:00Z\",\n        \"+2000-09-09 08:59Z\",  // because: new hour\n        \"-2000-09-09 08:58Z\",\n        \"-2000-09-09 08:01Z\",\n        \"+2000-09-09 07:30Z\",  // because: new hour\n        \"-2000-09-09 07:20Z\",\n        \"+2000-09-09 00:10Z\",  // because: new hour\n        \"-2000-09-09 00:00Z\",\n        \"+2000-09-08 23:59Z\",  // because: new hour, day\n        \"-2000-09-08 23:50Z\",\n        \"-2000-09-08 23:20Z\",\n        \"+2000-09-08 18:50Z\",  // because: new hour\n        \"-2000-09-08 18:20Z\",\n        \"+2000-09-08 09:00Z\",  // because: new hour\n        \"+2000-09-08 08:00Z\",  // because: new hour\n        \"+2000-09-08 07:00Z\",  // because: new hour\n        \"+2000-09-08 00:30Z\",  // because: new hour\n        \"+2000-09-07 23:45Z\",  // because: new hour, day\n        \"-2000-09-07 23:44Z\",\n        \"+2000-09-07 05:44Z\",  // because: new hour\n        \"+2000-09-06 05:44Z\",  // because: new hour, day\n        \"+2000-09-03 12:00Z\",  // because: new hour, day, isoWeek\n        \"+2000-09-03 06:00Z\",  // because: new hour\n        \"+2000-08-31 15:44Z\",  // because: new hour, day, month\n        \"+2000-08-31 12:00Z\",  // because: new hour\n        \"+2000-08-22 15:44Z\",  // because: new hour, day, isoWeek\n        \"+2000-08-09 15:44Z\",  // because: new hour, day, isoWeek\n        \"+2000-08-08 07:44Z\",  // because: new hour, day\n        \"+2000-08-07 15:44Z\",  // because: new hour, day\n        \"+2000-08-06 15:44Z\",  // because: new hour, day, isoWeek\n        \"+2000-08-05 15:44Z\",  // because: new hour, day\n        \"+2000-08-01 15:44Z\",  // because: new hour, day\n        \"+2000-07-31 15:44Z\",  // because: new hour, day, month\n        \"+2000-07-30 15:44Z\",  // because: new hour, day, isoWeek\n        \"+2000-07-28 15:44Z\",  // because: new day\n        \"+2000-07-24 15:44Z\",  // because: new day\n        \"+2000-07-23 15:44Z\",  // because: new day, isoWeek\n        \"+2000-07-14 15:44Z\",  // because: new day, isoWeek\n        \"+2000-07-11 15:44Z\",  // because: new day\n        \"+2000-07-01 15:44Z\",  // because: new day, isoWeek\n        \"+2000-06-28 15:44Z\",  // because: new day, month\n        \"+2000-06-26 00:00Z\",  // because: new day\n        \"+2000-06-19 00:00Z\",  // because: new day, isoWeek\n        \"+2000-06-02 00:00Z\",  // because: new day, isoWeek\n        \"+2000-05-25 00:00Z\",  // because: new day, isoWeek, month\n        \"+2000-05-15 00:00Z\",  // because: new day, isoWeek\n        \"+2000-05-05 00:00Z\",  // because: new day\n        \"+2000-04-30 23:59Z\",  // because: new day, month\n        \"+2000-04-15 00:00Z\",  // because: new day\n        \"+2000-04-01 00:00Z\",  // because: new day\n        \"+2000-03-10 12:00Z\",  // because: new day, month\n        \"+2000-02-01 12:00Z\",  // because: new day, month\n        \"+2000-01-20 12:00Z\",  // because: new month\n        \"-2000-01-01 00:00Z\",\n        \"+1999-12-31 23:59Z\",  // because: new month, year\n        \"-1999-12-03 23:59Z\",\n        \"+1999-11-20 23:59Z\",  // because: new month\n        \"+1999-06-20 23:59Z\",  // because: new month\n        \"-1999-06-01 00:00Z\",\n        \"+1999-01-20 23:59Z\",  // because: new month\n        \"-1999-01-05 00:00Z\",\n        \"+1998-12-15 23:59Z\",  // because: new month, year\n        \"+1998-06-15 23:59Z\",  // because: new month\n        \"+1998-02-15 23:59Z\",  // because: new month\n        \"-1998-02-03 23:59Z\",\n        \"+1997-10-03 23:59Z\",  // because: new month, year\n        \"+1997-09-12 23:59Z\",  // because: new month\n        \"+1997-08-03 23:59Z\",  // because: new month\n        \"+1997-05-05 23:59Z\",  // because: new month\n        \"+1997-01-01 00:00Z\",  // because: new month\n        \"+1996-01-01 00:00Z\",  // because: new month, year\n        \"+1995-05-01 00:00Z\",  // because: new month, year\n        \"+1995-01-01 00:00Z\",  // because: new month\n        \"+1990-04-05 00:00Z\",  // because: new month, year\n        \"+1990-01-02 00:00Z\",  // because: new month\n        \"+1980-04-05 07:50Z\",  // because: new month, year\n        \"-1980-04-05 07:40Z\",\n      ];\n      await checkDecisions(times);\n    });\n\n    it(\"respects document timezone\", async function() {\n      const times = [\n        \"+2000-09-08 09:30Z\",\n        \"+2000-09-08 09:29Z\",\n        \"+2000-09-08 09:28Z\",\n        \"+2000-09-08 09:27Z\",\n        \"+2000-09-08 09:26Z\",\n\n        \"+2000-09-08 08:26Z\",\n      ];\n      for (let i = 23; i >= 0; i--) {\n        times.push(`+2000-09-07 ${i.toString().padStart(2, \"0\")}:12Z`);\n      }\n\n      times.push(\"+2000-09-05 01:00Z\");\n      times.push(\"+2000-09-04 23:00Z\");\n      times.push(\"-2000-09-04 12:00Z\");\n\n      // The above +/- decisions hold in UTC.\n      await checkDecisions(times);\n\n      // In a timezone that is 5 hours behind UTC, the day threshold is different.\n      times.pop();\n      times.pop();\n      times.push(\"-2000-09-04 23:00Z\");\n      times.push(\"+2000-09-04 12:00Z\");\n      await checkDecisions(times, { timezone: \"Etc/GMT-5\" });\n    });\n\n    it(\"favors labelled versions\", async function() {\n      const times = [\n        \"+2000-09-08 09:30Z\",\n        \"+2000-09-08 09:29Z\",\n        \"+2000-09-08 09:28Z\",\n        \"+2000-09-08 09:27Z\",\n        \"+2000-09-08 09:26Z\",\n\n        \"+2000-09-08 08:26Z\",\n        \"+2000-09-08 08:25Z | save\",\n        \"-2000-09-08 08:24Z\",\n        \"+2000-09-08 08:23Z | me\",\n        \"-2000-09-08 08:22Z\",\n      ];\n      await checkDecisions(times);\n    });\n\n    it(\"eventually discards labelled versions\", async function() {\n      const times = [\n        \"+2000-09-08 09:30Z\",\n        \"+2000-09-08 09:29Z\",\n        \"+2000-09-08 09:28Z\",\n        \"+2000-09-08 09:27Z\",\n        \"+2000-09-08 09:26Z\",\n        \"+1990-09-09 08:25Z\",\n        \"-1990-09-09 08:24Z | save\",\n      ];\n      await checkDecisions(times);\n    });\n\n    it(\"enforces the snapshot window\", async function() {\n      const times = [\n        \"+2000-09-08 09:30Z\",\n        \"+2000-09-08 09:29Z\",\n        \"+2000-09-01 09:28Z\",\n        \"-2000-08-08 09:27Z\",\n        \"-2000-08-08 09:26Z\",\n        \"-1990-09-09 08:25Z\",\n        \"-1990-09-09 08:24Z\",\n      ];\n      await checkDecisions(times, { window: { count: 1, unit: \"month\" } });\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/DocStorage.js",
    "content": "const assert  = require(\"chai\").assert;\nconst child_process = require(\"child_process\");\nconst fs      = require(\"fs\");\n\nconst Promise = require(\"bluebird\");\nPromise.promisifyAll(child_process);\nPromise.promisifyAll(fs);\n\nconst {ActionHistoryImpl} = require(\"app/server/lib/ActionHistoryImpl\");\nconst {DocStorage}        = require(\"app/server/lib/DocStorage\");\nconst docUtils            = require(\"app/server/lib/docUtils\");\nconst marshal             = require(\"app/common/marshal\");\nconst {createDocTools}    = require(\"test/server/docTools\");\nconst testUtils           = require(\"test/server/testUtils\");\n\ndescribe(\"DocStorage\", function() {\n\n  var docStorageManager;\n\n  // Turn off debug logging for this test, and restore afterwards.\n  testUtils.setTmpLogLevel(\"warn\");\n\n  const docTools = createDocTools();\n\n\n  // Set Grist home to a temporary directory for each test.\n  before(function() {\n    docStorageManager = docTools.getStorageManager();\n  });\n\n  describe(\".createFile\", function() {\n\n    it(\"Should create a new db if one doesn't exist\", function() {\n      var docStorage = new DocStorage(docStorageManager, \"create-file-new\");\n      return docStorage.createFile()\n      // Check that the sqlite db was created on disk.\n        .then(() => docUtils.pathExists(docStorageManager.getPath(\"create-file-new\")))\n        .then(exists => assert.isTrue(exists))\n        .then(() => docStorage.shutdown())\n\n      // Check that opening it again works, except that the table has no metadata\n        .then(() => testUtils.expectRejection(docStorage.openFile(), \"NO_METADATA_ERROR\"))\n        .then(() => docStorage.shutdown())\n\n      // Check that attempting to create it again causes an error.\n        .then(() => testUtils.expectRejection(docStorage.createFile(), \"EEXISTS\"));\n    });\n\n    it(\"Should fail if asked to open a non-existent db\", function() {\n      var docStorage = new DocStorage(docStorageManager, \"open-fail\");\n      return testUtils.expectRejection(docStorage.openFile(), \"SQLITE_CANTOPEN\");\n    });\n\n    it(\"should allow writing right after createFile\", function() {\n      let bar_rw = new DocStorage(docStorageManager, \"bar_rw\");\n      return bar_rw.createFile()\n        .then(() => fs.accessAsync(docStorageManager.getPath(\"bar_rw\"), fs.R_OK | fs.W_OK))\n        .then(() => bar_rw.execTransaction(db => db.exec(\"CREATE TABLE 'test' ('test' TEXT)\")))\n        .then(() => bar_rw.shutdown());\n    });\n\n  });\n\n  describe(\".openFile\", function() {\n    // Read all tables in DocStorage, just to run some read-only queries.\n    async function fetchAllTables(docStorage) {\n      const tableNames = await docStorage.getAllTableNames();\n      return Promise.all(tableNames.map(t => docStorage.fetchTable(t)));\n    }\n\n    it(\"should allow reading without modifying mtime\", async function() {\n      let pastTime = new Date(\"2016/1/1\");\n\n      const docName = await testUtils.useFixtureDoc(\"Hello.grist\", docStorageManager);\n      const doc = new DocStorage(docStorageManager, docName);\n      try {\n        // In WAL mode, the db file is touched initially to go into WAL mode,\n        // it is a property of the database file not just the connection.\n        // So we do an extra close-open cycle.\n        await doc.openFile();\n        await doc.shutdown();\n        // On Windows, utimes() is strangely unreliable but works when stat() is called first.\n        await fs.statAsync(docStorageManager.getPath(docName));\n        await fs.utimesAsync(docStorageManager.getPath(docName), pastTime, pastTime);\n        await doc.openFile();\n        await fetchAllTables(doc);  // Should not touch mtime, even after shutdown.\n        await doc.shutdown();\n        let stats = await fs.statAsync(docStorageManager.getPath(docName));\n        assert.equal(pastTime.getTime(), stats.mtime.getTime());\n        // Try again, but this time actually make a change.\n        await doc.openFile();\n        await doc.applyStoredActions([[\"UpdateRecord\", \"Table1\", 2, {A: \"poke!\"}]]);\n        await doc.shutdown();\n        stats = await fs.statAsync(docStorageManager.getPath(docName));\n        assert.isBelow(pastTime.getTime(), stats.mtime.getTime());\n      } finally {\n        await doc.shutdown();\n      }\n    });\n\n    it(\"should allow reading but not writing a read-only file\", async function() {\n      if (process.env.GRIST_SQLITE_MODE === \"wal\") {\n        // Doesn't really make sense in WAL mode.\n        this.skip();\n      }\n\n      let pastTime = new Date(\"2016/1/1\");\n\n      const docName = await testUtils.useFixtureDoc(\"Hello.grist\", docStorageManager);\n      // On Windows, utimes() is strangely unreliable but works when stat() is called first.\n      await fs.statAsync(docStorageManager.getPath(docName));\n      await fs.utimesAsync(docStorageManager.getPath(docName), pastTime, pastTime);\n      await fs.chmodAsync(docStorageManager.getPath(docName), 0o400);\n      const doc = new DocStorage(docStorageManager, docName);\n      await doc.openFile();\n      try {\n        await fetchAllTables(doc); // Should not touch mtime\n        await testUtils.expectRejection(\n          doc.execTransaction(db => db.exec(\"CREATE TABLE 'test' ('test' TEXT)\")),\n          \"SQLITE_READONLY\"\n        );\n        const stats = await fs.statAsync(docStorageManager.getPath(docName));\n        assert.equal(pastTime.getTime(), stats.mtime.getTime());\n      } finally {\n        await doc.shutdown();\n      }\n    });\n\n    it(\"should allow writing\", async function() {\n      let pastTime = new Date(\"2016/1/1\");\n\n      const docName = await testUtils.useFixtureDoc(\"Hello.grist\", docStorageManager);\n      const doc = new DocStorage(docStorageManager, docName);\n      // before measuring stats, open/close for WAL mode.\n      await doc.openFile();\n      try {\n        await doc.shutdown();\n        // On Windows, utimes() is strangely unreliable but works when stat() is called first.\n        await fs.statAsync(docStorageManager.getPath(docName));\n        await fs.utimesAsync(docStorageManager.getPath(docName), pastTime, pastTime);\n        await doc.openFile();\n\n        // Should touch mtime\n        await doc.execTransaction(db => db.exec(\"CREATE TABLE 'test' ('test' TEXT)\"));\n        // Need to shutdown before impact in WAL mode\n        if (process.env.GRIST_SQLITE_MODE === \"wal\") {\n          await doc.shutdown();\n        }\n        const stats = await fs.statAsync(docStorageManager.getPath(docName));\n        assert.isBelow(pastTime.getTime(), stats.mtime.getTime());\n      } finally {\n        await doc.shutdown();\n      }\n    });\n\n  });\n\n  describe(\".execTransaction\", function() {\n    function assertTableList(doc, tables) {\n      return doc.all(\"SELECT name FROM sqlite_master WHERE type='table' \" +\n        \"AND name NOT LIKE '_gristsys_%'\")\n        .then(rows => assert.deepEqual(rows.map(r => r.name), tables));\n    }\n\n    it(\"should run callback inside a transaction\", function() {\n      var docStorage = new DocStorage(docStorageManager, \"exec-txn\");\n      return docStorage.createFile()\n        .then(() => {\n        // Simple case: just run a statement that should succeed.\n          return docStorage.execTransaction(db => db.exec(\"CREATE TABLE 'Bar1' ('foo' TEXT)\"))\n          // Ensure that the Bar table exists (so the sql statement succeeded).\n            .then(() => assertTableList(docStorage, [\"Bar1\"]));\n        })\n        .then(() => {\n        // Now try running one statement that should succeed, and then failing inside the\n        // transaction; it should be rolled back along with the first statement.\n          return docStorage.execTransaction((db) => {\n            return db.exec(\"CREATE TABLE 'Bar2' ('foo' TEXT)\")\n              .then(() => { throw new Error(\"Fake error to test rollback\"); });\n          })\n            .then(\n              () => assert(false, \"Transaction should have failed\"),\n              (err) => assert.match(err.message, /Fake error to test rollback/)\n            )\n          // Ensure that the Bar2 table does NOT exist (so the transaction got rolled back).\n            .then(() => assertTableList(docStorage, [\"Bar1\"]));\n        });\n    });\n\n    it(\"should serialize execTransaction calls\", function() {\n      var docStorage = new DocStorage(docStorageManager, \"exec-serial\");\n      return docStorage.createFile()\n        .then(() => assertTableList(docStorage, []))    // Make sure there are no tables initially.\n        .then(() => {\n        // Start several transactions simultaneously, including failing ones; subsequent\n        // transaction must see the effects of previous ones, and should not be affected by\n        // previous failures.\n          return Promise.all([\n            docStorage.execTransaction(db => db.exec(\"CREATE TABLE 'Bar1' ('foo' TEXT)\")),\n            docStorage.execTransaction(db => assertTableList(docStorage, [\"Bar1\"])),\n            docStorage.execTransaction(db => db.exec(\"CREATE TABLE 'Bar1' ('foo' TEXT)\"))\n              .then(\n                () => assert(false, \"Transaction should have failed\"),\n                (err) => assert.match(err.message, /SQLITE_ERROR.*Bar1.*already exists/)\n              ),\n            docStorage.execTransaction(db => assertTableList(docStorage, [\"Bar1\"])),\n            docStorage.execTransaction(db => db.exec(\"CREATE TABLE 'Bar2' ('foo' TEXT)\")),\n            docStorage.execTransaction(db => assertTableList(docStorage, [\"Bar1\", \"Bar2\"]))\n          ]);\n        });\n    });\n  });\n\n  /** We save some statements for the beginnings of tables to simplify tests*/\n  var barSql = [\n    [\"AddTable\", \"Bar\", [\n      { \"id\": \"fname\", \"label\": \"fname\", \"type\": \"Text\", \"isFormula\": false },\n      { \"id\": \"lname\", \"label\": \"lname\", \"type\": \"Text\", \"isFormula\": false }\n    ]] ];\n\n  var fruitSql = [\n    [\"AddTable\", \"Fruits\", [\n      { \"id\": \"name\",   \"label\": \"name\", \"type\": \"Text\", \"isFormula\": false },\n      { \"id\": \"yummy\",  \"label\": \"yummy\", \"type\": \"Int\", \"isFormula\": false }\n    ]],\n    [\"AddRecord\", \"Fruits\", 1, { \"name\": \"Apple\",      \"yummy\": 2 }],\n    [\"AddRecord\", \"Fruits\", 2, { \"name\": \"Clementine\", \"yummy\": 8 }] ];\n\n  var peopleSqlSmall = [\n    [\"AddTable\", \"People\", [\n      { \"id\": \"fname\", \"label\": \"fname\", \"type\": \"Text\", \"isFormula\": false },\n      { \"id\": \"lname\", \"label\": \"lname\", \"type\": \"Text\", \"isFormula\": false }\n    ]],\n    [\"AddRecord\", \"People\", 1, { \"fname\": \"George\", \"lname\": \"Washington\"}],\n    [\"AddRecord\", \"People\", 2, { \"fname\" : \"George\", \"lname\": \"Bush\" }],\n    [\"AddRecord\", \"People\", 3, { \"fname\" : \"Ephraim\", \"lname\" : \"Williams\" }] ];\n\n  var peopleSql = [\n    [\"AddTable\", \"People\", [\n      { \"id\": \"name\", \"label\": \"name\", \"type\": \"Text\", \"isFormula\": false},\n      { \"id\": \"age\",  \"label\": \"age\", \"type\": \"Int\",  \"isFormula\": false}\n    ]],\n    [\"AddTable\", \"_grist_Tables\", [\n      { \"id\" : \"tableId\", \"type\": \"Text\", \"isFormula\": false }]],\n    [\"AddTable\", \"_grist_Tables_column\", [\n      { \"id\" : \"colId\", \"isFormula\" : false, \"type\" : \"Text\" },\n      { \"id\" : \"isFormula\", \"isFormula\" : false, \"type\": \"Bool\" },\n      { \"id\" : \"parentId\",  \"isFormula\" : false, \"type\": \"Int\"},\n      { \"id\" : \"type\",      \"isFormula\" : false, \"type\": \"Text\"}]],\n    [\"AddRecord\", \"_grist_Tables\", 1, { \"tableId\" : \"People\" }],\n    [\"AddRecord\", \"_grist_Tables_column\", 1,\n      { \"colId\" : \"name\", \"parentId\" : 1, \"isFormula\" : false, \"type\" : \"Text\"}],\n    [\"AddRecord\", \"_grist_Tables_column\", 2,\n      { \"colId\" : \"age\", \"parentId\" : 1, \"isFormula\" : false, \"type\" : \"Int\"}],\n    [\"AddRecord\", \"People\", 1, { \"name\": \"Alice\", \"age\": 12 }],\n    [\"AddRecord\", \"People\", 2, { \"name\": \"Bob\",   \"age\": 13 }] ];\n\n\n  describe(\".AddTable\", function() {\n\n    it(\"Should create a table in sqlite\", function() {\n      var docStorage = new DocStorage(docStorageManager, \"add-table-create\");\n\n      var checkQuery = \"SELECT name FROM sqlite_master \" +\n                       \"WHERE type='table' AND name='Bar'\";\n\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(barSql);\n        })\n        .then(function() {\n          return docStorage.all(checkQuery);\n        })\n        .then(function(rows) {\n          assert.deepEqual(rows, [{ \"name\": \"Bar\" }]);\n        });\n    });\n\n    it(\"Should error if creating a duplicate table\", function() {\n      var docStorage = new DocStorage(docStorageManager, \"add-table-dup\");\n      return docStorage.createFile()\n        .then(function() {\n          return testUtils.expectRejection(docStorage.applyStoredActions(barSql.concat(barSql)),\n            \"SQLITE_ERROR\", /Bar.*already exists/);\n        });\n    });\n\n  });\n\n  describe(\".AddRecord\", function() {\n\n    it(\"Should add record to a table\", function() {\n      var addRecordAction = barSql.concat([\n        [ \"AddRecord\", \"Bar\", 1, { \"fname\": \"George\", \"lname\": \"Washington\" } ],\n        [ \"AddRecord\", \"Bar\", 2, { \"fname\": \"John\", \"lname\": \"Adams\" } ],\n        [ \"AddRecord\", \"Bar\", 3, { \"fname\": \"Thomas\", \"lname\": \"Jefferson\" } ]\n      ]);\n\n      var checkQuery = \"SELECT fname, lname FROM Bar\";\n\n      var docStorage = new DocStorage(docStorageManager, \"add-rec\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(addRecordAction);\n        })\n        .then(function() {\n          return docStorage.all(checkQuery);\n        })\n        .then(function(rows) {\n          assert.deepEqual(rows, [\n            { \"fname\": \"George\", \"lname\": \"Washington\" },\n            { \"fname\": \"John\",   \"lname\": \"Adams\" },\n            { \"fname\": \"Thomas\", \"lname\": \"Jefferson\" }\n          ]);\n        });\n    });\n  });\n\n  describe(\".BulkAddRecord\", function() {\n\n    it(\"Should add multiple records to a table\", function() {\n      var bulkAddRecordAction = barSql.concat([\n        [ \"BulkAddRecord\", \"bar\", [1, 2, 3], {\n          \"fname\": [\"George\", \"John\", \"Thomas\"],\n          \"lname\": [\"Washington\", \"Adams\", \"Jefferson\"]\n        }],\n        [ \"BulkAddRecord\", \"bar\", [4, 5], {\n          \"fname\": [\"James\", \"James\"],\n          \"lname\": [\"Madison\", \"Monroe\"]\n        }]\n      ]);\n\n      var checkQuery = \"SELECT fname, lname FROM Bar\";\n\n      var docStorage = new DocStorage(docStorageManager, \"bulk-add-rec\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(bulkAddRecordAction);\n        })\n        .then(function() {\n          return docStorage.all(checkQuery);\n        })\n        .then(function(rows) {\n          assert.deepEqual(rows, [\n            { \"fname\": \"George\", \"lname\": \"Washington\" },\n            { \"fname\": \"John\",   \"lname\": \"Adams\" },\n            { \"fname\": \"Thomas\", \"lname\": \"Jefferson\" },\n            { \"fname\": \"James\",  \"lname\": \"Madison\" },\n            { \"fname\": \"James\",  \"lname\": \"Monroe\" }\n          ]);\n        });\n    });\n\n  });\n\n  describe(\".fetchTable\", function() {\n    var expectedData = {\n      \"id\": [1, 2],\n      \"fname\": [\"pen\", \"book\"],\n      \"lname\": [\"17\", \"5\"]\n    };\n\n    it(\"Should return same data as was stored into the table\", function() {\n      var docStorage = new DocStorage(docStorageManager, \"fetch-table-same\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(barSql.concat([\n            [\"AddRecord\", \"Bar\", 1, { \"fname\": \"pen\", \"lname\": \"17\" }],\n            [\"AddRecord\", \"Bar\", 2, { \"fname\": \"book\", \"lname\": \"5\" }]\n          ]));\n        })\n        .then(function() {\n          return docStorage.fetchTable(\"Bar\");\n        })\n        .then(function(tableData) {\n          assert.deepEqual(marshal.loads(tableData), expectedData);\n          return docStorage.shutdown();\n        })\n        .then(function() {\n        // Check also that a new DocStorage object for the same DB will return the same data.\n          docStorage = new DocStorage(docStorageManager, \"fetch-table-same\");\n          return testUtils.expectRejection(docStorage.openFile(), \"NO_METADATA_ERROR\");\n        })\n        .then(function() {\n          return docStorage.fetchTable(\"Bar\");\n        })\n        .then(function(tableData) {\n          assert.deepEqual(marshal.loads(tableData), expectedData);\n        });\n    });\n  });\n\n  describe(\"attachFileIfNew\", function() {\n    var docStorage;\n    it(\"should create attachment blob\", function() {\n      docStorage = new DocStorage(docStorageManager, \"test_Attachments\");\n      const correctFileContents = \"Hello, world!\";\n      const replacementFileContents = \"Another file\";\n      return docStorage.createFile()\n        .then(() => docStorage.attachFileIfNew( \"hello_world.txt\", Buffer.from(correctFileContents)))\n        .then(result => assert.isTrue(result))\n        .then(() => docStorage.getFileInfo(\"hello_world.txt\"))\n        .then(fileInfo => assert.equal(fileInfo.data.toString(\"utf8\"), correctFileContents))\n\n      // If we use the same fileIdent for another file, it should not get attached.\n        .then(() => docStorage.attachFileIfNew(\"hello_world.txt\", Buffer.from(replacementFileContents)))\n        .then(result => assert.isFalse(result))\n        .then(() => docStorage.getFileInfo(\"hello_world.txt\"))\n        .then(fileInfo => assert.equal(fileInfo.data.toString(\"utf8\"), correctFileContents))\n\n      // The update parameter should allow the record to be overwritten\n        .then(() => docStorage.attachOrUpdateFile(\"hello_world.txt\", Buffer.from(replacementFileContents), undefined))\n        .then(result => assert.isFalse(result))\n        .then(() => docStorage.getFileInfo(\"hello_world.txt\"))\n        .then(fileInfo => assert.equal(fileInfo.data.toString(\"utf8\"), replacementFileContents));\n    });\n  });\n\n  describe(\".UpdateRecord\", function() {\n\n    it(\"Should update normal (non-formula) columns\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_UpdateRecord\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(fruitSql);\n        })\n        .then(function() {\n          return docStorage.applyStoredActions([\n            [\"UpdateRecord\", \"Fruits\", 1, { \"name\": \"red apple\", \"yummy\": 0 }],\n            [\"UpdateRecord\", \"Fruits\", 2, { \"yummy\": 8 }],\n            [\"UpdateRecord\", \"Fruits\", 1, { \"name\": \"green apple\" }]\n          ]);\n        })\n        .then(function() {\n          return docStorage.all(\"SELECT name, yummy FROM Fruits\");\n        })\n        .then(function(rows) {\n          assert.deepEqual(rows, [\n            { \"name\": \"green apple\", \"yummy\": 0 },\n            { \"name\": \"Clementine\",      \"yummy\": 8 }\n          ]);\n        });\n    });\n\n  });\n\n  describe(\".RemoveRecord\", function() {\n\n    it(\"Should remove an existent record\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_RemoveRecord\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(peopleSqlSmall.concat([\n            [\"RemoveRecord\", \"People\", 2]\n          ]));\n        }).then(function() {\n          return docStorage.all(\"SELECT * FROM People\");\n        }).then(function(rows) {\n          assert.deepEqual(rows, [\n            { \"id\" : 1, \"fname\": \"George\", \"lname\": \"Washington\" },\n            { \"id\" : 3, \"fname\": \"Ephraim\", \"lname\": \"Williams\" }\n          ]);\n        });\n    });\n\n    // TODO: Do we want to throw errors when removing a nonexistant column?\n    // Indeed, when and how should we present SQL errors to the user?\n    /*it(\"Should throw an error when removing nonexistent\", function() {\n      let docStorage = new DocStorage({ docName: 'test_RemoveColumn' });\n      return docStorage.createFile()\n      .then(function() {\n        return docStorage.applyStoredActions(peopleSqlSmall);\n      }).then(function() {\n        return testUtils.expectRejection(docStorage.applyStoredActions([\n          [\"RemoveRecord\", \"People\", 4]\n        ]), \"SQLITE_ERROR\");\n      });\n    });*/\n  });\n\n  describe(\".AddColumn\", function() {\n\n    it(\"Should add a column if it doesn't already exist\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_AddColumn\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(peopleSqlSmall.concat([\n            [\"AddColumn\", \"People\", \"quality\", { \"type\" : \"Int\", \"isFormula\" : false}],\n            [\"AddRecord\", \"People\", 4, { \"fname\" : \"Frank\", \"lname\": \"Sinatra\", \"quality\" : 10 }]\n          ]));\n        }).then(function() {\n          return docStorage.all(\"SELECT * FROM People\");\n        }).then(function(rows) {\n          assert.deepEqual(rows, [\n            { \"id\" : 1, \"fname\": \"George\", \"lname\": \"Washington\", \"quality\" : 0 },\n            { \"id\" : 2, \"fname\": \"George\", \"lname\": \"Bush\", \"quality\" : 0 },\n            { \"id\" : 3, \"fname\": \"Ephraim\", \"lname\": \"Williams\", \"quality\": 0},\n            { \"id\" : 4, \"fname\": \"Frank\", \"lname\": \"Sinatra\", \"quality\" : 10}\n          ]);\n        });\n    });\n    it(\"Should throw an error when trying to add a duplicate column\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_AddColumn2\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(peopleSqlSmall);\n        }).then(function() {\n          return testUtils.expectRejection(docStorage.applyStoredActions([\n            [\"AddColumn\", \"People\", \"fname\", { \"type\" : \"Int\", \"isFormula\" : false}]\n          ]), \"SQLITE_ERROR\", /duplicate column name: fname/);\n        });\n    });\n\n  });\n\n  describe(\".RenameColumn\", function() {\n\n    it(\"Should rename a column to a valid name\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_RenameColumn\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(peopleSqlSmall.concat([\n            [\"RenameColumn\", \"People\", \"fname\", \"first_name\"],\n            [\"AddRecord\", \"People\", 4, { \"first_name\": \"Frank\", \"lname\": \"Sinatra\" }]\n          ]));\n        })\n        .then(function() {\n          return docStorage.all(\"SELECT * FROM People\")\n            .then(function(rows) {\n              assert.deepEqual(rows, [\n                { \"id\": 1, \"first_name\": \"George\", \"lname\": \"Washington\" },\n                { \"id\": 2, \"first_name\": \"George\",   \"lname\": \"Bush\" },\n                { \"id\": 3, \"first_name\": \"Ephraim\", \"lname\": \"Williams\" },\n                { \"id\": 4, \"first_name\": \"Frank\", \"lname\": \"Sinatra\" }\n              ]);\n            });\n        })\n        .finally(function() {\n          return docStorage.shutdown();\n        });\n    });\n\n    it(\"Should throw an error if renaming to an existing column\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_RenameColumn2\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(peopleSqlSmall);\n        }).then(function() {\n          return testUtils.expectRejection(docStorage.applyStoredActions([\n            [\"RenameColumn\", \"People\", \"fname\", \"lname\"]\n          ]), \"SQLITE_ERROR\", /duplicate column name: lname/);\n        });\n    });\n\n  });\n\n  describe(\".ModifyColumn\", function() {\n    const marshaller = new marshal.Marshaller({version: 2});\n    const encoded = (v) => {\n      marshaller.marshal(v);\n      return marshaller.dump();\n    };\n\n    it(\"Should modify the column type\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_ModifyColumn\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(peopleSql.concat([\n            [\"AddRecord\", \"People\", 3, { \"name\": \"Kim\", \"age\": false }],\n            [\"ModifyColumn\", \"People\", \"age\", { \"type\": \"Text\" }],\n            [\"UpdateRecord\", \"_grist_Tables_column\", 2, { \"type\" : \"Text\" }],\n            [\"AddRecord\", \"People\", 4, { \"name\": \"Carol\", \"age\": 14 }],\n            [\"AddRecord\", \"People\", 5, { \"name\": \"Declan\", \"age\": 97 }],\n            [\"AddRecord\", \"People\", 6, { \"name\": \"Junior\", \"age\": 1 }],\n          ]));\n        })\n        .then(function() {\n          return docStorage.all(\"SELECT * FROM People\");\n        })\n        .then(function(rows) {\n        // We used to expect SQLite to convert values to the new type. Now we explicitly don't\n        // want it to. ModifyColumn docaction should preserve values unchanged. A separate\n        // BulkUpdateRecord should follow up to change any values that should be changed.\n        // If the column type in SQLite is not BLOB, as in this case, the values will be\n        // marshalled.\n          assert.deepEqual(rows, [\n            { \"id\": 1, \"name\": \"Alice\", \"age\": 12 },\n            { \"id\": 2, \"name\": \"Bob\",   \"age\": 13 },\n            { \"id\": 3, \"name\": \"Kim\", \"age\": encoded(false) },  // encoded to insert in int column\n            { \"id\": 4, \"name\": \"Carol\", \"age\": encoded(14) },   // encoded to insert in text column\n            { \"id\": 5, \"name\": \"Declan\", \"age\": encoded(97) },  // encoded to insert in text column\n            { \"id\": 6, \"name\": \"Junior\", \"age\": encoded(1) },   // encoded to insert in text column\n          ], \"Int values should NOT become Text values\");\n        })\n        .then(() => docStorage.applyStoredActions([\n          [\"UpdateRecord\", \"People\", 2, { \"age\": \"13\" }],\n          [\"UpdateRecord\", \"People\", 4, { \"age\": \"Fourteen\" }],\n        ]))\n        .then(() => docStorage.all(\"SELECT * FROM People\"))\n        .then(rows => {\n          assert.deepEqual(rows, [\n            { \"id\": 1, \"name\": \"Alice\", \"age\": 12 },\n            { \"id\": 2, \"name\": \"Bob\",   \"age\": \"13\" },\n            { \"id\": 3, \"name\": \"Kim\", \"age\": encoded(false) },\n            { \"id\": 4, \"name\": \"Carol\", \"age\": \"Fourteen\" },\n            { \"id\": 5, \"name\": \"Declan\", \"age\": encoded(97) },\n            { \"id\": 6, \"name\": \"Junior\", \"age\": encoded(1) },\n          ]);\n        })\n        .then(() => docStorage.applyStoredActions([\n          [\"ModifyColumn\", \"People\", \"age\", { \"type\": \"Int\" }]\n        ]))\n        .then(() => docStorage.all(\"SELECT * FROM People\"))\n        .then(rows => {\n          assert.deepEqual(rows, [\n            { \"id\": 1, \"name\": \"Alice\", \"age\": 12 },\n            { \"id\": 2, \"name\": \"Bob\",   \"age\": \"13\" },\n            { \"id\": 3, \"name\": \"Kim\", \"age\": encoded(false) },\n            { \"id\": 4, \"name\": \"Carol\", \"age\": \"Fourteen\" },\n            { \"id\": 5, \"name\": \"Declan\", \"age\": 97 },  // was decoded opportunistically\n            { \"id\": 6, \"name\": \"Junior\", \"age\": 1 },   // was decoded opportunistically\n          ], \"Text values should NOT become Int values, even when look like Ints\");\n        })\n        .then(() => docStorage.applyStoredActions([\n          [\"ModifyColumn\", \"People\", \"age\", { \"type\": \"Bool\" }]\n        ]))\n        .then(() => docStorage.all(\"SELECT * FROM People\"))\n        .then(rows => {\n          assert.deepEqual(rows, [\n            { \"id\": 1, \"name\": \"Alice\", \"age\": 12 },\n            { \"id\": 2, \"name\": \"Bob\",   \"age\": \"13\" },\n            { \"id\": 3, \"name\": \"Kim\", \"age\": 0 },      // was decoded opportunistically\n            { \"id\": 4, \"name\": \"Carol\", \"age\": \"Fourteen\" },\n            { \"id\": 5, \"name\": \"Declan\", \"age\": 97 },\n            { \"id\": 6, \"name\": \"Junior\", \"age\": 1 },   // 1 collides with representation of true\n            // (we could catch this and marshall it to\n            // preserve type if we wanted)\n          ], \"booleans and integers may get collapsed\");\n        })\n        .then(() => docStorage.applyStoredActions([\n          [\"ModifyColumn\", \"People\", \"age\", { \"type\": \"Int\" }]\n        ]))\n        .then(() => docStorage.all(\"SELECT * FROM People\"))\n        .then(rows => {\n          assert.deepEqual(rows, [\n            { \"id\": 1, \"name\": \"Alice\", \"age\": 12 },\n            { \"id\": 2, \"name\": \"Bob\",   \"age\": \"13\" },\n            { \"id\": 3, \"name\": \"Kim\", \"age\": 0 },      // not preserved as false\n            { \"id\": 4, \"name\": \"Carol\", \"age\": \"Fourteen\" },\n            { \"id\": 5, \"name\": \"Declan\", \"age\": 97 },\n            { \"id\": 6, \"name\": \"Junior\", \"age\": 1 },   // not interpreted as true\n          ], \"booleans and integers were collapsed\");\n        })\n        .finally(function() {\n          return docStorage.shutdown();\n        });\n    });\n\n    it(\"Should do nothing when modifying non-formula, non-types or to equal types\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_ModifyColumn2\");\n      let old_version = null;\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(peopleSql);\n        })\n        .then(function() {\n          return docStorage.get(\"PRAGMA schema_version\");\n        })\n        .get(\"schema_version\")\n        .then(function(version) {\n          old_version = version;\n          return docStorage.applyStoredActions([\n            [\"ModifyColumn\", \"People\", \"name\", { \"type\": \"Text\" }],\n            [\"ModifyColumn\", \"People\", \"age\",  { \"type\": \"Id\" }],\n            [\"ModifyColumn\", \"People\", \"age\",  { \"type\": \"Ref:foo\" }],\n            [\"ModifyColumn\", \"People\", \"name\", { \"label\": \"John\" }],\n          ]);\n        })\n        .then(function() {\n          return docStorage.get(\"PRAGMA schema_version\");\n        })\n        .get(\"schema_version\")\n        .then(function(new_version) {\n          assert.equal(new_version, old_version, \"Schema version should stay the same\");\n        })\n        .finally(function() {\n          return docStorage.shutdown();\n        });\n    });\n  });\n\n  describe(\".RemoveColumn\", function() {\n\n    it(\"Should remove an existent column\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_RemoveColumn\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(fruitSql.concat([\n            [\"RemoveColumn\", \"Fruits\", \"yummy\"]\n          ]));\n        })\n        .then(function() {\n          return docStorage.all(\"SELECT * FROM FRUITS\");\n        })\n        .then(function(rows) {\n          assert.deepEqual(rows, [\n            { \"id\": 1, \"name\": \"Apple\" },\n            { \"id\": 2, \"name\": \"Clementine\" }\n          ]);\n        });\n    });\n\n    /* TODO: Should this be an error?\n   it(\"Should throw an error when trying to remove a non-existent column\", function() {\n      let docStorage = new DocStorage({ docName: 'test_RemoveColumn2' });\n      return docStorage.createFile()\n      .then(function() {\n        return docStorage.applyStoredActions(fruitSql);\n      })\n      .then(function() {\n        return testUtils.expectRejection(docStorage.applyStoredActions([\n          [\"RemoveColumn\", \"Fruits\", \"yumyum\"]\n        ]), \"SQLITE_ERROR\");\n      });\n    });*/\n\n  });\n\n  describe(\".RemoveTable\", function() {\n\n    it(\"Should remove an existent table\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_RemoveTable\");\n      return docStorage.createFile()\n        .then(function() {\n          return docStorage.applyStoredActions(fruitSql.concat([\n            [\"RemoveTable\", \"Fruits\"]\n          ]));\n        })\n        .then(function() {\n          return testUtils.expectRejection(docStorage.get(\"SELECT 1 FROM Fruits\"),\n            \"SQLITE_ERROR\", /no such table: Fruits/);\n        });\n    });\n\n    it(\"Should throw an error when trying to remove an non-existent table\", function() {\n      let docStorage = new DocStorage(docStorageManager, \"test_RemoveTable2\");\n      return testUtils.expectRejection(docStorage.createFile()\n        .then(function(doc) {\n          return docStorage.applyStoredActions([[\"RemoveTable\", \"Vegetables\"]]);\n        }), \"SQLITE_ERROR\", /no such table: Vegetables/);\n    });\n\n  });\n\n  describe(\".RenameDoc\", function() {\n\n    it(\"Should rename an existing doc to a new unique name\", function() {\n      let foo = new DocStorage(docStorageManager, \"test_RenameDoc\");\n      return foo.createFile()\n        .then(() => foo.shutdown())\n        .then(() => docStorageManager.renameDoc(foo.docName, \"bar\"))\n        .then(() => docUtils.pathExists(docStorageManager.getPath(\"bar\")))\n        .then(exists => assert.isTrue(exists));\n    });\n\n    it(\"Should fail when renaming to an existing name\", function() {\n      return testUtils.captureLog(\"warn\", () => {\n        let foo = new DocStorage(docStorageManager, \"test_RenameDoc_foo\");\n        let bar = new DocStorage(docStorageManager, \"test_RenameDoc_bar\");\n        return Promise.try(() => foo.createFile())\n          .then(() => bar.createFile())\n          .then(() => foo.shutdown())\n          .then(() => testUtils.expectRejection(\n            docStorageManager.renameDoc(foo.docName, bar.docName),\n            \"EEXIST\", /open.*bar.grist/));\n      })\n        .then(messages => testUtils.assertMatchArray(messages, [\n          /rename.*failed.*file already exists.*\\/test_RenameDoc_bar.grist/\n        ]));\n    });\n\n    it(\"Should allow renaming to a name that differs only in capitalization\", function() {\n      let foo = new DocStorage(docStorageManager, \"test-rename-case\");\n      return foo.createFile()\n        .then(() => foo.shutdown())\n        .then(() => docStorageManager.listDocs())\n        .then(docs => {\n          assert.include(docs.map(o => o.name), \"test-rename-case\");\n          assert.notInclude(docs.map(o => o.name), \"TEST-RENAME-CASE\");\n        })\n        .then(() => docStorageManager.renameDoc(foo.docName, \"TEST-RENAME-CASE\"))\n        .then(() => docUtils.pathExists(docStorageManager.getPath(\"TEST-RENAME-CASE\")))\n        .then(exists => assert.isTrue(exists))\n        .then(() => docStorageManager.listDocs())\n        .then(docs => {\n          assert.include(docs.map(o => o.name), \"TEST-RENAME-CASE\");\n          assert.notInclude(docs.map(o => o.name), \"test-rename-case\");\n        });\n    });\n\n  });\n\n  describe(\".DeleteActions\", function() {\n\n    // Contains records from the _gristsys_ActionHistory table as they should look\n    // after deleting the two most recent actions.\n    const actions = [\n      {\n        actionNum: 213,\n        info: [0, {\n          time: 1480214489261,\n          user: \"dmitry@getgrist.com\",\n          desc: null,\n          linkId: null,\n          otherId: null,\n          inst: \"\",\n        }],\n        userActions: [[\"UpdateRecord\", \"_grist_Views\", 5, {\"name\":\"Friends-\"}]],\n        undo: [[\"RenameTable\", \"Friends_\", \"Friends\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 3, {\"tableId\":\"Friends\"}],\n          [\"UpdateRecord\", \"_grist_Views\", 5, {\"name\":\"Table (Raw)\"}]],\n      },\n      {\n        actionNum: 214,\n        info: [0, {\n          time: 1480214493424,\n          user: \"dmitry@getgrist.com\",\n          desc: null,\n          linkId: null,\n          otherId: null,\n          inst: \"\",\n        }],\n        userActions: [[\"UpdateRecord\", \"_grist_Views\", 5, {\"name\":\"Friends\"}]],\n        undo: [[\"RenameTable\", \"Friends\", \"Friends_\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 3, {\"tableId\":\"Friends_\"}],\n          [\"UpdateRecord\", \"_grist_Views\", 5, {\"name\":\"Friends-\"}]],\n      },\n      {\n        actionNum: 215,\n        info: [0, {\n          time: 1480214497083,\n          user: \"dmitry@getgrist.com\",\n          desc: null,\n          linkId: null,\n          otherId: null,\n          inst: \"\",\n        }],\n        userActions: [[\"UpdateRecord\", \"_grist_Views\", 3, {\"name\":\"Performances2\"}]],\n        undo: [[\"RenameTable\", \"Performances2\", \"Performances\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 2, {\"tableId\":\"Performances\"}],\n          [\"UpdateRecord\", \"_grist_Views\", 3, {\"name\":\"Table (Raw)\"}]]\n      },\n      {\n        actionNum: 216,\n        info: [0, {\n          time: 1480214500525,\n          user: \"dmitry@getgrist.com\",\n          desc: null,\n          linkId: null,\n          otherId: null,\n          inst: \"\",\n        }],\n        userActions: [[\"UpdateRecord\", \"_grist_Views\", 3, {\"name\":\"Performances\"}]],\n        undo: [[\"RenameTable\", \"Performances\", \"Performances2\"],\n          [\"UpdateRecord\", \"_grist_Tables\", 2, {\"tableId\":\"Performances2\"}],\n          [\"UpdateRecord\", \"_grist_Views\", 3, {\"name\":\"Performances2\"}]],\n      },\n    ];\n\n    // just look at fields supported by old ActionLog\n    function filterAction(action) {\n      return {\n        actionNum: action.actionNum,\n        info: action.info,\n        userActions: action.userActions,\n        undo: action.undo,\n      };\n    }\n\n    it(\"Should delete actions past the number given\", async function() {\n      this.timeout(5000); // test appears to occasionally exceed default\n\n      // create a new db for the test\n      let activeDoc, docStorage;\n      let originalSize;\n\n      activeDoc = await docTools.loadFixtureDoc(\"Favorite_Films.grist\");\n      docStorage = activeDoc.docStorage;\n      let recentActions = await activeDoc.getRecentActionsDirect(5);\n      // Check that the actions are as expected. Ignore the last action, which includes the\n      // formula calculations created during stored-formulas migration.\n      assert.deepEqual(recentActions.slice(0, -1).map(filterAction), actions);\n      let stat = await fs.statAsync(docStorage.docPath);\n      // Save the original size.\n      originalSize = stat.size;\n      // Delete all actions but the most recent 3 (last one being stored-formulas calculation).\n      const history = new ActionHistoryImpl(docStorage);\n      await history.initialize();\n      await history.deleteActions(3);\n      recentActions = await activeDoc.getRecentActionsDirect(4);\n      // Check that only the 2 most recent actions remain. Ignore the last action, which\n      // includes the formula calculations created during stored-formulas migration.\n      assert.deepEqual(recentActions.slice(0, -1).map(filterAction), actions.slice(-2));\n      if (process.env.GRIST_SQLITE_MODE === \"wal\") {\n        await activeDoc.shutdown();\n      }\n      stat = await fs.statAsync(docStorage.docPath);\n      // Check that the size is smaller (VACUUM should have been called after deletion).\n      assert(stat.size < originalSize);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/DocStorageManager.ts",
    "content": "/* global describe, before, it */\n\nimport { DocStorageManager } from \"app/server/lib/DocStorageManager\";\nimport * as docUtils from \"app/server/lib/docUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport * as path from \"path\";\n\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\nimport * as tmp from \"tmp-promise\";\n\ntmp.setGracefulCleanup();\n\ndescribe(\"DocStorageManager\", function() {\n  // Set Grist home to a temporary directory, and wipe it out on exit.\n  let docsRoot: string;\n  let docStorageManager: DocStorageManager;\n  before(async function() {\n    const tmpDir = (await tmp.dir({ prefix: \"grist_test_\", unsafeCleanup: true })).path;\n    docsRoot = fse.realpathSync(tmpDir);\n    docStorageManager = new DocStorageManager(docsRoot);\n  });\n\n  describe(\"getPath\", function() {\n    it(\"should return proper full path for docNames\", function() {\n      assert.equal(docStorageManager.getPath(\"Hello World\"),\n        path.join(docsRoot, \"Hello World.grist\"));\n      assert.equal(docStorageManager.getPath(\"/foo/bar/Hello.grist\"),\n        path.resolve(\"/foo/bar/Hello.grist\"));\n    });\n  });\n\n  describe(\"getCanonicalDocName\", function() {\n    it(\"should convert alternative names to canonical ones\", async function() {\n      // Define a shorthand for readability.\n      async function verify(altDocName: string, expected: string) {\n        const docName = await docStorageManager.getCanonicalDocName(altDocName);\n        assert.equal(docName, expected);\n      }\n\n      // docNames within docsRoot are represented by a human-friendly name (basename).\n      await verify(\"Hello\", \"Hello\");\n      // Same should be returned if the path is represented differently.\n      await verify(path.join(docsRoot, \"Hello.grist\"), \"Hello\");\n      // With or without extension.\n      await verify(path.join(docsRoot, \"Hello\"), \"Hello\");\n\n      // For paths outside of docsRoot, the canonical version is the realpath.\n      // For non-existent paths, realpath should leave the path unchanged.\n      await verify(\"/foo/bar/Hello.grist\", path.resolve(\"/foo/bar/Hello.grist\"));\n      // But extension should always be added.\n      await verify(\"/foo/bar/Hello\", path.resolve(\"/foo/bar/Hello.grist\"));\n      // Path should always be normalized.\n      await verify(\"/foo/./bar/../Hello\", path.resolve(\"/foo/Hello.grist\"));\n      // For realistic paths, they should be properly resolved.\n      await verify(path.join(docsRoot, \"..\", \"foo\"),\n        path.resolve(docsRoot, \"../foo.grist\"));\n    });\n  });\n\n  describe(\"listDocs\", function() {\n    it(\"should list all .grist documents in docsRoot\", async function() {\n      let docs = await docStorageManager.listDocs();\n      assert.deepEqual(docs, []);\n      // Create a couple of .grist files for testing.\n      fse.writeFileSync(path.join(docsRoot, \"World.grist\"), \"this is a test\");\n      fse.writeFileSync(path.join(docsRoot, \"hello.grist\"), \"this is a test\");\n      docs = await docStorageManager.listDocs();\n      // Check also that they are sorted in a human-friendly way.\n      assert.deepEqual(docs.map(o => o.name), [\"hello\", \"World\"]);\n    });\n  });\n\n  describe(\"deleteDoc\", function() {\n    it(\"should delete document\", async function() {\n      const doc1 = path.join(docsRoot, \"DeleteTest.grist\");\n\n      // First create a doc, one under docsRoot, one outside; and include an attachment.\n      fse.writeFileSync(doc1, \"this is a test\");\n      const doc2 = tmp.fileSync({\n        prefix: \"DeleteTest2\", postfix: \".grist\",\n        discardDescriptor: true,\n      }).name;\n      // Check that items got created as we expect.\n      assert(await docUtils.pathExists(doc1));\n      assert(await docUtils.pathExists(doc2));\n\n      // Now delete things.\n      await docStorageManager.deleteDoc(doc1, true);\n      // Check that attempting to send items to the trash fails\n      await assert.isRejected(docStorageManager.deleteDoc(doc2),\n        /Unable to move document to trash/);\n      await docStorageManager.deleteDoc(doc2, true);\n      // And check that all the files we created are gone.\n      assert(!(await docUtils.pathExists(doc1)));\n      assert(!(await docUtils.pathExists(doc2)));\n    });\n  });\n\n  describe(\"renameDoc\", function() {\n    it(\"should rename document\", async function() {\n      const doc1 = path.join(docsRoot, \"RenameFrom.grist\");\n      const doc2 = path.join(docsRoot, \"RenameTo.grist\");\n      fse.writeFileSync(doc1, \"this is a test\");\n\n      // Check that \"from\" files exist and \"to\" files don't.\n      assert(await docUtils.pathExists(doc1));\n      assert(!(await docUtils.pathExists(doc2)));\n\n      // Rename and check that \"to\" files now exist and \"from\" files don't.\n      await docStorageManager.renameDoc(doc1, doc2);\n      assert(!(await docUtils.pathExists(doc1)));\n      assert(await docUtils.pathExists(doc2));\n    });\n\n    it(\"Should not allow renaming to an existing file, unless it's the same file\", async function() {\n      const doc1 = path.join(docsRoot, \"Foo.grist\");\n      const doc2 = path.join(docsRoot, \"FOO.grist\");\n      const doc3 = path.join(docsRoot, \"Bar.grist\");\n      fse.writeFileSync(doc1, \"this is Foo test\");\n      fse.writeFileSync(doc3, \"this is Bar test\");\n\n      // Check that both files exists, but directory lists only the To file\n      let files = fse.readdirSync(docsRoot);\n      assert.include(files, \"Foo.grist\");\n      assert.include(files, \"Bar.grist\");\n      assert.notInclude(files, \"FOO.grist\");\n\n      // Rename and check that the new file has the same\n      await docStorageManager.renameDoc(doc1, doc2);\n      files = fse.readdirSync(docsRoot);\n      assert.notInclude(files, \"Foo.grist\");\n      assert.include(files, \"Bar.grist\");\n      assert.include(files, \"FOO.grist\");\n\n      const messages = await testUtils.captureLog(\"warn\", () => {\n        return testUtils.expectRejection(\n          docStorageManager.renameDoc(doc2, doc3), \"EEXIST\", /file already exists.*Bar\\.grist/)\n          .then(() => {\n            files = fse.readdirSync(docsRoot);\n            assert.notInclude(files, \"Foo.grist\");\n            assert.include(files, \"Bar.grist\");\n            assert.include(files, \"FOO.grist\");\n          });\n      });\n      testUtils.assertMatchArray(messages, [/file already exists.*Bar/]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/DocStorageMigrations.ts",
    "content": "import { DocStorage } from \"app/server/lib/DocStorage\";\nimport { DocStorageManager } from \"app/server/lib/DocStorageManager\";\nimport { OpenMode, SQLiteDB } from \"app/server/lib/SQLiteDB\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { assert } from \"test/server/testUtils\";\n\nimport * as child_process from \"child_process\";\nimport * as path from \"path\";\n\nimport { promisify } from \"bluebird\";\nimport * as fse from \"fs-extra\";\nimport * as tmp from \"tmp-promise\";\n\ntmp.setGracefulCleanup();\nconst execFileAsync = promisify(child_process.execFile);\n\ndescribe(\"DocStorageMigrations\", function() {\n  testUtils.setTmpLogLevel(\"warn\");\n\n  let docStorageManager: DocStorageManager;\n\n  before(async function() {\n    // Set Grist home to a temporary directory, and wipe it out on exit.\n    let tmpDir = (await tmp.dir({ prefix: \"grist_test_\", unsafeCleanup: true })).path;\n    tmpDir = await fse.realpath(tmpDir);\n    docStorageManager = new DocStorageManager(tmpDir);\n  });\n\n  async function testMigration(fromName: string, toName: string): Promise<void> {\n    const docName: string = await testUtils.useFixtureDoc(fromName, docStorageManager);\n    const docPath = docStorageManager.getPath(docName);\n    const docStorage = new DocStorage(docStorageManager, docName);\n    await docStorage.openFile();\n    await docStorage.shutdown();\n    const stdout = await execFileAsync(\"sqlite3\", [docPath, \".dump\"]);\n\n    const expectedPath = path.resolve(testUtils.fixturesRoot, \"docs\", toName);\n    const expectedStdout = await execFileAsync(\"sqlite3\", [expectedPath, \".dump\"]);\n    assert.deepEqual(stdout, expectedStdout);\n  }\n\n  it(\"should migrate from v1 correctly\", function() {\n    return testMigration(\"BlobMigrationV1.grist\", \"BlobMigrationV9.grist\");\n  });\n\n  it(\"should migrate from v2 correctly\", function() {\n    return testMigration(\"BlobMigrationV2.grist\", \"BlobMigrationV9.grist\");\n  });\n\n  it(\"should migrate from v3 correctly\", async function() {\n    // Open the original file read-only and check that it has no _gristsys_FileInfo table.\n    // (Any file would work, we use \"BlobMigration\" files because they are already there.)\n    const docName = await testUtils.useFixtureDoc(\"BlobMigrationV3.grist\", docStorageManager);\n    const docPath = docStorageManager.getPath(docName);\n    let db = await SQLiteDB.openDBRaw(docPath, OpenMode.OPEN_READONLY);\n    await assert.isRejected(db.run(\"SELECT * FROM _gristsys_FileInfo\"), /no such table: _gristsys_FileInfo/);\n    await db.close();\n\n    // Migrate by opening with DocStorage.\n    const docStorage = new DocStorage(docStorageManager, docName);\n    await docStorage.openFile();\n    await docStorage.shutdown();\n\n    // Open after migration and check that the table now exists and has a single record.\n    db = await SQLiteDB.openDBRaw(docPath, OpenMode.OPEN_READONLY);\n    const rows = await db.all(\"SELECT * FROM _gristsys_FileInfo\");\n    assert.deepEqual(rows, [{ id: 0, docId: \"\", ownerInstanceId: \"\" }]);\n    await db.close();\n\n    // Also do the test to check out the full document against a saved copy. To know if the copy\n    // makes sense, run in test/fixtures/docs:\n    //    diff -u <(sqlite3 BlobMigrationV3.grist .dump) <(sqlite3 BlobMigrationV4.grist .dump)\n    await testMigration(\"BlobMigrationV3.grist\", \"BlobMigrationV9.grist\");\n  });\n\n  it(\"should migrate from v4 correctly\", function() {\n    return testMigration(\"BlobMigrationV4.grist\", \"BlobMigrationV9.grist\");\n  });\n\n  it(\"should migrate from v5 correctly\", async function() {\n    // The DefaultValuesV5 sample document has two tables, one with correct defaults and values,\n    // the other wrong. On migration, one table should be untouched and the other fixed in both\n    // the schema (to have correct defaults), and the NULL values in the non-NULL columns.\n    //\n    // Verify correctness of these fixture files with:\n    //  diff -u <(sqlite3 DefaultValuesV5.grist .dump) <(sqlite3 DefaultValuesV7.grist .dump)\n    return testMigration(\"DefaultValuesV5.grist\", \"DefaultValuesV9.grist\");\n  });\n\n  it(\"should migrate from v6 correctly\", async function() {\n    // This migration adds formula columns to the database, filled with the value ['P'] (encoded\n    // as a marshalled buffer).\n    //\n    // Verify correctness of updated fixture files with, for instance:\n    //  cd test/fixtures/docs ; \\\n    //    diff -u <(sqlite3 DefaultValuesV6.grist .dump) <(sqlite3 DefaultValuesV7.grist .dump)\n    await testMigration(\"BlobMigrationV6.grist\", \"BlobMigrationV9.grist\");\n    await testMigration(\"DefaultValuesV6.grist\", \"DefaultValuesV9.grist\");\n  });\n});\n"
  },
  {
    "path": "test/server/lib/DocStorageQuery.ts",
    "content": "import { DocStorage } from \"app/server/lib/DocStorage\";\nimport { ExpandedQuery } from \"app/server/lib/ExpandedQuery\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"DocStorageQuery\", function() {\n  const sandbox = sinon.createSandbox();\n  const dbCalls: unknown[][] = [];\n  let docStorage: DocStorage;\n\n  const collapseWhitespace = (str: string) => str.replace(/\\s+/g, \" \").trim();\n\n  // Stub DocStorage and its database so that instead of making queries, we just extract the SQL\n  // that's constructed.\n  async function createDocStorage() {\n    const fakeStorageManager = { getPath: (name: string) => name };\n    const docStorage = new DocStorage(fakeStorageManager as any, \":memory:\");\n    await docStorage.createFile();\n    const db = (docStorage as any)._getDB();\n    sandbox.stub(db, \"run\").callsFake(\n      (sql: string, ...args: unknown[]) => { dbCalls.push([\"run\", sql, args]); });\n    sandbox.stub(db, \"exec\").callsFake(\n      (sql: string) => { dbCalls.push([\"exec\", sql]); });\n    sandbox.stub(db, \"allMarshal\").callsFake(\n      (sql: string, ...params: unknown[]) => { dbCalls.push([\"allMarshal\", collapseWhitespace(sql), params]); });\n    return docStorage;\n  }\n\n  testUtils.setTmpLogLevel(\"warn\");\n\n  beforeEach(async function() {\n    docStorage = await createDocStorage();\n  });\n\n  afterEach(async function() {\n    await docStorage.shutdown();\n    sandbox.restore();\n    dbCalls.length = 0;\n  });\n\n  async function getFetchQueryDbCalls(docStorage: DocStorage, query: ExpandedQuery) {\n    dbCalls.length = 0;\n    await docStorage.fetchQuery(query);\n    return dbCalls;\n  }\n\n  it(\"should construct correct query from normally expected fields\", async function() {\n    assert.deepEqual(await getFetchQueryDbCalls(docStorage,\n      { tableId: \"foo\", filters: {}, limit: 4 }),\n    [[\"allMarshal\", 'SELECT * FROM \"foo\" LIMIT 4', []]]);\n\n    assert.deepEqual(await getFetchQueryDbCalls(docStorage,\n      { tableId: \"foo\", filters: { tag: [1, 2, 3], X: [\"Y\"] } }),\n    [[\"allMarshal\", 'SELECT * FROM \"foo\" WHERE (\"foo\".\"tag\" IN (?, ?, ?)) AND (\"foo\".\"X\" IN (?))',\n      [1, 2, 3, \"Y\"]]]);\n  });\n\n  it(\"should reject invalid identifiers\", async function() {\n    // This is to ensure \"identifiers\" can't be used as a vector for an SQL injection attacks.\n    await assert.isRejected(getFetchQueryDbCalls(docStorage,\n      { tableId: 'foo\"; DROP TABLE foo', filters: {} }),\n    /SQL identifier is not valid/);\n\n    await assert.isRejected(getFetchQueryDbCalls(docStorage,\n      { tableId: \"foo\", filters: { 'bar\"; DROP TABLE foo;': [1] } }),\n    /SQL identifier is not valid/);\n  });\n\n  it(\"should ignore non-numeric limit\", async function() {\n    // This is to ensure \"limit\" can't be used as a vector for an SQL injection attack.\n    assert.deepEqual(await getFetchQueryDbCalls(docStorage,\n      { tableId: \"foo\", filters: {}, limit: \"5; DROP TABLE foo\" as any }),\n    [[\"allMarshal\", 'SELECT * FROM \"foo\"', []]]);\n\n    assert.deepEqual(await getFetchQueryDbCalls(docStorage,\n      { tableId: \"foo\", filters: { bar: [1] }, limit: { foo: \"bar\" } as any }),\n    [[\"allMarshal\", 'SELECT * FROM \"foo\" WHERE (\"foo\".\"bar\" IN (?))', [1]]]);\n  });\n\n  it(\"should combine where clause and filters correctly\", async function() {\n    assert.deepEqual(await getFetchQueryDbCalls(docStorage,\n      { tableId: \"foo\", filters: {}, limit: 4, where: { clause: \"age IS NULL OR age > ?\", params: [18] } }),\n    [[\"allMarshal\", 'SELECT * FROM \"foo\" WHERE (age IS NULL OR age > ?) LIMIT 4', [18]]]);\n\n    assert.deepEqual(await getFetchQueryDbCalls(docStorage,\n      { tableId: \"foo\", filters: { tag: [1, 2, 3], X: [\"Y\"] },\n        where: { clause: \"name LIKE ? OR ? = ?\", params: [\"J%\", 4, 5] },\n      }),\n    [[\"allMarshal\",\n      'SELECT * FROM \"foo\" WHERE (name LIKE ? OR ? = ?) AND (\"foo\".\"tag\" IN (?, ?, ?)) AND (\"foo\".\"X\" IN (?))',\n      [\"J%\", 4, 5, 1, 2, 3, \"Y\"]]]);\n  });\n\n  it(\"should construct correct query for many-valued filters\", async function() {\n    // Query with many values in the filter.\n    const values = Array.from(Array(1200), (_, i) => `foo-${i}`);\n    const ages = [28];\n    await getFetchQueryDbCalls(docStorage, { tableId: \"foo\", filters: { values, ages } });\n\n    // It's a bit tricky to test, so we use a clever helper (defined below) that checks that\n    // same-named matching groups all match.\n    assertMatches(dbCalls, [\n      [\"exec\", \"BEGIN\"],\n      [\"exec\", /^CREATE TEMPORARY TABLE (?<table1>_grist_tmp\\w+)\\(data\\)$/],\n      [\"run\", /^INSERT INTO (?<table1>_grist_tmp\\w+)\\(data\\) VALUES \\(\\?\\)(,\\(\\?\\))*$/, [values.slice(0, 500)]],\n      [\"run\", /^INSERT INTO (?<table1>_grist_tmp\\w+)\\(data\\) VALUES \\(\\?\\)(,\\(\\?\\))*$/, [values.slice(500, 1000)]],\n      [\"run\", /^INSERT INTO (?<table1>_grist_tmp\\w+)\\(data\\) VALUES \\(\\?\\)(,\\(\\?\\))*$/, [values.slice(1000, 1200)]],\n      [\"exec\", /^CREATE TEMPORARY TABLE (?<table2>_grist_tmp\\w+)\\(data\\)$/],\n      [\"run\", /^INSERT INTO (?<table2>_grist_tmp\\w+)\\(data\\) VALUES \\(\\?\\)(,\\(\\?\\))*$/, [[28]]],\n      [\"allMarshal\", new RegExp(\n        /^SELECT \\* FROM \"foo\" WHERE /.source +\n        /\\(\"foo\"\\.\"values\" IN \\(SELECT data FROM (?<table1>_grist_tmp\\w+)\\)\\) AND /.source +\n        /\\(\"foo\"\\.\"ages\" IN \\(SELECT data FROM (?<table2>_grist_tmp\\w+)\\)\\)/.source),\n      [],\n      ],\n      [\"exec\", /^DROP TABLE (?<table1>_grist_tmp\\w+)$/],\n      [\"exec\", /^DROP TABLE (?<table2>_grist_tmp\\w+)$/],\n      [\"exec\", \"COMMIT\"],\n    ]);\n  });\n\n  it(\"should combine where clause and many-valued filters correctly\", async function() {\n    // Query with many values in the filter, AND with a custom \"where\" clause.\n    const bars = Array.from(Array(600), (_, i) => `bar-${i}`);\n    await getFetchQueryDbCalls(docStorage, { tableId: \"foo\", filters: { bars },\n      where: { clause: \"name LIKE ? OR ? = ?\", params: [\"J%\", 4, 5] } });\n\n    // It's a bit tricky to test, so we use a clever helper (defined below) that checks that\n    // same-named matching groups all match.\n    assertMatches(dbCalls, [\n      [\"exec\", \"BEGIN\"],\n      [\"exec\", /^CREATE TEMPORARY TABLE (?<table1>_grist_tmp\\w+)\\(data\\)$/],\n      [\"run\", /^INSERT INTO (?<table1>_grist_tmp\\w+)\\(data\\) VALUES \\(\\?\\)(,\\(\\?\\))*$/, [bars.slice(0, 500)]],\n      [\"run\", /^INSERT INTO (?<table1>_grist_tmp\\w+)\\(data\\) VALUES \\(\\?\\)(,\\(\\?\\))*$/, [bars.slice(500, 600)]],\n      [\"allMarshal\", new RegExp(\n        /^SELECT \\* FROM \"foo\" WHERE \\(name LIKE \\? OR \\? = \\?\\) AND /.source +\n        /\\(\"foo\"\\.\"bars\" IN \\(SELECT data FROM (?<table1>_grist_tmp\\w+)\\)\\)/.source),\n      [\"J%\", 4, 5],\n      ],\n      [\"exec\", /^DROP TABLE (?<table1>_grist_tmp\\w+)$/],\n      [\"exec\", \"COMMIT\"],\n    ]);\n  });\n});\n\n/**\n * Clever function to match an array of arrays, with some dynamically generated parts of\n * strings. Items can be regular expressions. These can contain named groups (e.g. /Hello (?<name>.*)/).\n * Across different regular expressions, same-named groups must match.\n */\n// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents\nfunction assertMatches(calls: unknown[][], expected: (unknown | RegExp)[][]) {\n  const groups = new Map<string, string>();\n  for (const [n, expectedCall] of expected.entries()) {\n    assert.isAtLeast(calls.length, n + 1);\n    const actualCall = calls[n];\n    for (const [i, expectedPart] of expectedCall.entries()) {\n      assert.isAtLeast(actualCall.length, i + 1);\n      const actualPart = actualCall[i];\n      if (expectedPart instanceof RegExp) {\n        assert.equal(typeof actualPart, \"string\");\n        if (typeof actualPart !== \"string\") { throw new Error(\"X\"); }\n        assert.match(actualPart, expectedPart, `in call #${n}`);\n        const match = actualPart.match(expectedPart);\n        if (match?.groups) {\n          for (const [name, value] of Object.entries(match.groups)) {\n            if (groups.has(name)) {\n              assert.equal(value, groups.get(name), `in call #${n} while matching: ${actualPart}`);\n            } else {\n              groups.set(name, value);\n            }\n          }\n        }\n      } else {\n        assert.deepEqual(actualPart, expectedPart);\n      }\n    }\n    assert.equal(calls[n].length, expectedCall.length);\n  }\n  assert.equal(calls.length, expected.length);\n}\n"
  },
  {
    "path": "test/server/lib/DocWorkerLoadTracker.ts",
    "content": "import { getDocWorkerMap } from \"app/gen-server/lib/DocWorkerMap\";\nimport { IMemoryLoadEstimator } from \"app/server/lib/DocManager\";\nimport { Deps, DocWorkerLoadTracker } from \"app/server/lib/DocWorkerLoadTracker\";\nimport { DocWorkerInfo, IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\n\nimport fs from \"node:fs/promises\";\n\nimport { assert } from \"chai\";\nimport sinon from \"sinon\";\nimport tmp from \"tmp-promise\";\n\ndescribe(\"DocWorkerLoadTracker\", function() {\n  let docWorkerLoadTracker: DocWorkerLoadTracker;\n  let docWorkerMap: IDocWorkerMap;\n  let getTotalMemoryUsedStub: sinon.SinonStub;\n  const sandbox = sinon.createSandbox();\n  const originalDeps = { ...Deps };\n  const docWorkerInfoMap: DocWorkerInfo = {\n    id: \"some-id\",\n    internalUrl: \"http://grist-internal/dw/10.0.0.2/some-path\",\n    publicUrl: \"https://grist-public/some-path\",\n  };\n\n  before(function() {\n    docWorkerMap = getDocWorkerMap();\n  });\n\n  beforeEach(function() {\n    getTotalMemoryUsedStub = sandbox.stub();\n    const docManagerMock: IMemoryLoadEstimator = {\n      getTotalMemoryUsedMB: getTotalMemoryUsedStub,\n    };\n\n    docWorkerLoadTracker = new DocWorkerLoadTracker(\n      docWorkerInfoMap,\n      docWorkerMap,\n      docManagerMock,\n    );\n    docWorkerLoadTracker.stop();\n  });\n\n  afterEach(function() {\n    Object.assign(Deps, originalDeps);\n    sandbox.restore();\n  });\n\n  describe(\"getLoad()\", function() {\n    let cleanupFiles: (() => void)[] = [];\n    const registerCleanup = (cleanup: () => Promise<void>) => cleanupFiles.push(cleanup);\n\n    afterEach(function() {\n      for (const cleanup of cleanupFiles) {\n        cleanup();\n      }\n      cleanupFiles = [];\n    });\n\n    async function mockValueInFile(\n      depsProperty: \"docWorkerUsedMemoryBytesPath\" | \"docWorkerMaxMemoryBytesPath\",\n      value: number | string | undefined): Promise<void> {\n      if (value !== undefined) {\n        const { path, cleanup } = await tmp.file();\n        registerCleanup(cleanup);\n        Deps[depsProperty] = path;\n        await fs.writeFile(path, value.toString(), \"utf-8\");\n      } else {\n        Deps[depsProperty] = undefined;\n      }\n    }\n\n    const bytesToMb = (val: number) => val * (1024 ** 2);\n\n    for (const ctx of [{\n      itMsg: \"should retrieve max memory using GRIST_DOC_WORKER_MAX_MEMORY_MB in priority\",\n      setup() {\n        Deps.docWorkerMaxMemoryMBForcedValue = 1024;\n      },\n      maxFromFile: bytesToMb(512),\n      usedFromFile: bytesToMb(128),\n      result: 128 / 1024,\n    }, {\n      itMsg: \"should otherwise retrieve max memory using GRIST_DOC_WORKER_MAX_MEMORY_BYTES_PATH\",\n      maxFromFile: bytesToMb(512),\n      usedFromFile: bytesToMb(128),\n      result: 128 / 512,\n    }, {\n      itMsg: 'should consider value \"max\" in GRIST_DOC_WORKER_MAX_MEMORY_BYTES_PATH as Infinite',\n      maxFromFile: \"max\",\n      usedFromFile: bytesToMb(128),\n      result: 0,\n    }, {\n      itMsg: \"should consider having no load when no maximum is defined\",\n      usedFromFile: bytesToMb(128),\n      result: 0,\n    }, {\n      itMsg: \"should let the DocManager compute an estimation of the memory used when \" +\n        \"GRIST_DOC_WORKER_USED_MEMORY_BYTES_PATH is not provided\",\n      setup() {\n        getTotalMemoryUsedStub.returns(128);\n      },\n      maxFromFile: bytesToMb(512),\n      result: 128 / 512,\n    }]) {\n      it(ctx.itMsg, async function() {\n        ctx.setup?.();\n        await Promise.all([\n          mockValueInFile(\"docWorkerUsedMemoryBytesPath\", ctx.usedFromFile),\n          mockValueInFile(\"docWorkerMaxMemoryBytesPath\", ctx.maxFromFile),\n        ]);\n\n        assert.equal(await docWorkerLoadTracker.getLoad(), ctx.result);\n      });\n    }\n\n    it(\"should reject when the memory usage read from a file is not a number\", async function() {\n      await Promise.all([\n        mockValueInFile(\"docWorkerUsedMemoryBytesPath\", \"Yikes, not a number\"),\n        mockValueInFile(\"docWorkerMaxMemoryBytesPath\", bytesToMb(1024)),\n      ]);\n      getTotalMemoryUsedStub.returns(512);\n\n      await assert.isRejected(docWorkerLoadTracker.getLoad(), /Unexpected value .* found in file.*Yikes/);\n    });\n\n    it(\"should reject when the max memory available is specified but cannot be read\", async function() {\n      await mockValueInFile(\"docWorkerUsedMemoryBytesPath\", bytesToMb(512));\n      Deps.docWorkerMaxMemoryBytesPath = \"/this/path/leads/nowhere\";\n\n      await assert.isRejected(docWorkerLoadTracker.getLoad(), /ENOENT/);\n    });\n  });\n\n  describe(\"interval runner\", function() {\n    let getLoadStub: sinon.SinonStub;\n    let setWorkerLoadStub: sinon.SinonStub;\n    let logErrorStub: sinon.SinonStub;\n    beforeEach(function() {\n      getLoadStub = sandbox.stub(docWorkerLoadTracker, \"getLoad\").resolves(0);\n      setWorkerLoadStub = sandbox.stub(docWorkerMap, \"setWorkerLoad\").resolves(undefined);\n      logErrorStub = sandbox.stub(docWorkerLoadTracker[\"_log\"], \"error\").returns(undefined);\n    });\n\n    const triggerTimer = () => docWorkerLoadTracker[\"_interval\"][\"_onTimeoutTriggered\"]();\n\n    it(\"should update the worker load when the timer is triggered\", async function() {\n      getLoadStub.resolves(0.42);\n      await triggerTimer();\n      assert.equal(setWorkerLoadStub.callCount, 1, \"setWorkerLoad should have been called to update the load value\");\n      assert.deepEqual(setWorkerLoadStub.firstCall.args, [docWorkerInfoMap, 0.42]);\n    });\n\n    it(\"should log an error when the worker load cannot be computed\", async function() {\n      const error = new Error(\"an error\");\n      getLoadStub.rejects(error);\n      await triggerTimer();\n      assert.equal(setWorkerLoadStub.callCount, 0, \"setWorkerLoad should not have been called\");\n      assert.include(logErrorStub.firstCall.args, error, \"the error should have been logged\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/ExportsAccessRules.ts",
    "content": "import { UserAPIImpl } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport chai, { assert } from \"chai\";\nimport Excel from \"exceljs\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"ExportsAccessRules\", function() {\n  this.timeout(60000);\n  let home: TestServer;\n  testUtils.setTmpLogLevel(\"error\");\n  let owner: UserAPIImpl;\n  let editor: UserAPIImpl;\n  let wsId: number;\n\n  // Increase truncateThreshold for chai assertion diffs for this test, so that deepEqual failures\n  // are reported more usefully.\n  const sandbox = sinon.createSandbox();\n  before(() => { sandbox.stub(chai.config, \"truncateThreshold\").value(1000); });\n  after(() => { sandbox.restore(); });\n\n  before(async function() {\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n    const api = await home.createHomeApi(\"chimpy\", \"docs\", true);\n    await api.newOrg({ name: \"ExportsAccessRules\", domain: \"exports-access-rules\" });\n    owner = await home.createHomeApi(\"chimpy\", \"exports-access-rules\", true);\n    wsId = await owner.newWorkspace({ name: \"ws\" }, \"current\");\n    await owner.updateWorkspacePermissions(wsId, {\n      users: {\n        \"kiwi@getgrist.com\": \"owners\",\n        \"charon@getgrist.com\": \"editors\",\n      },\n    });\n    editor = await home.createHomeApi(\"charon\", \"exports-access-rules\", true);\n  });\n\n  after(async function() {\n    const messages = await testUtils.captureLog(\"error\", async () => {\n      const api = await home.createHomeApi(\"chimpy\", \"docs\");\n      await api.deleteOrg(\"exports-access-rules\");\n      await home.stop();\n    });\n    assert.deepEqual(messages, []);\n  });\n\n  async function createSampleTables(docId: string) {\n    // Add tables that are fully or partially hidden from Editors.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Public\", [{ id: \"ColPublic1\" }]],\n      [\"AddTable\", \"Private\", [{ id: \"ColPrivate1\" }]],\n      [\"AddTable\", \"Partial\", [{ id: \"ColPartialShow\" }, { id: \"ColPartialHide\" }, { id: \"ColPartialMaybe\" }]],\n      [\"RemoveTable\", \"Table1\"],\n      [\"AddRecord\", \"Public\", null, { ColPublic1: 10 }],\n      [\"AddRecord\", \"Private\", null, { ColPrivate1: 20 }],\n      [\"AddRecord\", \"Partial\", null, { ColPartialShow: \"show1\", ColPartialHide: \"hide1\", ColPartialMaybe: \"maybe1\" }],\n      [\"AddRecord\", \"Partial\", null, { ColPartialShow: \"show2\", ColPartialHide: \"hide2\", ColPartialMaybe: \"maybe2\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Private\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Partial\", colIds: \"ColPartialHide\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -4, { tableId: \"Partial\", colIds: \"ColPartialMaybe\" }],\n      // Negative IDs refer to rowIds used in the same action bundle.\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        // Deny non-owners access to table Private.\n        resource: -2, aclFormula: 'user.Access != \"owners\"', permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        // Deny non-owners access to column Partial.ColPartialHide\n        resource: -3, aclFormula: 'user.Access != \"owners\"', permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        // Deny non-owners access to rowIds <= 1 in column Partial.ColPartialMaybe.\n        resource: -4, aclFormula: 'user.Access != \"owners\" and rec.id <= 1', permissionsText: \"none\",\n      }],\n    ]);\n\n    // Add a table to be summarized, and deny access to it.\n    const summarizedTableRef = (await owner.applyUserActions(docId, [\n      [\"AddTable\", \"ToSummarize\", [{ id: \"SCol1\" }, { id: \"SCol2\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"ToSummarize\", null, { SCol1: \"a\", SCol2: 100 }],\n      [\"AddRecord\", \"ToSummarize\", null, { SCol1: \"a\", SCol2: 200 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"ToSummarize\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"none\",\n      }],\n    ])).retValues[0].id;\n\n    // Add a summary of table 'ToSummarize', grouped by 'SCol1'. This table is not denied.\n    const colRef = (await owner.getDocAPI(docId).getRecords(\"_grist_Tables_column\",\n      { filters: { parentId: [summarizedTableRef], colId: [\"SCol1\"] } }))[0].id;\n    await owner.applyUserActions(docId, [\n      [\"CreateViewSection\", summarizedTableRef, 0, \"record\", [colRef], null],\n    ]);\n  }\n\n  it(\"respects access rules for CSV exports\", async function() {\n    const docId = await owner.newDoc({ name: \"doc\" }, wsId);\n    await createSampleTables(docId);\n\n    // Some sections get created automatically. Find their IDs.\n    const tables = await owner.getDocAPI(docId).getRecords(\"_grist_Tables\");\n    const tableRefToTableId = new Map(tables.map(t => [t.id, t.fields.tableId]));\n    const viewSections = await owner.getDocAPI(docId).getRecords(\"_grist_Views_section\");\n    const tableIdToSection = new Map(viewSections.map(vs =>\n      [tableRefToTableId.get(vs.fields.tableRef as number), vs.id]));\n\n    async function getCSV(user: UserAPIImpl, tableId: string, options?: { viewAs: string }): Promise<string> {\n      const params = {\n        viewSection: tableIdToSection.get(tableId) as number,\n        tableId,\n        ...(options ? { aclAsUser_: options.viewAs } : {}),\n      };\n      const url = user.getDocAPI(docId).getDownloadCsvUrl(params);\n      // BaseAPI.request method is private, but so handy here, that we'll use it anyway.\n      const resp = await (user as any).request(url, { timeout: 1000 });\n      return (await resp.text()).trim();\n    }\n\n    // Owner and editor can CSV-export Public table in full.\n    assert.deepEqual(await getCSV(owner, \"Public\"), \"ColPublic1\\n10\");\n    assert.deepEqual(await getCSV(editor, \"Public\"), \"ColPublic1\\n10\");\n\n    // Only owner can CSV-export Private table.\n    assert.deepEqual(await getCSV(owner, \"Private\"), \"ColPrivate1\\n20\");\n    await assert.isRejected(getCSV(editor, \"Private\"),\n      /Request.*failed with status 404.*Cannot find or access table/);\n\n    // The partial table is full visible to the Owner, partially to Editor.\n    assert.deepEqual(await getCSV(owner, \"Partial\"),\n      \"ColPartialShow,ColPartialHide,ColPartialMaybe\\n\" +\n      \"show1,hide1,maybe1\\n\" +\n      \"show2,hide2,maybe2\",\n    );\n    assert.deepEqual(await getCSV(editor, \"Partial\"),\n      \"ColPartialShow,ColPartialMaybe\\n\" +\n      \"show1,CENSORED\\n\" +\n      \"show2,maybe2\",\n    );\n\n    // Test also that \"View As\" simulation respects access rules.\n    assert.deepEqual(await getCSV(owner, \"Partial\", { viewAs: \"charon@getgrist.com\" }),\n      \"ColPartialShow,ColPartialMaybe\\n\" +\n      \"show1,CENSORED\\n\" +\n      \"show2,maybe2\",\n    );\n\n    // The table ToSummarize is visible only to owner.\n    assert.deepEqual(await getCSV(owner, \"ToSummarize\"), \"SCol1,SCol2\\na,100\\na,200\");\n    await assert.isRejected(getCSV(editor, \"ToSummarize\"),\n      /Request.*failed with status 404.*Cannot find or access table/);\n\n    await assert.isRejected(getCSV(owner, \"ToSummarize\", { viewAs: \"charon@getgrist.com\" }),\n      /Request.*failed with status 404.*Cannot find or access table/);\n\n    // It's summary table is visible only to both owner and editor.\n    assert.deepEqual(await getCSV(owner, \"ToSummarize_summary_SCol1\"), \"SCol1,count,SCol2\\na,2,300\");\n    assert.deepEqual(await getCSV(editor, \"ToSummarize_summary_SCol1\"), \"SCol1,count,SCol2\\na,2,300\");\n  });\n\n  it(\"respects access rules for XLSX exports\", async function() {\n    const docId = await owner.newDoc({ name: \"doc\" }, wsId);\n    await createSampleTables(docId);\n\n    async function getXlsx(user: UserAPIImpl): Promise<any> {\n      const url = user.getDocAPI(docId).getDownloadXlsxUrl();\n      // BaseAPI.request method is private, but so handy here, that we'll use it anyway.\n      const resp = await (user as any).request(url, { timeout: 5000 });\n      const workbook = new Excel.Workbook();\n      await workbook.xlsx.read(resp.body);\n      const output: { [name: string]: string } = {};\n      for (const ws of workbook.worksheets) {\n        output[ws.name] = (await workbook.csv.writeBuffer({ sheetName: ws.name })).toString();\n      }\n      return output;\n    }\n\n    assert.deepEqual(await getXlsx(owner), {\n      Public: \"ColPublic1\\n10\",\n      Private: \"ColPrivate1\\n20\",\n      Partial: (\n        \"ColPartialShow,ColPartialHide,ColPartialMaybe\\n\" +\n        \"show1,hide1,maybe1\\n\" +\n        \"show2,hide2,maybe2\"\n      ),\n      ToSummarize: (\n        \"SCol1,SCol2\\n\" +\n        \"a,100\\n\" +\n        \"a,200\"\n      ),\n      // Summary tables are currently omitted from exports.\n    });\n\n    assert.deepEqual(await getXlsx(editor), {\n      Public: \"ColPublic1\\n10\",\n      Partial: (\n        \"ColPartialShow,ColPartialMaybe\\n\" +\n        \"show1,CENSORED\\n\" +\n        \"show2,maybe2\"\n      ),\n      // ToSummarize table is omitted.\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/ExternalStorageAttachmentStore.ts",
    "content": "import {\n  ExternalStorageAttachmentStore,\n  ExternalStorageSupportingAttachments, loadAttachmentFileIntoMemory,\n} from \"app/server/lib/AttachmentStore\";\n\nimport * as stream from \"node:stream\";\n\nimport { assert } from \"chai\";\nimport sinon from \"sinon\";\n\nconst testStoreId = \"test-store-1\";\nconst testPoolId = \"pool1\";\nconst testFileId = \"file1\";\nconst expectedPoolPrefix = \"pool1\";\nconst testFileContents = \"This is the contents of a file\";\nconst testFileBuffer = Buffer.from(testFileContents);\nconst getExpectedFilePath = (fileId: string) => `${expectedPoolPrefix}/${fileId}`;\n\ndescribe(\"ExternalStorageAttachmentStore\", () => {\n  it(\"can upload a file\", async () => {\n    const fakeStorage = {\n      uploadStream: sinon.fake.resolves(undefined),\n    };\n\n    const storage = fakeStorage as unknown as ExternalStorageSupportingAttachments;\n\n    const store = new ExternalStorageAttachmentStore(testStoreId, storage);\n    const fileStream = stream.Readable.from(testFileBuffer);\n    await store.upload(testPoolId, testFileId, fileStream);\n\n    assert.isTrue(fakeStorage.uploadStream.calledOnce, \"upload should be called exactly once\");\n    const call = fakeStorage.uploadStream.getCalls()[0];\n    assert.equal(call.args[0], getExpectedFilePath(testFileId), \"upload path is incorrect\");\n    assert.equal(call.args[1], fileStream, \"file stream is incorrect\");\n  });\n\n  it(\"can download a file\", async () => {\n    const fakeStorage = {\n      downloadStream: sinon.fake(async (_) => {\n        return {\n          metadata: { size: 0, snapshotId: \"\" },\n          contentStream: stream.Readable.from(testFileBuffer),\n        };\n      }),\n    };\n\n    // This line will error if downloadStream's return type changes in a way that breaks fakeStorage.\n    const downloadStreamStorage: Pick<ExternalStorageSupportingAttachments, \"downloadStream\"> = fakeStorage;\n\n    const storage = downloadStreamStorage as unknown as ExternalStorageSupportingAttachments;\n    const store = new ExternalStorageAttachmentStore(testStoreId, storage);\n    const download = await store.download(testPoolId, testFileId);\n    const file = await loadAttachmentFileIntoMemory(download);\n\n    assert.isTrue(fakeStorage.downloadStream.calledOnce, \"download should be called exactly once\");\n    const call = fakeStorage.downloadStream.getCalls()[0];\n    assert.equal(call.args[0], getExpectedFilePath(testFileId), \"download path is incorrect\");\n    assert.equal(file.contents.toString(), testFileContents, \"downloaded file contents don't match\");\n  });\n\n  it(\"can check if a file exists\", async () => {\n    const fakeStorage = {\n      exists: sinon.fake.resolves(true),\n    };\n\n    const storage = fakeStorage as unknown as ExternalStorageSupportingAttachments;\n    const store = new ExternalStorageAttachmentStore(testStoreId, storage);\n    const exists = await store.exists(testPoolId, testFileId);\n\n    assert.isTrue(fakeStorage.exists.calledOnce, \"exists should be called exactly once\");\n    const call = fakeStorage.exists.getCalls()[0];\n    assert.equal(call.args[0], getExpectedFilePath(testFileId), \"file path is incorrect\");\n    assert.isTrue(exists, \"correct exists value should be returned\");\n  });\n\n  it(\"can delete a file\", async () => {\n    const fakeStorage = {\n      remove: sinon.fake.resolves(undefined),\n    };\n\n    const storage = fakeStorage as unknown as ExternalStorageSupportingAttachments;\n    const store = new ExternalStorageAttachmentStore(testStoreId, storage);\n    await store.delete(testPoolId, testFileId);\n\n    assert.isTrue(fakeStorage.remove.calledOnce, \"remove should be called exactly once\");\n    const call = fakeStorage.remove.getCalls()[0];\n    assert.equal(call.args[0], getExpectedFilePath(testFileId), \"file path is incorrect\");\n  });\n\n  it(\"can remove an entire pool\", async () => {\n    const fakeStorage = {\n      removeAllWithPrefix: sinon.fake.resolves(undefined),\n    };\n\n    const storage = fakeStorage as unknown as ExternalStorageSupportingAttachments;\n    const store = new ExternalStorageAttachmentStore(testStoreId, storage);\n    await store.removePool(testPoolId);\n\n    assert.isTrue(fakeStorage.removeAllWithPrefix.calledOnce, \"removeAllWithPrefix should be called exactly once\");\n    const call = fakeStorage.removeAllWithPrefix.getCalls()[0];\n    assert.equal(call.args[0], expectedPoolPrefix, \"path of attachment pool is incorrect\");\n  });\n});\n"
  },
  {
    "path": "test/server/lib/FilesystemAttachmentStore.ts",
    "content": "import {\n  FilesystemAttachmentStore,\n  loadAttachmentFileIntoMemory,\n} from \"app/server/lib/AttachmentStore\";\nimport { IAttachmentStoreConfig } from \"app/server/lib/AttachmentStoreProvider\";\nimport { createTmpDir } from \"test/server/docTools\";\n\nimport * as stream from \"node:stream\";\nimport * as path from \"path\";\n\nimport { assert } from \"chai\";\nimport { mkdtemp, pathExists } from \"fs-extra\";\n\nconst testingDocPoolId = \"1234-5678\";\nconst testingFileId = \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.grist\";\nconst testingFileContents = \"Grist is the best tool ever.\";\n\nfunction getTestingFileAsBuffer(contents: string = testingFileContents) {\n  return Buffer.from(contents, \"utf8\");\n}\n\nfunction getTestingFileAsReadableStream(contents?: string): stream.Readable {\n  return stream.Readable.from(getTestingFileAsBuffer(contents));\n}\n\nexport async function makeTestingFilesystemStoreSpec(\n  name: string = \"filesystem\",\n) {\n  const tempFolder = await createTmpDir();\n  const tempDir = await mkdtemp(path.join(tempFolder, \"filesystem-store-test-\"));\n  return {\n    rootDirectory: tempDir,\n    name,\n    create: async (storeId: string) => (new FilesystemAttachmentStore(storeId, tempDir)),\n  };\n}\n\nexport async function makeTestingFilesystemStoreConfig(\n  name: string = \"test-filesystem\",\n): Promise<IAttachmentStoreConfig> {\n  return {\n    label: name,\n    spec: await makeTestingFilesystemStoreSpec(name),\n  };\n}\n\ndescribe(\"FilesystemAttachmentStore\", () => {\n  it(\"can upload a file\", async () => {\n    const spec = await makeTestingFilesystemStoreSpec();\n    const store = await spec.create(\"test-filesystem-store\");\n    await store.upload(testingDocPoolId, testingFileId, getTestingFileAsReadableStream());\n\n    const exists = await pathExists(path.join(spec.rootDirectory, testingDocPoolId, testingFileId));\n    assert.isTrue(exists, \"uploaded file does not exist on the filesystem\");\n  });\n\n  it(\"can download a file\", async () => {\n    const spec = await makeTestingFilesystemStoreSpec();\n    const store = await spec.create(\"test-filesystem-store\");\n    await store.upload(testingDocPoolId, testingFileId, getTestingFileAsReadableStream());\n\n    const download = await store.download(testingDocPoolId, testingFileId);\n    const file = await loadAttachmentFileIntoMemory(download);\n\n    assert.equal(file.contents.toString(), testingFileContents, \"file contents do not match\");\n  });\n\n  it(\"can check if a file exists\", async () => {\n    const spec = await makeTestingFilesystemStoreSpec();\n    const store = await spec.create(\"test-filesystem-store\");\n\n    assert.isFalse(await store.exists(testingDocPoolId, testingFileId));\n    await store.upload(testingDocPoolId, testingFileId, getTestingFileAsReadableStream());\n    assert.isTrue(await store.exists(testingDocPoolId, testingFileId));\n  });\n\n  it(\"can delete a file\", async () => {\n    const spec = await makeTestingFilesystemStoreSpec();\n    const store = await spec.create(\"test-filesystem-store\");\n\n    assert.isFalse(await store.exists(testingDocPoolId, testingFileId));\n    await store.upload(testingDocPoolId, testingFileId, getTestingFileAsReadableStream());\n    assert.isTrue(await store.exists(testingDocPoolId, testingFileId));\n    await store.delete(testingDocPoolId, testingFileId);\n    assert.isFalse(await store.exists(testingDocPoolId, testingFileId));\n  });\n\n  it(\"can remove an entire pool\", async () => {\n    const spec = await makeTestingFilesystemStoreSpec();\n    const store = await spec.create(\"test-filesystem-store\");\n    await store.upload(testingDocPoolId, testingFileId, getTestingFileAsReadableStream());\n    assert.isTrue(await store.exists(testingDocPoolId, testingFileId));\n\n    await store.removePool(testingDocPoolId);\n\n    assert.isFalse(await store.exists(testingDocPoolId, testingFileId));\n  });\n});\n"
  },
  {
    "path": "test/server/lib/GranularAccess.ts",
    "content": "import { LocalActionBundle, SandboxActionBundle } from \"app/common/ActionBundle\";\nimport { PermissionDataWithExtraUsers } from \"app/common/ActiveDocAPI\";\nimport { delay } from \"app/common/delay\";\nimport {\n  AddRecord,\n  BulkAddRecord,\n  BulkRemoveRecord,\n  BulkUpdateRecord,\n  CellValue,\n  DocAction,\n  RemoveRecord,\n  ReplaceTableData,\n  TableColValues,\n  TableDataAction,\n  UpdateRecord,\n} from \"app/common/DocActions\";\nimport { OpenDocOptions } from \"app/common/DocListAPI\";\nimport { SHARE_KEY_PREFIX } from \"app/common/gristUrls\";\nimport { isLongerThan, pruneArray } from \"app/common/gutil\";\nimport { UserAPI, UserAPIImpl } from \"app/common/UserAPI\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { Deps as DocClientsDeps } from \"app/server/lib/DocClients\";\nimport { DocManager } from \"app/server/lib/DocManager\";\nimport { docSessionFromRequest, makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport { filterColValues, GranularAccess } from \"app/server/lib/GranularAccess\";\nimport { globalUploadSet } from \"app/server/lib/uploads\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { createDocTools } from \"test/server/docTools\";\nimport { GristClient, openClient } from \"test/server/gristClient\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport { cloneDeep, isMatch, pick } from \"lodash\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"GranularAccess\", function() {\n  this.timeout(60000);\n  let home: TestServer;\n  testUtils.setTmpLogLevel(\"error\");\n  let owner: UserAPI;\n  let editor: UserAPI;\n  let docId: string;\n  let wsId: number;\n  let cliOwner: GristClient;\n  let cliEditor: GristClient;\n  let docManager: DocManager;\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  const docTools = createDocTools();\n  const sandbox = sinon.createSandbox();\n\n  async function getWebsocket(api: UserAPI) {\n    const who = await api.getSessionActive();\n    return openClient(home.server, who.user.email, who.org?.domain || \"docs\");\n  }\n\n  /**\n   * Add some actions directly into document history, so they can be used as an undo.\n   */\n  async function addFakeBundle(actions: DocAction[],\n    options?: {\n      user?: string,\n      time?: number,\n    }) {\n    const doc = await docManager.getActiveDoc(docId);\n    const history = doc?.getActionHistory();\n    const actionNum = fakeActionNum;\n    const actionHash = String(fakeActionNum);\n    fakeActionNum++;\n    const bundle: LocalActionBundle = {\n      actionNum,\n      actionHash,\n      parentActionHash: null,\n      userActions: actions,\n      undo: actions,\n      info: [0, { time: Date.now(), ...options } as any],\n      stored: [],\n      calc: [],\n      envelopes: [],\n    };\n    await history?.recordNextShared(bundle);\n    return { actionNum, actionHash };\n  }\n  let fakeActionNum = 10000;\n\n  /**\n   * Apply actions as a fake undo, inserting them in history and then activating\n   * them from there.\n   */\n  async function applyAsUndo(client: GristClient, actions: DocAction[],\n    options?: {\n      user?: string,\n      time?: number,\n    }) {\n    const { actionNum, actionHash } = await addFakeBundle(actions, options);\n    const result = await client.send(\"applyUserActionsById\", 0, [actionNum], [actionHash], true);\n    return result;\n  }\n\n  async function getShareKeyForUrl(linkId: string) {\n    const shares = await home.dbManager.connection.query(\n      \"select * from shares where link_id = ?\", [linkId]);\n    const key = shares[0].key;\n    if (!key) {\n      throw new Error(\"cannot find share key\");\n    }\n    return `${SHARE_KEY_PREFIX}${key}`;\n  }\n\n  async function removeShares(sharingDocId: string, api: UserAPI) {\n    const shares = await owner.getDocAPI(sharingDocId).getRecords(\"_grist_Shares\");\n    for (const share of shares) {\n      await api.applyUserActions(docId, [\n        [\"RemoveRecord\", \"_grist_Shares\", share.id],\n      ]);\n    }\n  }\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    home = new TestServer(this);\n    process.env.GRIST_DEFAULT_EMAIL = \"ham@getgrist.com\";\n    await home.start([\"home\", \"docs\"]);\n    const api = await home.createHomeApi(\"chimpy\", \"docs\", true);\n    await api.newOrg({ name: \"testy\", domain: \"testy\" });\n    owner = await home.createHomeApi(\"chimpy\", \"testy\", true);\n    wsId = await owner.newWorkspace({ name: \"ws\" }, \"current\");\n    await owner.updateWorkspacePermissions(wsId, {\n      users: {\n        \"kiwi@getgrist.com\": \"owners\",\n        \"charon@getgrist.com\": \"editors\",\n      },\n    });\n    editor = await home.createHomeApi(\"charon\", \"testy\", true);\n    docManager = (home.server as any)._docManager;\n  });\n\n  after(async function() {\n    const messages = await testUtils.captureLog(\"error\", async () => {\n      const api = await home.createHomeApi(\"chimpy\", \"docs\");\n      await api.deleteOrg(\"testy\");\n      await home.stop();\n    });\n    assert.lengthOf(messages, 0);\n    await globalUploadSet.cleanupAll();\n    oldEnv.restore();\n  });\n\n  afterEach(async function() {\n    if (docId) {\n      for (const cli of [cliEditor, cliOwner]) {\n        await closeClient(cli);\n      }\n      docId = \"\";\n    }\n    sandbox.restore();\n  });\n\n  async function getGranularAccess(): Promise<GranularAccess> {\n    const doc = await docManager.getActiveDoc(docId);\n    return (doc as any)._granularAccess;\n  }\n\n  async function freshDoc(fixture?: string) {\n    docId = await owner.newDoc({ name: \"doc\" }, wsId);\n    if (fixture) {\n      await home.copyFixtureDoc(fixture, docId);\n      await owner.getDocAPI(docId).forceReload();\n    }\n    cliEditor = await getWebsocket(editor);\n    cliOwner = await getWebsocket(owner);\n    try {\n      await cliEditor.openDocOnConnect(docId);\n      await cliOwner.openDocOnConnect(docId);\n    } catch (_e) {\n      // doc may be unusable\n    }\n  }\n\n  // Reopen clients in a different mode (e.g. default vs fork), or in a different order\n  // (editor first or owner first).\n  async function reopenClients(options?: OpenDocOptions & {\n    first?: \"owner\" | \"editor\" | \"any\",\n  }) {\n    cliEditor.flush();\n    cliOwner.flush();\n    await cliEditor.send(\"closeDoc\", 0);\n    await cliOwner.send(\"closeDoc\", 0);\n    const order = options?.first === \"owner\" ? [cliOwner, cliEditor] : [cliEditor, cliOwner];\n    await order[0].send(\"openDoc\", docId, options);\n    if (options?.first && options.first !== \"any\") {\n      await delay(250);\n    }\n    await order[1].send(\"openDoc\", docId, options);\n  }\n\n  // See the comment in PermissionInfo.ts/evaluateRule() for why we need this.\n  describe(\"forces a row check for rules with memo and rec\", function() {\n    it(\"for -U permission\", async function() {\n      await memoDoc();\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Table1\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"user.Access == OWNER\", permissionsText: \"all\",  // Owner can do anything\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"rec.A == 1\", permissionsText: \"-U\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"rec.A == 2\", permissionsText: \"-U\", memo: \"Cant2\",  // Can't update 2\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"rec.A == 3\", permissionsText: \"-U\", memo: \"Cant3\",  // Can't update 3\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"\", permissionsText: \"-U\",  // Actually can't update anything\n        }],\n      ]);\n\n      // Make sure we see correct memo.\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [1], A: [100] }), []);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [2], A: [100] }), [\"Cant2\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [3], A: [100] }), [\"Cant3\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [4], A: [100] }), []);\n    });\n\n    it(\"for -C permission\", async function() {\n      // Check atomic permission UCD\n      await memoDoc();\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.Access == OWNER\", permissionsText: \"all\",  // Owner can do anything\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 1\", permissionsText: \"-C\", // Can't create rec.A\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 2\", permissionsText: \"-C\", memo: \"Cant2\",  // Can't create rec with 2\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"\", permissionsText: \"-C\",  // Actually can't createy anything\n        }],\n      ]);\n\n      // Make sure we see correct memo.\n      await assertDeniedFor(editor.getDocAPI(docId).addRows(\"Table1\", { A: [1] }), []);\n      await assertDeniedFor(editor.getDocAPI(docId).addRows(\"Table1\", { A: [2] }), [\"Cant2\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).addRows(\"Table1\", { A: [3] }), []);\n    });\n\n    it(\"for -D permission\", async function() {\n      // Check atomic permission UCD\n      await memoDoc();\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.Access == OWNER\", permissionsText: \"all\",  // Owner can do anything\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 1\", permissionsText: \"-D\", // Can't remove 1\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 2\", permissionsText: \"-D\", memo: \"Cant2\",  // Can't remove 2 (with memo)\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"\", permissionsText: \"-D\",  // Actually can't remove anything.\n        }],\n      ]);\n\n      // Make sure we see correct memo.\n      await assertDeniedFor(editor.getDocAPI(docId).removeRows(\"Table1\", [1]), []);\n      await assertDeniedFor(editor.getDocAPI(docId).removeRows(\"Table1\", [2]), [\"Cant2\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).removeRows(\"Table1\", [3]), []);\n    });\n\n    it(\"for -U with mixed columns\", async function() {\n      // Check atomic permission UCD\n      await memoDoc();\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"A\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.Access == OWNER\", permissionsText: \"all\",  // Owner can do anything\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 1\", permissionsText: \"-U\", // Can't update 1\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 2\", permissionsText: \"-U\", memo: \"Cant2\",  // Can't update 2 (with memo)\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 3\", permissionsText: \"-U\", memo: \"Cant3\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"\", permissionsText: \"-U\",  // Actually can't update this column at all.\n        }],\n      ]);\n\n      // Make sure we see correct memo.\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [1], A: [100] }), []);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [2], A: [100] }), [\"Cant2\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [3], A: [100] }), [\"Cant3\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [4], A: [100] }), []);\n\n      // But B is ok to update.\n      await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [1], B: [100] }));\n      await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [2], B: [100] }));\n      await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [3], B: [100] }));\n      await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [4], B: [100] }));\n    });\n\n    it(\"for -U with mixed columns with default fallback\", async function() {\n      await memoDoc();\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"A\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Table1\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.Access == OWNER\", permissionsText: \"all\",  // Owner can do anything\n        }],\n        // ######### A column rules\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 1\", permissionsText: \"-U\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 2\", permissionsText: \"-U\", memo: \"Cant2\",  // Can't update 2 (with memo)\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 3\", permissionsText: \"-U\", memo: \"Cant3\",\n        }],\n        // ######## Table rules (default)\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"rec.A == 4\", permissionsText: \"-U\", memo: \"Cant4\",  // Row 4 is read only.\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"\", permissionsText: \"-U\", memo: \"no\", // Actually can't update this table at all.\n        }],\n      ]);\n\n      // Make sure we see correct memo.\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [1], A: [100] }), [\"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [2], A: [100] }), [\"Cant2\", \"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [3], A: [100] }), [\"Cant3\", \"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [4], A: [100] }), [\"Cant4\", \"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [4], B: [100] }), [\"Cant4\", \"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [5], A: [100] }), [\"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [5], B: [100] }), [\"no\"]);\n    });\n\n    it(\"for -U with mixed columns with default fallback\", async function() {\n      await memoDoc();\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"A\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Table1\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.Access == OWNER\", permissionsText: \"all\",  // Owner can do anything\n        }],\n        // ######### A column rules\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 1\", permissionsText: \"-U\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 2\", permissionsText: \"-U\",  // Can't update 2 (with memo)\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 3\", permissionsText: \"-U\",\n        }],\n        // ######## Table rules (default)\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"rec.A == 4\", permissionsText: \"-U\",  // Row 4 is read only.\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"\", permissionsText: \"-U\", memo: \"no\", // Actually can't update this table at all.\n        }],\n      ]);\n\n      // Make sure we see correct memo.\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [1], A: [100] }), [\"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [2], A: [100] }), [\"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [3], A: [100] }), [\"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [4], A: [100] }), [\"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [4], B: [100] }), [\"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [5], A: [100] }), [\"no\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [5], B: [100] }), [\"no\"]);\n    });\n\n    it(\"for -U with mixed columns without default fallback\", async function() {\n      await memoDoc();\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"A\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Table1\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.Access == OWNER\", permissionsText: \"all\",  // Owner can do anything\n        }],\n        // ######### A column rules\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 1\", permissionsText: \"-U\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 2\", permissionsText: \"-U\", memo: \"Cant2\",  // Can't update 2 (with memo)\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"rec.A == 3\", permissionsText: \"-U\", memo: \"Cant3\",\n        }],\n        // ######## Table rules (default)\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"rec.A == 4\", permissionsText: \"-U\", memo: \"Cant4\",  // Row 4 is read only.\n        }],\n      ]);\n\n      // Make sure we see correct memo.\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [1], A: [100] }), []);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [2], A: [100] }), [\"Cant2\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [3], A: [100] }), [\"Cant3\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [4], A: [100] }), [\"Cant4\"]);\n      await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [4], B: [100] }), [\"Cant4\"]);\n\n      await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [5], A: [100] }));\n      await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [5], B: [100] }));\n    });\n\n    async function memoDoc() {\n      await freshDoc();\n      await owner.applyUserActions(docId, [\n        [\"AddTable\", \"Table1\", [{ id: \"A\", type: \"Int\" }, { id: \"B\", type: \"Int\" }]],\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"-S\",  // drop schema rights\n        }],\n      ]);\n      cliEditor.flush();\n      cliOwner.flush();\n      await owner.getDocAPI(docId).addRows(\"Table1\", { A: [1, 2, 3, 4, 5] });\n    }\n  });\n\n  it(\"hides transform columns from users without SCHEMA_EDIT when any column has rules\", async () => {\n    // gristHelper_Converted and gristHelper_Transform columns are special. When a document\n    // has a granular access rules, those columns are hidden from users without SCHEMA_EDIT.\n    await applyTransformation(\"B\");\n    // Make sure we don't see transform columns as editor.\n    assert.deepEqual((await cliEditor.readDocUserAction()), [\n      [\"AddRecord\", \"_grist_Tables_column\", 8, {\n        isFormula: false, type: \"Any\", formula: \"\", colId: \"\", widgetOptions: \"\",\n        label: \"\", parentPos: 8, parentId: 0,\n      }],\n      [\"AddRecord\", \"_grist_Tables_column\", 9, {\n        isFormula: true, type: \"Any\", formula: \"\", colId: \"\", widgetOptions: \"\",\n        label: \"\", parentPos: 9, parentId: 0,\n      }],\n      [\"ModifyColumn\", \"Table1\", \"A\", { type: \"Text\" }],\n      [\"UpdateRecord\", \"Table1\", 1, { A: \"1234\" }],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 2, { widgetOptions: \"{}\", type: \"Text\" }],\n    ]);\n  });\n\n  it(\"hides transform columns from users without SCHEMA_EDIT if column has rules\", async () => {\n    await applyTransformation(\"A\");\n    // Make sure we don't see anything as editor (we hid column A).\n    assert.deepEqual((await cliEditor.readDocUserAction()), [\n      [\"AddRecord\", \"_grist_Tables_column\", 8, {\n        isFormula: false, type: \"Any\", formula: \"\", colId: \"\", widgetOptions: \"\",\n        label: \"\", parentPos: 8, parentId: 0,\n      }],\n      [\"AddRecord\", \"_grist_Tables_column\", 9, {\n        isFormula: true, type: \"Any\", formula: \"\", colId: \"\", widgetOptions: \"\",\n        label: \"\", parentPos: 9, parentId: 0,\n      }],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 2, { widgetOptions: \"\", type: \"Any\" }],\n    ]);\n  });\n\n  it(\"respects SCHEMA_EDIT when converting a column\", async () => {\n    // Initially, schema flag defaults to ON for editor.\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\", type: \"Int\" },\n        { id: \"B\", type: \"Int\" },\n        { id: \"C\", type: \"Int\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"C\" }],\n      // Add at least one access rule. Otherwise the test would succeed\n      // trivially, via shortcuts in place when the GranularAccess\n      // hasNuancedAccess test returns false. If there are no access\n      // rules present, editors can make any edit. Once a granular access\n      // rule is present, editors lose some rights that are simply too\n      // hard to compute or we haven't gotten around to.\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access == OWNER\", permissionsText: \"-R\",\n      }],\n      [\"AddRecord\", \"Table1\", null, { A: 1234, B: 1234 }],\n    ]);\n\n    // Make a transformation as editor.\n    await editor.applyUserActions(docId, [\n      [\"AddColumn\", \"Table1\", \"gristHelper_Converted\", { type: \"Text\", isFormula: false, visibleCol: 0, formula: \"\" }],\n      [\"AddColumn\", \"Table1\", \"gristHelper_Transform\",\n        { type: \"Text\", isFormula: true, visibleCol: 0, formula: \"rec.gristHelper_Converted\" }],\n      [\"ConvertFromColumn\", \"Table1\", \"A\", \"gristHelper_Converted\", \"Text\", \"\", 0],\n      [\"CopyFromColumn\", \"Table1\", \"gristHelper_Transform\", \"A\", \"{}\"],\n    ]);\n\n    // Now turn off schema flag for editor.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access == EDITOR\", permissionsText: \"-S\",\n      }],\n    ]);\n\n    // Now prepare another transformation.\n    const transformation = [\n      [\"AddColumn\", \"Table1\", \"gristHelper_Converted2\", { type: \"Text\", isFormula: false, visibleCol: 0, formula: \"\" }],\n      [\"AddColumn\", \"Table1\", \"gristHelper_Transform2\",\n        { type: \"Text\", isFormula: true, visibleCol: 0, formula: \"rec.gristHelper_Converted2\" }],\n      [\"ConvertFromColumn\", \"Table1\", \"B\", \"gristHelper_Converted2\", \"Text\", \"\", 0],\n      [\"CopyFromColumn\", \"Table1\", \"gristHelper_Transform\", \"B\", \"{}\"],\n    ];\n    // Should fail for editor.\n    await assert.isRejected(editor.applyUserActions(docId, transformation),\n      /Blocked by full structure access rules/);\n    // Should go through if run as owner.\n    await assert.isFulfilled(owner.applyUserActions(docId, transformation));\n  });\n\n  async function applyTransformation(colToHide: string) {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\", type: \"Int\" }, { id: \"B\", type: \"Int\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Table1\", colIds: colToHide }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"-S\",  // drop schema rights\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        // Transform columns are only hidden from non-owners when we have a granular access rules.\n        // Here we will hide either column A (which will be transformed) or column B (which is not relevant\n        // but will trigger ACL check).\n        resource: -2, aclFormula: \"user.Access != OWNER\", permissionsText: \"-R\",\n      }],\n      [\"AddRecord\", \"Table1\", null, { A: 1234 }],\n    ]);\n    cliEditor.flush();\n    cliOwner.flush();\n\n    // Make transformation as owner. This mimics what happens when we apply a transformation using UI (when\n    // we change column type from Number to Text).\n    await owner.applyUserActions(docId, [\n      [\"AddColumn\", \"Table1\", \"gristHelper_Converted\", { type: \"Text\", isFormula: false, visibleCol: 0, formula: \"\" }],\n      [\"AddColumn\", \"Table1\", \"gristHelper_Transform\",\n        { type: \"Text\", isFormula: true, visibleCol: 0, formula: \"rec.gristHelper_Converted\" }],\n      // This action is repeated by the UI just before applying (we don't to repeat it here).\n      [\"ConvertFromColumn\", \"Table1\", \"A\", \"gristHelper_Converted\", \"Text\", \"\", 0],\n      [\"CopyFromColumn\", \"Table1\", \"gristHelper_Transform\", \"A\", \"{}\"],\n    ]);\n\n    // Make sure we see the actions as owner.\n    assert.deepEqual(await cliOwner.readDocUserAction(), [\n      [\"AddColumn\", \"Table1\", \"gristHelper_Converted\", { isFormula: false, type: \"Text\", formula: \"\" }],\n      [\"AddRecord\", \"_grist_Tables_column\", 8, {\n        isFormula: false,\n        type: \"Text\",\n        formula: \"\",\n        colId: \"gristHelper_Converted\",\n        widgetOptions: \"\",\n        label: \"gristHelper_Converted\",\n        parentPos: 8,\n        parentId: 1,\n      }],\n      [\"AddColumn\", \"Table1\", \"gristHelper_Transform\", {\n        isFormula: true,\n        type: \"Text\",\n        formula: \"rec.gristHelper_Converted\",\n      }],\n      [\"AddRecord\", \"_grist_Tables_column\", 9, {\n        isFormula: true,\n        type: \"Text\",\n        formula: \"rec.gristHelper_Converted\",\n        colId: \"gristHelper_Transform\",\n        widgetOptions: \"\",\n        label: \"gristHelper_Transform\",\n        parentPos: 9,\n        parentId: 1,\n      }],\n      [\"UpdateRecord\", \"Table1\", 1, { gristHelper_Converted: \"1234\" }],\n      [\"ModifyColumn\", \"Table1\", \"A\", { type: \"Text\" }],\n      [\"UpdateRecord\", \"Table1\", 1, { A: \"1234\" }],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 2, { type: \"Text\", widgetOptions: \"{}\" }],\n      [\"UpdateRecord\", \"Table1\", 1, { gristHelper_Transform: \"1234\" }],\n    ]);\n  }\n\n  it(\"persist data when action is rejected\", async () => {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\" }, { id: \"B\" }]],\n      [\"AddRecord\", \"Table1\", null, { B: 1 }],\n      [\"ModifyColumn\", \"Table1\", \"B\", { isFormula: false, type: \"Text\" }],\n      [\"ModifyColumn\", \"Table1\", \"A\", { formula: \"UUID() + $B\", isFormula: true }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        // User can't change column B to 2\n        resource: -1,\n        aclFormula: 'newRec.B == \"2\"',\n        permissionsText: \"-U\",\n        memo: \"stop\",\n      }],\n    ]);\n    // Read A from the engine\n    const aMemBefore = await memCell(\"Table1\", \"A\", 1);\n    // Read A from database.\n    const aDbBefore = await dbCell(\"Table1\", \"A\", 1);\n    assert.equal(aMemBefore, aDbBefore);\n    // Trigger rejection.\n    await assertDeniedFor(owner.getDocAPI(docId).updateRows(\"Table1\", { id: [1], B: [\"2\"] }), [\"stop\"]);\n    // Read A value again.\n    const aDbAfter = await dbCell(\"Table1\", \"A\", 1);\n    // Now read A value from the engine.\n    const aMemAfter = await memCell(\"Table1\", \"A\", 1);\n    assert.equal(aMemAfter, aDbAfter);\n    assert.notEqual(aMemAfter, aMemBefore);\n  });\n\n  it(\"persist data when action is rejected with newRec.A != rec.A formula\", async () => {\n    // Create another example with a different formula.\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\" }, { id: \"B\" }]],\n      [\"AddRecord\", \"Table1\", null, { B: 1 }],\n      [\"ModifyColumn\", \"Table1\", \"B\", { isFormula: false, type: \"Int\" }],\n      [\"ModifyColumn\", \"Table1\", \"A\", { formula: \"UUID() if $B else UUID()\", isFormula: true }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        // We can't trigger A change as it will always have a different value.\n        // It looks like we can't reject this action, as it will cause a fatal failure,\n        // but this is indirect action, so it will bypass ACL check.\n        resource: -1,\n        aclFormula: \"newRec.A != rec.A\",\n        permissionsText: \"-U\",\n        memo: \"stop\",\n      }],\n    ]);\n\n    const aMemBefore = await memCell(\"Table1\", \"A\", 1);\n    const aDbBefore = await dbCell(\"Table1\", \"A\", 1);\n    await assertDeniedFor(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [1], B: [2] }), [\"stop\"]);\n    const aMemAfter = await memCell(\"Table1\", \"A\", 1);\n    const aDbAfter = await dbCell(\"Table1\", \"A\", 1);\n    assert.equal(aMemAfter, aDbAfter);\n    assert.equal(aMemBefore, aDbBefore);\n    assert.notEqual(aDbBefore, aDbAfter);\n    assert.notEqual(aMemBefore, aMemAfter);\n\n    // Make sure we can update formula, as a value change it's not a direct action.\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Table1\", \"A\", { formula: 'UUID() + \"test\"' }],\n    ]));\n  });\n\n  it(\"persist data when action is rejected with schema action\", async () => {\n    // Reject schema actions\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"Table1\", null, {}],\n      [\"ModifyColumn\", \"Table1\", \"A\", { formula: \"UUID()\", isFormula: true }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1,\n        aclFormula: \"user.access != OWNER\",\n        permissionsText: \"-S\",\n        memo: \"stop\",\n      }],\n    ]);\n\n    const aMemBefore = await memCell(\"Table1\", \"A\", 1);\n    const aDbBefore = await dbCell(\"Table1\", \"A\", 1);\n    await assertDeniedFor(editor.applyUserActions(docId, [\n      [\"RemoveColumn\", \"Table1\", \"A\"],\n    ]), [\"stop\"]);\n    const aMemAfter = await memCell(\"Table1\", \"A\", 1);\n    const aDbAfter = await dbCell(\"Table1\", \"A\", 1);\n    assert.equal(aMemAfter, aDbAfter);\n    assert.equal(aMemBefore, aDbBefore);\n    assert.notEqual(aDbBefore, aDbAfter);\n    assert.notEqual(aMemBefore, aMemAfter);\n  });\n\n  it(\"fails when action cannot be rejected\", async () => {\n    // Reject schema actions\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\" }, { id: \"B\" }]],\n      [\"AddRecord\", \"Table1\", null, { B: 1 }],\n      [\"ModifyColumn\", \"Table1\", \"B\", { isFormula: false, type: \"Int\" }],\n      [\"ModifyColumn\", \"Table1\", \"A\", { formula: \"UUID() if $B else UUID()\", isFormula: true }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        // We can't trigger A change as it will always have a different value.\n        // We can't also reject this action, as it will cause a fatal failure (with a direct action)\n        resource: -1,\n        aclFormula: \"newRec.A != rec.A\",\n        permissionsText: \"-U\",\n        memo: \"doom\",\n      }],\n    ]);\n    const engine = await docManager.getActiveDoc(docId)!;\n    // Now simulate a situation that extra actions generated by data engine are\n    // direct, with this, we should receive a fatal error.\n    const sharing = (engine as any)._sharing;\n    const stub: any = sinon.stub(sharing, \"_createExtraBundle\").callsFake((bundle: any, actions: any) => {\n      const result: SandboxActionBundle = stub.wrappedMethod(bundle, actions);\n      // Simulate direct actions.\n      result.direct = result.direct.map(([index]) => [index, true]);\n      return result;\n    });\n    try {\n      cliEditor.flush();\n      cliOwner.flush();\n      await assertFlux(editor.getDocAPI(docId).updateRows(\"Table1\", { id: [1], B: [2] }));\n    } finally {\n      stub.restore();\n    }\n    assert.equal((await cliEditor.readMessage()).type, \"docShutdown\");\n    assert.equal((await cliOwner.readMessage()).type, \"docShutdown\");\n  });\n\n  async function memCell(tableId: string, colId: string, rowId: number) {\n    const engine = await docManager.getActiveDoc(docId)!;\n    const systemSession = makeExceptionalDocSession(\"system\");\n    const { tableData } = await engine.fetchTable(systemSession, tableId, true);\n    return tableData[3][colId][tableData[2].indexOf(rowId)];\n  }\n\n  async function dbCell(tableId: string, colId: string, rowId: number) {\n    const engine = await docManager.getActiveDoc(docId)!;\n    const table = await engine.docStorage.fetchActionData(tableId, [rowId], [colId]);\n    return table[3][colId][0];\n  }\n\n  it(\"respects owner-private tables\", async function() {\n    await freshDoc();\n\n    // Add spies to check whether unexpected calculations are made, to prevent\n    // regression of optimizations.\n    const granularAccess = await getGranularAccess();\n    const metaSteps = sinon.spy(granularAccess, \"_getMetaSteps\" as any);\n    const rowSteps = sinon.spy(granularAccess, \"_getSteps\" as any);\n    assert.equal(metaSteps.called, false);\n    assert.equal(rowSteps.called, false);\n\n    // Make a Private table and mark it as owner-only (using temporary representation).\n    // Make a Public table without any particular access control.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Private\", [{ id: \"A\" }]],\n      [\"AddTable\", \"PartialPrivate\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"PartialPrivate\", null, { A: 0 }],\n      [\"AddRecord\", \"PartialPrivate\", null, { A: 1 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Private\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"PartialPrivate\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        // Negative IDs refer to rowIds used in the same action bundle.\n        resource: -1,\n        aclFormula: 'user.Access == \"owners\"',\n        permissionsText: \"all\",\n        memo: \"owner check\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-S\",  // drop schema rights\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -3, aclFormula: 'user.Access != \"owners\" and rec.A > 0', permissionsText: \"none\",\n      }],\n      [\"AddTable\", \"Public\", [{ id: \"A\" }]],\n    ]);\n\n    // Owner can access both Private and Public tables.\n    await assert.isFulfilled(owner.getDocAPI(docId).getRows(\"Private\"));\n    await assert.isFulfilled(owner.getDocAPI(docId).getRows(\"Public\"));\n\n    // Editor can access the Public table but not the Private table.\n    await assert.isRejected(editor.getDocAPI(docId).getRows(\"Private\"));\n    await assert.isFulfilled(editor.getDocAPI(docId).getRows(\"Public\"));\n\n    await assertDeniedFor(editor.getDocAPI(docId).getRows(\"Private\"), [\"owner check\"]);\n\n    // Metadata to editor should be filtered.  Private metadata gets blanked out\n    // rather than deleted, to keep ids consistent.\n    const tables = await editor.getDocAPI(docId).getRows(\"_grist_Tables\");\n    assert.deepEqual(tables.tableId, [\"Table1\", \"\", \"PartialPrivate\", \"Public\"]);\n\n    // Owner can download, editor can not.\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId));\n\n    // Owner can copy, editor can not.\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));\n    await assert.isRejected((await editor.getWorkerAPI(docId)).copyDoc(docId));\n\n    // Owner can use AddColumn, editor can not (even for public table).\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddColumn\", \"Public\", \"B\", {}],\n      [\"AddColumn\", \"Public\", \"C\", {}],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddColumn\", \"Public\", \"editorB\", {}],\n    ]));\n\n    // Owner can use RemoveColumn, editor can not (even for public table).\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"RemoveColumn\", \"Public\", \"B\"],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"RemoveColumn\", \"Public\", \"C\"],\n    ]));\n\n    // Check that changing a private table's data results in a broadcast to owner but not editor.\n    cliEditor.flush();\n    cliOwner.flush();\n    await owner.getDocAPI(docId).addRows(\"Private\", { A: [99, 100] });\n    assert.lengthOf(await cliOwner.readDocUserAction(), 1);\n    assert.equal(cliEditor.count(), 0);\n\n    // Check that changing a private table's columns results in a full broadcast to owner, but\n    // a filtered broadcast to editor.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddVisibleColumn\", \"Private\", \"X\", {}],\n    ]));\n    const ownerUpdate = await cliOwner.readDocUserAction();\n    const editorUpdate = await cliEditor.readDocUserAction();\n    assert.deepEqual(ownerUpdate.map(a => a[0]), [\"AddColumn\", \"AddRecord\", \"AddRecord\", \"AddRecord\", \"AddRecord\"]);\n    assert.deepEqual(editorUpdate.map(a => a[0]), [\"AddRecord\", \"AddRecord\", \"AddRecord\", \"AddRecord\"]);\n    assert.equal((ownerUpdate[1] as AddRecord)[3].label, \"X\");\n    assert.equal((editorUpdate[0] as AddRecord)[3].label, \"\");\n\n    // Owner can modify metadata, editor can not.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Tables_column\", 1, { formula: \"X\" }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Tables_column\", 1, { formula: \"Y\" }],\n    ]));\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Tables_column\", null, { formula: \"\" }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Tables_column\", null, { formula: \"\" }],\n    ]));\n\n    // Check we have never computed row steps yet.\n    assert.equal(metaSteps.called, true);\n    assert.equal(rowSteps.called, false);\n\n    // Now do something to tickle row step calculation, and make sure it happens.\n    await owner.getDocAPI(docId).addRows(\"PartialPrivate\", { A: [99, 100] });\n    assert.equal(rowSteps.called, true);\n\n    // Check editor cannot see private table schema via fetchPythonCode.\n    assert.match((await cliEditor.send(\"fetchPythonCode\", 0)).error!, /Cannot view code/);\n    assert.equal((await cliOwner.send(\"fetchPythonCode\", 0)).error, undefined);\n  });\n\n  it(\"reports memos sensibly\", async function() {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"Table1\", null, { A: \"test1\" }],\n      [\"AddRecord\", \"Table1\", null, { A: \"test2\" }],\n      [\"AddTable\", \"Table2\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Table2\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'rec.A == \"test1\"', permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1,\n        aclFormula: 'rec.A == \"test2\"',\n        permissionsText: \"-D\",\n        memo: \"rule_d1\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1,\n        aclFormula: 'rec.A == \"test2\"',\n        permissionsText: \"-D\",\n        memo: \"rule_d2\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1,\n        aclFormula: 'rec.A == \"test1\"',\n        permissionsText: \"+U\",\n        memo: \"rule_u\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1,   // Used to have -2, but table-specific rules cannot specify schemaEdit\n        // permission today; it now gets ignored if they do.\n        aclFormula: \"True\",\n        permissionsText: \"-S\",\n        memo: \"rule_s\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"-U\",\n      }],\n    ]);\n    await assertDeniedFor(owner.getDocAPI(docId).removeRows(\"Table1\", [1]), []);\n    await assertDeniedFor(owner.getDocAPI(docId).removeRows(\"Table1\", [2]), [\"rule_d1\", \"rule_d2\"]);\n    await assertDeniedFor(owner.getDocAPI(docId).updateRows(\"Table1\", { id: [2], A: [\"x\"] }),\n      [\"rule_u\"]);\n    await assertDeniedFor(owner.applyUserActions(docId, [\n      [\"AddVisibleColumn\", \"Table2\", \"B\", {}],\n    ]), [\"rule_s\"]);\n    await assertDeniedFor(owner.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Table2\", \"A\", { formula: \"a formula\" }],\n    ]), [\"rule_s\"]);\n  });\n\n  it(\"respects table wildcard\", async function() {\n    await freshDoc();\n\n    // Make a Private table, using wildcard.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Private\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"none\",\n      }],\n\n      [\"AddTable\", \"Private\", [{ id: \"A\" }]],\n    ]);\n\n    // Owner can access Private table.\n    await assert.isFulfilled(owner.getDocAPI(docId).getRows(\"Private\"));\n\n    // Editor cannot access Private table.\n    await assert.isRejected(editor.getDocAPI(docId).getRows(\"Private\"));\n  });\n\n  it(\"checks for special actions after schema actions\", async function() {\n    await freshDoc();\n\n    // Make a table with an owner-private column, and with only the owner\n    // allowed to make schema changes.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\" }, { id: \"B\", widgetOptions: \"{}\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: \"a1\", B: \"b1\" }],\n      [\"AddRecord\", \"Data1\", null, { A: \"a2\", B: \"b2\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"A\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access not in [OWNER]\", permissionsText: \"-RU\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"user.Access not in [OWNER]\", permissionsText: \"-S\",  // drop schema rights\n      }],\n    ]);\n\n    assert.deepEqual(await owner.getDocAPI(docId).getRows(\"Data1\"), {\n      id: [1, 2],\n      manualSort: [1, 2],\n      A: [\"a1\", \"a2\"],\n      B: [\"b1\", \"b2\"],\n    });\n\n    assert.deepEqual(await editor.getDocAPI(docId).getRows(\"Data1\"), {\n      id: [1, 2],\n      manualSort: [1, 2],\n      B: [\"b1\", \"b2\"],\n    });\n\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"CopyFromColumn\", \"Data1\", \"A\", \"B\", {}],\n    ]), /Blocked by full structure access rules/);\n\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"RenameColumn\", \"Data1\", \"B\", \"B\"],\n      [\"CopyFromColumn\", \"Data1\", \"A\", \"B\", {}],\n    ]), /Blocked by full structure access rules/);\n\n    assert.deepEqual(await editor.getDocAPI(docId).getRows(\"Data1\"), {\n      id: [1, 2],\n      manualSort: [1, 2],\n      B: [\"b1\", \"b2\"],\n    });\n\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"RenameColumn\", \"Data1\", \"B\", \"B\"],\n      [\"CopyFromColumn\", \"Data1\", \"A\", \"B\", {}],\n    ]));\n\n    assert.deepEqual(await editor.getDocAPI(docId).getRows(\"Data1\"), {\n      id: [1, 2],\n      manualSort: [1, 2],\n      B: [\"a1\", \"a2\"],\n    });\n  });\n\n  it(\"respects owner-only structure\", async function() {\n    await freshDoc();\n\n    // Make some tables, and lock structure.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Public1\", [{ id: \"A\", type: \"Text\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-S\",\n      }],\n      [\"AddTable\", \"Public2\", [{ id: \"A\", type: \"Text\" }]],\n    ]);\n\n    // Owner can access all tables.\n    await assert.isFulfilled(owner.getDocAPI(docId).getRows(\"Public1\"));\n    await assert.isFulfilled(owner.getDocAPI(docId).getRows(\"Public2\"));\n\n    // Editor can access all tables.\n    await assert.isFulfilled(editor.getDocAPI(docId).getRows(\"Public1\"));\n    await assert.isFulfilled(editor.getDocAPI(docId).getRows(\"Public2\"));\n\n    // Owner can download and copy.\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));\n\n    // Editor cannot download or copy. This used to be allowed, but since these operations reveal\n    // access rules, they are no longer allowed to non-owners by default.\n    await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId),\n      /not authorized to download/);\n    await assert.isRejected((await editor.getWorkerAPI(docId)).copyDoc(docId),\n      /insufficient access to document to copy it entirely/i);\n\n    // Owner can use AddColumn, editor can not.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddVisibleColumn\", \"Public1\", \"B\", {}],\n      [\"AddColumn\", \"Public1\", \"C\", {}],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddVisibleColumn\", \"Public1\", \"editorB\", {}],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddColumn\", \"Public1\", \"editorB\", {}],\n    ]));\n\n    // Owner can use RemoveColumn, editor can not.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"RemoveColumn\", \"Public1\", \"B\"],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"RemoveColumn\", \"Public1\", \"C\"],\n    ]));\n\n    // Owner can add an empty table, editor can not.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddEmptyTable\", null],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddEmptyTable\", null],\n    ]), /Blocked by table structure access rules/);\n\n    // Owner can duplicate a table, editor can not.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"DuplicateTable\", \"Public1\", \"Public1Copy\", false],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"DuplicateTable\", \"Public1\", \"Public1Copy\", false],\n    ]), /Blocked by table structure access rules/);\n\n    // Owner can modify metadata, editor can not.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Tables_column\", 1, { formula: \"\" }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Tables_column\", 1, { formula: \"X\" }],\n      // Need to change formula, or update will be ignored and thus succeed\n    ]));\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Tables_column\", null, { formula: \"\" }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Tables_column\", null, { formula: \"\" }],\n    ]));\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Pages\", 1, { indentation: 2 }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Pages\", 1, { indentation: 3 }],\n    ]));\n  });\n\n  it(\"non-owners need extra permissions for downloadDoc and copyDoc\", async function() {\n    // It used to be that \"canReadEverything\" was enough to allow downloadDoc and copyDoc, but\n    // that bypassed the permission to allow viewing Access Rules themselves. Now, in addition to\n    // requiring \"canReadEverything\", copying and downloading requires two other permissions:\n    // \"AccessRules\" (to permit viewing rules) and \"DocCopies\". The UI automatically adds a\n    // restriction on \"DocCopies\" when \"AccessRules\" is granted, so the user has to make a\n    // conscious decision to allow it.\n\n    await freshDoc();\n\n    // With no rules, both Owner and Editor may download and copy\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));\n    await assert.isFulfilled((await editor.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await editor.getWorkerAPI(docId)).copyDoc(docId));\n\n    // Make some tables, and lock structure.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\", type: \"Text\" }]],\n      [\"BulkAddRecord\", \"Table1\", [null, null], { A: [\"Foo\", \"Bar\"] }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"-S\",\n      }],\n    ]);\n\n    // Now only Owner may download and copy.\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));\n    await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId), /Forbidden/);\n    await assert.isRejected((await editor.getWorkerAPI(docId)).copyDoc(docId), /Forbidden/);\n\n    // Give \"Access Rules\" permission.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*SPECIAL\", colIds: \"AccessRules\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"True\", permissionsText: \"+R\",\n      }],\n    ]);\n\n    // Now again, both Owner and Editor may download and copy.\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));\n    await assert.isFulfilled((await editor.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await editor.getWorkerAPI(docId)).copyDoc(docId));\n\n    // Add the \"DocCopies\" restriction.\n    const results = await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*SPECIAL\", colIds: \"DocCopies\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"-R\",\n      }],\n    ]);\n    const restrictionRuleId = results.retValues[1];\n\n    // Editor can no longer download or copy.\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));\n    await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId), /Forbidden/);\n    await assert.isRejected((await editor.getWorkerAPI(docId)).copyDoc(docId), /Forbidden/);\n\n    // Remove the restriction.\n    await owner.applyUserActions(docId, [\n      [\"RemoveRecord\", \"_grist_ACLRules\", restrictionRuleId],\n    ]);\n\n    // If we have ANY data restrictions, copies and downloads should still be disallowed, even\n    // though both \"DocCopies\" and \"AccessRules\" permissions are granted.\n    const result2 = await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'rec.A == \"Foo\"', permissionsText: \"-R\",\n      }],\n    ]);\n    const dataRuleId = result2.retValues[1];\n\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));\n    await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId), /Forbidden/);\n    await assert.isRejected((await editor.getWorkerAPI(docId)).copyDoc(docId), /Forbidden/);\n\n    // If we update data so that it's all visible to the editor, then yes, with \"DocCopies\" and\n    // \"AccessRules\", they can now download. Test it by changing the rule from -R to +R-CUD.\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_ACLRules\", dataRuleId, { permissionsText: \"+R-CUD\" }],\n    ]);\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));\n    await assert.isFulfilled((await editor.getWorkerAPI(docId)).downloadDoc(docId));\n    await assert.isFulfilled((await editor.getWorkerAPI(docId)).copyDoc(docId));\n  });\n\n  it(\"owner can edit rules without structure permission\", async function() {\n    await freshDoc();\n\n    // Make some tables, and lock structure completely.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Public1\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"-S\",\n      }],\n      [\"AddTable\", \"Public2\", [{ id: \"A\" }]],\n    ]);\n\n    // Can still read.\n    await assert.isFulfilled(owner.getDocAPI(docId).getRows(\"Public1\"));\n\n    // Can edit data.\n    await assert.isFulfilled(owner.getDocAPI(docId).addRows(\"Public1\", { A: [67] }));\n\n    // Cannot rename column.\n    await assert.isRejected(owner.applyUserActions(docId, [\n      [\"RenameColumn\", \"Public1\", \"A\", \"Z\"],\n    ]), /Blocked by table structure access rules/);\n\n    // Can still change rules.\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_ACLRules\", 2, {\n        aclFormula: \"True\", permissionsText: \"+S\",\n      }],\n    ]);\n\n    // Can change columns again.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"RenameColumn\", \"Public1\", \"A\", \"Z\"],\n    ]));\n  });\n\n  it(\"supports AddEmptyTable\", async function() {\n    await freshDoc();\n    // Make some tables, and lock structure.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Public1\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-S\",\n      }],\n      [\"AddTable\", \"Public2\", [{ id: \"A\" }]],\n    ]);\n\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddEmptyTable\", null],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddEmptyTable\", null],\n    ]));\n  });\n\n  it(\"blocks formulas early\", async function() {\n    await freshDoc();\n    // Make some tables, and lock structure.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"Table1\", null, { A: [100] }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-S\",\n      }],\n    ]);\n\n    // Try a modification that would have a detectable side-effect even if reverted.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Table1\", \"A\", { isFormula: true, formula: \"datetime.MAXYEAR=1234\",\n        type: \"Int\" }],\n    ]), /Blocked by full structure access rules/);\n\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"_grist_Tables_column\", 1, { formula: \"datetime.MAXYEAR=1234\" }],\n    ]), /Blocked by full structure access rules/);\n\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Tables_column\", null, { formula: \"datetime.MAXYEAR=1234\" }],\n    ]), /Blocked by full structure access rules/);\n\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_Validations\", null, { formula: \"datetime.MAXYEAR=1234\" }],\n    ]), /Blocked by full structure access rules/);\n\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"SetDisplayFormula\", \"Table1\", null, 1, \"datetime.MAXYEAR=1234\"],\n    ]), /Blocked by full structure access rules/);\n\n    // Make sure that the poison formula was never evaluated.\n    await owner.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Table1\", \"A\", { isFormula: true, formula: \"datetime.MAXYEAR\",\n        type: \"Int\" }],\n    ]);\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Table1\")).A, [9999]);\n  });\n\n  it(\"allows AddOrUpdateRecord only with full read access\", async function() {\n    await freshDoc();\n    // Make some tables, and lock structure.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 100 }],\n      [\"AddTable\", \"Data2\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data2\", null, { A: 100 }],\n      [\"AddTable\", \"Data3\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data3\", null, { A: 100 }],\n      [\"AddTable\", \"Data4\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data4\", null, { A: 100 }],\n      [\"AddTable\", \"Data5\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data5\", null, { A: 100 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data2\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data3\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Data4\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -4, { tableId: \"Data5\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-R\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"rec.A == 999\", permissionsText: \"-R\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -3, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-U\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -4, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-C\",\n      }],\n    ]);\n\n    // Can AddOrUpdateRecord on a table with full read access.\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"AddOrUpdateRecord\", \"Data1\", { A: 100 }, { A: 200 }, {}],\n    ]));\n    assert.deepEqual(await editor.getDocAPI(docId).getRows(\"Data1\"), {\n      id: [1],\n      manualSort: [1],\n      A: [200],\n    });\n\n    // Cannot AddOrUpdateRecord on a table without read access.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddOrUpdateRecord\", \"Data2\", { A: 100 }, { A: 200 }, {}],\n    ]), /Blocked by table read access rules/);\n\n    // Cannot AddOrUpdateRecord on a table with partial read access.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddOrUpdateRecord\", \"Data3\", { A: 100 }, { A: 200 }, {}],\n    ]), /Blocked by table read access rules/);\n\n    // Currently cannot combine AddOrUpdateRecord with RenameTable.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"RenameTable\", \"Data1\", \"DataX\"],\n      [\"RenameTable\", \"Data2\", \"Data1\"],\n      [\"AddOrUpdateRecord\", \"Data1\", { A: 200 }, { A: 300 }, {}],\n    ]), /Can only combine AddOrUpdateRecord and BulkAddOrUpdateRecord with simple data changes/);\n\n    // Currently cannot use AddOrUpdateRecord for metadata changes.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddOrUpdateRecord\", \"Data1\", { A: 200 }, { A: 300 }, {}],\n      [\"AddOrUpdateRecord\", \"_grist_Tables\", { tableId: \"Data1\" }, { tableId: \"DataX\" }, {}],\n    ]), /AddOrUpdateRecord cannot yet be used on metadata tables/);\n\n    // Currently cannot combine AddOrUpdateRecord with metadata changes.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddOrUpdateRecord\", \"Data1\", { A: 200 }, { A: 300 }, {}],\n      [\"UpdateRecord\", \"_grist_Tables\", 1, { tableId: \"DataX\" }],\n    ]), /Can only combine AddOrUpdateRecord and BulkAddOrUpdateRecord with simple data changes/);\n\n    // Can combine some simple data changes.\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"AddOrUpdateRecord\", \"Data1\", { A: 200 }, { A: 300 }, {}],\n      [\"AddOrUpdateRecord\", \"Data1\", { A: 500 }, { A: 600 }, {}],\n      [\"AddOrUpdateRecord\", \"Data1\", { A: 300 }, { A: 400 }, {}],\n    ]));\n    assert.deepEqual(await editor.getDocAPI(docId).getRows(\"Data1\"), {\n      id: [1, 2],\n      manualSort: [1, 2],\n      A: [400, 600],\n    });\n\n    // Need both update + create rights\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddOrUpdateRecord\", \"Data4\", { A: 100 }, { A: 200 }, {}],\n    ]), /Blocked by table update access rules/);\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddOrUpdateRecord\", \"Data4\", { A: 300 }, { A: 200 }, {}],\n    ]), /Blocked by table update access rules/);\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddOrUpdateRecord\", \"Data5\", { A: 100 }, { A: 200 }, {}],\n    ]), /Blocked by table create access rules/);\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddOrUpdateRecord\", \"Data5\", { A: 300 }, { A: 200 }, {}],\n    ]), /Blocked by table create access rules/);\n  });\n\n  it(\"allows DuplicateTable only with full read access\", async function() {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 100 }],\n      [\"AddTable\", \"Data2\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data2\", null, { A: 100 }],\n      [\"AddTable\", \"Data3\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data3\", null, { A: 100 }],\n      [\"AddTable\", \"Data4\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data4\", null, { A: 100 }],\n      [\"AddTable\", \"Data5\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data5\", null, { A: 100 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data2\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data3\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Data4\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -4, { tableId: \"Data5\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-R\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"rec.A == 999\", permissionsText: \"-R\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -4, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-C\",\n      }],\n    ]);\n\n    // Can perform DuplicateTable on a table with full read access.\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"DuplicateTable\", \"Data1\", \"Data1Copy\", true],\n    ]));\n    assert.deepEqual(await editor.getDocAPI(docId).getRows(\"Data1Copy\"), {\n      id: [1],\n      manualSort: [1],\n      A: [100],\n    });\n\n    // Cannot perform DuplicateTable on a table without read access.\n    for (const includeData of [false, true]) {\n      await assert.isRejected(editor.applyUserActions(docId, [\n        [\"DuplicateTable\", \"Data2\", \"Data2Copy\", includeData],\n      ]), /Blocked by table read access rules/);\n    }\n\n    // Cannot perform DuplicateTable on a table with partial read access.\n    for (const includeData of [false, true]) {\n      await assert.isRejected(editor.applyUserActions(docId, [\n        [\"DuplicateTable\", \"Data3\", \"Data3Copy\", includeData],\n      ]), /Blocked by table read access rules/);\n    }\n\n    // Cannot perform DuplicateTable (with data) on a table without create access.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"DuplicateTable\", \"Data5\", \"Data5Copy\", true],\n    ]), /Blocked by table create access rules/);\n\n    // Check that denied schemaEdit prevents duplication. We can duplicate Data4 table until we deny schemaEdit.\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"DuplicateTable\", \"Data1\", \"Data4Copy0\", true],\n    ]));\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-S\",\n      }],\n    ]);\n    // Cannot perform DuplicateTable on a table without schema edit access.\n    for (const includeData of [false, true]) {\n      await assert.isRejected(editor.applyUserActions(docId, [\n        [\"DuplicateTable\", \"Data4\", \"Data4Copy\", includeData],\n      ]), /Blocked by table structure access rules/);\n    }\n\n    // Owner can still perform DuplicateTable, even with partial read access or\n    // without schema edit access.\n    for (const includeData of [false, true]) {\n      await assert.isFulfilled(owner.applyUserActions(docId, [\n        [\"DuplicateTable\", \"Data3\", \"Data3Copy\", includeData],\n      ]));\n      await assert.isFulfilled(owner.applyUserActions(docId, [\n        [\"DuplicateTable\", \"Data4\", \"Data4Copy\", includeData],\n      ]));\n    }\n\n    // Cannot combine DuplicateTable with other actions.\n    for (const includeData of [false, true]) {\n      await assert.isRejected(owner.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_Tables\", 4, { tableId: \"Data3New\" }],\n        [\"DuplicateTable\", \"Data3New\", \"Data3NewCopy\", includeData],\n      ]), /DuplicateTable currently cannot be combined with other actions/);\n      await assert.isRejected(owner.applyUserActions(docId, [\n        [\"AddOrUpdateRecord\", \"Data3\", { A: 100 }, { A: 200 }, {}],\n        [\"DuplicateTable\", \"Data3\", \"Data3Copy\", includeData],\n      ]), /DuplicateTable currently cannot be combined with other actions/);\n      await assert.isRejected(owner.applyUserActions(docId, [\n        [\"DuplicateTable\", \"Data3\", \"Data3Copy\", includeData],\n        [\"AddRecord\", \"Data3Copy\", null, { A: 100 }],\n      ]), /DuplicateTable currently cannot be combined with other actions/);\n    }\n\n    // Cannot duplicate metadata tables.\n    for (const includeData of [false, true]) {\n      await assert.isRejected(owner.applyUserActions(docId, [\n        [\"DuplicateTable\", \"_grist_Tables\", \"_grist_Tables\", includeData],\n      ]), /DuplicateTable cannot be used on metadata tables/);\n    }\n  });\n\n  it(\"allows a table that only owner can add/remove rows from\", async function() {\n    await freshDoc();\n\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"Data\", null, { A: 42 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-CD\",\n      }],\n    ]);\n\n    // Owner and editor can read table.\n    assert.lengthOf((await owner.getDocAPI(docId).getRows(\"Data\")).id, 1);\n    assert.lengthOf((await editor.getDocAPI(docId).getRows(\"Data\")).id, 1);\n\n    // Owner and editor can modify rows.\n    await assert.isFulfilled(owner.getDocAPI(docId).updateRows(\"Data\", { id: [1], A: [67] }));\n    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\"Data\", { id: [1], A: [68] }));\n\n    // Editor cannot add or remove rows.\n    await assert.isRejected(editor.getDocAPI(docId).addRows(\"Data\", { A: [999] }));\n    await assert.isRejected(editor.getDocAPI(docId).removeRows(\"Data\", [1]));\n\n    // Owner can add and remove rows.\n    await assert.isFulfilled(owner.getDocAPI(docId).addRows(\"Data\", { A: [999] }));\n    await assert.isFulfilled(owner.getDocAPI(docId).removeRows(\"Data\", [1]));\n  });\n\n  it(\"allows an editor to edit conditional formatting with a published form\", async function() {\n    await freshDoc();\n\n    // Create a doc with a published form\n    await owner.applyUserActions(docId, [\n      // Add a table\n      [\"AddTable\", \"Data\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"Data\", null, { A: 42 }],\n\n      // Add a published form\n      [\"AddRecord\", \"_grist_Shares\", null, {\n        linkId: \"x\",\n        options: '{\"publish\": true}',\n      }],\n      [\"UpdateRecord\", \"_grist_Views_section\", 1,\n        { shareOptions: '{\"publish\": true, \"form\": true}' }],\n      [\"UpdateRecord\", \"_grist_Pages\", 1, { shareRef: 1 }],\n    ]);\n\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      // Add a conditional formatting rule\n      [\"AddEmptyRule\", \"Data\", 0, 1],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 1, { formula: \"$A == 42\" }],\n    ]));\n\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      // Delete the rule\n      [\"RemoveColumn\", \"Data\", \"gristHelper_ConditionalRule\"],\n      [\"RemoveRecord\", \"_grist_Tables_column\", 1],\n    ]));\n\n    await removeShares(docId, owner);\n  });\n\n  it(\"rejects disabled users over websockets\", async function() {\n    await freshDoc();\n\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"Data\", null, { A: 42 }],\n    ]);\n\n    // Owner and editor can read table.\n    assert.equal((await cliOwner.send(\"fetchTable\", 0, \"Data\")).data.tableData[3].A, 42);\n    assert.equal((await cliEditor.send(\"fetchTable\", 0, \"Data\")).data.tableData[3].A, 42);\n\n    // ham (as in dramatic actor) is the admin\n    const admin = await home.createHomeApi(\"ham\", \"docs\", true);\n\n    // Admin bans the editor\n    const editorProfile = await editor.getUserProfile();\n    await admin.disableUser(editorProfile.id);\n    home.dbManager.flushDocAuthCache();\n\n    function assertResponseDenied(resp: any) {\n      assert.equal(resp.errorCode, \"AUTH_NO_VIEW\");\n      assert.equal(resp.error, \"No view access\");\n    }\n\n    // Editor should not be able to read or write anymore\n    assertResponseDenied(await cliEditor.send(\n      \"fetchTable\", 0, \"Data\",\n    ));\n    assertResponseDenied(await cliEditor.send(\n      \"applyUserActions\", 0, [[\"UpdateRecord\", \"Data\", 1, { A: 68 }]],\n    ));\n    assertResponseDenied(await cliEditor.send(\n      \"applyUserActions\", 0, [[\"AddRecord\", \"Data\", null, { A: 999 }]],\n    ));\n    assertResponseDenied(await cliEditor.send(\n      \"applyUserActions\", 0, [[\"RemoveRecord\", \"Data\", 1]],\n    ));\n\n    // Not even openDoc should work\n    assertResponseDenied(await cliEditor.send(\"openDoc\", docId));\n\n    // Admin restores the editor\n    await admin.enableUser(editorProfile.id);\n    home.dbManager.flushDocAuthCache();\n\n    function assertResponsePasses(resp: any) {\n      assert.isDefined(resp.data);\n      assert.isUndefined(resp.error);\n      assert.isUndefined(resp.errorCode);\n    }\n\n    // Editor can now do everything again.\n    assertResponsePasses(await cliEditor.send(\n      \"fetchTable\", 0, \"Data\"));\n    assertResponsePasses(await cliEditor.send(\n      \"applyUserActions\", 0, [[\"UpdateRecord\", \"Data\", 1, { A: 68 }]],\n    ));\n    assertResponsePasses(await cliEditor.send(\n      \"applyUserActions\", 0, [[\"AddRecord\", \"Data\", null, { A: 999 }]],\n    ));\n    assertResponsePasses(await cliEditor.send(\n      \"applyUserActions\", 0, [[\"RemoveRecord\", \"Data\", 1]],\n    ));\n\n    // Including calling openDoc\n    assertResponsePasses(await cliEditor.send(\"openDoc\", docId));\n  });\n\n  it(\"respects row-level access control\", async function() {\n    await freshDoc();\n    // Make a table, and limit non-owner access to some rows.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\" },\n        { id: \"B\" },\n        { id: \"Public\", isFormula: true, formula: '$B == \"clear\"' }]],\n      [\"AddRecord\", \"Data1\", null, { A: 1, B: \"clear\" }],\n      [\"AddRecord\", \"Data1\", null, { A: 2, B: \"notclear\" }],\n      [\"AddRecord\", \"Data1\", null, { A: 3, B: \"clear\" }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\" and not rec.Public', permissionsText: \"none\",\n      }],\n      // This alternative is equivalent:\n      //    aclFormula: 'user.Access == \"owners\" or rec.Public', permissionsText: 'all',\n      //    aclFormula: '', permissionsText: 'none',\n      [\"AddTable\", \"Data2\", [{ id: \"A\" }, { id: \"B\" }]],\n      [\"AddRecord\", \"Data2\", null, { A: 1, B: 2 }],\n    ]);\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1\")).id, [1, 2, 3]);\n    assert.deepEqual((await editor.getDocAPI(docId).getRows(\"Data1\")).id, [1, 3]);\n\n    // Owner can edit all rows, \"editor\" can only edit public rows.\n    await assert.isFulfilled(owner.getDocAPI(docId).updateRows(\n      \"Data1\", { id: [1], A: [99] }));\n    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\n      \"Data1\", { id: [1], A: [99] }));\n    await assert.isRejected(editor.getDocAPI(docId).updateRows(\n      \"Data1\", { id: [2], A: [99] }));\n\n    // For other tables, editor has normal rights on rows.\n    await assert.isFulfilled(owner.getDocAPI(docId).updateRows(\n      \"Data2\", { id: [1], A: [99] }));\n    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\n      \"Data2\", { id: [1], A: [99] }));\n  });\n\n  it(\"respects row-level access control on updates\", async function() {\n    await freshDoc();\n    // Make a table, and allow update of rows matching a condition.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 1, B: 100 }],\n      [\"AddRecord\", \"Data1\", null, { A: 2, B: 200 }],\n      [\"AddRecord\", \"Data1\", null, { A: 3, B: 300 }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\" and newRec.B <= rec.B', permissionsText: \"-U\",\n      }],\n    ]);\n    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\n      \"Data1\", { id: [1], B: [101] }));\n    await assert.isRejected(editor.getDocAPI(docId).updateRows(\n      \"Data1\", { id: [1], B: [99] }));\n    await assert.isFulfilled(owner.getDocAPI(docId).updateRows(\n      \"Data1\", { id: [1], B: [98] }));\n    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\n      \"Data1\", { id: [1], B: [99] }));\n  });\n\n  it(\"handles schema changes within a bundle\", async function() {\n    await freshDoc();\n    // Owner limits their own row access to a certain table.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 1, B: 100 }],\n      [\"AddRecord\", \"Data1\", null, { A: 2, B: 200 }],\n      [\"AddRecord\", \"Data1\", null, { A: 3, B: 100 }],\n      [\"AddTable\", \"Data2\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data2\", null, { A: 1, B: 100 }],\n      [\"AddRecord\", \"Data2\", null, { A: 2, B: 200 }],\n      [\"AddRecord\", \"Data2\", null, { A: 3, B: 100 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data2\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-U\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"rec.B == 100\", permissionsText: \"-U\",\n      }],\n    ]);\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data2\", 3, { A: 99 }],\n    ]));\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data2\", 2, { A: 99 }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 2, { A: 99 }],\n    ]));\n    // Swap Data1 and Data2 names, and check all is well.\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"RenameTable\", \"Data1\", \"Data3\"],\n      [\"RenameTable\", \"Data2\", \"Data1\"],\n      [\"RenameTable\", \"Data3\", \"Data2\"],\n      [\"UpdateRecord\", \"Data1\", 2, { A: 99 }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 3, { A: 99 }],\n    ]));\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 2, { A: 99 }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data2\", 2, { A: 99 }],\n    ]));\n\n    // This swaps A and B for Data1 (originally Data2).\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"RenameColumn\", \"Data1\", \"A\", \"C\"],\n      [\"RenameColumn\", \"Data1\", \"B\", \"A\"],\n      [\"RenameColumn\", \"Data1\", \"C\", \"B\"],\n      [\"UpdateRecord\", \"Data1\", 2, { B: 99 }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 3, { B: 99 }],\n    ]));\n    await assert.isFulfilled(editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 2, { B: 99 }],\n    ]));\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"RenameColumn\", \"Data1\", \"A\", \"C\"],\n      [\"RenameColumn\", \"Data1\", \"B\", \"A\"],\n      [\"RenameColumn\", \"Data1\", \"C\", \"B\"],\n      [\"UpdateRecord\", \"Data1\", 3, { A: 99 }],\n    ]));\n  });\n\n  it(\"only owners can change rules\", async function() {\n    // We currently have hardcoded permission that only owners can edit rules.\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" }]],\n      [\"AddTable\", \"Sensitive\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Sensitive\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"newRec.A != 1\", permissionsText: \"-U\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-R\",\n      }],\n    ]);\n    cliEditor.flush();\n    cliOwner.flush();\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: 1, aclFormula: \"newRec.A != 1\", permissionsText: \"-U\",\n      }],\n    ]));\n    assert.equal((await cliEditor.readMessage()).type, \"docShutdown\");\n    assert.equal((await cliOwner.readMessage()).type, \"docShutdown\");\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: 1, aclFormula: \"newRec.A != 1\", permissionsText: \"-U\",\n      }],\n    ]), /Only owners can modify access rules/);\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: 1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"-R\",\n      }],\n    ]));\n\n    cliEditor.flush();\n    cliOwner.flush();\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"RenameTable\", \"Data1\", \"Data2\"],\n    ]));\n    assert.deepEqual(await cliOwner.readDocUserAction(), [\n      [\"RenameTable\", \"Data1\", \"Data2\"],\n      [\"UpdateRecord\", \"_grist_Tables\", 2, { tableId: \"Data2\" }],\n      [\"UpdateRecord\", \"_grist_ACLResources\", 2, { tableId: \"Data2\" }],\n    ]);\n    assert.deepEqual(await cliEditor.readDocUserAction(), [\n      [\"RenameTable\", \"Data1\", \"Data2\"],\n      [\"UpdateRecord\", \"_grist_Tables\", 2, { tableId: \"Data2\" }],\n    ]);\n\n    // Editor cannot download doc with some private info.\n    await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId));\n\n    // Grant editor special access to access rules.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*SPECIAL\", colIds: \"AccessRules\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access == \"editors\"', permissionsText: \"+R\",\n      }],\n    ]);\n    cliEditor.flush();\n    cliOwner.flush();\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"RenameTable\", \"Data2\", \"Data3\"],\n    ]));\n    for (const cli of [cliEditor, cliOwner]) {\n      assert.deepEqual(await cli.readDocUserAction(), [\n        [\"RenameTable\", \"Data2\", \"Data3\"],\n        [\"UpdateRecord\", \"_grist_Tables\", 2, { tableId: \"Data3\" }],\n        [\"UpdateRecord\", \"_grist_ACLResources\", 2, { tableId: \"Data3\" }],\n      ]);\n    }\n    // Editor still cannot download doc.\n    await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId));\n\n    // Grant editor special access to download document.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*SPECIAL\", colIds: \"FullCopies\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access == \"editors\"', permissionsText: \"+R\",\n      }],\n    ]);\n\n    // Download should work, and have FullCopies rules/resources removed.\n    const download = await (await editor.getWorkerAPI(docId)).downloadDoc(docId);\n    const worker = await editor.getWorkerAPI(\"import\");\n    const uploadId = await worker.upload(await (download as any).buffer(), \"upload.grist\");\n    const workspaceId = (await editor.getOrgWorkspaces(\"current\"))[0].id;\n    const copyDocId = (await worker.importDocToWorkspace(uploadId, workspaceId)).id;\n    assert.deepEqual(await editor.getDocAPI(copyDocId).getRows(\"_grist_ACLResources\"),\n      { id: [1, 2, 3, 4],\n        colIds: [\"\", \"*\", \"*\", \"AccessRules\"],\n        tableId: [\"\", \"Data3\", \"Sensitive\", \"*SPECIAL\"] });\n    assert.deepEqual((await editor.getDocAPI(copyDocId).getRows(\"_grist_ACLRules\")).resource,\n      [1, 2, 3, 1, 1, 4]);\n\n    // Similarly for a fork.\n    cliEditor.flush();\n    const forkDocId = (await cliEditor.send(\"fork\", 0)).data.docId as string;\n    assert.deepEqual(await editor.getDocAPI(forkDocId).getRows(\"_grist_ACLResources\"),\n      { id: [1, 2, 3, 4],\n        colIds: [\"\", \"*\", \"*\", \"AccessRules\"],\n        tableId: [\"\", \"Data3\", \"Sensitive\", \"*SPECIAL\"] });\n    assert.deepEqual((await editor.getDocAPI(copyDocId).getRows(\"_grist_ACLRules\")).resource,\n      [1, 2, 3, 1, 1, 4]);\n\n    // Original doc should be unchanged.\n    assert.deepEqual(await editor.getDocAPI(docId).getRows(\"_grist_ACLResources\"),\n      { id: [1, 2, 3, 4, 5],\n        colIds: [\"\", \"*\", \"*\", \"AccessRules\", \"FullCopies\"],\n        tableId: [\"\", \"Data3\", \"Sensitive\", \"*SPECIAL\", \"*SPECIAL\"] });\n    assert.deepEqual((await editor.getDocAPI(docId).getRows(\"_grist_ACLRules\")).resource,\n      [1, 2, 3, 1, 1, 4, 5]);\n  });\n\n  it(\"handles fork ownership gracefully\", async function() {\n    // Make a document with some data only owners have access to.\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data1\", 1, { A: 14 }],\n      [\"AddRecord\", \"Data1\", 2, { A: 15 }],\n      [\"AddTable\", \"Sensitive\", [{ id: \"A\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Sensitive\", 1, { A: 16 }],\n      [\"AddRecord\", \"Sensitive\", 2, { A: 17 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Sensitive\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"none\",\n      }],\n    ]);\n\n    // Check editor can write to public table in regular document mode.\n    assert.equal((await cliEditor.send(\"applyUserActions\", 0,\n      [[\"AddRecord\", \"Data1\", null, { A: 99 }]])).error,\n    undefined);\n    // Check editor cannot read sensitive data.\n    assert.match((await cliEditor.send(\"fetchTable\", 0, \"Sensitive\")).error!,\n      /Blocked by table read access rules/);\n    // Check that in fork mode, editor still cannot read sensitive data.\n    await reopenClients({ openMode: \"fork\" });\n    assert.match((await cliEditor.send(\"fetchTable\", 0, \"Sensitive\")).error!,\n      /Blocked by table read access rules/);\n    // Nor can editor write in (pre)-fork mode.  Need to send an explicit \"fork\" command\n    // to create a different doc to write to (tested elsewhere).\n    assert.match((await cliEditor.send(\"applyUserActions\", 0,\n      [[\"AddRecord\", \"Data1\", null, { A: 99 }]])).error!,\n    /No write access/);\n\n    // Grant editor special access to copy/download/fork document.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*SPECIAL\", colIds: \"FullCopies\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access == \"editors\"', permissionsText: \"+R\",\n      }],\n    ]);\n\n    // Check editor can still write to public table in regular document mode.\n    await reopenClients();\n    assert.equal((await cliEditor.send(\"applyUserActions\", 0,\n      [[\"AddRecord\", \"Data1\", null, { A: 99 }]])).error,\n    undefined);\n    // Editor still cannot read sensitive data in regular mode (although they could download\n    // it, tested elsewhere).\n    assert.match((await cliEditor.send(\"fetchTable\", 0, \"Sensitive\")).error!,\n      /Blocked by table read access rules/);\n\n    // But now, if opening in fork mode, editor reads as owner, as if they' already\n    // copied everything and become its owner.\n    await reopenClients({ openMode: \"fork\" });\n    assert.deepEqual((await cliEditor.send(\"fetchTable\", 0, \"Sensitive\")).data.tableData[3],\n      { manualSort: [1, 2], A: [16, 17] });\n    // Modifications remain forbidden.  Were we to send the 'fork' message,\n    // (tested elsewhere) we'd get back a new docId to switch to, and there\n    // the editor would be a true owner.\n    assert.match((await cliEditor.send(\"applyUserActions\", 0,\n      [[\"AddRecord\", \"Data1\", null, { A: 99 }]])).error!,\n    /No write access/);\n  });\n\n  it(\"handles outgoing actions when an action triggers changes in other tables\", async function() {\n    await freshDoc();\n\n    // Set up a situation where there are two linked tables (a change to Contacts will trigger a\n    // change to Interactions), and one table has partial access.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Contacts\", [{ id: \"Name\", type: \"Text\" }, { id: \"Show\", type: \"Bool\" }]],\n      [\"AddTable\", \"Interactions\", [\n        { id: \"Contact\", type: \"Ref:Contacts\" },\n        { id: \"ContactName\", formula: \"$Contact.Name\" },\n      ]],\n      [\"AddRecord\", \"Contacts\", -1, { Name: \"Bob\", Show: true }],\n      [\"AddRecord\", \"Contacts\", -2, { Name: \"Jane\", Show: false }],\n      [\"AddRecord\", \"Interactions\", -1, { Contact: -1 }],\n      [\"AddRecord\", \"Interactions\", -2, { Contact: -1 }],\n      [\"AddRecord\", \"Interactions\", -3, { Contact: -2 }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Contacts\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\" and not rec.Show', permissionsText: \"none\",\n      }],\n    ]);\n\n    // Connect an editor, so that there is someone to receive filtered outgoing actions.\n    cliEditor.flush();\n\n    // Make a change that triggers an update to two different tables. It should succeed.\n    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\"Contacts\", { id: [1], Name: [\"Bert\"] }));\n\n    // Read the broadcast action, and check that it includes both expected updates.\n    const docAction1 = await cliEditor.readDocUserAction();\n    assert.deepEqual(docAction1, [\n      [\"UpdateRecord\", \"Contacts\", 1, { Name: \"Bert\" }],\n      [\"BulkUpdateRecord\", \"Interactions\", [1, 2], { ContactName: [\"Bert\", \"Bert\"] }],\n    ]);\n\n    // As a secondary test, check that the edit restriction works.\n    await assert.isRejected(editor.getDocAPI(docId).updateRows(\"Contacts\", { id: [2], Name: [\"Jennifer\"] }),\n      /Blocked by row update access rules/);\n\n    // Check that it didn't trigger a broadcast.\n    assert.equal(await isLongerThan(cliEditor.readDocUserAction(), 500), true);\n  });\n\n  it(\"restricts helper columns of restricted user columns\", async function() {\n    await freshDoc();\n\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Contacts\", [{ id: \"Name\", type: \"Text\" }]],\n      [\"AddTable\", \"Interactions\", [\n        { id: \"Contact\", type: \"Ref:Contacts\" },\n        { id: \"Show\", type: \"Bool\" },\n      ]],\n\n      [\"AddRecord\", \"Contacts\", 1, { Name: \"Bob\" }],\n      [\"AddRecord\", \"Contacts\", 2, { Name: \"Jane\" }],\n      [\"AddRecord\", \"Interactions\", 3, { Contact: 1, Show: true }],\n      [\"AddRecord\", \"Interactions\", 4, { Contact: 2, Show: false }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Interactions\", colIds: \"Contact\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\" and not rec.Show', permissionsText: \"none\",\n      }],\n    ]);\n\n    cliOwner.flush();\n    cliEditor.flush();\n\n    await owner.applyUserActions(docId, [\n      // Give Interactions.Contact a display column...\n      [\"SetDisplayFormula\", \"Interactions\", null, 8, \"$Contact.Name\"],\n\n      // ...and a conditional formatting rule column\n      [\"AddEmptyRule\", \"Interactions\", 0, 8],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 11, { formula: '$Contact.Name == \"Bob\"' }],\n\n      // Repeat the same for a *field* that uses that column\n      [\"SetDisplayFormula\", \"Interactions\", 13, null, '$Contact.Name + \"2\"'],\n      [\"AddEmptyRule\", \"Interactions\", 13, 0],\n      [\"UpdateRecord\", \"_grist_Tables_column\", 13, { formula: '$Contact.Name == \"Jane\"' }],\n    ]);\n\n    assert.deepEqual(\n      (await cliOwner.readDocUserAction()).slice(-4),\n      [\n        [\"BulkUpdateRecord\", \"Interactions\", [3, 4], { gristHelper_ConditionalRule: [true, false] }],\n        [\"BulkUpdateRecord\", \"Interactions\", [3, 4], { gristHelper_ConditionalRule2: [false, true] }],\n        [\"BulkUpdateRecord\", \"Interactions\", [3, 4], { gristHelper_Display: [\"Bob\", \"Jane\"] }],\n        [\"BulkUpdateRecord\", \"Interactions\", [3, 4], { gristHelper_Display2: [\"Bob2\", \"Jane2\"] }],\n      ],\n    );\n\n    // The helper columns are censored for the editor.\n    // They shouldn't actually be 100% censored in outgoing actions,\n    // this is a limitation with formulas involving `rec`.\n    // When fetching records as below, they're correctly partially censored.\n    const censoreds: CellValue[] = [[GristObjCode.Censored], [GristObjCode.Censored]];\n    assert.deepEqual(\n      (await cliEditor.readDocUserAction()).slice(-4),\n      [\n        [\"BulkUpdateRecord\", \"Interactions\", [3, 4], { gristHelper_ConditionalRule: censoreds }],\n        [\"BulkUpdateRecord\", \"Interactions\", [3, 4], { gristHelper_ConditionalRule2: censoreds }],\n        [\"BulkUpdateRecord\", \"Interactions\", [3, 4], { gristHelper_Display: censoreds }],\n        [\"BulkUpdateRecord\", \"Interactions\", [3, 4], { gristHelper_Display2: censoreds }],\n      ],\n    );\n\n    // Check that the columns were added correctly\n    const columns = await owner.getDocAPI(docId).getRecords(\"_grist_Tables_column\");\n    assert.isTrue(\n      isMatch(columns, [\n        // Table1\n        { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 },\n\n        // Contacts\n        { id: 5, fields: { parentId: 2, colId: \"manualSort\" } },\n        { id: 6, fields: { parentId: 2, colId: \"Name\", type: \"Text\" } },\n\n        // Interactions\n        { id: 7, fields: { parentId: 3, colId: \"manualSort\" } },\n        { id: 8, fields: { parentId: 3, colId: \"Contact\", type: \"Ref:Contacts\", displayCol: 10, rules: [\"L\", 11] } },\n        { id: 9, fields: { parentId: 3, colId: \"Show\", type: \"Bool\" } },\n        { id: 10, fields: { parentId: 3, colId: \"gristHelper_Display\", type: \"Any\", formula: \"$Contact.Name\" } },\n        {\n          id: 11, fields: {\n            parentId: 3, colId: \"gristHelper_ConditionalRule\", type: \"Any\", formula: '$Contact.Name == \"Bob\"',\n          },\n        },\n        { id: 12, fields: { parentId: 3, colId: \"gristHelper_Display2\", type: \"Any\", formula: '$Contact.Name + \"2\"' } },\n        {\n          id: 13, fields: {\n            parentId: 3, colId: \"gristHelper_ConditionalRule2\", type: \"Any\", formula: '$Contact.Name == \"Jane\"',\n          },\n        },\n      ]),\n      \"Unexpected columns: \" + JSON.stringify(columns, null, 4),\n    );\n\n    // Check that the field is also correct\n    const fields = await owner.getDocAPI(docId).getRecords(\"_grist_Views_section_field\");\n    assert.isTrue(\n      isMatch(fields[12], { id: 13, fields: { colRef: 8, displayCol: 12, rules: [\"L\", 13] } }),\n      \"Unexpected fields: \" + JSON.stringify(fields, null, 4),\n    );\n\n    const commonColumns = {\n      id: [3, 4],\n      manualSort: [1, 2],\n      Show: [true, false],\n    };\n\n    const ownerRows = await owner.getDocAPI(docId).getRows(\"Interactions\");\n    assert.deepEqual(ownerRows, {\n      ...commonColumns,\n      Contact: [1, 2],\n      gristHelper_Display: [\"Bob\", \"Jane\"],\n      gristHelper_Display2: [\"Bob2\", \"Jane2\"],\n      gristHelper_ConditionalRule: [true, false],\n      gristHelper_ConditionalRule2: [false, true],\n    });\n\n    const editorRows = await editor.getDocAPI(docId).getRows(\"Interactions\");\n    assert.deepEqual(editorRows, {\n      ...commonColumns,\n      Contact: [1, [GristObjCode.Censored]],\n      // Helper columns are censored in tandem with the associated user column\n      gristHelper_Display: [\"Bob\", [GristObjCode.Censored]],\n      gristHelper_Display2: [\"Bob2\", [GristObjCode.Censored]],\n      gristHelper_ConditionalRule: [true, [GristObjCode.Censored]],\n      gristHelper_ConditionalRule2: [false, [GristObjCode.Censored]],\n    });\n  });\n\n  it(\"respects row-level access control on creates (without formulas)\", async function() {\n    await freshDoc();\n    // Make a table, and allow creation of rows only matching a condition.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 100, B: 50 }],\n      [\"AddRecord\", \"Data1\", null, { A: 200, B: 150 }],\n      [\"AddRecord\", \"Data1\", null, { A: 300, B: 250 }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\" and newRec.A <= newRec.B', permissionsText: \"-C\",\n      }],\n    ]);\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 3);\n    await assert.isFulfilled(editor.getDocAPI(docId).addRows(\n      \"Data1\", { A: [10], B: [1] }));\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 4);\n    await assert.isRejected(editor.getDocAPI(docId).addRows(\n      \"Data1\", { A: [1], B: [10] }));\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 4);\n  });\n\n  it(\"respects row-level access control on creates (with formulas)\", async function() {\n    await freshDoc();\n    // Make a table, and allow creation of rows only matching a condition.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" },\n        { id: \"Good\", isFormula: true, formula: \"$A > $B\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 100, B: 50 }],\n      [\"AddRecord\", \"Data1\", null, { A: 200, B: 150 }],\n      [\"AddRecord\", \"Data1\", null, { A: 300, B: 250 }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\" and not newRec.Good', permissionsText: \"-C\",\n      }],\n    ]);\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 3);\n    await assert.isFulfilled(editor.getDocAPI(docId).addRows(\n      \"Data1\", { A: [10], B: [1] }));\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 4);\n    await assert.isRejected(editor.getDocAPI(docId).addRows(\n      \"Data1\", { A: [1], B: [10] }));\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 4);\n  });\n\n  it(\"respects row-level access control on deletes\", async function() {\n    await freshDoc();\n    // Make a table, and allow creation of rows only matching a condition.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" },\n        { id: \"Good\", isFormula: true, formula: \"$A > $B\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 100, B: 50 }],\n      [\"AddRecord\", \"Data1\", null, { A: 200, B: 250 }],\n      [\"AddRecord\", \"Data1\", null, { A: 300, B: 250 }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\" and not rec.Good', permissionsText: \"-D\",\n      }],\n    ]);\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 3);\n    await assert.isFulfilled(editor.getDocAPI(docId).removeRows(\n      \"Data1\", [1]));\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 2);\n    await assert.isRejected(editor.getDocAPI(docId).removeRows(\n      \"Data1\", [2]));\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 2);\n  });\n\n  it(\"can prevent duplicates\", async function() {\n    await freshDoc();\n    // Make a table, and allow creation or update of rows with unique keys.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"Count\", isFormula: true, formula: \"len(Data1.lookupRecords(A=$A))\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 100 }],\n      [\"AddRecord\", \"Data1\", null, { A: 200 }],\n      [\"AddRecord\", \"Data1\", null, { A: 300 }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1,\n        aclFormula: \"newRec.Count > 1\",\n        permissionsText: \"-CU\",\n        memo: \"duplicate check\",\n      }],\n    ]);\n\n    const noop = assertUnchanged(() => owner.getDocAPI(docId).getRows(\"Data1\"));\n\n    // Adding a row with a distinct key should work.\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 3);\n    await assert.isFulfilled(owner.getDocAPI(docId).addRows(\"Data1\", { A: [400] }));\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 4);\n    // Adding a row with a duplicated key should fail.\n    await noop(assertDeniedFor(owner.getDocAPI(docId).addRows(\"Data1\", { A: [200] }),\n      [\"duplicate check\"]));\n    assert.equal((await owner.getDocAPI(docId).getRows(\"Data1\")).id.length, 4);\n    // If original is removed, adding the row should now succeed.\n    await assert.isFulfilled(owner.getDocAPI(docId).removeRows(\"Data1\", [2]));\n    await assert.isFulfilled(owner.getDocAPI(docId).addRows(\"Data1\", { A: [200] }));\n    // Updating a row to duplicate an existing key should fail.\n    await noop(assert.isRejected(owner.getDocAPI(docId).updateRows(\"Data1\",\n      { id: [1], A: [200] })));\n    // Updating a row to have a new key should succeed.\n    await assert.isFulfilled(owner.getDocAPI(docId).updateRows(\"Data1\",\n      { id: [1], A: [500] }));\n    // Adding rows containing a new duplicate should fail.\n    await noop(assert.isRejected(owner.getDocAPI(docId).addRows(\"Data1\", { A: [600, 600] })));\n\n    // A duplicate introduced within an action bundle should cause the bundle to be rejected.\n    await noop(assert.isRejected(owner.applyUserActions(docId, [\n      [\"AddRecord\", \"Data1\", null, { A: 700 }],\n      [\"UpdateRecord\", \"Data1\", 1, { A: 700 }],\n    ])));\n\n    // An action bundle should otherwise succeed.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddRecord\", \"Data1\", null, { A: 800 }],\n      [\"UpdateRecord\", \"Data1\", 1, { A: 700 }],\n    ]));\n\n    // Adding 700 at this point should be rejected as a duplicate.\n    await noop(assert.isRejected(owner.applyUserActions(docId, [\n      [\"AddRecord\", \"Data1\", -1, { A: 700 }],\n    ])));\n\n    // Adding 700 and immediately overwriting should be accepted.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"AddRecord\", \"Data1\", -1, { A: 700 }],\n      [\"UpdateRecord\", \"Data1\", -1, { A: 750 }],\n    ]));\n\n    // Again, a duplicate introduced in a bundle should be rejected.\n    await noop(assert.isRejected(owner.applyUserActions(docId, [\n      [\"AddRecord\", \"Data1\", -1, { A: 760 }],\n      [\"UpdateRecord\", \"Data1\", -1, { A: 750 }],\n    ])));\n  });\n\n  it(\"permits indirect changes via formulas\", async function() {\n    await freshDoc();\n\n    // Make a table with a data column A, and a formula column Count.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"Count\", isFormula: true, formula: \"len(Data1.lookupRecords(A=$A))\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 100 }],\n      [\"AddRecord\", \"Data1\", null, { A: 200 }],\n      [\"AddRecord\", \"Data1\", null, { A: 300 }],\n\n      // Forbid write access to Count (this is redundant since the data engine forbids\n      // writing to a formula column in any case).\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"Count\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"True\", permissionsText: \"-U\",\n      }],\n    ]);\n\n    // Check initial state of formula column.\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1\")).Count, [1, 1, 1]);\n\n    // Make a change in data column.\n    await assert.isFulfilled(owner.getDocAPI(docId).updateRows(\"Data1\",\n      { id: [1], A: [200] }));\n\n    // Check that formula column changed as expected.\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1\")).Count, [2, 2, 1]);\n\n    // Check that we cannot write to the formula column.\n    await assert.isRejected(owner.getDocAPI(docId).updateRows(\"Data1\",\n      { id: [1], Count: [200] }),\n    /Can't save value to formula column/);\n  });\n\n  it(\"permits indirect changes via type conversion\", async function() {\n    await freshDoc();\n\n    // Make a table with a data column A, and make it read-only.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Int\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 100 }],\n      [\"AddRecord\", \"Data1\", null, { A: 200 }],\n      [\"AddRecord\", \"Data1\", null, { A: 300 }],\n\n      // Forbid write access to column.\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"A\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1,\n        aclFormula: \"True\",\n        permissionsText: \"-CUD\",\n        memo: \"COMPUTER SAYS NO\",\n      }],\n    ]);\n\n    // Check initial state of column.\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1\")).A, [100, 200, 300]);\n\n    // Try to make a change in data column.\n    await assertDeniedFor(owner.getDocAPI(docId).updateRows(\"Data1\",\n      { id: [1], A: [200] }),\n    [\"COMPUTER SAYS NO\"]);\n\n    // Convert column in bulk - we have +S bit so we can do this.\n    await owner.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Data1\", \"A\", { type: \"Text\" }],\n    ]);\n\n    // Check that column changed as expected.\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1\")).A, [\"100\", \"200\", \"300\"]);\n  });\n\n  it(\"permits indirect changes via simple summary tables\", async function() {\n    await freshDoc();\n\n    // Make test tables.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"G\", type: \"Numeric\" }, { id: \"V\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data1\", null, { G: 1, V: 10 }],\n      [\"AddRecord\", \"Data1\", null, { G: 2, V: 20 }],\n      [\"AddRecord\", \"Data1\", null, { G: 2, V: 20 }],\n      [\"AddTable\", \"Data2\", [{ id: \"A\", type: \"Numeric\" }]],\n    ]);\n\n    // Get tableRef and colRef of column 'G' so we can make a summary table.\n    const tableRef = (await owner.getDocAPI(docId).getRows(\"_grist_Tables\",\n      { filters: { tableId: [\"Data1\"] } })).id[0];\n    const colRef = (await owner.getDocAPI(docId).getRows(\"_grist_Tables_column\",\n      { filters: { colId: [\"G\"] } })).id[0];\n\n    // Make a summary table.\n    await owner.applyUserActions(docId, [\n      [\"CreateViewSection\", tableRef, 0, \"detail\", [colRef], null],\n    ]);\n\n    // Allow non-owners to edit data table only, not summary table.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"-CUD\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"user.Access != OWNER\", permissionsText: \"+CUD\",\n      }],\n    ]);\n\n    // Check summary looks as expected.\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1_summary_G\")).V, [10, 40]);\n\n    // Make sure that editor can indirectly create a new row in summary, despite access rules.\n    await editor.applyUserActions(docId, [\n      [\"AddRecord\", \"Data1\", null, { G: 3, V: 5 }],\n    ]);\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1_summary_G\")).V, [10, 40, 5]);\n\n    // Make sure that editor can indirectly hide a row in summary.\n    await editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 1, { G: 3 }],\n    ]);\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1_summary_G\")).V, [40, 15]);\n\n    // Make sure editor cannot directly change Data2.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"Data2\", null, { A: 1 }],\n    ]), /Blocked by table create access rules/);\n  });\n\n  it(\"permits indirect changes via flattened summary tables\", async function() {\n    await freshDoc();\n\n    // Make test tables.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"G\", type: \"ChoiceList\" }, { id: \"V\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data1\", null, { G: [\"L\", 1, 2], V: 10 }],\n      [\"AddRecord\", \"Data1\", null, { G: [\"L\", 2], V: 20 }],\n      [\"AddRecord\", \"Data1\", null, { G: [\"L\", 2], V: 20 }],\n      [\"AddTable\", \"Data2\", [{ id: \"A\", type: \"Numeric\" }]],\n    ]);\n\n    // Get tableRef and colRef of column 'G' so we can make a summary table.\n    const tableRef = (await owner.getDocAPI(docId).getRows(\"_grist_Tables\",\n      { filters: { tableId: [\"Data1\"] } })).id[0];\n    const colRef = (await owner.getDocAPI(docId).getRows(\"_grist_Tables_column\",\n      { filters: { colId: [\"G\"] } })).id[0];\n\n    // Make a summary table.\n    await owner.applyUserActions(docId, [\n      [\"CreateViewSection\", tableRef, 0, \"detail\", [colRef], null],\n    ]);\n\n    // Block create/update/delete to non-owners on summary table.\n    // Allow non-owners to edit data table only, not summary table.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"-CUD\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"user.Access != OWNER\", permissionsText: \"+CUD\",\n      }],\n    ]);\n\n    // Check summary looks as expected.\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1_summary_G\")).V, [10, 50]);\n\n    // Make sure that editor can indirectly create a new row in summary, despite access rules.\n    await editor.applyUserActions(docId, [\n      [\"AddRecord\", \"Data1\", null, { G: [\"L\", 2, 3, 4], V: 5 }],\n    ]);\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1_summary_G\")).V, [10, 55, 5, 5]);\n\n    // Make sure that editor can indirectly hide a row in summary.\n    await editor.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 1, { G: [\"L\", 3] }],\n    ]);\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1_summary_G\")).V, [45, 15, 5]);\n\n    // Make sure editor cannot directly change Data2.\n    await assert.isRejected(editor.applyUserActions(docId, [\n      [\"AddRecord\", \"Data2\", null, { A: 1 }],\n    ]), /Blocked by table create access rules/);\n  });\n\n  it(\"uncensors the raw view section of a source table when a summary table is visible\", async function() {\n    await freshDoc();\n    const docApi = owner.getDocAPI(docId);\n\n    // The doc starts out with one table by default, with three view sections (widgets): one 'normal',\n    // one raw, and one record card.\n    // Initially, they have no titles. Give them some. Note that naming the raw section 'My Data'\n    // also renames the table itself to 'My_Data'.\n    await docApi.updateRows(\"_grist_Views_section\", { id: [1, 2], title: [\"Widget\", \"My Data\"] });\n\n    // Check the initial tableId and title values.\n    let tableIds = (await docApi.getRows(\"_grist_Tables\")).tableId;\n    let sectionTitles = (await docApi.getRows(\"_grist_Views_section\")).title;\n    assert.deepEqual(tableIds, [\"My_Data\"]);\n    assert.deepEqual(sectionTitles, [\"Widget\", \"My Data\", \"\"]);\n\n    // Deny all access to the table.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"My_Data\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: null, permissionsText: \"-CRUD\",\n      }],\n    ]);\n\n    // Now all those values are 'censored', i.e. blank.\n    tableIds = (await docApi.getRows(\"_grist_Tables\")).tableId;\n    sectionTitles = (await docApi.getRows(\"_grist_Views_section\")).title;\n    assert.deepEqual(tableIds, [\"\"]);\n    assert.deepEqual(sectionTitles, [\"\", \"\", \"\"]);\n\n    // Make a summary table on the table grouped by column 'A'.\n    await owner.applyUserActions(docId, [\n      [\"CreateViewSection\", 1, 0, \"detail\", [2], null],\n    ]);\n\n    // Get the values again.\n    tableIds = (await docApi.getRows(\"_grist_Tables\")).tableId;\n    sectionTitles = (await docApi.getRows(\"_grist_Views_section\")).title;\n\n    // The source tableId is still hidden, and we now have a new summary table.\n    assert.deepEqual(tableIds, [\"\", \"My_Data_summary_A\"]);\n\n    assert.deepEqual(sectionTitles, [\n      // Source table sections. The normal section is still hidden, but the raw section title is revealed.\n      \"\", \"My Data\", \"\",\n      // Summary table sections. These aren't hidden, they just have no titles.\n      \"\", \"\",\n    ]);\n  });\n\n  it(\"merges rec and newRec for creations and deletions\", async function() {\n    await freshDoc();\n\n    // Make a table with a data column A, and allow user to add/remove odd rows.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Int\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 100 }],\n      [\"AddRecord\", \"Data1\", null, { A: 201 }],\n      [\"AddRecord\", \"Data1\", null, { A: 301 }],\n\n      // Forbid write access to column.\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"rec.A % 2 == 0\", permissionsText: \"-CD\", memo: \"STOP1\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"newRec.A % 2 == 0\", permissionsText: \"-CD\", memo: \"STOP2\",\n      }],\n    ]);\n\n    // Cannot add a row with even A.\n    await assertDeniedFor(owner.getDocAPI(docId).addRows(\"Data1\", { A: [500] }),\n      [\"STOP1\", \"STOP2\"]);\n\n    // Cannot remove a row with even A.\n    await assertDeniedFor(owner.getDocAPI(docId).removeRows(\"Data1\", [1]),\n      [\"STOP1\", \"STOP2\"]);\n\n    // Can add a row with odd A.\n    await assert.isFulfilled(owner.getDocAPI(docId).addRows(\"Data1\", { A: [501] }));\n\n    // Can remove a row with odd A.\n    await assert.isFulfilled(owner.getDocAPI(docId).removeRows(\"Data1\", [2]));\n  });\n\n  it(\"newRec behavior in a long or mixed bundle is as expected\", async function() {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", isFormula: true, formula: \"$A + 1\" },\n        { id: \"C\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 101 }],\n      [\"AddRecord\", \"Data1\", null, { A: 201 }],\n      [\"AddRecord\", \"Data1\", null, { A: 301 }],\n      [\"AddRecord\", \"Data1\", null, { A: 401 }],\n      [\"AddRecord\", \"Data1\", null, { A: 501 }],\n      [\"AddTable\", \"Data2\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", isFormula: true, formula: \"$A + 1\" }]],\n      [\"AddRecord\", \"Data2\", null, { A: 101 }],\n      [\"AddRecord\", \"Data2\", null, { A: 201 }],\n      [\"AddRecord\", \"Data2\", null, { A: 301 }],\n      [\"AddRecord\", \"Data2\", null, { A: 401 }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data2\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"newRec.B % 2 != 0\", permissionsText: \"-CU\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"newRec.B % 2 != 0\", permissionsText: \"-CU\",\n      }],\n    ]);\n\n    // It is ok for rows to temporarily disobey newRec constraint.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 1, { A: 91 }],\n      [\"UpdateRecord\", \"Data1\", 2, { A: 92 }],\n      [\"UpdateRecord\", \"Data1\", 2, { A: 93 }],\n      [\"UpdateRecord\", \"Data1\", 3, { A: 94 }],\n      [\"UpdateRecord\", \"Data2\", 4, { A: 96 }],\n      [\"UpdateRecord\", \"Data2\", 4, { A: 97 }],\n      [\"AddRecord\", \"Data2\", 5, {}],\n      [\"UpdateRecord\", \"Data2\", 5, { A: 99 }],\n      [\"UpdateRecord\", \"Data1\", 3, { A: 95 }],\n    ]));\n\n    // newRec behavior survives table renames.\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 2, { A: 6 }],\n      [\"RenameTable\", \"Data1\", \"Data11\"],\n      [\"UpdateRecord\", \"Data11\", 2, { A: 7 }],\n    ]));\n\n    // newRec behavior cannot at this time survive column renames.\n    await assert.isRejected(owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data11\", 2, { A: 4 }],\n      [\"RenameColumn\", \"Data11\", \"B\", \"BB\"],\n      [\"UpdateRecord\", \"Data11\", 2, { A: 5 }],\n    ]), /Blocked by row update access rules/);\n  });\n\n  it(\"rules survive schema changes within a bundle\", async function() {\n    // This is important because of renames, which propagate to ACL resources and rules.\n    // But then again, not that important since in-bundle changes are funky because of\n    // delayed formula updates.\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 0, B: 0 }],\n      [\"AddTable\", \"Data2\", [{ id: \"A\", type: \"Numeric\" }]],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"A\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"rec.B > 0\", permissionsText: \"+U\", memo: \"me I did it\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"-U\",\n      }],\n    ]);\n    await assert.isFulfilled(owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 1, { B: 1 }],\n      [\"UpdateRecord\", \"Data1\", 1, { A: 20 }],\n      [\"UpdateRecord\", \"Data1\", 1, { B: 2 }],\n      [\"RenameColumn\", \"Data1\", \"B\", \"BB\"],\n      [\"RenameTable\", \"Data1\", \"Data11\"],\n      [\"UpdateRecord\", \"Data11\", 1, { A: 21 }],\n      [\"RenameColumn\", \"Data11\", \"BB\", \"B\"],\n      [\"RenameTable\", \"Data11\", \"Data1\"],\n    ]));\n    await assert.isRejected(owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 1, { B: 1 }],\n      [\"UpdateRecord\", \"Data1\", 1, { A: 20 }],\n      [\"UpdateRecord\", \"Data1\", 1, { B: 0 }],\n      [\"RenameColumn\", \"Data1\", \"B\", \"BB\"],\n      [\"RenameTable\", \"Data1\", \"Data11\"],\n      [\"UpdateRecord\", \"Data11\", 1, { A: 21 }],\n      [\"RenameColumn\", \"Data11\", \"BB\", \"B\"],\n      [\"RenameTable\", \"Data11\", \"Data1\"],\n    ]), /Blocked by .* access rules/);\n  });\n\n  it(\"can limit workflow\", async function() {\n    await freshDoc();\n    // Make a table with a choice column containing PENDING, STARTED, and FINISHED, with\n    // only modification allowed to that column being to increment it.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"Status\", type: \"Choice\" },\n        { id: \"StatusIndex\", isFormula: true,\n          formula: 'try:\\n\\treturn [\"PENDING\", \"STARTED\", \"FINISHED\"]' +\n            \".index($Status)\\nexcept:\\n\\treturn -1\" }]],\n      [\"AddRecord\", \"Data1\", null, { Status: \"PENDING\" }],\n      [\"AddRecord\", \"Data1\", null, { Status: \"STARTED\" }],\n      [\"AddRecord\", \"Data1\", null, { Status: \"FINISHED\" }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"Status\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"newRec.StatusIndex <= rec.StatusIndex\", permissionsText: \"-U\",\n      }],\n    ]);\n    const api = owner.getDocAPI(docId);\n    // PENDING -> STARTED allowed.\n    await assert.isFulfilled(api.updateRows(\"Data1\", { id: [1], Status: [\"STARTED\"] }));\n    // STARTED -> PENDING forbidden.\n    await assert.isRejected(api.updateRows(\"Data1\", { id: [1], Status: [\"PENDING\"] }));\n    // STARTED -> FINISHED allowed.\n    await assert.isFulfilled(api.updateRows(\"Data1\", { id: [1], Status: [\"FINISHED\"] }));\n    // FINISHED -> earlier state forbidden.\n    await assert.isRejected(api.updateRows(\"Data1\", { id: [1], Status: [\"STARTED\"] }));\n    await assert.isRejected(api.updateRows(\"Data1\", { id: [1], Status: [\"PENDING\"] }));\n    await assert.isRejected(api.updateRows(\"Data1\", { id: [1], Status: [\"...\"] }));\n    // This next \"change\" succeeds because the user action is translated into a no-op\n    // by the data engine, and that no-op is permitted.\n    await assert.isFulfilled(api.updateRows(\"Data1\", { id: [1], Status: [\"FINISHED\"] }));\n  });\n\n  it(\"respects user-private tables\", async function() {\n    await freshDoc();\n\n    const editorProfile = await editor.getUserProfile();\n\n    // Make a Private table and mark it as user-only (using temporary representation).\n    // Make a Public table without any particular access control.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Private\", [{ id: \"A\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Private\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1,\n        aclFormula: `user.UserID == ${editorProfile.id}`,\n        permissionsText: \"all\",\n        memo: \"editor check\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"none\",\n      }],\n      [\"AddTable\", \"Public\", [{ id: \"A\" }]],\n    ]);\n\n    // Owner can access only the public table.\n    await assertDeniedFor(owner.getDocAPI(docId).getRows(\"Private\"), [\"editor check\"]);\n    await assert.isFulfilled(owner.getDocAPI(docId).getRows(\"Public\"));\n\n    // Editor can access both tables.\n    await assert.isFulfilled(editor.getDocAPI(docId).getRows(\"Private\"));\n    await assert.isFulfilled(editor.getDocAPI(docId).getRows(\"Public\"));\n\n    // There are a lot of things the owner can still do, because they are\n    // an owner - including downloading doc, changing access rules etc, editing\n    // the table.  But the table will be hidden in the client, making it difficult\n    // to accidentally edit/view through it at least.\n  });\n\n  it(\"allows user attribute tables\", async function() {\n    await freshDoc();\n\n    const editorProfile = await editor.getUserProfile();\n\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Seattle\", [{ id: \"A\" }]],\n      [\"AddTable\", \"Zones\", [{ id: \"Email\" }, { id: \"City\" }]],\n      [\"AddRecord\", \"Zones\", null, { Email: editorProfile.email, City: \"Seattle\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Seattle\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Zones\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, userAttributes: JSON.stringify({\n          name: \"Zone\",\n          tableId: \"Zones\",\n          charId: \"Email\",\n          lookupColId: \"Email\",\n        }),\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2,\n        aclFormula: 'user.Zone.City != \"Seattle\"',\n        permissionsText: \"none\",\n        memo: \"city check\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -3,\n        aclFormula: 'user.Access != \"owners\"',\n        permissionsText: \"none\",\n        memo: \"owner check\",\n      }],\n    ]);\n\n    await assertDeniedFor(owner.getDocAPI(docId).getRows(\"Seattle\"), [\"city check\"]);\n    await assert.isFulfilled(owner.getDocAPI(docId).getRows(\"Zones\"));\n\n    await assert.isFulfilled(editor.getDocAPI(docId).getRows(\"Seattle\"));\n    await assertDeniedFor(editor.getDocAPI(docId).getRows(\"Zones\"), [\"owner check\"]);\n  });\n\n  it(\"allows user attribute tables to control row access\", async function() {\n    await freshDoc();\n\n    const ownerProfile = await owner.getUserProfile();\n    const editorProfile = await editor.getUserProfile();\n\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Leads\", [{ id: \"Name\" }, { id: \"Place\" }]],\n      [\"AddRecord\", \"Leads\", null, { Name: \"Yi Wen\", Place: \"Seattle\" }],\n      [\"AddRecord\", \"Leads\", null, { Name: \"Zeng Hua\", Place: \"Seattle\" }],\n      [\"AddRecord\", \"Leads\", null, { Name: \"Tao Ping\", Place: \"Boston\" }],\n      [\"AddTable\", \"Zones\", [{ id: \"Email\" }, { id: \"City\" }]],\n      [\"AddRecord\", \"Zones\", null, { Email: editorProfile.email, City: \"Seattle\" }],\n      [\"AddRecord\", \"Zones\", null, { Email: ownerProfile.email, City: \"Boston\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Leads\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, userAttributes: JSON.stringify({\n          name: \"Zone\",\n          tableId: \"Zones\",\n          charId: \"Email\",\n          lookupColId: \"Email\",\n        }),\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"user.Zone.City != rec.Place\", permissionsText: \"none\",\n      }],\n    ]);\n\n    // Editor sees Seattle rows.\n    assert.deepEqual((await editor.getDocAPI(docId).getRows(\"Leads\")).id, [1, 2]);\n\n    // Owner sees Boston rows.\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Leads\")).id, [3]);\n  });\n\n  it(\"respects column level access denial\", async function() {\n    await freshDoc();\n\n    // Make a table with 4 columns, only 2 of which should be available to non-owners.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" }, { id: \"B\", type: \"Numeric\" },\n        { id: \"C\", isFormula: true, formula: \"$A + $B\" },\n        { id: \"D\", isFormula: true, formula: \"$A - $B\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 10, B: 4 }],\n      [\"AddRecord\", \"Data1\", null, { A: 20, B: 5 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"A,C\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"none\",\n      }],\n    ]);\n\n    const expect: TableColValues = {\n      id: [1, 2],\n      manualSort: [1, 2],\n      A: [10, 20],\n      B: [4, 5],\n      C: [14, 25],\n      D: [6, 15],\n    };\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1\")), expect);\n    delete expect.A;\n    delete expect.C;\n    assert.deepEqual((await editor.getDocAPI(docId).getRows(\"Data1\")), expect);\n  });\n\n  it(\"respects column level access granting\", async function() {\n    await freshDoc();\n\n    // Make a table with 4 columns, only 2 of which should be available to non-owners.\n    // Flips previous test by defaulting to denying columns, then granting access to\n    // those we want to share (rather than denying individual columns we don't wish to\n    // share).\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" }, { id: \"B\", type: \"Numeric\" },\n        { id: \"C\", isFormula: true, formula: \"$A + $B\" },\n        { id: \"D\", isFormula: true, formula: \"$A - $B\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 10, B: 4 }],\n      [\"AddRecord\", \"Data1\", null, { A: 20, B: 5 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data1\", colIds: \"B,D\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"\", permissionsText: \"all\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\"', permissionsText: \"none\",\n      }],\n    ]);\n\n    const expect: TableColValues = {\n      id: [1, 2],\n      manualSort: [1, 2],\n      A: [10, 20],\n      B: [4, 5],\n      C: [14, 25],\n      D: [6, 15],\n    };\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1\")), expect);\n    delete expect.A;\n    delete expect.C;\n    assert.deepEqual((await editor.getDocAPI(docId).getRows(\"Data1\")), expect);\n  });\n\n  it(\"only respects read+update permissions in column-level rules\", async function() {\n    // Seed rules previously could result in column-level rules that could contain create+delete\n    // permissions. Even if those appear in rules, we should ignore them.\n    await freshDoc();\n\n    // Create a table with columns A, B. Table denies access, but column A allows all. This\n    // situation used to be easy to get into with seed rules when they didn't trim permission bits.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" }, { id: \"B\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 10, B: 4 }],\n      [\"AddRecord\", \"Data1\", null, { A: 20, B: 5 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data1\", colIds: \"A\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"\", permissionsText: \"+CRUD\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"+R-UCD\",\n      }],\n    ]);\n\n    // Check that we can fetch all data, no restrictions there.\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1\")), {\n      id: [1, 2],\n      manualSort: [1, 2],\n      A: [10, 20],\n      B: [4, 5],\n    });\n\n    // Check that we cannot add or delete records (despite column rule seeming to allow it).\n    await assert.isRejected(owner.applyUserActions(docId, [\n      [\"AddRecord\", \"Data1\", null, { A: 30 }],\n    ]), /Blocked by table create access rules/);\n\n    await assert.isRejected(owner.applyUserActions(docId, [\n      [\"RemoveRecord\", \"Data1\", 2],\n    ]), /Blocked by table delete access rules/);\n\n    // The column rule does its job: allows update to column A.\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 2, { A: 2000 }],\n    ]);\n\n    // But the table rule applies to column B.\n    await assert.isRejected(owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 2, { B: 500 }],\n    ]), /Blocked by column update access rules/);\n\n    assert.deepEqual((await owner.getDocAPI(docId).getRows(\"Data1\")), {\n      id: [1, 2],\n      manualSort: [1, 2],\n      A: [10, 2000],\n      B: [4, 5],\n    });\n  });\n\n  it(\"always allows Calculate action\", async function() {\n    await freshDoc();\n\n    // Make a cell set to `=NOW()` and forbid updating it.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"Now\", isFormula: true, formula: \"NOW()\" }]],\n      [\"AddRecord\", \"Data1\", null, {}],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"-U\",\n      }],\n      [\"AddTable\", \"Private\", [{ id: \"A\" }]],\n    ]);\n\n    const now1 = (await owner.getDocAPI(docId).getRows(\"Data1\")).Now[0];\n    await owner.getDocAPI(docId).forceReload();\n    const now2 = (await owner.getDocAPI(docId).getRows(\"Data1\")).Now[0];\n    assert.notDeepEqual(now1, now2);\n  });\n\n  it(\"can undo changes partially if all are not permitted\", async function() {\n    await freshDoc();\n\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Int\" },  // editor has full rights\n        { id: \"B\", type: \"Int\" },  // editor can read only\n        { id: \"C\", type: \"Int\" },  // editor can edit on some rows\n        { id: \"D\", type: \"Int\" },  // editor can edit on some rows\n        { id: \"E\", type: \"Int\" },  // editor cannot view or edit\n        { id: \"F\", isFormula: true, formula: \"$A\" }]],  // read only\n      [\"AddRecord\", \"Data1\", null, { A: 10, B: 10, C: 10, D: 10, E: 10 }], //  x  x\n      [\"AddRecord\", \"Data1\", null, { A: 11, B: 11, C: 11, D: 11, E: 11 }],\n      [\"AddRecord\", \"Data1\", null, { A: 12, B: 12, C: 12, D: 12, E: 12 }], //  x\n      [\"AddRecord\", \"Data1\", null, { A: 13, B: 13, C: 13, D: 13, E: 13 }], //     x\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data1\", colIds: \"B\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Data1\", colIds: \"C\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -4, { tableId: \"Data1\", colIds: \"D\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -5, { tableId: \"Data1\", colIds: \"E\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -6, { tableId: \"Data1\", colIds: \"F\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        // editor can only create or delete rows with A odd.\n        resource: -1, aclFormula: \"user.Access != OWNER and rec.A % 2 == 1\", permissionsText: \"-CD\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"user.Access != OWNER\", permissionsText: \"-U\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -3, aclFormula: \"user.Access != OWNER and rec.id % 2 == 1\", permissionsText: \"-U\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -4, aclFormula: \"user.Access != OWNER and rec.id % 3 == 1\", permissionsText: \"-U\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -5, aclFormula: \"user.Access != OWNER\", permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -6, aclFormula: \"user.Access != OWNER\", permissionsText: \"-U\",\n      }],\n    ]);\n\n    // Share the document with everyone as an editor.\n    await owner.updateDocPermissions(docId, { users: { \"everyone@getgrist.com\": \"editors\" } });\n\n    // Check that a (fake) undo that affects only material user has edit rights on works.\n    const expected = await owner.getDocAPI(docId).getRows(\"Data1\");\n    await applyAsUndo(cliEditor, [[\"UpdateRecord\", \"Data1\", 1, { A: 55 }]]);\n    expected.A[0] = 55;\n    expected.F[0] = 55;\n    assert.deepEqual(await owner.getDocAPI(docId).getRows(\"Data1\"), expected);\n\n    // Check that an undo that includes a change to a column the user cannot edit has that\n    // change stripped.\n    await applyAsUndo(cliEditor, [[\"UpdateRecord\", \"Data1\", 1, { A: 56, B: 99, E: 99 }]]);\n    expected.A[0] = 56;\n    expected.F[0] = 56;\n    assert.deepEqual(await owner.getDocAPI(docId).getRows(\"Data1\"), expected);\n\n    // Check that changes to specific cells the user cannot edit are also stripped.\n    await applyAsUndo(cliEditor, [[\"BulkUpdateRecord\", \"Data1\", [1, 2, 3, 4],\n      { A: [60, 71, 81, 90],\n        C: [100, 110, 120, 130],\n        D: [140, 150, 160, 170] }]]);\n    expected.F[0] = expected.A[0] = 60;\n    expected.F[1] = expected.A[1] = 71;\n    expected.F[2] = expected.A[2] = 81;\n    expected.F[3] = expected.A[3] = 90;\n    expected.C[1] = 110;\n    expected.C[3] = 130;\n    expected.D[1] = 150;\n    expected.D[2] = 160;\n    assert.deepEqual(await owner.getDocAPI(docId).getRows(\"Data1\"), expected);\n\n    // Check that adds and removes work or are blocked as expected.\n    // Editor can only create/delete rows with A odd.\n    await applyAsUndo(cliEditor, [\n      [\"AddRecord\", \"Data1\", 999, { A: 77 }],   // should be skipped, A must be even\n      [\"BulkRemoveRecord\", \"Data1\", [1, 2]],   // should skip rowId 2, A must be even\n    ]);\n    for (const key of Object.keys(expected)) {\n      // Only first row is removed; no addition.\n      pruneArray(expected[key], [0]);\n    }\n    assert.deepEqual(await owner.getDocAPI(docId).getRows(\"Data1\"), expected);\n\n    await applyAsUndo(cliEditor, [\n      [\"AddRecord\", \"Data1\", 1000, { A: 88 }],   // should be allowed, A is even.\n      [\"BulkAddRecord\", \"Data1\", [1001, 1002], { A: [90, 91] }], // first should be allowed\n    ]);\n    expected.id.push(1000, 1001);\n    expected.A.push(88, 90);\n    expected.B.push(0, 0);\n    expected.C.push(0, 0);\n    expected.D.push(0, 0);\n    expected.E.push(0, 0);\n    expected.F.push(88, 90);\n    expected.manualSort.push(null, null);  // perhaps in a real undo these would have been set in DocActions?\n    assert.deepEqual(await owner.getDocAPI(docId).getRows(\"Data1\"), expected);\n  });\n\n  it(\"getAclResources exposes all tableIds and colIds to those with access rules access\", async function() {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" }, { id: \"B\", type: \"Numeric\" }]],\n      [\"AddTable\", \"Data2\", [{ id: \"C\", type: \"Numeric\" }, { id: \"D\", type: \"Numeric\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"A\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data2\", colIds: \"*\" }],\n      // Nobody gets access.\n      [\"AddRecord\", \"_grist_ACLRules\", null, { resource: -1, aclFormula: \"\", permissionsText: \"none\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, { resource: -2, aclFormula: \"\", permissionsText: \"none\" }],\n    ]);\n\n    // Check that the owner does not see the blocked resources normally.\n    const data1 = await owner.getDocAPI(docId).getRows(\"Data1\");\n    assert.property(data1, \"B\");\n    assert.notProperty(data1, \"A\");\n    await assert.isRejected(owner.getDocAPI(docId).getRows(\"Data2\"));\n\n    // But the owner sees them in getAclResources call. This call is available via the websocket.\n    assert.deepInclude((await cliOwner.send(\"getAclResources\", 0)).data.tables, {\n      Data1: {\n        title: \"Data1\",\n        colIds: [\"id\", \"manualSort\", \"A\", \"B\"],\n        groupByColLabels: null,\n      },\n      Data2: {\n        title: \"Data2\",\n        colIds: [\"id\", \"manualSort\", \"C\", \"D\"],\n        groupByColLabels: null,\n      },\n    });\n\n    // Others can NOT call getAclResources.\n    assert.match((await cliEditor.send(\"getAclResources\", 0)).error!, /Cannot list ACL resources/);\n\n    // Grant access to Access Rules.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*SPECIAL\", colIds: \"AccessRules\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access == \"editors\"', permissionsText: \"+R\",\n      }],\n    ]);\n\n    // Now others CAN call getAclResources.\n    assert.deepInclude((await cliEditor.send(\"getAclResources\", 0)).data.tables, {\n      Data1: {\n        title: \"Data1\",\n        colIds: [\"id\", \"manualSort\", \"A\", \"B\"],\n        groupByColLabels: null,\n      },\n      Data2: {\n        title: \"Data2\",\n        colIds: [\"id\", \"manualSort\", \"C\", \"D\"],\n        groupByColLabels: null,\n      },\n    });\n  });\n\n  it(\"allows column conversions in the presence of per-row rules\", async function() {\n    await freshDoc();\n    const results = await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\" }, { id: \"locked\", type: \"Bool\" }]],\n      [\"AddColumn\", \"Data1\", \"B\", { type: \"Text\", isFormula: false }],\n      [\"AddRecord\", \"Data1\", null, { A: 1, locked: true }],\n      [\"AddRecord\", \"Data1\", null, { A: 2, locked: true }],\n      [\"AddRecord\", \"Data1\", null, { A: 3, locked: false }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'rec.locked and user.Access != \"owners\"', permissionsText: \"+R-CUD\",\n      }],\n    ]);\n\n    // Get the metadata rowId of column B in table Data1.\n    const colRef = results.retValues[1].colRef;\n\n    // Cell changes in a column conversion will bypass access control.  If the user has the\n    // permissionn to change the schema, then the column conversion will be permitted.\n    // (this test used to be more elaborate before this was true).\n    await assert.isFulfilled(editor.applyUserActions(docId,\n      [[\"UpdateRecord\", \"_grist_Tables_column\", colRef, { type: \"Numeric\" }]]));\n  });\n\n  // Checks for a bug in filtering first row.\n  it(\"can filter out first row correctly\", async function() {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" },\n        { id: \"Sum\", isFormula: true, formula: \"$A + $A\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 100, B: 50 }],\n      [\"AddRecord\", \"Data1\", null, { A: 200, B: 150 }],\n      [\"AddRecord\", \"Data1\", null, { A: 300, B: 250 }],\n\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: 'user.Access != \"owners\" and rec.A != 7', permissionsText: \"-R\",\n      }],\n    ]);\n    cliOwner.flush();\n    cliEditor.flush();\n\n    // Change formula, which changes data in all rows, which then all need filtering out.\n    await owner.applyUserActions(docId, [\n      [\"ModifyColumn\", \"Data1\", \"Sum\", { formula: \"$A + $B\" }],\n    ]);\n    let fullResult = await cliOwner.readDocUserAction();\n    let filteredResult = await cliEditor.readDocUserAction();\n    assert.lengthOf(fullResult, 3);\n    assert.lengthOf(filteredResult, 2);\n    assert.deepEqual(fullResult.slice(0, 2), filteredResult);\n    assert.deepEqual(fullResult[2].slice(0, 2), [\"BulkUpdateRecord\", \"Data1\"]);\n\n    // Flip on a row to make sure it shows up.\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 3, { A: 7 }],\n    ]);\n    fullResult = await cliOwner.readDocUserAction();\n    filteredResult = await cliEditor.readDocUserAction();\n    assert.deepEqual(fullResult, [\n      [\"UpdateRecord\", \"Data1\", 3, { A: 7 }],\n      [\"UpdateRecord\", \"Data1\", 3, { Sum: 257 }],\n    ]);\n    assert.deepEqual(filteredResult, [\n      [\"BulkAddRecord\", \"Data1\", [3], { manualSort: [3], A: [7], B: [250], Sum: [550] }],\n      [\"UpdateRecord\", \"Data1\", 3, { Sum: 257 }],\n    ]);\n\n    // Flip on first row to make sure it shows up.\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Data1\", 1, { A: 7 }],\n    ]);\n    fullResult = await cliOwner.readDocUserAction();\n    filteredResult = await cliEditor.readDocUserAction();\n    assert.deepEqual(fullResult, [\n      [\"UpdateRecord\", \"Data1\", 1, { A: 7 }],\n      [\"UpdateRecord\", \"Data1\", 1, { Sum: 57 }],\n    ]);\n    assert.deepEqual(filteredResult, [\n      [\"BulkAddRecord\", \"Data1\", [1], { manualSort: [1], A: [7], B: [50], Sum: [150] }],\n      [\"UpdateRecord\", \"Data1\", 1, { Sum: 57 }],\n    ]);\n  });\n\n  for (const first of [\"editor\", \"owner\", \"any\"] as const) {\n    it(`can censor specific cells in a column (${first} first)`, async function() {\n      if (first !== \"any\") {\n        sandbox.stub(DocClientsDeps, \"BROADCAST_ORDER\").value(\"series\");\n      }\n\n      // Create some column rules that control read permission based on other columns.\n      // Add a rule that controls overall row read permission to check it interacts ok.\n      await freshDoc();\n      await owner.applyUserActions(docId, [\n        [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n          { id: \"B\", type: \"Numeric\" },\n          { id: \"C\", type: \"Numeric\" },\n          { id: \"D\", type: \"Numeric\" }]],\n        [\"AddRecord\", \"Data1\", null, { A: 100, B: 1, C: 40, D: 300 }],\n        [\"AddRecord\", \"Data1\", null, { A: 200, B: 2, C: 45, D: 200 }],\n        [\"AddRecord\", \"Data1\", null, { A: 300, B: 3, C: 50, D: 100 }],\n\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"C,D\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data1\", colIds: \"B\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Data1\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: 'user.Access != \"owners\" and rec.A < 200', permissionsText: \"-R\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: 'user.Access != \"owners\" and rec.A < 50', permissionsText: \"-R\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -3, aclFormula: 'user.Access != \"owners\" and rec.B == 99', permissionsText: \"-R\",\n        }],\n      ]);\n      await reopenClients({ first });\n\n      // Make a series of adds/updates, and make sure cells that are affected indirectly\n      // are censored or uncensored as appropriate.\n      cliEditor.flush();\n      cliOwner.flush();\n      await owner.getDocAPI(docId).addRows(\"Data1\", { A: [300, 150], B: [1, 1], C: [1, 1], D: [1, 1] });\n      assert.deepEqual(await cliEditor.readDocUserAction(),\n        [[\"BulkAddRecord\",\n          \"Data1\",\n          [4, 5],\n          { A: [300, 150], manualSort: [4, 5], B: [1, 1],\n            C: [1, [GristObjCode.Censored]],\n            D: [1, [GristObjCode.Censored]] }]]);\n      assert.deepEqual(await cliOwner.readDocUserAction(),\n        [[\"BulkAddRecord\",\n          \"Data1\",\n          [4, 5],\n          { A: [300, 150], manualSort: [4, 5], B: [1, 1], C: [1, 1], D: [1, 1] }]]);\n      cliEditor.flush();\n      cliOwner.flush();\n      await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [4], A: [100] });\n      assert.deepEqual(await cliEditor.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 4, { A: 100 }],\n          [\"BulkUpdateRecord\", \"Data1\", [4], {\n            C: [[GristObjCode.Censored]],\n            D: [[GristObjCode.Censored]],\n          }]]);\n      assert.deepEqual(await cliOwner.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 4, { A: 100 }]]);\n      cliEditor.flush();\n      cliOwner.flush();\n      await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [4], A: [600] });\n      assert.deepEqual(await cliEditor.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 4, { A: 600 }],\n          [\"BulkUpdateRecord\", \"Data1\", [4], {\n            C: [1],\n            D: [1],\n          }]]);\n      assert.deepEqual(await cliOwner.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 4, { A: 600 }]]);\n      cliEditor.flush();\n      cliOwner.flush();\n      await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [4], A: [3] });\n      assert.deepEqual(await cliEditor.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 4, { A: 3 }],\n          [\"BulkUpdateRecord\", \"Data1\", [4], {\n            C: [[GristObjCode.Censored]],\n            D: [[GristObjCode.Censored]],\n          }],\n          [\"BulkUpdateRecord\", \"Data1\", [4], { B: [[GristObjCode.Censored]] }],\n        ]);\n      assert.deepEqual(await cliOwner.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 4, { A: 3 }]]);\n      cliEditor.flush();\n      cliOwner.flush();\n      await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [4], A: [75] });\n      assert.deepEqual(await cliEditor.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 4, { A: 75 }],\n          [\"BulkUpdateRecord\", \"Data1\", [4], { B: [1] }]]);\n      assert.deepEqual(await cliOwner.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 4, { A: 75 }]]);\n      cliEditor.flush();\n      cliOwner.flush();\n      await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [4], B: [99] });\n      assert.deepEqual(await cliEditor.readDocUserAction(),\n        [[\"BulkRemoveRecord\", \"Data1\", [4]]]);\n      assert.deepEqual(await cliOwner.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 4, { B: 99 }]]);\n      cliEditor.flush();\n      cliOwner.flush();\n      await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [4], B: [98] });\n      assert.deepEqual(await cliEditor.readDocUserAction(),\n        [[\"BulkAddRecord\",\n          \"Data1\",\n          [4],\n          { manualSort: [4],\n            A: [75],\n            B: [98],\n            C: [[GristObjCode.Censored]],\n            D: [[GristObjCode.Censored]] }]]);\n      assert.deepEqual(await cliOwner.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 4, { B: 98 }]]);\n      cliEditor.flush();\n      cliOwner.flush();\n      await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [1, 2, 4], A: [1, 75, 200] });\n      assert.deepEqual(await cliEditor.readDocUserAction(),\n        [[\"BulkUpdateRecord\",\n          \"Data1\",\n          [1, 2, 4],\n          { A: [1, 75, 200] }],\n        [\"BulkUpdateRecord\",\n          \"Data1\",\n          [2, 4],\n          { C: [[GristObjCode.Censored], 1],\n            D: [[GristObjCode.Censored], 1] }],\n        [\"BulkUpdateRecord\", \"Data1\", [1], { B: [[GristObjCode.Censored]] }],\n        ]);\n      assert.deepEqual(await cliOwner.readDocUserAction(),\n        [[\"BulkUpdateRecord\",\n          \"Data1\",\n          [1, 2, 4],\n          { A: [1, 75, 200] }]]);\n\n      // Add a formula column to simulate a reported bug (not actually needed to tickle problem)\n      // where a censored cell for one user could show up as censored for another.\n      await owner.applyUserActions(docId, [\n        [\"AddColumn\", \"Data1\", \"E\", { formula: \"$C\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"E\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: 'user.Access != \"owners\" and rec.A < 200', permissionsText: \"-R\",\n        }],\n      ]);\n      cliEditor.flush();\n      cliOwner.flush();\n\n      await editor.getDocAPI(docId).updateRows(\"Data1\", { id: [2], C: [999] });\n      assert.deepEqual(await cliEditor.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 2, { C: [GristObjCode.Censored] }],\n          [\"UpdateRecord\", \"Data1\", 2, { E: [GristObjCode.Censored] }]]);\n      assert.deepEqual(await cliOwner.readDocUserAction(),\n        [[\"UpdateRecord\", \"Data1\", 2, { C: 999 }],\n          [\"UpdateRecord\", \"Data1\", 2, { E: 999 }]]);\n\n      // Check that only the owner can evaluate the formula.\n      let response = await cliOwner.send(\"getFormulaError\", 0, \"Data1\", \"E\", 2);\n      assert.equal(response.data, 999);\n      response = await cliEditor.send(\"getFormulaError\", 0, \"Data1\", \"E\", 2);\n      assert.equal(response.data, undefined);\n      assert.equal(response.error, \"Cannot access cell\");\n      assert.equal(response.errorCode, \"ACL_DENY\");\n    });\n  }\n\n  it(\"respects BROADCAST_TIMEOUT_MS\", async function() {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\", type: \"Numeric\" },\n        { id: \"B\", type: \"Numeric\" }]],\n    ]);\n\n    // Set timeout negative, so broadcasts fail reliably, and see\n    // that connections close.\n    const timeoutStub = sandbox.stub(DocClientsDeps, \"BROADCAST_TIMEOUT_MS\").value(-1);\n    try {\n      cliEditor.flush();\n      cliOwner.flush();\n      assert.equal(cliEditor.isOpen(), true);\n      assert.equal(cliOwner.isOpen(), true);\n      await owner.getDocAPI(docId).addRows(\"Data1\", { A: [300, 150], B: [1, 1] });\n      await delay(100);\n      assert.equal(cliEditor.isOpen(), false);\n      assert.equal(cliOwner.isOpen(), false);\n    } finally {\n      timeoutStub.restore();\n    }\n  });\n\n  describe(\"filterColValues\", async function() {\n    // A method for checking if a cell contains 'x'.\n    function xRemove(val: any) { return val === \"x\"; }\n\n    for (const actType of [\"BulkUpdateRecord\", \"BulkAddRecord\", \"ReplaceTableData\", \"TableData\"] as const) {\n      it(`should remove correct elements for ${actType}`, function() {\n        // Prepare a 1 row bulk action.\n        const action1: BulkUpdateRecord | BulkAddRecord | ReplaceTableData | TableDataAction = [\n          actType,\n          \"Table1\",\n          [1],\n          {\n            a: [\"x\"], b: [\"b\"], c: [\"x\"],\n          },\n        ];\n        // Check the action is unchanged if row is not specified for filtering.\n        assert.deepEqual(filterColValues(cloneDeep(action1), idx => idx === 99, xRemove),\n          [action1]);\n        // Check the action is filtered as expected if row is specified.  Action set returned\n        // is suboptimal, but nevertheless as expected.\n        assert.deepEqual(filterColValues(cloneDeep(action1), idx => idx === 0, xRemove),\n          [[actType, \"Table1\", [], { a: [], b: [], c: [] }],\n            [actType, \"Table1\", [1], { b: [\"b\"] }]]);\n        // Prepare a multi-row bulk action.\n        const action2: typeof action1 = [\n          actType,\n          \"Table1\",\n          [1, 2, 3],\n          {\n            a: [\"x\", \"a\", \"a\"], b: [\"b\", \"b\", \"b\"], c: [\"x\", \"c\", \"x\"],\n          },\n        ];\n        // Check filtering is as expected: one retained row, two new actions for the\n        // two new permutations of columns.\n        assert.deepEqual(filterColValues(cloneDeep(action2), idx => idx % 2 === 0, xRemove),\n          [[actType, \"Table1\", [2], { a: [\"a\"], b: [\"b\"], c: [\"c\"] }],\n            [actType, \"Table1\", [3], { a: [\"a\"], b: [\"b\"] }],\n            [actType, \"Table1\", [1], { b: [\"b\"] }]]);\n        // Prepare a many-row bulk action, and check filtering is as expected.\n        const action3: typeof action1 = [\n          actType,\n          \"Table1\",\n          [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],\n          {\n            a: [\"a\", \"a\", \"a\", \"a\", \"x\", \"x\", \"x\", \"x\", \"A\", \"A\", \"A\", \"A\"],\n            b: [\"b\", \"b\", \"x\", \"x\", \"b\", \"b\", \"x\", \"x\", \"B\", \"B\", \"x\", \"x\"],\n            c: [\"c\", \"x\", \"c\", \"x\", \"c\", \"x\", \"c\", \"x\", \"C\", \"x\", \"C\", \"x\"],\n          },\n        ];\n        assert.deepEqual(filterColValues(cloneDeep(action3), idx => ![0, 8].includes(idx), xRemove),\n          [[actType, \"Table1\", [1, 9], { a: [\"a\", \"A\"], b: [\"b\", \"B\"], c: [\"c\", \"C\"] }],\n            [actType, \"Table1\", [8], {}],\n            [actType, \"Table1\", [4, 12], { a: [\"a\", \"A\"] }],\n            [actType, \"Table1\", [2, 10], { a: [\"a\", \"A\"], b: [\"b\", \"B\"] }],\n            [actType, \"Table1\", [3, 11], { a: [\"a\", \"A\"], c: [\"c\", \"C\"] }],\n            [actType, \"Table1\", [6], { b: [\"b\"] }],\n            [actType, \"Table1\", [5], { b: [\"b\"], c: [\"c\"] }],\n            [actType, \"Table1\", [7], { c: [\"c\"] }]]);\n      });\n    }\n\n    for (const actType of [\"UpdateRecord\", \"AddRecord\"] as const) {\n      it(`should remove correct elements for ${actType}`, function() {\n        const action1: UpdateRecord | AddRecord = [\n          actType,\n          \"Table1\",\n          1,\n          {\n            a: \"x\", b: \"b\", c: \"x\",\n          },\n        ];\n        assert.deepEqual(filterColValues(cloneDeep(action1), idx => idx === 0, xRemove),\n          [[actType, \"Table1\", 1, { b: \"b\" }]]);\n        // shouldFilterRow is somewhat arbitrarily ignored for non-bulk changes.\n        assert.deepEqual(filterColValues(cloneDeep(action1), idx => idx === 99, xRemove),\n          [[actType, \"Table1\", 1, { b: \"b\" }]]);\n      });\n    }\n\n    it(\"should not remove anything for BulkRemoveRecord\", function() {\n      const action1: BulkRemoveRecord = [\"BulkRemoveRecord\", \"Table1\", [1, 2, 3]];\n      assert.deepEqual(filterColValues(cloneDeep(action1), idx => idx === 0, xRemove), [action1]);\n    });\n\n    it(\"should not remove anything for RemoveRecord\", function() {\n      const action1: RemoveRecord = [\"RemoveRecord\", \"Table1\", 1];\n      assert.deepEqual(filterColValues(cloneDeep(action1), idx => idx === 0, xRemove), [action1]);\n    });\n  });\n\n  it(\"respects exceptional sessions for reading\", async function() {\n    const activeDoc = await docTools.createDoc(\"test-doc\");\n    // Make an exceptional session with full unconditional access.\n    const systemSession = makeExceptionalDocSession(\"system\");\n    // Make a fake regular session with access-rule-dependent access.\n    const userSession = docSessionFromRequest({\n      docAuth: { access: \"viewers\" },\n      userId: 1,\n      fullUser: { id: 1, email: \"someone@getgrist.com\", name: \"\" },\n      get: () => undefined,\n    } as any);\n    // Deny everyone access to Table1, and a column and row of Table2.\n    await activeDoc.applyUserActions(systemSession, [\n      [\"AddTable\", \"Table1\", [{ id: \"A\" }, { id: \"B\" }]],\n      [\"AddRecord\", \"Table1\", null, { A: 2021, B: \"kangaroo\" }],\n      [\"AddTable\", \"Table2\", [{ id: \"A\" }, { id: \"B\" }]],\n      [\"AddRecord\", \"Table2\", null, { A: 2022, B: \"wallaby\" }],\n      [\"AddRecord\", \"Table2\", null, { A: -1, B: \"koala\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Table2\", colIds: \"B\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Table2\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"True\", permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"True\", permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -3, aclFormula: \"rec.A < 0\", permissionsText: \"none\",\n      }],\n    ]);\n    // Check that exceptional session has full access to Table1 anyway.\n    assert.deepEqual((await activeDoc.fetchTable(systemSession, \"Table1\")).tableData,\n      [\"TableData\", \"Table1\", [1],\n        { manualSort: [1], A: [\"2021\"], B: [\"kangaroo\"] }]);\n    // Check that regular session does not have access to Table1.\n    await assert.isRejected(activeDoc.fetchTable(userSession, \"Table1\"),\n      /Blocked by table read access rules/);\n    // Check that exceptional session has full access to Table2 anyway.\n    assert.deepEqual((await activeDoc.fetchTable(systemSession, \"Table2\")).tableData,\n      [\"TableData\", \"Table2\", [1, 2],\n        { manualSort: [1, 2], A: [\"2022\", \"-1\"], B: [\"wallaby\", \"koala\"] }]);\n    // Check that regular session does not have full access to Table2.\n    assert.deepEqual((await activeDoc.fetchTable(userSession, \"Table2\")).tableData,\n      [\"TableData\", \"Table2\", [1],\n        { manualSort: [1], A: [\"2022\"] }]);\n  });\n\n  for (const flags of [\"-R\", \"-RS\"]) {\n    it(`can receive metadata updates even if there is a default ${flags} rule`, async function() {\n      await freshDoc();\n      // Make a document with a default rule forbidding editor from reading anything.\n      await owner.applyUserActions(docId, [\n        [\"AddTable\", \"Private\", [{ id: \"A\" }]],\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: `user.Access != OWNER`, permissionsText: flags,\n        }],\n      ]);\n\n      // Add an extra table, and capture the update sent to the editor.\n      cliEditor.flush();\n      await owner.applyUserActions(docId, [\n        [\"AddTable\", \"Private2\", [{ id: \"A\" }]],\n      ]);\n      const msg = await cliEditor.readMessage();\n\n      // Make sure we saw something.\n      assert.isAbove(msg?.data?.docActions.length, 10);\n      // Make sure everything we saw was metadata, and the Private2 AddTable\n      // action itself did not slip through.\n      assert.equal((msg?.data?.docActions as DocAction[])\n        .every(a => a[1].startsWith(\"_grist\")), true);\n    });\n  }\n\n  it('can enumerate and use \"View As\" users', async function() {\n    await freshDoc();\n\n    // Check that \"View As\" users cover users the document is shared with, and\n    // example users.\n    cliOwner.flush();\n    let perm: PermissionDataWithExtraUsers = (await cliOwner.send(\"getUsersForViewAs\", 0)).data;\n    const getId = (name: string) => home.dbManager.testGetId(name) as Promise<number>;\n    const getRef = (email: string) => home.dbManager.getUserByLogin(email).then(user => user.ref);\n    assert.deepEqual(perm.users, [\n      { id: await getId(\"Chimpy\"), email: \"chimpy@getgrist.com\", name: \"Chimpy\",\n        ref: await getRef(\"chimpy@getgrist.com\"),\n        picture: null, access: \"owners\", isMember: true, disabledAt: null },\n      { id: await getId(\"Kiwi\"), email: \"kiwi@getgrist.com\", name: \"Kiwi\",\n        ref: await getRef(\"kiwi@getgrist.com\"),\n        picture: null, access: \"owners\", isMember: false, disabledAt: null },\n      { id: await getId(\"Charon\"), email: \"charon@getgrist.com\", name: \"Charon\",\n        ref: await getRef(\"charon@getgrist.com\"),\n        picture: null, access: \"editors\", isMember: false, disabledAt: null },\n    ]);\n    assert.deepEqual(perm.attributeTableUsers, []);\n    assert.deepEqual(perm.exampleUsers[0],\n      { id: 0, email: \"owner@example.com\", name: \"Owner\", access: \"owners\" });\n\n    // Add a user attribute table mentioning some users the doc is shared with and\n    // some novel users.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Leads\", [{ id: \"Name\" }, { id: \"Place\" }]],\n      [\"AddRecord\", \"Leads\", null, { Name: \"Yi Wen\", Place: \"Seattle\" }],\n      [\"AddRecord\", \"Leads\", null, { Name: \"Zeng Hua\", Place: \"Boston\" }],\n      [\"AddRecord\", \"Leads\", null, { Name: \"Tao Ping\", Place: \"Cambridge\" }],\n      [\"AddTable\", \"Zones\", [{ id: \"Email\" }, { id: \"City\" }]],\n      [\"AddRecord\", \"Zones\", null, { Email: \"chimpy@getgrist.com\", City: \"Seattle\" }],\n      [\"AddRecord\", \"Zones\", null, { Email: \"charon@getgrist.com\", City: \"Boston\" }],\n      [\"AddRecord\", \"Zones\", null, { Email: \"fast@speed.com\", City: \"Cambridge\" }],\n      [\"AddRecord\", \"Zones\", null, { Email: \"slow@speed.com\", City: \"Springfield\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Leads\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, userAttributes: JSON.stringify({\n          name: \"Zone\",\n          tableId: \"Zones\",\n          charId: \"Email\",\n          lookupColId: \"Email\",\n        }),\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"user.Zone.City and user.Zone.City != rec.Place\", permissionsText: \"none\",\n      }],\n    ]);\n\n    // Check that \"View As\" users now in addition have the novel user attribute table\n    // users.\n    cliOwner.flush();\n    perm = (await cliOwner.send(\"getUsersForViewAs\", 0)).data;\n    assert.deepEqual(perm.users, [\n      { id: await getId(\"Chimpy\"), email: \"chimpy@getgrist.com\", name: \"Chimpy\",\n        ref: await getRef(\"chimpy@getgrist.com\"),\n        picture: null, access: \"owners\", isMember: true, disabledAt: null },\n      { id: await getId(\"Kiwi\"), email: \"kiwi@getgrist.com\", name: \"Kiwi\",\n        ref: await getRef(\"kiwi@getgrist.com\"),\n        picture: null, access: \"owners\", isMember: false, disabledAt: null },\n      { id: await getId(\"Charon\"), email: \"charon@getgrist.com\", name: \"Charon\",\n        ref: await getRef(\"charon@getgrist.com\"),\n        picture: null, access: \"editors\", isMember: false, disabledAt: null },\n    ]);\n    assert.deepEqual(perm.attributeTableUsers, [\n      { id: 0, email: \"fast@speed.com\", name: \"fast\", access: \"editors\" },\n      { id: 0, email: \"slow@speed.com\", name: \"slow\", access: \"editors\" },\n    ]);\n    assert.deepEqual(perm.exampleUsers[0],\n      { id: 0, email: \"owner@example.com\", name: \"Owner\", access: \"owners\" });\n\n    // Add a second user attribute table, this time also with names and access levels.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Users\", [{ id: \"Email2\" }, { id: \"Name\" }, { id: \"Access\" }]],\n      [\"AddRecord\", \"Users\", null, { Email2: \"red@color.com\", Name: \"Rita\", Access: \"owners\" }],\n      [\"AddRecord\", \"Users\", null, { Email2: \"green@color.com\", Name: \"Gary\", Access: \"editors\" }],\n      [\"AddRecord\", \"Users\", null, { Email2: \"blue@color.com\", Name: \"Beatrix\", Access: \"viewers\" }],\n      [\"AddRecord\", \"Users\", null, { Email2: \"yellow@color.com\", Name: \"Yan\", Access: null }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: 2, userAttributes: JSON.stringify({\n          name: \"More\",\n          tableId: \"Users\",\n          charId: \"Email\",\n          lookupColId: \"Email2\",\n        }),\n      }],\n    ]);\n\n    // Check the new users get added as \"View As\" options.\n    cliOwner.flush();\n    perm = (await cliOwner.send(\"getUsersForViewAs\", 0)).data;\n    assert.deepEqual(perm.attributeTableUsers, [\n      { id: 0, email: \"fast@speed.com\", name: \"fast\", access: \"editors\" },\n      { id: 0, email: \"slow@speed.com\", name: \"slow\", access: \"editors\" },\n      { id: 0, email: \"red@color.com\", name: \"Rita\", access: \"owners\" },\n      { id: 0, email: \"green@color.com\", name: \"Gary\", access: \"editors\" },\n      { id: 0, email: \"blue@color.com\", name: \"Beatrix\", access: \"viewers\" },\n      { id: 0, email: \"yellow@color.com\", name: \"Yan\", access: null },\n    ]);\n\n    // Check that doing a \"View As\" as a user from the first user attribute table works\n    // as expected (the user is an editor, and has the expected user attributes in rules).\n    await reopenClients({ linkParameters: { aclAsUser: \"fast@speed.com\" } });\n    cliOwner.flush();\n    assert.deepEqual((await cliOwner.send(\"fetchTable\", 0, \"Leads\")).data.tableData,\n      [\"TableData\", \"Leads\", [3],\n        { manualSort: [3], Place: [\"Cambridge\"], Name: [\"Tao Ping\"] }]);\n    let res = await cliOwner.send(\"applyUserActions\", 0, [\n      [\"UpdateRecord\", \"Leads\", 3, { Name: \"Tao\" }],\n    ]);\n    assert.hasAllKeys(res.data, [\n      \"actionNum\",\n      \"actionHash\",\n      \"retValues\",\n      \"isModification\",\n    ]);\n    assert.deepEqual(\n      pick(res.data, \"actionNum\", \"retValues\", \"isModification\"),\n      {\n        actionNum: 4,\n        retValues: [null],\n        isModification: true,\n      },\n    );\n    assert.match((await cliOwner.send(\"applyUserActions\", 0,\n      [[\"UpdateRecord\", \"Leads\", 2, { Name: \"Zao\" }]])).error!,\n    /Blocked by row update access rules/);\n\n    // Check that doing a \"View As\" as a user from the second user attribute table works\n    // as expected (the user has the specified access level, \"viewers\" in this case).\n    await reopenClients({ linkParameters: { aclAsUser: \"blue@color.com\" } });\n    cliOwner.flush();\n    assert.deepEqual((await cliOwner.send(\"fetchTable\", 0, \"Leads\")).data.tableData,\n      [\"TableData\", \"Leads\", [1, 2, 3],\n        { manualSort: [1, 2, 3],\n          Place: [\"Seattle\", \"Boston\", \"Cambridge\"],\n          Name: [\"Yi Wen\", \"Zeng Hua\", \"Tao\"] }]);\n    assert.match((await cliOwner.send(\"applyUserActions\", 0,\n      [[\"UpdateRecord\", \"Leads\", 2, { Name: \"Zao\" }]])).error!,\n    /Blocked by table update access rules/);\n\n    // Check that doing a \"View As\" as a dummy user works as expected.\n    await reopenClients({ linkParameters: { aclAsUser: \"viewer@example.com\" } });\n    cliOwner.flush();\n    assert.match((await cliOwner.send(\"applyUserActions\", 0,\n      [[\"UpdateRecord\", \"Leads\", 2, { Name: \"Zao\" }]])).error!,\n    /Blocked by table update access rules/);\n    await reopenClients({ linkParameters: { aclAsUser: \"owner@example.com\" } });\n    cliOwner.flush();\n    res = await cliOwner.send(\"applyUserActions\", 0, [\n      [\"UpdateRecord\", \"Leads\", 2, { Name: \"Zao\" }],\n    ]);\n    assert.hasAllKeys(res.data, [\n      \"actionNum\",\n      \"actionHash\",\n      \"retValues\",\n      \"isModification\",\n    ]);\n    assert.deepEqual(\n      pick(res.data, \"actionNum\", \"retValues\", \"isModification\"),\n      {\n        actionNum: 5,\n        retValues: [null],\n        isModification: true,\n      },\n    );\n    await reopenClients({ linkParameters: { aclAsUser: \"unknown@example.com\" } });\n    cliOwner.flush();\n    assert.match((await cliOwner.send(\"applyUserActions\", 0,\n      [[\"UpdateRecord\", \"Leads\", 2, { Name: \"Gao\" }]])).error!,\n    /Blocked by table update access rules/);\n    assert.match((await cliOwner.send(\"fetchTable\", 0, \"Leads\")).error!,\n      /Blocked by table read access rules/);\n\n    // Check that doing a \"View As\" a user the doc is shared with works as expected.\n    await reopenClients({ linkParameters: { aclAsUser: \"charon@getgrist.com\" } });\n    cliOwner.flush();\n    assert.deepEqual((await cliOwner.send(\"fetchTable\", 0, \"Leads\")).data.tableData,\n      [\"TableData\", \"Leads\", [2],\n        { manualSort: [2], Place: [\"Boston\"], Name: [\"Zao\"] }]);\n\n    // Check that doing a \"View As\" an unknown user works reasonably\n    await reopenClients({ linkParameters: { aclAsUser: \"mystery@getgrist.com\" } });\n    cliOwner.flush();\n    assert.match((await cliOwner.send(\"fetchTable\", 0, \"Leads\")).error!,\n      /Blocked by table read access rules/);\n  });\n\n  it(\"asks for reload when user attribute table changes significantly\", async function() {\n    await freshDoc();\n\n    // Add a user attribute table.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Leads\", [{ id: \"Name\" }, { id: \"Place\" }]],\n      [\"AddRecord\", \"Leads\", null, { Name: \"Yi Wen\", Place: \"Seattle\" }],\n      [\"AddRecord\", \"Leads\", null, { Name: \"Zeng Hua\", Place: \"Boston\" }],\n      [\"AddRecord\", \"Leads\", null, { Name: \"Tao Ping\", Place: \"Cambridge\" }],\n      [\"AddTable\", \"Zones\", [{ id: \"Email\" }, { id: \"City\" }]],\n      [\"AddRecord\", \"Zones\", null, { Email: \"chimpy@getgrist.com\", City: \"Seattle\" }],\n      [\"AddRecord\", \"Zones\", null, { Email: \"charon@getgrist.com\", City: \"Boston\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Leads\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, userAttributes: JSON.stringify({\n          name: \"Zone\",\n          tableId: \"Zones\",\n          charId: \"Email\",\n          lookupColId: \"Email\",\n        }),\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"user.Zone.City and user.Zone.City != rec.Place\", permissionsText: \"none\",\n      }],\n    ]);\n\n    // Check owner (chimpy) and editor (charon) see expected data.\n    assert.deepEqual(await owner.getDocAPI(docId).getRecords(\"Leads\"),\n      [{ id: 1, fields: { Name: \"Yi Wen\", Place: \"Seattle\" } }]);\n\n    assert.deepEqual(await editor.getDocAPI(docId).getRecords(\"Leads\"),\n      [{ id: 2, fields: { Name: \"Zeng Hua\", Place: \"Boston\" } }]);\n\n    // We'll be checking message types. Ignore somewhat unpredictable\n    // user presence messages which are not relevant to this test.\n    cliOwner.ignorePresenceUpdates();\n    cliEditor.ignorePresenceUpdates();\n\n    // Open fresh clients, then change owner's access via user attribute table.\n    await reopenClients();\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Zones\", 1, { City: \"Boston\" }],\n    ]);\n    // Owner should see a shutdown request, editor just a change.\n    assert.equal((await cliOwner.read()).type, \"docShutdown\");\n    assert.equal((await cliEditor.read()).type, \"docUserAction\");\n    assert.deepEqual(await owner.getDocAPI(docId).getRecords(\"Leads\"),\n      [{ id: 2, fields: { Name: \"Zeng Hua\", Place: \"Boston\" } }]);\n\n    // Start over, this time changing editor's access.\n    await reopenClients();\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"Zones\", 2, { City: \"Seattle\" }],\n    ]);\n    assert.equal((await cliOwner.read()).type, \"docUserAction\");\n    assert.equal((await cliEditor.read()).type, \"docShutdown\");\n    assert.deepEqual(await editor.getDocAPI(docId).getRecords(\"Leads\"),\n      [{ id: 1, fields: { Name: \"Yi Wen\", Place: \"Seattle\" } }]);\n\n    // Start over, this time making a neutral change (adding a column).\n    await reopenClients();\n    await owner.applyUserActions(docId, [\n      [\"AddColumn\", \"Zones\", \"NewCol\", { type: \"Int\" }],\n    ]);\n    assert.equal((await cliOwner.read()).type, \"docUserAction\");\n    assert.equal((await cliEditor.read()).type, \"docUserAction\");\n  });\n\n  it(\"controls read and write access to attachment content\", async function() {\n    await freshDoc();\n\n    // Make a table, with attachments, and with non-owners missing access to a row.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\" },\n        { id: \"B\" },\n        { id: \"Texts\", type: \"Attachments\" },\n        { id: \"Public\", isFormula: true, formula: '$B == \"clear\"' }]],\n      [\"AddRecord\", \"Data1\", null, { A: \"near\", B: \"clear\" }],\n      [\"AddRecord\", \"Data1\", null, { A: \"far\", B: \"notclear\" }],\n      [\"AddRecord\", \"Data1\", null, { A: \"in a motor car\", B: \"clear\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER and not rec.Public\", permissionsText: \"none\",\n      }],\n    ]);\n\n    // Add some attachments.\n    const i1 = await owner.getDocAPI(docId).uploadAttachment(\"content1\", \"1.txt\");\n    const i2 = await owner.getDocAPI(docId).uploadAttachment(\"content2\", \"2.txt\");\n    const i3 = await owner.getDocAPI(docId).uploadAttachment(\"content3\", \"3.txt\");\n    const i4 = await owner.getDocAPI(docId).uploadAttachment(\"content4\", \"4.txt\");\n    await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [1], Texts: [[GristObjCode.List, i1, i2]] });\n    await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [2], Texts: [[GristObjCode.List, i3]] });\n    await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [3], Texts: [[GristObjCode.List, i4]] });\n\n    // Share the document with everyone as an editor.\n    await owner.updateDocPermissions(docId, { users: { \"everyone@getgrist.com\": \"editors\" } });\n\n    // Check an editor can only access the attachments we expect.\n    assert.equal(await getAttachment(editor, docId, i1), \"content1\");\n    assert.equal(await getAttachment(editor, docId, i2), \"content2\");\n    await assert.isRejected(getAttachment(editor, docId, i3), /403.*Cannot access attachment/);\n    assert.equal(await getAttachment(editor, docId, i4), \"content4\");\n\n    // Add another table with an attachment column, leaving access open.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data2\", [{ id: \"MoreTexts\", type: \"Attachments\" },\n        { id: \"Unrelated\", type: \"RefList:Data2\" }]],\n      [\"AddRecord\", \"Data2\", null, {}],\n    ]);\n\n    // Check that user can't gain access to an attachment by writing its id into a cell.\n    await assert.isRejected(getAttachment(editor, docId, i3), /403.*Cannot access attachment/);\n    await assert.isRejected(editor.getDocAPI(docId).updateRows(\n      \"Data2\",\n      { id: [1], MoreTexts: [[GristObjCode.List, i3]] },\n    ), /403.*Cannot access attachment/);\n    // Don't allow even sticking in an id in an unexpected format.\n    await assert.isRejected(editor.getDocAPI(docId).updateRows(\n      \"Data2\",\n      { id: [1], MoreTexts: [i3] },\n    ), /403.*Cannot access attachment/);\n    await assert.isRejected(editor.getDocAPI(docId).updateRows(\n      \"Data2\",\n      { id: [1], MoreTexts: [[GristObjCode.List, i2, i3]] },\n    ), /403.*Cannot access attachment/);\n    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\n      \"Data2\",\n      { id: [1], MoreTexts: [[GristObjCode.List, i2]] },\n    ));\n\n    // Check no confusion between columns.\n    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\n      \"Data2\",\n      { id: [1], MoreTexts: [[GristObjCode.List, i1]], Unrelated: [[GristObjCode.List, i3]] },\n    ));\n    await assert.isRejected(editor.getDocAPI(docId).updateRows(\n      \"Data2\",\n      { id: [1], MoreTexts: [[GristObjCode.List, i3]], Unrelated: [[GristObjCode.List, i2]] },\n    ), /403.*Cannot access attachment/);\n\n    // Check that user can add attachments they just uploaded.\n    const i5 = await editor.getDocAPI(docId).uploadAttachment(\"content5\", \"5.txt\");\n    await assert.isFulfilled(editor.getDocAPI(docId).updateRows(\n      \"Data2\",\n      { id: [1], MoreTexts: [[GristObjCode.List, i5]] },\n    ));\n\n    // Check that non-owner cannot add attachments uploaded by someone else.\n    const i6 = await owner.getDocAPI(docId).uploadAttachment(\"content6\", \"6.txt\");\n    await assert.isRejected(editor.getDocAPI(docId).updateRows(\n      \"Data2\",\n      { id: [1], MoreTexts: [[GristObjCode.List, i6]] },\n    ), /403.*Cannot access attachment/);\n\n    // Attachment check is not applied for undos of actions by the same user.\n    const ownerProfile = await owner.getUserProfile();\n    const editorProfile = await editor.getUserProfile();\n    const ownerInfo = {\n      user: ownerProfile.email,\n      time: Date.now(),\n    };\n    const editorInfo = {\n      user: editorProfile.email,\n      time: Date.now(),\n    };\n    // Owner mismatch case.\n    assert.match((await applyAsUndo(cliEditor, [[\"UpdateRecord\", \"Data2\", 1, { MoreTexts: [GristObjCode.List, i6] }]],\n      ownerInfo))?.error || \"\",\n    /Cannot access attachment/);\n    // Old action case.\n    assert.match((await applyAsUndo(cliEditor, [[\"UpdateRecord\", \"Data2\", 1, { MoreTexts: [GristObjCode.List, i6] }]],\n      { ...editorInfo, time: editorInfo.time - 48 * 60 * 60 * 1000 }))?.error || \"\",\n    /Cannot access attachment/);\n    // Good case.\n    assert.equal((await applyAsUndo(cliEditor, [[\"UpdateRecord\", \"Data2\", 1, { MoreTexts: [GristObjCode.List, i6] }]],\n      editorInfo))?.error || \"\", \"\");\n\n    // Check that adding an attachment to a cell a user has access to\n    // will grant them access to the attachment's contents.\n    await assert.isRejected(getAttachment(editor, docId, i3), /403.*Cannot access attachment/);\n    await owner.getDocAPI(docId).updateRows(\"Data2\", { id: [1], MoreTexts: [[GristObjCode.List, i3]] });\n    assert.equal(await getAttachment(editor, docId, i3), \"content3\");\n  });\n\n  it(\"can add attachments when there are row-level rules\", async function() {\n    await freshDoc();\n\n    // Make a table, with attachments, and with row-level edit rights.\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\" },\n        { id: \"Texts\", type: \"Attachments\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: \"edit\" }],\n      [\"AddRecord\", \"Data1\", null, { A: \"read\" }],\n      [\"AddRecord\", \"Data1\", null, { A: \"\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Data1\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: \"user.Access == OWNER\", permissionsText: \"+RUCD\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: '$A == \"edit\"', permissionsText: \"+RUCD\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2, aclFormula: '$A == \"read\"', permissionsText: \"+R-UCD\",\n      }],\n    ]);\n\n    // Share the document with everyone as an editor.\n    await owner.updateDocPermissions(docId, { users: { \"everyone@getgrist.com\": \"editors\" } });\n\n    let attachments = cliOwner.getMetaRecords(\"_grist_Attachments\");\n    assert.lengthOf(attachments, 0);\n\n    // Add an attachment as an owner.\n    const i1 = await owner.getDocAPI(docId).uploadAttachment(\"content1\", \"1.txt\");\n    await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [1],\n      Texts: [[GristObjCode.List, i1]] });\n\n    await cliOwner.waitForServer();\n    await cliEditor.waitForServer();\n    attachments = cliOwner.getMetaRecords(\"_grist_Attachments\");\n    assert.lengthOf(attachments, 1);\n    attachments = cliEditor.getMetaRecords(\"_grist_Attachments\");\n    assert.lengthOf(attachments, 1);  // record is visible to everyone because A is 'edit'\n\n    // Check an editor can add an attachment on an allowed row. Check that\n    // when doing so, the editor receives attachment metadata along with the\n    // attachment cell change.\n    cliEditor.flush();\n    cliOwner.flush();\n    const i2 = await editor.getDocAPI(docId).uploadAttachment(\"content2\", \"2.txt\");\n    // Owner should see attachment info already (no filtering for them).\n    let msg = await cliOwner.readMessage();\n    let gristAttachmentAction: any[] = msg.data.docActions[0];\n    assert.deepEqual(gristAttachmentAction.slice(0, 3),\n      [\"AddRecord\", \"_grist_Attachments\", 2]);\n    await cliOwner.waitForServer();\n    await cliEditor.waitForServer();\n    attachments = cliOwner.getMetaRecords(\"_grist_Attachments\");\n    assert.lengthOf(attachments, 2);\n    // Editor should not (need to wait until attachment is \"in\" a cell they can access).\n    attachments = cliEditor.getMetaRecords(\"_grist_Attachments\");\n    assert.lengthOf(attachments, 1);\n\n    cliEditor.flush();\n    // Add the attachment in a cell. Editor should receive metadata at this point.\n    await editor.getDocAPI(docId).updateRows(\"Data1\", { id: [1], Texts: [[GristObjCode.List, i1, i2]] });\n    msg = await cliEditor.readMessage();\n    gristAttachmentAction = msg.data.docActions[0];\n    const gristCellAction: any[] = msg.data.docActions[1];\n    assert.deepEqual(gristAttachmentAction.slice(0, 3),\n      [\"BulkAddRecord\", \"_grist_Attachments\", [1, 2]]);\n    assert.deepEqual(gristCellAction.slice(0, 3),\n      [\"UpdateRecord\", \"Data1\", 1]);\n    await cliEditor.waitForServer();\n    attachments = cliEditor.getMetaRecords(\"_grist_Attachments\");\n    assert.lengthOf(attachments, 2);\n\n    // Check an editor cannot add an attachment on a forbidden row.\n    await assert.isRejected(editor.getDocAPI(docId).updateRows(\"Data1\", { id: [2], Texts: [[GristObjCode.List, i2]] }),\n      /Blocked by row update access rules/);\n\n    // Check if an attachment is added to a cell the editor cannot read, they aren't\n    // told about it.\n    const i3 = await owner.getDocAPI(docId).uploadAttachment(\"content3\", \"3.txt\");\n    await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [3], Texts: [[GristObjCode.List, i3]] });\n    await cliOwner.waitForServer();\n    await cliEditor.waitForServer();\n    attachments = cliOwner.getMetaRecords(\"_grist_Attachments\");\n    assert.lengthOf(attachments, 3);\n    attachments = cliEditor.getMetaRecords(\"_grist_Attachments\");\n    assert.lengthOf(attachments, 2);\n    // Now tell them.\n    await owner.getDocAPI(docId).updateRows(\"Data1\", { id: [3], A: [\"read\"] });\n    msg = await cliEditor.readMessage();\n    gristAttachmentAction = msg.data.docActions[0];\n    assert.deepEqual(gristAttachmentAction.slice(0, 3),\n      [\"BulkAddRecord\", \"_grist_Attachments\", [3]]);\n    await cliEditor.waitForServer();\n    attachments = cliEditor.getMetaRecords(\"_grist_Attachments\");\n    assert.lengthOf(attachments, 3);\n  });\n\n  it(\"has access to user reference variable\", async function() {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data\", [{ id: \"A\" }]],\n    ]);\n\n    // Test that ACL rules works as usual.\n    await assert.isFulfilled(owner.applyUserActions(docId, [[\"AddRecord\", \"Data\", null, {}]]));\n    await assert.isFulfilled(editor.applyUserActions(docId, [[\"AddRecord\", \"Data\", null, {}]]));\n    // Add anonymous user as an editor.\n    await owner.updateDocPermissions(docId, { users: { \"anon@getgrist.com\": \"editors\" } });\n    const anonym = await openClient(home.server, \"anon@getgrist.com\", \"testy\");\n    anonym.ignoreTrivialActions();\n    await anonym.openDocOnConnect(docId);\n    try {\n      // Make sure he add record too\n      let result = await anonym.send(\"applyUserActions\", 0, [[\"AddRecord\", \"Data\", null, {}]]);\n      assert.isUndefined(result.errorCode);\n      // Now make rule, that he can't using UserRef attribute.\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.UserRef is None\", permissionsText: \"none\",\n        }],\n      ]);\n      // Test that ACL rules works as usual for logged in user.\n      await assert.isFulfilled(owner.applyUserActions(docId, [[\"AddRecord\", \"Data\", null, {}]]));\n      await assert.isFulfilled(editor.applyUserActions(docId, [[\"AddRecord\", \"Data\", null, {}]]));\n      // Test our new rule based on UserRef attribute.\n      result = await anonym.send(\"applyUserActions\", 0, [[\"AddRecord\", \"Data\", null, {}]]);\n      assert.equal(result.errorCode, \"ACL_DENY\");\n    } finally {\n      anonym.flush();\n      await closeClient(anonym);\n    }\n  });\n\n  it(\"cannot modify _grist_Attachments directly when granular access applies\", async function() {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"Texts\", type: \"Attachments\" }]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      // Add a dummy rule that doesn't change anything, just to make sure that\n      // granular access rules are processed.\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access in [OWNER]\", permissionsText: \"all\",\n      }],\n    ]);\n\n    // Add an attachment through regular mechanism.\n    const i1 = await owner.getDocAPI(docId).uploadAttachment(\"content1\", \"1.txt\");\n    await owner.getDocAPI(docId).addRows(\"Data1\", { Texts: [[GristObjCode.List, i1]] });\n\n    // Try to modify _grist_Attachments by shady means.\n    await assert.isRejected(owner.getDocAPI(docId).addRows(\"_grist_Attachments\", { fileName: [\"A\", \"B\"] }),\n      /_grist_Attachments modification is not allowed/);\n    await assert.isRejected(owner.getDocAPI(docId).updateRows(\"_grist_Attachments\", { id: [1], fileName: [\"A\"] }),\n      /_grist_Attachments modification is not allowed/);\n    await assert.isRejected(owner.getDocAPI(docId).removeRows(\"_grist_Attachments\", [1]),\n      /_grist_Attachments modification is not allowed/);\n  });\n\n  describe(\"shares\", function() {\n    it(\"can give table access for a form\", async function() {\n      await freshDoc();\n\n      // Publish an empty share.\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_Shares\", null, {\n          linkId: \"x\",\n          options: '{\"publish\": true}',\n        }],\n      ]);\n\n      // Check it reached the home db.\n      let shares = await home.dbManager.connection.query(\"select * from shares\");\n      assert.lengthOf(shares, 1);\n      assert.equal(shares[0].link_id, \"x\");\n      assert.deepEqual(JSON.parse(shares[0].options),\n        { publish: true });\n      assert.isAtLeast(shares[0].key.length, 12);\n\n      // Check that user data is not yet available via the share.\n      const ham = await home.createHomeApi(\"ham\", \"docs\", true);\n      const hamShare = ham.getDocAPI(await getShareKeyForUrl(\"x\"));\n      await assert.isRejected(hamShare.getRows(\"Table1\"), /Forbidden/);\n\n      // Check that metadata is available but censored.\n      let tables = await hamShare.getRows(\"_grist_Tables\");\n      assert.lengthOf(tables.id, 1);\n      assert.equal(tables.tableId[0], \"\");\n\n      // Form-share a section.\n      await owner.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_Views_section\", 1,\n          { shareOptions: '{\"publish\": true, \"form\": true}' }],\n        [\"UpdateRecord\", \"_grist_Pages\", 1, { shareRef: 1 }],\n        [\"AddRecord\", \"Table1\", null, { A: 1, B: 1, C: 1 }],\n      ]);\n\n      // Check the appropriate table is now available.\n      tables = await hamShare.getRows(\"_grist_Tables\");\n      assert.lengthOf(tables.id, 1);\n      assert.equal(tables.tableId[0], \"Table1\");\n\n      // Check an empty read is possible. This is a\n      // convenience rather than a necessity.\n      assert.deepEqual(\n        await hamShare.getRows(\"Table1\"),\n        { id: [], manualSort: [], A: [], C: [], B: [] },\n      );\n\n      // Owner sees all rows.\n      assert.deepEqual(\n        await owner.getDocAPI(docId).getRows(\"Table1\"),\n        { id: [1], manualSort: [1], A: [1], C: [1], B: [1] },\n      );\n\n      // Creating a row should be allowed.\n      await hamShare.addRows(\"Table1\", { A: [99] });\n\n      // Still don't see anything.\n      assert.deepEqual(\n        await hamShare.getRows(\"Table1\"),\n        { id: [], manualSort: [], A: [], C: [], B: [] },\n      );\n\n      // Confirm row is actually there.\n      assert.deepEqual(\n        await owner.getDocAPI(docId).getRows(\"Table1\"),\n        { id: [1, 2], manualSort: [1, 2], A: [1, 99], C: [1, 0], B: [1, 0] },\n      );\n\n      // Updates not allowed.\n      await assert.isRejected(hamShare.updateRows(\"Table1\", { id: [2], A: [100] }), /Forbidden/);\n\n      // Removals not allowed.\n      await assert.isRejected(hamShare.removeRows(\"Table1\", [2]), /Forbidden/);\n\n      // Check both operations work when you have rights.\n      await owner.getDocAPI(docId).updateRows(\"Table1\", { id: [2], A: [100] });\n      await owner.getDocAPI(docId).removeRows(\"Table1\", [2]);\n\n      // Modify shares options in doc, and see that they propagate.\n      await owner.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_Shares\", 1, {\n          options: '{\"publish\": true, \"test\": true}',\n        }],\n      ]);\n      shares = await home.dbManager.connection.query(\"select * from shares\");\n      assert.lengthOf(shares, 1);\n      assert.deepEqual(JSON.parse(shares[0].options),\n        { publish: true, test: true });\n\n      // Unpublish at share level, and make sure data access\n      // is now forbidden.\n      await owner.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_Shares\", 1, {\n          options: '{\"publish\": false}',\n        }],\n      ]);\n      await assert.isRejected(hamShare.getRows(\"Table1\"), /Forbidden/);\n      await assert.isRejected(hamShare.getRows(\"_grist_Tables\"), /Forbidden/);\n\n      await owner.applyUserActions(docId, [\n        [\"RemoveRecord\", \"_grist_Shares\", 1],\n      ]);\n      shares = await home.dbManager.connection.query(\"select * from shares\");\n      assert.lengthOf(shares, 0);\n    });\n\n    it(\"can give access to referenced columns for a form\", async function() {\n      // Use a fixture, since references with display columns are\n      // awkward to set up via the api\n      await freshDoc(\"FilmsWithImages.grist\");\n\n      // Publish an empty share.\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_Shares\", null, {\n          linkId: \"x\",\n          options: '{\"publish\": true}',\n        }],\n      ]);\n      await owner.applyUserActions(docId, [\n        // Turn on sharing on Friends widget on Friends page.\n        [\"UpdateRecord\", \"_grist_Views_section\", 7,\n          { shareOptions: '{\"publish\": true, \"form\": true}' }],\n        [\"UpdateRecord\", \"_grist_Pages\", 2, { shareRef: 1 }],\n        // Add some access rules too - there was a bug where references were\n        // null if a multi-column table rule was present.\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Films\", colIds: \"Title,Poster,PosterDup\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Films\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.access != OWNER\", permissionsText: \"-R\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"True\", permissionsText: \"all\",\n        }],\n      ]);\n\n      const ham = await home.createHomeApi(\"ham\", \"docs\", true);\n      const hamDoc = ham.getDocAPI(docId);\n      const hamShare = ham.getDocAPI(await getShareKeyForUrl(\"x\"));\n\n      // Friends looks empty.\n      assert.deepEqual(await hamShare.getRecords(\"Friends\"), []);\n      await assert.isRejected(hamDoc.getRecords(\"Friends\"), /Forbidden/);\n\n      // Films has just a title.\n      assert.deepEqual(await hamShare.getRecords(\"Films\"), [\n        { id: 1, fields: { Title: \"Toy Story\" } },\n        { id: 2, fields: { Title: \"Forrest Gump\" } },\n        { id: 3, fields: { Title: \"Alien\" } },\n        { id: 4, fields: { Title: \"Avatar\" } },\n        { id: 5, fields: { Title: \"The Dark Knight\" } },\n        { id: 6, fields: { Title: \"The Avengers\" } },\n      ]);\n      await assert.isRejected(hamDoc.getRecords(\"Films\"), /Forbidden/);\n\n      // Performance is not involved.\n      await assert.isRejected(hamShare.getRecords(\"Performances\"), /Forbidden/);\n      await assert.isRejected(hamDoc.getRecords(\"Performances\"), /Forbidden/);\n\n      // Find \"Favorite Film\" field on single section of \"Friends\" view.\n      const field = (await owner.getDocAPI(docId).sql(\n        \"select v.name, v.type, t.tableId, f.id, c.colId, s.title from _grist_Views_section_field as f\" +\n        \" left join _grist_Views_section s on s.id = f.parentId\" +\n        \" left join _grist_Tables_column c on c.id = f.colRef\" +\n        \" left join _grist_Tables t on t.id = c.parentId\" +\n        \" left join _grist_Views v on v.id = s.parentId\" +\n        \" where v.name = ? and c.colId = ? and s.title = ?\",\n        [\"Friends\", \"Favorite_Film\", \"\"],\n      )).records[0].fields;\n      assert.equal(field.colId, \"Favorite_Film\");\n\n      // Double check we can read film titles currently.\n      assert.deepEqual(await hamShare.getRecords(\"Films\"), [\n        { id: 1, fields: { Title: \"Toy Story\" } },\n        { id: 2, fields: { Title: \"Forrest Gump\" } },\n        { id: 3, fields: { Title: \"Alien\" } },\n        { id: 4, fields: { Title: \"Avatar\" } },\n        { id: 5, fields: { Title: \"The Dark Knight\" } },\n        { id: 6, fields: { Title: \"The Avengers\" } },\n      ]);\n      // Hide the field that refers to film titles.\n      await owner.applyUserActions(docId, [[\n        \"RemoveRecord\", \"_grist_Views_section_field\", field.id,\n      ]]);\n      // Check we can no longer read film titles in the share.\n      await assert.isRejected(hamShare.getRecords(\"Films\"), /Forbidden/);\n\n      await removeShares(docId, owner);\n    });\n\n    it(\"are separate from document access rules\", async function() {\n      await freshDoc(\"FilmsWithImages.grist\");\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_Shares\", null, {\n          linkId: \"x\",\n          options: '{\"publish\": true}',\n        }],\n      ]);\n      await owner.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_Views_section\", 7,\n          { shareOptions: '{\"publish\": true, \"form\": true}' }],\n        [\"UpdateRecord\", \"_grist_Pages\", 2, { shareRef: 1 }],\n      ]);\n      const ham = await home.createHomeApi(\"ham\", \"docs\", true);\n      const hamDoc = ham.getDocAPI(docId);\n      const hamShare = ham.getDocAPI(await getShareKeyForUrl(\"x\"));\n      const ownerDoc = owner.getDocAPI(docId);\n\n      // Check that neither share nor doc can update records.\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Films\", colIds: \"Title,Budget_millions\" }],\n        [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"*\", colIds: \"*\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"True\", permissionsText: \"+R\",\n        }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -2, aclFormula: \"True\", permissionsText: \"-U\",\n        }],\n      ]);\n      assert.deepEqual(await ownerDoc.getRows(\"_grist_ACLRules\"), {\n        id: [1, 2, 3],\n        aclColumn: [0, 0, 0],\n        resource: [1, 2, 3],\n        memo: [\"\", \"\", \"\"],\n        aclFormula: [\"\", \"True\", \"True\"],\n        userAttributes: [\"\", \"\", \"\"],\n        aclFormulaParsed: [\"\", '[\"Const\", true]', '[\"Const\", true]'],\n        permissionsText: [\"\", \"+R\", \"-U\"],\n        rulePos: [null, 1, 2],\n        principals: [\"[1]\", \"\", \"\"],\n        permissions: [63, 0, 0],\n      });\n      await assertDeniedFor(hamDoc.updateRows(\"Films\", { id: [1], Title: [\"Toy Story 2\"] }), [], /No view access/);\n      await assertDeniedFor(hamShare.updateRows(\"Films\", { id: [1], Title: [\"Toy Story 2\"] }), []);\n      await assertDeniedFor(ownerDoc.updateRows(\"Films\", { id: [1], Title: [\"Toy Story 2\"] }), [],\n        /Blocked by table update access rules/);\n\n      // Grant update permission to doc. Check that the share still can't update records.\n      await owner.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_ACLRules\", 3, { permissionsText: \"+U\" }],\n      ]);\n      assert.deepEqual(await ownerDoc.getRows(\"_grist_ACLRules\"), {\n        id: [1, 2, 3],\n        aclColumn: [0, 0, 0],\n        resource: [1, 2, 3],\n        memo: [\"\", \"\", \"\"],\n        aclFormula: [\"\", \"True\", \"True\"],\n        userAttributes: [\"\", \"\", \"\"],\n        aclFormulaParsed: [\"\", '[\"Const\", true]', '[\"Const\", true]'],\n        permissionsText: [\"\", \"+R\", \"+U\"],\n        rulePos: [null, 1, 2],\n        principals: [\"[1]\", \"\", \"\"],\n        permissions: [63, 0, 0],\n      });\n      await assert.isRejected(hamDoc.updateRows(\"Films\", { id: [1], Title: [\"Toy Story 2\"] }), /Forbidden/);\n      await assertDeniedFor(hamShare.updateRows(\"Films\", { id: [1], Title: [\"Toy Story 2\"] }), []);\n      await assert.isFulfilled(ownerDoc.updateRows(\"Films\", { id: [1], Title: [\"Toy Story 2\"] }));\n\n      await removeShares(docId, owner);\n    });\n\n    it(\"can give access to a pair of form-shared widgets on same page\", async function() {\n      await freshDoc(\"ManyRefs.grist\");\n\n      // Publish an empty share.\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_Shares\", null, {\n          linkId: \"manyref\",\n          options: '{\"publish\": true}',\n        }],\n      ]);\n      // viewsections 19 and 20, parent view 7, page 7.\n      await owner.applyUserActions(docId, [\n        // Turn on sharing on \"Dashboard\" page\n        [\"UpdateRecord\", \"_grist_Pages\", 7, { shareRef: 1 }],\n        // Turn on form-sharing on \"FILM\" section\n        [\"UpdateRecord\", \"_grist_Views_section\", 19,\n          { shareOptions: '{\"publish\": true, \"form\": true}' }],\n        // Turn on form-sharing on \"CUSTOMER\" section\n        [\"UpdateRecord\", \"_grist_Views_section\", 20,\n          { shareOptions: '{\"publish\": true, \"form\": true}' }],\n      ]);\n\n      const ham = await home.createHomeApi(\"kiwi\", \"docs\", true);\n      const hamShare = ham.getDocAPI(await getShareKeyForUrl(\"manyref\"));\n\n      // Friends looks empty - we just have rights to add records.\n      // assert.deepEqual(await anonDoc.getRecords('Film'), []);\n      // Can read some Actor columns, Codes for a Ref in one section,\n      // and Name for a RefList in another section.\n\n      // Some material is readable from Actor table for a reference\n      // and a ref list.\n      assert.deepEqual(await hamShare.getRecords(\"Actor\"), [\n        { id: 1, fields: { Code: \"ACT101\", Name: \"Impressive Name\" } },\n        { id: 2, fields: { Code: \"ACT102\", Name: \"Implausible Name\" } },\n      ]);\n\n      // No content readable from Films, but the read is allowed\n      // (a bit of a hack to allow form-like submissions via\n      // regular web client).\n      assert.deepEqual(await hamShare.getRecords(\"Film\"), []);\n\n      // Customer is a bit complicated. Reads allowed, but mostly\n      // no content available - EXCEPT for a column referenced by\n      // another shared widget.\n      const censored: any = [\"C\"];\n      assert.deepEqual(await hamShare.getRecords(\"Customer\"), [\n        {\n          id: 1,\n          fields: {\n            Name: \"J Public\",\n            Year_Joined: censored,\n            Good_Customer: censored,\n            Fav_Actor_Code: censored,\n          },\n        },\n        {\n          id: 2,\n          fields: {\n            Name: \"K Public\",\n            Year_Joined: censored,\n            Good_Customer: censored,\n            Fav_Actor_Code: censored,\n          },\n        },\n      ]);\n\n      // Make sure that basic functionality of adding rows works,\n      // for the expected tables.\n      await hamShare.addRows(\"Film\", { Name: [\"Foo\"] });\n      await hamShare.addRows(\"Customer\", { Name: [\"Foo\"] });\n      await assert.isRejected(hamShare.addRows(\"Actor\", { Name: [\"Foo\"] }));\n      await removeShares(docId, owner);\n      const shares = await home.dbManager.connection.query(\"select * from shares\");\n      assert.lengthOf(shares, 0);\n    });\n\n    it(\"can use shares after a copy\", async function() {\n      await freshDoc();\n\n      // Publish an empty share.\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_Shares\", null, {\n          linkId: \"x2\",\n          options: '{\"publish\": true}',\n        }],\n      ]);\n\n      // Check it reached the home db.\n      let shares = await home.dbManager.connection.query(\"select * from shares\");\n      assert.lengthOf(shares, 1);\n      assert.equal(shares[0].link_id, \"x2\");\n\n      const copyDocId = await owner.copyDoc(docId, wsId, {\n        documentName: \"copy\",\n      });\n      // Do anything with the new document.\n      await owner.getDocAPI(copyDocId).getRows(\"Table1\");\n      shares = await home.dbManager.connection.query(\"select * from shares\");\n      assert.lengthOf(shares, 2);\n      assert.equal(shares[0].link_id, \"x2\");\n      assert.equal(shares[1].link_id, \"x2\");\n      assert.notEqual(shares[0].doc_id, shares[1].doc_id);\n      await removeShares(docId, owner);\n      await removeShares(copyDocId, owner);\n    });\n\n    // There was a bug where some access rules were so bad recovery mode\n    // couldn't start.\n    it(\"can recover from certain bad access rules\", async function() {\n      await freshDoc(\"BadRules.grist\");\n      await assert.isRejected(\n        owner.getDocAPI(docId).getRows(\"Table1\"),\n        /Duplicate ACLResource 4: an ACLResource with the same tableId and colIds already exists/,\n      );\n      await owner.getDocAPI(docId).recover(true);\n      await assert.isFulfilled(owner.getDocAPI(docId).getRows(\"Table1\"));\n    });\n\n    it(\"can share forms with a reference with no display column\", async function() {\n      await freshDoc();\n\n      await owner.applyUserActions(docId, [\n        [\"AddRecord\", \"_grist_Shares\", null, {\n          linkId: \"x\",\n          options: '{\"publish\": true}',\n        }],\n      ]);\n      await owner.applyUserActions(docId, [\n        [\"UpdateRecord\", \"_grist_Views_section\", 1,\n          { shareOptions: '{\"publish\": true, \"form\": true}' },\n        ],\n        [\"UpdateRecord\", \"_grist_Pages\", 1, { shareRef: 1 }],\n        [\"AddRecord\", \"Table1\", null, { A: 1, B: 1 }],\n        [\"AddTable\", \"Table2\", [{ id: \"A\" }]],\n        [\"AddRecord\", \"Table2\", null, { A: 55 }],\n        [\"AddColumn\", \"Table1\", \"Reffy\", { type: \"Ref:Table2\" }],\n        [\"AddRecord\", \"_grist_Views_section_field\", null, {\n          colRef: 7,\n          parentId: 1,\n        }],\n      ]);\n      const ham = await home.createHomeApi(\"ham\", \"docs\", true);\n      const hamShare = ham.getDocAPI(await getShareKeyForUrl(\"x\"));\n      await assert.isFulfilled(hamShare.getRecords(\"Table1\"));\n      // This used to fail due to this case not being covered\n      // (access to Table2 isn't logically needed to see ids that\n      // are present in the referring table anyway, but it is more\n      // consistent if it is granted).\n      assert.deepEqual(await hamShare.getRecords(\"Table2\"),\n        [{ id: 1, fields: {} }]);\n    });\n  });\n\n  it(\"handles column types correctly when rows are created\", async function() {\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Data1\", [{ id: \"A\" }, { id: \"B\", type: \"Bool\" }]],\n      [\"AddRecord\", \"Data1\", null, { A: 12, B: true }],\n      [\"AddRecord\", \"Data1\", null, { A: 13, B: false }],\n      [\"AddRecord\", \"Data1\", null, { A: 14 }],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Data1\", colIds: \"*\" }],\n      // This is a rule that will behave differently if a cell is unset versus\n      // set with its default value.\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER and rec.B == False\", permissionsText: \"none\",\n      }],\n    ]);\n\n    // Check editor's view is limited, as expected.\n    assert.deepEqual(await editor.getDocAPI(docId).getRecords(\"Data1\"), [\n      { id: 1, fields: { A: \"12\", B: true } },\n    ]);\n    cliEditor.flush();\n\n    // Add a record without specifying a value for the Bool column.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"Data1\", null, { A: 15 }],\n    ]);\n    // Check editor sees correct records when enumerating them.\n    assert.deepEqual(await editor.getDocAPI(docId).getRecords(\"Data1\"), [\n      { id: 1, fields: { A: \"12\", B: true } },\n    ]);\n\n    // A bad broadcast used to go out by this point, based on a Bool\n    // being null instead of false during rule evalation and not\n    // matching exact access rule check. Check that hasn't happened.\n    assert.equal(cliEditor.count(), 0);\n\n    // Check that if something should get broadcast, it does.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"Data1\", null, { A: 16, B: true }],\n    ]);\n    assert.deepEqual(await editor.getDocAPI(docId).getRecords(\"Data1\"), [\n      { id: 1, fields: { A: \"12\", B: true } },\n      { id: 5, fields: { A: \"16\", B: true } },\n    ]);\n    assert.deepEqual((await cliEditor.readDocUserAction()), [\n      [\"AddRecord\", \"Data1\", 5, { A: \"16\", B: true, manualSort: 5 }],\n    ]);\n  });\n\n  it(\"is respected by /compare\", async function() {\n    // The /compare endpoint should work for anyone with full read access.\n    await freshDoc();\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"Table2\", [{ id: \"A\", type: \"Int\" }, { id: \"B\", type: \"Int\" }]],\n    ]);\n    const states = (await owner.getDocAPI(docId).getStates()).states;\n    assert.lengthOf(states, 2);\n    const v0 = states[0].h;\n    const v1 = states[1].h;\n    await assert.isFulfilled(editor.getDocAPI(docId).compareVersion(v1, v0));\n    await assert.isFulfilled(editor.getDocAPI(docId).compareDoc(docId, { detail: true }));\n\n    // The /compare endpoint should fail for anyone without full read\n    // access, currently.\n    await owner.applyUserActions(docId, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table2\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"user.Access != OWNER\", permissionsText: \"-R\",\n      }],\n    ]);\n    await assert.isFulfilled(owner.getDocAPI(docId).compareVersion(v1, v0));\n    await assert.isFulfilled(owner.getDocAPI(docId).compareDoc(docId, { detail: true }));\n    await assert.isRejected(editor.getDocAPI(docId).compareVersion(v1, v0), /insufficient access/);\n    await assert.isRejected(editor.getDocAPI(docId).compareDoc(docId, { detail: true }), /insufficient access/);\n  });\n});\n\nasync function closeClient(cli: GristClient) {\n  if (cli.isOpen()) {\n    await cli.send(\"closeDoc\", 0);\n  }\n  await cli.close();\n}\n\n// Create a wrapper to check that some property doesn't change during a test.\nfunction assertUnchanged(check: () => PromiseLike<any>) {\n  return async (body: PromiseLike<any>) => {\n    const pre = await check();\n    await body;\n    const post = await check();\n    assert.deepEqual(pre, post);\n  };\n}\n\nasync function assertDeniedFor(check: Promise<any>, memos: string[], test = /access rules/) {\n  try {\n    await check;\n    throw new Error(\"not denied\");\n  } catch (e) {\n    assert.match(e?.details?.userError, test);\n    assert.deepEqual(e?.details?.memos ?? [], memos);\n  }\n}\n\n// Read the content of an attachment, as text.\nasync function getAttachment(api: UserAPI, docId: string, attId: number) {\n  const userApi = api as UserAPIImpl;\n  const result = await userApi.testRequest(\n    userApi.getBaseUrl() + `/api/docs/${docId}/attachments/${attId}/download`, {\n      headers: userApi.defaultHeadersWithoutContentType(),\n    },\n  );\n  return result.text();\n}\n\nasync function assertFlux(check: Promise<any>) {\n  try {\n    await check;\n    throw new Error(\"not denied\");\n  } catch (e) {\n    assert.match(e?.details?.userError, /Document in flux/);\n  }\n}\n"
  },
  {
    "path": "test/server/lib/GristJobs.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { createGristJobs, GristJobs } from \"app/server/lib/GristJobs\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"GristJobs\", function() {\n  this.timeout(20000);\n\n  // Clean up any jobs left over from previous round of tests,\n  // if external queues are in use (Redis).\n  beforeEach(async function() {\n    const jobs = createGristJobs();\n    const q = jobs.queue();\n    await q.stop({ obliterate: true });\n    await jobs.stop();\n  });\n\n  describe(\"with redis\", function() {\n    before(async function() {\n      if (!process.env.REDIS_URL && !process.env.TEST_REDIS_URL) { this.skip(); }\n    });\n    runSuite();\n  });\n\n  describe(\"without redis\", function() {\n    let oldEnv: EnvironmentSnapshot;\n    before(async function() {\n      oldEnv = new EnvironmentSnapshot();\n      delete process.env.REDIS_URL;\n      delete process.env.TEST_REDIS_URL;\n    });\n    after(async function() {\n      oldEnv.restore();\n    });\n    runSuite();\n  });\n\n  function runSuite() {\n    it(\"can run immediate jobs\", async function() {\n      const jobs: GristJobs = createGristJobs();\n      const q = jobs.queue();\n      try {\n        let ct = 0;\n        let defaultCt = 0;\n        q.handleName(\"add\", async (job) => {\n          ct += job.data.delta;\n        });\n        q.handleDefault(async (job) => {\n          defaultCt++;\n        });\n        await q.add(\"add\", { delta: 2 });\n        await waitForIt(async () => {\n          assert.equal(ct, 2);\n          assert.equal(defaultCt, 0);\n        }, 2000, 10);\n        await q.add(\"add\", { delta: 3 });\n        await waitForIt(async () => {\n          assert.equal(ct, 5);\n          assert.equal(defaultCt, 0);\n        }, 2000, 10);\n        await q.add(\"badd\", { delta: 4 });\n        await waitForIt(async () => {\n          assert.equal(ct, 5);\n          assert.equal(defaultCt, 1);\n        }, 2000, 10);\n      } finally {\n        await jobs.stop({ obliterate: true });\n      }\n    });\n\n    it(\"can run delayed jobs\", async function() {\n      const jobs: GristJobs = createGristJobs();\n      const q = jobs.queue();\n      try {\n        let ct = 0;\n        let defaultCt = 0;\n        q.handleName(\"add\", async (job) => {\n          ct += job.data.delta;\n        });\n        q.handleDefault(async () => {\n          defaultCt++;\n        });\n        await q.add(\"add\", { delta: 2 }, { delay: 500 });\n        assert.equal(ct, 0);\n        assert.equal(defaultCt, 0);\n        // We need to wait long enough to see the effect.\n        await delay(100);\n        assert.equal(ct, 0);\n        assert.equal(defaultCt, 0);\n        await delay(900);\n        assert.equal(ct, 2);\n        assert.equal(defaultCt, 0);\n      } finally {\n        await jobs.stop({ obliterate: true });\n      }\n    });\n\n    it(\"can run repeated jobs\", async function() {\n      const jobs: GristJobs = createGristJobs();\n      const q = jobs.queue();\n      try {\n        let ct = 0;\n        let defaultCt = 0;\n        q.handleName(\"add\", async (job) => {\n          ct += job.data.delta;\n        });\n        q.handleDefault(async () => {\n          defaultCt++;\n        });\n        await q.add(\"add\", { delta: 2 }, { repeat: { every: 250 } });\n        await q.add(\"badd\", { delta: 2 }, { repeat: { every: 100 } });\n        assert.equal(ct, 0);\n        assert.equal(defaultCt, 0);\n        await delay(1000);\n        // allow for a lot of slop on CI\n        assert.isAtLeast(ct, 8 - 4);\n        assert.isAtMost(ct, 8 + 4);\n        assert.isAtLeast(defaultCt, 10 - 3);\n        assert.isAtMost(defaultCt, 10 + 3);\n      } finally {\n        await jobs.stop({ obliterate: true });\n      }\n    });\n\n    it(\"can pick up jobs again\", async function() {\n      // this test is only appropriate if we have an external queue.\n      if (!process.env.REDIS_URL &&\n        !process.env.TEST_REDIS_URL) { this.skip(); }\n      const jobs1: GristJobs = createGristJobs();\n      const q = jobs1.queue();\n      try {\n        let ct = 0;\n        q.handleName(\"add\", async (job) => {\n          ct += job.data.delta;\n        });\n        q.handleDefault(async () => {});\n        await q.add(\"add\", { delta: 1 }, { delay: 250 });\n        await q.add(\"add\", { delta: 1 }, { delay: 1000 });\n        await delay(500);\n        assert.equal(ct, 1);\n        await jobs1.stop();\n        const jobs2: GristJobs = createGristJobs();\n        const q2 = jobs2.queue();\n        try {\n          q2.handleName(\"add\", async (job) => {\n            ct += job.data.delta * 2;\n          });\n          q2.handleDefault(async () => {});\n          await delay(1000);\n          assert.equal(ct, 3);\n        } finally {\n          await jobs2.stop({ obliterate: true });\n        }\n      } finally {\n        await jobs1.stop({ obliterate: true });\n      }\n    });\n  }\n});\n"
  },
  {
    "path": "test/server/lib/GristSockets.ts",
    "content": "import { GristClientSocket } from \"app/client/components/GristClientSocket\";\nimport { GristSocketServer, GristSocketServerOptions } from \"app/server/lib/GristSocketServer\";\nimport { fromCallback, listenPromise } from \"app/server/lib/serverUtils\";\n\nimport * as http from \"http\";\nimport { AddressInfo } from \"net\";\n\nimport { assert } from \"chai\";\nimport httpProxy from \"http-proxy\";\n\ndescribe(`GristSockets`, function() {\n  for (const webSocketsSupported of [true, false]) {\n    describe(`when the networks ${webSocketsSupported ? \"supports\" : \"does not support\"} WebSockets`, function() {\n      let server: http.Server | null;\n      let serverPort: number;\n      let socketServer: GristSocketServer | null;\n      let proxy: httpProxy | null;\n      let proxyServer: http.Server | null;\n      let proxyPort: number;\n      let wsAddress: string;\n\n      beforeEach(async function() {\n        await startSocketServer();\n        await startProxyServer();\n      });\n\n      afterEach(async function() {\n        await stopProxyServer();\n        await stopSocketServer();\n      });\n\n      async function startSocketServer(options?: GristSocketServerOptions) {\n        server = http.createServer((req, res) => res.writeHead(404).end());\n        socketServer = new GristSocketServer(server, options);\n        await listenPromise(server.listen(0, \"localhost\"));\n        serverPort = (server.address() as AddressInfo).port;\n      }\n\n      async function stopSocketServer() {\n        await fromCallback(cb => socketServer?.close(cb));\n        await fromCallback((cb) => { server?.close(); server?.closeAllConnections(); server?.on(\"close\", cb); });\n        socketServer = server = null;\n      }\n\n      // Start an HTTP proxy that supports WebSockets or not\n      async function startProxyServer() {\n        proxy = httpProxy.createProxy({\n          target: `http://localhost:${serverPort}`,\n          ws: webSocketsSupported,\n          timeout: 1000,\n        });\n        proxy.on(\"error\", () => { });\n        proxyServer = http.createServer();\n\n        if (webSocketsSupported) {\n          // prevent non-WebSocket requests\n          proxyServer.on(\"request\", (req, res) => res.writeHead(404).end());\n          // proxy WebSocket requests\n          proxyServer.on(\"upgrade\", (req, socket, head) => proxy!.ws(req, socket, head));\n        } else {\n          // proxy non-WebSocket requests\n          proxyServer.on(\"request\", (req, res) => proxy!.web(req, res));\n          // don't leave WebSocket connection attempts hanging\n          proxyServer.on(\"upgrade\", (req, socket, head) => socket.destroy());\n        }\n\n        await listenPromise(proxyServer.listen(0, \"localhost\"));\n        proxyPort = (proxyServer.address() as AddressInfo).port;\n        wsAddress = `ws://localhost:${proxyPort}`;\n      }\n\n      async function stopProxyServer() {\n        if (proxy) {\n          proxy.close();\n          proxy = null;\n        }\n        if (proxyServer) {\n          const server = proxyServer;\n          await fromCallback((cb) => { server.close(cb); server.closeAllConnections(); });\n        }\n        proxyServer = null;\n      }\n\n      function getMessages(ws: GristClientSocket, count: number): Promise<string[]> {\n        return new Promise((resolve, reject) => {\n          const messages: string[] = [];\n          ws.onerror = (err) => {\n            ws.onerror = ws.onmessage = null;\n            reject(err);\n          };\n          ws.onmessage = (data: string) => {\n            messages.push(data);\n            if (messages.length >= count) {\n              ws.onerror = ws.onmessage = null;\n              resolve(messages);\n            }\n          };\n        });\n      }\n\n      /**\n       * Returns a promise for the connected websocket.\n       */\n      function connectClient(url: string): Promise<GristClientSocket> {\n        const socket = new GristClientSocket(url);\n        return new Promise<GristClientSocket>((resolve, reject) => {\n          socket.onopen = () => {\n            socket.onerror = null;\n            resolve(socket);\n          };\n          socket.onerror = (err) => {\n            socket.onopen = null;\n            reject(err);\n          };\n        });\n      }\n\n      it(\"should expose initial request\", async function() {\n        const connectionPromise = new Promise<http.IncomingMessage>((resolve) => {\n          socketServer!.onconnection = (socket, req) => {\n            resolve(req);\n          };\n        });\n        const clientWs = new GristClientSocket(wsAddress + \"/path?query=value\", {\n          headers: { cookie: \"session=1234\" },\n        });\n        const req = await connectionPromise;\n        clientWs.close();\n\n        // Engine.IO may append extra query parameters, so we check only the start of the URL\n        assert.match(req.url!, /^\\/path\\?query=value/);\n\n        assert.equal(req.headers.cookie, \"session=1234\");\n      });\n\n      it(\"should receive and send messages\", async function() {\n        socketServer!.onconnection = (socket, req) => {\n          socket.onmessage = (data) => {\n            socket.send(\"hello, \" + data);\n          };\n        };\n        const clientWs = await connectClient(wsAddress);\n        clientWs.send(\"world\");\n        assert.deepEqual(await getMessages(clientWs, 1), [\"hello, world\"]);\n        clientWs.close();\n      });\n\n      it(\"should invoke send callbacks\", async function() {\n        const connectionPromise = new Promise<void>((resolve) => {\n          socketServer!.onconnection = (socket, req) => {\n            socket.send(\"hello\", () => resolve());\n          };\n        });\n        const clientWs = await connectClient(wsAddress);\n        await connectionPromise;\n        clientWs.close();\n      });\n\n      it(\"should emit close event for client\", async function() {\n        const clientWs = await connectClient(wsAddress);\n        const closePromise = new Promise<void>((resolve) => {\n          clientWs.onclose = resolve;\n        });\n        clientWs.close();\n        await closePromise;\n      });\n\n      it(\"should fail gracefully if verifyClient throws exception\", async function() {\n        // Restart servers with a failing verifyClient method.\n        await stopProxyServer();\n        await stopSocketServer();\n        await startSocketServer({ verifyClient: () => { throw new Error(\"Test error from verifyClient\"); } });\n        await startProxyServer();\n\n        // Check whether we are getting an unhandledRejection.\n        let rejection: unknown = null;\n        const onUnhandledRejection = (err: unknown) => { rejection = err; };\n        process.on(\"unhandledRejection\", onUnhandledRejection);\n\n        try {\n          // The \"poll error\" comes from the fallback to polling.\n          await assert.isRejected(connectClient(wsAddress), /poll error/);\n        } finally {\n          // Typings for process.removeListener are broken, possibly by electron's presence\n          // (https://github.com/electron/electron/issues/9626).\n          process.removeListener(\"unhandledRejection\" as any, onUnhandledRejection);\n        }\n        // The important thing is that we don't get unhandledRejection.\n        assert.equal(rejection, null);\n      });\n    });\n  }\n});\n"
  },
  {
    "path": "test/server/lib/HashUtil.ts",
    "content": "import { DocState } from \"app/common/DocState\";\nimport { HashUtil } from \"app/server/lib/HashUtil\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"HashUtil\", function() {\n  const states: DocState[] = [{ n: 4, h: \"4123\" }, { n: 3, h: \"3123\" }, { n: 2, h: \"2123\" }, { n: 1, h: \"1123\" }];\n  const finder = new HashUtil(states);\n\n  it(\"understands HEAD\", function() {\n    assert.equal(finder.hashToOffset(\"HEAD\"), 0);\n    assert.throws(() => finder.hashToOffset(\"head\"));\n  });\n\n  it(\"understands HASH\", function() {\n    assert.equal(finder.hashToOffset(\"3123\"), 1);\n    assert.throws(() => finder.hashToOffset(\"312355\"));\n  });\n\n  it(\"understands ~\", function() {\n    assert.equal(finder.hashToOffset(\"3123~\"), 2);\n    assert.equal(finder.hashToOffset(\"3123~1\"), 2);\n    assert.equal(finder.hashToOffset(\"3123~2\"), 3);\n    assert.equal(finder.hashToOffset(\"3123~3\"), 4);\n    assert.equal(finder.hashToOffset(\"HEAD~\"), 1);\n    assert.equal(finder.hashToOffset(\"HEAD~~\"), 2);\n    assert.equal(finder.hashToOffset(\"HEAD~~~\"), 3);\n    assert.equal(finder.hashToOffset(\"HEAD~~~~\"), 4);\n    assert.equal(finder.hashToOffset(\"4123\"), 0);\n    assert.equal(finder.hashToOffset(\"4123~2~\"), 3);\n    assert.equal(finder.hashToOffset(\"4123~1~1~1\"), 3);\n    assert.equal(finder.hashToOffset(\"4123~~~1\"), 3);\n    assert.throws(() => finder.hashToOffset(\"~\"));\n    assert.throws(() => finder.hashToOffset(\"~~\"));\n    assert.throws(() => finder.hashToOffset(\"~e\"));\n    assert.throws(() => finder.hashToOffset(\"HEAD~e\"));\n  });\n\n  it(\"understands ^\", function() {\n    assert.equal(finder.hashToOffset(\"3123^1\"), 2);\n    assert.equal(finder.hashToOffset(\"3123^1^1\"), 3);\n    assert.equal(finder.hashToOffset(\"3123^1^1^1\"), 4);\n    assert.equal(finder.hashToOffset(\"HEAD^1\"), 1);\n    assert.equal(finder.hashToOffset(\"HEAD^\"), 1);\n    assert.equal(finder.hashToOffset(\"HEAD^^\"), 2);\n    assert.throws(() => finder.hashToOffset(\"^\"));\n    assert.throws(() => finder.hashToOffset(\"HEAD^2\"));\n    assert.throws(() => finder.hashToOffset(\"HEAD^e\"));\n  });\n\n  it(\"understands combinations of ^ and ~\", function() {\n    assert.equal(finder.hashToOffset(\"HEAD^1~\"), 2);\n    assert.equal(finder.hashToOffset(\"HEAD~^1\"), 2);\n    assert.equal(finder.hashToOffset(\"HEAD~^1~2\"), 4);\n    assert.equal(finder.hashToOffset(\"HEAD~^1~^1\"), 4);\n    assert.equal(finder.hashToOffset(\"HEAD~^1~1^1\"), 4);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/HostedMetadataManager.ts",
    "content": "import { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { HostedMetadataManager } from \"app/server/lib/HostedMetadataManager\";\nimport { removeConnection } from \"test/gen-server/seed\";\nimport { setTmpLogLevel } from \"test/server/testUtils\";\n\nimport { delay } from \"bluebird\";\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\n// Note that this is a stubbed test of the HostedMetadataManager and does not test interaction\n// with the live DB. We may want to revisit this with the DB running for a complete test.\ndescribe(\"HostedMetadataManager\", function() {\n  setTmpLogLevel(\"info\");\n\n  const sandbox = sinon.createSandbox();\n  let manager: HostedMetadataManager;\n  let updateCount: number = 0;\n\n  before(async function() {\n    const dbManager = new HomeDBManager();\n    await dbManager.connect();\n\n    // Stub the function that updates the value in the DB.\n    sandbox.stub(HostedMetadataManager.prototype, \"setDocsMetadata\").callsFake(() => {\n      updateCount += 1;\n      return Promise.resolve();\n    });\n\n    // Set the manager to make a call every 0.5s.\n    manager = new HostedMetadataManager(null as any /* not used since we have stub */, 0.5);\n  });\n\n  after(async function() {\n    await removeConnection();\n    sandbox.restore();\n  });\n\n  async function scheduleUpdate(docId: string, minimizeDelay?: boolean) {\n    manager.scheduleUpdate(docId, {\n      updatedAt: new Date().toISOString(),\n      usage: { rowCount: { total: 123 }, dataSizeBytes: 456, attachmentsSizeBytes: 789 },\n    }, minimizeDelay);\n    await delay(10);\n  }\n\n  it(\"can throttle push calls\", async function() {\n    this.timeout(3000);\n\n    // Schedule an update and check that it updates the count quickly.\n    await scheduleUpdate(\"Doc1\");\n    assert.equal(updateCount, 1);\n\n    // Schedule another update and check that it does not occur immediately, since not enough time\n    // has passed for another push.\n    await scheduleUpdate(\"Doc2\");\n    assert.equal(updateCount, 1);\n    await delay(501);\n    assert.equal(updateCount, 2);\n\n    // Schedule 5 updates for the same doc. The last push should have just occurred, so\n    // none of the updates should happen for 0.5s.\n    await scheduleUpdate(\"Doc1\");\n    await scheduleUpdate(\"Doc1\");\n    await scheduleUpdate(\"Doc1\");\n    await scheduleUpdate(\"Doc1\");\n    await scheduleUpdate(\"Doc1\");\n    assert.equal(updateCount, 2);\n    // All 5 updates should occur as a single update.\n    await delay(501);\n    assert.equal(updateCount, 3);\n\n    // Wait again to zero out any required delays.\n    await delay(500);\n\n    // Schedule multiple updates on multiple docs.\n    await scheduleUpdate(\"Doc1\");\n    await scheduleUpdate(\"Doc2\");\n    await scheduleUpdate(\"Doc1\");\n    await scheduleUpdate(\"Doc3\");\n    await scheduleUpdate(\"Doc4\");\n    await scheduleUpdate(\"Doc1\");\n    await scheduleUpdate(\"Doc2\");\n    // One of the updates should have happened immediately.\n    assert.equal(updateCount, 4);\n    // Wait and assert that despite updating multiple docs, all updates happen in a single call.\n    await delay(501);\n    assert.equal(updateCount, 5);\n\n    // Zero out any required delays for the next test clause.\n    await delay(500);\n  });\n\n  it(\"allows minimizing push delay when scheduling updates\", async function() {\n    updateCount = 0;\n\n    // Schedule an update with minimizeDelay set, and check that it updates the count immediately.\n    await scheduleUpdate(\"Doc1\", true);\n    assert.equal(updateCount, 1);\n\n    // Schedule another update and check that it does occur immediately, since minimizeDelay is set.\n    await scheduleUpdate(\"Doc2\", true);\n    assert.equal(updateCount, 2);\n\n    // Schedule multiple updates on multiple docs.\n    await scheduleUpdate(\"Doc1\");\n    await scheduleUpdate(\"Doc2\");\n    await scheduleUpdate(\"Doc1\");\n    await scheduleUpdate(\"Doc3\");\n    await scheduleUpdate(\"Doc4\");\n    await scheduleUpdate(\"Doc1\");\n    await scheduleUpdate(\"Doc2\");\n\n    // None of the updates should have happened yet.\n    assert.equal(updateCount, 2);\n\n    // Schedule an update with minimizeDelay set, and check that it updates the count immediately.\n    await scheduleUpdate(\"Doc1\", true);\n    assert.equal(updateCount, 3);\n\n    // Wait and assert that no further updates occured, since the last push should have flushed all\n    // outstanding doc updates.\n    await delay(501);\n    assert.equal(updateCount, 3);\n\n    // Zero out any required delays for the next test clause.\n    await delay(500);\n  });\n\n  it(\"allows calling close to force send pending requests\", async function() {\n    updateCount = 0;\n\n    // Schedule an update and check that it updates the count immediately.\n    await scheduleUpdate(\"Doc1\");\n    assert.equal(updateCount, 1);\n    // Schedule another update. Call close on the manager and check that the\n    // update occurs quickly.\n    await scheduleUpdate(\"Doc2\");\n    await manager.close();\n    // Push is called immediately, but we delay briefly here to allow the async call to return.\n    await delay(10);\n    assert.equal(updateCount, 2);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/HostedStorageManager.ts",
    "content": "import { ErrorOrValue, freezeError, mapGetOrSet, MapWithTTL } from \"app/common/AsyncCreate\";\nimport { delay } from \"app/common/delay\";\nimport { ObjMetadata, ObjSnapshot, ObjSnapshotWithMetadata } from \"app/common/DocSnapshot\";\nimport { SCHEMA_VERSION } from \"app/common/schema\";\nimport { DocWorkerMap, getDocWorkerMap } from \"app/gen-server/lib/DocWorkerMap\";\nimport { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport {\n  AttachmentStoreProvider,\n  IAttachmentStoreProvider,\n} from \"app/server/lib/AttachmentStoreProvider\";\nimport {\n  BackupEvent,\n  backupSqliteDatabase,\n  retryOnClose,\n} from \"app/server/lib/backupSqliteDatabase\";\nimport { create } from \"app/server/lib/create\";\nimport { DocManager } from \"app/server/lib/DocManager\";\nimport { makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport { IDocWorkerMap } from \"app/server/lib/DocWorkerMap\";\nimport {\n  DELETED_TOKEN,\n  ExternalStorage, ExternalStorageCreator,\n  ExternalStorageSettings,\n  wrapWithKeyMappedStorage,\n} from \"app/server/lib/ExternalStorage\";\nimport { createDummyGristServer, GristServer } from \"app/server/lib/GristServer\";\nimport {\n  HostedStorageManager,\n  HostedStorageOptions,\n} from \"app/server/lib/HostedStorageManager\";\nimport log from \"app/server/lib/log\";\nimport { SQLiteDB } from \"app/server/lib/SQLiteDB\";\nimport { createInitialDb, removeConnection, setUpDB } from \"test/gen-server/seed\";\nimport { createTmpDir, getGlobalPluginManager } from \"test/server/docTools\";\nimport { EnvironmentSnapshot, setTmpLogLevel, useFixtureDoc } from \"test/server/testUtils\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport * as path from \"node:path\";\nimport { setTimeout } from \"node:timers/promises\";\n\nimport * as bluebird from \"bluebird\";\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\nimport * as minio from \"minio\";\nimport { createClient, RedisClient } from \"redis\";\nimport * as sinon from \"sinon\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nbluebird.promisifyAll(RedisClient.prototype);\n\n/**\n * An in-memory store, for testing.\n */\nclass SimpleExternalStorage implements ExternalStorage {\n  protected _version = new Map<string, ObjSnapshot[]>();\n  private _nextId: number = 1;\n  private _memory = new Map<string, Buffer>();\n  private _metadata = new Map<string, ObjSnapshotWithMetadata>();\n\n  public constructor(public readonly label: string) {}\n\n  public async exists(key: string, snapshotId?: string): Promise<boolean> {\n    if (snapshotId) {\n      // Should check snapshotId is associated with key, but don't need to be too\n      // fussy this mock.\n      return this._memory.has(snapshotId);\n    }\n    return this._version.has(key);\n  }\n\n  public async head(key: string, snapshotId?: string) {\n    snapshotId = snapshotId || this._version.get(key)?.[0]?.snapshotId;\n    if (!snapshotId) { return null; }\n    return this._metadata.get(snapshotId) || null;\n  }\n\n  public async upload(key: string, fname: string, metadata?: ObjMetadata) {\n    const data = await fse.readFile(fname);\n    const id = `block-${this._nextId}`;\n    this._nextId++;\n    this._memory.set(id, data);\n    const info: ObjSnapshotWithMetadata = {\n      snapshotId: id,\n      lastModified: new Date().toISOString(),\n    };\n    this._metadata.set(id, { ...info, ...metadata && { metadata } });\n    const versions = this._version.get(key) || [];\n    versions.unshift(info);\n    this._version.set(key, versions);\n    return id;\n  }\n\n  public async remove(key: string, snapshotIds?: string[]) {\n    const versions = this._version.get(key);\n    if (!versions) { return; }\n    if (!snapshotIds) {\n      for (const version of versions) {\n        this._memory.delete(version.snapshotId);\n        this._metadata.delete(version.snapshotId);\n      }\n      this._version.delete(key);\n    } else {\n      for (const snapshotId of snapshotIds) {\n        this._memory.delete(snapshotId);\n        this._metadata.delete(snapshotId);\n      }\n      const blockList = new Set(snapshotIds);\n      this._version.set(key, (this._version.get(key) || []).filter(v => !blockList.has(v.snapshotId)));\n    }\n  }\n\n  public async download(key: string, fname: string, snapshotId?: string) {\n    const versions = this._version.get(key);\n    if (!versions) { throw new Error(\"oopsie key not found\"); }\n    if (snapshotId) {\n      if (!versions.find(v => v.snapshotId === snapshotId)) {\n        throw new Error(\"version not recognized\");\n      }\n    } else {\n      snapshotId = versions[0].snapshotId;\n    }\n    if (!snapshotId) { throw new Error(\"version not found\"); }\n    const data = this._memory.get(snapshotId);\n    if (!data) { throw new Error(\"version data not found\"); }\n    await fse.writeFile(fname, data);\n    return snapshotId;\n  }\n\n  public async versions(key: string) {\n    return this._version.get(key) || [];\n  }\n\n  public url(key: string): string {\n    return `simple://test/${this.label}/${key}`;\n  }\n\n  public isFatalError(err: any): boolean {\n    return !String(err).includes(\"oopsie\");\n  }\n\n  public async close() {\n    // nothing to do\n  }\n}\n\n/**\n * A wrapper around an external store, that deliberately gives stale values for the\n * `exists`, `download`, and `versions` methods.\n */\nclass CachedExternalStorage implements ExternalStorage {\n  private _cachedExists: MapWithTTL<string, Promise<ErrorOrValue<boolean>>>;\n  private _cachedHead: MapWithTTL<string, Promise<ErrorOrValue<ObjSnapshotWithMetadata | null>>>;\n  private _cachedDownload: MapWithTTL<string, Promise<ErrorOrValue<[string, Buffer]>>>;\n  private _cachedVersions: MapWithTTL<string, Promise<ErrorOrValue<ObjSnapshot[]>>>;\n\n  constructor(private _ext: ExternalStorage, ttlMs: number) {\n    this._cachedExists = new MapWithTTL(ttlMs);\n    this._cachedHead = new MapWithTTL(ttlMs);\n    this._cachedDownload = new MapWithTTL(ttlMs);\n    this._cachedVersions = new MapWithTTL(ttlMs);\n  }\n\n  public async exists(key: string, snapshotId?: string) {\n    const result = await mapGetOrSet(this._cachedExists, `${key}${snapshotId}`, () => {\n      return freezeError(this._ext.exists(key, snapshotId));\n    });\n    return result.unfreeze();\n  }\n\n  public async head(key: string, snapshotId?: string) {\n    const result = await mapGetOrSet(this._cachedHead, `${key}${snapshotId}`, () => {\n      return freezeError(this._ext.head(key, snapshotId));\n    });\n    return result.unfreeze();\n  }\n\n  public async upload(key: string, fname: string, metadata?: ObjMetadata) {\n    return this._ext.upload(key, fname, metadata);\n  }\n\n  public async remove(key: string, snapshotIds?: string[]) {\n    return this._ext.remove(key, snapshotIds);\n  }\n\n  public async download(key: string, fname: string, snapshotId?: string): Promise<string> {\n    const result = await mapGetOrSet(this._cachedDownload, `${key}${snapshotId}`, () => {\n      const altFname = fname + uuidv4();\n      return freezeError(\n        this._ext.download(key, altFname, snapshotId).then(async (v) => {\n          return [v, await fse.readFile(altFname)] as [string, Buffer];\n        }),\n      );\n    });\n    try {\n      const [downloadedSnapshotId, txt] = await result.unfreeze();\n      await fse.writeFile(fname, txt);\n      return downloadedSnapshotId;\n    } catch (e) {\n      await fse.writeFile(fname, \"put some junk here to simulate unclean failure\");\n      throw e;\n    }\n  }\n\n  public async versions(key: string) {\n    const result = await mapGetOrSet(this._cachedVersions, key, () => {\n      return freezeError(this._ext.versions(key));\n    });\n    return result.unfreeze();\n  }\n\n  public url(key: string): string {\n    return this._ext.url(key);\n  }\n\n  public isFatalError(err: any): boolean {\n    return this._ext.isFatalError(err);\n  }\n\n  public async close() {\n    // nothing to do\n  }\n}\n\n/**\n * A wrapper that slows down responses from a store.\n */\nclass SlowExternalStorage implements ExternalStorage {\n  constructor(private _ext: ExternalStorage, private _delayMs: number) {}\n\n  public async exists(key: string, snapshotId?: string) {\n    await bluebird.delay(this._delayMs);\n    return this._ext.exists(key, snapshotId);\n  }\n\n  public async head(key: string, snapshotId?: string) {\n    await bluebird.delay(this._delayMs);\n    return this._ext.head(key, snapshotId);\n  }\n\n  public async upload(key: string, fname: string, metadata?: ObjMetadata) {\n    await bluebird.delay(this._delayMs);\n    return this._ext.upload(key, fname, metadata);\n  }\n\n  public async remove(key: string, snapshotIds?: string[]) {\n    await bluebird.delay(this._delayMs);\n    return this._ext.remove(key, snapshotIds);\n  }\n\n  public async download(key: string, fname: string, snapshotId?: string): Promise<string> {\n    await bluebird.delay(this._delayMs);\n    return this._ext.download(key, fname, snapshotId);\n  }\n\n  public async versions(key: string) {\n    await bluebird.delay(this._delayMs);\n    return this._ext.versions(key);\n  }\n\n  public url(key: string): string {\n    return this._ext.url(key);\n  }\n\n  public isFatalError(err: any): boolean {\n    return this._ext.isFatalError(err);\n  }\n\n  public async close() {\n    // nothing to do\n  }\n}\n\n/**\n * A document store representing a doc worker's local store, for testing.\n * Uses TEST_S3_BUCKET and TEST_S3_PREFIX.  Objects in test bucket should be set up\n * to be deleted after a short period.  Since we don't attempt to garbage collect\n * within the unit test.  s3://grist-docs-test/unit-tests/... is set up that way.\n */\nclass TestStore {\n  public docManager: DocManager;\n  public storageManager: HostedStorageManager;\n  private _active: boolean = false;  // True if the simulated doc worker is started.\n  private _extraPrefix = uuidv4();   // Extra prefix in S3 (unique to this test).\n\n  public constructor(\n    private _localDirectory: string,\n    private _workerId: string,\n    private _workers: IDocWorkerMap,\n    private _externalStorageCreate: ExternalStorageCreator,\n    private _attachmentStoreProvider?: IAttachmentStoreProvider) {\n  }\n\n  public async run<T>(fn: () => Promise<T>): Promise<T> {\n    await this.begin();\n    let result;\n    try {\n      result = await fn();\n    } finally {\n      await this.end();\n    }\n    return result;\n  }\n\n  // Simulates doc worker startup.\n  public async begin() {\n    await this.end();\n    this._active = true;\n    const dbManager = new HomeDBManager();\n    await dbManager.connect();\n    await dbManager.initializeSpecialIds();\n    const options: HostedStorageOptions = {\n      secondsBeforePush: 0.5,\n      secondsBeforeFirstRetry: 3,   // rumors online suggest delays of 10-11 secs\n      // are not super-unusual.\n      pushDocUpdateTimes: false,\n\n    };\n    const externalStorageCreator = (purpose: ExternalStorageSettings[\"purpose\"]) => {\n      const result = this._externalStorageCreate(purpose, this._extraPrefix);\n      if (!result) { throw new Error(\"no storage\"); }\n      return result;\n    };\n\n    const attachmentStoreProvider =\n      this._attachmentStoreProvider ?? new AttachmentStoreProvider([],  \"TESTINSTALL\");\n\n    const testStore = this;\n    const gristServer: GristServer = {\n      ...createDummyGristServer(),\n      getDocManager() {\n        return testStore.docManager;\n      },\n    };\n\n    const storageManager = new HostedStorageManager(gristServer,\n      this._localDirectory,\n      this._workerId,\n      false,\n      this._workers,\n      dbManager,\n      externalStorageCreator,\n      options);\n    this.storageManager = storageManager;\n    this.docManager = new DocManager(storageManager, await getGlobalPluginManager(), dbManager, attachmentStoreProvider,\n      {\n        ...createDummyGristServer(),\n        getStorageManager() { return storageManager; },\n      });\n  }\n\n  // Simulates doc worker shutdown.  Closes all open documents.\n  public async end() {\n    if (this._active) {\n      await this.docManager.shutdownAll();\n    }\n    this._active = false;\n  }\n\n  // Close a single doc.  The server does this for docs that are not open by\n  // any client.\n  public async closeDoc(doc: ActiveDoc) {\n    await doc.shutdown();\n  }\n\n  // Waits for any required S3 pushes to have completed.\n  public async waitForUpdates(): Promise<boolean> {\n    for (let i = 0; i < 50; i++) {\n      if (!this.storageManager.needsUpdate()) {\n        return true;\n      }\n      await bluebird.delay(100);\n    }\n    log.error(\"waitForUpdates failed\");\n    return false;\n  }\n\n  // Wipes the doc worker's local document store.\n  public async removeAll(): Promise<void> {\n    const fnames = await fse.readdir(this._localDirectory);\n    await Promise.all(fnames.map((fname) => {\n      return fse.remove(path.join(this._localDirectory, fname));\n    }));\n  }\n\n  public getDocPath(docId: string) {\n    return path.join(this._localDirectory, `${docId}.grist`);\n  }\n}\n\ndescribe(\"HostedStorageManager\", function() {\n  setTmpLogLevel(\"info\");  // allow info messages for this test since failures are hard to replicate\n  this.timeout(60000);     // s3 can be slow\n\n  const docSession = makeExceptionalDocSession(\"system\");\n\n  before(async function() {\n    setUpDB(this);\n    await createInitialDb();\n  });\n\n  after(async function() {\n    await removeConnection();\n  });\n\n  for (const storage of [\"azure\", \"s3\", \"minio\", \"cached\"] as const) {\n    describe(storage, function() {\n      const sandbox = sinon.createSandbox();\n      let oldEnv: EnvironmentSnapshot;\n\n      const workerId = \"dw17\";\n      let cli: RedisClient;\n      let store: TestStore;\n      let workers: DocWorkerMap;\n      let tmpDir: string;\n\n      before(async function() {\n        if (!process.env.TEST_REDIS_URL) { this.skip(); return; }\n        cli = createClient(process.env.TEST_REDIS_URL);\n        oldEnv = new EnvironmentSnapshot();\n        await cli.flushdbAsync();\n        workers = new DocWorkerMap([cli]);\n        await workers.addWorker({\n          id: workerId,\n          publicUrl: \"notset\",\n          internalUrl: \"notset\",\n        });\n        await workers.setWorkerAvailability(workerId, true);\n\n        await workers.assignDocWorker(\"Hello\");\n        await workers.assignDocWorker(\"Hello2\");\n\n        tmpDir = await createTmpDir();\n\n        let externalStorageCreate:\n        (purpose: \"doc\" | \"meta\" | \"attachments\", extraPrefix: string) => ExternalStorage | undefined;\n        function requireStorage<T>(storage: T | undefined): T {\n          if (storage === undefined) { throw new Error(\"storage not found\"); }\n          return storage;\n        }\n        switch (storage) {\n          case \"cached\": {\n            // Make an in-memory store that is slow and aggressively cached.\n            // This tickles a lot of cases that occasionally happen with s3.\n            let ext: ExternalStorage = new SimpleExternalStorage(\"bucket\");\n            ext = new CachedExternalStorage(ext, 1000);\n            ext = new SlowExternalStorage(ext, 250);\n            // Everything is stored in fields of these objects, so the tests mustn't recreate them repeatedly.\n            externalStorageCreate = purpose => wrapWithKeyMappedStorage(ext, { purpose, basePrefix: \"prefix\" });\n            break;\n          }\n          case \"azure\":\n            if (!process.env.AZURE_STORAGE_CONNECTION_STRING) {\n              this.skip();\n            }\n            externalStorageCreate = requireStorage(create.getStorageOptions?.(\"azure\")?.create);\n            break;\n          case \"minio\":\n            if (!process.env.GRIST_DOCS_MINIO_ACCESS_KEY) {\n              this.skip();\n            }\n            externalStorageCreate = requireStorage(create.getStorageOptions?.(\"minio\")?.create);\n            break;\n          case \"s3\":\n            if (!process.env.TEST_S3_BUCKET) {\n              this.skip();\n            }\n            externalStorageCreate = requireStorage(create.getStorageOptions?.(\"s3\")?.create);\n            break;\n        }\n        store = new TestStore(tmpDir, workerId, workers, externalStorageCreate);\n      });\n\n      after(async function() {\n        await store?.storageManager.testStopOperations();\n        await workers?.removeWorker(workerId);\n        await cli?.quitAsync();\n      });\n\n      beforeEach(function() {\n        sandbox.spy(HostedStorageManager.prototype, \"markAsChanged\");\n      });\n\n      afterEach(async function() {\n        oldEnv.restore();\n        sandbox.restore();\n        if (store) {\n          await store.end();\n          await store.removeAll();\n        }\n      });\n\n      async function getRedisChecksum(docId: string): Promise<string> {\n        return (await cli.getAsync(`doc-${docId}-checksum`)) || \"\";\n      }\n\n      async function setRedisChecksum(docId: string, checksum: string): Promise<\"OK\"> {\n        return cli.setAsync(`doc-${docId}-checksum`, checksum);\n      }\n\n      async function dropAllChecksums() {\n        // `keys` is a potentially slow, unrecommended operation - but ok in test scenario\n        // against a test instance of redis.\n        for (const key of await cli.keysAsync(\"*-checksum\")) {\n          await cli.delAsync(key);\n        }\n      }\n\n      it(\"can create a fresh empty document\", async function() {\n        const docId = `create-${uuidv4()}`;\n        await workers.assignDocWorker(docId);\n        assert.equal(await getRedisChecksum(docId), \"null\");\n\n        // Create an empty document when checksum in redis is 'null'.\n        const checksum = await store.run(async () => {\n          await store.docManager.fetchDoc(docSession, docId);\n          assert(await store.waitForUpdates());\n          const checksum = await getRedisChecksum(docId);\n          assert.notEqual(checksum, \"null\");\n          return checksum;\n        });\n\n        // Check what happens when we nobble the expected checksum.\n        await setRedisChecksum(docId, \"nobble\");\n        await store.removeAll();\n\n        const warnSpy = sandbox.spy(log, \"warn\");\n        await store.run(async () => {\n          await assert.isFulfilled(store.docManager.fetchDoc(docSession, docId));\n          assert.isTrue(warnSpy.calledWithMatch(\"has wrong checksum\"), \"a warning should have been logged\");\n        });\n        warnSpy.restore();\n\n        // Check we get the document back on fresh start if checksum is correct.\n        await setRedisChecksum(docId, checksum);\n        await store.removeAll();\n        await store.run(async () => {\n          await store.docManager.fetchDoc(docSession, docId);\n        });\n      });\n\n      it(\"can save modifications\", async function() {\n        await store.run(async () => {\n          await workers.assignDocWorker(\"World\");\n          await useFixtureDoc(\"World.grist\", store.storageManager);\n\n          await workers.assignDocWorker(\"Hello2\");\n\n          const doc = await store.docManager.fetchDoc(docSession, \"World\");\n          const doc2 = await store.docManager.fetchDoc(docSession, \"Hello2\");\n          await doc.docStorage.exec(\"update Table1 set a = 'magic_word' where id = 1\");\n          await doc2.docStorage.exec(\"insert into Table1(id) values(42)\");\n          return { doc, doc2 };\n        });\n\n        await store.removeAll();\n\n        await store.run(async () => {\n          const doc = await store.docManager.fetchDoc(docSession, \"World\");\n          let result = await doc.docStorage.get(\"select * from Table1 where id = 1\");\n          assert.equal(result!.a, \"magic_word\");\n          const doc2 = await store.docManager.fetchDoc(docSession, \"Hello2\");\n          result = await doc2.docStorage.get(\"select id from Table1\");\n          assert.equal(result!.id, 42);\n        });\n      });\n\n      it(\"can save modifications with interfering backup file\", async function() {\n        await store.run(async () => {\n          // There was a bug where if a corrupt/truncated backup file was created, all future\n          // backups would fail.  This tickles the condition and makes sure backups now succeed.\n          await fse.writeFile(path.join(tmpDir, \"Hello.grist-backup\"), \"not a sqlite file\");\n\n          await workers.assignDocWorker(\"Hello\");\n          await useFixtureDoc(\"Hello.grist\", store.storageManager);\n\n          const doc = await store.docManager.fetchDoc(docSession, \"Hello\");\n          await doc.docStorage.exec(\"update Table1 set A = 'magic_word2' where id = 1\");\n        });\n\n        // S3 should have happened after store.run()\n\n        await store.removeAll();\n        await store.run(async () => {\n          const doc = await store.docManager.fetchDoc(docSession, \"Hello\");\n          const result = await doc.docStorage.get(\"select A from Table1 where id = 1\");\n          assert.equal(result!.A, \"magic_word2\");\n        });\n      });\n\n      it(\"survives if there is a doc marked dirty that turns out to be clean\", async function() {\n        await store.run(async () => {\n          await workers.assignDocWorker(\"Hello\");\n          await useFixtureDoc(\"Hello.grist\", store.storageManager);\n\n          const doc = await store.docManager.fetchDoc(docSession, \"Hello\");\n          await doc.docStorage.exec(\"update Table1 set A = 'magic_word' where id = 1\");\n        });\n\n        await store.removeAll();\n\n        await store.run(async () => {\n          const doc = await store.docManager.fetchDoc(docSession, \"Hello\");\n          const result = await doc.docStorage.get(\"select A from Table1 where id = 1\");\n          assert.equal(result!.A, \"magic_word\");\n          store.docManager.markAsChanged(doc);\n        });\n\n        // The real test is whether this test manages to complete.\n      });\n\n      it(\"serializes parallel opening of same document\", async function() {\n        await workers.assignDocWorker(\"Hello\");\n\n        // put a doc in s3\n        await store.run(async () => {\n          await useFixtureDoc(\"Hello.grist\", store.storageManager);\n          const doc = await store.docManager.fetchDoc(docSession, \"Hello\");\n          await doc.docStorage.exec(\"update Table1 set A = 'parallel' where id = 1\");\n        });\n\n        // now open it many times in parallel\n        await store.removeAll();\n        await store.run(async () => {\n          const docs = Promise.all([\n            store.docManager.fetchDoc(docSession, \"Hello\"),\n            store.docManager.fetchDoc(docSession, \"Hello\"),\n            store.docManager.fetchDoc(docSession, \"Hello\"),\n            store.docManager.fetchDoc(docSession, \"Hello\"),\n          ]);\n          await assert.isFulfilled(docs);\n          const doc = (await docs)[0];\n          const result = await doc.docStorage.get(\"select A from Table1 where id = 1\");\n          assert.equal(result!.A, \"parallel\");\n        });\n\n        // To be sure we are checking something, let's call prepareLocalDoc directly\n        // on storage manager and make sure it fails.\n        await store.removeAll();\n        await store.run(async () => {\n          const preps = Promise.all([\n            store.storageManager.prepareLocalDoc(\"Hello\"),\n            store.storageManager.prepareLocalDoc(\"Hello\"),\n            store.storageManager.prepareLocalDoc(\"Hello\"),\n            store.storageManager.prepareLocalDoc(\"Hello\"),\n          ]);\n          await assert.isRejected(preps, /in parallel/);\n        });\n      });\n\n      it(\"can delete a document\", async function() {\n        const docId = `create-${uuidv4()}`;\n        await workers.assignDocWorker(docId);\n\n        // Create a document\n        await store.run(async () => {\n          const doc = await store.docManager.fetchDoc(docSession, docId);\n          await doc.docStorage.exec(\"insert into Table1(id) values(42)\");\n        });\n\n        const docPath = store.getDocPath(docId);\n        const ext = store.storageManager.testGetExternalStorage();\n\n        // Check that the document exists on filesystem and in external store.\n        await store.run(async () => {\n          const doc = await store.docManager.fetchDoc(docSession, docId);\n          assert.equal(await fse.pathExists(docPath), true);\n          assert.equal(await fse.pathExists(docPath + \"-hash-doc\"), true);\n          await waitForIt(async () => assert.equal(await ext.exists(docId), true), 20000);\n          await doc.docStorage.exec(\"insert into Table1(id) values(43)\");\n\n          // Now delete the document, and check it no longer exists on filesystem or external store.\n          await store.docManager.deleteDoc(null, docId, true);\n          assert.equal(await fse.pathExists(docPath), false);\n          assert.equal(await fse.pathExists(docPath + \"-hash-doc\"), false);\n          assert.equal(await getRedisChecksum(docId), DELETED_TOKEN);\n          await waitForIt(async () => assert.equal(await ext.exists(docId), false), 20000);\n        });\n\n        // As far as the underlying storage is concerned it should be\n        // possible to recreate a doc with the same id after deletion.\n        // This should not happen in Grist, since in order to open a\n        // document it must exist in the db - however we'll need to watch\n        // out for caching.\n        // TODO: it could be worth tweaking fetchDoc so creation is explicit.\n        await store.run(async () => {\n          const doc = await store.docManager.fetchDoc(docSession, docId);\n          await doc.docStorage.exec(\"insert into Table1(id) values(42)\");\n        });\n\n        await store.run(async () => {\n          await store.docManager.fetchDoc(docSession, docId);\n          assert.equal(await fse.pathExists(docPath), true);\n          assert.equal(await fse.pathExists(docPath + \"-hash-doc\"), true);\n        });\n      });\n\n      it(\"individual document close is orderly\", async function() {\n        const docId = `create-${uuidv4()}`;\n        await workers.assignDocWorker(docId);\n\n        await store.run(async () => {\n          let doc = await store.docManager.fetchDoc(docSession, docId);\n          await store.closeDoc(doc);\n          const checksum1 = await getRedisChecksum(docId);\n          assert.notEqual(checksum1, \"null\");\n\n          doc = await store.docManager.fetchDoc(docSession, docId);\n          await doc.docStorage.exec(\"insert into Table1(id) values(42)\");\n\n          // Add an attachment file with no corresponding metadata. It should be deleted when shutting down.\n          await doc.docStorage.exec(\"insert into _gristsys_Files(id, ident) values(23, 'foo')\");\n          let files = await doc.docStorage.all(\"select * from _gristsys_Files\");\n          assert.isNotEmpty(files);\n\n          await store.closeDoc(doc);\n          const checksum2 = await getRedisChecksum(docId);\n          assert.notEqual(checksum1, checksum2);\n\n          doc = await store.docManager.fetchDoc(docSession, docId);\n          await doc.docStorage.exec(\"insert into Table1(id) values(43)\");\n\n          // Attachment file should have been deleted on previous close.\n          files = await doc.docStorage.all(\"select * from _gristsys_Files\");\n          assert.isEmpty(files);\n\n          const asyncClose = store.closeDoc(doc);  // this time, don't explicitly wait for closeDoc.\n          doc = await store.docManager.fetchDoc(docSession, docId);\n          const checksum3 = await getRedisChecksum(docId);\n          assert.notEqual(checksum2, checksum3);\n          await asyncClose;\n        });\n      });\n\n      // Viewing a document should not mark it as changed (unless a document-level migration\n      // needed to run).\n      it(\"viewing a document does not generally change it\", async function() {\n        const docId = `create-${uuidv4()}`;\n        await workers.assignDocWorker(docId);\n\n        await store.run(async () => {\n          const markAsChanged: { callCount: number } = store.storageManager.markAsChanged as any;\n\n          const changesInitial = markAsChanged.callCount;\n          let doc = await store.docManager.fetchDoc(docSession, docId);\n          await doc.waitForInitialization();\n          await store.closeDoc(doc);\n          const changesAfterCreation = markAsChanged.callCount;\n          assert.isAbove(changesAfterCreation, changesInitial);\n\n          doc = await store.docManager.fetchDoc(docSession, docId);\n          await doc.waitForInitialization();\n          await store.closeDoc(doc);\n          const changesAfterViewing = markAsChanged.callCount;\n          assert.equal(changesAfterViewing, changesAfterCreation);\n        });\n      });\n\n      it(\"can fork documents\", async function() {\n        const docId = `create-${uuidv4()}`;\n        const forkId = `${docId}~fork1`;\n        await workers.assignDocWorker(docId);\n        await workers.assignDocWorker(forkId);\n\n        await store.run(async () => {\n          await useFixtureDoc(\"Hello.grist\", store.storageManager, `${docId}.grist`);\n          const doc = await store.docManager.fetchDoc(docSession, docId);\n          await doc.docStorage.exec(\"update Table1 set A = 'trunk' where id = 1\");\n        });\n\n        await store.run(async () => {\n          await store.docManager.storageManager.prepareFork(docId, forkId);\n          const doc = await store.docManager.fetchDoc(docSession, forkId);\n          assert.equal(\"trunk\", (await doc.docStorage.get(\"select A from Table1 where id = 1\"))!.A);\n          await doc.docStorage.exec(\"update Table1 set A = 'fork' where id = 1\");\n        });\n\n        await store.removeAll();\n\n        await store.run(async () => {\n          let doc = await store.docManager.fetchDoc(docSession, docId);\n          assert.equal(\"trunk\", (await doc.docStorage.get(\"select A from Table1 where id = 1\"))!.A);\n          doc = await store.docManager.fetchDoc(docSession, forkId);\n          assert.equal(\"fork\", (await doc.docStorage.get(\"select A from Table1 where id = 1\"))!.A);\n        });\n\n        // Check that the trunk can be replaced by a fork\n        await store.removeAll();\n        await store.run(async () => {\n          await store.storageManager.replace(docId, { sourceDocId: forkId });\n          const doc = await store.docManager.fetchDoc(docSession, docId);\n          assert.equal(\"fork\", (await doc.docStorage.get(\"select A from Table1 where id = 1\"))!.A);\n        });\n      });\n\n      it(\"can persist a fork with no modifications\", async function() {\n        const docId = `create-${uuidv4()}`;\n        const forkId = `${docId}~fork1`;\n        await workers.assignDocWorker(docId);\n        await workers.assignDocWorker(forkId);\n\n        // Create a document.\n        await store.run(async () => {\n          await useFixtureDoc(\"Hello.grist\", store.storageManager, `${docId}.grist`);\n          const doc = await store.docManager.fetchDoc(docSession, docId);\n          await doc.docStorage.exec(\"update Table1 set A = 'trunk' where id = 1\");\n        });\n\n        // Create a fork with no modifications.\n        await store.run(async () => {\n          await store.docManager.storageManager.prepareFork(docId, forkId);\n        });\n        await store.waitForUpdates();\n        await store.removeAll();\n\n        // Zap local copy of fork.\n        await fse.remove(store.getDocPath(docId));\n\n        // Make sure opening the fork works as expected.\n        await store.run(async () => {\n          const doc = await store.docManager.fetchDoc(docSession, forkId);\n          assert.equal(\"trunk\", (await doc.docStorage.get(\"select A from Table1 where id = 1\"))!.A);\n        });\n        await store.removeAll();\n      });\n\n      it(\"can access snapshots\", async function() {\n        // Keep number of forks less than 5 so pruning doesn't kick in.\n        const forks = 4;\n\n        const docId = `create-${uuidv4()}`;\n        const forkId1 = `${docId}~fork1`;\n        const forkId2 = `${docId}~fork2`;\n        const forkId3 = `${docId}~fork3`;\n        await workers.assignDocWorker(docId);\n        await workers.assignDocWorker(forkId1);\n        await workers.assignDocWorker(forkId2);\n        await workers.assignDocWorker(forkId3);\n\n        const doc = await store.run(async () => {\n          await useFixtureDoc(\"Hello.grist\", store.storageManager, `${docId}.grist`);\n          const doc = await store.docManager.fetchDoc(docSession, docId);\n          await doc.waitForInitialization();\n          for (let i = 0; i < forks; i++) {\n            await doc.docStorage.exec(`update Table1 set A = 'v${i}' where id = 1`);\n            await doc.testKeepOpen();\n            await store.waitForUpdates();\n          }\n          return doc;\n        });\n\n        const { snapshots } = await store.storageManager.getSnapshots(doc.docName);\n        assert.isAtLeast(snapshots.length, forks + 1);  // May be 1 greater depending on how long\n        // it takes to run initial migrations.\n        await store.run(async () => {\n          for (let i = forks - 1; i >= 0; i--) {\n            const snapshot = snapshots.shift()!;\n            const forkId = snapshot.docId;\n            await workers.assignDocWorker(forkId);\n            const doc = await store.docManager.fetchDoc(docSession, forkId);\n            assert.equal(`v${i}`, (await doc.docStorage.get(\"select A from Table1 where id = 1\"))!.A);\n          }\n        });\n      });\n\n      it(\"can access snapshots with old schema versions\", async function() {\n        const snapshotId = `World~v=1`;\n        await workers.assignDocWorker(snapshotId);\n        await store.run(async () => {\n          // Pretend we have a snapshot of World-v33.grist and fetch/load it.\n          await useFixtureDoc(\"World-v33.grist\", store.storageManager, `${snapshotId}.grist`);\n          const doc = await store.docManager.fetchDoc(docSession, snapshotId);\n\n          // Check that the snapshot isn't broken.\n          assert.doesNotThrow(async () => await doc.waitForInitialization());\n\n          // Check that the snapshot was migrated to the latest schema version.\n          assert.equal(\n            SCHEMA_VERSION,\n            (await doc.docStorage.get(\"select schemaVersion from _grist_DocInfo where id = 1\"))!.schemaVersion,\n          );\n\n          // Check that the document is actually a snapshot.\n          await assert.isRejected(doc.replace(docSession, { sourceDocId: \"docId\" }),\n            /Snapshots cannot be replaced/);\n          await assert.isRejected(doc.applyUserActions(docSession, [[\"AddTable\", \"NewTable\", [{ id: \"A\" }]]]),\n            /pyCall is not available in snapshots/);\n        });\n      });\n\n      it(\"can prune snapshots\", async function() {\n        const versions = 8;\n\n        const docId = `create-${uuidv4()}`;\n        const doc = await store.run(async () => {\n          await useFixtureDoc(\"Hello.grist\", store.storageManager, `${docId}.grist`);\n          const doc = await store.docManager.fetchDoc(docSession, docId);\n          for (let i = 0; i < versions; i++) {\n            await doc.docStorage.exec(`update Table1 set A = 'v${i}' where id = 1`);\n            await doc.testKeepOpen();\n            await store.waitForUpdates();\n          }\n          await store.storageManager.testWaitForPrunes();\n          return doc;\n        });\n        await waitForIt(async () => {\n          const { snapshots } = await store.storageManager.getSnapshots(doc.docName);\n          // Should be keeping at least five, and then maybe 1 more if the hour changed\n          // during the test.\n          assert.isAtMost(snapshots.length, 6);\n          assert.isAtLeast(snapshots.length, 5);\n        }, 20000);\n        await waitForIt(async () => {\n          // Double check with external store directly.\n          const snapshots = await store.storageManager.testGetExternalStorage().versions(doc.docName);\n          assert.isAtMost(snapshots.length, 6);\n          assert.isAtLeast(snapshots.length, 5);\n        }, 20000);\n      });\n\n      for (const wipeLocal of [false, true]) {\n        it(`can lose checksums without disruption with${wipeLocal ? \"\" : \"out\"} local file wipe`, async function() {\n          const docId = `create-${uuidv4()}`;\n          await workers.assignDocWorker(docId);\n\n          // Create a series of versions of a document, and fetch them sequentially\n          // so that they are potentially available as stale values.\n          await store.run(async () => {\n            await useFixtureDoc(\"Hello.grist\", store.storageManager, `${docId}.grist`);\n            await store.docManager.fetchDoc(docSession, docId);\n          });\n          for (let i = 0; i < 3; i++) {\n            await store.removeAll();\n            await store.run(async () => {\n              const doc = await store.docManager.fetchDoc(docSession, docId);\n              if (i > 0) {\n                const prev = await doc.docStorage.get(\"select A from Table1 where id = 1\");\n                assert.equal(prev!.A, `magic_word${i - 1}`);\n              }\n              await doc.docStorage.exec(`update Table1 set A = 'magic_word${i}' where id = 1`);\n            });\n          }\n\n          // Wipe all checksums and make sure (1) we don't get any errors and (2) the\n          // right version of the document shows up after a while.\n          let result: string | undefined;\n          await waitForIt(async () => {\n            await dropAllChecksums();\n            if (wipeLocal) {\n              // Optionally wipe all local files.\n              await store.removeAll();\n            }\n            await store.run(async () => {\n              const doc = await store.docManager.fetchDoc(docSession, docId);\n              result = (await doc.docStorage.get(\"select A from Table1 where id = 1\"))?.A;\n            });\n            if (result !== \"magic_word2\") {\n              throw new Error(`inconsistent result: ${result}`);\n            }\n          }, 20000);\n          assert.equal(result, \"magic_word2\");\n        });\n      }\n\n      it(\"can access metadata\", async function() {\n        const docId = `create-${uuidv4()}`;\n        const { tz, h, doc } = await store.run(async () => {\n          // Use a doc that's up-to-date on storage migrations, but needs a python schema migration.\n          await useFixtureDoc(\"BlobMigrationV8.grist\", store.storageManager, `${docId}.grist`);\n          const doc = await store.docManager.fetchDoc(docSession, docId);\n          await doc.waitForInitialization();\n          const rec = await doc.fetchTable(makeExceptionalDocSession(\"system\"), \"_grist_DocInfo\");\n          const tz = rec.tableData[3].timezone[0];\n          const h = (await doc.getRecentStates(makeExceptionalDocSession(\"system\")))[0].h;\n          await store.docManager.makeBackup(doc, \"hello\");\n          return { tz, h, doc };\n        });\n        const { snapshots } = await store.storageManager.getSnapshots(doc.docName);\n        assert.equal(snapshots[0]?.metadata?.label, \"hello\");\n        // There can be extra snapshots, depending on timing.\n        const prevSnapshotWithLabel = snapshots.find((s, idx) => idx > 0 && s.metadata?.label);\n        assert.match(String(prevSnapshotWithLabel?.metadata?.label), /migrate-schema/);\n        assert.equal(snapshots[0]?.metadata?.tz, String(tz));\n        assert.equal(snapshots[0]?.metadata?.h, h);\n      });\n    });\n  }\n\n  describe(\"minio-without-redis\", async () => {\n    const workerId = \"dw17\";\n    let tmpDir: string;\n    let oldEnv: EnvironmentSnapshot;\n    let docWorkerMap: IDocWorkerMap;\n    let externalStorageCreate: ExternalStorageCreator;\n    let defaultParams: ConstructorParameters<typeof HostedStorageManager>;\n    const sandbox = sinon.createSandbox();\n\n    before(async function() {\n      tmpDir = await createTmpDir();\n      oldEnv = new EnvironmentSnapshot();\n      // Disable Redis\n      delete process.env.REDIS_URL;\n\n      const storage = create?.getStorageOptions?.(\"minio\");\n      const creator = storage?.create;\n      if (!creator || !storage?.check()) {\n        return this.skip();\n      }\n      externalStorageCreate = creator;\n    });\n\n    after(async () => {\n      oldEnv.restore();\n    });\n\n    let docManager: DocManager;\n    beforeEach(async function() {\n      // With Redis disabled, this should be the non-redis version of IDocWorkerMap (DummyDocWorkerMap)\n      docWorkerMap = getDocWorkerMap();\n      await docWorkerMap.addWorker({\n        id: workerId,\n        publicUrl: \"none\",\n        internalUrl: \"none\",\n      });\n      await docWorkerMap.setWorkerAvailability(workerId, true);\n\n      const gristServer: GristServer = {\n        ...createDummyGristServer(),\n        getDocManager() { return docManager; },\n      };\n\n      defaultParams = [\n        gristServer,\n        tmpDir,\n        workerId,\n        false,\n        docWorkerMap,\n        {\n          setDocsMetadata: async (metadata) => {},\n          getDocFeatures: async docId => undefined,\n        },\n        externalStorageCreate,\n      ];\n    });\n\n    afterEach(function() {\n      sandbox.restore();\n    });\n\n    it(\"doesn't wipe local docs when they exist on disk but not remote storage\", async function() {\n      const storageManager = new HostedStorageManager(...defaultParams);\n\n      const docId = \"NewDoc\";\n\n      const path = storageManager.getPath(docId);\n      // Simulate an uploaded .grist file.\n      await fse.writeFile(path, \"\");\n\n      await storageManager.prepareLocalDoc(docId);\n\n      assert.isTrue(await fse.pathExists(path));\n    });\n\n    it(\"does not overwrite remote doc on retriable error\", async function() {\n      const testStore = new TestStore(\n        tmpDir,\n        workerId,\n        docWorkerMap,\n        externalStorageCreate,\n      );\n\n      await testStore.run(async () => {\n        const { storageManager } = testStore;\n        const docId = \"ShouldNotBeOverwritten\";\n\n        // let's create a new document and ensure it is pushed to S3.\n        let isNew = await storageManager.prepareLocalDoc(docId);\n        storageManager.markAsChanged(docId);\n        await storageManager.flushDoc(docId);\n\n        assert.isTrue(isNew, \"The document should have been created\");\n\n        // Remove the document cache so we need to fetch it from the MinIO server\n        const path = storageManager.getPath(docId);\n        await fse.remove(path);\n\n        // Let's block the access to the MinIO server with a retriable error.\n        const retriableError: any = new Error(\"Error that should be retried\");\n        retriableError.code = \"ECONNRESET\";\n        const stub = sandbox.stub(minio.Client.prototype, \"statObject\")\n          .rejects(retriableError);\n\n        let promiseIsPending = true;\n        const promise = storageManager.prepareLocalDoc(docId).finally(() => { promiseIsPending = false; });\n\n        // Wait a little bit, the promise should not be resolved\n        await setTimeout(1000);\n        assert.isTrue(promiseIsPending, \"prepareLocalDoc should still be retrying to join the MinIO server\");\n        assert.isTrue(stub.called, \"the stub should have been called preventing \" +\n        \" the external storage to access MinIO\");\n\n        // Now let's unblock the access to the MinIO server\n        stub.restore();\n        isNew = await promise;\n\n        assert.isFalse(isNew, \"prepareLocalDoc should have fetched the existing document from MinIO\");\n      });\n    });\n\n    it(\"should fail immediately (without retries) on fatal error\", async function() {\n      const testStore = new TestStore(\n        tmpDir,\n        workerId,\n        docWorkerMap,\n        externalStorageCreate,\n      );\n\n      await testStore.run(async () => {\n        const { storageManager } = testStore;\n        const docId = \"ExpectFailure\";\n\n        const fatalError = new Error(\"this is fatal\");\n        const stub = sandbox.stub(minio.Client.prototype, \"statObject\")\n          .rejects(fatalError);\n        const promise = storageManager.prepareLocalDoc(docId);\n\n        await assert.isRejected(promise, fatalError);\n        assert.equal(stub.callCount, 1, \"The stub should have been called once stopping the rest of the execution\");\n      });\n    });\n\n    it(\"fetches remote docs if they don't exist locally\", async function() {\n      const testStore = new TestStore(\n        tmpDir,\n        workerId,\n        docWorkerMap,\n        externalStorageCreate,\n      );\n\n      let docName: string = \"\";\n      let docPath: string = \"\";\n\n      await testStore.run(async () => {\n        const newDoc = await testStore.docManager.createNewEmptyDoc(docSession, \"NewRemoteDoc\");\n        docName = newDoc.docName;\n        docPath = testStore.storageManager.getPath(docName);\n      });\n\n      // This should be safe since testStore.run closes everything down.\n      await fse.remove(docPath);\n      assert.isFalse(await fse.pathExists(docPath));\n\n      await testStore.run(async () => {\n        await testStore.docManager.fetchDoc(docSession, docName);\n      });\n\n      assert.isTrue(await fse.pathExists(docPath));\n    });\n  });\n\n  // This is a performance test, to check if the backup settings are plausible.\n  describe(\"backupSqliteDatabase\", async function() {\n    async function makeDb(rows: number) {\n      const tmpDir = await createTmpDir();\n      const src = path.join(tmpDir, \"src.db\");\n      const dest = path.join(tmpDir, \"dest.db\");\n      const db = await SQLiteDB.openDBRaw(src);\n      await db.run(\"create table data(x,y,z)\");\n      await db.execTransaction(async () => {\n        const stmt = await db.prepare(\"INSERT INTO data VALUES (?,?,?)\");\n        for (let i = 0; i < rows; i++) {\n          // Silly code to make a long random string to insert.\n          // We can make a big db faster this way.\n          const str = (new Array(100)).fill(1).map((_: any) => Math.random().toString(2)).join();\n          await stmt.run(str, str, str);\n        }\n        await stmt.finalize();\n      });\n      return { src, dest, db };\n    }\n\n    // If competing with intense user writes, backups should pause writes.\n    it(`backups will make time for themselves if competing with writes`, async function() {\n      this.timeout(\"10s\");\n      for (const allowPause of [false, true] as const) {\n        const { db, src, dest } = await makeDb(1000);\n        let busy = 0;\n        let done = false;\n        function progress(event: BackupEvent) {\n          if (event.error?.includes(\"SQLITE_BUSY\")) {\n            busy++;\n          }\n          if (event.action === \"close\" && event.phase === \"after\") {\n            done = true;\n          }\n        }\n        let running = true;\n        const writerThread = (async () => {\n          while (running) {\n            // If checking without pauses, null any pause before it\n            // takes effect.\n            if (!allowPause) { db.unpause(); }\n            // Open a write transaction.\n            await db.exec(\"BEGIN IMMEDIATE\");\n            // Hold it open a long time.\n            await delay(500);\n            // Close the write transaction.\n            await db.exec(\"COMMIT\", { testIgnorePause: true });\n          }\n        })();\n        try {\n          const backup = backupSqliteDatabase(db, src, dest, progress);\n          await Promise.race([\n            backup,\n            delay(3000),\n          ]);\n          assert.equal(done, allowPause);\n          assert.isAbove(busy, 10);\n          running = false;\n          await Promise.race([\n            backup,\n            delay(3000),\n          ]);\n          assert.equal(done, true);\n        } finally {\n          running = false;\n          await writerThread;\n          await db.close();\n        }\n      }\n    });\n\n    for (const mode of [\"without-doc\", \"with-doc\", \"with-closing-doc\"] as const) {\n      it(`backups are robust to locking (${mode})`, async function() {\n        // Takes some time to create large db and play with it.\n        this.timeout(\"30s\");\n\n        const { db, src, dest } = await makeDb(30000);\n        const stat = await fse.stat(src);\n        assert(stat.size > 150 * 1000 * 1000);\n        let done: boolean = false;\n        let eventStart: number = 0;\n        let eventAction: string = \"\";\n        let eventCount: number = 0;\n        let restartCount: number = 0;\n        let slowSteps: number = 0;\n        let slowStepsTotalTime: number = 0;\n        function progress(event: BackupEvent) {\n          if (event.phase === \"after\") {\n            // Duration of backup action should never approach the default node-sqlite3 busy_timeout of 1s.\n            // If it does, then user actions could be blocked.\n            assert.equal(event.action, eventAction);\n            const dt = Date.now() - eventStart;\n            if (dt > 250) {\n              slowSteps++;\n              slowStepsTotalTime += dt;\n            }\n            eventCount++;\n          } else if (event.phase === \"before\") {\n            eventStart = Date.now();\n            eventAction = event.action;\n          } else if (event.action === \"restart\") {\n            restartCount++;\n          }\n        }\n        let backupError: Error | undefined;\n        const runBackup = (db: SQLiteDB | undefined) => retryOnClose(\n          db, err => backupError = err, () => backupSqliteDatabase(db, src, dest, progress),\n        );\n        const backup =\n          (mode === \"with-doc\" || mode === \"with-closing-doc\") ?\n            runBackup(db) :\n            runBackup(undefined);\n        const act = backup.then(() => done = true)\n          .catch((e) => { console.log(\"catch!\"); done = true; backupError = e; });\n        assert(!done);\n\n        if (mode === \"with-closing-doc\") {\n          // Wait for snapshotting to start, then close the\n          // db from under it, and see we get the expected\n          // message out.\n          for (let i = 0; i < 100; i++) {\n            await bluebird.delay(10);\n            if (eventCount > 0) {\n              // Try immediately closing the document.\n              await db.close();\n            }\n          }\n          assert.match(String(backupError), /source closed/);\n          assert.equal(done, false);\n          // Wait a while longer and see if backup terminates\n          await waitForIt(() => assert.equal(done, true), 3000, 50);\n          // That's all we can test in this test variant now we closed the db.\n          return;\n        }\n\n        // Try a series of insertions, to check that db never appears locked to us.\n        for (let i = 0; i < 100; i++) {\n          await bluebird.delay(10);\n          try {\n            await db.exec(\"INSERT INTO data VALUES (1,2,3)\");\n          } catch (e) {\n            log.error(\"insertion failed, that is bad news, the db was locked for too long\");\n            throw e;\n          }\n        }\n        assert(!done);\n\n        // Lock the db up completely for a while.\n        await db.exec(\"PRAGMA locking_mode = EXCLUSIVE\", { testIgnorePause: true });\n        await db.exec(\"BEGIN EXCLUSIVE\", { testIgnorePause: true });\n        await bluebird.delay(500);\n        await db.exec(\"COMMIT\", { testIgnorePause: true });\n        await db.exec(\"PRAGMA locking_mode = NORMAL\", { testIgnorePause: true });\n\n        assert(!done);\n        while (!done) {\n          // Make sure regular queries don't get in the way of backup completing\n          await db.all(\"select * from data limit 100\");\n          await bluebird.delay(100);\n        }\n        await act;\n        if (backupError) { throw backupError; }\n\n        // Make sure we are receiving backup events and checking their timing.\n        assert.isAbove(eventCount, 100);\n\n        // Finally, check the backup looks sane.\n        const db2 = await SQLiteDB.openDBRaw(dest);\n        assert.lengthOf(await db2.all(\"select rowid from data\"), 30000 + 100);\n\n        if (mode === \"without-doc\") {\n          // If simulating a backup not done via the connection to the source database\n          // then disruption should cause backup restart.\n          assert.isAbove(restartCount, 0);\n          // There should be no slow steps.\n          assert.equal(slowSteps, 0);\n        } else {\n          // If simulating a backup done via the connection to the source database\n          // then disruption should not cause backup restart.\n          assert.equal(restartCount, 0);\n          // There may be one slowish step at the end if a lot of edits\n          // happen during backup.\n          assert.isBelow(slowSteps, 2);\n          // For this test, slow step shouldn't be too long, though\n          // that's hardware dependent.\n          // Could exceed busy time, but that isn't a problem now we\n          // are using the same db object as the rest of Grist - any\n          // work waiting will be held just like any pair of editors\n          // competing.\n          assert.isBelow(slowStepsTotalTime, 5000);\n        }\n      });\n    }\n  });\n});\n"
  },
  {
    "path": "test/server/lib/ManyFetches.ts",
    "content": "import { GristClientSocket } from \"app/client/components/GristClientSocket\";\nimport { GristWSConnection } from \"app/client/components/GristWSConnection\";\nimport { TableFetchResult } from \"app/common/ActiveDocAPI\";\nimport { delay } from \"app/common/delay\";\nimport { UserAPIImpl } from \"app/common/UserAPI\";\nimport { cookieName } from \"app/server/lib/gristSessions\";\nimport log from \"app/server/lib/log\";\nimport { getGristConfig } from \"test/gen-server/testUtils\";\nimport { prepareDatabase } from \"test/server/lib/helpers/PrepareDatabase\";\nimport { TestServer } from \"test/server/lib/helpers/TestServer\";\nimport { createTestDir, EnvironmentSnapshot, setTmpLogLevel } from \"test/server/testUtils\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport { assert } from \"chai\";\nimport * as cookie from \"cookie\";\nimport fetch from \"node-fetch\";\n\ndescribe(\"ManyFetches\", function() {\n  this.timeout(30000);\n\n  setTmpLogLevel(\"info\");   // Set to 'info' to see what heap size actually is.\n  let oldEnv: EnvironmentSnapshot;\n\n  const userName = \"chimpy\";\n  const email = \"chimpy@getgrist.com\";\n  const org = \"docs\";\n\n  let testCounter = 0;\n  let home: TestServer;\n  let docs: TestServer;\n  let userApi: UserAPIImpl;\n\n  before(function() {\n    if (!process.env.TEST_REDIS_URL) {\n      return this.skip();\n    }\n  });\n\n  beforeEach(async function() {\n    oldEnv = new EnvironmentSnapshot();   // Needed for prepareDatabase, which changes process.env\n    log.info(\"Starting servers\");\n    const testDir = await createTestDir(`ManyFetches-${testCounter++}`);\n    await prepareDatabase(testDir);\n    home = await TestServer.startServer(\"home\", testDir, \"home\");\n    docs = await TestServer.startServer(\"docs\", testDir, \"docs\", {\n      // The test verifies memory usage by checking heap sizes. The line below limits doc-worker\n      // process so that it crashes when memory management is wrong. With fetch sizes\n      // in this test, doc-worker's heap size goes from ~110M to ~440M;\n      // this limit is in-between as another way to verify that memory management helps.\n      // Without this limit, there is no pressure on node to garbage-collect, so it may use more\n      // memory than we expect, making the test less reliable.\n      NODE_OPTIONS: \"--max-old-space-size=250\",\n    }, home.serverUrl);\n    userApi = home.makeUserApi(org, userName);\n  });\n\n  afterEach(async function() {\n    // stop all servers\n    await home.stop();\n    await docs.stop();\n    oldEnv.restore();\n  });\n\n  // Assert and log; helpful for working on the test (when setTmpLogLevel is 'info').\n  function assertIsBelow(value: number, expected: number) {\n    log.info(\"HeapMB\", value, `(expected < ${expected})`);\n    assert.isBelow(value, expected);\n  }\n\n  it(\"should limit the memory used to respond to many simultaneuous fetches\", async function() {\n    // Here we create a large document, and fetch it in parallel 200 times, without reading\n    // responses. This test relies on the fact that the server caches the fetched data, so only\n    // the serialized responses to clients are responsible for large memory use. This is the\n    // memory use limited in Client.ts by jsonMemoryPool.\n\n    // Reduce the limit controlling memory for JSON responses from the default of 500MB to 50MB.\n    await docs.testingHooks.commSetClientJsonMemoryLimits({ totalSize: 50 * 1024 * 1024 });\n\n    // Create a large document where fetches would have a noticeable memory footprint.\n    // 40k rows should produce ~2MB fetch response.\n    const { docId } = await createLargeDoc({ rows: 40_000 });\n\n    // When we get results, here's a checker that it looks reasonable.\n    function checkResults(results: TableFetchResult[]) {\n      assert.lengthOf(results, 100);\n      for (const res of results) {\n        assert.lengthOf(res.tableData[2], 40_000);\n        assert.lengthOf(res.tableData[3].Num, 40_000);\n        assert.lengthOf(res.tableData[3].Text, 40_000);\n      }\n    }\n\n    // Prepare to make N requests. For N=100, doc-worker should need ~200M of additional memory\n    // without memory management.\n    const N = 100;\n\n    // Helper to get doc-worker's heap size.\n    // If the server dies, testingHooks calls may hang. This wrapper prevents that.\n    const serverErrorPromise = docs.getExitPromise().then(() => { throw new Error(\"server exited\"); });\n    const getMemoryUsage = () => Promise.race([docs.testingHooks.getMemoryUsage(), serverErrorPromise]);\n    const getHeapMB = async () => Math.round((await getMemoryUsage() as NodeJS.MemoryUsage).heapUsed / 1024 / 1024);\n\n    assertIsBelow(await getHeapMB(), 135);\n\n    // Create all the connections, but don't make the fetches just yet.\n    const createConnectionFunc = await prepareGristWSConnection(docId);\n    const connectionsA = Array.from(Array(N), createConnectionFunc);\n    const fetchersA = await Promise.all(connectionsA.map(c => connect(c, docId)));\n\n    const connectionsB = Array.from(Array(N), createConnectionFunc);\n    const fetchersB = await Promise.all(connectionsB.map(c => connect(c, docId)));\n\n    try {\n      assertIsBelow(await getHeapMB(), 135);\n\n      // Start fetches without reading responses. This is a step that should push memory limits.\n      fetchersA.map(f => f.startPausedFetch());\n\n      // Give it a few seconds, enough for server to use what memory it can.\n      await delay(2000);\n      assertIsBelow(await getHeapMB(), 225);\n\n      // Make N more requests. See that memory hasn't spiked.\n      fetchersB.map(f => f.startPausedFetch());\n      await delay(2000);\n      assertIsBelow(await getHeapMB(), 225);\n\n      // Complete the first batch of requests. This allows for the fetches to complete, and for\n      // memory to get released. Also check that results look reasonable.\n      checkResults(await Promise.all(fetchersA.map(f => f.completeFetch())));\n\n      assertIsBelow(await getHeapMB(), 225);\n\n      // Complete the outstanding requests. Memory shouldn't spike.\n      checkResults(await Promise.all(fetchersB.map(f => f.completeFetch())));\n\n      assertIsBelow(await getHeapMB(), 225);\n    } finally {\n      fetchersA.map(f => f.end());\n      fetchersB.map(f => f.end());\n    }\n  });\n\n  it(\"should cope gracefully when client messages fail\", async function() {\n    // It used to be that sending data to the client could produce uncaught errors (in particular,\n    // for exceeding V8 JSON limits). This test case fakes errors to make sure they get handled.\n\n    // Create a document, initially empty. We'll add lots of rows later.\n    const { docId } = await createLargeDoc({ rows: 0 });\n\n    // If the server dies, testingHooks calls may hang. This wrapper prevents that.\n    const serverErrorPromise = docs.getExitPromise().then(() => { throw new Error(\"server exited\"); });\n\n    // Make a connection.\n    const createConnectionFunc = await prepareGristWSConnection(docId);\n    const connectionA = createConnectionFunc();\n    const fetcherA = await connect(connectionA, docId);\n\n    // We'll expect 20k rows, taking up about 1MB. Set a lower limit for a fake exception.\n    const prev = await docs.testingHooks.commSetClientJsonMemoryLimits({\n      jsonResponseReservation: 100 * 1024,\n      maxReservationSize: 200 * 1024,\n    });\n\n    try {\n      // Adding lots of rows will produce an action that gets sent to the connected client.\n      // We've arranged for this send to fail. Promise.race helps notice if the server exits.\n      assert.equal(connectionA.established, true);\n      await Promise.race([serverErrorPromise, addRows(docId, 20_000, 20_000)]);\n\n      // Check that the send in fact failed, and the connection did get interrupted.\n      await waitForIt(() =>\n        assert.equal(connectionA.established, false, \"Failed message should interrupt connection\"),\n      1000, 100);\n\n      // Restore limits, so that fetch works below.\n      await docs.testingHooks.commSetClientJsonMemoryLimits(prev);\n\n      // Fetch data to make sure that the \"addRows\" call itself succeeded.\n      const connectionB = createConnectionFunc();\n      const fetcherB = await connect(connectionB, docId);\n      try {\n        fetcherB.startPausedFetch();\n        const data = await Promise.race([serverErrorPromise, fetcherB.completeFetch()]);\n        assert.lengthOf(data.tableData[2], 20_000);\n        assert.lengthOf(data.tableData[3].Num, 20_000);\n        assert.lengthOf(data.tableData[3].Text, 20_000);\n      } finally {\n        fetcherB.end();\n      }\n    } finally {\n      fetcherA.end();\n    }\n  });\n\n  // Creates a document with the given number of rows, and about 50 bytes per row.\n  async function createLargeDoc({ rows}: { rows: number }): Promise<{ docId: string }> {\n    log.info(\"Preparing a doc of %s rows\", rows);\n    const ws = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    const docId = await userApi.newDoc({ name: \"testdoc\" }, ws);\n    await userApi.applyUserActions(docId, [[\"AddTable\", \"TestTable\", [\n      { id: \"Num\", type: \"Numeric\" },\n      { id: \"Text\", type: \"Text\" },\n    ]]]);\n    await addRows(docId, rows);\n    return { docId };\n  }\n\n  async function addRows(docId: string, rows: number, chunk = 10_000): Promise<void> {\n    for (let i = 0; i < rows; i += chunk) {\n      const currentNumRows = Math.min(chunk, rows - i);\n      await userApi.getDocAPI(docId).addRows(\"TestTable\", {\n        // Roughly 8 bytes per row\n        Num: Array.from(Array(currentNumRows), (_, n) => (i + n) * 100),\n        // Roughly 40 bytes per row\n        Text: Array.from(Array(currentNumRows), (_, n) => `Hello, world, again for the ${i + n}th time.`),\n      });\n    }\n  }\n\n  // Get all the info for how to create a GristWSConnection, and returns a connection-creating\n  // function.\n  async function prepareGristWSConnection(docId: string): Promise<() => GristWSConnection> {\n    // Use cookies for access to stay as close as possible to regular operation.\n    const resp = await fetch(`${home.serverUrl}/test/session`);\n    const sid = cookie.parse(resp.headers.get(\"set-cookie\"))[cookieName];\n    if (!sid) { throw new Error(\"no session available\"); }\n    await home.testingHooks.setLoginSessionProfile(sid, { name: userName, email }, org);\n\n    // Load the document html.\n    const pageUrl = `${home.serverUrl}/o/docs/doc/${docId}`;\n    const headers = { Cookie: `${cookieName}=${sid}` };\n    const doc = await fetch(pageUrl, { headers });\n    const pageBody = await doc.text();\n\n    // Pull out the configuration object embedded in the html.\n    const gristConfig = getGristConfig(pageBody);\n    const { assignmentId, getWorker, homeUrl } = gristConfig;\n    if (!homeUrl) { throw new Error(\"no homeUrl\"); }\n    if (!assignmentId) { throw new Error(\"no assignmentId\"); }\n    const docWorkerUrl = getWorker?.[assignmentId];\n    if (!docWorkerUrl) { throw new Error(\"no docWorkerUrl\"); }\n\n    // Place the config object in window.gristConfig as if we were a\n    // real browser client.  GristWSConnection expects to find it there.\n    globalThis.window = globalThis.window || {};\n    (globalThis.window as any).gristConfig = gristConfig;\n\n    // We return a function that constructs a GristWSConnection.\n    return function createConnectionFunc() {\n      let clientId: string = \"0\";\n      return GristWSConnection.create(null, {\n        makeWebSocket(url: string)  { return new GristClientSocket(url, { headers }); },\n        getTimezone()               { return Promise.resolve(\"UTC\"); },\n        getPageUrl()                { return pageUrl; },\n        getDocWorkerUrl()           { return Promise.resolve(docWorkerUrl); },\n        getClientId(did)            { return clientId; },\n        getUserSelector()           { return \"\"; },\n        updateClientId(did: string, cid: string) { clientId = cid; },\n        advanceCounter(): string    { return \"0\"; },\n        log(msg, ...args)           {},\n        warn(msg, ...args)          {},\n      });\n    };\n  }\n\n  // Actually connect GristWSConnection, open the doc, and return a few methods for next steps.\n  async function connect(connection: GristWSConnection, docId: string) {\n    async function getMessage<T>(eventType: string, filter: (msg: T) => boolean): Promise<T> {\n      return new Promise<T>((resolve) => {\n        function callback(msg: T) {\n          if (filter(msg)) { connection.off(eventType, callback); resolve(msg); }\n        }\n        connection.on(eventType, callback);\n      });\n    }\n\n    // Launch the websocket\n    const connectionPromise = getMessage(\"connectState\", (isConnected: boolean) => isConnected);\n    connection.initialize(null);\n    await connectionPromise;  // Wait for connection to succeed.\n\n    const openPromise = getMessage(\"serverMessage\", ({ reqId}: { reqId?: number }) => (reqId === 0));\n    connection.send(JSON.stringify({ reqId: 0, method: \"openDoc\", args: [docId] }));\n    await openPromise;\n\n    let fetchPromise: Promise<TableFetchResult>;\n    return {\n      startPausedFetch: () => {\n        fetchPromise = getMessage<any>(\"serverMessage\", ({ reqId}: { reqId?: number }) => (reqId === 1));\n        (connection as any)._ws.pause();\n        connection.send(JSON.stringify({ reqId: 1, method: \"fetchTable\", args: [0, \"TestTable\"] }));\n      },\n\n      completeFetch: async (): Promise<TableFetchResult> => {\n        (connection as any)._ws.resume();\n        return (await fetchPromise as any).data;\n      },\n\n      end: () => {\n        connection.dispose();\n      },\n    };\n  }\n});\n"
  },
  {
    "path": "test/server/lib/MemoryPool.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { isLongerThan } from \"app/common/gutil\";\nimport { MemoryPool } from \"app/server/lib/MemoryPool\";\n\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\nasync function isResolved(promise: Promise<unknown>): Promise<boolean> {\n  return !await isLongerThan(promise, 0);\n}\n\nasync function areResolved(...promises: Promise<unknown>[]): Promise<boolean[]> {\n  return Promise.all(promises.map(p => isResolved(p)));\n}\n\nfunction poolInfo(mpool: MemoryPool): { total: number, reserved: number, available: number, awaiters: number } {\n  return {\n    total: mpool.getTotalSize(),\n    reserved: mpool.getReservedSize(),\n    available: mpool.getAvailableSize(),\n    awaiters: mpool.numWaiting(),\n  };\n}\n\ndescribe(\"MemoryPool\", function() {\n  afterEach(() => {\n    sinon.restore();\n  });\n\n  it(\"should wait for enough space\", async function() {\n    const mpool = new MemoryPool(1000);\n    const spy = sinon.spy();\n    let r1: () => void;\n    let r2: () => void;\n    let r3: () => void;\n    let r4: () => void;\n    const w1 = new Promise<void>((r) => { r1 = r; });\n    const w2 = new Promise<void>((r) => { r2 = r; });\n    const w3 = new Promise<void>((r) => { r3 = r; });\n    const w4 = new Promise<void>((r) => { r4 = r; });\n    const p1 = mpool.withReserved(400, () => { spy(1); return w1; });\n    const p2 = mpool.withReserved(400, () => { spy(2); return w2; });\n    const p3 = mpool.withReserved(400, () => { spy(3); return w3; });\n    const p4 = mpool.withReserved(400, () => { spy(4); return w4; });\n\n    // Only two callbacks run initially.\n    await delay(10);\n    assert.deepEqual(spy.args, [[1], [2]]);\n\n    // Others are waiting for something to finish.\n    await delay(50);\n    assert.deepEqual(spy.args, [[1], [2]]);\n\n    // Once 2nd task finishes, the next one should run.\n    r2!();\n    await delay(10);\n    assert.deepEqual(spy.args, [[1], [2], [3]]);\n    await delay(50);\n    assert.deepEqual(spy.args, [[1], [2], [3]]);\n\n    // Once another task finishes, the last one should run.\n    r3!();\n    await delay(10);\n    assert.deepEqual(spy.args, [[1], [2], [3], [4]]);\n\n    // Let all tasks finish.\n    r1!();\n    r4!();\n    await delay(10);\n    assert.deepEqual(spy.args, [[1], [2], [3], [4]]);\n    await Promise.all([p1, p2, p3, p4]);\n  });\n\n  it(\"should allow adjusting reservation\", async function() {\n    const mpool = new MemoryPool(1000);\n    const res1p = mpool.waitAndReserve(600);\n    const res2p = mpool.waitAndReserve(600);\n\n    // Initially only the first reservation can happen.\n    assert.deepEqual(poolInfo(mpool), { total: 1000, reserved: 600, available: 400, awaiters: 1 });\n    assert.deepEqual(await areResolved(res1p, res2p), [true, false]);\n\n    // Once the first reservation is adjusted, the next one should go.\n    const res1 = await res1p;\n    res1.updateReservation(400);\n    assert.deepEqual(poolInfo(mpool), { total: 1000, reserved: 1000, available: 0, awaiters: 0 });\n    assert.deepEqual(await areResolved(res1p, res2p), [true, true]);\n\n    const res2 = await res2p;\n\n    // Try some more complex combinations.\n    const res3p = mpool.waitAndReserve(200);\n    const res4p = mpool.waitAndReserve(200);\n    const res5p = mpool.waitAndReserve(200);\n    assert.deepEqual(poolInfo(mpool), { total: 1000, reserved: 1000, available: 0, awaiters: 3 });\n    assert.deepEqual(await areResolved(res3p, res4p, res5p), [false, false, false]);\n\n    res1.updateReservation(100);    // 300 units freed.\n    assert.deepEqual(poolInfo(mpool), { total: 1000, reserved: 900, available: 100, awaiters: 2 });\n    assert.deepEqual(await areResolved(res3p, res4p, res5p), [true, false, false]);\n\n    res1.dispose();   // Another 100 freed.\n    assert.deepEqual(poolInfo(mpool), { total: 1000, reserved: 1000, available: 0, awaiters: 1 });\n    assert.deepEqual(await areResolved(res3p, res4p, res5p), [true, true, false]);\n\n    res2.dispose();   // Lots freed.\n    assert.deepEqual(poolInfo(mpool), { total: 1000, reserved: 600, available: 400, awaiters: 0 });\n    assert.deepEqual(await areResolved(res3p, res4p, res5p), [true, true, true]);\n\n    (await res5p).dispose();\n    (await res4p).dispose();\n    (await res3p).dispose();\n    assert.deepEqual(poolInfo(mpool), { total: 1000, reserved: 0, available: 1000, awaiters: 0 });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/MinIOExternalStorage.ts",
    "content": "import { MinIOExternalStorage } from \"app/server/lib/MinIOExternalStorage\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport * as stream from \"node:stream\";\n\nimport { assert } from \"chai\";\nimport fse from \"fs-extra\";\nimport * as minio from \"minio\";\nimport sinon from \"sinon\";\n\ndescribe(\"MinIOExternalStorage\", function() {\n  const sandbox = sinon.createSandbox();\n  const FakeClientClass = class extends minio.Client {\n    public listObjects(\n      bucket: string,\n      key: string,\n      recursive: boolean,\n      options?: { IncludeVersion?: boolean },\n    ): minio.BucketStream<minio.BucketItem> {\n      return new stream.Readable();\n    }\n  };\n  const dummyBucket = \"some-bucket\";\n  const dummyOptions = {\n    endPoint: \"some-endpoint\",\n    accessKey: \"some-accessKey\",\n    secretKey: \"some-secretKey\",\n    region: \"some-region\",\n  };\n  afterEach(function() {\n    sandbox.restore();\n  });\n\n  describe(\"upload()\", function() {\n    const filename = \"some-filename\";\n    let filestream: fse.ReadStream;\n    let s3: sinon.SinonStubbedInstance<minio.Client>;\n    let extStorage: MinIOExternalStorage;\n\n    beforeEach(function() {\n      filestream = new stream.Readable() as any;\n      sandbox.stub(fse, \"lstat\").resolves({} as any);\n      sandbox.stub(fse, \"createReadStream\").withArgs(filename).returns(filestream as any);\n      s3 = sandbox.createStubInstance(minio.Client);\n      extStorage = new MinIOExternalStorage(\n        dummyBucket,\n        dummyOptions,\n        undefined,\n        s3 as any,\n      );\n    });\n\n    it(\"should call putObject with the right arguments\", async function() {\n      const putObjectPromise = sinon.promise<Awaited<ReturnType<typeof s3.putObject>>>();\n      s3.putObject\n        .withArgs(dummyBucket, \"some-key\", filestream, undefined, undefined)\n        .returns(putObjectPromise as any);\n\n      const uploadPromise = extStorage.upload(\"some-key\", filename);\n\n      await waitForIt(() => sinon.assert.called(s3.putObject));\n      assert.isFalse(filestream.destroyed,\n        \"filestream should not be destroyed before putObject resolves\");\n\n      await putObjectPromise.resolve({ versionId: \"some-versionId\", etag: \"some-etag\" });\n      assert.equal(await uploadPromise, \"some-versionId\");\n\n      assert.isTrue(filestream.destroyed,\n        \"filestream should be destroyed after putObject resolves\");\n    });\n\n    it(\"should close the file even if putObject fails\", async function() {\n      s3.putObject.rejects(new Error(\"some-error\"));\n\n      await assert.isRejected(extStorage.upload(\"some-key\", filename), \"some-error\");\n\n      assert.isTrue(filestream.destroyed);\n    });\n  });\n\n  describe(\"versions()\", function() {\n    function makeFakeStream(listedObjects: object[]) {\n      const fakeStream = new stream.Readable({ objectMode: true });\n      const readSpy = sandbox.stub(fakeStream, \"_read\");\n      for (const [index, obj] of listedObjects.entries()) {\n        readSpy.onCall(index).callsFake(() => fakeStream.push(obj));\n      }\n      readSpy.onCall(listedObjects.length).callsFake(() => fakeStream.push(null));\n      return { fakeStream, readSpy };\n    }\n\n    it(\"should call listObjects with the right arguments\", async function() {\n      const s3 = sandbox.createStubInstance(FakeClientClass);\n      const key = \"some-key\";\n      const expectedRecursive = false;\n      const expectedOptions = { IncludeVersion: true };\n      const { fakeStream } = makeFakeStream([]);\n\n      s3.listObjects.returns(fakeStream);\n\n      const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);\n      const result = await extStorage.versions(key);\n\n      assert.deepEqual(result, []);\n      assert.isTrue(s3.listObjects.calledWith(dummyBucket, key, expectedRecursive, expectedOptions));\n    });\n\n    // This test can be removed once this PR is merged: https://github.com/minio/minio-js/pull/1193\n    // and when the minio-js version used as a dependency includes that patch.\n    //\n    // For more context: https://github.com/gristlabs/grist-core/pull/577\n    it(\"should return versionId's as string when return snapshotId is an integer\", async function() {\n      // given\n      const s3 = sandbox.createStubInstance(FakeClientClass);\n      const key = \"some-key\";\n      const versionId = 123;\n      const lastModified = new Date();\n      const { fakeStream, readSpy } = makeFakeStream([\n        {\n          name: key,\n          lastModified,\n          versionId,\n        },\n      ]);\n\n      s3.listObjects.returns(fakeStream);\n      const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);\n      // when\n      const result = await extStorage.versions(key);\n      // then\n      assert.equal(readSpy.callCount, 2);\n      assert.deepEqual(result, [{\n        lastModified: lastModified.toISOString(),\n        snapshotId: String(versionId),\n      }]);\n    });\n\n    it(\"should include markers only when asked through options\", async function() {\n      // given\n      const s3 = sandbox.createStubInstance(FakeClientClass);\n      const key = \"some-key\";\n      const lastModified = new Date();\n      const objectsFromS3 = [\n        {\n          name: key,\n          lastModified,\n          versionId: \"regular-version-uuid\",\n          isDeleteMarker: false,\n        },\n        {\n          name: key,\n          lastModified,\n          versionId: \"delete-marker-version-uuid\",\n          isDeleteMarker: true,\n        },\n      ];\n      let { fakeStream } = makeFakeStream(objectsFromS3);\n\n      s3.listObjects.returns(fakeStream);\n      const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);\n\n      // when\n      const result = await extStorage.versions(key);\n\n      // then\n      assert.deepEqual(result, [{\n        lastModified: lastModified.toISOString(),\n        snapshotId: objectsFromS3[0].versionId,\n      }]);\n\n      // given\n      fakeStream = makeFakeStream(objectsFromS3).fakeStream;\n      s3.listObjects.returns(fakeStream);\n\n      // when\n      const resultWithDeleteMarkers = await extStorage.versions(key, { includeDeleteMarkers: true });\n\n      // then\n      assert.deepEqual(resultWithDeleteMarkers, [{\n        lastModified: lastModified.toISOString(),\n        snapshotId: objectsFromS3[0].versionId,\n      }, {\n        lastModified: lastModified.toISOString(),\n        snapshotId: objectsFromS3[1].versionId,\n      }]);\n    });\n\n    it(\"should reject when an error occurs while listing objects\", function() {\n      // given\n      const s3 = sandbox.createStubInstance(FakeClientClass);\n      const key = \"some-key\";\n      const fakeStream = new stream.Readable({ objectMode: true });\n      const error = new Error(\"dummy-error\");\n      sandbox.stub(fakeStream, \"_read\")\n        .returns(fakeStream as any)\n        .callsFake(() => fakeStream.emit(\"error\", error));\n      s3.listObjects.returns(fakeStream);\n      const extStorage = new MinIOExternalStorage(dummyBucket, dummyOptions, 42, s3 as any);\n\n      // when\n      const result = extStorage.versions(key);\n\n      // then\n      return assert.isRejected(result, error);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/OIDCConfig.ts",
    "content": "import { RequestWithLogin } from \"app/server/lib/Authorizer\";\nimport { SessionObj } from \"app/server/lib/BrowserSession\";\nimport log from \"app/server/lib/log\";\nimport { OIDCBuilder } from \"app/server/lib/OIDCConfig\";\nimport { agents, GristProxyAgent } from \"app/server/lib/ProxyAgent\";\nimport { SendAppPageFunction } from \"app/server/lib/sendAppPage\";\nimport { Sessions } from \"app/server/lib/Sessions\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport express from \"express\";\nimport _ from \"lodash\";\nimport { Client, custom, errors as OIDCError, generators } from \"openid-client\";\nimport Sinon from \"sinon\";\n\nconst NOOPED_SEND_APP_PAGE: SendAppPageFunction = () => Promise.resolve();\n\nclass OIDCConfigStubbed extends OIDCBuilder {\n  public static async buildWithStub(client: Client = new ClientStub().asClient()) {\n    return this.build(NOOPED_SEND_APP_PAGE, undefined, client);\n  }\n\n  public static async build(\n    sendAppPage: SendAppPageFunction,\n    config?: any,\n    clientStub?: Client,\n  ): Promise<OIDCConfigStubbed> {\n    const result = new OIDCConfigStubbed(sendAppPage, config);\n    if (clientStub) {\n      result._initClient = Sinon.spy(() => {\n        result._client = clientStub!;\n      });\n    }\n    await result.initOIDC();\n    return result;\n  }\n\n  public _initClient: Sinon.SinonSpy;\n}\n\nclass ClientStub {\n  public static FAKE_REDIRECT_URL = \"FAKE_REDIRECT_URL\";\n  public authorizationUrl = Sinon.stub().returns(ClientStub.FAKE_REDIRECT_URL);\n  public callbackParams = Sinon.stub().returns(undefined);\n  public callback = Sinon.stub().returns({});\n  public userinfo = Sinon.stub().returns(undefined);\n  public endSessionUrl = Sinon.stub().returns(undefined);\n  public issuer: {\n    metadata: {\n      end_session_endpoint: string | undefined;\n    }\n  } = {\n    metadata: {\n      end_session_endpoint: \"http://localhost:8484/logout\",\n    },\n  };\n\n  public asClient() {\n    return this as unknown as Client;\n  }\n\n  public getAuthorizationUrlStub() {\n    return this.authorizationUrl;\n  }\n}\n\ndescribe(\"OIDCConfig\", () => {\n  let oldEnv: EnvironmentSnapshot;\n  let sandbox: Sinon.SinonSandbox;\n  let logInfoStub: Sinon.SinonStub;\n  let logErrorStub: Sinon.SinonStub;\n  let logWarnStub: Sinon.SinonStub;\n  let logDebugStub: Sinon.SinonStub;\n\n  before(() => {\n    oldEnv = new EnvironmentSnapshot();\n  });\n\n  beforeEach(() => {\n    sandbox = Sinon.createSandbox();\n    logInfoStub = sandbox.stub(log, \"info\");\n    logErrorStub = sandbox.stub(log, \"error\");\n    logDebugStub = sandbox.stub(log, \"debug\");\n    logWarnStub = sandbox.stub(log, \"warn\");\n  });\n\n  afterEach(() => {\n    oldEnv.restore();\n    sandbox.restore();\n  });\n\n  function setEnvVars() {\n    // Prevent any environment variable from leaking into the test:\n    for (const envVar in process.env) {\n      if (envVar.startsWith(\"GRIST_OIDC_\")) {\n        delete process.env[envVar];\n      }\n    }\n    process.env.GRIST_OIDC_SP_HOST = \"http://localhost:8484\";\n    process.env.GRIST_OIDC_IDP_CLIENT_ID = \"client id\";\n    process.env.GRIST_OIDC_IDP_CLIENT_SECRET = \"secret\";\n    process.env.GRIST_OIDC_IDP_ISSUER = \"http://localhost:8000\";\n  }\n\n  describe(\"build\", () => {\n    function isInitializedLogCalled() {\n      return logInfoStub.calledWithExactly(`OIDCConfig: initialized with issuer ${process.env.GRIST_OIDC_IDP_ISSUER}`);\n    }\n\n    it(\"should reject when required env variables are not passed\", async () => {\n      for (const envVar of [\n        \"GRIST_OIDC_SP_HOST\",\n        \"GRIST_OIDC_IDP_ISSUER\",\n        \"GRIST_OIDC_IDP_CLIENT_ID\",\n        \"GRIST_OIDC_IDP_CLIENT_SECRET\",\n      ]) {\n        setEnvVars();\n        delete process.env[envVar];\n        const promise = OIDCConfigStubbed.build(NOOPED_SEND_APP_PAGE);\n        await assert.isRejected(promise, `missing environment variable: ${envVar}`);\n      }\n    });\n\n    it(\"should reject when the client initialization fails\", async () => {\n      setEnvVars();\n      sandbox.stub(OIDCConfigStubbed.prototype, \"_initClient\").rejects(new Error(\"client init failed\"));\n      const promise = OIDCConfigStubbed.build(NOOPED_SEND_APP_PAGE);\n      await assert.isRejected(promise, \"client init failed\");\n    });\n\n    it(\"should create a client with passed information\", async () => {\n      setEnvVars();\n      const config = await OIDCConfigStubbed.buildWithStub();\n      assert.isTrue(config._initClient.calledOnce);\n      assert.deepEqual(config._initClient.firstCall.args, [{\n        clientId: process.env.GRIST_OIDC_IDP_CLIENT_ID,\n        clientSecret: process.env.GRIST_OIDC_IDP_CLIENT_SECRET,\n        issuerUrl: process.env.GRIST_OIDC_IDP_ISSUER,\n        extraMetadata: {},\n      }]);\n\n      assert.isTrue(isInitializedLogCalled());\n    });\n\n    it(\"should create a client with passed information with extra configuration\", async () => {\n      setEnvVars();\n      const extraMetadata = {\n        userinfo_signed_response_alg: \"RS256\",\n      };\n      process.env.GRIST_OIDC_IDP_EXTRA_CLIENT_METADATA = JSON.stringify(extraMetadata);\n      const config = await OIDCConfigStubbed.buildWithStub();\n      assert.isTrue(config._initClient.calledOnce);\n      assert.deepEqual(config._initClient.firstCall.args, [{\n        clientId: process.env.GRIST_OIDC_IDP_CLIENT_ID,\n        clientSecret: process.env.GRIST_OIDC_IDP_CLIENT_SECRET,\n        issuerUrl: process.env.GRIST_OIDC_IDP_ISSUER,\n        extraMetadata,\n      }]);\n    });\n\n    describe(\"End Session Endpoint\", () => {\n      [\n        {\n          itMsg: \"should fulfill when the end_session_endpoint is not known \" +\n            \"and GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true\",\n          end_session_endpoint: undefined,\n          env: {\n            GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT: \"true\",\n          },\n        },\n        {\n          itMsg: \"should fulfill when the end_session_endpoint is provided with GRIST_OIDC_IDP_END_SESSION_ENDPOINT\",\n          end_session_endpoint: undefined,\n          env: {\n            GRIST_OIDC_IDP_END_SESSION_ENDPOINT: \"http://localhost:8484/logout\",\n          },\n        },\n        {\n          itMsg: \"should fulfill when the end_session_endpoint is provided with the issuer\",\n          end_session_endpoint: \"http://localhost:8484/logout\",\n        },\n        {\n          itMsg: \"should reject when the end_session_endpoint is not known\",\n          errorMsg: /If that is expected, please set GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT/,\n          end_session_endpoint: undefined,\n        },\n      ].forEach((ctx) => {\n        it(ctx.itMsg, async () => {\n          setEnvVars();\n          Object.assign(process.env, ctx.env);\n          const client = new ClientStub();\n          client.issuer.metadata.end_session_endpoint = ctx.end_session_endpoint;\n          const promise = OIDCConfigStubbed.buildWithStub(client.asClient());\n          if (ctx.errorMsg) {\n            await assert.isRejected(promise, ctx.errorMsg);\n            assert.isFalse(isInitializedLogCalled());\n          } else {\n            await assert.isFulfilled(promise);\n            assert.isTrue(isInitializedLogCalled());\n          }\n        });\n      });\n    });\n\n    describe(\"GRIST_OIDC_SP_HTTP_TIMEOUT\", function() {\n      [\n        {\n          itMsg: \"when omitted should not override openid-client default value\",\n          expectedUserDefinedHttpOptions: { },\n        },\n        {\n          itMsg: \"should reject when the provided value is not a number\",\n          env: {\n            GRIST_OIDC_SP_HTTP_TIMEOUT: \"__NOT_A_NUMBER__\",\n          },\n          expectedErrorMsg: /__NOT_A_NUMBER__ does not look like a number/,\n        },\n        {\n          itMsg: \"should override openid-client timeout accordingly to the provided value\",\n          env: {\n            GRIST_OIDC_SP_HTTP_TIMEOUT: \"10000\",\n          },\n          shouldSetTimeout: true,\n          expectedUserDefinedHttpOptions: {\n            timeout: 10000,\n          },\n        },\n        {\n          itMsg: \"should allow disabling the timeout by having its value set to 0\",\n          env: {\n            GRIST_OIDC_SP_HTTP_TIMEOUT: \"0\",\n          },\n          expectedUserDefinedHttpOptions: {\n            timeout: 0,\n          },\n        },\n      ].forEach((ctx) => {\n        it(ctx.itMsg, async () => {\n          const setHttpOptionsDefaultsStub = sandbox.stub(custom, \"setHttpOptionsDefaults\");\n          setEnvVars();\n          Object.assign(process.env, ctx.env);\n          const promise = OIDCConfigStubbed.buildWithStub();\n          if (ctx.expectedErrorMsg) {\n            await assert.isRejected(promise, ctx.expectedErrorMsg);\n          } else {\n            await assert.isFulfilled(promise, \"initOIDC should have been fulfilled\");\n            assert.isTrue(setHttpOptionsDefaultsStub.calledOnce, \"Should have called custom.setHttpOptionsDefaults\");\n            assert.deepEqual(setHttpOptionsDefaultsStub.firstCall.args[0], ctx.expectedUserDefinedHttpOptions);\n          }\n        });\n      });\n    });\n\n    describe(\"trusted proxy\", function() {\n      const proxyURL = \"http://localhost-proxy:8080\";\n      let setHttpOptionsDefaultsStub: Sinon.SinonStub;\n      beforeEach(function() {\n        setHttpOptionsDefaultsStub = sandbox.stub(custom, \"setHttpOptionsDefaults\");\n      });\n\n      it(\"when not configured should use the default proxy\", async function() {\n        setEnvVars();\n        await OIDCConfigStubbed.buildWithStub();\n        Sinon.assert.calledOnceWithExactly(setHttpOptionsDefaultsStub, {});\n      });\n\n      it(\"when configured should use the trusted proxy\", async function() {\n        const trustedAgent = new GristProxyAgent(proxyURL);\n        sandbox.stub(agents, \"trusted\").value(trustedAgent);\n        setEnvVars();\n        await OIDCConfigStubbed.buildWithStub();\n        Sinon.assert.calledOnceWithExactly(setHttpOptionsDefaultsStub, { agent: trustedAgent });\n      });\n    });\n  });\n\n  describe(\"GRIST_OIDC_IDP_ENABLED_PROTECTIONS\", () => {\n    async function checkRejection(promise: Promise<OIDCBuilder>, actualValue: string) {\n      return assert.isRejected(\n        promise,\n        `OIDC: Invalid protection in GRIST_OIDC_IDP_ENABLED_PROTECTIONS: \"${actualValue}\". ` +\n        'Expected at least one of these values: \"STATE,NONCE,PKCE\"');\n    }\n    it(\"should reject when GRIST_OIDC_IDP_ENABLED_PROTECTIONS contains unsupported values\", async () => {\n      setEnvVars();\n      process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = \"STATE,NONCE,PKCE,invalid\";\n      const promise = OIDCBuilder.build(NOOPED_SEND_APP_PAGE);\n      await checkRejection(promise, \"invalid\");\n    });\n\n    it(\"should successfully change the supported protections\", async function() {\n      setEnvVars();\n      process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = \"NONCE\";\n      const config = await OIDCConfigStubbed.buildWithStub();\n      assert.isTrue(config.supportsProtection(\"NONCE\"));\n      assert.isFalse(config.supportsProtection(\"PKCE\"));\n      assert.isFalse(config.supportsProtection(\"STATE\"));\n    });\n\n    it(\"should reject when set to an empty string\", async function() {\n      setEnvVars();\n      process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = \"\";\n      const promise = OIDCConfigStubbed.buildWithStub();\n      await checkRejection(promise, \"\");\n    });\n\n    it('should accept to be set to \"UNPROTECTED\"', async function() {\n      setEnvVars();\n      process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = \"UNPROTECTED\";\n      const config = await OIDCConfigStubbed.buildWithStub();\n      assert.isFalse(config.supportsProtection(\"NONCE\"));\n      assert.isFalse(config.supportsProtection(\"PKCE\"));\n      assert.isFalse(config.supportsProtection(\"STATE\"));\n      assert.equal(logWarnStub.callCount, 1, \"a warning should be raised\");\n      assert.match(logWarnStub.firstCall.args[0], /with no protection/);\n    });\n\n    it('should reject when set to \"UNPROTECTED,PKCE\"', async function() {\n      setEnvVars();\n      process.env.GRIST_OIDC_IDP_ENABLED_PROTECTIONS = \"UNPROTECTED,PKCE\";\n      const promise = OIDCConfigStubbed.buildWithStub();\n      await checkRejection(promise, \"UNPROTECTED\");\n    });\n\n    it('if omitted, should default to \"STATE,PKCE\"', async function() {\n      setEnvVars();\n      const config = await OIDCConfigStubbed.buildWithStub();\n      assert.isFalse(config.supportsProtection(\"NONCE\"));\n      assert.isTrue(config.supportsProtection(\"PKCE\"));\n      assert.isTrue(config.supportsProtection(\"STATE\"));\n    });\n  });\n\n  describe(\"getLoginRedirectUrl\", () => {\n    const FAKE_NONCE = \"fake-nonce\";\n    const FAKE_STATE = \"fake-state\";\n    const FAKE_CODE_VERIFIER = \"fake-code-verifier\";\n    const FAKE_CODE_CHALLENGE = \"fake-code-challenge\";\n    const TARGET_URL = \"http://localhost:8484/\";\n\n    beforeEach(() => {\n      sandbox.stub(generators, \"nonce\").returns(FAKE_NONCE);\n      sandbox.stub(generators, \"state\").returns(FAKE_STATE);\n      sandbox.stub(generators, \"codeVerifier\").returns(FAKE_CODE_VERIFIER);\n      sandbox.stub(generators, \"codeChallenge\").returns(FAKE_CODE_CHALLENGE);\n    });\n\n    [\n      {\n        itMsg: \"should forge the url with default values\",\n        expectedCalledWith: [{\n          scope: \"openid email profile\",\n          acr_values: undefined,\n          code_challenge: FAKE_CODE_CHALLENGE,\n          code_challenge_method: \"S256\",\n          state: FAKE_STATE,\n        }],\n        expectedSession: {\n          oidc: {\n            code_verifier: FAKE_CODE_VERIFIER,\n            state: FAKE_STATE,\n            targetUrl: TARGET_URL,\n          },\n        },\n      },\n      {\n        itMsg: \"should forge the URL with passed GRIST_OIDC_IDP_SCOPES\",\n        env: {\n          GRIST_OIDC_IDP_SCOPES: \"my scopes\",\n        },\n        expectedCalledWith: [{\n          scope: \"my scopes\",\n          acr_values: undefined,\n          code_challenge: FAKE_CODE_CHALLENGE,\n          code_challenge_method: \"S256\",\n          state: FAKE_STATE,\n        }],\n        expectedSession: {\n          oidc: {\n            code_verifier: FAKE_CODE_VERIFIER,\n            state: FAKE_STATE,\n            targetUrl: TARGET_URL,\n          },\n        },\n      },\n      {\n        itMsg: \"should pass the nonce when GRIST_OIDC_IDP_ENABLED_PROTECTIONS includes NONCE\",\n        env: {\n          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: \"STATE,NONCE,PKCE\",\n        },\n        expectedCalledWith: [{\n          scope: \"openid email profile\",\n          acr_values: undefined,\n          code_challenge: FAKE_CODE_CHALLENGE,\n          code_challenge_method: \"S256\",\n          state: FAKE_STATE,\n          nonce: FAKE_NONCE,\n        }],\n        expectedSession: {\n          oidc: {\n            code_verifier: FAKE_CODE_VERIFIER,\n            nonce: FAKE_NONCE,\n            state: FAKE_STATE,\n            targetUrl: TARGET_URL,\n          },\n        },\n      },\n      {\n        itMsg: \"should not pass the code_challenge when PKCE is omitted in GRIST_OIDC_IDP_ENABLED_PROTECTIONS\",\n        env: {\n          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: \"STATE,NONCE\",\n        },\n        expectedCalledWith: [{\n          scope: \"openid email profile\",\n          acr_values: undefined,\n          state: FAKE_STATE,\n          nonce: FAKE_NONCE,\n        }],\n        expectedSession: {\n          oidc: {\n            nonce: FAKE_NONCE,\n            state: FAKE_STATE,\n            targetUrl: TARGET_URL,\n          },\n        },\n      },\n    ].forEach((ctx) => {\n      it(ctx.itMsg, async () => {\n        setEnvVars();\n        Object.assign(process.env, ctx.env);\n        const clientStub = new ClientStub();\n        const config = await OIDCConfigStubbed.buildWithStub(clientStub.asClient());\n        const session = {};\n        const req = {\n          session,\n        } as unknown as express.Request;\n        const url = await config.getLoginRedirectUrl(req, new URL(TARGET_URL));\n        assert.equal(url, ClientStub.FAKE_REDIRECT_URL);\n        assert.isTrue(clientStub.authorizationUrl.calledOnce);\n        assert.deepEqual(clientStub.authorizationUrl.firstCall.args, ctx.expectedCalledWith);\n        assert.deepEqual(session, ctx.expectedSession);\n      });\n    });\n  });\n\n  describe(\"handleCallback\", () => {\n    const FAKE_STATE = \"fake-state\";\n    const FAKE_NONCE = \"fake-nonce\";\n    const FAKE_CODE_VERIFIER = \"fake-code-verifier\";\n    const FAKE_USER_INFO = {\n      email: \"fake-email\",\n      name: \"fake-name\",\n      email_verified: true,\n    };\n    const DEFAULT_SESSION = {\n      oidc: {\n        code_verifier: FAKE_CODE_VERIFIER,\n        state: FAKE_STATE,\n      },\n    } as SessionObj;\n    const DEFAULT_EXPECTED_CALLBACK_CHECKS = {\n      state: FAKE_STATE,\n      code_verifier: FAKE_CODE_VERIFIER,\n    };\n    let fakeRes: {\n      status: Sinon.SinonStub;\n      send: Sinon.SinonStub;\n      redirect: Sinon.SinonStub;\n    };\n    let fakeSessions: {\n      getOrCreateSessionFromRequest: Sinon.SinonStub\n    };\n    let fakeScopedSession: {\n      operateOnScopedSession: Sinon.SinonStub\n    };\n\n    beforeEach(() => {\n      fakeRes = {\n        redirect: Sinon.stub(),\n        status: Sinon.stub().returnsThis(),\n        send: Sinon.stub().returnsThis(),\n      };\n      fakeScopedSession = {\n        operateOnScopedSession: Sinon.stub().resolves(),\n      };\n      fakeSessions = {\n        getOrCreateSessionFromRequest: Sinon.stub().returns(fakeScopedSession),\n      };\n    });\n\n    function checkUserProfile(expectedUserProfile: object) {\n      return function({ user}: { user: any }) {\n        assert.deepEqual(user.profile, expectedUserProfile,\n          `user profile should have been populated with ${JSON.stringify(expectedUserProfile)}`);\n      };\n    }\n\n    function checkRedirect(expectedRedirection: string) {\n      return function({ fakeRes}: { fakeRes: any }) {\n        assert.deepEqual(fakeRes.redirect.firstCall.args, [expectedRedirection],\n          `should have redirected to ${expectedRedirection}`);\n      };\n    }\n\n    [\n      {\n        itMsg: \"should reject when no OIDC information is present in the session\",\n        session: {},\n        expectedErrorMsg: /Missing OIDC information/,\n        extraChecks: function({ sendAppPageStub }: { sendAppPageStub: Sinon.SinonStub }) {\n          Sinon.assert.calledWith(sendAppPageStub,\n            Sinon.match.any,\n            Sinon.match.any,\n            Sinon.match.hasNested(\"config.errTargetUrl\", \"/\"));\n        },\n      },\n      {\n        itMsg: \"should resolve when the state and the code challenge are found in the session\",\n        session: DEFAULT_SESSION,\n      },\n      {\n        itMsg: \"should reject when the state is not found in the session\",\n        session: {\n          oidc: {},\n        },\n        expectedErrorMsg: /Login or logout failed to complete/,\n      },\n      {\n        itMsg: \"should resolve when the state is missing and its check has been disabled (UNPROTECTED)\",\n        session: DEFAULT_SESSION,\n        env: {\n          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: \"UNPROTECTED\",\n        },\n        expectedCbChecks: {},\n      },\n      {\n        itMsg: \"should reject when the code_verifier is missing from the session\",\n        session: {\n          oidc: {\n            state: FAKE_STATE,\n            GRIST_OIDC_IDP_ENABLED_PROTECTIONS: \"STATE,PKCE\",\n          },\n        },\n        expectedErrorMsg: /Login is stale/,\n      },\n      {\n        itMsg: \"should resolve when the code_verifier is missing and its check has been disabled\",\n        session: {\n          oidc: {\n            state: FAKE_STATE,\n            nonce: FAKE_NONCE,\n          },\n        },\n        env: {\n          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: \"STATE,NONCE\",\n        },\n        expectedCbChecks: {\n          state: FAKE_STATE,\n          nonce: FAKE_NONCE,\n        },\n      },\n      {\n        itMsg: \"should reject when nonce is missing from the session despite its check being enabled\",\n        session: DEFAULT_SESSION,\n        env: {\n          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: \"STATE,NONCE,PKCE\",\n        },\n        expectedErrorMsg: /Login is stale/,\n      }, {\n        itMsg: \"should resolve when nonce is present in the session and its check is enabled\",\n        session: {\n          oidc: {\n            state: FAKE_STATE,\n            nonce: FAKE_NONCE,\n            code_verifier: undefined,\n          },\n        },\n        env: {\n          GRIST_OIDC_IDP_ENABLED_PROTECTIONS: \"STATE,NONCE\",\n        },\n        expectedCbChecks: {\n          state: FAKE_STATE,\n          nonce: FAKE_NONCE,\n        },\n      },\n      {\n        itMsg: \"should reject when the userinfo mail is not verified\",\n        session: DEFAULT_SESSION,\n        userInfo: {\n          ...FAKE_USER_INFO,\n          email_verified: false,\n        },\n        expectedErrorMsg: /email not verified for/,\n        extraChecks: function({ sendAppPageStub }: { sendAppPageStub: Sinon.SinonStub }) {\n          assert.equal(sendAppPageStub.firstCall.lastArg.config.errMessage, \"oidc.emailNotVerifiedError\");\n        },\n      },\n      {\n        itMsg: \"should resolve when the userinfo mail is not verified but its check disabled\",\n        session: DEFAULT_SESSION,\n        userInfo: {\n          ...FAKE_USER_INFO,\n          email_verified: false,\n        },\n        env: {\n          GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED: \"true\",\n        },\n      },\n      {\n        itMsg: \"should resolve when the userinfo mail is not verified but its check disabled\",\n        session: DEFAULT_SESSION,\n        userInfo: {\n          ...FAKE_USER_INFO,\n          email_verified: false,\n        },\n        env: {\n          GRIST_OIDC_SP_IGNORE_EMAIL_VERIFIED: \"true\",\n        },\n      },\n      {\n        itMsg: \"should fill user profile with email and name\",\n        session: DEFAULT_SESSION,\n        userInfo: FAKE_USER_INFO,\n        extraChecks: checkUserProfile({\n          email: FAKE_USER_INFO.email,\n          name: FAKE_USER_INFO.name,\n          extra: {},\n        }),\n      },\n      {\n        itMsg: \"should fill user profile with name constructed using \" +\n          \"given_name and family_name when GRIST_OIDC_SP_PROFILE_NAME_ATTR is not set\",\n        session: DEFAULT_SESSION,\n        userInfo: {\n          ...FAKE_USER_INFO,\n          given_name: \"given_name\",\n          family_name: \"family_name\",\n        },\n        extrachecks: checkUserProfile({\n          email: \"fake-email\",\n          name: \"given_name family_name\",\n        }),\n      },\n      {\n        itMsg: \"should fill user profile with email and name when \" +\n          \"GRIST_OIDC_SP_PROFILE_NAME_ATTR and GRIST_OIDC_SP_PROFILE_EMAIL_ATTR are set\",\n        session: DEFAULT_SESSION,\n        userInfo: {\n          ...FAKE_USER_INFO,\n          fooMail: \"fake-email2\",\n          fooName: \"fake-name2\",\n        },\n        env: {\n          GRIST_OIDC_SP_PROFILE_NAME_ATTR: \"fooName\",\n          GRIST_OIDC_SP_PROFILE_EMAIL_ATTR: \"fooMail\",\n        },\n        extraChecks: checkUserProfile({\n          email: \"fake-email2\",\n          name: \"fake-name2\",\n          extra: {},\n        }),\n      },\n      {\n        itMsg: \"should store extra info returned by the SSO provider when the env var is set\",\n        session: DEFAULT_SESSION,\n        env: {\n          GRIST_IDP_EXTRA_PROPS: \"extrafield,anotherfield,yetanotherfield\",\n        },\n        userInfo: {\n          ...FAKE_USER_INFO,\n          extrafield: \"randomvalue\",\n          anotherfield: 12,\n        },\n        extraChecks: checkUserProfile({\n          email: \"fake-email\",\n          name: \"fake-name\",\n          extra: {\n            extrafield: \"randomvalue\",\n            anotherfield: 12,\n          },\n        }),\n      },\n      {\n        itMsg: \"should not store extra info returned by the SSO provider when the env var is not set\",\n        session: DEFAULT_SESSION,\n        userInfo: {\n          ...FAKE_USER_INFO,\n          extrafield: \"randomvalue\",\n        },\n        extraChecks: checkUserProfile({\n          email: \"fake-email\",\n          name: \"fake-name\",\n          extra: {},\n        }),\n      },\n      {\n        itMsg: \"should not store extra info returned by the SSO provider when env var does not list it\",\n        session: DEFAULT_SESSION,\n        env: {\n          GRIST_IDP_EXTRA_PROPS: \"anotherfield\",\n        },\n        userInfo: {\n          ...FAKE_USER_INFO,\n          extrafield: \"randomvalue\",\n        },\n        extraChecks: checkUserProfile({\n          email: \"fake-email\",\n          name: \"fake-name\",\n          extra: {},\n        }),\n      },\n      {\n        itMsg: \"should redirect by default to the root page\",\n        session: DEFAULT_SESSION,\n        extraChecks: checkRedirect(\"/\"),\n      },\n      {\n        itMsg: \"should redirect to the targetUrl when it is present in the session\",\n        session: {\n          oidc: {\n            ...DEFAULT_SESSION.oidc,\n            targetUrl: \"http://localhost:8484/some/path\",\n          },\n        },\n        extraChecks: checkRedirect(\"http://localhost:8484/some/path\"),\n      },\n      {\n        itMsg: \"should tell error page to use targetUrl when it is present in the session if login fails\",\n        session: {\n          oidc: {\n            ...DEFAULT_SESSION.oidc,\n            targetUrl: \"/some/path\",\n          },\n        },\n        userInfo: {\n          ...FAKE_USER_INFO,\n          email_verified: false,\n        },\n        expectedErrorMsg: /email not verified for/,\n        extraChecks: function({ sendAppPageStub }: { sendAppPageStub: Sinon.SinonStub }) {\n          Sinon.assert.calledWith(sendAppPageStub,\n            Sinon.match.any,\n            Sinon.match.any,\n            Sinon.match.hasNested(\"config.errTargetUrl\", \"/some/path\"));\n        },\n      },\n      {\n        itMsg: \"should redact confidential information in the tokenSet in the logs\",\n        session: DEFAULT_SESSION,\n        tokenSet: {\n          id_token: \"fake-id-token\",\n          access_token: \"fake-access\",\n          whatever: \"fake-whatever\",\n          token_type: \"fake-token-type\",\n          expires_at: 1234567890,\n          expires_in: 987654321,\n          scope: \"fake-scope\",\n        },\n        extraChecks: function() {\n          assert.isTrue(logDebugStub.called);\n          assert.deepEqual(logDebugStub.firstCall.args, [\n            \"Got tokenSet: %o\", {\n              id_token: \"REDACTED\",\n              access_token: \"REDACTED\",\n              whatever: \"REDACTED\",\n              token_type: this.tokenSet.token_type,\n              expires_at: this.tokenSet.expires_at,\n              expires_in: this.tokenSet.expires_in,\n              scope: this.tokenSet.scope,\n            },\n          ]);\n        },\n      },\n    ].forEach((ctx) => {\n      it(ctx.itMsg, async () => {\n        setEnvVars();\n        Object.assign(process.env, ctx.env);\n        const clientStub = new ClientStub();\n        const sendAppPageStub = Sinon.stub().resolves();\n        const fakeParams = {\n          state: FAKE_STATE,\n        };\n        const config = await OIDCConfigStubbed.build(\n          sendAppPageStub as SendAppPageFunction, undefined, clientStub.asClient(),\n        );\n        const session = _.clone(ctx.session); // session is modified, so clone it\n        const req = {\n          session,\n          t: (key: string) => key,\n        } as unknown as express.Request;\n        clientStub.callbackParams.returns(fakeParams);\n        const tokenSet = { id_token: \"id_token\", ...ctx.tokenSet };\n        clientStub.callback.resolves(tokenSet);\n        clientStub.userinfo.returns(_.clone(ctx.userInfo ?? FAKE_USER_INFO));\n        const user: { profile?: object } = {};\n        fakeScopedSession.operateOnScopedSession.yields(user);\n\n        await config.handleCallback(\n          fakeSessions as unknown as Sessions,\n          req,\n          fakeRes as unknown as express.Response,\n        );\n\n        if (ctx.expectedErrorMsg) {\n          assert.isTrue(logErrorStub.calledOnce);\n          assert.match(logErrorStub.firstCall.args[0], ctx.expectedErrorMsg);\n          assert.isTrue(sendAppPageStub.calledOnceWith(req, fakeRes));\n          assert.include(sendAppPageStub.firstCall.lastArg, {\n            path: \"error.html\",\n            status: 500,\n          });\n        } else {\n          assert.isFalse(logErrorStub.called, \"no error should be logged. Got: \" + logErrorStub.firstCall?.args[0]);\n          assert.isTrue(fakeRes.redirect.calledOnce, \"should redirect\");\n          assert.isTrue(clientStub.callback.calledOnce);\n          assert.deepEqual(clientStub.callback.firstCall.args, [\n            \"http://localhost:8484/oauth2/callback\",\n            fakeParams,\n            ctx.expectedCbChecks ?? DEFAULT_EXPECTED_CALLBACK_CHECKS,\n          ]);\n          assert.deepEqual(session, {\n            oidc: {\n              idToken: tokenSet.id_token,\n            },\n          }, \"oidc info should only keep state and id_token in the session and for the logout\");\n        }\n        ctx.extraChecks?.({ fakeRes, user, sendAppPageStub });\n      });\n    });\n\n    it(\"should log err.response when userinfo fails to parse response body\", async () => {\n      // See https://github.com/panva/node-openid-client/blob/47a549cb4e36ffe2ebfe2dc9d6b69a02643cc0a9/lib/client.js#L1293\n      setEnvVars();\n      const clientStub = new ClientStub();\n      const sendAppPageStub = Sinon.stub().resolves();\n      const config = await OIDCConfigStubbed.build(\n        sendAppPageStub as SendAppPageFunction, undefined, clientStub.asClient(),\n      );\n      const req = {\n        session: DEFAULT_SESSION,\n      } as unknown as express.Request;\n      clientStub.callbackParams.returns({ state: FAKE_STATE });\n      const errorResponse = {\n        body: { property: \"response here\" },\n        statusCode: 400,\n        statusMessage: \"statusMessage\",\n      } as unknown as any;\n\n      const err = new OIDCError.OPError({ error: \"userinfo failed\" }, errorResponse);\n      clientStub.userinfo.rejects(err);\n\n      await config.handleCallback(\n        fakeSessions as unknown as Sessions,\n        req,\n        fakeRes as unknown as express.Response,\n      );\n\n      assert.equal(logErrorStub.callCount, 2, \"logErrorStub show be called twice\");\n      assert.include(logErrorStub.firstCall.args[0], err.message);\n      assert.include(logErrorStub.secondCall.args[0], \"Response received\");\n      assert.deepEqual(logErrorStub.secondCall.args[1], errorResponse);\n      assert.isTrue(sendAppPageStub.calledOnce, \"An error should have been sent\");\n    });\n  });\n\n  describe(\"getLogoutRedirectUrl\", () => {\n    const REDIRECT_URL = new URL(\"http://localhost:8484/docs/signed-out\");\n    const STABLE_LOGOUT_URL = new URL(\"http://localhost:8484/signed-out\");\n    const URL_RETURNED_BY_CLIENT = \"http://localhost:8484/logout_url_from_issuer\";\n    const ENV_VALUE_GRIST_OIDC_IDP_END_SESSION_ENDPOINT = \"http://localhost:8484/logout\";\n    const FAKE_SESSION = {\n      oidc: {\n        idToken: \"id_token\",\n      },\n    } as SessionObj;\n\n    [\n      {\n        itMsg: \"should skip the end session endpoint when GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true\",\n        env: {\n          GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT: \"true\",\n        },\n        expectedUrl: REDIRECT_URL.href,\n      }, {\n        itMsg: \"should use the GRIST_OIDC_IDP_END_SESSION_ENDPOINT when it is set\",\n        env: {\n          GRIST_OIDC_IDP_END_SESSION_ENDPOINT: ENV_VALUE_GRIST_OIDC_IDP_END_SESSION_ENDPOINT,\n        },\n        expectedUrl: ENV_VALUE_GRIST_OIDC_IDP_END_SESSION_ENDPOINT,\n      }, {\n        itMsg: \"should call the end session endpoint with the expected parameters\",\n        expectedUrl: URL_RETURNED_BY_CLIENT,\n        expectedLogoutParams: {\n          post_logout_redirect_uri: STABLE_LOGOUT_URL.href,\n          id_token_hint: FAKE_SESSION.oidc!.idToken,\n        },\n      }, {\n        itMsg: \"should call the end session endpoint with no idToken if session is missing\",\n        expectedUrl: URL_RETURNED_BY_CLIENT,\n        expectedLogoutParams: {\n          post_logout_redirect_uri: STABLE_LOGOUT_URL.href,\n          id_token_hint: undefined,\n        },\n        session: null,\n      },\n    ].forEach((ctx) => {\n      it(ctx.itMsg, async () => {\n        setEnvVars();\n        Object.assign(process.env, ctx.env);\n        const clientStub = new ClientStub();\n        clientStub.endSessionUrl.returns(URL_RETURNED_BY_CLIENT);\n        const config = await OIDCConfigStubbed.buildWithStub(clientStub.asClient());\n        const req = {\n          headers: {\n            host: STABLE_LOGOUT_URL.host,\n          },\n          session: \"session\" in ctx ? ctx.session : FAKE_SESSION,\n        } as unknown as RequestWithLogin;\n        const url = await config.getLogoutRedirectUrl(req, REDIRECT_URL);\n        assert.equal(url, ctx.expectedUrl);\n        if (ctx.expectedLogoutParams) {\n          assert.isTrue(clientStub.endSessionUrl.calledOnce);\n          assert.deepEqual(clientStub.endSessionUrl.firstCall.args, [ctx.expectedLogoutParams]);\n        }\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/OnDemandActions.ts",
    "content": "/**\n * Unittest for OnDemandActions, which translates basic UserActions into DocActions along with\n * corresponding undo actions.\n */\nimport { TableDataAction, UserAction } from \"app/common/DocActions\";\nimport { ActiveDoc, Deps } from \"app/server/lib/ActiveDoc\";\nimport { makeExceptionalDocSession } from \"app/server/lib/DocSession\";\nimport { DocStorage } from \"app/server/lib/DocStorage\";\nimport { OnDemandActions, ProcessedAction } from \"app/server/lib/OnDemandActions\";\nimport { createDocTools } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport times from \"lodash/times\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"OnDemandActions\", function() {\n  testUtils.withoutSandboxing();\n\n  // The maxSQLiteVariables test gets slower in WAL mode.\n  this.timeout(\"1m\");\n\n  // Turn off logging for this test, and restore afterwards.\n  testUtils.setTmpLogLevel(\"warn\");\n\n  const docTools = createDocTools({ persistAcrossCases: true });\n  const fakeSession = makeExceptionalDocSession(\"system\");\n  let activeDoc1: ActiveDoc;\n  let onDemandActions: OnDemandActions;\n  let docStorage: DocStorage;\n  const sandbox = sinon.createSandbox();\n\n  // Create an OnDemand table with a few rows and columns. We'll reuse it in all test cases.\n  before(async function() {\n    // The maxSQLiteVariables is a bit chonky, WAL mode tips\n    // it over the threshold.\n    sandbox.stub(Deps, \"ACTIVEDOC_TIMEOUT\").value(30);\n\n    const docName = \"docOnDemandActions\";\n    activeDoc1 = await docTools.createDoc(docName);\n    onDemandActions = (activeDoc1 as any)._onDemandActions;\n    docStorage = activeDoc1.docStorage;\n\n    const res = await activeDoc1.applyUserActions(fakeSession, [\n      [\"AddTable\", \"Foo\", [\n        { id: \"fname\",      type: \"Text\", isFormula: false },\n        { id: \"lname\",      type: \"Text\", isFormula: false },\n        { id: \"Birth_Date\", type: \"Date\", isFormula: false },\n        { id: \"age\",        type: \"Numeric\", isFormula: false },\n      ]],\n    ]);\n    const tableRef = res.retValues[0].id;\n\n    // Make the table \"on-demand\" right away.\n    await activeDoc1.applyUserActions(fakeSession, [\n      [\"UpdateRecord\", \"_grist_Tables\", tableRef, { onDemand: true }],\n    ]);\n    await activeDoc1.applyUserActions(fakeSession, [\n      [\"BulkAddRecord\", \"Foo\", initialData[2], initialData[3]]]);\n  });\n\n  after(async function() {\n    sandbox.restore();\n  });\n\n  // Initial data is used both to populate the initial data, and to verify that we get back to it\n  // after undo.\n  const initialData: TableDataAction = [\n    \"TableData\", \"Foo\", [1, 3, 4, 9], {\n      fname: [\"Aa\", \"Bb\", \"Cc\", \"Dd\"],\n      lname: [\"Xx\", \"Yy\", \"Zz\", \"Ww\"],\n      Birth_Date: [123,  null, 456,  null],\n      age: [50,   null, 40,   null],\n      manualSort: [1,    3,    4,    9],\n    }];\n\n  // Applies on-demand actions at a lower-level than ActiveDoc, so that we can get at their\n  // generated UNDO actions.\n  async function applyOnDemand(userAction: UserAction): Promise<ProcessedAction> {\n    const processed = await onDemandActions.processUserAction(userAction);\n    await docStorage.applyStoredActions(processed.stored);\n    return processed;\n  }\n\n  it(\"should create correct (Bulk)UpdateRecord\", async () => {\n    const processed1 = await applyOnDemand(\n      [\"UpdateRecord\", \"Foo\", 4, { fname: \"Clyde\", age: 45 }]);\n    const processed2 = await applyOnDemand(\n      [\"BulkUpdateRecord\", \"Foo\", [4, 9, 1],\n        { lname: [\"CX\", \"DX\", \"AX\"], Birth_Date: [678, 909, null] }]);\n\n    assert.deepEqual((await activeDoc1.fetchTable(fakeSession, \"Foo\")).tableData,\n      [\"TableData\", \"Foo\", [1, 3, 4, 9], {\n        fname: [\"Aa\", \"Bb\", \"Clyde\", \"Dd\"],\n        lname: [\"AX\", \"Yy\", \"CX\", \"DX\"],\n        Birth_Date: [null, null, 678,  909],\n        age: [50,   null, 45,   null],\n        manualSort: [1,    3,    4,    9],\n      }],\n    );\n    await docStorage.applyStoredActions(processed2.undo);\n    await docStorage.applyStoredActions(processed1.undo);\n    assert.deepEqual((await activeDoc1.fetchTable(fakeSession, \"Foo\")).tableData, initialData);\n\n    // Make sure the generated undo actions are as we expect.\n    assert.hasAllKeys(processed1.undo[0][3], [\"fname\", \"age\"]);\n    assert.sameMembers(processed1.undo[0][2] as number[], [4]);\n    assert.hasAllKeys(processed2.undo[0][3], [\"lname\", \"Birth_Date\"]);\n    assert.sameMembers(processed2.undo[0][2] as number[], [4, 9, 1]);\n  });\n\n  it(\"should create correct (Bulk)AddRecord\", async () => {\n    const processed1 = await applyOnDemand(\n      [\"AddRecord\", \"Foo\", null, { Birth_Date: 234567 }]);\n    const processed2 = await applyOnDemand(\n      [\"BulkAddRecord\", \"Foo\", [null, null], { fname: [\"Cou\", \"Gar\"] }]);\n\n    assert.deepEqual((await activeDoc1.fetchTable(fakeSession, \"Foo\")).tableData,\n      [\"TableData\", \"Foo\", [1, 3, 4, 9,       10,     11,   12], {\n        fname: [\"Aa\", \"Bb\", \"Cc\", \"Dd\", \"\",    \"Cou\", \"Gar\"],\n        lname: [\"Xx\", \"Yy\", \"Zz\", \"Ww\", \"\",     \"\",   \"\"],\n        Birth_Date: [123,  null, 456,  null, 234567, null, null],\n        age: [50,   null, 40,   null, 0,      0,    0],\n        manualSort: [1,    3,    4,    9,    10,     11,   12],\n      }],\n    );\n    await docStorage.applyStoredActions(processed2.undo);\n    await docStorage.applyStoredActions(processed1.undo);\n    assert.deepEqual(await activeDoc1.fetchTable(fakeSession, \"Foo\"),\n      { tableData: initialData });\n  });\n\n  it(\"should create correct (Bulk)RemoveRecord\", async () => {\n    const processed1 = await applyOnDemand([\"RemoveRecord\", \"Foo\", 4]);\n    const processed2 = await applyOnDemand([\"BulkRemoveRecord\", \"Foo\", [9, 1]]);\n\n    assert.deepEqual((await activeDoc1.fetchTable(fakeSession, \"Foo\")).tableData,\n      [\"TableData\", \"Foo\", [3], {\n        fname: [\"Bb\"],\n        lname: [\"Yy\"],\n        Birth_Date: [null],\n        age: [null],\n        manualSort: [3],\n      }],\n    );\n    await docStorage.applyStoredActions(processed2.undo);\n    await docStorage.applyStoredActions(processed1.undo);\n    assert.deepEqual(await activeDoc1.fetchTable(fakeSession, \"Foo\"),\n      { tableData: initialData });\n  });\n\n  it(\"should handle actions bigger than maxSQLiteVariables\", async function() {\n    const N = 1723;\n    const processed1 = await applyOnDemand(\n      [\"BulkAddRecord\", \"Foo\", times(N, i => null), {}]);\n    const processed2 = await applyOnDemand(\n      [\"BulkUpdateRecord\", \"Foo\", times(N, i => 10 + i), { age: times(N, i => i * 10) }]);\n\n    const intermediate: TableDataAction = [\n      \"TableData\", \"Foo\", [1, 3, 4, 9].concat(times(N, i => 10 + i)), {\n        fname: [\"Aa\", \"Bb\", \"Cc\", \"Dd\"].concat(times(N, i => \"\")),\n        lname: [\"Xx\", \"Yy\", \"Zz\", \"Ww\"].concat(times(N, i => \"\")),\n        Birth_Date: [123,  null, 456,  null].concat(times(N, i => null)),\n        age: [50,   null, 40,   null].concat(times(N, i => i * 10)),\n        manualSort: [1,    3,    4,    9].concat(times(N, i => 10 + i)),\n      }];\n\n    assert.deepEqual(await activeDoc1.fetchTable(fakeSession, \"Foo\"),\n      { tableData: intermediate });\n\n    const processed3 = await applyOnDemand(\n      [\"BulkRemoveRecord\", \"Foo\", times(N, i => 10 + i)]);\n\n    assert.deepEqual(await activeDoc1.fetchTable(fakeSession, \"Foo\"),\n      { tableData: initialData });\n\n    await docStorage.applyStoredActions(processed3.undo);\n    assert.deepEqual(await activeDoc1.fetchTable(fakeSession, \"Foo\"),\n      { tableData: intermediate });\n\n    await docStorage.applyStoredActions(processed2.undo);\n    await docStorage.applyStoredActions(processed1.undo);\n\n    assert.deepEqual(await activeDoc1.fetchTable(fakeSession, \"Foo\"),\n      { tableData: initialData });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/OpenAIAssistantV1.ts",
    "content": "import { AssistanceState } from \"app/common/Assistance\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { configureOpenAIAssistantV1 } from \"app/server/lib/configureOpenAIAssistantV1\";\nimport { OptDocSession } from \"app/server/lib/DocSession\";\nimport { AssistantV1 } from \"app/server/lib/IAssistant\";\nimport { DEPS, OpenAIAssistantV1 } from \"app/server/lib/OpenAIAssistantV1\";\nimport { GristProxyAgent } from \"app/server/lib/ProxyAgent\";\nimport { createDocTools } from \"test/server/docTools\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport chai from \"chai\";\nimport chaiAsPromised from \"chai-as-promised\";\nimport { Response } from \"node-fetch\";\nimport * as sinon from \"sinon\";\n\n// For some reason, assert.isRejected is not getting defined,\n// though test/chai-as-promised.js should be taking care of this.\n// So test/chai-as-promised.js is just repeated here.\nchai.use(chaiAsPromised);\n\n/**\n * We no longer use a longer context model by default, but we still\n * test this configuration.\n */\nconst LONGER_CONTEXT_MODEL_FOR_TEST = \"fake\";\n\ndescribe(\"OpenAIAssistantV1\", function() {\n  this.timeout(10000);\n\n  const docTools = createDocTools({ persistAcrossCases: true });\n  const table1Id = \"Table1\";\n  const table2Id = \"Table2\";\n  let assistant: AssistantV1;\n  let session: OptDocSession;\n  let doc: ActiveDoc;\n  let oldEnv: EnvironmentSnapshot;\n  before(async () => {\n    oldEnv = new EnvironmentSnapshot();\n    process.env.OPENAI_API_KEY = \"fake\";\n    process.env.ASSISTANT_LONGER_CONTEXT_MODEL = LONGER_CONTEXT_MODEL_FOR_TEST;\n    const openAIAssistant = configureOpenAIAssistantV1();\n    if (!openAIAssistant) {\n      throw new Error(\"no assistant\");\n    }\n    assistant = openAIAssistant;\n    session = docTools.createFakeSession();\n    doc = await docTools.createDoc(\"test.grist\");\n    await doc.applyUserActions(session, [\n      [\"AddTable\", table1Id, [{ id: \"A\" }, { id: \"B\" }, { id: \"C\" }]],\n      [\"AddTable\", table2Id, [{ id: \"A\" }, { id: \"B\" }, { id: \"C\" }]],\n    ]);\n  });\n  after(async function() {\n    oldEnv.restore();\n  });\n\n  const colId = \"C\";\n  const userMessageContent = \"Sum of A and B\";\n\n  function checkGetAssistance(state?: AssistanceState) {\n    return assistant.getAssistance(session, doc, {\n      conversationId: \"conversationId\",\n      context: { tableId: table1Id, colId },\n      state,\n      text: userMessageContent,\n    });\n  }\n\n  let fakeResponse: () => any;\n  let fakeFetch: sinon.SinonSpy;\n\n  beforeEach(() => {\n    fakeFetch = sinon.fake(() => {\n      const body = fakeResponse();\n      return new Response(\n        JSON.stringify(body),\n        { status: body.status },\n      );\n    });\n    sinon.replace(DEPS, \"fetch\", fakeFetch as any);\n    sinon.replace(DEPS, \"delayTime\", 1);\n  });\n\n  afterEach(function() {\n    sinon.restore();\n  });\n\n  function checkModels(expectedModels: string[]) {\n    assert.deepEqual(\n      fakeFetch.getCalls().map(call => JSON.parse(call.args[1].body).model),\n      expectedModels,\n    );\n  }\n\n  it(\"can suggest a formula\", async function() {\n    const reply = \"Here's a formula that adds columns A and B:\\n\\n\" +\n      \"```python\\na = int(rec.A)\\nb=int(rec.B)\\n\\nreturn str(a + b)\\n```\" +\n      \"\\n\\nLet me know if there's anything else I can help with.\";\n    const replyMessage = { role: \"assistant\", content: reply };\n\n    fakeResponse = () => ({\n      choices: [{\n        index: 0,\n        message: replyMessage,\n        finish_reason: \"stop\",\n      }],\n      status: 200,\n    });\n    const result = await checkGetAssistance();\n    checkModels([OpenAIAssistantV1.DEFAULT_MODEL]);\n    const callInfo = fakeFetch.getCall(0);\n    const [url, request] = callInfo.args;\n    assert.equal(url, \"https://api.openai.com/v1/chat/completions\");\n    assert.equal(request.method, \"POST\");\n    const { messages: requestMessages } = JSON.parse(request.body);\n    const systemMessageContent = requestMessages[0].content;\n    assert.match(systemMessageContent, /def C\\(rec: Table1\\)/);\n    assert.deepEqual(requestMessages, [\n      {\n        role: \"system\",\n        content: systemMessageContent,\n      },\n      {\n        role: \"user\",\n        content: userMessageContent,\n      },\n    ],\n    );\n    const suggestedFormula = \"a = int($A)\\nb=int($B)\\n\\nstr(a + b)\";\n    const replyWithSuggestedFormula = \"Here's a formula that adds columns A and B:\\n\\n\" +\n      \"```python\\na = int($A)\\nb=int($B)\\n\\nstr(a + b)\\n```\" +\n      \"\\n\\nLet me know if there's anything else I can help with.\";\n    assert.deepEqual(result, {\n      suggestedActions: [\n        [\"ModifyColumn\", table1Id, colId, { formula: suggestedFormula }],\n      ],\n      suggestedFormula,\n      reply: replyWithSuggestedFormula,\n      state: {\n        messages: [...requestMessages, replyMessage],\n      },\n    },\n    );\n  });\n\n  it(\"does not use the trusted proxy when not configured\", async function() {\n    const agentsFake = { trusted: undefined, untrusted: undefined };\n    sinon.replace(DEPS, \"agents\", agentsFake);\n    await checkGetAssistance();\n    checkModels([OpenAIAssistantV1.DEFAULT_MODEL]);\n    const callInfo = fakeFetch.getCall(0);\n    const [url, request] = callInfo.args;\n    assert.equal(url, \"https://api.openai.com/v1/chat/completions\");\n    assert.equal(request.method, \"POST\");\n    assert.isUndefined(request.agent);\n  });\n\n  it(\"uses trusted proxy when configured\", async function() {\n    const proxyURL = \"http://localhost-proxy:8080\";\n    process.env.HTTPS_PROXY = proxyURL;\n    const trustedAgent = new GristProxyAgent(proxyURL);\n    const agentsFake = { trusted: trustedAgent, untrusted: undefined };\n    sinon.replace(DEPS, \"agents\", agentsFake);\n    await checkGetAssistance();\n    checkModels([OpenAIAssistantV1.DEFAULT_MODEL]);\n    const callInfo = fakeFetch.getCall(0);\n    const [url, request] = callInfo.args;\n    assert.equal(url, \"https://api.openai.com/v1/chat/completions\");\n    assert.equal(request.method, \"POST\");\n    assert.deepEqual(request.agent, trustedAgent);\n  });\n\n  it(\"does not suggest anything if formula is invalid\", async function() {\n    const reply = \"This isn't valid Python code:\\n```python\\nclass = 'foo'\\n```\";\n    const replyMessage = {\n      role: \"assistant\",\n      content: reply,\n    };\n\n    fakeResponse = () => ({\n      choices: [{\n        index: 0,\n        message: replyMessage,\n        finish_reason: \"stop\",\n      }],\n      status: 200,\n    });\n    const result = await checkGetAssistance();\n    const callInfo = fakeFetch.getCall(0);\n    const [, request] = callInfo.args;\n    const { messages: requestMessages } = JSON.parse(request.body);\n    const suggestedFormula = undefined;\n    assert.deepEqual(result, {\n      suggestedActions: [],\n      suggestedFormula,\n      reply,\n      state: {\n        messages: [...requestMessages, replyMessage],\n      },\n    },\n    );\n  });\n\n  it(\"tries 3 times in case of network errors\", async function() {\n    fakeResponse = () => {\n      throw new Error(\"Network error\");\n    };\n    await assert.isRejected(\n      checkGetAssistance(),\n      \"Sorry, the assistant is unavailable right now. \" +\n      \"Try again in a few minutes.\\n\\n\" +\n      \"```\\n(Error: Network error)\\n```\",\n    );\n    assert.equal(fakeFetch.callCount, 3);\n  });\n\n  it(\"tries 3 times in case of bad status code\", async function() {\n    fakeResponse = () => ({ status: 500 });\n    await assert.isRejected(\n      checkGetAssistance(),\n      \"Sorry, the assistant is unavailable right now. \" +\n      \"Try again in a few minutes.\\n\\n\" +\n      '```\\n(Error: AI service provider API returned status 500: {\"status\":500})\\n```',\n    );\n    assert.equal(fakeFetch.callCount, 3);\n  });\n\n  it(\"handles exceeded billing quota\", async function() {\n    fakeResponse = () => ({\n      error: {\n        code: \"insufficient_quota\",\n      },\n      status: 429,\n    });\n    await assert.isRejected(\n      checkGetAssistance(),\n      \"Sorry, the assistant is facing some long term capacity issues. \" +\n      \"Maybe try again tomorrow.\",\n    );\n    assert.equal(fakeFetch.callCount, 1);\n  });\n\n  it(\"switches to a longer model with no retries if the prompt is too long\", async function() {\n    fakeResponse = () => ({\n      error: {\n        code: \"context_length_exceeded\",\n      },\n      status: 400,\n    });\n    await assert.isRejected(\n      checkGetAssistance(),\n      /You'll need to either shorten your message or delete some columns/,\n    );\n    checkModels([\n      OpenAIAssistantV1.DEFAULT_MODEL,\n      LONGER_CONTEXT_MODEL_FOR_TEST,\n      LONGER_CONTEXT_MODEL_FOR_TEST,\n    ]);\n  });\n\n  it(\"switches to a shorter prompt if the longer model exceeds its token limit\", async function() {\n    fakeResponse = () => ({\n      error: {\n        code: \"context_length_exceeded\",\n      },\n      status: 400,\n    });\n    await assert.isRejected(\n      checkGetAssistance(),\n      /You'll need to either shorten your message or delete some columns/,\n    );\n    fakeFetch.getCalls().map((callInfo, i) => {\n      const [, request] = callInfo.args;\n      const { messages } = JSON.parse(request.body);\n      const systemMessageContent = messages[0].content;\n      const shortCallIndex = 2;\n      if (i === shortCallIndex) {\n        assert.match(systemMessageContent, /class Table1/);\n        assert.notMatch(systemMessageContent, /class Table2/);\n        assert.notMatch(systemMessageContent, /def lookupOne/);\n        assert.lengthOf(systemMessageContent, 1001);\n      } else {\n        assert.match(systemMessageContent, /class Table1/);\n        assert.match(systemMessageContent, /class Table2/);\n        assert.match(systemMessageContent, /def lookupOne/);\n        assert.lengthOf(systemMessageContent, 1982);\n      }\n    });\n  });\n\n  it(\"switches to a longer model with no retries if the model runs out of tokens while responding\", async function() {\n    fakeResponse = () => ({\n      choices: [{\n        index: 0,\n        message: {},\n        finish_reason: \"length\",\n      }],\n      status: 200,\n    });\n    await assert.isRejected(\n      checkGetAssistance(),\n      /You'll need to either shorten your message or delete some columns/,\n    );\n    checkModels([\n      OpenAIAssistantV1.DEFAULT_MODEL,\n      LONGER_CONTEXT_MODEL_FOR_TEST,\n      LONGER_CONTEXT_MODEL_FOR_TEST,\n    ]);\n  });\n\n  it(\"suggests restarting conversation if the prompt is too long and there are past messages\", async function() {\n    fakeResponse = () => ({\n      error: {\n        code: \"context_length_exceeded\",\n      },\n      status: 400,\n    });\n    await assert.isRejected(\n      checkGetAssistance({\n        messages: [\n          { role: \"system\", content: \"Be good.\" },\n          { role: \"user\", content: \"Hi.\" },\n          { role: \"assistant\", content: \"Hi!\" },\n        ],\n      }),\n      /You'll need to either shorten your message, restart the conversation, or delete some columns/,\n    );\n    checkModels([\n      OpenAIAssistantV1.DEFAULT_MODEL,\n      LONGER_CONTEXT_MODEL_FOR_TEST,\n      LONGER_CONTEXT_MODEL_FOR_TEST,\n    ]);\n  });\n\n  it(\"can switch to a longer model, retry, and succeed\", async function() {\n    fakeResponse = () => {\n      if (fakeFetch.callCount === 1) {\n        return {\n          error: {\n            code: \"context_length_exceeded\",\n          },\n          status: 400,\n        };\n      } else if (fakeFetch.callCount === 2) {\n        return {\n          status: 500,\n        };\n      } else {\n        return {\n          choices: [{\n            index: 0,\n            message: { role: \"assistant\", content: \"123\" },\n            finish_reason: \"stop\",\n          }],\n          status: 200,\n        };\n      }\n    };\n    const result = await checkGetAssistance();\n    checkModels([\n      OpenAIAssistantV1.DEFAULT_MODEL,\n      LONGER_CONTEXT_MODEL_FOR_TEST,\n      LONGER_CONTEXT_MODEL_FOR_TEST,\n    ]);\n    assert.deepEqual(result.suggestedActions, [\n      [\"ModifyColumn\", table1Id, colId, { formula: \"123\" }],\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/Proposals.ts",
    "content": "import { DocAPI, UserAPI } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { createTmpDir } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"Proposals\", function() {\n  this.timeout(40000);\n  let server: TestServer;\n  let owner: UserAPI;\n  let wsId: number;\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  let oldLogLevel: testUtils.NestedLogLevel;\n\n  before(async function() {\n    oldLogLevel = testUtils.nestLogLevel(\"error\");\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    const tmpDir = await createTmpDir();\n    process.env.GRIST_DATA_DIR = tmpDir;\n    server = new TestServer(this);\n    await server.start([\"home\", \"docs\"]);\n    const api = await server.createHomeApi(\"chimpy\", \"docs\", true);\n    await api.newOrg({ name: \"testy\", domain: \"testy\" });\n    owner = await server.createHomeApi(\"chimpy\", \"testy\", true);\n    wsId = await owner.newWorkspace({ name: \"ws\" }, \"current\");\n  });\n\n  after(async function() {\n    const api = await server.createHomeApi(\"chimpy\", \"docs\");\n    await api.deleteOrg(\"testy\");\n    await server.stop();\n    oldEnv.restore();\n    oldLogLevel.restore();\n  });\n\n  async function testApply(options: {\n    modifyAfterProposal?: (trunkApi: DocAPI, forkApi: DocAPI) => Promise<void>,\n    testAfterApply?: (trunkApi: DocAPI, forkApi: DocAPI) => Promise<void>,\n  }) {\n    const docId = await owner.newDoc({ name: \"doc\" }, wsId);\n    const docApi = owner.getDocAPI(docId);\n    await docApi.addRows(\"Table1\", {\n      A: [\"x\", \"y\"],\n      B: [100, 200],\n    });\n    const forkResult = await docApi.fork();\n    const forkApi = owner.getDocAPI(forkResult.urlId);\n    await forkApi.updateRows(\"Table1\", {\n      id: [2],\n      A: [\"yy\"],\n    });\n    const proposal = await forkApi.makeProposal();\n    assert.equal(proposal.shortId, 1);\n    assert.equal(proposal.comparison.comparison?.summary, \"left\");\n    const changes = proposal.comparison.comparison?.details?.leftChanges;\n    assert.deepEqual(changes, {\n      tableRenames: [],\n      tableDeltas: {\n        Table1: {\n          updateRows: [2],\n          removeRows: [],\n          addRows: [],\n          columnDeltas: { A: { 2: [[\"y\"], [\"yy\"]] } },\n          columnRenames: [],\n        },\n      },\n    });\n    const query = \"select A from Table1 where id = 2\";\n    assert.deepEqual((await docApi.sql(query)).records, [\n      { fields: { A: \"y\" } },\n    ]);\n    assert.deepEqual((await forkApi.sql(query)).records, [\n      { fields: { A: \"yy\" } },\n    ]);\n    await options.modifyAfterProposal?.(docApi, forkApi);\n    await docApi.applyProposal(proposal.shortId);\n    await options.testAfterApply?.(docApi, forkApi);\n  }\n\n  it(\"can make and apply a simple proposal\", async function() {\n    await testApply({\n      async modifyAfterProposal() {},\n      async testAfterApply(trunkApi) {\n        const query = \"select A from Table1 where id = 2\";\n        assert.deepEqual((await trunkApi.sql(query)).records, [\n          { fields: { A: \"yy\" } },\n        ]);\n      },\n    });\n  });\n\n  it(\"can apply a proposal after a table rename\", async function() {\n    await testApply({\n      async modifyAfterProposal(trunkApi) {\n        await trunkApi.applyUserActions([\n          [\"RenameTable\", \"Table1\", \"Table2\"],\n        ]);\n      },\n      async testAfterApply(trunkApi) {\n        const query = \"select A from Table2 where id = 2\";\n        assert.deepEqual((await trunkApi.sql(query)).records, [\n          { fields: { A: \"yy\" } },\n        ]);\n      },\n    });\n  });\n\n  it(\"can apply a proposal after a column rename\", async function() {\n    await testApply({\n      async modifyAfterProposal(trunkApi) {\n        await trunkApi.applyUserActions([\n          [\"RenameColumn\", \"Table1\", \"A\", \"AA\"],\n        ]);\n      },\n      async testAfterApply(trunkApi) {\n        const query = \"select AA from Table1 where id = 2\";\n        assert.deepEqual((await trunkApi.sql(query)).records, [\n          { fields: { AA: \"yy\" } },\n        ]);\n      },\n    });\n  });\n\n  it(\"can apply a proposal that includes a formula column\", async function() {\n    const docId = await owner.newDoc({ name: \"doc\" }, wsId);\n    const docApi = owner.getDocAPI(docId);\n    await docApi.addRows(\"Table1\", {\n      A: [\"x\", \"y\"],\n      B: [100, 200],\n    });\n    await docApi.applyUserActions([\n      // Add a real formula column\n      [\"AddColumn\", \"Table1\", \"F\", {\n        type: \"Text\",\n        isFormula: true,\n        formula: '\"quote \" + str($A) + \" unquote\"',\n      }],\n      // Add an empty column\n      [\"AddColumn\", \"Table1\", \"E\", {\n        type: \"Any\",\n        isFormula: true,\n      }],\n    ]);\n    const forkResult = await docApi.fork();\n    const forkApi = owner.getDocAPI(forkResult.urlId);\n    await forkApi.updateRows(\"Table1\", {\n      id: [2],\n      A: [\"yy\"],\n      E: [20],\n    });\n    await forkApi.addRows(\"Table1\", {\n      A: [\"zz\"],\n    });\n    const proposal = await forkApi.makeProposal();\n    await docApi.applyProposal(proposal.shortId);\n    const query = \"select A, E, F from Table1 where id = 2 or id = 3\";\n    assert.deepEqual((await docApi.sql(query)).records, [\n      { fields: { A: \"yy\", E: 20, F: \"quote yy unquote\" } },\n      { fields: { A: \"zz\", E: 0,  F: \"quote zz unquote\" } },\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/ProxyAgent.ts",
    "content": "import log from \"app/server/lib/log\";\nimport {\n  agents, fetchUntrustedWithAgent, GristProxyAgent, test_generateProxyAgents,\n} from \"app/server/lib/ProxyAgent\";\nimport { getAvailablePort } from \"app/server/lib/serverUtils\";\nimport { serveSomething, Serving } from \"test/server/customUtil\";\nimport { TestProxyServer } from \"test/server/lib/helpers/TestProxyServer\";\nimport { assertMatchArray, captureLog, EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport sinon from \"sinon\";\n\ndescribe(\"ProxyAgent\", function() {\n  let oldEnv: EnvironmentSnapshot;\n  let warnStub: sinon.SinonStub;\n  let sandbox: sinon.SinonSandbox;\n\n  const proxyForTrustedUrlExample = \"https://localhost:9000\";\n  const proxyForUntrustedUrlExample = \"https://localhost:9001\";\n  beforeEach(function() {\n    oldEnv = new EnvironmentSnapshot();\n    sandbox = sinon.createSandbox();\n  });\n\n  afterEach(function() {\n    sandbox?.restore();\n    oldEnv.restore();\n  });\n\n  describe(\"configuration\", function() {\n    beforeEach(() => {\n      warnStub = sandbox.stub(log, \"warn\");\n    });\n\n    it(\"should create a proxy agent for trusted URLs when using https_proxy env var\", function() {\n      process.env.https_proxy = proxyForTrustedUrlExample;\n\n      const proxyAgents = test_generateProxyAgents();\n\n      assert.instanceOf(proxyAgents.trusted, GristProxyAgent);\n      assert.isUndefined(proxyAgents.untrusted);\n      sinon.assert.notCalled(warnStub);\n    });\n\n    it(\"should create a proxy agent for trusted URLs when using HTTPS_PROXY env var\", function() {\n      process.env.HTTPS_PROXY = proxyForTrustedUrlExample;\n\n      const proxyAgents = test_generateProxyAgents();\n\n      assert.instanceOf(proxyAgents.trusted, GristProxyAgent);\n      assert.isUndefined(proxyAgents.untrusted);\n      sinon.assert.notCalled(warnStub);\n    });\n\n    it(\"should create a proxy agent for untrusted URLs when using GRIST_PROXY_FOR_UNTRUSTED_URLS env var\", function() {\n      process.env.GRIST_PROXY_FOR_UNTRUSTED_URLS = proxyForUntrustedUrlExample;\n\n      const proxyAgents = test_generateProxyAgents();\n\n      assert.instanceOf(proxyAgents.untrusted, GristProxyAgent);\n      assert.isUndefined(proxyAgents.trusted);\n      sinon.assert.notCalled(warnStub);\n    });\n\n    it(\"should create both proxy agents for untrusted and trusted URLS using \" +\n      \"GRIST_PROXY_FOR_UNTRUSTED_URLS and HTTPS_PROXY\", function() {\n      process.env.GRIST_PROXY_FOR_UNTRUSTED_URLS = proxyForUntrustedUrlExample;\n      process.env.HTTPS_PROXY = proxyForTrustedUrlExample;\n\n      const proxyAgents = test_generateProxyAgents();\n\n      assert.instanceOf(proxyAgents.untrusted, GristProxyAgent);\n      assert.instanceOf(proxyAgents.trusted, GristProxyAgent);\n      sinon.assert.notCalled(warnStub);\n    });\n\n    it(\"should create a proxy agent for untrusted URLs when using GRIST_HTTPS_PROXY env var \" +\n      \"and show a deprecation message\", function() {\n      process.env.GRIST_HTTPS_PROXY = proxyForUntrustedUrlExample;\n\n      const proxyAgents = test_generateProxyAgents();\n\n      assert.instanceOf(proxyAgents.untrusted, GristProxyAgent);\n      assert.isUndefined(proxyAgents.trusted);\n      sinon.assert.calledWithMatch(\n        warnStub, /GRIST_HTTPS_PROXY.*GRIST_PROXY_FOR_UNTRUSTED_URLS=\"https:\\/\\/localhost:9001/,\n      );\n    });\n\n    it('should create no proxy agent when GRIST_PROXY_FOR_UNTRUSTED_URLS is set to \"direct\"', function() {\n      process.env.GRIST_PROXY_FOR_UNTRUSTED_URLS = \"direct\";\n\n      const proxyAgents = test_generateProxyAgents();\n\n      assert.isUndefined(proxyAgents.untrusted);\n    });\n  });\n\n  describe(\"proxy error handling\", async function() {\n    // Handling requests\n    let serving: Serving;\n    // Proxy server emulation to test possible behaviours of real life server\n    let testProxyServer: TestProxyServer;\n\n    beforeEach(async function() {\n      // Set up a server and a proxy.\n      const port = await getAvailablePort(22340);\n      testProxyServer = await TestProxyServer.Prepare(port);\n      serving = await serveSomething((app) => {\n        app.post(\"/200\", (_, res) => { res.sendStatus(200); res.end(); });\n        app.post(\"/404\", (_, res) => { res.sendStatus(404); res.end(); });\n      });\n      const proxyUrl = `http://localhost:${testProxyServer.portNumber}`;\n      process.env.GRIST_PROXY_FOR_UNTRUSTED_URLS = proxyUrl;\n      sandbox.stub(agents, \"untrusted\").value(test_generateProxyAgents().untrusted);\n    });\n\n    afterEach(async function() {\n      await serving.shutdown();\n      await testProxyServer.dispose().catch(() => {});\n    });\n\n    it(\"should not report error when proxy is working\", async function() {\n      // Normally fetch through proxy works and produces no errors, even for failing status.\n      const logMessages1 = await captureLog(\"warn\", async () => {\n        assert.equal((await fetchUntrustedWithAgent(serving.url + \"/200\")).status, 200);\n        assert.equal((await fetchUntrustedWithAgent(serving.url + \"/404\")).status, 404);\n      });\n      assert.equal(testProxyServer.proxyCallCounter, 2, \"The proxy should have been called twice\");\n      assert.deepEqual(logMessages1, []);\n    });\n\n    it(\"should report error when proxy fails\", async function() {\n      // if the proxy isn't listening, fetches produces error messages.\n      await testProxyServer.dispose();\n      // Error message depends a little on node version.\n      const logMessages2 = await captureLog(\"warn\", async () => {\n        await assert.isRejected(fetchUntrustedWithAgent(serving.url + \"/200\"), /(request.*failed)|(ECONNREFUSED)/);\n        await assert.isRejected(fetchUntrustedWithAgent(serving.url + \"/404\"), /(request.*failed)|(ECONNREFUSED)/);\n      });\n\n      // We rely on \"ProxyAgent error\" message to detect issues with the proxy server.\n      // Error message depends a little on node version.\n      assertMatchArray(logMessages2, [\n        /warn: ProxyAgent error.*((request.*failed)|(ECONNREFUSED)|(AggregateError))/,\n        /warn: ProxyAgent error.*((request.*failed)|(ECONNREFUSED)|(AggregateError))/,\n      ]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/PubSubCache.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { PubSubCache } from \"app/server/lib/PubSubCache\";\nimport { createPubSubManager, IPubSubManager } from \"app/server/lib/PubSubManager\";\nimport { setupCleanup } from \"test/server/testCleanup\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport { assert } from \"chai\";\nimport IORedis from \"ioredis\";\nimport sinon from \"sinon\";\n\ndescribe(\"PubSubCache\", function() {\n  this.timeout(5000);\n  const sandbox = sinon.createSandbox();\n  const cleanup = setupCleanup();\n\n  afterEach(function() {\n    sandbox.restore();\n  });\n\n  describe(\"with redis\", function() {\n    before(function() {\n      if (!process.env.TEST_REDIS_URL) {\n        this.skip();\n      }\n    });\n    testSuite(true, () => createPubSubManager(process.env.TEST_REDIS_URL));\n  });\n\n  describe(\"without redis\", function() {\n    testSuite(false, () => createPubSubManager(undefined));\n  });\n\n  function testSuite(useRedis: boolean, createPubSubManager: () => IPubSubManager) {\n    const fetch = sandbox.stub<[key: string], Promise<string>>();\n    const getChannel = sandbox.stub<[key: string], string>();\n\n    function createPubSubCache(options: { ttlMs: number }) {\n      fetch.callsFake(async (key: string) => key.toUpperCase());\n      getChannel.callsFake((key: string) => `foo:${key}`);\n      cleanup.addAfterEach(() => { fetch.reset(); getChannel.reset(); });\n\n      const manager = createPubSubManager();\n      const cache = new PubSubCache({ pubSubManager: manager, fetch, getChannel, ...options });\n      cleanup.addAfterEach(async () => {\n        cache.clear();\n        await manager.close();\n      });\n      return cache;\n    }\n\n    it(\"should refetch after invalidateKeys\", async function() {\n      const cache = createPubSubCache({ ttlMs: 1000 });\n      let suffix = 1;\n      fetch.callsFake(async (key: string) => key.toUpperCase() + \"@\" + suffix);\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@1\");\n      assert.equal(await cache.getValue(\"bar\"), \"BAR@1\");\n\n      // Until invalidated, the cache should be reused.\n      suffix = 2;\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@1\");\n      assert.equal(await cache.getValue(\"bar\"), \"BAR@1\");\n\n      // Once invalidated, the new value gets used, just for the key that got invalidated\n      await cache.invalidateKeys([\"bar\"]);\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@1\");\n      assert.equal(await cache.getValue(\"bar\"), \"BAR@2\");\n\n      // Still reused without invalidation.\n      suffix = 3;\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@1\");\n      assert.equal(await cache.getValue(\"bar\"), \"BAR@2\");\n\n      // Invalidate two keys at once; now both should get re-fetched.\n      await cache.invalidateKeys([\"bar\", \"foo\"]);\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@3\");\n      assert.equal(await cache.getValue(\"bar\"), \"BAR@3\");\n    });\n\n    it(\"should refetch after invalidateKeys on another server\", async function() {\n      if (!useRedis) { this.skip(); }\n      const cache = createPubSubCache({ ttlMs: 1000 });\n      const cache2 = createPubSubCache({ ttlMs: 1000 });\n\n      let suffix = 1;\n      fetch.callsFake(async (key: string) => key.toUpperCase() + \"@\" + suffix);\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@1\");\n      assert.equal(await cache.getValue(\"bar\"), \"BAR@1\");\n\n      // Until invalidated, the cache should be reused.\n      suffix = 2;\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@1\");\n      assert.equal(await cache.getValue(\"bar\"), \"BAR@1\");\n\n      // Once invalidated, the new value gets used, just for the key that got invalidated\n      await cache2.invalidateKeys([\"bar\"]);\n      await waitForIt(async () => {\n        assert.equal(await cache.getValue(\"foo\"), \"FOO@1\");\n        assert.equal(await cache.getValue(\"bar\"), \"BAR@2\");\n      }, 200, 50);\n\n      // Still reused without invalidation.\n      suffix = 3;\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@1\");\n      assert.equal(await cache.getValue(\"bar\"), \"BAR@2\");\n\n      // Invalidate two keys at once; now both should get re-fetched.\n      await cache.invalidateKeys([\"bar\", \"foo\"]);\n      await waitForIt(async () => {\n        assert.equal(await cache.getValue(\"foo\"), \"FOO@3\");\n        assert.equal(await cache.getValue(\"bar\"), \"BAR@3\");\n      }, 200, 50);\n\n      // Trigger a condition where immediately after a fetch another server invalidates. We should\n      // not miss that invalidation.\n      const [val] = await Promise.all([cache.getValue(\"race\"), cache2.invalidateKeys([\"race\"])]);\n      assert.equal(val, \"RACE@3\");\n      suffix = 4;\n      await waitForIt(async () => {\n        assert.equal(await cache.getValue(\"race\"), \"RACE@4\");\n      }, 200, 50);\n    });\n\n    it(\"should not cache on fetch errors\", async function() {\n      const cache = createPubSubCache({ ttlMs: 1000 });\n      fetch.callsFake(async (key: string) => { throw new Error(\"dummy\"); });\n      await assert.isRejected(cache.getValue(\"foo\"), /dummy/);\n      await assert.isRejected(cache.getValue(\"foo\"), /dummy/);\n      assert.equal(fetch.callCount, 2);\n    });\n\n    it(\"should re-fetch after expiration\", async function() {\n      const subSpy = sandbox.spy(IORedis.prototype, \"subscribe\");\n      const unsubSpy = sandbox.spy(IORedis.prototype, \"unsubscribe\");\n\n      function assertSubscriptions(expected: { sub: number, unsub: number }) {\n        if (useRedis) {\n          assert.equal(subSpy.callCount, expected.sub);\n          assert.equal(unsubSpy.callCount, expected.unsub);\n        }\n      }\n\n      const cache = createPubSubCache({ ttlMs: 100 });\n      let suffix = 1;\n      fetch.callsFake(async (key: string) => key.toUpperCase() + \"@\" + suffix);\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@1\");\n      suffix = 2;\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@1\");\n      assert.equal(fetch.callCount, 1);\n\n      assertSubscriptions({ sub: 1, unsub: 0 });\n\n      // Wait for expiration.\n      await delay(100);\n      assertSubscriptions({ sub: 1, unsub: 1 });\n\n      suffix = 3;\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@3\");\n      assert.equal(fetch.callCount, 2);\n      assertSubscriptions({ sub: 2, unsub: 1 });\n\n      suffix = 4;\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@3\");\n      assert.equal(fetch.callCount, 2);\n\n      // Try invalidation: it should get fetch() called again, but not subscribe/unsubscribe.\n      await cache.invalidateKeys([\"foo\"]);\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@4\");\n      assert.equal(fetch.callCount, 3);\n      assertSubscriptions({ sub: 2, unsub: 1 });\n\n      await delay(100);\n      assertSubscriptions({ sub: 2, unsub: 2 });\n      suffix = 5;\n      assert.equal(await cache.getValue(\"foo\"), \"FOO@5\");\n      assert.equal(fetch.callCount, 4);\n      assertSubscriptions({ sub: 3, unsub: 2 });\n      await waitForIt(async () => {\n        assertSubscriptions({ sub: 3, unsub: 3 });\n      }, 200, 50);\n    });\n\n    it(\"should re-attempt subscriptions on failure to subscribe\", async function() {\n      if (!useRedis) { this.skip(); }\n\n      const subStub = sandbox.stub(IORedis.prototype, \"subscribe\").callsFake(\n        () => Promise.reject(new Error(\"Fake subscribe error\")));\n      const unsubSpy = sandbox.spy(IORedis.prototype, \"unsubscribe\");\n\n      const cache = createPubSubCache({ ttlMs: 100 });\n      await assert.isRejected(cache.getValue(\"key1\"), /Fake subscribe error/);\n      assert.equal(subStub.callCount, 1);\n\n      await delay(50);\n\n      // Another call should try to subscribe again.\n      await assert.isRejected(cache.getValue(\"key1\"), /Fake subscribe error/);\n      assert.equal(subStub.callCount, 2);\n\n      // There should have been no other calls yet.\n      assert.equal(unsubSpy.callCount, 0);\n      assert.equal(fetch.callCount, 0);\n\n      // Now make the subscription succeed.\n      subStub.resetBehavior();\n\n      assert.equal(await cache.getValue(\"key1\"), \"KEY1\");\n      assert.equal(subStub.callCount, 3);\n      assert.equal(fetch.callCount, 1);\n\n      // The next call is cached normally (no new subscribe() or fetch() calls)\n      assert.equal(await cache.getValue(\"key1\"), \"KEY1\");\n      assert.equal(subStub.callCount, 3);\n      assert.equal(fetch.callCount, 1);\n\n      // After expiration, there should be a single unsubscribe call.\n      assert.equal(unsubSpy.callCount, 0);\n      await delay(100);\n      assert.equal(unsubSpy.callCount, 1);\n    });\n  }\n});\n"
  },
  {
    "path": "test/server/lib/PubSubManager.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { createPubSubManager, IPubSubManager } from \"app/server/lib/PubSubManager\";\nimport { getPubSubPrefix } from \"app/server/lib/serverUtils\";\nimport { setupCleanup } from \"test/server/testCleanup\";\n\nimport { assert } from \"chai\";\nimport IORedis from \"ioredis\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"PubSubManager\", function() {\n  const sandbox = sinon.createSandbox();\n  const cleanup = setupCleanup();\n  const prefix: string = getPubSubPrefix();\n\n  describe(\"with redis\", function() {\n    before(function() {\n      if (!process.env.TEST_REDIS_URL) {\n        this.skip();\n      }\n    });\n\n    afterEach(function() {\n      sandbox.restore();\n    });\n\n    it(\"subscribes and unsubscribes once for multiple listeners\", async function() {\n      const manager: IPubSubManager = createPubSubManager(process.env.TEST_REDIS_URL);\n      cleanup.addAfterEach(() => manager.close());\n\n      const subSpy = sandbox.spy(IORedis.prototype, \"subscribe\");\n      const unsubSpy = sandbox.spy(IORedis.prototype, \"unsubscribe\");\n\n      const cbA1 = sandbox.spy();\n      const cbA2 = sandbox.spy();\n      const cbB1 = sandbox.spy();\n      const resetHistory = () => { [cbA1, cbA2, cbB1].forEach(spy => spy.resetHistory()); };\n\n      // first subscription\n      const unsubscribe1 = manager.subscribe(\"testChanA\", cbA1).unsubscribeCB;\n      assert.deepEqual(subSpy.args, [[`${prefix}testChanA`]]);\n\n      // second subscription to same channel\n      const unsubscribe2 = manager.subscribe(\"testChanA\", cbA2).unsubscribeCB;\n      assert.equal(subSpy.callCount, 1, \"subscribe should only be called once\");\n\n      const unsubscribe3 = await manager.subscribe(\"testChanB\", cbB1);\n      assert.equal(subSpy.callCount, 2);\n\n      await manager.publish(\"testChanA\", \"foo\");\n      await manager.publish(\"testChanB\", \"bar\");\n      await delay(200);   // Give subscriptions a chance to get called.\n      assert.deepEqual(cbA1.args, [[\"foo\"]]);\n      assert.deepEqual(cbA2.args, [[\"foo\"]]);\n      assert.deepEqual(cbB1.args, [[\"bar\"]]);\n      resetHistory();\n\n      // cleanup first A listener\n      unsubscribe1();\n      assert.isFalse(unsubSpy.called, \"unsubscribe should not be called after removing first listener\");\n\n      await manager.publishBatch([\n        { channel: \"testChanA\", message: \"foo2\" },\n        { channel: \"testChanB\", message: \"bar2\" }],\n      );\n      await delay(200);   // Give subscriptions a chance to get called.\n      assert.deepEqual(cbA1.args, []);\n      assert.deepEqual(cbA2.args, [[\"foo2\"]]);\n      assert.deepEqual(cbB1.args, [[\"bar2\"]]);\n      resetHistory();\n\n      // cleanup second (last) A listener\n      unsubscribe2();\n      assert.deepEqual(unsubSpy.args, [[`${prefix}testChanA`]],\n        \"unsubscribe should be called once on last listener removal\");\n\n      await manager.publish(\"testChanA\", \"foo3\");\n      await manager.publish(\"testChanB\", \"bar3\");\n      await delay(200);   // Give subscriptions a chance to get called.\n      assert.deepEqual(cbA1.args, []);\n      assert.deepEqual(cbA2.args, []);\n      assert.deepEqual(cbB1.args, [[\"bar3\"]]);\n      resetHistory();\n\n      // clean up the only B listener.\n      unsubscribe3();\n      await manager.publish(\"testChanA\", \"foo4\");\n      await manager.publish(\"testChanB\", \"bar4\");\n      await delay(200);   // Give subscriptions a chance to get called.\n      assert.deepEqual(cbA1.args, []);\n      assert.deepEqual(cbA2.args, []);\n      assert.deepEqual(cbB1.args, []);\n      resetHistory();\n    });\n\n    it(\"delivers to multiple instances\", async function() {\n      const manager1 = createPubSubManager(process.env.TEST_REDIS_URL);\n      const manager2 = createPubSubManager(process.env.TEST_REDIS_URL);\n      cleanup.addAfterEach(() => manager1.close());\n      cleanup.addAfterEach(() => manager2.close());\n\n      const subSpy = sandbox.spy(IORedis.prototype, \"subscribe\");\n      const unsubSpy = sandbox.spy(IORedis.prototype, \"unsubscribe\");\n\n      const cbA1 = sandbox.spy();\n      const cbA2 = sandbox.spy();\n      const cbB1 = sandbox.spy();\n      const resetHistory = () => { [cbA1, cbA2, cbB1].forEach(spy => spy.resetHistory()); };\n\n      const unsubscribeA1 = manager1.subscribe(\"testChanA\", cbA1).unsubscribeCB;\n      const unsubscribeA2 = await manager1.subscribe(\"testChanA\", cbA2);\n      assert.deepEqual(subSpy.args, [[`${prefix}testChanA`]]);\n\n      // Publish on the OTHER manager. It should be noticed by the first manager's subscribers.\n      await manager2.publish(\"testChanA\", \"foo\");\n      await delay(200);   // Give subscriptions a chance to get called.\n      assert.deepEqual(cbA1.args, [[\"foo\"]]);\n      assert.deepEqual(cbA2.args, [[\"foo\"]]);\n      resetHistory();\n\n      // Subscribe a callback on the other manager.\n      void manager2.subscribe(\"testChanA\", cbB1);\n      assert.deepEqual(subSpy.args, [[`${prefix}testChanA`], [`${prefix}testChanA`]]);\n\n      // Messages from either manager should be seen on both.\n      await manager1.publish(\"testChanA\", \"a\");\n      await manager2.publish(\"testChanA\", \"b\");\n      await delay(200);   // Give subscriptions a chance to get called.\n      assert.deepEqual(cbA1.args, [[\"a\"], [\"b\"]]);\n      assert.deepEqual(cbA2.args, [[\"a\"], [\"b\"]]);\n      assert.deepEqual(cbB1.args, [[\"a\"], [\"b\"]]);\n      resetHistory();\n\n      // cleanup the first manager's listeners.\n      unsubscribeA1();\n      unsubscribeA2();\n      assert.deepEqual(unsubSpy.args, [[`${prefix}testChanA`]]);\n\n      // We can still publish on the first manager, and get noticed by the second.\n      await manager1.publish(\"testChanA\", \"b2\");\n      await delay(200);   // Give subscriptions a chance to get called.\n      assert.deepEqual(cbA1.args, []);\n      assert.deepEqual(cbA2.args, []);\n      assert.deepEqual(cbB1.args, [[\"b2\"]]);\n      resetHistory();\n    });\n\n    it(\"should handle errors\", async function() {\n      const manager: IPubSubManager = createPubSubManager(process.env.TEST_REDIS_URL);\n      cleanup.addAfterEach(() => manager.close());\n\n      const subStub = sandbox.stub(IORedis.prototype, \"subscribe\").callsFake(() =>\n        Promise.reject(new Error(\"Fake subscribe error\")));\n      const unsubSpy = sandbox.spy(IORedis.prototype, \"unsubscribe\");\n\n      const cbA1 = sandbox.spy();\n      const sub1 = manager.subscribe(\"testChanA\", cbA1);\n      assert.equal(subStub.callCount, 1);\n\n      await assert.isRejected(sub1, /Fake subscribe error/);\n\n      // But it should be safe to call unsubscribeCB.\n      sub1.unsubscribeCB();\n      assert.equal(unsubSpy.callCount, 1);\n\n      // Try subscribing again, first with a failure.\n      const sub2 = manager.subscribe(\"testChanA\", cbA1);\n      assert.equal(subStub.callCount, 2);\n      await assert.isRejected(sub2, /Fake subscribe error/);\n\n      // Then successfully.\n      subStub.resetBehavior();\n      const cbA3 = sandbox.spy();\n      const sub3 = manager.subscribe(\"testChanA\", cbA3);\n      assert.equal(subStub.callCount, 3);\n      assert.equal(await sub3, sub3.unsubscribeCB);\n\n      // It should still be safe to call any unsubscribeCBs.\n      sub2.unsubscribeCB();\n      assert.equal(unsubSpy.callCount, 1);    // no change from last call.\n\n      // When the last subscription is gone, the actual redis-unsubscribe happens.\n      sub3.unsubscribeCB();\n      assert.equal(unsubSpy.callCount, 2);\n\n      // Let calls complete before after-test cleanup closes the connection.\n      await delay(50);\n    });\n  });\n\n  describe(\"without redis\", function() {\n    after(function() {\n      sandbox.restore();\n    });\n\n    it(\"works in-memory without redis\", async function() {\n      const manager = createPubSubManager(undefined);\n      cleanup.addAfterEach(() => manager.close());\n\n      const subSpy = sandbox.spy(IORedis.prototype, \"subscribe\");\n      const unsubSpy = sandbox.spy(IORedis.prototype, \"unsubscribe\");\n\n      const cbA1 = sandbox.spy();\n      const cbA2 = sandbox.spy();\n      const cbB1 = sandbox.spy();\n      const resetHistory = () => { [cbA1, cbA2, cbB1].forEach(spy => spy.resetHistory()); };\n\n      const unsubscribe1 = manager.subscribe(\"testChanA\", cbA1).unsubscribeCB;\n      const unsubscribe2 = manager.subscribe(\"testChanA\", cbA2).unsubscribeCB;\n      const unsubscribe3 = manager.subscribe(\"testChanB\", cbB1).unsubscribeCB;\n\n      await manager.publish(\"testChanA\", \"foo\");\n      await manager.publish(\"testChanB\", \"bar\");\n      assert.deepEqual(cbA1.args, [[\"foo\"]]);\n      assert.deepEqual(cbA2.args, [[\"foo\"]]);\n      assert.deepEqual(cbB1.args, [[\"bar\"]]);\n      resetHistory();\n\n      // cleanup first A listener\n      unsubscribe1();\n\n      await manager.publishBatch([\n        { channel: \"testChanA\", message: \"foo2\" },\n        { channel: \"testChanB\", message: \"bar2\" }],\n      );\n      await delay(200);   // Give subscriptions a chance to get called.\n      assert.deepEqual(cbA1.args, []);\n      assert.deepEqual(cbA2.args, [[\"foo2\"]]);\n      assert.deepEqual(cbB1.args, [[\"bar2\"]]);\n      resetHistory();\n\n      // cleanup second (last) A listener\n      unsubscribe2();\n\n      await manager.publish(\"testChanA\", \"foo3\");\n      await manager.publish(\"testChanB\", \"bar3\");\n      await delay(200);   // Give subscriptions a chance to get called.\n      assert.deepEqual(cbA1.args, []);\n      assert.deepEqual(cbA2.args, []);\n      assert.deepEqual(cbB1.args, [[\"bar3\"]]);\n      resetHistory();\n\n      // clean up the only B listener.\n      unsubscribe3();\n      await manager.publish(\"testChanA\", \"foo4\");\n      await manager.publish(\"testChanB\", \"bar4\");\n      await delay(200);   // Give subscriptions a chance to get called.\n      assert.deepEqual(cbA1.args, []);\n      assert.deepEqual(cbA2.args, []);\n      assert.deepEqual(cbB1.args, []);\n      resetHistory();\n\n      assert.equal(subSpy.callCount, 0, \"this test case should not involve Redis\");\n      assert.equal(unsubSpy.callCount, 0, \"this test case should not involve Redis\");\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/RowAccess.ts",
    "content": "// import { DocAction } from 'app/common/DocActions';\nimport { getRelatedRows } from \"app/server/lib/RowAccess\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"RowAccess\", function() {\n  describe(\"getRelatedRows\", function() {\n    it(\"accumulates individual updates and removes\", function() {\n      assert.deepEqual(getRelatedRows([[\"UpdateRecord\", \"Table1\", 1, { X: 1 }]]),\n        [[\"Table1\", new Set([1])]]);\n      // check sets are compared correctly\n      assert.notDeepEqual(getRelatedRows([[\"UpdateRecord\", \"Table1\", 1, { X: 1 }]]),\n        [[\"Table1\", new Set([])]]);\n      assert.deepEqual(getRelatedRows([[\"UpdateRecord\", \"Table1\", 1, { X: 1 }],\n        [\"AddRecord\", \"Table2\", 2, {}],\n        [\"RemoveRecord\", \"Table3\", 3]]),\n      [[\"Table1\", new Set([1])],\n        [\"Table2\", new Set([])],\n        [\"Table3\", new Set([3])]]);\n    });\n\n    it(\"accumulates bulk updates and removes\", function() {\n      assert.deepEqual(getRelatedRows([[\"BulkUpdateRecord\", \"Table1\", [1, 2], {}]]),\n        [[\"Table1\", new Set([1, 2])]]);\n      assert.deepEqual(getRelatedRows([[\"BulkUpdateRecord\", \"Table1\", [1, 10], {}],\n        [\"BulkAddRecord\", \"Table2\", [2, 20], {}],\n        [\"BulkRemoveRecord\", \"Table3\", [3, 30]]]),\n      [[\"Table1\", new Set([1, 10])],\n        [\"Table2\", new Set([])],\n        [\"Table3\", new Set([3, 30])]]);\n    });\n\n    it(\"accumulates individual and bulk updates and removes\", function() {\n      assert.deepEqual(getRelatedRows([[\"BulkUpdateRecord\", \"Table1\", [1, 10], {}],\n        [\"UpdateRecord\", \"Table1\", 100, {}],\n        [\"BulkAddRecord\", \"Table1\", [2, 20], {}],\n        [\"AddRecord\", \"Table1\", 200, {}],\n        [\"BulkRemoveRecord\", \"Table1\", [3, 30]],\n        [\"RemoveRecord\", \"Table1\", 300]]),\n      [[\"Table1\", new Set([1, 3, 10, 30, 100, 300])]]);\n    });\n\n    it(\"discounts rows added within the bundle\", function() {\n      assert.deepEqual(getRelatedRows([[\"BulkUpdateRecord\", \"Table1\", [1, 2], {}],\n        [\"AddRecord\", \"Table1\", 10, {}],\n        [\"BulkAddRecord\", \"Table1\", [11, 12], {}],\n        [\"UpdateRecord\", \"Table1\", 10, {}],\n        [\"RemoveRecord\", \"Table1\", 10],\n        [\"UpdateRecord\", \"Table1\", 11, {}],\n        [\"BulkRemoveRecord\", \"Table1\", [12, 30]]]),\n      [[\"Table1\", new Set([1, 2, 30])]]);\n    });\n\n    it(\"discounts replacement rows\", function() {\n      assert.deepEqual(getRelatedRows([[\"BulkUpdateRecord\", \"Table1\", [1, 2], {}],\n        [\"ReplaceTableData\", \"Table1\", [1, 2, 3, 4], {}],\n        [\"BulkUpdateRecord\", \"Table1\", [2, 3], {}]]),\n      [[\"Table1\", new Set([1, 2])]]);\n    });\n\n    it(\"tolerate table renames\", function() {\n      assert.deepEqual(getRelatedRows([[\"BulkUpdateRecord\", \"Table1\", [1, 2], {}],\n        [\"AddRecord\", \"Table1\", 10, {}],\n        [\"RenameTable\", \"Table1\", \"Table2\"],\n        [\"BulkAddRecord\", \"Table1\", [11, 12], {}],\n        [\"UpdateRecord\", \"Table2\", 10, {}],\n        [\"RemoveRecord\", \"Table2\", 10],\n        [\"UpdateRecord\", \"Table2\", 11, {}],\n        [\"BulkRemoveRecord\", \"Table2\", [12, 30]]]),\n      [[\"Table1\", new Set([1, 2, 30])]]);\n    });\n\n    it(\"ignore new tables\", function() {\n      assert.deepEqual(getRelatedRows([[\"BulkUpdateRecord\", \"Table1\", [1, 2], {}],\n        [\"AddRecord\", \"Table1\", 10, {}],\n        [\"AddTable\", \"Table2\", []],\n        [\"BulkUpdateRecord\", \"Table2\", [1, 2], {}]]),\n      [[\"Table1\", new Set([1, 2])]]);\n    });\n\n    it(\"keep table names straight\", function() {\n      assert.deepEqual(getRelatedRows([[\"BulkUpdateRecord\", \"Table1\", [1, 2], {}],\n        [\"RenameTable\", \"Table1\", \"Table3\"],\n        [\"RenameTable\", \"Table2\", \"Table1\"],\n        [\"RenameTable\", \"Table3\", \"Table2\"],\n        [\"UpdateRecord\", \"Table1\", 10, {}],\n        [\"RemoveTable\", \"Table1\"],\n        [\"AddTable\", \"Table1\", []],\n        [\"AddRecord\", \"Table1\", 20, {}]]),\n      [[\"Table1\", new Set([1, 2])],\n        [\"Table2\", new Set([10])]]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/SQLiteDB.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { timeoutReached } from \"app/common/gutil\";\nimport { OpenMode, SchemaInfo, SQLiteDB } from \"app/server/lib/SQLiteDB\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { join as pathJoin } from \"path\";\n\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\nimport { map, noop } from \"lodash\";\nimport * as tmp from \"tmp-promise\";\n\ntmp.setGracefulCleanup();\n\ndescribe(\"SQLiteDB\", function() {\n  let tmpDir: string;\n  let cleanup: () => void;\n\n  // Turn off logging for this test, and restore afterwards.\n  testUtils.setTmpLogLevel(\"warn\");\n\n  before(async function() {\n    // Create a temporary directory, and wipe it out on exit.\n    ({ path: tmpDir, cleanup } = await tmp.dir({ prefix: \"grist_test_SQLiteDB_\", unsafeCleanup: true }));\n  });\n\n  after(function() {\n    cleanup();\n  });\n\n  // Convenience helpers to make tests more concise and readable.\n  function dbPath(fileName: string): string {\n    return pathJoin(tmpDir, fileName);\n  }\n\n  async function getTableNames(sdb: SQLiteDB): Promise<string[]> {\n    return map(await sdb.all(\"SELECT * FROM sqlite_master WHERE type='table'\"), \"name\");\n  }\n\n  it(\"should create correct schema for a new DB\", async function() {\n    assert.isFalse(await fse.pathExists(dbPath(\"test1A\")));\n    const sdb = await SQLiteDB.openDB(dbPath(\"test1A\"), schemaInfo, OpenMode.OPEN_CREATE);\n    assert.isTrue(await fse.pathExists(dbPath(\"test1A\")));\n\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\", \"Bar\"]);\n    assert.deepEqual(await sdb.collectMetadata(), {\n      Foo: { A: \"TEXT\", B: \"NUMERIC\" },\n      Bar: { C: \"TEXT\", D: \"NUMERIC\" },\n    });\n    await sdb.close();\n  });\n\n  it(\"should support all OpenMode values\", async function() {\n    let sdb: SQLiteDB;\n\n    // Check that OPEN_CREATE works for both new and existing DB.\n    assert.isFalse(await fse.pathExists(dbPath(\"test2A\")));\n    sdb = await SQLiteDB.openDB(dbPath(\"test2A\"), schemaInfo, OpenMode.OPEN_CREATE);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\", \"Bar\"]);\n    await sdb.close();\n\n    assert.isTrue(await fse.pathExists(dbPath(\"test2A\")));\n    sdb = await SQLiteDB.openDB(dbPath(\"test2A\"), schemaInfo, OpenMode.OPEN_CREATE);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\", \"Bar\"]);\n    await sdb.close();\n\n    // Check that OPEN_EXISTING works for existing but fails for new.\n    sdb = await SQLiteDB.openDB(dbPath(\"test2A\"), schemaInfo, OpenMode.OPEN_EXISTING);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\", \"Bar\"]);\n    await sdb.close();\n\n    assert.isFalse(await fse.pathExists(dbPath(\"test2B\")));\n    await assert.isRejected(SQLiteDB.openDB(dbPath(\"test2B\"), schemaInfo, OpenMode.OPEN_EXISTING),\n      /unable to open database/);\n    assert.isFalse(await fse.pathExists(dbPath(\"test2B\")));\n\n    // Check that OPEN_READONLY works for existing, fails for new, and prevents changes.\n    await assert.isRejected(SQLiteDB.openDB(dbPath(\"test2B\"), schemaInfo, OpenMode.OPEN_READONLY),\n      /unable to open database/);\n    sdb = await SQLiteDB.openDB(dbPath(\"test2A\"), schemaInfo, OpenMode.OPEN_READONLY);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\", \"Bar\"]);\n    await assert.isRejected(sdb.run('INSERT INTO Foo (A) VALUES (\"hello\")'),\n      /readonly database/);\n    await sdb.close();\n\n    // Check that OPEN_READONLY works when a migration is required, without a migration.\n    sdb = await SQLiteDB.openDB(dbPath(\"test2C\"), schemaInfoV1, OpenMode.OPEN_CREATE);\n    await sdb.close();\n    await testUtils.captureLog(\"warn\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test2C\"), schemaInfo, OpenMode.OPEN_READONLY);\n    });\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\"]);\n    assert.strictEqual(sdb.migrationBackupPath, null);\n    await sdb.close();\n\n    // Check that CREATE_EXCL works for new, fails for existing.\n    assert.isFalse(await fse.pathExists(dbPath(\"test2D\")));\n    sdb = await SQLiteDB.openDB(dbPath(\"test2D\"), schemaInfo, OpenMode.CREATE_EXCL);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\", \"Bar\"]);\n    await sdb.close();\n\n    assert.isTrue(await fse.pathExists(dbPath(\"test2D\")));\n    await assert.isRejected(SQLiteDB.openDB(dbPath(\"test2D\"), schemaInfo, OpenMode.CREATE_EXCL),\n      /already exists/);\n    assert.isTrue(await fse.pathExists(dbPath(\"test2D\")));\n  });\n\n  it(\"should warn about opening the same DB more than once\", async function() {\n    // Open a database.\n    const sdb = await SQLiteDB.openDB(dbPath(\"testWarn\"), schemaInfo, OpenMode.OPEN_CREATE);\n    let msgs: string[];\n\n    // Open the same database again, with open() and openRaw()\n    msgs = await testUtils.captureLog(\"warn\", async () => {\n      const sdb2 = await SQLiteDB.openDB(dbPath(\"testWarn\"), schemaInfo, OpenMode.OPEN_EXISTING);\n      await sdb2.close();\n      const sdb3 = await SQLiteDB.openDBRaw(dbPath(\"testWarn\"), OpenMode.OPEN_EXISTING);\n      await sdb3.close();\n    });\n    testUtils.assertMatchArray(msgs, [\n      /\\/testWarn.* avoid opening same DB more than once/,\n      /\\/testWarn.* avoid opening same DB more than once/,\n    ]);\n\n    // Close and repeat the test: should have no errors.\n    await sdb.close();\n    msgs = await testUtils.captureLog(\"warn\", async () => {\n      const sdb2 = await SQLiteDB.openDB(dbPath(\"testWarn\"), schemaInfo, OpenMode.OPEN_EXISTING);\n      await sdb2.close();\n      const sdb3 = await SQLiteDB.openDBRaw(dbPath(\"testWarn\"), OpenMode.OPEN_EXISTING);\n      await sdb3.close();\n    });\n    testUtils.assertMatchArray(msgs, []);\n  });\n\n  it(\"should apply migrations, with backup, if needed\", async function() {\n    let sdb: SQLiteDB = null as any;    // To silence warnings about use before assignment.\n    let msgs: string[];\n\n    // On creating a V1 schema doc.\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test3A\"), schemaInfoV1, OpenMode.CREATE_EXCL);\n    });\n    assert.deepEqual(msgs, []);                         // No warnings.\n    assert.strictEqual(sdb.migrationBackupPath, null);  // No migration backup.\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\"]);\n    await sdb.close();\n\n    // On reopen using V1 schema.\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test3A\"), schemaInfoV1, OpenMode.OPEN_EXISTING);\n    });\n    assert.deepEqual(msgs, []);                         // No warnings.\n    assert.strictEqual(sdb.migrationBackupPath, null);  // No migration backup.\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\"]);\n    assert.deepEqual(await sdb.collectMetadata(), { Foo: { A: \"TEXT\" } });\n    await sdb.close();\n\n    // On reopen using V2 schema.\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test3A\"), schemaInfoV2, OpenMode.OPEN_EXISTING);\n    });\n    testUtils.assertMatchArray(msgs, [\n      /\\/test3A.* needs migration from version 1 to 2/,\n      /\\/test3A.* backed up to .*\\/test3A\\.[0-9-]+\\.V1\\.bak, migrated to 2/,\n    ]);\n    assert.match(sdb.migrationBackupPath!, /test3A\\.\\d{4}-\\d{2}-\\d{2}\\.V1\\.bak$/);\n    assert.isTrue(await fse.pathExists(sdb.migrationBackupPath!));\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\", \"Bar\"]);\n    assert.deepEqual(await sdb.collectMetadata(), {\n      Foo: { A: \"TEXT\", B: \"NUMERIC\" },\n      Bar: { D: \"NUMERIC\" },\n    });\n    await sdb.close();\n\n    // Check that the backup makes sense.\n    const migrationSDB = await SQLiteDB.openDB(\n      sdb.migrationBackupPath!, schemaInfoV1, OpenMode.OPEN_READONLY);\n    assert.deepEqual(await migrationSDB.collectMetadata(), { Foo: { A: \"TEXT\" } });\n    await migrationSDB.close();\n\n    // On reopen using V3 schema.\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test3A\"), schemaInfo, OpenMode.OPEN_EXISTING);\n    });\n    testUtils.assertMatchArray(msgs, [\n      /\\/test3A.* needs migration from version 2 to 3/,\n      /\\/test3A.* backed up to .*\\/test3A\\.[0-9-]+\\.V2\\.bak, migrated to 3/,\n    ]);\n    assert.match(sdb.migrationBackupPath!, /test3A\\.\\d{4}-\\d{2}-\\d{2}\\.V2\\.bak$/);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\", \"Bar\"]);\n    assert.deepEqual(await sdb.collectMetadata(), {\n      Foo: { A: \"TEXT\", B: \"NUMERIC\" },\n      Bar: { C: \"TEXT\", D: \"NUMERIC\" },\n    });\n    await sdb.close();\n\n    // Second reopen using V3 schema.\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test3A\"), schemaInfo, OpenMode.OPEN_EXISTING);\n    });\n    assert.deepEqual(msgs, []);                         // No warnings.\n    assert.strictEqual(sdb.migrationBackupPath, null);  // No migration backup.\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\", \"Bar\"]);\n    assert.deepEqual(await sdb.collectMetadata(), {\n      Foo: { A: \"TEXT\", B: \"NUMERIC\" },\n      Bar: { C: \"TEXT\", D: \"NUMERIC\" },\n    });\n    await sdb.close();\n  });\n\n  it(\"should allow migrating across multiple versions\", async function() {\n    let sdb: SQLiteDB = null as any;    // To silence warnings about use before assignment.\n    let msgs: string[];\n    // Open a document at V1, then migrate directly to V3 skipping V2.\n    sdb = await SQLiteDB.openDB(dbPath(\"test3B\"), schemaInfoV1, OpenMode.CREATE_EXCL);\n    assert.strictEqual(await sdb.getMigrationVersion(), 1);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\"]);\n    await sdb.close();\n    assert.strictEqual(await SQLiteDB.getMigrationVersion(dbPath(\"test3B\")), 1);\n\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test3B\"), schemaInfo, OpenMode.OPEN_EXISTING);\n    });\n    assert.strictEqual(await sdb.getMigrationVersion(), 3);\n    testUtils.assertMatchArray(msgs, [\n      /\\/test3B.* needs migration from version 1 to 3/,\n      /\\/test3B.* backed up to .*\\/test3B\\.[0-9-]+\\.V1\\.bak, migrated to 3/,\n    ]);\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      assert.strictEqual(await SQLiteDB.getMigrationVersion(dbPath(\"test3B\")), 3);\n    });\n    testUtils.assertMatchArray(msgs, [\n      /\\/test3B.* avoid opening same DB more than once/,\n    ]);\n    assert.match(sdb.migrationBackupPath!, /test3B\\.\\d{4}-\\d{2}-\\d{2}\\.V1\\.bak$/);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\", \"Bar\"]);\n    assert.deepEqual(await sdb.collectMetadata(), {\n      Foo: { A: \"TEXT\", B: \"NUMERIC\" },\n      Bar: { C: \"TEXT\", D: \"NUMERIC\" },\n    });\n    await sdb.close();\n  });\n\n  it(\"should open read-only files needing migration\", async function() {\n    let sdb: SQLiteDB = null as any;    // To silence warnings about use before assignment.\n    // Open a document at V1, then open READONLY with v3.\n    sdb = await SQLiteDB.openDB(dbPath(\"testRO\"), schemaInfoV1, OpenMode.CREATE_EXCL);\n    assert.strictEqual(await sdb.getMigrationVersion(), 1);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\"]);\n    await sdb.close();\n\n    await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"testRO\"), schemaInfo, OpenMode.OPEN_READONLY);\n    });\n    assert.strictEqual(await sdb.getMigrationVersion(), 1);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\"]);\n    assert.match(sdb.migrationError!.message, /migration .* readonly/);\n  });\n\n  it(\"should migrate DBs created without versioning\", async function() {\n    let sdb: SQLiteDB = null as any;    // To silence warnings about use before assignment.\n    sdb = await SQLiteDB.openDBRaw(dbPath(\"testPRE\"), OpenMode.OPEN_CREATE);\n    await sdb.exec(\"CREATE TABLE _grist_Foo (A TEXT)\");\n    void sdb.close();\n\n    async function create(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE _grist_Foo (A TEXT)\");\n    }\n    async function migrateV0(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE IF NOT EXISTS _grist_Bar (B TEXT)\");\n    }\n\n    sdb = await SQLiteDB.openDB(dbPath(\"testPRE\"), { create, migrations: [migrateV0] }, OpenMode.OPEN_CREATE);\n    assert.strictEqual(await sdb.getMigrationVersion(), 1);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"_grist_Foo\", \"_grist_Bar\"]);\n    assert.match(sdb.migrationBackupPath!, /testPRE\\..*\\.V0\\.bak$/);\n    void sdb.close();\n\n    // Simulate new schema version.\n    async function create2(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE _grist_Foo (A TEXT)\");\n      await db.exec(\"CREATE TABLE _grist_Bar (B TEXT, X TEXT)\");\n    }\n    async function migrateV1(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE IF NOT EXISTS _grist_Bar (B TEXT, X TEXT)\");\n    }\n\n    sdb = await SQLiteDB.openDBRaw(dbPath(\"testPRE2\"), OpenMode.OPEN_CREATE);\n    await create2(sdb);\n    void sdb.close();\n    sdb = await SQLiteDB.openDB(dbPath(\"testPRE2\"),\n      { create: create2, migrations: [migrateV0, migrateV1] }, OpenMode.OPEN_CREATE);\n    assert.strictEqual(await sdb.getMigrationVersion(), 2);\n    assert.sameDeepMembers(await getTableNames(sdb), [\"_grist_Foo\", \"_grist_Bar\"]);\n    assert.match(sdb.migrationBackupPath!, /testPRE2\\..*\\.V0\\.bak$/);\n    void sdb.close();\n  });\n\n  it(\"should skip migration backup on migration failure\", async function() {\n    // Create a document at V1.\n    let sdb: SQLiteDB = null as any;    // To silence warnings about use before assignment.\n    let msgs: string[];\n\n    // Create a V1 schema doc.\n    sdb = await SQLiteDB.openDB(dbPath(\"test4A\"), schemaInfoV1, OpenMode.CREATE_EXCL);\n    await sdb.close();\n\n    // Open it using a broken V4 version. When the migration fails, we complain but continue with\n    // unmigrated DB, and expose a `.migrationError` property.\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test4A\"), schemaInfoV4Broken, OpenMode.OPEN_EXISTING);\n    });\n    testUtils.assertMatchArray(msgs, [\n      /\\/test4A.* needs migration from version 1 to 4/,\n      /\\/test4A.* migration from 1 to 4 failed.*\"SOME_INVALID_SQL\".*syntax error/,\n      /table Foo does not match schema/,\n      /table Bar does not match schema/,\n    ]);\n    // Check the migrationError property.\n    assert.match(sdb.migrationError!.message, /migration to 4.*SOME_INVALID_SQL.*syntax error/);\n    // Check that migrationBackupPath isn't set.\n    assert.strictEqual(sdb.migrationBackupPath, null);\n\n    assert.sameDeepMembers(await getTableNames(sdb), [\"Foo\"]);\n    assert.deepEqual(await sdb.collectMetadata(), { Foo: { A: \"TEXT\" } });\n    await sdb.close();\n\n    // Check that the only file with test4A is our file, i.e. no extra files got created.\n    assert.deepEqual((await fse.readdir(tmpDir)).filter(d => d.includes(\"test4A\")), [\"test4A\"]);\n\n    // Check also that if we open READONLY with a bad migration, we only get discrepancy warnings.\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test4A\"), schemaInfoV4Broken, OpenMode.OPEN_READONLY);\n    });\n    testUtils.assertMatchArray(msgs, [\n      /table Foo does not match schema/,\n      /table Bar does not match schema/,\n    ]);\n    assert.match(sdb.migrationError!.message, /migration .* readonly/);\n    assert.strictEqual(sdb.migrationBackupPath, null);\n    await sdb.close();\n  });\n\n  it(\"should warn if DB is incorrect, incl after migrations\", async function() {\n    let sdb: SQLiteDB = null as any;    // To silence warnings about use before assignment.\n    let msgs: string[];\n\n    // Create a doc.\n    sdb = await SQLiteDB.openDB(dbPath(\"test5A\"), schemaInfo, OpenMode.CREATE_EXCL);\n    assert.strictEqual(sdb.migrationBackupPath, null);\n\n    // Now modify its schema.\n    await sdb.exec(\"ALTER TABLE Foo ADD COLUMN Unplanned TEXT\");\n    await sdb.close();\n\n    // Reopening should produce warnings.\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test5A\"), schemaInfo, OpenMode.OPEN_CREATE);\n    });\n    assert.strictEqual(sdb.migrationBackupPath, null);\n    testUtils.assertMatchArray(msgs, [\n      /warn:.*\\/test5A.* table Foo does not match schema.*Unplanned/,\n    ]);\n    await sdb.close();\n\n    // Now create a doc using V1 and migrate to an inconsitent migration.\n    sdb = await SQLiteDB.openDB(dbPath(\"test5B\"), schemaInfoV1, OpenMode.CREATE_EXCL);\n    assert.strictEqual(sdb.migrationBackupPath, null);\n    await sdb.close();\n\n    // Use a migration that doesn't match the create() func.\n    msgs = await testUtils.captureLog(\"info\", async () => {\n      sdb = await SQLiteDB.openDB(dbPath(\"test5B\"), schemaInfoV2Inconsistent, OpenMode.OPEN_CREATE);\n    });\n    assert.match(sdb.migrationBackupPath!, /test5B\\.\\d{4}-\\d{2}-\\d{2}\\.V1\\.bak$/);\n    assert.strictEqual(sdb.migrationError, null);\n    testUtils.assertMatchArray(msgs, [\n      /\\/test5B.* needs migration from version 1 to 2/,\n      /\\/test5B.* backed up to .*\\/test5B\\.[0-9-]+\\.V1\\.bak, migrated to 2/,\n      /warn: .*table Foo does not match schema.*BadCol/,\n    ]);\n  });\n\n  describe(\"execTransaction\", function() {\n    it(\"should run callback inside a transaction\", async function() {\n      const sdb = await SQLiteDB.openDB(dbPath(\"testTrans1\"), schemaInfo, OpenMode.OPEN_CREATE);\n\n      // Simple case: just run a statement and it should succeed.\n      await sdb.execTransaction(() => sdb.exec('INSERT INTO Foo (A) VALUES (\"hello\")'));\n      assert.deepEqual(await sdb.all(\"SELECT A FROM Foo\"), [{ A: \"hello\" }]);\n\n      // Now try running one statement that should succeed, and then failing inside the\n      // transaction; it should be rolled back along with the first statement.\n      await assert.isRejected(sdb.execTransaction(async () => {\n        await sdb.exec('INSERT INTO Foo (A) VALUES (\"good bye\")');\n        throw new Error(\"Fake error\");\n      }), /Fake error/);\n      // Ensure that the new value did NOT get added.\n      assert.deepEqual(await sdb.all(\"SELECT A FROM Foo\"), [{ A: \"hello\" }]);\n      void sdb.close();\n    });\n\n    it(\"should serialize execTransaction calls\", async function() {\n      // We do not maintain a chain of promises but rely on SQLite's serialize() behavior.\n      // Start several transactions simultaneously, including failing ones; later transaction must\n      // see the effects of earlier ones, and should not be affected by earlier failures.\n      const sdb = await SQLiteDB.openDB(dbPath(\"testTrans2\"), schemaInfo, OpenMode.OPEN_CREATE);\n      const results: any[] = await Promise.all([\n        sdb.execTransaction(() => sdb.exec('INSERT INTO Foo (A) VALUES (\"trans1\")')),\n        sdb.execTransaction(() => sdb.all(\"SELECT A FROM Foo\")),\n        sdb.execTransaction(() => sdb.exec('INSERT INTO Foo (A) VALUES (\"trans2\")')),\n        sdb.execTransaction(() => sdb.exec(\"CREATE TABLE TableNew (foo TEXT)\")),\n        assert.isRejected(sdb.execTransaction(() => sdb.exec(\"CREATE TABLE TableNew (foo TEXT)\")),\n          /TableNew.*already exists/).then(noop),\n        sdb.execTransaction(() => sdb.all(\"SELECT A FROM Foo\")),\n        sdb.execTransaction(async () => {\n          await delay(30);\n          await sdb.exec('INSERT INTO Foo (A) VALUES (\"trans3\")');\n          await delay(30);\n        }),\n        sdb.execTransaction(() => sdb.all(\"SELECT A FROM Foo\")),\n      ]);\n      assert.deepEqual(results, [\n        undefined,\n        [{ A: \"trans1\" }],\n        undefined,\n        undefined,\n        undefined,\n        [{ A: \"trans1\" }, { A: \"trans2\" }],\n        undefined,\n        [{ A: \"trans1\" }, { A: \"trans2\" }, { A: \"trans3\" }],\n      ]);\n      void sdb.close();\n    });\n\n    it(\"should allow nested execTransaction calls\", async function() {\n      const sdb = await SQLiteDB.openDB(dbPath(\"testTrans3\"), schemaInfo, OpenMode.OPEN_CREATE);\n      await sdb.execTransaction(async () => {\n        await sdb.exec('INSERT INTO Foo (A) VALUES (\"thing1\")');\n        await sdb.execTransaction(async () => {\n          await sdb.exec('INSERT INTO Foo (A) VALUES (\"thing2\")');\n          await sdb.exec('INSERT INTO Foo (A) VALUES (\"thing3\")');\n        });\n      });\n      assert.lengthOf(await sdb.all(\"SELECT A FROM Foo\"), 3);\n    });\n\n    it(\"should rollback nested execTransaction calls as a group\", async function() {\n      const sdb = await SQLiteDB.openDB(dbPath(\"testTrans4\"), schemaInfo, OpenMode.OPEN_CREATE);\n      await assert.isRejected(\n        sdb.execTransaction(async () => {\n          await sdb.exec('INSERT INTO Foo (A) VALUES (\"thing1\")');\n          await sdb.execTransaction(async () => {\n            await sdb.exec('INSERT INTO Foo (A) VALUES (\"thing2\")');\n            await sdb.exec('INSERT INTO Foo (A) VALUESBORKBORKBORK (\"thing3\")');\n          });\n        }),\n        /SQLITE_ERROR/);\n      assert.lengthOf(await sdb.all(\"SELECT A FROM Foo\"), 0);\n    });\n  });\n\n  it(\"should nest execTransaction calls robustly regardless of timing\", async function() {\n    const sdb = await SQLiteDB.openDB(dbPath(\"testTrans5\"), schemaInfo, OpenMode.OPEN_CREATE);\n    await sdb.exec('INSERT INTO Foo (A,B) VALUES (\"key\", 1)');\n    await Promise.all([\n      sdb.execTransaction(async () => {\n        // Give an opportunity for another operation to be snuck in.\n        await delay(1000);\n        await sdb.exec('UPDATE Foo SET B = 2 WHERE A = \"key\"');\n      }),\n      // Wait a little so body of previous transaction has started.\n      // This used to be enough to let us sneak an operation into\n      // it, potentially in the wrong order.\n      delay(100).then(() => sdb.execTransaction(async () => {\n        await sdb.exec('UPDATE Foo SET B = 3 WHERE A = \"key\"');\n      })),\n    ]);\n    assert.equal((await sdb.get('SELECT B FROM Foo WHERE A = \"key\"'))!.B, 3);\n  });\n\n  it(\"should forbid ATTACHed databases\", async function() {\n    const db0 = await SQLiteDB.openDB(dbPath(\"testAttach0\"), schemaInfo, OpenMode.OPEN_CREATE);\n    await db0.close();\n    const db1 = await SQLiteDB.openDB(dbPath(\"testAttach1\"), schemaInfo, OpenMode.OPEN_CREATE);\n    await assert.isRejected(db1.exec(`ATTACH '${dbPath(\"testAttach0\")}' AS zing`),\n      /SQLITE_ERROR: too many attached databases - max 0/);\n    await db1.close();\n  });\n\n  it(\"should honor pauses\", async function() {\n    const sdb = await SQLiteDB.openDB(dbPath(\"testPause\"), schemaInfo, OpenMode.OPEN_CREATE);\n\n    // Time that is certainly enough for SQLite statements to complete when not paused.\n    const delayMs = 200;\n\n    // Modification should happen immediately.\n    assert.isFalse(await timeoutReached(\n      delayMs,\n      sdb.exec(\"CREATE TABLE Foo1(id INTEGER)\"),\n    ));\n\n    // Modification should not happen immediately once paused.\n    sdb.pause();\n    assert.isTrue(await timeoutReached(\n      delayMs,\n      sdb.exec(\"CREATE TABLE Foo2(id INTEGER)\"),\n    ));\n\n    // After unpausing we should be back to immediate.\n    sdb.unpause();\n    assert.isFalse(await timeoutReached(\n      delayMs,\n      sdb.exec(\"CREATE TABLE Foo3(id INTEGER)\"),\n    ));\n\n    // And we should be able to pause again.\n    sdb.pause();\n    assert.isTrue(await timeoutReached(\n      delayMs,\n      sdb.exec(\"CREATE TABLE Foo4(id INTEGER)\"),\n    ));\n\n    // Check that all tables were made eventually once we\n    // unpause and wait a little bit, since the exec() calls\n    // above are never cancelled.\n    sdb.unpause();\n    await delay(delayMs);\n    await assert.isRejected(sdb.all(\"SELECT * FROM Foo0\"));\n    await assert.isFulfilled(sdb.all(\"SELECT * FROM Foo1\"));\n    await assert.isFulfilled(sdb.all(\"SELECT * FROM Foo2\"));\n    await assert.isFulfilled(sdb.all(\"SELECT * FROM Foo3\"));\n    await assert.isFulfilled(sdb.all(\"SELECT * FROM Foo4\"));\n\n    // Pause and start a write before closing.\n    sdb.pause();\n    assert.isTrue(await timeoutReached(\n      delayMs,\n      sdb.exec(\"CREATE TABLE Foo5(id INTEGER)\"),\n    ));\n\n    await sdb.close();\n    // Take a little time to give that last write a chance (we don't\n    // expect it to write though, it should be cancelled).\n    await delay(delayMs);\n\n    // Check the last write didn't happen.\n    const sdb2 = await SQLiteDB.openDB(dbPath(\"testPause\"), schemaInfo, OpenMode.OPEN_EXISTING);\n    await assert.isFulfilled(sdb2.all(\"SELECT * FROM Foo4\"));\n    await assert.isRejected(sdb2.all(\"SELECT * FROM Foo5\"));\n    await sdb2.close();\n  });\n\n  // This used to tickle a deadlock.\n  it(\"should handle pauses and transactions\", async function() {\n    const sdb = await SQLiteDB.openDB(dbPath(\"testPauseWithTransaction\"), schemaInfo, OpenMode.OPEN_CREATE);\n    const t1 = sdb.execTransaction(async () => {\n      await sdb.exec(\"create table if not exists data(x,y,z)\");\n    });\n    const t2 = sdb.execTransaction(async () => {\n      await sdb.exec(\"create table if not exists data(x,y,z)\");\n    });\n    const t3 = sdb.execTransaction(async () => {\n      await sdb.exec(\"create table if not exists data(x,y,z)\");\n    });\n    sdb.pause();\n    assert.isFalse(await timeoutReached(\n      1000,\n      Promise.all([t1, t2, t3]),\n    ));\n  });\n});\n\n// The schema we use in the tests.\nconst schemaInfo: SchemaInfo = {\n  async create(db: SQLiteDB) {\n    await db.exec(\"CREATE TABLE Foo (A TEXT, B NUMERIC)\");\n    await db.exec(\"CREATE TABLE Bar (C TEXT, D NUMERIC)\");\n  },\n  migrations: [\n    async function(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE Foo (A TEXT)\");\n    },\n    async function(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE Bar (D NUMERIC)\");\n      await db.exec(\"ALTER TABLE Foo ADD COLUMN B NUMERIC\");\n    },\n    async function(db: SQLiteDB) {\n      await db.exec(\"ALTER TABLE Bar ADD COLUMN C TEXT\");\n    },\n  ],\n};\n\n// Version 1 of the schema above. This illustratees how code gets updated when schema evolves.\n// Note that migration array only gets new steps, not changes to existing ones.\nconst schemaInfoV1: SchemaInfo = {\n  async create(db: SQLiteDB) {\n    await db.exec(\"CREATE TABLE Foo (A TEXT)\");\n  },\n  migrations: [\n    async function(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE Foo (A TEXT)\");\n    },\n  ],\n};\n\n// Version 2 of the schema above.\nconst schemaInfoV2: SchemaInfo = {\n  async create(db: SQLiteDB) {\n    await db.exec(\"CREATE TABLE Foo (A TEXT, B NUMERIC)\");\n    await db.exec(\"CREATE TABLE Bar (D NUMERIC)\");\n  },\n  migrations: [\n    async function(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE Foo (A TEXT)\");\n    },\n    async function(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE Bar (D NUMERIC)\");\n      await db.exec(\"ALTER TABLE Foo ADD COLUMN B NUMERIC\");\n    },\n  ],\n};\n\n// Inconsistent schema version.\nconst schemaInfoV2Inconsistent: SchemaInfo = {\n  create: schemaInfoV2.create,\n  migrations: [\n    async function(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE Foo (A TEXT)\");\n    },\n    async function(db: SQLiteDB) {\n      await db.exec(\"CREATE TABLE Bar (D NUMERIC)\");\n      await db.exec(\"ALTER TABLE Foo ADD COLUMN BadCol NUMERIC\");\n    },\n  ],\n};\n\n// Broken schema version.\nconst schemaInfoV4Broken: SchemaInfo = {\n  create: schemaInfo.create,\n  migrations: [\n    ...schemaInfo.migrations,\n    async function(db: SQLiteDB) {\n      await db.exec(\"SOME_INVALID_SQL\");\n    },\n  ],\n};\n"
  },
  {
    "path": "test/server/lib/SamlConfig.ts",
    "content": "import { cookieName } from \"app/server/lib/gristSessions\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport * as path from \"path\";\nimport zlib from \"zlib\";\n\nimport { assert } from \"chai\";\nimport * as cookie from \"cookie\";\nimport * as fse from \"fs-extra\";\nimport fetch from \"node-fetch\";\n\ndescribe(\"SamlConfig\", () => {\n  testUtils.setTmpLogLevel(\"error\");\n\n  const spKey = path.resolve(testUtils.fixturesRoot, \"saml/saml.key\");\n  const spCert = path.resolve(testUtils.fixturesRoot, \"saml/saml.crt\");\n  const idpCert = path.resolve(testUtils.fixturesRoot, \"saml/keycloak.pem\");\n  const spHost = \"https://grist.localhost\";\n  // A keycloak-styled URL\n  const idpHost = \"http://localhost:8080/realms/grist/protocol/saml/clients/grist\";\n\n  // Static SAML assertion fixtures, valid until February 17, 2075.\n  // May Grist reign's be long.\n  const loginSamlPath = path.resolve(testUtils.fixturesRoot, \"saml/saml-login\");\n  const logoutSamlPath = path.resolve(testUtils.fixturesRoot, \"saml/saml-logout\");\n\n  const setupTestServer = (enabled: boolean) => {\n    let homeUrl: string;\n    let oldEnv: testUtils.EnvironmentSnapshot;\n    let server: TestServer;\n\n    const env = enabled ? {\n      GRIST_SAML_SP_HOST: spHost,\n      GRIST_SAML_IDP_UNENCRYPTED: \"1\",\n      GRIST_SAML_IDP_LOGIN: idpHost,\n      GRIST_SAML_IDP_LOGOUT: idpHost,\n      GRIST_SAML_IDP_CERTS: idpCert,\n      GRIST_SAML_SP_KEY: spKey,\n      GRIST_SAML_SP_CERT: spCert,\n    } : {};\n\n    beforeEach(async function() {\n      oldEnv = new testUtils.EnvironmentSnapshot();\n      process.env.TYPEORM_DATABASE = \":memory:\";\n      process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = \"core\";\n      Object.assign(process.env, env);\n      server = new TestServer(this);\n      homeUrl = await server.start();\n    });\n\n    afterEach(async () => {\n      oldEnv.restore();\n      await server.stop();\n    });\n\n    return {\n      homeUrl: () => homeUrl,\n      sessionUrl: () => `${homeUrl}/test/session`,\n      getDbManager: () => server.dbManager,\n    };\n  };\n\n  describe(\"when disabled\", () => {\n    const { homeUrl } = setupTestServer(false);\n    it(\"should not have the metadata url\", async () => {\n      const url = `${homeUrl()}/saml/metadata.xml`;\n      const res = await fetch(url);\n      assert.equal(res.status, 404);\n    });\n  });\n\n  describe(\"when enabled\", () => {\n    const { homeUrl, sessionUrl, getDbManager } = setupTestServer(true);\n\n    it(\"should return the server identity\", async () => {\n      const url = `${homeUrl()}/saml/metadata.xml`;\n      const res = await fetch(url);\n      const xml = await res.text();\n      assert.match(xml, /entityID=\"https:\\/\\/grist.localhost\\/saml\\/metadata.xml\"/);\n    });\n\n    it(\"should allow logins and logouts via redirect\", async () => {\n      // Ensure a clean start\n      let jordi = await getDbManager().getExistingUserByLogin(\"jordi@getgrist.com\");\n      assert.equal(jordi, undefined, \"jordi should not exist\");\n\n      // Get a session started\n      const sessionResp = await fetch(sessionUrl());\n      const sid = cookie.parse(sessionResp.headers.get(\"set-cookie\"))[cookieName];\n      const headers = { Cookie: `${cookieName}=${sid}` };\n\n      // Let's get redirected to the SAML IdP\n      const loginResp = await fetch(`${homeUrl()}/saml/login`, {\n        redirect: \"manual\",\n        headers,\n      });\n      assert.equal(loginResp.status, 302, \"should redirect\");\n\n      const idpUrl = new URL(loginResp.headers.get(\"location\") || \"\");\n      assert.equal(idpUrl.origin + idpUrl.pathname, idpHost, \"should redirect to IdP host\");\n      const relayState = idpUrl.searchParams.get(\"RelayState\") || \"\";\n      assert.match(relayState, /permit-external-[0-9a-f-]+/, \"should have a relay state\");\n      const samlRequest = decodeSaml(idpUrl.searchParams.get(\"SAMLRequest\") || \"\");\n      assert.match(samlRequest, new RegExp(`AssertionConsumerServiceURL=\"${spHost}/saml/assert\"`),\n        \"SAML request should redirect back to Grist\");\n\n      // Now we pretend that the IdP server authenticated the user and\n      // sent back a SAML login, which we post back to the SAML\n      // processing endpoint.\n      const loginSamlPayload = await fse.readFile(loginSamlPath);\n      const samlResp = await fetch(`${homeUrl()}/saml/assert`, {\n        redirect: \"manual\",\n        method: \"POST\",\n        body: new URLSearchParams({\n          SAMLResponse: loginSamlPayload.toString(),\n          RelayState: relayState,\n        }),\n      });\n      assert.equal(samlResp.status, 302, \"should redirect\");\n      const spUrl = new URL(samlResp.headers.get(\"location\") || \"\");\n      assert.equal(spUrl.origin + spUrl.pathname, homeUrl() + \"/\", \"should redirect to main\");\n\n      // Finish following the redirect\n      await fetch(spUrl.href, { headers });\n\n      // Let's check the user was created via SAML\n      jordi = await getDbManager().getExistingUserByLogin(\"jordi@getgrist.com\");\n      assert.ok(jordi, \"jordi should exist\");\n      assert.ok(jordi?.firstLoginAt instanceof Date, \"jordi should have logged in\");\n\n      // Finally, we log out\n      const logoutResp = await fetch(`${homeUrl()}/o/docs/logout`, {\n        redirect: \"manual\",\n        headers,\n      });\n      const idpLogoutUrl = new URL(logoutResp.headers.get(\"location\") || \"\");\n      assert.equal(idpLogoutUrl.origin + idpLogoutUrl.pathname, idpHost, \"should redirect to IdP host\");\n      const samlLogoutRequest = decodeSaml(idpLogoutUrl.searchParams.get(\"SAMLRequest\") || \"\");\n      assert.match(samlLogoutRequest, /samlp:LogoutRequest/,\n        \"SAML logout request should have the right root element\");\n\n      const logoutRelayState = idpLogoutUrl.searchParams.get(\"RelayState\") || \"\";\n      assert.match(logoutRelayState, /permit-external-[0-9a-f-]+/, \"should have a relay state\");\n\n      // Pretend that the IdP server sent back a logout response\n      const logoutSamlPayload = await fse.readFile(logoutSamlPath);\n      const samlLogoutResp = await fetch(`${homeUrl()}/saml/assert`, {\n        redirect: \"manual\",\n        method: \"POST\",\n        body: new URLSearchParams({\n          SAMLResponse: logoutSamlPayload.toString(),\n          RelayState: logoutRelayState,\n        }),\n      });\n      assert.equal(samlLogoutResp.status, 302, \"should redirect\");\n      const spLogoutUrl = new URL(samlLogoutResp.headers.get(\"location\") || \"\");\n      assert.equal(\n        spLogoutUrl.origin + spLogoutUrl.pathname,\n        homeUrl() + \"/o/docs/signed-out\",\n        \"should redirect to main\",\n      );\n\n      // Finish the logout redirect\n      await fetch(`${homeUrl()}/o/docs/signed-out`, { headers });\n    });\n\n    it(\"should allow IdP-initiated logins\", async () => {\n      // Ensure a clean start\n      let jordi = await getDbManager().getExistingUserByLogin(\"jordi@getgrist.com\");\n      assert.equal(jordi, undefined, \"jordi should not exist\");\n\n      // Grist, our SP, is sitting there quietly, minding its own\n      // business, when suddenly AN UNSOLICITED SAML LOGIN APPEARS.\n      const loginSamlPayload = await fse.readFile(loginSamlPath);\n      const samlResp = await fetch(`${homeUrl()}/saml/assert`, {\n        redirect: \"manual\",\n        method: \"POST\",\n        body: new URLSearchParams({\n          SAMLResponse: loginSamlPayload.toString(),\n          // No RelayState, the SAML comes unrequested, nothing to relay\n        }),\n      });\n      const sid = cookie.parse(samlResp.headers.get(\"set-cookie\")).SameSite.split(\"=\")[1];\n      const headers = { Cookie: `${cookieName}=${sid}` };\n\n      assert.equal(samlResp.status, 302, \"should redirect\");\n      const spUrl = new URL(samlResp.headers.get(\"location\") || \"\");\n      assert.equal(spUrl.origin + spUrl.pathname, homeUrl() + \"/\", \"should redirect to main\");\n\n      // Finish following the redirect\n      await fetch(spUrl.href, { headers });\n\n      // Let's check the user was created via SAML\n      jordi = await getDbManager().getExistingUserByLogin(\"jordi@getgrist.com\");\n      assert.ok(jordi, \"jordi should exist\");\n      assert.ok(jordi?.firstLoginAt instanceof Date, \"jordi should have logged in\");\n    });\n\n    it(\"should follow redirects from IdP-initiated logins\", async () => {\n      // Grist, our unsuspecting SP, once again sits quietly when\n      // suddenly A SAML REQUEST WITH VALID REDIRECT COMES OUTTA\n      // NOWHERE\n      const loginSamlPayload = await fse.readFile(loginSamlPath);\n      const samlResp = await fetch(`${homeUrl()}/saml/assert`, {\n        redirect: \"manual\",\n        method: \"POST\",\n        body: new URLSearchParams({\n          SAMLResponse: loginSamlPayload.toString(),\n          RelayState: `${homeUrl()}/admin`,\n        }),\n      });\n      assert.equal(samlResp.status, 302, \"should redirect\");\n      const spUrl = new URL(samlResp.headers.get(\"location\") || \"\");\n      assert.equal(spUrl.origin + spUrl.pathname, `${homeUrl()}/admin`, \"should redirect to admin\");\n    });\n\n    it(\"should ignore invalid redirects from IdP-initiated logins\", async () => {\n      // This time, Grist, our innocent SP as before, is not caught\n      // unaware when a SAML assert arrives, but without a valid\n      // redirection URL.\n      const loginSamlPayload = await fse.readFile(loginSamlPath);\n      const samlResp = await fetch(`${homeUrl()}/saml/assert`, {\n        redirect: \"manual\",\n        method: \"POST\",\n        body: new URLSearchParams({\n          SAMLResponse: loginSamlPayload.toString(),\n          RelayState: `https://evilcorp.com`,\n        }),\n      });\n      assert.equal(samlResp.status, 302, \"should redirect\");\n      const spUrl = new URL(samlResp.headers.get(\"location\") || \"\");\n      assert.equal(spUrl.origin + spUrl.pathname, homeUrl() + \"/\", \"should redirect to main\");\n    });\n  });\n});\n\nfunction decodeSaml(encodedSAML: string) {\n  const buffer = Buffer.from(encodedSAML, \"base64\");\n  return zlib.inflateRawSync(buffer).toString();\n}\n"
  },
  {
    "path": "test/server/lib/Scim.ts",
    "content": "import { isAffirmative } from \"app/common/gutil\";\nimport { UserType } from \"app/common/User\";\nimport { Group } from \"app/gen-server/entity/Group\";\nimport log from \"app/server/lib/log\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios, { AxiosResponse } from \"axios\";\nimport { assert } from \"chai\";\nimport capitalize from \"lodash/capitalize\";\nimport { Context } from \"mocha\";\nimport Sinon from \"sinon\";\n\nfunction scimConfigForUser(user: string) {\n  const config = configForUser(user);\n  return {\n    ...config,\n    headers: {\n      ...config.headers,\n      \"Content-Type\": \"application/scim+json\",\n    },\n  };\n}\n\nconst chimpy = scimConfigForUser(\"Chimpy\");\nconst kiwi = scimConfigForUser(\"Kiwi\");\nconst charon = scimConfigForUser(\"Charon\");\nconst anon = scimConfigForUser(\"Anonymous\");\nconst shouldCleanup = !isAffirmative(process.env.NO_CLEANUP);\n\nconst USER_CONFIG_BY_NAME = {\n  chimpy,\n  kiwi,\n  anon,\n};\n\ntype UserConfigByName = typeof USER_CONFIG_BY_NAME;\n\ndescribe(\"Scim\", () => {\n  const SCIM_ENV_VARS = {\n    GRIST_ENABLE_SCIM: \"1\",\n    GRIST_DEFAULT_EMAIL: \"chimpy@getgrist.com\",\n    GRIST_SCIM_EMAIL: \"charon@getgrist.com\",\n  };\n  const SEARCH_SCHEMA = \"urn:ietf:params:scim:api:messages:2.0:SearchRequest\";\n  testUtils.setTmpLogLevel(\"alert\");\n\n  const setupTestServer = (env: NodeJS.ProcessEnv) => {\n    let homeUrl: string;\n    let oldEnv: testUtils.EnvironmentSnapshot;\n    let server: TestServer;\n    type Cleanup = (this: Context) => void | Promise<void>;\n    const cleanups: Cleanup[] = [];\n\n    before(async function() {\n      this.timeout(10_000);\n      oldEnv = new testUtils.EnvironmentSnapshot();\n      Object.assign(process.env, env);\n      server = new TestServer(this);\n      homeUrl = await server.start();\n    });\n\n    after(async function() {\n      for (const cleanup of cleanups) {\n        await cleanup.call(this);\n      }\n      oldEnv.restore();\n      await server.stop();\n    });\n\n    return {\n      scimUrl: (path: string) => (homeUrl + \"/api/scim/v2\" + path),\n      getDbManager: () => server.dbManager,\n      getServer: () => server,\n      cleanupPreShutdown: (cleanup: Cleanup) => cleanups.push(cleanup),\n    };\n  };\n\n  describe(\"when disabled\", function() {\n    const { scimUrl } = setupTestServer({});\n\n    it(\"should return 501 for /api/scim/v2/Users\", async function() {\n      const res = await axios.get(scimUrl(\"/Users\"), chimpy);\n      assert.equal(res.status, 501);\n      assert.deepEqual(res.data, { error: \"SCIM API is not enabled\" });\n    });\n  });\n\n  describe(\"when enabled using GRIST_ENABLE_SCIM=1\", function() {\n    const { scimUrl, getDbManager, getServer } = setupTestServer(SCIM_ENV_VARS);\n    const userIdByName: { [name in keyof UserConfigByName]?: number } = {};\n    let logWarnStub: Sinon.SinonStub;\n    let logErrorStub: Sinon.SinonStub;\n\n    before(async function() {\n      const userNames = Object.keys(USER_CONFIG_BY_NAME) as (keyof UserConfigByName)[];\n      for (const user of userNames) {\n        userIdByName[user] = await getOrCreateUserId(user);\n      }\n    });\n\n    beforeEach(() => {\n      logWarnStub = Sinon.stub(log, \"warn\");\n      logErrorStub = Sinon.stub(log, \"error\");\n    });\n\n    afterEach(() => {\n      logWarnStub.restore();\n      logErrorStub.restore();\n    });\n\n    function personaToSCIMMYUserWithId(user: keyof UserConfigByName) {\n      return toSCIMUserWithId(user, userIdByName[user]!);\n    }\n\n    function toSCIMUserWithId(user: string, id: number) {\n      return {\n        ...toSCIMUserWithoutId(user),\n        id: String(id),\n        meta: { resourceType: \"User\", location: \"/api/scim/v2/Users/\" + id },\n      };\n    }\n\n    function toSCIMUserWithoutId(user: string) {\n      return {\n        schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n        userName: user + \"@getgrist.com\",\n        name: { formatted: capitalize(user) },\n        displayName: capitalize(user),\n        preferredLanguage: \"en\",\n        locale: \"en\",\n        emails: [{ value: user + \"@getgrist.com\", primary: true }],\n      };\n    }\n\n    async function getOrCreateUserId(user: string, { type }: { type?: UserType } = {}) {\n      const domain = type === \"service\" ? \"serviceaccounts.invalid\" : \"getgrist.com\";\n      return (await getDbManager().getUserByLogin(`${user}@${domain}`, {}, type)).id;\n    }\n\n    async function cleanupUser(userId: number) {\n      if (await getDbManager().getUser(userId)) {\n        await getDbManager().deleteUser({ userId: userId }, userId);\n      }\n    }\n\n    async function checkOperationOnTechUserDisallowed({ op, opType}: {\n      op: (id: number) => Promise<AxiosResponse>,\n      opType: string\n    }) {\n      const db = getDbManager();\n      const specialUsers = {\n        anonymous: db.getAnonymousUserId(),\n        support: db.getSupportUserId(),\n        everyone: db.getEveryoneUserId(),\n        preview: db.getPreviewerUserId(),\n      };\n      for (const [label, id] of Object.entries(specialUsers)) {\n        const res = await op(id);\n        assert.equal(res.status, 403, `should forbid ${opType} of the special user ${label}`);\n        assert.deepEqual(res.data, {\n          schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n          status: \"403\",\n          detail: `System user ${opType} not permitted.`,\n        });\n      }\n    }\n\n    async function withUserName(userName: string, cb: (userName: string) => Promise<void>) {\n      try {\n        await cb(userName);\n      } finally {\n        const user = await getDbManager().getExistingUserByLogin(userName + \"@getgrist.com\");\n        if (user && shouldCleanup) {\n          await cleanupUser(user.id);\n        }\n      }\n    }\n\n    function checkCommonErrors(\n      method: \"get\" | \"post\" | \"put\" | \"patch\" | \"delete\",\n      path: string,\n      validBody: object = {},\n    ) {\n      function makeCallWith(user: keyof UserConfigByName) {\n        if (method === \"get\" || method === \"delete\") {\n          return axios[method](scimUrl(path), USER_CONFIG_BY_NAME[user]);\n        }\n        return axios[method](scimUrl(path), validBody, USER_CONFIG_BY_NAME[user]);\n      }\n\n      it(\"should return 401 for anonymous\", async function() {\n        const res = await makeCallWith(\"anon\");\n        assert.equal(res.status, 401);\n      });\n\n      it(\"should return 403 for kiwi\", async function() {\n        const res = await makeCallWith(\"kiwi\");\n        assert.deepEqual(res.data, {\n          schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n          status: \"403\",\n          detail: \"You are not authorized to access this resource\",\n        });\n        assert.equal(res.status, 403);\n      });\n\n      it(\"should return a 500 in case of unknown Error\", async function() {\n        const sandbox = Sinon.createSandbox();\n        try {\n          const error = new Error(\"Some unexpected Error\");\n\n          // Stub all the dbManager methods called by the controller\n          sandbox.stub(getDbManager(), \"getUsers\").throws(error);\n          sandbox.stub(getDbManager(), \"getUser\").throws(error);\n          sandbox.stub(getDbManager(), \"getUserByLoginWithRetry\").throws(error);\n          sandbox.stub(getDbManager(), \"overwriteUser\").throws(error);\n          sandbox.stub(getDbManager(), \"deleteUser\").throws(error);\n          sandbox.stub(getDbManager(), \"getGroupWithMembersById\").throws(error);\n          sandbox.stub(getDbManager(), \"getGroupsWithMembersByType\").throws(error);\n          sandbox.stub(getDbManager(), \"getGroupsWithMembers\").throws(error);\n          sandbox.stub(getDbManager(), \"createGroup\").throws(error);\n          sandbox.stub(getDbManager(), \"overwriteTeamGroup\").throws(error);\n          sandbox.stub(getDbManager(), \"overwriteRoleGroup\").throws(error);\n          sandbox.stub(getDbManager(), \"deleteGroup\").throws(error);\n\n          const res = await makeCallWith(\"chimpy\");\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"500\",\n            detail: error.message,\n          });\n          assert.equal(res.status, 500);\n        } finally {\n          sandbox.restore();\n        }\n      });\n    }\n\n    describe(\"/Me\", function() {\n      async function checkGetMeAs(user: keyof UserConfigByName, expected: any) {\n        const res = await axios.get(scimUrl(\"/Me\"), USER_CONFIG_BY_NAME[user]);\n        assert.equal(res.status, 200);\n        assert.deepInclude(res.data, expected);\n      }\n\n      it(`should return the current user for chimpy`, async function() {\n        return checkGetMeAs(\"chimpy\", personaToSCIMMYUserWithId(\"chimpy\"));\n      });\n\n      it(`should return the current user for kiwi`, async function() {\n        return checkGetMeAs(\"kiwi\", personaToSCIMMYUserWithId(\"kiwi\"));\n      });\n\n      it(\"should return 401 for anonymous\", async function() {\n        const res = await axios.get(scimUrl(\"/Me\"), anon);\n        assert.equal(res.status, 401);\n      });\n    });\n\n    describe(\"/Users\", function() {\n      describe(\"GET /Users/{id}\", function() {\n        it(\"should return the user of id=1 for chimpy\", async function() {\n          const res = await axios.get(scimUrl(\"/Users/1\"), chimpy);\n\n          assert.equal(res.status, 200);\n          assert.deepInclude(res.data, {\n            schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n            id: \"1\",\n            displayName: \"Chimpy\",\n            userName: \"chimpy@getgrist.com\",\n          });\n        });\n\n        it(\"should return 404 when the user is not found\", async function() {\n          const res = await axios.get(scimUrl(\"/Users/1000\"), chimpy);\n          assert.equal(res.status, 404);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"404\",\n            detail: \"User with ID 1000 not found\",\n          });\n        });\n\n        it(\"should return 404 when the user is not of type login\", async function() {\n          const serviceUserId = await getOrCreateUserId(\"alfred\", { type: \"service\" });\n          const res = await axios.get(scimUrl(`/Users/${serviceUserId}`), chimpy);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"404\",\n            detail: `User with ID ${serviceUserId} not found`,\n          });\n          assert.equal(res.status, 404);\n        });\n\n        checkCommonErrors(\"get\", \"/Users/1\");\n      });\n\n      describe(\"GET /Users\", function() {\n        it(\"should return all users for chimpy\", async function() {\n          const res = await axios.get(scimUrl(\"/Users\"), chimpy);\n          assert.equal(res.status, 200);\n          assert.isAbove(res.data.totalResults, 0, \"should have retrieved some users\");\n          assert.deepInclude(res.data.Resources, personaToSCIMMYUserWithId(\"chimpy\"));\n          assert.deepInclude(res.data.Resources, personaToSCIMMYUserWithId(\"kiwi\"));\n        });\n\n        it(\"should handle pagination\", async function() {\n          const endpointPaginated = \"/Users?count=1&sortBy=id\";\n          {\n            const firstPage = await axios.get(scimUrl(endpointPaginated), chimpy);\n            assert.equal(firstPage.status, 200);\n            assert.lengthOf(firstPage.data.Resources, 1);\n            const firstPageResourceId = parseInt(firstPage.data.Resources[0].id);\n            assert.equal(firstPageResourceId, 1);\n          }\n\n          {\n            const secondPage = await axios.get(scimUrl(endpointPaginated + \"&startIndex=2\"), chimpy);\n            assert.equal(secondPage.status, 200);\n            assert.lengthOf(secondPage.data.Resources, 1);\n            const secondPageResourceId = parseInt(secondPage.data.Resources[0].id);\n            assert.equal(secondPageResourceId, 2);\n          }\n        });\n\n        it('should skip users of type other than \"login\"', async function() {\n          const serviceUserId = await getOrCreateUserId(\"alfred\", { type: \"service\" });\n          const res = await axios.get(scimUrl(\"/Users\"), chimpy);\n          assert.isEmpty(res.data.Resources.filter((user: any) => user.id === serviceUserId));\n        });\n\n        checkCommonErrors(\"get\", \"/Users\");\n      });\n\n      describe(\"POST /Users/.search\", function() {\n        const SEARCH_SCHEMA = \"urn:ietf:params:scim:api:messages:2.0:SearchRequest\";\n\n        const searchExample = {\n          schemas: [SEARCH_SCHEMA],\n          sortBy: \"userName\",\n          sortOrder: \"descending\",\n        };\n\n        it(\"should return all users for chimpy ordered by userName in descending order\", async function() {\n          const res = await axios.post(scimUrl(\"/Users/.search\"), searchExample, chimpy);\n          assert.equal(res.status, 200);\n          assert.isAbove(res.data.totalResults, 0, \"should have retrieved some users\");\n          const users = res.data.Resources.map((r: any) => r.userName);\n          assert.include(users, \"chimpy@getgrist.com\");\n          assert.include(users, \"kiwi@getgrist.com\");\n          const indexOfChimpy = users.indexOf(\"chimpy@getgrist.com\");\n          const indexOfKiwi = users.indexOf(\"kiwi@getgrist.com\");\n          assert.isBelow(indexOfKiwi, indexOfChimpy, \"kiwi should come before chimpy\");\n        });\n\n        it(\"should also allow access for user Charon (the one refered in GRIST_SCIM_EMAIL)\", async function() {\n          const res = await axios.post(scimUrl(\"/Users/.search\"), searchExample, charon);\n          assert.equal(res.status, 200);\n        });\n\n        it(\"should filter the users by partial displayName\", async function() {\n          const res = await axios.post(scimUrl(\"/Users/.search\"), {\n            schemas: [SEARCH_SCHEMA],\n            attributes: [\"userName\"],\n            filter: 'displayName sw \"Chi\"',\n          }, chimpy);\n          assert.equal(res.status, 200);\n          assert.equal(res.data.totalResults, 1);\n          assert.deepEqual(res.data.Resources[0], {\n            id: String(userIdByName.chimpy),\n            userName: \"chimpy@getgrist.com\",\n          },\n          \"should have retrieved only chimpy's username and not other attribute\");\n        });\n\n        it(\"should filter the users by userName being set\", async function() {\n          // This filter should not take the condition branch of the optimization.\n          // Check that the operator is processed by SCIMMY and does not raise an error.\n          const res = await axios.post(scimUrl(\"/Users/.search\"), {\n            schemas: [SEARCH_SCHEMA],\n            attributes: [\"userName\"],\n            filter: \"userName pr\",\n          }, chimpy);\n          assert.equal(res.status, 200);\n          assert.isAbove(res.data.totalResults, 1);\n          assert.deepEqual(res.data.Resources[0], {\n            id: String(userIdByName.chimpy),\n            userName: \"chimpy@getgrist.com\",\n          },\n          \"should have retrieved only chimpy's username and not other attribute\");\n        });\n\n        it(\"should reject when the filter on userName contains an invalid operator\", async function() {\n          const res = await axios.post(scimUrl(\"/Users/.search\"), {\n            schemas: [SEARCH_SCHEMA],\n            filter: \"userName blah\",\n          }, chimpy);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"400\",\n            detail: \"Unexpected token 'blah' in filter\",\n            scimType: \"invalidFilter\",\n          });\n          assert.equal(res.status, 400);\n        });\n\n        for (const criterion of [\n          'username eq \"chimpy@getgrist.com\"',\n          'username sw \"chimpy\"',\n          'username ew \"py@getgrist.com\"',\n          'username co \"mpy\"',\n        ]) {\n          it(`should find chimpy given this criteria : '${criterion}'`, async function() {\n            const res = await axios.post(scimUrl(\"/Users/.search\"), {\n              schemas: [SEARCH_SCHEMA],\n              attributes: [\"userName\"],\n              filter: criterion,\n            }, chimpy);\n            assert.equal(res.status, 200);\n            assert.equal(res.data.totalResults, 1);\n            assert.deepEqual(res.data.Resources[0], {\n              id: String(userIdByName.chimpy),\n              userName: \"chimpy@getgrist.com\",\n            },\n            `should have retrieved only chimpy's username when searching using: '${criterion}'`);\n          });\n        }\n\n        it(`should exclude chimpy when searching using 'username ne \"chimpy@getgrist.com\"'`, async function() {\n          const resAllUsers = await axios.get(scimUrl(\"/Users?count=1000\"), chimpy);\n          assert.equal(resAllUsers.status, 200);\n          const allUsers = resAllUsers.data.Resources;\n\n          const res = await axios.post(scimUrl(\"/Users/.search\"), {\n            schemas: [SEARCH_SCHEMA],\n            attributes: [\"userName\"],\n            filter: 'username ne \"chimpy@getgrist.com\"',\n          }, chimpy);\n          assert.equal(res.status, 200);\n          assert.equal(res.data.totalResults, allUsers.length - 1);\n          assert.isFalse(res.data.Resources.some((r: any) => r.userName === \"chimpy@getgrist.com\"));\n        });\n\n        it(\"should work with comparison operators\", async function() {\n          await withUserName(\"aa\", async (username) => {\n            const expectedUserId = await getOrCreateUserId(username);\n            const resLt = await axios.post(scimUrl(\"/Users/.search\"), {\n              schemas: [SEARCH_SCHEMA],\n              attributes: [\"userName\"],\n              filter: 'username lt \"ab@getgrist.com\"',\n            }, chimpy);\n            assert.equal(resLt.status, 200);\n            assert.equal(resLt.data.totalResults, 1);\n            assert.equal(resLt.data.Resources[0].id, expectedUserId);\n\n            const resLe = await axios.post(scimUrl(\"/Users/.search\"), {\n              schemas: [SEARCH_SCHEMA],\n              attributes: [\"userName\"],\n              filter: 'username le \"aa@getgrist.com\"',\n            }, chimpy);\n            assert.equal(resLe.status, 200);\n            assert.equal(resLe.data.totalResults, 1);\n            assert.equal(resLe.data.Resources[0].id, expectedUserId);\n          });\n\n          await withUserName(\"zz\", async (username) => {\n            const expectedUserId = await getOrCreateUserId(username);\n            const resGt = await axios.post(scimUrl(\"/Users/.search\"), {\n              schemas: [SEARCH_SCHEMA],\n              attributes: [\"userName\"],\n              filter: 'username gt \"zy@getgrist.com\"',\n            }, chimpy);\n            assert.equal(resGt.status, 200);\n            assert.equal(resGt.data.totalResults, 1);\n            assert.equal(resGt.data.Resources[0].id, expectedUserId);\n\n            const resGe = await axios.post(scimUrl(\"/Users/.search\"), {\n              schemas: [SEARCH_SCHEMA],\n              attributes: [\"userName\"],\n              filter: 'username ge \"zy@getgrist.com\"',\n            }, chimpy);\n            assert.equal(resGe.status, 200);\n            assert.equal(resGe.data.totalResults, 1);\n            assert.equal(resGe.data.Resources[0].id, expectedUserId);\n          });\n        });\n\n        it(\"should escape the pattern used for LIKE\", async function() {\n          await withUserName(\"hello%%world\", async (username) => {\n            await getOrCreateUserId(username);\n            const shouldMatch = await axios.post(scimUrl(\"/Users/.search\"), {\n              schemas: [SEARCH_SCHEMA],\n              attributes: [\"userName\"],\n              filter: 'username co \"lo%%world\"',\n            }, chimpy);\n            assert.equal(shouldMatch.status, 200);\n            assert.equal(shouldMatch.data.totalResults, 1);\n\n            const shouldNotMatch = await axios.post(scimUrl(\"/Users/.search\"), {\n              schemas: [SEARCH_SCHEMA],\n              attributes: [\"userName\"],\n              filter: 'username co \"lo%world\"',\n            }, chimpy);\n            assert.equal(shouldNotMatch.status, 200);\n            assert.equal(shouldNotMatch.data.totalResults, 0);\n          });\n        });\n\n        checkCommonErrors(\"post\", \"/Users/.search\", searchExample);\n      });\n\n      describe(\"POST /Users\", function() { // Create a new users\n        it(\"should create a new user\", async function() {\n          await withUserName(\"newuser1\", async (userName) => {\n            const res = await axios.post(scimUrl(\"/Users\"), toSCIMUserWithoutId(userName), chimpy);\n            assert.equal(res.status, 201);\n            const newUserId = await getOrCreateUserId(userName);\n            assert.deepEqual(res.data, toSCIMUserWithId(userName, newUserId));\n            const newUser = await getDbManager().getUser(newUserId);\n            assert.equal(newUser!.type, \"login\");\n          });\n        });\n\n        it(\"should allow creating a new user given only their email passed as username\", async function() {\n          await withUserName(\"new.user2\", async (userName) => {\n            const res = await axios.post(scimUrl(\"/Users\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n              userName: \"new.user2@getgrist.com\",\n            }, chimpy);\n            assert.equal(res.status, 201);\n            assert.equal(res.data.userName, userName + \"@getgrist.com\");\n            assert.equal(res.data.displayName, userName);\n          });\n        });\n\n        it(\"should also allow user Charon to create a user (the one refered in GRIST_SCIM_EMAIL)\", async function() {\n          await withUserName(\"new.user.by.charon\", async (userName) => {\n            const res = await axios.post(scimUrl(\"/Users\"), toSCIMUserWithoutId(userName), charon);\n            assert.equal(res.status, 201);\n          });\n        });\n\n        it(\"should warn when passed email differs from username, and ignore the username\", async function() {\n          await withUserName(\"emails.value\", async (userName) => {\n            const res = await axios.post(scimUrl(\"/Users\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n              userName: userName,\n              emails: [{ value: userName + \"@getgrist.com\" }],\n            }, chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n              id: res.data.id,\n              meta: { resourceType: \"User\", location: `/api/scim/v2/Users/${res.data.id}` },\n              userName: \"emails.value@getgrist.com\",\n              name: { formatted: \"emails.value\" },\n              displayName: \"emails.value\",\n              preferredLanguage: \"en\",\n              locale: \"en\",\n              emails: [\n                { value: \"emails.value@getgrist.com\", primary: true },\n              ],\n            });\n            assert.equal(res.status, 201);\n            assert.equal(logWarnStub.callCount, 1, \"A warning should have been raised\");\n            assert.match(\n              logWarnStub.getCalls()[0].args[0],\n              new RegExp(`userName \"${userName}\" differ from passed primary email`),\n            );\n          });\n        });\n\n        it(\"should disallow creating a user with the same email\", async function() {\n          const res = await axios.post(scimUrl(\"/Users\"), toSCIMUserWithoutId(\"chimpy\"), chimpy);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"409\",\n            detail: \"An existing user with the passed email exist.\",\n            scimType: \"uniqueness\",\n          });\n          assert.equal(res.status, 409);\n        });\n\n        checkCommonErrors(\"post\", \"/Users\", toSCIMUserWithoutId(\"some-user\"));\n      });\n\n      describe(\"PUT /Users/{id}\", function() {\n        let userToUpdateId: number;\n        const userToUpdateEmailLocalPart = \"user-to-update\";\n\n        beforeEach(async function() {\n          userToUpdateId = await getOrCreateUserId(userToUpdateEmailLocalPart);\n        });\n\n        afterEach(async function() {\n          if (shouldCleanup) {\n            await cleanupUser(userToUpdateId);\n          }\n        });\n\n        it(\"should update an existing user\", async function() {\n          const userToUpdateProperties = {\n            schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n            userName: userToUpdateEmailLocalPart + \"-now-updated@getgrist.com\",\n            displayName: \"User to Update\",\n            photos: [{ value: \"https://example.com/photo.jpg\", type: \"photo\", primary: true }],\n            locale: \"fr\",\n          };\n          const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), userToUpdateProperties, chimpy);\n          assert.equal(res.status, 200);\n          const refreshedUser = await axios.get(scimUrl(`/Users/${userToUpdateId}`), chimpy);\n          assert.deepEqual(refreshedUser.data, {\n            ...userToUpdateProperties,\n            id: String(userToUpdateId),\n            meta: { resourceType: \"User\", location: `/api/scim/v2/Users/${userToUpdateId}` },\n            emails: [{ value: userToUpdateProperties.userName, primary: true }],\n            name: { formatted: userToUpdateProperties.displayName },\n            preferredLanguage: \"fr\",\n          });\n        });\n\n        it(\"should warn when passed email differs from username\", async function() {\n          const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), {\n            schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n            userName: \"whatever@getgrist.com\",\n            emails: [{ value: userToUpdateEmailLocalPart + \"@getgrist.com\", primary: true }],\n          }, chimpy);\n          assert.equal(res.status, 200);\n          assert.equal(logWarnStub.callCount, 1, \"A warning should have been raised\");\n          assert.match(logWarnStub.getCalls()[0].args[0], /differ from passed primary email/);\n        });\n\n        it(\"should disallow updating a user with the same email as another user's\", async function() {\n          const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), toSCIMUserWithoutId(\"chimpy\"), chimpy);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"409\",\n            detail: \"An existing user with the passed email exist.\",\n            scimType: \"uniqueness\",\n          });\n          assert.equal(res.status, 409);\n        });\n\n        it(\"should return 404 when the user is not found\", async function() {\n          const res = await axios.put(scimUrl(\"/Users/1000\"), toSCIMUserWithoutId(\"whoever\"), chimpy);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"404\",\n            detail: \"unable to find user to update\",\n          });\n          assert.equal(res.status, 404);\n        });\n\n        it(\"should return 404 when the user is not of type login\", async function() {\n          const serviceUserId = await getOrCreateUserId(\"alfred\", { type: \"service\" });\n          const res = await axios.put(scimUrl(`/Users/${serviceUserId}`), toSCIMUserWithoutId(\"chimpy\"), chimpy);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"404\",\n            detail: \"unable to find user to update\",\n          });\n          assert.equal(res.status, 404);\n        });\n\n        it(\"should return 403 for system users\", async function() {\n          const data = toSCIMUserWithoutId(\"whoever\");\n          await checkOperationOnTechUserDisallowed({\n            op: id => axios.put(scimUrl(`/Users/${id}`), data, chimpy),\n            opType: \"modification\",\n          });\n        });\n\n        it(\"should deduce the name from the displayEmail when not provided\", async function() {\n          const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), {\n            schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n            userName: \"my-email@getgrist.com\",\n          }, chimpy);\n          assert.equal(res.status, 200);\n          assert.deepInclude(res.data, {\n            schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n            id: String(userToUpdateId),\n            userName: \"my-email@getgrist.com\",\n            displayName: \"my-email\",\n          });\n        });\n\n        it(\"should return 400 when the user id is malformed\", async function() {\n          const res = await axios.put(scimUrl(\"/Users/not-an-id\"), toSCIMUserWithoutId(\"whoever\"), chimpy);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"400\",\n            detail: \"Invalid passed user ID\",\n            scimType: \"invalidValue\",\n          });\n          assert.equal(res.status, 400);\n        });\n\n        it(\"should normalize the passed email for the userName and keep the case for email.value\", async function() {\n          const newEmail = \"my-EMAIL@getgrist.com\";\n          const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), {\n            schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n            userName: newEmail,\n          }, chimpy);\n          assert.equal(res.status, 200);\n          assert.deepInclude(res.data, {\n            schemas: [\"urn:ietf:params:scim:schemas:core:2.0:User\"],\n            id: String(userToUpdateId),\n            userName: newEmail.toLowerCase(),\n            displayName: \"my-EMAIL\",\n            emails: [{ value: newEmail, primary: true }],\n          });\n        });\n\n        checkCommonErrors(\"put\", \"/Users/1\", toSCIMUserWithoutId(\"chimpy\"));\n      });\n\n      describe(\"PATCH /Users/{id}\", function() {\n        let userToPatchId: number;\n        const userToPatchEmailLocalPart = \"user-to-patch\";\n        beforeEach(async function() {\n          userToPatchId = await getOrCreateUserId(userToPatchEmailLocalPart);\n        });\n        afterEach(async function() {\n          await cleanupUser(userToPatchId);\n        });\n\n        const validPatchBody = (newName: string) => ({\n          schemas: [\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\n          Operations: [{\n            op: \"replace\",\n            path: \"displayName\",\n            value: newName,\n          }, {\n            op: \"replace\",\n            path: \"locale\",\n            value: \"fr\",\n          }],\n        });\n\n        it(\"should replace values of an existing user\", async function() {\n          const newName = \"User to Patch new Name\";\n          const res = await axios.patch(scimUrl(`/Users/${userToPatchId}`), validPatchBody(newName), chimpy);\n          assert.equal(res.status, 200);\n          const refreshedUser = await axios.get(scimUrl(`/Users/${userToPatchId}`), chimpy);\n          assert.deepEqual(refreshedUser.data, {\n            ...toSCIMUserWithId(userToPatchEmailLocalPart, userToPatchId),\n            displayName: newName,\n            name: { formatted: newName },\n            locale: \"fr\",\n            preferredLanguage: \"fr\",\n          });\n        });\n\n        it(\"should return 404 when the user is not of type login\", async function() {\n          const serviceUserId = await getOrCreateUserId(\"alfred\", { type: \"service\" });\n          const res = await axios.patch(scimUrl(`/Users/${serviceUserId}`), validPatchBody(\"whatever\"), chimpy);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"404\",\n            detail: `User with ID ${serviceUserId} not found`,\n          });\n          assert.equal(res.status, 404);\n        });\n        checkCommonErrors(\"patch\", \"/Users/1\", validPatchBody(\"new name2\"));\n      });\n\n      describe(\"DELETE /Users/{id}\", function() {\n        let userToDeleteId: number;\n        const userToDeleteEmailLocalPart = \"user-to-delete\";\n\n        beforeEach(async function() {\n          userToDeleteId = await getOrCreateUserId(userToDeleteEmailLocalPart);\n        });\n        afterEach(async function() {\n          await cleanupUser(userToDeleteId);\n        });\n\n        it(\"should delete a user\", async function() {\n          const res = await axios.delete(scimUrl(`/Users/${userToDeleteId}`), chimpy);\n          assert.equal(res.status, 204);\n          const refreshedUser = await axios.get(scimUrl(`/Users/${userToDeleteId}`), chimpy);\n          assert.equal(refreshedUser.status, 404);\n        });\n\n        it(\"should return 404 when the user is not found\", async function() {\n          const res = await axios.delete(scimUrl(\"/Users/1000\"), chimpy);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"404\",\n            detail: \"user not found\",\n          });\n          assert.equal(res.status, 404);\n        });\n\n        it(\"should return 404 when the user is not of type login\", async function() {\n          const serviceUserId = await getOrCreateUserId(\"alfred\", { type: \"service\" });\n          const res = await axios.delete(scimUrl(`/Users/${serviceUserId}`), chimpy);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n            status: \"404\",\n            detail: \"user not found\",\n          });\n          assert.equal(res.status, 404);\n        });\n\n        it(\"should return 403 for system users\", async function() {\n          await checkOperationOnTechUserDisallowed({\n            op: id => axios.delete(scimUrl(`/Users/${id}`), chimpy),\n            opType: \"deletion\",\n          });\n        });\n\n        checkCommonErrors(\"delete\", \"/Users/1\");\n      });\n    });\n\n    describe(\"Groups and Roles\", function() {\n      async function cleanupGroups() {\n        const groupsToDelete = await getDbManager().connection.createQueryBuilder()\n          .select(\"groups\")\n          .from(Group, \"groups\")\n          .where(\"groups.name like 'test-%'\")\n          .getMany();\n        for (const { id } of groupsToDelete) {\n          await getDbManager().deleteGroup(id);\n        }\n      }\n\n      async function withGroupNames<T>(groupNames: string[], cb: (groupNames: string[]) => Promise<T>) {\n        try {\n          await cb(groupNames);\n        } finally {\n          if (shouldCleanup) {\n            await cleanupGroups();\n          }\n        }\n      }\n\n      async function withGroupName<T>(groupName: string, cb: (groupName: string) => Promise<T>) {\n        return await withGroupNames([groupName], groupNames => cb(groupNames[0]));\n      }\n\n      function getUserMember(user: keyof UserConfigByName) {\n        return { value: String(userIdByName[user]), display: capitalize(user), type: \"User\" };\n      }\n\n      function getUserMemberWithRef(user: keyof UserConfigByName) {\n        return { ...getUserMember(user), $ref: `/api/scim/v2/Users/${userIdByName[user]}` };\n      }\n\n      function withGroup<T>(cb: (groupId: string, group: Group) => Promise<T>) {\n        return withGroupName(\"test-group\", async (groupName) => {\n          const group = await getDbManager().createGroup({\n            name: groupName,\n            type: Group.TEAM_TYPE,\n            memberUsers: [userIdByName.chimpy!],\n          });\n          return await cb(String(group.id), group);\n        });\n      }\n\n      function withRole<T>(cb: (groupId: string, role: Group) => Promise<T>) {\n        return withGroupName(\"test-role\", async (groupName) => {\n          const role = await getDbManager().createGroup({\n            name: groupName,\n            type: Group.ROLE_TYPE,\n            memberUsers: [userIdByName.chimpy!],\n          });\n          return await cb(String(role.id), role);\n        });\n      }\n\n      describe(\"Groups\", function() {\n        describe(\"GET /Groups/{id}\", function() {\n          it(`should return a \"${Group.TEAM_TYPE}\" group for chimpy`, async function() {\n            await withGroup(async (groupId, group) => {\n              const res = await axios.get(scimUrl(\"/Groups/\" + groupId), chimpy);\n\n              assert.equal(res.status, 200);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                id: groupId,\n                displayName: group.name,\n                members: [\n                  { value: \"1\", display: \"Chimpy\", $ref: \"/api/scim/v2/Users/1\", type: \"User\" },\n                ],\n                meta: { resourceType: \"Group\", location: `/api/scim/v2/Groups/${groupId}` },\n              });\n            });\n          });\n\n          it(\"should return 404 when the group is not found\", async function() {\n            const nonExistingId = 10000000;\n            const res = await axios.get(scimUrl(`/Groups/${nonExistingId}`), chimpy);\n            assert.equal(res.status, 404);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"404\",\n              detail: `Group with ID ${nonExistingId} not found`,\n            });\n          });\n\n          it(`should return 404 when the group is of type ${Group.ROLE_TYPE}`, async function() {\n            await withRole(async (groupId, groupName) => {\n              const res = await axios.get(scimUrl(\"/Groups/\" + groupId), chimpy);\n              assert.equal(res.status, 404);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n                status: \"404\",\n                detail: `Group with ID ${groupId} not found`,\n              });\n            });\n          });\n\n          it(\"should return 400 when the group id is malformed\", async function() {\n            const res = await axios.get(scimUrl(\"/Groups/not-an-id\"), chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"400\",\n              detail: \"Invalid passed group ID\",\n              scimType: \"invalidValue\",\n            });\n            assert.equal(res.status, 400);\n          });\n\n          checkCommonErrors(\"get\", \"/Groups/1\");\n        });\n\n        describe(\"GET /Groups\", function() {\n          it(`should return all ${Group.TEAM_TYPE} groups for chimpy`, async function() {\n            return withGroupNames(\n              [\"test-group1\", \"test-group2\", \"test-role-group\"],\n              async ([group1Name, group2Name, roleGroupName]) => {\n                await getDbManager().createGroup({\n                  name: roleGroupName,\n                  type: Group.ROLE_TYPE,\n                  memberUsers: [userIdByName.chimpy!],\n                });\n                const group1 = await getDbManager().createGroup({\n                  name: group1Name,\n                  type: Group.TEAM_TYPE,\n                  memberUsers: [userIdByName.chimpy!],\n                });\n                const group2 = await getDbManager().createGroup({\n                  name: group2Name,\n                  type: Group.TEAM_TYPE,\n                  memberUsers: [userIdByName.kiwi!],\n                });\n\n                const res = await axios.get(scimUrl(\"/Groups\"), chimpy);\n                assert.equal(res.status, 200);\n                assert.isAbove(res.data.totalResults, 0, \"should have retrieved some groups\");\n                assert.isFalse(res.data.Resources.some(\n                  ({ displayName}: { displayName: string }) => displayName === roleGroupName,\n                ), \"The API endpoint should not return role Groups\");\n                assert.deepEqual(res.data.Resources, [\n                  {\n                    schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                    id: String(group1.id),\n                    displayName: group1Name,\n                    members: [getUserMemberWithRef(\"chimpy\")],\n                    meta: { resourceType: \"Group\", location: `/api/scim/v2/Groups/${group1.id}` },\n                  }, {\n                    schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                    id: String(group2.id),\n                    displayName: group2Name,\n                    members: [getUserMemberWithRef(\"kiwi\")],\n                    meta: { resourceType: \"Group\", location: `/api/scim/v2/Groups/${group2.id}` },\n                  },\n                ]);\n              },\n            );\n          });\n\n          checkCommonErrors(\"get\", \"/Groups\");\n        });\n\n        describe(\"POST /Groups\", function() {\n          it(`should create a new group of type \"${Group.TEAM_TYPE}\"`, async function() {\n            await withGroupName(\"test-group\", async (groupName) => {\n              const res = await axios.post(scimUrl(\"/Groups\"), {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                displayName: groupName,\n                members: [\n                  getUserMember(\"chimpy\"),\n                  getUserMember(\"kiwi\"),\n                ],\n              }, chimpy);\n              assert.equal(res.status, 201);\n              const newGroupId = parseInt(res.data.id);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                id: String(newGroupId),\n                displayName: groupName,\n                members: [\n                  getUserMemberWithRef(\"chimpy\"),\n                  getUserMemberWithRef(\"kiwi\"),\n                ],\n                meta: { resourceType: \"Group\", location: `/api/scim/v2/Groups/${newGroupId}` },\n              });\n            });\n          });\n\n          it(\"should allow to create a group without members\", function() {\n            return withGroupName(\"test-group-without-members\", async (groupName) => {\n              const res = await axios.post(scimUrl(\"/Groups\"), {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                displayName: groupName,\n              }, chimpy);\n              assert.equal(res.status, 201);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                id: res.data.id,\n                displayName: groupName,\n                members: [],\n                meta: { resourceType: \"Group\", location: `/api/scim/v2/Groups/${res.data.id}` },\n              });\n            });\n          });\n\n          it(\"should return 400 when the group name is missing\", async function() {\n            const res = await axios.post(scimUrl(\"/Groups\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n              members: [\n                { value: String(userIdByName.chimpy), display: \"Chimpy\", type: \"User\" },\n              ],\n            }, chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"400\",\n              detail: \"Required attribute 'displayName' is missing\",\n              scimType: \"invalidValue\",\n            });\n            assert.equal(res.status, 400);\n          });\n\n          it(\"should return 409 when the group name is coliding with an existing group\", async function() {\n            await withGroupName(\"test-group\", async (groupName) => {\n              const existingGroupCreationRes = await axios.post(scimUrl(\"/Groups\"), {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                displayName: groupName,\n              }, chimpy);\n              assert.equal(existingGroupCreationRes.status, 201);\n              const res = await axios.post(scimUrl(\"/Groups\"), {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                displayName: groupName,\n              }, chimpy);\n              assert.equal(res.status, 409);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n                status: \"409\",\n                detail: `Group with name \"${groupName}\" already exists`,\n                scimType: \"uniqueness\",\n              });\n            });\n          });\n\n          it(\"should return 400 when the group members contain an invalid user id\", async function() {\n            const res = await axios.post(scimUrl(\"/Groups\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n              displayName: \"test-group\",\n              members: [\n                { value: \"not-an-id\", display: \"Non-Existing User\", type: \"User\" },\n              ],\n            }, chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"400\",\n              detail: \"Invalid User member ID: not-an-id\",\n              scimType: \"invalidValue\",\n            });\n            assert.equal(res.status, 400);\n          });\n\n          it(\"should return 400 when the group members contain an invalid group id\", async function() {\n            const res = await axios.post(scimUrl(\"/Groups\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n              displayName: \"test-group\",\n              members: [\n                { value: \"not-an-id\", display: \"Non-Existing Group\", type: \"Group\" },\n              ],\n            }, chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"400\",\n              detail: \"Invalid Group member ID: not-an-id\",\n              scimType: \"invalidValue\",\n            });\n            assert.equal(res.status, 400);\n          });\n\n          it(\"should return 404 when the group members contain a non-existing user id\", async function() {\n            const res = await axios.post(scimUrl(\"/Groups\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n              displayName: \"test-group\",\n              members: [\n                getUserMember(\"chimpy\"),\n                { value: \"1000\", display: \"Non-Existing User\", type: \"User\" },\n              ],\n            }, chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"404\",\n              detail: \"Users not found: 1000\",\n            });\n            assert.equal(res.status, 404);\n          });\n\n          it(\"should return 404 when the group members contain a non-existing group id\", async function() {\n            const res = await axios.post(scimUrl(\"/Groups\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n              displayName: \"test-group\",\n              members: [\n                { value: \"1000\", display: \"Non-Existing Group\", type: \"Group\" },\n              ],\n            }, chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"404\",\n              detail: \"Groups not found: 1000\",\n            });\n            assert.equal(res.status, 404);\n          });\n\n          it(\"should return 400 when the group members contain other groups\", async function() {\n            await withRole(async (groupId) => {\n              const res = await axios.post(scimUrl(\"/Groups\"), {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                displayName: \"test-group\",\n                members: [\n                  { value: \"1\", type: \"User\" },\n                  { value: \"2\", type: \"User\" },\n                  { value: groupId, type: \"Group\" },\n                ],\n              }, chimpy);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n                status: \"400\",\n                detail: `Groups of type \"${Group.TEAM_TYPE}\" cannot contain groups.`,\n              });\n              assert.equal(res.status, 400);\n            });\n          });\n\n          checkCommonErrors(\"post\", \"/Groups\", {\n            schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n            displayName: \"test-group\",\n            // We need to differ the moment we call userIdByName['chimpy'] (set during a \"before()\" hook)\n            get members() {\n              return [\n                getUserMember(\"chimpy\"),\n                getUserMember(\"kiwi\"),\n              ];\n            },\n          });\n        });\n\n        describe(\"PUT /Groups/{id}\", function() {\n          it(\"should update an existing group\", async function() {\n            return withGroup(async (groupId) => {\n              const newGroupName = \"test-new-group-name\";\n              const res = await axios.put(scimUrl(\"/Groups/\" + groupId), {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                displayName: newGroupName,\n                members: [\n                  getUserMember(\"kiwi\"),\n                ],\n              }, chimpy);\n              assert.equal(res.status, 200);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                id: groupId,\n                displayName: newGroupName,\n                members: [\n                  getUserMemberWithRef(\"kiwi\"),\n                ],\n                meta: { resourceType: \"Group\", location: \"/api/scim/v2/Groups/\" + groupId },\n              });\n            });\n          });\n\n          it(\"should update a group with members omitted\", async function() {\n            return withGroup(async (groupId) => {\n              const newGroupName = \"test-new-group-name\";\n              const res = await axios.put(scimUrl(\"/Groups/\" + groupId), {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                displayName: newGroupName,\n              }, chimpy);\n              assert.equal(res.status, 200);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                id: groupId,\n                displayName: newGroupName,\n                members: [],\n                meta: { resourceType: \"Group\", location: \"/api/scim/v2/Groups/\" + groupId },\n              });\n            });\n          });\n\n          it(\"should refuse to alter a role\", async function() {\n            return withRole(async (groupId) => {\n              const res = await axios.put(scimUrl(\"/Groups/\" + groupId), {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                displayName: \"whatever\",\n              }, chimpy);\n              assert.equal(res.status, 404);\n            });\n          });\n\n          it(\"should return 404 when the group is not found\", async function() {\n            const res = await axios.put(scimUrl(\"/Groups/1000\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n              displayName: \"New Group Name\",\n              members: [\n                getUserMember(\"kiwi\"),\n              ],\n            }, chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"404\",\n              detail: \"Group with id 1000 not found\",\n            });\n            assert.equal(res.status, 404);\n          });\n\n          it(\"should return 400 when the group id is malformed\", async function() {\n            const res = await axios.put(scimUrl(\"/Groups/not-an-id\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n              displayName: \"New Group Name\",\n              members: [\n                getUserMember(\"kiwi\"),\n              ],\n            }, chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"400\",\n              detail: \"Invalid passed group ID\",\n              scimType: \"invalidValue\",\n            });\n            assert.equal(res.status, 400);\n          });\n\n          checkCommonErrors(\"put\", \"/Groups/1\", {\n            schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n            displayName: \"New Group Name\",\n            // We need to differ the moment we call userIdByName['kiwi'] (set during a \"before()\" hook)\n            get members() {\n              return [getUserMember(\"kiwi\")];\n            },\n          });\n        });\n\n        describe(\"PATCH /Groups/{id}\", function() {\n          it(\"should update an existing group name\", async function() {\n            return withGroup(async (groupId) => {\n              const newGroupName = \"test-new-group-name\";\n              const res = await axios.patch(scimUrl(\"/Groups/\" + groupId), {\n                schemas: [\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\n                Operations: [{\n                  op: \"replace\", path: \"displayName\", value: newGroupName,\n                }],\n              }, chimpy);\n              assert.equal(res.status, 200);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Group\"],\n                id: groupId,\n                displayName: newGroupName,\n                members: [\n                  getUserMemberWithRef(\"chimpy\"),\n                ],\n                meta: { resourceType: \"Group\", location: \"/api/scim/v2/Groups/\" + groupId },\n              });\n            });\n          });\n\n          it(\"should add a member to a group\", async function() {\n            return withGroup(async (groupId) => {\n              const res = await axios.patch(scimUrl(\"/Groups/\" + groupId), {\n                schemas: [\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\n                Operations: [{\n                  op: \"add\", path: \"members\", value: [getUserMember(\"kiwi\")],\n                }],\n              }, chimpy);\n              assert.equal(res.status, 200);\n              assert.deepEqual(res.data.members, [\n                getUserMemberWithRef(\"chimpy\"),\n                getUserMemberWithRef(\"kiwi\"),\n              ]);\n            });\n          });\n\n          it(\"should refuse to alter a role\", async function() {\n            return withRole(async (groupId) => {\n              const res = await axios.patch(scimUrl(\"/Groups/\" + groupId), {\n                schemas: [\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\n                Operations: [{\n                  op: \"add\", path: \"members\", value: [getUserMember(\"kiwi\")],\n                }],\n              }, chimpy);\n              assert.equal(res.status, 404);\n            });\n          });\n\n          checkCommonErrors(\"patch\", \"/Groups/1\", {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\n            Operations: [{\n              op: \"replace\", path: \"displayName\", value: \"Updated Group Name\",\n            }],\n          });\n        });\n\n        describe(\"DELETE /Groups/{id}\", function() {\n          it(\"should delete a group\", async function() {\n            return withGroup(async (groupId) => {\n              const res = await axios.delete(scimUrl(\"/Groups/\" + groupId), chimpy);\n              assert.equal(res.status, 204);\n              const refreshedGroup = await axios.get(scimUrl(\"/Groups/\" + groupId), chimpy);\n              assert.equal(refreshedGroup.status, 404);\n            });\n          });\n\n          it(\"should refuse to delete a role\", async function() {\n            return withRole(async (groupId) => {\n              const res = await axios.delete(scimUrl(\"/Groups/\" + groupId), chimpy);\n              assert.equal(res.status, 404);\n            });\n          });\n\n          it(\"should return 404 when the group is not found\", async function() {\n            const res = await axios.delete(scimUrl(\"/Groups/1000\"), chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"404\",\n              detail: \"Group with id 1000 not found\",\n            });\n            assert.equal(res.status, 404);\n          });\n\n          it(\"should return 400 when the group id is malformed\", async function() {\n            const res = await axios.delete(scimUrl(\"/Groups/not-an-id\"), chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"400\",\n              detail: \"Invalid passed group ID\",\n              scimType: \"invalidValue\",\n            });\n            assert.equal(res.status, 400);\n          });\n\n          checkCommonErrors(\"delete\", \"/Groups/1\");\n        });\n      });\n\n      describe(\"Roles\", function() {\n        describe(\"GET /Roles/{id}\", function() {\n          it(`should return a \"${Group.ROLE_TYPE}\" group for chimpy`, async function() {\n            await withRole(async (roleId: string, role: Group) => {\n              const res = await axios.get(scimUrl(\"/Roles/\" + roleId), chimpy);\n\n              assert.equal(res.status, 200);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n                id: String(roleId),\n                displayName: role.name,\n                members: [\n                  { value: \"1\", display: \"Chimpy\", $ref: \"/api/scim/v2/Users/1\", type: \"User\" },\n                ],\n                meta: { resourceType: \"Role\", location: `/api/scim/v2/Roles/${roleId}` },\n              });\n            });\n          });\n\n          it(\"should return 404 when the role is not found\", async function() {\n            const nonExistingId = 10000000;\n            const res = await axios.get(scimUrl(`/Roles/${nonExistingId}`), chimpy);\n            assert.equal(res.status, 404);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"404\",\n              detail: `Role with ID ${nonExistingId} not found`,\n            });\n          });\n\n          it(`should return 404 when the role is of type ${Group.TEAM_TYPE}`, async function() {\n            await withGroupName(\"test-group\", async (groupName) => {\n              const { id: roleId } = await getDbManager().createGroup({\n                name: groupName,\n                type: Group.TEAM_TYPE,\n                memberUsers: [userIdByName.chimpy!],\n              });\n\n              const res = await axios.get(scimUrl(\"/Roles/\" + roleId), chimpy);\n              assert.equal(res.status, 404);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n                status: \"404\",\n                detail: `Role with ID ${roleId} not found`,\n              });\n            });\n          });\n\n          it(\"should return 400 when the role id is malformed\", async function() {\n            const res = await axios.get(scimUrl(\"/Roles/not-an-id\"), chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"400\",\n              detail: \"Invalid passed role ID\",\n              scimType: \"invalidValue\",\n            });\n            assert.equal(res.status, 400);\n          });\n\n          checkCommonErrors(\"get\", \"/Roles/1\");\n        });\n\n        describe(\"GET /Roles\", function() {\n          it(`should return all ${Group.ROLE_TYPE} groups for chimpy`, async function() {\n            return withGroupNames(\n              [\"test-role1\", \"test-role2\", \"test-group\"],\n              async ([role1Name, role2Name, group1Name]) => {\n                const beforeAdditions = await axios.get(scimUrl(\"/Roles?count=300\"), chimpy);\n                const beforeAdditionsIds = new Set(beforeAdditions.data.Resources.map(({ id}: { id: number }) => id));\n                const group1 = await getDbManager().createGroup({\n                  name: group1Name,\n                  type: Group.TEAM_TYPE,\n                  memberUsers: [userIdByName.chimpy!],\n                });\n                const role1 = await getDbManager().createGroup({\n                  name: role1Name,\n                  type: Group.ROLE_TYPE,\n                  memberUsers: [userIdByName.chimpy!],\n                });\n                const role2 = await getDbManager().createGroup({\n                  name: role2Name,\n                  type: Group.ROLE_TYPE,\n                  memberUsers: [userIdByName.kiwi!],\n                  memberGroups: [role1.id, group1.id],\n                });\n\n                const res = await axios.get(scimUrl(\"/Roles?count=300\"), chimpy);\n                const newResources = res.data.Resources.filter(({ id}: { id: number }) => !beforeAdditionsIds.has(id));\n                assert.equal(res.status, 200);\n                assert.lengthOf(newResources, 2, \"should have the newly created roles\");\n                assert.isFalse(res.data.Resources.some(\n                  ({ displayName}: { displayName: string }) => displayName === group1Name,\n                ), \"The API endpoint should not return resource Groups\");\n                assert.deepEqual(newResources, [\n                  {\n                    schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n                    id: String(role1.id),\n                    displayName: role1Name,\n                    members: [getUserMemberWithRef(\"chimpy\")],\n                    meta: { resourceType: \"Role\", location: `/api/scim/v2/Roles/${role1.id}` },\n                  }, {\n                    schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n                    id: String(role2.id),\n                    displayName: role2Name,\n                    members: [\n                      getUserMemberWithRef(\"kiwi\"),\n                      {\n                        value: String(group1.id),\n                        display: group1Name,\n                        type: \"Group\",\n                        $ref: `/api/scim/v2/Groups/${group1.id}`,\n                      },\n                      {\n                        value: String(role1.id),\n                        display: role1Name,\n                        type: \"Role\",\n                        $ref: `/api/scim/v2/Roles/${role1.id}`,\n                      },\n                    ],\n                    meta: { resourceType: \"Role\", location: `/api/scim/v2/Roles/${role2.id}` },\n                  },\n                ]);\n              },\n            );\n          });\n\n          it(\"should return describe the docId, workspaceId and orgId associated to the Role\", async function() {\n            const api = await getServer().createHomeApi(\"chimpy\", \"docs\", true);\n            const newOrgId = await api.newOrg({ name: \"someOrg\", domain: \"testy\" });\n            const newWsId = await api.newWorkspace({ name: \"someWs\" }, newOrgId);\n            const newDocId = await api.newDoc({ name: \"someDoc\" }, newWsId);\n\n            const res = await axios.get(scimUrl(\"/Roles?count=300\"), chimpy);\n\n            assert.equal(res.status, 200);\n            assert.isTrue(res.data.Resources.some((role: any) => role.orgId === newOrgId),\n              \"no role with orgId=\" + newOrgId);\n            assert.isTrue(res.data.Resources.some((role: any) => role.workspaceId === newWsId),\n              \"no role with workspaceId=\" + newWsId);\n            assert.isTrue(res.data.Resources.some((role: any) => role.docId === newDocId),\n              \"no role with docId=\" + newDocId);\n          });\n\n          checkCommonErrors(\"get\", \"/Roles\");\n        });\n\n        describe(\"POST /Roles\", function() {\n          it(\"should return 501 Not implemented\", async function() {\n            const res = await axios.post(scimUrl(\"/Roles\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n              displayName: \"test-role\",\n              members: [],\n            }, chimpy);\n            assert.equal(res.status, 501);\n          });\n        });\n\n        describe(\"PUT /Roles/{id}\", function() {\n          it(\"should update the role's members\", async function() {\n            return withRole(async (roleId) => {\n              const res = await axios.put(scimUrl(\"/Roles/\" + roleId), {\n                schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n                members: [\n                  getUserMember(\"kiwi\"),\n                ],\n              }, chimpy);\n              assert.equal(res.status, 200);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n                id: roleId,\n                members: [\n                  getUserMemberWithRef(\"kiwi\"),\n                ],\n                meta: { resourceType: \"Role\", location: \"/api/scim/v2/Roles/\" + roleId },\n              });\n            });\n          });\n\n          it(\"should not update the role name\", async function() {\n            return withRole(async (roleId, role) => {\n              const newName = \"new-name\";\n              const oldName = role.name;\n\n              const res = await axios.put(scimUrl(\"/Roles/\" + roleId), {\n                schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n                displayName: newName,\n                members: [\n                  getUserMember(\"kiwi\"),\n                ],\n              }, chimpy);\n              assert.equal(res.status, 200);\n              const updatedRole = await axios.get(scimUrl(\"/Roles/\" + roleId), chimpy);\n              assert.equal(updatedRole.data.displayName, oldName, \"Role name should not have changed\");\n            });\n          });\n\n          it(\"should return 404 when the role is not found\", async function() {\n            const res = await axios.put(scimUrl(\"/Roles/1000\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n              displayName: \"test-role\",\n              members: [\n                getUserMember(\"kiwi\"),\n              ],\n            }, chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"404\",\n              detail: \"Role with id 1000 not found\",\n            });\n            assert.equal(res.status, 404);\n          });\n\n          it(\"should return 400 when the role id is malformed\", async function() {\n            const res = await axios.put(scimUrl(\"/Roles/not-an-id\"), {\n              schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n              displayName: \"test-role\",\n              members: [\n                getUserMember(\"kiwi\"),\n              ],\n            }, chimpy);\n            assert.deepEqual(res.data, {\n              schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n              status: \"400\",\n              detail: \"Invalid passed role ID\",\n              scimType: \"invalidValue\",\n            });\n            assert.equal(res.status, 400);\n          });\n\n          it(\"should not update the docId, the wsId nor the orgId\", async function() {\n            return withRole(async (roleId) => {\n              const res = await axios.put(scimUrl(\"/Roles/\" + roleId), {\n                schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n                docId: 1000,\n                wsId: 1000,\n                orgId: 1000,\n                displayName: \"test-role\",\n                members: [\n                  getUserMember(\"kiwi\"),\n                ],\n              }, chimpy);\n              assert.equal(res.status, 200);\n              const updatedRole = await axios.get(scimUrl(\"/Roles/\" + roleId), chimpy);\n              assert.isUndefined(updatedRole.data.docId, \"docId should not have changed\");\n              assert.isUndefined(updatedRole.data.wsId, \"wsId should not have changed\");\n              assert.isUndefined(updatedRole.data.orgId, \"orgId should not have changed\");\n            });\n          });\n\n          checkCommonErrors(\"put\", \"/Roles/1\", {\n            schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n            displayName: \"test-role\",\n            // We need to differ the moment we call userIdByName['kiwi'] (set during a \"before()\" hook)\n            get members() {\n              return [getUserMember(\"kiwi\")];\n            },\n          });\n        });\n\n        describe(\"PATCH /Roles/{id}\", function() {\n          it(\"should update the role's members\", async function() {\n            return withRole(async (roleId, role) => {\n              const res = await axios.patch(scimUrl(\"/Roles/\" + roleId), {\n                schemas: [\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\n                Operations: [{\n                  op: \"replace\", path: \"members\", value: [getUserMember(\"kiwi\")],\n                }],\n              }, chimpy);\n              assert.equal(res.status, 200);\n              assert.deepEqual(res.data, {\n                schemas: [\"urn:ietf:params:scim:schemas:Grist:1.0:Role\"],\n                id: roleId,\n                members: [\n                  getUserMemberWithRef(\"kiwi\"),\n                ],\n                meta: { resourceType: \"Role\", location: \"/api/scim/v2/Roles/\" + roleId },\n              });\n            });\n          });\n\n          checkCommonErrors(\"patch\", \"/Roles/1\", {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\n            Operations: [{\n              op: \"replace\", path: \"displayName\", value: \"Updated Role Name\",\n            }],\n          });\n        });\n\n        describe(\"DELETE /Roles/{id}\", function() {\n          it(\"should return 501 Not implemented\", async function() {\n            await withRole(async (roleId) => {\n              const res = await axios.delete(scimUrl(\"/Roles/\" + roleId), chimpy);\n              assert.equal(res.status, 501);\n            });\n          });\n        });\n      });\n    });\n\n    describe(\"POST /Bulk\", function() {\n      let usersToCleanupEmails: string[];\n\n      beforeEach(async function() {\n        usersToCleanupEmails = [];\n      });\n\n      afterEach(async function() {\n        for (const email of usersToCleanupEmails) {\n          const user = await getDbManager().getExistingUserByLogin(email);\n          if (user) {\n            await cleanupUser(user.id);\n          }\n        }\n      });\n\n      it(\"should return statuses for each operation\", async function() {\n        await withUserName(\"bulk-user3\", async (bulkUserName) => {\n          const putOnUnknownResource = { method: \"PUT\", path: \"/Users/1000\", value: toSCIMUserWithoutId(\"chimpy\") };\n          const validCreateOperation = {\n            method: \"POST\", path: \"/Users/\", data: toSCIMUserWithoutId(bulkUserName), bulkId: \"1\",\n          };\n          usersToCleanupEmails.push(bulkUserName);\n          const createOperationWithUserNameConflict = {\n            method: \"POST\", path: \"/Users/\", data: toSCIMUserWithoutId(\"chimpy\"), bulkId: \"2\",\n          };\n          const res = await axios.post(scimUrl(\"/Bulk\"), {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:BulkRequest\"],\n            Operations: [\n              putOnUnknownResource,\n              validCreateOperation,\n              createOperationWithUserNameConflict,\n            ],\n          }, chimpy);\n          assert.equal(res.status, 200);\n\n          const newUserID = await getOrCreateUserId(bulkUserName);\n          assert.deepEqual(res.data, {\n            schemas: [\"urn:ietf:params:scim:api:messages:2.0:BulkResponse\"],\n            Operations: [\n              {\n                method: \"PUT\",\n                location: \"/api/scim/v2/Users/1000\",\n                status: \"400\",\n                response: {\n                  schemas: [\n                    \"urn:ietf:params:scim:api:messages:2.0:Error\",\n                  ],\n                  status: \"400\",\n                  scimType: \"invalidSyntax\",\n                  detail: \"Expected 'data' to be a single complex value in BulkRequest operation #1\",\n                },\n              }, {\n                method: \"POST\",\n                bulkId: \"1\",\n                location: \"/api/scim/v2/Users/\" + newUserID,\n                status: \"201\",\n              }, {\n                method: \"POST\",\n                bulkId: \"2\",\n                status: \"409\",\n                response: {\n                  schemas: [\n                    \"urn:ietf:params:scim:api:messages:2.0:Error\",\n                  ],\n                  status: \"409\",\n                  scimType: \"uniqueness\",\n                  detail: \"An existing user with the passed email exist.\",\n                },\n              },\n            ],\n          });\n        });\n      });\n\n      it(\"should return 400 when no operations are provided\", async function() {\n        const res = await axios.post(scimUrl(\"/Bulk\"), {\n          schemas: [\"urn:ietf:params:scim:api:messages:2.0:BulkRequest\"],\n          Operations: [],\n        }, chimpy);\n        assert.equal(res.status, 400);\n        assert.deepEqual(res.data, {\n          schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n          status: \"400\",\n          detail: \"BulkRequest request body must contain 'Operations' attribute with at least one operation\",\n          scimType: \"invalidValue\",\n        });\n      });\n\n      it(\"should disallow accessing resources to kiwi\", async function() {\n        const creationOperation = {\n          method: \"POST\", path: \"/Users\", data: toSCIMUserWithoutId(\"bulk-user4\"), bulkId: \"1\",\n        };\n        usersToCleanupEmails.push(\"bulk-user4\");\n        const selfPutOperation = { method: \"PUT\", path: \"/Me\", value: toSCIMUserWithoutId(\"kiwi\") };\n        const res = await axios.post(scimUrl(\"/Bulk\"), {\n          schemas: [\"urn:ietf:params:scim:api:messages:2.0:BulkRequest\"],\n          Operations: [\n            creationOperation,\n            selfPutOperation,\n          ],\n        }, kiwi);\n        assert.equal(res.status, 200);\n        assert.deepEqual(res.data, {\n          schemas: [\"urn:ietf:params:scim:api:messages:2.0:BulkResponse\"],\n          Operations: [\n            {\n              method: \"POST\",\n              bulkId: \"1\",\n              status: \"403\",\n              response: {\n                detail: \"You are not authorized to access this resource\",\n                schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n                status: \"403\",\n              },\n            }, {\n              // When writing this test, the SCIMMY implementation does not yet support PUT operations on /Me.\n              // This reflects the current behavior, but it may change in the future.\n              // Change this test if the behavior changes.\n              // It is probably fine to allow altering oneself even for non-admins.\n              method: \"PUT\",\n              location: \"/Me\",\n              status: \"400\",\n              response: {\n                schemas: [\n                  \"urn:ietf:params:scim:api:messages:2.0:Error\",\n                ],\n                status: \"400\",\n                detail: \"Invalid 'path' value '/Me' in BulkRequest operation #2\",\n                scimType: \"invalidValue\",\n              },\n            },\n          ],\n        });\n      });\n\n      it(\"should disallow accessing resources to anonymous\", async function() {\n        const creationOperation = {\n          method: \"POST\", path: \"/Users\", data: toSCIMUserWithoutId(\"bulk-user5\"), bulkId: \"1\",\n        };\n        usersToCleanupEmails.push(\"bulk-user5\");\n        const res = await axios.post(scimUrl(\"/Bulk\"), {\n          schemas: [\"urn:ietf:params:scim:api:messages:2.0:BulkRequest\"],\n          Operations: [creationOperation],\n        }, anon);\n        assert.equal(res.status, 401);\n      });\n    });\n\n    it(\"should allow fetching the Scim schema when autenticated\", async function() {\n      const res = await axios.get(scimUrl(\"/Schemas\"), kiwi);\n      assert.equal(res.status, 200);\n      assert.deepInclude(res.data, {\n        schemas: [\"urn:ietf:params:scim:api:messages:2.0:ListResponse\"],\n      });\n      assert.property(res.data, \"Resources\");\n      assert.deepInclude(res.data.Resources[0], {\n        schemas: [\"urn:ietf:params:scim:schemas:core:2.0:Schema\"],\n        id: \"urn:ietf:params:scim:schemas:core:2.0:User\",\n        name: \"User\",\n        description: \"User Account\",\n      });\n    });\n\n    it(\"should allow fetching the Scim resource types when autenticated\", async function() {\n      const res = await axios.get(scimUrl(\"/ResourceTypes\"), kiwi);\n      assert.equal(res.status, 200);\n      assert.deepInclude(res.data, {\n        schemas: [\"urn:ietf:params:scim:api:messages:2.0:ListResponse\"],\n      });\n      assert.property(res.data, \"Resources\");\n      assert.deepInclude(res.data.Resources[0], {\n        schemas: [\"urn:ietf:params:scim:schemas:core:2.0:ResourceType\"],\n        name: \"User\",\n        endpoint: \"/Users\",\n      });\n    });\n\n    it(\"should allow fetching the Scim service provider config when autenticated\", async function() {\n      const res = await axios.get(scimUrl(\"/ServiceProviderConfig\"), kiwi);\n      assert.equal(res.status, 200);\n      assert.deepInclude(res.data, {\n        schemas: [\"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig\"],\n      });\n      assert.property(res.data, \"patch\");\n      assert.property(res.data, \"bulk\");\n      assert.property(res.data, \"filter\");\n    });\n  });\n\n  describe(\"With lots of data\", function() {\n    let userIds: { id: number }[];\n    const { scimUrl, getDbManager, cleanupPreShutdown } = setupTestServer(SCIM_ENV_VARS);\n    before(async function() {\n      this.timeout(60000);\n      const nbUsers = 1_000_000;\n      userIds = await getDbManager().connection.query(`\n        WITH RECURSIVE\n          loop(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM loop WHERE i < $1)\n        INSERT INTO users(name, type, ref) SELECT 'user' || i, 'login', 'user-ref' || i FROM loop\n        RETURNING id;`, [nbUsers]);\n\n      await getDbManager().connection.query(`\n        INSERT INTO logins(user_id, email, display_email)\n        SELECT\n          id,\n          name || '@getgrist.com',\n          name || '@getgrist.com'\n        FROM users\n        WHERE name LIKE 'user%';\n      `);\n\n      // Also create the Chimpy user\n      await getDbManager().getExistingUserByLogin(\"chimpy@getgrist.com\");\n    });\n\n    /**\n     * When running on postgresql, disable all the triggers.\n     * This considerably speed up the deletion time.\n     * In our case, we know what we do as the users have been manually inserted in the database\n     * and we don't need to look for foreign keys in other tables.\n     */\n    async function deactivatePgTriggers(cb: () => Promise<void>) {\n      const dbType = getDbManager().connection.options.type;\n      if (dbType !== \"postgres\") {\n        return cb();\n      }\n      try {\n        await getDbManager().connection.query(`SET session_replication_role = replica`);\n        await cb();\n      } finally {\n        await getDbManager().connection.query(`SET session_replication_role = DEFAULT`);\n      }\n    };\n\n    cleanupPreShutdown(async function() {\n      this.timeout(60_000);\n      if (shouldCleanup) {\n        const minUserId = userIds[0].id;\n        const maxUserId = userIds.at(-1)!.id;\n\n        await deactivatePgTriggers(async () => {\n          await getDbManager().connection.createQueryBuilder()\n            .delete()\n            .from(\"logins\")\n            .where(\"logins.user_id between :minUserId and :maxUserId\", { minUserId, maxUserId })\n            .execute();\n          await getDbManager().connection.createQueryBuilder()\n            .delete()\n            .from(\"users\")\n            .where(\"users.id between :minUserId and :maxUserId\", { minUserId, maxUserId })\n            .execute();\n        });\n      }\n    });\n\n    describe(\"POST /Users/.search\", function() {\n      const apiUrl = () => scimUrl(\"/Users/.search\");\n      this.timeout(5000);\n\n      it(\"should return the user chimpy by its email within a reasonable amount of time\", async function() {\n        const chimpyUser = (await getDbManager().getExistingUserByLogin(\"chimpy@getgrist.com\"))!;\n        const res = await axios.post(apiUrl(), {\n          schemas: [SEARCH_SCHEMA],\n          attributes: [\"userName\"],\n          filter: `userName eq \"${chimpyUser.loginEmail}\"`,\n        }, chimpy);\n        assert.equal(res.status, 200);\n        assert.deepEqual(res.data.Resources, [{\n          id: String(chimpyUser.id),\n          userName: chimpyUser.loginEmail,\n        }]);\n\n        const resWithEmailValue = await axios.post(apiUrl(), {\n          schemas: [SEARCH_SCHEMA],\n          attributes: [\"userName\"],\n          filter: `email.value eq \"${chimpyUser.loginEmail}\"`,\n        }, chimpy);\n        assert.equal(resWithEmailValue.status, 200);\n        assert.deepEqual(resWithEmailValue.data.Resources, [{\n          id: String(chimpyUser.id),\n          userName: chimpyUser.loginEmail,\n        }]);\n      });\n\n      it(\"should reject when the filter gives too many results\", async function() {\n        const resTooManyResults = await axios.post(apiUrl(), {\n          schemas: [SEARCH_SCHEMA],\n          attributes: [\"userName\"],\n          filter: \"userName pr\",\n        }, chimpy);\n        assert.deepEqual(resTooManyResults.data, {\n          schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n          status: \"413\",\n          detail: \"Please refine the filter to limit the number of results to less than 200\",\n          scimType: \"tooMany\",\n        });\n        assert.equal(resTooManyResults.status, 413);\n      });\n\n      it(\"should return an empty array within a reasonable amount of time\", async function() {\n        const resNoUsers = await axios.post(apiUrl(), {\n          schemas: [SEARCH_SCHEMA],\n          attributes: [\"userName\"],\n          filter: 'userName eq \"iDontExist@getgrist.com\"',\n        }, chimpy);\n        assert.equal(resNoUsers.status, 200);\n        assert.deepEqual(resNoUsers.data.Resources, []);\n      });\n\n      it(\"should return a result from a complex query within a reasonable amount of time\", async function() {\n        const res = await axios.post(apiUrl(), {\n          schemas: [SEARCH_SCHEMA],\n          attributes: [\"userName\"],\n          // Use operator in uppercase, it should work too.\n          filter: 'userName SW \"user99999\"',\n        }, chimpy);\n        assert.equal(res.status, 200);\n        assert.lengthOf(res.data.Resources, 11);\n        assert.equal(res.data.Resources[0].userName, \"user99999@getgrist.com\");\n        assert.equal(res.data.Resources[1].userName, \"user999990@getgrist.com\");\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/TableMetadataLoader.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { TableColValues } from \"app/common/DocActions\";\nimport { TableMetadataLoader } from \"app/server/lib/TableMetadataLoader\";\n\nimport { assert } from \"chai\";\n\n/**\n * A test harness for trying different load timings. Written with delays and pollings so\n * that it doesn't in turn need testing.\n */\nclass TableMetadataLoaderHarness {\n  public fetchWait = new Map<string, number>();\n  public loadWait = new Map<string, number>();\n  public loaded = new Set<string>();\n\n  public async loadMetaTables(tables: Buffer, columns: Buffer): Promise<any> {\n    await delay(this.loadWait.get(\"metatables\") || 1000);\n    this.loaded.add(\"metatables\");\n  }\n\n  public async loadTable(tableId: string, buffer: Buffer): Promise<any> {\n    await delay(this.loadWait.get(tableId) || 1000);\n    this.loaded.add(tableId);\n  }\n\n  public decodeBuffer(buffer: Buffer, tableId: string): TableColValues {\n    return 1 as any;\n  }\n\n  public async fetchTable(tableId: string): Promise<Buffer> {\n    await delay(this.fetchWait.get(tableId) || 1000);\n    return Buffer.from(tableId);\n  }\n}\n\ndescribe(\"TableMetadataLoader\", function() {\n  this.timeout(10000);\n\n  it(\"check flow works with typical operation order\", async function() {\n    for (let i = 0; i < 5; i++) {\n      const harness = new TableMetadataLoaderHarness();\n      const loader = new TableMetadataLoader(harness);\n      for (const key of [\"_grist_Tables\", \"_grist_Tables_column\", \"_grist_DocInfo\",\n        \"_grist_Thing\", \"User\", \"metatables\"]) {\n        harness.fetchWait.set(key, Math.random() * 100);\n        harness.loadWait.set(key, Math.random() * 100);\n      }\n      loader.startFetchingTable(\"_grist_DocInfo\");\n      await loader.fetchBulkColValuesWithoutIds(\"_grist_DocInfo\");\n      loader.startStreamingToEngine();\n      loader.startFetchingTable(\"_grist_Tables\");\n      loader.startFetchingTable(\"_grist_Tables_column\");\n      loader.startFetchingTable(\"_grist_Thing\");\n      assert.deepEqual(Object.keys(await loader.fetchTablesAsActions()).sort(),\n        [\"_grist_DocInfo\", \"_grist_Tables\", \"_grist_Tables_column\", \"_grist_Thing\"]);\n      loader.startFetchingTable(\"User\");\n      await loader.wait();\n      assert.deepEqual(Object.keys(await loader.fetchTablesAsActions()).sort(),\n        [\"User\", \"_grist_DocInfo\", \"_grist_Tables\", \"_grist_Tables_column\", \"_grist_Thing\"]);\n      assert.deepEqual([...harness.loaded].sort(),\n        [\"User\", \"_grist_DocInfo\", \"_grist_Thing\", \"metatables\"]);\n    }\n  });\n\n  it(\"check flow works with atypical operation order\", async function() {\n    for (let i = 0; i < 5; i++) {\n      const harness = new TableMetadataLoaderHarness();\n      const loader = new TableMetadataLoader(harness);\n      for (const key of [\"_grist_Tables\", \"_grist_Tables_column\", \"_grist_DocInfo\",\n        \"_grist_Thing\", \"User\", \"metatables\"]) {\n        harness.fetchWait.set(key, Math.random() * 100);\n        harness.loadWait.set(key, Math.random() * 100);\n      }\n      loader.startStreamingToEngine();\n      loader.startFetchingTable(\"User\");\n      loader.startFetchingTable(\"_grist_Thing\");\n      loader.startFetchingTable(\"_grist_Tables_column\");\n      loader.startFetchingTable(\"_grist_Tables\");\n      loader.startFetchingTable(\"_grist_DocInfo\");\n      await loader.wait();\n      assert.deepEqual(Object.keys(await loader.fetchTablesAsActions()).sort(),\n        [\"User\", \"_grist_DocInfo\", \"_grist_Tables\", \"_grist_Tables_column\", \"_grist_Thing\"]);\n      assert.deepEqual([...harness.loaded].sort(),\n        [\"User\", \"_grist_DocInfo\", \"_grist_Thing\", \"metatables\"]);\n    }\n  });\n});\n"
  },
  {
    "path": "test/server/lib/Telemetry.ts",
    "content": "import { GristDeploymentType } from \"app/common/gristUrls\";\nimport { PrefSource } from \"app/common/InstallAPI\";\nimport { TelemetryEvent, TelemetryLevel } from \"app/common/Telemetry\";\nimport { ILogMeta, LogMethods } from \"app/server/lib/LogMethods\";\nimport { filterMetadata, ITelemetry, Telemetry } from \"app/server/lib/Telemetry\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\nconst chimpy = configForUser(\"Chimpy\");\nconst kiwi = configForUser(\"Kiwi\");\nconst anon = configForUser(\"Anonymous\");\n\ndescribe(\"Telemetry\", function() {\n  let oldEnv: testUtils.EnvironmentSnapshot;\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    process.env.TYPEORM_DATABASE = \":memory:\";\n  });\n\n  after(function() {\n    oldEnv.restore();\n  });\n\n  const variants: [GristDeploymentType, TelemetryLevel, PrefSource][] = [\n    [\"saas\", \"off\", \"environment-variable\"],\n    [\"saas\", \"limited\", \"environment-variable\"],\n    [\"saas\", \"full\", \"environment-variable\"],\n    [\"core\", \"off\", \"environment-variable\"],\n    [\"core\", \"limited\", \"environment-variable\"],\n    [\"core\", \"full\", \"environment-variable\"],\n    [\"core\", \"off\", \"preferences\"],\n    [\"core\", \"limited\", \"preferences\"],\n    [\"core\", \"full\", \"preferences\"],\n  ];\n\n  for (const [deploymentType, telemetryLevel, settingSource] of variants) {\n    describe(`in grist-${deploymentType} with level \"${telemetryLevel}\" set via ${settingSource}`, function() {\n      let server: TestServer;\n      let homeUrl: string;\n      let installationId: string;\n      let telemetry: ITelemetry;\n      let forwardEventSpy: sinon.SinonSpy;\n      let doForwardEventStub: sinon.SinonStub;\n\n      const sandbox = sinon.createSandbox();\n      const loggedEvents: [TelemetryEvent, ILogMeta][] = [];\n\n      before(async function() {\n        process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = deploymentType;\n        if (settingSource === \"environment-variable\") {\n          process.env.GRIST_TELEMETRY_LEVEL = telemetryLevel;\n        }\n        process.env.GRIST_DEFAULT_EMAIL = \"chimpy@getgrist.com\";\n        server = new TestServer(this);\n        homeUrl = await server.start();\n        if (settingSource === \"preferences\") {\n          await axios.patch(`${homeUrl}/api/install/prefs`, {\n            telemetry: { telemetryLevel },\n          }, chimpy);\n        }\n        installationId = (await server.server.getActivations().current()).id;\n        telemetry = server.server.getTelemetry();\n\n        sandbox\n          .stub(LogMethods.prototype, \"rawLog\")\n          .callsFake((_level: string, _info: unknown, name: string, meta: ILogMeta) => {\n            loggedEvents.push([name as TelemetryEvent, meta]);\n          });\n        forwardEventSpy = sandbox\n          .spy(Telemetry.prototype as any, \"_forwardEvent\");\n        doForwardEventStub = sandbox\n          .stub(Telemetry.prototype as any, \"_doForwardEvent\");\n      });\n\n      after(async function() {\n        delete process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE;\n        delete process.env.GRIST_TELEMETRY_LEVEL;\n        delete process.env.GRIST_DEFAULT_EMAIL;\n        await server.stop();\n        sandbox.restore();\n      });\n\n      it(\"returns the current telemetry config\", async function() {\n        assert.deepEqual(telemetry.getTelemetryConfig(), {\n          telemetryLevel,\n        });\n      });\n\n      if (deploymentType === \"core\") {\n        it(\"returns the current telemetry status\", async function() {\n          const resp = await axios.get(`${homeUrl}/api/install/prefs`, chimpy);\n          assert.equal(resp.status, 200);\n          assert.deepInclude(resp.data, {\n            telemetry: {\n              telemetryLevel: {\n                value: telemetryLevel,\n                source: settingSource,\n              },\n            },\n          });\n        });\n      }\n\n      if (telemetryLevel !== \"off\") {\n        if (deploymentType === \"saas\") {\n          it(\"logs telemetry events\", async function() {\n            if (telemetryLevel === \"limited\") {\n              telemetry.logEvent(null, \"documentOpened\", {\n                limited: {\n                  docIdDigest: \"digest\",\n                  isPublic: false,\n                },\n              });\n              assert.deepEqual(loggedEvents[loggedEvents.length - 1], [\n                \"documentOpened\",\n                {\n                  eventName: \"documentOpened\",\n                  eventSource: `grist-${deploymentType}`,\n                  docIdDigest: \"dige:Vq9L3nCkeufQ8euzDkXtM2Fl1cnsALqakjEeM6QlbXQ=\",\n                  isPublic: false,\n                  installationId,\n                },\n              ]);\n            }\n\n            if (telemetryLevel === \"full\") {\n              telemetry.logEvent(null, \"documentOpened\", {\n                limited: {\n                  docIdDigest: \"digest\",\n                  isPublic: false,\n                },\n                full: {\n                  userId: 1,\n                },\n              });\n              assert.deepEqual(loggedEvents[loggedEvents.length - 1], [\n                \"documentOpened\",\n                {\n                  eventName: \"documentOpened\",\n                  eventSource: `grist-${deploymentType}`,\n                  docIdDigest: \"dige:Vq9L3nCkeufQ8euzDkXtM2Fl1cnsALqakjEeM6QlbXQ=\",\n                  isPublic: false,\n                  userId: 1,\n                  installationId,\n                },\n              ]);\n            }\n\n            assert.equal(loggedEvents.length, 1);\n            assert.equal(forwardEventSpy.callCount, 0);\n          });\n        } else {\n          it(\"forwards telemetry events\", async function() {\n            if (telemetryLevel === \"limited\") {\n              telemetry.logEvent(null, \"documentOpened\", {\n                limited: {\n                  docIdDigest: \"digest\",\n                  isPublic: false,\n                },\n              });\n              assert.deepEqual(forwardEventSpy.lastCall.args, [\n                null,\n                \"documentOpened\",\n                {\n                  docIdDigest: \"dige:Vq9L3nCkeufQ8euzDkXtM2Fl1cnsALqakjEeM6QlbXQ=\",\n                  isPublic: false,\n                },\n              ]);\n              assert.equal(forwardEventSpy.callCount, 1);\n            }\n\n            if (telemetryLevel === \"full\") {\n              telemetry.logEvent(null, \"documentOpened\", {\n                limited: {\n                  docIdDigest: \"digest\",\n                  isPublic: false,\n                },\n                full: {\n                  userId: 1,\n                },\n              });\n              assert.deepEqual(forwardEventSpy.lastCall.args, [\n                null,\n                \"documentOpened\",\n                {\n                  docIdDigest: \"dige:Vq9L3nCkeufQ8euzDkXtM2Fl1cnsALqakjEeM6QlbXQ=\",\n                  isPublic: false,\n                  userId: 1,\n                },\n              ]);\n              // An earlier test triggered an apiUsage event.\n              assert.equal(forwardEventSpy.callCount, 2);\n            }\n\n            assert.isEmpty(loggedEvents);\n          });\n        }\n      } else {\n        it(\"does not log telemetry events\", async function() {\n          telemetry.logEvent(null, \"documentOpened\", {\n            limited: {\n              docIdDigest: \"digest\",\n              isPublic: false,\n            },\n          });\n          assert.isEmpty(loggedEvents);\n          assert.equal(forwardEventSpy.callCount, 0);\n        });\n      }\n\n      if (telemetryLevel !== \"off\") {\n        it(\"throws an error when an event is invalid\", async function() {\n          await assert.isRejected(\n            telemetry.logEventAsync(null, \"invalidEvent\" as TelemetryEvent, { limited: { method: \"GET\" } }),\n            /Unknown telemetry event: invalidEvent/,\n          );\n        });\n\n        it(\"throws an error when an event's metadata is invalid\", async function() {\n          await assert.isRejected(\n            telemetry.logEventAsync(null, \"documentOpened\", { limited: { invalidMetadata: \"GET\" } }),\n            /Unknown metadata for telemetry event documentOpened: invalidMetadata/,\n          );\n        });\n\n        if (telemetryLevel === \"limited\") {\n          it(\"throws an error when an event's metadata requires an elevated telemetry level\", async function() {\n            await assert.isRejected(\n              telemetry.logEventAsync(null, \"documentOpened\", { limited: { userId: 1 } }),\n\n              /Telemetry metadata userId of event documentOpened requires a minimum telemetry level of 2 but the current level is 1/,\n            );\n          });\n        }\n      }\n\n      if (telemetryLevel !== \"off\") {\n        if (deploymentType === \"saas\") {\n          it(\"logs telemetry events sent to /api/telemetry\", async function() {\n            await axios.post(`${homeUrl}/api/telemetry`, {\n              event: \"watchedVideoTour\",\n              metadata: {\n                limited: { watchTimeSeconds: 30 },\n              },\n            }, chimpy);\n            const [event, metadata] = loggedEvents[loggedEvents.length - 1];\n            assert.equal(event, \"watchedVideoTour\");\n            if (telemetryLevel === \"limited\") {\n              assert.deepEqual(metadata, {\n                eventName: \"watchedVideoTour\",\n                eventCategory: \"Welcome\",\n                eventSource: `grist-${deploymentType}`,\n                watchTimeSeconds: 30,\n                installationId,\n                isInternalUser: true,\n              });\n            } else {\n              assert.containsAllKeys(metadata, [\n                \"eventSource\",\n                \"watchTimeSeconds\",\n                \"userId\",\n                \"altSessionId\",\n              ]);\n              assert.equal(metadata.watchTimeSeconds, 30);\n              assert.equal(metadata.userId, 1);\n            }\n\n            if (telemetryLevel === \"limited\") {\n              assert.equal(loggedEvents.length, 2);\n            } else {\n              // The POST above also triggers an \"apiUsage\" event.\n              assert.equal(loggedEvents.length, 3);\n              assert.equal(loggedEvents[1][0], \"apiUsage\");\n            }\n            assert.equal(forwardEventSpy.callCount, 0);\n          });\n\n          if (telemetryLevel === \"limited\") {\n            it(\"skips checks if event sent to /api/telemetry is from an external source\", async function() {\n              await axios.post(`${homeUrl}/api/telemetry`, {\n                event: \"watchedVideoTour\",\n                metadata: {\n                  eventSource: \"grist-core\",\n                  watchTimeSeconds: 60,\n                  userId: 123,\n                  altSessionId: \"altSessionId\",\n                },\n              }, anon);\n              const [event, metadata] = loggedEvents[loggedEvents.length - 1];\n              assert.equal(event, \"watchedVideoTour\");\n              assert.containsAllKeys(metadata, [\n                \"eventSource\",\n                \"watchTimeSeconds\",\n                \"userId\",\n                \"altSessionId\",\n              ]);\n              assert.equal(metadata.watchTimeSeconds, 60);\n              assert.equal(metadata.userId, 123);\n              assert.equal(loggedEvents.length, 3);\n              assert.equal(forwardEventSpy.callCount, 0);\n            });\n          }\n        } else {\n          it(\"forwards telemetry events sent to /api/telemetry\", async function() {\n            await axios.post(`${homeUrl}/api/telemetry`, {\n              event: \"watchedVideoTour\",\n              metadata: {\n                limited: { watchTimeSeconds: 30 },\n              },\n            }, chimpy);\n            const [, event, metadata] = forwardEventSpy.lastCall.args;\n            assert.equal(event, \"watchedVideoTour\");\n            if (telemetryLevel === \"limited\") {\n              assert.deepEqual(metadata, {\n                watchTimeSeconds: 30,\n              });\n            } else {\n              assert.containsAllKeys(metadata, [\n                \"watchTimeSeconds\",\n                \"userId\",\n                \"altSessionId\",\n              ]);\n              assert.equal(metadata.watchTimeSeconds, 30);\n              assert.equal(metadata.userId, 1);\n            }\n\n            if (telemetryLevel === \"limited\") {\n              assert.equal(forwardEventSpy.callCount, 2);\n            } else {\n              // The count below includes 2 apiUsage events triggered as side effects.\n              assert.equal(forwardEventSpy.callCount, 4);\n              assert.equal(forwardEventSpy.thirdCall.args[1], \"apiUsage\");\n            }\n            assert.isEmpty(loggedEvents);\n          });\n\n          it(\"skips forwarding events if too many requests are pending\", async function() {\n            let numRequestsMade = 0;\n            doForwardEventStub.callsFake(async () => {\n              numRequestsMade += 1;\n              await new Promise(resolve => setTimeout(resolve, 1000));\n            });\n            forwardEventSpy.resetHistory();\n\n            // Log enough events simultaneously to cause some to be skipped. (The limit is 25.)\n            for (let i = 0; i < 30; i++) {\n              void telemetry.logEvent(null, \"documentOpened\", {\n                limited: {\n                  docIdDigest: \"digest\",\n                  isPublic: false,\n                },\n              });\n            }\n\n            // Check that out of the 30 forwardEvent calls, only 25 made POST requests.\n            assert.equal(forwardEventSpy.callCount, 30);\n            assert.equal(numRequestsMade, 25);\n          });\n        }\n      } else {\n        it(\"does not log telemetry events sent to /api/telemetry\", async function() {\n          telemetry.logEvent(null, \"apiUsage\", { limited: { method: \"GET\" } });\n          assert.isEmpty(loggedEvents);\n          assert.equal(forwardEventSpy.callCount, 0);\n        });\n      }\n    });\n  }\n\n  describe(\"api\", function() {\n    let server: TestServer;\n    let homeUrl: string;\n\n    const sandbox = sinon.createSandbox();\n\n    before(async function() {\n      process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = \"core\";\n      process.env.GRIST_DEFAULT_EMAIL = \"chimpy@getgrist.com\";\n      server = new TestServer(this);\n      homeUrl = await server.start();\n      sandbox.stub(Telemetry.prototype as any, \"_doForwardEvent\");\n    });\n\n    after(async function() {\n      delete process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE;\n      delete process.env.GRIST_DEFAULT_EMAIL;\n      await server.stop();\n      sandbox.restore();\n    });\n\n    it(\"GET /install/prefs returns 403 for non-default users\", async function() {\n      const resp = await axios.get(`${homeUrl}/api/install/prefs`, kiwi);\n      assert.equal(resp.status, 403);\n    });\n\n    it(\"GET /install/prefs returns 200 for the default user\", async function() {\n      const resp = await axios.get(`${homeUrl}/api/install/prefs`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepInclude(resp.data, {\n        telemetry: {\n          telemetryLevel: {\n            value: \"off\",\n            source: \"preferences\",\n          },\n        },\n      });\n    });\n\n    it(\"PATCH /install/prefs returns 403 for non-default users\", async function() {\n      const resp = await axios.patch(`${homeUrl}/api/install/prefs`, {\n        telemetry: { telemetryLevel: \"limited\" },\n      }, kiwi);\n      assert.equal(resp.status, 403);\n    });\n\n    it(\"PATCH /install/prefs returns 200 for the default user\", async function() {\n      let resp = await axios.patch(`${homeUrl}/api/install/prefs`, {\n        telemetry: { telemetryLevel: \"limited\" },\n      }, chimpy);\n      assert.equal(resp.status, 200);\n\n      resp = await axios.get(`${homeUrl}/api/install/prefs`, chimpy);\n      assert.deepInclude(resp.data, {\n        telemetry: {\n          telemetryLevel: {\n            value: \"limited\",\n            source: \"preferences\",\n          },\n        },\n      });\n    });\n\n    it(\"checkForLatestVersion can be modified independently\", async function() {\n      let resp = await axios.get(`${homeUrl}/api/install/prefs`, chimpy);\n      assert.deepInclude(resp.data, {\n        telemetry: {\n          telemetryLevel: {\n            value: \"limited\",\n            source: \"preferences\",\n          },\n        },\n        checkForLatestVersion: true,\n      });\n\n      resp = await axios.patch(`${homeUrl}/api/install/prefs`, {\n        checkForLatestVersion: false,\n      }, chimpy);\n      assert.equal(resp.status, 200);\n\n      resp = await axios.patch(`${homeUrl}/api/install/prefs`, {\n        telemetry: { telemetryLevel: \"off\" },\n      }, chimpy);\n      assert.equal(resp.status, 200);\n\n      resp = await axios.get(`${homeUrl}/api/install/prefs`, chimpy);\n      assert.deepInclude(resp.data, {\n        telemetry: {\n          telemetryLevel: {\n            value: \"off\",\n            source: \"preferences\",\n          },\n        },\n        checkForLatestVersion: false,\n      });\n    });\n  });\n\n  describe(\"filterMetadata\", function() {\n    it('returns filtered and flattened metadata when maxLevel is \"full\"', function() {\n      const metadata = {\n        limited: {\n          foo: \"abc\",\n        },\n        full: {\n          bar: \"123\",\n        },\n      };\n      assert.deepEqual(filterMetadata(metadata, \"full\"), {\n        foo: \"abc\",\n        bar: \"123\",\n      });\n    });\n\n    it('returns filtered and flattened metadata when maxLevel is \"limited\"', function() {\n      const metadata = {\n        limited: {\n          foo: \"abc\",\n        },\n        full: {\n          bar: \"123\",\n        },\n      };\n      assert.deepEqual(filterMetadata(metadata, \"limited\"), {\n        foo: \"abc\",\n      });\n    });\n\n    it('returns undefined when maxLevel is \"off\"', function() {\n      assert.isUndefined(filterMetadata(undefined, \"off\"));\n    });\n\n    it(\"returns an empty object when metadata is empty\", function() {\n      assert.isEmpty(filterMetadata({}, \"full\"));\n    });\n\n    it(\"returns undefined when metadata is undefined\", function() {\n      assert.isUndefined(filterMetadata(undefined, \"full\"));\n    });\n\n    it(\"does not mutate metadata\", function() {\n      const metadata = {\n        limited: {\n          foo: \"abc\",\n        },\n        full: {\n          bar: \"123\",\n        },\n      };\n      filterMetadata(metadata, \"limited\");\n      assert.deepEqual(metadata, {\n        limited: {\n          foo: \"abc\",\n        },\n        full: {\n          bar: \"123\",\n        },\n      });\n    });\n\n    it(\"excludes keys with nullish values\", function() {\n      const metadata = {\n        limited: {\n          foo1: null,\n          foo2: \"abc\",\n        },\n        full: {\n          bar1: undefined,\n          bar2: \"123\",\n        },\n      };\n      assert.deepEqual(filterMetadata(metadata, \"full\"), {\n        foo2: \"abc\",\n        bar2: \"123\",\n      });\n    });\n\n    it('hashes keys suffixed with \"Digest\"', function() {\n      const metadata = {\n        limited: {\n          docIdDigest: \"FGWGX4S6TB6\",\n          docId: \"3WH3D68J28\",\n        },\n      };\n      assert.deepEqual(filterMetadata(metadata, \"limited\"), {\n        docIdDigest: \"FGWG:omhYAysWiM7coZK+FLK/tIOPW4BaowXjU7J/P9ynYcU=\",\n        docId: \"3WH3D68J28\",\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/TestingHooks.ts",
    "content": "import { connectTestingHooks, startTestingHooks, TestingHooksClient } from \"app/server/lib/TestingHooks\";\nimport { assert, setTmpLogLevel } from \"test/server/testUtils\";\n\nimport * as tmp from \"tmp-promise\";\ntmp.setGracefulCleanup();\n\ndescribe(\"TestingHooks\", function() {\n  setTmpLogLevel(\"warn\");\n\n  it(\"should start server and accept basic calls\", async function() {\n    const tmpName: string = await tmp.tmpName({ prefix: \"gristtest-\" });\n    const server = await startTestingHooks(tmpName, 192348, null as any, null as any, []);\n    const stub: TestingHooksClient = await connectTestingHooks(tmpName);\n    try {\n      assert.equal(await stub.getPort(), 192348);\n    } finally {\n      server.close();\n      stub.close();\n    }\n  });\n});\n"
  },
  {
    "path": "test/server/lib/Throttle.ts",
    "content": "import { exitPromise } from \"app/server/lib/serverUtils\";\nimport { Throttle, ThrottleTiming } from \"app/server/lib/Throttle\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { ChildProcess, spawn } from \"child_process\";\n\nimport { delay } from \"bluebird\";\nimport { assert } from \"chai\";\nimport pidusage from \"pidusage\";\n\nconst testTiming: ThrottleTiming = {\n  dutyCyclePositiveMs: 20,\n  samplePeriodMs: 100,\n  targetAveragingPeriodMs: 1000,\n  minimumAveragingPeriodMs: 50,\n  minimumLogPeriodMs: 10,\n  targetRate: 0.25,\n  maxThrottle: 10,\n  traceNudgeOffset: 5,\n};\n\ninterface ThrottleTestCase {\n  child: ChildProcess;\n  throttle: Throttle;\n  done: Promise<number | string>;\n  cpuHog: boolean;\n}\n\ndescribe(\"Throttle\", function() {\n  testUtils.setTmpLogLevel(\"error\");\n\n  // Test with N processes, half very busy, half not busy at all.\n  for (const processCount of [2, 10]) {\n    it(`throttle looks sane with ${processCount / 2} busy process(es)`, async function() {\n      this.timeout(10000);\n      const tests: ThrottleTestCase[] = [];\n      for (let i = 0; i < processCount; i++) {\n        const cpuHog = i % 2 === 0;\n        const cmd = cpuHog ? \"while true; do true; done\" : \"sleep 10000\";\n        const child = spawn(cmd, [], { shell: true, detached: true, stdio: \"ignore\" });\n        if (!child.pid) {\n          throw new Error(\"failed to spawn process\");\n        }\n\n        const done = exitPromise(child);\n        const throttle = new Throttle({\n          pid: child.pid,\n          logMeta: { sandboxPid: child.pid, docId: `case${i}` },\n          timing: testTiming,\n        });\n        tests.push({\n          child, throttle, done, cpuHog,\n        });\n      }\n      await delay(5000);\n      for (const test of tests) {\n        test.child.kill();\n      }\n      for (const test of tests) {\n        await test.done;\n      }\n      for (const test of tests) {\n        test.throttle.stop();\n        const stats = test.throttle.testStats;\n        if (!stats) { throw new Error(\"throttling never ran\"); }\n        if (test.cpuHog) {\n          // Process should have received some cpu time.  Exactly how much depends on\n          // the load on the test server, so don't be too fussy.\n          assert.isAbove(stats.cpuDuration, 500);\n          // Process should not have received an excessive amount of cpu time.\n          assert.isBelow(stats.cpuDuration, 2500);\n          assert.isAbove(stats.offDuration, 1000);\n        } else {\n          // Sleep should take almost no cpu.\n          assert.isBelow(stats.cpuDuration, 100);\n          assert.equal(stats.offDuration, 0);\n        }\n      }\n      // Clear the setInterval that the pidusage module sets up internally.\n      await delay(100);  // Wait a little in case an async pidusage call hasn't finished yet.\n      // TODO: fix pidusage upstream to allow graceful shutdown.\n      pidusage.clear();\n    });\n  }\n});\n"
  },
  {
    "path": "test/server/lib/TimeQuery.ts",
    "content": "import { summarizeAction } from \"app/common/ActionSummarizer\";\nimport { ActionSummary } from \"app/common/ActionSummary\";\nimport { TimeCursor, TimeLayout, TimeQuery } from \"app/common/TimeQuery\";\nimport { ActiveDoc } from \"app/server/lib/ActiveDoc\";\nimport { SQLiteTimeData } from \"app/server/lib/TimeQuery\";\nimport { createDocTools } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { assert } from \"test/server/testUtils\";\n\n/** get a summary of the last LocalActionBundle applied to a given document */\nasync function summarizeLastAction(doc: ActiveDoc): Promise<ActionSummary> {\n  return summarizeAction((await doc.getRecentActionsDirect(1))[0]);\n}\n\ndescribe(\"TimeQuery\", function() {\n  // No sandboxing, to avoid change in speed.\n  testUtils.withoutSandboxing();\n\n  // Comment this out to see debug-log output when debugging tests.\n  testUtils.setTmpLogLevel(\"error\");\n\n  const docTools = createDocTools();\n\n  it(\"can view state of table in past\", async function() {\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    const db = doc.docStorage;\n    const cursor = new TimeCursor(new SQLiteTimeData(db));\n\n    // We'll be interested in viewing the state of table \"Fish\", column \"age\".\n    const fish = new TimeQuery(cursor, \"Fish\", [\"age\"]);\n    const session = docTools.createFakeSession();\n\n    // Stick some data in the Fish table.\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Fish\", [{ id: \"age\" }, { id: \"species\" }, { id: \"color\" }]],\n      [\"AddRecord\", \"Fish\", null, { age: \"11\", species: \"flounder\", color: \"blue\" }],\n      [\"AddRecord\", \"Fish\", null, { age: \"22\", species: \"bounder\", color: \"red\" }],\n    ]);\n    const summary1 = await summarizeLastAction(doc);\n\n    // Change some data, remove some data.\n    await doc.applyUserActions(session, [\n      [\"UpdateRecord\", \"Fish\", 1, { age: \"111\" }],\n      [\"RemoveRecord\", \"Fish\", 2],\n    ]);\n    const summary2 = await summarizeLastAction(doc);\n\n    // Now read out the current state.\n    await fish.update();\n    assert.sameDeepMembers(fish.all(),\n      [{ id: 1, age: \"111\" }]);\n\n    // Go back one step in time.\n    cursor.prepend(summary2);\n    await fish.update();\n    assert.sameDeepMembers(fish.all(),\n      [{ id: 1, age: \"11\" },\n        { id: 2, age: \"22\" }]);\n\n    // and one more step\n    cursor.prepend(summary1);\n    await fish.update();\n    assert.sameDeepMembers(fish.all(), []);\n  });\n\n  it(\"can track column order and user-facing table name\", async function() {\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    const db = doc.docStorage;\n    const cursor = new TimeCursor(new SQLiteTimeData(db));\n    const layout = new TimeLayout(cursor);\n    const session = docTools.createFakeSession();\n\n    // Create a table with three columns.\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Fish!\", [{ id: \"age\" }, { id: \"species\" }, { id: \"color\" }]],\n      // AddTable doesn't actually set the requested name, so patch it up.\n      [\"UpdateRecord\", \"_grist_Views\", 1, { name: \"Fish!\" }],\n      [\"UpdateRecord\", \"_grist_Views_section\", 2, { title: \"Fish!\" }],  // Change section (and table) name\n      [\"AddRecord\", \"Fish_\", null, { age: \"11\", species: \"flounder\", color: \"blue\" }],\n      [\"AddRecord\", \"Fish_\", null, { age: \"22\", species: \"bounder\", color: \"red\" }],\n    ]);\n    const summary1 = await summarizeLastAction(doc);\n\n    // Now move the species column. We need its field id to do so.\n    // Just for practice, we read its field id from the db.\n    await layout.update();\n    const table = layout.tables.one({ tableId: \"Fish_\" });\n    const column = layout.columns.one({ parentId: table.id, colId: \"species\" });\n    const field = layout.fields.one({ parentId: table.primaryViewId, colRef: column.id });\n    const section = layout.sections.one({ id: table.rawViewSectionRef });\n    await doc.applyUserActions(session, [\n      [\"UpdateRecord\", \"_grist_Views_section_field\", field.id, { parentPos: 999 }],\n      [\"UpdateRecord\", \"Fish_\", 1, { age: \"111\" }],  // Change some data as well for the heck of it.\n      [\"RemoveRecord\", \"Fish_\", 2],                // Remove some data as well for the heck of it.\n      [\"UpdateRecord\", \"_grist_Views\", 1, { name: \"Poissons!\" }],  // Change view name\n      [\"UpdateRecord\", \"_grist_Views_section\", section.id, { title: \"Poissons!\" }],  // Change section (and table) name\n    ]);\n    const summary2 = await summarizeLastAction(doc);\n\n    // Check column order now.\n    await layout.update();\n    assert.deepEqual(layout.getColumnOrder(\"Poissons_\"), [\"age\", \"color\", \"species\"]);\n    assert.deepEqual(layout.getTableName(\"Poissons_\"), \"Poissons!\");\n\n    // Move back one step, then check column order again.\n    cursor.prepend(summary2);\n    await layout.update();\n    assert.deepEqual(layout.getColumnOrder(\"Fish_\"), [\"age\", \"species\", \"color\"]);\n    assert.deepEqual(layout.getTableName(\"Fish_\"), \"Fish!\");\n\n    // Move back one step, then check column order again.\n    cursor.prepend(summary1);\n    await layout.update();\n    assert.throws(() => layout.getColumnOrder(\"Fish_\"),\n      /could not find/);\n  });\n\n  it(\"can handle renames\", async function() {\n    this.timeout(10000);\n\n    const doc: ActiveDoc = await docTools.createDoc(\"test.grist\");\n    const db = doc.docStorage;\n    const cursor = new TimeCursor(new SQLiteTimeData(db));\n    const session = docTools.createFakeSession();\n\n    // Create a table with three columns. In this test, we will rename\n    // the table and a column and make sure the renames don't affect\n    // querying information prior to those changes.\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Fish\", [{ id: \"age\" }, { id: \"species\" }, { id: \"color\" }]],\n      [\"AddRecord\", \"Fish\", null, { age: \"11\", species: \"flounder\", color: \"blue\" }],\n      [\"AddRecord\", \"Fish\", null, { age: \"22\", species: \"bounder\", color: \"red\" }],\n    ]);\n    await doc.applyUserActions(session, [\n      [\"RenameTable\", \"Fish\", \"Fish2\"],\n    ]);\n    cursor.append(await summarizeLastAction(doc));\n    const query = new TimeQuery(cursor, \"Fish\", [\"species\"]);\n    const expectedResult = [\n      { id: 1, species: \"flounder\" },\n      { id: 2, species: \"bounder\" },\n    ];\n    let result = await query.update();\n    assert.deepEqual(result, expectedResult);\n\n    await doc.applyUserActions(session, [\n      [\"RenameColumn\", \"Fish2\", \"species\", \"species2\"],\n    ]);\n    cursor.append(await summarizeLastAction(doc));\n    result = await query.update();\n    assert.deepEqual(result, expectedResult);\n\n    await doc.applyUserActions(session, [\n      [\"RenameColumn\", \"Fish2\", \"color\", \"color2\"],\n      [\"RenameColumn\", \"Fish2\", \"species2\", \"color\"],\n    ]);\n    cursor.append(await summarizeLastAction(doc));\n    result = await query.update();\n    assert.deepEqual(result, expectedResult);\n\n    await doc.applyUserActions(session, [\n      [\"AddTable\", \"Fish\", [{ id: \"species\" }]],\n      [\"AddRecord\", \"Fish\", null, { species: \"tadpole\" }],\n    ]);\n    cursor.append(await summarizeLastAction(doc));\n    result = await query.update();\n    assert.deepEqual(result, expectedResult);\n\n    await doc.applyUserActions(session, [\n      [\"UpdateRecord\", \"Fish2\", 1, { color: \"whale\" }],\n    ]);\n    cursor.append(await summarizeLastAction(doc));\n    result = await query.update();\n    assert.deepEqual(result, expectedResult);\n\n    // Double check that we've actually made all the changes we think we have.\n    assert.deepEqual(await doc.fetchQuery(session, { tableId: \"Fish2\", filters: {} }), {\n      tableData: [\n        \"TableData\",\n        \"Fish2\",\n        [1, 2],\n        {\n          manualSort: [\n            1,\n            2,\n          ],\n          age: [\n            \"11\",\n            \"22\",\n          ],\n          color2: [\n            \"blue\",\n            \"red\",\n          ],\n          color: [\n            \"whale\",\n            \"bounder\",\n          ],\n        },\n      ],\n    });\n\n    query.reset(\"Fish\", \"*\");\n    result = await query.update();\n    assert.deepEqual(result, [\n      {\n        id: 1,\n        manualSort: 1,\n        age: \"11\",\n        species: \"flounder\",\n        color: \"blue\",\n      },\n      {\n        id: 2,\n        manualSort: 2,\n        age: \"22\",\n        species: \"bounder\",\n        color: \"red\",\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/Triggers.ts",
    "content": "import { DocAPI, UserAPI } from \"app/common/UserAPI\";\nimport { DocTriggers } from \"app/server/lib/Triggers\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport { Defer, serveSomething, Serving } from \"test/server/customUtil\";\nimport { createTmpDir } from \"test/server/docTools\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\nimport pick from \"lodash/pick\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"Triggers\", function() {\n  let server: TestServer;\n  let serverUrl: string;\n  let ownerApi: UserAPI;\n  let wsId: number;\n  testUtils.setTmpLogLevel(\"error\");\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  let serving: Serving;  // manages the test webhook server\n  const chimpy = configForUser(\"Chimpy\");\n  this.timeout(\"10s\");\n  let sandbox: sinon.SinonSandbox;\n  // Helper to wait for events to be enqueued.\n  class EventAwaiter {\n    private _defer: Defer<number> | null = new Defer<number>();\n    public start() {\n      this._defer = new Defer<number>();\n    }\n\n    public wait() {\n      assert.isOk(this._defer, \"Waiter not started\");\n      return this._defer;\n    }\n\n    public enqueueCalled(numEvents: number) {\n      if (!this._defer) {\n        return;  // not waiting\n      }\n      this._defer.resolve(numEvents);\n    }\n  }\n  const waiter = new EventAwaiter();\n\n  before(async function() {\n    if (!process.env.REDIS_URL && !process.env.TEST_REDIS_URL) {\n      this.skip();  // skip if no redis available\n    }\n\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    // Store data in a temporary directory\n    process.env.GRIST_DATA_DIR = await createTmpDir();\n    // Allow any webhook domain\n    process.env.ALLOWED_WEBHOOK_DOMAINS = \"*\";\n    // Use the TEST_REDIS_URL as the global redis url, if supplied.\n    if (process.env.TEST_REDIS_URL && !process.env.REDIS_URL) {\n      process.env.REDIS_URL = process.env.TEST_REDIS_URL;\n    }\n\n    // Lets hijack the DocTriggers.enqueue to detect when the events are pushed to the queue (or no\n    // events to be pushed).\n    server = new TestServer(this);\n    sandbox = sinon.createSandbox();\n    const oldEnqueue = DocTriggers.prototype.enqueue;\n    sandbox.replace(DocTriggers.prototype, \"enqueue\", async function(this: DocTriggers, events: any[]) {\n      // This function is called even if there are no events to enqueue.\n      try {\n        return await oldEnqueue.call(this as any, events as any);\n      } finally {\n        waiter.enqueueCalled(events.length);\n      }\n    });\n\n    // Start doc and home server\n    serverUrl = await server.start([\"home\", \"docs\"]);\n\n    // Create a team site for testing (don't check access as org is not there yet)\n    ownerApi = await server.createHomeApi(\"chimpy\", \"testy\", true, false);\n    await ownerApi.newOrg({ name: \"testy\", domain: \"testy\" });\n\n    // Create new workspace (finding Home workspace is harder :) )\n    wsId = await ownerApi.newWorkspace({ name: \"ws\" }, \"current\");\n\n    // Serve something so that webhooks are not failing due to connection errors.\n    serving = await serveSomething((app) => {\n      app.use((_, res) => res.sendStatus(200));\n    });\n  });\n\n  after(async function() {\n    await server.stop();\n    oldEnv.restore();\n    await serving.shutdown();\n    sandbox.restore();\n  });\n\n  async function autoSubscribe(\n    docId: string, options?: {\n      tableId?: string,\n      isReadyColumn?: string | null,\n      eventTypes?: string[]\n      watchedColIds?: string[],\n      condition?: string,\n    }) {\n    // Subscribe helper that returns a method to unsubscribe.\n    const webhook = await subscribe(docId, options);\n    return () => unsubscribe(docId, webhook.id);\n  }\n\n  function unsubscribe(docId: string, webhookId: string) {\n    return axios.delete(\n      `${serverUrl}/api/docs/${docId}/webhooks/${webhookId}`,\n      chimpy,\n    );\n  }\n\n  async function subscribe(docId: string, options?: {\n    tableId?: string,\n    isReadyColumn?: string | null,\n    eventTypes?: string[],\n    watchedColIds?: string[],\n    condition?: string,\n    name?: string,\n    memo?: string,\n    enabled?: boolean,\n  }) {\n    // Subscribe helper that returns a method to unsubscribe.\n    const { data, status } = await axios.post(`${serverUrl}/api/docs/${docId}/webhooks`, {\n      webhooks: [{\n        fields: {\n          tableId: options?.tableId ?? \"Table1\",\n          eventTypes: options?.eventTypes ?? [\"add\", \"update\"],\n          url: `${serving.url}/data`,\n          isReadyColumn: options?.isReadyColumn,\n          ...pick(options, \"name\", \"memo\", \"enabled\", \"watchedColIds\", \"condition\"),\n        },\n      }],\n    }, chimpy);\n    assert.equal(status, 200, `Error during subscription: ` + JSON.stringify(data));\n    const [webhook] = data.webhooks;\n    assert.isOk(webhook?.id, `Missing webhook id in response: ` + JSON.stringify(data));\n    return webhook;\n  }\n\n  async function clearQueue(docId: string) {\n    const deleteResult = await axios.delete(\n      `${serverUrl}/api/docs/${docId}/webhooks/queue`, chimpy,\n    );\n    assert.equal(deleteResult.status, 200);\n  }\n\n  describe(\"conditions\", function() {\n    let docId: string;\n    let doc: DocAPI;\n    this.timeout(\"10s\");\n\n    before(async function() {\n      docId = await ownerApi.newDoc({ name: \"testdoc\" }, wsId);\n      doc = ownerApi.getDocAPI(docId);\n    });\n\n    afterEach(async function() {\n      await doc.flushWebhooks();\n      await clearQueue(docId);\n      await doc.applyUserActions([\n        [\"RemoveRecord\", \"_grist_Triggers\", 1],\n      ]);\n      await doc.applyUserActions([\n        [\"RemoveRecord\", \"Table1\", 1],\n      ]);\n    });\n\n    // Plain smoke test that conditions somehow works.\n    it(\"should support custom expression\", async function() {\n      const clear = await autoSubscribe(docId, {\n        tableId: \"Table1\",\n        condition: \"$A > 10\",\n      });\n\n      // Add a record to the table\n      await notCalled(async () => {\n        await doc.addRows(\"Table1\", {\n          A: [1],\n        });\n      });\n\n      // Update this record to meet the condition\n      await called(async () => {\n        await doc.updateRows(\"Table1\", {\n          id: [1],\n          A: [11],\n        });\n      });\n      await clear();\n    });\n\n    const called = async (fn: () => Promise<any>) => {\n      waiter.start();\n      await fn();\n      assert.equal(await waiter.wait(), 1, \"Should have processed one event\");\n    };\n\n    const notCalled = async (fn: () => Promise<any>) => {\n      waiter.start();\n      await fn();\n      assert.equal(await waiter.wait(), 0, \"Should have processed one event\");\n    };\n\n    it(\"should see previous record values in condition\", async function() {\n      const clear = await autoSubscribe(docId, {\n        tableId: \"Table1\",\n        condition: \"oldRec.A == 1 and oldRec.B == 2\",\n      });\n\n      // Not called, this is new row.\n      await notCalled(async () => {\n        await doc.addRows(\"Table1\", {\n          A: [1],\n          B: [2],\n        });\n      });\n\n      // Called, oldRec matches.\n      await called(() => doc.updateRows(\"Table1\", {\n        id: [1],\n        A: [3],\n      }));\n\n      // Not called still, old rec does not match.\n      await notCalled(() => doc.updateRows(\"Table1\", {\n        id: [1],\n        A: [1],\n      }));\n\n      // Now called, old rec matches again.\n      await called(() => doc.updateRows(\"Table1\", {\n        id: [1],\n        B: [5],\n      }));\n\n      await clear();\n    });\n\n    it(\"should detect that this was a new row\", async function() {\n      const clear = await autoSubscribe(docId, {\n        tableId: \"Table1\",\n        condition: \"not oldRec.id\",\n      });\n\n      // Called, this is new row.\n      await called(async () => {\n        await doc.addRows(\"Table1\", {\n          A: [1],\n          B: [2],\n        });\n      });\n\n      // Not called, this is an update.\n      await notCalled(async () => {\n        await doc.updateRows(\"Table1\", {\n          id: [1],\n          A: [3],\n        });\n      });\n\n      await clear();\n    });\n\n    it(\"should detect that column was empty\", async function() {\n      const clear = await autoSubscribe(docId, {\n        tableId: \"Table1\",\n        condition: \"not oldRec.A\",\n      });\n\n      // Called, this is new row.\n      await called(async () => {\n        await doc.addRows(\"Table1\", {\n          A: [10],\n          B: [20],\n        });\n      });\n\n      // Not called, this is an update.\n      await notCalled(async () => {\n        await doc.updateRows(\"Table1\", {\n          id: [1],\n          A: [30],\n        });\n      });\n\n      // But it will be called after the record is removed and re-added.\n      await notCalled(async () => {\n        await doc.applyUserActions([\n          [\"RemoveRecord\", \"Table1\", 1],\n        ]);\n      });\n\n      await called(async () => {\n        await doc.addRows(\"Table1\", {\n          A: [10],\n          B: [20],\n        });\n      });\n\n      // And will be called again if we clear record and fill again.\n      await notCalled(async () => {\n        await doc.updateRows(\"Table1\", {\n          id: [1],\n          A: [null],\n        });\n      });\n\n      await called(async () => {\n        await doc.updateRows(\"Table1\", {\n          id: [1],\n          A: [10],\n        });\n      });\n\n      await clear();\n    });\n\n    it(\"should have access columns not modified in action\", async function() {\n      const clear = await autoSubscribe(docId, {\n        tableId: \"Table1\",\n        condition: \"oldRec.B == 2\",\n      });\n\n      // Not called this is a new row.\n      await notCalled(async () => {\n        await doc.addRows(\"Table1\", {\n          A: [1],\n          B: [2],\n        });\n      });\n\n      // Now update A should be called.\n      await called(async () => {\n        await doc.updateRows(\"Table1\", {\n          id: [1],\n          A: [10],\n        });\n      });\n\n      // Sanity check that engine does ignore same update.\n      await notCalled(async () => {\n        await doc.updateRows(\"Table1\", {\n          id: [1],\n          A: [10],\n        });\n      });\n\n      await clear();\n    });\n\n    it(\"should have access to new rec columns not modified in action\", async function() {\n      const clear = await autoSubscribe(docId, {\n        tableId: \"Table1\",\n        condition: \"rec.B == 3\",\n      });\n\n      // Called for a new row that matches.\n      await called(async () => {\n        await doc.addRows(\"Table1\", {\n          A: [1],\n          B: [3],\n        });\n      });\n\n      // Not called when that row is removed.\n      await notCalled(async () => {\n        await doc.applyUserActions([\n          [\"RemoveRecord\", \"Table1\", 1],\n        ]);\n      });\n\n      // Not called for the new row that does not match.\n      await notCalled(async () => {\n        await doc.addRows(\"Table1\", {\n          A: [10],\n          B: [2],\n        });\n      });\n\n      // Called after updating column in test to match.\n      await called(async () => {\n        await doc.updateRows(\"Table1\", {\n          id: [1],\n          B: [3],\n        });\n      });\n\n      // Called when updating other column but this column still matches.\n      await called(async () => {\n        await doc.updateRows(\"Table1\", {\n          id: [1],\n          A: [20],\n        });\n      });\n\n      await clear();\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/UnhandledErrors.ts",
    "content": "import { prepareDatabase } from \"test/server/lib/helpers/PrepareDatabase\";\nimport { TestServer } from \"test/server/lib/helpers/TestServer\";\nimport { createTestDir, setTmpLogLevel } from \"test/server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport { PassThrough } from \"stream\";\n\nimport { assert } from \"chai\";\nimport fetch from \"node-fetch\";\n\n/**\n * Grist sticks to the Node 18 default and recommendation to give up and exit on uncaught\n * exceptions, unhandled promise rejections, and unhandled 'error' events. But it makes an effort\n * to clean up before exiting, and to log the error in a better way.\n */\ndescribe(\"UnhandledErrors\", function() {\n  this.timeout(30000);\n\n  setTmpLogLevel(\"warn\");\n\n  let testDir: string;\n  let oldEnv: testUtils.EnvironmentSnapshot;\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    testDir = await createTestDir(\"UnhandledErrors\");\n    await prepareDatabase(testDir);\n  });\n\n  after(function() {\n    oldEnv.restore();\n  });\n\n  for (const errType of [\"exception\", \"rejection\", \"error-event\"]) {\n    it(`should clean up on unhandled ${errType}`, async function() {\n      // Capture server log output, so that we can look to see how the server coped.\n      const output = new PassThrough();\n      const serverLogLines: string[] = [];\n      output.on(\"data\", data => serverLogLines.push(data.toString()));\n\n      const server = await TestServer.startServer(\"home\", testDir, errType, undefined, undefined, { output });\n\n      try {\n        assert.equal((await fetch(`${server.serverUrl}/status`)).status, 200);\n        serverLogLines.length  = 0;\n\n        // Trigger an unhandled error, and check that the server logged it and attempted cleanup.\n        await server.testingHooks.tickleUnhandledErrors(errType);\n        await waitForIt(() => {\n          assert.isTrue(serverLogLines.some(line => new RegExp(`Fake ${errType}`).test(line)));\n          assert.isTrue(serverLogLines.some(line => /Server .* cleaning up/.test(line)));\n        }, 1000, 100);\n\n        // We expect the server to be dead now.\n        // Error message depends a little on node version.\n        await assert.isRejected(fetch(`${server.serverUrl}/status`), /(request.*failed)|(ECONNREFUSED)/);\n      } finally {\n        await server.stop();\n      }\n    });\n  }\n});\n"
  },
  {
    "path": "test/server/lib/UserAttributes.ts",
    "content": "import { CellValue, fromTableDataAction, TableColValues, toTableDataAction } from \"app/common/DocActions\";\nimport { UserAPI, UserAPIImpl } from \"app/common/UserAPI\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { GristClient, openClient } from \"test/server/gristClient\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\nimport fetch from \"node-fetch\";\n\ndescribe(\"UserAttributes\", function() {\n  this.timeout(60000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  let server: TestServer;\n  let serverUrl: string;\n  let wsId: number;\n\n  let owner: UserAPI;\n  const testDisplayEmail = \"Charon@gEtGrIsT.com\";\n  const testLowerEmail = \"charon@getgrist.com\";\n  const clients: GristClient[] = [];\n\n  beforeEach(async function() {\n    server = new TestServer(this);\n    serverUrl = await server.start([\"home\", \"docs\"]);\n    owner = await server.createHomeApi(\"Chimpy\", \"nasa\", true);\n    wsId = await owner.newWorkspace({ name: \"ws\" }, \"current\");\n    // Give the test user some access.\n    await owner.updateWorkspacePermissions(wsId, {\n      users: { [testLowerEmail]: \"editors\" },\n    });\n  });\n\n  afterEach(async function() {\n    for (const cli of clients) {\n      await cli.send(\"closeDoc\", 0);\n      await cli.close();\n    }\n    await server.stop();\n  });\n\n  it(\"access rules matches email to user-attributes case-insensitively\", async function() {\n    // Log in with a simulated provider giving a non-lowercase capitalization.\n    const cookie = await server.getCookieLogin(\"nasa\", { email: testDisplayEmail, name: \"Chimpy\" });\n    const resp = await axios.get(`${serverUrl}/o/nasa/api/profile/user`, cookie);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.email, testDisplayEmail);\n    assert.equal(resp.data.loginEmail, testLowerEmail);\n\n    const docId = await owner.newDoc({ name: \"UserAttributes1\" }, wsId);\n\n    // The document we set up has two tables and a bunch of rules:\n    // 'People' table, with EmailAddress column, is the user-attribute table.\n    // 'Projects' table is what we use to test access. It has a Ref:People column and Email. We\n    // have several rules, each determining access to one of the TagX columns. We verify access\n    // by checking which cells in the TagX columns show up as censored.\n    const peopleData = toTableDataAction(\"People\", tableToColValues([\n      [\"id\", \"EmailAddress\"],\n      [1,    \"alice@example.com\"],\n      [2,    testLowerEmail],\n    ]));\n    const projectsData = toTableDataAction(\"Projects\", tableToColValues([\n      [\"id\", \"Person\", \"Email\",             \"TagByRef\", \"TagByEmail\", \"TagByEmailLower\"],\n      [1,    1,        \"alice@example.com\", \"ok\",       \"ok\",         \"ok\"],\n      [2,    2,        testLowerEmail,      \"ok\",       \"ok\",         \"ok\"],\n      [3,    2,        testDisplayEmail,    \"ok\",       \"ok\",         \"ok\"],\n    ]));\n    await owner.applyUserActions(docId, [\n      [\"AddTable\", \"People\", [\n        { id: \"EmailAddress\" },\n      ]],\n      [\"AddTable\", \"Projects\", [\n        { id: \"Person\", type: \"Ref:People\" },\n        { id: \"Email\" },\n        { id: \"TagByRef\" },\n        { id: \"TagByEmail\" },\n        { id: \"TagByEmailLower\" },\n      ]],\n      [\"BulkAddRecord\", \"People\", peopleData[2], peopleData[3]],\n      [\"BulkAddRecord\", \"Projects\", projectsData[2], projectsData[3]],\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"*\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"Projects\", colIds: \"TagByRef\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -3, { tableId: \"Projects\", colIds: \"TagByEmail\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -4, { tableId: \"Projects\", colIds: \"TagByEmailLower\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, userAttributes: JSON.stringify({\n          name: \"Person\",\n          tableId: \"People\",\n          charId: \"Email\",\n          lookupColId: \"EmailAddress\",\n        }),\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -2,   // TagByRef\n        aclFormula: \"rec.Person != user.Person.id\",\n        permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -3,   // TagByEmail\n        aclFormula: \"rec.Email != user.Email\",\n        permissionsText: \"none\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -4,   // TagByEmailLower\n        aclFormula: \"rec.Email != user.Email.lower()\",\n        permissionsText: \"none\",\n      }],\n    ]);\n\n    const Cens: CellValue = [GristObjCode.Censored];\n    const expectedFixApi = tableToColValues([\n      [\"id\", \"Person\", \"Email\",               \"TagByRef\", \"TagByEmail\", \"TagByEmailLower\"],\n      [1,    1,        \"alice@example.com\",   Cens,       Cens,         Cens],\n      [2,    2,        \"charon@getgrist.com\", \"ok\",       \"ok\",         \"ok\"],\n      [3,    2,        \"Charon@gEtGrIsT.com\", \"ok\",       Cens,         Cens],\n    ]);\n    const expectedFixWs = tableToColValues([\n      [\"id\", \"Person\", \"Email\",               \"TagByRef\", \"TagByEmail\", \"TagByEmailLower\"],\n      [1,    1,        \"alice@example.com\",   Cens,       Cens,         Cens],\n      [2,    2,        \"charon@getgrist.com\", \"ok\",       Cens,         \"ok\"],\n      [3,    2,        \"Charon@gEtGrIsT.com\", \"ok\",       \"ok\",         Cens],\n    ]);\n\n    async function testExpected() {\n      const userApi = new UserAPIImpl(`${serverUrl}/o/nasa`, {\n        fetch: fetch as any,\n        headers: { Cookie: cookie.headers.Cookie },\n        newFormData: () => new FormData() as any,\n      });\n      const actualDataApi = await userApi.getDocAPI(docId).getRows(\"Projects\");\n      delete actualDataApi.manualSort;\n      assert.deepEqual(actualDataApi, expectedFixApi);\n\n      const client = await openClient(server.server, testDisplayEmail, \"nasa\");\n      await client.openDocOnConnect(docId);\n      clients.push(client);\n      const actualDataWs = fromTableDataAction((await client.send(\"fetchTable\", 0, \"Projects\")).data);\n      delete actualDataWs.manualSort;\n      assert.deepEqual(actualDataWs, expectedFixWs);\n    }\n\n    await testExpected();\n\n    // Now change the user-attribute table to use the non-lowercase email version. It shouldn't\n    // affect the matching in the user-attribute table, so we expect unchanged results.\n    await owner.applyUserActions(docId, [\n      [\"UpdateRecord\", \"People\", 2, { EmailAddress: testDisplayEmail }],\n    ]);\n    await testExpected();\n  });\n});\n\n/**\n * Tables an array of rows, each an array of CellValues, with the first row containing column\n * headers, including the \"id\" column. Returns a TableColValues object, mapping each column ID to\n * an array of values for that column.\n */\nfunction tableToColValues(rowData: CellValue[][]): TableColValues {\n  const colIds = rowData[0];\n  const rows = rowData.slice(1);\n  return Object.fromEntries(colIds.map((colId, i) => [colId, rows.map(r => r[i])]));\n}\n"
  },
  {
    "path": "test/server/lib/UserPresence.ts",
    "content": "import { ANONYMOUS_USER_EMAIL, EVERYONE_EMAIL, UserAPI } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { GristClient, openClient } from \"test/server/gristClient\";\n\nimport { assert } from \"chai\";\nimport { zipObject } from \"lodash\";\nimport * as sinon from \"sinon\";\n\nconst TEST_ORG = \"userpresence\";\n\nconst Users = {\n  owner: {\n    name: \"Chimpy\",\n    username: \"chimpy\",\n    email: \"chimpy@getgrist.com\",\n  },\n  editor: {\n    name: \"Charon\",\n    username: \"charon\",\n    email: \"charon@getgrist.com\",\n  },\n  loggedInPublic: {\n    name: \"Kiwi\",\n    username: \"kiwi\",\n    email: \"kiwi@getgrist.com\",\n  },\n  anon: {\n    name: \"Anonymous User\",\n    email: ANONYMOUS_USER_EMAIL,\n  },\n};\n\ndescribe(\"UserPresence\", function() {\n  // Keep a short timeout so we don't waste time waiting for messages that won't arrive.\n  this.timeout(7000);\n  let home: TestServer;\n  let owner: UserAPI;\n  let editor: UserAPI;\n  let docId: string;\n  let wsId: number;\n  const sandbox = sinon.createSandbox();\n  let clients: GristClient[] = [];\n\n  async function openTrackedClient(...args: Parameters<typeof openClient>) {\n    const client = await openClient(...args);\n    clients.push(client);\n    return client;\n  }\n\n  async function closeClient(client: GristClient) {\n    if (client.ws.isOpen()) {\n      await client.send(\"closeDoc\", 0);\n    }\n    await client.close();\n    clients = clients.filter(clientToTest => clientToTest !== client);\n  }\n\n  async function openTrackedClientForUser(email: string) {\n    return openTrackedClient(home.server, email, TEST_ORG, undefined);\n  }\n\n  async function getWebsocket(api: UserAPI) {\n    const who = await api.getSessionActive();\n    return openTrackedClient(home.server, who.user.email, who.org?.domain || \"docs\");\n  }\n\n  before(async function() {\n    home = new TestServer(this);\n    await home.start([\"home\", \"docs\"]);\n    const api = await home.createHomeApi(\"chimpy\", \"docs\", true);\n    await api.newOrg({ name: TEST_ORG, domain: TEST_ORG });\n    owner = await home.createHomeApi(Users.owner.username, TEST_ORG, true);\n    wsId = await owner.newWorkspace({ name: \"userpresence\" }, \"current\");\n    docId = await owner.newDoc({ name: \"doc\" }, wsId);\n\n    await owner.updateWorkspacePermissions(wsId, {\n      users: {\n        [Users.editor.email]: \"editors\",\n      },\n    });\n    await owner.updateDocPermissions(docId, {\n      users: {\n        [EVERYONE_EMAIL]: \"viewers\",\n      },\n    });\n    editor = await home.createHomeApi(Users.editor.username, TEST_ORG, true);\n  });\n\n  after(async function() {\n    const api = await home.createHomeApi(\"chimpy\", \"docs\");\n    await api.deleteOrg(TEST_ORG);\n    await home.stop();\n  });\n\n  afterEach(async function() {\n    // Close all tracked clients to make sure the node process can exit normally.\n    await Promise.all(clients.map(closeClient));\n    clients = [];\n    sandbox.restore();\n  });\n\n  const joiningTestCases = [\n    {\n      name: \"editors can see other users\",\n      makeObserverClient: () => getWebsocket(editor),\n      makeJoinerClient: () => getWebsocket(owner),\n      expectedProfile: {\n        name: Users.owner.name,\n        email: Users.owner.email,\n        picture: null,\n        isAnonymous: false,\n      },\n    },\n    {\n      name: \"anonymous users show as anonymous\",\n      makeObserverClient: () => getWebsocket(editor),\n      makeJoinerClient: () => openTrackedClientForUser(Users.anon.email),\n      expectedProfile: {\n        name: Users.anon.name,\n        isAnonymous: true,\n      },\n    },\n    {\n      name: \"public users show as anonymous\",\n      makeObserverClient: () => getWebsocket(editor),\n      makeJoinerClient: () => openTrackedClientForUser(Users.loggedInPublic.email),\n      expectedProfile: {\n        name: Users.anon.name,\n        isAnonymous: true,\n      },\n    },\n  ];\n\n  const publicEmails = [EVERYONE_EMAIL, ANONYMOUS_USER_EMAIL];\n\n  // everyone@getgrist.com and anon@getgrist.com need testing separately, as they both provide\n  // public access to a doc, and in both cases public users should show as anonymous\n  publicEmails.forEach((currentPublicEmail) => {\n    const _newPermissions = zipObject(\n      publicEmails,\n      publicEmails.map(email => email === currentPublicEmail ? \"viewers\" : null),\n    );\n\n    describe(`shows the correct profile details - public email ${currentPublicEmail}`, async function() {\n      before(async function() {\n        await owner.updateDocPermissions(docId, {\n          users: _newPermissions,\n        });\n      });\n\n      joiningTestCases.forEach(testCase =>\n        it(testCase.name, async function() {\n          const observerClient = await testCase.makeObserverClient();\n          const joiningClient = await testCase.makeJoinerClient();\n          await observerClient.openDocOnConnect(docId);\n          await joiningClient.openDocOnConnect(docId);\n\n          const joinMessage = await waitForDocUserPresenceUpdateMessage(observerClient);\n          const expectedProfile = { ...testCase.expectedProfile, id: joinMessage.data.profile.id };\n          assert.deepStrictEqual(joinMessage.data.profile, expectedProfile);\n\n          await closeClient(joiningClient);\n\n          const sessionEndMessage = await waitForDocUserPresenceUpdateMessage(observerClient);\n          assert.equal(sessionEndMessage.data.id, joinMessage.data.id, \"id of session ended doesn't match start\");\n        }),\n      );\n    });\n  });\n\n  describe(\"multiple user connections should only show once on the client\", async function() {\n    const testCombinations = [\n      {\n        name: \"editor and owner\",\n        makeObserverClient: () => getWebsocket(editor),\n        makeJoinerClient: () => getWebsocket(owner),\n      },\n      {\n        name: \"editor and a logged-in public user\",\n        makeObserverClient: () => getWebsocket(editor),\n        makeJoinerClient: () => openTrackedClientForUser(Users.loggedInPublic.email),\n      },\n      // Testing with logged out users won't work here, as these clients don't have a consistent\n      // session id to track.\n    ];\n\n    testCombinations.forEach((testCase) => {\n      it(`only shows 1 message for multiple clients: ${testCase.name}`, async function() {\n        const observerClient = await testCase.makeObserverClient();\n        const otherClients = await Promise.all([\n          testCase.makeJoinerClient(),\n          testCase.makeJoinerClient(),\n          testCase.makeJoinerClient(),\n          testCase.makeJoinerClient(),\n        ]);\n\n        await observerClient.openDocOnConnect(docId);\n        await Promise.all(otherClients.map(client => client.openDocOnConnect(docId)));\n\n        const firstMessage = await waitForDocUserPresenceUpdateMessage(observerClient);\n        // First message should be the one showing a user is present\n        assert(firstMessage.data.profile != undefined, \"connect message not received first\");\n\n        await Promise.all(otherClients.map(closeClient));\n\n        const secondMessage = await waitForDocUserPresenceUpdateMessage(observerClient);\n        // Second message should be a disconnect - there should only have been 1 present message for all clients.\n        assert(secondMessage.data.profile == null, \"received unexpected user presence update\");\n\n        // Connect one more client to trigger a connection message.\n        const finalClient = await testCase.makeJoinerClient();\n        await finalClient.openDocOnConnect(docId);\n\n        const finalMessage = await waitForDocUserPresenceUpdateMessage(observerClient);\n        // Third message should be the reconnect - i.e. only one disconnection message should have been sent.\n        assert(finalMessage.data.profile != null, \"received unexpected user presence disconnect\");\n      });\n    });\n  });\n\n  describe(\"users without the correct permissions can't see other users\", async function() {\n    before(async () => {\n      await owner.updateDocPermissions(docId, {\n        users: {\n          [Users.loggedInPublic.email]: \"viewers\",\n          [ANONYMOUS_USER_EMAIL]: null,\n          // Make all public users editors to show that no public users can see others.\n          [EVERYONE_EMAIL]: \"editors\",\n        },\n      });\n    });\n\n    after(async () => {\n      await owner.updateDocPermissions(docId, {\n        users: {\n          [Users.loggedInPublic.email]: null,\n          [EVERYONE_EMAIL]: \"viewers\",\n        },\n      });\n    });\n\n    const testCases = [\n      {\n        name: \"viewers can't see other users\",\n        makeObserverClient: () => openTrackedClientForUser(Users.loggedInPublic.email),\n      },\n      {\n        name: \"anonymous can't see other users\",\n        makeObserverClient: () => openTrackedClientForUser(Users.anon.email),\n      },\n    ];\n\n    testCases.forEach((testCase) => {\n      it(testCase.name, async function() {\n        const viewerClient = await testCase.makeObserverClient();\n        const joiningClient = await getWebsocket(owner);\n\n        await viewerClient.openDocOnConnect(docId);\n        await joiningClient.openDocOnConnect(docId);\n\n        await assert.isFulfilled(new Promise((resolve, reject) => {\n          setTimeout(() => {\n            while (viewerClient.count() > 0) {\n              const msg = viewerClient.shift();\n              if (msg && \"type\" in msg && msg.type === \"docUserPresenceUpdate\") {\n                reject(msg); // eslint-disable-line @typescript-eslint/prefer-promise-reject-errors\n              }\n            }\n            resolve(undefined);\n          }, 2000);\n        }));\n      });\n    });\n  });\n});\n\nasync function waitForDocUserPresenceUpdateMessage(client: GristClient): Promise<any> {\n  return waitForMatchingMessage(client, (msg) => {\n    return msg.type === \"docUserPresenceUpdate\";\n  });\n}\n\nasync function waitForMatchingMessage(client: GristClient, checkMessage: (msg: any) => boolean): Promise<any> {\n  for (;;) {\n    const message = await client.read();\n    if (checkMessage(message)) {\n      return message;\n    }\n  }\n}\n"
  },
  {
    "path": "test/server/lib/Webhooks-Proxy.ts",
    "content": "import { UserAPIImpl } from \"app/common/UserAPI\";\nimport { WebhookSubscription } from \"app/server/lib/DocApiTriggers\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport { serveSomething, Serving } from \"test/server/customUtil\";\nimport { prepareDatabase } from \"test/server/lib/helpers/PrepareDatabase\";\nimport { prepareFilesystemDirectoryForTests } from \"test/server/lib/helpers/PrepareFilesystemDirectoryForTests\";\nimport { signal } from \"test/server/lib/helpers/Signal\";\nimport { TestProxyServer } from \"test/server/lib/helpers/TestProxyServer\";\nimport { TestServer } from \"test/server/lib/helpers/TestServer\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { tmpdir } from \"os\";\nimport * as path from \"path\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\nimport * as express from \"express\";\nimport { createClient } from \"redis\";\n\nconst chimpy = configForUser(\"Chimpy\");\n\n// some doc ids\nconst docIds: { [name: string]: string } = {\n  ApiDataRecordsTest: \"sampledocid_7\",\n  Timesheets: \"sampledocid_13\",\n  Bananas: \"sampledocid_6\",\n  Antartic: \"sampledocid_11\",\n};\n\nlet dataDir: string;\nlet suitename: string;\nlet serverUrl: string;\nlet userApi: UserAPIImpl;\n\nasync function cleanRedisDatabase() {\n  const cli = createClient(process.env.TEST_REDIS_URL);\n  await cli.flushdbAsync();\n  await cli.quitAsync();\n}\n\nfunction backupEnvironmentVariables() {\n  let oldEnv: testUtils.EnvironmentSnapshot;\n  before(() => {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n  });\n  after(() => {\n    oldEnv.restore();\n  });\n}\n\nconst webhooksTestPort = Number(process.env.WEBHOOK_TEST_PORT || 34365);\nconst webhooksTestProxyPort = Number(process.env.WEBHOOK_TEST_PROXY_PORT || 22335);\n\ndescribe(\"Webhooks-Proxy\", function() {\n  // A testDir of the form grist_test_{USER}_{SERVER_NAME}\n  // - its a directory that will be base for all test related files and activities\n  const username = process.env.USER || \"nobody\";\n  const tmpDir = path.join(tmpdir(), `grist_test_${username}_docapi_webhooks_proxy`);\n  let home: TestServer;\n  let docs: TestServer;\n\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"debug\");\n  // test might override environment values, therefore we need to backup current ones to restore them later\n  backupEnvironmentVariables();\n\n  function setupMockServers(name: string, tmpDir: string, cb: () => Promise<void>) {\n    let api: UserAPIImpl;\n\n    before(async function() {\n      suitename = name;\n      await cb();\n\n      // create TestDoc as an empty doc into Private workspace\n      userApi = api = home.makeUserApi(ORG_NAME);\n      const wid = await getWorkspaceId(api, \"Private\");\n      docIds.TestDoc = await api.newDoc({ name: \"TestDoc\" }, wid);\n    });\n\n    after(async function() {\n      // remove TestDoc\n      await api.deleteDoc(docIds.TestDoc);\n      delete docIds.TestDoc;\n\n      // stop all servers\n      await home.stop();\n      await docs.stop();\n    });\n  }\n\n  describe(\"Proxy is configured\", function() {\n    runServerConfigurations({ GRIST_HTTPS_PROXY: `http://localhost:${webhooksTestProxyPort}` }, () => testWebhookProxy(true));\n  });\n\n  describe(\"Proxy not configured\", function() {\n    runServerConfigurations({ GRIST_HTTPS_PROXY: undefined }, () => testWebhookProxy(false));\n  });\n\n  function runServerConfigurations(additionaEnvConfiguration: NodeJS.ProcessEnv, subTestCall: Function) {\n    additionaEnvConfiguration = {\n      ALLOWED_WEBHOOK_DOMAINS: `example.com,localhost:${webhooksTestPort}`,\n      GRIST_DATA_DIR: dataDir,\n      ...additionaEnvConfiguration,\n    };\n\n    before(async function() {\n      // Clear redis test database if redis is in use.\n      if (process.env.TEST_REDIS_URL) {\n        await cleanRedisDatabase();\n      }\n      await prepareFilesystemDirectoryForTests(tmpDir);\n      await prepareDatabase(tmpDir);\n    });\n    /**\n     * Doc api tests are run against three different setup:\n     *  - a merged server: a single server serving both as a home and doc worker\n     *  - two separated servers: requests are sent to a home server which then forward them to a doc worker\n     *  - a doc worker: request are sent directly to the doc worker (note that even though it is not\n     *    used for testing we starts anyway a home server, needed for setting up the test cases)\n     *\n     *  Future tests must be added within the testDocApi() function.\n     */\n    describe(\"should work with a merged server\", async () => {\n      setupMockServers(\"merged\", tmpDir, async () => {\n        home = docs = await TestServer.startServer(\"home,docs\", tmpDir, suitename, additionaEnvConfiguration);\n        serverUrl = home.serverUrl;\n      });\n      subTestCall();\n    });\n\n    // the way these tests are written, non-merged server requires redis.\n    if (process.env.TEST_REDIS_URL) {\n      describe(\"should work with a home server and a docworker\", async () => {\n        setupMockServers(\"separated\", tmpDir, async () => {\n          home = await TestServer.startServer(\"home\", tmpDir, suitename, additionaEnvConfiguration);\n          docs = await TestServer.startServer(\"docs\", tmpDir, suitename, additionaEnvConfiguration, home.serverUrl);\n          serverUrl = home.serverUrl;\n        });\n        subTestCall();\n      });\n\n      describe(\"should work directly with a docworker\", async () => {\n        setupMockServers(\"docs\", tmpDir, async () => {\n          home = await TestServer.startServer(\"home\", tmpDir, suitename, additionaEnvConfiguration);\n          docs = await TestServer.startServer(\"docs\", tmpDir, suitename, additionaEnvConfiguration, home.serverUrl);\n          serverUrl = docs.serverUrl;\n        });\n        subTestCall();\n      });\n    }\n  }\n\n  function testWebhookProxy(shouldProxyBeCalled: boolean) {\n    describe(\"calling registered webhooks after data update\", function() {\n      let serving: Serving;  // manages the test webhook server\n      let testProxyServer: TestProxyServer;  // manages the test webhook server\n\n      let redisMonitor: any;\n\n      // Create couple of promises that can be used to monitor\n      // if the endpoint was called.\n      const successCalled = signal();\n      const createdCalled = signal();\n      const notFoundCalled = signal();\n\n      async function autoSubscribe(\n        endpoint: string, docId: string, options?: {\n          tableId?: string,\n          isReadyColumn?: string | null,\n          eventTypes?: string[]\n        }) {\n        // Subscribe helper that returns a method to unsubscribe.\n        const data = await subscribe(endpoint, docId, options);\n        return () => unsubscribe(docId, data, options?.tableId ?? \"Table1\");\n      }\n\n      function unsubscribe(docId: string, data: any, tableId = \"Table1\") {\n        return axios.post(\n          `${serverUrl}/api/docs/${docId}/tables/${tableId}/_unsubscribe`,\n          data, chimpy,\n        );\n      }\n\n      async function subscribe(endpoint: string, docId: string, options?: {\n        tableId?: string,\n        isReadyColumn?: string | null,\n        eventTypes?: string[]\n      }) {\n        // Subscribe helper that returns a method to unsubscribe.\n        const { data, status } = await axios.post(\n          `${serverUrl}/api/docs/${docId}/tables/${options?.tableId ?? \"Table1\"}/_subscribe`,\n          {\n            eventTypes: options?.eventTypes ?? [\"add\", \"update\"],\n            url: `${serving.url}/${endpoint}`,\n            isReadyColumn: options?.isReadyColumn === undefined ? \"B\" : options?.isReadyColumn,\n          }, chimpy,\n        );\n        assert.equal(status, 200);\n        return data as WebhookSubscription;\n      }\n\n      async function clearQueue(docId: string) {\n        const deleteResult = await axios.delete(\n          `${serverUrl}/api/docs/${docId}/webhooks/queue`, chimpy,\n        );\n        assert.equal(deleteResult.status, 200);\n      }\n\n      before(async function() {\n        this.timeout(30000);\n        serving = await serveSomething((app) => {\n          app.use(express.json());\n          app.post(\"/200\", ({ body }, res) => {\n            successCalled.emit(body[0].A);\n            res.sendStatus(200);\n            res.end();\n          });\n          app.post(\"/201\", ({ body }, res) => {\n            createdCalled.emit(body[0].A);\n            res.sendStatus(201);\n            res.end();\n          });\n          app.post(\"/404\", ({ body }, res) => {\n            notFoundCalled.emit(body[0].A);\n            res.sendStatus(404); // Webhooks treats it as an error and will retry. Probably it shouldn't work this way.\n            res.end();\n          });\n        }, webhooksTestPort);\n        testProxyServer = await TestProxyServer.Prepare(webhooksTestProxyPort);\n      });\n\n      after(async function() {\n        await serving.shutdown();\n        await testProxyServer.dispose();\n      });\n\n      before(async function() {\n        this.timeout(30000);\n\n        if (process.env.TEST_REDIS_URL) {\n          redisMonitor = createClient(process.env.TEST_REDIS_URL);\n        }\n      });\n\n      after(async function() {\n        if (process.env.TEST_REDIS_URL) {\n          await redisMonitor.quitAsync();\n        }\n      });\n\n      if (shouldProxyBeCalled) {\n        it(\"Should call proxy\", async function() {\n          // Run standard subscribe-modify data-check response - unsubscribe scenario, we are not mutch\n          // intrested in it, only want to check if proxy was used\n          await runTestCase();\n          assert.isTrue(testProxyServer.wasProxyCalled());\n        });\n      } else {\n        it(\"Should not call proxy\", async function() {\n          // Run standard subscribe-modify data-check response - unsubscribe scenario, we are not mutch\n          // intrested in it, only want to check if proxy was used\n          await runTestCase();\n          assert.isFalse(testProxyServer.wasProxyCalled());\n        });\n      }\n\n      async function runTestCase() {\n        // Create a test document.\n        const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n        const docId = await userApi.newDoc({ name: \"testdoc2\" }, ws1);\n        const doc = userApi.getDocAPI(docId);\n        await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [\n          [\"ModifyColumn\", \"Table1\", \"B\", { type: \"Bool\" }],\n        ], chimpy);\n\n        // Try to clear the queue, even if it is empty.\n        await clearQueue(docId);\n\n        const cleanup: (() => Promise<any>)[] = [];\n\n        // Subscribe a valid webhook endpoint.\n        cleanup.push(await autoSubscribe(\"200\", docId));\n        cleanup.push(await autoSubscribe(\"201\", docId));\n        // Subscribe an invalid webhook endpoint.\n        cleanup.push(await autoSubscribe(\"404\", docId));\n\n        // Prepare signals, we will be waiting for those two to be called.\n        successCalled.reset();\n        createdCalled.reset();\n        notFoundCalled.reset();\n        // Trigger both events.\n        await doc.addRows(\"Table1\", {\n          A: [1],\n          B: [true],\n        });\n\n        // Wait for both of them to be called (this is correct order)\n        await successCalled.waitAndReset();\n        await createdCalled.waitAndReset();\n        await notFoundCalled.waitAndReset();\n\n        // Broken endpoint will be called multiple times here, and any subsequent triggers for working\n        // endpoint won't be called.\n        await notFoundCalled.waitAndReset();\n\n        // But the working endpoint won't be called more then once.\n        successCalled.assertNotCalled();\n        createdCalled.assertNotCalled();\n\n        // Cleanup all\n        await Promise.all(cleanup.map(fn => fn())).finally(() => cleanup.length = 0);\n        await clearQueue(docId);\n      }\n    });\n  }\n});\n\nconst ORG_NAME = \"docs-1\";\n\nasync function getWorkspaceId(api: UserAPIImpl, name: string) {\n  const workspaces = await api.getOrgWorkspaces(\"current\");\n  return workspaces.find(w => w.name === name)!.id;\n}\n"
  },
  {
    "path": "test/server/lib/checksumFile.ts",
    "content": "import { checksumFile, HashPassthroughStream } from \"app/server/lib/checksumFile\";\nimport { MemoryWritableStream } from \"app/server/utils/streams\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport stream from \"node:stream\";\n\nimport { assert } from \"chai\";\nimport times from \"lodash/times\";\n\nconst testValues = {\n  small: {\n    contents: \"This is a test.\\n\",\n    hashes: {\n      sha1: \"0828324174b10cc867b7255a84a8155cf89e1b8b\",\n      md5: \"02bcabffffd16fe0fc250f08cad95e0c\",\n    },\n  },\n  empty: {\n    contents: \"\",\n    hashes: {\n      sha1: \"da39a3ee5e6b4b0d3255bfef95601890afd80709\",\n      md5: \"d41d8cd98f00b204e9800998ecf8427e\",\n    },\n  },\n};\n\ndescribe(\"#checksumFile\", function() {\n  it(\"should compute correct checksum of a small file\", async function() {\n    const path = await testUtils.writeTmpFile(testValues.small.contents);\n    assert.equal(await checksumFile(path), testValues.small.hashes.sha1);\n    assert.equal(await checksumFile(path, \"md5\"), testValues.small.hashes.md5);\n  });\n\n  it(\"should compute correct checksum of the empty file\", async function() {\n    const path = await testUtils.writeTmpFile(testValues.empty.contents);\n    assert.equal(await checksumFile(path), testValues.empty.hashes.sha1);\n    assert.equal(await checksumFile(path, \"md5\"), testValues.empty.hashes.md5);\n  });\n\n  it(\"should compute correct checksum of a large file\", async function() {\n    this.timeout(4000);\n    // Make the test more intense by doing it a few times in parallel;\n    // this helped catch a bug in the implementation that manifested very rarely.\n    await Promise.all(times(10, async () => {\n      const path = await testUtils.generateTmpFile(100000);   // about 3MB\n      assert.equal(await checksumFile(path), \"894159eca97e575f0ddbf712175a1853d7d3e619\");\n      assert.equal(await checksumFile(path, \"md5\"), \"5dd0680c1ee14351a5aa300d5e6460d6\");\n    }));\n  });\n\n  it(\"should reject promise on error reading file\", async function() {\n    await assert.isRejected(checksumFile(\"/non-existent/foo/bar\"), /no such file/);\n  });\n});\n\ndescribe(\"HashPassthroughStream\", function() {\n  async function testStreamHash(contents: string, hash: string, algorithm?: string) {\n    const contentBuffer = Buffer.from(contents);\n    const sourceStream = stream.Readable.from(contentBuffer);\n    const hashStream = new HashPassthroughStream(algorithm);\n    const destination = new MemoryWritableStream();\n    await stream.promises.pipeline(sourceStream, hashStream, destination);\n    assert.equal(hashStream.getDigest(), hash);\n    assert(destination.getBuffer().equals(contentBuffer));\n  }\n\n  const testCases: (keyof typeof testValues)[] = [\"small\", \"empty\"];\n  for (const testCase of testCases) {\n    it(`can hash a stream (${testCase})`, async function() {\n      const values = testValues[testCase];\n      await testStreamHash(values.contents, values.hashes.sha1);\n      await testStreamHash(values.contents, values.hashes.md5, \"md5\");\n    });\n  }\n\n  it(`errors if the stream isn't finished`, async function() {\n    const sourceStream = stream.Readable.from(Buffer.from(\"Doesn't matter\"));\n    const hashStream = new HashPassthroughStream();\n    sourceStream.pipe(hashStream);\n    assert.throws(() => hashStream.getDigest(), \"must be closed\");\n  });\n});\n"
  },
  {
    "path": "test/server/lib/config.ts",
    "content": "import { ConfigAccessors, createConfigValue, Deps, FileConfig } from \"app/server/lib/config\";\n\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\ninterface TestFileContents {\n  myNum?: number\n  myStr?: string\n}\n\nconst testFileContentsExample: TestFileContents = {\n  myNum: 1,\n  myStr: \"myStr\",\n};\n\nconst testFileContentsJSON = JSON.stringify(testFileContentsExample);\n\ndescribe(\"FileConfig\", () => {\n  const useFakeConfigFile = (contents: string) => {\n    const fakeFile = { contents };\n    sinon.replace(Deps, \"pathExists\", sinon.fake.resolves(true));\n    sinon.replace(Deps, \"readFile\", sinon.fake((path, encoding: string) => fakeFile.contents) as any);\n    sinon.replace(Deps, \"writeFile\", sinon.fake((path, newContents) => {\n      fakeFile.contents = newContents;\n      return Promise.resolve();\n    }));\n\n    return fakeFile;\n  };\n\n  afterEach(() => {\n    sinon.restore();\n  });\n\n  it(\"throws an error from create if the validator does not return a value\", () => {\n    useFakeConfigFile(testFileContentsJSON);\n    const validator = () => null;\n    assert.throws(() => FileConfig.create<TestFileContents>(\"anypath.json\", validator));\n  });\n\n  it(\"persists changes when values are assigned\", async () => {\n    const fakeFile = useFakeConfigFile(testFileContentsJSON);\n    // Don't validate - this is guaranteed to be valid above.\n    const validator = (input: any) => input as TestFileContents;\n    const fileConfig = FileConfig.create(\"anypath.json\", validator);\n    await fileConfig.set(\"myNum\", 999);\n\n    assert.equal(fileConfig.get(\"myNum\"), 999);\n    assert.equal(JSON.parse(fakeFile.contents).myNum, 999);\n  });\n\n  // Avoid removing extra properties from the file, in case another edition of grist is doing something.\n  it(\"does not remove extra values from the file\", async () => {\n    const configWithExtraProperties = {\n      ...testFileContentsExample,\n      someProperty: \"isPresent\",\n    };\n\n    const fakeFile = useFakeConfigFile(JSON.stringify(configWithExtraProperties));\n    // It's entirely possible the validator can damage the extra properties, but that's not in scope for this test.\n    const validator = (input: any) => input as TestFileContents;\n    const fileConfig = FileConfig.create(\"anypath.json\", validator);\n    // Triggering a write to the file\n    await fileConfig.set(\"myNum\", 999);\n    await fileConfig.set(\"myStr\", \"Something\");\n\n    const newContents = JSON.parse(fakeFile.contents);\n    assert.equal(newContents.myNum, 999);\n    assert.equal(newContents.myStr, \"Something\");\n    assert.equal(newContents.someProperty, \"isPresent\");\n  });\n});\n\ndescribe(\"createConfigValue\", () => {\n  const makeInMemoryAccessors = <T>(initialValue: T): ConfigAccessors<T> => {\n    let value: T = initialValue;\n    return {\n      get: () => value,\n      set: async (newValue: T) => { value = newValue; },\n    };\n  };\n\n  it(\"works without persistence\", async () => {\n    const configValue = createConfigValue(1);\n    assert.equal(configValue.get(), 1);\n    await configValue.set(2);\n    assert.equal(configValue.get(), 2);\n  });\n\n  it(\"writes to persistence when saved\", async () => {\n    const accessors = makeInMemoryAccessors(1);\n    const configValue = createConfigValue(1, accessors);\n    assert.equal(accessors.get(), 1);\n    await configValue.set(2);\n    assert.equal(accessors.get(), 2);\n  });\n\n  it(\"initialises with the persistent value if available\", () => {\n    const accessors = makeInMemoryAccessors(22);\n    const configValue = createConfigValue(1, accessors);\n    assert.equal(configValue.get(), 22);\n\n    const accessorsWithUndefinedValue = makeInMemoryAccessors<number | undefined>(undefined);\n    const configValueWithDefault = createConfigValue(333, accessorsWithUndefinedValue);\n    assert.equal(configValueWithDefault.get(), 333);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/configCore.ts",
    "content": "import { createConfigValue, Deps, IWritableConfigValue } from \"app/server/lib/config\";\nimport { IGristCoreConfig, loadGristCoreConfig, loadGristCoreConfigFile } from \"app/server/lib/configCore\";\n\nimport { assert } from \"chai\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"loadGristCoreConfig\", () => {\n  afterEach(() => {\n    sinon.restore();\n  });\n\n  it(\"can be used with an in-memory store if no file config is provided\", async () => {\n    const config = loadGristCoreConfig();\n    await config.edition.set(\"enterprise\");\n    assert.equal(config.edition.get(), \"enterprise\");\n  });\n\n  it(\"will function correctly when no config file is present\", async () => {\n    sinon.replace(Deps, \"pathExists\", sinon.fake.returns(false));\n    sinon.replace(Deps, \"readFile\", sinon.fake.returns(\"\" as any));\n    const writeFileFake = sinon.fake.resolves(undefined);\n    sinon.replace(Deps, \"writeFile\", writeFileFake);\n\n    const config = loadGristCoreConfigFile(\"doesntmatter.json\");\n    assert.exists(config.edition.get());\n\n    await config.edition.set(\"enterprise\");\n    // Make sure that the change was written back to the file.\n    assert.isTrue(writeFileFake.calledOnce);\n  });\n\n  it(\"can be extended\", async () => {\n    // Extend the core config\n    type NewConfig = IGristCoreConfig & {\n      newThing: IWritableConfigValue<number>\n    };\n\n    const coreConfig = loadGristCoreConfig();\n\n    const newConfig: NewConfig = {\n      ...coreConfig,\n      newThing: createConfigValue(3),\n    };\n\n    // Ensure that it's backwards compatible.\n    const gristConfig: IGristCoreConfig = newConfig;\n    return gristConfig;\n  });\n});\n"
  },
  {
    "path": "test/server/lib/configCoreFileFormats.ts",
    "content": "import { convertToCoreFileContents, IGristCoreConfigFileLatest } from \"app/server/lib/configCoreFileFormats\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"convertToCoreFileContents\", () => {\n  it(\"fails with a malformed config\", () => {\n    const badConfig = {\n      version: \"This is a random version number that will never exist\",\n    };\n\n    assert.throws(() => convertToCoreFileContents(badConfig));\n  });\n\n  // This is necessary to handle users who don't have a config file yet.\n  it(\"will upgrade an empty object to a valid config\", () => {\n    const validConfig = convertToCoreFileContents({});\n    assert.exists(validConfig?.version);\n  });\n\n  it(\"will validate the latest config file format\", () => {\n    const validRawObject: IGristCoreConfigFileLatest = {\n      version: \"1\",\n      edition: \"enterprise\",\n    };\n\n    const validConfig = convertToCoreFileContents(validRawObject);\n    assert.exists(validConfig?.version);\n    assert.exists(validConfig?.edition);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiAnonPlayground.ts",
    "content": "/**\n * Tests for anonymous playground disabled mode.\n *\n * These tests require GRIST_ANON_PLAYGROUND: \"false\" environment setting,\n * which conflicts with other creation tests that need the playground enabled.\n */\n\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport { prepareDatabase } from \"test/server/lib/helpers/PrepareDatabase\";\nimport { prepareFilesystemDirectoryForTests } from \"test/server/lib/helpers/PrepareFilesystemDirectoryForTests\";\nimport { TestServer } from \"test/server/lib/helpers/TestServer\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { tmpdir } from \"os\";\nimport * as path from \"path\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\n\ndescribe(\"DocApiAnonPlayground\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  const username = process.env.USER || \"nobody\";\n  let tmpDir: string;\n  let home: TestServer;\n  let serverUrl: string;\n  let oldEnv: testUtils.EnvironmentSnapshot;\n\n  before(async function() {\n    // Create temp directory\n    tmpDir = path.join(tmpdir(), `grist_test_${username}_docapi-anon-playground`);\n    await prepareFilesystemDirectoryForTests(tmpDir);\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    await prepareDatabase(tmpDir);\n\n    // Create data directory\n    const dataDir = path.join(tmpDir, \"anon-playground-data\");\n    await fse.mkdirs(dataDir);\n\n    // Start server with GRIST_ANON_PLAYGROUND disabled\n    const additionalEnvConfiguration = {\n      GRIST_DATA_DIR: dataDir,\n      GRIST_ANON_PLAYGROUND: \"false\",\n      GRIST_SANDBOX_FLAVOR: \"unsandboxed\",\n    };\n\n    home = await TestServer.startServer(\"home,docs\", tmpDir, \"anon-playground\", additionalEnvConfiguration);\n    serverUrl = home.serverUrl;\n  });\n\n  after(async function() {\n    await home.stop();\n    oldEnv.restore();\n  });\n\n  it(\"should not allow anonymous users to create new docs\", async () => {\n    const nobody = configForUser(\"Anonymous\");\n    const resp = await axios.post(`${serverUrl}/api/docs`, null, nobody);\n    assert.equal(resp.status, 403);\n  });\n});\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiAttachments.ts",
    "content": "/**\n * Tests for attachment operations:\n * - POST /docs/{did}/attachments (upload)\n * - GET /docs/{did}/attachments (list)\n * - GET /docs/{did}/attachments/{id} (metadata)\n * - GET /docs/{did}/attachments/{id}/download\n * - GET /docs/{did}/attachments/archive\n * - POST /docs/{did}/attachments/updateUsed\n * - POST /docs/{did}/attachments/removeUnused\n * - External attachment stores\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { UserAction } from \"app/common/DocActions\";\nimport { arrayRepeat } from \"app/common/gutil\";\nimport { Record as ApiRecord } from \"app/plugin/DocApiTypes\";\nimport { addAllScenarios, addAttachmentsToDoc, TestContext } from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { readFixtureDoc } from \"test/server/testUtils\";\n\nimport axios, { AxiosResponse } from \"axios\";\nimport { assert } from \"chai\";\nimport decompress from \"decompress\";\nimport FormData from \"form-data\";\nimport * as _ from \"lodash\";\nimport defaultsDeep from \"lodash/defaultsDeep\";\n\ndescribe(\"DocApiAttachments\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addAttachmentsTests, \"docapi-attachments\");\n});\n\nfunction checkError(status: number, pattern: RegExp, resp: AxiosResponse) {\n  assert.equal(resp.status, status);\n  assert.match(resp.data.error, pattern);\n}\n\nfunction addAttachmentsTests(getCtx: () => TestContext) {\n  async function getWorkspaceId(name: string): Promise<number> {\n    const { userApi } = getCtx();\n    const workspaces = await userApi.getOrgWorkspaces(\"current\");\n    return workspaces.find(w => w.name === name)!.id;\n  }\n\n  describe(\"attachments\", function() {\n    it(\"POST /docs/{did}/attachments adds attachments\", async function() {\n      const { homeUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const uploadResp = await addAttachmentsToDoc(homeUrl, testDoc, [\n        { name: \"hello.doc\", contents: \"foobar\" },\n        { name: \"world.jpg\", contents: \"123456\" },\n      ], chimpy);\n      assert.deepEqual(uploadResp.data, [1, 2]);\n\n      // Another upload gets the next number.\n      const upload2Resp = await addAttachmentsToDoc(homeUrl, testDoc, [\n        { name: \"hello.png\", contents: \"abcdef\" },\n      ], chimpy);\n      assert.deepEqual(upload2Resp.data, [3]);\n    });\n\n    it(\"GET /docs/{did}/attachments lists attachment metadata\", async function() {\n      const { homeUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      // Test that the usual /records query parameters like sort and filter also work\n      const url = `${homeUrl}/api/docs/${testDoc}/attachments?sort=-fileName&limit=2`;\n      const resp = await axios.get(url, chimpy);\n      assert.equal(resp.status, 200);\n      const { records } = resp.data;\n      for (const record of records) {\n        assert.match(record.fields.timeUploaded, /^\\d{4}-\\d{2}-\\d{2}T/);\n        delete record.fields.timeUploaded;\n      }\n      assert.deepEqual(records, [\n        { id: 2, fields: { fileName: \"world.jpg\", fileSize: 6 } },\n        { id: 3, fields: { fileName: \"hello.png\", fileSize: 6 } },\n      ],\n      );\n    });\n\n    it(\"GET /docs/{did}/attachments/{id} returns attachment metadata\", async function() {\n      const { homeUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/2`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.include(resp.data, { fileName: \"world.jpg\", fileSize: 6 });\n      assert.match(resp.data.timeUploaded, /^\\d{4}-\\d{2}-\\d{2}T/);\n    });\n\n    it(\"GET /docs/{did}/attachments/{id}/download downloads attachment contents\", async function() {\n      const { homeUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/2/download`,\n        { ...chimpy, responseType: \"arraybuffer\" });\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.headers[\"content-type\"], \"image/jpeg\");\n      assert.deepEqual(resp.headers[\"content-disposition\"], 'attachment; filename=\"world.jpg\"');\n      assert.deepEqual(resp.headers[\"cache-control\"], \"private, max-age=3600\");\n      assert.deepEqual(resp.data, Buffer.from(\"123456\"));\n    });\n\n    async function assertArchiveContents(\n      archive: string | Buffer, expectedFiles: { name: string; contents?: string }[],\n    ) {\n      const getFileName = (filePath: string) => filePath.substring(filePath.indexOf(\"_\") + 1);\n      const files = await decompress(archive);\n      for (const expectedFile of expectedFiles) {\n        const file = files.find(file => getFileName(file.path) === expectedFile.name);\n        assert(file, \"file not found in archive\");\n        if (expectedFile.contents) {\n          assert.equal(file?.data.toString(), expectedFile.contents, \"file contents in archive don't match\");\n        }\n      }\n    }\n\n    it(\"GET /docs/{did}/attachments/archive downloads all attachments as a .zip\", async function() {\n      const { homeUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/archive`,\n        { ...chimpy, responseType: \"arraybuffer\" });\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.headers[\"content-type\"], \"application/zip\");\n      assert.deepEqual(resp.headers[\"content-disposition\"], `attachment; filename=\"TestDoc-Attachments.zip\"`);\n\n      await assertArchiveContents(resp.data, [\n        {\n          name: \"hello.doc\",\n          contents: \"foobar\",\n        },\n        {\n          name: \"world.jpg\",\n        },\n        {\n          name: \"hello.png\",\n        },\n      ]);\n    });\n\n    it(\"GET /docs/{did}/attachments/archive downloads all attachments as a .tar\", async function() {\n      const { homeUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/archive?format=tar`,\n        { ...chimpy, responseType: \"arraybuffer\" });\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.headers[\"content-type\"], \"application/x-tar\");\n      assert.deepEqual(resp.headers[\"content-disposition\"], `attachment; filename=\"TestDoc-Attachments.tar\"`);\n\n      await assertArchiveContents(resp.data, [\n        {\n          name: \"hello.doc\",\n          contents: \"foobar\",\n        },\n        {\n          name: \"world.jpg\",\n        },\n        {\n          name: \"hello.png\",\n        },\n      ]);\n    });\n\n    it(\"GET /docs/{did}/attachments/{id}/download works after doc shutdown\", async function() {\n      const { homeUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      // Check that we can download when ActiveDoc isn't currently open.\n      let resp = await axios.post(`${homeUrl}/api/docs/${testDoc}/force-reload`, null, chimpy);\n      assert.equal(resp.status, 200);\n      resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/2/download`,\n        { ...chimpy, responseType: \"arraybuffer\" });\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.headers[\"content-type\"], \"image/jpeg\");\n      assert.deepEqual(resp.headers[\"content-disposition\"], 'attachment; filename=\"world.jpg\"');\n      assert.deepEqual(resp.headers[\"cache-control\"], \"private, max-age=3600\");\n      assert.deepEqual(resp.data, Buffer.from(\"123456\"));\n    });\n\n    it(\"GET /docs/{did}/attachments/{id}... returns 404 when attachment not found\", async function() {\n      const { homeUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      let resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/22`, chimpy);\n      checkError(404, /Attachment not found: 22/, resp);\n      resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/moo`, chimpy);\n      checkError(400, /parameter cannot be understood as an integer: moo/, resp);\n      resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/22/download`, chimpy);\n      checkError(404, /Attachment not found: 22/, resp);\n      resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/moo/download`, chimpy);\n      checkError(400, /parameter cannot be understood as an integer: moo/, resp);\n    });\n\n    it(\"POST /docs/{did}/attachments produces reasonable errors\", async function() {\n      const { homeUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      // Check that it produces reasonable errors if we try to use it with non-form-data\n      let resp = await axios.post(`${homeUrl}/api/docs/${testDoc}/attachments`, [4, 5, 6], chimpy);\n      assert.equal(resp.status, 415);     // Wrong content-type\n\n      // Check for an error if there is no data included.\n      const formData = new FormData();\n      resp = await axios.post(`${homeUrl}/api/docs/${testDoc}/attachments`, formData,\n        defaultsDeep({ headers: formData.getHeaders() }, chimpy));\n      assert.equal(resp.status, 400);\n      // TODO The error here is \"stream ended unexpectedly\", which isn't really reasonable.\n    });\n\n    it(\"POST/GET /docs/{did}/attachments respect document permissions\", async function() {\n      const { homeUrl, kiwi, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const formData = new FormData();\n      formData.append(\"upload\", \"xyzzz\", \"wrong.png\");\n      let resp = await axios.post(`${homeUrl}/api/docs/${testDoc}/attachments`, formData,\n        defaultsDeep({ headers: formData.getHeaders() }, kiwi));\n      checkError(403, /No view access/, resp);\n\n      resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/3`, kiwi);\n      checkError(403, /No view access/, resp);\n\n      resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/3/download`, kiwi);\n      checkError(403, /No view access/, resp);\n    });\n\n    it(\"POST /docs/{did}/attachments respects untrusted content-type only if valid\", async function() {\n      const { homeUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const formData = new FormData();\n      formData.append(\"upload\", \"xyz\", { filename: \"foo\", contentType: \"application/pdf\" });\n      formData.append(\"upload\", \"abc\", { filename: \"hello.png\", contentType: \"invalid/content-type\" });\n      formData.append(\"upload\", \"def\", { filename: \"world.doc\", contentType: \"text/plain\\nbad-header: 1\\n\\nEvil\" });\n      let resp = await axios.post(`${homeUrl}/api/docs/${testDoc}/attachments`, formData,\n        defaultsDeep({ headers: formData.getHeaders() }, chimpy));\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, [4, 5, 6]);\n\n      resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/4/download`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.headers[\"content-type\"], \"application/pdf\");    // A valid content-type is respected\n      assert.deepEqual(resp.headers[\"content-disposition\"], 'attachment; filename=\"foo.pdf\"');\n      assert.deepEqual(resp.data, \"xyz\");\n\n      resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/5/download`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.headers[\"content-type\"], \"image/png\");    // Did not pay attention to invalid header\n      assert.deepEqual(resp.headers[\"content-disposition\"], 'attachment; filename=\"hello.png\"');\n      assert.deepEqual(resp.data, \"abc\");\n\n      resp = await axios.get(`${homeUrl}/api/docs/${testDoc}/attachments/6/download`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.headers[\"content-type\"], \"application/msword\");    // Another invalid header ignored\n      assert.deepEqual(resp.headers[\"content-disposition\"], 'attachment; filename=\"world.doc\"');\n      assert.deepEqual(resp.headers[\"cache-control\"], \"private, max-age=3600\");\n      assert.deepEqual(resp.headers[\"bad-header\"], undefined);   // Attempt to hack in more headers didn't work\n      assert.deepEqual(resp.data, \"def\");\n    });\n\n    it(\"POST /docs/{did}/attachments/updateUsed updates timeDeleted on metadata\", async function() {\n      const { homeUrl, userApi, chimpy } = getCtx();\n      const wid = await getWorkspaceId(\"Private\");\n      const docId = await userApi.newDoc({ name: \"TestDoc2\" }, wid);\n\n      // Apply the given user actions,\n      // POST to /attachments/updateUsed\n      // Check that Table1 and _grist_Attachments contain the expected rows\n      async function check(\n        actions: UserAction[],\n        userData: { id: number, Attached: any }[],\n        metaData: { id: number, deleted: boolean }[],\n      ) {\n        const docUrl = `${homeUrl}/api/docs/${docId}`;\n\n        let resp = await axios.post(`${docUrl}/apply`, actions, chimpy);\n        assert.equal(resp.status, 200);\n\n        resp = await axios.post(`${docUrl}/attachments/updateUsed`, null, chimpy);\n        assert.equal(resp.status, 200);\n\n        resp = await axios.get(`${docUrl}/tables/Table1/records`, chimpy);\n        const actualUserData = resp.data.records.map(\n          ({ id, fields: { Attached } }: ApiRecord) =>\n            ({ id, Attached }),\n        );\n        assert.deepEqual(actualUserData, userData);\n\n        resp = await axios.get(`${docUrl}/tables/_grist_Attachments/records`, chimpy);\n        const actualMetaData = resp.data.records.map(\n          ({ id, fields: { timeDeleted } }: ApiRecord) =>\n            ({ id, deleted: Boolean(timeDeleted) }),\n        );\n        assert.deepEqual(actualMetaData, metaData);\n      }\n\n      // Set up the document and initial data.\n      await check(\n        [\n          [\"AddColumn\", \"Table1\", \"Attached\", { type: \"Attachments\" }],\n          [\"BulkAddRecord\", \"Table1\", [1, 2], { Attached: [[\"L\", 1], [\"L\", 2, 3]] }],\n          // There's no actual attachments here but that doesn't matter\n          [\"BulkAddRecord\", \"_grist_Attachments\", [1, 2, 3], {}],\n        ],\n        [\n          { id: 1, Attached: [\"L\", 1] },\n          { id: 2, Attached: [\"L\", 2, 3] },\n        ],\n        [\n          { id: 1, deleted: false },\n          { id: 2, deleted: false },\n          { id: 3, deleted: false },\n        ],\n      );\n\n      // Remove the record containing ['L', 2, 3], so the metadata for 2 and 3 now says deleted\n      await check(\n        [[\"RemoveRecord\", \"Table1\", 2]],\n        [\n          { id: 1, Attached: [\"L\", 1] },\n        ],\n        [\n          { id: 1, deleted: false },\n          { id: 2, deleted: true },  // deleted here\n          { id: 3, deleted: true },  // deleted here\n        ],\n      );\n\n      // Add back a reference to attachment 2 to test 'undeletion', plus some junk values\n      await check(\n        [[\"BulkAddRecord\", \"Table1\", [3, 4, 5], { Attached: [null, \"foo\", [\"L\", 2, 2, 4, 4, 5]] }]],\n        [\n          { id: 1, Attached: [\"L\", 1] },\n          { id: 3, Attached: null },\n          { id: 4, Attached: \"foo\" },\n          { id: 5, Attached: [\"L\", 2, 2, 4, 4, 5] },\n        ],\n        [\n          { id: 1, deleted: false },\n          { id: 2, deleted: false },  // undeleted here\n          { id: 3, deleted: true },\n        ],\n      );\n\n      // Remove the whole column to test what happens when there's no Attachment columns\n      await check(\n        [[\"RemoveColumn\", \"Table1\", \"Attached\"]],\n        [\n          { id: 1, Attached: undefined },\n          { id: 3, Attached: undefined },\n          { id: 4, Attached: undefined },\n          { id: 5, Attached: undefined },\n        ],\n        [\n          { id: 1, deleted: true },  // deleted here\n          { id: 2, deleted: true },  // deleted here\n          { id: 3, deleted: true },\n        ],\n      );\n\n      // Test performance with a large number of records and attachments.\n      const numRecords = 10000;\n      const attachmentsPerRecord = 4;\n      const totalUsedAttachments = numRecords * attachmentsPerRecord;\n      const totalAttachments = totalUsedAttachments * 1.1;\n\n      const attachedValues = _.chunk(_.range(1, totalUsedAttachments + 1), attachmentsPerRecord)\n        .map(arr => [\"L\", ...arr]);\n      await check(\n        [\n          // Reset the state: add back the removed column and delete the previously added data\n          [\"AddColumn\", \"Table1\", \"Attached\", { type: \"Attachments\" }],\n          [\"BulkRemoveRecord\", \"Table1\", [1, 3, 4, 5]],\n          [\"BulkRemoveRecord\", \"_grist_Attachments\", [1, 2, 3]],\n          [\"BulkAddRecord\", \"Table1\", arrayRepeat(numRecords, null), { Attached: attachedValues }],\n          [\"BulkAddRecord\", \"_grist_Attachments\", arrayRepeat(totalAttachments, null), {}],\n        ],\n        attachedValues.map((Attached, index) => ({ id: index + 1, Attached })),\n        _.range(totalAttachments).map(index => ({ id: index + 1, deleted: index >= totalUsedAttachments })),\n      );\n    });\n\n    it(\"POST /docs/{did}/attachments/removeUnused removes unused attachments\", async function() {\n      const { homeUrl, userApi, chimpy } = getCtx();\n      const wid = await getWorkspaceId(\"Private\");\n      const docId = await userApi.newDoc({ name: \"TestDoc3\" }, wid);\n      const docUrl = `${homeUrl}/api/docs/${docId}`;\n\n      const formData = new FormData();\n      formData.append(\"upload\", \"foobar\", \"hello.doc\");\n      formData.append(\"upload\", \"123456\", \"world.jpg\");\n      formData.append(\"upload\", \"foobar\", \"hello2.doc\");\n      let resp = await axios.post(`${docUrl}/attachments`, formData,\n        defaultsDeep({ headers: formData.getHeaders() }, chimpy));\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, [1, 2, 3]);\n\n      async function checkAttachmentIds(ids: number[]) {\n        resp = await axios.get(`${docUrl}/attachments`, chimpy);\n        assert.equal(resp.status, 200);\n        assert.deepEqual(resp.data.records.map((r: any) => r.id), ids);\n      }\n\n      resp = await axios.patch(\n        `${docUrl}/tables/_grist_Attachments/records`,\n        {\n          records: [\n            { id: 1, fields: { timeDeleted: Date.now() / 1000 - 8 * 24 * 60 * 60 } },  // 8 days ago, i.e. expired\n            { id: 2, fields: { timeDeleted: Date.now() / 1000 - 6 * 24 * 60 * 60 } },  // 6 days ago, i.e. not expired\n          ],\n        },\n        chimpy,\n      );\n      assert.equal(resp.status, 200);\n      await checkAttachmentIds([1, 2, 3]);\n\n      // Remove the expired attachment (1) by force-reloading, so it removes it during shutdown.\n      resp = await axios.post(`${docUrl}/force-reload`, null, chimpy);\n      assert.equal(resp.status, 200);\n      await checkAttachmentIds([2, 3]);\n      resp = await axios.post(`${docUrl}/attachments/verifyFiles`, null, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Remove the not expired attachments (2 and 3).\n      resp = await axios.post(`${docUrl}/attachments/removeUnused?verifyfiles=1`, null, chimpy);\n      assert.equal(resp.status, 200);\n      await checkAttachmentIds([]);\n    });\n\n    describe(\"external attachment stores\", async () => {\n      let docId = \"\";\n      let docUrl = \"\";\n\n      before(async () => {\n        const { homeUrl, userApi, chimpy } = getCtx();\n        const wid = await getWorkspaceId(\"Private\");\n        docId = await userApi.newDoc({ name: \"TestDocExternalAttachments\" }, wid);\n        docUrl = `${homeUrl}/api/docs/${docId}`;\n\n        const resp = await addAttachmentsToDoc(homeUrl, docId, [\n          { name: \"hello.doc\", contents: \"foobar\" },\n          { name: \"world.jpg\", contents: \"123456\" },\n          // Duplicate of 'hello.doc', so only 2 files should be in external storage.\n          { name: \"hello2.doc\", contents: \"foobar\" },\n        ], chimpy);\n        assert.deepEqual(resp.data, [1, 2, 3]);\n      });\n\n      after(async () => {\n        const { userApi } = getCtx();\n        await userApi?.deleteDoc(docId);\n      });\n\n      it(\"GET /docs/{did}/attachments/transferStatus reports idle transfer status\", async function() {\n        const { chimpy } = getCtx();\n        const resp = await axios.get(`${docUrl}/attachments/transferStatus`, chimpy);\n        assert.deepEqual(resp.data, {\n          status: {\n            pendingTransferCount: 0,\n            isRunning: false,\n            successes: 0,\n            failures: 0,\n          },\n          locationSummary: \"internal\",\n        });\n      });\n\n      it(\"GET /docs/{did}/attachments/store gets the external store\", async function() {\n        const { chimpy } = getCtx();\n        const resp = await axios.get(`${docUrl}/attachments/store`, chimpy);\n        assert.equal(resp.data.type, \"internal\");\n      });\n\n      it(\"POST /docs/{did}/attachments/store sets the external store\", async function() {\n        const { chimpy } = getCtx();\n        const postResp = await axios.post(`${docUrl}/attachments/store`, {\n          type: \"external\",\n        }, chimpy);\n        assert.equal(postResp.status, 200, JSON.stringify(postResp.data));\n\n        const getResp = await axios.get(`${docUrl}/attachments/store`, chimpy);\n        assert.equal(getResp.data.type, \"external\");\n      });\n\n      it(\"POST /docs/{did}/attachments/transferAll transfers all attachments\", async function() {\n        const { chimpy } = getCtx();\n        const transferResp = await axios.post(`${docUrl}/attachments/transferAll`, {}, chimpy);\n\n        assert.deepEqual(transferResp.data, {\n          status: {\n            pendingTransferCount: 2,\n            isRunning: true,\n            successes: 0,\n            failures: 0,\n          },\n          locationSummary: \"internal\",\n        });\n      });\n\n      it(\"GET /docs/{did}/attachments/archive downloads all attachments as a .zip when external\", async function() {\n        const { chimpy } = getCtx();\n        const resp = await axios.get(`${docUrl}/attachments/archive`,\n          { ...chimpy, responseType: \"arraybuffer\" });\n        assert.equal(resp.status, 200);\n        assert.deepEqual(resp.headers[\"content-type\"], \"application/zip\");\n        assert.deepEqual(resp.headers[\"content-disposition\"],\n          `attachment; filename=\"TestDocExternalAttachments-Attachments.zip\"`,\n        );\n\n        await assertArchiveContents(resp.data, [\n          {\n            name: \"hello.doc\",\n            contents: \"foobar\",\n          },\n          {\n            name: \"world.jpg\",\n          },\n        ]);\n      });\n\n      it(\"POST /docs/{did}/attachments/archive adds missing attachments from a .tar\", async function() {\n        const { homeUrl, chimpy } = getCtx();\n        const archiveResp = await axios.get(`${docUrl}/attachments/archive?format=tar`,\n          { ...chimpy, responseType: \"arraybuffer\" });\n        assert.equal(archiveResp.status, 200, \"can download the archive\");\n\n        const docResp = await axios.get(`${docUrl}/download`,\n          { ...chimpy, responseType: \"arraybuffer\" });\n        assert.equal(docResp.status, 200, \"can download the doc\");\n\n        const docWorkspaceId = (await axios.get(docUrl, chimpy)).data.workspace.id;\n\n        const docUploadForm = new FormData();\n        docUploadForm.append(\"upload\", docResp.data, \"ExternalAttachmentsMissing.grist\");\n        docUploadForm.append(\"workspaceId\", docWorkspaceId);\n        const docUploadResp = await axios.post(`${homeUrl}/api/docs`, docUploadForm,\n          defaultsDeep({ headers: docUploadForm.getHeaders() }, chimpy));\n        assert.equal(docUploadResp.status, 200, \"can upload the doc\");\n\n        const newDocId = docUploadResp.data;\n\n        const tarUploadForm = new FormData();\n        tarUploadForm.append(\"upload\", archiveResp.data, {\n          filename: \"AttachmentsAreHere.tar\",\n          contentType: \"application/x-tar\",\n        });\n\n        const tarUploadResp = await axios.post(`${homeUrl}/api/docs/${newDocId}/attachments/archive`, tarUploadForm,\n          defaultsDeep({ headers: tarUploadForm.getHeaders() }, chimpy));\n        assert.equal(tarUploadResp.status, 200, \"can upload the attachment archive\");\n\n        assert.deepEqual(tarUploadResp.data, {\n          added: 2,\n          errored: 0,\n          // One attachment in the .tar is a duplicate (identical content + extension), so it won't be used\n          unused: 1,\n        }, \"2 attachments should be added, 1 unused, no errors\");\n      });\n\n      it(\"POST /docs/{did}/attachments/archive errors if no .tar file is found\", async function() {\n        const { chimpy } = getCtx();\n        const badUploadForm = new FormData();\n        badUploadForm.append(\"upload\", \"Random content\", {\n          filename: \"AttachmentsAreHere.zip\",\n          contentType: \"application/zip\",\n        });\n\n        const tarUploadResp = await axios.post(`${docUrl}/attachments/archive`, badUploadForm,\n          defaultsDeep({ headers: badUploadForm.getHeaders() }, chimpy));\n        assert.equal(tarUploadResp.status, 400, \"should be a bad request\");\n      });\n\n      it(\"POST /docs/{did}/attachments/archive has a useful error if a bad file is used\", async function() {\n        const { chimpy } = getCtx();\n        const badUploadForm = new FormData();\n        badUploadForm.append(\"upload\", \"Random content\", {\n          filename: \"AttachmentsAreHere.tar\",\n          contentType: \"application/x-tar\",\n        });\n\n        const tarUploadResp = await axios.post(`${docUrl}/attachments/archive`, badUploadForm,\n          defaultsDeep({ headers: badUploadForm.getHeaders() }, chimpy));\n        assert.equal(tarUploadResp.status, 500, \"should be a bad request\");\n        assert.deepEqual(tarUploadResp.data, { error: \"File is not a valid .tar\" });\n      });\n\n      it(\"POST /docs/{did}/copy doesn't throw when the document has external attachments\", async function() {\n        const { userApi } = getCtx();\n        const worker1 = await userApi.getWorkerAPI(docId);\n        await worker1.copyDoc(docId, undefined, \"copy\");\n      });\n\n      it(\"POST /docs/{did} with sourceDocId can copy a document with external attachments\", async function() {\n        const { homeUrl, userApi, chimpy } = getCtx();\n        const chimpyWs = await userApi.newWorkspace({ name: \"Chimpy's Workspace\" }, \"current\");\n        const resp = await axios.post(`${homeUrl}/api/docs`, {\n          sourceDocumentId: docId,\n          documentName: \"copy of TestDocExternalAttachments\",\n          asTemplate: false,\n          workspaceId: chimpyWs,\n        }, chimpy);\n        assert.equal(resp.status, 200);\n        assert.isString(resp.data);\n        // There's no expectation that the external attachments are copied - just that the document is.\n      });\n\n      it(\n        `enables documents with external attachments from other installations to work when imported`,\n        async function() {\n          const { homeUrl, userApi, chimpy } = getCtx();\n          const wid = await getWorkspaceId(\"Private\");\n          const formData = new FormData();\n          formData.append(\n            \"upload\",\n            // This doc has a store id that won't exist on the server.\n            // This should be updated to a valid one by the server on import.\n            await readFixtureDoc(\"ExternalAttachmentsInvalidStoreId.grist\"),\n            \"ExternalAttachmentsInvalidStoreId.grist\",\n          );\n          const config = defaultsDeep({ headers: formData.getHeaders() }, chimpy);\n          const importResp = await axios.post(`${homeUrl}/api/workspaces/${wid}/import`, formData, config);\n          assert.equal(importResp.status, 200);\n          const importedDocId = importResp.data.id;\n          const docApi = userApi.getDocAPI(importedDocId);\n\n          assert.equal((await docApi.getAttachmentStore()).type, \"external\");\n\n          await addAttachmentsToDoc(homeUrl, importedDocId, [{ name: \"Test.txt\", contents: \"Irrelevant\" }], chimpy);\n\n          const transferStatus = await docApi.getAttachmentTransferStatus();\n          assert.equal(transferStatus.locationSummary, \"external\", \"all attachments should be external\");\n\n          const url = `${homeUrl}/api/docs/${importedDocId}/attachments`;\n          const resp = await axios.get(url, chimpy);\n          assert.equal(resp.status, 200);\n          assert.equal(resp.data.records[0].fields.fileName, \"Test.txt\");\n        });\n    });\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiBugsAndFixes.ts",
    "content": "/**\n * Tests for various bug fixes and miscellaneous DocApi functionality:\n * - /move endpoint performance scaling\n * - DELETE /docs/{did} with forks\n * - /docs/{did}/timing endpoints\n *\n * Note: These tests use the in-process TestServer (from test/gen-server/apiUtils)\n * rather than scenarios.ts because they need access to FlexServer internals\n * (e.g., getStorageManager() for fork deletion tests).\n */\n\nimport { UserAPI } from \"app/common/UserAPI\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport { createTmpDir } from \"test/server/docTools\";\nimport { openClient } from \"test/server/gristClient\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\n\nconst chimpy = configForUser(\"Chimpy\");\nconst kiwi = configForUser(\"Kiwi\");\n\ndescribe(\"DocApiBugsAndFixes\", function() {\n  this.timeout(40000);\n  let server: TestServer;\n  let homeUrl: string;\n  let owner: UserAPI;\n  let wsId: number;\n  testUtils.setTmpLogLevel(\"error\");\n  let oldEnv: testUtils.EnvironmentSnapshot;\n\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    const tmpDir = await createTmpDir();\n    process.env.GRIST_DATA_DIR = tmpDir;\n    process.env.STRIPE_ENDPOINT_SECRET = \"TEST_WITHOUT_ENDPOINT_SECRET\";\n    // Use the TEST_REDIS_URL as the global redis url, if supplied.\n    if (process.env.TEST_REDIS_URL && !process.env.REDIS_URL) {\n      process.env.REDIS_URL = process.env.TEST_REDIS_URL;\n    }\n\n    server = new TestServer(this);\n    homeUrl = await server.start([\"home\", \"docs\"]);\n    const api = await server.createHomeApi(\"chimpy\", \"docs\", true);\n    await api.newOrg({ name: \"testy\", domain: \"testy\" });\n    owner = await server.createHomeApi(\"chimpy\", \"testy\", true);\n    wsId = await owner.newWorkspace({ name: \"ws\" }, \"current\");\n  });\n\n  after(async function() {\n    const api = await server.createHomeApi(\"chimpy\", \"docs\");\n    await api.deleteOrg(\"testy\");\n    await server.stop();\n    oldEnv.restore();\n  });\n\n  describe(\"bugs and fixes\", function() {\n    it(\"/move endpoint scales well with user count\", async function() {\n      this.timeout(120000); // Increase timeout for creating many users\n\n      const testScenarios = [\n        { userCount: 5, label: \"small document\" },\n        { userCount: 5, label: \"small document\" }, // Use results from second run to avoid cold start issues\n        { userCount: 100, label: \"large document\" },\n        { userCount: 100, label: \"large document\" },\n      ];\n\n      const results = new Map<number, number>();\n\n      for (const scenario of testScenarios) {\n        // Create a test document\n        const docId = await owner.newDoc({ name: `test-move-doc-${scenario.userCount}` }, wsId);\n\n        // Create users and share the document with them\n        const userEmails: string[] = [];\n        for (let i = 1; i <= scenario.userCount; i++) {\n          const email = `testuser${scenario.userCount}_${i}@example.com`;\n          userEmails.push(email);\n        }\n\n        // Share the document with all users\n        await owner.updateDocPermissions(docId, {\n          users: userEmails.reduce((acc, email) => {\n            acc[email] = \"viewers\";\n            return acc;\n          }, {} as { [email: string]: \"viewers\" }),\n        });\n\n        // Create a new workspace called \"TO\"\n        const toWsId = await owner.newWorkspace({ name: `TO_${scenario.userCount}` }, \"current\");\n\n        try {\n          // Measure the time it takes to move the document\n          const startTime = Date.now();\n          await owner.moveDoc(docId, toWsId);\n          const moveTime = Date.now() - startTime;\n          results.set(scenario.userCount, moveTime);\n\n          // Verify the document was moved by checking its workspace\n          const docInfo = await owner.getDoc(docId);\n          assert.equal(docInfo.workspace.id, toWsId);\n        } finally {\n          // Clean up: delete the document and workspace\n          await owner.deleteDoc(docId);\n          await owner.deleteWorkspace(toWsId);\n        }\n      }\n\n      // Analyze performance scaling\n      const smallDocTime = results.get(5)!;\n      const largeDocTime = results.get(100)!;\n\n      // Make sure that moving the larger doc is within a reasonable factor of the smaller one. Before the fix\n      // it wasn't possible to move a document with 100 users at all, so this is a big improvement.\n      assert.isAtMost(largeDocTime, smallDocTime * 7); // As for 20251008 on my machine it is ~5x\n    });\n\n    it(\"/move endpoint scales well with user count (cross-org)\", async function() {\n      this.timeout(120000); // Increase timeout for creating many users\n\n      const testScenarios = [\n        // userCount describes both the number of users in the document and in the destination workspace\n        { userCount: 5, label: \"small document\" },\n        { userCount: 5, label: \"small document\" }, // Use results from second run to avoid cold start issues\n\n        { userCount: 100, label: \"large document\" },\n        { userCount: 100, label: \"large document\" },\n      ];\n\n      const results = new Map<number, number>();\n\n      // Create a destination org\n      const destApi = await server.createHomeApi(\"chimpy\", \"docs\", true);\n      await destApi.newOrg({ name: \"dest-org\", domain: \"dest-org\" });\n      const destOwner = await server.createHomeApi(\"chimpy\", \"dest-org\", true);\n      const destWsId = await destOwner.newWorkspace({ name: \"dest-ws\" }, \"current\");\n\n      try {\n        for (const scenario of testScenarios) {\n          // Create a test document in the original org\n          const docId = await owner.newDoc({ name: `test-move-doc-${scenario.userCount}` }, wsId);\n\n          // Create users and share the document with them\n          const userEmails: string[] = [];\n          for (let i = 1; i <= scenario.userCount; i++) {\n            const email = `testuser${scenario.userCount}_${i}@example.com`;\n            userEmails.push(email);\n          }\n\n          // Share the document with all users\n          await owner.updateDocPermissions(docId, {\n            users: userEmails.reduce((acc, email) => {\n              acc[email] = \"viewers\";\n              return acc;\n            }, {} as { [email: string]: \"viewers\" }),\n          });\n\n          const destUserEmails: string[] = [];\n          for (let i = 1; i <= scenario.userCount; i++) {\n            const email = `testuser${scenario.userCount}_${i}_dst@example.com`;\n            destUserEmails.push(email);\n          }\n          await destOwner.updateWorkspacePermissions(destWsId, {\n            users: destUserEmails.reduce((acc, email) => {\n              acc[email] = \"viewers\";\n              return acc;\n            }, {} as { [email: string]: \"viewers\" }),\n          });\n\n          try {\n            // Measure the time it takes to move the document to different org\n            const startTime = Date.now();\n            await owner.moveDoc(docId, destWsId);\n            const moveTime = Date.now() - startTime;\n            results.set(scenario.userCount, moveTime);\n\n            // Verify the document was moved by checking its workspace\n            const docInfo = await destOwner.getDoc(docId);\n            assert.equal(docInfo.workspace.id, destWsId);\n          } finally {\n            // Clean up: delete the document\n            await destOwner.deleteDoc(docId);\n          }\n        }\n\n        // Analyze performance scaling\n        const smallDocTime = results.get(5)!;\n        const largeDocTime = results.get(100)!;\n\n        // Make sure that moving the larger doc is within a reasonable factor of the smaller one. Before the\n        // optimizations it wasn't possible at all to move a document with 100 users invited to the document\n        // and 100 another users invited to the destination workspace, so these are big improvements.\n        assert.isAtMost(largeDocTime, smallDocTime * 9); // As for 20251008 on my machine it is ~4x\n      } finally {\n        // Clean up: delete the destination org\n        await destApi.deleteOrg(\"dest-org\");\n      }\n    });\n  });\n\n  describe(\"DELETE /docs/{did}\", function() {\n    it(\"permanently deletes a document and all of its forks\", async function() {\n      // Create a new document and fork it twice.\n      const docId = await owner.newDoc({ name: \"doc\" }, wsId);\n      const session = await owner.getSessionActive();\n      const client = await openClient(server.server, session.user.email, session.org?.domain || \"docs\");\n      await client.openDocOnConnect(docId);\n      const forkDocResponse1 = await client.send(\"fork\", 0);\n      const forkDocResponse2 = await client.send(\"fork\", 0);\n\n      // Check that files were created for the trunk and forks.\n      const docPath = server.server.getStorageManager().getPath(docId);\n      const forkPath1 = server.server.getStorageManager().getPath(forkDocResponse1.data.docId);\n      const forkPath2 = server.server.getStorageManager().getPath(forkDocResponse2.data.docId);\n      assert.equal(await fse.pathExists(docPath), true);\n      assert.equal(await fse.pathExists(forkPath1), true);\n      assert.equal(await fse.pathExists(forkPath2), true);\n\n      // Delete the trunk via API.\n      const deleteDocResponse = await axios.delete(`${homeUrl}/api/docs/${docId}`, chimpy);\n      assert.equal(deleteDocResponse.status, 200);\n\n      // Check that files for the trunk and forks were deleted.\n      assert.equal(await fse.pathExists(docPath), false);\n      assert.equal(await fse.pathExists(forkPath1), false);\n      assert.equal(await fse.pathExists(forkPath2), false);\n    });\n  });\n\n  describe(\"/docs/{did}/timing\", function() {\n    let docId: string;\n    before(async function() {\n      docId = await owner.newDoc({ name: \"doc2\" }, wsId);\n    });\n\n    after(async function() {\n      await owner.deleteDoc(docId);\n    });\n\n    // There are two endpoints here /timing/start and /timing/stop.\n    // Here we just test that it is operational, available only for owners\n    // and that it returns sane results. Exact tests are done in python.\n\n    // Smoke test.\n    it(\"POST /docs/{did}/timing smoke tests\", async function() {\n      // We are disabled.\n      let resp = await axios.get(`${homeUrl}/api/docs/${docId}/timing`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, { status: \"disabled\" });\n\n      // Start it.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/start`, {}, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Stop it.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/stop`, {}, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, []);\n    });\n\n    it(\"POST /docs/{did}/timing/start\", async function() {\n      // Start timing as non owner, should fail.\n      let resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/start`, {}, kiwi);\n      assert.equal(resp.status, 403);\n\n      // Query status as non owner, should fail.\n      resp = await axios.get(`${homeUrl}/api/docs/${docId}/timing`, kiwi);\n      assert.equal(resp.status, 403);\n\n      // Check as owner.\n      resp = await axios.get(`${homeUrl}/api/docs/${docId}/timing`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, { status: \"disabled\" });\n\n      // Start timing as owner.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/start`, {}, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Check we are started.\n      resp = await axios.get(`${homeUrl}/api/docs/${docId}/timing`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, { status: \"active\", timing: [] });\n\n      // Starting timing again works as expected, returns 400 as this is already.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/start`, {}, chimpy);\n      assert.equal(resp.status, 400);\n\n      // As non owner\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/stop`, {}, kiwi);\n      assert.equal(resp.status, 403);\n    });\n\n    it(\"POST /docs/{did}/timing/stop\", async function() {\n      // Timings are turned on, so we can stop them.\n      // First as non owner, we should fail.\n      let resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/stop`, {}, kiwi);\n      assert.equal(resp.status, 403);\n\n      // Next as owner.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/stop`, {}, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Now do it once again, we should got 400, as we are not timing.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/stop`, {}, chimpy);\n      assert.equal(resp.status, 400);\n    });\n\n    it(\"GET /docs/{did}/timing\", async function() {\n      // Now we can check the results. Start timing and check that we got [] in response.\n      let resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/start`, {}, chimpy);\n      assert.equal(resp.status, 200);\n\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/stop`, {}, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, []);\n\n      // Now create a table with a formula column and make sure we see it in the results.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/apply`, [\n        [\"AddTable\", \"Timings\", [\n          { id: \"A\", formula: \"$id\" },\n        ]],\n      ], chimpy);\n      assert.equal(resp.status, 200);\n\n      // Now start it again,\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/start`, {}, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Make sure we see that it is active and we have some intermediate results\n      resp = await axios.get(`${homeUrl}/api/docs/${docId}/timing`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, { status: \"active\", timing: [] });\n\n      // And trigger some formula calculations.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/apply`, [\n        [\"BulkAddRecord\", \"Timings\", [null, null], {}],\n      ], chimpy);\n      assert.equal(resp.status, 200, JSON.stringify(resp.data));\n\n      // Make sure we can't stop it as non owner.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/stop`, {}, kiwi);\n      assert.equal(resp.status, 403);\n\n      // Now stop it as owner and make sure the result is sane.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/stop`, {}, chimpy);\n      assert.equal(resp.status, 200, JSON.stringify(resp.data));\n      const data = resp.data as {\n        tableId: string;\n        colId: string;\n        sum: number;\n        count: number;\n        average: number;\n        max: number;\n        markers?: {\n          name: string;\n          sum: number;\n          count: number;\n          average: number;\n          max: number;\n        }[]\n      }[];\n\n      assert.isAbove(data.length, 0);\n      assert.equal(data[0].tableId, \"Timings\");\n      assert.isTrue(typeof data[0].sum === \"number\");\n      assert.isTrue(typeof data[0].count === \"number\");\n      assert.isTrue(typeof data[0].average === \"number\");\n      assert.isTrue(typeof data[0].max === \"number\");\n    });\n\n    it(\"POST /docs/{did}/timing/start remembers state after reload\", async function() {\n      // Make sure we are off.\n      let resp = await axios.get(`${homeUrl}/api/docs/${docId}/timing`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, { status: \"disabled\" });\n\n      // Now start it.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/timing/start`, {}, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Now reload document.\n      resp = await axios.post(`${homeUrl}/api/docs/${docId}/force-reload`, {}, chimpy);\n      assert.equal(resp.status, 200);\n\n      // And check that we are still on.\n      resp = await axios.get(`${homeUrl}/api/docs/${docId}/timing`, chimpy);\n      assert.equal(resp.status, 200, JSON.stringify(resp.data));\n      assert.equal(resp.data.status, \"active\");\n      assert.isNotEmpty(resp.data.timing);\n    });\n  });\n});\n\n// API limits tests - these need separate server configurations for each limit value\nfor (const { limit, expected, desc } of [\n  { limit: \"10\", expected: 10, desc: \"should limit to 10 requests\" },\n  { limit: \"20\", expected: 20, desc: \"should limit to 20 requests\" },\n  { limit: \"0\", expected: 30, desc: \"should not limit the requests\" },\n]) {\n  describe(`DocApiBugsAndFixes - GRIST_MAX_PARALLEL_REQUESTS_PER_DOC=${limit}`, function() {\n    this.timeout(40000);\n    let limitServer: TestServer;\n    let limitHomeUrl: string;\n    let limitDocId: string;\n    testUtils.setTmpLogLevel(\"error\");\n    let oldEnv: testUtils.EnvironmentSnapshot;\n\n    before(async function() {\n      oldEnv = new testUtils.EnvironmentSnapshot();\n      const tmpDir = await createTmpDir();\n      process.env.GRIST_DATA_DIR = tmpDir;\n      process.env.GRIST_MAX_PARALLEL_REQUESTS_PER_DOC = limit;\n      process.env.STRIPE_ENDPOINT_SECRET = \"TEST_WITHOUT_ENDPOINT_SECRET\";\n      if (process.env.TEST_REDIS_URL && !process.env.REDIS_URL) {\n        process.env.REDIS_URL = process.env.TEST_REDIS_URL;\n      }\n\n      // Copy Hello.grist as a test document\n      const docPath = `${tmpDir}/sampledocid_13.grist`;\n      await testUtils.copyFixtureDoc(\"Hello.grist\", docPath);\n\n      limitServer = new TestServer(this);\n      limitHomeUrl = await limitServer.start([\"home\", \"docs\"]);\n      limitDocId = \"sampledocid_13\";\n    });\n\n    after(async function() {\n      await limitServer.stop();\n      oldEnv.restore();\n    });\n\n    it(desc, async function() {\n      // Launch 30 requests in parallel and see how many are honored and how many return 429s.\n      // We force-reload the doc first to increase the odds that results won't start coming back\n      // before all the requests have passed authorization.\n      await axios.post(`${limitHomeUrl}/api/docs/${limitDocId}/force-reload`, null, chimpy);\n      const reqs = [...Array(30).keys()].map(\n        _i => axios.get(`${limitHomeUrl}/api/docs/${limitDocId}/tables/Table1/data`, chimpy));\n      const responses = await Promise.all(reqs);\n      assert.lengthOf(responses.filter(r => r.status === 200), expected);\n      assert.lengthOf(responses.filter(r => r.status === 429), 30 - expected);\n    });\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiColumns.ts",
    "content": "/**\n * Tests for column operations.\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { addAllScenarios, TestContext } from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\n\ndescribe(\"DocApiColumns\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addColumnsTests, \"docapi-columns\");\n});\n\nfunction addColumnsTests(getCtx: () => TestContext) {\n  async function generateDocAndUrl(docName: string = \"Dummy\") {\n    const { serverUrl, userApi } = getCtx();\n    const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n    const docId = await userApi.newDoc({ name: docName }, wid);\n    return {\n      docId,\n      url: `${serverUrl}/api/docs/${docId}/tables/Table1/columns`,\n    };\n  }\n\n  it(\"GET /docs/{did}/tables/{tid}/columns retrieves columns\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const data = {\n      columns: [\n        {\n          id: \"A\",\n          fields: {\n            colRef: 2,\n            parentId: 1,\n            parentPos: 1,\n            type: \"Text\",\n            widgetOptions: \"\",\n            isFormula: false,\n            formula: \"\",\n            label: \"A\",\n            description: \"\",\n            untieColIdFromLabel: false,\n            summarySourceCol: 0,\n            displayCol: 0,\n            visibleCol: 0,\n            rules: null,\n            recalcWhen: 0,\n            recalcDeps: null,\n            reverseCol: 0,\n          },\n        },\n        {\n          id: \"B\",\n          fields: {\n            colRef: 3,\n            parentId: 1,\n            parentPos: 2,\n            type: \"Text\",\n            widgetOptions: \"\",\n            isFormula: false,\n            formula: \"\",\n            label: \"B\",\n            description: \"\",\n            untieColIdFromLabel: false,\n            summarySourceCol: 0,\n            displayCol: 0,\n            visibleCol: 0,\n            rules: null,\n            recalcWhen: 0,\n            recalcDeps: null,\n            reverseCol: 0,\n          },\n        },\n        {\n          id: \"C\",\n          fields: {\n            colRef: 4,\n            parentId: 1,\n            parentPos: 3,\n            type: \"Text\",\n            widgetOptions: \"\",\n            isFormula: false,\n            formula: \"\",\n            label: \"C\",\n            description: \"\",\n            untieColIdFromLabel: false,\n            summarySourceCol: 0,\n            displayCol: 0,\n            visibleCol: 0,\n            rules: null,\n            recalcWhen: 0,\n            recalcDeps: null,\n            reverseCol: 0,\n          },\n        },\n        {\n          id: \"D\",\n          fields: {\n            colRef: 5,\n            parentId: 1,\n            parentPos: 3,\n            type: \"Any\",\n            widgetOptions: \"\",\n            isFormula: true,\n            formula: \"\",\n            label: \"D\",\n            description: \"\",\n            untieColIdFromLabel: false,\n            summarySourceCol: 0,\n            displayCol: 0,\n            visibleCol: 0,\n            rules: null,\n            recalcWhen: 0,\n            recalcDeps: null,\n            reverseCol: 0,\n          },\n        },\n        {\n          id: \"E\",\n          fields: {\n            colRef: 6,\n            parentId: 1,\n            parentPos: 4,\n            type: \"Any\",\n            widgetOptions: \"\",\n            isFormula: true,\n            formula: \"$A.upper()\",\n            label: \"E\",\n            description: \"\",\n            untieColIdFromLabel: false,\n            summarySourceCol: 0,\n            displayCol: 0,\n            visibleCol: 0,\n            rules: null,\n            recalcWhen: 0,\n            recalcDeps: null,\n            reverseCol: 0,\n          },\n        },\n      ],\n    };\n    const respWithTableId = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/columns`, chimpy);\n    assert.equal(respWithTableId.status, 200);\n    assert.deepEqual(respWithTableId.data, data);\n    const respWithTableRef = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/1/columns`, chimpy);\n    assert.equal(respWithTableRef.status, 200);\n    assert.deepEqual(respWithTableRef.data, data);\n  });\n\n  it('GET /docs/{did}/tables/{tid}/columns retrieves hidden columns when \"hidden\" is set', async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const params = { hidden: true };\n    const resp = await axios.get(\n      `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/columns`,\n      { ...chimpy, params },\n    );\n    assert.equal(resp.status, 200);\n    const columnsMap = new Map(resp.data.columns.map(({ id, fields}: { id: string, fields: object }) => [id, fields]));\n    assert.include([...columnsMap.keys()], \"manualSort\");\n    assert.deepInclude(columnsMap.get(\"manualSort\"), {\n      colRef: 1,\n      type: \"ManualSortPos\",\n    });\n  });\n\n  it(\"GET /docs/{did}/tables/{tid}/columns returns 404 for non-existent doc\", async function() {\n    const { serverUrl, chimpy } = getCtx();\n    const resp = await axios.get(`${serverUrl}/api/docs/typotypotypo/tables/Table1/data`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.match(resp.data.error, /document not found/i);\n  });\n\n  it(\"GET /docs/{did}/tables/{tid}/columns returns 404 for non-existent table\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Typo1/data`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.match(resp.data.error, /table not found/i);\n  });\n\n  describe(\"/docs/{did}/tables/{tid}/columns\", function() {\n    async function generateDocAndUrlForColumns(name: string) {\n      const { url, docId } = await generateDocAndUrl(name);\n      return { docId, url };\n    }\n\n    describe(\"PUT /docs/{did}/tables/{tid}/columns\", function() {\n      async function getColumnFieldsMapById(url: string, params: any) {\n        const { chimpy } = getCtx();\n        const result = await axios.get(url, { ...chimpy, params });\n        assert.equal(result.status, 200);\n        return new Map<string, object>(\n          result.data.columns.map(\n            ({ id, fields}: { id: string, fields: object }) => [id, fields],\n          ),\n        );\n      }\n\n      interface RecordWithStringId {\n        id: string;\n        fields: Record<string, any>;\n      }\n\n      async function checkPut(\n        columns: [RecordWithStringId, ...RecordWithStringId[]],\n        params: Record<string, any>,\n        expectedFieldsByColId: Record<string, object>,\n        opts?: { getParams?: any },\n      ) {\n        const { chimpy } = getCtx();\n        const { url } = await generateDocAndUrlForColumns(\"ColumnsPut\");\n        const body = { columns };\n        const resp = await axios.put(url, body, { ...chimpy, params });\n        assert.equal(resp.status, 200);\n        const fieldsByColId = await getColumnFieldsMapById(url, opts?.getParams);\n\n        assert.deepEqual(\n          [...fieldsByColId.keys()],\n          Object.keys(expectedFieldsByColId),\n          \"The updated table should have the expected columns\",\n        );\n\n        for (const [colId, expectedFields] of Object.entries(expectedFieldsByColId)) {\n          assert.deepInclude(fieldsByColId.get(colId), expectedFields);\n        }\n      }\n\n      const COLUMN_TO_ADD = {\n        id: \"Foo\",\n        fields: {\n          type: \"Text\",\n          label: \"FooLabel\",\n        },\n      };\n\n      const COLUMN_TO_UPDATE = {\n        id: \"A\",\n        fields: {\n          type: \"Numeric\",\n          colId: \"NewA\",\n        },\n      };\n\n      it(\"should create new columns\", async function() {\n        await checkPut([COLUMN_TO_ADD], {}, {\n          A: {}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields,\n        });\n      });\n\n      it(\"should update existing columns and create new ones\", async function() {\n        await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {}, {\n          NewA: { type: \"Numeric\", label: \"A\" }, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields,\n        });\n      });\n\n      it(\"should only update existing columns when noadd is set\", async function() {\n        await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], { noadd: \"1\" }, {\n          NewA: { type: \"Numeric\" }, B: {}, C: {},\n        });\n      });\n\n      it(\"should only add columns when noupdate is set\", async function() {\n        await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], { noupdate: \"1\" }, {\n          A: { type: \"Any\" }, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields,\n        });\n      });\n\n      it(\"should remove existing columns if replaceall is set\", async function() {\n        await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], { replaceall: \"1\" }, {\n          NewA: { type: \"Numeric\" }, Foo: COLUMN_TO_ADD.fields,\n        });\n      });\n\n      it(\"should NOT remove hidden columns even when replaceall is set\", async function() {\n        await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], { replaceall: \"1\" }, {\n          manualSort: { type: \"ManualSortPos\" }, NewA: { type: \"Numeric\" }, Foo: COLUMN_TO_ADD.fields,\n        }, { getParams: { hidden: true } });\n      });\n\n      it(\"should forbid update by viewers\", async function() {\n        const { userApi, kiwi } = getCtx();\n        // given\n        const { url, docId } = await generateDocAndUrlForColumns(\"ColumnsPut\");\n        await userApi.updateDocPermissions(docId, { users: { \"kiwi@getgrist.com\": \"viewers\" } });\n\n        // when\n        const resp = await axios.put(url, { columns: [COLUMN_TO_ADD] }, kiwi);\n\n        // then\n        assert.equal(resp.status, 403);\n      });\n\n      it(\"should return 404 when table is not found\", async function() {\n        const { chimpy } = getCtx();\n        // given\n        const { url } = await generateDocAndUrlForColumns(\"ColumnsPut\");\n        const notFoundUrl = url.replace(\"Table1\", \"NonExistingTable\");\n\n        // when\n        const resp = await axios.put(notFoundUrl, { columns: [COLUMN_TO_ADD] }, chimpy);\n\n        // then\n        assert.equal(resp.status, 404);\n        assert.equal(resp.data.error, 'Table not found \"NonExistingTable\"');\n      });\n    });\n\n    describe(\"DELETE /docs/{did}/tables/{tid}/columns/{colId}\", function() {\n      it(\"should delete some column\", async function() {\n        const { chimpy } = getCtx();\n        const { url } = await generateDocAndUrlForColumns(\"ColumnDelete\");\n        const deleteUrl = url + \"/A\";\n        const resp = await axios.delete(deleteUrl, chimpy);\n\n        assert.equal(resp.status, 200, \"Should succeed in requesting column deletion\");\n\n        const listColResp = await axios.get(url, { ...chimpy, params: { hidden: true } });\n        assert.equal(listColResp.status, 200, \"Should succeed in listing columns\");\n\n        const columnIds = listColResp.data.columns.map(({ id}: { id: string }) => id).sort();\n        assert.deepEqual(columnIds, [\"B\", \"C\", \"manualSort\"]);\n      });\n\n      it(\"should return 404 if table not found\", async function() {\n        const { chimpy } = getCtx();\n        const { url } = await generateDocAndUrlForColumns(\"ColumnDelete\");\n        const deleteUrl = url.replace(\"Table1\", \"NonExistingTable\") + \"/A\";\n        const resp = await axios.delete(deleteUrl, chimpy);\n\n        assert.equal(resp.status, 404);\n        assert.equal(resp.data.error, 'Table or column not found \"NonExistingTable.A\"');\n      });\n\n      it(\"should return 404 if column not found\", async function() {\n        const { chimpy } = getCtx();\n        const { url } = await generateDocAndUrlForColumns(\"ColumnDelete\");\n        const deleteUrl = url + \"/NonExistingColId\";\n        const resp = await axios.delete(deleteUrl, chimpy);\n\n        assert.equal(resp.status, 404);\n        assert.equal(resp.data.error, 'Table or column not found \"Table1.NonExistingColId\"');\n      });\n\n      it(\"should forbid column deletion by viewers\", async function() {\n        const { userApi, kiwi } = getCtx();\n        const { url, docId } = await generateDocAndUrlForColumns(\"ColumnDelete\");\n        await userApi.updateDocPermissions(docId, { users: { \"kiwi@getgrist.com\": \"viewers\" } });\n        const deleteUrl = url + \"/A\";\n        const resp = await axios.delete(deleteUrl, kiwi);\n\n        assert.equal(resp.status, 403);\n      });\n    });\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiCreation.ts",
    "content": "/**\n * Tests for document creation:\n * - POST /api/docs (create unsaved docs, create saved docs)\n * - Column type guessing\n * - Anonymous user restrictions\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { addAllScenarios, ORG_NAME, TestContext } from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\nimport FormData from \"form-data\";\nimport defaultsDeep from \"lodash/defaultsDeep\";\n\ndescribe(\"DocApiCreation\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addCreationTests, \"docapi-creation\");\n});\n\nfunction addCreationTests(getCtx: () => TestContext) {\n  // Note: The test \"should not allow anonymous users to create new docs\" is in anonPlayground.ts\n  // because it requires GRIST_ANON_PLAYGROUND: \"false\" environment setting.\n\n  it(\"guesses types of new columns\", async () => {\n    const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n    const testDoc = await getOrCreateTestDoc();\n    const userActions = [\n      [\"AddTable\", \"GuessTypes\", []],\n      // Make 5 blank columns of type Any\n      [\"AddColumn\", \"GuessTypes\", \"Date\", {}],\n      [\"AddColumn\", \"GuessTypes\", \"DateTime\", {}],\n      [\"AddColumn\", \"GuessTypes\", \"Bool\", {}],\n      [\"AddColumn\", \"GuessTypes\", \"Numeric\", {}],\n      [\"AddColumn\", \"GuessTypes\", \"Text\", {}],\n      // Add string values from which the initial type will be guessed\n      [\"AddRecord\", \"GuessTypes\", null, {\n        Date: \"1970-01-02\",\n        DateTime: \"1970-01-02 12:00\",\n        Bool: \"true\",\n        Numeric: \"1.2\",\n        Text: \"hello\",\n      }],\n    ];\n    const resp = await axios.post(`${serverUrl}/api/docs/${testDoc}/apply`, userActions, chimpy);\n    assert.equal(resp.status, 200);\n\n    // Check that the strings were parsed to typed values\n    assert.deepEqual(\n      (await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/GuessTypes/records`, chimpy)).data,\n      {\n        records: [\n          {\n            id: 1,\n            fields: {\n              Date: 24 * 60 * 60,\n              DateTime: 36 * 60 * 60,\n              Bool: true,\n              Numeric: 1.2,\n              Text: \"hello\",\n            },\n          },\n        ],\n      },\n    );\n\n    // Check the column types\n    assert.deepEqual(\n      (await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/GuessTypes/columns`, chimpy))\n        .data.columns.map((col: any) => col.fields.type),\n      [\"Date\", \"DateTime:UTC\", \"Bool\", \"Numeric\", \"Text\"],\n    );\n  });\n\n  for (const content of [\"with content\", \"without content\"]) {\n    for (const mode of [\"logged in\", \"anonymous\"]) {\n      it(`POST /api/docs ${content} can create unsaved docs when ${mode}`, async function() {\n        const { serverUrl, homeUrl, chimpy, charon, nobody } = getCtx();\n        const user = (mode === \"logged in\") ? chimpy : nobody;\n        const formData = new FormData();\n        formData.append(\"upload\", \"A,B\\n1,2\\n3,4\\n\", \"table1.csv\");\n        const config = defaultsDeep({ headers: formData.getHeaders() }, user);\n        let resp = await axios.post(`${serverUrl}/api/docs`,\n          ...(content === \"with content\" ? [formData, config] : [null, user]));\n        assert.equal(resp.status, 200);\n        const urlId = resp.data;\n        if (mode === \"logged in\") {\n          assert.match(urlId, /^new~[^~]*~[0-9]+$/);\n        } else {\n          assert.match(urlId, /^new~[^~]*$/);\n        }\n\n        // Access information about that document should be sane for current user\n        resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, user);\n        assert.equal(resp.status, 200);\n        assert.equal(resp.data.name, \"Untitled\");\n        assert.equal(resp.data.workspace.name, \"Examples & Templates\");\n        assert.equal(resp.data.access, \"owners\");\n        if (mode === \"anonymous\") {\n          resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, chimpy);\n          assert.equal(resp.data.access, \"owners\");\n        } else {\n          resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, charon);\n          assert.equal(resp.status, 403);\n          resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, nobody);\n          assert.equal(resp.status, 403);\n        }\n\n        // content was successfully stored\n        resp = await axios.get(`${serverUrl}/api/docs/${urlId}/tables/Table1/data`, user);\n        if (content === \"with content\") {\n          assert.deepEqual(resp.data, { id: [1, 2], manualSort: [1, 2], A: [1, 3], B: [2, 4] });\n        } else {\n          assert.deepEqual(resp.data, { id: [], manualSort: [], A: [], B: [], C: [] });\n        }\n      });\n    }\n\n    it(`POST /api/docs ${content} can create saved docs in workspaces`, async function() {\n      const { serverUrl, homeUrl, userApi, chimpy, charon, nobody } = getCtx();\n      // Make a workspace.\n      const chimpyWs = await userApi.newWorkspace({ name: \"Chimpy's Workspace\" }, ORG_NAME);\n\n      // Create a document in the new workspace.\n      const user = chimpy;\n      const body = {\n        documentName: \"Chimpy's Document\",\n        workspaceId: chimpyWs,\n      };\n      const formData = new FormData();\n      formData.append(\"upload\", \"A,B\\n1,2\\n3,4\\n\", \"table1.csv\");\n      formData.append(\"documentName\", body.documentName);\n      formData.append(\"workspaceId\", body.workspaceId);\n      const config = defaultsDeep({ headers: formData.getHeaders() }, user);\n      let resp = await axios.post(`${serverUrl}/api/docs`,\n        ...(content === \"with content\" ?\n          [formData, config] :\n          [body, user]),\n      );\n      assert.equal(resp.status, 200);\n      const urlId = resp.data;\n      assert.notMatch(urlId, /^new~[^~]*~[0-9]+$/);\n      assert.match(urlId, /^[^~]+$/);\n\n      // Check document metadata.\n      resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, user);\n      assert.equal(resp.status, 200);\n      assert.equal(resp.data.name, \"Chimpy's Document\");\n      assert.equal(resp.data.workspace.name, \"Chimpy's Workspace\");\n      assert.equal(resp.data.access, \"owners\");\n      resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, charon);\n      assert.equal(resp.status, 200);\n      resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, nobody);\n      assert.equal(resp.status, 403);\n\n      // Check document contents.\n      resp = await axios.get(`${serverUrl}/api/docs/${urlId}/tables/Table1/data`, user);\n      if (content === \"with content\") {\n        assert.deepEqual(resp.data, { id: [1, 2], manualSort: [1, 2], A: [1, 3], B: [2, 4] });\n      } else {\n        assert.deepEqual(resp.data, { id: [], manualSort: [], A: [], B: [], C: [] });\n      }\n\n      // Delete the workspace.\n      await userApi.deleteWorkspace(chimpyWs);\n    });\n\n    it(`POST /api/docs ${content} fails if workspace access is denied`, async function() {\n      const { serverUrl, homeUrl, userApi, chimpy, charon, kiwi } = getCtx();\n      // Make a workspace.\n      const chimpyWs = await userApi.newWorkspace({ name: \"Chimpy's Workspace\" }, ORG_NAME);\n\n      // Try to create a document in the new workspace as Kiwi and Charon, who do not have write access.\n      for (const user of [kiwi, charon]) {\n        const body = {\n          documentName: \"Untitled document\",\n          workspaceId: chimpyWs,\n        };\n        const formData = new FormData();\n        formData.append(\"upload\", \"A,B\\n1,2\\n3,4\\n\", \"table1.csv\");\n        formData.append(\"documentName\", body.documentName);\n        formData.append(\"workspaceId\", body.workspaceId);\n        const config = defaultsDeep({ headers: formData.getHeaders() }, user);\n        const resp = await axios.post(`${serverUrl}/api/docs`,\n          ...(content === \"with content\" ?\n            [formData, config] :\n            [body, user]),\n        );\n        assert.equal(resp.status, 403);\n        assert.equal(resp.data.error, \"access denied\");\n      }\n\n      // Try to create a document in the new workspace as Chimpy, who does have write access.\n      const user = chimpy;\n      const body = {\n        documentName: \"Chimpy's Document\",\n        workspaceId: chimpyWs,\n      };\n      const formData = new FormData();\n      formData.append(\"upload\", \"A,B\\n1,2\\n3,4\\n\", \"table1.csv\");\n      formData.append(\"documentName\", body.documentName);\n      formData.append(\"workspaceId\", body.workspaceId);\n      const config = defaultsDeep({ headers: formData.getHeaders() }, user);\n      let resp = await axios.post(`${serverUrl}/api/docs`,\n        ...(content === \"with content\" ?\n          [formData, config] :\n          [body, user]),\n      );\n      assert.equal(resp.status, 200);\n      const urlId = resp.data;\n      assert.notMatch(urlId, /^new~[^~]*~[0-9]+$/);\n      assert.match(urlId, /^[^~]+$/);\n      resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, user);\n      assert.equal(resp.status, 200);\n      assert.equal(resp.data.name, \"Chimpy's Document\");\n      assert.equal(resp.data.workspace.name, \"Chimpy's Workspace\");\n      assert.equal(resp.data.access, \"owners\");\n\n      // Delete the workspace.\n      await userApi.deleteWorkspace(chimpyWs);\n    });\n\n    it(`POST /api/docs ${content} fails if workspace is soft-deleted`, async function() {\n      const { serverUrl, userApi, chimpy } = getCtx();\n      // Make a workspace and promptly remove it.\n      const chimpyWs = await userApi.newWorkspace({ name: \"Chimpy's Workspace\" }, ORG_NAME);\n      await userApi.softDeleteWorkspace(chimpyWs);\n\n      // Try to create a document in the soft-deleted workspace.\n      const user = chimpy;\n      const body = {\n        documentName: \"Chimpy's Document\",\n        workspaceId: chimpyWs,\n      };\n      const formData = new FormData();\n      formData.append(\"upload\", \"A,B\\n1,2\\n3,4\\n\", \"table1.csv\");\n      formData.append(\"documentName\", body.documentName);\n      formData.append(\"workspaceId\", body.workspaceId);\n      const config = defaultsDeep({ headers: formData.getHeaders() }, user);\n      const resp = await axios.post(`${serverUrl}/api/docs`,\n        ...(content === \"with content\" ?\n          [formData, config] :\n          [body, user]),\n      );\n      assert.equal(resp.status, 400);\n      assert.equal(resp.data.error, \"Cannot add document to a deleted workspace\");\n\n      // Delete the workspace.\n      await userApi.deleteWorkspace(chimpyWs);\n    });\n\n    it(`POST /api/docs ${content} fails if workspace does not exist`, async function() {\n      const { serverUrl, chimpy } = getCtx();\n      // Try to create a document in a non-existent workspace.\n      const user = chimpy;\n      const body = {\n        documentName: \"Chimpy's Document\",\n        workspaceId: 123456789,\n      };\n      const formData = new FormData();\n      formData.append(\"upload\", \"A,B\\n1,2\\n3,4\\n\", \"table1.csv\");\n      formData.append(\"documentName\", body.documentName);\n      formData.append(\"workspaceId\", body.workspaceId);\n      const config = defaultsDeep({ headers: formData.getHeaders() }, user);\n      const resp = await axios.post(`${serverUrl}/api/docs`,\n        ...(content === \"with content\" ?\n          [formData, config] :\n          [body, user]),\n      );\n      assert.equal(resp.status, 404);\n      assert.equal(resp.data.error, \"workspace not found\");\n    });\n  }\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiDocuments.ts",
    "content": "/**\n * Tests for document operations:\n * - POST /docs/{did}/force-reload\n * - POST /docs/{did}/assign\n * - GET/POST /docs/{did}/replace\n * - GET /docs/{did}/snapshots\n * - POST /docs/{did}/states/remove\n * - GET /docs/{did}/compare\n * - GET /docs/{did1}/compare/{did2}\n * - URL ID handling\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { ActionSummary } from \"app/common/ActionSummary\";\nimport { DocState } from \"app/common/DocState\";\nimport { UserAPI, UserAPIImpl } from \"app/common/UserAPI\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport { addAllScenarios, ORG_NAME, TestContext } from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\nimport FormData from \"form-data\";\nimport range from \"lodash/range\";\nimport fetch from \"node-fetch\";\n\ndescribe(\"DocApiDocuments\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addDocumentsTests, \"docapi-documents\");\n});\n\nfunction addDocumentsTests(getCtx: () => TestContext) {\n  function makeUserApi(org: string, username: string, options?: { baseUrl?: string }): UserAPI {\n    const { homeUrl } = getCtx();\n    const config = configForUser(username);\n    const baseUrl = options?.baseUrl || homeUrl;\n    return new UserAPIImpl(`${baseUrl}/o/${org}`, {\n      headers: config.headers as Record<string, string>,\n      fetch: fetch as unknown as typeof globalThis.fetch,\n      newFormData: () => new FormData() as any,\n    });\n  }\n\n  it(\"allows forced reloads\", async function() {\n    const { serverUrl, docIds, chimpy, support, hasHomeApi } = getCtx();\n    let resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/force-reload`, null, chimpy);\n    assert.equal(resp.status, 200);\n    // Check that support cannot force a reload.\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/force-reload`, null, support);\n    assert.equal(resp.status, 403);\n    if (hasHomeApi) {\n      // Check that support can force a reload through housekeeping api.\n      resp = await axios.post(`${serverUrl}/api/housekeeping/docs/${docIds.Timesheets}/force-reload`, null, support);\n      assert.equal(resp.status, 200);\n      // Check that regular user cannot force a reload through housekeeping api.\n      resp = await axios.post(`${serverUrl}/api/housekeeping/docs/${docIds.Timesheets}/force-reload`, null, chimpy);\n      assert.equal(resp.status, 403);\n    }\n  });\n\n  it(\"allows assignments\", async function() {\n    const { serverUrl, docIds, chimpy, support, hasHomeApi } = getCtx();\n    let resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/assign`, null, chimpy);\n    assert.equal(resp.status, 200);\n    // Check that support cannot force an assignment.\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/assign`, null, support);\n    assert.equal(resp.status, 403);\n    if (hasHomeApi) {\n      // Check that support can force an assignment through housekeeping api.\n      resp = await axios.post(`${serverUrl}/api/housekeeping/docs/${docIds.Timesheets}/assign`, null, support);\n      assert.equal(resp.status, 200);\n      // Check that regular user cannot force an assignment through housekeeping api.\n      resp = await axios.post(`${serverUrl}/api/housekeeping/docs/${docIds.Timesheets}/assign`, null, chimpy);\n      assert.equal(resp.status, 403);\n    }\n  });\n\n  it(\"honors urlIds\", async function() {\n    const { serverUrl, userApi, chimpy } = getCtx();\n    // Make a document with a urlId\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    const doc1 = await userApi.newDoc({ name: \"testdoc1\", urlId: \"urlid1\" }, ws1);\n    try {\n      // Make sure an edit made by docId is visible when accessed via docId or urlId\n      await axios.post(`${serverUrl}/api/docs/${doc1}/tables/Table1/data`, {\n        A: [\"Apple\"], B: [99],\n      }, chimpy);\n      let resp = await axios.get(`${serverUrl}/api/docs/${doc1}/tables/Table1/data`, chimpy);\n      assert.equal(resp.data.A[0], \"Apple\");\n      resp = await axios.get(`${serverUrl}/api/docs/urlid1/tables/Table1/data`, chimpy);\n      assert.equal(resp.data.A[0], \"Apple\");\n      // Make sure an edit made by urlId is visible when accessed via docId or urlId\n      await axios.post(`${serverUrl}/api/docs/urlid1/tables/Table1/data`, {\n        A: [\"Orange\"], B: [42],\n      }, chimpy);\n      resp = await axios.get(`${serverUrl}/api/docs/${doc1}/tables/Table1/data`, chimpy);\n      assert.equal(resp.data.A[1], \"Orange\");\n      resp = await axios.get(`${serverUrl}/api/docs/urlid1/tables/Table1/data`, chimpy);\n      assert.equal(resp.data.A[1], \"Orange\");\n    } finally {\n      await userApi.deleteDoc(doc1);\n    }\n  });\n\n  it(\"filters urlIds by org\", async function() {\n    const { serverUrl, userApi, chimpy } = getCtx();\n    // Make two documents with same urlId\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    const doc1 = await userApi.newDoc({ name: \"testdoc1\", urlId: \"urlid\" }, ws1);\n    const nasaApi = makeUserApi(\"nasa\", \"chimpy\");\n    const ws2 = (await nasaApi.getOrgWorkspaces(\"current\"))[0].id;\n    const doc2 = await nasaApi.newDoc({ name: \"testdoc2\", urlId: \"urlid\" }, ws2);\n    try {\n      // Place a value in \"docs\" doc\n      await axios.post(`${serverUrl}/o/docs/api/docs/urlid/tables/Table1/data`, {\n        A: [\"Apple\"], B: [99],\n      }, chimpy);\n      // Place a value in \"nasa\" doc\n      await axios.post(`${serverUrl}/o/nasa/api/docs/urlid/tables/Table1/data`, {\n        A: [\"Orange\"], B: [99],\n      }, chimpy);\n      // Check the values made it to the right places\n      let resp = await axios.get(`${serverUrl}/api/docs/${doc1}/tables/Table1/data`, chimpy);\n      assert.equal(resp.data.A[0], \"Apple\");\n      resp = await axios.get(`${serverUrl}/api/docs/${doc2}/tables/Table1/data`, chimpy);\n      assert.equal(resp.data.A[0], \"Orange\");\n    } finally {\n      await userApi.deleteDoc(doc1);\n      await nasaApi.deleteDoc(doc2);\n    }\n  });\n\n  it(\"allows docId access to any document from merged org\", async function() {\n    const { serverUrl, userApi, chimpy } = getCtx();\n    // Make two documents\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    const doc1 = await userApi.newDoc({ name: \"testdoc1\" }, ws1);\n    const nasaApi = makeUserApi(\"nasa\", \"chimpy\");\n    const ws2 = (await nasaApi.getOrgWorkspaces(\"current\"))[0].id;\n    const doc2 = await nasaApi.newDoc({ name: \"testdoc2\" }, ws2);\n    try {\n      // Should fail to write to a document in \"docs\" from \"nasa\" url\n      let resp = await axios.post(`${serverUrl}/o/nasa/api/docs/${doc1}/tables/Table1/data`, {\n        A: [\"Apple\"], B: [99],\n      }, chimpy);\n      assert.equal(resp.status, 404);\n      // Should successfully write to a document in \"nasa\" from \"docs\" url\n      resp = await axios.post(`${serverUrl}/o/docs/api/docs/${doc2}/tables/Table1/data`, {\n        A: [\"Orange\"], B: [99],\n      }, chimpy);\n      assert.equal(resp.status, 200);\n      // Should fail to write to a document in \"nasa\" from \"pr\" url\n      resp = await axios.post(`${serverUrl}/o/pr/api/docs/${doc2}/tables/Table1/data`, {\n        A: [\"Orange\"], B: [99],\n      }, chimpy);\n      assert.equal(resp.status, 404);\n    } finally {\n      await userApi.deleteDoc(doc1);\n      await nasaApi.deleteDoc(doc2);\n    }\n  });\n\n  it(\"GET /docs/{did}/replace replaces one document with another\", async function() {\n    const { serverUrl, userApi, chimpy, kiwi } = getCtx();\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    const doc1 = await userApi.newDoc({ name: \"testdoc1\" }, ws1);\n    const doc2 = await userApi.newDoc({ name: \"testdoc2\" }, ws1);\n    const doc3 = await userApi.newDoc({ name: \"testdoc3\" }, ws1);\n    const doc4 = await userApi.newDoc({ name: \"testdoc4\" }, ws1);\n    await userApi.updateDocPermissions(doc2, { users: { \"kiwi@getgrist.com\": \"editors\" } });\n    await userApi.updateDocPermissions(doc3, { users: { \"kiwi@getgrist.com\": \"viewers\" } });\n    await userApi.updateDocPermissions(doc4, { users: { \"kiwi@getgrist.com\": \"owners\" } });\n    try {\n      // Put some material in doc3\n      let resp = await axios.post(`${serverUrl}/o/docs/api/docs/${doc3}/tables/Table1/data`, {\n        A: [\"Orange\"],\n      }, chimpy);\n      assert.equal(resp.status, 200);\n\n      // Kiwi cannot replace doc2 with doc3, not an owner\n      resp = await axios.post(`${serverUrl}/o/docs/api/docs/${doc2}/replace`, {\n        sourceDocId: doc3,\n      }, kiwi);\n      assert.equal(resp.status, 403);\n      assert.match(resp.data.error, /Only owners can replace a document/);\n\n      // Kiwi can't replace doc1 with doc3, no access to doc1\n      resp = await axios.post(`${serverUrl}/o/docs/api/docs/${doc1}/replace`, {\n        sourceDocId: doc3,\n      }, kiwi);\n      assert.equal(resp.status, 403);\n      assert.match(resp.data.error, /No view access/);\n\n      // Kiwi can't replace doc2 with doc1, no read access to doc1\n      resp = await axios.post(`${serverUrl}/o/docs/api/docs/${doc2}/replace`, {\n        sourceDocId: doc1,\n      }, kiwi);\n      assert.equal(resp.status, 403);\n      assert.match(resp.data.error, /access denied/);\n\n      // Kiwi cannot replace a doc with material they have only partial read access to.\n      resp = await axios.post(`${serverUrl}/api/docs/${doc3}/apply`, [\n        [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"Table1\", colIds: \"A\" }],\n        [\"AddRecord\", \"_grist_ACLRules\", null, {\n          resource: -1, aclFormula: \"user.Access not in [OWNER]\", permissionsText: \"-R\",\n        }],\n      ], chimpy);\n      assert.equal(resp.status, 200);\n      resp = await axios.post(`${serverUrl}/o/docs/api/docs/${doc4}/replace`, {\n        sourceDocId: doc3,\n      }, kiwi);\n      assert.equal(resp.status, 403);\n      assert.match(resp.data.error, /not authorized/);\n      resp = await axios.post(`${serverUrl}/api/docs/${doc3}/tables/_grist_ACLRules/data/delete`,\n        [2], chimpy);\n      assert.equal(resp.status, 200);\n      resp = await axios.post(`${serverUrl}/o/docs/api/docs/${doc4}/replace`, {\n        sourceDocId: doc3,\n      }, kiwi);\n      assert.equal(resp.status, 200);\n    } finally {\n      await userApi.deleteDoc(doc1);\n      await userApi.deleteDoc(doc2);\n      await userApi.deleteDoc(doc3);\n      await userApi.deleteDoc(doc4);\n    }\n  });\n\n  it(\"GET /docs/{did}/snapshots retrieves a list of snapshots\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/snapshots`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.isAtLeast(resp.data.snapshots.length, 1);\n    assert.hasAllKeys(resp.data.snapshots[0], [\"docId\", \"lastModified\", \"snapshotId\"]);\n  });\n\n  it(\"POST /docs/{did}/states/remove removes old states\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    // Check doc has plenty of states.\n    let resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/states`, chimpy);\n    assert.equal(resp.status, 200);\n    const states: DocState[] = resp.data.states;\n    assert.isAbove(states.length, 5);\n\n    // Remove all but 3.\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/states/remove`, { keep: 3 }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/states`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.lengthOf(resp.data.states, 3);\n    assert.equal(resp.data.states[0].h, states[0].h);\n    assert.equal(resp.data.states[1].h, states[1].h);\n    assert.equal(resp.data.states[2].h, states[2].h);\n\n    // Remove all but 1.\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/states/remove`, { keep: 1 }, chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/states`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.lengthOf(resp.data.states, 1);\n    assert.equal(resp.data.states[0].h, states[0].h);\n  });\n\n  it(\"GET /docs/{did1}/compare/{did2} tracks changes between docs\", async function() {\n    const { serverUrl, docs } = getCtx();\n    // Pass kiwi's headers as it contains both Authorization and Origin headers\n    // if run behind a proxy, so we can ensure that the Origin header check is not made.\n    const userApiServerUrl = docs.proxiedServer ? serverUrl : undefined;\n    const chimpyApi = makeUserApi(ORG_NAME, \"chimpy\", { baseUrl: userApiServerUrl });\n    const ws1 = (await chimpyApi.getOrgWorkspaces(\"current\"))[0].id;\n    const docId1 = await chimpyApi.newDoc({ name: \"testdoc1\" }, ws1);\n    const docId2 = await chimpyApi.newDoc({ name: \"testdoc2\" }, ws1);\n    const doc1 = chimpyApi.getDocAPI(docId1);\n    const doc2 = chimpyApi.getDocAPI(docId2);\n\n    // Stick some content in column A so it has a defined type\n    await doc2.addRows(\"Table1\", { A: [0] });\n\n    let comp = await doc1.compareDoc(docId2);\n    assert.hasAllKeys(comp, [\"left\", \"right\", \"parent\", \"summary\"]);\n    assert.equal(comp.summary, \"unrelated\");\n    assert.equal(comp.parent, null);\n    assert.hasAllKeys(comp.left, [\"n\", \"h\"]);\n    assert.hasAllKeys(comp.right, [\"n\", \"h\"]);\n    assert.equal(comp.left.n, 1);\n    assert.equal(comp.right.n, 2);\n\n    await doc1.replace({ sourceDocId: docId2 });\n\n    comp = await doc1.compareDoc(docId2);\n    assert.equal(comp.summary, \"same\");\n    assert.equal(comp.left.n, 2);\n    assert.deepEqual(comp.left, comp.right);\n    assert.deepEqual(comp.left, comp.parent);\n    assert.equal(comp.details, undefined);\n\n    comp = await doc1.compareDoc(docId2, { detail: true });\n    assert.deepEqual(comp.details, {\n      leftChanges: { tableRenames: [], tableDeltas: {} },\n      rightChanges: { tableRenames: [], tableDeltas: {} },\n    });\n\n    await doc1.addRows(\"Table1\", { A: [1] });\n    comp = await doc1.compareDoc(docId2);\n    assert.equal(comp.summary, \"left\");\n    assert.equal(comp.left.n, 3);\n    assert.equal(comp.right.n, 2);\n    assert.deepEqual(comp.right, comp.parent);\n    assert.equal(comp.details, undefined);\n\n    comp = await doc1.compareDoc(docId2, { detail: true });\n    assert.deepEqual(comp.details!.rightChanges,\n      { tableRenames: [], tableDeltas: {} });\n    const addA1: ActionSummary = {\n      tableRenames: [],\n      tableDeltas: {\n        Table1: {\n          updateRows: [],\n          removeRows: [],\n          addRows: [2],\n          columnDeltas: {\n            A: { [2]: [null, [1]] },\n            manualSort: { [2]: [null, [2]] },\n          },\n          columnRenames: [],\n        },\n      },\n    };\n    assert.deepEqual(comp.details!.leftChanges, addA1);\n\n    await doc2.addRows(\"Table1\", { A: [1] });\n    comp = await doc1.compareDoc(docId2);\n    assert.equal(comp.summary, \"both\");\n    assert.equal(comp.left.n, 3);\n    assert.equal(comp.right.n, 3);\n    assert.equal(comp.parent!.n, 2);\n    assert.equal(comp.details, undefined);\n\n    comp = await doc1.compareDoc(docId2, { detail: true });\n    assert.deepEqual(comp.details!.leftChanges, addA1);\n    assert.deepEqual(comp.details!.rightChanges, addA1);\n\n    await doc1.replace({ sourceDocId: docId2 });\n\n    comp = await doc1.compareDoc(docId2);\n    assert.equal(comp.summary, \"same\");\n    assert.equal(comp.left.n, 3);\n    assert.deepEqual(comp.left, comp.right);\n    assert.deepEqual(comp.left, comp.parent);\n    assert.equal(comp.details, undefined);\n\n    comp = await doc1.compareDoc(docId2, { detail: true });\n    assert.deepEqual(comp.details, {\n      leftChanges: { tableRenames: [], tableDeltas: {} },\n      rightChanges: { tableRenames: [], tableDeltas: {} },\n    });\n\n    await doc2.addRows(\"Table1\", { A: range(2, 100) });\n    comp = await doc1.compareDoc(docId2);\n    assert.equal(comp.summary, \"right\");\n    assert.equal(comp.left.n, 3);\n    assert.equal(comp.right.n, 4);\n    assert.deepEqual(comp.left, comp.parent);\n    assert.equal(comp.details, undefined);\n\n    comp = await doc1.compareDoc(docId2, { detail: true });\n    assert.deepEqual(comp.details!.leftChanges,\n      { tableRenames: [], tableDeltas: {} });\n    const addA2To99Truncated: ActionSummary = {\n      tableRenames: [],\n      tableDeltas: {\n        Table1: {\n          updateRows: [],\n          removeRows: [],\n          addRows: range(3, 101),\n          columnDeltas: {\n            A: [...range(3, 12), 100].reduce(\n              (acc, cur) => ({ ...acc, [cur]: [null, [cur - 1]] }),\n              {},\n            ),\n            manualSort: [...range(3, 12), 100].reduce(\n              (acc, cur) => ({ ...acc, [cur]: [null, [cur]] }),\n              {},\n            ),\n          },\n          columnRenames: [],\n        },\n      },\n    };\n    assert.deepEqual(comp.details!.rightChanges, addA2To99Truncated);\n\n    const addA2To99Full: ActionSummary = {\n      tableRenames: [],\n      tableDeltas: {\n        Table1: {\n          updateRows: [],\n          removeRows: [],\n          addRows: range(3, 101),\n          columnDeltas: {\n            A: range(3, 101).reduce(\n              (acc, cur) => ({ ...acc, [cur]: [null, [cur - 1]] }),\n              {},\n            ),\n            manualSort: range(3, 101).reduce(\n              (acc, cur) => ({ ...acc, [cur]: [null, [cur]] }),\n              {},\n            ),\n          },\n          columnRenames: [],\n        },\n      },\n    };\n    for (const maxRows of [100, null]) {\n      comp = await doc1.compareDoc(docId2, { detail: true, maxRows });\n      assert.deepEqual(comp.details!.rightChanges, addA2To99Full);\n    }\n  });\n\n  it(\"GET /docs/{did}/compare tracks changes within a doc\", async function() {\n    const { userApi } = getCtx();\n    // Create a test document.\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    const docId = await userApi.newDoc({ name: \"testdoc\" }, ws1);\n    const doc = userApi.getDocAPI(docId);\n\n    // Give the document some history.\n    await doc.addRows(\"Table1\", { A: [\"a1\"], B: [\"b1\"] });\n    await doc.addRows(\"Table1\", { A: [\"a2\"], B: [\"b2\"] });\n    await doc.updateRows(\"Table1\", { id: [1], A: [\"A1\"] });\n\n    // Examine the most recent change, from HEAD~ to HEAD.\n    let comp = await doc.compareVersion(\"HEAD~\", \"HEAD\");\n    assert.hasAllKeys(comp, [\"left\", \"right\", \"parent\", \"summary\", \"details\"]);\n    assert.equal(comp.summary, \"right\");\n    assert.deepEqual(comp.parent, comp.left);\n    assert.notDeepEqual(comp.parent, comp.right);\n    assert.hasAllKeys(comp.left, [\"n\", \"h\"]);\n    assert.hasAllKeys(comp.right, [\"n\", \"h\"]);\n    assert.equal(comp.left.n, 3);\n    assert.equal(comp.right.n, 4);\n    assert.deepEqual(comp.details!.leftChanges, { tableRenames: [], tableDeltas: {} });\n    assert.deepEqual(comp.details!.rightChanges, {\n      tableRenames: [],\n      tableDeltas: {\n        Table1: {\n          updateRows: [1],\n          removeRows: [],\n          addRows: [],\n          columnDeltas: {\n            A: { [1]: [[\"a1\"], [\"A1\"]] },\n          },\n          columnRenames: [],\n        },\n      },\n    });\n\n    // Check we get the same result with actual hashes.\n    assert.notMatch(comp.left.h, /HEAD/);\n    assert.notMatch(comp.right.h, /HEAD/);\n    const comp2 = await doc.compareVersion(comp.left.h, comp.right.h);\n    assert.deepEqual(comp, comp2);\n\n    // Check that comparing the HEAD with itself shows no changes.\n    comp = await doc.compareVersion(\"HEAD\", \"HEAD\");\n    assert.equal(comp.summary, \"same\");\n    assert.deepEqual(comp.parent, comp.left);\n    assert.deepEqual(comp.parent, comp.right);\n    assert.deepEqual(comp.details!.leftChanges, { tableRenames: [], tableDeltas: {} });\n    assert.deepEqual(comp.details!.rightChanges, { tableRenames: [], tableDeltas: {} });\n\n    // Examine the combination of the last two changes.\n    comp = await doc.compareVersion(\"HEAD~~\", \"HEAD\");\n    assert.hasAllKeys(comp, [\"left\", \"right\", \"parent\", \"summary\", \"details\"]);\n    assert.equal(comp.summary, \"right\");\n    assert.deepEqual(comp.parent, comp.left);\n    assert.notDeepEqual(comp.parent, comp.right);\n    assert.hasAllKeys(comp.left, [\"n\", \"h\"]);\n    assert.hasAllKeys(comp.right, [\"n\", \"h\"]);\n    assert.equal(comp.left.n, 2);\n    assert.equal(comp.right.n, 4);\n    assert.deepEqual(comp.details!.leftChanges, { tableRenames: [], tableDeltas: {} });\n    assert.deepEqual(comp.details!.rightChanges, {\n      tableRenames: [],\n      tableDeltas: {\n        Table1: {\n          updateRows: [1],\n          removeRows: [],\n          addRows: [2],\n          columnDeltas: {\n            A: {\n              [1]: [[\"a1\"], [\"A1\"]],\n              [2]: [null, [\"a2\"]],\n            },\n            B: { [2]: [null, [\"b2\"]] },\n            manualSort: { [2]: [null, [2]] },\n          },\n          columnRenames: [],\n        },\n      },\n    });\n  });\n\n  it(\"doc worker endpoints ignore any /dw/.../ prefix\", async function() {\n    const { homeUrl, docs, docIds, chimpy } = getCtx();\n    if (docs.proxiedServer) {\n      this.skip();\n    }\n    const docWorkerUrl = docs.serverUrl;\n    let resp = await axios.get(`${docWorkerUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.containsAllKeys(resp.data, [\"A\", \"B\", \"C\"]);\n\n    resp = await axios.get(`${docWorkerUrl}/dw/zing/api/docs/${docIds.Timesheets}/tables/Table1/data`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.containsAllKeys(resp.data, [\"A\", \"B\", \"C\"]);\n\n    if (docWorkerUrl !== homeUrl) {\n      resp = await axios.get(`${homeUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`, chimpy);\n      assert.equal(resp.status, 200);\n      assert.containsAllKeys(resp.data, [\"A\", \"B\", \"C\"]);\n\n      resp = await axios.get(`${homeUrl}/dw/zing/api/docs/${docIds.Timesheets}/tables/Table1/data`, chimpy);\n      assert.equal(resp.status, 404);\n    }\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiDownloads.ts",
    "content": "/**\n * Tests for download endpoints:\n * - GET /docs/{did}/download (document download)\n * - GET /docs/{did}/download/csv\n * - GET /docs/{did}/download/xlsx\n * - GET /docs/{did}/download/table-schema\n * - POST /docs/{did}/copy\n * - POST /workspaces/{wid}/import\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { addAllScenarios, ORG_NAME, TestContext } from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\n\ndescribe(\"DocApiDownloads\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addDownloadsTests, \"docapi-downloads\");\n});\n\nfunction addDownloadsTests(getCtx: () => TestContext) {\n  // Set up test data that the CSV/table-schema download tests depend on\n  before(async function() {\n    const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n    const testDoc = await getOrCreateTestDoc();\n    // Create Foo table with test data in TestDoc\n    const userActions = [\n      [\"AddTable\", \"Foo\", [{ id: \"A\" }, { id: \"B\" }]],\n      [\"BulkAddRecord\", \"Foo\", [1, 2, 3, 4], {\n        A: [\"Santa\", \"Bob\", \"Alice\", \"Felix\"],\n        B: [1, 11, 2, 22],\n      }],\n    ];\n    await axios.post(`${serverUrl}/api/docs/${testDoc}/apply`, userActions, chimpy);\n  });\n\n  async function generateDocAndUrl(docName: string = \"Dummy\") {\n    const { serverUrl, userApi } = getCtx();\n    const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n    const docId = await userApi.newDoc({ name: docName }, wid);\n    const docUrl = `${serverUrl}/api/docs/${docId}`;\n    const tableUrl = `${serverUrl}/api/docs/${docId}/tables/Table1`;\n    return { docUrl, tableUrl, docId };\n  }\n\n  it(\"GET /docs/{did}/download serves document\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/download`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.match(resp.data, /grist_Tables_column/);\n  });\n\n  it(\"GET /docs/{did}/download respects permissions\", async function() {\n    const { serverUrl, docIds, kiwi } = getCtx();\n    // kiwi has no access to TestDoc\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/download`, kiwi);\n    assert.equal(resp.status, 403);\n    assert.notMatch(resp.data, /grist_Tables_column/);\n  });\n\n  // A tiny test that /copy doesn't throw.\n  it(\"POST /copy succeeds on a doc worker\", async function() {\n    const { userApi, docIds } = getCtx();\n    const docId = docIds.TestDoc;\n    const worker1 = await userApi.getWorkerAPI(docId);\n    await worker1.copyDoc(docId, undefined, \"copy\");\n  });\n\n  it(\"POST /docs/{did} with sourceDocId copies a document\", async function() {\n    const { serverUrl, userApi, docIds, chimpy } = getCtx();\n    const chimpyWs = await userApi.newWorkspace({ name: \"Chimpy's Workspace\" }, ORG_NAME);\n    const resp = await axios.post(`${serverUrl}/api/docs`, {\n      sourceDocumentId: docIds.TestDoc,\n      documentName: \"copy of TestDoc\",\n      asTemplate: false,\n      workspaceId: chimpyWs,\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.isString(resp.data);\n  });\n\n  it(\"POST /docs/{did}/copy copies a document\", async function() {\n    const { serverUrl, userApi, docIds, chimpy } = getCtx();\n    const chimpyWs2 = await userApi.newWorkspace({ name: \"Chimpy's Workspace 2\" }, ORG_NAME);\n    const resp = await axios.post(`${serverUrl}/api/docs/${docIds.TestDoc}/copy`, {\n      documentName: \"copy of TestDoc\",\n      workspaceId: chimpyWs2,\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.isString(resp.data);\n  });\n\n  it(\"GET /docs/{did}/download/csv serves CSV-encoded document\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/download/csv?tableId=Table1`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data, \"A,B,C,D,E\\nhello,,,,HELLO\\n,world,,,\\n,,,,\\n,,,,\\n\");\n\n    const resp2 = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/download/csv?tableId=Foo`, chimpy);\n    assert.equal(resp2.status, 200);\n    assert.equal(resp2.data, \"A,B\\nSanta,1\\nBob,11\\nAlice,2\\nFelix,22\\n\");\n  });\n\n  it(\"GET /docs/{did}/download/csv with header=colId shows columns id in the header instead of their name\",\n    async function() {\n      const { chimpy } = getCtx();\n      const { docUrl } = await generateDocAndUrl(\"csvWithColIdAsHeader\");\n      const AColRef = 2;\n      const userActions = [\n        [\"AddRecord\", \"Table1\", null, { A: \"a1\", B: \"b1\" }],\n        [\"UpdateRecord\", \"_grist_Tables_column\", AColRef, { untieColIdFromLabel: true }],\n        [\"UpdateRecord\", \"_grist_Tables_column\", AColRef, {\n          label: \"Column label for A\",\n          colId: \"AColId\",\n        }],\n      ];\n      const resp = await axios.post(`${docUrl}/apply`, userActions, chimpy);\n      assert.equal(resp.status, 200);\n      const csvResp = await axios.get(`${docUrl}/download/csv?tableId=Table1&header=colId`, chimpy);\n      assert.equal(csvResp.status, 200);\n      assert.equal(csvResp.data, \"AColId,B,C\\na1,b1,\\n\");\n    });\n\n  it(\"GET /docs/{did}/download/csv respects permissions\", async function() {\n    const { serverUrl, docIds, kiwi } = getCtx();\n    // kiwi has no access to TestDoc\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/download/csv?tableId=Table1`, kiwi);\n    assert.equal(resp.status, 403);\n    assert.notEqual(resp.data, \"A,B,C,D,E\\nhello,,,,HELLO\\n,world,,,\\n,,,,\\n,,,,\\n\");\n  });\n\n  it(\"GET /docs/{did}/download/csv returns 404 if tableId is invalid\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/download/csv?tableId=MissingTableId`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: \"Table MissingTableId not found.\" });\n  });\n\n  it(\"GET /docs/{did}/download/csv returns 404 if viewSectionId is invalid\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(\n      `${serverUrl}/api/docs/${docIds.TestDoc}/download/csv?tableId=Table1&viewSection=9999`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: \"No record 9999 in table _grist_Views_section\" });\n  });\n\n  it(\"GET /docs/{did}/download/csv returns 400 if tableId is missing\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(\n      `${serverUrl}/api/docs/${docIds.TestDoc}/download/csv`, chimpy);\n    assert.equal(resp.status, 400);\n    assert.deepEqual(resp.data, { error: \"tableId parameter is required\" });\n  });\n\n  it(\"GET /docs/{did}/download/table-schema serves table-schema-encoded document\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/download/table-schema?tableId=Foo`, chimpy);\n    assert.equal(resp.status, 200);\n    const expected = {\n      format: \"csv\",\n      mediatype: \"text/csv\",\n      encoding: \"utf-8\",\n      dialect: {\n        delimiter: \",\",\n        doubleQuote: true,\n      },\n      name: \"foo\",\n      title: \"Foo\",\n      schema: {\n        fields: [{\n          name: \"A\",\n          type: \"string\",\n          format: \"default\",\n        }, {\n          name: \"B\",\n          type: \"string\",\n          format: \"default\",\n        }],\n      },\n    };\n    assert.deepInclude(resp.data, expected);\n\n    const resp2 = await axios.get(resp.data.path, chimpy);\n    assert.equal(resp2.status, 200);\n    assert.equal(resp2.data, \"A,B\\nSanta,1\\nBob,11\\nAlice,2\\nFelix,22\\n\");\n  });\n\n  it(\"GET /docs/{did}/download/table-schema serves table-schema-encoded document with header=colId\", async function() {\n    const { chimpy } = getCtx();\n    const { docUrl, tableUrl } = await generateDocAndUrl(\"tableSchemaWithColIdAsHeader\");\n    const columns = [\n      {\n        id: \"Some_ID\",\n        fields: {\n          label: \"Some Label\",\n          type: \"Text\",\n        },\n      },\n    ];\n    const setupColResp = await axios.put(`${tableUrl}/columns`, { columns }, { ...chimpy, params: { replaceall: true } });\n    assert.equal(setupColResp.status, 200);\n\n    const resp = await axios.get(`${docUrl}/download/table-schema?tableId=Table1&header=colId`, chimpy);\n    assert.equal(resp.status, 200);\n    const expected = {\n      format: \"csv\",\n      mediatype: \"text/csv\",\n      encoding: \"utf-8\",\n      dialect: {\n        delimiter: \",\",\n        doubleQuote: true,\n      },\n      name: \"table1\",\n      title: \"Table1\",\n      schema: {\n        fields: [{\n          name: \"Some_ID\",\n          type: \"string\",\n          format: \"default\",\n        }],\n      },\n    };\n    assert.deepInclude(resp.data, expected);\n  });\n\n  it(\"GET /docs/{did}/download/table-schema respects permissions\", async function() {\n    const { serverUrl, docIds, kiwi } = getCtx();\n    // kiwi has no access to TestDoc\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/download/table-schema?tableId=Table1`, kiwi);\n    assert.equal(resp.status, 403);\n    assert.deepEqual(resp.data, { error: \"No view access\" });\n  });\n\n  it(\"GET /docs/{did}/download/table-schema returns 404 if tableId is invalid\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(\n      `${serverUrl}/api/docs/${docIds.TestDoc}/download/table-schema?tableId=MissingTableId`,\n      chimpy,\n    );\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: \"Table MissingTableId not found.\" });\n  });\n\n  it(\"GET /docs/{did}/download/table-schema returns 400 if tableId is missing\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(\n      `${serverUrl}/api/docs/${docIds.TestDoc}/download/table-schema`, chimpy);\n    assert.equal(resp.status, 400);\n    assert.deepEqual(resp.data, { error: \"tableId parameter is required\" });\n  });\n\n  it(\"GET /docs/{did}/download/xlsx serves XLSX-encoded document\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/download/xlsx?tableId=Table1`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.notEqual(resp.data, null);\n  });\n\n  it(\"GET /docs/{did}/download/xlsx respects permissions\", async function() {\n    const { serverUrl, docIds, kiwi } = getCtx();\n    // kiwi has no access to TestDoc\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/download/xlsx?tableId=Table1`, kiwi);\n    assert.equal(resp.status, 403);\n    assert.deepEqual(resp.data, { error: \"No view access\" });\n  });\n\n  it(\"GET /docs/{did}/download/xlsx returns 404 if tableId is invalid\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(\n      `${serverUrl}/api/docs/${docIds.TestDoc}/download/xlsx?tableId=MissingTableId`,\n      chimpy,\n    );\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: \"Table MissingTableId not found.\" });\n  });\n\n  it(\"GET /docs/{did}/download/xlsx returns 404 if viewSectionId is invalid\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(\n      `${serverUrl}/api/docs/${docIds.TestDoc}/download/xlsx?tableId=Table1&viewSection=9999`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: \"No record 9999 in table _grist_Views_section\" });\n  });\n\n  it(\"GET /docs/{did}/download/xlsx returns 200 if tableId is missing\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(\n      `${serverUrl}/api/docs/${docIds.TestDoc}/download/xlsx`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.notEqual(resp.data, null);\n  });\n\n  it(\"GET /docs/{did}/download/xlsx returns 200 if tableId is missing and header present\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(\n      `${serverUrl}/api/docs/${docIds.TestDoc}/download/xlsx?header=label`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.notEqual(resp.data, null);\n  });\n\n  it(\"POST /workspaces/{wid}/import handles empty filenames\", async function() {\n    const { userApi, chimpy } = getCtx();\n    if (!process.env.TEST_REDIS_URL) {\n      this.skip();\n    }\n    const worker = await userApi.getWorkerAPI(\"import\");\n    const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n    const fakeData1 = await testUtils.readFixtureDoc(\"Hello.grist\");\n    const uploadId1 = await worker.upload(fakeData1, \".grist\");\n    const resp = await axios.post(`${worker.url}/api/workspaces/${wid}/import`, { uploadId: uploadId1 }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.equal(resp.data.title, \"Untitled upload\");\n    assert.equal(typeof resp.data.id, \"string\");\n    assert.notEqual(resp.data.id, \"\");\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiMisc.ts",
    "content": "/**\n * Miscellaneous tests:\n * - Reference column conversion when target table is deleted\n * - String parsing in user actions\n * - Shares handling (/s/ variants)\n * - Document protection during upload-and-import\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { SHARE_KEY_PREFIX } from \"app/common/gristUrls\";\nimport { addAllScenarios, TestContext } from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { getDatabase } from \"test/testUtils\";\n\nimport axios, { AxiosResponse } from \"axios\";\nimport { assert } from \"chai\";\nimport FormData from \"form-data\";\nimport defaultsDeep from \"lodash/defaultsDeep\";\n\ndescribe(\"DocApiMisc\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addMiscTests, \"docapi-misc\");\n});\n\nfunction addMiscTests(getCtx: () => TestContext) {\n  // This is mostly tested in Python, but this case requires the data engine to call\n  // 'external' (i.e. JS) code to do the type conversion.\n  it(\"converts reference columns when the target table is deleted\", async () => {\n    const { serverUrl, userApi, chimpy } = getCtx();\n    // Create a test document.\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    const docId = await userApi.newDoc({ name: \"testdoc\" }, ws1);\n    const docUrl = `${serverUrl}/api/docs/${docId}`;\n\n    // Make a new table with a reference column pointing at Table1, displaying column A.\n    let resp = await axios.post(`${docUrl}/apply`, [\n      [\"AddTable\", \"Table2\", [{ id: \"R\", type: \"RefList:Table1\" }]],\n      [\"ModifyColumn\", \"Table2\", \"R\", { visibleCol: 2 }],\n      [\"SetDisplayFormula\", \"Table2\", 0, 6, \"$R.A\"],\n      [\"BulkAddRecord\", \"Table1\", [1, 2], { A: [\"Alice\", \"Bob\"] }],\n      [\"BulkAddRecord\", \"Table2\", [1], { R: [[\"L\", 1, 2]] }],\n    ], chimpy);\n    assert.equal(resp.status, 200);\n\n    // Now delete the referenced table.\n    // This action has to be separate for the test to pass.\n    resp = await axios.post(`${docUrl}/apply`, [\n      [\"RemoveTable\", \"Table1\"],\n    ], chimpy);\n    assert.equal(resp.status, 200);\n\n    resp = await axios.get(`${docUrl}/tables/Table2/columns`, chimpy);\n    assert.deepEqual(resp.data, {\n      columns: [\n        {\n          id: \"R\",\n          fields: {\n            colRef: 6,\n            parentId: 2,\n            parentPos: 6,\n            // Type changed from RefList to Text\n            type: \"Text\",\n            widgetOptions: \"\",\n            isFormula: false,\n            formula: \"\",\n            label: \"R\",\n            description: \"\",\n            untieColIdFromLabel: false,\n            summarySourceCol: 0,\n            // Display and visible columns cleared\n            displayCol: 0,\n            visibleCol: 0,\n            rules: null,\n            recalcWhen: 0,\n            recalcDeps: null,\n            reverseCol: 0,\n          },\n        },\n      ],\n    },\n    );\n\n    resp = await axios.get(`${docUrl}/tables/Table2/records`, chimpy);\n    assert.deepEqual(resp.data, {\n      records:\n          [\n            // Reflist converted to comma separated display values.\n            { id: 1, fields: { R: \"Alice, Bob\" } },\n          ],\n    },\n    );\n  });\n\n  it(\"parses strings in user actions\", async () => {\n    const { serverUrl, userApi, chimpy } = getCtx();\n    // Create a test document.\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    const docId = await userApi.newDoc({ name: \"testdoc\" }, ws1);\n    const docUrl = `${serverUrl}/api/docs/${docId}`;\n    const recordsUrl = `${docUrl}/tables/Table1/records`;\n\n    // Make the column numeric, delete the other columns we don't care about\n    await axios.post(`${docUrl}/apply`, [\n      [\"ModifyColumn\", \"Table1\", \"A\", { type: \"Numeric\" }],\n      [\"RemoveColumn\", \"Table1\", \"B\"],\n      [\"RemoveColumn\", \"Table1\", \"C\"],\n    ], chimpy);\n\n    // Add/update some records without and with string parsing\n    // Specifically test:\n    // 1. /apply, with an AddRecord\n    // 2. POST  /records (BulkAddRecord)\n    // 3. PATCH /records (BulkUpdateRecord)\n    // Send strings that look like currency which need string parsing to become numbers\n    for (const queryParams of [\"?noparse=1\", \"\"]) {\n      await axios.post(`${docUrl}/apply${queryParams}`, [\n        [\"AddRecord\", \"Table1\", null, { A: \"$1\" }],\n      ], chimpy);\n\n      const response = await axios.post(`${recordsUrl}${queryParams}`,\n        {\n          records: [\n            { fields: { A: \"$2\" } },\n            { fields: { A: \"$3\" } },\n          ],\n        },\n        chimpy);\n\n      // Update $3 -> $4\n      const rowId = response.data.records[1].id;\n      await axios.patch(`${recordsUrl}${queryParams}`,\n        {\n          records: [\n            { id: rowId, fields: { A: \"$4\" } },\n          ],\n        },\n        chimpy);\n    }\n\n    // Check the results\n    const resp = await axios.get(recordsUrl, chimpy);\n    assert.deepEqual(resp.data, {\n      records:\n          [\n            // Without string parsing\n            { id: 1, fields: { A: \"$1\" } },\n            { id: 2, fields: { A: \"$2\" } },\n            { id: 3, fields: { A: \"$4\" } },\n\n            // With string parsing\n            { id: 4, fields: { A: 1 } },\n            { id: 5, fields: { A: 2 } },\n            { id: 6, fields: { A: 4 } },\n          ],\n    },\n    );\n  });\n\n  it(`POST /workspaces/{wid}/import can import a new file`, async function() {\n    const { homeUrl, userApi, chimpy } = getCtx();\n    const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n    const formData = new FormData();\n    formData.append(\"upload\", \"A,B\\n1,2\\n3,4\\n\", \"table1.csv\");\n    const config = defaultsDeep({ headers: formData.getHeaders() }, chimpy);\n    const importResp = await axios.post(`${homeUrl}/api/workspaces/${wid}/import`, formData, config);\n    assert.equal(importResp.status, 200);\n    const urlId = importResp.data.id;\n\n    const docDetailsResp = await axios.get(`${homeUrl}/api/docs/${urlId}`, chimpy);\n    assert.equal(docDetailsResp.status, 200);\n    assert.equal(docDetailsResp.data.name, \"table1\");\n    assert.equal(docDetailsResp.data.workspace.name, \"Private\");\n\n    // content was successfully stored\n    const contentResp = await axios.get(`${homeUrl}/api/docs/${urlId}/tables/Table1/data`, chimpy);\n    assert.deepEqual(contentResp.data, { id: [1, 2], manualSort: [1, 2], A: [1, 3], B: [2, 4] });\n  });\n\n  it(\"handles /s/ variants for shares\", async function() {\n    const { serverUrl, userApi, chimpy } = getCtx();\n    const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n    const docId = await userApi.newDoc({ name: \"BlankTest\" }, wid);\n    // const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;\n    const userActions = [\n      [\"AddRecord\", \"_grist_Shares\", null, {\n        linkId: \"x\",\n        options: '{\"publish\": true}',\n      }],\n      [\"UpdateRecord\", \"_grist_Views_section\", 1,\n        { shareOptions: '{\"publish\": true, \"form\": true}' }],\n      [\"UpdateRecord\", \"_grist_Pages\", 1, { shareRef: 1 }],\n    ];\n    let resp: AxiosResponse;\n    resp = await axios.post(`${serverUrl}/api/docs/${docId}/apply`, userActions, chimpy);\n    assert.equal(resp.status, 200);\n\n    const db = await getDatabase();\n    const shares = await db.connection.query(\"select * from shares\");\n    const { key } = shares[0];\n\n    resp = await axios.get(`${serverUrl}/api/docs/${docId}/tables/Table1/records`, chimpy);\n    assert.equal(resp.status, 200);\n\n    resp = await axios.get(`${serverUrl}/api/s/${key}/tables/Table1/records`, chimpy);\n    assert.equal(resp.status, 200);\n\n    resp = await axios.get(`${serverUrl}/api/docs/${key}/tables/Table1/records`, chimpy);\n    assert.equal(resp.status, 404);\n\n    resp = await axios.get(`${serverUrl}/api/docs/${SHARE_KEY_PREFIX}${key}/tables/Table1/records`, chimpy);\n    assert.equal(resp.status, 200);\n\n    resp = await axios.get(`${serverUrl}/api/s/${key}xxx/tables/Table1/records`, chimpy);\n    assert.equal(resp.status, 404);\n  });\n\n  it(\"document is protected during upload-and-import sequence\", async function() {\n    const { userApi, chimpy, kiwi, home } = getCtx();\n    if (!process.env.TEST_REDIS_URL) {\n      this.skip();\n    }\n    // Prepare an API for a different user.\n    const kiwiApi = home.makeUserApi(\"Fish\", \"kiwi\");\n    // upload something for Chimpy and something else for Kiwi.\n    const worker1 = await userApi.getWorkerAPI(\"import\");\n    const fakeData1 = await testUtils.readFixtureDoc(\"Hello.grist\");\n    const uploadId1 = await worker1.upload(fakeData1, \"upload.grist\");\n    const worker2 = await kiwiApi.getWorkerAPI(\"import\");\n    const fakeData2 = await testUtils.readFixtureDoc(\"Favorite_Films.grist\");\n    const uploadId2 = await worker2.upload(fakeData2, \"upload2.grist\");\n\n    // Check that kiwi only has access to their own upload.\n    let wid = (await kiwiApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Big\")!.id;\n    let resp = await axios.post(`${worker2.url}/api/workspaces/${wid}/import`, { uploadId: uploadId1 },\n      kiwi);\n    assert.equal(resp.status, 403);\n    assert.deepEqual(resp.data, { error: \"access denied\" });\n\n    resp = await axios.post(`${worker2.url}/api/workspaces/${wid}/import`, { uploadId: uploadId2 },\n      kiwi);\n    assert.equal(resp.status, 200);\n\n    // Check that chimpy has access to their own upload.\n    wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n    resp = await axios.post(`${worker1.url}/api/workspaces/${wid}/import`, { uploadId: uploadId1 },\n      chimpy);\n    assert.equal(resp.status, 200);\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiOrgLimitFlags.ts",
    "content": "/**\n * Tests env var flags that affect org creation and viewing:\n * - GRIST_ORG_CREATION_ANYONE - Checks this disables org creation by non-admins\n * - GRIST_PERSONAL_ORGS - Checks this disables personal org creation\n *\n * Tests only run using merged server (as they don't utilise doc workers)\n */\n\nimport {\n  getAnonPlaygroundEnabled,\n  getCanAnyoneCreateOrgs,\n  getPersonalOrgsEnabled,\n} from \"app/server/lib/gristSettings\";\nimport { configForApiKey, configForUser } from \"test/gen-server/testUtils\";\nimport {\n  addAllScenarios,\n  makeUserApi,\n  ORG_NAME,\n  TestContext,\n} from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { EnvironmentSnapshot } from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\n\ndescribe(\"DocApiOrgLimitFlags\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  describe(\"Personal orgs\", function() {\n    addAllScenarios(addPersonalOrgLimitTests, \"docapi-personal orgs disabled\", {\n      extraEnv: {\n        GRIST_TEST_LOGIN: \"1\",\n        GRIST_PERSONAL_ORGS: \"false\",\n      },\n    });\n  });\n\n  describe(\"Team orgs\", function() {\n    addAllScenarios(addTeamOrgLimitTests, \"docapi-team org creation disabled\", {\n      extraEnv: {\n        GRIST_ORG_CREATION_ANYONE: \"false\",\n      },\n    });\n  });\n\n  describe(\"Org creation setting default values\", function() {\n    let oldEnv: EnvironmentSnapshot;\n    before(async function() {\n      oldEnv = new EnvironmentSnapshot();\n      delete process.env.GRIST_ORG_CREATION_ANYONE;\n      delete process.env.GRIST_PERSONAL_ORGS;\n      delete process.env.GRIST_ANON_PLAYGROUND;\n\n      // Clear memoized function cache.\n      getCanAnyoneCreateOrgs.cache.clear();\n      getAnonPlaygroundEnabled.cache.clear();\n      getPersonalOrgsEnabled.cache.clear();\n    });\n\n    afterEach(function() {\n      // Clear memoized function cache.\n      getCanAnyoneCreateOrgs.cache.clear();\n      getAnonPlaygroundEnabled.cache.clear();\n      getPersonalOrgsEnabled.cache.clear();\n    });\n\n    after(async function() {\n      oldEnv.restore();\n    });\n\n    describe(\"GRIST_ORG_CREATION_ANYONE sets default values of GRIST_PERSONAL_ORGS and GRIST_ANON_PLAYGROUND\", () => {\n      it(\"defaults to true\", () => {\n        assert.equal(getCanAnyoneCreateOrgs(), true);\n        assert.equal(getAnonPlaygroundEnabled(), true);\n        assert.equal(getPersonalOrgsEnabled(), true);\n      });\n\n      it(\"sets them to true\", () => {\n        process.env.GRIST_ORG_CREATION_ANYONE = \"true\";\n        assert.equal(getCanAnyoneCreateOrgs(), true);\n        assert.equal(getAnonPlaygroundEnabled(), true);\n        assert.equal(getPersonalOrgsEnabled(), true);\n      });\n\n      it(\"sets them to false\", () => {\n        process.env.GRIST_ORG_CREATION_ANYONE = \"false\";\n        assert.equal(getCanAnyoneCreateOrgs(), false);\n        assert.equal(getAnonPlaygroundEnabled(), false);\n        assert.equal(getPersonalOrgsEnabled(), false);\n      });\n    });\n  });\n});\n\nfunction addPersonalOrgLimitTests(getCtx: () => TestContext) {\n  it(\"should not create personal orgs for users\", async () => {\n    const { homeUrl } = getCtx();\n    // Need to manually login - adding a user to seed.ts isn't enough to test this, as it won't result\n    // in a personal org being created when we need it to be.\n    const loginResponse = await axios.get(new URL(\"/test/login\", homeUrl).toString(), {\n      params: {\n        username: \"test_personal_org_disabling@getgrist.com\",\n        name: \"Test personal org disabling\",\n      },\n    });\n    const apiKeyResponse = await axios.post(\n      new URL(\"/api/profile/apikey\", homeUrl).toString(),\n      // Needed in case this test runs multiple times against the same DB (e.g. merged server + separate servers)\n      { force: true },\n      {\n        // Copy cookies from login so we're authorized to create an actual API key\n        headers: { cookie: loginResponse.headers[\"set-cookie\"] },\n        withCredentials: true,\n      },\n    );\n\n    const apiKey = apiKeyResponse.data;\n    const newUserApi = makeUserApi(homeUrl, ORG_NAME, configForApiKey(apiKey));\n    const id = (await newUserApi.getUserProfile()).id;\n    assert.isNumber(id);\n    const orgs = await newUserApi.getOrgs();\n    const personalOrg = orgs.find(org => org.owner?.id === id);\n    assert.isUndefined(personalOrg);\n  });\n\n  it(\"should not allow users to view their existing personal orgs\", async () => {\n    const { homeUrl } = getCtx();\n    // Chimpy has an existing personal org set up already.\n    const userApi = makeUserApi(homeUrl, ORG_NAME, configForUser(\"chimpy\"));\n    const orgs = await userApi.getOrgs();\n    const personalOrg = orgs.find(org => Boolean(org.owner));\n    assert.isUndefined(personalOrg);\n  });\n}\n\nfunction addTeamOrgLimitTests(getCtx: () => TestContext) {\n  it(\"prevents non-admins creating team orgs\", async function() {\n    const { homeUrl, chimpy } = getCtx();\n    const chimpyApi = makeUserApi(homeUrl, ORG_NAME, chimpy);\n    await assert.isRejected(chimpyApi.newOrg({ name: \"New org should fail\" }), \"403: Forbidden\");\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiPermissions.ts",
    "content": "/**\n * Tests for document and workspace permissions/ownership.\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { UserAPIImpl } from \"app/common/UserAPI\";\nimport { addAllScenarios, TestContext } from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport FormData from \"form-data\";\nimport fetch from \"node-fetch\";\n\ndescribe(\"DocApiPermissions\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addPermissionsTests, \"docapi-permissions\");\n});\n\nfunction addPermissionsTests(getCtx: () => TestContext) {\n  it(\"creator should be owner of a created ws\", async function() {\n    const { userApi, homeUrl } = getCtx();\n    const kiwiEmail = \"kiwi@getgrist.com\";\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    // Make sure kiwi isn't allowed here.\n    await userApi.updateOrgPermissions(\"docs-1\", { users: { [kiwiEmail]: null } });\n    const kiwiApi = new UserAPIImpl(`${homeUrl}/o/docs-1`, {\n      headers: { Authorization: \"Bearer api_key_for_kiwi\" },\n      fetch: fetch as unknown as typeof globalThis.fetch,\n      newFormData: () => new FormData() as any,\n    });\n    await assert.isRejected(kiwiApi.getWorkspaceAccess(ws1), /Forbidden/);\n    // Add kiwi as an editor for the org.\n    await assert.isRejected(kiwiApi.getOrgAccess(\"docs-1\"), /Forbidden/);\n    await userApi.updateOrgPermissions(\"docs-1\", { users: { [kiwiEmail]: \"editors\" } });\n    // Make a workspace as Kiwi, he should be owner of it.\n    const kiwiWs = await kiwiApi.newWorkspace({ name: \"kiwiWs\" }, \"docs-1\");\n    const kiwiWsAccess = await kiwiApi.getWorkspaceAccess(kiwiWs);\n    assert.equal(kiwiWsAccess.users.find(u => u.email === kiwiEmail)?.access, \"owners\");\n    // Delete workspace.\n    await kiwiApi.deleteWorkspace(kiwiWs);\n    // Remove kiwi from the org.\n    await userApi.updateOrgPermissions(\"docs-1\", { users: { [kiwiEmail]: null } });\n  });\n\n  it(\"creator should be owner of a created doc\", async function() {\n    const { userApi, homeUrl } = getCtx();\n    const kiwiEmail = \"kiwi@getgrist.com\";\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    await userApi.updateOrgPermissions(\"docs-1\", { users: { [kiwiEmail]: null } });\n    // Make sure kiwi isn't allowed here.\n    const kiwiApi = new UserAPIImpl(`${homeUrl}/o/docs-1`, {\n      headers: { Authorization: \"Bearer api_key_for_kiwi\" },\n      fetch: fetch as unknown as typeof globalThis.fetch,\n      newFormData: () => new FormData() as any,\n    });\n    await assert.isRejected(kiwiApi.getWorkspaceAccess(ws1), /Forbidden/);\n    // Add kiwi as an editor of this workspace.\n    await userApi.updateWorkspacePermissions(ws1, { users: { [kiwiEmail]: \"editors\" } });\n    await assert.isFulfilled(kiwiApi.getWorkspaceAccess(ws1));\n    // Create a document as kiwi.\n    const kiwiDoc = await kiwiApi.newDoc({ name: \"kiwiDoc\" }, ws1);\n    // Make sure kiwi is an owner of the document.\n    const kiwiDocAccess = await kiwiApi.getDocAccess(kiwiDoc);\n    assert.equal(kiwiDocAccess.users.find(u => u.email === kiwiEmail)?.access, \"owners\");\n    await kiwiApi.deleteDoc(kiwiDoc);\n    // Remove kiwi from the workspace.\n    await userApi.updateWorkspacePermissions(ws1, { users: { [kiwiEmail]: null } });\n    await assert.isRejected(kiwiApi.getWorkspaceAccess(ws1), /Forbidden/);\n  });\n\n  it(\"should allow only owners to remove a document\", async function() {\n    const { userApi, homeUrl } = getCtx();\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    const doc1 = await userApi.newDoc({ name: \"testdeleteme1\" }, ws1);\n    const kiwiApi = new UserAPIImpl(`${homeUrl}/o/docs-1`, {\n      headers: { Authorization: \"Bearer api_key_for_kiwi\" },\n      fetch: fetch as unknown as typeof globalThis.fetch,\n      newFormData: () => new FormData() as any,\n    });\n\n    // Kiwi is editor of the document, so he can't delete it.\n    await userApi.updateDocPermissions(doc1, { users: { \"kiwi@getgrist.com\": \"editors\" } });\n    await assert.isRejected(kiwiApi.softDeleteDoc(doc1), /Forbidden/);\n    await assert.isRejected(kiwiApi.deleteDoc(doc1), /Forbidden/);\n\n    // Kiwi is owner of the document - now he can delete it.\n    await userApi.updateDocPermissions(doc1, { users: { \"kiwi@getgrist.com\": \"owners\" } });\n    await assert.isFulfilled(kiwiApi.softDeleteDoc(doc1));\n    await assert.isFulfilled(kiwiApi.deleteDoc(doc1));\n  });\n\n  it(\"should allow only owners to rename a document\", async function() {\n    const { userApi, homeUrl } = getCtx();\n    const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n    const doc1 = await userApi.newDoc({ name: \"testrenameme1\" }, ws1);\n    const kiwiApi = new UserAPIImpl(`${homeUrl}/o/docs-1`, {\n      headers: { Authorization: \"Bearer api_key_for_kiwi\" },\n      fetch: fetch as unknown as typeof globalThis.fetch,\n      newFormData: () => new FormData() as any,\n    });\n\n    // Kiwi is editor of the document, so he can't rename it.\n    await userApi.updateDocPermissions(doc1, { users: { \"kiwi@getgrist.com\": \"editors\" } });\n    await assert.isRejected(kiwiApi.renameDoc(doc1, \"testrenameme2\"), /Forbidden/);\n\n    // Kiwi is owner of the document - now he can rename it.\n    await userApi.updateDocPermissions(doc1, { users: { \"kiwi@getgrist.com\": \"owners\" } });\n    await assert.isFulfilled(kiwiApi.renameDoc(doc1, \"testrenameme2\"));\n\n    await userApi.deleteDoc(doc1);\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiQueryParameters.ts",
    "content": "/**\n * Tests for query parameter handling (sort, limit, filter).\n * These tests verify the applyQueryParameters function.\n *\n * These are unit tests that don't require server setup.\n */\n\nimport { applyQueryParameters } from \"app/server/lib/DocApi\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"DocApiQueryParameters\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  function makeExample() {\n    return {\n      id: [1, 2, 3, 7, 8, 9],\n      color: [\"red\", \"yellow\", \"white\", \"blue\", \"black\", \"purple\"],\n      spin: [\"up\", \"up\", \"down\", \"down\", \"up\", \"up\"],\n    };\n  }\n\n  it(\"supports ascending sort\", async function() {\n    assert.deepEqual(applyQueryParameters(makeExample(), { sort: [\"color\"] }, null), {\n      id: [8, 7, 9, 1, 3, 2],\n      color: [\"black\", \"blue\", \"purple\", \"red\", \"white\", \"yellow\"],\n      spin: [\"up\", \"down\", \"up\", \"up\", \"down\", \"up\"],\n    });\n  });\n\n  it(\"supports descending sort\", async function() {\n    assert.deepEqual(applyQueryParameters(makeExample(), { sort: [\"-id\"] }, null), {\n      id: [9, 8, 7, 3, 2, 1],\n      color: [\"purple\", \"black\", \"blue\", \"white\", \"yellow\", \"red\"],\n      spin: [\"up\", \"up\", \"down\", \"down\", \"up\", \"up\"],\n    });\n  });\n\n  it(\"supports multi-key sort\", async function() {\n    assert.deepEqual(applyQueryParameters(makeExample(), { sort: [\"-spin\", \"color\"] }, null), {\n      id: [8, 9, 1, 2, 7, 3],\n      color: [\"black\", \"purple\", \"red\", \"yellow\", \"blue\", \"white\"],\n      spin: [\"up\", \"up\", \"up\", \"up\", \"down\", \"down\"],\n    });\n  });\n\n  it(\"does not freak out sorting mixed data\", async function() {\n    const example = {\n      id: [1, 2, 3, 4, 5, 6, 7, 8, 9],\n      mixed: [\"red\", \"green\", \"white\", 2.5, 1, null, [\"zing\", 3] as any, 5, \"blue\"],\n    };\n    assert.deepEqual(applyQueryParameters(example, { sort: [\"mixed\"] }, null), {\n      mixed: [1, 2.5, 5, null, [\"zing\", 3] as any, \"blue\", \"green\", \"red\", \"white\"],\n      id: [5, 4, 8, 6, 7, 9, 2, 1, 3],\n    });\n  });\n\n  it(\"supports limit\", async function() {\n    assert.deepEqual(applyQueryParameters(makeExample(), { limit: 1 }),\n      { id: [1], color: [\"red\"], spin: [\"up\"] });\n  });\n\n  it(\"supports sort and limit\", async function() {\n    assert.deepEqual(applyQueryParameters(makeExample(), { sort: [\"-color\"], limit: 2 }, null),\n      { id: [2, 3], color: [\"yellow\", \"white\"], spin: [\"up\", \"down\"] });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiRecords.ts",
    "content": "/**\n * Tests for record operations.\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { BulkColValues, CellValue } from \"app/common/DocActions\";\nimport { isRaisedException } from \"app/common/gristTypes\";\nimport { AddOrUpdateRecord } from \"app/plugin/DocApiTypes\";\nimport { GristObjCode } from \"app/plugin/GristData\";\nimport { addAllScenarios, addAttachmentsToDoc, TestContext } from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios, { AxiosResponse } from \"axios\";\nimport { assert } from \"chai\";\n\ndescribe(\"DocApiRecords\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addRecordsTests, \"docapi-records\");\n});\n\nfunction addRecordsTests(getCtx: () => TestContext) {\n  it(\"GET /docs/{did}/tables/{tid}/data retrieves data in column format\", async function() {\n    const { serverUrl, docIds, chimpy, getOrCreateTestDoc } = getCtx();\n    await getOrCreateTestDoc();\n    const data = {\n      id: [1, 2, 3, 4],\n      A: [\"hello\", \"\", \"\", \"\"],\n      B: [\"\", \"world\", \"\", \"\"],\n      C: [\"\", \"\", \"\", \"\"],\n      D: [null, null, null, null],\n      E: [\"HELLO\", \"\", \"\", \"\"],\n      manualSort: [1, 2, 3, 4],\n    };\n    const respWithTableId = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`, chimpy);\n    assert.equal(respWithTableId.status, 200);\n    assert.deepEqual(respWithTableId.data, data);\n    const respWithTableRef = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/1/data`, chimpy);\n    assert.equal(respWithTableRef.status, 200);\n    assert.deepEqual(respWithTableRef.data, data);\n  });\n\n  it(\"GET /docs/{did}/tables/{tid}/records retrieves data in records format\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const data = {\n      records:\n        [\n          {\n            id: 1,\n            fields: {\n              A: \"hello\",\n              B: \"\",\n              C: \"\",\n              D: null,\n              E: \"HELLO\",\n            },\n          },\n          {\n            id: 2,\n            fields: {\n              A: \"\",\n              B: \"world\",\n              C: \"\",\n              D: null,\n              E: \"\",\n            },\n          },\n          {\n            id: 3,\n            fields: {\n              A: \"\",\n              B: \"\",\n              C: \"\",\n              D: null,\n              E: \"\",\n            },\n          },\n          {\n            id: 4,\n            fields: {\n              A: \"\",\n              B: \"\",\n              C: \"\",\n              D: null,\n              E: \"\",\n            },\n          },\n        ],\n    };\n    const respWithTableId = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/records`, chimpy);\n    assert.equal(respWithTableId.status, 200);\n    assert.deepEqual(respWithTableId.data, data);\n    const respWithTableRef = await axios.get(\n      `${serverUrl}/api/docs/${docIds.Timesheets}/tables/1/records`, chimpy);\n    assert.equal(respWithTableRef.status, 200);\n    assert.deepEqual(respWithTableRef.data, data);\n  });\n\n  it('GET /docs/{did}/tables/{tid}/records honors the \"hidden\" param', async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const params = { hidden: true };\n    const data = {\n      id: 1,\n      fields: {\n        manualSort: 1,\n        A: \"hello\",\n        B: \"\",\n        C: \"\",\n        D: null,\n        E: \"HELLO\",\n      },\n    };\n    const respWithTableId = await axios.get(\n      `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/records`,\n      { ...chimpy, params },\n    );\n    assert.equal(respWithTableId.status, 200);\n    assert.deepEqual(respWithTableId.data.records[0], data);\n    const respWithTableRef = await axios.get(\n      `${serverUrl}/api/docs/${docIds.Timesheets}/tables/1/records`,\n      { ...chimpy, params },\n    );\n    assert.equal(respWithTableRef.status, 200);\n    assert.deepEqual(respWithTableRef.data.records[0], data);\n  });\n\n  it(\"GET /docs/{did}/tables/{tid}/records handles errors and hidden columns\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    let resp = await axios.get(`${serverUrl}/api/docs/${docIds.ApiDataRecordsTest}/tables/Table1/records`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data,\n      {\n        records: [\n          {\n            id: 1,\n            fields: {\n              A: null,\n              B: \"Hi\",\n              C: 1,\n            },\n            errors: {\n              A: \"ZeroDivisionError\",\n            },\n          },\n        ],\n      },\n    );\n\n    // /data format for comparison: includes manualSort, gristHelper_Display, and [\"E\", \"ZeroDivisionError\"]\n    resp = await axios.get(`${serverUrl}/api/docs/${docIds.ApiDataRecordsTest}/tables/Table1/data`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data,\n      {\n        id: [\n          1,\n        ],\n        manualSort: [\n          1,\n        ],\n        A: [\n          [\n            \"E\",\n            \"ZeroDivisionError\",\n          ],\n        ],\n        B: [\n          \"Hi\",\n        ],\n        C: [\n          1,\n        ],\n        gristHelper_Display: [\n          \"Hi\",\n        ],\n      },\n    );\n  });\n\n  it(\"GET /docs/{did}/tables/{tid}/records supports cellFormat=typed\", async function() {\n    // Create a new document with various column types.\n    const { serverUrl, userApi, chimpy } = getCtx();\n    const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n    const docId = await userApi.newDoc({ name: \"TypedCells\" }, wid);\n    const docUrl = `${serverUrl}/api/docs/${docId}`;\n\n    // Set up columns with different types and add data.\n    const setupActions = [\n      [\"AddColumn\", \"Table1\", \"Attachments\", { type: \"Attachments\" }],\n      [\"AddColumn\", \"Table1\", \"Bool\", { type: \"Bool\" }],\n      [\"AddColumn\", \"Table1\", \"Choice\", { type: \"Choice\" }],\n      [\"AddColumn\", \"Table1\", \"ChoiceList\", { type: \"ChoiceList\" }],\n      [\"AddColumn\", \"Table1\", \"Date\", { type: \"Date\" }],\n      [\"AddColumn\", \"Table1\", \"DateTime\", { type: \"DateTime:America/New_York\" }],\n      [\"AddColumn\", \"Table1\", \"Int\", { type: \"Int\" }],\n      [\"AddColumn\", \"Table1\", \"Numeric\", { type: \"Numeric\" }],\n      [\"AddColumn\", \"Table1\", \"Ref\", { type: \"Ref:Table1\" }],\n      [\"AddColumn\", \"Table1\", \"RefList\", { type: \"RefList:Table1\" }],\n      [\"AddColumn\", \"Table1\", \"Text\", { type: \"Text\" }],\n      [\"AddColumn\", \"Table1\", \"ALL\", { type: \"Any\", isFormula: true, formula:\n        \"[$Attachments, $Bool, $Choice, $ChoiceList, $Date, $DateTime, $Int, $Numeric, $Ref, $RefList, $Text]\",\n      }],\n    ];\n    await axios.post(`${docUrl}/apply`, setupActions, chimpy);\n\n    const fields = setupActions.map(a => a[2]).slice(0, -1);\n    assert.deepEqual(fields, [\n      \"Attachments\", \"Bool\", \"Choice\", \"ChoiceList\", \"Date\", \"DateTime\",\n      \"Int\", \"Numeric\", \"Ref\", \"RefList\", \"Text\",\n    ]);\n\n    // Add a row with data.\n    const addActions = [\n      [\"AddRecord\", \"Table1\", null, {\n        Bool: true,\n        Choice: \"Yes\",\n        ChoiceList: [\"L\", \"Yes\", \"No\"],\n        Date: 1707868800,\n        DateTime: 1707868800,\n        Int: 42,\n        Numeric: -8.5,\n        Ref: 1,\n        RefList: [\"L\", 2, 1],\n        Text: \"hello world\",\n      }],\n\n      // Add an empty row, with default values.\n      [\"AddRecord\", \"Table1\", null, {}],\n\n      // Add a row with invalid values.\n      [\"AddRecord\", \"Table1\", null, {\n        Attachments: [\"E\", \"ValueError\"],\n        Bool: [\"E\", \"ValueError\"],\n        Choice: [\"E\", \"ValueError\"],\n        ChoiceList: [\"E\", \"ValueError\"],\n        Date: [\"E\", \"ValueError\"],\n        DateTime: [\"E\", \"ValueError\"],\n        Int: [\"E\", \"ZeroDivisionError\"],\n        Numeric: [\"E\", \"ZeroDivisionError\"],\n        Ref: [\"E\", \"ValueError\"],\n        RefList: [\"E\", \"ValueError\"],\n        Text: [\"E\", \"ValueError\"],\n      }],\n    ];\n    await axios.post(`${docUrl}/apply`, addActions, chimpy);\n\n    // To test attachments, add some attachments.\n    const uploadResp = await addAttachmentsToDoc(serverUrl, docId, [\n      { name: \"hello.doc\", contents: \"foobar\" },\n      { name: \"world.jpg\", contents: \"123456\" },\n    ], chimpy);\n    assert.deepEqual(uploadResp.data, [1, 2]);\n    await axios.post(`${docUrl}/apply`, [[\"UpdateRecord\", \"Table1\", 1, { Attachments: [\"L\", 1, 2] }]], chimpy);\n\n    // Fetch with and without cellFormat=typed.\n    const respDefault = await axios.get(`${docUrl}/tables/Table1/records`, chimpy);\n    const respNormal = await axios.get(`${docUrl}/tables/Table1/records`,\n      { ...chimpy, params: { cellFormat: \"normal\" } });\n    const respTyped = await axios.get(`${docUrl}/tables/Table1/records`,\n      { ...chimpy, params: { cellFormat: \"typed\" } });\n    assert.equal(respDefault.status, 200);\n    assert.equal(respNormal.status, 200);\n    assert.equal(respTyped.status, 200);\n\n    assert.deepEqual(respDefault.data, respNormal.data);\n    const normalRecords = respNormal.data.records;\n    const typedRecords = respTyped.data.records;\n    assert.lengthOf(normalRecords, 3);\n    assert.lengthOf(typedRecords, 3);\n\n    function check(rowIndex: number, colId: string, expNormalValue: unknown, expTypedValue: unknown) {\n      assert.deepEqual(normalRecords[rowIndex].fields[colId], expNormalValue);\n      assert.deepEqual(typedRecords[rowIndex].fields[colId], expTypedValue);\n\n      const indexIntoALL = fields.indexOf(colId);\n      assert.notEqual(indexIntoALL, -1);\n      // The \"ALL\" column should have the form [\"L\", values...], and they should all be typed,\n      // regardless of cellFormat argument, because the column has type \"Any\".\n      // But we skip testing error values; the \"ALL\" column doesn't return a list in that case.\n      if (!isRaisedException(expTypedValue as CellValue)) {\n        assert.deepEqual(normalRecords[rowIndex].fields.ALL[indexIntoALL + 1], expTypedValue);\n        assert.deepEqual(typedRecords[rowIndex].fields.ALL[indexIntoALL + 1], expTypedValue);\n      }\n    }\n\n    check(0, \"Attachments\", [\"L\", 1, 2], [\"r\", \"_grist_Attachments\", [1, 2]]);\n    check(0, \"Bool\", true, true);\n    check(0, \"Choice\", \"Yes\", \"Yes\");\n    check(0, \"ChoiceList\", [\"L\", \"Yes\", \"No\"], [\"L\", \"Yes\", \"No\"]);\n    check(0, \"Date\", 1707868800, [\"d\", 1707868800]);\n    check(0, \"DateTime\", 1707868800, [\"D\", 1707868800, \"America/New_York\"]);\n    check(0, \"Int\", 42, 42);\n    check(0, \"Numeric\", -8.5, -8.5);\n    check(0, \"Ref\", 1, [\"R\", \"Table1\", 1]);\n    check(0, \"RefList\", [\"L\", 2, 1], [\"r\", \"Table1\", [2, 1]]);\n    check(0, \"Text\", \"hello world\", \"hello world\");\n\n    check(1, \"Attachments\", null, [\"r\", \"_grist_Attachments\", []]);\n    check(1, \"Bool\", false, false);\n    check(1, \"Choice\", \"\", \"\");\n    check(1, \"ChoiceList\", null, [\"L\"]);\n    check(1, \"Date\", null, null);\n    check(1, \"DateTime\", null, null);\n    check(1, \"Int\", 0, 0);\n    check(1, \"Numeric\", 0, 0);\n    check(1, \"Ref\", 0, [\"R\", \"Table1\", 0]);\n    check(1, \"RefList\", null, [\"r\", \"Table1\", []]);\n    check(1, \"Text\", \"\", \"\");\n\n    check(2, \"Attachments\", null, [\"E\", \"ValueError\"]);\n    check(2, \"Bool\", null, [\"E\", \"ValueError\"]);\n    check(2, \"Choice\", null, [\"E\", \"ValueError\"]);\n    check(2, \"ChoiceList\", null, [\"E\", \"ValueError\"]);\n    check(2, \"Date\", null, [\"E\", \"ValueError\"]);\n    check(2, \"DateTime\", null, [\"E\", \"ValueError\"]);\n    check(2, \"Int\", null, [\"E\", \"ZeroDivisionError\"]);\n    check(2, \"Numeric\", null, [\"E\", \"ZeroDivisionError\"]);\n    check(2, \"Ref\", null, [\"E\", \"ValueError\"]);\n    check(2, \"RefList\", null, [\"E\", \"ValueError\"]);\n    check(2, \"Text\", null, [\"E\", \"ValueError\"]);\n  });\n\n  it(\"GET /docs/{did}/tables/{tid}/data returns 404 for non-existent doc\", async function() {\n    const { serverUrl, chimpy } = getCtx();\n    const resp = await axios.get(`${serverUrl}/api/docs/typotypotypo/tables/Table1/data`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.match(resp.data.error, /document not found/i);\n  });\n\n  it(\"GET /docs/{did}/tables/{tid}/data returns 404 for non-existent table\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Typo1/data`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.match(resp.data.error, /table not found/i);\n  });\n\n  it(\"GET /docs/{did}/tables/{tid}/data supports filters\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    function makeQuery(filters: { [colId: string]: any[] }) {\n      const query = \"filter=\" + encodeURIComponent(JSON.stringify(filters));\n      return axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data?${query}`, chimpy);\n    }\n\n    function checkResults(resp: AxiosResponse, expectedData: any) {\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, expectedData);\n    }\n\n    checkResults(await makeQuery({ B: [\"world\"] }), {\n      id: [2], A: [\"\"], B: [\"world\"], C: [\"\"], D: [null], E: [\"\"], manualSort: [2],\n    });\n\n    // Can query by id\n    checkResults(await makeQuery({ id: [1] }), {\n      id: [1], A: [\"hello\"], B: [\"\"], C: [\"\"], D: [null], E: [\"HELLO\"], manualSort: [1],\n    });\n\n    checkResults(await makeQuery({ B: [\"\"], A: [\"\"] }), {\n      id: [3, 4], A: [\"\", \"\"], B: [\"\", \"\"], C: [\"\", \"\"], D: [null, null], E: [\"\", \"\"], manualSort: [3, 4],\n    });\n\n    // Empty filter is equivalent to no filter and should return full data.\n    checkResults(await makeQuery({}), {\n      id: [1, 2, 3, 4],\n      A: [\"hello\", \"\", \"\", \"\"],\n      B: [\"\", \"world\", \"\", \"\"],\n      C: [\"\", \"\", \"\", \"\"],\n      D: [null, null, null, null],\n      E: [\"HELLO\", \"\", \"\", \"\"],\n      manualSort: [1, 2, 3, 4],\n    });\n\n    // An impossible filter should succeed but return an empty set of rows.\n    checkResults(await makeQuery({ B: [\"world\"], C: [\"Neptune\"] }), {\n      id: [], A: [], B: [], C: [], D: [], E: [], manualSort: [],\n    });\n\n    // An invalid filter should return an error\n    {\n      const resp = await makeQuery({ BadCol: [\"\"] });\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /BadCol/);\n    }\n\n    {\n      const resp = await makeQuery({ B: \"world\" } as any);\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /filter values must be arrays/);\n    }\n  });\n\n  for (const mode of [\"url\", \"header\"]) {\n    it(`GET /docs/{did}/tables/{tid}/data supports sorts and limits in ${mode}`, async function() {\n      const { serverUrl, docIds, chimpy } = getCtx();\n      function makeQuery(params: { sort?: string; limit?: number }) {\n        const url = `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`;\n        if (mode === \"url\") {\n          const urlParams = new URLSearchParams(params as any);\n          return axios.get(`${url}?${urlParams}`, chimpy);\n        } else {\n          return axios.get(url, { ...chimpy, headers: {\n            ...chimpy.headers, \"X-Sort\": params.sort, \"X-Limit\": params.limit,\n          } });\n        }\n      }\n\n      function checkResults(resp: AxiosResponse, expectedData: any) {\n        assert.equal(resp.status, 200);\n        assert.deepEqual(resp.data, expectedData);\n      }\n\n      checkResults(await makeQuery({ sort: \"A\" }), {\n        id: [2, 3, 4, 1], A: [\"\", \"\", \"\", \"hello\"], B: [\"world\", \"\", \"\", \"\"],\n        C: [\"\", \"\", \"\", \"\"], D: [null, null, null, null], E: [\"\", \"\", \"\", \"HELLO\"], manualSort: [2, 3, 4, 1],\n      });\n      checkResults(await makeQuery({ sort: \"-A\" }), {\n        id: [1, 2, 3, 4], A: [\"hello\", \"\", \"\", \"\"], B: [\"\", \"world\", \"\", \"\"],\n        C: [\"\", \"\", \"\", \"\"], D: [null, null, null, null], E: [\"HELLO\", \"\", \"\", \"\"], manualSort: [1, 2, 3, 4],\n      });\n      checkResults(await makeQuery({ sort: \"B,-A\" }), {\n        id: [1, 3, 4, 2], A: [\"hello\", \"\", \"\", \"\"], B: [\"\", \"\", \"\", \"world\"],\n        C: [\"\", \"\", \"\", \"\"], D: [null, null, null, null], E: [\"HELLO\", \"\", \"\", \"\"], manualSort: [1, 3, 4, 2],\n      });\n      checkResults(await makeQuery({ limit: 1 }), {\n        id: [1], A: [\"hello\"], B: [\"\"], C: [\"\"], D: [null], E: [\"HELLO\"], manualSort: [1],\n      });\n      checkResults(await makeQuery({ sort: \"B\", limit: 2 }), {\n        id: [1, 3], A: [\"hello\", \"\"], B: [\"\", \"\"], C: [\"\", \"\"], D: [null, null], E: [\"HELLO\", \"\"], manualSort: [1, 3],\n      });\n      checkResults(await makeQuery({ sort: \"-B\", limit: 2 }), {\n        id: [2, 1], A: [\"\", \"hello\"], B: [\"world\", \"\"], C: [\"\", \"\"], D: [null, null], E: [\"\", \"HELLO\"],\n        manualSort: [2, 1],\n      });\n      // Sort disc, then asc\n      checkResults(await makeQuery({ sort: \"-B,A\", limit: 2 }), {\n        id: [2, 3], A: [\"\", \"\"], B: [\"world\", \"\"], C: [\"\", \"\"], D: [null, null], E: [\"\", \"\"], manualSort: [2, 3],\n      });\n      // Limit only\n      checkResults(await makeQuery({ limit: 2 }), {\n        id: [1, 2], A: [\"hello\", \"\"], B: [\"\", \"world\"], C: [\"\", \"\"], D: [null, null], E: [\"HELLO\", \"\"],\n        manualSort: [1, 2],\n      });\n      // Limit with sorting in reverse order\n      checkResults(await makeQuery({ sort: \"-id\", limit: 2 }), {\n        id: [4, 3],\n        A: [\"\", \"\"],\n        B: [\"\", \"\"],\n        C: [\"\", \"\"],\n        D: [null, null],\n        E: [\"\", \"\"],\n        manualSort: [4, 3],\n      });\n    });\n  }\n\n  it(\"GET /docs/{did}/tables/{tid}/data respects document permissions\", async function() {\n    const { serverUrl, docIds, kiwi } = getCtx();\n    // as not part of any group kiwi cannot fetch Timesheets\n    const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`, kiwi);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"GET /docs/{did}/tables/{tid}/data returns matches /not found/ for bad table id\", async function() {\n    const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n    const testDoc = await getOrCreateTestDoc();\n    const resp = await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/Bad_Foo_/data`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.match(resp.data.error, /not found/);\n  });\n\n  it(\"POST /docs/{did}/apply applies user actions\", async function() {\n    const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n    const testDoc = await getOrCreateTestDoc();\n    const userActions = [\n      [\"AddTable\", \"Foo\", [{ id: \"A\" }, { id: \"B\" }]],\n      [\"BulkAddRecord\", \"Foo\", [1, 2], { A: [\"Santa\", \"Bob\"], B: [1, 11] }],\n    ];\n    const resp = await axios.post(`${serverUrl}/api/docs/${testDoc}/apply`, userActions, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(\n      (await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, chimpy)).data,\n      { id: [1, 2], A: [\"Santa\", \"Bob\"], B: [\"1\", \"11\"], manualSort: [1, 2] });\n  });\n\n  it(\"POST /docs/{did}/apply respects document permissions\", async function() {\n    const { serverUrl, docIds, chimpy, kiwi, getOrCreateTestDoc } = getCtx();\n    const testDoc = await getOrCreateTestDoc();\n    const userActions = [\n      [\"AddTable\", \"FooBar\", [{ id: \"A\" }]],\n    ];\n    let resp: AxiosResponse;\n\n    // as a guest chimpy cannot edit Bananas\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Bananas}/apply`, userActions, chimpy);\n    assert.equal(resp.status, 403);\n    assert.deepEqual(resp.data, { error: \"No write access\" });\n\n    // check that changes did not apply\n    resp = await axios.get(`${serverUrl}/api/docs/${docIds.Bananas}/tables/FooBar/data`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.match(resp.data.error, /not found/);\n\n    // as not in any group kiwi cannot edit TestDoc\n    resp = await axios.post(`${serverUrl}/api/docs/${testDoc}/apply`, userActions, kiwi);\n    assert.equal(resp.status, 403);\n\n    // check that changes did not apply\n    resp = await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/FooBar/data`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.match(resp.data.error, /not found/);\n  });\n\n  it(\"POST /docs/{did}/tables/{tid}/data adds records\", async function() {\n    const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n    const testDoc = await getOrCreateTestDoc();\n    let resp = await axios.post(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, {\n      A: [\"Alice\", \"Felix\"],\n      B: [2, 22],\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, [3, 4]);\n    resp = await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, chimpy);\n    assert.deepEqual(resp.data, {\n      id: [1, 2, 3, 4],\n      A: [\"Santa\", \"Bob\", \"Alice\", \"Felix\"],\n      B: [\"1\", \"11\", \"2\", \"22\"],\n      manualSort: [1, 2, 3, 4],\n    });\n  });\n\n  it(\"POST /docs/{did}/tables/{tid}/data respects document permissions\", async function() {\n    const { serverUrl, docIds, chimpy, kiwi, getOrCreateTestDoc } = getCtx();\n    const testDoc = await getOrCreateTestDoc();\n    let resp: AxiosResponse;\n    // as a guest chimpy cannot edit Bananas\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Bananas}/tables/Table1/data`, { A: [\"Alice\"] }, chimpy);\n    assert.equal(resp.status, 403);\n\n    // as not in any group kiwi cannot edit TestDoc\n    resp = await axios.post(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, { A: [\"Alice\"] }, kiwi);\n    assert.equal(resp.status, 403);\n  });\n\n  it(\"POST /docs/{did}/tables/{tid}/records adds records\", async function() {\n    const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n    const testDoc = await getOrCreateTestDoc();\n    let resp = await axios.post(`${serverUrl}/api/docs/${testDoc}/tables/Foo/records`, {\n      records: [\n        { fields: { A: \"John\", B: 55 } },\n        { fields: { A: \"Jane\", B: 0 } },\n      ],\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, {\n      records: [\n        { id: 5 },\n        { id: 6 },\n      ],\n    });\n    resp = await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/Foo/records`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data,\n      {\n        records:\n          [\n            {\n              id: 1,\n              fields: {\n                A: \"Santa\",\n                B: \"1\",\n              },\n            },\n            {\n              id: 2,\n              fields: {\n                A: \"Bob\",\n                B: \"11\",\n              },\n            },\n            {\n              id: 3,\n              fields: {\n                A: \"Alice\",\n                B: \"2\",\n              },\n            },\n            {\n              id: 4,\n              fields: {\n                A: \"Felix\",\n                B: \"22\",\n              },\n            },\n            {\n              id: 5,\n              fields: {\n                A: \"John\",\n                B: \"55\",\n              },\n            },\n            {\n              id: 6,\n              fields: {\n                A: \"Jane\",\n                B: \"0\",\n              },\n            },\n          ],\n      });\n  });\n\n  for (const { desc, url } of [\n    {\n      desc: \"POST /docs/{did}/tables/{tid}/data/delete deletes records\",\n      url: \"tables/Foo/data/delete\",\n    },\n    {\n      desc: \"POST /docs/{did}/tables/{tid}/records/delete deletes records\",\n      url: \"tables/Foo/records/delete\",\n    },\n  ]) {\n    it(desc, async function() {\n      const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      let resp = await axios.post(\n        `${serverUrl}/api/docs/${testDoc}/${url}`,\n        [3, 4, 5, 6],\n        chimpy,\n      );\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, null);\n      resp = await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, chimpy);\n      assert.deepEqual(resp.data, {\n        id: [1, 2],\n        A: [\"Santa\", \"Bob\"],\n        B: [\"1\", \"11\"],\n        manualSort: [1, 2],\n      });\n\n      // restore rows\n      await axios.post(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, {\n        A: [\"Alice\", \"Felix\"],\n        B: [2, 22],\n      }, chimpy);\n      resp = await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, chimpy);\n      assert.deepEqual(resp.data, {\n        id: [1, 2, 3, 4],\n        A: [\"Santa\", \"Bob\", \"Alice\", \"Felix\"],\n        B: [\"1\", \"11\", \"2\", \"22\"],\n        manualSort: [1, 2, 3, 4],\n      });\n    });\n  }\n\n  describe(\"PUT /docs/{did}/tables/{tid}/records\", function() {\n    it(\"should add or update records\", async function() {\n      const { serverUrl, userApi, chimpy } = getCtx();\n      // create sample document for testing\n      const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n      const docId = await userApi.newDoc({ name: \"BlankTest\" }, wid);\n      const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;\n\n      async function check(records: AddOrUpdateRecord[], expectedTableData: BulkColValues, params: any = {}) {\n        const resp = await axios.put(url, { records }, { ...chimpy, params });\n        assert.equal(resp.status, 200);\n        const table = await userApi.getTable(docId, \"Table1\");\n        delete table.manualSort;\n        delete table.C;\n        assert.deepStrictEqual(table, expectedTableData);\n      }\n\n      // Add 3 new records, since the table is empty so nothing matches `requires`\n      await check(\n        [\n          {\n            require: { A: 1 },\n          },\n          {\n            // Since no record with A=2 is found, create a new record,\n            // but `fields` overrides `require` for the value when creating,\n            // so the new record has A=3\n            require: { A: 2 },\n            fields: { A: 3 },\n          },\n          {\n            require: { A: 4 },\n            fields: { B: 5 },\n          },\n        ],\n        { id: [1, 2, 3], A: [1, 3, 4], B: [0, 0, 5] },\n      );\n\n      // Update all three records since they all match the `require` values here\n      await check(\n        [\n          {\n            // Does nothing\n            require: { A: 1 },\n          },\n          {\n            // Changes A from 3 to 33\n            require: { A: 3 },\n            fields: { A: 33 },\n          },\n          {\n            // Changes B from 5 to 6 in the third record where A=4\n            require: { A: 4 },\n            fields: { B: 6 },\n          },\n        ],\n        { id: [1, 2, 3], A: [1, 33, 4], B: [0, 0, 6] },\n      );\n\n      // This would normally add a record, but noadd suppresses that\n      await check([\n        {\n          require: { A: 100 },\n        },\n      ],\n      { id: [1, 2, 3], A: [1, 33, 4], B: [0, 0, 6] },\n      { noadd: \"1\" },\n      );\n\n      // This would normally update A from 1 to 11, bot noupdate suppresses that\n      await check([\n        {\n          require: { A: 1 },\n          fields: { A: 11 },\n        },\n      ],\n      { id: [1, 2, 3], A: [1, 33, 4], B: [0, 0, 6] },\n      { noupdate: \"1\" },\n      );\n\n      // There are 2 records with B=0, update them both to B=1\n      // Use onmany=all to specify that they should both be updated\n      await check([\n        {\n          require: { B: 0 },\n          fields: { B: 1 },\n        },\n      ],\n      { id: [1, 2, 3], A: [1, 33, 4], B: [1, 1, 6] },\n      { onmany: \"all\" },\n      );\n\n      // In contrast to the above, the default behaviour for no value of onmany\n      // is to only update the first matching record,\n      // so only one of the records with B=1 is updated to B=2\n      await check([\n        {\n          require: { B: 1 },\n          fields: { B: 2 },\n        },\n      ],\n      { id: [1, 2, 3], A: [1, 33, 4], B: [2, 1, 6] },\n      );\n\n      // By default, strings in `require` and `fields` are parsed based on column type,\n      // so these dollar amounts are treated as currency\n      // and parsed as A=4 and A=44\n      await check([\n        {\n          require: { A: \"$4\" },\n          fields: { A: \"$44\" },\n        },\n      ],\n      { id: [1, 2, 3], A: [1, 33, 44], B: [2, 1, 6] },\n      );\n\n      // Turn off the default string parsing with noparse=1\n      // Now we need A=44 to actually be a number to match,\n      // A=\"$44\" wouldn't match and would create a new record.\n      // Because A=\"$55\" isn't parsed, the raw string is stored in the table.\n      await check([\n        {\n          require: { A: 44 },\n          fields: { A: \"$55\" },\n        },\n      ],\n      { id: [1, 2, 3], A: [1, 33, \"$55\"], B: [2, 1, 6] },\n      { noparse: 1 },\n      );\n\n      await check([\n        // First three records already exist and nothing happens\n        { require: { A: 1 } },\n        { require: { A: 33 } },\n        { require: { A: \"$55\" } },\n        // Without string parsing, A=\"$33\" doesn't match A=33 and a new record is created\n        { require: { A: \"$33\" } },\n      ],\n      { id: [1, 2, 3, 4], A: [1, 33, \"$55\", \"$33\"], B: [2, 1, 6, 0] },\n      { noparse: 1 },\n      );\n\n      // Checking that updating by `id` works.\n      await check([\n        {\n          require: { id: 3 },\n          fields: { A: \"66\" },\n        },\n      ],\n      { id: [1, 2, 3, 4], A: [1, 33, 66, \"$33\"], B: [2, 1, 6, 0] },\n      );\n\n      // Test bulk case with a mixture of record shapes\n      await check([\n        {\n          require: { A: 1 },\n          fields: { A: 111 },\n        },\n        {\n          require: { A: 33 },\n          fields: { A: 222, B: 444 },\n        },\n        {\n          require: { id: 3 },\n          fields: { A: 555, B: 666 },\n        },\n      ],\n      { id: [1, 2, 3, 4], A: [111, 222, 555, \"$33\"], B: [2, 444, 666, 0] },\n      );\n\n      // allow_empty_require option with empty `require` updates all records\n      await check([\n        {\n          require: {},\n          fields: { A: 99, B: 99 },\n        },\n      ],\n      { id: [1, 2, 3, 4], A: [99, 99, 99, 99], B: [99, 99, 99, 99] },\n      { allow_empty_require: \"1\", onmany: \"all\" },\n      );\n    });\n\n    it(\"should 404 for missing tables\", async function() {\n      const { serverUrl, userApi, chimpy } = getCtx();\n      const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n      const docId = await userApi.newDoc({ name: \"BlankTest2\" }, wid);\n      const url = `${serverUrl}/api/docs/${docId}/tables/Table2/records`;\n      const resp = await axios.put(url, { records: [{ require: { A: 1 } }] }, chimpy);\n      assert.equal(resp.status, 404);\n      assert.match(resp.data.error, /Table not found/);\n    });\n\n    it(\"should 400 for missing columns\", async function() {\n      const { serverUrl, userApi, chimpy } = getCtx();\n      const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n      const docId = await userApi.newDoc({ name: \"BlankTest3\" }, wid);\n      const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;\n      const resp = await axios.put(url, {\n        records: [\n          { require: { NoColumn: 1 } },\n        ],\n      }, chimpy);\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /Invalid column \"NoColumn\"/);\n    });\n\n    it(\"should 400 for an incorrect onmany parameter\", async function() {\n      const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const resp = await axios.put(`${serverUrl}/api/docs/${testDoc}/tables/Foo/records`,\n        { records: [{ require: { id: 1 } }] }, { ...chimpy, params: { onmany: \"foo\" } });\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /onmany parameter foo should be one of first,none,all/);\n    });\n\n    it(\"should 400 for an empty require without allow_empty_require\", async function() {\n      const { serverUrl, userApi, chimpy } = getCtx();\n      const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n      const docId = await userApi.newDoc({ name: \"BlankTest5\" }, wid);\n      const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;\n      const resp = await axios.put(url, {\n        records: [{ require: {} }],\n      }, chimpy);\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /require is empty but allow_empty_require isn't set/);\n    });\n\n    it(\"should validate request schema\", async function() {\n      const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const url = `${serverUrl}/api/docs/${testDoc}/tables/Foo/records`;\n      const test = async (payload: any, error: { error: string, details: { userError: string } }) => {\n        const resp = await axios.put(url, payload, chimpy);\n        assert.equal(resp.status, 400);\n        assert.deepEqual(resp.data, error);\n      };\n      await test({}, { error: \"Invalid payload\", details: { userError: \"Error: body.records is missing\" } });\n      await test({ records: 1 }, {\n        error: \"Invalid payload\",\n        details: { userError: \"Error: body.records is not an array\" } });\n      await test({ records: [{ fields: {} }] },\n        {\n          error: \"Invalid payload\",\n          details: { userError: \"Error: \" +\n            \"body.records[0] is not a AddOrUpdateRecord; \" +\n            \"body.records[0].require is missing\",\n          } });\n      await test({ records: [{ require: { id: \"1\" } }] },\n        {\n          error: \"Invalid payload\",\n          details: { userError: \"Error: \" +\n            \"body.records[0] is not a AddOrUpdateRecord; \" +\n            \"body.records[0].require.id is not a number\",\n          } });\n    });\n  });\n\n  describe(\"POST /docs/{did}/tables/{tid}/records\", function() {\n    it(\"POST should have good errors\", async function() {\n      const { serverUrl, userApi, chimpy } = getCtx();\n      const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n      const docId = await userApi.newDoc({ name: \"PostErrors\" }, wid);\n      const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;\n\n      // Completely invalid request\n      let resp = await axios.post(url, { records: \"hi\" }, chimpy);\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /Invalid payload/);\n\n      // Missing records\n      resp = await axios.post(url, {}, chimpy);\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /Invalid payload/);\n    });\n\n    it(\"allows to create a blank record\", async function() {\n      const { serverUrl, userApi, chimpy } = getCtx();\n      // create sample document for testing\n      const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n      const docId = await userApi.newDoc({ name: \"BlankTest\" }, wid);\n      // Create two blank records\n      const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;\n      const resp = await axios.post(url, { records: [{}, { fields: {} }] }, chimpy);\n      assert.equal(resp.status, 200);\n      assert.deepEqual(resp.data, { records: [{ id: 1 }, { id: 2 }] });\n    });\n\n    it(\"allows to create partial records\", async function() {\n      const { serverUrl, userApi, chimpy } = getCtx();\n      // create sample document for testing\n      const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n      const docId = await userApi.newDoc({ name: \"BlankTest\" }, wid);\n      const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;\n      // create partial records\n      const resp = await axios.post(url, { records: [{ fields: { A: 1 } }, { fields: { B: 2 } }, {}] }, chimpy);\n      assert.equal(resp.status, 200);\n      const table = await userApi.getTable(docId, \"Table1\");\n      delete table.manualSort;\n      assert.deepStrictEqual(\n        table,\n        { id: [1, 2, 3], A: [1, null, null], B: [null, 2, null], C: [null, null, null] });\n    });\n\n    it(\"validates request schema\", async function() {\n      const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const url = `${serverUrl}/api/docs/${testDoc}/tables/Foo/records`;\n      const test = async (payload: any, error: { error: string, details: { userError: string } }) => {\n        const resp = await axios.post(url, payload, chimpy);\n        assert.equal(resp.status, 400);\n        assert.deepEqual(resp.data, error);\n      };\n      await test({}, { error: \"Invalid payload\", details: { userError: \"Error: body.records is missing\" } });\n      await test({ records: 1 }, {\n        error: \"Invalid payload\",\n        details: { userError: \"Error: body.records is not an array\" } });\n      // All column types are allowed, except Arrays (or objects) without correct code.\n      const testField = async (A: any) => {\n        await test({ records: [{ id: 1, fields: { A } }] }, { error: \"Invalid payload\", details: { userError:\n                    \"Error: body.records[0] is not a NewRecord; \" +\n                    \"body.records[0].fields.A is not a CellValue; \" +\n                    \"body.records[0].fields.A is none of number, \" +\n                    \"string, boolean, null, 1 more; body.records[0].\" +\n                    \"fields.A[0] is not a GristObjCode; body.records[0]\" +\n                    \".fields.A[0] is not a valid enum value\" } });\n      };\n      // test no code at all\n      await testField([]);\n      // test invalid code\n      await testField([\"ZZ\"]);\n    });\n\n    it(\"allows CellValue as a field\", async function() {\n      const { serverUrl, userApi, chimpy } = getCtx();\n      // create sample document\n      const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n      const docId = await userApi.newDoc({ name: \"PostTest\" }, wid);\n      const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;\n      const testField = async (A?: CellValue, message?: string) => {\n        const resp = await axios.post(url, { records: [{ fields: { A } }] }, chimpy);\n        assert.equal(resp.status, 200, message ?? `Error for code ${A}`);\n      };\n      // test allowed types for a field\n      await testField(1); // ints\n      await testField(1.2); // floats\n      await testField(\"string\"); // strings\n      await testField(true); // true and false\n      await testField(false);\n      await testField(null); // null\n      // encoded values (though not all make sense)\n      for (const code of [\n        GristObjCode.List,\n        GristObjCode.Dict,\n        GristObjCode.DateTime,\n        GristObjCode.Date,\n        GristObjCode.Skip,\n        GristObjCode.Censored,\n        GristObjCode.Reference,\n        GristObjCode.ReferenceList,\n        GristObjCode.Exception,\n        GristObjCode.Pending,\n        GristObjCode.Unmarshallable,\n        GristObjCode.Versions,\n      ]) {\n        await testField([code]);\n      }\n    });\n  });\n\n  describe(\"PATCH /docs/{did}/tables/{tid}/records\", function() {\n    it(\"updates records\", async function() {\n      const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      let resp = await axios.patch(`${serverUrl}/api/docs/${testDoc}/tables/Foo/records`, {\n        records: [\n          {\n            id: 1,\n            fields: {\n              A: \"Father Christmas\",\n            },\n          },\n        ],\n      }, chimpy);\n      assert.equal(resp.status, 200);\n      resp = await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/Foo/records`, chimpy);\n      // check that rest of the data is left unchanged\n      assert.deepEqual(resp.data, {\n        records:\n          [\n            {\n              id: 1,\n              fields: {\n                A: \"Father Christmas\",\n                B: \"1\",\n              },\n            },\n            {\n              id: 2,\n              fields: {\n                A: \"Bob\",\n                B: \"11\",\n              },\n            },\n            {\n              id: 3,\n              fields: {\n                A: \"Alice\",\n                B: \"2\",\n              },\n            },\n            {\n              id: 4,\n              fields: {\n                A: \"Felix\",\n                B: \"22\",\n              },\n            },\n          ],\n      });\n    });\n\n    it(\"validates request schema\", async function() {\n      const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const url = `${serverUrl}/api/docs/${testDoc}/tables/Foo/records`;\n      async function failsWithError(payload: any, error: { error: string, details?: { userError: string } }) {\n        const resp = await axios.patch(url, payload, chimpy);\n        assert.equal(resp.status, 400);\n        assert.deepEqual(resp.data, error);\n      }\n\n      await failsWithError({}, { error: \"Invalid payload\", details: { userError: \"Error: body.records is missing\" } });\n\n      await failsWithError({ records: 1 }, {\n        error: \"Invalid payload\",\n        details: { userError: \"Error: body.records is not an array\" } });\n\n      await failsWithError({ records: [] }, { error: \"Invalid payload\", details: { userError:\n                  \"Error: body.records[0] is not a Record; body.records[0] is not an object\" } });\n\n      await failsWithError({ records: [{}] }, { error: \"Invalid payload\", details: { userError:\n                  \"Error: body.records[0] is not a Record\\n    \" +\n                  \"body.records[0].id is missing\\n    \" +\n                  \"body.records[0].fields is missing\" } });\n\n      await failsWithError({ records: [{ id: \"1\" }] }, { error: \"Invalid payload\", details: { userError:\n                  \"Error: body.records[0] is not a Record\\n\" +\n                  \"    body.records[0].id is not a number\\n\" +\n                  \"    body.records[0].fields is missing\" } });\n\n      await failsWithError(\n        { records: [{ id: 1, fields: { A: 1 } }, { id: 2, fields: { B: 3 } }] },\n        { error: \"PATCH requires all records to have same fields\" });\n\n      // Test invalid object codes\n      const fieldIsNotValid = async (A: any) => {\n        await failsWithError({ records: [{ id: 1, fields: { A } }] }, { error: \"Invalid payload\", details: { userError:\n                    \"Error: body.records[0] is not a Record; \" +\n                    \"body.records[0].fields.A is not a CellValue; \" +\n                    \"body.records[0].fields.A is none of number, \" +\n                    \"string, boolean, null, 1 more; body.records[0].\" +\n                    \"fields.A[0] is not a GristObjCode; body.records[0]\" +\n                    \".fields.A[0] is not a valid enum value\" } });\n      };\n      await fieldIsNotValid([]);\n      await fieldIsNotValid([\"ZZ\"]);\n    });\n\n    it(\"allows CellValue as a field\", async function() {\n      const { serverUrl, userApi, chimpy } = getCtx();\n      // create sample document for testing\n      const wid = (await userApi.getOrgWorkspaces(\"current\")).find(w => w.name === \"Private\")!.id;\n      const docId = await userApi.newDoc({ name: \"PatchTest\" }, wid);\n      const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;\n      // create record for patching\n      const id = (await axios.post(url, { records: [{}] }, chimpy)).data.records[0].id;\n      const testField = async (A?: CellValue, message?: string) => {\n        const resp = await axios.patch(url, { records: [{ id, fields: { A } }] }, chimpy);\n        assert.equal(resp.status, 200, message ?? `Error for code ${A}`);\n      };\n      await testField(1);\n      await testField(1.2);\n      await testField(\"string\");\n      await testField(true);\n      await testField(false);\n      await testField(null);\n      for (const code of [\n        GristObjCode.List,\n        GristObjCode.Dict,\n        GristObjCode.DateTime,\n        GristObjCode.Date,\n        GristObjCode.Skip,\n        GristObjCode.Censored,\n        GristObjCode.Reference,\n        GristObjCode.ReferenceList,\n        GristObjCode.Exception,\n        GristObjCode.Pending,\n        GristObjCode.Unmarshallable,\n        GristObjCode.Versions,\n      ]) {\n        await testField([code]);\n      }\n    });\n  });\n\n  describe(\"PATCH /docs/{did}/tables/{tid}/data\", function() {\n    it(\"updates records\", async function() {\n      const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      let resp = await axios.patch(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, {\n        id: [1],\n        A: [\"Santa Klaus\"],\n      }, chimpy);\n      assert.equal(resp.status, 200);\n      resp = await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, chimpy);\n      // check that rest of the data is left unchanged\n      assert.deepEqual(resp.data, {\n        id: [1, 2, 3, 4],\n        A: [\"Santa Klaus\", \"Bob\", \"Alice\", \"Felix\"],\n        B: [\"1\", \"11\", \"2\", \"22\"],\n        manualSort: [1, 2, 3, 4],\n      });\n    });\n\n    it(\"throws 400 for invalid row ids\", async function() {\n      const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      // combination of valid and invalid ids fails\n      let resp = await axios.patch(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, {\n        id: [1, 5],\n        A: [\"Alice\", \"Felix\"],\n      }, chimpy);\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /Invalid row id 5/);\n\n      // only invalid ids also fails\n      resp = await axios.patch(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, {\n        id: [10, 5],\n        A: [\"Alice\", \"Felix\"],\n      }, chimpy);\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /Invalid row id 10/);\n\n      // check that changes related to id 1 did not apply\n      assert.deepEqual((await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, chimpy)).data, {\n        id: [1, 2, 3, 4],\n        A: [\"Santa Klaus\", \"Bob\", \"Alice\", \"Felix\"],\n        B: [\"1\", \"11\", \"2\", \"22\"],\n        manualSort: [1, 2, 3, 4],\n      });\n    });\n\n    it(\"throws 400 for invalid column\", async function() {\n      const { serverUrl, chimpy, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      const resp = await axios.patch(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, {\n        id: [1],\n        A: [\"Alice\"],\n        X: [\"mystery\"],\n      }, chimpy);\n      assert.equal(resp.status, 400);\n      assert.match(resp.data.error, /Invalid column \"X\"/);\n\n      // check that changes related to A did not apply\n      assert.deepEqual((await axios.get(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`, chimpy)).data, {\n        id: [1, 2, 3, 4],\n        A: [\"Santa Klaus\", \"Bob\", \"Alice\", \"Felix\"],\n        B: [\"1\", \"11\", \"2\", \"22\"],\n        manualSort: [1, 2, 3, 4],\n      });\n    });\n\n    it(\"respects document permissions\", async function() {\n      const { serverUrl, docIds, chimpy, kiwi, getOrCreateTestDoc } = getCtx();\n      const testDoc = await getOrCreateTestDoc();\n      let resp: AxiosResponse;\n      // as a guest Chimpy cannot edit Bananas\n      resp = await axios.patch(`${serverUrl}/api/docs/${docIds.Bananas}/tables/Table1/data`,\n        { id: [1], A: [\"Alice\"] }, chimpy);\n      assert.equal(resp.status, 403);\n\n      // as not in any group kiwi cannot edit TestDoc\n      resp = await axios.patch(`${serverUrl}/api/docs/${testDoc}/tables/Foo/data`,\n        { id: [1], A: [\"Alice\"] }, kiwi);\n      assert.equal(resp.status, 403);\n    });\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiReverseProxy.ts",
    "content": "/**\n * Tests for DocApi functionality behind a reverse proxy.\n * These tests verify that document comparison works correctly when:\n * - APP_HOME_INTERNAL_URL is set (should succeed)\n * - APP_HOME_INTERNAL_URL is not set (should fail with expected error)\n *\n * These tests require Redis and use TestServerReverseProxy to simulate\n * a reverse proxy in front of home and doc servers.\n */\n\nimport { UserAPIImpl } from \"app/common/UserAPI\";\nimport { getAvailablePort } from \"app/server/lib/serverUtils\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport { ORG_NAME } from \"test/server/lib/docapi/helpers\";\nimport { prepareDatabase } from \"test/server/lib/helpers/PrepareDatabase\";\nimport { prepareFilesystemDirectoryForTests } from \"test/server/lib/helpers/PrepareFilesystemDirectoryForTests\";\nimport { TestServer, TestServerReverseProxy } from \"test/server/lib/helpers/TestServer\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { tmpdir } from \"os\";\nimport * as path from \"path\";\n\nimport { AxiosRequestConfig } from \"axios\";\nimport { assert } from \"chai\";\nimport FormData from \"form-data\";\nimport fetch from \"node-fetch\";\nimport { createClient } from \"redis\";\n\n// A testDir of the form grist_test_{USER}_{SERVER_NAME}\nconst username = process.env.USER || \"nobody\";\nconst tmpDir = path.join(tmpdir(), `grist_test_${username}_docapi_reverse_proxy`);\nlet dataDir: string;\nlet oldEnv: testUtils.EnvironmentSnapshot;\n\nlet homeUrl: string;\nlet extraHeadersForConfig: { [key: string]: any };\n\ndescribe(\"DocApiReverseProxy\", function() {\n  this.timeout(60000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  // Skip all tests if Redis is not available\n  before(function() {\n    if (!process.env.TEST_REDIS_URL) {\n      this.skip();\n    }\n  });\n\n  before(async function() {\n    await prepareFilesystemDirectoryForTests(tmpDir);\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    await prepareDatabase(tmpDir);\n    dataDir = path.join(tmpDir, \"data\");\n  });\n\n  after(async function() {\n    oldEnv.restore();\n  });\n\n  async function flushAllRedis() {\n    if (process.env.TEST_REDIS_URL) {\n      const cli = createClient(process.env.TEST_REDIS_URL);\n      await cli.flushdbAsync();\n      await cli.quitAsync();\n    }\n  }\n\n  function makeConfig(user: string): AxiosRequestConfig {\n    const originalConfig = configForUser(user);\n    return {\n      ...originalConfig,\n      headers: {\n        ...originalConfig.headers,\n        ...extraHeadersForConfig,\n      },\n    };\n  }\n\n  function makeUserApi(org: string, user: string): UserAPIImpl {\n    return new UserAPIImpl(`${homeUrl}/o/${org}`, {\n      headers: makeConfig(user).headers as Record<string, string>,\n      fetch: fetch as unknown as typeof globalThis.fetch,\n      newFormData: () => new FormData() as any,\n    });\n  }\n\n  async function setupServersWithProxy(\n    testSuiteName: string,\n    { withAppHomeInternalUrl }: { withAppHomeInternalUrl: boolean },\n  ) {\n    const proxy = await TestServerReverseProxy.build();\n\n    const homePort = await getAvailablePort(parseInt(process.env.GET_AVAILABLE_PORT_START || \"8080\", 10));\n    const home = new TestServer(\"home\", homePort, tmpDir, testSuiteName);\n\n    const additionalEnvConfiguration = {\n      GRIST_DATA_DIR: dataDir,\n      APP_HOME_URL: proxy.serverUrl,\n      GRIST_ORG_IN_PATH: \"true\",\n      GRIST_SINGLE_PORT: \"0\",\n      APP_HOME_INTERNAL_URL: withAppHomeInternalUrl ? home.serverUrl : \"\",\n      GRIST_EXTERNAL_ATTACHMENTS_MODE: \"test\",\n    };\n\n    await home.start(home.serverUrl, additionalEnvConfiguration);\n\n    const docPort = await getAvailablePort(parseInt(process.env.GET_AVAILABLE_PORT_START || \"8080\", 10));\n    const docs = new TestServer(\"docs\", docPort, tmpDir, testSuiteName);\n    await docs.start(home.serverUrl, {\n      ...additionalEnvConfiguration,\n      APP_DOC_URL: `${proxy.serverUrl}/dw/dw1`,\n      APP_DOC_INTERNAL_URL: docs.serverUrl,\n    });\n\n    proxy.requireFromOutsideHeader();\n\n    proxy.start(home, docs);\n\n    homeUrl = proxy.serverUrl;\n    extraHeadersForConfig = {\n      Origin: proxy.serverUrl,\n      ...TestServerReverseProxy.FROM_OUTSIDE_HEADER,\n    };\n\n    return { proxy, home, docs };\n  }\n\n  async function tearDown(proxy: TestServerReverseProxy, servers: TestServer[]) {\n    proxy.stop();\n    for (const server of servers) {\n      await server.stop();\n    }\n    await flushAllRedis();\n  }\n\n  async function testCompareDocs() {\n    const chimpyApi = makeUserApi(ORG_NAME, \"chimpy\");\n    const ws1 = (await chimpyApi.getOrgWorkspaces(\"current\"))[0].id;\n    const docId1 = await chimpyApi.newDoc({ name: \"testdoc1\" }, ws1);\n    const docId2 = await chimpyApi.newDoc({ name: \"testdoc2\" }, ws1);\n    const doc1 = chimpyApi.getDocAPI(docId1);\n\n    return doc1.compareDoc(docId2);\n  }\n\n  describe(\"specific tests with APP_HOME_INTERNAL_URL\", function() {\n    let proxy: TestServerReverseProxy;\n    let home: TestServer;\n    let docs: TestServer;\n\n    before(async function() {\n      ({ proxy, home, docs } = await setupServersWithProxy(\n        \"behind-proxy-with-apphomeinternalurl\",\n        { withAppHomeInternalUrl: true },\n      ));\n    });\n\n    after(async function() {\n      await tearDown(proxy, [home, docs]);\n    });\n\n    it(\"should succeed to compare docs\", async function() {\n      const res = await testCompareDocs();\n      assert.exists(res);\n    });\n  });\n\n  describe(\"specific tests without APP_HOME_INTERNAL_URL\", function() {\n    let proxy: TestServerReverseProxy;\n    let home: TestServer;\n    let docs: TestServer;\n\n    before(async function() {\n      ({ proxy, home, docs } = await setupServersWithProxy(\n        \"behind-proxy-without-apphomeinternalurl\",\n        { withAppHomeInternalUrl: false },\n      ));\n    });\n\n    after(async function() {\n      await tearDown(proxy, [home, docs]);\n    });\n\n    it(\"should fail to compare docs\", async function() {\n      const promise = testCompareDocs();\n      await assert.isRejected(promise, /TestServerReverseProxy: called public URL/);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiSql.ts",
    "content": "/**\n * Tests for SQL endpoint:\n * - GET /docs/{did}/sql\n * - POST /docs/{did}/sql\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { addAllScenarios, TestContext } from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\n\ndescribe(\"DocApiSql\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addSqlTests, \"docapi-sql\");\n});\n\nfunction addSqlTests(getCtx: () => TestContext) {\n  it(\"GET /docs/{did}/sql is functional\", async function() {\n    const { homeUrl, docIds, chimpy } = getCtx();\n    const query = \"select+*+from+Table1+order+by+id\";\n    const resp = await axios.get(`${homeUrl}/api/docs/${docIds.Timesheets}/sql?q=${query}`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, {\n      statement: \"select * from Table1 order by id\",\n      records: [\n        {\n          fields: {\n            id: 1,\n            manualSort: 1,\n            A: \"hello\",\n            B: \"\",\n            C: \"\",\n            D: null,\n            E: \"HELLO\",\n          },\n        },\n        {\n          fields: { id: 2, manualSort: 2, A: \"\", B: \"world\", C: \"\", D: null, E: \"\" },\n        },\n        {\n          fields: { id: 3, manualSort: 3, A: \"\", B: \"\", C: \"\", D: null, E: \"\" },\n        },\n        {\n          fields: { id: 4, manualSort: 4, A: \"\", B: \"\", C: \"\", D: null, E: \"\" },\n        },\n      ],\n    });\n  });\n\n  it(\"POST /docs/{did}/sql is functional\", async function() {\n    const { homeUrl, docIds, chimpy } = getCtx();\n    let resp = await axios.post(\n      `${homeUrl}/api/docs/${docIds.Timesheets}/sql`,\n      { sql: \"select A from Table1 where id = ?\", args: [1] },\n      chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data.records, [{\n      fields: {\n        A: \"hello\",\n      },\n    }]);\n\n    resp = await axios.post(\n      `${homeUrl}/api/docs/${docIds.Timesheets}/sql`,\n      { nosql: \"select A from Table1 where id = ?\", args: [1] },\n      chimpy);\n    assert.equal(resp.status, 400);\n    assert.deepEqual(resp.data, {\n      error: \"Invalid payload\",\n      details: { userError: \"Error: body.sql is missing\" },\n    });\n  });\n\n  it(\"POST /docs/{did}/sql has access control\", async function() {\n    const { homeUrl, docIds, chimpy, kiwi, flushAuth } = getCtx();\n    // Check non-viewer doesn't have access.\n    const url = `${homeUrl}/api/docs/${docIds.Timesheets}/sql`;\n    const query = { sql: \"select A from Table1 where id = ?\", args: [1] };\n    let resp = await axios.post(url, query, kiwi);\n    assert.equal(resp.status, 403);\n    assert.deepEqual(resp.data, {\n      error: \"No view access\",\n    });\n\n    try {\n      // Check a viewer would have access.\n      const delta = {\n        users: { \"kiwi@getgrist.com\": \"viewers\" },\n      };\n      await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, { delta }, chimpy);\n      await flushAuth();\n      resp = await axios.post(url, query, kiwi);\n      assert.equal(resp.status, 200);\n\n      // Check a viewer would not have access if there is some private material.\n      await axios.post(\n        `${homeUrl}/api/docs/${docIds.Timesheets}/apply`, [\n          [\"AddTable\", \"TablePrivate\", [{ id: \"A\", type: \"Int\" }]],\n          [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"TablePrivate\", colIds: \"*\" }],\n          [\"AddRecord\", \"_grist_ACLRules\", null, {\n            resource: -1, aclFormula: \"\", permissionsText: \"none\",\n          }],\n        ], chimpy);\n      resp = await axios.post(url, query, kiwi);\n      assert.equal(resp.status, 403);\n    } finally {\n      // Remove extra viewer; remove extra table.\n      const delta = {\n        users: { \"kiwi@getgrist.com\": null },\n      };\n      await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, { delta }, chimpy);\n      await flushAuth();\n      await axios.post(\n        `${homeUrl}/api/docs/${docIds.Timesheets}/apply`, [\n          [\"RemoveTable\", \"TablePrivate\"],\n        ], chimpy);\n    }\n  });\n\n  it(\"POST /docs/{did}/sql accepts only selects\", async function() {\n    const { homeUrl, docIds, chimpy } = getCtx();\n    async function check(accept: boolean, sql: string, ...args: any[]) {\n      const resp = await axios.post(\n        `${homeUrl}/api/docs/${docIds.Timesheets}/sql`,\n        { sql, args },\n        chimpy);\n      if (accept) {\n        assert.equal(resp.status, 200);\n      } else {\n        assert.equal(resp.status, 400);\n      }\n      return resp.data;\n    }\n    await check(true, \"select * from Table1\");\n    await check(true, \"  SeLeCT * from Table1\");\n    await check(true, \"with results as (select 1) select * from results\");\n\n    // rejected quickly since no select\n    await check(false, \"delete from Table1\");\n    await check(false, \"\");\n\n    // rejected because deletes/updates/... can't be nested within a select\n    await check(false, \"delete from Table1 where id in (select id from Table1) and 'selecty' = 'selecty'\");\n    await check(false, \"update Table1 set A = ? where 'selecty' = 'selecty'\", \"test\");\n    await check(false, \"pragma data_store_directory = 'selecty'\");\n    await check(false, \"create table selecty(x, y)\");\n    await check(false, \"attach database 'selecty' AS test\");\n\n    // rejected because \";\" can't be nested\n    await check(false, \"select * from Table1; delete from Table1\");\n\n    // Of course, we can get out of the wrapping select, but we can't\n    // add on more statements. For example, the following runs with no\n    // trouble - but only the SELECT part. The DELETE is discarded.\n    // (node-sqlite3 doesn't expose enough to give an error message for\n    // this, though we could extend it).\n    await check(true, \"select * from Table1); delete from Table1 where id in (select id from Table1\");\n    const { records } = await check(true, \"select * from Table1\");\n    // Double-check the deletion didn't happen.\n    assert.lengthOf(records, 4);\n  });\n\n  it(\"POST /docs/{did}/sql timeout is effective\", async function() {\n    const { homeUrl, docIds, chimpy } = getCtx();\n    const slowQuery = \"WITH RECURSIVE r(i) AS (VALUES(0) \" +\n      \"UNION ALL SELECT i FROM r  LIMIT 1000000) \" +\n      \"SELECT i FROM r WHERE i = 1\";\n    const resp = await axios.post(\n      `${homeUrl}/api/docs/${docIds.Timesheets}/sql`,\n      { sql: slowQuery, timeout: 10 },\n      chimpy);\n    assert.equal(resp.status, 400);\n    assert.match(resp.data.error, /database interrupt/);\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiTables.ts",
    "content": "/**\n * Tests for table operations:\n * - GET /docs/{did}/tables\n * - POST /docs/{did}/tables\n * - PATCH /docs/{did}/tables\n * - POST /docs/{did}/tables/{tid}/columns\n * - PATCH /docs/{did}/tables/{tid}/columns\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { TableMetadata } from \"app/plugin/DocApiTypes\";\nimport { addAllScenarios, TestContext } from \"test/server/lib/docapi/helpers\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport axios from \"axios\";\nimport { assert } from \"chai\";\n\ndescribe(\"DocApiTables\", function() {\n  this.timeout(30000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addTablesTests, \"docapi-tables\");\n});\n\nfunction addTablesTests(getCtx: () => TestContext) {\n  it(\"GET/POST/PATCH /docs/{did}/tables and /columns\", async function() {\n    const { serverUrl, docIds, chimpy } = getCtx();\n    // POST /tables: Create new tables\n    let resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables`, {\n      tables: [\n        { columns: [{}] },  // The minimal allowed request\n        { id: \"\", columns: [{ id: \"\" }] },\n        { id: \"NewTable1\", columns: [{ id: \"NewCol1\", fields: {} }] },\n        {\n          id: \"NewTable2\",\n          columns: [\n            { id: \"NewCol2\", fields: { label: \"Label2\" } },\n            { id: \"NewCol3\", fields: { label: \"Label3\" } },\n            { id: \"NewCol3\", fields: { label: \"Label3\" } },  // Duplicate column id\n          ],\n        },\n        {\n          id: \"NewTable2\",   // Create a table with duplicate tableId\n          columns: [\n            { id: \"NewCol2\", fields: { label: \"Label2\" } },\n            { id: \"NewCol3\", fields: { label: \"Label3\" } },\n          ],\n        },\n      ],\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, {\n      tables: [\n        { id: \"Table2\" },\n        { id: \"Table3\" },\n        { id: \"NewTable1\" },\n        { id: \"NewTable2\" },\n        { id: \"NewTable2_2\" },  // duplicated tableId ends with _2\n      ],\n    });\n\n    // POST /columns: Create new columns\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/NewTable2/columns`, {\n      columns: [\n        {},\n        { id: \"\" },\n        { id: \"NewCol4\", fields: {} },\n        { id: \"NewCol4\", fields: {} },  // Create a column with duplicate colId\n        { id: \"NewCol5\", fields: { label: \"Label5\" } },\n      ],\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, {\n      columns: [\n        { id: \"A\" },\n        { id: \"B\" },\n        { id: \"NewCol4\" },\n        { id: \"NewCol4_2\" },  // duplicated colId ends with _2\n        { id: \"NewCol5\" },\n      ],\n    });\n\n    // POST /columns: Create new columns using tableRef in URL\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/5/columns`, {\n      columns: [{ id: \"NewCol6\", fields: {} }],\n    }, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data, { columns: [{ id: \"NewCol6\" }] });\n\n    // POST /columns to invalid table ID\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/NoSuchTable/columns`,\n      { columns: [{}] }, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: 'Table not found \"NoSuchTable\"' });\n\n    // PATCH /tables: Modify a table. This is pretty much only good for renaming tables.\n    resp = await axios.patch(`${serverUrl}/api/docs/${docIds.Timesheets}/tables`, {\n      tables: [\n        { id: \"Table3\", fields: { tableId: \"Table3_Renamed\" } },\n      ],\n    }, chimpy);\n    assert.equal(resp.status, 200);\n\n    // Repeat the same operation to check that it gives 404 if the table doesn't exist.\n    resp = await axios.patch(`${serverUrl}/api/docs/${docIds.Timesheets}/tables`, {\n      tables: [\n        { id: \"Table3\", fields: { tableId: \"Table3_Renamed\" } },\n      ],\n    }, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: 'Table not found \"Table3\"' });\n\n    // PATCH /columns: Modify a column.\n    resp = await axios.patch(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table2/columns`, {\n      columns: [\n        { id: \"A\", fields: { colId: \"A_Renamed\" } },\n      ],\n    }, chimpy);\n    assert.equal(resp.status, 200);\n\n    // Repeat the same operation to check that it gives 404 if the column doesn't exist.\n    resp = await axios.patch(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table2/columns`, {\n      columns: [\n        { id: \"A\", fields: { colId: \"A_Renamed\" } },\n      ],\n    }, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: 'Column not found \"A\"' });\n\n    // Repeat the same operation to check that it gives 404 if the table doesn't exist.\n    resp = await axios.patch(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table222/columns`, {\n      columns: [\n        { id: \"A\", fields: { colId: \"A_Renamed\" } },\n      ],\n    }, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: 'Table not found \"Table222\"' });\n\n    // Rename NewTable2.A -> B to test the name conflict resolution.\n    resp = await axios.patch(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/NewTable2/columns`, {\n      columns: [\n        { id: \"A\", fields: { colId: \"B\" } },\n      ],\n    }, chimpy);\n    assert.equal(resp.status, 200);\n\n    // Hide NewTable2.NewCol5 and NewTable2_2 with ACL\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/apply`, [\n      [\"AddRecord\", \"_grist_ACLResources\", -1, { tableId: \"NewTable2\", colIds: \"NewCol5\" }],\n      [\"AddRecord\", \"_grist_ACLResources\", -2, { tableId: \"NewTable2_2\", colIds: \"*\" }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        resource: -1, aclFormula: \"\", permissionsText: \"-R\",\n      }],\n      [\"AddRecord\", \"_grist_ACLRules\", null, {\n        // Don't use permissionsText: 'none' here because we need S permission to delete the table at the end.\n        resource: -2, aclFormula: \"\", permissionsText: \"-R\",\n      }],\n    ], chimpy);\n    assert.equal(resp.status, 200);\n\n    // GET /tables: Check that the tables were created and renamed.\n    resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables`, chimpy);\n    assert.equal(resp.status, 200);\n    assert.deepEqual(resp.data,\n      {\n        tables: [\n          {\n            id: \"Table1\",\n            fields: {\n              rawViewSectionRef: 2,\n              recordCardViewSectionRef: 3,\n              primaryViewId: 1,\n              onDemand: false,\n              summarySourceTable: 0,\n              tableRef: 1,\n            },\n          },\n          // New tables start here\n          {\n            id: \"Table2\",\n            fields: {\n              rawViewSectionRef: 5,\n              recordCardViewSectionRef: 6,\n              primaryViewId: 2,\n              onDemand: false,\n              summarySourceTable: 0,\n              tableRef: 2,\n            },\n          },\n          {\n            id: \"Table3_Renamed\",\n            fields: {\n              rawViewSectionRef: 8,\n              recordCardViewSectionRef: 9,\n              primaryViewId: 3,\n              onDemand: false,\n              summarySourceTable: 0,\n              tableRef: 3,\n            },\n          },\n          {\n            id: \"NewTable1\",\n            fields: {\n              rawViewSectionRef: 11,\n              recordCardViewSectionRef: 12,\n              primaryViewId: 4,\n              onDemand: false,\n              summarySourceTable: 0,\n              tableRef: 4,\n            },\n          },\n          {\n            id: \"NewTable2\",\n            fields: {\n              rawViewSectionRef: 14,\n              recordCardViewSectionRef: 15,\n              primaryViewId: 5,\n              onDemand: false,\n              summarySourceTable: 0,\n              tableRef: 5,\n            },\n          },\n          // NewTable2_2 is hidden by ACL\n        ],\n      },\n    );\n\n    // GET /tables: Check the created columns using the `expand` parameter.\n    const url = new URL(`${serverUrl}/api/docs/${docIds.Timesheets}/tables`);\n    url.searchParams.set(\"expand\", \"column\");\n    const tablesWithColsResp = await axios.get(url.href, chimpy);\n    assert.equal(tablesWithColsResp.status, 200);\n\n    async function checkColumns(tableId: string, expected: { colId: string, label: string }[]) {\n      const table = tablesWithColsResp.data.tables.find((t: TableMetadata) => t.id === tableId);\n      const actual = table.columns.map((c: any) => ({\n        colId: c.id,\n        label: c.fields.label,\n      }));\n      assert.deepEqual(actual, expected);\n    }\n\n    await checkColumns(\"Table2\", [\n      { colId: \"A_Renamed\", label: \"A\" },\n    ]);\n    await checkColumns(\"Table3_Renamed\", [\n      { colId: \"A\", label: \"A\" },\n    ]);\n    await checkColumns(\"NewTable1\", [\n      { colId: \"NewCol1\", label: \"NewCol1\" },\n    ]);\n    await checkColumns(\"NewTable2\", [\n      { colId: \"NewCol2\", label: \"Label2\" },\n      { colId: \"NewCol3\", label: \"Label3\" },\n      { colId: \"NewCol3_2\", label: \"Label3\" },\n      { colId: \"B2\", label: \"A\" },  // Result of renaming A -> B\n      { colId: \"B\", label: \"B\" },\n      { colId: \"NewCol4\", label: \"NewCol4\" },\n      { colId: \"NewCol4_2\", label: \"NewCol4_2\" },\n      // NewCol5 is hidden by ACL\n      { colId: \"NewCol6\", label: \"NewCol6\" },\n    ]);\n\n    // NewTable2_2 is hidden by ACL.\n    assert.isUndefined(tablesWithColsResp.data.tables.NewTable2_2);\n    resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/NewTable2_2/columns`, chimpy);\n    assert.equal(resp.status, 404);\n    assert.deepEqual(resp.data, { error: 'Table not found \"NewTable2_2\"' });\n\n    // Clean up the created tables for other tests\n    // TODO add a DELETE endpoint for /tables and /columns. Probably best to do alongside DELETE /records.\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_Tables/data/delete`,\n      [2, 3, 4, 5, 6], chimpy);\n    assert.equal(resp.status, 200);\n\n    // Despite deleting tables (even in a more official way than above),\n    // there are rules lingering relating to them. TODO: look into this.\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_ACLRules/data/delete`,\n      [2, 3], chimpy);\n    assert.equal(resp.status, 200);\n    resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_ACLResources/data/delete`,\n      [2, 3], chimpy);\n    assert.equal(resp.status, 200);\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/DocApiWebhooks.ts",
    "content": "/**\n * Tests for webhook operations:\n * - GET /docs/{did}/webhooks\n * - POST /docs/{did}/webhooks\n * - PATCH /docs/{did}/webhooks/{wid}\n * - DELETE /docs/{did}/webhooks/{wid}\n * - DELETE /docs/{did}/webhooks/queue\n * - POST /docs/{did}/tables/{tid}/_subscribe (legacy)\n * - POST /docs/{did}/tables/{tid}/_unsubscribe (legacy)\n *\n * Tests run in multiple server configurations:\n * - Merged server (home + docs in one process)\n * - Separated servers (home + docworker, requires Redis)\n * - Direct to docworker (requires Redis)\n */\n\nimport { delay } from \"app/common/delay\";\nimport { arrayRepeat } from \"app/common/gutil\";\nimport { WebhookSummary } from \"app/common/Triggers\";\nimport { UserAPI, UserAPIImpl } from \"app/common/UserAPI\";\nimport { DocAPI } from \"app/common/UserAPI\";\nimport {\n  docApiUsagePeriods,\n  docPeriodicApiUsageKey,\n  getDocApiUsageKeysToIncr,\n} from \"app/server/lib/DocApi\";\nimport { delayAbort } from \"app/server/lib/serverUtils\";\nimport { testDailyApiLimitFeatures } from \"test/gen-server/seed\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport { serveSomething, Serving } from \"test/server/customUtil\";\nimport { addAllScenarios, TestContext } from \"test/server/lib/docapi/helpers\";\nimport { signal } from \"test/server/lib/helpers/Signal\";\nimport * as testUtils from \"test/server/testUtils\";\nimport { waitForIt } from \"test/server/wait\";\n\nimport { Agent } from \"http\";\n\nimport axios, { AxiosRequestConfig, AxiosResponse } from \"axios\";\nimport { assert } from \"chai\";\nimport * as express from \"express\";\nimport FormData from \"form-data\";\nimport * as _ from \"lodash\";\nimport pick from \"lodash/pick\";\nimport LRUCache from \"lru-cache\";\nimport * as moment from \"moment-timezone\";\nimport { AbortController } from \"node-abort-controller\";\nimport fetch from \"node-fetch\";\nimport { createClient, RedisClient } from \"redis\";\n\nconst webhooksTestPort = Number(process.env.WEBHOOK_TEST_PORT || 34365);\nconst axiosInstance = axios.create({\n  // FIXME: disable keepAlive, otherwise since axios in version 1.13.2\n  // we have a \"socket hang up\" error (regression in axios?)\n  // Maybe related to the use of agentkeepalive (present in yarn.lock)\n  httpAgent: new Agent({ keepAlive: false }),\n});\n\nasync function getWorkspaceId(api: UserAPIImpl, name: string) {\n  const workspaces = await api.getOrgWorkspaces(\"current\");\n  return workspaces.find(w => w.name === name)!.id;\n}\n\ninterface WebhookRequests {\n  \"add\": object[][];\n  \"update\": object[][];\n  \"add,update\": object[][];\n}\n\ninterface WebhookSubscription {\n  unsubscribeKey: string;\n  webhookId: string;\n}\n\ndescribe(\"DocApiWebhooks\", function() {\n  this.timeout(60000);\n  testUtils.setTmpLogLevel(\"error\");\n\n  addAllScenarios(addWebhooksTests, \"docapi-webhooks\", {\n    extraEnv: {\n      ALLOWED_WEBHOOK_DOMAINS: `example.com,localhost:${webhooksTestPort}`,\n    },\n  });\n});\n\nfunction addWebhooksTests(getCtx: () => TestContext) {\n  function makeUserApi(org: string, username: string): UserAPI {\n    const { homeUrl } = getCtx();\n    const config = configForUser(username);\n    return new UserAPIImpl(`${homeUrl}/o/${org}`, {\n      headers: config.headers as Record<string, string>,\n      fetch: fetch as unknown as typeof globalThis.fetch,\n      newFormData: () => new FormData() as any,\n    });\n  }\n\n  /**\n   * Tests for basic webhook CRUD operations.\n   */\n  describe(\"webhooksRelatedEndpoints\", function() {\n    let serving: Serving;\n\n    before(async function() {\n      serving = await serveSomething((app: express.Application) => {\n        app.use(express.json());\n        app.post(\"/200\", ({ body }: express.Request, res: express.Response) => {\n          res.sendStatus(200);\n          res.end();\n        });\n      }, webhooksTestPort);\n    });\n\n    after(async function() {\n      await serving.shutdown();\n    });\n\n    async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) {\n      const { serverUrl, docIds, chimpy } = getCtx();\n      const resp = await axiosInstance.post(\n        `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`,\n        requestBody, chimpy,\n      );\n      assert.equal(resp.status, status);\n      for (const error of errors) {\n        assert.match(resp.data.details?.userError || resp.data.error, error);\n      }\n    }\n\n    async function postWebhookCheck(requestBody: any, status: number, ...errors: RegExp[]) {\n      const { serverUrl, docIds, chimpy } = getCtx();\n      const resp = await axiosInstance.post(\n        `${serverUrl}/api/docs/${docIds.Timesheets}/webhooks`,\n        requestBody, chimpy,\n      );\n      assert.equal(resp.status, status);\n      for (const error of errors) {\n        assert.match(resp.data.details?.userError || resp.data.error, error);\n      }\n      return resp.data;\n    }\n\n    async function userCheck(user: AxiosRequestConfig, requestBody: any, status: number, responseBody: any) {\n      const { serverUrl, docIds } = getCtx();\n      const resp = await axiosInstance.post(\n        `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_unsubscribe`,\n        requestBody, user,\n      );\n      assert.equal(resp.status, status);\n      if (status !== 200) {\n        responseBody = { error: responseBody };\n      }\n      assert.deepEqual(resp.data, responseBody);\n    }\n\n    async function userDeleteCheck(user: AxiosRequestConfig, webhookId: string, status: number, ...errors: RegExp[]) {\n      const { serverUrl, docIds } = getCtx();\n      const resp = await axiosInstance.delete(\n        `${serverUrl}/api/docs/${docIds.Timesheets}/webhooks/${webhookId}`,\n        user,\n      );\n      assert.equal(resp.status, status);\n      for (const error of errors) {\n        assert.match(resp.data.details?.userError || resp.data.error, error);\n      }\n    }\n\n    interface SubscriptionInfo {\n      unsubscribeKey: string;\n      webhookId: string;\n    }\n\n    async function subscribeWebhook(): Promise<SubscriptionInfo> {\n      const { serverUrl, docIds, chimpy } = getCtx();\n      const subscribeResponse = await axiosInstance.post(\n        `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`,\n        { eventTypes: [\"add\"], url: \"https://example.com\" }, chimpy,\n      );\n      assert.equal(subscribeResponse.status, 200);\n      const { unsubscribeKey, webhookId } = subscribeResponse.data;\n      return { unsubscribeKey, webhookId };\n    }\n\n    async function getRegisteredWebhooks() {\n      const { serverUrl, docIds, chimpy } = getCtx();\n      const response = await axiosInstance.get(\n        `${serverUrl}/api/docs/${docIds.Timesheets}/webhooks`, chimpy);\n      return response.data.webhooks;\n    }\n\n    async function deleteWebhookCheck(webhookId: any) {\n      const { serverUrl, docIds, chimpy } = getCtx();\n      const response = await axiosInstance.delete(\n        `${serverUrl}/api/docs/${docIds.Timesheets}/webhooks/${webhookId}`, chimpy);\n      return response.data;\n    }\n\n    it(\"GET /docs/{did}/webhooks retrieves a list of webhooks\", async function() {\n      const { serverUrl, docIds, chimpy } = getCtx();\n      const registerResponse = await postWebhookCheck({\n        webhooks: [{ fields: { tableId: \"Table1\", eventTypes: [\"add\"], url: \"https://example.com\" } }],\n      }, 200);\n      const resp = await axiosInstance.get(`${serverUrl}/api/docs/${docIds.Timesheets}/webhooks`, chimpy);\n      try {\n        assert.equal(resp.status, 200);\n        assert.isAtLeast(resp.data.webhooks.length, 1);\n        assert.containsAllKeys(resp.data.webhooks[0], [\"id\", \"fields\"]);\n        assert.containsAllKeys(resp.data.webhooks[0].fields,\n          [\"enabled\", \"isReadyColumn\", \"memo\", \"name\", \"tableId\", \"eventTypes\", \"url\"]);\n      } finally {\n        await deleteWebhookCheck(registerResponse.webhooks[0].id);\n      }\n    });\n\n    it(\"POST /docs/{did}/tables/{tid}/_subscribe validates inputs\", async function() {\n      await oldSubscribeCheck({}, 400, /eventTypes is missing/);\n      await oldSubscribeCheck({ eventTypes: 0 }, 400, /url is missing/, /eventTypes is not an array/);\n      await oldSubscribeCheck({ eventTypes: [] }, 400, /url is missing/);\n      await oldSubscribeCheck({ eventTypes: [], url: \"https://example.com\" }, 400, /eventTypes must be a non-empty array/);\n      await oldSubscribeCheck({ eventTypes: [\"foo\"], url: \"https://example.com\" }, 400, /eventTypes\\[0] is none of \"add\", \"update\"/);\n      await oldSubscribeCheck({ eventTypes: [\"add\"] }, 400, /url is missing/);\n      await oldSubscribeCheck({ eventTypes: [\"add\"], url: \"https://evil.com\" }, 403, /Provided url is forbidden/);\n      await oldSubscribeCheck({ eventTypes: [\"add\"], url: \"http://example.com\" }, 403, /Provided url is forbidden/);\n      await oldSubscribeCheck({ eventTypes: [\"add\"], url: \"https://example.com\", isReadyColumn: \"bar\" }, 404, /Column not found \"bar\"/);\n    });\n\n    it(\"POST /docs/{did}/webhooks validates inputs\", async function() {\n      await postWebhookCheck({ webhooks: [{ fields: { tableId: \"Table1\" } }] }, 400,\n        /eventTypes is missing/);\n      await postWebhookCheck({ webhooks: [{ fields: { tableId: \"Table1\", eventTypes: 0 } }] }, 400,\n        /url is missing/, /eventTypes is not an array/);\n      await postWebhookCheck({ webhooks: [{ fields: { tableId: \"Table1\", eventTypes: [] } }] },\n        400, /url is missing/);\n      await postWebhookCheck({ webhooks: [{ fields: { tableId: \"Table1\", eventTypes: [],\n        url: \"https://example.com\" } }] },\n      400, /eventTypes must be a non-empty array/);\n      await postWebhookCheck({ webhooks: [{ fields: { tableId: \"Table1\", eventTypes: [\"foo\"],\n        url: \"https://example.com\" } }] },\n      400, /eventTypes\\[0] is none of \"add\", \"update\"/);\n      await postWebhookCheck({ webhooks: [{ fields: { tableId: \"Table1\", eventTypes: [\"add\"] } }] },\n        400, /url is missing/);\n      await postWebhookCheck({ webhooks: [{ fields: { tableId: \"Table1\", eventTypes: [\"add\"],\n        url: \"https://evil.com\" } }] },\n      403, /Provided url is forbidden/);\n      await postWebhookCheck({ webhooks: [{ fields: { tableId: \"Table1\", eventTypes: [\"add\"],\n        url: \"http://example.com\" } }] },\n      403, /Provided url is forbidden/);\n      await postWebhookCheck({ webhooks: [{ fields: { tableId: \"Table1\", eventTypes: [\"add\"],\n        url: \"https://example.com\", isReadyColumn: \"bar\" } }] },\n      404, /Column not found \"bar\"/);\n      await postWebhookCheck({ webhooks: [{ fields: { eventTypes: [\"add\"], url: \"https://example.com\" } }] },\n        400, /tableId is missing/);\n      await postWebhookCheck({}, 400, /webhooks is missing/);\n      await postWebhookCheck({\n        webhooks: [{\n          fields: {\n            tableId: \"Table1\", eventTypes: [\"update\"], watchedColIds: [\"notExisting\"],\n            url: `${serving.url}/200`,\n          },\n        }],\n      },\n      404, /Column not found \"notExisting\"/);\n    });\n\n    it(\"POST /docs/{did}/tables/{tid}/_unsubscribe validates inputs for owners\", async function() {\n      const { chimpy } = getCtx();\n      const { webhookId } = await subscribeWebhook();\n      const check = userCheck.bind(null, chimpy);\n\n      await check({ webhookId: \"foo\" }, 404, `Webhook not found \"foo\"`);\n      await check({}, 404, `Webhook not found \"\"`);\n      await check({ webhookId }, 200, { success: true });\n      await check({ webhookId }, 404, `Webhook not found \"${webhookId}\"`);\n    });\n\n    it(\"DELETE /docs/{did}/tables/webhooks validates inputs for owners\", async function() {\n      const { docIds, chimpy } = getCtx();\n      const { webhookId } = await subscribeWebhook();\n      const check = userDeleteCheck.bind(null, chimpy);\n\n      await check(\"foo\", 404, /Webhook not found \"foo\"/);\n      await check(\"\", 404, /not found/, new RegExp(`/api/docs/${docIds.Timesheets}/webhooks/`));\n      await check(webhookId, 200);\n      await check(webhookId, 404, new RegExp(`Webhook not found \"${webhookId}\"`));\n    });\n\n    it(\"POST /docs/{did}/webhooks is adding new webhook to table \" +\n      \"and DELETE /docs/{did}/webhooks/{wid} is removing new webhook from table\", async function() {\n      const registeredWebhook = await postWebhookCheck({\n        webhooks: [{ fields: { tableId: \"Table1\", eventTypes: [\"add\"], url: \"https://example.com\" } }],\n      }, 200);\n      let webhookList = await getRegisteredWebhooks();\n      assert.equal(webhookList.length, 1);\n      assert.equal(webhookList[0].id, registeredWebhook.webhooks[0].id);\n      await deleteWebhookCheck(registeredWebhook.webhooks[0].id);\n      webhookList = await getRegisteredWebhooks();\n      assert.equal(webhookList.length, 0);\n    });\n\n    it(\"POST /docs/{did}/webhooks is adding new webhook should be able to add many webhooks at once\", async function() {\n      const response = await postWebhookCheck(\n        {\n          webhooks: [\n            { fields: { tableId: \"Table1\", eventTypes: [\"add\"], url: \"https://example.com\" } },\n            { fields: { tableId: \"Table1\", eventTypes: [\"add\"], url: \"https://example.com/2\" } },\n            { fields: { tableId: \"Table1\", eventTypes: [\"add\"], url: \"https://example.com/3\" } },\n          ] }, 200);\n      assert.equal(response.webhooks.length, 3);\n      const webhookList = await getRegisteredWebhooks();\n      assert.equal(webhookList.length, 3);\n    });\n\n    it(\"POST /docs/{did}/tables/{tid}/_unsubscribe validates inputs for editors\", async function() {\n      const { homeUrl, docIds, flushAuth, chimpy, kiwi } = getCtx();\n      const subscribeResponse = await subscribeWebhook();\n\n      const delta = {\n        users: { \"kiwi@getgrist.com\": \"editors\" as string | null },\n      };\n      let accessResp = await axiosInstance.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, { delta }, chimpy);\n      await flushAuth();\n      assert.equal(accessResp.status, 200);\n\n      const check = userCheck.bind(null, kiwi);\n\n      await check({ webhookId: \"foo\" }, 404, `Webhook not found \"foo\"`);\n      await check({ webhookId: subscribeResponse.webhookId }, 400, \"Bad request: unsubscribeKey required\");\n      await check({ webhookId: subscribeResponse.webhookId, unsubscribeKey: \"foo\" },\n        401, \"Wrong unsubscribeKey\");\n      await check({ webhookId: subscribeResponse.webhookId, unsubscribeKey: subscribeResponse.unsubscribeKey },\n        200, { success: true });\n      await check({ webhookId: subscribeResponse.webhookId, unsubscribeKey: subscribeResponse.unsubscribeKey },\n        404, `Webhook not found \"${subscribeResponse.webhookId}\"`);\n\n      delta.users[\"kiwi@getgrist.com\"] = null;\n      accessResp = await axiosInstance.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, { delta }, chimpy);\n      assert.equal(accessResp.status, 200);\n      await flushAuth();\n    });\n\n    it(\"DELETE /docs/{did}/tables/webhooks should not be allowed for not-owner\", async function() {\n      const { homeUrl, docIds, flushAuth, chimpy, kiwi } = getCtx();\n      const subscribeResponse = await subscribeWebhook();\n      const check = userDeleteCheck.bind(null, kiwi);\n\n      const delta = {\n        users: { \"kiwi@getgrist.com\": \"editors\" as string | null },\n      };\n      let accessResp = await axiosInstance.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, { delta }, chimpy);\n      assert.equal(accessResp.status, 200);\n      await flushAuth();\n\n      await check(subscribeResponse.webhookId, 403, /No owner access/);\n\n      delta.users[\"kiwi@getgrist.com\"] = null;\n      accessResp = await axiosInstance.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, { delta }, chimpy);\n      assert.equal(accessResp.status, 200);\n      await flushAuth();\n    });\n  });\n\n  /**\n   * Tests for daily API usage limits.\n   */\n  describe(\"dailyApiLimits\", function() {\n    let redisClient: RedisClient;\n\n    before(async function() {\n      if (!process.env.TEST_REDIS_URL) {\n        this.skip();\n      }\n      redisClient = createClient(process.env.TEST_REDIS_URL);\n    });\n\n    after(async function() {\n      if (process.env.TEST_REDIS_URL) {\n        await redisClient.quitAsync();\n      }\n    });\n\n    it(\"limits daily API usage\", async function() {\n      const api = makeUserApi(\"testdailyapilimit\", \"chimpy\") as UserAPIImpl;\n      const workspaceId = await getWorkspaceId(api, \"TestDailyApiLimitWs\");\n      const docId = await api.newDoc({ name: \"TestDoc1\" }, workspaceId);\n      const max = testDailyApiLimitFeatures.baseMaxApiUnitsPerDocumentPerDay;\n\n      for (let i = 1; i <= max + 2; i++) {\n        let success = true;\n        try {\n          await api.getTable(docId, \"Table1\");\n        } catch (e) {\n          success = false;\n        }\n\n        if (success) {\n          assert.isAtMost(i, max + 1);\n        } else {\n          assert.isAtLeast(i, max + 1);\n        }\n      }\n    });\n\n    it(\"limits daily API usage and sets the correct keys in redis\", async function() {\n      const { serverUrl, chimpy } = getCtx();\n      this.retries(3);\n      const freeTeamApi = makeUserApi(\"freeteam\", \"chimpy\") as UserAPIImpl;\n      const workspaceId = await getWorkspaceId(freeTeamApi, \"FreeTeamWs\");\n      const docId = await freeTeamApi.newDoc({ name: \"TestDoc2\" }, workspaceId);\n      const used = 999999;\n      let m = moment.utc();\n      const currentDay = docPeriodicApiUsageKey(docId, true, docApiUsagePeriods[0], m);\n      const currentHour = docPeriodicApiUsageKey(docId, true, docApiUsagePeriods[1], m);\n      const nextDay = docPeriodicApiUsageKey(docId, false, docApiUsagePeriods[0], m);\n      const nextHour = docPeriodicApiUsageKey(docId, false, docApiUsagePeriods[1], m);\n      await redisClient.multi()\n        .set(currentDay, String(used))\n        .set(currentHour, String(used))\n        .set(nextDay, String(used))\n        .set(nextHour, String(used))\n        .execAsync();\n\n      for (let i = 1; i <= 9; i++) {\n        const last = i === 9;\n        m = moment.utc();\n        const response = await axiosInstance.get(`${serverUrl}/api/docs/${docId}/tables/Table1/records`,\n          chimpy);\n        await delay(100);\n        if (i <= 4) {\n          assert.equal(response.status, 200);\n          const first = i === 1;\n          const day = docPeriodicApiUsageKey(docId, first, docApiUsagePeriods[0], m);\n          const hour = docPeriodicApiUsageKey(docId, first, docApiUsagePeriods[1], m);\n          const minute = docPeriodicApiUsageKey(docId, true, docApiUsagePeriods[2], m);\n\n          if (!first) {\n            assert.deepEqual(\n              await redisClient.multi()\n                .ttl(minute)\n                .ttl(hour)\n                .ttl(day)\n                .execAsync(),\n              [\n                2 * 60,\n                2 * 60 * 60,\n                2 * 60 * 60 * 24,\n              ],\n            );\n          }\n\n          assert.deepEqual(\n            await redisClient.multi()\n              .get(minute)\n              .get(hour)\n              .get(day)\n              .execAsync(),\n            [\n              String(i),\n              String(used + (first ? 1 : i - 1)),\n              String(used + (first ? 1 : i - 1)),\n            ],\n          );\n        }\n\n        if (last) {\n          assert.equal(response.status, 429);\n          assert.deepEqual(response.data, { error: `Exceeded daily limit for document ${docId}` });\n        }\n      }\n    });\n\n    it(\"correctly allocates API requests based on the day, hour, and minute\", async function() {\n      const m = moment.utc(\"1999-12-31T23:59:59Z\");\n      const docId = \"myDocId\";\n      const currentDay = docPeriodicApiUsageKey(docId, true, docApiUsagePeriods[0], m);\n      const currentHour = docPeriodicApiUsageKey(docId, true, docApiUsagePeriods[1], m);\n      const currentMinute = docPeriodicApiUsageKey(docId, true, docApiUsagePeriods[2], m);\n      const nextDay = docPeriodicApiUsageKey(docId, false, docApiUsagePeriods[0], m);\n      const nextHour = docPeriodicApiUsageKey(docId, false, docApiUsagePeriods[1], m);\n      assert.equal(currentDay, `doc-myDocId-periodicApiUsage-1999-12-31`);\n      assert.equal(currentHour, `doc-myDocId-periodicApiUsage-1999-12-31T23`);\n      assert.equal(currentMinute, `doc-myDocId-periodicApiUsage-1999-12-31T23:59`);\n      assert.equal(nextDay, `doc-myDocId-periodicApiUsage-2000-01-01`);\n      assert.equal(nextHour, `doc-myDocId-periodicApiUsage-2000-01-01T00`);\n\n      const usage = new LRUCache<string, number>({ max: 1024 });\n\n      function check(expected: string[] | undefined) {\n        assert.deepEqual(getDocApiUsageKeysToIncr(docId, usage, dailyMax, m), expected);\n      }\n\n      const dailyMax = 5000;\n      const hourlyMax = 209;\n      const minuteMax = 4;\n      check([currentDay, currentHour, currentMinute]);\n      usage.set(currentDay, dailyMax - 1);\n      check([currentDay, currentHour, currentMinute]);\n      usage.set(currentDay, dailyMax);\n      check([nextDay, currentHour, currentMinute]);\n      usage.set(currentHour, hourlyMax - 1);\n      check([nextDay, currentHour, currentMinute]);\n      usage.set(currentHour, hourlyMax);\n      check([nextDay, nextHour, currentMinute]);\n      usage.set(currentMinute, minuteMax - 1);\n      check([nextDay, nextHour, currentMinute]);\n      usage.set(currentMinute, minuteMax);\n      check(undefined);\n      usage.set(currentDay, 0);\n      check([currentDay, currentHour, currentMinute]);\n      usage.set(currentDay, dailyMax);\n      usage.set(currentHour, 0);\n      check([nextDay, currentHour, currentMinute]);\n    });\n  });\n\n  /**\n   * Main webhook behavior tests.\n   */\n  describe(\"mainWebhooks\", function() {\n    let serving: Serving;\n    let requests: WebhookRequests;\n    let receivedLastEvent: Promise<void>;\n\n    const expected200AddEvents = [\n      _.range(100).map(i => ({\n        id: 9 + i, manualSort: 9 + i, A3: 200 + i, B3: true,\n      })),\n      _.range(100).map(i => ({\n        id: 109 + i, manualSort: 109 + i, A3: 300 + i, B3: true,\n      })),\n    ];\n\n    const expectedRequests: WebhookRequests = {\n      \"add\": [\n        [{ id: 1, A: 1, B: true, C: null, manualSort: 1 }],\n        [{ id: 2, A: 4, B: true, C: null, manualSort: 2 }],\n        [{ id: 2, A: 7, B: true, C: null, manualSort: 2 }],\n        [{ id: 3, A3: 13, B3: true, manualSort: 3 },\n          { id: 5, A3: 15, B3: true, manualSort: 5 }],\n        [{ id: 7, A3: 18, B3: true, manualSort: 7 }],\n        ...expected200AddEvents,\n      ],\n      \"update\": [\n        [{ id: 2, A: 8, B: true, C: null, manualSort: 2 }],\n        [{ id: 1, A3: 101, B3: true, manualSort: 1 }],\n      ],\n      \"add,update\": [\n        [{ id: 1, A: 1, B: true, C: null, manualSort: 1 }],\n        [{ id: 2, A: 4, B: true, C: null, manualSort: 2 }],\n        [{ id: 2, A: 7, B: true, C: null, manualSort: 2 }],\n        [{ id: 2, A: 8, B: true, C: null, manualSort: 2 }],\n        [{ id: 1, A3: 101, B3: true, manualSort: 1 },\n          { id: 3, A3: 13, B3: true, manualSort: 3 },\n          { id: 5, A3: 15, B3: true, manualSort: 5 }],\n        [{ id: 7, A3: 18, B3: true, manualSort: 7 }],\n        ...expected200AddEvents,\n      ],\n    };\n\n    let redisMonitor: any;\n    let redisCalls: any[] = [];\n\n    const successCalled = signal();\n    const notFoundCalled = signal();\n    const longStarted = signal();\n    const longFinished = signal();\n    let probeStatus = 200;\n    let probeMessage: string | null = \"OK\";\n    let controller = new AbortController();\n\n    async function autoSubscribe(\n      endpoint: string, docId: string, options?: {\n        tableId?: string,\n        isReadyColumn?: string | null,\n        eventTypes?: string[]\n        watchedColIds?: string[],\n      }) {\n      const data = await subscribe(endpoint, docId, options);\n      return () => unsubscribe(docId, data, options?.tableId ?? \"Table1\");\n    }\n\n    function unsubscribe(docId: string, data: any, tableId = \"Table1\") {\n      const { serverUrl, chimpy } = getCtx();\n      return axiosInstance.post(\n        `${serverUrl}/api/docs/${docId}/tables/${tableId}/_unsubscribe`,\n        data, chimpy,\n      );\n    }\n\n    async function subscribe(endpoint: string, docId: string, options?: {\n      tableId?: string,\n      isReadyColumn?: string | null,\n      eventTypes?: string[],\n      watchedColIds?: string[],\n      name?: string,\n      memo?: string,\n      enabled?: boolean,\n    }) {\n      const { serverUrl, chimpy } = getCtx();\n      const { data, status } = await axiosInstance.post(\n        `${serverUrl}/api/docs/${docId}/tables/${options?.tableId ?? \"Table1\"}/_subscribe`,\n        {\n          eventTypes: options?.eventTypes ?? [\"add\", \"update\"],\n          url: `${serving.url}/${endpoint}`,\n          isReadyColumn: options?.isReadyColumn === undefined ? \"B\" : options?.isReadyColumn,\n          ...pick(options, \"name\", \"memo\", \"enabled\", \"watchedColIds\"),\n        }, chimpy,\n      );\n      assert.equal(status, 200, `Error during subscription: ` + JSON.stringify(data));\n      return data as WebhookSubscription;\n    }\n\n    async function clearQueue(docId: string) {\n      const { serverUrl, chimpy } = getCtx();\n      const deleteResult = await axiosInstance.delete(\n        `${serverUrl}/api/docs/${docId}/webhooks/queue`, chimpy,\n      );\n      assert.equal(deleteResult.status, 200);\n    }\n\n    async function readStats(docId: string): Promise<WebhookSummary[]> {\n      const { serverUrl, chimpy } = getCtx();\n      const result = await axiosInstance.get(\n        `${serverUrl}/api/docs/${docId}/webhooks`, chimpy,\n      );\n      assert.equal(result.status, 200);\n      return result.data.webhooks;\n    }\n\n    before(async function() {\n      this.timeout(30000);\n\n      requests = {\n        \"add,update\": [],\n        \"add\": [],\n        \"update\": [],\n      };\n\n      let resolveReceivedLastEvent: () => void;\n      receivedLastEvent = new Promise<void>((r) => {\n        resolveReceivedLastEvent = r;\n      });\n\n      // TODO test retries on failure and slowness in a new test\n      serving = await serveSomething((app: express.Application) => {\n        app.use(express.json());\n        app.post(\"/200\", ({ body }: express.Request, res: express.Response) => {\n          successCalled.emit(body[0].A);\n          res.sendStatus(200);\n          res.end();\n        });\n        app.post(\"/404\", ({ body }: express.Request, res: express.Response) => {\n          notFoundCalled.emit(body[0].A);\n          res.sendStatus(404);\n          res.end();\n        });\n        app.post(\"/probe\", async ({ body }: express.Request, res: express.Response) => {\n          longStarted.emit(body.map((r: any) => r.A));\n          const scoped = new AbortController();\n          controller = scoped;\n          try {\n            await delayAbort(20000, scoped.signal);\n            assert.fail(\"Should have been aborted\");\n          } catch (exc) {\n            res.status(probeStatus);\n            res.send(probeMessage);\n            res.end();\n            longFinished.emit(body.map((r: any) => r.A));\n          }\n        });\n        app.post(\"/long\", async ({ body }: express.Request, res: express.Response) => {\n          longStarted.emit(body[0].A);\n          const scoped = new AbortController();\n          controller = scoped;\n          try {\n            await delayAbort(20000, scoped.signal);\n            res.sendStatus(200);\n            res.end();\n            longFinished.emit(body[0].A);\n          } catch (exc) {\n            res.sendStatus(200);\n            res.end();\n            longFinished.emit([408, body[0].A]);\n          }\n        });\n        app.post(\"/:eventTypes\", async ({ body, params: { eventTypes } }: express.Request, res: express.Response) => {\n          requests[eventTypes as keyof WebhookRequests].push(body);\n          res.sendStatus(200);\n          if (\n            _.flattenDeep(_.values(requests)).length >=\n            _.flattenDeep(_.values(expectedRequests)).length\n          ) {\n            resolveReceivedLastEvent();\n          }\n        });\n      }, webhooksTestPort);\n    });\n\n    after(async function() {\n      await serving.shutdown();\n    });\n\n    describe(\"table endpoints\", function() {\n      before(async function() {\n        this.timeout(30000);\n        if (!process.env.TEST_REDIS_URL) {\n          this.skip();\n        }\n\n        redisMonitor = createClient(process.env.TEST_REDIS_URL);\n        redisMonitor.monitor();\n        redisMonitor.on(\"monitor\", (_time: any, args: any, _rawReply: any) => {\n          redisCalls.push(args);\n        });\n      });\n\n      beforeEach(function() {\n        requests = {\n          \"add,update\": [],\n          \"add\": [],\n          \"update\": [],\n        };\n        redisCalls = [];\n      });\n\n      after(async function() {\n        if (process.env.TEST_REDIS_URL) {\n          await redisMonitor.quitAsync();\n        }\n      });\n\n      async function createWebhooks(\n        {\n          docId,\n          tableId,\n          eventTypesSet,\n          isReadyColumn,\n          watchedColIds,\n          enabled,\n        }: {\n          docId: string,\n          tableId: string,\n          eventTypesSet: string[][],\n          isReadyColumn: string,\n          watchedColIds?: string[],\n          enabled?: boolean\n        },\n      ) {\n        const { serverUrl, chimpy } = getCtx();\n        await axiosInstance.post(`${serverUrl}/api/docs/${docId}/apply`, [\n          [\"ModifyColumn\", tableId, isReadyColumn, { type: \"Bool\" }],\n        ], chimpy);\n\n        const subscribeResponses = [];\n        const webhookIds: Record<string, string> = {};\n\n        for (const eventTypes of eventTypesSet) {\n          const data = await subscribe(String(eventTypes), docId, {\n            tableId,\n            eventTypes,\n            isReadyColumn,\n            watchedColIds,\n            enabled,\n          });\n          subscribeResponses.push(data);\n          webhookIds[data.webhookId] = String(eventTypes);\n        }\n        return { subscribeResponses, webhookIds };\n      }\n\n      [{\n        itMsg: \"delivers expected payloads from combinations of changes, with retrying and batching\",\n        watchedColIds: undefined,\n      }, {\n        itMsg: \"delivers expected payloads when watched col ids are set\",\n        watchedColIds: [\"A\", \"B\"],\n      }].forEach((ctx) => {\n        it(ctx.itMsg,\n          async function() {\n            const { serverUrl, userApi, chimpy } = getCtx();\n            const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n            const docId = await userApi.newDoc({ name: \"testdoc\" }, ws1);\n            const doc = userApi.getDocAPI(docId);\n\n            const { subscribeResponses, webhookIds } = await createWebhooks({\n              docId, tableId: \"Table1\", isReadyColumn: \"B\", watchedColIds: ctx.watchedColIds,\n              eventTypesSet: [\n                [\"add\"],\n                [\"update\"],\n                [\"add\", \"update\"],\n              ],\n            });\n\n            await doc.addRows(\"Table1\", {\n              A: [1, 2],\n              B: [true, false],\n            });\n            await doc.updateRows(\"Table1\", { id: [2], A: [3] });\n            await doc.updateRows(\"Table1\", { id: [2], A: [4], B: [true] });\n            await doc.updateRows(\"Table1\", { id: [2], A: [5], B: [false] });\n            await doc.updateRows(\"Table1\", { id: [2], A: [6] });\n            await doc.updateRows(\"Table1\", { id: [2], A: [7], B: [true] });\n            await doc.updateRows(\"Table1\", { id: [2], A: [8] });\n\n            await axiosInstance.post(`${serverUrl}/api/docs/${docId}/apply`, [\n              [\"BulkAddRecord\", \"Table1\", [3, 4, 5, 6], { A: [9, 10, 11, 12], B: [true, true, false, false] }],\n              [\"BulkUpdateRecord\", \"Table1\", [1, 2, 3, 4, 5, 6], {\n                A: [101, 102, 13, 14, 15, 16],\n                B: [true, false, true, false, true, false],\n              }],\n              [\"RenameColumn\", \"Table1\", \"A\", \"A3\"],\n              [\"RenameColumn\", \"Table1\", \"B\", \"B3\"],\n              [\"RenameTable\", \"Table1\", \"Table12\"],\n              [\"RemoveColumn\", \"Table12\", \"C\"],\n            ], chimpy);\n\n            await axiosInstance.post(`${serverUrl}/api/docs/${docId}/apply`, [\n              [\"AddRecord\", \"Table12\", 7, { A3: 17, B3: false }],\n              [\"UpdateRecord\", \"Table12\", 7, { A3: 18, B3: true }],\n              [\"AddRecord\", \"Table12\", 8, { A3: 19, B3: true }],\n              [\"UpdateRecord\", \"Table12\", 8, { A3: 20, B3: false }],\n              [\"AddRecord\", \"Table12\", 9, { A3: 20, B3: true }],\n              [\"RemoveRecord\", \"Table12\", 9],\n            ], chimpy);\n\n            await doc.addRows(\"Table12\", {\n              A3: _.range(200, 400),\n              B3: arrayRepeat(200, true),\n            });\n\n            await receivedLastEvent;\n\n            await Promise.all(subscribeResponses.map(async (subscribeResponse) => {\n              const unsubscribeResponse = await axiosInstance.post(\n                `${serverUrl}/api/docs/${docId}/tables/Table12/_unsubscribe`,\n                subscribeResponse, chimpy,\n              );\n              assert.equal(unsubscribeResponse.status, 200);\n              assert.deepEqual(unsubscribeResponse.data, { success: true });\n            }));\n\n            await doc.addRows(\"Table12\", {\n              A3: [88, 99],\n              B3: [true, false],\n            });\n\n            assert.deepEqual(requests, expectedRequests);\n\n            const queueRedisCalls = redisCalls.filter(args => args[1] === \"webhook-queue-\" + docId);\n            const redisPushes = _.chain(queueRedisCalls)\n              .filter(args => args[0] === \"rpush\")\n              .flatMap(args => args.slice(2))\n              .map(JSON.parse)\n              .groupBy(\"id\")\n              .mapKeys((_value, key) => webhookIds[key])\n              .mapValues(group => _.map(group, \"payload\"))\n              .value();\n            const expectedPushes = _.mapValues(expectedRequests, value => _.flatten(value));\n            assert.deepEqual(redisPushes, expectedPushes);\n\n            const redisTrims = queueRedisCalls.filter(args => args[0] === \"ltrim\")\n              .map(([, , start, end]) => {\n                assert.equal(end, \"-1\");\n                return Number(start);\n              });\n            const expectedTrims = Object.values(redisPushes).map(value => value.length);\n            assert.equal(\n              _.sum(redisTrims),\n              _.sum(expectedTrims),\n            );\n          });\n      });\n\n      [{\n        itMsg: \"doesn't trigger webhook that has been disabled\",\n        enabled: false,\n      }, {\n        itMsg: \"does trigger webhook that has been enable\",\n        enabled: true,\n      }].forEach((ctx) => {\n        it(ctx.itMsg, async function() {\n          const { userApi } = getCtx();\n          const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n          const docId = await userApi.newDoc({ name: \"testdoc\" }, ws1);\n          const doc = userApi.getDocAPI(docId);\n\n          await createWebhooks({\n            docId, tableId: \"Table1\", isReadyColumn: \"B\", eventTypesSet: [[\"add\"]], enabled: ctx.enabled,\n          });\n\n          await doc.addRows(\"Table1\", {\n            A: [42],\n            B: [true],\n          });\n\n          const queueRedisCalls = redisCalls.filter(args => args[1] === \"webhook-queue-\" + docId);\n          const redisPushIndex = queueRedisCalls.findIndex(args => args[0] === \"rpush\");\n\n          if (ctx.enabled) {\n            assert.isAbove(redisPushIndex, 0, \"Should have pushed events to the redis queue\");\n          } else {\n            assert.equal(redisPushIndex, -1, \"Should not have pushed any events to the redis queue\");\n          }\n        });\n      });\n    });\n\n    describe(\"/webhooks endpoint\", function() {\n      let docId: string;\n      let doc: DocAPI;\n      let stats: WebhookSummary[];\n\n      before(async function() {\n        const { serverUrl, userApi, chimpy } = getCtx();\n        const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n        docId = await userApi.newDoc({ name: \"testdoc2\" }, ws1);\n        doc = userApi.getDocAPI(docId);\n        await axiosInstance.post(`${serverUrl}/api/docs/${docId}/apply`, [\n          [\"ModifyColumn\", \"Table1\", \"B\", { type: \"Bool\" }],\n        ], chimpy);\n        await userApi.applyUserActions(docId, [[\"AddTable\", \"Table2\", [{ id: \"Foo\" }, { id: \"Bar\" }]]]);\n      });\n\n      const waitForQueue = async (length: number) => {\n        await waitForIt(async () => {\n          stats = await readStats(docId);\n          assert.equal(length, _.sum(stats.map(x => x.usage?.numWaiting ?? 0)));\n        }, 1000, 200);\n      };\n\n      it(\"should clear the outgoing queue\", async () => {\n        const { serverUrl, userApi, chimpy } = getCtx();\n        const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n        const docId = await userApi.newDoc({ name: \"testdoc2\" }, ws1);\n        const doc = userApi.getDocAPI(docId);\n        await axiosInstance.post(`${serverUrl}/api/docs/${docId}/apply`, [\n          [\"ModifyColumn\", \"Table1\", \"B\", { type: \"Bool\" }],\n        ], chimpy);\n\n        await clearQueue(docId);\n\n        const cleanup: (() => Promise<any>)[] = [];\n\n        cleanup.push(await autoSubscribe(\"200\", docId));\n        cleanup.push(await autoSubscribe(\"404\", docId));\n\n        successCalled.reset();\n        notFoundCalled.reset();\n        await doc.addRows(\"Table1\", {\n          A: [1],\n          B: [true],\n        });\n\n        await successCalled.waitAndReset();\n        await notFoundCalled.waitAndReset();\n\n        await notFoundCalled.waitAndReset();\n\n        successCalled.assertNotCalled();\n\n        await doc.addRows(\"Table1\", {\n          A: [2],\n          B: [true],\n        });\n        const firstRow = await notFoundCalled.waitAndReset();\n        assert.deepEqual(firstRow, 1);\n\n        successCalled.assertNotCalled();\n\n        await clearQueue(docId);\n\n        successCalled.assertNotCalled();\n        notFoundCalled.assertNotCalled();\n\n        successCalled.reset();\n        notFoundCalled.reset();\n        await doc.addRows(\"Table1\", {\n          A: [3],\n          B: [true],\n        });\n        let thirdRow = await successCalled.waitAndReset();\n        assert.deepEqual(thirdRow, 3);\n        thirdRow = await notFoundCalled.waitAndReset();\n        assert.deepEqual(thirdRow, 3);\n        await notFoundCalled.waitAndReset();\n        successCalled.assertNotCalled();\n\n        await Promise.all(cleanup.map(fn => fn())).finally(() => cleanup.length = 0);\n        await clearQueue(docId);\n\n        cleanup.push(await autoSubscribe(\"200\", docId));\n        cleanup.push(await autoSubscribe(\"long\", docId));\n        successCalled.reset();\n        longFinished.reset();\n        longStarted.reset();\n        await doc.addRows(\"Table1\", {\n          A: [4],\n          B: [true],\n        });\n        await successCalled.waitAndReset();\n        await longStarted.waitAndReset();\n        longFinished.assertNotCalled();\n        controller.abort();\n        assert.deepEqual(await longFinished.waitAndReset(), [408, 4]);\n\n        await doc.addRows(\"Table1\", {\n          A: [5],\n          B: [true],\n        });\n        assert.deepEqual(await successCalled.waitAndReset(), 5);\n        assert.deepEqual(await longStarted.waitAndReset(), 5);\n        longFinished.assertNotCalled();\n\n        const controller5 = controller;\n        await doc.addRows(\"Table1\", {\n          A: [6],\n          B: [true],\n        });\n        successCalled.assertNotCalled();\n        longFinished.assertNotCalled();\n        assert.isTrue((await axiosInstance.delete(\n          `${serverUrl}/api/docs/${docId}/webhooks/queue`, chimpy,\n        )).status === 200);\n        controller5.abort();\n        assert.deepEqual(await longFinished.waitAndReset(), [408, 5]);\n\n        successCalled.assertNotCalled();\n        longStarted.assertNotCalled();\n\n        await doc.addRows(\"Table1\", {\n          A: [7],\n          B: [true],\n        });\n        assert.deepEqual(await successCalled.waitAndReset(), 7);\n        assert.deepEqual(await longStarted.waitAndReset(), 7);\n        longFinished.assertNotCalled();\n        controller.abort();\n        assert.deepEqual(await longFinished.waitAndReset(), [408, 7]);\n\n        await Promise.all(cleanup.map(fn => fn())).finally(() => cleanup.length = 0);\n        await clearQueue(docId);\n      });\n\n      it(\"should not call to a deleted webhook\", async () => {\n        const { serverUrl, userApi, chimpy } = getCtx();\n        const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n        const docId = await userApi.newDoc({ name: \"testdoc4\" }, ws1);\n        const doc = userApi.getDocAPI(docId);\n        await axiosInstance.post(`${serverUrl}/api/docs/${docId}/apply`, [\n          [\"ModifyColumn\", \"Table1\", \"B\", { type: \"Bool\" }],\n        ], chimpy);\n\n        const webhook1 = await autoSubscribe(\"probe\", docId);\n        const webhook2 = await autoSubscribe(\"200\", docId);\n\n        probeStatus = 200;\n        successCalled.reset();\n        longFinished.reset();\n        await doc.addRows(\"Table1\", {\n          A: [1],\n          B: [true],\n        });\n\n        await longStarted.waitAndReset();\n        const stats = await readStats(docId);\n        assert.equal(2, _.sum(stats.map(x => x.usage?.numWaiting ?? 0)));\n        await webhook2();\n        controller.abort();\n        await longFinished.waitAndReset();\n        successCalled.assertNotCalled();\n        await doc.addRows(\"Table1\", {\n          A: [2],\n          B: [true],\n        });\n        await longStarted.waitAndReset();\n        controller.abort();\n        await longFinished.waitAndReset();\n\n        await webhook1();\n      });\n\n      it(\"should call to a webhook only when columns updated are in watchedColIds if not empty\", async () => {\n        const { serverUrl, userApi, chimpy } = getCtx();\n        const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n        const docId = await userApi.newDoc({ name: \"testdoc5\" }, ws1);\n        const doc = userApi.getDocAPI(docId);\n        await axiosInstance.post(`${serverUrl}/api/docs/${docId}/apply`, [\n          [\"ModifyColumn\", \"Table1\", \"B\", { type: \"Bool\" }],\n        ], chimpy);\n\n        const modifyColumn = async (newValues: { [key: string]: any; }) => {\n          await axiosInstance.post(`${serverUrl}/api/docs/${docId}/apply`, [\n            [\"UpdateRecord\", \"Table1\", newRowIds[0], newValues],\n          ], chimpy);\n        };\n        const assertSuccessNotCalled = async () => {\n          successCalled.assertNotCalled();\n          successCalled.reset();\n        };\n        const assertSuccessCalled = async () => {\n          await successCalled.waitAndReset();\n        };\n\n        const webhook1 = await autoSubscribe(\"200\", docId, {\n          watchedColIds: [\"A\"], eventTypes: [\"add\", \"update\"],\n        });\n        successCalled.reset();\n        const newRowIds = await doc.addRows(\"Table1\", {\n          A: [2],\n          B: [true],\n          C: [\"c1\"],\n        });\n        await successCalled.waitAndReset();\n        await modifyColumn({ C: \"c2\" });\n        await assertSuccessNotCalled();\n        await modifyColumn({ A: 19 });\n        await assertSuccessCalled();\n        await webhook1();\n\n        const webhook2 = await autoSubscribe(\"200\", docId, {\n          watchedColIds: [\"A\", \"B\"], eventTypes: [\"update\"],\n        });\n        successCalled.reset();\n        await modifyColumn({ C: \"c3\" });\n        await assertSuccessNotCalled();\n        await modifyColumn({ A: 20 });\n        await assertSuccessCalled();\n        await webhook2();\n\n        const webhook3 = await autoSubscribe(\"200\", docId, {\n          watchedColIds: [\"A\", \"\"], eventTypes: [\"update\"],\n        });\n        await modifyColumn({ C: \"c4\" });\n        await assertSuccessNotCalled();\n        await modifyColumn({ A: 21 });\n        await assertSuccessCalled();\n        await webhook3();\n      });\n\n      it(\"should return statistics\", async () => {\n        await clearQueue(docId);\n        assert.deepEqual(await readStats(docId), []);\n        const first = await subscribe(\"200\", docId);\n        const second = await subscribe(\"404\", docId);\n        assert.deepEqual(await readStats(docId), [\n          {\n            id: first.webhookId,\n            fields: {\n              url: `${serving.url}/200`,\n              authorization: \"\",\n              unsubscribeKey: first.unsubscribeKey,\n              eventTypes: [\"add\", \"update\"],\n              enabled: true,\n              isReadyColumn: \"B\",\n              tableId: \"Table1\",\n              name: \"\",\n              memo: \"\",\n              watchedColIds: [],\n            }, usage: {\n              status: \"idle\",\n              numWaiting: 0,\n              lastEventBatch: null,\n            },\n          },\n          {\n            id: second.webhookId,\n            fields: {\n              url: `${serving.url}/404`,\n              authorization: \"\",\n              unsubscribeKey: second.unsubscribeKey,\n              eventTypes: [\"add\", \"update\"],\n              enabled: true,\n              isReadyColumn: \"B\",\n              tableId: \"Table1\",\n              name: \"\",\n              memo: \"\",\n              watchedColIds: [],\n            }, usage: {\n              status: \"idle\",\n              numWaiting: 0,\n              lastEventBatch: null,\n            },\n          },\n        ]);\n\n        await unsubscribe(docId, first);\n        await unsubscribe(docId, second);\n        assert.deepEqual(await readStats(docId), []);\n\n        let unsubscribe1 = await autoSubscribe(\"200\", docId, { isReadyColumn: null });\n        assert.isNull((await readStats(docId))[0].fields.isReadyColumn);\n        await unsubscribe1();\n\n        unsubscribe1 = await autoSubscribe(\"probe\", docId);\n        let now = Date.now();\n        longStarted.reset();\n        longFinished.reset();\n        await doc.addRows(\"Table1\", {\n          A: [1],\n          B: [true],\n        });\n        await longStarted.waitAndReset();\n        stats = await readStats(docId);\n        assert.isNotNull(stats[0].usage);\n        assert.equal(stats[0].usage?.numWaiting, 1);\n        assert.equal(stats[0].usage?.status, \"sending\");\n        assert.isNotNull(stats[0].usage?.updatedTime);\n        assert.isAbove(stats[0].usage?.updatedTime ?? 0, now);\n        assert.isNull(stats[0].usage?.lastErrorMessage);\n        assert.isNull(stats[0].usage?.lastSuccessTime);\n        assert.isNull(stats[0].usage?.lastFailureTime);\n        assert.isNull(stats[0].usage?.lastHttpStatus);\n        assert.isNull(stats[0].usage?.lastEventBatch);\n        probeStatus = 200;\n        controller.abort();\n        await longFinished.waitAndReset();\n        await waitForIt(async () => {\n          stats = await readStats(docId);\n          assert.equal(stats[0].usage?.numWaiting, 0);\n        }, 1000, 200);\n        assert.equal(stats[0].usage?.numWaiting, 0);\n        assert.equal(stats[0].usage?.status, \"idle\");\n        assert.isAtLeast(stats[0].usage?.updatedTime ?? 0, now);\n        assert.isNull(stats[0].usage?.lastErrorMessage);\n        assert.isNull(stats[0].usage?.lastFailureTime);\n        assert.equal(stats[0].usage?.lastHttpStatus, 200);\n        assert.isAtLeast(stats[0].usage?.lastSuccessTime ?? 0, now);\n        assert.deepEqual(stats[0].usage?.lastEventBatch, {\n          status: \"success\",\n          attempts: 1,\n          size: 1,\n          errorMessage: null,\n          httpStatus: 200,\n        });\n\n        now = Date.now();\n        await doc.addRows(\"Table1\", {\n          A: [2],\n          B: [true],\n        });\n        await longStarted.waitAndReset();\n        probeStatus = 404;\n        probeMessage = null;\n        controller.abort();\n        await longFinished.waitAndReset();\n        await longStarted.waitAndReset();\n        stats = await readStats(docId);\n        assert.equal(stats[0].usage?.numWaiting, 1);\n        assert.equal(stats[0].usage?.status, \"retrying\");\n        assert.isAtLeast(stats[0].usage?.updatedTime ?? 0, now);\n        assert.isNull(stats[0].usage?.lastErrorMessage);\n        assert.isAtLeast(stats[0].usage?.lastFailureTime ?? 0, now);\n        assert.isBelow(stats[0].usage?.lastSuccessTime ?? 0, now);\n        assert.equal(stats[0].usage?.lastHttpStatus, 404);\n        assert.deepEqual(stats[0].usage?.lastEventBatch, {\n          status: \"failure\",\n          attempts: 1,\n          size: 1,\n          errorMessage: null,\n          httpStatus: 404,\n        });\n        probeStatus = 500;\n        probeMessage = \"Some error\";\n        controller.abort();\n        await longFinished.waitAndReset();\n        await longStarted.waitAndReset();\n        stats = await readStats(docId);\n        assert.equal(stats[0].usage?.numWaiting, 1);\n        assert.equal(stats[0].usage?.status, \"retrying\");\n        assert.equal(stats[0].usage?.lastHttpStatus, 500);\n        assert.equal(stats[0].usage?.lastErrorMessage, probeMessage);\n        assert.deepEqual(stats[0].usage?.lastEventBatch, {\n          status: \"failure\",\n          attempts: 2,\n          size: 1,\n          errorMessage: probeMessage,\n          httpStatus: 500,\n        });\n        probeStatus = 200;\n        controller.abort();\n        await longFinished.waitAndReset();\n        await waitForIt(async () => {\n          stats = await readStats(docId);\n          assert.equal(stats[0].usage?.numWaiting, 0);\n        }, 1000, 200);\n        stats = await readStats(docId);\n        assert.equal(stats[0].usage?.numWaiting, 0);\n        assert.equal(stats[0].usage?.status, \"idle\");\n        assert.equal(stats[0].usage?.lastHttpStatus, 200);\n        assert.equal(stats[0].usage?.lastErrorMessage, probeMessage);\n        assert.isAtLeast(stats[0].usage?.lastFailureTime ?? 0, now);\n        assert.isAtLeast(stats[0].usage?.lastSuccessTime ?? 0, now);\n        assert.deepEqual(stats[0].usage?.lastEventBatch, {\n          status: \"success\",\n          attempts: 3,\n          size: 1,\n          errorMessage: null,\n          httpStatus: 200,\n        });\n        await clearQueue(docId);\n        stats = await readStats(docId);\n        assert.isNotNull(stats[0].usage);\n        assert.equal(stats[0].usage?.numWaiting, 0);\n        assert.equal(stats[0].usage?.status, \"idle\");\n        const unsubscribe2 = await autoSubscribe(\"probe\", docId);\n        await doc.addRows(\"Table1\", {\n          A: [3],\n          B: [true],\n        });\n        await doc.addRows(\"Table1\", {\n          A: [4],\n          B: [true],\n        });\n        await doc.addRows(\"Table1\", {\n          A: [5],\n          B: [true],\n        });\n        assert.deepEqual(await longStarted.waitAndReset(), [3]);\n        stats = await readStats(docId);\n        assert.lengthOf(stats, 2);\n        assert.equal(stats[0].usage?.status, \"sending\");\n        assert.equal(stats[1].usage?.status, \"idle\");\n        assert.isNull(stats[0].usage?.lastEventBatch);\n        assert.isNull(stats[1].usage?.lastEventBatch);\n        assert.equal(6, _.sum(stats.map(x => x.usage?.numWaiting ?? 0)));\n        controller.abort();\n        assert.deepEqual(await longFinished.waitAndReset(), [3]);\n        const nextPass = async (length: number, A: number) => {\n          assert.deepEqual(await longStarted.waitAndReset(), [A]);\n          stats = await readStats(docId);\n          assert.equal(length, _.sum(stats.map(x => x.usage?.numWaiting ?? 0)));\n          controller.abort();\n          assert.deepEqual(await longFinished.waitAndReset(), [A]);\n        };\n        await nextPass(5, 3);\n        await nextPass(4, 4);\n        await nextPass(3, 4);\n        await nextPass(2, 5);\n        await nextPass(1, 5);\n\n        await waitForQueue(0);\n        await unsubscribe2();\n        await unsubscribe1();\n      });\n\n      it(\"should not block document load (gh issue #799)\", async function() {\n        const { serverUrl, userApi, chimpy } = getCtx();\n        const ws1 = (await userApi.getOrgWorkspaces(\"current\"))[0].id;\n        const docId = await userApi.newDoc({ name: \"testdoc5\" }, ws1);\n        const doc = userApi.getDocAPI(docId);\n        const formulaEvaluatedAtDocLoad = \"NOW()\";\n\n        await axiosInstance.post(`${serverUrl}/api/docs/${docId}/apply`, [\n          [\"ModifyColumn\", \"Table1\", \"C\", { isFormula: true, formula: formulaEvaluatedAtDocLoad }],\n        ], chimpy);\n\n        const unsubscribeWebhook1 = await autoSubscribe(\"probe\", docId);\n\n        await doc.addRows(\"Table1\", {\n          A: [1],\n        });\n\n        await doc.forceReload();\n\n        await doc.addRows(\"Table1\", {\n          A: [2],\n        });\n\n        await unsubscribeWebhook1();\n      });\n\n      it(\"should monitor failures\", async () => {\n        const webhook3 = await subscribe(\"probe\", docId);\n        const webhook4 = await subscribe(\"probe\", docId);\n        probeStatus = 509;\n        probeMessage = \"fail\";\n        await doc.addRows(\"Table1\", {\n          A: [5],\n          B: [true],\n        });\n\n        const pass = async () => {\n          await longStarted.waitAndReset();\n          controller.abort();\n          await longFinished.waitAndReset();\n        };\n        await pass();\n        await pass();\n        await pass();\n        await pass();\n        await longStarted.waitAndReset();\n\n        stats = await readStats(docId);\n        assert.equal(stats.length, 2);\n        assert.equal(stats[0].id, webhook3.webhookId);\n        assert.equal(stats[1].id, webhook4.webhookId);\n        assert.equal(stats[0].usage?.status, \"postponed\");\n        assert.equal(stats[1].usage?.status, \"sending\");\n        assert.equal(stats[0].usage?.numWaiting, 1);\n        assert.equal(stats[1].usage?.numWaiting, 1);\n        assert.equal(stats[0].usage?.lastErrorMessage, probeMessage);\n        assert.equal(stats[0].usage?.lastHttpStatus, 509);\n        assert.equal(stats[0].usage?.lastEventBatch?.status, \"failure\");\n        assert.isNull(stats[1].usage?.lastErrorMessage);\n\n        await waitForQueue(2);\n        const addRowProm = doc.addRows(\"Table1\", {\n          A: arrayRepeat(5, 100),\n          B: arrayRepeat(5, true),\n        }).catch(() => {\n        });\n\n        probeStatus = 429;\n        controller.abort();\n        await longFinished.waitAndReset();\n        await pass();\n        await pass();\n\n        await waitForQueue(12);\n        await pass();\n\n        await longStarted.waitAndReset();\n\n        stats = await readStats(docId);\n        assert.equal(stats.length, 2);\n        assert.equal(stats[0].id, webhook3.webhookId);\n        assert.equal(stats[0].usage?.status, \"sending\");\n        assert.equal(stats[0].usage?.numWaiting, 6);\n        assert.equal(stats[0].usage?.lastErrorMessage, probeMessage);\n        assert.equal(stats[0].usage?.lastHttpStatus, 509);\n\n        assert.equal(stats[1].id, webhook4.webhookId);\n        assert.equal(stats[1].usage?.status, \"error\");\n        assert.equal(stats[1].usage?.lastEventBatch?.status, \"rejected\");\n        assert.equal(stats[1].usage?.numWaiting, 5);\n\n        probeStatus = 200;\n        controller.abort();\n        await longFinished.waitAndReset();\n        await pass();\n        await waitForQueue(0);\n\n        await addRowProm;\n        await unsubscribe(docId, webhook3);\n        await unsubscribe(docId, webhook4);\n      });\n\n      describe(\"webhook update\", function() {\n        it(\"should work correctly\", async function() {\n          const { serverUrl, userApi, chimpy } = getCtx();\n          async function check(fields: any, status: number, error?: RegExp | string,\n            expectedFieldsCallback?: (fields: any) => any) {\n            const origFields = {\n              tableId: \"Table1\",\n              eventTypes: [\"add\"],\n              isReadyColumn: \"B\",\n              name: \"My Webhook\",\n              memo: \"Sync store\",\n              watchedColIds: [\"A\"],\n            };\n\n            const doc = userApi.getDocAPI(docId);\n            const fork = await doc.fork();\n            const { data: errorData } = await axiosInstance.post(\n              `${serverUrl}/api/docs/${fork.docId}/webhooks`,\n              {\n                webhooks: [{\n                  fields: {\n                    ...origFields,\n                    url: `${serving.url}/foo`,\n                  },\n                }],\n              }, chimpy,\n            );\n            assert.equal(errorData.error, \"Unsaved document copies cannot have webhooks\");\n\n            const { data } = await axiosInstance.post(\n              `${serverUrl}/api/docs/${docId}/webhooks`,\n              {\n                webhooks: [{\n                  fields: {\n                    ...origFields,\n                    url: `${serving.url}/foo`,\n                  },\n                }],\n              }, chimpy,\n            );\n            const webhooks = data;\n\n            const expectedFields = {\n              url: `${serving.url}/foo`,\n              authorization: \"\",\n              eventTypes: [\"add\"],\n              isReadyColumn: \"B\",\n              tableId: \"Table1\",\n              enabled: true,\n              name: \"My Webhook\",\n              memo: \"Sync store\",\n              watchedColIds: [\"A\"],\n            };\n\n            let stats = await readStats(docId);\n            assert.equal(stats.length, 1, \"stats=\" + JSON.stringify(stats));\n            assert.equal(stats[0].id, webhooks.webhooks[0].id);\n            // eslint-disable-next-line @typescript-eslint/no-unused-vars\n            const { unsubscribeKey, ...fieldsWithoutUnsubscribeKey } = stats[0].fields;\n            assert.deepEqual(fieldsWithoutUnsubscribeKey, expectedFields);\n\n            const resp = await axiosInstance.patch(\n              `${serverUrl}/api/docs/${docId}/webhooks/${webhooks.webhooks[0].id}`, fields, chimpy,\n            );\n\n            assert.equal(resp.status, status, JSON.stringify(pick(resp, [\"data\", \"status\"])));\n            if (resp.status === 200) {\n              stats = await readStats(docId);\n              assert.equal(stats.length, 1);\n              assert.equal(stats[0].id, webhooks.webhooks[0].id);\n              if (expectedFieldsCallback) {\n                expectedFieldsCallback(expectedFields);\n              }\n              // eslint-disable-next-line @typescript-eslint/no-unused-vars\n              const { unsubscribeKey, ...fieldsWithoutUnsubscribeKey } = stats[0].fields;\n              assert.deepEqual(fieldsWithoutUnsubscribeKey, { ...expectedFields, ...fields });\n            } else {\n              if (error instanceof RegExp) {\n                assert.match(resp.data.details?.userError || resp.data.error, error);\n              } else {\n                assert.deepEqual(resp.data, { error });\n              }\n            }\n\n            const unsubscribeResp = await axiosInstance.delete(\n              `${serverUrl}/api/docs/${docId}/webhooks/${webhooks.webhooks[0].id}`, chimpy,\n            );\n            assert.equal(unsubscribeResp.status, 200, JSON.stringify(pick(unsubscribeResp, [\"data\", \"status\"])));\n            stats = await readStats(docId);\n            assert.equal(stats.length, 0, \"stats=\" + JSON.stringify(stats));\n          }\n\n          await check({ url: `${serving.url}/bar` }, 200);\n          await check({ url: \"https://evil.com\" }, 403, \"Provided url is forbidden\");\n          await check({ url: \"http://example.com\" }, 403, \"Provided url is forbidden\");\n\n          await check({ tableId: \"Table2\" }, 200, \"\", (expectedFields) => {\n            expectedFields.isReadyColumn = null;\n            expectedFields.watchedColIds = [];\n          });\n\n          await check({ tableId: \"Santa\" }, 404, `Table not found \"Santa\"`);\n          await check({ tableId: \"Table2\", isReadyColumn: \"Foo\", watchedColIds: [] }, 200);\n\n          await check({ eventTypes: [\"add\", \"update\"] }, 200);\n          await check({ eventTypes: [] }, 400, \"eventTypes must be a non-empty array\");\n          await check({ eventTypes: [\"foo\"] }, 400, /eventTypes\\[0] is none of \"add\", \"update\"/);\n\n          await check({ isReadyColumn: null }, 200);\n          await check({ isReadyColumn: \"bar\" }, 404, `Column not found \"bar\"`);\n\n          await check({ authorization: \"Bearer fake-token\" }, 200);\n        });\n      });\n    });\n  });\n\n  /**\n   * Tests for CORS allowed origin.\n   */\n  describe(\"allowedOrigin\", function() {\n    it(\"should respond with correct CORS headers\", async function() {\n      const { serverUrl, home, userApi, chimpy, nobody } = getCtx();\n      const wid = await getWorkspaceId(userApi as UserAPIImpl, \"Private\");\n      const docId = await userApi.newDoc({ name: \"CorsTestDoc\" }, wid);\n      await userApi.updateDocPermissions(docId, {\n        users: {\n          \"everyone@getgrist.com\": \"owners\",\n        },\n      });\n\n      const chimpyConfig = { ...chimpy };\n      const anonConfig = { ...nobody };\n      delete chimpyConfig.headers![\"X-Requested-With\"];\n      delete anonConfig.headers![\"X-Requested-With\"];\n\n      let allowedOrigin;\n\n      // Target a more realistic Host than \"localhost:port\"\n      // (if behind a proxy, we already benefit from a custom and realistic host).\n      if (!home.proxiedServer) {\n        anonConfig.headers!.Host = chimpyConfig.headers!.Host =\n          \"api.example.com\";\n        allowedOrigin = \"http://front.example.com\";\n      } else {\n        allowedOrigin = serverUrl;\n      }\n\n      const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;\n      const data = { records: [{ fields: {} }] };\n\n      const forbiddenOrigin = \"http://evil.com\";\n\n      // Normal same origin requests\n      anonConfig.headers!.Origin = allowedOrigin;\n      let response: AxiosResponse;\n      for (response of [\n        await axiosInstance.post(url, data, anonConfig),\n        await axiosInstance.get(url, anonConfig),\n        await axiosInstance.options(url, anonConfig),\n      ]) {\n        assert.equal(response.status, 200);\n        assert.equal(response.headers[\"access-control-allow-methods\"], \"GET, PATCH, PUT, POST, DELETE, OPTIONS\");\n        assert.equal(response.headers[\"access-control-allow-headers\"], \"Authorization, Content-Type, X-Requested-With\");\n        assert.equal(response.headers[\"access-control-allow-origin\"], allowedOrigin);\n        assert.equal(response.headers[\"access-control-allow-credentials\"], \"true\");\n      }\n\n      // Cross origin requests from untrusted origin.\n      for (const config of [anonConfig, chimpyConfig]) {\n        config.headers!.Origin = forbiddenOrigin;\n        for (response of [\n          await axiosInstance.post(url, data, config),\n          await axiosInstance.get(url, config),\n          await axiosInstance.options(url, config),\n        ]) {\n          if (config === anonConfig) {\n            // Requests without credentials are still OK.\n            assert.equal(response.status, 200);\n          } else {\n            assert.equal(response.status, 403);\n            assert.deepEqual(response.data, { error: \"Credentials not supported for cross-origin requests\" });\n          }\n          assert.equal(response.headers[\"access-control-allow-methods\"], \"GET, PATCH, PUT, POST, DELETE, OPTIONS\");\n          // Authorization header is not allowed\n          assert.equal(response.headers[\"access-control-allow-headers\"], \"Content-Type, X-Requested-With\");\n          // Origin is not echoed back. Arbitrary origin is allowed, but credentials are not.\n          assert.equal(response.headers[\"access-control-allow-origin\"], \"*\");\n          assert.equal(response.headers[\"access-control-allow-credentials\"], undefined);\n        }\n      }\n\n      // POST requests without credentials require a custom header so that a CORS preflight request is triggered.\n      // One possible header is X-Requested-With, which we removed at the start of the test.\n      // The other is Content-Type: application/json, which we have been using implicitly above because axios\n      // automatically treats the given data object as data. Passing a string instead prevents this.\n      response = await axiosInstance.post(url, JSON.stringify(data), anonConfig);\n      assert.equal(response.status, 401);\n      assert.deepEqual(response.data, {\n        error: \"Unauthenticated requests require one of the headers\" +\n          \"'Content-Type: application/json' or 'X-Requested-With: XMLHttpRequest'\",\n      });\n\n      // ^ that's for requests without credentials, otherwise we get the same 403 as earlier.\n      response = await axiosInstance.post(url, JSON.stringify(data), chimpyConfig);\n      assert.equal(response.status, 403);\n      assert.deepEqual(response.data, { error: \"Credentials not supported for cross-origin requests\" });\n    });\n  });\n}\n"
  },
  {
    "path": "test/server/lib/docapi/helpers.ts",
    "content": "/**\n * Shared test scenarios for DocApi tests.\n *\n * Provides setup functions for running tests against different server configurations:\n * - Merged server: single server for home + docs\n * - Separated servers: home server + doc worker (requires Redis)\n * - Direct to docworker: requests sent directly to doc worker (requires Redis)\n */\n\nimport { UserAPI, UserAPIImpl } from \"app/common/UserAPI\";\nimport { configForUser } from \"test/gen-server/testUtils\";\nimport { prepareDatabase } from \"test/server/lib/helpers/PrepareDatabase\";\nimport { prepareFilesystemDirectoryForTests } from \"test/server/lib/helpers/PrepareFilesystemDirectoryForTests\";\nimport { TestServer } from \"test/server/lib/helpers/TestServer\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { tmpdir } from \"os\";\nimport * as path from \"path\";\n\nimport axios, { AxiosRequestConfig } from \"axios\";\nimport { assert } from \"chai\";\nimport FormData from \"form-data\";\nimport * as fse from \"fs-extra\";\nimport defaultsDeep from \"lodash/defaultsDeep\";\nimport fetch from \"node-fetch\";\nimport { createClient } from \"redis\";\n\n/**\n * Context provided to tests - contains everything needed to run API tests.\n */\nexport interface TestContext {\n  /** URL for API requests (may be home or docs server depending on scenario) */\n  serverUrl: string;\n  /** URL for home server */\n  homeUrl: string;\n  /** User API client */\n  userApi: UserAPI;\n  /** Create a new testing document, or get it if already created **/\n  getOrCreateTestDoc: (workspace?: string) => Promise<string>;\n  /** Document IDs for fixture documents */\n  docIds: { [name: string]: string };\n  /** Axios config for Chimpy user */\n  chimpy: AxiosRequestConfig;\n  /** Axios config for Kiwi user */\n  kiwi: AxiosRequestConfig;\n  /** Axios config for Charon user */\n  charon: AxiosRequestConfig;\n  /** Axios config for anonymous user */\n  nobody: AxiosRequestConfig;\n  /** Axios config for support user */\n  support: AxiosRequestConfig;\n  /** Whether home API is available (false when direct to docworker) */\n  hasHomeApi: boolean;\n  /** Home server instance */\n  home: TestServer;\n  /** Docs server instance (same as home for merged) */\n  docs: TestServer;\n  /** Flush auth cache after permission changes */\n  flushAuth: () => Promise<void>;\n  /** Cleanup function - call in after() */\n  cleanup: () => Promise<void>;\n}\n\n/** Server configuration mode */\nexport type ServerMode = \"merged\" | \"separated\" | \"direct\";\n\n// Module-level state for each test run\nconst username = process.env.USER || \"nobody\";\nlet tmpDir: string;\nlet dataDir: string;\n// Track which test suites have been set up (keyed by testSuiteName)\nconst globalSetupDone = new Set<string>();\n\n// Pre-seeded document IDs from the test database\nconst docIds: { [name: string]: string } = {\n  ApiDataRecordsTest: \"sampledocid_7\",\n  Timesheets: \"sampledocid_13\",\n  Bananas: \"sampledocid_6\",\n};\n\n// Org name from the seeded database\nexport const ORG_NAME = \"docs-1\";\n\n/**\n * Flush Redis database if Redis is in use.\n */\nasync function flushAllRedis() {\n  if (process.env.TEST_REDIS_URL) {\n    const cli = createClient(process.env.TEST_REDIS_URL);\n    await cli.flushdbAsync();\n    await cli.quitAsync();\n  }\n}\n\n/**\n * Create a UserAPI instance for the given org and request config, as fetched by configForUser\n */\nexport function makeUserApi(homeUrl: string, org: string, config: AxiosRequestConfig): UserAPI {\n  return new UserAPIImpl(`${homeUrl}/o/${org}`, {\n    headers: config.headers as Record<string, string>,\n    fetch: fetch as unknown as typeof globalThis.fetch,\n    newFormData: () => new FormData() as any,\n  });\n}\n\n/**\n * Set up fixture documents in the data directory.\n */\nasync function setupDataDir(dir: string) {\n  await testUtils.copyFixtureDoc(\"Hello.grist\", path.resolve(dir, docIds.Timesheets + \".grist\"));\n  await testUtils.copyFixtureDoc(\"Hello.grist\", path.resolve(dir, docIds.Bananas + \".grist\"));\n  await testUtils.copyFixtureDoc(\n    \"ApiDataRecordsTest.grist\",\n    path.resolve(dir, docIds.ApiDataRecordsTest + \".grist\"),\n  );\n}\n\n/**\n * Get workspace ID by name.\n */\nasync function getWorkspaceId(api: UserAPI, name: string): Promise<number | undefined> {\n  const workspaces = await api.getOrgWorkspaces(\"current\");\n  return workspaces.find(w => w.name === name)?.id;\n}\n\n/**\n * Global setup that runs once before any scenario.\n * Sets up the temp directory and seeded database.\n */\nasync function globalSetup(testSuiteName: string) {\n  if (globalSetupDone.has(testSuiteName)) {\n    return;\n  }\n  globalSetupDone.add(testSuiteName);\n\n  // Create a stable temp directory (like DocApi.ts)\n  tmpDir = path.join(tmpdir(), `grist_test_${username}_${testSuiteName}`);\n  await prepareFilesystemDirectoryForTests(tmpDir);\n\n  // Create the seeded database\n  await prepareDatabase(tmpDir);\n}\n\n/**\n * Per-scenario setup. Sets up data directory and fixtures.\n */\nasync function scenarioSetup(suitename: string): Promise<{ docIds: { [name: string]: string } }> {\n  await flushAllRedis();\n\n  // Create data directory with fixtures\n  dataDir = path.join(tmpDir, `${suitename}-data`);\n  await fse.mkdirs(dataDir);\n  await setupDataDir(dataDir);\n\n  return { docIds: { ...docIds } };\n}\n\n/**\n * Create the TestContext with user configs and helpers.\n */\nfunction createContext(\n  home: TestServer,\n  docs: TestServer,\n  serverUrl: string,\n  homeUrl: string,\n  scenarioDocIds: { [name: string]: string },\n  hasHomeApi: boolean,\n): TestContext {\n  const userApi = makeUserApi(homeUrl, ORG_NAME, configForUser(\"chimpy\"));\n\n  const flushAuth = async () => {\n    await home.testingHooks.flushAuthorizerCache();\n    if (docs !== home) {\n      await docs.testingHooks.flushAuthorizerCache();\n    }\n  };\n\n  const getOrCreateTestDoc = async (workspace: string = \"Private\") => {\n    if (scenarioDocIds.TestDoc) {\n      return scenarioDocIds.TestDoc;\n    }\n    // Create TestDoc as an empty doc in Private workspace\n    const privateWorkspaceId = await getWorkspaceId(userApi, workspace);\n    scenarioDocIds.TestDoc = await userApi.newDoc({ name: \"TestDoc\" }, privateWorkspaceId!);\n    return scenarioDocIds.TestDoc;\n  };\n\n  const cleanup = async () => {\n    // Delete TestDoc if it was created\n    if (scenarioDocIds.TestDoc) {\n      await userApi.deleteDoc(scenarioDocIds.TestDoc);\n      delete scenarioDocIds.TestDoc;\n    }\n    await home.stop();\n    if (docs !== home) {\n      await docs.stop();\n    }\n  };\n\n  return {\n    serverUrl,\n    homeUrl,\n    userApi,\n    getOrCreateTestDoc,\n    docIds: scenarioDocIds,\n    chimpy: configForUser(\"Chimpy\"),\n    kiwi: configForUser(\"Kiwi\"),\n    charon: configForUser(\"Charon\"),\n    nobody: configForUser(\"Anonymous\"),\n    support: configForUser(\"Support\"),\n    hasHomeApi,\n    home,\n    docs,\n    flushAuth,\n    cleanup,\n  };\n}\n\n/**\n * Set up servers for a given mode.\n *\n * @param mode - \"merged\" (single server), \"separated\" (home + docworker), or \"direct\" (to docworker)\n * @param extraEnv - Additional environment variables\n */\nexport async function setupServers(\n  mode: ServerMode,\n  extraEnv?: Record<string, string>,\n): Promise<TestContext> {\n  const { docIds: scenarioDocIds } = await scenarioSetup(mode);\n\n  const env = {\n    GRIST_DATA_DIR: dataDir,\n    GRIST_EXTERNAL_ATTACHMENTS_MODE: \"test\",\n    // The XLS test fails on Jenkins without this. Mysterious? Maybe a real problem or\n    // a problem in test setup related to plugins? TODO: investigate and fix.\n    GRIST_SANDBOX_FLAVOR: \"unsandboxed\",\n    ...extraEnv,\n  };\n\n  let home: TestServer;\n  let docs: TestServer;\n  let serverUrl: string;\n\n  if (mode === \"merged\") {\n    home = docs = await TestServer.startServer(\"home,docs\", tmpDir, mode, env);\n    serverUrl = home.serverUrl;\n  } else {\n    home = await TestServer.startServer(\"home\", tmpDir, mode, env);\n    docs = await TestServer.startServer(\"docs\", tmpDir, mode, env, home.serverUrl);\n    serverUrl = mode === \"direct\" ? docs.serverUrl : home.serverUrl;\n  }\n\n  return createContext(home, docs, serverUrl, home.serverUrl, scenarioDocIds, mode !== \"direct\");\n}\n\n/**\n * Options for scenario configuration.\n */\nexport interface ScenarioOptions {\n  /** Additional environment variables to pass to the server */\n  extraEnv?: Record<string, string>;\n}\n\n/**\n * Add a single test scenario as a describe block.\n */\nfunction addScenario(\n  name: string,\n  mode: ServerMode,\n  addTests: (getCtx: () => TestContext) => void,\n  options: ScenarioOptions = {},\n) {\n  describe(name, function() {\n    let ctx: TestContext;\n\n    before(async function() {\n      ctx = await setupServers(mode, options.extraEnv);\n    });\n\n    after(async function() {\n      await ctx.cleanup();\n    });\n\n    addTests(() => ctx);\n  });\n}\n\n/**\n * Add all test scenarios to the current describe block.\n *\n * This creates nested describe blocks for each server configuration:\n * - \"merged server\" - always runs\n * - \"home + docworker\" - runs if Redis available\n * - \"direct to docworker\" - runs if Redis available\n *\n * @param addTests Function that adds it() blocks, receives context getter\n * @param testSuiteName Optional name for the test suite (used for temp directory)\n * @param options Optional configuration (extraEnv, etc.)\n */\nexport function addAllScenarios(\n  addTests: (getCtx: () => TestContext) => void,\n  testSuiteName: string = \"docapi\",\n  options: ScenarioOptions = {},\n) {\n  let oldEnv: testUtils.EnvironmentSnapshot;\n\n  // Global setup runs once before any scenario\n  before(async function() {\n    oldEnv = new testUtils.EnvironmentSnapshot();\n    await globalSetup(testSuiteName);\n  });\n\n  after(async function() {\n    oldEnv.restore();\n    globalSetupDone.delete(testSuiteName);\n  });\n\n  addScenario(\"merged server\", \"merged\", addTests, options);\n\n  if (process.env.TEST_REDIS_URL) {\n    addScenario(\"home + docworker\", \"separated\", addTests, options);\n    addScenario(\"direct to docworker\", \"direct\", addTests, options);\n  }\n}\n\nexport async function addAttachmentsToDoc(\n  serverUrl: string,\n  docId: string,\n  attachments: { name: string; contents: string }[],\n  config: AxiosRequestConfig,\n) {\n  const formData = new FormData();\n  for (const attachment of attachments) {\n    formData.append(\"upload\", attachment.contents, attachment.name);\n  }\n  const resp = await axios.post(`${serverUrl}/api/docs/${docId}/attachments`, formData,\n    defaultsDeep({ headers: formData.getHeaders() }, config));\n  assert.equal(resp.status, 200);\n  return resp;\n}\n"
  },
  {
    "path": "test/server/lib/extractOrg.ts",
    "content": "import { Hosts } from \"app/server/lib/extractOrg\";\nimport { listenPromise } from \"app/server/lib/serverUtils\";\n\nimport * as http from \"http\";\nimport { AddressInfo } from \"net\";\n\nimport { assert } from \"chai\";\nimport express from \"express\";\nimport { pick } from \"lodash\";\nimport fetch from \"node-fetch\";\n\ndescribe(\"extractOrg\", function() {\n  let port: number;\n  let server: http.Server;\n\n  const agent = new http.Agent();\n  const createConnection = (agent as any).createConnection;\n  (agent as any).createConnection = (options: any, cb: any) =>\n    createConnection.call(this, { ...options, host: \"localhost\", port }, cb);\n  const baseDomain = \".getgrist.com\";\n  const hosts = new Hosts(baseDomain, {\n    isMergedOrg(org: string) { return false; },\n    connection: {\n      manager: {\n        async findOne(table: any, options: { where: { domain?: string, host?: string } }) {\n          if (options.where.host === \"zoom.quick.com\") { return { domain: \"zoomy\" }; }\n          if (options.where.domain === \"zoomy\") { return { host: \"zoom.quick.com\" }; }\n          return undefined;\n        },\n      },\n    },\n  } as any, {\n    getPluginUrl() { return \"https://prod.grist-usercontent.com\"; },\n  } as any);\n\n  before(async () => {\n    // Create a dummy express app with extractOrg middleware, and an endpoint which reports\n    // various parts of the request.\n    const app = express();\n    server = http.createServer(app);\n    await listenPromise(server.listen(0, \"localhost\"));\n    app.use(hosts.extractOrg);\n    app.use(hosts.redirectHost);\n    app.use((req, res) => {\n      res.json(pick(req, [\"hostname\", \"path\", \"url\", \"org\", \"isCustomHost\"]));\n    });\n    port = (server.address() as AddressInfo).port;\n  });\n\n  after(() => {\n    server.close();\n    hosts.close();\n  });\n\n  // Fetches the URL from our dummy server regardless of the hostname, and returns a parsed JSON\n  // response which includes an extra 'STATUS' key with the status.\n  async function myFetch(url: string): Promise<any> {\n    const resp = await fetch(url, { agent });\n    try {\n      const values = await resp.json();\n      if (!values.isCustomHost) { delete values.isCustomHost; }\n      return { ...values, STATUS: resp.status };\n    } catch (e) {\n      return { STATUS: resp.status };\n    }\n  }\n\n  it(\"should set org to the subdomain from the Host header\", async function() {\n    assert.deepEqual(await myFetch(\"http://foo.getgrist.com\"),\n      { STATUS: 200, hostname: \"foo.getgrist.com\", path: \"/\", url: \"/\", org: \"foo\" });\n    assert.deepEqual(await myFetch(\"http://foo.getgrist.com/hello?world=1&123%20\"),\n      { STATUS: 200, hostname: \"foo.getgrist.com\", path: \"/hello\", url: \"/hello?world=1&123%20\", org: \"foo\" });\n    assert.deepEqual(await myFetch(\"http://foo-BAR-123.getgrist.com\"),\n      { STATUS: 200, hostname: \"foo-bar-123.getgrist.com\", path: \"/\", url: \"/\", org: \"foo-bar-123\" });\n    assert.deepEqual(await myFetch(\"http://foo.getgrist.com:9000\"),\n      { STATUS: 200, hostname: \"foo.getgrist.com\", path: \"/\", url: \"/\", org: \"foo\" });\n    assert.deepEqual(await myFetch(\"http://x.y.z.getgrist.com\"),\n      { STATUS: 200, hostname: \"x.y.z.getgrist.com\", path: \"/\", url: \"/\", org: \"x\" });\n    assert.deepEqual(await myFetch(\"http://localhost:9000\"),\n      { STATUS: 200, hostname: \"localhost\", path: \"/\", url: \"/\", org: \"\" });\n    assert.deepEqual(await myFetch(\"http://foo.getgrist.com/o/\"),\n      { STATUS: 200, hostname: \"foo.getgrist.com\", path: \"/o/\", url: \"/o/\", org: \"foo\" });\n  });\n\n  it(\"should set org to the /o/ORG value when it matches subdomain\", async function() {\n    assert.deepEqual(await myFetch(\"http://foo.getgrist.com/o/foo\"),\n      { STATUS: 200, hostname: \"foo.getgrist.com\", path: \"/\", url: \"/\", org: \"foo\" });\n    assert.deepEqual(await myFetch(\"http://foo.getgrist.com/o/foo/\"),\n      { STATUS: 200, hostname: \"foo.getgrist.com\", path: \"/\", url: \"/\", org: \"foo\" });\n    assert.deepEqual(await myFetch(\"http://foo.getgrist.com/o/foo/hello?world&123%20\"),\n      { STATUS: 200, hostname: \"foo.getgrist.com\", path: \"/hello\", url: \"/hello?world&123%20\", org: \"foo\" });\n    assert.deepEqual(await myFetch(\"http://foo-BAR-123.getgrist.com/o/foo-bAr-123/doc/123\"),\n      { STATUS: 200, hostname: \"foo-bar-123.getgrist.com\", path: \"/doc/123\", url: \"/doc/123\", org: \"foo-bar-123\" });\n    assert.deepEqual(await myFetch(\"http://foo.getgrist.com/o/bar\"),\n      { STATUS: 400, error: \"Wrong org for this domain: 'bar' does not match 'foo'\" });\n  });\n\n  it(\"should set org to the /o/ORG value when no subdomain in request\", async function() {\n    assert.deepEqual(await myFetch(\"http://localhost:8000/o/foo\"),\n      { STATUS: 200, hostname: \"localhost\", path: \"/\", url: \"/\", org: \"foo\" });\n    assert.deepEqual(await myFetch(\"http://localhost:8000/o/foo/\"),\n      { STATUS: 200, hostname: \"localhost\", path: \"/\", url: \"/\", org: \"foo\" });\n    assert.deepEqual(await myFetch(\"http://localhost:8000/o/foo/hello?world&123%20\"),\n      { STATUS: 200, hostname: \"localhost\", path: \"/hello\", url: \"/hello?world&123%20\", org: \"foo\" });\n    assert.deepEqual(await myFetch(\"http://localhost:8000/o/bar\"),\n      { STATUS: 200, hostname: \"localhost\", path: \"/\", url: \"/\", org: \"bar\" });\n    assert.deepEqual(await myFetch(\"http://localhost:8000/o/\"),\n      { STATUS: 200, hostname: \"localhost\", path: \"/o/\", url: \"/o/\", org: \"\" });\n\n    assert.deepEqual(await myFetch(\"http://x.y.z.getgrist.com/o/bar\"),\n      { STATUS: 400, error: \"Wrong org for this domain: 'bar' does not match 'x'\" });\n\n    // Certain subdomains are not treated as significant, and org can be read from path\n    assert.deepEqual(await myFetch(\"http://api.getgrist.com/o/bar\"),\n      { STATUS: 200, hostname: \"api.getgrist.com\", path: \"/\", url: \"/\", org: \"bar\" });\n    assert.deepEqual(await myFetch(\"http://v1-staging.getgrist.com/o/bar/test\"),\n      { STATUS: 200, hostname: \"v1-staging.getgrist.com\", path: \"/test\", url: \"/test\", org: \"bar\" });\n  });\n\n  it(\"should produce URL that starts with slash\", async function() {\n    // Trailing slash shouldn't matter to the result.\n    assert.deepEqual(await myFetch(\"http://api.getgrist.com/o/bar\"),\n      { STATUS: 200, hostname: \"api.getgrist.com\", path: \"/\", url: \"/\", org: \"bar\" });\n    assert.deepEqual(await myFetch(\"http://api.getgrist.com/o/bar/\"),\n      { STATUS: 200, hostname: \"api.getgrist.com\", path: \"/\", url: \"/\", org: \"bar\" });\n    assert.deepEqual(await myFetch(\"http://bar.getgrist.com\"),\n      { STATUS: 200, hostname: \"bar.getgrist.com\", path: \"/\", url: \"/\", org: \"bar\" });\n    assert.deepEqual(await myFetch(\"http://bar.getgrist.com/\"),\n      { STATUS: 200, hostname: \"bar.getgrist.com\", path: \"/\", url: \"/\", org: \"bar\" });\n\n    // Trailing slash shouldn't matter when followed by \"?\"\n    assert.deepEqual(await myFetch(\"http://api.getgrist.com/o/bar?asdf\"),\n      { STATUS: 200, hostname: \"api.getgrist.com\", path: \"/\", url: \"/?asdf\", org: \"bar\" });\n    assert.deepEqual(await myFetch(\"http://api.getgrist.com/o/bar/?asdf\"),\n      { STATUS: 200, hostname: \"api.getgrist.com\", path: \"/\", url: \"/?asdf\", org: \"bar\" });\n    assert.deepEqual(await myFetch(\"http://api.getgrist.com/o/bar/baz?asdf\"),\n      { STATUS: 200, hostname: \"api.getgrist.com\", path: \"/baz\", url: \"/baz?asdf\", org: \"bar\" });\n    assert.deepEqual(await myFetch(\"http://bar.getgrist.com?asdf\"),\n      { STATUS: 200, hostname: \"bar.getgrist.com\", path: \"/\", url: \"/?asdf\", org: \"bar\" });\n    assert.deepEqual(await myFetch(\"http://bar.getgrist.com/?asdf\"),\n      { STATUS: 200, hostname: \"bar.getgrist.com\", path: \"/\", url: \"/?asdf\", org: \"bar\" });\n  });\n\n  it(\"should return 404 for unrecognized domains\", async function() {\n    assert.deepEqual(await myFetch(\"http://getgrist.com/\"),\n      { STATUS: 404, error: \"Domain not recognized: getgrist.com\" });\n    assert.deepEqual(await myFetch(\"http://example.com\"),\n      { STATUS: 404, error: \"Domain not recognized: example.com\" });\n    assert.deepEqual(await myFetch(\"http://1.2.3.4/\"),\n      { STATUS: 404, error: \"Domain not recognized: 1.2.3.4\" });\n  });\n\n  it(\"should recognize custom domains\", async function() {\n    assert.deepEqual(await myFetch(\"http://zoom.quick.com/d\"),\n      { STATUS: 200, hostname: \"zoom.quick.com\", path: \"/d\", url: \"/d\",\n        org: \"zoomy\", isCustomHost: true });\n    assert.deepEqual(await myFetch(\"http://zoom.quick.com/o/zoomy/d\"),\n      { STATUS: 200, hostname: \"zoom.quick.com\", path: \"/d\", url: \"/d\",\n        org: \"zoomy\", isCustomHost: true });\n    assert.deepEqual(await myFetch(\"http://zoom.quick.com/o/zoom/d\"),\n      { STATUS: 400, error: \"Wrong org for this domain: 'zoom' does not match 'zoomy'\" });\n  });\n\n  it(\"should recognize plugin domains\", async function() {\n    assert.deepEqual(await myFetch(\"http://prod.grist-usercontent.com/d\"),\n      { STATUS: 200, hostname: \"prod.grist-usercontent.com\", path: \"/d\", url: \"/d\",\n        org: \"\" });\n    assert.deepEqual(await myFetch(\"http://prod2.grist-usercontent.com/d\"),\n      { STATUS: 404, error: \"Domain not recognized: prod2.grist-usercontent.com\" });\n    assert.deepEqual(await myFetch(\"http://getgrist.localtest.me/d\"),\n      { STATUS: 404, error: \"Domain not recognized: getgrist.localtest.me\" });\n  });\n\n  it(\"should redirect to custom domains\", async function() {\n    assert.deepEqual(await myFetch(\"http://zoomy.getgrist.com/d\"),\n      { STATUS: 200, hostname: \"zoom.quick.com\", path: \"/d\", url: \"/d\",\n        org: \"zoomy\", isCustomHost: true });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/helpers/PrepareDatabase.ts",
    "content": "import * as testUtils from \"test/server/testUtils\";\n\nimport { execFileSync } from \"child_process\";\nimport path from \"path\";\n\nexport async function prepareDatabase(tempDirectory: string, filename: string = \"landing.db\") {\n  // Let's create a sqlite db that we can share with servers that run in other processes, hence\n  // not an in-memory db. Running seed.ts directly might not take in account the most recent value\n  // for TYPEORM_DATABASE, because ormconfig.js may already have been loaded with a different\n  // configuration (in-memory for instance). Spawning a process is one way to make sure that the\n  // latest value prevail.\n  process.env.TYPEORM_DATABASE = path.join(tempDirectory, filename);\n  const seed = await testUtils.getBuildFile(\"test/gen-server/seed.js\");\n  execFileSync(\"node\", [seed, \"init\"], {\n    env: process.env,\n    stdio: \"inherit\",\n  });\n}\n"
  },
  {
    "path": "test/server/lib/helpers/PrepareFilesystemDirectoryForTests.ts",
    "content": "import log from \"app/server/lib/log\";\n\nimport * as fse from \"fs-extra\";\n\nexport async function prepareFilesystemDirectoryForTests(directory: string) {\n  // Create the tmp dir removing any previous one\n  await fse.remove(directory);\n  await fse.mkdirs(directory);\n  log.warn(`Test logs and data are at: ${directory}/`);\n}\n"
  },
  {
    "path": "test/server/lib/helpers/Signal.ts",
    "content": "import { delay } from \"bluebird\";\nimport { assert } from \"chai\";\n\n/**\n * Helper that creates a promise that can be resolved from outside.\n *\n * @example\n * const methodCalled = signal();\n * setTimeout(() => methodCalled.emit(), 1000);\n * methodCalled.assertNotCalled(); // won't throw as the method hasn't been called yet\n * await methodCalled.wait(); // will wait for the method to be called\n * await methodCalled.wait(); // can be called multiple times\n * methodCalled.reset(); // resets the signal (so that it can be awaited again)\n * setTimeout(() => methodCalled.emit(), 3000);\n * await methodCalled.wait(); // will fail, as we wait only 2 seconds\n */\nexport function signal() {\n  let resolve: null | ((data: any) => void) = null;\n  let promise: null | Promise<any> = null;\n  let called = false;\n  return {\n    emit(data: any) {\n      if (!resolve) {\n        throw new Error(\"signal.emit() called before signal.reset()\");\n      }\n      called = true;\n      resolve(data);\n    },\n    async wait() {\n      if (!promise) {\n        throw new Error(\"signal.wait() called before signal.reset()\");\n      }\n      const proms = Promise.race([\n        promise,\n        delay(2000).then(() => {\n          throw new Error(\"signal.wait() timed out\");\n        }),\n      ]);\n      return await proms;\n    },\n    async waitAndReset() {\n      try {\n        return await this.wait();\n      } finally {\n        this.reset();\n      }\n    },\n    assertNotCalled() {\n      assert.isFalse(called);\n    },\n    reset() {\n      called = false;\n      promise = new Promise((res) => {\n        resolve = res;\n      });\n    },\n  };\n}\n"
  },
  {
    "path": "test/server/lib/helpers/TestProxyServer.ts",
    "content": "import { serveSomething, Serving } from \"test/server/customUtil\";\n\nimport axios from \"axios\";\nimport * as express from \"express\";\n\nexport class TestProxyServer {\n  public static async Prepare(portNumber: number): Promise<TestProxyServer> {\n    const server = new TestProxyServer(portNumber);\n    await server._prepare();\n    return server;\n  }\n\n  public get proxyCallCounter() { return this._proxyCallsCounter; }\n  private _proxyCallsCounter: number = 0;\n  private _proxyServing: Serving;\n\n  private constructor(public readonly portNumber: number) {\n  }\n\n  public wasProxyCalled(): boolean {\n    return this._proxyCallsCounter > 0;\n  }\n\n  public async dispose() {\n    await this._proxyServing.shutdown();\n  }\n\n  private async _prepare() {\n    this._proxyServing = await serveSomething((app) => {\n      app.use(express.json());\n      app.all(\"*\", async (req: express.Request, res: express.Response) => {\n        this._proxyCallsCounter += 1;\n        let responseCode;\n        try {\n          const axiosResponse = await axios.post(req.url, req.body);\n          responseCode = axiosResponse.status;\n        } catch (error: any) {\n          responseCode = error.response.status;\n        }\n        res.sendStatus(responseCode);\n        res.end();\n      });\n    }, this.portNumber);\n  }\n}\n"
  },
  {
    "path": "test/server/lib/helpers/TestServer.ts",
    "content": "import { isAffirmative } from \"app/common/gutil\";\nimport { UserAPIImpl } from \"app/common/UserAPI\";\nimport log from \"app/server/lib/log\";\nimport { exitPromise, getAvailablePort } from \"app/server/lib/serverUtils\";\nimport { connectTestingHooks, TestingHooksClient } from \"app/server/lib/TestingHooks\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { ChildProcess, execFileSync, spawn } from \"child_process\";\nimport * as http from \"http\";\nimport path from \"path\";\nimport { Writable } from \"stream\";\n\nimport { delay } from \"bluebird\";\nimport express from \"express\";\nimport FormData from \"form-data\";\nimport * as fse from \"fs-extra\";\nimport httpProxy from \"http-proxy\";\nimport fetch from \"node-fetch\";\n\n/**\n * This starts a server in a separate process.\n */\nexport class TestServer {\n  public static async startServer(\n    serverTypes: string,\n    tempDirectory: string,\n    suitename: string,\n    customEnv?: NodeJS.ProcessEnv,\n    _homeUrl?: string,\n    options: { output?: Writable } = {},      // Pipe server output to the given stream\n  ): Promise<TestServer> {\n    const port = await getAvailablePort(parseInt(process.env.GET_AVAILABLE_PORT_START || \"8080\", 10));\n    const server = new this(serverTypes, port, tempDirectory, suitename);\n    await server.start(_homeUrl, customEnv, options);\n    return server;\n  }\n\n  public testingSocket: string;\n  public testingHooks: TestingHooksClient;\n  public stopped = false;\n  public get serverUrl() {\n    if (this._proxiedServer) {\n      throw new Error(\"Direct access to this test server is disallowed\");\n    }\n\n    return `http://localhost:${this.port}`;\n  }\n\n  public get proxiedServer() { return this._proxiedServer; }\n\n  private _server: ChildProcess;\n  private _exitPromise: Promise<number | string>;\n  private _proxiedServer: boolean = false;\n\n  private readonly _defaultEnv;\n\n  constructor(\n    private _serverTypes: string,\n    public readonly port: number,\n    public readonly rootDir: string,\n    private _suiteName: string,\n  ) {\n    this._defaultEnv = {\n      GRIST_INST_DIR: this.rootDir,\n      GRIST_DATA_DIR: path.join(this.rootDir, \"data\"),\n      GRIST_SERVERS: this._serverTypes,\n      GRIST_DISABLE_S3: \"true\",\n      REDIS_URL: process.env.TEST_REDIS_URL,\n      GRIST_TRIGGER_WAIT_DELAY: \"100\",\n      // this is calculated value, some tests expect 4 attempts and some will try 3 times\n      GRIST_TRIGGER_MAX_ATTEMPTS: \"4\",\n      GRIST_MAX_QUEUE_SIZE: \"10\",\n      ...process.env,\n    };\n  }\n\n  public async start(homeUrl?: string, customEnv?: NodeJS.ProcessEnv, options: { output?: Writable } = {}) {\n    // put node logs into files with meaningful name that relate to the suite name and server type\n    const fixedName = this._serverTypes.replace(/,/, \"_\");\n    const nodeLogPath = path.join(this.rootDir, `${this._suiteName}-${fixedName}-node.log`);\n    const nodeLogFd = await fse.open(nodeLogPath, \"a\");\n    const serverLog = options.output ? \"pipe\" : (process.env.VERBOSE ? \"inherit\" : nodeLogFd);\n    // use a path for socket that relates to suite name and server types\n    this.testingSocket = path.join(this.rootDir, `${this._suiteName}-${fixedName}.socket`);\n    if (this.testingSocket.length >= 104) {\n      // Unix socket paths typically can't be longer than this. Who knew. Make the error obvious.\n      throw new Error(`Path of testingSocket too long: ${this.testingSocket.length} (${this.testingSocket})`);\n    }\n\n    const env: NodeJS.ProcessEnv = {\n      APP_HOME_URL: homeUrl,\n      GRIST_TESTING_SOCKET: this.testingSocket,\n      GRIST_PORT: String(this.port),\n      ...this._defaultEnv,\n      ...customEnv,\n    };\n    const main = await testUtils.getBuildFile(\"app/server/MergedServer.js\");\n    this._server = spawn(\"node\", [main, \"--testingHooks\"], {\n      env,\n      stdio: [\"inherit\", serverLog, serverLog],\n    });\n    if (options.output) {\n      this._server.stdout!.pipe(options.output);\n      this._server.stderr!.pipe(options.output);\n    }\n\n    this._exitPromise = exitPromise(this._server);\n\n    // Try to be more helpful when server exits by printing out the tail of its log.\n    this._exitPromise.then((code) => {\n      if (this._server.killed) {\n        return;\n      }\n      log.error(\"Server died unexpectedly, with code\", code);\n      const output = execFileSync(\"tail\", [\"-30\", nodeLogPath]);\n      log.info(`\\n===== BEGIN SERVER OUTPUT ====\\n${output}\\n===== END SERVER OUTPUT =====`);\n    })\n      .catch(() => undefined);\n\n    await this._waitServerReady();\n    log.info(`server ${this._serverTypes} up and listening on ${this.serverUrl}`);\n  }\n\n  public async stop() {\n    if (this.stopped) {\n      return;\n    }\n    log.info(\"Stopping node server: \" + this._serverTypes);\n    this.stopped = true;\n    this._server.kill();\n    this.testingHooks.close();\n    await this._exitPromise;\n  }\n\n  public async isServerReady(): Promise<boolean> {\n    // Let's wait for the testingSocket to be created, then get the port the server is listening on,\n    // and then do an api check. This approach allow us to start server with GRIST_PORT set to '0',\n    // which will listen on first available port, removing the need to hard code a port number.\n    try {\n      // wait for testing socket\n      while (!(await fse.pathExists(this.testingSocket))) {\n        await delay(200);\n      }\n\n      // create testing hooks and get own port\n      this.testingHooks = await connectTestingHooks(this.testingSocket);\n\n      // wait for check\n      return (await fetch(`${this.serverUrl}/status/hooks`, { timeout: 1000 })).ok;\n    } catch (err) {\n      log.warn(\"Failed to initialize server\", err);\n      return false;\n    }\n  }\n\n  // Get access to the ChildProcess object for this server, e.g. to get its PID.\n  public getChildProcess(): ChildProcess { return this._server; }\n\n  // Returns the promise for the ChildProcess's signal or exit code.\n  public getExitPromise(): Promise<string | number> { return this._exitPromise; }\n\n  public makeUserApi(org: string, user: string = \"chimpy\"): UserAPIImpl {\n    return new UserAPIImpl(`${this.serverUrl}/o/${org}`, {\n      headers: { Authorization: `Bearer api_key_for_${user}` },\n      fetch: fetch as unknown as typeof globalThis.fetch,\n      newFormData: () => new FormData() as any,\n    });\n  }\n\n  /**\n   * Assuming that the server is behind a reverse-proxy (like TestServerReverseProxy),\n   * disallow access to the serverUrl to prevent the tests to join the server directly.\n   */\n  public disallowDirectAccess() {\n    this._proxiedServer = true;\n  }\n\n  private async _waitServerReady() {\n    // It's important to clear the timeout, because it can prevent node from exiting otherwise,\n    // which is annoying when running only this test for debugging.\n    let timeout: any;\n    const maxDelay = new Promise((resolve) => {\n      timeout = setTimeout(resolve, 30000);\n    });\n    try {\n      await Promise.race([\n        this.isServerReady(),\n        this._exitPromise.then(() => {\n          throw new Error(\"Server exited while waiting for it\");\n        }),\n        maxDelay,\n      ]);\n    } finally {\n      clearTimeout(timeout);\n    }\n  }\n}\n\nconst FROM_OUTSIDE_HEADER_KEY = \"X-FROM-OUTSIDE\";\n\n/**\n * Creates a reverse-proxy for a home and a doc worker.\n *\n * The workers are then disallowed to be joined directly, the tests are assumed to\n * pass through this reverse-proxy.\n *\n * You may use it like follow:\n * ```ts\n * const proxy = await TestServerReverseProxy.build();\n * // Create here a doc and a home workers with their env variables\n * proxy.requireFromOutsideHeader(); // Optional\n * await proxy.start(home, docs);\n * ```\n */\nexport class TestServerReverseProxy {\n  // Use a different hostname for the proxy than the doc and home workers'\n  // so we can ensure that either we omit the Origin header (so the internal calls to home and doc workers\n  // are not considered as CORS requests), or otherwise we fail because the hostnames are different\n  // https://github.com/gristlabs/grist-core/blob/24b39c651b9590cc360cc91b587d3e1b301a9c63/app/server/lib/requestUtils.ts#L85-L98\n  public static readonly HOSTNAME: string = \"grist-test-proxy.127.0.0.1.nip.io\";\n\n  public static FROM_OUTSIDE_HEADER = { [FROM_OUTSIDE_HEADER_KEY]: true };\n\n  public static async build() {\n    const port = await getAvailablePort(parseInt(process.env.GET_AVAILABLE_PORT_START || \"8080\", 10));\n    return new this(port);\n  }\n\n  public get serverUrl() { return `http://${TestServerReverseProxy.HOSTNAME}:${this.port}`; }\n\n  private _app = express();\n  private _proxyServer: http.Server;\n  private _proxy: httpProxy = httpProxy.createProxy();\n  private _requireFromOutsideHeader = false;\n\n  public get stopped() { return !this._proxyServer.listening; }\n\n  public constructor(public readonly port: number) {\n    this._proxyServer = this._app.listen(port);\n  }\n\n  /**\n  * Require the reverse-proxy to be called from the outside world.\n  * This assumes that every requests to the proxy includes the header\n  * provided in TestServerReverseProxy.FROM_OUTSIDE_HEADER\n  *\n  * If a call is done by a worker (assuming they don't include that header),\n  * the proxy rejects with a FORBIDEN http status.\n  */\n  public requireFromOutsideHeader() {\n    this._requireFromOutsideHeader = true;\n  }\n\n  public start(homeServer: TestServer, docServer: TestServer) {\n    this._app.all([\"/dw/dw1\", \"/dw/dw1/*\"], this._getRequestHandlerFor(docServer));\n    this._app.all(\"/*\", this._getRequestHandlerFor(homeServer));\n\n    // Forbid now the use of serverUrl property, so we don't allow the tests to\n    // call the workers directly\n    homeServer.disallowDirectAccess();\n    docServer.disallowDirectAccess();\n\n    log.info(\"proxy server running on \", this.serverUrl);\n  }\n\n  public stop() {\n    if (this.stopped) {\n      return;\n    }\n    log.info(\"Stopping node TestServerReverseProxy\");\n    this._proxyServer.close();\n    this._proxy.close();\n  }\n\n  private _getRequestHandlerFor(server: TestServer) {\n    const serverUrl = new URL(server.serverUrl);\n\n    return (oreq: express.Request, ores: express.Response) => {\n      log.debug(`[proxy] Requesting (method=${oreq.method}): ${new URL(oreq.url, serverUrl).href}`);\n\n      // See the requireFromOutsideHeader() method for the explanation\n      if (this._requireFromOutsideHeader && !isAffirmative(oreq.get(FROM_OUTSIDE_HEADER_KEY))) {\n        log.error(\"TestServerReverseProxy: called public URL from internal\");\n        return ores.status(403).json({ error: \"TestServerReverseProxy: called public URL from internal \" });\n      }\n\n      this._proxy.web(oreq, ores, { target: serverUrl });\n    };\n  }\n}\n"
  },
  {
    "path": "test/server/lib/idUtils.ts",
    "content": "import { makeId } from \"app/server/lib/idUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"idUtils\", function() {\n  this.timeout(10000);\n\n  it(\"makes distinct ids with consistent length\", function() {\n    const ids = new Set<string>();\n    for (let i = 0; i < 10000; i++) {\n      const id = makeId();\n      assert.lengthOf(id, 22);\n      assert.equal(ids.has(id), false);\n      ids.add(id);\n    }\n  });\n});\n"
  },
  {
    "path": "test/server/lib/requestUtils.ts",
    "content": "import { trustOrigin } from \"app/server/lib/requestUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"requestUtils\", function() {\n  describe(\"trustOrigin\", function() {\n    const combinations = [\n      [\"http://localhost:8080\", \"localhost:9999\", true],\n      [\"http://localhost:8080\", \"api.getgrist.com\", false],\n      [\"https://docs.getgrist.com\", \"docs.getgrist.com\", true],\n      [\"https://www.getgrist.com\", \"docs.getgrist.com\", true],\n      [\"https://nytimes.com\", \"docs.getgrist.com\", false],\n      [\"https://getgrist.com.co.uk\", \"docs.getgrist.com\", false],\n      [\"https://efc-r.com\", \"docs.getgrist.com\", false],\n      [\"https://efc-r.com\", \"nasa.getgrist.com\", false],\n      [\"https://nasa.efc-r.com\", \"docs.getgrist.com\", false],\n      [\"https://nasa.efc-r.com\", \"docs.efc-r.com\", true],\n      [\"https://nasa.efc-r.com\", \"api.efc-r.com\", true],\n    ];\n    for (const [origin, host, permitted] of combinations) {\n      it(`${origin} can${permitted ? \"\" : \"not\"} access ${host} in browser`, function() {\n        assert.equal(\n          trustOrigin({ headers: { origin, host } } as any, { header: (a: string, b: string) => true } as any),\n          permitted,\n        );\n      });\n    }\n  });\n});\n"
  },
  {
    "path": "test/server/lib/sandboxUtil.ts",
    "content": "import * as sandboxUtil from \"app/server/lib/sandboxUtil\";\nimport { captureLog } from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"sandboxUtil\", function() {\n  describe(\"makeLinePrefixer\", function() {\n    it(\"should not interpret placeholders\", async function() {\n      const messages = await captureLog(\"debug\", () => {\n        const prefixer = sandboxUtil.makeLinePrefixer(\"My prefix: \", { foo: \"bar\" });\n        prefixer(Buffer.from(\n          \"Hello!\\n\" +\n          \"My name is %s!\\n\",\n        ));\n      });\n      assert.deepEqual(messages, [\n        \"info: My prefix: Hello! foo=bar\",\n        \"info: My prefix: My name is %s! foo=bar\",\n      ]);\n    });\n  });\n\n  describe(\"makeLogLinePrefixer\", function() {\n    it(\"should escape non-printable characters\", async function() {\n      const messages = await captureLog(\"debug\", () => {\n        const prefixer = sandboxUtil.makeLogLinePrefixer(\"My prefix: \", { foo: \"bar\" });\n        prefixer(Buffer.from(\"Some chars: \\n \\t \\0 \\b π Ї 🙂\\n\"));\n      });\n      assert.deepEqual(messages, [\n        \"info: My prefix: Some chars: \\n \\\\t \\\\u0000 \\\\b π Ї 🙂 foo=bar\",\n      ]);\n    });\n\n    it(\"should break up log messages but not other lines\", async function() {\n      const messages = await captureLog(\"debug\", () => {\n        const prefixer = sandboxUtil.makeLogLinePrefixer(\"My prefix: \", { foo: \"bar\" });\n        prefixer(Buffer.from(\n          \"[INFO] [engine] Hello!\\n\" +\n          \"[WARNING] [engine] World, with\\n\" +\n          \"  extra\\n\" +\n          \"lines\\n\" +\n          \"[WARNING] another message\\n\" +\n          \"with two lines\\n\",\n        ));\n      });\n      assert.deepEqual(messages, [\n        \"info: My prefix: [INFO] [engine] Hello! foo=bar\",\n        \"info: My prefix: [WARNING] [engine] World, with\\n  extra\\nlines foo=bar\",\n        \"info: My prefix: [WARNING] another message\\nwith two lines foo=bar\",\n      ]);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/serverUtils.js",
    "content": "var assert = require(\"chai\").assert;\nvar net = require(\"net\");\nvar Promise = require(\"bluebird\");\n\nvar serverUtils = require(\"app/server/lib/serverUtils\");\n\ndescribe(\"serverUtils\", function() {\n  describe(\"#getAvailablePort\", function() {\n    var tmpServers = [];\n\n    function holdPort(port) {\n      return new Promise((resolve, reject) => {\n        let server = net.createServer();\n        server.on(\"error\", reject);\n        server.listen(port, \"localhost\", resolve);\n        tmpServers.push(server);\n      });\n    }\n\n    afterEach(function() {\n      tmpServers.forEach(server => server.close());\n    });\n\n    it(\"should find an available port number\", function() {\n      this.timeout(60000);\n      var port1;\n      // Try getting a somewhat random port.\n      return serverUtils.getAvailablePort(9123, 20)\n        .then(port => {\n          port1 = port;\n          assert.isAtLeast(port, 9123);\n          assert.isAtMost(port, 9143);\n          return holdPort(port);\n        })\n        .then(() => {\n        // While holding the port we got, do it again.\n          return serverUtils.getAvailablePort(9123, 20);\n        })\n        .then(port => {\n          assert.isAtLeast(port, 9123);\n          assert.isAtMost(port, 9143);\n          // Ensure that the new port is different from the first one.\n          assert.notEqual(port, port1);\n          return holdPort(port);\n        })\n        .then(() => {\n          return serverUtils.getAvailablePort(port1, 2)\n            .then(() => {\n              assert(false, \"Ports \" + port1 + \" and next should not be available\");\n            }, err => {\n              assert.match(err.toString(), /No available ports/);\n            });\n        })\n        .then(() => {\n          return serverUtils.getAvailablePort();\n        })\n        .then(port => {\n          assert.isAtLeast(port, 8000);\n          assert.isAtMost(port, 8200);\n        });\n    });\n  });\n\n  describe(\"isPathWithin\", function() {\n    it(\"should return when on path is within another\", function() {\n      assert.strictEqual(serverUtils.isPathWithin(\"/foo/bar\", \"/foo/bar/baz\"), true);\n      assert.strictEqual(serverUtils.isPathWithin(\"/foo/bar\", \"/foo/bar\"), true);\n      assert.strictEqual(serverUtils.isPathWithin(\"/foo/bar\", \"/foo/baz/bar\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"/foo/bar\", \"/foo/barbaz\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"/foo/bar\", \"/foo/baz\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"/foo/bar\", \"/foo/ba\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"/foo/bar\", \"/foo\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"/foo/bar\", \"/\"), false);\n\n      // Paths get normalized.\n      assert.strictEqual(serverUtils.isPathWithin(\"///foo/.//bar//./\", \"/foo/bar/baz\"), true);\n      assert.strictEqual(serverUtils.isPathWithin(\"///foo/.//bar//./\", \"/foo/baz\"), false);\n\n      // Works with all relative paths.\n      assert.strictEqual(serverUtils.isPathWithin(\"foo/bar\", \"foo/bar/baz\"), true);\n      assert.strictEqual(serverUtils.isPathWithin(\"foo/bar\", \"./foo/bar\"), true);\n      assert.strictEqual(serverUtils.isPathWithin(\"foo/bar\", \"foo/baz/bar\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"foo/bar\", \"foo/barbaz\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"foo/bar\", \"./foo/baz\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"foo/bar\", \"foo/ba\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"foo/bar\", \"foo\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"foo/bar\", \".\"), false);\n      assert.strictEqual(serverUtils.isPathWithin(\"foo/bar\", \"\"), false);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/serverUtils2.ts",
    "content": "import { exitPromise, expectedResetDate } from \"app/server/lib/serverUtils\";\nimport { assert } from \"test/server/testUtils\";\n\nimport { spawn } from \"child_process\";\n\ndescribe(\"serverUtils2\", function() {\n  describe(\"exitPromise\", function() {\n    it(\"should resolve to exit code when child process exits\", async function() {\n      const child = spawn(\"echo\", [\"hello\", \"world\"]);\n      assert.strictEqual(await exitPromise(child), 0);\n\n      const child2 = spawn(\"exit 4\", [], { shell: true });\n      assert.strictEqual(await exitPromise(child2), 4);\n    });\n\n    it(\"should resolve to signal when child process is killed\", async function() {\n      const child = spawn(\"sleep\", [\"1\"]);\n      child.kill();\n      assert.strictEqual(await exitPromise(child), \"SIGTERM\");\n\n      const child2 = spawn(\"sleep\", [\"1\"]);\n      child2.kill(\"SIGINT\");\n      assert.strictEqual(await exitPromise(child2), \"SIGINT\");\n    });\n\n    it(\"should be rejected when child process can't start\", async function() {\n      const child = spawn(\"non-existent-command-83714\", [\"hello\"]);\n      await assert.isRejected(exitPromise(child), /ENOENT/);\n    });\n  });\n\n  describe(\"period calculations\", function() {\n    it(\"should give up for wrong data\", function() {\n      // Accepts plausible dates.\n      assert.isNotNull(test(day(-40), day(-40 + 365))); // NOW somewhere in the second period.\n\n      // Wrong period dates.\n      assert.isNull(test(day(360), day(0))); // start after end\n      assert.isNull(test(NOW, NOW)); // start equals end\n\n      // Period outside the ~year range.\n      assert.isNull(test(day(-365 - 1), day(-1)));\n      assert.isNull(test(day(1), day(365 + 1)));\n    });\n\n    it(\"should not calculate reset dates for first subperiod\", function() {\n      // If now is in the first month (on yearly period), we should have no reset date.\n      assert.isNull(test(NOW, day(365))); // started exactly now.\n      assert.isNull(test(day(-1), day(365 - 1))); // started yesterday.\n      assert.isNull(test(day(-10), day(365 - 10))); // started 10 days ago.\n    });\n\n    it(\"should calculate properly and the start\", function() {\n      // If period starts 9 days before, we should have null.\n      assert.equal(test2(\"2025-01-01\", \"2026-01-01\"), null);\n\n      // But if the period started month ago, and we are in the second month, we should have a reset date.\n      assert.equal(test2(\"2024-12-09\", \"2025-12-09\"), str(\"2025-01-09\"));\n    });\n\n    it(\"should calculate properly and the end\", function() {\n      // If period ends tomorrow, we should have a reset date\n      assert.equal(test2(\"2024-01-11\", \"2025-01-11\"), str(\"2024-12-11\"));\n      // Same that if the period ends in next month.\n      assert.equal(test2(\"2024-02-11\", \"2025-02-11\"), str(\"2024-12-11\"));\n      // And in 4 months.\n      assert.equal(test2(\"2024-05-11\", \"2025-05-11\"), str(\"2024-12-11\"));\n    });\n  });\n});\n\nconst D = 24 * 60 * 60 * 1000;\n// const M = 30.5 * D;\nconst NOW = new Date(\"2025-01-10T00:00:00Z\").getTime();\nconst day = (d: number) => new Date(Math.floor(NOW + d * D)).getTime();\nconst test = (start: number, end: number) => expectedResetDate(start, end, NOW);\nconst str = (s: string) => new Date(s + \"T00:00:00Z\").getTime();\nconst test2 = (start: string, end: string) => expectedResetDate(str(start), str(end), NOW);\n"
  },
  {
    "path": "test/server/lib/shortDesc.js",
    "content": "const assert = require(\"chai\").assert;\nconst {shortDesc} = require(\"app/server/lib/shortDesc\");\nconst _ = require(\"underscore\");\n\ndescribe(\"shortDesc\", function() {\n  it(\"should produce human-friendly output\", function() {\n    assert.equal(shortDesc(new Array(101).join(\"abcd \")),\n      \"'\" + new Array(17).join(\"abcd \") + \"... (500 length)'\");\n    assert.equal(shortDesc(_.range(1000)),\n      \"[0, 1, 2, 3, 4, ... (1000 items)]\");\n    assert.equal(shortDesc({a: 123, b: { c: [\"d\"] }}),\n      \"{a: 123, b: {c: ['d']}}\");\n    assert.equal(shortDesc(Uint8Array.from([84, 101, 120, 116])),\n      \"b'Text'\");\n    assert.equal(shortDesc(Uint8Array.from([0, 101, 189, 116])),\n      \"b'?e?t'\");\n  });\n\n  it(\"should respect passed-in limits\", function() {\n    assert.equal(shortDesc([\n      [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],\n      \"abcdefghij\",\n      {1:1, 2:2, 3:3, 4:4, 5:5, 6:6},\n      Uint8Array.from(_.range(30, 35).concat(_.range(125, 135)))\n    ], {\n      maxArrayLength: 7,\n      maxStringLength: 7,\n      maxObjectKeys: 3,\n      maxBufferLength: 12\n    }), \"[[1, 2, 3, 4, 5, 6, 7, ... (10 items)], \" +\n        \"'abcdefg... (10 length)', \" +\n        \"{1: 1, 2: 2, 3: 3, ... (6 keys)}, \" +\n        \"b'?? !\\\"}~?????... (15 length)']\"\n    );\n  });\n});\n"
  },
  {
    "path": "test/server/lib/updateChecker.ts",
    "content": "import { version as installedVersion } from \"app/common/version\";\nimport { Timings } from \"app/gen-server/lib/Housekeeper\";\nimport { FlexServer } from \"app/server/lib/FlexServer\";\nimport { LatestVersion } from \"app/server/lib/UpdateManager\";\nimport { TestServer } from \"test/gen-server/apiUtils\";\nimport { getGristConfig } from \"test/gen-server/testUtils\";\nimport * as testUtils from \"test/server/testUtils\";\n\nimport { assert } from \"chai\";\nimport fetch from \"node-fetch\";\nimport * as sinon from \"sinon\";\n\nconst fakeVersionUrl = \"https://whatever.computer/version\";\ndescribe(\"updateChecker\", function() {\n  testUtils.setTmpLogLevel(\"error\");\n\n  let server: TestServer;\n  let homeUrl: string;\n  let oldServerEnv: testUtils.EnvironmentSnapshot;\n\n  const sandbox = sinon.createSandbox();\n\n  function setupTestServer(mockResponse: LatestVersion) {\n    beforeEach(async function() {\n      oldServerEnv = new testUtils.EnvironmentSnapshot();\n\n      // Stub out the fetch to the external version API endpoint so we\n      // can specify what the latest publicly available version is.\n      sandbox.stub(global, \"fetch\")\n        .withArgs(fakeVersionUrl, sinon.match.any)\n        .resolves(new Response(\n          JSON.stringify(mockResponse),\n          {\n            status: 200,\n            headers: { \"Content-Type\": \"application/json\" },\n          },\n        ))\n        .callThrough();\n\n      // Stub out FlexServer.setLatestVersionAvailable so we can await\n      // a promise that it's been called before we start running our tests.\n      let setVersionResolved: () => void;\n      const setVersionPromise: Promise<void> = new Promise<void>((resolve) => {\n        setVersionResolved = resolve;\n      });\n      const originalSetMethod = FlexServer.prototype.setLatestVersionAvailable;\n      sandbox.stub(FlexServer.prototype, \"setLatestVersionAvailable\")\n        .callsFake(function(this: FlexServer, ...args) {\n          originalSetMethod.apply(this, args);\n          setVersionResolved();\n        });\n\n      // Remove the waiting time to do the first version check at startup\n      sandbox.stub(Timings, \"VERSION_CHECK_OFFSET_MS\").value(0);\n\n      process.env.GRIST_ALLOW_AUTOMATIC_VERSION_CHECKING = \"true\";\n      process.env.GRIST_TEST_VERSION_CHECK_URL = fakeVersionUrl;\n      server = new TestServer(this);\n      homeUrl = await server.start();\n      await setVersionPromise;\n    });\n\n    afterEach(async function() {\n      await server.stop();\n      oldServerEnv.restore();\n      sandbox.restore();\n    });\n    return {\n      server: () => server,\n      homeUrl: () => homeUrl,\n    };\n  }\n\n  describe(\"when everything is up to date\", () => {\n    const mockVersionResponse: LatestVersion = {\n      latestVersion: installedVersion,\n      updatedAt: \"2025-02-18T22:11:09.455904Z\",\n      isCritical: false,\n      updateURL: \"https://hub.docker.com/r/gristlabs/grist\",\n    };\n    const { homeUrl } = setupTestServer(mockVersionResponse);\n\n    it(\"can get the latest available version information\", async function() {\n      const doc = await fetch(homeUrl());\n      const pageBody = await doc.text();\n      const config = getGristConfig(pageBody);\n      const latestVersionAvailable = config.latestVersionAvailable;\n      assert.equal(latestVersionAvailable?.version, installedVersion);\n      assert.equal(latestVersionAvailable?.isNewer, false);\n    });\n  });\n\n  describe(\"when a newer version is available\", () => {\n    const newestVersion = \"99.99.99\";\n    const mockVersionResponse: LatestVersion = {\n      latestVersion: newestVersion,\n      updatedAt: \"2025-02-18T22:11:09.455904Z\",\n      isCritical: false,\n      updateURL: \"https://hub.docker.com/r/gristlabs/grist\",\n    };\n    const { homeUrl } = setupTestServer(mockVersionResponse);\n\n    it(\"can get the latest available version information\", async function() {\n      const doc = await fetch(homeUrl());\n      const pageBody = await doc.text();\n      const config = getGristConfig(pageBody);\n      const latestVersionAvailable = config.latestVersionAvailable;\n      assert.equal(latestVersionAvailable?.version, newestVersion);\n      assert.equal(latestVersionAvailable?.isNewer, true);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/lib/uploads.ts",
    "content": "import { createTmpDir, Deps, fetchURL, moveUpload, UploadSet } from \"app/server/lib/uploads\";\nimport { globalUploadSet } from \"app/server/lib/uploads\";\nimport { createFile } from \"test/server/docTools\";\nimport { setTmpLogLevel } from \"test/server/testUtils\";\n\nimport * as path from \"path\";\nimport { Readable } from \"stream\";\n\nimport { delay } from \"bluebird\";\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\nimport noop from \"lodash/noop\";\nimport pick from \"lodash/pick\";\nimport { Response } from \"node-fetch\";\nimport * as sinon from \"sinon\";\n\ndescribe(\"uploads\", function() {\n  setTmpLogLevel(\"warn\");\n\n  describe(\"createTmpDir\", function() {\n    it(\"should create and clean up tmp dirs\", async function() {\n      // Create tmp dir.\n      const { tmpDir, cleanupCallback } = await createTmpDir({ prefix: \"test-uploads-\", postfix: \"-foo\" });\n      assert.isTrue(path.basename(tmpDir).startsWith(\"test-uploads-\"));\n      assert.isTrue(tmpDir.endsWith(\"-foo\"));\n\n      // Check that it exists.\n      assert.isTrue(await fse.pathExists(tmpDir));\n\n      // Touch a file there.\n      const filePath = path.join(tmpDir, \"hello.txt\");\n      await fse.outputFile(filePath, \"Hello\");\n      assert.isTrue(await fse.pathExists(filePath));\n\n      // Call cleanupCallback.\n      await cleanupCallback();\n\n      // Check that neither file nor directory exist.\n      assert.isFalse(await fse.pathExists(filePath));\n      assert.isFalse(await fse.pathExists(tmpDir));\n    });\n  });\n\n  describe(\"UploadSet\", function() {\n    it(\"should allow registering and cleaning uploads\", async function() {\n      const uploadSet = new UploadSet();\n\n      // Register upload, get id 0.\n      const { tmpDir: tmpDir0, cleanupCallback: cleanupCallback0 } = await createTmpDir({});\n      const files0 = [await createFile(tmpDir0, \"aa\"), await createFile(tmpDir0, \"bb\")];\n      assert.strictEqual(uploadSet.registerUpload(files0, tmpDir0, cleanupCallback0, null), 0);\n\n      // Register another upload, get uploadId 1.\n      const { tmpDir: tmpDir1, cleanupCallback: cleanupCallback1 } = await createTmpDir({});\n      const files1 = [await createFile(tmpDir1, \"cc\"), await createFile(tmpDir1, \"dd\")];\n      assert.strictEqual(uploadSet.registerUpload(files1, tmpDir1, cleanupCallback1, null), 1);\n\n      // Check that tmp dirs exist.\n      assert.sameMembers(await fse.readdir(tmpDir0), [\"aa\", \"bb\"]);\n      assert.sameMembers(await fse.readdir(tmpDir1), [\"cc\", \"dd\"]);\n\n      // Clean first upload; check that it worked.\n      await uploadSet.cleanup(0);\n\n      assert.isFalse(await fse.pathExists(tmpDir0));\n      assert.sameMembers(await fse.readdir(tmpDir1), [\"cc\", \"dd\"]);\n\n      // Create and register another upload, get uploadId 2 (ids are not reused); this one does\n      // NOT clean up tmpDir.\n      const { tmpDir: tmpDir2, cleanupCallback: cleanupCallback2 } = await createTmpDir({});\n      const fakeCleanupCallback2 = sinon.spy();\n      const files2 = [await createFile(tmpDir2, \"ee\"), await createFile(tmpDir2, \"ff\")];\n      assert.strictEqual(uploadSet.registerUpload(files2, null, fakeCleanupCallback2, null), 2);\n\n      assert.isFalse(await fse.pathExists(tmpDir0));\n      assert.sameMembers(await fse.readdir(tmpDir1), [\"cc\", \"dd\"]);\n      assert.sameMembers(await fse.readdir(tmpDir2), [\"ee\", \"ff\"]);\n\n      sinon.assert.notCalled(fakeCleanupCallback2);\n      await uploadSet.cleanupAll();\n      sinon.assert.calledOnce(fakeCleanupCallback2);\n\n      // Assert that it workd.\n      assert.isFalse(await fse.pathExists(tmpDir0));\n      assert.isFalse(await fse.pathExists(tmpDir1));\n      assert.sameMembers(await fse.readdir(tmpDir2), [\"ee\", \"ff\"]);\n\n      // Manually clean the remaining dir.\n      await cleanupCallback2();\n      assert.isFalse(await fse.pathExists(tmpDir2));\n    });\n\n    it(\"should allow moving uploads\", async function() {\n      const uploadSet = new UploadSet();\n\n      // Register upload, get id 0.\n      const { tmpDir: tmpDir0, cleanupCallback: cleanupCallback0 } = await createTmpDir({});\n      const files0 = [await createFile(tmpDir0, \"xx\"), await createFile(tmpDir0, \"yy\")];\n      assert.strictEqual(uploadSet.registerUpload(files0, tmpDir0, cleanupCallback0, null), 0);\n\n      // Register another upload without cleanup, get id 1.\n      const { tmpDir: tmpDir1, cleanupCallback: cleanupCallback1 } = await createTmpDir({});\n      const fakeCleanupCallback1 = sinon.spy();\n      const files1 = [await createFile(tmpDir1, \"zz\"), await createFile(tmpDir1, \"ww\")];\n      assert.strictEqual(uploadSet.registerUpload(files1, null, fakeCleanupCallback1, null), 1);\n\n      // Move upload 0: old directory should be gone, new one should exist.\n      const { tmpDir: tmpDir2, cleanupCallback: cleanupCallback2 } = await createTmpDir({});\n      await moveUpload(uploadSet.getUploadInfo(0, null), tmpDir2);\n      assert.isFalse(await fse.pathExists(tmpDir0));\n\n      // New directory is a new tmpDir within tmpDir2.\n      const moved0 = uploadSet.getUploadInfo(0, null);\n      assert.strictEqual(path.dirname(moved0.tmpDir!), tmpDir2);\n      assert.sameMembers(await fse.readdir(moved0.tmpDir!), [\"xx\", \"yy\"]);\n      assert.sameMembers(moved0.files.map(f => f.absPath),\n        [path.join(moved0.tmpDir!, \"xx\"), path.join(moved0.tmpDir!, \"yy\")]);\n\n      // Move upload 1: old directory should still be there, and new one have copies.\n      await moveUpload(uploadSet.getUploadInfo(1, null), tmpDir2);\n\n      // Old directory is still there (this one is not being cleaned up).\n      assert.sameMembers(await fse.readdir(tmpDir1), [\"zz\", \"ww\"]);\n\n      // New directory should be similar to the first move.\n      const moved1 = uploadSet.getUploadInfo(1, null);\n      assert.strictEqual(path.dirname(moved1.tmpDir!), tmpDir2);\n      assert.sameMembers(await fse.readdir(moved1.tmpDir!), [\"zz\", \"ww\"]);\n      assert.sameMembers(moved1.files.map(f => f.absPath),\n        [path.join(moved1.tmpDir!, \"zz\"), path.join(moved1.tmpDir!, \"ww\")]);\n\n      sinon.assert.calledOnce(fakeCleanupCallback1);\n\n      // Cleanup gets rid of all directories maintained by uploadSet.\n      await uploadSet.cleanupAll();\n      assert.isFalse(await fse.pathExists(moved0.tmpDir!));\n      assert.isFalse(await fse.pathExists(moved1.tmpDir!));\n      assert.sameMembers(await fse.readdir(tmpDir2), []);\n      assert.sameMembers(await fse.readdir(tmpDir1), [\"zz\", \"ww\"]);\n\n      // Also clean up the directories we created for the sake of the test.\n      await cleanupCallback1();\n      await cleanupCallback2();\n      assert.isFalse(await fse.pathExists(tmpDir1));\n      assert.isFalse(await fse.pathExists(tmpDir2));\n    });\n\n    it(\"should clean up automatically after a timeout\", async function() {\n      this.timeout(10000);\n      const sandbox = sinon.createSandbox();\n      const tmpTimeout = 400;\n      try {\n        sandbox.stub(Deps, \"INACTIVITY_CLEANUP_MS\").value(tmpTimeout);\n\n        const uploadSet = new UploadSet();\n\n        // Register upload, get id 0.\n        const { tmpDir: tmpDir0, cleanupCallback: cleanupCallback0 } = await createTmpDir({});\n        const files0 = [await createFile(tmpDir0, \"aa\"), await createFile(tmpDir0, \"bb\")];\n        assert.strictEqual(uploadSet.registerUpload(files0, tmpDir0, cleanupCallback0, null), 0);\n\n        // Register another upload, get uploadId 1.\n        const { tmpDir: tmpDir1, cleanupCallback: cleanupCallback1 } = await createTmpDir({});\n        const files1 = [await createFile(tmpDir1, \"cc\"), await createFile(tmpDir1, \"dd\")];\n        assert.strictEqual(uploadSet.registerUpload(files1, tmpDir1, cleanupCallback1, null), 1);\n\n        // Check that tmp dirs exist.\n        assert.sameMembers(await fse.readdir(tmpDir0), [\"aa\", \"bb\"]);\n        assert.sameMembers(await fse.readdir(tmpDir1), [\"cc\", \"dd\"]);\n\n        // Wait a bit.\n        await delay(200);\n\n        // Check that tmp dirs still exist.\n        assert.sameMembers(await fse.readdir(tmpDir0), [\"aa\", \"bb\"]);\n        assert.sameMembers(await fse.readdir(tmpDir1), [\"cc\", \"dd\"]);\n\n        // Touch one of the uploads.\n        assert.deepInclude(uploadSet.getUploadInfo(0, null), { uploadId: 0, tmpDir: tmpDir0 });\n\n        // Wait a bit longer.\n        await delay(250);\n\n        // Upload 1 should now be cleaned out, but not the recently touched upload 0.\n        assert.isFalse(await fse.pathExists(tmpDir1));\n        assert.sameMembers(await fse.readdir(tmpDir0), [\"aa\", \"bb\"]);\n\n        // Wait a bit longer still; now the other one should be clean too.\n        await delay(200);\n        assert.isFalse(await fse.pathExists(tmpDir0));\n      } finally {\n        sandbox.restore();\n        assert.isAbove(Deps.INACTIVITY_CLEANUP_MS, tmpTimeout);   // Check that .restore() worked\n      }\n    });\n\n    describe(\"fetchURL\", async function() {\n      const sandbox = sinon.createSandbox();\n\n      let response: Response;\n      let url: string;\n\n      beforeEach(function() {\n        sandbox.stub(Deps, \"fetch\").callsFake(async () => response);\n        sandbox.stub(Response.prototype, \"url\").get(() => url);\n      });\n\n      afterEach(async function() {\n        sandbox.restore();\n        await globalUploadSet.cleanupAll();\n      });\n\n      it(\"should guess name from content-type if provided\", async function() {\n        response = new Response(streamify(\"a, b\\n0, 1\\n\"));\n        url = \"fake/url\";\n        response.headers.set(\"content-type\", \"text/csv; charset=utf-8\");\n        const result = await fetchURL(url, null);\n        assert.equal(result.files.length, 1);\n        assert.deepEqual(pick(result.files[0], [\"origName\", \"ext\"]), { origName: \"url\", ext: \".csv\" });\n      });\n\n      it(\"should guess name from url if content-type is missing or text/plain\", async function() {\n        response = new Response(streamify(\"a, b\\n0, 1\\n\"));\n        // content-type should be shadowed by the type in the url when it's text/plain\n        response.headers.set(\"content-type\", \"text/plain; charset=utf-8\");\n        url = \"fake/url/file.csv\";\n        const result = await fetchURL(url, null);\n        assert.equal(result.files.length, 1);\n        assert.deepEqual(pick(result.files[0], [\"origName\", \"ext\"]), { origName: \"file.csv\", ext: \".csv\" });\n\n        // Just URL extension should be used if no content type.\n        response = new Response(streamify(\"a, b\\n0, 1\\n\"));\n        url = \"fake/url/file2.csv\";\n        const result2 = await fetchURL(url, null);\n        assert.equal(result2.files.length, 1);\n        assert.deepEqual(pick(result2.files[0], [\"origName\", \"ext\"]), { origName: \"file2.csv\", ext: \".csv\" });\n\n        // Content-type should be used in other cases.\n        response = new Response(streamify(\"a, b\\n0, 1\\n\"));\n        response.headers.set(\"content-type\", \"application/json; charset=utf-8\");\n        url = \"fake/url/file3.csv\";\n        const result3 = await fetchURL(url, null);\n        assert.equal(result3.files.length, 1);\n        assert.deepEqual(pick(result3.files[0], [\"origName\", \"ext\"]), { origName: \"file3.csv\", ext: \".json\" });\n      });\n    });\n\n    it(\"should respect access ids for uploads\", async function() {\n      const uploadSet = new UploadSet();\n\n      // Register upload for accessId x42, get uploadId 0.\n      const { tmpDir: tmpDir0, cleanupCallback: cleanupCallback0 } = await createTmpDir({});\n      const files0 = [await createFile(tmpDir0, \"aa\"), await createFile(tmpDir0, \"bb\")];\n      assert.strictEqual(uploadSet.registerUpload(files0, tmpDir0, cleanupCallback0, \"x42\"), 0);\n\n      // Register upload for accessId x43, get uploadId 1.\n      const { tmpDir: tmpDir1, cleanupCallback: cleanupCallback1 } = await createTmpDir({});\n      const files1 = [await createFile(tmpDir1, \"cc\"), await createFile(tmpDir1, \"dd\")];\n      assert.strictEqual(uploadSet.registerUpload(files1, tmpDir1, cleanupCallback1, \"x43\"), 1);\n\n      // Check that we can access uploadId 0 with accessId x42 but not x43 or null.\n      assert.isObject(uploadSet.getUploadInfo(0, \"x42\"));\n      assert.throws(() => uploadSet.getUploadInfo(0, \"x43\"), /access denied/i);\n      assert.throws(() => uploadSet.getUploadInfo(0, null), /access denied/i);\n\n      // Check that we can access uploadId 1 with accessId x43 but not x42 or null.\n      assert.isObject(uploadSet.getUploadInfo(1, \"x43\"));\n      assert.throws(() => uploadSet.getUploadInfo(1, \"x42\"), /access denied/i);\n      assert.throws(() => uploadSet.getUploadInfo(1, null), /access denied/i);\n\n      // Check that noone can access uploadId 2 (non-existent)\n      assert.throws(() => uploadSet.getUploadInfo(2, \"x42\"), /unknown upload/i);\n      assert.throws(() => uploadSet.getUploadInfo(2, \"x43\"), /unknown upload/i);\n      assert.throws(() => uploadSet.getUploadInfo(2, null), /unknown upload/i);\n\n      await uploadSet.cleanupAll();\n    });\n  });\n});\n\nfunction streamify(str: string): Readable {\n  const s = new Readable();\n  s._read = noop;\n  s.push(str);\n  s.push(null);\n  return s;\n}\n"
  },
  {
    "path": "test/server/tcpForwarder.ts",
    "content": "import { connect as connectSock, getAvailablePort, listenPromise } from \"app/server/lib/serverUtils\";\n\nimport { Server, Socket } from \"net\";\n\n// We'll test reconnects by making a connection through this TcpForwarder, which we'll use to\n// simulate disconnects.\nexport class TcpForwarder {\n  public port: number | null = null;\n  private _connections = new Map<Socket, Socket>();\n  private _server: Server | null = null;\n\n  constructor(private _serverPort: number, private _serverHost?: string) {}\n\n  public async pickForwarderPort(): Promise<number> {\n    this.port = await getAvailablePort(5834);\n    return this.port;\n  }\n\n  public async connect() {\n    await this.disconnect();\n    this._server = new Server(sock => this._onConnect(sock));\n    await listenPromise(this._server.listen(this.port));\n  }\n\n  public async disconnectClientSide() {\n    await Promise.all(Array.from(this._connections.keys(), destroySock));\n    if (this._server) {\n      await new Promise(resolve => this._server!.close(resolve));\n      this._server = null;\n    }\n    this.cleanup();\n  }\n\n  public async disconnectServerSide() {\n    await Promise.all(Array.from(this._connections.values(), destroySock));\n    this.cleanup();\n  }\n\n  public async disconnect() {\n    await this.disconnectClientSide();\n    await this.disconnectServerSide();\n  }\n\n  public cleanup() {\n    const pairs = Array.from(this._connections.entries());\n    for (const [clientSock, serverSock] of pairs) {\n      if (clientSock.destroyed && serverSock.destroyed) {\n        this._connections.delete(clientSock);\n      }\n    }\n  }\n\n  private async _onConnect(clientSock: Socket) {\n    const serverSock = await connectSock(this._serverPort, this._serverHost);\n    clientSock.pipe(serverSock);\n    serverSock.pipe(clientSock);\n    clientSock.on(\"error\", err => serverSock.destroy(err));\n    serverSock.on(\"error\", err => clientSock.destroy(err));\n    this._connections.set(clientSock, serverSock);\n  }\n}\n\nasync function destroySock(sock: Socket): Promise<void> {\n  if (!sock.destroyed) {\n    await new Promise((resolve, reject) =>\n      sock.on(\"close\", resolve).destroy());\n  }\n}\n"
  },
  {
    "path": "test/server/testCleanup.ts",
    "content": "export type CleanupFunc = (() => void | Promise<void>);\n\n/**\n * Helper to run cleanup callbacks created in a test case. See setupCleanup() below for usage.\n */\nexport class Cleanup {\n  private _callbacksAfterAll: CleanupFunc[] = [];\n  private _callbacksAfterEach: CleanupFunc[] = [];\n\n  public addAfterAll(cleanupFunc: CleanupFunc) {\n    this._callbacksAfterAll.push(cleanupFunc);\n  }\n\n  public addAfterEach(cleanupFunc: CleanupFunc) {\n    this._callbacksAfterEach.push(cleanupFunc);\n  }\n\n  public async runCleanup(which: \"all\" | \"each\") {\n    const callbacks = which === \"all\" ? this._callbacksAfterAll : this._callbacksAfterEach;\n    const list = callbacks.splice(0);   // Get a copy of the list AND clear it out.\n    for (const f of list) {\n      await f();\n    }\n  }\n}\n\n/**\n * Helper to run cleanup callbacks created in the course of running a test.\n * Usage:\n *    const cleanup = setupCleanup();\n *    it(\"should do stuff\", function() {\n *      cleanup.addAfterAll(() => { ...doSomething1()... });\n *      cleanup.addAfterEach(() => { ...doSomething2()... });\n *    });\n *\n * Here, doSomething1() is called at the end of a suite, while doSomething2() is called at the end\n * of the current test case.\n */\nexport function setupCleanup() {\n  const cleanup = new Cleanup();\n  after(() => cleanup.runCleanup(\"all\"));\n  afterEach(() => cleanup.runCleanup(\"each\"));\n  return cleanup;\n}\n"
  },
  {
    "path": "test/server/testUtils.ts",
    "content": "/**\n * Functions useful for testing.\n *\n * It re-exports chai.assert, so that you can import it from here with confidence\n * that it has been instrumented to support things like assert.isRejected\n * (via chai.use(chaiAsPromised).\n *\n */\n\n/* global before, after */\n\nimport * as docUtils from \"app/server/lib/docUtils\";\nimport log from \"app/server/lib/log\";\nimport { getAppRoot } from \"app/server/lib/places\";\n\nimport { tmpdir } from \"os\";\nimport * as path from \"path\";\n\nimport { assert } from \"chai\";\nimport * as fse from \"fs-extra\";\nimport clone from \"lodash/clone\";\nimport { FileOptions as TmpOptions } from \"tmp\";\nimport * as tmp from \"tmp-promise\";\nimport * as _ from \"underscore\";\nimport * as winston from \"winston\";\nimport { serialize } from \"winston/lib/winston/common\";\n\n/**\n * Creates a temporary file with the given contents.\n * @param {String} content. Data to store in the file.\n * @param {[Boolean]} options.keep. Optionally pass in true to keep the file from being deleted, which\n *    is useful to see the content while debugging a test.\n * @returns {Promise} A promise for the path of the new file.\n */\nexport async function writeTmpFile(content: any, options: TmpOptions = {}) {\n  // discardDescriptor ensures tmp module closes it. It can lead to horrible bugs to close this\n  // descriptor yourself, since tmp also closes it on exit, and if it's a different descriptor by\n  // that time, it can lead to a crash. See https://github.com/raszi/node-tmp/issues/168\n  const obj = await tmp.file({ discardDescriptor: true, ...options });\n  await fse.writeFile(obj.path, content);\n  return obj.path;\n}\n\n/**\n * Creates a temporary file with `numLines` of generated data, each line about 30 bytes long.\n * This is useful for testing operations with large files.\n * @param {Number} numLines. How many lines to store in the file.\n * @param {[Boolean]} options.keep. Optionally pass in true to keep the file from being deleted, which\n *    is useful to see the content while debugging a test.\n * @returns {Promise} A promise for the path of the new file.\n */\nexport async function generateTmpFile(numLines: number, options: TmpOptions = {}) {\n  // Generate a bigger data file.\n  const data = [];\n  for (let i = 0; i < numLines; i++) {\n    data.push(i + \" abcdefghijklmnopqrstuvwxyz\\n\");\n  }\n  return writeTmpFile(data.join(\"\"), options);\n}\n\n/**\n * Helper class to capture log output when we want to test it.\n */\nclass CaptureTransport extends winston.Transport {\n  private _captureFunc: (level: string, msg: string, meta: any) => void;\n\n  public constructor(options: any) {\n    super(options);\n    this._captureFunc = options.captureFunc;\n    if (options.name) {\n      this.name = options.name;\n    }\n  }\n\n  public log(level: string, msg: string, meta: any, callback: () => void) {\n    this._captureFunc(level, msg, meta);\n  }\n}\n\ntype CaptureFunc = (level: string, msg: string, meta: any) => void;\n\n/**\n * When used inside a test suite (inside describe()), changes the log level to the given one\n * before tests, restoring it back afterwards. In addition, if optCaptureTo is given, it can be\n * a function, called as optCaptureTo(level, msg) with every message logged (including those\n * suppressed), or it may be the name of a file where to record the log (which will be created in\n * a per-test-suite directory).\n *\n * This should be called at the suite level (i.e. inside describe()).\n */\nexport function setTmpLogLevel(level: string, optCaptureTo?: CaptureFunc | string) {\n  // If verbose is set in the environment, sabotage all reductions in logging level.\n  // Handier than modifying the setTmpLogLevel line and then remembering to set it back\n  // before committing.\n  if (process.env.VERBOSE === \"1\") {\n    level = \"debug\";\n  }\n\n  let prevLogLevel: string | undefined = undefined;\n  const name = _.uniqueId(\"CaptureLog\");\n\n  before(async function() {\n    if (this.runnable().parent?.root) {\n      throw new Error(\"setTmpLogLevel should be called at suite level, not at root level\");\n    }\n\n    prevLogLevel = log.transports.file.level;\n    log.transports.file.level = level;\n    if (optCaptureTo instanceof Function) {\n      log.add(CaptureTransport as any, { captureFunc: optCaptureTo, name });  // typing is off.\n    } else if (optCaptureTo) {\n      const suiteName = this.test?.parent?.title || \"unknown-suite\";\n      const testDir = await createTestDir(suiteName);\n      const logPath = path.join(testDir, optCaptureTo);\n      const stream = fse.createWriteStream(logPath, { flags: \"a\" });\n      log.add(winston.transports.File, {\n        name,\n        stream,\n        level: \"debug\",\n        timestamp: true,\n        json: false,\n      });\n    }\n  });\n\n  after(function() {\n    if (optCaptureTo) {\n      log.remove(name);\n    }\n    log.transports.file.level = prevLogLevel;\n  });\n}\n\nexport interface NestedLogLevel {\n  restore(): void;\n}\n\nexport function nestLogLevel(level: string): NestedLogLevel {\n  const prevLogLevel = log.transports.file.level;\n  log.transports.file.level = level;\n  return {\n    restore() {\n      log.transports.file.level = prevLogLevel;\n    },\n  };\n}\n\n/**\n * Captures debug log messages produced by callback. Suppresses ALL messages from console, and\n * captures those at minLevel and higher. Returns a promise for the array of \"level: message\"\n * strings. These may be tested using testUtils.assertMatchArray(). Callback may return a promise.\n */\nexport async function captureLog(\n  minLevel: string, callback: (messages: string[]) => void | Promise<void>,\n  options: { timestamp?: boolean, waitForFirstLog?: boolean } = { timestamp: false, waitForFirstLog: false },\n): Promise<string[]> {\n  const messages: string[] = [];\n  const prevLogLevel = log.transports.file.level;\n  const name = _.uniqueId(\"CaptureLog\");\n\n  const captureFirstLogPromise = new Promise((resolve) => {\n    function capture(level: string, msg: string, meta: any) {\n      if ((log as any).levels[level] <= (log as any).levels[minLevel]) {  // winston types are off?\n        const timePrefix = options.timestamp ? new Date().toISOString() + \" \" : \"\";\n        messages.push(`${timePrefix}${level}: ${msg}${meta ? \" \" + serialize(meta) : \"\"}`);\n        resolve(null);\n      }\n    }\n\n    if (!process.env.VERBOSE) {\n      log.transports.file.level = -1 as any;   // Suppress all log output.\n    }\n    log.add(CaptureTransport as any, { captureFunc: capture, name, level: minLevel });  // types are off.\n  });\n\n  try {\n    await callback(messages);\n    if (options.waitForFirstLog) {\n      await captureFirstLogPromise;\n    }\n  } finally {\n    log.remove(name);\n    log.transports.file.level = prevLogLevel;\n  }\n  return messages;\n}\n\n/**\n * Asserts that each string of stringArray matches the corresponding regex in regexArray.\n */\nexport function assertMatchArray(stringArray: string[], regexArray: RegExp[]) {\n  for (let i = 0; i < Math.min(stringArray.length, regexArray.length); i++) {\n    assert.match(stringArray[i], regexArray[i]);\n  }\n  assert.isAtMost(stringArray.length, regexArray.length,\n    `Unexpected strings seen: ${stringArray.slice(regexArray.length).join(\"\\n\")}`);\n  assert.isAtLeast(stringArray.length, regexArray.length,\n    \"Not all expected strings were seen\");\n}\n\n/**\n * Helper method for handling expected Promise rejections.\n *\n * @param {Promise} promise = the promise we are checking for errors\n * @param {String} errCode - Error code to check against `err.code` from the caller.\n * @param {RegExp} errRegexp - Regular expression to check against `err.message` from the caller.\n */\nexport function expectRejection(promise: Promise<any>, errCode: number | string, errRegexp: RegExp) {\n  return promise\n    .then(function() {\n      assert(false, \"Expected promise to return an error: \" + errCode);\n    })\n    .catch(function(err) {\n      if (err.cause) {\n        err = err.cause;\n      }\n      assert.strictEqual(err.code, errCode);\n\n      if (errRegexp !== undefined) {\n        assert(errRegexp.test(err.message), \"Description doesn't match regexp: \" +\n        errRegexp + \" !~ \" + err.message);\n      }\n    });\n}\n\n/**\n * Reads in doc actions from a test script. Used in DocStorage_Script.js and DocData.js.\n * This parser inserts line numbers into the step names of the test case bodies. Users of the test\n * script should iterate through the steps using processTestScriptSteps, which will strip out the\n * line numbers, and include them into any failure messages.\n *\n * @param {String} file - Input test script\n * @returns {Promise:Object} - Parsed test script object\n */\nexport async function readTestScript(file: string) {\n  const fullText = await fse.readFile(file, { encoding: \"utf8\" });\n  const allLines: string[] = [];\n  fullText.split(\"\\n\").forEach(function(line, i) {\n    if (line.match(/^\\s*\\/\\//)) {\n      allLines.push(\"\");\n    } else {\n      line = line.replace(/\"(APPLY|CHECK_OUTPUT|LOAD_SAMPLE)\"\\s*,/, '\"$1@' + (i + 1) + '\",');\n      allLines.push(line);\n    }\n  });\n  return JSON.parse(allLines.join(\"\\n\"));\n}\n\n/**\n * For a test case step, such as [\"APPLY\", {actions}], checks if the step name has an encoded line\n * number, strips it, runs the callback with the step data, and inserts the line number into any\n * errors thrown by the callback.\n */\nexport async function processTestScriptSteps<T>(body: Promise<[string, T]>[],\n  stepCallback: (step: [string, T]) => Promise<void>) {\n  for (const promise of body) {\n    const step = await promise;\n    const stepName = step[0];\n    const lineNoPos = stepName.indexOf(\"@\");\n    const lineNum = (lineNoPos === -1) ? null : stepName.slice(lineNoPos + 1);\n    step[0] = (lineNoPos === -1) ? stepName : stepName.slice(0, lineNoPos);\n    try {\n      await stepCallback(step);\n    } catch (e) {\n      e.message = \"LINE \" + lineNum + \": \" + e.message;\n      throw e;\n    }\n  }\n}\n\n/**\n * Helper that substitutes every instance of `from` value to `to` value. Iterates down the object.\n */\nexport function deepSubstitute(obj: any, from: any, to: any): any {\n  from = _.isArray(from) ? from : [from];\n  if (_.isArray(obj)) {\n    return obj.map(el => deepSubstitute(el, from, to));\n  } else if (obj && typeof obj === \"object\" && !_.isFunction(obj)) {\n    return _.mapObject(obj, el => deepSubstitute(el, from, to));\n  } else {\n    return from.indexOf(obj) !== -1 ? to : obj;\n  }\n}\n\nexport const fixturesRoot = path.resolve(getAppRoot(), \"test\", \"fixtures\");\n\nexport const appRoot = getAppRoot();\n\n/**\n * Copy the given filename from the fixtures directory (test/fixtures)\n * to the storage manager root.\n * @param {string} alias - Optional alias that lets you rename the document on disk.\n */\nexport async function useFixtureDoc(fileName: string, storageManager: any, alias: string = fileName) {\n  const srcPath = path.resolve(fixturesRoot, \"docs\", fileName);\n  const docName = await useLocalDoc(srcPath, storageManager, alias);\n  log.info(\"Using fixture %s as %s\", fileName, docName + \".grist\");\n  return docName;\n}\n\n/**\n * Copy the given filename from srcPath to the storage manager root.\n * @param {string} alias - Optional alias that lets you rename the document on disk.\n */\nexport async function useLocalDoc(srcPath: string, storageManager: any, alias: string = srcPath) {\n  let docName = path.basename(alias || srcPath, \".grist\");\n  docName = await docUtils.createNumbered(\n    docName, \"-\",\n    (name: string) => docUtils.createExclusive(storageManager.getPath(name)));\n  await docUtils.copyFile(srcPath, storageManager.getPath(docName));\n  await storageManager.markAsChanged(docName);\n  return docName;\n}\n\n// an helper to copy a fixtures document to destPath\nexport async function copyFixtureDoc(docName: string, destPath: string) {\n  const srcPath = path.resolve(fixturesRoot, \"docs\", docName);\n  await docUtils.copyFile(srcPath, destPath);\n}\n\n// a helper to read a fixtures document into memory\nexport async function readFixtureDoc(docName: string) {\n  const srcPath = path.resolve(fixturesRoot, \"docs\", docName);\n  return fse.readFile(srcPath);\n}\n\n// a class to store a snapshot of environment variables, can be reverted to by\n// calling .restore()\nexport class EnvironmentSnapshot {\n  public static push() {\n    this._stack.push(new EnvironmentSnapshot());\n  }\n\n  public static pop() {\n    const snapshot = this._stack.pop();\n    if (!snapshot) {\n      throw new Error(\"EnvironmentSnapshot stack is empty\");\n    }\n    snapshot.restore();\n  }\n\n  private static _stack: EnvironmentSnapshot[] = [];\n\n  private _oldEnv: NodeJS.ProcessEnv;\n\n  public constructor() {\n    this._oldEnv = clone(process.env);\n  }\n\n  // Reset environment variables.\n  public restore() {\n    Object.assign(process.env, this._oldEnv);\n    for (const key of Object.keys(process.env)) {\n      if (this._oldEnv[key] === undefined) {\n        delete process.env[key];\n      }\n    }\n  }\n\n  public get(key: string): string | undefined {\n    return this._oldEnv[key];\n  }\n}\n\nconst createdInThisRun = new Set<string>();\n\nexport async function createTestDir(suiteName: string): Promise<string> {\n  const tmpRootDir = process.env.TESTDIR || tmpdir();\n  const workerIdText = process.env.MOCHA_WORKER_ID || \"0\";\n  const username = process.env.USER || \"nobody\";\n  const testDir = path.join(tmpRootDir, `grist_test_${username}_${suiteName}_${workerIdText}`);\n  // Remove any previous tmp dir, and create the new one. But don't clobber the previous directory\n  // if it was created in this same run, e.g. if different code called createTestDir().\n  if (!createdInThisRun.has(testDir)) {\n    await fse.remove(testDir);\n  }\n  createdInThisRun.add(testDir);\n  await fse.mkdirs(testDir);\n  log.warn(`Test logs and data are at: ${testDir}/`);\n  return testDir;\n}\n\nexport async function getBuildFile(relativePath: string): Promise<string> {\n  if (await fse.pathExists(path.join(\"_build\", relativePath))) {\n    return path.join(\"_build\", relativePath);\n  }\n  return path.join(\"_build\", \"core\", relativePath);\n}\n\n/**\n * Setup a new environment in which sandboxing is disabled. Do not use\n * this function if you also need to modify the environment in your\n * own tests, because the environments will not be restored in the\n * right order.\n */\nexport function withoutSandboxing() {\n  let env: EnvironmentSnapshot;\n  before(() => {\n    env = new EnvironmentSnapshot();\n    process.env.GRIST_SANDBOX_FLAVOR = \"unsandboxed\";\n  });\n  after(() => {\n    env.restore();\n  });\n}\n\nexport { assert };\n"
  },
  {
    "path": "test/server/utils/CachedFetcher.ts",
    "content": "import crypto from \"crypto\";\nimport * as fs from \"fs\";\nimport { join } from \"path\";\n\nimport fetch, { RequestInfo, RequestInit, Response } from \"node-fetch\";\n\n/**\n * A wrapper around `node-fetch` that caches responses on the local filesystem.\n *\n * This class avoids redundant HTTP requests by storing and retrieving responses\n * from disk. If a cached response exists and is still valid, it will be returned\n * instead of making a new network call.\n */\nexport class CachedFetcher {\n  public callCount = 0;\n\n  private _queue = new Map<string, any>();\n\n  constructor(private _basePath: string) {\n    if (!fs.existsSync(_basePath)) {\n      fs.mkdirSync(join(_basePath), { recursive: true });\n    }\n  }\n\n  public async fetch(info: RequestInfo, init?: RequestInit): Promise<Response> {\n    const url =\n      typeof info === \"string\" ? info : \"href\" in info ? info.href : info.url;\n    const hash = JSON.stringify({ url, body: init?.body });\n    if (this._has(hash)) {\n      return new Response(this._get(hash), { status: 200 });\n    }\n    if (this._queue.has(hash)) {\n      return new Response(await this._queue.get(hash), { status: 200 });\n    }\n\n    this._queue.set(hash, fetch(url, init));\n    const response = await this._queue.get(hash);\n    this.callCount++;\n    if (response.status === 200) {\n      this._set(hash, await response.clone().text());\n    }\n    return response;\n  }\n\n  private _get(key: string): string | undefined {\n    if (!this._has(key)) {\n      return undefined;\n    }\n\n    const content = JSON.parse(fs.readFileSync(this._path(key), \"utf8\"));\n    return JSON.stringify(content.responseBody);\n  }\n\n  private _has(key: string): boolean {\n    return fs.existsSync(this._path(key));\n  }\n\n  private _set(key: string, value: any): void {\n    const content = {\n      requestBody: key,\n      responseBody: JSON.parse(value),\n    };\n    fs.writeFileSync(this._path(key), JSON.stringify(content));\n  }\n\n  private _path(key: string) {\n    return join(this._basePath, this._hash(key) + \".json\");\n  }\n\n  private _hash(key: string) {\n    return crypto.createHash(\"md5\").update(key).digest(\"hex\");\n  }\n}\n"
  },
  {
    "path": "test/server/utils/LogSanitizer.ts",
    "content": "import { LogSanitizer } from \"app/server/utils/LogSanitizer\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"LogSanitizer\", () => {\n  it(\"should return neutral logs untouched\", (done) => {\n    const exampleLog =\n      \"DocTriggers: Webhook responded with non-200 status status=404, attempt=1, docId=8x9U6xe4hNz8WaJCzAjDBM,\" +\n      \" queueLength=8, drainingQueue=false, shuttingDown=false, sending=true, redisClient=true\";\n    const sanitizer = new LogSanitizer();\n    const sanitizedLog = sanitizer.sanitize(exampleLog);\n    assert.equal(sanitizedLog, exampleLog);\n    done();\n  });\n\n  it(\"should not crashed when empty log was passed to sanitizer\", (done) => {\n    const exampleLog = undefined;\n    const sanitizer = new LogSanitizer();\n    const sanitizedLog = sanitizer.sanitize(exampleLog);\n    assert.equal(sanitizedLog, exampleLog);\n    done();\n  });\n\n  it(\"should sanitize redis webhooks rpush logs\", (done) => {\n    const exampleLog = {\n      command: \"RPUSH\",\n      code: \"NR_CLOSED\",\n      args: [\n        \"webhook-queue-8x9U6xe4hNz8WaJCzAjDBM\",\n        // Data send to redis is kept there in string format, therefore in our solution we are stringify them before\n        // sending. we know that the payload is a json though, so here we are trying to reproduce that data structure.\n        JSON.stringify({\n          id: \"f3517b07-9846-4fe3-bcb2-d26cc07e40bd\",\n          payload: {\n            id: 355,\n            manualSort: 355,\n            Name: \"Johny\",\n            InsuranceNumber: \"12345\",\n          },\n        }),\n        // in thie redis those are json, but send as a strings, so we need to parse them\n        JSON.stringify({\n          id: \"b3091e47-00a0-4614-a58f-cb1ae383ea43\",\n          payload: {\n            id: 355,\n            manualSort: 355,\n            Name: \"Mark\",\n            InsuranceNumber: \"65844\",\n          },\n        }),\n      ],\n    };\n\n    const sanitizer = new LogSanitizer();\n    const sanitizedLogObj = sanitizer.sanitize(exampleLog);\n    const sanitizedLog = JSON.stringify(sanitizedLogObj);\n\n    // tests on stringify object, to make it fast to search in.\n    assert.isTrue(sanitizedLog.includes(\"[sanitized]\"));\n    assert.isFalse(sanitizedLog.includes(\"InsuranceNumber\"));\n    assert.isFalse(sanitizedLog.includes(\"Name\"));\n\n    done();\n  });\n});\n"
  },
  {
    "path": "test/server/utils/streams.ts",
    "content": "import { drainWhenSettled } from \"app/server/utils/streams\";\n\nimport stream from \"node:stream\";\n\nimport { assert } from \"chai\";\n\ndescribe(\"streams\", function() {\n  describe(\"drainWhenSettled\", function() {\n    it(\"drains a stream when a promise is resolved\", async function() {\n      const readable = stream.Readable.from(Buffer.from(\"Some content\"));\n      assert.isTrue(readable.readable);\n      const value = await drainWhenSettled(readable, Promise.resolve(\"Some value\"));\n      assert.isFalse(readable.readable);\n      assert.equal(value, \"Some value\");\n    });\n\n    it(\"drain a stream when a promise is rejected, and rejects\", async function() {\n      const readable = stream.Readable.from(Buffer.from(\"Some content\"));\n      assert.isTrue(readable.readable);\n      await assert.isRejected(drainWhenSettled(readable, Promise.reject(new Error(\"Some value\"))), \"Some value\");\n      assert.isFalse(readable.readable);\n    });\n  });\n});\n"
  },
  {
    "path": "test/server/wait.ts",
    "content": "import { delay } from \"app/common/delay\";\nimport { MaybePromise } from \"app/plugin/gutil\";\n\n/**\n * A helper function that invokes a function until it passes without throwing an error.\n *\n * Notice: unlike `waitForPass` from `gristUtils`, this function doesn't use browser to delay\n * execution, so it's suitable for server-side tests.\n *\n * @param fn Function that throws an error if the condition is not met.\n * @param maxWaitMs Maximum time to wait for the condition to be met.\n * @param stepWaitMs Time to wait between attempts to check the condition.\n */\nexport async function waitForIt(fn: () => MaybePromise<any>, maxWaitMs: number = 2000,\n  stepWaitMs: number = 1000) {\n  const start = Date.now();\n  const timePassed = () => Date.now() - start;\n  while (true) {\n    try {\n      await fn();\n      return;\n    } catch (e) {\n      if (timePassed() > maxWaitMs) {\n        throw e;\n      }\n      await delay(stepWaitMs);\n    }\n  }\n}\n"
  },
  {
    "path": "test/setupPaths.js",
    "content": "// enhance require() to support project paths and typescript.\nconst path = require(\"path\");\nconst appModulePath = require(\"app-module-path\");\n// Root path can be complicated, pwd is more reliable for tests.\nconst root = process.cwd();\nconst nodePath = (process.env.NODE_PATH || \"\").split(path.delimiter);\nconst paths = [path.join(root, \"_build\"),\n  path.join(root, \"_build/core\"),\n  path.join(root, \"_build/ext\"),\n  path.join(root, \"_build/stubs\")];\nfor (const p of paths) {\n  appModulePath.addPath(p);\n}\n// add to path for any subprocesses also\nprocess.env.NODE_PATH = [...nodePath, ...paths]\n  .filter(p => p !== \"\")\n  .join(path.delimiter);\n"
  },
  {
    "path": "test/split-tests.js",
    "content": "/**\n * This module handles splitting tests for parallelizing them. This module is imported by any run\n * of mocha, due by being listed in package.json.\n *\n * It only does anything if TEST_SPLITS is set, which must have the form \"3-of-8\".\n *\n * If TEST_SPLITS is set to M-of-N, it is used to divide up all test suites in this mocha run into\n * N groups, and runs the Mth of them. Note that M is 1-based, i.e. in [1..N] range. To have all\n * tests run, each of the groups 1-of-N through N-of-N must run on the same total set of tests.\n *\n * The actual breaking into groups is informed by a timings file, defaulting to\n * test/timings-all.txt. This has the format \"<top-suite> <file-suite-title> <duration-in-ms>\".\n * Only those lines whose <top-suite> matches process.env.TEST_SUITE_FOR_TIMINGS will be used.\n *\n * The timings for test/timings-all.txt are prepared by our test reporter and written during\n * Jenkins run as the timings/timings-all.txt artifact. After tests are added or changed, if\n * timings may have changed significantly, it's good to update test/timings-all.txt, so that the\n * parallel groups can be evened out as much as possible.\n */\n\nconst fs = require(\"fs\");\nconst { assert } = require(\"chai\");\n\nconst testSuite = process.env.TEST_SUITE_FOR_TIMINGS || \"unset_suite\";\nconst timingsFile = process.env.TIMINGS_FILE || \"test/timings-all.txt\";\n\nexports.mochaHooks = {\n  beforeAll(done) {\n    const testSplits = process.env.TEST_SPLITS;\n    if (!testSplits) {\n      return done();\n    }\n    const match = testSplits.match(/^(\\d+)-of-(\\d+)$/);\n    if (!match) {\n      assert.fail(`Invalid test split spec '${testSplits}': use format 'N-of-M'`);\n    }\n\n    const group = Number(match[1]);\n    const groupCount = Number(match[2]);\n    if (!(group >= 1 && group <= groupCount)) {\n      assert.fail(`Invalid test split spec '${testSplits}': index must be in range 1..{groupCount}`);\n    }\n\n    const testParent = this.test.parent;\n    const timings = getTimings();\n    const groups = groupSuites(testParent.suites, timings, groupCount);\n\n    testParent.suites = groups[group - 1];  // Convert to a 0-based index.\n    console.log(`Split tests groups; will run group ${group} of ${groupCount}`);\n    done();\n  }\n};\n\n/**\n * Read timings from timingsFile into a Map mapping file-suite-title to duration.\n */\nfunction getTimings() {\n  const timings = new Map();\n  try {\n    const content = fs.readFileSync(timingsFile, {encoding: \"utf8\"});\n    for (const line of content.split(/\\r?\\n/)) {\n      const [bigSuite, fileSuite, duration] = line.split(/\\s+/);\n      if (bigSuite === testSuite && !isNaN(Number(duration))) {\n        timings.set(fileSuite, Number(duration));\n      }\n    }\n  } catch (e) {\n    if (e.code === \"ENOENT\") {\n      console.warn(`No timings found in ${timingsFile}; proceeding without timings`);\n    } else {\n      throw e;\n    }\n  }\n  return timings;\n}\n\n/**\n * Splits suites into groups and returns the list of them.\n *\n * The algorithm to group tests into suites starts goes one by one from longest to shortest,\n * adding them to the least filled-up group.\n */\nfunction groupSuites(suites, timings, groupCount) {\n  // Calculate a fallback value for durations as the average of existing durations.\n  const totalDuration = Array.from(timings.values()).reduce(((s, dur) => s + dur), 0);\n  if (!totalDuration) {\n    console.warn(\"No timings; assuming all tests are equally long\");\n  }\n  const fallbackDuration = totalDuration ? totalDuration / timings.size : 1000;\n\n  const groups = Array.from(Array(groupCount), () => []);\n  const groupDurations = groups.map(() => 0);\n\n  // Check for duplicate suite titles.\n  const suitesByTitle = new Map(suites.map(s => [s.title, s]));\n  for (const suite of suites) {\n    if (suitesByTitle.get(suite.title) !== suite) {\n      assert.fail(`Please fix duplicate suite title: ${suite.title}`);\n    }\n  }\n\n  // Get timing for the given suite, falling back to fallbackDuration.\n  function getTiming(suite) {\n    const value = timings.get(suite.title);\n    return (typeof value !== \"number\" || isNaN(value)) ? fallbackDuration : value;\n  }\n\n  // Sort suites by descending duration.\n  const sortedSuites = suites.slice().sort((a, b) => getTiming(b) - getTiming(a));\n\n  for (const suite of sortedSuites) {\n    // Pick a least-duration group.\n    const index = groupDurations.indexOf(Math.min(...groupDurations));\n    groups[index].push(suite);\n    groupDurations[index] += getTiming(suite);\n  }\n\n  // Sort each group alphabetically by title.\n  for (const group of groups) {\n    group.sort((a, b) => a.title < b.title ? -1 : 1);\n  }\n  return groups;\n}\n"
  },
  {
    "path": "test/testUtils.ts",
    "content": "import { HomeDBManager } from \"app/gen-server/lib/homedb/HomeDBManager\";\n\nexport async function getDatabase(typeormDb?: string): Promise<HomeDBManager> {\n  const origTypeormDB = process.env.TYPEORM_DATABASE;\n  if (typeormDb) {\n    process.env.TYPEORM_DATABASE = typeormDb;\n  }\n  const db = new HomeDBManager();\n  await db.connect();\n  await db.initializeSpecialIds();\n  if (origTypeormDB) {\n    process.env.TYPEORM_DATABASE = origTypeormDB;\n  }\n  return db;\n}\n"
  },
  {
    "path": "test/test_env.sh",
    "content": "#!/usr/bin/env bash\n\nexport GRIST_SESSION_COOKIE=\"grist_test_cookie\"\nexport LANGUAGE=\"en_US\"\nexport TEST_CLEAN_DATABASE=\"true\"\nexport TEST_SUPPORT_API_KEY=\"api_key_for_support\"\n\nexec \"$@\"\n"
  },
  {
    "path": "test/test_under_docker.sh",
    "content": "#!/usr/bin/env bash\n\n# This runs browser tests with the server started using docker, to\n# catch any configuration problems.\n# Run with MOCHA_WEBDRIVER_HEADLESS=1 for headless operation.\n# Run with DEBUG=1 for server logs.\n\n# Settings for script robustness\nset -o pipefail  # trace ERR through pipes\nset -o nounset   # same as set -u : treat unset variables as an error\nset -o errtrace  # same as set -E: inherit ERR trap in functions\nset -o errexit   # same as set -e: exit on command failures\ntrap 'cleanup' EXIT\ntrap 'echo \"Exiting on SIGINT\"; exit 1' INT\ntrap 'echo \"Exiting on SIGTERM\"; exit 1' TERM\n\nsource $(dirname $0)/test_env.sh\n\nPORT=8585\nDOCKER_CONTAINER=grist-core-test\nDOCKER_PID=\"\"\n\ncleanup() {\n  return_value=$?\n  docker rm -f $DOCKER_CONTAINER\n  if [ -n \"$DOCKER_PID\" ]; then\n    wait $DOCKER_PID || echo \"docker container gone\"\n  fi\n  echo \"Cleaned up docker container, bye.\"\n  exit $return_value\n}\n\nGRIST_LOG_LEVEL=\"error\"\nif [[ \"${DEBUG:-}\" == 1 ]]; then\n  GRIST_LOG_LEVEL=\"\"\n  GRIST_LOG_HTTP=\"true\"\n  GRIST_LOG_HTTP_BODY=\"true\"\nfi\n\nset -x\ndocker run --name $DOCKER_CONTAINER --rm \\\n  --env VERBOSE=${DEBUG:-} \\\n  -p $PORT:$PORT --env PORT=$PORT \\\n  --env GRIST_SESSION_COOKIE=grist_test_cookie \\\n  --env GRIST_TEST_LOGIN=1 \\\n  --env GRIST_LOG_LEVEL=$GRIST_LOG_LEVEL \\\n  --env GRIST_LOG_HTTP=${GRIST_LOG_HTTP:-false} \\\n  --env GRIST_LOG_HTTP_BODY=${GRIST_LOG_HTTP_BODY:-false} \\\n  --env TEST_SUPPORT_API_KEY=api_key_for_support \\\n  --env GRIST_TEMPLATE_ORG=templates \\\n  ${TEST_DOCKER_OPTIONS:-} \\\n  ${TEST_IMAGE:-gristlabs/grist} &\nset +x\n\nDOCKER_PID=\"$!\"\n\necho \"[waiting for server]\"\nwhile true; do\n  curl -s http://localhost:$PORT/status && break\n  sleep 1\ndone\necho \"\"\necho \"[server found]\"\nMOCHA=mocha\n# Test if we have mocha available as a command\nif ! type $MOCHA > /dev/null 2>&1; then\n  echo \"Mocha not found, using from ./node_modules/.bin/mocha\"\n  MOCHA=./node_modules/.bin/mocha\nfi\n\nTEST_ADD_SAMPLES=1 TEST_ACCOUNT_PASSWORD=not-needed \\\n  HOME_URL=http://localhost:8585 \\\n  GRIST_TEST_LOGIN=1 \\\n  NODE_PATH=_build:_build/ext:_build/stubs \\\n  $MOCHA _build/test/deployment/*.js --slow 6000 -g \"${GREP_TESTS:-}\" \"$@\"\n"
  },
  {
    "path": "test/timings/nbrowser.txt",
    "content": "nbrowser ActionLog 14737\nnbrowser ChoiceList 33037\nnbrowser CustomView 22055\nnbrowser CustomWidgets 14958\nnbrowser CustomWidgetsConfig 48287\nnbrowser DescriptionColumn 4649\nnbrowser DuplicateDocument 14042\nnbrowser Fork 112089\nnbrowser HomeIntro 44706\nnbrowser LanguageSettings 25427\nnbrowser Localization 10069\nnbrowser MultiColumn 455648\nnbrowser Pages 24986\nnbrowser ReferenceColumns 27590\nnbrowser ReferenceList 34333\nnbrowser RefTransforms 9072\nnbrowser RightPanel 10530\nnbrowser RightPanelSelectBy 6255\nnbrowser RowMenu 3702\nnbrowser saveViewSection 7596\nnbrowser SelectBy 5846\nnbrowser SelectByRefList 15186\nnbrowser SelectByRightPanel 3531\nnbrowser SelectBySummary 17516\nnbrowser SelectBySummaryRef 5382\nnbrowser SelectionSummary 6833\nnbrowser Smoke 1800\nnbrowser ToggleColumns 6530\n"
  },
  {
    "path": "test/timings/server.txt",
    "content": "server Comm 9557\nserver generateInitialDocSql 1304\nserver Authorizer 2375\nserver DocApi 94358\nserver DocApi2 730\nserver HostedStorageManager 220307\nserver backupSqliteDatabase 4348\n"
  },
  {
    "path": "test/tsconfig.json",
    "content": "{\n  \"extends\": \"../buildtools/tsconfig-base.json\",\n  \"include\": [\n    \"*\",\n    \"**/*\",\n    \"../app/client/declarations.d.ts\",\n    \"../app/common/declarations.d.ts\",\n    \"../app/server/declarations.d.ts\",\n    \"../app/server/declarations/**/*.d.ts\",\n    \"../stubs/app/server/declarations.d.ts\",\n    \"../stubs/app/server/declarations/**/*.d.ts\"\n  ],\n  \"files\": [\n    \"chai-as-promised.js\"\n  ],\n  \"references\": [\n    { \"path\": \"../app\" },\n    { \"path\": \"../stubs/app\" }\n  ]\n}\n"
  },
  {
    "path": "test/upgradeDocument",
    "content": "#!/usr/bin/env node\n\nrequire('./setupPaths');\nrequire('test/upgradeDocumentImpl').main().catch(e => console.error(String(e)));\n"
  },
  {
    "path": "test/upgradeDocumentImpl.ts",
    "content": "/**\n * Upgrade one or more documents (both the DocStorage and schema migrations).\n *\n * Usage:\n *    test/upgradeDocument <docPaths...>\n */\n\nimport { DocStorage } from \"app/server/lib/DocStorage\";\nimport { DocStorageManager } from \"app/server/lib/DocStorageManager\";\nimport { copyFile } from \"app/server/lib/docUtils\";\nimport log from \"app/server/lib/log\";\nimport { createDocTools } from \"test/server/docTools\";\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\n\nimport * as fse from \"fs-extra\";\nimport * as tmp from \"tmp-promise\";\n\nexport async function upgradeDocuments(docPaths: string[]): Promise<void> {\n  const docTools = createDocTools();\n  await docTools.before();\n  try {\n    for (const docPath of docPaths) {\n      console.log(`Upgrading ${docPath}`);\n      const activeDoc = await docTools.loadLocalDoc(docPath);\n      await activeDoc.waitForInitialization();\n      await activeDoc.shutdown();\n      await copyFile(docTools.getStorageManager().getPath(activeDoc.docName), docPath);\n    }\n  } finally {\n    await docTools.after();\n  }\n}\n\nexport async function upgradeDocumentsDocStorageOnly(paths: string[]): Promise<void> {\n  let tmpDir = (await tmp.dir({ prefix: \"grist_migrate_\", unsafeCleanup: true })).path;\n  tmpDir = await fse.realpath(tmpDir);\n  const docStorageManager = new DocStorageManager(tmpDir);\n\n  for (const docPath of paths) {\n    console.log(`Upgrading '${docPath}' (DocStorage migrations only)`);\n    const docName = path.basename(docPath);\n    const tempPath = docStorageManager.getPath(docName);\n    fs.copyFileSync(docPath, tempPath);\n\n    const docStorage = new DocStorage(docStorageManager, docName);\n    await docStorage.openFile();\n    await docStorage.shutdown();\n\n    fs.copyFileSync(tempPath, docPath);\n  }\n}\n\nexport async function main() {\n  const params = process.argv.slice(2);\n  const onlyRunDocStorageMigrations = params.map(text => text.toLowerCase()).includes(\"--doc-storage-only\");\n  const docPaths = params.filter(text => !text.trim().startsWith(\"-\"));\n  if (docPaths.length === 0) {\n    console.log(`Usage:\\n    test/upgradeDocument path/to/doc.grist ...\\n`);\n    console.log(`Parameters: `);\n    console.log(`  --doc-storage-only - Only runs DocStorage migrations`);\n    throw new Error(\"Document argument required\");\n  }\n  for (const docPath of docPaths) {\n    if (!docPath.endsWith(\".grist\")) {\n      throw new Error(`Document path should have .grist extension: ${docPath}`);\n    }\n    if (!fs.existsSync(docPath)) {\n      throw new Error(`Document path doesn't exist: ${docPath}`);\n    }\n  }\n\n  const prevLogLevel = log.transports.file.level;\n  log.transports.file.level = \"warn\";\n  try {\n    if (onlyRunDocStorageMigrations) {\n      await upgradeDocumentsDocStorageOnly(docPaths);\n    } else {\n      await upgradeDocuments(docPaths);\n    }\n  } finally {\n    log.transports.file.level = prevLogLevel;\n  }\n}\n"
  },
  {
    "path": "test/utils.js",
    "content": "/* global location */\n\nvar _ = require(\"underscore\");\nvar Chance = require(\"chance\");\nvar assert = require(\"chai\").assert;\n\nfunction mod(r) { return function(x) { return x%r; }; }\nexports.mod = mod;\n\n/**\n * Runs the given function for the specified number of iterations and returns the total time taken.\n * This function has no side effects.\n * @param {Function} func - function to apply\n * @param {object} context - this\n * @param {Array} args - array of arguments to apply on the function\n * @param {Integer} options.iters - number of iterations to apply the given function\n * @param {Boolean} options.avg - if true, return the avg iteration time, else return the total time\n */\nfunction time(func, context, args, options) {\n  console.assert(options.iters > 0, \"Number of iterations must be greater than 0\");\n  var start, copy;\n  var elapsed = 0;\n  // Apply the function on a copy of the context on each iteration to avoid side effects\n  for (var i = 0; i < options.iters; i++) {\n    copy = _.clone(context);\n    start = Date.now();\n    func.apply(copy, args);\n    elapsed += Date.now() - start;\n  }\n\n  if (options.avg) return elapsed/options.iters;\n  else return elapsed;\n}\nexports.time = time;\n\n\n/**\n * Repeats running the given function on the given arguments count times, returning the last\n * result.\n */\nfunction repeat(count, func, varArgs) {\n  var ret, args = Array.prototype.slice.call(arguments, 2);\n  for (var i = 0; i < count; i++) {\n    ret = func.apply(null, args);\n  }\n  return ret;\n}\nexports.repeat = repeat;\n\n\n/**\n * Defines a test suite for running timing tests. See documentation for exports.timing.\n */\nfunction timingDescribe(desc, func) {\n  // If under Node, non-empty ENABLE_TIMING_TESTS environment variable turns on the timing tests.\n  // If under the Browser, we look for 'timing=1' among URL params, set by test/browser.js.\n  var enableTimingTests = (process.browser ?\n    (location.search.substr(1).split(\"&\").indexOf(\"timing=1\") !== -1) :\n    process.env.ENABLE_TIMING_TESTS);\n\n  function body() {\n    func();\n\n    // We collect the tests, then check if any of them exceeded the expected timing. We do it in\n    // one pass in after() (rather than in afterEach()) to allow them all to run, since it's\n    // useful to see all their timings.\n    var testsToCheck = [];\n    afterEach(function() {\n      testsToCheck.push(this.currentTest);\n    });\n    after(function() {\n      testsToCheck.forEach(function(test) {\n        if (test.expectedDuration) {\n          assert.isBelow(test.duration, test.expectedDuration * 1.5, \"Test took longer than expected\");\n        }\n      });\n    });\n  }\n\n  if (enableTimingTests) {\n    return describe(desc, body);\n  } else {\n    return describe.skip(desc + \" (skipping timing test)\", body);\n  }\n}\n\n/**\n * Defines a test case for a timing test. This should be used in place of it() for timing test\n * cases created inside utils.timing.describe(). See documentation for exports.timing.\n */\nfunction timingTest(expectedMs, desc, testFunc) {\n  var test = it(desc + \" (exp ~\" + expectedMs + \"ms)\", testFunc);\n  test.slow(expectedMs * 1.5);\n  test.timeout(expectedMs * 5 + 2000);\n  test.expectedDuration = expectedMs;\n}\n\n/**\n * To write timing tests, the following pattern is recommended:\n *\n * (1) Use utils.timing.describe() in place of describe().\n * (2) Use utils.timing.it() in place of it(). It takes an extra first parameter with the number\n *     of expected milliseconds. The test will fail if it takes more than 1.5x longer.\n * (3) Place only the code to be timed in utils.timing.it(), and do all setup in before() and all\n *     non-trivial post-test assertions in after().\n *\n * These tests only run when ENABLE_TIMING_TESTS environment variable is non-empty. It enables\n * timing tests both under Node and running in the browser under Selenium. To enable timing tests\n * in the browser when running /test.html manually, go to /test.html?timing=1.\n */\nexports.timing = {\n  describe: timingDescribe,\n  it: timingTest\n};\n\n\n// Dummy object used for tests\nfunction TestPerson(last, first, age, year, month, day) {\n  this.last = last;\n  this.first = first;\n  this.age = age;\n  this.year = year;\n  this.month = month;\n  this.day = day;\n}\n\n/**\n * Returns a list of randomly generated TestPersons.\n * @param {integer} num - length of people list to return\n */\nfunction genPeople(num, seed) {\n  if (typeof seed === \"undefined\") seed = 0;\n  var ageOpts = {min: 0, max: 90};\n  var monthOpts = {min:1, max:12};\n  var dayOpts = {min:1, max:30};\n  var people = [];\n  var chance = new Chance(seed);\n  for (var i = 0; i < num; i++) {\n    people.push(new TestPerson(chance.last(),\n      chance.first(),\n      chance.integer(ageOpts),\n      parseInt(chance.year()),\n      chance.integer(monthOpts),\n      chance.integer(dayOpts)\n    ));\n  }\n  return people;\n}\nexports.genPeople = genPeople;\n\n/**\n * Generates a list of items denoted by the given chanceFunc string.\n * Ex : genItems('integers', 10, {min:0, max:20}) generates a list of 10 integers between 0 and 20\n *    : genItems('string', 10, {length: 6}) generates a list of 10 strings of length 6\n * @param {string} chanceFunc - string name of a chance.js function\n * @param {integer} num - length of item list to return\n * @param {object} options - object denoting options for the given chance.js function\n */\nfunction genItems(chanceFunc, num, options, seed) {\n  if (typeof seed === \"undefined\") seed = 0;\n  console.assert(typeof new Chance()[chanceFunc] === \"function\");\n  var chance = new Chance(seed);\n  var items = [];\n  for (var i = 0; i < num; i++) {\n    items.push(chance[chanceFunc](options));\n  }\n  return items;\n}\nexports.genItems = genItems;\n"
  },
  {
    "path": "test/xunit-file.js",
    "content": "// Based on https://github.com/peerigon/xunit-file, with changes that are impossible to\n// monkey-patch. Also refactored, but not converted to typescript, to avoid slowing down mocha\n// runs with ts-node.\n//\n// It also produces a file timings.txt with timings, made of lines of the form:\n//    <TEST_SUITE> <top-level-describe-suite> <number-of-milliseconds>\n//\n// Respects the following environment variables:\n//    XUNIT_FILE: path of output XML file (default: xunit.xml)\n//    XUNIT_SILENT: suppress human-friendly logging to the console\n//    XUNIT_SUITE_NAME: name to use for the top-level <testsuite> (default: \"Mocha Tests\")\n//    XUNIT_CLASS_PREFIX: prefix to use for <testcase classname=...> attribute (default: \"\")\n//    TEST_SUITE: name of the test suite to prefix timings with.\n\n\n\nconst fse = require(\"fs-extra\");\nconst {reporters, utils} = require(\"mocha\");\nconst path = require(\"path\");\nconst escape = utils.escape;\n\nconst filePath = process.env.XUNIT_FILE || \"xunit.xml\";\nconst consoleOutput = !process.env.XUNIT_SILENT;\nconst suiteName = process.env.XUNIT_SUITE_NAME || \"Mocha Tests\";\nconst classPrefix = process.env.XUNIT_CLASS_PREFIX || \"\";\nconst timingsPath = path.join(path.dirname(filePath), \"timings.txt\");\nconst testSuite = process.env.TEST_SUITE || \"unset_suite\";\n\n/**\n * Save reference to avoid Sinon interfering (see GH-237).\n */\nconst MDate = global.Date;\n\n// Special marker for tag() to produce an unclosed opening XML tag.\nconst UNCLOSED = Symbol(\"UNCLOSED\");\n\nfunction logToConsole(msg) {\n  if (consoleOutput) { console.log(msg); }\n}\n\nconst failureNumbers = new Map();   // Maps test object to failure number.\n\n/**\n * Initialize a new `XUnitFile` reporter.\n */\nclass XUnitFile extends reporters.Base {\n  constructor(runner) {\n    super(runner);\n    const stats = this.stats;\n    const tests = [];\n    fse.mkdirpSync(path.dirname(filePath));\n    const fd = fse.openSync(filePath, \"w\", 0o0644);\n    const timingsFd = fse.openSync(timingsPath, \"w\", 0o0644);\n    const startedSuites = new Map();\n    let ending = false;\n\n    // We have to be a little clever about closing the timings descriptor because the 'end' event\n    // may occur *before* the last 'suite end' event.\n    function maybeCloseTimings() {\n      if (ending && startedSuites.size === 0) {\n        fse.closeSync(timingsFd);\n      }\n    }\n\n    runner.on(\"suite\", (suite) => {\n      logToConsole(suite.fullTitle());\n      startedSuites.set(suite, Date.now());\n    });\n\n    runner.on(\"suite end\", (suite) => {\n      // Every time a (top-level) suite ends, add a line to the timings file.\n      if (suite.titlePath?.()?.length == 1) {\n        const duration = Date.now() - startedSuites.get(suite);\n        appendLine(timingsFd, `${testSuite} ${suite.fullTitle()} ${duration}`);\n        startedSuites.delete(suite);\n        // If 'end' has already happened, close the file.\n        maybeCloseTimings();\n      }\n    });\n\n    runner.on(\"pass\", (test) => {\n      logToConsole(`  ${reporters.Base.symbols.ok} ${test.fullTitle()}`);\n      tests.push(test);\n    });\n\n    runner.on(\"fail\", (test) => {\n      failureNumbers.set(test, failureNumbers.size + 1);\n      logToConsole(`  (${failureNumbers.get(test)}) ${test.fullTitle()}`);\n      logToConsole(`      ERROR: ${test.err}`);\n      tests.push(test);\n    });\n\n    runner.on(\"pending\", (test) => {\n      logToConsole(`  - ${test.fullTitle()}`);\n      tests.push(test);\n    });\n\n    runner.once(\"end\", () => {\n      const timestampStr = new MDate().toISOString().split(\".\", 1)[0];\n      appendLine(fd, tag(\"testsuite\", {\n        name: suiteName,\n        tests: stats.tests,\n        failures: stats.failures,\n        errors: stats.failures,\n        skipped: stats.tests - stats.failures - stats.passes,\n        timestamp: timestampStr,\n        time: (stats.duration || 0) / 1000\n      }, UNCLOSED));\n\n      logToConsole(\"\");\n      for (const test of tests) {\n        writeTest(fd, test);\n      }\n\n      appendLine(fd, \"</testsuite>\");\n      fse.closeSync(fd);\n      ending = true;\n      maybeCloseTimings();\n    });\n  }\n}\n\n/**\n * Output tag for the given `test.`\n */\nfunction writeTest(fd, test) {\n  const classname = classPrefix + test.parent.fullTitle();\n  const name = test.title;\n  const time = (test.duration || 0) / 1000;\n  if (test.state === \"failed\") {\n    const err = test.err;\n    appendLine(fd,\n      tag(\"testcase\", {classname, name, time},\n        tag(\"failure\", {message: err.message}, cdata(err.stack))));\n    logToConsole(`***\\n(${failureNumbers.get(test)}) ${test.fullTitle()}`);\n    logToConsole(err.stack + \"\\n\");\n  } else if (test.pending) {\n    appendLine(fd, tag(\"testcase\", {classname, name}, tag(\"skipped\", {})));\n  } else {\n    appendLine(fd, tag(\"testcase\", {classname, name, time}) );\n  }\n}\n\n/**\n * HTML tag helper.\n * content may be undefined, a string, or the symbol UNCLOSED to produce just an opening tag.\n */\nfunction tag(name, attrs, content) {\n  const attrStr = Object.keys(attrs).map((key) => ` ${key}=\"${escape(String(attrs[key]))}\"`).join(\"\");\n  return (\n    content === undefined ? `<${name}${attrStr}/>` :\n      content === UNCLOSED ? `<${name}${attrStr}>` :\n        `<${name}${attrStr}>${content}</${name}>`\n  );\n}\n\n/**\n * Return cdata escaped CDATA `str`.\n */\nfunction cdata(str) {\n  return \"<![CDATA[\" + escape(str) + \"]]>\";\n}\n\nfunction appendLine(fd, line) {\n  fse.writeSync(fd, line + \"\\n\", null, \"utf8\");\n}\n\nmodule.exports = XUnitFile;\n"
  },
  {
    "path": "tsconfig-ext.json",
    "content": "{\n  \"extends\": \"./buildtools/tsconfig-base-ext.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    { \"path\": \"./ext/app\" }\n  ],\n}\n"
  },
  {
    "path": "tsconfig-prod.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  // ./test reference from tsconfig.json is removed\n  \"references\": [\n    { \"path\": \"./app\" },\n    { \"path\": \"./stubs/app\" },\n  ],\n}\n"
  },
  {
    "path": "tsconfig.eslint.json",
    "content": "{\n  \"extends\": \"./buildtools/tsconfig-base.json\",\n  \"include\": [\n    \"app/**/*.js\",\n    \"app/**/*.ts\",\n    \"stubs/**/*.ts\",\n    \"stubs/**/*.ts\",\n    \"test/**/*.js\",\n    \"test/**/*.ts\",\n    \"plugins/**/*.js\",\n    \"plugins/**/*.ts\",\n    \"buildtools/**/*.js\",\n    \"buildtools/**/*.ts\",\n  ]\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"extends\": \"./buildtools/tsconfig-base.json\",\n  \"files\": [],\n  \"include\": [],\n  \"references\": [\n    { \"path\": \"./app\" },\n    { \"path\": \"./stubs/app\" },\n    { \"path\": \"./test\" },\n  ],\n  \"typedocOptions\": {\n    \"entryPoints\": [\n      \"app/plugin/grist-plugin-api.ts\",\n      \"app/plugin/TableOperations.ts\",\n    ],\n    \"excludeInternal\": \"true\",\n    \"excludeNotDocumented\": \"true\",\n    \"out\": \"doc\"\n  }\n}\n"
  }
]